package services import ( "context" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/hex" "errors" "fmt" "time" "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" "golang.org/x/crypto/bcrypt" "github.com/breakpilot/consent-service/internal/models" ) var ( ErrInvalidCredentials = errors.New("invalid email or password") ErrUserNotFound = errors.New("user not found") ErrUserExists = errors.New("user with this email already exists") ErrInvalidToken = errors.New("invalid or expired token") ErrAccountLocked = errors.New("account is temporarily locked") ErrAccountSuspended = errors.New("account is suspended") ErrEmailNotVerified = errors.New("email not verified") ) // AuthService handles authentication logic type AuthService struct { db *pgxpool.Pool jwtSecret string jwtRefreshSecret string accessTokenExp time.Duration refreshTokenExp time.Duration } // NewAuthService creates a new AuthService func NewAuthService(db *pgxpool.Pool, jwtSecret, jwtRefreshSecret string) *AuthService { return &AuthService{ db: db, jwtSecret: jwtSecret, jwtRefreshSecret: jwtRefreshSecret, accessTokenExp: time.Hour * 1, // 1 hour refreshTokenExp: time.Hour * 24 * 30, // 30 days } } // HashPassword hashes a password using bcrypt func (s *AuthService) 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 } // VerifyPassword verifies a password against a hash func (s *AuthService) VerifyPassword(password, hash string) bool { err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) return err == nil } // GenerateSecureToken generates a cryptographically secure token func (s *AuthService) GenerateSecureToken(length int) (string, error) { bytes := make([]byte, length) if _, err := rand.Read(bytes); err != nil { return "", fmt.Errorf("failed to generate token: %w", err) } return base64.URLEncoding.EncodeToString(bytes), nil } // HashToken creates a SHA256 hash of a token for storage func (s *AuthService) HashToken(token string) string { hash := sha256.Sum256([]byte(token)) return hex.EncodeToString(hash[:]) } // JWTClaims for access tokens type JWTClaims struct { UserID string `json:"user_id"` Email string `json:"email"` Role string `json:"role"` AccountStatus string `json:"account_status"` jwt.RegisteredClaims } // GenerateAccessToken creates a new JWT access token func (s *AuthService) GenerateAccessToken(user *models.User) (string, error) { claims := JWTClaims{ UserID: user.ID.String(), Email: user.Email, Role: user.Role, AccountStatus: user.AccountStatus, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(s.accessTokenExp)), IssuedAt: jwt.NewNumericDate(time.Now()), NotBefore: jwt.NewNumericDate(time.Now()), Subject: user.ID.String(), }, } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) return token.SignedString([]byte(s.jwtSecret)) } // GenerateRefreshToken creates a new refresh token func (s *AuthService) GenerateRefreshToken() (string, string, error) { token, err := s.GenerateSecureToken(32) if err != nil { return "", "", err } hash := s.HashToken(token) return token, hash, nil } // ValidateAccessToken validates a JWT access token func (s *AuthService) ValidateAccessToken(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.jwtSecret), nil }) if err != nil { return nil, fmt.Errorf("failed to parse token: %w", err) } claims, ok := token.Claims.(*JWTClaims) if !ok || !token.Valid { return nil, ErrInvalidToken } return claims, nil } // Register creates a new user account func (s *AuthService) Register(ctx context.Context, req *models.RegisterRequest) (*models.User, string, error) { // Check if user already exists var exists bool err := s.db.QueryRow(ctx, "SELECT EXISTS(SELECT 1 FROM users WHERE email = $1)", req.Email).Scan(&exists) if err != nil { return nil, "", fmt.Errorf("failed to check existing user: %w", err) } if exists { return nil, "", ErrUserExists } // Hash password passwordHash, err := s.HashPassword(req.Password) if err != nil { return nil, "", err } // Create user user := &models.User{ ID: uuid.New(), Email: req.Email, PasswordHash: &passwordHash, Name: req.Name, Role: "user", EmailVerified: false, AccountStatus: "active", } _, err = s.db.Exec(ctx, ` INSERT INTO users (id, email, password_hash, name, role, email_verified, account_status, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW()) `, user.ID, user.Email, user.PasswordHash, user.Name, user.Role, user.EmailVerified, user.AccountStatus) if err != nil { return nil, "", fmt.Errorf("failed to create user: %w", err) } // Generate email verification token verificationToken, err := s.GenerateSecureToken(32) if err != nil { return nil, "", err } // Store verification token _, err = s.db.Exec(ctx, ` INSERT INTO email_verification_tokens (user_id, token, expires_at, created_at) VALUES ($1, $2, $3, NOW()) `, user.ID, verificationToken, time.Now().Add(24*time.Hour)) if err != nil { return nil, "", fmt.Errorf("failed to create verification token: %w", err) } // Create notification preferences _, err = s.db.Exec(ctx, ` INSERT INTO notification_preferences (user_id, email_enabled, push_enabled, in_app_enabled, reminder_frequency, created_at, updated_at) VALUES ($1, true, true, true, 'weekly', NOW(), NOW()) `, user.ID) if err != nil { // Non-critical error, just log fmt.Printf("Warning: failed to create notification preferences: %v\n", err) } return user, verificationToken, nil } // Login authenticates a user and returns tokens func (s *AuthService) Login(ctx context.Context, req *models.LoginRequest, ipAddress, userAgent string) (*models.LoginResponse, error) { var user models.User var passwordHash *string err := s.db.QueryRow(ctx, ` SELECT id, email, password_hash, name, role, email_verified, account_status, failed_login_attempts, locked_until, created_at, updated_at FROM users WHERE email = $1 `, req.Email).Scan( &user.ID, &user.Email, &passwordHash, &user.Name, &user.Role, &user.EmailVerified, &user.AccountStatus, &user.FailedLoginAttempts, &user.LockedUntil, &user.CreatedAt, &user.UpdatedAt, ) if err != nil { return nil, ErrInvalidCredentials } // Check if account is locked if user.LockedUntil != nil && user.LockedUntil.After(time.Now()) { return nil, ErrAccountLocked } // Check if account is suspended if user.AccountStatus == "suspended" { return nil, ErrAccountSuspended } // Verify password if passwordHash == nil || !s.VerifyPassword(req.Password, *passwordHash) { // Increment failed login attempts _, _ = s.db.Exec(ctx, ` UPDATE users SET failed_login_attempts = failed_login_attempts + 1, locked_until = CASE WHEN failed_login_attempts >= 4 THEN NOW() + INTERVAL '30 minutes' ELSE locked_until END, updated_at = NOW() WHERE id = $1 `, user.ID) return nil, ErrInvalidCredentials } // Reset failed login attempts and update last login _, _ = s.db.Exec(ctx, ` UPDATE users SET failed_login_attempts = 0, locked_until = NULL, last_login_at = NOW(), updated_at = NOW() WHERE id = $1 `, user.ID) // Generate tokens accessToken, err := s.GenerateAccessToken(&user) if err != nil { return nil, fmt.Errorf("failed to generate access token: %w", err) } refreshToken, refreshTokenHash, err := s.GenerateRefreshToken() if err != nil { return nil, fmt.Errorf("failed to generate refresh token: %w", err) } // Store session _, err = s.db.Exec(ctx, ` INSERT INTO user_sessions (user_id, token_hash, ip_address, user_agent, expires_at, created_at, last_activity_at) VALUES ($1, $2, $3, $4, $5, NOW(), NOW()) `, user.ID, refreshTokenHash, ipAddress, userAgent, time.Now().Add(s.refreshTokenExp)) if err != nil { return nil, fmt.Errorf("failed to create session: %w", err) } return &models.LoginResponse{ User: user, AccessToken: accessToken, RefreshToken: refreshToken, ExpiresIn: int(s.accessTokenExp.Seconds()), }, nil } // RefreshToken refreshes the access token using a refresh token func (s *AuthService) RefreshToken(ctx context.Context, refreshToken string) (*models.LoginResponse, error) { tokenHash := s.HashToken(refreshToken) var session models.UserSession var userID uuid.UUID err := s.db.QueryRow(ctx, ` SELECT id, user_id, expires_at, revoked_at FROM user_sessions WHERE token_hash = $1 `, tokenHash).Scan(&session.ID, &userID, &session.ExpiresAt, &session.RevokedAt) if err != nil { return nil, ErrInvalidToken } // Check if session is expired or revoked if session.RevokedAt != nil || session.ExpiresAt.Before(time.Now()) { return nil, ErrInvalidToken } // Get user var user models.User err = s.db.QueryRow(ctx, ` SELECT id, email, name, role, email_verified, account_status, created_at, updated_at FROM users WHERE id = $1 `, userID).Scan( &user.ID, &user.Email, &user.Name, &user.Role, &user.EmailVerified, &user.AccountStatus, &user.CreatedAt, &user.UpdatedAt, ) if err != nil { return nil, ErrUserNotFound } // Check account status if user.AccountStatus == "suspended" { return nil, ErrAccountSuspended } // Generate new access token accessToken, err := s.GenerateAccessToken(&user) if err != nil { return nil, fmt.Errorf("failed to generate access token: %w", err) } // Update session last activity _, _ = s.db.Exec(ctx, ` UPDATE user_sessions SET last_activity_at = NOW() WHERE id = $1 `, session.ID) return &models.LoginResponse{ User: user, AccessToken: accessToken, RefreshToken: refreshToken, ExpiresIn: int(s.accessTokenExp.Seconds()), }, nil } // VerifyEmail verifies a user's email address func (s *AuthService) VerifyEmail(ctx context.Context, token string) error { var tokenID uuid.UUID var userID uuid.UUID var expiresAt time.Time var usedAt *time.Time err := s.db.QueryRow(ctx, ` SELECT id, user_id, expires_at, used_at FROM email_verification_tokens WHERE token = $1 `, token).Scan(&tokenID, &userID, &expiresAt, &usedAt) if err != nil { return ErrInvalidToken } if usedAt != nil || expiresAt.Before(time.Now()) { return ErrInvalidToken } // Mark token as used _, err = s.db.Exec(ctx, `UPDATE email_verification_tokens SET used_at = NOW() WHERE id = $1`, tokenID) if err != nil { return fmt.Errorf("failed to update token: %w", err) } // Verify user email _, err = s.db.Exec(ctx, ` UPDATE users SET email_verified = true, email_verified_at = NOW(), updated_at = NOW() WHERE id = $1 `, userID) if err != nil { return fmt.Errorf("failed to verify email: %w", err) } return nil } // CreatePasswordResetToken creates a password reset token func (s *AuthService) CreatePasswordResetToken(ctx context.Context, email, ipAddress string) (string, *uuid.UUID, error) { var userID uuid.UUID err := s.db.QueryRow(ctx, "SELECT id FROM users WHERE email = $1", email).Scan(&userID) if err != nil { // Don't reveal if user exists return "", nil, nil } token, err := s.GenerateSecureToken(32) if err != nil { return "", nil, err } _, err = s.db.Exec(ctx, ` INSERT INTO password_reset_tokens (user_id, token, expires_at, ip_address, created_at) VALUES ($1, $2, $3, $4, NOW()) `, userID, token, time.Now().Add(time.Hour), ipAddress) if err != nil { return "", nil, fmt.Errorf("failed to create reset token: %w", err) } return token, &userID, nil } // ResetPassword resets a user's password using a reset token func (s *AuthService) ResetPassword(ctx context.Context, token, newPassword string) error { var tokenID uuid.UUID var userID uuid.UUID var expiresAt time.Time var usedAt *time.Time err := s.db.QueryRow(ctx, ` SELECT id, user_id, expires_at, used_at FROM password_reset_tokens WHERE token = $1 `, token).Scan(&tokenID, &userID, &expiresAt, &usedAt) if err != nil { return ErrInvalidToken } if usedAt != nil || expiresAt.Before(time.Now()) { return ErrInvalidToken } // Hash new password passwordHash, err := s.HashPassword(newPassword) if err != nil { return err } // Mark token as used _, err = s.db.Exec(ctx, `UPDATE password_reset_tokens SET used_at = NOW() WHERE id = $1`, tokenID) if err != nil { return fmt.Errorf("failed to update token: %w", err) } // Update password _, err = s.db.Exec(ctx, ` UPDATE users SET password_hash = $1, updated_at = NOW() WHERE id = $2 `, passwordHash, userID) if err != nil { return fmt.Errorf("failed to update password: %w", err) } // Revoke all sessions for security _, err = s.db.Exec(ctx, `UPDATE user_sessions SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL`, userID) if err != nil { fmt.Printf("Warning: failed to revoke sessions: %v\n", err) } return nil } // ChangePassword changes a user's password (requires current password) func (s *AuthService) ChangePassword(ctx context.Context, userID uuid.UUID, currentPassword, newPassword string) error { var passwordHash *string err := s.db.QueryRow(ctx, "SELECT password_hash FROM users WHERE id = $1", userID).Scan(&passwordHash) if err != nil { return ErrUserNotFound } if passwordHash == nil || !s.VerifyPassword(currentPassword, *passwordHash) { return ErrInvalidCredentials } newPasswordHash, err := s.HashPassword(newPassword) if err != nil { return err } _, err = s.db.Exec(ctx, `UPDATE users SET password_hash = $1, updated_at = NOW() WHERE id = $2`, newPasswordHash, userID) if err != nil { return fmt.Errorf("failed to update password: %w", err) } return nil } // GetUserByID retrieves a user by ID func (s *AuthService) GetUserByID(ctx context.Context, userID uuid.UUID) (*models.User, error) { var user models.User err := s.db.QueryRow(ctx, ` SELECT id, email, name, role, email_verified, email_verified_at, account_status, last_login_at, created_at, updated_at FROM users WHERE id = $1 `, userID).Scan( &user.ID, &user.Email, &user.Name, &user.Role, &user.EmailVerified, &user.EmailVerifiedAt, &user.AccountStatus, &user.LastLoginAt, &user.CreatedAt, &user.UpdatedAt, ) if err != nil { return nil, ErrUserNotFound } return &user, nil } // UpdateProfile updates a user's profile func (s *AuthService) UpdateProfile(ctx context.Context, userID uuid.UUID, req *models.UpdateProfileRequest) (*models.User, error) { _, err := s.db.Exec(ctx, `UPDATE users SET name = $1, updated_at = NOW() WHERE id = $2`, req.Name, userID) if err != nil { return nil, fmt.Errorf("failed to update profile: %w", err) } return s.GetUserByID(ctx, userID) } // GetActiveSessions retrieves all active sessions for a user func (s *AuthService) GetActiveSessions(ctx context.Context, userID uuid.UUID) ([]models.UserSession, error) { rows, err := s.db.Query(ctx, ` SELECT id, user_id, device_info, ip_address, user_agent, expires_at, created_at, last_activity_at FROM user_sessions WHERE user_id = $1 AND revoked_at IS NULL AND expires_at > NOW() ORDER BY last_activity_at DESC `, userID) if err != nil { return nil, fmt.Errorf("failed to get sessions: %w", err) } defer rows.Close() var sessions []models.UserSession for rows.Next() { var session models.UserSession err := rows.Scan( &session.ID, &session.UserID, &session.DeviceInfo, &session.IPAddress, &session.UserAgent, &session.ExpiresAt, &session.CreatedAt, &session.LastActivityAt, ) if err != nil { return nil, fmt.Errorf("failed to scan session: %w", err) } sessions = append(sessions, session) } return sessions, nil } // RevokeSession revokes a specific session func (s *AuthService) RevokeSession(ctx context.Context, userID, sessionID uuid.UUID) error { result, err := s.db.Exec(ctx, ` UPDATE user_sessions SET revoked_at = NOW() WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL `, sessionID, userID) if err != nil { return fmt.Errorf("failed to revoke session: %w", err) } if result.RowsAffected() == 0 { return errors.New("session not found") } return nil } // Logout revokes a session by refresh token func (s *AuthService) Logout(ctx context.Context, refreshToken string) error { tokenHash := s.HashToken(refreshToken) _, err := s.db.Exec(ctx, `UPDATE user_sessions SET revoked_at = NOW() WHERE token_hash = $1`, tokenHash) return err }