c77ff07ce9
Remediation of the 2026-05-12/13 audits (78 findings + cluster gaps), tracked in deploy-k3s/SECURITY.md, plus fixes from two independent post-remediation reviews. Auth & sessions: - SHA-256 hashed auth-token storage (C1); prior-token cache eviction on re-login (MEDIUM-1) - local Google JWKS verification, iss/aud/exp checks (C2/C3) - constant-time login + generic errors (L1/LIVE-L11/LIVE-L13) - per-account login lockout keyed on distinct source IPs (M5/MEDIUM-3) - verified-email gating, login rate limiting (LIVE-L19, H1-H3) IAP & webhooks: - Apple/Google cross-account replay protection (C5/C6/C10/C13, H5/H6) - migrations 000003-000006 (token hashing, IAP replay, audit_log + webhook_event_log table creation, append-only audit log) Authorization & races: - file-ownership owner-OR-member fix (C7), atomic share-code join (C9/H9), device-token reassignment (C8/LOW-3) Secrets & deploy: - secrets file-mounted at /etc/honeydue/secrets, not env (F8); Redis password out of the ConfigMap (HIGH-1); B2 keys reconciled - digest-pinned images, admin ingress hardening, CSP/HSTS, /metrics lockdown; kubeconfig 0600, etcd secrets-encryption, fail2ban + unattended-upgrades at provision; secret-rotation runbook Build, vet, and the full test suite (incl. -race) pass; the goose migration chain is verified against PostgreSQL 16. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1046 lines
33 KiB
Go
1046 lines
33 KiB
Go
package services
|
||
|
||
import (
|
||
"context"
|
||
"crypto/rand"
|
||
"encoding/binary"
|
||
"encoding/hex"
|
||
"errors"
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/rs/zerolog/log"
|
||
"golang.org/x/crypto/bcrypt"
|
||
|
||
"github.com/treytartt/honeydue-api/internal/apperrors"
|
||
"github.com/treytartt/honeydue-api/internal/config"
|
||
"github.com/treytartt/honeydue-api/internal/dto/requests"
|
||
"github.com/treytartt/honeydue-api/internal/dto/responses"
|
||
"github.com/treytartt/honeydue-api/internal/models"
|
||
"github.com/treytartt/honeydue-api/internal/repositories"
|
||
)
|
||
|
||
// Deprecated: Legacy error constants - kept for reference during transition
|
||
// Use apperrors package instead
|
||
var (
|
||
// ErrInvalidCredentials = errors.New("invalid credentials")
|
||
// ErrUsernameTaken = errors.New("username already taken")
|
||
// ErrEmailTaken = errors.New("email already taken")
|
||
// ErrUserInactive = errors.New("user account is inactive")
|
||
// ErrInvalidCode = errors.New("invalid verification code")
|
||
// ErrCodeExpired = errors.New("verification code expired")
|
||
// ErrAlreadyVerified = errors.New("email already verified")
|
||
// ErrRateLimitExceeded = errors.New("too many requests, please try again later")
|
||
// ErrInvalidResetToken = errors.New("invalid or expired reset token")
|
||
ErrAppleSignInFailed = errors.New("Apple Sign In failed")
|
||
ErrGoogleSignInFailed = errors.New("Google Sign In failed")
|
||
)
|
||
|
||
// Per-account login lockout (audit M5, hardened per MEDIUM-3).
|
||
const (
|
||
// maxLoginFailureIPs is how many DISTINCT source IPs may fail to log in to
|
||
// one account within the window before that account is locked. Counting
|
||
// distinct IPs (not raw attempts) means a single attacker who knows a
|
||
// victim's email cannot lock the victim out by spamming failures — only a
|
||
// genuinely distributed credential-stuffing attack reaches this threshold.
|
||
maxLoginFailureIPs = 5
|
||
// loginLockWindow is how long the failed-IP set persists; it is refreshed
|
||
// on each failure so an active attack keeps the window open.
|
||
loginLockWindow = 15 * time.Minute
|
||
)
|
||
|
||
// AuthService handles authentication business logic
|
||
type AuthService struct {
|
||
userRepo *repositories.UserRepository
|
||
notificationRepo *repositories.NotificationRepository
|
||
cache *CacheService
|
||
cfg *config.Config
|
||
}
|
||
|
||
// SetCacheService wires Redis for per-account login-failure tracking (M5).
|
||
func (s *AuthService) SetCacheService(cache *CacheService) {
|
||
s.cache = cache
|
||
}
|
||
|
||
// NewAuthService creates a new auth service
|
||
func NewAuthService(userRepo *repositories.UserRepository, cfg *config.Config) *AuthService {
|
||
return &AuthService{
|
||
userRepo: userRepo,
|
||
cfg: cfg,
|
||
}
|
||
}
|
||
|
||
// SetNotificationRepository sets the notification repository for creating notification preferences
|
||
func (s *AuthService) SetNotificationRepository(notificationRepo *repositories.NotificationRepository) {
|
||
s.notificationRepo = notificationRepo
|
||
}
|
||
|
||
// dummyPasswordHash is a valid bcrypt hash used to keep login response time
|
||
// constant when the account does not exist (audit LIVE-L11). It is computed
|
||
// once at startup; the plaintext it hashes is irrelevant and never used.
|
||
var dummyPasswordHash = func() string {
|
||
h, err := bcrypt.GenerateFromPassword([]byte("honeydue-login-timing-equalizer"), models.BcryptCost)
|
||
if err != nil {
|
||
return "" // CompareHashAndPassword against "" always fails — safe
|
||
}
|
||
return string(h)
|
||
}()
|
||
|
||
// freshToken mints a new auth token for the user and evicts any prior token's
|
||
// Redis cache entry (audit MEDIUM-1). Without the eviction a re-login would
|
||
// not actually kill a previously-issued token until the cache TTL lapsed — a
|
||
// stolen token would keep working for up to 5 minutes after the victim
|
||
// re-authenticates. A cache-eviction failure is logged, not fatal: the token
|
||
// row is already gone, so the stale entry simply ages out on its own.
|
||
func (s *AuthService) freshToken(ctx context.Context, userID uint) (*models.AuthToken, error) {
|
||
token, oldHashes, err := s.userRepo.WithContext(ctx).CreateFreshToken(userID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if s.cache != nil && len(oldHashes) > 0 {
|
||
if cErr := s.cache.InvalidateAuthTokenHashes(ctx, oldHashes...); cErr != nil {
|
||
log.Warn().Err(cErr).Uint("user_id", userID).
|
||
Msg("failed to evict prior auth-token cache entries on re-login")
|
||
}
|
||
}
|
||
return token, nil
|
||
}
|
||
|
||
// Login authenticates a user and returns a token. clientIP is the request's
|
||
// source IP (echo c.RealIP()), used for the distributed-attack lockout.
|
||
func (s *AuthService) Login(ctx context.Context, req *requests.LoginRequest, clientIP string) (*responses.LoginResponse, error) {
|
||
// Find user by username or email
|
||
identifier := req.Username
|
||
if identifier == "" {
|
||
identifier = req.Email
|
||
}
|
||
lockKey := strings.ToLower(strings.TrimSpace(identifier))
|
||
|
||
// Audit M5 (hardened per MEDIUM-3): per-account lockout keyed on the set
|
||
// of distinct source IPs that have failed. Once enough distinct IPs have
|
||
// failed for one account within the window, reject — this still catches
|
||
// distributed credential stuffing, without letting a single attacker lock
|
||
// a victim out by spamming failed logins from one IP.
|
||
if s.cache != nil && lockKey != "" {
|
||
if n, cErr := s.cache.LoginFailureIPCount(ctx, lockKey); cErr == nil && n >= maxLoginFailureIPs {
|
||
return nil, apperrors.TooManyRequests("error.too_many_login_attempts")
|
||
}
|
||
}
|
||
|
||
user, err := s.userRepo.WithContext(ctx).FindByUsernameOrEmail(identifier)
|
||
if err != nil && !errors.Is(err, repositories.ErrUserNotFound) {
|
||
return nil, apperrors.Internal(err)
|
||
}
|
||
|
||
// Constant-time login (audit LIVE-L11): always run a bcrypt comparison,
|
||
// even when the account does not exist or is inactive, so response
|
||
// timing never reveals which emails are real accounts. Compare against
|
||
// the user's hash when available, otherwise a fixed dummy hash.
|
||
passwordHash := dummyPasswordHash
|
||
if user != nil {
|
||
passwordHash = user.Password
|
||
}
|
||
passwordOK := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(req.Password)) == nil
|
||
|
||
// One generic error for not-found, inactive, and wrong-password
|
||
// (audit L1) — none of them disclose which condition failed.
|
||
if user == nil || !user.IsActive || !passwordOK {
|
||
if s.cache != nil && lockKey != "" {
|
||
_, _ = s.cache.RegisterLoginFailure(ctx, lockKey, clientIP, loginLockWindow)
|
||
}
|
||
return nil, apperrors.Unauthorized("error.invalid_credentials")
|
||
}
|
||
|
||
// Successful authentication — clear the failure counter (audit M5).
|
||
if s.cache != nil && lockKey != "" {
|
||
_ = s.cache.ClearLoginFailures(ctx, lockKey)
|
||
}
|
||
|
||
// Get or create auth token
|
||
token, err := s.freshToken(ctx, user.ID)
|
||
if err != nil {
|
||
return nil, apperrors.Internal(err)
|
||
}
|
||
|
||
// Update last login
|
||
if err := s.userRepo.WithContext(ctx).UpdateLastLogin(user.ID); err != nil {
|
||
// Log error but don't fail the login
|
||
log.Warn().Err(err).Uint("user_id", user.ID).Msg("Failed to update last login")
|
||
}
|
||
|
||
return &responses.LoginResponse{
|
||
Token: token.Plaintext,
|
||
User: responses.NewUserResponse(user),
|
||
}, nil
|
||
}
|
||
|
||
// Register creates a new user account.
|
||
// F-10: User creation, profile creation, notification preferences, and confirmation code
|
||
// are wrapped in a transaction for atomicity.
|
||
func (s *AuthService) Register(ctx context.Context, req *requests.RegisterRequest) (*responses.RegisterResponse, string, error) {
|
||
// Check if username exists
|
||
exists, err := s.userRepo.WithContext(ctx).ExistsByUsername(req.Username)
|
||
if err != nil {
|
||
return nil, "", apperrors.Internal(err)
|
||
}
|
||
if exists {
|
||
return nil, "", apperrors.Conflict("error.username_taken")
|
||
}
|
||
|
||
// Check if email exists
|
||
exists, err = s.userRepo.WithContext(ctx).ExistsByEmail(req.Email)
|
||
if err != nil {
|
||
return nil, "", apperrors.Internal(err)
|
||
}
|
||
if exists {
|
||
return nil, "", apperrors.Conflict("error.email_taken")
|
||
}
|
||
|
||
// Create user
|
||
user := &models.User{
|
||
Username: req.Username,
|
||
Email: req.Email,
|
||
FirstName: req.FirstName,
|
||
LastName: req.LastName,
|
||
IsActive: true,
|
||
}
|
||
|
||
// Hash password
|
||
if err := user.SetPassword(req.Password); err != nil {
|
||
return nil, "", apperrors.Internal(err)
|
||
}
|
||
|
||
// Generate confirmation code - use fixed code when DEBUG_FIXED_CODES is enabled for easier local testing
|
||
var code string
|
||
if s.cfg.Server.DebugFixedCodes {
|
||
code = "123456"
|
||
} else {
|
||
code = generateSixDigitCode()
|
||
}
|
||
expiresAt := time.Now().UTC().Add(s.cfg.Security.ConfirmationExpiry)
|
||
|
||
// Wrap user creation + profile + notification preferences + confirmation code in a transaction
|
||
txErr := s.userRepo.WithContext(ctx).Transaction(func(txRepo *repositories.UserRepository) error {
|
||
// Save user
|
||
if err := txRepo.Create(user); err != nil {
|
||
return err
|
||
}
|
||
|
||
// Create user profile
|
||
if _, err := txRepo.GetOrCreateProfile(user.ID); err != nil {
|
||
log.Warn().Err(err).Uint("user_id", user.ID).Msg("Failed to create user profile during registration")
|
||
}
|
||
|
||
// Create notification preferences with all options enabled
|
||
if s.notificationRepo != nil {
|
||
if _, err := s.notificationRepo.WithContext(ctx).GetOrCreatePreferences(user.ID); err != nil {
|
||
log.Warn().Err(err).Uint("user_id", user.ID).Msg("Failed to create notification preferences during registration")
|
||
}
|
||
}
|
||
|
||
// Create confirmation code
|
||
if _, err := txRepo.CreateConfirmationCode(user.ID, code, expiresAt); err != nil {
|
||
log.Warn().Err(err).Uint("user_id", user.ID).Msg("Failed to create confirmation code during registration")
|
||
}
|
||
|
||
return nil
|
||
})
|
||
if txErr != nil {
|
||
return nil, "", apperrors.Internal(txErr)
|
||
}
|
||
|
||
// Create auth token (outside transaction since token generation is idempotent)
|
||
token, err := s.freshToken(ctx, user.ID)
|
||
if err != nil {
|
||
return nil, "", apperrors.Internal(err)
|
||
}
|
||
|
||
return &responses.RegisterResponse{
|
||
Token: token.Plaintext,
|
||
User: responses.NewUserResponse(user),
|
||
Message: "Registration successful. Please check your email to verify your account.",
|
||
}, code, nil
|
||
}
|
||
|
||
// RefreshToken handles token refresh logic.
|
||
// - If token is expired (> expiryDays old), returns error (must re-login).
|
||
// - If token is in the renewal window (> refreshDays old), generates a new token.
|
||
// - If token is still fresh (< refreshDays old), returns the existing token (no-op).
|
||
func (s *AuthService) RefreshToken(ctx context.Context, tokenKey string, userID uint) (*responses.RefreshTokenResponse, error) {
|
||
expiryDays := s.cfg.Security.TokenExpiryDays
|
||
if expiryDays <= 0 {
|
||
expiryDays = 90
|
||
}
|
||
refreshDays := s.cfg.Security.TokenRefreshDays
|
||
if refreshDays <= 0 {
|
||
refreshDays = 60
|
||
}
|
||
|
||
// Look up the token
|
||
authToken, err := s.userRepo.WithContext(ctx).FindTokenByKey(tokenKey)
|
||
if err != nil {
|
||
return nil, apperrors.Unauthorized("error.invalid_token")
|
||
}
|
||
|
||
// Verify ownership
|
||
if authToken.UserID != userID {
|
||
return nil, apperrors.Unauthorized("error.invalid_token")
|
||
}
|
||
|
||
tokenAge := time.Since(authToken.Created)
|
||
expiryDuration := time.Duration(expiryDays) * 24 * time.Hour
|
||
refreshDuration := time.Duration(refreshDays) * 24 * time.Hour
|
||
|
||
// Token is expired — must re-login
|
||
if tokenAge > expiryDuration {
|
||
return nil, apperrors.Unauthorized("error.token_expired")
|
||
}
|
||
|
||
// Token is still fresh — no-op refresh
|
||
if tokenAge < refreshDuration {
|
||
return &responses.RefreshTokenResponse{
|
||
Token: tokenKey,
|
||
Message: "Token is still valid.",
|
||
}, nil
|
||
}
|
||
|
||
// Token is in the renewal window — generate a new one
|
||
// Delete the old token
|
||
if err := s.userRepo.WithContext(ctx).DeleteToken(tokenKey); err != nil {
|
||
log.Warn().Err(err).Str("token", tokenKey[:8]+"...").Msg("Failed to delete old token during refresh")
|
||
}
|
||
|
||
// Create a new token
|
||
newToken, err := s.userRepo.WithContext(ctx).CreateToken(userID)
|
||
if err != nil {
|
||
return nil, apperrors.Internal(err)
|
||
}
|
||
|
||
return &responses.RefreshTokenResponse{
|
||
Token: newToken.Plaintext,
|
||
Message: "Token refreshed successfully.",
|
||
}, nil
|
||
}
|
||
|
||
// Logout invalidates a user's token
|
||
func (s *AuthService) Logout(ctx context.Context, token string) error {
|
||
return s.userRepo.WithContext(ctx).DeleteToken(token)
|
||
}
|
||
|
||
// GetCurrentUser returns the current authenticated user with profile
|
||
func (s *AuthService) GetCurrentUser(ctx context.Context, userID uint) (*responses.CurrentUserResponse, error) {
|
||
user, err := s.userRepo.WithContext(ctx).FindByIDWithProfile(userID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
authProvider, err := s.userRepo.WithContext(ctx).FindAuthProvider(userID)
|
||
if err != nil {
|
||
// Log but don't fail - default to "email"
|
||
log.Warn().Err(err).Uint("user_id", userID).Msg("Failed to determine auth provider")
|
||
authProvider = "email"
|
||
}
|
||
|
||
response := responses.NewCurrentUserResponse(user, authProvider)
|
||
return &response, nil
|
||
}
|
||
|
||
// DeleteAccount deletes a user's account and all associated data.
|
||
// For email auth users, password verification is required.
|
||
// For social auth users, confirmation string "DELETE" is required.
|
||
// Returns a list of file URLs that need to be deleted from disk.
|
||
func (s *AuthService) DeleteAccount(ctx context.Context, userID uint, password, confirmation *string) ([]string, error) {
|
||
// Fetch user
|
||
user, err := s.userRepo.WithContext(ctx).FindByID(userID)
|
||
if err != nil {
|
||
if errors.Is(err, repositories.ErrUserNotFound) {
|
||
return nil, apperrors.NotFound("error.user_not_found")
|
||
}
|
||
return nil, apperrors.Internal(err)
|
||
}
|
||
|
||
// Determine auth provider
|
||
authProvider, err := s.userRepo.WithContext(ctx).FindAuthProvider(userID)
|
||
if err != nil {
|
||
return nil, apperrors.Internal(err)
|
||
}
|
||
|
||
// Validate credentials based on auth provider
|
||
if authProvider == "email" {
|
||
if password == nil || *password == "" {
|
||
return nil, apperrors.BadRequest("error.password_required")
|
||
}
|
||
if !user.CheckPassword(*password) {
|
||
return nil, apperrors.Unauthorized("error.invalid_credentials")
|
||
}
|
||
} else {
|
||
// Social auth (apple or google) - require confirmation
|
||
if confirmation == nil || *confirmation != "DELETE" {
|
||
return nil, apperrors.BadRequest("error.confirmation_required")
|
||
}
|
||
}
|
||
|
||
// Start transaction and cascade delete
|
||
var fileURLs []string
|
||
txErr := s.userRepo.WithContext(ctx).Transaction(func(txRepo *repositories.UserRepository) error {
|
||
urls, err := txRepo.DeleteUserCascade(userID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
fileURLs = urls
|
||
return nil
|
||
})
|
||
if txErr != nil {
|
||
return nil, apperrors.Internal(txErr)
|
||
}
|
||
|
||
return fileURLs, nil
|
||
}
|
||
|
||
// UpdateProfile updates a user's profile
|
||
func (s *AuthService) UpdateProfile(ctx context.Context, userID uint, req *requests.UpdateProfileRequest) (*responses.CurrentUserResponse, error) {
|
||
user, err := s.userRepo.WithContext(ctx).FindByID(userID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// Check if new email is taken (if email is being changed)
|
||
if req.Email != nil && *req.Email != user.Email {
|
||
exists, err := s.userRepo.WithContext(ctx).ExistsByEmail(*req.Email)
|
||
if err != nil {
|
||
return nil, apperrors.Internal(err)
|
||
}
|
||
if exists {
|
||
return nil, apperrors.Conflict("error.email_already_taken")
|
||
}
|
||
user.Email = *req.Email
|
||
}
|
||
|
||
if req.FirstName != nil {
|
||
user.FirstName = *req.FirstName
|
||
}
|
||
if req.LastName != nil {
|
||
user.LastName = *req.LastName
|
||
}
|
||
|
||
if err := s.userRepo.WithContext(ctx).Update(user); err != nil {
|
||
return nil, apperrors.Internal(err)
|
||
}
|
||
|
||
// Reload with profile
|
||
user, err = s.userRepo.WithContext(ctx).FindByIDWithProfile(userID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
authProvider, err := s.userRepo.WithContext(ctx).FindAuthProvider(userID)
|
||
if err != nil {
|
||
log.Warn().Err(err).Uint("user_id", userID).Msg("Failed to determine auth provider")
|
||
authProvider = "email"
|
||
}
|
||
|
||
response := responses.NewCurrentUserResponse(user, authProvider)
|
||
return &response, nil
|
||
}
|
||
|
||
// VerifyEmail verifies a user's email with a confirmation code
|
||
func (s *AuthService) VerifyEmail(ctx context.Context, userID uint, code string) error {
|
||
// Get user profile
|
||
profile, err := s.userRepo.WithContext(ctx).GetOrCreateProfile(userID)
|
||
if err != nil {
|
||
return apperrors.Internal(err)
|
||
}
|
||
|
||
// Check if already verified
|
||
if profile.Verified {
|
||
return apperrors.BadRequest("error.email_already_verified")
|
||
}
|
||
|
||
// Check for test code when DEBUG_FIXED_CODES is enabled
|
||
if s.cfg.Server.DebugFixedCodes && code == "123456" {
|
||
if err := s.userRepo.WithContext(ctx).SetProfileVerified(userID, true); err != nil {
|
||
return apperrors.Internal(err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// Audit M4: validate the code, consume it, and flip the verified flag in
|
||
// one transaction so the three writes commit or roll back together.
|
||
txErr := s.userRepo.WithContext(ctx).Transaction(func(txRepo *repositories.UserRepository) error {
|
||
confirmCode, err := txRepo.FindConfirmationCode(userID, code)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := txRepo.MarkConfirmationCodeUsed(confirmCode.ID); err != nil {
|
||
return err
|
||
}
|
||
return txRepo.SetProfileVerified(userID, true)
|
||
})
|
||
if txErr != nil {
|
||
if errors.Is(txErr, repositories.ErrCodeNotFound) {
|
||
return apperrors.BadRequest("error.invalid_verification_code")
|
||
}
|
||
if errors.Is(txErr, repositories.ErrCodeExpired) {
|
||
return apperrors.BadRequest("error.verification_code_expired")
|
||
}
|
||
return apperrors.Internal(txErr)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// ResendVerificationCode creates and returns a new verification code
|
||
func (s *AuthService) ResendVerificationCode(ctx context.Context, userID uint) (string, error) {
|
||
// Get user profile
|
||
profile, err := s.userRepo.WithContext(ctx).GetOrCreateProfile(userID)
|
||
if err != nil {
|
||
return "", apperrors.Internal(err)
|
||
}
|
||
|
||
// Check if already verified
|
||
if profile.Verified {
|
||
return "", apperrors.BadRequest("error.email_already_verified")
|
||
}
|
||
|
||
// Generate new code - use fixed code when DEBUG_FIXED_CODES is enabled for easier local testing
|
||
var code string
|
||
if s.cfg.Server.DebugFixedCodes {
|
||
code = "123456"
|
||
} else {
|
||
code = generateSixDigitCode()
|
||
}
|
||
expiresAt := time.Now().UTC().Add(s.cfg.Security.ConfirmationExpiry)
|
||
|
||
if _, err := s.userRepo.WithContext(ctx).CreateConfirmationCode(userID, code, expiresAt); err != nil {
|
||
return "", apperrors.Internal(err)
|
||
}
|
||
|
||
return code, nil
|
||
}
|
||
|
||
// ForgotPassword initiates the password reset process
|
||
func (s *AuthService) ForgotPassword(ctx context.Context, email string) (string, *models.User, error) {
|
||
// Find user by email
|
||
user, err := s.userRepo.WithContext(ctx).FindByEmail(email)
|
||
if err != nil {
|
||
if errors.Is(err, repositories.ErrUserNotFound) {
|
||
// Don't reveal that the email doesn't exist
|
||
return "", nil, nil
|
||
}
|
||
return "", nil, err
|
||
}
|
||
|
||
// Check rate limit
|
||
count, err := s.userRepo.WithContext(ctx).CountRecentPasswordResetRequests(user.ID)
|
||
if err != nil {
|
||
return "", nil, apperrors.Internal(err)
|
||
}
|
||
if count >= int64(s.cfg.Security.MaxPasswordResetRate) {
|
||
return "", nil, apperrors.TooManyRequests("error.rate_limit_exceeded")
|
||
}
|
||
|
||
// Generate code and reset token - use fixed code when DEBUG_FIXED_CODES is enabled for easier local testing
|
||
var code string
|
||
if s.cfg.Server.DebugFixedCodes {
|
||
code = "123456"
|
||
} else {
|
||
code = generateSixDigitCode()
|
||
}
|
||
resetToken := generateResetToken()
|
||
expiresAt := time.Now().UTC().Add(s.cfg.Security.PasswordResetExpiry)
|
||
|
||
// Hash the code before storing
|
||
codeHash, err := bcrypt.GenerateFromPassword([]byte(code), models.BcryptCost)
|
||
if err != nil {
|
||
return "", nil, apperrors.Internal(err)
|
||
}
|
||
|
||
if _, err := s.userRepo.WithContext(ctx).CreatePasswordResetCode(user.ID, string(codeHash), resetToken, expiresAt); err != nil {
|
||
return "", nil, apperrors.Internal(err)
|
||
}
|
||
|
||
return code, user, nil
|
||
}
|
||
|
||
// VerifyResetCode verifies a password reset code and returns a reset token
|
||
func (s *AuthService) VerifyResetCode(ctx context.Context, email, code string) (string, error) {
|
||
// Find the reset code
|
||
resetCode, user, err := s.userRepo.WithContext(ctx).FindPasswordResetCodeByEmail(email)
|
||
if err != nil {
|
||
if errors.Is(err, repositories.ErrUserNotFound) || errors.Is(err, repositories.ErrCodeNotFound) {
|
||
return "", apperrors.BadRequest("error.invalid_verification_code")
|
||
}
|
||
return "", apperrors.Internal(err)
|
||
}
|
||
|
||
// Check for test code when DEBUG_FIXED_CODES is enabled
|
||
if s.cfg.Server.DebugFixedCodes && code == "123456" {
|
||
return resetCode.ResetToken, nil
|
||
}
|
||
|
||
// Verify the code
|
||
if !resetCode.CheckCode(code) {
|
||
// Increment attempts
|
||
s.userRepo.WithContext(ctx).IncrementResetCodeAttempts(resetCode.ID)
|
||
return "", apperrors.BadRequest("error.invalid_verification_code")
|
||
}
|
||
|
||
// Check if code is still valid
|
||
if !resetCode.IsValid() {
|
||
if resetCode.Used {
|
||
return "", apperrors.BadRequest("error.invalid_verification_code")
|
||
}
|
||
if resetCode.Attempts >= resetCode.MaxAttempts {
|
||
return "", apperrors.TooManyRequests("error.rate_limit_exceeded")
|
||
}
|
||
return "", apperrors.BadRequest("error.verification_code_expired")
|
||
}
|
||
|
||
_ = user // user available if needed
|
||
|
||
return resetCode.ResetToken, nil
|
||
}
|
||
|
||
// ResetPassword resets the user's password using a reset token
|
||
func (s *AuthService) ResetPassword(ctx context.Context, resetToken, newPassword string) error {
|
||
// Find the reset code by token
|
||
resetCode, err := s.userRepo.WithContext(ctx).FindPasswordResetCodeByToken(resetToken)
|
||
if err != nil {
|
||
if errors.Is(err, repositories.ErrCodeNotFound) || errors.Is(err, repositories.ErrCodeExpired) {
|
||
return apperrors.BadRequest("error.invalid_reset_token")
|
||
}
|
||
return apperrors.Internal(err)
|
||
}
|
||
|
||
// Get the user
|
||
user, err := s.userRepo.WithContext(ctx).FindByID(resetCode.UserID)
|
||
if err != nil {
|
||
return apperrors.Internal(err)
|
||
}
|
||
|
||
// Update password
|
||
if err := user.SetPassword(newPassword); err != nil {
|
||
return apperrors.Internal(err)
|
||
}
|
||
|
||
if err := s.userRepo.WithContext(ctx).Update(user); err != nil {
|
||
return apperrors.Internal(err)
|
||
}
|
||
|
||
// Mark reset code as used
|
||
if err := s.userRepo.WithContext(ctx).MarkPasswordResetCodeUsed(resetCode.ID); err != nil {
|
||
// Log error but don't fail
|
||
log.Warn().Err(err).Uint("reset_code_id", resetCode.ID).Msg("Failed to mark reset code as used")
|
||
}
|
||
|
||
// Invalidate all existing tokens for this user (security measure)
|
||
if err := s.userRepo.WithContext(ctx).DeleteTokenByUserID(user.ID); err != nil {
|
||
// Log error but don't fail
|
||
log.Warn().Err(err).Uint("user_id", user.ID).Msg("Failed to delete user tokens after password reset")
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// AppleSignIn handles Sign in with Apple authentication
|
||
func (s *AuthService) AppleSignIn(ctx context.Context, appleAuth *AppleAuthService, req *requests.AppleSignInRequest) (*responses.AppleSignInResponse, error) {
|
||
// 1. Verify the Apple JWT token
|
||
claims, err := appleAuth.VerifyIdentityToken(ctx, req.IDToken)
|
||
if err != nil {
|
||
return nil, apperrors.Unauthorized("error.invalid_credentials").Wrap(err)
|
||
}
|
||
|
||
// Use the subject from claims as the authoritative Apple ID
|
||
appleID := claims.Subject
|
||
if appleID == "" {
|
||
appleID = req.UserID // Fallback to request UserID
|
||
}
|
||
|
||
// 2. Check if this Apple ID is already linked to an account
|
||
existingAuth, err := s.userRepo.WithContext(ctx).FindByAppleID(appleID)
|
||
if err == nil && existingAuth != nil {
|
||
// User already linked with this Apple ID - log them in
|
||
user, err := s.userRepo.WithContext(ctx).FindByIDWithProfile(existingAuth.UserID)
|
||
if err != nil {
|
||
return nil, apperrors.Internal(err)
|
||
}
|
||
|
||
if !user.IsActive {
|
||
return nil, apperrors.Unauthorized("error.account_inactive")
|
||
}
|
||
|
||
// Get or create token
|
||
token, err := s.freshToken(ctx, user.ID)
|
||
if err != nil {
|
||
return nil, apperrors.Internal(err)
|
||
}
|
||
|
||
// Update last login
|
||
_ = s.userRepo.WithContext(ctx).UpdateLastLogin(user.ID)
|
||
|
||
return &responses.AppleSignInResponse{
|
||
Token: token.Plaintext,
|
||
User: responses.NewUserResponse(user),
|
||
IsNewUser: false,
|
||
}, nil
|
||
}
|
||
|
||
// 3. Check if email matches an existing user (for account linking)
|
||
email := getEmailFromRequest(req.Email, claims.Email)
|
||
if email != "" {
|
||
existingUser, err := s.userRepo.WithContext(ctx).FindByEmail(email)
|
||
if err == nil && existingUser != nil {
|
||
// S-06: Log auto-linking of social account to existing user
|
||
log.Warn().
|
||
Str("email", email).
|
||
Str("provider", "apple").
|
||
Uint("user_id", existingUser.ID).
|
||
Msg("Auto-linking social account to existing user by email match")
|
||
|
||
// Link Apple ID to existing account
|
||
appleAuthRecord := &models.AppleSocialAuth{
|
||
UserID: existingUser.ID,
|
||
AppleID: appleID,
|
||
Email: email,
|
||
IsPrivateEmail: isPrivateRelayEmail(email) || claims.IsPrivateRelayEmail(),
|
||
}
|
||
if err := s.userRepo.WithContext(ctx).CreateAppleSocialAuth(appleAuthRecord); err != nil {
|
||
return nil, apperrors.Internal(err)
|
||
}
|
||
|
||
// Mark as verified since Apple verified the email
|
||
_ = s.userRepo.WithContext(ctx).SetProfileVerified(existingUser.ID, true)
|
||
|
||
// Get or create token
|
||
token, err := s.freshToken(ctx, existingUser.ID)
|
||
if err != nil {
|
||
return nil, apperrors.Internal(err)
|
||
}
|
||
|
||
// Update last login
|
||
_ = s.userRepo.WithContext(ctx).UpdateLastLogin(existingUser.ID)
|
||
|
||
// B-08: Check error from FindByIDWithProfile
|
||
existingUser, err = s.userRepo.WithContext(ctx).FindByIDWithProfile(existingUser.ID)
|
||
if err != nil {
|
||
return nil, apperrors.Internal(err)
|
||
}
|
||
|
||
return &responses.AppleSignInResponse{
|
||
Token: token.Plaintext,
|
||
User: responses.NewUserResponse(existingUser),
|
||
IsNewUser: false,
|
||
}, nil
|
||
}
|
||
}
|
||
|
||
// 4. Create new user
|
||
username := generateUniqueUsername(email, req.FirstName)
|
||
|
||
user := &models.User{
|
||
Username: username,
|
||
Email: getEmailOrDefault(email),
|
||
FirstName: getStringOrEmpty(req.FirstName),
|
||
LastName: getStringOrEmpty(req.LastName),
|
||
IsActive: true,
|
||
}
|
||
|
||
// Set a random password (user won't use it since they log in with Apple)
|
||
randomPassword := generateResetToken()
|
||
_ = user.SetPassword(randomPassword)
|
||
|
||
if err := s.userRepo.WithContext(ctx).Create(user); err != nil {
|
||
return nil, apperrors.Internal(err)
|
||
}
|
||
|
||
// Create profile (already verified since Apple verified)
|
||
profile, _ := s.userRepo.WithContext(ctx).GetOrCreateProfile(user.ID)
|
||
if profile != nil {
|
||
_ = s.userRepo.WithContext(ctx).SetProfileVerified(user.ID, true)
|
||
}
|
||
|
||
// Create notification preferences with all options enabled
|
||
if s.notificationRepo != nil {
|
||
if _, err := s.notificationRepo.WithContext(ctx).GetOrCreatePreferences(user.ID); err != nil {
|
||
log.Warn().Err(err).Uint("user_id", user.ID).Msg("Failed to create notification preferences for Apple Sign In user")
|
||
}
|
||
}
|
||
|
||
// Link Apple ID
|
||
appleAuthRecord := &models.AppleSocialAuth{
|
||
UserID: user.ID,
|
||
AppleID: appleID,
|
||
Email: getEmailOrDefault(email),
|
||
IsPrivateEmail: isPrivateRelayEmail(email) || claims.IsPrivateRelayEmail(),
|
||
}
|
||
if err := s.userRepo.WithContext(ctx).CreateAppleSocialAuth(appleAuthRecord); err != nil {
|
||
return nil, apperrors.Internal(err)
|
||
}
|
||
|
||
// Create token
|
||
token, err := s.freshToken(ctx, user.ID)
|
||
if err != nil {
|
||
return nil, apperrors.Internal(err)
|
||
}
|
||
|
||
// B-08: Check error from FindByIDWithProfile
|
||
user, err = s.userRepo.WithContext(ctx).FindByIDWithProfile(user.ID)
|
||
if err != nil {
|
||
return nil, apperrors.Internal(err)
|
||
}
|
||
|
||
return &responses.AppleSignInResponse{
|
||
Token: token.Plaintext,
|
||
User: responses.NewUserResponse(user),
|
||
IsNewUser: true,
|
||
}, nil
|
||
}
|
||
|
||
// GoogleSignIn handles Google Sign In authentication
|
||
func (s *AuthService) GoogleSignIn(ctx context.Context, googleAuth *GoogleAuthService, req *requests.GoogleSignInRequest) (*responses.GoogleSignInResponse, error) {
|
||
// 1. Verify the Google ID token
|
||
tokenInfo, err := googleAuth.VerifyIDToken(ctx, req.IDToken)
|
||
if err != nil {
|
||
return nil, apperrors.Unauthorized("error.invalid_credentials").Wrap(err)
|
||
}
|
||
|
||
googleID := tokenInfo.Sub
|
||
if googleID == "" {
|
||
return nil, apperrors.Unauthorized("error.invalid_credentials")
|
||
}
|
||
|
||
// 2. Check if this Google ID is already linked to an account
|
||
existingAuth, err := s.userRepo.WithContext(ctx).FindByGoogleID(googleID)
|
||
if err == nil && existingAuth != nil {
|
||
// User already linked with this Google ID - log them in
|
||
user, err := s.userRepo.WithContext(ctx).FindByIDWithProfile(existingAuth.UserID)
|
||
if err != nil {
|
||
return nil, apperrors.Internal(err)
|
||
}
|
||
|
||
if !user.IsActive {
|
||
return nil, apperrors.Unauthorized("error.account_inactive")
|
||
}
|
||
|
||
// Get or create token
|
||
token, err := s.freshToken(ctx, user.ID)
|
||
if err != nil {
|
||
return nil, apperrors.Internal(err)
|
||
}
|
||
|
||
// Update last login
|
||
_ = s.userRepo.WithContext(ctx).UpdateLastLogin(user.ID)
|
||
|
||
return &responses.GoogleSignInResponse{
|
||
Token: token.Plaintext,
|
||
User: responses.NewUserResponse(user),
|
||
IsNewUser: false,
|
||
}, nil
|
||
}
|
||
|
||
// 3. Check if email matches an existing user (for account linking)
|
||
email := tokenInfo.Email
|
||
if email != "" {
|
||
existingUser, err := s.userRepo.WithContext(ctx).FindByEmail(email)
|
||
if err == nil && existingUser != nil {
|
||
// S-06: Log auto-linking of social account to existing user
|
||
log.Warn().
|
||
Str("email", email).
|
||
Str("provider", "google").
|
||
Uint("user_id", existingUser.ID).
|
||
Msg("Auto-linking social account to existing user by email match")
|
||
|
||
// Link Google ID to existing account
|
||
googleAuthRecord := &models.GoogleSocialAuth{
|
||
UserID: existingUser.ID,
|
||
GoogleID: googleID,
|
||
Email: email,
|
||
Name: tokenInfo.Name,
|
||
Picture: tokenInfo.Picture,
|
||
}
|
||
if err := s.userRepo.WithContext(ctx).CreateGoogleSocialAuth(googleAuthRecord); err != nil {
|
||
return nil, apperrors.Internal(err)
|
||
}
|
||
|
||
// Mark as verified since Google verified the email
|
||
if tokenInfo.IsEmailVerified() {
|
||
_ = s.userRepo.WithContext(ctx).SetProfileVerified(existingUser.ID, true)
|
||
}
|
||
|
||
// Get or create token
|
||
token, err := s.freshToken(ctx, existingUser.ID)
|
||
if err != nil {
|
||
return nil, apperrors.Internal(err)
|
||
}
|
||
|
||
// Update last login
|
||
_ = s.userRepo.WithContext(ctx).UpdateLastLogin(existingUser.ID)
|
||
|
||
// B-08: Check error from FindByIDWithProfile
|
||
existingUser, err = s.userRepo.WithContext(ctx).FindByIDWithProfile(existingUser.ID)
|
||
if err != nil {
|
||
return nil, apperrors.Internal(err)
|
||
}
|
||
|
||
return &responses.GoogleSignInResponse{
|
||
Token: token.Plaintext,
|
||
User: responses.NewUserResponse(existingUser),
|
||
IsNewUser: false,
|
||
}, nil
|
||
}
|
||
}
|
||
|
||
// 4. Create new user
|
||
username := generateGoogleUsername(email, tokenInfo.GivenName)
|
||
|
||
user := &models.User{
|
||
Username: username,
|
||
Email: email,
|
||
FirstName: tokenInfo.GivenName,
|
||
LastName: tokenInfo.FamilyName,
|
||
IsActive: true,
|
||
}
|
||
|
||
// Set a random password (user won't use it since they log in with Google)
|
||
randomPassword := generateResetToken()
|
||
_ = user.SetPassword(randomPassword)
|
||
|
||
if err := s.userRepo.WithContext(ctx).Create(user); err != nil {
|
||
return nil, apperrors.Internal(err)
|
||
}
|
||
|
||
// Create profile (already verified if Google verified email)
|
||
profile, _ := s.userRepo.WithContext(ctx).GetOrCreateProfile(user.ID)
|
||
if profile != nil && tokenInfo.IsEmailVerified() {
|
||
_ = s.userRepo.WithContext(ctx).SetProfileVerified(user.ID, true)
|
||
}
|
||
|
||
// Create notification preferences with all options enabled
|
||
if s.notificationRepo != nil {
|
||
if _, err := s.notificationRepo.WithContext(ctx).GetOrCreatePreferences(user.ID); err != nil {
|
||
log.Warn().Err(err).Uint("user_id", user.ID).Msg("Failed to create notification preferences for Google Sign In user")
|
||
}
|
||
}
|
||
|
||
// Link Google ID
|
||
googleAuthRecord := &models.GoogleSocialAuth{
|
||
UserID: user.ID,
|
||
GoogleID: googleID,
|
||
Email: email,
|
||
Name: tokenInfo.Name,
|
||
Picture: tokenInfo.Picture,
|
||
}
|
||
if err := s.userRepo.WithContext(ctx).CreateGoogleSocialAuth(googleAuthRecord); err != nil {
|
||
return nil, apperrors.Internal(err)
|
||
}
|
||
|
||
// Create token
|
||
token, err := s.freshToken(ctx, user.ID)
|
||
if err != nil {
|
||
return nil, apperrors.Internal(err)
|
||
}
|
||
|
||
// B-08: Check error from FindByIDWithProfile
|
||
user, err = s.userRepo.WithContext(ctx).FindByIDWithProfile(user.ID)
|
||
if err != nil {
|
||
return nil, apperrors.Internal(err)
|
||
}
|
||
|
||
return &responses.GoogleSignInResponse{
|
||
Token: token.Plaintext,
|
||
User: responses.NewUserResponse(user),
|
||
IsNewUser: true,
|
||
}, nil
|
||
}
|
||
|
||
// Helper functions
|
||
|
||
func generateSixDigitCode() string {
|
||
// Uniform 000000–999999 via rejection sampling on crypto/rand,
|
||
// removing the modulo bias of `n % 1000000` (audit H4).
|
||
for {
|
||
var b [4]byte
|
||
if _, err := rand.Read(b[:]); err != nil {
|
||
continue
|
||
}
|
||
// 4294000000 is the largest multiple of 1e6 <= MaxUint32.
|
||
n := binary.BigEndian.Uint32(b[:])
|
||
if n < 4294000000 {
|
||
return fmt.Sprintf("%06d", n%1000000)
|
||
}
|
||
}
|
||
}
|
||
|
||
func generateResetToken() string {
|
||
b := make([]byte, 32)
|
||
rand.Read(b)
|
||
return hex.EncodeToString(b)
|
||
}
|
||
|
||
// Helper functions for Apple Sign In
|
||
|
||
func getEmailFromRequest(reqEmail *string, claimsEmail string) string {
|
||
if reqEmail != nil && *reqEmail != "" {
|
||
return *reqEmail
|
||
}
|
||
return claimsEmail
|
||
}
|
||
|
||
func getEmailOrDefault(email string) string {
|
||
if email == "" {
|
||
// Generate a placeholder email for users without one
|
||
return fmt.Sprintf("apple_%s@privaterelay.appleid.com", generateResetToken()[:16])
|
||
}
|
||
return email
|
||
}
|
||
|
||
func getStringOrEmpty(s *string) string {
|
||
if s == nil {
|
||
return ""
|
||
}
|
||
return *s
|
||
}
|
||
|
||
func isPrivateRelayEmail(email string) bool {
|
||
return strings.HasSuffix(strings.ToLower(email), "@privaterelay.appleid.com")
|
||
}
|
||
|
||
func generateUniqueUsername(email string, firstName *string) string {
|
||
// Try using first part of email
|
||
if email != "" && !isPrivateRelayEmail(email) {
|
||
parts := strings.Split(email, "@")
|
||
if len(parts) > 0 && parts[0] != "" {
|
||
// Add random suffix to ensure uniqueness
|
||
return parts[0] + "_" + generateResetToken()[:6]
|
||
}
|
||
}
|
||
|
||
// Try using first name
|
||
if firstName != nil && *firstName != "" {
|
||
return strings.ToLower(*firstName) + "_" + generateResetToken()[:6]
|
||
}
|
||
|
||
// Fallback to random username
|
||
return "user_" + generateResetToken()[:10]
|
||
}
|
||
|
||
func generateGoogleUsername(email string, firstName string) string {
|
||
// Try using first part of email
|
||
if email != "" {
|
||
parts := strings.Split(email, "@")
|
||
if len(parts) > 0 && parts[0] != "" {
|
||
// Add random suffix to ensure uniqueness
|
||
return parts[0] + "_" + generateResetToken()[:6]
|
||
}
|
||
}
|
||
|
||
// Try using first name
|
||
if firstName != "" {
|
||
return strings.ToLower(firstName) + "_" + generateResetToken()[:6]
|
||
}
|
||
|
||
// Fallback to random username
|
||
return "google_" + generateResetToken()[:10]
|
||
}
|