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について次回は記載する予定です。