tercul-backend/services/auth_service.go
Damir Mukimov fa336cacf3
wip
2025-09-01 00:43:59 +02:00

372 lines
10 KiB
Go

package services
import (
"context"
"errors"
"fmt"
"strings"
models2 "tercul/internal/models"
"tercul/internal/repositories"
"time"
"github.com/asaskevich/govalidator"
"tercul/internal/platform/auth"
"tercul/internal/platform/log"
)
var (
ErrInvalidCredentials = errors.New("invalid credentials")
ErrUserNotFound = errors.New("user not found")
ErrUserAlreadyExists = errors.New("user already exists")
ErrInvalidInput = errors.New("invalid input")
ErrContextRequired = errors.New("context is required")
)
// AuthService interface defines authentication operations
type AuthService interface {
Login(ctx context.Context, input LoginInput) (*AuthResponse, error)
Register(ctx context.Context, input RegisterInput) (*AuthResponse, error)
GetUserFromContext(ctx context.Context) (*models2.User, error)
ValidateToken(ctx context.Context, tokenString string) (*models2.User, error)
}
// authService handles authentication operations
type authService struct {
userRepo repositories.UserRepository
jwtManager *auth.JWTManager
}
// NewAuthService creates a new authentication service
func NewAuthService(userRepo repositories.UserRepository) AuthService {
return &authService{
userRepo: userRepo,
jwtManager: auth.NewJWTManager(),
}
}
// LoginInput represents login request data
type LoginInput struct {
Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required,min=6"`
}
// RegisterInput represents registration request data
type RegisterInput struct {
Username string `json:"username" validate:"required,min=3,max=50"`
Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required,min=6"`
FirstName string `json:"first_name" validate:"required,min=1,max=50"`
LastName string `json:"last_name" validate:"required,min=1,max=50"`
}
// AuthResponse represents authentication response
type AuthResponse struct {
Token string `json:"token"`
User *models2.User `json:"user"`
ExpiresAt time.Time `json:"expires_at"`
}
// Login authenticates a user and returns a JWT token
func (s *authService) Login(ctx context.Context, input LoginInput) (*AuthResponse, error) {
// Validate context
if ctx == nil {
return nil, ErrContextRequired
}
// Validate input
if err := s.validateLoginInput(input); err != nil {
log.LogWarn("Login failed - invalid input",
log.F("email", input.Email),
log.F("error", err))
return nil, fmt.Errorf("%w: %v", ErrInvalidInput, err)
}
// Sanitize email
email := strings.TrimSpace(input.Email)
// Find user by email
user, err := s.userRepo.FindByEmail(ctx, email)
if err != nil {
log.LogWarn("Login failed - user not found",
log.F("email", email))
return nil, ErrInvalidCredentials
}
// Check if user is active
if !user.Active {
log.LogWarn("Login failed - user inactive",
log.F("user_id", user.ID),
log.F("email", email))
return nil, ErrInvalidCredentials
}
// Verify password
if !user.CheckPassword(input.Password) {
log.LogWarn("Login failed - invalid password",
log.F("user_id", user.ID),
log.F("email", email))
return nil, ErrInvalidCredentials
}
// Generate JWT token
token, err := s.jwtManager.GenerateToken(user)
if err != nil {
log.LogError("Failed to generate JWT token",
log.F("user_id", user.ID),
log.F("email", email),
log.F("error", err))
return nil, fmt.Errorf("failed to generate token: %w", err)
}
// Update last login time
now := time.Now()
user.LastLoginAt = &now
if err := s.userRepo.Update(ctx, user); err != nil {
log.LogWarn("Failed to update last login time",
log.F("user_id", user.ID),
log.F("error", err))
// Don't fail the login if we can't update the timestamp
}
log.LogInfo("User logged in successfully",
log.F("user_id", user.ID),
log.F("email", email),
log.F("role", user.Role))
return &AuthResponse{
Token: token,
User: user,
ExpiresAt: time.Now().Add(24 * time.Hour), // This should match JWT expiration
}, nil
}
// Register creates a new user account
func (s *authService) Register(ctx context.Context, input RegisterInput) (*AuthResponse, error) {
// Validate context
if ctx == nil {
return nil, ErrContextRequired
}
// Validate input
if err := s.validateRegisterInput(input); err != nil {
log.LogWarn("Registration failed - invalid input",
log.F("email", input.Email),
log.F("username", input.Username),
log.F("error", err))
return nil, fmt.Errorf("%w: %v", ErrInvalidInput, err)
}
// Sanitize inputs
email := strings.TrimSpace(input.Email)
username := strings.TrimSpace(input.Username)
firstName := strings.TrimSpace(input.FirstName)
lastName := strings.TrimSpace(input.LastName)
// Check if user already exists by email
existingUser, err := s.userRepo.FindByEmail(ctx, email)
if err == nil && existingUser != nil {
log.LogWarn("Registration failed - email already exists",
log.F("email", email))
return nil, ErrUserAlreadyExists
}
// Check if user already exists by username
existingUser, err = s.userRepo.FindByUsername(ctx, username)
if err == nil && existingUser != nil {
log.LogWarn("Registration failed - username already exists",
log.F("username", username))
return nil, ErrUserAlreadyExists
}
// Create new user
user := &models2.User{
Username: username,
Email: email,
Password: input.Password, // Will be hashed by BeforeSave hook
FirstName: firstName,
LastName: lastName,
DisplayName: firstName + " " + lastName,
Role: models2.UserRoleReader, // Default role
Active: true,
Verified: false, // Email verification required
}
// Save user to database
if err := s.userRepo.Create(ctx, user); err != nil {
log.LogError("Failed to create user",
log.F("email", email),
log.F("username", username),
log.F("error", err))
return nil, fmt.Errorf("failed to create user: %w", err)
}
// Generate JWT token
token, err := s.jwtManager.GenerateToken(user)
if err != nil {
log.LogError("Failed to generate JWT token for new user",
log.F("user_id", user.ID),
log.F("email", email),
log.F("error", err))
return nil, fmt.Errorf("failed to generate token: %w", err)
}
log.LogInfo("User registered successfully",
log.F("user_id", user.ID),
log.F("email", email),
log.F("username", username),
log.F("role", user.Role))
return &AuthResponse{
Token: token,
User: user,
ExpiresAt: time.Now().Add(24 * time.Hour),
}, nil
}
// GetUserFromContext extracts user from context
func (s *authService) GetUserFromContext(ctx context.Context) (*models2.User, error) {
// Validate context
if ctx == nil {
return nil, ErrContextRequired
}
claims, err := auth.RequireAuth(ctx)
if err != nil {
log.LogWarn("Failed to get user from context - authentication required",
log.F("error", err))
return nil, err
}
user, err := s.userRepo.GetByID(ctx, claims.UserID)
if err != nil {
log.LogWarn("Failed to get user from context - user not found",
log.F("user_id", claims.UserID),
log.F("error", err))
return nil, ErrUserNotFound
}
// Check if user is still active
if !user.Active {
log.LogWarn("Failed to get user from context - user inactive",
log.F("user_id", user.ID))
return nil, ErrInvalidCredentials
}
return user, nil
}
// ValidateToken validates a JWT token and returns the user
func (s *authService) ValidateToken(ctx context.Context, tokenString string) (*models2.User, error) {
// Validate context
if ctx == nil {
return nil, ErrContextRequired
}
// Validate token string
if tokenString == "" {
log.LogWarn("Token validation failed - empty token")
return nil, auth.ErrMissingToken
}
claims, err := s.jwtManager.ValidateToken(tokenString)
if err != nil {
log.LogWarn("Token validation failed - invalid token",
log.F("error", err))
return nil, err
}
user, err := s.userRepo.GetByID(ctx, claims.UserID)
if err != nil {
log.LogWarn("Token validation failed - user not found",
log.F("user_id", claims.UserID),
log.F("error", err))
return nil, ErrUserNotFound
}
if !user.Active {
log.LogWarn("Token validation failed - user inactive",
log.F("user_id", user.ID))
return nil, ErrInvalidCredentials
}
log.LogInfo("Token validated successfully",
log.F("user_id", user.ID),
log.F("role", user.Role))
return user, nil
}
// validateLoginInput validates login input
func (s *authService) validateLoginInput(input LoginInput) error {
if input.Email == "" {
return errors.New("email is required")
}
if input.Password == "" {
return errors.New("password is required")
}
// Sanitize and validate email
email := strings.TrimSpace(input.Email)
if !govalidator.IsEmail(email) {
return errors.New("invalid email format")
}
// Validate password length
if len(input.Password) < 6 {
return errors.New("password must be at least 6 characters")
}
return nil
}
// validateRegisterInput validates registration input
func (s *authService) validateRegisterInput(input RegisterInput) error {
if input.Username == "" {
return errors.New("username is required")
}
if input.Email == "" {
return errors.New("email is required")
}
if input.Password == "" {
return errors.New("password is required")
}
if input.FirstName == "" {
return errors.New("first name is required")
}
if input.LastName == "" {
return errors.New("last name is required")
}
// Sanitize and validate username
username := strings.TrimSpace(input.Username)
if len(username) < 3 || len(username) > 50 {
return errors.New("username must be between 3 and 50 characters")
}
if !govalidator.Matches(username, `^[a-zA-Z0-9_-]+$`) {
return errors.New("username can only contain letters, numbers, underscores, and hyphens")
}
// Sanitize and validate email
email := strings.TrimSpace(input.Email)
if !govalidator.IsEmail(email) {
return errors.New("invalid email format")
}
// Validate password strength
if len(input.Password) < 6 {
return errors.New("password must be at least 6 characters")
}
// Sanitize and validate names
firstName := strings.TrimSpace(input.FirstName)
lastName := strings.TrimSpace(input.LastName)
if len(firstName) < 1 || len(firstName) > 50 {
return errors.New("first name must be between 1 and 50 characters")
}
if len(lastName) < 1 || len(lastName) > 50 {
return errors.New("last name must be between 1 and 50 characters")
}
return nil
}