package service import ( "bytes" "context" "encoding/json" "errors" "strings" "time" "github.com/go-webauthn/webauthn/protocol" webauthnlib "github.com/go-webauthn/webauthn/webauthn" "wucher/internal/domain/auth" ) const ( webAuthnCeremonyRegistration = "registration" webAuthnCeremonyLogin = "login" ) var ( ErrWebAuthnNotConfigured = errors.New("webauthn not configured") ErrWebAuthnChallengeInvalid = errors.New("invalid or expired webauthn challenge") ErrWebAuthnPayloadInvalid = errors.New("invalid webauthn payload") ErrWebAuthnCredentialNotFound = errors.New("webauthn credential not found") ErrWebAuthnCredentialExists = errors.New("webauthn credential already exists") ErrWebAuthnCredentialUnavailable = errors.New("webauthn credential is not configured for this account") ErrWebAuthnVerificationFailed = errors.New("webauthn verification failed") ) type webAuthnChallengeEnvelope struct { Ceremony string `json:"ceremony"` UserID []byte `json:"user_id"` Session webauthnlib.SessionData `json:"session"` } type webAuthnUser struct { id []byte name string displayName string credentials []webauthnlib.Credential } func (u webAuthnUser) WebAuthnID() []byte { return append([]byte(nil), u.id...) } func (u webAuthnUser) WebAuthnName() string { return u.name } func (u webAuthnUser) WebAuthnDisplayName() string { return u.displayName } func (u webAuthnUser) WebAuthnCredentials() []webauthnlib.Credential { return append([]webauthnlib.Credential(nil), u.credentials...) } func (s *AuthService) WebAuthnChallengeTTL() time.Duration { ttl := s.cfg.WebAuthnChallengeTTL if ttl <= 0 { return 5 * time.Minute } return ttl } func (s *AuthService) IsWebAuthnConfigured(ctx context.Context, userID []byte) (bool, error) { records, err := s.repo.ListUserWebAuthnCredentials(ctx, userID) if err != nil { return false, err } return len(records) > 0, nil } func (s *AuthService) BeginWebAuthnRegistration(ctx context.Context, userID []byte) (*protocol.CredentialCreation, string, error) { if len(userID) != 16 { return nil, "", ErrInvalidToken } client, err := s.webAuthnClient() if err != nil { return nil, "", err } user, err := s.repo.GetUserByID(ctx, userID) if err != nil { return nil, "", err } if user == nil { return nil, "", ErrInvalidToken } creds, err := s.loadWebAuthnCredentials(ctx, userID) if err != nil { return nil, "", err } waUser := buildWebAuthnUser(user, creds) opts := []webauthnlib.RegistrationOption{ webauthnlib.WithConveyancePreference(protocol.PreferNoAttestation), webauthnlib.WithResidentKeyRequirement(protocol.ResidentKeyRequirementPreferred), } if len(creds) > 0 { opts = append(opts, webauthnlib.WithExclusions(webauthnlib.Credentials(creds).CredentialDescriptors())) } creation, session, err := client.BeginRegistration(waUser, opts...) if err != nil { return nil, "", err } challengeToken, err := s.storeWebAuthnChallenge(ctx, webAuthnCeremonyRegistration, userID, session) if err != nil { return nil, "", err } s.logServiceAudit(ctx, "auth.webauthn.register.begin", userID, true, "challenge_issued", nil) return creation, challengeToken, nil } func (s *AuthService) FinishWebAuthnRegistration(ctx context.Context, userID []byte, challengeToken string, credentialJSON []byte, name string) error { if len(userID) != 16 { return ErrInvalidToken } client, err := s.webAuthnClient() if err != nil { return err } session, err := s.consumeWebAuthnChallenge(ctx, challengeToken, webAuthnCeremonyRegistration) if err != nil { s.logServiceAudit(ctx, "auth.webauthn.register.finish", userID, false, "invalid_challenge", nil) return err } if !bytes.Equal(session.UserID, userID) { s.logServiceAudit(ctx, "auth.webauthn.register.finish", userID, false, "challenge_user_mismatch", nil) return ErrWebAuthnChallengeInvalid } user, err := s.repo.GetUserByID(ctx, userID) if err != nil { return err } if user == nil { return ErrInvalidToken } creds, err := s.loadWebAuthnCredentials(ctx, userID) if err != nil { return err } waUser := buildWebAuthnUser(user, creds) parsed, err := protocol.ParseCredentialCreationResponseBody(bytes.NewReader(credentialJSON)) if err != nil { s.logServiceAudit(ctx, "auth.webauthn.register.finish", userID, false, "invalid_payload", nil) return ErrWebAuthnPayloadInvalid } credential, err := client.CreateCredential(waUser, session.Session, parsed) if err != nil { s.logServiceAudit(ctx, "auth.webauthn.register.finish", userID, false, "verification_failed", nil) return ErrWebAuthnVerificationFailed } for i := range creds { if bytes.Equal(creds[i].ID, credential.ID) { return ErrWebAuthnCredentialExists } } serialized, err := json.Marshal(credential) if err != nil { return err } rec := &auth.UserWebAuthnCredential{ UserID: userID, Name: truncate(strings.TrimSpace(name), 100), CredentialID: append([]byte(nil), credential.ID...), CredentialJSON: serialized, } if err := s.repo.CreateUserWebAuthnCredential(ctx, rec); err != nil { return err } s.logServiceAudit(ctx, "auth.webauthn.register.finish", userID, true, "credential_registered", nil) return nil } func (s *AuthService) BeginWebAuthnLogin(ctx context.Context, email string) (*protocol.CredentialAssertion, string, error) { client, err := s.webAuthnClient() if err != nil { return nil, "", err } email = strings.ToLower(strings.TrimSpace(email)) user, err := s.repo.GetUserByEmail(ctx, email) if err != nil { return nil, "", err } if user == nil { return nil, "", ErrInvalidCredentials } if !user.IsActive { return nil, "", ErrInvalidCredentials } if user.EmailVerifiedAt == nil { return nil, "", ErrEmailNotVerified } creds, err := s.loadWebAuthnCredentials(ctx, user.ID) if err != nil { return nil, "", err } if len(creds) == 0 { return nil, "", ErrWebAuthnCredentialUnavailable } waUser := buildWebAuthnUser(user, creds) assertion, session, err := client.BeginLogin(waUser, webauthnlib.WithUserVerification(protocol.VerificationRequired)) if err != nil { return nil, "", err } challengeToken, err := s.storeWebAuthnChallenge(ctx, webAuthnCeremonyLogin, user.ID, session) if err != nil { return nil, "", err } s.logServiceAudit(ctx, "auth.webauthn.login.begin", user.ID, true, "challenge_issued", nil) return assertion, challengeToken, nil } func (s *AuthService) FinishWebAuthnLogin(ctx context.Context, challengeToken string, credentialJSON []byte) (*auth.User, error) { client, err := s.webAuthnClient() if err != nil { return nil, err } session, err := s.consumeWebAuthnChallenge(ctx, challengeToken, webAuthnCeremonyLogin) if err != nil { s.logServiceAudit(ctx, "auth.webauthn.login.finish", nil, false, "invalid_challenge", nil) return nil, err } if len(session.UserID) != 16 { return nil, ErrWebAuthnChallengeInvalid } user, err := s.repo.GetUserByID(ctx, session.UserID) if err != nil { return nil, err } if user == nil { return nil, ErrInvalidCredentials } creds, err := s.loadWebAuthnCredentials(ctx, session.UserID) if err != nil { return nil, err } if len(creds) == 0 { return nil, ErrWebAuthnCredentialUnavailable } waUser := buildWebAuthnUser(user, creds) parsed, err := protocol.ParseCredentialRequestResponseBody(bytes.NewReader(credentialJSON)) if err != nil { s.logServiceAudit(ctx, "auth.webauthn.login.finish", session.UserID, false, "invalid_payload", nil) return nil, ErrWebAuthnPayloadInvalid } credential, err := client.ValidateLogin(waUser, session.Session, parsed) if err != nil { s.logServiceAudit(ctx, "auth.webauthn.login.finish", session.UserID, false, "verification_failed", nil) return nil, ErrWebAuthnVerificationFailed } serialized, err := json.Marshal(credential) if err != nil { return nil, err } if err := s.repo.UpdateUserWebAuthnCredential(ctx, session.UserID, credential.ID, serialized, time.Now().UTC()); err != nil { return nil, err } s.logServiceAudit(ctx, "auth.webauthn.login.finish", session.UserID, true, "login_success", nil) return user, nil } func (s *AuthService) webAuthnClient() (*webauthnlib.WebAuthn, error) { rpID := strings.TrimSpace(s.cfg.WebAuthnRPID) rpDisplayName := strings.TrimSpace(s.cfg.WebAuthnRPDisplayName) rpOrigins := make([]string, 0, len(s.cfg.WebAuthnRPOrigins)) for i := range s.cfg.WebAuthnRPOrigins { origin := strings.TrimSpace(s.cfg.WebAuthnRPOrigins[i]) if origin == "" { continue } rpOrigins = append(rpOrigins, origin) } if rpID == "" || len(rpOrigins) == 0 { return nil, ErrWebAuthnNotConfigured } if rpDisplayName == "" { rpDisplayName = "Wucher" } ttl := s.WebAuthnChallengeTTL() return webauthnlib.New(&webauthnlib.Config{ RPID: rpID, RPDisplayName: rpDisplayName, RPOrigins: rpOrigins, AttestationPreference: protocol.PreferNoAttestation, AuthenticatorSelection: protocol.AuthenticatorSelection{ ResidentKey: protocol.ResidentKeyRequirementPreferred, RequireResidentKey: protocol.ResidentKeyNotRequired(), UserVerification: protocol.VerificationRequired, }, Timeouts: webauthnlib.TimeoutsConfig{ Login: webauthnlib.TimeoutConfig{ Enforce: true, Timeout: ttl, TimeoutUVD: ttl, }, Registration: webauthnlib.TimeoutConfig{ Enforce: true, Timeout: ttl, TimeoutUVD: ttl, }, }, }) } func (s *AuthService) loadWebAuthnCredentials(ctx context.Context, userID []byte) ([]webauthnlib.Credential, error) { records, err := s.repo.ListUserWebAuthnCredentials(ctx, userID) if err != nil { return nil, err } out := make([]webauthnlib.Credential, 0, len(records)) for i := range records { var credential webauthnlib.Credential if err := json.Unmarshal(records[i].CredentialJSON, &credential); err != nil { return nil, err } out = append(out, credential) } return out, nil } func buildWebAuthnUser(user *auth.User, credentials []webauthnlib.Credential) webAuthnUser { name := strings.TrimSpace(user.Email) displayName := strings.TrimSpace(strings.TrimSpace(user.FirstName) + " " + strings.TrimSpace(user.LastName)) if displayName == "" { displayName = name } return webAuthnUser{ id: append([]byte(nil), user.ID...), name: name, displayName: displayName, credentials: credentials, } } func (s *AuthService) storeWebAuthnChallenge(ctx context.Context, ceremony string, userID []byte, session *webauthnlib.SessionData) (string, error) { if s.tokens == nil { return "", ErrWebAuthnChallengeInvalid } if session == nil { return "", ErrWebAuthnChallengeInvalid } token, err := randomToken(32) if err != nil { return "", err } envelope := webAuthnChallengeEnvelope{ Ceremony: ceremony, UserID: append([]byte(nil), userID...), Session: *session, } payload, err := json.Marshal(envelope) if err != nil { return "", err } if err := s.tokens.Set(ctx, webAuthnChallengeKey(token), payload, s.WebAuthnChallengeTTL()); err != nil { return "", err } return token, nil } func (s *AuthService) consumeWebAuthnChallenge(ctx context.Context, token, ceremony string) (*webAuthnChallengeEnvelope, error) { token = strings.TrimSpace(token) if token == "" || s.tokens == nil { return nil, ErrWebAuthnChallengeInvalid } raw, err := s.tokens.Get(ctx, webAuthnChallengeKey(token)) if err != nil || raw == nil { return nil, ErrWebAuthnChallengeInvalid } _ = s.tokens.Delete(ctx, webAuthnChallengeKey(token)) var envelope webAuthnChallengeEnvelope if err := json.Unmarshal(raw, &envelope); err != nil { return nil, ErrWebAuthnChallengeInvalid } if strings.TrimSpace(envelope.Ceremony) != ceremony { return nil, ErrWebAuthnChallengeInvalid } if !envelope.Session.Expires.IsZero() && envelope.Session.Expires.Before(time.Now().UTC()) { return nil, ErrWebAuthnChallengeInvalid } return &envelope, nil } func webAuthnChallengeKey(token string) string { return "auth:webauthn:challenge:" + token }