Files
the-biergarten-app/docs/website/diagrams-src/authentication-flow.puml
Aaron Po de1e9ac02b Migrate Auth/Brewery/Location repositories to pure Dapper; rename Timer to RowVersion (#237)
* Integrate Spectre.Console into seed application, use async data loading

* Replace stored-procedure repositories with Dapper-based repositories

Removes the USP_* stored procedures for Auth, Location, and Brewery
data access in favour of services built with Dapper.

Adds a new Features.Locations slice and optimistic concurrency support
for the Brewery slice.

* Add new documentation for Dapper migration/database schema

* Update FluentAssertions to most recent open source ver

* Update XML doc comment in backend projects

* Modify brewery location dto to include denormalized data

* - Updated column names from 'Timer' to 'RowVersion' in schema.sql for
- Introduced new BeerPost entity and updated BreweryPost and BreweryPostLocation entities to use 'RowVersion' for concurrency.
- Modified UserAccount, UserCredential, UserVerification, City, StateProvince, and Country entities to use 'RowVersion' instead of 'Timer'.
- Updated tests and command handlers to reflect changes in concurrency token from 'Timer' to 'RowVersion'.
- Adjusted BreweryDto and BreweryDtoMapper to include 'RowVersion' and updated mapping logic accordingly.
- Ensured all repository methods and interfaces are aligned with the new concurrency handling approach.

* updates

* Add BeerStyle entity and related properties; refactor BreweryDto to remove BeerPosts

* Fix Api.Specs test failures: stale Timer column refs and missing test.user fixture

* Remove repository tests, usages of registration dto used in repo

* archive old stored procs/functions

* update stale docs

* Add instructions for generating cryptographically secure base64 secrets on macOS/Linux and Windows
2026-08-24 01:09:47 -04:00

338 lines
11 KiB
Plaintext

@startuml authentication-flow
!theme plain
skinparam backgroundColor #FFFFFF
skinparam defaultFontName Arial
skinparam sequenceMessageAlign center
skinparam maxMessageSize 200
title User Authentication Flow - Expanded
actor User
participant "API\nController" as API
participant "MediatR pipeline\n(ValidationBehavior)" as Mediator
box "Handler Layer" #LightBlue
participant "RegisterUserHandler" as RegHandler
participant "LoginHandler" as LoginHandler
participant "TokenService" as TokenSvc
end box
box "Infrastructure Layer" #LightGreen
participant "Argon2\nInfrastructure" as Argon2
participant "JWT\nInfrastructure" as JWT
participant "Email\nProvider" as SMTP
participant "Template\nProvider" as Template
end box
box "Repository / Cross-slice" #LightYellow
participant "AuthRepository" as AuthRepo
participant "EmailDispatcher\n(Features.Emails)" as EmailDispatcher
end box
database "SQL Server" as DB
== Registration Flow ==
User -> API: POST /api/auth/register\n{username, firstName, lastName,\nemail, dateOfBirth, password}
activate API
API -> Mediator: Send(RegisterUserCommand)
activate Mediator
note right of Mediator
ValidationBehavior runs RegisterUserValidator
(FluentValidation) before the handler:
- Username: 3-64 chars, alphanumeric + [._-]
- Email: valid format, max 128 chars
- Password: min 8 chars, uppercase,\n lowercase, number, special char
- DateOfBirth: must be 19+ years old
end note
alt Validation fails
Mediator -> API: throws FluentValidation.ValidationException
API -> User: 400 Bad Request\n{message: "Validation failed", errors: {...}}
else Validation succeeds
Mediator -> RegHandler: Handle(command)
activate RegHandler
RegHandler -> AuthRepo: GetUserByUsernameAsync(username)
activate AuthRepo
AuthRepo -> DB: SELECT UserAccountID, Username, ...\nFROM dbo.UserAccount\nWHERE Username = @Username
activate DB
DB --> AuthRepo: null (user doesn't exist)
deactivate DB
deactivate AuthRepo
RegHandler -> AuthRepo: GetUserByEmailAsync(email)
activate AuthRepo
AuthRepo -> DB: SELECT UserAccountID, Username, ...\nFROM dbo.UserAccount\nWHERE Email = @Email
activate DB
DB --> AuthRepo: null (email doesn't exist)
deactivate DB
deactivate AuthRepo
alt Username or email already exists
RegHandler -> Mediator: throw ConflictException
Mediator -> API: propagate
API -> User: 409 Conflict\n"Username or email already exists"
else Username and email are free
RegHandler -> Argon2: Hash(password)
activate Argon2
note right of Argon2
Argon2id parameters:
- Salt: 16 bytes (128-bit)
- Memory: 64MB
- Iterations: 4
- Parallelism: CPU count
- Hash output: 32 bytes
end note
Argon2 -> Argon2: Generate random salt\n(16 bytes)
Argon2 -> Argon2: Hash password with\nArgon2id algorithm
Argon2 --> RegHandler: "base64(salt):base64(hash)"
deactivate Argon2
RegHandler -> AuthRepo: RegisterUserAsync(userAccount)
activate AuthRepo
AuthRepo -> DB: BEGIN TRANSACTION
activate DB
AuthRepo -> DB: INSERT INTO dbo.UserAccount\n(Username, FirstName, LastName,\nDateOfBirth, Email)\nOUTPUT INSERTED.UserAccountID
DB --> AuthRepo: UserAccountId (GUID)
AuthRepo -> DB: INSERT INTO dbo.UserCredential\n(UserAccountId, Hash)
AuthRepo -> DB: COMMIT TRANSACTION
deactivate DB
note right of AuthRepo
All three statements run via Dapper's
ExecuteScalarAsync/ExecuteAsync against
a single DbTransaction, not a stored
procedure - no manual parameter binding
end note
AuthRepo -> DB: SELECT UserAccountID, Username, ...\nFROM dbo.UserAccount\nWHERE UserAccountID = @UserAccountId
activate DB
note right of DB
Round trip to load the row the
INSERT just created
end note
DB --> AuthRepo: UserAccount row
deactivate DB
AuthRepo --> RegHandler: UserAccount entity
deactivate AuthRepo
RegHandler -> TokenSvc: GenerateAccessToken(user)
activate TokenSvc
TokenSvc -> JWT: GenerateJwt(userId, username, expiry)
activate JWT
note right of JWT
JWT Configuration:
- Algorithm: HS256
- Expires: 1 hour
- Claims:
* sub: userId
* unique_name: username
* iat / jti
end note
JWT -> JWT: Create JWT with claims
JWT -> JWT: 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
Refresh Token:
- Expires: 21 days (504 hours)
- Same structure as access token,\n signed with a separate secret
end note
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
Confirmation Token:
- Expires: 30 minutes (0.5 hours)
- Signed with a third, separate secret
end note
JWT --> TokenSvc: Confirmation Token
deactivate JWT
TokenSvc --> RegHandler: Confirmation Token
deactivate TokenSvc
RegHandler -> Mediator: Send(SendRegistrationEmailCommand)\n(Shared.Application cross-slice contract)
activate Mediator
Mediator -> EmailDispatcher: SendRegistrationEmailAsync(\n firstName, email, confirmationToken)
note right of Mediator
Routed to Features.Emails'
SendRegistrationEmailHandler,
the only cross-slice call in
the backend
end note
activate EmailDispatcher
EmailDispatcher -> Template: RenderUserRegisteredEmailAsync(\n firstName, confirmationLink)
activate Template
note right of Template
Razor Component:
- Header with branding
- Welcome message
- Confirmation button
- Footer
end note
Template -> Template: Render Razor component\nto HTML
Template --> EmailDispatcher: HTML email content
deactivate Template
EmailDispatcher -> SMTP: SendAsync(email, subject, body)
activate SMTP
note right of SMTP
SMTP Configuration (MailKit):
- Host/port: from env (SMTP_HOST/SMTP_PORT)
- TLS: StartTLS when SMTP_USE_SSL=true
- Auth: username/password, if configured
end note
SMTP -> SMTP: Build MIME message
SMTP -> SMTP: Connect, authenticate,\nsend, 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),\nlog to stderr,\nemailSent = false
end
RegHandler --> Mediator: RegistrationPayload(\n userAccountId, username,\n refreshToken, accessToken,\n confirmationEmailSent)
deactivate RegHandler
Mediator --> API: RegistrationPayload
deactivate Mediator
API -> User: 201 Created\n{\n message: "User registered successfully.",\n payload: {\n userAccountId, username,\n refreshToken, accessToken,\n confirmationEmailSent\n }\n}
end
end
deactivate API
== Login Flow ==
User -> API: POST /api/auth/login\n{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
end note
alt Validation fails
Mediator -> API: throws FluentValidation.ValidationException
API -> User: 400 Bad Request\n{message: "Validation failed", errors: {...}}
else Validation succeeds
Mediator -> LoginHandler: Handle(command)
activate LoginHandler
LoginHandler -> AuthRepo: GetUserByUsernameAsync(username)
activate AuthRepo
AuthRepo -> DB: SELECT UserAccountID, Username, ...\nFROM dbo.UserAccount\nWHERE Username = @Username
activate DB
DB --> AuthRepo: UserAccount row or null
deactivate DB
deactivate AuthRepo
alt User not found
LoginHandler -> Mediator: throw UnauthorizedException\n"Invalid username or password."
Mediator -> API: propagate
API -> User: 401 Unauthorized
else User found
LoginHandler -> AuthRepo: GetActiveCredentialByUserAccountIdAsync(userId)
activate AuthRepo
AuthRepo -> DB: SELECT UserCredentialId, UserAccountId,\nHash, CreatedAt, RowVersion\nFROM dbo.UserCredential\nWHERE UserAccountId = @UserAccountId\n AND IsRevoked = 0
activate DB
DB --> AuthRepo: UserCredential row or null
deactivate DB
deactivate AuthRepo
alt No active credential
LoginHandler -> Mediator: throw UnauthorizedException
Mediator -> API: propagate
API -> User: 401 Unauthorized
else Active credential found
LoginHandler -> Argon2: Verify(password, storedHash)
activate Argon2
note right of Argon2
1. Split stored hash: "salt:hash"
2. Extract salt
3. Hash provided password\n with same salt
4. Constant-time comparison\n (CryptographicOperations.FixedTimeEquals)
end note
Argon2 -> Argon2: Parse salt from stored hash
Argon2 -> Argon2: Hash provided password\nwith extracted salt
Argon2 -> Argon2: FixedTimeEquals(\n computed, stored)
Argon2 --> LoginHandler: true/false
deactivate Argon2
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(\n userAccountId, username,\n refreshToken, accessToken)
deactivate LoginHandler
Mediator --> API: LoginPayload
deactivate Mediator
API -> User: 200 OK\n{\n message: "Logged in successfully.",\n payload: {\n userAccountId, username,\n refreshToken, accessToken\n }\n}
end
end
end
end
deactivate API
== Error Handling (Global Exception Filter) ==
note over API
API.Core's GlobalExceptionFilter catches:
- FluentValidation.ValidationException -> 400 Bad Request\n {message, errors} (grouped by property)
- ConflictException -> 409 Conflict
- NotFoundException -> 404 Not Found
- UnauthorizedException -> 401 Unauthorized
- ForbiddenException -> 403 Forbidden
- Domain.Exceptions.ValidationException -> 400 Bad Request
- Microsoft.Data.SqlClient.SqlException -> 503 Service Unavailable
- All others -> 500 Internal Server Error
end note
@enduml