Files
honeyDueAPI/internal/services/auth_service.go
Trey T 4abc57535e Add delete account endpoint and file encryption at rest
Delete Account (Plan #2):
- DELETE /api/auth/account/ with password or "DELETE" confirmation
- Cascade delete across 15+ tables in correct FK order
- Auth provider detection (email/apple/google) for /auth/me/
- File cleanup after account deletion
- Handler + repository tests (12 tests)

Encryption at Rest (Plan #3):
- AES-256-GCM envelope encryption (per-file DEK wrapped by KEK)
- Encrypt on upload, auto-decrypt on serve via StorageService.ReadFile()
- MediaHandler serves decrypted files via c.Blob()
- TaskService email image loading uses ReadFile()
- cmd/migrate-encrypt CLI tool with --dry-run for existing files
- Encryption service + storage service tests (18 tests)
2026-03-26 10:41:01 -05:00

906 lines
26 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
}
// 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]
}