Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
ad189a3
feat(users): fill the Admin activation time on its first access
MendesMat Sep 30, 2026
f51205a
feat(auth): sign in with login and password
MendesMat Sep 30, 2026
2a95177
feat(auth): lock out, deactivated accounts and the mandatory password…
MendesMat Sep 30, 2026
65cd18d
feat(auth): refresh the session and rotate its token
MendesMat Sep 30, 2026
b3b0775
feat(auth): validate the new password and sign out
MendesMat Sep 30, 2026
7dde3b1
feat(auth): change the initial password
MendesMat Sep 30, 2026
0c7f739
feat(auth): return the signed-in person and their level on every screen
MendesMat Sep 30, 2026
b3d9d7e
feat(auth): store credentials with Identity and seed the Admin creden…
MendesMat Sep 30, 2026
daec4de
feat(auth): store sessions as hashed refresh tokens
MendesMat Sep 30, 2026
693f81b
feat(auth): lock the login after five consecutive failures
MendesMat Sep 30, 2026
b42c841
feat(auth): sign in over HTTP
MendesMat Sep 30, 2026
20d35af
feat(auth): return the signed-in person from /me and validate the bea…
MendesMat Sep 30, 2026
bb4e760
feat(auth): refresh and sign out
MendesMat Sep 30, 2026
fad469b
feat(auth): require the initial password change and change it
MendesMat Sep 30, 2026
b002da6
feat(auth): rate limit sign-in and refresh, and keep secrets out of t…
MendesMat Sep 30, 2026
16ee1c3
fix(auth): treat null request fields as empty
MendesMat Sep 30, 2026
b571679
docs(auth): document sign-in, sessions and the current user
MendesMat Sep 30, 2026
ae442d2
docs(agents): warn that PowerShell 5.1 drops the refresh cookie
MendesMat Sep 30, 2026
a9fb94c
test(auth): give the Admin back as seeded after each test that change…
MendesMat Sep 30, 2026
5cf13a2
fix(auth): name the secret when it is missing, and accept long signin…
MendesMat Sep 30, 2026
9133963
test(auth): capture the logs really at Trace in the secrets test
MendesMat Sep 30, 2026
4b1a507
test(auth): never repeat a refresh token in the in-memory session store
MendesMat Sep 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,8 @@ The current slice is tracked in the milestone [M1: Sign-in, users and permission
- [x] Back-end skeleton, local environment with Aspire, CI
- [x] Domain: value objects, users, permission profiles, effective access
- [x] Persistence: EF Core, audit fields, concurrency, seeded system records
- [ ] Authentication and per-screen authorization
- [x] Sign-in, sessions and the current user (`/me`)
- [ ] Per-screen authorization
- [ ] Permission profiles and users endpoints, activation and password reset links
- [ ] Connect the front-end to the real API
- [ ] Remaining screens: catalog, commercial, finance and reports
Expand Down
14 changes: 14 additions & 0 deletions backend/ControlService/src/ControlService.API/Auth/AuthClaims.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using System.Security.Claims;

namespace ControlService.API.Auth;

/// <summary>Reads the claims the access token carries (T5).</summary>
internal static class AuthClaims
{
public static Guid? UserId(this ClaimsPrincipal principal) => GuidClaim(principal, "sub");

public static Guid? SessionId(this ClaimsPrincipal principal) => GuidClaim(principal, JwtAccessTokenIssuer.SessionIdClaim);

private static Guid? GuidClaim(ClaimsPrincipal principal, string type) =>
Guid.TryParse(principal.FindFirstValue(type), out var value) ? value : null;
}
157 changes: 157 additions & 0 deletions backend/ControlService/src/ControlService.API/Auth/AuthEndpoints.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
using System.Globalization;
using System.Security.Claims;
using ControlService.API.Common;
using ControlService.Application.Auth;
using ControlService.Application.Auth.ChangePassword;
using ControlService.Application.Auth.GetMe;
using ControlService.Application.Auth.RefreshSession;
using ControlService.Application.Auth.SignIn;
using ControlService.Application.Auth.SignOut;
using ControlService.Application.Common;
using ControlService.Domain.Common;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.RateLimiting;

namespace ControlService.API.Auth;

public static class AuthEndpoints
{
public static RouteGroupBuilder MapAuthEndpoints(this RouteGroupBuilder api)
{
api.MapPost("/auth/sign-in", SignIn)
.AllowAnonymous()
.RequireRateLimiting(AuthRateLimiting.SignInPolicy)
.WithSummary("Signs in with login and password; sets the refresh cookie.");

api.MapPost("/auth/refresh", Refresh)
.AllowAnonymous()
.RequireRateLimiting(AuthRateLimiting.RefreshPolicy)
.WithSummary("Renews the session from the refresh cookie and rotates it.");

api.MapPost("/auth/sign-out", SignOut)
.AllowPendingPasswordChange()
.WithSummary("Ends the session named by the access token and expires the refresh cookie.");

api.MapPost("/auth/change-password", ChangePassword)
.AllowPendingPasswordChange()
.WithSummary("Replaces the initial password; ends every session and starts a new one.");

api.MapGet("/me", Me)
.AllowPendingPasswordChange()
.WithSummary("The signed-in person and their effective level on every screen.");

return api;
}

private static async Task<Results<Ok<SessionResponse>, ProblemHttpResult>> Refresh(
ICommandHandler<RefreshSessionCommand, SessionGrant> handler,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var refreshToken = httpContext.Request.Cookies[RefreshCookie.Name] ?? string.Empty;
var result = await handler.Handle(new RefreshSessionCommand(refreshToken), cancellationToken);
if (result.IsFailure)
{
return result.Error.ToProblem();
}

RefreshCookie.Append(httpContext.Response, result.Value.RefreshToken, result.Value.RefreshTokenExpiresAt);
return TypedResults.Ok(SessionResponse.From(result.Value));
}

private static async Task<Results<Ok<SessionResponse>, ProblemHttpResult>> ChangePassword(
ChangePasswordRequest request,
ClaimsPrincipal principal,
ICommandHandler<ChangePasswordCommand, SessionGrant> handler,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var userId = principal.UserId();
if (userId is null)
{
return AuthErrors.SessionExpired.ToProblem();
}

var result = await handler.Handle(
new ChangePasswordCommand(
userId.Value, request.Password ?? string.Empty, request.PasswordConfirmation ?? string.Empty), cancellationToken);
if (result.IsFailure)
{
return result.Error.ToProblem();
}

RefreshCookie.Append(httpContext.Response, result.Value.RefreshToken, result.Value.RefreshTokenExpiresAt);
return TypedResults.Ok(SessionResponse.From(result.Value));
}

// The refresh cookie is scoped to the refresh route, so sign-out never receives it: the session comes
// from the access token's `sid` claim instead (D3).
private static async Task<Results<NoContent, ProblemHttpResult>> SignOut(
ClaimsPrincipal principal,
ICommandHandler<SignOutCommand, Unit> handler,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var sessionId = principal.SessionId();
if (sessionId is null)
{
return AuthErrors.SessionExpired.ToProblem();
}

await handler.Handle(new SignOutCommand(sessionId.Value), cancellationToken);
RefreshCookie.Expire(httpContext.Response);
return TypedResults.NoContent();
}

private static async Task<Results<Ok<MeResponse>, ProblemHttpResult>> Me(
ClaimsPrincipal principal,
IQueryHandler<GetMeQuery, MeResponse> handler,
CancellationToken cancellationToken)
{
var userId = principal.UserId();
if (userId is null)
{
return AuthErrors.SessionExpired.ToProblem();
}

var result = await handler.Handle(new GetMeQuery(userId.Value), cancellationToken);
return result.IsFailure ? result.Error.ToProblem() : TypedResults.Ok(result.Value);
}

private static async Task<Results<Ok<SessionResponse>, ProblemHttpResult>> SignIn(
SignInRequest request,
ICommandHandler<SignInCommand, SessionGrant> handler,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var result = await handler.Handle(new SignInCommand(request.Login ?? string.Empty, request.Password ?? string.Empty), cancellationToken);
if (result.IsFailure)
{
return SignInProblem(result.Error, httpContext);
}

RefreshCookie.Append(httpContext.Response, result.Value.RefreshToken, result.Value.RefreshTokenExpiresAt);
return TypedResults.Ok(SessionResponse.From(result.Value));
}

// The 403 of account_inactive is sign-in only, and the lockout tells the browser how long to wait.
private static ProblemHttpResult SignInProblem(Error error, HttpContext httpContext)
{
if (error.Code == "account_inactive")
{
return error.ToProblem(StatusCodes.Status403Forbidden);
}

if (error.Details is not null && error.Details.TryGetValue("retryAfterSeconds", out var seconds))
{
httpContext.Response.Headers.RetryAfter = Convert.ToString(seconds, CultureInfo.InvariantCulture);
}

return error.ToProblem();
}

// A missing or null field is treated as empty, so it fails validation instead of crashing (API-12).
private sealed record ChangePasswordRequest(string? Password = null, string? PasswordConfirmation = null);

private sealed record SignInRequest(string? Login = null, string? Password = null);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
using System.Globalization;
using System.Threading.RateLimiting;
using ControlService.API.Common;
using ControlService.Application.Auth;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Options;

namespace ControlService.API.Auth;

/// <summary>Rate limits of the authentication routes (ADR-0023, D14, T9): a sliding window of 60 seconds
/// in 6 segments per client address, with no queue. A refused request answers like a lockout does:
/// 429 `locked_out` with `Retry-After` (AUTH-26).</summary>
internal static class AuthRateLimiting
{
public const string SignInPolicy = "sign-in";
public const string RefreshPolicy = "refresh";

private static readonly TimeSpan Window = TimeSpan.FromSeconds(60);

public static IServiceCollection AddAuthRateLimiting(this IServiceCollection services, IConfiguration configuration)
{
services.AddOptions<RateLimitingOptions>()
.Bind(configuration.GetSection(RateLimitingOptions.SectionName))
.Validate(
options => options.SignIn.PermitLimit > 0 && options.Refresh.PermitLimit > 0,
"RateLimiting:SignIn:PermitLimit and RateLimiting:Refresh:PermitLimit must be greater than zero.")
.ValidateOnStart();

services.AddRateLimiter();
services.AddOptions<RateLimiterOptions>().Configure<IOptions<RateLimitingOptions>>((limiter, limits) =>
{
limiter.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
limiter.AddPolicy(SignInPolicy, context => PartitionByAddress(context, limits.Value.SignIn.PermitLimit));
limiter.AddPolicy(RefreshPolicy, context => PartitionByAddress(context, limits.Value.Refresh.PermitLimit));
limiter.OnRejected = RejectAsync;
});

return services;
}

// `unknown` covers a request with no remote address, as in the test server.
private static RateLimitPartition<string> PartitionByAddress(HttpContext context, int permitLimit) =>
RateLimitPartition.GetSlidingWindowLimiter(
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new SlidingWindowRateLimiterOptions
{
PermitLimit = permitLimit,
Window = Window,
SegmentsPerWindow = 6,
QueueLimit = 0,
});

private static async ValueTask RejectAsync(OnRejectedContext context, CancellationToken cancellationToken)
{
// The limiter reports when the next segment frees up; the whole window is the safe fallback.
var retryAfter = context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var wait) ? wait : Window;
var seconds = (int)Math.Ceiling(retryAfter.TotalSeconds);

context.HttpContext.Response.Headers.RetryAfter = seconds.ToString(CultureInfo.InvariantCulture);
await AuthErrors.TooManyRequests(seconds).ToProblem().ExecuteAsync(context.HttpContext);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
using ControlService.Application.Auth;
using ControlService.Application.Auth.ChangePassword;
using ControlService.Application.Auth.GetMe;
using ControlService.Application.Auth.RefreshSession;
using ControlService.Application.Auth.SignIn;
using ControlService.Application.Auth.SignOut;
using ControlService.Application.Common;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Options;

namespace ControlService.API.Auth;

public static class AuthServiceCollectionExtensions
{
public static IServiceCollection AddAuthFeature(this IServiceCollection services, IConfiguration configuration)
{
services.AddOptions<JwtOptions>()
.Bind(configuration.GetSection(JwtOptions.SectionName))
.ValidateDataAnnotations()
.Validate(IsValidSigningKey, "Auth:SigningKey must be the base64 of at least 32 bytes.")
.ValidateOnStart();

services.AddSingleton<IAccessTokenIssuer, JwtAccessTokenIssuer>();

services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer();
services.AddSingleton<IConfigureOptions<JwtBearerOptions>, ConfigureJwtBearer>();
services.AddAuthorization();
services.AddAuthRateLimiting(configuration);

// Handlers are registered by hand (ADR-0008); the ones with input to validate are wrapped in the decorator.
services.AddScoped<ICommandHandler<SignInCommand, SessionGrant>, SignInHandler>();
services.AddScoped<ICommandHandler<ChangePasswordCommand, SessionGrant>>(serviceProvider =>
new ValidatingCommandHandler<ChangePasswordCommand, SessionGrant>(
ActivatorUtilities.CreateInstance<ChangePasswordHandler>(serviceProvider),
new ChangePasswordValidator(serviceProvider.GetRequiredService<AuthSettings>())));
services.AddScoped<ICommandHandler<RefreshSessionCommand, SessionGrant>, RefreshSessionHandler>();
services.AddScoped<ICommandHandler<SignOutCommand, Unit>, SignOutHandler>();
services.AddScoped<IQueryHandler<GetMeQuery, MeResponse>, GetMeHandler>();

return services;
}

// Never echoes the key: the message names it, and that is all. Binding leaves it null when the user
// secret was never set.
private static bool IsValidSigningKey(JwtOptions options)
{
if (options.SigningKey is null)
{
return false;
}

// Sized to what the text can decode to, so a key longer than the minimum is never refused.
Span<byte> buffer = new byte[options.SigningKey.Length * 3 / 4];
return Convert.TryFromBase64String(options.SigningKey, buffer, out var written) && written >= 32;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
using ControlService.API.Common;
using ControlService.Application.Auth;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;

namespace ControlService.API.Auth;

/// <summary>How access tokens are checked (T5, T7). Claim names stay as issued (`sub`, `sid`), because
/// ICurrentUser reads `sub`; the lifetime is judged by the application's clock, with no skew, so 15
/// minutes means 15 minutes; and every failed authentication looks like any other error (API-12).</summary>
public sealed class ConfigureJwtBearer(IOptions<JwtOptions> jwtOptions, TimeProvider timeProvider)
: IConfigureNamedOptions<JwtBearerOptions>
{
public void Configure(JwtBearerOptions options) => Configure(Options.DefaultName, options);

public void Configure(string? name, JwtBearerOptions options)
{
var jwt = jwtOptions.Value;

options.MapInboundClaims = false;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = jwt.Issuer,
ValidateAudience = true,
ValidAudience = jwt.Audience,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(jwt.SigningKeyBytes()),
ValidAlgorithms = [SecurityAlgorithms.HmacSha256],
ClockSkew = TimeSpan.Zero,

// IdentityModel has no clock seam, so the lifetime check is ours: it reads TimeProvider (which
// tests control), and `exp` is exclusive, as RFC 7519 says.
LifetimeValidator = (notBefore, expires, _, _) =>
{
var now = timeProvider.GetUtcNow().UtcDateTime;
return expires is not null && now < expires && (notBefore is null || notBefore <= now);
},
};

options.Events = new JwtBearerEvents
{
OnChallenge = async context =>
{
context.HandleResponse();
await AuthErrors.SessionExpired.ToProblem().ExecuteAsync(context.HttpContext);
},
};
}
}
Loading
Loading