概要
新規登録からログインまで解説してきて、いよいよ今回でJWT認証の実装を行なっていきます。
正直あまり参考になる記事がなく、自分なりにパッケージのドキュメントや海外の記事を参考にしました。
間違っている点もあるかもしれませんが、少しでも参考になればと思いアウトプットしていきます。
また、JWT認証についてよくわからない方は下記の記事で解説していますのでそちらを参照してください。
過去のシリーズ
環境
過去の記事にDockerの設定については記載しているのでそちらを参考にしてください。
なお、認証機能を構築するのにjwt-go
パッケージを利用していきます。
バージョンはv5.0.0-rc.1
を使用します。
JWT認証実装
今回は共通鍵認証を前提とします。
処理を記載する前にまずは、pkg
ディレクトリ配下にtoken.go
を作成してください。
作成したファイルに処理を記載していきます。
JWT生成
まずはJWT
を生成する関数を作成していきます。
これは新規登録時やログイン時にクライアントにレスポンスとしてトークンを返すために作成します。
package utils
import (
"os"
"strconv"
"time"
"github.com/golang-jwt/jwt/v5"
)
func GenerateToken(userId uint) (string, error) {
secretKey := os.Getenv("SECRET_KEY") // 暗号化、復号化するためのキー
tokenLifeTime, err := strconv.Atoi(os.Getenv("TOKEN_LIFETIME"))
if err != nil {
return "", err
}
claims := jwt.MapClaims{
"user_id": userId,
"exp": time.Now().Add(time.Hour * time.Duration(tokenLifeTime)).Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString([]byte(secretKey))
if err != nil {
return "", err
}
return tokenString, nil
}
claims
にはuser_id
を設定しました。
調べた中でPassword
やemail
をclaim
に保存している記事がありましたが、
個人的にはJWTに個人情報は含めるべきではないと考えているので避けました。
なぜ個人情報を含めるべきではないというと、
JWTはあくまでBase64Urlというアルゴリズムでエンコードしたにすぎません。
もしJWTが流失してしまった場合に、デコードされるとJWTの中身が分かってしまします。
なので個人情報は含めるべきではないと考え、今回はuser_id
を格納しました!
secretKey
は署名を暗号化・復号化するために必要です。
基本的に文字列ならなんでもいいですが、
私は秘密鍵などを生成する際によく使用されるopenssl
コマンドを利用して作成しました。
openssl rand -base64 32
これはBASE64形式のランダムなデータを32バイト長で生成してくれます。
おそらく上記のコマンドを実行したら下記のような文字列がターミナルに出力されたのではないでしょうか。
0N3mCELJP4RvrKyigmq/Q0uB+ak3TGDuBRSSRKQvv9s=
ここで出力されたを値を各自設定してください。
また、トークンの有効期限も設定します。
今回TOKEN_LIFETIME
という環境変数で設定していますが、各自好きなように設定してください。
トークンの有効期限のできるだけ短くしたほうが良いとされています。
なぜならトークンは一度発行されると削除することが困難なのため、数カ月単位などの長期期間は避けたほうがいいです。
新規登録処理を編集
前段ではトークンを生成する関数を作成しました。
今度は作成した関数をcontrollers
の新規登録ハンドラーの中で呼び出したいので以前の記事で作成した新規登録処理部分を編集していきます。
またクライアントとのトークンをやりとりはCookie
を利用します。
func (handler *Handler) SignUpHandler(ctx *gin.Context) {
var signUpInput models.SignUpInput
err = ctx.ShouldBind(&signUpInput)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"message": "Invalid request body",
})
return
}
user := &models.User{
Name: signUpInput.Name,
Email: signUpInput.Email,
Password: signUpInput.Password,
}
err = user.Validate()
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"message": err.Error(),
})
return
}
newUser, err := user.Create(handler.DB)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"message": "Failed to create user",
})
return
}
// 追加部分
token, err := utils.GenerateToken(newUser.ID)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"message": "Failed to sign up",
})
return
}
// Cookieにトークンをセット
ctx.SetCookie("token", token, cookieMaxAge, "/", "localhost", false, true)
ctx.JSON(http.StatusOK, gin.H{
"user_id": newUser.ID,
"message": "Successfully created user",
})
}
先ほど作成したGenerateToken
でトークンを生成し、
生成したトークンをSetCookie
でCookie
にセットしています。
SetCookie
メソッドについて各パラメータが何を指しているのか知りたい方は下記を参照してください。
Gin
のソースコードを見ても各パラメーターが何を指しているのかよくわからなかったので下記を参考にしました!
JWTを検証する関数を生成
次にリクエストで送られてきたJWTを検証する関数を作成していきます。
package utils
import (
"fmt"
"os"
"strconv"
"time"
"github.com/golang-jwt/jwt/v5"
)
func GenerateToken(userId uint) (string, error) {
secretKey := os.Getenv("SECRET_KEY")
tokenLifeTime, err := strconv.Atoi(os.Getenv("TOKEN_LIFETIME"))
if err != nil {
return "", err
}
claims := jwt.MapClaims{
"user_id": userId,
"exp": time.Now().Add(time.Hour * time.Duration(tokenLifeTime)).Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString([]byte(secretKey))
if err != nil {
return "", err
}
return tokenString, nil
}
// 下記追加部分
func ParseToken(tokenString string) (*jwt.Token, error) {
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"])
}
return []byte(os.Getenv("SECRET_KEY")), nil
})
if err != nil {
return nil, err
}
return token, nil
}
jwt.Parse
でトークンを検証し、不正なトークンの場合にエラーを返すようになっています。
ミドルウェアを作成
次にmiddleware
を作成していきます。
まず、pkg
ディレクトリ配下にmiddleware
ディレクトリを作成し、middleware.go
ファイルを作ります。
具体的な処理は下記になります。
package middleware
import (
"net/http"
"github.com/gin-gonic/gin"
"app/pkg/utils"
)
func AuthMiddleware(ctx *gin.Context) {
tokenString, err := ctx.Cookie("token")
if err != nil {
ctx.JSON(http.StatusUnauthorized, gin.H{
"message": "Unauthorized",
})
ctx.Abort()
return
}
token, err := utils.ParseToken(tokenString)
if err != nil {
ctx.JSON(http.StatusUnauthorized, gin.H{
"message": "Invalid token",
})
ctx.Abort()
return
}
ctx.Next()
}
処理の流れとしては下記です。
- リクエスト内のクッキーからJWTを取得
- 取得したJWTを検証する
- そして処理をHandlerに投げます
またトークンなどに不正があり認証できない場合は、後続のハンドラーに処理がいかないように
ctx.Abort()
で処理をストップさせなければなりません。
これがないと、後続のハンドラーの処理が続いてしまうので必須です。
ミドルウェアの設定
あとは認証が必要なエンドポイントごと設定すればmiddleware
を使用できます。
あくまで例ですが、
下記のようにmiddleware
を利用します。
package router
import (
"github.com/gin-gonic/gin"
"app/pkg/controllers"
"app/pkg/database"
"app/pkg/middleware"
)
func Run() {
router := setupRouter()
router.Run()
}
func setupRouter() *gin.Engine {
router := gin.Default()
handler := &controllers.Handler{
DB: database.GetDB(),
}
api := router.Group("/api")
v1 := api.Group("/v1")
users := v1.Group("/users")
users.Use(middleware.AuthMiddleware) // middlewareを設定
{
users.GET("", handler.GetUsers) // ユーザー一覧を取得
}
return router
}
users.Use(middleware.AuthMiddleware)
の部分でmiddlewareを設定しています。
上記設定であれば、users
配下のエンドポイントにアクセスする際はmiddleware
が作動します。
なので今後認証した場合のみエンドポイントへのアクセスを許可する場合は、上記のように追加していけばOKです。
まとめ
いかがだったでしょうか。
新規登録〜ログイン〜JWT認証の3回に渡って解説してきました。
正直まだまだ勉強中なので未熟な部分もあるかと思います。
その場合は遠慮なくコメントいただけるとありがたいです!
なお、今回はログの管理やトークンからユーザーIDを抽出するような処理は記載しませんでしたが、
本来はあったほうがいいと思います。
実装する際はその辺も考えながら実装するといいかもしれません。
最後まで読んでいただいてありがとうございました!
参考