diff --git a/README.md b/README.md index 7be9c31..9e8456d 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/backend/ControlService/src/ControlService.API/Auth/AuthClaims.cs b/backend/ControlService/src/ControlService.API/Auth/AuthClaims.cs new file mode 100644 index 0000000..e84140d --- /dev/null +++ b/backend/ControlService/src/ControlService.API/Auth/AuthClaims.cs @@ -0,0 +1,14 @@ +using System.Security.Claims; + +namespace ControlService.API.Auth; + +/// Reads the claims the access token carries (T5). +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; +} diff --git a/backend/ControlService/src/ControlService.API/Auth/AuthEndpoints.cs b/backend/ControlService/src/ControlService.API/Auth/AuthEndpoints.cs new file mode 100644 index 0000000..aa08848 --- /dev/null +++ b/backend/ControlService/src/ControlService.API/Auth/AuthEndpoints.cs @@ -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("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/auth/sign-in", SignIn) + .AllowAnonymous() + .RequireRateLimiting(AuthRateLimiting.SignInPolicy) + .WithSummary("Signs in with login and password; sets the refresh cookie."); + + api.MapPost("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/auth/refresh", Refresh) + .AllowAnonymous() + .RequireRateLimiting(AuthRateLimiting.RefreshPolicy) + .WithSummary("Renews the session from the refresh cookie and rotates it."); + + api.MapPost("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/auth/sign-out", SignOut) + .AllowPendingPasswordChange() + .WithSummary("Ends the session named by the access token and expires the refresh cookie."); + + api.MapPost("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/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, ProblemHttpResult>> Refresh( + ICommandHandler 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, ProblemHttpResult>> ChangePassword( + ChangePasswordRequest request, + ClaimsPrincipal principal, + ICommandHandler 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> SignOut( + ClaimsPrincipal principal, + ICommandHandler 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, ProblemHttpResult>> Me( + ClaimsPrincipal principal, + IQueryHandler 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, ProblemHttpResult>> SignIn( + SignInRequest request, + ICommandHandler 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); +} diff --git a/backend/ControlService/src/ControlService.API/Auth/AuthRateLimiting.cs b/backend/ControlService/src/ControlService.API/Auth/AuthRateLimiting.cs new file mode 100644 index 0000000..4929ae4 --- /dev/null +++ b/backend/ControlService/src/ControlService.API/Auth/AuthRateLimiting.cs @@ -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; + +/// 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). +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() + .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().Configure>((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 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); + } +} diff --git a/backend/ControlService/src/ControlService.API/Auth/AuthServiceCollectionExtensions.cs b/backend/ControlService/src/ControlService.API/Auth/AuthServiceCollectionExtensions.cs new file mode 100644 index 0000000..27df179 --- /dev/null +++ b/backend/ControlService/src/ControlService.API/Auth/AuthServiceCollectionExtensions.cs @@ -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() + .Bind(configuration.GetSection(JwtOptions.SectionName)) + .ValidateDataAnnotations() + .Validate(IsValidSigningKey, "Auth:SigningKey must be the base64 of at least 32 bytes.") + .ValidateOnStart(); + + services.AddSingleton(); + + services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(); + services.AddSingleton, 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, SignInHandler>(); + services.AddScoped>(serviceProvider => + new ValidatingCommandHandler( + ActivatorUtilities.CreateInstance(serviceProvider), + new ChangePasswordValidator(serviceProvider.GetRequiredService()))); + services.AddScoped, RefreshSessionHandler>(); + services.AddScoped, SignOutHandler>(); + services.AddScoped, 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 buffer = new byte[options.SigningKey.Length * 3 / 4]; + return Convert.TryFromBase64String(options.SigningKey, buffer, out var written) && written >= 32; + } +} diff --git a/backend/ControlService/src/ControlService.API/Auth/ConfigureJwtBearer.cs b/backend/ControlService/src/ControlService.API/Auth/ConfigureJwtBearer.cs new file mode 100644 index 0000000..e6b23f7 --- /dev/null +++ b/backend/ControlService/src/ControlService.API/Auth/ConfigureJwtBearer.cs @@ -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; + +/// 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). +public sealed class ConfigureJwtBearer(IOptions jwtOptions, TimeProvider timeProvider) + : IConfigureNamedOptions +{ + 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); + }, + }; + } +} diff --git a/backend/ControlService/src/ControlService.API/Auth/JwtAccessTokenIssuer.cs b/backend/ControlService/src/ControlService.API/Auth/JwtAccessTokenIssuer.cs new file mode 100644 index 0000000..c9dade5 --- /dev/null +++ b/backend/ControlService/src/ControlService.API/Auth/JwtAccessTokenIssuer.cs @@ -0,0 +1,44 @@ +using ControlService.Application.Auth; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; + +namespace ControlService.API.Auth; + +/// Issues the HS256 access token (T5). It carries the user id in `sub` (ICurrentUser reads it), +/// the session in `sid`, and `must_change_password` only while the change is mandatory. No personal data. +public sealed class JwtAccessTokenIssuer(IOptions options, TimeProvider timeProvider) : IAccessTokenIssuer +{ + public const string MustChangePasswordClaim = "must_change_password"; + public const string SessionIdClaim = "sid"; + + public AccessToken Issue(Guid userId, Guid sessionId, bool mustChangePassword) + { + var jwt = options.Value; + var now = timeProvider.GetUtcNow().UtcDateTime; + var lifetime = TimeSpan.FromMinutes(jwt.AccessTokenMinutes); + + var claims = new Dictionary + { + [JwtRegisteredClaimNames.Sub] = userId.ToString(), + [SessionIdClaim] = sessionId.ToString(), + }; + if (mustChangePassword) + { + claims[MustChangePasswordClaim] = true; + } + + var descriptor = new SecurityTokenDescriptor + { + Issuer = jwt.Issuer, + Audience = jwt.Audience, + IssuedAt = now, + NotBefore = now, + Expires = now + lifetime, + Claims = claims, + SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(jwt.SigningKeyBytes()), SecurityAlgorithms.HmacSha256), + }; + + return new AccessToken(new JsonWebTokenHandler().CreateToken(descriptor), (int)lifetime.TotalSeconds); + } +} diff --git a/backend/ControlService/src/ControlService.API/Auth/JwtOptions.cs b/backend/ControlService/src/ControlService.API/Auth/JwtOptions.cs new file mode 100644 index 0000000..f383b90 --- /dev/null +++ b/backend/ControlService/src/ControlService.API/Auth/JwtOptions.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations; + +namespace ControlService.API.Auth; + +/// The `Auth` keys that decide how access tokens are signed and checked (ADR-0019, ADR-0032). +/// The keys Infrastructure reads live in the same section. +public sealed class JwtOptions +{ + public const string SectionName = "Auth"; + + [Range(1, 24 * 60)] + public int AccessTokenMinutes { get; init; } + + [Required] + public string Issuer { get; init; } = string.Empty; + + [Required] + public string Audience { get; init; } = string.Empty; + + /// The base64 of at least 32 random bytes. A secret: user secrets locally (T5). + public string SigningKey { get; init; } = string.Empty; + + public byte[] SigningKeyBytes() => Convert.FromBase64String(SigningKey); +} diff --git a/backend/ControlService/src/ControlService.API/Auth/PasswordChangeRequiredFilter.cs b/backend/ControlService/src/ControlService.API/Auth/PasswordChangeRequiredFilter.cs new file mode 100644 index 0000000..110de10 --- /dev/null +++ b/backend/ControlService/src/ControlService.API/Auth/PasswordChangeRequiredFilter.cs @@ -0,0 +1,34 @@ +using ControlService.API.Common; +using ControlService.Application.Auth; + +namespace ControlService.API.Auth; + +/// While the Admin has not replaced the initial password, its access token carries +/// `must_change_password` and every endpoint except the ones that opted out answers 401 +/// `password_change_required` (AUTH-14, ADR-0032, T8). +public sealed class PasswordChangeRequiredFilter : IEndpointFilter +{ + public ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + var httpContext = context.HttpContext; + var isPending = httpContext.User.HasClaim( + claim => claim.Type == JwtAccessTokenIssuer.MustChangePasswordClaim + && string.Equals(claim.Value, "true", StringComparison.OrdinalIgnoreCase)); + var mayProceed = httpContext.GetEndpoint()?.Metadata.GetMetadata() is not null; + + return isPending && !mayProceed + ? ValueTask.FromResult(AuthErrors.PasswordChangeRequired.ToProblem()) + : next(context); + } +} + +/// Marks an endpoint the person may call before replacing the initial password: `me`, sign-out +/// and change-password (ADR-0032). +public sealed record AllowPendingPasswordChangeMetadata; + +public static class PasswordChangeRequiredExtensions +{ + public static TBuilder AllowPendingPasswordChange(this TBuilder builder) + where TBuilder : IEndpointConventionBuilder => + builder.WithMetadata(new AllowPendingPasswordChangeMetadata()); +} diff --git a/backend/ControlService/src/ControlService.API/Auth/RateLimitingOptions.cs b/backend/ControlService/src/ControlService.API/Auth/RateLimitingOptions.cs new file mode 100644 index 0000000..5493caa --- /dev/null +++ b/backend/ControlService/src/ControlService.API/Auth/RateLimitingOptions.cs @@ -0,0 +1,17 @@ +namespace ControlService.API.Auth; + +/// Requests per client address in a 60-second window (ADR-0023, AUTH-22). Sign-in and refresh +/// each have their own limit; the global limit and the e-mail routes come with their own issues. +public sealed class RateLimitingOptions +{ + public const string SectionName = "RateLimiting"; + + public RateLimitPolicyOptions SignIn { get; init; } = new(); + + public RateLimitPolicyOptions Refresh { get; init; } = new(); +} + +public sealed class RateLimitPolicyOptions +{ + public int PermitLimit { get; init; } +} diff --git a/backend/ControlService/src/ControlService.API/Auth/RefreshCookie.cs b/backend/ControlService/src/ControlService.API/Auth/RefreshCookie.cs new file mode 100644 index 0000000..c32c666 --- /dev/null +++ b/backend/ControlService/src/ControlService.API/Auth/RefreshCookie.cs @@ -0,0 +1,24 @@ +namespace ControlService.API.Auth; + +/// The refresh token cookie (ADR-0019, T6). Its `Path` is the refresh route only, so the +/// browser sends it nowhere else; expiring it needs the same name and `Path`. +internal static class RefreshCookie +{ + public const string Name = "__Secure-cs-refresh"; + public const string Path = "/api/v1/auth/refresh"; + + public static void Append(HttpResponse response, string refreshToken, DateTimeOffset expiresAt) => + response.Cookies.Append(Name, refreshToken, Options(expiresAt)); + + public static void Expire(HttpResponse response) => response.Cookies.Delete(Name, Options(expiresAt: null)); + + private static CookieOptions Options(DateTimeOffset? expiresAt) => new() + { + HttpOnly = true, + Secure = true, + SameSite = SameSiteMode.Strict, + Path = Path, + Expires = expiresAt, + IsEssential = true, + }; +} diff --git a/backend/ControlService/src/ControlService.API/Auth/SessionResponse.cs b/backend/ControlService/src/ControlService.API/Auth/SessionResponse.cs new file mode 100644 index 0000000..c77fbf8 --- /dev/null +++ b/backend/ControlService/src/ControlService.API/Auth/SessionResponse.cs @@ -0,0 +1,10 @@ +using ControlService.Application.Auth; + +namespace ControlService.API.Auth; + +/// The body of sign-in, refresh and change-password (D1). `ExpiresIn` is in seconds, not a +/// timestamp, so a wrong clock on the person's computer does not break the refresh decision. +public sealed record SessionResponse(string AccessToken, int ExpiresIn, bool MustChangePassword) +{ + public static SessionResponse From(SessionGrant grant) => new(grant.AccessToken, grant.ExpiresIn, grant.MustChangePassword); +} diff --git a/backend/ControlService/src/ControlService.API/Common/ApiV1Group.cs b/backend/ControlService/src/ControlService.API/Common/ApiV1Group.cs new file mode 100644 index 0000000..0d10465 --- /dev/null +++ b/backend/ControlService/src/ControlService.API/Common/ApiV1Group.cs @@ -0,0 +1,14 @@ +using ControlService.API.Auth; + +namespace ControlService.API.Common; + +/// The `/api/v1` group every feature registers its endpoints under (ADR-0003, API-01). +/// `Program.cs` and the test factories both map it through here. +public static class ApiV1Group +{ + /// Every endpoint requires a signed-in person unless it opts out with `AllowAnonymous()`, and + /// refuses a person who still has to replace the initial password unless it opts out with + /// `AllowPendingPasswordChange()` (AUTH-14, ADR-0032). + public static RouteGroupBuilder MapApiV1(this IEndpointRouteBuilder app) => + app.MapGroup("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/api/v1").RequireAuthorization().AddEndpointFilter(); +} diff --git a/backend/ControlService/src/ControlService.API/Common/ErrorResults.cs b/backend/ControlService/src/ControlService.API/Common/ErrorResults.cs index 1392dce..caf93ea 100644 --- a/backend/ControlService/src/ControlService.API/Common/ErrorResults.cs +++ b/backend/ControlService/src/ControlService.API/Common/ErrorResults.cs @@ -8,10 +8,13 @@ namespace ControlService.API.Common; /// in ADR-0009 and `docs/api/conventions.md#errors` (API-12, API-13). public static class ErrorResults { - public static ProblemHttpResult ToProblem(this Error error) + /// The error to translate. + /// For the one code whose status depends on the endpoint: `account_inactive` + /// is a 403 at sign-in and a 401 during a session (ADR-0009). + public static ProblemHttpResult ToProblem(this Error error, int? statusOverride = null) { // An unmapped code is a bug: the exception handler logs it and answers with the generic 500 (API-12). - var status = ErrorStatusCodes.For(error.Code) + var status = statusOverride ?? ErrorStatusCodes.For(error.Code) ?? throw new InvalidOperationException( $"The error code '{error.Code}' has no HTTP status in ErrorStatusCodes (ADR-0009)."); var (type, title) = ProblemTypes.For(status); diff --git a/backend/ControlService/src/ControlService.API/ControlService.API.csproj b/backend/ControlService/src/ControlService.API/ControlService.API.csproj index 2078a40..9aeae7a 100644 --- a/backend/ControlService/src/ControlService.API/ControlService.API.csproj +++ b/backend/ControlService/src/ControlService.API/ControlService.API.csproj @@ -11,6 +11,7 @@ + diff --git a/backend/ControlService/src/ControlService.API/ControlService.API.http b/backend/ControlService/src/ControlService.API/ControlService.API.http index d1d3ff8..a2fddee 100644 --- a/backend/ControlService/src/ControlService.API/ControlService.API.http +++ b/backend/ControlService/src/ControlService.API/ControlService.API.http @@ -10,3 +10,44 @@ Accept: application/json GET {{ControlService.API_HostAddress}}/health ### + +# Authentication (ADR-0019). Replace the password placeholder with the value you stored in the user +# secrets as Admin:InitialPassword; never commit a real one. Paste the access token from the sign-in +# response into @accessToken below. The refresh token travels in an HttpOnly cookie scoped to the refresh route. +@accessToken = + +POST {{ControlService.API_HostAddress}}/api/v1/auth/sign-in +Content-Type: application/json + +{ + "login": "admin", + "password": "" +} + +### + +# First access of the Admin: the initial password must be replaced (AUTH-14). +POST {{ControlService.API_HostAddress}}/api/v1/auth/change-password +Authorization: Bearer {{accessToken}} +Content-Type: application/json + +{ + "password": "", + "passwordConfirmation": "" +} + +### + +GET {{ControlService.API_HostAddress}}/api/v1/me +Authorization: Bearer {{accessToken}} + +### + +POST {{ControlService.API_HostAddress}}/api/v1/auth/refresh + +### + +POST {{ControlService.API_HostAddress}}/api/v1/auth/sign-out +Authorization: Bearer {{accessToken}} + +### diff --git a/backend/ControlService/src/ControlService.API/Program.cs b/backend/ControlService/src/ControlService.API/Program.cs index 07b2c1b..b833fde 100644 --- a/backend/ControlService/src/ControlService.API/Program.cs +++ b/backend/ControlService/src/ControlService.API/Program.cs @@ -1,3 +1,4 @@ +using ControlService.API.Auth; using ControlService.API.Common; using ControlService.Application.Common; using ControlService.Infrastructure; @@ -21,6 +22,7 @@ builder.Services.AddSingleton(TimeProvider.System); builder.AddInfrastructure(); +builder.Services.AddAuthFeature(builder.Configuration); var app = builder.Build(); @@ -37,9 +39,13 @@ } app.UseHttpsRedirection(); +app.UseRateLimiter(); +app.UseAuthentication(); +app.UseAuthorization(); // Every feature registers its endpoints under /api/v1 (ADR-0003), for example: api.MapUserEndpoints(); -var api = app.MapGroup("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/api/v1"); +var api = app.MapApiV1(); +api.MapAuthEndpoints(); app.MapDefaultEndpoints(); diff --git a/backend/ControlService/src/ControlService.API/appsettings.json b/backend/ControlService/src/ControlService.API/appsettings.json index 10f68b8..ef2692c 100644 --- a/backend/ControlService/src/ControlService.API/appsettings.json +++ b/backend/ControlService/src/ControlService.API/appsettings.json @@ -5,5 +5,18 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "Auth": { + "AccessTokenMinutes": 15, + "RefreshTokenIdleHours": 8, + "Issuer": "control-service", + "Audience": "control-service", + "PasswordMinLength": 8, + "LockoutMaxFailedAttempts": 5, + "LockoutMinutes": 15 + }, + "RateLimiting": { + "SignIn": { "PermitLimit": 20 }, + "Refresh": { "PermitLimit": 60 } + } } diff --git a/backend/ControlService/src/ControlService.Application/Auth/AuthErrors.cs b/backend/ControlService/src/ControlService.Application/Auth/AuthErrors.cs new file mode 100644 index 0000000..b90ab8b --- /dev/null +++ b/backend/ControlService/src/ControlService.Application/Auth/AuthErrors.cs @@ -0,0 +1,37 @@ +using ControlService.Domain.Common; + +namespace ControlService.Application.Auth; + +/// The authentication errors and their Portuguese messages, verbatim from +/// docs/product/features/authentication.md (ADR-0009). +public static class AuthErrors +{ + public static Error InvalidCredentials { get; } = new("invalid_credentials", "Login ou senha incorretos."); + + public static Error PasswordChangeRequired { get; } = new( + "password_change_required", "Por segurança, a senha inicial precisa ser trocada no primeiro acesso."); + + public static Error TooManyRequests(int retryAfterSeconds) => new( + "locked_out", + "Muitas tentativas em pouco tempo. Aguarde alguns instantes e tente de novo.", + Details: new Dictionary { ["retryAfterSeconds"] = retryAfterSeconds }); + + public static Error SessionExpired { get; } = new( + "session_expired", "Sua sessão terminou. Entre de novo para continuar. Suas abas continuam abertas."); + + public static Error AccountInactive { get; } = new( + "account_inactive", "Este acesso está desativado. Fale com o responsável pelo sistema."); + + public static Error PasswordAlreadyCreated { get; } = new( + "forbidden", "Sua senha já foi criada. Para trocá-la, use “Esqueci minha senha”."); + + public static Error NewPasswordEqualsInitial { get; } = new( + "validation_failed", + "Alguns campos precisam ser corrigidos.", + Fields: new Dictionary { ["password"] = ["A nova senha precisa ser diferente da senha inicial."] }); + + public static Error LockedOut(int lockoutMinutes, int retryAfterSeconds) => new( + "locked_out", + $"Muitas tentativas sem sucesso. Aguarde {lockoutMinutes} minutos e tente de novo.", + Details: new Dictionary { ["retryAfterSeconds"] = retryAfterSeconds }); +} diff --git a/backend/ControlService/src/ControlService.Application/Auth/AuthSettings.cs b/backend/ControlService/src/ControlService.Application/Auth/AuthSettings.cs new file mode 100644 index 0000000..752ba2c --- /dev/null +++ b/backend/ControlService/src/ControlService.Application/Auth/AuthSettings.cs @@ -0,0 +1,5 @@ +namespace ControlService.Application.Auth; + +/// The configured values that appear in user-facing messages (AUTH-22, AUTH-28). Infrastructure +/// fills it from the `Auth` configuration section. +public sealed record AuthSettings(int PasswordMinLength, int LockoutMinutes); diff --git a/backend/ControlService/src/ControlService.Application/Auth/ChangePassword/ChangePasswordCommand.cs b/backend/ControlService/src/ControlService.Application/Auth/ChangePassword/ChangePasswordCommand.cs new file mode 100644 index 0000000..c9dc502 --- /dev/null +++ b/backend/ControlService/src/ControlService.Application/Auth/ChangePassword/ChangePasswordCommand.cs @@ -0,0 +1,5 @@ +namespace ControlService.Application.Auth.ChangePassword; + +/// `UserId` comes from the `sub` claim of the access token. Passwords are never trimmed: +/// a password is stored only as a hash (CNV-03 is about stored text). +public sealed record ChangePasswordCommand(Guid UserId, string Password, string PasswordConfirmation); diff --git a/backend/ControlService/src/ControlService.Application/Auth/ChangePassword/ChangePasswordHandler.cs b/backend/ControlService/src/ControlService.Application/Auth/ChangePassword/ChangePasswordHandler.cs new file mode 100644 index 0000000..fb6f400 --- /dev/null +++ b/backend/ControlService/src/ControlService.Application/Auth/ChangePassword/ChangePasswordHandler.cs @@ -0,0 +1,51 @@ +using ControlService.Application.Common; +using ControlService.Application.Users; +using ControlService.Domain.Common; + +namespace ControlService.Application.Auth.ChangePassword; + +public sealed class ChangePasswordHandler( + IUserRepository users, + ICredentialStore credentials, + ISessionStore sessions, + IAccessTokenIssuer accessTokens, + IUnitOfWork unitOfWork, + TimeProvider timeProvider) : ICommandHandler +{ + public async Task> Handle(ChangePasswordCommand command, CancellationToken cancellationToken) + { + var user = await users.GetByIdAsync(command.UserId, cancellationToken); + if (user is null) + { + return Result.Failure(AuthErrors.SessionExpired); + } + + if (!await credentials.MustChangePasswordAsync(command.UserId, cancellationToken)) + { + return Result.Failure(AuthErrors.PasswordAlreadyCreated); + } + + if (await credentials.IsCurrentPasswordAsync(command.UserId, command.Password, cancellationToken)) + { + return Result.Failure(AuthErrors.NewPasswordEqualsInitial); + } + + // The account with a mandatory change is the Admin, and this is its own first access (USR-34). + // The user is saved before the password changes: a retry after a failure repeats harmlessly. + user.CompleteFirstAccess(timeProvider.GetUtcNow()); + var saved = await unitOfWork.SaveChangesAsync(cancellationToken); + if (saved.IsFailure) + { + return Result.Failure(saved.Error); + } + + await credentials.ReplacePasswordAsync(command.UserId, command.Password, cancellationToken); + + // A new password ends every session, including the one that asked for the change (ADR-0019). + await sessions.EndAllAsync(command.UserId, cancellationToken); + var session = await sessions.StartAsync(command.UserId, cancellationToken); + var accessToken = accessTokens.Issue(command.UserId, session.SessionId, mustChangePassword: false); + + return Result.Success(SessionGrant.From(accessToken, session, mustChangePassword: false)); + } +} diff --git a/backend/ControlService/src/ControlService.Application/Auth/ChangePassword/ChangePasswordValidator.cs b/backend/ControlService/src/ControlService.Application/Auth/ChangePassword/ChangePasswordValidator.cs new file mode 100644 index 0000000..48fbc84 --- /dev/null +++ b/backend/ControlService/src/ControlService.Application/Auth/ChangePassword/ChangePasswordValidator.cs @@ -0,0 +1,17 @@ +using FluentValidation; + +namespace ControlService.Application.Auth.ChangePassword; + +public sealed class ChangePasswordValidator : AbstractValidator +{ + public ChangePasswordValidator(AuthSettings settings) + { + RuleFor(command => command.Password) + .MinimumLength(settings.PasswordMinLength) + .WithMessage($"A senha precisa ter pelo menos {settings.PasswordMinLength} caracteres."); + + RuleFor(command => command.PasswordConfirmation) + .Equal(command => command.Password) + .WithMessage("As duas senhas não são iguais. Digite de novo."); + } +} diff --git a/backend/ControlService/src/ControlService.Application/Auth/CredentialCheck.cs b/backend/ControlService/src/ControlService.Application/Auth/CredentialCheck.cs new file mode 100644 index 0000000..143c04d --- /dev/null +++ b/backend/ControlService/src/ControlService.Application/Auth/CredentialCheck.cs @@ -0,0 +1,19 @@ +namespace ControlService.Application.Auth; + +public enum CredentialCheckOutcome +{ + Succeeded, + Failed, + LockedOut, +} + +/// The result of checking a password against the person's credential, lockout included +/// (AUTH-08). A login with no credential fails like a wrong password. +public sealed record CredentialCheck(CredentialCheckOutcome Outcome, DateTimeOffset? LockoutEnd = null) +{ + public static CredentialCheck Succeeded { get; } = new(CredentialCheckOutcome.Succeeded); + + public static CredentialCheck Failed { get; } = new(CredentialCheckOutcome.Failed); + + public static CredentialCheck LockedOut(DateTimeOffset lockoutEnd) => new(CredentialCheckOutcome.LockedOut, lockoutEnd); +} diff --git a/backend/ControlService/src/ControlService.Application/Auth/GetMe/GetMeHandler.cs b/backend/ControlService/src/ControlService.Application/Auth/GetMe/GetMeHandler.cs new file mode 100644 index 0000000..af41644 --- /dev/null +++ b/backend/ControlService/src/ControlService.Application/Auth/GetMe/GetMeHandler.cs @@ -0,0 +1,30 @@ +using ControlService.Application.Common; +using ControlService.Application.PermissionProfiles; +using ControlService.Application.Users; +using ControlService.Domain.Access; +using ControlService.Domain.Common; + +namespace ControlService.Application.Auth.GetMe; + +public sealed class GetMeHandler( + IUserRepository users, + IPermissionProfileRepository profiles) : IQueryHandler +{ + public async Task> Handle(GetMeQuery query, CancellationToken cancellationToken) + { + var user = await users.GetByIdAsync(query.UserId, cancellationToken); + if (user is null) + { + return Result.Failure(AuthErrors.SessionExpired); + } + + var userProfiles = await profiles.ListByIdsAsync(user.ProfileIds, cancellationToken); + + var levels = ScreenKeys.All + .Select(key => new ScreenLevelResponse( + key, EffectiveAccess.GetLevel(userProfiles, ScreenKey.Create(key).Value).ToWireValue())) + .ToArray(); + + return Result.Success(new MeResponse(user.Id, user.DisplayName, user.Login.Value, levels)); + } +} diff --git a/backend/ControlService/src/ControlService.Application/Auth/GetMe/GetMeQuery.cs b/backend/ControlService/src/ControlService.Application/Auth/GetMe/GetMeQuery.cs new file mode 100644 index 0000000..bfd83a4 --- /dev/null +++ b/backend/ControlService/src/ControlService.Application/Auth/GetMe/GetMeQuery.cs @@ -0,0 +1,3 @@ +namespace ControlService.Application.Auth.GetMe; + +public sealed record GetMeQuery(Guid UserId); diff --git a/backend/ControlService/src/ControlService.Application/Auth/GetMe/MeResponse.cs b/backend/ControlService/src/ControlService.Application/Auth/GetMe/MeResponse.cs new file mode 100644 index 0000000..39df207 --- /dev/null +++ b/backend/ControlService/src/ControlService.Application/Auth/GetMe/MeResponse.cs @@ -0,0 +1,7 @@ +namespace ControlService.Application.Auth.GetMe; + +/// The signed-in person and their effective level on every screen, in catalog order, +/// including `negado` (D2). `Level` is the wire value. +public sealed record MeResponse(Guid Id, string DisplayName, string Login, IReadOnlyList Levels); + +public sealed record ScreenLevelResponse(string Screen, string Level); diff --git a/backend/ControlService/src/ControlService.Application/Auth/IAccessTokenIssuer.cs b/backend/ControlService/src/ControlService.Application/Auth/IAccessTokenIssuer.cs new file mode 100644 index 0000000..4d830e8 --- /dev/null +++ b/backend/ControlService/src/ControlService.Application/Auth/IAccessTokenIssuer.cs @@ -0,0 +1,10 @@ +namespace ControlService.Application.Auth; + +public interface IAccessTokenIssuer +{ + AccessToken Issue(Guid userId, Guid sessionId, bool mustChangePassword); +} + +/// The signed access token and how many seconds it lasts, so a wrong clock on the person's +/// computer does not break the refresh decision (D1). +public sealed record AccessToken(string Value, int ExpiresInSeconds); diff --git a/backend/ControlService/src/ControlService.Application/Auth/ICredentialStore.cs b/backend/ControlService/src/ControlService.Application/Auth/ICredentialStore.cs new file mode 100644 index 0000000..4ba765e --- /dev/null +++ b/backend/ControlService/src/ControlService.Application/Auth/ICredentialStore.cs @@ -0,0 +1,17 @@ +namespace ControlService.Application.Auth; + +/// Passwords, lockout and the mandatory-change flag. Credentials are not part of the user +/// record (AUTH-19); lockout counting lives behind this interface (ADR-0019). +public interface ICredentialStore +{ + Task CheckPasswordAsync(Guid userId, string password, CancellationToken cancellationToken); + + /// Compares without counting a failure toward the lockout: it is not a sign-in attempt. + Task IsCurrentPasswordAsync(Guid userId, string password, CancellationToken cancellationToken); + + /// Sets a new password and clears the mandatory-change flag. + Task ReplacePasswordAsync(Guid userId, string newPassword, CancellationToken cancellationToken); + + /// True while the account still has its initial password (AUTH-14). + Task MustChangePasswordAsync(Guid userId, CancellationToken cancellationToken); +} diff --git a/backend/ControlService/src/ControlService.Application/Auth/ISessionStore.cs b/backend/ControlService/src/ControlService.Application/Auth/ISessionStore.cs new file mode 100644 index 0000000..f583f6d --- /dev/null +++ b/backend/ControlService/src/ControlService.Application/Auth/ISessionStore.cs @@ -0,0 +1,15 @@ +namespace ControlService.Application.Auth; + +/// Sessions are refresh tokens stored hashed (AUTH-19, ADR-0019). +public interface ISessionStore +{ + Task StartAsync(Guid userId, CancellationToken cancellationToken); + + Task EndAsync(Guid sessionId, CancellationToken cancellationToken); + + Task EndAllAsync(Guid userId, CancellationToken cancellationToken); + + /// Replaces a valid refresh token with a new one, or returns null when the token is + /// unknown or has expired. Two calls with the same token never both succeed (ADR-0019). + Task RotateAsync(string refreshToken, CancellationToken cancellationToken); +} diff --git a/backend/ControlService/src/ControlService.Application/Auth/RefreshSession/RefreshSessionCommand.cs b/backend/ControlService/src/ControlService.Application/Auth/RefreshSession/RefreshSessionCommand.cs new file mode 100644 index 0000000..551663e --- /dev/null +++ b/backend/ControlService/src/ControlService.Application/Auth/RefreshSession/RefreshSessionCommand.cs @@ -0,0 +1,4 @@ +namespace ControlService.Application.Auth.RefreshSession; + +/// `RefreshToken` is what the cookie carried, or an empty string when there was no cookie. +public sealed record RefreshSessionCommand(string RefreshToken); diff --git a/backend/ControlService/src/ControlService.Application/Auth/RefreshSession/RefreshSessionHandler.cs b/backend/ControlService/src/ControlService.Application/Auth/RefreshSession/RefreshSessionHandler.cs new file mode 100644 index 0000000..d170407 --- /dev/null +++ b/backend/ControlService/src/ControlService.Application/Auth/RefreshSession/RefreshSessionHandler.cs @@ -0,0 +1,41 @@ +using ControlService.Application.Common; +using ControlService.Application.Users; +using ControlService.Domain.Common; +using ControlService.Domain.Users; + +namespace ControlService.Application.Auth.RefreshSession; + +public sealed class RefreshSessionHandler( + IUserRepository users, + ICredentialStore credentials, + ISessionStore sessions, + IAccessTokenIssuer accessTokens) : ICommandHandler +{ + public async Task> Handle(RefreshSessionCommand command, CancellationToken cancellationToken) + { + var rotated = await sessions.RotateAsync(command.RefreshToken, cancellationToken); + if (rotated is null) + { + return Result.Failure(AuthErrors.SessionExpired); + } + + // Refresh is anonymous (cookie only), so the per-request account check does not cover it (D10). + var user = await users.GetByIdAsync(rotated.UserId, cancellationToken); + if (user is null) + { + await sessions.EndAsync(rotated.Tokens.SessionId, cancellationToken); + return Result.Failure(AuthErrors.SessionExpired); + } + + if (user.Status != UserStatus.Active) + { + await sessions.EndAsync(rotated.Tokens.SessionId, cancellationToken); + return Result.Failure(AuthErrors.AccountInactive); + } + + var mustChangePassword = await credentials.MustChangePasswordAsync(user.Id, cancellationToken); + var accessToken = accessTokens.Issue(user.Id, rotated.Tokens.SessionId, mustChangePassword); + + return Result.Success(SessionGrant.From(accessToken, rotated.Tokens, mustChangePassword)); + } +} diff --git a/backend/ControlService/src/ControlService.Application/Auth/RotatedSession.cs b/backend/ControlService/src/ControlService.Application/Auth/RotatedSession.cs new file mode 100644 index 0000000..7db6e5c --- /dev/null +++ b/backend/ControlService/src/ControlService.Application/Auth/RotatedSession.cs @@ -0,0 +1,4 @@ +namespace ControlService.Application.Auth; + +/// A refresh token that was valid and has just been replaced by a new one (AUTH-17). +public sealed record RotatedSession(Guid UserId, SessionTokens Tokens); diff --git a/backend/ControlService/src/ControlService.Application/Auth/SessionGrant.cs b/backend/ControlService/src/ControlService.Application/Auth/SessionGrant.cs new file mode 100644 index 0000000..a58ef1a --- /dev/null +++ b/backend/ControlService/src/ControlService.Application/Auth/SessionGrant.cs @@ -0,0 +1,14 @@ +namespace ControlService.Application.Auth; + +/// What a sign-in, a refresh or a password change grants. The endpoint sends the first +/// three in the body and the refresh token in its cookie (D1). +public sealed record SessionGrant( + string AccessToken, + int ExpiresIn, + bool MustChangePassword, + string RefreshToken, + DateTimeOffset RefreshTokenExpiresAt) +{ + public static SessionGrant From(AccessToken accessToken, SessionTokens session, bool mustChangePassword) => + new(accessToken.Value, accessToken.ExpiresInSeconds, mustChangePassword, session.RefreshToken, session.ExpiresAt); +} diff --git a/backend/ControlService/src/ControlService.Application/Auth/SessionTokens.cs b/backend/ControlService/src/ControlService.Application/Auth/SessionTokens.cs new file mode 100644 index 0000000..efe4d39 --- /dev/null +++ b/backend/ControlService/src/ControlService.Application/Auth/SessionTokens.cs @@ -0,0 +1,5 @@ +namespace ControlService.Application.Auth; + +/// A session just started or rotated: its id, the plain refresh token (returned once, for +/// the cookie) and when the token stops working if it is not used. +public sealed record SessionTokens(Guid SessionId, string RefreshToken, DateTimeOffset ExpiresAt); diff --git a/backend/ControlService/src/ControlService.Application/Auth/SignIn/SignInCommand.cs b/backend/ControlService/src/ControlService.Application/Auth/SignIn/SignInCommand.cs new file mode 100644 index 0000000..9b7f06f --- /dev/null +++ b/backend/ControlService/src/ControlService.Application/Auth/SignIn/SignInCommand.cs @@ -0,0 +1,3 @@ +namespace ControlService.Application.Auth.SignIn; + +public sealed record SignInCommand(string Login, string Password); diff --git a/backend/ControlService/src/ControlService.Application/Auth/SignIn/SignInHandler.cs b/backend/ControlService/src/ControlService.Application/Auth/SignIn/SignInHandler.cs new file mode 100644 index 0000000..8212f3e --- /dev/null +++ b/backend/ControlService/src/ControlService.Application/Auth/SignIn/SignInHandler.cs @@ -0,0 +1,50 @@ +using ControlService.Application.Common; +using ControlService.Application.Users; +using ControlService.Domain.Common; +using ControlService.Domain.Users; + +namespace ControlService.Application.Auth.SignIn; + +public sealed class SignInHandler( + IUserRepository users, + ICredentialStore credentials, + ISessionStore sessions, + IAccessTokenIssuer accessTokens, + AuthSettings settings, + TimeProvider timeProvider) : ICommandHandler +{ + public async Task> Handle(SignInCommand command, CancellationToken cancellationToken) + { + // A login that could never exist is answered like one that does not (AUTH-08). + var login = Login.Create(command.Login); + var user = login.IsSuccess ? await users.GetByLoginAsync(login.Value, cancellationToken) : null; + if (user is null) + { + return Result.Failure(AuthErrors.InvalidCredentials); + } + + var check = await credentials.CheckPasswordAsync(user.Id, command.Password, cancellationToken); + if (check.Outcome == CredentialCheckOutcome.LockedOut) + { + var retryAfter = Math.Ceiling((check.LockoutEnd!.Value - timeProvider.GetUtcNow()).TotalSeconds); + return Result.Failure(AuthErrors.LockedOut(settings.LockoutMinutes, (int)retryAfter)); + } + + if (check.Outcome != CredentialCheckOutcome.Succeeded) + { + return Result.Failure(AuthErrors.InvalidCredentials); + } + + // Only a correct password reveals that the account is deactivated (AUTH-09). + if (user.Status == UserStatus.Inactive) + { + return Result.Failure(AuthErrors.AccountInactive); + } + + var session = await sessions.StartAsync(user.Id, cancellationToken); + var mustChangePassword = await credentials.MustChangePasswordAsync(user.Id, cancellationToken); + var accessToken = accessTokens.Issue(user.Id, session.SessionId, mustChangePassword); + + return Result.Success(SessionGrant.From(accessToken, session, mustChangePassword)); + } +} diff --git a/backend/ControlService/src/ControlService.Application/Auth/SignOut/SignOutCommand.cs b/backend/ControlService/src/ControlService.Application/Auth/SignOut/SignOutCommand.cs new file mode 100644 index 0000000..0239282 --- /dev/null +++ b/backend/ControlService/src/ControlService.Application/Auth/SignOut/SignOutCommand.cs @@ -0,0 +1,5 @@ +namespace ControlService.Application.Auth.SignOut; + +/// `SessionId` comes from the `sid` claim of the access token (D3): the refresh cookie is +/// scoped to the refresh route, so sign-out never receives it. +public sealed record SignOutCommand(Guid SessionId); diff --git a/backend/ControlService/src/ControlService.Application/Auth/SignOut/SignOutHandler.cs b/backend/ControlService/src/ControlService.Application/Auth/SignOut/SignOutHandler.cs new file mode 100644 index 0000000..cd9f53d --- /dev/null +++ b/backend/ControlService/src/ControlService.Application/Auth/SignOut/SignOutHandler.cs @@ -0,0 +1,13 @@ +using ControlService.Application.Common; +using ControlService.Domain.Common; + +namespace ControlService.Application.Auth.SignOut; + +public sealed class SignOutHandler(ISessionStore sessions) : ICommandHandler +{ + public async Task> Handle(SignOutCommand command, CancellationToken cancellationToken) + { + await sessions.EndAsync(command.SessionId, cancellationToken); + return Result.Success(default); + } +} diff --git a/backend/ControlService/src/ControlService.Application/Common/Unit.cs b/backend/ControlService/src/ControlService.Application/Common/Unit.cs new file mode 100644 index 0000000..fa8f398 --- /dev/null +++ b/backend/ControlService/src/ControlService.Application/Common/Unit.cs @@ -0,0 +1,5 @@ +namespace ControlService.Application.Common; + +/// The response of a command that has nothing to return, since handlers always return a +/// Result<TResponse> (ADR-0009). +public readonly record struct Unit; diff --git a/backend/ControlService/src/ControlService.Application/PermissionProfiles/IPermissionProfileRepository.cs b/backend/ControlService/src/ControlService.Application/PermissionProfiles/IPermissionProfileRepository.cs new file mode 100644 index 0000000..da4b7fa --- /dev/null +++ b/backend/ControlService/src/ControlService.Application/PermissionProfiles/IPermissionProfileRepository.cs @@ -0,0 +1,9 @@ +using ControlService.Domain.PermissionProfiles; + +namespace ControlService.Application.PermissionProfiles; + +public interface IPermissionProfileRepository +{ + /// The profiles that exist among the given ids; an unknown id is ignored (PERM-05). + Task> ListByIdsAsync(IEnumerable ids, CancellationToken cancellationToken); +} diff --git a/backend/ControlService/src/ControlService.Application/Users/IUserRepository.cs b/backend/ControlService/src/ControlService.Application/Users/IUserRepository.cs new file mode 100644 index 0000000..8d42ca7 --- /dev/null +++ b/backend/ControlService/src/ControlService.Application/Users/IUserRepository.cs @@ -0,0 +1,10 @@ +using ControlService.Domain.Users; + +namespace ControlService.Application.Users; + +public interface IUserRepository +{ + Task GetByIdAsync(Guid id, CancellationToken cancellationToken); + + Task GetByLoginAsync(Login login, CancellationToken cancellationToken); +} diff --git a/backend/ControlService/src/ControlService.Domain/Users/User.cs b/backend/ControlService/src/ControlService.Domain/Users/User.cs index 23f8c85..0a4fe3b 100644 --- a/backend/ControlService/src/ControlService.Domain/Users/User.cs +++ b/backend/ControlService/src/ControlService.Domain/Users/User.cs @@ -68,6 +68,10 @@ public Result Activate(DateTimeOffset now) return Result.Success(); } + /// The Admin replacing its initial password is its own first access (USR-34): the system-record + /// guard does not apply, for the same reason as AUTH-15. + public void CompleteFirstAccess(DateTimeOffset now) => ActivatedAt ??= now; + public Result Deactivate(Guid by, DateTimeOffset now) { var systemCheck = EnsureNotSystem(); diff --git a/backend/ControlService/src/ControlService.Infrastructure/Auth/AuthOptions.cs b/backend/ControlService/src/ControlService.Infrastructure/Auth/AuthOptions.cs new file mode 100644 index 0000000..c9949f0 --- /dev/null +++ b/backend/ControlService/src/ControlService.Infrastructure/Auth/AuthOptions.cs @@ -0,0 +1,22 @@ +using System.ComponentModel.DataAnnotations; + +namespace ControlService.Infrastructure.Auth; + +/// The `Auth` values Infrastructure needs (AUTH-22). The JWT keys of the same section are +/// read by the API, which is where tokens are issued. +public sealed class AuthOptions +{ + public const string SectionName = "Auth"; + + [Range(1, 24 * 30)] + public int RefreshTokenIdleHours { get; init; } + + [Range(1, 1000)] + public int PasswordMinLength { get; init; } + + [Range(1, 1000)] + public int LockoutMaxFailedAttempts { get; init; } + + [Range(1, 24 * 60)] + public int LockoutMinutes { get; init; } +} diff --git a/backend/ControlService/src/ControlService.Infrastructure/Auth/CredentialStore.cs b/backend/ControlService/src/ControlService.Infrastructure/Auth/CredentialStore.cs new file mode 100644 index 0000000..026ec2d --- /dev/null +++ b/backend/ControlService/src/ControlService.Infrastructure/Auth/CredentialStore.cs @@ -0,0 +1,62 @@ +using ControlService.Application.Auth; +using Microsoft.AspNetCore.Identity; + +namespace ControlService.Infrastructure.Auth; + +/// Passwords, lockout and the mandatory-change flag on top of Identity's UserManager, without +/// SignInManager (T2). Lockout is checked before the password, and an attempt made while locked out is +/// not counted (D6). +public sealed class CredentialStore(UserManager users) : ICredentialStore +{ + public async Task CheckPasswordAsync(Guid userId, string password, CancellationToken cancellationToken) + { + var credential = await FindAsync(userId); + if (credential is null) + { + return CredentialCheck.Failed; + } + + if (await users.IsLockedOutAsync(credential)) + { + return await LockedOutAsync(credential); + } + + if (await users.CheckPasswordAsync(credential, password)) + { + await users.ResetAccessFailedCountAsync(credential); + return CredentialCheck.Succeeded; + } + + await users.AccessFailedAsync(credential); + return await users.IsLockedOutAsync(credential) ? await LockedOutAsync(credential) : CredentialCheck.Failed; + } + + public async Task IsCurrentPasswordAsync(Guid userId, string password, CancellationToken cancellationToken) + { + var credential = await FindAsync(userId); + return credential is not null && await users.CheckPasswordAsync(credential, password); + } + + public async Task MustChangePasswordAsync(Guid userId, CancellationToken cancellationToken) => + (await FindAsync(userId))?.MustChangePassword ?? false; + + public async Task ReplacePasswordAsync(Guid userId, string newPassword, CancellationToken cancellationToken) + { + var credential = await FindAsync(userId) ?? throw new InvalidOperationException("The user has no credential."); + credential.PasswordHash = users.PasswordHasher.HashPassword(credential, newPassword); + credential.MustChangePassword = false; + // A new password invalidates whatever the old one had granted (security stamp). + credential.SecurityStamp = Guid.NewGuid().ToString(); + + var updated = await users.UpdateAsync(credential); + if (!updated.Succeeded) + { + throw new InvalidOperationException("The credential could not be updated."); + } + } + + private async Task FindAsync(Guid userId) => await users.FindByIdAsync(userId.ToString()); + + private async Task LockedOutAsync(UserCredential credential) => + CredentialCheck.LockedOut((await users.GetLockoutEndDateAsync(credential))!.Value); +} diff --git a/backend/ControlService/src/ControlService.Infrastructure/Auth/CredentialUserManager.cs b/backend/ControlService/src/ControlService.Infrastructure/Auth/CredentialUserManager.cs new file mode 100644 index 0000000..f3a2f8c --- /dev/null +++ b/backend/ControlService/src/ControlService.Infrastructure/Auth/CredentialUserManager.cs @@ -0,0 +1,51 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace ControlService.Infrastructure.Auth; + +/// Identity's lockout reads `DateTimeOffset.UtcNow` directly, which tests cannot control. +/// This manager overrides only the two virtual methods that compare or set the lockout end so they +/// use the application's ; the counting and the threshold are Identity's +/// own logic, copied unchanged (ADR-0019). +public sealed class CredentialUserManager( + IUserStore store, + IOptions optionsAccessor, + IPasswordHasher passwordHasher, + IEnumerable> userValidators, + IEnumerable> passwordValidators, + ILookupNormalizer keyNormalizer, + IdentityErrorDescriber errors, + IServiceProvider services, + ILogger> logger, + TimeProvider timeProvider) + : UserManager( + store, optionsAccessor, passwordHasher, userValidators, passwordValidators, keyNormalizer, errors, services, logger) +{ + public override async Task IsLockedOutAsync(UserCredential user) + { + if (!await GetLockoutEnabledAsync(user)) + { + return false; + } + + // Locked while the end is still ahead: at exactly the end the wait is over and Retry-After is 0. + return await GetLockoutEndDateAsync(user) > timeProvider.GetUtcNow(); + } + + public override async Task AccessFailedAsync(UserCredential user) + { + var lockoutStore = (IUserLockoutStore)Store; + var count = await lockoutStore.IncrementAccessFailedCountAsync(user, CancellationToken); + if (count < Options.Lockout.MaxFailedAccessAttempts) + { + return await UpdateAsync(user); + } + + // Reaching the threshold locks the login and starts counting again (AUTH-08, D6). + await lockoutStore.SetLockoutEndDateAsync( + user, timeProvider.GetUtcNow().Add(Options.Lockout.DefaultLockoutTimeSpan), CancellationToken); + await lockoutStore.ResetAccessFailedCountAsync(user, CancellationToken); + return await UpdateAsync(user); + } +} diff --git a/backend/ControlService/src/ControlService.Infrastructure/Auth/SessionStore.cs b/backend/ControlService/src/ControlService.Infrastructure/Auth/SessionStore.cs new file mode 100644 index 0000000..4f9f431 --- /dev/null +++ b/backend/ControlService/src/ControlService.Infrastructure/Auth/SessionStore.cs @@ -0,0 +1,71 @@ +using System.Buffers.Text; +using System.Security.Cryptography; +using System.Text; +using ControlService.Application.Auth; +using ControlService.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace ControlService.Infrastructure.Auth; + +/// Refresh tokens are 32 random bytes; only their SHA-256 is stored (AUTH-19). A session is +/// valid while now < expires_at, and every use slides that time forward (ADR-0032, T4). +public sealed class SessionStore(AppDbContext db, IOptions options, TimeProvider timeProvider) : ISessionStore +{ + private TimeSpan IdleTime => TimeSpan.FromHours(options.Value.RefreshTokenIdleHours); + + public async Task StartAsync(Guid userId, CancellationToken cancellationToken) + { + var now = timeProvider.GetUtcNow(); + var token = NewToken(); + var session = new UserSession + { + Id = Guid.CreateVersion7(), + UserId = userId, + TokenHash = Hash(token), + CreatedAt = now, + ExpiresAt = now + IdleTime, + }; + + db.Sessions.Add(session); + await db.SaveChangesAsync(cancellationToken); + + return new SessionTokens(session.Id, token, session.ExpiresAt); + } + + public async Task RotateAsync(string refreshToken, CancellationToken cancellationToken) + { + var now = timeProvider.GetUtcNow(); + var oldHash = Hash(refreshToken); + var newToken = NewToken(); + var newHash = Hash(newToken); + var expiresAt = now + IdleTime; + + // One UPDATE: two refreshes with the same token cannot both match the old hash (T4). + var rotated = await db.Sessions + .Where(session => session.TokenHash == oldHash && session.ExpiresAt > now) + .ExecuteUpdateAsync( + setters => setters + .SetProperty(session => session.TokenHash, newHash) + .SetProperty(session => session.ExpiresAt, expiresAt), + cancellationToken); + if (rotated == 0) + { + return null; + } + + var session = await db.Sessions.AsNoTracking() + .SingleAsync(candidate => candidate.TokenHash == newHash, cancellationToken); + return new RotatedSession(session.UserId, new SessionTokens(session.Id, newToken, expiresAt)); + } + + public Task EndAsync(Guid sessionId, CancellationToken cancellationToken) => + db.Sessions.Where(session => session.Id == sessionId).ExecuteDeleteAsync(cancellationToken); + + public Task EndAllAsync(Guid userId, CancellationToken cancellationToken) => + db.Sessions.Where(session => session.UserId == userId).ExecuteDeleteAsync(cancellationToken); + + private static string NewToken() => Base64Url.EncodeToString(RandomNumberGenerator.GetBytes(32)); + + private static byte[] Hash(string token) => SHA256.HashData(Encoding.UTF8.GetBytes(token)); +} diff --git a/backend/ControlService/src/ControlService.Infrastructure/Auth/UserCredential.cs b/backend/ControlService/src/ControlService.Infrastructure/Auth/UserCredential.cs new file mode 100644 index 0000000..b87d868 --- /dev/null +++ b/backend/ControlService/src/ControlService.Infrastructure/Auth/UserCredential.cs @@ -0,0 +1,12 @@ +using Microsoft.AspNetCore.Identity; + +namespace ControlService.Infrastructure.Auth; + +/// The person's credential: password hash, lockout counter and security stamp, kept apart +/// from the user record (AUTH-19). `Id` is the user's id and `UserName` is that id too, so nothing +/// needs syncing when a login changes; Identity's e-mail and phone columns stay empty (D12). +public sealed class UserCredential : IdentityUser +{ + /// True while the account still has its initial password (AUTH-14). + public bool MustChangePassword { get; set; } +} diff --git a/backend/ControlService/src/ControlService.Infrastructure/Auth/UserSession.cs b/backend/ControlService/src/ControlService.Infrastructure/Auth/UserSession.cs new file mode 100644 index 0000000..4f5b1d8 --- /dev/null +++ b/backend/ControlService/src/ControlService.Infrastructure/Auth/UserSession.cs @@ -0,0 +1,16 @@ +namespace ControlService.Infrastructure.Auth; + +/// One signed-in browser: only the SHA-256 of its refresh token is stored (AUTH-19). An +/// infrastructure row, not an aggregate, so it has no audit fields (T4). +public sealed class UserSession +{ + public Guid Id { get; init; } + + public Guid UserId { get; init; } + + public required byte[] TokenHash { get; set; } + + public DateTimeOffset CreatedAt { get; init; } + + public DateTimeOffset ExpiresAt { get; set; } +} diff --git a/backend/ControlService/src/ControlService.Infrastructure/ControlService.Infrastructure.csproj b/backend/ControlService/src/ControlService.Infrastructure/ControlService.Infrastructure.csproj index d6af0b0..a9d671d 100644 --- a/backend/ControlService/src/ControlService.Infrastructure/ControlService.Infrastructure.csproj +++ b/backend/ControlService/src/ControlService.Infrastructure/ControlService.Infrastructure.csproj @@ -9,6 +9,8 @@ + + diff --git a/backend/ControlService/src/ControlService.Infrastructure/DependencyInjection.cs b/backend/ControlService/src/ControlService.Infrastructure/DependencyInjection.cs index 429515e..8e44de6 100644 --- a/backend/ControlService/src/ControlService.Infrastructure/DependencyInjection.cs +++ b/backend/ControlService/src/ControlService.Infrastructure/DependencyInjection.cs @@ -1,6 +1,11 @@ +using ControlService.Application.Auth; using ControlService.Application.Common; +using ControlService.Application.PermissionProfiles; +using ControlService.Application.Users; using ControlService.Domain.Users; +using ControlService.Infrastructure.Auth; using ControlService.Infrastructure.Persistence; +using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -18,21 +23,56 @@ public static IHostApplicationBuilder AddInfrastructure(this IHostApplicationBui { builder.Services.AddSingleton(); + builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection(AuthOptions.SectionName)) + .ValidateDataAnnotations() + .ValidateOnStart(); + + // Message texts state these two values (D11, AUTH-28), and Application cannot read options. + builder.Services.AddSingleton(serviceProvider => + { + var auth = serviceProvider.GetRequiredService>().Value; + return new AuthSettings(auth.PasswordMinLength, auth.LockoutMinutes); + }); + builder.Services.AddOptions() .Bind(builder.Configuration.GetSection(AdminOptions.SectionName)) .Validate(options => EmailAddress.Create(options.Email).IsSuccess, "Admin:Email must be a valid e-mail address.") + .Validate>( + // Binding leaves the value null when the user secret was never set. + (options, auth) => options.InitialPassword?.Length >= auth.Value.PasswordMinLength, + "Admin:InitialPassword must be set, with at least Auth:PasswordMinLength characters.") .ValidateOnStart(); + // No SignInManager: it pulls in cookie authentication. The credential store uses UserManager + // for hashing and lockout only (T2). + builder.Services.AddIdentityCore() + .AddEntityFrameworkStores() + .AddUserManager(); + builder.Services.AddOptions().Configure>((identity, auth) => + { + // Identity must never refuse a password the validator accepted (AUTH-16): length only. + identity.Password.RequireDigit = false; + identity.Password.RequireLowercase = false; + identity.Password.RequireUppercase = false; + identity.Password.RequireNonAlphanumeric = false; + identity.Password.RequiredUniqueChars = 1; + identity.Password.RequiredLength = auth.Value.PasswordMinLength; + identity.Lockout.AllowedForNewUsers = true; + identity.Lockout.MaxFailedAccessAttempts = auth.Value.LockoutMaxFailedAttempts; + identity.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(auth.Value.LockoutMinutes); + }); + builder.Services.AddDbContext((serviceProvider, options) => { - var adminEmail = serviceProvider.GetRequiredService>().Value.Email; + var admin = serviceProvider.GetRequiredService>().Value; options .UseNpgsql(builder.Configuration.GetConnectionString("controlservice")) .UseSnakeCaseNamingConvention() .AddInterceptors(serviceProvider.GetRequiredService()) - .UseSeeding((context, _) => SystemRecordsSeeder.Seed(context, adminEmail)) + .UseSeeding((context, _) => SystemRecordsSeeder.Seed(context, admin.Email, admin.InitialPassword)) .UseAsyncSeeding((context, _, cancellationToken) => - SystemRecordsSeeder.SeedAsync(context, adminEmail, cancellationToken)); + SystemRecordsSeeder.SeedAsync(context, admin.Email, admin.InitialPassword, cancellationToken)); }); // AddNpgsqlDbContext pools contexts, which does not fit a scoped interceptor (T7): the @@ -41,6 +81,10 @@ public static IHostApplicationBuilder AddInfrastructure(this IHostApplicationBui builder.EnrichNpgsqlDbContext(); builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); return builder; } diff --git a/backend/ControlService/src/ControlService.Infrastructure/Persistence/AdminOptions.cs b/backend/ControlService/src/ControlService.Infrastructure/Persistence/AdminOptions.cs index fe8bf1f..3148cf8 100644 --- a/backend/ControlService/src/ControlService.Infrastructure/Persistence/AdminOptions.cs +++ b/backend/ControlService/src/ControlService.Infrastructure/Persistence/AdminOptions.cs @@ -1,10 +1,13 @@ namespace ControlService.Infrastructure.Persistence; -/// The Admin's e-mail comes from configuration, read only when the Admin is first -/// created (USR-24, USR-33, ADR-0022). +/// The Admin's e-mail and initial password come from configuration, each read only when +/// the record it creates is missing (USR-24, USR-33, AUTH-13, ADR-0022). public sealed class AdminOptions { public const string SectionName = "Admin"; public required string Email { get; init; } + + /// Never logged, echoed or committed (AUTH-13): it lives in user secrets. + public required string InitialPassword { get; init; } } diff --git a/backend/ControlService/src/ControlService.Infrastructure/Persistence/AppDbContext.cs b/backend/ControlService/src/ControlService.Infrastructure/Persistence/AppDbContext.cs index af7ce58..13d5ad1 100644 --- a/backend/ControlService/src/ControlService.Infrastructure/Persistence/AppDbContext.cs +++ b/backend/ControlService/src/ControlService.Infrastructure/Persistence/AppDbContext.cs @@ -1,17 +1,32 @@ using ControlService.Domain.PermissionProfiles; using ControlService.Domain.Users; +using ControlService.Infrastructure.Auth; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; namespace ControlService.Infrastructure.Persistence; -public sealed class AppDbContext(DbContextOptions options) : DbContext(options) +/// The domain aggregates plus the Identity tables of the credentials, with no roles: +/// permissions are per screen (ADR-0020). +public sealed class AppDbContext(DbContextOptions options) : IdentityUserContext(options) { - public DbSet Users => Set(); + // Identity's base context already has a `Users` set, of credentials. The domain users keep the + // name the rest of the code base uses; the credentials are reached through `Set()`. + public new DbSet Users => Set(); public DbSet PermissionProfiles => Set(); - protected override void OnModelCreating(ModelBuilder modelBuilder) + public DbSet Sessions => Set(); + + protected override void OnModelCreating(ModelBuilder builder) { - modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly); + base.OnModelCreating(builder); + + builder.Entity>().ToTable("user_credential_claims"); + builder.Entity>().ToTable("user_credential_logins"); + builder.Entity>().ToTable("user_credential_tokens"); + + builder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly); } } diff --git a/backend/ControlService/src/ControlService.Infrastructure/Persistence/Configurations/UserCredentialConfiguration.cs b/backend/ControlService/src/ControlService.Infrastructure/Persistence/Configurations/UserCredentialConfiguration.cs new file mode 100644 index 0000000..1802986 --- /dev/null +++ b/backend/ControlService/src/ControlService.Infrastructure/Persistence/Configurations/UserCredentialConfiguration.cs @@ -0,0 +1,30 @@ +using ControlService.Domain.Users; +using ControlService.Infrastructure.Auth; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ControlService.Infrastructure.Persistence.Configurations; + +public sealed class UserCredentialConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("user_credentials"); + builder.Property(credential => credential.MustChangePassword).IsRequired(); + builder.HasIndex(credential => credential.NormalizedUserName).HasDatabaseName("ix_user_credentials_normalized_user_name").IsUnique(); + + // The credential belongs to a user and never outlives it (AUTH-19). + builder.HasOne().WithOne().HasForeignKey(credential => credential.Id).OnDelete(DeleteBehavior.Restrict); + + // Identity's satellite tables are required by its EF store but unused here; only their + // leftover default names (asp_net_users, EmailIndex) are replaced. + builder.HasIndex(credential => credential.NormalizedEmail).HasDatabaseName("ix_user_credentials_normalized_email"); + builder.HasMany>().WithOne().HasForeignKey(claim => claim.UserId) + .HasConstraintName("fk_user_credential_claims_user_credentials_user_id"); + builder.HasMany>().WithOne().HasForeignKey(login => login.UserId) + .HasConstraintName("fk_user_credential_logins_user_credentials_user_id"); + builder.HasMany>().WithOne().HasForeignKey(token => token.UserId) + .HasConstraintName("fk_user_credential_tokens_user_credentials_user_id"); + } +} diff --git a/backend/ControlService/src/ControlService.Infrastructure/Persistence/Configurations/UserSessionConfiguration.cs b/backend/ControlService/src/ControlService.Infrastructure/Persistence/Configurations/UserSessionConfiguration.cs new file mode 100644 index 0000000..3ceb4b1 --- /dev/null +++ b/backend/ControlService/src/ControlService.Infrastructure/Persistence/Configurations/UserSessionConfiguration.cs @@ -0,0 +1,19 @@ +using ControlService.Domain.Users; +using ControlService.Infrastructure.Auth; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ControlService.Infrastructure.Persistence.Configurations; + +public sealed class UserSessionConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("user_sessions"); + builder.HasKey(session => session.Id); + builder.Property(session => session.Id).ValueGeneratedNever(); + builder.Property(session => session.TokenHash).IsRequired(); + builder.HasIndex(session => session.TokenHash).IsUnique().HasDatabaseName("ix_user_sessions_token_hash"); + builder.HasOne().WithMany().HasForeignKey(session => session.UserId).OnDelete(DeleteBehavior.Restrict); + } +} diff --git a/backend/ControlService/src/ControlService.Infrastructure/Persistence/Migrations/20260930051103_AddAuthentication.Designer.cs b/backend/ControlService/src/ControlService.Infrastructure/Persistence/Migrations/20260930051103_AddAuthentication.Designer.cs new file mode 100644 index 0000000..e363b37 --- /dev/null +++ b/backend/ControlService/src/ControlService.Infrastructure/Persistence/Migrations/20260930051103_AddAuthentication.Designer.cs @@ -0,0 +1,539 @@ +// +using System; +using ControlService.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace ControlService.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260930051103_AddAuthentication")] + partial class AddAuthentication + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.12") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("ControlService.Domain.PermissionProfiles.PermissionProfile", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("IsSystem") + .HasColumnType("boolean") + .HasColumnName("is_system"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("NormalizedName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name_normalized"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasColumnName("updated_by"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_permission_profiles"); + + b.HasIndex("CreatedBy") + .HasDatabaseName("ix_permission_profiles_created_by"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("ix_permission_profiles_name_normalized"); + + b.HasIndex("UpdatedBy") + .HasDatabaseName("ix_permission_profiles_updated_by"); + + b.ToTable("permission_profiles", (string)null); + }); + + modelBuilder.Entity("ControlService.Domain.Users.User", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActivatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("activated_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("DeactivatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deactivated_at"); + + b.Property("DeactivatedBy") + .HasColumnType("uuid") + .HasColumnName("deactivated_by"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("display_name"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text") + .HasColumnName("email"); + + b.Property("FullName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("full_name"); + + b.Property("IsSystem") + .HasColumnType("boolean") + .HasColumnName("is_system"); + + b.Property("Login") + .IsRequired() + .HasColumnType("text") + .HasColumnName("login"); + + b.Property("NormalizedDisplayName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("display_name_normalized"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasColumnName("updated_by"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_users"); + + b.HasIndex("CreatedBy") + .HasDatabaseName("ix_users_created_by"); + + b.HasIndex("DeactivatedBy") + .HasDatabaseName("ix_users_deactivated_by"); + + b.HasIndex("Login") + .IsUnique() + .HasDatabaseName("ix_users_login"); + + b.HasIndex("NormalizedDisplayName") + .IsUnique() + .HasDatabaseName("ix_users_display_name_normalized"); + + b.HasIndex("UpdatedBy") + .HasDatabaseName("ix_users_updated_by"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("ControlService.Infrastructure.Auth.UserCredential", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AccessFailedCount") + .HasColumnType("integer") + .HasColumnName("access_failed_count"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text") + .HasColumnName("concurrency_stamp"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("email"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean") + .HasColumnName("email_confirmed"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean") + .HasColumnName("lockout_enabled"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone") + .HasColumnName("lockout_end"); + + b.Property("MustChangePassword") + .HasColumnType("boolean") + .HasColumnName("must_change_password"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("normalized_email"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("normalized_user_name"); + + b.Property("PasswordHash") + .HasColumnType("text") + .HasColumnName("password_hash"); + + b.Property("PhoneNumber") + .HasColumnType("text") + .HasColumnName("phone_number"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean") + .HasColumnName("phone_number_confirmed"); + + b.Property("SecurityStamp") + .HasColumnType("text") + .HasColumnName("security_stamp"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean") + .HasColumnName("two_factor_enabled"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("user_name"); + + b.HasKey("Id") + .HasName("pk_user_credentials"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("ix_user_credentials_normalized_email"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("ix_user_credentials_normalized_user_name"); + + b.ToTable("user_credentials", (string)null); + }); + + modelBuilder.Entity("ControlService.Infrastructure.Auth.UserSession", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("token_hash"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_user_sessions"); + + b.HasIndex("TokenHash") + .IsUnique() + .HasDatabaseName("ix_user_sessions_token_hash"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_user_sessions_user_id"); + + b.ToTable("user_sessions", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text") + .HasColumnName("claim_type"); + + b.Property("ClaimValue") + .HasColumnType("text") + .HasColumnName("claim_value"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_user_credential_claims"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_user_credential_claims_user_id"); + + b.ToTable("user_credential_claims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text") + .HasColumnName("login_provider"); + + b.Property("ProviderKey") + .HasColumnType("text") + .HasColumnName("provider_key"); + + b.Property("ProviderDisplayName") + .HasColumnType("text") + .HasColumnName("provider_display_name"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("LoginProvider", "ProviderKey") + .HasName("pk_user_credential_logins"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_user_credential_logins_user_id"); + + b.ToTable("user_credential_logins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("LoginProvider") + .HasColumnType("text") + .HasColumnName("login_provider"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Value") + .HasColumnType("text") + .HasColumnName("value"); + + b.HasKey("UserId", "LoginProvider", "Name") + .HasName("pk_user_credential_tokens"); + + b.ToTable("user_credential_tokens", (string)null); + }); + + modelBuilder.Entity("ControlService.Domain.PermissionProfiles.PermissionProfile", b => + { + b.HasOne("ControlService.Domain.Users.User", null) + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_permission_profiles_users_created_by"); + + b.HasOne("ControlService.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UpdatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_permission_profiles_users_updated_by"); + + b.OwnsMany("ControlService.Domain.PermissionProfiles.ScreenLevel", "_levels", b1 => + { + b1.Property("PermissionProfileId") + .HasColumnType("uuid") + .HasColumnName("permission_profile_id"); + + b1.Property("Screen") + .HasColumnType("text") + .HasColumnName("screen_key"); + + b1.Property("Level") + .HasColumnType("smallint") + .HasColumnName("level"); + + b1.HasKey("PermissionProfileId", "Screen") + .HasName("pk_permission_profile_levels"); + + b1.ToTable("permission_profile_levels", (string)null); + + b1.WithOwner() + .HasForeignKey("PermissionProfileId") + .HasConstraintName("fk_permission_profile_levels_permission_profiles_permission_pr"); + }); + + b.Navigation("_levels"); + }); + + modelBuilder.Entity("ControlService.Domain.Users.User", b => + { + b.HasOne("ControlService.Domain.Users.User", null) + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_users_users_created_by"); + + b.HasOne("ControlService.Domain.Users.User", null) + .WithMany() + .HasForeignKey("DeactivatedBy") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_users_users_deactivated_by"); + + b.HasOne("ControlService.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UpdatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_users_users_updated_by"); + + b.OwnsMany("ControlService.Domain.Users.ProfileAssignment", "_profileAssignments", b1 => + { + b1.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b1.Property("ProfileId") + .HasColumnType("uuid") + .HasColumnName("profile_id"); + + b1.HasKey("UserId", "ProfileId") + .HasName("pk_user_permission_profiles"); + + b1.HasIndex("ProfileId") + .HasDatabaseName("ix_user_permission_profiles_profile_id"); + + b1.ToTable("user_permission_profiles", (string)null); + + b1.HasOne("ControlService.Domain.PermissionProfiles.PermissionProfile", null) + .WithMany() + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_user_permission_profiles_permission_profiles_profile_id"); + + b1.WithOwner() + .HasForeignKey("UserId") + .HasConstraintName("fk_user_permission_profiles_users_user_id"); + }); + + b.Navigation("_profileAssignments"); + }); + + modelBuilder.Entity("ControlService.Infrastructure.Auth.UserCredential", b => + { + b.HasOne("ControlService.Domain.Users.User", null) + .WithOne() + .HasForeignKey("ControlService.Infrastructure.Auth.UserCredential", "Id") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_user_credentials_users_id"); + }); + + modelBuilder.Entity("ControlService.Infrastructure.Auth.UserSession", b => + { + b.HasOne("ControlService.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_user_sessions_users_user_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("ControlService.Infrastructure.Auth.UserCredential", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_credential_claims_user_credentials_user_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("ControlService.Infrastructure.Auth.UserCredential", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_credential_logins_user_credentials_user_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("ControlService.Infrastructure.Auth.UserCredential", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_credential_tokens_user_credentials_user_id"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/ControlService/src/ControlService.Infrastructure/Persistence/Migrations/20260930051103_AddAuthentication.cs b/backend/ControlService/src/ControlService.Infrastructure/Persistence/Migrations/20260930051103_AddAuthentication.cs new file mode 100644 index 0000000..9e01636 --- /dev/null +++ b/backend/ControlService/src/ControlService.Infrastructure/Persistence/Migrations/20260930051103_AddAuthentication.cs @@ -0,0 +1,180 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace ControlService.Infrastructure.Persistence.Migrations; + +/// +public partial class AddAuthentication : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "user_credentials", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + must_change_password = table.Column(type: "boolean", nullable: false), + user_name = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + normalized_user_name = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + email = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + normalized_email = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + email_confirmed = table.Column(type: "boolean", nullable: false), + password_hash = table.Column(type: "text", nullable: true), + security_stamp = table.Column(type: "text", nullable: true), + concurrency_stamp = table.Column(type: "text", nullable: true), + phone_number = table.Column(type: "text", nullable: true), + phone_number_confirmed = table.Column(type: "boolean", nullable: false), + two_factor_enabled = table.Column(type: "boolean", nullable: false), + lockout_end = table.Column(type: "timestamp with time zone", nullable: true), + lockout_enabled = table.Column(type: "boolean", nullable: false), + access_failed_count = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_user_credentials", x => x.id); + table.ForeignKey( + name: "fk_user_credentials_users_id", + column: x => x.id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "user_sessions", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + user_id = table.Column(type: "uuid", nullable: false), + token_hash = table.Column(type: "bytea", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + expires_at = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_user_sessions", x => x.id); + table.ForeignKey( + name: "fk_user_sessions_users_user_id", + column: x => x.user_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "user_credential_claims", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + user_id = table.Column(type: "uuid", nullable: false), + claim_type = table.Column(type: "text", nullable: true), + claim_value = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_user_credential_claims", x => x.id); + table.ForeignKey( + name: "fk_user_credential_claims_user_credentials_user_id", + column: x => x.user_id, + principalTable: "user_credentials", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "user_credential_logins", + columns: table => new + { + login_provider = table.Column(type: "text", nullable: false), + provider_key = table.Column(type: "text", nullable: false), + provider_display_name = table.Column(type: "text", nullable: true), + user_id = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_user_credential_logins", x => new { x.login_provider, x.provider_key }); + table.ForeignKey( + name: "fk_user_credential_logins_user_credentials_user_id", + column: x => x.user_id, + principalTable: "user_credentials", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "user_credential_tokens", + columns: table => new + { + user_id = table.Column(type: "uuid", nullable: false), + login_provider = table.Column(type: "text", nullable: false), + name = table.Column(type: "text", nullable: false), + value = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_user_credential_tokens", x => new { x.user_id, x.login_provider, x.name }); + table.ForeignKey( + name: "fk_user_credential_tokens_user_credentials_user_id", + column: x => x.user_id, + principalTable: "user_credentials", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "ix_user_credential_claims_user_id", + table: "user_credential_claims", + column: "user_id"); + + migrationBuilder.CreateIndex( + name: "ix_user_credential_logins_user_id", + table: "user_credential_logins", + column: "user_id"); + + migrationBuilder.CreateIndex( + name: "ix_user_credentials_normalized_email", + table: "user_credentials", + column: "normalized_email"); + + migrationBuilder.CreateIndex( + name: "ix_user_credentials_normalized_user_name", + table: "user_credentials", + column: "normalized_user_name", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_user_sessions_token_hash", + table: "user_sessions", + column: "token_hash", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_user_sessions_user_id", + table: "user_sessions", + column: "user_id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "user_credential_claims"); + + migrationBuilder.DropTable( + name: "user_credential_logins"); + + migrationBuilder.DropTable( + name: "user_credential_tokens"); + + migrationBuilder.DropTable( + name: "user_sessions"); + + migrationBuilder.DropTable( + name: "user_credentials"); + } +} diff --git a/backend/ControlService/src/ControlService.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs b/backend/ControlService/src/ControlService.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs index fc7b49e..fa2e13a 100644 --- a/backend/ControlService/src/ControlService.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/ControlService/src/ControlService.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs @@ -182,6 +182,207 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("users", (string)null); }); + modelBuilder.Entity("ControlService.Infrastructure.Auth.UserCredential", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AccessFailedCount") + .HasColumnType("integer") + .HasColumnName("access_failed_count"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text") + .HasColumnName("concurrency_stamp"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("email"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean") + .HasColumnName("email_confirmed"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean") + .HasColumnName("lockout_enabled"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone") + .HasColumnName("lockout_end"); + + b.Property("MustChangePassword") + .HasColumnType("boolean") + .HasColumnName("must_change_password"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("normalized_email"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("normalized_user_name"); + + b.Property("PasswordHash") + .HasColumnType("text") + .HasColumnName("password_hash"); + + b.Property("PhoneNumber") + .HasColumnType("text") + .HasColumnName("phone_number"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean") + .HasColumnName("phone_number_confirmed"); + + b.Property("SecurityStamp") + .HasColumnType("text") + .HasColumnName("security_stamp"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean") + .HasColumnName("two_factor_enabled"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("user_name"); + + b.HasKey("Id") + .HasName("pk_user_credentials"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("ix_user_credentials_normalized_email"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("ix_user_credentials_normalized_user_name"); + + b.ToTable("user_credentials", (string)null); + }); + + modelBuilder.Entity("ControlService.Infrastructure.Auth.UserSession", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("token_hash"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_user_sessions"); + + b.HasIndex("TokenHash") + .IsUnique() + .HasDatabaseName("ix_user_sessions_token_hash"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_user_sessions_user_id"); + + b.ToTable("user_sessions", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text") + .HasColumnName("claim_type"); + + b.Property("ClaimValue") + .HasColumnType("text") + .HasColumnName("claim_value"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_user_credential_claims"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_user_credential_claims_user_id"); + + b.ToTable("user_credential_claims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text") + .HasColumnName("login_provider"); + + b.Property("ProviderKey") + .HasColumnType("text") + .HasColumnName("provider_key"); + + b.Property("ProviderDisplayName") + .HasColumnType("text") + .HasColumnName("provider_display_name"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("LoginProvider", "ProviderKey") + .HasName("pk_user_credential_logins"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_user_credential_logins_user_id"); + + b.ToTable("user_credential_logins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("LoginProvider") + .HasColumnType("text") + .HasColumnName("login_provider"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Value") + .HasColumnType("text") + .HasColumnName("value"); + + b.HasKey("UserId", "LoginProvider", "Name") + .HasName("pk_user_credential_tokens"); + + b.ToTable("user_credential_tokens", (string)null); + }); + modelBuilder.Entity("ControlService.Domain.PermissionProfiles.PermissionProfile", b => { b.HasOne("ControlService.Domain.Users.User", null) @@ -279,6 +480,56 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("_profileAssignments"); }); + + modelBuilder.Entity("ControlService.Infrastructure.Auth.UserCredential", b => + { + b.HasOne("ControlService.Domain.Users.User", null) + .WithOne() + .HasForeignKey("ControlService.Infrastructure.Auth.UserCredential", "Id") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_user_credentials_users_id"); + }); + + modelBuilder.Entity("ControlService.Infrastructure.Auth.UserSession", b => + { + b.HasOne("ControlService.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_user_sessions_users_user_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("ControlService.Infrastructure.Auth.UserCredential", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_credential_claims_user_credentials_user_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("ControlService.Infrastructure.Auth.UserCredential", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_credential_logins_user_credentials_user_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("ControlService.Infrastructure.Auth.UserCredential", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_credential_tokens_user_credentials_user_id"); + }); #pragma warning restore 612, 618 } } diff --git a/backend/ControlService/src/ControlService.Infrastructure/Persistence/PermissionProfileRepository.cs b/backend/ControlService/src/ControlService.Infrastructure/Persistence/PermissionProfileRepository.cs new file mode 100644 index 0000000..fa61970 --- /dev/null +++ b/backend/ControlService/src/ControlService.Infrastructure/Persistence/PermissionProfileRepository.cs @@ -0,0 +1,17 @@ +using ControlService.Application.PermissionProfiles; +using ControlService.Domain.PermissionProfiles; +using Microsoft.EntityFrameworkCore; + +namespace ControlService.Infrastructure.Persistence; + +public sealed class PermissionProfileRepository(AppDbContext dbContext) : IPermissionProfileRepository +{ + public async Task> ListByIdsAsync(IEnumerable ids, CancellationToken cancellationToken) + { + var idList = ids.ToArray(); + return await dbContext.PermissionProfiles + .AsNoTracking() + .Where(profile => idList.Contains(profile.Id)) + .ToListAsync(cancellationToken); + } +} diff --git a/backend/ControlService/src/ControlService.Infrastructure/Persistence/SystemRecordsSeeder.cs b/backend/ControlService/src/ControlService.Infrastructure/Persistence/SystemRecordsSeeder.cs index 2628d69..352a66c 100644 --- a/backend/ControlService/src/ControlService.Infrastructure/Persistence/SystemRecordsSeeder.cs +++ b/backend/ControlService/src/ControlService.Infrastructure/Persistence/SystemRecordsSeeder.cs @@ -1,42 +1,72 @@ using ControlService.Domain.Common; using ControlService.Domain.PermissionProfiles; using ControlService.Domain.Users; +using ControlService.Infrastructure.Auth; +using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; namespace ControlService.Infrastructure.Persistence; -/// Inserts the Admin and the Gerenciador profile only when missing, so restarting the -/// application never duplicates or changes them (ADR-0022, D2, D7: the Admin comes first, since -/// the profile's authorship references it). +/// Inserts the Admin, its credential and the Gerenciador profile only when missing, so +/// restarting the application never duplicates or changes them (ADR-0022, D2, D7: the Admin comes +/// first, since the profile's authorship references it). The credential is checked apart from the +/// Admin row, so databases created before authentication get it too (T3). public static class SystemRecordsSeeder { - public static void Seed(DbContext context, string adminEmail) + public static void Seed(DbContext context, string adminEmail, string adminInitialPassword) { var db = (AppDbContext)context; - AddMissingRecords(db, adminEmail, db.Users.Any(user => user.Id == SystemIds.AdminUser), + AddMissingRecords(db, adminEmail, adminInitialPassword, + db.Users.Any(user => user.Id == SystemIds.AdminUser), + db.Set().Any(credential => credential.Id == SystemIds.AdminUser), db.PermissionProfiles.Any(profile => profile.Id == SystemIds.ManagerProfile)); db.SaveChanges(); } - public static async Task SeedAsync(DbContext context, string adminEmail, CancellationToken cancellationToken) + public static async Task SeedAsync( + DbContext context, string adminEmail, string adminInitialPassword, CancellationToken cancellationToken) { var db = (AppDbContext)context; - AddMissingRecords(db, adminEmail, + AddMissingRecords(db, adminEmail, adminInitialPassword, await db.Users.AnyAsync(user => user.Id == SystemIds.AdminUser, cancellationToken), + await db.Set().AnyAsync(credential => credential.Id == SystemIds.AdminUser, cancellationToken), await db.PermissionProfiles.AnyAsync(profile => profile.Id == SystemIds.ManagerProfile, cancellationToken)); await db.SaveChangesAsync(cancellationToken); } - private static void AddMissingRecords(AppDbContext db, string adminEmail, bool hasAdmin, bool hasManagerProfile) + private static void AddMissingRecords( + AppDbContext db, string adminEmail, string adminInitialPassword, + bool hasAdmin, bool hasAdminCredential, bool hasManagerProfile) { if (!hasAdmin) { db.Users.Add(User.CreateAdmin(EmailAddress.Create(adminEmail).Value)); } + if (!hasAdminCredential) + { + db.Set().Add(CreateAdminCredential(adminInitialPassword)); + } + if (!hasManagerProfile) { db.PermissionProfiles.Add(PermissionProfile.CreateManagerProfile()); } } + + private static UserCredential CreateAdminCredential(string initialPassword) + { + var id = SystemIds.AdminUser.ToString(); + var credential = new UserCredential + { + Id = SystemIds.AdminUser, + UserName = id, + NormalizedUserName = id.ToUpperInvariant(), + SecurityStamp = Guid.NewGuid().ToString(), + LockoutEnabled = true, + MustChangePassword = true, + }; + credential.PasswordHash = new PasswordHasher().HashPassword(credential, initialPassword); + return credential; + } } diff --git a/backend/ControlService/src/ControlService.Infrastructure/Persistence/UserRepository.cs b/backend/ControlService/src/ControlService.Infrastructure/Persistence/UserRepository.cs new file mode 100644 index 0000000..595aec6 --- /dev/null +++ b/backend/ControlService/src/ControlService.Infrastructure/Persistence/UserRepository.cs @@ -0,0 +1,14 @@ +using ControlService.Application.Users; +using ControlService.Domain.Users; +using Microsoft.EntityFrameworkCore; + +namespace ControlService.Infrastructure.Persistence; + +public sealed class UserRepository(AppDbContext dbContext) : IUserRepository +{ + public Task GetByIdAsync(Guid id, CancellationToken cancellationToken) => + dbContext.Users.SingleOrDefaultAsync(user => user.Id == id, cancellationToken); + + public Task GetByLoginAsync(Login login, CancellationToken cancellationToken) => + dbContext.Users.SingleOrDefaultAsync(user => user.Login == login, cancellationToken); +} diff --git a/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/AdminCredentialTests.cs b/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/AdminCredentialTests.cs new file mode 100644 index 0000000..a40047c --- /dev/null +++ b/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/AdminCredentialTests.cs @@ -0,0 +1,73 @@ +using ControlService.Api.IntegrationTests.Common; +using ControlService.Domain.Common; +using ControlService.Infrastructure.Auth; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace ControlService.Api.IntegrationTests.Auth; + +public sealed class AdminCredentialTests(ApiFactory factory) : IClassFixture +{ + [Fact] + public async Task Startup_creates_the_admin_credential_with_a_mandatory_change() // AUTH-13, ADR-0022 + { + // The first host already created it; removing it lets the next start prove it is created when missing. + await AuthTestSupport.DeleteAdminCredentialAsync(factory.Services); + + using var restarted = factory.WithWebHostBuilder(_ => { }); + await using var scope = restarted.Services.CreateAsyncScope(); + var users = scope.ServiceProvider.GetRequiredService>(); + + var credential = await users.FindByIdAsync(SystemIds.AdminUser.ToString()); + + credential.ShouldNotBeNull(); + (await users.CheckPasswordAsync(credential, ApiFactory.AdminInitialPassword)).ShouldBeTrue(); + credential.MustChangePassword.ShouldBeTrue(); + credential.UserName.ShouldBe(SystemIds.AdminUser.ToString()); + } + + [Fact] + public async Task Restarting_does_not_change_the_admin_credential() // AUTH-13, T3 + { + await AuthTestSupport.DeleteAdminCredentialAsync(factory.Services); + using var first = factory.WithWebHostBuilder(_ => { }); + _ = first.Services; + + using var restarted = factory.WithWebHostBuilder(builder => + builder.ConfigureAppConfiguration((_, config) => config.AddInMemoryCollection(new Dictionary + { + ["Admin:InitialPassword"] = "outra-senha-inicial", + }))); + await using var scope = restarted.Services.CreateAsyncScope(); + var users = scope.ServiceProvider.GetRequiredService>(); + + var credential = await users.FindByIdAsync(SystemIds.AdminUser.ToString()); + + credential.ShouldNotBeNull(); + (await users.CheckPasswordAsync(credential, ApiFactory.AdminInitialPassword)).ShouldBeTrue(); + (await users.CheckPasswordAsync(credential, "outra-senha-inicial")).ShouldBeFalse(); + } + + [Theory] + [InlineData(null)] // the user secret was never set + [InlineData("")] + [InlineData("1234567")] + public void Api_does_not_start_without_a_valid_initial_password(string? invalidPassword) // AUTH-13, AUTH-16 + { + using var invalid = factory.WithWebHostBuilder(builder => + builder.ConfigureAppConfiguration((_, config) => config.AddInMemoryCollection(new Dictionary + { + ["Admin:InitialPassword"] = invalidPassword, + }))); + + var exception = Should.Throw(() => invalid.Services); + + exception.Message.ShouldContain("Admin:InitialPassword"); + if (!string.IsNullOrEmpty(invalidPassword)) + { + exception.Message.ShouldNotContain(invalidPassword); + } + } +} diff --git a/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/AdminFirstAccessApiTests.cs b/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/AdminFirstAccessApiTests.cs new file mode 100644 index 0000000..d0ee949 --- /dev/null +++ b/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/AdminFirstAccessApiTests.cs @@ -0,0 +1,150 @@ +using System.Net; +using ControlService.Api.IntegrationTests.Common; +using ControlService.Domain.Common; +using ControlService.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.IdentityModel.JsonWebTokens; + +namespace ControlService.Api.IntegrationTests.Auth; + +public sealed class AdminFirstAccessApiTests(AuthApiFactory factory) : IClassFixture, IAsyncLifetime +{ + public ValueTask InitializeAsync() => ValueTask.CompletedTask; + + // The Admin is one row in a database shared by the whole assembly: whatever a test did to it is undone, + // so the tests that expect it as seeded (StartupSeedTests) pass in any order. + public async ValueTask DisposeAsync() => await AuthTestSupport.ResetAdminAsync(factory.Services); + + private const string NewPassword = "nova-senha-admin-1"; + private const string ChangeRequired = "Por segurança, a senha inicial precisa ser trocada no primeiro acesso."; + + [Fact] + public async Task Admin_with_the_initial_password_signs_in_with_must_change_password() // AUTH-13, AUTH-14 + { + await AuthTestSupport.ResetAdminAsync(factory.Services); + using var client = factory.CreateHttpsClient(); + + using var response = await client.SignInAsync("admin", ApiFactory.AdminInitialPassword); + + response.StatusCode.ShouldBe(HttpStatusCode.OK); + var body = await response.ReadJsonAsync(); + body.GetProperty("mustChangePassword").GetBoolean().ShouldBeTrue(); + var token = new JsonWebTokenHandler().ReadJsonWebToken(body.GetProperty("accessToken").GetString()); + token.GetClaim("must_change_password").Value.ShouldBe("true"); + } + + [Fact] + public async Task Admin_with_the_initial_password_gets_401_password_change_required_on_another_endpoint() // AUTH-14, AUTH-27, ADR-0032 + { + await AuthTestSupport.ResetAdminAsync(factory.Services); + using var client = factory.CreateHttpsClient(); + var token = await SignInAsAdminAsync(client); + + using var response = await client.GetWithTokenAsync(AuthApiFactory.ProtectedPath, token); + + await response.ShouldBeProblemAsync(HttpStatusCode.Unauthorized, "password_change_required", ChangeRequired); + } + + [Fact] + public async Task Admin_with_the_initial_password_can_use_me_and_sign_out() // ADR-0032 + { + await AuthTestSupport.ResetAdminAsync(factory.Services); + using var client = factory.CreateHttpsClient(); + var token = await SignInAsAdminAsync(client); + + using var me = await client.GetWithTokenAsync("/api/v1/me", token); + using var signOut = await client.PostWithTokenAsync("/api/v1/auth/sign-out", token); + + me.StatusCode.ShouldBe(HttpStatusCode.OK); + signOut.StatusCode.ShouldBe(HttpStatusCode.NoContent); + } + + [Fact] + public async Task Change_password_returns_new_tokens_and_unlocks_other_endpoints() // AUTH-14, USR-34, D3 of #7 + { + await AuthTestSupport.ResetAdminAsync(factory.Services); + using var client = factory.CreateHttpsClient(); + var oldToken = await SignInAsAdminAsync(client); + + using var changed = await client.PostWithTokenAsync( + "/api/v1/auth/change-password", oldToken, new { password = NewPassword, passwordConfirmation = NewPassword }); + + changed.StatusCode.ShouldBe(HttpStatusCode.OK); + var body = await changed.ReadJsonAsync(); + body.GetProperty("mustChangePassword").GetBoolean().ShouldBeFalse(); + changed.RefreshCookie().ShouldNotBeNull(); + var newToken = body.GetProperty("accessToken").GetString(); + + using var protectedCall = await client.GetWithTokenAsync(AuthApiFactory.ProtectedPath, newToken); + protectedCall.StatusCode.ShouldBe(HttpStatusCode.OK); + using var oldPassword = await client.SignInAsync("admin", ApiFactory.AdminInitialPassword); + oldPassword.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); + using var newPassword = await client.SignInAsync("admin", NewPassword); + newPassword.StatusCode.ShouldBe(HttpStatusCode.OK); + + await using var scope = factory.Services.CreateAsyncScope(); + var admin = await scope.ServiceProvider.GetRequiredService().Users + .AsNoTracking().SingleAsync(user => user.Id == SystemIds.AdminUser, TestContext.Current.CancellationToken); + admin.ActivatedAt.ShouldBe(factory.Clock.GetUtcNow()); + admin.UpdatedBy.ShouldBe(SystemIds.AdminUser); + } + + [Fact] + public async Task Change_password_ends_the_other_sessions() // ADR-0019 + { + await AuthTestSupport.ResetAdminAsync(factory.Services); + using var client = factory.CreateHttpsClient(); + using var other = await client.SignInAsync("admin", ApiFactory.AdminInitialPassword); + var otherCookie = other.RefreshCookie()!.Value.Value; + var token = await SignInAsAdminAsync(client); + + using var changed = await client.PostWithTokenAsync( + "/api/v1/auth/change-password", token, new { password = NewPassword, passwordConfirmation = NewPassword }); + changed.StatusCode.ShouldBe(HttpStatusCode.OK); + + using var refreshed = await client.RefreshAsync(otherCookie); + refreshed.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); + (await refreshed.ReadJsonAsync()).GetProperty("code").GetString().ShouldBe("session_expired"); + } + + [Fact] + public async Task Change_password_with_invalid_fields_returns_400_with_field_messages() // AUTH-16, API-15 + { + await AuthTestSupport.ResetAdminAsync(factory.Services); + using var client = factory.CreateHttpsClient(); + var token = await SignInAsAdminAsync(client); + + using var response = await client.PostWithTokenAsync( + "/api/v1/auth/change-password", token, new { password = "1234567", passwordConfirmation = "7654321" }); + + var problem = await response.ShouldBeProblemAsync( + HttpStatusCode.BadRequest, "validation_failed", "Alguns campos precisam ser corrigidos."); + var errors = problem.GetProperty("errors"); + errors.GetProperty("password").EnumerateArray().Select(message => message.GetString()) + .ShouldBe(["A senha precisa ter pelo menos 8 caracteres."]); + errors.GetProperty("passwordConfirmation").EnumerateArray().Select(message => message.GetString()) + .ShouldBe(["As duas senhas não são iguais. Digite de novo."]); + } + + [Fact] + public async Task Change_password_without_a_mandatory_change_returns_403_forbidden() // AUTH-24 + { + var user = await AuthTestSupport.CreateUserAsync(factory.Services); + using var client = factory.CreateHttpsClient(); + var token = await client.SignInForTokenAsync(user); + + using var response = await client.PostWithTokenAsync( + "/api/v1/auth/change-password", token, new { password = NewPassword, passwordConfirmation = NewPassword }); + + await response.ShouldBeProblemAsync( + HttpStatusCode.Forbidden, "forbidden", "Sua senha já foi criada. Para trocá-la, use “Esqueci minha senha”."); + } + + private static async Task SignInAsAdminAsync(HttpClient client) + { + using var response = await client.SignInAsync("admin", ApiFactory.AdminInitialPassword); + response.StatusCode.ShouldBe(HttpStatusCode.OK); + return (await response.ReadJsonAsync()).GetProperty("accessToken").GetString()!; + } +} diff --git a/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/AuthApiFactory.cs b/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/AuthApiFactory.cs new file mode 100644 index 0000000..85d64d5 --- /dev/null +++ b/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/AuthApiFactory.cs @@ -0,0 +1,54 @@ +using ControlService.Api.IntegrationTests.Common; +using ControlService.API.Common; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace ControlService.Api.IntegrationTests.Auth; + +/// The real API with a clock the test controls (sessions and tokens expire with it) and rate +/// limits high enough that no other test hits them (T12). The refresh cookie is `Secure`, so the +/// clients of this factory talk to `https://localhost`. +public class AuthApiFactory(PostgresContainerFixture postgres) : ApiFactory(postgres) +{ + public const string ProtectedPath = "/api/v1/test-only/protected"; + + public FixedTimeProvider Clock { get; } = new(new DateTimeOffset(2026, 9, 30, 12, 0, 0, TimeSpan.Zero)); + + /// A client over `https://localhost`, so the `Secure` cookie is honoured. Cookies are handled by + /// the tests themselves, which need to read `Set-Cookie` and to send several sessions' cookies. + public HttpClient CreateHttpsClient() => CreateClient(new WebApplicationFactoryClientOptions + { + BaseAddress = new Uri("https://localhost"), + HandleCookies = false, + AllowAutoRedirect = false, + }); + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + base.ConfigureWebHost(builder); + builder.ConfigureServices(services => + { + services.RemoveAll(); + services.AddSingleton(Clock); + services.AddSingleton(); + }); + } + + // Stands for "any other endpoint" while #8 has no other authenticated route (T8, T12). It is mapped + // through MapApiV1() like a real one. A startup filter gets a plain ApplicationBuilder, so it needs its + // own routing, and authorization must run after that routing selects the endpoint. + private sealed class TestOnlyEndpoints : IStartupFilter + { + public Action Configure(Action next) => app => + { + next(app); + app.UseRouting(); + app.UseAuthentication(); + app.UseAuthorization(); + app.UseEndpoints(endpoints => endpoints.MapApiV1().MapGet("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/test-only/protected", () => "ok")); + }; + } +} diff --git a/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/AuthHttp.cs b/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/AuthHttp.cs new file mode 100644 index 0000000..8688153 --- /dev/null +++ b/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/AuthHttp.cs @@ -0,0 +1,78 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.Net.Http.Headers; + +namespace ControlService.Api.IntegrationTests.Auth; + +/// The calls and readings of the authentication routes that many tests repeat. +internal static class AuthHttp +{ + public const string RefreshCookieName = "__Secure-cs-refresh"; + public const string RefreshPath = "/api/v1/auth/refresh"; + + public static Task SignInAsync(this HttpClient client, string login, string password) => + client.PostAsJsonAsync("/api/v1/auth/sign-in", new { login, password }, TestContext.Current.CancellationToken); + + public static async Task SignInForTokenAsync(this HttpClient client, TestUser user) + { + using var response = await client.SignInAsync(user.Login, user.Password); + response.StatusCode.ShouldBe(HttpStatusCode.OK); + return (await response.ReadJsonAsync()).GetProperty("accessToken").GetString()!; + } + + public static async Task GetWithTokenAsync(this HttpClient client, string path, string? accessToken) + { + using var request = new HttpRequestMessage(HttpMethod.Get, path); + if (accessToken is not null) + { + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + } + + return await client.SendAsync(request, TestContext.Current.CancellationToken); + } + + /// Calls refresh with the given cookie value, or with no cookie when null. + public static async Task RefreshAsync(this HttpClient client, string? cookieValue) + { + using var request = new HttpRequestMessage(HttpMethod.Post, RefreshPath); + if (cookieValue is not null) + { + request.Headers.Add(HeaderNames.Cookie, $"{RefreshCookieName}={cookieValue}"); + } + + return await client.SendAsync(request, TestContext.Current.CancellationToken); + } + + public static async Task PostWithTokenAsync(this HttpClient client, string path, string accessToken, object? body = null) + { + using var request = new HttpRequestMessage(HttpMethod.Post, path); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + if (body is not null) + { + request.Content = JsonContent.Create(body); + } + + return await client.SendAsync(request, TestContext.Current.CancellationToken); + } + + public static async Task ReadJsonAsync(this HttpResponseMessage response) => + await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + + /// Asserts the status and the `code` and `message` of a Problem Details response (API-12). + public static async Task ShouldBeProblemAsync( + this HttpResponseMessage response, HttpStatusCode status, string code, string message) + { + response.StatusCode.ShouldBe(status); + var problem = await response.ReadJsonAsync(); + problem.GetProperty("code").GetString().ShouldBe(code); + problem.GetProperty("message").GetString().ShouldBe(message); + return problem; + } + + public static SetCookieHeaderValue? RefreshCookie(this HttpResponseMessage response) => + response.Headers.TryGetValues(HeaderNames.SetCookie, out var values) + ? values.Select(value => SetCookieHeaderValue.Parse(value)).FirstOrDefault(cookie => cookie.Name == RefreshCookieName) + : null; +} diff --git a/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/AuthLoggingTests.cs b/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/AuthLoggingTests.cs new file mode 100644 index 0000000..cb8610c --- /dev/null +++ b/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/AuthLoggingTests.cs @@ -0,0 +1,65 @@ +using System.Net; +using ControlService.Api.IntegrationTests.Common; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace ControlService.Api.IntegrationTests.Auth; + +public sealed class AuthLoggingTests(AuthApiFactory factory) : IClassFixture, IAsyncLifetime +{ + public ValueTask InitializeAsync() => ValueTask.CompletedTask; + + // The Admin is one row in a database shared by the whole assembly: whatever a test did to it is undone, + // so the tests that expect it as seeded (StartupSeedTests) pass in any order. + public async ValueTask DisposeAsync() => await AuthTestSupport.ResetAdminAsync(factory.Services); + + private const string NewPassword = "nova-senha-admin-1"; + + [Fact] + public async Task Authentication_writes_no_password_or_token_to_the_logs() // AUTH-20, issue Definition of Done + { + await AuthTestSupport.ResetAdminAsync(factory.Services); + var logs = new CapturingLoggerProvider(); + using var logged = factory.WithWebHostBuilder(builder => builder.ConfigureLogging(logging => + { + // A rule of this provider wins over the appsettings rules (Default: Information), which a + // minimum level would not override. + logging.AddFilter(category: null, LogLevel.Trace); + logging.Services.AddSingleton(logs); + })); + using var client = logged.CreateClient(new Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactoryClientOptions + { + BaseAddress = new Uri("https://localhost"), + HandleCookies = false, + }); + + using var signIn = await client.SignInAsync("admin", ApiFactory.AdminInitialPassword); + signIn.StatusCode.ShouldBe(HttpStatusCode.OK); + var signInToken = (await signIn.ReadJsonAsync()).GetProperty("accessToken").GetString()!; + var signInCookie = signIn.RefreshCookie()!.Value.Value!; + + using var refresh = await client.RefreshAsync(signInCookie); + refresh.StatusCode.ShouldBe(HttpStatusCode.OK); + var refreshToken = (await refresh.ReadJsonAsync()).GetProperty("accessToken").GetString()!; + var refreshCookie = refresh.RefreshCookie()!.Value.Value!; + + using var change = await client.PostWithTokenAsync( + "/api/v1/auth/change-password", refreshToken, new { password = NewPassword, passwordConfirmation = NewPassword }); + change.StatusCode.ShouldBe(HttpStatusCode.OK); + var changeToken = (await change.ReadJsonAsync()).GetProperty("accessToken").GetString()!; + var changeCookie = change.RefreshCookie()!.Value.Value!; + + // Below Information is where frameworks write their diagnostics: the capture must really reach it. + logs.Lines.ShouldContain(line => line.StartsWith($"{LogLevel.Debug} ", StringComparison.Ordinal)); + string[] secrets = + [ + ApiFactory.AdminInitialPassword, NewPassword, ApiFactory.SigningKey, + signInToken, signInCookie, refreshToken, refreshCookie, changeToken, changeCookie, + ]; + foreach (var secret in secrets) + { + logs.Lines.ShouldNotContain(line => line.Contains(secret, StringComparison.Ordinal)); + } + } +} diff --git a/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/AuthTestSupport.cs b/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/AuthTestSupport.cs new file mode 100644 index 0000000..424c9a6 --- /dev/null +++ b/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/AuthTestSupport.cs @@ -0,0 +1,92 @@ +using ControlService.Api.IntegrationTests.Common; +using ControlService.Domain.Access; +using ControlService.Domain.Common; +using ControlService.Domain.PermissionProfiles; +using ControlService.Domain.Users; +using ControlService.Infrastructure.Auth; +using ControlService.Infrastructure.Persistence; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace ControlService.Api.IntegrationTests.Auth; + +/// Helpers that reach the database directly. The Admin is one row in a database shared by +/// the whole assembly, so tests that change its credential put it back first. +internal static class AuthTestSupport +{ + public const string UserPassword = "senha-de-teste-1"; + + public static async Task DeleteAdminCredentialAsync(IServiceProvider services) + { + await using var scope = services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await db.Set().Where(session => session.UserId == SystemIds.AdminUser).ExecuteDeleteAsync(); + await db.Set().Where(credential => credential.Id == SystemIds.AdminUser).ExecuteDeleteAsync(); + } + + /// Puts the Admin back as the seeder creates it: initial password, mandatory change, no lockout, + /// no sessions and no activation time. The Admin is one row shared by the whole assembly. + public static async Task ResetAdminAsync(IServiceProvider services) + { + await using var scope = services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var credential = await db.Set().SingleAsync(candidate => candidate.Id == SystemIds.AdminUser); + var hash = new PasswordHasher().HashPassword(credential, ApiFactory.AdminInitialPassword); + + await db.Set().Where(session => session.UserId == SystemIds.AdminUser).ExecuteDeleteAsync(); + await db.Set().Where(candidate => candidate.Id == SystemIds.AdminUser).ExecuteUpdateAsync(setters => setters + .SetProperty(candidate => candidate.PasswordHash, hash) + .SetProperty(candidate => candidate.MustChangePassword, true) + .SetProperty(candidate => candidate.AccessFailedCount, 0) + .SetProperty(candidate => candidate.LockoutEnd, (DateTimeOffset?)null)); + await db.Users.Where(user => user.Id == SystemIds.AdminUser) + .ExecuteUpdateAsync(setters => setters.SetProperty(user => user.ActivatedAt, (DateTimeOffset?)null)); + } + + public static async Task DeactivateUserAsync(IServiceProvider services, Guid userId) + { + await using var scope = services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var user = await db.Users.SingleAsync(candidate => candidate.Id == userId); + user.Deactivate(SystemIds.AdminUser, DateTimeOffset.UtcNow).IsSuccess.ShouldBeTrue(); + await db.SaveChangesAsync(); + } + + /// Gives the user a new profile (unique name, so profile uniqueness never collides) with one level. + public static async Task GrantAsync(IServiceProvider services, Guid userId, string screen, AccessLevel level) + { + await using var scope = services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var profile = PermissionProfile.Create($"Perfil {Guid.NewGuid():N}", "Perfil de teste"); + profile.SetLevel(ScreenKey.Create(screen).Value, level).IsSuccess.ShouldBeTrue(); + db.PermissionProfiles.Add(profile); + var user = await db.Users.SingleAsync(candidate => candidate.Id == userId); + user.AssignProfiles([.. user.ProfileIds, profile.Id]).IsSuccess.ShouldBeTrue(); + await db.SaveChangesAsync(); + } + + /// An active user with a credential, under a login no other test uses, so a test never + /// collides with the unique indexes of another. + public static async Task CreateUserAsync(IServiceProvider services, string password = UserPassword) + { + await using var scope = services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var credentials = scope.ServiceProvider.GetRequiredService>(); + + var suffix = Guid.NewGuid().ToString("N")[..12]; + var login = $"user{suffix}"; + var user = User.Create( + Login.Create(login).Value, EmailAddress.Create($"{login}@example.com").Value, $"Pessoa {suffix}", $"Pessoa de Teste {suffix}"); + user.Activate(DateTimeOffset.UtcNow); + db.Users.Add(user); + await db.SaveChangesAsync(); + + var credential = new UserCredential { Id = user.Id, UserName = user.Id.ToString() }; + (await credentials.CreateAsync(credential, password)).Succeeded.ShouldBeTrue(); + + return new TestUser(user.Id, login, password, $"Pessoa {suffix}"); + } +} + +internal sealed record TestUser(Guid Id, string Login, string Password, string DisplayName); diff --git a/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/CapturingLoggerProvider.cs b/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/CapturingLoggerProvider.cs new file mode 100644 index 0000000..86557c5 --- /dev/null +++ b/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/CapturingLoggerProvider.cs @@ -0,0 +1,30 @@ +using System.Collections.Concurrent; +using Microsoft.Extensions.Logging; + +namespace ControlService.Api.IntegrationTests.Auth; + +/// Keeps every log line, prefixed by its level and with its exception, so a test can assert what was never written (AUTH-20). +internal sealed class CapturingLoggerProvider : ILoggerProvider +{ + private readonly ConcurrentQueue _lines = new(); + + public IReadOnlyCollection Lines => _lines; + + public ILogger CreateLogger(string categoryName) => new CapturingLogger(categoryName, _lines); + + public void Dispose() + { + } + + private sealed class CapturingLogger(string category, ConcurrentQueue lines) : ILogger + { + public IDisposable? BeginScope(TState state) + where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) => + lines.Enqueue($"{logLevel} {category}: {formatter(state, exception)} {exception}"); + } +} diff --git a/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/CredentialStoreTests.cs b/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/CredentialStoreTests.cs new file mode 100644 index 0000000..30d02e9 --- /dev/null +++ b/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/CredentialStoreTests.cs @@ -0,0 +1,70 @@ +using ControlService.Application.Auth; +using Microsoft.Extensions.DependencyInjection; + +namespace ControlService.Api.IntegrationTests.Auth; + +public sealed class CredentialStoreTests(AuthApiFactory factory) : IClassFixture +{ + private const string WrongPassword = "senha-errada-123"; + + [Fact] + public async Task Credential_store_locks_the_login_on_the_fifth_consecutive_failure() // AUTH-08 + { + var user = await AuthTestSupport.CreateUserAsync(factory.Services); + await using var scope = factory.Services.CreateAsyncScope(); + var store = scope.ServiceProvider.GetRequiredService(); + + for (var attempt = 1; attempt <= 4; attempt++) + { + var failure = await store.CheckPasswordAsync(user.Id, WrongPassword, TestContext.Current.CancellationToken); + failure.Outcome.ShouldBe(CredentialCheckOutcome.Failed); + } + + var fifth = await store.CheckPasswordAsync(user.Id, WrongPassword, TestContext.Current.CancellationToken); + + fifth.Outcome.ShouldBe(CredentialCheckOutcome.LockedOut); + fifth.LockoutEnd.ShouldBe(factory.Clock.GetUtcNow().AddMinutes(15)); + } + + [Fact] + public async Task Lockout_ends_after_the_configured_minutes() // AUTH-08 + { + var user = await AuthTestSupport.CreateUserAsync(factory.Services); + await using var scope = factory.Services.CreateAsyncScope(); + var store = scope.ServiceProvider.GetRequiredService(); + await FailAsync(store, user.Id, 5); + (await store.CheckPasswordAsync(user.Id, user.Password, TestContext.Current.CancellationToken)) + .Outcome.ShouldBe(CredentialCheckOutcome.LockedOut); + + factory.Clock.Advance(TimeSpan.FromMinutes(15)); + + (await store.CheckPasswordAsync(user.Id, user.Password, TestContext.Current.CancellationToken)) + .Outcome.ShouldBe(CredentialCheckOutcome.Succeeded); + } + + [Fact] + public async Task A_successful_check_resets_the_failure_count() // AUTH-08 + { + var user = await AuthTestSupport.CreateUserAsync(factory.Services); + await using var scope = factory.Services.CreateAsyncScope(); + var store = scope.ServiceProvider.GetRequiredService(); + await FailAsync(store, user.Id, 4); + (await store.CheckPasswordAsync(user.Id, user.Password, TestContext.Current.CancellationToken)) + .Outcome.ShouldBe(CredentialCheckOutcome.Succeeded); + + var outcomes = await FailAsync(store, user.Id, 4); + + outcomes.ShouldAllBe(outcome => outcome == CredentialCheckOutcome.Failed); + } + + private static async Task> FailAsync(ICredentialStore store, Guid userId, int attempts) + { + var outcomes = new List(); + for (var attempt = 1; attempt <= attempts; attempt++) + { + outcomes.Add((await store.CheckPasswordAsync(userId, WrongPassword, TestContext.Current.CancellationToken)).Outcome); + } + + return outcomes; + } +} diff --git a/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/MeApiTests.cs b/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/MeApiTests.cs new file mode 100644 index 0000000..ffb864b --- /dev/null +++ b/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/MeApiTests.cs @@ -0,0 +1,54 @@ +using System.Net; +using ControlService.Domain.Access; + +namespace ControlService.Api.IntegrationTests.Auth; + +public sealed class MeApiTests(AuthApiFactory factory) : IClassFixture +{ + private const string SessionEnded = "Sua sessão terminou. Entre de novo para continuar. Suas abas continuam abertas."; + + [Fact] + public async Task Me_returns_the_signed_in_person_and_their_levels() // Operations, D2 + { + var user = await AuthTestSupport.CreateUserAsync(factory.Services); + await AuthTestSupport.GrantAsync(factory.Services, user.Id, ScreenKeys.Users, AccessLevel.Editor); + using var client = factory.CreateHttpsClient(); + var token = await client.SignInForTokenAsync(user); + + using var response = await client.GetWithTokenAsync("/api/v1/me", token); + + response.StatusCode.ShouldBe(HttpStatusCode.OK); + var me = await response.ReadJsonAsync(); + me.GetProperty("id").GetGuid().ShouldBe(user.Id); + me.GetProperty("displayName").GetString().ShouldBe(user.DisplayName); + me.GetProperty("login").GetString().ShouldBe(user.Login); + var levels = me.GetProperty("levels").EnumerateArray().ToList(); + levels.Select(entry => entry.GetProperty("screen").GetString()).ShouldBe(ScreenKeys.All); + levels.Single(entry => entry.GetProperty("screen").GetString() == ScreenKeys.Users).GetProperty("level").GetString().ShouldBe("editor"); + levels.Where(entry => entry.GetProperty("screen").GetString() != ScreenKeys.Users) + .ShouldAllBe(entry => entry.GetProperty("level").GetString() == "negado"); + } + + [Fact] + public async Task Me_without_a_token_returns_401_session_expired() // ADR-0009, AUTH-27, T7 + { + using var client = factory.CreateHttpsClient(); + + using var response = await client.GetWithTokenAsync("/api/v1/me", accessToken: null); + + await response.ShouldBeProblemAsync(HttpStatusCode.Unauthorized, "session_expired", SessionEnded); + } + + [Fact] + public async Task Expired_access_token_returns_401_session_expired() // AUTH-17 + { + var user = await AuthTestSupport.CreateUserAsync(factory.Services); + using var client = factory.CreateHttpsClient(); + var token = await client.SignInForTokenAsync(user); + factory.Clock.Advance(TimeSpan.FromMinutes(15)); + + using var response = await client.GetWithTokenAsync("/api/v1/me", token); + + await response.ShouldBeProblemAsync(HttpStatusCode.Unauthorized, "session_expired", SessionEnded); + } +} diff --git a/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/NullFieldsApiTests.cs b/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/NullFieldsApiTests.cs new file mode 100644 index 0000000..3bbeb11 --- /dev/null +++ b/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/NullFieldsApiTests.cs @@ -0,0 +1,39 @@ +using System.Net; +using System.Net.Http.Json; +using ControlService.Api.IntegrationTests.Common; + +namespace ControlService.Api.IntegrationTests.Auth; + +public sealed class NullFieldsApiTests(AuthApiFactory factory) : IClassFixture, IAsyncLifetime +{ + public ValueTask InitializeAsync() => ValueTask.CompletedTask; + + // The Admin is one row in a database shared by the whole assembly: whatever a test did to it is undone, + // so the tests that expect it as seeded (StartupSeedTests) pass in any order. + public async ValueTask DisposeAsync() => await AuthTestSupport.ResetAdminAsync(factory.Services); + + [Fact] + public async Task Sign_in_with_null_fields_returns_401_invalid_credentials() // AUTH-08, API-12 + { + using var client = factory.CreateHttpsClient(); + + using var response = await client.PostAsJsonAsync( + "/api/v1/auth/sign-in", new { login = (string?)null, password = (string?)null }, TestContext.Current.CancellationToken); + + await response.ShouldBeProblemAsync(HttpStatusCode.Unauthorized, "invalid_credentials", "Login ou senha incorretos."); + } + + [Fact] + public async Task Change_password_with_null_fields_returns_400_validation_failed() // AUTH-16, API-15 + { + await AuthTestSupport.ResetAdminAsync(factory.Services); + using var client = factory.CreateHttpsClient(); + using var signIn = await client.SignInAsync("admin", ApiFactory.AdminInitialPassword); + var token = (await signIn.ReadJsonAsync()).GetProperty("accessToken").GetString()!; + + using var response = await client.PostWithTokenAsync( + "/api/v1/auth/change-password", token, new { password = (string?)null, passwordConfirmation = (string?)null }); + + await response.ShouldBeProblemAsync(HttpStatusCode.BadRequest, "validation_failed", "Alguns campos precisam ser corrigidos."); + } +} diff --git a/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/RateLimitApiTests.cs b/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/RateLimitApiTests.cs new file mode 100644 index 0000000..7a86089 --- /dev/null +++ b/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/RateLimitApiTests.cs @@ -0,0 +1,41 @@ +using System.Net; + +namespace ControlService.Api.IntegrationTests.Auth; + +public sealed class RateLimitApiTests(RateLimitedApiFactory factory) : IClassFixture +{ + private const string TooManyRequests = "Muitas tentativas em pouco tempo. Aguarde alguns instantes e tente de novo."; + + [Fact] + public async Task Sign_in_over_the_rate_limit_returns_429_with_retry_after() // AUTH-26, ADR-0023 + { + using var client = factory.CreateHttpsClient(); + for (var request = 1; request <= 2; request++) + { + using var allowed = await client.SignInAsync("nobody", "any-password-1"); + allowed.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); + } + + using var response = await client.SignInAsync("nobody", "any-password-1"); + + var problem = await response.ShouldBeProblemAsync(HttpStatusCode.TooManyRequests, "locked_out", TooManyRequests); + response.Headers.RetryAfter.ShouldNotBeNull(); + problem.GetProperty("details").GetProperty("retryAfterSeconds").GetInt32().ShouldBeGreaterThan(0); + } + + [Fact] + public async Task Refresh_over_the_rate_limit_returns_429() // AUTH-26, ADR-0023 + { + using var client = factory.CreateHttpsClient(); + for (var request = 1; request <= 2; request++) + { + using var allowed = await client.RefreshAsync(cookieValue: null); + allowed.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); + } + + using var response = await client.RefreshAsync(cookieValue: null); + + await response.ShouldBeProblemAsync(HttpStatusCode.TooManyRequests, "locked_out", TooManyRequests); + response.Headers.RetryAfter.ShouldNotBeNull(); + } +} diff --git a/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/RateLimitedApiFactory.cs b/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/RateLimitedApiFactory.cs new file mode 100644 index 0000000..a39ed9d --- /dev/null +++ b/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/RateLimitedApiFactory.cs @@ -0,0 +1,20 @@ +using ControlService.Api.IntegrationTests.Common; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; + +namespace ControlService.Api.IntegrationTests.Auth; + +/// The real API with rate limits of two requests per window, so the third request is refused +/// without a test having to send dozens (T12, ADR-0023). +public sealed class RateLimitedApiFactory(PostgresContainerFixture postgres) : AuthApiFactory(postgres) +{ + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + base.ConfigureWebHost(builder); + builder.ConfigureAppConfiguration((_, config) => config.AddInMemoryCollection(new Dictionary + { + ["RateLimiting:SignIn:PermitLimit"] = "2", + ["RateLimiting:Refresh:PermitLimit"] = "2", + })); + } +} diff --git a/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/RefreshApiTests.cs b/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/RefreshApiTests.cs new file mode 100644 index 0000000..60871be --- /dev/null +++ b/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/RefreshApiTests.cs @@ -0,0 +1,89 @@ +using System.Net; + +namespace ControlService.Api.IntegrationTests.Auth; + +public sealed class RefreshApiTests(AuthApiFactory factory) : IClassFixture +{ + private const string SessionEnded = "Sua sessão terminou. Entre de novo para continuar. Suas abas continuam abertas."; + + [Fact] + public async Task Refresh_rotates_the_cookie_and_the_old_one_stops_working() // AUTH-17 + { + var user = await AuthTestSupport.CreateUserAsync(factory.Services); + using var client = factory.CreateHttpsClient(); + using var signIn = await client.SignInAsync(user.Login, user.Password); + var oldCookie = signIn.RefreshCookie()!.Value.Value!; + + using var refreshed = await client.RefreshAsync(oldCookie); + + refreshed.StatusCode.ShouldBe(HttpStatusCode.OK); + var body = await refreshed.ReadJsonAsync(); + body.GetProperty("accessToken").GetString().ShouldNotBeNullOrEmpty(); + body.GetProperty("expiresIn").GetInt32().ShouldBe(900); + body.GetProperty("mustChangePassword").GetBoolean().ShouldBeFalse(); + var newCookie = refreshed.RefreshCookie(); + newCookie.ShouldNotBeNull(); + newCookie.Value.Value.ShouldNotBe(oldCookie); + newCookie.Path.Value.ShouldBe(AuthHttp.RefreshPath); + + using var reused = await client.RefreshAsync(oldCookie); + await reused.ShouldBeProblemAsync(HttpStatusCode.Unauthorized, "session_expired", SessionEnded); + } + + [Fact] + public async Task Refresh_slides_the_session_for_another_8_hours() // AUTH-17, ADR-0032 + { + var user = await AuthTestSupport.CreateUserAsync(factory.Services); + using var client = factory.CreateHttpsClient(); + using var signIn = await client.SignInAsync(user.Login, user.Password); + var cookie = signIn.RefreshCookie()!.Value.Value!; + + factory.Clock.Advance(TimeSpan.FromHours(7)); + using var first = await client.RefreshAsync(cookie); + first.StatusCode.ShouldBe(HttpStatusCode.OK); + + factory.Clock.Advance(TimeSpan.FromHours(7)); + using var second = await client.RefreshAsync(first.RefreshCookie()!.Value.Value); + second.StatusCode.ShouldBe(HttpStatusCode.OK); + } + + [Fact] + public async Task Refresh_after_8_idle_hours_returns_401_session_expired() // AUTH-17, ADR-0032 + { + var user = await AuthTestSupport.CreateUserAsync(factory.Services); + using var client = factory.CreateHttpsClient(); + using var signIn = await client.SignInAsync(user.Login, user.Password); + factory.Clock.Advance(TimeSpan.FromHours(8)); + + using var response = await client.RefreshAsync(signIn.RefreshCookie()!.Value.Value); + + await response.ShouldBeProblemAsync(HttpStatusCode.Unauthorized, "session_expired", SessionEnded); + } + + [Fact] + public async Task Refresh_without_a_cookie_returns_401_session_expired() // AUTH-17 + { + using var client = factory.CreateHttpsClient(); + + using var response = await client.RefreshAsync(cookieValue: null); + + await response.ShouldBeProblemAsync(HttpStatusCode.Unauthorized, "session_expired", SessionEnded); + } + + [Fact] + public async Task Refresh_of_a_deactivated_account_returns_401_account_inactive() // AUTH-18, D10 + { + var user = await AuthTestSupport.CreateUserAsync(factory.Services); + using var client = factory.CreateHttpsClient(); + using var signIn = await client.SignInAsync(user.Login, user.Password); + var cookie = signIn.RefreshCookie()!.Value.Value; + await AuthTestSupport.DeactivateUserAsync(factory.Services, user.Id); + + using var response = await client.RefreshAsync(cookie); + await response.ShouldBeProblemAsync( + HttpStatusCode.Unauthorized, "account_inactive", "Este acesso está desativado. Fale com o responsável pelo sistema."); + + using var again = await client.RefreshAsync(cookie); + await again.ShouldBeProblemAsync(HttpStatusCode.Unauthorized, "session_expired", SessionEnded); + } +} diff --git a/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/SessionStoreTests.cs b/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/SessionStoreTests.cs new file mode 100644 index 0000000..244713f --- /dev/null +++ b/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/SessionStoreTests.cs @@ -0,0 +1,30 @@ +using System.Security.Cryptography; +using System.Text; +using ControlService.Application.Auth; +using ControlService.Domain.Common; +using ControlService.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace ControlService.Api.IntegrationTests.Auth; + +public sealed class SessionStoreTests(AuthApiFactory factory) : IClassFixture +{ + [Fact] + public async Task Session_store_keeps_only_the_hash_of_the_refresh_token() // AUTH-19 + { + await using var scope = factory.Services.CreateAsyncScope(); + var sessions = scope.ServiceProvider.GetRequiredService(); + var db = scope.ServiceProvider.GetRequiredService(); + + var started = await sessions.StartAsync(SystemIds.AdminUser, TestContext.Current.CancellationToken); + + var plainToken = Encoding.UTF8.GetBytes(started.RefreshToken); + var stored = await db.Sessions.AsNoTracking().SingleAsync(session => session.Id == started.SessionId, TestContext.Current.CancellationToken); + stored.TokenHash.ShouldBe(SHA256.HashData(plainToken)); + stored.UserId.ShouldBe(SystemIds.AdminUser); + stored.ExpiresAt.ShouldBe(factory.Clock.GetUtcNow().AddHours(8)); + started.ExpiresAt.ShouldBe(stored.ExpiresAt); + (await db.Sessions.CountAsync(session => session.TokenHash == plainToken, TestContext.Current.CancellationToken)).ShouldBe(0); + } +} diff --git a/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/SignInApiTests.cs b/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/SignInApiTests.cs new file mode 100644 index 0000000..7d5654f --- /dev/null +++ b/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/SignInApiTests.cs @@ -0,0 +1,94 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.Net.Http.Headers; + +namespace ControlService.Api.IntegrationTests.Auth; + +public sealed class SignInApiTests(AuthApiFactory factory) : IClassFixture +{ + [Fact] + public async Task Sign_in_returns_200_with_an_access_token_and_the_refresh_cookie() // AUTH-07, AUTH-17, D1 + { + var user = await AuthTestSupport.CreateUserAsync(factory.Services); + using var client = factory.CreateHttpsClient(); + + using var response = await client.SignInAsync(user.Login, user.Password); + + response.StatusCode.ShouldBe(HttpStatusCode.OK); + var body = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + body.GetProperty("accessToken").GetString().ShouldNotBeNullOrEmpty(); + body.GetProperty("expiresIn").GetInt32().ShouldBe(900); + body.GetProperty("mustChangePassword").GetBoolean().ShouldBeFalse(); + body.EnumerateObject().Select(property => property.Name).ShouldBe(["accessToken", "expiresIn", "mustChangePassword"], ignoreOrder: true); + + var cookie = response.RefreshCookie(); + cookie.ShouldNotBeNull(); + cookie.Value.Value.ShouldNotBeNullOrEmpty(); + cookie.HttpOnly.ShouldBeTrue(); + cookie.Secure.ShouldBeTrue(); + cookie.SameSite.ShouldBe(SameSiteMode.Strict); + cookie.Path.Value.ShouldBe(AuthHttp.RefreshPath); + cookie.Expires.ShouldBe(factory.Clock.GetUtcNow().AddHours(8)); + } + + [Fact] + public async Task Access_token_carries_the_user_id_in_sub_and_the_session_in_sid() // ADR-0019, D3 of #7 + { + var user = await AuthTestSupport.CreateUserAsync(factory.Services); + using var client = factory.CreateHttpsClient(); + using var response = await client.SignInAsync(user.Login, user.Password); + var body = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + + var token = new JsonWebTokenHandler().ReadJsonWebToken(body.GetProperty("accessToken").GetString()); + + token.GetClaim("sub").Value.ShouldBe(user.Id.ToString()); + Guid.TryParse(token.GetClaim("sid").Value, out _).ShouldBeTrue(); + token.Claims.ShouldNotContain(claim => claim.Type == "must_change_password"); + (token.ValidTo - token.IssuedAt).ShouldBe(TimeSpan.FromSeconds(900)); + } + + [Fact] + public async Task Wrong_password_returns_401_invalid_credentials() // AUTH-08 + { + var user = await AuthTestSupport.CreateUserAsync(factory.Services); + using var client = factory.CreateHttpsClient(); + + using var response = await client.SignInAsync(user.Login, "senha-errada-123"); + + await response.ShouldBeProblemAsync(HttpStatusCode.Unauthorized, "invalid_credentials", "Login ou senha incorretos."); + } + + [Fact] + public async Task Fifth_wrong_password_returns_429_locked_out_with_retry_after() // AUTH-08, D6, T10 + { + var user = await AuthTestSupport.CreateUserAsync(factory.Services); + using var client = factory.CreateHttpsClient(); + for (var attempt = 1; attempt <= 4; attempt++) + { + using var failure = await client.SignInAsync(user.Login, "senha-errada-123"); + failure.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); + } + + using var response = await client.SignInAsync(user.Login, "senha-errada-123"); + + var problem = await response.ShouldBeProblemAsync( + HttpStatusCode.TooManyRequests, "locked_out", "Muitas tentativas sem sucesso. Aguarde 15 minutos e tente de novo."); + response.Headers.RetryAfter.ShouldNotBeNull().Delta.ShouldBe(TimeSpan.FromSeconds(900)); + problem.GetProperty("details").GetProperty("retryAfterSeconds").GetInt32().ShouldBe(900); + } + + [Fact] + public async Task Deactivated_account_returns_403_account_inactive() // AUTH-09 + { + var user = await AuthTestSupport.CreateUserAsync(factory.Services); + await AuthTestSupport.DeactivateUserAsync(factory.Services, user.Id); + using var client = factory.CreateHttpsClient(); + + using var response = await client.SignInAsync(user.Login, user.Password); + + await response.ShouldBeProblemAsync( + HttpStatusCode.Forbidden, "account_inactive", "Este acesso está desativado. Fale com o responsável pelo sistema."); + } +} diff --git a/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/SignOutApiTests.cs b/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/SignOutApiTests.cs new file mode 100644 index 0000000..0f5926f --- /dev/null +++ b/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/SignOutApiTests.cs @@ -0,0 +1,28 @@ +using System.Net; + +namespace ControlService.Api.IntegrationTests.Auth; + +public sealed class SignOutApiTests(AuthApiFactory factory) : IClassFixture +{ + [Fact] + public async Task Sign_out_returns_204_expires_the_cookie_and_ends_the_session() // ADR-0019, D3 + { + var user = await AuthTestSupport.CreateUserAsync(factory.Services); + using var client = factory.CreateHttpsClient(); + using var signIn = await client.SignInAsync(user.Login, user.Password); + var token = (await signIn.ReadJsonAsync()).GetProperty("accessToken").GetString()!; + var cookie = signIn.RefreshCookie()!.Value.Value; + + using var response = await client.PostWithTokenAsync("/api/v1/auth/sign-out", token); + + response.StatusCode.ShouldBe(HttpStatusCode.NoContent); + var expired = response.RefreshCookie(); + expired.ShouldNotBeNull(); + expired.Path.Value.ShouldBe(AuthHttp.RefreshPath); + expired.Expires.ShouldNotBeNull().ShouldBeLessThan(factory.Clock.GetUtcNow()); + + using var refreshed = await client.RefreshAsync(cookie); + refreshed.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); + (await refreshed.ReadJsonAsync()).GetProperty("code").GetString().ShouldBe("session_expired"); + } +} diff --git a/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/SigningKeyTests.cs b/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/SigningKeyTests.cs new file mode 100644 index 0000000..f31ce30 --- /dev/null +++ b/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/SigningKeyTests.cs @@ -0,0 +1,43 @@ +using ControlService.Api.IntegrationTests.Common; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; + +namespace ControlService.Api.IntegrationTests.Auth; + +public sealed class SigningKeyTests(ApiFactory factory) : IClassFixture +{ + [Theory] + [InlineData(null)] // the user secret was never set + [InlineData("")] + [InlineData("AAAAAAAAAAAAAAAAAAAAAA==")] // base64 of 16 bytes: too short for HS256 + [InlineData("not base64 at all!")] + public void Api_does_not_start_without_a_valid_signing_key(string? invalidKey) // ADR-0019, T5 + { + using var invalid = factory.WithWebHostBuilder(builder => + builder.ConfigureAppConfiguration((_, config) => config.AddInMemoryCollection(new Dictionary + { + ["Auth:SigningKey"] = invalidKey, + }))); + + var exception = Should.Throw(() => invalid.Services); + + exception.Message.ShouldContain("Auth:SigningKey"); + if (!string.IsNullOrEmpty(invalidKey)) + { + exception.Message.ShouldNotContain(invalidKey); + } + } + + [Fact] + public void Api_starts_with_a_signing_key_longer_than_64_bytes() // ADR-0019, T5: "at least 32 bytes" + { + var longKey = Convert.ToBase64String(new byte[96].Select(_ => (byte)9).ToArray()); + using var valid = factory.WithWebHostBuilder(builder => + builder.ConfigureAppConfiguration((_, config) => config.AddInMemoryCollection(new Dictionary + { + ["Auth:SigningKey"] = longKey, + }))); + + Should.NotThrow(() => valid.Services); + } +} diff --git a/backend/ControlService/tests/ControlService.Api.IntegrationTests/Common/ApiFactory.cs b/backend/ControlService/tests/ControlService.Api.IntegrationTests/Common/ApiFactory.cs index 59f6cb7..566155d 100644 --- a/backend/ControlService/tests/ControlService.Api.IntegrationTests/Common/ApiFactory.cs +++ b/backend/ControlService/tests/ControlService.Api.IntegrationTests/Common/ApiFactory.cs @@ -9,6 +9,11 @@ namespace ControlService.Api.IntegrationTests.Common; /// project derives from this one, since Program now needs a database to start. public class ApiFactory(PostgresContainerFixture postgres) : WebApplicationFactory { + // Fictitious secrets: the real ones live in user secrets and never in source (local environment guide). + public const string AdminInitialPassword = "senha-inicial-de-teste"; + + public static readonly string SigningKey = Convert.ToBase64String(new byte[32].Select(_ => (byte)7).ToArray()); + protected override void ConfigureWebHost(IWebHostBuilder builder) { builder.UseEnvironment(Environments.Development); @@ -16,6 +21,11 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) { ["ConnectionStrings:controlservice"] = postgres.ConnectionString, ["Admin:Email"] = "admin@example.com", + ["Admin:InitialPassword"] = AdminInitialPassword, + ["Auth:SigningKey"] = SigningKey, + // High enough that no test hits them; the rate-limit tests lower them (RateLimitedApiFactory). + ["RateLimiting:SignIn:PermitLimit"] = "1000", + ["RateLimiting:Refresh:PermitLimit"] = "1000", })); } } diff --git a/backend/ControlService/tests/ControlService.Api.IntegrationTests/Persistence/FixedTimeProvider.cs b/backend/ControlService/tests/ControlService.Api.IntegrationTests/Common/FixedTimeProvider.cs similarity index 53% rename from backend/ControlService/tests/ControlService.Api.IntegrationTests/Persistence/FixedTimeProvider.cs rename to backend/ControlService/tests/ControlService.Api.IntegrationTests/Common/FixedTimeProvider.cs index 5e71a92..4854a56 100644 --- a/backend/ControlService/tests/ControlService.Api.IntegrationTests/Persistence/FixedTimeProvider.cs +++ b/backend/ControlService/tests/ControlService.Api.IntegrationTests/Common/FixedTimeProvider.cs @@ -1,6 +1,6 @@ -namespace ControlService.Api.IntegrationTests.Persistence; +namespace ControlService.Api.IntegrationTests.Common; -/// A controllable clock so persistence tests never depend on the real time (T12, testing guide). +/// A controllable clock so tests never depend on the real time (T12, testing guide). public sealed class FixedTimeProvider(DateTimeOffset utcNow) : TimeProvider { private DateTimeOffset _utcNow = utcNow; @@ -8,4 +8,6 @@ public sealed class FixedTimeProvider(DateTimeOffset utcNow) : TimeProvider public override DateTimeOffset GetUtcNow() => _utcNow; public void Set(DateTimeOffset utcNow) => _utcNow = utcNow; + + public void Advance(TimeSpan span) => _utcNow += span; } diff --git a/backend/ControlService/tests/ControlService.Application.Tests/Auth/AuthTestBed.cs b/backend/ControlService/tests/ControlService.Application.Tests/Auth/AuthTestBed.cs new file mode 100644 index 0000000..077c03b --- /dev/null +++ b/backend/ControlService/tests/ControlService.Application.Tests/Auth/AuthTestBed.cs @@ -0,0 +1,92 @@ +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.Application.Tests.Fakes; +using ControlService.Domain.Access; +using ControlService.Domain.PermissionProfiles; +using ControlService.Domain.Users; + +namespace ControlService.Application.Tests.Auth; + +/// Builds the fakes and the handlers of the authentication use cases, and the users the +/// tests sign in as, so a constructor change touches one file (plan of #7). +internal sealed class AuthTestBed(AuthSettings? settings = null) +{ + public AuthSettings Settings { get; } = settings ?? new AuthSettings(PasswordMinLength: 8, LockoutMinutes: 15); + + public FixedTimeProvider Clock { get; } = new(new DateTimeOffset(2026, 9, 30, 12, 0, 0, TimeSpan.Zero)); + + public InMemoryUserRepository Users { get; } = new(); + + public InMemoryPermissionProfileRepository Profiles { get; } = new(); + + public FakeCredentialStore Credentials { get; } = new(); + + public InMemorySessionStore Sessions { get; } = new(); + + public FakeAccessTokenIssuer Tokens { get; } = new(); + + public FakeUnitOfWork UnitOfWork { get; } = new(); + + public SignInHandler CreateSignInHandler() => new(Users, Credentials, Sessions, Tokens, Settings, Clock); + + public RefreshSessionHandler CreateRefreshSessionHandler() => new(Users, Credentials, Sessions, Tokens); + + public SignOutHandler CreateSignOutHandler() => new(Sessions); + + public ValidatingCommandHandler CreateChangePasswordHandler() => + new(new ChangePasswordHandler(Users, Credentials, Sessions, Tokens, UnitOfWork, Clock), new ChangePasswordValidator(Settings)); + + public GetMeHandler CreateGetMeHandler() => new(Users, Profiles); + + /// Gives the user a new profile with the given levels. + public PermissionProfile AddProfile(User user, params (string Screen, AccessLevel Level)[] levels) + { + var profile = PermissionProfile.Create($"Profile {Guid.CreateVersion7()}", "Test profile"); + foreach (var (screen, level) in levels) + { + profile.SetLevel(ScreenKey.Create(screen).Value, level); + } + + Profiles.Add(profile); + user.AssignProfiles([.. user.ProfileIds, profile.Id]); + return profile; + } + + public User AddPendingUser(string login) + { + var user = User.Create( + Login.Create(login).Value, EmailAddress.Create($"{login}@example.com").Value, login, login); + Users.Add(user); + return user; + } + + public User AddActiveUser(string login, string password) + { + var user = AddPendingUser(login); + user.Activate(new DateTimeOffset(2026, 9, 1, 8, 0, 0, TimeSpan.Zero)); + Credentials.SetPassword(user.Id, password); + return user; + } + + /// The Admin as the seeder creates it, with the initial password that must be replaced (AUTH-13). + public User AddAdminWithInitialPassword(string initialPassword) + { + var admin = User.CreateAdmin(EmailAddress.Create("admin@example.com").Value); + Users.Add(admin); + Credentials.SetPassword(admin.Id, initialPassword); + Credentials.RequirePasswordChange(admin.Id); + return admin; + } + + public User AddInactiveUser(string login, string password) + { + var user = AddActiveUser(login, password); + user.Deactivate(Guid.CreateVersion7(), new DateTimeOffset(2026, 9, 2, 8, 0, 0, TimeSpan.Zero)); + return user; + } +} diff --git a/backend/ControlService/tests/ControlService.Application.Tests/Auth/ChangePasswordTests.cs b/backend/ControlService/tests/ControlService.Application.Tests/Auth/ChangePasswordTests.cs new file mode 100644 index 0000000..27b1ed1 --- /dev/null +++ b/backend/ControlService/tests/ControlService.Application.Tests/Auth/ChangePasswordTests.cs @@ -0,0 +1,165 @@ +using ControlService.Application.Auth; +using ControlService.Application.Auth.ChangePassword; +using ControlService.Application.Auth.SignIn; +using ControlService.Application.Tests.Fakes; +using ControlService.Domain.Common; + +namespace ControlService.Application.Tests.Auth; + +public class ChangePasswordTests +{ + [Fact] + public async Task Short_password_is_refused() // AUTH-16, API-15 + { + var bed = new AuthTestBed(); + var admin = bed.AddAdminWithInitialPassword("senha-inicial"); + + var result = await bed.CreateChangePasswordHandler() + .Handle(new ChangePasswordCommand(admin.Id, "1234567", "1234567"), TestContext.Current.CancellationToken); + + result.IsFailure.ShouldBeTrue(); + result.Error.Code.ShouldBe("validation_failed"); + result.Error.Message.ShouldBe("Alguns campos precisam ser corrigidos."); + result.Error.Fields.ShouldNotBeNull(); + result.Error.Fields["password"].ShouldBe(["A senha precisa ter pelo menos 8 caracteres."]); + } + + [Fact] + public async Task Password_of_exactly_the_minimum_length_is_accepted() // AUTH-16 + { + var bed = new AuthTestBed(); + var admin = bed.AddAdminWithInitialPassword("senha-inicial"); + + var result = await bed.CreateChangePasswordHandler() + .Handle(new ChangePasswordCommand(admin.Id, "12345678", "12345678"), TestContext.Current.CancellationToken); + + result.IsSuccess.ShouldBeTrue(); + } + + [Fact] + public async Task Different_confirmation_is_refused() // AUTH-16 + { + var bed = new AuthTestBed(); + var admin = bed.AddAdminWithInitialPassword("senha-inicial"); + + var result = await bed.CreateChangePasswordHandler() + .Handle(new ChangePasswordCommand(admin.Id, "12345678", "87654321"), TestContext.Current.CancellationToken); + + result.IsFailure.ShouldBeTrue(); + result.Error.Code.ShouldBe("validation_failed"); + result.Error.Fields.ShouldNotBeNull(); + result.Error.Fields["passwordConfirmation"].ShouldBe(["As duas senhas não são iguais. Digite de novo."]); + } + + [Fact] + public async Task Minimum_length_message_uses_the_configured_length() // AUTH-28 + { + var bed = new AuthTestBed(new AuthSettings(PasswordMinLength: 10, LockoutMinutes: 15)); + var admin = bed.AddAdminWithInitialPassword("senha-inicial"); + + var result = await bed.CreateChangePasswordHandler() + .Handle(new ChangePasswordCommand(admin.Id, "123456789", "123456789"), TestContext.Current.CancellationToken); + + result.IsFailure.ShouldBeTrue(); + result.Error.Fields.ShouldNotBeNull(); + result.Error.Fields["password"].ShouldBe(["A senha precisa ter pelo menos 10 caracteres."]); + } + + [Fact] + public async Task Password_equal_to_the_initial_one_is_refused() // AUTH-25 + { + var bed = new AuthTestBed(); + var admin = bed.AddAdminWithInitialPassword("senha-inicial"); + + var result = await bed.CreateChangePasswordHandler() + .Handle(new ChangePasswordCommand(admin.Id, "senha-inicial", "senha-inicial"), TestContext.Current.CancellationToken); + + result.IsFailure.ShouldBeTrue(); + result.Error.Code.ShouldBe("validation_failed"); + result.Error.Message.ShouldBe("Alguns campos precisam ser corrigidos."); + result.Error.Fields.ShouldNotBeNull(); + result.Error.Fields["password"].ShouldBe(["A nova senha precisa ser diferente da senha inicial."]); + bed.Sessions.SessionsOf(admin.Id).ShouldBeEmpty(); + } + + [Fact] + public async Task Account_without_a_mandatory_change_cannot_use_change_password() // AUTH-24 + { + var bed = new AuthTestBed(); + var user = bed.AddActiveUser("ana.souza", "senha-da-ana"); + + var result = await bed.CreateChangePasswordHandler() + .Handle(new ChangePasswordCommand(user.Id, "nova-senha-1", "nova-senha-1"), TestContext.Current.CancellationToken); + + result.IsFailure.ShouldBeTrue(); + result.Error.Code.ShouldBe("forbidden"); + result.Error.Message.ShouldBe("Sua senha já foi criada. Para trocá-la, use “Esqueci minha senha”."); + bed.Sessions.SessionsOf(user.Id).ShouldBeEmpty(); + } + + [Fact] + public async Task Changing_the_password_ends_every_session_and_starts_a_new_one() // ADR-0019, ADR-0032 + { + var bed = new AuthTestBed(); + var admin = bed.AddAdminWithInitialPassword("senha-inicial"); + var other = bed.AddActiveUser("ana.souza", "senha-da-ana"); + var signIn = bed.CreateSignInHandler(); + await signIn.Handle(new SignInCommand("admin", "senha-inicial"), TestContext.Current.CancellationToken); + await signIn.Handle(new SignInCommand("admin", "senha-inicial"), TestContext.Current.CancellationToken); + await signIn.Handle(new SignInCommand("ana.souza", "senha-da-ana"), TestContext.Current.CancellationToken); + + var result = await bed.CreateChangePasswordHandler() + .Handle(new ChangePasswordCommand(admin.Id, "nova-senha-1", "nova-senha-1"), TestContext.Current.CancellationToken); + + result.IsSuccess.ShouldBeTrue(); + var session = bed.Sessions.SessionsOf(admin.Id).ShouldHaveSingleItem(); + result.Value.RefreshToken.ShouldBe(session.RefreshToken); + result.Value.AccessToken.ShouldBe(FakeAccessTokenIssuer.TokenFor(admin.Id, session.Id, mustChangePassword: false)); + result.Value.MustChangePassword.ShouldBeFalse(); + (await bed.Credentials.IsCurrentPasswordAsync(admin.Id, "nova-senha-1", TestContext.Current.CancellationToken)).ShouldBeTrue(); + (await bed.Credentials.MustChangePasswordAsync(admin.Id, TestContext.Current.CancellationToken)).ShouldBeFalse(); + bed.Sessions.SessionsOf(other.Id).ShouldHaveSingleItem(); + } + + [Fact] + public async Task Changing_the_initial_password_fills_the_admin_activation_time() // USR-34 + { + var bed = new AuthTestBed(); + var admin = bed.AddAdminWithInitialPassword("senha-inicial"); + + var result = await bed.CreateChangePasswordHandler() + .Handle(new ChangePasswordCommand(admin.Id, "nova-senha-1", "nova-senha-1"), TestContext.Current.CancellationToken); + + result.IsSuccess.ShouldBeTrue(); + admin.ActivatedAt.ShouldBe(bed.Clock.GetUtcNow()); + bed.UnitOfWork.SaveCount.ShouldBe(1); + } + + [Fact] + public async Task Change_password_of_a_user_that_no_longer_exists_is_refused_as_session_expired() // AUTH-17 + { + var bed = new AuthTestBed(); + + var result = await bed.CreateChangePasswordHandler() + .Handle(new ChangePasswordCommand(Guid.CreateVersion7(), "nova-senha-1", "nova-senha-1"), TestContext.Current.CancellationToken); + + result.IsFailure.ShouldBeTrue(); + result.Error.ShouldBe(AuthErrors.SessionExpired); + } + + [Fact] + public async Task Change_password_stops_when_the_user_cannot_be_saved() // ADR-0014 + { + var bed = new AuthTestBed(); + var admin = bed.AddAdminWithInitialPassword("senha-inicial"); + var conflict = new Error("concurrency_conflict", "Este cadastro foi alterado."); + bed.UnitOfWork.FailWith = conflict; + + var result = await bed.CreateChangePasswordHandler() + .Handle(new ChangePasswordCommand(admin.Id, "nova-senha-1", "nova-senha-1"), TestContext.Current.CancellationToken); + + result.IsFailure.ShouldBeTrue(); + result.Error.ShouldBe(conflict); + (await bed.Credentials.IsCurrentPasswordAsync(admin.Id, "senha-inicial", TestContext.Current.CancellationToken)).ShouldBeTrue(); + } +} diff --git a/backend/ControlService/tests/ControlService.Application.Tests/Auth/GetMeTests.cs b/backend/ControlService/tests/ControlService.Application.Tests/Auth/GetMeTests.cs new file mode 100644 index 0000000..94403f4 --- /dev/null +++ b/backend/ControlService/tests/ControlService.Application.Tests/Auth/GetMeTests.cs @@ -0,0 +1,69 @@ +using ControlService.Application.Auth; +using ControlService.Application.Auth.GetMe; +using ControlService.Domain.Access; +using ControlService.Domain.PermissionProfiles; + +namespace ControlService.Application.Tests.Auth; + +public class GetMeTests +{ + [Fact] + public async Task Me_returns_the_person_and_every_screen_of_the_catalog() // PERM-05, Operations + { + var bed = new AuthTestBed(); + var user = bed.AddActiveUser("ana.souza", "senha-da-ana"); + bed.AddProfile(user, (ScreenKeys.Users, AccessLevel.Editor)); + + var result = await bed.CreateGetMeHandler() + .Handle(new GetMeQuery(user.Id), TestContext.Current.CancellationToken); + + result.IsSuccess.ShouldBeTrue(); + result.Value.Id.ShouldBe(user.Id); + result.Value.DisplayName.ShouldBe("ana.souza"); + result.Value.Login.ShouldBe("ana.souza"); + result.Value.Levels.Select(entry => entry.Screen).ShouldBe(ScreenKeys.All); + result.Value.Levels.Single(entry => entry.Screen == ScreenKeys.Users).Level.ShouldBe("editor"); + result.Value.Levels.Where(entry => entry.Screen != ScreenKeys.Users).ShouldAllBe(entry => entry.Level == "negado"); + } + + [Fact] + public async Task Me_without_profiles_is_denied_everywhere() // USR-21, PERM-05 + { + var bed = new AuthTestBed(); + var user = bed.AddActiveUser("ana.souza", "senha-da-ana"); + + var result = await bed.CreateGetMeHandler() + .Handle(new GetMeQuery(user.Id), TestContext.Current.CancellationToken); + + result.IsSuccess.ShouldBeTrue(); + result.Value.Levels.Count.ShouldBe(ScreenKeys.All.Count); + result.Value.Levels.ShouldAllBe(entry => entry.Level == "negado"); + } + + [Fact] + public async Task Me_of_the_admin_is_manager_everywhere() // PERM-06, PERM-23 + { + var bed = new AuthTestBed(); + var admin = bed.AddAdminWithInitialPassword("senha-inicial"); + bed.Profiles.Add(PermissionProfile.CreateManagerProfile()); + + var result = await bed.CreateGetMeHandler() + .Handle(new GetMeQuery(admin.Id), TestContext.Current.CancellationToken); + + result.IsSuccess.ShouldBeTrue(); + result.Value.Levels.Count.ShouldBe(ScreenKeys.All.Count); + result.Value.Levels.ShouldAllBe(entry => entry.Level == "gerenciador"); + } + + [Fact] + public async Task Me_of_a_user_that_no_longer_exists_is_refused_as_session_expired() // AUTH-17 + { + var bed = new AuthTestBed(); + + var result = await bed.CreateGetMeHandler() + .Handle(new GetMeQuery(Guid.CreateVersion7()), TestContext.Current.CancellationToken); + + result.IsFailure.ShouldBeTrue(); + result.Error.ShouldBe(AuthErrors.SessionExpired); + } +} diff --git a/backend/ControlService/tests/ControlService.Application.Tests/Auth/RefreshSessionTests.cs b/backend/ControlService/tests/ControlService.Application.Tests/Auth/RefreshSessionTests.cs new file mode 100644 index 0000000..ce6d18c --- /dev/null +++ b/backend/ControlService/tests/ControlService.Application.Tests/Auth/RefreshSessionTests.cs @@ -0,0 +1,98 @@ +using ControlService.Application.Auth; +using ControlService.Application.Auth.RefreshSession; +using ControlService.Application.Auth.SignIn; +using ControlService.Application.Tests.Fakes; + +namespace ControlService.Application.Tests.Auth; + +public class RefreshSessionTests +{ + [Fact] + public async Task Refresh_with_a_valid_token_rotates_it_and_issues_a_new_access_token() // AUTH-17 + { + var bed = new AuthTestBed(); + var user = bed.AddActiveUser("ana.souza", "senha-da-ana"); + var signedIn = await bed.CreateSignInHandler() + .Handle(new SignInCommand("ana.souza", "senha-da-ana"), TestContext.Current.CancellationToken); + + var result = await bed.CreateRefreshSessionHandler() + .Handle(new RefreshSessionCommand(signedIn.Value.RefreshToken), TestContext.Current.CancellationToken); + + result.IsSuccess.ShouldBeTrue(); + var session = bed.Sessions.SessionsOf(user.Id).ShouldHaveSingleItem(); + result.Value.RefreshToken.ShouldNotBe(signedIn.Value.RefreshToken); + result.Value.RefreshToken.ShouldBe(session.RefreshToken); + result.Value.AccessToken.ShouldBe(FakeAccessTokenIssuer.TokenFor(user.Id, session.Id, mustChangePassword: false)); + result.Value.ExpiresIn.ShouldBe(900); + result.Value.MustChangePassword.ShouldBeFalse(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Refresh_with_an_unknown_or_expired_token_is_refused_as_session_expired(bool expired) // AUTH-17, AUTH-27 + { + var bed = new AuthTestBed(); + bed.AddActiveUser("ana.souza", "senha-da-ana"); + var signedIn = await bed.CreateSignInHandler() + .Handle(new SignInCommand("ana.souza", "senha-da-ana"), TestContext.Current.CancellationToken); + var token = expired ? signedIn.Value.RefreshToken : "unknown-token"; + bed.Sessions.Expire(signedIn.Value.RefreshToken); + + var result = await bed.CreateRefreshSessionHandler() + .Handle(new RefreshSessionCommand(token), TestContext.Current.CancellationToken); + + result.IsFailure.ShouldBeTrue(); + result.Error.Code.ShouldBe("session_expired"); + result.Error.Message.ShouldBe("Sua sessão terminou. Entre de novo para continuar. Suas abas continuam abertas."); + } + + [Fact] + public async Task Refresh_of_a_deactivated_account_is_refused_and_ends_the_session() // AUTH-18, AUTH-27 + { + var bed = new AuthTestBed(); + var user = bed.AddActiveUser("ana.souza", "senha-da-ana"); + var signedIn = await bed.CreateSignInHandler() + .Handle(new SignInCommand("ana.souza", "senha-da-ana"), TestContext.Current.CancellationToken); + user.Deactivate(Guid.CreateVersion7(), bed.Clock.GetUtcNow()); + + var result = await bed.CreateRefreshSessionHandler() + .Handle(new RefreshSessionCommand(signedIn.Value.RefreshToken), TestContext.Current.CancellationToken); + + result.IsFailure.ShouldBeTrue(); + result.Error.Code.ShouldBe("account_inactive"); + result.Error.Message.ShouldBe("Este acesso está desativado. Fale com o responsável pelo sistema."); + bed.Sessions.SessionsOf(user.Id).ShouldBeEmpty(); + } + + [Fact] + public async Task Refresh_keeps_the_mandatory_password_change() // AUTH-14 + { + var bed = new AuthTestBed(); + var admin = bed.AddActiveUser("admin", "senha-inicial"); + bed.Credentials.RequirePasswordChange(admin.Id); + var signedIn = await bed.CreateSignInHandler() + .Handle(new SignInCommand("admin", "senha-inicial"), TestContext.Current.CancellationToken); + + var result = await bed.CreateRefreshSessionHandler() + .Handle(new RefreshSessionCommand(signedIn.Value.RefreshToken), TestContext.Current.CancellationToken); + + result.IsSuccess.ShouldBeTrue(); + var session = bed.Sessions.SessionsOf(admin.Id).ShouldHaveSingleItem(); + result.Value.MustChangePassword.ShouldBeTrue(); + result.Value.AccessToken.ShouldBe(FakeAccessTokenIssuer.TokenFor(admin.Id, session.Id, mustChangePassword: true)); + } + + [Fact] + public async Task Refresh_of_a_session_whose_user_no_longer_exists_is_refused_as_session_expired() // AUTH-17 + { + var bed = new AuthTestBed(); + var orphan = await bed.Sessions.StartAsync(Guid.CreateVersion7(), TestContext.Current.CancellationToken); + + var result = await bed.CreateRefreshSessionHandler() + .Handle(new RefreshSessionCommand(orphan.RefreshToken), TestContext.Current.CancellationToken); + + result.IsFailure.ShouldBeTrue(); + result.Error.ShouldBe(AuthErrors.SessionExpired); + } +} diff --git a/backend/ControlService/tests/ControlService.Application.Tests/Auth/SignInTests.cs b/backend/ControlService/tests/ControlService.Application.Tests/Auth/SignInTests.cs new file mode 100644 index 0000000..97359d4 --- /dev/null +++ b/backend/ControlService/tests/ControlService.Application.Tests/Auth/SignInTests.cs @@ -0,0 +1,154 @@ +using ControlService.Application.Auth; +using ControlService.Application.Auth.SignIn; +using ControlService.Application.Tests.Fakes; + +namespace ControlService.Application.Tests.Auth; + +public class SignInTests +{ + [Fact] + public async Task Sign_in_with_the_right_password_returns_a_token_and_starts_a_session() // AUTH-07 + { + var bed = new AuthTestBed(); + var user = bed.AddActiveUser("ana.souza", "senha-da-ana"); + + var result = await bed.CreateSignInHandler() + .Handle(new SignInCommand("ana.souza", "senha-da-ana"), TestContext.Current.CancellationToken); + + result.IsSuccess.ShouldBeTrue(); + var session = bed.Sessions.SessionsOf(user.Id).ShouldHaveSingleItem(); + result.Value.AccessToken.ShouldBe(FakeAccessTokenIssuer.TokenFor(user.Id, session.Id, mustChangePassword: false)); + result.Value.ExpiresIn.ShouldBe(900); + result.Value.MustChangePassword.ShouldBeFalse(); + result.Value.RefreshToken.ShouldBe(session.RefreshToken); + } + + [Fact] + public async Task Unknown_login_is_refused_as_invalid_credentials() // AUTH-08 + { + var bed = new AuthTestBed(); + + var result = await bed.CreateSignInHandler() + .Handle(new SignInCommand("nobody", "any-password"), TestContext.Current.CancellationToken); + + result.IsFailure.ShouldBeTrue(); + result.Error.Code.ShouldBe("invalid_credentials"); + result.Error.Message.ShouldBe("Login ou senha incorretos."); + } + + [Fact] + public async Task Login_outside_the_login_format_is_refused_as_invalid_credentials() // AUTH-08 + { + var bed = new AuthTestBed(); + + var result = await bed.CreateSignInHandler() + .Handle(new SignInCommand("a", "any-password"), TestContext.Current.CancellationToken); + + result.IsFailure.ShouldBeTrue(); + result.Error.ShouldBe(AuthErrors.InvalidCredentials); + } + + [Fact] + public async Task Wrong_password_is_refused_with_the_same_error_as_an_unknown_login() // AUTH-08 + { + var bed = new AuthTestBed(); + var user = bed.AddActiveUser("ana.souza", "senha-da-ana"); + + var result = await bed.CreateSignInHandler() + .Handle(new SignInCommand("ana.souza", "outra-senha"), TestContext.Current.CancellationToken); + + result.IsFailure.ShouldBeTrue(); + result.Error.ShouldBe(AuthErrors.InvalidCredentials); + bed.Sessions.SessionsOf(user.Id).ShouldBeEmpty(); + } + + [Fact] + public async Task Pending_account_is_refused_as_invalid_credentials() // AUTH-02, AUTH-08 + { + var bed = new AuthTestBed(); + var user = bed.AddPendingUser("bruno.lima"); + + var result = await bed.CreateSignInHandler() + .Handle(new SignInCommand("bruno.lima", "any-password"), TestContext.Current.CancellationToken); + + result.IsFailure.ShouldBeTrue(); + result.Error.ShouldBe(AuthErrors.InvalidCredentials); + bed.Sessions.SessionsOf(user.Id).ShouldBeEmpty(); + } + + [Fact] + public async Task Locked_out_login_is_refused_even_with_the_right_password() // AUTH-08 + { + var bed = new AuthTestBed(); + var user = bed.AddActiveUser("ana.souza", "senha-da-ana"); + bed.Credentials.LockOutUntil(user.Id, bed.Clock.GetUtcNow().AddMinutes(10)); + + var result = await bed.CreateSignInHandler() + .Handle(new SignInCommand("ana.souza", "senha-da-ana"), TestContext.Current.CancellationToken); + + result.IsFailure.ShouldBeTrue(); + result.Error.Code.ShouldBe("locked_out"); + result.Error.Message.ShouldBe("Muitas tentativas sem sucesso. Aguarde 15 minutos e tente de novo."); + result.Error.Details.ShouldNotBeNull(); + result.Error.Details["retryAfterSeconds"].ShouldBe(600); + bed.Sessions.SessionsOf(user.Id).ShouldBeEmpty(); + } + + [Fact] + public async Task Lockout_message_uses_the_configured_minutes() // AUTH-28 + { + var bed = new AuthTestBed(new AuthSettings(PasswordMinLength: 8, LockoutMinutes: 30)); + var user = bed.AddActiveUser("ana.souza", "senha-da-ana"); + bed.Credentials.LockOutUntil(user.Id, bed.Clock.GetUtcNow().AddMinutes(30)); + + var result = await bed.CreateSignInHandler() + .Handle(new SignInCommand("ana.souza", "senha-da-ana"), TestContext.Current.CancellationToken); + + result.IsFailure.ShouldBeTrue(); + result.Error.Message.ShouldBe("Muitas tentativas sem sucesso. Aguarde 30 minutos e tente de novo."); + } + + [Fact] + public async Task Deactivated_account_with_the_right_password_is_refused() // AUTH-09 + { + var bed = new AuthTestBed(); + var user = bed.AddInactiveUser("carla.dias", "senha-da-carla"); + + var result = await bed.CreateSignInHandler() + .Handle(new SignInCommand("carla.dias", "senha-da-carla"), TestContext.Current.CancellationToken); + + result.IsFailure.ShouldBeTrue(); + result.Error.Code.ShouldBe("account_inactive"); + result.Error.Message.ShouldBe("Este acesso está desativado. Fale com o responsável pelo sistema."); + bed.Sessions.SessionsOf(user.Id).ShouldBeEmpty(); + } + + [Fact] + public async Task Deactivated_account_with_a_wrong_password_gets_invalid_credentials() // AUTH-08, AUTH-09 + { + var bed = new AuthTestBed(); + bed.AddInactiveUser("carla.dias", "senha-da-carla"); + + var result = await bed.CreateSignInHandler() + .Handle(new SignInCommand("carla.dias", "outra-senha"), TestContext.Current.CancellationToken); + + result.IsFailure.ShouldBeTrue(); + result.Error.ShouldBe(AuthErrors.InvalidCredentials); + } + + [Fact] + public async Task Account_with_a_mandatory_password_change_gets_a_restricted_token() // AUTH-14, ADR-0032 + { + var bed = new AuthTestBed(); + var admin = bed.AddActiveUser("admin", "senha-inicial"); + bed.Credentials.RequirePasswordChange(admin.Id); + + var result = await bed.CreateSignInHandler() + .Handle(new SignInCommand("admin", "senha-inicial"), TestContext.Current.CancellationToken); + + result.IsSuccess.ShouldBeTrue(); + var session = bed.Sessions.SessionsOf(admin.Id).ShouldHaveSingleItem(); + result.Value.MustChangePassword.ShouldBeTrue(); + result.Value.AccessToken.ShouldBe(FakeAccessTokenIssuer.TokenFor(admin.Id, session.Id, mustChangePassword: true)); + } +} diff --git a/backend/ControlService/tests/ControlService.Application.Tests/Auth/SignOutTests.cs b/backend/ControlService/tests/ControlService.Application.Tests/Auth/SignOutTests.cs new file mode 100644 index 0000000..d2fa376 --- /dev/null +++ b/backend/ControlService/tests/ControlService.Application.Tests/Auth/SignOutTests.cs @@ -0,0 +1,24 @@ +using ControlService.Application.Auth.SignIn; +using ControlService.Application.Auth.SignOut; + +namespace ControlService.Application.Tests.Auth; + +public class SignOutTests +{ + [Fact] + public async Task Sign_out_ends_only_the_current_session() // ADR-0019 + { + var bed = new AuthTestBed(); + var user = bed.AddActiveUser("ana.souza", "senha-da-ana"); + var signIn = bed.CreateSignInHandler(); + await signIn.Handle(new SignInCommand("ana.souza", "senha-da-ana"), TestContext.Current.CancellationToken); + await signIn.Handle(new SignInCommand("ana.souza", "senha-da-ana"), TestContext.Current.CancellationToken); + var (ending, staying) = (bed.Sessions.SessionsOf(user.Id)[0], bed.Sessions.SessionsOf(user.Id)[1]); + + var result = await bed.CreateSignOutHandler() + .Handle(new SignOutCommand(ending.Id), TestContext.Current.CancellationToken); + + result.IsSuccess.ShouldBeTrue(); + bed.Sessions.SessionsOf(user.Id).ShouldBe([staying]); + } +} diff --git a/backend/ControlService/tests/ControlService.Application.Tests/Fakes/FakeAccessTokenIssuer.cs b/backend/ControlService/tests/ControlService.Application.Tests/Fakes/FakeAccessTokenIssuer.cs new file mode 100644 index 0000000..dea3588 --- /dev/null +++ b/backend/ControlService/tests/ControlService.Application.Tests/Fakes/FakeAccessTokenIssuer.cs @@ -0,0 +1,12 @@ +using ControlService.Application.Auth; + +namespace ControlService.Application.Tests.Fakes; + +internal sealed class FakeAccessTokenIssuer : IAccessTokenIssuer +{ + public AccessToken Issue(Guid userId, Guid sessionId, bool mustChangePassword) => + new(TokenFor(userId, sessionId, mustChangePassword), ExpiresInSeconds: 900); + + public static string TokenFor(Guid userId, Guid sessionId, bool mustChangePassword) => + $"access-token:{userId}:{sessionId}:{(mustChangePassword ? "must-change" : "regular")}"; +} diff --git a/backend/ControlService/tests/ControlService.Application.Tests/Fakes/FakeCredentialStore.cs b/backend/ControlService/tests/ControlService.Application.Tests/Fakes/FakeCredentialStore.cs new file mode 100644 index 0000000..197226d --- /dev/null +++ b/backend/ControlService/tests/ControlService.Application.Tests/Fakes/FakeCredentialStore.cs @@ -0,0 +1,43 @@ +using ControlService.Application.Auth; + +namespace ControlService.Application.Tests.Fakes; + +/// Holds one password per user. Lockout counting is Identity's job and is tested against +/// the real store; here a test scripts the outcome it needs. +internal sealed class FakeCredentialStore : ICredentialStore +{ + private readonly Dictionary _passwords = []; + private readonly Dictionary _lockouts = []; + private readonly HashSet _mustChange = []; + + public void SetPassword(Guid userId, string password) => _passwords[userId] = password; + + public void RequirePasswordChange(Guid userId) => _mustChange.Add(userId); + + public void LockOutUntil(Guid userId, DateTimeOffset lockoutEnd) => _lockouts[userId] = lockoutEnd; + + public Task CheckPasswordAsync(Guid userId, string password, CancellationToken cancellationToken) + { + if (_lockouts.TryGetValue(userId, out var lockoutEnd)) + { + return Task.FromResult(CredentialCheck.LockedOut(lockoutEnd)); + } + + return Task.FromResult(_passwords.TryGetValue(userId, out var stored) && stored == password + ? CredentialCheck.Succeeded + : CredentialCheck.Failed); + } + + public Task ReplacePasswordAsync(Guid userId, string newPassword, CancellationToken cancellationToken) + { + _passwords[userId] = newPassword; + _mustChange.Remove(userId); + return Task.CompletedTask; + } + + public Task IsCurrentPasswordAsync(Guid userId, string password, CancellationToken cancellationToken) => + Task.FromResult(_passwords.TryGetValue(userId, out var stored) && stored == password); + + public Task MustChangePasswordAsync(Guid userId, CancellationToken cancellationToken) => + Task.FromResult(_mustChange.Contains(userId)); +} diff --git a/backend/ControlService/tests/ControlService.Application.Tests/Fakes/FakeUnitOfWork.cs b/backend/ControlService/tests/ControlService.Application.Tests/Fakes/FakeUnitOfWork.cs new file mode 100644 index 0000000..1c488c1 --- /dev/null +++ b/backend/ControlService/tests/ControlService.Application.Tests/Fakes/FakeUnitOfWork.cs @@ -0,0 +1,19 @@ +using ControlService.Application.Common; +using ControlService.Domain.Common; + +namespace ControlService.Application.Tests.Fakes; + +/// Counts the saves: the in-memory repositories hold the aggregates themselves, so what a +/// test checks is that the handler asked to persist them. +internal sealed class FakeUnitOfWork : IUnitOfWork +{ + public int SaveCount { get; private set; } + + public Error? FailWith { get; set; } + + public Task SaveChangesAsync(CancellationToken cancellationToken) + { + SaveCount++; + return Task.FromResult(FailWith is null ? Result.Success() : Result.Failure(FailWith)); + } +} diff --git a/backend/ControlService/tests/ControlService.Application.Tests/Fakes/FixedTimeProvider.cs b/backend/ControlService/tests/ControlService.Application.Tests/Fakes/FixedTimeProvider.cs new file mode 100644 index 0000000..c4a6a09 --- /dev/null +++ b/backend/ControlService/tests/ControlService.Application.Tests/Fakes/FixedTimeProvider.cs @@ -0,0 +1,11 @@ +namespace ControlService.Application.Tests.Fakes; + +/// A controllable clock so no test depends on the real time (testing guide). +internal sealed class FixedTimeProvider(DateTimeOffset utcNow) : TimeProvider +{ + private DateTimeOffset _utcNow = utcNow; + + public override DateTimeOffset GetUtcNow() => _utcNow; + + public void Advance(TimeSpan span) => _utcNow += span; +} diff --git a/backend/ControlService/tests/ControlService.Application.Tests/Fakes/InMemoryPermissionProfileRepository.cs b/backend/ControlService/tests/ControlService.Application.Tests/Fakes/InMemoryPermissionProfileRepository.cs new file mode 100644 index 0000000..8fd971c --- /dev/null +++ b/backend/ControlService/tests/ControlService.Application.Tests/Fakes/InMemoryPermissionProfileRepository.cs @@ -0,0 +1,14 @@ +using ControlService.Application.PermissionProfiles; +using ControlService.Domain.PermissionProfiles; + +namespace ControlService.Application.Tests.Fakes; + +internal sealed class InMemoryPermissionProfileRepository : IPermissionProfileRepository +{ + private readonly List _profiles = []; + + public void Add(PermissionProfile profile) => _profiles.Add(profile); + + public Task> ListByIdsAsync(IEnumerable ids, CancellationToken cancellationToken) => + Task.FromResult>(_profiles.Where(profile => ids.Contains(profile.Id)).ToArray()); +} diff --git a/backend/ControlService/tests/ControlService.Application.Tests/Fakes/InMemorySessionStore.cs b/backend/ControlService/tests/ControlService.Application.Tests/Fakes/InMemorySessionStore.cs new file mode 100644 index 0000000..9512d09 --- /dev/null +++ b/backend/ControlService/tests/ControlService.Application.Tests/Fakes/InMemorySessionStore.cs @@ -0,0 +1,55 @@ +using ControlService.Application.Auth; + +namespace ControlService.Application.Tests.Fakes; + +internal sealed class InMemorySessionStore : ISessionStore +{ + private readonly List _sessions = []; + + // Counts every token ever issued: numbering by the current count would repeat a token after a rotation. + private int _issuedTokens; + + public IReadOnlyList SessionsOf(Guid userId) => _sessions.Where(session => session.UserId == userId).ToArray(); + + /// Stands for the idle time running out: the real store compares dates in SQL, and that + /// is tested against PostgreSQL. + public void Expire(string refreshToken) => _sessions.RemoveAll(session => session.RefreshToken == refreshToken); + + public Task StartAsync(Guid userId, CancellationToken cancellationToken) + { + var session = new StoredSession(Guid.CreateVersion7(), userId, NewToken()); + _sessions.Add(session); + return Task.FromResult(new SessionTokens(session.Id, session.RefreshToken, DateTimeOffset.MaxValue)); + } + + public Task EndAsync(Guid sessionId, CancellationToken cancellationToken) + { + _sessions.RemoveAll(session => session.Id == sessionId); + return Task.CompletedTask; + } + + public Task EndAllAsync(Guid userId, CancellationToken cancellationToken) + { + _sessions.RemoveAll(session => session.UserId == userId); + return Task.CompletedTask; + } + + public Task RotateAsync(string refreshToken, CancellationToken cancellationToken) + { + var index = _sessions.FindIndex(session => session.RefreshToken == refreshToken); + if (index < 0) + { + return Task.FromResult(null); + } + + var current = _sessions[index]; + var rotated = current with { RefreshToken = NewToken() }; + _sessions[index] = rotated; + return Task.FromResult( + new RotatedSession(rotated.UserId, new SessionTokens(rotated.Id, rotated.RefreshToken, DateTimeOffset.MaxValue))); + } + + private string NewToken() => $"refresh-token-{++_issuedTokens}"; + + internal sealed record StoredSession(Guid Id, Guid UserId, string RefreshToken); +} diff --git a/backend/ControlService/tests/ControlService.Application.Tests/Fakes/InMemoryUserRepository.cs b/backend/ControlService/tests/ControlService.Application.Tests/Fakes/InMemoryUserRepository.cs new file mode 100644 index 0000000..b682de1 --- /dev/null +++ b/backend/ControlService/tests/ControlService.Application.Tests/Fakes/InMemoryUserRepository.cs @@ -0,0 +1,17 @@ +using ControlService.Application.Users; +using ControlService.Domain.Users; + +namespace ControlService.Application.Tests.Fakes; + +internal sealed class InMemoryUserRepository : IUserRepository +{ + private readonly List _users = []; + + public void Add(User user) => _users.Add(user); + + public Task GetByIdAsync(Guid id, CancellationToken cancellationToken) => + Task.FromResult(_users.Find(user => user.Id == id)); + + public Task GetByLoginAsync(Login login, CancellationToken cancellationToken) => + Task.FromResult(_users.Find(user => user.Login.Value == login.Value)); +} diff --git a/backend/ControlService/tests/ControlService.Domain.Tests/Users/UserTests.cs b/backend/ControlService/tests/ControlService.Domain.Tests/Users/UserTests.cs index 3e3b464..9602430 100644 --- a/backend/ControlService/tests/ControlService.Domain.Tests/Users/UserTests.cs +++ b/backend/ControlService/tests/ControlService.Domain.Tests/Users/UserTests.cs @@ -276,4 +276,28 @@ public void Admin_profiles_cannot_change() // USR-24, USR-28 result.Error.Message.ShouldBe("O usuário Admin é do sistema e não pode ser alterado nem desativado."); admin.ProfileIds.ShouldBe([SystemIds.ManagerProfile]); } + + [Fact] + public void Completing_the_first_access_fills_activated_at() // USR-34 + { + var admin = TestData.NewAdmin(); + var now = new DateTimeOffset(2026, 9, 30, 8, 0, 0, TimeSpan.Zero); + + admin.CompleteFirstAccess(now); + + admin.ActivatedAt.ShouldBe(now); + admin.Status.ShouldBe(UserStatus.Active); + } + + [Fact] + public void Completing_the_first_access_again_keeps_the_first_time() // USR-34 + { + var admin = TestData.NewAdmin(); + var first = new DateTimeOffset(2026, 9, 30, 8, 0, 0, TimeSpan.Zero); + admin.CompleteFirstAccess(first); + + admin.CompleteFirstAccess(first.AddHours(1)); + + admin.ActivatedAt.ShouldBe(first); + } } diff --git a/docs/agents/guides/architecture.md b/docs/agents/guides/architecture.md index cd39795..d47c8bc 100644 --- a/docs/agents/guides/architecture.md +++ b/docs/agents/guides/architecture.md @@ -71,4 +71,16 @@ Current features: `Access`, `Auth`, `Users`, `PermissionProfiles`, `Screens`. Sh - Concurrency token: `Version` (`uint`), mapped with `.IsRowVersion()` to PostgreSQL's `xmin` system column — no extra column. Conflicts return 409 `concurrency_conflict`, translated by `Infrastructure/Persistence/UnitOfWork` (`Application/Common/IUnitOfWork`), which names the record's latest editor (ADR-0014). - Audit fields are filled by `AuditFieldsInterceptor` (an EF Core `SaveChangesInterceptor`) using `ICurrentUser` and `TimeProvider`, never by handlers. On insert, `updatedAt`/`updatedBy` get the same values as `createdAt`/`createdBy` (CNV-19). A change to an owned collection only (a profile's levels, a user's profiles) also counts as a change to the aggregate: the interceptor marks its `updatedAt`/`updatedBy` as modified, which forces an `UPDATE` of the owner row, so `xmin` changes and is checked (CNV-12, CNV-13). A save during a request with no signed-in user throws; outside a request (startup, seeding) `ICurrentUser.UserId` answers the Admin's id instead of `null`, so the seeder's own inserts are authored by the Admin (CNV-20). - Uniqueness is guaranteed by unique indexes (login, normalized display name, normalized profile name). -- `SystemRecordsSeeder` inserts the Admin and the Gerenciador profile only when missing, through EF Core's `UseSeeding`/`UseAsyncSeeding` (ADR-0022), which run as part of `Database.Migrate`/`MigrateAsync`. +- `SystemRecordsSeeder` inserts the Admin, its credential and the Gerenciador profile only when missing (the credential is checked apart from the Admin row, so databases created before authentication get it), through EF Core's `UseSeeding`/`UseAsyncSeeding` (ADR-0022), which run as part of `Database.Migrate`/`MigrateAsync`. + +## Authentication (ADR-0019, ADR-0032, ADR-0023) + +- **Application/Auth** has one folder per use case (`SignIn`, `RefreshSession`, `SignOut`, `ChangePassword`, `GetMe`) and the ports they use: `ICredentialStore` (password check with lockout, mandatory-change flag, replace password), `ISessionStore` (start, rotate, end one, end all) and `IAccessTokenIssuer`. `AuthErrors` holds every authentication error with its Portuguese message; `AuthSettings` carries the two configured values messages state (minimum password length, lockout minutes), because Application cannot read options. +- **Identity** (`Infrastructure/Auth`): `AppDbContext` inherits `IdentityUserContext` (no roles). Its own `Users` set is the credentials; the domain users hide it under the same name, and credentials are reached through `Set()`. `UserCredential.Id` is the user's id, `UserName` is that id too (so a login change needs no syncing), and `MustChangePassword` marks the initial password. There is no `SignInManager` (it pulls in cookie authentication): `CredentialStore` uses `UserManager` for hashing and lockout only. +- **Identity reads the real clock** for lockout, so `CredentialUserManager` overrides only the two virtual methods that compare or set the lockout end to use `TimeProvider`. IdentityModel has no clock seam either: `ConfigureJwtBearer` judges the token lifetime with a `LifetimeValidator` that reads `TimeProvider`, with no skew and an exclusive `exp`. +- **Sessions** are rows of `user_sessions` (`SessionStore`): the SHA-256 of a 32-byte random refresh token, valid while `now < expires_at`. Rotation is a single `UPDATE ... WHERE token_hash = old AND expires_at > now`, so two refreshes with the same token cannot both succeed. Sessions are infrastructure, not aggregates: no audit fields. +- **Access token** (HS256, `API/Auth/JwtAccessTokenIssuer`): `sub` (user id, read by `HttpCurrentUser`), `sid` (session id, used by sign-out because the refresh cookie never reaches it), `iat`, `exp`, and `must_change_password` only while the change is mandatory. `MapInboundClaims` is off so the claim names stay as issued. Configuration is split by layer over the same `Auth` section: `AuthOptions` (Infrastructure) and `JwtOptions` (API). +- **`MapApiV1()`** (`API/Common/ApiV1Group.cs`) builds the `/api/v1` group for `Program.cs` and the test factories: it requires authorization and adds `PasswordChangeRequiredFilter`, which answers 401 `password_change_required` while the token carries `must_change_password`. Endpoints opt out with `.AllowAnonymous()` (sign-in, refresh) or `.AllowPendingPasswordChange()` (`me`, sign-out, change-password). +- **Rate limiting** (`API/Auth/AuthRateLimiting`): a sliding window of 60 seconds in 6 segments per client address on sign-in and refresh; a refused request answers 429 `locked_out` with `Retry-After`. +- **Not yet covered:** the per-request account check and permission cache (#9; it must also cover `me`, sign-out and change-password), revoking sessions on deactivation (AUTH-21, #11), activation and reset links (#12) and the global rate limit of ADR-0023. + diff --git a/docs/agents/guides/local-environment.md b/docs/agents/guides/local-environment.md index f0e8625..42f0671 100644 --- a/docs/agents/guides/local-environment.md +++ b/docs/agents/guides/local-environment.md @@ -61,9 +61,13 @@ Register the API side with the Aspire client integrations for these names, and c |---|---|---|---| | `Auth:AccessTokenMinutes` | Access token lifetime (ADR-0032) | 15 | `appsettings.json` | | `Auth:RefreshTokenIdleHours` | Sliding refresh token lifetime (ADR-0032) | 8 | `appsettings.json` | -| `Auth:SigningKey` | JWT signing key (ADR-0019) | none | **User secrets** | +| `Auth:SigningKey` | JWT signing key: the base64 of at least 32 random bytes (ADR-0019) | none | **User secrets** | +| `Auth:Issuer`, `Auth:Audience` | JWT issuer and audience | `control-service` | `appsettings.json` | +| `Auth:PasswordMinLength` | Minimum password length (AUTH-16) | 8 | `appsettings.json` | +| `Auth:LockoutMaxFailedAttempts`, `Auth:LockoutMinutes` | Consecutive failures that lock a login, and for how long (AUTH-08) | 5 and 15 | `appsettings.json` | +| `RateLimiting:SignIn:PermitLimit`, `RateLimiting:Refresh:PermitLimit` | Requests per address in 60 seconds (ADR-0023, AUTH-26) | 20 and 60 | `appsettings.json` | | `Admin:Email` | E-mail of the seeded Admin (ADR-0022) | none | **User secrets** | -| `Admin:InitialPassword` | Initial Admin password, changed on first access (AUTH-13) | none | **User secrets** | +| `Admin:InitialPassword` | Initial Admin password, changed on first access (AUTH-13); at least `Auth:PasswordMinLength` characters | none | **User secrets** | | `Email:SendTimeoutSeconds` | Timeout for activation e-mails (ADR-0031) | 10 | `appsettings.json` | - **Secrets live only in the user secrets of the API project** (`UserSecretsId` is in `ControlService.API.csproj`) and, in deployment, in environment variables. Never in `appsettings*.json`, code, tests or documentation. @@ -76,6 +80,17 @@ Register the API side with the Aspire client integrations for these names, and c dotnet user-secrets set "Admin:Email" "" --project src/ControlService.API ``` +- `Admin:InitialPassword` is read only when the Admin's credential is created (the first run against a database that has none), so set it before that run. Changing it afterwards has no effect. +- The API refuses to start when `Auth:SigningKey` or `Admin:InitialPassword` is missing or invalid; the message names the key and never prints its value. + +Set the two secrets (the owner does this; agents never choose or print real values). In Windows PowerShell 5.1, this generates 32 random bytes and stores their base64 without printing it: + +```powershell +$bytes = New-Object byte[] 32; [Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($bytes) +dotnet user-secrets set 'Auth:SigningKey' ([Convert]::ToBase64String($bytes)) --project src/ControlService.API +dotnet user-secrets set 'Admin:InitialPassword' '' --project src/ControlService.API +``` + ## EF Core migrations `dotnet-ef` is a local tool (`.config/dotnet-tools.json`); restore it once per clone: @@ -90,7 +105,7 @@ Add a migration after changing the model (from `backend/ControlService`): dotnet ef migrations add --project src/ControlService.Infrastructure --startup-project src/ControlService.Infrastructure --output-dir Persistence/Migrations ``` -This uses `DesignTimeDbContextFactory`, not `Program.cs`, so it never needs a running database or Aspire: it only inspects the model. Keep a single migration, `InitialSchema`, regenerated as the model grows, until the schema is stable enough to branch into a second one. +This uses `DesignTimeDbContextFactory`, not `Program.cs`, so it never needs a running database or Aspire: it only inspects the model. The schema has two migrations: `InitialSchema` and `AddAuthentication` (Identity's credential tables and `user_sessions`, approved in #8). From here on, each schema change adds a migration; review it before committing, since a migration has no Red of its own. Migrations are applied automatically only when `ASPNETCORE_ENVIRONMENT` is `Development` (ADR-0013), which also runs `SystemRecordsSeeder` (ADR-0022). Never in other environments; a deployment step applies them there instead. @@ -116,6 +131,7 @@ The AppHost keeps `ASPIRE010` suppressed: the Aspire CLI bundle tries to trust t - `&&` and `||` do not exist. Use `;` or `if ($?) { ... }`. - Native tools that write progress to stderr (git, gh, docker) make PowerShell report an error even when the exit code is 0. Check `$LASTEXITCODE` and the actual output. - Embedded double quotes in arguments to native tools are mangled. Prefer single quotes, pass JSON with `gh api --input `, and parse `gh` output with `ConvertFrom-Json` instead of complex `--jq` strings. +- `Invoke-RestMethod -SessionVariable` / `-WebSession` does not keep the refresh cookie (`__Secure-cs-refresh`, `Secure`, `SameSite=Strict`), so `POST /api/v1/auth/refresh` answers 401 `session_expired` even though the server is right. Use `curl.exe` with a cookie jar (`-c jar.txt` on sign-in, `-b jar.txt -c jar.txt` on refresh), and pass the JSON body through stdin (`--data-binary '@-'`) to avoid the quote mangling above. Never print the cookie or the token. - Multi-line commit messages: use a single-quoted here-string with `git commit -m @'...'@` (the closing `'@` must start the line). - A tool installed during the session may need the PATH refreshed in the current shell: diff --git a/docs/agents/guides/testing.md b/docs/agents/guides/testing.md index dc19e1e..715c62d 100644 --- a/docs/agents/guides/testing.md +++ b/docs/agents/guides/testing.md @@ -75,3 +75,14 @@ Because every factory targets the same database, test collections run **sequenti - **Starting the host before mutating a fake.** `WebApplicationFactory.Services` builds and starts the host lazily on first access, which is also when migrations and seeding run. A test that sets `PersistenceApiFactory.CurrentUser` to `null` or an unknown id *before* touching `.Services` makes seeding itself fail (it also needs a signed-in "user", the Admin) instead of the save the test means to target. Access `.Services` (or open a scope) once under the default Admin first. - `Persistence/PersistenceApiFactory.cs` replaces `ICurrentUser` and `TimeProvider` with `FakeCurrentUser` and `FixedTimeProvider`, so authorship and timestamps are asserted precisely instead of against the real clock. - Assert database errors through `DbUpdateException.InnerException` as `Npgsql.PostgresException` (`SqlState`, `ConstraintName`). PostgreSQL reports an immediate `ON DELETE RESTRICT` violation as `23001` (`restrict_violation`), not `23503` (`foreign_key_violation`, which `NO ACTION` raises instead) — check which delete behavior a foreign key uses before asserting a code. + +## Authentication tests + +- **`Auth/AuthApiFactory`** is the API with a controllable clock (`Clock`, a `FixedTimeProvider` with `Advance`), so sessions and access tokens expire when the test says. `CreateHttpsClient()` talks to `https://localhost` with cookie handling off: the refresh cookie is `Secure`, so it never comes back over `http`, and tests read `Set-Cookie` and send each session's `Cookie` header themselves (`Auth/AuthHttp.cs`). +- **`Auth/RateLimitedApiFactory`** lowers both rate limits to 2 requests. `ApiFactory` sets them to 1000 so no other test hits them. +- `ApiFactory` also wires fictitious values for `Admin:InitialPassword` and `Auth:SigningKey`. The real ones live in user secrets and never in source. +- **The Admin is one row shared by the whole test assembly**, so a test that signs in as the Admin or changes its password starts with `AuthTestSupport.ResetAdminAsync` (initial password, mandatory change, no sessions, no activation time). `AuthTestSupport.CreateUserAsync` creates an active user with a credential under a unique login; `GrantAsync` and `DeactivateUserAsync` change what that user may do. +- **Any other authenticated endpoint** is stood in for by `/api/v1/test-only/protected`, mapped through `MapApiV1()` by a startup filter (that filter needs its own `UseRouting`, `UseAuthentication` and `UseAuthorization`). +- `Auth/CapturingLoggerProvider` keeps every log line, prefixed by its level, so a test can assert that no password, token or cookie value was written. To capture below `Information`, add a filter rule for the provider (`AddFilter(null, LogLevel.Trace)`): `SetMinimumLevel` applies only when no rule matches, and `appsettings.json` always matches with `Default: Information`. +- In `ControlService.Application.Tests`, `Auth/AuthTestBed` builds the handlers over the hand-written fakes in `Fakes/` (in-memory repositories, `FakeCredentialStore`, `InMemorySessionStore`, `FakeAccessTokenIssuer`, `FakeUnitOfWork`, `FixedTimeProvider`). Lockout counting is Identity's job, so the fake store scripts the outcome and the real counting is tested against PostgreSQL (`CredentialStoreTests`). + diff --git a/docs/api/conventions.md b/docs/api/conventions.md index 5b4b25b..8f76836 100644 --- a/docs/api/conventions.md +++ b/docs/api/conventions.md @@ -83,7 +83,7 @@ The front-end calls the API only through its `api` object. Replacing the prototy | 409 | `profile_in_use` | Profile assigned to users; `details.userNames` says who | Explains who uses the profile | | 409 | `system_record`, `self_deactivation`, `not_pending`, `not_inactive`, `email_missing` | Business rule refused | Shows the message in a notice | | 410 | `link_invalid` | Link expired or already used | Shows the invalid-link screen | -| 429 | `locked_out` | Lockout or rate limit, with `Retry-After` | Shows the wait message | +| 429 | `locked_out` | Lockout or rate limit, with `Retry-After` and `details.retryAfterSeconds` | Shows the wait message | | 500 | `unexpected_error` | Unexpected failure (API-14) | Shows a generic message and keeps what the person typed | Any other failure shows a generic message and keeps what the person typed. diff --git a/docs/product/features/authentication.md b/docs/product/features/authentication.md index e403820..03b1dd1 100644 --- a/docs/product/features/authentication.md +++ b/docs/product/features/authentication.md @@ -24,8 +24,11 @@ If the record has unsaved changes, "Reenviar acesso" asks to save first: *"Este | ID | Rule | |---|---| | AUTH-07 | People sign in with **login and password**. The e-mail is not used to sign in, because several people may share it. | -| AUTH-08 | After **5 consecutive failed attempts**, that login is locked for **15 minutes**. The sign-in screen never reveals whether the login or the password was wrong. | +| AUTH-08 | After **5 consecutive failed attempts**, that login is locked for **15 minutes**. The sign-in screen never reveals whether the login or the password was wrong. The **5th** wrong password already answers as locked; while locked, even the right password is refused and those attempts are not counted; a successful sign-in resets the count. | | AUTH-09 | A deactivated account cannot sign in, even with the correct password. | +| AUTH-26 | Too many sign-in or refresh requests from the same address are refused (429 `locked_out`, with `Retry-After`) with *"Muitas tentativas em pouco tempo. Aguarde alguns instantes e tente de novo."* This is not the lockout of AUTH-08, whose message states the 15 minutes. | + +**Known limitation.** A login that does not exist is never locked, so five attempts show whether a login exists. This is Identity's standard behavior, and the per-address rate limit of AUTH-26 makes scanning logins slow. ## Forgot password @@ -42,19 +45,22 @@ If the record has unsaved changes, "Reenviar acesso" asks to save first: *"Este | AUTH-13 | The Admin is the only account without an activation link, because nobody can register it. It signs in with an initial password that comes from the server configuration, never from the code (ADR-0022). | | AUTH-14 | Right after that first sign-in, the system shows **"Crie uma senha nova"**: *"Por segurança, a senha inicial precisa ser trocada no primeiro acesso."* Until the password is changed, no other screen or operation is available. | | AUTH-15 | The Admin is a system record that "cannot be changed", but **can change its own password** and use "Esqueci minha senha": credentials are not part of the user record (AUTH-19). | +| AUTH-24 | Changing the password through the change-password operation is only for the account with a mandatory change. For any other account it is refused (403 `forbidden`) with *"Sua senha já foi criada. Para trocá-la, use “Esqueci minha senha”."* | ## Passwords | ID | Rule | |---|---| -| AUTH-16 | A password has at least **8 characters**, with no requirement for symbols or digits: long passwords are safer and easier to remember than short complicated ones. The person types it twice to confirm. | +| AUTH-16 | A password has at least **8 characters**, with no requirement for symbols or digits: long passwords are safer and easier to remember than short complicated ones. The person types it twice to confirm. Passwords are never trimmed: spaces are part of the password. | +| AUTH-25 | The new password must be different from the initial one: *"A nova senha precisa ser diferente da senha inicial."* (field `password`) | ## Sessions | ID | Rule | |---|---| | AUTH-17 | A session ends after **8 hours without any action**. In the back-end: a 15-minute access token kept only in the page's memory, and a refresh token in an `HttpOnly` cookie, valid for 8 hours from the last refresh and rotated on each use (ADR-0019, ADR-0032). The front-end refreshes only when it needs to make a request, never on a timer. | -| AUTH-18 | The server checks on **every request** that the account is still active. A deactivated person is rejected on their next request, even with a valid access token. | +| AUTH-18 | The server checks on **every request** that the account is still active. A deactivated person is rejected on their next request, even with a valid access token. Refresh is anonymous (it carries only the cookie), so it makes the same check itself: a non-active account is refused with `account_inactive` and its session is removed. | +| AUTH-27 | The server's `message` for 401 `session_expired`, `password_change_required` and `account_inactive` (during a session) is, respectively, the "Session ended" message, the AUTH-14 text and the "Deactivated account" message. A missing, invalid or expired access token is answered as `session_expired`. | When a session ends, the sign-in screen appears **over** the system without closing the tabs. If the same person signs in again, everything is where it was, including unsaved forms. @@ -67,6 +73,7 @@ When a session ends, the sign-in screen appears **over** the system without clos | AUTH-21 | Deactivating a user invalidates their pending links and revokes their refresh tokens. | | AUTH-22 | Every duration and limit in this document is an initial value, adjustable in the server configuration. | | AUTH-23 | Activating a user who is not `pending` (for example, opening the link after the account became active or was deactivated) is refused like any other invalid link, with the message *"Este link não vale mais. Peça a quem cadastrou você para reenviar o acesso."* | +| AUTH-28 | Messages that state a duration or a limit show the configured value (AUTH-22): with the defaults, the texts below are exact; with 30 minutes of lockout, the message says "Aguarde 30 minutos". | ## Messages @@ -79,12 +86,16 @@ Verbatim, in Portuguese. | Access resent | Enviamos um novo link de ativação para *{e-mail}*. | | Wrong login or password | Login ou senha incorretos. | | Locked out | Muitas tentativas sem sucesso. Aguarde 15 minutos e tente de novo. | +| Too many requests (rate limit) | Muitas tentativas em pouco tempo. Aguarde alguns instantes e tente de novo. | | Deactivated account (correct password) | Este acesso está desativado. Fale com o responsável pelo sistema. | | Activation link expired or used | Este link não vale mais. Peça a quem cadastrou você para reenviar o acesso. | | Reset link expired or used | Este link não vale mais. Peça um novo em “Esqueci minha senha”. | | Password reset requested | Se esse login existir, enviamos um link para o e-mail cadastrado. Confira sua caixa de entrada. | | Password too short | A senha precisa ter pelo menos 8 caracteres. | | Passwords differ | As duas senhas não são iguais. Digite de novo. | +| New password equal to the initial one | A nova senha precisa ser diferente da senha inicial. | +| Change password without a mandatory change | Sua senha já foi criada. Para trocá-la, use “Esqueci minha senha”. | +| Initial password must be changed (`password_change_required`) | Por segurança, a senha inicial precisa ser trocada no primeiro acesso. | | Admin initial password changed | Senha criada. Tudo pronto para começar. | | Session ended | Sua sessão terminou. Entre de novo para continuar. Suas abas continuam abertas. | | Signed out with the Sair button | Você saiu do sistema. | @@ -94,17 +105,17 @@ Verbatim, in Portuguese. | Front-end method | Route | Who | Notes | |---|---|---|---| -| `signIn(login, password)` | `POST /api/v1/auth/sign-in` | Anyone | Returns `{ mustChangePassword }` | -| `hasSession()` | `POST /api/v1/auth/refresh` | Anyone | Uses the refresh cookie; called when the page opens | -| `signOut()` | `POST /api/v1/auth/sign-out` | Signed in | Revokes the refresh token | -| `changePassword(password, confirmation)` | `POST /api/v1/auth/change-password` | Signed in | Only for the account with a mandatory change | +| `signIn(login, password)` | `POST /api/v1/auth/sign-in` | Anyone | 200 `{ accessToken, expiresIn, mustChangePassword }`; sets the refresh cookie. `expiresIn` is in **seconds** (900), not a timestamp, so a wrong clock on the person's computer does not break the refresh decision | +| `hasSession()` | `POST /api/v1/auth/refresh` | Anyone | Uses the refresh cookie; called when the page opens. Same 200 body as sign-in and a rotated cookie; without a valid session, 401 `session_expired` | +| `signOut()` | `POST /api/v1/auth/sign-out` | Signed in | 204. Ends the session named by the `sid` claim of the access token (the cookie is scoped to the refresh route and never arrives here) and expires the cookie; the person's other sessions stay open | +| `changePassword(password, confirmation)` | `POST /api/v1/auth/change-password` | Signed in | Only for the account with a mandatory change (AUTH-24). Same 200 body as sign-in; ends every session of the person and starts a new one | | `checkLink(token, purpose)` | `POST /api/v1/auth/links/check` | Anyone | `purpose`: `activation` or `reset`. Returns `{ valid, displayName, login }` | | `activateAccount(token, password, confirmation)` | `POST /api/v1/auth/activate` | Anyone | | | `requestPasswordReset(login)` | `POST /api/v1/auth/password-reset/request` | Anyone | Always the same response (AUTH-11) | | `resetPassword(token, password, confirmation)` | `POST /api/v1/auth/password-reset` | Anyone | | -| `me()` | `GET /api/v1/me` | Signed in | `{ id, displayName, login, levels }`, with the effective level on each screen | +| `me()` | `GET /api/v1/me` | Signed in | `{ id, displayName, login, levels }`, where `levels` is `[{ "screen": "", "level": "" }]` with **every** screen of the catalog, in catalog order, including `negado` (same shape as a profile's `levels`) | -Sign-in, refresh and the e-mail-sending operations are rate limited (ADR-0023). +Sign-in, refresh and the e-mail-sending operations are rate limited (ADR-0023, AUTH-26). Until the initial password is replaced, every operation except `me`, sign-out and change-password answers 401 `password_change_required` (AUTH-14). ## Errors @@ -112,11 +123,12 @@ Sign-in, refresh and the e-mail-sending operations are rate limited (ADR-0023). |---|---|---| | 401 | `invalid_credentials` | Wrong login or password | | 403 | `account_inactive` | **At sign-in**: correct password, deactivated account | -| 401 | `account_inactive` | **During a session**: the account was deactivated (AUTH-18) | +| 401 | `account_inactive` | **During a session**, including at refresh: the account was deactivated (AUTH-18) | | 401 | `password_change_required` | The initial password was not changed yet (AUTH-14) | | 401 | `session_expired` | The session ended | | 410 | `link_invalid` | Link expired or already used, or the account is no longer pending (AUTH-23) | -| 429 | `locked_out` | Lockout (AUTH-08) or rate limit, with `Retry-After` | +| 403 | `forbidden` | Change password on an account without a mandatory change (AUTH-24) | +| 429 | `locked_out` | Lockout (AUTH-08) or rate limit (AUTH-26), with `Retry-After` and `details.retryAfterSeconds` (the seconds left, rounded up) | | 400 | `validation_failed` | Password rules; fields `password` and `passwordConfirmation` | The error format and what the front-end does with each code are in the [API conventions](../../api/conventions.md#errors). diff --git a/docs/product/features/users.md b/docs/product/features/users.md index e562515..a786bf7 100644 --- a/docs/product/features/users.md +++ b/docs/product/features/users.md @@ -157,6 +157,7 @@ The deactivation confirmation is: *{Nome} não vai mais conseguir entrar no sist | USR-28 | Changing or deactivating the Admin is refused with the message *"O usuário Admin é do sistema e não pode ser alterado nem desativado."* | | USR-32 | The Admin is created with `activatedAt` empty: it never creates a password through an activation link. | | USR-33 | The Admin's e-mail is read from the configuration only when the Admin is first created; changing the configuration later does not change the stored record. | +| USR-34 | The Admin's `activatedAt` is filled when it replaces the initial password on its first access ([AUTH-14](authentication.md#first-access-of-the-admin)). This does not break USR-23, for the same reason as AUTH-15: it is the Admin's own first access, not an edit of the record. | | Field | Value | |---|---|