Дочистить код-стайл: 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:
Rustam Khalimov
2026-09-11 19:00:35 +03:00
parent dde86fd63c
commit 39c9bdc1b7
51 changed files with 3204 additions and 3215 deletions
@@ -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();
}
@@ -1,114 +1,114 @@
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models;
namespace Deal.Modules.Tenants.Application.Services;
/// <summary>
/// Прикладной сервис аутентификации оператора
/// </summary>
public sealed class OperatorAuthService(IOperatorAuthStore operatorAuthStore, IPasswordHasher passwordHasher)
{
/// <summary>
/// Срок жизни сессии оператора, часов.
/// </summary>
public const int SessionLifetimeHours = 12;
// Статус «активен»: только активного оператора разрешает ResolveSessionAsync (удалённый/приостановленный — null).
private const string ActiveStatus = "active";
/// <summary>
/// Вход оператора: при успехе создаёт сессию и возвращает её raw-токен.
/// </summary>
/// <param name="login">Логин (регистр и пробелы не важны — нормализуется).</param>
/// <param name="password">Пароль в открытом виде.</param>
/// <returns>При успехе — Login и Token; иначе оба null (текст «Неверный логин или пароль оператора» фиксирует endpoint).</returns>
public async Task<OperatorLoginResultDto> LoginAsync(
string login,
string password,
CancellationToken ct)
{
var normalizedLogin = NormalizeLogin(login);
if (string.IsNullOrEmpty(normalizedLogin) || string.IsNullOrEmpty(password))
{
return new OperatorLoginResultDto(null, null);
}
var operatorRecord = await operatorAuthStore.FindByLoginAsync(normalizedLogin, ct);
if (operatorRecord is null || !passwordHasher.Verify(password, operatorRecord.PasswordHash))
{
return new OperatorLoginResultDto(null, null);
}
var token = await CreateSessionForOperatorAsync(operatorRecord, ct);
return new OperatorLoginResultDto(operatorRecord.Login, token, operatorRecord.Id);
}
/// <summary>
/// Выход оператора
/// </summary>
/// <param name="rawToken">Raw-токен из куки (может отсутствовать — no-op).</param>
public async Task LogoutAsync(string? rawToken, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(rawToken))
{
return;
}
await operatorAuthStore.DeleteSessionAsync(SessionTokens.HashToken(rawToken), ct);
}
/// <summary>
/// Разрешение операторской сессии по raw-токену
/// </summary>
/// <param name="rawToken">Raw-токен из куки deal_operator_session.</param>
/// <returns>Идентичность активного оператора или null.</returns>
public async Task<OperatorIdentityDto?> ResolveSessionAsync(string? rawToken, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(rawToken))
{
return null;
}
var tokenHash = SessionTokens.HashToken(rawToken);
var session = await operatorAuthStore.FindSessionByTokenHashAsync(tokenHash, ct);
OperatorIdentityDto? operatorIdentity = null;
if (session is not null && session.ExpiresAt > DateTimeOffset.UtcNow)
{
var operatorRecord = await operatorAuthStore.FindByLoginAsync(session.Login, ct);
if (operatorRecord is not null && operatorRecord.Status == ActiveStatus)
{
operatorIdentity = new OperatorIdentityDto(operatorRecord.Id, operatorRecord.Login, operatorRecord.Status);
}
}
else if (session is not null)
{
// Очистка протухших сессий — только при обнаружении протухшей (редкий случай), не на каждый запрос
// (hot-path, Security review).
await operatorAuthStore.DeleteExpiredSessionsAsync(ct);
}
return operatorIdentity;
}
// Создаёт сессию оператора: raw-токен наружу, в хранилище — его SHA-256-хэш (формат как у SessionTokens).
// operatorRecord: Оператор (логин денормализуется в сессию).
// ct: Токен отмены.
// Возвращает: Raw-токен для выдачи клиенту.
private async Task<string> CreateSessionForOperatorAsync(StoredOperatorDto operatorRecord, CancellationToken ct)
{
var rawToken = SessionTokens.NewToken();
var session = new OperatorSessionDto(
TokenHash: SessionTokens.HashToken(rawToken),
OperatorId: operatorRecord.Id,
Login: operatorRecord.Login,
ExpiresAt: DateTimeOffset.UtcNow.AddHours(SessionLifetimeHours));
await operatorAuthStore.CreateSessionAsync(session, ct);
return rawToken;
}
// Нормализация логина: нижний регистр и обрезка пробелов (единая форма хранения/сравнения).
// 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 OperatorAuthService(IOperatorAuthStore operatorAuthStore, IPasswordHasher passwordHasher)
{
/// <summary>
/// Срок жизни сессии оператора, часов.
/// </summary>
public const int SessionLifetimeHours = 12;
// Статус «активен»: только активного оператора разрешает ResolveSessionAsync (удалённый/приостановленный — null).
private const string ActiveStatus = "active";
/// <summary>
/// Вход оператора: при успехе создаёт сессию и возвращает её raw-токен.
/// </summary>
/// <param name="login">Логин (регистр и пробелы не важны — нормализуется).</param>
/// <param name="password">Пароль в открытом виде.</param>
/// <returns>При успехе — Login и Token; иначе оба null (текст «Неверный логин или пароль оператора» фиксирует endpoint).</returns>
public async Task<OperatorLoginResultDto> LoginAsync(
string login,
string password,
CancellationToken ct)
{
string normalizedLogin = NormalizeLogin(login);
if (string.IsNullOrEmpty(normalizedLogin) || string.IsNullOrEmpty(password))
{
return new OperatorLoginResultDto(null, null);
}
var operatorRecord = await operatorAuthStore.FindByLoginAsync(normalizedLogin, ct);
if (operatorRecord is null || !passwordHasher.Verify(password, operatorRecord.PasswordHash))
{
return new OperatorLoginResultDto(null, null);
}
string token = await CreateSessionForOperatorAsync(operatorRecord, ct);
return new OperatorLoginResultDto(operatorRecord.Login, token, operatorRecord.Id);
}
/// <summary>
/// Выход оператора
/// </summary>
/// <param name="rawToken">Raw-токен из куки (может отсутствовать — no-op).</param>
public async Task LogoutAsync(string? rawToken, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(rawToken))
{
return;
}
await operatorAuthStore.DeleteSessionAsync(SessionTokens.HashToken(rawToken), ct);
}
/// <summary>
/// Разрешение операторской сессии по raw-токену
/// </summary>
/// <param name="rawToken">Raw-токен из куки deal_operator_session.</param>
/// <returns>Идентичность активного оператора или null.</returns>
public async Task<OperatorIdentityDto?> ResolveSessionAsync(string? rawToken, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(rawToken))
{
return null;
}
string tokenHash = SessionTokens.HashToken(rawToken);
var session = await operatorAuthStore.FindSessionByTokenHashAsync(tokenHash, ct);
OperatorIdentityDto? operatorIdentity = null;
if (session is not null && session.ExpiresAt > DateTimeOffset.UtcNow)
{
var operatorRecord = await operatorAuthStore.FindByLoginAsync(session.Login, ct);
if (operatorRecord is not null && operatorRecord.Status == ActiveStatus)
{
operatorIdentity = new OperatorIdentityDto(operatorRecord.Id, operatorRecord.Login, operatorRecord.Status);
}
}
else if (session is not null)
{
// Очистка протухших сессий — только при обнаружении протухшей (редкий случай), не на каждый запрос
// (горячий путь запросов).
await operatorAuthStore.DeleteExpiredSessionsAsync(ct);
}
return operatorIdentity;
}
// Создаёт сессию оператора: raw-токен наружу, в хранилище — его SHA-256-хэш (формат как у SessionTokens).
// operatorRecord: Оператор (логин денормализуется в сессию).
// ct: Токен отмены.
// Возвращает: Raw-токен для выдачи клиенту.
private async Task<string> CreateSessionForOperatorAsync(StoredOperatorDto operatorRecord, CancellationToken ct)
{
string rawToken = SessionTokens.NewToken();
var session = new OperatorSessionDto(
TokenHash: SessionTokens.HashToken(rawToken),
OperatorId: operatorRecord.Id,
Login: operatorRecord.Login,
ExpiresAt: DateTimeOffset.UtcNow.AddHours(SessionLifetimeHours));
await operatorAuthStore.CreateSessionAsync(session, ct);
return rawToken;
}
// Нормализация логина: нижний регистр и обрезка пробелов (единая форма хранения/сравнения).
// login: Входной логин.
// Возвращает: Нормализованный логин (пустая строка, если вход был пустым).
private static string NormalizeLogin(string? login) => (login ?? string.Empty).ToLowerInvariant().Trim();
}
@@ -1,80 +1,80 @@
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models;
namespace Deal.Modules.Tenants.Application.Services;
/// <summary>
/// Bootstrap оператора при старте
/// </summary>
public sealed class OperatorBootstrapService(IOperatorAuthStore operatorAuthStore, IPasswordHasher passwordHasher)
{
/// <summary>
/// Переменная окружения
/// </summary>
public const string LoginEnvKey = "DEAL_OPERATOR_LOGIN";
/// <summary>
/// Переменная окружения
/// </summary>
public const string PasswordEnvKey = "DEAL_OPERATOR_PASSWORD";
/// <summary>
/// Дефолтный логин в Development при отсутствии env-кред.
/// </summary>
public const string DefaultOperatorLogin = "operator";
/// <summary>
/// Дефолтный пароль в Development при отсутствии env-кред.
/// </summary>
public const string DefaultOperatorPassword = "operator";
private const string ActiveStatus = "active";
/// <summary>
/// Гарантирует наличие оператора
/// </summary>
/// <param name="login">Логин из env (<see cref="LoginEnvKey"/>) или null/пусто, если не задан.</param>
/// <param name="password">Пароль из env (<see cref="PasswordEnvKey"/>) или null/пусто, если не задан.</param>
/// <param name="allowDevelopmentDefaults">true в Development: при отсутствии кред берутся дефолты operator/operator; false (Production) при отсутствии кред — шаг пропускается, хост логирует warning.</param>
/// <returns>Логин оператора, присутствующего после шага (созданного или уже существовавшего); null — шаг пропущен.</returns>
public async Task<string?> EnsureOperatorAsync(
string? login,
string? password,
bool allowDevelopmentDefaults,
CancellationToken ct)
{
var normalizedLogin = NormalizeLogin(login);
var resolvedPassword = password;
if (string.IsNullOrEmpty(normalizedLogin) || string.IsNullOrEmpty(resolvedPassword))
{
if (!allowDevelopmentDefaults)
{
return null;
}
normalizedLogin = DefaultOperatorLogin;
resolvedPassword = DefaultOperatorPassword;
}
// Идемпотентность: оператор с таким логином уже есть — пароль не перезаписываем.
var existing = await operatorAuthStore.FindByLoginAsync(normalizedLogin, ct);
if (existing is not null)
{
return existing.Login;
}
await operatorAuthStore.CreateAsync(
new StoredOperatorDto(
Id: Guid.NewGuid(),
Login: normalizedLogin,
Status: ActiveStatus,
PasswordHash: passwordHasher.Hash(resolvedPassword)),
ct);
return normalizedLogin;
}
// Нормализация логина: нижний регистр и обрезка пробелов (единая форма хранения).
// login: Входной логин (может быть null — как «не задан»).
// Возвращает: Нормализованный логин (пустая строка, если вход был пустым).
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>
/// Bootstrap оператора при старте
/// </summary>
public sealed class OperatorBootstrapService(IOperatorAuthStore operatorAuthStore, IPasswordHasher passwordHasher)
{
/// <summary>
/// Переменная окружения
/// </summary>
public const string LoginEnvKey = "DEAL_OPERATOR_LOGIN";
/// <summary>
/// Переменная окружения
/// </summary>
public const string PasswordEnvKey = "DEAL_OPERATOR_PASSWORD";
/// <summary>
/// Дефолтный логин в Development при отсутствии env-кред.
/// </summary>
public const string DefaultOperatorLogin = "operator";
/// <summary>
/// Дефолтный пароль в Development при отсутствии env-кред.
/// </summary>
public const string DefaultOperatorPassword = "operator";
private const string ActiveStatus = "active";
/// <summary>
/// Гарантирует наличие оператора
/// </summary>
/// <param name="login">Логин из env (<see cref="LoginEnvKey"/>) или null/пусто, если не задан.</param>
/// <param name="password">Пароль из env (<see cref="PasswordEnvKey"/>) или null/пусто, если не задан.</param>
/// <param name="allowDevelopmentDefaults">true в Development: при отсутствии кред берутся дефолты operator/operator; false (Production) при отсутствии кред — шаг пропускается, хост логирует warning.</param>
/// <returns>Логин оператора, присутствующего после шага (созданного или уже существовавшего); null — шаг пропущен.</returns>
public async Task<string?> EnsureOperatorAsync(
string? login,
string? password,
bool allowDevelopmentDefaults,
CancellationToken ct)
{
string normalizedLogin = NormalizeLogin(login);
string? resolvedPassword = password;
if (string.IsNullOrEmpty(normalizedLogin) || string.IsNullOrEmpty(resolvedPassword))
{
if (!allowDevelopmentDefaults)
{
return null;
}
normalizedLogin = DefaultOperatorLogin;
resolvedPassword = DefaultOperatorPassword;
}
// Идемпотентность: оператор с таким логином уже есть — пароль не перезаписываем.
var existing = await operatorAuthStore.FindByLoginAsync(normalizedLogin, ct);
if (existing is not null)
{
return existing.Login;
}
await operatorAuthStore.CreateAsync(
new StoredOperatorDto(
Id: Guid.NewGuid(),
Login: normalizedLogin,
Status: ActiveStatus,
PasswordHash: passwordHasher.Hash(resolvedPassword)),
ct);
return normalizedLogin;
}
// Нормализация логина: нижний регистр и обрезка пробелов (единая форма хранения).
// login: Входной логин (может быть null — как «не задан»).
// Возвращает: Нормализованный логин (пустая строка, если вход был пустым).
private static string NormalizeLogin(string? login) => (login ?? string.Empty).ToLowerInvariant().Trim();
}
@@ -1,32 +1,32 @@
using System.Security.Cryptography;
using System.Text;
using Deal.SharedKernel;
using Deal.SharedKernel.Utilities;
namespace Deal.Modules.Tenants.Application.Services;
/// <summary>
/// Токены сессий: генерация raw-токена и его SHA-256-хэша для хранения.
/// </summary>
public static class SessionTokens
{
// Случайные байты raw-токена (32 → 43 символа Base64Url).
private const int RawTokenByteLength = 32;
/// <summary>
/// Новый raw-токен
/// </summary>
/// <returns>Строка токена длиной 43 символа.</returns>
public static string NewToken() => UrlSafeToken.New(RawTokenByteLength);
/// <summary>
/// SHA-256-хэш raw-токена в нижнем регистре
/// </summary>
/// <param name="rawToken">Raw-токен из <see cref="NewToken"/> (или от клиента).</param>
/// <returns>64 hex-символа.</returns>
public static string HashToken(string rawToken)
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(rawToken));
return Convert.ToHexString(hash).ToLowerInvariant();
}
}
using System.Security.Cryptography;
using System.Text;
using Deal.SharedKernel;
using Deal.SharedKernel.Utilities;
namespace Deal.Modules.Tenants.Application.Services;
/// <summary>
/// Токены сессий: генерация raw-токена и его SHA-256-хэша для хранения.
/// </summary>
public static class SessionTokens
{
// Случайные байты raw-токена (32 → 43 символа Base64Url).
private const int RawTokenByteLength = 32;
/// <summary>
/// Новый raw-токен
/// </summary>
/// <returns>Строка токена длиной 43 символа.</returns>
public static string NewToken() => UrlSafeToken.New(RawTokenByteLength);
/// <summary>
/// SHA-256-хэш raw-токена в нижнем регистре
/// </summary>
/// <param name="rawToken">Raw-токен из <see cref="NewToken"/> (или от клиента).</param>
/// <returns>64 hex-символа.</returns>
public static string HashToken(string rawToken)
{
byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(rawToken));
return Convert.ToHexString(hash).ToLowerInvariant();
}
}
@@ -1,50 +1,50 @@
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models;
using Deal.SharedKernel.Tenants.Models;
namespace Deal.Modules.Tenants.Application.Services;
/// <summary>
/// Прикладной сервис реестра тенантов
/// </summary>
public sealed class TenantService(ITenantRepository tenantRepository, ITenantProvisioner tenantProvisioner)
{
/// <summary>
/// Создаёт тенанта
/// </summary>
/// <param name="name">Имя тенанта.</param>
/// <returns>Идентификатор созданного тенанта (он же имя схемы tenant_&lt;id&gt).</returns>
public Task<TenantId> CreateTenantAsync(string name, CancellationToken ct) =>
CreateTenantAsync(name, Guid.NewGuid(), ct);
/// <summary>
/// Создаёт тенанта с явным id и провижинит его схему.
/// </summary>
/// <param name="name">Имя тенанта.</param>
/// <param name="id">Идентификатор тенанта (определяет имя схемы).</param>
/// <returns>Идентификатор созданного тенанта (он же имя схемы tenant_&lt;id&gt).</returns>
public async Task<TenantId> CreateTenantAsync(
string name,
Guid id,
CancellationToken ct)
{
await tenantRepository.CreateAsync(
new TenantRecordDto(
Id: id,
Name: name,
Status: TenantStatuses.Active,
CreatedAt: DateTimeOffset.UtcNow),
ct);
var tenantIdValue = id.ToString("N");
await tenantProvisioner.ProvisionAsync(new TenantId(tenantIdValue), ct);
return new TenantId(tenantIdValue);
}
/// <summary>
/// Возвращает список всех тенантов.
/// </summary>
/// <returns>Список тенантов.</returns>
public Task<IReadOnlyList<TenantRecordDto>> ListTenantsAsync(CancellationToken ct) =>
tenantRepository.ListAsync(ct);
}
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models;
using Deal.SharedKernel.Tenants.Models;
namespace Deal.Modules.Tenants.Application.Services;
/// <summary>
/// Прикладной сервис реестра тенантов
/// </summary>
public sealed class TenantService(ITenantRepository tenantRepository, ITenantProvisioner tenantProvisioner)
{
/// <summary>
/// Создаёт тенанта
/// </summary>
/// <param name="name">Имя тенанта.</param>
/// <returns>Идентификатор созданного тенанта (он же имя схемы tenant_&lt;id&gt).</returns>
public Task<TenantId> CreateTenantAsync(string name, CancellationToken ct) =>
CreateTenantAsync(name, Guid.NewGuid(), ct);
/// <summary>
/// Создаёт тенанта с явным id и провижинит его схему.
/// </summary>
/// <param name="name">Имя тенанта.</param>
/// <param name="id">Идентификатор тенанта (определяет имя схемы).</param>
/// <returns>Идентификатор созданного тенанта (он же имя схемы tenant_&lt;id&gt).</returns>
public async Task<TenantId> CreateTenantAsync(
string name,
Guid id,
CancellationToken ct)
{
await tenantRepository.CreateAsync(
new TenantRecordDto(
Id: id,
Name: name,
Status: TenantStatuses.Active,
CreatedAt: DateTimeOffset.UtcNow),
ct);
string tenantIdValue = id.ToString("N");
await tenantProvisioner.ProvisionAsync(new TenantId(tenantIdValue), ct);
return new TenantId(tenantIdValue);
}
/// <summary>
/// Возвращает список всех тенантов.
/// </summary>
/// <returns>Список тенантов.</returns>
public Task<IReadOnlyList<TenantRecordDto>> ListTenantsAsync(CancellationToken ct) =>
tenantRepository.ListAsync(ct);
}