Files
Benjamin Boenisch ad111d5e69 Initial commit: breakpilot-core - Shared Infrastructure
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>
2026-02-11 23:47:13 +01:00

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
}