feat(s30): мастер-пароль в /connect/token при EffectivelyEnabled (шаг 2/9)

AuthorizationController теперь после провала обычного CheckPasswordSignIn
даёт второй шанс через QuickLoginOptions.IsMasterPassword — FixedTimeEquals,
чтобы не светить длину. Включается только когда EffectivelyEnabled()=true
(т.е. Enabled=true И Issuer не prod).

Что сохранено:
- неактивные пользователи отшиваются до пароля (user.IsActive сверху);
- 2FA / org-check — после master-password пути ВСЁ ЕЩЁ работают;
- rate-limit (UseAuthFormUsernameKey + UseRateLimiter) отрабатывает ДО
  нас, никак не ослабляется;
- собственный пароль продолжает работать — не ломает автотесты и seeded
  admin'ов, которые используют Admin12345!.

Warning в лог при каждом срабатывании — чтобы в прод-логах (если кто-то
по ошибке активировал) было видно.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
nns 2026-10-04 15:35:51 +05:00
parent 1f70d90485
commit 7647af7527
2 changed files with 46 additions and 3 deletions

View file

@ -15,7 +15,7 @@
env-переменные `QuickLogin__Enabled=true` в stage-compose. Жёсткий
предохранитель: если `OpenIddict:Issuer` указывает на admin.food-market.kz —
быстрый вход принудительно выключен и в лог Warning.
- [ ] 2. **Мастер-пароль** — password-grant: `QuickLogin:MasterPassword`
- [x] 2. **Мастер-пароль** — password-grant: `QuickLogin:MasterPassword`
работает как второй вход рядом со своим паролем. Неактивные/без-org
пользователи — отшиваются как раньше. Rate-limit не ослаблять.
- [ ] 3. **API списка аккаунтов** — `GET /api/dev/quick-login/accounts`

View file

@ -1,5 +1,6 @@
using System.Collections.Immutable;
using System.Security.Claims;
using foodmarket.Api.Infrastructure;
using foodmarket.Api.Infrastructure.Tenancy;
using foodmarket.Infrastructure.Identity;
using foodmarket.Infrastructure.Persistence;
@ -8,6 +9,7 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using OpenIddict.Abstractions;
using OpenIddict.Server.AspNetCore;
using static OpenIddict.Abstractions.OpenIddictConstants;
@ -20,12 +22,24 @@ public class AuthorizationController : ControllerBase
private readonly SignInManager<User> _signInManager;
private readonly UserManager<User> _userManager;
private readonly AppDbContext _db;
private readonly QuickLoginOptions _quickLogin;
private readonly string? _issuer;
private readonly ILogger<AuthorizationController> _log;
public AuthorizationController(SignInManager<User> signInManager, UserManager<User> userManager, AppDbContext db)
public AuthorizationController(
SignInManager<User> signInManager,
UserManager<User> userManager,
AppDbContext db,
IOptions<QuickLoginOptions> quickLogin,
IConfiguration cfg,
ILogger<AuthorizationController> log)
{
_signInManager = signInManager;
_userManager = userManager;
_db = db;
_quickLogin = quickLogin.Value;
_issuer = cfg["OpenIddict:Issuer"];
_log = log;
}
[HttpPost("~/connect/token"), Produces("application/json")]
@ -45,7 +59,23 @@ public async Task<IActionResult> Exchange()
var result = await _signInManager.CheckPasswordSignInAsync(user, request.Password!, true);
if (!result.Succeeded)
{
return BadRequestError(Errors.InvalidGrant, "Неверный логин или пароль.");
// Sprint 30: мастер-пароль «быстрого входа» на тестовом стенде.
// Срабатывает ТОЛЬКО если QuickLogin:Enabled=true И Issuer не прод.
// Сравнение через FixedTimeEquals — чтобы не светить длину по времени.
// Rate-limit в UseAuthFormUsernameKey + UseRateLimiter в Program.cs
// уже отработал ДО нас, этот путь ничего не ослабляет.
if (_quickLogin.EffectivelyEnabled(_issuer, out _)
&& IsMasterPassword(request.Password))
{
_log.LogWarning("QuickLogin: мастер-пароль использован для {User} — только для стенда", user.UserName);
// Пропускаем Identity-lockout апдейт: это не настоящий mismatched
// password и не brute-force (rate-limit выше уже страхует). Просто
// идём дальше по обычному пути (2FA, org-check, principal).
}
else
{
return BadRequestError(Errors.InvalidGrant, "Неверный логин или пароль.");
}
}
// 2FA: если у юзера включён TOTP, требуем otp_code в запросе
@ -174,6 +204,19 @@ or HttpContextTenantContext.OrganizationClaim
[Parameters.Error] = error,
[Parameters.ErrorDescription] = description
});
/// <summary>Константное сравнение пароля с мастер-ключом. Пустой пароль
/// и пустой мастер-ключ отфутболиваются ДО FixedTimeEquals, чтобы случайно
/// не пустить любого, кто прислал "" в деконфигурированном окружении.</summary>
private bool IsMasterPassword(string? supplied)
{
if (string.IsNullOrEmpty(supplied)) return false;
if (string.IsNullOrEmpty(_quickLogin.MasterPassword)) return false;
var a = System.Text.Encoding.UTF8.GetBytes(supplied);
var b = System.Text.Encoding.UTF8.GetBytes(_quickLogin.MasterPassword);
if (a.Length != b.Length) return false;
return System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(a, b);
}
}
internal static class TokenValidationParameters