412 lines
12 KiB
Go
412 lines
12 KiB
Go
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
|
|
}
|