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:
parent
1f70d90485
commit
7647af7527
|
|
@ -15,7 +15,7 @@
|
||||||
env-переменные `QuickLogin__Enabled=true` в stage-compose. Жёсткий
|
env-переменные `QuickLogin__Enabled=true` в stage-compose. Жёсткий
|
||||||
предохранитель: если `OpenIddict:Issuer` указывает на admin.food-market.kz —
|
предохранитель: если `OpenIddict:Issuer` указывает на admin.food-market.kz —
|
||||||
быстрый вход принудительно выключен и в лог Warning.
|
быстрый вход принудительно выключен и в лог Warning.
|
||||||
- [ ] 2. **Мастер-пароль** — password-grant: `QuickLogin:MasterPassword`
|
- [x] 2. **Мастер-пароль** — password-grant: `QuickLogin:MasterPassword`
|
||||||
работает как второй вход рядом со своим паролем. Неактивные/без-org
|
работает как второй вход рядом со своим паролем. Неактивные/без-org
|
||||||
пользователи — отшиваются как раньше. Rate-limit не ослаблять.
|
пользователи — отшиваются как раньше. Rate-limit не ослаблять.
|
||||||
- [ ] 3. **API списка аккаунтов** — `GET /api/dev/quick-login/accounts`
|
- [ ] 3. **API списка аккаунтов** — `GET /api/dev/quick-login/accounts`
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
using System.Collections.Immutable;
|
using System.Collections.Immutable;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
using foodmarket.Api.Infrastructure;
|
||||||
using foodmarket.Api.Infrastructure.Tenancy;
|
using foodmarket.Api.Infrastructure.Tenancy;
|
||||||
using foodmarket.Infrastructure.Identity;
|
using foodmarket.Infrastructure.Identity;
|
||||||
using foodmarket.Infrastructure.Persistence;
|
using foodmarket.Infrastructure.Persistence;
|
||||||
|
|
@ -8,6 +9,7 @@
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
using OpenIddict.Abstractions;
|
using OpenIddict.Abstractions;
|
||||||
using OpenIddict.Server.AspNetCore;
|
using OpenIddict.Server.AspNetCore;
|
||||||
using static OpenIddict.Abstractions.OpenIddictConstants;
|
using static OpenIddict.Abstractions.OpenIddictConstants;
|
||||||
|
|
@ -20,12 +22,24 @@ public class AuthorizationController : ControllerBase
|
||||||
private readonly SignInManager<User> _signInManager;
|
private readonly SignInManager<User> _signInManager;
|
||||||
private readonly UserManager<User> _userManager;
|
private readonly UserManager<User> _userManager;
|
||||||
private readonly AppDbContext _db;
|
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;
|
_signInManager = signInManager;
|
||||||
_userManager = userManager;
|
_userManager = userManager;
|
||||||
_db = db;
|
_db = db;
|
||||||
|
_quickLogin = quickLogin.Value;
|
||||||
|
_issuer = cfg["OpenIddict:Issuer"];
|
||||||
|
_log = log;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("~/connect/token"), Produces("application/json")]
|
[HttpPost("~/connect/token"), Produces("application/json")]
|
||||||
|
|
@ -45,7 +59,23 @@ public async Task<IActionResult> Exchange()
|
||||||
var result = await _signInManager.CheckPasswordSignInAsync(user, request.Password!, true);
|
var result = await _signInManager.CheckPasswordSignInAsync(user, request.Password!, true);
|
||||||
if (!result.Succeeded)
|
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 в запросе
|
// 2FA: если у юзера включён TOTP, требуем otp_code в запросе
|
||||||
|
|
@ -174,6 +204,19 @@ or HttpContextTenantContext.OrganizationClaim
|
||||||
[Parameters.Error] = error,
|
[Parameters.Error] = error,
|
||||||
[Parameters.ErrorDescription] = description
|
[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
|
internal static class TokenValidationParameters
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue