veza/veza-backend-api/internal/api/routes_auth.go
senke 95682e3029 feat(v0.13.3): complete - Polish Sécurité Avancée
TASK-SECADV-001: WebAuthn/Passkeys (F022)
- WebAuthn credential model, service, handler
- Registration/authentication ceremony endpoints
- CRUD operations (list, rename, delete passkeys)
- Routes: GET/POST/PUT/DELETE /auth/passkeys/*

TASK-SECADV-002: Configurable password policy (F015)
- PasswordPolicyConfig with MinLength, MaxLength, RequireUpper/Lower/Number/Special
- NewPasswordValidatorWithPolicy constructor
- PasswordPolicyFromEnv() reads env vars (PASSWORD_MIN_LENGTH, etc.)
- All character class checks now respect policy configuration

TASK-SECADV-003: Géolocalisation connexions (F025)
- GeoIPResolver interface + GeoIPService implementation
- Country/city columns added to login_history table
- LoginHistoryService.Record() performs GeoIP lookup
- GetUserHistory returns geolocation data
- GET /auth/login-history endpoint

TASK-SECADV-004: Password expiration (F016)
- password_changed_at column on users table
- CheckPasswordExpiration() method on PasswordService
- All password change/reset methods now set password_changed_at
- NewPasswordServiceWithPolicy() supports expiration days config

Migration: 971_security_advanced_v0133.sql

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:09:01 +01:00

245 lines
9.4 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.JWTPrivateKeyPath, r.config.JWTPublicKeyPath, 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)
// SFIX-001: Wire TwoFactorService as MFA checker on AuthMiddleware
if r.config.AuthMiddleware != nil {
r.config.AuthMiddleware.SetTwoFactorChecker(twoFactorService)
}
// 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)
}
// F022: WebAuthn/Passkeys routes (v0.13.3)
rpID := r.config.AppDomain
if rpID == "" {
rpID = "localhost"
}
webauthnService := services.NewWebAuthnService(r.db, r.logger, rpID, "Veza")
webauthnHandler := handlers.NewWebAuthnHandler(webauthnService, r.logger)
{
protected.GET("/passkeys", webauthnHandler.ListPasskeys)
protected.POST("/passkeys/register/begin", webauthnHandler.BeginRegistration)
protected.POST("/passkeys/register/finish", webauthnHandler.FinishRegistration)
protected.PUT("/passkeys/:id", webauthnHandler.RenamePasskey)
protected.DELETE("/passkeys/:id", webauthnHandler.DeletePasskey)
}
// F024/F025: Login history with geolocation (v0.13.3)
loginHistoryService := services.NewLoginHistoryService(r.db, r.logger)
geoIPService := services.NewGeoIPService(r.logger)
loginHistoryService.SetGeoIPResolver(geoIPService)
protected.GET("/login-history", handlers.GetLoginHistory(loginHistoryService, r.logger))
}
}
return nil
}