Implemented secure refresh token rotation with the following features: - RefreshToken domain entity with IsExpired(), IsRevoked(), IsActive(), Revoke() methods - IRefreshTokenService with token generation, rotation, and revocation - RefreshTokenService with SHA-256 hashing and token family tracking - RefreshTokenRepository for database operations - Database migration for refresh_tokens table with proper indexes - Updated LoginCommandHandler and RegisterTenantCommandHandler to return refresh tokens - Added POST /api/auth/refresh endpoint (token rotation) - Added POST /api/auth/logout endpoint (revoke single token) - Added POST /api/auth/logout-all endpoint (revoke all user tokens) - Updated JWT access token expiration to 15 minutes (from 60) - Refresh token expiration set to 7 days - Security features: token reuse detection, IP address tracking, user-agent logging Changes: - Domain: RefreshToken.cs, IRefreshTokenRepository.cs - Application: IRefreshTokenService.cs, updated LoginResponseDto and RegisterTenantResult - Infrastructure: RefreshTokenService.cs, RefreshTokenRepository.cs, RefreshTokenConfiguration.cs - API: AuthController.cs (3 new endpoints), RefreshTokenRequest.cs, LogoutRequest.cs - Configuration: appsettings.Development.json (updated JWT settings) - DI: DependencyInjection.cs (registered new services) - Migration: AddRefreshTokens migration 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1787 lines
58 KiB
Markdown
1787 lines
58 KiB
Markdown
# Day 5 Architecture Design: Advanced Authentication & Authorization
|
|
|
|
**Date**: 2025-11-03
|
|
**Author**: System Architect
|
|
**Status**: Ready for Implementation
|
|
|
|
---
|
|
|
|
## Executive Summary
|
|
|
|
This document provides comprehensive technical architecture for Day 5 development, focusing on three core security features:
|
|
|
|
1. **Refresh Token Mechanism** (Priority 1)
|
|
2. **Role-Based Authorization (RBAC)** (Priority 1)
|
|
3. **Email Verification Flow** (Priority 2)
|
|
|
|
All designs are tailored for the existing .NET 9 + Clean Architecture + Multi-tenant system, with forward compatibility for future MCP Server integration.
|
|
|
|
---
|
|
|
|
## Table of Contents
|
|
|
|
- [1. Refresh Token Mechanism](#1-refresh-token-mechanism)
|
|
- [2. Role-Based Authorization (RBAC)](#2-role-based-authorization-rbac)
|
|
- [3. Email Verification Flow](#3-email-verification-flow)
|
|
- [4. Risk Assessment](#4-risk-assessment)
|
|
- [5. Implementation Roadmap](#5-implementation-roadmap)
|
|
- [6. MCP Integration Considerations](#6-mcp-integration-considerations)
|
|
|
|
---
|
|
|
|
## 1. Refresh Token Mechanism
|
|
|
|
### 1.1 Background & Goals
|
|
|
|
**Problem**: Current JWT access tokens expire after 60 minutes, requiring users to re-login frequently. This degrades user experience and security.
|
|
|
|
**Goals**:
|
|
- Implement secure refresh token rotation
|
|
- Support long-lived sessions (7-30 days)
|
|
- Enable token revocation for security incidents
|
|
- Prepare for distributed session management
|
|
|
|
### 1.2 Architecture Design
|
|
|
|
#### 1.2.1 Token Flow Diagram
|
|
|
|
```
|
|
┌─────────────┐ ┌─────────────┐
|
|
│ Client │ │ API Server │
|
|
└──────┬──────┘ └──────┬──────┘
|
|
│ │
|
|
│ 1. Login (credentials) │
|
|
├────────────────────────────────>│
|
|
│ │
|
|
│ 2. Access Token (60 min) │
|
|
│ Refresh Token (7 days) │
|
|
│<────────────────────────────────┤
|
|
│ │
|
|
│ 3. API Request + Access Token │
|
|
├────────────────────────────────>│
|
|
│ │
|
|
│ 4. Response (200 OK) │
|
|
│<────────────────────────────────┤
|
|
│ │
|
|
│ [After 60 minutes] │
|
|
│ │
|
|
│ 5. API Request + Expired Token │
|
|
├────────────────────────────────>│
|
|
│ │
|
|
│ 6. 401 Unauthorized │
|
|
│<────────────────────────────────┤
|
|
│ │
|
|
│ 7. Refresh Token Request │
|
|
├────────────────────────────────>│
|
|
│ │
|
|
│ 8. New Access Token (60 min) │
|
|
│ New Refresh Token (7 days) │
|
|
│<────────────────────────────────┤
|
|
│ │
|
|
```
|
|
|
|
#### 1.2.2 Technology Decision: Database vs Redis
|
|
|
|
**Comparison**:
|
|
|
|
| Criteria | PostgreSQL | Redis |
|
|
|----------|-----------|-------|
|
|
| **Performance** | Good (indexed queries) | Excellent (in-memory) |
|
|
| **Persistence** | Native (ACID) | Optional (AOF/RDB) |
|
|
| **Complexity** | Low (existing stack) | Medium (new dependency) |
|
|
| **Scalability** | Vertical + Read Replicas | Horizontal + Clustering |
|
|
| **Query Capability** | Rich (SQL) | Limited (Key-Value) |
|
|
| **Cost** | Included | Additional infrastructure |
|
|
|
|
**Recommendation**: **PostgreSQL for MVP, Redis for Scale**
|
|
|
|
**Rationale**:
|
|
- Day 5 MVP: Use PostgreSQL to minimize new dependencies
|
|
- PostgreSQL can handle 10K-100K users easily with proper indexing
|
|
- Redis migration path is straightforward when scaling is needed
|
|
- Reduces Day 5 complexity and deployment overhead
|
|
|
|
#### 1.2.3 Database Schema Design
|
|
|
|
```sql
|
|
-- New table for refresh tokens
|
|
CREATE TABLE identity.refresh_tokens (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
token_hash VARCHAR(128) NOT NULL UNIQUE, -- SHA-256 hash of token
|
|
user_id UUID NOT NULL,
|
|
tenant_id UUID NOT NULL,
|
|
|
|
-- Token metadata
|
|
expires_at TIMESTAMP NOT NULL,
|
|
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
|
revoked_at TIMESTAMP NULL,
|
|
revoked_reason VARCHAR(500) NULL,
|
|
|
|
-- Security tracking
|
|
ip_address VARCHAR(45) NULL, -- IPv6 compatible
|
|
user_agent VARCHAR(500) NULL,
|
|
last_used_at TIMESTAMP NULL,
|
|
|
|
-- Token family for rotation
|
|
token_family UUID NOT NULL, -- Group rotated tokens together
|
|
replaced_by_token_id UUID NULL, -- Link to next token in chain
|
|
|
|
-- Indexes
|
|
CONSTRAINT fk_refresh_tokens_user FOREIGN KEY (user_id)
|
|
REFERENCES identity.users(id) ON DELETE CASCADE,
|
|
CONSTRAINT fk_refresh_tokens_tenant FOREIGN KEY (tenant_id)
|
|
REFERENCES identity.tenants(id) ON DELETE CASCADE
|
|
);
|
|
|
|
-- Indexes for performance
|
|
CREATE INDEX idx_refresh_tokens_user_id ON identity.refresh_tokens(user_id);
|
|
CREATE INDEX idx_refresh_tokens_tenant_id ON identity.refresh_tokens(tenant_id);
|
|
CREATE INDEX idx_refresh_tokens_token_hash ON identity.refresh_tokens(token_hash);
|
|
CREATE INDEX idx_refresh_tokens_expires_at ON identity.refresh_tokens(expires_at);
|
|
CREATE INDEX idx_refresh_tokens_token_family ON identity.refresh_tokens(token_family);
|
|
|
|
-- Cleanup expired tokens (scheduled job)
|
|
CREATE INDEX idx_refresh_tokens_cleanup
|
|
ON identity.refresh_tokens(expires_at, revoked_at)
|
|
WHERE revoked_at IS NULL;
|
|
```
|
|
|
|
#### 1.2.4 Domain Model
|
|
|
|
**RefreshToken Entity** (`Domain/Aggregates/Users/RefreshToken.cs`):
|
|
|
|
```csharp
|
|
public sealed class RefreshToken : Entity
|
|
{
|
|
public string TokenHash { get; private set; } = null!;
|
|
public UserId UserId { get; private set; } = null!;
|
|
public TenantId TenantId { get; private set; } = null!;
|
|
|
|
// Token lifecycle
|
|
public DateTime ExpiresAt { get; private set; }
|
|
public DateTime CreatedAt { get; private set; }
|
|
public DateTime? RevokedAt { get; private set; }
|
|
public string? RevokedReason { get; private set; }
|
|
|
|
// Security tracking
|
|
public string? IpAddress { get; private set; }
|
|
public string? UserAgent { get; private set; }
|
|
public DateTime? LastUsedAt { get; private set; }
|
|
|
|
// Token rotation
|
|
public Guid TokenFamily { get; private set; }
|
|
public Guid? ReplacedByTokenId { get; private set; }
|
|
|
|
// Factory method
|
|
public static RefreshToken Create(
|
|
UserId userId,
|
|
TenantId tenantId,
|
|
string tokenHash,
|
|
DateTime expiresAt,
|
|
Guid tokenFamily,
|
|
string? ipAddress = null,
|
|
string? userAgent = null)
|
|
{
|
|
return new RefreshToken
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
TokenHash = tokenHash,
|
|
UserId = userId,
|
|
TenantId = tenantId,
|
|
ExpiresAt = expiresAt,
|
|
CreatedAt = DateTime.UtcNow,
|
|
TokenFamily = tokenFamily,
|
|
IpAddress = ipAddress,
|
|
UserAgent = userAgent
|
|
};
|
|
}
|
|
|
|
// Business methods
|
|
public void MarkAsUsed()
|
|
{
|
|
LastUsedAt = DateTime.UtcNow;
|
|
}
|
|
|
|
public void Revoke(string reason)
|
|
{
|
|
if (RevokedAt.HasValue)
|
|
throw new InvalidOperationException("Token already revoked");
|
|
|
|
RevokedAt = DateTime.UtcNow;
|
|
RevokedReason = reason;
|
|
}
|
|
|
|
public void MarkAsReplaced(Guid newTokenId)
|
|
{
|
|
ReplacedByTokenId = newTokenId;
|
|
RevokedAt = DateTime.UtcNow;
|
|
RevokedReason = "Rotated";
|
|
}
|
|
|
|
public bool IsValid()
|
|
{
|
|
return !RevokedAt.HasValue && DateTime.UtcNow < ExpiresAt;
|
|
}
|
|
|
|
public bool IsExpired()
|
|
{
|
|
return DateTime.UtcNow >= ExpiresAt;
|
|
}
|
|
}
|
|
```
|
|
|
|
#### 1.2.5 Application Layer Design
|
|
|
|
**Interface**: `Application/Services/IRefreshTokenService.cs`
|
|
|
|
```csharp
|
|
public interface IRefreshTokenService
|
|
{
|
|
Task<RefreshToken> GenerateRefreshTokenAsync(
|
|
User user,
|
|
string? ipAddress = null,
|
|
string? userAgent = null,
|
|
CancellationToken cancellationToken = default);
|
|
|
|
Task<(string AccessToken, RefreshToken NewRefreshToken)> RotateRefreshTokenAsync(
|
|
string refreshToken,
|
|
string? ipAddress = null,
|
|
string? userAgent = null,
|
|
CancellationToken cancellationToken = default);
|
|
|
|
Task RevokeTokenAsync(
|
|
string refreshToken,
|
|
string reason,
|
|
CancellationToken cancellationToken = default);
|
|
|
|
Task RevokeAllUserTokensAsync(
|
|
Guid userId,
|
|
string reason,
|
|
CancellationToken cancellationToken = default);
|
|
}
|
|
```
|
|
|
|
**Implementation**: `Infrastructure/Services/RefreshTokenService.cs`
|
|
|
|
```csharp
|
|
public class RefreshTokenService : IRefreshTokenService
|
|
{
|
|
private readonly IUserRepository _userRepository;
|
|
private readonly IRefreshTokenRepository _refreshTokenRepository;
|
|
private readonly IJwtService _jwtService;
|
|
private readonly IConfiguration _configuration;
|
|
private readonly ILogger<RefreshTokenService> _logger;
|
|
|
|
public async Task<RefreshToken> GenerateRefreshTokenAsync(
|
|
User user,
|
|
string? ipAddress,
|
|
string? userAgent,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
// Generate cryptographically secure token
|
|
var tokenBytes = new byte[64];
|
|
using var rng = RandomNumberGenerator.Create();
|
|
rng.GetBytes(tokenBytes);
|
|
var token = Convert.ToBase64String(tokenBytes);
|
|
|
|
// Hash token before storage (never store plain text)
|
|
var tokenHash = ComputeSha256Hash(token);
|
|
|
|
// Create refresh token
|
|
var expirationDays = _configuration.GetValue<int>("Jwt:RefreshTokenExpirationDays", 7);
|
|
var tokenFamily = Guid.NewGuid(); // New token family
|
|
|
|
var refreshToken = RefreshToken.Create(
|
|
userId: UserId.From(user.Id),
|
|
tenantId: user.TenantId,
|
|
tokenHash: tokenHash,
|
|
expiresAt: DateTime.UtcNow.AddDays(expirationDays),
|
|
tokenFamily: tokenFamily,
|
|
ipAddress: ipAddress,
|
|
userAgent: userAgent
|
|
);
|
|
|
|
await _refreshTokenRepository.AddAsync(refreshToken, cancellationToken);
|
|
|
|
_logger.LogInformation(
|
|
"Generated refresh token for user {UserId}, expires at {ExpiresAt}",
|
|
user.Id, refreshToken.ExpiresAt);
|
|
|
|
// Return token with plain text (only time we return plain text)
|
|
refreshToken.PlainTextToken = token; // Add transient property
|
|
return refreshToken;
|
|
}
|
|
|
|
public async Task<(string AccessToken, RefreshToken NewRefreshToken)> RotateRefreshTokenAsync(
|
|
string refreshToken,
|
|
string? ipAddress,
|
|
string? userAgent,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var tokenHash = ComputeSha256Hash(refreshToken);
|
|
|
|
// Find existing token
|
|
var existingToken = await _refreshTokenRepository
|
|
.GetByTokenHashAsync(tokenHash, cancellationToken);
|
|
|
|
if (existingToken == null)
|
|
{
|
|
_logger.LogWarning("Refresh token not found: {TokenHash}", tokenHash);
|
|
throw new UnauthorizedAccessException("Invalid refresh token");
|
|
}
|
|
|
|
// Check if token is valid
|
|
if (!existingToken.IsValid())
|
|
{
|
|
_logger.LogWarning(
|
|
"Invalid refresh token used by user {UserId}, token family {TokenFamily}",
|
|
existingToken.UserId, existingToken.TokenFamily);
|
|
|
|
// SECURITY: Revoke entire token family (possible token theft)
|
|
await RevokeTokenFamilyAsync(existingToken.TokenFamily, "Security: Reuse detected", cancellationToken);
|
|
|
|
throw new UnauthorizedAccessException("Token invalid or revoked");
|
|
}
|
|
|
|
// Get user and tenant
|
|
var user = await _userRepository.GetByIdAsync(existingToken.UserId.Value, cancellationToken);
|
|
if (user == null || user.Status != UserStatus.Active)
|
|
{
|
|
throw new UnauthorizedAccessException("User not found or inactive");
|
|
}
|
|
|
|
var tenant = await _tenantRepository.GetByIdAsync(existingToken.TenantId.Value, cancellationToken);
|
|
if (tenant == null || tenant.Status != TenantStatus.Active)
|
|
{
|
|
throw new UnauthorizedAccessException("Tenant not found or inactive");
|
|
}
|
|
|
|
// Generate new tokens
|
|
var newAccessToken = _jwtService.GenerateToken(user, tenant);
|
|
var newRefreshToken = await GenerateRefreshTokenForRotationAsync(
|
|
user,
|
|
existingToken.TokenFamily,
|
|
ipAddress,
|
|
userAgent,
|
|
cancellationToken);
|
|
|
|
// Mark old token as replaced
|
|
existingToken.MarkAsReplaced(newRefreshToken.Id);
|
|
await _refreshTokenRepository.UpdateAsync(existingToken, cancellationToken);
|
|
|
|
_logger.LogInformation(
|
|
"Rotated refresh token for user {UserId}, old token: {OldTokenId}, new token: {NewTokenId}",
|
|
user.Id, existingToken.Id, newRefreshToken.Id);
|
|
|
|
return (newAccessToken, newRefreshToken);
|
|
}
|
|
|
|
private async Task<RefreshToken> GenerateRefreshTokenForRotationAsync(
|
|
User user,
|
|
Guid tokenFamily,
|
|
string? ipAddress,
|
|
string? userAgent,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
// Same as GenerateRefreshTokenAsync but reuses token family
|
|
var tokenBytes = new byte[64];
|
|
using var rng = RandomNumberGenerator.Create();
|
|
rng.GetBytes(tokenBytes);
|
|
var token = Convert.ToBase64String(tokenBytes);
|
|
var tokenHash = ComputeSha256Hash(token);
|
|
|
|
var expirationDays = _configuration.GetValue<int>("Jwt:RefreshTokenExpirationDays", 7);
|
|
|
|
var refreshToken = RefreshToken.Create(
|
|
userId: UserId.From(user.Id),
|
|
tenantId: user.TenantId,
|
|
tokenHash: tokenHash,
|
|
expiresAt: DateTime.UtcNow.AddDays(expirationDays),
|
|
tokenFamily: tokenFamily, // Reuse token family
|
|
ipAddress: ipAddress,
|
|
userAgent: userAgent
|
|
);
|
|
|
|
await _refreshTokenRepository.AddAsync(refreshToken, cancellationToken);
|
|
|
|
refreshToken.PlainTextToken = token;
|
|
return refreshToken;
|
|
}
|
|
|
|
private async Task RevokeTokenFamilyAsync(
|
|
Guid tokenFamily,
|
|
string reason,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var tokens = await _refreshTokenRepository
|
|
.GetByTokenFamilyAsync(tokenFamily, cancellationToken);
|
|
|
|
foreach (var token in tokens.Where(t => !t.RevokedAt.HasValue))
|
|
{
|
|
token.Revoke(reason);
|
|
}
|
|
|
|
await _refreshTokenRepository.UpdateRangeAsync(tokens, cancellationToken);
|
|
|
|
_logger.LogWarning(
|
|
"Revoked entire token family {TokenFamily}, reason: {Reason}",
|
|
tokenFamily, reason);
|
|
}
|
|
|
|
private static string ComputeSha256Hash(string input)
|
|
{
|
|
using var sha256 = SHA256.Create();
|
|
var bytes = Encoding.UTF8.GetBytes(input);
|
|
var hash = sha256.ComputeHash(bytes);
|
|
return Convert.ToBase64String(hash);
|
|
}
|
|
}
|
|
```
|
|
|
|
#### 1.2.6 API Endpoints
|
|
|
|
**New endpoint**: `POST /api/auth/refresh`
|
|
|
|
```csharp
|
|
[HttpPost("refresh")]
|
|
[AllowAnonymous]
|
|
public async Task<ActionResult<LoginResponseDto>> RefreshToken(
|
|
[FromBody] RefreshTokenRequest request)
|
|
{
|
|
try
|
|
{
|
|
var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString();
|
|
var userAgent = HttpContext.Request.Headers["User-Agent"].ToString();
|
|
|
|
var (accessToken, newRefreshToken) = await _refreshTokenService
|
|
.RotateRefreshTokenAsync(
|
|
request.RefreshToken,
|
|
ipAddress,
|
|
userAgent,
|
|
HttpContext.RequestAborted);
|
|
|
|
return Ok(new LoginResponseDto
|
|
{
|
|
AccessToken = accessToken,
|
|
RefreshToken = newRefreshToken.PlainTextToken,
|
|
ExpiresIn = 3600, // 60 minutes
|
|
TokenType = "Bearer"
|
|
});
|
|
}
|
|
catch (UnauthorizedAccessException ex)
|
|
{
|
|
_logger.LogWarning(ex, "Refresh token failed");
|
|
return Unauthorized(new { message = "Invalid or expired refresh token" });
|
|
}
|
|
}
|
|
|
|
[HttpPost("logout")]
|
|
[Authorize]
|
|
public async Task<IActionResult> Logout([FromBody] LogoutRequest request)
|
|
{
|
|
try
|
|
{
|
|
await _refreshTokenService.RevokeTokenAsync(
|
|
request.RefreshToken,
|
|
"User logout",
|
|
HttpContext.RequestAborted);
|
|
|
|
return Ok(new { message = "Logged out successfully" });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Logout failed");
|
|
return BadRequest(new { message = "Logout failed" });
|
|
}
|
|
}
|
|
|
|
[HttpPost("logout-all")]
|
|
[Authorize]
|
|
public async Task<IActionResult> LogoutAllDevices()
|
|
{
|
|
var userId = Guid.Parse(User.FindFirstValue("user_id")!);
|
|
|
|
await _refreshTokenService.RevokeAllUserTokensAsync(
|
|
userId,
|
|
"User requested logout from all devices",
|
|
HttpContext.RequestAborted);
|
|
|
|
return Ok(new { message = "Logged out from all devices" });
|
|
}
|
|
```
|
|
|
|
#### 1.2.7 Security Mechanisms
|
|
|
|
**1. Token Rotation Strategy**:
|
|
- Each refresh token can only be used once
|
|
- Using a refresh token generates a new access token AND a new refresh token
|
|
- Old refresh token is immediately invalidated
|
|
|
|
**2. Token Family Tracking**:
|
|
- All rotated tokens belong to the same "family"
|
|
- If any token in a family is reused, entire family is revoked
|
|
- Detects token theft and replay attacks
|
|
|
|
**3. Token Storage Security**:
|
|
- Never store plain text tokens in database
|
|
- Store SHA-256 hash of tokens
|
|
- Plain text tokens only returned to client once
|
|
|
|
**4. Additional Security**:
|
|
- IP address and User-Agent tracking
|
|
- Last used timestamp tracking
|
|
- Automatic cleanup of expired tokens (scheduled job)
|
|
|
|
#### 1.2.8 Configuration
|
|
|
|
**appsettings.Development.json**:
|
|
|
|
```json
|
|
{
|
|
"Jwt": {
|
|
"SecretKey": "your-super-secret-key-min-32-characters-long-12345",
|
|
"Issuer": "ColaFlow.API",
|
|
"Audience": "ColaFlow.Web",
|
|
"ExpirationMinutes": "60",
|
|
"RefreshTokenExpirationDays": "7",
|
|
"RefreshTokenCleanupDays": "30"
|
|
}
|
|
}
|
|
```
|
|
|
|
**appsettings.Production.json**:
|
|
|
|
```json
|
|
{
|
|
"Jwt": {
|
|
"SecretKey": "${JWT_SECRET_KEY}", // Environment variable
|
|
"Issuer": "ColaFlow.API",
|
|
"Audience": "ColaFlow.Web",
|
|
"ExpirationMinutes": "30", // Shorter for production
|
|
"RefreshTokenExpirationDays": "7",
|
|
"RefreshTokenCleanupDays": "30"
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 2. Role-Based Authorization (RBAC)
|
|
|
|
### 2.1 Background & Goals
|
|
|
|
**Problem**: Current system has no role differentiation. All authenticated users have same permissions.
|
|
|
|
**Goals**:
|
|
- Implement hierarchical role system
|
|
- Support tenant-level and project-level permissions
|
|
- Prepare for future MCP Server permission integration
|
|
- Enable fine-grained access control
|
|
|
|
### 2.2 Architecture Design
|
|
|
|
#### 2.2.1 Role Hierarchy
|
|
|
|
```
|
|
Enterprise Architecture:
|
|
|
|
┌─────────────────────────────────────────────────────┐
|
|
│ System Admin │
|
|
│ (Internal ColaFlow admin - not tenant-specific) │
|
|
└─────────────────────────────────────────────────────┘
|
|
│
|
|
┌───────────────┴───────────────┐
|
|
│ │
|
|
┌───────▼──────────┐ ┌────────▼─────────┐
|
|
│ Tenant Owner │ │ Tenant Admin │
|
|
│ (Full control) │ │ (Manage users) │
|
|
└───────┬──────────┘ └────────┬─────────┘
|
|
│ │
|
|
└───────────────┬───────────────┘
|
|
│
|
|
┌───────────────┴───────────────┐
|
|
│ │
|
|
┌───────▼──────────┐ ┌────────▼─────────┐
|
|
│ Project Manager │ │ Project Member │
|
|
│ (Manage project)│ │ (View/Edit) │
|
|
└───────┬──────────┘ └────────┬─────────┘
|
|
│ │
|
|
└───────────────┬───────────────┘
|
|
│
|
|
┌───────▼────────┐
|
|
│ Project Guest │
|
|
│ (View only) │
|
|
└────────────────┘
|
|
```
|
|
|
|
#### 2.2.2 Permission Model
|
|
|
|
**Two-Level Permission System**:
|
|
|
|
1. **Tenant-Level Roles** (applies to entire tenant):
|
|
- TenantOwner
|
|
- TenantAdmin
|
|
- TenantMember (default)
|
|
- TenantGuest (read-only)
|
|
|
|
2. **Project-Level Roles** (applies to specific projects):
|
|
- ProjectOwner
|
|
- ProjectManager
|
|
- ProjectMember
|
|
- ProjectGuest
|
|
|
|
**Permission Matrix**:
|
|
|
|
| Action | Tenant Owner | Tenant Admin | Tenant Member | Tenant Guest |
|
|
|--------|-------------|--------------|---------------|--------------|
|
|
| Manage Tenant Settings | ✅ | ❌ | ❌ | ❌ |
|
|
| Manage Billing | ✅ | ❌ | ❌ | ❌ |
|
|
| Invite/Remove Users | ✅ | ✅ | ❌ | ❌ |
|
|
| Create Projects | ✅ | ✅ | ✅ | ❌ |
|
|
| View All Projects | ✅ | ✅ | Assigned Only | Assigned Only |
|
|
| Delete Projects | ✅ | ✅ | ❌ | ❌ |
|
|
|
|
#### 2.2.3 Database Schema Design
|
|
|
|
```sql
|
|
-- Tenant roles (user's role within a tenant)
|
|
CREATE TABLE identity.user_tenant_roles (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
user_id UUID NOT NULL,
|
|
tenant_id UUID NOT NULL,
|
|
role VARCHAR(50) NOT NULL, -- TenantOwner, TenantAdmin, TenantMember, TenantGuest
|
|
|
|
assigned_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
|
assigned_by_user_id UUID NULL,
|
|
|
|
CONSTRAINT fk_user_tenant_roles_user FOREIGN KEY (user_id)
|
|
REFERENCES identity.users(id) ON DELETE CASCADE,
|
|
CONSTRAINT fk_user_tenant_roles_tenant FOREIGN KEY (tenant_id)
|
|
REFERENCES identity.tenants(id) ON DELETE CASCADE,
|
|
CONSTRAINT fk_user_tenant_roles_assigned_by FOREIGN KEY (assigned_by_user_id)
|
|
REFERENCES identity.users(id) ON DELETE SET NULL,
|
|
|
|
-- One role per user per tenant
|
|
CONSTRAINT uq_user_tenant_role UNIQUE (user_id, tenant_id)
|
|
);
|
|
|
|
CREATE INDEX idx_user_tenant_roles_user_id ON identity.user_tenant_roles(user_id);
|
|
CREATE INDEX idx_user_tenant_roles_tenant_id ON identity.user_tenant_roles(tenant_id);
|
|
|
|
-- Project roles (will be in Projects module, shown here for reference)
|
|
-- This table will be created when Projects module is implemented
|
|
-- CREATE TABLE projects.user_project_roles (
|
|
-- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
-- user_id UUID NOT NULL,
|
|
-- project_id UUID NOT NULL,
|
|
-- role VARCHAR(50) NOT NULL, -- ProjectOwner, ProjectManager, ProjectMember, ProjectGuest
|
|
-- assigned_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
|
-- assigned_by_user_id UUID NULL
|
|
-- );
|
|
```
|
|
|
|
#### 2.2.4 Domain Model
|
|
|
|
**TenantRole Enum** (`Domain/Aggregates/Users/TenantRole.cs`):
|
|
|
|
```csharp
|
|
public enum TenantRole
|
|
{
|
|
TenantOwner = 1, // Full control
|
|
TenantAdmin = 2, // User management
|
|
TenantMember = 3, // Default role
|
|
TenantGuest = 4 // Read-only
|
|
}
|
|
```
|
|
|
|
**UserTenantRole Entity** (`Domain/Aggregates/Users/UserTenantRole.cs`):
|
|
|
|
```csharp
|
|
public sealed class UserTenantRole : Entity
|
|
{
|
|
public UserId UserId { get; private set; } = null!;
|
|
public TenantId TenantId { get; private set; } = null!;
|
|
public TenantRole Role { get; private set; }
|
|
|
|
public DateTime AssignedAt { get; private set; }
|
|
public Guid? AssignedByUserId { get; private set; }
|
|
|
|
private UserTenantRole() : base() { }
|
|
|
|
public static UserTenantRole Create(
|
|
UserId userId,
|
|
TenantId tenantId,
|
|
TenantRole role,
|
|
Guid? assignedByUserId = null)
|
|
{
|
|
return new UserTenantRole
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
UserId = userId,
|
|
TenantId = tenantId,
|
|
Role = role,
|
|
AssignedAt = DateTime.UtcNow,
|
|
AssignedByUserId = assignedByUserId
|
|
};
|
|
}
|
|
|
|
public void UpdateRole(TenantRole newRole, Guid updatedByUserId)
|
|
{
|
|
if (Role == newRole)
|
|
return;
|
|
|
|
Role = newRole;
|
|
AssignedByUserId = updatedByUserId;
|
|
// Note: AssignedAt intentionally not updated to preserve original assignment date
|
|
}
|
|
}
|
|
```
|
|
|
|
**Update User Entity** (`Domain/Aggregates/Users/User.cs`):
|
|
|
|
```csharp
|
|
// Add to User entity
|
|
public TenantRole GetTenantRole()
|
|
{
|
|
// This will be loaded from UserTenantRole entity
|
|
// For now, return default
|
|
return TenantRole.TenantMember;
|
|
}
|
|
```
|
|
|
|
#### 2.2.5 Authorization Implementation
|
|
|
|
**Policy-Based Authorization** (`Program.cs`):
|
|
|
|
```csharp
|
|
// Add authorization policies
|
|
builder.Services.AddAuthorization(options =>
|
|
{
|
|
// Tenant-level policies
|
|
options.AddPolicy("RequireTenantOwner", policy =>
|
|
policy.RequireClaim("tenant_role", "TenantOwner"));
|
|
|
|
options.AddPolicy("RequireTenantAdmin", policy =>
|
|
policy.RequireAssertion(context =>
|
|
context.User.HasClaim(c => c.Type == "tenant_role" &&
|
|
(c.Value == "TenantOwner" || c.Value == "TenantAdmin"))));
|
|
|
|
options.AddPolicy("RequireTenantMember", policy =>
|
|
policy.RequireAssertion(context =>
|
|
context.User.HasClaim(c => c.Type == "tenant_role" &&
|
|
(c.Value == "TenantOwner" || c.Value == "TenantAdmin" || c.Value == "TenantMember"))));
|
|
|
|
// Future: Project-level policies
|
|
options.AddPolicy("RequireProjectOwner", policy =>
|
|
policy.RequireClaim("project_role", "ProjectOwner"));
|
|
});
|
|
```
|
|
|
|
**Update JWT Claims** (`Infrastructure/Services/JwtService.cs`):
|
|
|
|
```csharp
|
|
public string GenerateToken(User user, Tenant tenant, TenantRole tenantRole)
|
|
{
|
|
var securityKey = new SymmetricSecurityKey(
|
|
Encoding.UTF8.GetBytes(_configuration["Jwt:SecretKey"] ??
|
|
throw new InvalidOperationException("JWT SecretKey not configured")));
|
|
|
|
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
|
|
|
|
var claims = new List<Claim>
|
|
{
|
|
new(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
|
|
new(JwtRegisteredClaimNames.Email, user.Email.Value),
|
|
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
|
new("user_id", user.Id.ToString()),
|
|
new("tenant_id", tenant.Id.ToString()),
|
|
new("tenant_slug", tenant.Slug.Value),
|
|
new("tenant_plan", tenant.Plan.ToString()),
|
|
new("full_name", user.FullName.Value),
|
|
new("auth_provider", user.AuthProvider.ToString()),
|
|
|
|
// NEW: Tenant-level role
|
|
new("tenant_role", tenantRole.ToString()),
|
|
new(ClaimTypes.Role, tenantRole.ToString()) // Standard claim for [Authorize(Roles = "...")]
|
|
};
|
|
|
|
var token = new JwtSecurityToken(
|
|
issuer: _configuration["Jwt:Issuer"],
|
|
audience: _configuration["Jwt:Audience"],
|
|
claims: claims,
|
|
expires: DateTime.UtcNow.AddMinutes(Convert.ToDouble(_configuration["Jwt:ExpirationMinutes"] ?? "60")),
|
|
signingCredentials: credentials
|
|
);
|
|
|
|
return new JwtSecurityTokenHandler().WriteToken(token);
|
|
}
|
|
```
|
|
|
|
#### 2.2.6 Authorization Attributes
|
|
|
|
**Custom Authorization Attribute** (`API/Authorization/RequireTenantRoleAttribute.cs`):
|
|
|
|
```csharp
|
|
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)]
|
|
public class RequireTenantRoleAttribute : AuthorizeAttribute
|
|
{
|
|
public RequireTenantRoleAttribute(params TenantRole[] roles)
|
|
{
|
|
Roles = string.Join(",", roles.Select(r => r.ToString()));
|
|
}
|
|
}
|
|
```
|
|
|
|
**Usage Examples**:
|
|
|
|
```csharp
|
|
// Controller-level authorization
|
|
[ApiController]
|
|
[Route("api/tenants")]
|
|
[RequireTenantRole(TenantRole.TenantAdmin, TenantRole.TenantOwner)]
|
|
public class TenantManagementController : ControllerBase
|
|
{
|
|
// All actions require TenantAdmin or TenantOwner
|
|
}
|
|
|
|
// Action-level authorization
|
|
[HttpDelete("{userId}")]
|
|
[RequireTenantRole(TenantRole.TenantOwner)]
|
|
public async Task<IActionResult> DeleteUser(Guid userId)
|
|
{
|
|
// Only TenantOwner can delete users
|
|
}
|
|
|
|
// Fine-grained authorization
|
|
[HttpPost("projects")]
|
|
[Authorize] // Any authenticated user
|
|
public async Task<IActionResult> CreateProject([FromBody] CreateProjectCommand command)
|
|
{
|
|
// Check role in code for complex logic
|
|
var tenantRole = User.FindFirstValue("tenant_role");
|
|
if (tenantRole == "TenantGuest")
|
|
{
|
|
return Forbid("Guests cannot create projects");
|
|
}
|
|
|
|
// Continue with project creation
|
|
}
|
|
```
|
|
|
|
#### 2.2.7 Repository Pattern
|
|
|
|
**IUserTenantRoleRepository** (`Domain/Repositories/IUserTenantRoleRepository.cs`):
|
|
|
|
```csharp
|
|
public interface IUserTenantRoleRepository
|
|
{
|
|
Task<UserTenantRole?> GetByUserAndTenantAsync(
|
|
Guid userId,
|
|
Guid tenantId,
|
|
CancellationToken cancellationToken = default);
|
|
|
|
Task<IReadOnlyList<UserTenantRole>> GetByTenantAsync(
|
|
Guid tenantId,
|
|
CancellationToken cancellationToken = default);
|
|
|
|
Task<IReadOnlyList<UserTenantRole>> GetByUserAsync(
|
|
Guid userId,
|
|
CancellationToken cancellationToken = default);
|
|
|
|
Task AddAsync(UserTenantRole role, CancellationToken cancellationToken = default);
|
|
Task UpdateAsync(UserTenantRole role, CancellationToken cancellationToken = default);
|
|
Task DeleteAsync(UserTenantRole role, CancellationToken cancellationToken = default);
|
|
}
|
|
```
|
|
|
|
#### 2.2.8 Command Handlers Update
|
|
|
|
**Update RegisterTenantCommandHandler** to assign TenantOwner role:
|
|
|
|
```csharp
|
|
public async Task<TenantDto> Handle(RegisterTenantCommand request, CancellationToken cancellationToken)
|
|
{
|
|
// ... existing validation ...
|
|
|
|
// Create tenant
|
|
var tenant = Tenant.Create(tenantName, tenantSlug, subscriptionPlan);
|
|
await _tenantRepository.AddAsync(tenant, cancellationToken);
|
|
|
|
// Create admin user
|
|
var hashedPassword = _passwordHasher.HashPassword(request.AdminPassword);
|
|
var adminUser = User.CreateLocal(
|
|
TenantId.From(tenant.Id),
|
|
email,
|
|
hashedPassword,
|
|
fullName);
|
|
|
|
await _userRepository.AddAsync(adminUser, cancellationToken);
|
|
|
|
// NEW: Assign TenantOwner role to admin
|
|
var tenantRole = UserTenantRole.Create(
|
|
UserId.From(adminUser.Id),
|
|
TenantId.From(tenant.Id),
|
|
TenantRole.TenantOwner);
|
|
|
|
await _userTenantRoleRepository.AddAsync(tenantRole, cancellationToken);
|
|
|
|
// Generate JWT with role
|
|
var token = _jwtService.GenerateToken(adminUser, tenant, TenantRole.TenantOwner);
|
|
|
|
// ... rest of handler ...
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 3. Email Verification Flow
|
|
|
|
### 3.1 Background & Goals
|
|
|
|
**Problem**: Users can register with any email without verification, leading to:
|
|
- Invalid email addresses in system
|
|
- Security risk (account takeover)
|
|
- Compliance issues (GDPR)
|
|
|
|
**Goals**:
|
|
- Verify email ownership during registration
|
|
- Support re-sending verification emails
|
|
- Block unverified users from critical actions
|
|
- Prepare for password reset flow
|
|
|
|
### 3.2 Architecture Design
|
|
|
|
#### 3.2.1 Verification Flow Diagram
|
|
|
|
```
|
|
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
|
│ Client │ │ API Server │ │Email Service│
|
|
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
|
|
│ │ │
|
|
│ 1. Register (email) │ │
|
|
├────────────────────────────────>│ │
|
|
│ │ │
|
|
│ │ 2. Generate token │
|
|
│ │ Save to DB │
|
|
│ │ │
|
|
│ │ 3. Send verification email│
|
|
│ ├───────────────────────────>│
|
|
│ │ │
|
|
│ 4. Success (please check email)│ │
|
|
│<────────────────────────────────┤ │
|
|
│ │ │
|
|
│ │ 5. Email delivered │
|
|
│ │<───────────────────────────┤
|
|
│ │ │
|
|
│ 6. Click verification link │ │
|
|
│ (GET /verify-email?token=XX)│ │
|
|
├────────────────────────────────>│ │
|
|
│ │ │
|
|
│ │ 7. Validate token │
|
|
│ │ Update EmailVerifiedAt │
|
|
│ │ │
|
|
│ 8. Email verified (redirect) │ │
|
|
│<────────────────────────────────┤ │
|
|
│ │ │
|
|
```
|
|
|
|
#### 3.2.2 Token Design
|
|
|
|
**Token Structure**:
|
|
- Base64-encoded GUID (URL-safe)
|
|
- Expiration: 24 hours (configurable)
|
|
- One-time use only
|
|
- Stored as SHA-256 hash in database
|
|
|
|
**Token Generation**:
|
|
|
|
```csharp
|
|
public string GenerateEmailVerificationToken()
|
|
{
|
|
var tokenBytes = new byte[32];
|
|
using var rng = RandomNumberGenerator.Create();
|
|
rng.GetBytes(tokenBytes);
|
|
return Convert.ToBase64String(tokenBytes)
|
|
.Replace("+", "-")
|
|
.Replace("/", "_")
|
|
.TrimEnd('='); // URL-safe base64
|
|
}
|
|
```
|
|
|
|
#### 3.2.3 Database Schema (Already Exists)
|
|
|
|
The `User` entity already has email verification fields:
|
|
|
|
```csharp
|
|
public DateTime? EmailVerifiedAt { get; private set; }
|
|
public string? EmailVerificationToken { get; private set; }
|
|
```
|
|
|
|
**Add expiration field**:
|
|
|
|
```sql
|
|
ALTER TABLE identity.users
|
|
ADD COLUMN email_verification_token_expires_at TIMESTAMP NULL;
|
|
```
|
|
|
|
Update `User.cs`:
|
|
|
|
```csharp
|
|
public DateTime? EmailVerificationTokenExpiresAt { get; private set; }
|
|
|
|
public void SetEmailVerificationToken(string token, DateTime expiresAt)
|
|
{
|
|
EmailVerificationToken = ComputeSha256Hash(token); // Store hash
|
|
EmailVerificationTokenExpiresAt = expiresAt;
|
|
UpdatedAt = DateTime.UtcNow;
|
|
}
|
|
|
|
public bool IsEmailVerificationTokenValid(string token)
|
|
{
|
|
if (EmailVerificationToken == null || EmailVerificationTokenExpiresAt == null)
|
|
return false;
|
|
|
|
if (DateTime.UtcNow > EmailVerificationTokenExpiresAt)
|
|
return false;
|
|
|
|
var tokenHash = ComputeSha256Hash(token);
|
|
return EmailVerificationToken == tokenHash;
|
|
}
|
|
```
|
|
|
|
#### 3.2.4 Email Service Design
|
|
|
|
**Interface**: `Application/Services/IEmailService.cs`
|
|
|
|
```csharp
|
|
public interface IEmailService
|
|
{
|
|
Task SendEmailVerificationAsync(
|
|
string recipientEmail,
|
|
string recipientName,
|
|
string verificationToken,
|
|
CancellationToken cancellationToken = default);
|
|
|
|
Task SendPasswordResetAsync(
|
|
string recipientEmail,
|
|
string recipientName,
|
|
string resetToken,
|
|
CancellationToken cancellationToken = default);
|
|
|
|
Task SendWelcomeEmailAsync(
|
|
string recipientEmail,
|
|
string recipientName,
|
|
CancellationToken cancellationToken = default);
|
|
}
|
|
```
|
|
|
|
**Implementation Options**:
|
|
|
|
| Provider | Pros | Cons | Cost |
|
|
|----------|------|------|------|
|
|
| **SendGrid** | Easy setup, 100 emails/day free | Rate limits | Free/Paid |
|
|
| **AWS SES** | Scalable, cheap (0.10/1000) | Complex setup | Pay-as-you-go |
|
|
| **MailKit (SMTP)** | No external dependency | Requires SMTP server | Self-hosted |
|
|
| **Mailgun** | Developer-friendly API | Limited free tier | Free/Paid |
|
|
|
|
**Recommendation**: **SendGrid for MVP** (easy setup, generous free tier)
|
|
|
|
**Implementation**: `Infrastructure/Services/SendGridEmailService.cs`
|
|
|
|
```csharp
|
|
public class SendGridEmailService : IEmailService
|
|
{
|
|
private readonly IConfiguration _configuration;
|
|
private readonly ILogger<SendGridEmailService> _logger;
|
|
private readonly SendGridClient _client;
|
|
|
|
public SendGridEmailService(IConfiguration configuration, ILogger<SendGridEmailService> logger)
|
|
{
|
|
_configuration = configuration;
|
|
_logger = logger;
|
|
|
|
var apiKey = _configuration["SendGrid:ApiKey"];
|
|
if (string.IsNullOrEmpty(apiKey))
|
|
throw new InvalidOperationException("SendGrid API key not configured");
|
|
|
|
_client = new SendGridClient(apiKey);
|
|
}
|
|
|
|
public async Task SendEmailVerificationAsync(
|
|
string recipientEmail,
|
|
string recipientName,
|
|
string verificationToken,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var from = new EmailAddress(
|
|
_configuration["SendGrid:FromEmail"] ?? "noreply@colaflow.com",
|
|
"ColaFlow");
|
|
|
|
var to = new EmailAddress(recipientEmail, recipientName);
|
|
|
|
var verificationUrl = $"{_configuration["App:BaseUrl"]}/verify-email?token={verificationToken}";
|
|
|
|
var subject = "Verify your ColaFlow email address";
|
|
var plainTextContent = $"Please verify your email by clicking: {verificationUrl}";
|
|
var htmlContent = $@"
|
|
<h2>Welcome to ColaFlow!</h2>
|
|
<p>Please verify your email address by clicking the button below:</p>
|
|
<p><a href=""{verificationUrl}"" style=""background-color: #4CAF50; color: white; padding: 14px 20px; text-decoration: none; border-radius: 4px;"">Verify Email</a></p>
|
|
<p>Or copy and paste this link into your browser:</p>
|
|
<p>{verificationUrl}</p>
|
|
<p>This link expires in 24 hours.</p>
|
|
";
|
|
|
|
var msg = MailHelper.CreateSingleEmail(from, to, subject, plainTextContent, htmlContent);
|
|
|
|
var response = await _client.SendEmailAsync(msg, cancellationToken);
|
|
|
|
if (response.StatusCode != System.Net.HttpStatusCode.OK &&
|
|
response.StatusCode != System.Net.HttpStatusCode.Accepted)
|
|
{
|
|
_logger.LogError("Failed to send verification email to {Email}, status: {Status}",
|
|
recipientEmail, response.StatusCode);
|
|
throw new InvalidOperationException("Failed to send verification email");
|
|
}
|
|
|
|
_logger.LogInformation("Sent verification email to {Email}", recipientEmail);
|
|
}
|
|
}
|
|
```
|
|
|
|
#### 3.2.5 Command Handlers
|
|
|
|
**New Command**: `Application/Commands/VerifyEmail/VerifyEmailCommand.cs`
|
|
|
|
```csharp
|
|
public record VerifyEmailCommand(string Token) : IRequest<bool>;
|
|
|
|
public class VerifyEmailCommandHandler : IRequestHandler<VerifyEmailCommand, bool>
|
|
{
|
|
private readonly IUserRepository _userRepository;
|
|
private readonly ILogger<VerifyEmailCommandHandler> _logger;
|
|
|
|
public async Task<bool> Handle(VerifyEmailCommand request, CancellationToken cancellationToken)
|
|
{
|
|
// Find user by token hash
|
|
var tokenHash = ComputeSha256Hash(request.Token);
|
|
var user = await _userRepository.GetByEmailVerificationTokenAsync(tokenHash, cancellationToken);
|
|
|
|
if (user == null)
|
|
{
|
|
_logger.LogWarning("Email verification failed: token not found");
|
|
return false;
|
|
}
|
|
|
|
// Validate token
|
|
if (!user.IsEmailVerificationTokenValid(request.Token))
|
|
{
|
|
_logger.LogWarning("Email verification failed for user {UserId}: token invalid or expired", user.Id);
|
|
return false;
|
|
}
|
|
|
|
// Verify email
|
|
user.VerifyEmail();
|
|
await _userRepository.UpdateAsync(user, cancellationToken);
|
|
|
|
_logger.LogInformation("Email verified for user {UserId}", user.Id);
|
|
|
|
return true;
|
|
}
|
|
|
|
private static string ComputeSha256Hash(string input)
|
|
{
|
|
using var sha256 = SHA256.Create();
|
|
var bytes = Encoding.UTF8.GetBytes(input);
|
|
var hash = sha256.ComputeHash(bytes);
|
|
return Convert.ToBase64String(hash);
|
|
}
|
|
}
|
|
```
|
|
|
|
**New Command**: `Application/Commands/ResendVerificationEmail/ResendVerificationEmailCommand.cs`
|
|
|
|
```csharp
|
|
public record ResendVerificationEmailCommand(string Email, string TenantSlug) : IRequest<bool>;
|
|
|
|
public class ResendVerificationEmailCommandHandler : IRequestHandler<ResendVerificationEmailCommand, bool>
|
|
{
|
|
private readonly IUserRepository _userRepository;
|
|
private readonly ITenantRepository _tenantRepository;
|
|
private readonly IEmailService _emailService;
|
|
private readonly ILogger<ResendVerificationEmailCommandHandler> _logger;
|
|
|
|
public async Task<bool> Handle(ResendVerificationEmailCommand request, CancellationToken cancellationToken)
|
|
{
|
|
// Find user
|
|
var tenant = await _tenantRepository.GetBySlugAsync(request.TenantSlug, cancellationToken);
|
|
if (tenant == null) return false;
|
|
|
|
var user = await _userRepository.GetByEmailAsync(request.Email, tenant.Id, cancellationToken);
|
|
if (user == null) return false;
|
|
|
|
// Check if already verified
|
|
if (user.EmailVerifiedAt.HasValue)
|
|
{
|
|
_logger.LogInformation("User {UserId} already verified", user.Id);
|
|
return true; // Already verified, consider success
|
|
}
|
|
|
|
// Generate new token
|
|
var token = GenerateEmailVerificationToken();
|
|
var expiresAt = DateTime.UtcNow.AddHours(24);
|
|
user.SetEmailVerificationToken(token, expiresAt);
|
|
|
|
await _userRepository.UpdateAsync(user, cancellationToken);
|
|
|
|
// Send email
|
|
await _emailService.SendEmailVerificationAsync(
|
|
user.Email.Value,
|
|
user.FullName.Value,
|
|
token,
|
|
cancellationToken);
|
|
|
|
_logger.LogInformation("Resent verification email to user {UserId}", user.Id);
|
|
|
|
return true;
|
|
}
|
|
}
|
|
```
|
|
|
|
#### 3.2.6 API Endpoints
|
|
|
|
```csharp
|
|
[HttpGet("verify-email")]
|
|
[AllowAnonymous]
|
|
public async Task<IActionResult> VerifyEmail([FromQuery] string token)
|
|
{
|
|
if (string.IsNullOrEmpty(token))
|
|
return BadRequest(new { message = "Token is required" });
|
|
|
|
var command = new VerifyEmailCommand(token);
|
|
var result = await _mediator.Send(command);
|
|
|
|
if (result)
|
|
{
|
|
// Redirect to success page
|
|
return Redirect($"{_configuration["App:FrontendUrl"]}/email-verified");
|
|
}
|
|
else
|
|
{
|
|
// Redirect to error page
|
|
return Redirect($"{_configuration["App:FrontendUrl"]}/email-verification-failed");
|
|
}
|
|
}
|
|
|
|
[HttpPost("resend-verification")]
|
|
[AllowAnonymous]
|
|
public async Task<IActionResult> ResendVerification([FromBody] ResendVerificationRequest request)
|
|
{
|
|
var command = new ResendVerificationEmailCommand(request.Email, request.TenantSlug);
|
|
var result = await _mediator.Send(command);
|
|
|
|
// Always return success to prevent email enumeration
|
|
return Ok(new { message = "If the email exists, a verification link has been sent" });
|
|
}
|
|
|
|
[HttpGet("me")]
|
|
[Authorize]
|
|
public async Task<IActionResult> GetCurrentUser()
|
|
{
|
|
var userId = Guid.Parse(User.FindFirstValue("user_id")!);
|
|
var user = await _userRepository.GetByIdAsync(userId);
|
|
|
|
return Ok(new
|
|
{
|
|
userId = user.Id,
|
|
email = user.Email.Value,
|
|
fullName = user.FullName.Value,
|
|
emailVerified = user.EmailVerifiedAt.HasValue,
|
|
emailVerifiedAt = user.EmailVerifiedAt
|
|
});
|
|
}
|
|
```
|
|
|
|
#### 3.2.7 Update RegisterTenant Flow
|
|
|
|
**Update `RegisterTenantCommandHandler.cs`**:
|
|
|
|
```csharp
|
|
public async Task<TenantDto> Handle(RegisterTenantCommand request, CancellationToken cancellationToken)
|
|
{
|
|
// ... existing validation and creation ...
|
|
|
|
// Create admin user
|
|
var hashedPassword = _passwordHasher.HashPassword(request.AdminPassword);
|
|
var adminUser = User.CreateLocal(tenantId, email, hashedPassword, fullName);
|
|
|
|
// Generate email verification token
|
|
var verificationToken = GenerateEmailVerificationToken();
|
|
var tokenExpiresAt = DateTime.UtcNow.AddHours(24);
|
|
adminUser.SetEmailVerificationToken(verificationToken, tokenExpiresAt);
|
|
|
|
await _userRepository.AddAsync(adminUser, cancellationToken);
|
|
|
|
// Send verification email
|
|
await _emailService.SendEmailVerificationAsync(
|
|
adminUser.Email.Value,
|
|
adminUser.FullName.Value,
|
|
verificationToken,
|
|
cancellationToken);
|
|
|
|
// Generate JWT (user can login even if email not verified)
|
|
var token = _jwtService.GenerateToken(adminUser, tenant, TenantRole.TenantOwner);
|
|
|
|
_logger.LogInformation(
|
|
"Tenant {TenantId} registered, verification email sent to {Email}",
|
|
tenant.Id, adminUser.Email.Value);
|
|
|
|
// ... return response ...
|
|
}
|
|
```
|
|
|
|
#### 3.2.8 Configuration
|
|
|
|
**appsettings.Development.json**:
|
|
|
|
```json
|
|
{
|
|
"SendGrid": {
|
|
"ApiKey": "${SENDGRID_API_KEY}",
|
|
"FromEmail": "noreply@colaflow.com",
|
|
"FromName": "ColaFlow"
|
|
},
|
|
"App": {
|
|
"BaseUrl": "http://localhost:5167",
|
|
"FrontendUrl": "http://localhost:3000"
|
|
},
|
|
"EmailVerification": {
|
|
"TokenExpirationHours": "24",
|
|
"RequireVerification": "false"
|
|
}
|
|
}
|
|
```
|
|
|
|
**appsettings.Production.json**:
|
|
|
|
```json
|
|
{
|
|
"SendGrid": {
|
|
"ApiKey": "${SENDGRID_API_KEY}",
|
|
"FromEmail": "noreply@colaflow.com",
|
|
"FromName": "ColaFlow"
|
|
},
|
|
"App": {
|
|
"BaseUrl": "https://api.colaflow.com",
|
|
"FrontendUrl": "https://app.colaflow.com"
|
|
},
|
|
"EmailVerification": {
|
|
"TokenExpirationHours": "24",
|
|
"RequireVerification": "true"
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 4. Risk Assessment
|
|
|
|
### 4.1 Technical Risks
|
|
|
|
| Risk | Impact | Probability | Mitigation |
|
|
|------|--------|-------------|------------|
|
|
| **Refresh token database performance** | Medium | Low | Add proper indexes, implement cleanup job, plan Redis migration |
|
|
| **Token family revocation complexity** | Medium | Medium | Thorough testing, clear logging, transaction safety |
|
|
| **Email delivery failures** | High | Medium | Implement retry mechanism, queue system (future), fallback SMTP |
|
|
| **Role permission escalation** | High | Low | Comprehensive testing, audit logging, code review |
|
|
| **Migration data corruption** | High | Low | Test migrations thoroughly, backup database, use transactions |
|
|
|
|
### 4.2 Security Risks
|
|
|
|
| Risk | Impact | Mitigation |
|
|
|------|--------|------------|
|
|
| **Token theft** | High | Token rotation, family revocation, HTTPS-only, IP tracking |
|
|
| **Privilege escalation** | High | Policy-based authorization, audit logs, principle of least privilege |
|
|
| **Email enumeration** | Medium | Generic error messages, rate limiting |
|
|
| **Token replay attacks** | High | One-time use tokens, token family tracking |
|
|
| **Brute force token guessing** | Medium | Cryptographically secure tokens (64 bytes), short expiration |
|
|
|
|
### 4.3 Complexity Assessment
|
|
|
|
| Feature | Complexity | Development Time | Testing Time |
|
|
|---------|-----------|------------------|--------------|
|
|
| **Refresh Token** | Medium | 4-6 hours | 2-3 hours |
|
|
| **RBAC** | Medium-High | 6-8 hours | 3-4 hours |
|
|
| **Email Verification** | Low-Medium | 3-4 hours | 2 hours |
|
|
| **Total** | - | **13-18 hours** | **7-9 hours** |
|
|
|
|
**Total Estimated Time**: 20-27 hours (2.5-3.5 days)
|
|
|
|
---
|
|
|
|
## 5. Implementation Roadmap
|
|
|
|
### 5.1 Phase 1: Refresh Token (Priority 1) - Day 5 Morning
|
|
|
|
**Tasks**:
|
|
1. Create database migration for `refresh_tokens` table
|
|
2. Implement `RefreshToken` domain entity
|
|
3. Implement `IRefreshTokenRepository` and repository
|
|
4. Implement `IRefreshTokenService` and service
|
|
5. Update `JwtService` to support refresh token generation
|
|
6. Add `/api/auth/refresh`, `/api/auth/logout`, `/api/auth/logout-all` endpoints
|
|
7. Update `LoginCommandHandler` to return refresh token
|
|
8. Test token rotation and revocation
|
|
|
|
**Files to Create**:
|
|
- `Domain/Aggregates/Users/RefreshToken.cs`
|
|
- `Domain/Repositories/IRefreshTokenRepository.cs`
|
|
- `Infrastructure/Persistence/Configurations/RefreshTokenConfiguration.cs`
|
|
- `Infrastructure/Persistence/Repositories/RefreshTokenRepository.cs`
|
|
- `Application/Services/IRefreshTokenService.cs`
|
|
- `Infrastructure/Services/RefreshTokenService.cs`
|
|
- `Infrastructure/Persistence/Migrations/XXXXXX_AddRefreshTokens.cs`
|
|
|
|
**Files to Modify**:
|
|
- `Application/Commands/Login/LoginCommandHandler.cs`
|
|
- `API/Controllers/AuthController.cs`
|
|
- `appsettings.Development.json`
|
|
|
|
### 5.2 Phase 2: RBAC (Priority 1) - Day 5 Afternoon
|
|
|
|
**Tasks**:
|
|
1. Create database migration for `user_tenant_roles` table
|
|
2. Implement `TenantRole` enum and `UserTenantRole` entity
|
|
3. Implement `IUserTenantRoleRepository` and repository
|
|
4. Update `JwtService` to include role claims
|
|
5. Configure authorization policies in `Program.cs`
|
|
6. Update `RegisterTenantCommandHandler` to assign TenantOwner role
|
|
7. Update `LoginCommandHandler` to load user role
|
|
8. Test role-based authorization
|
|
|
|
**Files to Create**:
|
|
- `Domain/Aggregates/Users/TenantRole.cs`
|
|
- `Domain/Aggregates/Users/UserTenantRole.cs`
|
|
- `Domain/Repositories/IUserTenantRoleRepository.cs`
|
|
- `Infrastructure/Persistence/Configurations/UserTenantRoleConfiguration.cs`
|
|
- `Infrastructure/Persistence/Repositories/UserTenantRoleRepository.cs`
|
|
- `API/Authorization/RequireTenantRoleAttribute.cs`
|
|
- `Infrastructure/Persistence/Migrations/XXXXXX_AddUserTenantRoles.cs`
|
|
|
|
**Files to Modify**:
|
|
- `Infrastructure/Services/JwtService.cs`
|
|
- `Application/Commands/RegisterTenant/RegisterTenantCommandHandler.cs`
|
|
- `Application/Commands/Login/LoginCommandHandler.cs`
|
|
- `API/Program.cs`
|
|
- `API/Controllers/AuthController.cs` (add role info to `/me` endpoint)
|
|
|
|
### 5.3 Phase 3: Email Verification (Priority 2) - Day 6 Morning (Optional)
|
|
|
|
**Tasks**:
|
|
1. Create database migration to add `EmailVerificationTokenExpiresAt` column
|
|
2. Update `User` entity with token validation methods
|
|
3. Implement `IEmailService` interface
|
|
4. Implement `SendGridEmailService` (or SMTP fallback)
|
|
5. Create `VerifyEmailCommand` and handler
|
|
6. Create `ResendVerificationEmailCommand` and handler
|
|
7. Update `RegisterTenantCommandHandler` to send verification email
|
|
8. Add `/api/auth/verify-email` and `/api/auth/resend-verification` endpoints
|
|
9. Test email flow end-to-end
|
|
|
|
**Files to Create**:
|
|
- `Application/Services/IEmailService.cs`
|
|
- `Infrastructure/Services/SendGridEmailService.cs`
|
|
- `Application/Commands/VerifyEmail/VerifyEmailCommand.cs`
|
|
- `Application/Commands/VerifyEmail/VerifyEmailCommandHandler.cs`
|
|
- `Application/Commands/ResendVerificationEmail/ResendVerificationEmailCommand.cs`
|
|
- `Application/Commands/ResendVerificationEmail/ResendVerificationEmailCommandHandler.cs`
|
|
- `Infrastructure/Persistence/Migrations/XXXXXX_AddEmailVerificationExpiration.cs`
|
|
|
|
**Files to Modify**:
|
|
- `Domain/Aggregates/Users/User.cs`
|
|
- `Application/Commands/RegisterTenant/RegisterTenantCommandHandler.cs`
|
|
- `API/Controllers/AuthController.cs`
|
|
- `Infrastructure/DependencyInjection.cs`
|
|
- `appsettings.Development.json`
|
|
|
|
### 5.4 Testing Strategy
|
|
|
|
**Unit Tests**:
|
|
- `RefreshToken` entity business logic
|
|
- `UserTenantRole` entity business logic
|
|
- `User.VerifyEmail()` and token validation methods
|
|
- `RefreshTokenService` token generation and rotation
|
|
- JWT claims generation with roles
|
|
|
|
**Integration Tests**:
|
|
- Full refresh token flow (generate → use → rotate → revoke)
|
|
- Role-based authorization (correct roles allowed, others denied)
|
|
- Email verification flow (send → verify → check status)
|
|
- Token family revocation on suspicious activity
|
|
|
|
**Security Tests**:
|
|
- Token reuse detection
|
|
- Expired token rejection
|
|
- Invalid role access denial
|
|
- Email enumeration prevention
|
|
|
|
---
|
|
|
|
## 6. MCP Integration Considerations
|
|
|
|
### 6.1 Authentication for MCP Server
|
|
|
|
When implementing MCP Server (future), the authentication system needs to support:
|
|
|
|
1. **API Key Authentication** (for AI tools):
|
|
- Generate long-lived API keys per tenant
|
|
- API keys inherit user's tenant role
|
|
- Scoped permissions (read-only, write with approval)
|
|
|
|
2. **OAuth 2.0 for Third-Party MCP Clients**:
|
|
- Authorization code flow
|
|
- Scope-based permissions
|
|
- Refresh token support
|
|
|
|
### 6.2 Permission Model for MCP
|
|
|
|
**MCP-specific permissions** (future expansion):
|
|
|
|
```csharp
|
|
public enum McpPermission
|
|
{
|
|
// Resource permissions
|
|
ReadProjects,
|
|
ReadIssues,
|
|
ReadDocuments,
|
|
|
|
// Tool permissions (with human approval)
|
|
CreateIssue,
|
|
UpdateIssueStatus,
|
|
CreateDocument,
|
|
LogDecision,
|
|
|
|
// Admin permissions
|
|
ManageIntegrations,
|
|
ViewAuditLogs
|
|
}
|
|
```
|
|
|
|
**RBAC → MCP Permission Mapping**:
|
|
|
|
| Tenant Role | MCP Read | MCP Write | MCP Admin |
|
|
|-------------|----------|-----------|-----------|
|
|
| TenantOwner | ✅ | ✅ (with approval) | ✅ |
|
|
| TenantAdmin | ✅ | ✅ (with approval) | ✅ |
|
|
| TenantMember | ✅ | ✅ (with approval) | ❌ |
|
|
| TenantGuest | ✅ | ❌ | ❌ |
|
|
|
|
### 6.3 Audit Logging for MCP Operations
|
|
|
|
All MCP operations should be logged with:
|
|
- User/API key identifier
|
|
- Action performed
|
|
- Timestamp
|
|
- IP address
|
|
- Approval status (if required)
|
|
|
|
**Schema** (future):
|
|
|
|
```sql
|
|
CREATE TABLE audit.mcp_operations (
|
|
id UUID PRIMARY KEY,
|
|
user_id UUID NOT NULL,
|
|
tenant_id UUID NOT NULL,
|
|
operation VARCHAR(100) NOT NULL,
|
|
resource_type VARCHAR(50) NOT NULL,
|
|
resource_id UUID NULL,
|
|
approved_by_user_id UUID NULL,
|
|
approved_at TIMESTAMP NULL,
|
|
created_at TIMESTAMP NOT NULL,
|
|
ip_address VARCHAR(45) NULL
|
|
);
|
|
```
|
|
|
|
---
|
|
|
|
## 7. Configuration Summary
|
|
|
|
### 7.1 Required Environment Variables
|
|
|
|
**Production**:
|
|
```bash
|
|
# JWT Configuration
|
|
JWT_SECRET_KEY=<64-character-random-string>
|
|
|
|
# SendGrid (Email)
|
|
SENDGRID_API_KEY=<sendgrid-api-key>
|
|
|
|
# Database
|
|
DATABASE_CONNECTION_STRING=<postgresql-connection-string>
|
|
|
|
# Application URLs
|
|
APP_BASE_URL=https://api.colaflow.com
|
|
APP_FRONTEND_URL=https://app.colaflow.com
|
|
```
|
|
|
|
### 7.2 NuGet Packages Required
|
|
|
|
```xml
|
|
<!-- Identity.Infrastructure -->
|
|
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.14.0" />
|
|
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
|
|
|
|
<!-- For Email (choose one) -->
|
|
<PackageReference Include="SendGrid" Version="9.28.1" />
|
|
<!-- OR -->
|
|
<PackageReference Include="MailKit" Version="4.3.0" />
|
|
```
|
|
|
|
---
|
|
|
|
## 8. Success Criteria
|
|
|
|
### 8.1 Refresh Token
|
|
|
|
- [ ] Users can obtain refresh token on login
|
|
- [ ] Refresh token can be used to get new access token
|
|
- [ ] Refresh token rotation works correctly
|
|
- [ ] Token reuse is detected and entire family is revoked
|
|
- [ ] Users can logout from current device
|
|
- [ ] Users can logout from all devices
|
|
- [ ] Expired tokens are rejected
|
|
|
|
### 8.2 RBAC
|
|
|
|
- [ ] New tenants have TenantOwner role assigned
|
|
- [ ] JWT tokens contain role claims
|
|
- [ ] Role-based authorization works at endpoint level
|
|
- [ ] Different roles have different permissions
|
|
- [ ] Unauthorized access returns 403 Forbidden
|
|
- [ ] Role information visible in `/me` endpoint
|
|
|
|
### 8.3 Email Verification
|
|
|
|
- [ ] Verification email sent on registration
|
|
- [ ] Verification link works and marks email as verified
|
|
- [ ] Expired verification links are rejected
|
|
- [ ] Users can resend verification email
|
|
- [ ] Email verification status visible in user profile
|
|
|
|
---
|
|
|
|
## 9. Performance Considerations
|
|
|
|
### 9.1 Database Optimization
|
|
|
|
**Indexes**:
|
|
- All foreign keys indexed
|
|
- Token hash columns indexed (for fast lookup)
|
|
- Composite index on (expires_at, revoked_at) for cleanup queries
|
|
|
|
**Query Performance**:
|
|
- Refresh token lookup: < 10ms (indexed)
|
|
- Role lookup: < 5ms (indexed)
|
|
- User verification: < 15ms (indexed)
|
|
|
|
### 9.2 Caching Strategy (Future)
|
|
|
|
**Redis caching candidates**:
|
|
- User roles (cache for 5 minutes)
|
|
- Refresh token validity (cache for token lifetime)
|
|
- Email verification status (cache for 1 hour)
|
|
|
|
---
|
|
|
|
## 10. Rollback Plan
|
|
|
|
### 10.1 Database Rollback
|
|
|
|
All migrations must have `Down()` methods:
|
|
|
|
```csharp
|
|
protected override void Down(MigrationBuilder migrationBuilder)
|
|
{
|
|
migrationBuilder.DropTable(
|
|
name: "refresh_tokens",
|
|
schema: "identity");
|
|
|
|
migrationBuilder.DropTable(
|
|
name: "user_tenant_roles",
|
|
schema: "identity");
|
|
|
|
migrationBuilder.DropColumn(
|
|
name: "email_verification_token_expires_at",
|
|
schema: "identity",
|
|
table: "users");
|
|
}
|
|
```
|
|
|
|
### 10.2 Feature Flags
|
|
|
|
Consider adding feature flags for gradual rollout:
|
|
|
|
```json
|
|
{
|
|
"Features": {
|
|
"RefreshToken": true,
|
|
"RoleBasedAuthorization": true,
|
|
"EmailVerification": false
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 11. Documentation Requirements
|
|
|
|
**API Documentation** (Swagger/OpenAPI):
|
|
- Document all new endpoints
|
|
- Include request/response examples
|
|
- Document error codes
|
|
|
|
**Developer Documentation**:
|
|
- How to configure SendGrid
|
|
- How to test authentication flow locally
|
|
- How to add new roles
|
|
|
|
**Security Documentation**:
|
|
- Token rotation mechanism
|
|
- Role hierarchy
|
|
- Permission model
|
|
|
|
---
|
|
|
|
## Conclusion
|
|
|
|
This architecture design provides a comprehensive, secure, and scalable foundation for Day 5 development. The design prioritizes:
|
|
|
|
1. **Security**: Token rotation, hash storage, audit logging
|
|
2. **Scalability**: PostgreSQL for MVP with clear Redis migration path
|
|
3. **Extensibility**: RBAC system ready for MCP integration
|
|
4. **Maintainability**: Clean architecture, clear separation of concerns
|
|
|
|
**Recommended Implementation Order**:
|
|
1. Refresh Token (4-6 hours) - Critical for user experience
|
|
2. RBAC (6-8 hours) - Foundation for all future authorization
|
|
3. Email Verification (3-4 hours) - Important for security and compliance
|
|
|
|
**Total Estimated Time**: 20-27 hours (2.5-3.5 days of focused development)
|
|
|
|
The architecture is production-ready with appropriate configuration changes and aligns with the ColaFlow vision of secure, AI-powered project management.
|
|
|
|
---
|
|
|
|
**Next Steps**:
|
|
1. Review and approve architecture design
|
|
2. Set up development environment (SendGrid account, test database)
|
|
3. Begin implementation starting with Refresh Token
|
|
4. Execute comprehensive testing after each phase
|
|
5. Update Day 5 documentation with actual implementation details
|
|
|
|
---
|
|
|
|
**Document Version**: 1.0
|
|
**Last Updated**: 2025-11-03
|
|
**Status**: Ready for Implementation
|