passkey.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403
  1. package service
  2. import (
  3. "bytes"
  4. "context"
  5. "crypto/rand"
  6. "fmt"
  7. "net/http"
  8. "strings"
  9. "time"
  10. "github.com/Wei-Shaw/sub2api/internal/config"
  11. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  12. "github.com/go-webauthn/webauthn/protocol"
  13. "github.com/go-webauthn/webauthn/webauthn"
  14. )
  15. const (
  16. passkeySessionRegistration = "registration"
  17. passkeySessionLogin = "login"
  18. passkeySessionTTL = 5 * time.Minute
  19. defaultPasskeyName = "Passkey"
  20. maxPasskeyNameLength = 100
  21. )
  22. var (
  23. ErrPasskeysDisabled = infraerrors.Forbidden("PASSKEY_DISABLED", "passkey authentication is not enabled")
  24. ErrPasskeyNotFound = infraerrors.NotFound("PASSKEY_NOT_FOUND", "passkey not found")
  25. ErrPasskeyExists = infraerrors.Conflict("PASSKEY_ALREADY_EXISTS", "this passkey is already registered")
  26. ErrPasskeySession = infraerrors.BadRequest("PASSKEY_SESSION_INVALID", "passkey session is invalid or expired")
  27. ErrPasskeyVerify = infraerrors.Unauthorized("PASSKEY_VERIFICATION_FAILED", "passkey verification failed")
  28. )
  29. // PasskeyCredentialRecord is the persistence representation used by the
  30. // WebAuthn service. Credential contains the complete WebAuthn credential record
  31. // so future library versions can continue to validate and update it.
  32. type PasskeyCredentialRecord struct {
  33. ID int64
  34. UserID int64
  35. UserHandle []byte
  36. Name string
  37. Credential webauthn.Credential
  38. LastUsedAt *time.Time
  39. CreatedAt time.Time
  40. UpdatedAt time.Time
  41. }
  42. type PasskeyRepository interface {
  43. EnsureUserHandle(ctx context.Context, userID int64, candidate []byte) ([]byte, error)
  44. GetUserHandle(ctx context.Context, userID int64) ([]byte, error)
  45. GetByCredentialID(ctx context.Context, credentialID []byte) (*PasskeyCredentialRecord, error)
  46. ListByUserID(ctx context.Context, userID int64) ([]PasskeyCredentialRecord, error)
  47. Create(ctx context.Context, record *PasskeyCredentialRecord) (*PasskeyCredentialRecord, error)
  48. UpdateCredential(ctx context.Context, userID int64, credential *webauthn.Credential, usedAt time.Time) error
  49. Rename(ctx context.Context, userID, credentialID int64, name string) error
  50. Delete(ctx context.Context, userID, credentialID int64) error
  51. }
  52. type PasskeySession struct {
  53. Kind string `json:"kind"`
  54. UserID int64 `json:"user_id,omitempty"`
  55. WebAuthn webauthn.SessionData `json:"webauthn"`
  56. }
  57. type PasskeySessionStore interface {
  58. Store(ctx context.Context, session *PasskeySession, ttl time.Duration) (string, error)
  59. Consume(ctx context.Context, token string) (*PasskeySession, error)
  60. }
  61. type PasskeyCredentialSummary struct {
  62. ID int64 `json:"id"`
  63. Name string `json:"name"`
  64. CreatedAt time.Time `json:"created_at"`
  65. LastUsedAt *time.Time `json:"last_used_at,omitempty"`
  66. Backup bool `json:"backup"`
  67. }
  68. type passkeyUser struct {
  69. account *User
  70. handle []byte
  71. credentials []webauthn.Credential
  72. }
  73. func (u *passkeyUser) WebAuthnID() []byte {
  74. return u.handle
  75. }
  76. func (u *passkeyUser) WebAuthnName() string {
  77. return u.account.Email
  78. }
  79. func (u *passkeyUser) WebAuthnDisplayName() string {
  80. if name := strings.TrimSpace(u.account.Username); name != "" {
  81. return name
  82. }
  83. return u.account.Email
  84. }
  85. func (u *passkeyUser) WebAuthnCredentials() []webauthn.Credential {
  86. return u.credentials
  87. }
  88. type PasskeyService struct {
  89. enabled bool
  90. webAuthn *webauthn.WebAuthn
  91. repo PasskeyRepository
  92. sessions PasskeySessionStore
  93. userRepo UserRepository
  94. }
  95. func NewPasskeyService(
  96. cfg *config.Config,
  97. repo PasskeyRepository,
  98. sessions PasskeySessionStore,
  99. userRepo UserRepository,
  100. ) (*PasskeyService, error) {
  101. s := &PasskeyService{
  102. repo: repo,
  103. sessions: sessions,
  104. userRepo: userRepo,
  105. }
  106. if cfg == nil || !cfg.WebAuthn.Enabled {
  107. return s, nil
  108. }
  109. instance, err := webauthn.New(&webauthn.Config{
  110. RPDisplayName: cfg.WebAuthn.RPDisplayName,
  111. RPID: cfg.WebAuthn.RPID,
  112. RPOrigins: cfg.WebAuthn.RPOrigins,
  113. AuthenticatorSelection: protocol.AuthenticatorSelection{
  114. ResidentKey: protocol.ResidentKeyRequirementRequired,
  115. UserVerification: protocol.VerificationRequired,
  116. },
  117. })
  118. if err != nil {
  119. return nil, fmt.Errorf("initialize WebAuthn: %w", err)
  120. }
  121. s.enabled = true
  122. s.webAuthn = instance
  123. return s, nil
  124. }
  125. func (s *PasskeyService) Enabled() bool {
  126. return s != nil && s.enabled && s.webAuthn != nil
  127. }
  128. func (s *PasskeyService) requireEnabled() error {
  129. if !s.Enabled() {
  130. return ErrPasskeysDisabled
  131. }
  132. return nil
  133. }
  134. // verifyPasskeyPassword gates credential enrollment and revocation with the
  135. // account password so a hijacked session cannot silently add or remove
  136. // passkeys. The password is used instead of TOTP step-up so the guard also
  137. // works on deployments without a TOTP encryption key configured.
  138. func verifyPasskeyPassword(user *User, password string) error {
  139. if password == "" {
  140. return ErrPasswordRequired
  141. }
  142. if user == nil || !user.CheckPassword(password) {
  143. return ErrPasswordIncorrect
  144. }
  145. return nil
  146. }
  147. func (s *PasskeyService) BeginRegistration(
  148. ctx context.Context,
  149. userID int64,
  150. password string,
  151. ) (creation *protocol.CredentialCreation, sessionToken string, err error) {
  152. if err = s.requireEnabled(); err != nil {
  153. return nil, "", err
  154. }
  155. user, err := s.userRepo.GetByID(ctx, userID)
  156. if err != nil {
  157. return nil, "", err
  158. }
  159. if !user.IsActive() {
  160. return nil, "", ErrUserNotActive
  161. }
  162. if err = verifyPasskeyPassword(user, password); err != nil {
  163. return nil, "", err
  164. }
  165. candidate := make([]byte, 32)
  166. if _, err = rand.Read(candidate); err != nil {
  167. return nil, "", fmt.Errorf("generate passkey user handle: %w", err)
  168. }
  169. handle, err := s.repo.EnsureUserHandle(ctx, userID, candidate)
  170. if err != nil {
  171. return nil, "", err
  172. }
  173. waUser, err := s.loadWebAuthnUser(ctx, user, handle)
  174. if err != nil {
  175. return nil, "", err
  176. }
  177. creation, session, err := s.webAuthn.BeginRegistration(
  178. waUser,
  179. webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementRequired),
  180. webauthn.WithExclusions(webauthn.Credentials(waUser.credentials).CredentialDescriptors()),
  181. webauthn.WithExtensions(protocol.AuthenticationExtensions{"credProps": true}),
  182. )
  183. if err != nil {
  184. return nil, "", fmt.Errorf("begin passkey registration: %w", err)
  185. }
  186. sessionToken, err = s.sessions.Store(ctx, &PasskeySession{
  187. Kind: passkeySessionRegistration,
  188. UserID: userID,
  189. WebAuthn: *session,
  190. }, passkeySessionTTL)
  191. if err != nil {
  192. return nil, "", err
  193. }
  194. return creation, sessionToken, nil
  195. }
  196. func (s *PasskeyService) FinishRegistration(
  197. ctx context.Context,
  198. userID int64,
  199. sessionToken, name string,
  200. request *http.Request,
  201. ) (*PasskeyCredentialSummary, error) {
  202. if err := s.requireEnabled(); err != nil {
  203. return nil, err
  204. }
  205. session, err := s.sessions.Consume(ctx, sessionToken)
  206. if err != nil {
  207. return nil, err
  208. }
  209. if session == nil || session.Kind != passkeySessionRegistration || session.UserID != userID {
  210. return nil, ErrPasskeySession
  211. }
  212. user, err := s.userRepo.GetByID(ctx, userID)
  213. if err != nil {
  214. return nil, err
  215. }
  216. handle, err := s.repo.GetUserHandle(ctx, userID)
  217. if err != nil {
  218. return nil, err
  219. }
  220. waUser, err := s.loadWebAuthnUser(ctx, user, handle)
  221. if err != nil {
  222. return nil, err
  223. }
  224. credential, err := s.webAuthn.FinishRegistration(waUser, session.WebAuthn, request)
  225. if err != nil {
  226. return nil, ErrPasskeyVerify
  227. }
  228. record, err := s.repo.Create(ctx, &PasskeyCredentialRecord{
  229. UserID: userID,
  230. UserHandle: handle,
  231. Name: normalizePasskeyName(name),
  232. Credential: *credential,
  233. })
  234. if err != nil {
  235. return nil, err
  236. }
  237. return passkeySummary(record), nil
  238. }
  239. func (s *PasskeyService) BeginLogin(
  240. ctx context.Context,
  241. ) (assertion *protocol.CredentialAssertion, sessionToken string, err error) {
  242. if err = s.requireEnabled(); err != nil {
  243. return nil, "", err
  244. }
  245. assertion, session, err := s.webAuthn.BeginDiscoverableLogin(
  246. webauthn.WithUserVerification(protocol.VerificationRequired),
  247. )
  248. if err != nil {
  249. return nil, "", fmt.Errorf("begin passkey login: %w", err)
  250. }
  251. sessionToken, err = s.sessions.Store(ctx, &PasskeySession{
  252. Kind: passkeySessionLogin,
  253. WebAuthn: *session,
  254. }, passkeySessionTTL)
  255. if err != nil {
  256. return nil, "", err
  257. }
  258. return assertion, sessionToken, nil
  259. }
  260. func (s *PasskeyService) FinishLogin(
  261. ctx context.Context,
  262. sessionToken string,
  263. request *http.Request,
  264. ) (*User, error) {
  265. if err := s.requireEnabled(); err != nil {
  266. return nil, err
  267. }
  268. session, err := s.sessions.Consume(ctx, sessionToken)
  269. if err != nil {
  270. return nil, err
  271. }
  272. if session == nil || session.Kind != passkeySessionLogin {
  273. return nil, ErrPasskeySession
  274. }
  275. handler := func(rawID, userHandle []byte) (webauthn.User, error) {
  276. record, lookupErr := s.repo.GetByCredentialID(ctx, rawID)
  277. if lookupErr != nil || record == nil || !bytes.Equal(record.UserHandle, userHandle) {
  278. return nil, ErrPasskeyVerify
  279. }
  280. account, lookupErr := s.userRepo.GetByID(ctx, record.UserID)
  281. if lookupErr != nil || account == nil || !account.IsActive() {
  282. return nil, ErrPasskeyVerify
  283. }
  284. return s.loadWebAuthnUser(ctx, account, record.UserHandle)
  285. }
  286. validatedUser, credential, err := s.webAuthn.FinishPasskeyLogin(handler, session.WebAuthn, request)
  287. if err != nil {
  288. return nil, ErrPasskeyVerify
  289. }
  290. waUser, ok := validatedUser.(*passkeyUser)
  291. if !ok || waUser.account == nil {
  292. return nil, ErrPasskeyVerify
  293. }
  294. if err = s.repo.UpdateCredential(ctx, waUser.account.ID, credential, time.Now().UTC()); err != nil {
  295. return nil, err
  296. }
  297. return waUser.account, nil
  298. }
  299. func (s *PasskeyService) List(ctx context.Context, userID int64) ([]PasskeyCredentialSummary, error) {
  300. if err := s.requireEnabled(); err != nil {
  301. return nil, err
  302. }
  303. records, err := s.repo.ListByUserID(ctx, userID)
  304. if err != nil {
  305. return nil, err
  306. }
  307. result := make([]PasskeyCredentialSummary, 0, len(records))
  308. for i := range records {
  309. result = append(result, *passkeySummary(&records[i]))
  310. }
  311. return result, nil
  312. }
  313. func (s *PasskeyService) Rename(ctx context.Context, userID, credentialID int64, name string) error {
  314. if err := s.requireEnabled(); err != nil {
  315. return err
  316. }
  317. return s.repo.Rename(ctx, userID, credentialID, normalizePasskeyName(name))
  318. }
  319. func (s *PasskeyService) Delete(ctx context.Context, userID, credentialID int64, password string) error {
  320. if err := s.requireEnabled(); err != nil {
  321. return err
  322. }
  323. user, err := s.userRepo.GetByID(ctx, userID)
  324. if err != nil {
  325. return err
  326. }
  327. if err = verifyPasskeyPassword(user, password); err != nil {
  328. return err
  329. }
  330. return s.repo.Delete(ctx, userID, credentialID)
  331. }
  332. func (s *PasskeyService) loadWebAuthnUser(
  333. ctx context.Context,
  334. user *User,
  335. handle []byte,
  336. ) (*passkeyUser, error) {
  337. records, err := s.repo.ListByUserID(ctx, user.ID)
  338. if err != nil {
  339. return nil, err
  340. }
  341. credentials := make([]webauthn.Credential, 0, len(records))
  342. for i := range records {
  343. credentials = append(credentials, records[i].Credential)
  344. }
  345. return &passkeyUser{account: user, handle: handle, credentials: credentials}, nil
  346. }
  347. func normalizePasskeyName(name string) string {
  348. name = strings.TrimSpace(name)
  349. if name == "" {
  350. return defaultPasskeyName
  351. }
  352. runes := []rune(name)
  353. if len(runes) > maxPasskeyNameLength {
  354. name = string(runes[:maxPasskeyNameLength])
  355. }
  356. return name
  357. }
  358. func passkeySummary(record *PasskeyCredentialRecord) *PasskeyCredentialSummary {
  359. return &PasskeyCredentialSummary{
  360. ID: record.ID,
  361. Name: record.Name,
  362. CreatedAt: record.CreatedAt,
  363. LastUsedAt: record.LastUsedAt,
  364. Backup: record.Credential.Flags.BackupState,
  365. }
  366. }
  367. var _ webauthn.User = (*passkeyUser)(nil)