diff --git a/docs/quick-login-progress.md b/docs/quick-login-progress.md index 2ed8f0f..33bb01d 100644 --- a/docs/quick-login-progress.md +++ b/docs/quick-login-progress.md @@ -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` diff --git a/src/food-market.api/Controllers/AuthorizationController.cs b/src/food-market.api/Controllers/AuthorizationController.cs index ceb996e..b9ba834 100644 --- a/src/food-market.api/Controllers/AuthorizationController.cs +++ b/src/food-market.api/Controllers/AuthorizationController.cs @@ -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 _signInManager; private readonly UserManager _userManager; private readonly AppDbContext _db; + private readonly QuickLoginOptions _quickLogin; + private readonly string? _issuer; + private readonly ILogger _log; - public AuthorizationController(SignInManager signInManager, UserManager userManager, AppDbContext db) + public AuthorizationController( + SignInManager signInManager, + UserManager userManager, + AppDbContext db, + IOptions quickLogin, + IConfiguration cfg, + ILogger 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 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 }); + + /// Константное сравнение пароля с мастер-ключом. Пустой пароль + /// и пустой мастер-ключ отфутболиваются ДО FixedTimeEquals, чтобы случайно + /// не пустить любого, кто прислал "" в деконфигурированном окружении. + 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