Files
skybridge/user/internal/services/auth_service.go
2025-09-01 18:26:44 -04:00

654 lines
20 KiB
Go

package services
import (
"context"
"crypto/rand"
"crypto/subtle"
"encoding/base32"
"encoding/base64"
"fmt"
"net/url"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"github.com/pquerna/otp"
"github.com/pquerna/otp/totp"
"go.uber.org/zap"
"golang.org/x/crypto/bcrypt"
"github.com/RyanCopley/skybridge/user/internal/domain"
"github.com/RyanCopley/skybridge/user/internal/repository/interfaces"
)
// AuthService defines the interface for authentication operations
type AuthService interface {
// Authentication
Login(ctx context.Context, req *domain.LoginRequest, ipAddress, userAgent string) (*domain.LoginResponse, error)
Register(ctx context.Context, req *domain.RegisterRequest, ipAddress, userAgent string) (*domain.RegisterResponse, error)
Logout(ctx context.Context, token string) error
RefreshToken(ctx context.Context, token string) (*domain.LoginResponse, error)
// Password management
ForgotPassword(ctx context.Context, req *domain.ForgotPasswordRequest, ipAddress, userAgent string) error
ResetPassword(ctx context.Context, req *domain.ResetPasswordRequest, ipAddress, userAgent string) error
ChangePassword(ctx context.Context, userID uuid.UUID, req *domain.ChangePasswordRequest, actorID string) error
// Email verification
VerifyEmail(ctx context.Context, req *domain.VerifyEmailRequest, ipAddress, userAgent string) error
ResendVerification(ctx context.Context, req *domain.ResendVerificationRequest, ipAddress, userAgent string) error
// Two-factor authentication
SetupTwoFactor(ctx context.Context, userID uuid.UUID) (*domain.SetupTwoFactorResponse, error)
EnableTwoFactor(ctx context.Context, userID uuid.UUID, req *domain.EnableTwoFactorRequest, actorID string) error
DisableTwoFactor(ctx context.Context, userID uuid.UUID, req *domain.DisableTwoFactorRequest, actorID string) error
ValidateTwoFactor(ctx context.Context, req *domain.ValidateTwoFactorRequest, ipAddress, userAgent string) (*domain.LoginResponse, error)
RegenerateTwoFactorBackupCodes(ctx context.Context, userID uuid.UUID, actorID string) ([]string, error)
// Session management
GetUserSessions(ctx context.Context, userID uuid.UUID) (*domain.ListSessionsResponse, error)
RevokeSession(ctx context.Context, userID uuid.UUID, sessionID uuid.UUID, actorID string) error
RevokeAllSessions(ctx context.Context, userID uuid.UUID, actorID string) error
// Token validation
ValidateToken(ctx context.Context, tokenString string) (*domain.AuthContext, error)
// Security utilities
IsAccountLocked(ctx context.Context, email string) (bool, *time.Time, error)
RecordLoginAttempt(ctx context.Context, email, ipAddress, userAgent, failureReason string, success bool) error
}
type authService struct {
userRepo interfaces.UserRepository
sessionRepo interfaces.UserSessionRepository
auditRepo interfaces.AuditRepository
logger *zap.Logger
jwtSecret string
jwtIssuer string
tokenExpiry time.Duration
bcryptCost int
maxLoginAttempts int
lockoutDuration time.Duration
}
// JWT Claims structure
type JWTClaims struct {
UserID string `json:"user_id"`
Email string `json:"email"`
Role string `json:"role"`
SessionID string `json:"session_id"`
TwoFactor bool `json:"two_factor,omitempty"`
TempToken bool `json:"temp_token,omitempty"`
jwt.RegisteredClaims
}
// NewAuthService creates a new authentication service
func NewAuthService(
userRepo interfaces.UserRepository,
sessionRepo interfaces.UserSessionRepository,
auditRepo interfaces.AuditRepository,
logger *zap.Logger,
jwtSecret string,
jwtIssuer string,
) AuthService {
return &authService{
userRepo: userRepo,
sessionRepo: sessionRepo,
auditRepo: auditRepo,
logger: logger,
jwtSecret: jwtSecret,
jwtIssuer: jwtIssuer,
tokenExpiry: 24 * time.Hour,
bcryptCost: 12,
maxLoginAttempts: 5,
lockoutDuration: 15 * time.Minute,
}
}
func (s *authService) Register(ctx context.Context, req *domain.RegisterRequest, ipAddress, userAgent string) (*domain.RegisterResponse, error) {
// Check if user already exists
exists, err := s.userRepo.ExistsByEmail(ctx, req.Email)
if err != nil {
return nil, fmt.Errorf("failed to check user existence: %w", err)
}
if exists {
return nil, fmt.Errorf("user with email %s already exists", req.Email)
}
// Hash password
passwordHash, err := s.hashPassword(req.Password)
if err != nil {
return nil, fmt.Errorf("failed to hash password: %w", err)
}
// Generate email verification token
verificationToken, err := s.generateSecureToken()
if err != nil {
return nil, fmt.Errorf("failed to generate verification token: %w", err)
}
// Create user
user := &domain.User{
ID: uuid.New(),
Email: req.Email,
FirstName: req.FirstName,
LastName: req.LastName,
DisplayName: req.DisplayName,
Role: domain.UserRoleUser,
Status: domain.UserStatusPending,
PasswordHash: passwordHash,
EmailVerified: false,
EmailVerificationToken: &verificationToken,
EmailVerificationExpiresAt: timePtr(time.Now().Add(24 * time.Hour)),
TwoFactorEnabled: false,
CreatedBy: req.Email,
UpdatedBy: req.Email,
}
err = s.userRepo.Create(ctx, user)
if err != nil {
return nil, fmt.Errorf("failed to create user: %w", err)
}
// Log registration
if s.auditRepo != nil {
auditEvent := &interfaces.AuditEvent{
ID: uuid.New(),
Type: "auth.register",
Severity: "info",
Status: "success",
Timestamp: time.Now().Format(time.RFC3339),
ActorID: user.Email,
ActorType: "user",
ActorIP: ipAddress,
UserAgent: userAgent,
ResourceID: user.ID.String(),
ResourceType: "user",
Action: "register",
Description: fmt.Sprintf("User %s registered", user.Email),
Details: map[string]interface{}{
"user_id": user.ID.String(),
"email": user.Email,
},
}
_ = s.auditRepo.LogEvent(ctx, auditEvent)
}
// TODO: Send welcome email with verification link
return &domain.RegisterResponse{
User: user,
Message: "Registration successful. Please check your email for verification instructions.",
}, nil
}
func (s *authService) Login(ctx context.Context, req *domain.LoginRequest, ipAddress, userAgent string) (*domain.LoginResponse, error) {
// Check if account is locked
locked, lockedUntil, err := s.IsAccountLocked(ctx, req.Email)
if err != nil {
s.logger.Error("Failed to check account lock status", zap.String("email", req.Email), zap.Error(err))
return nil, fmt.Errorf("authentication failed")
}
if locked {
_ = s.RecordLoginAttempt(ctx, req.Email, ipAddress, userAgent, "account_locked", false)
return nil, fmt.Errorf("account is temporarily locked until %v", lockedUntil.Format(time.RFC3339))
}
// Get user
user, err := s.userRepo.GetByEmail(ctx, req.Email)
if err != nil {
_ = s.RecordLoginAttempt(ctx, req.Email, ipAddress, userAgent, "user_not_found", false)
return nil, fmt.Errorf("authentication failed")
}
// Verify password
if !s.verifyPassword(req.Password, user.PasswordHash) {
_ = s.RecordLoginAttempt(ctx, req.Email, ipAddress, userAgent, "invalid_password", false)
_ = s.incrementFailedAttempts(ctx, user.ID)
return nil, fmt.Errorf("authentication failed")
}
// Check user status
if user.Status != domain.UserStatusActive {
_ = s.RecordLoginAttempt(ctx, req.Email, ipAddress, userAgent, fmt.Sprintf("user_status_%s", user.Status), false)
return nil, fmt.Errorf("account is not active")
}
// Check email verification
if !user.EmailVerified {
_ = s.RecordLoginAttempt(ctx, req.Email, ipAddress, userAgent, "email_not_verified", false)
return nil, fmt.Errorf("please verify your email address before logging in")
}
// Handle two-factor authentication
if user.TwoFactorEnabled {
if req.TwoFactorCode == nil {
// Generate temporary token for 2FA completion
tempToken, err := s.generateTempTwoFactorToken(user, ipAddress, userAgent)
if err != nil {
return nil, fmt.Errorf("failed to generate temporary token: %w", err)
}
return &domain.LoginResponse{
RequiresTwoFactor: true,
TwoFactorTempToken: &tempToken,
}, nil
}
// Validate 2FA code
valid, err := s.validateTOTPCode(*req.TwoFactorCode, *user.TwoFactorSecret)
if err != nil || !valid {
_ = s.RecordLoginAttempt(ctx, req.Email, ipAddress, userAgent, "invalid_2fa_code", false)
return nil, fmt.Errorf("invalid two-factor authentication code")
}
}
// Reset failed login attempts on successful login
_ = s.resetFailedAttempts(ctx, user.ID)
// Create session
session, err := s.createSession(ctx, user, ipAddress, userAgent, req.RememberMe)
if err != nil {
return nil, fmt.Errorf("failed to create session: %w", err)
}
// Generate JWT token
token, expiresAt, err := s.generateJWTToken(user, session.ID.String(), false)
if err != nil {
return nil, fmt.Errorf("failed to generate token: %w", err)
}
// Update last login
_ = s.userRepo.UpdateLastLogin(ctx, user.ID)
// Record successful login
_ = s.RecordLoginAttempt(ctx, req.Email, ipAddress, userAgent, "", true)
// Log successful login
if s.auditRepo != nil {
auditEvent := &interfaces.AuditEvent{
ID: uuid.New(),
Type: "auth.login",
Severity: "info",
Status: "success",
Timestamp: time.Now().Format(time.RFC3339),
ActorID: user.Email,
ActorType: "user",
ActorIP: ipAddress,
UserAgent: userAgent,
ResourceID: user.ID.String(),
ResourceType: "user",
Action: "login",
Description: fmt.Sprintf("User %s logged in", user.Email),
SessionID: session.ID.String(),
Details: map[string]interface{}{
"user_id": user.ID.String(),
"session_id": session.ID.String(),
"two_factor": user.TwoFactorEnabled,
},
}
_ = s.auditRepo.LogEvent(ctx, auditEvent)
}
return &domain.LoginResponse{
User: user,
Token: token,
ExpiresAt: expiresAt,
}, nil
}
// Helper functions
func (s *authService) hashPassword(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), s.bcryptCost)
return string(bytes), err
}
func (s *authService) verifyPassword(password, hash string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err == nil
}
func (s *authService) generateSecureToken() (string, error) {
bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return base64.URLEncoding.EncodeToString(bytes), nil
}
func (s *authService) generateJWTToken(user *domain.User, sessionID string, isTempToken bool) (string, time.Time, error) {
expiresAt := time.Now().Add(s.tokenExpiry)
if isTempToken {
expiresAt = time.Now().Add(5 * time.Minute) // Temp tokens expire in 5 minutes
}
claims := JWTClaims{
UserID: user.ID.String(),
Email: user.Email,
Role: string(user.Role),
SessionID: sessionID,
TwoFactor: user.TwoFactorEnabled,
TempToken: isTempToken,
RegisteredClaims: jwt.RegisteredClaims{
Subject: user.ID.String(),
Issuer: s.jwtIssuer,
IssuedAt: jwt.NewNumericDate(time.Now()),
ExpiresAt: jwt.NewNumericDate(expiresAt),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString([]byte(s.jwtSecret))
if err != nil {
return "", time.Time{}, err
}
return tokenString, expiresAt, nil
}
func (s *authService) generateTempTwoFactorToken(user *domain.User, ipAddress, userAgent string) (string, error) {
token, _, err := s.generateJWTToken(user, "", true)
return token, err
}
func (s *authService) createSession(ctx context.Context, user *domain.User, ipAddress, userAgent string, rememberMe bool) (*domain.UserSession, error) {
expiresAt := time.Now().Add(s.tokenExpiry)
if rememberMe {
expiresAt = time.Now().Add(30 * 24 * time.Hour) // 30 days for remember me
}
sessionToken, err := s.generateSecureToken()
if err != nil {
return nil, err
}
session := &domain.UserSession{
ID: uuid.New(),
UserID: user.ID,
Token: sessionToken,
IPAddress: ipAddress,
UserAgent: userAgent,
ExpiresAt: expiresAt,
LastUsedAt: time.Now(),
}
err = s.sessionRepo.Create(ctx, session)
if err != nil {
return nil, err
}
return session, nil
}
func (s *authService) incrementFailedAttempts(ctx context.Context, userID uuid.UUID) error {
return s.userRepo.IncrementFailedAttempts(ctx, userID, s.lockoutDuration)
}
func (s *authService) resetFailedAttempts(ctx context.Context, userID uuid.UUID) error {
return s.userRepo.ResetFailedAttempts(ctx, userID)
}
func (s *authService) validateTOTPCode(code, secret string) (bool, error) {
return totp.Validate(code, secret, time.Now()), nil
}
func (s *authService) generateBackupCode() (string, error) {
// Generate 8-digit backup code
bytes := make([]byte, 4)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
code := ""
for _, b := range bytes {
code += fmt.Sprintf("%02d", int(b)%100)
}
return code, nil
}
// Utility function
func timePtr(t time.Time) *time.Time {
return &t
}
// Placeholder implementations for other methods (to be completed)
func (s *authService) Logout(ctx context.Context, token string) error {
// TODO: Implement logout
return nil
}
func (s *authService) RefreshToken(ctx context.Context, token string) (*domain.LoginResponse, error) {
// TODO: Implement refresh token
return nil, nil
}
func (s *authService) ForgotPassword(ctx context.Context, req *domain.ForgotPasswordRequest, ipAddress, userAgent string) error {
// TODO: Implement forgot password
return nil
}
func (s *authService) ResetPassword(ctx context.Context, req *domain.ResetPasswordRequest, ipAddress, userAgent string) error {
// TODO: Implement reset password
return nil
}
func (s *authService) ChangePassword(ctx context.Context, userID uuid.UUID, req *domain.ChangePasswordRequest, actorID string) error {
// TODO: Implement change password
return nil
}
func (s *authService) VerifyEmail(ctx context.Context, req *domain.VerifyEmailRequest, ipAddress, userAgent string) error {
// TODO: Implement email verification
return nil
}
func (s *authService) ResendVerification(ctx context.Context, req *domain.ResendVerificationRequest, ipAddress, userAgent string) error {
// TODO: Implement resend verification
return nil
}
func (s *authService) SetupTwoFactor(ctx context.Context, userID uuid.UUID) (*domain.SetupTwoFactorResponse, error) {
user, err := s.userRepo.GetByID(ctx, userID)
if err != nil {
return nil, fmt.Errorf("user not found: %w", err)
}
if user.TwoFactorEnabled {
return nil, fmt.Errorf("two-factor authentication is already enabled")
}
// Generate TOTP secret
key, err := totp.Generate(totp.GenerateOpts{
Issuer: s.jwtIssuer,
AccountName: user.Email,
SecretSize: 32,
})
if err != nil {
return nil, fmt.Errorf("failed to generate TOTP secret: %w", err)
}
// Generate backup codes
backupCodes := make([]string, 10)
for i := 0; i < 10; i++ {
code, err := s.generateBackupCode()
if err != nil {
return nil, fmt.Errorf("failed to generate backup code: %w", err)
}
backupCodes[i] = code
}
// Create QR code URL
qrCodeURL := key.URL()
return &domain.SetupTwoFactorResponse{
Secret: key.Secret(),
QRCodeURL: qrCodeURL,
BackupCodes: backupCodes,
}, nil
}
func (s *authService) EnableTwoFactor(ctx context.Context, userID uuid.UUID, req *domain.EnableTwoFactorRequest, actorID string) error {
user, err := s.userRepo.GetByID(ctx, userID)
if err != nil {
return fmt.Errorf("user not found: %w", err)
}
if user.TwoFactorEnabled {
return fmt.Errorf("two-factor authentication is already enabled")
}
if user.TwoFactorSecret == nil {
return fmt.Errorf("two-factor authentication setup required first")
}
// Validate the provided code
valid, err := s.validateTOTPCode(req.Code, *user.TwoFactorSecret)
if err != nil || !valid {
return fmt.Errorf("invalid verification code")
}
// Generate backup codes
backupCodes := make([]string, 10)
for i := 0; i < 10; i++ {
code, err := s.generateBackupCode()
if err != nil {
return fmt.Errorf("failed to generate backup code: %w", err)
}
backupCodes[i] = code
}
// Enable 2FA in database
err = s.userRepo.UpdateTwoFactorSettings(ctx, userID, true, user.TwoFactorSecret, backupCodes)
if err != nil {
return fmt.Errorf("failed to enable two-factor authentication: %w", err)
}
// Log audit event
if s.auditRepo != nil {
auditEvent := &interfaces.AuditEvent{
ID: uuid.New(),
Type: "auth.2fa_enabled",
Severity: "info",
Status: "success",
Timestamp: time.Now().Format(time.RFC3339),
ActorID: actorID,
ActorType: "user",
ResourceID: userID.String(),
ResourceType: "user",
Action: "enable_2fa",
Description: fmt.Sprintf("Two-factor authentication enabled for user %s", user.Email),
Details: map[string]interface{}{
"user_id": userID.String(),
"email": user.Email,
},
}
_ = s.auditRepo.LogEvent(ctx, auditEvent)
}
s.logger.Info("Two-factor authentication enabled",
zap.String("user_id", userID.String()),
zap.String("email", user.Email),
zap.String("actor", actorID))
return nil
}
func (s *authService) DisableTwoFactor(ctx context.Context, userID uuid.UUID, req *domain.DisableTwoFactorRequest, actorID string) error {
// TODO: Implement disable 2FA
return nil
}
func (s *authService) ValidateTwoFactor(ctx context.Context, req *domain.ValidateTwoFactorRequest, ipAddress, userAgent string) (*domain.LoginResponse, error) {
// TODO: Implement validate 2FA
return nil, nil
}
func (s *authService) RegenerateTwoFactorBackupCodes(ctx context.Context, userID uuid.UUID, actorID string) ([]string, error) {
// TODO: Implement regenerate backup codes
return nil, nil
}
func (s *authService) GetUserSessions(ctx context.Context, userID uuid.UUID) (*domain.ListSessionsResponse, error) {
// TODO: Implement get user sessions
return nil, nil
}
func (s *authService) RevokeSession(ctx context.Context, userID uuid.UUID, sessionID uuid.UUID, actorID string) error {
// TODO: Implement revoke session
return nil
}
func (s *authService) RevokeAllSessions(ctx context.Context, userID uuid.UUID, actorID string) error {
// TODO: Implement revoke all sessions
return nil
}
func (s *authService) ValidateToken(ctx context.Context, tokenString string) (*domain.AuthContext, error) {
// TODO: Implement token validation
return nil, nil
}
func (s *authService) IsAccountLocked(ctx context.Context, email string) (bool, *time.Time, error) {
user, err := s.userRepo.GetByEmail(ctx, email)
if err != nil {
return false, nil, nil // User doesn't exist, not locked
}
// Check if account is currently locked
if user.LockedUntil != nil && time.Now().Before(*user.LockedUntil) {
return true, user.LockedUntil, nil
}
// Clear expired lock
if user.LockedUntil != nil && time.Now().After(*user.LockedUntil) {
_ = s.userRepo.ResetFailedAttempts(ctx, user.ID)
}
return false, nil, nil
}
func (s *authService) RecordLoginAttempt(ctx context.Context, email, ipAddress, userAgent, failureReason string, success bool) error {
attempt := &domain.LoginAttempt{
ID: uuid.New(),
Email: email,
IPAddress: ipAddress,
UserAgent: &userAgent,
Success: success,
FailureReason: &failureReason,
AttemptedAt: time.Now(),
}
if failureReason == "" {
attempt.FailureReason = nil
}
// In a full implementation, you would use a LoginAttemptRepository
// For now, just log the audit event
if s.auditRepo != nil {
auditEvent := &interfaces.AuditEvent{
ID: uuid.New(),
Type: "auth.login_attempt",
Severity: "info",
Status: "success",
Timestamp: time.Now().Format(time.RFC3339),
ActorID: email,
ActorType: "user",
ActorIP: ipAddress,
UserAgent: userAgent,
Action: "login_attempt",
Description: fmt.Sprintf("Login attempt for %s: success=%v", email, success),
Details: map[string]interface{}{
"email": email,
"success": success,
"failure_reason": failureReason,
},
}
_ = s.auditRepo.LogEvent(ctx, auditEvent)
}
return nil
}