From ad189a390a42e31eda223f34aa605487cbcc66d5 Mon Sep 17 00:00:00 2001
From: Matheus Mendes <104966152+MendesMat@users.noreply.github.com>
Date: Wed, 30 Sep 2026 01:35:28 -0300
Subject: [PATCH 01/22] feat(users): fill the Admin activation time on its
first access
USR-34: the Admin replacing the initial password is its own first access,
so the system-record guard does not apply. Repeating it keeps the first time.
---
.../src/ControlService.Domain/Users/User.cs | 4 ++++
.../Users/UserTests.cs | 24 +++++++++++++++++++
2 files changed, 28 insertions(+)
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/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);
+ }
}
From f51205ad6b39899ef5e6b4104c325d1957c7ef92 Mon Sep 17 00:00:00 2001
From: Matheus Mendes <104966152+MendesMat@users.noreply.github.com>
Date: Wed, 30 Sep 2026 01:41:27 -0300
Subject: [PATCH 02/22] feat(auth): sign in with login and password
AUTH-07, AUTH-08: the handler returns invalid_credentials for an unknown
login, a login outside the login format and a wrong password, so the answer
never reveals which part was wrong. A pending account has no credential and
fails like a wrong password (AUTH-02): that test passed without a Red because
the previous cycle already covered a login with no stored password.
---
.../Auth/AuthErrors.cs | 10 +++
.../Auth/AuthSettings.cs | 5 ++
.../Auth/CredentialCheck.cs | 19 +++++
.../Auth/IAccessTokenIssuer.cs | 10 +++
.../Auth/ICredentialStore.cs | 8 ++
.../Auth/ISessionStore.cs | 7 ++
.../Auth/SessionGrant.cs | 10 +++
.../Auth/SessionTokens.cs | 5 ++
.../Auth/SignIn/SignInCommand.cs | 3 +
.../Auth/SignIn/SignInHandler.cs | 41 ++++++++++
.../Users/IUserRepository.cs | 8 ++
.../Auth/AuthTestBed.cs | 37 +++++++++
.../Auth/SignInTests.cs | 78 +++++++++++++++++++
.../Fakes/FakeAccessTokenIssuer.cs | 12 +++
.../Fakes/FakeCredentialStore.cs | 17 ++++
.../Fakes/FixedTimeProvider.cs | 11 +++
.../Fakes/InMemorySessionStore.cs | 19 +++++
.../Fakes/InMemoryUserRepository.cs | 14 ++++
18 files changed, 314 insertions(+)
create mode 100644 backend/ControlService/src/ControlService.Application/Auth/AuthErrors.cs
create mode 100644 backend/ControlService/src/ControlService.Application/Auth/AuthSettings.cs
create mode 100644 backend/ControlService/src/ControlService.Application/Auth/CredentialCheck.cs
create mode 100644 backend/ControlService/src/ControlService.Application/Auth/IAccessTokenIssuer.cs
create mode 100644 backend/ControlService/src/ControlService.Application/Auth/ICredentialStore.cs
create mode 100644 backend/ControlService/src/ControlService.Application/Auth/ISessionStore.cs
create mode 100644 backend/ControlService/src/ControlService.Application/Auth/SessionGrant.cs
create mode 100644 backend/ControlService/src/ControlService.Application/Auth/SessionTokens.cs
create mode 100644 backend/ControlService/src/ControlService.Application/Auth/SignIn/SignInCommand.cs
create mode 100644 backend/ControlService/src/ControlService.Application/Auth/SignIn/SignInHandler.cs
create mode 100644 backend/ControlService/src/ControlService.Application/Users/IUserRepository.cs
create mode 100644 backend/ControlService/tests/ControlService.Application.Tests/Auth/AuthTestBed.cs
create mode 100644 backend/ControlService/tests/ControlService.Application.Tests/Auth/SignInTests.cs
create mode 100644 backend/ControlService/tests/ControlService.Application.Tests/Fakes/FakeAccessTokenIssuer.cs
create mode 100644 backend/ControlService/tests/ControlService.Application.Tests/Fakes/FakeCredentialStore.cs
create mode 100644 backend/ControlService/tests/ControlService.Application.Tests/Fakes/FixedTimeProvider.cs
create mode 100644 backend/ControlService/tests/ControlService.Application.Tests/Fakes/InMemorySessionStore.cs
create mode 100644 backend/ControlService/tests/ControlService.Application.Tests/Fakes/InMemoryUserRepository.cs
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..ce918a3
--- /dev/null
+++ b/backend/ControlService/src/ControlService.Application/Auth/AuthErrors.cs
@@ -0,0 +1,10 @@
+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.");
+}
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/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/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..98ebce3
--- /dev/null
+++ b/backend/ControlService/src/ControlService.Application/Auth/ICredentialStore.cs
@@ -0,0 +1,8 @@
+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);
+}
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..c38c702
--- /dev/null
+++ b/backend/ControlService/src/ControlService.Application/Auth/ISessionStore.cs
@@ -0,0 +1,7 @@
+namespace ControlService.Application.Auth;
+
+/// Sessions are refresh tokens stored hashed (AUTH-19, ADR-0019).
+public interface ISessionStore
+{
+ Task StartAsync(Guid userId, CancellationToken cancellationToken);
+}
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..bebbd90
--- /dev/null
+++ b/backend/ControlService/src/ControlService.Application/Auth/SessionGrant.cs
@@ -0,0 +1,10 @@
+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);
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..79488b0
--- /dev/null
+++ b/backend/ControlService/src/ControlService.Application/Auth/SignIn/SignInHandler.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.SignIn;
+
+public sealed class SignInHandler(
+ IUserRepository users,
+ ICredentialStore credentials,
+ ISessionStore sessions,
+ IAccessTokenIssuer accessTokens) : 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);
+ if (login.IsFailure)
+ {
+ return Result.Failure(AuthErrors.InvalidCredentials);
+ }
+
+ var user = await users.GetByLoginAsync(login.Value, cancellationToken);
+ if (user is null)
+ {
+ return Result.Failure(AuthErrors.InvalidCredentials);
+ }
+
+ var check = await credentials.CheckPasswordAsync(user.Id, command.Password, cancellationToken);
+ if (check.Outcome != CredentialCheckOutcome.Succeeded)
+ {
+ return Result.Failure(AuthErrors.InvalidCredentials);
+ }
+
+ var session = await sessions.StartAsync(user.Id, cancellationToken);
+ var accessToken = accessTokens.Issue(user.Id, session.SessionId, mustChangePassword: false);
+
+ return Result.Success(new SessionGrant(
+ accessToken.Value, accessToken.ExpiresInSeconds, MustChangePassword: false, session.RefreshToken, session.ExpiresAt));
+ }
+}
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..6ea889a
--- /dev/null
+++ b/backend/ControlService/src/ControlService.Application/Users/IUserRepository.cs
@@ -0,0 +1,8 @@
+using ControlService.Domain.Users;
+
+namespace ControlService.Application.Users;
+
+public interface IUserRepository
+{
+ Task GetByLoginAsync(Login login, CancellationToken cancellationToken);
+}
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..1c51e6c
--- /dev/null
+++ b/backend/ControlService/tests/ControlService.Application.Tests/Auth/AuthTestBed.cs
@@ -0,0 +1,37 @@
+using ControlService.Application.Auth;
+using ControlService.Application.Auth.SignIn;
+using ControlService.Application.Tests.Fakes;
+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
+{
+ public InMemoryUserRepository Users { get; } = new();
+
+ public FakeCredentialStore Credentials { get; } = new();
+
+ public InMemorySessionStore Sessions { get; } = new();
+
+ public FakeAccessTokenIssuer Tokens { get; } = new();
+
+ public SignInHandler CreateSignInHandler() => new(Users, Credentials, Sessions, Tokens);
+
+ 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;
+ }
+}
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..00df3db
--- /dev/null
+++ b/backend/ControlService/tests/ControlService.Application.Tests/Auth/SignInTests.cs
@@ -0,0 +1,78 @@
+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();
+ }
+}
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..1c9fc12
--- /dev/null
+++ b/backend/ControlService/tests/ControlService.Application.Tests/Fakes/FakeCredentialStore.cs
@@ -0,0 +1,17 @@
+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 = [];
+
+ public void SetPassword(Guid userId, string password) => _passwords[userId] = password;
+
+ public Task CheckPasswordAsync(Guid userId, string password, CancellationToken cancellationToken) =>
+ Task.FromResult(_passwords.TryGetValue(userId, out var stored) && stored == password
+ ? CredentialCheck.Succeeded
+ : CredentialCheck.Failed);
+}
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/InMemorySessionStore.cs b/backend/ControlService/tests/ControlService.Application.Tests/Fakes/InMemorySessionStore.cs
new file mode 100644
index 0000000..c048aee
--- /dev/null
+++ b/backend/ControlService/tests/ControlService.Application.Tests/Fakes/InMemorySessionStore.cs
@@ -0,0 +1,19 @@
+using ControlService.Application.Auth;
+
+namespace ControlService.Application.Tests.Fakes;
+
+internal sealed class InMemorySessionStore : ISessionStore
+{
+ private readonly List _sessions = [];
+
+ public IReadOnlyList SessionsOf(Guid userId) => _sessions.Where(session => session.UserId == userId).ToArray();
+
+ public Task StartAsync(Guid userId, CancellationToken cancellationToken)
+ {
+ var session = new StoredSession(Guid.CreateVersion7(), userId, $"refresh-token-{_sessions.Count + 1}");
+ _sessions.Add(session);
+ return Task.FromResult(new SessionTokens(session.Id, session.RefreshToken, DateTimeOffset.MaxValue));
+ }
+
+ 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..6efc2b4
--- /dev/null
+++ b/backend/ControlService/tests/ControlService.Application.Tests/Fakes/InMemoryUserRepository.cs
@@ -0,0 +1,14 @@
+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 GetByLoginAsync(Login login, CancellationToken cancellationToken) =>
+ Task.FromResult(_users.Find(user => user.Login.Value == login.Value));
+}
From 2a951773a6e2c26923791c5585e671543838bfd4 Mon Sep 17 00:00:00 2001
From: Matheus Mendes <104966152+MendesMat@users.noreply.github.com>
Date: Wed, 30 Sep 2026 01:46:18 -0300
Subject: [PATCH 03/22] feat(auth): lock out, deactivated accounts and the
mandatory password change at sign-in
AUTH-08, AUTH-09, AUTH-14, AUTH-28: a locked login is refused even with the
right password, with the configured minutes and the seconds left; only a
correct password reveals a deactivated account; an account with its initial
password gets a token flagged for the mandatory change. The deactivated
account with a wrong password had no Red: the password check already comes
first.
---
.../Auth/AuthErrors.cs | 8 ++
.../Auth/ICredentialStore.cs | 3 +
.../Auth/SignIn/SignInHandler.cs | 26 +++++--
.../Auth/AuthTestBed.cs | 15 +++-
.../Auth/SignInTests.cs | 76 +++++++++++++++++++
.../Fakes/FakeCredentialStore.cs | 20 ++++-
6 files changed, 136 insertions(+), 12 deletions(-)
diff --git a/backend/ControlService/src/ControlService.Application/Auth/AuthErrors.cs b/backend/ControlService/src/ControlService.Application/Auth/AuthErrors.cs
index ce918a3..3d3fd9f 100644
--- a/backend/ControlService/src/ControlService.Application/Auth/AuthErrors.cs
+++ b/backend/ControlService/src/ControlService.Application/Auth/AuthErrors.cs
@@ -7,4 +7,12 @@ namespace ControlService.Application.Auth;
public static class AuthErrors
{
public static Error InvalidCredentials { get; } = new("invalid_credentials", "Login ou senha incorretos.");
+
+ public static Error AccountInactive { get; } = new(
+ "account_inactive", "Este acesso está desativado. Fale com o responsável pelo sistema.");
+
+ 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/ICredentialStore.cs b/backend/ControlService/src/ControlService.Application/Auth/ICredentialStore.cs
index 98ebce3..e7f2b37 100644
--- a/backend/ControlService/src/ControlService.Application/Auth/ICredentialStore.cs
+++ b/backend/ControlService/src/ControlService.Application/Auth/ICredentialStore.cs
@@ -5,4 +5,7 @@ namespace ControlService.Application.Auth;
public interface ICredentialStore
{
Task CheckPasswordAsync(Guid userId, string password, 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/SignIn/SignInHandler.cs b/backend/ControlService/src/ControlService.Application/Auth/SignIn/SignInHandler.cs
index 79488b0..bdc452d 100644
--- a/backend/ControlService/src/ControlService.Application/Auth/SignIn/SignInHandler.cs
+++ b/backend/ControlService/src/ControlService.Application/Auth/SignIn/SignInHandler.cs
@@ -9,33 +9,43 @@ public sealed class SignInHandler(
IUserRepository users,
ICredentialStore credentials,
ISessionStore sessions,
- IAccessTokenIssuer accessTokens) : ICommandHandler
+ 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);
- if (login.IsFailure)
+ var user = login.IsSuccess ? await users.GetByLoginAsync(login.Value, cancellationToken) : null;
+ if (user is null)
{
return Result.Failure(AuthErrors.InvalidCredentials);
}
- var user = await users.GetByLoginAsync(login.Value, cancellationToken);
- if (user is null)
+ var check = await credentials.CheckPasswordAsync(user.Id, command.Password, cancellationToken);
+ if (check.Outcome == CredentialCheckOutcome.LockedOut)
{
- return Result.Failure(AuthErrors.InvalidCredentials);
+ var retryAfter = Math.Ceiling((check.LockoutEnd!.Value - timeProvider.GetUtcNow()).TotalSeconds);
+ return Result.Failure(AuthErrors.LockedOut(settings.LockoutMinutes, (int)retryAfter));
}
- var check = await credentials.CheckPasswordAsync(user.Id, command.Password, cancellationToken);
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 accessToken = accessTokens.Issue(user.Id, session.SessionId, mustChangePassword: false);
+ var mustChangePassword = await credentials.MustChangePasswordAsync(user.Id, cancellationToken);
+ var accessToken = accessTokens.Issue(user.Id, session.SessionId, mustChangePassword);
return Result.Success(new SessionGrant(
- accessToken.Value, accessToken.ExpiresInSeconds, MustChangePassword: false, session.RefreshToken, session.ExpiresAt));
+ accessToken.Value, accessToken.ExpiresInSeconds, mustChangePassword, session.RefreshToken, session.ExpiresAt));
}
}
diff --git a/backend/ControlService/tests/ControlService.Application.Tests/Auth/AuthTestBed.cs b/backend/ControlService/tests/ControlService.Application.Tests/Auth/AuthTestBed.cs
index 1c51e6c..b678bc4 100644
--- a/backend/ControlService/tests/ControlService.Application.Tests/Auth/AuthTestBed.cs
+++ b/backend/ControlService/tests/ControlService.Application.Tests/Auth/AuthTestBed.cs
@@ -7,8 +7,12 @@ 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
+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 FakeCredentialStore Credentials { get; } = new();
@@ -17,7 +21,7 @@ internal sealed class AuthTestBed
public FakeAccessTokenIssuer Tokens { get; } = new();
- public SignInHandler CreateSignInHandler() => new(Users, Credentials, Sessions, Tokens);
+ public SignInHandler CreateSignInHandler() => new(Users, Credentials, Sessions, Tokens, Settings, Clock);
public User AddPendingUser(string login)
{
@@ -34,4 +38,11 @@ public User AddActiveUser(string login, string password)
Credentials.SetPassword(user.Id, password);
return user;
}
+
+ 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/SignInTests.cs b/backend/ControlService/tests/ControlService.Application.Tests/Auth/SignInTests.cs
index 00df3db..97359d4 100644
--- a/backend/ControlService/tests/ControlService.Application.Tests/Auth/SignInTests.cs
+++ b/backend/ControlService/tests/ControlService.Application.Tests/Auth/SignInTests.cs
@@ -75,4 +75,80 @@ public async Task Pending_account_is_refused_as_invalid_credentials() // AUTH-02
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/Fakes/FakeCredentialStore.cs b/backend/ControlService/tests/ControlService.Application.Tests/Fakes/FakeCredentialStore.cs
index 1c9fc12..e02653f 100644
--- a/backend/ControlService/tests/ControlService.Application.Tests/Fakes/FakeCredentialStore.cs
+++ b/backend/ControlService/tests/ControlService.Application.Tests/Fakes/FakeCredentialStore.cs
@@ -7,11 +7,27 @@ namespace ControlService.Application.Tests.Fakes;
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 Task CheckPasswordAsync(Guid userId, string password, CancellationToken cancellationToken) =>
- Task.FromResult(_passwords.TryGetValue(userId, out var stored) && stored == 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 MustChangePasswordAsync(Guid userId, CancellationToken cancellationToken) =>
+ Task.FromResult(_mustChange.Contains(userId));
}
From 65cd18dcd4a13155dc900ef235ec3f9f4af7037d Mon Sep 17 00:00:00 2001
From: Matheus Mendes <104966152+MendesMat@users.noreply.github.com>
Date: Wed, 30 Sep 2026 01:51:24 -0300
Subject: [PATCH 04/22] feat(auth): refresh the session and rotate its token
AUTH-17, AUTH-18, AUTH-27, AUTH-14: refresh answers session_expired for an
unknown or expired token, checks the account status because the route is
anonymous and ends the session of a deactivated account, and keeps the
mandatory password change flag.
---
.../Auth/AuthErrors.cs | 3 +
.../Auth/ISessionStore.cs | 6 ++
.../RefreshSession/RefreshSessionCommand.cs | 4 +
.../RefreshSession/RefreshSessionHandler.cs | 41 ++++++++
.../Auth/RotatedSession.cs | 4 +
.../Auth/SessionGrant.cs | 6 +-
.../Auth/SignIn/SignInHandler.cs | 3 +-
.../Users/IUserRepository.cs | 2 +
.../Auth/AuthTestBed.cs | 3 +
.../Auth/RefreshSessionTests.cs | 98 +++++++++++++++++++
.../Fakes/InMemorySessionStore.cs | 25 +++++
.../Fakes/InMemoryUserRepository.cs | 3 +
12 files changed, 195 insertions(+), 3 deletions(-)
create mode 100644 backend/ControlService/src/ControlService.Application/Auth/RefreshSession/RefreshSessionCommand.cs
create mode 100644 backend/ControlService/src/ControlService.Application/Auth/RefreshSession/RefreshSessionHandler.cs
create mode 100644 backend/ControlService/src/ControlService.Application/Auth/RotatedSession.cs
create mode 100644 backend/ControlService/tests/ControlService.Application.Tests/Auth/RefreshSessionTests.cs
diff --git a/backend/ControlService/src/ControlService.Application/Auth/AuthErrors.cs b/backend/ControlService/src/ControlService.Application/Auth/AuthErrors.cs
index 3d3fd9f..55a93eb 100644
--- a/backend/ControlService/src/ControlService.Application/Auth/AuthErrors.cs
+++ b/backend/ControlService/src/ControlService.Application/Auth/AuthErrors.cs
@@ -8,6 +8,9 @@ public static class AuthErrors
{
public static Error InvalidCredentials { get; } = new("invalid_credentials", "Login ou senha incorretos.");
+ 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.");
diff --git a/backend/ControlService/src/ControlService.Application/Auth/ISessionStore.cs b/backend/ControlService/src/ControlService.Application/Auth/ISessionStore.cs
index c38c702..b9bfdf0 100644
--- a/backend/ControlService/src/ControlService.Application/Auth/ISessionStore.cs
+++ b/backend/ControlService/src/ControlService.Application/Auth/ISessionStore.cs
@@ -4,4 +4,10 @@ namespace ControlService.Application.Auth;
public interface ISessionStore
{
Task StartAsync(Guid userId, CancellationToken cancellationToken);
+
+ Task EndAsync(Guid sessionId, 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
index bebbd90..a58ef1a 100644
--- a/backend/ControlService/src/ControlService.Application/Auth/SessionGrant.cs
+++ b/backend/ControlService/src/ControlService.Application/Auth/SessionGrant.cs
@@ -7,4 +7,8 @@ public sealed record SessionGrant(
int ExpiresIn,
bool MustChangePassword,
string RefreshToken,
- DateTimeOffset RefreshTokenExpiresAt);
+ 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/SignIn/SignInHandler.cs b/backend/ControlService/src/ControlService.Application/Auth/SignIn/SignInHandler.cs
index bdc452d..8212f3e 100644
--- a/backend/ControlService/src/ControlService.Application/Auth/SignIn/SignInHandler.cs
+++ b/backend/ControlService/src/ControlService.Application/Auth/SignIn/SignInHandler.cs
@@ -45,7 +45,6 @@ public async Task> Handle(SignInCommand command, Cancellati
var mustChangePassword = await credentials.MustChangePasswordAsync(user.Id, cancellationToken);
var accessToken = accessTokens.Issue(user.Id, session.SessionId, mustChangePassword);
- return Result.Success(new SessionGrant(
- accessToken.Value, accessToken.ExpiresInSeconds, mustChangePassword, session.RefreshToken, session.ExpiresAt));
+ return Result.Success(SessionGrant.From(accessToken, session, mustChangePassword));
}
}
diff --git a/backend/ControlService/src/ControlService.Application/Users/IUserRepository.cs b/backend/ControlService/src/ControlService.Application/Users/IUserRepository.cs
index 6ea889a..8d42ca7 100644
--- a/backend/ControlService/src/ControlService.Application/Users/IUserRepository.cs
+++ b/backend/ControlService/src/ControlService.Application/Users/IUserRepository.cs
@@ -4,5 +4,7 @@ namespace ControlService.Application.Users;
public interface IUserRepository
{
+ Task GetByIdAsync(Guid id, CancellationToken cancellationToken);
+
Task GetByLoginAsync(Login login, CancellationToken cancellationToken);
}
diff --git a/backend/ControlService/tests/ControlService.Application.Tests/Auth/AuthTestBed.cs b/backend/ControlService/tests/ControlService.Application.Tests/Auth/AuthTestBed.cs
index b678bc4..fb3004b 100644
--- a/backend/ControlService/tests/ControlService.Application.Tests/Auth/AuthTestBed.cs
+++ b/backend/ControlService/tests/ControlService.Application.Tests/Auth/AuthTestBed.cs
@@ -1,4 +1,5 @@
using ControlService.Application.Auth;
+using ControlService.Application.Auth.RefreshSession;
using ControlService.Application.Auth.SignIn;
using ControlService.Application.Tests.Fakes;
using ControlService.Domain.Users;
@@ -23,6 +24,8 @@ internal sealed class AuthTestBed(AuthSettings? settings = null)
public SignInHandler CreateSignInHandler() => new(Users, Credentials, Sessions, Tokens, Settings, Clock);
+ public RefreshSessionHandler CreateRefreshSessionHandler() => new(Users, Credentials, Sessions, Tokens);
+
public User AddPendingUser(string login)
{
var user = User.Create(
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/Fakes/InMemorySessionStore.cs b/backend/ControlService/tests/ControlService.Application.Tests/Fakes/InMemorySessionStore.cs
index c048aee..8be3370 100644
--- a/backend/ControlService/tests/ControlService.Application.Tests/Fakes/InMemorySessionStore.cs
+++ b/backend/ControlService/tests/ControlService.Application.Tests/Fakes/InMemorySessionStore.cs
@@ -8,6 +8,10 @@ internal sealed class InMemorySessionStore : ISessionStore
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, $"refresh-token-{_sessions.Count + 1}");
@@ -15,5 +19,26 @@ public Task StartAsync(Guid userId, CancellationToken cancellatio
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 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 = $"refresh-token-{_sessions.Count + 1}" };
+ _sessions[index] = rotated;
+ return Task.FromResult(
+ new RotatedSession(rotated.UserId, new SessionTokens(rotated.Id, rotated.RefreshToken, DateTimeOffset.MaxValue)));
+ }
+
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
index 6efc2b4..b682de1 100644
--- a/backend/ControlService/tests/ControlService.Application.Tests/Fakes/InMemoryUserRepository.cs
+++ b/backend/ControlService/tests/ControlService.Application.Tests/Fakes/InMemoryUserRepository.cs
@@ -9,6 +9,9 @@ internal sealed class InMemoryUserRepository : IUserRepository
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));
}
From b3b07753566683524181d1158faa94e41f58ed2e Mon Sep 17 00:00:00 2001
From: Matheus Mendes <104966152+MendesMat@users.noreply.github.com>
Date: Wed, 30 Sep 2026 01:55:28 -0300
Subject: [PATCH 05/22] feat(auth): validate the new password and sign out
AUTH-16, AUTH-28, ADR-0019: the password needs the configured minimum length
(default 8) and a matching confirmation, with the messages under the fields
password and passwordConfirmation. Sign-out ends only the session named by
the sid claim. The configured-length test had no Red: the validator already
interpolated the setting.
---
.../ChangePassword/ChangePasswordCommand.cs | 5 ++
.../ChangePassword/ChangePasswordHandler.cs | 17 +++++
.../ChangePassword/ChangePasswordValidator.cs | 17 +++++
.../Auth/SignOut/SignOutCommand.cs | 5 ++
.../Auth/SignOut/SignOutHandler.cs | 13 ++++
.../ControlService.Application/Common/Unit.cs | 5 ++
.../Auth/AuthTestBed.cs | 18 ++++++
.../Auth/ChangePasswordTests.cs | 64 +++++++++++++++++++
.../Auth/SignOutTests.cs | 24 +++++++
9 files changed, 168 insertions(+)
create mode 100644 backend/ControlService/src/ControlService.Application/Auth/ChangePassword/ChangePasswordCommand.cs
create mode 100644 backend/ControlService/src/ControlService.Application/Auth/ChangePassword/ChangePasswordHandler.cs
create mode 100644 backend/ControlService/src/ControlService.Application/Auth/ChangePassword/ChangePasswordValidator.cs
create mode 100644 backend/ControlService/src/ControlService.Application/Auth/SignOut/SignOutCommand.cs
create mode 100644 backend/ControlService/src/ControlService.Application/Auth/SignOut/SignOutHandler.cs
create mode 100644 backend/ControlService/src/ControlService.Application/Common/Unit.cs
create mode 100644 backend/ControlService/tests/ControlService.Application.Tests/Auth/ChangePasswordTests.cs
create mode 100644 backend/ControlService/tests/ControlService.Application.Tests/Auth/SignOutTests.cs
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..3f27ff9
--- /dev/null
+++ b/backend/ControlService/src/ControlService.Application/Auth/ChangePassword/ChangePasswordHandler.cs
@@ -0,0 +1,17 @@
+using ControlService.Application.Common;
+using ControlService.Domain.Common;
+
+namespace ControlService.Application.Auth.ChangePassword;
+
+public sealed class ChangePasswordHandler(
+ ISessionStore sessions,
+ IAccessTokenIssuer accessTokens) : ICommandHandler
+{
+ public async Task> Handle(ChangePasswordCommand command, CancellationToken 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/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/tests/ControlService.Application.Tests/Auth/AuthTestBed.cs b/backend/ControlService/tests/ControlService.Application.Tests/Auth/AuthTestBed.cs
index fb3004b..3a3d1eb 100644
--- a/backend/ControlService/tests/ControlService.Application.Tests/Auth/AuthTestBed.cs
+++ b/backend/ControlService/tests/ControlService.Application.Tests/Auth/AuthTestBed.cs
@@ -1,6 +1,9 @@
using ControlService.Application.Auth;
+using ControlService.Application.Auth.ChangePassword;
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.Users;
@@ -26,6 +29,11 @@ internal sealed class AuthTestBed(AuthSettings? settings = null)
public RefreshSessionHandler CreateRefreshSessionHandler() => new(Users, Credentials, Sessions, Tokens);
+ public SignOutHandler CreateSignOutHandler() => new(Sessions);
+
+ public ValidatingCommandHandler CreateChangePasswordHandler() =>
+ new(new ChangePasswordHandler(Sessions, Tokens), new ChangePasswordValidator(Settings));
+
public User AddPendingUser(string login)
{
var user = User.Create(
@@ -42,6 +50,16 @@ public User AddActiveUser(string login, string 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);
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..c9165c1
--- /dev/null
+++ b/backend/ControlService/tests/ControlService.Application.Tests/Auth/ChangePasswordTests.cs
@@ -0,0 +1,64 @@
+using ControlService.Application.Auth;
+using ControlService.Application.Auth.ChangePassword;
+
+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."]);
+ }
+}
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]);
+ }
+}
From 7dde3b1f7a86324d2730ac00d6cc7ae97ceca503 Mon Sep 17 00:00:00 2001
From: Matheus Mendes <104966152+MendesMat@users.noreply.github.com>
Date: Wed, 30 Sep 2026 02:00:35 -0300
Subject: [PATCH 06/22] feat(auth): change the initial password
AUTH-24, AUTH-25, USR-34, ADR-0019, ADR-0032: only an account with a mandatory
change may use it (403 forbidden otherwise), the new password must differ from
the initial one, the Admin's activatedAt is filled through the unit of work, every
session ends and a new one starts. The refused save keeps the password unchanged:
that test had no Red, since the branch was written with the previous cycle.
---
.../Auth/AuthErrors.cs | 8 ++
.../ChangePassword/ChangePasswordHandler.cs | 36 ++++++-
.../Auth/ICredentialStore.cs | 6 ++
.../Auth/ISessionStore.cs | 2 +
.../Auth/AuthTestBed.cs | 4 +-
.../Auth/ChangePasswordTests.cs | 101 ++++++++++++++++++
.../Fakes/FakeCredentialStore.cs | 10 ++
.../Fakes/FakeUnitOfWork.cs | 19 ++++
.../Fakes/InMemorySessionStore.cs | 6 ++
9 files changed, 190 insertions(+), 2 deletions(-)
create mode 100644 backend/ControlService/tests/ControlService.Application.Tests/Fakes/FakeUnitOfWork.cs
diff --git a/backend/ControlService/src/ControlService.Application/Auth/AuthErrors.cs b/backend/ControlService/src/ControlService.Application/Auth/AuthErrors.cs
index 55a93eb..b37e043 100644
--- a/backend/ControlService/src/ControlService.Application/Auth/AuthErrors.cs
+++ b/backend/ControlService/src/ControlService.Application/Auth/AuthErrors.cs
@@ -14,6 +14,14 @@ public static class AuthErrors
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.",
diff --git a/backend/ControlService/src/ControlService.Application/Auth/ChangePassword/ChangePasswordHandler.cs b/backend/ControlService/src/ControlService.Application/Auth/ChangePassword/ChangePasswordHandler.cs
index 3f27ff9..fb6f400 100644
--- a/backend/ControlService/src/ControlService.Application/Auth/ChangePassword/ChangePasswordHandler.cs
+++ b/backend/ControlService/src/ControlService.Application/Auth/ChangePassword/ChangePasswordHandler.cs
@@ -1,14 +1,48 @@
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) : ICommandHandler
+ 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);
diff --git a/backend/ControlService/src/ControlService.Application/Auth/ICredentialStore.cs b/backend/ControlService/src/ControlService.Application/Auth/ICredentialStore.cs
index e7f2b37..4ba765e 100644
--- a/backend/ControlService/src/ControlService.Application/Auth/ICredentialStore.cs
+++ b/backend/ControlService/src/ControlService.Application/Auth/ICredentialStore.cs
@@ -6,6 +6,12 @@ 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
index b9bfdf0..f583f6d 100644
--- a/backend/ControlService/src/ControlService.Application/Auth/ISessionStore.cs
+++ b/backend/ControlService/src/ControlService.Application/Auth/ISessionStore.cs
@@ -7,6 +7,8 @@ public interface ISessionStore
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/tests/ControlService.Application.Tests/Auth/AuthTestBed.cs b/backend/ControlService/tests/ControlService.Application.Tests/Auth/AuthTestBed.cs
index 3a3d1eb..f1f328a 100644
--- a/backend/ControlService/tests/ControlService.Application.Tests/Auth/AuthTestBed.cs
+++ b/backend/ControlService/tests/ControlService.Application.Tests/Auth/AuthTestBed.cs
@@ -25,6 +25,8 @@ internal sealed class AuthTestBed(AuthSettings? settings = null)
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);
@@ -32,7 +34,7 @@ internal sealed class AuthTestBed(AuthSettings? settings = null)
public SignOutHandler CreateSignOutHandler() => new(Sessions);
public ValidatingCommandHandler CreateChangePasswordHandler() =>
- new(new ChangePasswordHandler(Sessions, Tokens), new ChangePasswordValidator(Settings));
+ new(new ChangePasswordHandler(Users, Credentials, Sessions, Tokens, UnitOfWork, Clock), new ChangePasswordValidator(Settings));
public User AddPendingUser(string login)
{
diff --git a/backend/ControlService/tests/ControlService.Application.Tests/Auth/ChangePasswordTests.cs b/backend/ControlService/tests/ControlService.Application.Tests/Auth/ChangePasswordTests.cs
index c9165c1..27b1ed1 100644
--- a/backend/ControlService/tests/ControlService.Application.Tests/Auth/ChangePasswordTests.cs
+++ b/backend/ControlService/tests/ControlService.Application.Tests/Auth/ChangePasswordTests.cs
@@ -1,5 +1,8 @@
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;
@@ -61,4 +64,102 @@ public async Task Minimum_length_message_uses_the_configured_length() // AUTH-28
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/Fakes/FakeCredentialStore.cs b/backend/ControlService/tests/ControlService.Application.Tests/Fakes/FakeCredentialStore.cs
index e02653f..197226d 100644
--- a/backend/ControlService/tests/ControlService.Application.Tests/Fakes/FakeCredentialStore.cs
+++ b/backend/ControlService/tests/ControlService.Application.Tests/Fakes/FakeCredentialStore.cs
@@ -28,6 +28,16 @@ public Task CheckPasswordAsync(Guid userId, string password, Ca
: 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/InMemorySessionStore.cs b/backend/ControlService/tests/ControlService.Application.Tests/Fakes/InMemorySessionStore.cs
index 8be3370..b557af2 100644
--- a/backend/ControlService/tests/ControlService.Application.Tests/Fakes/InMemorySessionStore.cs
+++ b/backend/ControlService/tests/ControlService.Application.Tests/Fakes/InMemorySessionStore.cs
@@ -25,6 +25,12 @@ public Task EndAsync(Guid sessionId, CancellationToken cancellationToken)
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);
From 0c7f7398ee5e31b75a80f5e4501ae5ec1161fbb0 Mon Sep 17 00:00:00 2001
From: Matheus Mendes <104966152+MendesMat@users.noreply.github.com>
Date: Wed, 30 Sep 2026 02:02:45 -0300
Subject: [PATCH 07/22] feat(auth): return the signed-in person and their level
on every screen
PERM-05, PERM-06, PERM-23, USR-21: me lists every screen of the catalog in
order, including the denied ones. The no-profiles and Admin tests had no Red,
since the effective-access rules are already covered in the domain.
---
.../Auth/GetMe/GetMeHandler.cs | 30 ++++++++
.../Auth/GetMe/GetMeQuery.cs | 3 +
.../Auth/GetMe/MeResponse.cs | 7 ++
.../IPermissionProfileRepository.cs | 9 +++
.../Auth/AuthTestBed.cs | 21 ++++++
.../Auth/GetMeTests.cs | 69 +++++++++++++++++++
.../InMemoryPermissionProfileRepository.cs | 14 ++++
7 files changed, 153 insertions(+)
create mode 100644 backend/ControlService/src/ControlService.Application/Auth/GetMe/GetMeHandler.cs
create mode 100644 backend/ControlService/src/ControlService.Application/Auth/GetMe/GetMeQuery.cs
create mode 100644 backend/ControlService/src/ControlService.Application/Auth/GetMe/MeResponse.cs
create mode 100644 backend/ControlService/src/ControlService.Application/PermissionProfiles/IPermissionProfileRepository.cs
create mode 100644 backend/ControlService/tests/ControlService.Application.Tests/Auth/GetMeTests.cs
create mode 100644 backend/ControlService/tests/ControlService.Application.Tests/Fakes/InMemoryPermissionProfileRepository.cs
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/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/tests/ControlService.Application.Tests/Auth/AuthTestBed.cs b/backend/ControlService/tests/ControlService.Application.Tests/Auth/AuthTestBed.cs
index f1f328a..077c03b 100644
--- a/backend/ControlService/tests/ControlService.Application.Tests/Auth/AuthTestBed.cs
+++ b/backend/ControlService/tests/ControlService.Application.Tests/Auth/AuthTestBed.cs
@@ -1,10 +1,13 @@
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;
@@ -19,6 +22,8 @@ internal sealed class AuthTestBed(AuthSettings? settings = null)
public InMemoryUserRepository Users { get; } = new();
+ public InMemoryPermissionProfileRepository Profiles { get; } = new();
+
public FakeCredentialStore Credentials { get; } = new();
public InMemorySessionStore Sessions { get; } = new();
@@ -36,6 +41,22 @@ internal sealed class AuthTestBed(AuthSettings? settings = null)
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(
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/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());
+}
From b3d9d7e09053a5bbfb27a2fcfa5524674cf27267 Mon Sep 17 00:00:00 2001
From: Matheus Mendes <104966152+MendesMat@users.noreply.github.com>
Date: Wed, 30 Sep 2026 02:12:57 -0300
Subject: [PATCH 08/22] feat(auth): store credentials with Identity and seed
the Admin credential
AUTH-13, AUTH-19, ADR-0019, ADR-0022: the Admin credential is created from
Admin:InitialPassword when missing, independently of the Admin row, with a
mandatory password change. Migration AddAuthentication adds user_credentials
(Identity), its unused satellite tables and user_sessions; it has no Red of its
own, the startup tests cover it. The restart and invalid-password tests had no
Red: the first cycle already brought that behavior.
---
.../ControlService.API.csproj | 1 +
.../src/ControlService.API/appsettings.json | 11 +-
.../Auth/AuthOptions.cs | 22 +
.../Auth/UserCredential.cs | 12 +
.../Auth/UserSession.cs | 16 +
.../ControlService.Infrastructure.csproj | 2 +
.../DependencyInjection.cs | 41 +-
.../Persistence/AdminOptions.cs | 7 +-
.../Persistence/AppDbContext.cs | 23 +-
.../UserCredentialConfiguration.cs | 30 +
.../UserSessionConfiguration.cs | 19 +
...260930051103_AddAuthentication.Designer.cs | 539 ++++++++++++++++++
.../20260930051103_AddAuthentication.cs | 180 ++++++
.../Migrations/AppDbContextModelSnapshot.cs | 251 ++++++++
.../Persistence/SystemRecordsSeeder.cs | 46 +-
.../Auth/AdminCredentialTests.cs | 72 +++
.../Auth/AuthTestSupport.cs | 20 +
.../Common/ApiFactory.cs | 7 +
18 files changed, 1281 insertions(+), 18 deletions(-)
create mode 100644 backend/ControlService/src/ControlService.Infrastructure/Auth/AuthOptions.cs
create mode 100644 backend/ControlService/src/ControlService.Infrastructure/Auth/UserCredential.cs
create mode 100644 backend/ControlService/src/ControlService.Infrastructure/Auth/UserSession.cs
create mode 100644 backend/ControlService/src/ControlService.Infrastructure/Persistence/Configurations/UserCredentialConfiguration.cs
create mode 100644 backend/ControlService/src/ControlService.Infrastructure/Persistence/Configurations/UserSessionConfiguration.cs
create mode 100644 backend/ControlService/src/ControlService.Infrastructure/Persistence/Migrations/20260930051103_AddAuthentication.Designer.cs
create mode 100644 backend/ControlService/src/ControlService.Infrastructure/Persistence/Migrations/20260930051103_AddAuthentication.cs
create mode 100644 backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/AdminCredentialTests.cs
create mode 100644 backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/AuthTestSupport.cs
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/appsettings.json b/backend/ControlService/src/ControlService.API/appsettings.json
index 10f68b8..ccd0505 100644
--- a/backend/ControlService/src/ControlService.API/appsettings.json
+++ b/backend/ControlService/src/ControlService.API/appsettings.json
@@ -5,5 +5,14 @@
"Microsoft.AspNetCore": "Warning"
}
},
- "AllowedHosts": "*"
+ "AllowedHosts": "*",
+ "Auth": {
+ "AccessTokenMinutes": 15,
+ "RefreshTokenIdleHours": 8,
+ "Issuer": "control-service",
+ "Audience": "control-service",
+ "PasswordMinLength": 8,
+ "LockoutMaxFailedAttempts": 5,
+ "LockoutMinutes": 15
+ }
}
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/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..b021bc5 100644
--- a/backend/ControlService/src/ControlService.Infrastructure/DependencyInjection.cs
+++ b/backend/ControlService/src/ControlService.Infrastructure/DependencyInjection.cs
@@ -1,6 +1,9 @@
+using ControlService.Application.Auth;
using ControlService.Application.Common;
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 +21,53 @@ 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>(
+ (options, auth) => options.InitialPassword.Length >= auth.Value.PasswordMinLength,
+ "Admin:InitialPassword must have 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();
+ 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
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