e881d37de0
Every public method on these five services now takes ctx context.Context as the first arg and routes its repo calls through .WithContext(ctx). With TaskService and ResidenceService already migrated, this means every in-process service that touches Postgres now produces a flame graph in Jaeger where the SQL spans nest under the parent HTTP request span. Endpoints now fully traced (HTTP → service → SQL): - /api/auth/login, /register, /logout, /me, /verify-email, /resend-verification - /api/auth/forgot-password, /verify-reset, /reset-password, /update-profile - /api/contractors/* (CRUD + favorite + by-residence + tasks) - /api/documents/* (CRUD + activate/deactivate + image upload/delete) - /api/notifications/* (list, count, mark-read, prefs, devices) - /api/subscription/* (status, purchase, cancel, triggers, promotions) - All previously-migrated /api/tasks/* and /api/residences/* paths Internal helpers also threaded: - TaskService.sendTaskCompletedNotification → forwards ctx - TaskService.UpdateUserTimezone → forwards ctx to NotificationService - ResidenceService.CreateResidence → forwards ctx to SubscriptionService.CheckLimit - NotificationService.registerAPNSDevice / registerGCMDevice → both take ctx ~75 method signatures, ~120 handler/test call sites updated. Tests pass green; the only failure is the pre-existing flaky TaskHandler_QuickComplete SQLite race that fails ~60% of runs on master. Step 3 of the observability plan is now genuinely complete: every API endpoint backed by a Go service emits a per-request flame graph with HTTP → service → SQL spans, plus B2/APNs/FCM/asynq spans where applicable. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
966 lines
30 KiB
Go
966 lines
30 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(ctx context.Context, req *requests.LoginRequest) (*responses.LoginResponse, error) {
|
|
// Find user by username or email
|
|
identifier := req.Username
|
|
if identifier == "" {
|
|
identifier = req.Email
|
|
}
|
|
|
|
user, err := s.userRepo.WithContext(ctx).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.WithContext(ctx).GetOrCreateToken(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.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(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.userRepo.WithContext(ctx).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(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.Key,
|
|
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
|
|
}
|
|
|
|
// Find and validate confirmation code
|
|
confirmCode, err := s.userRepo.WithContext(ctx).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.WithContext(ctx).MarkConfirmationCodeUsed(confirmCode.ID); err != nil {
|
|
return apperrors.Internal(err)
|
|
}
|
|
|
|
// Set profile as verified
|
|
if err := s.userRepo.WithContext(ctx).SetProfileVerified(userID, true); err != nil {
|
|
return apperrors.Internal(err)
|
|
}
|
|
|
|
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), bcrypt.DefaultCost)
|
|
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.userRepo.WithContext(ctx).GetOrCreateToken(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.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.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.userRepo.WithContext(ctx).GetOrCreateToken(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.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.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.userRepo.WithContext(ctx).GetOrCreateToken(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.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.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.userRepo.WithContext(ctx).GetOrCreateToken(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.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.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.userRepo.WithContext(ctx).GetOrCreateToken(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.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.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.userRepo.WithContext(ctx).GetOrCreateToken(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.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]
|
|
}
|