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