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.
291 lines
9.4 KiB
Go
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
|
|
}
|