Дочистить код-стайл: var встроенных типов, приватные доки, доки интерфейсам
Гейт csharp_style_var_for_built_in_types=false:warning (ломает сборку), остаток выправлен dotnet format IDE0008 по 5 sln (только встроенные типы). Понижено 12 новых XML-доков private/internal, добавлены <summary> членам IContainerRules и ITenantContext, 3 англоязычных комментария переведены на русский.
This commit is contained in:
@@ -1,269 +1,269 @@
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
namespace Deal.Modules.Tenants.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Прикладной сервис аутентификации
|
||||
/// </summary>
|
||||
public sealed class AuthService(
|
||||
IAuthStore authStore,
|
||||
IPasswordHasher passwordHasher,
|
||||
ITenantRepository tenantRepository)
|
||||
{
|
||||
/// <summary>
|
||||
/// Срок жизни сессии, дней.
|
||||
/// </summary>
|
||||
public const int SessionLifetimeDays = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Минимальная длина нового пароля.
|
||||
/// </summary>
|
||||
public const int MinNewPasswordLength = 8;
|
||||
|
||||
// Статус «активен»: только активного пользователя разрешает ResolveSessionAsync (деактивированный
|
||||
// вручную с живой сессией — null, как у оператора OperatorAuthService).
|
||||
private const string UserActiveStatus = "active";
|
||||
|
||||
/// <summary>
|
||||
/// Вход: при успехе создаёт сессию и возвращает её raw-токен.
|
||||
/// </summary>
|
||||
/// <param name="login">Логин (регистр и пробелы не важны — нормализуется).</param>
|
||||
/// <param name="password">Пароль в открытом виде.</param>
|
||||
/// <returns>При успехе — Login и Token (UserId/TenantId для аудита). Иначе Login/Token null: Error может отличать заблокированный вход приостановленного тенанта (<see cref="LoginResultDto.ErrorTenantSuspended"/>, UserId/TenantId заполнены) от «неверные учётные данные» (Error null, UserId/TenantId пусты).</returns>
|
||||
public async Task<LoginResultDto> LoginAsync(
|
||||
string login,
|
||||
string password,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var normalizedLogin = NormalizeLogin(login);
|
||||
if (string.IsNullOrEmpty(normalizedLogin) || string.IsNullOrEmpty(password))
|
||||
{
|
||||
return new LoginResultDto(null, null);
|
||||
}
|
||||
|
||||
var user = await authStore.FindUserByLoginAsync(normalizedLogin, ct);
|
||||
// Порядок проверок: сначала учётные данные, затем статус тенанта — неверный пароль не раскрывает
|
||||
// приостановку (тот же «Неверный логин или пароль», что и для активного тенанта).
|
||||
if (user is null || !passwordHasher.Verify(password, user.PasswordHash))
|
||||
{
|
||||
return new LoginResultDto(null, null);
|
||||
}
|
||||
|
||||
var tenant = await tenantRepository.FindByIdAsync(user.TenantId, ct);
|
||||
if (tenant is not null && tenant.Status == TenantStatuses.Suspended)
|
||||
{
|
||||
return new LoginResultDto(
|
||||
Login: null,
|
||||
Token: null,
|
||||
UserId: user.Id,
|
||||
TenantId: user.TenantId,
|
||||
Error: LoginResultDto.ErrorTenantSuspended);
|
||||
}
|
||||
|
||||
var token = await CreateSessionForUserAsync(user.Id, user.Login, impersonatedByOperatorId: null, ct);
|
||||
return new LoginResultDto(user.Login, token, user.Id, user.TenantId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Выход: удаляет сессию по raw-токену
|
||||
/// </summary>
|
||||
/// <param name="rawToken">Raw-токен из куки (может отсутствовать — no-op).</param>
|
||||
/// <returns>Не null, если удалённая сессия была impersonation — сведения для аудита impersonation_stopped (актор — оператор по маркеру сессии). Обычный logout и no-op возвращают null.</returns>
|
||||
public async Task<LogoutResultDto?> LogoutAsync(string? rawToken, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rawToken))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var tokenHash = SessionTokens.HashToken(rawToken);
|
||||
var session = await authStore.FindSessionByTokenHashAsync(tokenHash, ct);
|
||||
await authStore.DeleteSessionAsync(tokenHash, ct);
|
||||
|
||||
if (session is null || session.ImpersonatedByOperatorId is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Пользователь не удаляется при живой сессии (FK sessions→users Cascade, SessionConfiguration):
|
||||
// TenantId берём из реестра пользователей для поля-фильтра аудита.
|
||||
var user = await authStore.FindUserByIdAsync(session.UserId, ct);
|
||||
return user is null
|
||||
? null
|
||||
: new LogoutResultDto(
|
||||
OperatorId: session.ImpersonatedByOperatorId.Value,
|
||||
Login: session.Login,
|
||||
UserId: user.Id,
|
||||
TenantId: user.TenantId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Смена пароля: инвалидирует все сессии, обновляет хэш и выдаёт свежую сессию.
|
||||
/// </summary>
|
||||
/// <param name="login">Логин пользователя.</param>
|
||||
/// <param name="oldPassword">Текущий пароль.</param>
|
||||
/// <param name="NewPassword">Новый пароль (минимум 8 символов).</param>
|
||||
/// <returns>При успехе — Ok=true и NewToken (raw-токен свежей сессии). Иначе Ok=false и код ошибки: <see cref="ChangePasswordResultDto.ErrorOldPassword"/> или <see cref="ChangePasswordResultDto.ErrorTooShort"/>.</returns>
|
||||
public async Task<ChangePasswordResultDto> ChangePasswordAsync(
|
||||
string login,
|
||||
string oldPassword,
|
||||
string newPassword,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var normalizedLogin = NormalizeLogin(login);
|
||||
var user = await authStore.FindUserByLoginAsync(normalizedLogin, ct);
|
||||
if (user is null || oldPassword is null || !passwordHasher.Verify(oldPassword, user.PasswordHash))
|
||||
{
|
||||
return new ChangePasswordResultDto(Ok: false, Error: ChangePasswordResultDto.ErrorOldPassword, NewToken: null);
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(newPassword) || newPassword.Length < MinNewPasswordLength)
|
||||
{
|
||||
return new ChangePasswordResultDto(Ok: false, Error: ChangePasswordResultDto.ErrorTooShort, NewToken: null);
|
||||
}
|
||||
|
||||
await authStore.DeleteSessionsByUserIdAsync(user.Id, ct);
|
||||
await authStore.UpdatePasswordHashAsync(user.Id, passwordHasher.Hash(newPassword), ct);
|
||||
var token = await CreateSessionForUserAsync(user.Id, user.Login, impersonatedByOperatorId: null, ct);
|
||||
return new ChangePasswordResultDto(Ok: true, Error: null, NewToken: token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Impersonation: tenant-сессия целевого пользователя от имени оператора.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Идентификатор тенанта.</param>
|
||||
/// <param name="targetLogin">Логин пользователя (null/пустой — первый пользователь тенанта).</param>
|
||||
/// <param name="operatorId">Идентификатор оператора, начинающего impersonation (маркер сессии).</param>
|
||||
/// <returns>Результат: Ok=true — SessionToken (raw-токен для куки deal_session) и срок жизни; иначе код ошибки.</returns>
|
||||
public async Task<ImpersonationResultDto> ImpersonateAsync(
|
||||
Guid tenantId,
|
||||
string? targetLogin,
|
||||
Guid operatorId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var tenant = await tenantRepository.FindByIdAsync(tenantId, ct);
|
||||
if (tenant is null)
|
||||
{
|
||||
return ImpersonationResultDto.Failed(ImpersonationResultDto.ErrorTenantNotFound);
|
||||
}
|
||||
|
||||
var normalizedLogin = NormalizeLogin(targetLogin);
|
||||
if (string.IsNullOrEmpty(normalizedLogin))
|
||||
{
|
||||
// login не задан — первый пользователь тенанта (по CreatedAt, порядок ListUsersByTenantIdAsync).
|
||||
var tenantUsers = await authStore.ListUsersByTenantIdAsync(tenantId, ct);
|
||||
if (tenantUsers.Count == 0)
|
||||
{
|
||||
return ImpersonationResultDto.Failed(ImpersonationResultDto.ErrorTenantHasNoUsers);
|
||||
}
|
||||
|
||||
UserIdentityDto firstUser = tenantUsers[0];
|
||||
var rawTokenForFirst = await CreateSessionForUserAsync(firstUser.Id, firstUser.Login, operatorId, ct);
|
||||
return Success(rawTokenForFirst, firstUser.Id, firstUser.Login, firstUser.TenantId);
|
||||
}
|
||||
|
||||
var user = await authStore.FindUserByLoginAsync(normalizedLogin, ct);
|
||||
if (user is null || user.TenantId != tenantId)
|
||||
{
|
||||
// Пользователь не найден или принадлежит другому тенанту — не раскрываем существование логина.
|
||||
return ImpersonationResultDto.Failed(ImpersonationResultDto.ErrorUserNotFound);
|
||||
}
|
||||
|
||||
var rawToken = await CreateSessionForUserAsync(user.Id, user.Login, operatorId, ct);
|
||||
return Success(rawToken, user.Id, user.Login, user.TenantId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Разрешение сессии по raw-токену
|
||||
/// </summary>
|
||||
/// <param name="rawToken">Raw-токен из куки.</param>
|
||||
/// <returns>Идентичность пользователя или null.</returns>
|
||||
public async Task<UserIdentityDto?> ResolveSessionAsync(string? rawToken, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rawToken))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var tokenHash = SessionTokens.HashToken(rawToken);
|
||||
var session = await authStore.FindSessionByTokenHashAsync(tokenHash, ct);
|
||||
UserIdentityDto? user = null;
|
||||
if (session is not null && session.ExpiresAt > DateTimeOffset.UtcNow)
|
||||
{
|
||||
user = await authStore.FindUserByIdAsync(session.UserId, ct);
|
||||
// Деактивированный пользователь при живой сессии не получает доступ (Security review):
|
||||
// сессия разрешается только для активной учётки (как операторская в OperatorAuthService).
|
||||
if (user is not null && user.Status != UserActiveStatus)
|
||||
{
|
||||
user = null;
|
||||
}
|
||||
|
||||
if (user is not null)
|
||||
{
|
||||
var tenant = await tenantRepository.FindByIdAsync(user.TenantId, ct);
|
||||
if (tenant is not null && tenant.Status == TenantStatuses.Suspended)
|
||||
{
|
||||
user = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (session is not null)
|
||||
{
|
||||
// Сессия протухла: глобальная очистка протухших сессий выполняется ТОЛЬКО при обнаружении
|
||||
// протухшей (редкий случай), а не на каждом разрешении сессии (hot-path, Security review).
|
||||
await authStore.DeleteExpiredSessionsAsync(ct);
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
// Создаёт сессию пользователя: raw-токен наружу, в хранилище — его SHA-256-хэш.
|
||||
// userId: Идентификатор пользователя.
|
||||
// login: Логин (денормализуется в сессию для чтения без join).
|
||||
// impersonatedByOperatorId: Маркер impersonation: оператор, создавший сессию; null — обычный вход.
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: Raw-токен для выдачи клиенту.
|
||||
private async Task<string> CreateSessionForUserAsync(
|
||||
Guid userId,
|
||||
string login,
|
||||
Guid? impersonatedByOperatorId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var rawToken = SessionTokens.NewToken();
|
||||
var session = new SessionDto(
|
||||
TokenHash: SessionTokens.HashToken(rawToken),
|
||||
UserId: userId,
|
||||
Login: login,
|
||||
ExpiresAt: DateTimeOffset.UtcNow.AddDays(SessionLifetimeDays),
|
||||
ImpersonatedByOperatorId: impersonatedByOperatorId);
|
||||
|
||||
await authStore.CreateSessionAsync(session, ct);
|
||||
return rawToken;
|
||||
}
|
||||
|
||||
// Успешный результат impersonation (токен + пользователь + тенант).
|
||||
// rawToken: Raw-токен созданной сессии.
|
||||
// userId: Идентификатор пользователя.
|
||||
// login: Логин пользователя.
|
||||
// tenantId: Тенант пользователя.
|
||||
// Возвращает: ImpersonationResultDto с Ok=true.
|
||||
private ImpersonationResultDto Success(
|
||||
string rawToken,
|
||||
Guid userId,
|
||||
string login,
|
||||
Guid tenantId) =>
|
||||
new(
|
||||
Ok: true,
|
||||
Error: null,
|
||||
SessionToken: rawToken,
|
||||
ExpiresAt: DateTimeOffset.UtcNow.AddDays(SessionLifetimeDays),
|
||||
Login: login,
|
||||
UserId: userId,
|
||||
TenantId: tenantId);
|
||||
|
||||
// Нормализация логина: нижний регистр и обрезка пробелов (единая форма хранения/сравнения).
|
||||
// login: Входной логин.
|
||||
// Возвращает: Нормализованный логин (пустая строка, если вход был пустым).
|
||||
private static string NormalizeLogin(string? login) => (login ?? string.Empty).ToLowerInvariant().Trim();
|
||||
}
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
namespace Deal.Modules.Tenants.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Прикладной сервис аутентификации
|
||||
/// </summary>
|
||||
public sealed class AuthService(
|
||||
IAuthStore authStore,
|
||||
IPasswordHasher passwordHasher,
|
||||
ITenantRepository tenantRepository)
|
||||
{
|
||||
/// <summary>
|
||||
/// Срок жизни сессии, дней.
|
||||
/// </summary>
|
||||
public const int SessionLifetimeDays = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Минимальная длина нового пароля.
|
||||
/// </summary>
|
||||
public const int MinNewPasswordLength = 8;
|
||||
|
||||
// Статус «активен»: только активного пользователя разрешает ResolveSessionAsync (деактивированный
|
||||
// вручную с живой сессией — null, как у оператора OperatorAuthService).
|
||||
private const string UserActiveStatus = "active";
|
||||
|
||||
/// <summary>
|
||||
/// Вход: при успехе создаёт сессию и возвращает её raw-токен.
|
||||
/// </summary>
|
||||
/// <param name="login">Логин (регистр и пробелы не важны — нормализуется).</param>
|
||||
/// <param name="password">Пароль в открытом виде.</param>
|
||||
/// <returns>При успехе — Login и Token (UserId/TenantId для аудита). Иначе Login/Token null: Error может отличать заблокированный вход приостановленного тенанта (<see cref="LoginResultDto.ErrorTenantSuspended"/>, UserId/TenantId заполнены) от «неверные учётные данные» (Error null, UserId/TenantId пусты).</returns>
|
||||
public async Task<LoginResultDto> LoginAsync(
|
||||
string login,
|
||||
string password,
|
||||
CancellationToken ct)
|
||||
{
|
||||
string normalizedLogin = NormalizeLogin(login);
|
||||
if (string.IsNullOrEmpty(normalizedLogin) || string.IsNullOrEmpty(password))
|
||||
{
|
||||
return new LoginResultDto(null, null);
|
||||
}
|
||||
|
||||
var user = await authStore.FindUserByLoginAsync(normalizedLogin, ct);
|
||||
// Порядок проверок: сначала учётные данные, затем статус тенанта — неверный пароль не раскрывает
|
||||
// приостановку (тот же «Неверный логин или пароль», что и для активного тенанта).
|
||||
if (user is null || !passwordHasher.Verify(password, user.PasswordHash))
|
||||
{
|
||||
return new LoginResultDto(null, null);
|
||||
}
|
||||
|
||||
var tenant = await tenantRepository.FindByIdAsync(user.TenantId, ct);
|
||||
if (tenant is not null && tenant.Status == TenantStatuses.Suspended)
|
||||
{
|
||||
return new LoginResultDto(
|
||||
Login: null,
|
||||
Token: null,
|
||||
UserId: user.Id,
|
||||
TenantId: user.TenantId,
|
||||
Error: LoginResultDto.ErrorTenantSuspended);
|
||||
}
|
||||
|
||||
string token = await CreateSessionForUserAsync(user.Id, user.Login, impersonatedByOperatorId: null, ct);
|
||||
return new LoginResultDto(user.Login, token, user.Id, user.TenantId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Выход: удаляет сессию по raw-токену
|
||||
/// </summary>
|
||||
/// <param name="rawToken">Raw-токен из куки (может отсутствовать — no-op).</param>
|
||||
/// <returns>Не null, если удалённая сессия была impersonation — сведения для аудита impersonation_stopped (актор — оператор по маркеру сессии). Обычный logout и no-op возвращают null.</returns>
|
||||
public async Task<LogoutResultDto?> LogoutAsync(string? rawToken, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rawToken))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string tokenHash = SessionTokens.HashToken(rawToken);
|
||||
var session = await authStore.FindSessionByTokenHashAsync(tokenHash, ct);
|
||||
await authStore.DeleteSessionAsync(tokenHash, ct);
|
||||
|
||||
if (session is null || session.ImpersonatedByOperatorId is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Пользователь не удаляется при живой сессии (FK sessions→users Cascade, SessionConfiguration):
|
||||
// TenantId берём из реестра пользователей для поля-фильтра аудита.
|
||||
var user = await authStore.FindUserByIdAsync(session.UserId, ct);
|
||||
return user is null
|
||||
? null
|
||||
: new LogoutResultDto(
|
||||
OperatorId: session.ImpersonatedByOperatorId.Value,
|
||||
Login: session.Login,
|
||||
UserId: user.Id,
|
||||
TenantId: user.TenantId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Смена пароля: инвалидирует все сессии, обновляет хэш и выдаёт свежую сессию.
|
||||
/// </summary>
|
||||
/// <param name="login">Логин пользователя.</param>
|
||||
/// <param name="oldPassword">Текущий пароль.</param>
|
||||
/// <param name="NewPassword">Новый пароль (минимум 8 символов).</param>
|
||||
/// <returns>При успехе — Ok=true и NewToken (raw-токен свежей сессии). Иначе Ok=false и код ошибки: <see cref="ChangePasswordResultDto.ErrorOldPassword"/> или <see cref="ChangePasswordResultDto.ErrorTooShort"/>.</returns>
|
||||
public async Task<ChangePasswordResultDto> ChangePasswordAsync(
|
||||
string login,
|
||||
string oldPassword,
|
||||
string newPassword,
|
||||
CancellationToken ct)
|
||||
{
|
||||
string normalizedLogin = NormalizeLogin(login);
|
||||
var user = await authStore.FindUserByLoginAsync(normalizedLogin, ct);
|
||||
if (user is null || oldPassword is null || !passwordHasher.Verify(oldPassword, user.PasswordHash))
|
||||
{
|
||||
return new ChangePasswordResultDto(Ok: false, Error: ChangePasswordResultDto.ErrorOldPassword, NewToken: null);
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(newPassword) || newPassword.Length < MinNewPasswordLength)
|
||||
{
|
||||
return new ChangePasswordResultDto(Ok: false, Error: ChangePasswordResultDto.ErrorTooShort, NewToken: null);
|
||||
}
|
||||
|
||||
await authStore.DeleteSessionsByUserIdAsync(user.Id, ct);
|
||||
await authStore.UpdatePasswordHashAsync(user.Id, passwordHasher.Hash(newPassword), ct);
|
||||
string token = await CreateSessionForUserAsync(user.Id, user.Login, impersonatedByOperatorId: null, ct);
|
||||
return new ChangePasswordResultDto(Ok: true, Error: null, NewToken: token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Impersonation: tenant-сессия целевого пользователя от имени оператора.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Идентификатор тенанта.</param>
|
||||
/// <param name="targetLogin">Логин пользователя (null/пустой — первый пользователь тенанта).</param>
|
||||
/// <param name="operatorId">Идентификатор оператора, начинающего impersonation (маркер сессии).</param>
|
||||
/// <returns>Результат: Ok=true — SessionToken (raw-токен для куки deal_session) и срок жизни; иначе код ошибки.</returns>
|
||||
public async Task<ImpersonationResultDto> ImpersonateAsync(
|
||||
Guid tenantId,
|
||||
string? targetLogin,
|
||||
Guid operatorId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var tenant = await tenantRepository.FindByIdAsync(tenantId, ct);
|
||||
if (tenant is null)
|
||||
{
|
||||
return ImpersonationResultDto.Failed(ImpersonationResultDto.ErrorTenantNotFound);
|
||||
}
|
||||
|
||||
string normalizedLogin = NormalizeLogin(targetLogin);
|
||||
if (string.IsNullOrEmpty(normalizedLogin))
|
||||
{
|
||||
// login не задан — первый пользователь тенанта (по CreatedAt, порядок ListUsersByTenantIdAsync).
|
||||
var tenantUsers = await authStore.ListUsersByTenantIdAsync(tenantId, ct);
|
||||
if (tenantUsers.Count == 0)
|
||||
{
|
||||
return ImpersonationResultDto.Failed(ImpersonationResultDto.ErrorTenantHasNoUsers);
|
||||
}
|
||||
|
||||
UserIdentityDto firstUser = tenantUsers[0];
|
||||
string rawTokenForFirst = await CreateSessionForUserAsync(firstUser.Id, firstUser.Login, operatorId, ct);
|
||||
return Success(rawTokenForFirst, firstUser.Id, firstUser.Login, firstUser.TenantId);
|
||||
}
|
||||
|
||||
var user = await authStore.FindUserByLoginAsync(normalizedLogin, ct);
|
||||
if (user is null || user.TenantId != tenantId)
|
||||
{
|
||||
// Пользователь не найден или принадлежит другому тенанту — не раскрываем существование логина.
|
||||
return ImpersonationResultDto.Failed(ImpersonationResultDto.ErrorUserNotFound);
|
||||
}
|
||||
|
||||
string rawToken = await CreateSessionForUserAsync(user.Id, user.Login, operatorId, ct);
|
||||
return Success(rawToken, user.Id, user.Login, user.TenantId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Разрешение сессии по raw-токену
|
||||
/// </summary>
|
||||
/// <param name="rawToken">Raw-токен из куки.</param>
|
||||
/// <returns>Идентичность пользователя или null.</returns>
|
||||
public async Task<UserIdentityDto?> ResolveSessionAsync(string? rawToken, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rawToken))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string tokenHash = SessionTokens.HashToken(rawToken);
|
||||
var session = await authStore.FindSessionByTokenHashAsync(tokenHash, ct);
|
||||
UserIdentityDto? user = null;
|
||||
if (session is not null && session.ExpiresAt > DateTimeOffset.UtcNow)
|
||||
{
|
||||
user = await authStore.FindUserByIdAsync(session.UserId, ct);
|
||||
// Деактивированный пользователь при живой сессии не получает доступ (Security review):
|
||||
// сессия разрешается только для активной учётки (как операторская в OperatorAuthService).
|
||||
if (user is not null && user.Status != UserActiveStatus)
|
||||
{
|
||||
user = null;
|
||||
}
|
||||
|
||||
if (user is not null)
|
||||
{
|
||||
var tenant = await tenantRepository.FindByIdAsync(user.TenantId, ct);
|
||||
if (tenant is not null && tenant.Status == TenantStatuses.Suspended)
|
||||
{
|
||||
user = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (session is not null)
|
||||
{
|
||||
// Сессия протухла: глобальная очистка протухших сессий выполняется ТОЛЬКО при обнаружении
|
||||
// протухшей (редкий случай), а не на каждом разрешении сессии (hot-path, Security review).
|
||||
await authStore.DeleteExpiredSessionsAsync(ct);
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
// Создаёт сессию пользователя: raw-токен наружу, в хранилище — его SHA-256-хэш.
|
||||
// userId: Идентификатор пользователя.
|
||||
// login: Логин (денормализуется в сессию для чтения без join).
|
||||
// impersonatedByOperatorId: Маркер impersonation: оператор, создавший сессию; null — обычный вход.
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: Raw-токен для выдачи клиенту.
|
||||
private async Task<string> CreateSessionForUserAsync(
|
||||
Guid userId,
|
||||
string login,
|
||||
Guid? impersonatedByOperatorId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
string rawToken = SessionTokens.NewToken();
|
||||
var session = new SessionDto(
|
||||
TokenHash: SessionTokens.HashToken(rawToken),
|
||||
UserId: userId,
|
||||
Login: login,
|
||||
ExpiresAt: DateTimeOffset.UtcNow.AddDays(SessionLifetimeDays),
|
||||
ImpersonatedByOperatorId: impersonatedByOperatorId);
|
||||
|
||||
await authStore.CreateSessionAsync(session, ct);
|
||||
return rawToken;
|
||||
}
|
||||
|
||||
// Успешный результат impersonation (токен + пользователь + тенант).
|
||||
// rawToken: Raw-токен созданной сессии.
|
||||
// userId: Идентификатор пользователя.
|
||||
// login: Логин пользователя.
|
||||
// tenantId: Тенант пользователя.
|
||||
// Возвращает: ImpersonationResultDto с Ok=true.
|
||||
private ImpersonationResultDto Success(
|
||||
string rawToken,
|
||||
Guid userId,
|
||||
string login,
|
||||
Guid tenantId) =>
|
||||
new(
|
||||
Ok: true,
|
||||
Error: null,
|
||||
SessionToken: rawToken,
|
||||
ExpiresAt: DateTimeOffset.UtcNow.AddDays(SessionLifetimeDays),
|
||||
Login: login,
|
||||
UserId: userId,
|
||||
TenantId: tenantId);
|
||||
|
||||
// Нормализация логина: нижний регистр и обрезка пробелов (единая форма хранения/сравнения).
|
||||
// login: Входной логин.
|
||||
// Возвращает: Нормализованный логин (пустая строка, если вход был пустым).
|
||||
private static string NormalizeLogin(string? login) => (login ?? string.Empty).ToLowerInvariant().Trim();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user