- PKCE (S256) in OAuth flow: code_verifier in oauth_states, code_challenge in auth URL - CryptoService: AES-256-GCM encryption for OAuth provider tokens at rest - OAuth redirect URL validated against OAUTH_ALLOWED_REDIRECT_DOMAINS - CHAT_JWT_SECRET must differ from JWT_SECRET in production - Migration script: cmd/tools/encrypt_oauth_tokens for existing tokens - Fixes: VEZA-SEC-003, VEZA-SEC-004, VEZA-SEC-009, VEZA-SEC-010
219 lines
8.2 KiB
Go
219 lines
8.2 KiB
Go
package api
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
authcore "veza-backend-api/internal/core/auth"
|
|
"veza-backend-api/internal/handlers"
|
|
"veza-backend-api/internal/repositories"
|
|
"veza-backend-api/internal/services"
|
|
"veza-backend-api/internal/validators"
|
|
)
|
|
|
|
// setupAuthRoutes configure les routes d'authentification avec toutes les dépendances
|
|
func (r *APIRouter) setupAuthRoutes(router *gin.RouterGroup) error {
|
|
// 1. Instanciation des dépendances
|
|
emailValidator := validators.NewEmailValidator(r.db.GormDB)
|
|
passwordValidator := validators.NewPasswordValidator()
|
|
passwordService := services.NewPasswordService(r.db, r.logger)
|
|
passwordResetService := services.NewPasswordResetService(r.db, r.logger)
|
|
jwtService, err := services.NewJWTService(r.config.JWTSecret, r.config.JWTIssuer, r.config.JWTAudience)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to initialize JWT service: %w", err)
|
|
}
|
|
refreshTokenService := services.NewRefreshTokenService(r.db.GormDB)
|
|
emailVerificationService := services.NewEmailVerificationService(r.db, r.logger)
|
|
emailService := services.NewEmailService(r.db, r.logger)
|
|
sessionService := services.NewSessionService(r.db, r.logger)
|
|
refreshLock := services.NewRefreshLock(r.config.RedisClient)
|
|
|
|
// 2. Service Auth complet
|
|
authService := authcore.NewAuthService(
|
|
r.db.GormDB,
|
|
emailValidator,
|
|
passwordValidator,
|
|
passwordService,
|
|
jwtService,
|
|
refreshTokenService,
|
|
emailVerificationService,
|
|
passwordResetService,
|
|
emailService,
|
|
r.config.JobWorker,
|
|
refreshLock,
|
|
r.logger,
|
|
)
|
|
|
|
// BE-SEC-007: Initialize account lockout service and set it on auth service
|
|
if r.config.RedisClient != nil {
|
|
lockoutConfig := &services.AccountLockoutConfig{
|
|
MaxAttempts: 5,
|
|
LockoutDuration: 30 * time.Minute,
|
|
WindowDuration: 15 * time.Minute,
|
|
ExemptEmails: r.config.AccountLockoutExemptEmails,
|
|
}
|
|
accountLockoutService := services.NewAccountLockoutServiceWithConfig(r.config.RedisClient, r.logger, lockoutConfig)
|
|
authService.SetAccountLockoutService(accountLockoutService)
|
|
} else {
|
|
r.logger.Warn("Redis not available - account lockout disabled")
|
|
}
|
|
r.authService = authService
|
|
|
|
// 2.5. User Service for GetMe endpoint
|
|
userRepo := repositories.NewGormUserRepository(r.db.GormDB)
|
|
userService := services.NewUserServiceWithDB(userRepo, r.db.GormDB)
|
|
|
|
// 3. Handlers
|
|
authGroup := router.Group("/auth")
|
|
{
|
|
// BE-SEC-005: Apply rate limiting to register endpoint (A04: toujours actif)
|
|
registerGroup := authGroup.Group("/register")
|
|
if r.config.EndpointLimiter != nil {
|
|
registerGroup.Use(r.config.EndpointLimiter.RegisterRateLimit())
|
|
}
|
|
registerGroup.POST("", handlers.Register(authService, sessionService, r.logger, r.config))
|
|
|
|
// BE-API-001: Initialize 2FA service for login handler
|
|
twoFactorService := services.NewTwoFactorService(r.db, r.logger)
|
|
|
|
// Apply rate limiting to login endpoint (PR-3)
|
|
loginGroup := authGroup.Group("/login")
|
|
if r.config.EndpointLimiter != nil {
|
|
loginGroup.Use(r.config.EndpointLimiter.LoginRateLimit())
|
|
}
|
|
loginGroup.POST("", handlers.Login(authService, sessionService, twoFactorService, r.logger, r.config))
|
|
loginGroup.POST("/2fa", handlers.LoginWith2FA(authService, sessionService, twoFactorService, r.logger, r.config))
|
|
|
|
// SEC-010: Rate limit refresh to prevent token grinding
|
|
refreshGroup := authGroup.Group("")
|
|
if r.config.EndpointLimiter != nil {
|
|
refreshGroup.Use(r.config.EndpointLimiter.RefreshRateLimit())
|
|
}
|
|
refreshGroup.POST("/refresh", handlers.Refresh(authService, sessionService, r.logger, r.config))
|
|
|
|
// BE-SEC-005: Apply rate limiting to email verification endpoints
|
|
verifyEmailGroup := authGroup.Group("/verify-email")
|
|
if r.config.EndpointLimiter != nil {
|
|
verifyEmailGroup.Use(r.config.EndpointLimiter.VerifyEmailRateLimit())
|
|
}
|
|
verifyEmailGroup.POST("", handlers.VerifyEmail(authService))
|
|
|
|
resendVerificationGroup := authGroup.Group("/resend-verification")
|
|
if r.config.EndpointLimiter != nil {
|
|
resendVerificationGroup.Use(r.config.EndpointLimiter.ResendVerificationRateLimit())
|
|
}
|
|
resendVerificationGroup.POST("", handlers.ResendVerification(authService, r.logger))
|
|
|
|
// SEC-009: Rate limit check-username to prevent enumeration
|
|
checkUsernameGroup := authGroup.Group("")
|
|
if r.config.EndpointLimiter != nil {
|
|
checkUsernameGroup.Use(r.config.EndpointLimiter.CheckUsernameRateLimit())
|
|
}
|
|
checkUsernameGroup.GET("/check-username", handlers.CheckUsername(authService))
|
|
|
|
// BE-API-042: OAuth routes (v0.902: CryptoService, redirect validation)
|
|
oauthCfg := &services.OAuthServiceConfig{
|
|
FrontendURL: r.config.FrontendURL,
|
|
AllowedDomains: r.config.OAuthAllowedRedirectDomains,
|
|
}
|
|
if r.config.OAuthEncryptionKey != "" {
|
|
var cryptoService *services.CryptoService
|
|
var err error
|
|
cryptoService, err = services.NewCryptoServiceFromBase64(r.config.OAuthEncryptionKey)
|
|
if err != nil {
|
|
// Fallback: use raw bytes if key is long enough
|
|
keyBytes := []byte(r.config.OAuthEncryptionKey)
|
|
if len(keyBytes) >= 32 {
|
|
cryptoService, err = services.NewCryptoService(keyBytes)
|
|
}
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("OAuth CryptoService: %w", err)
|
|
}
|
|
if cryptoService != nil {
|
|
oauthCfg.CryptoService = cryptoService
|
|
}
|
|
}
|
|
oauthService := services.NewOAuthService(r.db, r.logger, jwtService, sessionService, userService, oauthCfg)
|
|
baseURL := os.Getenv("BASE_URL")
|
|
if baseURL == "" {
|
|
appDomain := os.Getenv("APP_DOMAIN")
|
|
if appDomain == "" {
|
|
appDomain = "veza.fr"
|
|
}
|
|
baseURL = "http://" + appDomain + ":8080"
|
|
}
|
|
googleClientID := os.Getenv("OAUTH_GOOGLE_CLIENT_ID")
|
|
googleClientSecret := os.Getenv("OAUTH_GOOGLE_CLIENT_SECRET")
|
|
githubClientID := os.Getenv("OAUTH_GITHUB_CLIENT_ID")
|
|
githubClientSecret := os.Getenv("OAUTH_GITHUB_CLIENT_SECRET")
|
|
discordClientID := os.Getenv("OAUTH_DISCORD_CLIENT_ID")
|
|
discordClientSecret := os.Getenv("OAUTH_DISCORD_CLIENT_SECRET")
|
|
spotifyClientID := os.Getenv("OAUTH_SPOTIFY_CLIENT_ID")
|
|
spotifyClientSecret := os.Getenv("OAUTH_SPOTIFY_CLIENT_SECRET")
|
|
|
|
hasAnyOAuth := (googleClientID != "" && googleClientSecret != "") ||
|
|
(githubClientID != "" && githubClientSecret != "") ||
|
|
(discordClientID != "" && discordClientSecret != "") ||
|
|
(spotifyClientID != "" && spotifyClientSecret != "")
|
|
if hasAnyOAuth {
|
|
oauthService.InitializeConfigs(googleClientID, googleClientSecret, githubClientID, githubClientSecret, discordClientID, discordClientSecret, spotifyClientID, spotifyClientSecret, baseURL)
|
|
}
|
|
|
|
oauthHandler := handlers.NewOAuthHandler(oauthService, r.logger, r.config.CORSOrigins, r.config.FrontendURL, r.config)
|
|
oauthGroup := authGroup.Group("/oauth")
|
|
{
|
|
oauthGroup.GET("/providers", oauthHandler.GetOAuthProviders)
|
|
oauthGroup.GET("/:provider", oauthHandler.InitiateOAuth)
|
|
oauthGroup.GET("/:provider/callback", oauthHandler.OAuthCallback)
|
|
}
|
|
|
|
// Password reset routes (public)
|
|
passwordGroup := authGroup.Group("/password")
|
|
if r.config.EndpointLimiter != nil {
|
|
passwordGroup.Use(r.config.EndpointLimiter.PasswordResetRateLimit())
|
|
}
|
|
{
|
|
auditService := services.NewAuditService(r.db, r.logger)
|
|
passwordGroup.POST("/reset-request", handlers.RequestPasswordReset(
|
|
passwordResetService,
|
|
passwordService,
|
|
emailService,
|
|
auditService,
|
|
r.logger,
|
|
))
|
|
passwordGroup.POST("/reset", handlers.ResetPassword(
|
|
passwordResetService,
|
|
passwordService,
|
|
authService,
|
|
sessionService,
|
|
auditService,
|
|
r.logger,
|
|
))
|
|
}
|
|
|
|
// Protected routes (authentification JWT requise)
|
|
protected := authGroup.Group("")
|
|
protected.Use(r.config.AuthMiddleware.RequireAuth())
|
|
r.applyCSRFProtection(protected)
|
|
{
|
|
protected.POST("/logout", handlers.Logout(authService, sessionService, r.logger, r.config))
|
|
protected.GET("/me", handlers.GetMe(userService))
|
|
// SEC-03: Ephemeral token for HLS/WebSocket (httpOnly cookies prevent direct access)
|
|
protected.POST("/stream-token", handlers.GenerateStreamToken(userService, jwtService))
|
|
|
|
twoFactorHandler := handlers.NewTwoFactorHandler(twoFactorService, userService, r.logger)
|
|
{
|
|
protected.POST("/2fa/setup", twoFactorHandler.SetupTwoFactor)
|
|
protected.POST("/2fa/verify", twoFactorHandler.VerifyTwoFactor)
|
|
protected.POST("/2fa/disable", twoFactorHandler.DisableTwoFactor)
|
|
protected.GET("/2fa/status", twoFactorHandler.GetTwoFactorStatus)
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|