1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

【Go】ログイン機能でウェブアプリを作ってみる(12)

Last updated at Posted at 2023-08-21

こんにちは。

Part 12は「リフレッシュ /auth/refresh」です。

現在、有効期限24時間のアクセストークンで認証を行なってます。
「認証の時間を伸ばしたい!でもアクセストークンの期限は伸ばしたくない・・・」
そんな場合はどうしましょうか。

解決方法の1つとしてリフレッシュトークンという別のトークンを使います。
アクセストークンとリフレッシュトークンの違いはこんな感じです。

  • アクセストークン
    • 認証トークン。制限されたリソースにアクセスするときに使う
    • 有効期限が短い(今回は30分)
    • Authorizationヘッダーでやり取りする
  • リフレッシュトークン
    • アクセストークンを再取得するため、だけのトークン
    • 有効期限が長い(今回は3日)
    • アクセストークンが期限切れの場合、リフレッシュトークンを使ってアクセストークンを再取得できる
    • 今回はCookieでやり取りする

では、このリフレッシュトークンが使えるように変更していきましょう!

今回の目標

  • ログイン時にリフレッシュトークンをcookieにセットする
  • /auth/refreshの実装

ログイン時にリフレッシュトークンをcookieにセットする

JwtBuilder

今まではJwtBuilderはアクセストークンを作ることが役割でしたが、今回からリフレッシュトークンも作れるように改良していきます。
まずはコードを少し変更します。

auth/jwt_builder.go

const (
	userIDClaim      = "user_id"
	issClaim         = "login-example"
	accessSubClaim   = "access-token"
+	refreshSubClaim  = "refresh-token"
	userIDContextKey = "user_id"
)

var (
	//go:embed keys/secret.pem
	secretKey []byte
	//go:embed keys/public.pem
	publicKey []byte
	
	// アクセストークンの有効期限
	expAccess = 30 * time.Minute
+	// リフレッシュトークンの有効期限
+	expRefresh = 3 * 24 * time.Hour
)

+ type IJwtBuilder interface {
+	IJwtGenerator
+	IJwtParser
+ }

type IJwtGenerator interface {
-	GenerateToken(u *entity.User) ([]byte, error)
+	GenerateAccessToken(u *entity.User) ([]byte, error)
+	GenerateRefreshToken(u *entity.User) ([]byte, error)
}


+ func (j *JwtBuilder) GenerateAccessToken(u *entity.User) ([]byte, error) {
+ 	return j.generateJWT(u, accessSubClaim, expAccess)
+ }

+ func (j *JwtBuilder) GenerateRefreshToken(u *entity.User) ([]byte, error) {
+ 	return j.generateJWT(u, refreshSubClaim, expRefresh)
+ }

// JWTを作成する
- func (j *JwtBuilder) GenerateToken(u *entity.User) ([]byte, error) {
+ func (j *JwtBuilder) generateJWT(u *entity.User, subClaim string, exp time.Duration) ([]byte, error) {
	// JWTを作成
	tok, err := jwt.NewBuilder().
		Issuer(issClaim).
		Subject(subClaim).
		IssuedAt(time.Now()).
- 		Expiration(time.Now().Add(expAccess)).
+		Expiration(time.Now().Add(exp)).
		Claim(userIDClaim, u.ID).
		Build()
	if err != nil {
		return nil, fmt.Errorf("failed to jwt build: %w", err)
	}

	// JWTを秘密鍵でハッシュ化します。
	signed, err := jwt.Sign(tok, jwt.WithKey(jwa.RS256, j.secretKey))
	if err != nil {
		return nil, fmt.Errorf("failed to sign: %w", err)
	}
	return signed, nil
}

この変更に合わせてusecaseも変更します。

usecase/user_usecase.go

func (uu *userUsecase) Login(ctx context.Context, email, password string) ([]byte, error) {

	// ~~~
	
	// ユーザー情報からJWTを作成
-	tok, err := uu.jwter.GenerateToken(u)
+	tok, err := uu.jwter.GenerateAccessToken(u)
	if err != nil {
		return nil, err
	}
	return tok, nil
}

コードを少し修正しました。

Usecase

usecase/user_usecase.go

type IUserUsecase interface {
	PreRegister(ctx context.Context, email, pw string) (*entity.User, error)
	Activate(ctx context.Context, email, token string) error
-	Login(ctx context.Context, email, password string) ([]byte, error)
+	Login(ctx context.Context, email, password string) ([]byte, *http.Cookie, error)
	Get(ctx context.Context, uid entity.UserID) (*entity.User, error)
}

// ~~~

- func NewUserUsecase(ur repository.IUserRepository, mailer mail.IMailer, jwter auth.IJwtGenerator) IUserUsecase {
+ func NewUserUsecase(ur repository.IUserRepository, mailer mail.IMailer, jwter auth.IJwtBuilder) IUserUsecase {
	return &userUsecase{ur: ur, mailer: mailer, jwter: jwter}
}

// ~~~

- func (uu *userUsecase) Login(ctx context.Context, email, password string) ([]byte, error) 
+ func (uu *userUsecase) Login(ctx context.Context, email, password string) ([]byte, *http.Cookie, error) {
	// emailからユーザー情報を取得する
	u, err := uu.ur.GetByEmail(ctx, email)
	if err != nil {
-		return nil, err
+		return nil, nil, err
	}
	// ユーザーがアクティブでないならエラー
	if !u.IsActive() {
-		return nil, errors.New("user inactive")
+		return nil, nil, errors.New("user inactive")
	}
	// ユーザーのパスワードを検証
	if err := u.Authenticate(password); err != nil {
-		return nil, err
+		return nil, nil, err
	}
	// ユーザー情報からJWTを作成
	tok, err := uu.jwter.GenerateAccessToken(u)
	if err != nil {
-		return nil, err
+		return nil, nil, err
	}

	refreshToken, err := uu.jwter.GenerateRefreshToken(u)
	if err != nil {
-		return nil, err
+		return nil, nil, err
	}
+	cookie := new(http.Cookie)
+	cookie.Name = "refresh-token"
+	cookie.Value = string(refreshToken)
+	cookie.Expires = time.Now().Add(3 * 24 * time.Hour)
+	// cookieのsame-site属性。今回は使うとしてもlocalhostからなのでStrictを指定
+	cookie.SameSite = http.SameSiteStrictMode
+	// HttpOnlyを設定することでJavaScriptでCookie操作を禁止
+	cookie.HttpOnly = true
+	// https通信のみcookieを利用する
+	// 本来はtrueに設定するべきだが、httpsは使わないので今回はなし
+	// cookie.Secure = true

	return tok, cookie, nil
}

Handler

handler/user_handler.go

func (h *userHandler) Login(c echo.Context) error {
	
	// ~~~
	
-	tok, err := h.uu.Login(ctx, rb.Email, rb.Password)
+	tok, cookie, err := h.uu.Login(ctx, rb.Email, rb.Password)
	if err != nil {
		return err
	}

+	c.SetCookie(cookie)

	// ログイン成功、としてJWTを返す
	return c.JSON(http.StatusOK, echo.Map{
		"access_token": string(tok),
	})
}

これでcookieがセットされるようになりました!

確認しよう

実際にcookieがセットされるか確認してみましょう

$ curl -v -XPOST localhost:8000/api/auth/login \
        -H 'Content-Type: application/json; charset=UTF-8' \
        -H 'X-CSRF-Header: secret' \
        -d '{"email": "test-user-1@example.xyz", "password": "foobar"}
< HTTP/1.1 200 OK
< Content-Type: application/json; charset=UTF-8
< Set-Cookie: refresh-token=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2ODg3MTgyNzAsImlhdCI6MTY4ODQ1OTA3MCwiaXNzIjoibG9naW4tZ28iLCJzdWIiOiJyZWZyZXNoLXRva2VuIiwidXNlcl9pZCI6MTAwMDA0fQ.gaLwIzYjoV-q_U-f__T1RiELwzXVpcXYuWfLSythkPDizeKJLhQxsMRj1e0D9laoJcuxaf1lr2K4qPo-NWJKRS5JQ0o1J8A80SNTc0PzbdRPkocrJ26PhSz1v5_-w7Cq8bicGcRX9L1gLZqorJYXjTA0o9My6MjKczbUFGZCJfg5WL26xktE48_SioJQszgPPu9WLuz_aB_--ShnK7xX3-DkVVdINuvrq4vC6yf-SmhA7pv25pAWmLPsTyqKjRld2xunVoBGO_bOHVpcaHceKZ_gUtthLrWRUKjkF934M2H9Kt8SDn54apfE1C_a2Rg1DwUh5HVoRLx6Za9dogoaHw; HttpOnly; Secure; SameSite=Strict
< Vary: Origin
< Date: Tue, 04 Jul 2023 08:24:30 GMT
< Content-Length: 520

ちゃんとSet-Cookieでrefresh-tokenがセットされてます。
JWT.IOで確認してもsubはrefresh-tokenになってますし、expも伸びてます。

/auth/refreshの実装

JwtBuilder

auth/jwt_builder.go

type IJwtParser interface {
	SetAuthToContext(c echo.Context) error
+	GetUserIDFromJWT(token []byte) (entity.UserID, error)
}
// ~~~

func (j *JwtBuilder) GetUserIDFromJWT(token []byte) (entity.UserID, error) {
	tok, err := j.parseJWT(token)
	if err != nil {
		return 0, err
	}
	id, ok := tok.Get(userIDClaim)
	if !ok {
		return 0, errors.New("failed to get user_id from token")
	}
	uid, ok := id.(float64)
	if !ok {
		return 0, fmt.Errorf("get invalid user_id: %v, %T", id, id)
	}
	return entity.UserID(uid), nil
}

func (j *JwtBuilder) parseJWT(token []byte) (jwt.Token, error) {
	tok, err := jwt.Parse(token,
		jwt.WithKey(jwa.RS256, j.publicKey),
		jwt.WithIssuer(issClaim),
		jwt.WithSubject(refreshSubClaim))
	if err != nil {
		return nil, fmt.Errorf("failed to parse token: %w", err)
	}
	return tok, err
}

Usecase

usecase/user_usecase.go

// ~~~

func (uu *userUsecase) Refresh(ctx context.Context, token []byte) ([]byte, error) {
	uid, err := uu.jwter.GetUserIDFromJWT(token)
	if err != nil {
		return nil, err
	}
	u, err := uu.ur.Get(ctx, uid)
	if err != nil {
		return nil, err
	}
	tok, err := uu.jwter.GenerateAccessToken(u)
	if err != nil {
		return nil, err
	}
	return tok, nil
}

Handler

handler/user_handler.go

type IUserHandler interface {
	PreRegister(c echo.Context) error
	Activate(c echo.Context) error
	Login(c echo.Context) error
	GetMe(c echo.Context) error
+	Refresh(c echo.Context) error
}
// ~~~

func (h *userHandler) Refresh(c echo.Context) error {
	cookie, err := c.Cookie("refresh-token")
	if err != nil {
		return err
	}

	ctx := c.Request().Context()

	v := cookie.Value
	tok, err := h.uu.Refresh(ctx, []byte(v))
	if err != nil {
		return err
	}
	return c.JSON(http.StatusOK, echo.Map{
		"access_token": string(tok),
	})
}

Router

router.go

func NewRouter(db *sqlx.DB, mailer mail.IMailer, jwter *auth.JwtBuilder) *echo.Echo {
	// ~~~
	
	a := e.Group("/api/auth")
	a.POST("/register/initial", uh.PreRegister)
	a.POST("/register/complete", uh.Activate)
	a.POST("/login", uh.Login)
+	a.GET("/refresh", uh.Refresh)

	// ~~~
}

確認

毎度のごとく、本当にリフレッシュトークンからアクセストークンが取得できるか確認していきましょう。

curl -XGET localhost:8000/api/auth/refresh \
	-H 'X-CSRF-Header: secret' \
	-b "refresh-token=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2ODg3MjU0MDcsImlhdCI6MTY4ODQ2NjIwNywiaXNzIjoibG9naW4tZ28iLCJzdWIiOiJyZWZyZXNoLXRva2VuIiwidXNlcl9pZCI6MTAwMDA0fQ.QFZl6jCpF1LxT8q6b2QXNsMBJGVNWQDNeQE7WbRauQ8fSsiLHco2Ed_wZ2_Nz9NktLQazvgTedeGnpIyEzXrMD9dVF5OHxDYsp0gygOaFf4rmvReOpLTJF-xRMnxzDrQ6kk0YiYDKQrfgkVHcQUoCwek3LVoCIT35N7QWDabvs5OAKshiYkLJQknM2v2jHYep5jwf0vqQexXYsegUiBCmTGu6dNAnRIT6q82b0tAZq8CTUTF7KE7_xSrc67NI333Z5OUJjJS7Gq3jnQEJgvWmYAaYgWCJImeepgKGUUqd5A-_QQ57dQBibqs_xe8JwmHWobnWx3p9tNkrdM1q7G57Q"
{"access_token":"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2ODg0NzIxOTcsImlhdCI6MTY4ODQ3MDM5NywiaXNzIjoibG9naW4tZ28iLCJzdWIiOiJhY2Nlc3MtdG9rZW4iLCJ1c2VyX2lkIjoxMDAwMDR9.T-xLgnVfqu8NwsnK5IeDhLpfgcQkYKb2qtpeHUPTzY8sRs5akDWapiI98i7m5QIouXWeRhSf-rbJDCQ7bZl4nwxqPTnv85GuefluYQELc8tJCBZ5xVD7mpYBw_eFxNoFk01tmoNWJ1FaRpwLPvpo2yE-jwfd5EvSFouqeFNuNQF5vbqFYV518SA5nFviPMVVY5OBbiDD_rvsBqt4KP4ZxjGC-n5GfmPMZJccitMWf998_gOrZ-EfM3EclL4QPK433P3Il7qey5NEW8Vv7Fc6aaK8_Lhforq4AYo7TfIE3bT0_Et8O6coJjR6Dq_tZKnCn8f4XbLO3-HiCKFXej79DA"}

ちゃんとaccess_tokenが返ってきました!

まとめ

今回行なった作業です

  • RefreshTokenのJWTを作成
  • ログイン時にcookieをセットするようにした
  • /auth/refreshを実装しました。

今回のリフレッシュはcookieの操作くらいが新しいことで、他は今までやった内容とあんまり変わらなかったです。

今日作成したアプリをgithubに追加しました。
必要な場合はこちらのリンクをクリックしてください。

今日は以上です。
ありがとうございました。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?