package service import ( "bytes" "context" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "errors" "html" "io" "log" "net" "net/url" "sort" "strconv" "strings" "time" "github.com/golang-jwt/jwt/v5" "github.com/pquerna/otp" "github.com/pquerna/otp/totp" "golang.org/x/crypto/argon2" "wucher/internal/config" "wucher/internal/domain/auth" "wucher/internal/queue" "wucher/internal/shared/pkg/timezone" "wucher/internal/shared/pkg/uuidv7" ) var ( ErrInvalidCredentials = errors.New("invalid credentials") ErrEmailNotVerified = errors.New("email not verified") ErrInvalidToken = errors.New("invalid or expired token") ErrInvalidSSOState = errors.New("invalid or expired sso state") ErrInvalidSSOReauth = errors.New("invalid or expired sso reauth") ErrSSONotLinked = errors.New("sso not linked") ErrSSOEmailNotFound = errors.New("sso email not found") ErrSSOAlreadyLinked = errors.New("sso already linked") ErrSSOLinkedToAnotherUser = errors.New("sso linked to another user") ErrUserNotFound = errors.New("user not found") ErrUnsupportedSSOProvider = errors.New("unsupported sso provider") ErrTOTPRequired = errors.New("totp required") ErrTOTPChallengeAttemptLimit = errors.New("totp challenge attempt limit reached") ErrEmailOTPRequired = errors.New("email otp required") ErrInvalidEmailOTP = errors.New("invalid email otp") ErrEmailOTPRateLimited = errors.New("email otp resend is rate limited") ErrEmailOTPResendExceeded = errors.New("email otp resend limit exceeded") ErrInvalidResetLink = errors.New("invalid or expired reset link") ErrInvalidSecurityPIN = errors.New("invalid security pin") ErrSecurityPINAlreadySet = errors.New("security pin is already set") ErrSecurityPINNotSet = errors.New("security pin is not set") ErrSecurityPINLocked = errors.New("security pin is blocked") ErrSecurityPINNotBlocked = errors.New("security pin is not blocked") ErrSecurityPINResetRatelimit = errors.New("security pin reset request rate limited") ErrSecurityPINResetRequired = errors.New("security pin reset required") ErrSecurityPINAttemptLimit = errors.New("security pin attempt limit reached") ErrInvalidPINResetPassword = errors.New("invalid password for pin reset") ErrInvalidPINAction = errors.New("invalid or expired pin verification token") ErrInvalidReauthMethod = errors.New("invalid reauth method") ErrSessionNotFound = errors.New("session not found") ErrMicrosoftReauthRequired = errors.New("microsoft reauth required") ) type TokenStore interface { Set(ctx context.Context, key string, value []byte, ttl time.Duration) error SetIfNotExists(ctx context.Context, key string, value []byte, ttl time.Duration) (bool, error) Get(ctx context.Context, key string) ([]byte, error) Delete(ctx context.Context, key string) error } type SecretProtector interface { Encrypt(plain []byte) ([]byte, error) Decrypt(cipher []byte) ([]byte, error) } type MicrosoftSSO interface { AuthCodeURL(state string) string LogoutURL(postLogoutRedirectURL string) string ExchangeCode(ctx context.Context, code string) (MicrosoftClaims, error) } type MicrosoftClaims struct { Subject string Email string Name string TenantID string ObjectID string SessionID string } const totpChallengeMaxAttempts = 5 type microsoftTokenExchanger interface { ExchangeCodeAndTokens(ctx context.Context, code, codeVerifier string) (MicrosoftTokenSet, MicrosoftClaims, error) RefreshAccessToken(ctx context.Context, refreshToken, codeVerifier string) (MicrosoftTokenSet, error) } type microsoftOAuthTokenStore interface { UpsertMicrosoftOAuthToken(ctx context.Context, token *auth.UserMicrosoftOAuthToken) error GetMicrosoftOAuthTokenByUserIDProvider(ctx context.Context, userID []byte, provider string) (*auth.UserMicrosoftOAuthToken, error) DeleteMicrosoftOAuthTokenByUserIDProvider(ctx context.Context, userID []byte, provider string) (bool, error) } type microsoftStatePayload struct { Flow string `json:"flow"` UserID string `json:"user_id,omitempty"` Purpose string `json:"purpose,omitempty"` CodeVerifier string `json:"code_verifier,omitempty"` } type AuthService struct { repo auth.Repository roleRepo auth.RoleRepository tokens TokenStore emailQueue EmailQueue emailOutbox auth.EmailOutboxWriter emailSerializer queue.MessageSerializer audit *AuditLogService sso MicrosoftSSO protector SecretProtector ipProtector SecretProtector emailBranding AuthEmailBrandingResolver emailOTPEnabled func(context.Context) (bool, error) cfg config.AuthConfig } type AuthServiceDependencies struct { SSO MicrosoftSSO Protector SecretProtector Config config.AuthConfig Audit *AuditLogService EmailOutbox auth.EmailOutboxWriter EmailSerializer queue.MessageSerializer EmailBranding AuthEmailBrandingResolver EmailOTPEnabled func(context.Context) (bool, error) // Disable legacy behavior that swaps nil email queue with noop queue. DisableLegacyNoopEmailQueue bool } func NewAuthService( repo auth.Repository, roleRepo auth.RoleRepository, tokens TokenStore, emailQueue EmailQueue, deps AuthServiceDependencies, ) *AuthService { if emailQueue == nil && !deps.DisableLegacyNoopEmailQueue { emailQueue = noopEmailQueue{} } cfg := normalizeAuthConfig(deps.Config) return &AuthService{ repo: repo, roleRepo: roleRepo, tokens: tokens, emailQueue: emailQueue, emailOutbox: deps.EmailOutbox, emailSerializer: deps.EmailSerializer, audit: deps.Audit, sso: deps.SSO, protector: deps.Protector, ipProtector: newIPSecretProtector(cfg.IPEncryptionKey), emailBranding: deps.EmailBranding, emailOTPEnabled: deps.EmailOTPEnabled, cfg: cfg, } } type noopEmailQueue struct{} func (noopEmailQueue) Enqueue(context.Context, EmailJob) error { return nil } func (s *AuthService) SetAuditLogger(audit *AuditLogService) { s.audit = audit } func (s *AuthService) SetEmailOutbox(outbox auth.EmailOutboxWriter, serializer queue.MessageSerializer) { s.emailOutbox = outbox s.emailSerializer = serializer } func (s *AuthService) BeginMicrosoftLogin(ctx context.Context) (string, error) { return s.beginMicrosoftLoginWithFlowAndPrompt(ctx, microsoftStateFlowLogin, "select_account") } func (s *AuthService) BeginMicrosoftSessionCheck(ctx context.Context) (string, error) { return s.beginMicrosoftLoginWithFlowAndPrompt(ctx, microsoftStateFlowLoginCheck, "none") } func (s *AuthService) BeginMicrosoftSessionCheckStrict(ctx context.Context) (string, error) { return s.beginMicrosoftLoginWithFlowAndPrompt(ctx, microsoftStateFlowLoginCheckStrict, "none") } func (s *AuthService) beginMicrosoftLoginWithFlowAndPrompt(ctx context.Context, flow, prompt string) (string, error) { if s.sso == nil || s.tokens == nil { return "", errors.New("sso not configured") } state, err := randomToken(32) if err != nil { return "", err } codeVerifier, err := randomToken(64) if err != nil { return "", err } payload, err := encodeMicrosoftStatePayload(microsoftStatePayload{Flow: flow, CodeVerifier: codeVerifier}) if err != nil { return "", err } authURL := strings.TrimSpace(s.sso.AuthCodeURL(state)) if _, ok := s.sso.(microsoftTokenExchanger); ok { authURL = withMicrosoftPKCE(authURL, codeVerifier) } authURL = withMicrosoftAuthPrompt(authURL, prompt) if authURL == "" { return "", errors.New("sso not configured") } if err := s.tokens.Set(ctx, ssoStateKey(state), payload, s.cfg.SSOStateTTL); err != nil { return "", err } return authURL, nil } func (s *AuthService) BeginMicrosoftLink(ctx context.Context, userID []byte) (string, error) { if s.sso == nil || s.tokens == nil { return "", errors.New("sso not configured") } if len(userID) != 16 { return "", ErrUserNotFound } userIDStr, err := uuidv7.BytesToString(userID) if err != nil { return "", ErrUserNotFound } state, err := randomToken(32) if err != nil { return "", err } codeVerifier, err := randomToken(64) if err != nil { return "", err } payload, err := encodeMicrosoftStatePayload(microsoftStatePayload{ Flow: microsoftStateFlowLink, UserID: userIDStr, CodeVerifier: codeVerifier, }) if err != nil { return "", err } authURL := strings.TrimSpace(s.sso.AuthCodeURL(state)) if _, ok := s.sso.(microsoftTokenExchanger); ok { authURL = withMicrosoftPKCE(authURL, codeVerifier) } if authURL == "" { return "", errors.New("sso not configured") } if err := s.tokens.Set(ctx, ssoStateKey(state), payload, s.cfg.SSOStateTTL); err != nil { return "", err } return authURL, nil } func (s *AuthService) BeginMicrosoftReauth(ctx context.Context, userID []byte, purpose string) (string, error) { if s.sso == nil || s.tokens == nil { return "", errors.New("sso not configured") } if len(userID) != 16 || !isSupportedMicrosoftReauthPurpose(purpose) { return "", ErrInvalidToken } userIDStr, err := uuidv7.BytesToString(userID) if err != nil { return "", ErrInvalidToken } state, err := randomToken(32) if err != nil { return "", err } codeVerifier, err := randomToken(64) if err != nil { return "", err } payload, err := encodeMicrosoftStatePayload(microsoftStatePayload{ Flow: microsoftStateFlowReauth, UserID: userIDStr, Purpose: purpose, CodeVerifier: codeVerifier, }) if err != nil { return "", err } authURL := strings.TrimSpace(s.sso.AuthCodeURL(state)) if _, ok := s.sso.(microsoftTokenExchanger); ok { authURL = withMicrosoftPKCE(authURL, codeVerifier) } if authURL == "" { return "", errors.New("sso not configured") } if err := s.tokens.Set(ctx, ssoStateKey(state), payload, s.cfg.SSOStateTTL); err != nil { return "", err } return authURL, nil } func (s *AuthService) BeginMicrosoftPINResetReauth(ctx context.Context, rawResetToken string) (string, error) { rawResetToken = strings.TrimSpace(rawResetToken) if rawResetToken == "" { return "", ErrInvalidResetLink } now := time.Now().UTC() user, err := s.repo.GetActiveSecurityPINResetTokenUser(ctx, hashToken(rawResetToken), now) if err != nil { return "", err } if user == nil || len(user.ID) != 16 { return "", ErrInvalidResetLink } return s.BeginMicrosoftReauth(ctx, user.ID, microsoftReauthPurposePINResetConfirm) } func (s *AuthService) MicrosoftStateMode(ctx context.Context, state string) (string, error) { payload, err := s.loadMicrosoftStatePayload(ctx, state) if err != nil { return "", err } return payload.Flow, nil } func (s *AuthService) CompleteMicrosoftLogin(ctx context.Context, code, state string) (*auth.UserIdentity, string, error) { if s.sso == nil || s.tokens == nil { return nil, "", errors.New("sso not configured") } payload, err := s.loadMicrosoftStatePayload(ctx, state) if err != nil { return nil, "", err } if payload.Flow != microsoftStateFlowLogin && payload.Flow != microsoftStateFlowLoginCheck && payload.Flow != microsoftStateFlowLoginCheckStrict { return nil, "", ErrInvalidSSOState } // Consume SSO state exactly once to avoid replay with the same OAuth code. defer func() { _ = s.tokens.Delete(ctx, ssoStateKey(state)) }() identity, microsoftSessionID, err := s.LoginWithMicrosoft(ctx, code, payload.CodeVerifier) if err != nil { return nil, "", err } return identity, microsoftSessionID, nil } func (s *AuthService) ClearMicrosoftState(ctx context.Context, state string) { if s == nil || s.tokens == nil { return } state = strings.TrimSpace(state) if state == "" { return } _ = s.tokens.Delete(ctx, ssoStateKey(state)) } func (s *AuthService) CompleteMicrosoftReauth(ctx context.Context, code, state string) (string, string, error) { if s.sso == nil || s.tokens == nil { return "", "", errors.New("sso not configured") } payload, err := s.loadMicrosoftStatePayload(ctx, state) if err != nil { return "", "", err } if payload.Flow != microsoftStateFlowReauth || payload.UserID == "" || !isSupportedMicrosoftReauthPurpose(payload.Purpose) { return "", "", ErrInvalidSSOState } // Consume SSO state exactly once to avoid replay with the same OAuth code. defer func() { _ = s.tokens.Delete(ctx, ssoStateKey(state)) }() claims, _, err := s.exchangeMicrosoftCode(ctx, code, payload.CodeVerifier) if err != nil { return "", "", err } identity, err := s.repo.GetIdentityByProviderSubject(ctx, "microsoft", claims.Subject) if err != nil { return "", "", err } if identity == nil || len(identity.UserID) != 16 || bytesToString(identity.UserID) != payload.UserID { return "", "", ErrInvalidSSOReauth } token, err := randomToken(32) if err != nil { return "", "", err } if err := s.tokens.Set(ctx, microsoftReauthKey(token), []byte(payload.UserID+"|"+payload.Purpose), s.microsoftReauthTTL()); err != nil { return "", "", err } return token, payload.Purpose, nil } func (s *AuthService) CompleteMicrosoftLink(ctx context.Context, code, state string) (*auth.UserIdentity, error) { if s.sso == nil || s.tokens == nil { return nil, errors.New("sso not configured") } payload, err := s.loadMicrosoftStatePayload(ctx, state) if err != nil { return nil, err } if payload.Flow != microsoftStateFlowLink || payload.UserID == "" { return nil, ErrInvalidSSOState } parsedUserID, err := uuidv7.ParseString(payload.UserID) if err != nil || len(parsedUserID) != 16 { return nil, ErrInvalidSSOState } // Consume SSO state exactly once to avoid replay with the same OAuth code. defer func() { _ = s.tokens.Delete(ctx, ssoStateKey(state)) }() return s.LinkSSOForUser(ctx, parsedUserID, ssoProviderMicrosoft, code, payload.CodeVerifier) } // RegisterWithEmail creates a user and records verification email intent in outbox. func (s *AuthService) RegisterWithEmail(ctx context.Context, email, username, firstName, lastName, mobilePhone, password string, roleID []byte) (*auth.User, error) { email = strings.ToLower(strings.TrimSpace(email)) username, err := normalizeUsername(username) if err != nil { return nil, err } if email == "" || password == "" { return nil, errors.New("email, username, and password are required") } existingByUsername, err := s.repo.GetUserByUsername(ctx, username) if err != nil { return nil, err } if existingByUsername != nil { return nil, errors.New("username already registered") } hash, salt, err := s.hashPassword(password) if err != nil { return nil, err } user := &auth.User{ ID: uuidv7.MustBytes(), Email: email, Username: ptrString(username), FirstName: firstName, LastName: lastName, MobilePhone: mobilePhone, Timezone: timezone.Default, RoleID: roleID, RoleIDs: normalizeAuthServiceRoleIDs(roleID, nil), IsActive: true, PasswordHash: hash, SaltValue: salt, } job, err := s.buildEmailVerificationJob(ctx, user) if err != nil { return nil, err } if job.Metadata == nil { job.Metadata = map[string]string{} } // Keep outbox record for auditing/workflow, but suppress actual delivery. job.Metadata["skip_send"] = "true" if job.Tags == nil { job.Tags = map[string]string{} } job.Tags["source"] = "register" if err := s.createUserAndDispatchEmail(ctx, user, job); err != nil { return nil, err } return user, nil } // InviteUser creates a user without password and sends set-password link. func (s *AuthService) InviteUser(ctx context.Context, email, username, firstName, lastName, mobilePhone string, roleID []byte) (*auth.User, error) { return s.InviteUserWithRoles(ctx, email, username, firstName, lastName, mobilePhone, roleID, nil) } func (s *AuthService) InviteUserWithRoles( ctx context.Context, email, username, firstName, lastName, mobilePhone string, roleID []byte, roleIDs [][]byte, ) (*auth.User, error) { email = strings.ToLower(strings.TrimSpace(email)) username, err := normalizeUsername(username) if err != nil { return nil, err } if email == "" { return nil, errors.New("email and username are required") } existing, err := s.repo.GetUserByEmail(ctx, email) if err != nil { return nil, err } if existing != nil { return nil, errors.New("email already registered") } existingByUsername, err := s.repo.GetUserByUsername(ctx, username) if err != nil { return nil, err } if existingByUsername != nil { return nil, errors.New("username already registered") } user := &auth.User{ ID: uuidv7.MustBytes(), Email: email, Username: ptrString(username), FirstName: firstName, LastName: lastName, MobilePhone: mobilePhone, Timezone: timezone.Default, RoleID: roleID, RoleIDs: normalizeAuthServiceRoleIDs(roleID, roleIDs), IsActive: true, } job, err := s.buildInviteSetPasswordJob(ctx, user) if err != nil { return nil, err } if err := s.createUserAndDispatchEmail(ctx, user, job); err != nil { return nil, err } return user, nil } // SendInviteSetPasswordForUser sends invite set-password email for an existing user. func (s *AuthService) SendInviteSetPasswordForUser(ctx context.Context, userID []byte) error { if s.tokens == nil || strings.TrimSpace(s.cfg.SetPasswordURL) == "" { return errors.New("invite set-password is not configured") } if len(userID) != 16 { return errors.New("invalid user id") } user, err := s.repo.GetUserByID(ctx, userID) if err != nil { return err } if user == nil { return errors.New("user not found") } if strings.TrimSpace(user.Email) == "" { return errors.New("user email is empty") } return s.sendInviteSetPassword(ctx, user) } // ResendInviteSetPassword triggers invite set-password email for invited users. // This method intentionally keeps behavior generic and returns no error. func (s *AuthService) ResendInviteSetPassword(ctx context.Context, userID []byte) { start := time.Now() defer sleepRemaining(start, s.cfg.ForgotPasswordMinDuration) if s.tokens == nil || strings.TrimSpace(s.cfg.SetPasswordURL) == "" || len(userID) != 16 { return } cooldownKey := inviteSetPasswordResendCooldownKey(userID) if s.isInviteSetPasswordResendCooldownActive(ctx, cooldownKey) { return } user, err := s.repo.GetUserByID(ctx, userID) if err != nil || user == nil { return } if !user.IsActive || strings.TrimSpace(user.Email) == "" { return } // Only resend for users that still have no password set. if len(user.PasswordHash) > 0 { return } if err := s.sendInviteSetPassword(ctx, user); err != nil { return } _ = s.markInviteSetPasswordResendCooldown(ctx, cooldownKey) } // ForgotPassword always returns without exposing whether an account exists. func (s *AuthService) ForgotPassword(ctx context.Context, email, requestedIP, userAgent string) { start := time.Now() defer sleepRemaining(start, s.cfg.ForgotPasswordMinDuration) email = strings.ToLower(strings.TrimSpace(email)) if email == "" { s.logServiceAudit(ctx, "auth.forgot_password.request", nil, false, "empty_email", map[string]any{ "email_sha256": shortEmailHash(email), }) return } cooldownKey := forgotPasswordCooldownKey(email) if s.isForgotPasswordCooldownActive(ctx, cooldownKey, email, requestedIP) { s.logServiceAudit(ctx, "auth.forgot_password.cooldown", nil, true, "cooldown_active", map[string]any{ "email_sha256": shortEmailHash(email), }) return } user, err := s.repo.GetUserByEmail(ctx, email) if err != nil { s.auditForgotPasswordAnomaly(email, requestedIP, "lookup_failed") _ = s.markForgotPasswordCooldown(ctx, cooldownKey) s.logServiceAudit(ctx, "auth.forgot_password.lookup", nil, false, "lookup_failed", map[string]any{ "email_sha256": shortEmailHash(email), }) return } if user != nil { if err := s.createAndSendPasswordReset(ctx, user, requestedIP, userAgent); err != nil { s.auditForgotPasswordAnomaly(email, requestedIP, "issue_failed") s.logServiceAudit(ctx, "auth.forgot_password.issue", user.ID, false, "issue_failed", map[string]any{ "email_sha256": shortEmailHash(email), }) } else { s.logServiceAudit(ctx, "auth.forgot_password.issue", user.ID, true, "enqueued", map[string]any{ "email_sha256": shortEmailHash(email), }) } } else { s.logServiceAudit(ctx, "auth.forgot_password.request", nil, true, "account_not_found_hidden", map[string]any{ "email_sha256": shortEmailHash(email), }) } _ = s.markForgotPasswordCooldown(ctx, cooldownKey) } func (s *AuthService) ResetPassword(ctx context.Context, rawToken, newPassword string) error { rawToken = strings.TrimSpace(rawToken) if rawToken == "" || newPassword == "" { s.logServiceAudit(ctx, "auth.reset_password.consume", nil, false, "missing_token_or_password", nil) return ErrInvalidResetLink } passwordHash, salt, err := s.hashPassword(newPassword) if err != nil { return err } userID, consumed, err := s.repo.ConsumePasswordResetTokenAndSetPassword(ctx, hashToken(rawToken), passwordHash, salt, time.Now().UTC()) if err != nil { s.logServiceAudit(ctx, "auth.reset_password.consume", nil, false, "consume_failed", nil) return err } if !consumed || len(userID) != 16 { s.logServiceAudit(ctx, "auth.reset_password.consume", nil, false, "invalid_or_expired", nil) return ErrInvalidResetLink } if err := s.bumpSessionVersion(ctx, userID); err != nil { log.Printf("auth reset-password session_revocation_failed user=%s", shortIdentifier(userID)) s.logServiceAudit(ctx, "auth.reset_password.revoke_sessions", userID, false, "session_revocation_failed", nil) } else { s.logServiceAudit(ctx, "auth.reset_password.revoke_sessions", userID, true, "session_revoked", nil) } s.logServiceAudit(ctx, "auth.reset_password.consume", userID, true, "password_reset_success", nil) return nil } func (s *AuthService) GetRoleIDByName(ctx context.Context, name string) ([]byte, error) { if s.roleRepo == nil { return nil, errors.New("role repo not configured") } role, err := s.roleRepo.GetRoleByName(ctx, name) if err != nil || role == nil { return nil, errors.New("role not found") } return role.ID, nil } func (s *AuthService) DefaultRoleName() string { return s.cfg.DefaultRoleName } func (s *AuthService) RegisterDisabled() bool { return s.cfg.DisableRegister } // LoginWithEmailPassword verifies email/password and returns the user. func (s *AuthService) LoginWithEmailPassword(ctx context.Context, email, password string) (*auth.User, error) { email = strings.ToLower(strings.TrimSpace(email)) user, err := s.repo.GetUserByEmail(ctx, email) if err != nil || user == nil { return nil, ErrInvalidCredentials } if !s.verifyPassword(password, user.PasswordHash, user.SaltValue) { return nil, ErrInvalidCredentials } if !user.IsActive { return nil, ErrInvalidCredentials } if user.EmailVerifiedAt == nil { return nil, ErrEmailNotVerified } return user, nil } func (s *AuthService) IsTOTPEnabled(ctx context.Context, userID []byte) (bool, error) { record, err := s.repo.GetUserTOTP(ctx, userID) if err != nil || record == nil { return false, err } return record.Enabled, nil } func (s *AuthService) IsTOTPPreviouslyConfigured(ctx context.Context, userID []byte) (bool, error) { record, err := s.repo.GetUserTOTP(ctx, userID) if err != nil || record == nil { return false, err } return !record.Enabled, nil } type TokenPair struct { AccessToken string RefreshToken string RefreshTTL time.Duration SessionID string } func (s *AuthService) IssueTokens(ctx context.Context, user *auth.User) (TokenPair, error) { return s.IssueTokensWithClient(ctx, user, "", "") } func (s *AuthService) IssueTokensAfterTOTP(ctx context.Context, user *auth.User) (TokenPair, error) { return s.IssueTokensAfterTOTPWithClient(ctx, user, "", "") } func (s *AuthService) issueLoginTokensWithRefreshTTL(ctx context.Context, user *auth.User, refreshTTL time.Duration, longLived bool) (TokenPair, error) { if user == nil || len(user.ID) != 16 { return TokenPair{}, ErrInvalidToken } return s.issueTokensWithRefreshTTL(ctx, user, refreshTTL, longLived) } func (s *AuthService) issueTokensWithRefreshTTL(ctx context.Context, user *auth.User, refreshTTL time.Duration, longLived bool) (TokenPair, error) { return s.issueTokensWithRefreshTTLAndClient(ctx, user, refreshTTL, longLived, "", "", "", "", "", "") } func (s *AuthService) RefreshTokens(ctx context.Context, refreshToken string) (TokenPair, error) { return s.RefreshTokensWithClient(ctx, refreshToken, "", "") } func (s *AuthService) RevokeRefreshToken(ctx context.Context, refreshToken string) error { return s.revokeRefreshTokenWithSession(ctx, refreshToken) } func (s *AuthService) ParseAccessToken(ctx context.Context, accessToken string) ([]byte, error) { userID, _, err := s.parseAccessTokenWithSession(ctx, accessToken) if err != nil { return nil, err } return userID, nil } func (s *AuthService) parseAccessTokenWithSession(ctx context.Context, accessToken string) ([]byte, string, error) { if s.cfg.JWTAccessSecret == "" || s.cfg.JWTRefreshSecret == "" { return nil, "", errors.New("jwt secrets not configured") } if accessToken == "" { return nil, "", ErrInvalidToken } parsed, err := jwt.Parse(accessToken, func(t *jwt.Token) (any, error) { if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { return nil, ErrInvalidToken } return []byte(s.cfg.JWTAccessSecret), nil }) if err != nil || !parsed.Valid { return nil, "", ErrInvalidToken } claims, ok := parsed.Claims.(jwt.MapClaims) if !ok { return nil, "", ErrInvalidToken } if claims["type"] != "access" { return nil, "", ErrInvalidToken } sub, _ := claims["sub"].(string) sid, _ := claims["sid"].(string) if sub == "" || sid == "" { return nil, "", ErrInvalidToken } userID, err := uuidv7.ParseString(sub) if err != nil { return nil, "", ErrInvalidToken } if !s.validSessionVersion(ctx, userID, claims["sv"]) { return nil, "", ErrInvalidToken } if !s.isSessionActive(ctx, userID, sid) { return nil, "", ErrInvalidToken } user, err := s.repo.GetUserByID(ctx, userID) if err != nil || user == nil || !user.IsActive { return nil, "", ErrInvalidToken } record, err := s.loadSessionRecord(ctx, sid) if err != nil || record == nil { return nil, "", ErrInvalidToken } if s.isSessionIdleExpired(record) { // Local/email sessions expire at idle boundary. if !s.canRecoverSSOSessionOnIdle(ctx, userID, record) { _ = s.RevokeUserSession(ctx, userID, sid) return nil, "", ErrInvalidToken } } s.touchSessionActivity(ctx, userID, sid) return userID, sid, nil } func (s *AuthService) GetUserByID(ctx context.Context, id []byte) (*auth.User, error) { return s.repo.GetUserByID(ctx, id) } func (s *AuthService) IsSSOLinked(ctx context.Context, userID []byte) (bool, error) { if len(userID) != 16 { return false, nil } identity, err := s.repo.GetIdentityByUserID(ctx, userID) if err != nil { return false, err } return identity != nil, nil } func (s *AuthService) GetMicrosoftScopeByUserID(ctx context.Context, userID []byte) (string, error) { if len(userID) != 16 { return "", nil } repo, ok := s.repo.(microsoftOAuthTokenStore) if !ok { return "", nil } record, err := repo.GetMicrosoftOAuthTokenByUserIDProvider(ctx, userID, ssoProviderMicrosoft) if err != nil { return "", err } if record == nil { return "", nil } return normalizeMicrosoftScopeCSV(record.Scope), nil } func normalizeMicrosoftScopeCSV(scope string) string { parts := strings.Fields(strings.TrimSpace(scope)) if len(parts) == 0 { return "" } return strings.Join(parts, ", ") } func (s *AuthService) UpdateUserTimezone(ctx context.Context, userID []byte, timezoneName string) (string, error) { if len(userID) == 0 { return "", errors.New("user_id is required") } normalized, err := timezone.Normalize(timezoneName) if err != nil { return "", err } if err := s.repo.UpdateUserTimezone(ctx, userID, normalized); err != nil { return "", err } return normalized, nil } func (s *AuthService) SetSecurityPIN(ctx context.Context, userID []byte, pin string) error { if len(userID) != 16 { return ErrInvalidSecurityPIN } pin = strings.TrimSpace(pin) if err := validateSecurityPIN(pin); err != nil { return err } user, err := s.repo.GetUserByID(ctx, userID) if err != nil { return err } if user == nil { return ErrInvalidToken } if len(user.SecurityPINHash) > 0 { return ErrSecurityPINAlreadySet } pinRecord, err := s.hashSecurityPIN(pin) if err != nil { return err } now := time.Now().UTC() if err := s.repo.SetUserSecurityPIN(ctx, userID, pinRecord, now); err != nil { return err } _ = s.clearSecurityPINLockState(ctx, userID) s.logServiceAudit(ctx, "auth.pin.set", userID, true, "pin_set", nil) return nil } func (s *AuthService) ChangeSecurityPIN(ctx context.Context, userID []byte, currentPIN, newPIN string) error { if len(userID) != 16 { return ErrInvalidSecurityPIN } currentPIN = strings.TrimSpace(currentPIN) newPIN = strings.TrimSpace(newPIN) if err := validateSecurityPIN(currentPIN); err != nil { return err } if err := validateSecurityPIN(newPIN); err != nil { return err } if err := s.verifySecurityPIN(ctx, userID, currentPIN); err != nil { return err } pinRecord, err := s.hashSecurityPIN(newPIN) if err != nil { return err } now := time.Now().UTC() if err := s.repo.SetUserSecurityPIN(ctx, userID, pinRecord, now); err != nil { return err } _ = s.clearSecurityPINLockState(ctx, userID) s.logServiceAudit(ctx, "auth.pin.change", userID, true, "pin_changed", nil) return nil } func (s *AuthService) VerifySecurityPINForAction(ctx context.Context, userID []byte, pin, action string) (string, error) { return s.verifySecurityPINForAction(ctx, userID, pin, action, "") } func (s *AuthService) VerifySecurityPINForActionInSession(ctx context.Context, userID []byte, pin, action, sessionID string) (string, error) { return s.verifySecurityPINForAction(ctx, userID, pin, action, sessionID) } func (s *AuthService) verifySecurityPINForAction(ctx context.Context, userID []byte, pin, action, sessionID string) (string, error) { if len(userID) != 16 { return "", ErrInvalidSecurityPIN } if err := s.verifySecurityPIN(ctx, userID, strings.TrimSpace(pin)); err != nil { return "", err } action = normalizeSensitiveAction(action) if action == "" { return "", errors.New("action is required") } if s.tokens == nil { return "", errors.New("token store not configured") } token, err := randomToken(24) if err != nil { return "", err } payload := bytesToString(userID) + "|" + action if strings.TrimSpace(sessionID) != "" { payload += "|" + strings.TrimSpace(sessionID) } if err := s.tokens.Set(ctx, pinActionKey(token), []byte(payload), s.cfg.SecurityPINActionTokenTTL); err != nil { return "", err } s.logServiceAudit(ctx, "auth.pin.verify", userID, true, "pin_verified", map[string]any{ "action": action, }) return token, nil } func (s *AuthService) ConsumeSecurityPINActionToken(ctx context.Context, userID []byte, action, token string) error { return s.validateSecurityPINActionToken(ctx, userID, action, token, "", true) } func (s *AuthService) ValidateSecurityPINActionToken(ctx context.Context, userID []byte, action, token string) error { return s.validateSecurityPINActionToken(ctx, userID, action, token, "", false) } func (s *AuthService) ConsumeSecurityPINActionTokenInSession(ctx context.Context, userID []byte, action, token, sessionID string) error { return s.validateSecurityPINActionToken(ctx, userID, action, token, sessionID, true) } func (s *AuthService) ValidateSecurityPINActionTokenInSession(ctx context.Context, userID []byte, action, token, sessionID string) error { return s.validateSecurityPINActionToken(ctx, userID, action, token, sessionID, false) } func (s *AuthService) validateSecurityPINActionToken(ctx context.Context, userID []byte, action, token, sessionID string, consume bool) error { if len(userID) != 16 { return ErrInvalidPINAction } action = normalizeSensitiveAction(action) token = strings.TrimSpace(token) if action == "" || token == "" || s.tokens == nil { return ErrInvalidPINAction } raw, err := s.tokens.Get(ctx, pinActionKey(token)) if err != nil || raw == nil { return ErrInvalidPINAction } parts := strings.Split(string(raw), "|") if len(parts) != 2 && len(parts) != 3 { return ErrInvalidPINAction } if !subtleConstantTimeCompare([]byte(parts[0]), []byte(bytesToString(userID))) { return ErrInvalidPINAction } if !subtleConstantTimeCompare([]byte(parts[1]), []byte(action)) { return ErrInvalidPINAction } if len(parts) == 3 { if !subtleConstantTimeCompare([]byte(parts[2]), []byte(strings.TrimSpace(sessionID))) { return ErrInvalidPINAction } } if consume { _ = s.tokens.Delete(ctx, pinActionKey(token)) } return nil } // ForgotSecurityPIN always returns without exposing whether an account exists. func (s *AuthService) ForgotSecurityPIN(ctx context.Context, email, requestedIP, userAgent string) { start := time.Now() defer sleepRemaining(start, s.cfg.ForgotSecurityPINMinDuration) email = strings.ToLower(strings.TrimSpace(email)) if email == "" { s.logServiceAudit(ctx, "auth.pin.forgot.request", nil, false, "empty_email", map[string]any{ "email_sha256": shortEmailHash(email), }) return } cooldownKey := forgotSecurityPINCooldownKey(email) if s.isForgotSecurityPINCooldownActive(ctx, cooldownKey) { s.logServiceAudit(ctx, "auth.pin.forgot.cooldown", nil, true, "cooldown_active", map[string]any{ "email_sha256": shortEmailHash(email), }) return } user, err := s.repo.GetUserByEmail(ctx, email) if err != nil { _ = s.markForgotSecurityPINCooldown(ctx, cooldownKey) s.logServiceAudit(ctx, "auth.pin.forgot.lookup", nil, false, "lookup_failed", map[string]any{ "email_sha256": shortEmailHash(email), }) return } if user != nil { if err := s.createAndSendSecurityPINReset(ctx, user, requestedIP, userAgent); err != nil { s.logServiceAudit(ctx, "auth.pin.forgot.issue", user.ID, false, "issue_failed", map[string]any{ "email_sha256": shortEmailHash(email), }) } else { s.logServiceAudit(ctx, "auth.pin.forgot.issue", user.ID, true, "enqueued", map[string]any{ "email_sha256": shortEmailHash(email), }) } } else { s.logServiceAudit(ctx, "auth.pin.forgot.request", nil, true, "account_not_found_hidden", map[string]any{ "email_sha256": shortEmailHash(email), }) } _ = s.markForgotSecurityPINCooldown(ctx, cooldownKey) } func (s *AuthService) RequestSecurityPINReset(ctx context.Context, userID []byte, method, password, reauthToken, requestedIP, userAgent string) error { if len(userID) != 16 { return ErrInvalidToken } method = normalizePINResetMethod(method) if !s.isSecurityPINLocked(ctx, userID) { return ErrSecurityPINNotBlocked } user, err := s.repo.GetUserByID(ctx, userID) if err != nil { return err } if user == nil { return ErrInvalidToken } switch method { case pinResetMethodPassword: password = strings.TrimSpace(password) if password == "" { return ErrInvalidPINResetPassword } if !s.verifyPassword(password, user.PasswordHash, user.SaltValue) { s.logServiceAudit(ctx, "auth.pin.reset.issue", userID, false, "invalid_password", map[string]any{ "trigger": "blocked_reset_button", }) return ErrInvalidPINResetPassword } case pinResetMethodMicrosoft: if err := s.consumeMicrosoftReauthToken(ctx, userID, microsoftReauthPurposePINResetRequest, reauthToken); err != nil { s.logServiceAudit(ctx, "auth.pin.reset.issue", userID, false, "invalid_sso_reauth", map[string]any{ "trigger": "blocked_reset_button", }) return err } default: return ErrInvalidReauthMethod } cooldownKey := forgotSecurityPINCooldownKey(user.Email) if s.isForgotSecurityPINCooldownActive(ctx, cooldownKey) { return ErrSecurityPINResetRatelimit } if err := s.createAndSendSecurityPINReset(ctx, user, requestedIP, userAgent); err != nil { return err } _ = s.markForgotSecurityPINCooldown(ctx, cooldownKey) s.logServiceAudit(ctx, "auth.pin.reset.issue", userID, true, "requested_by_user", map[string]any{ "trigger": "blocked_reset_button", }) return nil } func (s *AuthService) ResetSecurityPIN(ctx context.Context, rawToken, method, password, reauthToken, newPIN string) error { method = normalizePINResetMethod(method) rawToken = strings.TrimSpace(rawToken) password = strings.TrimSpace(password) newPIN = strings.TrimSpace(newPIN) if err := validateSecurityPIN(newPIN); err != nil { return err } now := time.Now().UTC() switch method { case pinResetMethodPassword: if rawToken == "" { return ErrInvalidResetLink } tokenHash := hashToken(rawToken) user, err := s.repo.GetActiveSecurityPINResetTokenUser(ctx, tokenHash, now) if err != nil { s.logServiceAudit(ctx, "auth.pin.reset.lookup", nil, false, "lookup_failed", nil) return err } if user == nil || len(user.ID) != 16 { s.logServiceAudit(ctx, "auth.pin.reset.lookup", nil, false, "invalid_or_expired", nil) return ErrInvalidResetLink } if password == "" { return ErrInvalidPINResetPassword } if !s.verifyPassword(password, user.PasswordHash, user.SaltValue) { s.logServiceAudit(ctx, "auth.pin.reset.password", user.ID, false, "invalid_password", nil) return ErrInvalidPINResetPassword } pinRecord, err := s.hashSecurityPIN(newPIN) if err != nil { return err } userID, consumed, err := s.repo.ConsumeSecurityPINResetTokenAndSetPIN(ctx, tokenHash, pinRecord, now) if err != nil { s.logServiceAudit(ctx, "auth.pin.reset.consume", nil, false, "consume_failed", nil) return err } if !consumed || len(userID) != 16 { s.logServiceAudit(ctx, "auth.pin.reset.consume", nil, false, "invalid_or_expired", nil) return ErrInvalidResetLink } _ = s.clearSecurityPINLockState(ctx, userID) s.logServiceAudit(ctx, "auth.pin.reset.consume", userID, true, "pin_reset_success", nil) return nil case pinResetMethodMicrosoft: userID, err := s.consumeMicrosoftReauthTokenForPurposes(ctx, []string{ microsoftReauthPurposePINResetRequest, microsoftReauthPurposePINResetConfirm, }, reauthToken) if err != nil { s.logServiceAudit(ctx, "auth.pin.reset.microsoft", nil, false, "invalid_sso_reauth", nil) return err } user, err := s.repo.GetUserByID(ctx, userID) if err != nil { return err } if user == nil || len(user.ID) != 16 { return ErrInvalidToken } pinRecord, err := s.hashSecurityPIN(newPIN) if err != nil { return err } if err := s.repo.SetUserSecurityPIN(ctx, user.ID, pinRecord, now); err != nil { return err } if err := s.repo.InvalidateActiveSecurityPINResetTokensByUserID(ctx, user.ID, now); err != nil { return err } _ = s.clearSecurityPINLockState(ctx, user.ID) s.logServiceAudit(ctx, "auth.pin.reset.microsoft", user.ID, true, "pin_reset_success", nil) return nil default: return ErrInvalidReauthMethod } } func (s *AuthService) GetRoleByID(ctx context.Context, id []byte) (*auth.Role, error) { if s.roleRepo == nil { return nil, errors.New("role repo not configured") } return s.roleRepo.GetRoleByID(ctx, id) } func (s *AuthService) PermissionRequiresPIN(ctx context.Context, permissionKey string) (bool, error) { if s.roleRepo == nil { return false, errors.New("role repo not configured") } perm, err := s.roleRepo.GetPermissionByKey(ctx, permissionKey) if err != nil { return false, err } if perm == nil { return false, nil } return perm.RequiresPIN, nil } func (s *AuthService) HasPermission(ctx context.Context, userID []byte, permissionKey string) (bool, error) { if s.repo == nil { return false, errors.New("auth repo not configured") } if s.roleRepo == nil { return false, errors.New("role repo not configured") } user, err := s.repo.GetUserByID(ctx, userID) if err != nil { return false, err } if user == nil { return false, nil } for _, roleID := range collectAuthUserRoleIDs(user) { allowed, err := s.roleRepo.HasRolePermission(ctx, roleID, permissionKey) if err != nil { return false, err } if allowed { return true, nil } } return false, nil } func (s *AuthService) GetPermissionsByRoleID(ctx context.Context, roleID []byte) ([]auth.Permission, error) { if s.roleRepo == nil { return nil, errors.New("role repo not configured") } return s.roleRepo.ListPermissionsByRoleID(ctx, roleID) } func (s *AuthService) GetPermissionsByUserID(ctx context.Context, userID []byte) ([]auth.Permission, error) { if s.repo == nil { return nil, errors.New("auth repo not configured") } if s.roleRepo == nil { return nil, errors.New("role repo not configured") } user, err := s.repo.GetUserByID(ctx, userID) if err != nil { return nil, err } if user == nil { return []auth.Permission{}, nil } permsByKey := make(map[string]auth.Permission) for _, roleID := range collectAuthUserRoleIDs(user) { perms, err := s.roleRepo.ListPermissionsByRoleID(ctx, roleID) if err != nil { return nil, err } for i := range perms { key := strings.TrimSpace(perms[i].Key) if key == "" { key = string(perms[i].ID) } if _, exists := permsByKey[key]; exists { continue } permsByKey[key] = perms[i] } } out := make([]auth.Permission, 0, len(permsByKey)) for _, perm := range permsByKey { out = append(out, perm) } sort.Slice(out, func(i, j int) bool { leftName := strings.ToLower(strings.TrimSpace(out[i].Name)) rightName := strings.ToLower(strings.TrimSpace(out[j].Name)) if leftName == rightName { return strings.ToLower(strings.TrimSpace(out[i].Key)) < strings.ToLower(strings.TrimSpace(out[j].Key)) } return leftName < rightName }) return out, nil } func (s *AuthService) AccessTTL() time.Duration { return s.cfg.JWTAccessTTL } func (s *AuthService) RefreshTTL() time.Duration { return s.cfg.JWTRefreshTTL } func (s *AuthService) RefreshTOTPTTL() time.Duration { return s.cfg.JWTRefreshTOTPTTL } func (s *AuthService) EmailOTPResendCooldown() time.Duration { return s.cfg.EmailOTPResendCooldown } func (s *AuthService) EmailOTPMaxResendPerHour() int { return s.cfg.EmailOTPMaxResendPerHour } func (s *AuthService) IsLoginEmailOTPEnabled(ctx context.Context) bool { if s == nil || s.emailOTPEnabled == nil { return true } enabled, err := s.emailOTPEnabled(ctx) if err != nil { return true } return enabled } func (s *AuthService) SecurityPINTokenTTL() time.Duration { return s.cfg.SecurityPINActionTokenTTL } func (s *AuthService) IsSecurityPINBlocked(ctx context.Context, userID []byte) bool { return s.isSecurityPINLocked(ctx, userID) } func (s *AuthService) CookieDomain() string { return s.cfg.JWTCookieDomain } func (s *AuthService) CookieSecure() bool { return s.cfg.JWTCookieSecure } func (s *AuthService) CookieSameSite() string { return s.cfg.JWTCookieSameSite } func (s *AuthService) AccessCookieName() string { return s.cfg.JWTAccessCookieName } func (s *AuthService) RefreshCookieName() string { return s.cfg.JWTRefreshCookieName } func (s *AuthService) SSOSuccessRedirectURL() string { return s.cfg.SSOSuccessRedirectURL } func (s *AuthService) MicrosoftLogoutURL() string { if s == nil || s.sso == nil { return "" } return strings.TrimSpace(s.sso.LogoutURL(s.cfg.SSOSuccessRedirectURL)) } func (s *AuthService) ForgotPasswordIPRateMax() int { return s.cfg.ForgotPasswordIPRateMax } func (s *AuthService) LoginIPRateMax() int { return s.cfg.LoginIPRateMax } func collectAuthUserRoleIDs(user *auth.User) [][]byte { if user == nil { return nil } roleIDs := make([][]byte, 0, len(user.RoleIDs)+1) seen := make(map[string]struct{}, len(user.RoleIDs)+1) appendRole := func(roleID []byte) { if len(roleID) != 16 { return } key := string(roleID) if _, exists := seen[key]; exists { return } seen[key] = struct{}{} roleIDs = append(roleIDs, append([]byte(nil), roleID...)) } appendRole(user.RoleID) for i := range user.RoleIDs { appendRole(user.RoleIDs[i]) } return roleIDs } func normalizeAuthServiceRoleIDs(primaryRoleID []byte, roleIDs [][]byte) [][]byte { roleSet := &auth.User{ RoleID: primaryRoleID, RoleIDs: roleIDs, } return collectAuthUserRoleIDs(roleSet) } func (s *AuthService) ForgotPasswordIPRateWindow() time.Duration { return s.cfg.ForgotPasswordIPRateWindow } func (s *AuthService) LoginIPRateWindow() time.Duration { return s.cfg.LoginIPRateWindow } // VerifyEmail verifies a token and marks the user as verified. func (s *AuthService) VerifyEmail(ctx context.Context, token string) error { if token == "" { return ErrInvalidToken } if s.tokens == nil { return ErrInvalidToken } key := emailVerifyKey(token) data, err := s.tokens.Get(ctx, key) if err != nil || len(data) != 16 { return ErrInvalidToken } _ = s.tokens.Delete(ctx, key) return s.repo.MarkEmailVerified(ctx, data) } func (s *AuthService) SetPasswordWithInviteToken(ctx context.Context, token, password string) error { if token == "" || password == "" { return errors.New("token and password are required") } if s.tokens == nil { return ErrInvalidToken } var ( data []byte key string err error parsedUID []byte ) if uid, jti, _, parseErr := s.parseInviteJWT(token); parseErr == nil { key = inviteKey(jti) data, err = s.tokens.Get(ctx, key) if err != nil || len(data) != 16 || !bytes.Equal(data, uid) { return ErrInvalidToken } parsedUID = uid } else { // Backward compatibility for legacy invite token format. key = inviteKey(token) data, err = s.tokens.Get(ctx, key) if err != nil || len(data) != 16 { return ErrInvalidToken } parsedUID = data } hash, salt, err := s.hashPassword(password) if err != nil { return err } if err := s.repo.SetUserPassword(ctx, parsedUID, hash, salt); err != nil { return err } if err := s.repo.MarkEmailVerified(ctx, parsedUID); err != nil { return err } _ = s.tokens.Delete(ctx, key) return nil } // SSO Microsoft (Entra) login flow: exchange code -> resolve existing identity -> get user. func (s *AuthService) LoginWithMicrosoft(ctx context.Context, code string, codeVerifier ...string) (*auth.UserIdentity, string, error) { claims, tokenSet, err := s.exchangeMicrosoftCode(ctx, code, msFirstNonEmpty(codeVerifier...)) if err != nil { return nil, "", err } identity, err := s.resolveOrSyncMicrosoftIdentityByClaims(ctx, claims) if err != nil { return nil, "", err } if tokenSet != nil { if err := s.storeMicrosoftOAuthToken(ctx, identity.UserID, claims, *tokenSet); err != nil { return nil, "", err } } return identity, claims.SessionID, nil } // ExchangeMicrosoftToken resolves a Microsoft identity from frontend-provided tokens. // This flow is intended for SPA silent SSO (ssoSilent) where backend receives id_token/access_token directly. func (s *AuthService) ExchangeMicrosoftToken(ctx context.Context, accessToken, idToken string, idTokenClaims map[string]any) (*auth.UserIdentity, error) { accessToken = strings.TrimSpace(accessToken) idToken = strings.TrimSpace(idToken) if accessToken == "" { return nil, errors.New("access token is required") } if idToken == "" { return nil, errors.New("id token is required") } claimsMap := idTokenClaims if len(claimsMap) == 0 { var err error claimsMap, err = parseJWTClaims(idToken) if err != nil { return nil, errors.New("invalid id token claims") } } exp := parseJWTUnixClaim(claimsMap["exp"]) if exp <= 0 || time.Unix(exp, 0).Before(time.Now().UTC()) { return nil, ErrInvalidToken } claims := MicrosoftClaims{ Subject: strings.TrimSpace(parseJWTStringClaim(claimsMap, "sub")), Email: strings.TrimSpace(parseJWTStringClaim(claimsMap, "email")), Name: strings.TrimSpace(parseJWTStringClaim(claimsMap, "name")), TenantID: strings.TrimSpace(parseJWTStringClaim(claimsMap, "tid")), ObjectID: strings.TrimSpace(parseJWTStringClaim(claimsMap, "oid")), SessionID: strings.TrimSpace(parseJWTStringClaim(claimsMap, "sid")), } if claims.Email == "" { claims.Email = strings.TrimSpace(parseJWTStringClaim(claimsMap, "preferred_username")) } return s.resolveOrSyncMicrosoftIdentityByClaims(ctx, claims) } func (s *AuthService) LinkSSOForUser(ctx context.Context, userID []byte, provider, code string, codeVerifier ...string) (*auth.UserIdentity, error) { if s.sso == nil { return nil, errors.New("sso not configured") } if len(userID) != 16 { return nil, ErrUserNotFound } provider = strings.ToLower(strings.TrimSpace(provider)) if provider != ssoProviderMicrosoft { return nil, ErrUnsupportedSSOProvider } code = strings.TrimSpace(code) if code == "" { return nil, errors.New("authorization code is required") } user, err := s.repo.GetUserByID(ctx, userID) if err != nil { return nil, err } if user == nil || len(user.ID) != 16 { return nil, ErrUserNotFound } claims, tokenSet, err := s.exchangeMicrosoftCode(ctx, code, msFirstNonEmpty(codeVerifier...)) if err != nil { return nil, err } if !strings.EqualFold(strings.TrimSpace(user.Email), strings.TrimSpace(claims.Email)) { return nil, ErrSSONotLinked } subject := strings.TrimSpace(claims.Subject) if subject == "" { return nil, errors.New("microsoft subject is empty") } existingBySubject, err := s.repo.GetIdentityByProviderSubject(ctx, provider, subject) if err != nil { return nil, err } if existingBySubject != nil { if bytes.Equal(existingBySubject.UserID, userID) { return nil, ErrSSOAlreadyLinked } return nil, ErrSSOLinkedToAnotherUser } existingByUser, err := s.repo.GetIdentityByUserID(ctx, userID) if err != nil { return nil, err } if existingByUser != nil { return nil, ErrSSOAlreadyLinked } identity := &auth.UserIdentity{ ID: uuidv7.MustBytes(), UserID: userID, Provider: provider, ProviderSubject: subject, Email: strings.ToLower(strings.TrimSpace(claims.Email)), } if err := s.repo.CreateIdentity(ctx, identity); err != nil { return nil, err } if tokenSet != nil { if err := s.storeMicrosoftOAuthToken(ctx, userID, claims, *tokenSet); err != nil { return nil, err } } return identity, nil } func (s *AuthService) resolveOrSyncMicrosoftIdentityByClaims(ctx context.Context, claims MicrosoftClaims) (*auth.UserIdentity, error) { subject := strings.TrimSpace(claims.Subject) if subject == "" { return nil, errors.New("microsoft subject is empty") } email := strings.ToLower(strings.TrimSpace(claims.Email)) if email == "" { return nil, ErrSSOEmailNotFound } user, err := s.repo.GetUserBySSOEmail(ctx, email) if err != nil { return nil, err } if user == nil || len(user.ID) != 16 { return nil, ErrSSONotLinked } existingBySubject, err := s.repo.GetIdentityByProviderSubject(ctx, ssoProviderMicrosoft, subject) if err != nil { return nil, err } if existingBySubject != nil && len(existingBySubject.UserID) == 16 && !bytes.Equal(existingBySubject.UserID, user.ID) { return nil, ErrSSOLinkedToAnotherUser } existingByUser, err := s.repo.GetIdentityByUserID(ctx, user.ID) if err != nil { return nil, err } if existingByUser != nil && existingByUser.Provider == ssoProviderMicrosoft && strings.TrimSpace(existingByUser.ProviderSubject) != "" && !strings.EqualFold(existingByUser.ProviderSubject, subject) { if _, err := s.repo.DeleteIdentityByUserIDProvider(ctx, user.ID, ssoProviderMicrosoft); err != nil { return nil, err } } identity := &auth.UserIdentity{ UserID: append([]byte(nil), user.ID...), Provider: ssoProviderMicrosoft, ProviderSubject: subject, Email: email, } if name := strings.TrimSpace(claims.Name); name != "" { metadata, _ := json.Marshal(map[string]string{"name": name}) identity.Metadata = metadata } if existingBySubject != nil && len(existingBySubject.ID) == 16 { identity.ID = append([]byte(nil), existingBySubject.ID...) } if err := s.repo.UpsertIdentity(ctx, identity); err != nil { return nil, err } createdIdentity, err := s.repo.GetIdentityByProviderSubject(ctx, ssoProviderMicrosoft, subject) if err != nil { return nil, err } if createdIdentity != nil { return createdIdentity, nil } return identity, nil } func (s *AuthService) UnlinkSSOForUser(ctx context.Context, userID []byte, provider string) error { if len(userID) != 16 { return ErrUserNotFound } provider = strings.ToLower(strings.TrimSpace(provider)) if provider != ssoProviderMicrosoft { return ErrUnsupportedSSOProvider } user, err := s.repo.GetUserByID(ctx, userID) if err != nil { return err } if user == nil || len(user.ID) != 16 { return ErrUserNotFound } identity, err := s.repo.GetIdentityByUserID(ctx, userID) if err != nil { return err } if identity == nil || !strings.EqualFold(identity.Provider, provider) { return ErrSSONotLinked } deleted, err := s.repo.DeleteIdentityByUserIDProvider(ctx, userID, provider) if err != nil { return err } if !deleted { return ErrSSONotLinked } return nil } // TOTP verify: decrypt secret and verify code (6 digits). func (s *AuthService) VerifyTOTP(ctx context.Context, userID []byte, code string) (bool, error) { record, err := s.repo.GetUserTOTP(ctx, userID) if err != nil || record == nil || !record.Enabled { return false, nil } secret, err := s.protector.Decrypt(record.SecretEncrypted) if err != nil { return false, err } ok := verifyTOTP(string(secret), code) if ok { _ = s.repo.UpdateUserTOTPLastUsed(ctx, userID) } return ok, nil } func (s *AuthService) CreateTOTPChallenge(ctx context.Context, userID []byte) (string, error) { if s.tokens == nil { return "", errors.New("token store not configured") } token, err := randomToken(32) if err != nil { return "", err } if err := s.tokens.Set(ctx, totpChallengeKey(token), userID, s.cfg.TOTPChallengeTTL); err != nil { return "", err } return token, nil } func (s *AuthService) ConsumeTOTPChallenge(ctx context.Context, token string) ([]byte, error) { if s.tokens == nil || token == "" { return nil, ErrInvalidToken } data, err := s.tokens.Get(ctx, totpChallengeKey(token)) if err != nil || data == nil { return nil, ErrInvalidToken } _ = s.tokens.Delete(ctx, totpChallengeKey(token)) _ = s.tokens.Delete(ctx, totpChallengeAttemptKey(token)) return data, nil } func (s *AuthService) ResolveTOTPChallenge(ctx context.Context, token string) ([]byte, error) { token = strings.TrimSpace(token) if s.tokens == nil || token == "" { return nil, ErrInvalidToken } data, err := s.tokens.Get(ctx, totpChallengeKey(token)) if err != nil || data == nil { return nil, ErrInvalidToken } return data, nil } func (s *AuthService) InvalidateTOTPChallenge(ctx context.Context, token string) { token = strings.TrimSpace(token) if s.tokens == nil || token == "" { return } _ = s.tokens.Delete(ctx, totpChallengeKey(token)) _ = s.tokens.Delete(ctx, totpChallengeAttemptKey(token)) } func (s *AuthService) RegisterFailedTOTPChallengeAttempt(ctx context.Context, token string) (int, error) { token = strings.TrimSpace(token) if s.tokens == nil || token == "" { return 0, ErrInvalidToken } key := totpChallengeAttemptKey(token) raw, err := s.tokens.Get(ctx, key) if err != nil { return 0, err } attempts := 0 if len(raw) > 0 { attempts, _ = strconv.Atoi(strings.TrimSpace(string(raw))) } attempts++ if attempts >= totpChallengeMaxAttempts { s.InvalidateTOTPChallenge(ctx, token) return 0, ErrTOTPChallengeAttemptLimit } remaining := totpChallengeMaxAttempts - attempts if setErr := s.tokens.Set(ctx, key, []byte(strconv.Itoa(attempts)), s.cfg.TOTPChallengeTTL); setErr != nil { return 0, setErr } return remaining, nil } func (s *AuthService) TOTPChallengeMaxAttempts() int { return totpChallengeMaxAttempts } func (s *AuthService) CreateEmailOTPChallenge(ctx context.Context, userID []byte) (string, error) { if s.tokens == nil { return "", errors.New("token store not configured") } if len(userID) != 16 { return "", ErrUserNotFound } token, err := randomToken(32) if err != nil { return "", err } if err := s.tokens.Set(ctx, emailOTPChallengeKey(token), userID, s.cfg.EmailOTPChallengeTTL); err != nil { return "", err } return token, nil } func (s *AuthService) ResolveEmailOTPChallenge(ctx context.Context, token string) ([]byte, error) { token = strings.TrimSpace(token) if token == "" || s.tokens == nil { return nil, ErrInvalidToken } data, err := s.tokens.Get(ctx, emailOTPChallengeKey(token)) if err != nil || len(data) != 16 { return nil, ErrInvalidToken } return data, nil } func (s *AuthService) SendLoginEmailOTP(ctx context.Context, challengeToken string) error { userID, err := s.ResolveEmailOTPChallenge(ctx, challengeToken) if err != nil { return ErrInvalidToken } now := time.Now().UTC() active, err := s.repo.GetActiveUserEmailLoginOTP(ctx, userID, now) if err != nil { return err } if active != nil && now.Sub(active.LastSentAt) < s.cfg.EmailOTPResendCooldown { s.logServiceAudit(ctx, "auth.email_otp.send", userID, false, "resend_cooldown", nil) return ErrEmailOTPRateLimited } count, err := s.repo.CountUserEmailLoginOTPSince(ctx, userID, now.Add(-5*time.Minute)) if err != nil { return err } if count >= int64(s.cfg.EmailOTPMaxResendPerHour) { s.logServiceAudit(ctx, "auth.email_otp.send", userID, false, "resend_limit_exceeded", nil) return ErrEmailOTPResendExceeded } user, err := s.repo.GetUserByID(ctx, userID) if err != nil { return err } if user == nil || !user.IsActive || strings.TrimSpace(user.Email) == "" { return ErrUserNotFound } otpCode, err := randomNumericCode(s.cfg.EmailOTPLength) if err != nil { return err } if err := s.repo.InvalidateActiveUserEmailLoginOTPs(ctx, userID, now); err != nil { return err } row := &auth.UserEmailLoginOTP{ UserID: append([]byte(nil), userID...), OTPHash: hashEmailOTPCode(userID, otpCode, s.cfg.PasswordPepper), ExpiresAt: now.Add(s.cfg.EmailOTPExpiry), MaxAttempts: s.cfg.EmailOTPMaxAttempts, ResendCount: int(count) + 1, LastSentAt: now, AttemptCount: 0, } if err := s.repo.CreateUserEmailLoginOTP(ctx, row); err != nil { return err } job := s.buildLoginEmailOTPJob(ctx, user, otpCode) if err := s.dispatchPreparedEmailJob(ctx, job); err != nil { return err } s.logServiceAudit(ctx, "auth.email_otp.send", userID, true, "otp_sent", map[string]any{ "resend_count": row.ResendCount, }) return nil } func (s *AuthService) VerifyLoginEmailOTP(ctx context.Context, challengeToken, code string) (*auth.User, error) { userID, err := s.ResolveEmailOTPChallenge(ctx, challengeToken) if err != nil { return nil, ErrInvalidToken } code = strings.TrimSpace(code) if len(code) != s.cfg.EmailOTPLength { s.logServiceAudit(ctx, "auth.email_otp.verify", userID, false, "invalid_format", nil) return nil, ErrInvalidEmailOTP } for i := 0; i < len(code); i++ { if code[i] < '0' || code[i] > '9' { s.logServiceAudit(ctx, "auth.email_otp.verify", userID, false, "invalid_format", nil) return nil, ErrInvalidEmailOTP } } ok, attemptsLeft, err := s.repo.ConsumeUserEmailLoginOTP(ctx, userID, hashEmailOTPCode(userID, code, s.cfg.PasswordPepper), time.Now().UTC()) if err != nil { return nil, err } if !ok { s.logServiceAudit(ctx, "auth.email_otp.verify", userID, false, "invalid_otp", map[string]any{ "attempts_left": attemptsLeft, }) return nil, ErrInvalidEmailOTP } user, err := s.repo.GetUserByID(ctx, userID) if err != nil || user == nil { return nil, ErrUserNotFound } _ = s.tokens.Delete(ctx, emailOTPChallengeKey(strings.TrimSpace(challengeToken))) s.logServiceAudit(ctx, "auth.email_otp.verify", userID, true, "otp_verified", nil) return user, nil } // SetupTOTP creates or replaces the user's TOTP secret and returns the provisioning URI. func (s *AuthService) SetupTOTP(ctx context.Context, userID []byte, accountName string) (secret, otpAuthURL, issuer string, err error) { if s.protector == nil { return "", "", "", errors.New("secret protector not configured") } issuer = s.cfg.TOTPIssuer existing, err := s.repo.GetUserTOTP(ctx, userID) if err != nil { return "", "", "", err } if existing != nil && len(existing.SecretEncrypted) > 0 { secretBytes, err := s.protector.Decrypt(existing.SecretEncrypted) if err != nil { return "", "", "", err } secret = string(secretBytes) return secret, buildTOTPAuthURL(issuer, accountName, secret), issuer, nil } key, err := totp.Generate(totp.GenerateOpts{ Issuer: issuer, AccountName: accountName, }) if err != nil { return "", "", "", err } secret = key.Secret() otpAuthURL = key.URL() enc, err := s.protector.Encrypt([]byte(secret)) if err != nil { return "", "", "", err } record := &auth.UserTOTP{ UserID: userID, SecretEncrypted: enc, Enabled: false, } if err := s.repo.UpsertUserTOTP(ctx, record); err != nil { return "", "", "", err } return secret, otpAuthURL, issuer, nil } func (s *AuthService) DisableTOTP(ctx context.Context, userID []byte) error { return s.repo.DisableUserTOTP(ctx, userID) } func (s *AuthService) ResetTOTPSetup(ctx context.Context, userID []byte, method, pin, password string) error { if len(userID) != 16 { return ErrInvalidToken } if err := s.authorizeWithPINOrPassword(ctx, userID, method, pin, password); err != nil { return err } return s.DeleteTOTPSetup(ctx, userID) } func (s *AuthService) ResetWebAuthnSetup(ctx context.Context, userID []byte, method, pin, password string) error { if len(userID) != 16 { return ErrInvalidToken } if err := s.authorizeWithPINOrPassword(ctx, userID, method, pin, password); err != nil { return err } return s.DeleteWebAuthnSetup(ctx, userID) } func (s *AuthService) DeleteTOTPSetup(ctx context.Context, userID []byte) error { if len(userID) != 16 { return ErrInvalidToken } if err := s.repo.DeleteUserTOTPSetup(ctx, userID); err != nil { return err } s.logServiceAudit(ctx, "auth.totp.reset_setup", userID, true, "totp_setup_deleted", nil) return nil } func (s *AuthService) DeleteWebAuthnSetup(ctx context.Context, userID []byte) error { if len(userID) != 16 { return ErrInvalidToken } if err := s.repo.DeleteAllUserWebAuthnCredentials(ctx, userID); err != nil { return err } s.logServiceAudit(ctx, "auth.webauthn.reset_setup", userID, true, "webauthn_credentials_deleted", nil) return nil } func (s *AuthService) authorizeWithPINOrPassword(ctx context.Context, userID []byte, method, pin, password string) error { switch strings.ToLower(strings.TrimSpace(method)) { case "pin": return s.verifySecurityPIN(ctx, userID, strings.TrimSpace(pin)) case "password": user, err := s.repo.GetUserByID(ctx, userID) if err != nil { return err } if user == nil { return ErrInvalidToken } if !s.verifyPassword(password, user.PasswordHash, user.SaltValue) { return ErrInvalidCredentials } return nil default: return ErrInvalidReauthMethod } } func (s *AuthService) consumeMicrosoftReauthToken(ctx context.Context, userID []byte, purpose, token string) error { userIDFromToken, err := s.consumeMicrosoftReauthTokenForPurposes(ctx, []string{purpose}, token) if err != nil { return ErrInvalidSSOReauth } if !subtleConstantTimeCompare(userIDFromToken, userID) { return ErrInvalidSSOReauth } return nil } func (s *AuthService) consumeMicrosoftReauthTokenForPurposes(ctx context.Context, purposes []string, token string) ([]byte, error) { if s.tokens == nil || len(purposes) == 0 { return nil, ErrInvalidSSOReauth } validPurposes := make([]string, 0, len(purposes)) for _, purpose := range purposes { purpose = strings.TrimSpace(purpose) if !isSupportedMicrosoftReauthPurpose(purpose) { return nil, ErrInvalidSSOReauth } validPurposes = append(validPurposes, purpose) } token = strings.TrimSpace(token) if token == "" { return nil, ErrInvalidSSOReauth } raw, err := s.tokens.Get(ctx, microsoftReauthKey(token)) if err != nil || raw == nil { return nil, ErrInvalidSSOReauth } _ = s.tokens.Delete(ctx, microsoftReauthKey(token)) parts := strings.SplitN(string(raw), "|", 2) if len(parts) != 2 { return nil, ErrInvalidSSOReauth } userID, err := uuidv7.ParseString(parts[0]) if err != nil || len(userID) != 16 { return nil, ErrInvalidSSOReauth } for _, purpose := range validPurposes { expected := parts[0] + "|" + purpose if subtleConstantTimeCompare(raw, []byte(expected)) { return userID, nil } } return nil, ErrInvalidSSOReauth } func (s *AuthService) ConfirmTOTP(ctx context.Context, userID []byte, code string) (bool, error) { record, err := s.repo.GetUserTOTP(ctx, userID) if err != nil || record == nil { return false, ErrInvalidToken } secret, err := s.protector.Decrypt(record.SecretEncrypted) if err != nil { return false, err } ok := verifyTOTP(string(secret), code) if !ok { return false, nil } record.Enabled = true if err := s.repo.UpsertUserTOTP(ctx, record); err != nil { return false, err } return true, nil } func (s *AuthService) sendEmailVerification(ctx context.Context, user *auth.User) error { job, err := s.buildEmailVerificationJob(ctx, user) if err != nil { return err } return s.dispatchPreparedEmailJob(ctx, job) } func (s *AuthService) buildEmailVerificationJob(ctx context.Context, user *auth.User) (EmailJob, error) { if s.tokens == nil || s.cfg.EmailVerifyURL == "" { return EmailJob{}, nil } token, err := randomToken(32) if err != nil { return EmailJob{}, err } if err := s.tokens.Set(ctx, emailVerifyKey(token), user.ID, s.cfg.EmailVerifyTTL); err != nil { return EmailJob{}, err } link := strings.TrimRight(s.cfg.EmailVerifyURL, "/") + "?token=" + token return EmailJob{ MessageType: "email_verify", TemplateName: "auth.email_verify", To: user.Email, Subject: "Verify your email", TextBody: "Click this link to verify your email: " + link, Tags: map[string]string{ "message_type": "email_verify", "template": "auth_email_verify", }, }, nil } func (s *AuthService) sendInviteSetPassword(ctx context.Context, user *auth.User) error { job, err := s.buildInviteSetPasswordJob(ctx, user) if err != nil { return err } return s.dispatchPreparedEmailJob(ctx, job) } func (s *AuthService) buildInviteSetPasswordJob(ctx context.Context, user *auth.User) (EmailJob, error) { if s.tokens == nil || s.cfg.SetPasswordURL == "" { return EmailJob{}, nil } token := "" storeKey := "" if s.cfg.JWTAccessSecret != "" { var jti string var err error token, jti, err = s.issueInviteJWT(user) if err != nil { return EmailJob{}, err } storeKey = inviteKey(jti) if err := s.tokens.Set(ctx, storeKey, user.ID, s.cfg.InviteTTL); err != nil { return EmailJob{}, err } } else { // Backward compatibility when JWT secret isn't configured. var err error token, err = randomToken(32) if err != nil { return EmailJob{}, err } storeKey = inviteKey(token) if err := s.tokens.Set(ctx, storeKey, user.ID, s.cfg.InviteTTL); err != nil { return EmailJob{}, err } } link := strings.TrimRight(s.cfg.SetPasswordURL, "/") + "?token=" + token subject, textBody, htmlBody := buildInviteSetPasswordEmail(user, link, s.cfg.InviteTTL) return EmailJob{ MessageType: "invite_set_password", TemplateName: "auth.invite_set_password", To: user.Email, Subject: subject, TextBody: textBody, HTMLBody: htmlBody, Tags: map[string]string{ "message_type": "invite_set_password", "template": "auth_invite_set_password", }, }, nil } func (s *AuthService) buildLoginEmailOTPJob(ctx context.Context, user *auth.User, code string) EmailJob { email := buildLoginOTPEmail(ctx, s.emailBranding, user.FirstName, code, s.cfg.EmailOTPExpiry) return EmailJob{ To: user.Email, MessageType: "login_email_otp", TemplateName: "auth.login_email_otp", TemplateData: map[string]any{"otp_code": code}, Subject: email.Subject, TextBody: email.TextBody, HTMLBody: email.HTMLBody, Attachments: email.Attachments, Tags: map[string]string{ "message_type": "login_email_otp", "template": "auth_login_email_otp", }, } } func (s *AuthService) issueInviteJWT(user *auth.User) (token, jti string, err error) { if user == nil || len(user.ID) != 16 { return "", "", ErrInvalidToken } userID, convErr := uuidv7.BytesToString(user.ID) if convErr != nil { return "", "", convErr } jti, err = randomToken(24) if err != nil { return "", "", err } now := time.Now().UTC() claims := jwt.MapClaims{ "type": "invite_set_password", "sub": userID, "email": strings.ToLower(strings.TrimSpace(user.Email)), "jti": jti, "iat": now.Unix(), "exp": now.Add(s.cfg.InviteTTL).Unix(), } token, err = jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(s.cfg.JWTAccessSecret)) if err != nil { return "", "", err } return token, jti, nil } func (s *AuthService) parseInviteJWT(token string) (userID []byte, jti, email string, err error) { if strings.TrimSpace(token) == "" || s.cfg.JWTAccessSecret == "" { return nil, "", "", ErrInvalidToken } parsed, parseErr := jwt.Parse(token, func(t *jwt.Token) (any, error) { if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { return nil, ErrInvalidToken } return []byte(s.cfg.JWTAccessSecret), nil }) if parseErr != nil || !parsed.Valid { return nil, "", "", ErrInvalidToken } claims, ok := parsed.Claims.(jwt.MapClaims) if !ok { return nil, "", "", ErrInvalidToken } if claims["type"] != "invite_set_password" { return nil, "", "", ErrInvalidToken } sub, _ := claims["sub"].(string) email, _ = claims["email"].(string) jti, _ = claims["jti"].(string) if sub == "" || strings.TrimSpace(email) == "" || strings.TrimSpace(jti) == "" { return nil, "", "", ErrInvalidToken } userID, err = uuidv7.ParseString(sub) if err != nil { return nil, "", "", ErrInvalidToken } return userID, jti, strings.ToLower(strings.TrimSpace(email)), nil } func (s *AuthService) createAndSendPasswordReset(ctx context.Context, user *auth.User, requestedIP, userAgent string) error { if strings.TrimSpace(s.cfg.PasswordResetURL) == "" { log.Printf("auth forgot-password email skipped: AUTH_PASSWORD_RESET_URL is empty") return nil } rawToken, err := randomToken(32) if err != nil { return err } now := time.Now().UTC() expiresAt := now.Add(s.cfg.PasswordResetTTL) record := &auth.PasswordResetToken{ UserID: user.ID, TokenHash: hashToken(rawToken), ExpiresAt: expiresAt, RequestedIP: sanitizeIP(requestedIP, s.ipProtector), UserAgent: truncate(strings.TrimSpace(userAgent), 255), } link := buildResetLink(s.cfg.PasswordResetURL, rawToken) if link == "" { return errors.New("invalid password reset url") } email := buildPasswordResetEmail(ctx, s.emailBranding, user.FirstName, link, s.cfg.PasswordResetTTL) job := EmailJob{ MessageType: "password_reset", TemplateName: "auth.password_reset", To: user.Email, Subject: email.Subject, TextBody: email.TextBody, HTMLBody: email.HTMLBody, Attachments: email.Attachments, Tags: map[string]string{ "message_type": "password_reset", "template": "auth_password_reset", }, } if txRepo, ok := s.repo.(auth.TransactionalRepository); ok && s.emailOutboxReady() { return txRepo.WithTransaction(ctx, func(repo auth.Repository, outbox auth.EmailOutboxWriter) error { if err := repo.InvalidateActivePasswordResetTokensByUserID(ctx, user.ID, now); err != nil { return err } if err := repo.CreatePasswordResetToken(ctx, record); err != nil { return err } return s.persistEmailOutbox(ctx, outbox, job) }) } if err := s.repo.InvalidateActivePasswordResetTokensByUserID(ctx, user.ID, now); err != nil { return err } if err := s.repo.CreatePasswordResetToken(ctx, record); err != nil { return err } return s.dispatchPreparedEmailJob(ctx, job) } func (s *AuthService) createAndSendSecurityPINReset(ctx context.Context, user *auth.User, requestedIP, userAgent string) error { if strings.TrimSpace(s.cfg.SecurityPINResetURL) == "" { log.Printf("auth pin-reset email skipped: AUTH_SECURITY_PIN_RESET_URL is empty") return nil } rawToken, err := randomToken(32) if err != nil { return err } now := time.Now().UTC() expiresAt := now.Add(s.cfg.SecurityPINResetTTL) record := &auth.SecurityPINResetToken{ UserID: user.ID, TokenHash: hashToken(rawToken), ExpiresAt: expiresAt, RequestedIP: sanitizeIP(requestedIP, s.ipProtector), UserAgent: truncate(strings.TrimSpace(userAgent), 255), } link := buildResetLink(s.cfg.SecurityPINResetURL, rawToken) if link == "" { return errors.New("invalid security pin reset url") } email := buildSecurityPINResetEmail(ctx, s.emailBranding, user.FirstName, link, s.cfg.SecurityPINResetTTL) job := EmailJob{ MessageType: "security_pin_reset", TemplateName: "auth.security_pin_reset", To: user.Email, Subject: email.Subject, TextBody: email.TextBody, HTMLBody: email.HTMLBody, Attachments: email.Attachments, Tags: map[string]string{ "message_type": "security_pin_reset", "template": "auth_security_pin_reset", }, } if txRepo, ok := s.repo.(auth.TransactionalRepository); ok && s.emailOutboxReady() { return txRepo.WithTransaction(ctx, func(repo auth.Repository, outbox auth.EmailOutboxWriter) error { if err := repo.InvalidateActiveSecurityPINResetTokensByUserID(ctx, user.ID, now); err != nil { return err } if err := repo.CreateSecurityPINResetToken(ctx, record); err != nil { return err } return s.persistEmailOutbox(ctx, outbox, job) }) } if err := s.repo.InvalidateActiveSecurityPINResetTokensByUserID(ctx, user.ID, now); err != nil { return err } if err := s.repo.CreateSecurityPINResetToken(ctx, record); err != nil { return err } return s.dispatchPreparedEmailJob(ctx, job) } func (s *AuthService) isForgotPasswordCooldownActive(ctx context.Context, key, email, requestedIP string) bool { if s.tokens == nil || s.cfg.ForgotPasswordEmailCooldown <= 0 { return false } v, err := s.tokens.Get(ctx, key) if err != nil { return false } if v != nil { s.auditForgotPasswordAnomaly(email, requestedIP, "cooldown_hit") return true } return false } func (s *AuthService) markForgotPasswordCooldown(ctx context.Context, key string) error { if s.tokens == nil || s.cfg.ForgotPasswordEmailCooldown <= 0 { return nil } return s.tokens.Set(ctx, key, []byte("1"), s.cfg.ForgotPasswordEmailCooldown) } func (s *AuthService) isForgotSecurityPINCooldownActive(ctx context.Context, key string) bool { if s.tokens == nil || s.cfg.ForgotSecurityPINCooldown <= 0 { return false } v, err := s.tokens.Get(ctx, key) if err != nil { return false } return v != nil } func (s *AuthService) markForgotSecurityPINCooldown(ctx context.Context, key string) error { if s.tokens == nil || s.cfg.ForgotSecurityPINCooldown <= 0 { return nil } return s.tokens.Set(ctx, key, []byte("1"), s.cfg.ForgotSecurityPINCooldown) } func (s *AuthService) isInviteSetPasswordResendCooldownActive(ctx context.Context, key string) bool { if s.tokens == nil || s.cfg.ForgotPasswordEmailCooldown <= 0 { return false } v, err := s.tokens.Get(ctx, key) if err != nil { return false } return v != nil } func (s *AuthService) markInviteSetPasswordResendCooldown(ctx context.Context, key string) error { if s.tokens == nil || s.cfg.ForgotPasswordEmailCooldown <= 0 { return nil } return s.tokens.Set(ctx, key, []byte("1"), s.cfg.ForgotPasswordEmailCooldown) } func (s *AuthService) verifySecurityPIN(ctx context.Context, userID []byte, pin string) error { if len(userID) != 16 { return ErrInvalidSecurityPIN } if err := validateSecurityPIN(pin); err != nil { return err } if s.isSecurityPINLocked(ctx, userID) { s.logServiceAudit(ctx, "auth.pin.verify", userID, false, "blocked", nil) return ErrSecurityPINLocked } user, err := s.repo.GetUserByID(ctx, userID) if err != nil { return err } if user == nil { return ErrInvalidToken } if len(user.SecurityPINHash) == 0 { return ErrSecurityPINNotSet } pinVerified := false if hash, salt, ok := decodeSecurityPINRecord(user.SecurityPINHash); ok { pinVerified = s.verifyPassword(pin, hash, salt) } else { // Backward-compatibility path for legacy encrypted PIN values. if s.protector == nil { return errors.New("secret protector not configured") } storedPIN, err := s.protector.Decrypt(user.SecurityPINHash) if err != nil { return err } pinVerified = subtleConstantTimeCompare(storedPIN, []byte(pin)) } if pinVerified { _ = s.clearSecurityPINLockState(ctx, userID) return nil } reachedLimit, _ := s.increaseSecurityPINFailedAttempt(ctx, userID) if reachedLimit { s.logServiceAudit(ctx, "auth.pin.verify", userID, false, "max_attempts_reached_blocked", nil) return ErrSecurityPINAttemptLimit } s.logServiceAudit(ctx, "auth.pin.verify", userID, false, "invalid_pin", nil) return ErrInvalidSecurityPIN } func (s *AuthService) isSecurityPINLocked(ctx context.Context, userID []byte) bool { if s.tokens == nil { return false } v, err := s.tokens.Get(ctx, securityPINLockKey(userID)) if err != nil { return false } return v != nil } func (s *AuthService) increaseSecurityPINFailedAttempt(ctx context.Context, userID []byte) (bool, error) { if s.tokens == nil || s.cfg.SecurityPINMaxAttempts <= 0 { return false, nil } key := securityPINAttemptKey(userID) current := 0 raw, err := s.tokens.Get(ctx, key) if err == nil && raw != nil { if parsed, parseErr := strconv.Atoi(string(raw)); parseErr == nil && parsed >= 0 { current = parsed } } current++ if current >= s.cfg.SecurityPINMaxAttempts { lockTTL := s.cfg.SecurityPINLockDuration if lockTTL < 0 { lockTTL = 0 } _ = s.tokens.Set(ctx, securityPINLockKey(userID), []byte("1"), lockTTL) current = 0 if err := s.tokens.Set(ctx, key, []byte(strconv.Itoa(current)), lockTTL); err != nil { return true, err } return true, nil } lockTTL := s.cfg.SecurityPINLockDuration if lockTTL < 0 { lockTTL = 0 } return false, s.tokens.Set(ctx, key, []byte(strconv.Itoa(current)), lockTTL) } func (s *AuthService) clearSecurityPINLockState(ctx context.Context, userID []byte) error { if s.tokens == nil { return nil } _ = s.tokens.Delete(ctx, securityPINAttemptKey(userID)) _ = s.tokens.Delete(ctx, securityPINLockKey(userID)) return nil } func (s *AuthService) sessionVersion(ctx context.Context, userID []byte) (int64, error) { if s.tokens == nil || len(userID) == 0 { return 0, nil } raw, err := s.tokens.Get(ctx, sessionVersionKey(userID)) if err != nil || len(raw) == 0 { return 0, err } n, err := strconv.ParseInt(string(raw), 10, 64) if err != nil || n < 0 { return 0, ErrInvalidToken } return n, nil } func (s *AuthService) bumpSessionVersion(ctx context.Context, userID []byte) error { if s.tokens == nil || len(userID) == 0 { return nil } current, err := s.sessionVersion(ctx, userID) if err != nil { return err } next := strconv.FormatInt(current+1, 10) return s.tokens.Set(ctx, sessionVersionKey(userID), []byte(next), 0) } func (s *AuthService) validSessionVersion(ctx context.Context, userID []byte, claim any) bool { if s.tokens == nil || len(userID) == 0 { return false } current, err := s.sessionVersion(ctx, userID) if err != nil { return false } return current == int64Claim(claim) } func (s *AuthService) auditForgotPasswordAnomaly(email, requestedIP, reason string) { log.Printf("auth forgot-password %s email_sha256=%s ip=%s", reason, shortEmailHash(email), sanitizeIP(requestedIP, s.ipProtector)) } func (s *AuthService) logServiceAudit(ctx context.Context, action string, userID []byte, success bool, message string, metadata map[string]any) { if s.audit == nil { return } s.audit.Log(ctx, AuditLogInput{ Layer: "service", Module: "auth", Action: action, ActorUserID: userID, Success: success, Message: message, Metadata: metadata, }) } func (s *AuthService) hashPassword(password string) (hash []byte, salt []byte, err error) { salt = make([]byte, 16) if _, err = rand.Read(salt); err != nil { return nil, nil, err } peppered := []byte(password + s.cfg.PasswordPepper) hash = argon2.IDKey(peppered, salt, s.cfg.HashTime, s.cfg.HashMemoryKB, s.cfg.HashThreads, s.cfg.HashKeyLen) return hash, salt, nil } func (s *AuthService) verifyPassword(password string, hash, salt []byte) bool { if len(hash) == 0 || len(salt) == 0 { return false } peppered := []byte(password + s.cfg.PasswordPepper) candidate := argon2.IDKey(peppered, salt, s.cfg.HashTime, s.cfg.HashMemoryKB, s.cfg.HashThreads, s.cfg.HashKeyLen) return subtleConstantTimeCompare(hash, candidate) } func (s *AuthService) hashSecurityPIN(pin string) ([]byte, error) { hash, salt, err := s.hashPassword(pin) if err != nil { return nil, err } return encodeSecurityPINRecord(hash, salt), nil } func encodeSecurityPINRecord(hash, salt []byte) []byte { if len(hash) == 0 || len(salt) == 0 { return nil } return []byte( base64.RawURLEncoding.EncodeToString(salt) + "$" + base64.RawURLEncoding.EncodeToString(hash), ) } func decodeSecurityPINRecord(v []byte) (hash, salt []byte, ok bool) { raw := strings.TrimSpace(string(v)) if raw == "" { return nil, nil, false } parts := strings.Split(raw, "$") var saltPart, hashPart string switch { case len(parts) == 2: saltPart = parts[0] hashPart = parts[1] case len(parts) == 3 && parts[0] == "v1": // Backward compatibility for older records. saltPart = parts[1] hashPart = parts[2] default: return nil, nil, false } salt, err := base64.RawURLEncoding.DecodeString(saltPart) if err != nil || len(salt) == 0 { return nil, nil, false } hash, err = base64.RawURLEncoding.DecodeString(hashPart) if err != nil || len(hash) == 0 { return nil, nil, false } return hash, salt, true } func randomToken(n int) (string, error) { b := make([]byte, n) if _, err := io.ReadFull(rand.Reader, b); err != nil { return "", err } return base64.RawURLEncoding.EncodeToString(b), nil } func hashToken(raw string) []byte { sum := sha256.Sum256([]byte(raw)) return sum[:] } func hashEmailOTPCode(userID []byte, code, pepper string) []byte { payload := bytes.NewBuffer(nil) payload.Write(userID) payload.WriteString(":") payload.WriteString(strings.TrimSpace(code)) payload.WriteString(":") payload.WriteString(strings.TrimSpace(pepper)) sum := sha256.Sum256(payload.Bytes()) return sum[:] } func randomNumericCode(length int) (string, error) { if length <= 0 { length = 6 } buf := make([]byte, length) if _, err := io.ReadFull(rand.Reader, buf); err != nil { return "", err } for i := range buf { buf[i] = '0' + (buf[i] % 10) } return string(buf), nil } func hashIdentifier(raw string) string { sum := sha256.Sum256([]byte(strings.ToLower(strings.TrimSpace(raw)))) return hex.EncodeToString(sum[:]) } func emailVerifyKey(token string) string { return "auth:email_verify:" + token } const ( ssoProviderMicrosoft = "microsoft" microsoftStateFlowLogin = "login" microsoftStateFlowLoginCheck = "login_check" microsoftStateFlowLoginCheckStrict = "login_check_strict" microsoftStateFlowLink = "link" microsoftStateFlowReauth = "reauth" pinResetMethodPassword = "password" pinResetMethodMicrosoft = "microsoft" microsoftReauthPurposePINResetRequest = "pin_reset_request" microsoftReauthPurposePINResetConfirm = "pin_reset_confirm" ) func withMicrosoftAuthPrompt(rawURL, prompt string) string { rawURL = strings.TrimSpace(rawURL) prompt = strings.TrimSpace(prompt) if rawURL == "" || prompt == "" { return rawURL } parsed, err := url.Parse(rawURL) if err != nil { return rawURL } q := parsed.Query() q.Set("prompt", prompt) parsed.RawQuery = q.Encode() return parsed.String() } func withMicrosoftPKCE(rawURL, codeVerifier string) string { rawURL = strings.TrimSpace(rawURL) codeVerifier = strings.TrimSpace(codeVerifier) if rawURL == "" || codeVerifier == "" { return rawURL } parsed, err := url.Parse(rawURL) if err != nil { return rawURL } q := parsed.Query() q.Set("code_challenge_method", "S256") q.Set("code_challenge", microsoftPKCEChallengeS256(codeVerifier)) parsed.RawQuery = q.Encode() return parsed.String() } func ssoStateKey(state string) string { return "auth:sso_state:" + state } func microsoftReauthKey(token string) string { return "auth:microsoft_reauth:" + token } func refreshKey(jti string) string { return "auth:refresh:" + jti } func refreshReplayKey(jti string) string { return "auth:refresh:replay:" + jti } func refreshLockKey(sessionID string) string { return "auth:refresh:lock:" + sessionID } func totpChallengeKey(token string) string { return "auth:totp_challenge:" + token } func totpChallengeAttemptKey(token string) string { return "auth:totp_challenge_attempt:" + token } func emailOTPChallengeKey(token string) string { return "auth:email_otp_challenge:" + token } func inviteKey(token string) string { return "auth:invite:" + token } func forgotPasswordCooldownKey(email string) string { return "auth:forgot_password:cooldown:" + hashIdentifier(email) } func forgotSecurityPINCooldownKey(email string) string { return "auth:security_pin:cooldown:" + hashIdentifier(email) } func inviteSetPasswordResendCooldownKey(userID []byte) string { return "auth:invite:resend:cooldown:" + bytesToString(userID) } func sessionVersionKey(userID []byte) string { return "auth:session_version:" + bytesToString(userID) } func pinActionKey(token string) string { return "auth:pin_action:" + token } func securityPINAttemptKey(userID []byte) string { return "auth:security_pin:attempt:" + bytesToString(userID) } func securityPINLockKey(userID []byte) string { return "auth:security_pin:lock:" + bytesToString(userID) } func encodeMicrosoftStatePayload(payload microsoftStatePayload) ([]byte, error) { return json.Marshal(payload) } func decodeMicrosoftStatePayload(raw []byte) (microsoftStatePayload, error) { if len(raw) == 1 && raw[0] == '1' { return microsoftStatePayload{Flow: microsoftStateFlowLogin}, nil } var payload microsoftStatePayload if err := json.Unmarshal(raw, &payload); err != nil { return microsoftStatePayload{}, err } if payload.Flow == "" { return microsoftStatePayload{}, ErrInvalidSSOState } return payload, nil } func (s *AuthService) loadMicrosoftStatePayload(ctx context.Context, state string) (microsoftStatePayload, error) { state = strings.TrimSpace(state) if state == "" || s.tokens == nil { return microsoftStatePayload{}, ErrInvalidSSOState } raw, err := s.tokens.Get(ctx, ssoStateKey(state)) if err != nil || raw == nil { return microsoftStatePayload{}, ErrInvalidSSOState } payload, err := decodeMicrosoftStatePayload(raw) if err != nil { return microsoftStatePayload{}, ErrInvalidSSOState } return payload, nil } func isSupportedMicrosoftReauthPurpose(purpose string) bool { switch strings.TrimSpace(purpose) { case microsoftReauthPurposePINResetRequest, microsoftReauthPurposePINResetConfirm: return true default: return false } } func normalizePINResetMethod(method string) string { method = strings.ToLower(strings.TrimSpace(method)) if method == "" { return pinResetMethodPassword } return method } func (s *AuthService) microsoftReauthTTL() time.Duration { if s.cfg.SSOStateTTL > 0 { return s.cfg.SSOStateTTL } return 5 * time.Minute } func (s *AuthService) exchangeMicrosoftCode(ctx context.Context, code, codeVerifier string) (MicrosoftClaims, *MicrosoftTokenSet, error) { if advanced, ok := s.sso.(microsoftTokenExchanger); ok { set, claims, err := advanced.ExchangeCodeAndTokens(ctx, code, codeVerifier) if err != nil { return MicrosoftClaims{}, nil, err } return claims, &set, nil } claims, err := s.sso.ExchangeCode(ctx, code) if err != nil { return MicrosoftClaims{}, nil, err } return claims, nil, nil } func (s *AuthService) storeMicrosoftOAuthToken(ctx context.Context, userID []byte, claims MicrosoftClaims, tokenSet MicrosoftTokenSet) error { repo, ok := s.repo.(microsoftOAuthTokenStore) if !ok { return errors.New("microsoft oauth token store is not configured") } if s.protector == nil { return errors.New("secret protector not configured") } tenantID := strings.TrimSpace(claims.TenantID) if tenantID == "" { return errors.New("missing microsoft tenant id") } objectID := strings.TrimSpace(claims.ObjectID) if objectID == "" { return errors.New("missing microsoft object id") } refreshToken := strings.TrimSpace(tokenSet.RefreshToken) if refreshToken == "" { return errors.New("missing refresh token") } encRefresh, err := s.protector.Encrypt([]byte(refreshToken)) if err != nil { return err } var encAccess []byte if strings.TrimSpace(tokenSet.AccessToken) != "" { encAccess, err = s.protector.Encrypt([]byte(strings.TrimSpace(tokenSet.AccessToken))) if err != nil { return err } } var encID []byte if strings.TrimSpace(tokenSet.IDToken) != "" { encID, err = s.protector.Encrypt([]byte(strings.TrimSpace(tokenSet.IDToken))) if err != nil { return err } } now := time.Now().UTC() var expAt *time.Time if tokenSet.ExpiresIn > 0 { t := now.Add(time.Duration(tokenSet.ExpiresIn) * time.Second) expAt = &t } var refreshExpAt *time.Time if tokenSet.RefreshTokenExpiresIn > 0 { t := now.Add(time.Duration(tokenSet.RefreshTokenExpiresIn) * time.Second) refreshExpAt = &t } return repo.UpsertMicrosoftOAuthToken(ctx, &auth.UserMicrosoftOAuthToken{ UserID: append([]byte(nil), userID...), Provider: ssoProviderMicrosoft, TenantID: tenantID, MicrosoftObjectID: objectID, MicrosoftSubject: strings.TrimSpace(claims.Subject), MicrosoftSessionID: strings.TrimSpace(claims.SessionID), RefreshTokenEncrypted: encRefresh, AccessTokenEncrypted: encAccess, IDTokenEncrypted: encID, Scope: strings.TrimSpace(tokenSet.Scope), TokenType: strings.TrimSpace(tokenSet.TokenType), AccessTokenExpiresAt: expAt, RefreshTokenExpiresAt: refreshExpAt, RefreshTokenRotatedAt: &now, }) } func (s *AuthService) RefreshMicrosoftAccessToken(ctx context.Context, userID []byte) (MicrosoftTokenSet, error) { if len(userID) != 16 { return MicrosoftTokenSet{}, ErrInvalidToken } advanced, ok := s.sso.(microsoftTokenExchanger) if !ok { return MicrosoftTokenSet{}, errors.New("microsoft refresh is not supported") } if s.protector == nil { return MicrosoftTokenSet{}, errors.New("secret protector not configured") } repo, ok := s.repo.(microsoftOAuthTokenStore) if !ok { return MicrosoftTokenSet{}, errors.New("microsoft oauth token store is not configured") } record, err := repo.GetMicrosoftOAuthTokenByUserIDProvider(ctx, userID, ssoProviderMicrosoft) if err != nil { return MicrosoftTokenSet{}, err } if record == nil || len(record.RefreshTokenEncrypted) == 0 { return MicrosoftTokenSet{}, ErrMicrosoftReauthRequired } rawRefresh, err := s.protector.Decrypt(record.RefreshTokenEncrypted) if err != nil || len(rawRefresh) == 0 { return MicrosoftTokenSet{}, ErrMicrosoftReauthRequired } set, err := advanced.RefreshAccessToken(ctx, string(rawRefresh), "") if err != nil { msg := strings.ToLower(strings.TrimSpace(err.Error())) if strings.Contains(msg, "invalid_grant") || strings.Contains(msg, "interaction_required") || strings.Contains(msg, "consent_required") { _, _ = repo.DeleteMicrosoftOAuthTokenByUserIDProvider(ctx, userID, ssoProviderMicrosoft) return MicrosoftTokenSet{}, ErrMicrosoftReauthRequired } return MicrosoftTokenSet{}, err } claims, parseErr := parseMicrosoftClaimsFromIDToken(set.IDToken) if parseErr != nil { return MicrosoftTokenSet{}, parseErr } if claims.Subject == "" { claims.Subject = record.MicrosoftSubject } if claims.TenantID == "" { claims.TenantID = record.TenantID } if claims.ObjectID == "" { claims.ObjectID = record.MicrosoftObjectID } if set.RefreshToken == "" { // Microsoft may not always rotate refresh token in response. set.RefreshToken = string(rawRefresh) } if err := s.storeMicrosoftOAuthToken(ctx, userID, claims, set); err != nil { return MicrosoftTokenSet{}, err } return set, nil } func (s *AuthService) LogoutMicrosoft(ctx context.Context, userID []byte) error { if len(userID) != 16 { return ErrInvalidToken } repo, ok := s.repo.(microsoftOAuthTokenStore) if !ok { return errors.New("microsoft oauth token store is not configured") } if _, err := repo.DeleteMicrosoftOAuthTokenByUserIDProvider(ctx, userID, ssoProviderMicrosoft); err != nil { return err } return s.RevokeAllUserSessions(ctx, userID) } func (s *AuthService) MicrosoftSessionIDForUser(ctx context.Context, userID []byte) (string, error) { if len(userID) != 16 { return "", ErrInvalidToken } repo, ok := s.repo.(microsoftOAuthTokenStore) if !ok { return "", errors.New("microsoft oauth token store is not configured") } record, err := repo.GetMicrosoftOAuthTokenByUserIDProvider(ctx, userID, ssoProviderMicrosoft) if err != nil || record == nil { return "", err } return strings.TrimSpace(record.MicrosoftSessionID), nil } func msFirstNonEmpty(values ...string) string { for i := range values { if strings.TrimSpace(values[i]) != "" { return strings.TrimSpace(values[i]) } } return "" } func parseJWTStringClaim(claims map[string]any, key string) string { if claims == nil { return "" } v, _ := claims[key].(string) return strings.TrimSpace(v) } func parseJWTUnixClaim(value any) int64 { switch v := value.(type) { case int64: return v case int: return int64(v) case float64: return int64(v) case json.Number: i, _ := v.Int64() return i default: return 0 } } func bytesToString(b []byte) string { s, _ := uuidv7.BytesToString(b) return s } func splitName(full string) (string, string) { full = strings.TrimSpace(full) if full == "" { return "User", "" } parts := strings.Fields(full) if len(parts) == 1 { return parts[0], "" } return parts[0], strings.Join(parts[1:], " ") } func normalizeUsername(username string) (string, error) { username = strings.TrimSpace(username) if username == "" { return "", errors.New("username is required") } return username, nil } func (s *AuthService) nextAvailableUsernameFromEmail(ctx context.Context, email string) (string, error) { base := usernameBaseFromEmail(email) if base == "" { return "", errors.New("username is required") } for i := 1; i <= 999; i++ { candidate := base if i > 1 { suffix := strconv.Itoa(i) candidate = truncate(base, 100-len(suffix)) + suffix } existing, err := s.repo.GetUserByUsername(ctx, candidate) if err != nil { return "", err } if existing == nil { return candidate, nil } } return "", errors.New("unable to allocate username") } func usernameBaseFromEmail(email string) string { email = strings.ToLower(strings.TrimSpace(email)) localPart, _, found := strings.Cut(email, "@") if !found { localPart = email } localPart = strings.Trim(localPart, ".") if localPart == "" { localPart = "user" } return truncate(localPart, 100) } func ptrString(v string) *string { if strings.TrimSpace(v) == "" { return nil } out := strings.TrimSpace(v) return &out } func ptrTime(t time.Time) *time.Time { return &t } func subtleConstantTimeCompare(a, b []byte) bool { if len(a) != len(b) { return false } var v byte for i := 0; i < len(a); i++ { v |= a[i] ^ b[i] } return v == 0 } func verifyTOTP(secret, code string) bool { code = strings.ReplaceAll(code, " ", "") ok, _ := totp.ValidateCustom(code, secret, time.Now().UTC(), totp.ValidateOpts{ Period: 30, Skew: 1, Digits: otp.DigitsSix, Algorithm: otp.AlgorithmSHA1, }) return ok } func buildTOTPAuthURL(issuer, accountName, secret string) string { label := url.PathEscape(strings.TrimSpace(issuer) + ":" + strings.TrimSpace(accountName)) q := url.Values{} q.Set("secret", strings.TrimSpace(secret)) q.Set("issuer", strings.TrimSpace(issuer)) return "otpauth://totp/" + label + "?" + q.Encode() } func int64Claim(v any) int64 { switch n := v.(type) { case nil: return 0 case float64: return int64(n) case float32: return int64(n) case int: return int64(n) case int64: return n case int32: return int64(n) case uint: return int64(n) case uint64: return int64(n) case uint32: return int64(n) case string: parsed, err := strconv.ParseInt(n, 10, 64) if err != nil { return 0 } return parsed default: return 0 } } func boolClaim(v any) bool { switch b := v.(type) { case bool: return b case string: return strings.EqualFold(strings.TrimSpace(b), "true") default: return false } } func buildResetLink(baseURL, token string) string { u, err := url.Parse(strings.TrimSpace(baseURL)) if err != nil || u.Scheme == "" || u.Host == "" { return "" } q := u.Query() q.Set("token", token) u.RawQuery = q.Encode() return u.String() } func buildInviteSetPasswordEmail(user *auth.User, link string, ttl time.Duration) (string, string, string) { minutes := int(ttl.Minutes()) if minutes < 1 { minutes = 1440 } displayName := strings.TrimSpace(strings.TrimSpace(user.FirstName + " " + user.LastName)) if displayName == "" { displayName = "there" } subject := "Welcome to Wucher - set your password" textBody := strings.Join([]string{ "Hi " + displayName + ",", "", "You've been invited to Wucher.", "Set your password using the link below:", link, "", "This invitation link expires in " + strconv.Itoa(minutes) + " minutes and can only be used once.", "If you did not expect this invitation, you can ignore this email.", }, "\n") escapedLink := html.EscapeString(link) escapedName := html.EscapeString(displayName) htmlBody := strings.Join([]string{ "", ``, "Set your password", ``, ``, `
`, ``, ``, ``, ``, ``, ``, ``, ``, ``, `
`, `
Wucher
`, `
Account Invitation
`, `
Set your password to activate your account
Hi ` + escapedName + `, you've been invited to access Wucher. Click the button below to create your password and complete account activation.
`, `Set Password`, `
This invitation link expires in ` + strconv.Itoa(minutes) + ` minutes and can be used only once.
If the button does not work, copy this URL into your browser:
` + escapedLink + `
For security reasons, never forward this email or share this link.
`, `
`, ``, }, "") return subject, textBody, htmlBody } func shortEmailHash(email string) string { return hashIdentifier(email)[:12] } func shortIdentifier(id []byte) string { sum := sha256.Sum256(id) encoded := hex.EncodeToString(sum[:]) if len(encoded) < 12 { return encoded } return encoded[:12] } func sanitizeIP(v string, protector SecretProtector) string { v = strings.TrimSpace(v) if v == "" { return "" } normalized := v if ip := net.ParseIP(v); ip != nil { normalized = ip.String() } else { normalized = truncate(v, 45) } if protector == nil { return "" } enc, err := protector.Encrypt([]byte(normalized)) if err != nil { return "" } return base64.RawURLEncoding.EncodeToString(enc) } func decryptStoredIP(v string, protector SecretProtector) string { v = strings.TrimSpace(v) if v == "" { return "" } raw, err := base64.RawURLEncoding.DecodeString(v) if err != nil { // Backward compatibility: existing rows may still contain non-encrypted values. return truncate(v, 45) } if protector == nil { return truncate(v, 45) } plain, err := protector.Decrypt(raw) if err != nil { // Backward compatibility: existing rows may still contain non-encrypted values. return truncate(v, 45) } return truncate(strings.TrimSpace(string(plain)), 45) } func deriveIPEncryptionKey(secret string) []byte { sum := sha256.Sum256([]byte(secret)) key := make([]byte, len(sum)) copy(key, sum[:]) return key } func (s *AuthService) emailOutboxReady() bool { return s.emailOutbox != nil && s.emailSerializer != nil } func (s *AuthService) createUserAndDispatchEmail(ctx context.Context, user *auth.User, job EmailJob) error { if emailJobEmpty(job) { return s.repo.CreateUser(ctx, user) } if txRepo, ok := s.repo.(auth.TransactionalRepository); ok && s.emailOutboxReady() { return txRepo.WithTransaction(ctx, func(repo auth.Repository, outbox auth.EmailOutboxWriter) error { if err := repo.CreateUser(ctx, user); err != nil { return err } return s.persistEmailOutbox(ctx, outbox, job) }) } if err := s.repo.CreateUser(ctx, user); err != nil { return err } return s.dispatchPreparedEmailJob(ctx, job) } func (s *AuthService) persistEmailOutbox(ctx context.Context, outbox auth.EmailOutboxWriter, job EmailJob) error { if outbox == nil || !s.emailOutboxReady() { return errors.New("email outbox is not configured") } if err := job.Validate(); err != nil { return err } message, err := s.emailSerializer.Serialize(ctx, queue.EmailJob(job)) if err != nil { return err } record, err := auth.NewEmailOutboxMessage(time.Now().UTC(), message) if err != nil { return err } if err := outbox.CreateEmailOutboxMessage(ctx, record); err != nil { log.Printf("auth email outbox_persist_failed type=%s email_sha256=%s err=%v", job.NormalizedType(), job.RecipientHash(), err) return err } log.Printf("auth email outbox_persisted type=%s message_id=%s email_sha256=%s correlation_id=%s", job.NormalizedType(), record.MessageID, job.RecipientHash(), record.CorrelationID) return nil } func (s *AuthService) dispatchPreparedEmailJob(ctx context.Context, job EmailJob) error { if emailJobEmpty(job) { return nil } if s.emailOutboxReady() { return s.persistEmailOutbox(ctx, s.emailOutbox, job) } if s.emailQueue != nil { if err := s.emailQueue.Enqueue(ctx, job); err != nil { log.Printf("auth email enqueue_failed type=%s email_sha256=%s err=%v", job.NormalizedType(), job.RecipientHash(), err) return err } log.Printf("auth email enqueued type=%s email_sha256=%s", job.NormalizedType(), job.RecipientHash()) return nil } return errors.New("email queue is not configured") } func emailJobEmpty(job EmailJob) bool { job = job.Canonical() return strings.TrimSpace(job.To) == "" && strings.TrimSpace(job.Subject) == "" && strings.TrimSpace(job.TemplateName) == "" && strings.TrimSpace(job.TextBody) == "" && strings.TrimSpace(job.HTMLBody) == "" } func newIPSecretProtector(secret string) SecretProtector { secret = strings.TrimSpace(secret) if secret == "" { return nil } if p, err := NewAESGCMProtectorFromBase64(secret); err == nil { return p } // Backward compatibility for non-base64 secrets already used in environments/tests. return &AESGCMProtector{key: deriveIPEncryptionKey(secret)} } func truncate(v string, max int) string { if max <= 0 || len(v) <= max { return v } return v[:max] } func sleepRemaining(start time.Time, min time.Duration) { if min <= 0 { return } elapsed := time.Since(start) if elapsed < min { time.Sleep(min - elapsed) } } func validateSecurityPIN(pin string) error { if len(pin) != 6 { return ErrInvalidSecurityPIN } for i := 0; i < len(pin); i++ { if pin[i] < '0' || pin[i] > '9' { return ErrInvalidSecurityPIN } } return nil } func normalizeSensitiveAction(action string) string { action = strings.TrimSpace(strings.ToLower(action)) if !isCanonicalUUID(action) { return "" } return action } func isCanonicalUUID(v string) bool { if len(v) != 36 { return false } if v[8] != '-' || v[13] != '-' || v[18] != '-' || v[23] != '-' { return false } _, err := uuidv7.ParseString(v) return err == nil } func normalizeAuthConfig(cfg config.AuthConfig) config.AuthConfig { if strings.TrimSpace(cfg.IPEncryptionKey) == "" { cfg.IPEncryptionKey = cfg.PasswordPepper } if cfg.HashTime == 0 { cfg.HashTime = 1 } if cfg.HashMemoryKB == 0 { cfg.HashMemoryKB = 64 * 1024 } if cfg.HashThreads == 0 { cfg.HashThreads = 4 } if cfg.HashKeyLen == 0 { cfg.HashKeyLen = 32 } if cfg.PasswordResetTTL < 15*time.Minute || cfg.PasswordResetTTL > 30*time.Minute { cfg.PasswordResetTTL = 20 * time.Minute } if cfg.SecurityPINResetTTL < 15*time.Minute || cfg.SecurityPINResetTTL > 30*time.Minute { cfg.SecurityPINResetTTL = 20 * time.Minute } if cfg.ForgotSecurityPINCooldown < 0 { cfg.ForgotSecurityPINCooldown = 2 * time.Minute } if cfg.ForgotSecurityPINMinDuration < 0 { cfg.ForgotSecurityPINMinDuration = 0 } if cfg.SecurityPINMaxAttempts <= 0 { cfg.SecurityPINMaxAttempts = 5 } if cfg.SecurityPINLockDuration < 0 { cfg.SecurityPINLockDuration = 0 } if cfg.SecurityPINActionTokenTTL <= 0 { cfg.SecurityPINActionTokenTTL = 5 * time.Minute } if cfg.JWTAccessTTL <= 0 { cfg.JWTAccessTTL = 10 * time.Minute } if cfg.JWTRefreshTTL <= 0 { cfg.JWTRefreshTTL = time.Hour } if cfg.JWTRefreshTOTPTTL <= 0 { cfg.JWTRefreshTOTPTTL = cfg.JWTRefreshTTL } if cfg.JWTLocalRefreshTTL <= 0 { cfg.JWTLocalRefreshTTL = 24 * time.Hour } if cfg.SessionIdleTTL <= 0 { cfg.SessionIdleTTL = 15 * time.Minute } if cfg.ForgotPasswordEmailCooldown < 0 { cfg.ForgotPasswordEmailCooldown = 2 * time.Minute } if cfg.ForgotPasswordMinDuration < 0 { cfg.ForgotPasswordMinDuration = 0 } if cfg.ForgotPasswordIPRateMax <= 0 { cfg.ForgotPasswordIPRateMax = 10 } if cfg.ForgotPasswordIPRateWindow <= 0 { cfg.ForgotPasswordIPRateWindow = 15 * time.Minute } if cfg.LoginIPRateMax <= 0 { cfg.LoginIPRateMax = 5 } if cfg.LoginIPRateWindow <= 0 { cfg.LoginIPRateWindow = time.Minute } if cfg.EmailOTPChallengeTTL <= 0 { cfg.EmailOTPChallengeTTL = 10 * time.Minute } if cfg.EmailOTPLength < 6 || cfg.EmailOTPLength > 8 { cfg.EmailOTPLength = 6 } if cfg.EmailOTPExpiry < 5*time.Minute || cfg.EmailOTPExpiry > 10*time.Minute { cfg.EmailOTPExpiry = 10 * time.Minute } if cfg.EmailOTPMaxAttempts < 3 || cfg.EmailOTPMaxAttempts > 5 { cfg.EmailOTPMaxAttempts = 5 } if cfg.EmailOTPResendCooldown <= 0 { cfg.EmailOTPResendCooldown = 60 * time.Second } if cfg.EmailOTPMaxResendPerHour <= 0 { cfg.EmailOTPMaxResendPerHour = 5 } return cfg }