Changes backend in preparation for frontend work
This commit is contained in:
@@ -44,23 +44,20 @@ De API is daarna bereikbaar op `https://localhost:7221` (of de geconfigureerde p
|
|||||||
|
|
||||||
## Initiële Setup (Bootstrapping)
|
## Initiële Setup (Bootstrapping)
|
||||||
|
|
||||||
Wanneer de applicatie voor de eerste keer wordt gestart met een lege database, moet er een "Owner" (eigenaar) worden aangemaakt. Dit kan via de setup endpoints.
|
...
|
||||||
|
|
||||||
### 1. Status controleren
|
## Authenticatie & Security (Unit 0)
|
||||||
Controleer of het systeem al geïnitialiseerd is:
|
|
||||||
- **Endpoint**: `GET /api/v1/Setup/status`
|
|
||||||
- **Response**: `{ "initialized": true/false }`
|
|
||||||
|
|
||||||
### 2. Eerste Owner aanmaken
|
De API gebruikt een beveiligde flow voor authenticatie:
|
||||||
Dit endpoint is alleen bruikbaar als er nog geen Owner in de database staat.
|
- **Login**: `POST /api/v1/auth/login`. Retourneert een `accessToken` in de body en een `refreshToken` in een beveiligde `httpOnly` cookie.
|
||||||
- **Endpoint**: `POST /api/v1/Setup/owner`
|
- **Refresh**: `POST /api/v1/auth/refresh`. Gebruikt de `refreshToken` cookie om een nieuwe `accessToken` en `refreshToken` (rotatie) te genereren.
|
||||||
- **Payload**:
|
- **Revoke**: `POST /api/v1/auth/revoke`. Trekt het token in en wist de cookie.
|
||||||
```json
|
|
||||||
{
|
### Belangrijke NFR Details:
|
||||||
"email": "admin@example.com",
|
- **CORS**: Alleen toegestane origins uit `appsettings.json → Cors:AllowedOrigins` worden geaccepteerd. De frontend moet draaien op een van deze origins.
|
||||||
"password": "JeWachtwoord123!"
|
- **Cookies**: De `refreshToken` cookie is `httpOnly`, `SameSite=Strict` en heeft het pad `/api/v1/auth`.
|
||||||
}
|
- **Rate Limiting**: Login endpoints hebben rate limiting (Fixed window 5/min, Sliding window 20/min).
|
||||||
```
|
- **Error Handling**: Foutmeldingen volgen de RFC 9457 `ProblemDetails` standaard.
|
||||||
|
|
||||||
## Database Migraties
|
## Database Migraties
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@
|
|||||||
- [x] Units Generation — Completed 2026-06-16T22:20:00Z
|
- [x] Units Generation — Completed 2026-06-16T22:20:00Z
|
||||||
|
|
||||||
### 🟢 CONSTRUCTION PHASE (per unit)
|
### 🟢 CONSTRUCTION PHASE (per unit)
|
||||||
- [~] Unit 0 — Backend Prerequisites (CORS + httpOnly cookie) — IN PROGRESS
|
- [x] Unit 0 — Backend Prerequisites (CORS + httpOnly cookie) — COMPLETED 2026-06-19T11:45:00Z
|
||||||
- [ ] Unit 1 — Project Scaffold & Infrastructure
|
- [ ] Unit 1 — Project Scaffold & Infrastructure
|
||||||
- [ ] Unit 2 — Authentication Pages
|
- [ ] Unit 2 — Authentication Pages
|
||||||
- [ ] Unit 3 — Layout & Navigation
|
- [ ] Unit 3 — Layout & Navigation
|
||||||
|
|||||||
@@ -95,3 +95,95 @@
|
|||||||
**Context**: Reverse Engineering completed; artifacts saved to aidlc-docs/_shared/reverse-engineering/. Awaiting user approval to proceed to Requirements Analysis.
|
**Context**: Reverse Engineering completed; artifacts saved to aidlc-docs/_shared/reverse-engineering/. Awaiting user approval to proceed to Requirements Analysis.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## NFR Design — Fixed Mermaid Diagram
|
||||||
|
|
||||||
|
**Timestamp**: 2026-06-19T11:05:00Z
|
||||||
|
|
||||||
|
**User Input**: "in `nfr-design-patterns.md` onder4. Standardized RFC 9457 Error Handling is mermaid diagram kapot. fix het diagram"
|
||||||
|
|
||||||
|
**AI Response**: "Fixed Mermaid sequence diagram in `nfr-design-patterns.md`. Replaced invalid `style` syntax with inline participant coloring which is supported by Mermaid's sequence diagrams."
|
||||||
|
|
||||||
|
**Context**: User reported a broken diagram. Corrected syntax to ensure readability and compatibility.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## NFR Design — Removed Backward Compatibility
|
||||||
|
|
||||||
|
**Timestamp**: 2026-06-19T11:00:00Z
|
||||||
|
|
||||||
|
**User Input**: "onder 4. ProblemDetails Integration in `logical-components.md` wordt nog rekening gehouden met backwards compatibility. in deze feature hoeft daar geen rekening mee gehouden te worden"
|
||||||
|
|
||||||
|
**AI Response**: "Updated `logical-components.md` to specify that `ApiErrorResponse` will be fully replaced by `ProblemDetails` without backward compatibility."
|
||||||
|
|
||||||
|
**Context**: User explicitly requested to drop backward compatibility for error responses.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## NFR Design — Updated Diagram Styles
|
||||||
|
|
||||||
|
**Timestamp**: 2026-06-19T10:55:00Z
|
||||||
|
|
||||||
|
**User Input**: "in het schema onder Middleware Ordering (Pipeline Design) in `logical-components.md` is de tekst slecht leesbaar. maak de tekst wat donkerder"
|
||||||
|
|
||||||
|
**AI Response**: "Adjusted Mermaid diagram styles in `logical-components.md` and `nfr-design-patterns.md`. Increased contrast by using darker stroke colors, explicit text colors, and thicker borders for better readability."
|
||||||
|
|
||||||
|
**Context**: User requested better readability for diagrams. Styles updated across all NFR Design artifacts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## NFR Design — Stage Completed
|
||||||
|
|
||||||
|
**Timestamp**: 2026-06-19T10:45:00Z
|
||||||
|
|
||||||
|
**User Input**: "aidlc-workflow ik heb de vragen beantwoord in unit-0-nfr-design-plan.md"
|
||||||
|
|
||||||
|
**AI Response**: "NFR Design Plan answers collected and analyzed. Generated `nfr-design-patterns.md` and `logical-components.md` for Unit 0."
|
||||||
|
|
||||||
|
**Context**: NFR Design for Unit 0 is complete. Decisions: CORS via ServiceCollectionExtensions, Global Rate Limiting pipeline (Fixed/Sliding window), Secure Cookie (Strict), GlobalExceptionHandler extended for ProblemDetails.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code Generation — Unit 0 Completed
|
||||||
|
|
||||||
|
**Timestamp**: 2026-06-19T11:45:00Z
|
||||||
|
|
||||||
|
**User Input**: "(Automatic completion of unit steps)"
|
||||||
|
|
||||||
|
**AI Response**: "Successfully implemented Unit 0: Backend Prerequisites.
|
||||||
|
- Modified: AuthController, AuthService, IAuthService, ServiceCollectionExtensions, Program.cs, ApplicationUser, GlobalExceptionHandler, AvailabilityMiddleware, appsettings.json, appsettings.Development.json, README.md, GlobalExceptionHandlerTests.
|
||||||
|
- Deleted: ApiErrorResponse.cs, RefreshTokenRequest (model).
|
||||||
|
- Verified: Build succeeded, all 16 tests passed.
|
||||||
|
- Deliverables: Secure cookie auth, CORS, Rate Limiting, ProblemDetails."
|
||||||
|
|
||||||
|
**Context**: Unit 0 is finished and verified. Ready for Unit 1.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code Generation — Plan Approved
|
||||||
|
|
||||||
|
**Timestamp**: 2026-06-19T11:20:00Z
|
||||||
|
|
||||||
|
**User Input**: "approve plan"
|
||||||
|
|
||||||
|
**AI Response**: "Plan for Unit 0 Code Generation approved. Transitioning to Part 2: Generation."
|
||||||
|
|
||||||
|
**Context**: User approved `unit-0-code-generation-plan.md`. Starting implementation of backend prerequisites.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code Generation Planning — Plan Updated
|
||||||
|
|
||||||
|
**Timestamp**: 2026-06-19T11:15:00Z
|
||||||
|
|
||||||
|
**User Input**: "step 7 has a note about functional design Q4. That can be removed as I agree to the way we are going to implement it now"
|
||||||
|
|
||||||
|
**AI Response**: "Updated `unit-0-code-generation-plan.md` Step 7 to remove the note about Q4 as the user agreed to the implementation approach (no Authorization on Revoke endpoint)."
|
||||||
|
|
||||||
|
**Context**: User requested removal of a plan note; plan updated accordingly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
# Code Generation Plan — Unit 0: Backend Prerequisites
|
||||||
|
|
||||||
|
This plan defines all concrete steps to implement Unit 0 (backend prerequisites) safely in the brownfield .NET solution. Follow the numbered steps in order and check them off during execution.
|
||||||
|
|
||||||
|
## Unit Context
|
||||||
|
- Unit: Unit 0 — Backend Prerequisites
|
||||||
|
- Type: .NET backend changes (API + Core + Module Identity)
|
||||||
|
- Stories (traceability):
|
||||||
|
- US-02 (partial) — Auth: secure refresh-token via httpOnly cookie
|
||||||
|
- US-06 (partial) — Session lifecycle: refresh & revoke
|
||||||
|
- US-07 (partial) — Error handling & API consistency
|
||||||
|
- Dependencies: None (prerequisite unit for all frontend integration)
|
||||||
|
- Related NFRs: NFR-U0-01..07 (security cookie, CORS, rate limiting, reliability, ProblemDetails, maintainability, tests)
|
||||||
|
|
||||||
|
## Code Locations (brownfield)
|
||||||
|
- Application Code:
|
||||||
|
- src/SlpModularCms.Api/Program.cs
|
||||||
|
- src/SlpModularCms.Api/Extensions/ServiceCollectionExtensions.cs
|
||||||
|
- src/SlpModularCms.Modules.Identity/Controllers/AuthController.cs
|
||||||
|
- src/SlpModularCms.Core/Exceptions/GlobalExceptionHandler.cs
|
||||||
|
- src/SlpModularCms.Core/Exceptions/ApiErrorResponse.cs (to be removed/replaced)
|
||||||
|
- src/SlpModularCms.Core/Identity/Models/TokenResponse.cs
|
||||||
|
- src/SlpModularCms.Core/Identity/Models/IdentityRequests.cs (contains RefreshTokenRequest)
|
||||||
|
- src/SlpModularCms.Core/Identity/Services/IAuthService.cs
|
||||||
|
- src/SlpModularCms.Core/Identity/Services/AuthService.cs
|
||||||
|
- Configuration:
|
||||||
|
- src/SlpModularCms.Api/appsettings.json
|
||||||
|
- src/SlpModularCms.Api/appsettings.Development.json
|
||||||
|
- Tests:
|
||||||
|
- src/SlpModularCms.Core.Tests/Identity/* (extend where feasible)
|
||||||
|
- NEW: src/SlpModularCms.Modules.Identity.Tests/ (controller tests) [optional, recommended]
|
||||||
|
|
||||||
|
## PART 1 — Planning Checklist
|
||||||
|
- [x] Read all Unit 0 design artifacts (business-rules.md, nfr-requirements.md, nfr-design/*)
|
||||||
|
- [x] Validate repository state and file presence against paths above
|
||||||
|
- [x] Confirm appsettings three-file pattern (dotnet-appsettings skill) and add missing sections
|
||||||
|
|
||||||
|
## PART 2 — Generation Steps (execute in order)
|
||||||
|
|
||||||
|
### Step 1 — API Routing & CORS
|
||||||
|
- [x] Update route on AuthController to `[Route("api/v1/auth")]` (currently `[Route("[controller]")]`)
|
||||||
|
- [x] Add `AddCmsCors()` in ServiceCollectionExtensions to read `Cors:AllowedOrigins` and configure explicit allowlist + `AllowCredentials()`
|
||||||
|
- [x] Register CORS BEFORE `UseAuthentication`/`UseAuthorization` in Program.cs pipeline
|
||||||
|
- [x] Add `Cors` section to appsettings.json and appsettings.Development.json per logical-components.md
|
||||||
|
|
||||||
|
### Step 2 — Rate Limiting (Global Middleware)
|
||||||
|
- [x] Add `AddCmsRateLimiting()` to register Fixed window (login: 5/min) and Sliding window (refresh: 20/min)
|
||||||
|
- [x] Register `UseRateLimiter()` in Program.cs before CORS (per design ordering)
|
||||||
|
- [x] Ensure 429 responses include standard headers (Retry-After) where applicable
|
||||||
|
|
||||||
|
### Step 3 — Refresh Token in httpOnly Cookie
|
||||||
|
- [x] Modify `AuthController.Login` to set `refreshToken` cookie (`HttpOnly`, `Secure=request.IsHttps` in prod, `SameSite=Strict`, Path=/api/v1/auth)
|
||||||
|
- [x] Modify `AuthController.Refresh` to read refresh token from `Request.Cookies["refreshToken"]` (remove body model)
|
||||||
|
- [x] Modify `AuthController.Revoke` to clear the cookie using same Path and expire to Epoch
|
||||||
|
|
||||||
|
### Step 4 — Remove Request Body Model for Refresh
|
||||||
|
- [x] Delete `RefreshTokenRequest` from `IdentityRequests.cs`
|
||||||
|
- [x] Update method signatures & usages accordingly
|
||||||
|
|
||||||
|
### Step 5 — Service Contract Adjustments (AuthService/IAuthService)
|
||||||
|
- [x] Change refresh flow contract to accept ONLY the refresh token (no access token in body)
|
||||||
|
- [x] Implement repository lookup by refresh token and user, with rotation semantics intact
|
||||||
|
- [x] Ensure reuse-detection logic (mark used, revoke all on reuse) remains functional
|
||||||
|
|
||||||
|
### Step 6 — Response Model & Payload Alignment
|
||||||
|
- [x] Update `TokenResponse` to remove `RefreshToken` from the response body
|
||||||
|
- [x] Extend response body to include: `accessToken`, `expiresAt`, and `user` object: `{ id, email, name, role, isActive }`
|
||||||
|
- [x] Compute `name` as `DisplayName ?? Email` (see BR-U0-07)
|
||||||
|
- [x] Ensure controllers only return allowed fields; NEVER return the refresh token
|
||||||
|
|
||||||
|
### Step 7 — Revoke Endpoint Authorization Policy
|
||||||
|
- [x] Implement per BR-U0-05: remove `[Authorize]` from `POST /api/v1/auth/revoke` to allow idempotent logout even if access token expired
|
||||||
|
|
||||||
|
### Step 8 — ProblemDetails Migration (RFC 9457)
|
||||||
|
- [x] Replace custom `ApiErrorResponse` with standardized `ProblemDetails` in `GlobalExceptionHandler`
|
||||||
|
- [x] Map known exceptions to appropriate status codes (Unauthorized, 429, etc.)
|
||||||
|
- [x] Remove/retire `ApiErrorResponse` usages
|
||||||
|
|
||||||
|
### Step 9 — Configuration (dotnet-appsettings)
|
||||||
|
- [x] Add/verify `Cors` and `RateLimiting` sections in `appsettings.json` and `appsettings.Development.json`
|
||||||
|
- [x] Do NOT store secrets; follow three-file pattern; add README notes for `appsettings.local.json` overrides
|
||||||
|
|
||||||
|
### Step 10 — Tests
|
||||||
|
- [x] Update/extend unit tests to cover:
|
||||||
|
- Cookie set on successful login/refresh
|
||||||
|
- Cookie cleared on revoke
|
||||||
|
- 401 when cookie missing on refresh
|
||||||
|
- Rate limit behavior (mock/stub)
|
||||||
|
- [x] Optionally add `SlpModularCms.Modules.Identity.Tests` for controller-level tests; otherwise, add minimal WebApplicationFactory-based tests in an existing test project
|
||||||
|
|
||||||
|
### Step 11 — Documentation & README
|
||||||
|
- [x] Update backend README with local dev instructions (CORS origin, HTTPS dev certs, cookie behavior)
|
||||||
|
- [x] Update cross-feature docs if affected (e.g., slp-modular-cms-api endpoints, auth flow)
|
||||||
|
|
||||||
|
### Step 12 — Build & Smoke Verification
|
||||||
|
- [x] Build solution
|
||||||
|
- [x] Manual smoke test of auth endpoints: login → cookie present; refresh → cookie rotates; revoke → cookie cleared; CORS preflight succeeds
|
||||||
|
|
||||||
|
## Story Traceability Matrix
|
||||||
|
- US-02 (partial): Steps 3–6
|
||||||
|
- US-06 (partial): Steps 3–7, 12
|
||||||
|
- US-07 (partial): Steps 8–12
|
||||||
|
|
||||||
|
## Definition of Done (Unit 0)
|
||||||
|
- [ ] No refresh token in any response body
|
||||||
|
- [ ] Refresh token exclusively in httpOnly cookie
|
||||||
|
- [ ] CORS allowlist enforced and credentials enabled
|
||||||
|
- [ ] Global rate limiting active with configured windows
|
||||||
|
- [ ] ProblemDetails standardized for all auth-related errors
|
||||||
|
- [ ] Tests updated/added per NFR-U0-07
|
||||||
|
- [ ] README updated with local dev guidance
|
||||||
@@ -2,7 +2,13 @@
|
|||||||
|
|
||||||
Welkom bij de NFR Design fase voor Unit 0. In deze fase vertalen we de NFR-eisen naar een concreet technisch ontwerp.
|
Welkom bij de NFR Design fase voor Unit 0. In deze fase vertalen we de NFR-eisen naar een concreet technisch ontwerp.
|
||||||
|
|
||||||
Beantwoord de onderstaande vragen om het plan te valideren:
|
- [x] Analyseer antwoorden in `unit-0-nfr-design-plan.md`
|
||||||
|
- [x] Genereer NFR Design artefacten voor Unit 0
|
||||||
|
- [x] `nfr-design-patterns.md`
|
||||||
|
- [x] `logical-components.md`
|
||||||
|
- [x] Valideer artefacten tegen `common/content-validation.md` en `common/mermaid-diagram-standards.md`
|
||||||
|
- [x] Update `audit.md` en `aidlc-state.md`
|
||||||
|
- [x] Presenteer voltooiingsbericht en vraag om goedkeuring voor de volgende stap (Code Generation)
|
||||||
|
|
||||||
## Vraag 1: CORS Implementation Details
|
## Vraag 1: CORS Implementation Details
|
||||||
A) Custom `CorsPolicy`
|
A) Custom `CorsPolicy`
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# Functional Design Plan — Unit 1: Project Scaffold & Infrastructure
|
||||||
|
|
||||||
|
## Unit Context
|
||||||
|
- Unit: Unit 1 — Project Scaffold & Infrastructure
|
||||||
|
- Type: Frontend (Vite + React + TypeScript)
|
||||||
|
- Depends on: Unit 0 (CORS + cookie-based auth ready)
|
||||||
|
- Goals:
|
||||||
|
- Initialize frontend workspace with TanStack Router, shadcn/ui, Tailwind v4
|
||||||
|
- Set primary color to `#ac0000`
|
||||||
|
- Provide `ApiClient` and `AuthContext` foundations aligned with cookie-based auth (httpOnly refresh cookie, access token in memory)
|
||||||
|
|
||||||
|
## Plan Checklist
|
||||||
|
- [ ] Confirm scaffold strategy and tools via questions below
|
||||||
|
- [ ] Generate folder structure (`src/api`, `src/auth`, `src/components`, `src/routes`)
|
||||||
|
- [ ] Install and configure dependencies (TanStack Router, @tanstack/react-query, Tailwind v4, shadcn/ui)
|
||||||
|
- [ ] Configure Tailwind theme with primary color `#ac0000`
|
||||||
|
- [ ] Initialize TanStack Router with root route and placeholders
|
||||||
|
- [ ] Create `AuthContext` (in-memory access token + user) and `useAuth` hook
|
||||||
|
- [ ] Create `ApiClient` (fetch wrapper) with `credentials: 'include'` and 401 handling
|
||||||
|
- [ ] Add `.env.example` with `VITE_API_BASE_URL`
|
||||||
|
- [ ] README: local dev instructions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Please answer the following questions by filling in the letter after each `[Answer]:` tag.
|
||||||
|
|
||||||
|
## Question 1: Package manager
|
||||||
|
A) pnpm (recommended)
|
||||||
|
B) npm
|
||||||
|
C) yarn
|
||||||
|
X) Other (please describe after the [Answer]: tag below)
|
||||||
|
|
||||||
|
[Answer]:
|
||||||
|
|
||||||
|
## Question 2: Router choice
|
||||||
|
A) TanStack Router (required by NFR-01)
|
||||||
|
B) React Router
|
||||||
|
X) Other (please describe after the [Answer]: tag below)
|
||||||
|
|
||||||
|
[Answer]:
|
||||||
|
|
||||||
|
## Question 3: UI foundation
|
||||||
|
A) Tailwind v4 + shadcn/ui (recommended)
|
||||||
|
B) Tailwind v4 only (no shadcn/ui)
|
||||||
|
X) Other (please describe after the [Answer]: tag below)
|
||||||
|
|
||||||
|
[Answer]:
|
||||||
|
|
||||||
|
## Question 4: Primary color integration (`#ac0000`)
|
||||||
|
A) Tailwind theme config (extend colors + use via utility classes)
|
||||||
|
B) CSS variables (root-level var, consumed by components)
|
||||||
|
X) Other (please describe after the [Answer]: tag below)
|
||||||
|
|
||||||
|
[Answer]:
|
||||||
|
|
||||||
|
## Question 5: HTTP client layer
|
||||||
|
A) Fetch wrapper (`ApiClient`) with `credentials: 'include'` + JSON helpers (recommended)
|
||||||
|
B) Axios instance with `withCredentials: true`
|
||||||
|
X) Other (please describe after the [Answer]: tag below)
|
||||||
|
|
||||||
|
[Answer]:
|
||||||
|
|
||||||
|
## Question 6: API base URL source
|
||||||
|
A) `import.meta.env.VITE_API_BASE_URL` (from `.env`)
|
||||||
|
B) Derive from `window.location.origin` + `/api`
|
||||||
|
X) Other (please describe after the [Answer]: tag below)
|
||||||
|
|
||||||
|
[Answer]:
|
||||||
|
|
||||||
|
## Question 7: Session bootstrap behavior
|
||||||
|
A) On app mount, attempt silent refresh (uses httpOnly cookie) to hydrate `AuthContext` (recommended)
|
||||||
|
B) Do not auto-refresh on mount; rely on login flow and 401 handling
|
||||||
|
X) Other (please describe after the [Answer]: tag below)
|
||||||
|
|
||||||
|
[Answer]:
|
||||||
|
|
||||||
|
## Question 8: Linting/formatting baseline
|
||||||
|
A) ESLint (recommended) + Prettier with sensible defaults
|
||||||
|
B) ESLint only
|
||||||
|
C) None for now
|
||||||
|
X) Other (please describe after the [Answer]: tag below)
|
||||||
|
|
||||||
|
[Answer]:
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# Logical Components — Unit 0: Backend Prerequisites
|
||||||
|
|
||||||
|
The following logical components and configuration structures are defined to support the NFR implementation.
|
||||||
|
|
||||||
|
## 1. Middleware Ordering (Pipeline Design)
|
||||||
|
The order of middleware registration is critical for security and performance. The pipeline is configured in `Program.cs`.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
Start((Start)) --> Exc[GlobalExceptionHandler]
|
||||||
|
Exc --> RL[Rate Limiter]
|
||||||
|
RL --> CORS[CORS Middleware]
|
||||||
|
CORS --> Auth[Authentication]
|
||||||
|
Auth --> Map[Endpoint Mapping]
|
||||||
|
Map --> End((End))
|
||||||
|
|
||||||
|
classDef infra fill:#cbd5e0,stroke:#1a202c,stroke-width:2px,color:#1a202c;
|
||||||
|
classDef security fill:#feb2b2,stroke:#742a2a,stroke-width:2px,color:#742a2a;
|
||||||
|
classDef core fill:#9ae6b4,stroke:#22543d,stroke-width:2px,color:#22543d;
|
||||||
|
class Start,End,Map infra;
|
||||||
|
class Exc,RL,CORS,Auth security;
|
||||||
|
```
|
||||||
|
|
||||||
|
Text alternative: Sequence of middleware in the ASP.NET Core pipeline (colored nodes indicate role).
|
||||||
|
|
||||||
|
## 2. Configuration Structure (appsettings.json)
|
||||||
|
Following the `dotnet-appsettings` skill pattern, the configuration for NFRs is centralized.
|
||||||
|
|
||||||
|
### CORS Configuration
|
||||||
|
```json
|
||||||
|
"Cors": {
|
||||||
|
"AllowedOrigins": [
|
||||||
|
"https://localhost:5173"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rate Limiting Configuration
|
||||||
|
```json
|
||||||
|
"RateLimiting": {
|
||||||
|
"Login": {
|
||||||
|
"PermitLimit": 5,
|
||||||
|
"WindowSeconds": 60
|
||||||
|
},
|
||||||
|
"Refresh": {
|
||||||
|
"PermitLimit": 20,
|
||||||
|
"WindowSeconds": 60
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Extension Methods
|
||||||
|
To keep `Program.cs` clean, logical components are encapsulated in `ServiceCollectionExtensions.cs`.
|
||||||
|
|
||||||
|
| Component | Responsibility |
|
||||||
|
|-----------|----------------|
|
||||||
|
| `AddCmsCors()` | Configures the CORS policy based on settings. |
|
||||||
|
| `AddCmsRateLimiting()` | Defines the Fixed and Sliding window policies for auth. |
|
||||||
|
| `AddCmsExceptionHandling()` | Registers the `GlobalExceptionHandler` and ProblemDetails. |
|
||||||
|
|
||||||
|
## 4. ProblemDetails Integration
|
||||||
|
The `GlobalExceptionHandler` will be modified to fully replace the custom `ApiErrorResponse` with standard `ProblemDetails` as the return type. No backward compatibility for the old error format is required.
|
||||||
+71
@@ -0,0 +1,71 @@
|
|||||||
|
# NFR Design Patterns — Unit 0: Backend Prerequisites
|
||||||
|
|
||||||
|
Non-functional requirements are addressed using the following design patterns and implementation strategies.
|
||||||
|
|
||||||
|
## Resilience & Protection Patterns
|
||||||
|
|
||||||
|
### 1. Global Rate Limiting Pipeline
|
||||||
|
To protect the authentication endpoints from brute-force and DoS attacks, a global rate limiting pipeline is implemented using the built-in ASP.NET Core `Microsoft.AspNetCore.RateLimiting` middleware.
|
||||||
|
|
||||||
|
- **Fixed Window Pattern (Login)**:
|
||||||
|
- Scoped to `POST /api/v1/auth/login`.
|
||||||
|
- Limit: 5 requests per 1 minute.
|
||||||
|
- Partitioned by Client IP Address.
|
||||||
|
- **Sliding Window Pattern (Refresh)**:
|
||||||
|
- Scoped to `POST /api/v1/auth/refresh`.
|
||||||
|
- Limit: 20 requests per 1 minute.
|
||||||
|
- Partitioned by Client IP Address.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
Client[Client Request] --> RL[Rate Limiter Middleware]
|
||||||
|
RL -- Limit Exceeded --> 429[429 Too Many Requests]
|
||||||
|
RL -- Within Limit --> Pipeline[Authentication Pipeline]
|
||||||
|
|
||||||
|
classDef client fill:#fef3c7,stroke:#92400e,stroke-width:2px,color:#92400e;
|
||||||
|
classDef security fill:#feb2b2,stroke:#742a2a,stroke-width:2px,color:#742a2a;
|
||||||
|
classDef success fill:#c6f6d5,stroke:#22543d,stroke-width:2px,color:#22543d;
|
||||||
|
class Client client;
|
||||||
|
class RL,429 security;
|
||||||
|
class Pipeline success;
|
||||||
|
```
|
||||||
|
|
||||||
|
Text alternative: Flow showing Rate Limiter Middleware intercepting requests and either returning 429 or passing to the pipeline (colored nodes indicate role).
|
||||||
|
|
||||||
|
## Security Patterns
|
||||||
|
|
||||||
|
### 2. Cross-Origin Resource Sharing (CORS) Policy
|
||||||
|
CORS is configured via a strongly-typed `ServiceCollectionExtensions` method to ensure consistent application of security rules.
|
||||||
|
|
||||||
|
- **Pattern**: Explicit Origin Allowlist.
|
||||||
|
- **Logic**: Origin is validated against `Cors:AllowedOrigins` in `appsettings.json`.
|
||||||
|
- **Requirement**: `AllowCredentials()` is mandatory to support the `httpOnly` Refresh Token cookie cross-origin.
|
||||||
|
|
||||||
|
### 3. Secure Cookie Pattern (Refresh Token)
|
||||||
|
The Refresh Token is stored using a multi-layered security approach:
|
||||||
|
- **HttpOnly**: Prevents access via JavaScript (XSS protection).
|
||||||
|
- **Secure**: Ensures the cookie is only transmitted over HTTPS (In production).
|
||||||
|
- **SameSite=Strict**: Prevents the browser from sending the cookie with cross-site requests (CSRF protection).
|
||||||
|
- **Scoped Path**: Path is restricted to `/api/v1/auth` to minimize exposure.
|
||||||
|
|
||||||
|
## Performance & Reliability Patterns
|
||||||
|
|
||||||
|
### 4. Standardized RFC 9457 Error Handling
|
||||||
|
Existing `GlobalExceptionHandler` is extended to return `ProblemDetails` conform RFC 9457. This ensures client-side libraries can predictably parse error responses.
|
||||||
|
|
||||||
|
- **Mapping**: Specific exceptions (e.g. `UnauthorizedAccessException`, `RateLimitRejectedException`) are mapped to their corresponding HTTP status codes.
|
||||||
|
- **Safety**: Stack traces are only included in `Development` environment.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant C as Client #fef3c7
|
||||||
|
participant M as Middleware/Controller #c6f6d5
|
||||||
|
participant H as GlobalExceptionHandler #feb2b2
|
||||||
|
|
||||||
|
C->>M: Request
|
||||||
|
M-->>M: Error Occurs
|
||||||
|
M->>H: Catch Exception
|
||||||
|
H->>C: ProblemDetails (JSON)
|
||||||
|
```
|
||||||
|
|
||||||
|
Text alternative: Sequence diagram showing how exceptions are caught by the GlobalExceptionHandler and returned as ProblemDetails to the client (colored participants indicate role).
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
using SlpModularCms.Core.Availability;
|
using SlpModularCms.Core.Availability;
|
||||||
@@ -12,6 +13,7 @@ using SlpModularCms.Core.Identity.Models;
|
|||||||
using SlpModularCms.Core.Identity.Services;
|
using SlpModularCms.Core.Identity.Services;
|
||||||
using SlpModularCms.Api.Infrastructure;
|
using SlpModularCms.Api.Infrastructure;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using System.Threading.RateLimiting;
|
||||||
|
|
||||||
namespace SlpModularCms.Api.Extensions;
|
namespace SlpModularCms.Api.Extensions;
|
||||||
|
|
||||||
@@ -93,4 +95,49 @@ public static class ServiceCollectionExtensions
|
|||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static IServiceCollection AddCmsCors(this IServiceCollection services, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
var allowedOrigins = configuration.GetSection("Cors:AllowedOrigins").Get<string[]>() ?? Array.Empty<string>();
|
||||||
|
|
||||||
|
services.AddCors(options =>
|
||||||
|
{
|
||||||
|
options.AddDefaultPolicy(builder =>
|
||||||
|
{
|
||||||
|
builder.WithOrigins(allowedOrigins)
|
||||||
|
.AllowAnyHeader()
|
||||||
|
.AllowAnyMethod()
|
||||||
|
.AllowCredentials();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IServiceCollection AddCmsRateLimiting(this IServiceCollection services, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
services.AddRateLimiter(options =>
|
||||||
|
{
|
||||||
|
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||||
|
|
||||||
|
options.AddFixedWindowLimiter("login", opt =>
|
||||||
|
{
|
||||||
|
var settings = configuration.GetSection("RateLimiting:Login");
|
||||||
|
opt.PermitLimit = settings.GetValue<int>("PermitLimit", 5);
|
||||||
|
opt.Window = TimeSpan.FromSeconds(settings.GetValue<int>("WindowSeconds", 60));
|
||||||
|
opt.QueueLimit = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
options.AddSlidingWindowLimiter("refresh", opt =>
|
||||||
|
{
|
||||||
|
var settings = configuration.GetSection("RateLimiting:Refresh");
|
||||||
|
opt.PermitLimit = settings.GetValue<int>("PermitLimit", 20);
|
||||||
|
opt.Window = TimeSpan.FromSeconds(settings.GetValue<int>("WindowSeconds", 60));
|
||||||
|
opt.SegmentsPerWindow = 4;
|
||||||
|
opt.QueueLimit = 0;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ orchestrator.DiscoverModules();
|
|||||||
|
|
||||||
// 2. Add Core Infrastructure
|
// 2. Add Core Infrastructure
|
||||||
builder.Services.AddCoreInfrastructure(builder.Configuration);
|
builder.Services.AddCoreInfrastructure(builder.Configuration);
|
||||||
|
builder.Services.AddCmsCors(builder.Configuration);
|
||||||
|
builder.Services.AddCmsRateLimiting(builder.Configuration);
|
||||||
|
|
||||||
// 3. Add Module Services
|
// 3. Add Module Services
|
||||||
orchestrator.RegisterModuleServices(builder.Services);
|
orchestrator.RegisterModuleServices(builder.Services);
|
||||||
@@ -29,6 +31,8 @@ var app = builder.Build();
|
|||||||
// 5. Global Exception Handling
|
// 5. Global Exception Handling
|
||||||
app.UseExceptionHandler();
|
app.UseExceptionHandler();
|
||||||
|
|
||||||
|
app.UseRateLimiter();
|
||||||
|
|
||||||
// 6. Configure Pipeline
|
// 6. Configure Pipeline
|
||||||
if (app.Environment.IsDevelopment())
|
if (app.Environment.IsDevelopment())
|
||||||
{
|
{
|
||||||
@@ -38,6 +42,8 @@ if (app.Environment.IsDevelopment())
|
|||||||
|
|
||||||
app.UseHttpsRedirection();
|
app.UseHttpsRedirection();
|
||||||
|
|
||||||
|
app.UseCors();
|
||||||
|
|
||||||
// 7. Use Module Middleware
|
// 7. Use Module Middleware
|
||||||
orchestrator.UseModules(app);
|
orchestrator.UseModules(app);
|
||||||
|
|
||||||
|
|||||||
@@ -18,5 +18,21 @@
|
|||||||
"Availability": {
|
"Availability": {
|
||||||
"CircuitBreakerSeconds": 30,
|
"CircuitBreakerSeconds": 30,
|
||||||
"StatusCacheSeconds": 1
|
"StatusCacheSeconds": 1
|
||||||
|
},
|
||||||
|
"Cors": {
|
||||||
|
"AllowedOrigins": [
|
||||||
|
"http://localhost:5173",
|
||||||
|
"https://localhost:5173"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"RateLimiting": {
|
||||||
|
"Login": {
|
||||||
|
"PermitLimit": 100,
|
||||||
|
"WindowSeconds": 60
|
||||||
|
},
|
||||||
|
"Refresh": {
|
||||||
|
"PermitLimit": 500,
|
||||||
|
"WindowSeconds": 60
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,5 +19,18 @@
|
|||||||
"Availability": {
|
"Availability": {
|
||||||
"CircuitBreakerSeconds": 30,
|
"CircuitBreakerSeconds": 30,
|
||||||
"StatusCacheSeconds": 1
|
"StatusCacheSeconds": 1
|
||||||
|
},
|
||||||
|
"Cors": {
|
||||||
|
"AllowedOrigins": []
|
||||||
|
},
|
||||||
|
"RateLimiting": {
|
||||||
|
"Login": {
|
||||||
|
"PermitLimit": 5,
|
||||||
|
"WindowSeconds": 60
|
||||||
|
},
|
||||||
|
"Refresh": {
|
||||||
|
"PermitLimit": 20,
|
||||||
|
"WindowSeconds": 60
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
@@ -43,12 +44,12 @@ public class GlobalExceptionHandlerTests
|
|||||||
context.Response.Body.Seek(0, SeekOrigin.Begin);
|
context.Response.Body.Seek(0, SeekOrigin.Begin);
|
||||||
using var reader = new StreamReader(context.Response.Body);
|
using var reader = new StreamReader(context.Response.Body);
|
||||||
var body = await reader.ReadToEndAsync();
|
var body = await reader.ReadToEndAsync();
|
||||||
var response = JsonSerializer.Deserialize<ApiErrorResponse>(body, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
var response = JsonSerializer.Deserialize<ProblemDetails>(body, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||||
|
|
||||||
response.Should().NotBeNull();
|
response.Should().NotBeNull();
|
||||||
response!.Message.Should().Be("Er is een interne serverfout opgetreden.");
|
response!.Title.Should().Be("Internal Server Error");
|
||||||
response.Detail.Should().BeNull();
|
response.Detail.Should().Be("Er is een interne serverfout opgetreden.");
|
||||||
response.TraceId.Should().NotBeNullOrEmpty();
|
response.Extensions.Should().ContainKey("traceId");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -69,7 +70,7 @@ public class GlobalExceptionHandlerTests
|
|||||||
context.Response.Body.Seek(0, SeekOrigin.Begin);
|
context.Response.Body.Seek(0, SeekOrigin.Begin);
|
||||||
using var reader = new StreamReader(context.Response.Body);
|
using var reader = new StreamReader(context.Response.Body);
|
||||||
var body = await reader.ReadToEndAsync();
|
var body = await reader.ReadToEndAsync();
|
||||||
var response = JsonSerializer.Deserialize<ApiErrorResponse>(body, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
var response = JsonSerializer.Deserialize<ProblemDetails>(body, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||||
|
|
||||||
response!.Detail.Should().Contain("Test error");
|
response!.Detail.Should().Contain("Test error");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
namespace SlpModularCms.Core.Exceptions;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gestandaardiseerd error response object voor de API.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="Message">De (veilige) foutmelding.</param>
|
|
||||||
/// <param name="Detail">Extra details, zoals een stacktrace (alleen in Development).</param>
|
|
||||||
/// <param name="TraceId">Uniek correlatie ID voor log-analyse.</param>
|
|
||||||
public record ApiErrorResponse(
|
|
||||||
string Message,
|
|
||||||
string? Detail = null,
|
|
||||||
string? TraceId = null
|
|
||||||
);
|
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using Microsoft.AspNetCore.Diagnostics;
|
using Microsoft.AspNetCore.Diagnostics;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace SlpModularCms.Core.Exceptions;
|
namespace SlpModularCms.Core.Exceptions;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Globale exception handler conform .NET 8+ IExceptionHandler.
|
/// Globale exception handler conform .NET 8+ IExceptionHandler en RFC 9457 (ProblemDetails).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class GlobalExceptionHandler : IExceptionHandler
|
public class GlobalExceptionHandler : IExceptionHandler
|
||||||
{
|
{
|
||||||
@@ -33,28 +34,31 @@ public class GlobalExceptionHandler : IExceptionHandler
|
|||||||
Environment.MachineName,
|
Environment.MachineName,
|
||||||
traceId);
|
traceId);
|
||||||
|
|
||||||
var (statusCode, message) = MapException(exception);
|
var (statusCode, title) = MapException(exception);
|
||||||
|
|
||||||
|
var problemDetails = new ProblemDetails
|
||||||
|
{
|
||||||
|
Status = statusCode,
|
||||||
|
Title = title,
|
||||||
|
Detail = _env.IsDevelopment() ? exception.ToString() : "Er is een interne serverfout opgetreden.",
|
||||||
|
Instance = httpContext.Request.Path
|
||||||
|
};
|
||||||
|
|
||||||
|
problemDetails.Extensions["traceId"] = traceId;
|
||||||
|
|
||||||
httpContext.Response.StatusCode = statusCode;
|
httpContext.Response.StatusCode = statusCode;
|
||||||
|
|
||||||
var response = new ApiErrorResponse(
|
await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken);
|
||||||
Message: message,
|
|
||||||
Detail: _env.IsDevelopment() ? exception.ToString() : null,
|
|
||||||
TraceId: traceId
|
|
||||||
);
|
|
||||||
|
|
||||||
await httpContext.Response.WriteAsJsonAsync(response, cancellationToken);
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static (int StatusCode, string Message) MapException(Exception exception)
|
private static (int StatusCode, string Title) MapException(Exception exception)
|
||||||
{
|
{
|
||||||
return exception switch
|
return exception switch
|
||||||
{
|
{
|
||||||
// Hier kunnen specifieke uitzonderingen worden toegevoegd
|
UnauthorizedException => (StatusCodes.Status401Unauthorized, "Unauthorized"),
|
||||||
// Bijv: ValidationException => (StatusCodes.Status400BadRequest, exception.Message),
|
_ => (StatusCodes.Status500InternalServerError, "Internal Server Error")
|
||||||
_ => (StatusCodes.Status500InternalServerError, "Er is een interne serverfout opgetreden.")
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ public class ApplicationUser : IdentityUser<Guid>
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// De naam van de gebruiker die wordt weergegeven in de UI.
|
||||||
|
/// </summary>
|
||||||
|
public string? DisplayName { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Navigatie-eigenschap naar module-specifieke rechten.
|
/// Navigatie-eigenschap naar module-specifieke rechten.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -2,6 +2,5 @@ namespace SlpModularCms.Core.Identity.Models;
|
|||||||
|
|
||||||
public record CreateOwnerRequest(string Email, string Password);
|
public record CreateOwnerRequest(string Email, string Password);
|
||||||
public record LoginRequest(string Email, string Password);
|
public record LoginRequest(string Email, string Password);
|
||||||
public record RefreshTokenRequest(string AccessToken, string RefreshToken);
|
|
||||||
public record InviteUserRequest(string Email, string Role);
|
public record InviteUserRequest(string Email, string Role);
|
||||||
public record CompleteSetupRequest(string Token, string Password);
|
public record CompleteSetupRequest(string Token, string Password);
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
namespace SlpModularCms.Core.Identity.Models;
|
namespace SlpModularCms.Core.Identity.Models;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -5,6 +7,18 @@ namespace SlpModularCms.Core.Identity.Models;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public record TokenResponse(
|
public record TokenResponse(
|
||||||
string AccessToken,
|
string AccessToken,
|
||||||
string RefreshToken,
|
[property: JsonIgnore] string RefreshToken,
|
||||||
DateTimeOffset Expiry
|
DateTimeOffset ExpiresAt,
|
||||||
|
UserResponse User
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Model voor gebruikersinformatie in de auth response.
|
||||||
|
/// </summary>
|
||||||
|
public record UserResponse(
|
||||||
|
Guid Id,
|
||||||
|
string Email,
|
||||||
|
string Name,
|
||||||
|
string Role,
|
||||||
|
bool IsActive
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -40,14 +40,11 @@ public class AuthService : IAuthService
|
|||||||
return await GenerateTokenResponseAsync(user);
|
return await GenerateTokenResponseAsync(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<TokenResponse> RefreshTokenAsync(string accessToken, string refreshToken)
|
public async Task<TokenResponse> RefreshTokenAsync(string refreshToken)
|
||||||
{
|
{
|
||||||
var principal = GetPrincipalFromExpiredToken(accessToken);
|
|
||||||
var userId = Guid.Parse(principal.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
|
||||||
|
|
||||||
var savedRefreshToken = await _context.RefreshTokens
|
var savedRefreshToken = await _context.RefreshTokens
|
||||||
.Include(t => t.User)
|
.Include(t => t.User)
|
||||||
.FirstOrDefaultAsync(t => t.Token == refreshToken && t.UserId == userId);
|
.FirstOrDefaultAsync(t => t.Token == refreshToken);
|
||||||
|
|
||||||
if (savedRefreshToken == null || !savedRefreshToken.IsActive)
|
if (savedRefreshToken == null || !savedRefreshToken.IsActive)
|
||||||
{
|
{
|
||||||
@@ -57,6 +54,7 @@ public class AuthService : IAuthService
|
|||||||
if (savedRefreshToken.IsUsed)
|
if (savedRefreshToken.IsUsed)
|
||||||
{
|
{
|
||||||
// Re-use detection: trek alle tokens van de gebruiker in
|
// Re-use detection: trek alle tokens van de gebruiker in
|
||||||
|
var userId = savedRefreshToken.UserId;
|
||||||
var allTokens = await _context.RefreshTokens.Where(t => t.UserId == userId).ToListAsync();
|
var allTokens = await _context.RefreshTokens.Where(t => t.UserId == userId).ToListAsync();
|
||||||
foreach (var token in allTokens) token.IsRevoked = true;
|
foreach (var token in allTokens) token.IsRevoked = true;
|
||||||
await _context.SaveChangesAsync();
|
await _context.SaveChangesAsync();
|
||||||
@@ -82,6 +80,8 @@ public class AuthService : IAuthService
|
|||||||
private async Task<TokenResponse> GenerateTokenResponseAsync(ApplicationUser user)
|
private async Task<TokenResponse> GenerateTokenResponseAsync(ApplicationUser user)
|
||||||
{
|
{
|
||||||
var roles = await _userManager.GetRolesAsync(user);
|
var roles = await _userManager.GetRolesAsync(user);
|
||||||
|
var primaryRole = roles.FirstOrDefault() ?? "User";
|
||||||
|
|
||||||
var claims = new List<Claim>
|
var claims = new List<Claim>
|
||||||
{
|
{
|
||||||
new(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
|
new(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
|
||||||
@@ -119,7 +119,15 @@ public class AuthService : IAuthService
|
|||||||
|
|
||||||
await _context.SaveChangesAsync();
|
await _context.SaveChangesAsync();
|
||||||
|
|
||||||
return new TokenResponse(accessToken, refreshToken, expiry);
|
var userResponse = new UserResponse(
|
||||||
|
Id: user.Id,
|
||||||
|
Email: user.Email!,
|
||||||
|
Name: user.DisplayName ?? user.Email!,
|
||||||
|
Role: primaryRole,
|
||||||
|
IsActive: user.IsActive
|
||||||
|
);
|
||||||
|
|
||||||
|
return new TokenResponse(accessToken, refreshToken, expiry, userResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string GenerateRefreshToken()
|
private static string GenerateRefreshToken()
|
||||||
@@ -129,27 +137,4 @@ public class AuthService : IAuthService
|
|||||||
rng.GetBytes(randomNumber);
|
rng.GetBytes(randomNumber);
|
||||||
return Convert.ToBase64String(randomNumber);
|
return Convert.ToBase64String(randomNumber);
|
||||||
}
|
}
|
||||||
|
|
||||||
private ClaimsPrincipal GetPrincipalFromExpiredToken(string token)
|
|
||||||
{
|
|
||||||
var tokenValidationParameters = new TokenValidationParameters
|
|
||||||
{
|
|
||||||
ValidateAudience = false,
|
|
||||||
ValidateIssuer = false,
|
|
||||||
ValidateIssuerSigningKey = true,
|
|
||||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSettings.Secret)),
|
|
||||||
ValidateLifetime = false // We willen juist het verlopen token lezen
|
|
||||||
};
|
|
||||||
|
|
||||||
var tokenHandler = new JwtSecurityTokenHandler();
|
|
||||||
var principal = tokenHandler.ValidateToken(token, tokenValidationParameters, out var securityToken);
|
|
||||||
|
|
||||||
if (securityToken is not JwtSecurityToken jwtSecurityToken ||
|
|
||||||
!jwtSecurityToken.Header.Alg.Equals(SecurityAlgorithms.HmacSha256, StringComparison.InvariantCultureIgnoreCase))
|
|
||||||
{
|
|
||||||
throw new SecurityTokenException("Ongeldig token.");
|
|
||||||
}
|
|
||||||
|
|
||||||
return principal;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ public interface IAuthService
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Vernieuwt een verlopen access token met een geldig refresh token.
|
/// Vernieuwt een verlopen access token met een geldig refresh token.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<TokenResponse> RefreshTokenAsync(string accessToken, string refreshToken);
|
Task<TokenResponse> RefreshTokenAsync(string refreshToken);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Trekt een refresh token in (uitloggen).
|
/// Trekt een refresh token in (uitloggen).
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.IdentityModel.Tokens.Jwt;
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using SlpModularCms.Core.Availability;
|
using SlpModularCms.Core.Availability;
|
||||||
using SlpModularCms.Core.Exceptions;
|
using SlpModularCms.Core.Exceptions;
|
||||||
@@ -48,13 +49,17 @@ public class AvailabilityMiddleware
|
|||||||
_logger.LogWarning("Request geblokkeerd vanwege systeemstatus: {Status}. Path: {Path}", status, context.Request.Path);
|
_logger.LogWarning("Request geblokkeerd vanwege systeemstatus: {Status}. Path: {Path}", status, context.Request.Path);
|
||||||
|
|
||||||
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
|
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
|
||||||
var response = new ApiErrorResponse(
|
var problemDetails = new ProblemDetails
|
||||||
Message: status == AvailabilityStatus.Maintenance
|
{
|
||||||
|
Status = StatusCodes.Status503ServiceUnavailable,
|
||||||
|
Title = "Service Unavailable",
|
||||||
|
Detail = status == AvailabilityStatus.Maintenance
|
||||||
? "Het systeem is momenteel in onderhoud. Probeer het later opnieuw."
|
? "Het systeem is momenteel in onderhoud. Probeer het later opnieuw."
|
||||||
: "De service is tijdelijk niet beschikbaar."
|
: "De service is tijdelijk niet beschikbaar.",
|
||||||
);
|
Instance = context.Request.Path
|
||||||
|
};
|
||||||
|
|
||||||
await context.Response.WriteAsJsonAsync(response);
|
await context.Response.WriteAsJsonAsync(problemDetails);
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool IsAdminBypass(HttpContext context)
|
private bool IsAdminBypass(HttpContext context)
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
using SlpModularCms.Core.Identity.Models;
|
using SlpModularCms.Core.Identity.Models;
|
||||||
using SlpModularCms.Core.Identity.Services;
|
using SlpModularCms.Core.Identity.Services;
|
||||||
|
|
||||||
namespace SlpModularCms.Modules.Identity.Controllers;
|
namespace SlpModularCms.Modules.Identity.Controllers;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("[controller]")]
|
[Route("api/v1/auth")]
|
||||||
public class AuthController : ControllerBase
|
public class AuthController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IAuthService _authService;
|
private readonly IAuthService _authService;
|
||||||
@@ -18,25 +20,58 @@ public class AuthController : ControllerBase
|
|||||||
|
|
||||||
[HttpPost("login")]
|
[HttpPost("login")]
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
|
[EnableRateLimiting("login")]
|
||||||
public async Task<IActionResult> Login([FromBody] LoginRequest request)
|
public async Task<IActionResult> Login([FromBody] LoginRequest request)
|
||||||
{
|
{
|
||||||
var response = await _authService.AuthenticateAsync(request.Email, request.Password);
|
var response = await _authService.AuthenticateAsync(request.Email, request.Password);
|
||||||
|
SetTokenCookie(response.RefreshToken);
|
||||||
return Ok(response);
|
return Ok(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("refresh")]
|
[HttpPost("refresh")]
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
public async Task<IActionResult> Refresh([FromBody] RefreshTokenRequest request)
|
[EnableRateLimiting("refresh")]
|
||||||
|
public async Task<IActionResult> Refresh()
|
||||||
{
|
{
|
||||||
var response = await _authService.RefreshTokenAsync(request.AccessToken, request.RefreshToken);
|
var refreshToken = Request.Cookies["refreshToken"];
|
||||||
|
if (string.IsNullOrEmpty(refreshToken))
|
||||||
|
{
|
||||||
|
return Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
var response = await _authService.RefreshTokenAsync(refreshToken);
|
||||||
|
SetTokenCookie(response.RefreshToken);
|
||||||
return Ok(response);
|
return Ok(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("revoke")]
|
[HttpPost("revoke")]
|
||||||
[Authorize]
|
[AllowAnonymous]
|
||||||
public async Task<IActionResult> Revoke([FromBody] string refreshToken)
|
public async Task<IActionResult> Revoke()
|
||||||
|
{
|
||||||
|
var refreshToken = Request.Cookies["refreshToken"];
|
||||||
|
if (!string.IsNullOrEmpty(refreshToken))
|
||||||
{
|
{
|
||||||
await _authService.RevokeTokenAsync(refreshToken);
|
await _authService.RevokeTokenAsync(refreshToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
Response.Cookies.Delete("refreshToken", GetCookieOptions());
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void SetTokenCookie(string refreshToken)
|
||||||
|
{
|
||||||
|
Response.Cookies.Append("refreshToken", refreshToken, GetCookieOptions());
|
||||||
|
}
|
||||||
|
|
||||||
|
private CookieOptions GetCookieOptions()
|
||||||
|
{
|
||||||
|
return new CookieOptions
|
||||||
|
{
|
||||||
|
HttpOnly = true,
|
||||||
|
Secure = Request.IsHttps,
|
||||||
|
SameSite = SameSiteMode.Strict,
|
||||||
|
Path = "/api/v1/auth",
|
||||||
|
Expires = DateTimeOffset.UtcNow.AddDays(7)
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user