3593 lines
106 KiB
Go
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
|
|
}
|