mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-15 10:04:44 +00:00
Remove svgs, update puml diagrams -- separate auth diagram into multiple diagrams
This commit is contained in:
@@ -32,3 +32,24 @@ skinparam interfaceFontColor #14180C
|
||||
skinparam enumBackgroundColor #E4E6D8
|
||||
skinparam enumBorderColor #4A5837
|
||||
skinparam enumFontColor #14180C
|
||||
skinparam ParticipantBackgroundColor #EBECE3
|
||||
skinparam ParticipantBorderColor #4A5837
|
||||
skinparam ParticipantFontColor #14180C
|
||||
skinparam ActorBackgroundColor #DBEEDD
|
||||
skinparam ActorBorderColor #4A5837
|
||||
skinparam ActorFontColor #14180C
|
||||
skinparam LifeLineBorderColor #4A5837
|
||||
skinparam LifeLineBackgroundColor #F1F3EA
|
||||
skinparam DatabaseBackgroundColor #EBECE3
|
||||
skinparam DatabaseBorderColor #4A5837
|
||||
skinparam DatabaseFontColor #14180C
|
||||
skinparam SequenceGroupBackgroundColor #DBEEDD
|
||||
skinparam SequenceGroupBorderColor #4A5837
|
||||
skinparam SequenceGroupFontColor #14180C
|
||||
skinparam SequenceGroupBodyBackgroundColor transparent
|
||||
skinparam SequenceDividerBackgroundColor #DBEEDD
|
||||
skinparam SequenceDividerBorderColor #4A5837
|
||||
skinparam SequenceDividerFontColor #14180C
|
||||
skinparam SequenceBoxBackgroundColor #F1F3EA
|
||||
skinparam SequenceBoxBorderColor #4A5837
|
||||
skinparam SequenceBoxFontColor #14180C
|
||||
|
||||
@@ -1,22 +1,9 @@
|
||||
@startuml
|
||||
!include ../biergarten-weizen-theme.puml
|
||||
skinparam style strictuml
|
||||
skinparam defaultFontName "DM Sans"
|
||||
skinparam defaultFontSize 14
|
||||
skinparam titleFontName "Volkhov"
|
||||
skinparam titleFontSize 20
|
||||
skinparam backgroundColor #FAFCF9
|
||||
skinparam defaultFontColor #28342A
|
||||
skinparam titleFontColor #28342A
|
||||
skinparam ArrowColor #628A5B
|
||||
skinparam NoteBackgroundColor #EAF0E8
|
||||
skinparam NoteBorderColor #547461
|
||||
skinparam ActivityBackgroundColor #FAFCF9
|
||||
skinparam ActivityBorderColor #547461
|
||||
skinparam ActivityDiamondBackgroundColor #FAFCF9
|
||||
skinparam ActivityDiamondBorderColor #628A5B
|
||||
skinparam ActivityBarColor #628A5B
|
||||
skinparam SwimlaneBorderColor #547461
|
||||
skinparam SwimlaneBorderThickness 0.3
|
||||
|
||||
title The Biergarten Data Pipeline (Current — Synchronous Data Path)
|
||||
|
||||
|
||||
@@ -1,27 +1,8 @@
|
||||
@startuml
|
||||
!include ../biergarten-weizen-theme.puml
|
||||
skinparam style strictuml
|
||||
skinparam defaultFontName "DM Sans"
|
||||
skinparam defaultFontSize 14
|
||||
skinparam titleFontName "Volkhov"
|
||||
skinparam titleFontSize 20
|
||||
skinparam backgroundColor #FAFCF9
|
||||
skinparam defaultFontColor #28342A
|
||||
skinparam titleFontColor #28342A
|
||||
skinparam ArrowColor #628A5B
|
||||
|
||||
skinparam class {
|
||||
BackgroundColor #FAFCF9
|
||||
HeaderBackgroundColor #EAF0E8
|
||||
BorderColor #547461
|
||||
ArrowColor #628A5B
|
||||
FontColor #28342A
|
||||
}
|
||||
|
||||
skinparam note {
|
||||
BackgroundColor #EAF0E8
|
||||
BorderColor #547461
|
||||
FontColor #28342A
|
||||
}
|
||||
|
||||
title The Biergarten Data Pipeline - Class Diagram
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 142 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 240 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 50 KiB |
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 76 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 108 KiB |
@@ -1,12 +1,9 @@
|
||||
@startuml architecture
|
||||
!theme plain
|
||||
skinparam backgroundColor #FFFFFF
|
||||
skinparam defaultFontName Arial
|
||||
skinparam packageStyle rectangle
|
||||
!include ../../pipeline/diagrams/biergarten-weizen-theme.puml
|
||||
|
||||
title The Biergarten App - Vertical Slice Architecture
|
||||
|
||||
package "API.Core (thin host)" #E3F2FD {
|
||||
package "API.Core (thin host)" #F1F3EA {
|
||||
[API.Core\nASP.NET Core Web API host] as API
|
||||
note right of API
|
||||
- Program.cs wiring only
|
||||
@@ -18,7 +15,7 @@ package "API.Core (thin host)" #E3F2FD {
|
||||
end note
|
||||
}
|
||||
|
||||
package "Feature Slices" #F3E5F5 {
|
||||
package "Feature Slices" #DBEEDD {
|
||||
[Features.Users] as UsersSlice
|
||||
[Features.Breweries] as BrewerySlice
|
||||
[Features.Emails] as EmailsSlice
|
||||
@@ -50,7 +47,7 @@ package "Feature Slices" #F3E5F5 {
|
||||
end note
|
||||
}
|
||||
|
||||
package "Shared" #FCE4EC {
|
||||
package "Shared" #E4E6D8 {
|
||||
[Shared.Contracts] as SharedContracts
|
||||
[Shared.Application] as SharedApp
|
||||
note right of SharedApp
|
||||
@@ -62,7 +59,7 @@ package "Shared" #FCE4EC {
|
||||
end note
|
||||
}
|
||||
|
||||
package "Infrastructure Layer" #FFF3E0 {
|
||||
package "Infrastructure Layer" #EBECE3 {
|
||||
[Infrastructure.Sql] as Sql
|
||||
[Infrastructure.Jwt] as JWT
|
||||
[Infrastructure.PasswordHashing] as PwdHash
|
||||
@@ -70,7 +67,7 @@ package "Infrastructure Layer" #FFF3E0 {
|
||||
[Infrastructure.Email.Templates] as EmailTemplates
|
||||
}
|
||||
|
||||
package "Domain Layer" #E8F5E9 {
|
||||
package "Domain Layer" #CBD2B5 {
|
||||
[Domain.Entities] as Domain
|
||||
[Domain.Exceptions] as DomainExceptions
|
||||
note right of Domain
|
||||
|
||||
109
docs/website/diagrams-src/auth/confirm-user.puml
Normal file
109
docs/website/diagrams-src/auth/confirm-user.puml
Normal file
@@ -0,0 +1,109 @@
|
||||
@startuml confirm-user
|
||||
!include ../../../pipeline/diagrams/biergarten-weizen-theme.puml
|
||||
skinparam sequenceMessageAlign center
|
||||
skinparam maxMessageSize 200
|
||||
|
||||
title User Authentication Flow - Confirm User
|
||||
|
||||
actor User
|
||||
participant "API\nController" as API
|
||||
participant "MediatR pipeline\n(ValidationBehavior)" as Mediator
|
||||
box "Handler Layer" #F1F3EA
|
||||
participant "ConfirmUserHandler" as ConfirmHandler
|
||||
participant "UserManager<ApplicationUser>" as UserMgr
|
||||
participant "TokenService" as TokenSvc
|
||||
end box
|
||||
box "Infrastructure Layer" #EBECE3
|
||||
participant "JWT\nInfrastructure" as JWT
|
||||
end box
|
||||
box "Repository / Cross-slice" #DBEEDD
|
||||
participant "DapperUserStore\n(IUserStore/IUserPasswordStore/\nIUserEmailStore)" as UserStore
|
||||
end box
|
||||
database "SQL Server" as DB
|
||||
|
||||
note over UserMgr
|
||||
ASP.NET Core Identity's UserManager<TUser>
|
||||
replaces the deleted AuthRepository: handlers
|
||||
no longer issue SQL or call Argon2 directly,
|
||||
they delegate to UserManager, which in turn
|
||||
calls DapperUserStore (persistence) and
|
||||
Argon2PasswordHasher (IPasswordHasher<TUser>).
|
||||
end note
|
||||
|
||||
User -> API: POST /api/auth/confirm?token=<token>\n(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\ntoken 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 ...\nFROM dbo.UserAccount\nWHERE 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\n"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.
|
||||
end note
|
||||
|
||||
ConfirmHandler -> UserMgr: UpdateAsync(user)
|
||||
activate UserMgr
|
||||
UserMgr -> UserStore: UpdateAsync(user)
|
||||
activate UserStore
|
||||
UserStore -> DB: UPDATE dbo.UserAccount SET ...
|
||||
UserStore -> DB: INSERT INTO dbo.UserVerification\n(UserAccountId, VerificationDateTime)
|
||||
note right of UserStore
|
||||
Idempotent: skipped (duplicate-key
|
||||
exception swallowed) if a verification
|
||||
row already exists.
|
||||
end note
|
||||
UserStore --> UserMgr: IdentityResult.Success
|
||||
deactivate UserStore
|
||||
UserMgr --> ConfirmHandler: IdentityResult
|
||||
deactivate UserMgr
|
||||
|
||||
ConfirmHandler --> Mediator: ConfirmationPayload(\n user.Id, DateTime.UtcNow)
|
||||
deactivate ConfirmHandler
|
||||
Mediator --> API: ConfirmationPayload
|
||||
deactivate Mediator
|
||||
|
||||
API -> User: 200 OK\n{message: "User with ID {id} is confirmed.",\npayload: {...}}
|
||||
end
|
||||
end
|
||||
deactivate API
|
||||
|
||||
@enduml
|
||||
91
docs/website/diagrams-src/auth/delete-account.puml
Normal file
91
docs/website/diagrams-src/auth/delete-account.puml
Normal file
@@ -0,0 +1,91 @@
|
||||
@startuml delete-account
|
||||
!include ../../../pipeline/diagrams/biergarten-weizen-theme.puml
|
||||
skinparam sequenceMessageAlign center
|
||||
skinparam maxMessageSize 200
|
||||
|
||||
title User Authentication Flow - Delete Account
|
||||
|
||||
actor User
|
||||
participant "API\nController" as API
|
||||
participant "MediatR pipeline\n(ValidationBehavior)" as Mediator
|
||||
box "Handler Layer" #F1F3EA
|
||||
participant "DeleteAccountHandler" as DeleteAccountHandler
|
||||
participant "UserManager<ApplicationUser>" as UserMgr
|
||||
end box
|
||||
box "Repository / Cross-slice" #DBEEDD
|
||||
participant "DapperUserStore\n(IUserStore/IUserPasswordStore/\nIUserEmailStore)" as UserStore
|
||||
end box
|
||||
database "SQL Server" as DB
|
||||
|
||||
note over UserMgr
|
||||
ASP.NET Core Identity's UserManager<TUser>
|
||||
replaces the deleted AuthRepository: handlers
|
||||
no longer issue SQL or call Argon2 directly,
|
||||
they delegate to UserManager, which in turn
|
||||
calls DapperUserStore (persistence) and
|
||||
Argon2PasswordHasher (IPasswordHasher<TUser>).
|
||||
end note
|
||||
|
||||
User -> API: DELETE /api/auth/account\n(requires Bearer access token)
|
||||
activate API
|
||||
|
||||
API -> Mediator: Send(DeleteAccountCommand(\n 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 dbo.UserAccount\nWHERE 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).
|
||||
end note
|
||||
|
||||
alt Foreign key violation (dependent records exist)
|
||||
UserStore --> UserMgr: IdentityResult.Failed\n(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\n"Account cannot be deleted while it still\nhas associated posts, comments, photos,\nor 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\n{message: "Account deleted successfully."}
|
||||
end
|
||||
end
|
||||
deactivate API
|
||||
|
||||
@enduml
|
||||
30
docs/website/diagrams-src/auth/error-handling.puml
Normal file
30
docs/website/diagrams-src/auth/error-handling.puml
Normal file
@@ -0,0 +1,30 @@
|
||||
@startuml error-handling
|
||||
!include ../../../pipeline/diagrams/biergarten-weizen-theme.puml
|
||||
skinparam sequenceMessageAlign center
|
||||
skinparam maxMessageSize 200
|
||||
|
||||
title User Authentication Flow - Error Handling (Global Exception Filter)
|
||||
|
||||
participant "API\nController" as API
|
||||
|
||||
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
|
||||
|
||||
note over API
|
||||
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.
|
||||
end note
|
||||
|
||||
@enduml
|
||||
129
docs/website/diagrams-src/auth/login.puml
Normal file
129
docs/website/diagrams-src/auth/login.puml
Normal file
@@ -0,0 +1,129 @@
|
||||
@startuml login
|
||||
!include ../../../pipeline/diagrams/biergarten-weizen-theme.puml
|
||||
skinparam sequenceMessageAlign center
|
||||
skinparam maxMessageSize 200
|
||||
|
||||
title User Authentication Flow - Login
|
||||
|
||||
actor User
|
||||
participant "API\nController" as API
|
||||
participant "MediatR pipeline\n(ValidationBehavior)" as Mediator
|
||||
box "Handler Layer" #F1F3EA
|
||||
participant "LoginHandler" as LoginHandler
|
||||
participant "UserManager<ApplicationUser>" as UserMgr
|
||||
participant "TokenService" as TokenSvc
|
||||
end box
|
||||
box "Infrastructure Layer" #EBECE3
|
||||
participant "Argon2PasswordHasher" as Argon2
|
||||
participant "JWT\nInfrastructure" as JWT
|
||||
end box
|
||||
box "Repository / Cross-slice" #DBEEDD
|
||||
participant "DapperUserStore\n(IUserStore/IUserPasswordStore/\nIUserEmailStore)" as UserStore
|
||||
end box
|
||||
database "SQL Server" as DB
|
||||
|
||||
note over UserMgr
|
||||
ASP.NET Core Identity's UserManager<TUser>
|
||||
replaces the deleted AuthRepository: handlers
|
||||
no longer issue SQL or call Argon2 directly,
|
||||
they delegate to UserManager, which in turn
|
||||
calls DapperUserStore (persistence) and
|
||||
Argon2PasswordHasher (IPasswordHasher<TUser>).
|
||||
end note
|
||||
|
||||
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 -> UserMgr: FindByNameAsync(username)
|
||||
activate UserMgr
|
||||
UserMgr -> UserStore: FindByNameAsync(username)
|
||||
activate UserStore
|
||||
UserStore -> DB: SELECT UserAccountID, Username, ...\nFROM dbo.UserAccount\nWHERE Username = @Username
|
||||
activate DB
|
||||
DB --> UserStore: UserAccount row or null
|
||||
deactivate DB
|
||||
UserStore -> UserStore: HydrateAsync\n(load active UserCredential.Hash,\nUserVerification -> EmailConfirmed)
|
||||
UserStore --> UserMgr: ApplicationUser or null
|
||||
deactivate UserStore
|
||||
UserMgr --> LoginHandler: ApplicationUser or null
|
||||
deactivate UserMgr
|
||||
|
||||
alt User not found
|
||||
LoginHandler -> Mediator: throw UnauthorizedException\n"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 UserCredential was hydrated)
|
||||
without calling the hasher; otherwise
|
||||
delegates to Argon2PasswordHasher.
|
||||
end note
|
||||
UserMgr -> Argon2: VerifyHashedPassword(\n user, user.PasswordHash, password)
|
||||
activate Argon2
|
||||
note right of Argon2
|
||||
Delegates to IPasswordInfrastructure.Verify:
|
||||
1. Split stored hash: "salt:hash"
|
||||
2. Hash provided password with the same salt
|
||||
3. Constant-time comparison\n (CryptographicOperations.FixedTimeEquals)
|
||||
end note
|
||||
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(\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
|
||||
deactivate API
|
||||
|
||||
@enduml
|
||||
108
docs/website/diagrams-src/auth/refresh-token.puml
Normal file
108
docs/website/diagrams-src/auth/refresh-token.puml
Normal file
@@ -0,0 +1,108 @@
|
||||
@startuml refresh-token
|
||||
!include ../../../pipeline/diagrams/biergarten-weizen-theme.puml
|
||||
skinparam sequenceMessageAlign center
|
||||
skinparam maxMessageSize 200
|
||||
|
||||
title User Authentication Flow - Refresh Token
|
||||
|
||||
actor User
|
||||
participant "API\nController" as API
|
||||
participant "MediatR pipeline\n(ValidationBehavior)" as Mediator
|
||||
box "Handler Layer" #F1F3EA
|
||||
participant "RefreshTokenHandler" as RefreshHandler
|
||||
participant "UserManager<ApplicationUser>" as UserMgr
|
||||
participant "TokenService" as TokenSvc
|
||||
end box
|
||||
box #EBECE3
|
||||
participant "JWT\nInfrastructure" as JWT
|
||||
end box
|
||||
box "Repository / Cross-slice" #DBEEDD
|
||||
participant "DapperUserStore\n(IUserStore/IUserPasswordStore/\nIUserEmailStore)" as UserStore
|
||||
end box
|
||||
database "SQL Server" as DB
|
||||
|
||||
note over UserMgr
|
||||
ASP.NET Core Identity's UserManager<TUser>
|
||||
replaces the deleted AuthRepository: handlers
|
||||
no longer issue SQL or call Argon2 directly,
|
||||
they delegate to UserManager, which in turn
|
||||
calls DapperUserStore (persistence) and
|
||||
Argon2PasswordHasher (IPasswordHasher<TUser>).
|
||||
end note
|
||||
|
||||
User -> API: POST /api/auth/refresh\nHeader: 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.
|
||||
end note
|
||||
|
||||
alt Validation fails
|
||||
Mediator -> API: throws FluentValidation.ValidationException
|
||||
API -> User: 400 Bad Request\n{message: "Validation failed", 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.
|
||||
end note
|
||||
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 UserAccountID, Username, ...\nFROM dbo.UserAccount\nWHERE 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\n"User account not found"
|
||||
RefreshHandler -> Mediator: propagate
|
||||
Mediator -> API: propagate
|
||||
API -> User: 401 Unauthorized
|
||||
else User found
|
||||
TokenSvc -> JWT: GenerateAccessToken(user)\nGenerateRefreshToken(user)
|
||||
JWT --> TokenSvc: new access + refresh tokens
|
||||
TokenSvc --> RefreshHandler: RefreshTokenResult
|
||||
deactivate TokenSvc
|
||||
|
||||
RefreshHandler --> Mediator: LoginPayload(\n userId, username,\n refreshToken, accessToken)
|
||||
deactivate RefreshHandler
|
||||
Mediator --> API: LoginPayload
|
||||
deactivate Mediator
|
||||
|
||||
API -> User: 200 OK\n{message: "Token refreshed successfully.",\npayload: {...}}
|
||||
end
|
||||
end
|
||||
end
|
||||
deactivate API
|
||||
|
||||
@enduml
|
||||
241
docs/website/diagrams-src/auth/registration.puml
Normal file
241
docs/website/diagrams-src/auth/registration.puml
Normal file
@@ -0,0 +1,241 @@
|
||||
@startuml registration
|
||||
!include ../../../pipeline/diagrams/biergarten-weizen-theme.puml
|
||||
skinparam sequenceMessageAlign center
|
||||
skinparam maxMessageSize 200
|
||||
|
||||
title User Authentication Flow - Registration
|
||||
|
||||
actor User
|
||||
participant "API\nController" as API
|
||||
participant "MediatR pipeline\n(ValidationBehavior)" as Mediator
|
||||
box "Handler Layer" #F1F3EA
|
||||
participant "RegisterUserHandler" as RegHandler
|
||||
participant "UserManager<ApplicationUser>" as UserMgr
|
||||
participant "TokenService" as TokenSvc
|
||||
end box
|
||||
box "Infrastructure Layer" #EBECE3
|
||||
participant "Argon2PasswordHasher" as Argon2
|
||||
participant "JWT\nInfrastructure" as JWT
|
||||
participant "Email\nProvider" as SMTP
|
||||
participant "Template\nProvider" as Template
|
||||
end box
|
||||
box "Repository / Cross-slice" #DBEEDD
|
||||
participant "DapperUserStore\n(IUserStore/IUserPasswordStore/\nIUserEmailStore)" as UserStore
|
||||
participant "EmailDispatcher\n(Features.Emails)" as EmailDispatcher
|
||||
end box
|
||||
database "SQL Server" as DB
|
||||
|
||||
note over UserMgr
|
||||
ASP.NET Core Identity's UserManager<TUser>
|
||||
replaces the deleted AuthRepository: handlers
|
||||
no longer issue SQL or call Argon2 directly,
|
||||
they delegate to UserManager, which in turn
|
||||
calls DapperUserStore (persistence) and
|
||||
Argon2PasswordHasher (IPasswordHasher<TUser>).
|
||||
end note
|
||||
|
||||
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 -> RegHandler: user = new UserAccount{...}\n.ToApplicationUser()
|
||||
|
||||
RegHandler -> UserMgr: CreateAsync(user, password)
|
||||
activate UserMgr
|
||||
|
||||
UserMgr -> Argon2: HashPassword(user, password)
|
||||
activate Argon2
|
||||
note right of Argon2
|
||||
Delegates to IPasswordInfrastructure.Hash:
|
||||
Argon2id parameters:
|
||||
- Salt: 16 bytes (128-bit)
|
||||
- Memory: 64MB
|
||||
- Iterations: 4
|
||||
- Parallelism: CPU count
|
||||
- Hash output: 32 bytes
|
||||
end note
|
||||
Argon2 --> UserMgr: "base64(salt):base64(hash)"
|
||||
deactivate Argon2
|
||||
|
||||
UserMgr -> UserStore: SetPasswordHashAsync(user, hash)
|
||||
note right of UserStore
|
||||
user.Id is still empty at this point -
|
||||
only sets the in-memory hash. CreateAsync
|
||||
writes it to dbo.UserCredential itself.
|
||||
end note
|
||||
|
||||
UserMgr -> UserStore: FindByNameAsync(username)
|
||||
activate UserStore
|
||||
UserStore -> DB: SELECT UserAccountID, Username, ...\nFROM dbo.UserAccount\nWHERE Username = @Username
|
||||
activate DB
|
||||
DB --> UserStore: null (user doesn't exist)
|
||||
deactivate DB
|
||||
deactivate UserStore
|
||||
|
||||
UserMgr -> UserStore: FindByEmailAsync(email)
|
||||
activate UserStore
|
||||
UserStore -> DB: SELECT UserAccountID, Username, ...\nFROM dbo.UserAccount\nWHERE 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 is called;
|
||||
DapperUserStore.CreateAsync also guards
|
||||
against a genuine race via a caught
|
||||
duplicate-key SqlException (2601/2627).
|
||||
end note
|
||||
|
||||
UserMgr -> UserStore: CreateAsync(user)
|
||||
activate UserStore
|
||||
|
||||
UserStore -> DB: BEGIN TRANSACTION
|
||||
activate DB
|
||||
UserStore -> DB: INSERT INTO dbo.UserAccount\n(Username, FirstName, LastName,\nDateOfBirth, Email)\nOUTPUT INSERTED.UserAccountID
|
||||
DB --> UserStore: UserAccountId (GUID)
|
||||
UserStore -> DB: INSERT INTO dbo.UserCredential\n(UserAccountId, Hash)
|
||||
UserStore -> DB: COMMIT TRANSACTION
|
||||
deactivate DB
|
||||
note right of UserStore
|
||||
Both statements run via Dapper's
|
||||
ExecuteScalarAsync/ExecuteAsync against
|
||||
a single DbTransaction, not a stored
|
||||
procedure - no manual parameter binding
|
||||
end note
|
||||
|
||||
UserStore --> UserMgr: IdentityResult.Success\n(user.Id set)
|
||||
deactivate UserStore
|
||||
|
||||
UserMgr --> RegHandler: IdentityResult
|
||||
deactivate UserMgr
|
||||
|
||||
alt !result.Succeeded
|
||||
RegHandler -> Mediator: throw ConflictException\n(DuplicateUserName/DuplicateEmail codes ->\n"Username or email already exists";\notherwise joined error descriptions)
|
||||
Mediator -> API: propagate
|
||||
API -> User: 409 Conflict\n"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
|
||||
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
|
||||
|
||||
@enduml
|
||||
97
docs/website/diagrams-src/auth/resend-confirmation.puml
Normal file
97
docs/website/diagrams-src/auth/resend-confirmation.puml
Normal file
@@ -0,0 +1,97 @@
|
||||
@startuml resend-confirmation
|
||||
!include ../../../pipeline/diagrams/biergarten-weizen-theme.puml
|
||||
skinparam sequenceMessageAlign center
|
||||
skinparam maxMessageSize 200
|
||||
|
||||
title User Authentication Flow - Resend Confirmation Email
|
||||
|
||||
actor User
|
||||
participant "API\nController" as API
|
||||
participant "MediatR pipeline\n(ValidationBehavior)" as Mediator
|
||||
box "Handler Layer" #F1F3EA
|
||||
participant "ResendConfirmationEmailHandler" as ResendHandler
|
||||
participant "UserManager<ApplicationUser>" as UserMgr
|
||||
participant "TokenService" as TokenSvc
|
||||
end box
|
||||
box "Infrastructure Layer" #EBECE3
|
||||
participant "Email\nProvider" as SMTP
|
||||
participant "Template\nProvider" as Template
|
||||
end box
|
||||
box "Repository / Cross-slice" #DBEEDD
|
||||
participant "DapperUserStore\n(IUserStore/IUserPasswordStore/\nIUserEmailStore)" as UserStore
|
||||
participant "EmailDispatcher\n(Features.Emails)" as EmailDispatcher
|
||||
end box
|
||||
database "SQL Server" as DB
|
||||
|
||||
note over UserMgr
|
||||
ASP.NET Core Identity's UserManager<TUser>
|
||||
replaces the deleted AuthRepository: handlers
|
||||
no longer issue SQL or call Argon2 directly,
|
||||
they delegate to UserManager, which in turn
|
||||
calls DapperUserStore (persistence) and
|
||||
Argon2PasswordHasher (IPasswordHasher<TUser>).
|
||||
end note
|
||||
|
||||
User -> API: POST /api/auth/confirm/resend?userId=<id>\n(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 ...\nFROM dbo.UserAccount\nWHERE 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\n(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(\n firstName, email, confirmationToken)
|
||||
activate EmailDispatcher
|
||||
EmailDispatcher -> Template: Render email\n(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\n{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.
|
||||
end note
|
||||
deactivate API
|
||||
|
||||
@enduml
|
||||
93
docs/website/diagrams-src/auth/update-email.puml
Normal file
93
docs/website/diagrams-src/auth/update-email.puml
Normal file
@@ -0,0 +1,93 @@
|
||||
@startuml update-email
|
||||
!include ../../../pipeline/diagrams/biergarten-weizen-theme.puml
|
||||
skinparam sequenceMessageAlign center
|
||||
skinparam maxMessageSize 200
|
||||
|
||||
title User Authentication Flow - Update Email
|
||||
|
||||
actor User
|
||||
participant "API\nController" as API
|
||||
participant "MediatR pipeline\n(ValidationBehavior)" as Mediator
|
||||
box "Handler Layer" #F1F3EA
|
||||
participant "UpdateEmailHandler" as UpdateEmailHandler
|
||||
participant "UserManager<ApplicationUser>" as UserMgr
|
||||
end box
|
||||
box "Repository / Cross-slice" #DBEEDD
|
||||
participant "DapperUserStore\n(IUserStore/IUserPasswordStore/\nIUserEmailStore)" as UserStore
|
||||
end box
|
||||
database "SQL Server" as DB
|
||||
|
||||
note over UserMgr
|
||||
ASP.NET Core Identity's UserManager<TUser>
|
||||
replaces the deleted AuthRepository: handlers
|
||||
no longer issue SQL or call Argon2 directly,
|
||||
they delegate to UserManager, which in turn
|
||||
calls DapperUserStore (persistence) and
|
||||
Argon2PasswordHasher (IPasswordHasher<TUser>).
|
||||
end note
|
||||
|
||||
User -> API: PATCH /api/auth/email\n{newEmail}\n(requires Bearer access token)
|
||||
activate API
|
||||
|
||||
API -> Mediator: Send(UpdateEmailCommand(\n GetAuthenticatedUserId(), newEmail))
|
||||
activate Mediator
|
||||
|
||||
note right of Mediator
|
||||
ValidationBehavior runs UpdateEmailValidator
|
||||
(same format rules as registration)
|
||||
end note
|
||||
|
||||
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 -
|
||||
the caller must re-confirm via
|
||||
POST /api/auth/confirm/resend.
|
||||
end note
|
||||
UserMgr -> UserStore: UpdateAsync(user)
|
||||
UserStore -> DB: UPDATE dbo.UserAccount\nSET Email = @Email, ...
|
||||
UserStore -> DB: DELETE FROM dbo.UserVerification\nWHERE UserAccountID = @Id
|
||||
UserStore --> UserMgr: IdentityResult
|
||||
UserMgr --> UpdateEmailHandler: IdentityResult
|
||||
deactivate UserMgr
|
||||
|
||||
alt !result.Succeeded
|
||||
UpdateEmailHandler -> Mediator: throw ConflictException\n(DuplicateEmail code ->\n"Email address already exists")
|
||||
Mediator -> API: propagate
|
||||
API -> User: 409 Conflict
|
||||
else result.Succeeded
|
||||
UpdateEmailHandler --> Mediator: UpdateEmailPayload(\n user.Id, user.Email,\n user.EmailConfirmed = false)
|
||||
deactivate UpdateEmailHandler
|
||||
Mediator --> API: UpdateEmailPayload
|
||||
deactivate Mediator
|
||||
API -> User: 200 OK\n{message: "Email updated successfully.",\npayload: {...}}
|
||||
end
|
||||
end
|
||||
end
|
||||
deactivate API
|
||||
|
||||
@enduml
|
||||
111
docs/website/diagrams-src/auth/update-password.puml
Normal file
111
docs/website/diagrams-src/auth/update-password.puml
Normal file
@@ -0,0 +1,111 @@
|
||||
@startuml update-password
|
||||
!include ../../../pipeline/diagrams/biergarten-weizen-theme.puml
|
||||
skinparam sequenceMessageAlign center
|
||||
skinparam maxMessageSize 200
|
||||
|
||||
title User Authentication Flow - Update Password
|
||||
|
||||
actor User
|
||||
participant "API\nController" as API
|
||||
participant "MediatR pipeline\n(ValidationBehavior)" as Mediator
|
||||
box "Handler Layer" #F1F3EA
|
||||
participant "UpdatePasswordHandler" as UpdatePasswordHandler
|
||||
participant "UserManager<ApplicationUser>" as UserMgr
|
||||
end box
|
||||
box "Infrastructure Layer" #EBECE3
|
||||
participant "Argon2PasswordHasher" as Argon2
|
||||
end box
|
||||
box "Repository / Cross-slice" #DBEEDD
|
||||
participant "DapperUserStore\n(IUserStore/IUserPasswordStore/\nIUserEmailStore)" as UserStore
|
||||
end box
|
||||
database "SQL Server" as DB
|
||||
|
||||
note over UserMgr
|
||||
ASP.NET Core Identity's UserManager<TUser>
|
||||
replaces the deleted AuthRepository: handlers
|
||||
no longer issue SQL or call Argon2 directly,
|
||||
they delegate to UserManager, which in turn
|
||||
calls DapperUserStore (persistence) and
|
||||
Argon2PasswordHasher (IPasswordHasher<TUser>).
|
||||
end note
|
||||
|
||||
User -> API: PATCH /api/auth/password\n{currentPassword, newPassword}\n(requires Bearer access token)
|
||||
activate API
|
||||
|
||||
API -> Mediator: Send(UpdatePasswordCommand(\n GetAuthenticatedUserId(),\n currentPassword, newPassword))
|
||||
activate Mediator
|
||||
|
||||
note right of Mediator
|
||||
ValidationBehavior runs UpdatePasswordValidator
|
||||
(same password strength rules as registration)
|
||||
end note
|
||||
|
||||
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(\n user, currentPassword, newPassword)
|
||||
activate UserMgr
|
||||
UserMgr -> Argon2: VerifyHashedPassword(\n user, user.PasswordHash, currentPassword)
|
||||
activate Argon2
|
||||
Argon2 --> UserMgr: Success/Failed
|
||||
deactivate Argon2
|
||||
|
||||
alt Current password mismatch
|
||||
UserMgr --> UpdatePasswordHandler: IdentityResult.Failed\n(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 dbo.UserCredential\nSET IsRevoked = 1, RevokedAt = GETDATE()\nWHERE UserAccountId = @Id AND IsRevoked = 0
|
||||
UserStore -> DB: INSERT INTO dbo.UserCredential\n(UserAccountId, Hash)
|
||||
note right of UserStore
|
||||
Revoke-then-insert runs inside one
|
||||
DbTransaction (credential rotation).
|
||||
end note
|
||||
UserStore --> UserMgr: (void)
|
||||
deactivate UserStore
|
||||
UserMgr --> UpdatePasswordHandler: IdentityResult.Success
|
||||
end
|
||||
deactivate UserMgr
|
||||
|
||||
alt Current password is incorrect
|
||||
UpdatePasswordHandler -> Mediator: throw UnauthorizedException\n"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(\n user.Id, DateTime.UtcNow)
|
||||
deactivate UpdatePasswordHandler
|
||||
Mediator --> API: UpdatePasswordPayload
|
||||
deactivate Mediator
|
||||
API -> User: 200 OK\n{message: "Password updated successfully.",\npayload: {...}}
|
||||
end
|
||||
end
|
||||
end
|
||||
deactivate API
|
||||
|
||||
@enduml
|
||||
92
docs/website/diagrams-src/auth/update-profile.puml
Normal file
92
docs/website/diagrams-src/auth/update-profile.puml
Normal file
@@ -0,0 +1,92 @@
|
||||
@startuml update-profile
|
||||
!include ../../../pipeline/diagrams/biergarten-weizen-theme.puml
|
||||
skinparam sequenceMessageAlign center
|
||||
skinparam maxMessageSize 200
|
||||
|
||||
title User Authentication Flow - Update Profile
|
||||
|
||||
actor User
|
||||
participant "API\nController" as API
|
||||
participant "MediatR pipeline\n(ValidationBehavior)" as Mediator
|
||||
box "Handler Layer" #F1F3EA
|
||||
participant "UpdateProfileHandler" as UpdateProfileHandler
|
||||
participant "UserManager<ApplicationUser>" as UserMgr
|
||||
end box
|
||||
box "Repository / Cross-slice" #DBEEDD
|
||||
participant "DapperUserStore\n(IUserStore/IUserPasswordStore/\nIUserEmailStore)" as UserStore
|
||||
end box
|
||||
database "SQL Server" as DB
|
||||
|
||||
note over UserMgr
|
||||
ASP.NET Core Identity's UserManager<TUser>
|
||||
replaces the deleted AuthRepository: handlers
|
||||
no longer issue SQL or call Argon2 directly,
|
||||
they delegate to UserManager, which in turn
|
||||
calls DapperUserStore (persistence) and
|
||||
Argon2PasswordHasher (IPasswordHasher<TUser>).
|
||||
end note
|
||||
|
||||
User -> API: PATCH /api/auth/profile\n{firstName, lastName, dateOfBirth}\n(requires Bearer access token)
|
||||
activate API
|
||||
|
||||
API -> Mediator: Send(UpdateProfileCommand(\n GetAuthenticatedUserId(),\n firstName, lastName, dateOfBirth))
|
||||
activate Mediator
|
||||
|
||||
note right of Mediator
|
||||
ValidationBehavior runs UpdateProfileValidator
|
||||
(same name/DateOfBirth rules as registration)
|
||||
end note
|
||||
|
||||
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 = ...\nuser.LastName = ...\nuser.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.
|
||||
end note
|
||||
|
||||
UpdateProfileHandler -> UserMgr: UpdateAsync(user)
|
||||
activate UserMgr
|
||||
UserMgr -> UserStore: UpdateAsync(user)
|
||||
UserStore -> DB: UPDATE dbo.UserAccount\nSET FirstName = @FirstName,\n LastName = @LastName,\n 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(\n user.Id, firstName,\n lastName, dateOfBirth)
|
||||
deactivate UpdateProfileHandler
|
||||
Mediator --> API: UpdateProfilePayload
|
||||
deactivate Mediator
|
||||
API -> User: 200 OK\n{message: "Profile updated successfully.",\npayload: {...}}
|
||||
end
|
||||
end
|
||||
end
|
||||
deactivate API
|
||||
|
||||
@enduml
|
||||
94
docs/website/diagrams-src/auth/update-username.puml
Normal file
94
docs/website/diagrams-src/auth/update-username.puml
Normal file
@@ -0,0 +1,94 @@
|
||||
@startuml update-username
|
||||
!include ../../../pipeline/diagrams/biergarten-weizen-theme.puml
|
||||
skinparam sequenceMessageAlign center
|
||||
skinparam maxMessageSize 200
|
||||
|
||||
title User Authentication Flow - Update Username
|
||||
|
||||
actor User
|
||||
participant "API\nController" as API
|
||||
participant "MediatR pipeline\n(ValidationBehavior)" as Mediator
|
||||
box "Handler Layer" #F1F3EA
|
||||
participant "UpdateUsernameHandler" as UpdateUsernameHandler
|
||||
participant "UserManager<ApplicationUser>" as UserMgr
|
||||
end box
|
||||
box "Repository / Cross-slice" #DBEEDD
|
||||
participant "DapperUserStore\n(IUserStore/IUserPasswordStore/\nIUserEmailStore)" as UserStore
|
||||
end box
|
||||
database "SQL Server" as DB
|
||||
|
||||
note over UserMgr
|
||||
ASP.NET Core Identity's UserManager<TUser>
|
||||
replaces the deleted AuthRepository: handlers
|
||||
no longer issue SQL or call Argon2 directly,
|
||||
they delegate to UserManager, which in turn
|
||||
calls DapperUserStore (persistence) and
|
||||
Argon2PasswordHasher (IPasswordHasher<TUser>).
|
||||
end note
|
||||
|
||||
User -> API: PATCH /api/auth/username\n{newUsername}\n(requires Bearer access token)
|
||||
activate API
|
||||
|
||||
API -> Mediator: Send(UpdateUsernameCommand(\n GetAuthenticatedUserId(), newUsername))
|
||||
activate Mediator
|
||||
|
||||
note right of Mediator
|
||||
ValidationBehavior runs UpdateUsernameValidator
|
||||
(same format rules as registration)
|
||||
end note
|
||||
|
||||
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 ...\nFROM dbo.UserAccount\nWHERE 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.
|
||||
end note
|
||||
UserMgr -> UserStore: UpdateAsync(user)
|
||||
UserStore -> DB: UPDATE dbo.UserAccount\nSET Username = @UserName, ...
|
||||
UserStore --> UserMgr: IdentityResult
|
||||
UserMgr --> UpdateUsernameHandler: IdentityResult
|
||||
deactivate UserMgr
|
||||
|
||||
alt !result.Succeeded
|
||||
UpdateUsernameHandler -> Mediator: throw ConflictException\n(DuplicateUserName code ->\n"Username already exists")
|
||||
Mediator -> API: propagate
|
||||
API -> User: 409 Conflict
|
||||
else result.Succeeded
|
||||
UpdateUsernameHandler --> Mediator: UpdateUsernamePayload(\n user.Id, user.UserName)
|
||||
deactivate UpdateUsernameHandler
|
||||
Mediator --> API: UpdateUsernamePayload
|
||||
deactivate Mediator
|
||||
API -> User: 200 OK\n{message: "Username updated successfully.",\npayload: {...}}
|
||||
end
|
||||
end
|
||||
end
|
||||
deactivate API
|
||||
|
||||
@enduml
|
||||
@@ -1,927 +0,0 @@
|
||||
@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 "RefreshTokenHandler" as RefreshHandler
|
||||
participant "ConfirmUserHandler" as ConfirmHandler
|
||||
participant "ResendConfirmationEmailHandler" as ResendHandler
|
||||
participant "UpdateUsernameHandler" as UpdateUsernameHandler
|
||||
participant "UpdateEmailHandler" as UpdateEmailHandler
|
||||
participant "UpdatePasswordHandler" as UpdatePasswordHandler
|
||||
participant "UpdateProfileHandler" as UpdateProfileHandler
|
||||
participant "DeleteAccountHandler" as DeleteAccountHandler
|
||||
participant "UserManager<ApplicationUser>" as UserMgr
|
||||
participant "TokenService" as TokenSvc
|
||||
end box
|
||||
box "Infrastructure Layer" #LightGreen
|
||||
participant "Argon2PasswordHasher" 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 "DapperUserStore\n(IUserStore/IUserPasswordStore/\nIUserEmailStore)" as UserStore
|
||||
participant "EmailDispatcher\n(Features.Emails)" as EmailDispatcher
|
||||
end box
|
||||
database "SQL Server" as DB
|
||||
|
||||
note over UserMgr
|
||||
ASP.NET Core Identity's UserManager<TUser>
|
||||
replaces the deleted AuthRepository: handlers
|
||||
no longer issue SQL or call Argon2 directly,
|
||||
they delegate to UserManager, which in turn
|
||||
calls DapperUserStore (persistence) and
|
||||
Argon2PasswordHasher (IPasswordHasher<TUser>).
|
||||
end note
|
||||
|
||||
== 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 -> RegHandler: user = new UserAccount{...}\n.ToApplicationUser()
|
||||
|
||||
RegHandler -> UserMgr: CreateAsync(user, password)
|
||||
activate UserMgr
|
||||
|
||||
UserMgr -> Argon2: HashPassword(user, password)
|
||||
activate Argon2
|
||||
note right of Argon2
|
||||
Delegates to IPasswordInfrastructure.Hash:
|
||||
Argon2id parameters:
|
||||
- Salt: 16 bytes (128-bit)
|
||||
- Memory: 64MB
|
||||
- Iterations: 4
|
||||
- Parallelism: CPU count
|
||||
- Hash output: 32 bytes
|
||||
end note
|
||||
Argon2 --> UserMgr: "base64(salt):base64(hash)"
|
||||
deactivate Argon2
|
||||
|
||||
UserMgr -> UserStore: SetPasswordHashAsync(user, hash)
|
||||
note right of UserStore
|
||||
user.Id is still empty at this point -
|
||||
only sets the in-memory hash. CreateAsync
|
||||
writes it to dbo.UserCredential itself.
|
||||
end note
|
||||
|
||||
UserMgr -> UserStore: FindByNameAsync(username)
|
||||
activate UserStore
|
||||
UserStore -> DB: SELECT UserAccountID, Username, ...\nFROM dbo.UserAccount\nWHERE Username = @Username
|
||||
activate DB
|
||||
DB --> UserStore: null (user doesn't exist)
|
||||
deactivate DB
|
||||
deactivate UserStore
|
||||
|
||||
UserMgr -> UserStore: FindByEmailAsync(email)
|
||||
activate UserStore
|
||||
UserStore -> DB: SELECT UserAccountID, Username, ...\nFROM dbo.UserAccount\nWHERE 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 is called;
|
||||
DapperUserStore.CreateAsync also guards
|
||||
against a genuine race via a caught
|
||||
duplicate-key SqlException (2601/2627).
|
||||
end note
|
||||
|
||||
UserMgr -> UserStore: CreateAsync(user)
|
||||
activate UserStore
|
||||
|
||||
UserStore -> DB: BEGIN TRANSACTION
|
||||
activate DB
|
||||
UserStore -> DB: INSERT INTO dbo.UserAccount\n(Username, FirstName, LastName,\nDateOfBirth, Email)\nOUTPUT INSERTED.UserAccountID
|
||||
DB --> UserStore: UserAccountId (GUID)
|
||||
UserStore -> DB: INSERT INTO dbo.UserCredential\n(UserAccountId, Hash)
|
||||
UserStore -> DB: COMMIT TRANSACTION
|
||||
deactivate DB
|
||||
note right of UserStore
|
||||
Both statements run via Dapper's
|
||||
ExecuteScalarAsync/ExecuteAsync against
|
||||
a single DbTransaction, not a stored
|
||||
procedure - no manual parameter binding
|
||||
end note
|
||||
|
||||
UserStore --> UserMgr: IdentityResult.Success\n(user.Id set)
|
||||
deactivate UserStore
|
||||
|
||||
UserMgr --> RegHandler: IdentityResult
|
||||
deactivate UserMgr
|
||||
|
||||
alt !result.Succeeded
|
||||
RegHandler -> Mediator: throw ConflictException\n(DuplicateUserName/DuplicateEmail codes ->\n"Username or email already exists";\notherwise joined error descriptions)
|
||||
Mediator -> API: propagate
|
||||
API -> User: 409 Conflict\n"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
|
||||
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 -> UserMgr: FindByNameAsync(username)
|
||||
activate UserMgr
|
||||
UserMgr -> UserStore: FindByNameAsync(username)
|
||||
activate UserStore
|
||||
UserStore -> DB: SELECT UserAccountID, Username, ...\nFROM dbo.UserAccount\nWHERE Username = @Username
|
||||
activate DB
|
||||
DB --> UserStore: UserAccount row or null
|
||||
deactivate DB
|
||||
UserStore -> UserStore: HydrateAsync\n(load active UserCredential.Hash,\nUserVerification -> EmailConfirmed)
|
||||
UserStore --> UserMgr: ApplicationUser or null
|
||||
deactivate UserStore
|
||||
UserMgr --> LoginHandler: ApplicationUser or null
|
||||
deactivate UserMgr
|
||||
|
||||
alt User not found
|
||||
LoginHandler -> Mediator: throw UnauthorizedException\n"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 UserCredential was hydrated)
|
||||
without calling the hasher; otherwise
|
||||
delegates to Argon2PasswordHasher.
|
||||
end note
|
||||
UserMgr -> Argon2: VerifyHashedPassword(\n user, user.PasswordHash, password)
|
||||
activate Argon2
|
||||
note right of Argon2
|
||||
Delegates to IPasswordInfrastructure.Verify:
|
||||
1. Split stored hash: "salt:hash"
|
||||
2. Hash provided password with the same salt
|
||||
3. Constant-time comparison\n (CryptographicOperations.FixedTimeEquals)
|
||||
end note
|
||||
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(\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
|
||||
deactivate API
|
||||
|
||||
== Refresh Token Flow ==
|
||||
|
||||
User -> API: POST /api/auth/refresh\nHeader: 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.
|
||||
end note
|
||||
|
||||
alt Validation fails
|
||||
Mediator -> API: throws FluentValidation.ValidationException
|
||||
API -> User: 400 Bad Request\n{message: "Validation failed", 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.
|
||||
end note
|
||||
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 UserAccountID, Username, ...\nFROM dbo.UserAccount\nWHERE 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\n"User account not found"
|
||||
RefreshHandler -> Mediator: propagate
|
||||
Mediator -> API: propagate
|
||||
API -> User: 401 Unauthorized
|
||||
else User found
|
||||
TokenSvc -> JWT: GenerateAccessToken(user)\nGenerateRefreshToken(user)
|
||||
JWT --> TokenSvc: new access + refresh tokens
|
||||
TokenSvc --> RefreshHandler: RefreshTokenResult
|
||||
deactivate TokenSvc
|
||||
|
||||
RefreshHandler --> Mediator: LoginPayload(\n userId, username,\n refreshToken, accessToken)
|
||||
deactivate RefreshHandler
|
||||
Mediator --> API: LoginPayload
|
||||
deactivate Mediator
|
||||
|
||||
API -> User: 200 OK\n{message: "Token refreshed successfully.",\npayload: {...}}
|
||||
end
|
||||
end
|
||||
end
|
||||
deactivate API
|
||||
|
||||
== Confirm User Flow ==
|
||||
|
||||
User -> API: POST /api/auth/confirm?token=<token>\n(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\ntoken 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 ...\nFROM dbo.UserAccount\nWHERE 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\n"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.
|
||||
end note
|
||||
|
||||
ConfirmHandler -> UserMgr: UpdateAsync(user)
|
||||
activate UserMgr
|
||||
UserMgr -> UserStore: UpdateAsync(user)
|
||||
activate UserStore
|
||||
UserStore -> DB: UPDATE dbo.UserAccount SET ...
|
||||
UserStore -> DB: INSERT INTO dbo.UserVerification\n(UserAccountId, VerificationDateTime)
|
||||
note right of UserStore
|
||||
Idempotent: skipped (duplicate-key
|
||||
exception swallowed) if a verification
|
||||
row already exists.
|
||||
end note
|
||||
UserStore --> UserMgr: IdentityResult.Success
|
||||
deactivate UserStore
|
||||
UserMgr --> ConfirmHandler: IdentityResult
|
||||
deactivate UserMgr
|
||||
|
||||
ConfirmHandler --> Mediator: ConfirmationPayload(\n user.Id, DateTime.UtcNow)
|
||||
deactivate ConfirmHandler
|
||||
Mediator --> API: ConfirmationPayload
|
||||
deactivate Mediator
|
||||
|
||||
API -> User: 200 OK\n{message: "User with ID {id} is confirmed.",\npayload: {...}}
|
||||
end
|
||||
end
|
||||
deactivate API
|
||||
|
||||
== Resend Confirmation Email Flow ==
|
||||
|
||||
User -> API: POST /api/auth/confirm/resend?userId=<id>\n(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 ...\nFROM dbo.UserAccount\nWHERE 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\n(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(\n firstName, email, confirmationToken)
|
||||
activate EmailDispatcher
|
||||
EmailDispatcher -> Template: Render email\n(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\n{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.
|
||||
end note
|
||||
deactivate API
|
||||
|
||||
== Update Username Flow ==
|
||||
|
||||
User -> API: PATCH /api/auth/username\n{newUsername}\n(requires Bearer access token)
|
||||
activate API
|
||||
|
||||
API -> Mediator: Send(UpdateUsernameCommand(\n GetAuthenticatedUserId(), newUsername))
|
||||
activate Mediator
|
||||
|
||||
note right of Mediator
|
||||
ValidationBehavior runs UpdateUsernameValidator
|
||||
(same format rules as registration)
|
||||
end note
|
||||
|
||||
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 ...\nFROM dbo.UserAccount\nWHERE 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.
|
||||
end note
|
||||
UserMgr -> UserStore: UpdateAsync(user)
|
||||
UserStore -> DB: UPDATE dbo.UserAccount\nSET Username = @UserName, ...
|
||||
UserStore --> UserMgr: IdentityResult
|
||||
UserMgr --> UpdateUsernameHandler: IdentityResult
|
||||
deactivate UserMgr
|
||||
|
||||
alt !result.Succeeded
|
||||
UpdateUsernameHandler -> Mediator: throw ConflictException\n(DuplicateUserName code ->\n"Username already exists")
|
||||
Mediator -> API: propagate
|
||||
API -> User: 409 Conflict
|
||||
else result.Succeeded
|
||||
UpdateUsernameHandler --> Mediator: UpdateUsernamePayload(\n user.Id, user.UserName)
|
||||
deactivate UpdateUsernameHandler
|
||||
Mediator --> API: UpdateUsernamePayload
|
||||
deactivate Mediator
|
||||
API -> User: 200 OK\n{message: "Username updated successfully.",\npayload: {...}}
|
||||
end
|
||||
end
|
||||
end
|
||||
deactivate API
|
||||
|
||||
== Update Email Flow ==
|
||||
|
||||
User -> API: PATCH /api/auth/email\n{newEmail}\n(requires Bearer access token)
|
||||
activate API
|
||||
|
||||
API -> Mediator: Send(UpdateEmailCommand(\n GetAuthenticatedUserId(), newEmail))
|
||||
activate Mediator
|
||||
|
||||
note right of Mediator
|
||||
ValidationBehavior runs UpdateEmailValidator
|
||||
(same format rules as registration)
|
||||
end note
|
||||
|
||||
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 -
|
||||
the caller must re-confirm via
|
||||
POST /api/auth/confirm/resend.
|
||||
end note
|
||||
UserMgr -> UserStore: UpdateAsync(user)
|
||||
UserStore -> DB: UPDATE dbo.UserAccount\nSET Email = @Email, ...
|
||||
UserStore -> DB: DELETE FROM dbo.UserVerification\nWHERE UserAccountID = @Id
|
||||
UserStore --> UserMgr: IdentityResult
|
||||
UserMgr --> UpdateEmailHandler: IdentityResult
|
||||
deactivate UserMgr
|
||||
|
||||
alt !result.Succeeded
|
||||
UpdateEmailHandler -> Mediator: throw ConflictException\n(DuplicateEmail code ->\n"Email address already exists")
|
||||
Mediator -> API: propagate
|
||||
API -> User: 409 Conflict
|
||||
else result.Succeeded
|
||||
UpdateEmailHandler --> Mediator: UpdateEmailPayload(\n user.Id, user.Email,\n user.EmailConfirmed = false)
|
||||
deactivate UpdateEmailHandler
|
||||
Mediator --> API: UpdateEmailPayload
|
||||
deactivate Mediator
|
||||
API -> User: 200 OK\n{message: "Email updated successfully.",\npayload: {...}}
|
||||
end
|
||||
end
|
||||
end
|
||||
deactivate API
|
||||
|
||||
== Update Password Flow ==
|
||||
|
||||
User -> API: PATCH /api/auth/password\n{currentPassword, newPassword}\n(requires Bearer access token)
|
||||
activate API
|
||||
|
||||
API -> Mediator: Send(UpdatePasswordCommand(\n GetAuthenticatedUserId(),\n currentPassword, newPassword))
|
||||
activate Mediator
|
||||
|
||||
note right of Mediator
|
||||
ValidationBehavior runs UpdatePasswordValidator
|
||||
(same password strength rules as registration)
|
||||
end note
|
||||
|
||||
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(\n user, currentPassword, newPassword)
|
||||
activate UserMgr
|
||||
UserMgr -> Argon2: VerifyHashedPassword(\n user, user.PasswordHash, currentPassword)
|
||||
activate Argon2
|
||||
Argon2 --> UserMgr: Success/Failed
|
||||
deactivate Argon2
|
||||
|
||||
alt Current password mismatch
|
||||
UserMgr --> UpdatePasswordHandler: IdentityResult.Failed\n(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 dbo.UserCredential\nSET IsRevoked = 1, RevokedAt = GETDATE()\nWHERE UserAccountId = @Id AND IsRevoked = 0
|
||||
UserStore -> DB: INSERT INTO dbo.UserCredential\n(UserAccountId, Hash)
|
||||
note right of UserStore
|
||||
Revoke-then-insert runs inside one
|
||||
DbTransaction (credential rotation).
|
||||
end note
|
||||
UserStore --> UserMgr: (void)
|
||||
deactivate UserStore
|
||||
UserMgr --> UpdatePasswordHandler: IdentityResult.Success
|
||||
end
|
||||
deactivate UserMgr
|
||||
|
||||
alt Current password is incorrect
|
||||
UpdatePasswordHandler -> Mediator: throw UnauthorizedException\n"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(\n user.Id, DateTime.UtcNow)
|
||||
deactivate UpdatePasswordHandler
|
||||
Mediator --> API: UpdatePasswordPayload
|
||||
deactivate Mediator
|
||||
API -> User: 200 OK\n{message: "Password updated successfully.",\npayload: {...}}
|
||||
end
|
||||
end
|
||||
end
|
||||
deactivate API
|
||||
|
||||
== Update Profile Flow ==
|
||||
|
||||
User -> API: PATCH /api/auth/profile\n{firstName, lastName, dateOfBirth}\n(requires Bearer access token)
|
||||
activate API
|
||||
|
||||
API -> Mediator: Send(UpdateProfileCommand(\n GetAuthenticatedUserId(),\n firstName, lastName, dateOfBirth))
|
||||
activate Mediator
|
||||
|
||||
note right of Mediator
|
||||
ValidationBehavior runs UpdateProfileValidator
|
||||
(same name/DateOfBirth rules as registration)
|
||||
end note
|
||||
|
||||
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 = ...\nuser.LastName = ...\nuser.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.
|
||||
end note
|
||||
|
||||
UpdateProfileHandler -> UserMgr: UpdateAsync(user)
|
||||
activate UserMgr
|
||||
UserMgr -> UserStore: UpdateAsync(user)
|
||||
UserStore -> DB: UPDATE dbo.UserAccount\nSET FirstName = @FirstName,\n LastName = @LastName,\n 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(\n user.Id, firstName,\n lastName, dateOfBirth)
|
||||
deactivate UpdateProfileHandler
|
||||
Mediator --> API: UpdateProfilePayload
|
||||
deactivate Mediator
|
||||
API -> User: 200 OK\n{message: "Profile updated successfully.",\npayload: {...}}
|
||||
end
|
||||
end
|
||||
end
|
||||
deactivate API
|
||||
|
||||
== Delete Account Flow ==
|
||||
|
||||
User -> API: DELETE /api/auth/account\n(requires Bearer access token)
|
||||
activate API
|
||||
|
||||
API -> Mediator: Send(DeleteAccountCommand(\n 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 dbo.UserAccount\nWHERE 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).
|
||||
end note
|
||||
|
||||
alt Foreign key violation (dependent records exist)
|
||||
UserStore --> UserMgr: IdentityResult.Failed\n(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\n"Account cannot be deleted while it still\nhas associated posts, comments, photos,\nor 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\n{message: "Account deleted successfully."}
|
||||
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
|
||||
@@ -1,9 +1,6 @@
|
||||
@startuml database-schema
|
||||
!theme plain
|
||||
skinparam backgroundColor #FFFFFF
|
||||
skinparam defaultFontName Arial
|
||||
!include ../../pipeline/diagrams/biergarten-weizen-theme.puml
|
||||
skinparam linetype ortho
|
||||
skinparam packageStyle rectangle
|
||||
|
||||
title Database Schema
|
||||
caption Every table also has a RowVersion ROWVERSION column (optimistic concurrency), omitted below for brevity.\nTables marked (*) have no repository/feature slice yet.
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
@startuml deployment
|
||||
!theme plain
|
||||
skinparam backgroundColor #FFFFFF
|
||||
skinparam defaultFontName Arial
|
||||
!include ../../pipeline/diagrams/biergarten-weizen-theme.puml
|
||||
skinparam linetype ortho
|
||||
|
||||
title Docker Deployment Architecture
|
||||
@@ -10,7 +8,7 @@ title Docker Deployment Architecture
|
||||
actor Developer
|
||||
cloud "Docker Host" as Host
|
||||
|
||||
package "Development Environment\n(docker-compose.dev.yaml)" #E3F2FD {
|
||||
package "Development Environment\n(docker-compose.dev.yaml)" #F1F3EA {
|
||||
|
||||
node "SQL Server\n(mcr.microsoft.com/mssql/server:2022-latest)" as DevDB {
|
||||
database "Biergarten\nDatabase" as DevDBInner {
|
||||
@@ -80,7 +78,7 @@ package "Development Environment\n(docker-compose.dev.yaml)" #E3F2FD {
|
||||
|
||||
}
|
||||
|
||||
package "Test Environment\n(docker-compose.test.yaml)" #FFF3E0 {
|
||||
package "Test Environment\n(docker-compose.test.yaml)" #EBECE3 {
|
||||
|
||||
node "SQL Server\n(isolated instance)" as TestDB {
|
||||
database "Biergarten\nTest Database" as TestDBInner {
|
||||
@@ -142,7 +140,7 @@ package "Test Environment\n(docker-compose.test.yaml)" #FFF3E0 {
|
||||
|
||||
}
|
||||
|
||||
package "Production Environment\n(docker-compose.prod.yaml)" #FCE4EC {
|
||||
package "Production Environment\n(docker-compose.prod.yaml)" #CBD2B5 {
|
||||
|
||||
node "SQL Server\n(mcr.microsoft.com/mssql/server:2022-latest)" as ProdDB {
|
||||
database "Biergarten\nDatabase" as ProdDBInner
|
||||
@@ -195,7 +193,7 @@ package "Production Environment\n(docker-compose.prod.yaml)" #FCE4EC {
|
||||
end note
|
||||
}
|
||||
|
||||
package "Database-only Environment\n(docker-compose.db.yaml)" #EDE7F6 {
|
||||
package "Database-only Environment\n(docker-compose.db.yaml)" #E4E6D8 {
|
||||
|
||||
node "SQL Server" as DbOnlyDB {
|
||||
database "Biergarten\nDatabase" as DbOnlyDBInner {
|
||||
@@ -225,7 +223,7 @@ package "Database-only Environment\n(docker-compose.db.yaml)" #EDE7F6 {
|
||||
DbOnlySeed .up.> DbOnlyMig : depends_on
|
||||
}
|
||||
|
||||
package "Minimal Environment\n(docker-compose.min.yaml)" #FFF9C4 {
|
||||
package "Minimal Environment\n(docker-compose.min.yaml)" #DBEEDD {
|
||||
|
||||
node "SQL Server" as MinDB {
|
||||
database "Biergarten\nDatabase" as MinDBInner {
|
||||
|
||||
Reference in New Issue
Block a user