package authService import ( "crypto/rand" "crypto/sha256" "encoding/hex" "errors" "fmt" "time" "git.ma-al.com/goc_daniel/b2b/app/config" "git.ma-al.com/goc_daniel/b2b/app/db" "git.ma-al.com/goc_daniel/b2b/app/model" "git.ma-al.com/goc_daniel/b2b/app/service/emailService" constdata "git.ma-al.com/goc_daniel/b2b/app/utils/const_data" "git.ma-al.com/goc_daniel/b2b/app/utils/responseErrors" "github.com/dlclark/regexp2" "github.com/golang-jwt/jwt/v5" "golang.org/x/crypto/bcrypt" "gorm.io/gorm" ) // JWTClaims represents the JWT claims type JWTClaims struct { UserID uint `json:"user_id"` Email string `json:"email"` Username string `json:"username"` Role model.CustomerRole `json:"customer_role"` FirstName string `json:"first_name"` LastName string `json:"last_name"` jwt.RegisteredClaims } // AuthService handles authentication operations type AuthService struct { db *gorm.DB config *config.AuthConfig email *emailService.EmailService } // NewAuthService creates a new AuthService instance func NewAuthService() *AuthService { svc := &AuthService{ db: db.Get(), config: &config.Get().Auth, email: emailService.NewEmailService(), } // Auto-migrate the refresh_tokens table if svc.db != nil { _ = svc.db.AutoMigrate(&model.RefreshToken{}) } return svc } // Login authenticates a user with email and password func (s *AuthService) Login(req *model.LoginRequest) (*model.AuthResponse, string, error) { var user model.Customer // Find user by email if err := s.db.Where("email = ?", req.Email).First(&user).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, "", responseErrors.ErrInvalidCredentials } return nil, "", fmt.Errorf("database error: %w", err) } // Check if user is active if !user.IsActive { return nil, "", responseErrors.ErrUserInactive } // Check if email is verified if !user.EmailVerified { return nil, "", responseErrors.ErrEmailNotVerified } // Verify password if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil { return nil, "", responseErrors.ErrInvalidCredentials } // Update last login time now := time.Now() user.LastLoginAt = &now s.db.Save(&user) // Generate access token (JWT) accessToken, err := s.generateAccessToken(&user) if err != nil { return nil, "", fmt.Errorf("failed to generate access token: %w", err) } // Generate opaque refresh token and store in DB rawRefreshToken, err := s.createRefreshToken(user.ID) if err != nil { return nil, "", fmt.Errorf("failed to create refresh token: %w", err) } return &model.AuthResponse{ AccessToken: accessToken, TokenType: "Bearer", ExpiresIn: s.config.JWTExpiration, User: user.ToSession(), }, rawRefreshToken, nil } // Register initiates user registration func (s *AuthService) Register(req *model.RegisterRequest) error { // Check if email already exists var existingUser model.Customer if err := s.db.Where("email = ?", req.Email).First(&existingUser).Error; err == nil { return responseErrors.ErrEmailExists } // Validate passwords match if req.Password != req.ConfirmPassword { return responseErrors.ErrPasswordsDoNotMatch } // Validate password strength if err := validatePassword(req.Password); err != nil { return responseErrors.ErrInvalidPassword } // Hash password hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) if err != nil { return fmt.Errorf("failed to hash password: %w", err) } // Generate verification token token, err := s.generateVerificationToken() if err != nil { return fmt.Errorf("failed to generate verification token: %w", err) } // Set expiration (24 hours from now) expiresAt := time.Now().Add(24 * time.Hour) // Create user with verification token user := model.Customer{ Email: req.Email, Password: string(hashedPassword), FirstName: req.FirstName, LastName: req.LastName, Role: model.RoleUser, Provider: model.ProviderLocal, IsActive: false, EmailVerified: false, EmailVerificationToken: token, EmailVerificationExpires: &expiresAt, Lang: req.Lang, } if err := s.db.Create(&user).Error; err != nil { return fmt.Errorf("failed to create user: %w", err) } // Send verification email baseURL := config.Get().App.BaseURL lang := req.Lang if lang == "" { lang = "en" // Default to English } if err := s.email.SendVerificationEmail(user.Email, user.EmailVerificationToken, baseURL, lang); err != nil { // Log error but don't fail registration - user can request resend _ = err } return nil } // CompleteRegistration completes the registration with password verification after email verification func (s *AuthService) CompleteRegistration(req *model.CompleteRegistrationRequest) (*model.AuthResponse, string, error) { // Find user by verification token var user model.Customer if err := s.db.Where("email_verification_token = ?", req.Token).First(&user).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, "", responseErrors.ErrInvalidVerificationToken } return nil, "", fmt.Errorf("database error: %w", err) } // Check if token is expired if user.EmailVerificationExpires != nil && user.EmailVerificationExpires.Before(time.Now()) { return nil, "", responseErrors.ErrVerificationTokenExpired } // Update user - activate account and mark email as verified user.IsActive = true user.EmailVerified = true user.EmailVerificationToken = "" user.EmailVerificationExpires = nil if err := s.db.Save(&user).Error; err != nil { return nil, "", fmt.Errorf("failed to update user: %w", err) } // Send admin notification about new user registration baseURL := config.Get().App.BaseURL if err := s.email.SendNewUserAdminNotification(user.Email, user.FullName(), baseURL); err != nil { _ = err } // Generate access token accessToken, err := s.generateAccessToken(&user) if err != nil { return nil, "", fmt.Errorf("failed to generate access token: %w", err) } // Generate opaque refresh token and store in DB rawRefreshToken, err := s.createRefreshToken(user.ID) if err != nil { return nil, "", fmt.Errorf("failed to create refresh token: %w", err) } return &model.AuthResponse{ AccessToken: accessToken, TokenType: "Bearer", ExpiresIn: s.config.JWTExpiration, User: user.ToSession(), }, rawRefreshToken, nil } // RequestPasswordReset initiates a password reset request func (s *AuthService) RequestPasswordReset(emailAddr string) error { // Find user by email var user model.Customer if err := s.db.Where("email = ?", emailAddr).First(&user).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { // Don't reveal if email exists or not for security return nil } return fmt.Errorf("database error: %w", err) } // Check if user is active, email verified, and is a local user if !user.IsActive || !user.EmailVerified || user.Provider != model.ProviderLocal { // Don't reveal account status for security return nil } // Check rate limit: don't allow password reset requests more than once per hour if user.LastPasswordResetRequest != nil && time.Since(*user.LastPasswordResetRequest) < time.Hour { // Rate limit hit, silently fail for security return nil } // Generate reset token token, err := s.generateVerificationToken() if err != nil { return fmt.Errorf("failed to generate reset token: %w", err) } // Set expiration (1 hour from now) expiresAt := time.Now().Add(1 * time.Hour) // Update user with reset token and last request time now := time.Now() user.PasswordResetToken = token user.PasswordResetExpires = &expiresAt user.LastPasswordResetRequest = &now if err := s.db.Save(&user).Error; err != nil { return fmt.Errorf("failed to save reset token: %w", err) } // Send password reset email baseURL := config.Get().App.BaseURL lang := "en" if user.Lang != "" { lang = user.Lang } if err := s.email.SendPasswordResetEmail(user.Email, user.PasswordResetToken, baseURL, lang); err != nil { _ = err } return nil } // ResetPassword completes the password reset with a new password func (s *AuthService) ResetPassword(token, newPassword string) error { // Find user by reset token var user model.Customer if err := s.db.Where("password_reset_token = ?", token).First(&user).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return responseErrors.ErrInvalidResetToken } return fmt.Errorf("database error: %w", err) } // Check if token is expired if user.PasswordResetExpires == nil || user.PasswordResetExpires.Before(time.Now()) { return responseErrors.ErrResetTokenExpired } // Validate new password if err := validatePassword(newPassword); err != nil { return responseErrors.ErrInvalidPassword } // Hash new password hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) if err != nil { return fmt.Errorf("failed to hash password: %w", err) } // Update user password and clear reset token user.Password = string(hashedPassword) user.PasswordResetToken = "" user.PasswordResetExpires = nil if err := s.db.Save(&user).Error; err != nil { return fmt.Errorf("failed to update password: %w", err) } // Revoke all existing refresh tokens for this user (security: password changed) s.db.Where("customer_id = ?", user.ID).Delete(&model.RefreshToken{}) return nil } // ValidateToken validates a JWT access token and returns the claims func (s *AuthService) ValidateToken(tokenString string) (*JWTClaims, error) { token, err := jwt.ParseWithClaims(tokenString, &JWTClaims{}, func(token *jwt.Token) (interface{}, error) { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) } return []byte(s.config.JWTSecret), nil }) if err != nil { if errors.Is(err, jwt.ErrTokenExpired) { return nil, responseErrors.ErrTokenExpired } return nil, responseErrors.ErrInvalidToken } claims, ok := token.Claims.(*JWTClaims) if !ok || !token.Valid { return nil, responseErrors.ErrInvalidToken } return claims, nil } // RefreshToken validates an opaque refresh token, rotates it, and returns a new access token. // Returns: AuthResponse, new raw refresh token, error func (s *AuthService) RefreshToken(rawToken string) (*model.AuthResponse, string, error) { tokenHash := hashToken(rawToken) // Find the refresh token record var rt model.RefreshToken if err := s.db.Where("token_hash = ?", tokenHash).First(&rt).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, "", responseErrors.ErrInvalidToken } return nil, "", fmt.Errorf("database error: %w", err) } // Check expiry if rt.ExpiresAt.Before(time.Now()) { // Clean up expired token s.db.Delete(&rt) return nil, "", responseErrors.ErrTokenExpired } // Get user from database var user model.Customer if err := s.db.First(&user, rt.CustomerID).Error; err != nil { return nil, "", responseErrors.ErrUserNotFound } if !user.IsActive { return nil, "", responseErrors.ErrUserInactive } if !user.EmailVerified { return nil, "", responseErrors.ErrEmailNotVerified } // Delete the old refresh token (rotation: one-time use) s.db.Delete(&rt) // Generate new access token accessToken, err := s.generateAccessToken(&user) if err != nil { return nil, "", fmt.Errorf("failed to generate access token: %w", err) } // Issue a new opaque refresh token newRawRefreshToken, err := s.createRefreshToken(user.ID) if err != nil { return nil, "", fmt.Errorf("failed to create refresh token: %w", err) } return &model.AuthResponse{ AccessToken: accessToken, TokenType: "Bearer", ExpiresIn: s.config.JWTExpiration, User: user.ToSession(), }, newRawRefreshToken, nil } // RevokeRefreshToken deletes a specific refresh token from the DB (used on logout) func (s *AuthService) RevokeRefreshToken(rawToken string) { if rawToken == "" { return } tokenHash := hashToken(rawToken) s.db.Where("token_hash = ?", tokenHash).Delete(&model.RefreshToken{}) } // RevokeAllRefreshTokens deletes all refresh tokens for a user (used on logout-all-devices) func (s *AuthService) RevokeAllRefreshTokens(userID uint) { s.db.Where("customer_id = ?", userID).Delete(&model.RefreshToken{}) } // GetUserByID retrieves a user by ID func (s *AuthService) GetUserByID(userID uint) (*model.Customer, error) { var user model.Customer if err := s.db.First(&user, userID).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, responseErrors.ErrUserNotFound } return nil, fmt.Errorf("database error: %w", err) } return &user, nil } // GetUserByEmail retrieves a user by email func (s *AuthService) GetUserByEmail(email string) (*model.Customer, error) { var user model.Customer if err := s.db.Where("email = ?", email).First(&user).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, responseErrors.ErrUserNotFound } return nil, fmt.Errorf("database error: %w", err) } return &user, nil } // createRefreshToken generates a random opaque token, stores its hash in the DB, and returns the raw token. func (s *AuthService) createRefreshToken(userID uint) (string, error) { // Generate 32 random bytes → 64-char hex string b := make([]byte, 32) if _, err := rand.Read(b); err != nil { return "", err } rawToken := hex.EncodeToString(b) tokenHash := hashToken(rawToken) expiresAt := time.Now().Add(time.Duration(s.config.RefreshExpiration) * time.Second) rt := model.RefreshToken{ CustomerID: userID, TokenHash: tokenHash, ExpiresAt: expiresAt, } if err := s.db.Create(&rt).Error; err != nil { return "", fmt.Errorf("failed to store refresh token: %w", err) } return rawToken, nil } // hashToken returns the SHA-256 hex digest of a raw token string. func hashToken(raw string) string { h := sha256.Sum256([]byte(raw)) return hex.EncodeToString(h[:]) } // generateAccessToken generates a short-lived JWT access token func (s *AuthService) generateAccessToken(user *model.Customer) (string, error) { claims := JWTClaims{ UserID: user.ID, Email: user.Email, Username: user.Email, Role: user.Role, FirstName: user.FirstName, LastName: user.LastName, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(s.config.JWTExpiration) * time.Second)), IssuedAt: jwt.NewNumericDate(time.Now()), }, } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) return token.SignedString([]byte(s.config.JWTSecret)) } // generateVerificationToken generates a random verification token func (s *AuthService) generateVerificationToken() (string, error) { bytes := make([]byte, 32) if _, err := rand.Read(bytes); err != nil { return "", err } return hex.EncodeToString(bytes), nil } // validatePassword validates password strength using RE2-compatible regexes. func validatePassword(password string) error { var passregex2 = regexp2.MustCompile(constdata.PASSWORD_VALIDATION_REGEX, regexp2.None) if ok, _ := passregex2.MatchString(password); !ok { return errors.New("password must be at least 10 characters long and contain at least one lowercase letter, one uppercase letter, and one digit") } return nil }