1. はじめに
Goを使ったバックエンド開発において、JWT認証機能を実装したので忘備録も兼ねて整理てしておこうと思います。
echo-jwtを使った基本的な実装をどのようにレイヤードアーキテクチャに取り込むかということに重点を置いて説明をしますので、JWT認証とは何か、レイヤードアーキテクチャとは何かという部分までは説明しませんのでご容赦ください。
目次
2. JWT認証の実装
APIフレームワークはecho、JWT認証にはecho-jwtを使っています。
アクセストークンを作る際には秘密鍵となる暗号鍵が必要です。echo-jwtを使った認証コードの記述に先立って、暗号鍵となる文字列を作成しましょう。
opensslコマンド、openssl rand -base64 32
で文字列を作成してenvファイルに値を保管しましょう。例えば以下の通りになると思います。
JWT_SECRET=LQ9M+Cv9n8bYL+aB9DyT22VL9yPMVfXbgh/DuVjcJ3o=
次に、JWT認証の実装に移っていきます。
今回はpkgフォルダを作り、そこにjwt認証を実装するコードをまとめました。
全体のコードをお示しします。
package pkg
import (
"fmt"
"time"
jwt "github.com/golang-jwt/jwt/v5"
echojwt "github.com/labstack/echo-jwt/v4"
"github.com/labstack/echo/v4"
)
type jwtCustomClaims struct {
Email string
UserId int
jwt.RegisteredClaims
}
type UserInToken struct{
UserId int
Email string
}
func GetJWTToken(email string, userId int) (string, error) {
claims := &jwtCustomClaims{
email,
userId,
jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24)),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
JWTSecret := os.Getenv("JWT_SECRET")
t, err := token.SignedString([]byte(JWTSecret))
if err != nil{
return "", fmt.Errorf("failed to create access token: %w", err)
}
return t, nil
}
func ApplyJWTMiddleware() echo.MiddlewareFunc {
JWTSecret := os.Getenv("JWT_SECRET")
jwtMiddleware := echojwt.WithConfig(echojwt.Config{
SigningKey: []byte(JWTSecret),
NewClaimsFunc: func(c echo.Context) jwt.Claims {
return new(jwtCustomClaims)
},
})
return jwtMiddleware
}
func UserInfoViaToken(e echo.Context) (*UserInToken, error){
user, ok := e.Get("user").(*jwt.Token)
if !ok {
return nil, fmt.Errorf("failed to retrieve user token")
}
claims, ok := user.Claims.(*jwtCustomClaims)
if !ok {
return nil, fmt.Errorf("invalid token claims")
}
uToken := &UserInToken{
claims.UserId,
claims.Email,
}
return uToken, nil
}
このファイルには三つのメソッドが含まれており、上から順に以下の通りです。
1. (正常にサインインされた場合は)emailとuserIdをトークンデータに含める形でアクセストークンを出力するGetJWTTokenメソッド
2. JWT認証を適用し、パスを保護するためのインスタンスを返すApplyJWTMiddlewareメソッド
3. JWT認証が適用され保護されたパスにアクセスされたときはアクセストークンがあることになるので、アクセストークンからユーザーデータを抽出するUserInfoViaTokenメソッド
もう少し詳しく見てみましょう。
1. GETJWTToken
GetJWTTokenメソッドではjwtCustomClaims構造体をインスタンス化したものをclaims変数に代入しています。このとき、jwtCustomClaimsのプロパティは任意です。ここではemialとuserId、そしてjwt.RegisteredClaimsを指定していますが、例えばuserIdの代わりにadminなどのようにロールを含めさせることもできます。
type jwtCustomClaims struct {
Email string
UserId int
jwt.RegisteredClaims
}
アクセストークンを作る、暗号化する際のメソッドをNewWithClaims
によって指定し、暗号鍵をSignedString
メソッドの引数に渡せば、アクセストークンの完成です。
2. ApplyJWTMiddlewareメソッド
これは、パスを保護するために用いられます。
echo.Echo.Getメソッドの第三引数においてミドルウェアの関数を受け取ることができるので、そこにこのjwtMiddlewareを渡すことを想定しています。
jwtMiddleware := echojwt.WithConfig(echojwt.Config{
SigningKey: []byte(JWTSecret),
NewClaimsFunc: func(c echo.Context) jwt.Claims {
return new(jwtCustomClaims)
},
})
3. UserInfoViaTokenメソッド
ここでは、Authorizationヘッダーからアクセストークンを取得し、さらにカスタムクレームを取得することで、トークンに含まれていたuserIdやemailなどのデータを取り出しています。
なお、Authorizationヘッダーからトークンを取り出すためにはecho.Contextインスタンスが必要になります。
user, ok := e.Get("user").(*jwt.Token)
user
とは?
これは、middlewareが値をecho.Contextに保存する際使われるデフォルトのキーのようです。ApplyJWTMiddlewareメソッドでechojwt.Configをインスタンス化する際にコンテキストキーを設定することでuser
から変更することもできます。
func ApplyJWTMiddleware() echo.MiddlewareFunc {
// ... abbreviation
jwtMiddleware := echojwt.WithConfig(echojwt.Config{
SigningKey: []byte(JWTSecret),
NewClaimsFunc: func(c echo.Context) jwt.Claims {
return new(jwtCustomClaims)
},
+ ContextKey: "Wow_Found",
})
return jwtMiddleware
}
func UserInfoViaToken(e echo.Context) (*UserInToken, error){
+ user, ok := e.Get("Wow_Found").(*jwt.Token)
if !ok {
return nil, fmt.Errorf("failed to retrieve user token")
}
// ... abbreviation
}
参考:
以上でJWT認証機能が一通り実装終えました。
3. レイヤードアーキテクチャへの組み込み
私の場合、レイヤードアーキテクチャとドメイン駆動設計(DDD: Domain-Driven Design)を組み合わせています。
レイヤードアーキテクチャはソフトウェアシステムを複数の層に分けて構造化する方法です。各層はそれぞれ明確な役割を持ち、依存関係を一方通行になるようにします。これにより、システムの複雑さを軽減し、各部分の独立性を高めることができます。さらに、依存性逆転の法則を用いてドメイン駆動設計と組み合わせることで、ドメイン層が中心となるレイヤー構造を組み上げています。
模式的には、interface -> usecase -> domain <- infrastructure という形になっています。
JWT認証を適用する、即ちjwt.goファイルで定義した諸々のメソッドを使うのはinterface層ですが、注意したいのはJWT認証は全てのパスに適用したいわけではないということです。
アクセストークンを発行するのはサインインを担当するルーティングなわけですから、そこでJWT認証を適用してしまうと、そもそもサインイン機能が使えずに破綻してしまいます。
また、例えばパスワードを忘れてしまったためJWT認証なしでパスワードを変更したいときもあると思います。その場合は、例えばメールアドレスやSMSを送ってユーザーの認証を行い、アクセストークンなしでパスワードリセット機能をユーザーに提供することになるでしょう。
このため、jwtのmiddlewareを echo.Echo.<HTTPMETHOD>
の第三引数に渡したり渡さなかったりしなければならないので、e.Useのように全体に一様に適用する手法は取れません。
詳しく見ていきましょう。
以下のファイルは全てinterfaces層でのお話になります。
なお、ルーティングの例としては、認証に関わるuserをお示しします。
controllerファイル
package interfaces
import (
"github.com/labstack/echo/v4"
)
type Controllers struct {
userController *UserController
signUpController *SignUpController
}
func NewControllers(
userController *UserController,
signUpController *SignUpController,
) *Controllers {
return &Controllers{
userController: userController,
signUpController: signUpController,
}
}
func (c *Controllers) Mount(e *echo.Echo) {
jwtMiddleware := ApplyJWTMiddleware()
c.userController.Mount(e.Group("/user"), jwtMiddleware)
c.signUpController.Mount(e.Group("/sign_up"))
}
ここでは、Mountメソッドの中でApplyJWTMiddlewareメソッドを呼び出しています。
実装の中身まではお示ししませんが、signUpControllerにはjwtMiddlewareを渡していません。ルート全体として保護する必要のないものと一部でも必要なものとの出し分けを行っていることがわかると思います。
userファイル
package interfaces
import (
"net/http"
"strconv"
"github.com/labstack/echo/v4"
"github.com/yupon-pro/note-for-debater/domain"
"github.com/yupon-pro/note-for-debater/usecase"
"github.com/yupon-pro/note-for-debater/pkg"
)
type UserResponse struct {
Id string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
type SignInResponse struct{
AccessToken string `json:"accessToken"`
User UserResponse `json:"user"`
}
type UserController struct {
userUsecase usecase.UserUsecase
}
func NewUserController(userUsecase usecase.UserUsecase) *UserController {
return &UserController{
userUsecase: userUsecase,
}
}
func (c *UserController) Mount(group *echo.Group, jwtMiddleware echo.MiddlewareFunc) {
group.POST("/signin", c.Signin)
group.PATCH("/auth", c.AuthUpdate, jwtMiddleware)
}
func (c *UserController) Signin(e echo.Context) error {
req := struct{
Email string `json:"email" form:"email"`
Password string `json:"password" form:"password"`
}{}
if err := e.Bind(&req); err != nil{
return echo.NewHTTPError(http.StatusBadRequest, err)
}
user, err := c.userUsecase.ReadAuthUser(req.Email)
if err != nil{
return echo.NewHTTPError(http.StatusBadRequest, err)
}
hashPwd := user.Password
reqPwd := req.Password
if err := pkg.ComparePwd(hashPwd, reqPwd); err != nil{
return echo.NewHTTPError(http.StatusBadRequest, err)
}
t, err := GetJWTToken(user.Email, user.UserId)
if err != nil{
return echo.NewHTTPError(http.StatusBadRequest, err)
}
return e.JSON(http.StatusOK, SignInResponse{
AccessToken: t,
User: UserResponse{
Id: strconv.Itoa(user.UserId),
Name: user.Name,
Email: user.Email,
},
})
}
func (c *UserController) AuthUpdate(e echo.Context) error {
uInfo, err := UserInfoViaToken(e)
if err != nil {
return echo.NewHTTPError(http.StatusNotFound, err)
}
var req usecase.UpdateUserInput
if err := e.Bind(&req); err != nil{
return echo.NewHTTPError(http.StatusBadRequest, err)
}
req.UserId = uInfo.UserId
hashPwd, err := pkg.EncryptPwd(req.Password)
if err != nil{
return echo.NewHTTPError(http.StatusBadRequest, err)
}
req.Password = hashPwd
res, err := c.userUsecase.UpdateUser(req)
if err != nil{
return echo.NewHTTPError(http.StatusBadRequest, err)
}
return e.JSON(http.StatusOK, userMapper(res))
}
func userMapper(user *domain.APIUser) UserResponse{
return UserResponse{
Id: strconv.Itoa(user.UserId),
Name: user.Name,
Email : user.Email,
}
}
SigninメソッドでGetJWTTokenメソッドを呼び出していますが、その前に与えられたメールアドレスとパスワードが正しいかどうかを検証しています。
また、AuthUpdateメソッド(JWT認証を必要とするパス)では始めにUserInfoViaTokenを呼び出しており、そこでユーザーデータを取得しています。 userUsecaseのUpdateUserメソッドにはUpdateUserInput構造体のインスタンスを引数として渡していますが、ここでアクセストークンを読み込んだ時に取得したuserIdを利用しています。
AuthUpdateメソッドを渡したHTTPメソッドには、パスを保護するためにミドルウェアも渡していることがわかると思います。
group.PATCH("/auth", c.AuthUpdate, jwtMiddleware)
ちなみに、パスワードの暗号化とその比較に関するメソッドはpkgファイルで定義していました。ライブラリーはcryptoです。
package pkg
import (
"fmt"
"golang.org/x/crypto/bcrypt"
)
func EncryptPwd(password string) (string, error) {
hashPwd, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(hashPwd), nil
}
func ComparePwd(hashPwd, reqPwd string) error {
if err := bcrypt.CompareHashAndPassword([]byte(hashPwd), []byte(reqPwd)); err != nil{
return fmt.Errorf("the password doesn't match: %w", err)
}
return nil
}
以上が、JWT認証とレイヤードアーキテクチャへの実装に関わる内容です。
4. おわりに
基本的には、echoの公式が出している例で事足りますが、それをアーキテクチャに組み込む記事が見当たらなかったのでまとめてみました。
レイヤードアーキテクチャやDDDの解説は特にしなかったので、ここが気になる方は他の方の記事を読むか、私の記事も参考にしてみてください。
ご覧くださりありがとうございました。
5. 参考
What is context.Get and Why the "user" to get auth header?