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 <noreply@anthropic.com>
1894 lines
65 KiB
Markdown
1894 lines
65 KiB
Markdown
# 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<br>• Built-in analytics<br>• Bounce handling<br>• 100 emails/day free tier | • External dependency<br>• Requires API key |
|
|
| **SMTP** | Development & Self-hosted | • No external dependencies<br>• Air-gapped support<br>• Works with MailHog/Papercut | • Lower delivery rate<br>• Manual spam management |
|
|
| **Mock** | Testing | • No actual sends<br>• Fast tests<br>• File logging | • Not for production |
|
|
|
|
**Implementation Strategy**:
|
|
```csharp
|
|
// Configuration-driven selection
|
|
services.AddScoped<IEmailService>(provider =>
|
|
{
|
|
var config = provider.GetRequiredService<EmailSettings>();
|
|
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<br>• Fast rendering<br>• Type-safe | • Limited HTML support<br>• No layouts |
|
|
| **Razor Templates** | Complex HTML templates | • Full HTML support<br>• Layout inheritance<br>• IntelliSense | • RazorLight dependency<br>• 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<string> 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<br>• Existing infrastructure<br>• Easy expiration queries |
|
|
| **Hashing Algorithm** | SHA-256 | • Fast (token lookup performance)<br>• Sufficient for time-limited tokens<br>• Industry standard |
|
|
| **Token Format** | Base64URL-encoded random 256-bit | • URL-safe<br>• Cryptographically secure<br>• 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;
|
|
|
|
/// <summary>
|
|
/// Abstraction for sending transactional emails.
|
|
/// Implementations: SendGrid, SMTP, Mock (for testing).
|
|
/// </summary>
|
|
public interface IEmailService
|
|
{
|
|
/// <summary>
|
|
/// Send an email asynchronously.
|
|
/// </summary>
|
|
/// <param name="message">Email message details</param>
|
|
/// <param name="cancellationToken">Cancellation token</param>
|
|
/// <returns>EmailSendResult with success status and metadata</returns>
|
|
Task<EmailSendResult> SendEmailAsync(
|
|
EmailMessage message,
|
|
CancellationToken cancellationToken = default);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Email message data transfer object.
|
|
/// </summary>
|
|
public record EmailMessage(
|
|
string ToAddress,
|
|
string ToName,
|
|
string Subject,
|
|
string TextBody,
|
|
string? HtmlBody = null,
|
|
string? FromAddress = null,
|
|
string? FromName = null);
|
|
|
|
/// <summary>
|
|
/// Result of email send operation.
|
|
/// </summary>
|
|
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<SendGridEmailService> _logger;
|
|
|
|
public SendGridEmailService(
|
|
SendGridSettings settings,
|
|
ILogger<SendGridEmailService> logger)
|
|
{
|
|
_client = new SendGridClient(settings.ApiKey);
|
|
_defaultFromAddress = settings.FromAddress;
|
|
_defaultFromName = settings.FromName;
|
|
_logger = logger;
|
|
}
|
|
|
|
public async Task<EmailSendResult> 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<SmtpEmailService> _logger;
|
|
|
|
public async Task<EmailSendResult> 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;
|
|
|
|
/// <summary>
|
|
/// Abstract base class for all time-limited security tokens.
|
|
/// Enforces consistent token hashing and expiration logic.
|
|
/// </summary>
|
|
public abstract class SecurityToken : Entity
|
|
{
|
|
/// <summary>
|
|
/// SHA-256 hash of the actual token (never store plaintext token).
|
|
/// </summary>
|
|
public string TokenHash { get; protected set; } = string.Empty;
|
|
|
|
/// <summary>
|
|
/// Token expiration timestamp (UTC).
|
|
/// </summary>
|
|
public DateTime ExpiresAt { get; protected set; }
|
|
|
|
/// <summary>
|
|
/// Timestamp when token was created (UTC).
|
|
/// </summary>
|
|
public DateTime CreatedAt { get; protected set; }
|
|
|
|
/// <summary>
|
|
/// Timestamp when token was used/consumed (UTC). Null if not used.
|
|
/// </summary>
|
|
public DateTime? UsedAt { get; protected set; }
|
|
|
|
/// <summary>
|
|
/// User ID associated with this token.
|
|
/// </summary>
|
|
public UserId UserId { get; protected set; } = null!;
|
|
|
|
/// <summary>
|
|
/// Check if token is expired.
|
|
/// </summary>
|
|
public bool IsExpired => DateTime.UtcNow > ExpiresAt;
|
|
|
|
/// <summary>
|
|
/// Check if token has been used.
|
|
/// </summary>
|
|
public bool IsUsed => UsedAt.HasValue;
|
|
|
|
/// <summary>
|
|
/// Check if token is valid (not expired and not used).
|
|
/// </summary>
|
|
public bool IsValid => !IsExpired && !IsUsed;
|
|
|
|
/// <summary>
|
|
/// Mark token as used (prevents reuse).
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Validate provided token against stored hash.
|
|
/// </summary>
|
|
/// <param name="providedToken">The plaintext token to validate</param>
|
|
/// <returns>True if token matches hash</returns>
|
|
public bool ValidateToken(string providedToken)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(providedToken))
|
|
return false;
|
|
|
|
var providedHash = HashToken(providedToken);
|
|
return TokenHash == providedHash;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Hash a token using SHA-256.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Generate a cryptographically secure random token (256-bit, base64url-encoded).
|
|
/// </summary>
|
|
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;
|
|
|
|
/// <summary>
|
|
/// Email verification token entity.
|
|
/// Lifetime: 24 hours.
|
|
/// </summary>
|
|
public sealed class EmailVerificationToken : SecurityToken
|
|
{
|
|
/// <summary>
|
|
/// Email address being verified.
|
|
/// </summary>
|
|
public Email Email { get; private set; } = null!;
|
|
|
|
/// <summary>
|
|
/// Tenant ID for multi-tenant isolation.
|
|
/// </summary>
|
|
public TenantId TenantId { get; private set; } = null!;
|
|
|
|
// EF Core constructor
|
|
private EmailVerificationToken() : base() { }
|
|
|
|
/// <summary>
|
|
/// Factory method to create new verification token.
|
|
/// </summary>
|
|
/// <returns>Tuple of (entity, plaintext token for email)</returns>
|
|
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
|
|
/// <summary>
|
|
/// Password reset token entity.
|
|
/// Lifetime: 1 hour.
|
|
/// </summary>
|
|
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
|
|
/// <summary>
|
|
/// User invitation entity.
|
|
/// Lifetime: 7 days.
|
|
/// </summary>
|
|
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<EmailVerificationToken?> GetByTokenHashAsync(
|
|
string tokenHash,
|
|
CancellationToken cancellationToken = default);
|
|
|
|
Task<EmailVerificationToken?> GetActiveByUserIdAsync(
|
|
UserId userId,
|
|
CancellationToken cancellationToken = default);
|
|
|
|
Task AddAsync(
|
|
EmailVerificationToken token,
|
|
CancellationToken cancellationToken = default);
|
|
|
|
Task UpdateAsync(
|
|
EmailVerificationToken token,
|
|
CancellationToken cancellationToken = default);
|
|
}
|
|
|
|
public interface IPasswordResetTokenRepository
|
|
{
|
|
Task<PasswordResetToken?> 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<Invitation?> GetByTokenHashAsync(
|
|
string tokenHash,
|
|
CancellationToken cancellationToken = default);
|
|
|
|
Task<Invitation?> GetByIdAsync(
|
|
Guid invitationId,
|
|
CancellationToken cancellationToken = default);
|
|
|
|
Task<IReadOnlyList<Invitation>> GetAllByTenantAsync(
|
|
TenantId tenantId,
|
|
int pageNumber,
|
|
int pageSize,
|
|
InvitationStatus? status = null,
|
|
CancellationToken cancellationToken = default);
|
|
|
|
Task<int> 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<Result>;
|
|
public record ResendVerificationEmailCommand(string TenantSlug, string Email) : IRequest<Result>;
|
|
|
|
// Password Reset
|
|
public record ForgotPasswordCommand(string TenantSlug, string Email) : IRequest<Result>;
|
|
public record ResetPasswordCommand(string Token, string NewPassword) : IRequest<Result>;
|
|
|
|
// User Invitation
|
|
public record InviteUserCommand(
|
|
Guid TenantId,
|
|
string Email,
|
|
TenantRole Role) : IRequest<Result<InvitationDto>>;
|
|
|
|
public record AcceptInvitationCommand(
|
|
string Token,
|
|
string FullName,
|
|
string Password) : IRequest<Result<LoginResponseDto>>;
|
|
|
|
public record CancelInvitationCommand(
|
|
Guid TenantId,
|
|
Guid InvitationId) : IRequest<Result>;
|
|
```
|
|
|
|
#### Queries
|
|
|
|
```csharp
|
|
public record GetTenantInvitationsQuery(
|
|
Guid TenantId,
|
|
int PageNumber = 1,
|
|
int PageSize = 20,
|
|
InvitationStatus? Status = null) : IRequest<Result<PagedList<InvitationDto>>>;
|
|
|
|
public record GetInvitationByTokenQuery(
|
|
string Token) : IRequest<Result<InvitationDetailsDto>>;
|
|
```
|
|
|
|
---
|
|
|
|
## 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<EmailVerificationToken>
|
|
{
|
|
public void Configure(EntityTypeBuilder<EmailVerificationToken> 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<User>()
|
|
.WithMany()
|
|
.HasForeignKey(t => t.UserId)
|
|
.OnDelete(DeleteBehavior.Cascade);
|
|
|
|
builder.HasOne<Tenant>()
|
|
.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<Result> 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<bool> 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<bool> 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<ForgotPasswordCommand, Result>
|
|
{
|
|
private readonly IRateLimitingService _rateLimiter;
|
|
|
|
public async Task<Result> 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<RegisterTenantCommand, Result<TenantDto>>
|
|
{
|
|
private readonly IEmailService _emailService;
|
|
private readonly IEmailVerificationTokenRepository _tokenRepository;
|
|
// ... other dependencies
|
|
|
|
public async Task<Result<TenantDto>> 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<ForgotPasswordCommand, Result>
|
|
{
|
|
public async Task<Result> 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<InviteUserCommand, Result<InvitationDto>>
|
|
{
|
|
public async Task<Result<InvitationDto>> 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<EmailVerifiedEvent>
|
|
{
|
|
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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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
|