| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239 |
- package repository
- import (
- "context"
- "database/sql"
- "encoding/json"
- "errors"
- "fmt"
- "strings"
- "time"
- "github.com/Wei-Shaw/sub2api/internal/service"
- "github.com/go-webauthn/webauthn/webauthn"
- "github.com/lib/pq"
- )
- type passkeyRepository struct {
- db *sql.DB
- }
- func NewPasskeyRepository(db *sql.DB) service.PasskeyRepository {
- return &passkeyRepository{db: db}
- }
- func (r *passkeyRepository) EnsureUserHandle(
- ctx context.Context,
- userID int64,
- candidate []byte,
- ) ([]byte, error) {
- if len(candidate) < 16 || len(candidate) > 64 {
- return nil, fmt.Errorf("passkey user handle must contain 16-64 bytes")
- }
- if _, err := r.db.ExecContext(ctx, `
- INSERT INTO passkey_user_handles (user_id, user_handle)
- VALUES ($1, $2)
- ON CONFLICT (user_id) DO NOTHING
- `, userID, candidate); err != nil {
- return nil, fmt.Errorf("ensure passkey user handle: %w", err)
- }
- return r.GetUserHandle(ctx, userID)
- }
- func (r *passkeyRepository) GetUserHandle(ctx context.Context, userID int64) ([]byte, error) {
- var handle []byte
- err := r.db.QueryRowContext(ctx, `
- SELECT user_handle
- FROM passkey_user_handles
- WHERE user_id = $1
- `, userID).Scan(&handle)
- if errors.Is(err, sql.ErrNoRows) {
- return nil, service.ErrPasskeyNotFound
- }
- if err != nil {
- return nil, fmt.Errorf("get passkey user handle: %w", err)
- }
- return handle, nil
- }
- func (r *passkeyRepository) GetByCredentialID(
- ctx context.Context,
- credentialID []byte,
- ) (*service.PasskeyCredentialRecord, error) {
- row := r.db.QueryRowContext(ctx, `
- SELECT c.id, c.user_id, h.user_handle, c.name, c.credential_data,
- c.last_used_at, c.created_at, c.updated_at
- FROM passkey_credentials c
- JOIN passkey_user_handles h ON h.user_id = c.user_id
- WHERE c.credential_id = $1
- `, credentialID)
- record, err := scanPasskeyCredential(row)
- if errors.Is(err, sql.ErrNoRows) {
- return nil, service.ErrPasskeyNotFound
- }
- if err != nil {
- return nil, fmt.Errorf("get passkey credential: %w", err)
- }
- return record, nil
- }
- func (r *passkeyRepository) ListByUserID(
- ctx context.Context,
- userID int64,
- ) ([]service.PasskeyCredentialRecord, error) {
- rows, err := r.db.QueryContext(ctx, `
- SELECT c.id, c.user_id, h.user_handle, c.name, c.credential_data,
- c.last_used_at, c.created_at, c.updated_at
- FROM passkey_credentials c
- JOIN passkey_user_handles h ON h.user_id = c.user_id
- WHERE c.user_id = $1
- ORDER BY c.created_at DESC, c.id DESC
- `, userID)
- if err != nil {
- return nil, fmt.Errorf("list passkey credentials: %w", err)
- }
- defer func() { _ = rows.Close() }()
- records := make([]service.PasskeyCredentialRecord, 0)
- for rows.Next() {
- record, scanErr := scanPasskeyCredential(rows)
- if scanErr != nil {
- return nil, fmt.Errorf("scan passkey credential: %w", scanErr)
- }
- records = append(records, *record)
- }
- if err = rows.Err(); err != nil {
- return nil, fmt.Errorf("list passkey credentials: %w", err)
- }
- return records, nil
- }
- func (r *passkeyRepository) Create(
- ctx context.Context,
- record *service.PasskeyCredentialRecord,
- ) (*service.PasskeyCredentialRecord, error) {
- if record == nil || len(record.Credential.ID) == 0 {
- return nil, fmt.Errorf("passkey credential is required")
- }
- credentialJSON, err := json.Marshal(record.Credential)
- if err != nil {
- return nil, fmt.Errorf("encode passkey credential: %w", err)
- }
- name := strings.TrimSpace(record.Name)
- if name == "" {
- name = "Passkey"
- }
- created := &service.PasskeyCredentialRecord{
- UserID: record.UserID,
- UserHandle: append([]byte(nil), record.UserHandle...),
- Name: name,
- Credential: record.Credential,
- }
- err = r.db.QueryRowContext(ctx, `
- INSERT INTO passkey_credentials
- (user_id, credential_id, name, credential_data)
- VALUES ($1, $2, $3, $4::jsonb)
- RETURNING id, name, created_at, updated_at
- `, record.UserID, record.Credential.ID, name, string(credentialJSON)).
- Scan(&created.ID, &created.Name, &created.CreatedAt, &created.UpdatedAt)
- if err != nil {
- var pqErr *pq.Error
- if errors.As(err, &pqErr) && pqErr.Code == "23505" {
- return nil, service.ErrPasskeyExists
- }
- return nil, fmt.Errorf("create passkey credential: %w", err)
- }
- return created, nil
- }
- func (r *passkeyRepository) UpdateCredential(
- ctx context.Context,
- userID int64,
- credential *webauthn.Credential,
- usedAt time.Time,
- ) error {
- if credential == nil || len(credential.ID) == 0 {
- return fmt.Errorf("passkey credential is required")
- }
- credentialJSON, err := json.Marshal(credential)
- if err != nil {
- return fmt.Errorf("encode passkey credential: %w", err)
- }
- result, err := r.db.ExecContext(ctx, `
- UPDATE passkey_credentials
- SET credential_data = $3::jsonb, last_used_at = $4, updated_at = NOW()
- WHERE user_id = $1 AND credential_id = $2
- `, userID, credential.ID, string(credentialJSON), usedAt.UTC())
- if err != nil {
- return fmt.Errorf("update passkey credential: %w", err)
- }
- return requirePasskeyAffected(result)
- }
- func (r *passkeyRepository) Rename(
- ctx context.Context,
- userID, credentialID int64,
- name string,
- ) error {
- result, err := r.db.ExecContext(ctx, `
- UPDATE passkey_credentials
- SET name = $3, updated_at = NOW()
- WHERE user_id = $1 AND id = $2
- `, userID, credentialID, name)
- if err != nil {
- return fmt.Errorf("rename passkey credential: %w", err)
- }
- return requirePasskeyAffected(result)
- }
- func (r *passkeyRepository) Delete(
- ctx context.Context,
- userID, credentialID int64,
- ) error {
- result, err := r.db.ExecContext(ctx, `
- DELETE FROM passkey_credentials
- WHERE user_id = $1 AND id = $2
- `, userID, credentialID)
- if err != nil {
- return fmt.Errorf("delete passkey credential: %w", err)
- }
- return requirePasskeyAffected(result)
- }
- type passkeyScanner interface {
- Scan(dest ...any) error
- }
- func scanPasskeyCredential(scanner passkeyScanner) (*service.PasskeyCredentialRecord, error) {
- var (
- record service.PasskeyCredentialRecord
- credentialJSON []byte
- )
- if err := scanner.Scan(
- &record.ID,
- &record.UserID,
- &record.UserHandle,
- &record.Name,
- &credentialJSON,
- &record.LastUsedAt,
- &record.CreatedAt,
- &record.UpdatedAt,
- ); err != nil {
- return nil, err
- }
- if err := json.Unmarshal(credentialJSON, &record.Credential); err != nil {
- return nil, fmt.Errorf("decode passkey credential: %w", err)
- }
- return &record, nil
- }
- func requirePasskeyAffected(result sql.Result) error {
- affected, err := result.RowsAffected()
- if err != nil {
- return err
- }
- if affected == 0 {
- return service.ErrPasskeyNotFound
- }
- return nil
- }
|