init push

This commit is contained in:
2026-07-16 22:16:45 +07:00
commit 8b068bdb10
1021 changed files with 332816 additions and 0 deletions

View 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
}