passkey_repo.go 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239
  1. package repository
  2. import (
  3. "context"
  4. "database/sql"
  5. "encoding/json"
  6. "errors"
  7. "fmt"
  8. "strings"
  9. "time"
  10. "github.com/Wei-Shaw/sub2api/internal/service"
  11. "github.com/go-webauthn/webauthn/webauthn"
  12. "github.com/lib/pq"
  13. )
  14. type passkeyRepository struct {
  15. db *sql.DB
  16. }
  17. func NewPasskeyRepository(db *sql.DB) service.PasskeyRepository {
  18. return &passkeyRepository{db: db}
  19. }
  20. func (r *passkeyRepository) EnsureUserHandle(
  21. ctx context.Context,
  22. userID int64,
  23. candidate []byte,
  24. ) ([]byte, error) {
  25. if len(candidate) < 16 || len(candidate) > 64 {
  26. return nil, fmt.Errorf("passkey user handle must contain 16-64 bytes")
  27. }
  28. if _, err := r.db.ExecContext(ctx, `
  29. INSERT INTO passkey_user_handles (user_id, user_handle)
  30. VALUES ($1, $2)
  31. ON CONFLICT (user_id) DO NOTHING
  32. `, userID, candidate); err != nil {
  33. return nil, fmt.Errorf("ensure passkey user handle: %w", err)
  34. }
  35. return r.GetUserHandle(ctx, userID)
  36. }
  37. func (r *passkeyRepository) GetUserHandle(ctx context.Context, userID int64) ([]byte, error) {
  38. var handle []byte
  39. err := r.db.QueryRowContext(ctx, `
  40. SELECT user_handle
  41. FROM passkey_user_handles
  42. WHERE user_id = $1
  43. `, userID).Scan(&handle)
  44. if errors.Is(err, sql.ErrNoRows) {
  45. return nil, service.ErrPasskeyNotFound
  46. }
  47. if err != nil {
  48. return nil, fmt.Errorf("get passkey user handle: %w", err)
  49. }
  50. return handle, nil
  51. }
  52. func (r *passkeyRepository) GetByCredentialID(
  53. ctx context.Context,
  54. credentialID []byte,
  55. ) (*service.PasskeyCredentialRecord, error) {
  56. row := r.db.QueryRowContext(ctx, `
  57. SELECT c.id, c.user_id, h.user_handle, c.name, c.credential_data,
  58. c.last_used_at, c.created_at, c.updated_at
  59. FROM passkey_credentials c
  60. JOIN passkey_user_handles h ON h.user_id = c.user_id
  61. WHERE c.credential_id = $1
  62. `, credentialID)
  63. record, err := scanPasskeyCredential(row)
  64. if errors.Is(err, sql.ErrNoRows) {
  65. return nil, service.ErrPasskeyNotFound
  66. }
  67. if err != nil {
  68. return nil, fmt.Errorf("get passkey credential: %w", err)
  69. }
  70. return record, nil
  71. }
  72. func (r *passkeyRepository) ListByUserID(
  73. ctx context.Context,
  74. userID int64,
  75. ) ([]service.PasskeyCredentialRecord, error) {
  76. rows, err := r.db.QueryContext(ctx, `
  77. SELECT c.id, c.user_id, h.user_handle, c.name, c.credential_data,
  78. c.last_used_at, c.created_at, c.updated_at
  79. FROM passkey_credentials c
  80. JOIN passkey_user_handles h ON h.user_id = c.user_id
  81. WHERE c.user_id = $1
  82. ORDER BY c.created_at DESC, c.id DESC
  83. `, userID)
  84. if err != nil {
  85. return nil, fmt.Errorf("list passkey credentials: %w", err)
  86. }
  87. defer func() { _ = rows.Close() }()
  88. records := make([]service.PasskeyCredentialRecord, 0)
  89. for rows.Next() {
  90. record, scanErr := scanPasskeyCredential(rows)
  91. if scanErr != nil {
  92. return nil, fmt.Errorf("scan passkey credential: %w", scanErr)
  93. }
  94. records = append(records, *record)
  95. }
  96. if err = rows.Err(); err != nil {
  97. return nil, fmt.Errorf("list passkey credentials: %w", err)
  98. }
  99. return records, nil
  100. }
  101. func (r *passkeyRepository) Create(
  102. ctx context.Context,
  103. record *service.PasskeyCredentialRecord,
  104. ) (*service.PasskeyCredentialRecord, error) {
  105. if record == nil || len(record.Credential.ID) == 0 {
  106. return nil, fmt.Errorf("passkey credential is required")
  107. }
  108. credentialJSON, err := json.Marshal(record.Credential)
  109. if err != nil {
  110. return nil, fmt.Errorf("encode passkey credential: %w", err)
  111. }
  112. name := strings.TrimSpace(record.Name)
  113. if name == "" {
  114. name = "Passkey"
  115. }
  116. created := &service.PasskeyCredentialRecord{
  117. UserID: record.UserID,
  118. UserHandle: append([]byte(nil), record.UserHandle...),
  119. Name: name,
  120. Credential: record.Credential,
  121. }
  122. err = r.db.QueryRowContext(ctx, `
  123. INSERT INTO passkey_credentials
  124. (user_id, credential_id, name, credential_data)
  125. VALUES ($1, $2, $3, $4::jsonb)
  126. RETURNING id, name, created_at, updated_at
  127. `, record.UserID, record.Credential.ID, name, string(credentialJSON)).
  128. Scan(&created.ID, &created.Name, &created.CreatedAt, &created.UpdatedAt)
  129. if err != nil {
  130. var pqErr *pq.Error
  131. if errors.As(err, &pqErr) && pqErr.Code == "23505" {
  132. return nil, service.ErrPasskeyExists
  133. }
  134. return nil, fmt.Errorf("create passkey credential: %w", err)
  135. }
  136. return created, nil
  137. }
  138. func (r *passkeyRepository) UpdateCredential(
  139. ctx context.Context,
  140. userID int64,
  141. credential *webauthn.Credential,
  142. usedAt time.Time,
  143. ) error {
  144. if credential == nil || len(credential.ID) == 0 {
  145. return fmt.Errorf("passkey credential is required")
  146. }
  147. credentialJSON, err := json.Marshal(credential)
  148. if err != nil {
  149. return fmt.Errorf("encode passkey credential: %w", err)
  150. }
  151. result, err := r.db.ExecContext(ctx, `
  152. UPDATE passkey_credentials
  153. SET credential_data = $3::jsonb, last_used_at = $4, updated_at = NOW()
  154. WHERE user_id = $1 AND credential_id = $2
  155. `, userID, credential.ID, string(credentialJSON), usedAt.UTC())
  156. if err != nil {
  157. return fmt.Errorf("update passkey credential: %w", err)
  158. }
  159. return requirePasskeyAffected(result)
  160. }
  161. func (r *passkeyRepository) Rename(
  162. ctx context.Context,
  163. userID, credentialID int64,
  164. name string,
  165. ) error {
  166. result, err := r.db.ExecContext(ctx, `
  167. UPDATE passkey_credentials
  168. SET name = $3, updated_at = NOW()
  169. WHERE user_id = $1 AND id = $2
  170. `, userID, credentialID, name)
  171. if err != nil {
  172. return fmt.Errorf("rename passkey credential: %w", err)
  173. }
  174. return requirePasskeyAffected(result)
  175. }
  176. func (r *passkeyRepository) Delete(
  177. ctx context.Context,
  178. userID, credentialID int64,
  179. ) error {
  180. result, err := r.db.ExecContext(ctx, `
  181. DELETE FROM passkey_credentials
  182. WHERE user_id = $1 AND id = $2
  183. `, userID, credentialID)
  184. if err != nil {
  185. return fmt.Errorf("delete passkey credential: %w", err)
  186. }
  187. return requirePasskeyAffected(result)
  188. }
  189. type passkeyScanner interface {
  190. Scan(dest ...any) error
  191. }
  192. func scanPasskeyCredential(scanner passkeyScanner) (*service.PasskeyCredentialRecord, error) {
  193. var (
  194. record service.PasskeyCredentialRecord
  195. credentialJSON []byte
  196. )
  197. if err := scanner.Scan(
  198. &record.ID,
  199. &record.UserID,
  200. &record.UserHandle,
  201. &record.Name,
  202. &credentialJSON,
  203. &record.LastUsedAt,
  204. &record.CreatedAt,
  205. &record.UpdatedAt,
  206. ); err != nil {
  207. return nil, err
  208. }
  209. if err := json.Unmarshal(credentialJSON, &record.Credential); err != nil {
  210. return nil, fmt.Errorf("decode passkey credential: %w", err)
  211. }
  212. return &record, nil
  213. }
  214. func requirePasskeyAffected(result sql.Result) error {
  215. affected, err := result.RowsAffected()
  216. if err != nil {
  217. return err
  218. }
  219. if affected == 0 {
  220. return service.ErrPasskeyNotFound
  221. }
  222. return nil
  223. }