package services import ( "crypto/rand" "encoding/hex" "errors" "fmt" "time" "golang.org/x/crypto/bcrypt" "github.com/treytartt/mycrib-api/internal/config" "github.com/treytartt/mycrib-api/internal/dto/requests" "github.com/treytartt/mycrib-api/internal/dto/responses" "github.com/treytartt/mycrib-api/internal/models" "github.com/treytartt/mycrib-api/internal/repositories" ) 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") ) // AuthService handles authentication business logic type AuthService struct { userRepo *repositories.UserRepository cfg *config.Config } // NewAuthService creates a new auth service func NewAuthService(userRepo *repositories.UserRepository, cfg *config.Config) *AuthService { return &AuthService{ userRepo: userRepo, cfg: cfg, } } // 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, ErrInvalidCredentials } return nil, fmt.Errorf("failed to find user: %w", err) } // Check if user is active if !user.IsActive { return nil, ErrUserInactive } // Verify password if !user.CheckPassword(req.Password) { return nil, ErrInvalidCredentials } // Get or create auth token token, err := s.userRepo.GetOrCreateToken(user.ID) if err != nil { return nil, fmt.Errorf("failed to create token: %w", err) } // Update last login if err := s.userRepo.UpdateLastLogin(user.ID); err != nil { // Log error but don't fail the login fmt.Printf("Failed to update last login: %v\n", err) } return &responses.LoginResponse{ Token: token.Key, User: responses.NewUserResponse(user), }, nil } // Register creates a new user account 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, "", fmt.Errorf("failed to check username: %w", err) } if exists { return nil, "", ErrUsernameTaken } // Check if email exists exists, err = s.userRepo.ExistsByEmail(req.Email) if err != nil { return nil, "", fmt.Errorf("failed to check email: %w", err) } if exists { return nil, "", ErrEmailTaken } // 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, "", fmt.Errorf("failed to hash password: %w", err) } // Save user if err := s.userRepo.Create(user); err != nil { return nil, "", fmt.Errorf("failed to create user: %w", err) } // Create user profile if _, err := s.userRepo.GetOrCreateProfile(user.ID); err != nil { // Log error but don't fail registration fmt.Printf("Failed to create user profile: %v\n", err) } // Create auth token token, err := s.userRepo.GetOrCreateToken(user.ID) if err != nil { return nil, "", fmt.Errorf("failed to create token: %w", err) } // Generate confirmation code code := generateSixDigitCode() expiresAt := time.Now().UTC().Add(s.cfg.Security.ConfirmationExpiry) if _, err := s.userRepo.CreateConfirmationCode(user.ID, code, expiresAt); err != nil { // Log error but don't fail registration fmt.Printf("Failed to create confirmation code: %v\n", 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 } response := responses.NewCurrentUserResponse(user) return &response, 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, fmt.Errorf("failed to check email: %w", err) } if exists { return nil, ErrEmailTaken } 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, fmt.Errorf("failed to update user: %w", err) } // Reload with profile user, err = s.userRepo.FindByIDWithProfile(userID) if err != nil { return nil, err } response := responses.NewCurrentUserResponse(user) 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 fmt.Errorf("failed to get profile: %w", err) } // Check if already verified if profile.Verified { return ErrAlreadyVerified } // Check for test code in debug mode if s.cfg.Server.Debug && code == "123456" { if err := s.userRepo.SetProfileVerified(userID, true); err != nil { return fmt.Errorf("failed to verify profile: %w", 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 ErrInvalidCode } if errors.Is(err, repositories.ErrCodeExpired) { return ErrCodeExpired } return err } // Mark code as used if err := s.userRepo.MarkConfirmationCodeUsed(confirmCode.ID); err != nil { return fmt.Errorf("failed to mark code as used: %w", err) } // Set profile as verified if err := s.userRepo.SetProfileVerified(userID, true); err != nil { return fmt.Errorf("failed to verify profile: %w", 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 "", fmt.Errorf("failed to get profile: %w", err) } // Check if already verified if profile.Verified { return "", ErrAlreadyVerified } // Generate new code code := generateSixDigitCode() expiresAt := time.Now().UTC().Add(s.cfg.Security.ConfirmationExpiry) if _, err := s.userRepo.CreateConfirmationCode(userID, code, expiresAt); err != nil { return "", fmt.Errorf("failed to create confirmation code: %w", 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, fmt.Errorf("failed to check rate limit: %w", err) } if count >= int64(s.cfg.Security.MaxPasswordResetRate) { return "", nil, ErrRateLimitExceeded } // Generate code and reset token 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, fmt.Errorf("failed to hash code: %w", err) } if _, err := s.userRepo.CreatePasswordResetCode(user.ID, string(codeHash), resetToken, expiresAt); err != nil { return "", nil, fmt.Errorf("failed to create reset code: %w", 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 "", ErrInvalidCode } return "", err } // Check for test code in debug mode if s.cfg.Server.Debug && code == "123456" { return resetCode.ResetToken, nil } // Verify the code if !resetCode.CheckCode(code) { // Increment attempts s.userRepo.IncrementResetCodeAttempts(resetCode.ID) return "", ErrInvalidCode } // Check if code is still valid if !resetCode.IsValid() { if resetCode.Used { return "", ErrInvalidCode } if resetCode.Attempts >= resetCode.MaxAttempts { return "", ErrRateLimitExceeded } return "", ErrCodeExpired } _ = 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 ErrInvalidResetToken } return err } // Get the user user, err := s.userRepo.FindByID(resetCode.UserID) if err != nil { return fmt.Errorf("failed to find user: %w", err) } // Update password if err := user.SetPassword(newPassword); err != nil { return fmt.Errorf("failed to hash password: %w", err) } if err := s.userRepo.Update(user); err != nil { return fmt.Errorf("failed to update user: %w", err) } // Mark reset code as used if err := s.userRepo.MarkPasswordResetCodeUsed(resetCode.ID); err != nil { // Log error but don't fail fmt.Printf("Failed to mark reset code as used: %v\n", err) } // Invalidate all existing tokens for this user (security measure) if err := s.userRepo.DeleteTokenByUserID(user.ID); err != nil { // Log error but don't fail fmt.Printf("Failed to delete user tokens: %v\n", err) } return 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) }