@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