Files
honeyDueAPI/internal/services/auth_service.go
T
Trey t c77ff07ce9
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
fix(security): remediate 2026-05-12 audit findings (Stages 2–5)
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>
2026-05-16 22:28:33 -05:00

1046 lines
33 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 000000999999 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]
}