diff --git a/docs/quick-login-progress.md b/docs/quick-login-progress.md index 33bb01d..13c8959 100644 --- a/docs/quick-login-progress.md +++ b/docs/quick-login-progress.md @@ -18,7 +18,7 @@ - [x] 2. **Мастер-пароль** — password-grant: `QuickLogin:MasterPassword` работает как второй вход рядом со своим паролем. Неактивные/без-org пользователи — отшиваются как раньше. Rate-limit не ослаблять. -- [ ] 3. **API списка аккаунтов** — `GET /api/dev/quick-login/accounts` +- [x] 3. **API списка аккаунтов** — `GET /api/dev/quick-login/accounts` (анонимный, 404 если выключен). Порядок: SuperAdmin → демо-* → остальные «человеческие». Autotest-патéрны (cat31-*, visual-*, factory-smoke-*, mt6*, quality-*, i18n*-*, auth2*-*, doc4*-*, rep5*-*, rt8*-*, twofa*-*, wiz9*-*, diff --git a/src/food-market.api/Controllers/QuickLoginController.cs b/src/food-market.api/Controllers/QuickLoginController.cs new file mode 100644 index 0000000..b9eda6e --- /dev/null +++ b/src/food-market.api/Controllers/QuickLoginController.cs @@ -0,0 +1,184 @@ +using foodmarket.Api.Infrastructure; +using foodmarket.Infrastructure.Identity; +using foodmarket.Infrastructure.Persistence; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace foodmarket.Api.Controllers; + +/// Dev-only список аккаунтов для «быстрого входа» на тестовом стенде. +/// Анонимный, но доступен ТОЛЬКО когда +/// = true. В проде отдаёт 404 — ни отфильтрованных, ни пустых ответов, чтобы +/// по наличию эндпоинта нельзя было вычислить среду. +/// +/// Что отдаём: активных «человеческих» пользователей — SuperAdmin, затем +/// demo-* (seeded), затем остальные. Автотестовые (с числовым суффиксом или +/// служебным префиксом) отсеиваются по шаблонам из . +[ApiController] +[AllowAnonymous] +[Route("api/dev/quick-login")] +public class QuickLoginController : ControllerBase +{ + private readonly AppDbContext _db; + private readonly UserManager _userManager; + private readonly QuickLoginOptions _opts; + private readonly string? _issuer; + + public QuickLoginController( + AppDbContext db, + UserManager userManager, + IOptions opts, + IConfiguration cfg) + { + _db = db; + _userManager = userManager; + _opts = opts.Value; + _issuer = cfg["OpenIddict:Issuer"]; + } + + public record QuickLoginAccount( + string Email, + string FullName, + string? Role, + string? OrganizationName, + bool IsSuperAdmin, + bool IsDemo); + + /// Шаблоны логинов, которые считаем автотестовыми и не показываем + /// в списке. Проверка — case-insensitive, по началу email'а. + private static readonly string[] AutoTestPatterns = + { + "cat31-", "cat32-", "cat33-", "cat34-", "cat35-", + "visual-", "factory-smoke-", "factory-", + "mt61-", "mt62-", "mt63-", + "doc41-", "doc42-", "doc43-", "doc44-", "doc45-", "doc46-", "doc47-", "doc48-", + "auth21-", "auth22-", "auth23-", "auth24-", + "rep51-", "rep52-", "rep53-", "rep54-", + "rt81-", "rt82-", "rt83-", "rt84-", + "wiz91-", "wiz92-", "wiz93-", "wiz94-", "wiz95-", "wiz-", + "quality-", "i18n71-", "i18n72-", "twofa73-", + "a11y-", "mob14", "mob15", "iso", + "axdet-", "smoke-", "ratetest-", "perf-", "probe-", + "load-sales-", "load-", "s19-test-", + "admin-", "cashier-", "keeper-", // seeded test triples по id + }; + + /// Email-домены, которые считаем «служебными» для автотестов. + /// На стенде реальные юзеры создаются только с @food-market.local и + /// @demo-market.local, остальное — автоматика. + private static readonly string[] AutoTestDomains = + { + "@e.kz", // короткие diagnostic-probes (dn29418@e.kz и т.п.) + "@test-fm.local", // quality-watchdog + }; + + [HttpGet("accounts")] + public async Task GetAccounts([FromQuery] string? q, CancellationToken ct = default) + { + if (!_opts.EffectivelyEnabled(_issuer, out _)) + { + // 404 — не 403: наличие эндпоинта не должно palятся в проде. По + // шаблону Admin-only эндпоинтов, где отключение = скрытие. + return NotFound(); + } + + // Активные + с org (SuperAdmin — без org). IsActive=false не пускаем, + // как и в password-grant: быстрый вход для «уволенного» = бессмыслица. + var users = await _db.Users + .IgnoreQueryFilters() + .Where(u => u.IsActive) + .Select(u => new + { + u.Id, + u.UserName, + u.Email, + u.FullName, + u.OrganizationId, + OrgName = u.OrganizationId == null + ? null + : _db.Organizations.IgnoreQueryFilters() + .Where(o => o.Id == u.OrganizationId && !o.IsArchived) + .Select(o => o.Name).FirstOrDefault(), + // Роль в organization — через Employee.RoleId → EmployeeRole.Name. + // Берём первую активную (у юзера обычно одна Employee-запись). + RoleName = _db.Employees.IgnoreQueryFilters() + .Where(e => e.UserId == u.Id && e.IsActive && !e.IsDeleted) + .Select(e => e.Role.Name) + .FirstOrDefault(), + }) + .ToListAsync(ct); + + // ASP.NET Identity role: SuperAdmin не живёт в Employee, а в AspNetUserRoles. + // Делаем отдельный проход — JOIN через UserManager, не EF-рефлексией. + var superAdminUserIds = (await _userManager.GetUsersInRoleAsync(SystemRoles.SuperAdmin)) + .Select(u => u.Id) + .ToHashSet(); + + var result = users + .Where(u => !IsAutoTest(u.Email ?? u.UserName ?? "")) + // SuperAdmin и demo-* остаются даже без OrgName (SuperAdmin без org'и). + .Select(u => + { + var isSa = superAdminUserIds.Contains(u.Id); + var isDemo = IsDemoAccount(u.Email ?? u.UserName ?? ""); + string? role = isSa ? "SuperAdmin" : (u.RoleName ?? null); + return new QuickLoginAccount( + Email: u.Email ?? u.UserName ?? "", + FullName: string.IsNullOrWhiteSpace(u.FullName) ? (u.Email ?? "") : u.FullName, + Role: role, + OrganizationName: isSa ? "Платформа" : u.OrgName, + IsSuperAdmin: isSa, + IsDemo: isDemo); + }) + .Where(a => + { + if (string.IsNullOrWhiteSpace(q)) return true; + var needle = q.Trim().ToLowerInvariant(); + return a.Email.ToLowerInvariant().Contains(needle) + || (a.FullName ?? "").ToLowerInvariant().Contains(needle) + || (a.OrganizationName ?? "").ToLowerInvariant().Contains(needle) + || (a.Role ?? "").ToLowerInvariant().Contains(needle); + }) + // Порядок: SuperAdmin → demo → остальные (по email для стабильности). + .OrderByDescending(a => a.IsSuperAdmin) + .ThenByDescending(a => a.IsDemo) + .ThenBy(a => a.Email) + .Take(50) + .ToList(); + + return Ok(result); + } + + /// Автотестовые юзеры: длинный числовой суффикс (10+ цифр подряд) + /// или email начинается с одного из . + /// Исключения — demo-admin@.../demo-cashier@.../demo-keeper@... + /// обрабатываются , они НЕ matchат ни один из + /// autotest-префиксов (нет "admin-", только "demo-admin@"). + private static bool IsAutoTest(string email) + { + if (string.IsNullOrEmpty(email)) return true; + var lower = email.ToLowerInvariant(); + + // Длинный числовой суффикс (миллисекундный timestamp 13 цифр, или 10+ цифр + // в середине). Любой email вида что-то-1780849101465@... считаем автотестом. + var m = System.Text.RegularExpressions.Regex.Match(lower, "[0-9]{10,}"); + if (m.Success) return true; + + foreach (var p in AutoTestPatterns) + { + if (lower.StartsWith(p)) return true; + } + foreach (var d in AutoTestDomains) + { + if (lower.EndsWith(d)) return true; + } + return false; + } + + private static bool IsDemoAccount(string email) => + email.StartsWith("demo-", StringComparison.OrdinalIgnoreCase) + && email.Contains("@food-market.local", StringComparison.OrdinalIgnoreCase); +}