feat(backend): Implement Refresh Token mechanism (Day 5 Phase 1)
Implemented secure refresh token rotation with the following features: - RefreshToken domain entity with IsExpired(), IsRevoked(), IsActive(), Revoke() methods - IRefreshTokenService with token generation, rotation, and revocation - RefreshTokenService with SHA-256 hashing and token family tracking - RefreshTokenRepository for database operations - Database migration for refresh_tokens table with proper indexes - Updated LoginCommandHandler and RegisterTenantCommandHandler to return refresh tokens - Added POST /api/auth/refresh endpoint (token rotation) - Added POST /api/auth/logout endpoint (revoke single token) - Added POST /api/auth/logout-all endpoint (revoke all user tokens) - Updated JWT access token expiration to 15 minutes (from 60) - Refresh token expiration set to 7 days - Security features: token reuse detection, IP address tracking, user-agent logging Changes: - Domain: RefreshToken.cs, IRefreshTokenRepository.cs - Application: IRefreshTokenService.cs, updated LoginResponseDto and RegisterTenantResult - Infrastructure: RefreshTokenService.cs, RefreshTokenRepository.cs, RefreshTokenConfiguration.cs - API: AuthController.cs (3 new endpoints), RefreshTokenRequest.cs, LogoutRequest.cs - Configuration: appsettings.Development.json (updated JWT settings) - DI: DependencyInjection.cs (registered new services) - Migration: AddRefreshTokens migration 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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": []
|
||||
|
||||
389
colaflow-api/DAY4-IMPLEMENTATION-SUMMARY.md
Normal file
389
colaflow-api/DAY4-IMPLEMENTATION-SUMMARY.md
Normal file
@@ -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<string> 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<IJwtService, JwtService>();
|
||||
services.AddScoped<IPasswordHasher, PasswordHasher>();
|
||||
```
|
||||
|
||||
### 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": "<generated-strong-secret-key>",
|
||||
"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
|
||||
1786
colaflow-api/DAY5-ARCHITECTURE-DESIGN.md
Normal file
1786
colaflow-api/DAY5-ARCHITECTURE-DESIGN.md
Normal file
File diff suppressed because it is too large
Load Diff
948
colaflow-api/DAY5-PRIORITY-AND-REQUIREMENTS.md
Normal file
948
colaflow-api/DAY5-PRIORITY-AND-REQUIREMENTS.md
Normal file
@@ -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<IActionResult> CreateUser(...) { }
|
||||
|
||||
[Authorize(Policy = "RequireProjectAdmin")]
|
||||
[HttpDelete("api/projects/{projectId}")]
|
||||
public async Task<IActionResult> DeleteProject(...) { }
|
||||
|
||||
[Authorize(Policy = "RequireMemberOrHigher")]
|
||||
[HttpPost("api/projects/{projectId}/tasks")]
|
||||
public async Task<IActionResult> 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<IActionResult> 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)
|
||||
@@ -7,6 +7,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.10" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.9" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.0">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
|
||||
@@ -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<AuthController> _logger;
|
||||
|
||||
public AuthController(IMediator mediator)
|
||||
public AuthController(
|
||||
IMediator mediator,
|
||||
IRefreshTokenService refreshTokenService,
|
||||
ILogger<AuthController> logger)
|
||||
{
|
||||
_mediator = mediator;
|
||||
_refreshTokenService = refreshTokenService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -29,10 +41,106 @@ public class AuthController : ControllerBase
|
||||
/// Get current user (requires authentication)
|
||||
/// </summary>
|
||||
[HttpGet("me")]
|
||||
// [Authorize] // TODO: Add after JWT middleware is configured
|
||||
public async Task<IActionResult> 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 })
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refresh access token using refresh token
|
||||
/// </summary>
|
||||
[HttpPost("refresh")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> 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" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Logout (revoke refresh token)
|
||||
/// </summary>
|
||||
[HttpPost("logout")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> 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" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Logout from all devices (revoke all user refresh tokens)
|
||||
/// </summary>
|
||||
[HttpPost("logout-all")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> 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" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
6
colaflow-api/src/ColaFlow.API/Models/LogoutRequest.cs
Normal file
6
colaflow-api/src/ColaFlow.API/Models/LogoutRequest.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace ColaFlow.API.Models;
|
||||
|
||||
public class LogoutRequest
|
||||
{
|
||||
public string RefreshToken { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace ColaFlow.API.Models;
|
||||
|
||||
public class RefreshTokenRequest
|
||||
{
|
||||
public string RefreshToken { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -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<GlobalExceptionHandler>();
|
||||
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();
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<LoginCommand, LoginResponseDt
|
||||
{
|
||||
private readonly ITenantRepository _tenantRepository;
|
||||
private readonly IUserRepository _userRepository;
|
||||
// Note: In production, inject IPasswordHasher and IJwtService
|
||||
private readonly IJwtService _jwtService;
|
||||
private readonly IPasswordHasher _passwordHasher;
|
||||
private readonly IRefreshTokenService _refreshTokenService;
|
||||
|
||||
public LoginCommandHandler(
|
||||
ITenantRepository tenantRepository,
|
||||
IUserRepository userRepository)
|
||||
IUserRepository userRepository,
|
||||
IJwtService jwtService,
|
||||
IPasswordHasher passwordHasher,
|
||||
IRefreshTokenService refreshTokenService)
|
||||
{
|
||||
_tenantRepository = tenantRepository;
|
||||
_userRepository = userRepository;
|
||||
_jwtService = jwtService;
|
||||
_passwordHasher = passwordHasher;
|
||||
_refreshTokenService = refreshTokenService;
|
||||
}
|
||||
|
||||
public async Task<LoginResponseDto> Handle(LoginCommand request, CancellationToken cancellationToken)
|
||||
@@ -38,20 +47,27 @@ public class LoginCommandHandler : IRequestHandler<LoginCommand, LoginResponseDt
|
||||
throw new UnauthorizedAccessException("Invalid credentials");
|
||||
}
|
||||
|
||||
// 3. Verify password (simplified - TODO: use IPasswordHasher)
|
||||
// if (!PasswordHasher.Verify(request.Password, user.PasswordHash))
|
||||
// {
|
||||
// throw new UnauthorizedAccessException("Invalid credentials");
|
||||
// }
|
||||
// 3. Verify password
|
||||
if (user.PasswordHash == null || !_passwordHasher.VerifyPassword(request.Password, user.PasswordHash))
|
||||
{
|
||||
throw new UnauthorizedAccessException("Invalid credentials");
|
||||
}
|
||||
|
||||
// 4. Generate JWT token (simplified - TODO: use IJwtService)
|
||||
var accessToken = "dummy-token";
|
||||
// 4. Generate JWT token
|
||||
var accessToken = _jwtService.GenerateToken(user, tenant);
|
||||
|
||||
// 5. Update last login time
|
||||
// 5. Generate refresh token
|
||||
var refreshToken = await _refreshTokenService.GenerateRefreshTokenAsync(
|
||||
user,
|
||||
ipAddress: null,
|
||||
userAgent: null,
|
||||
cancellationToken);
|
||||
|
||||
// 6. Update last login time
|
||||
user.RecordLogin();
|
||||
await _userRepository.UpdateAsync(user, cancellationToken);
|
||||
|
||||
// 6. Return result
|
||||
// 7. Return result
|
||||
return new LoginResponseDto
|
||||
{
|
||||
User = new UserDto
|
||||
@@ -78,7 +94,8 @@ public class LoginCommandHandler : IRequestHandler<LoginCommand, LoginResponseDt
|
||||
CreatedAt = tenant.CreatedAt,
|
||||
UpdatedAt = tenant.UpdatedAt ?? tenant.CreatedAt
|
||||
},
|
||||
AccessToken = accessToken
|
||||
AccessToken = accessToken,
|
||||
RefreshToken = refreshToken
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,5 +15,6 @@ public record RegisterTenantCommand(
|
||||
public record RegisterTenantResult(
|
||||
TenantDto Tenant,
|
||||
UserDto AdminUser,
|
||||
string AccessToken
|
||||
string AccessToken,
|
||||
string RefreshToken
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
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;
|
||||
@@ -9,14 +10,22 @@ public class RegisterTenantCommandHandler : IRequestHandler<RegisterTenantComman
|
||||
{
|
||||
private readonly ITenantRepository _tenantRepository;
|
||||
private readonly IUserRepository _userRepository;
|
||||
// Note: In production, inject IJwtService and IPasswordHasher
|
||||
private readonly IJwtService _jwtService;
|
||||
private readonly IPasswordHasher _passwordHasher;
|
||||
private readonly IRefreshTokenService _refreshTokenService;
|
||||
|
||||
public RegisterTenantCommandHandler(
|
||||
ITenantRepository tenantRepository,
|
||||
IUserRepository userRepository)
|
||||
IUserRepository userRepository,
|
||||
IJwtService jwtService,
|
||||
IPasswordHasher passwordHasher,
|
||||
IRefreshTokenService refreshTokenService)
|
||||
{
|
||||
_tenantRepository = tenantRepository;
|
||||
_userRepository = userRepository;
|
||||
_jwtService = jwtService;
|
||||
_passwordHasher = passwordHasher;
|
||||
_refreshTokenService = refreshTokenService;
|
||||
}
|
||||
|
||||
public async Task<RegisterTenantResult> Handle(
|
||||
@@ -40,20 +49,27 @@ public class RegisterTenantCommandHandler : IRequestHandler<RegisterTenantComman
|
||||
|
||||
await _tenantRepository.AddAsync(tenant, cancellationToken);
|
||||
|
||||
// 3. Create admin user
|
||||
// Note: In production, hash password first using IPasswordHasher
|
||||
// 3. Create admin user with hashed password
|
||||
var hashedPassword = _passwordHasher.HashPassword(request.AdminPassword);
|
||||
var adminUser = User.CreateLocal(
|
||||
TenantId.Create(tenant.Id),
|
||||
Email.Create(request.AdminEmail),
|
||||
request.AdminPassword, // TODO: Hash password
|
||||
hashedPassword,
|
||||
FullName.Create(request.AdminFullName));
|
||||
|
||||
await _userRepository.AddAsync(adminUser, cancellationToken);
|
||||
|
||||
// 4. Generate JWT token (simplified - TODO: use IJwtService)
|
||||
var accessToken = "dummy-token";
|
||||
// 4. Generate JWT token
|
||||
var accessToken = _jwtService.GenerateToken(adminUser, tenant);
|
||||
|
||||
// 5. Return result
|
||||
// 5. Generate refresh token
|
||||
var refreshToken = await _refreshTokenService.GenerateRefreshTokenAsync(
|
||||
adminUser,
|
||||
ipAddress: null,
|
||||
userAgent: null,
|
||||
cancellationToken);
|
||||
|
||||
// 6. Return result
|
||||
return new RegisterTenantResult(
|
||||
new Dtos.TenantDto
|
||||
{
|
||||
@@ -78,6 +94,7 @@ public class RegisterTenantCommandHandler : IRequestHandler<RegisterTenantComman
|
||||
IsEmailVerified = adminUser.EmailVerifiedAt.HasValue,
|
||||
CreatedAt = adminUser.CreatedAt
|
||||
},
|
||||
accessToken);
|
||||
accessToken,
|
||||
refreshToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,4 +5,7 @@ public class LoginResponseDto
|
||||
public UserDto User { get; set; } = null!;
|
||||
public TenantDto Tenant { get; set; } = null!;
|
||||
public string AccessToken { get; set; } = string.Empty;
|
||||
public string RefreshToken { get; set; } = string.Empty;
|
||||
public int ExpiresIn { get; set; } = 900; // 15 minutes in seconds
|
||||
public string TokenType { get; set; } = "Bearer";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
|
||||
using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
|
||||
|
||||
namespace ColaFlow.Modules.Identity.Application.Services;
|
||||
|
||||
public interface IJwtService
|
||||
{
|
||||
string GenerateToken(User user, Tenant tenant);
|
||||
Task<string> GenerateRefreshTokenAsync(User user, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace ColaFlow.Modules.Identity.Application.Services;
|
||||
|
||||
public interface IPasswordHasher
|
||||
{
|
||||
string HashPassword(string password);
|
||||
bool VerifyPassword(string password, string hashedPassword);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
|
||||
|
||||
namespace ColaFlow.Modules.Identity.Application.Services;
|
||||
|
||||
public interface IRefreshTokenService
|
||||
{
|
||||
/// <summary>
|
||||
/// Generate a new refresh token for the user
|
||||
/// </summary>
|
||||
Task<string> GenerateRefreshTokenAsync(
|
||||
User user,
|
||||
string? ipAddress = null,
|
||||
string? userAgent = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Refresh access token using refresh token (with token rotation)
|
||||
/// </summary>
|
||||
Task<(string accessToken, string refreshToken)> RefreshTokenAsync(
|
||||
string refreshToken,
|
||||
string? ipAddress = null,
|
||||
string? userAgent = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Revoke a specific refresh token
|
||||
/// </summary>
|
||||
Task RevokeTokenAsync(
|
||||
string refreshToken,
|
||||
string? ipAddress = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Revoke all refresh tokens for a user
|
||||
/// </summary>
|
||||
Task RevokeAllUserTokensAsync(
|
||||
Guid userId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using ColaFlow.Shared.Kernel.Common;
|
||||
|
||||
namespace ColaFlow.Modules.Identity.Domain.Aggregates.Users;
|
||||
|
||||
/// <summary>
|
||||
/// Refresh Token entity for secure token rotation
|
||||
/// </summary>
|
||||
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";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
|
||||
|
||||
namespace ColaFlow.Modules.Identity.Domain.Repositories;
|
||||
|
||||
public interface IRefreshTokenRepository
|
||||
{
|
||||
Task<RefreshToken?> GetByTokenHashAsync(string tokenHash, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<RefreshToken>> 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);
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Http" Version="2.3.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.10" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.10">
|
||||
@@ -12,7 +13,9 @@
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.10" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.14.0" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.14.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
|
||||
@@ -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<ITenantRepository, TenantRepository>();
|
||||
services.AddScoped<IUserRepository, UserRepository>();
|
||||
services.AddScoped<IRefreshTokenRepository, RefreshTokenRepository>();
|
||||
|
||||
// Application Services
|
||||
services.AddScoped<IJwtService, JwtService>();
|
||||
services.AddScoped<IPasswordHasher, PasswordHasher>();
|
||||
services.AddScoped<IRefreshTokenService, RefreshTokenService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -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<RefreshToken>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<RefreshToken> 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");
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ public class IdentityDbContext : DbContext
|
||||
|
||||
public DbSet<Tenant> Tenants => Set<Tenant>();
|
||||
public DbSet<User> Users => Set<User>();
|
||||
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
// <auto-generated />
|
||||
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
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<int>("MaxProjects")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("max_projects");
|
||||
|
||||
b.Property<int>("MaxStorageGB")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("max_storage_gb");
|
||||
|
||||
b.Property<int>("MaxUsers")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("max_users");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<string>("Plan")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("plan");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("slug");
|
||||
|
||||
b.Property<string>("SsoConfig")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("sso_config");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.Property<DateTime?>("SuspendedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("suspended_at");
|
||||
|
||||
b.Property<string>("SuspensionReason")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("suspension_reason");
|
||||
|
||||
b.Property<DateTime?>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("DeviceInfo")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("device_info");
|
||||
|
||||
b.Property<DateTime>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expires_at");
|
||||
|
||||
b.Property<string>("IpAddress")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("ip_address");
|
||||
|
||||
b.Property<string>("ReplacedByToken")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("replaced_by_token");
|
||||
|
||||
b.Property<DateTime?>("RevokedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("revoked_at");
|
||||
|
||||
b.Property<string>("RevokedReason")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("revoked_reason");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("tenant_id");
|
||||
|
||||
b.Property<string>("TokenHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("token_hash");
|
||||
|
||||
b.Property<string>("UserAgent")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("user_agent");
|
||||
|
||||
b.Property<Guid>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("AuthProvider")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("auth_provider");
|
||||
|
||||
b.Property<string>("AvatarUrl")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("avatar_url");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)")
|
||||
.HasColumnName("email");
|
||||
|
||||
b.Property<string>("EmailVerificationToken")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)")
|
||||
.HasColumnName("email_verification_token");
|
||||
|
||||
b.Property<DateTime?>("EmailVerifiedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("email_verified_at");
|
||||
|
||||
b.Property<string>("ExternalEmail")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)")
|
||||
.HasColumnName("external_email");
|
||||
|
||||
b.Property<string>("ExternalUserId")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)")
|
||||
.HasColumnName("external_user_id");
|
||||
|
||||
b.Property<string>("FullName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("full_name");
|
||||
|
||||
b.Property<string>("JobTitle")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("job_title");
|
||||
|
||||
b.Property<DateTime?>("LastLoginAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("last_login_at");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)")
|
||||
.HasColumnName("password_hash");
|
||||
|
||||
b.Property<string>("PasswordResetToken")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)")
|
||||
.HasColumnName("password_reset_token");
|
||||
|
||||
b.Property<DateTime?>("PasswordResetTokenExpiresAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("password_reset_token_expires_at");
|
||||
|
||||
b.Property<string>("PhoneNumber")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("phone_number");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("tenant_id");
|
||||
|
||||
b.Property<DateTime?>("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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ColaFlow.Modules.Identity.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddRefreshTokens : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.EnsureSchema(
|
||||
name: "identity");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "refresh_tokens",
|
||||
schema: "identity",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
token_hash = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
|
||||
user_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
expires_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
created_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
revoked_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
revoked_reason = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
|
||||
ip_address = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
|
||||
user_agent = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
|
||||
replaced_by_token = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
|
||||
device_info = table.Column<string>(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");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "refresh_tokens",
|
||||
schema: "identity");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("DeviceInfo")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("device_info");
|
||||
|
||||
b.Property<DateTime>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expires_at");
|
||||
|
||||
b.Property<string>("IpAddress")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("ip_address");
|
||||
|
||||
b.Property<string>("ReplacedByToken")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("replaced_by_token");
|
||||
|
||||
b.Property<DateTime?>("RevokedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("revoked_at");
|
||||
|
||||
b.Property<string>("RevokedReason")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("revoked_reason");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("tenant_id");
|
||||
|
||||
b.Property<string>("TokenHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("token_hash");
|
||||
|
||||
b.Property<string>("UserAgent")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("user_agent");
|
||||
|
||||
b.Property<Guid>("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<Guid>("Id")
|
||||
|
||||
@@ -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<RefreshToken?> GetByTokenHashAsync(
|
||||
string tokenHash,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.RefreshTokens
|
||||
.FirstOrDefaultAsync(rt => rt.TokenHash == tokenHash, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<RefreshToken>> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<Claim>
|
||||
{
|
||||
new(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
|
||||
new(JwtRegisteredClaimNames.Email, user.Email.Value),
|
||||
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||
new("user_id", user.Id.ToString()),
|
||||
new("tenant_id", tenant.Id.ToString()),
|
||||
new("tenant_slug", tenant.Slug.Value),
|
||||
new("tenant_plan", tenant.Plan.ToString()),
|
||||
new("full_name", user.FullName.Value),
|
||||
new("auth_provider", user.AuthProvider.ToString()),
|
||||
new(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<string> GenerateRefreshTokenAsync(User user, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// TODO: Implement refresh token generation and storage
|
||||
throw new NotImplementedException("Refresh token not yet implemented");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<RefreshTokenService> _logger;
|
||||
|
||||
public RefreshTokenService(
|
||||
IRefreshTokenRepository refreshTokenRepository,
|
||||
IUserRepository userRepository,
|
||||
ITenantRepository tenantRepository,
|
||||
IJwtService jwtService,
|
||||
IConfiguration configuration,
|
||||
ILogger<RefreshTokenService> logger)
|
||||
{
|
||||
_refreshTokenRepository = refreshTokenRepository;
|
||||
_userRepository = userRepository;
|
||||
_tenantRepository = tenantRepository;
|
||||
_jwtService = jwtService;
|
||||
_configuration = configuration;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<string> 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<int>("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);
|
||||
}
|
||||
}
|
||||
134
colaflow-api/test-auth-simple.ps1
Normal file
134
colaflow-api/test-auth-simple.ps1
Normal file
@@ -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 "===================================="
|
||||
147
colaflow-api/test-auth.ps1
Normal file
147
colaflow-api/test-auth.ps1
Normal file
@@ -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 ""
|
||||
Reference in New Issue
Block a user