Docker Compose with 24+ services: - PostgreSQL (PostGIS), Valkey, MinIO, Qdrant - Vault (PKI/TLS), Nginx (Reverse Proxy) - Backend Core API, Consent Service, Billing Service - RAG Service, Embedding Service - Gitea, Woodpecker CI/CD - Night Scheduler, Health Aggregator - Jitsi (Web/XMPP/JVB/Jicofo), Mailpit Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
569 lines
17 KiB
Go
569 lines
17 KiB
Go
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
|
|
}
|