Files
Novault-backend/internal/service/auth_service.go
2026-01-25 21:59:00 +08:00

293 lines
7.6 KiB
Go

// Package service provides business logic for the application
package service
import (
"errors"
"regexp"
"time"
"accounting-app/internal/config"
"accounting-app/internal/models"
"accounting-app/internal/repository"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
)
// Auth service errors
var (
ErrInvalidCredentials = errors.New("invalid email or password")
ErrInvalidEmail = errors.New("invalid email format")
ErrWeakPassword = errors.New("password must be at least 8 characters")
ErrUserNotActive = errors.New("user account is not active")
ErrInvalidToken = errors.New("invalid token")
ErrTokenExpired = errors.New("token has expired")
ErrUserExists = errors.New("user with this email already exists")
)
// TokenClaims represents JWT token claims
// Feature: api-interface-optimization
// Validates: Requirements 12.3
type TokenClaims struct {
UserID uint `json:"user_id"`
Email string `json:"email"`
jwt.RegisteredClaims
}
// TokenPair represents access and refresh tokens
type TokenPair struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int64 `json:"expires_in"`
}
// RegisterInput represents user registration input
type RegisterInput struct {
Email string `json:"email" binding:"required"`
Password string `json:"password" binding:"required"`
Username string `json:"username" binding:"required"`
}
// LoginInput represents user login input
type LoginInput struct {
Email string `json:"email" binding:"required"`
Password string `json:"password" binding:"required"`
}
// AuthService handles authentication operations
// Feature: api-interface-optimization
// Validates: Requirements 12.1, 12.2, 12.3, 12.4, 12.5
type AuthService struct {
userRepo *repository.UserRepository
cfg *config.Config
emailRegex *regexp.Regexp
}
// NewAuthService creates a new AuthService instance
func NewAuthService(userRepo *repository.UserRepository, cfg *config.Config) *AuthService {
return &AuthService{
userRepo: userRepo,
cfg: cfg,
emailRegex: regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`),
}
}
// Register creates a new user account
// Feature: api-interface-optimization
// Validates: Requirements 12.1, 12.2, 12.5
func (s *AuthService) Register(input RegisterInput) (*models.User, *TokenPair, error) {
// Validate email format
if !s.ValidateEmail(input.Email) {
return nil, nil, ErrInvalidEmail
}
// Validate password strength
if len(input.Password) < 8 {
return nil, nil, ErrWeakPassword
}
// Check if email already exists
exists, err := s.userRepo.EmailExists(input.Email)
if err != nil {
return nil, nil, err
}
if exists {
return nil, nil, ErrUserExists
}
// Hash password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost)
if err != nil {
return nil, nil, err
}
// Create user
user := &models.User{
Email: input.Email,
PasswordHash: string(hashedPassword),
Username: input.Username,
IsActive: true,
}
if err := s.userRepo.Create(user); err != nil {
if errors.Is(err, repository.ErrUserEmailExists) {
return nil, nil, ErrUserExists
}
return nil, nil, err
}
// Generate tokens
tokens, err := s.generateTokenPair(user)
if err != nil {
return nil, nil, err
}
return user, tokens, nil
}
// Login authenticates a user and returns tokens
// Feature: api-interface-optimization
// Validates: Requirements 12.2
func (s *AuthService) Login(input LoginInput) (*models.User, *TokenPair, error) {
// Get user by email
user, err := s.userRepo.GetByEmail(input.Email)
if err != nil {
if errors.Is(err, repository.ErrUserNotFound) {
return nil, nil, ErrInvalidCredentials
}
return nil, nil, err
}
// Check if user is active
if !user.IsActive {
return nil, nil, ErrUserNotActive
}
// Verify password
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(input.Password)); err != nil {
return nil, nil, ErrInvalidCredentials
}
// Generate tokens
tokens, err := s.generateTokenPair(user)
if err != nil {
return nil, nil, err
}
return user, tokens, nil
}
// RefreshToken generates new tokens using a refresh token
// Feature: api-interface-optimization
// Validates: Requirements 12.4
func (s *AuthService) RefreshToken(refreshToken string) (*TokenPair, error) {
// Parse and validate refresh token
claims, err := s.ValidateToken(refreshToken)
if err != nil {
return nil, err
}
// Get user
user, err := s.userRepo.GetByID(claims.UserID)
if err != nil {
return nil, err
}
// Check if user is active
if !user.IsActive {
return nil, ErrUserNotActive
}
// Generate new tokens
return s.generateTokenPair(user)
}
// ValidateToken validates a JWT token and returns claims
// Feature: api-interface-optimization
// Validates: Requirements 12.3
func (s *AuthService) ValidateToken(tokenString string) (*TokenClaims, error) {
token, err := jwt.ParseWithClaims(tokenString, &TokenClaims{}, func(token *jwt.Token) (interface{}, error) {
return []byte(s.cfg.JWTSecret), nil
})
if err != nil {
if errors.Is(err, jwt.ErrTokenExpired) {
return nil, ErrTokenExpired
}
return nil, ErrInvalidToken
}
claims, ok := token.Claims.(*TokenClaims)
if !ok || !token.Valid {
return nil, ErrInvalidToken
}
return claims, nil
}
// ValidateEmail validates email format
// Feature: api-interface-optimization
// Validates: Requirements 12.1 (Property 10)
func (s *AuthService) ValidateEmail(email string) bool {
return s.emailRegex.MatchString(email)
}
// GetUserByID retrieves a user by ID
func (s *AuthService) GetUserByID(id uint) (*models.User, error) {
return s.userRepo.GetByID(id)
}
// generateTokenPair generates access and refresh tokens
func (s *AuthService) generateTokenPair(user *models.User) (*TokenPair, error) {
now := time.Now()
// Generate access token
accessClaims := &TokenClaims{
UserID: user.ID,
Email: user.Email,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(now.Add(s.cfg.JWTAccessExpiry)),
IssuedAt: jwt.NewNumericDate(now),
NotBefore: jwt.NewNumericDate(now),
},
}
accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims)
accessTokenString, err := accessToken.SignedString([]byte(s.cfg.JWTSecret))
if err != nil {
return nil, err
}
// Generate refresh token
refreshClaims := &TokenClaims{
UserID: user.ID,
Email: user.Email,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(now.Add(s.cfg.JWTRefreshExpiry)),
IssuedAt: jwt.NewNumericDate(now),
NotBefore: jwt.NewNumericDate(now),
},
}
refreshToken := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims)
refreshTokenString, err := refreshToken.SignedString([]byte(s.cfg.JWTSecret))
if err != nil {
return nil, err
}
return &TokenPair{
AccessToken: accessTokenString,
RefreshToken: refreshTokenString,
ExpiresIn: int64(s.cfg.JWTAccessExpiry.Seconds()),
}, nil
}
// UpdatePassword updates a user's password
func (s *AuthService) UpdatePassword(userID uint, oldPassword, newPassword string) error {
user, err := s.userRepo.GetByID(userID)
if err != nil {
return err
}
// Verify old password
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(oldPassword)); err != nil {
return ErrInvalidCredentials
}
// Validate new password
if len(newPassword) < 8 {
return ErrWeakPassword
}
// Hash new password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
return err
}
user.PasswordHash = string(hashedPassword)
return s.userRepo.Update(user)
}