コンテンツにスキップするには Enter キーを押してください

goaで作ったAPIサーバにJWT認証を追加する

Fusicの岡嵜です。

私が担当した前回の記事でgoaというフレームワークを使ってgolang製のAPIサーバを作りました。

今回は、JWT認証を兼ね備えたAPIサーバを作ってみることとします。

参考資料

goaの公式リポジトリにJWT認証のサンプルがあるのでこれを参考とします。
https://github.com/goadesign/examples/blob/master/security/jwt.go

designを定義

前回同様、design.goを記述します。

package design

import (
	. "github.com/goadesign/goa/design"
	. "github.com/goadesign/goa/design/apidsl"
)

var _ = API("Secure", func() {
	Scheme("http")
	Host("localhost:8080")
	BasePath("/api/v1")
})

// BasicAuth defines a security scheme using basic authentication. The scheme protects the "signin"
// action used to create JWTs.
var SigninBasicAuth = BasicAuthSecurity("SigninBasicAuth")

// JWT defines a security scheme using JWT.  The scheme uses the "Authorization" header to lookup
// the token.  It also defines then scope "api".
var JWT = JWTSecurity("jwt", func() {
	Header("Authorization")
	Scope("api:access", "API access") // Define "api:access" scope
})

// Resource jwt uses the JWTSecurity security scheme.
var _ = Resource("jwt", func() {
	DefaultMedia(SuccessMedia)

	Security(JWT, func() { // Use JWT to auth requests to this endpoint
		Scope("api:access") // Enforce presence of "api" scope in JWT claims.
	})

	Action("signin", func() {
		Security(SigninBasicAuth)
		Routing(POST("/jwt/signin"))
		Response(NoContent, func() {
			Headers(func() {
				Header("Authorization", String, "Generated JWT")
			})
		})
		Response(Unauthorized)
	})

	Action("secure", func() {
		Routing(GET("/jwt"))
		Response(OK)
		Response(Unauthorized)
	})
})

var SuccessMedia = MediaType("application/vnd.goa.examples.security.success", func() {
	Attributes(func() {
		Attribute("ok", Boolean, "Always true")
		Required("ok")
	})
	View("default", func() {
		Attribute("ok")
	})
})

定義しているAPIは2つです。

POST /jwt/signin

このエンドポイントへbasic認証付きでPOSTすることでtokenを発行します。

GET /jwt

このエンドポイントへtoken付きでGETすることで、ok:trueを返します。
tokenがない場合や不正な場合はok:falseを返します。

以下コマンドを実行してコードを自動生成します。

$ goagen bootstrap -d github.com/yuuu/auth_api/design

middlewareを追加

今回、basic認証とJWTという2つのミドルウェアを使用するため、自動生成されたmain.goのmain()に以下を追記します。

package main

import (
	"context"
	"io/ioutil"
	"net/http"
	"os"

	jwtgo "github.com/dgrijalva/jwt-go"
	"github.com/goadesign/goa"
	"github.com/goadesign/goa/middleware"
	"github.com/goadesign/goa/middleware/security/jwt"
	"github.com/yuuu/auth_api/app"
)

func main() {
	// Create service
	service := goa.New("Secure")

	// Mount middleware
	service.Use(middleware.RequestID())
	service.Use(middleware.LogRequest(true))
	service.Use(middleware.ErrorHandler(service, true))
	service.Use(middleware.Recover())

	// 追記ここから
	pem, err := ioutil.ReadFile("./jwtkey/jwt.key.pub")
	if err != nil {
		os.Exit(1)
	}
	key, err := jwtgo.ParseRSAPublicKeyFromPEM([]byte(pem))
	if err != nil {
		os.Exit(1)
	}
	jwtHandler := func(h goa.Handler) goa.Handler {
		return func(ctx context.Context, rw http.ResponseWriter, req *http.Request) error {
			return h(ctx, rw, req)
		}
	}
	app.UseJWTMiddleware(service, jwt.New(jwt.NewSimpleResolver([]jwt.Key{key}), jwtHandler, app.NewJWTSecurity()))

	basicAuthHandler := func(h goa.Handler) goa.Handler {
		return func(ctx context.Context, rw http.ResponseWriter, req *http.Request) error {
			user, pass, ok := req.BasicAuth()
			if !ok || user != "foo" || pass != "bar" {
				errUnauthorized := goa.NewErrorClass("unauthorized", 401)
				return errUnauthorized("missing auth")
			}
			return h(ctx, rw, req)
		}
	}
	app.UseSigninBasicAuthMiddleware(service, basicAuthHandler)
	// 追記ここまで

	// Mount "jwt" controller
	c := NewJWTController(service)
	app.MountJWTController(service, c)

	// Start service
	if err := service.ListenAndServe(":8080"); err != nil {
		service.LogError("startup", "err", err)
	}

}

POST /jwt/signinを実装

signinの処理を記載します。
読み出した秘密鍵を使ってtokenを生成し、レスポンスのAuthorizationヘッダにセットしています。

func (c *JWTController) Signin(ctx *app.SigninJWTContext) error {
	// JWTController_Signin: start_implement

	// Put your logic here
	b, err := ioutil.ReadFile("./jwtkey/jwt.key")
	if err != nil {
		return fmt.Errorf("read private key file: %s", err) // internal error
	}
	privKey, err := jwtgo.ParseRSAPrivateKeyFromPEM(b)
	if err != nil {
		return fmt.Errorf("failed to parse RSA private key: %s", err) // internal error
	}
	token := jwtgo.New(jwtgo.SigningMethodRS512)
	in3m := time.Now().Add(time.Duration(3) * time.Minute).Unix()
	token.Claims = jwtgo.MapClaims{
		"iss":    "Issuer",                         // who creates the token and signs it
		"aud":    "Audience",                       // to whom the token is intended to be sent
		"exp":    in3m,                             // time when the token will expire (10 minutes from now)
		"jti":    uuid.Must(uuid.NewV4()).String(), // a unique identifier for the token
		"iat":    time.Now().Unix(),                // when the token was issued/created (now)
		"nbf":    2,                                // time before which the token is not yet valid (2 minutes ago)
		"sub":    "subject",                        // the subject/principal is whom the token is about
		"scopes": "api:access",                     // token scope - not a standard claim
	}
	signedToken, err := token.SignedString(privKey)
	if err != nil {
		return fmt.Errorf("failed to sign token: %s", err) // internal error
	}
	ctx.ResponseData.Header().Set("Authorization", "Bearer "+signedToken)
	return ctx.NoContent()

	// JWTController_Signin: end_implement
}

GET /jwtを実装

tokenを復号しclaimsの中身をチェックしています。特に問題が無ければOK : trueを返します。

func (c *JWTController) Secure(ctx *app.SecureJWTContext) error {
	// JWTController_Secure: start_implement

	// Put your logic here
	// Retrieve the token claims
	token := jwt.ContextJWT(ctx)
	if token == nil {
		return fmt.Errorf("JWT token is missing from context") // internal error
	}
	claims := token.Claims.(jwtgo.MapClaims)

	// Use the claims to authorize
	subject := claims["sub"]
	if subject != "subject" {
		// A real app would probably use an "Unauthorized" response here
		res := &app.GoaExamplesSecuritySuccess{OK: false}
		return ctx.OK(res)
	}

	res := &app.GoaExamplesSecuritySuccess{OK: true}
	return ctx.OK(res)

	// JWTController_Secure: end_implement
}

動作確認

以下コマンドでAPIサーバを起動します。

$ go run main.go jwt.go                                                        
2018/11/14 23:08:17 [INFO] mount ctrl=JWT action=Secure route=GET /api/v1/jwt security=jwt
2018/11/14 23:08:17 [INFO] mount ctrl=JWT action=Signin route=POST /api/v1/jwt/signin security=SigninBasicAuth
2018/11/14 23:08:17 [INFO] listen transport=http addr=:8080

curlコマンドで/jwt/signinへPOSTしてみます。

$ curl localhost:8080/api/v1/jwt/signin -H "accept: application/json" -H "Authorization:Basic $(echo -n foo:bar | openssl base64)" -X POST -i
HTTP/1.1 204 No Content
Authorization: Bearer eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJBdWRpZW5jZSIsImV4cCI6MTU0MjM0MDM1NiwiaWF0IjoxNTQyMzQwMTc2LCJpc3MiOiJJc3N1ZXIiLCJqdGkiOiI1NDAxOThiNy01NGEzLTQyMzUtOTlkMC05ZTRjMDE3MDM3YmIiLCJuYmYiOjIsInNjb3BlcyI6ImFwaTphY2Nlc3MiLCJzdWIiOiJzdWJqZWN0In0.dqRAaMwyLpuM0enz-5W0H6BSZNCZmIgl78aSc3aPDArQKb7jU_JXgzkQXm6kHP1QNJ_s89uUIOCtRG-B-OUXnhYya07l2AAA_dzudXeGxM4RSCuyX7xJ7t2N-ZMjMLevgGfPi1CXKMEFjkUx1QSKuzN751goaR7X7NY_FXz7kZcVI9b2ANXNZb0D6Q77QfhUAPG0BYNYDvaBHlRhAhm8H1wnO6uxspD0LKjM4Mj9ExLh3wuKxgX9OJg6tovjf2RnHByV3xHAkScSpxTh4a_IYHJKmfduVynubyV1bLJkvmHsrN9qLl3fYb72nNqYFGTV25P_6vuJSP2WvOwwxYN8Vg
Date: Fri, 16 Nov 2018 03:49:36 GMT

tokenが取得できました。
このtokenを使って、/jwtをGETします。

$ curl localhost:8080/api/v1/jwt -H "accept: application/json" -H "Authorization: Bearer eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJBdWRpZW5jZSIsImV4cCI6MTU0MjM0MDM1NiwiaWF0IjoxNTQyMzQwMTc2LCJpc3MiOiJJc3N1ZXIiLCJqdGkiOiI1NDAxOThiNy01NGEzLTQyMzUtOTlkMC05ZTRjMDE3MDM3YmIiLCJuYmYiOjIsInNjb3BlcyI6ImFwaTphY2Nlc3MiLCJzdWIiOiJzdWJqZWN0In0.dqRAaMwyLpuM0enz-5W0H6BSZNCZmIgl78aSc3aPDArQKb7jU_JXgzkQXm6kHP1QNJ_s89uUIOCtRG-B-OUXnhYya07l2AAA_dzudXeGxM4RSCuyX7xJ7t2N-ZMjMLevgGfPi1CXKMEFjkUx1QSKuzN751goaR7X7NY_FXz7kZcVI9b2ANXNZb0D6Q77QfhUAPG0BYNYDvaBHlRhAhm8H1wnO6uxspD0LKjM4Mj9ExLh3wuKxgX9OJg6tovjf2RnHByV3xHAkScSpxTh4a_IYHJKmfduVynubyV1bLJkvmHsrN9qLl3fYb72nNqYFGTV25P_6vuJSP2WvOwwxYN8Vg"
{"ok":true}

3分経つとtokenの有効期限が切れて、失敗します。

$ curl localhost:8080/api/v1/jwt -H "accept: application/json" -H "Authorization: Bearer eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJBdWRpZW5jZSIsImV4cCI6MTU0MjM0MDM1NiwiaWF0IjoxNTQyMzQwMTc2LCJpc3MiOiJJc3N1ZXIiLCJqdGkiOiI1NDAxOThiNy01NGEzLTQyMzUtOTlkMC05ZTRjMDE3MDM3YmIiLCJuYmYiOjIsInNjb3BlcyI6ImFwaTphY2Nlc3MiLCJzdWIiOiJzdWJqZWN0In0.dqRAaMwyLpuM0enz-5W0H6BSZNCZmIgl78aSc3aPDArQKb7jU_JXgzkQXm6kHP1QNJ_s89uUIOCtRG-B-OUXnhYya07l2AAA_dzudXeGxM4RSCuyX7xJ7t2N-ZMjMLevgGfPi1CXKMEFjkUx1QSKuzN751goaR7X7NY_FXz7kZcVI9b2ANXNZb0D6Q77QfhUAPG0BYNYDvaBHlRhAhm8H1wnO6uxspD0LKjM4Mj9ExLh3wuKxgX9OJg6tovjf2RnHByV3xHAkScSpxTh4a_IYHJKmfduVynubyV1bLJkvmHsrN9qLl3fYb72nNqYFGTV25P_6vuJSP2WvOwwxYN8Vg"
{"id":"TCklakKK","code":"jwt_security_error","status":401,"detail":"JWT validationfailed"}

もちろん、tokenが無くても失敗します。

$ curl localhost:8080/api/v1/jwt -H "accept: application/json"
{"id":"i2irAndZ","code":"jwt_security_error","status":401,"detail":"missing header\"Authorization\""}

まとめ

フレームワークの仕様さえ理解すれば簡単にJWT認証を追加できました。

次はgormaを使ったモデル定義をしていく予定です。

2018年の年明けに組込み畑からやってきた、2児の父 兼 Webエンジニアです。
業務ではRuby on Rails、最近ではフロントエンドにVue.jsを使っています。趣味でGo言語を触ることも。
Lab.Consoleのプロダクトオーナーをしており、AWSと仲良くなれるよう日々勉強中です。

コメントする

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です