はじめに
アプリ開発でS3にReactを配置したSPA構成で作成し、ログイン認証にCognitoを利用しました。バックエンドにはGo。フレームワークはechoを使用しています。
まずトークンが3種類あることを知りませんでした。その中でもどのトークンを使っていいのかイメージが湧きませんでした。
実装イメージ
色々考えた結果このような流れで実装しようと考えました。
まず、フロントエンドでログインしようとするとCognitoのページに飛んで認証を行います。
認証されたらトークンを受け取ることができます。
そのトークンには3種類ありました。3種類一気に受け取ります。
- IDトークン
- アクセストークン
- リフレッシュトークン
IDトークン
目的: ユーザー認証(ユーザーが誰であるかを確認)
情報: ユーザーに関する情報(クレームと呼ばれる属性)が含まれます。例えば、ユーザーID (sub)、名前、メールアドレスなど。
対象: 主にクライアント(フロントエンド)がユーザーインターフェースの表示や簡単な認可判断のために使用します。
アクセストークン
目的: リソースへのアクセス認可(ユーザーが特定のリソースにアクセスできるかを確認)
情報: アクセスを許可されたリソース(API、サービスなど)と、その範囲(スコープ)が記述されています。
対象: 主にバックエンドAPIが、クライアントからのリクエストを認可するために使用します。
リフレッシュトークン
目的: アクセストークンが期限切れになった際、ユーザーに再ログインを求めることなく、新しいアクセストークンを取得すること
結局何を使うのか?
フロントエンドからバックエンドにトークンを使ってアクセスする時、何を使うのか?と悩みましたが、
結論、アクセストークンを使うべきとなりました。
理由
- IDトークンにはSub(ユーザーID)、name、emailなどが含まれている。
- すでに認証自体はCognitoで行なっているので、バックエンドが行うのはそのトークンの検証ができればいい。
- トークンの検証はトークンに署名が付与されているので、それを検証する。これはアクセストークンでも可能。
- Sub以外のユーザー情報が必要な時はSDKを使用してCognitoから直接取得する。その際、アクセストークンが必要だった.(userInfoエンドポイント)
実際に使用したCognitoのGerUserエンドポイントの記述
https://docs.aws.amazon.com/ja_jp/cognito-user-identity-pools/latest/APIReference/API_GetUser.html
上記の理由からフロントエンドからのリクエストヘッダーにアクセストークンを含めてバックエンドでそれを検証するという実装に落ち着きました。
トークン検証する際はこちらを参考にしました。
https://pkg.go.dev/github.com/jhosan7/cognito-jwt-verify
実際に実装した内容
// Cognito認証関連
package auth
import (
"context"
"fmt"
"log"
"os"
"skill-typing-back/repository"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/cognitoidentityprovider"
"github.com/golang-jwt/jwt/v5"
"github.com/jhosan7/cognito-jwt-verify/utils"
"github.com/labstack/echo/v4"
"gorm.io/gorm"
)
// Cognito API クライアントを保持する構造体
type CognitoUserService struct {
client *cognitoidentityprovider.Client
}
// ユーザー情報取得メソッド
func (s *CognitoUserService) GetUserInfo(ctx context.Context, accessToken string) (*cognitoidentityprovider.GetUserOutput, error) {
input := &cognitoidentityprovider.GetUserInput{
AccessToken: &accessToken,
}
return s.client.GetUser(ctx, input)
}
// Cognitoの設定やクレームを扱う構造体
type CognitoAuth struct {
verifier CognitoJwtVerifier
repo *repository.DbRepository
}
// JWTクレームの構造体
type CognitoClaims struct {
jwt.RegisteredClaims
Sub string `json:"sub"`
Iss string `json:"iss"`
Version int `json:"version"`
ClientID string `json:"client_id"`
OriginJti string `json:"origin_jti"`
EventID string `json:"event_id"`
TokenUse string `json:"token_use"` // トークンタイプ("access" of "id")
Scope string `json:"scope"`
AuthTime int64 `json:"auth_time"`
Username string `json:"username"`
}
// CognitoJwtVerifierの構造体
type CognitoJwtVerifier struct {
issuer string
jwksUri string
tokenUse string
cache *utils.Cache
}
type Config struct {
UserPoolId string
TokenUse string
ClientId string
}
func NewCognitoUserService() (*CognitoUserService, error) {
// SDkの設定を初期化
cfg, err := config.LoadDefaultConfig(context.TODO(),
config.WithRegion(os.Getenv("AWS_REGION")),
)
if err != nil {
return nil, fmt.Errorf("unable to load SDK config: %v", err)
}
// Cognitoクライアントを作成
client := cognitoidentityprovider.NewFromConfig(cfg)
return &CognitoUserService{
client: client,
}, nil
}
// 新しいCognitoAuth インスタンス作成
func NewCognitoAuth(repo *repository.DbRepository) (*CognitoAuth, error) {
config := Config{
UserPoolId: os.Getenv("COGNITO_USER_POOL_ID"),
TokenUse: "access",
ClientId: os.Getenv("COGNITO_CLIENT_ID"),
}
verifier, err := Create(config)
if err != nil {
return nil, fmt.Errorf("failed to create cognito verifier: %w", err)
}
return &CognitoAuth{
verifier: verifier,
repo: repo,
}, nil
}
// JWTVerifierの作成
func Create(config Config) (CognitoJwtVerifier, error) {
issuer, jwksUri, err := utils.ParseUserPoolId(config.UserPoolId)
if err != nil {
return CognitoJwtVerifier{}, err
}
return CognitoJwtVerifier{
issuer: issuer,
jwksUri: jwksUri,
tokenUse: config.TokenUse,
cache: utils.NewCache(),
}, nil
}
// トークン検証
func (c CognitoJwtVerifier) Verify(token string) (jwt.Claims, error) {
decomposeUnverifiedJwt, err := utils.DecomposeUnverifiedJwt(token)
if err != nil {
return nil, err
}
jwk, err := utils.GetJwk(decomposeUnverifiedJwt, c.jwksUri, c.cache)
if err != nil {
return nil, err
}
err = utils.VerifyDecomposedJwt(decomposeUnverifiedJwt, c.issuer, c.tokenUse, jwk.Alg)
if err != nil {
return nil, err
}
validToken, err := utils.ValidateJwt(token, jwk)
if err != nil {
return nil, err
}
return validToken.Claims, nil
}
// 認証ミドルウェア
func (a *CognitoAuth) AuthMiddleware(cognitoService *CognitoUserService) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
// Authorizationヘッダーからトークンを取得
auth := c.Request().Header.Get("Authorization")
if auth == "" {
return echo.ErrUnauthorized
}
// "Bearer"を除去
token := auth[7:]
// トークンを検証
claims, err := a.verifier.Verify(token)
if err != nil {
return echo.ErrUnauthorized
}
mapClaims, ok := claims.(jwt.MapClaims)
if !ok {
return echo.ErrInternalServerError
}
// クレームをCognitoClaimsにマッピング
cognitoClaims := &CognitoClaims{
Sub: mapClaims["sub"].(string),
TokenUse: mapClaims["token_use"].(string),
}
sub := cognitoClaims.Sub
// 以下DBでユーザーを検索する処理など
この認証ミドルウェアをrouter.goで認証をしたいエンドポイントに適用させました。
/*
* ルーティング(cognito認証なし)
*/
e.GET("/", func(c echo.Context) error {
return c.String(http.StatusOK, "Hello, World!")
})
/*
* ルーティング(cognito認証あり)
*/
api := e.Group("/api")
// api配下にのみauthMiddlwareを適用
api.Use(authMiddleware)
// ユーザー情報取得エンドポイント
api.GET("/users/me", apiHandler.GetMe)
// 質問の作成エンドポイント
api.POST("/questions", apiHandler.SaveQuizHandler)
// スコアの作成エンドポイント
api.POST("/scores", apiHandler.CreateScore)
// 最新スコア取得エンドポイント
api.GET("/scores/latest", apiHandler.GetLatestScore)
// AI クイズ生成エンドポイント
api.GET("/generate-quiz", apiHandler.GenerateQuizHandler)
//ゲーム画面の取得エンドポイント
api.GET("/game/questions", apiHandler.GetGameQuestions)
//ゲーム画面の追加の取得エンドポイント
api.GET("/game/questions/additional", apiHandler.GetAdditionalGameQuestions)
return e
}
これで、バックエンドでトークン検証を行なったユーザーのみがアプリを利用できるようになりました。
さいごに
この実装内容がベストプラクティスでは無いかもしれませんが、誰かの参考になれば幸いです。