mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-07-16 17:47:22 +00:00
add xmldoc comments
This commit is contained in:
@@ -8,6 +8,15 @@ using Microsoft.Extensions.Options;
|
|||||||
|
|
||||||
namespace API.Core.Authentication;
|
namespace API.Core.Authentication;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Custom ASP.NET Core authentication handler that validates bearer JWT access tokens
|
||||||
|
/// supplied in the <c>Authorization</c> header against the <c>JWT</c> authentication scheme.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="options">The monitored options for the <c>JWT</c> authentication scheme.</param>
|
||||||
|
/// <param name="logger">The logger factory used by the base <see cref="AuthenticationHandler{TOptions}"/>.</param>
|
||||||
|
/// <param name="encoder">The URL encoder used by the base <see cref="AuthenticationHandler{TOptions}"/>.</param>
|
||||||
|
/// <param name="tokenInfrastructure">The infrastructure service used to validate JWT access tokens.</param>
|
||||||
|
/// <param name="configuration">The application configuration, used as a fallback source for the JWT secret key.</param>
|
||||||
public class JwtAuthenticationHandler(
|
public class JwtAuthenticationHandler(
|
||||||
IOptionsMonitor<JwtAuthenticationOptions> options,
|
IOptionsMonitor<JwtAuthenticationOptions> options,
|
||||||
ILoggerFactory logger,
|
ILoggerFactory logger,
|
||||||
@@ -16,6 +25,20 @@ public class JwtAuthenticationHandler(
|
|||||||
IConfiguration configuration
|
IConfiguration configuration
|
||||||
) : AuthenticationHandler<JwtAuthenticationOptions>(options, logger, encoder)
|
) : AuthenticationHandler<JwtAuthenticationOptions>(options, logger, encoder)
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Validates the incoming request's bearer JWT access token and produces an authentication result.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The signing secret is resolved first from the <c>ACCESS_TOKEN_SECRET</c> environment variable, falling
|
||||||
|
/// back to the <c>Jwt:SecretKey</c> configuration value, to stay consistent with the secret source used
|
||||||
|
/// when tokens are issued. Authentication fails if the secret is not configured, the
|
||||||
|
/// <c>Authorization</c> header is missing, the header does not use the <c>Bearer</c> scheme, or the
|
||||||
|
/// token fails validation.
|
||||||
|
/// </remarks>
|
||||||
|
/// <returns>
|
||||||
|
/// A successful <see cref="AuthenticateResult"/> containing the validated <see cref="System.Security.Claims.ClaimsPrincipal"/>
|
||||||
|
/// on success, or a failure result describing why authentication could not be completed.
|
||||||
|
/// </returns>
|
||||||
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||||
{
|
{
|
||||||
// Use the same access-token secret source as TokenService to avoid mismatched validation.
|
// Use the same access-token secret source as TokenService to avoid mismatched validation.
|
||||||
@@ -73,6 +96,11 @@ public class JwtAuthenticationHandler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes a JSON 401 Unauthorized response when authentication fails or is required but not supplied.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="properties">The authentication properties associated with the challenge.</param>
|
||||||
|
/// <returns>A task that completes once the response body has been written.</returns>
|
||||||
protected override async Task HandleChallengeAsync(AuthenticationProperties properties)
|
protected override async Task HandleChallengeAsync(AuthenticationProperties properties)
|
||||||
{
|
{
|
||||||
Response.ContentType = "application/json";
|
Response.ContentType = "application/json";
|
||||||
@@ -83,4 +111,8 @@ public class JwtAuthenticationHandler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Options for the <c>JWT</c> authentication scheme handled by <see cref="JwtAuthenticationHandler"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>No additional options are defined beyond those provided by <see cref="AuthenticationSchemeOptions"/>.</remarks>
|
||||||
public class JwtAuthenticationOptions : AuthenticationSchemeOptions { }
|
public class JwtAuthenticationOptions : AuthenticationSchemeOptions { }
|
||||||
|
|||||||
@@ -3,6 +3,13 @@ using Org.BouncyCastle.Asn1.Cms;
|
|||||||
|
|
||||||
namespace API.Core.Contracts.Auth;
|
namespace API.Core.Contracts.Auth;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Payload returned to the client after a successful login or token refresh.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="UserAccountId">The unique identifier of the authenticated user account.</param>
|
||||||
|
/// <param name="Username">The username of the authenticated user account.</param>
|
||||||
|
/// <param name="RefreshToken">The newly issued refresh token used to obtain new access tokens.</param>
|
||||||
|
/// <param name="AccessToken">The newly issued JWT access token used to authorize subsequent requests.</param>
|
||||||
public record LoginPayload(
|
public record LoginPayload(
|
||||||
Guid UserAccountId,
|
Guid UserAccountId,
|
||||||
string Username,
|
string Username,
|
||||||
@@ -10,6 +17,14 @@ public record LoginPayload(
|
|||||||
string AccessToken
|
string AccessToken
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Payload returned to the client after a successful registration.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="UserAccountId">The unique identifier of the newly created user account.</param>
|
||||||
|
/// <param name="Username">The username of the newly created user account.</param>
|
||||||
|
/// <param name="RefreshToken">The refresh token issued for the new account.</param>
|
||||||
|
/// <param name="AccessToken">The JWT access token issued for the new account.</param>
|
||||||
|
/// <param name="ConfirmationEmailSent">Whether a confirmation email was successfully sent to the new user.</param>
|
||||||
public record RegistrationPayload(
|
public record RegistrationPayload(
|
||||||
Guid UserAccountId,
|
Guid UserAccountId,
|
||||||
string Username,
|
string Username,
|
||||||
@@ -18,4 +33,9 @@ public record RegistrationPayload(
|
|||||||
bool ConfirmationEmailSent
|
bool ConfirmationEmailSent
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Payload returned to the client after a user account's email has been confirmed.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="UserAccountId">The unique identifier of the user account that was confirmed.</param>
|
||||||
|
/// <param name="ConfirmedDate">The date and time at which the account was confirmed.</param>
|
||||||
public record ConfirmationPayload(Guid UserAccountId, DateTime ConfirmedDate);
|
public record ConfirmationPayload(Guid UserAccountId, DateTime ConfirmedDate);
|
||||||
|
|||||||
@@ -3,14 +3,31 @@ using FluentValidation;
|
|||||||
|
|
||||||
namespace API.Core.Contracts.Auth;
|
namespace API.Core.Contracts.Auth;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Request body for the login endpoint, containing the credentials used to authenticate a user.
|
||||||
|
/// </summary>
|
||||||
public record LoginRequest
|
public record LoginRequest
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The username of the account attempting to log in.
|
||||||
|
/// </summary>
|
||||||
public string Username { get; init; } = default!;
|
public string Username { get; init; } = default!;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The plaintext password of the account attempting to log in.
|
||||||
|
/// </summary>
|
||||||
public string Password { get; init; } = default!;
|
public string Password { get; init; } = default!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates <see cref="LoginRequest"/> instances before they are processed by the login endpoint.
|
||||||
|
/// </summary>
|
||||||
public class LoginRequestValidator : AbstractValidator<LoginRequest>
|
public class LoginRequestValidator : AbstractValidator<LoginRequest>
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Configures validation rules requiring both <see cref="LoginRequest.Username"/> and
|
||||||
|
/// <see cref="LoginRequest.Password"/> to be non-empty.
|
||||||
|
/// </summary>
|
||||||
public LoginRequestValidator()
|
public LoginRequestValidator()
|
||||||
{
|
{
|
||||||
RuleFor(x => x.Username).NotEmpty().WithMessage("Username is required");
|
RuleFor(x => x.Username).NotEmpty().WithMessage("Username is required");
|
||||||
|
|||||||
@@ -2,14 +2,26 @@ using FluentValidation;
|
|||||||
|
|
||||||
namespace API.Core.Contracts.Auth;
|
namespace API.Core.Contracts.Auth;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Request body for the token refresh endpoint, containing the refresh token used to obtain a new access token.
|
||||||
|
/// </summary>
|
||||||
public record RefreshTokenRequest
|
public record RefreshTokenRequest
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The refresh token previously issued to the client during login, registration, or a prior refresh.
|
||||||
|
/// </summary>
|
||||||
public string RefreshToken { get; init; } = default!;
|
public string RefreshToken { get; init; } = default!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates <see cref="RefreshTokenRequest"/> instances before they are processed by the refresh endpoint.
|
||||||
|
/// </summary>
|
||||||
public class RefreshTokenRequestValidator
|
public class RefreshTokenRequestValidator
|
||||||
: AbstractValidator<RefreshTokenRequest>
|
: AbstractValidator<RefreshTokenRequest>
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Configures a validation rule requiring <see cref="RefreshTokenRequest.RefreshToken"/> to be non-empty.
|
||||||
|
/// </summary>
|
||||||
public RefreshTokenRequestValidator()
|
public RefreshTokenRequestValidator()
|
||||||
{
|
{
|
||||||
RuleFor(x => x.RefreshToken)
|
RuleFor(x => x.RefreshToken)
|
||||||
|
|||||||
@@ -3,6 +3,15 @@ using FluentValidation;
|
|||||||
|
|
||||||
namespace API.Core.Contracts.Auth;
|
namespace API.Core.Contracts.Auth;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Request body for the registration endpoint, containing the details needed to create a new user account.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Username">The desired username; must be 3-64 characters and contain only letters, numbers, dots, underscores, and hyphens.</param>
|
||||||
|
/// <param name="FirstName">The user's first name; up to 128 characters.</param>
|
||||||
|
/// <param name="LastName">The user's last name; up to 128 characters.</param>
|
||||||
|
/// <param name="Email">The user's email address; up to 128 characters and must be a valid email format.</param>
|
||||||
|
/// <param name="DateOfBirth">The user's date of birth; the user must be at least 19 years old.</param>
|
||||||
|
/// <param name="Password">The desired plaintext password; must be at least 8 characters and contain an uppercase letter, a lowercase letter, a number, and a special character.</param>
|
||||||
public record RegisterRequest(
|
public record RegisterRequest(
|
||||||
string Username,
|
string Username,
|
||||||
string FirstName,
|
string FirstName,
|
||||||
@@ -12,8 +21,15 @@ public record RegisterRequest(
|
|||||||
string Password
|
string Password
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates <see cref="RegisterRequest"/> instances before they are processed by the registration endpoint.
|
||||||
|
/// </summary>
|
||||||
public class RegisterRequestValidator : AbstractValidator<RegisterRequest>
|
public class RegisterRequestValidator : AbstractValidator<RegisterRequest>
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Configures validation rules for username format and length, first/last name length, email format and
|
||||||
|
/// length, minimum age based on date of birth, and password strength requirements.
|
||||||
|
/// </summary>
|
||||||
public RegisterRequestValidator()
|
public RegisterRequestValidator()
|
||||||
{
|
{
|
||||||
RuleFor(x => x.Username)
|
RuleFor(x => x.Username)
|
||||||
|
|||||||
@@ -2,8 +2,17 @@ using FluentValidation;
|
|||||||
|
|
||||||
namespace API.Core.Contracts.Breweries;
|
namespace API.Core.Contracts.Breweries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates <see cref="BreweryCreateDto"/> instances before they are processed by the brewery creation endpoint.
|
||||||
|
/// </summary>
|
||||||
public class BreweryCreateDtoValidator : AbstractValidator<BreweryCreateDto>
|
public class BreweryCreateDtoValidator : AbstractValidator<BreweryCreateDto>
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Configures validation rules requiring <see cref="BreweryCreateDto.PostedById"/>,
|
||||||
|
/// <see cref="BreweryCreateDto.BreweryName"/>, <see cref="BreweryCreateDto.Description"/>, and
|
||||||
|
/// <see cref="BreweryCreateDto.Location"/> to be present, with length limits on the name, description,
|
||||||
|
/// address line 1, and postal code fields.
|
||||||
|
/// </summary>
|
||||||
public BreweryCreateDtoValidator()
|
public BreweryCreateDtoValidator()
|
||||||
{
|
{
|
||||||
RuleFor(x => x.PostedById)
|
RuleFor(x => x.PostedById)
|
||||||
|
|||||||
@@ -1,41 +1,147 @@
|
|||||||
namespace API.Core.Contracts.Breweries;
|
namespace API.Core.Contracts.Breweries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Request payload describing the location of a brewery to be created, supplied as part of
|
||||||
|
/// <see cref="BreweryCreateDto"/>.
|
||||||
|
/// </summary>
|
||||||
public class BreweryLocationCreateDto
|
public class BreweryLocationCreateDto
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The unique identifier of the city in which the brewery is located.
|
||||||
|
/// </summary>
|
||||||
public Guid CityId { get; set; }
|
public Guid CityId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The primary street address line of the brewery.
|
||||||
|
/// </summary>
|
||||||
public string AddressLine1 { get; set; } = string.Empty;
|
public string AddressLine1 { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An optional secondary address line (e.g. suite or unit number).
|
||||||
|
/// </summary>
|
||||||
public string? AddressLine2 { get; set; }
|
public string? AddressLine2 { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The postal/ZIP code of the brewery's address.
|
||||||
|
/// </summary>
|
||||||
public string PostalCode { get; set; } = string.Empty;
|
public string PostalCode { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The optional geographic coordinates of the brewery, in a raw binary representation.
|
||||||
|
/// </summary>
|
||||||
public byte[]? Coordinates { get; set; }
|
public byte[]? Coordinates { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the location details of an existing brewery, as returned in <see cref="BreweryDto"/>.
|
||||||
|
/// </summary>
|
||||||
public class BreweryLocationDto
|
public class BreweryLocationDto
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The unique identifier of the brewery's location record.
|
||||||
|
/// </summary>
|
||||||
public Guid BreweryPostLocationId { get; set; }
|
public Guid BreweryPostLocationId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The unique identifier of the brewery post that this location belongs to.
|
||||||
|
/// </summary>
|
||||||
public Guid BreweryPostId { get; set; }
|
public Guid BreweryPostId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The unique identifier of the city in which the brewery is located.
|
||||||
|
/// </summary>
|
||||||
public Guid CityId { get; set; }
|
public Guid CityId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The primary street address line of the brewery.
|
||||||
|
/// </summary>
|
||||||
public string AddressLine1 { get; set; } = string.Empty;
|
public string AddressLine1 { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An optional secondary address line (e.g. suite or unit number).
|
||||||
|
/// </summary>
|
||||||
public string? AddressLine2 { get; set; }
|
public string? AddressLine2 { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The postal/ZIP code of the brewery's address.
|
||||||
|
/// </summary>
|
||||||
public string PostalCode { get; set; } = string.Empty;
|
public string PostalCode { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The optional geographic coordinates of the brewery, in a raw binary representation.
|
||||||
|
/// </summary>
|
||||||
public byte[]? Coordinates { get; set; }
|
public byte[]? Coordinates { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Request body used by <see cref="Controllers.BreweryController.Create"/> to create a new brewery post.
|
||||||
|
/// </summary>
|
||||||
public class BreweryCreateDto
|
public class BreweryCreateDto
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The unique identifier of the user account that is creating the brewery post.
|
||||||
|
/// </summary>
|
||||||
public Guid PostedById { get; set; }
|
public Guid PostedById { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The name of the brewery; required and limited to 256 characters.
|
||||||
|
/// </summary>
|
||||||
public string BreweryName { get; set; } = string.Empty;
|
public string BreweryName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A description of the brewery; required and limited to 512 characters.
|
||||||
|
/// </summary>
|
||||||
public string Description { get; set; } = string.Empty;
|
public string Description { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The location details of the brewery being created.
|
||||||
|
/// </summary>
|
||||||
public BreweryLocationCreateDto Location { get; set; } = null!;
|
public BreweryLocationCreateDto Location { get; set; } = null!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a brewery post as returned by, or submitted to, the brewery endpoints, including its
|
||||||
|
/// metadata and optional location.
|
||||||
|
/// </summary>
|
||||||
public class BreweryDto
|
public class BreweryDto
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The unique identifier of the brewery post.
|
||||||
|
/// </summary>
|
||||||
public Guid BreweryPostId { get; set; }
|
public Guid BreweryPostId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The unique identifier of the user account that created the brewery post.
|
||||||
|
/// </summary>
|
||||||
public Guid PostedById { get; set; }
|
public Guid PostedById { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The name of the brewery.
|
||||||
|
/// </summary>
|
||||||
public string BreweryName { get; set; } = string.Empty;
|
public string BreweryName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A description of the brewery.
|
||||||
|
/// </summary>
|
||||||
public string Description { get; set; } = string.Empty;
|
public string Description { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The date and time at which the brewery post was created.
|
||||||
|
/// </summary>
|
||||||
public DateTime CreatedAt { get; set; }
|
public DateTime CreatedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The date and time at which the brewery post was last updated, or <c>null</c> if it has never been updated.
|
||||||
|
/// </summary>
|
||||||
public DateTime? UpdatedAt { get; set; }
|
public DateTime? UpdatedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A row-version/concurrency token used to detect conflicting concurrent updates to the brewery post.
|
||||||
|
/// </summary>
|
||||||
public byte[]? Timer { get; set; }
|
public byte[]? Timer { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The location details of the brewery, or <c>null</c> if no location is associated with it.
|
||||||
|
/// </summary>
|
||||||
public BreweryLocationDto? Location { get; set; }
|
public BreweryLocationDto? Location { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,29 @@
|
|||||||
namespace API.Core.Contracts.Common;
|
namespace API.Core.Contracts.Common;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generic envelope used to wrap API responses that carry a data payload alongside a human-readable message.
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="T">The type of the data payload returned in the response.</typeparam>
|
||||||
public record ResponseBody<T>
|
public record ResponseBody<T>
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// A human-readable message describing the outcome of the request.
|
||||||
|
/// </summary>
|
||||||
public required string Message { get; init; }
|
public required string Message { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The data payload associated with the response.
|
||||||
|
/// </summary>
|
||||||
public required T Payload { get; init; }
|
public required T Payload { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Envelope used to wrap API responses that carry only a human-readable message and no data payload.
|
||||||
|
/// </summary>
|
||||||
public record ResponseBody
|
public record ResponseBody
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// A human-readable message describing the outcome of the request.
|
||||||
|
/// </summary>
|
||||||
public required string Message { get; init; }
|
public required string Message { get; init; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,17 @@ using Service.Auth;
|
|||||||
|
|
||||||
namespace API.Core.Controllers
|
namespace API.Core.Controllers
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Handles user authentication concerns: registration, login, email confirmation, and token refresh.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The controller is decorated with <c>[Authorize(AuthenticationSchemes = "JWT")]</c> by default, but most
|
||||||
|
/// actions opt out via <c>[AllowAnonymous]</c> since they are entry points used before a caller holds a token.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="registerService">Service used to register new user accounts.</param>
|
||||||
|
/// <param name="loginService">Service used to authenticate existing user accounts.</param>
|
||||||
|
/// <param name="confirmationService">Service used to confirm user accounts and resend confirmation emails.</param>
|
||||||
|
/// <param name="tokenService">Service used to refresh JWT access/refresh token pairs.</param>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/[controller]")]
|
[Route("api/[controller]")]
|
||||||
[Authorize(AuthenticationSchemes = "JWT")]
|
[Authorize(AuthenticationSchemes = "JWT")]
|
||||||
@@ -17,6 +28,15 @@ namespace API.Core.Controllers
|
|||||||
ITokenService tokenService
|
ITokenService tokenService
|
||||||
) : ControllerBase
|
) : ControllerBase
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Registers a new user account.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Allows anonymous access. On success, responds with <c>201 Created</c> containing the new user's ID,
|
||||||
|
/// username, issued refresh/access tokens, and whether a confirmation email was sent.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="req">The registration details, including username, name, email, date of birth, and password.</param>
|
||||||
|
/// <returns>A <c>201 Created</c> result wrapping a <see cref="ResponseBody{T}"/> of <see cref="RegistrationPayload"/>.</returns>
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
[HttpPost("register")]
|
[HttpPost("register")]
|
||||||
public async Task<ActionResult<UserAccount>> Register(
|
public async Task<ActionResult<UserAccount>> Register(
|
||||||
@@ -50,6 +70,15 @@ namespace API.Core.Controllers
|
|||||||
return Created("/", response);
|
return Created("/", response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Authenticates a user with a username and password.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Allows anonymous access. On success, responds with <c>200 OK</c> containing the user's ID,
|
||||||
|
/// username, and a newly issued refresh/access token pair.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="req">The login credentials (username and password).</param>
|
||||||
|
/// <returns>An <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}"/> of <see cref="LoginPayload"/>.</returns>
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
[HttpPost("login")]
|
[HttpPost("login")]
|
||||||
public async Task<ActionResult> Login([FromBody] LoginRequest req)
|
public async Task<ActionResult> Login([FromBody] LoginRequest req)
|
||||||
@@ -70,6 +99,15 @@ namespace API.Core.Controllers
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Confirms a user account using a confirmation token.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Requires JWT authentication. On success, responds with <c>200 OK</c> containing the confirmed
|
||||||
|
/// user's ID and the confirmation timestamp.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="token">The confirmation token supplied via the confirmation email link.</param>
|
||||||
|
/// <returns>A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}"/> of <see cref="ConfirmationPayload"/>.</returns>
|
||||||
[HttpPost("confirm")]
|
[HttpPost("confirm")]
|
||||||
public async Task<ActionResult> Confirm([FromQuery] string token)
|
public async Task<ActionResult> Confirm([FromQuery] string token)
|
||||||
{
|
{
|
||||||
@@ -86,6 +124,14 @@ namespace API.Core.Controllers
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resends the account confirmation email for the specified user.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Requires JWT authentication. On success, responds with <c>200 OK</c>.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="userId">The unique identifier of the user account to resend the confirmation email for.</param>
|
||||||
|
/// <returns>A <c>200 OK</c> result wrapping a <see cref="ResponseBody"/> confirming the email was resent.</returns>
|
||||||
[HttpPost("confirm/resend")]
|
[HttpPost("confirm/resend")]
|
||||||
public async Task<ActionResult> ResendConfirmation([FromQuery] Guid userId)
|
public async Task<ActionResult> ResendConfirmation([FromQuery] Guid userId)
|
||||||
{
|
{
|
||||||
@@ -93,6 +139,15 @@ namespace API.Core.Controllers
|
|||||||
return Ok(new ResponseBody { Message = "confirmation email has been resent" });
|
return Ok(new ResponseBody { Message = "confirmation email has been resent" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exchanges a valid refresh token for a new access/refresh token pair.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Allows anonymous access. On success, responds with <c>200 OK</c> containing the user's ID,
|
||||||
|
/// username, and the newly issued refresh/access token pair.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="req">The request containing the refresh token to exchange.</param>
|
||||||
|
/// <returns>A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}"/> of <see cref="LoginPayload"/>.</returns>
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
[HttpPost("refresh")]
|
[HttpPost("refresh")]
|
||||||
public async Task<ActionResult> Refresh(
|
public async Task<ActionResult> Refresh(
|
||||||
|
|||||||
@@ -1,16 +1,34 @@
|
|||||||
using API.Core.Contracts.Breweries;
|
using API.Core.Contracts.Breweries;
|
||||||
using API.Core.Contracts.Common;
|
using API.Core.Contracts.Common;
|
||||||
|
using Domain.Entities;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Service.Breweries;
|
using Service.Breweries;
|
||||||
|
|
||||||
namespace API.Core.Controllers;
|
namespace API.Core.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Provides CRUD endpoints for managing brewery posts.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The controller is decorated with <c>[Authorize(AuthenticationSchemes = "JWT")]</c> by default; read endpoints
|
||||||
|
/// (<see cref="GetById"/> and <see cref="GetAll"/>) opt out via <c>[AllowAnonymous]</c>.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="breweryService">Service used to query and mutate brewery post data.</param>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/[controller]")]
|
[Route("api/[controller]")]
|
||||||
[Authorize(AuthenticationSchemes = "JWT")]
|
[Authorize(AuthenticationSchemes = "JWT")]
|
||||||
public class BreweryController(IBreweryService breweryService) : ControllerBase
|
public class BreweryController(IBreweryService breweryService) : ControllerBase
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves a single brewery post by its unique identifier.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>Allows anonymous access.</remarks>
|
||||||
|
/// <param name="id">The unique identifier of the brewery post to retrieve.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}"/> of <see cref="BreweryDto"/> if found;
|
||||||
|
/// otherwise a <c>404 Not Found</c> result wrapping a <see cref="ResponseBody"/> error message.
|
||||||
|
/// </returns>
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
[HttpGet("{id:guid}")]
|
[HttpGet("{id:guid}")]
|
||||||
public async Task<ActionResult<ResponseBody<BreweryDto>>> GetById(Guid id)
|
public async Task<ActionResult<ResponseBody<BreweryDto>>> GetById(Guid id)
|
||||||
@@ -26,6 +44,13 @@ public class BreweryController(IBreweryService breweryService) : ControllerBase
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves a paginated list of brewery posts.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>Allows anonymous access.</remarks>
|
||||||
|
/// <param name="limit">The maximum number of brewery posts to return, or <c>null</c> for no limit.</param>
|
||||||
|
/// <param name="offset">The number of brewery posts to skip before returning results, or <c>null</c> for no offset.</param>
|
||||||
|
/// <returns>A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}"/> of a collection of <see cref="BreweryDto"/>.</returns>
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<ActionResult<ResponseBody<IEnumerable<BreweryDto>>>> GetAll(
|
public async Task<ActionResult<ResponseBody<IEnumerable<BreweryDto>>>> GetAll(
|
||||||
@@ -40,6 +65,14 @@ public class BreweryController(IBreweryService breweryService) : ControllerBase
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new brewery post.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="dto">The brewery details to create, including the posting user, name, description, and location.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// A <c>201 Created</c> result wrapping a <see cref="ResponseBody{T}"/> of the newly created <see cref="BreweryDto"/>
|
||||||
|
/// if creation succeeds; otherwise a <c>400 Bad Request</c> result wrapping a <see cref="ResponseBody"/> error message.
|
||||||
|
/// </returns>
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
public async Task<ActionResult<ResponseBody<BreweryDto>>> Create([FromBody] BreweryCreateDto dto)
|
public async Task<ActionResult<ResponseBody<BreweryDto>>> Create([FromBody] BreweryCreateDto dto)
|
||||||
{
|
{
|
||||||
@@ -67,6 +100,16 @@ public class BreweryController(IBreweryService breweryService) : ControllerBase
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates an existing brewery post.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">The unique identifier of the brewery post from the route, which must match <paramref name="dto"/>'s ID.</param>
|
||||||
|
/// <param name="dto">The updated brewery details, including the posting user, name, description, and optional location.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}"/> of the updated brewery (as a <see cref="BreweryPost"/>)
|
||||||
|
/// if the update succeeds; otherwise a <c>400 Bad Request</c> result wrapping a <see cref="ResponseBody"/> error message
|
||||||
|
/// (returned when the route ID does not match the payload ID, or when the update itself fails).
|
||||||
|
/// </returns>
|
||||||
[HttpPut("{id:guid}")]
|
[HttpPut("{id:guid}")]
|
||||||
public async Task<ActionResult<ResponseBody<BreweryDto>>> Update(Guid id, [FromBody] BreweryDto dto)
|
public async Task<ActionResult<ResponseBody<BreweryDto>>> Update(Guid id, [FromBody] BreweryDto dto)
|
||||||
{
|
{
|
||||||
@@ -92,13 +135,18 @@ public class BreweryController(IBreweryService breweryService) : ControllerBase
|
|||||||
if (!result.Success)
|
if (!result.Success)
|
||||||
return BadRequest(new ResponseBody { Message = result.Message });
|
return BadRequest(new ResponseBody { Message = result.Message });
|
||||||
|
|
||||||
return Ok(new ResponseBody<BreweryDto>
|
return Ok(new ResponseBody<BreweryPost>
|
||||||
{
|
{
|
||||||
Message = "Brewery updated successfully.",
|
Message = "Brewery updated successfully.",
|
||||||
Payload = MapToDto(result.Brewery),
|
Payload = result.Brewery,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes a brewery post.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">The unique identifier of the brewery post to delete.</param>
|
||||||
|
/// <returns>A <c>200 OK</c> result wrapping a <see cref="ResponseBody"/> confirming the deletion.</returns>
|
||||||
[HttpDelete("{id:guid}")]
|
[HttpDelete("{id:guid}")]
|
||||||
public async Task<ActionResult<ResponseBody>> Delete(Guid id)
|
public async Task<ActionResult<ResponseBody>> Delete(Guid id)
|
||||||
{
|
{
|
||||||
@@ -106,7 +154,13 @@ public class BreweryController(IBreweryService breweryService) : ControllerBase
|
|||||||
return Ok(new ResponseBody { Message = "Brewery deleted successfully." });
|
return Ok(new ResponseBody { Message = "Brewery deleted successfully." });
|
||||||
}
|
}
|
||||||
|
|
||||||
private static BreweryDto MapToDto(Domain.Entities.BreweryPost b) => new()
|
/// <summary>
|
||||||
|
/// Maps a <see cref="BreweryPost"/> domain entity to its <see cref="BreweryDto"/> representation,
|
||||||
|
/// including its location, if present.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="b">The brewery post entity to map.</param>
|
||||||
|
/// <returns>The mapped <see cref="BreweryDto"/>.</returns>
|
||||||
|
private static BreweryDto MapToDto(BreweryPost b) => new()
|
||||||
{
|
{
|
||||||
BreweryPostId = b.BreweryPostId,
|
BreweryPostId = b.BreweryPostId,
|
||||||
PostedById = b.PostedById,
|
PostedById = b.PostedById,
|
||||||
|
|||||||
@@ -2,11 +2,22 @@ using Microsoft.AspNetCore.Mvc;
|
|||||||
|
|
||||||
namespace API.Core.Controllers
|
namespace API.Core.Controllers
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Handles requests that do not match any other route, used as the application's fallback controller.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Excluded from API explorer/Swagger output via <c>[ApiExplorerSettings(IgnoreApi = true)]</c>.
|
||||||
|
/// Wired up as the fallback target via <c>app.MapFallbackToController("Handle404", "NotFound")</c> in <c>Program.cs</c>.
|
||||||
|
/// </remarks>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[ApiExplorerSettings(IgnoreApi = true)]
|
[ApiExplorerSettings(IgnoreApi = true)]
|
||||||
[Route("error")] // required
|
[Route("error")] // required
|
||||||
public class NotFoundController : ControllerBase
|
public class NotFoundController : ControllerBase
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a generic 404 response for any request that did not match a defined route.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A <c>404 Not Found</c> result with a JSON body containing a "Route not found." message.</returns>
|
||||||
[HttpGet("404")] //required
|
[HttpGet("404")] //required
|
||||||
public IActionResult Handle404()
|
public IActionResult Handle404()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,11 +5,22 @@ using Microsoft.AspNetCore.Mvc;
|
|||||||
|
|
||||||
namespace API.Core.Controllers;
|
namespace API.Core.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sample controller demonstrating an endpoint that requires JWT authentication.
|
||||||
|
/// </summary>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/[controller]")]
|
[Route("api/[controller]")]
|
||||||
[Authorize(AuthenticationSchemes = "JWT")]
|
[Authorize(AuthenticationSchemes = "JWT")]
|
||||||
public class ProtectedController : ControllerBase
|
public class ProtectedController : ControllerBase
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the authenticated caller's user ID and username, extracted from the JWT claims.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>
|
||||||
|
/// A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}"/> whose payload contains the
|
||||||
|
/// caller's <c>userId</c> (from <see cref="ClaimTypes.NameIdentifier"/>) and <c>username</c>
|
||||||
|
/// (from <see cref="ClaimTypes.Name"/>), either of which may be <c>null</c> if not present in the token.
|
||||||
|
/// </returns>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public ActionResult<ResponseBody<object>> Get()
|
public ActionResult<ResponseBody<object>> Get()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,10 +4,20 @@ using Service.UserManagement.User;
|
|||||||
|
|
||||||
namespace API.Core.Controllers
|
namespace API.Core.Controllers
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Provides read-only endpoints for retrieving user accounts.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="userService">Service used to query user account data.</param>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/[controller]")]
|
[Route("api/[controller]")]
|
||||||
public class UserController(IUserService userService) : ControllerBase
|
public class UserController(IUserService userService) : ControllerBase
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves a paginated list of user accounts.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="limit">The maximum number of user accounts to return, or <c>null</c> for no limit.</param>
|
||||||
|
/// <param name="offset">The number of user accounts to skip before returning results, or <c>null</c> for no offset.</param>
|
||||||
|
/// <returns>A <c>200 OK</c> result containing the collection of <see cref="UserAccount"/> entities.</returns>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<ActionResult<IEnumerable<UserAccount>>> GetAll(
|
public async Task<ActionResult<IEnumerable<UserAccount>>> GetAll(
|
||||||
[FromQuery] int? limit,
|
[FromQuery] int? limit,
|
||||||
@@ -18,6 +28,11 @@ namespace API.Core.Controllers
|
|||||||
return Ok(users);
|
return Ok(users);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves a single user account by its unique identifier.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">The unique identifier of the user account to retrieve.</param>
|
||||||
|
/// <returns>A <c>200 OK</c> result containing the matching <see cref="UserAccount"/>.</returns>
|
||||||
[HttpGet("{id:guid}")]
|
[HttpGet("{id:guid}")]
|
||||||
public async Task<ActionResult<UserAccount>> GetById(Guid id)
|
public async Task<ActionResult<UserAccount>> GetById(Guid id)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,9 +9,32 @@ using Microsoft.AspNetCore.Mvc.Filters;
|
|||||||
|
|
||||||
namespace API.Core;
|
namespace API.Core;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MVC exception filter that converts unhandled exceptions raised by controller actions into
|
||||||
|
/// consistent JSON error responses with appropriate HTTP status codes.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="logger">Logger used to record unhandled exceptions before they are translated into a response.</param>
|
||||||
public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
|
public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
|
||||||
: IExceptionFilter
|
: IExceptionFilter
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Logs the exception and sets <see cref="ExceptionContext.Result"/> to an appropriate error response,
|
||||||
|
/// marking the exception as handled.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Maps exception types to responses as follows:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><description><see cref="FluentValidation.ValidationException"/> → <c>400 Bad Request</c> with per-property validation error messages.</description></item>
|
||||||
|
/// <item><description><see cref="ConflictException"/> → <c>409 Conflict</c> with a <see cref="ResponseBody"/> message.</description></item>
|
||||||
|
/// <item><description><see cref="NotFoundException"/> → <c>404 Not Found</c> with a <see cref="ResponseBody"/> message.</description></item>
|
||||||
|
/// <item><description><see cref="UnauthorizedException"/> → <c>401 Unauthorized</c> with a <see cref="ResponseBody"/> message.</description></item>
|
||||||
|
/// <item><description><see cref="ForbiddenException"/> → <c>403 Forbidden</c> with a <see cref="ResponseBody"/> message.</description></item>
|
||||||
|
/// <item><description><see cref="SqlException"/> → <c>503 Service Unavailable</c> with a generic database error message.</description></item>
|
||||||
|
/// <item><description><see cref="Domain.Exceptions.ValidationException"/> → <c>400 Bad Request</c> with a <see cref="ResponseBody"/> message.</description></item>
|
||||||
|
/// <item><description>Any other exception → <c>500 Internal Server Error</c> with a generic error message.</description></item>
|
||||||
|
/// </list>
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="context">The context for the unhandled exception, including the exception itself and the result to populate.</param>
|
||||||
public void OnException(ExceptionContext context)
|
public void OnException(ExceptionContext context)
|
||||||
{
|
{
|
||||||
logger.LogError(context.Exception, "Unhandled exception occurred");
|
logger.LogError(context.Exception, "Unhandled exception occurred");
|
||||||
|
|||||||
@@ -5,8 +5,27 @@ using Microsoft.Data.SqlClient;
|
|||||||
|
|
||||||
namespace Database.Migrations;
|
namespace Database.Migrations;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Entry point for the database migration runner. Reads connection details from
|
||||||
|
/// environment variables, optionally clears and recreates the target database, and
|
||||||
|
/// applies all SQL migration scripts embedded in this assembly using DbUp.
|
||||||
|
/// </summary>
|
||||||
public static class Program
|
public static class Program
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a SQL Server connection string from the <c>DB_SERVER</c>, <c>DB_NAME</c>,
|
||||||
|
/// <c>DB_USER</c>, <c>DB_PASSWORD</c>, and <c>DB_TRUST_SERVER_CERTIFICATE</c>
|
||||||
|
/// environment variables.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="databaseName">
|
||||||
|
/// The database (initial catalog) to connect to. When <c>null</c>, falls back to the
|
||||||
|
/// <c>DB_NAME</c> environment variable.
|
||||||
|
/// </param>
|
||||||
|
/// <returns>A fully built SQL Server connection string.</returns>
|
||||||
|
/// <exception cref="InvalidOperationException">
|
||||||
|
/// Thrown when <c>DB_SERVER</c>, <c>DB_USER</c>, <c>DB_PASSWORD</c>, or (when
|
||||||
|
/// <paramref name="databaseName"/> is <c>null</c>) <c>DB_NAME</c> is not set.
|
||||||
|
/// </exception>
|
||||||
private static string BuildConnectionString(string? databaseName = null)
|
private static string BuildConnectionString(string? databaseName = null)
|
||||||
{
|
{
|
||||||
var server = Environment.GetEnvironmentVariable("DB_SERVER")
|
var server = Environment.GetEnvironmentVariable("DB_SERVER")
|
||||||
@@ -38,9 +57,17 @@ public static class Program
|
|||||||
return builder.ConnectionString;
|
return builder.ConnectionString;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>The connection string for the target application database (<c>DB_NAME</c>).</summary>
|
||||||
private static readonly string connectionString = BuildConnectionString();
|
private static readonly string connectionString = BuildConnectionString();
|
||||||
|
|
||||||
|
/// <summary>The connection string for the <c>master</c> database, used for create/drop operations.</summary>
|
||||||
private static readonly string masterConnectionString = BuildConnectionString("master");
|
private static readonly string masterConnectionString = BuildConnectionString("master");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Applies all pending SQL migration scripts embedded in this assembly to the target
|
||||||
|
/// database using DbUp, logging progress to the console.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns><c>true</c> if the upgrade completed successfully; otherwise <c>false</c>.</returns>
|
||||||
private static bool DeployMigrations()
|
private static bool DeployMigrations()
|
||||||
{
|
{
|
||||||
var upgrader = DeployChanges
|
var upgrader = DeployChanges
|
||||||
@@ -53,6 +80,14 @@ public static class Program
|
|||||||
return result.Successful;
|
return result.Successful;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Drops the <c>Biergarten</c> database if it exists, first forcing it into single-user
|
||||||
|
/// mode with rollback to terminate any existing connections.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>
|
||||||
|
/// <c>true</c> if the database was dropped (or did not exist) without error; <c>false</c>
|
||||||
|
/// if an error occurred while connecting or dropping the database.
|
||||||
|
/// </returns>
|
||||||
private static bool ClearDatabase()
|
private static bool ClearDatabase()
|
||||||
{
|
{
|
||||||
var myConn = new SqlConnection(masterConnectionString);
|
var myConn = new SqlConnection(masterConnectionString);
|
||||||
@@ -104,6 +139,12 @@ public static class Program
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates the <c>Biergarten</c> database on the master connection if it does not
|
||||||
|
/// already exist. Errors encountered while creating the database are logged but do
|
||||||
|
/// not stop execution.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns><c>true</c> always; this method does not propagate database errors as a failure result.</returns>
|
||||||
private static bool CreateDatabaseIfNotExists()
|
private static bool CreateDatabaseIfNotExists()
|
||||||
{
|
{
|
||||||
var myConn = new SqlConnection(masterConnectionString);
|
var myConn = new SqlConnection(masterConnectionString);
|
||||||
@@ -134,6 +175,13 @@ public static class Program
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Migration runner entry point. Optionally clears the existing database when the
|
||||||
|
/// <c>CLEAR_DATABASE</c> environment variable is set to <c>"true"</c>, ensures the
|
||||||
|
/// database exists, then deploys all pending migrations.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="args">Command-line arguments (unused).</param>
|
||||||
|
/// <returns><c>0</c> if migrations completed successfully; <c>1</c> if they failed or an error occurred.</returns>
|
||||||
public static int Main(string[] args)
|
public static int Main(string[] args)
|
||||||
{
|
{
|
||||||
Console.WriteLine("Starting database migrations...");
|
Console.WriteLine("Starting database migrations...");
|
||||||
|
|||||||
@@ -2,7 +2,18 @@ using Microsoft.Data.SqlClient;
|
|||||||
|
|
||||||
namespace Database.Seed;
|
namespace Database.Seed;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defines a unit of seed data that can be applied to the database. Implementations
|
||||||
|
/// should be safe to run against an already-seeded database (e.g. by checking for
|
||||||
|
/// existing data before inserting) and may depend on data created by seeders that run
|
||||||
|
/// before them.
|
||||||
|
/// </summary>
|
||||||
internal interface ISeeder
|
internal interface ISeeder
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Inserts this seeder's data into the database using the supplied open connection.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="connection">An open connection to the target database.</param>
|
||||||
|
/// <returns>A task that completes when seeding is finished.</returns>
|
||||||
Task SeedAsync(SqlConnection connection);
|
Task SeedAsync(SqlConnection connection);
|
||||||
}
|
}
|
||||||
@@ -3,8 +3,18 @@ using Microsoft.Data.SqlClient;
|
|||||||
|
|
||||||
namespace Database.Seed;
|
namespace Database.Seed;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Seeds the location hierarchy (countries, states/provinces, and cities) used to
|
||||||
|
/// associate other entities (e.g. breweries, users) with a geographic location.
|
||||||
|
/// Countries must be seeded before states/provinces, which must be seeded before
|
||||||
|
/// cities, since each level references its parent by code. The underlying stored
|
||||||
|
/// procedures (<c>USP_CreateCountry</c>, <c>USP_CreateStateProvince</c>,
|
||||||
|
/// <c>USP_CreateCity</c>) are expected to be idempotent, so re-running this seeder
|
||||||
|
/// against an already-seeded database is safe.
|
||||||
|
/// </summary>
|
||||||
internal class LocationSeeder : ISeeder
|
internal class LocationSeeder : ISeeder
|
||||||
{
|
{
|
||||||
|
/// <summary>The set of countries to seed, identified by name and ISO 3166-1 code.</summary>
|
||||||
private static readonly IReadOnlyList<(
|
private static readonly IReadOnlyList<(
|
||||||
string CountryName,
|
string CountryName,
|
||||||
string CountryCode
|
string CountryCode
|
||||||
@@ -15,6 +25,10 @@ internal class LocationSeeder : ISeeder
|
|||||||
("United States", "US"),
|
("United States", "US"),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The set of states/provinces to seed, each identified by name, ISO 3166-2 code,
|
||||||
|
/// and the ISO 3166-1 code of its parent country.
|
||||||
|
/// </summary>
|
||||||
private static IReadOnlyList<(string StateProvinceName, string StateProvinceCode, string CountryCode)> States
|
private static IReadOnlyList<(string StateProvinceName, string StateProvinceCode, string CountryCode)> States
|
||||||
{
|
{
|
||||||
get;
|
get;
|
||||||
@@ -123,6 +137,10 @@ internal class LocationSeeder : ISeeder
|
|||||||
("Ciudad de México", "MX-CMX", "MX"),
|
("Ciudad de México", "MX-CMX", "MX"),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The set of cities to seed, each identified by name and the ISO 3166-2 code of its
|
||||||
|
/// parent state/province.
|
||||||
|
/// </summary>
|
||||||
private static IReadOnlyList<(string StateProvinceCode, string CityName)> Cities { get; } =
|
private static IReadOnlyList<(string StateProvinceCode, string CityName)> Cities { get; } =
|
||||||
[
|
[
|
||||||
("US-CA", "Los Angeles"),
|
("US-CA", "Los Angeles"),
|
||||||
@@ -240,6 +258,12 @@ internal class LocationSeeder : ISeeder
|
|||||||
("MX-ZAC", "Zacatecas"),
|
("MX-ZAC", "Zacatecas"),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Seeds all countries, then states/provinces, then cities, in that order, so that
|
||||||
|
/// each level's parent reference already exists by the time it is created.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="connection">An open connection to the target database.</param>
|
||||||
|
/// <returns>A task that completes when all locations have been seeded.</returns>
|
||||||
public async Task SeedAsync(SqlConnection connection)
|
public async Task SeedAsync(SqlConnection connection)
|
||||||
{
|
{
|
||||||
foreach (var (countryName, countryCode) in Countries)
|
foreach (var (countryName, countryCode) in Countries)
|
||||||
@@ -265,6 +289,11 @@ internal class LocationSeeder : ISeeder
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Creates a single country by invoking the <c>dbo.USP_CreateCountry</c> stored procedure.</summary>
|
||||||
|
/// <param name="connection">An open connection to the target database.</param>
|
||||||
|
/// <param name="countryName">The display name of the country.</param>
|
||||||
|
/// <param name="countryCode">The ISO 3166-1 code of the country.</param>
|
||||||
|
/// <returns>A task that completes when the country has been created.</returns>
|
||||||
private static async Task CreateCountryAsync(
|
private static async Task CreateCountryAsync(
|
||||||
SqlConnection connection,
|
SqlConnection connection,
|
||||||
string countryName,
|
string countryName,
|
||||||
@@ -282,6 +311,12 @@ internal class LocationSeeder : ISeeder
|
|||||||
await command.ExecuteNonQueryAsync();
|
await command.ExecuteNonQueryAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Creates a single state/province by invoking the <c>dbo.USP_CreateStateProvince</c> stored procedure.</summary>
|
||||||
|
/// <param name="connection">An open connection to the target database.</param>
|
||||||
|
/// <param name="stateProvinceName">The display name of the state/province.</param>
|
||||||
|
/// <param name="stateProvinceCode">The ISO 3166-2 code of the state/province.</param>
|
||||||
|
/// <param name="countryCode">The ISO 3166-1 code of the parent country, which must already exist.</param>
|
||||||
|
/// <returns>A task that completes when the state/province has been created.</returns>
|
||||||
private static async Task CreateStateProvinceAsync(
|
private static async Task CreateStateProvinceAsync(
|
||||||
SqlConnection connection,
|
SqlConnection connection,
|
||||||
string stateProvinceName,
|
string stateProvinceName,
|
||||||
@@ -304,6 +339,11 @@ internal class LocationSeeder : ISeeder
|
|||||||
await command.ExecuteNonQueryAsync();
|
await command.ExecuteNonQueryAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Creates a single city by invoking the <c>dbo.USP_CreateCity</c> stored procedure.</summary>
|
||||||
|
/// <param name="connection">An open connection to the target database.</param>
|
||||||
|
/// <param name="cityName">The display name of the city.</param>
|
||||||
|
/// <param name="stateProvinceCode">The ISO 3166-2 code of the parent state/province, which must already exist.</param>
|
||||||
|
/// <returns>A task that completes when the city has been created.</returns>
|
||||||
private static async Task CreateCityAsync(
|
private static async Task CreateCityAsync(
|
||||||
SqlConnection connection,
|
SqlConnection connection,
|
||||||
string cityName,
|
string cityName,
|
||||||
|
|||||||
@@ -3,6 +3,20 @@ using DbUp;
|
|||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using Database.Seed;
|
using Database.Seed;
|
||||||
|
|
||||||
|
// Entry point for the database seeding utility. Connects to the target database
|
||||||
|
// (retrying on transient failures), then runs each registered ISeeder in order to
|
||||||
|
// populate location and user data.
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a SQL Server connection string from the <c>DB_SERVER</c>, <c>DB_NAME</c>,
|
||||||
|
/// <c>DB_USER</c>, <c>DB_PASSWORD</c>, and <c>DB_TRUST_SERVER_CERTIFICATE</c>
|
||||||
|
/// environment variables.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A fully built SQL Server connection string.</returns>
|
||||||
|
/// <exception cref="InvalidOperationException">
|
||||||
|
/// Thrown when <c>DB_SERVER</c>, <c>DB_NAME</c>, <c>DB_USER</c>, or <c>DB_PASSWORD</c>
|
||||||
|
/// is not set.
|
||||||
|
/// </exception>
|
||||||
string BuildConnectionString()
|
string BuildConnectionString()
|
||||||
{
|
{
|
||||||
var server = Environment.GetEnvironmentVariable("DB_SERVER")
|
var server = Environment.GetEnvironmentVariable("DB_SERVER")
|
||||||
|
|||||||
@@ -7,8 +7,18 @@ using Microsoft.Data.SqlClient;
|
|||||||
|
|
||||||
namespace Database.Seed;
|
namespace Database.Seed;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Seeds user accounts, credentials, and verification records. Creates one fixed
|
||||||
|
/// "Test User" account (<c>test.user@thebiergarten.app</c> / password <c>"password"</c>)
|
||||||
|
/// for testing, followed by a randomly-generated account for each name in
|
||||||
|
/// <see cref="SeedNames"/>, each with a random password and date of birth. Skips
|
||||||
|
/// adding a verification record for a user that already has one, so this seeder is
|
||||||
|
/// safe to re-run, though re-running will still attempt to register (and may fail on)
|
||||||
|
/// users that already exist.
|
||||||
|
/// </summary>
|
||||||
internal class UserSeeder : ISeeder
|
internal class UserSeeder : ISeeder
|
||||||
{
|
{
|
||||||
|
/// <summary>The first/last name pairs used to generate seed user accounts.</summary>
|
||||||
private static readonly IReadOnlyList<(
|
private static readonly IReadOnlyList<(
|
||||||
string FirstName,
|
string FirstName,
|
||||||
string LastName
|
string LastName
|
||||||
@@ -116,6 +126,14 @@ internal class UserSeeder : ISeeder
|
|||||||
("Zoie", "Armstrong"),
|
("Zoie", "Armstrong"),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers a fixed test user account followed by one randomly-generated account
|
||||||
|
/// per entry in <see cref="SeedNames"/>, adding a user verification record for each
|
||||||
|
/// newly created account that does not already have one. Progress counts are
|
||||||
|
/// written to the console on completion.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="connection">An open connection to the target database.</param>
|
||||||
|
/// <returns>A task that completes when all users have been seeded.</returns>
|
||||||
public async Task SeedAsync(SqlConnection connection)
|
public async Task SeedAsync(SqlConnection connection)
|
||||||
{
|
{
|
||||||
var generator = new PasswordGenerator();
|
var generator = new PasswordGenerator();
|
||||||
@@ -184,6 +202,18 @@ internal class UserSeeder : ISeeder
|
|||||||
Console.WriteLine($"Added {createdVerifications} user verifications.");
|
Console.WriteLine($"Added {createdVerifications} user verifications.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers a new user account and its credential by invoking the
|
||||||
|
/// <c>dbo.USP_RegisterUser</c> stored procedure.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="connection">An open connection to the target database.</param>
|
||||||
|
/// <param name="username">The unique username for the account.</param>
|
||||||
|
/// <param name="firstName">The user's first name.</param>
|
||||||
|
/// <param name="lastName">The user's last name.</param>
|
||||||
|
/// <param name="dateOfBirth">The user's date of birth.</param>
|
||||||
|
/// <param name="email">The user's email address.</param>
|
||||||
|
/// <param name="hash">The salted password hash, as produced by <see cref="GeneratePasswordHash"/>.</param>
|
||||||
|
/// <returns>The newly created user account's <see cref="Guid"/> identifier.</returns>
|
||||||
private static async Task<Guid> RegisterUserAsync(
|
private static async Task<Guid> RegisterUserAsync(
|
||||||
SqlConnection connection,
|
SqlConnection connection,
|
||||||
string username,
|
string username,
|
||||||
@@ -211,6 +241,13 @@ internal class UserSeeder : ISeeder
|
|||||||
return (Guid)result!;
|
return (Guid)result!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hashes a plaintext password using Argon2id with a randomly generated 16-byte
|
||||||
|
/// salt, a degree of parallelism matching the available processor count, 64 MB of
|
||||||
|
/// memory, and 4 iterations.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="pwd">The plaintext password to hash.</param>
|
||||||
|
/// <returns>A string containing the base64-encoded salt and hash, separated by a colon.</returns>
|
||||||
private static string GeneratePasswordHash(string pwd)
|
private static string GeneratePasswordHash(string pwd)
|
||||||
{
|
{
|
||||||
byte[] salt = RandomNumberGenerator.GetBytes(16);
|
byte[] salt = RandomNumberGenerator.GetBytes(16);
|
||||||
@@ -227,6 +264,10 @@ internal class UserSeeder : ISeeder
|
|||||||
return $"{Convert.ToBase64String(salt)}:{Convert.ToBase64String(hash)}";
|
return $"{Convert.ToBase64String(salt)}:{Convert.ToBase64String(hash)}";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Checks whether a user verification record already exists for the given user account.</summary>
|
||||||
|
/// <param name="connection">An open connection to the target database.</param>
|
||||||
|
/// <param name="userAccountId">The user account identifier to check.</param>
|
||||||
|
/// <returns><c>true</c> if a verification record already exists; otherwise <c>false</c>.</returns>
|
||||||
private static async Task<bool> HasUserVerificationAsync(
|
private static async Task<bool> HasUserVerificationAsync(
|
||||||
SqlConnection connection,
|
SqlConnection connection,
|
||||||
Guid userAccountId
|
Guid userAccountId
|
||||||
@@ -243,6 +284,10 @@ internal class UserSeeder : ISeeder
|
|||||||
return result is not null;
|
return result is not null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Creates a user verification record by invoking the <c>dbo.USP_CreateUserVerification</c> stored procedure.</summary>
|
||||||
|
/// <param name="connection">An open connection to the target database.</param>
|
||||||
|
/// <param name="userAccountId">The identifier of the user account to verify.</param>
|
||||||
|
/// <returns>A task that completes when the verification record has been created.</returns>
|
||||||
private static async Task AddUserVerificationAsync(
|
private static async Task AddUserVerificationAsync(
|
||||||
SqlConnection connection,
|
SqlConnection connection,
|
||||||
Guid userAccountId
|
Guid userAccountId
|
||||||
@@ -258,6 +303,12 @@ internal class UserSeeder : ISeeder
|
|||||||
await command.ExecuteNonQueryAsync();
|
await command.ExecuteNonQueryAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a random date of birth corresponding to an age between 19 and 48
|
||||||
|
/// years (inclusive), with a random day offset within that birth year.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="random">The random number source to use.</param>
|
||||||
|
/// <returns>A randomly generated date of birth.</returns>
|
||||||
private static DateTime GenerateDateOfBirth(Random random)
|
private static DateTime GenerateDateOfBirth(Random random)
|
||||||
{
|
{
|
||||||
int age = 19 + random.Next(0, 30);
|
int age = 19 + random.Next(0, 30);
|
||||||
|
|||||||
@@ -1,13 +1,48 @@
|
|||||||
namespace Domain.Entities;
|
namespace Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a user-submitted post about a brewery. Maps to the <c>BreweryPost</c> table.
|
||||||
|
/// A user account cannot be deleted while it has an associated brewery post.
|
||||||
|
/// </summary>
|
||||||
public class BreweryPost
|
public class BreweryPost
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Primary key identifying this brewery post. Maps to <c>BreweryPostID</c>.
|
||||||
|
/// </summary>
|
||||||
public Guid BreweryPostId { get; set; }
|
public Guid BreweryPostId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Foreign key referencing the <see cref="UserAccount"/> that authored this post. Maps to <c>PostedByID</c>.
|
||||||
|
/// </summary>
|
||||||
public Guid PostedById { get; set; }
|
public Guid PostedById { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The name of the brewery being posted about.
|
||||||
|
/// </summary>
|
||||||
public string BreweryName { get; set; } = string.Empty;
|
public string BreweryName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Free-text description of the brewery.
|
||||||
|
/// </summary>
|
||||||
public string Description { get; set; } = string.Empty;
|
public string Description { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The date and time the post was created.
|
||||||
|
/// </summary>
|
||||||
public DateTime CreatedAt { get; set; }
|
public DateTime CreatedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The date and time the post was last updated, or <c>null</c> if it has never been updated.
|
||||||
|
/// </summary>
|
||||||
public DateTime? UpdatedAt { get; set; }
|
public DateTime? UpdatedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SQL Server <c>ROWVERSION</c> concurrency token used to detect concurrent updates. <c>null</c> until the row has been read from the database.
|
||||||
|
/// </summary>
|
||||||
public byte[]? Timer { get; set; }
|
public byte[]? Timer { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The associated <see cref="BreweryPostLocation"/> for this post, if one has been set. This is a one-to-one navigation property.
|
||||||
|
/// </summary>
|
||||||
public BreweryPostLocation? Location { get; set; }
|
public BreweryPostLocation? Location { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,48 @@
|
|||||||
namespace Domain.Entities;
|
namespace Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the physical location of a <see cref="BreweryPost"/>. Maps to the <c>BreweryPostLocation</c> table.
|
||||||
|
/// Each brewery post has at most one location, and the location is deleted automatically when its parent post is deleted.
|
||||||
|
/// </summary>
|
||||||
public class BreweryPostLocation
|
public class BreweryPostLocation
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Primary key identifying this location. Maps to <c>BreweryPostLocationID</c>.
|
||||||
|
/// </summary>
|
||||||
public Guid BreweryPostLocationId { get; set; }
|
public Guid BreweryPostLocationId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Foreign key referencing the owning <see cref="BreweryPost"/>. Maps to <c>BreweryPostID</c>; unique, enforcing a one-to-one relationship.
|
||||||
|
/// </summary>
|
||||||
public Guid BreweryPostId { get; set; }
|
public Guid BreweryPostId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The primary street address line.
|
||||||
|
/// </summary>
|
||||||
public string AddressLine1 { get; set; } = string.Empty;
|
public string AddressLine1 { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An optional secondary address line (e.g., suite or unit number).
|
||||||
|
/// </summary>
|
||||||
public string? AddressLine2 { get; set; }
|
public string? AddressLine2 { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The postal/ZIP code of the location.
|
||||||
|
/// </summary>
|
||||||
public string PostalCode { get; set; } = string.Empty;
|
public string PostalCode { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Foreign key referencing the city in which the brewery is located. Maps to <c>CityID</c>.
|
||||||
|
/// </summary>
|
||||||
public Guid CityId { get; set; }
|
public Guid CityId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serialized SQL Server <c>GEOGRAPHY</c> value representing the geographic coordinates of the location, or <c>null</c> if not set.
|
||||||
|
/// </summary>
|
||||||
public byte[]? Coordinates { get; set; }
|
public byte[]? Coordinates { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SQL Server <c>ROWVERSION</c> concurrency token used to detect concurrent updates. <c>null</c> until the row has been read from the database.
|
||||||
|
/// </summary>
|
||||||
public byte[]? Timer { get; set; }
|
public byte[]? Timer { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,53 @@
|
|||||||
namespace Domain.Entities;
|
namespace Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a registered user of the application. Maps to the <c>UserAccount</c> table, the root entity
|
||||||
|
/// referenced by <see cref="UserCredential"/>, <see cref="UserVerification"/>, brewery posts, and other user-owned data.
|
||||||
|
/// </summary>
|
||||||
public class UserAccount
|
public class UserAccount
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Primary key identifying this user account. Maps to <c>UserAccountID</c>.
|
||||||
|
/// </summary>
|
||||||
public Guid UserAccountId { get; set; }
|
public Guid UserAccountId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The user's unique username, used for login and display. Enforced unique at the database level.
|
||||||
|
/// </summary>
|
||||||
public string Username { get; set; } = string.Empty;
|
public string Username { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The user's first name.
|
||||||
|
/// </summary>
|
||||||
public string FirstName { get; set; } = string.Empty;
|
public string FirstName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The user's last name.
|
||||||
|
/// </summary>
|
||||||
public string LastName { get; set; } = string.Empty;
|
public string LastName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The user's email address. Enforced unique at the database level.
|
||||||
|
/// </summary>
|
||||||
public string Email { get; set; } = string.Empty;
|
public string Email { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The date and time the account was created.
|
||||||
|
/// </summary>
|
||||||
public DateTime CreatedAt { get; set; }
|
public DateTime CreatedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The date and time the account was last updated, or <c>null</c> if it has never been updated.
|
||||||
|
/// </summary>
|
||||||
public DateTime? UpdatedAt { get; set; }
|
public DateTime? UpdatedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The user's date of birth.
|
||||||
|
/// </summary>
|
||||||
public DateTime DateOfBirth { get; set; }
|
public DateTime DateOfBirth { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SQL Server <c>ROWVERSION</c> concurrency token used to detect concurrent updates. <c>null</c> until the row has been read from the database.
|
||||||
|
/// </summary>
|
||||||
public byte[]? Timer { get; set; }
|
public byte[]? Timer { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,38 @@
|
|||||||
namespace Domain.Entities;
|
namespace Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents an issued authentication credential (refresh token) for a <see cref="UserAccount"/>.
|
||||||
|
/// Maps to the <c>UserCredential</c> table. Credentials are deleted automatically when the owning user account is deleted.
|
||||||
|
/// </summary>
|
||||||
public class UserCredential
|
public class UserCredential
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Primary key identifying this credential. Maps to <c>UserCredentialID</c>.
|
||||||
|
/// </summary>
|
||||||
public Guid UserCredentialId { get; set; }
|
public Guid UserCredentialId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Foreign key referencing the owning <see cref="UserAccount"/>. Maps to <c>UserAccountID</c>.
|
||||||
|
/// </summary>
|
||||||
public Guid UserAccountId { get; set; }
|
public Guid UserAccountId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The date and time the credential was issued.
|
||||||
|
/// </summary>
|
||||||
public DateTime CreatedAt { get; set; }
|
public DateTime CreatedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The date and time after which the credential is no longer valid.
|
||||||
|
/// </summary>
|
||||||
public DateTime Expiry { get; set; }
|
public DateTime Expiry { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The Argon2 hash of the credential's secret value. The plaintext secret is never persisted.
|
||||||
|
/// </summary>
|
||||||
public string Hash { get; set; } = string.Empty;
|
public string Hash { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SQL Server <c>ROWVERSION</c> concurrency token used to detect concurrent updates. <c>null</c> until the row has been read from the database.
|
||||||
|
/// </summary>
|
||||||
public byte[]? Timer { get; set; }
|
public byte[]? Timer { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,29 @@
|
|||||||
namespace Domain.Entities;
|
namespace Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Records that a <see cref="UserAccount"/> has completed verification (e.g., email confirmation).
|
||||||
|
/// Maps to the <c>UserVerification</c> table. A user account has at most one verification record,
|
||||||
|
/// and it is deleted automatically when the owning user account is deleted.
|
||||||
|
/// </summary>
|
||||||
public class UserVerification
|
public class UserVerification
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Primary key identifying this verification record. Maps to <c>UserVerificationID</c>.
|
||||||
|
/// </summary>
|
||||||
public Guid UserVerificationId { get; set; }
|
public Guid UserVerificationId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Foreign key referencing the verified <see cref="UserAccount"/>. Maps to <c>UserAccountID</c>; unique, enforcing a one-to-one relationship.
|
||||||
|
/// </summary>
|
||||||
public Guid UserAccountId { get; set; }
|
public Guid UserAccountId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The date and time at which the user account was verified.
|
||||||
|
/// </summary>
|
||||||
public DateTime VerificationDateTime { get; set; }
|
public DateTime VerificationDateTime { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SQL Server <c>ROWVERSION</c> concurrency token used to detect concurrent updates. <c>null</c> until the row has been read from the database.
|
||||||
|
/// </summary>
|
||||||
public byte[]? Timer { get; set; }
|
public byte[]? Timer { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ namespace Infrastructure.Email.Templates.Rendering;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Service for rendering Razor email templates to HTML using HtmlRenderer.
|
/// Service for rendering Razor email templates to HTML using HtmlRenderer.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="serviceProvider">The service provider used to resolve dependencies for the Razor component rendering pipeline.</param>
|
||||||
|
/// <param name="loggerFactory">The logger factory passed to the <see cref="HtmlRenderer"/> used to render components.</param>
|
||||||
public class EmailTemplateProvider(
|
public class EmailTemplateProvider(
|
||||||
IServiceProvider serviceProvider,
|
IServiceProvider serviceProvider,
|
||||||
ILoggerFactory loggerFactory
|
ILoggerFactory loggerFactory
|
||||||
@@ -16,6 +18,9 @@ public class EmailTemplateProvider(
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Renders the UserRegisteredEmail template with the specified parameters.
|
/// Renders the UserRegisteredEmail template with the specified parameters.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="username">The username to include in the email</param>
|
||||||
|
/// <param name="confirmationLink">The email confirmation link</param>
|
||||||
|
/// <returns>The rendered HTML string</returns>
|
||||||
public async Task<string> RenderUserRegisteredEmailAsync(
|
public async Task<string> RenderUserRegisteredEmailAsync(
|
||||||
string username,
|
string username,
|
||||||
string confirmationLink
|
string confirmationLink
|
||||||
@@ -33,6 +38,9 @@ public class EmailTemplateProvider(
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Renders the ResendConfirmation template with the specified parameters.
|
/// Renders the ResendConfirmation template with the specified parameters.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="username">The username to include in the email</param>
|
||||||
|
/// <param name="confirmationLink">The new confirmation link</param>
|
||||||
|
/// <returns>The rendered HTML string</returns>
|
||||||
public async Task<string> RenderResendConfirmationEmailAsync(
|
public async Task<string> RenderResendConfirmationEmailAsync(
|
||||||
string username,
|
string username,
|
||||||
string confirmationLink
|
string confirmationLink
|
||||||
@@ -49,7 +57,12 @@ public class EmailTemplateProvider(
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Generic method to render any Razor component to HTML.
|
/// Generic method to render any Razor component to HTML.
|
||||||
|
/// Creates a scoped <see cref="HtmlRenderer"/>, dispatches the render onto its renderer thread,
|
||||||
|
/// and returns the resulting HTML string.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <typeparam name="TComponent">The type of the Razor component to render.</typeparam>
|
||||||
|
/// <param name="parameters">A dictionary of parameter names and values to pass to the component.</param>
|
||||||
|
/// <returns>The rendered HTML string for the component.</returns>
|
||||||
private async Task<string> RenderComponentAsync<TComponent>(
|
private async Task<string> RenderComponentAsync<TComponent>(
|
||||||
Dictionary<string, object?> parameters
|
Dictionary<string, object?> parameters
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -18,6 +18,25 @@ public class SmtpEmailProvider : IEmailProvider
|
|||||||
private readonly string _fromEmail;
|
private readonly string _fromEmail;
|
||||||
private readonly string _fromName;
|
private readonly string _fromName;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of <see cref="SmtpEmailProvider"/>, reading SMTP configuration
|
||||||
|
/// from environment variables.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Reads the following environment variables:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><description><c>SMTP_HOST</c> (required) - the SMTP server hostname.</description></item>
|
||||||
|
/// <item><description><c>SMTP_PORT</c> (optional, default "587") - the SMTP server port, must be a valid integer.</description></item>
|
||||||
|
/// <item><description><c>SMTP_USERNAME</c> (optional) - the username used for authentication.</description></item>
|
||||||
|
/// <item><description><c>SMTP_PASSWORD</c> (optional) - the password used for authentication.</description></item>
|
||||||
|
/// <item><description><c>SMTP_USE_SSL</c> (optional, default "true") - whether to use StartTls when connecting.</description></item>
|
||||||
|
/// <item><description><c>SMTP_FROM_EMAIL</c> (required) - the email address used as the sender.</description></item>
|
||||||
|
/// <item><description><c>SMTP_FROM_NAME</c> (optional, default "The Biergarten") - the display name used as the sender.</description></item>
|
||||||
|
/// </list>
|
||||||
|
/// </remarks>
|
||||||
|
/// <exception cref="InvalidOperationException">
|
||||||
|
/// Thrown when <c>SMTP_HOST</c> or <c>SMTP_FROM_EMAIL</c> is not set, or when <c>SMTP_PORT</c> is not a valid integer.
|
||||||
|
/// </exception>
|
||||||
public SmtpEmailProvider()
|
public SmtpEmailProvider()
|
||||||
{
|
{
|
||||||
_host =
|
_host =
|
||||||
@@ -53,6 +72,14 @@ public class SmtpEmailProvider : IEmailProvider
|
|||||||
?? "The Biergarten";
|
?? "The Biergarten";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sends an email to a single recipient by delegating to the multi-recipient overload.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="to">Recipient email address</param>
|
||||||
|
/// <param name="subject">Email subject line</param>
|
||||||
|
/// <param name="body">Email body (HTML or plain text)</param>
|
||||||
|
/// <param name="isHtml">Whether the body is HTML (default: true)</param>
|
||||||
|
/// <exception cref="InvalidOperationException">Thrown when connecting, authenticating, or sending via SMTP fails.</exception>
|
||||||
public async Task SendAsync(
|
public async Task SendAsync(
|
||||||
string to,
|
string to,
|
||||||
string subject,
|
string subject,
|
||||||
@@ -63,6 +90,16 @@ public class SmtpEmailProvider : IEmailProvider
|
|||||||
await SendAsync([to], subject, body, isHtml);
|
await SendAsync([to], subject, body, isHtml);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sends an email to multiple recipients using MailKit's <see cref="SmtpClient"/>.
|
||||||
|
/// Connects using StartTls when SSL is enabled (or no encryption otherwise), authenticates
|
||||||
|
/// if credentials were configured, sends the message, and disconnects.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="to">List of recipient email addresses</param>
|
||||||
|
/// <param name="subject">Email subject line</param>
|
||||||
|
/// <param name="body">Email body (HTML or plain text)</param>
|
||||||
|
/// <param name="isHtml">Whether the body is HTML (default: true)</param>
|
||||||
|
/// <exception cref="InvalidOperationException">Thrown when connecting, authenticating, or sending via SMTP fails. The original exception is included as the inner exception.</exception>
|
||||||
public async Task SendAsync(
|
public async Task SendAsync(
|
||||||
IEnumerable<string> to,
|
IEnumerable<string> to,
|
||||||
string subject,
|
string subject,
|
||||||
|
|||||||
@@ -2,8 +2,19 @@ using System.Security.Claims;
|
|||||||
|
|
||||||
namespace Infrastructure.Jwt;
|
namespace Infrastructure.Jwt;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service for generating and validating JSON Web Tokens (JWTs) used for authentication.
|
||||||
|
/// </summary>
|
||||||
public interface ITokenInfrastructure
|
public interface ITokenInfrastructure
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a signed JWT for the given user.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="userId">The unique identifier of the user the token is issued for.</param>
|
||||||
|
/// <param name="username">The username of the user, included as a claim.</param>
|
||||||
|
/// <param name="expiry">The date and time at which the token expires.</param>
|
||||||
|
/// <param name="secret">The symmetric secret used to sign the token.</param>
|
||||||
|
/// <returns>The serialized, signed JWT string.</returns>
|
||||||
string GenerateJwt(
|
string GenerateJwt(
|
||||||
Guid userId,
|
Guid userId,
|
||||||
string username,
|
string username,
|
||||||
@@ -11,5 +22,12 @@ public interface ITokenInfrastructure
|
|||||||
string secret
|
string secret
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates a JWT and returns the resulting claims principal.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="token">The JWT string to validate.</param>
|
||||||
|
/// <param name="secret">The symmetric secret used to verify the token's signature.</param>
|
||||||
|
/// <returns>A <see cref="ClaimsPrincipal"/> representing the validated token's claims.</returns>
|
||||||
|
/// <exception cref="Domain.Exceptions.UnauthorizedException">Thrown when the token is invalid, expired, or fails validation.</exception>
|
||||||
Task<ClaimsPrincipal> ValidateJwtAsync(string token, string secret);
|
Task<ClaimsPrincipal> ValidateJwtAsync(string token, string secret);
|
||||||
}
|
}
|
||||||
@@ -7,8 +7,24 @@ using Domain.Exceptions;
|
|||||||
|
|
||||||
namespace Infrastructure.Jwt;
|
namespace Infrastructure.Jwt;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates and validates HMAC-SHA256 signed JWTs using <see cref="JsonWebTokenHandler"/>.
|
||||||
|
/// </summary>
|
||||||
public class JwtInfrastructure : ITokenInfrastructure
|
public class JwtInfrastructure : ITokenInfrastructure
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a signed JWT containing the user's ID, username, issued-at time, expiry time,
|
||||||
|
/// and a unique token identifier (JTI), signed using HMAC-SHA256 with the provided secret.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Sets the following registered claims: <c>sub</c> (userId), <c>unique_name</c> (username),
|
||||||
|
/// <c>iat</c> (current UTC time), <c>exp</c> (expiry), and <c>jti</c> (a newly generated GUID).
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="userId">The unique identifier of the user the token is issued for.</param>
|
||||||
|
/// <param name="username">The username of the user, included as a claim.</param>
|
||||||
|
/// <param name="expiry">The date and time at which the token expires.</param>
|
||||||
|
/// <param name="secret">The symmetric secret used to sign the token (encoded as UTF-8 bytes).</param>
|
||||||
|
/// <returns>The serialized, signed JWT string.</returns>
|
||||||
public string GenerateJwt(
|
public string GenerateJwt(
|
||||||
Guid userId,
|
Guid userId,
|
||||||
string username,
|
string username,
|
||||||
@@ -47,6 +63,17 @@ public class JwtInfrastructure : ITokenInfrastructure
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates a JWT's signature and lifetime (issuer and audience validation are disabled),
|
||||||
|
/// using the provided secret as the HMAC-SHA256 symmetric signing key.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="token">The JWT string to validate.</param>
|
||||||
|
/// <param name="secret">The symmetric secret used to verify the token's signature (encoded as UTF-8 bytes).</param>
|
||||||
|
/// <returns>A <see cref="ClaimsPrincipal"/> wrapping the validated token's claims identity.</returns>
|
||||||
|
/// <exception cref="UnauthorizedException">
|
||||||
|
/// Thrown when the token is invalid, has no claims identity, is expired, or otherwise fails validation
|
||||||
|
/// (including when validation itself throws, e.g. due to a malformed token or signature mismatch).
|
||||||
|
/// </exception>
|
||||||
public async Task<ClaimsPrincipal> ValidateJwtAsync(
|
public async Task<ClaimsPrincipal> ValidateJwtAsync(
|
||||||
string token,
|
string token,
|
||||||
string secret
|
string secret
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ using Konscious.Security.Cryptography;
|
|||||||
|
|
||||||
namespace Infrastructure.PasswordHashing;
|
namespace Infrastructure.PasswordHashing;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hashes and verifies passwords using the Argon2id algorithm via Konscious.Security.Cryptography.
|
||||||
|
/// </summary>
|
||||||
public class Argon2Infrastructure : IPasswordInfrastructure
|
public class Argon2Infrastructure : IPasswordInfrastructure
|
||||||
{
|
{
|
||||||
private const int SaltSize = 16; // 128-bit
|
private const int SaltSize = 16; // 128-bit
|
||||||
@@ -11,6 +14,16 @@ public class Argon2Infrastructure : IPasswordInfrastructure
|
|||||||
private const int ArgonIterations = 4;
|
private const int ArgonIterations = 4;
|
||||||
private const int ArgonMemoryKb = 65536; // 64MB
|
private const int ArgonMemoryKb = 65536; // 64MB
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hashes a plaintext password using Argon2id with a newly generated 128-bit cryptographically
|
||||||
|
/// random salt, 4 iterations, 64MB of memory, and a degree of parallelism equal to the number of
|
||||||
|
/// available processors (minimum 1).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="password">The plaintext password to hash.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// A string of the form <c>"{base64Salt}:{base64Hash}"</c> containing the salt and the resulting
|
||||||
|
/// 256-bit hash, suitable for storage and later verification.
|
||||||
|
/// </returns>
|
||||||
public string Hash(string password)
|
public string Hash(string password)
|
||||||
{
|
{
|
||||||
var salt = RandomNumberGenerator.GetBytes(SaltSize);
|
var salt = RandomNumberGenerator.GetBytes(SaltSize);
|
||||||
@@ -26,6 +39,20 @@ public class Argon2Infrastructure : IPasswordInfrastructure
|
|||||||
return $"{Convert.ToBase64String(salt)}:{Convert.ToBase64String(hash)}";
|
return $"{Convert.ToBase64String(salt)}:{Convert.ToBase64String(hash)}";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies a plaintext password against a stored salt/hash string by recomputing the Argon2id
|
||||||
|
/// hash with the extracted salt and comparing it to the stored hash using a fixed-time comparison
|
||||||
|
/// to mitigate timing attacks.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="password">The plaintext password to verify.</param>
|
||||||
|
/// <param name="stored">
|
||||||
|
/// The stored string of the form <c>"{base64Salt}:{base64Hash}"</c> previously produced by <see cref="Hash"/>.
|
||||||
|
/// </param>
|
||||||
|
/// <returns>
|
||||||
|
/// <c>true</c> if the password matches the stored hash; <c>false</c> if it does not match, the stored
|
||||||
|
/// string is malformed (e.g. not in the expected two-part format, or not valid base64), or any other
|
||||||
|
/// error occurs while verifying.
|
||||||
|
/// </returns>
|
||||||
public bool Verify(string password, string stored)
|
public bool Verify(string password, string stored)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -1,7 +1,22 @@
|
|||||||
namespace Infrastructure.PasswordHashing;
|
namespace Infrastructure.PasswordHashing;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service for hashing and verifying user passwords.
|
||||||
|
/// </summary>
|
||||||
public interface IPasswordInfrastructure
|
public interface IPasswordInfrastructure
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Hashes a plaintext password, generating a new random salt.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="password">The plaintext password to hash.</param>
|
||||||
|
/// <returns>A string encoding both the salt and the resulting hash, suitable for storage.</returns>
|
||||||
public string Hash(string password);
|
public string Hash(string password);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies a plaintext password against a previously stored hash.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="password">The plaintext password to verify.</param>
|
||||||
|
/// <param name="stored">The stored salt/hash string previously produced by <see cref="Hash"/>.</param>
|
||||||
|
/// <returns><c>true</c> if the password matches the stored hash; otherwise <c>false</c>.</returns>
|
||||||
public bool Verify(string password, string stored);
|
public bool Verify(string password, string stored);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,34 @@ using Microsoft.Data.SqlClient;
|
|||||||
|
|
||||||
namespace Infrastructure.Repository.Auth;
|
namespace Infrastructure.Repository.Auth;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ADO.NET-based implementation of <see cref="IAuthRepository"/> backed by SQL Server stored procedures,
|
||||||
|
/// handling user registration, credential lookup/rotation, and account verification.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="connectionFactory">The factory used to create database connections.</param>
|
||||||
public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||||
: Repository<Domain.Entities.UserAccount>(connectionFactory),
|
: Repository<Domain.Entities.UserAccount>(connectionFactory),
|
||||||
IAuthRepository
|
IAuthRepository
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Registers a new user account and initial credential using the <c>USP_RegisterUser</c> stored
|
||||||
|
/// procedure, then fetches and returns the newly created user.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The stored procedure's scalar result (expected to be the new user's ID) is parsed defensively:
|
||||||
|
/// it may be returned as a <see cref="Guid"/>, a parseable <see cref="string"/>, or a 16-byte array.
|
||||||
|
/// If the result cannot be interpreted, <see cref="Guid.Empty"/> is used, which will cause the
|
||||||
|
/// subsequent lookup to fail.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="username">Unique username for the user</param>
|
||||||
|
/// <param name="firstName">User's first name</param>
|
||||||
|
/// <param name="lastName">User's last name</param>
|
||||||
|
/// <param name="email">User's email address</param>
|
||||||
|
/// <param name="dateOfBirth">User's date of birth</param>
|
||||||
|
/// <param name="passwordHash">Hashed password</param>
|
||||||
|
/// <returns>The newly created UserAccount with generated ID</returns>
|
||||||
|
/// <exception cref="Exception">Thrown when the newly registered user cannot be retrieved after registration.</exception>
|
||||||
|
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
|
||||||
public async Task<Domain.Entities.UserAccount> RegisterUserAsync(
|
public async Task<Domain.Entities.UserAccount> RegisterUserAsync(
|
||||||
string username,
|
string username,
|
||||||
string firstName,
|
string firstName,
|
||||||
@@ -68,6 +92,13 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
return await GetUserByIdAsync(userAccountId) ?? throw new Exception("Failed to retrieve newly registered user.");
|
return await GetUserByIdAsync(userAccountId) ?? throw new Exception("Failed to retrieve newly registered user.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves a user account by email address (typically used for login) using the
|
||||||
|
/// <c>usp_GetUserAccountByEmail</c> stored procedure.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="email">Email address to search for</param>
|
||||||
|
/// <returns>UserAccount if found, null otherwise</returns>
|
||||||
|
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
|
||||||
public async Task<Domain.Entities.UserAccount?> GetUserByEmailAsync(
|
public async Task<Domain.Entities.UserAccount?> GetUserByEmailAsync(
|
||||||
string email
|
string email
|
||||||
)
|
)
|
||||||
@@ -83,6 +114,13 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
return await reader.ReadAsync() ? MapToEntity(reader) : null;
|
return await reader.ReadAsync() ? MapToEntity(reader) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves a user account by username (typically used for login) using the
|
||||||
|
/// <c>usp_GetUserAccountByUsername</c> stored procedure.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="username">Username to search for</param>
|
||||||
|
/// <returns>UserAccount if found, null otherwise</returns>
|
||||||
|
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
|
||||||
public async Task<Domain.Entities.UserAccount?> GetUserByUsernameAsync(
|
public async Task<Domain.Entities.UserAccount?> GetUserByUsernameAsync(
|
||||||
string username
|
string username
|
||||||
)
|
)
|
||||||
@@ -98,6 +136,13 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
return await reader.ReadAsync() ? MapToEntity(reader) : null;
|
return await reader.ReadAsync() ? MapToEntity(reader) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves the active (non-revoked) credential for a user account using the
|
||||||
|
/// <c>USP_GetActiveUserCredentialByUserAccountId</c> stored procedure.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="userAccountId">ID of the user account</param>
|
||||||
|
/// <returns>Active UserCredential if found, null otherwise</returns>
|
||||||
|
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
|
||||||
public async Task<UserCredential?> GetActiveCredentialByUserAccountIdAsync(
|
public async Task<UserCredential?> GetActiveCredentialByUserAccountIdAsync(
|
||||||
Guid userAccountId
|
Guid userAccountId
|
||||||
)
|
)
|
||||||
@@ -113,6 +158,13 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
return await reader.ReadAsync() ? MapToCredentialEntity(reader) : null;
|
return await reader.ReadAsync() ? MapToCredentialEntity(reader) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Rotates a user's credential by invalidating all existing credentials and creating a new one,
|
||||||
|
/// using the <c>USP_RotateUserCredential</c> stored procedure.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="userAccountId">ID of the user account</param>
|
||||||
|
/// <param name="newPasswordHash">New hashed password</param>
|
||||||
|
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
|
||||||
public async Task RotateCredentialAsync(
|
public async Task RotateCredentialAsync(
|
||||||
Guid userAccountId,
|
Guid userAccountId,
|
||||||
string newPasswordHash
|
string newPasswordHash
|
||||||
@@ -129,6 +181,12 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
await command.ExecuteNonQueryAsync();
|
await command.ExecuteNonQueryAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves a user account by ID using the <c>usp_GetUserAccountById</c> stored procedure.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="userAccountId">ID of the user account</param>
|
||||||
|
/// <returns>UserAccount if found, null otherwise</returns>
|
||||||
|
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
|
||||||
public async Task<Domain.Entities.UserAccount?> GetUserByIdAsync(
|
public async Task<Domain.Entities.UserAccount?> GetUserByIdAsync(
|
||||||
Guid userAccountId
|
Guid userAccountId
|
||||||
)
|
)
|
||||||
@@ -144,6 +202,15 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
return await reader.ReadAsync() ? MapToEntity(reader) : null;
|
return await reader.ReadAsync() ? MapToEntity(reader) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Marks a user account as confirmed by creating a verification record via the
|
||||||
|
/// <c>USP_CreateUserVerification</c> stored procedure. If the user is already verified, this is a
|
||||||
|
/// no-op and the existing user is returned (idempotent). If a concurrent request verifies the user
|
||||||
|
/// first, the resulting duplicate-key SQL exception (error 2601/2627) is swallowed.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="userAccountId">ID of the user account to confirm</param>
|
||||||
|
/// <returns>The confirmed <see cref="Domain.Entities.UserAccount"/>, or <c>null</c> if the user account does not exist.</returns>
|
||||||
|
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails for a reason other than a duplicate verification record.</exception>
|
||||||
public async Task<Domain.Entities.UserAccount?> ConfirmUserAccountAsync(
|
public async Task<Domain.Entities.UserAccount?> ConfirmUserAccountAsync(
|
||||||
Guid userAccountId
|
Guid userAccountId
|
||||||
)
|
)
|
||||||
@@ -180,6 +247,13 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
return await GetUserByIdAsync(userAccountId);
|
return await GetUserByIdAsync(userAccountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks whether a user account has been verified by querying the
|
||||||
|
/// <c>dbo.UserVerification</c> table for a matching record.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="userAccountId">ID of the user account</param>
|
||||||
|
/// <returns>True if the user has a verification record, false otherwise</returns>
|
||||||
|
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
|
||||||
public async Task<bool> IsUserVerifiedAsync(Guid userAccountId)
|
public async Task<bool> IsUserVerifiedAsync(Guid userAccountId)
|
||||||
{
|
{
|
||||||
await using var connection = await CreateConnection();
|
await using var connection = await CreateConnection();
|
||||||
@@ -194,6 +268,12 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
return result != null && result != DBNull.Value;
|
return result != null && result != DBNull.Value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines whether a <see cref="SqlException"/> represents a duplicate key violation
|
||||||
|
/// (SQL Server error 2601 or 2627), used to detect a concurrent duplicate verification insert.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="ex">The SQL exception to inspect.</param>
|
||||||
|
/// <returns>True if the exception represents a duplicate key violation, false otherwise.</returns>
|
||||||
private static bool IsDuplicateVerificationViolation(SqlException ex)
|
private static bool IsDuplicateVerificationViolation(SqlException ex)
|
||||||
{
|
{
|
||||||
// 2601/2627 are duplicate key violations in SQL Server.
|
// 2601/2627 are duplicate key violations in SQL Server.
|
||||||
@@ -204,6 +284,8 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Maps a data reader row to a UserAccount entity.
|
/// Maps a data reader row to a UserAccount entity.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="reader">The data reader positioned on the row to map.</param>
|
||||||
|
/// <returns>The mapped <see cref="Domain.Entities.UserAccount"/> instance.</returns>
|
||||||
protected override Domain.Entities.UserAccount MapToEntity(
|
protected override Domain.Entities.UserAccount MapToEntity(
|
||||||
DbDataReader reader
|
DbDataReader reader
|
||||||
)
|
)
|
||||||
@@ -227,8 +309,11 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Maps a data reader row to a UserCredential entity.
|
/// Maps a data reader row to a UserCredential entity. The <c>Timer</c> column is mapped only if
|
||||||
|
/// present in the reader's schema, allowing this method to support result sets that omit it.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="reader">The data reader positioned on the row to map.</param>
|
||||||
|
/// <returns>The mapped <see cref="UserCredential"/> instance.</returns>
|
||||||
private static UserCredential MapToCredentialEntity(DbDataReader reader)
|
private static UserCredential MapToCredentialEntity(DbDataReader reader)
|
||||||
{
|
{
|
||||||
var entity = new UserCredential
|
var entity = new UserCredential
|
||||||
@@ -265,8 +350,12 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Helper method to add a parameter to a database command.
|
/// Helper method to add a parameter to a database command, converting <c>null</c> values to
|
||||||
|
/// <see cref="DBNull.Value"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="command">The command to add the parameter to.</param>
|
||||||
|
/// <param name="name">The parameter name (including any prefix, e.g. "@Username").</param>
|
||||||
|
/// <param name="value">The parameter value, or <c>null</c> to bind <see cref="DBNull.Value"/>.</param>
|
||||||
private static void AddParameter(
|
private static void AddParameter(
|
||||||
DbCommand command,
|
DbCommand command,
|
||||||
string name,
|
string name,
|
||||||
|
|||||||
@@ -65,8 +65,7 @@ public interface IAuthRepository
|
|||||||
/// Marks a user account as confirmed.
|
/// Marks a user account as confirmed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="userAccountId">ID of the user account to confirm</param>
|
/// <param name="userAccountId">ID of the user account to confirm</param>
|
||||||
/// <returns>The confirmed UserAccount entity</returns>
|
/// <returns>The confirmed UserAccount entity, or null if the user account does not exist</returns>
|
||||||
/// <exception cref="UnauthorizedException">If user account not found</exception>
|
|
||||||
Task<Domain.Entities.UserAccount?> ConfirmUserAccountAsync(Guid userAccountId);
|
Task<Domain.Entities.UserAccount?> ConfirmUserAccountAsync(Guid userAccountId);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -4,11 +4,22 @@ using Infrastructure.Repository.Sql;
|
|||||||
|
|
||||||
namespace Infrastructure.Repository.Breweries;
|
namespace Infrastructure.Repository.Breweries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ADO.NET-based implementation of <see cref="IBreweryRepository"/> backed by SQL Server stored
|
||||||
|
/// procedures.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="connectionFactory">The factory used to create database connections.</param>
|
||||||
public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
||||||
: Repository<BreweryPost>(connectionFactory), IBreweryRepository
|
: Repository<BreweryPost>(connectionFactory), IBreweryRepository
|
||||||
{
|
{
|
||||||
private readonly ISqlConnectionFactory _connectionFactory = connectionFactory;
|
private readonly ISqlConnectionFactory _connectionFactory = connectionFactory;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves a brewery post by ID using the <c>USP_GetBreweryById</c> stored procedure.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">The unique identifier of the brewery post.</param>
|
||||||
|
/// <returns>The matching <see cref="BreweryPost"/>, or <c>null</c> if not found.</returns>
|
||||||
|
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
|
||||||
public async Task<BreweryPost?> GetByIdAsync(Guid id)
|
public async Task<BreweryPost?> GetByIdAsync(Guid id)
|
||||||
{
|
{
|
||||||
await using var connection = await CreateConnection();
|
await using var connection = await CreateConnection();
|
||||||
@@ -26,21 +37,44 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Not yet implemented.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="limit">The maximum number of records to return, or <c>null</c> for no limit.</param>
|
||||||
|
/// <param name="offset">The number of records to skip, or <c>null</c> for no offset.</param>
|
||||||
|
/// <returns>Never returns; always throws.</returns>
|
||||||
|
/// <exception cref="NotImplementedException">Always thrown.</exception>
|
||||||
public Task<IEnumerable<BreweryPost>> GetAllAsync(int? limit, int? offset)
|
public Task<IEnumerable<BreweryPost>> GetAllAsync(int? limit, int? offset)
|
||||||
{
|
{
|
||||||
throw new NotImplementedException();
|
throw new NotImplementedException();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Not yet implemented.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="brewery">The brewery post containing updated values.</param>
|
||||||
|
/// <exception cref="NotImplementedException">Always thrown.</exception>
|
||||||
public Task UpdateAsync(BreweryPost brewery)
|
public Task UpdateAsync(BreweryPost brewery)
|
||||||
{
|
{
|
||||||
throw new NotImplementedException();
|
throw new NotImplementedException();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Not yet implemented.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">The unique identifier of the brewery post to delete.</param>
|
||||||
|
/// <exception cref="NotImplementedException">Always thrown.</exception>
|
||||||
public Task DeleteAsync(Guid id)
|
public Task DeleteAsync(Guid id)
|
||||||
{
|
{
|
||||||
throw new NotImplementedException();
|
throw new NotImplementedException();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new brewery post and its location using the <c>USP_CreateBrewery</c> stored procedure.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="brewery">The brewery post to create. Must have a non-null <c>Location</c>.</param>
|
||||||
|
/// <exception cref="ArgumentException">Thrown when <paramref name="brewery"/>.<c>Location</c> is <c>null</c>.</exception>
|
||||||
|
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
|
||||||
public async Task CreateAsync(BreweryPost brewery)
|
public async Task CreateAsync(BreweryPost brewery)
|
||||||
{
|
{
|
||||||
await using var connection = await CreateConnection();
|
await using var connection = await CreateConnection();
|
||||||
@@ -66,6 +100,13 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps the current row of a data reader to a <see cref="BreweryPost"/> entity, including its
|
||||||
|
/// rowversion <c>Timer</c> field and, if location columns are present in the result set, its
|
||||||
|
/// associated <see cref="BreweryPostLocation"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="reader">The data reader positioned on the row to map.</param>
|
||||||
|
/// <returns>The mapped <see cref="BreweryPost"/> instance.</returns>
|
||||||
protected override BreweryPost MapToEntity(DbDataReader reader)
|
protected override BreweryPost MapToEntity(DbDataReader reader)
|
||||||
{
|
{
|
||||||
var brewery = new BreweryPost();
|
var brewery = new BreweryPost();
|
||||||
@@ -133,6 +174,13 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
return brewery;
|
return brewery;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Helper method to add a parameter to a database command, converting <c>null</c> values to
|
||||||
|
/// <see cref="DBNull.Value"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="command">The command to add the parameter to.</param>
|
||||||
|
/// <param name="name">The parameter name (including any prefix, e.g. "@BreweryName").</param>
|
||||||
|
/// <param name="value">The parameter value, or <c>null</c> to bind <see cref="DBNull.Value"/>.</param>
|
||||||
private static void AddParameter(
|
private static void AddParameter(
|
||||||
DbCommand command,
|
DbCommand command,
|
||||||
string name,
|
string name,
|
||||||
|
|||||||
@@ -2,11 +2,42 @@ using Domain.Entities;
|
|||||||
|
|
||||||
namespace Infrastructure.Repository.Breweries;
|
namespace Infrastructure.Repository.Breweries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Repository for CRUD operations on brewery post records.
|
||||||
|
/// </summary>
|
||||||
public interface IBreweryRepository
|
public interface IBreweryRepository
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves a brewery post by its unique identifier.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">The unique identifier of the brewery post.</param>
|
||||||
|
/// <returns>The matching <see cref="BreweryPost"/>, or <c>null</c> if not found.</returns>
|
||||||
Task<BreweryPost?> GetByIdAsync(Guid id);
|
Task<BreweryPost?> GetByIdAsync(Guid id);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves all brewery posts, optionally paginated.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="limit">The maximum number of records to return, or <c>null</c> for no limit.</param>
|
||||||
|
/// <param name="offset">The number of records to skip, or <c>null</c> for no offset.</param>
|
||||||
|
/// <returns>The collection of matching <see cref="BreweryPost"/> records.</returns>
|
||||||
Task<IEnumerable<BreweryPost>> GetAllAsync(int? limit, int? offset);
|
Task<IEnumerable<BreweryPost>> GetAllAsync(int? limit, int? offset);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates an existing brewery post.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="brewery">The brewery post containing updated values.</param>
|
||||||
Task UpdateAsync(BreweryPost brewery);
|
Task UpdateAsync(BreweryPost brewery);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes a brewery post by its unique identifier.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">The unique identifier of the brewery post to delete.</param>
|
||||||
Task DeleteAsync(Guid id);
|
Task DeleteAsync(Guid id);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new brewery post, including its location details.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="brewery">The brewery post to create. Must have a non-null <c>Location</c>.</param>
|
||||||
|
/// <exception cref="ArgumentException">Thrown when <paramref name="brewery"/> has no <c>Location</c>.</exception>
|
||||||
Task CreateAsync(BreweryPost brewery);
|
Task CreateAsync(BreweryPost brewery);
|
||||||
}
|
}
|
||||||
@@ -3,9 +3,20 @@ using Infrastructure.Repository.Sql;
|
|||||||
|
|
||||||
namespace Infrastructure.Repository;
|
namespace Infrastructure.Repository;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Base class for ADO.NET-based repositories, providing shared connection creation and
|
||||||
|
/// entity-mapping infrastructure for derived repository implementations.
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="T">The entity type managed by the repository.</typeparam>
|
||||||
|
/// <param name="connectionFactory">The factory used to create database connections.</param>
|
||||||
public abstract class Repository<T>(ISqlConnectionFactory connectionFactory)
|
public abstract class Repository<T>(ISqlConnectionFactory connectionFactory)
|
||||||
where T : class
|
where T : class
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Creates and opens a new database connection using the configured <see cref="ISqlConnectionFactory"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>An open <see cref="DbConnection"/> ready for use.</returns>
|
||||||
|
/// <exception cref="DbException">Thrown when the connection cannot be opened.</exception>
|
||||||
protected async Task<DbConnection> CreateConnection()
|
protected async Task<DbConnection> CreateConnection()
|
||||||
{
|
{
|
||||||
var connection = connectionFactory.CreateConnection();
|
var connection = connectionFactory.CreateConnection();
|
||||||
@@ -13,5 +24,10 @@ public abstract class Repository<T>(ISqlConnectionFactory connectionFactory)
|
|||||||
return connection;
|
return connection;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps the current row of a data reader to an instance of <typeparamref name="T"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="reader">The data reader positioned on the row to map.</param>
|
||||||
|
/// <returns>The mapped entity instance.</returns>
|
||||||
protected abstract T MapToEntity(DbDataReader reader);
|
protected abstract T MapToEntity(DbDataReader reader);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ using Microsoft.Extensions.Configuration;
|
|||||||
|
|
||||||
namespace Infrastructure.Repository.Sql;
|
namespace Infrastructure.Repository.Sql;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Default <see cref="ISqlConnectionFactory"/> implementation that creates SQL Server connections,
|
||||||
|
/// resolving the connection string from environment variables or application configuration.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="configuration">The application configuration, used as a fallback source for the connection string.</param>
|
||||||
public class DefaultSqlConnectionFactory(IConfiguration configuration)
|
public class DefaultSqlConnectionFactory(IConfiguration configuration)
|
||||||
: ISqlConnectionFactory
|
: ISqlConnectionFactory
|
||||||
{
|
{
|
||||||
@@ -11,6 +16,17 @@ public class DefaultSqlConnectionFactory(IConfiguration configuration)
|
|||||||
configuration
|
configuration
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves the SQL Server connection string, preferring (in order): the <c>DB_CONNECTION_STRING</c>
|
||||||
|
/// environment variable, a connection string built from individual <c>DB_*</c> environment variables
|
||||||
|
/// via <see cref="SqlConnectionStringHelper.BuildConnectionString"/>, and finally the <c>"Default"</c>
|
||||||
|
/// connection string from <paramref name="configuration"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="configuration">The application configuration to fall back to.</param>
|
||||||
|
/// <returns>The resolved SQL Server connection string.</returns>
|
||||||
|
/// <exception cref="InvalidOperationException">
|
||||||
|
/// Thrown when no connection string can be resolved from any of the supported sources.
|
||||||
|
/// </exception>
|
||||||
private static string GetConnectionString(IConfiguration configuration)
|
private static string GetConnectionString(IConfiguration configuration)
|
||||||
{
|
{
|
||||||
// Check for full connection string first
|
// Check for full connection string first
|
||||||
@@ -42,6 +58,10 @@ public class DefaultSqlConnectionFactory(IConfiguration configuration)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new, unopened <see cref="SqlConnection"/> using the resolved connection string.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A new <see cref="SqlConnection"/> instance.</returns>
|
||||||
public DbConnection CreateConnection()
|
public DbConnection CreateConnection()
|
||||||
{
|
{
|
||||||
return new SqlConnection(_connectionString);
|
return new SqlConnection(_connectionString);
|
||||||
|
|||||||
@@ -2,7 +2,14 @@ using System.Data.Common;
|
|||||||
|
|
||||||
namespace Infrastructure.Repository.Sql;
|
namespace Infrastructure.Repository.Sql;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Factory for creating database connections used by repositories.
|
||||||
|
/// </summary>
|
||||||
public interface ISqlConnectionFactory
|
public interface ISqlConnectionFactory
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new, unopened database connection.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A new <see cref="DbConnection"/> instance.</returns>
|
||||||
DbConnection CreateConnection();
|
DbConnection CreateConnection();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ using Microsoft.Data.SqlClient;
|
|||||||
|
|
||||||
namespace Infrastructure.Repository.Sql;
|
namespace Infrastructure.Repository.Sql;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Helper for building SQL Server connection strings from environment variables.
|
||||||
|
/// </summary>
|
||||||
public static class SqlConnectionStringHelper
|
public static class SqlConnectionStringHelper
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -1,14 +1,51 @@
|
|||||||
namespace Infrastructure.Repository.UserAccount;
|
namespace Infrastructure.Repository.UserAccount;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Repository for CRUD operations on user account records.
|
||||||
|
/// </summary>
|
||||||
public interface IUserAccountRepository
|
public interface IUserAccountRepository
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves a user account by its unique identifier.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">The unique identifier of the user account.</param>
|
||||||
|
/// <returns>The matching <see cref="Domain.Entities.UserAccount"/>, or <c>null</c> if not found.</returns>
|
||||||
Task<Domain.Entities.UserAccount?> GetByIdAsync(Guid id);
|
Task<Domain.Entities.UserAccount?> GetByIdAsync(Guid id);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves all user accounts, optionally paginated.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="limit">The maximum number of records to return, or <c>null</c> for no limit.</param>
|
||||||
|
/// <param name="offset">The number of records to skip, or <c>null</c> for no offset.</param>
|
||||||
|
/// <returns>The collection of matching <see cref="Domain.Entities.UserAccount"/> records.</returns>
|
||||||
Task<IEnumerable<Domain.Entities.UserAccount>> GetAllAsync(
|
Task<IEnumerable<Domain.Entities.UserAccount>> GetAllAsync(
|
||||||
int? limit,
|
int? limit,
|
||||||
int? offset
|
int? offset
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates an existing user account's details.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="userAccount">The user account containing updated values. Must have a valid <c>UserAccountId</c>.</param>
|
||||||
Task UpdateAsync(Domain.Entities.UserAccount userAccount);
|
Task UpdateAsync(Domain.Entities.UserAccount userAccount);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes a user account by its unique identifier.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">The unique identifier of the user account to delete.</param>
|
||||||
Task DeleteAsync(Guid id);
|
Task DeleteAsync(Guid id);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves a user account by username.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="username">The username to search for.</param>
|
||||||
|
/// <returns>The matching <see cref="Domain.Entities.UserAccount"/>, or <c>null</c> if not found.</returns>
|
||||||
Task<Domain.Entities.UserAccount?> GetByUsernameAsync(string username);
|
Task<Domain.Entities.UserAccount?> GetByUsernameAsync(string username);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves a user account by email address.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="email">The email address to search for.</param>
|
||||||
|
/// <returns>The matching <see cref="Domain.Entities.UserAccount"/>, or <c>null</c> if not found.</returns>
|
||||||
Task<Domain.Entities.UserAccount?> GetByEmailAsync(string email);
|
Task<Domain.Entities.UserAccount?> GetByEmailAsync(string email);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,21 @@ using Infrastructure.Repository.Sql;
|
|||||||
|
|
||||||
namespace Infrastructure.Repository.UserAccount;
|
namespace Infrastructure.Repository.UserAccount;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ADO.NET-based implementation of <see cref="IUserAccountRepository"/> backed by SQL Server
|
||||||
|
/// stored procedures.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="connectionFactory">The factory used to create database connections.</param>
|
||||||
public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
|
public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
|
||||||
: Repository<Domain.Entities.UserAccount>(connectionFactory),
|
: Repository<Domain.Entities.UserAccount>(connectionFactory),
|
||||||
IUserAccountRepository
|
IUserAccountRepository
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves a user account by ID using the <c>usp_GetUserAccountById</c> stored procedure.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">The unique identifier of the user account.</param>
|
||||||
|
/// <returns>The matching <see cref="Domain.Entities.UserAccount"/>, or <c>null</c> if not found.</returns>
|
||||||
|
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
|
||||||
public async Task<Domain.Entities.UserAccount?> GetByIdAsync(Guid id)
|
public async Task<Domain.Entities.UserAccount?> GetByIdAsync(Guid id)
|
||||||
{
|
{
|
||||||
await using var connection = await CreateConnection();
|
await using var connection = await CreateConnection();
|
||||||
@@ -21,6 +32,15 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
return await reader.ReadAsync() ? MapToEntity(reader) : null;
|
return await reader.ReadAsync() ? MapToEntity(reader) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves all user accounts, optionally paginated, using the <c>usp_GetAllUserAccounts</c>
|
||||||
|
/// stored procedure. The <c>@Limit</c> and <c>@Offset</c> parameters are only added when their
|
||||||
|
/// corresponding argument has a value.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="limit">The maximum number of records to return, or <c>null</c> for no limit.</param>
|
||||||
|
/// <param name="offset">The number of records to skip, or <c>null</c> for no offset.</param>
|
||||||
|
/// <returns>The collection of matching <see cref="Domain.Entities.UserAccount"/> records.</returns>
|
||||||
|
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
|
||||||
public async Task<IEnumerable<Domain.Entities.UserAccount>> GetAllAsync(
|
public async Task<IEnumerable<Domain.Entities.UserAccount>> GetAllAsync(
|
||||||
int? limit,
|
int? limit,
|
||||||
int? offset
|
int? offset
|
||||||
@@ -48,6 +68,12 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
return users;
|
return users;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates a user account's username, first name, last name, email, and date of birth using
|
||||||
|
/// the <c>usp_UpdateUserAccount</c> stored procedure.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="userAccount">The user account containing updated values. Must have a valid <c>UserAccountId</c>.</param>
|
||||||
|
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
|
||||||
public async Task UpdateAsync(Domain.Entities.UserAccount userAccount)
|
public async Task UpdateAsync(Domain.Entities.UserAccount userAccount)
|
||||||
{
|
{
|
||||||
await using var connection = await CreateConnection();
|
await using var connection = await CreateConnection();
|
||||||
@@ -65,6 +91,11 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
await command.ExecuteNonQueryAsync();
|
await command.ExecuteNonQueryAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes a user account by ID using the <c>usp_DeleteUserAccount</c> stored procedure.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">The unique identifier of the user account to delete.</param>
|
||||||
|
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
|
||||||
public async Task DeleteAsync(Guid id)
|
public async Task DeleteAsync(Guid id)
|
||||||
{
|
{
|
||||||
await using var connection = await CreateConnection();
|
await using var connection = await CreateConnection();
|
||||||
@@ -76,6 +107,12 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
await command.ExecuteNonQueryAsync();
|
await command.ExecuteNonQueryAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves a user account by username using the <c>usp_GetUserAccountByUsername</c> stored procedure.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="username">The username to search for.</param>
|
||||||
|
/// <returns>The matching <see cref="Domain.Entities.UserAccount"/>, or <c>null</c> if not found.</returns>
|
||||||
|
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
|
||||||
public async Task<Domain.Entities.UserAccount?> GetByUsernameAsync(
|
public async Task<Domain.Entities.UserAccount?> GetByUsernameAsync(
|
||||||
string username
|
string username
|
||||||
)
|
)
|
||||||
@@ -91,6 +128,12 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
return await reader.ReadAsync() ? MapToEntity(reader) : null;
|
return await reader.ReadAsync() ? MapToEntity(reader) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves a user account by email address using the <c>usp_GetUserAccountByEmail</c> stored procedure.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="email">The email address to search for.</param>
|
||||||
|
/// <returns>The matching <see cref="Domain.Entities.UserAccount"/>, or <c>null</c> if not found.</returns>
|
||||||
|
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
|
||||||
public async Task<Domain.Entities.UserAccount?> GetByEmailAsync(
|
public async Task<Domain.Entities.UserAccount?> GetByEmailAsync(
|
||||||
string email
|
string email
|
||||||
)
|
)
|
||||||
@@ -106,6 +149,11 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
return await reader.ReadAsync() ? MapToEntity(reader) : null;
|
return await reader.ReadAsync() ? MapToEntity(reader) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps the current row of a data reader to a <see cref="Domain.Entities.UserAccount"/> entity.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="reader">The data reader positioned on the row to map.</param>
|
||||||
|
/// <returns>The mapped <see cref="Domain.Entities.UserAccount"/> instance.</returns>
|
||||||
protected override Domain.Entities.UserAccount MapToEntity(
|
protected override Domain.Entities.UserAccount MapToEntity(
|
||||||
DbDataReader reader
|
DbDataReader reader
|
||||||
)
|
)
|
||||||
@@ -128,6 +176,13 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Helper method to add a parameter to a database command, converting <c>null</c> values to
|
||||||
|
/// <see cref="DBNull.Value"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="command">The command to add the parameter to.</param>
|
||||||
|
/// <param name="name">The parameter name (including any prefix, e.g. "@Username").</param>
|
||||||
|
/// <param name="value">The parameter value, or <c>null</c> to bind <see cref="DBNull.Value"/>.</param>
|
||||||
private static void AddParameter(
|
private static void AddParameter(
|
||||||
DbCommand command,
|
DbCommand command,
|
||||||
string name,
|
string name,
|
||||||
|
|||||||
@@ -5,12 +5,28 @@ using Service.Emails;
|
|||||||
|
|
||||||
namespace Service.Auth;
|
namespace Service.Auth;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles confirmation of newly registered user accounts and resending of confirmation emails.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="authRepository">Repository used to look up and confirm user accounts.</param>
|
||||||
|
/// <param name="tokenService">Service used to generate and validate confirmation tokens.</param>
|
||||||
|
/// <param name="emailService">Service used to send confirmation-related emails.</param>
|
||||||
public class ConfirmationService(
|
public class ConfirmationService(
|
||||||
IAuthRepository authRepository,
|
IAuthRepository authRepository,
|
||||||
ITokenService tokenService,
|
ITokenService tokenService,
|
||||||
IEmailService emailService
|
IEmailService emailService
|
||||||
) : IConfirmationService
|
) : IConfirmationService
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Validates a confirmation token and marks the corresponding user account as confirmed.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="confirmationToken">The confirmation token issued to the user, typically delivered via email.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// A <see cref="ConfirmationServiceReturn"/> containing the UTC timestamp of confirmation and the confirmed user's ID.
|
||||||
|
/// </returns>
|
||||||
|
/// <exception cref="Domain.Exceptions.UnauthorizedException">
|
||||||
|
/// Thrown when the confirmation token is invalid or expired, or when the associated user account cannot be found.
|
||||||
|
/// </exception>
|
||||||
public async Task<ConfirmationServiceReturn> ConfirmUserAsync(
|
public async Task<ConfirmationServiceReturn> ConfirmUserAsync(
|
||||||
string confirmationToken
|
string confirmationToken
|
||||||
)
|
)
|
||||||
@@ -34,6 +50,15 @@ public class ConfirmationService(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resends the account confirmation email to a user, generating a fresh confirmation token.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="userId">The unique identifier of the user requesting the resend.</param>
|
||||||
|
/// <returns>A task that completes once the operation has finished.</returns>
|
||||||
|
/// <remarks>
|
||||||
|
/// Returns silently without sending an email if the user does not exist (to prevent user enumeration)
|
||||||
|
/// or if the user's account is already verified.
|
||||||
|
/// </remarks>
|
||||||
public async Task ResendConfirmationEmailAsync(Guid userId)
|
public async Task ResendConfirmationEmailAsync(Guid userId)
|
||||||
{
|
{
|
||||||
var user = await authRepository.GetUserByIdAsync(userId);
|
var user = await authRepository.GetUserByIdAsync(userId);
|
||||||
|
|||||||
@@ -3,11 +3,30 @@ using Infrastructure.Repository.Auth;
|
|||||||
|
|
||||||
namespace Service.Auth;
|
namespace Service.Auth;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the result of successfully confirming a user account.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="ConfirmedAt">The UTC date and time at which the account was confirmed.</param>
|
||||||
|
/// <param name="UserId">The unique identifier of the confirmed user.</param>
|
||||||
public record ConfirmationServiceReturn(DateTime ConfirmedAt, Guid UserId);
|
public record ConfirmationServiceReturn(DateTime ConfirmedAt, Guid UserId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defines operations for confirming user accounts and resending confirmation emails.
|
||||||
|
/// </summary>
|
||||||
public interface IConfirmationService
|
public interface IConfirmationService
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Validates a confirmation token and confirms the corresponding user account.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="confirmationToken">The confirmation token issued to the user.</param>
|
||||||
|
/// <returns>A <see cref="ConfirmationServiceReturn"/> describing the confirmed account.</returns>
|
||||||
Task<ConfirmationServiceReturn> ConfirmUserAsync(string confirmationToken);
|
Task<ConfirmationServiceReturn> ConfirmUserAsync(string confirmationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resends the account confirmation email for the specified user.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="userId">The unique identifier of the user requesting the resend.</param>
|
||||||
|
/// <returns>A task that completes once the operation has finished.</returns>
|
||||||
Task ResendConfirmationEmailAsync(Guid userId);
|
Task ResendConfirmationEmailAsync(Guid userId);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,28 @@ using Domain.Entities;
|
|||||||
|
|
||||||
namespace Service.Auth;
|
namespace Service.Auth;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the result of a successful login attempt.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="UserAccount">The authenticated user's account.</param>
|
||||||
|
/// <param name="RefreshToken">The issued refresh token.</param>
|
||||||
|
/// <param name="AccessToken">The issued access token.</param>
|
||||||
public record LoginServiceReturn(
|
public record LoginServiceReturn(
|
||||||
UserAccount UserAccount,
|
UserAccount UserAccount,
|
||||||
string RefreshToken,
|
string RefreshToken,
|
||||||
string AccessToken
|
string AccessToken
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defines the operation for authenticating a user with a username and password.
|
||||||
|
/// </summary>
|
||||||
public interface ILoginService
|
public interface ILoginService
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Authenticates a user using their username and password and issues new tokens.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="username">The username of the account to authenticate.</param>
|
||||||
|
/// <param name="password">The plain-text password to verify against the stored credential.</param>
|
||||||
|
/// <returns>A <see cref="LoginServiceReturn"/> containing the authenticated user and issued tokens.</returns>
|
||||||
Task<LoginServiceReturn> LoginAsync(string username, string password);
|
Task<LoginServiceReturn> LoginAsync(string username, string password);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,14 +2,45 @@ using Domain.Entities;
|
|||||||
|
|
||||||
namespace Service.Auth;
|
namespace Service.Auth;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the result of a user registration attempt.
|
||||||
|
/// </summary>
|
||||||
public record RegisterServiceReturn
|
public record RegisterServiceReturn
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a value indicating whether tokens were issued for the newly created account.
|
||||||
|
/// <c>false</c> when token generation failed, in which case <see cref="AccessToken"/> and
|
||||||
|
/// <see cref="RefreshToken"/> are empty.
|
||||||
|
/// </summary>
|
||||||
public bool IsAuthenticated { get; init; } = false;
|
public bool IsAuthenticated { get; init; } = false;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a value indicating whether the registration confirmation email was sent successfully.
|
||||||
|
/// </summary>
|
||||||
public bool EmailSent { get; init; } = false;
|
public bool EmailSent { get; init; } = false;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the user account that was created.
|
||||||
|
/// </summary>
|
||||||
public UserAccount UserAccount { get; init; }
|
public UserAccount UserAccount { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the issued access token, or an empty string if authentication was not completed.
|
||||||
|
/// </summary>
|
||||||
public string AccessToken { get; init; } = string.Empty;
|
public string AccessToken { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the issued refresh token, or an empty string if authentication was not completed.
|
||||||
|
/// </summary>
|
||||||
public string RefreshToken { get; init; } = string.Empty;
|
public string RefreshToken { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance representing a successful registration with issued tokens.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="userAccount">The newly created user account.</param>
|
||||||
|
/// <param name="accessToken">The issued access token.</param>
|
||||||
|
/// <param name="refreshToken">The issued refresh token.</param>
|
||||||
|
/// <param name="emailSent">Whether the confirmation email was sent successfully.</param>
|
||||||
public RegisterServiceReturn(
|
public RegisterServiceReturn(
|
||||||
UserAccount userAccount,
|
UserAccount userAccount,
|
||||||
string accessToken,
|
string accessToken,
|
||||||
@@ -24,14 +55,27 @@ public record RegisterServiceReturn
|
|||||||
RefreshToken = refreshToken;
|
RefreshToken = refreshToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance representing a registration where tokens could not be issued.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="userAccount">The newly created user account.</param>
|
||||||
public RegisterServiceReturn(UserAccount userAccount)
|
public RegisterServiceReturn(UserAccount userAccount)
|
||||||
{
|
{
|
||||||
UserAccount = userAccount;
|
UserAccount = userAccount;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defines the operation for registering a new user account.
|
||||||
|
/// </summary>
|
||||||
public interface IRegisterService
|
public interface IRegisterService
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Registers a new user account with the given password.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="userAccount">The user account details to register.</param>
|
||||||
|
/// <param name="password">The plain-text password to hash and store for the new account.</param>
|
||||||
|
/// <returns>A <see cref="RegisterServiceReturn"/> describing the outcome of the registration.</returns>
|
||||||
Task<RegisterServiceReturn> RegisterAsync(
|
Task<RegisterServiceReturn> RegisterAsync(
|
||||||
UserAccount userAccount,
|
UserAccount userAccount,
|
||||||
string password
|
string password
|
||||||
|
|||||||
@@ -7,40 +7,119 @@ using Infrastructure.Repository.Auth;
|
|||||||
|
|
||||||
namespace Service.Auth;
|
namespace Service.Auth;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Identifies the kind of token being generated or validated.
|
||||||
|
/// </summary>
|
||||||
public enum TokenType
|
public enum TokenType
|
||||||
{
|
{
|
||||||
|
/// <summary>A short-lived token used to authorize API requests.</summary>
|
||||||
AccessToken,
|
AccessToken,
|
||||||
|
/// <summary>A long-lived token used to obtain new access tokens.</summary>
|
||||||
RefreshToken,
|
RefreshToken,
|
||||||
|
/// <summary>A short-lived token used to confirm a user's email/account.</summary>
|
||||||
ConfirmationToken,
|
ConfirmationToken,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the result of successfully validating a token.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="UserId">The unique identifier of the user the token belongs to.</param>
|
||||||
|
/// <param name="Username">The username extracted from the token's claims.</param>
|
||||||
|
/// <param name="Principal">The <see cref="ClaimsPrincipal"/> produced from the validated token.</param>
|
||||||
public record ValidatedToken(Guid UserId, string Username, ClaimsPrincipal Principal);
|
public record ValidatedToken(Guid UserId, string Username, ClaimsPrincipal Principal);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the result of refreshing a user's session.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="UserAccount">The user account associated with the refreshed session.</param>
|
||||||
|
/// <param name="RefreshToken">The newly issued refresh token.</param>
|
||||||
|
/// <param name="AccessToken">The newly issued access token.</param>
|
||||||
public record RefreshTokenResult(
|
public record RefreshTokenResult(
|
||||||
UserAccount UserAccount,
|
UserAccount UserAccount,
|
||||||
string RefreshToken,
|
string RefreshToken,
|
||||||
string AccessToken
|
string AccessToken
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defines the expiration windows, in hours, for each type of token issued by <see cref="ITokenService"/>.
|
||||||
|
/// </summary>
|
||||||
public static class TokenServiceExpirationHours
|
public static class TokenServiceExpirationHours
|
||||||
{
|
{
|
||||||
|
/// <summary>The expiration window, in hours, for access tokens.</summary>
|
||||||
public const double AccessTokenHours = 1;
|
public const double AccessTokenHours = 1;
|
||||||
|
/// <summary>The expiration window, in hours, for refresh tokens (21 days).</summary>
|
||||||
public const double RefreshTokenHours = 504; // 21 days
|
public const double RefreshTokenHours = 504; // 21 days
|
||||||
|
/// <summary>The expiration window, in hours, for confirmation tokens (30 minutes).</summary>
|
||||||
public const double ConfirmationTokenHours = 0.5; // 30 minutes
|
public const double ConfirmationTokenHours = 0.5; // 30 minutes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defines operations for generating and validating JWTs used for access, refresh, and account confirmation.
|
||||||
|
/// </summary>
|
||||||
public interface ITokenService
|
public interface ITokenService
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a new access token for the given user.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="user">The user account to generate the token for.</param>
|
||||||
|
/// <returns>The signed access token string.</returns>
|
||||||
string GenerateAccessToken(UserAccount user);
|
string GenerateAccessToken(UserAccount user);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a new refresh token for the given user.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="user">The user account to generate the token for.</param>
|
||||||
|
/// <returns>The signed refresh token string.</returns>
|
||||||
string GenerateRefreshToken(UserAccount user);
|
string GenerateRefreshToken(UserAccount user);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a new confirmation token for the given user.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="user">The user account to generate the token for.</param>
|
||||||
|
/// <returns>The signed confirmation token string.</returns>
|
||||||
string GenerateConfirmationToken(UserAccount user);
|
string GenerateConfirmationToken(UserAccount user);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a token of the type specified by <typeparamref name="T"/>, which must be <see cref="TokenType"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="T">The enum type identifying which kind of token to generate. Must be <see cref="TokenType"/>.</typeparam>
|
||||||
|
/// <param name="user">The user account to generate the token for.</param>
|
||||||
|
/// <returns>The signed token string corresponding to the requested token type.</returns>
|
||||||
string GenerateToken<T>(UserAccount user) where T : struct, Enum;
|
string GenerateToken<T>(UserAccount user) where T : struct, Enum;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates an access token.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="token">The access token string to validate.</param>
|
||||||
|
/// <returns>A <see cref="ValidatedToken"/> describing the token's claims.</returns>
|
||||||
Task<ValidatedToken> ValidateAccessTokenAsync(string token);
|
Task<ValidatedToken> ValidateAccessTokenAsync(string token);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates a refresh token.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="token">The refresh token string to validate.</param>
|
||||||
|
/// <returns>A <see cref="ValidatedToken"/> describing the token's claims.</returns>
|
||||||
Task<ValidatedToken> ValidateRefreshTokenAsync(string token);
|
Task<ValidatedToken> ValidateRefreshTokenAsync(string token);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates a confirmation token.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="token">The confirmation token string to validate.</param>
|
||||||
|
/// <returns>A <see cref="ValidatedToken"/> describing the token's claims.</returns>
|
||||||
Task<ValidatedToken> ValidateConfirmationTokenAsync(string token);
|
Task<ValidatedToken> ValidateConfirmationTokenAsync(string token);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates a refresh token and issues a new access/refresh token pair for the associated user.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="refreshTokenString">The refresh token string to validate and exchange.</param>
|
||||||
|
/// <returns>A <see cref="RefreshTokenResult"/> containing the user and newly issued tokens.</returns>
|
||||||
Task<RefreshTokenResult> RefreshTokenAsync(string refreshTokenString);
|
Task<RefreshTokenResult> RefreshTokenAsync(string refreshTokenString);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Default implementation of <see cref="ITokenService"/> that generates and validates JWTs
|
||||||
|
/// for access, refresh, and confirmation flows using secrets read from environment variables.
|
||||||
|
/// </summary>
|
||||||
public class TokenService : ITokenService
|
public class TokenService : ITokenService
|
||||||
{
|
{
|
||||||
private readonly ITokenInfrastructure _tokenInfrastructure;
|
private readonly ITokenInfrastructure _tokenInfrastructure;
|
||||||
@@ -50,6 +129,16 @@ public class TokenService : ITokenService
|
|||||||
private readonly string _refreshTokenSecret;
|
private readonly string _refreshTokenSecret;
|
||||||
private readonly string _confirmationTokenSecret;
|
private readonly string _confirmationTokenSecret;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of <see cref="TokenService"/>, loading the access, refresh, and
|
||||||
|
/// confirmation token signing secrets from environment variables.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="tokenInfrastructure">The infrastructure component used to generate and validate JWTs.</param>
|
||||||
|
/// <param name="authRepository">Repository used to look up user accounts during token refresh.</param>
|
||||||
|
/// <exception cref="InvalidOperationException">
|
||||||
|
/// Thrown when any of the <c>ACCESS_TOKEN_SECRET</c>, <c>REFRESH_TOKEN_SECRET</c>, or
|
||||||
|
/// <c>CONFIRMATION_TOKEN_SECRET</c> environment variables are not set.
|
||||||
|
/// </exception>
|
||||||
public TokenService(
|
public TokenService(
|
||||||
ITokenInfrastructure tokenInfrastructure,
|
ITokenInfrastructure tokenInfrastructure,
|
||||||
IAuthRepository authRepository
|
IAuthRepository authRepository
|
||||||
@@ -68,24 +157,53 @@ public class TokenService : ITokenService
|
|||||||
?? throw new InvalidOperationException("CONFIRMATION_TOKEN_SECRET environment variable is not set");
|
?? throw new InvalidOperationException("CONFIRMATION_TOKEN_SECRET environment variable is not set");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates an access token for the given user, signed with the access token secret and
|
||||||
|
/// expiring after <see cref="TokenServiceExpirationHours.AccessTokenHours"/> hours.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="user">The user account to generate the token for.</param>
|
||||||
|
/// <returns>The signed access token string.</returns>
|
||||||
public string GenerateAccessToken(UserAccount user)
|
public string GenerateAccessToken(UserAccount user)
|
||||||
{
|
{
|
||||||
var expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.AccessTokenHours);
|
var expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.AccessTokenHours);
|
||||||
return _tokenInfrastructure.GenerateJwt(user.UserAccountId, user.Username, expiresAt, _accessTokenSecret);
|
return _tokenInfrastructure.GenerateJwt(user.UserAccountId, user.Username, expiresAt, _accessTokenSecret);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a refresh token for the given user, signed with the refresh token secret and
|
||||||
|
/// expiring after <see cref="TokenServiceExpirationHours.RefreshTokenHours"/> hours.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="user">The user account to generate the token for.</param>
|
||||||
|
/// <returns>The signed refresh token string.</returns>
|
||||||
public string GenerateRefreshToken(UserAccount user)
|
public string GenerateRefreshToken(UserAccount user)
|
||||||
{
|
{
|
||||||
var expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.RefreshTokenHours);
|
var expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.RefreshTokenHours);
|
||||||
return _tokenInfrastructure.GenerateJwt(user.UserAccountId, user.Username, expiresAt, _refreshTokenSecret);
|
return _tokenInfrastructure.GenerateJwt(user.UserAccountId, user.Username, expiresAt, _refreshTokenSecret);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a confirmation token for the given user, signed with the confirmation token secret and
|
||||||
|
/// expiring after <see cref="TokenServiceExpirationHours.ConfirmationTokenHours"/> hours.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="user">The user account to generate the token for.</param>
|
||||||
|
/// <returns>The signed confirmation token string.</returns>
|
||||||
public string GenerateConfirmationToken(UserAccount user)
|
public string GenerateConfirmationToken(UserAccount user)
|
||||||
{
|
{
|
||||||
var expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.ConfirmationTokenHours);
|
var expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.ConfirmationTokenHours);
|
||||||
return _tokenInfrastructure.GenerateJwt(user.UserAccountId, user.Username, expiresAt, _confirmationTokenSecret);
|
return _tokenInfrastructure.GenerateJwt(user.UserAccountId, user.Username, expiresAt, _confirmationTokenSecret);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a token of the kind specified by <typeparamref name="T"/>, dispatching to
|
||||||
|
/// <see cref="GenerateAccessToken"/>, <see cref="GenerateRefreshToken"/>, or
|
||||||
|
/// <see cref="GenerateConfirmationToken"/> based on the corresponding <see cref="TokenType"/> value.
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="T">The enum type identifying which kind of token to generate. Must be <see cref="TokenType"/>.</typeparam>
|
||||||
|
/// <param name="user">The user account to generate the token for.</param>
|
||||||
|
/// <returns>The signed token string corresponding to the requested token type.</returns>
|
||||||
|
/// <exception cref="InvalidOperationException">
|
||||||
|
/// Thrown when <typeparamref name="T"/> is not <see cref="TokenType"/> or does not resolve to a known token type.
|
||||||
|
/// </exception>
|
||||||
public string GenerateToken<T>(UserAccount user) where T : struct, Enum
|
public string GenerateToken<T>(UserAccount user) where T : struct, Enum
|
||||||
{
|
{
|
||||||
if (typeof(T) != typeof(TokenType))
|
if (typeof(T) != typeof(TokenType))
|
||||||
@@ -105,15 +223,51 @@ public class TokenService : ITokenService
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates an access token against the access token secret.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="token">The access token string to validate.</param>
|
||||||
|
/// <returns>A <see cref="ValidatedToken"/> describing the token's claims.</returns>
|
||||||
|
/// <exception cref="Domain.Exceptions.UnauthorizedException">
|
||||||
|
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.
|
||||||
|
/// </exception>
|
||||||
public async Task<ValidatedToken> ValidateAccessTokenAsync(string token)
|
public async Task<ValidatedToken> ValidateAccessTokenAsync(string token)
|
||||||
=> await ValidateTokenInternalAsync(token, _accessTokenSecret, "access");
|
=> await ValidateTokenInternalAsync(token, _accessTokenSecret, "access");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates a refresh token against the refresh token secret.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="token">The refresh token string to validate.</param>
|
||||||
|
/// <returns>A <see cref="ValidatedToken"/> describing the token's claims.</returns>
|
||||||
|
/// <exception cref="Domain.Exceptions.UnauthorizedException">
|
||||||
|
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.
|
||||||
|
/// </exception>
|
||||||
public async Task<ValidatedToken> ValidateRefreshTokenAsync(string token)
|
public async Task<ValidatedToken> ValidateRefreshTokenAsync(string token)
|
||||||
=> await ValidateTokenInternalAsync(token, _refreshTokenSecret, "refresh");
|
=> await ValidateTokenInternalAsync(token, _refreshTokenSecret, "refresh");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates a confirmation token against the confirmation token secret.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="token">The confirmation token string to validate.</param>
|
||||||
|
/// <returns>A <see cref="ValidatedToken"/> describing the token's claims.</returns>
|
||||||
|
/// <exception cref="Domain.Exceptions.UnauthorizedException">
|
||||||
|
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.
|
||||||
|
/// </exception>
|
||||||
public async Task<ValidatedToken> ValidateConfirmationTokenAsync(string token)
|
public async Task<ValidatedToken> ValidateConfirmationTokenAsync(string token)
|
||||||
=> await ValidateTokenInternalAsync(token, _confirmationTokenSecret, "confirmation");
|
=> await ValidateTokenInternalAsync(token, _confirmationTokenSecret, "confirmation");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Performs the shared validation logic for access, refresh, and confirmation tokens:
|
||||||
|
/// validates the JWT signature/expiration, then extracts and parses the user ID and username claims.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="token">The token string to validate.</param>
|
||||||
|
/// <param name="secret">The secret key to validate the token's signature against.</param>
|
||||||
|
/// <param name="tokenType">A human-readable label (e.g. "access", "refresh", "confirmation") used in error messages.</param>
|
||||||
|
/// <returns>A <see cref="ValidatedToken"/> describing the token's claims.</returns>
|
||||||
|
/// <exception cref="Domain.Exceptions.UnauthorizedException">
|
||||||
|
/// Thrown when required claims are missing, the user ID claim is not a valid <see cref="Guid"/>,
|
||||||
|
/// or the underlying token validation fails for any other reason (e.g. expired or invalid signature).
|
||||||
|
/// </exception>
|
||||||
private async Task<ValidatedToken> ValidateTokenInternalAsync(string token, string secret, string tokenType)
|
private async Task<ValidatedToken> ValidateTokenInternalAsync(string token, string secret, string tokenType)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -141,6 +295,15 @@ public class TokenService : ITokenService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates the given refresh token, looks up the associated user, and issues a fresh
|
||||||
|
/// access/refresh token pair.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="refreshTokenString">The refresh token string to validate and exchange.</param>
|
||||||
|
/// <returns>A <see cref="RefreshTokenResult"/> containing the user and newly issued tokens.</returns>
|
||||||
|
/// <exception cref="Domain.Exceptions.UnauthorizedException">
|
||||||
|
/// Thrown when the refresh token is invalid, or when the user account it refers to no longer exists.
|
||||||
|
/// </exception>
|
||||||
public async Task<RefreshTokenResult> RefreshTokenAsync(string refreshTokenString)
|
public async Task<RefreshTokenResult> RefreshTokenAsync(string refreshTokenString)
|
||||||
{
|
{
|
||||||
var validated = await ValidateRefreshTokenAsync(refreshTokenString);
|
var validated = await ValidateRefreshTokenAsync(refreshTokenString);
|
||||||
|
|||||||
@@ -6,12 +6,28 @@ using Infrastructure.Repository.Auth;
|
|||||||
namespace Service.Auth;
|
namespace Service.Auth;
|
||||||
|
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles authenticating users by verifying their credentials and issuing access/refresh tokens.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="authRepo">Repository used to look up user accounts and their active credentials.</param>
|
||||||
|
/// <param name="passwordInfrastructure">Infrastructure component used to verify a plain-text password against a stored hash.</param>
|
||||||
|
/// <param name="tokenService">Service used to generate access and refresh tokens for an authenticated user.</param>
|
||||||
public class LoginService(
|
public class LoginService(
|
||||||
IAuthRepository authRepo,
|
IAuthRepository authRepo,
|
||||||
IPasswordInfrastructure passwordInfrastructure,
|
IPasswordInfrastructure passwordInfrastructure,
|
||||||
ITokenService tokenService
|
ITokenService tokenService
|
||||||
) : ILoginService
|
) : ILoginService
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Authenticates a user by username and password, issuing a new access and refresh token on success.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="username">The username of the account to authenticate.</param>
|
||||||
|
/// <param name="password">The plain-text password to verify against the stored credential.</param>
|
||||||
|
/// <returns>A <see cref="LoginServiceReturn"/> containing the authenticated user and issued tokens.</returns>
|
||||||
|
/// <exception cref="Domain.Exceptions.UnauthorizedException">
|
||||||
|
/// Thrown when the username does not match any account, the account has no active credential,
|
||||||
|
/// or the supplied password does not match the stored hash.
|
||||||
|
/// </exception>
|
||||||
public async Task<LoginServiceReturn> LoginAsync(
|
public async Task<LoginServiceReturn> LoginAsync(
|
||||||
string username,
|
string username,
|
||||||
string password
|
string password
|
||||||
|
|||||||
@@ -9,6 +9,14 @@ using Service.Emails;
|
|||||||
|
|
||||||
namespace Service.Auth;
|
namespace Service.Auth;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles registration of new user accounts, including uniqueness validation, password hashing,
|
||||||
|
/// token issuance, and sending the registration confirmation email.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="authRepo">Repository used to check for existing users and persist the new account.</param>
|
||||||
|
/// <param name="passwordInfrastructure">Infrastructure component used to hash the user's plain-text password.</param>
|
||||||
|
/// <param name="tokenService">Service used to generate access, refresh, and confirmation tokens.</param>
|
||||||
|
/// <param name="emailService">Service used to send the registration confirmation email.</param>
|
||||||
public class RegisterService(
|
public class RegisterService(
|
||||||
IAuthRepository authRepo,
|
IAuthRepository authRepo,
|
||||||
IPasswordInfrastructure passwordInfrastructure,
|
IPasswordInfrastructure passwordInfrastructure,
|
||||||
@@ -16,6 +24,13 @@ public class RegisterService(
|
|||||||
IEmailService emailService
|
IEmailService emailService
|
||||||
) : IRegisterService
|
) : IRegisterService
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that no existing user account has the same username or email as the given account.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="userAccount">The candidate user account whose username and email should be checked for uniqueness.</param>
|
||||||
|
/// <exception cref="Domain.Exceptions.ConflictException">
|
||||||
|
/// Thrown when an existing account already has the same username or email address.
|
||||||
|
/// </exception>
|
||||||
private async Task ValidateUserDoesNotExist(UserAccount userAccount)
|
private async Task ValidateUserDoesNotExist(UserAccount userAccount)
|
||||||
{
|
{
|
||||||
// Check if user already exists
|
// Check if user already exists
|
||||||
@@ -32,6 +47,21 @@ public class RegisterService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers a new user account: validates uniqueness, hashes the password, persists the account,
|
||||||
|
/// issues access/refresh/confirmation tokens, and attempts to send the registration confirmation email.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="userAccount">The user account details to register.</param>
|
||||||
|
/// <param name="password">The plain-text password to hash and store for the new account.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// A <see cref="RegisterServiceReturn"/> describing the outcome. If token generation fails, the
|
||||||
|
/// returned value has <see cref="RegisterServiceReturn.IsAuthenticated"/> set to <c>false</c> and no tokens.
|
||||||
|
/// Otherwise tokens are populated and <see cref="RegisterServiceReturn.EmailSent"/> reflects whether the
|
||||||
|
/// confirmation email was sent successfully (failures to send the email are swallowed and do not fail registration).
|
||||||
|
/// </returns>
|
||||||
|
/// <exception cref="Domain.Exceptions.ConflictException">
|
||||||
|
/// Thrown when an existing account already has the same username or email address.
|
||||||
|
/// </exception>
|
||||||
public async Task<RegisterServiceReturn> RegisterAsync(
|
public async Task<RegisterServiceReturn> RegisterAsync(
|
||||||
UserAccount userAccount,
|
UserAccount userAccount,
|
||||||
string password
|
string password
|
||||||
|
|||||||
@@ -3,14 +3,34 @@ using Infrastructure.Repository.Breweries;
|
|||||||
|
|
||||||
namespace Service.Breweries;
|
namespace Service.Breweries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles retrieval, creation, update, and deletion of brewery posts.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="repository">Repository used to persist and query brewery post data.</param>
|
||||||
public class BreweryService(IBreweryRepository repository) : IBreweryService
|
public class BreweryService(IBreweryRepository repository) : IBreweryService
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves a brewery post by its unique identifier.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">The unique identifier of the brewery post.</param>
|
||||||
|
/// <returns>The matching <see cref="BreweryPost"/>, or <c>null</c> if no such post exists.</returns>
|
||||||
public Task<BreweryPost?> GetByIdAsync(Guid id) =>
|
public Task<BreweryPost?> GetByIdAsync(Guid id) =>
|
||||||
repository.GetByIdAsync(id);
|
repository.GetByIdAsync(id);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves all brewery posts, optionally paginated.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="limit">The maximum number of results to return, or <c>null</c> for no limit.</param>
|
||||||
|
/// <param name="offset">The number of results to skip, or <c>null</c> to start from the beginning.</param>
|
||||||
|
/// <returns>A collection of <see cref="BreweryPost"/> entities.</returns>
|
||||||
public Task<IEnumerable<BreweryPost>> GetAllAsync(int? limit = null, int? offset = null) =>
|
public Task<IEnumerable<BreweryPost>> GetAllAsync(int? limit = null, int? offset = null) =>
|
||||||
repository.GetAllAsync(limit, offset);
|
repository.GetAllAsync(limit, offset);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new brewery post, generating new identifiers for the post and its location.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The details of the brewery post to create.</param>
|
||||||
|
/// <returns>A successful <see cref="BreweryServiceReturn"/> wrapping the newly created brewery post.</returns>
|
||||||
public async Task<BreweryServiceReturn> CreateAsync(BreweryCreateRequest request)
|
public async Task<BreweryServiceReturn> CreateAsync(BreweryCreateRequest request)
|
||||||
{
|
{
|
||||||
var entity = new BreweryPost
|
var entity = new BreweryPost
|
||||||
@@ -35,6 +55,12 @@ public class BreweryService(IBreweryRepository repository) : IBreweryService
|
|||||||
return new BreweryServiceReturn(entity);
|
return new BreweryServiceReturn(entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates an existing brewery post. If <paramref name="request"/> has no <c>Location</c>,
|
||||||
|
/// the brewery's location is cleared.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The updated details of the brewery post.</param>
|
||||||
|
/// <returns>A successful <see cref="BreweryServiceReturn"/> wrapping the updated brewery post.</returns>
|
||||||
public async Task<BreweryServiceReturn> UpdateAsync(BreweryUpdateRequest request)
|
public async Task<BreweryServiceReturn> UpdateAsync(BreweryUpdateRequest request)
|
||||||
{
|
{
|
||||||
var entity = new BreweryPost
|
var entity = new BreweryPost
|
||||||
@@ -60,6 +86,11 @@ public class BreweryService(IBreweryRepository repository) : IBreweryService
|
|||||||
return new BreweryServiceReturn(entity);
|
return new BreweryServiceReturn(entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes a brewery post by its unique identifier.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">The unique identifier of the brewery post to delete.</param>
|
||||||
|
/// <returns>A task that completes once the deletion has finished.</returns>
|
||||||
public Task DeleteAsync(Guid id) =>
|
public Task DeleteAsync(Guid id) =>
|
||||||
repository.DeleteAsync(id);
|
repository.DeleteAsync(id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,13 @@ using Domain.Entities;
|
|||||||
|
|
||||||
namespace Service.Breweries;
|
namespace Service.Breweries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the data required to create a new brewery post.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="PostedById">The unique identifier of the user creating the post.</param>
|
||||||
|
/// <param name="BreweryName">The name of the brewery.</param>
|
||||||
|
/// <param name="Description">A description of the brewery.</param>
|
||||||
|
/// <param name="Location">The location details for the new brewery post.</param>
|
||||||
public record BreweryCreateRequest(
|
public record BreweryCreateRequest(
|
||||||
Guid PostedById,
|
Guid PostedById,
|
||||||
string BreweryName,
|
string BreweryName,
|
||||||
@@ -9,6 +16,14 @@ public record BreweryCreateRequest(
|
|||||||
BreweryLocationCreateRequest Location
|
BreweryLocationCreateRequest Location
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the location data required to create a new brewery post.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="CityId">The unique identifier of the city the brewery is located in.</param>
|
||||||
|
/// <param name="AddressLine1">The primary address line.</param>
|
||||||
|
/// <param name="AddressLine2">An optional secondary address line.</param>
|
||||||
|
/// <param name="PostalCode">The postal code of the brewery's address.</param>
|
||||||
|
/// <param name="Coordinates">Optional geographic coordinates for the brewery's location.</param>
|
||||||
public record BreweryLocationCreateRequest(
|
public record BreweryLocationCreateRequest(
|
||||||
Guid CityId,
|
Guid CityId,
|
||||||
string AddressLine1,
|
string AddressLine1,
|
||||||
@@ -17,6 +32,14 @@ public record BreweryLocationCreateRequest(
|
|||||||
byte[]? Coordinates
|
byte[]? Coordinates
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the data required to update an existing brewery post.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="BreweryPostId">The unique identifier of the brewery post to update.</param>
|
||||||
|
/// <param name="PostedById">The unique identifier of the user who owns/posted the brewery.</param>
|
||||||
|
/// <param name="BreweryName">The updated name of the brewery.</param>
|
||||||
|
/// <param name="Description">The updated description of the brewery.</param>
|
||||||
|
/// <param name="Location">The updated location details, or <c>null</c> to clear the location.</param>
|
||||||
public record BreweryUpdateRequest(
|
public record BreweryUpdateRequest(
|
||||||
Guid BreweryPostId,
|
Guid BreweryPostId,
|
||||||
Guid PostedById,
|
Guid PostedById,
|
||||||
@@ -25,6 +48,15 @@ public record BreweryUpdateRequest(
|
|||||||
BreweryLocationUpdateRequest? Location
|
BreweryLocationUpdateRequest? Location
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the location data required to update an existing brewery post.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="BreweryPostLocationId">The unique identifier of the existing brewery location record.</param>
|
||||||
|
/// <param name="CityId">The unique identifier of the city the brewery is located in.</param>
|
||||||
|
/// <param name="AddressLine1">The primary address line.</param>
|
||||||
|
/// <param name="AddressLine2">An optional secondary address line.</param>
|
||||||
|
/// <param name="PostalCode">The postal code of the brewery's address.</param>
|
||||||
|
/// <param name="Coordinates">Optional geographic coordinates for the brewery's location.</param>
|
||||||
public record BreweryLocationUpdateRequest(
|
public record BreweryLocationUpdateRequest(
|
||||||
Guid BreweryPostLocationId,
|
Guid BreweryPostLocationId,
|
||||||
Guid CityId,
|
Guid CityId,
|
||||||
@@ -34,18 +66,40 @@ public record BreweryLocationUpdateRequest(
|
|||||||
byte[]? Coordinates
|
byte[]? Coordinates
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the result of a create or update operation against a brewery post.
|
||||||
|
/// </summary>
|
||||||
public record BreweryServiceReturn
|
public record BreweryServiceReturn
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a value indicating whether the operation succeeded.
|
||||||
|
/// </summary>
|
||||||
public bool Success { get; init; }
|
public bool Success { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the resulting brewery post when the operation succeeded; otherwise a default/unset value.
|
||||||
|
/// </summary>
|
||||||
public BreweryPost Brewery { get; init; }
|
public BreweryPost Brewery { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a message describing the failure reason, empty when <see cref="Success"/> is <c>true</c>.
|
||||||
|
/// </summary>
|
||||||
public string Message { get; init; } = string.Empty;
|
public string Message { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new successful result wrapping the given brewery post.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="brewery">The brewery post resulting from the operation.</param>
|
||||||
public BreweryServiceReturn(BreweryPost brewery)
|
public BreweryServiceReturn(BreweryPost brewery)
|
||||||
{
|
{
|
||||||
Success = true;
|
Success = true;
|
||||||
Brewery = brewery;
|
Brewery = brewery;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new failed result with the given error message.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="message">A message describing why the operation failed.</param>
|
||||||
public BreweryServiceReturn(string message)
|
public BreweryServiceReturn(string message)
|
||||||
{
|
{
|
||||||
Success = false;
|
Success = false;
|
||||||
@@ -54,11 +108,44 @@ public record BreweryServiceReturn
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defines operations for retrieving, creating, updating, and deleting brewery posts.
|
||||||
|
/// </summary>
|
||||||
public interface IBreweryService
|
public interface IBreweryService
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves a brewery post by its unique identifier.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">The unique identifier of the brewery post.</param>
|
||||||
|
/// <returns>The matching <see cref="BreweryPost"/>, or <c>null</c> if no such post exists.</returns>
|
||||||
Task<BreweryPost?> GetByIdAsync(Guid id);
|
Task<BreweryPost?> GetByIdAsync(Guid id);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves all brewery posts, optionally paginated.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="limit">The maximum number of results to return, or <c>null</c> for no limit.</param>
|
||||||
|
/// <param name="offset">The number of results to skip, or <c>null</c> to start from the beginning.</param>
|
||||||
|
/// <returns>A collection of <see cref="BreweryPost"/> entities.</returns>
|
||||||
Task<IEnumerable<BreweryPost>> GetAllAsync(int? limit = null, int? offset = null);
|
Task<IEnumerable<BreweryPost>> GetAllAsync(int? limit = null, int? offset = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new brewery post.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The details of the brewery post to create.</param>
|
||||||
|
/// <returns>A <see cref="BreweryServiceReturn"/> describing the outcome of the creation.</returns>
|
||||||
Task<BreweryServiceReturn> CreateAsync(BreweryCreateRequest request);
|
Task<BreweryServiceReturn> CreateAsync(BreweryCreateRequest request);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates an existing brewery post.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The updated details of the brewery post.</param>
|
||||||
|
/// <returns>A <see cref="BreweryServiceReturn"/> describing the outcome of the update.</returns>
|
||||||
Task<BreweryServiceReturn> UpdateAsync(BreweryUpdateRequest request);
|
Task<BreweryServiceReturn> UpdateAsync(BreweryUpdateRequest request);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes a brewery post by its unique identifier.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">The unique identifier of the brewery post to delete.</param>
|
||||||
|
/// <returns>A task that completes once the deletion has finished.</returns>
|
||||||
Task DeleteAsync(Guid id);
|
Task DeleteAsync(Guid id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,28 +4,63 @@ using Infrastructure.Email.Templates.Rendering;
|
|||||||
|
|
||||||
namespace Service.Emails;
|
namespace Service.Emails;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defines operations for sending account-related emails, such as registration and confirmation resend emails.
|
||||||
|
/// </summary>
|
||||||
public interface IEmailService
|
public interface IEmailService
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Sends a welcome email containing an account confirmation link to a newly registered user.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="createdUser">The newly created user account to email.</param>
|
||||||
|
/// <param name="confirmationToken">The confirmation token to embed in the confirmation link.</param>
|
||||||
|
/// <returns>A task that completes once the email has been sent.</returns>
|
||||||
public Task SendRegistrationEmailAsync(
|
public Task SendRegistrationEmailAsync(
|
||||||
UserAccount createdUser,
|
UserAccount createdUser,
|
||||||
string confirmationToken
|
string confirmationToken
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sends an email containing a fresh account confirmation link to a user who requested a resend.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="user">The user account to email.</param>
|
||||||
|
/// <param name="confirmationToken">The confirmation token to embed in the confirmation link.</param>
|
||||||
|
/// <returns>A task that completes once the email has been sent.</returns>
|
||||||
public Task SendResendConfirmationEmailAsync(
|
public Task SendResendConfirmationEmailAsync(
|
||||||
UserAccount user,
|
UserAccount user,
|
||||||
string confirmationToken
|
string confirmationToken
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Default implementation of <see cref="IEmailService"/> that renders email templates and dispatches
|
||||||
|
/// them via an <see cref="IEmailProvider"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="emailProvider">Provider used to deliver the rendered emails.</param>
|
||||||
|
/// <param name="emailTemplateProvider">Provider used to render HTML email bodies from templates.</param>
|
||||||
public class EmailService(
|
public class EmailService(
|
||||||
IEmailProvider emailProvider,
|
IEmailProvider emailProvider,
|
||||||
IEmailTemplateProvider emailTemplateProvider
|
IEmailTemplateProvider emailTemplateProvider
|
||||||
) : IEmailService
|
) : IEmailService
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The base URL of the website, used to build confirmation links. Read from the
|
||||||
|
/// <c>WEBSITE_BASE_URL</c> environment variable.
|
||||||
|
/// </summary>
|
||||||
|
/// <exception cref="InvalidOperationException">
|
||||||
|
/// Thrown at type initialization time when the <c>WEBSITE_BASE_URL</c> environment variable is not set.
|
||||||
|
/// </exception>
|
||||||
private static readonly string WebsiteBaseUrl =
|
private static readonly string WebsiteBaseUrl =
|
||||||
Environment.GetEnvironmentVariable("WEBSITE_BASE_URL")
|
Environment.GetEnvironmentVariable("WEBSITE_BASE_URL")
|
||||||
?? throw new InvalidOperationException("WEBSITE_BASE_URL environment variable is not set");
|
?? throw new InvalidOperationException("WEBSITE_BASE_URL environment variable is not set");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a confirmation link from the given token, renders the registration welcome email template,
|
||||||
|
/// and sends it to the newly created user.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="createdUser">The newly created user account to email.</param>
|
||||||
|
/// <param name="confirmationToken">The confirmation token to embed in the confirmation link.</param>
|
||||||
|
/// <returns>A task that completes once the email has been sent.</returns>
|
||||||
public async Task SendRegistrationEmailAsync(
|
public async Task SendRegistrationEmailAsync(
|
||||||
UserAccount createdUser,
|
UserAccount createdUser,
|
||||||
string confirmationToken
|
string confirmationToken
|
||||||
@@ -48,6 +83,13 @@ public class EmailService(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a confirmation link from the given token, renders the resend-confirmation email template,
|
||||||
|
/// and sends it to the user.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="user">The user account to email.</param>
|
||||||
|
/// <param name="confirmationToken">The confirmation token to embed in the confirmation link.</param>
|
||||||
|
/// <returns>A task that completes once the email has been sent.</returns>
|
||||||
public async Task SendResendConfirmationEmailAsync(
|
public async Task SendResendConfirmationEmailAsync(
|
||||||
UserAccount user,
|
UserAccount user,
|
||||||
string confirmationToken
|
string confirmationToken
|
||||||
|
|||||||
@@ -2,13 +2,33 @@ using Domain.Entities;
|
|||||||
|
|
||||||
namespace Service.UserManagement.User;
|
namespace Service.UserManagement.User;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defines operations for retrieving and updating user accounts.
|
||||||
|
/// </summary>
|
||||||
public interface IUserService
|
public interface IUserService
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves all user accounts, optionally paginated.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="limit">The maximum number of results to return, or <c>null</c> for no limit.</param>
|
||||||
|
/// <param name="offset">The number of results to skip, or <c>null</c> to start from the beginning.</param>
|
||||||
|
/// <returns>A collection of <see cref="UserAccount"/> entities.</returns>
|
||||||
Task<IEnumerable<UserAccount>> GetAllAsync(
|
Task<IEnumerable<UserAccount>> GetAllAsync(
|
||||||
int? limit = null,
|
int? limit = null,
|
||||||
int? offset = null
|
int? offset = null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves a user account by its unique identifier.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">The unique identifier of the user account.</param>
|
||||||
|
/// <returns>The matching <see cref="UserAccount"/>.</returns>
|
||||||
Task<UserAccount> GetByIdAsync(Guid id);
|
Task<UserAccount> GetByIdAsync(Guid id);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates an existing user account.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="userAccount">The user account containing the updated data.</param>
|
||||||
|
/// <returns>A task that completes once the update has finished.</returns>
|
||||||
Task UpdateAsync(UserAccount userAccount);
|
Task UpdateAsync(UserAccount userAccount);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,18 @@ using Infrastructure.Repository.UserAccount;
|
|||||||
|
|
||||||
namespace Service.UserManagement.User;
|
namespace Service.UserManagement.User;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles retrieval and update of user accounts.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="repository">Repository used to persist and query user account data.</param>
|
||||||
public class UserService(IUserAccountRepository repository) : IUserService
|
public class UserService(IUserAccountRepository repository) : IUserService
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves all user accounts, optionally paginated.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="limit">The maximum number of results to return, or <c>null</c> for no limit.</param>
|
||||||
|
/// <param name="offset">The number of results to skip, or <c>null</c> to start from the beginning.</param>
|
||||||
|
/// <returns>A collection of <see cref="UserAccount"/> entities.</returns>
|
||||||
public async Task<IEnumerable<UserAccount>> GetAllAsync(
|
public async Task<IEnumerable<UserAccount>> GetAllAsync(
|
||||||
int? limit = null,
|
int? limit = null,
|
||||||
int? offset = null
|
int? offset = null
|
||||||
@@ -14,6 +24,12 @@ public class UserService(IUserAccountRepository repository) : IUserService
|
|||||||
return await repository.GetAllAsync(limit, offset);
|
return await repository.GetAllAsync(limit, offset);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves a user account by its unique identifier.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">The unique identifier of the user account.</param>
|
||||||
|
/// <returns>The matching <see cref="UserAccount"/>.</returns>
|
||||||
|
/// <exception cref="NotFoundException">Thrown when no user account exists with the given <paramref name="id"/>.</exception>
|
||||||
public async Task<UserAccount> GetByIdAsync(Guid id)
|
public async Task<UserAccount> GetByIdAsync(Guid id)
|
||||||
{
|
{
|
||||||
var user = await repository.GetByIdAsync(id);
|
var user = await repository.GetByIdAsync(id);
|
||||||
@@ -22,6 +38,11 @@ public class UserService(IUserAccountRepository repository) : IUserService
|
|||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates an existing user account.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="userAccount">The user account containing the updated data.</param>
|
||||||
|
/// <returns>A task that completes once the update has finished.</returns>
|
||||||
public async Task UpdateAsync(UserAccount userAccount)
|
public async Task UpdateAsync(UserAccount userAccount)
|
||||||
{
|
{
|
||||||
await repository.UpdateAsync(userAccount);
|
await repository.UpdateAsync(userAccount);
|
||||||
|
|||||||
Reference in New Issue
Block a user