mirror of
https://github.com/SamyRai/tercul-backend.git
synced 2025-12-27 05:11:34 +00:00
372 lines
10 KiB
Go
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
|
|
}
|