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>
65 KiB
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
Table of Contents
- Overview
- Technology Stack Decisions
- Core Architecture Components
- Database Schema Design
- Security Architecture (ADRs)
- Integration Architecture
- Implementation Phases
- 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
- Abstraction First: Email provider is pluggable (SendGrid, SMTP, Mock)
- Security by Design: All tokens hashed (SHA-256), short expiration windows
- Fail-Safe Operations: Email delivery failures don't block user actions
- Domain-Driven Design: Token entities are first-class domain objects
- 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:
// 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 • 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:
// 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 • 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
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
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:
- Non-blocking: Email failures return error result but don't throw exceptions
- Logging: All sends logged for audit trail
- Graceful Degradation: Commands continue even if email fails (see Integration section)
Implementation: SMTP (Simplified)
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.
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
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
/// <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
/// <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
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
// 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
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
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
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
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
-- 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:
- Check existing User entity for deprecated columns
- Create migration to drop them (data loss acceptable for Day 7, as these were never used)
- Add
email_verified_atif missing
4.3 EF Core Entity Configurations
EmailVerificationTokenConfiguration
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
# 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:
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_hashcolumn - 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:
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
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
// 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
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)
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)
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)
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:
// 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):
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:
[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:
[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:
- ✅ Create
IEmailServiceinterface - ✅ Implement
SendGridEmailService - ✅ Implement
SmtpEmailService - ✅ Implement
MockEmailService(for tests) - ✅ Add email configuration to
appsettings.json - ✅ Create email templates (simple string interpolation for Day 7)
- ✅ Register services in DI container
- ✅ 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:
# 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:
- ✅ Create
SecurityTokenbase class - ✅ Create
EmailVerificationTokenentity - ✅ Create
IEmailVerificationTokenRepositoryinterface and implementation - ✅ Create EF Core entity configuration
- ✅ Generate and apply database migration
- ✅ Create
VerifyEmailCommandand handler - ✅ Create
ResendVerificationEmailCommandand handler - ✅ Modify
RegisterTenantCommandHandlerto send verification email - ✅ Add
/api/auth/verify-emailendpoint - ✅ Add
/api/auth/resend-verificationendpoint - ✅ 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:
# 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:
- ✅ Create
PasswordResetTokenentity - ✅ Create
IPasswordResetTokenRepositoryinterface and implementation - ✅ Create EF Core entity configuration
- ✅ Generate and apply database migration
- ✅ Create
ForgotPasswordCommandand handler - ✅ Create
ResetPasswordCommandand handler - ✅ Implement password complexity validation
- ✅ Implement refresh token revocation on password reset
- ✅ Add
/api/auth/forgot-passwordendpoint - ✅ Add
/api/auth/reset-passwordendpoint - ✅ 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:
# 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:
- ✅ Create
Invitationentity withInvitationStatusenum - ✅ Create
IInvitationRepositoryinterface and implementation - ✅ Create EF Core entity configuration
- ✅ Generate and apply database migration
- ✅ Create
InviteUserCommandand handler - ✅ Create
AcceptInvitationCommandand handler - ✅ Create
CancelInvitationCommandand handler - ✅ Create
GetTenantInvitationsQueryand handler - ✅ Create authorization policies (
RequireTenantOwner,RequireTenantAdmin) - ✅ Add
/api/tenants/{id}/invitationsendpoints (POST, GET, DELETE) - ✅ Add
/api/invitations/acceptendpoint - ✅ 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:
# 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:
- ✅ Add rate limiting service
- ✅ Add comprehensive logging
- ✅ Write email template HTML (upgrade from string interpolation)
- ✅ Add password complexity validation
- ✅ Write unit tests for all commands/queries
- ✅ Write integration tests for all endpoints
- ✅ Update API documentation (Swagger)
- ✅ Test edge cases (expired tokens, invalid tokens, etc.)
- ✅ Load test email sending (ensure SendGrid quota)
- ✅ 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:
{
"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:
{
"EmailSettings": {
"Provider": "SendGrid",
"FromAddress": "noreply@colaflow.io",
"FromName": "ColaFlow",
"SendGrid": {
"ApiKey": "${SENDGRID_API_KEY}" // Injected from environment variable
}
}
}
Environment Variables (Production):
SENDGRID_API_KEY=SG.xxxxxxxxxxxxxxxxxxxxx
COLAFLOW_EMAIL_FROM=noreply@colaflow.io
ASPNETCORE_ENVIRONMENT=Production
8.2 Database Migration Strategy
Deployment Steps:
# 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):
# 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:
// 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
- Abstraction Layer: Email provider is pluggable (SendGrid/SMTP/Mock)
- Domain-Driven Design: Security tokens are first-class domain entities with business logic
- Security First: SHA-256 token hashing, email enumeration prevention, rate limiting
- Fail-Safe: Email failures don't block user actions
- Scalability: In-memory rate limiting for Day 7, Redis-ready for production
Next Steps
- Backend Team: Implement Phase 1-4 in order
- Frontend Team: Build email verification/reset UI (Day 8)
- QA Team: Prepare test scenarios for all 4 features
- 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