Implement refresh token rotation handling to prevent duplicate refresh attempts
- Enhance the OAuth authentication flow to allow concurrent requests to safely handle expired tokens without triggering multiple refresh attempts. - Introduce a mechanism to mark refresh tokens as being rotated, ensuring that only one request processes the refresh while others can proceed with valid claims. - Update the guard logic to reflect these changes, improving the overall efficiency and reliability of token management.
This commit is contained in:
parent
a0307d4d6d
commit
a6d91c866d
2 changed files with 47 additions and 11 deletions
|
|
@ -67,14 +67,21 @@ func (s *Service) Authenticate(c *gin.Context) bool {
|
||||||
|
|
||||||
// Signature valid but expired — attempt auto refresh
|
// Signature valid but expired — attempt auto refresh
|
||||||
if !expiredClaims.ExpiresAt.IsZero() && expiredClaims.ExpiresAt.Before(time.Now()) {
|
if !expiredClaims.ExpiresAt.IsZero() && expiredClaims.ExpiresAt.Before(time.Now()) {
|
||||||
newClaims, refreshErr := s.TryRefreshToken(c, expiredClaims)
|
if refreshToken := s.getRefreshToken(c); s.IsRefreshing(refreshToken) {
|
||||||
if refreshErr != nil {
|
// Another request is already rotating this refresh token.
|
||||||
log.Error("[OAuth] Token refresh failed: %v", refreshErr)
|
// The signature has been verified, so we can safely let
|
||||||
response.RespondWithError(c, http.StatusUnauthorized, types.ErrInvalidRefreshToken)
|
// this request through with the expired-but-authentic claims.
|
||||||
c.Abort()
|
claims = expiredClaims
|
||||||
return false
|
} else {
|
||||||
|
newClaims, refreshErr := s.TryRefreshToken(c, expiredClaims)
|
||||||
|
if refreshErr != nil {
|
||||||
|
log.Error("[OAuth] Token refresh failed: %v", refreshErr)
|
||||||
|
response.RespondWithError(c, http.StatusUnauthorized, types.ErrInvalidRefreshToken)
|
||||||
|
c.Abort()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
claims = newClaims
|
||||||
}
|
}
|
||||||
claims = newClaims
|
|
||||||
} else {
|
} else {
|
||||||
response.RespondWithError(c, http.StatusUnauthorized, types.ErrInvalidToken)
|
response.RespondWithError(c, http.StatusUnauthorized, types.ErrInvalidToken)
|
||||||
c.Abort()
|
c.Abort()
|
||||||
|
|
@ -104,6 +111,13 @@ func (s *Service) TryRefreshToken(c *gin.Context, expiredClaims *types.TokenClai
|
||||||
return nil, fmt.Errorf("refresh token missing")
|
return nil, fmt.Errorf("refresh token missing")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mark this refresh token as being rotated so concurrent requests
|
||||||
|
// can detect the in-flight rotation and skip duplicate refresh attempts.
|
||||||
|
// TTL acts as a safety net in case defer doesn't run (e.g. process crash).
|
||||||
|
refreshingKey := s.refreshingCacheKey(refreshToken)
|
||||||
|
s.cache.Set(refreshingKey, true, 30*time.Second)
|
||||||
|
defer s.cache.Del(refreshingKey)
|
||||||
|
|
||||||
refreshClaims, err := s.VerifyRefreshToken(refreshToken)
|
refreshClaims, err := s.VerifyRefreshToken(refreshToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("invalid or expired refresh token: %w", err)
|
return nil, fmt.Errorf("invalid or expired refresh token: %w", err)
|
||||||
|
|
@ -244,6 +258,21 @@ func (s *Service) GetRefreshToken(c *gin.Context) string {
|
||||||
return s.getRefreshToken(c)
|
return s.getRefreshToken(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsRefreshing reports whether the given refresh token is currently being
|
||||||
|
// rotated by another request. Callers can use this to avoid duplicate
|
||||||
|
// refresh attempts that would fail because the old token has been revoked.
|
||||||
|
func (s *Service) IsRefreshing(refreshToken string) bool {
|
||||||
|
if refreshToken == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, ok := s.cache.Get(s.refreshingCacheKey(refreshToken))
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) refreshingCacheKey(refreshToken string) string {
|
||||||
|
return fmt.Sprintf("%soauth:refreshing:%s", s.prefix, refreshToken)
|
||||||
|
}
|
||||||
|
|
||||||
// GetSessionID gets the session ID from the request (public method)
|
// GetSessionID gets the session ID from the request (public method)
|
||||||
func (s *Service) GetSessionID(c *gin.Context) string {
|
func (s *Service) GetSessionID(c *gin.Context) string {
|
||||||
return s.getSessionID(c)
|
return s.getSessionID(c)
|
||||||
|
|
|
||||||
|
|
@ -122,11 +122,18 @@ func guardOAuth(r *Request) error {
|
||||||
expiredClaims, expErr := oauth.OAuth.VerifyTokenAllowExpired(token)
|
expiredClaims, expErr := oauth.OAuth.VerifyTokenAllowExpired(token)
|
||||||
if expErr == nil && expiredClaims != nil &&
|
if expErr == nil && expiredClaims != nil &&
|
||||||
!expiredClaims.ExpiresAt.IsZero() && expiredClaims.ExpiresAt.Before(time.Now()) {
|
!expiredClaims.ExpiresAt.IsZero() && expiredClaims.ExpiresAt.Before(time.Now()) {
|
||||||
refreshed, refreshErr := oauth.OAuth.TryRefreshToken(c, expiredClaims)
|
if refreshToken := oauth.OAuth.GetRefreshToken(c); oauth.OAuth.IsRefreshing(refreshToken) {
|
||||||
if refreshErr != nil {
|
// Another request is already rotating this refresh token.
|
||||||
return fmt.Errorf("Exception|401:Token expired and refresh failed")
|
// The signature has been verified, so we can safely let
|
||||||
|
// this request through with the expired-but-authentic claims.
|
||||||
|
claims = expiredClaims
|
||||||
|
} else {
|
||||||
|
refreshed, refreshErr := oauth.OAuth.TryRefreshToken(c, expiredClaims)
|
||||||
|
if refreshErr != nil {
|
||||||
|
return fmt.Errorf("Exception|401:Token expired and refresh failed")
|
||||||
|
}
|
||||||
|
claims = refreshed
|
||||||
}
|
}
|
||||||
claims = refreshed
|
|
||||||
} else {
|
} else {
|
||||||
return fmt.Errorf("Exception|401:Invalid token")
|
return fmt.Errorf("Exception|401:Invalid token")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue