init push
This commit is contained in:
411
internal/service/auth_webauthn.go
Normal file
411
internal/service/auth_webauthn.go
Normal file
@@ -0,0 +1,411 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user