initial commit. Cloned timetracker repository
This commit is contained in:
509
app/service/authService/auth.go
Normal file
509
app/service/authService/auth.go
Normal file
@@ -0,0 +1,509 @@
|
||||
package authService
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.ma-al.com/goc_marek/timetracker/app/config"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/db"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/model"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/service/emailService"
|
||||
constdata "git.ma-al.com/goc_marek/timetracker/app/utils/const_data"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/view"
|
||||
|
||||
"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, "", view.ErrInvalidCredentials
|
||||
}
|
||||
return nil, "", fmt.Errorf("database error: %w", err)
|
||||
}
|
||||
// Check if user is active
|
||||
if !user.IsActive {
|
||||
return nil, "", view.ErrUserInactive
|
||||
}
|
||||
|
||||
// Check if email is verified
|
||||
if !user.EmailVerified {
|
||||
return nil, "", view.ErrEmailNotVerified
|
||||
}
|
||||
|
||||
// Verify password
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil {
|
||||
return nil, "", view.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 view.ErrEmailExists
|
||||
}
|
||||
|
||||
// Validate passwords match
|
||||
if req.Password != req.ConfirmPassword {
|
||||
return view.ErrPasswordsDoNotMatch
|
||||
}
|
||||
|
||||
// Validate password strength
|
||||
if err := validatePassword(req.Password); err != nil {
|
||||
return view.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, "", view.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, "", view.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 view.ErrInvalidResetToken
|
||||
}
|
||||
return fmt.Errorf("database error: %w", err)
|
||||
}
|
||||
|
||||
// Check if token is expired
|
||||
if user.PasswordResetExpires == nil || user.PasswordResetExpires.Before(time.Now()) {
|
||||
return view.ErrResetTokenExpired
|
||||
}
|
||||
|
||||
// Validate new password
|
||||
if err := validatePassword(newPassword); err != nil {
|
||||
return view.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, view.ErrTokenExpired
|
||||
}
|
||||
return nil, view.ErrInvalidToken
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(*JWTClaims)
|
||||
if !ok || !token.Valid {
|
||||
return nil, view.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, "", view.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, "", view.ErrTokenExpired
|
||||
}
|
||||
|
||||
// Get user from database
|
||||
var user model.Customer
|
||||
if err := s.db.First(&user, rt.CustomerID).Error; err != nil {
|
||||
return nil, "", view.ErrUserNotFound
|
||||
}
|
||||
|
||||
if !user.IsActive {
|
||||
return nil, "", view.ErrUserInactive
|
||||
}
|
||||
|
||||
if !user.EmailVerified {
|
||||
return nil, "", view.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, view.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, view.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
|
||||
}
|
||||
Reference in New Issue
Block a user