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>
This commit is contained in:
senke 2026-03-13 10:09:01 +01:00
parent 955be70935
commit 6a675565e1
16 changed files with 1284 additions and 45 deletions

View file

@ -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))
}
}

View file

@ -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,
})
}
}

View file

@ -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
}

View file

@ -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:"-"`

View file

@ -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,
}
}

View file

@ -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
}

View file

@ -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{}
}

View file

@ -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)

View file

@ -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")
}
}

View file

@ -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 {

View file

@ -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
}

View file

@ -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")
}
}

View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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;

View file

@ -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;