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:
Yaojia Wang
2025-11-03 14:44:36 +01:00
parent 1f66b25f30
commit 9e2edb2965
32 changed files with 4669 additions and 28 deletions

View File

@@ -1,7 +1,13 @@
{ {
"permissions": { "permissions": {
"allow": [ "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": [], "deny": [],
"ask": [] "ask": []

View 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

File diff suppressed because it is too large Load Diff

View 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)

View File

@@ -7,6 +7,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.10" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.9" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.0"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.0">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>

View File

@@ -1,6 +1,11 @@
using ColaFlow.API.Models;
using ColaFlow.Modules.Identity.Application.Commands.Login; using ColaFlow.Modules.Identity.Application.Commands.Login;
using ColaFlow.Modules.Identity.Application.Services;
using MediatR; using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using System.Security.Claims;
namespace ColaFlow.API.Controllers; namespace ColaFlow.API.Controllers;
@@ -9,10 +14,17 @@ namespace ColaFlow.API.Controllers;
public class AuthController : ControllerBase public class AuthController : ControllerBase
{ {
private readonly IMediator _mediator; 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; _mediator = mediator;
_refreshTokenService = refreshTokenService;
_logger = logger;
} }
/// <summary> /// <summary>
@@ -29,10 +41,106 @@ public class AuthController : ControllerBase
/// Get current user (requires authentication) /// Get current user (requires authentication)
/// </summary> /// </summary>
[HttpGet("me")] [HttpGet("me")]
// [Authorize] // TODO: Add after JWT middleware is configured [Authorize]
public async Task<IActionResult> GetCurrentUser() public IActionResult GetCurrentUser()
{ {
// TODO: Implement after JWT middleware // Extract user information from JWT Claims
return Ok(new { message = "Current user endpoint - to be implemented" }); 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" });
}
} }
} }

View File

@@ -0,0 +1,6 @@
namespace ColaFlow.API.Models;
public class LogoutRequest
{
public string RefreshToken { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,6 @@
namespace ColaFlow.API.Models;
public class RefreshTokenRequest
{
public string RefreshToken { get; set; } = string.Empty;
}

View File

@@ -2,7 +2,10 @@ using ColaFlow.API.Extensions;
using ColaFlow.API.Handlers; using ColaFlow.API.Handlers;
using ColaFlow.Modules.Identity.Application; using ColaFlow.Modules.Identity.Application;
using ColaFlow.Modules.Identity.Infrastructure; using ColaFlow.Modules.Identity.Infrastructure;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using Scalar.AspNetCore; using Scalar.AspNetCore;
using System.Text;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@@ -20,6 +23,29 @@ builder.Services.AddControllers();
builder.Services.AddExceptionHandler<GlobalExceptionHandler>(); builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails(); 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 // Configure CORS for frontend
builder.Services.AddCors(options => builder.Services.AddCors(options =>
{ {
@@ -50,6 +76,11 @@ app.UseExceptionHandler();
app.UseCors("AllowFrontend"); app.UseCors("AllowFrontend");
app.UseHttpsRedirection(); app.UseHttpsRedirection();
// Authentication & Authorization
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers(); app.MapControllers();
app.Run(); app.Run();

View File

@@ -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": { "ConnectionStrings": {
"PMDatabase": "Host=localhost;Port=5432;Database=colaflow_pm;Username=colaflow;Password=colaflow_dev_password", "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" "DefaultConnection": "Host=localhost;Port=5432;Database=colaflow_identity;Username=colaflow;Password=colaflow_dev_password"

View File

@@ -1,4 +1,5 @@
using ColaFlow.Modules.Identity.Application.Dtos; 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.Tenants;
using ColaFlow.Modules.Identity.Domain.Aggregates.Users; using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
using ColaFlow.Modules.Identity.Domain.Repositories; using ColaFlow.Modules.Identity.Domain.Repositories;
@@ -10,14 +11,22 @@ public class LoginCommandHandler : IRequestHandler<LoginCommand, LoginResponseDt
{ {
private readonly ITenantRepository _tenantRepository; private readonly ITenantRepository _tenantRepository;
private readonly IUserRepository _userRepository; 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( public LoginCommandHandler(
ITenantRepository tenantRepository, ITenantRepository tenantRepository,
IUserRepository userRepository) IUserRepository userRepository,
IJwtService jwtService,
IPasswordHasher passwordHasher,
IRefreshTokenService refreshTokenService)
{ {
_tenantRepository = tenantRepository; _tenantRepository = tenantRepository;
_userRepository = userRepository; _userRepository = userRepository;
_jwtService = jwtService;
_passwordHasher = passwordHasher;
_refreshTokenService = refreshTokenService;
} }
public async Task<LoginResponseDto> Handle(LoginCommand request, CancellationToken cancellationToken) public async Task<LoginResponseDto> Handle(LoginCommand request, CancellationToken cancellationToken)
@@ -38,20 +47,27 @@ public class LoginCommandHandler : IRequestHandler<LoginCommand, LoginResponseDt
throw new UnauthorizedAccessException("Invalid credentials"); throw new UnauthorizedAccessException("Invalid credentials");
} }
// 3. Verify password (simplified - TODO: use IPasswordHasher) // 3. Verify password
// if (!PasswordHasher.Verify(request.Password, user.PasswordHash)) if (user.PasswordHash == null || !_passwordHasher.VerifyPassword(request.Password, user.PasswordHash))
// { {
// throw new UnauthorizedAccessException("Invalid credentials"); throw new UnauthorizedAccessException("Invalid credentials");
// } }
// 4. Generate JWT token (simplified - TODO: use IJwtService) // 4. Generate JWT token
var accessToken = "dummy-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(); user.RecordLogin();
await _userRepository.UpdateAsync(user, cancellationToken); await _userRepository.UpdateAsync(user, cancellationToken);
// 6. Return result // 7. Return result
return new LoginResponseDto return new LoginResponseDto
{ {
User = new UserDto User = new UserDto
@@ -78,7 +94,8 @@ public class LoginCommandHandler : IRequestHandler<LoginCommand, LoginResponseDt
CreatedAt = tenant.CreatedAt, CreatedAt = tenant.CreatedAt,
UpdatedAt = tenant.UpdatedAt ?? tenant.CreatedAt UpdatedAt = tenant.UpdatedAt ?? tenant.CreatedAt
}, },
AccessToken = accessToken AccessToken = accessToken,
RefreshToken = refreshToken
}; };
} }
} }

View File

@@ -15,5 +15,6 @@ public record RegisterTenantCommand(
public record RegisterTenantResult( public record RegisterTenantResult(
TenantDto Tenant, TenantDto Tenant,
UserDto AdminUser, UserDto AdminUser,
string AccessToken string AccessToken,
string RefreshToken
); );

View File

@@ -1,3 +1,4 @@
using ColaFlow.Modules.Identity.Application.Services;
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants; using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
using ColaFlow.Modules.Identity.Domain.Aggregates.Users; using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
using ColaFlow.Modules.Identity.Domain.Repositories; using ColaFlow.Modules.Identity.Domain.Repositories;
@@ -9,14 +10,22 @@ public class RegisterTenantCommandHandler : IRequestHandler<RegisterTenantComman
{ {
private readonly ITenantRepository _tenantRepository; private readonly ITenantRepository _tenantRepository;
private readonly IUserRepository _userRepository; 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( public RegisterTenantCommandHandler(
ITenantRepository tenantRepository, ITenantRepository tenantRepository,
IUserRepository userRepository) IUserRepository userRepository,
IJwtService jwtService,
IPasswordHasher passwordHasher,
IRefreshTokenService refreshTokenService)
{ {
_tenantRepository = tenantRepository; _tenantRepository = tenantRepository;
_userRepository = userRepository; _userRepository = userRepository;
_jwtService = jwtService;
_passwordHasher = passwordHasher;
_refreshTokenService = refreshTokenService;
} }
public async Task<RegisterTenantResult> Handle( public async Task<RegisterTenantResult> Handle(
@@ -40,20 +49,27 @@ public class RegisterTenantCommandHandler : IRequestHandler<RegisterTenantComman
await _tenantRepository.AddAsync(tenant, cancellationToken); await _tenantRepository.AddAsync(tenant, cancellationToken);
// 3. Create admin user // 3. Create admin user with hashed password
// Note: In production, hash password first using IPasswordHasher var hashedPassword = _passwordHasher.HashPassword(request.AdminPassword);
var adminUser = User.CreateLocal( var adminUser = User.CreateLocal(
TenantId.Create(tenant.Id), TenantId.Create(tenant.Id),
Email.Create(request.AdminEmail), Email.Create(request.AdminEmail),
request.AdminPassword, // TODO: Hash password hashedPassword,
FullName.Create(request.AdminFullName)); FullName.Create(request.AdminFullName));
await _userRepository.AddAsync(adminUser, cancellationToken); await _userRepository.AddAsync(adminUser, cancellationToken);
// 4. Generate JWT token (simplified - TODO: use IJwtService) // 4. Generate JWT token
var accessToken = "dummy-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( return new RegisterTenantResult(
new Dtos.TenantDto new Dtos.TenantDto
{ {
@@ -78,6 +94,7 @@ public class RegisterTenantCommandHandler : IRequestHandler<RegisterTenantComman
IsEmailVerified = adminUser.EmailVerifiedAt.HasValue, IsEmailVerified = adminUser.EmailVerifiedAt.HasValue,
CreatedAt = adminUser.CreatedAt CreatedAt = adminUser.CreatedAt
}, },
accessToken); accessToken,
refreshToken);
} }
} }

View File

@@ -5,4 +5,7 @@ public class LoginResponseDto
public UserDto User { get; set; } = null!; public UserDto User { get; set; } = null!;
public TenantDto Tenant { get; set; } = null!; public TenantDto Tenant { get; set; } = null!;
public string AccessToken { get; set; } = string.Empty; 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";
} }

View File

@@ -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);
}

View File

@@ -0,0 +1,7 @@
namespace ColaFlow.Modules.Identity.Application.Services;
public interface IPasswordHasher
{
string HashPassword(string password);
bool VerifyPassword(string password, string hashedPassword);
}

View File

@@ -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);
}

View File

@@ -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";
}
}

View File

@@ -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);
}

View File

@@ -5,6 +5,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="Microsoft.AspNetCore.Http" Version="2.3.0" /> <PackageReference Include="Microsoft.AspNetCore.Http" Version="2.3.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.10" /> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.10"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.10">
@@ -12,7 +13,9 @@
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.10" /> <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="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.14.0" />
</ItemGroup> </ItemGroup>
<PropertyGroup> <PropertyGroup>

View File

@@ -1,3 +1,4 @@
using ColaFlow.Modules.Identity.Application.Services;
using ColaFlow.Modules.Identity.Domain.Repositories; using ColaFlow.Modules.Identity.Domain.Repositories;
using ColaFlow.Modules.Identity.Infrastructure.Persistence; using ColaFlow.Modules.Identity.Infrastructure.Persistence;
using ColaFlow.Modules.Identity.Infrastructure.Persistence.Repositories; using ColaFlow.Modules.Identity.Infrastructure.Persistence.Repositories;
@@ -27,6 +28,12 @@ public static class DependencyInjection
// Repositories // Repositories
services.AddScoped<ITenantRepository, TenantRepository>(); services.AddScoped<ITenantRepository, TenantRepository>();
services.AddScoped<IUserRepository, UserRepository>(); services.AddScoped<IUserRepository, UserRepository>();
services.AddScoped<IRefreshTokenRepository, RefreshTokenRepository>();
// Application Services
services.AddScoped<IJwtService, JwtService>();
services.AddScoped<IPasswordHasher, PasswordHasher>();
services.AddScoped<IRefreshTokenService, RefreshTokenService>();
return services; return services;
} }

View File

@@ -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");
}
}

View File

@@ -19,6 +19,7 @@ public class IdentityDbContext : DbContext
public DbSet<Tenant> Tenants => Set<Tenant>(); public DbSet<Tenant> Tenants => Set<Tenant>();
public DbSet<User> Users => Set<User>(); public DbSet<User> Users => Set<User>();
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {

View File

@@ -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
}
}
}

View File

@@ -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");
}
}
}

View File

@@ -95,6 +95,81 @@ namespace ColaFlow.Modules.Identity.Infrastructure.Persistence.Migrations
b.ToTable("tenants", (string)null); 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 => modelBuilder.Entity("ColaFlow.Modules.Identity.Domain.Aggregates.Users.User", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")

View File

@@ -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);
}
}

View File

@@ -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");
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View 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
View 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 ""