Убрать неиспользуемые using по код-стайлу
Прогон dotnet format (IDE0005) по 4 решениям: удалены лишние using, оставшиеся после миграции namespace (676 файлов).
This commit is contained in:
@@ -1,7 +1,5 @@
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Extensions;
|
||||
using Deal.Modules.Tenants.Application.Registrars;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
namespace Deal.Modules.Tenants.Application.Services;
|
||||
|
||||
|
||||
@@ -1,87 +1,85 @@
|
||||
using System.Text.Json;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.SharedKernel.Observability;
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Extensions;
|
||||
using Deal.Modules.Tenants.Application.Registrars;
|
||||
|
||||
namespace Deal.Modules.Tenants.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Прикладной сервис аудита (Ruling 4 этапа 7): append-only запись событий и чтение ленты оператором.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Единственная точка записи в public.audit_log: <see cref="AppendAsync"/> сам проставляет At=UTC-now, вызывается
|
||||
/// из эндпоинтов/сервисов (входы, инвайты, impersonation, действия оператора — задачи 4–10). Update/Delete в
|
||||
/// порту отсутствуют (append-only на уровне кода); TTL/авто-очистка не делаются (Ruling 4). Чтение — только
|
||||
/// оператору (GET /api/operator/audit через QueryAsync/CountAsync). Каталог событий — <see cref="AuditEvents"/>,
|
||||
/// типы акторов — <see cref="AuditActorTypes"/>, JSON деталей — <see cref="ToDetailJson"/> (camelCase, без секретов).
|
||||
/// </remarks>
|
||||
public sealed class AuditService(IAuditLogStore store)
|
||||
{
|
||||
/// <summary>
|
||||
/// Верхняя граница выборки аудита (Ruling 4: limit ≤500).
|
||||
/// </summary>
|
||||
public const int MaxQueryLimit = 500;
|
||||
|
||||
/// <summary>
|
||||
/// Размер выборки по умолчанию при отсутствии limit в запросе (эталон DiscoveryLogService).
|
||||
/// </summary>
|
||||
public const int DefaultQueryLimit = 100;
|
||||
|
||||
// Опции JSON деталей: camelCase (конвенция DetailJson/JSON проекта).
|
||||
private static readonly JsonSerializerOptions DetailJsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
/// <summary>
|
||||
/// Записывает событие аудита (append-only; At = сейчас, UTC).
|
||||
/// </summary>
|
||||
/// <param name="record">Запись события (At и Id игнорируются: At проставляет сервис, Id — БД).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
public async Task AppendAsync(AuditRecordDto record, CancellationToken ct)
|
||||
{
|
||||
await store.AppendAsync(record with { At = DateTimeOffset.UtcNow }, ct);
|
||||
// Прикладная метрика (этап 12, пакет A): счётчик событий аудита по типу/актору
|
||||
// (низкокардинальные метки — без tenantId/actorId).
|
||||
DealMetrics.RecordAuditEvent(record.EventType, record.ActorType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Записи по фильтру, новые сверху (прокси порта; чтение — операторский эндпоинт).
|
||||
/// </summary>
|
||||
/// <param name="filter">Фильтр выборки.</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Записи от новых к старым.</returns>
|
||||
public Task<IReadOnlyList<AuditRecordDto>> QueryAsync(AuditQueryDto filter, CancellationToken ct) =>
|
||||
store.QueryAsync(filter, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Число записей по фильтру (для ответа {items, total}).
|
||||
/// </summary>
|
||||
/// <param name="filter">Фильтр выборки.</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Полное число записей по фильтру.</returns>
|
||||
public Task<int> CountAsync(AuditQueryDto filter, CancellationToken ct) => store.CountAsync(filter, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Сериализует детали события в JSON (camelCase; секреты в объект не класть — правило Ruling 4).
|
||||
/// </summary>
|
||||
/// <param name="details">Объект деталей (обычно анонимный: { login = ... }).</param>
|
||||
/// <returns>JSON-строка деталей.</returns>
|
||||
public static string ToDetailJson(object? details) => JsonSerializer.Serialize(details, DetailJsonOptions);
|
||||
|
||||
/// <summary>
|
||||
/// Актор «пользователь тенанта» по разрешённой сессии: (ActorType, ActorId, TenantId).
|
||||
/// </summary>
|
||||
/// <param name="user">Идентичность пользователя тенанта.</param>
|
||||
/// <returns>Кортеж актора для полей записи аудита.</returns>
|
||||
public static (string ActorType, Guid? ActorId, Guid? TenantId) ActorFromUser(UserIdentityDto user) =>
|
||||
(AuditActorTypes.Tenant, user.Id, user.TenantId);
|
||||
|
||||
/// <summary>
|
||||
/// Актор «оператор» по разрешённой операторской сессии: (ActorType, ActorId, TenantId=null).
|
||||
/// </summary>
|
||||
/// <param name="operatorIdentity">Идентичность оператора.</param>
|
||||
/// <returns>Кортеж актора для полей записи аудита.</returns>
|
||||
public static (string ActorType, Guid? ActorId, Guid? TenantId) ActorFromOperator(OperatorIdentityDto operatorIdentity) =>
|
||||
(AuditActorTypes.Operator, operatorIdentity.Id, null);
|
||||
}
|
||||
using System.Text.Json;
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.SharedKernel.Observability;
|
||||
|
||||
namespace Deal.Modules.Tenants.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Прикладной сервис аудита (Ruling 4 этапа 7): append-only запись событий и чтение ленты оператором.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Единственная точка записи в public.audit_log: <see cref="AppendAsync"/> сам проставляет At=UTC-now, вызывается
|
||||
/// из эндпоинтов/сервисов (входы, инвайты, impersonation, действия оператора — задачи 4–10). Update/Delete в
|
||||
/// порту отсутствуют (append-only на уровне кода); TTL/авто-очистка не делаются (Ruling 4). Чтение — только
|
||||
/// оператору (GET /api/operator/audit через QueryAsync/CountAsync). Каталог событий — <see cref="AuditEvents"/>,
|
||||
/// типы акторов — <see cref="AuditActorTypes"/>, JSON деталей — <see cref="ToDetailJson"/> (camelCase, без секретов).
|
||||
/// </remarks>
|
||||
public sealed class AuditService(IAuditLogStore store)
|
||||
{
|
||||
/// <summary>
|
||||
/// Верхняя граница выборки аудита (Ruling 4: limit ≤500).
|
||||
/// </summary>
|
||||
public const int MaxQueryLimit = 500;
|
||||
|
||||
/// <summary>
|
||||
/// Размер выборки по умолчанию при отсутствии limit в запросе (эталон DiscoveryLogService).
|
||||
/// </summary>
|
||||
public const int DefaultQueryLimit = 100;
|
||||
|
||||
// Опции JSON деталей: camelCase (конвенция DetailJson/JSON проекта).
|
||||
private static readonly JsonSerializerOptions DetailJsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
/// <summary>
|
||||
/// Записывает событие аудита (append-only; At = сейчас, UTC).
|
||||
/// </summary>
|
||||
/// <param name="record">Запись события (At и Id игнорируются: At проставляет сервис, Id — БД).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
public async Task AppendAsync(AuditRecordDto record, CancellationToken ct)
|
||||
{
|
||||
await store.AppendAsync(record with { At = DateTimeOffset.UtcNow }, ct);
|
||||
// Прикладная метрика (этап 12, пакет A): счётчик событий аудита по типу/актору
|
||||
// (низкокардинальные метки — без tenantId/actorId).
|
||||
DealMetrics.RecordAuditEvent(record.EventType, record.ActorType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Записи по фильтру, новые сверху (прокси порта; чтение — операторский эндпоинт).
|
||||
/// </summary>
|
||||
/// <param name="filter">Фильтр выборки.</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Записи от новых к старым.</returns>
|
||||
public Task<IReadOnlyList<AuditRecordDto>> QueryAsync(AuditQueryDto filter, CancellationToken ct) =>
|
||||
store.QueryAsync(filter, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Число записей по фильтру (для ответа {items, total}).
|
||||
/// </summary>
|
||||
/// <param name="filter">Фильтр выборки.</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Полное число записей по фильтру.</returns>
|
||||
public Task<int> CountAsync(AuditQueryDto filter, CancellationToken ct) => store.CountAsync(filter, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Сериализует детали события в JSON (camelCase; секреты в объект не класть — правило Ruling 4).
|
||||
/// </summary>
|
||||
/// <param name="details">Объект деталей (обычно анонимный: { login = ... }).</param>
|
||||
/// <returns>JSON-строка деталей.</returns>
|
||||
public static string ToDetailJson(object? details) => JsonSerializer.Serialize(details, DetailJsonOptions);
|
||||
|
||||
/// <summary>
|
||||
/// Актор «пользователь тенанта» по разрешённой сессии: (ActorType, ActorId, TenantId).
|
||||
/// </summary>
|
||||
/// <param name="user">Идентичность пользователя тенанта.</param>
|
||||
/// <returns>Кортеж актора для полей записи аудита.</returns>
|
||||
public static (string ActorType, Guid? ActorId, Guid? TenantId) ActorFromUser(UserIdentityDto user) =>
|
||||
(AuditActorTypes.Tenant, user.Id, user.TenantId);
|
||||
|
||||
/// <summary>
|
||||
/// Актор «оператор» по разрешённой операторской сессии: (ActorType, ActorId, TenantId=null).
|
||||
/// </summary>
|
||||
/// <param name="operatorIdentity">Идентичность оператора.</param>
|
||||
/// <returns>Кортеж актора для полей записи аудита.</returns>
|
||||
public static (string ActorType, Guid? ActorId, Guid? TenantId) ActorFromOperator(OperatorIdentityDto operatorIdentity) =>
|
||||
(AuditActorTypes.Operator, operatorIdentity.Id, null);
|
||||
}
|
||||
|
||||
@@ -1,317 +1,315 @@
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Extensions;
|
||||
using Deal.Modules.Tenants.Application.Registrars;
|
||||
|
||||
namespace Deal.Modules.Tenants.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Прикладной сервис аутентификации: login, logout, смена пароля, разрешение сессии, impersonation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Семантика повторяет прототип LeadRadar (<c>backend/app/auth.py</c>): сообщения об ошибках
|
||||
/// фиксирует HTTP-слой (Task 4/7), сервис возвращает коды/null и не бросает исключений
|
||||
/// для бизнес-отказов. Логин нормализуется в нижний регистр (Ruling: сравнение по lowercase).
|
||||
/// <para>
|
||||
/// Гейт приостановки (Task 7/Ruling 10(5); этап 12, пакет B): login проверяет статус тенанта через
|
||||
/// <see cref="ITenantRepository"/> — suspended возвращает <see cref="LoginResultDto.ErrorTenantSuspended"/>
|
||||
/// (HTTP-слой отвечает 403 и пишет tenant_login_failed с tenantId); <see cref="ResolveSessionAsync"/>
|
||||
/// проверяет статус на каждом запросе — активные сессии suspended-тенанта перестают действовать немедленно
|
||||
/// (включая impersonation), при resume — вновь работают. Impersonation выпускает
|
||||
/// обычную tenant-сессию выбранного пользователя с маркером оператора (<see cref="SessionDto.ImpersonatedByOperatorId"/>);
|
||||
/// завершение — logout'ом пользователя, о нём сервис сообщает <see cref="LogoutResultDto"/> для аудита
|
||||
/// impersonation_stopped. Пароль при impersonation не меняется.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class AuthService(
|
||||
IAuthStore authStore,
|
||||
IPasswordHasher passwordHasher,
|
||||
ITenantRepository tenantRepository)
|
||||
{
|
||||
/// <summary>
|
||||
/// Срок жизни сессии, дней (Ruling 6: 30). Единый источник «30» — на него ссылается кука (Task 5).
|
||||
/// </summary>
|
||||
public const int SessionLifetimeDays = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Минимальная длина нового пароля. Единый источник — на него ссылается активация инвайта
|
||||
/// (JoinService, Task 6), чтобы минимум не разошёлся.
|
||||
/// </summary>
|
||||
public const int MinNewPasswordLength = 8;
|
||||
|
||||
// Статус «активен»: только активного пользователя разрешает ResolveSessionAsync (деактивированный
|
||||
// вручную с живой сессией — null, как у оператора OperatorAuthService).
|
||||
private const string UserActiveStatus = "active";
|
||||
|
||||
/// <summary>
|
||||
/// Вход: при успехе создаёт сессию и возвращает её raw-токен. Вход suspended-тенанта заблокирован (Ruling 10(5)).
|
||||
/// </summary>
|
||||
/// <param name="login">Логин (регистр и пробелы не важны — нормализуется).</param>
|
||||
/// <param name="password">Пароль в открытом виде.</param>
|
||||
/// <param name="ct">Токен отмены.</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)
|
||||
{
|
||||
// Заблокированный вход: HTTP-слой пишет tenant_login_failed с tenantId (замечание ревью Task 4).
|
||||
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-токену (no-op без токена).
|
||||
/// </summary>
|
||||
/// <param name="rawToken">Raw-токен из куки (может отсутствовать — no-op).</param>
|
||||
/// <param name="ct">Токен отмены.</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>
|
||||
/// <param name="ct">Токен отмены.</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);
|
||||
}
|
||||
|
||||
// Семантика прототипа (change_password + auth_routes.change): разлогиниваем все старые
|
||||
// сессии, обновляем хэш и выдаём свежую — её raw-токен вернёт HTTP-слой в куке.
|
||||
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-сессия целевого пользователя от имени оператора (план Task 7).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Пароль пользователя не меняется и не требуется: сессия выпускается оператором напрямую с маркером
|
||||
/// <see cref="SessionDto.ImpersonatedByOperatorId"/> (для аудита stopped при logout). login опционален:
|
||||
/// не задан — берётся первый пользователь тенанта (по CreatedAt); задан — пользователь обязан
|
||||
/// принадлежать тенанту. Приостановленный тенант не блокирует impersonation (операторский доступ,
|
||||
/// аудируется; ИИ-расход всё равно заморожен гейтом Task 9).
|
||||
/// </remarks>
|
||||
/// <param name="tenantId">Идентификатор тенанта.</param>
|
||||
/// <param name="targetLogin">Логин пользователя (null/пустой — первый пользователь тенанта).</param>
|
||||
/// <param name="operatorId">Идентификатор оператора, начинающего impersonation (маркер сессии).</param>
|
||||
/// <param name="ct">Токен отмены.</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-токену: возвращает пользователя или null (нет/протухла).
|
||||
/// </summary>
|
||||
/// <param name="rawToken">Raw-токен из куки.</param>
|
||||
/// <param name="ct">Токен отмены.</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)
|
||||
{
|
||||
// Приостановка тенанта действует немедленно (этап 12, пакет B): статус проверяется на
|
||||
// каждом разрешении сессии, поэтому активные сессии suspended-тенанта перестают работать
|
||||
// сразу после suspend, а не доживают до expiry (ранее — Ruling 10(5)). Флаг состояния не
|
||||
// храним: при resume доступ возвращается тем же путём (login не блокирует активные сессии).
|
||||
// Касается и impersonation-сессий (та же tenant-сессия с маркером оператора).
|
||||
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>
|
||||
/// Прикладной сервис аутентификации: login, logout, смена пароля, разрешение сессии, impersonation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Семантика повторяет прототип LeadRadar (<c>backend/app/auth.py</c>): сообщения об ошибках
|
||||
/// фиксирует HTTP-слой (Task 4/7), сервис возвращает коды/null и не бросает исключений
|
||||
/// для бизнес-отказов. Логин нормализуется в нижний регистр (Ruling: сравнение по lowercase).
|
||||
/// <para>
|
||||
/// Гейт приостановки (Task 7/Ruling 10(5); этап 12, пакет B): login проверяет статус тенанта через
|
||||
/// <see cref="ITenantRepository"/> — suspended возвращает <see cref="LoginResultDto.ErrorTenantSuspended"/>
|
||||
/// (HTTP-слой отвечает 403 и пишет tenant_login_failed с tenantId); <see cref="ResolveSessionAsync"/>
|
||||
/// проверяет статус на каждом запросе — активные сессии suspended-тенанта перестают действовать немедленно
|
||||
/// (включая impersonation), при resume — вновь работают. Impersonation выпускает
|
||||
/// обычную tenant-сессию выбранного пользователя с маркером оператора (<see cref="SessionDto.ImpersonatedByOperatorId"/>);
|
||||
/// завершение — logout'ом пользователя, о нём сервис сообщает <see cref="LogoutResultDto"/> для аудита
|
||||
/// impersonation_stopped. Пароль при impersonation не меняется.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class AuthService(
|
||||
IAuthStore authStore,
|
||||
IPasswordHasher passwordHasher,
|
||||
ITenantRepository tenantRepository)
|
||||
{
|
||||
/// <summary>
|
||||
/// Срок жизни сессии, дней (Ruling 6: 30). Единый источник «30» — на него ссылается кука (Task 5).
|
||||
/// </summary>
|
||||
public const int SessionLifetimeDays = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Минимальная длина нового пароля. Единый источник — на него ссылается активация инвайта
|
||||
/// (JoinService, Task 6), чтобы минимум не разошёлся.
|
||||
/// </summary>
|
||||
public const int MinNewPasswordLength = 8;
|
||||
|
||||
// Статус «активен»: только активного пользователя разрешает ResolveSessionAsync (деактивированный
|
||||
// вручную с живой сессией — null, как у оператора OperatorAuthService).
|
||||
private const string UserActiveStatus = "active";
|
||||
|
||||
/// <summary>
|
||||
/// Вход: при успехе создаёт сессию и возвращает её raw-токен. Вход suspended-тенанта заблокирован (Ruling 10(5)).
|
||||
/// </summary>
|
||||
/// <param name="login">Логин (регистр и пробелы не важны — нормализуется).</param>
|
||||
/// <param name="password">Пароль в открытом виде.</param>
|
||||
/// <param name="ct">Токен отмены.</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)
|
||||
{
|
||||
// Заблокированный вход: HTTP-слой пишет tenant_login_failed с tenantId (замечание ревью Task 4).
|
||||
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-токену (no-op без токена).
|
||||
/// </summary>
|
||||
/// <param name="rawToken">Raw-токен из куки (может отсутствовать — no-op).</param>
|
||||
/// <param name="ct">Токен отмены.</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>
|
||||
/// <param name="ct">Токен отмены.</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);
|
||||
}
|
||||
|
||||
// Семантика прототипа (change_password + auth_routes.change): разлогиниваем все старые
|
||||
// сессии, обновляем хэш и выдаём свежую — её raw-токен вернёт HTTP-слой в куке.
|
||||
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-сессия целевого пользователя от имени оператора (план Task 7).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Пароль пользователя не меняется и не требуется: сессия выпускается оператором напрямую с маркером
|
||||
/// <see cref="SessionDto.ImpersonatedByOperatorId"/> (для аудита stopped при logout). login опционален:
|
||||
/// не задан — берётся первый пользователь тенанта (по CreatedAt); задан — пользователь обязан
|
||||
/// принадлежать тенанту. Приостановленный тенант не блокирует impersonation (операторский доступ,
|
||||
/// аудируется; ИИ-расход всё равно заморожен гейтом Task 9).
|
||||
/// </remarks>
|
||||
/// <param name="tenantId">Идентификатор тенанта.</param>
|
||||
/// <param name="targetLogin">Логин пользователя (null/пустой — первый пользователь тенанта).</param>
|
||||
/// <param name="operatorId">Идентификатор оператора, начинающего impersonation (маркер сессии).</param>
|
||||
/// <param name="ct">Токен отмены.</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-токену: возвращает пользователя или null (нет/протухла).
|
||||
/// </summary>
|
||||
/// <param name="rawToken">Raw-токен из куки.</param>
|
||||
/// <param name="ct">Токен отмены.</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)
|
||||
{
|
||||
// Приостановка тенанта действует немедленно (этап 12, пакет B): статус проверяется на
|
||||
// каждом разрешении сессии, поэтому активные сессии suspended-тенанта перестают работать
|
||||
// сразу после suspend, а не доживают до expiry (ранее — Ruling 10(5)). Флаг состояния не
|
||||
// храним: при resume доступ возвращается тем же путём (login не блокирует активные сессии).
|
||||
// Касается и impersonation-сессий (та же tenant-сессия с маркером оператора).
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -1,25 +1,22 @@
|
||||
using Isopoh.Cryptography.Argon2;
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Extensions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.Modules.Tenants.Application.Registrars;
|
||||
|
||||
namespace Deal.Modules.Tenants.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Реализация <see cref="IPasswordHasher"/> на Argon2id (Ruling 5).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Используются дефолты пакета Isopoh.Cryptography.Argon2 (версия 2.0.0):
|
||||
/// соль — 16 случайных байт, t=3, m=65536 (64 MiB), p=1, вариант Argon2id
|
||||
/// (в библиотеке — Argon2Type.HybridAddressing), длина хэша 32 байта.
|
||||
/// Класс без состояния — регистрируется как singleton.
|
||||
/// </remarks>
|
||||
public sealed class DefaultPasswordHasher : IPasswordHasher
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string Hash(string password) => Argon2.Hash(password);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool Verify(string password, string encodedHash) => Argon2.Verify(encodedHash, password);
|
||||
}
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Isopoh.Cryptography.Argon2;
|
||||
|
||||
namespace Deal.Modules.Tenants.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Реализация <see cref="IPasswordHasher"/> на Argon2id (Ruling 5).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Используются дефолты пакета Isopoh.Cryptography.Argon2 (версия 2.0.0):
|
||||
/// соль — 16 случайных байт, t=3, m=65536 (64 MiB), p=1, вариант Argon2id
|
||||
/// (в библиотеке — Argon2Type.HybridAddressing), длина хэша 32 байта.
|
||||
/// Класс без состояния — регистрируется как singleton.
|
||||
/// </remarks>
|
||||
public sealed class DefaultPasswordHasher : IPasswordHasher
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string Hash(string password) => Argon2.Hash(password);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool Verify(string password, string encodedHash) => Argon2.Verify(encodedHash, password);
|
||||
}
|
||||
|
||||
@@ -1,33 +1,29 @@
|
||||
using Deal.SharedKernel;
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Extensions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.Modules.Tenants.Application.Registrars;
|
||||
|
||||
namespace Deal.Modules.Tenants.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Генератор кодов приглашений: случайный url-safe код, 16 символов, без префикса (Ruling 2 этапа 7).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Ровно 16 символов получаются из 12 случайных байт в Base64Url без padding (12 байт → 16 символов).
|
||||
/// Код — первичный ключ public.invites и одноразовый «секрет» приглашения (его вводит приглашённый в /api/join,
|
||||
/// Task 6), поэтому источник — криптостойкий <see cref="System.Security.Cryptography.RandomNumberGenerator"/>
|
||||
/// (общий генератор <see cref="UrlSafeToken"/>, Security review C36).
|
||||
/// </remarks>
|
||||
public static class InviteCodeGenerator
|
||||
{
|
||||
/// <summary>
|
||||
/// Длина кода в символах (Ruling 2: 16).
|
||||
/// </summary>
|
||||
public const int CodeLength = 16;
|
||||
|
||||
// Случайные байты одного кода: 12 → ровно 16 символов Base64Url.
|
||||
private const int RandomByteCount = 12;
|
||||
|
||||
/// <summary>
|
||||
/// Новый код приглашения: 16 url-safe символов (Base64Url 12 случайных байт, без '+' и '/').
|
||||
/// </summary>
|
||||
/// <returns>Строка кода длиной <see cref="CodeLength"/> символов.</returns>
|
||||
public static string NewCode() => UrlSafeToken.New(RandomByteCount);
|
||||
}
|
||||
using Deal.SharedKernel;
|
||||
|
||||
namespace Deal.Modules.Tenants.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Генератор кодов приглашений: случайный url-safe код, 16 символов, без префикса (Ruling 2 этапа 7).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Ровно 16 символов получаются из 12 случайных байт в Base64Url без padding (12 байт → 16 символов).
|
||||
/// Код — первичный ключ public.invites и одноразовый «секрет» приглашения (его вводит приглашённый в /api/join,
|
||||
/// Task 6), поэтому источник — криптостойкий <see cref="System.Security.Cryptography.RandomNumberGenerator"/>
|
||||
/// (общий генератор <see cref="UrlSafeToken"/>, Security review C36).
|
||||
/// </remarks>
|
||||
public static class InviteCodeGenerator
|
||||
{
|
||||
/// <summary>
|
||||
/// Длина кода в символах (Ruling 2: 16).
|
||||
/// </summary>
|
||||
public const int CodeLength = 16;
|
||||
|
||||
// Случайные байты одного кода: 12 → ровно 16 символов Base64Url.
|
||||
private const int RandomByteCount = 12;
|
||||
|
||||
/// <summary>
|
||||
/// Новый код приглашения: 16 url-safe символов (Base64Url 12 случайных байт, без '+' и '/').
|
||||
/// </summary>
|
||||
/// <returns>Строка кода длиной <see cref="CodeLength"/> символов.</returns>
|
||||
public static string NewCode() => UrlSafeToken.New(RandomByteCount);
|
||||
}
|
||||
|
||||
@@ -1,192 +1,191 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Extensions;
|
||||
using Deal.Modules.Tenants.Application.Registrars;
|
||||
|
||||
namespace Deal.Modules.Tenants.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Прикладной сервис приглашений (Ruling 2 этапа 7): создание оператором, отзыв, список, чтение по коду.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Жизненный цикл статусов: pending → revoked | expired | activated. «expired» хранилище не проставляет само:
|
||||
/// он вычисляется лениво при чтении/проверке (<see cref="GetByCodeAsync"/>/<see cref="ListAsync"/>) и сохраняется,
|
||||
/// иначе частичный unique-индекс invites.Email по pending (InviteConfiguration, Task 1) заблокировал бы повторное
|
||||
/// приглашение на тот же email после истечения. Создание/отзыв — только оператор (вызывается из
|
||||
/// /api/operator/invites, Task 5); тексты HTTP-ошибок фиксирует слой эндпоинтов — сервис возвращает коды/null и
|
||||
/// не бросает исключений для бизнес-отказов (паттерн AuthService). Активацию (pending → activated) выполняет
|
||||
/// /api/join (Task 6): он читает приглашение через <see cref="GetByCodeAsync"/> (валидация статуса/expiry).
|
||||
/// </remarks>
|
||||
public sealed partial class InvitesService(IInviteStore inviteStore)
|
||||
{
|
||||
/// <summary>
|
||||
/// Срок действия приглашения, часов (Ruling 2: 72).
|
||||
/// </summary>
|
||||
public const int ExpiryHours = 72;
|
||||
|
||||
// Максимальная длина email — совпадает с шириной колонки invites.Email (InviteConfiguration, Task 1).
|
||||
private const int MaxEmailLength = 200;
|
||||
|
||||
// Проверяемый формат: один '@', непустые локальная часть и домен с точкой, без пробелов.
|
||||
private const string EmailFormatPattern = @"^[^@\s]+@[^@\s]+\.[^@\s]+$";
|
||||
|
||||
// Компилируется из EmailFormatPattern; вход ограничен 200 символами, паттерн линейный.
|
||||
[GeneratedRegex(EmailFormatPattern, RegexOptions.CultureInvariant)]
|
||||
private static partial Regex EmailFormatRegex();
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт приглашение оператором: нормализация/валидация email, антидубль активного, срок +72 часа.
|
||||
/// </summary>
|
||||
/// <param name="operatorId">Идентификатор оператора (CreatedById приглашения).</param>
|
||||
/// <param name="email">Email приглашённого (регистр/пробелы не важны — нормализуется).</param>
|
||||
/// <param name="tenantId">Целевой тенант; null — при активации будет создан новый тенант (Task 6).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Ok=true + созданное приглашение, либо код ошибки (текст — HTTP-слой).</returns>
|
||||
public async Task<InviteCreateResultDto> CreateInviteAsync(
|
||||
Guid operatorId,
|
||||
string? email,
|
||||
Guid? tenantId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
string normalizedEmail = NormalizeEmail(email);
|
||||
if (!IsValidEmail(normalizedEmail))
|
||||
{
|
||||
return new InviteCreateResultDto(Ok: false, Error: InviteCreateResultDto.ErrorInvalidEmail, Invite: null);
|
||||
}
|
||||
|
||||
// Антидубль «одно активное приглашение на email» (Ruling 2): pending блокирует новое. Если найденное
|
||||
// pending уже истекло (статус ещё не переведён), сначала помечаем expired — иначе partial unique-индекс
|
||||
// по pending не пустит новую строку; затем создаём новое приглашение. Отозванный/активированный/
|
||||
// истёкший email свободен (глобальную уникальность регистрации держит unique-индекс users.Login).
|
||||
var active = await inviteStore.FindActiveByEmailAsync(normalizedEmail, ct);
|
||||
if (active is not null)
|
||||
{
|
||||
if (active.IsExpired())
|
||||
{
|
||||
await inviteStore.UpdateStatusAsync(active.Code, InviteStatuses.Expired, null, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
return new InviteCreateResultDto(Ok: false, Error: InviteCreateResultDto.ErrorDuplicateActive, Invite: null);
|
||||
}
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var invite = new InviteDto(
|
||||
Code: InviteCodeGenerator.NewCode(),
|
||||
Email: normalizedEmail,
|
||||
TenantId: tenantId,
|
||||
Status: InviteStatuses.Pending,
|
||||
ExpiresAt: now.AddHours(ExpiryHours),
|
||||
ActivatedAt: null,
|
||||
CreatedById: operatorId,
|
||||
CreatedAt: now);
|
||||
await inviteStore.CreateAsync(invite, ct);
|
||||
return new InviteCreateResultDto(Ok: true, Error: null, Invite: invite);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Отзывает приглашение: только pending переводится в revoked; иные статусы не трогаем (Ruling 2).
|
||||
/// </summary>
|
||||
/// <param name="code">Код приглашения.</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Ok=true + отозванное приглашение, либо код ошибки (текст — HTTP-слой).</returns>
|
||||
public async Task<InviteRevokeResultDto> RevokeAsync(string code, CancellationToken ct)
|
||||
{
|
||||
var invite = await inviteStore.GetByCodeAsync(code, ct);
|
||||
if (invite is null)
|
||||
{
|
||||
return new InviteRevokeResultDto(Ok: false, Error: InviteRevokeResultDto.ErrorNotFound, Invite: null);
|
||||
}
|
||||
|
||||
if (invite.Status != InviteStatuses.Pending)
|
||||
{
|
||||
return new InviteRevokeResultDto(Ok: false, Error: InviteRevokeResultDto.ErrorNotPending, Invite: invite);
|
||||
}
|
||||
|
||||
bool updated = await inviteStore.UpdateStatusAsync(code, InviteStatuses.Revoked, null, ct);
|
||||
if (!updated)
|
||||
{
|
||||
// Строка исчезла между чтением и обновлением — считаем «не найдено».
|
||||
return new InviteRevokeResultDto(Ok: false, Error: InviteRevokeResultDto.ErrorNotFound, Invite: null);
|
||||
}
|
||||
|
||||
return new InviteRevokeResultDto(Ok: true, Error: null, Invite: invite with { Status = InviteStatuses.Revoked });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Список приглашений для оператора (новые сверху) с ленивой пометкой истёкших (status → expired сохраняется).
|
||||
/// </summary>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Приглашения в порядке CreatedAt DESC; протухшие pending приходят со статусом expired.</returns>
|
||||
public async Task<IReadOnlyList<InviteDto>> ListAsync(CancellationToken ct)
|
||||
{
|
||||
IReadOnlyList<InviteDto> invites = await inviteStore.ListAsync(ct);
|
||||
var result = new List<InviteDto>(invites.Count);
|
||||
foreach (var invite in invites)
|
||||
{
|
||||
if (invite.Status == InviteStatuses.Pending && invite.ExpiresAt <= DateTimeOffset.UtcNow)
|
||||
{
|
||||
await inviteStore.UpdateStatusAsync(invite.Code, InviteStatuses.Expired, null, ct);
|
||||
result.Add(invite with { Status = InviteStatuses.Expired });
|
||||
}
|
||||
else
|
||||
{
|
||||
result.Add(invite);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Читает приглашение по коду, вычисляя статус expired при чтении (проверку кода использует /api/join, Task 6).
|
||||
/// </summary>
|
||||
/// <param name="code">Код приглашения.</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Приглашение (протухшее pending — со статусом expired и сохранённым переходом) или null.</returns>
|
||||
public async Task<InviteDto?> GetByCodeAsync(string code, CancellationToken ct)
|
||||
{
|
||||
var invite = await inviteStore.GetByCodeAsync(code, ct);
|
||||
if (invite is null || invite.Status != InviteStatuses.Pending || !invite.IsExpired())
|
||||
{
|
||||
return invite;
|
||||
}
|
||||
|
||||
await inviteStore.UpdateStatusAsync(code, InviteStatuses.Expired, null, ct);
|
||||
return invite with { Status = InviteStatuses.Expired };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Активирует приглашение CAS-переходом (Task 6): атомарный pending → activated, ActivatedAt = сейчас.
|
||||
/// Прокси над <see cref="IInviteStore.TryActivateAsync"/> — реальное условие на статус исполняет хранилище
|
||||
/// (условный UPDATE), сервис лишь фиксирует момент активации. Возвращает false, если к моменту обновления
|
||||
/// приглашение уже не pending (параллельно отозвано/активировано) — вызывающий (JoinService) перечитает статус.
|
||||
/// </summary>
|
||||
/// <param name="code">Код приглашения.</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>true, если активация выполнена; false — строка не в статусе pending.</returns>
|
||||
public Task<bool> TryActivateAsync(string code, CancellationToken ct) =>
|
||||
inviteStore.TryActivateAsync(code, DateTimeOffset.UtcNow, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Нормализация email: обрезка пробелов и нижний регистр (единая форма хранения/сравнения).
|
||||
/// </summary>
|
||||
/// <param name="email">Входной email (может быть null).</param>
|
||||
/// <returns>Нормализованный email (пустая строка, если вход был пустым).</returns>
|
||||
public static string NormalizeEmail(string? email) => (email ?? string.Empty).Trim().ToLowerInvariant();
|
||||
|
||||
/// <summary>
|
||||
/// Проверка формата email (санити-уровень): непустой, ≤ <see cref="MaxEmailLength"/>, один '@', домен с точкой, без пробелов.
|
||||
/// </summary>
|
||||
/// <param name="email">Входной email (регистр/пробелы не важны).</param>
|
||||
/// <returns>true, если email выглядит корректно.</returns>
|
||||
public static bool IsValidEmail(string? email)
|
||||
{
|
||||
string normalized = NormalizeEmail(email);
|
||||
return normalized.Length > 0
|
||||
&& normalized.Length <= MaxEmailLength
|
||||
&& EmailFormatRegex().IsMatch(normalized);
|
||||
}
|
||||
}
|
||||
using System.Text.RegularExpressions;
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Extensions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
namespace Deal.Modules.Tenants.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Прикладной сервис приглашений (Ruling 2 этапа 7): создание оператором, отзыв, список, чтение по коду.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Жизненный цикл статусов: pending → revoked | expired | activated. «expired» хранилище не проставляет само:
|
||||
/// он вычисляется лениво при чтении/проверке (<see cref="GetByCodeAsync"/>/<see cref="ListAsync"/>) и сохраняется,
|
||||
/// иначе частичный unique-индекс invites.Email по pending (InviteConfiguration, Task 1) заблокировал бы повторное
|
||||
/// приглашение на тот же email после истечения. Создание/отзыв — только оператор (вызывается из
|
||||
/// /api/operator/invites, Task 5); тексты HTTP-ошибок фиксирует слой эндпоинтов — сервис возвращает коды/null и
|
||||
/// не бросает исключений для бизнес-отказов (паттерн AuthService). Активацию (pending → activated) выполняет
|
||||
/// /api/join (Task 6): он читает приглашение через <see cref="GetByCodeAsync"/> (валидация статуса/expiry).
|
||||
/// </remarks>
|
||||
public sealed partial class InvitesService(IInviteStore inviteStore)
|
||||
{
|
||||
/// <summary>
|
||||
/// Срок действия приглашения, часов (Ruling 2: 72).
|
||||
/// </summary>
|
||||
public const int ExpiryHours = 72;
|
||||
|
||||
// Максимальная длина email — совпадает с шириной колонки invites.Email (InviteConfiguration, Task 1).
|
||||
private const int MaxEmailLength = 200;
|
||||
|
||||
// Проверяемый формат: один '@', непустые локальная часть и домен с точкой, без пробелов.
|
||||
private const string EmailFormatPattern = @"^[^@\s]+@[^@\s]+\.[^@\s]+$";
|
||||
|
||||
// Компилируется из EmailFormatPattern; вход ограничен 200 символами, паттерн линейный.
|
||||
[GeneratedRegex(EmailFormatPattern, RegexOptions.CultureInvariant)]
|
||||
private static partial Regex EmailFormatRegex();
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт приглашение оператором: нормализация/валидация email, антидубль активного, срок +72 часа.
|
||||
/// </summary>
|
||||
/// <param name="operatorId">Идентификатор оператора (CreatedById приглашения).</param>
|
||||
/// <param name="email">Email приглашённого (регистр/пробелы не важны — нормализуется).</param>
|
||||
/// <param name="tenantId">Целевой тенант; null — при активации будет создан новый тенант (Task 6).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Ok=true + созданное приглашение, либо код ошибки (текст — HTTP-слой).</returns>
|
||||
public async Task<InviteCreateResultDto> CreateInviteAsync(
|
||||
Guid operatorId,
|
||||
string? email,
|
||||
Guid? tenantId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
string normalizedEmail = NormalizeEmail(email);
|
||||
if (!IsValidEmail(normalizedEmail))
|
||||
{
|
||||
return new InviteCreateResultDto(Ok: false, Error: InviteCreateResultDto.ErrorInvalidEmail, Invite: null);
|
||||
}
|
||||
|
||||
// Антидубль «одно активное приглашение на email» (Ruling 2): pending блокирует новое. Если найденное
|
||||
// pending уже истекло (статус ещё не переведён), сначала помечаем expired — иначе partial unique-индекс
|
||||
// по pending не пустит новую строку; затем создаём новое приглашение. Отозванный/активированный/
|
||||
// истёкший email свободен (глобальную уникальность регистрации держит unique-индекс users.Login).
|
||||
var active = await inviteStore.FindActiveByEmailAsync(normalizedEmail, ct);
|
||||
if (active is not null)
|
||||
{
|
||||
if (active.IsExpired())
|
||||
{
|
||||
await inviteStore.UpdateStatusAsync(active.Code, InviteStatuses.Expired, null, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
return new InviteCreateResultDto(Ok: false, Error: InviteCreateResultDto.ErrorDuplicateActive, Invite: null);
|
||||
}
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var invite = new InviteDto(
|
||||
Code: InviteCodeGenerator.NewCode(),
|
||||
Email: normalizedEmail,
|
||||
TenantId: tenantId,
|
||||
Status: InviteStatuses.Pending,
|
||||
ExpiresAt: now.AddHours(ExpiryHours),
|
||||
ActivatedAt: null,
|
||||
CreatedById: operatorId,
|
||||
CreatedAt: now);
|
||||
await inviteStore.CreateAsync(invite, ct);
|
||||
return new InviteCreateResultDto(Ok: true, Error: null, Invite: invite);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Отзывает приглашение: только pending переводится в revoked; иные статусы не трогаем (Ruling 2).
|
||||
/// </summary>
|
||||
/// <param name="code">Код приглашения.</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Ok=true + отозванное приглашение, либо код ошибки (текст — HTTP-слой).</returns>
|
||||
public async Task<InviteRevokeResultDto> RevokeAsync(string code, CancellationToken ct)
|
||||
{
|
||||
var invite = await inviteStore.GetByCodeAsync(code, ct);
|
||||
if (invite is null)
|
||||
{
|
||||
return new InviteRevokeResultDto(Ok: false, Error: InviteRevokeResultDto.ErrorNotFound, Invite: null);
|
||||
}
|
||||
|
||||
if (invite.Status != InviteStatuses.Pending)
|
||||
{
|
||||
return new InviteRevokeResultDto(Ok: false, Error: InviteRevokeResultDto.ErrorNotPending, Invite: invite);
|
||||
}
|
||||
|
||||
bool updated = await inviteStore.UpdateStatusAsync(code, InviteStatuses.Revoked, null, ct);
|
||||
if (!updated)
|
||||
{
|
||||
// Строка исчезла между чтением и обновлением — считаем «не найдено».
|
||||
return new InviteRevokeResultDto(Ok: false, Error: InviteRevokeResultDto.ErrorNotFound, Invite: null);
|
||||
}
|
||||
|
||||
return new InviteRevokeResultDto(Ok: true, Error: null, Invite: invite with { Status = InviteStatuses.Revoked });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Список приглашений для оператора (новые сверху) с ленивой пометкой истёкших (status → expired сохраняется).
|
||||
/// </summary>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Приглашения в порядке CreatedAt DESC; протухшие pending приходят со статусом expired.</returns>
|
||||
public async Task<IReadOnlyList<InviteDto>> ListAsync(CancellationToken ct)
|
||||
{
|
||||
IReadOnlyList<InviteDto> invites = await inviteStore.ListAsync(ct);
|
||||
var result = new List<InviteDto>(invites.Count);
|
||||
foreach (var invite in invites)
|
||||
{
|
||||
if (invite.Status == InviteStatuses.Pending && invite.ExpiresAt <= DateTimeOffset.UtcNow)
|
||||
{
|
||||
await inviteStore.UpdateStatusAsync(invite.Code, InviteStatuses.Expired, null, ct);
|
||||
result.Add(invite with { Status = InviteStatuses.Expired });
|
||||
}
|
||||
else
|
||||
{
|
||||
result.Add(invite);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Читает приглашение по коду, вычисляя статус expired при чтении (проверку кода использует /api/join, Task 6).
|
||||
/// </summary>
|
||||
/// <param name="code">Код приглашения.</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Приглашение (протухшее pending — со статусом expired и сохранённым переходом) или null.</returns>
|
||||
public async Task<InviteDto?> GetByCodeAsync(string code, CancellationToken ct)
|
||||
{
|
||||
var invite = await inviteStore.GetByCodeAsync(code, ct);
|
||||
if (invite is null || invite.Status != InviteStatuses.Pending || !invite.IsExpired())
|
||||
{
|
||||
return invite;
|
||||
}
|
||||
|
||||
await inviteStore.UpdateStatusAsync(code, InviteStatuses.Expired, null, ct);
|
||||
return invite with { Status = InviteStatuses.Expired };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Активирует приглашение CAS-переходом (Task 6): атомарный pending → activated, ActivatedAt = сейчас.
|
||||
/// Прокси над <see cref="IInviteStore.TryActivateAsync"/> — реальное условие на статус исполняет хранилище
|
||||
/// (условный UPDATE), сервис лишь фиксирует момент активации. Возвращает false, если к моменту обновления
|
||||
/// приглашение уже не pending (параллельно отозвано/активировано) — вызывающий (JoinService) перечитает статус.
|
||||
/// </summary>
|
||||
/// <param name="code">Код приглашения.</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>true, если активация выполнена; false — строка не в статусе pending.</returns>
|
||||
public Task<bool> TryActivateAsync(string code, CancellationToken ct) =>
|
||||
inviteStore.TryActivateAsync(code, DateTimeOffset.UtcNow, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Нормализация email: обрезка пробелов и нижний регистр (единая форма хранения/сравнения).
|
||||
/// </summary>
|
||||
/// <param name="email">Входной email (может быть null).</param>
|
||||
/// <returns>Нормализованный email (пустая строка, если вход был пустым).</returns>
|
||||
public static string NormalizeEmail(string? email) => (email ?? string.Empty).Trim().ToLowerInvariant();
|
||||
|
||||
/// <summary>
|
||||
/// Проверка формата email (санити-уровень): непустой, ≤ <see cref="MaxEmailLength"/>, один '@', домен с точкой, без пробелов.
|
||||
/// </summary>
|
||||
/// <param name="email">Входной email (регистр/пробелы не важны).</param>
|
||||
/// <returns>true, если email выглядит корректно.</returns>
|
||||
public static bool IsValidEmail(string? email)
|
||||
{
|
||||
string normalized = NormalizeEmail(email);
|
||||
return normalized.Length > 0
|
||||
&& normalized.Length <= MaxEmailLength
|
||||
&& EmailFormatRegex().IsMatch(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,153 +1,151 @@
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Extensions;
|
||||
using Deal.Modules.Tenants.Application.Registrars;
|
||||
|
||||
namespace Deal.Modules.Tenants.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Прикладной сервис активации инвайта через публичную ручку POST /api/join (Ruling 2, Task 6 этапа 7):
|
||||
/// код + email + пароль → пользователь (login=email), при пустом TenantId — новый тенант с провижинингом схемы,
|
||||
/// инвайт переводится в activated. Кука НЕ ставится — после активации клиент входит обычным /api/auth/login.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Порядок операций и CAS-семантика (замечание ревью T5): валидации (код/email/пароль/дубль email) выполняются
|
||||
/// до записи; затем инвайт резервируется атомарным переходом pending → activated (<see cref="InvitesService.TryActivateAsync"/>
|
||||
/// → условный UPDATE в хранилище). Резервирование первым гарантирует, что из двух параллельных активаций одного кода
|
||||
/// (или активации против параллельного отзыва) победит ровно одна, а проигравший не создаст «лишних» пользователя/
|
||||
/// тенанта. Если CAS не прошёл — статус перечитывается, и возвращается фактическая причина (already used / revoked).
|
||||
/// После резервирования создаётся тенант (если TenantId инвайта пуст) и пользователь; сбой на этом шаге — серверная
|
||||
/// ошибка (исключение наружу), инвайт остаётся активированным и оператор видит аномалию в списке/аудите. Сервис не
|
||||
/// бросает исключений для бизнес-отказов — коды ошибок, тексты на HTTP-слое (паттерн AuthService/InvitesService).
|
||||
/// Глобальную уникальность email держит unique-индекс users.login; предпроверка здесь закрывает типичный случай
|
||||
/// «уже зарегистрирован» без побочных эффектов (инвайт остаётся pending для повторного использования).
|
||||
/// </remarks>
|
||||
public sealed class JoinService(
|
||||
InvitesService invitesService,
|
||||
TenantService tenantService,
|
||||
IAuthStore authStore,
|
||||
IPasswordHasher passwordHasher,
|
||||
ITenantRepository tenantRepository)
|
||||
{
|
||||
// Статус создаваемого пользователя (как у seed-пользователя TenantBootstrapService).
|
||||
private const string UserActiveStatus = "active";
|
||||
|
||||
/// <summary>
|
||||
/// Активирует инвайт: валидация, CAS-резервирование, создание тенанта (при необходимости) и пользователя.
|
||||
/// </summary>
|
||||
/// <param name="code">Код приглашения (пробелы по краям не важны).</param>
|
||||
/// <param name="email">Email активирующего; обязан совпасть с email приглашения (регистр/пробелы не важны).</param>
|
||||
/// <param name="name">Имя тенанта при создании нового (TenantId инвайта пуст); null/пустое — имя = email.</param>
|
||||
/// <param name="password">Пароль пользователя (открытым текстом; минимум <see cref="AuthService.MinNewPasswordLength"/>).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>При успехе — Ok=true, Login (нормализованный email), UserId и TenantId; иначе код ошибки (см. <see cref="JoinResultDto"/>).</returns>
|
||||
public async Task<JoinResultDto> ActivateAsync(
|
||||
string? code,
|
||||
string? email,
|
||||
string? name,
|
||||
string? password,
|
||||
CancellationToken ct)
|
||||
{
|
||||
string normalizedEmail = InvitesService.NormalizeEmail(email);
|
||||
string normalizedCode = code?.Trim() ?? string.Empty;
|
||||
|
||||
// 1. Чтение приглашения: GetByCodeAsync сам помечает протухшее pending как expired (ленивый переход, Task 5),
|
||||
// поэтому вернувшийся pending гарантированно не истёк; отдельная проверка кода не нужна — отвечает статус.
|
||||
var invite = await invitesService.GetByCodeAsync(normalizedCode, ct);
|
||||
if (invite is null)
|
||||
{
|
||||
return Failed(JoinResultDto.ErrorNotFound);
|
||||
}
|
||||
|
||||
if (invite.Status != InviteStatuses.Pending)
|
||||
{
|
||||
return Failed(ErrorFromStatus(invite.Status));
|
||||
}
|
||||
|
||||
// 2. Email обязан совпасть с приглашением (Ruling 2); пустой/иной email не проходит — инвайт не расходуется.
|
||||
if (normalizedEmail != invite.Email)
|
||||
{
|
||||
return Failed(JoinResultDto.ErrorEmailMismatch);
|
||||
}
|
||||
|
||||
// 3. Пароль: минимум AuthService.MinNewPasswordLength (единый источник), текст на HTTP-слое.
|
||||
if (password is null || password.Length < AuthService.MinNewPasswordLength)
|
||||
{
|
||||
return Failed(JoinResultDto.ErrorPasswordTooShort);
|
||||
}
|
||||
|
||||
// 4. Глобальная уникальность email (users.login unique, Ruling 2): предпроверка до записи, чтобы занятый
|
||||
// email не создавал тенанта и не расходовал инвайт (остаётся pending — оператор видит/отзывает его).
|
||||
var existingUser = await authStore.FindUserByLoginAsync(normalizedEmail, ct);
|
||||
if (existingUser is not null)
|
||||
{
|
||||
return Failed(JoinResultDto.ErrorEmailTaken);
|
||||
}
|
||||
|
||||
// 5. Целевой тенант (инвайт с TenantId): обязан существовать и быть активным ДО резервирования кода —
|
||||
// «битый» инвайт (тенант удалён/приостановлен) не должен ни расходовать код, ни создавать пользователя
|
||||
// на несуществующем/замороженном тенанте (Security review).
|
||||
if (invite.TenantId is not null)
|
||||
{
|
||||
var target = await tenantRepository.FindByIdAsync(invite.TenantId.Value, ct);
|
||||
if (target is null)
|
||||
{
|
||||
return Failed(JoinResultDto.ErrorTenantNotFound);
|
||||
}
|
||||
|
||||
if (target.Status == TenantStatuses.Suspended)
|
||||
{
|
||||
return Failed(JoinResultDto.ErrorTenantSuspended);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. CAS-резервирование инвайта (pending → activated). Если параллельно инвайт отозван/активирован —
|
||||
// переход не выполнен: перечитываем фактический статус и отвечаем соответствующей ошибкой без побочных
|
||||
// эффектов (ни тенант, ни пользователь не создаются — создаёт только победитель гонки).
|
||||
if (!await invitesService.TryActivateAsync(normalizedCode, ct))
|
||||
{
|
||||
var current = await invitesService.GetByCodeAsync(normalizedCode, ct);
|
||||
return current is null
|
||||
? Failed(JoinResultDto.ErrorNotFound)
|
||||
: Failed(ErrorFromStatus(current.Status));
|
||||
}
|
||||
|
||||
// 7. Тенант: TenantId инвайта задан — присоединение к существующему (провижининг не нужен — схема уже есть);
|
||||
// пуст — создание нового с явным id (TenantService сам провижинит схему) и именем name ?? email.
|
||||
Guid tenantId = invite.TenantId ?? Guid.NewGuid();
|
||||
if (invite.TenantId is null)
|
||||
{
|
||||
string tenantName = string.IsNullOrWhiteSpace(name) ? normalizedEmail : name.Trim();
|
||||
await tenantService.CreateTenantAsync(tenantName, tenantId, ct);
|
||||
}
|
||||
|
||||
// 8. Пользователь в public.users (login=email, Argon2id-хэш пароля).
|
||||
var userId = Guid.NewGuid();
|
||||
await authStore.CreateUserAsync(
|
||||
new StoredUserDto(
|
||||
Id: userId,
|
||||
Login: normalizedEmail,
|
||||
TenantId: tenantId,
|
||||
Status: UserActiveStatus,
|
||||
PasswordHash: passwordHasher.Hash(password)),
|
||||
ct);
|
||||
|
||||
return new JoinResultDto(Ok: true, Error: null, Login: normalizedEmail, UserId: userId, TenantId: tenantId);
|
||||
}
|
||||
|
||||
// Код ошибки по фактическому статусу приглашения (используется при отказе CAS и при не-pending чтении).
|
||||
// status: Статус из InviteStatuses.
|
||||
// Возвращает: Код JoinResultDto: activated → used, revoked → revoked, иначе expired (для не-pending чтения).
|
||||
private static string ErrorFromStatus(string status) =>
|
||||
status switch
|
||||
{
|
||||
InviteStatuses.Activated => JoinResultDto.ErrorUsed,
|
||||
InviteStatuses.Revoked => JoinResultDto.ErrorRevoked,
|
||||
_ => JoinResultDto.ErrorExpired,
|
||||
};
|
||||
|
||||
// Неуспешный результат без полезной нагрузки.
|
||||
// error: Код ошибки JoinResultDto.
|
||||
// Возвращает: JoinResultDto с Ok=false.
|
||||
private static JoinResultDto Failed(string error) => new(Ok: false, Error: error, Login: null, UserId: null, TenantId: null);
|
||||
}
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
namespace Deal.Modules.Tenants.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Прикладной сервис активации инвайта через публичную ручку POST /api/join (Ruling 2, Task 6 этапа 7):
|
||||
/// код + email + пароль → пользователь (login=email), при пустом TenantId — новый тенант с провижинингом схемы,
|
||||
/// инвайт переводится в activated. Кука НЕ ставится — после активации клиент входит обычным /api/auth/login.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Порядок операций и CAS-семантика (замечание ревью T5): валидации (код/email/пароль/дубль email) выполняются
|
||||
/// до записи; затем инвайт резервируется атомарным переходом pending → activated (<see cref="InvitesService.TryActivateAsync"/>
|
||||
/// → условный UPDATE в хранилище). Резервирование первым гарантирует, что из двух параллельных активаций одного кода
|
||||
/// (или активации против параллельного отзыва) победит ровно одна, а проигравший не создаст «лишних» пользователя/
|
||||
/// тенанта. Если CAS не прошёл — статус перечитывается, и возвращается фактическая причина (already used / revoked).
|
||||
/// После резервирования создаётся тенант (если TenantId инвайта пуст) и пользователь; сбой на этом шаге — серверная
|
||||
/// ошибка (исключение наружу), инвайт остаётся активированным и оператор видит аномалию в списке/аудите. Сервис не
|
||||
/// бросает исключений для бизнес-отказов — коды ошибок, тексты на HTTP-слое (паттерн AuthService/InvitesService).
|
||||
/// Глобальную уникальность email держит unique-индекс users.login; предпроверка здесь закрывает типичный случай
|
||||
/// «уже зарегистрирован» без побочных эффектов (инвайт остаётся pending для повторного использования).
|
||||
/// </remarks>
|
||||
public sealed class JoinService(
|
||||
InvitesService invitesService,
|
||||
TenantService tenantService,
|
||||
IAuthStore authStore,
|
||||
IPasswordHasher passwordHasher,
|
||||
ITenantRepository tenantRepository)
|
||||
{
|
||||
// Статус создаваемого пользователя (как у seed-пользователя TenantBootstrapService).
|
||||
private const string UserActiveStatus = "active";
|
||||
|
||||
/// <summary>
|
||||
/// Активирует инвайт: валидация, CAS-резервирование, создание тенанта (при необходимости) и пользователя.
|
||||
/// </summary>
|
||||
/// <param name="code">Код приглашения (пробелы по краям не важны).</param>
|
||||
/// <param name="email">Email активирующего; обязан совпасть с email приглашения (регистр/пробелы не важны).</param>
|
||||
/// <param name="name">Имя тенанта при создании нового (TenantId инвайта пуст); null/пустое — имя = email.</param>
|
||||
/// <param name="password">Пароль пользователя (открытым текстом; минимум <see cref="AuthService.MinNewPasswordLength"/>).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>При успехе — Ok=true, Login (нормализованный email), UserId и TenantId; иначе код ошибки (см. <see cref="JoinResultDto"/>).</returns>
|
||||
public async Task<JoinResultDto> ActivateAsync(
|
||||
string? code,
|
||||
string? email,
|
||||
string? name,
|
||||
string? password,
|
||||
CancellationToken ct)
|
||||
{
|
||||
string normalizedEmail = InvitesService.NormalizeEmail(email);
|
||||
string normalizedCode = code?.Trim() ?? string.Empty;
|
||||
|
||||
// 1. Чтение приглашения: GetByCodeAsync сам помечает протухшее pending как expired (ленивый переход, Task 5),
|
||||
// поэтому вернувшийся pending гарантированно не истёк; отдельная проверка кода не нужна — отвечает статус.
|
||||
var invite = await invitesService.GetByCodeAsync(normalizedCode, ct);
|
||||
if (invite is null)
|
||||
{
|
||||
return Failed(JoinResultDto.ErrorNotFound);
|
||||
}
|
||||
|
||||
if (invite.Status != InviteStatuses.Pending)
|
||||
{
|
||||
return Failed(ErrorFromStatus(invite.Status));
|
||||
}
|
||||
|
||||
// 2. Email обязан совпасть с приглашением (Ruling 2); пустой/иной email не проходит — инвайт не расходуется.
|
||||
if (normalizedEmail != invite.Email)
|
||||
{
|
||||
return Failed(JoinResultDto.ErrorEmailMismatch);
|
||||
}
|
||||
|
||||
// 3. Пароль: минимум AuthService.MinNewPasswordLength (единый источник), текст на HTTP-слое.
|
||||
if (password is null || password.Length < AuthService.MinNewPasswordLength)
|
||||
{
|
||||
return Failed(JoinResultDto.ErrorPasswordTooShort);
|
||||
}
|
||||
|
||||
// 4. Глобальная уникальность email (users.login unique, Ruling 2): предпроверка до записи, чтобы занятый
|
||||
// email не создавал тенанта и не расходовал инвайт (остаётся pending — оператор видит/отзывает его).
|
||||
var existingUser = await authStore.FindUserByLoginAsync(normalizedEmail, ct);
|
||||
if (existingUser is not null)
|
||||
{
|
||||
return Failed(JoinResultDto.ErrorEmailTaken);
|
||||
}
|
||||
|
||||
// 5. Целевой тенант (инвайт с TenantId): обязан существовать и быть активным ДО резервирования кода —
|
||||
// «битый» инвайт (тенант удалён/приостановлен) не должен ни расходовать код, ни создавать пользователя
|
||||
// на несуществующем/замороженном тенанте (Security review).
|
||||
if (invite.TenantId is not null)
|
||||
{
|
||||
var target = await tenantRepository.FindByIdAsync(invite.TenantId.Value, ct);
|
||||
if (target is null)
|
||||
{
|
||||
return Failed(JoinResultDto.ErrorTenantNotFound);
|
||||
}
|
||||
|
||||
if (target.Status == TenantStatuses.Suspended)
|
||||
{
|
||||
return Failed(JoinResultDto.ErrorTenantSuspended);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. CAS-резервирование инвайта (pending → activated). Если параллельно инвайт отозван/активирован —
|
||||
// переход не выполнен: перечитываем фактический статус и отвечаем соответствующей ошибкой без побочных
|
||||
// эффектов (ни тенант, ни пользователь не создаются — создаёт только победитель гонки).
|
||||
if (!await invitesService.TryActivateAsync(normalizedCode, ct))
|
||||
{
|
||||
var current = await invitesService.GetByCodeAsync(normalizedCode, ct);
|
||||
return current is null
|
||||
? Failed(JoinResultDto.ErrorNotFound)
|
||||
: Failed(ErrorFromStatus(current.Status));
|
||||
}
|
||||
|
||||
// 7. Тенант: TenantId инвайта задан — присоединение к существующему (провижининг не нужен — схема уже есть);
|
||||
// пуст — создание нового с явным id (TenantService сам провижинит схему) и именем name ?? email.
|
||||
Guid tenantId = invite.TenantId ?? Guid.NewGuid();
|
||||
if (invite.TenantId is null)
|
||||
{
|
||||
string tenantName = string.IsNullOrWhiteSpace(name) ? normalizedEmail : name.Trim();
|
||||
await tenantService.CreateTenantAsync(tenantName, tenantId, ct);
|
||||
}
|
||||
|
||||
// 8. Пользователь в public.users (login=email, Argon2id-хэш пароля).
|
||||
var userId = Guid.NewGuid();
|
||||
await authStore.CreateUserAsync(
|
||||
new StoredUserDto(
|
||||
Id: userId,
|
||||
Login: normalizedEmail,
|
||||
TenantId: tenantId,
|
||||
Status: UserActiveStatus,
|
||||
PasswordHash: passwordHasher.Hash(password)),
|
||||
ct);
|
||||
|
||||
return new JoinResultDto(Ok: true, Error: null, Login: normalizedEmail, UserId: userId, TenantId: tenantId);
|
||||
}
|
||||
|
||||
// Код ошибки по фактическому статусу приглашения (используется при отказе CAS и при не-pending чтении).
|
||||
// status: Статус из InviteStatuses.
|
||||
// Возвращает: Код JoinResultDto: activated → used, revoked → revoked, иначе expired (для не-pending чтения).
|
||||
private static string ErrorFromStatus(string status) =>
|
||||
status switch
|
||||
{
|
||||
InviteStatuses.Activated => JoinResultDto.ErrorUsed,
|
||||
InviteStatuses.Revoked => JoinResultDto.ErrorRevoked,
|
||||
_ => JoinResultDto.ErrorExpired,
|
||||
};
|
||||
|
||||
// Неуспешный результат без полезной нагрузки.
|
||||
// error: Код ошибки JoinResultDto.
|
||||
// Возвращает: JoinResultDto с Ok=false.
|
||||
private static JoinResultDto Failed(string error) => new(Ok: false, Error: error, Login: null, UserId: null, TenantId: null);
|
||||
}
|
||||
|
||||
@@ -1,129 +1,127 @@
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Extensions;
|
||||
using Deal.Modules.Tenants.Application.Registrars;
|
||||
|
||||
namespace Deal.Modules.Tenants.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Прикладной сервис аутентификации оператора (Ruling 1 этапа 7): login, logout, разрешение сессии.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Оператор ≠ пользователь тенанта: учётные записи и сессии живут в отдельных public-таблицах
|
||||
/// (Operators/OperatorSessions) и за отдельным портом <see cref="IOperatorAuthStore"/>, а кука
|
||||
/// deal_operator_session (Task 3) не совпадает с тенантной deal_session — взаимной подмены сессий нет.
|
||||
/// Срок жизни операторской сессии — 12 часов. Семантика повторяет <see cref="AuthService"/>: бизнес-отказы
|
||||
/// возвращаются кодами/null, тексты сообщений фиксирует HTTP-слой (Task 3).
|
||||
/// </remarks>
|
||||
public sealed class OperatorAuthService(IOperatorAuthStore operatorAuthStore, IPasswordHasher passwordHasher)
|
||||
{
|
||||
/// <summary>
|
||||
/// Срок жизни сессии оператора, часов (Ruling 1: 12). Единый источник «12» — на него ссылается кука (Task 3).
|
||||
/// </summary>
|
||||
public const int SessionLifetimeHours = 12;
|
||||
|
||||
// Статус «активен»: только активного оператора разрешает ResolveSessionAsync (удалённый/приостановленный — null).
|
||||
private const string ActiveStatus = "active";
|
||||
|
||||
/// <summary>
|
||||
/// Вход оператора: при успехе создаёт сессию и возвращает её raw-токен.
|
||||
/// </summary>
|
||||
/// <param name="login">Логин (регистр и пробелы не важны — нормализуется).</param>
|
||||
/// <param name="password">Пароль в открытом виде.</param>
|
||||
/// <param name="ct">Токен отмены.</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>
|
||||
/// Выход оператора: удаляет сессию по raw-токену, если он передан.
|
||||
/// </summary>
|
||||
/// <param name="rawToken">Raw-токен из куки (может отсутствовать — no-op).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
public async Task LogoutAsync(string? rawToken, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rawToken))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await operatorAuthStore.DeleteSessionAsync(SessionTokens.HashToken(rawToken), ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Разрешение операторской сессии по raw-токену: возвращает оператора или null (нет/протухла/оператор не активен).
|
||||
/// </summary>
|
||||
/// <param name="rawToken">Raw-токен из куки deal_operator_session.</param>
|
||||
/// <param name="ct">Токен отмены.</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)
|
||||
{
|
||||
// Оператора ищем по денормализованному в сессию логину: он уникален и не меняется.
|
||||
// Сессия разрешается только для активного оператора (решение ревью Task 2): удалённый
|
||||
// или приостановленный оператор при живой сессии получает null — 401 на HTTP-слое.
|
||||
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>
|
||||
/// Прикладной сервис аутентификации оператора (Ruling 1 этапа 7): login, logout, разрешение сессии.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Оператор ≠ пользователь тенанта: учётные записи и сессии живут в отдельных public-таблицах
|
||||
/// (Operators/OperatorSessions) и за отдельным портом <see cref="IOperatorAuthStore"/>, а кука
|
||||
/// deal_operator_session (Task 3) не совпадает с тенантной deal_session — взаимной подмены сессий нет.
|
||||
/// Срок жизни операторской сессии — 12 часов. Семантика повторяет <see cref="AuthService"/>: бизнес-отказы
|
||||
/// возвращаются кодами/null, тексты сообщений фиксирует HTTP-слой (Task 3).
|
||||
/// </remarks>
|
||||
public sealed class OperatorAuthService(IOperatorAuthStore operatorAuthStore, IPasswordHasher passwordHasher)
|
||||
{
|
||||
/// <summary>
|
||||
/// Срок жизни сессии оператора, часов (Ruling 1: 12). Единый источник «12» — на него ссылается кука (Task 3).
|
||||
/// </summary>
|
||||
public const int SessionLifetimeHours = 12;
|
||||
|
||||
// Статус «активен»: только активного оператора разрешает ResolveSessionAsync (удалённый/приостановленный — null).
|
||||
private const string ActiveStatus = "active";
|
||||
|
||||
/// <summary>
|
||||
/// Вход оператора: при успехе создаёт сессию и возвращает её raw-токен.
|
||||
/// </summary>
|
||||
/// <param name="login">Логин (регистр и пробелы не важны — нормализуется).</param>
|
||||
/// <param name="password">Пароль в открытом виде.</param>
|
||||
/// <param name="ct">Токен отмены.</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>
|
||||
/// Выход оператора: удаляет сессию по raw-токену, если он передан.
|
||||
/// </summary>
|
||||
/// <param name="rawToken">Raw-токен из куки (может отсутствовать — no-op).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
public async Task LogoutAsync(string? rawToken, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rawToken))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await operatorAuthStore.DeleteSessionAsync(SessionTokens.HashToken(rawToken), ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Разрешение операторской сессии по raw-токену: возвращает оператора или null (нет/протухла/оператор не активен).
|
||||
/// </summary>
|
||||
/// <param name="rawToken">Raw-токен из куки deal_operator_session.</param>
|
||||
/// <param name="ct">Токен отмены.</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)
|
||||
{
|
||||
// Оператора ищем по денормализованному в сессию логину: он уникален и не меняется.
|
||||
// Сессия разрешается только для активного оператора (решение ревью Task 2): удалённый
|
||||
// или приостановленный оператор при живой сессии получает null — 401 на HTTP-слое.
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -1,93 +1,91 @@
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Extensions;
|
||||
using Deal.Modules.Tenants.Application.Registrars;
|
||||
|
||||
namespace Deal.Modules.Tenants.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Bootstrap оператора при старте (Ruling 1 этапа 7): идемпотентный seed из env DEAL_OPERATOR_*.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Шаг вызывается хостом при старте (встраивается в TenantBootstrapService или идёт отдельным
|
||||
/// hosted-шагом после него — подключение в Task 3 вместе с EF-адаптером <see cref="IOperatorAuthStore"/>).
|
||||
/// В Development при отсутствии кред используется дефолт operator/operator (зеркало dev-seed admin/admin);
|
||||
/// в Production без env-кред шаг пропускается — оператора заводит админ позже через env и рестарт,
|
||||
/// кода регистрации оператора нет. Секреты не логируются и не возвращаются.
|
||||
/// </remarks>
|
||||
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-кред (Ruling 1).
|
||||
/// </summary>
|
||||
public const string DefaultOperatorLogin = "operator";
|
||||
|
||||
/// <summary>
|
||||
/// Дефолтный пароль в Development при отсутствии env-кред (Ruling 1).
|
||||
/// </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>
|
||||
/// <param name="ct">Токен отмены.</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 оператора при старте (Ruling 1 этапа 7): идемпотентный seed из env DEAL_OPERATOR_*.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Шаг вызывается хостом при старте (встраивается в TenantBootstrapService или идёт отдельным
|
||||
/// hosted-шагом после него — подключение в Task 3 вместе с EF-адаптером <see cref="IOperatorAuthStore"/>).
|
||||
/// В Development при отсутствии кред используется дефолт operator/operator (зеркало dev-seed admin/admin);
|
||||
/// в Production без env-кред шаг пропускается — оператора заводит админ позже через env и рестарт,
|
||||
/// кода регистрации оператора нет. Секреты не логируются и не возвращаются.
|
||||
/// </remarks>
|
||||
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-кред (Ruling 1).
|
||||
/// </summary>
|
||||
public const string DefaultOperatorLogin = "operator";
|
||||
|
||||
/// <summary>
|
||||
/// Дефолтный пароль в Development при отсутствии env-кред (Ruling 1).
|
||||
/// </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>
|
||||
/// <param name="ct">Токен отмены.</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();
|
||||
}
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Deal.SharedKernel;
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Extensions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.Modules.Tenants.Application.Registrars;
|
||||
|
||||
namespace Deal.Modules.Tenants.Application.Services;
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
using System.Text.Json;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Extensions;
|
||||
using Deal.Modules.Tenants.Application.Registrars;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
namespace Deal.Modules.Tenants.Application.Services;
|
||||
|
||||
|
||||
@@ -1,172 +1,170 @@
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.SharedKernel;
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Extensions;
|
||||
using Deal.Modules.Tenants.Application.Registrars;
|
||||
|
||||
namespace Deal.Modules.Tenants.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Прикладной сервис операторского реестра тенантов (GET /api/operator/tenants[/{id}], POST (создание),
|
||||
/// suspend/unsuspend — план Task 7): чтение реестра + пользователей, создание тенанта (с провижинингом схемы) и
|
||||
/// смена статуса приостановки.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Аудит (tenant_created/tenant_status_changed) пишет HTTP-слой (паттерн Task 4/5), сервис возвращает результат
|
||||
/// с кодами ошибок; impersonation живёт в <see cref="AuthService.ImpersonateAsync"/> (создание tenant-сессии
|
||||
/// пользователя). Создание с email (план Task 7: «email-опция создаёт сразу пользователя-владельца») заводит
|
||||
/// пользователя с автоматическим одноразовым паролем — владелец получает его от оператора и меняет после первого
|
||||
/// входа (прямой ввод пароля оператором в контракте не предусмотрен; см. <see cref="TenantCreateResultDto"/>).
|
||||
/// Счётчики пользователей считаются по списку пользователей тенанта (порт <see cref="IAuthStore"/>); на масштабах
|
||||
/// операторской админки N+1 осознан (сводка usage/лимитов появится в Task 8–10).
|
||||
/// </remarks>
|
||||
public sealed class TenantAdminService(
|
||||
ITenantRepository tenantRepository,
|
||||
IAuthStore authStore,
|
||||
TenantService tenantService,
|
||||
IPasswordHasher passwordHasher)
|
||||
{
|
||||
// Случайные байты одноразового пароля владельца: 12 → ровно 16 символов Base64Url (как InviteCodeGenerator).
|
||||
private const int InitialPasswordRandomByteCount = 12;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт тенанта оператором (план Task 7): строка реестра (Status active) + провижининг схемы
|
||||
/// (<see cref="TenantService.CreateTenantAsync"/>); при email — сразу пользователь-владелец с одноразовым паролем.
|
||||
/// </summary>
|
||||
/// <param name="name">Имя тенанта (обязательно; обрезается).</param>
|
||||
/// <param name="email">Email владельца (опционально): создаёт пользователя-владельца сразу, иначе владелец
|
||||
/// заводится инвайтом (Ruling 2).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>
|
||||
/// Ok=true — тенант создан (Tenant); при email дополнительно OwnerUserId/OwnerLogin/InitialPassword.
|
||||
/// Ok=false — код ошибки (имя пусто, email невалиден/уже зарегистрирован); текст — HTTP-слой.
|
||||
/// </returns>
|
||||
public async Task<TenantCreateResultDto> CreateAsync(
|
||||
string? name,
|
||||
string? email,
|
||||
CancellationToken ct)
|
||||
{
|
||||
string tenantName = name?.Trim() ?? string.Empty;
|
||||
if (string.IsNullOrEmpty(tenantName))
|
||||
{
|
||||
return TenantCreateResultDto.Failed(TenantCreateResultDto.ErrorNameRequired);
|
||||
}
|
||||
|
||||
string? normalizedEmail = null;
|
||||
if (!string.IsNullOrWhiteSpace(email))
|
||||
{
|
||||
normalizedEmail = InvitesService.NormalizeEmail(email);
|
||||
if (!InvitesService.IsValidEmail(email))
|
||||
{
|
||||
return TenantCreateResultDto.Failed(TenantCreateResultDto.ErrorInvalidEmail);
|
||||
}
|
||||
|
||||
// Глобальная уникальность email (users.login unique, Ruling 2): предпроверка до записи, чтобы занятый
|
||||
// email не создавал тенанта без владельца (как JoinService, Task 6). Гонка закрыта unique-индексом.
|
||||
var existingUser = await authStore.FindUserByLoginAsync(normalizedEmail, ct);
|
||||
if (existingUser is not null)
|
||||
{
|
||||
return TenantCreateResultDto.Failed(TenantCreateResultDto.ErrorEmailTaken);
|
||||
}
|
||||
}
|
||||
|
||||
// Создание тенанта — единая операция: строка public.tenants (Status active) + провижининг схемы.
|
||||
var tenantId = Guid.NewGuid();
|
||||
await tenantService.CreateTenantAsync(tenantName, tenantId, ct);
|
||||
var tenant = new TenantRecordDto(tenantId, tenantName, TenantStatuses.Active, DateTimeOffset.UtcNow);
|
||||
|
||||
if (normalizedEmail is null)
|
||||
{
|
||||
return new TenantCreateResultDto(Ok: true, Error: null, Tenant: tenant);
|
||||
}
|
||||
|
||||
// Пользователь-владелец с одноразовым паролем: raw-пароль возвращается один раз (наружу), в хранилище —
|
||||
// только хэш; в аудит/логи не пишется.
|
||||
string initialPassword = GenerateInitialPassword();
|
||||
var ownerUserId = Guid.NewGuid();
|
||||
await authStore.CreateUserAsync(
|
||||
new StoredUserDto(
|
||||
Id: ownerUserId,
|
||||
Login: normalizedEmail,
|
||||
TenantId: tenantId,
|
||||
Status: "active",
|
||||
PasswordHash: passwordHasher.Hash(initialPassword)),
|
||||
ct);
|
||||
|
||||
return new TenantCreateResultDto(
|
||||
Ok: true,
|
||||
Error: null,
|
||||
Tenant: tenant,
|
||||
OwnerUserId: ownerUserId,
|
||||
OwnerLogin: normalizedEmail,
|
||||
InitialPassword: initialPassword);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Список тенантов со счётчиками пользователей (реестр + счётчики, план Task 7).
|
||||
/// </summary>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Тенанты в порядке создания (реестр) с числом пользователей каждого.</returns>
|
||||
public async Task<IReadOnlyList<TenantListItemDto>> ListAsync(CancellationToken ct)
|
||||
{
|
||||
var tenants = await tenantRepository.ListAsync(ct);
|
||||
var items = new List<TenantListItemDto>(tenants.Count);
|
||||
foreach (TenantRecordDto tenant in tenants)
|
||||
{
|
||||
IReadOnlyList<UserIdentityDto> users = await authStore.ListUsersByTenantIdAsync(tenant.Id, ct);
|
||||
items.Add(new TenantListItemDto(tenant.Id, tenant.Name, tenant.Status, tenant.CreatedAt, users.Count));
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Детали тенанта с пользователями (GET /api/operator/tenants/{id}).
|
||||
/// </summary>
|
||||
/// <param name="id">Идентификатор тенанта.</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Детали и пользователи тенанта (по CreatedAt) или null, если тенанта нет.</returns>
|
||||
public async Task<TenantDetailDto?> GetAsync(Guid id, CancellationToken ct)
|
||||
{
|
||||
var tenant = await tenantRepository.FindByIdAsync(id, ct);
|
||||
if (tenant is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
IReadOnlyList<UserIdentityDto> users = await authStore.ListUsersByTenantIdAsync(id, ct);
|
||||
return new TenantDetailDto(tenant.Id, tenant.Name, tenant.Status, tenant.CreatedAt, users);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Меняет статус тенанта (POST …/suspend → suspended, …/unsuspend → active; аудит пишет HTTP-слой).
|
||||
/// </summary>
|
||||
/// <param name="id">Идентификатор тенанта.</param>
|
||||
/// <param name="status">Новый статус — константа <c>TenantStatuses</c>.</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Результат: тенант не найден (404) или применение с признаком реального изменения (Changed).</returns>
|
||||
public async Task<TenantStatusChangeResultDto> ChangeStatusAsync(
|
||||
Guid id,
|
||||
string status,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var tenant = await tenantRepository.FindByIdAsync(id, ct);
|
||||
if (tenant is null)
|
||||
{
|
||||
return new TenantStatusChangeResultDto(Ok: false, Error: TenantStatusChangeResultDto.ErrorNotFound, Changed: false, Tenant: null);
|
||||
}
|
||||
|
||||
// Идемпотентность: повторный suspend уже приостановленного — Ok без изменения (аудит не дублируется).
|
||||
if (tenant.Status == status)
|
||||
{
|
||||
return new TenantStatusChangeResultDto(Ok: true, Error: null, Changed: false, Tenant: tenant);
|
||||
}
|
||||
|
||||
bool updated = await tenantRepository.UpdateStatusAsync(id, status, ct);
|
||||
return new TenantStatusChangeResultDto(Ok: true, Error: null, Changed: updated, Tenant: tenant with { Status = status });
|
||||
}
|
||||
|
||||
// Новый одноразовый пароль владельца: 16 url-safe символов (общий UrlSafeToken, Security review C36).
|
||||
// Возвращает: Строка пароля.
|
||||
private static string GenerateInitialPassword() => UrlSafeToken.New(InitialPasswordRandomByteCount);
|
||||
}
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.SharedKernel;
|
||||
|
||||
namespace Deal.Modules.Tenants.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Прикладной сервис операторского реестра тенантов (GET /api/operator/tenants[/{id}], POST (создание),
|
||||
/// suspend/unsuspend — план Task 7): чтение реестра + пользователей, создание тенанта (с провижинингом схемы) и
|
||||
/// смена статуса приостановки.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Аудит (tenant_created/tenant_status_changed) пишет HTTP-слой (паттерн Task 4/5), сервис возвращает результат
|
||||
/// с кодами ошибок; impersonation живёт в <see cref="AuthService.ImpersonateAsync"/> (создание tenant-сессии
|
||||
/// пользователя). Создание с email (план Task 7: «email-опция создаёт сразу пользователя-владельца») заводит
|
||||
/// пользователя с автоматическим одноразовым паролем — владелец получает его от оператора и меняет после первого
|
||||
/// входа (прямой ввод пароля оператором в контракте не предусмотрен; см. <see cref="TenantCreateResultDto"/>).
|
||||
/// Счётчики пользователей считаются по списку пользователей тенанта (порт <see cref="IAuthStore"/>); на масштабах
|
||||
/// операторской админки N+1 осознан (сводка usage/лимитов появится в Task 8–10).
|
||||
/// </remarks>
|
||||
public sealed class TenantAdminService(
|
||||
ITenantRepository tenantRepository,
|
||||
IAuthStore authStore,
|
||||
TenantService tenantService,
|
||||
IPasswordHasher passwordHasher)
|
||||
{
|
||||
// Случайные байты одноразового пароля владельца: 12 → ровно 16 символов Base64Url (как InviteCodeGenerator).
|
||||
private const int InitialPasswordRandomByteCount = 12;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт тенанта оператором (план Task 7): строка реестра (Status active) + провижининг схемы
|
||||
/// (<see cref="TenantService.CreateTenantAsync"/>); при email — сразу пользователь-владелец с одноразовым паролем.
|
||||
/// </summary>
|
||||
/// <param name="name">Имя тенанта (обязательно; обрезается).</param>
|
||||
/// <param name="email">Email владельца (опционально): создаёт пользователя-владельца сразу, иначе владелец
|
||||
/// заводится инвайтом (Ruling 2).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>
|
||||
/// Ok=true — тенант создан (Tenant); при email дополнительно OwnerUserId/OwnerLogin/InitialPassword.
|
||||
/// Ok=false — код ошибки (имя пусто, email невалиден/уже зарегистрирован); текст — HTTP-слой.
|
||||
/// </returns>
|
||||
public async Task<TenantCreateResultDto> CreateAsync(
|
||||
string? name,
|
||||
string? email,
|
||||
CancellationToken ct)
|
||||
{
|
||||
string tenantName = name?.Trim() ?? string.Empty;
|
||||
if (string.IsNullOrEmpty(tenantName))
|
||||
{
|
||||
return TenantCreateResultDto.Failed(TenantCreateResultDto.ErrorNameRequired);
|
||||
}
|
||||
|
||||
string? normalizedEmail = null;
|
||||
if (!string.IsNullOrWhiteSpace(email))
|
||||
{
|
||||
normalizedEmail = InvitesService.NormalizeEmail(email);
|
||||
if (!InvitesService.IsValidEmail(email))
|
||||
{
|
||||
return TenantCreateResultDto.Failed(TenantCreateResultDto.ErrorInvalidEmail);
|
||||
}
|
||||
|
||||
// Глобальная уникальность email (users.login unique, Ruling 2): предпроверка до записи, чтобы занятый
|
||||
// email не создавал тенанта без владельца (как JoinService, Task 6). Гонка закрыта unique-индексом.
|
||||
var existingUser = await authStore.FindUserByLoginAsync(normalizedEmail, ct);
|
||||
if (existingUser is not null)
|
||||
{
|
||||
return TenantCreateResultDto.Failed(TenantCreateResultDto.ErrorEmailTaken);
|
||||
}
|
||||
}
|
||||
|
||||
// Создание тенанта — единая операция: строка public.tenants (Status active) + провижининг схемы.
|
||||
var tenantId = Guid.NewGuid();
|
||||
await tenantService.CreateTenantAsync(tenantName, tenantId, ct);
|
||||
var tenant = new TenantRecordDto(tenantId, tenantName, TenantStatuses.Active, DateTimeOffset.UtcNow);
|
||||
|
||||
if (normalizedEmail is null)
|
||||
{
|
||||
return new TenantCreateResultDto(Ok: true, Error: null, Tenant: tenant);
|
||||
}
|
||||
|
||||
// Пользователь-владелец с одноразовым паролем: raw-пароль возвращается один раз (наружу), в хранилище —
|
||||
// только хэш; в аудит/логи не пишется.
|
||||
string initialPassword = GenerateInitialPassword();
|
||||
var ownerUserId = Guid.NewGuid();
|
||||
await authStore.CreateUserAsync(
|
||||
new StoredUserDto(
|
||||
Id: ownerUserId,
|
||||
Login: normalizedEmail,
|
||||
TenantId: tenantId,
|
||||
Status: "active",
|
||||
PasswordHash: passwordHasher.Hash(initialPassword)),
|
||||
ct);
|
||||
|
||||
return new TenantCreateResultDto(
|
||||
Ok: true,
|
||||
Error: null,
|
||||
Tenant: tenant,
|
||||
OwnerUserId: ownerUserId,
|
||||
OwnerLogin: normalizedEmail,
|
||||
InitialPassword: initialPassword);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Список тенантов со счётчиками пользователей (реестр + счётчики, план Task 7).
|
||||
/// </summary>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Тенанты в порядке создания (реестр) с числом пользователей каждого.</returns>
|
||||
public async Task<IReadOnlyList<TenantListItemDto>> ListAsync(CancellationToken ct)
|
||||
{
|
||||
var tenants = await tenantRepository.ListAsync(ct);
|
||||
var items = new List<TenantListItemDto>(tenants.Count);
|
||||
foreach (TenantRecordDto tenant in tenants)
|
||||
{
|
||||
IReadOnlyList<UserIdentityDto> users = await authStore.ListUsersByTenantIdAsync(tenant.Id, ct);
|
||||
items.Add(new TenantListItemDto(tenant.Id, tenant.Name, tenant.Status, tenant.CreatedAt, users.Count));
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Детали тенанта с пользователями (GET /api/operator/tenants/{id}).
|
||||
/// </summary>
|
||||
/// <param name="id">Идентификатор тенанта.</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Детали и пользователи тенанта (по CreatedAt) или null, если тенанта нет.</returns>
|
||||
public async Task<TenantDetailDto?> GetAsync(Guid id, CancellationToken ct)
|
||||
{
|
||||
var tenant = await tenantRepository.FindByIdAsync(id, ct);
|
||||
if (tenant is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
IReadOnlyList<UserIdentityDto> users = await authStore.ListUsersByTenantIdAsync(id, ct);
|
||||
return new TenantDetailDto(tenant.Id, tenant.Name, tenant.Status, tenant.CreatedAt, users);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Меняет статус тенанта (POST …/suspend → suspended, …/unsuspend → active; аудит пишет HTTP-слой).
|
||||
/// </summary>
|
||||
/// <param name="id">Идентификатор тенанта.</param>
|
||||
/// <param name="status">Новый статус — константа <c>TenantStatuses</c>.</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Результат: тенант не найден (404) или применение с признаком реального изменения (Changed).</returns>
|
||||
public async Task<TenantStatusChangeResultDto> ChangeStatusAsync(
|
||||
Guid id,
|
||||
string status,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var tenant = await tenantRepository.FindByIdAsync(id, ct);
|
||||
if (tenant is null)
|
||||
{
|
||||
return new TenantStatusChangeResultDto(Ok: false, Error: TenantStatusChangeResultDto.ErrorNotFound, Changed: false, Tenant: null);
|
||||
}
|
||||
|
||||
// Идемпотентность: повторный suspend уже приостановленного — Ok без изменения (аудит не дублируется).
|
||||
if (tenant.Status == status)
|
||||
{
|
||||
return new TenantStatusChangeResultDto(Ok: true, Error: null, Changed: false, Tenant: tenant);
|
||||
}
|
||||
|
||||
bool updated = await tenantRepository.UpdateStatusAsync(id, status, ct);
|
||||
return new TenantStatusChangeResultDto(Ok: true, Error: null, Changed: updated, Tenant: tenant with { Status = status });
|
||||
}
|
||||
|
||||
// Новый одноразовый пароль владельца: 16 url-safe символов (общий UrlSafeToken, Security review C36).
|
||||
// Возвращает: Строка пароля.
|
||||
private static string GenerateInitialPassword() => UrlSafeToken.New(InitialPasswordRandomByteCount);
|
||||
}
|
||||
|
||||
@@ -1,57 +1,54 @@
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.SharedKernel.Tenants.Abstractions;
|
||||
using Deal.SharedKernel.Tenants.Models;
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Extensions;
|
||||
using Deal.Modules.Tenants.Application.Registrars;
|
||||
|
||||
namespace Deal.Modules.Tenants.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Прикладной сервис реестра тенантов: создание тенанта и его списка.
|
||||
/// </summary>
|
||||
/// <remarks>Создание тенанта — единая транзакция по смыслу: запись в public.tenants и провижининг схемы.</remarks>
|
||||
public sealed class TenantService(ITenantRepository tenantRepository, ITenantProvisioner tenantProvisioner)
|
||||
{
|
||||
/// <summary>
|
||||
/// Создаёт тенанта (id = новый Guid в формате "N") и провижинит его схему.
|
||||
/// </summary>
|
||||
/// <param name="name">Имя тенанта.</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Идентификатор созданного тенанта (он же имя схемы tenant_<id>).</returns>
|
||||
public Task<TenantId> CreateTenantAsync(string name, CancellationToken ct) =>
|
||||
CreateTenantAsync(name, Guid.NewGuid(), ct);
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт тенанта с явным id и провижинит его схему (bootstrap дефолтного тенанта, Ruling 8).
|
||||
/// </summary>
|
||||
/// <param name="name">Имя тенанта.</param>
|
||||
/// <param name="id">Идентификатор тенанта (определяет имя схемы).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Идентификатор созданного тенанта (он же имя схемы tenant_<id>).</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>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <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>
|
||||
/// <remarks>Создание тенанта — единая транзакция по смыслу: запись в public.tenants и провижининг схемы.</remarks>
|
||||
public sealed class TenantService(ITenantRepository tenantRepository, ITenantProvisioner tenantProvisioner)
|
||||
{
|
||||
/// <summary>
|
||||
/// Создаёт тенанта (id = новый Guid в формате "N") и провижинит его схему.
|
||||
/// </summary>
|
||||
/// <param name="name">Имя тенанта.</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Идентификатор созданного тенанта (он же имя схемы tenant_<id>).</returns>
|
||||
public Task<TenantId> CreateTenantAsync(string name, CancellationToken ct) =>
|
||||
CreateTenantAsync(name, Guid.NewGuid(), ct);
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт тенанта с явным id и провижинит его схему (bootstrap дефолтного тенанта, Ruling 8).
|
||||
/// </summary>
|
||||
/// <param name="name">Имя тенанта.</param>
|
||||
/// <param name="id">Идентификатор тенанта (определяет имя схемы).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Идентификатор созданного тенанта (он же имя схемы tenant_<id>).</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>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Список тенантов.</returns>
|
||||
public Task<IReadOnlyList<TenantRecordDto>> ListTenantsAsync(CancellationToken ct) =>
|
||||
tenantRepository.ListAsync(ct);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Extensions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.Modules.Tenants.Application.Registrars;
|
||||
|
||||
namespace Deal.Modules.Tenants.Application.Services;
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Extensions;
|
||||
using Deal.Modules.Tenants.Application.Registrars;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
namespace Deal.Modules.Tenants.Application.Services;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user