From 3dcecc656f3810c9ffad4a09f5f433d21245be63 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Mon, 3 Nov 2025 21:30:40 +0100 Subject: [PATCH] feat(backend): Implement email verification flow - Phase 2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add complete email verification system with token-based verification. Changes: - Created EmailVerificationToken domain entity with expiration and verification tracking - Created EmailVerifiedEvent domain event for audit trail - Updated User entity with IsEmailVerified property and VerifyEmail method - Created IEmailVerificationTokenRepository interface and implementation - Created SecurityTokenService for secure token generation and SHA-256 hashing - Created EmailVerificationTokenConfiguration for EF Core mapping - Updated IdentityDbContext to include EmailVerificationTokens DbSet - Created SendVerificationEmailCommand and handler for sending verification emails - Created VerifyEmailCommand and handler for email verification - Added POST /api/auth/verify-email endpoint to AuthController - Integrated email verification into RegisterTenantCommandHandler - Registered all new services in DependencyInjection - Created and applied AddEmailVerification database migration - Build successful with no compilation errors Database Schema: - email_verification_tokens table with indexes on token_hash and user_id - 24-hour token expiration - One-time use tokens with verification tracking πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- colaflow-api/DAY7-ARCHITECTURE.md | 1893 +++++++++++++++++ .../Controllers/AuthController.cs | 19 + .../RegisterTenantCommandHandler.cs | 18 +- .../SendVerificationEmailCommand.cs | 9 + .../SendVerificationEmailCommandHandler.cs | 95 + .../VerifyEmail/VerifyEmailCommand.cs | 5 + .../VerifyEmail/VerifyEmailCommandHandler.cs | 68 + .../Services/ISecurityTokenService.cs | 22 + .../Users/Events/EmailVerifiedEvent.cs | 5 + .../Aggregates/Users/User.cs | 10 +- .../Entities/EmailVerificationToken.cs | 52 + .../IEmailVerificationTokenRepository.cs | 38 + .../DependencyInjection.cs | 2 + .../EmailVerificationTokenConfiguration.cs | 51 + .../Persistence/IdentityDbContext.cs | 2 + ...103202856_AddEmailVerification.Designer.cs | 370 ++++ .../20251103202856_AddEmailVerification.cs | 48 + .../IdentityDbContextModelSnapshot.cs | 40 + .../EmailVerificationTokenRepository.cs | 43 + .../Services/SecurityTokenService.cs | 36 + 20 files changed, 2823 insertions(+), 3 deletions(-) create mode 100644 colaflow-api/DAY7-ARCHITECTURE.md create mode 100644 colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/SendVerificationEmail/SendVerificationEmailCommand.cs create mode 100644 colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/SendVerificationEmail/SendVerificationEmailCommandHandler.cs create mode 100644 colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/VerifyEmail/VerifyEmailCommand.cs create mode 100644 colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/VerifyEmail/VerifyEmailCommandHandler.cs create mode 100644 colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Services/ISecurityTokenService.cs create mode 100644 colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Aggregates/Users/Events/EmailVerifiedEvent.cs create mode 100644 colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Entities/EmailVerificationToken.cs create mode 100644 colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Repositories/IEmailVerificationTokenRepository.cs create mode 100644 colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Configurations/EmailVerificationTokenConfiguration.cs create mode 100644 colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Migrations/20251103202856_AddEmailVerification.Designer.cs create mode 100644 colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Migrations/20251103202856_AddEmailVerification.cs create mode 100644 colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Repositories/EmailVerificationTokenRepository.cs create mode 100644 colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Services/SecurityTokenService.cs diff --git a/colaflow-api/DAY7-ARCHITECTURE.md b/colaflow-api/DAY7-ARCHITECTURE.md new file mode 100644 index 0000000..527f20a --- /dev/null +++ b/colaflow-api/DAY7-ARCHITECTURE.md @@ -0,0 +1,1893 @@ +# Day 7 Technical Architecture +# Email Service & User Management + +**Version**: 1.0 +**Date**: 2025-11-03 +**Sprint**: M1 Sprint 2 - Day 7 +**Author**: Architecture Team +**Status**: Ready for Implementation +**Related PRD**: [DAY7-PRD.md](./DAY7-PRD.md) + +--- + +## Table of Contents + +1. [Overview](#1-overview) +2. [Technology Stack Decisions](#2-technology-stack-decisions) +3. [Core Architecture Components](#3-core-architecture-components) +4. [Database Schema Design](#4-database-schema-design) +5. [Security Architecture (ADRs)](#5-security-architecture-adrs) +6. [Integration Architecture](#6-integration-architecture) +7. [Implementation Phases](#7-implementation-phases) +8. [Deployment Considerations](#8-deployment-considerations) + +--- + +## 1. Overview + +### 1.1 Scope + +Day 7 implements the foundation for secure user communication and team collaboration: + +| Component | Purpose | Priority | +|-----------|---------|----------| +| **Email Service** | Send transactional emails (verification, reset, invitations) | P0 | +| **Email Verification** | Validate user email ownership | P0 | +| **Password Reset** | Self-service password recovery | P0 | +| **User Invitations** | Team member onboarding | P0 | + +### 1.2 Architecture Principles + +1. **Abstraction First**: Email provider is pluggable (SendGrid, SMTP, Mock) +2. **Security by Design**: All tokens hashed (SHA-256), short expiration windows +3. **Fail-Safe Operations**: Email delivery failures don't block user actions +4. **Domain-Driven Design**: Token entities are first-class domain objects +5. **Modular Boundaries**: Email service is infrastructure concern, separated from domain logic + +### 1.3 System Context + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Application Layer β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Register β”‚ β”‚ ForgotPasswordβ”‚ β”‚ InviteUser β”‚ β”‚ +β”‚ β”‚ Tenant β”‚ β”‚ Command β”‚ β”‚ Command β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ β”‚ β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + ↓ +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Infrastructure Layer β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ IEmailService (Abstraction) β”‚ β”‚ +β”‚ β”‚ + SendEmailAsync(EmailMessage message) β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ ↓ ↓ ↓ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚SendGrid β”‚ β”‚ SMTP β”‚ β”‚ Mock β”‚ β”‚ +β”‚ β”‚ Service β”‚ β”‚ Service β”‚ β”‚ Service β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ IEmailTemplateRenderer β”‚ β”‚ +β”‚ β”‚ + RenderAsync(templateName, data) β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + ↓ +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Domain Layer β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ EmailVerificationβ”‚ β”‚ PasswordReset β”‚ β”‚ +β”‚ β”‚ Token (Entity) β”‚ β”‚ Token (Entity) β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Invitation β”‚ β”‚ +β”‚ β”‚ (Entity) β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +--- + +## 2. Technology Stack Decisions + +### 2.1 Email Service Provider + +**Decision**: Hybrid approach with **SendGrid as primary, SMTP as fallback** + +| Provider | Use Case | Pros | Cons | +|----------|----------|------|------| +| **SendGrid** | Production | β€’ 99.9% delivery rate
β€’ Built-in analytics
β€’ Bounce handling
β€’ 100 emails/day free tier | β€’ External dependency
β€’ Requires API key | +| **SMTP** | Development & Self-hosted | β€’ No external dependencies
β€’ Air-gapped support
β€’ Works with MailHog/Papercut | β€’ Lower delivery rate
β€’ Manual spam management | +| **Mock** | Testing | β€’ No actual sends
β€’ Fast tests
β€’ File logging | β€’ Not for production | + +**Implementation Strategy**: +```csharp +// Configuration-driven selection +services.AddScoped(provider => +{ + var config = provider.GetRequiredService(); + return config.Provider switch + { + "SendGrid" => new SendGridEmailService(config.SendGrid), + "Smtp" => new SmtpEmailService(config.Smtp), + "Mock" => new MockEmailService(config.MockSettings), + _ => throw new InvalidOperationException($"Unknown email provider: {config.Provider}") + }; +}); +``` + +**Rationale**: +- **Production**: SendGrid ensures high deliverability for critical emails (password resets, verifications) +- **Development**: SMTP with MailHog allows offline development without external dependencies +- **Testing**: Mock service enables fast, deterministic unit/integration tests + +### 2.2 Email Template Engine + +**Decision**: Use **C# String Interpolation + Razor Templates** (Hybrid) + +| Option | When to Use | Pros | Cons | +|--------|-------------|------|------| +| **C# String Interpolation** | Simple templates (text-only) | β€’ No dependencies
β€’ Fast rendering
β€’ Type-safe | β€’ Limited HTML support
β€’ No layouts | +| **Razor Templates** | Complex HTML templates | β€’ Full HTML support
β€’ Layout inheritance
β€’ IntelliSense | β€’ RazorLight dependency
β€’ Slower rendering | + +**Implementation**: +```csharp +// Simple text emails: String interpolation +public string RenderSimple(string userName, string link) => + $"Hi {userName},\n\nClick here to verify: {link}"; + +// Complex HTML emails: Razor +public async Task RenderHtmlAsync(string templateName, object model) +{ + var engine = new RazorLightEngineBuilder() + .UseFileSystemProject(Path.Combine(AppContext.BaseDirectory, "EmailTemplates")) + .UseMemoryCachingProvider() + .Build(); + + return await engine.CompileRenderAsync(templateName, model); +} +``` + +**Day 7 Recommendation**: Start with **String Interpolation** for simplicity. Upgrade to Razor if HTML complexity grows. + +### 2.3 Token Storage & Hashing + +**Decision**: Database storage with **SHA-256 hashing** + +| Aspect | Decision | Rationale | +|--------|----------|-----------| +| **Storage** | PostgreSQL tables (3 new tables) | β€’ ACID guarantees
β€’ Existing infrastructure
β€’ Easy expiration queries | +| **Hashing Algorithm** | SHA-256 | β€’ Fast (token lookup performance)
β€’ Sufficient for time-limited tokens
β€’ Industry standard | +| **Token Format** | Base64URL-encoded random 256-bit | β€’ URL-safe
β€’ Cryptographically secure
β€’ Collision-resistant | + +**Alternative Rejected**: Redis for token storage +- **Reason**: Adds operational complexity for marginal performance gain. PostgreSQL with indexing is sufficient for Day 7 scale. +- **Future**: Revisit for 10M+ tokens/day scenarios. + +--- + +## 3. Core Architecture Components + +### 3.1 Email Service Abstraction + +#### Interface Design + +```csharp +namespace ColaFlow.Modules.Identity.Application.Services; + +/// +/// Abstraction for sending transactional emails. +/// Implementations: SendGrid, SMTP, Mock (for testing). +/// +public interface IEmailService +{ + /// + /// Send an email asynchronously. + /// + /// Email message details + /// Cancellation token + /// EmailSendResult with success status and metadata + Task SendEmailAsync( + EmailMessage message, + CancellationToken cancellationToken = default); +} + +/// +/// Email message data transfer object. +/// +public record EmailMessage( + string ToAddress, + string ToName, + string Subject, + string TextBody, + string? HtmlBody = null, + string? FromAddress = null, + string? FromName = null); + +/// +/// Result of email send operation. +/// +public record EmailSendResult( + bool Success, + string? MessageId = null, + string? ErrorMessage = null); +``` + +#### Implementation: SendGrid + +```csharp +namespace ColaFlow.Modules.Identity.Infrastructure.Services; + +public class SendGridEmailService : IEmailService +{ + private readonly SendGridClient _client; + private readonly string _defaultFromAddress; + private readonly string _defaultFromName; + private readonly ILogger _logger; + + public SendGridEmailService( + SendGridSettings settings, + ILogger logger) + { + _client = new SendGridClient(settings.ApiKey); + _defaultFromAddress = settings.FromAddress; + _defaultFromName = settings.FromName; + _logger = logger; + } + + public async Task SendEmailAsync( + EmailMessage message, + CancellationToken cancellationToken = default) + { + try + { + var from = new EmailAddress( + message.FromAddress ?? _defaultFromAddress, + message.FromName ?? _defaultFromName); + + var to = new EmailAddress(message.ToAddress, message.ToName); + + var msg = MailHelper.CreateSingleEmail( + from, + to, + message.Subject, + message.TextBody, + message.HtmlBody); + + var response = await _client.SendEmailAsync(msg, cancellationToken); + + if (response.IsSuccessStatusCode) + { + _logger.LogInformation( + "Email sent successfully to {Email}. Subject: {Subject}", + message.ToAddress, + message.Subject); + + return new EmailSendResult( + Success: true, + MessageId: response.Headers.GetValues("X-Message-Id").FirstOrDefault()); + } + + var body = await response.Body.ReadAsStringAsync(cancellationToken); + _logger.LogWarning( + "Email send failed. StatusCode: {StatusCode}, Body: {Body}", + response.StatusCode, + body); + + return new EmailSendResult( + Success: false, + ErrorMessage: $"SendGrid returned {response.StatusCode}"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Exception sending email to {Email}", message.ToAddress); + return new EmailSendResult( + Success: false, + ErrorMessage: ex.Message); + } + } +} +``` + +**Key Design Decisions**: +1. **Non-blocking**: Email failures return error result but don't throw exceptions +2. **Logging**: All sends logged for audit trail +3. **Graceful Degradation**: Commands continue even if email fails (see Integration section) + +#### Implementation: SMTP (Simplified) + +```csharp +public class SmtpEmailService : IEmailService +{ + private readonly SmtpSettings _settings; + private readonly ILogger _logger; + + public async Task SendEmailAsync( + EmailMessage message, + CancellationToken cancellationToken = default) + { + try + { + using var smtpClient = new SmtpClient(_settings.Host, _settings.Port) + { + Credentials = new NetworkCredential(_settings.Username, _settings.Password), + EnableSsl = _settings.EnableSsl + }; + + var mailMessage = new MailMessage( + from: new MailAddress(_settings.FromAddress, _settings.FromName), + to: new MailAddress(message.ToAddress, message.ToName)) + { + Subject = message.Subject, + Body = message.HtmlBody ?? message.TextBody, + IsBodyHtml = message.HtmlBody != null + }; + + await smtpClient.SendMailAsync(mailMessage, cancellationToken); + + _logger.LogInformation("Email sent via SMTP to {Email}", message.ToAddress); + return new EmailSendResult(Success: true); + } + catch (Exception ex) + { + _logger.LogError(ex, "SMTP send failed to {Email}", message.ToAddress); + return new EmailSendResult(Success: false, ErrorMessage: ex.Message); + } + } +} +``` + +### 3.2 Security Token Base Class + +All security tokens (verification, reset, invitation) share common characteristics. Use an **abstract base class** for consistency. + +```csharp +namespace ColaFlow.Modules.Identity.Domain.Aggregates.SecurityTokens; + +/// +/// Abstract base class for all time-limited security tokens. +/// Enforces consistent token hashing and expiration logic. +/// +public abstract class SecurityToken : Entity +{ + /// + /// SHA-256 hash of the actual token (never store plaintext token). + /// + public string TokenHash { get; protected set; } = string.Empty; + + /// + /// Token expiration timestamp (UTC). + /// + public DateTime ExpiresAt { get; protected set; } + + /// + /// Timestamp when token was created (UTC). + /// + public DateTime CreatedAt { get; protected set; } + + /// + /// Timestamp when token was used/consumed (UTC). Null if not used. + /// + public DateTime? UsedAt { get; protected set; } + + /// + /// User ID associated with this token. + /// + public UserId UserId { get; protected set; } = null!; + + /// + /// Check if token is expired. + /// + public bool IsExpired => DateTime.UtcNow > ExpiresAt; + + /// + /// Check if token has been used. + /// + public bool IsUsed => UsedAt.HasValue; + + /// + /// Check if token is valid (not expired and not used). + /// + public bool IsValid => !IsExpired && !IsUsed; + + /// + /// Mark token as used (prevents reuse). + /// + public void MarkAsUsed() + { + if (IsUsed) + throw new InvalidOperationException("Token has already been used"); + + if (IsExpired) + throw new InvalidOperationException("Cannot use expired token"); + + UsedAt = DateTime.UtcNow; + } + + /// + /// Validate provided token against stored hash. + /// + /// The plaintext token to validate + /// True if token matches hash + public bool ValidateToken(string providedToken) + { + if (string.IsNullOrWhiteSpace(providedToken)) + return false; + + var providedHash = HashToken(providedToken); + return TokenHash == providedHash; + } + + /// + /// Hash a token using SHA-256. + /// + protected static string HashToken(string token) + { + using var sha256 = SHA256.Create(); + var bytes = Encoding.UTF8.GetBytes(token); + var hash = sha256.ComputeHash(bytes); + return Convert.ToBase64String(hash); + } + + /// + /// Generate a cryptographically secure random token (256-bit, base64url-encoded). + /// + protected static string GenerateToken() + { + var randomBytes = new byte[32]; // 256 bits + using var rng = RandomNumberGenerator.Create(); + rng.GetBytes(randomBytes); + + // Base64URL encoding (URL-safe, no padding) + return Convert.ToBase64String(randomBytes) + .Replace("+", "-") + .Replace("/", "_") + .TrimEnd('='); + } +} +``` + +**Design Rationale**: +- **Inheritance over Duplication**: 3 token types share 80% of logic +- **Immutability**: Protected setters prevent external modification +- **Validation**: Centralized expiration/usage checks +- **Security**: Token generation and hashing encapsulated + +### 3.3 Domain Entities + +#### EmailVerificationToken + +```csharp +namespace ColaFlow.Modules.Identity.Domain.Aggregates.SecurityTokens; + +/// +/// Email verification token entity. +/// Lifetime: 24 hours. +/// +public sealed class EmailVerificationToken : SecurityToken +{ + /// + /// Email address being verified. + /// + public Email Email { get; private set; } = null!; + + /// + /// Tenant ID for multi-tenant isolation. + /// + public TenantId TenantId { get; private set; } = null!; + + // EF Core constructor + private EmailVerificationToken() : base() { } + + /// + /// Factory method to create new verification token. + /// + /// Tuple of (entity, plaintext token for email) + public static (EmailVerificationToken entity, string plaintextToken) Create( + UserId userId, + Email email, + TenantId tenantId) + { + var plaintextToken = GenerateToken(); + var tokenHash = HashToken(plaintextToken); + + var entity = new EmailVerificationToken + { + Id = Guid.NewGuid(), + UserId = userId, + Email = email, + TenantId = tenantId, + TokenHash = tokenHash, + ExpiresAt = DateTime.UtcNow.AddHours(24), + CreatedAt = DateTime.UtcNow + }; + + return (entity, plaintextToken); + } +} +``` + +#### PasswordResetToken + +```csharp +/// +/// Password reset token entity. +/// Lifetime: 1 hour. +/// +public sealed class PasswordResetToken : SecurityToken +{ + public Email Email { get; private set; } = null!; + public TenantId TenantId { get; private set; } = null!; + + // EF Core constructor + private PasswordResetToken() : base() { } + + public static (PasswordResetToken entity, string plaintextToken) Create( + UserId userId, + Email email, + TenantId tenantId) + { + var plaintextToken = GenerateToken(); + var tokenHash = HashToken(plaintextToken); + + var entity = new PasswordResetToken + { + Id = Guid.NewGuid(), + UserId = userId, + Email = email, + TenantId = tenantId, + TokenHash = tokenHash, + ExpiresAt = DateTime.UtcNow.AddHours(1), // Shorter for security + CreatedAt = DateTime.UtcNow + }; + + return (entity, plaintextToken); + } +} +``` + +#### Invitation + +```csharp +/// +/// User invitation entity. +/// Lifetime: 7 days. +/// +public sealed class Invitation : SecurityToken +{ + public Email InviteeEmail { get; private set; } = null!; + public TenantId TenantId { get; private set; } = null!; + public TenantRole AssignedRole { get; private set; } + public UserId InvitedBy { get; private set; } = null!; + public InvitationStatus Status { get; private set; } + + // EF Core constructor + private Invitation() : base() { } + + public static (Invitation entity, string plaintextToken) Create( + Email inviteeEmail, + TenantId tenantId, + TenantRole assignedRole, + UserId invitedBy) + { + // Validate role (cannot invite as TenantOwner or AIAgent) + if (assignedRole == TenantRole.TenantOwner || assignedRole == TenantRole.AIAgent) + throw new ArgumentException($"Cannot invite user with role {assignedRole}"); + + var plaintextToken = GenerateToken(); + var tokenHash = HashToken(plaintextToken); + + var entity = new Invitation + { + Id = Guid.NewGuid(), + UserId = UserId.Empty, // Will be set when accepted + InviteeEmail = inviteeEmail, + TenantId = tenantId, + AssignedRole = assignedRole, + InvitedBy = invitedBy, + TokenHash = tokenHash, + Status = InvitationStatus.Pending, + ExpiresAt = DateTime.UtcNow.AddDays(7), + CreatedAt = DateTime.UtcNow + }; + + return (entity, plaintextToken); + } + + public void Accept(UserId userId) + { + if (Status != InvitationStatus.Pending) + throw new InvalidOperationException($"Invitation is {Status}, cannot accept"); + + MarkAsUsed(); + UserId = userId; + Status = InvitationStatus.Accepted; + } + + public void Cancel() + { + if (Status != InvitationStatus.Pending) + throw new InvalidOperationException($"Invitation is {Status}, cannot cancel"); + + Status = InvitationStatus.Canceled; + } +} + +public enum InvitationStatus +{ + Pending = 0, + Accepted = 1, + Canceled = 2, + Expired = 3 +} +``` + +### 3.4 Repository Interfaces + +```csharp +namespace ColaFlow.Modules.Identity.Domain.Repositories; + +public interface IEmailVerificationTokenRepository +{ + Task GetByTokenHashAsync( + string tokenHash, + CancellationToken cancellationToken = default); + + Task GetActiveByUserIdAsync( + UserId userId, + CancellationToken cancellationToken = default); + + Task AddAsync( + EmailVerificationToken token, + CancellationToken cancellationToken = default); + + Task UpdateAsync( + EmailVerificationToken token, + CancellationToken cancellationToken = default); +} + +public interface IPasswordResetTokenRepository +{ + Task GetByTokenHashAsync( + string tokenHash, + CancellationToken cancellationToken = default); + + Task InvalidateAllByUserIdAsync( + UserId userId, + CancellationToken cancellationToken = default); + + Task AddAsync( + PasswordResetToken token, + CancellationToken cancellationToken = default); + + Task UpdateAsync( + PasswordResetToken token, + CancellationToken cancellationToken = default); +} + +public interface IInvitationRepository +{ + Task GetByTokenHashAsync( + string tokenHash, + CancellationToken cancellationToken = default); + + Task GetByIdAsync( + Guid invitationId, + CancellationToken cancellationToken = default); + + Task> GetAllByTenantAsync( + TenantId tenantId, + int pageNumber, + int pageSize, + InvitationStatus? status = null, + CancellationToken cancellationToken = default); + + Task CountByTenantAsync( + TenantId tenantId, + InvitationStatus? status = null, + CancellationToken cancellationToken = default); + + Task AddAsync( + Invitation invitation, + CancellationToken cancellationToken = default); + + Task UpdateAsync( + Invitation invitation, + CancellationToken cancellationToken = default); +} +``` + +### 3.5 Command/Query Structure + +#### Commands + +```csharp +// Email Verification +public record VerifyEmailCommand(string Token) : IRequest; +public record ResendVerificationEmailCommand(string TenantSlug, string Email) : IRequest; + +// Password Reset +public record ForgotPasswordCommand(string TenantSlug, string Email) : IRequest; +public record ResetPasswordCommand(string Token, string NewPassword) : IRequest; + +// User Invitation +public record InviteUserCommand( + Guid TenantId, + string Email, + TenantRole Role) : IRequest>; + +public record AcceptInvitationCommand( + string Token, + string FullName, + string Password) : IRequest>; + +public record CancelInvitationCommand( + Guid TenantId, + Guid InvitationId) : IRequest; +``` + +#### Queries + +```csharp +public record GetTenantInvitationsQuery( + Guid TenantId, + int PageNumber = 1, + int PageSize = 20, + InvitationStatus? Status = null) : IRequest>>; + +public record GetInvitationByTokenQuery( + string Token) : IRequest>; +``` + +--- + +## 4. Database Schema Design + +### 4.1 New Tables + +#### email_verification_tokens + +```sql +CREATE TABLE email_verification_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL, + tenant_id UUID NOT NULL, + email VARCHAR(255) NOT NULL, + token_hash VARCHAR(64) NOT NULL, -- SHA-256 base64 + expires_at TIMESTAMP NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + used_at TIMESTAMP NULL, + + CONSTRAINT fk_email_verification_tokens_user + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT fk_email_verification_tokens_tenant + FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE +); + +-- Indexes for performance +CREATE INDEX idx_email_verification_tokens_token_hash + ON email_verification_tokens(token_hash); + +CREATE INDEX idx_email_verification_tokens_user_id + ON email_verification_tokens(user_id) + WHERE used_at IS NULL AND expires_at > NOW(); + +CREATE INDEX idx_email_verification_tokens_expires_at + ON email_verification_tokens(expires_at) + WHERE used_at IS NULL; +``` + +**Design Notes**: +- **token_hash**: Indexed for O(1) lookup during verification +- **Partial index on user_id**: Only active tokens (performance optimization) +- **Cascade delete**: Remove tokens when user/tenant deleted +- **Expiration index**: Efficient cleanup of expired tokens + +#### password_reset_tokens + +```sql +CREATE TABLE password_reset_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL, + tenant_id UUID NOT NULL, + email VARCHAR(255) NOT NULL, + token_hash VARCHAR(64) NOT NULL, + expires_at TIMESTAMP NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + used_at TIMESTAMP NULL, + + CONSTRAINT fk_password_reset_tokens_user + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT fk_password_reset_tokens_tenant + FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE +); + +-- Indexes +CREATE INDEX idx_password_reset_tokens_token_hash + ON password_reset_tokens(token_hash); + +CREATE INDEX idx_password_reset_tokens_user_id + ON password_reset_tokens(user_id) + WHERE used_at IS NULL AND expires_at > NOW(); + +CREATE INDEX idx_password_reset_tokens_expires_at + ON password_reset_tokens(expires_at) + WHERE used_at IS NULL; +``` + +#### invitations + +```sql +CREATE TABLE invitations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + invitee_email VARCHAR(255) NOT NULL, + assigned_role VARCHAR(50) NOT NULL, -- TenantAdmin, Developer, Guest + invited_by_user_id UUID NOT NULL, + token_hash VARCHAR(64) NOT NULL, + status INT NOT NULL DEFAULT 0, -- 0=Pending, 1=Accepted, 2=Canceled, 3=Expired + user_id UUID NULL, -- Set when accepted + expires_at TIMESTAMP NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + used_at TIMESTAMP NULL, + + CONSTRAINT fk_invitations_tenant + FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE, + CONSTRAINT fk_invitations_invited_by + FOREIGN KEY (invited_by_user_id) REFERENCES users(id) ON DELETE RESTRICT, + CONSTRAINT fk_invitations_user + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT ck_invitations_role + CHECK (assigned_role IN ('TenantAdmin', 'Developer', 'Guest')) +); + +-- Indexes +CREATE INDEX idx_invitations_token_hash + ON invitations(token_hash); + +CREATE INDEX idx_invitations_tenant_id_status + ON invitations(tenant_id, status); + +CREATE UNIQUE INDEX idx_invitations_unique_pending + ON invitations(tenant_id, invitee_email) + WHERE status = 0 AND expires_at > NOW(); + +CREATE INDEX idx_invitations_expires_at + ON invitations(expires_at) + WHERE status = 0; +``` + +**Design Notes**: +- **Unique constraint**: Prevent duplicate pending invitations for same email +- **RESTRICT on invited_by**: Prevent deletion of user who sent invitations +- **CHECK constraint**: Enforce valid roles at database level + +### 4.2 Schema Changes to Existing Tables + +#### users table modifications + +```sql +-- Add email verification timestamp (if not already exists) +ALTER TABLE users + ADD COLUMN IF NOT EXISTS email_verified_at TIMESTAMP NULL; + +-- Remove deprecated columns (if they exist on User entity) +ALTER TABLE users + DROP COLUMN IF EXISTS email_verification_token, + DROP COLUMN IF EXISTS password_reset_token, + DROP COLUMN IF EXISTS password_reset_token_expires_at; +``` + +**Migration Strategy**: +1. Check existing User entity for deprecated columns +2. Create migration to drop them (data loss acceptable for Day 7, as these were never used) +3. Add `email_verified_at` if missing + +### 4.3 EF Core Entity Configurations + +#### EmailVerificationTokenConfiguration + +```csharp +namespace ColaFlow.Modules.Identity.Infrastructure.Persistence.Configurations; + +public class EmailVerificationTokenConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("email_verification_tokens"); + + builder.HasKey(t => t.Id); + builder.Property(t => t.Id).HasColumnName("id"); + + builder.Property(t => t.TokenHash) + .HasColumnName("token_hash") + .HasMaxLength(64) + .IsRequired(); + + builder.Property(t => t.ExpiresAt) + .HasColumnName("expires_at") + .IsRequired(); + + builder.Property(t => t.CreatedAt) + .HasColumnName("created_at") + .IsRequired(); + + builder.Property(t => t.UsedAt) + .HasColumnName("used_at"); + + // Value Objects + builder.Property(t => t.UserId) + .HasColumnName("user_id") + .HasConversion( + id => id.Value, + value => UserId.Create(value)) + .IsRequired(); + + builder.Property(t => t.TenantId) + .HasColumnName("tenant_id") + .HasConversion( + id => id.Value, + value => TenantId.Create(value)) + .IsRequired(); + + builder.Property(t => t.Email) + .HasColumnName("email") + .HasMaxLength(255) + .HasConversion( + email => email.Value, + value => Email.Create(value).Value) + .IsRequired(); + + // Relationships + builder.HasOne() + .WithMany() + .HasForeignKey(t => t.UserId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasOne() + .WithMany() + .HasForeignKey(t => t.TenantId) + .OnDelete(DeleteBehavior.Cascade); + + // Indexes + builder.HasIndex(t => t.TokenHash) + .HasDatabaseName("idx_email_verification_tokens_token_hash"); + + builder.HasIndex(t => t.UserId) + .HasDatabaseName("idx_email_verification_tokens_user_id") + .HasFilter("used_at IS NULL AND expires_at > NOW()"); + } +} +``` + +**Note**: Repeat similar configurations for `PasswordResetTokenConfiguration` and `InvitationConfiguration`. + +### 4.4 Migration Approach + +```bash +# Create migration +cd src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure +dotnet ef migrations add Day7_EmailAndInvitations --context IdentityDbContext --output-dir Persistence/Migrations + +# Review generated migration +# Ensure it includes: +# - CREATE TABLE for 3 new tables +# - CREATE INDEX for all specified indexes +# - ALTER TABLE users DROP COLUMN (deprecated token fields) +# - ALTER TABLE users ADD COLUMN email_verified_at + +# Apply migration +dotnet ef database update --context IdentityDbContext +``` + +--- + +## 5. Security Architecture (ADRs) + +### ADR-013: Token Hashing with SHA-256 + +**Status**: Accepted +**Date**: 2025-11-03 +**Context**: We need to securely store security tokens (email verification, password reset, invitations) in the database. + +**Decision**: Use SHA-256 for hashing tokens before database storage. + +**Rationale**: + +| Aspect | SHA-256 | BCrypt | PBKDF2 | Decision | +|--------|---------|--------|--------|----------| +| **Purpose** | Token hashing | Password hashing | Password hashing | βœ… SHA-256 | +| **Speed** | Very fast (~500K ops/sec) | Slow (12 rounds = ~50 ops/sec) | Medium | Fast lookup needed | +| **Collision Resistance** | Excellent (256-bit) | N/A | N/A | Tokens are random | +| **Rainbow Table Resistance** | Not applicable (tokens are random, not user-chosen) | Excellent | Excellent | Not a concern | +| **Use Case Fit** | βœ… Time-limited random tokens | ❌ User passwords | ❌ User passwords | βœ… | + +**Why NOT BCrypt?** +- BCrypt is designed for **slow** hashing (defense against brute force) +- Tokens are **already cryptographically random** (256-bit entropy) +- Token expiration provides time-bound security +- Performance: Token lookups happen on every verification/reset request + +**Implementation**: +```csharp +protected static string HashToken(string token) +{ + using var sha256 = SHA256.Create(); + var bytes = Encoding.UTF8.GetBytes(token); + var hash = sha256.ComputeHash(bytes); + return Convert.ToBase64String(hash); // 44 characters +} +``` + +**Security Properties**: +- **One-way**: Cannot reverse hash to plaintext token +- **Deterministic**: Same token always produces same hash (lookup possible) +- **Fast**: O(1) database lookup with indexed `token_hash` column +- **Collision-resistant**: 2^256 possible hashes + +**Consequences**: +- βœ… High-performance token validation +- βœ… Database breach doesn't expose plaintext tokens +- βœ… Simple implementation (no salt management) +- ⚠️ Tokens must have high entropy (256-bit random) +- ⚠️ Must enforce short expiration windows + +--- + +### ADR-014: Email Enumeration Prevention + +**Status**: Accepted +**Date**: 2025-11-03 +**Context**: Password reset and email verification endpoints could reveal if an email exists in the system. + +**Decision**: Always return success (200 OK) regardless of whether the email exists. + +**Vulnerability Scenario**: +``` +❌ Bad Implementation: +POST /api/auth/forgot-password { "email": "admin@victim.com" } +β†’ 404 Not Found: "Email not found" + +Attacker learns: admin@victim.com is NOT a registered user + +βœ… Good Implementation: +POST /api/auth/forgot-password { "email": "admin@victim.com" } +β†’ 200 OK: "If an account exists, a reset link has been sent" + +Attacker learns: Nothing +``` + +**Implementation Pattern**: +```csharp +public async Task Handle(ForgotPasswordCommand command, CancellationToken ct) +{ + // 1. Look up user (internal logic) + var user = await _userRepository.GetByEmailAsync(tenantId, email, ct); + + // 2. If user exists, send email (internal) + if (user is not null) + { + var (token, plaintextToken) = PasswordResetToken.Create(user.Id, email, tenantId); + await _tokenRepository.AddAsync(token, ct); + await _emailService.SendPasswordResetEmailAsync(email, plaintextToken, ct); + } + + // 3. ALWAYS return success (same response for exists/not-exists) + return Result.Success("If an account exists, a reset link has been sent."); +} +``` + +**Mitigation Checklist**: +- βœ… Same HTTP status code (200) for exists/not-exists +- βœ… Same response message (generic) +- βœ… Same response time (no timing attacks) +- βœ… Rate limiting (prevent mass enumeration) +- βœ… Audit logging (detect enumeration attempts) + +**Trade-offs**: +- βœ… Prevents user enumeration attacks +- βœ… Improves privacy (attackers can't harvest email lists) +- ⚠️ Slightly worse UX (user doesn't know if email was wrong) +- ⚠️ Support burden (users may not realize they used wrong email) + +**Related Security Measures**: +- Rate limiting: Max 3 requests per email per hour +- Honeypot emails: Log suspicious patterns (e.g., 100 different emails from same IP) + +--- + +### ADR-015: Rate Limiting Strategy + +**Status**: Accepted +**Date**: 2025-11-03 +**Context**: Email endpoints are vulnerable to abuse (spam, enumeration, DoS). + +**Decision**: Implement multi-layer rate limiting with different strategies per endpoint. + +#### Rate Limiting Tiers + +| Endpoint | Limit | Window | Scope | Rationale | +|----------|-------|--------|-------|-----------| +| **Verification Email** | 3 requests | 1 hour | Per email | Prevent spam, normal user needs 1-2 max | +| **Password Reset** | 3 requests | 1 hour | Per email | Prevent enumeration, balance security vs UX | +| **User Invitation** | 20 invitations | 1 hour | Per tenant | Prevent bulk spam, allow team onboarding | +| **Accept Invitation** | 5 attempts | 15 minutes | Per token | Prevent brute force (token is 256-bit, near impossible) | + +#### Implementation: In-Memory + Redis Hybrid + +**Phase 1 (Day 7)**: In-memory rate limiting with `MemoryCache` +```csharp +public class RateLimitingService +{ + private readonly IMemoryCache _cache; + + public async Task IsAllowedAsync(string key, int maxRequests, TimeSpan window) + { + var cacheKey = $"ratelimit:{key}"; + + if (_cache.TryGetValue(cacheKey, out int count)) + { + if (count >= maxRequests) + return false; + + _cache.Set(cacheKey, count + 1, window); + return true; + } + + _cache.Set(cacheKey, 1, window); + return true; + } +} +``` + +**Phase 2 (Post-Day 7)**: Redis-based distributed rate limiting +```csharp +// Use Redis INCR with expiration +public async Task IsAllowedAsync(string key, int maxRequests, TimeSpan window) +{ + var redisKey = $"ratelimit:{key}"; + var count = await _redis.StringIncrementAsync(redisKey); + + if (count == 1) + await _redis.KeyExpireAsync(redisKey, window); + + return count <= maxRequests; +} +``` + +#### Integration with Commands + +```csharp +public class ForgotPasswordCommandHandler : IRequestHandler +{ + private readonly IRateLimitingService _rateLimiter; + + public async Task Handle(ForgotPasswordCommand command, CancellationToken ct) + { + // Rate limit check + var rateLimitKey = $"forgot-password:{command.Email}"; + if (!await _rateLimiter.IsAllowedAsync(rateLimitKey, maxRequests: 3, TimeSpan.FromHours(1))) + { + return Result.Failure("Too many password reset requests. Please try again later."); + } + + // ... rest of handler logic + } +} +``` + +**Consequences**: +- βœ… Prevents abuse (spam, DoS, enumeration) +- βœ… Low latency (in-memory for Day 7) +- βœ… Scalable (Redis for distributed systems) +- ⚠️ In-memory limits don't work across multiple API instances (acceptable for Day 7) +- ⚠️ Requires Redis for production multi-instance deployments + +--- + +## 6. Integration Architecture + +### 6.1 Email Service Integration Points + +#### RegisterTenantCommandHandler (Modified) + +```csharp +public class RegisterTenantCommandHandler : IRequestHandler> +{ + private readonly IEmailService _emailService; + private readonly IEmailVerificationTokenRepository _tokenRepository; + // ... other dependencies + + public async Task> Handle( + RegisterTenantCommand command, + CancellationToken ct) + { + // 1. Create tenant and admin user (existing logic) + var tenant = Tenant.Create(...); + var adminUser = User.CreateLocal(...); + await _tenantRepository.AddAsync(tenant, ct); + await _userRepository.AddAsync(adminUser, ct); + + // 2. Generate verification token (NEW) + var (verificationToken, plaintextToken) = EmailVerificationToken.Create( + adminUser.Id, + adminUser.Email, + tenant.Id); + + await _tokenRepository.AddAsync(verificationToken, ct); + + // 3. Send verification email (NEW - non-blocking) + var emailResult = await _emailService.SendEmailAsync(new EmailMessage( + ToAddress: adminUser.Email.Value, + ToName: adminUser.FullName.Value, + Subject: "Verify your email - ColaFlow", + TextBody: $"Click here to verify: https://app.colaflow.io/verify-email?token={plaintextToken}", + HtmlBody: RenderVerificationEmailHtml(adminUser.FullName.Value, plaintextToken) + ), ct); + + // 4. Log email failure but don't block registration + if (!emailResult.Success) + { + _logger.LogWarning( + "Failed to send verification email to {Email}: {Error}", + adminUser.Email.Value, + emailResult.ErrorMessage); + } + + // 5. Return success (even if email failed) + return Result.Success(MapToDto(tenant, adminUser, jwtToken)); + } +} +``` + +**Key Design Decision**: **Email failure is non-blocking** +- βœ… User registration succeeds even if SendGrid is down +- βœ… User can still login (verification is optional for Day 7) +- βœ… User can request resend later + +#### ForgotPasswordCommandHandler (New) + +```csharp +public class ForgotPasswordCommandHandler : IRequestHandler +{ + public async Task Handle(ForgotPasswordCommand command, CancellationToken ct) + { + // 1. Rate limiting + if (!await _rateLimiter.IsAllowedAsync($"forgot-password:{command.Email}", 3, TimeSpan.FromHours(1))) + return Result.Failure("Too many requests. Try again in 1 hour."); + + // 2. Lookup tenant and user + var tenant = await _tenantRepository.GetBySlugAsync(command.TenantSlug, ct); + if (tenant is null) + return Result.Success("If an account exists, a reset link has been sent."); // Enumerate protection + + var email = Email.Create(command.Email).Value; + var user = await _userRepository.GetByEmailAsync(tenant.Id, email, ct); + if (user is null) + return Result.Success("If an account exists, a reset link has been sent."); // Enumerate protection + + // 3. Invalidate old reset tokens + await _resetTokenRepository.InvalidateAllByUserIdAsync(user.Id, ct); + + // 4. Create new reset token + var (resetToken, plaintextToken) = PasswordResetToken.Create(user.Id, email, tenant.Id); + await _resetTokenRepository.AddAsync(resetToken, ct); + + // 5. Send reset email + await _emailService.SendEmailAsync(new EmailMessage( + ToAddress: email.Value, + ToName: user.FullName.Value, + Subject: "Reset your password - ColaFlow", + TextBody: $"Reset link: https://app.colaflow.io/reset-password?token={plaintextToken}", + HtmlBody: RenderPasswordResetEmailHtml(user.FullName.Value, plaintextToken) + ), ct); + + // 6. Always return success (enumerate protection) + return Result.Success("If an account exists, a reset link has been sent."); + } +} +``` + +#### InviteUserCommandHandler (New) + +```csharp +public class InviteUserCommandHandler : IRequestHandler> +{ + public async Task> Handle(InviteUserCommand command, CancellationToken ct) + { + // 1. Authorization check (must be TenantOwner or TenantAdmin) + var currentUser = await _currentUserService.GetCurrentUserAsync(ct); + var role = await _roleRepository.GetUserRoleAsync(currentUser.Id, command.TenantId, ct); + if (role != TenantRole.TenantOwner && role != TenantRole.TenantAdmin) + return Result.Failure("Insufficient permissions"); + + // 2. Validate tenant ownership + if (currentUser.TenantId != command.TenantId) + return Result.Failure("Cross-tenant invitation not allowed"); + + // 3. Validate email not already member + var email = Email.Create(command.Email).Value; + if (await _userRepository.ExistsByEmailAsync(command.TenantId, email, ct)) + return Result.Failure("User already exists in this tenant"); + + // 4. Check for existing pending invitation + var existingInvitations = await _invitationRepository.GetAllByTenantAsync( + command.TenantId, 1, 100, InvitationStatus.Pending, ct); + + if (existingInvitations.Any(i => i.InviteeEmail == email && i.IsValid)) + return Result.Failure("A pending invitation already exists for this email"); + + // 5. Create invitation + var (invitation, plaintextToken) = Invitation.Create( + email, + command.TenantId, + command.Role, + currentUser.Id); + + await _invitationRepository.AddAsync(invitation, ct); + + // 6. Send invitation email + var tenant = await _tenantRepository.GetByIdAsync(command.TenantId, ct); + await _emailService.SendEmailAsync(new EmailMessage( + ToAddress: email.Value, + ToName: email.Value, // We don't know their name yet + Subject: $"You're invited to join {tenant.Name} on ColaFlow", + TextBody: $"Accept invitation: https://app.colaflow.io/accept-invitation?token={plaintextToken}", + HtmlBody: RenderInvitationEmailHtml(tenant.Name, currentUser.FullName.Value, command.Role, plaintextToken) + ), ct); + + // 7. Return invitation details + return Result.Success(MapToDto(invitation)); + } +} +``` + +### 6.2 Domain Events Integration + +Day 7 introduces new domain events for audit logging and future integrations: + +```csharp +// New Events +public record EmailVerifiedEvent(UserId UserId, Email Email, TenantId TenantId, DateTime VerifiedAt); +public record PasswordResetRequestedEvent(UserId UserId, Email Email, TenantId TenantId, string IpAddress); +public record PasswordResetCompletedEvent(UserId UserId, TenantId TenantId); +public record UserInvitedEvent(Guid InvitationId, Email InviteeEmail, TenantId TenantId, UserId InvitedBy, TenantRole Role); +public record InvitationAcceptedEvent(Guid InvitationId, UserId NewUserId, TenantId TenantId); +``` + +**Event Handlers** (for audit logging): +```csharp +public class EmailVerifiedEventHandler : INotificationHandler +{ + private readonly IAuditLogService _auditLog; + + public async Task Handle(EmailVerifiedEvent @event, CancellationToken ct) + { + await _auditLog.LogAsync(new AuditLogEntry( + EntityType: "User", + EntityId: @event.UserId.Value, + Action: "EmailVerified", + Details: $"Email {@event.Email.Value} verified", + TenantId: @event.TenantId.Value, + Timestamp: @event.VerifiedAt + ), ct); + } +} +``` + +### 6.3 API Endpoint Integration + +New endpoints to add to `AuthController`: + +```csharp +[ApiController] +[Route("api/auth")] +public class AuthController : ControllerBase +{ + // Email Verification + [HttpPost("verify-email")] + [AllowAnonymous] + public async Task VerifyEmail( + [FromBody] VerifyEmailRequest request, + CancellationToken ct) + { + var command = new VerifyEmailCommand(request.Token); + var result = await _mediator.Send(command, ct); + return result.IsSuccess ? Ok(result.Value) : BadRequest(result.Error); + } + + [HttpPost("resend-verification")] + [AllowAnonymous] + public async Task ResendVerification( + [FromBody] ResendVerificationRequest request, + CancellationToken ct) + { + var command = new ResendVerificationEmailCommand(request.TenantSlug, request.Email); + var result = await _mediator.Send(command, ct); + return Ok(new { message = "If an account exists, a verification email has been sent." }); + } + + // Password Reset + [HttpPost("forgot-password")] + [AllowAnonymous] + public async Task ForgotPassword( + [FromBody] ForgotPasswordRequest request, + CancellationToken ct) + { + var command = new ForgotPasswordCommand(request.TenantSlug, request.Email); + var result = await _mediator.Send(command, ct); + return Ok(new { message = "If an account exists, a reset link has been sent." }); + } + + [HttpPost("reset-password")] + [AllowAnonymous] + public async Task ResetPassword( + [FromBody] ResetPasswordRequest request, + CancellationToken ct) + { + var command = new ResetPasswordCommand(request.Token, request.NewPassword); + var result = await _mediator.Send(command, ct); + return result.IsSuccess ? Ok(result.Value) : BadRequest(result.Error); + } +} +``` + +New controller for invitations: + +```csharp +[ApiController] +[Route("api/tenants/{tenantId}/invitations")] +[Authorize] +public class InvitationsController : ControllerBase +{ + [HttpPost] + [RequireTenantOwner] // Custom authorization policy + public async Task InviteUser( + [FromRoute] Guid tenantId, + [FromBody] InviteUserRequest request, + CancellationToken ct) + { + var command = new InviteUserCommand(tenantId, request.Email, request.Role); + var result = await _mediator.Send(command, ct); + return result.IsSuccess ? CreatedAtAction(nameof(GetInvitation), new { id = result.Value.Id }, result.Value) : BadRequest(result.Error); + } + + [HttpGet] + public async Task GetInvitations( + [FromRoute] Guid tenantId, + [FromQuery] int pageNumber = 1, + [FromQuery] int pageSize = 20, + [FromQuery] InvitationStatus? status = null, + CancellationToken ct) + { + var query = new GetTenantInvitationsQuery(tenantId, pageNumber, pageSize, status); + var result = await _mediator.Send(query, ct); + return result.IsSuccess ? Ok(result.Value) : BadRequest(result.Error); + } + + [HttpDelete("{invitationId}")] + public async Task CancelInvitation( + [FromRoute] Guid tenantId, + [FromRoute] Guid invitationId, + CancellationToken ct) + { + var command = new CancelInvitationCommand(tenantId, invitationId); + var result = await _mediator.Send(command, ct); + return result.IsSuccess ? NoContent() : BadRequest(result.Error); + } +} + +[ApiController] +[Route("api/invitations")] +public class PublicInvitationsController : ControllerBase +{ + [HttpPost("accept")] + [AllowAnonymous] + public async Task AcceptInvitation( + [FromBody] AcceptInvitationRequest request, + CancellationToken ct) + { + var command = new AcceptInvitationCommand(request.Token, request.FullName, request.Password); + var result = await _mediator.Send(command, ct); + return result.IsSuccess ? Ok(result.Value) : BadRequest(result.Error); + } +} +``` + +--- + +## 7. Implementation Phases + +### Phase 1: Email Infrastructure (Priority P0) +**Duration**: 4-6 hours +**Goal**: Get email sending working end-to-end + +**Tasks**: +1. βœ… Create `IEmailService` interface +2. βœ… Implement `SendGridEmailService` +3. βœ… Implement `SmtpEmailService` +4. βœ… Implement `MockEmailService` (for tests) +5. βœ… Add email configuration to `appsettings.json` +6. βœ… Create email templates (simple string interpolation for Day 7) +7. βœ… Register services in DI container +8. βœ… Write unit tests for email services + +**Acceptance Criteria**: +- [ ] Email service can send test email via SendGrid +- [ ] Email service can send test email via SMTP (MailHog) +- [ ] Mock service logs emails to file in development +- [ ] Configuration is environment-specific + +**Testing**: +```bash +# Start MailHog (Docker) +docker run -d -p 1025:1025 -p 8025:8025 mailhog/mailhog + +# Test SMTP send +curl -X POST http://localhost:5167/api/test/send-email \ + -H "Content-Type: application/json" \ + -d '{"to": "test@example.com", "subject": "Test", "body": "Hello"}' + +# Check MailHog UI: http://localhost:8025 +``` + +--- + +### Phase 2: Email Verification Flow (Priority P0) +**Duration**: 6-8 hours +**Goal**: Users can verify their email addresses + +**Tasks**: +1. βœ… Create `SecurityToken` base class +2. βœ… Create `EmailVerificationToken` entity +3. βœ… Create `IEmailVerificationTokenRepository` interface and implementation +4. βœ… Create EF Core entity configuration +5. βœ… Generate and apply database migration +6. βœ… Create `VerifyEmailCommand` and handler +7. βœ… Create `ResendVerificationEmailCommand` and handler +8. βœ… Modify `RegisterTenantCommandHandler` to send verification email +9. βœ… Add `/api/auth/verify-email` endpoint +10. βœ… Add `/api/auth/resend-verification` endpoint +11. βœ… Write integration tests + +**Acceptance Criteria**: +- [ ] Registration sends verification email +- [ ] User can verify email with token +- [ ] Invalid/expired tokens return 400 +- [ ] Resend works and invalidates old token +- [ ] Email enumeration is prevented + +**Testing**: +```powershell +# 1. Register tenant +$regResponse = Invoke-RestMethod -Uri "http://localhost:5167/api/tenants/register" ` + -Method Post -ContentType "application/json" ` + -Body '{"tenantName":"Test","tenantSlug":"test","adminEmail":"admin@test.com","adminPassword":"Admin@123","adminFullName":"Admin"}' + +# 2. Extract token from email logs (in development mode) +$token = "...token-from-log..." + +# 3. Verify email +Invoke-RestMethod -Uri "http://localhost:5167/api/auth/verify-email" ` + -Method Post -ContentType "application/json" ` + -Body "{`"token`":`"$token`"}" + +# 4. Test resend +Invoke-RestMethod -Uri "http://localhost:5167/api/auth/resend-verification" ` + -Method Post -ContentType "application/json" ` + -Body '{"tenantSlug":"test","email":"admin@test.com"}' +``` + +--- + +### Phase 3: Password Reset Flow (Priority P0) +**Duration**: 6-8 hours +**Goal**: Users can reset forgotten passwords + +**Tasks**: +1. βœ… Create `PasswordResetToken` entity +2. βœ… Create `IPasswordResetTokenRepository` interface and implementation +3. βœ… Create EF Core entity configuration +4. βœ… Generate and apply database migration +5. βœ… Create `ForgotPasswordCommand` and handler +6. βœ… Create `ResetPasswordCommand` and handler +7. βœ… Implement password complexity validation +8. βœ… Implement refresh token revocation on password reset +9. βœ… Add `/api/auth/forgot-password` endpoint +10. βœ… Add `/api/auth/reset-password` endpoint +11. βœ… Write integration tests + +**Acceptance Criteria**: +- [ ] User can request password reset +- [ ] Reset email is sent with valid token +- [ ] User can reset password with token +- [ ] Password complexity is enforced +- [ ] All refresh tokens are invalidated on reset +- [ ] Used tokens cannot be reused +- [ ] Email enumeration is prevented + +**Testing**: +```powershell +# 1. Request password reset +Invoke-RestMethod -Uri "http://localhost:5167/api/auth/forgot-password" ` + -Method Post -ContentType "application/json" ` + -Body '{"tenantSlug":"test","email":"admin@test.com"}' + +# 2. Extract token from email +$resetToken = "...token-from-email..." + +# 3. Reset password +Invoke-RestMethod -Uri "http://localhost:5167/api/auth/reset-password" ` + -Method Post -ContentType "application/json" ` + -Body "{`"token`":`"$resetToken`",`"newPassword`":`"NewPassword@123`"}" + +# 4. Login with new password +Invoke-RestMethod -Uri "http://localhost:5167/api/auth/login" ` + -Method Post -ContentType "application/json" ` + -Body '{"tenantSlug":"test","email":"admin@test.com","password":"NewPassword@123"}' +``` + +--- + +### Phase 4: User Invitation System (Priority P0) +**Duration**: 8-10 hours +**Goal**: Tenant owners can invite team members + +**Tasks**: +1. βœ… Create `Invitation` entity with `InvitationStatus` enum +2. βœ… Create `IInvitationRepository` interface and implementation +3. βœ… Create EF Core entity configuration +4. βœ… Generate and apply database migration +5. βœ… Create `InviteUserCommand` and handler +6. βœ… Create `AcceptInvitationCommand` and handler +7. βœ… Create `CancelInvitationCommand` and handler +8. βœ… Create `GetTenantInvitationsQuery` and handler +9. βœ… Create authorization policies (`RequireTenantOwner`, `RequireTenantAdmin`) +10. βœ… Add `/api/tenants/{id}/invitations` endpoints (POST, GET, DELETE) +11. βœ… Add `/api/invitations/accept` endpoint +12. βœ… Write integration tests (unblock 3 skipped tests) + +**Acceptance Criteria**: +- [ ] Tenant owner can invite user with role +- [ ] Invitation email is sent +- [ ] User can accept invitation and create account +- [ ] Accepting invitation auto-logs user in +- [ ] Cannot invite with TenantOwner or AIAgent role +- [ ] Cannot invite existing members +- [ ] Tenant owner can view pending invitations +- [ ] Tenant owner can cancel pending invitations +- [ ] 3 previously skipped integration tests now pass + +**Testing**: +```powershell +# 1. Login as tenant owner +$loginResponse = Invoke-RestMethod -Uri "http://localhost:5167/api/auth/login" ` + -Method Post -ContentType "application/json" ` + -Body '{"tenantSlug":"test","email":"admin@test.com","password":"Admin@123"}' +$token = $loginResponse.accessToken + +# 2. Invite user +$inviteResponse = Invoke-RestMethod -Uri "http://localhost:5167/api/tenants/{tenantId}/invitations" ` + -Method Post -ContentType "application/json" ` + -Headers @{ Authorization = "Bearer $token" } ` + -Body '{"email":"developer@test.com","role":"Developer"}' + +# 3. List invitations +$invitations = Invoke-RestMethod -Uri "http://localhost:5167/api/tenants/{tenantId}/invitations" ` + -Method Get -Headers @{ Authorization = "Bearer $token" } + +# 4. Extract token from email +$inviteToken = "...token-from-email..." + +# 5. Accept invitation +$acceptResponse = Invoke-RestMethod -Uri "http://localhost:5167/api/invitations/accept" ` + -Method Post -ContentType "application/json" ` + -Body "{`"token`":`"$inviteToken`",`"fullName`":`"John Developer`",`"password`":`"Dev@123`"}" + +# 6. Verify new user can login +Invoke-RestMethod -Uri "http://localhost:5167/api/auth/login" ` + -Method Post -ContentType "application/json" ` + -Body '{"tenantSlug":"test","email":"developer@test.com","password":"Dev@123"}' +``` + +--- + +### Phase 5: Polish & Testing (Priority P1) +**Duration**: 4-6 hours +**Goal**: Production readiness + +**Tasks**: +1. βœ… Add rate limiting service +2. βœ… Add comprehensive logging +3. βœ… Write email template HTML (upgrade from string interpolation) +4. βœ… Add password complexity validation +5. βœ… Write unit tests for all commands/queries +6. βœ… Write integration tests for all endpoints +7. βœ… Update API documentation (Swagger) +8. βœ… Test edge cases (expired tokens, invalid tokens, etc.) +9. βœ… Load test email sending (ensure SendGrid quota) +10. βœ… Update Day 7 implementation summary + +**Acceptance Criteria**: +- [ ] All tests passing (unit + integration) +- [ ] Code coverage >80% +- [ ] Rate limiting working +- [ ] Swagger docs updated +- [ ] Email templates production-ready +- [ ] No compiler warnings + +--- + +## 8. Deployment Considerations + +### 8.1 Configuration Management + +**appsettings.Development.json**: +```json +{ + "EmailSettings": { + "Provider": "Smtp", + "FromAddress": "noreply@colaflow.local", + "FromName": "ColaFlow Development", + "SaveEmailsToFile": true, + "EmailOutputPath": "temp/emails", + "Smtp": { + "Host": "localhost", + "Port": 1025, + "EnableSsl": false + } + } +} +``` + +**appsettings.Production.json**: +```json +{ + "EmailSettings": { + "Provider": "SendGrid", + "FromAddress": "noreply@colaflow.io", + "FromName": "ColaFlow", + "SendGrid": { + "ApiKey": "${SENDGRID_API_KEY}" // Injected from environment variable + } + } +} +``` + +**Environment Variables** (Production): +```bash +SENDGRID_API_KEY=SG.xxxxxxxxxxxxxxxxxxxxx +COLAFLOW_EMAIL_FROM=noreply@colaflow.io +ASPNETCORE_ENVIRONMENT=Production +``` + +### 8.2 Database Migration Strategy + +**Deployment Steps**: +```bash +# 1. Backup production database +pg_dump colaflow_prod > backup_before_day7.sql + +# 2. Apply migration (zero-downtime) +dotnet ef database update --context IdentityDbContext --connection "Host=prod-db;Database=colaflow_prod" + +# 3. Verify migration +psql colaflow_prod -c "\d email_verification_tokens" +psql colaflow_prod -c "\d password_reset_tokens" +psql colaflow_prod -c "\d invitations" + +# 4. Run smoke tests +curl -X POST https://api.colaflow.io/api/auth/forgot-password \ + -H "Content-Type: application/json" \ + -d '{"tenantSlug":"test","email":"test@example.com"}' +``` + +**Rollback Plan** (if issues occur): +```bash +# 1. Revert database migration +dotnet ef migrations remove --context IdentityDbContext + +# 2. Restore from backup +psql colaflow_prod < backup_before_day7.sql + +# 3. Redeploy previous API version +``` + +### 8.3 Monitoring & Alerting + +**Metrics to Monitor**: +| Metric | Threshold | Alert | +|--------|-----------|-------| +| Email send failure rate | >5% | Warning | +| Email send failure rate | >20% | Critical | +| Token validation failure rate | >10% | Warning | +| Password reset requests | >1000/hour | Suspicious activity | +| Invitation acceptance rate | <30% | Poor UX indicator | +| Token expiration before use | >50% | Expiration too short | + +**Logging Strategy**: +```csharp +// INFO level +_logger.LogInformation("Email sent to {Email} for {Purpose}", email, "EmailVerification"); + +// WARNING level +_logger.LogWarning("Email send failed to {Email}: {Error}", email, error); + +// ERROR level (only for unexpected exceptions) +_logger.LogError(ex, "Unexpected error in email service"); + +// AUDIT level (security events) +_auditLogger.Log("PASSWORD_RESET_REQUESTED", userId, tenantId, ipAddress); +``` + +### 8.4 Security Checklist + +**Pre-Production**: +- [ ] SendGrid API key stored in Azure Key Vault (not appsettings) +- [ ] HTTPS enforced for all email links +- [ ] Rate limiting enabled (Redis-based for multi-instance) +- [ ] Email enumeration protection verified +- [ ] Token expiration windows validated +- [ ] SQL injection prevention verified (parameterized queries) +- [ ] XSS prevention in email templates (HTML encoding) +- [ ] CORS configured correctly +- [ ] Audit logging enabled for all security events + +**Post-Deployment**: +- [ ] Monitor for unusual password reset patterns +- [ ] Monitor for email bounce rates +- [ ] Monitor for failed token validations +- [ ] Review security logs weekly + +--- + +## Summary + +### Key Deliverables + +| Component | Status | Priority | +|-----------|--------|----------| +| Email Service Abstraction | βœ… Designed | P0 | +| SendGrid Integration | βœ… Designed | P0 | +| Email Verification Flow | βœ… Designed | P0 | +| Password Reset Flow | βœ… Designed | P0 | +| User Invitation System | βœ… Designed | P0 | +| Database Schema (3 tables) | βœ… Designed | P0 | +| Security ADRs (3 ADRs) | βœ… Written | P0 | +| Integration Architecture | βœ… Defined | P0 | +| Implementation Phases | βœ… Planned | P0 | + +### Architecture Highlights + +1. **Abstraction Layer**: Email provider is pluggable (SendGrid/SMTP/Mock) +2. **Domain-Driven Design**: Security tokens are first-class domain entities with business logic +3. **Security First**: SHA-256 token hashing, email enumeration prevention, rate limiting +4. **Fail-Safe**: Email failures don't block user actions +5. **Scalability**: In-memory rate limiting for Day 7, Redis-ready for production + +### Next Steps + +1. **Backend Team**: Implement Phase 1-4 in order +2. **Frontend Team**: Build email verification/reset UI (Day 8) +3. **QA Team**: Prepare test scenarios for all 4 features +4. **DevOps Team**: Provision SendGrid account and configure secrets + +### Estimated Timeline + +- **Phase 1** (Email Infrastructure): 4-6 hours +- **Phase 2** (Email Verification): 6-8 hours +- **Phase 3** (Password Reset): 6-8 hours +- **Phase 4** (Invitations): 8-10 hours +- **Phase 5** (Polish): 4-6 hours + +**Total**: 28-38 hours (~4-5 working days for 1 backend engineer) + +--- + +**Document Status**: βœ… Ready for Implementation +**Last Updated**: 2025-11-03 +**Review Required**: Backend Lead, Security Team, Product Manager diff --git a/colaflow-api/src/ColaFlow.API/Controllers/AuthController.cs b/colaflow-api/src/ColaFlow.API/Controllers/AuthController.cs index 1838c9f..27bcb3d 100644 --- a/colaflow-api/src/ColaFlow.API/Controllers/AuthController.cs +++ b/colaflow-api/src/ColaFlow.API/Controllers/AuthController.cs @@ -1,5 +1,6 @@ using ColaFlow.API.Models; using ColaFlow.Modules.Identity.Application.Commands.Login; +using ColaFlow.Modules.Identity.Application.Commands.VerifyEmail; using ColaFlow.Modules.Identity.Application.Services; using MediatR; using Microsoft.AspNetCore.Authorization; @@ -148,6 +149,22 @@ public class AuthController( return BadRequest(new { message = "Logout failed" }); } } + + /// + /// Verify email address using token + /// + [HttpPost("verify-email")] + [AllowAnonymous] + public async Task VerifyEmail([FromBody] VerifyEmailRequest request) + { + var command = new VerifyEmailCommand(request.Token); + var success = await mediator.Send(command); + + if (!success) + return BadRequest(new { message = "Invalid or expired verification token" }); + + return Ok(new { message = "Email verified successfully" }); + } } public record LoginRequest( @@ -155,3 +172,5 @@ public record LoginRequest( string Email, string Password ); + +public record VerifyEmailRequest(string Token); diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/RegisterTenant/RegisterTenantCommandHandler.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/RegisterTenant/RegisterTenantCommandHandler.cs index c780c5c..346d323 100644 --- a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/RegisterTenant/RegisterTenantCommandHandler.cs +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/RegisterTenant/RegisterTenantCommandHandler.cs @@ -1,8 +1,10 @@ +using ColaFlow.Modules.Identity.Application.Commands.SendVerificationEmail; using ColaFlow.Modules.Identity.Application.Services; using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants; using ColaFlow.Modules.Identity.Domain.Aggregates.Users; using ColaFlow.Modules.Identity.Domain.Repositories; using MediatR; +using Microsoft.Extensions.Configuration; namespace ColaFlow.Modules.Identity.Application.Commands.RegisterTenant; @@ -12,7 +14,9 @@ public class RegisterTenantCommandHandler( IJwtService jwtService, IPasswordHasher passwordHasher, IRefreshTokenService refreshTokenService, - IUserTenantRoleRepository userTenantRoleRepository) + IUserTenantRoleRepository userTenantRoleRepository, + IMediator mediator, + IConfiguration configuration) : IRequestHandler { public async Task Handle( @@ -64,7 +68,17 @@ public class RegisterTenantCommandHandler( userAgent: null, cancellationToken); - // 6. Return result + // 7. Send verification email (non-blocking) + var baseUrl = configuration["App:BaseUrl"] ?? "http://localhost:3000"; + var sendEmailCommand = new SendVerificationEmailCommand( + adminUser.Id, + request.AdminEmail, + baseUrl); + + // Fire and forget - don't wait for email to send + _ = mediator.Send(sendEmailCommand, cancellationToken); + + // 8. Return result return new RegisterTenantResult( new Dtos.TenantDto { diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/SendVerificationEmail/SendVerificationEmailCommand.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/SendVerificationEmail/SendVerificationEmailCommand.cs new file mode 100644 index 0000000..9e1b195 --- /dev/null +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/SendVerificationEmail/SendVerificationEmailCommand.cs @@ -0,0 +1,9 @@ +using MediatR; + +namespace ColaFlow.Modules.Identity.Application.Commands.SendVerificationEmail; + +public sealed record SendVerificationEmailCommand( + Guid UserId, + string Email, + string BaseUrl +) : IRequest; diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/SendVerificationEmail/SendVerificationEmailCommandHandler.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/SendVerificationEmail/SendVerificationEmailCommandHandler.cs new file mode 100644 index 0000000..05cd565 --- /dev/null +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/SendVerificationEmail/SendVerificationEmailCommandHandler.cs @@ -0,0 +1,95 @@ +using ColaFlow.Modules.Identity.Application.Services; +using ColaFlow.Modules.Identity.Domain.Aggregates.Users; +using ColaFlow.Modules.Identity.Domain.Entities; +using ColaFlow.Modules.Identity.Domain.Repositories; +using ColaFlow.Modules.Identity.Domain.Services; +using MediatR; +using Microsoft.Extensions.Logging; + +namespace ColaFlow.Modules.Identity.Application.Commands.SendVerificationEmail; + +public class SendVerificationEmailCommandHandler : IRequestHandler +{ + private readonly IUserRepository _userRepository; + private readonly IEmailVerificationTokenRepository _tokenRepository; + private readonly ISecurityTokenService _tokenService; + private readonly IEmailService _emailService; + private readonly IEmailTemplateService _templateService; + private readonly ILogger _logger; + + public SendVerificationEmailCommandHandler( + IUserRepository userRepository, + IEmailVerificationTokenRepository tokenRepository, + ISecurityTokenService tokenService, + IEmailService emailService, + IEmailTemplateService templateService, + ILogger logger) + { + _userRepository = userRepository; + _tokenRepository = tokenRepository; + _tokenService = tokenService; + _emailService = emailService; + _templateService = templateService; + _logger = logger; + } + + public async Task Handle(SendVerificationEmailCommand request, CancellationToken cancellationToken) + { + var userId = UserId.Create(request.UserId); + var user = await _userRepository.GetByIdAsync(userId, cancellationToken); + + if (user == null) + { + _logger.LogWarning("User {UserId} not found, cannot send verification email", request.UserId); + return Unit.Value; + } + + // If already verified, no need to send email + if (user.IsEmailVerified) + { + _logger.LogInformation("User {UserId} email already verified, skipping verification email", request.UserId); + return Unit.Value; + } + + // Generate token + var token = _tokenService.GenerateToken(); + var tokenHash = _tokenService.HashToken(token); + + // Create verification token entity + var verificationToken = EmailVerificationToken.Create( + userId, + tokenHash, + DateTime.UtcNow.AddHours(24)); + + await _tokenRepository.AddAsync(verificationToken, cancellationToken); + + // Send email (non-blocking) + var verificationLink = $"{request.BaseUrl}/verify-email?token={token}"; + var htmlBody = _templateService.RenderVerificationEmail(user.FullName.Value, verificationLink); + + var emailMessage = new EmailMessage( + To: request.Email, + Subject: "Verify your email address - ColaFlow", + HtmlBody: htmlBody, + PlainTextBody: $"Click the link to verify your email: {verificationLink}"); + + var success = await _emailService.SendEmailAsync(emailMessage, cancellationToken); + + if (!success) + { + _logger.LogWarning( + "Failed to send verification email to {Email} for user {UserId}", + request.Email, + request.UserId); + } + else + { + _logger.LogInformation( + "Verification email sent to {Email} for user {UserId}", + request.Email, + request.UserId); + } + + return Unit.Value; + } +} diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/VerifyEmail/VerifyEmailCommand.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/VerifyEmail/VerifyEmailCommand.cs new file mode 100644 index 0000000..e41e97a --- /dev/null +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/VerifyEmail/VerifyEmailCommand.cs @@ -0,0 +1,5 @@ +using MediatR; + +namespace ColaFlow.Modules.Identity.Application.Commands.VerifyEmail; + +public sealed record VerifyEmailCommand(string Token) : IRequest; diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/VerifyEmail/VerifyEmailCommandHandler.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/VerifyEmail/VerifyEmailCommandHandler.cs new file mode 100644 index 0000000..244695c --- /dev/null +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/VerifyEmail/VerifyEmailCommandHandler.cs @@ -0,0 +1,68 @@ +using ColaFlow.Modules.Identity.Application.Services; +using ColaFlow.Modules.Identity.Domain.Repositories; +using MediatR; +using Microsoft.Extensions.Logging; + +namespace ColaFlow.Modules.Identity.Application.Commands.VerifyEmail; + +public class VerifyEmailCommandHandler : IRequestHandler +{ + private readonly IEmailVerificationTokenRepository _tokenRepository; + private readonly IUserRepository _userRepository; + private readonly ISecurityTokenService _tokenService; + private readonly ILogger _logger; + + public VerifyEmailCommandHandler( + IEmailVerificationTokenRepository tokenRepository, + IUserRepository userRepository, + ISecurityTokenService tokenService, + ILogger logger) + { + _tokenRepository = tokenRepository; + _userRepository = userRepository; + _tokenService = tokenService; + _logger = logger; + } + + public async Task Handle(VerifyEmailCommand request, CancellationToken cancellationToken) + { + // Hash the token to look it up + var tokenHash = _tokenService.HashToken(request.Token); + var verificationToken = await _tokenRepository.GetByTokenHashAsync(tokenHash, cancellationToken); + + if (verificationToken == null) + { + _logger.LogWarning("Email verification token not found"); + return false; + } + + if (!verificationToken.IsValid) + { + _logger.LogWarning( + "Email verification token is invalid. IsExpired: {IsExpired}, IsVerified: {IsVerified}", + verificationToken.IsExpired, + verificationToken.IsVerified); + return false; + } + + // Get user and mark email as verified + var user = await _userRepository.GetByIdAsync(verificationToken.UserId, cancellationToken); + if (user == null) + { + _logger.LogError("User {UserId} not found for email verification", verificationToken.UserId); + return false; + } + + // Mark token as verified + verificationToken.MarkAsVerified(); + await _tokenRepository.UpdateAsync(verificationToken, cancellationToken); + + // Mark user email as verified (will emit domain event) + user.VerifyEmail(); + await _userRepository.UpdateAsync(user, cancellationToken); + + _logger.LogInformation("Email verified for user {UserId}", user.Id); + + return true; + } +} diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Services/ISecurityTokenService.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Services/ISecurityTokenService.cs new file mode 100644 index 0000000..0cbc616 --- /dev/null +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Services/ISecurityTokenService.cs @@ -0,0 +1,22 @@ +namespace ColaFlow.Modules.Identity.Application.Services; + +/// +/// Service for generating and hashing security tokens +/// +public interface ISecurityTokenService +{ + /// + /// Generate a cryptographically secure random token (256-bit, base64url-encoded) + /// + string GenerateToken(); + + /// + /// Hash a token using SHA-256 + /// + string HashToken(string token); + + /// + /// Verify a token against a hash + /// + bool VerifyToken(string token, string hash); +} diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Aggregates/Users/Events/EmailVerifiedEvent.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Aggregates/Users/Events/EmailVerifiedEvent.cs new file mode 100644 index 0000000..3bc54ce --- /dev/null +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Aggregates/Users/Events/EmailVerifiedEvent.cs @@ -0,0 +1,5 @@ +using ColaFlow.Shared.Kernel.Events; + +namespace ColaFlow.Modules.Identity.Domain.Aggregates.Users.Events; + +public sealed record EmailVerifiedEvent(Guid UserId, string Email) : DomainEvent; diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Aggregates/Users/User.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Aggregates/Users/User.cs index 9c94cde..6ff0f70 100644 --- a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Aggregates/Users/User.cs +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Aggregates/Users/User.cs @@ -34,7 +34,10 @@ public sealed class User : AggregateRoot public DateTime? LastLoginAt { get; private set; } public DateTime? EmailVerifiedAt { get; private set; } - // Security + // Email verification status + public bool IsEmailVerified => EmailVerifiedAt.HasValue; + + // Security (deprecated - moved to separate token entities) public string? EmailVerificationToken { get; private set; } public string? PasswordResetToken { get; private set; } public DateTime? PasswordResetTokenExpiresAt { get; private set; } @@ -159,9 +162,14 @@ public sealed class User : AggregateRoot public void VerifyEmail() { + if (IsEmailVerified) + return; // Already verified, idempotent + EmailVerifiedAt = DateTime.UtcNow; EmailVerificationToken = null; UpdatedAt = DateTime.UtcNow; + + AddDomainEvent(new EmailVerifiedEvent(Id, Email)); } public void Suspend(string reason) diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Entities/EmailVerificationToken.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Entities/EmailVerificationToken.cs new file mode 100644 index 0000000..d04fb80 --- /dev/null +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Entities/EmailVerificationToken.cs @@ -0,0 +1,52 @@ +using ColaFlow.Shared.Kernel.Common; +using ColaFlow.Modules.Identity.Domain.Aggregates.Users; + +namespace ColaFlow.Modules.Identity.Domain.Entities; + +/// +/// Email verification token entity. +/// Lifetime: 24 hours. +/// +public sealed class EmailVerificationToken : Entity +{ + public UserId UserId { get; private set; } = null!; + public string TokenHash { get; private set; } = string.Empty; + public DateTime ExpiresAt { get; private set; } + public DateTime? VerifiedAt { get; private set; } + public DateTime CreatedAt { get; private set; } + + // Private constructor for EF Core + private EmailVerificationToken() : base() + { + } + + /// + /// Factory method to create new verification token. + /// + public static EmailVerificationToken Create( + UserId userId, + string tokenHash, + DateTime expiresAt) + { + return new EmailVerificationToken + { + Id = Guid.NewGuid(), + UserId = userId, + TokenHash = tokenHash, + ExpiresAt = expiresAt, + CreatedAt = DateTime.UtcNow + }; + } + + public bool IsExpired => DateTime.UtcNow > ExpiresAt; + public bool IsVerified => VerifiedAt.HasValue; + public bool IsValid => !IsExpired && !IsVerified; + + public void MarkAsVerified() + { + if (!IsValid) + throw new InvalidOperationException("Token is not valid for verification"); + + VerifiedAt = DateTime.UtcNow; + } +} diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Repositories/IEmailVerificationTokenRepository.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Repositories/IEmailVerificationTokenRepository.cs new file mode 100644 index 0000000..391764a --- /dev/null +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Repositories/IEmailVerificationTokenRepository.cs @@ -0,0 +1,38 @@ +using ColaFlow.Modules.Identity.Domain.Aggregates.Users; +using ColaFlow.Modules.Identity.Domain.Entities; + +namespace ColaFlow.Modules.Identity.Domain.Repositories; + +/// +/// Repository interface for EmailVerificationToken entity +/// +public interface IEmailVerificationTokenRepository +{ + /// + /// Get verification token by token hash + /// + Task GetByTokenHashAsync( + string tokenHash, + CancellationToken cancellationToken = default); + + /// + /// Get active verification token by user ID + /// + Task GetActiveByUserIdAsync( + UserId userId, + CancellationToken cancellationToken = default); + + /// + /// Add a new verification token + /// + Task AddAsync( + EmailVerificationToken token, + CancellationToken cancellationToken = default); + + /// + /// Update an existing verification token + /// + Task UpdateAsync( + EmailVerificationToken token, + CancellationToken cancellationToken = default); +} diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/DependencyInjection.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/DependencyInjection.cs index b771d69..6f3638c 100644 --- a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/DependencyInjection.cs +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/DependencyInjection.cs @@ -37,11 +37,13 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // Application Services services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // Email Services var emailProvider = configuration["Email:Provider"] ?? "Mock"; diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Configurations/EmailVerificationTokenConfiguration.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Configurations/EmailVerificationTokenConfiguration.cs new file mode 100644 index 0000000..92c762b --- /dev/null +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Configurations/EmailVerificationTokenConfiguration.cs @@ -0,0 +1,51 @@ +using ColaFlow.Modules.Identity.Domain.Aggregates.Users; +using ColaFlow.Modules.Identity.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ColaFlow.Modules.Identity.Infrastructure.Persistence.Configurations; + +public class EmailVerificationTokenConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("email_verification_tokens"); + + // Primary Key + builder.HasKey(t => t.Id); + builder.Property(t => t.Id).HasColumnName("id"); + + // User ID (foreign key) - stored as Guid, mapped to UserId value object + builder.Property(t => t.UserId) + .HasConversion( + userId => (Guid)userId, + value => UserId.Create(value)) + .IsRequired() + .HasColumnName("user_id"); + + // Token hash + builder.Property(t => t.TokenHash) + .HasMaxLength(64) + .IsRequired() + .HasColumnName("token_hash"); + + // Timestamps + builder.Property(t => t.ExpiresAt) + .IsRequired() + .HasColumnName("expires_at"); + + builder.Property(t => t.CreatedAt) + .IsRequired() + .HasColumnName("created_at"); + + builder.Property(t => t.VerifiedAt) + .HasColumnName("verified_at"); + + // Indexes + builder.HasIndex(t => t.TokenHash) + .HasDatabaseName("ix_email_verification_tokens_token_hash"); + + builder.HasIndex(t => t.UserId) + .HasDatabaseName("ix_email_verification_tokens_user_id"); + } +} diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/IdentityDbContext.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/IdentityDbContext.cs index f6f22bf..db895ef 100644 --- a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/IdentityDbContext.cs +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/IdentityDbContext.cs @@ -1,5 +1,6 @@ using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants; using ColaFlow.Modules.Identity.Domain.Aggregates.Users; +using ColaFlow.Modules.Identity.Domain.Entities; using ColaFlow.Modules.Identity.Infrastructure.Services; using ColaFlow.Shared.Kernel.Common; using MediatR; @@ -17,6 +18,7 @@ public class IdentityDbContext( public DbSet Users => Set(); public DbSet RefreshTokens => Set(); public DbSet UserTenantRoles => Set(); + public DbSet EmailVerificationTokens => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Migrations/20251103202856_AddEmailVerification.Designer.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Migrations/20251103202856_AddEmailVerification.Designer.cs new file mode 100644 index 0000000..e576040 --- /dev/null +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Migrations/20251103202856_AddEmailVerification.Designer.cs @@ -0,0 +1,370 @@ +ο»Ώ// +using System; +using ColaFlow.Modules.Identity.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace ColaFlow.Modules.Identity.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(IdentityDbContext))] + [Migration("20251103202856_AddEmailVerification")] + partial class AddEmailVerification + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("ColaFlow.Modules.Identity.Domain.Aggregates.Tenants.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("MaxProjects") + .HasColumnType("integer") + .HasColumnName("max_projects"); + + b.Property("MaxStorageGB") + .HasColumnType("integer") + .HasColumnName("max_storage_gb"); + + b.Property("MaxUsers") + .HasColumnType("integer") + .HasColumnName("max_users"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Plan") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("plan"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("slug"); + + b.Property("SsoConfig") + .HasColumnType("jsonb") + .HasColumnName("sso_config"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("status"); + + b.Property("SuspendedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("suspended_at"); + + b.Property("SuspensionReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("suspension_reason"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_tenants_slug"); + + b.ToTable("tenants", (string)null); + }); + + modelBuilder.Entity("ColaFlow.Modules.Identity.Domain.Aggregates.Users.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeviceInfo") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("device_info"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("IpAddress") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("ip_address"); + + b.Property("ReplacedByToken") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("replaced_by_token"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_at"); + + b.Property("RevokedReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("revoked_reason"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("token_hash"); + + b.Property("UserAgent") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("user_agent"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt") + .HasDatabaseName("ix_refresh_tokens_expires_at"); + + b.HasIndex("TenantId") + .HasDatabaseName("ix_refresh_tokens_tenant_id"); + + b.HasIndex("TokenHash") + .IsUnique() + .HasDatabaseName("ix_refresh_tokens_token_hash"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_refresh_tokens_user_id"); + + b.ToTable("refresh_tokens", "identity"); + }); + + modelBuilder.Entity("ColaFlow.Modules.Identity.Domain.Aggregates.Users.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AuthProvider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("auth_provider"); + + b.Property("AvatarUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("avatar_url"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("email"); + + b.Property("EmailVerificationToken") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("email_verification_token"); + + b.Property("EmailVerifiedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("email_verified_at"); + + b.Property("ExternalEmail") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("external_email"); + + b.Property("ExternalUserId") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("external_user_id"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("full_name"); + + b.Property("JobTitle") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("job_title"); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_login_at"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("password_hash"); + + b.Property("PasswordResetToken") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("password_reset_token"); + + b.Property("PasswordResetTokenExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("password_reset_token_expires_at"); + + b.Property("PhoneNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("phone_number"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Email") + .IsUnique() + .HasDatabaseName("ix_users_tenant_id_email"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("ColaFlow.Modules.Identity.Domain.Aggregates.Users.UserTenantRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AssignedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("assigned_at"); + + b.Property("AssignedByUserId") + .HasColumnType("uuid") + .HasColumnName("assigned_by_user_id"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("role"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("Role") + .HasDatabaseName("ix_user_tenant_roles_role"); + + b.HasIndex("TenantId") + .HasDatabaseName("ix_user_tenant_roles_tenant_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_user_tenant_roles_user_id"); + + b.HasIndex("UserId", "TenantId") + .IsUnique() + .HasDatabaseName("uq_user_tenant_roles_user_tenant"); + + b.ToTable("user_tenant_roles", "identity"); + }); + + modelBuilder.Entity("ColaFlow.Modules.Identity.Domain.Entities.EmailVerificationToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("token_hash"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("VerifiedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("verified_at"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .HasDatabaseName("ix_email_verification_tokens_token_hash"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_email_verification_tokens_user_id"); + + b.ToTable("email_verification_tokens", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Migrations/20251103202856_AddEmailVerification.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Migrations/20251103202856_AddEmailVerification.cs new file mode 100644 index 0000000..31c883b --- /dev/null +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Migrations/20251103202856_AddEmailVerification.cs @@ -0,0 +1,48 @@ +ο»Ώusing System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ColaFlow.Modules.Identity.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddEmailVerification : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "email_verification_tokens", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + user_id = table.Column(type: "uuid", nullable: false), + token_hash = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + expires_at = table.Column(type: "timestamp with time zone", nullable: false), + verified_at = table.Column(type: "timestamp with time zone", nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_email_verification_tokens", x => x.id); + }); + + migrationBuilder.CreateIndex( + name: "ix_email_verification_tokens_token_hash", + table: "email_verification_tokens", + column: "token_hash"); + + migrationBuilder.CreateIndex( + name: "ix_email_verification_tokens_user_id", + table: "email_verification_tokens", + column: "user_id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "email_verification_tokens"); + } + } +} diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Migrations/IdentityDbContextModelSnapshot.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Migrations/IdentityDbContextModelSnapshot.cs index 59b06d8..19c2d7d 100644 --- a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Migrations/IdentityDbContextModelSnapshot.cs +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Migrations/IdentityDbContextModelSnapshot.cs @@ -321,6 +321,46 @@ namespace ColaFlow.Modules.Identity.Infrastructure.Persistence.Migrations b.ToTable("user_tenant_roles", "identity"); }); + + modelBuilder.Entity("ColaFlow.Modules.Identity.Domain.Entities.EmailVerificationToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("token_hash"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("VerifiedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("verified_at"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .HasDatabaseName("ix_email_verification_tokens_token_hash"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_email_verification_tokens_user_id"); + + b.ToTable("email_verification_tokens", (string)null); + }); #pragma warning restore 612, 618 } } diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Repositories/EmailVerificationTokenRepository.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Repositories/EmailVerificationTokenRepository.cs new file mode 100644 index 0000000..f4953d7 --- /dev/null +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Repositories/EmailVerificationTokenRepository.cs @@ -0,0 +1,43 @@ +using ColaFlow.Modules.Identity.Domain.Aggregates.Users; +using ColaFlow.Modules.Identity.Domain.Entities; +using ColaFlow.Modules.Identity.Domain.Repositories; +using Microsoft.EntityFrameworkCore; + +namespace ColaFlow.Modules.Identity.Infrastructure.Persistence.Repositories; + +public class EmailVerificationTokenRepository(IdentityDbContext context) : IEmailVerificationTokenRepository +{ + public async Task GetByTokenHashAsync( + string tokenHash, + CancellationToken cancellationToken = default) + { + return await context.EmailVerificationTokens + .FirstOrDefaultAsync(t => t.TokenHash == tokenHash, cancellationToken); + } + + public async Task GetActiveByUserIdAsync( + UserId userId, + CancellationToken cancellationToken = default) + { + return await context.EmailVerificationTokens + .Where(t => t.UserId == userId && t.VerifiedAt == null && t.ExpiresAt > DateTime.UtcNow) + .OrderByDescending(t => t.CreatedAt) + .FirstOrDefaultAsync(cancellationToken); + } + + public async Task AddAsync( + EmailVerificationToken token, + CancellationToken cancellationToken = default) + { + await context.EmailVerificationTokens.AddAsync(token, cancellationToken); + await context.SaveChangesAsync(cancellationToken); + } + + public async Task UpdateAsync( + EmailVerificationToken token, + CancellationToken cancellationToken = default) + { + context.EmailVerificationTokens.Update(token); + await context.SaveChangesAsync(cancellationToken); + } +} diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Services/SecurityTokenService.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Services/SecurityTokenService.cs new file mode 100644 index 0000000..f835fdf --- /dev/null +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Services/SecurityTokenService.cs @@ -0,0 +1,36 @@ +using System.Security.Cryptography; +using System.Text; +using ColaFlow.Modules.Identity.Application.Services; + +namespace ColaFlow.Modules.Identity.Infrastructure.Services; + +public class SecurityTokenService : ISecurityTokenService +{ + public string GenerateToken() + { + var tokenBytes = new byte[32]; // 256 bits + using var rng = RandomNumberGenerator.Create(); + rng.GetBytes(tokenBytes); + + // Base64URL encoding (URL-safe, no padding) + return Convert.ToBase64String(tokenBytes) + .Replace("+", "-") + .Replace("/", "_") + .TrimEnd('='); + } + + public string HashToken(string token) + { + using var sha256 = SHA256.Create(); + var hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(token)); + return Convert.ToBase64String(hashBytes); + } + + public bool VerifyToken(string token, string hash) + { + var computedHash = HashToken(token); + return CryptographicOperations.FixedTimeEquals( + Encoding.UTF8.GetBytes(computedHash), + Encoding.UTF8.GetBytes(hash)); + } +}