package auth import ( "context" "errors" "fmt" "strings" "tercul/internal/domain" "tercul/internal/platform/auth" "tercul/internal/platform/log" "time" "github.com/asaskevich/govalidator" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/trace" ) var ( ErrInvalidCredentials = errors.New("invalid credentials") ErrUserAlreadyExists = errors.New("user already exists") ErrInvalidInput = errors.New("invalid input") ) // 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 *domain.User `json:"user"` ExpiresAt time.Time `json:"expires_at"` } // AuthCommands contains the command handlers for authentication. type AuthCommands struct { userRepo domain.UserRepository jwtManager auth.JWTManagement tracer trace.Tracer } // NewAuthCommands creates a new AuthCommands handler. func NewAuthCommands(userRepo domain.UserRepository, jwtManager auth.JWTManagement) *AuthCommands { return &AuthCommands{ userRepo: userRepo, jwtManager: jwtManager, tracer: otel.Tracer("auth.commands"), } } // Login authenticates a user and returns a JWT token func (c *AuthCommands) Login(ctx context.Context, input LoginInput) (*AuthResponse, error) { ctx, span := c.tracer.Start(ctx, "Login") defer span.End() logger := log.FromContext(ctx).With("email", input.Email) if err := validateLoginInput(input); err != nil { logger.Warn("Login validation failed") return nil, fmt.Errorf("%w: %v", ErrInvalidInput, err) } email := strings.TrimSpace(input.Email) logger.Debug("Attempting to log in user") user, err := c.userRepo.FindByEmail(ctx, email) if err != nil { logger.Warn("Login failed - user not found") return nil, ErrInvalidCredentials } logger = logger.With("user_id", user.ID) if !user.Active { logger.Warn("Login failed - user inactive") return nil, ErrInvalidCredentials } if !user.CheckPassword(input.Password) { logger.Warn("Login failed - invalid password") return nil, ErrInvalidCredentials } token, err := c.jwtManager.GenerateToken(user) if err != nil { logger.Error(err, "Failed to generate JWT token") return nil, fmt.Errorf("failed to generate token: %w", err) } now := time.Now() user.LastLoginAt = &now if err := c.userRepo.Update(ctx, user); err != nil { logger.Error(err, "Failed to update last login time") // Do not fail the login if this update fails } logger.Info("User logged in successfully") return &AuthResponse{ Token: token, User: user, ExpiresAt: time.Now().Add(24 * time.Hour), // This should be configurable }, nil } // Logout invalidates a user's session. func (c *AuthCommands) Logout(ctx context.Context) error { // Implementation depends on how sessions are managed (e.g., blacklisting tokens). // For now, this is a placeholder. return nil } // RefreshToken generates a new token for an authenticated user. func (c *AuthCommands) RefreshToken(ctx context.Context) (*AuthResponse, error) { userID, ok := auth.GetUserIDFromContext(ctx) if !ok { return nil, domain.ErrUnauthorized } user, err := c.userRepo.GetByID(ctx, userID) if err != nil { return nil, domain.ErrUserNotFound } token, err := c.jwtManager.GenerateToken(user) if err != nil { return nil, fmt.Errorf("failed to generate token: %w", err) } return &AuthResponse{ Token: token, User: user, ExpiresAt: time.Now().Add(24 * time.Hour), }, nil } // ForgotPassword initiates the password reset process for a user. func (c *AuthCommands) ForgotPassword(ctx context.Context, email string) error { // In a real application, this would generate a reset token and send an email. // For now, this is a placeholder. return nil } // ResetPasswordInput represents the input for resetting a password. type ResetPasswordInput struct { Token string `json:"token"` NewPassword string `json:"new_password"` } // ResetPassword resets a user's password using a reset token. func (c *AuthCommands) ResetPassword(ctx context.Context, input ResetPasswordInput) error { // In a real application, this would validate the token, find the user, and update the password. // For now, this is a placeholder. return nil } // VerifyEmail verifies a user's email address using a verification token. func (c *AuthCommands) VerifyEmail(ctx context.Context, token string) error { // In a real application, this would validate the token and mark the user's email as verified. // For now, this is a placeholder. return nil } // ResendVerificationEmail resends the email verification link to a user. func (c *AuthCommands) ResendVerificationEmail(ctx context.Context, email string) error { // In a real application, this would generate a new verification token and send it. // For now, this is a placeholder. return nil } // ChangePasswordInput represents the input for changing a password. type ChangePasswordInput struct { UserID uint CurrentPassword string NewPassword string } // ChangePassword allows an authenticated user to change their password. func (c *AuthCommands) ChangePassword(ctx context.Context, input ChangePasswordInput) error { user, err := c.userRepo.GetByID(ctx, input.UserID) if err != nil { return domain.ErrUserNotFound } if !user.CheckPassword(input.CurrentPassword) { return ErrInvalidCredentials } if err := user.SetPassword(input.NewPassword); err != nil { return err } return c.userRepo.Update(ctx, user) } // Register creates a new user account func (c *AuthCommands) Register(ctx context.Context, input RegisterInput) (*AuthResponse, error) { ctx, span := c.tracer.Start(ctx, "Register") defer span.End() logger := log.FromContext(ctx).With("email", input.Email).With("username", input.Username) if err := validateRegisterInput(input); err != nil { logger.Warn("Registration validation failed") return nil, fmt.Errorf("%w: %v", ErrInvalidInput, err) } email := strings.TrimSpace(input.Email) username := strings.TrimSpace(input.Username) logger.Debug("Attempting to register new user") existingUser, _ := c.userRepo.FindByEmail(ctx, email) if existingUser != nil { logger.Warn("Registration failed - email already exists") return nil, ErrUserAlreadyExists } existingUser, _ = c.userRepo.FindByUsername(ctx, username) if existingUser != nil { logger.Warn("Registration failed - username already exists") return nil, ErrUserAlreadyExists } user := &domain.User{ Username: username, Email: email, Password: input.Password, FirstName: strings.TrimSpace(input.FirstName), LastName: strings.TrimSpace(input.LastName), DisplayName: fmt.Sprintf("%s %s", strings.TrimSpace(input.FirstName), strings.TrimSpace(input.LastName)), Role: domain.UserRoleReader, Active: true, Verified: false, // Should be false until email verification } if err := c.userRepo.Create(ctx, user); err != nil { logger.Error(err, "Failed to create user") return nil, fmt.Errorf("failed to create user: %w", err) } logger = logger.With("user_id", user.ID) token, err := c.jwtManager.GenerateToken(user) if err != nil { logger.Error(err, "Failed to generate JWT token for new user") return nil, fmt.Errorf("failed to generate token: %w", err) } logger.Info("User registered successfully") return &AuthResponse{ Token: token, User: user, ExpiresAt: time.Now().Add(24 * time.Hour), // This should be configurable }, nil } func validateLoginInput(input LoginInput) error { if input.Email == "" { return errors.New("email is required") } if !govalidator.IsEmail(strings.TrimSpace(input.Email)) { return errors.New("invalid email format") } if len(input.Password) < 6 { return errors.New("password must be at least 6 characters") } return nil } func validateRegisterInput(input RegisterInput) error { if !govalidator.IsEmail(strings.TrimSpace(input.Email)) { return errors.New("invalid email format") } if len(input.Password) < 6 { return errors.New("password must be at least 6 characters") } 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") } return nil }