GoのWebフレームワーク Echo でJWT ( JSON Web Token )を使う
はじめに
前回はEchoで作成したwebアプリのセッション管理をRedisで行いました。
今回は JWT ( JSON Web Token ) を利用してAPIに認証をかけます。
■ JWTとは
JWTとは、JSON形式で表現された認証情報などをURL文字列などとして安全に送受信できるよう、符号化やデジタル署名の仕組みを規定した標準規格
中身は <ヘッダー>.<ペイロード>.<署名>
で構成されている
コード
ディレクトリ構成
❯ tree -L 2 . ├── README.md ├── api │ ├── base.go │ ├── client.go │ ├── crypto_coincheck.go │ ├── crypto_coincheck_test.go │ ├── crypto_gmo.go │ ├── crypto_gmo_test.go │ ├── healthcheck.go │ ├── healthcheck_test.go │ ├── investment_trust.go │ └── metal.go ├── conf │ └── config.go ├── docker │ ├── mysql │ └── redis ├── docker-compose.yml ├── go.mod ├── go.sum ├── handler │ ├── auth.go │ └── top.go ├── main.go ├── model │ ├── db.go │ └── user.go ├── staticcheck.conf └── view ├── login.html ├── mypage.html ├── signup.html └── top.html
他のコードは 前回の記事に記載。
ORMのgormを利用して Echo で サインアップとログイン機能を追加する - My Note
- main.go(一部抜粋)
package main import ( "io" "net/http" "text/template" "github.com/gorilla/sessions" "github.com/labstack/echo-contrib/session" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" "github.com/yhidetoshi/apiEchoGAE-local/api" "github.com/yhidetoshi/apiEchoGAE-local/conf" "github.com/yhidetoshi/apiEchoGAE-local/handler" ) var e = createMux() func createMux() *echo.Echo { e := echo.New() return e } type TemplateRender struct { templates *template.Template } func (t *TemplateRender) Render(w io.Writer, name string, data interface{}, c echo.Context) error { return t.templates.ExecuteTemplate(w, name, data) } func main() { // Echoインスタンス作成 // http.Handle("/", e) e.Use(middleware.Logger()) e.Use(middleware.Recover()) e.Use(middleware.Gzip()) // Template renderer := &TemplateRender{ templates: template.Must(template.ParseGlob("view/*.html")), } e.Renderer = renderer // セッション e.Use(session.Middleware(sessions.NewCookieStore([]byte(conf.SIGNING_KEY)))) // ルーティング // apiG := e.Group("/api") // HTMLページ e.GET("/", handler.ShowTopHTML) e.GET("/signup", handler.ShowSignUpHTML) e.POST("/signup", handler.SignUp) e.GET("/login", handler.ShowLoginHTML) e.POST("/login", handler.Login) e.GET("/restricted", handler.ShowRestrictedPage) apiG.Use(middleware.JWTWithConfig(handler.JWTConfig)) apiG.GET("/private", handler.ShowData) e.Start(":8080") }
- conf/config.go
package conf const ( // MySQL DB_HOST = "localhost" DB_NAME = "echo" DB_PORT = "3306" DB_USER = "root" DB_PASS = "root" // Redis REDIS_HOST = "localhost" REDIS_PORT = "6379" SESSION_KEY = "my-investment" // JWT SIGNING_KEY = "secret" )
- handler/auth.go
package handler import ( "context" "html" "net/http" "time" "github.com/go-redis/redis/v8" "github.com/golang-jwt/jwt" "github.com/gorilla/sessions" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" "github.com/labstack/gommon/log" "github.com/rbcervilla/redisstore/v8" "github.com/yhidetoshi/apiEchoGAE-local/conf" "github.com/yhidetoshi/apiEchoGAE-local/model" ) const ( redisEndpoint = conf.REDIS_HOST + ":" + conf.REDIS_PORT sessionKey = conf.SESSION_KEY ) var signingKey = []byte(conf.SIGNING_KEY) // JWTConfig https://echo.labstack.com/middleware/jwt/#configuration var JWTConfig = middleware.JWTConfig{ Claims: &JWTCustomClaims{}, SigningKey: signingKey, } // FormData Loginフォームのデータ処理用 type FormData struct { Name string Password string Message string } type JWTCustomClaims struct { UID int `json:"uid"` Name string `json:"name"` jwt.StandardClaims } type JWTResponseSample struct { UID int `json:"uid"` Name string `json:"name"` } // ResponseMessageJSON レスポンスメッセージ type ResponseMessageJSON struct { Message string `json:"message"` } type ResponseJTWTokenJSON struct { Name string `json:"name"` Token string `json:"token"` } // ShowSignUpHTML サインアップのページ表示 func ShowSignUpHTML(c echo.Context) error { return c.Render(http.StatusOK, "signup", FormData{}) } // ShowLoginHTML Loginページの表示 func ShowLoginHTML(c echo.Context) error { return c.Render(http.StatusOK, "login", FormData{}) } // ShowMyPageHTML Myページの表示 func ShowMyPageHTML(c echo.Context) error { // ログイン確認 session := getSession(c) if session.Values["auth"] != true { return c.String(http.StatusUnauthorized, "401") } else { mypageData := map[string]string{ "UserName": session.Values["username"].(string), } return c.Render(http.StatusOK, "mypage", mypageData) } } func getSession(c echo.Context) *sessions.Session { client := redis.NewClient(&redis.Options{ Addr: redisEndpoint, }) store, err := redisstore.NewRedisStore(context.Background(), client) if err != nil { log.Fatal("Failed cannot connect redis", err) //return err } store.KeyPrefix("session_") store.Options(sessions.Options{ MaxAge: 600, HttpOnly: true, }) session, err := store.Get(c.Request(), sessionKey) if err != nil { log.Fatal("Failed cannot get session", err) } return session } // SignUp サインアップ処理 func SignUp(c echo.Context) error { signUpForm := FormData{ Name: c.FormValue("name"), Password: c.FormValue("password"), } name := html.EscapeString(signUpForm.Name) // Form(Name)に入力されたデータを取得 password := html.EscapeString(signUpForm.Password) // Form(Password)に入力されたデータを取得 if name == "" || password == "" { // nullだけ弾く signUpForm.Message = "Invalid Name or Password" return c.Render(http.StatusOK, "signup", signUpForm) } user := new(model.User) user.Name = name user.Password = password if err := c.Bind(user); err != nil { return err } u := model.GetUser(&model.User{ Name: user.Name, }) if u.ID != 0 { return &echo.HTTPError{ Code: http.StatusConflict, Message: "Name already exists", } } model.CreateUser(user) user.Password = "" responseJSON := ResponseMessageJSON{ Message: "SignUp Success", } return c.JSON(http.StatusOK, responseJSON) } // Login ログイン処理 func Login(c echo.Context) error { loginForm := FormData{ Name: c.FormValue("name"), Password: c.FormValue("password"), } // Formからデータ取得 name := html.EscapeString(loginForm.Name) password := html.EscapeString(loginForm.Password) u := new(model.User) u.Name = name u.Password = password if err := c.Bind(u); err != nil { return err } user := model.GetUser( &model.User{Name: u.Name}, ) if u.Name != user.Name || u.Password != user.Password { // FormとDBのデータを比較 return &echo.HTTPError{ Code: http.StatusUnauthorized, Message: "Invalid Name or Password", } } // セッション変数に値を付与 // session := getSession(c) session.Values["username"] = u.Name //ログインユーザ名を付与 session.Values["auth"] = true // ログイン有無の確認用 if err := sessions.Save(c.Request(), c.Response()); err != nil { // Session情報の保存 log.Fatal("Failed save session", err) return err } // JWT(Json Web Token)の処理 // claims := &JWTCustomClaims{ // https://pkg.go.dev/github.com/golang-jwt/jwt@v3.2.2+incompatible#NewWithClaims user.ID, user.Name, jwt.StandardClaims{ ExpiresAt: time.Now().Add(time.Hour * 1).Unix(), }, } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) t, err := token.SignedString(signingKey) if err != nil { return err } session.Values["token"] = t ResponseJTWTokenJSON := ResponseJTWTokenJSON{ Name: user.Name, Token: t, } c.Redirect(http.StatusFound, "/mypage") return c.JSON(http.StatusOK, ResponseJTWTokenJSON) } // Logout ログアウト処理 func Logout(c echo.Context) error { session := getSession(c) // ログアクト session.Values["auth"] = false // セッション削除 session.Options.MaxAge = -1 if err := sessions.Save(c.Request(), c.Response()); err != nil { log.Fatal("Failed cannot delete session", err) } c.Redirect(http.StatusFound, "/login") return c.Render(http.StatusOK, "login", FormData{}) } // ShowRestrictedPage ログイン済みユーザに表示するページ func ShowRestrictedPage(c echo.Context) error { session := getSession(c) // ログイン確認 if session.Values["auth"] != true { return c.String(http.StatusUnauthorized, "401") } else { return c.String(http.StatusOK, session.Values["username"].(string)) } } // ShowData JWT認証の確認用 func ShowData(c echo.Context) error { user := c.Get("user").(*jwt.Token) claims := user.Claims.(*JWTCustomClaims) responseJSON := JWTResponseSample{ UID: claims.UID, Name: claims.Name, } return c.JSON(http.StatusOK, responseJSON) }
動作確認
JWT Recipe | Echo - High performance, minimalist Go web framework
■ JWT認証なし
❯ curl http://127.0.0.1:8080/api/private {"message":"missing or malformed jwt"}
■ ログインしてトークンを発行
❯ curl -X POST -d 'name=yhidetoshi&password=hoge' http://127.0.0.1:8080/login <h1> <div>My Page</div> </h1> <body> <p>こんにちは yhidetoshi さん</p> <p> <h6>{"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOjEsIm5hbWUiOiJ5aGlkZXRvc2hpIiwiZXhwIjoxNjQ4ODA0MDgxfQ._9dvPVRxRvmPOx_vP5h_EEm_qqKnBXxBSENwNljtR2k"}</h6> </p> </body>
■ トークンをデコードして中身を確認
- ヘッダー:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
- ペイロード:
eyJ1aWQiOjEsIm5hbWUiOiJ5aGlkZXRvc2hpIiwiZXhwIjoxNjQ4ODA0MDgxfQ
- 署名:
_9dvPVRxRvmPOx_vP5h_EEm_qqKnBXxBSENwNljtR2k
これらをそれぞれデコードして中身を確認してみる。
■ <ヘッダー> をデコード ❯ echo eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 | base64 -d | jq { "alg": "HS256", "typ": "JWT" } ■ <ペイロード> をデコード ❯ echo eyJ1aWQiOjEsIm5hbWUiOiJ5aGlkZXRvc2hpIiwiZXhwIjoxNjQ4ODA0MDgxfQ | base64 -d {"uid":1,"name":"yhidetoshi","exp":1648804081 ■ <署名> をデコード ❯ echo _9dvPVRxRvmPOx_vP5h_EEm_qqKnBXxBSENwNljtR2k | nkf -WmB o=TqF;I|AHCp6X
■ JWT認証あり
❯ curl -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOjEsIm5hbWUiOiJ5aGlkZXRvc2hpIiwiZXhwIjoxNjQ4ODA0MDgxfQ._9dvPVRxRvmPOx_vP5h_EEm_qqKnBXxBSENwNljtR2k" localhost:8080/api/private {"uid":1,"name":"yhidetoshi"}