- Update Go module from mycrib-api to casera-api - Update all import statements across 69 Go files - Update admin panel branding (title, sidebar, login form) - Update email templates (subjects, bodies, signatures) - Update PDF report generation branding - Update Docker container names and network - Update config defaults (database name, email sender, APNS topic) - Update README and documentation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
419 lines
11 KiB
Go
419 lines
11 KiB
Go
package services
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
|
|
"github.com/treytartt/casera-api/internal/config"
|
|
"github.com/treytartt/casera-api/internal/dto/requests"
|
|
"github.com/treytartt/casera-api/internal/dto/responses"
|
|
"github.com/treytartt/casera-api/internal/models"
|
|
"github.com/treytartt/casera-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)
|
|
}
|