From 115a10ed6639740485f26ece73aecb8816790a80 Mon Sep 17 00:00:00 2001 From: nutcas3 Date: Thu, 23 Apr 2026 01:55:21 +0300 Subject: [PATCH] feat: Add authentication service with JWT token management, session handling, and user operations - Add JWTService struct with db/secretKey fields and NewJWTService constructor - Add Claims struct with UserID/Username/Role fields and jwt.RegisteredClaims embedding - Add GenerateToken method with 15-minute access token, 7-day refresh token, session storage, and inactive session cleanup - Add ValidateToken method with JWT parsing, HMAC signature verification, and active session validation - Add validateTokenWithoutSessionCheck for token parsing --- apps/api-server/internal/services/auth.go | 327 ++++++++++++++++++++++ 1 file changed, 327 insertions(+) create mode 100644 apps/api-server/internal/services/auth.go diff --git a/apps/api-server/internal/services/auth.go b/apps/api-server/internal/services/auth.go new file mode 100644 index 0000000..c2ba53c --- /dev/null +++ b/apps/api-server/internal/services/auth.go @@ -0,0 +1,327 @@ +package services + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "time" + + "github.com/golang-jwt/jwt/v5" + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" + + "github.com/nutcas3/telecom-platform/apps/api-server/internal/models" +) + +// JWTService handles JWT token generation and validation +type JWTService struct { + db *gorm.DB + secretKey string +} + +// NewJWTService creates a new JWT service +func NewJWTService(db *gorm.DB, secretKey string) *JWTService { + return &JWTService{ + db: db, + secretKey: secretKey, + } +} + +// Claims represents JWT claims +type Claims struct { + UserID uint `json:"user_id"` + Username string `json:"username"` + Role string `json:"role"` + jwt.RegisteredClaims +} + +// GenerateToken generates a new JWT token for a user +func (s *JWTService) GenerateToken(user *models.User) (string, string, error) { + // Generate access token (short-lived) + accessTokenExpiry := time.Now().Add(15 * time.Minute) + accessClaims := &Claims{ + UserID: user.ID, + Username: user.Username, + Role: user.Role, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(accessTokenExpiry), + IssuedAt: jwt.NewNumericDate(time.Now()), + NotBefore: jwt.NewNumericDate(time.Now()), + Issuer: "telecom-platform", + Subject: fmt.Sprintf("%d", user.ID), + }, + } + + accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims) + accessTokenString, err := accessToken.SignedString([]byte(s.secretKey)) + if err != nil { + return "", "", fmt.Errorf("failed to sign access token: %w", err) + } + + // Generate refresh token (long-lived) + refreshTokenExpiry := time.Now().Add(7 * 24 * time.Hour) // 7 days + refreshClaims := &Claims{ + UserID: user.ID, + Username: user.Username, + Role: user.Role, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(refreshTokenExpiry), + IssuedAt: jwt.NewNumericDate(time.Now()), + NotBefore: jwt.NewNumericDate(time.Now()), + Issuer: "telecom-platform", + Subject: fmt.Sprintf("%d", user.ID), + }, + } + + refreshToken := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims) + refreshTokenString, err := refreshToken.SignedString([]byte(s.secretKey)) + if err != nil { + return "", "", fmt.Errorf("failed to sign refresh token: %w", err) + } + + // Store session in database + sessionID := generateSessionID() + session := &models.AuthSession{ + ID: sessionID, + UserID: user.ID, + Token: accessTokenString, + RefreshToken: refreshTokenString, + ExpiresAt: accessTokenExpiry, + IsActive: true, + } + + // Delete any existing inactive sessions for this user to clean up + s.db.Where("user_id = ? AND is_active = ?", user.ID, false).Delete(&models.AuthSession{}) + + if err := s.db.Create(session).Error; err != nil { + return "", "", fmt.Errorf("failed to create session: %w", err) + } + + return accessTokenString, refreshTokenString, nil +} + +// ValidateToken validates a JWT token and returns the claims +func (s *JWTService) ValidateToken(tokenString string) (*Claims, error) { + token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (any, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return []byte(s.secretKey), nil + }) + + if err != nil { + return nil, fmt.Errorf("failed to parse token: %w", err) + } + + if claims, ok := token.Claims.(*Claims); ok && token.Valid { + // Check if session is still active + var session models.AuthSession + if err := s.db.Where("token = ? AND is_active = ?", tokenString, true).First(&session).Error; err != nil { + return nil, fmt.Errorf("session not found or inactive") + } + + if time.Now().After(session.ExpiresAt) { + return nil, fmt.Errorf("session expired") + } + + return claims, nil + } + + return nil, fmt.Errorf("invalid token") +} + +// validateTokenWithoutSessionCheck validates a JWT token without checking the database session +func (s *JWTService) validateTokenWithoutSessionCheck(tokenString string) (*Claims, error) { + token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (any, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return []byte(s.secretKey), nil + }) + + if err != nil { + return nil, fmt.Errorf("failed to parse token: %w", err) + } + + if claims, ok := token.Claims.(*Claims); ok && token.Valid { + return claims, nil + } + + return nil, fmt.Errorf("invalid token") +} + +// RefreshToken generates new tokens using a refresh token +func (s *JWTService) RefreshToken(refreshTokenString string) (string, string, error) { + // First validate the refresh token by checking it in the database + var session models.AuthSession + if err := s.db.Where("refresh_token = ? AND is_active = ?", refreshTokenString, true).First(&session).Error; err != nil { + return "", "", fmt.Errorf("invalid refresh token: %w", err) + } + + // Check if session has expired + if time.Now().After(session.ExpiresAt) { + return "", "", fmt.Errorf("refresh token expired") + } + + // Parse the refresh token to get claims + claims, err := s.validateTokenWithoutSessionCheck(refreshTokenString) + if err != nil { + return "", "", fmt.Errorf("invalid refresh token: %w", err) + } + + // Get user from database + var user models.User + if err := s.db.First(&user, claims.UserID).Error; err != nil { + return "", "", fmt.Errorf("user not found: %w", err) + } + + // Deactivate old session + s.db.Model(&models.AuthSession{}).Where("refresh_token = ?", refreshTokenString).Update("is_active", false) + + // Generate new tokens + return s.GenerateToken(&user) +} + +// InvalidateSession invalidates a user session +func (s *JWTService) InvalidateSession(tokenString string) error { + return s.db.Model(&models.AuthSession{}).Where("token = ?", tokenString).Update("is_active", false).Error +} + +// InvalidateAllUserSessions invalidates all sessions for a user +func (s *JWTService) InvalidateAllUserSessions(userID uint) error { + return s.db.Model(&models.AuthSession{}).Where("user_id = ?", userID).Update("is_active", false).Error +} + +// HashPassword hashes a password using bcrypt +func HashPassword(password string) (string, error) { + bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return "", fmt.Errorf("failed to hash password: %w", err) + } + return string(bytes), nil +} + +// CheckPassword verifies a password against its hash +func CheckPassword(password, hash string) error { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) +} + +// generateSessionID generates a random session ID +func generateSessionID() string { + bytes := make([]byte, 32) + rand.Read(bytes) + return hex.EncodeToString(bytes) +} + +// AuthService handles authentication operations +type AuthService struct { + db *gorm.DB + jwtService *JWTService +} + +// NewAuthService creates a new authentication service +func NewAuthService(db *gorm.DB, secretKey string) *AuthService { + return &AuthService{ + db: db, + jwtService: NewJWTService(db, secretKey), + } +} + +// Login authenticates a user and returns tokens +func (s *AuthService) Login(username, password string) (string, string, *models.User, error) { + var user models.User + if err := s.db.Where("username = ? AND is_active = ?", username, true).First(&user).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return "", "", nil, fmt.Errorf("invalid credentials") + } + return "", "", nil, fmt.Errorf("database error: %w", err) + } + + if err := CheckPassword(password, user.Password); err != nil { + return "", "", nil, fmt.Errorf("invalid credentials") + } + + // Update last login + now := time.Now() + s.db.Model(&user).Update("last_login", &now) + + // Generate tokens + accessToken, refreshToken, err := s.jwtService.GenerateToken(&user) + if err != nil { + return "", "", nil, fmt.Errorf("failed to generate tokens: %w", err) + } + + return accessToken, refreshToken, &user, nil +} + +// Register creates a new user +func (s *AuthService) Register(username, email, password, firstName, lastName, role string) (*models.User, error) { + // Hash password + hashedPassword, err := HashPassword(password) + if err != nil { + return nil, fmt.Errorf("failed to hash password: %w", err) + } + + // Create user + user := &models.User{ + Username: username, + Email: email, + Password: hashedPassword, + FirstName: firstName, + LastName: lastName, + Role: role, + IsActive: true, + } + + if err := s.db.Create(user).Error; err != nil { + return nil, fmt.Errorf("failed to create user: %w", err) + } + + return user, nil +} + +// GetUserByID retrieves a user by ID +func (s *AuthService) GetUserByID(userID uint) (*models.User, error) { + var user models.User + if err := s.db.First(&user, userID).Error; err != nil { + return nil, fmt.Errorf("user not found: %w", err) + } + return &user, nil +} + +// ChangePassword changes a user's password +func (s *AuthService) ChangePassword(userID uint, oldPassword, newPassword string) error { + var user models.User + if err := s.db.First(&user, userID).Error; err != nil { + return fmt.Errorf("user not found: %w", err) + } + + if err := CheckPassword(oldPassword, user.Password); err != nil { + return fmt.Errorf("invalid old password") + } + + hashedPassword, err := HashPassword(newPassword) + if err != nil { + return fmt.Errorf("failed to hash new password: %w", err) + } + + if err := s.db.Model(&user).Update("password", hashedPassword).Error; err != nil { + return fmt.Errorf("failed to update password: %w", err) + } + + // Invalidate all sessions for this user + s.jwtService.InvalidateAllUserSessions(userID) + + return nil +} + +// GetJWTService returns the JWT service +func (s *AuthService) GetJWTService() *JWTService { + return s.jwtService +} + +// GetDB returns the database instance +func (s *AuthService) GetDB() *gorm.DB { + return s.db +}