diff --git a/.claude/settings.local.json b/.claude/settings.local.json index faca770..88dfa8c 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,7 +1,13 @@ { "permissions": { "allow": [ - "Bash(powershell:*)" + "Bash(powershell:*)", + "Bash(dotnet ef migrations add:*)", + "Bash(dotnet build:*)", + "Bash(Select-String -Pattern \"error\")", + "Bash(dotnet ef database update:*)", + "Bash(git add:*)", + "Bash(git commit:*)" ], "deny": [], "ask": [] diff --git a/colaflow-api/DAY4-IMPLEMENTATION-SUMMARY.md b/colaflow-api/DAY4-IMPLEMENTATION-SUMMARY.md new file mode 100644 index 0000000..2cd4ab1 --- /dev/null +++ b/colaflow-api/DAY4-IMPLEMENTATION-SUMMARY.md @@ -0,0 +1,389 @@ +# Day 4 Implementation Summary: JWT Service + Password Hashing + Authentication Middleware + +## Date: 2025-11-03 + +--- + +## Overview + +Successfully implemented **Day 4** objectives: +- ✅ JWT Token Generation Service +- ✅ BCrypt Password Hashing Service +- ✅ Real JWT Authentication Middleware +- ✅ Protected Endpoints with [Authorize] +- ✅ Replaced all dummy tokens with real JWT +- ✅ Compilation Successful + +--- + +## Files Created + +### 1. Application Layer Interfaces + +**`src/Modules/Identity/ColaFlow.Modules.Identity.Application/Services/IJwtService.cs`** +```csharp +public interface IJwtService +{ + string GenerateToken(User user, Tenant tenant); + Task GenerateRefreshTokenAsync(User user, CancellationToken cancellationToken = default); +} +``` + +**`src/Modules/Identity/ColaFlow.Modules.Identity.Application/Services/IPasswordHasher.cs`** +```csharp +public interface IPasswordHasher +{ + string HashPassword(string password); + bool VerifyPassword(string password, string hashedPassword); +} +``` + +### 2. Infrastructure Layer Implementations + +**`src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Services/JwtService.cs`** +- Uses `System.IdentityModel.Tokens.Jwt` +- Generates JWT with tenant and user claims +- Configurable via appsettings (Issuer, Audience, SecretKey, Expiration) +- Token includes: user_id, tenant_id, tenant_slug, email, full_name, auth_provider, role + +**`src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Services/PasswordHasher.cs`** +- Uses `BCrypt.Net-Next` +- Work factor: 12 (balance between security and performance) +- HashPassword() - hashes plain text passwords +- VerifyPassword() - verifies password against hash + +--- + +## Files Modified + +### 1. Dependency Injection + +**`src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/DependencyInjection.cs`** +```csharp +// Added services +services.AddScoped(); +services.AddScoped(); +``` + +### 2. Command Handlers + +**`src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/RegisterTenant/RegisterTenantCommandHandler.cs`** +- Removed dummy token generation +- Now uses `IPasswordHasher` to hash admin password +- Now uses `IJwtService` to generate real JWT token + +**`src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/Login/LoginCommandHandler.cs`** +- Removed dummy token generation +- Now uses `IPasswordHasher.VerifyPassword()` to validate password +- Now uses `IJwtService.GenerateToken()` to generate real JWT token + +### 3. API Configuration + +**`src/ColaFlow.API/Program.cs`** +- Added JWT Bearer authentication configuration +- Added authentication and authorization middleware +- Token validation parameters: ValidateIssuer, ValidateAudience, ValidateLifetime, ValidateIssuerSigningKey + +**`src/ColaFlow.API/appsettings.Development.json`** +```json +{ + "Jwt": { + "SecretKey": "your-super-secret-key-min-32-characters-long-12345", + "Issuer": "ColaFlow.API", + "Audience": "ColaFlow.Web", + "ExpirationMinutes": "60" + } +} +``` + +**`src/ColaFlow.API/Controllers/AuthController.cs`** +- Added `[Authorize]` attribute to `/api/auth/me` endpoint +- Endpoint now extracts and returns JWT claims (user_id, tenant_id, email, etc.) + +--- + +## NuGet Packages Added + +| Package | Version | Project | Purpose | +|---------|---------|---------|---------| +| Microsoft.IdentityModel.Tokens | 8.14.0 | Identity.Infrastructure | JWT token validation | +| System.IdentityModel.Tokens.Jwt | 8.14.0 | Identity.Infrastructure | JWT token generation | +| BCrypt.Net-Next | 4.0.3 | Identity.Infrastructure | Password hashing | +| Microsoft.AspNetCore.Authentication.JwtBearer | 9.0.10 | ColaFlow.API | JWT bearer authentication | + +--- + +## JWT Claims Structure + +Tokens include the following claims: + +```json +{ + "sub": "user-guid", + "email": "user@example.com", + "jti": "unique-token-id", + "user_id": "user-guid", + "tenant_id": "tenant-guid", + "tenant_slug": "tenant-slug", + "tenant_plan": "Professional", + "full_name": "User Full Name", + "auth_provider": "Local", + "role": "User", + "iss": "ColaFlow.API", + "aud": "ColaFlow.Web", + "exp": 1762125000 +} +``` + +--- + +## Security Features Implemented + +1. **Password Hashing**: BCrypt with work factor 12 + - Passwords are never stored in plain text + - Salted hashing prevents rainbow table attacks + +2. **JWT Token Security**: + - HMAC SHA-256 signing algorithm + - 60-minute token expiration (configurable) + - Secret key validation (min 32 characters) + - Issuer and Audience validation + +3. **Authentication Middleware**: + - Validates token signature + - Validates token expiration + - Validates issuer and audience + - Rejects requests without valid tokens to protected endpoints + +--- + +## Testing Instructions + +### Prerequisites +1. Ensure PostgreSQL is running +2. Database migrations are up to date: `dotnet ef database update --context IdentityDbContext` + +### Manual Testing + +#### Step 1: Start the API +```bash +cd c:\Users\yaoji\git\ColaCoder\product-master\colaflow-api +dotnet run --project src/ColaFlow.API +``` + +#### Step 2: Register a Tenant +```powershell +$body = @{ + tenantName = "Test Corp" + tenantSlug = "test-corp" + subscriptionPlan = "Professional" + adminEmail = "admin@testcorp.com" + adminPassword = "Admin@1234" + adminFullName = "Test Admin" +} | ConvertTo-Json + +$response = Invoke-RestMethod -Uri "http://localhost:5167/api/tenants/register" ` + -Method Post ` + -ContentType "application/json" ` + -Body $body + +$token = $response.accessToken +Write-Host "Token: $token" +``` + +**Expected Result**: Returns JWT token (long base64 string) + +#### Step 3: Login with Correct Password +```powershell +$loginBody = @{ + tenantSlug = "test-corp" + email = "admin@testcorp.com" + password = "Admin@1234" +} | ConvertTo-Json + +$loginResponse = Invoke-RestMethod -Uri "http://localhost:5167/api/auth/login" ` + -Method Post ` + -ContentType "application/json" ` + -Body $loginBody + +Write-Host "Login Token: $($loginResponse.accessToken)" +``` + +**Expected Result**: Returns JWT token + +#### Step 4: Login with Wrong Password +```powershell +$wrongPasswordBody = @{ + tenantSlug = "test-corp" + email = "admin@testcorp.com" + password = "WrongPassword" +} | ConvertTo-Json + +try { + Invoke-RestMethod -Uri "http://localhost:5167/api/auth/login" ` + -Method Post ` + -ContentType "application/json" ` + -Body $wrongPasswordBody +} catch { + Write-Host "Correctly rejected: $($_.Exception.Response.StatusCode)" +} +``` + +**Expected Result**: 401 Unauthorized + +#### Step 5: Access Protected Endpoint WITHOUT Token +```powershell +try { + Invoke-RestMethod -Uri "http://localhost:5167/api/auth/me" -Method Get +} catch { + Write-Host "Correctly rejected: $($_.Exception.Response.StatusCode)" +} +``` + +**Expected Result**: 401 Unauthorized + +#### Step 6: Access Protected Endpoint WITH Token +```powershell +$headers = @{ + "Authorization" = "Bearer $token" +} + +$meResponse = Invoke-RestMethod -Uri "http://localhost:5167/api/auth/me" ` + -Method Get ` + -Headers $headers + +$meResponse | ConvertTo-Json +``` + +**Expected Result**: Returns user claims +```json +{ + "userId": "...", + "tenantId": "...", + "email": "admin@testcorp.com", + "fullName": "Test Admin", + "tenantSlug": "test-corp", + "claims": [...] +} +``` + +--- + +## Automated Test Script + +A PowerShell test script is available: + +```bash +powershell -ExecutionPolicy Bypass -File test-auth-simple.ps1 +``` + +--- + +## Build Status + +✅ **Compilation**: Successful +✅ **Warnings**: Minor (async method without await, EF Core version conflicts) +✅ **Errors**: None + +``` +Build succeeded. + 20 Warning(s) + 0 Error(s) +``` + +--- + +## Next Steps (Day 5) + +Based on the original 10-day plan: + +1. **Refresh Token Implementation** + - Implement `GenerateRefreshTokenAsync()` in JwtService + - Add refresh token storage (Database or Redis) + - Add `/api/auth/refresh` endpoint + +2. **Role-Based Authorization** + - Implement real role system (Admin, Member, Guest) + - Add role claims to JWT + - Add `[Authorize(Roles = "Admin")]` attributes + +3. **Email Verification** + - Email verification flow + - Update `User.EmailVerifiedAt` on verification + +4. **SSO Integration** (if time permits) + - OAuth 2.0 / OpenID Connect support + - Azure AD / Google / GitHub providers + +--- + +## Configuration Recommendations + +### Production Configuration + +**Never use the default secret key in production!** Generate a strong secret: + +```powershell +# Generate a 64-character random secret +$bytes = New-Object byte[] 64 +[Security.Cryptography.RNGCryptoServiceProvider]::Create().GetBytes($bytes) +$secret = [Convert]::ToBase64String($bytes) +Write-Host $secret +``` + +Update `appsettings.Production.json`: +```json +{ + "Jwt": { + "SecretKey": "", + "Issuer": "ColaFlow.API", + "Audience": "ColaFlow.Web", + "ExpirationMinutes": "30" + } +} +``` + +### Security Best Practices + +1. **Secret Key**: Use environment variables for production +2. **Token Expiration**: Shorter tokens (15-30 min) + refresh tokens +3. **HTTPS**: Always use HTTPS in production +4. **Password Policy**: Enforce strong password requirements (min length, complexity) +5. **Rate Limiting**: Add rate limiting to auth endpoints +6. **Audit Logging**: Log all authentication attempts + +--- + +## Troubleshooting + +### Issue: "JWT SecretKey not configured" +**Solution**: Ensure `appsettings.Development.json` contains `Jwt:SecretKey` + +### Issue: Token validation fails +**Solution**: Check Issuer and Audience match between token generation and validation + +### Issue: "Invalid credentials" even with correct password +**Solution**: +- Check if password was hashed during registration +- Verify `PasswordHash` column in database is not null +- Re-register tenant to re-hash password + +--- + +## Summary + +Day 4 successfully implemented **real authentication security**: +- ✅ BCrypt password hashing (no plain text passwords) +- ✅ JWT token generation with proper claims +- ✅ JWT authentication middleware +- ✅ Protected endpoints with [Authorize] +- ✅ Token validation (signature, expiration, issuer, audience) + +The authentication system is now production-ready (with appropriate configuration changes). + +--- + +**Implementation Time**: ~3 hours +**Files Created**: 2 interfaces, 2 implementations, 1 test script +**Files Modified**: 6 files (handlers, DI, Program.cs, AuthController, appsettings) +**Packages Added**: 4 NuGet packages diff --git a/colaflow-api/DAY5-ARCHITECTURE-DESIGN.md b/colaflow-api/DAY5-ARCHITECTURE-DESIGN.md new file mode 100644 index 0000000..1b6b339 --- /dev/null +++ b/colaflow-api/DAY5-ARCHITECTURE-DESIGN.md @@ -0,0 +1,1786 @@ +# 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 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 _logger; + + public async Task 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("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 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("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> 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 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 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 + { + 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 DeleteUser(Guid userId) +{ + // Only TenantOwner can delete users +} + +// Fine-grained authorization +[HttpPost("projects")] +[Authorize] // Any authenticated user +public async Task 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 GetByUserAndTenantAsync( + Guid userId, + Guid tenantId, + CancellationToken cancellationToken = default); + + Task> GetByTenantAsync( + Guid tenantId, + CancellationToken cancellationToken = default); + + Task> 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 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 _logger; + private readonly SendGridClient _client; + + public SendGridEmailService(IConfiguration configuration, ILogger 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 = $@" +

Welcome to ColaFlow!

+

Please verify your email address by clicking the button below:

+

Verify Email

+

Or copy and paste this link into your browser:

+

{verificationUrl}

+

This link expires in 24 hours.

+ "; + + 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; + +public class VerifyEmailCommandHandler : IRequestHandler +{ + private readonly IUserRepository _userRepository; + private readonly ILogger _logger; + + public async Task 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; + +public class ResendVerificationEmailCommandHandler : IRequestHandler +{ + private readonly IUserRepository _userRepository; + private readonly ITenantRepository _tenantRepository; + private readonly IEmailService _emailService; + private readonly ILogger _logger; + + public async Task 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 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 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 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 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= + +# Database +DATABASE_CONNECTION_STRING= + +# Application URLs +APP_BASE_URL=https://api.colaflow.com +APP_FRONTEND_URL=https://app.colaflow.com +``` + +### 7.2 NuGet Packages Required + +```xml + + + + + + + + +``` + +--- + +## 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 diff --git a/colaflow-api/DAY5-PRIORITY-AND-REQUIREMENTS.md b/colaflow-api/DAY5-PRIORITY-AND-REQUIREMENTS.md new file mode 100644 index 0000000..6a4871a --- /dev/null +++ b/colaflow-api/DAY5-PRIORITY-AND-REQUIREMENTS.md @@ -0,0 +1,948 @@ +# Day 5 Priority Analysis and Requirements Document + +**Date**: 2025-11-03 +**Project**: ColaFlow Authentication System +**Milestone**: M1 - Core Project Module + +--- + +## Executive Summary + +Based on Day 4's authentication implementation (JWT + BCrypt + Middleware) and ColaFlow's M1-M6 roadmap, this document prioritizes 4 pending features and defines Day 5 implementation focus. + +**Day 5 Recommendation**: Focus on **Refresh Token** + **Role-Based Authorization (RBAC)** + +--- + +## 1. Priority Analysis + +### Feature Priority Matrix + +| Feature | Business Value | Technical Complexity | MCP Dependency | Risk | Priority | +|---------|---------------|---------------------|----------------|------|----------| +| **Refresh Token** | HIGH | LOW | HIGH | LOW | **P0 (Must Have)** | +| **Role-Based Authorization** | HIGH | MEDIUM | CRITICAL | MEDIUM | **P0 (Must Have)** | +| **Email Verification** | MEDIUM | LOW | LOW | LOW | **P1 (Should Have)** | +| **SSO Integration** | LOW | HIGH | LOW | HIGH | **P2 (Nice to Have)** | + +--- + +### 1.1 Refresh Token Implementation + +**Priority**: **P0 (Must Have)** + +#### Why P0? +1. **Security Best Practice**: Current 60-minute JWT is too long for production (increases vulnerability window) +2. **User Experience**: Prevents frequent re-logins (enables 7-day "Remember Me" functionality) +3. **MCP Integration**: AI tools need long-lived sessions to perform multi-step operations (create PRD → generate tasks → update progress) +4. **Industry Standard**: All production auth systems use refresh tokens + +#### Business Value +- **High**: Essential for production security and UX +- **MCP Relevance**: Critical - AI agents need persistent sessions to complete multi-turn workflows + +#### Technical Complexity +- **Low**: Interface already exists (`GenerateRefreshTokenAsync()`) +- **Effort**: 2-3 hours +- **Dependencies**: Database or Redis storage + +#### Risk +- **Low**: Well-defined pattern, no architectural changes needed + +--- + +### 1.2 Role-Based Authorization (RBAC) + +**Priority**: **P0 (Must Have)** + +#### Why P0? +1. **MCP Security Requirement**: AI tools must have restricted permissions (read-only vs. read-write) +2. **Multi-Tenant Architecture**: Tenant Admins vs. Members vs. Guests need different access levels +3. **Project Core Requirement**: Epic/Story/Task management requires role-based access control +4. **Audit & Compliance**: ColaFlow's audit log system requires role tracking for accountability + +#### Business Value +- **High**: Foundation for all access control in M1-M6 +- **MCP Relevance**: Critical - AI agents must operate under restricted roles (e.g., "AI Agent" role with write-preview permissions) + +#### Technical Complexity +- **Medium**: Requires database schema changes (User-Role mapping), claims modification, authorization policies +- **Effort**: 4-5 hours +- **Dependencies**: JWT claims, authorization middleware + +#### Risk +- **Medium**: Requires migration of existing users, potential breaking changes + +--- + +### 1.3 Email Verification + +**Priority**: **P1 (Should Have)** + +#### Why P1? +1. **Security Enhancement**: Prevents fake account registrations +2. **User Validation**: Ensures users own their email addresses +3. **Password Reset Prerequisite**: Required for secure password reset flow + +#### Business Value +- **Medium**: Improves security but not blocking for M1 +- **MCP Relevance**: Low - AI tools don't require email verification + +#### Technical Complexity +- **Low**: Standard email verification flow +- **Effort**: 3-4 hours +- **Dependencies**: Email service (SendGrid/AWS SES), verification token storage + +#### Risk +- **Low**: Non-breaking addition to registration flow + +#### Deferral Justification +- Not blocking for M1 Core Project Module +- Can be added in M2 or M3 without architectural changes +- Focus on MCP-critical features first + +--- + +### 1.4 SSO Integration + +**Priority**: **P2 (Nice to Have)** + +#### Why P2? +1. **Enterprise Feature**: Primarily for M5 Enterprise Pilot +2. **High Complexity**: Requires OAuth 2.0/OIDC implementation, multiple provider support +3. **Not MCP-Critical**: AI tools use API tokens, not SSO + +#### Business Value +- **Low**: Enterprise convenience feature, not required for M1-M3 +- **MCP Relevance**: None - AI tools don't use SSO + +#### Technical Complexity +- **High**: Multiple providers (Azure AD, Google, GitHub), token exchange, user mapping +- **Effort**: 10-15 hours +- **Dependencies**: OAuth libraries, provider registrations, user linking logic + +#### Risk +- **High**: Complex integration, provider-specific quirks, testing overhead + +#### Deferral Justification +- Target for M4 (External Integration) or M5 (Enterprise Pilot) +- Does not block M1-M3 development +- Local authentication + API tokens sufficient for early milestones + +--- + +## 2. Day 5 Focus: Refresh Token + RBAC + +### Recommended Scope + +**Day 5 Goals**: +1. Implement **Refresh Token** mechanism (2-3 hours) +2. Implement **Role-Based Authorization** foundation (4-5 hours) + +**Total Effort**: 6-8 hours (achievable in 1 day) + +--- + +## 3. Feature Requirements + +--- + +## 3.1 Refresh Token Implementation + +### 3.1.1 Background & Goals + +#### Business Context +- Current JWT tokens expire in 60 minutes, forcing users to re-login frequently +- AI agents performing long-running tasks (multi-step PRD generation) lose authentication mid-workflow +- Industry standard: Short-lived access tokens (15-30 min) + long-lived refresh tokens (7-30 days) + +#### User Pain Points +- Users lose session while actively working +- AI tools fail mid-operation due to token expiration +- No "Remember Me" functionality + +#### Project Objectives +- Reduce access token lifetime to 15 minutes (increase security) +- Implement 7-day refresh tokens (improve UX) +- Enable seamless token refresh for AI agents + +--- + +### 3.1.2 Requirements + +#### Core Functionality + +**FR-RT-1**: JWT Access Token Generation +- Reduce JWT expiration to 15 minutes (configurable) +- Keep existing JWT structure and claims +- Access tokens remain stateless + +**FR-RT-2**: Refresh Token Generation +- Generate cryptographically secure refresh tokens (GUID or random bytes) +- Store refresh tokens in database (or Redis) +- Associate refresh tokens with User + Tenant + Device/Client +- Set expiration to 7 days (configurable) + +**FR-RT-3**: Refresh Token Storage +```sql +CREATE TABLE RefreshTokens ( + Id UUID PRIMARY KEY, + UserId UUID NOT NULL FOREIGN KEY REFERENCES Users(Id), + TenantId UUID NOT NULL FOREIGN KEY REFERENCES Tenants(Id), + Token VARCHAR(500) NOT NULL UNIQUE, + ExpiresAt TIMESTAMP NOT NULL, + CreatedAt TIMESTAMP NOT NULL DEFAULT NOW(), + RevokedAt TIMESTAMP NULL, + ReplacedByToken VARCHAR(500) NULL +); + +CREATE INDEX IX_RefreshTokens_Token ON RefreshTokens(Token); +CREATE INDEX IX_RefreshTokens_UserId ON RefreshTokens(UserId); +``` + +**FR-RT-4**: Token Refresh Endpoint +- **POST /api/auth/refresh** +- **Request Body**: `{ "refreshToken": "..." }` +- **Response**: New access token + new refresh token (token rotation) +- **Validation**: + - Refresh token exists and not revoked + - Refresh token not expired + - User and Tenant still active +- **Behavior**: Issue new access token + rotate refresh token (invalidate old token) + +**FR-RT-5**: Token Revocation +- **POST /api/auth/logout** +- Mark refresh token as revoked +- Prevent reuse of revoked tokens + +**FR-RT-6**: Automatic Cleanup +- Background job to delete expired refresh tokens (older than 30 days) + +--- + +#### User Scenarios + +**Scenario 1: User Login** +1. User submits credentials → `/api/auth/login` +2. System validates credentials +3. System generates: + - Access Token (15-minute JWT) + - Refresh Token (7-day GUID stored in database) +4. System returns both tokens +5. Client stores refresh token securely (HttpOnly cookie or secure storage) + +**Expected Result**: User receives short-lived access token + long-lived refresh token + +--- + +**Scenario 2: Access Token Expiration** +1. Client makes API request with expired access token +2. API returns `401 Unauthorized` +3. Client automatically calls `/api/auth/refresh` with refresh token +4. System validates refresh token and issues new access token + new refresh token +5. Client retries original API request with new access token + +**Expected Result**: Seamless token refresh without user re-login + +--- + +**Scenario 3: Refresh Token Expiration** +1. User hasn't accessed app for 7+ days +2. Refresh token expired +3. Client attempts token refresh → System returns `401 Unauthorized` +4. Client redirects user to login page + +**Expected Result**: User must re-authenticate after 7 days of inactivity + +--- + +**Scenario 4: User Logout** +1. User clicks "Logout" +2. Client calls `/api/auth/logout` with refresh token +3. System marks refresh token as revoked +4. Client clears stored tokens + +**Expected Result**: Refresh token becomes invalid, user must re-login + +--- + +#### Priority Levels + +**P0 (Must Have)**: +- Refresh token generation and storage +- `/api/auth/refresh` endpoint with token rotation +- Database schema for refresh tokens +- Token revocation on logout + +**P1 (Should Have)**: +- Automatic expired token cleanup job +- Multiple device/session support (one refresh token per device) +- Admin endpoint to revoke all user tokens + +**P2 (Nice to Have)**: +- Refresh token usage analytics +- Suspicious activity detection (token reuse, concurrent sessions) + +--- + +### 3.1.3 Acceptance Criteria + +#### Functional Criteria +- [ ] **AC-RT-1**: Access tokens expire in 15 minutes (configurable via `appsettings.json`) +- [ ] **AC-RT-2**: Refresh tokens expire in 7 days (configurable) +- [ ] **AC-RT-3**: `/api/auth/login` returns both access token and refresh token +- [ ] **AC-RT-4**: `/api/auth/refresh` validates refresh token and issues new tokens +- [ ] **AC-RT-5**: Old refresh token is revoked when new token is issued (token rotation) +- [ ] **AC-RT-6**: Revoked refresh tokens cannot be reused +- [ ] **AC-RT-7**: Expired refresh tokens cannot be used +- [ ] **AC-RT-8**: `/api/auth/logout` revokes refresh token +- [ ] **AC-RT-9**: Refresh tokens are stored securely (hashed or encrypted) + +#### Security Criteria +- [ ] **AC-RT-10**: Refresh tokens are cryptographically secure (min 256-bit entropy) +- [ ] **AC-RT-11**: Token rotation prevents token replay attacks +- [ ] **AC-RT-12**: Refresh tokens are unique per user session +- [ ] **AC-RT-13**: Concurrent refresh attempts invalidate all tokens (suspicious activity detection - P1) + +#### Performance Criteria +- [ ] **AC-RT-14**: Token refresh completes in < 200ms (database lookup + JWT generation) +- [ ] **AC-RT-15**: Database indexes on `Token` and `UserId` for fast lookups + +--- + +### 3.1.4 Timeline + +- **Epic**: Identity & Authentication +- **Story**: Refresh Token Implementation +- **Tasks**: + 1. Create `RefreshToken` entity and DbContext configuration (30 min) + 2. Add database migration for `RefreshTokens` table (15 min) + 3. Implement `GenerateRefreshTokenAsync()` in `JwtService` (30 min) + 4. Implement `RefreshTokenRepository` for storage (30 min) + 5. Update `/api/auth/login` to return refresh token (15 min) + 6. Implement `/api/auth/refresh` endpoint (45 min) + 7. Implement `/api/auth/logout` token revocation (15 min) + 8. Update JWT expiration to 15 minutes (5 min) + 9. Write integration tests (30 min) + 10. Update documentation (15 min) + +**Estimated Effort**: 3 hours +**Target Milestone**: M1 + +--- + +## 3.2 Role-Based Authorization (RBAC) + +### 3.2.1 Background & Goals + +#### Business Context +- ColaFlow is a multi-tenant system with hierarchical permissions +- Different users need different access levels (Tenant Admin, Project Admin, Member, Guest, AI Agent) +- MCP integration requires AI agents to operate under restricted roles +- Audit logs require role information for accountability + +#### User Pain Points +- No granular access control (all users have same permissions) +- Cannot restrict AI agents to read-only or preview-only operations +- Cannot enforce tenant-level vs. project-level permissions + +#### Project Objectives +- Implement role hierarchy: Tenant Admin > Project Admin > Member > Guest > AI Agent (Read-Only) +- Support role-based JWT claims for authorization +- Enable `[Authorize(Roles = "Admin")]` attribute usage +- Prepare for MCP-specific roles (AI agents with write-preview permissions) + +--- + +### 3.2.2 Requirements + +#### Core Functionality + +**FR-RBAC-1**: Role Definitions + +Define 5 core roles: + +| Role | Scope | Permissions | +|------|-------|------------| +| **TenantAdmin** | Tenant-wide | Full control: manage users, roles, projects, billing | +| **ProjectAdmin** | Project-specific | Manage project: create/edit/delete tasks, assign members | +| **Member** | Project-specific | Create/edit own tasks, view all project data | +| **Guest** | Project-specific | Read-only access to assigned tasks | +| **AIAgent** | Tenant-wide | Read all + Write with preview (requires human approval) | + +**FR-RBAC-2**: Database Schema + +```sql +-- Enum or lookup table for roles +CREATE TABLE Roles ( + Id UUID PRIMARY KEY, + Name VARCHAR(50) NOT NULL UNIQUE, -- TenantAdmin, ProjectAdmin, Member, Guest, AIAgent + Description VARCHAR(500), + IsSystemRole BOOLEAN NOT NULL DEFAULT TRUE +); + +-- User-Role mapping (many-to-many) +CREATE TABLE UserRoles ( + Id UUID PRIMARY KEY, + UserId UUID NOT NULL FOREIGN KEY REFERENCES Users(Id) ON DELETE CASCADE, + RoleId UUID NOT NULL FOREIGN KEY REFERENCES Roles(Id) ON DELETE CASCADE, + TenantId UUID NOT NULL FOREIGN KEY REFERENCES Tenants(Id) ON DELETE CASCADE, + ProjectId UUID NULL FOREIGN KEY REFERENCES Projects(Id) ON DELETE CASCADE, -- NULL for tenant-level roles + GrantedAt TIMESTAMP NOT NULL DEFAULT NOW(), + GrantedBy UUID NULL FOREIGN KEY REFERENCES Users(Id), -- Who assigned this role + UNIQUE(UserId, RoleId, TenantId, ProjectId) +); + +CREATE INDEX IX_UserRoles_UserId ON UserRoles(UserId); +CREATE INDEX IX_UserRoles_TenantId ON UserRoles(TenantId); +CREATE INDEX IX_UserRoles_ProjectId ON UserRoles(ProjectId); +``` + +**FR-RBAC-3**: JWT Claims Enhancement + +Add role claims to JWT: +```json +{ + "sub": "user-guid", + "email": "user@example.com", + "role": "TenantAdmin", // Primary role + "roles": ["TenantAdmin", "ProjectAdmin"], // All roles (array) + "tenant_id": "tenant-guid", + "permissions": ["users:read", "users:write", "projects:admin"] // Optional: fine-grained permissions +} +``` + +**FR-RBAC-4**: Authorization Policies + +Configure policies in `Program.cs`: +```csharp +builder.Services.AddAuthorization(options => +{ + options.AddPolicy("RequireTenantAdmin", policy => + policy.RequireRole("TenantAdmin")); + + options.AddPolicy("RequireProjectAdmin", policy => + policy.RequireRole("TenantAdmin", "ProjectAdmin")); + + options.AddPolicy("RequireMemberOrHigher", policy => + policy.RequireRole("TenantAdmin", "ProjectAdmin", "Member")); + + options.AddPolicy("RequireHumanUser", policy => + policy.RequireAssertion(ctx => + !ctx.User.HasClaim("role", "AIAgent"))); +}); +``` + +**FR-RBAC-5**: Controller Protection + +Apply role-based authorization to endpoints: +```csharp +[Authorize(Roles = "TenantAdmin")] +[HttpPost("api/tenants/{tenantId}/users")] +public async Task CreateUser(...) { } + +[Authorize(Policy = "RequireProjectAdmin")] +[HttpDelete("api/projects/{projectId}")] +public async Task DeleteProject(...) { } + +[Authorize(Policy = "RequireMemberOrHigher")] +[HttpPost("api/projects/{projectId}/tasks")] +public async Task CreateTask(...) { } +``` + +**FR-RBAC-6**: Default Role Assignment + +- New tenant registration: First user gets `TenantAdmin` role +- Invited users: Get `Member` role by default +- AI agents: Require explicit `AIAgent` role assignment + +--- + +#### User Scenarios + +**Scenario 1: Tenant Admin Creates User** +1. Tenant Admin invites new user via `/api/tenants/{tenantId}/users` +2. System validates requester has `TenantAdmin` role +3. System creates user with `Member` role by default +4. System sends invitation email + +**Expected Result**: User created successfully, assigned Member role + +--- + +**Scenario 2: Member Attempts Tenant Admin Action** +1. Member user attempts to delete tenant via `/api/tenants/{tenantId}` +2. System validates JWT role claim +3. System returns `403 Forbidden` (insufficient permissions) + +**Expected Result**: Request rejected with clear error message + +--- + +**Scenario 3: Project Admin Assigns Roles** +1. Project Admin assigns user to project with `ProjectAdmin` role +2. System validates requester has `TenantAdmin` or `ProjectAdmin` role for this project +3. System creates `UserRoles` entry (UserId, ProjectAdmin, ProjectId) +4. User receives notification + +**Expected Result**: User gains ProjectAdmin role for specific project + +--- + +**Scenario 4: AI Agent Creates Task (MCP Integration)** +1. AI agent calls `/api/projects/{projectId}/tasks` with `AIAgent` role token +2. System detects `AIAgent` role → triggers diff preview mode +3. System generates task preview (not committed to database) +4. System returns preview to AI agent → AI presents to human for approval +5. Human approves → AI agent calls `/api/tasks/preview/{previewId}/commit` +6. System validates approval and commits task + +**Expected Result**: AI agent creates task only after human approval + +--- + +#### Priority Levels + +**P0 (Must Have)**: +- Role definitions (TenantAdmin, ProjectAdmin, Member, Guest, AIAgent) +- Database schema: `Roles` + `UserRoles` tables +- JWT role claims +- Authorization policies in `Program.cs` +- Controller-level `[Authorize(Roles = "...")]` protection +- Default role assignment (TenantAdmin for first user, Member for new users) + +**P1 (Should Have)**: +- Project-specific role assignment (UserRoles with ProjectId) +- Role management API (assign/revoke roles) +- Admin UI for role management +- Role-based audit logging + +**P2 (Nice to Have)**: +- Fine-grained permissions (users:read, users:write, etc.) +- Custom role creation +- Role inheritance (ProjectAdmin inherits Member permissions) + +--- + +### 3.2.3 Acceptance Criteria + +#### Functional Criteria +- [ ] **AC-RBAC-1**: 5 system roles exist in database (TenantAdmin, ProjectAdmin, Member, Guest, AIAgent) +- [ ] **AC-RBAC-2**: First user in new tenant is automatically assigned `TenantAdmin` role +- [ ] **AC-RBAC-3**: JWT tokens include `role` and `roles` claims +- [ ] **AC-RBAC-4**: Endpoints protected with `[Authorize(Roles = "...")]` reject unauthorized users with `403 Forbidden` +- [ ] **AC-RBAC-5**: `TenantAdmin` can access all tenant-level endpoints +- [ ] **AC-RBAC-6**: `Member` cannot access admin endpoints (returns `403`) +- [ ] **AC-RBAC-7**: Role assignment is logged in audit trail (P1) + +#### Security Criteria +- [ ] **AC-RBAC-8**: Role claims are cryptographically signed in JWT (tamper-proof) +- [ ] **AC-RBAC-9**: Role validation happens on every request (no role caching vulnerabilities) +- [ ] **AC-RBAC-10**: AI agents cannot access endpoints requiring human user (RequireHumanUser policy) + +#### MCP Integration Criteria +- [ ] **AC-RBAC-11**: `AIAgent` role is distinguishable in authorization logic +- [ ] **AC-RBAC-12**: Endpoints can detect AI agent role and trigger preview mode (P0 for M2) +- [ ] **AC-RBAC-13**: Human-only endpoints (e.g., approve preview) reject AI agent tokens + +#### Performance Criteria +- [ ] **AC-RBAC-14**: Role lookup from JWT claims (no database query per request) +- [ ] **AC-RBAC-15**: Authorization decision completes in < 10ms + +--- + +### 3.2.4 Timeline + +- **Epic**: Identity & Authentication +- **Story**: Role-Based Authorization (RBAC) +- **Tasks**: + 1. Design role hierarchy and permissions matrix (30 min) + 2. Create `Role` and `UserRole` entities (30 min) + 3. Add database migration for RBAC tables (15 min) + 4. Seed default roles (TenantAdmin, ProjectAdmin, Member, Guest, AIAgent) (15 min) + 5. Update `JwtService` to include role claims (30 min) + 6. Update `RegisterTenantCommandHandler` to assign TenantAdmin role (15 min) + 7. Configure authorization policies in `Program.cs` (30 min) + 8. Add `[Authorize(Roles = "...")]` to existing controllers (30 min) + 9. Implement role assignment/revocation API (P1) (45 min) + 10. Write integration tests for RBAC (45 min) + 11. Update API documentation (15 min) + +**Estimated Effort**: 4.5 hours +**Target Milestone**: M1 + +--- + +## 4. MCP Integration Requirements + +### 4.1 Authentication System Capabilities for MCP + +To support M2 (MCP Server Implementation) and M3 (ChatGPT Integration PoC), the authentication system must provide: + +--- + +#### MCP-1: AI Agent Authentication + +**Requirement**: AI tools must authenticate with ColaFlow using API tokens (not username/password) + +**Implementation**: +- Generate long-lived API tokens (30-90 days) for AI agents +- API tokens stored in database (hashed) with metadata (agent name, permissions, expiration) +- API tokens map to User with `AIAgent` role +- Endpoint: **POST /api/auth/tokens** (generate API token for AI agent) + +**Example**: +```json +POST /api/auth/tokens +{ + "agentName": "ChatGPT-PRD-Generator", + "permissions": ["projects:read", "tasks:write_preview"], + "expiresInDays": 90 +} + +Response: +{ + "token": "cola_live_sk_abc123...", + "expiresAt": "2026-02-01T00:00:00Z" +} +``` + +--- + +#### MCP-2: AI Agent Role & Permissions + +**Requirement**: AI agents must have restricted permissions (read + write-preview only) + +**Implementation**: +- `AIAgent` role defined with permissions: + - **Read**: All projects, tasks, docs (tenant-scoped) + - **Write Preview**: Generate diffs for tasks/docs (not committed) + - **No Direct Write**: Cannot commit changes without human approval +- Authorization policies detect `AIAgent` role and enforce preview mode + +**Example**: +```csharp +[Authorize(Roles = "Member,ProjectAdmin,TenantAdmin")] +[HttpPost("api/projects/{projectId}/tasks")] +public async Task CreateTask(...) +{ + if (User.IsInRole("AIAgent")) + { + // Generate preview, return for human approval + return Ok(new { preview: taskPreview, requiresApproval: true }); + } + + // Direct commit for human users + await _taskService.CreateTaskAsync(...); + return Created(...); +} +``` + +--- + +#### MCP-3: Multi-Turn Session Management + +**Requirement**: AI agents need persistent sessions for multi-turn workflows (e.g., create PRD → generate tasks → update status) + +**Implementation**: +- Refresh tokens for AI agents (90-day expiration) +- Session storage for AI agent context (e.g., current project, draft document ID) +- Session cleanup after 24 hours of inactivity + +**Example Workflow**: +``` +1. AI: Generate PRD draft → System: Creates draft (not committed), returns previewId +2. AI: Review PRD draft → System: Returns preview with previewId +3. Human: Approve PRD → System: Commits draft to database +4. AI: Generate tasks from PRD → System: Creates task previews +5. Human: Approve tasks → System: Commits tasks +``` + +--- + +#### MCP-4: Audit Trail for AI Actions + +**Requirement**: All AI agent actions must be logged for compliance and debugging + +**Implementation**: +- Audit log entries include: + - Actor: AI agent name (from JWT `sub` or `agent_name` claim) + - Action: Resource + Operation (e.g., "tasks.create_preview") + - Timestamp + - Request payload (diff) + - Approval status (pending, approved, rejected) +- Queryable audit log: **GET /api/audit?actorType=AIAgent** + +--- + +#### MCP-5: Human Approval Workflow + +**Requirement**: All AI write operations require human approval + +**Implementation**: +- Preview storage: Store AI-generated changes in temporary table +- Approval API: + - **GET /api/previews/{previewId}** - View diff + - **POST /api/previews/{previewId}/approve** - Commit changes + - **POST /api/previews/{previewId}/reject** - Discard changes +- Preview expiration: Auto-delete after 24 hours + +**Database Schema**: +```sql +CREATE TABLE Previews ( + Id UUID PRIMARY KEY, + EntityType VARCHAR(50) NOT NULL, -- Task, Document, etc. + Operation VARCHAR(50) NOT NULL, -- Create, Update, Delete + Payload JSONB NOT NULL, -- Full entity data or diff + CreatedBy UUID NOT NULL FOREIGN KEY REFERENCES Users(Id), -- AI agent user + CreatedAt TIMESTAMP NOT NULL DEFAULT NOW(), + ExpiresAt TIMESTAMP NOT NULL, + ApprovedBy UUID NULL FOREIGN KEY REFERENCES Users(Id), + ApprovedAt TIMESTAMP NULL, + RejectedBy UUID NULL FOREIGN KEY REFERENCES Users(Id), + RejectedAt TIMESTAMP NULL, + Status VARCHAR(20) NOT NULL DEFAULT 'Pending' -- Pending, Approved, Rejected, Expired +); +``` + +--- + +#### MCP-6: Rate Limiting for AI Agents + +**Requirement**: Prevent AI agents from overwhelming the system + +**Implementation**: +- Rate limits per AI agent token: + - Read operations: 100 requests/minute + - Write preview operations: 10 requests/minute + - Commit operations: N/A (human-initiated) +- Return `429 Too Many Requests` when limit exceeded +- Use Redis or in-memory cache for rate limit tracking + +--- + +### 4.2 MCP Integration Readiness Checklist + +For Day 5 implementation, ensure authentication system supports: + +- [ ] **MCP-Ready-1**: AI agent user creation (User with `AIAgent` role) +- [ ] **MCP-Ready-2**: API token generation and validation (long-lived tokens) +- [ ] **MCP-Ready-3**: Role-based authorization (AIAgent role defined) +- [ ] **MCP-Ready-4**: Refresh tokens for multi-turn AI sessions +- [ ] **MCP-Ready-5**: Audit logging foundation (log actor role in all operations) +- [ ] **MCP-Ready-6**: Preview storage schema (P1 - can be added in M2) + +--- + +## 5. Technical Constraints & Dependencies + +### 5.1 Technology Stack + +- **.NET 9.0**: Use latest C# 13 features +- **PostgreSQL**: Primary database (RBAC tables, refresh tokens) +- **Entity Framework Core 9.0**: ORM for database access +- **System.IdentityModel.Tokens.Jwt**: JWT token handling +- **Redis** (Optional): For refresh token storage (if high throughput needed) + +--- + +### 5.2 Dependencies + +#### Internal Dependencies +- **Day 4 Completion**: JWT service, password hashing, authentication middleware +- **Database Migrations**: Existing `IdentityDbContext` must be migrated +- **Tenant & User Entities**: Must support role relationships + +#### External Dependencies +- **PostgreSQL Instance**: Running and accessible +- **Configuration**: `appsettings.json` updated with token lifetimes +- **Testing Environment**: Integration tests require test database + +--- + +### 5.3 Breaking Changes + +#### Refresh Token Implementation +- **Breaking**: Access token lifetime changes from 60 min → 15 min +- **Migration Path**: Clients must implement token refresh logic +- **Backward Compatibility**: Old tokens valid until expiration (no immediate break) + +#### RBAC Implementation +- **Breaking**: Existing users have no roles (must assign default role in migration) +- **Migration Path**: Data migration to assign `TenantAdmin` to first user per tenant +- **Backward Compatibility**: Endpoints without `[Authorize(Roles)]` remain accessible + +--- + +### 5.4 Testing Requirements + +#### Refresh Token Tests +1. Token refresh succeeds with valid refresh token +2. Token refresh fails with expired refresh token +3. Token refresh fails with revoked refresh token +4. Token rotation invalidates old refresh token +5. Logout revokes refresh token +6. Concurrent refresh attempts handled correctly (P1) + +#### RBAC Tests +1. TenantAdmin can access admin endpoints +2. Member cannot access admin endpoints (403 Forbidden) +3. Guest has read-only access +4. AIAgent role triggers preview mode +5. Role claims present in JWT +6. Authorization policies enforce role requirements + +--- + +## 6. Next Steps After Day 5 + +### Day 6-7: Complete M1 Core Project Module +- Implement Project/Epic/Story/Task entities +- Implement Kanban workflow (To Do → In Progress → Done) +- Basic audit log for entity changes + +### Day 8-9: Email Verification + Password Reset +- Email verification flow (P1 from this document) +- Password reset with secure tokens +- Email service integration (SendGrid) + +### Day 10-12: M2 MCP Server Foundation +- Implement Preview storage and approval API (MCP-5) +- Implement API token generation for AI agents (MCP-1) +- Rate limiting for AI agents (MCP-6) +- MCP protocol implementation (Resources + Tools) + +--- + +## 7. Success Metrics + +### Day 5 Success Criteria + +#### Refresh Token +- [ ] Access token lifetime: 15 minutes +- [ ] Refresh token lifetime: 7 days +- [ ] Token refresh endpoint response time: < 200ms +- [ ] All refresh token tests passing + +#### RBAC +- [ ] 5 system roles seeded in database +- [ ] JWT includes role claims +- [ ] Admin endpoints protected with role-based authorization +- [ ] All RBAC tests passing + +#### MCP Readiness +- [ ] AIAgent role defined and assignable +- [ ] Role-based authorization policies configured +- [ ] Audit logging includes actor role (foundation) + +--- + +## 8. Risk Mitigation + +### Risk 1: Refresh Token Implementation Complexity +**Risk**: Token rotation logic may introduce race conditions +**Mitigation**: Use database transactions, test concurrent refresh attempts +**Fallback**: Implement simple refresh without rotation (P0), add rotation in P1 + +### Risk 2: RBAC Migration Breaks Existing Users +**Risk**: Existing users have no roles, break auth flow +**Mitigation**: Data migration assigns default roles before deploying RBAC +**Fallback**: Add fallback logic (users without roles get Member role temporarily) + +### Risk 3: Day 5 Scope Too Large +**Risk**: Cannot complete both features in 1 day +**Mitigation**: Prioritize Refresh Token (P0), defer RBAC project-level roles to Day 6 +**Fallback**: Complete Refresh Token only, move RBAC to Day 6 + +--- + +## 9. Approval & Sign-Off + +### Stakeholders +- **Product Manager**: Approved +- **Architect**: Pending review +- **Backend Lead**: Pending review +- **Security Team**: Pending review (refresh token security) + +### Next Steps +1. Review this PRD with architect and backend lead +2. Create detailed technical design for refresh token storage (database vs. Redis) +3. Begin Day 5 implementation + +--- + +## Appendix A: Alternative Approaches Considered + +### Refresh Token Storage: Database vs. Redis + +#### Option 1: PostgreSQL (Recommended) +**Pros**: +- Simple setup, no additional infrastructure +- ACID guarantees for token rotation +- Easy audit trail integration + +**Cons**: +- Slower than Redis (but < 200ms acceptable) +- Database load for high-traffic scenarios + +**Decision**: Use PostgreSQL for M1-M3, evaluate Redis for M4-M6 if needed + +--- + +#### Option 2: Redis +**Pros**: +- Extremely fast (< 10ms lookup) +- TTL-based automatic expiration +- Scales horizontally + +**Cons**: +- Additional infrastructure complexity +- No ACID transactions (potential race conditions) +- Audit trail requires separate logging + +**Decision**: Defer to M4+ if performance bottleneck identified + +--- + +### RBAC Implementation: Enum vs. Database Roles + +#### Option 1: Database Roles (Recommended) +**Pros**: +- Flexible, supports custom roles in future +- Queryable, auditable +- Supports project-level roles + +**Cons**: +- More complex schema +- Requires migration for role changes + +**Decision**: Use database roles for extensibility + +--- + +#### Option 2: Enum Roles +**Pros**: +- Simple, type-safe in C# +- No database lookups + +**Cons**: +- Cannot add custom roles without code changes +- No project-level role support + +**Decision**: Rejected - too rigid for M2+ requirements + +--- + +## Appendix B: References + +- [RFC 6749: OAuth 2.0](https://datatracker.ietf.org/doc/html/rfc6749) - Refresh token spec +- [OWASP Authentication Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html) +- [ASP.NET Core Authorization](https://learn.microsoft.com/en-us/aspnet/core/security/authorization/introduction) +- ColaFlow Product Plan: `product.md` +- Day 4 Implementation: `DAY4-IMPLEMENTATION-SUMMARY.md` + +--- + +**Document Version**: 1.0 +**Last Updated**: 2025-11-03 +**Next Review**: Day 6 (Post-Implementation Review) diff --git a/colaflow-api/src/ColaFlow.API/ColaFlow.API.csproj b/colaflow-api/src/ColaFlow.API/ColaFlow.API.csproj index ef2475c..059af9b 100644 --- a/colaflow-api/src/ColaFlow.API/ColaFlow.API.csproj +++ b/colaflow-api/src/ColaFlow.API/ColaFlow.API.csproj @@ -7,6 +7,7 @@ + all diff --git a/colaflow-api/src/ColaFlow.API/Controllers/AuthController.cs b/colaflow-api/src/ColaFlow.API/Controllers/AuthController.cs index 03ed66b..97ea97f 100644 --- a/colaflow-api/src/ColaFlow.API/Controllers/AuthController.cs +++ b/colaflow-api/src/ColaFlow.API/Controllers/AuthController.cs @@ -1,6 +1,11 @@ +using ColaFlow.API.Models; using ColaFlow.Modules.Identity.Application.Commands.Login; +using ColaFlow.Modules.Identity.Application.Services; using MediatR; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using System.Security.Claims; namespace ColaFlow.API.Controllers; @@ -9,10 +14,17 @@ namespace ColaFlow.API.Controllers; public class AuthController : ControllerBase { private readonly IMediator _mediator; + private readonly IRefreshTokenService _refreshTokenService; + private readonly ILogger _logger; - public AuthController(IMediator mediator) + public AuthController( + IMediator mediator, + IRefreshTokenService refreshTokenService, + ILogger logger) { _mediator = mediator; + _refreshTokenService = refreshTokenService; + _logger = logger; } /// @@ -29,10 +41,106 @@ public class AuthController : ControllerBase /// Get current user (requires authentication) /// [HttpGet("me")] - // [Authorize] // TODO: Add after JWT middleware is configured - public async Task GetCurrentUser() + [Authorize] + public IActionResult GetCurrentUser() { - // TODO: Implement after JWT middleware - return Ok(new { message = "Current user endpoint - to be implemented" }); + // Extract user information from JWT Claims + var userId = User.FindFirst("user_id")?.Value; + var tenantId = User.FindFirst("tenant_id")?.Value; + var email = User.FindFirst(ClaimTypes.Email)?.Value; + var fullName = User.FindFirst("full_name")?.Value; + var tenantSlug = User.FindFirst("tenant_slug")?.Value; + + return Ok(new + { + userId, + tenantId, + email, + fullName, + tenantSlug, + claims = User.Claims.Select(c => new { c.Type, c.Value }) + }); + } + + /// + /// Refresh access token using refresh token + /// + [HttpPost("refresh")] + [AllowAnonymous] + public async Task RefreshToken([FromBody] RefreshTokenRequest request) + { + try + { + var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString(); + var userAgent = HttpContext.Request.Headers["User-Agent"].ToString(); + + var (accessToken, newRefreshToken) = await _refreshTokenService.RefreshTokenAsync( + request.RefreshToken, + ipAddress, + userAgent, + HttpContext.RequestAborted); + + return Ok(new + { + accessToken, + refreshToken = newRefreshToken, + expiresIn = 900, // 15 minutes in seconds + tokenType = "Bearer" + }); + } + catch (UnauthorizedAccessException ex) + { + _logger.LogWarning(ex, "Refresh token failed"); + return Unauthorized(new { message = "Invalid or expired refresh token" }); + } + } + + /// + /// Logout (revoke refresh token) + /// + [HttpPost("logout")] + [AllowAnonymous] + public async Task Logout([FromBody] LogoutRequest request) + { + try + { + var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString(); + + await _refreshTokenService.RevokeTokenAsync( + request.RefreshToken, + ipAddress, + HttpContext.RequestAborted); + + return Ok(new { message = "Logged out successfully" }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Logout failed"); + return BadRequest(new { message = "Logout failed" }); + } + } + + /// + /// Logout from all devices (revoke all user refresh tokens) + /// + [HttpPost("logout-all")] + [Authorize] + public async Task LogoutAllDevices() + { + try + { + var userId = Guid.Parse(User.FindFirstValue("user_id")!); + + await _refreshTokenService.RevokeAllUserTokensAsync( + userId, + HttpContext.RequestAborted); + + return Ok(new { message = "Logged out from all devices successfully" }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Logout from all devices failed"); + return BadRequest(new { message = "Logout failed" }); + } } } diff --git a/colaflow-api/src/ColaFlow.API/Models/LogoutRequest.cs b/colaflow-api/src/ColaFlow.API/Models/LogoutRequest.cs new file mode 100644 index 0000000..2971090 --- /dev/null +++ b/colaflow-api/src/ColaFlow.API/Models/LogoutRequest.cs @@ -0,0 +1,6 @@ +namespace ColaFlow.API.Models; + +public class LogoutRequest +{ + public string RefreshToken { get; set; } = string.Empty; +} diff --git a/colaflow-api/src/ColaFlow.API/Models/RefreshTokenRequest.cs b/colaflow-api/src/ColaFlow.API/Models/RefreshTokenRequest.cs new file mode 100644 index 0000000..e18b06d --- /dev/null +++ b/colaflow-api/src/ColaFlow.API/Models/RefreshTokenRequest.cs @@ -0,0 +1,6 @@ +namespace ColaFlow.API.Models; + +public class RefreshTokenRequest +{ + public string RefreshToken { get; set; } = string.Empty; +} diff --git a/colaflow-api/src/ColaFlow.API/Program.cs b/colaflow-api/src/ColaFlow.API/Program.cs index 64357c3..5610b96 100644 --- a/colaflow-api/src/ColaFlow.API/Program.cs +++ b/colaflow-api/src/ColaFlow.API/Program.cs @@ -2,7 +2,10 @@ using ColaFlow.API.Extensions; using ColaFlow.API.Handlers; using ColaFlow.Modules.Identity.Application; using ColaFlow.Modules.Identity.Infrastructure; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.IdentityModel.Tokens; using Scalar.AspNetCore; +using System.Text; var builder = WebApplication.CreateBuilder(args); @@ -20,6 +23,29 @@ builder.Services.AddControllers(); builder.Services.AddExceptionHandler(); builder.Services.AddProblemDetails(); +// Configure Authentication +builder.Services.AddAuthentication(options => +{ + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; +}) +.AddJwtBearer(options => +{ + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = builder.Configuration["Jwt:Issuer"], + ValidAudience = builder.Configuration["Jwt:Audience"], + IssuerSigningKey = new SymmetricSecurityKey( + Encoding.UTF8.GetBytes(builder.Configuration["Jwt:SecretKey"] ?? throw new InvalidOperationException("JWT SecretKey not configured"))) + }; +}); + +builder.Services.AddAuthorization(); + // Configure CORS for frontend builder.Services.AddCors(options => { @@ -50,6 +76,11 @@ app.UseExceptionHandler(); app.UseCors("AllowFrontend"); app.UseHttpsRedirection(); + +// Authentication & Authorization +app.UseAuthentication(); +app.UseAuthorization(); + app.MapControllers(); app.Run(); diff --git a/colaflow-api/src/ColaFlow.API/appsettings.Development.json b/colaflow-api/src/ColaFlow.API/appsettings.Development.json index 98f588e..0ffb605 100644 --- a/colaflow-api/src/ColaFlow.API/appsettings.Development.json +++ b/colaflow-api/src/ColaFlow.API/appsettings.Development.json @@ -1,4 +1,11 @@ { + "Jwt": { + "SecretKey": "your-super-secret-key-min-32-characters-long-12345", + "Issuer": "ColaFlow.API", + "Audience": "ColaFlow.Web", + "ExpirationMinutes": "15", + "RefreshTokenExpirationDays": "7" + }, "ConnectionStrings": { "PMDatabase": "Host=localhost;Port=5432;Database=colaflow_pm;Username=colaflow;Password=colaflow_dev_password", "DefaultConnection": "Host=localhost;Port=5432;Database=colaflow_identity;Username=colaflow;Password=colaflow_dev_password" diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/Login/LoginCommandHandler.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/Login/LoginCommandHandler.cs index 0caf114..eb3d1c5 100644 --- a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/Login/LoginCommandHandler.cs +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/Login/LoginCommandHandler.cs @@ -1,4 +1,5 @@ using ColaFlow.Modules.Identity.Application.Dtos; +using ColaFlow.Modules.Identity.Application.Services; using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants; using ColaFlow.Modules.Identity.Domain.Aggregates.Users; using ColaFlow.Modules.Identity.Domain.Repositories; @@ -10,14 +11,22 @@ public class LoginCommandHandler : IRequestHandler Handle(LoginCommand request, CancellationToken cancellationToken) @@ -38,20 +47,27 @@ public class LoginCommandHandler : IRequestHandler Handle( @@ -40,20 +49,27 @@ public class RegisterTenantCommandHandler : IRequestHandler GenerateRefreshTokenAsync(User user, CancellationToken cancellationToken = default); +} diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Services/IPasswordHasher.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Services/IPasswordHasher.cs new file mode 100644 index 0000000..17f258c --- /dev/null +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Services/IPasswordHasher.cs @@ -0,0 +1,7 @@ +namespace ColaFlow.Modules.Identity.Application.Services; + +public interface IPasswordHasher +{ + string HashPassword(string password); + bool VerifyPassword(string password, string hashedPassword); +} diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Services/IRefreshTokenService.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Services/IRefreshTokenService.cs new file mode 100644 index 0000000..b7bd0b0 --- /dev/null +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Services/IRefreshTokenService.cs @@ -0,0 +1,39 @@ +using ColaFlow.Modules.Identity.Domain.Aggregates.Users; + +namespace ColaFlow.Modules.Identity.Application.Services; + +public interface IRefreshTokenService +{ + /// + /// Generate a new refresh token for the user + /// + Task GenerateRefreshTokenAsync( + User user, + string? ipAddress = null, + string? userAgent = null, + CancellationToken cancellationToken = default); + + /// + /// Refresh access token using refresh token (with token rotation) + /// + Task<(string accessToken, string refreshToken)> RefreshTokenAsync( + string refreshToken, + string? ipAddress = null, + string? userAgent = null, + CancellationToken cancellationToken = default); + + /// + /// Revoke a specific refresh token + /// + Task RevokeTokenAsync( + string refreshToken, + string? ipAddress = null, + CancellationToken cancellationToken = default); + + /// + /// Revoke all refresh tokens for a user + /// + Task RevokeAllUserTokensAsync( + Guid userId, + CancellationToken cancellationToken = default); +} diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Aggregates/Users/RefreshToken.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Aggregates/Users/RefreshToken.cs new file mode 100644 index 0000000..64f8115 --- /dev/null +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Aggregates/Users/RefreshToken.cs @@ -0,0 +1,88 @@ +using ColaFlow.Shared.Kernel.Common; + +namespace ColaFlow.Modules.Identity.Domain.Aggregates.Users; + +/// +/// Refresh Token entity for secure token rotation +/// +public sealed class RefreshToken : Entity +{ + public string TokenHash { get; private set; } = null!; + public UserId UserId { get; private set; } = null!; + public Guid TenantId { get; private set; } + + // 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; } + + // Token rotation (token family tracking) + public string? ReplacedByToken { get; private set; } + + // Navigation properties + public string? DeviceInfo { get; private set; } + + // Private constructor for EF Core + private RefreshToken() : base() { } + + // Factory method + public static RefreshToken Create( + string tokenHash, + UserId userId, + Guid tenantId, + DateTime expiresAt, + string? ipAddress = null, + string? userAgent = null, + string? deviceInfo = null) + { + if (string.IsNullOrWhiteSpace(tokenHash)) + throw new ArgumentException("Token hash cannot be empty", nameof(tokenHash)); + + if (expiresAt <= DateTime.UtcNow) + throw new ArgumentException("Expiration date must be in the future", nameof(expiresAt)); + + return new RefreshToken + { + Id = Guid.NewGuid(), + TokenHash = tokenHash, + UserId = userId, + TenantId = tenantId, + ExpiresAt = expiresAt, + CreatedAt = DateTime.UtcNow, + IpAddress = ipAddress, + UserAgent = userAgent, + DeviceInfo = deviceInfo + }; + } + + // Business methods + public bool IsExpired() => DateTime.UtcNow >= ExpiresAt; + + public bool IsRevoked() => RevokedAt.HasValue; + + public bool IsActive() => !IsExpired() && !IsRevoked(); + + public void Revoke(string reason) + { + if (IsRevoked()) + throw new InvalidOperationException("Token is already revoked"); + + RevokedAt = DateTime.UtcNow; + RevokedReason = reason; + } + + public void MarkAsReplaced(string newTokenHash) + { + if (string.IsNullOrWhiteSpace(newTokenHash)) + throw new ArgumentException("New token hash cannot be empty", nameof(newTokenHash)); + + ReplacedByToken = newTokenHash; + RevokedAt = DateTime.UtcNow; + RevokedReason = "Token rotated"; + } +} diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Repositories/IRefreshTokenRepository.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Repositories/IRefreshTokenRepository.cs new file mode 100644 index 0000000..3b85664 --- /dev/null +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Repositories/IRefreshTokenRepository.cs @@ -0,0 +1,13 @@ +using ColaFlow.Modules.Identity.Domain.Aggregates.Users; + +namespace ColaFlow.Modules.Identity.Domain.Repositories; + +public interface IRefreshTokenRepository +{ + Task GetByTokenHashAsync(string tokenHash, CancellationToken cancellationToken = default); + Task> GetByUserIdAsync(Guid userId, CancellationToken cancellationToken = default); + Task AddAsync(RefreshToken refreshToken, CancellationToken cancellationToken = default); + Task UpdateAsync(RefreshToken refreshToken, CancellationToken cancellationToken = default); + Task RevokeAllUserTokensAsync(Guid userId, string reason, CancellationToken cancellationToken = default); + Task DeleteExpiredTokensAsync(CancellationToken cancellationToken = default); +} diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/ColaFlow.Modules.Identity.Infrastructure.csproj b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/ColaFlow.Modules.Identity.Infrastructure.csproj index 9ab42e5..f9f2f3e 100644 --- a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/ColaFlow.Modules.Identity.Infrastructure.csproj +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/ColaFlow.Modules.Identity.Infrastructure.csproj @@ -5,6 +5,7 @@ + @@ -12,7 +13,9 @@ all + + diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/DependencyInjection.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/DependencyInjection.cs index e99152d..f3372d8 100644 --- a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/DependencyInjection.cs +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/DependencyInjection.cs @@ -1,3 +1,4 @@ +using ColaFlow.Modules.Identity.Application.Services; using ColaFlow.Modules.Identity.Domain.Repositories; using ColaFlow.Modules.Identity.Infrastructure.Persistence; using ColaFlow.Modules.Identity.Infrastructure.Persistence.Repositories; @@ -27,6 +28,12 @@ public static class DependencyInjection // Repositories services.AddScoped(); services.AddScoped(); + services.AddScoped(); + + // Application Services + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); return services; } diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Configurations/RefreshTokenConfiguration.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Configurations/RefreshTokenConfiguration.cs new file mode 100644 index 0000000..68cf15c --- /dev/null +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Configurations/RefreshTokenConfiguration.cs @@ -0,0 +1,83 @@ +using ColaFlow.Modules.Identity.Domain.Aggregates.Users; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ColaFlow.Modules.Identity.Infrastructure.Persistence.Configurations; + +public class RefreshTokenConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("refresh_tokens", "identity"); + + builder.HasKey(rt => rt.Id); + + builder.Property(rt => rt.TokenHash) + .HasColumnName("token_hash") + .HasMaxLength(500) + .IsRequired(); + + builder.Property(rt => rt.TenantId) + .HasColumnName("tenant_id") + .IsRequired(); + + builder.Property(rt => rt.ExpiresAt) + .HasColumnName("expires_at") + .IsRequired(); + + builder.Property(rt => rt.CreatedAt) + .HasColumnName("created_at") + .IsRequired(); + + builder.Property(rt => rt.RevokedAt) + .HasColumnName("revoked_at") + .IsRequired(false); + + builder.Property(rt => rt.RevokedReason) + .HasColumnName("revoked_reason") + .HasMaxLength(500) + .IsRequired(false); + + builder.Property(rt => rt.IpAddress) + .HasColumnName("ip_address") + .HasMaxLength(50) + .IsRequired(false); + + builder.Property(rt => rt.UserAgent) + .HasColumnName("user_agent") + .HasMaxLength(500) + .IsRequired(false); + + builder.Property(rt => rt.ReplacedByToken) + .HasColumnName("replaced_by_token") + .HasMaxLength(500) + .IsRequired(false); + + builder.Property(rt => rt.DeviceInfo) + .HasColumnName("device_info") + .HasMaxLength(500) + .IsRequired(false); + + // Value object conversion for UserId + builder.Property(rt => rt.UserId) + .HasColumnName("user_id") + .HasConversion( + id => id.Value, + value => UserId.Create(value)) + .IsRequired(); + + // Indexes for performance + builder.HasIndex(rt => rt.TokenHash) + .HasDatabaseName("ix_refresh_tokens_token_hash") + .IsUnique(); + + builder.HasIndex(rt => rt.UserId) + .HasDatabaseName("ix_refresh_tokens_user_id"); + + builder.HasIndex(rt => rt.ExpiresAt) + .HasDatabaseName("ix_refresh_tokens_expires_at"); + + builder.HasIndex(rt => rt.TenantId) + .HasDatabaseName("ix_refresh_tokens_tenant_id"); + } +} diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/IdentityDbContext.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/IdentityDbContext.cs index 16d617b..ac64fca 100644 --- a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/IdentityDbContext.cs +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/IdentityDbContext.cs @@ -19,6 +19,7 @@ public class IdentityDbContext : DbContext public DbSet Tenants => Set(); public DbSet Users => Set(); + public DbSet RefreshTokens => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Migrations/20251103133337_AddRefreshTokens.Designer.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Migrations/20251103133337_AddRefreshTokens.Designer.cs new file mode 100644 index 0000000..deedfb4 --- /dev/null +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Migrations/20251103133337_AddRefreshTokens.Designer.cs @@ -0,0 +1,283 @@ +// +using System; +using ColaFlow.Modules.Identity.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace ColaFlow.Modules.Identity.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(IdentityDbContext))] + [Migration("20251103133337_AddRefreshTokens")] + partial class AddRefreshTokens + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("ColaFlow.Modules.Identity.Domain.Aggregates.Tenants.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("MaxProjects") + .HasColumnType("integer") + .HasColumnName("max_projects"); + + b.Property("MaxStorageGB") + .HasColumnType("integer") + .HasColumnName("max_storage_gb"); + + b.Property("MaxUsers") + .HasColumnType("integer") + .HasColumnName("max_users"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Plan") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("plan"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("slug"); + + b.Property("SsoConfig") + .HasColumnType("jsonb") + .HasColumnName("sso_config"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("status"); + + b.Property("SuspendedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("suspended_at"); + + b.Property("SuspensionReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("suspension_reason"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_tenants_slug"); + + b.ToTable("tenants", (string)null); + }); + + modelBuilder.Entity("ColaFlow.Modules.Identity.Domain.Aggregates.Users.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeviceInfo") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("device_info"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("IpAddress") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("ip_address"); + + b.Property("ReplacedByToken") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("replaced_by_token"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_at"); + + b.Property("RevokedReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("revoked_reason"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("token_hash"); + + b.Property("UserAgent") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("user_agent"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt") + .HasDatabaseName("ix_refresh_tokens_expires_at"); + + b.HasIndex("TenantId") + .HasDatabaseName("ix_refresh_tokens_tenant_id"); + + b.HasIndex("TokenHash") + .IsUnique() + .HasDatabaseName("ix_refresh_tokens_token_hash"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_refresh_tokens_user_id"); + + b.ToTable("refresh_tokens", "identity"); + }); + + modelBuilder.Entity("ColaFlow.Modules.Identity.Domain.Aggregates.Users.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AuthProvider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("auth_provider"); + + b.Property("AvatarUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("avatar_url"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("email"); + + b.Property("EmailVerificationToken") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("email_verification_token"); + + b.Property("EmailVerifiedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("email_verified_at"); + + b.Property("ExternalEmail") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("external_email"); + + b.Property("ExternalUserId") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("external_user_id"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("full_name"); + + b.Property("JobTitle") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("job_title"); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_login_at"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("password_hash"); + + b.Property("PasswordResetToken") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("password_reset_token"); + + b.Property("PasswordResetTokenExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("password_reset_token_expires_at"); + + b.Property("PhoneNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("phone_number"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Email") + .IsUnique() + .HasDatabaseName("ix_users_tenant_id_email"); + + b.ToTable("users", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Migrations/20251103133337_AddRefreshTokens.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Migrations/20251103133337_AddRefreshTokens.cs new file mode 100644 index 0000000..a747f1b --- /dev/null +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Migrations/20251103133337_AddRefreshTokens.cs @@ -0,0 +1,74 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ColaFlow.Modules.Identity.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddRefreshTokens : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "identity"); + + migrationBuilder.CreateTable( + name: "refresh_tokens", + schema: "identity", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + token_hash = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + user_id = table.Column(type: "uuid", nullable: false), + tenant_id = table.Column(type: "uuid", nullable: false), + expires_at = table.Column(type: "timestamp with time zone", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + revoked_at = table.Column(type: "timestamp with time zone", nullable: true), + revoked_reason = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + ip_address = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + user_agent = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + replaced_by_token = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + device_info = table.Column(type: "character varying(500)", maxLength: 500, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_refresh_tokens", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "ix_refresh_tokens_expires_at", + schema: "identity", + table: "refresh_tokens", + column: "expires_at"); + + migrationBuilder.CreateIndex( + name: "ix_refresh_tokens_tenant_id", + schema: "identity", + table: "refresh_tokens", + column: "tenant_id"); + + migrationBuilder.CreateIndex( + name: "ix_refresh_tokens_token_hash", + schema: "identity", + table: "refresh_tokens", + column: "token_hash", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_refresh_tokens_user_id", + schema: "identity", + table: "refresh_tokens", + column: "user_id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "refresh_tokens", + schema: "identity"); + } + } +} diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Migrations/IdentityDbContextModelSnapshot.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Migrations/IdentityDbContextModelSnapshot.cs index 4c68ad7..21935c5 100644 --- a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Migrations/IdentityDbContextModelSnapshot.cs +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Migrations/IdentityDbContextModelSnapshot.cs @@ -95,6 +95,81 @@ namespace ColaFlow.Modules.Identity.Infrastructure.Persistence.Migrations b.ToTable("tenants", (string)null); }); + modelBuilder.Entity("ColaFlow.Modules.Identity.Domain.Aggregates.Users.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeviceInfo") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("device_info"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("IpAddress") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("ip_address"); + + b.Property("ReplacedByToken") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("replaced_by_token"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_at"); + + b.Property("RevokedReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("revoked_reason"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("token_hash"); + + b.Property("UserAgent") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("user_agent"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt") + .HasDatabaseName("ix_refresh_tokens_expires_at"); + + b.HasIndex("TenantId") + .HasDatabaseName("ix_refresh_tokens_tenant_id"); + + b.HasIndex("TokenHash") + .IsUnique() + .HasDatabaseName("ix_refresh_tokens_token_hash"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_refresh_tokens_user_id"); + + b.ToTable("refresh_tokens", "identity"); + }); + modelBuilder.Entity("ColaFlow.Modules.Identity.Domain.Aggregates.Users.User", b => { b.Property("Id") diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Repositories/RefreshTokenRepository.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Repositories/RefreshTokenRepository.cs new file mode 100644 index 0000000..7a0d6ba --- /dev/null +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Repositories/RefreshTokenRepository.cs @@ -0,0 +1,76 @@ +using ColaFlow.Modules.Identity.Domain.Aggregates.Users; +using ColaFlow.Modules.Identity.Domain.Repositories; +using Microsoft.EntityFrameworkCore; + +namespace ColaFlow.Modules.Identity.Infrastructure.Persistence.Repositories; + +public class RefreshTokenRepository : IRefreshTokenRepository +{ + private readonly IdentityDbContext _context; + + public RefreshTokenRepository(IdentityDbContext context) + { + _context = context; + } + + public async Task GetByTokenHashAsync( + string tokenHash, + CancellationToken cancellationToken = default) + { + return await _context.RefreshTokens + .FirstOrDefaultAsync(rt => rt.TokenHash == tokenHash, cancellationToken); + } + + public async Task> GetByUserIdAsync( + Guid userId, + CancellationToken cancellationToken = default) + { + return await _context.RefreshTokens + .Where(rt => rt.UserId.Value == userId) + .OrderByDescending(rt => rt.CreatedAt) + .ToListAsync(cancellationToken); + } + + public async Task AddAsync( + RefreshToken refreshToken, + CancellationToken cancellationToken = default) + { + await _context.RefreshTokens.AddAsync(refreshToken, cancellationToken); + await _context.SaveChangesAsync(cancellationToken); + } + + public async Task UpdateAsync( + RefreshToken refreshToken, + CancellationToken cancellationToken = default) + { + _context.RefreshTokens.Update(refreshToken); + await _context.SaveChangesAsync(cancellationToken); + } + + public async Task RevokeAllUserTokensAsync( + Guid userId, + string reason, + CancellationToken cancellationToken = default) + { + var tokens = await _context.RefreshTokens + .Where(rt => rt.UserId.Value == userId && rt.RevokedAt == null) + .ToListAsync(cancellationToken); + + foreach (var token in tokens) + { + token.Revoke(reason); + } + + await _context.SaveChangesAsync(cancellationToken); + } + + public async Task DeleteExpiredTokensAsync(CancellationToken cancellationToken = default) + { + var expiredTokens = await _context.RefreshTokens + .Where(rt => rt.ExpiresAt < DateTime.UtcNow) + .ToListAsync(cancellationToken); + + _context.RefreshTokens.RemoveRange(expiredTokens); + await _context.SaveChangesAsync(cancellationToken); + } +} diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Services/JwtService.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Services/JwtService.cs new file mode 100644 index 0000000..cfa8aed --- /dev/null +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Services/JwtService.cs @@ -0,0 +1,58 @@ +using ColaFlow.Modules.Identity.Application.Services; +using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants; +using ColaFlow.Modules.Identity.Domain.Aggregates.Users; +using Microsoft.Extensions.Configuration; +using Microsoft.IdentityModel.Tokens; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; + +namespace ColaFlow.Modules.Identity.Infrastructure.Services; + +public class JwtService : IJwtService +{ + private readonly IConfiguration _configuration; + + public JwtService(IConfiguration configuration) + { + _configuration = configuration; + } + + public string GenerateToken(User user, Tenant tenant) + { + 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 + { + 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(ClaimTypes.Role, "User") // TODO: Implement real 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); + } + + public Task GenerateRefreshTokenAsync(User user, CancellationToken cancellationToken = default) + { + // TODO: Implement refresh token generation and storage + throw new NotImplementedException("Refresh token not yet implemented"); + } +} diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Services/PasswordHasher.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Services/PasswordHasher.cs new file mode 100644 index 0000000..7879cec --- /dev/null +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Services/PasswordHasher.cs @@ -0,0 +1,16 @@ +using ColaFlow.Modules.Identity.Application.Services; + +namespace ColaFlow.Modules.Identity.Infrastructure.Services; + +public class PasswordHasher : IPasswordHasher +{ + public string HashPassword(string password) + { + return BCrypt.Net.BCrypt.HashPassword(password, workFactor: 12); + } + + public bool VerifyPassword(string password, string hashedPassword) + { + return BCrypt.Net.BCrypt.Verify(password, hashedPassword); + } +} diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Services/RefreshTokenService.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Services/RefreshTokenService.cs new file mode 100644 index 0000000..b486015 --- /dev/null +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Services/RefreshTokenService.cs @@ -0,0 +1,201 @@ +using ColaFlow.Modules.Identity.Application.Services; +using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants; +using ColaFlow.Modules.Identity.Domain.Aggregates.Users; +using ColaFlow.Modules.Identity.Domain.Repositories; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using System.Security.Cryptography; +using System.Text; + +namespace ColaFlow.Modules.Identity.Infrastructure.Services; + +public class RefreshTokenService : IRefreshTokenService +{ + private readonly IRefreshTokenRepository _refreshTokenRepository; + private readonly IUserRepository _userRepository; + private readonly ITenantRepository _tenantRepository; + private readonly IJwtService _jwtService; + private readonly IConfiguration _configuration; + private readonly ILogger _logger; + + public RefreshTokenService( + IRefreshTokenRepository refreshTokenRepository, + IUserRepository userRepository, + ITenantRepository tenantRepository, + IJwtService jwtService, + IConfiguration configuration, + ILogger logger) + { + _refreshTokenRepository = refreshTokenRepository; + _userRepository = userRepository; + _tenantRepository = tenantRepository; + _jwtService = jwtService; + _configuration = configuration; + _logger = logger; + } + + public async Task GenerateRefreshTokenAsync( + User user, + string? ipAddress = null, + string? userAgent = null, + CancellationToken cancellationToken = default) + { + // Generate cryptographically secure random token (64 bytes) + var randomBytes = new byte[64]; + using var rng = RandomNumberGenerator.Create(); + rng.GetBytes(randomBytes); + var token = Convert.ToBase64String(randomBytes); + + // Hash token before storage (SHA-256) + var tokenHash = ComputeSha256Hash(token); + + // Get expiration from configuration (default 7 days) + var expirationDays = _configuration.GetValue("Jwt:RefreshTokenExpirationDays", 7); + var expiresAt = DateTime.UtcNow.AddDays(expirationDays); + + // Create refresh token entity + var refreshToken = RefreshToken.Create( + tokenHash: tokenHash, + userId: UserId.Create(user.Id), + tenantId: user.TenantId.Value, + expiresAt: expiresAt, + ipAddress: ipAddress, + userAgent: userAgent, + deviceInfo: userAgent // Use user agent as device info for now + ); + + // Save to database + await _refreshTokenRepository.AddAsync(refreshToken, cancellationToken); + + _logger.LogInformation( + "Generated refresh token for user {UserId}, expires at {ExpiresAt}", + user.Id, expiresAt); + + // Return plain text token (only time we return it) + return token; + } + + public async Task<(string accessToken, string refreshToken)> RefreshTokenAsync( + string refreshToken, + string? ipAddress = null, + string? userAgent = null, + CancellationToken cancellationToken = default) + { + // Hash the provided token to look it up + 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[..10] + "..."); + throw new UnauthorizedAccessException("Invalid refresh token"); + } + + // Check if token is active (not expired and not revoked) + if (!existingToken.IsActive()) + { + _logger.LogWarning( + "Attempted to use invalid refresh token for user {UserId}. Expired: {IsExpired}, Revoked: {IsRevoked}", + existingToken.UserId.Value, existingToken.IsExpired(), existingToken.IsRevoked()); + + // SECURITY: Token reuse detection - revoke all user tokens + if (existingToken.IsRevoked()) + { + _logger.LogWarning( + "SECURITY ALERT: Revoked token reused for user {UserId}. Revoking all tokens.", + existingToken.UserId.Value); + await RevokeAllUserTokensAsync(existingToken.UserId.Value, cancellationToken); + } + + throw new UnauthorizedAccessException("Invalid or expired refresh token"); + } + + // Get user and tenant + var user = await _userRepository.GetByIdAsync(existingToken.UserId, cancellationToken); + if (user == null || user.Status != UserStatus.Active) + { + _logger.LogWarning("User not found or inactive: {UserId}", existingToken.UserId.Value); + throw new UnauthorizedAccessException("User not found or inactive"); + } + + var tenant = await _tenantRepository.GetByIdAsync(TenantId.Create(existingToken.TenantId), cancellationToken); + if (tenant == null || tenant.Status != TenantStatus.Active) + { + _logger.LogWarning("Tenant not found or inactive: {TenantId}", existingToken.TenantId); + throw new UnauthorizedAccessException("Tenant not found or inactive"); + } + + // Generate new access token + var newAccessToken = _jwtService.GenerateToken(user, tenant); + + // Generate new refresh token (token rotation) + var newRefreshToken = await GenerateRefreshTokenAsync(user, ipAddress, userAgent, cancellationToken); + + // Mark old token as replaced + var newTokenHash = ComputeSha256Hash(newRefreshToken); + existingToken.MarkAsReplaced(newTokenHash); + await _refreshTokenRepository.UpdateAsync(existingToken, cancellationToken); + + _logger.LogInformation( + "Rotated refresh token for user {UserId}", + user.Id); + + return (newAccessToken, newRefreshToken); + } + + public async Task RevokeTokenAsync( + string refreshToken, + string? ipAddress = null, + CancellationToken cancellationToken = default) + { + var tokenHash = ComputeSha256Hash(refreshToken); + var token = await _refreshTokenRepository.GetByTokenHashAsync(tokenHash, cancellationToken); + + if (token == null) + { + _logger.LogWarning("Attempted to revoke non-existent token"); + return; // Silent failure for security + } + + if (token.IsRevoked()) + { + _logger.LogWarning("Token already revoked: {TokenId}", token.Id); + return; + } + + var reason = ipAddress != null + ? $"User logout from {ipAddress}" + : "User logout"; + + token.Revoke(reason); + await _refreshTokenRepository.UpdateAsync(token, cancellationToken); + + _logger.LogInformation( + "Revoked refresh token {TokenId} for user {UserId}", + token.Id, token.UserId.Value); + } + + public async Task RevokeAllUserTokensAsync( + Guid userId, + CancellationToken cancellationToken = default) + { + await _refreshTokenRepository.RevokeAllUserTokensAsync( + userId, + "User requested logout from all devices", + cancellationToken); + + _logger.LogInformation( + "Revoked all refresh tokens for user {UserId}", + userId); + } + + 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); + } +} diff --git a/colaflow-api/test-auth-simple.ps1 b/colaflow-api/test-auth-simple.ps1 new file mode 100644 index 0000000..f1bdc1c --- /dev/null +++ b/colaflow-api/test-auth-simple.ps1 @@ -0,0 +1,134 @@ +# Day 4 Authentication Flow Test Script +$baseUrl = "http://localhost:5167/api" + +Write-Host "====================================" +Write-Host "Day 4: Authentication Flow Test" +Write-Host "====================================" +Write-Host "" + +# Test 1: Register Tenant +Write-Host "Test 1: Register Tenant" +$registerBody = @{ + tenantName = "Test Corp" + tenantSlug = "test-corp-" + (Get-Random -Maximum 10000) + subscriptionPlan = "Professional" + adminEmail = "admin@testcorp.com" + adminPassword = "Admin@1234" + adminFullName = "Test Admin" +} | ConvertTo-Json + +try { + $registerResponse = Invoke-RestMethod -Uri "$baseUrl/tenants/register" ` + -Method Post ` + -ContentType "application/json" ` + -Body $registerBody + + Write-Host "[OK] Tenant registered successfully" + Write-Host " Tenant Slug: $($registerResponse.tenant.slug)" + Write-Host " Admin Email: $($registerResponse.user.email)" + Write-Host " Token Length: $($registerResponse.accessToken.Length) characters" + + $token = $registerResponse.accessToken + $tenantSlug = $registerResponse.tenant.slug + $email = $registerResponse.user.email +} catch { + Write-Host "[FAIL] Registration failed: $_" + exit 1 +} + +Write-Host "" + +# Test 2: Login +Write-Host "Test 2: Login with Password Verification" +$loginBody = @{ + tenantSlug = $tenantSlug + email = $email + password = "Admin@1234" +} | ConvertTo-Json + +try { + $loginResponse = Invoke-RestMethod -Uri "$baseUrl/auth/login" ` + -Method Post ` + -ContentType "application/json" ` + -Body $loginBody + + Write-Host "[OK] Login successful" + Write-Host " User ID: $($loginResponse.user.id)" + Write-Host " Tenant ID: $($loginResponse.tenant.id)" + + $loginToken = $loginResponse.accessToken +} catch { + Write-Host "[FAIL] Login failed: $_" + exit 1 +} + +Write-Host "" + +# Test 3: Access protected endpoint without token +Write-Host "Test 3: Access Protected Endpoint WITHOUT Token" +try { + $response = Invoke-RestMethod -Uri "$baseUrl/auth/me" ` + -Method Get ` + -ErrorAction Stop + + Write-Host "[FAIL] Should have been rejected!" +} catch { + if ($_.Exception.Response.StatusCode -eq 401) { + Write-Host "[OK] Correctly rejected (401 Unauthorized)" + } else { + Write-Host "[FAIL] Unexpected error: $($_.Exception.Response.StatusCode)" + } +} + +Write-Host "" + +# Test 4: Access protected endpoint with token +Write-Host "Test 4: Access Protected Endpoint WITH Token" +try { + $headers = @{ + "Authorization" = "Bearer $loginToken" + } + + $meResponse = Invoke-RestMethod -Uri "$baseUrl/auth/me" ` + -Method Get ` + -Headers $headers + + Write-Host "[OK] Successfully accessed protected endpoint" + Write-Host " User ID: $($meResponse.userId)" + Write-Host " Email: $($meResponse.email)" + Write-Host " Full Name: $($meResponse.fullName)" +} catch { + Write-Host "[FAIL] Failed to access: $_" + exit 1 +} + +Write-Host "" + +# Test 5: Login with wrong password +Write-Host "Test 5: Login with Wrong Password" +$wrongPasswordBody = @{ + tenantSlug = $tenantSlug + email = $email + password = "WrongPassword123" +} | ConvertTo-Json + +try { + $response = Invoke-RestMethod -Uri "$baseUrl/auth/login" ` + -Method Post ` + -ContentType "application/json" ` + -Body $wrongPasswordBody ` + -ErrorAction Stop + + Write-Host "[FAIL] Should have been rejected!" +} catch { + if ($_.Exception.Response.StatusCode -eq 401) { + Write-Host "[OK] Correctly rejected wrong password" + } else { + Write-Host "[FAIL] Unexpected error: $($_.Exception.Response.StatusCode)" + } +} + +Write-Host "" +Write-Host "====================================" +Write-Host "All Tests Completed!" +Write-Host "====================================" diff --git a/colaflow-api/test-auth.ps1 b/colaflow-api/test-auth.ps1 new file mode 100644 index 0000000..01e9727 --- /dev/null +++ b/colaflow-api/test-auth.ps1 @@ -0,0 +1,147 @@ +# Day 4 Authentication Flow Test Script +# Test JWT Service, Password Hashing, and Authentication Middleware + +$baseUrl = "http://localhost:5000/api" + +Write-Host "====================================" -ForegroundColor Cyan +Write-Host "Day 4: Authentication Flow Test" -ForegroundColor Cyan +Write-Host "====================================" -ForegroundColor Cyan +Write-Host "" + +# Test 1: Register Tenant (should return JWT token) +Write-Host "Test 1: Register Tenant with Hashed Password" -ForegroundColor Yellow +$registerBody = @{ + tenantName = "Test Corp" + tenantSlug = "test-corp-" + (Get-Random -Maximum 10000) + subscriptionPlan = "Professional" + adminEmail = "admin@testcorp.com" + adminPassword = "Admin@1234" + adminFullName = "Test Admin" +} | ConvertTo-Json + +try { + $registerResponse = Invoke-RestMethod -Uri "$baseUrl/tenants/register" ` + -Method Post ` + -ContentType "application/json" ` + -Body $registerBody + + Write-Host "✓ Tenant registered successfully" -ForegroundColor Green + Write-Host " Tenant Slug: $($registerResponse.tenant.slug)" -ForegroundColor Gray + Write-Host " Admin Email: $($registerResponse.user.email)" -ForegroundColor Gray + Write-Host " Access Token (first 50 chars): $($registerResponse.accessToken.Substring(0, [Math]::Min(50, $registerResponse.accessToken.Length)))..." -ForegroundColor Gray + + $token = $registerResponse.accessToken + $tenantSlug = $registerResponse.tenant.slug + $email = $registerResponse.user.email +} catch { + Write-Host "✗ Registration failed: $_" -ForegroundColor Red + exit 1 +} + +Write-Host "" + +# Test 2: Login with hashed password verification +Write-Host "Test 2: Login with Password Verification" -ForegroundColor Yellow +$loginBody = @{ + tenantSlug = $tenantSlug + email = $email + password = "Admin@1234" +} | ConvertTo-Json + +try { + $loginResponse = Invoke-RestMethod -Uri "$baseUrl/auth/login" ` + -Method Post ` + -ContentType "application/json" ` + -Body $loginBody + + Write-Host "✓ Login successful" -ForegroundColor Green + Write-Host " User ID: $($loginResponse.user.id)" -ForegroundColor Gray + Write-Host " Tenant ID: $($loginResponse.tenant.id)" -ForegroundColor Gray + Write-Host " Access Token (first 50 chars): $($loginResponse.accessToken.Substring(0, [Math]::Min(50, $loginResponse.accessToken.Length)))..." -ForegroundColor Gray + + $loginToken = $loginResponse.accessToken +} catch { + Write-Host "✗ Login failed: $_" -ForegroundColor Red + exit 1 +} + +Write-Host "" + +# Test 3: Access protected endpoint without token (should fail) +Write-Host "Test 3: Access Protected Endpoint WITHOUT Token" -ForegroundColor Yellow +try { + $response = Invoke-RestMethod -Uri "$baseUrl/auth/me" ` + -Method Get ` + -ErrorAction Stop + + Write-Host "✗ Should have failed but succeeded!" -ForegroundColor Red +} catch { + if ($_.Exception.Response.StatusCode -eq 401) { + Write-Host "✓ Correctly rejected (401 Unauthorized)" -ForegroundColor Green + } else { + Write-Host "✗ Unexpected error: $($_.Exception.Response.StatusCode)" -ForegroundColor Red + } +} + +Write-Host "" + +# Test 4: Access protected endpoint with valid token (should succeed) +Write-Host "Test 4: Access Protected Endpoint WITH Token" -ForegroundColor Yellow +try { + $headers = @{ + "Authorization" = "Bearer $loginToken" + } + + $meResponse = Invoke-RestMethod -Uri "$baseUrl/auth/me" ` + -Method Get ` + -Headers $headers + + Write-Host "✓ Successfully accessed protected endpoint" -ForegroundColor Green + Write-Host " User ID: $($meResponse.userId)" -ForegroundColor Gray + Write-Host " Tenant ID: $($meResponse.tenantId)" -ForegroundColor Gray + Write-Host " Email: $($meResponse.email)" -ForegroundColor Gray + Write-Host " Full Name: $($meResponse.fullName)" -ForegroundColor Gray + Write-Host " Tenant Slug: $($meResponse.tenantSlug)" -ForegroundColor Gray +} catch { + Write-Host "✗ Failed to access protected endpoint: $_" -ForegroundColor Red + exit 1 +} + +Write-Host "" + +# Test 5: Login with wrong password (should fail) +Write-Host "Test 5: Login with Wrong Password" -ForegroundColor Yellow +$wrongPasswordBody = @{ + tenantSlug = $tenantSlug + email = $email + password = "WrongPassword123" +} | ConvertTo-Json + +try { + $response = Invoke-RestMethod -Uri "$baseUrl/auth/login" ` + -Method Post ` + -ContentType "application/json" ` + -Body $wrongPasswordBody ` + -ErrorAction Stop + + Write-Host "✗ Should have failed but succeeded!" -ForegroundColor Red +} catch { + if ($_.Exception.Response.StatusCode -eq 401) { + Write-Host "✓ Correctly rejected wrong password (401 Unauthorized)" -ForegroundColor Green + } else { + Write-Host "✗ Unexpected error: $($_.Exception.Response.StatusCode)" -ForegroundColor Red + } +} + +Write-Host "" +Write-Host "====================================" -ForegroundColor Cyan +Write-Host "All Authentication Tests Completed!" -ForegroundColor Cyan +Write-Host "====================================" -ForegroundColor Cyan +Write-Host "" +Write-Host "Summary:" -ForegroundColor Yellow +Write-Host "✓ JWT Token Generation" -ForegroundColor Green +Write-Host "✓ Password Hashing (BCrypt)" -ForegroundColor Green +Write-Host "✓ Password Verification" -ForegroundColor Green +Write-Host "✓ JWT Authentication Middleware" -ForegroundColor Green +Write-Host "✓ Protected Endpoint Access Control" -ForegroundColor Green +Write-Host ""