Finishes functional design for unit 0 (back-end changes before front-end work)

This commit is contained in:
2026-06-18 21:40:45 +02:00
parent 9e49489f7e
commit 53a307cdbd
14 changed files with 1065 additions and 4 deletions
@@ -0,0 +1,93 @@
# Business Logic Model — Unit 0: Backend Prerequisites
## Overview
Unit 0 modifies the authentication flow to use httpOnly cookies for refresh token delivery and adds CORS support for the React SPA. No new business transactions are introduced — existing auth transactions are made more secure.
---
## Flow 1: Login
```mermaid
sequenceDiagram
participant FE as Frontend
participant AC as AuthController
participant AS as AuthService
participant DB as Database
FE->>AC: POST /auth/login
AC->>AS: AuthenticateAsync()
AS->>DB: FindUser + ValidatePassword
DB-->>AS: User + Role
AS-->>AC: TokenResponse
AC-->>FE: 200 accessToken + Set-Cookie refreshToken
```
**Changes from current behaviour**:
- `RefreshToken` is no longer returned in the response body
- httpOnly cookie is set on the response
- `UserDto` with `Name` (DisplayName ?? Email) is added to response
---
## Flow 2: Refresh Token
```mermaid
sequenceDiagram
participant FE as Frontend
participant AC as AuthController
participant AS as AuthService
participant DB as Database
FE->>AC: POST /auth/refresh (cookie auto-sent)
AC->>AC: Read cookie refreshToken
AC->>AS: RefreshTokenAsync(cookieValue)
AS->>DB: Validate RefreshToken
DB-->>AS: Valid + User
AS-->>AC: new TokenResponse
AC-->>FE: 200 new accessToken + Set-Cookie new refreshToken
```
**Changes from current behaviour**:
- Request body (`RefreshTokenRequest`) is **removed** — token read from cookie only
- New refresh token set in cookie (rotation still applies)
---
## Flow 3: Revoke (Logout)
```mermaid
sequenceDiagram
participant FE as Frontend
participant AC as AuthController
participant AS as AuthService
participant DB as Database
FE->>AC: POST /auth/revoke (no Authorize)
AC->>AC: Read cookie refreshToken
AC->>AS: RevokeTokenAsync(cookieValue)
AS->>DB: Mark token revoked
DB-->>AS: OK
AC->>AC: Clear cookie Expires=epoch
AC-->>FE: 204 No Content
```
**Changes from current behaviour**:
- `[Authorize]` attribute removed — logout works even if access token has expired
- Token read from cookie, not request body
- Cookie explicitly cleared in response
---
## Flow 4: CORS Preflight
```mermaid
sequenceDiagram
participant Browser
participant API as API
Browser->>API: OPTIONS /auth/login preflight
API-->>Browser: 204 Access-Control-Allow headers
Browser->>API: POST /auth/login with credentials
API-->>Browser: 200 + Set-Cookie
```
@@ -0,0 +1,66 @@
# Business Rules — Unit 0: Backend Prerequisites
## BR-U0-01: CORS Origin Validation
- The CORS policy MUST only allow origins listed in `Cors:AllowedOrigins` from configuration
- An empty `AllowedOrigins` array in production means no SPA origin is allowed (safe default)
- `AllowCredentials()` MUST be set — required for the browser to send the httpOnly cookie
- `AllowAnyMethod()` and `AllowAnyHeader()` are used to avoid CORS issues for future endpoints
- The CORS middleware MUST be registered BEFORE `UseAuthentication` and `UseAuthorization` in the pipeline
## BR-U0-02: Refresh Token Cookie — Set Rules
- Cookie name: `refreshToken`
- Cookie path: `/api/v1/auth` — scoped to auth endpoints only; not sent with other API calls
- `HttpOnly = true` — ALWAYS, no exceptions
- `Secure = request.IsHttps` — adapts to the current request protocol (true in production, false in local HTTP)
- `SameSite = Strict` — cookie only sent when request originates from the exact same site
- Cookie is set on BOTH `Login` and `Refresh` responses (token rotation)
- Cookie MUST NOT be set on failed auth attempts
## BR-U0-03: Refresh Token Cookie — Clear Rules
- On `Revoke`: Set-Cookie with the same name/path but `Expires = DateTime.UnixEpoch` (epoch = effectively deleted)
- On failed refresh (invalid/expired token): Clear the cookie in the error response
- Cookie clearing MUST use the exact same `Path` as cookie setting (`/api/v1/auth`)
## BR-U0-04: HTTP Response Body — Login and Refresh
The following fields MUST be returned in the response body:
- `accessToken` (string) — JWT bearer token
- `expiresAt` (ISO datetime) — access token expiry
- `user.id` (Guid)
- `user.email` (string)
- `user.name` (string) — `ApplicationUser.DisplayName ?? ApplicationUser.Email`
- `user.role` (string) — the user's primary role name (Owner / Admin / User)
- `user.isActive` (bool)
The `refreshToken` MUST NOT appear in the response body.
## BR-U0-05: Revoke Endpoint Authorization
- `[Authorize]` is REMOVED from the `Revoke` endpoint
- If the refresh token cookie is present: revoke it and clear the cookie
- If the refresh token cookie is absent: no-op, return `204 No Content` (idempotent logout)
- This allows logout to succeed even when the access token has already expired
## BR-U0-06: Refresh Endpoint — Input
- The `RefreshTokenRequest` body parameter is REMOVED
- The refresh token is read exclusively from `Request.Cookies["refreshToken"]`
- If the cookie is absent: return `401 Unauthorized` with a generic error message
- The existing `accessToken` validation logic in `IAuthService.RefreshTokenAsync` can be relaxed or the signature adapted (see code generation for details)
## BR-U0-07: DisplayName Field
- `ApplicationUser.DisplayName` is nullable (`string?`)
- Maximum length: 100 characters
- Default value: `NULL` for existing users
- `name` in the response is computed as: `user.DisplayName ?? user.Email`
- On new user creation via invitation (`POST /users/complete-setup`): the `DisplayName` is set from the form field
- On owner creation (`POST /setup/owner`): `DisplayName` defaults to `NULL` (falls back to email)
## BR-U0-08: Migration
- A new EF Core Code-First migration is required named e.g. `AddDisplayNameToApplicationUser`
- The migration adds `DisplayName nvarchar(100) NULL` to the `AspNetUsers` table
- All existing users receive `NULL` as their `DisplayName` (they see their email as display name)
## BR-U0-09: Security Baseline Compliance (SECURITY-12)
- httpOnly cookie prevents XSS token theft ✅
- `Secure = request.IsHttps` ensures the cookie is only sent over HTTPS in production ✅
- `SameSite=Strict` prevents CSRF attacks on the refresh endpoint ✅
- No credentials (tokens) in localStorage or response body ✅
- Session invalidated on logout (token revoked + cookie cleared) ✅
@@ -0,0 +1,90 @@
# Domain Entities — Unit 0: Backend Prerequisites
## Modified Entity: ApplicationUser
`ApplicationUser` is extended with a new `DisplayName` property.
```csharp
public class ApplicationUser : IdentityUser<Guid>
{
// Existing properties (unchanged)
public bool IsActive { get; set; }
public DateTime CreatedAt { get; set; }
// NEW: Display name shown in the frontend UI
// Optional — if null/empty, the frontend falls back to the user's email
public string? DisplayName { get; set; }
}
```
**Migration required**: A new EF Core migration must be added to `SlpModularCms.Core/Migrations/` to add the `DisplayName` column to the `AspNetUsers` table.
| Property | Type | Nullable | Max Length | Default |
|----------|------|----------|-----------|---------|
| `DisplayName` | `nvarchar` | Yes | 100 | NULL |
---
## Modified DTO: TokenResponse
`TokenResponse` is extended to carry the user's display name and role for the frontend auth context.
```csharp
public record TokenResponse(
string AccessToken,
string RefreshToken, // Kept in record for internal use; NOT returned in HTTP response body
DateTime ExpiresAt,
UserDto User
);
public record UserDto(
Guid Id,
string Email,
string Name, // DisplayName ?? Email (fallback)
string Role,
bool IsActive
);
```
> **Note**: `RefreshToken` is no longer returned in the HTTP response body. The `AuthController` reads it from the `TokenResponse` record internally to set the httpOnly cookie, then returns only `AccessToken`, `ExpiresAt`, and `User` to the client.
---
## Removed: RefreshTokenRequest
`RefreshTokenRequest.cs` is deleted. The refresh token is now read exclusively from the httpOnly cookie.
```csharp
// DELETED — no longer needed
// public record RefreshTokenRequest(string AccessToken, string RefreshToken);
```
---
## Configuration Entity: CorsSettings
New configuration section read via `IConfiguration` — no database entity.
```json
// appsettings.json (production placeholder)
{
"Cors": {
"AllowedOrigins": []
}
}
// appsettings.Development.json
{
"Cors": {
"AllowedOrigins": ["http://localhost:5173"]
}
}
```
```csharp
// Bound via IConfiguration — not a domain entity
public class CorsSettings
{
public string[] AllowedOrigins { get; set; } = [];
}
```