feat(s30): /api/dev/quick-login/accounts — анонимный список для стенда (шаг 3/9)
Анонимный эндпоинт, который ВИДЕН только когда QuickLogin эффективно включён (EffectivelyEnabled=true). В проде отдаёт 404, чтобы по наличию эндпоинта нельзя было вычислить среду. Фильтрация: - IsActive=false → не показываем; - автотестовые (prefix cat31-/visual-/factory-/mt6*/doc4*/auth2*/rep5*/ rt8*/wiz9*/quality-/i18n*/twofa*/a11y-/mob14-/mob15-/iso*/axdet-/ smoke-/ratetest-/perf-/probe-/load-/s19-test-/admin-/cashier-/ keeper-) → не показываем; - email@e.kz, @test-fm.local — служебные домены diag-probes, тоже скрыты; - длинный числовой суффикс [0-9]{10,} в email → автотест. Порядок: SuperAdmin → demo → остальные (ThenBy email для стабильности). Лимит 50 + поиск ?q по email/full-name/org-name/role (case-insensitive). Карточка: email, FullName, роль (SuperAdmin / EmployeeRole.Name), название org, флаги IsSuperAdmin/IsDemo для сортировки/отображения. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
7647af7527
commit
ed5089bac3
|
|
@ -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*-*,
|
||||
|
|
|
|||
184
src/food-market.api/Controllers/QuickLoginController.cs
Normal file
184
src/food-market.api/Controllers/QuickLoginController.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>Dev-only список аккаунтов для «быстрого входа» на тестовом стенде.
|
||||
/// Анонимный, но доступен ТОЛЬКО когда <see cref="QuickLoginOptions.EffectivelyEnabled"/>
|
||||
/// = true. В проде отдаёт 404 — ни отфильтрованных, ни пустых ответов, чтобы
|
||||
/// по наличию эндпоинта нельзя было вычислить среду.
|
||||
///
|
||||
/// Что отдаём: активных «человеческих» пользователей — SuperAdmin, затем
|
||||
/// demo-* (seeded), затем остальные. Автотестовые (с числовым суффиксом или
|
||||
/// служебным префиксом) отсеиваются по шаблонам из <see cref="AutoTestPatterns"/>.</summary>
|
||||
[ApiController]
|
||||
[AllowAnonymous]
|
||||
[Route("api/dev/quick-login")]
|
||||
public class QuickLoginController : ControllerBase
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly UserManager<User> _userManager;
|
||||
private readonly QuickLoginOptions _opts;
|
||||
private readonly string? _issuer;
|
||||
|
||||
public QuickLoginController(
|
||||
AppDbContext db,
|
||||
UserManager<User> userManager,
|
||||
IOptions<QuickLoginOptions> 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);
|
||||
|
||||
/// <summary>Шаблоны логинов, которые считаем автотестовыми и не показываем
|
||||
/// в списке. Проверка — case-insensitive, по началу email'а.</summary>
|
||||
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
|
||||
};
|
||||
|
||||
/// <summary>Email-домены, которые считаем «служебными» для автотестов.
|
||||
/// На стенде реальные юзеры создаются только с @food-market.local и
|
||||
/// @demo-market.local, остальное — автоматика.</summary>
|
||||
private static readonly string[] AutoTestDomains =
|
||||
{
|
||||
"@e.kz", // короткие diagnostic-probes (dn29418@e.kz и т.п.)
|
||||
"@test-fm.local", // quality-watchdog
|
||||
};
|
||||
|
||||
[HttpGet("accounts")]
|
||||
public async Task<IActionResult> 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);
|
||||
}
|
||||
|
||||
/// <summary>Автотестовые юзеры: длинный числовой суффикс (10+ цифр подряд)
|
||||
/// или email начинается с одного из <see cref="AutoTestPatterns"/>.
|
||||
/// Исключения — <c>demo-admin@...</c>/<c>demo-cashier@...</c>/<c>demo-keeper@...</c>
|
||||
/// обрабатываются <see cref="IsDemoAccount"/>, они НЕ matchат ни один из
|
||||
/// autotest-префиксов (нет "admin-", только "demo-admin@").</summary>
|
||||
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);
|
||||
}
|
||||
Loading…
Reference in a new issue