Initial commit: Open sourcing all of the Maple Open Technologies code.
This commit is contained in:
commit
755d54a99d
2010 changed files with 448675 additions and 0 deletions
177
cloud/maplefile-backend/internal/service/auth/refresh_token.go
Normal file
177
cloud/maplefile-backend/internal/service/auth/refresh_token.go
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
// codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/auth/refresh_token.go
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/gocql/gocql"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
|
||||
uc_user "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/usecase/user"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/auditlog"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/security/hash"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/security/jwt"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/storage/cache/cassandracache"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/transaction"
|
||||
)
|
||||
|
||||
type RefreshTokenRequestDTO struct {
|
||||
RefreshToken string `json:"value"`
|
||||
}
|
||||
|
||||
type RefreshTokenResponseDTO struct {
|
||||
Message string `json:"message"`
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
AccessTokenExpiryDate string `json:"access_token_expiry_date"`
|
||||
RefreshTokenExpiryDate string `json:"refresh_token_expiry_date"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
type RefreshTokenService interface {
|
||||
Execute(ctx context.Context, req *RefreshTokenRequestDTO) (*RefreshTokenResponseDTO, error)
|
||||
}
|
||||
|
||||
type refreshTokenServiceImpl struct {
|
||||
config *config.Config
|
||||
logger *zap.Logger
|
||||
auditLogger auditlog.AuditLogger
|
||||
cache cassandracache.CassandraCacher
|
||||
jwtProvider jwt.JWTProvider
|
||||
userGetByIDUC uc_user.UserGetByIDUseCase
|
||||
}
|
||||
|
||||
func NewRefreshTokenService(
|
||||
config *config.Config,
|
||||
logger *zap.Logger,
|
||||
auditLogger auditlog.AuditLogger,
|
||||
cache cassandracache.CassandraCacher,
|
||||
jwtProvider jwt.JWTProvider,
|
||||
userGetByIDUC uc_user.UserGetByIDUseCase,
|
||||
) RefreshTokenService {
|
||||
return &refreshTokenServiceImpl{
|
||||
config: config,
|
||||
logger: logger.Named("RefreshTokenService"),
|
||||
auditLogger: auditLogger,
|
||||
cache: cache,
|
||||
jwtProvider: jwtProvider,
|
||||
userGetByIDUC: userGetByIDUC,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *refreshTokenServiceImpl) Execute(ctx context.Context, req *RefreshTokenRequestDTO) (*RefreshTokenResponseDTO, error) {
|
||||
// Create SAGA for token refresh workflow
|
||||
saga := transaction.NewSaga("refresh-token", s.logger)
|
||||
|
||||
s.logger.Info("starting token refresh")
|
||||
|
||||
// Step 1: Validate refresh token JWT
|
||||
userID, err := s.jwtProvider.ProcessJWTToken(req.RefreshToken)
|
||||
if err != nil {
|
||||
s.logger.Warn("Invalid refresh token JWT", zap.Error(err))
|
||||
return nil, fmt.Errorf("invalid refresh token")
|
||||
}
|
||||
|
||||
// Step 2: Check if refresh token exists in cache
|
||||
// SECURITY: Hash refresh token to match how it was stored (prevents token leakage via cache keys)
|
||||
refreshTokenHash := hash.HashToken(req.RefreshToken)
|
||||
refreshKey := fmt.Sprintf("refresh:%s", refreshTokenHash)
|
||||
cachedUserID, err := s.cache.Get(ctx, refreshKey)
|
||||
if err != nil || cachedUserID == nil {
|
||||
s.logger.Warn("Refresh token not found in cache", zap.String("user_id", userID))
|
||||
return nil, fmt.Errorf("refresh token not found or expired")
|
||||
}
|
||||
|
||||
// Step 3: Verify user IDs match
|
||||
if string(cachedUserID) != userID {
|
||||
s.logger.Warn("User ID mismatch", zap.String("jwt_user_id", userID), zap.String("cached_user_id", string(cachedUserID)))
|
||||
return nil, fmt.Errorf("invalid refresh token")
|
||||
}
|
||||
|
||||
// Step 4: Generate new token pair (token rotation for security)
|
||||
newAccessToken, accessExpiry, newRefreshToken, refreshExpiry, err := s.jwtProvider.GenerateJWTTokenPair(
|
||||
userID,
|
||||
s.config.JWT.AccessTokenDuration,
|
||||
s.config.JWT.RefreshTokenDuration,
|
||||
)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to generate new tokens", zap.Error(err))
|
||||
return nil, fmt.Errorf("failed to generate new tokens")
|
||||
}
|
||||
|
||||
// Step 5: Store NEW refresh token FIRST (compensate: delete new token)
|
||||
// CRITICAL: Store new token before deleting old token to prevent lockout
|
||||
// SECURITY: Hash new refresh token to prevent token leakage via cache key inspection
|
||||
newRefreshTokenHash := hash.HashToken(newRefreshToken)
|
||||
newRefreshKey := fmt.Sprintf("refresh:%s", newRefreshTokenHash)
|
||||
if err := s.cache.SetWithExpiry(ctx, newRefreshKey, []byte(userID), s.config.JWT.RefreshTokenDuration); err != nil {
|
||||
s.logger.Error("Failed to store new refresh token", zap.Error(err))
|
||||
return nil, fmt.Errorf("failed to store new refresh token")
|
||||
}
|
||||
|
||||
// Register compensation: if deletion of old token fails, delete new token
|
||||
newRefreshKeyCaptured := newRefreshKey
|
||||
saga.AddCompensation(func(ctx context.Context) error {
|
||||
s.logger.Info("compensating: deleting new refresh token",
|
||||
zap.String("new_refresh_key", newRefreshKeyCaptured))
|
||||
return s.cache.Delete(ctx, newRefreshKeyCaptured)
|
||||
})
|
||||
|
||||
// Step 6: Delete old refresh token from cache (compensate: restore old token)
|
||||
oldRefreshKeyCaptured := refreshKey
|
||||
oldUserIDCaptured := userID
|
||||
if err := s.cache.Delete(ctx, refreshKey); err != nil {
|
||||
s.logger.Error("Failed to delete old refresh token",
|
||||
zap.String("refresh_key", refreshKey),
|
||||
zap.Error(err))
|
||||
|
||||
// Trigger compensation: Delete new token (restore consistency)
|
||||
saga.Rollback(ctx)
|
||||
return nil, fmt.Errorf("failed to delete old refresh token: %w", err)
|
||||
}
|
||||
|
||||
// Register compensation: restore old token with reduced TTL (1 hour grace period)
|
||||
saga.AddCompensation(func(ctx context.Context) error {
|
||||
s.logger.Info("compensating: restoring old refresh token",
|
||||
zap.String("old_refresh_key", oldRefreshKeyCaptured))
|
||||
// Restore with reduced TTL (1 hour) to allow user retry without long-lived old token
|
||||
return s.cache.SetWithExpiry(ctx, oldRefreshKeyCaptured, []byte(oldUserIDCaptured), 1*time.Hour)
|
||||
})
|
||||
|
||||
// Step 7: Get user to retrieve username/email (read-only, no compensation needed)
|
||||
userUUID, err := gocql.ParseUUID(userID)
|
||||
if err != nil {
|
||||
s.logger.Error("Invalid user ID", zap.Error(err))
|
||||
// No rollback needed for UUID parsing error (tokens already rotated successfully)
|
||||
return nil, fmt.Errorf("invalid user ID")
|
||||
}
|
||||
|
||||
user, err := s.userGetByIDUC.Execute(ctx, userUUID)
|
||||
if err != nil || user == nil {
|
||||
s.logger.Error("User not found", zap.String("user_id", userID), zap.Error(err))
|
||||
// No rollback needed for user lookup error (tokens already rotated successfully)
|
||||
return nil, fmt.Errorf("user not found")
|
||||
}
|
||||
|
||||
s.logger.Info("Token refreshed successfully",
|
||||
zap.String("user_id", userID),
|
||||
zap.String("new_refresh_token", newRefreshToken[:16]+"...")) // Log prefix only for security
|
||||
|
||||
// Audit log token refresh
|
||||
s.auditLogger.LogAuth(ctx, auditlog.EventTypeTokenRefresh, auditlog.OutcomeSuccess,
|
||||
"", "", map[string]string{
|
||||
"user_id": userID,
|
||||
})
|
||||
|
||||
return &RefreshTokenResponseDTO{
|
||||
Message: "Token refreshed successfully",
|
||||
AccessToken: newAccessToken,
|
||||
RefreshToken: newRefreshToken,
|
||||
AccessTokenExpiryDate: accessExpiry.Format(time.RFC3339),
|
||||
RefreshTokenExpiryDate: refreshExpiry.Format(time.RFC3339),
|
||||
Username: user.Email,
|
||||
}, nil
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue