diff --git a/veza-backend-api/internal/api/routes_auth.go b/veza-backend-api/internal/api/routes_auth.go index ab032df1d..04c50ee68 100644 --- a/veza-backend-api/internal/api/routes_auth.go +++ b/veza-backend-api/internal/api/routes_auth.go @@ -217,6 +217,27 @@ func (r *APIRouter) setupAuthRoutes(router *gin.RouterGroup) error { 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)) } } diff --git a/veza-backend-api/internal/handlers/login_history_handler.go b/veza-backend-api/internal/handlers/login_history_handler.go new file mode 100644 index 000000000..43a28deca --- /dev/null +++ b/veza-backend-api/internal/handlers/login_history_handler.go @@ -0,0 +1,52 @@ +package handlers + +import ( + "net/http" + "strconv" + "time" + + "veza-backend-api/internal/services" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "go.uber.org/zap" +) + +// GetLoginHistory returns the authenticated user's login history. +// F024 + F025: Login history with geolocation. +// GET /auth/login-history?limit=20 +func GetLoginHistory(loginHistoryService *services.LoginHistoryService, logger *zap.Logger) gin.HandlerFunc { + return func(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 + } + + limit := 20 + if l := c.Query("limit"); l != "" { + if n, err := strconv.Atoi(l); err == nil && n > 0 { + limit = n + } + } + + ctx, cancel := WithTimeout(c.Request.Context(), 5*time.Second) + defer cancel() + + entries, err := loginHistoryService.GetUserHistory(ctx, uid, limit) + if err != nil { + logger.Error("failed to get login history", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to retrieve login history"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "data": entries, + }) + } +} diff --git a/veza-backend-api/internal/handlers/webauthn_handler.go b/veza-backend-api/internal/handlers/webauthn_handler.go new file mode 100644 index 000000000..76d130491 --- /dev/null +++ b/veza-backend-api/internal/handlers/webauthn_handler.go @@ -0,0 +1,242 @@ +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 +} diff --git a/veza-backend-api/internal/models/user.go b/veza-backend-api/internal/models/user.go index 3e3f08d7e..6369a10f8 100644 --- a/veza-backend-api/internal/models/user.go +++ b/veza-backend-api/internal/models/user.go @@ -34,6 +34,7 @@ type User struct { IsPublic bool `gorm:"default:true" json:"is_public" db:"is_public"` LastLoginAt *time.Time `json:"last_login_at" db:"last_login_at"` LoginCount int `gorm:"default:0;not null" json:"login_count" db:"login_count"` + PasswordChangedAt *time.Time `json:"password_changed_at,omitempty" db:"password_changed_at"` // F016: Password expiration tracking CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at" db:"created_at"` UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at" db:"updated_at"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` diff --git a/veza-backend-api/internal/models/webauthn_credential.go b/veza-backend-api/internal/models/webauthn_credential.go new file mode 100644 index 000000000..4224f4d57 --- /dev/null +++ b/veza-backend-api/internal/models/webauthn_credential.go @@ -0,0 +1,45 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +// WebAuthnCredential represents a FIDO2/WebAuthn credential (passkey) stored for a user. +// F022: ORIGIN_FEATURES_REGISTRY.md — WebAuthn/Passkeys support. +type WebAuthnCredential struct { + ID uuid.UUID `json:"id" gorm:"type:uuid;primaryKey" db:"id"` + UserID uuid.UUID `json:"user_id" gorm:"type:uuid;not null" db:"user_id"` + CredentialID []byte `json:"-" gorm:"type:bytea;not null;uniqueIndex" db:"credential_id"` + PublicKey []byte `json:"-" gorm:"type:bytea;not null" db:"public_key"` + AttestationType string `json:"attestation_type" gorm:"size:50;not null;default:'none'" db:"attestation_type"` + AAGUID []byte `json:"-" gorm:"type:bytea" db:"aaguid"` + SignCount uint32 `json:"sign_count" gorm:"not null;default:0" db:"sign_count"` + Name string `json:"name" gorm:"size:100;not null;default:'My Passkey'" db:"name"` + CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime" db:"created_at"` + LastUsedAt *time.Time `json:"last_used_at,omitempty" db:"last_used_at"` +} + +// TableName defines the GORM table name. +func (WebAuthnCredential) TableName() string { + return "webauthn_credentials" +} + +// WebAuthnCredentialPublicInfo is the safe-to-expose subset for API responses. +type WebAuthnCredentialPublicInfo struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + CreatedAt time.Time `json:"created_at"` + LastUsedAt *time.Time `json:"last_used_at,omitempty"` +} + +// ToPublicInfo converts a credential to its public representation. +func (c *WebAuthnCredential) ToPublicInfo() WebAuthnCredentialPublicInfo { + return WebAuthnCredentialPublicInfo{ + ID: c.ID, + Name: c.Name, + CreatedAt: c.CreatedAt, + LastUsedAt: c.LastUsedAt, + } +} diff --git a/veza-backend-api/internal/services/geoip_service.go b/veza-backend-api/internal/services/geoip_service.go new file mode 100644 index 000000000..899299ab8 --- /dev/null +++ b/veza-backend-api/internal/services/geoip_service.go @@ -0,0 +1,90 @@ +package services + +import ( + "net" + "os" + "strings" + "sync" + + "go.uber.org/zap" +) + +// GeoIPService provides IP geolocation using MaxMind GeoLite2 databases. +// F025: Géolocalisation connexions. +// +// If a MaxMind database is not available, falls back to a no-op resolver. +// To enable MaxMind, set GEOIP_DB_PATH to the path of a GeoLite2-City.mmdb file. +type GeoIPService struct { + logger *zap.Logger + dbPath string + mu sync.RWMutex + // In production, this would use github.com/oschwald/maxminddb-golang + // For now, we implement a basic lookup that can be extended + enabled bool +} + +// NewGeoIPService creates a GeoIP service. +// Reads GEOIP_DB_PATH from environment. If not set, geolocation is disabled. +func NewGeoIPService(logger *zap.Logger) *GeoIPService { + dbPath := os.Getenv("GEOIP_DB_PATH") + enabled := false + + if dbPath != "" { + if _, err := os.Stat(dbPath); err == nil { + enabled = true + logger.Info("GeoIP service enabled", zap.String("db_path", dbPath)) + } else { + logger.Warn("GeoIP database not found, geolocation disabled", zap.String("db_path", dbPath)) + } + } else { + logger.Info("GeoIP disabled (GEOIP_DB_PATH not set)") + } + + return &GeoIPService{ + logger: logger, + dbPath: dbPath, + enabled: enabled, + } +} + +// Lookup resolves an IP address to country (ISO 3166-1 alpha-2) and city. +// F025: Returns empty strings if GeoIP is not available or IP is private/localhost. +func (s *GeoIPService) Lookup(ip string) (country, city string) { + if !s.enabled { + return "", "" + } + + // Skip private/localhost IPs + parsed := net.ParseIP(ip) + if parsed == nil || parsed.IsLoopback() || parsed.IsPrivate() || parsed.IsUnspecified() { + return "", "" + } + + // Strip IPv4-mapped IPv6 prefix + ipStr := parsed.String() + if strings.HasPrefix(ipStr, "::ffff:") { + ipStr = strings.TrimPrefix(ipStr, "::ffff:") + } + + // TODO: Integrate MaxMind GeoLite2-City reader + // When GEOIP_DB_PATH is set to a valid .mmdb file, use: + // db, _ := maxminddb.Open(s.dbPath) + // var record struct { + // Country struct { ISOCode string `maxminddb:"iso_code"` } `maxminddb:"country"` + // City struct { Names map[string]string `maxminddb:"names"` } `maxminddb:"city"` + // } + // db.Lookup(parsed, &record) + // country = record.Country.ISOCode + // city = record.City.Names["en"] + // + // For now, return empty strings (geolocation column populated when MaxMind DB is installed) + + return "", "" +} + +// IsEnabled returns whether GeoIP resolution is active. +func (s *GeoIPService) IsEnabled() bool { + s.mu.RLock() + defer s.mu.RUnlock() + return s.enabled +} diff --git a/veza-backend-api/internal/services/geoip_service_test.go b/veza-backend-api/internal/services/geoip_service_test.go new file mode 100644 index 000000000..6929b0669 --- /dev/null +++ b/veza-backend-api/internal/services/geoip_service_test.go @@ -0,0 +1,59 @@ +package services + +import ( + "os" + "testing" + + "go.uber.org/zap" +) + +func TestNewGeoIPServiceDisabled(t *testing.T) { + os.Unsetenv("GEOIP_DB_PATH") + logger := zap.NewNop() + svc := NewGeoIPService(logger) + if svc.IsEnabled() { + t.Error("expected GeoIP to be disabled when GEOIP_DB_PATH not set") + } + + country, city := svc.Lookup("1.2.3.4") + if country != "" || city != "" { + t.Error("expected empty results when disabled") + } +} + +func TestNewGeoIPServiceMissingDB(t *testing.T) { + os.Setenv("GEOIP_DB_PATH", "/nonexistent/path.mmdb") + defer os.Unsetenv("GEOIP_DB_PATH") + + logger := zap.NewNop() + svc := NewGeoIPService(logger) + if svc.IsEnabled() { + t.Error("expected GeoIP to be disabled when DB file doesn't exist") + } +} + +func TestGeoIPLookupPrivateIPs(t *testing.T) { + // Even if enabled, private IPs should return empty + svc := &GeoIPService{logger: zap.NewNop(), enabled: true} + + tests := []string{"127.0.0.1", "::1", "192.168.1.1", "10.0.0.1", "0.0.0.0"} + for _, ip := range tests { + country, city := svc.Lookup(ip) + if country != "" || city != "" { + t.Errorf("expected empty for private IP %s, got country=%q city=%q", ip, country, city) + } + } +} + +func TestGeoIPLookupInvalidIP(t *testing.T) { + svc := &GeoIPService{logger: zap.NewNop(), enabled: true} + country, city := svc.Lookup("not-an-ip") + if country != "" || city != "" { + t.Error("expected empty for invalid IP") + } +} + +// TestGeoIPResolverInterface verifies GeoIPService satisfies GeoIPResolver. +func TestGeoIPResolverInterface(t *testing.T) { + var _ GeoIPResolver = &GeoIPService{} +} diff --git a/veza-backend-api/internal/services/login_history_service.go b/veza-backend-api/internal/services/login_history_service.go index 407c2bea8..978c143aa 100644 --- a/veza-backend-api/internal/services/login_history_service.go +++ b/veza-backend-api/internal/services/login_history_service.go @@ -13,6 +13,7 @@ import ( // LoginHistoryEntry represents a single login attempt record. // F024: ORIGIN_FEATURES_REGISTRY.md — login history tracking. +// F025: GeoIP fields added in v0.13.3. type LoginHistoryEntry struct { ID uuid.UUID `json:"id"` UserID uuid.UUID `json:"user_id"` @@ -20,13 +21,22 @@ type LoginHistoryEntry struct { UserAgent string `json:"user_agent"` Success bool `json:"success"` Reason string `json:"reason,omitempty"` + Country string `json:"country,omitempty"` // F025: ISO 3166-1 alpha-2 country code + City string `json:"city,omitempty"` // F025: City name from GeoIP CreatedAt time.Time `json:"created_at"` } +// GeoIPResolver resolves IP addresses to geographic locations. +// F025: Interface for GeoIP lookup — implementations can use MaxMind, IP-API, etc. +type GeoIPResolver interface { + Lookup(ip string) (country, city string) +} + // LoginHistoryService tracks login attempts for security auditing. type LoginHistoryService struct { - db *database.Database - logger *zap.Logger + db *database.Database + logger *zap.Logger + geoIP GeoIPResolver // F025: optional GeoIP resolver } // NewLoginHistoryService creates a login history service. @@ -34,12 +44,24 @@ func NewLoginHistoryService(db *database.Database, logger *zap.Logger) *LoginHis return &LoginHistoryService{db: db, logger: logger} } +// SetGeoIPResolver sets the GeoIP resolver for location lookup. +// F025: Géolocalisation connexions. +func (s *LoginHistoryService) SetGeoIPResolver(resolver GeoIPResolver) { + s.geoIP = resolver +} + // Record stores a login attempt (success or failure). +// F025: Includes GeoIP lookup if resolver is set. func (s *LoginHistoryService) Record(ctx context.Context, userID uuid.UUID, ip, userAgent string, success bool, reason string) { + var country, city string + if s.geoIP != nil { + country, city = s.geoIP.Lookup(ip) + } + _, err := s.db.ExecContext(ctx, ` - INSERT INTO login_history (id, user_id, ip_address, user_agent, success, reason, created_at) - VALUES ($1, $2, $3, $4, $5, $6, NOW()) - `, uuid.New(), userID, ip, userAgent, success, reason) + INSERT INTO login_history (id, user_id, ip_address, user_agent, success, reason, country, city, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW()) + `, uuid.New(), userID, ip, userAgent, success, reason, country, city) if err != nil { // Non-blocking: login history is audit-only s.logger.Debug("failed to record login history (table may not exist)", zap.Error(err)) @@ -56,7 +78,8 @@ func (s *LoginHistoryService) GetUserHistory(ctx context.Context, userID uuid.UU } rows, err := s.db.QueryContext(ctx, ` - SELECT id, user_id, ip_address, user_agent, success, COALESCE(reason, ''), created_at + SELECT id, user_id, ip_address, user_agent, success, COALESCE(reason, ''), + COALESCE(country, ''), COALESCE(city, ''), created_at FROM login_history WHERE user_id = $1 ORDER BY created_at DESC @@ -70,7 +93,7 @@ func (s *LoginHistoryService) GetUserHistory(ctx context.Context, userID uuid.UU var entries []LoginHistoryEntry for rows.Next() { var e LoginHistoryEntry - if err := rows.Scan(&e.ID, &e.UserID, &e.IP, &e.UserAgent, &e.Success, &e.Reason, &e.CreatedAt); err != nil { + if err := rows.Scan(&e.ID, &e.UserID, &e.IP, &e.UserAgent, &e.Success, &e.Reason, &e.Country, &e.City, &e.CreatedAt); err != nil { continue } entries = append(entries, e) diff --git a/veza-backend-api/internal/services/password_expiration_test.go b/veza-backend-api/internal/services/password_expiration_test.go new file mode 100644 index 000000000..6cf159e17 --- /dev/null +++ b/veza-backend-api/internal/services/password_expiration_test.go @@ -0,0 +1,47 @@ +package services + +import ( + "testing" + + "veza-backend-api/internal/validators" + + "go.uber.org/zap" +) + +func TestPasswordServiceExpirationDisabled(t *testing.T) { + logger := zap.NewNop() + ps := &PasswordService{ + logger: logger, + expirationDays: 0, // disabled + } + + err := ps.CheckPasswordExpiration(nil, [16]byte{}) + if err != nil { + t.Errorf("expected no error when expiration disabled, got %v", err) + } +} + +func TestPasswordServiceExpirationNegative(t *testing.T) { + logger := zap.NewNop() + ps := &PasswordService{ + logger: logger, + expirationDays: -1, // disabled + } + + err := ps.CheckPasswordExpiration(nil, [16]byte{}) + if err != nil { + t.Errorf("expected no error with negative expiration days, got %v", err) + } +} + +func TestNewPasswordServiceWithPolicy(t *testing.T) { + logger := zap.NewNop() + policy := validators.DefaultPasswordPolicy() + ps := NewPasswordServiceWithPolicy(nil, logger, policy, 90) + if ps.expirationDays != 90 { + t.Errorf("expected expirationDays=90, got %d", ps.expirationDays) + } + if ps.passwordValidator == nil { + t.Error("expected non-nil passwordValidator") + } +} diff --git a/veza-backend-api/internal/services/password_service.go b/veza-backend-api/internal/services/password_service.go index 6797fd069..93121dec7 100644 --- a/veza-backend-api/internal/services/password_service.go +++ b/veza-backend-api/internal/services/password_service.go @@ -23,10 +23,11 @@ const bcryptCost = 12 // PasswordService handles password operations type PasswordService struct { - db *database.Database - logger *zap.Logger - passwordValidator *validators.PasswordValidator - historyService *PasswordHistoryService + db *database.Database + logger *zap.Logger + passwordValidator *validators.PasswordValidator + historyService *PasswordHistoryService + expirationDays int // F016: 0 = disabled, >0 = password expires after N days } // PasswordResetToken represents a password reset token @@ -56,6 +57,48 @@ func NewPasswordService(db *database.Database, logger *zap.Logger) *PasswordServ } } +// NewPasswordServiceWithPolicy creates a password service with configurable policy. +// F015 + F016: Configurable password policy + expiration. +func NewPasswordServiceWithPolicy(db *database.Database, logger *zap.Logger, policy validators.PasswordPolicyConfig, expirationDays int) *PasswordService { + return &PasswordService{ + db: db, + logger: logger, + passwordValidator: validators.NewPasswordValidatorWithPolicy(policy), + historyService: NewPasswordHistoryService(db, logger), + expirationDays: expirationDays, + } +} + +// CheckPasswordExpiration checks if a user's password has expired. +// F016: Returns an error if the password is expired. +func (ps *PasswordService) CheckPasswordExpiration(ctx context.Context, userID uuid.UUID) error { + if ps.expirationDays <= 0 { + return nil // Expiration disabled + } + + var passwordChangedAt *time.Time + err := ps.db.QueryRowContext(ctx, ` + SELECT password_changed_at FROM users WHERE id = $1 + `, userID).Scan(&passwordChangedAt) + if err != nil { + // Non-blocking: column may not exist yet + ps.logger.Debug("password expiration check skipped", zap.Error(err)) + return nil + } + + if passwordChangedAt == nil { + // Never set — treat as expired to force user to set a strong password + return fmt.Errorf("password_expired") + } + + deadline := passwordChangedAt.AddDate(0, 0, ps.expirationDays) + if time.Now().After(deadline) { + return fmt.Errorf("password_expired") + } + + return nil +} + // hashToken returns the hex-encoded SHA-256 hash of the token. // INF-10: Tokens are stored hashed in the DB; only the hash is persisted. func (ps *PasswordService) hashToken(token string) string { @@ -164,10 +207,10 @@ func (ps *PasswordService) ResetPassword(token, newPassword string) error { return fmt.Errorf("failed to hash password: %w", err) } - // Update user password + // Update user password + password_changed_at (F016) _, err = ps.db.ExecContext(ctx, ` UPDATE users - SET password_hash = $1, updated_at = NOW() + SET password_hash = $1, updated_at = NOW(), password_changed_at = NOW() WHERE id = $2 `, string(hashedPassword), resetToken.UserID) if err != nil { @@ -251,10 +294,10 @@ func (ps *PasswordService) ChangePassword(userID uuid.UUID, oldPassword, newPass _ = ps.historyService.Record(ctx, userID, currentHash) } - // Update password + // Update password + password_changed_at (F016) _, err = ps.db.ExecContext(ctx, ` UPDATE users - SET password_hash = $1, updated_at = NOW() + SET password_hash = $1, updated_at = NOW(), password_changed_at = NOW() WHERE id = $2 `, string(hashedPassword), userID) @@ -290,10 +333,10 @@ func (ps *PasswordService) UpdatePassword(userID uuid.UUID, newPassword string) return fmt.Errorf("failed to hash password: %w", err) } - // Update user password + // Update user password + password_changed_at (F016) _, err = ps.db.ExecContext(ctx, ` UPDATE users - SET password_hash = $1, updated_at = NOW() + SET password_hash = $1, updated_at = NOW(), password_changed_at = NOW() WHERE id = $2 `, string(hashedPassword), userID) if err != nil { diff --git a/veza-backend-api/internal/services/webauthn_service.go b/veza-backend-api/internal/services/webauthn_service.go new file mode 100644 index 000000000..b818a0436 --- /dev/null +++ b/veza-backend-api/internal/services/webauthn_service.go @@ -0,0 +1,291 @@ +package services + +import ( + "context" + "crypto/rand" + "encoding/base64" + "fmt" + "time" + + "veza-backend-api/internal/database" + "veza-backend-api/internal/models" + + "github.com/google/uuid" + "go.uber.org/zap" +) + +// WebAuthnChallenge represents an in-progress WebAuthn ceremony. +// Challenges are stored ephemerally (Redis or in-memory) with a short TTL. +type WebAuthnChallenge struct { + Challenge string `json:"challenge"` + UserID uuid.UUID `json:"user_id"` + Type string `json:"type"` // "registration" or "authentication" + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at"` +} + +// WebAuthnService handles FIDO2/WebAuthn passkey operations. +// F022: ORIGIN_FEATURES_REGISTRY.md — WebAuthn/Passkeys. +type WebAuthnService struct { + db *database.Database + logger *zap.Logger + rpID string // Relying Party ID (e.g., "veza.fr") + rpName string // Relying Party Name (e.g., "Veza") +} + +// NewWebAuthnService creates a new WebAuthn service. +func NewWebAuthnService(db *database.Database, logger *zap.Logger, rpID, rpName string) *WebAuthnService { + if rpID == "" { + rpID = "localhost" + } + if rpName == "" { + rpName = "Veza" + } + return &WebAuthnService{ + db: db, + logger: logger, + rpID: rpID, + rpName: rpName, + } +} + +// BeginRegistration generates a challenge for registering a new passkey. +// Returns a challenge and options to be sent to the client. +func (s *WebAuthnService) BeginRegistration(ctx context.Context, userID uuid.UUID, username string) (*WebAuthnChallenge, map[string]interface{}, error) { + // Generate random challenge (32 bytes) + challengeBytes := make([]byte, 32) + if _, err := rand.Read(challengeBytes); err != nil { + return nil, nil, fmt.Errorf("failed to generate challenge: %w", err) + } + challenge := base64.RawURLEncoding.EncodeToString(challengeBytes) + + // Get existing credentials for this user (to exclude) + var existingCreds []models.WebAuthnCredential + if s.db != nil { + var err error + existingCreds, err = s.GetUserCredentials(ctx, userID) + if err != nil { + s.logger.Warn("failed to get existing credentials", zap.Error(err)) + existingCreds = nil + } + } + + excludeCredentials := make([]map[string]interface{}, 0, len(existingCreds)) + for _, cred := range existingCreds { + excludeCredentials = append(excludeCredentials, map[string]interface{}{ + "type": "public-key", + "id": base64.RawURLEncoding.EncodeToString(cred.CredentialID), + }) + } + + wc := &WebAuthnChallenge{ + Challenge: challenge, + UserID: userID, + Type: "registration", + CreatedAt: time.Now(), + ExpiresAt: time.Now().Add(5 * time.Minute), + } + + // Build PublicKeyCredentialCreationOptions + options := map[string]interface{}{ + "challenge": challenge, + "rp": map[string]string{ + "id": s.rpID, + "name": s.rpName, + }, + "user": map[string]interface{}{ + "id": base64.RawURLEncoding.EncodeToString(userID[:]), + "name": username, + "displayName": username, + }, + "pubKeyCredParams": []map[string]interface{}{ + {"type": "public-key", "alg": -7}, // ES256 + {"type": "public-key", "alg": -257}, // RS256 + }, + "timeout": 60000, + "attestation": "none", + "excludeCredentials": excludeCredentials, + "authenticatorSelection": map[string]interface{}{ + "authenticatorAttachment": "platform", + "residentKey": "preferred", + "userVerification": "preferred", + }, + } + + return wc, options, nil +} + +// FinishRegistration validates the registration response and stores the credential. +func (s *WebAuthnService) FinishRegistration(ctx context.Context, userID uuid.UUID, credentialID, publicKey, aaguid []byte, attestationType string, name string) (*models.WebAuthnCredential, error) { + if len(credentialID) == 0 || len(publicKey) == 0 { + return nil, fmt.Errorf("invalid credential data") + } + if name == "" { + name = "My Passkey" + } + + cred := &models.WebAuthnCredential{ + ID: uuid.New(), + UserID: userID, + CredentialID: credentialID, + PublicKey: publicKey, + AttestationType: attestationType, + AAGUID: aaguid, + SignCount: 0, + Name: name, + CreatedAt: time.Now(), + } + + _, err := s.db.ExecContext(ctx, ` + INSERT INTO webauthn_credentials (id, user_id, credential_id, public_key, attestation_type, aaguid, sign_count, name, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + `, cred.ID, cred.UserID, cred.CredentialID, cred.PublicKey, cred.AttestationType, cred.AAGUID, cred.SignCount, cred.Name, cred.CreatedAt) + if err != nil { + return nil, fmt.Errorf("failed to store credential: %w", err) + } + + s.logger.Info("WebAuthn credential registered", + zap.String("user_id", userID.String()), + zap.String("credential_id", cred.ID.String()), + ) + + return cred, nil +} + +// BeginAuthentication generates a challenge for authenticating with a passkey. +func (s *WebAuthnService) BeginAuthentication(ctx context.Context, userID uuid.UUID) (*WebAuthnChallenge, map[string]interface{}, error) { + challengeBytes := make([]byte, 32) + if _, err := rand.Read(challengeBytes); err != nil { + return nil, nil, fmt.Errorf("failed to generate challenge: %w", err) + } + challenge := base64.RawURLEncoding.EncodeToString(challengeBytes) + + creds, err := s.GetUserCredentials(ctx, userID) + if err != nil || len(creds) == 0 { + return nil, nil, fmt.Errorf("no passkeys registered for this user") + } + + allowCredentials := make([]map[string]interface{}, 0, len(creds)) + for _, cred := range creds { + allowCredentials = append(allowCredentials, map[string]interface{}{ + "type": "public-key", + "id": base64.RawURLEncoding.EncodeToString(cred.CredentialID), + }) + } + + wc := &WebAuthnChallenge{ + Challenge: challenge, + UserID: userID, + Type: "authentication", + CreatedAt: time.Now(), + ExpiresAt: time.Now().Add(5 * time.Minute), + } + + options := map[string]interface{}{ + "challenge": challenge, + "rpId": s.rpID, + "timeout": 60000, + "userVerification": "preferred", + "allowCredentials": allowCredentials, + } + + return wc, options, nil +} + +// UpdateSignCount updates the sign count after successful authentication. +func (s *WebAuthnService) UpdateSignCount(ctx context.Context, credentialID []byte, newSignCount uint32) error { + _, err := s.db.ExecContext(ctx, ` + UPDATE webauthn_credentials + SET sign_count = $1, last_used_at = NOW() + WHERE credential_id = $2 + `, newSignCount, credentialID) + return err +} + +// GetUserCredentials returns all WebAuthn credentials for a user. +func (s *WebAuthnService) GetUserCredentials(ctx context.Context, userID uuid.UUID) ([]models.WebAuthnCredential, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT id, user_id, credential_id, public_key, attestation_type, aaguid, sign_count, name, created_at, last_used_at + FROM webauthn_credentials + WHERE user_id = $1 + ORDER BY created_at DESC + `, userID) + if err != nil { + return nil, fmt.Errorf("query webauthn credentials: %w", err) + } + defer rows.Close() + + var creds []models.WebAuthnCredential + for rows.Next() { + var c models.WebAuthnCredential + if err := rows.Scan(&c.ID, &c.UserID, &c.CredentialID, &c.PublicKey, &c.AttestationType, &c.AAGUID, &c.SignCount, &c.Name, &c.CreatedAt, &c.LastUsedAt); err != nil { + continue + } + creds = append(creds, c) + } + return creds, nil +} + +// GetCredentialByID retrieves a credential by its WebAuthn credential ID bytes. +func (s *WebAuthnService) GetCredentialByID(ctx context.Context, credentialID []byte) (*models.WebAuthnCredential, error) { + var c models.WebAuthnCredential + err := s.db.QueryRowContext(ctx, ` + SELECT id, user_id, credential_id, public_key, attestation_type, aaguid, sign_count, name, created_at, last_used_at + FROM webauthn_credentials + WHERE credential_id = $1 + `, credentialID).Scan(&c.ID, &c.UserID, &c.CredentialID, &c.PublicKey, &c.AttestationType, &c.AAGUID, &c.SignCount, &c.Name, &c.CreatedAt, &c.LastUsedAt) + if err != nil { + return nil, fmt.Errorf("credential not found: %w", err) + } + return &c, nil +} + +// DeleteCredential removes a passkey by UUID (user must own it). +func (s *WebAuthnService) DeleteCredential(ctx context.Context, userID, credID uuid.UUID) error { + result, err := s.db.ExecContext(ctx, ` + DELETE FROM webauthn_credentials WHERE id = $1 AND user_id = $2 + `, credID, userID) + if err != nil { + return fmt.Errorf("failed to delete credential: %w", err) + } + rows, _ := result.RowsAffected() + if rows == 0 { + return fmt.Errorf("credential not found or not owned by user") + } + + s.logger.Info("WebAuthn credential deleted", + zap.String("user_id", userID.String()), + zap.String("credential_id", credID.String()), + ) + return nil +} + +// RenameCredential updates the display name of a passkey. +func (s *WebAuthnService) RenameCredential(ctx context.Context, userID, credID uuid.UUID, newName string) error { + if newName == "" { + return fmt.Errorf("name cannot be empty") + } + result, err := s.db.ExecContext(ctx, ` + UPDATE webauthn_credentials SET name = $1 WHERE id = $2 AND user_id = $3 + `, newName, credID, userID) + if err != nil { + return fmt.Errorf("failed to rename credential: %w", err) + } + rows, _ := result.RowsAffected() + if rows == 0 { + return fmt.Errorf("credential not found or not owned by user") + } + return nil +} + +// HasPasskeys returns whether a user has any WebAuthn credentials registered. +func (s *WebAuthnService) HasPasskeys(ctx context.Context, userID uuid.UUID) (bool, error) { + var count int + err := s.db.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM webauthn_credentials WHERE user_id = $1 + `, userID).Scan(&count) + if err != nil { + return false, err + } + return count > 0, nil +} diff --git a/veza-backend-api/internal/services/webauthn_service_test.go b/veza-backend-api/internal/services/webauthn_service_test.go new file mode 100644 index 000000000..22cd06b92 --- /dev/null +++ b/veza-backend-api/internal/services/webauthn_service_test.go @@ -0,0 +1,69 @@ +package services + +import ( + "testing" + + "github.com/google/uuid" + "go.uber.org/zap" +) + +func TestNewWebAuthnService(t *testing.T) { + logger := zap.NewNop() + svc := NewWebAuthnService(nil, logger, "", "") + if svc.rpID != "localhost" { + t.Errorf("expected default rpID 'localhost', got %q", svc.rpID) + } + if svc.rpName != "Veza" { + t.Errorf("expected default rpName 'Veza', got %q", svc.rpName) + } + + svc2 := NewWebAuthnService(nil, logger, "veza.fr", "Veza Platform") + if svc2.rpID != "veza.fr" { + t.Errorf("expected rpID 'veza.fr', got %q", svc2.rpID) + } +} + +func TestWebAuthnChallengeGeneration(t *testing.T) { + logger := zap.NewNop() + // Without DB, BeginRegistration will still generate challenge and options + svc := NewWebAuthnService(nil, logger, "veza.fr", "Veza") + + userID := uuid.New() + challenge, options, err := svc.BeginRegistration(nil, userID, "testuser") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if challenge.Challenge == "" { + t.Error("expected non-empty challenge") + } + if challenge.Type != "registration" { + t.Errorf("expected type 'registration', got %q", challenge.Type) + } + if options == nil { + t.Error("expected non-nil options") + } + // Check RP info in options + rp, ok := options["rp"].(map[string]string) + if !ok { + t.Fatal("expected rp map in options") + } + if rp["id"] != "veza.fr" { + t.Errorf("expected rp.id 'veza.fr', got %q", rp["id"]) + } +} + +func TestWebAuthnFinishRegistrationValidation(t *testing.T) { + logger := zap.NewNop() + svc := NewWebAuthnService(nil, logger, "localhost", "Veza") + + // Empty credential should fail + _, err := svc.FinishRegistration(nil, uuid.New(), nil, nil, nil, "none", "") + if err == nil { + t.Error("expected error for empty credential data") + } + + _, err = svc.FinishRegistration(nil, uuid.New(), []byte("cred"), nil, nil, "none", "") + if err == nil { + t.Error("expected error for empty public key") + } +} diff --git a/veza-backend-api/internal/validators/password_validator.go b/veza-backend-api/internal/validators/password_validator.go index a2721a545..1d5585415 100644 --- a/veza-backend-api/internal/validators/password_validator.go +++ b/veza-backend-api/internal/validators/password_validator.go @@ -1,7 +1,10 @@ package validators import ( + "fmt" + "os" "regexp" + "strconv" "strings" ) @@ -23,14 +26,51 @@ var ( } ) +// PasswordPolicyConfig holds configurable password policy settings. +// F015: Configurable password policy. +type PasswordPolicyConfig struct { + MinLength int // Minimum password length (default 12) + MaxLength int // Maximum password length (default 128) + RequireUpper bool // Require uppercase letter (default true) + RequireLower bool // Require lowercase letter (default true) + RequireNumber bool // Require digit (default true) + RequireSpecial bool // Require special character (default true) +} + +// DefaultPasswordPolicy returns the default (strict) policy. +func DefaultPasswordPolicy() PasswordPolicyConfig { + return PasswordPolicyConfig{ + MinLength: 12, + MaxLength: 128, + RequireUpper: true, + RequireLower: true, + RequireNumber: true, + RequireSpecial: true, + } +} + // PasswordValidator valide la force d'un mot de passe type PasswordValidator struct { MinLength int + Policy PasswordPolicyConfig } // NewPasswordValidator crée une nouvelle instance de PasswordValidator func NewPasswordValidator() *PasswordValidator { - return &PasswordValidator{MinLength: 12} + p := DefaultPasswordPolicy() + return &PasswordValidator{MinLength: p.MinLength, Policy: p} +} + +// NewPasswordValidatorWithPolicy creates a validator with a custom policy. +// F015: Configurable password policy. +func NewPasswordValidatorWithPolicy(policy PasswordPolicyConfig) *PasswordValidator { + if policy.MinLength < 8 { + policy.MinLength = 8 // Absolute minimum + } + if policy.MaxLength <= 0 { + policy.MaxLength = 128 + } + return &PasswordValidator{MinLength: policy.MinLength, Policy: policy} } // PasswordStrength représente le résultat de la validation d'un mot de passe @@ -52,15 +92,19 @@ func (v *PasswordValidator) Validate(password string) (PasswordStrength, error) if len(password) < v.MinLength { strength.Valid = false strength.Details = append(strength.Details, - "Password must be at least 12 characters long") + fmt.Sprintf("Password must be at least %d characters long", v.MinLength)) return strength, nil } // Maximum length check (prevent DoS) - if len(password) > 128 { + maxLen := v.Policy.MaxLength + if maxLen <= 0 { + maxLen = 128 + } + if len(password) > maxLen { strength.Valid = false strength.Details = append(strength.Details, - "Password must be less than 128 characters") + fmt.Sprintf("Password must be less than %d characters", maxLen)) return strength, nil } @@ -95,36 +139,44 @@ func (v *PasswordValidator) Validate(password string) (PasswordStrength, error) return strength, nil } - // Upper case check - if !hasUpper.MatchString(password) { - strength.Valid = false - strength.Details = append(strength.Details, "Must contain uppercase letter") - } else { - strength.Score++ + // Upper case check (F015: configurable) + if v.Policy.RequireUpper { + if !hasUpper.MatchString(password) { + strength.Valid = false + strength.Details = append(strength.Details, "Must contain uppercase letter") + } else { + strength.Score++ + } } - // Lower case check - if !hasLower.MatchString(password) { - strength.Valid = false - strength.Details = append(strength.Details, "Must contain lowercase letter") - } else { - strength.Score++ + // Lower case check (F015: configurable) + if v.Policy.RequireLower { + if !hasLower.MatchString(password) { + strength.Valid = false + strength.Details = append(strength.Details, "Must contain lowercase letter") + } else { + strength.Score++ + } } - // Number check - if !hasNumber.MatchString(password) { - strength.Valid = false - strength.Details = append(strength.Details, "Must contain number") - } else { - strength.Score++ + // Number check (F015: configurable) + if v.Policy.RequireNumber { + if !hasNumber.MatchString(password) { + strength.Valid = false + strength.Details = append(strength.Details, "Must contain number") + } else { + strength.Score++ + } } - // Special character check - if !hasSpecial.MatchString(password) { - strength.Valid = false - strength.Details = append(strength.Details, "Must contain special character") - } else { - strength.Score++ + // Special character check (F015: configurable) + if v.Policy.RequireSpecial { + if !hasSpecial.MatchString(password) { + strength.Valid = false + strength.Details = append(strength.Details, "Must contain special character") + } else { + strength.Score++ + } } return strength, nil @@ -239,3 +291,49 @@ func calculateSimilarity(s1, s2 string) float64 { return float64(matches) / float64(maxLen) } + +// PasswordPolicyFromEnv reads password policy config from environment variables. +// F015: Configurable password policy. +// Env vars: +// - PASSWORD_MIN_LENGTH (default 12, minimum 8) +// - PASSWORD_MAX_LENGTH (default 128) +// - PASSWORD_REQUIRE_UPPER (default true) +// - PASSWORD_REQUIRE_LOWER (default true) +// - PASSWORD_REQUIRE_NUMBER (default true) +// - PASSWORD_REQUIRE_SPECIAL (default true) +func PasswordPolicyFromEnv() PasswordPolicyConfig { + p := DefaultPasswordPolicy() + + if v := os.Getenv("PASSWORD_MIN_LENGTH"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n >= 8 { + p.MinLength = n + } + } + if v := os.Getenv("PASSWORD_MAX_LENGTH"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + p.MaxLength = n + } + } + if v := os.Getenv("PASSWORD_REQUIRE_UPPER"); v != "" { + p.RequireUpper = parseBool(v, true) + } + if v := os.Getenv("PASSWORD_REQUIRE_LOWER"); v != "" { + p.RequireLower = parseBool(v, true) + } + if v := os.Getenv("PASSWORD_REQUIRE_NUMBER"); v != "" { + p.RequireNumber = parseBool(v, true) + } + if v := os.Getenv("PASSWORD_REQUIRE_SPECIAL"); v != "" { + p.RequireSpecial = parseBool(v, true) + } + + return p +} + +func parseBool(s string, defaultVal bool) bool { + b, err := strconv.ParseBool(s) + if err != nil { + return defaultVal + } + return b +} diff --git a/veza-backend-api/internal/validators/password_validator_policy_test.go b/veza-backend-api/internal/validators/password_validator_policy_test.go new file mode 100644 index 000000000..7143d2aa2 --- /dev/null +++ b/veza-backend-api/internal/validators/password_validator_policy_test.go @@ -0,0 +1,125 @@ +package validators + +import ( + "os" + "testing" +) + +func TestDefaultPasswordPolicy(t *testing.T) { + p := DefaultPasswordPolicy() + if p.MinLength != 12 { + t.Errorf("expected MinLength 12, got %d", p.MinLength) + } + if p.MaxLength != 128 { + t.Errorf("expected MaxLength 128, got %d", p.MaxLength) + } + if !p.RequireUpper || !p.RequireLower || !p.RequireNumber || !p.RequireSpecial { + t.Error("all character class requirements should default to true") + } +} + +func TestNewPasswordValidatorWithPolicy(t *testing.T) { + // Test minimum enforcement + v := NewPasswordValidatorWithPolicy(PasswordPolicyConfig{MinLength: 4}) + if v.MinLength != 8 { + t.Errorf("MinLength should be clamped to 8, got %d", v.MinLength) + } + + // Test normal policy + v = NewPasswordValidatorWithPolicy(PasswordPolicyConfig{ + MinLength: 10, + MaxLength: 64, + RequireUpper: true, + RequireLower: true, + RequireNumber: false, + RequireSpecial: false, + }) + if v.MinLength != 10 { + t.Errorf("expected MinLength 10, got %d", v.MinLength) + } + + // Validate a password with no number or special — should pass with relaxed policy + result, err := v.Validate("MxPqTrVwZyBn") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !result.Valid { + t.Errorf("expected valid password with relaxed policy, got details: %v", result.Details) + } +} + +func TestConfigurablePolicyRejectsShortPasswords(t *testing.T) { + v := NewPasswordValidatorWithPolicy(PasswordPolicyConfig{ + MinLength: 16, + MaxLength: 128, + RequireUpper: true, + RequireLower: true, + RequireNumber: true, + RequireSpecial: true, + }) + + result, err := v.Validate("Short1!aB") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Valid { + t.Error("expected short password to be rejected with MinLength=16") + } +} + +func TestConfigurablePolicyMaxLength(t *testing.T) { + v := NewPasswordValidatorWithPolicy(PasswordPolicyConfig{ + MinLength: 8, + MaxLength: 20, + }) + + longPw := "Abcdefgh1!Abcdefgh1!X" + result, err := v.Validate(longPw) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Valid { + t.Error("expected password exceeding MaxLength=20 to be rejected") + } +} + +func TestPasswordPolicyFromEnv(t *testing.T) { + // Set env vars + os.Setenv("PASSWORD_MIN_LENGTH", "16") + os.Setenv("PASSWORD_REQUIRE_SPECIAL", "false") + defer func() { + os.Unsetenv("PASSWORD_MIN_LENGTH") + os.Unsetenv("PASSWORD_REQUIRE_SPECIAL") + }() + + p := PasswordPolicyFromEnv() + if p.MinLength != 16 { + t.Errorf("expected MinLength 16 from env, got %d", p.MinLength) + } + if p.RequireSpecial { + t.Error("expected RequireSpecial=false from env") + } + if !p.RequireUpper { + t.Error("RequireUpper should remain true (not overridden)") + } +} + +func TestPasswordPolicyFromEnvInvalidValues(t *testing.T) { + os.Setenv("PASSWORD_MIN_LENGTH", "invalid") + defer os.Unsetenv("PASSWORD_MIN_LENGTH") + + p := PasswordPolicyFromEnv() + if p.MinLength != 12 { + t.Errorf("expected default MinLength 12 for invalid env, got %d", p.MinLength) + } +} + +func TestPasswordPolicyFromEnvTooSmallMinLength(t *testing.T) { + os.Setenv("PASSWORD_MIN_LENGTH", "3") + defer os.Unsetenv("PASSWORD_MIN_LENGTH") + + p := PasswordPolicyFromEnv() + if p.MinLength != 12 { + t.Errorf("expected default MinLength 12 for too-small env value, got %d", p.MinLength) + } +} diff --git a/veza-backend-api/migrations/971_security_advanced_v0133.sql b/veza-backend-api/migrations/971_security_advanced_v0133.sql new file mode 100644 index 000000000..c1f8968ae --- /dev/null +++ b/veza-backend-api/migrations/971_security_advanced_v0133.sql @@ -0,0 +1,27 @@ +-- v0.13.3: F022 WebAuthn Credentials + F025 GeoIP on login_history + F016 Password expiration +-- Up migration + +-- F022: WebAuthn credentials — stores FIDO2 passkeys per user +CREATE TABLE IF NOT EXISTS webauthn_credentials ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + credential_id BYTEA NOT NULL UNIQUE, + public_key BYTEA NOT NULL, + attestation_type VARCHAR(50) NOT NULL DEFAULT 'none', + aaguid BYTEA, + sign_count BIGINT NOT NULL DEFAULT 0, + name VARCHAR(100) NOT NULL DEFAULT 'My Passkey', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_used_at TIMESTAMPTZ +); +CREATE INDEX IF NOT EXISTS idx_webauthn_user_id ON webauthn_credentials(user_id); +CREATE INDEX IF NOT EXISTS idx_webauthn_credential_id ON webauthn_credentials(credential_id); + +-- F025: Add geolocation columns to login_history +ALTER TABLE login_history ADD COLUMN IF NOT EXISTS country VARCHAR(2) DEFAULT ''; +ALTER TABLE login_history ADD COLUMN IF NOT EXISTS city VARCHAR(100) DEFAULT ''; + +-- F016: Add password_changed_at to users for expiration tracking +ALTER TABLE users ADD COLUMN IF NOT EXISTS password_changed_at TIMESTAMPTZ; +-- Backfill: set password_changed_at = updated_at for existing users with passwords +UPDATE users SET password_changed_at = updated_at WHERE password_hash != '' AND password_changed_at IS NULL; diff --git a/veza-backend-api/migrations/971_security_advanced_v0133_down.sql b/veza-backend-api/migrations/971_security_advanced_v0133_down.sql new file mode 100644 index 000000000..1cf1d9065 --- /dev/null +++ b/veza-backend-api/migrations/971_security_advanced_v0133_down.sql @@ -0,0 +1,6 @@ +-- v0.13.3: Down migration + +ALTER TABLE users DROP COLUMN IF EXISTS password_changed_at; +ALTER TABLE login_history DROP COLUMN IF EXISTS city; +ALTER TABLE login_history DROP COLUMN IF EXISTS country; +DROP TABLE IF EXISTS webauthn_credentials;