Golang + Gin + JSON Web Token(JWT)
更新履歴
日付 | 更新内容 |
---|---|
2024/06/12 | アクセストークンの取り扱い方を修正しました。 |
はじめに
今更ながらですが、GoとJSON Web Tokenで認証認可周りを簡単に遊んでみました。
ついでに、今までEchoを多く触れていたのですが、Ginは触れていなかったのでこの機会にやってみようということでフレームワークはGinを採用しています。
Goの業務経験は半年ほどで、それ以外はプライベートで少し触るだけです。
なので私自身も完全に理解しているわけではありませんが、自身の勉強とナレッジを兼ねて記事にさせていただきます。
成果物
早速コードから。
- JSON Web Token(以下JWT)を利用してユーザーを認証し、アクセストークンを発行すること
- 発行したアクセストークンを利用して、API実行時に認可確認をすること
- REST-APIの構成に合わせて実装すること
詳しくはGitHubリポジトリ内の.docを参照してください。
比較的環境構築も楽にできるようにしているはずです。環境構築と実行方法についてはsetupを参照してください。
目次
- JSON Web Token(JWT)とは
- REST-APIの構成
- JWTとREST-APIの実装
JSON Web Token(JWT)とは
読み方なんですが、「ジョット」と読む人もいれば「ジェット」と読む人もいて正直どっちが正解かわからんです。個人的には「ジョット」のが読みやすいような?
簡単にJWTについて言うならば、「ユーザーを認証しトークンを発行させ、そのトークンを利用して認可を行うよ」ってイメージだと思っています。
細かいJWTについての解説は↓の方などが参考になりそうです。
JWT(JSON Web Token)って何に使うの?仕組みとその利便性
REST-APIの構成
先に、今回組んだREST-APIとしての構成をお話しさせていただきます。
基本は以下の方のリポジトリを参考にさせていただきました。コードを見ていて複雑でもなく、かなり簡易的な実装をするうえで分かりやすかったです。
詳細なディレクトリ構成についてはGitHubのリポジトリから参照してください。
主要2点だけ簡単に説明します。雑に概要だけなので、飛ばしてもらって構いません。
common
主に各パッケージで利用する共通処理を記載しています。
connect
DB接続情報を管理しています。今回はenvではなくapp.iniを利用する形です。
response
レスポンスの値をcontroller内に毎回書くのではなく、responseで定めた値を利用するようにしました。
今回は成功時にStatusCodeとResult、失敗時にStatusCodeと失敗したメッセージを返す形を想定しています。
AbortWithStatusをdeferすることで、そのレスポンスを返した後の処理には入らないようになるっぽい?ですね。
routes
routes内で各APIに対するアクセスを提供しています。
書籍のマスタ管理を行う動きを想定してREST-APIを作成しました。といっても、今回はJWTがメインなので、提供しているAPIは書籍の全件検索と書籍の1件登録だけですが...
auth
authでログインと検証、ユーザー情報のあれこれを行っています。
実際にログインする際のトークン発行などは後述します。
book
こちらが書籍マスタ管理のAPIを提供しています。
後述するJWT実装についての解説で詳しくお話ししますが、bookパッケージ内のみアクセストークンの検証を行っています。
JWTとREST-APIの実装
前置きが長くなりましたが、ここからが本題です。
まず、最初のJWTの説明でも記載しましたが、JWTについての個人的な理解として「ユーザーを認証しトークンを発行させ、そのトークンを利用して認可を行うよ」だと思っています。
なので、ユーザーの認証と認証後の認可の2つに分けます。
ちなみに認証と認可の違いについてもしわからない方は、以下を参考にしてみてください。
意外と知らない「認証」と「認可(承認)」の違い
今回ユーザー情報については以下のusersテーブルで管理しています。
物理名 | 主 | 型 | 必須 | 桁数 | 一意 | hash |
---|---|---|---|---|---|---|
id | 〇 | int | 〇 | 〇 | ||
user_id | varchar | 〇 | 50 | 〇 | ||
name | varchar | 50 | 〇 | |||
password | varchar | 〇 | 500 | 〇 | ||
created_at | datetime | |||||
updated_at | datetime | |||||
deleted_at | datetime |
認証
認証する際は今回Basic認証を利用します。おそらく、上手いことやればMFAやFIDO、WebAuthnなんかとも組み合わせられるんじゃないかなと思いますが、今回はシンプルにIDとパスワードだけでいきましょう。
コードを交えながら解説します。また、GitHubリポジトリ内、.docについても参照してみてください。
以下の流れでパスワードを検証します。
- ログイン時のパラメタに入力されたIDでハッシュ済みのパスワードを取得
users := rep.getPasswordById(p.UserId)
- ログイン時のパラメタに入力されたパスワードをハッシュ化
// authsパッケージ配下、util.goでsha-256ハッシュを行う関数が定義されている
if users.Password == "" || hashPW(p.Password) != users.Password {
response.Res(c, http.StatusUnauthorized, errors.New("unauthorized UserID or Password"))
return
}
- クレームの生成
claims := &ClaimsRecord{
users.UserId,
users.Password,
jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 6)),
},
}
クレームは特性上デコードして値の取得が可能であるため、「パスワードを含めるべきではない」との意見もありますが、ハッシュ化していることと、後述のJWT_SECRET_KEYが分からないと判定できないため、私は含めています。
また、今回はやってませんが、テーブル定義に権限(Authority)などを持たせて、クレームに指定してあげれば、権限ごとのAPI認可なども可能な気がします。
- NewWithClaimsによるトークンの生成
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
今回はHS256を利用していますが、やはりHS384、HS512のが暗号ロジック的には強度高いのでしょうかね?
- JWT_SECRET_KEYによるアクセストークンの生成
// app.iniから呼び出し
secret, err := config.Cfg.Section("jwt").GetKey("JWT_SECRET_KEY")
if err != nil {
log.Printf("Login error : %s", err)
response.Res(c, http.StatusInternalServerError, err)
return
}
t, err := token.SignedString([]byte(secret.String()))
if err != nil {
log.Printf("Login error : %s", err)
response.Res(c, http.StatusInternalServerError, err)
return
}
- クライアントにアクセストークンを返す
r := map[string]string{"AccessToken": t}
response.Res(c, http.StatusOK, r)
以上がJWTにおけるシンプルな認証の実装になります。
蓋を開けてみると、意外とシンプルなんですよね。詳細な中身まで突き詰めてみるともっと奥深い気はしますが、多くの実装例を見ても、このロジックが多いと思います。
認可
続いての認可は認証が行えた上での話になります。認証で取得したアクセストークンを利用します。
「クレームの生成」でも記載しましたが、権限については今回テーブル定義に含めていません。テーブル定義に含めてクレームに指定してあげることで、権限ごとの認可も可能だと思います。
また、当たり前ですが認可をするためにはアクセストークンが必要なので、loginについて認可は行いません。
同様に、ユーザーの登録についてもシステムによっては管理者が登録するものもありますが、今回はユーザー自身で登録することを想定しているため、ユーザー登録についても認可は行いません。
今回は書籍管理のAPIだけ認可を行います。認可時にmiddlewareとしてVerifyAccessTokenを実行します。
routers/api/books/routes.go
// execution middleware
{
setUp()
r.Use(auths.VerifyAccessToken)
}
書籍管理以外に認可したいドメインが増えた場合は、同様にそのドメインに対するroutesのmiddlewareとしてr.Use(auths.VerifyAccessToken)
を実行してあげることで認可が行えます。
- リクエストヘッダーのAuthorizationからトークンを取得
token := c.GetHeader("Authorization")
リクエストヘッダーのAuthorizationに認証で取得したアクセストークンを指定する必要があります。
Postmanや、今回Webについては考慮していませんが、Ajaxなどを利用する際には注意してください。
- トークンのデコード
t, err := jwt.Parse(token, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %s", t.Header["alg"])
}
secret, err := config.Cfg.Section("jwt").GetKey("JWT_SECRET_KEY")
if err != nil {
return nil, nil
}
return []byte(secret.String()), nil
})
jwt.Parseでトークンを解析して、解析されたトークンを返します。
ここで、認証の時に利用したJWT_SECRET_KEYを利用して署名の検証を行うことで、認可を行うようです。
問題がなければjwt.Token形式でトークンが返されます。
- クレームからuserIdを取得
userId := t.Claims.(jwt.MapClaims)["user_id"]
この点が認証でも説明させていただいた、「クレームの特性上、デコードして値の取得が可能である」という点になりますね。
先に取得したトークンが持つクレームから、クレーム生成時に指定した値を取得可能です。
なので例えば、
password := t.Claims.(jwt.MapClaims)["password"]
でパスワードの取得も可能ということです。ですが、ここでpasswordに入る値はハッシュ化された値になるはずです。PassTheHash攻撃などが可能であれば問題ですが、今回の認証の仕様上、PassTheHash攻撃時にハッシュ値をさらにハッシュ化されてしまうので、認証されません。
- アクセストークンの検証
ut := rep.getAccessTokenById(userId.(string))
if !t.Valid || ut.AccessToken != hashToken(t.Raw) {
err = errors.New("unauthorized accessToken")
log.Print(err)
response.Res(c, http.StatusUnauthorized, err)
return
}
パスワードの検証とほぼ同じです。
Authorizationから渡されてきたアクセストークンをハッシュ化し、usersテーブルに格納されているハッシュ済みアクセストークンと検証する流れですね。
以上が認可の流れになります。ここまで通って初めてAPIを実行するようになります。
おわりに
ざっとシンプルな認証の流れを知るという意味ではかなり勉強になったかなあと思いました。
なによりGoの実務経験はあまりないのですが、やはりこの言語楽しいです。
また、かなり自己流が入っているので、Goをプロフェッショナルの方や、セキュアコーディング面をしっかりとやっている方からしたら雑な印象受けるかと思いますがご容赦ください。
できるかぎりEffectiveGoなどに従ってみることを意識していますが、まだまだですね。
ここまでお読みいただきありがとうございました。