Files
fm_be/internal/service/auth_service.go
2026-07-16 22:16:45 +07:00

3593 lines
106 KiB
Go

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{
"<!DOCTYPE html>",
`<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">`,
"<title>Set your password</title></head>",
`<body style="margin:0;padding:0;background:#eef2f7;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;color:#111827;">`,
`<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#eef2f7;padding:28px 12px;">`,
`<tr><td align="center">`,
`<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:680px;background:#ffffff;border:1px solid #e5e7eb;border-radius:16px;overflow:hidden;">`,
`<tr><td style="padding:22px 28px;background:linear-gradient(135deg,#0f172a,#1f2937);">`,
`<div style="font-size:20px;line-height:1.2;font-weight:700;color:#ffffff;">Wucher</div>`,
`<div style="margin-top:6px;font-size:13px;line-height:1.5;color:#cbd5e1;">Account Invitation</div>`,
`</td></tr>`,
`<tr><td style="padding:30px 28px 8px;font-size:24px;line-height:1.3;font-weight:700;color:#0f172a;">Set your password to activate your account</td></tr>`,
`<tr><td style="padding:0 28px 8px;font-size:15px;line-height:1.7;color:#334155;">Hi ` + escapedName + `, you've been invited to access Wucher. Click the button below to create your password and complete account activation.</td></tr>`,
`<tr><td style="padding:20px 28px 10px;">`,
`<a href="` + escapedLink + `" style="display:inline-block;background:#0f172a;color:#ffffff;text-decoration:none;font-weight:700;font-size:14px;letter-spacing:0.2px;padding:13px 22px;border-radius:10px;">Set Password</a>`,
`</td></tr>`,
`<tr><td style="padding:4px 28px 0;font-size:13px;line-height:1.7;color:#475569;">This invitation link expires in ` + strconv.Itoa(minutes) + ` minutes and can be used only once.</td></tr>`,
`<tr><td style="padding:6px 28px 0;font-size:13px;line-height:1.7;color:#475569;">If the button does not work, copy this URL into your browser:</td></tr>`,
`<tr><td style="padding:6px 28px 18px;word-break:break-all;font-size:12px;line-height:1.6;color:#0f172a;">` + escapedLink + `</td></tr>`,
`<tr><td style="padding:14px 28px 24px;border-top:1px solid #e5e7eb;font-size:12px;line-height:1.7;color:#64748b;">For security reasons, never forward this email or share this link.</td></tr>`,
`</table>`,
`</td></tr></table>`,
`</body></html>`,
}, "")
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
}