goaでJWT認証を導入する
こちらで、goa
を使ったマイクロサービス用のフレームワークのファーストステップについて記載しましたが、ここではJWTMiddleware
を使った、JWT
認証の実現方法について記載します。
なお、こちらでは、middleware
を利用したgoa
のログ出力について記載しております。
サンプルコードについて
本記事のサンプルコードはgithubで用意しております。
モデル(Repository)層を用意する
refresh token
を使った再認証もしますので、モデル(Repository)層を用意しました。
テーブル定義
下記のようにrefresh_tokens
テーブルを定義しました。
CREATE TABLE IF NOT EXISTS refresh_tokens
(
jti VARCHAR(36) NOT NULL,
user_id int unsigned UNIQUE NOT NULL,
created_at datetime DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL,
deleted_at datetime,
PRIMARY KEY (jti)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4;
モデル(Repository)層の定義
サービス層から呼び出すモデル(Repository)で簡単なCRUD
を定義しております。
認証のResource
を用意する
こちらのソースコードの説明になりますが、email
とpassword
で認証をするログインAPIと、refresh token
で再認証をするAPIの2つ用意します。
var JWT = JWTSecurity("jwt", func() {
Header("Authorization")
Scope("api:access", "API access") // Define "api:access" scope
})
var _ = Resource("auth", func() {
Action("login", func() {
Routing(POST("/login"))
Payload(func() {
Attribute("email", String, "name of sample", func() {
Example("sample@goa-sample.test.com")
})
Attribute("password", String, "detail of sample", func() {
Example("test1234")
})
Required("email", "password")
})
Response(OK, AuthSamples)
Response(NotFound)
Response(BadRequest, ErrorMedia)
})
Action("reauthenticate", func() {
Routing(POST("/refresh_token"))
Payload(func() {
Attribute("refresh_token", String, "refresh token", func() {
Example("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c")
})
Required("refresh_token")
})
Response(OK, AuthSamples)
Response(NotFound)
Response(BadRequest, ErrorMedia)
})
})
また、Authorization:Bearerヘッダ
で認証をしますが、対象とするAPIについて下記のように記述します。
var _ = Resource("samples", func() {
BasePath("/samples")
Description("sample APIs with JWT Authorization")
Security(JWT, func() {
Scope("api:access")
})
Action("list", func() {
Description("複数")
Routing(
GET("/"),
)
Response(OK, CollectionOf(MediaSamples))
Response(NotFound)
Response(BadRequest, ErrorMedia)
Response(Unauthorized, ErrorMedia)
})
middleware
を用意する
こちらのようにmiddleware
を用意します。
下記のように複数のRASの公開鍵をロードすることを前提にしております(サンプルで利用しているRSAの秘密鍵は1つですが・・・)。
func LoadJWTPublicKeys() ([]*rsa.PublicKey, error) {
keyFiles, err := filepath.Glob("./jwtkey/*.pub")
if err != nil {
return nil, err
}
keys := make([]*rsa.PublicKey, len(keyFiles))
for i, keyFile := range keyFiles {
pem, err := ioutil.ReadFile(keyFile)
if err != nil {
return nil, err
}
key, err := jwtgo.ParseRSAPublicKeyFromPEM([]byte(pem))
if err != nil {
return nil, fmt.Errorf("failed to load key %s: %s", keyFile, err)
}
keys[i] = key
}
if len(keys) == 0 {
return nil, fmt.Errorf("couldn't load public keys for JWT security")
}
return keys, nil
}
サービス層を用意する
認証用のサービス
認証に利用するサービス層はこちらで定義しました。
Authorization:Bearerヘッダ
で受け取ったtoken
文字列を下記のようパースしています。
func (c *AuthSharedService) VerifyToken(tokenString string) (string, *sample_error.SampleError) {
keys, err := sample_middleware.LoadJWTPublicKeys()
if err != nil {
return "", sample_error.NewSampleError(sample_error.InternalError, err.Error())
}
var parsedToken *jwt.Token
for _, key := range keys {
parsedToken, err = jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
// check signing method
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
return nil, errors.New("Unexpected signing method")
}
return key, nil
})
if err != nil {
break
}
}
if err != nil {
return "", sample_error.NewSampleError(sample_error.UnAuthorized, err.Error())
}
err = parsedToken.Claims.Valid()
if err !=nil {
return "", sample_error.NewSampleError(sample_error.UnAuthorized, err.Error())
}
claims := parsedToken.Claims.(jwt.MapClaims)
return claims["jti"].(string), nil
}
User
サービス
User
サービスはこちらで用意しておりますが、transactionを使っております。
なお、パスワードはbcrypt
で暗号化しております。
func (s *UserService) AuthWithEmailAndPassword(email, password string) (*app.Auth, *sample_error.SampleError) {
var Auth *app.Auth
txFunc := func(db *gorm.DB) *sample_error.SampleError {
h, err := NewHashedRefreshTokenService(db)
if err != nil {
return err
}
u, err := s.model.GetWithEmail(email, db)
if err != nil {
return err
}
err = Confirm(u.HashedPassword, password)
if err != nil {
return err
}
var jti string
Auth, jti, err = s.Auth.IssueTokens(u.ID)
if err != nil {
return err
}
err = h.AddOrUpdate(u.ID, jti)
return err
}
err := models.GormTransaction(s.model.Db, txFunc)
if err != nil {
return nil, err
}
return Auth, nil
}
transaction用のwrapperを用意しております。
func GormTransaction(db *gorm.DB, txFunc func(*gorm.DB) *sample_error.SampleError) (err *sample_error.SampleError) {
tx := db.Begin()
defer func() {
if err != nil {
tx.Rollback()
} else {
tx.Commit()
}
}()
err = txFunc(tx)
return err
}
middleware
を指定する
認証用のcontrollerを用意し、main.go
でJWTMiddleware
を指定します。
jwtMiddleware, err := sample_middleware.NewJWTMiddleware()
if err != nil {
fmt.Println(err)
return;
}
app.UseJWTMiddleware(service, jwtMiddleware)
デモ
token
とrefresh token
を取得する
email
とpassword
で認証するログインAPIを実行し、token
とrefresh token
を取得します。
Authorization:Bearerヘッダ
で認証する
Authorization:Bearerヘッダ
にtokenを指定して、APIを実行した結果です。
tokenを指定しないとこのように401
エラーとなります。
refresh token
を使って再認証する
このようにrefresh token
を使って再認証します。
その他
各層でgo test
をしたいので、せめてモデル(repository)層のdi
層は用意したいと思います。
gomock
、assert
、require
を使ったTable Driven Test
について次回は記載する予定です。