backend phase 2: identity — phone-OTP auth, sessions & roles (REST)

- six REST endpoints (auth/request_otp, verify_otp, refresh, logout, me,
  me/select_role) wrapping the existing JWE/TOTP/RBAC engine
- usr.UserSessions with refresh-token rotation + stolen-token (replay)
  detection → revoke-all + 401; logout rotates the security stamp
- users extended: gender, national_id (enc, NULL until KYC),
  shahkar_verified_at (auto-reset on phone change), phone_hash UNIQUE,
  is_active, deleted_at + soft-delete filter; phone/email/national_id
  encrypted at rest via IFieldEncryptor value converter
- user_roles grant/revoke audit trail + global revoked filter; 7 roles
  seeded; admin sub-roles never self-assignable (403)
- ISmsSender seam (mock logs the OTP code) replaces the TODO log lines
- OperationResult/BaseController learned enveloped 401/403
- auth knobs as platform_configs rows (resend/attempts/session TTL)
- migration IdentitySessionsAndUserExtensions applied to the dev DB
- 24 new tests incl. Baya.Test.Api (WebApplicationFactory over SQLite);
  47 total green, zero new build warnings; swagger snapshot + contract
  (identity-auth.md), handoff, report, mocks-registry updated

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
hamid
2026-07-02 02:34:11 +03:30
parent 94fdcbe0d1
commit 3a51305343
88 changed files with 4619 additions and 91 deletions
@@ -0,0 +1,119 @@
using Baya.Application.Contracts;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Configuration;
using Baya.Application.Contracts.Identity;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Features.Identity;
using Baya.Application.Features.Identity.Commands.RefreshToken;
using Baya.Application.Models.Jwt;
using Baya.Domain.Entities.User;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using NSubstitute.ReturnsExtensions;
namespace Baya.Test.Foundation.Identity;
public class RefreshTokenCommandHandlerTests
{
private static readonly DateTimeOffset Now = new(2026, 7, 1, 12, 0, 0, TimeSpan.Zero);
private readonly IUnitOfWork _unitOfWork = Substitute.For<IUnitOfWork>();
private readonly IUserSessionRepository _sessions = Substitute.For<IUserSessionRepository>();
private readonly IJwtService _jwtService = Substitute.For<IJwtService>();
private readonly IAppUserManager _userManager = Substitute.For<IAppUserManager>();
private readonly IPlatformConfig _platformConfig = Substitute.For<IPlatformConfig>();
private readonly IFieldEncryptor _fieldEncryptor = Substitute.For<IFieldEncryptor>();
private readonly IDateTimeProvider _clock = Substitute.For<IDateTimeProvider>();
private readonly ICurrentUser _currentUser = Substitute.For<ICurrentUser>();
private RefreshTokenCommandHandler CreateHandler()
{
_clock.UtcNow.Returns(Now);
_unitOfWork.UserSessionRepository.Returns(_sessions);
_fieldEncryptor.Hash(Arg.Any<string>()).Returns("TOKEN_HASH");
_platformConfig.GetConfig<int>(IdentityDefaults.SessionTtlDaysKey, Arg.Any<CancellationToken>()).Returns(30);
_jwtService.GenerateAccessTokenAsync(Arg.Any<User>())
.Returns(new JweAccessToken("new-access-token", Now.AddMinutes(15)));
return new RefreshTokenCommandHandler(
_unitOfWork, _jwtService, _userManager, _platformConfig, _fieldEncryptor, _clock, _currentUser,
NullLogger<RefreshTokenCommandHandler>.Instance);
}
[Fact]
public async Task Handle_ActiveSession_RotatesAndReturnsNewPair()
{
// Arrange
var user = new User { Id = 7, IsActive = true };
var session = new UserSession
{
UserId = 7,
User = user,
RefreshTokenHash = "TOKEN_HASH",
ExpiresAt = Now.AddDays(10)
};
_sessions.GetByTokenHashAsync("TOKEN_HASH", Arg.Any<CancellationToken>()).Returns(session);
_userManager.GetRoleAsync(user).Returns(["customer"]);
var handler = CreateHandler();
// Act
var result = await handler.Handle(new RefreshTokenCommand("raw-refresh-token"), CancellationToken.None);
// Assert
Assert.True(result.IsSuccess);
Assert.True(session.IsRevoked);
Assert.Equal(Now, session.RevokedAt);
Assert.Equal("new-access-token", result.Result.AccessToken);
Assert.Contains("customer", result.Result.Roles);
await _sessions.Received(1).AddAsync(Arg.Is<UserSession>(s => !s.IsRevoked && s.UserId == 7), Arg.Any<CancellationToken>());
await _unitOfWork.Received(1).CommitAsync();
}
[Fact]
public async Task Handle_RevokedSessionReplay_RevokesEverythingAndReturns401()
{
// Arrange — a token presented against an already-rotated session is a stolen-token signal.
var session = new UserSession { UserId = 7, RefreshTokenHash = "TOKEN_HASH", IsRevoked = true, ExpiresAt = Now.AddDays(10) };
_sessions.GetByTokenHashAsync("TOKEN_HASH", Arg.Any<CancellationToken>()).Returns(session);
var handler = CreateHandler();
// Act
var result = await handler.Handle(new RefreshTokenCommand("raw-refresh-token"), CancellationToken.None);
// Assert
Assert.False(result.IsSuccess);
Assert.True(result.IsUnauthorized);
await _sessions.Received(1).RevokeAllActiveForUserAsync(7, Now, Arg.Any<CancellationToken>());
await _sessions.DidNotReceive().AddAsync(Arg.Any<UserSession>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_ExpiredSession_Returns401AndRevokesIt()
{
// Arrange
var session = new UserSession { UserId = 7, RefreshTokenHash = "TOKEN_HASH", ExpiresAt = Now.AddMinutes(-1) };
_sessions.GetByTokenHashAsync("TOKEN_HASH", Arg.Any<CancellationToken>()).Returns(session);
var handler = CreateHandler();
// Act
var result = await handler.Handle(new RefreshTokenCommand("raw-refresh-token"), CancellationToken.None);
// Assert
Assert.True(result.IsUnauthorized);
Assert.True(session.IsRevoked);
}
[Fact]
public async Task Handle_UnknownToken_Returns401()
{
// Arrange
_sessions.GetByTokenHashAsync(Arg.Any<string>(), Arg.Any<CancellationToken>()).ReturnsNull();
var handler = CreateHandler();
// Act
var result = await handler.Handle(new RefreshTokenCommand("bogus"), CancellationToken.None);
// Assert
Assert.True(result.IsUnauthorized);
}
}
@@ -0,0 +1,90 @@
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Configuration;
using Baya.Application.Contracts.Identity;
using Baya.Application.Features.Identity;
using Baya.Application.Features.Identity.Commands.RequestOtp;
using Baya.Domain.Entities.User;
using Microsoft.AspNetCore.Identity;
using NSubstitute;
using NSubstitute.ReturnsExtensions;
namespace Baya.Test.Foundation.Identity;
public class RequestOtpCommandHandlerTests
{
private static readonly DateTimeOffset Now = new(2026, 7, 1, 12, 0, 0, TimeSpan.Zero);
private readonly IAppUserManager _userManager = Substitute.For<IAppUserManager>();
private readonly ISmsSender _smsSender = Substitute.For<ISmsSender>();
private readonly IPlatformConfig _platformConfig = Substitute.For<IPlatformConfig>();
private readonly ICacheService _cache = Substitute.For<ICacheService>();
private readonly IFieldEncryptor _fieldEncryptor = Substitute.For<IFieldEncryptor>();
private readonly IDateTimeProvider _clock = Substitute.For<IDateTimeProvider>();
private RequestOtpCommandHandler CreateHandler()
{
_clock.UtcNow.Returns(Now);
_fieldEncryptor.Hash(Arg.Any<string>()).Returns("PHONE_HASH");
_platformConfig.GetConfig<int>(IdentityDefaults.OtpResendSecondsKey, Arg.Any<CancellationToken>())
.Returns(120);
return new RequestOtpCommandHandler(_userManager, _smsSender, _platformConfig, _cache, _fieldEncryptor, _clock);
}
[Fact]
public async Task Handle_NewPhone_CreatesInactiveUserAndSendsOtp()
{
// Arrange
_userManager.GetUserByPhoneNumber("09123456789").ReturnsNull();
_userManager.CreateUser(Arg.Any<User>()).Returns(IdentityResult.Success);
_userManager.GeneratePhoneNumberConfirmationToken(Arg.Any<User>(), "09123456789").Returns("123456");
var handler = CreateHandler();
// Act
var result = await handler.Handle(new RequestOtpCommand("09123456789"), CancellationToken.None);
// Assert
Assert.True(result.IsSuccess);
Assert.True(result.Result.OtpSent);
Assert.Equal(120, result.Result.ResendAvailableInSeconds);
await _userManager.Received(1).CreateUser(Arg.Is<User>(u => !u.IsActive && u.PhoneNumber == "09123456789"));
await _smsSender.Received(1).SendOtpAsync("09123456789", "123456", Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_ResendWindowOpen_DoesNotSendAndReportsRemainingSeconds()
{
// Arrange
_cache.GetAsync<DateTimeOffset>(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(Now.AddSeconds(60));
var handler = CreateHandler();
// Act
var result = await handler.Handle(new RequestOtpCommand("09123456789"), CancellationToken.None);
// Assert
Assert.True(result.IsSuccess);
Assert.False(result.Result.OtpSent);
Assert.Equal(60, result.Result.ResendAvailableInSeconds);
await _smsSender.DidNotReceive().SendOtpAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_ExistingConfirmedUser_UsesPasswordlessOtpAndSameShape()
{
// Arrange
var user = new User { PhoneNumber = "09123456789", PhoneNumberConfirmed = true };
_userManager.GetUserByPhoneNumber("09123456789").Returns(user);
_userManager.GenerateOtpCode(user).Returns("654321");
var handler = CreateHandler();
// Act
var result = await handler.Handle(new RequestOtpCommand("+989123456789"), CancellationToken.None);
// Assert — normalized phone, no enumeration (same shape as the new-user path).
Assert.True(result.IsSuccess);
Assert.True(result.Result.OtpSent);
await _smsSender.Received(1).SendOtpAsync("09123456789", "654321", Arg.Any<CancellationToken>());
await _userManager.DidNotReceive().CreateUser(Arg.Any<User>());
}
}
@@ -0,0 +1,104 @@
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Features.Identity.Commands.SelectRole;
using Baya.Application.Models.Identity;
using Baya.Domain.Entities.User;
using NSubstitute;
using NSubstitute.ReturnsExtensions;
namespace Baya.Test.Foundation.Identity;
public class SelectRoleCommandHandlerTests
{
private static readonly DateTimeOffset Now = new(2026, 7, 1, 12, 0, 0, TimeSpan.Zero);
private readonly ICurrentUser _currentUser = Substitute.For<ICurrentUser>();
private readonly IUnitOfWork _unitOfWork = Substitute.For<IUnitOfWork>();
private readonly IUserAccountRepository _accounts = Substitute.For<IUserAccountRepository>();
private readonly IDateTimeProvider _clock = Substitute.For<IDateTimeProvider>();
private SelectRoleCommandHandler CreateHandler()
{
_clock.UtcNow.Returns(Now);
_currentUser.UserId.Returns(7);
_unitOfWork.UserAccountRepository.Returns(_accounts);
_accounts.GetAccountSnapshotAsync(7, Arg.Any<CancellationToken>())
.Returns(new UserAccountSnapshot(7, "09123456789", null, null, null, true, ["customer"]));
return new SelectRoleCommandHandler(_currentUser, _unitOfWork, _clock);
}
[Fact]
public async Task Handle_AdminSubRole_IsForbidden()
{
// Arrange
var handler = CreateHandler();
// Act
var result = await handler.Handle(new SelectRoleCommand("super_admin"), CancellationToken.None);
// Assert
Assert.False(result.IsSuccess);
Assert.True(result.IsForbidden);
await _accounts.DidNotReceive().AddUserRoleAsync(Arg.Any<UserRole>(), Arg.Any<CancellationToken>());
await _unitOfWork.DidNotReceive().CommitAsync();
}
[Fact]
public async Task Handle_NewCustomerRole_GrantsWithSelfAudit()
{
// Arrange
_accounts.GetRoleByNameAsync("customer", Arg.Any<CancellationToken>()).Returns(new Role { Id = 3, Name = "customer" });
_accounts.GetUserRoleIncludingRevokedAsync(7, 3, Arg.Any<CancellationToken>()).ReturnsNull();
var handler = CreateHandler();
// Act
var result = await handler.Handle(new SelectRoleCommand("Customer"), CancellationToken.None);
// Assert — case-insensitive input, granted_by = self, masked phone in the payload.
Assert.True(result.IsSuccess);
Assert.Contains("customer", result.Result.Roles);
Assert.DoesNotContain("09123456789", result.Result.Phone);
await _accounts.Received(1).AddUserRoleAsync(
Arg.Is<UserRole>(ur => ur.UserId == 7 && ur.RoleId == 3 && ur.GrantedById == 7 && ur.GrantedAt == Now),
Arg.Any<CancellationToken>());
await _unitOfWork.Received(1).CommitAsync();
}
[Fact]
public async Task Handle_RoleAlreadyHeld_IsIdempotent()
{
// Arrange
_accounts.GetRoleByNameAsync("customer", Arg.Any<CancellationToken>()).Returns(new Role { Id = 3, Name = "customer" });
_accounts.GetUserRoleIncludingRevokedAsync(7, 3, Arg.Any<CancellationToken>())
.Returns(new UserRole { UserId = 7, RoleId = 3, GrantedAt = Now.AddDays(-1) });
var handler = CreateHandler();
// Act
var result = await handler.Handle(new SelectRoleCommand("customer"), CancellationToken.None);
// Assert
Assert.True(result.IsSuccess);
await _accounts.DidNotReceive().AddUserRoleAsync(Arg.Any<UserRole>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_RevokedGrant_IsReactivatedNotDuplicated()
{
// Arrange
var revoked = new UserRole { UserId = 7, RoleId = 3, GrantedAt = Now.AddDays(-10), RevokedAt = Now.AddDays(-5) };
_accounts.GetRoleByNameAsync("nurse", Arg.Any<CancellationToken>()).Returns(new Role { Id = 3, Name = "nurse" });
_accounts.GetUserRoleIncludingRevokedAsync(7, 3, Arg.Any<CancellationToken>()).Returns(revoked);
var handler = CreateHandler();
// Act
var result = await handler.Handle(new SelectRoleCommand("nurse"), CancellationToken.None);
// Assert
Assert.True(result.IsSuccess);
Assert.Null(revoked.RevokedAt);
Assert.Equal(Now, revoked.GrantedAt);
Assert.Equal(7, revoked.GrantedById);
await _accounts.DidNotReceive().AddUserRoleAsync(Arg.Any<UserRole>(), Arg.Any<CancellationToken>());
}
}
@@ -0,0 +1,120 @@
using Baya.Application.Contracts;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Configuration;
using Baya.Application.Contracts.Identity;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Features.Identity;
using Baya.Application.Features.Identity.Commands.VerifyOtp;
using Baya.Application.Models.Jwt;
using Baya.Domain.Entities.User;
using Microsoft.AspNetCore.Identity;
using NSubstitute;
using NSubstitute.ReturnsExtensions;
namespace Baya.Test.Foundation.Identity;
public class VerifyOtpCommandHandlerTests
{
private static readonly DateTimeOffset Now = new(2026, 7, 1, 12, 0, 0, TimeSpan.Zero);
private readonly IAppUserManager _userManager = Substitute.For<IAppUserManager>();
private readonly IJwtService _jwtService = Substitute.For<IJwtService>();
private readonly IUnitOfWork _unitOfWork = Substitute.For<IUnitOfWork>();
private readonly IUserSessionRepository _sessions = Substitute.For<IUserSessionRepository>();
private readonly IPlatformConfig _platformConfig = Substitute.For<IPlatformConfig>();
private readonly IFieldEncryptor _fieldEncryptor = Substitute.For<IFieldEncryptor>();
private readonly IDateTimeProvider _clock = Substitute.For<IDateTimeProvider>();
private readonly ICurrentUser _currentUser = Substitute.For<ICurrentUser>();
private VerifyOtpCommandHandler CreateHandler()
{
_clock.UtcNow.Returns(Now);
_unitOfWork.UserSessionRepository.Returns(_sessions);
_fieldEncryptor.Hash(Arg.Any<string>()).Returns("TOKEN_HASH");
_platformConfig.GetConfig<int>(IdentityDefaults.OtpMaxAttemptsKey, Arg.Any<CancellationToken>()).Returns(5);
_platformConfig.GetConfig<int>(IdentityDefaults.SessionTtlDaysKey, Arg.Any<CancellationToken>()).Returns(30);
_jwtService.GenerateAccessTokenAsync(Arg.Any<User>())
.Returns(new JweAccessToken("jwe-access-token", Now.AddMinutes(15)));
return new VerifyOtpCommandHandler(
_userManager, _jwtService, _unitOfWork, _platformConfig, _fieldEncryptor, _clock, _currentUser);
}
[Fact]
public async Task Handle_ValidCodeForNewUser_ActivatesUserMintsTokensAndSession()
{
// Arrange
var user = new User { PhoneNumber = "09123456789", PhoneNumberConfirmed = false };
_userManager.GetUserByPhoneNumber("09123456789").Returns(user);
_userManager.ChangePhoneNumber(user, "09123456789", "123456").Returns(IdentityResult.Success);
_userManager.GetRoleAsync(user).Returns([]);
var handler = CreateHandler();
// Act
var result = await handler.Handle(new VerifyOtpCommand("09123456789", "123456", "test-device"), CancellationToken.None);
// Assert
Assert.True(result.IsSuccess);
Assert.True(result.Result.IsNewUser);
Assert.Empty(result.Result.Roles);
Assert.Equal("jwe-access-token", result.Result.AccessToken);
Assert.False(string.IsNullOrEmpty(result.Result.RefreshToken));
Assert.Equal(Now.AddDays(30), result.Result.RefreshExpiresAt);
Assert.True(user.IsActive);
Assert.Equal(Now, user.PhoneVerifiedAt);
await _sessions.Received(1).AddAsync(
Arg.Is<UserSession>(s => s.RefreshTokenHash == "TOKEN_HASH" && !s.IsRevoked && s.DeviceInfo == "test-device"),
Arg.Any<CancellationToken>());
await _unitOfWork.Received(1).CommitAsync();
}
[Fact]
public async Task Handle_WrongCode_IncrementsAttemptsAndFailsSafely()
{
// Arrange
var user = new User { PhoneNumber = "09123456789", PhoneNumberConfirmed = true };
_userManager.GetUserByPhoneNumber("09123456789").Returns(user);
_userManager.VerifyUserCode(user, "999999")
.Returns(IdentityResult.Failed(new IdentityError { Description = "Incorrect Code" }));
var handler = CreateHandler();
// Act
var result = await handler.Handle(new VerifyOtpCommand("09123456789", "999999"), CancellationToken.None);
// Assert
Assert.False(result.IsSuccess);
await _userManager.Received(1).IncrementAccessFailedCountAsync(user);
await _sessions.DidNotReceive().AddAsync(Arg.Any<UserSession>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_TooManyFailedAttempts_RefusesWithoutCheckingTheCode()
{
// Arrange
var user = new User { PhoneNumber = "09123456789", PhoneNumberConfirmed = true, AccessFailedCount = 5 };
_userManager.GetUserByPhoneNumber("09123456789").Returns(user);
var handler = CreateHandler();
// Act
var result = await handler.Handle(new VerifyOtpCommand("09123456789", "123456"), CancellationToken.None);
// Assert
Assert.False(result.IsSuccess);
await _userManager.DidNotReceive().VerifyUserCode(Arg.Any<User>(), Arg.Any<string>());
}
[Fact]
public async Task Handle_UnknownPhone_FailsWithTheSameSafeMessageAsAWrongCode()
{
// Arrange — no enumeration: unknown phone and wrong code are indistinguishable.
_userManager.GetUserByPhoneNumber(Arg.Any<string>()).ReturnsNull();
var handler = CreateHandler();
// Act
var result = await handler.Handle(new VerifyOtpCommand("09123456789", "123456"), CancellationToken.None);
// Assert
Assert.False(result.IsSuccess);
Assert.Contains(result.ErrorMessages, e => e.Value.Contains("invalid or expired", StringComparison.OrdinalIgnoreCase));
}
}
@@ -3,6 +3,7 @@ using Baya.Domain.Entities.User;
using Baya.Infrastructure.CrossCutting.Seams;
using Baya.Infrastructure.Persistence;
using Baya.Infrastructure.Persistence.Interceptors;
using Baya.Tests.Setup.Setups;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
@@ -36,7 +37,7 @@ internal sealed class OpsTestHost : IDisposable
.AddInterceptors(interceptor)
.Options;
Db = new ApplicationDbContext(options);
Db = new ApplicationDbContext(options, TestFieldEncryptor.Instance);
Db.Database.EnsureCreated();
}
@@ -66,4 +67,5 @@ internal sealed class TestCurrentUser : ICurrentUser
public int? UserId { get; set; }
public bool IsAuthenticated => UserId is not null;
public IReadOnlyList<string> Roles { get; set; } = [];
public string? IpAddress { get; set; }
}