mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-04-06 02:19:05 +00:00
Update documentation (#156)
This commit is contained in:
75
docs/diagrams/architecture.puml
Normal file
75
docs/diagrams/architecture.puml
Normal file
@@ -0,0 +1,75 @@
|
||||
@startuml architecture
|
||||
!theme plain
|
||||
skinparam backgroundColor #FFFFFF
|
||||
skinparam defaultFontName Arial
|
||||
skinparam packageStyle rectangle
|
||||
|
||||
title The Biergarten App - Layered Architecture
|
||||
|
||||
package "API Layer" #E3F2FD {
|
||||
[API.Core\nASP.NET Core Web API] as API
|
||||
note right of API
|
||||
- Controllers (Auth, User)
|
||||
- Swagger/OpenAPI
|
||||
- Middleware
|
||||
- Health Checks
|
||||
end note
|
||||
}
|
||||
|
||||
package "Service Layer" #F3E5F5 {
|
||||
[Service.Auth] as AuthSvc
|
||||
[Service.UserManagement] as UserSvc
|
||||
note right of AuthSvc
|
||||
- Business Logic
|
||||
- Validation
|
||||
- Orchestration
|
||||
end note
|
||||
}
|
||||
|
||||
package "Infrastructure Layer" #FFF3E0 {
|
||||
[Infrastructure.Repository] as Repo
|
||||
[Infrastructure.Jwt] as JWT
|
||||
[Infrastructure.PasswordHashing] as PwdHash
|
||||
[Infrastructure.Email] as Email
|
||||
}
|
||||
|
||||
package "Domain Layer" #E8F5E9 {
|
||||
[Domain.Entities] as Domain
|
||||
note right of Domain
|
||||
- UserAccount
|
||||
- UserCredential
|
||||
- UserVerification
|
||||
end note
|
||||
}
|
||||
|
||||
database "SQL Server" {
|
||||
[Stored Procedures] as SP
|
||||
[Tables] as Tables
|
||||
}
|
||||
|
||||
' Relationships
|
||||
API --> AuthSvc
|
||||
API --> UserSvc
|
||||
|
||||
AuthSvc --> Repo
|
||||
AuthSvc --> JWT
|
||||
AuthSvc --> PwdHash
|
||||
AuthSvc --> Email
|
||||
|
||||
UserSvc --> Repo
|
||||
|
||||
Repo --> SP
|
||||
Repo --> Domain
|
||||
SP --> Tables
|
||||
|
||||
AuthSvc --> Domain
|
||||
UserSvc --> Domain
|
||||
|
||||
' Notes
|
||||
note left of Repo
|
||||
SQL-first approach
|
||||
All queries via
|
||||
stored procedures
|
||||
end note
|
||||
|
||||
@enduml
|
||||
298
docs/diagrams/authentication-flow.puml
Normal file
298
docs/diagrams/authentication-flow.puml
Normal file
@@ -0,0 +1,298 @@
|
||||
@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
|
||||
box "Service Layer" #LightBlue
|
||||
participant "RegisterService" as RegSvc
|
||||
participant "LoginService" as LoginSvc
|
||||
participant "TokenService" as TokenSvc
|
||||
participant "EmailService" as EmailSvc
|
||||
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 Layer" #LightYellow
|
||||
participant "AuthRepository" as AuthRepo
|
||||
participant "UserAccount\nRepository" as UserRepo
|
||||
end box
|
||||
database "SQL Server\nStored Procedures" as DB
|
||||
|
||||
== Registration Flow ==
|
||||
|
||||
User -> API: POST /api/auth/register\n{username, firstName, lastName,\nemail, dateOfBirth, password}
|
||||
activate API
|
||||
|
||||
note right of API
|
||||
FluentValidation runs:
|
||||
- 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
|
||||
|
||||
API -> API: Validate request\n(FluentValidation)
|
||||
|
||||
alt Validation fails
|
||||
API -> User: 400 Bad Request\n{errors: {...}}
|
||||
else Validation succeeds
|
||||
API -> RegSvc: RegisterAsync(userAccount, password)
|
||||
activate RegSvc
|
||||
|
||||
RegSvc -> AuthRepo: GetUserByUsernameAsync(username)
|
||||
activate AuthRepo
|
||||
AuthRepo -> DB: EXEC usp_GetUserAccountByUsername
|
||||
activate DB
|
||||
DB --> AuthRepo: null (user doesn't exist)
|
||||
deactivate DB
|
||||
deactivate AuthRepo
|
||||
|
||||
RegSvc -> AuthRepo: GetUserByEmailAsync(email)
|
||||
activate AuthRepo
|
||||
AuthRepo -> DB: EXEC usp_GetUserAccountByEmail
|
||||
activate DB
|
||||
DB --> AuthRepo: null (email doesn't exist)
|
||||
deactivate DB
|
||||
deactivate AuthRepo
|
||||
|
||||
alt User/Email already exists
|
||||
RegSvc -> API: throw ConflictException
|
||||
API -> User: 409 Conflict\n"Username or email already exists"
|
||||
else User doesn't exist
|
||||
|
||||
RegSvc -> 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 --> RegSvc: "base64(salt):base64(hash)"
|
||||
deactivate Argon2
|
||||
|
||||
RegSvc -> AuthRepo: RegisterUserAsync(\n username, firstName, lastName,\n email, dateOfBirth, hash)
|
||||
activate AuthRepo
|
||||
|
||||
AuthRepo -> DB: EXEC USP_RegisterUser
|
||||
activate DB
|
||||
note right of DB
|
||||
Transaction begins:
|
||||
1. INSERT UserAccount
|
||||
2. INSERT UserCredential
|
||||
(with hashed password)
|
||||
Transaction commits
|
||||
end note
|
||||
DB -> DB: BEGIN TRANSACTION
|
||||
DB -> DB: INSERT INTO UserAccount\n(Username, FirstName, LastName,\nEmail, DateOfBirth)
|
||||
DB -> DB: OUTPUT INSERTED.UserAccountID
|
||||
DB -> DB: INSERT INTO UserCredential\n(UserAccountId, Hash)
|
||||
DB -> DB: COMMIT TRANSACTION
|
||||
DB --> AuthRepo: UserAccountId (GUID)
|
||||
deactivate DB
|
||||
|
||||
AuthRepo --> RegSvc: UserAccount entity
|
||||
deactivate AuthRepo
|
||||
|
||||
RegSvc -> TokenSvc: GenerateAccessToken(userAccount)
|
||||
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
|
||||
* jti: unique token ID
|
||||
end note
|
||||
JWT -> JWT: Create JWT with claims
|
||||
JWT -> JWT: Sign with secret key
|
||||
JWT --> TokenSvc: Access Token
|
||||
deactivate JWT
|
||||
TokenSvc --> RegSvc: Access Token
|
||||
deactivate TokenSvc
|
||||
|
||||
RegSvc -> TokenSvc: GenerateRefreshToken(userAccount)
|
||||
activate TokenSvc
|
||||
TokenSvc -> JWT: GenerateJwt(userId, username, expiry)
|
||||
activate JWT
|
||||
note right of JWT
|
||||
Refresh Token:
|
||||
- Expires: 21 days
|
||||
- Same structure as access token
|
||||
end note
|
||||
JWT --> TokenSvc: Refresh Token
|
||||
deactivate JWT
|
||||
TokenSvc --> RegSvc: Refresh Token
|
||||
deactivate TokenSvc
|
||||
|
||||
RegSvc -> EmailSvc: SendRegistrationEmailAsync(\n createdUser, confirmationToken)
|
||||
activate EmailSvc
|
||||
|
||||
EmailSvc -> 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 --> EmailSvc: HTML email content
|
||||
deactivate Template
|
||||
|
||||
EmailSvc -> SMTP: SendAsync(email, subject, body)
|
||||
activate SMTP
|
||||
note right of SMTP
|
||||
SMTP Configuration:
|
||||
- Host: from env (SMTP_HOST)
|
||||
- Port: from env (SMTP_PORT)
|
||||
- TLS: StartTLS
|
||||
- Auth: username/password
|
||||
end note
|
||||
SMTP -> SMTP: Create MIME message
|
||||
SMTP -> SMTP: Connect to SMTP server
|
||||
SMTP -> SMTP: Authenticate
|
||||
SMTP -> SMTP: Send email
|
||||
SMTP -> SMTP: Disconnect
|
||||
SMTP --> EmailSvc: Success / Failure
|
||||
deactivate SMTP
|
||||
|
||||
alt Email sent successfully
|
||||
EmailSvc --> RegSvc: emailSent = true
|
||||
else Email failed
|
||||
EmailSvc --> RegSvc: emailSent = false\n(error suppressed)
|
||||
end
|
||||
deactivate EmailSvc
|
||||
|
||||
RegSvc --> API: RegisterServiceReturn(\n userAccount, accessToken,\n refreshToken, emailSent)
|
||||
deactivate RegSvc
|
||||
|
||||
API -> API: Create response body
|
||||
API -> User: 201 Created\n{\n message: "User registered successfully",\n payload: {\n userAccountId, username,\n accessToken, refreshToken,\n confirmationEmailSent\n }\n}
|
||||
end
|
||||
end
|
||||
deactivate API
|
||||
|
||||
== Login Flow ==
|
||||
|
||||
User -> API: POST /api/auth/login\n{username, password}
|
||||
activate API
|
||||
|
||||
API -> API: Validate request\n(FluentValidation)
|
||||
|
||||
alt Validation fails
|
||||
API -> User: 400 Bad Request\n{errors: {...}}
|
||||
else Validation succeeds
|
||||
|
||||
API -> LoginSvc: LoginAsync(username, password)
|
||||
activate LoginSvc
|
||||
|
||||
LoginSvc -> AuthRepo: GetUserByUsernameAsync(username)
|
||||
activate AuthRepo
|
||||
AuthRepo -> DB: EXEC usp_GetUserAccountByUsername
|
||||
activate DB
|
||||
DB -> DB: SELECT FROM UserAccount\nWHERE Username = @Username
|
||||
DB --> AuthRepo: UserAccount entity
|
||||
deactivate DB
|
||||
deactivate AuthRepo
|
||||
|
||||
alt User not found
|
||||
LoginSvc -> API: throw UnauthorizedException\n"Invalid username or password"
|
||||
API -> User: 401 Unauthorized
|
||||
else User found
|
||||
|
||||
LoginSvc -> AuthRepo: GetActiveCredentialByUserAccountIdAsync(userId)
|
||||
activate AuthRepo
|
||||
AuthRepo -> DB: EXEC USP_GetActiveUserCredentialByUserAccountId
|
||||
activate DB
|
||||
note right of DB
|
||||
SELECT FROM UserCredential
|
||||
WHERE UserAccountId = @UserAccountId
|
||||
AND IsRevoked = 0
|
||||
end note
|
||||
DB --> AuthRepo: UserCredential entity
|
||||
deactivate DB
|
||||
deactivate AuthRepo
|
||||
|
||||
alt No active credential
|
||||
LoginSvc -> API: throw UnauthorizedException
|
||||
API -> User: 401 Unauthorized
|
||||
else Active credential found
|
||||
|
||||
LoginSvc -> 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
|
||||
end note
|
||||
Argon2 -> Argon2: Parse salt from stored hash
|
||||
Argon2 -> Argon2: Hash provided password\nwith extracted salt
|
||||
Argon2 -> Argon2: FixedTimeEquals(\n computed, stored)
|
||||
Argon2 --> LoginSvc: true/false
|
||||
deactivate Argon2
|
||||
|
||||
alt Password invalid
|
||||
LoginSvc -> API: throw UnauthorizedException
|
||||
API -> User: 401 Unauthorized
|
||||
else Password valid
|
||||
|
||||
LoginSvc -> TokenSvc: GenerateAccessToken(user)
|
||||
activate TokenSvc
|
||||
TokenSvc -> JWT: GenerateJwt(...)
|
||||
activate JWT
|
||||
JWT --> TokenSvc: Access Token
|
||||
deactivate JWT
|
||||
TokenSvc --> LoginSvc: Access Token
|
||||
deactivate TokenSvc
|
||||
|
||||
LoginSvc -> TokenSvc: GenerateRefreshToken(user)
|
||||
activate TokenSvc
|
||||
TokenSvc -> JWT: GenerateJwt(...)
|
||||
activate JWT
|
||||
JWT --> TokenSvc: Refresh Token
|
||||
deactivate JWT
|
||||
TokenSvc --> LoginSvc: Refresh Token
|
||||
deactivate TokenSvc
|
||||
|
||||
LoginSvc --> API: LoginServiceReturn(\n userAccount, accessToken,\n refreshToken)
|
||||
deactivate LoginSvc
|
||||
|
||||
API -> User: 200 OK\n{\n message: "Logged in successfully",\n payload: {\n userAccountId, username,\n accessToken, refreshToken\n }\n}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
deactivate API
|
||||
|
||||
== Error Handling (Global Exception Filter) ==
|
||||
|
||||
note over API
|
||||
GlobalExceptionFilter catches:
|
||||
- ValidationException → 400 Bad Request
|
||||
- ConflictException → 409 Conflict
|
||||
- NotFoundException → 404 Not Found
|
||||
- UnauthorizedException → 401 Unauthorized
|
||||
- ForbiddenException → 403 Forbidden
|
||||
- All others → 500 Internal Server Error
|
||||
end note
|
||||
|
||||
@enduml
|
||||
523
docs/diagrams/class-diagram.puml
Normal file
523
docs/diagrams/class-diagram.puml
Normal file
@@ -0,0 +1,523 @@
|
||||
@startuml class-diagram
|
||||
!theme plain
|
||||
skinparam backgroundColor #FFFFFF
|
||||
skinparam defaultFontName Arial
|
||||
skinparam classAttributeIconSize 0
|
||||
skinparam linetype ortho
|
||||
|
||||
title Biergarten Application - Class Diagram
|
||||
|
||||
' API Layer
|
||||
package "API.Core" <<Rectangle>> #E3F2FD {
|
||||
|
||||
class AuthController {
|
||||
- IRegisterService _registerService
|
||||
- ILoginService _loginService
|
||||
+ <<async>> Task<ActionResult> Register(RegisterRequest)
|
||||
+ <<async>> Task<ActionResult> Login(LoginRequest)
|
||||
}
|
||||
|
||||
class UserController {
|
||||
- IUserService _userService
|
||||
+ <<async>> Task<ActionResult<IEnumerable<UserAccount>>> GetAll(int?, int?)
|
||||
+ <<async>> Task<ActionResult<UserAccount>> GetById(Guid)
|
||||
}
|
||||
|
||||
class GlobalExceptionFilter {
|
||||
- ILogger<GlobalExceptionFilter> _logger
|
||||
+ void OnException(ExceptionContext)
|
||||
}
|
||||
|
||||
package "Contracts" {
|
||||
class RegisterRequest <<record>> {
|
||||
+ string Username
|
||||
+ string FirstName
|
||||
+ string LastName
|
||||
+ string Email
|
||||
+ DateTime DateOfBirth
|
||||
+ string Password
|
||||
}
|
||||
|
||||
class LoginRequest <<record>> {
|
||||
+ string Username
|
||||
+ string Password
|
||||
}
|
||||
|
||||
class RegisterRequestValidator {
|
||||
+ RegisterRequestValidator()
|
||||
}
|
||||
|
||||
class LoginRequestValidator {
|
||||
+ LoginRequestValidator()
|
||||
}
|
||||
|
||||
class "ResponseBody<T>" <<record>> {
|
||||
+ string Message
|
||||
+ T Payload
|
||||
}
|
||||
|
||||
class LoginPayload <<record>> {
|
||||
+ Guid UserAccountId
|
||||
+ string Username
|
||||
+ string RefreshToken
|
||||
+ string AccessToken
|
||||
}
|
||||
|
||||
class RegistrationPayload <<record>> {
|
||||
+ Guid UserAccountId
|
||||
+ string Username
|
||||
+ string RefreshToken
|
||||
+ string AccessToken
|
||||
+ bool ConfirmationEmailSent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
' Service Layer
|
||||
package "Service Layer" <<Rectangle>> #C8E6C9 {
|
||||
|
||||
package "Service.Auth" {
|
||||
interface IRegisterService {
|
||||
+ <<async>> Task<RegisterServiceReturn> RegisterAsync(UserAccount, string)
|
||||
}
|
||||
|
||||
class RegisterService {
|
||||
- IAuthRepository _authRepo
|
||||
- IPasswordInfrastructure _passwordInfra
|
||||
- ITokenService _tokenService
|
||||
- IEmailService _emailService
|
||||
+ <<async>> Task<RegisterServiceReturn> RegisterAsync(UserAccount, string)
|
||||
- <<async>> Task ValidateUserDoesNotExist(UserAccount)
|
||||
}
|
||||
|
||||
interface ILoginService {
|
||||
+ <<async>> Task<LoginServiceReturn> LoginAsync(string, string)
|
||||
}
|
||||
|
||||
class LoginService {
|
||||
- IAuthRepository _authRepo
|
||||
- IPasswordInfrastructure _passwordInfra
|
||||
- ITokenService _tokenService
|
||||
+ <<async>> Task<LoginServiceReturn> LoginAsync(string, string)
|
||||
}
|
||||
|
||||
interface ITokenService {
|
||||
+ string GenerateAccessToken(UserAccount)
|
||||
+ string GenerateRefreshToken(UserAccount)
|
||||
}
|
||||
|
||||
class TokenService {
|
||||
- ITokenInfrastructure _tokenInfrastructure
|
||||
+ string GenerateAccessToken(UserAccount)
|
||||
+ string GenerateRefreshToken(UserAccount)
|
||||
}
|
||||
|
||||
class RegisterServiceReturn <<record>> {
|
||||
+ bool IsAuthenticated
|
||||
+ bool EmailSent
|
||||
+ UserAccount UserAccount
|
||||
+ string AccessToken
|
||||
+ string RefreshToken
|
||||
}
|
||||
|
||||
class LoginServiceReturn <<record>> {
|
||||
+ UserAccount UserAccount
|
||||
+ string RefreshToken
|
||||
+ string AccessToken
|
||||
}
|
||||
}
|
||||
|
||||
package "Service.UserManagement" {
|
||||
interface IUserService {
|
||||
+ <<async>> Task<IEnumerable<UserAccount>> GetAllAsync(int?, int?)
|
||||
+ <<async>> Task<UserAccount> GetByIdAsync(Guid)
|
||||
+ <<async>> Task UpdateAsync(UserAccount)
|
||||
}
|
||||
|
||||
class UserService {
|
||||
- IUserAccountRepository _repository
|
||||
+ <<async>> Task<IEnumerable<UserAccount>> GetAllAsync(int?, int?)
|
||||
+ <<async>> Task<UserAccount> GetByIdAsync(Guid)
|
||||
+ <<async>> Task UpdateAsync(UserAccount)
|
||||
}
|
||||
}
|
||||
|
||||
package "Service.Emails" {
|
||||
interface IEmailService {
|
||||
+ <<async>> Task SendRegistrationEmailAsync(UserAccount, string)
|
||||
}
|
||||
|
||||
class EmailService {
|
||||
- IEmailProvider _emailProvider
|
||||
- IEmailTemplateProvider _templateProvider
|
||||
+ <<async>> Task SendRegistrationEmailAsync(UserAccount, string)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
' Domain Layer
|
||||
package "Domain" <<Rectangle>> #FFF9C4 {
|
||||
|
||||
package "Domain.Entities" {
|
||||
class UserAccount {
|
||||
+ Guid UserAccountId
|
||||
+ string Username
|
||||
+ string FirstName
|
||||
+ string LastName
|
||||
+ string Email
|
||||
+ DateTime CreatedAt
|
||||
+ DateTime? UpdatedAt
|
||||
+ DateTime DateOfBirth
|
||||
+ byte[]? Timer
|
||||
}
|
||||
|
||||
class UserCredential {
|
||||
+ Guid UserCredentialId
|
||||
+ Guid UserAccountId
|
||||
+ DateTime CreatedAt
|
||||
+ DateTime Expiry
|
||||
+ string Hash
|
||||
+ byte[]? Timer
|
||||
}
|
||||
|
||||
class UserVerification {
|
||||
+ Guid UserVerificationId
|
||||
+ Guid UserAccountId
|
||||
+ DateTime VerificationDateTime
|
||||
+ byte[]? Timer
|
||||
}
|
||||
}
|
||||
|
||||
package "Domain.Exceptions" {
|
||||
class ConflictException {
|
||||
+ ConflictException(string)
|
||||
}
|
||||
|
||||
class NotFoundException {
|
||||
+ NotFoundException(string)
|
||||
}
|
||||
|
||||
class UnauthorizedException {
|
||||
+ UnauthorizedException(string)
|
||||
}
|
||||
|
||||
class ForbiddenException {
|
||||
+ ForbiddenException(string)
|
||||
}
|
||||
|
||||
class ValidationException {
|
||||
+ ValidationException(string)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
' Infrastructure Layer
|
||||
package "Infrastructure" <<Rectangle>> #E1BEE7 {
|
||||
|
||||
package "Infrastructure.Repository" {
|
||||
interface IAuthRepository {
|
||||
+ <<async>> Task<UserAccount> RegisterUserAsync(string, string, string, string, DateTime, string)
|
||||
+ <<async>> Task<UserAccount?> GetUserByEmailAsync(string)
|
||||
+ <<async>> Task<UserAccount?> GetUserByUsernameAsync(string)
|
||||
+ <<async>> Task<UserCredential?> GetActiveCredentialByUserAccountIdAsync(Guid)
|
||||
+ <<async>> Task RotateCredentialAsync(Guid, string)
|
||||
}
|
||||
|
||||
class AuthRepository {
|
||||
- ISqlConnectionFactory _connectionFactory
|
||||
+ <<async>> Task<UserAccount> RegisterUserAsync(...)
|
||||
+ <<async>> Task<UserAccount?> GetUserByEmailAsync(string)
|
||||
+ <<async>> Task<UserAccount?> GetUserByUsernameAsync(string)
|
||||
+ <<async>> Task<UserCredential?> GetActiveCredentialByUserAccountIdAsync(Guid)
|
||||
+ <<async>> Task RotateCredentialAsync(Guid, string)
|
||||
# UserAccount MapToEntity(DbDataReader)
|
||||
- UserCredential MapToCredentialEntity(DbDataReader)
|
||||
}
|
||||
|
||||
interface IUserAccountRepository {
|
||||
+ <<async>> Task<UserAccount?> GetByIdAsync(Guid)
|
||||
+ <<async>> Task<IEnumerable<UserAccount>> GetAllAsync(int?, int?)
|
||||
+ <<async>> Task UpdateAsync(UserAccount)
|
||||
+ <<async>> Task DeleteAsync(Guid)
|
||||
+ <<async>> Task<UserAccount?> GetByUsernameAsync(string)
|
||||
+ <<async>> Task<UserAccount?> GetByEmailAsync(string)
|
||||
}
|
||||
|
||||
class UserAccountRepository {
|
||||
- ISqlConnectionFactory _connectionFactory
|
||||
+ <<async>> Task<UserAccount?> GetByIdAsync(Guid)
|
||||
+ <<async>> Task<IEnumerable<UserAccount>> GetAllAsync(int?, int?)
|
||||
+ <<async>> Task UpdateAsync(UserAccount)
|
||||
+ <<async>> Task DeleteAsync(Guid)
|
||||
+ <<async>> Task<UserAccount?> GetByUsernameAsync(string)
|
||||
+ <<async>> Task<UserAccount?> GetByEmailAsync(string)
|
||||
# UserAccount MapToEntity(DbDataReader)
|
||||
}
|
||||
|
||||
abstract class "Repository<T>" {
|
||||
# ISqlConnectionFactory _connectionFactory
|
||||
# <<async>> Task<DbConnection> CreateConnection()
|
||||
# {abstract} T MapToEntity(DbDataReader)
|
||||
}
|
||||
|
||||
package "Sql" {
|
||||
interface ISqlConnectionFactory {
|
||||
+ DbConnection CreateConnection()
|
||||
}
|
||||
|
||||
class DefaultSqlConnectionFactory {
|
||||
- string _connectionString
|
||||
+ DbConnection CreateConnection()
|
||||
}
|
||||
|
||||
class SqlConnectionStringHelper <<static>> {
|
||||
+ {static} string BuildConnectionString(string?)
|
||||
+ {static} string BuildMasterConnectionString()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
package "Infrastructure.PasswordHashing" {
|
||||
interface IPasswordInfrastructure {
|
||||
+ string Hash(string)
|
||||
+ bool Verify(string, string)
|
||||
}
|
||||
|
||||
class Argon2Infrastructure {
|
||||
- {static} int SaltSize = 16
|
||||
- {static} int HashSize = 32
|
||||
- {static} int ArgonIterations = 4
|
||||
- {static} int ArgonMemoryKb = 65536
|
||||
+ string Hash(string)
|
||||
+ bool Verify(string, string)
|
||||
}
|
||||
}
|
||||
|
||||
package "Infrastructure.Jwt" {
|
||||
interface ITokenInfrastructure {
|
||||
+ string GenerateJwt(Guid, string, DateTime)
|
||||
}
|
||||
|
||||
class JwtInfrastructure {
|
||||
- string? _secret
|
||||
+ string GenerateJwt(Guid, string, DateTime)
|
||||
}
|
||||
}
|
||||
|
||||
package "Infrastructure.Email" {
|
||||
interface IEmailProvider {
|
||||
+ <<async>> Task SendAsync(string, string, string, bool)
|
||||
+ <<async>> Task SendAsync(IEnumerable<string>, string, string, bool)
|
||||
}
|
||||
|
||||
class SmtpEmailProvider {
|
||||
- string _host
|
||||
- int _port
|
||||
- string? _username
|
||||
- string? _password
|
||||
- bool _useSsl
|
||||
- string _fromEmail
|
||||
- string _fromName
|
||||
+ <<async>> Task SendAsync(string, string, string, bool)
|
||||
+ <<async>> Task SendAsync(IEnumerable<string>, string, string, bool)
|
||||
}
|
||||
}
|
||||
|
||||
package "Infrastructure.Email.Templates" {
|
||||
interface IEmailTemplateProvider {
|
||||
+ <<async>> Task<string> RenderUserRegisteredEmailAsync(string, string)
|
||||
}
|
||||
|
||||
class EmailTemplateProvider {
|
||||
- IServiceProvider _serviceProvider
|
||||
- ILoggerFactory _loggerFactory
|
||||
+ <<async>> Task<string> RenderUserRegisteredEmailAsync(string, string)
|
||||
- <<async>> Task<string> RenderComponentAsync<TComponent>(Dictionary<string, object?>)
|
||||
}
|
||||
|
||||
class "UserRegistration <<Razor>>" {
|
||||
+ string Username
|
||||
+ string ConfirmationLink
|
||||
}
|
||||
|
||||
class "Header <<Razor>>" {
|
||||
}
|
||||
|
||||
class "Footer <<Razor>>" {
|
||||
+ string? FooterText
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
' Database Layer
|
||||
package "Database" <<Rectangle>> #FFCCBC {
|
||||
|
||||
class "SQL Server" <<Database>> {
|
||||
.. Tables ..
|
||||
UserAccount
|
||||
UserCredential
|
||||
UserVerification
|
||||
UserAvatar
|
||||
Photo
|
||||
UserFollow
|
||||
Country
|
||||
StateProvince
|
||||
City
|
||||
BreweryPost
|
||||
BreweryPostLocation
|
||||
BreweryPostPhoto
|
||||
BeerStyle
|
||||
BeerPost
|
||||
BeerPostPhoto
|
||||
BeerPostComment
|
||||
.. Stored Procedures ..
|
||||
USP_RegisterUser
|
||||
usp_GetUserAccountByUsername
|
||||
usp_GetUserAccountByEmail
|
||||
usp_GetUserAccountById
|
||||
USP_GetActiveUserCredentialByUserAccountId
|
||||
USP_RotateUserCredential
|
||||
USP_CreateUserVerification
|
||||
}
|
||||
}
|
||||
|
||||
' Relationships - API to Service
|
||||
AuthController ..> IRegisterService : uses
|
||||
AuthController ..> ILoginService : uses
|
||||
UserController ..> IUserService : uses
|
||||
|
||||
AuthController ..> RegisterRequest : receives
|
||||
AuthController ..> LoginRequest : receives
|
||||
AuthController ..> "ResponseBody<T>" : returns
|
||||
AuthController ..> RegistrationPayload : returns
|
||||
AuthController ..> LoginPayload : returns
|
||||
|
||||
RegisterRequest ..> RegisterRequestValidator : validated by
|
||||
LoginRequest ..> LoginRequestValidator : validated by
|
||||
|
||||
' Relationships - Service Layer
|
||||
IRegisterService <|.. RegisterService : implements
|
||||
ILoginService <|.. LoginService : implements
|
||||
ITokenService <|.. TokenService : implements
|
||||
IUserService <|.. UserService : implements
|
||||
IEmailService <|.. EmailService : implements
|
||||
|
||||
RegisterService ..> IAuthRepository : uses
|
||||
RegisterService ..> IPasswordInfrastructure : uses
|
||||
RegisterService ..> ITokenService : uses
|
||||
RegisterService ..> IEmailService : uses
|
||||
RegisterService ..> RegisterServiceReturn : returns
|
||||
RegisterService ..> UserAccount : uses
|
||||
|
||||
LoginService ..> IAuthRepository : uses
|
||||
LoginService ..> IPasswordInfrastructure : uses
|
||||
LoginService ..> ITokenService : uses
|
||||
LoginService ..> LoginServiceReturn : returns
|
||||
LoginService ..> UserAccount : uses
|
||||
LoginService ..> UserCredential : uses
|
||||
|
||||
TokenService ..> ITokenInfrastructure : uses
|
||||
TokenService ..> UserAccount : uses
|
||||
|
||||
UserService ..> IUserAccountRepository : uses
|
||||
UserService ..> UserAccount : uses
|
||||
|
||||
EmailService ..> IEmailProvider : uses
|
||||
EmailService ..> IEmailTemplateProvider : uses
|
||||
EmailService ..> UserAccount : uses
|
||||
|
||||
' Relationships - Repository Layer
|
||||
IAuthRepository <|.. AuthRepository : implements
|
||||
IUserAccountRepository <|.. UserAccountRepository : implements
|
||||
"Repository<T>" <|-- AuthRepository : extends
|
||||
"Repository<T>" <|-- UserAccountRepository : extends
|
||||
|
||||
AuthRepository ..> ISqlConnectionFactory : uses
|
||||
AuthRepository ..> UserAccount : returns
|
||||
AuthRepository ..> UserCredential : returns
|
||||
AuthRepository ..> "SQL Server" : queries
|
||||
|
||||
UserAccountRepository ..> ISqlConnectionFactory : uses
|
||||
UserAccountRepository ..> UserAccount : returns
|
||||
UserAccountRepository ..> "SQL Server" : queries
|
||||
|
||||
"Repository<T>" ..> ISqlConnectionFactory : uses
|
||||
|
||||
ISqlConnectionFactory <|.. DefaultSqlConnectionFactory : implements
|
||||
DefaultSqlConnectionFactory ..> SqlConnectionStringHelper : uses
|
||||
|
||||
' Relationships - Infrastructure
|
||||
IPasswordInfrastructure <|.. Argon2Infrastructure : implements
|
||||
ITokenInfrastructure <|.. JwtInfrastructure : implements
|
||||
IEmailProvider <|.. SmtpEmailProvider : implements
|
||||
IEmailTemplateProvider <|.. EmailTemplateProvider : implements
|
||||
|
||||
EmailTemplateProvider ..> "UserRegistration <<Razor>>" : renders
|
||||
"UserRegistration <<Razor>>" ..> "Header <<Razor>>" : includes
|
||||
"UserRegistration <<Razor>>" ..> "Footer <<Razor>>" : includes
|
||||
|
||||
' Relationships - Domain
|
||||
UserAccount -- UserCredential : "1" -- "*"
|
||||
UserAccount -- UserVerification : "1" -- "0..1"
|
||||
|
||||
' Exception handling
|
||||
GlobalExceptionFilter ..> ConflictException : catches
|
||||
GlobalExceptionFilter ..> NotFoundException : catches
|
||||
GlobalExceptionFilter ..> UnauthorizedException : catches
|
||||
GlobalExceptionFilter ..> ForbiddenException : catches
|
||||
GlobalExceptionFilter ..> ValidationException : catches
|
||||
|
||||
RegisterService ..> ConflictException : throws
|
||||
LoginService ..> UnauthorizedException : throws
|
||||
UserService ..> NotFoundException : throws
|
||||
|
||||
' Notes
|
||||
note right of Argon2Infrastructure
|
||||
Security Parameters:
|
||||
- Memory: 64MB
|
||||
- Iterations: 4
|
||||
- Salt: 16 bytes
|
||||
- Output: 32 bytes
|
||||
end note
|
||||
|
||||
note right of JwtInfrastructure
|
||||
JWT Configuration:
|
||||
- Algorithm: HS256
|
||||
- Access: 1 hour
|
||||
- Refresh: 21 days
|
||||
end note
|
||||
|
||||
note right of "SQL Server"
|
||||
Stored Procedures:
|
||||
- USP_RegisterUser: Transaction
|
||||
creates UserAccount +
|
||||
UserCredential
|
||||
- Credentials tracked with
|
||||
IsRevoked flag
|
||||
end note
|
||||
|
||||
note right of AuthRepository
|
||||
Uses ADO.NET with
|
||||
parameterized queries
|
||||
to prevent SQL injection
|
||||
end note
|
||||
|
||||
note bottom of RegisterService
|
||||
Registration Flow:
|
||||
1. Validate user doesn't exist
|
||||
2. Hash password (Argon2)
|
||||
3. Create account + credential
|
||||
4. Generate tokens
|
||||
5. Send confirmation email
|
||||
end note
|
||||
|
||||
note bottom of LoginService
|
||||
Login Flow:
|
||||
1. Find user by username
|
||||
2. Get active credential
|
||||
3. Verify password
|
||||
4. Generate tokens
|
||||
5. Return authenticated user
|
||||
end note
|
||||
|
||||
@enduml
|
||||
104
docs/diagrams/database-schema.puml
Normal file
104
docs/diagrams/database-schema.puml
Normal file
@@ -0,0 +1,104 @@
|
||||
@startuml database-schema
|
||||
!theme plain
|
||||
skinparam backgroundColor #FFFFFF
|
||||
skinparam defaultFontName Arial
|
||||
skinparam linetype ortho
|
||||
|
||||
title Key Database Schema - User & Authentication
|
||||
|
||||
entity "UserAccount" as User {
|
||||
* UserAccountId: INT <<PK>>
|
||||
--
|
||||
* Username: NVARCHAR(30) <<UNIQUE>>
|
||||
* Email: NVARCHAR(255) <<UNIQUE>>
|
||||
* FirstName: NVARCHAR(50)
|
||||
* LastName: NVARCHAR(50)
|
||||
Bio: NVARCHAR(500)
|
||||
CreatedAt: DATETIME2
|
||||
UpdatedAt: DATETIME2
|
||||
LastLoginAt: DATETIME2
|
||||
}
|
||||
|
||||
entity "UserCredential" as Cred {
|
||||
* UserCredentialId: INT <<PK>>
|
||||
--
|
||||
* UserAccountId: INT <<FK>>
|
||||
* PasswordHash: VARBINARY(32)
|
||||
* PasswordSalt: VARBINARY(16)
|
||||
CredentialRotatedAt: DATETIME2
|
||||
CredentialExpiresAt: DATETIME2
|
||||
CredentialRevokedAt: DATETIME2
|
||||
* IsActive: BIT
|
||||
CreatedAt: DATETIME2
|
||||
}
|
||||
|
||||
entity "UserVerification" as Verify {
|
||||
* UserVerificationId: INT <<PK>>
|
||||
--
|
||||
* UserAccountId: INT <<FK>>
|
||||
* IsVerified: BIT
|
||||
VerifiedAt: DATETIME2
|
||||
VerificationToken: NVARCHAR(255)
|
||||
TokenExpiresAt: DATETIME2
|
||||
}
|
||||
|
||||
entity "UserAvatar" as Avatar {
|
||||
* UserAvatarId: INT <<PK>>
|
||||
--
|
||||
* UserAccountId: INT <<FK>>
|
||||
PhotoId: INT <<FK>>
|
||||
* IsActive: BIT
|
||||
CreatedAt: DATETIME2
|
||||
}
|
||||
|
||||
entity "UserFollow" as Follow {
|
||||
* UserFollowId: INT <<PK>>
|
||||
--
|
||||
* FollowerUserId: INT <<FK>>
|
||||
* FollowedUserId: INT <<FK>>
|
||||
CreatedAt: DATETIME2
|
||||
}
|
||||
|
||||
entity "Photo" as Photo {
|
||||
* PhotoId: INT <<PK>>
|
||||
--
|
||||
* Url: NVARCHAR(500)
|
||||
* CloudinaryPublicId: NVARCHAR(255)
|
||||
Width: INT
|
||||
Height: INT
|
||||
Format: NVARCHAR(10)
|
||||
CreatedAt: DATETIME2
|
||||
}
|
||||
|
||||
' Relationships
|
||||
User ||--o{ Cred : "has"
|
||||
User ||--o| Verify : "has"
|
||||
User ||--o{ Avatar : "has"
|
||||
User ||--o{ Follow : "follows"
|
||||
User ||--o{ Follow : "followed by"
|
||||
Avatar }o--|| Photo : "refers to"
|
||||
|
||||
note right of Cred
|
||||
Password hashing:
|
||||
- Algorithm: Argon2id
|
||||
- Memory: 64MB
|
||||
- Iterations: 4
|
||||
- Salt: 128-bit
|
||||
- Hash: 256-bit
|
||||
end note
|
||||
|
||||
note right of Verify
|
||||
Account verification
|
||||
via email token
|
||||
with expiry
|
||||
end note
|
||||
|
||||
note bottom of User
|
||||
Core stored procedures:
|
||||
- USP_RegisterUser
|
||||
- USP_GetUserAccountByUsername
|
||||
- USP_RotateUserCredential
|
||||
- USP_UpdateUserAccount
|
||||
end note
|
||||
|
||||
@enduml
|
||||
227
docs/diagrams/deployment.puml
Normal file
227
docs/diagrams/deployment.puml
Normal file
@@ -0,0 +1,227 @@
|
||||
@startuml deployment
|
||||
!theme plain
|
||||
skinparam backgroundColor #FFFFFF
|
||||
skinparam defaultFontName Arial
|
||||
skinparam linetype ortho
|
||||
|
||||
title Docker Deployment Architecture
|
||||
|
||||
' External systems
|
||||
actor Developer
|
||||
cloud "Docker Host" as Host
|
||||
|
||||
package "Development Environment\n(docker-compose.dev.yaml)" #E3F2FD {
|
||||
|
||||
node "SQL Server\n(mcr.microsoft.com/mssql/server:2022-latest)" as DevDB {
|
||||
database "Biergarten\nDatabase" as DevDBInner {
|
||||
portin "1433"
|
||||
}
|
||||
note right
|
||||
Environment:
|
||||
- ACCEPT_EULA=Y
|
||||
- SA_PASSWORD=***
|
||||
- MSSQL_PID=Developer
|
||||
|
||||
Volumes:
|
||||
- biergarten-dev-data
|
||||
end note
|
||||
}
|
||||
|
||||
node "API Container\n(API.Core)" as DevAPI {
|
||||
component "ASP.NET Core 10" as API1
|
||||
portin "8080:8080 (HTTP)" as DevPort1
|
||||
portin "8081:8081 (HTTPS)" as DevPort2
|
||||
|
||||
note right
|
||||
Environment:
|
||||
- ASPNETCORE_ENVIRONMENT=Development
|
||||
- DB_SERVER=sql-server
|
||||
- DB_NAME=Biergarten
|
||||
- DB_USER/PASSWORD
|
||||
- JWT_SECRET
|
||||
- SMTP_* (10+ variables)
|
||||
|
||||
Health Check:
|
||||
/health endpoint
|
||||
end note
|
||||
}
|
||||
|
||||
node "Migrations\n(run-once)" as DevMig {
|
||||
component "Database.Migrations" as Mig1
|
||||
note bottom
|
||||
Runs: DbUp migrations
|
||||
Environment:
|
||||
- CLEAR_DATABASE=false
|
||||
Depends on: sql-server
|
||||
end note
|
||||
}
|
||||
|
||||
node "Seed\n(run-once)" as DevSeed {
|
||||
component "Database.Seed" as Seed1
|
||||
note bottom
|
||||
Creates:
|
||||
- 100 test users
|
||||
- Location data (US/CA/MX)
|
||||
- test.user account
|
||||
Depends on: migrations
|
||||
end note
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
package "Test Environment\n(docker-compose.test.yaml)" #FFF3E0 {
|
||||
|
||||
node "SQL Server\n(isolated instance)" as TestDB {
|
||||
database "Biergarten\nTest Database" as TestDBInner {
|
||||
portin "1434"
|
||||
}
|
||||
note right
|
||||
Fresh instance each run
|
||||
CLEAR_DATABASE=true
|
||||
|
||||
Volumes:
|
||||
- biergarten-test-data
|
||||
(ephemeral)
|
||||
end note
|
||||
}
|
||||
|
||||
node "Migrations\n(test)" as TestMig {
|
||||
component "Database.Migrations"
|
||||
}
|
||||
|
||||
node "Seed\n(test)" as TestSeed {
|
||||
component "Database.Seed"
|
||||
note bottom
|
||||
Minimal seed:
|
||||
- test.user only
|
||||
- Essential data
|
||||
end note
|
||||
}
|
||||
|
||||
node "API.Specs\n(Integration Tests)" as Specs {
|
||||
component "Reqnroll + xUnit" as SpecsComp
|
||||
note right
|
||||
Tests:
|
||||
- Registration flow
|
||||
- Login flow
|
||||
- Validation rules
|
||||
- 404 handling
|
||||
|
||||
Uses: TestApiFactory
|
||||
Mocks: Email services
|
||||
end note
|
||||
}
|
||||
|
||||
node "Infrastructure.Repository.Tests\n(Unit Tests)" as RepoTests {
|
||||
component "xUnit + DbMocker" as RepoComp
|
||||
note right
|
||||
Tests:
|
||||
- AuthRepository
|
||||
- UserAccountRepository
|
||||
- SQL command building
|
||||
|
||||
Uses: Mock connections
|
||||
No real database needed
|
||||
end note
|
||||
}
|
||||
|
||||
node "Service.Auth.Tests\n(Unit Tests)" as SvcTests {
|
||||
component "xUnit + Moq" as SvcComp
|
||||
note right
|
||||
Tests:
|
||||
- RegisterService
|
||||
- LoginService
|
||||
- Token generation
|
||||
|
||||
Uses: Mocked dependencies
|
||||
No database or infrastructure
|
||||
end note
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
folder "test-results/\n(mounted volume)" as Results {
|
||||
file "api-specs/\n results.trx" as Result1
|
||||
file "repository-tests/\n results.trx" as Result2
|
||||
file "service-auth-tests/\n results.trx" as Result3
|
||||
|
||||
note bottom
|
||||
TRX format
|
||||
Readable by:
|
||||
- Visual Studio
|
||||
- Azure DevOps
|
||||
- GitHub Actions
|
||||
end note
|
||||
}
|
||||
|
||||
' External access
|
||||
Developer --> Host : docker compose up
|
||||
Host --> DevAPI : http://localhost:8080
|
||||
|
||||
' Development dependencies
|
||||
DevMig --> DevDB : 1. Run migrations
|
||||
DevSeed --> DevDB : 2. Seed data
|
||||
DevAPI --> DevDB : 3. Connect & serve
|
||||
DevMig .up.> DevDB : depends_on
|
||||
DevSeed .up.> DevMig : depends_on
|
||||
DevAPI .up.> DevSeed : depends_on
|
||||
|
||||
' Test dependencies
|
||||
TestMig --> TestDB : 1. Migrate
|
||||
TestSeed --> TestDB : 2. Seed
|
||||
Specs --> TestDB : 3. Integration test
|
||||
RepoTests ..> TestDB : Mock (no connection)
|
||||
SvcTests ..> TestDB : Mock (no connection)
|
||||
|
||||
TestMig .up.> TestDB : depends_on
|
||||
TestSeed .up.> TestMig : depends_on
|
||||
Specs .up.> TestSeed : depends_on
|
||||
|
||||
' Test results export
|
||||
Specs --> Results : Export TRX
|
||||
RepoTests --> Results : Export TRX
|
||||
SvcTests --> Results : Export TRX
|
||||
|
||||
' Network notes
|
||||
note bottom of DevDB
|
||||
<b>Dev Network (bridge: biergarten-dev)</b>
|
||||
Internal DNS:
|
||||
- sql-server (resolves to SQL container)
|
||||
- api (resolves to API container)
|
||||
end note
|
||||
|
||||
note bottom of TestDB
|
||||
<b>Test Network (bridge: biergarten-test)</b>
|
||||
All test components isolated
|
||||
end note
|
||||
|
||||
' Startup sequence notes
|
||||
note top of DevMig
|
||||
Startup Order:
|
||||
1. SQL Server (health check)
|
||||
2. Migrations (run-once)
|
||||
3. Seed (run-once)
|
||||
4. API (long-running)
|
||||
end note
|
||||
|
||||
note top of Specs
|
||||
Test Execution:
|
||||
All tests run in parallel
|
||||
Results aggregated
|
||||
end note
|
||||
|
||||
' Production note
|
||||
note as ProductionNote
|
||||
<b>Production Deployment (not shown):</b>
|
||||
|
||||
Would include:
|
||||
• Azure SQL Database / AWS RDS
|
||||
• Azure Container Apps / ECS
|
||||
• Azure Key Vault for secrets
|
||||
• Application Insights / CloudWatch
|
||||
• Load balancer
|
||||
• HTTPS termination
|
||||
• CDN for static assets
|
||||
end note
|
||||
|
||||
@enduml
|
||||
Reference in New Issue
Block a user