293 lines
7.6 KiB
Go
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)
|
|
}
|