メリークリスマス!開発が楽しくて充実してますー!
Ca Tech Dojo/Challenge/JOB Advent Calender 2019の25日目は@yawn_yawn_yawn_が書かせていただきます!
@ta9starにめっちゃハードル上げられた気がしますがマイペースにいきます。
はじめに
やっと忙しさも緩和されて開発できるようになったので。楽しくなってきました。
個人開発でもゼミで始めたプロジェクトでも、個人認証が必須になりました。今のご時世必須機能ですね。
認証周りのことで、cookieにセッションIDとか乗っければいいかーと思っていたのですが、
「インターンでJWTという単語を聞いたな...そういえばまだ手が出せてなかったな...」
ということで、調べて実装!までやってみました。
ついでに改ざん検知をやって感動したので。
JWTとは
JWTについての知識は調べればそこらじゅうに記事があるので要約だけ。
JWT(JsonWebToken)とは、二者間で安全に情報を送信するための、RFC7519で定められた方式です。Jsonオブジェクトでデータを送りつけます。
発行されるトークンの構造は、header.payload.signature
という感じになります。
header
一般的に2つパラメータがあります。
パラメータ | 値 | 説明 |
---|---|---|
typ | JWT | JWTトークンであることを示す |
alg | HMAC,SHA256,... | 署名に使うアルゴリズムを指定 |
payload
予約されているクレームが割とたくさんあるので、3つだけ。サーバ側で独自にクレームを設定できます。
パラメータ | 値の例 | 説明 |
---|---|---|
iss | JohnDoe | 発行者の識別子 |
exp | 1300819380 | 有効期限 |
sub | 1234567890 | JWTに含まれるクレームのsubject |
最初、クレームってなによって思ったので補足。クレームはJsonオブジェクトのメンバだそうです。 |
signature
署名部分で、メッセージが改ざんされてないかを検証する時に使います。
エンコードしたheader、エンコードしたpayload、シークレットキー、headerに指定されたアルゴリズムから、署名が作成されます。
要約が長い...下手か...
さらに詳しくは JWT Introduction や OpenID Foundation Japan から。
実装
dgrijalva/jwt-goを使って実装していきます。
トークンをリクエストから簡単に読みだせるパッケージdgrijalva/jwt-go/requestもありますが、今回は使ってません。コメントアウトされた場所に実装してた気がします...たしか...。
今回のコードはgithubにあります。
アーキテクチャを多少かじりましたが、そこまで厳密に考えて実装してないので、ものすごくごちゃごちゃです。
いい感じに崩すって難しい。
実装した機能は以下です。
- signup
- login
- 認証が必要な機能(who am i)
signupはまあ、アカウント登録です。
loginはまあ、ログインです。ここでJWTを発行します。
whoamiは、認証してからI am {user_name}
ってレスポンスしてくれる機能です。しょぼ。
JWTがリクエストに載ってこない場合、400を吐いてくれます。401ではない...ゆるして...
signup
signupについてはJWT全く関係ないので、どんな機能かだけ。
必要な情報を載せたリクエストを受けて、DBに登録する機能です。
login
loginの全体的な流れは以下です。
リクエストの読み出し -> ユーザ認証 -> JWT発行 -> レスポンスの作成
エラーハンドリングは省略します。
func Login(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// bodyの読み出し
body, err := ioutil.ReadAll(r.Body)
if err != nil {
...
}
var req LoginRequest
err = json.Unmarshal(body, &req)
if err != nil {
...
}
// ユーザ認証
var hash string
row := db.QueryRow("SELECT password FROM users WHERE id=?", req.ID)
if err = row.Scan(&hash); err != nil {
...
}
err = server.PasswordVerify(hash, req.Password)
if err != nil {
...
}
log.Println("login success: userid = ", req.ID)
// tokenの発行
token, err := server.CreateToken(req.ID)
if err != nil {
...
}
// response
server.Success(w, &LoginResponse{
Token: token,
})
}
}
type LoginRequest struct {
ID string `json:"id"`
Password string `json:"password"`
}
type LoginResponse struct {
Token string `json:"token"`
}
CreateToken()
にてJWTの発行を行なっています。CreateToken()
は以下。
func CreateToken(userID string) (string, error) {
// tokenの作成
token := jwt.New(jwt.GetSigningMethod("HS256"))
// claimsの設定
token.Claims = jwt.MapClaims{
"user": userID,
"exp": time.Now().Add(time.Hour * 1).Unix(), // 有効期限を指定
}
// 署名
var secretKey = "secret" // 任意の文字列
tokenString, err := token.SignedString([]byte(secretKey))
if err != nil {
return "", err
}
return tokenString, nil
}
トークン作成 -> クレームの設定 -> 署名 して発行完了です!
who am i
whoamiの全体的な流れは以下です。
リクエストの読み出し -> JWTの検証 -> レスポンスの作成
エラーハンドリングは省略します...。
func WhoAmI(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// headerから読み出し
tokenString := r.Header.Get("Authorization")
tokenString = strings.TrimPrefix(tokenString, "Bearer ")
// tokenの認証
token, err := server.VerifyToken(tokenString)
if err != nil {
...
}
// ペイロードの読み出し
claims := token.Claims.(jwt.MapClaims)
server.Success(w, &whoAmIResponse{
Message: fmt.Sprintf("I am %s", claims["user"]),
})
}
}
type whoAmIResponse struct {
Message string `json:"message"`
}
server.VerifyToken()
にてトークンの検証を行なっています。VerifyToken()
は以下。
func VerifyToken(tokenString string) (*jwt.Token, error) {
// jwtの検証
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
return []byte("secret"), nil // CreateTokenにて指定した文字列を使います
})
if err != nil {
return token, err
}
return token, nil
}
検証
ちょっとだけ中身拝見、ということで、以下のことを行いました。
- 作成されたJWTのデコード
- 期限切れのトークンを使ったらどうなるか
- JWTの改竄検知
作成されたJWTのデコード
jwt.ioにてエンコード/デコードが可能です。
作成されたトークンの中身を拝見します。
$ curl -H 'Content-Type:application/json' -d '{"id":"yawn-yawn-yawn", "password":"password"}' http://localhost:8080/login
{
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1NzY3MTUzNjIsInVzZXIiOiJ5YXduLXlhd24teWF3biJ9.DEwXA_Kg3QRfxH6NiwbT0or0NvqFRUAi49UPT9HwLlo"
}
このトークンをデコードするとこんな感じ
うんうん。
ちゃんと入ってそう。
認証できている状態で whoami するとこんな感じ。
$ curl -H 'Authorization:Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1NzY3MTUzNjIsInVzZXIiOiJ5YXduLXlhd24teWF3biJ9.DEwXA_Kg3QRfxH6NiwbT0or0NvqFRUAi49UPT9HwLlo' http://localhost:8080/whoami
{
"message":"I am yawn-yawn-yawn"
}
ちゃんと読み出せてますね。
余談。
JWTをAuthorizationヘッダに載せるときは、Bearerにすることが多いらしいです。
期限切れのトークンを使ったらどうなるか
CreateToken()
の中のexp
クレームで、そのJWTがいつまで有効かを設定しています。
期限切れのJWTを使ってリクエストした結果
{
"code": 400,
"message": "Token is expired"
}
ちゃんと使えない!
改竄検知
client -> proxy -> server って感じでリクエストを流して、プロキシで中身をいじります。
Burp Suiteというローカルプロキシツールを使います。
今回やってみるのは、userクレームの改ざんです。
yawn-yawn-yawn
からyawn-yawn
に差し替えます。
- requestに使うJWT
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1NzY4MTQwMTgsInVzZXIiOiJ5YXduLXlhd24teWF3biJ9.mGn6fs7GPOa6b5g5I6wDaGC_qzSkSqOWmpR09Qt6CEU
- 改ざんしたJWT
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1NzY4MTQwMTgsInVzZXIiOiJ5YXduLXlhd24ifQ.3XM58H8JMFOIUuRO7LMTGde-zNXTQHgymHVfxuJlEP4
違いがわかりづらい。末尾の方とかちょこちょこ変わっていると思います。
それぞれのデコード結果はこんな感じ。
userクレームが差し替えられてます。
リクエストがBurpSuiteを経由した際に、トークンをちょこちょこーっと書き換えます。
こんなかんじ。
結果は...
{
"code": 400,
"message": "signature is invalid"
}
改ざん検知してくれましたー!
ただエンコード、デコードが割とサクッとできてしまうため、盗聴はできるし、ハイジャックもできる気が...
ここでSSL/TLSの登場ですね!
認証周りは闇が深い
おわりに
最初に書いてたネタがインフラ寄りだったのですが...(先日投稿した記事です...)
DojoでGoやったんだしGoしたい!と思ってギリギリに書き直していた所存です。
深夜のノリで、Dojoの仲間に、「クリスマスもやるしかないな」みたいなことを言われ、
「おっしゃクリスマスもやるかー!」とか返してしまったので、両方出しました。良い経験になりました...!
本当に最後に
Ca Tech Dojo/Challenge/JOB Advent Calender 2019参加者のみなさまお疲れ様でした!
読んでいただいたみなさま、本当にありがとうございます!
良いお年を!