| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403 |
- package service
- import (
- "bytes"
- "context"
- "crypto/rand"
- "fmt"
- "net/http"
- "strings"
- "time"
- "github.com/Wei-Shaw/sub2api/internal/config"
- infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
- "github.com/go-webauthn/webauthn/protocol"
- "github.com/go-webauthn/webauthn/webauthn"
- )
- const (
- passkeySessionRegistration = "registration"
- passkeySessionLogin = "login"
- passkeySessionTTL = 5 * time.Minute
- defaultPasskeyName = "Passkey"
- maxPasskeyNameLength = 100
- )
- var (
- ErrPasskeysDisabled = infraerrors.Forbidden("PASSKEY_DISABLED", "passkey authentication is not enabled")
- ErrPasskeyNotFound = infraerrors.NotFound("PASSKEY_NOT_FOUND", "passkey not found")
- ErrPasskeyExists = infraerrors.Conflict("PASSKEY_ALREADY_EXISTS", "this passkey is already registered")
- ErrPasskeySession = infraerrors.BadRequest("PASSKEY_SESSION_INVALID", "passkey session is invalid or expired")
- ErrPasskeyVerify = infraerrors.Unauthorized("PASSKEY_VERIFICATION_FAILED", "passkey verification failed")
- )
- // PasskeyCredentialRecord is the persistence representation used by the
- // WebAuthn service. Credential contains the complete WebAuthn credential record
- // so future library versions can continue to validate and update it.
- type PasskeyCredentialRecord struct {
- ID int64
- UserID int64
- UserHandle []byte
- Name string
- Credential webauthn.Credential
- LastUsedAt *time.Time
- CreatedAt time.Time
- UpdatedAt time.Time
- }
- type PasskeyRepository interface {
- EnsureUserHandle(ctx context.Context, userID int64, candidate []byte) ([]byte, error)
- GetUserHandle(ctx context.Context, userID int64) ([]byte, error)
- GetByCredentialID(ctx context.Context, credentialID []byte) (*PasskeyCredentialRecord, error)
- ListByUserID(ctx context.Context, userID int64) ([]PasskeyCredentialRecord, error)
- Create(ctx context.Context, record *PasskeyCredentialRecord) (*PasskeyCredentialRecord, error)
- UpdateCredential(ctx context.Context, userID int64, credential *webauthn.Credential, usedAt time.Time) error
- Rename(ctx context.Context, userID, credentialID int64, name string) error
- Delete(ctx context.Context, userID, credentialID int64) error
- }
- type PasskeySession struct {
- Kind string `json:"kind"`
- UserID int64 `json:"user_id,omitempty"`
- WebAuthn webauthn.SessionData `json:"webauthn"`
- }
- type PasskeySessionStore interface {
- Store(ctx context.Context, session *PasskeySession, ttl time.Duration) (string, error)
- Consume(ctx context.Context, token string) (*PasskeySession, error)
- }
- type PasskeyCredentialSummary struct {
- ID int64 `json:"id"`
- Name string `json:"name"`
- CreatedAt time.Time `json:"created_at"`
- LastUsedAt *time.Time `json:"last_used_at,omitempty"`
- Backup bool `json:"backup"`
- }
- type passkeyUser struct {
- account *User
- handle []byte
- credentials []webauthn.Credential
- }
- func (u *passkeyUser) WebAuthnID() []byte {
- return u.handle
- }
- func (u *passkeyUser) WebAuthnName() string {
- return u.account.Email
- }
- func (u *passkeyUser) WebAuthnDisplayName() string {
- if name := strings.TrimSpace(u.account.Username); name != "" {
- return name
- }
- return u.account.Email
- }
- func (u *passkeyUser) WebAuthnCredentials() []webauthn.Credential {
- return u.credentials
- }
- type PasskeyService struct {
- enabled bool
- webAuthn *webauthn.WebAuthn
- repo PasskeyRepository
- sessions PasskeySessionStore
- userRepo UserRepository
- }
- func NewPasskeyService(
- cfg *config.Config,
- repo PasskeyRepository,
- sessions PasskeySessionStore,
- userRepo UserRepository,
- ) (*PasskeyService, error) {
- s := &PasskeyService{
- repo: repo,
- sessions: sessions,
- userRepo: userRepo,
- }
- if cfg == nil || !cfg.WebAuthn.Enabled {
- return s, nil
- }
- instance, err := webauthn.New(&webauthn.Config{
- RPDisplayName: cfg.WebAuthn.RPDisplayName,
- RPID: cfg.WebAuthn.RPID,
- RPOrigins: cfg.WebAuthn.RPOrigins,
- AuthenticatorSelection: protocol.AuthenticatorSelection{
- ResidentKey: protocol.ResidentKeyRequirementRequired,
- UserVerification: protocol.VerificationRequired,
- },
- })
- if err != nil {
- return nil, fmt.Errorf("initialize WebAuthn: %w", err)
- }
- s.enabled = true
- s.webAuthn = instance
- return s, nil
- }
- func (s *PasskeyService) Enabled() bool {
- return s != nil && s.enabled && s.webAuthn != nil
- }
- func (s *PasskeyService) requireEnabled() error {
- if !s.Enabled() {
- return ErrPasskeysDisabled
- }
- return nil
- }
- // verifyPasskeyPassword gates credential enrollment and revocation with the
- // account password so a hijacked session cannot silently add or remove
- // passkeys. The password is used instead of TOTP step-up so the guard also
- // works on deployments without a TOTP encryption key configured.
- func verifyPasskeyPassword(user *User, password string) error {
- if password == "" {
- return ErrPasswordRequired
- }
- if user == nil || !user.CheckPassword(password) {
- return ErrPasswordIncorrect
- }
- return nil
- }
- func (s *PasskeyService) BeginRegistration(
- ctx context.Context,
- userID int64,
- password string,
- ) (creation *protocol.CredentialCreation, sessionToken string, err error) {
- if err = s.requireEnabled(); err != nil {
- return nil, "", err
- }
- user, err := s.userRepo.GetByID(ctx, userID)
- if err != nil {
- return nil, "", err
- }
- if !user.IsActive() {
- return nil, "", ErrUserNotActive
- }
- if err = verifyPasskeyPassword(user, password); err != nil {
- return nil, "", err
- }
- candidate := make([]byte, 32)
- if _, err = rand.Read(candidate); err != nil {
- return nil, "", fmt.Errorf("generate passkey user handle: %w", err)
- }
- handle, err := s.repo.EnsureUserHandle(ctx, userID, candidate)
- if err != nil {
- return nil, "", err
- }
- waUser, err := s.loadWebAuthnUser(ctx, user, handle)
- if err != nil {
- return nil, "", err
- }
- creation, session, err := s.webAuthn.BeginRegistration(
- waUser,
- webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementRequired),
- webauthn.WithExclusions(webauthn.Credentials(waUser.credentials).CredentialDescriptors()),
- webauthn.WithExtensions(protocol.AuthenticationExtensions{"credProps": true}),
- )
- if err != nil {
- return nil, "", fmt.Errorf("begin passkey registration: %w", err)
- }
- sessionToken, err = s.sessions.Store(ctx, &PasskeySession{
- Kind: passkeySessionRegistration,
- UserID: userID,
- WebAuthn: *session,
- }, passkeySessionTTL)
- if err != nil {
- return nil, "", err
- }
- return creation, sessionToken, nil
- }
- func (s *PasskeyService) FinishRegistration(
- ctx context.Context,
- userID int64,
- sessionToken, name string,
- request *http.Request,
- ) (*PasskeyCredentialSummary, error) {
- if err := s.requireEnabled(); err != nil {
- return nil, err
- }
- session, err := s.sessions.Consume(ctx, sessionToken)
- if err != nil {
- return nil, err
- }
- if session == nil || session.Kind != passkeySessionRegistration || session.UserID != userID {
- return nil, ErrPasskeySession
- }
- user, err := s.userRepo.GetByID(ctx, userID)
- if err != nil {
- return nil, err
- }
- handle, err := s.repo.GetUserHandle(ctx, userID)
- if err != nil {
- return nil, err
- }
- waUser, err := s.loadWebAuthnUser(ctx, user, handle)
- if err != nil {
- return nil, err
- }
- credential, err := s.webAuthn.FinishRegistration(waUser, session.WebAuthn, request)
- if err != nil {
- return nil, ErrPasskeyVerify
- }
- record, err := s.repo.Create(ctx, &PasskeyCredentialRecord{
- UserID: userID,
- UserHandle: handle,
- Name: normalizePasskeyName(name),
- Credential: *credential,
- })
- if err != nil {
- return nil, err
- }
- return passkeySummary(record), nil
- }
- func (s *PasskeyService) BeginLogin(
- ctx context.Context,
- ) (assertion *protocol.CredentialAssertion, sessionToken string, err error) {
- if err = s.requireEnabled(); err != nil {
- return nil, "", err
- }
- assertion, session, err := s.webAuthn.BeginDiscoverableLogin(
- webauthn.WithUserVerification(protocol.VerificationRequired),
- )
- if err != nil {
- return nil, "", fmt.Errorf("begin passkey login: %w", err)
- }
- sessionToken, err = s.sessions.Store(ctx, &PasskeySession{
- Kind: passkeySessionLogin,
- WebAuthn: *session,
- }, passkeySessionTTL)
- if err != nil {
- return nil, "", err
- }
- return assertion, sessionToken, nil
- }
- func (s *PasskeyService) FinishLogin(
- ctx context.Context,
- sessionToken string,
- request *http.Request,
- ) (*User, error) {
- if err := s.requireEnabled(); err != nil {
- return nil, err
- }
- session, err := s.sessions.Consume(ctx, sessionToken)
- if err != nil {
- return nil, err
- }
- if session == nil || session.Kind != passkeySessionLogin {
- return nil, ErrPasskeySession
- }
- handler := func(rawID, userHandle []byte) (webauthn.User, error) {
- record, lookupErr := s.repo.GetByCredentialID(ctx, rawID)
- if lookupErr != nil || record == nil || !bytes.Equal(record.UserHandle, userHandle) {
- return nil, ErrPasskeyVerify
- }
- account, lookupErr := s.userRepo.GetByID(ctx, record.UserID)
- if lookupErr != nil || account == nil || !account.IsActive() {
- return nil, ErrPasskeyVerify
- }
- return s.loadWebAuthnUser(ctx, account, record.UserHandle)
- }
- validatedUser, credential, err := s.webAuthn.FinishPasskeyLogin(handler, session.WebAuthn, request)
- if err != nil {
- return nil, ErrPasskeyVerify
- }
- waUser, ok := validatedUser.(*passkeyUser)
- if !ok || waUser.account == nil {
- return nil, ErrPasskeyVerify
- }
- if err = s.repo.UpdateCredential(ctx, waUser.account.ID, credential, time.Now().UTC()); err != nil {
- return nil, err
- }
- return waUser.account, nil
- }
- func (s *PasskeyService) List(ctx context.Context, userID int64) ([]PasskeyCredentialSummary, error) {
- if err := s.requireEnabled(); err != nil {
- return nil, err
- }
- records, err := s.repo.ListByUserID(ctx, userID)
- if err != nil {
- return nil, err
- }
- result := make([]PasskeyCredentialSummary, 0, len(records))
- for i := range records {
- result = append(result, *passkeySummary(&records[i]))
- }
- return result, nil
- }
- func (s *PasskeyService) Rename(ctx context.Context, userID, credentialID int64, name string) error {
- if err := s.requireEnabled(); err != nil {
- return err
- }
- return s.repo.Rename(ctx, userID, credentialID, normalizePasskeyName(name))
- }
- func (s *PasskeyService) Delete(ctx context.Context, userID, credentialID int64, password string) error {
- if err := s.requireEnabled(); err != nil {
- return err
- }
- user, err := s.userRepo.GetByID(ctx, userID)
- if err != nil {
- return err
- }
- if err = verifyPasskeyPassword(user, password); err != nil {
- return err
- }
- return s.repo.Delete(ctx, userID, credentialID)
- }
- func (s *PasskeyService) loadWebAuthnUser(
- ctx context.Context,
- user *User,
- handle []byte,
- ) (*passkeyUser, error) {
- records, err := s.repo.ListByUserID(ctx, user.ID)
- if err != nil {
- return nil, err
- }
- credentials := make([]webauthn.Credential, 0, len(records))
- for i := range records {
- credentials = append(credentials, records[i].Credential)
- }
- return &passkeyUser{account: user, handle: handle, credentials: credentials}, nil
- }
- func normalizePasskeyName(name string) string {
- name = strings.TrimSpace(name)
- if name == "" {
- return defaultPasskeyName
- }
- runes := []rune(name)
- if len(runes) > maxPasskeyNameLength {
- name = string(runes[:maxPasskeyNameLength])
- }
- return name
- }
- func passkeySummary(record *PasskeyCredentialRecord) *PasskeyCredentialSummary {
- return &PasskeyCredentialSummary{
- ID: record.ID,
- Name: record.Name,
- CreatedAt: record.CreatedAt,
- LastUsedAt: record.LastUsedAt,
- Backup: record.Credential.Flags.BackupState,
- }
- }
- var _ webauthn.User = (*passkeyUser)(nil)
|