veza/veza-backend-api/internal/services/webauthn_service.go
senke a1000ce7fb style(backend): gofmt -w on 85 files (whitespace only)
backend-ci.yml's `test -z "$(gofmt -l .)"` strict gate (added in
13c21ac11) failed on a backlog of unformatted files. None of the
85 files in this commit had been edited since the gate was added
because no push touched veza-backend-api/** in between, so the
gate never fired until today's CI fixes triggered it.

The diff is exclusively whitespace alignment in struct literals
and trailing-space comments. `go build ./...` and the full test
suite (with VEZA_SKIP_INTEGRATION=1 -short) pass identically.
2026-04-14 12:22:14 +02:00

291 lines
9.4 KiB
Go

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
}