veza/veza-backend-api/internal/handlers/webauthn_handler.go
senke 6a675565e1 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

242 lines
6.5 KiB
Go

package handlers
import (
"encoding/base64"
"fmt"
"net/http"
"time"
"veza-backend-api/internal/services"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
)
// WebAuthnHandler handles FIDO2/WebAuthn passkey endpoints.
// F022: ORIGIN_FEATURES_REGISTRY.md — WebAuthn/Passkeys.
type WebAuthnHandler struct {
webauthnService *services.WebAuthnService
logger *zap.Logger
}
// NewWebAuthnHandler creates a new WebAuthn handler.
func NewWebAuthnHandler(webauthnService *services.WebAuthnService, logger *zap.Logger) *WebAuthnHandler {
return &WebAuthnHandler{
webauthnService: webauthnService,
logger: logger,
}
}
// BeginRegistration starts the passkey registration ceremony.
// POST /auth/passkeys/register/begin
func (h *WebAuthnHandler) BeginRegistration(c *gin.Context) {
userID, exists := c.Get("userID")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"})
return
}
uid, ok := userID.(uuid.UUID)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid user ID"})
return
}
username, _ := c.Get("username")
usernameStr, _ := username.(string)
if usernameStr == "" {
usernameStr = uid.String()
}
ctx, cancel := WithTimeout(c.Request.Context(), 5*time.Second)
defer cancel()
challenge, options, err := h.webauthnService.BeginRegistration(ctx, uid, usernameStr)
if err != nil {
h.logger.Error("WebAuthn begin registration failed", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to begin registration"})
return
}
c.JSON(http.StatusOK, gin.H{
"challenge": challenge.Challenge,
"options": options,
})
}
// FinishRegistrationRequest represents the client's registration response.
type FinishRegistrationRequest struct {
CredentialID string `json:"credential_id" binding:"required"`
PublicKey string `json:"public_key" binding:"required"`
AttestationType string `json:"attestation_type"`
AAGUID string `json:"aaguid"`
Name string `json:"name"`
}
// FinishRegistration completes the passkey registration.
// POST /auth/passkeys/register/finish
func (h *WebAuthnHandler) FinishRegistration(c *gin.Context) {
userID, exists := c.Get("userID")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"})
return
}
uid, ok := userID.(uuid.UUID)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid user ID"})
return
}
var req FinishRegistrationRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
credentialID, err := decodeBase64URL(req.CredentialID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid credential_id encoding"})
return
}
publicKey, err := decodeBase64URL(req.PublicKey)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid public_key encoding"})
return
}
var aaguid []byte
if req.AAGUID != "" {
aaguid, _ = decodeBase64URL(req.AAGUID)
}
attestation := req.AttestationType
if attestation == "" {
attestation = "none"
}
ctx, cancel := WithTimeout(c.Request.Context(), 5*time.Second)
defer cancel()
cred, err := h.webauthnService.FinishRegistration(ctx, uid, credentialID, publicKey, aaguid, attestation, req.Name)
if err != nil {
h.logger.Error("WebAuthn finish registration failed", zap.Error(err))
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{
"credential": cred.ToPublicInfo(),
})
}
// ListPasskeys returns the user's registered passkeys.
// GET /auth/passkeys
func (h *WebAuthnHandler) ListPasskeys(c *gin.Context) {
userID, exists := c.Get("userID")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"})
return
}
uid, ok := userID.(uuid.UUID)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid user ID"})
return
}
ctx, cancel := WithTimeout(c.Request.Context(), 5*time.Second)
defer cancel()
creds, err := h.webauthnService.GetUserCredentials(ctx, uid)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list passkeys"})
return
}
result := make([]interface{}, 0, len(creds))
for _, cr := range creds {
result = append(result, cr.ToPublicInfo())
}
c.JSON(http.StatusOK, gin.H{"passkeys": result})
}
// DeletePasskey removes a registered passkey.
// DELETE /auth/passkeys/:id
func (h *WebAuthnHandler) DeletePasskey(c *gin.Context) {
userID, exists := c.Get("userID")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"})
return
}
uid, ok := userID.(uuid.UUID)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid user ID"})
return
}
credID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid passkey ID"})
return
}
ctx, cancel := WithTimeout(c.Request.Context(), 5*time.Second)
defer cancel()
if err := h.webauthnService.DeleteCredential(ctx, uid, credID); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "passkey deleted"})
}
// RenamePasskeyRequest holds the new name for a passkey.
type RenamePasskeyRequest struct {
Name string `json:"name" binding:"required,max=100"`
}
// RenamePasskey updates the display name of a passkey.
// PUT /auth/passkeys/:id
func (h *WebAuthnHandler) RenamePasskey(c *gin.Context) {
userID, exists := c.Get("userID")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"})
return
}
uid, ok := userID.(uuid.UUID)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid user ID"})
return
}
credID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid passkey ID"})
return
}
var req RenamePasskeyRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
ctx, cancel := WithTimeout(c.Request.Context(), 5*time.Second)
defer cancel()
if err := h.webauthnService.RenameCredential(ctx, uid, credID, req.Name); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "passkey renamed"})
}
// decodeBase64URL decodes a base64url-encoded string (no padding).
func decodeBase64URL(s string) ([]byte, error) {
b, err := base64.RawURLEncoding.DecodeString(s)
if err != nil {
return nil, fmt.Errorf("invalid base64url encoding: %w", err)
}
return b, nil
}