initial commit. Cloned timetracker repository

This commit is contained in:
Daniel Goc
2026-03-10 09:02:57 +01:00
commit f2952bcef0
189 changed files with 21334 additions and 0 deletions

View 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
}