From db7b54c5d7224218eb3516ddec3e2fac94211ae2 Mon Sep 17 00:00:00 2001 From: Aaron Liu Date: Fri, 23 May 2025 16:49:01 +0800 Subject: [PATCH] feat(session): sign out and revoke root token --- assets | 2 +- pkg/auth/jwt.go | 49 +++++++++++++++++++++++++++++++++---- routers/controllers/user.go | 13 ++++++++-- routers/router.go | 6 +++-- service/user/login.go | 18 +++++++++++++- service/user/passkey.go | 8 +++--- 6 files changed, 81 insertions(+), 15 deletions(-) diff --git a/assets b/assets index 5c58055..59bb76d 160000 --- a/assets +++ b/assets @@ -1 +1 @@ -Subproject commit 5c580559714d6650b3e61dccfcb751adb6cb3db3 +Subproject commit 59bb76d3fc86fb39e6ad4e3a5f53c3df9092f476 diff --git a/pkg/auth/jwt.go b/pkg/auth/jwt.go index 9fe0d0c..7791bf3 100644 --- a/pkg/auth/jwt.go +++ b/pkg/auth/jwt.go @@ -11,23 +11,27 @@ import ( "github.com/cloudreve/Cloudreve/v4/ent" "github.com/cloudreve/Cloudreve/v4/inventory" + "github.com/cloudreve/Cloudreve/v4/pkg/cache" "github.com/cloudreve/Cloudreve/v4/pkg/hashid" "github.com/cloudreve/Cloudreve/v4/pkg/logging" "github.com/cloudreve/Cloudreve/v4/pkg/serializer" "github.com/cloudreve/Cloudreve/v4/pkg/setting" "github.com/cloudreve/Cloudreve/v4/pkg/util" "github.com/gin-gonic/gin" + "github.com/gofrs/uuid" "github.com/golang-jwt/jwt/v5" ) type TokenAuth interface { // Issue issues a new pair of credentials for the given user. - Issue(ctx context.Context, u *ent.User) (*Token, error) + Issue(ctx context.Context, u *ent.User, rootTokenID *uuid.UUID) (*Token, error) // VerifyAndRetrieveUser verifies the given token and inject the user into current context. // Returns if upper caller should continue process other session provider. VerifyAndRetrieveUser(c *gin.Context) (bool, error) // Refresh refreshes the given refresh token and returns a new pair of credentials. Refresh(ctx context.Context, refreshToken string) (*Token, error) + // Claims parses the given token string and returns the claims. + Claims(ctx context.Context, tokenStr string) (*Claims, error) } // Token stores token pair for authentication @@ -56,12 +60,14 @@ var ( const ( AuthorizationHeader = "Authorization" TokenHeaderPrefix = "Bearer " + RevokeTokenPrefix = "jwt_revoke_" ) type Claims struct { TokenType TokenType `json:"token_type"` jwt.RegisteredClaims - StateHash []byte `json:"state_hash,omitempty"` + StateHash []byte `json:"state_hash,omitempty"` + RootTokenID *uuid.UUID `json:"root_token_id,omitempty"` } // NewTokenAuth creates a new token based auth provider. @@ -81,6 +87,24 @@ type tokenAuth struct { s setting.Provider secret []byte userClient inventory.UserClient + kv cache.Driver +} + +func (t *tokenAuth) Claims(ctx context.Context, tokenStr string) (*Claims, error) { + token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(token *jwt.Token) (interface{}, error) { + return t.secret, nil + }) + + if err != nil { + return nil, fmt.Errorf("invalid token: %w", err) + } + + claims, ok := token.Claims.(*Claims) + if !ok { + return nil, fmt.Errorf("invalid token claims") + } + + return claims, nil } func (t *tokenAuth) Refresh(ctx context.Context, refreshToken string) (*Token, error) { @@ -113,7 +137,17 @@ func (t *tokenAuth) Refresh(ctx context.Context, refreshToken string) (*Token, e return nil, ErrInvalidRefreshToken } - return t.Issue(ctx, expectedUser) + // Check if root token is revoked + if claims.RootTokenID == nil { + return nil, ErrInvalidRefreshToken + } + + _, ok = t.kv.Get(fmt.Sprintf("%s%s", RevokeTokenPrefix, claims.RootTokenID.String())) + if ok { + return nil, ErrInvalidRefreshToken + } + + return t.Issue(ctx, expectedUser, claims.RootTokenID) } func (t *tokenAuth) VerifyAndRetrieveUser(c *gin.Context) (bool, error) { @@ -151,12 +185,16 @@ func (t *tokenAuth) VerifyAndRetrieveUser(c *gin.Context) (bool, error) { return false, nil } -func (t *tokenAuth) Issue(ctx context.Context, u *ent.User) (*Token, error) { +func (t *tokenAuth) Issue(ctx context.Context, u *ent.User, rootTokenID *uuid.UUID) (*Token, error) { uidEncoded := hashid.EncodeUserID(t.idEncoder, u.ID) tokenSettings := t.s.TokenAuth(ctx) issueDate := time.Now() accessTokenExpired := time.Now().Add(tokenSettings.AccessTokenTTL) refreshTokenExpired := time.Now().Add(tokenSettings.RefreshTokenTTL) + if rootTokenID == nil { + newRootTokenID := uuid.Must(uuid.NewV4()) + rootTokenID = &newRootTokenID + } accessToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, Claims{ TokenType: TokenTypeAccess, @@ -172,7 +210,8 @@ func (t *tokenAuth) Issue(ctx context.Context, u *ent.User) (*Token, error) { userHash := t.hashUserState(ctx, u) refreshToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, Claims{ - TokenType: TokenTypeRefresh, + TokenType: TokenTypeRefresh, + RootTokenID: rootTokenID, RegisteredClaims: jwt.RegisteredClaims{ Subject: uidEncoded, NotBefore: jwt.NewNumericDate(issueDate), diff --git a/routers/controllers/user.go b/routers/controllers/user.go index ca7d180..84f1c5a 100644 --- a/routers/controllers/user.go +++ b/routers/controllers/user.go @@ -172,8 +172,17 @@ func UserActivate(c *gin.Context) { // UserSignOut 用户退出登录 func UserSignOut(c *gin.Context) { - util.DeleteSession(c, "user_id") - c.JSON(200, serializer.Response{}) + service := ParametersFromContext[*user.RefreshTokenService](c, user.RefreshTokenParameterCtx{}) + res, err := service.Delete(c) + if err != nil { + c.JSON(200, serializer.Err(c, err)) + c.Abort() + return + } + + c.JSON(200, serializer.Response{ + Data: res, + }) } // UserMe 获取当前登录的用户 diff --git a/routers/router.go b/routers/router.go index 953c1e5..6b17260 100644 --- a/routers/router.go +++ b/routers/router.go @@ -299,6 +299,10 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine { controllers.FromJSON[usersvc.RefreshTokenService](usersvc.RefreshTokenParameterCtx{}), controllers.UserRefreshToken, ) + token.DELETE("", + controllers.FromJSON[usersvc.RefreshTokenService](usersvc.RefreshTokenParameterCtx{}), + controllers.UserSignOut, + ) } // Prepare login @@ -1057,8 +1061,6 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine { controllers.FromQuery[usersvc.SearchUserService](usersvc.SearchUserParamCtx{}), controllers.UserSearch, ) - // 退出登录 - user.DELETE("session", controllers.UserSignOut) // WebAuthn 注册相关 authn := user.Group("authn", diff --git a/service/user/login.go b/service/user/login.go index 4c8e19e..b0aa086 100644 --- a/service/user/login.go +++ b/service/user/login.go @@ -159,7 +159,7 @@ type ( func IssueToken(c *gin.Context) (*BuiltinLoginResponse, error) { dep := dependency.FromContext(c) u := inventory.UserFromContext(c) - token, err := dep.TokenAuth().Issue(c, u) + token, err := dep.TokenAuth().Issue(c, u, nil) if err != nil { return nil, serializer.NewError(serializer.CodeEncryptError, "Failed to issue token pair", err) } @@ -188,6 +188,22 @@ func (s *RefreshTokenService) Refresh(c *gin.Context) (*auth.Token, error) { return token, nil } +func (s *RefreshTokenService) Delete(c *gin.Context) (string, error) { + dep := dependency.FromContext(c) + claims, err := dep.TokenAuth().Claims(c, s.RefreshToken) + if err != nil { + return "", serializer.NewError(serializer.CodeCredentialInvalid, "Failed to parse token", err) + } + + // Block root token + if claims.RootTokenID != nil { + tokenSettings := dep.SettingProvider().TokenAuth(c) + dep.KV().Set(fmt.Sprintf("%s%s", auth.RevokeTokenPrefix, claims.RootTokenID.String()), true, int(tokenSettings.AccessTokenTTL.Seconds()+10)) + } + + return "", nil +} + type ( OtpValidationParameterCtx struct{} OtpValidationService struct { diff --git a/service/user/passkey.go b/service/user/passkey.go index b238362..4fa928d 100644 --- a/service/user/passkey.go +++ b/service/user/passkey.go @@ -70,7 +70,7 @@ func PreparePasskeyLogin(c *gin.Context) (*PreparePasskeyLoginResponse, error) { } sessionID := uuid.Must(uuid.NewV4()).String() - if err := dep.KV().Set(fmt.Sprint("%s%s", authnSessionKey, sessionID), *sessionData, 300); err != nil { + if err := dep.KV().Set(fmt.Sprintf("%s%s", authnSessionKey, sessionID), *sessionData, 300); err != nil { return nil, serializer.NewError(serializer.CodeInternalSetting, "Failed to store session data", err) } @@ -93,7 +93,7 @@ func (s *FinishPasskeyLoginService) FinishPasskeyLogin(c *gin.Context) (*ent.Use kv := dep.KV() userClient := dep.UserClient() - sessionDataRaw, ok := kv.Get(fmt.Sprint("%s%s", authnSessionKey, s.SessionID)) + sessionDataRaw, ok := kv.Get(fmt.Sprintf("%s%s", authnSessionKey, s.SessionID)) if !ok { return nil, serializer.NewError(serializer.CodeNotFound, "Session not found", nil) } @@ -192,7 +192,7 @@ func PreparePasskeyRegister(c *gin.Context) (*protocol.CredentialCreation, error return nil, serializer.NewError(serializer.CodeInitializeAuthn, "Failed to begin registration", err) } - if err := dep.KV().Set(fmt.Sprint("%s%d", authnSessionKey, u.ID), *sessionData, 300); err != nil { + if err := dep.KV().Set(fmt.Sprintf("%s%d", authnSessionKey, u.ID), *sessionData, 300); err != nil { return nil, serializer.NewError(serializer.CodeInternalSetting, "Failed to store session data", err) } @@ -213,7 +213,7 @@ func (s *FinishPasskeyRegisterService) FinishPasskeyRegister(c *gin.Context) (*P kv := dep.KV() u := inventory.UserFromContext(c) - sessionDataRaw, ok := kv.Get(fmt.Sprint("%s%d", authnSessionKey, u.ID)) + sessionDataRaw, ok := kv.Get(fmt.Sprintf("%s%d", authnSessionKey, u.ID)) if !ok { return nil, serializer.NewError(serializer.CodeNotFound, "Session not found", nil) }