Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
404 lines
11 KiB
Go
404 lines
11 KiB
Go
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)
|