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>
242 lines
6.5 KiB
Go
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
|
|
}
|