510 lines
15 KiB
Go
510 lines
15 KiB
Go
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"`
|
|
CartsIDs []uint `json:"carts_ids"`
|
|
CountryID uint `json:"country_id"`
|
|
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,
|
|
CartsIDs: []uint{},
|
|
CountryID: 1,
|
|
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
|
|
}
|