mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-15 01:54:43 +00:00
Add mkdocs site and GitHub Pages deployment for docs
This commit is contained in:
84
docs/website/diagrams-md/architecture.md
Normal file
84
docs/website/diagrams-md/architecture.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# The Biergarten App — Vertical Slice Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph HOST["API.Core (thin host)"]
|
||||
API["API.Core<br/>ASP.NET Core Web API host"]
|
||||
end
|
||||
|
||||
subgraph SLICES["Feature Slices"]
|
||||
UsersSlice["Features.Users"]
|
||||
BrewerySlice["Features.Breweries"]
|
||||
EmailsSlice["Features.Emails<br/>(no controller)"]
|
||||
LocationsSlice["Features.Locations<br/>(no controller, no MediatR)"]
|
||||
end
|
||||
|
||||
subgraph SHARED["Shared"]
|
||||
SharedContracts["Shared.Contracts"]
|
||||
SharedApp["Shared.Application"]
|
||||
end
|
||||
|
||||
subgraph INFRA["Infrastructure Layer"]
|
||||
Sql["Infrastructure.Sql"]
|
||||
JWT["Infrastructure.Jwt"]
|
||||
PwdHash["Infrastructure.PasswordHashing"]
|
||||
Email["Infrastructure.Email"]
|
||||
EmailTemplates["Infrastructure.Email.Templates"]
|
||||
end
|
||||
|
||||
subgraph DOMAIN["Domain Layer"]
|
||||
Domain["Domain.Entities"]
|
||||
DomainExceptions["Domain.Exceptions"]
|
||||
end
|
||||
|
||||
Tables[("SQL Server<br/>Tables")]
|
||||
Seed["Database.Seed<br/>(separate console app)"]
|
||||
|
||||
API -.->|AddApplicationPart| UsersSlice
|
||||
API -.->|AddApplicationPart| BrewerySlice
|
||||
API -.->|MediatR scan only| EmailsSlice
|
||||
|
||||
Seed -.->|AddFeaturesUsers| UsersSlice
|
||||
Seed -.->|AddFeaturesBreweries| BrewerySlice
|
||||
Seed -.->|AddFeaturesLocations| LocationsSlice
|
||||
|
||||
UsersSlice --> Sql
|
||||
UsersSlice --> JWT
|
||||
UsersSlice --> PwdHash
|
||||
UsersSlice --> SharedContracts
|
||||
UsersSlice --> SharedApp
|
||||
|
||||
BrewerySlice --> Sql
|
||||
BrewerySlice --> SharedContracts
|
||||
BrewerySlice --> SharedApp
|
||||
|
||||
LocationsSlice --> Sql
|
||||
|
||||
EmailsSlice --> Email
|
||||
EmailsSlice --> EmailTemplates
|
||||
EmailsSlice --> SharedApp
|
||||
|
||||
UsersSlice -.->|sends SendRegistrationEmailCommand| SharedApp
|
||||
EmailsSlice -.->|handles SendRegistrationEmailCommand| SharedApp
|
||||
|
||||
Sql --> Domain
|
||||
|
||||
UsersSlice --> Tables
|
||||
BrewerySlice --> Tables
|
||||
LocationsSlice --> Tables
|
||||
|
||||
UsersSlice --> Domain
|
||||
BrewerySlice --> Domain
|
||||
LocationsSlice --> Domain
|
||||
UsersSlice --> DomainExceptions
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- **API.Core**: Program.cs wiring only — MediatR + `AddApplicationPart` per `Features.*` assembly, Swagger/OpenAPI, health checks, JWT auth middleware, global exception filter.
|
||||
- **Feature slices**: each owns its own controller (HTTP endpoints), commands/queries + handlers, validators, and Dapper repository. `Features.Users` covers auth (`Commands/Authentication`), account management (`Commands/Account`), and profile/avatar (`Commands/Profile`). `Features.Emails` has no controller — invoked only via MediatR commands sent from other slices. `Features.Locations` has no controller and no MediatR handlers — it exposes `ILocationRepository` directly as a plain DI service, not registered by `API.Core` at all; only `Database.Seed` depends on it today.
|
||||
- **Shared.Application**: `ValidationBehavior` (MediatR pipeline) plus the cross-slice email commands (`SendRegistrationEmailCommand`, `SendResendConfirmationEmailCommand`) — the one cross-slice interaction is a MediatR command contract, not a project reference.
|
||||
- **Domain.Entities**: `UserAccount`, `UserCredential`, `UserVerification`, `UserProfile`, `UserAvatar`, `BreweryPost`, `BreweryPostLocation`, `City`, `StateProvince`, `Country`, `BeerPost`, `BeerStyle` (schema tables exist for the last two; no repository yet).
|
||||
- **Infrastructure.Sql**: SQL-first approach — each slice's own repository issues inline SQL via Dapper, no ORM, no stored procedures.
|
||||
- **Database.Seed**: calls each slice's `AddFeatures*()` DI extension directly — no API host, no MediatR pipeline.
|
||||
- No `Features.*` project ever references another `Features.*` project directly.
|
||||
85
docs/website/diagrams-md/auth/confirm-user.md
Normal file
85
docs/website/diagrams-md/auth/confirm-user.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# User Authentication Flow — Confirm User
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
actor User
|
||||
participant API as API Controller
|
||||
participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
|
||||
box rgb(241,243,234) Handler Layer
|
||||
participant ConfirmHandler as ConfirmUserHandler
|
||||
participant UserMgr as UserManager(ApplicationUser)
|
||||
participant TokenSvc as TokenService
|
||||
end
|
||||
box rgb(235,236,227) Infrastructure Layer
|
||||
participant JWT as JWT Infrastructure
|
||||
end
|
||||
box rgb(219,238,221) Repository / Cross-slice
|
||||
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
|
||||
end
|
||||
participant DB as SQL Server
|
||||
|
||||
Note over UserMgr: ASP.NET Core Identity's UserManager replaces the deleted AuthRepository — handlers delegate to UserManager, which calls DapperUserStore (persistence) and Argon2PasswordHasher.
|
||||
|
||||
User->>API: POST /api/auth/confirm?token={token} (requires Bearer access token)
|
||||
activate API
|
||||
API->>Mediator: Send(ConfirmUserCommand(token))
|
||||
activate Mediator
|
||||
Mediator->>ConfirmHandler: Handle(command)
|
||||
activate ConfirmHandler
|
||||
|
||||
ConfirmHandler->>TokenSvc: ValidateConfirmationTokenAsync(token)
|
||||
activate TokenSvc
|
||||
TokenSvc->>JWT: Validate against confirmation token secret, extract userId
|
||||
activate JWT
|
||||
JWT-->>TokenSvc: ValidatedToken or throws
|
||||
deactivate JWT
|
||||
TokenSvc-->>ConfirmHandler: ValidatedToken
|
||||
deactivate TokenSvc
|
||||
|
||||
alt Token invalid or expired
|
||||
ConfirmHandler->>Mediator: throw UnauthorizedException
|
||||
Mediator->>API: propagate
|
||||
API->>User: 401 Unauthorized
|
||||
else Token valid
|
||||
ConfirmHandler->>UserMgr: FindByIdAsync(validatedToken.UserId)
|
||||
activate UserMgr
|
||||
UserMgr->>UserStore: FindByIdAsync(userId)
|
||||
activate UserStore
|
||||
UserStore->>DB: SELECT ... FROM UserAccount WHERE UserAccountID = @Id
|
||||
activate DB
|
||||
DB-->>UserStore: row or null
|
||||
deactivate DB
|
||||
UserStore-->>UserMgr: ApplicationUser or null
|
||||
deactivate UserStore
|
||||
UserMgr-->>ConfirmHandler: ApplicationUser or null
|
||||
deactivate UserMgr
|
||||
|
||||
alt User not found
|
||||
ConfirmHandler->>Mediator: throw UnauthorizedException "User account not found"
|
||||
Mediator->>API: propagate
|
||||
API->>User: 401 Unauthorized
|
||||
else User found
|
||||
ConfirmHandler->>UserStore: SetEmailConfirmedAsync(user, true)
|
||||
Note right of UserStore: IUserEmailStore(ApplicationUser) is injected directly into the handler (not via UserManager), setting the confirmed flag in-memory here
|
||||
|
||||
ConfirmHandler->>UserMgr: UpdateAsync(user)
|
||||
activate UserMgr
|
||||
UserMgr->>UserStore: UpdateAsync(user)
|
||||
activate UserStore
|
||||
UserStore->>DB: UPDATE UserAccount SET ...
|
||||
UserStore->>DB: INSERT INTO UserVerification (UserAccountId, VerificationDateTime)
|
||||
Note right of UserStore: Idempotent — skipped (duplicate-key exception swallowed) if a verification row already exists
|
||||
UserStore-->>UserMgr: IdentityResult.Success
|
||||
deactivate UserStore
|
||||
UserMgr-->>ConfirmHandler: IdentityResult
|
||||
deactivate UserMgr
|
||||
|
||||
ConfirmHandler-->>Mediator: ConfirmationPayload(user.Id, DateTime.UtcNow)
|
||||
deactivate ConfirmHandler
|
||||
Mediator-->>API: ConfirmationPayload
|
||||
deactivate Mediator
|
||||
API->>User: 200 OK {message: "User with ID {id} is confirmed.", payload: {...}}
|
||||
end
|
||||
end
|
||||
deactivate API
|
||||
```
|
||||
71
docs/website/diagrams-md/auth/delete-account.md
Normal file
71
docs/website/diagrams-md/auth/delete-account.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# User Authentication Flow — Delete Account
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
actor User
|
||||
participant API as API Controller
|
||||
participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
|
||||
box rgb(241,243,234) Handler Layer
|
||||
participant DeleteAccountHandler
|
||||
participant UserMgr as UserManager(ApplicationUser)
|
||||
end
|
||||
box rgb(219,238,221) Repository / Cross-slice
|
||||
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
|
||||
end
|
||||
participant DB as SQL Server
|
||||
|
||||
Note over UserMgr: ASP.NET Core Identity's UserManager replaces the deleted AuthRepository — handlers delegate to UserManager, which calls DapperUserStore (persistence) and Argon2PasswordHasher.
|
||||
|
||||
User->>API: DELETE /api/auth/account (requires Bearer access token)
|
||||
activate API
|
||||
API->>Mediator: Send(DeleteAccountCommand(GetAuthenticatedUserId()))
|
||||
activate Mediator
|
||||
Mediator->>DeleteAccountHandler: Handle(command)
|
||||
activate DeleteAccountHandler
|
||||
|
||||
DeleteAccountHandler->>UserMgr: FindByIdAsync(userAccountId)
|
||||
activate UserMgr
|
||||
UserMgr->>UserStore: FindByIdAsync(userAccountId)
|
||||
UserStore-->>UserMgr: ApplicationUser or null
|
||||
UserMgr-->>DeleteAccountHandler: ApplicationUser or null
|
||||
deactivate UserMgr
|
||||
|
||||
alt Account not found
|
||||
DeleteAccountHandler->>Mediator: throw NotFoundException
|
||||
Mediator->>API: propagate
|
||||
API->>User: 404 Not Found
|
||||
else Account found
|
||||
DeleteAccountHandler->>UserMgr: DeleteAsync(user)
|
||||
activate UserMgr
|
||||
UserMgr->>UserStore: DeleteAsync(user)
|
||||
activate UserStore
|
||||
UserStore->>DB: DELETE FROM UserAccount WHERE UserAccountID = @Id
|
||||
Note right of UserStore: UserCredential/UserVerification/UserAvatar cascade on delete. Posts, comments, photos, and follows use ON DELETE NO ACTION — if any reference this account, the DELETE throws a foreign key violation (SQL error 547)
|
||||
|
||||
alt Foreign key violation (dependent records exist)
|
||||
UserStore-->>UserMgr: IdentityResult.Failed (AccountHasDependentRecords)
|
||||
else Delete succeeds
|
||||
UserStore-->>UserMgr: IdentityResult.Success
|
||||
end
|
||||
deactivate UserStore
|
||||
UserMgr-->>DeleteAccountHandler: IdentityResult
|
||||
deactivate UserMgr
|
||||
|
||||
alt Account has dependent records
|
||||
DeleteAccountHandler->>Mediator: throw ConflictException "Account cannot be deleted while it still has associated posts, comments, photos, or follows."
|
||||
Mediator->>API: propagate
|
||||
API->>User: 409 Conflict
|
||||
else !result.Succeeded (other failure)
|
||||
DeleteAccountHandler->>Mediator: throw ConflictException
|
||||
Mediator->>API: propagate
|
||||
API->>User: 409 Conflict
|
||||
else result.Succeeded
|
||||
DeleteAccountHandler-->>Mediator: (void)
|
||||
deactivate DeleteAccountHandler
|
||||
Mediator-->>API: (void)
|
||||
deactivate Mediator
|
||||
API->>User: 200 OK {message: "Account deleted successfully."}
|
||||
end
|
||||
end
|
||||
deactivate API
|
||||
```
|
||||
18
docs/website/diagrams-md/auth/error-handling.md
Normal file
18
docs/website/diagrams-md/auth/error-handling.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# User Authentication Flow — Error Handling
|
||||
|
||||
Global exception mapping performed by `API.Core`'s `GlobalExceptionFilter`. Applies uniformly across every authentication endpoint (registration, login, refresh, confirm, resend, update-username/email/password/profile, delete-account) — see the per-flow diagrams for where each exception type is thrown.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant API as API Controller
|
||||
|
||||
Note over API: GlobalExceptionFilter catches:
|
||||
Note over API: FluentValidation.ValidationException → 400 Bad Request {message, errors} (grouped by property)
|
||||
Note over API: ConflictException → 409 Conflict
|
||||
Note over API: NotFoundException → 404 Not Found
|
||||
Note over API: UnauthorizedException → 401 Unauthorized
|
||||
Note over API: ForbiddenException → 403 Forbidden
|
||||
Note over API: Domain.Exceptions.ValidationException → 400 Bad Request
|
||||
Note over API: Microsoft.Data.SqlClient.SqlException → 503 Service Unavailable
|
||||
Note over API: All others → 500 Internal Server Error
|
||||
```
|
||||
99
docs/website/diagrams-md/auth/login.md
Normal file
99
docs/website/diagrams-md/auth/login.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# User Authentication Flow — Login
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
actor User
|
||||
participant API as API Controller
|
||||
participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
|
||||
box rgb(241,243,234) Handler Layer
|
||||
participant LoginHandler
|
||||
participant UserMgr as UserManager(ApplicationUser)
|
||||
participant TokenSvc as TokenService
|
||||
end
|
||||
box rgb(235,236,227) Infrastructure Layer
|
||||
participant Argon2 as Argon2PasswordHasher
|
||||
participant JWT as JWT Infrastructure
|
||||
end
|
||||
box rgb(219,238,221) Repository / Cross-slice
|
||||
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
|
||||
end
|
||||
participant DB as SQL Server
|
||||
|
||||
Note over UserMgr: ASP.NET Core Identity's UserManager replaces the deleted AuthRepository — handlers delegate to UserManager, which calls DapperUserStore (persistence) and Argon2PasswordHasher.
|
||||
|
||||
User->>API: POST /api/auth/login {username, password}
|
||||
activate API
|
||||
API->>Mediator: Send(LoginCommand)
|
||||
activate Mediator
|
||||
Note right of Mediator: ValidationBehavior runs LoginValidator — username and password must both be non-empty
|
||||
|
||||
alt Validation fails
|
||||
Mediator->>API: throws FluentValidation.ValidationException
|
||||
API->>User: 400 Bad Request {message, errors}
|
||||
else Validation succeeds
|
||||
Mediator->>LoginHandler: Handle(command)
|
||||
activate LoginHandler
|
||||
|
||||
LoginHandler->>UserMgr: FindByNameAsync(username)
|
||||
activate UserMgr
|
||||
UserMgr->>UserStore: FindByNameAsync(username)
|
||||
activate UserStore
|
||||
UserStore->>DB: SELECT ... FROM UserAccount WHERE Username = @Username
|
||||
activate DB
|
||||
DB-->>UserStore: UserAccount row or null
|
||||
deactivate DB
|
||||
UserStore->>UserStore: Hydrate (active UserCredential.Hash, UserVerification → EmailConfirmed)
|
||||
UserStore-->>UserMgr: ApplicationUser or null
|
||||
deactivate UserStore
|
||||
UserMgr-->>LoginHandler: ApplicationUser or null
|
||||
deactivate UserMgr
|
||||
|
||||
alt User not found
|
||||
LoginHandler->>Mediator: throw UnauthorizedException "Invalid username or password."
|
||||
Mediator->>API: propagate
|
||||
API->>User: 401 Unauthorized
|
||||
else User found
|
||||
LoginHandler->>UserMgr: CheckPasswordAsync(user, password)
|
||||
activate UserMgr
|
||||
Note right of UserMgr: HasPasswordAsync short-circuits to false (no active credential hydrated) without calling the hasher — otherwise delegates to Argon2PasswordHasher
|
||||
UserMgr->>Argon2: VerifyHashedPassword(user, user.PasswordHash, password)
|
||||
activate Argon2
|
||||
Note right of Argon2: Split stored "salt:hash", rehash password with same salt, constant-time compare (CryptographicOperations.FixedTimeEquals)
|
||||
Argon2-->>UserMgr: Success/Failed
|
||||
deactivate Argon2
|
||||
UserMgr-->>LoginHandler: true/false
|
||||
deactivate UserMgr
|
||||
|
||||
alt Password invalid
|
||||
LoginHandler->>Mediator: throw UnauthorizedException
|
||||
Mediator->>API: propagate
|
||||
API->>User: 401 Unauthorized
|
||||
else Password valid
|
||||
LoginHandler->>TokenSvc: GenerateAccessToken(user)
|
||||
activate TokenSvc
|
||||
TokenSvc->>JWT: GenerateJwt(...)
|
||||
activate JWT
|
||||
JWT-->>TokenSvc: Access Token
|
||||
deactivate JWT
|
||||
TokenSvc-->>LoginHandler: Access Token
|
||||
deactivate TokenSvc
|
||||
|
||||
LoginHandler->>TokenSvc: GenerateRefreshToken(user)
|
||||
activate TokenSvc
|
||||
TokenSvc->>JWT: GenerateJwt(...)
|
||||
activate JWT
|
||||
JWT-->>TokenSvc: Refresh Token
|
||||
deactivate JWT
|
||||
TokenSvc-->>LoginHandler: Refresh Token
|
||||
deactivate TokenSvc
|
||||
|
||||
LoginHandler-->>Mediator: LoginPayload(userAccountId, username, refreshToken, accessToken)
|
||||
deactivate LoginHandler
|
||||
Mediator-->>API: LoginPayload
|
||||
deactivate Mediator
|
||||
API->>User: 200 OK {message: "Logged in successfully.", payload: {...}}
|
||||
end
|
||||
end
|
||||
end
|
||||
deactivate API
|
||||
```
|
||||
83
docs/website/diagrams-md/auth/refresh-token.md
Normal file
83
docs/website/diagrams-md/auth/refresh-token.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# User Authentication Flow — Refresh Token
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
actor User
|
||||
participant API as API Controller
|
||||
participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
|
||||
box rgb(241,243,234) Handler Layer
|
||||
participant RefreshHandler as RefreshTokenHandler
|
||||
participant UserMgr as UserManager(ApplicationUser)
|
||||
participant TokenSvc as TokenService
|
||||
end
|
||||
box rgb(235,236,227) Infrastructure Layer
|
||||
participant JWT as JWT Infrastructure
|
||||
end
|
||||
box rgb(219,238,221) Repository / Cross-slice
|
||||
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
|
||||
end
|
||||
participant DB as SQL Server
|
||||
|
||||
Note over UserMgr: ASP.NET Core Identity's UserManager replaces the deleted AuthRepository — handlers delegate to UserManager, which calls DapperUserStore (persistence) and Argon2PasswordHasher.
|
||||
|
||||
User->>API: POST /api/auth/refresh<br/>Header: X-Refresh-Token: {token}
|
||||
activate API
|
||||
API->>Mediator: Send(RefreshTokenCommand(refreshToken))
|
||||
activate Mediator
|
||||
Note right of Mediator: ValidationBehavior runs RefreshTokenValidator — refreshToken must be non-empty. Anonymous endpoint, no Bearer token required.
|
||||
|
||||
alt Validation fails
|
||||
Mediator->>API: throws FluentValidation.ValidationException
|
||||
API->>User: 400 Bad Request {message, errors}
|
||||
else Validation succeeds
|
||||
Mediator->>RefreshHandler: Handle(command)
|
||||
activate RefreshHandler
|
||||
|
||||
RefreshHandler->>TokenSvc: RefreshTokenAsync(refreshToken)
|
||||
activate TokenSvc
|
||||
TokenSvc->>JWT: ValidateRefreshTokenAsync(refreshToken)
|
||||
activate JWT
|
||||
Note right of JWT: Validates signature/expiry against the refresh token secret, extracts userId/username claims
|
||||
JWT-->>TokenSvc: ValidatedToken or throws
|
||||
deactivate JWT
|
||||
|
||||
alt Token invalid or expired
|
||||
TokenSvc->>RefreshHandler: throw UnauthorizedException
|
||||
RefreshHandler->>Mediator: propagate
|
||||
Mediator->>API: propagate
|
||||
API->>User: 401 Unauthorized
|
||||
else Token valid
|
||||
TokenSvc->>UserMgr: FindByIdAsync(validated.UserId)
|
||||
activate UserMgr
|
||||
UserMgr->>UserStore: FindByIdAsync(userId)
|
||||
activate UserStore
|
||||
UserStore->>DB: SELECT ... FROM UserAccount WHERE UserAccountID = @Id
|
||||
activate DB
|
||||
DB-->>UserStore: row or null
|
||||
deactivate DB
|
||||
UserStore-->>UserMgr: ApplicationUser or null
|
||||
deactivate UserStore
|
||||
UserMgr-->>TokenSvc: ApplicationUser or null
|
||||
deactivate UserMgr
|
||||
|
||||
alt User not found
|
||||
TokenSvc->>RefreshHandler: throw UnauthorizedException "User account not found"
|
||||
RefreshHandler->>Mediator: propagate
|
||||
Mediator->>API: propagate
|
||||
API->>User: 401 Unauthorized
|
||||
else User found
|
||||
TokenSvc->>JWT: GenerateAccessToken(user) + GenerateRefreshToken(user)
|
||||
JWT-->>TokenSvc: new access + refresh tokens
|
||||
TokenSvc-->>RefreshHandler: RefreshTokenResult
|
||||
deactivate TokenSvc
|
||||
|
||||
RefreshHandler-->>Mediator: LoginPayload(userId, username, refreshToken, accessToken)
|
||||
deactivate RefreshHandler
|
||||
Mediator-->>API: LoginPayload
|
||||
deactivate Mediator
|
||||
API->>User: 200 OK {message: "Token refreshed successfully.", payload: {...}}
|
||||
end
|
||||
end
|
||||
end
|
||||
deactivate API
|
||||
```
|
||||
157
docs/website/diagrams-md/auth/registration.md
Normal file
157
docs/website/diagrams-md/auth/registration.md
Normal file
@@ -0,0 +1,157 @@
|
||||
# User Authentication Flow — Registration
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
actor User
|
||||
participant API as API Controller
|
||||
participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
|
||||
box rgb(241,243,234) Handler Layer
|
||||
participant RegHandler as RegisterUserHandler
|
||||
participant UserMgr as UserManager(ApplicationUser)
|
||||
participant TokenSvc as TokenService
|
||||
end
|
||||
box rgb(235,236,227) Infrastructure Layer
|
||||
participant Argon2 as Argon2PasswordHasher
|
||||
participant JWT as JWT Infrastructure
|
||||
participant SMTP as Email Provider
|
||||
participant Template as Template Provider
|
||||
end
|
||||
box rgb(219,238,221) Repository / Cross-slice
|
||||
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
|
||||
participant EmailDispatcher as EmailDispatcher<br/>(Features.Emails)
|
||||
end
|
||||
participant DB as SQL Server
|
||||
|
||||
Note over UserMgr: ASP.NET Core Identity's UserManager replaces the deleted AuthRepository — handlers delegate to UserManager, which calls DapperUserStore (persistence) and Argon2PasswordHasher.
|
||||
|
||||
User->>API: POST /api/auth/register {username, firstName, lastName, email, dateOfBirth, password}
|
||||
activate API
|
||||
API->>Mediator: Send(RegisterUserCommand)
|
||||
activate Mediator
|
||||
Note right of Mediator: ValidationBehavior runs RegisterUserValidator — username 3-64 chars alphanumeric+[._-], valid email ≤128 chars, password min 8 chars with upper/lower/number/special, DateOfBirth 19+ years old
|
||||
|
||||
alt Validation fails
|
||||
Mediator->>API: throws FluentValidation.ValidationException
|
||||
API->>User: 400 Bad Request {message, errors}
|
||||
else Validation succeeds
|
||||
Mediator->>RegHandler: Handle(command)
|
||||
activate RegHandler
|
||||
RegHandler->>RegHandler: user = new UserAccount{...}.ToApplicationUser()
|
||||
|
||||
RegHandler->>UserMgr: CreateAsync(user, password)
|
||||
activate UserMgr
|
||||
UserMgr->>Argon2: HashPassword(user, password)
|
||||
activate Argon2
|
||||
Note right of Argon2: Argon2id — salt 16 bytes, memory 64MB, iterations 4, parallelism = CPU count, hash output 32 bytes
|
||||
Argon2-->>UserMgr: "base64(salt):base64(hash)"
|
||||
deactivate Argon2
|
||||
|
||||
UserMgr->>UserStore: SetPasswordHashAsync(user, hash)
|
||||
Note right of UserStore: user.Id is still empty here — only sets the in-memory hash — CreateAsync writes it to UserCredential itself
|
||||
|
||||
UserMgr->>UserStore: FindByNameAsync(username)
|
||||
activate UserStore
|
||||
UserStore->>DB: SELECT ... FROM UserAccount WHERE Username = @Username
|
||||
activate DB
|
||||
DB-->>UserStore: null (user doesn't exist)
|
||||
deactivate DB
|
||||
deactivate UserStore
|
||||
|
||||
UserMgr->>UserStore: FindByEmailAsync(email)
|
||||
activate UserStore
|
||||
UserStore->>DB: SELECT ... FROM UserAccount WHERE Email = @Email
|
||||
activate DB
|
||||
DB-->>UserStore: null (email doesn't exist)
|
||||
deactivate DB
|
||||
deactivate UserStore
|
||||
Note right of UserMgr: Default UserManager validators check uniqueness before CreateAsync — DapperUserStore.CreateAsync also guards a genuine race via a caught duplicate-key SqlException (2601/2627)
|
||||
|
||||
UserMgr->>UserStore: CreateAsync(user)
|
||||
activate UserStore
|
||||
UserStore->>DB: BEGIN TRANSACTION
|
||||
activate DB
|
||||
UserStore->>DB: INSERT INTO UserAccount (...) OUTPUT INSERTED.UserAccountID
|
||||
DB-->>UserStore: UserAccountId (GUID)
|
||||
UserStore->>DB: INSERT INTO UserCredential (UserAccountId, Hash)
|
||||
UserStore->>DB: COMMIT TRANSACTION
|
||||
deactivate DB
|
||||
Note right of UserStore: Both statements run via Dapper against a single DbTransaction, not a stored procedure
|
||||
|
||||
UserStore-->>UserMgr: IdentityResult.Success (user.Id set)
|
||||
deactivate UserStore
|
||||
UserMgr-->>RegHandler: IdentityResult
|
||||
deactivate UserMgr
|
||||
|
||||
alt !result.Succeeded
|
||||
RegHandler->>Mediator: throw ConflictException (DuplicateUserName/DuplicateEmail → "Username or email already exists", otherwise joined error descriptions)
|
||||
Mediator->>API: propagate
|
||||
API->>User: 409 Conflict "Username or email already exists"
|
||||
else result.Succeeded
|
||||
RegHandler->>TokenSvc: GenerateAccessToken(user)
|
||||
activate TokenSvc
|
||||
TokenSvc->>JWT: GenerateJwt(userId, username, expiry)
|
||||
activate JWT
|
||||
Note right of JWT: HS256, expires 1 hour, claims sub/unique_name/iat/jti
|
||||
JWT->>JWT: Create JWT with claims, sign with secret key
|
||||
JWT-->>TokenSvc: Access Token
|
||||
deactivate JWT
|
||||
TokenSvc-->>RegHandler: Access Token
|
||||
deactivate TokenSvc
|
||||
|
||||
RegHandler->>TokenSvc: GenerateRefreshToken(user)
|
||||
activate TokenSvc
|
||||
TokenSvc->>JWT: GenerateJwt(userId, username, expiry)
|
||||
activate JWT
|
||||
Note right of JWT: Expires 21 days (504h), same structure as access token, signed with a separate secret
|
||||
JWT-->>TokenSvc: Refresh Token
|
||||
deactivate JWT
|
||||
TokenSvc-->>RegHandler: Refresh Token
|
||||
deactivate TokenSvc
|
||||
|
||||
RegHandler->>TokenSvc: GenerateConfirmationToken(user)
|
||||
activate TokenSvc
|
||||
TokenSvc->>JWT: GenerateJwt(userId, username, expiry)
|
||||
activate JWT
|
||||
Note right of JWT: Expires 30 minutes, signed with a third, separate secret
|
||||
JWT-->>TokenSvc: Confirmation Token
|
||||
deactivate JWT
|
||||
TokenSvc-->>RegHandler: Confirmation Token
|
||||
deactivate TokenSvc
|
||||
|
||||
RegHandler->>Mediator: Send(SendRegistrationEmailCommand) (Shared.Application cross-slice contract)
|
||||
activate Mediator
|
||||
Note right of Mediator: Routed to Features.Emails' SendRegistrationEmailHandler — the only cross-slice call in the backend
|
||||
Mediator->>EmailDispatcher: SendRegistrationEmailAsync(firstName, email, confirmationToken)
|
||||
activate EmailDispatcher
|
||||
|
||||
EmailDispatcher->>Template: RenderUserRegisteredEmailAsync(firstName, confirmationLink)
|
||||
activate Template
|
||||
Note right of Template: Razor component — header/branding, welcome message, confirmation button, footer
|
||||
Template->>Template: Render Razor component to HTML
|
||||
Template-->>EmailDispatcher: HTML email content
|
||||
deactivate Template
|
||||
|
||||
EmailDispatcher->>SMTP: SendAsync(email, subject, body)
|
||||
activate SMTP
|
||||
Note right of SMTP: MailKit — host/port from SMTP_HOST/SMTP_PORT, StartTLS when SMTP_USE_SSL=true, optional username/password auth
|
||||
SMTP->>SMTP: Build MIME message, connect, authenticate, send, disconnect
|
||||
SMTP-->>EmailDispatcher: Success / throws
|
||||
deactivate SMTP
|
||||
deactivate EmailDispatcher
|
||||
deactivate Mediator
|
||||
|
||||
alt Email sent successfully
|
||||
RegHandler->>RegHandler: emailSent = true
|
||||
else mediator.Send throws
|
||||
RegHandler->>RegHandler: catch(Exception), log to stderr, emailSent = false
|
||||
end
|
||||
|
||||
RegHandler-->>Mediator: RegistrationPayload(userAccountId, username, refreshToken, accessToken, confirmationEmailSent)
|
||||
deactivate RegHandler
|
||||
Mediator-->>API: RegistrationPayload
|
||||
deactivate Mediator
|
||||
API->>User: 201 Created {message: "User registered successfully.", payload: {...}}
|
||||
end
|
||||
end
|
||||
deactivate API
|
||||
```
|
||||
77
docs/website/diagrams-md/auth/resend-confirmation.md
Normal file
77
docs/website/diagrams-md/auth/resend-confirmation.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# User Authentication Flow — Resend Confirmation Email
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
actor User
|
||||
participant API as API Controller
|
||||
participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
|
||||
box rgb(241,243,234) Handler Layer
|
||||
participant ResendHandler as ResendConfirmationEmailHandler
|
||||
participant UserMgr as UserManager(ApplicationUser)
|
||||
participant TokenSvc as TokenService
|
||||
end
|
||||
box rgb(235,236,227) Infrastructure Layer
|
||||
participant SMTP as Email Provider
|
||||
participant Template as Template Provider
|
||||
end
|
||||
box rgb(219,238,221) Repository / Cross-slice
|
||||
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
|
||||
participant EmailDispatcher as EmailDispatcher<br/>(Features.Emails)
|
||||
end
|
||||
participant DB as SQL Server
|
||||
|
||||
Note over UserMgr: ASP.NET Core Identity's UserManager replaces the deleted AuthRepository — handlers delegate to UserManager, which calls DapperUserStore (persistence) and Argon2PasswordHasher.
|
||||
|
||||
User->>API: POST /api/auth/confirm/resend?userId={id} (requires Bearer access token)
|
||||
activate API
|
||||
API->>Mediator: Send(ResendConfirmationEmailCommand(userId))
|
||||
activate Mediator
|
||||
Mediator->>ResendHandler: Handle(command)
|
||||
activate ResendHandler
|
||||
|
||||
ResendHandler->>UserMgr: FindByIdAsync(userId)
|
||||
activate UserMgr
|
||||
UserMgr->>UserStore: FindByIdAsync(userId)
|
||||
activate UserStore
|
||||
UserStore->>DB: SELECT ... FROM UserAccount WHERE UserAccountID = @Id
|
||||
activate DB
|
||||
DB-->>UserStore: row or null
|
||||
deactivate DB
|
||||
UserStore-->>UserMgr: ApplicationUser or null
|
||||
deactivate UserStore
|
||||
UserMgr-->>ResendHandler: ApplicationUser or null
|
||||
deactivate UserMgr
|
||||
|
||||
alt User not found
|
||||
ResendHandler->>ResendHandler: return silently (prevents user enumeration)
|
||||
else User found
|
||||
ResendHandler->>UserMgr: IsEmailConfirmedAsync(user)
|
||||
UserMgr-->>ResendHandler: bool
|
||||
|
||||
alt Already confirmed
|
||||
ResendHandler->>ResendHandler: return silently (no-op)
|
||||
else Not yet confirmed
|
||||
ResendHandler->>TokenSvc: GenerateConfirmationToken(user)
|
||||
activate TokenSvc
|
||||
TokenSvc-->>ResendHandler: confirmation token
|
||||
deactivate TokenSvc
|
||||
|
||||
ResendHandler->>Mediator: Send(SendResendConfirmationEmailCommand)
|
||||
activate Mediator
|
||||
Mediator->>EmailDispatcher: SendResendConfirmationEmailAsync(firstName, email, confirmationToken)
|
||||
activate EmailDispatcher
|
||||
EmailDispatcher->>Template: Render email (same Razor pipeline as registration)
|
||||
EmailDispatcher->>SMTP: SendAsync(email, subject, body)
|
||||
deactivate EmailDispatcher
|
||||
deactivate Mediator
|
||||
end
|
||||
end
|
||||
|
||||
ResendHandler-->>Mediator: (void)
|
||||
deactivate ResendHandler
|
||||
Mediator-->>API: (void)
|
||||
deactivate Mediator
|
||||
API->>User: 200 OK {message: "confirmation email has been resent"}
|
||||
Note right of API: Always 200 OK regardless of outcome above — same user-enumeration precaution as a not-found user or an already-confirmed account
|
||||
deactivate API
|
||||
```
|
||||
70
docs/website/diagrams-md/auth/update-email.md
Normal file
70
docs/website/diagrams-md/auth/update-email.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# User Authentication Flow — Update Email
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
actor User
|
||||
participant API as API Controller
|
||||
participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
|
||||
box rgb(241,243,234) Handler Layer
|
||||
participant UpdateEmailHandler
|
||||
participant UserMgr as UserManager(ApplicationUser)
|
||||
end
|
||||
box rgb(219,238,221) Repository / Cross-slice
|
||||
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
|
||||
end
|
||||
participant DB as SQL Server
|
||||
|
||||
Note over UserMgr: ASP.NET Core Identity's UserManager replaces the deleted AuthRepository — handlers delegate to UserManager, which calls DapperUserStore (persistence) and Argon2PasswordHasher.
|
||||
|
||||
User->>API: PATCH /api/auth/email {newEmail} (requires Bearer access token)
|
||||
activate API
|
||||
API->>Mediator: Send(UpdateEmailCommand(GetAuthenticatedUserId(), newEmail))
|
||||
activate Mediator
|
||||
Note right of Mediator: ValidationBehavior runs UpdateEmailValidator (same format rules as registration)
|
||||
|
||||
alt Validation fails
|
||||
Mediator->>API: throws FluentValidation.ValidationException
|
||||
API->>User: 400 Bad Request
|
||||
else Validation succeeds
|
||||
Mediator->>UpdateEmailHandler: Handle(command)
|
||||
activate UpdateEmailHandler
|
||||
|
||||
UpdateEmailHandler->>UserMgr: FindByIdAsync(userAccountId)
|
||||
activate UserMgr
|
||||
UserMgr->>UserStore: FindByIdAsync(userAccountId)
|
||||
UserStore->>DB: SELECT ...
|
||||
UserStore-->>UserMgr: ApplicationUser or null
|
||||
UserMgr-->>UpdateEmailHandler: ApplicationUser or null
|
||||
deactivate UserMgr
|
||||
|
||||
alt Account not found
|
||||
UpdateEmailHandler->>Mediator: throw NotFoundException
|
||||
Mediator->>API: propagate
|
||||
API->>User: 404 Not Found
|
||||
else Account found
|
||||
UpdateEmailHandler->>UserMgr: SetEmailAsync(user, newEmail)
|
||||
activate UserMgr
|
||||
UserMgr->>UserStore: FindByEmailAsync(newEmail)
|
||||
Note right of UserMgr: Uniqueness check. Also resets user.EmailConfirmed = false — caller must re-confirm via POST /api/auth/confirm/resend
|
||||
UserMgr->>UserStore: UpdateAsync(user)
|
||||
UserStore->>DB: UPDATE UserAccount SET Email = @Email, ...
|
||||
UserStore->>DB: DELETE FROM UserVerification WHERE UserAccountID = @Id
|
||||
UserStore-->>UserMgr: IdentityResult
|
||||
UserMgr-->>UpdateEmailHandler: IdentityResult
|
||||
deactivate UserMgr
|
||||
|
||||
alt !result.Succeeded
|
||||
UpdateEmailHandler->>Mediator: throw ConflictException (DuplicateEmail → "Email address already exists")
|
||||
Mediator->>API: propagate
|
||||
API->>User: 409 Conflict
|
||||
else result.Succeeded
|
||||
UpdateEmailHandler-->>Mediator: UpdateEmailPayload(user.Id, user.Email, user.EmailConfirmed = false)
|
||||
deactivate UpdateEmailHandler
|
||||
Mediator-->>API: UpdateEmailPayload
|
||||
deactivate Mediator
|
||||
API->>User: 200 OK {message: "Email updated successfully.", payload: {...}}
|
||||
end
|
||||
end
|
||||
end
|
||||
deactivate API
|
||||
```
|
||||
90
docs/website/diagrams-md/auth/update-password.md
Normal file
90
docs/website/diagrams-md/auth/update-password.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# User Authentication Flow — Update Password
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
actor User
|
||||
participant API as API Controller
|
||||
participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
|
||||
box rgb(241,243,234) Handler Layer
|
||||
participant UpdatePasswordHandler
|
||||
participant UserMgr as UserManager(ApplicationUser)
|
||||
end
|
||||
box rgb(235,236,227) Infrastructure Layer
|
||||
participant Argon2 as Argon2PasswordHasher
|
||||
end
|
||||
box rgb(219,238,221) Repository / Cross-slice
|
||||
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
|
||||
end
|
||||
participant DB as SQL Server
|
||||
|
||||
Note over UserMgr: ASP.NET Core Identity's UserManager replaces the deleted AuthRepository — handlers delegate to UserManager, which calls DapperUserStore (persistence) and Argon2PasswordHasher.
|
||||
|
||||
User->>API: PATCH /api/auth/password {currentPassword, newPassword} (requires Bearer access token)
|
||||
activate API
|
||||
API->>Mediator: Send(UpdatePasswordCommand(GetAuthenticatedUserId(), currentPassword, newPassword))
|
||||
activate Mediator
|
||||
Note right of Mediator: ValidationBehavior runs UpdatePasswordValidator (same password strength rules as registration)
|
||||
|
||||
alt Validation fails
|
||||
Mediator->>API: throws FluentValidation.ValidationException
|
||||
API->>User: 400 Bad Request
|
||||
else Validation succeeds
|
||||
Mediator->>UpdatePasswordHandler: Handle(command)
|
||||
activate UpdatePasswordHandler
|
||||
|
||||
UpdatePasswordHandler->>UserMgr: FindByIdAsync(userAccountId)
|
||||
activate UserMgr
|
||||
UserMgr->>UserStore: FindByIdAsync(userAccountId)
|
||||
UserStore-->>UserMgr: ApplicationUser or null
|
||||
UserMgr-->>UpdatePasswordHandler: ApplicationUser or null
|
||||
deactivate UserMgr
|
||||
|
||||
alt Account not found
|
||||
UpdatePasswordHandler->>Mediator: throw NotFoundException
|
||||
Mediator->>API: propagate
|
||||
API->>User: 404 Not Found
|
||||
else Account found
|
||||
UpdatePasswordHandler->>UserMgr: ChangePasswordAsync(user, currentPassword, newPassword)
|
||||
activate UserMgr
|
||||
UserMgr->>Argon2: VerifyHashedPassword(user, user.PasswordHash, currentPassword)
|
||||
activate Argon2
|
||||
Argon2-->>UserMgr: Success/Failed
|
||||
deactivate Argon2
|
||||
|
||||
alt Current password mismatch
|
||||
UserMgr-->>UpdatePasswordHandler: IdentityResult.Failed (PasswordMismatch)
|
||||
else Current password matches
|
||||
UserMgr->>Argon2: HashPassword(user, newPassword)
|
||||
activate Argon2
|
||||
Argon2-->>UserMgr: new hash
|
||||
deactivate Argon2
|
||||
UserMgr->>UserStore: SetPasswordHashAsync(user, newHash)
|
||||
activate UserStore
|
||||
UserStore->>DB: UPDATE UserCredential SET IsRevoked = 1, RevokedAt = GETDATE() WHERE UserAccountId = @Id AND IsRevoked = 0
|
||||
UserStore->>DB: INSERT INTO UserCredential (UserAccountId, Hash)
|
||||
Note right of UserStore: Revoke-then-insert runs inside one DbTransaction (credential rotation)
|
||||
UserStore-->>UserMgr: (void)
|
||||
deactivate UserStore
|
||||
UserMgr-->>UpdatePasswordHandler: IdentityResult.Success
|
||||
end
|
||||
deactivate UserMgr
|
||||
|
||||
alt Current password is incorrect
|
||||
UpdatePasswordHandler->>Mediator: throw UnauthorizedException "Current password is incorrect"
|
||||
Mediator->>API: propagate
|
||||
API->>User: 401 Unauthorized
|
||||
else !result.Succeeded (other failure)
|
||||
UpdatePasswordHandler->>Mediator: throw ConflictException
|
||||
Mediator->>API: propagate
|
||||
API->>User: 409 Conflict
|
||||
else result.Succeeded
|
||||
UpdatePasswordHandler-->>Mediator: UpdatePasswordPayload(user.Id, DateTime.UtcNow)
|
||||
deactivate UpdatePasswordHandler
|
||||
Mediator-->>API: UpdatePasswordPayload
|
||||
deactivate Mediator
|
||||
API->>User: 200 OK {message: "Password updated successfully.", payload: {...}}
|
||||
end
|
||||
end
|
||||
end
|
||||
deactivate API
|
||||
```
|
||||
69
docs/website/diagrams-md/auth/update-profile.md
Normal file
69
docs/website/diagrams-md/auth/update-profile.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# User Authentication Flow — Update Profile
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
actor User
|
||||
participant API as API Controller
|
||||
participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
|
||||
box rgb(241,243,234) Handler Layer
|
||||
participant UpdateProfileHandler
|
||||
participant UserMgr as UserManager(ApplicationUser)
|
||||
end
|
||||
box rgb(219,238,221) Repository / Cross-slice
|
||||
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
|
||||
end
|
||||
participant DB as SQL Server
|
||||
|
||||
Note over UserMgr: ASP.NET Core Identity's UserManager replaces the deleted AuthRepository — handlers delegate to UserManager, which calls DapperUserStore (persistence) and Argon2PasswordHasher.
|
||||
|
||||
User->>API: PATCH /api/auth/profile {firstName, lastName, dateOfBirth} (requires Bearer access token)
|
||||
activate API
|
||||
API->>Mediator: Send(UpdateProfileCommand(GetAuthenticatedUserId(), firstName, lastName, dateOfBirth))
|
||||
activate Mediator
|
||||
Note right of Mediator: ValidationBehavior runs UpdateProfileValidator (same name/DateOfBirth rules as registration)
|
||||
|
||||
alt Validation fails
|
||||
Mediator->>API: throws FluentValidation.ValidationException
|
||||
API->>User: 400 Bad Request
|
||||
else Validation succeeds
|
||||
Mediator->>UpdateProfileHandler: Handle(command)
|
||||
activate UpdateProfileHandler
|
||||
|
||||
UpdateProfileHandler->>UserMgr: FindByIdAsync(userAccountId)
|
||||
activate UserMgr
|
||||
UserMgr->>UserStore: FindByIdAsync(userAccountId)
|
||||
UserStore-->>UserMgr: ApplicationUser or null
|
||||
UserMgr-->>UpdateProfileHandler: ApplicationUser or null
|
||||
deactivate UserMgr
|
||||
|
||||
alt Account not found
|
||||
UpdateProfileHandler->>Mediator: throw NotFoundException
|
||||
Mediator->>API: propagate
|
||||
API->>User: 404 Not Found
|
||||
else Account found
|
||||
UpdateProfileHandler->>UpdateProfileHandler: user.FirstName = ...<br/>user.LastName = ...<br/>user.DateOfBirth = ...
|
||||
Note right of UpdateProfileHandler: First/last name and date of birth aren't tracked by any Identity store interface — mutated directly on the ApplicationUser, then saved via UserManager.UpdateAsync
|
||||
|
||||
UpdateProfileHandler->>UserMgr: UpdateAsync(user)
|
||||
activate UserMgr
|
||||
UserMgr->>UserStore: UpdateAsync(user)
|
||||
UserStore->>DB: UPDATE UserAccount SET FirstName = @FirstName, LastName = @LastName, DateOfBirth = @DateOfBirth, ...
|
||||
UserStore-->>UserMgr: IdentityResult
|
||||
UserMgr-->>UpdateProfileHandler: IdentityResult
|
||||
deactivate UserMgr
|
||||
|
||||
alt !result.Succeeded
|
||||
UpdateProfileHandler->>Mediator: throw ConflictException
|
||||
Mediator->>API: propagate
|
||||
API->>User: 409 Conflict
|
||||
else result.Succeeded
|
||||
UpdateProfileHandler-->>Mediator: UpdateProfilePayload(user.Id, firstName, lastName, dateOfBirth)
|
||||
deactivate UpdateProfileHandler
|
||||
Mediator-->>API: UpdateProfilePayload
|
||||
deactivate Mediator
|
||||
API->>User: 200 OK {message: "Profile updated successfully.", payload: {...}}
|
||||
end
|
||||
end
|
||||
end
|
||||
deactivate API
|
||||
```
|
||||
74
docs/website/diagrams-md/auth/update-username.md
Normal file
74
docs/website/diagrams-md/auth/update-username.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# User Authentication Flow — Update Username
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
actor User
|
||||
participant API as API Controller
|
||||
participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
|
||||
box rgb(241,243,234) Handler Layer
|
||||
participant UpdateUsernameHandler
|
||||
participant UserMgr as UserManager(ApplicationUser)
|
||||
end
|
||||
box rgb(219,238,221) Repository / Cross-slice
|
||||
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
|
||||
end
|
||||
participant DB as SQL Server
|
||||
|
||||
Note over UserMgr: ASP.NET Core Identity's UserManager replaces the deleted AuthRepository — handlers delegate to UserManager, which calls DapperUserStore (persistence) and Argon2PasswordHasher.
|
||||
|
||||
User->>API: PATCH /api/auth/username {newUsername} (requires Bearer access token)
|
||||
activate API
|
||||
API->>Mediator: Send(UpdateUsernameCommand(GetAuthenticatedUserId(), newUsername))
|
||||
activate Mediator
|
||||
Note right of Mediator: ValidationBehavior runs UpdateUsernameValidator (same format rules as registration)
|
||||
|
||||
alt Validation fails
|
||||
Mediator->>API: throws FluentValidation.ValidationException
|
||||
API->>User: 400 Bad Request
|
||||
else Validation succeeds
|
||||
Mediator->>UpdateUsernameHandler: Handle(command)
|
||||
activate UpdateUsernameHandler
|
||||
|
||||
UpdateUsernameHandler->>UserMgr: FindByIdAsync(userAccountId)
|
||||
activate UserMgr
|
||||
UserMgr->>UserStore: FindByIdAsync(userAccountId)
|
||||
activate UserStore
|
||||
UserStore->>DB: SELECT ... FROM UserAccount WHERE UserAccountID = @Id
|
||||
activate DB
|
||||
DB-->>UserStore: row or null
|
||||
deactivate DB
|
||||
UserStore-->>UserMgr: ApplicationUser or null
|
||||
deactivate UserStore
|
||||
UserMgr-->>UpdateUsernameHandler: ApplicationUser or null
|
||||
deactivate UserMgr
|
||||
|
||||
alt Account not found
|
||||
UpdateUsernameHandler->>Mediator: throw NotFoundException
|
||||
Mediator->>API: propagate
|
||||
API->>User: 404 Not Found
|
||||
else Account found
|
||||
UpdateUsernameHandler->>UserMgr: SetUserNameAsync(user, newUsername)
|
||||
activate UserMgr
|
||||
UserMgr->>UserStore: FindByNameAsync(newUsername)
|
||||
Note right of UserMgr: Uniqueness check before the UPDATE
|
||||
UserMgr->>UserStore: UpdateAsync(user)
|
||||
UserStore->>DB: UPDATE UserAccount SET Username = @UserName, ...
|
||||
UserStore-->>UserMgr: IdentityResult
|
||||
UserMgr-->>UpdateUsernameHandler: IdentityResult
|
||||
deactivate UserMgr
|
||||
|
||||
alt !result.Succeeded
|
||||
UpdateUsernameHandler->>Mediator: throw ConflictException (DuplicateUserName → "Username already exists")
|
||||
Mediator->>API: propagate
|
||||
API->>User: 409 Conflict
|
||||
else result.Succeeded
|
||||
UpdateUsernameHandler-->>Mediator: UpdateUsernamePayload(user.Id, user.UserName)
|
||||
deactivate UpdateUsernameHandler
|
||||
Mediator-->>API: UpdateUsernamePayload
|
||||
deactivate Mediator
|
||||
API->>User: 200 OK {message: "Username updated successfully.", payload: {...}}
|
||||
end
|
||||
end
|
||||
end
|
||||
deactivate API
|
||||
```
|
||||
168
docs/website/diagrams-md/database-schema.md
Normal file
168
docs/website/diagrams-md/database-schema.md
Normal file
@@ -0,0 +1,168 @@
|
||||
# Database Schema
|
||||
|
||||
Every table also has a `RowVersion ROWVERSION` column (optimistic concurrency), omitted below for brevity. Tables marked `(*)` have no repository/feature slice yet.
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
User ||--o{ Cred : "has credentials"
|
||||
User ||--o| Verify : "has verification"
|
||||
User ||--o| Profile : "has profile"
|
||||
Profile ||--o{ Avatar : "has avatar history"
|
||||
Avatar }o--|| Photo : "uses photo"
|
||||
User ||--o{ Follow : "follows"
|
||||
User ||--o{ Follow : "followed by"
|
||||
User ||--o{ Photo : "uploads"
|
||||
|
||||
Country ||--o{ StateProvince : "has"
|
||||
StateProvince ||--o{ City : "has"
|
||||
|
||||
User ||--o{ Brewery : "posts"
|
||||
Brewery ||--o| BreweryLoc : "has location"
|
||||
City ||--o{ BreweryLoc : "located in"
|
||||
Brewery ||--o{ BreweryPhoto : "has photos"
|
||||
Photo ||--o{ BreweryPhoto : "used in"
|
||||
|
||||
User ||--o{ Beer : "posts"
|
||||
BeerStyle ||--o{ Beer : "categorizes"
|
||||
Brewery ||--o{ Beer : "brews"
|
||||
Beer ||--o{ BeerPhoto : "has photos"
|
||||
Photo ||--o{ BeerPhoto : "used in"
|
||||
Beer ||--o{ BeerComment : "has comments"
|
||||
User ||--o{ BeerComment : "comments"
|
||||
|
||||
User {
|
||||
uniqueidentifier UserAccountID PK
|
||||
varchar Username UK
|
||||
nvarchar FirstName
|
||||
nvarchar LastName
|
||||
varchar Email UK
|
||||
datetime CreatedAt
|
||||
datetime UpdatedAt "nullable"
|
||||
date DateOfBirth
|
||||
}
|
||||
|
||||
Cred {
|
||||
uniqueidentifier UserCredentialID PK
|
||||
uniqueidentifier UserAccountID FK
|
||||
datetime CreatedAt
|
||||
datetime Expiry
|
||||
nvarchar Hash
|
||||
bit IsRevoked
|
||||
datetime RevokedAt "nullable"
|
||||
}
|
||||
|
||||
Verify {
|
||||
uniqueidentifier UserVerificationID PK
|
||||
uniqueidentifier UserAccountID "FK, UK"
|
||||
datetime VerificationDateTime
|
||||
}
|
||||
|
||||
Profile {
|
||||
uniqueidentifier UserProfileID PK
|
||||
uniqueidentifier UserAccountID "FK, UK"
|
||||
nvarchar Biography
|
||||
}
|
||||
|
||||
Avatar {
|
||||
uniqueidentifier UserAvatarID PK
|
||||
uniqueidentifier UserProfileID FK
|
||||
uniqueidentifier PhotoID FK
|
||||
datetime2 ValidFrom
|
||||
datetime2 ValidTo "nullable; at most one row per UserProfileID has ValidTo IS NULL (active avatar)"
|
||||
}
|
||||
|
||||
Follow {
|
||||
uniqueidentifier UserFollowID PK
|
||||
uniqueidentifier UserAccountID FK
|
||||
uniqueidentifier FollowingID FK
|
||||
datetime CreatedAt
|
||||
}
|
||||
|
||||
Photo {
|
||||
uniqueidentifier PhotoID PK
|
||||
nvarchar Hyperlink "nullable"
|
||||
uniqueidentifier UploadedByID FK
|
||||
datetime UploadedAt
|
||||
}
|
||||
|
||||
Country {
|
||||
uniqueidentifier CountryID PK
|
||||
nvarchar CountryName
|
||||
char ISO3166_1 UK
|
||||
}
|
||||
|
||||
StateProvince {
|
||||
uniqueidentifier StateProvinceID PK
|
||||
nvarchar StateProvinceName
|
||||
char ISO3166_2 UK
|
||||
uniqueidentifier CountryID FK
|
||||
}
|
||||
|
||||
City {
|
||||
uniqueidentifier CityID PK
|
||||
nvarchar CityName
|
||||
uniqueidentifier StateProvinceID FK
|
||||
}
|
||||
|
||||
Brewery {
|
||||
uniqueidentifier BreweryPostID PK
|
||||
nvarchar BreweryName
|
||||
uniqueidentifier PostedByID FK
|
||||
nvarchar Description
|
||||
datetime CreatedAt
|
||||
datetime UpdatedAt "nullable"
|
||||
}
|
||||
|
||||
BreweryLoc {
|
||||
uniqueidentifier BreweryPostLocationID PK
|
||||
uniqueidentifier BreweryPostID "FK, UK"
|
||||
nvarchar AddressLine1
|
||||
nvarchar AddressLine2 "nullable"
|
||||
nvarchar PostalCode
|
||||
uniqueidentifier CityID FK
|
||||
geography Coordinates "nullable"
|
||||
}
|
||||
|
||||
BreweryPhoto {
|
||||
uniqueidentifier BreweryPostPhotoID PK
|
||||
uniqueidentifier BreweryPostID FK
|
||||
uniqueidentifier PhotoID FK
|
||||
datetime LinkedAt
|
||||
}
|
||||
|
||||
BeerStyle {
|
||||
uniqueidentifier BeerStyleID PK
|
||||
nvarchar StyleName UK
|
||||
nvarchar Description "nullable"
|
||||
}
|
||||
|
||||
Beer {
|
||||
uniqueidentifier BeerPostID PK
|
||||
nvarchar Name
|
||||
nvarchar Description
|
||||
decimal ABV
|
||||
int IBU
|
||||
uniqueidentifier PostedByID FK
|
||||
uniqueidentifier BeerStyleID FK
|
||||
uniqueidentifier BrewedByID FK
|
||||
datetime CreatedAt
|
||||
datetime UpdatedAt "nullable"
|
||||
}
|
||||
|
||||
BeerPhoto {
|
||||
uniqueidentifier BeerPostPhotoID PK
|
||||
uniqueidentifier BeerPostID FK
|
||||
uniqueidentifier PhotoID FK
|
||||
datetime LinkedAt
|
||||
}
|
||||
|
||||
BeerComment {
|
||||
uniqueidentifier BeerPostCommentID PK
|
||||
nvarchar Comment
|
||||
uniqueidentifier BeerPostID FK
|
||||
uniqueidentifier CommentedByID FK
|
||||
int Rating
|
||||
datetime CreatedAt
|
||||
datetime UpdatedAt "nullable"
|
||||
}
|
||||
```
|
||||
90
docs/website/diagrams-md/deployment.md
Normal file
90
docs/website/diagrams-md/deployment.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# Docker Deployment Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
Developer(["Developer"])
|
||||
Host[["Docker Host"]]
|
||||
Developer -->|docker compose up| Host
|
||||
Host -->|http://localhost:8080| DevAPI
|
||||
|
||||
subgraph DEV["Development Environment (docker-compose.dev.yaml)"]
|
||||
DevDB[("SQL Server 2022<br/>Biergarten Database<br/>:1433")]
|
||||
DevAPI["API Container<br/>ASP.NET Core 10<br/>:8080 HTTP / :8081 HTTPS"]
|
||||
DevMig["Migrations (run-once)<br/>Database.Migrations"]
|
||||
DevSeed["Seed (run-once)<br/>Database.Seed"]
|
||||
DevMailpit["Mailpit<br/>:8025 Web UI / :1025 SMTP"]
|
||||
end
|
||||
|
||||
DevMig -->|1. Run migrations| DevDB
|
||||
DevSeed -->|2. Seed data| DevDB
|
||||
DevAPI -->|3. Connect and serve| DevDB
|
||||
DevAPI -.->|4. SMTP_HOST/PORT| DevMailpit
|
||||
DevMig -.->|depends_on| DevDB
|
||||
DevSeed -.->|depends_on| DevMig
|
||||
DevAPI -.->|depends_on| DevSeed
|
||||
|
||||
subgraph TEST["Test Environment (docker-compose.test.yaml)"]
|
||||
TestDB[("SQL Server, isolated instance<br/>Biergarten Test Database<br/>:1434")]
|
||||
TestMig["Migrations (test)"]
|
||||
TestSeed["Seed (test)<br/>minimal: test.user only"]
|
||||
Specs["API.Specs<br/>Reqnroll + xUnit<br/>(integration tests)"]
|
||||
UnitTests["unit.tests<br/>xUnit + Moq<br/>Features.*.Tests, one container"]
|
||||
end
|
||||
|
||||
TestMig -->|1. Migrate| TestDB
|
||||
TestSeed -->|2. Seed| TestDB
|
||||
Specs -->|3. Integration test| TestDB
|
||||
UnitTests -.->|depends_on for startup ordering only, no real connection| TestDB
|
||||
TestMig -.->|depends_on| TestDB
|
||||
TestSeed -.->|depends_on| TestMig
|
||||
Specs -.->|depends_on| TestSeed
|
||||
UnitTests -.->|depends_on| TestSeed
|
||||
|
||||
Results[["test-results/ (mounted volume)<br/>api-specs/results.trx<br/>Features.Users.Tests.trx<br/>Features.Breweries.Tests.trx<br/>Features.Emails.Tests.trx"]]
|
||||
Specs -->|Export TRX| Results
|
||||
UnitTests -->|Export TRX, one per slice| Results
|
||||
|
||||
subgraph PROD["Production Environment (docker-compose.prod.yaml)"]
|
||||
ProdDB[("SQL Server 2022<br/>Biergarten Database<br/>no host port published")]
|
||||
ProdMig["Migrations (run-once)"]
|
||||
ProdAPI["API Container<br/>ASP.NET Core 10<br/>:8080 HTTP / :8081 HTTPS"]
|
||||
end
|
||||
|
||||
ProdMig -->|1. Run migrations| ProdDB
|
||||
ProdAPI -->|2. Connect and serve| ProdDB
|
||||
ProdMig -.->|depends_on| ProdDB
|
||||
ProdAPI -.->|"depends_on: sql-server healthy (not migrations/seed)"| ProdDB
|
||||
|
||||
subgraph DBONLY["Database-only Environment (docker-compose.db.yaml)"]
|
||||
DbOnlyDB[("SQL Server<br/>:1434")]
|
||||
DbOnlyMig["Migrations (run-once)"]
|
||||
DbOnlySeed["Seed (run-once)"]
|
||||
end
|
||||
|
||||
DbOnlyMig -->|1. Run migrations| DbOnlyDB
|
||||
DbOnlySeed -->|2. Seed data| DbOnlyDB
|
||||
DbOnlyMig -.->|depends_on| DbOnlyDB
|
||||
DbOnlySeed -.->|depends_on| DbOnlyMig
|
||||
|
||||
subgraph MIN["Minimal Environment (docker-compose.min.yaml)"]
|
||||
MinDB[("SQL Server<br/>:1433")]
|
||||
MinMig["Migrations (run-once)"]
|
||||
MinMailpit["Mailpit<br/>:8025 / :1025"]
|
||||
MinSeed["Seed (run-once)"]
|
||||
end
|
||||
|
||||
MinMig -->|1. Run migrations| MinDB
|
||||
MinSeed -->|2. Seed data| MinDB
|
||||
MinMig -.->|depends_on| MinDB
|
||||
MinSeed -.->|depends_on| MinMig
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- **Dev network** (`biergarten-dev`, bridge): internal DNS resolves `sql-server` and `api`. Volumes: `biergarten-dev-data`. Startup order: SQL Server (health check) → migrations (run-once) → seed (run-once) → API (long-running).
|
||||
- **Test network** (`biergarten-test`, bridge): all test components isolated; fresh DB instance each run (`CLEAR_DATABASE=true`, ephemeral `biergarten-test-data` volume). All tests run in parallel, results aggregated.
|
||||
- **Prod network** (`prodnet`, bridge): isolated from dev/test networks. Volumes: `sqlserverdata-prod`, `nuget-cache-prod`. No seed step in production.
|
||||
- **Database-only environment**: same devnet volumes/images as `docker-compose.dev.yaml`, minus `api.core` and `mailpit` — for working against the database without running the API.
|
||||
- **Minimal environment**: database + mailpit only, no API container — for frontend development against an `API.Core` process run outside Docker.
|
||||
- `unit.tests` builds `Dockerfile.tests`, which restores/builds against `Core.slnx` then runs `dotnet test` for every `Features/*.Tests` project in turn (`Features.Users.Tests`, `Features.Breweries.Tests`, `Features.Emails.Tests`), mocking each handler's repository/service dependencies with Moq — no real database needed.
|
||||
- `.trx` results are readable by Visual Studio, Azure DevOps, and GitHub Actions.
|
||||
@@ -1,5 +1,7 @@
|
||||
@startuml architecture
|
||||
!include ../../pipeline/diagrams/biergarten-weizen-theme.puml
|
||||
@startuml architecture_v6
|
||||
skinparam linetype ortho
|
||||
skinparam nodesep 90
|
||||
skinparam ranksep 110
|
||||
|
||||
title The Biergarten App - Vertical Slice Architecture
|
||||
|
||||
@@ -85,13 +87,19 @@ database "SQL Server" {
|
||||
}
|
||||
|
||||
node "Database.Seed\n(separate console app)" as Seed
|
||||
note right of Seed
|
||||
Seed calls each slice's
|
||||
AddFeatures*() DI extension
|
||||
directly - no API host,
|
||||
no MediatR pipeline
|
||||
end note
|
||||
|
||||
' Relationships
|
||||
API ..> UsersSlice : AddApplicationPart\n+ MediatR scan
|
||||
API ..> BrewerySlice : AddApplicationPart\n+ MediatR scan
|
||||
API ..> EmailsSlice : MediatR scan only\n(no controller)
|
||||
API ..> UsersSlice : AddApplicationPart
|
||||
API ..> BrewerySlice : AddApplicationPart
|
||||
API ..> EmailsSlice : MediatR scan only
|
||||
|
||||
Seed ..> UsersSlice : AddFeaturesUsers()\n(DI only, no API/MediatR)
|
||||
Seed ..> UsersSlice : AddFeaturesUsers()
|
||||
Seed ..> BrewerySlice : AddFeaturesBreweries()
|
||||
Seed ..> LocationsSlice : AddFeaturesLocations()
|
||||
|
||||
@@ -118,8 +126,8 @@ EmailsSlice ..> SharedApp : handles\nSendRegistrationEmailCommand
|
||||
Sql --> Domain
|
||||
|
||||
UsersSlice --> Tables : Dapper SQL
|
||||
BrewerySlice --> Tables : Dapper SQL
|
||||
LocationsSlice --> Tables : Dapper SQL
|
||||
BrewerySlice --> Tables
|
||||
LocationsSlice --> Tables
|
||||
|
||||
UsersSlice --> Domain
|
||||
BrewerySlice --> Domain
|
||||
|
||||
@@ -170,49 +170,33 @@ package "Beer (*)" {
|
||||
}
|
||||
|
||||
' Relationships: User & Auth
|
||||
User ||--o{ Cred : "has"
|
||||
User ||--o| Verify : "has"
|
||||
User ||--o| Profile : "has"
|
||||
Profile ||--o{ Avatar : "has (history)"
|
||||
Avatar }o--|| Photo : "refers to"
|
||||
User ||--o{ Follow : "follows (UserAccountID)"
|
||||
User ||--o{ Follow : "followed by (FollowingID)"
|
||||
User ||--o{ Photo : "uploaded"
|
||||
User ||--o{ Cred
|
||||
User ||--o| Verify
|
||||
User ||--o| Profile
|
||||
Profile ||--o{ Avatar
|
||||
Avatar }o--|| Photo
|
||||
User ||--o{ Follow
|
||||
User ||--o{ Follow
|
||||
User ||--o{ Photo
|
||||
|
||||
' Relationships: Location
|
||||
Country ||--o{ StateProvince : "has"
|
||||
StateProvince ||--o{ City : "has"
|
||||
Country ||--o{ StateProvince
|
||||
StateProvince ||--o{ City
|
||||
|
||||
' Relationships: Brewery
|
||||
User ||--o{ Brewery : "posted"
|
||||
Brewery ||--o| BreweryLoc : "has"
|
||||
City ||--o{ BreweryLoc : "located in"
|
||||
Brewery ||--o{ BreweryPhoto : "has"
|
||||
Photo ||--o{ BreweryPhoto : "linked to"
|
||||
User ||--o{ Brewery
|
||||
Brewery ||--o| BreweryLoc
|
||||
City ||--o{ BreweryLoc
|
||||
Brewery ||--o{ BreweryPhoto
|
||||
Photo ||--o{ BreweryPhoto
|
||||
|
||||
' Relationships: Beer
|
||||
User ||--o{ Beer : "posted"
|
||||
BeerStyle ||--o{ Beer : "categorizes"
|
||||
Brewery ||--o{ Beer : "brewed"
|
||||
Beer ||--o{ BeerPhoto : "has"
|
||||
Photo ||--o{ BeerPhoto : "linked to"
|
||||
Beer ||--o{ BeerComment : "has"
|
||||
User ||--o{ BeerComment : "commented"
|
||||
|
||||
note bottom of User
|
||||
Auth reads/writes (registration, login, credential
|
||||
rotation, verification) go through ASP.NET Core
|
||||
Identity's UserManager<ApplicationUser>, backed by
|
||||
Features.Users' DapperUserStore - inline SQL, no
|
||||
stored procedures. Profile/avatar data goes through
|
||||
Features.Users' UserProfileRepository.
|
||||
end note
|
||||
|
||||
note bottom of Brewery
|
||||
Backed by Features.Breweries' BreweryRepository
|
||||
(Dapper); create/update/delete run inline SQL
|
||||
inside a transaction, with optimistic concurrency
|
||||
via RowVersion
|
||||
end note
|
||||
User ||--o{ Beer
|
||||
BeerStyle ||--o{ Beer
|
||||
Brewery ||--o{ Beer
|
||||
Beer ||--o{ BeerPhoto
|
||||
Photo ||--o{ BeerPhoto
|
||||
Beer ||--o{ BeerComment
|
||||
User ||--o{ BeerComment
|
||||
|
||||
@enduml
|
||||
|
||||
@@ -10,13 +10,13 @@ tags:
|
||||
|
||||
## Overview
|
||||
|
||||
The Core project validates JSON Web Tokens (JWTs) across three token types:
|
||||
The Biergarten API validates JSON Web Tokens (JWTs) across three token types:
|
||||
|
||||
- **Access Tokens**: Short-lived (1 hour) tokens for API authentication
|
||||
- **Refresh Tokens**: Long-lived (21 days) tokens for obtaining new access
|
||||
tokens
|
||||
- **Confirmation Tokens**: Short-lived (30 minutes) tokens for email
|
||||
confirmation
|
||||
| Token Type | Lifetime | Purpose |
|
||||
| --- | --- | --- |
|
||||
| **Access Tokens** | 1 hour | API authentication |
|
||||
| **Refresh Tokens** | 21 days | Obtaining new access tokens |
|
||||
| **Confirmation Tokens** | 30 minutes | Email confirmation |
|
||||
|
||||
## Components
|
||||
|
||||
@@ -183,29 +183,35 @@ Validation failures return HTTP 401 Unauthorized:
|
||||
|
||||
### Access token lifecycle
|
||||
|
||||
1. **Generation**: During login (1-hour validity)
|
||||
2. **Usage**: Included in Authorization header on API requests
|
||||
3. **Validation**: Validated on protected endpoints
|
||||
4. **Expiration**: Token becomes invalid after 1 hour
|
||||
5. **Refresh**: Use refresh token to obtain new access token
|
||||
| Step | Description |
|
||||
| --- | --- |
|
||||
| Generation | During login (1-hour validity) |
|
||||
| Usage | Included in Authorization header on API requests |
|
||||
| Validation | Validated on protected endpoints |
|
||||
| Expiration | Token becomes invalid after 1 hour |
|
||||
| Refresh | Use refresh token to obtain new access token |
|
||||
|
||||
### Refresh token lifecycle
|
||||
|
||||
1. **Generation**: During login (21-day validity)
|
||||
2. **Storage**: Client-side (secure storage)
|
||||
3. **Usage**: Posted to `/api/auth/refresh` endpoint
|
||||
4. **Validation**: Validated by `ITokenService.RefreshTokenAsync()`
|
||||
5. **Rotation**: New refresh token issued on successful refresh
|
||||
6. **Expiration**: Token becomes invalid after 21 days
|
||||
| Step | Description |
|
||||
| --- | --- |
|
||||
| Generation | During login (21-day validity) |
|
||||
| Storage | Client-side (secure storage) |
|
||||
| Usage | Posted to `/api/auth/refresh` endpoint |
|
||||
| Validation | Validated by `ITokenService.RefreshTokenAsync()` |
|
||||
| Rotation | New refresh token issued on successful refresh |
|
||||
| Expiration | Token becomes invalid after 21 days |
|
||||
|
||||
### Confirmation token lifecycle
|
||||
|
||||
1. **Generation**: During user registration (30-minute validity)
|
||||
2. **Delivery**: Emailed to user in confirmation link
|
||||
3. **Usage**: User clicks link, token posted to `/api/auth/confirm`
|
||||
4. **Validation**: Validated by `ConfirmUserHandler`
|
||||
5. **Completion**: User account marked as confirmed
|
||||
6. **Expiration**: Token becomes invalid after 30 minutes
|
||||
| Step | Description |
|
||||
| --- | --- |
|
||||
| Generation | During user registration (30-minute validity) |
|
||||
| Delivery | Emailed to user in confirmation link |
|
||||
| Usage | User clicks link, token posted to `/api/auth/confirm` |
|
||||
| Validation | Validated by `ConfirmUserHandler` |
|
||||
| Completion | User account marked as confirmed |
|
||||
| Expiration | Token becomes invalid after 30 minutes |
|
||||
|
||||
## Testing
|
||||
|
||||
@@ -266,21 +272,21 @@ All of the following live in `Features.Users.Tests`.
|
||||
### Stretch goals
|
||||
|
||||
1. **Middleware for Access Token Validation**
|
||||
- Automatically validate access tokens on protected routes
|
||||
- Populate HttpContext.User from token claims
|
||||
- Return 401 for invalid/missing tokens
|
||||
- Automatically validate access tokens on protected routes
|
||||
- Populate HttpContext.User from token claims
|
||||
- Return 401 for invalid/missing tokens
|
||||
|
||||
2. **Token Denylisting**
|
||||
- Implement token revocation (for example, on logout)
|
||||
- Store denylisted tokens in cache/database
|
||||
- Check the denylist during validation
|
||||
- Implement token revocation (for example, on logout)
|
||||
- Store denylisted tokens in cache/database
|
||||
- Check the denylist during validation
|
||||
|
||||
3. **Refresh Token Rotation Strategy**
|
||||
- Detect token reuse (replay attacks)
|
||||
- Automatically invalidate entire token chain on reuse
|
||||
- Log suspicious activity
|
||||
- Detect token reuse (replay attacks)
|
||||
- Automatically invalidate entire token chain on reuse
|
||||
- Log suspicious activity
|
||||
|
||||
4. **Structured Logging**
|
||||
- Log token validation attempts
|
||||
- Track failed validation reasons
|
||||
- Alert on repeated validation failures (brute force detection)
|
||||
- Log token validation attempts
|
||||
- Track failed validation reasons
|
||||
- Alert on repeated validation failures (brute force detection)
|
||||
|
||||
Reference in New Issue
Block a user