Password complexity: custom validator requiring uppercase, lowercase, digit (min 8 chars)
Token expiry: 90-day token lifetime with refresh endpoint (60-90 day renewal window)
Health check: /api/health/ now pings Postgres + Redis, returns 503 on failure
Audit logging: async audit_log table for auth events (login, register, delete, etc.)
Circuit breaker: APNs/FCM push sends wrapped with 5-failure threshold, 30s recovery
FK indexes: 27 missing foreign key indexes across all tables (migration 017)
CSP header: default-src 'none'; frame-ancestors 'none'
Gzip compression: level 5 with media endpoint skipper
Prometheus metrics: /metrics endpoint using existing monitoring service
External timeouts: 15s push, 30s SMTP, context timeouts on all external calls
Migrations: 016 (token created_at), 017 (FK indexes), 018 (audit_log)
Tests: circuit breaker (15), audit service (8), token refresh (7), health (4),
middleware expiry (5), validator (new)
966 lines
28 KiB
Go
966 lines
28 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"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")
|
|
)
|
|
|
|
// AuthService handles authentication business logic
|
|
type AuthService struct {
|
|
userRepo *repositories.UserRepository
|
|
notificationRepo *repositories.NotificationRepository
|
|
cfg *config.Config
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// Login authenticates a user and returns a token
|
|
func (s *AuthService) Login(req *requests.LoginRequest) (*responses.LoginResponse, error) {
|
|
// Find user by username or email
|
|
identifier := req.Username
|
|
if identifier == "" {
|
|
identifier = req.Email
|
|
}
|
|
|
|
user, err := s.userRepo.FindByUsernameOrEmail(identifier)
|
|
if err != nil {
|
|
if errors.Is(err, repositories.ErrUserNotFound) {
|
|
return nil, apperrors.Unauthorized("error.invalid_credentials")
|
|
}
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// Check if user is active
|
|
if !user.IsActive {
|
|
return nil, apperrors.Unauthorized("error.account_inactive")
|
|
}
|
|
|
|
// Verify password
|
|
if !user.CheckPassword(req.Password) {
|
|
return nil, apperrors.Unauthorized("error.invalid_credentials")
|
|
}
|
|
|
|
// Get or create auth token
|
|
token, err := s.userRepo.GetOrCreateToken(user.ID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// Update last login
|
|
if err := s.userRepo.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.Key,
|
|
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(req *requests.RegisterRequest) (*responses.RegisterResponse, string, error) {
|
|
// Check if username exists
|
|
exists, err := s.userRepo.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.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.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.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.userRepo.GetOrCreateToken(user.ID)
|
|
if err != nil {
|
|
return nil, "", apperrors.Internal(err)
|
|
}
|
|
|
|
return &responses.RegisterResponse{
|
|
Token: token.Key,
|
|
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(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.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.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.CreateToken(userID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
return &responses.RefreshTokenResponse{
|
|
Token: newToken.Key,
|
|
Message: "Token refreshed successfully.",
|
|
}, nil
|
|
}
|
|
|
|
// Logout invalidates a user's token
|
|
func (s *AuthService) Logout(token string) error {
|
|
return s.userRepo.DeleteToken(token)
|
|
}
|
|
|
|
// GetCurrentUser returns the current authenticated user with profile
|
|
func (s *AuthService) GetCurrentUser(userID uint) (*responses.CurrentUserResponse, error) {
|
|
user, err := s.userRepo.FindByIDWithProfile(userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
authProvider, err := s.userRepo.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(userID uint, password, confirmation *string) ([]string, error) {
|
|
// Fetch user
|
|
user, err := s.userRepo.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.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.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(userID uint, req *requests.UpdateProfileRequest) (*responses.CurrentUserResponse, error) {
|
|
user, err := s.userRepo.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.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.Update(user); err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// Reload with profile
|
|
user, err = s.userRepo.FindByIDWithProfile(userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
authProvider, err := s.userRepo.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(userID uint, code string) error {
|
|
// Get user profile
|
|
profile, err := s.userRepo.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.SetProfileVerified(userID, true); err != nil {
|
|
return apperrors.Internal(err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Find and validate confirmation code
|
|
confirmCode, err := s.userRepo.FindConfirmationCode(userID, code)
|
|
if err != nil {
|
|
if errors.Is(err, repositories.ErrCodeNotFound) {
|
|
return apperrors.BadRequest("error.invalid_verification_code")
|
|
}
|
|
if errors.Is(err, repositories.ErrCodeExpired) {
|
|
return apperrors.BadRequest("error.verification_code_expired")
|
|
}
|
|
return apperrors.Internal(err)
|
|
}
|
|
|
|
// Mark code as used
|
|
if err := s.userRepo.MarkConfirmationCodeUsed(confirmCode.ID); err != nil {
|
|
return apperrors.Internal(err)
|
|
}
|
|
|
|
// Set profile as verified
|
|
if err := s.userRepo.SetProfileVerified(userID, true); err != nil {
|
|
return apperrors.Internal(err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ResendVerificationCode creates and returns a new verification code
|
|
func (s *AuthService) ResendVerificationCode(userID uint) (string, error) {
|
|
// Get user profile
|
|
profile, err := s.userRepo.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.CreateConfirmationCode(userID, code, expiresAt); err != nil {
|
|
return "", apperrors.Internal(err)
|
|
}
|
|
|
|
return code, nil
|
|
}
|
|
|
|
// ForgotPassword initiates the password reset process
|
|
func (s *AuthService) ForgotPassword(email string) (string, *models.User, error) {
|
|
// Find user by email
|
|
user, err := s.userRepo.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.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), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return "", nil, apperrors.Internal(err)
|
|
}
|
|
|
|
if _, err := s.userRepo.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(email, code string) (string, error) {
|
|
// Find the reset code
|
|
resetCode, user, err := s.userRepo.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.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(resetToken, newPassword string) error {
|
|
// Find the reset code by token
|
|
resetCode, err := s.userRepo.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.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.Update(user); err != nil {
|
|
return apperrors.Internal(err)
|
|
}
|
|
|
|
// Mark reset code as used
|
|
if err := s.userRepo.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.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.FindByAppleID(appleID)
|
|
if err == nil && existingAuth != nil {
|
|
// User already linked with this Apple ID - log them in
|
|
user, err := s.userRepo.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.userRepo.GetOrCreateToken(user.ID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// Update last login
|
|
_ = s.userRepo.UpdateLastLogin(user.ID)
|
|
|
|
return &responses.AppleSignInResponse{
|
|
Token: token.Key,
|
|
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.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.CreateAppleSocialAuth(appleAuthRecord); err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// Mark as verified since Apple verified the email
|
|
_ = s.userRepo.SetProfileVerified(existingUser.ID, true)
|
|
|
|
// Get or create token
|
|
token, err := s.userRepo.GetOrCreateToken(existingUser.ID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// Update last login
|
|
_ = s.userRepo.UpdateLastLogin(existingUser.ID)
|
|
|
|
// B-08: Check error from FindByIDWithProfile
|
|
existingUser, err = s.userRepo.FindByIDWithProfile(existingUser.ID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
return &responses.AppleSignInResponse{
|
|
Token: token.Key,
|
|
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.Create(user); err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// Create profile (already verified since Apple verified)
|
|
profile, _ := s.userRepo.GetOrCreateProfile(user.ID)
|
|
if profile != nil {
|
|
_ = s.userRepo.SetProfileVerified(user.ID, true)
|
|
}
|
|
|
|
// Create notification preferences with all options enabled
|
|
if s.notificationRepo != nil {
|
|
if _, err := s.notificationRepo.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.CreateAppleSocialAuth(appleAuthRecord); err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// Create token
|
|
token, err := s.userRepo.GetOrCreateToken(user.ID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// B-08: Check error from FindByIDWithProfile
|
|
user, err = s.userRepo.FindByIDWithProfile(user.ID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
return &responses.AppleSignInResponse{
|
|
Token: token.Key,
|
|
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.FindByGoogleID(googleID)
|
|
if err == nil && existingAuth != nil {
|
|
// User already linked with this Google ID - log them in
|
|
user, err := s.userRepo.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.userRepo.GetOrCreateToken(user.ID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// Update last login
|
|
_ = s.userRepo.UpdateLastLogin(user.ID)
|
|
|
|
return &responses.GoogleSignInResponse{
|
|
Token: token.Key,
|
|
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.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.CreateGoogleSocialAuth(googleAuthRecord); err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// Mark as verified since Google verified the email
|
|
if tokenInfo.IsEmailVerified() {
|
|
_ = s.userRepo.SetProfileVerified(existingUser.ID, true)
|
|
}
|
|
|
|
// Get or create token
|
|
token, err := s.userRepo.GetOrCreateToken(existingUser.ID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// Update last login
|
|
_ = s.userRepo.UpdateLastLogin(existingUser.ID)
|
|
|
|
// B-08: Check error from FindByIDWithProfile
|
|
existingUser, err = s.userRepo.FindByIDWithProfile(existingUser.ID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
return &responses.GoogleSignInResponse{
|
|
Token: token.Key,
|
|
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.Create(user); err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// Create profile (already verified if Google verified email)
|
|
profile, _ := s.userRepo.GetOrCreateProfile(user.ID)
|
|
if profile != nil && tokenInfo.IsEmailVerified() {
|
|
_ = s.userRepo.SetProfileVerified(user.ID, true)
|
|
}
|
|
|
|
// Create notification preferences with all options enabled
|
|
if s.notificationRepo != nil {
|
|
if _, err := s.notificationRepo.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.CreateGoogleSocialAuth(googleAuthRecord); err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// Create token
|
|
token, err := s.userRepo.GetOrCreateToken(user.ID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// B-08: Check error from FindByIDWithProfile
|
|
user, err = s.userRepo.FindByIDWithProfile(user.ID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
return &responses.GoogleSignInResponse{
|
|
Token: token.Key,
|
|
User: responses.NewUserResponse(user),
|
|
IsNewUser: true,
|
|
}, nil
|
|
}
|
|
|
|
// Helper functions
|
|
|
|
func generateSixDigitCode() string {
|
|
b := make([]byte, 4)
|
|
rand.Read(b)
|
|
num := int(b[0])<<24 | int(b[1])<<16 | int(b[2])<<8 | int(b[3])
|
|
if num < 0 {
|
|
num = -num
|
|
}
|
|
code := num % 1000000
|
|
return fmt.Sprintf("%06d", code)
|
|
}
|
|
|
|
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]
|
|
}
|