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 }