diff --git a/src/food-market.api/Controllers/AuthorizationController.cs b/src/food-market.api/Controllers/AuthorizationController.cs index a3b4e82..a05fbc9 100644 --- a/src/food-market.api/Controllers/AuthorizationController.cs +++ b/src/food-market.api/Controllers/AuthorizationController.cs @@ -138,9 +138,89 @@ public async Task Exchange() return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); } + // Быстрый вход кассира по PIN на зарегистрированном устройстве. + // Параметры: retail_point_id (точка устройства) + pin. Находим активного + // сотрудника, привязанного к точке, чей PIN совпадает. Брутфорс-защита — + // in-memory по точке (5 неверных → блок 5 мин). Пароль остаётся запасным. + if (string.Equals(request.GrantType, "pin", StringComparison.Ordinal)) + { + var pointRaw = request["retail_point_id"]?.Value?.ToString(); + var pin = request["pin"]?.Value?.ToString(); + if (!Guid.TryParse(pointRaw, out var pointId) || string.IsNullOrWhiteSpace(pin)) + return BadRequestError(Errors.InvalidRequest, "Нужны retail_point_id и pin."); + + if (PinLockout.IsLocked(pointId, out var until)) + return BadRequestError(Errors.InvalidGrant, $"pin_locked:{until:O}"); + + // Кандидаты: активные сотрудники с PIN, привязанные к этой точке. + var candidates = await _db.Employees.IgnoreQueryFilters() + .Where(e => e.IsActive && !e.IsDeleted && e.PinHash != null && e.UserId != null + && e.RetailPointAssignments.Any(a => a.RetailPointId == pointId)) + .Select(e => new { e.UserId, e.PinHash }) + .ToListAsync(); + + var match = candidates.FirstOrDefault(c => foodmarket.Application.Common.PinHasher.Verify(pin, c.PinHash)); + if (match is null) + { + var locked = PinLockout.RegisterFailure(pointId); + return BadRequestError(Errors.InvalidGrant, + locked ? "pin_locked:" + PinLockout.LockedUntil(pointId)?.ToString("O") : "pin_invalid"); + } + + var user = await _userManager.FindByIdAsync(match.UserId!.Value.ToString()); + if (user is null || !user.IsActive) + return BadRequestError(Errors.InvalidGrant, "Аккаунт недоступен."); + + var rejectionPin = await CheckUserStillBelongsToLiveOrgAsync(user); + if (rejectionPin is not null) return rejectionPin; + + PinLockout.Reset(pointId); + user.LastLoginAt = DateTime.UtcNow; + await _userManager.UpdateAsync(user); + var principalPin = await CreatePrincipal(user, request.GetScopes()); + return SignIn(principalPin, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + } + return BadRequestError(Errors.UnsupportedGrantType, "Этот grant type не поддерживается."); } + /// In-memory брутфорс-защита PIN-входа по точке продаж: 5 неверных + /// подряд → блок на 5 минут. Сбрасывается при успешном входе. Процесс-локально + /// (стенд — один инстанс); достаточно против перебора 4–6-значного PIN. + private static class PinLockout + { + private const int MaxFails = 5; + private static readonly TimeSpan LockFor = TimeSpan.FromMinutes(5); + private static readonly System.Collections.Concurrent.ConcurrentDictionary State = new(); + + public static bool IsLocked(Guid point, out DateTime until) + { + until = default; + if (State.TryGetValue(point, out var s) && s.Until is { } u && u > DateTime.UtcNow) + { until = u; return true; } + return false; + } + + public static DateTime? LockedUntil(Guid point) + => State.TryGetValue(point, out var s) ? s.Until : null; + + /// Регистрирует неудачу; возвращает true если точка теперь заблокирована. + public static bool RegisterFailure(Guid point) + { + var s = State.AddOrUpdate(point, _ => (1, null), + (_, cur) => (cur.Until is { } u && u > DateTime.UtcNow ? cur.Fails : cur.Fails + 1, cur.Until)); + if (s.Fails >= MaxFails && (s.Until is null || s.Until <= DateTime.UtcNow)) + { + var until = DateTime.UtcNow.Add(LockFor); + State[point] = (0, until); + return true; + } + return false; + } + + public static void Reset(Guid point) => State.TryRemove(point, out _); + } + /// SuperAdmin-у org не нужна — он работает на уровне платформы. /// Любой другой пользователь обязан указывать на живую (не архивную) /// Organization. Если связь оборвана (org удалена / архивирована) — diff --git a/src/food-market.api/Controllers/Organizations/EmployeesController.cs b/src/food-market.api/Controllers/Organizations/EmployeesController.cs index 27ffdcc..3e9bf7d 100644 --- a/src/food-market.api/Controllers/Organizations/EmployeesController.cs +++ b/src/food-market.api/Controllers/Organizations/EmployeesController.cs @@ -219,6 +219,50 @@ public async Task> Create([FromBody] Employee return new EmployeeCreateResult(dto!, tempPassword); } + public record SetPinInput(string? Pin); + + /// Задать/сменить/снять PIN кассира (быстрый вход на кассе). + /// Пустой PIN — снять. PIN — 4–6 цифр, уникален среди активных сотрудников + /// на пересекающихся точках продаж (иначе вход по PIN неоднозначен). + [HttpPost("{id:guid}/pin"), Authorize(Roles = "SuperAdmin,Admin")] + public async Task SetPin(Guid id, [FromBody] SetPinInput input, CancellationToken ct) + { + var e = await _db.Employees.Include(x => x.RetailPointAssignments) + .FirstOrDefaultAsync(x => x.Id == id, ct); + if (e is null) return NotFound(); + + if (string.IsNullOrWhiteSpace(input.Pin)) + { + e.PinHash = null; + await _db.SaveChangesAsync(ct); + return Ok(new { ok = true, pinSet = false }); + } + + var pin = input.Pin.Trim(); + if (!Application.Common.PinHasher.IsValidPin(pin)) + return BadRequest(new { error = "PIN должен быть 4–6 цифр.", field = "pin" }); + + // Уникальность PIN среди активных сотрудников на тех же точках продаж. + var myPoints = e.RetailPointAssignments.Select(a => a.RetailPointId).ToHashSet(); + if (myPoints.Count > 0) + { + var others = await _db.Employees + .Include(x => x.RetailPointAssignments) + .Where(x => x.Id != id && x.IsActive && !x.IsDeleted && x.PinHash != null + && x.RetailPointAssignments.Any(a => myPoints.Contains(a.RetailPointId))) + .Select(x => new { x.PinHash, x.FirstName, x.LastName }) + .ToListAsync(ct); + var clash = others.FirstOrDefault(o => Application.Common.PinHasher.Verify(pin, o.PinHash)); + if (clash is not null) + return Conflict(new { error = $"Такой PIN уже у сотрудника {clash.LastName} {clash.FirstName} на этой точке. Выберите другой.", field = "pin", code = "pin-duplicate" }); + } + + e.PinHash = Application.Common.PinHasher.Hash(pin); + await _db.SaveChangesAsync(ct); + await _audit.LogAsync("SetEmployeePin", "Employee", e.Id, new { e.LastName, e.FirstName }, ct); + return Ok(new { ok = true, pinSet = true }); + } + [HttpPut("{id:guid}"), Authorize(Roles = "SuperAdmin,Admin")] public async Task Update(Guid id, [FromBody] EmployeeInput input, CancellationToken ct) { diff --git a/src/food-market.api/Program.cs b/src/food-market.api/Program.cs index 7574837..5186264 100644 --- a/src/food-market.api/Program.cs +++ b/src/food-market.api/Program.cs @@ -125,6 +125,9 @@ static string ApplyDefaultPoolConfig(string? raw) { opts.SetTokenEndpointUris("/connect/token"); opts.AllowPasswordFlow().AllowRefreshTokenFlow(); + // Быстрый вход кассира по PIN на зарегистрированном устройстве + // (grant_type=pin, параметры retail_point_id + pin). См. AuthorizationController. + opts.AllowCustomFlow("pin"); opts.AcceptAnonymousClients(); opts.RegisterScopes(Scopes.OpenId, Scopes.Profile, Scopes.Email, Scopes.Roles, "api"); diff --git a/src/food-market.api/Seed/OpenIddictClientSeeder.cs b/src/food-market.api/Seed/OpenIddictClientSeeder.cs index b34e392..c232387 100644 --- a/src/food-market.api/Seed/OpenIddictClientSeeder.cs +++ b/src/food-market.api/Seed/OpenIddictClientSeeder.cs @@ -8,6 +8,10 @@ public class OpenIddictClientSeeder : IHostedService public const string WebClientId = "food-market-web"; public const string PosClientId = "food-market-pos"; + // Custom grant «pin» — быстрый вход кассира (grant_type=pin). Разрешён + // клиенту отдельным permission gt:pin. + private const string PinGrantPermission = Permissions.Prefixes.GrantType + "pin"; + private readonly IServiceProvider _services; public OpenIddictClientSeeder(IServiceProvider services) @@ -15,50 +19,54 @@ public OpenIddictClientSeeder(IServiceProvider services) _services = services; } + private static OpenIddictApplicationDescriptor Descriptor(string clientId, string displayName) => new() + { + ClientId = clientId, + DisplayName = displayName, + ClientType = ClientTypes.Public, + Permissions = + { + Permissions.Endpoints.Token, + Permissions.GrantTypes.Password, + Permissions.GrantTypes.RefreshToken, + PinGrantPermission, + Permissions.Scopes.Email, + Permissions.Scopes.Profile, + Permissions.Scopes.Roles, + Permissions.Prefixes.Scope + "api", + } + }; + public async Task StartAsync(CancellationToken ct) { using var scope = _services.CreateScope(); var mgr = scope.ServiceProvider.GetRequiredService(); - if (await mgr.FindByClientIdAsync(WebClientId, ct) is null) + await EnsureClientAsync(mgr, WebClientId, "food-market web admin", ct); + await EnsureClientAsync(mgr, PosClientId, "food-market POS client", ct); + } + + /// Создаёт клиента, либо (если уже есть) досоздаёт недостающие + /// permissions — например, gt:pin на уже засиженных стендах/проде, чтобы + /// добавление нового grant-type не требовало пересоздания клиента. + private static async Task EnsureClientAsync(IOpenIddictApplicationManager mgr, string clientId, string displayName, CancellationToken ct) + { + var existing = await mgr.FindByClientIdAsync(clientId, ct); + if (existing is null) { - await mgr.CreateAsync(new OpenIddictApplicationDescriptor - { - ClientId = WebClientId, - DisplayName = "food-market web admin", - ClientType = ClientTypes.Public, - Permissions = - { - Permissions.Endpoints.Token, - Permissions.GrantTypes.Password, - Permissions.GrantTypes.RefreshToken, - Permissions.Scopes.Email, - Permissions.Scopes.Profile, - Permissions.Scopes.Roles, - Permissions.Prefixes.Scope + "api", - } - }, ct); + await mgr.CreateAsync(Descriptor(clientId, displayName), ct); + return; } - if (await mgr.FindByClientIdAsync(PosClientId, ct) is null) - { - await mgr.CreateAsync(new OpenIddictApplicationDescriptor - { - ClientId = PosClientId, - DisplayName = "food-market POS client", - ClientType = ClientTypes.Public, - Permissions = - { - Permissions.Endpoints.Token, - Permissions.GrantTypes.Password, - Permissions.GrantTypes.RefreshToken, - Permissions.Scopes.Email, - Permissions.Scopes.Profile, - Permissions.Scopes.Roles, - Permissions.Prefixes.Scope + "api", - } - }, ct); - } + // Добавить отсутствующие permissions (идемпотентно). + var desc = new OpenIddictApplicationDescriptor(); + await mgr.PopulateAsync(desc, existing, ct); + var want = Descriptor(clientId, displayName).Permissions; + var changed = false; + foreach (var p in want) + if (!desc.Permissions.Contains(p)) { desc.Permissions.Add(p); changed = true; } + if (changed) + await mgr.UpdateAsync(existing, desc, ct); } public Task StopAsync(CancellationToken ct) => Task.CompletedTask; diff --git a/src/food-market.application/Common/PinHasher.cs b/src/food-market.application/Common/PinHasher.cs new file mode 100644 index 0000000..bcaca83 --- /dev/null +++ b/src/food-market.application/Common/PinHasher.cs @@ -0,0 +1,37 @@ +using System.Security.Cryptography; +using System.Text; + +namespace foodmarket.Application.Common; + +/// Хэширование PIN-кода кассира (PBKDF2-HMAC-SHA256 со случайной солью). +/// PIN низкоэнтропийный (4–6 цифр), поэтому защита — хэш + серверный lockout +/// (5 неверных → блок). Формат хранения: «base64(salt).base64(key)». +public static class PinHasher +{ + private const int SaltSize = 16; + private const int KeySize = 32; + private const int Iterations = 100_000; + + public static string Hash(string pin) + { + var salt = RandomNumberGenerator.GetBytes(SaltSize); + var key = Rfc2898DeriveBytes.Pbkdf2(Encoding.UTF8.GetBytes(pin), salt, Iterations, HashAlgorithmName.SHA256, KeySize); + return $"{Convert.ToBase64String(salt)}.{Convert.ToBase64String(key)}"; + } + + public static bool Verify(string pin, string? stored) + { + if (string.IsNullOrEmpty(stored)) return false; + var parts = stored.Split('.'); + if (parts.Length != 2) return false; + byte[] salt, key; + try { salt = Convert.FromBase64String(parts[0]); key = Convert.FromBase64String(parts[1]); } + catch { return false; } + var test = Rfc2898DeriveBytes.Pbkdf2(Encoding.UTF8.GetBytes(pin), salt, Iterations, HashAlgorithmName.SHA256, key.Length); + return CryptographicOperations.FixedTimeEquals(test, key); + } + + /// PIN — 4–6 цифр. + public static bool IsValidPin(string? pin) + => pin is not null && pin.Length is >= 4 and <= 6 && pin.All(char.IsDigit); +} diff --git a/src/food-market.domain/Organizations/Employee.cs b/src/food-market.domain/Organizations/Employee.cs index 8650b15..1451634 100644 --- a/src/food-market.domain/Organizations/Employee.cs +++ b/src/food-market.domain/Organizations/Employee.cs @@ -46,6 +46,13 @@ public class Employee : TenantEntity public ICollection RetailPointAssignments { get; set; } = new List(); + + // ─ Быстрый вход кассира по PIN на зарегистрированном устройстве ───── + /// Хэш PIN-кода (PBKDF2 с солью, base64). null — PIN не задан, + /// вход только по паролю. Задаёт администратор в карточке сотрудника. + /// Брутфорс-защита (5 неверных → блок 5 мин) — на уровне точки (in-memory), + /// т.к. вход по PIN без логина не идентифицирует конкретного сотрудника. + public string? PinHash { get; set; } } /// Привязка сотрудника к кассе (для роли Кассир): к каким RetailPoint'ам diff --git a/src/food-market.infrastructure/Persistence/Migrations/20261008090000_Phase36_EmployeePin.cs b/src/food-market.infrastructure/Persistence/Migrations/20261008090000_Phase36_EmployeePin.cs new file mode 100644 index 0000000..093a6d6 --- /dev/null +++ b/src/food-market.infrastructure/Persistence/Migrations/20261008090000_Phase36_EmployeePin.cs @@ -0,0 +1,23 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using foodmarket.Infrastructure.Persistence; + +#nullable disable + +namespace foodmarket.Infrastructure.Persistence.Migrations +{ + /// Phase36 — быстрый вход кассира по PIN на зарегистрированном + /// устройстве. Добавляет в employees PinHash text (PBKDF2-хэш + /// PIN, null = не задан). Идемпотентно. Вход по паролю не меняется. + /// Брутфорс-защита (5 неверных → блок 5 мин) — in-memory на уровне точки. + [DbContext(typeof(AppDbContext))] + [Migration("20261008090000_Phase36_EmployeePin")] + public partial class Phase36_EmployeePin : Migration + { + protected override void Up(MigrationBuilder b) + => b.Sql(@"ALTER TABLE public.employees ADD COLUMN IF NOT EXISTS ""PinHash"" text;"); + + protected override void Down(MigrationBuilder b) + => b.Sql(@"ALTER TABLE public.employees DROP COLUMN IF EXISTS ""PinHash"";"); + } +} diff --git a/src/food-market.pos.core/Auth/AuthService.cs b/src/food-market.pos.core/Auth/AuthService.cs index f5979c1..1e2b01f 100644 --- a/src/food-market.pos.core/Auth/AuthService.cs +++ b/src/food-market.pos.core/Auth/AuthService.cs @@ -30,6 +30,30 @@ public async Task LoginAsync(string serverUrl, string username, stri return Parse(body); } + /// Быстрый вход кассира по PIN (grant_type=pin) на зарегистрированном + /// устройстве: сервер находит кассира, привязанного к точке, по PIN. + public async Task PinLoginAsync(string serverUrl, Guid retailPointId, string pin, CancellationToken ct = default) + { + var form = new FormUrlEncodedContent(new Dictionary + { + ["grant_type"] = "pin", + ["retail_point_id"] = retailPointId.ToString(), + ["pin"] = pin, + ["client_id"] = "food-market-web", + ["scope"] = "openid profile email roles api offline_access", + }); + using var resp = await _http.PostAsync(serverUrl.TrimEnd('/') + "/connect/token", form, ct); + var body = await resp.Content.ReadAsStringAsync(ct); + if (!resp.IsSuccessStatusCode) + { + var desc = DescribeError(body) ?? ""; + if (desc.StartsWith("pin_locked")) throw new AuthException("Слишком много неверных попыток. Вход по PIN заблокирован на 5 минут — войдите по паролю."); + if (desc.StartsWith("pin_invalid")) throw new AuthException("Неверный PIN."); + throw new AuthException(string.IsNullOrEmpty(desc) ? "Вход по PIN не удался." : desc); + } + return Parse(body); + } + public async Task RefreshAsync(string serverUrl, string refreshToken, CancellationToken ct = default) { var form = new FormUrlEncodedContent(new Dictionary diff --git a/src/food-market.pos/Tooling/ScreenshotRunner.cs b/src/food-market.pos/Tooling/ScreenshotRunner.cs index 6e530f9..3f33bea 100644 --- a/src/food-market.pos/Tooling/ScreenshotRunner.cs +++ b/src/food-market.pos/Tooling/ScreenshotRunner.cs @@ -30,7 +30,8 @@ public static int Run(string[] args) var shots = new List { - new("01-login", "Вход кассира (адрес сервера, логин, PIN)", () => new LoginView { DataContext = LoginViewModel.Sample() }), + new("01-login", "Вход кассира (адрес сервера, логин, пароль)", () => new LoginView { DataContext = LoginViewModel.Sample() }), + new("01b-login-pin", "Быстрый вход по PIN (зарегистрированное устройство)", () => new LoginView { DataContext = LoginViewModel.SamplePin() }), new("02-shift-open", "Смена: Z-отчёт/состояние смены", () => new ShiftView { DataContext = ShiftViewModel.Sample() }), new("03-sale-empty", "Продажа: пустой чек, ожидание сканирования", () => new SaleView { DataContext = new SaleViewModel() }), new("04-sale-items", "Продажа: чек с товарами (вкл. весовой)", () => new SaleView { DataContext = SaleViewModel.Sample() }), diff --git a/src/food-market.pos/ViewModels/ViewModels.cs b/src/food-market.pos/ViewModels/ViewModels.cs index 7c69de3..81de6df 100644 --- a/src/food-market.pos/ViewModels/ViewModels.cs +++ b/src/food-market.pos/ViewModels/ViewModels.cs @@ -147,9 +147,11 @@ public partial class RetailPointOption : ObservableObject public partial class LoginViewModel : ViewModelBase { private const string DevicePointKey = "device.retailPointId"; + private const string DeviceUrlKey = "device.serverUrl"; private readonly AppServices? _svc; private PosSession? _pending; // сессия, ожидающая выбора точки private bool _canManagePoints; + private Guid? _devicePoint; [ObservableProperty] private string _serverUrl = "https://test.admin.food-market.kz"; [ObservableProperty] private string _username = ""; @@ -162,10 +164,25 @@ public partial class LoginViewModel : ViewModelBase [ObservableProperty] private RetailPointOption? _selectedPoint; public ObservableCollection Points { get; } = new(); + // Быстрый вход по PIN (на зарегистрированном устройстве). + [ObservableProperty] private bool _pinMode; + [ObservableProperty] private bool _deviceRegistered; + [ObservableProperty] private string _pinEntry = ""; + public string PinMasked => new string('●', PinEntry.Length); + partial void OnPinEntryChanged(string value) => OnPropertyChanged(nameof(PinMasked)); + public Action? OnLoggedIn; public LoginViewModel() { } - public LoginViewModel(AppServices svc) { _svc = svc; } + public LoginViewModel(AppServices svc) + { + _svc = svc; + _devicePoint = Guid.TryParse(svc.Store.GetKv(DevicePointKey), out var dp) ? dp : null; + var savedUrl = svc.Store.GetKv(DeviceUrlKey); + if (!string.IsNullOrEmpty(savedUrl)) ServerUrl = savedUrl; + DeviceRegistered = _devicePoint is not null; + PinMode = DeviceRegistered && !string.IsNullOrEmpty(savedUrl); + } [RelayCommand] private async Task LoginAsync() @@ -199,6 +216,7 @@ private async Task LoginAsync() { case DeviceRegistration.Decision.Proceed: session.RetailPointId = devicePoint; + _svc.Store.SetKv(DeviceUrlKey, ServerUrl.Trim()); // для быстрого входа по PIN _svc.Tokens.Save(session); _svc.Session = session; OnLoggedIn?.Invoke(); break; @@ -230,12 +248,46 @@ private void ConfirmPoint() { if (_pending is null || SelectedPoint is null) { Error = "Выберите точку продаж"; return; } _svc!.Store.SetKv(DevicePointKey, SelectedPoint.Id.ToString()); + _svc.Store.SetKv(DeviceUrlKey, ServerUrl.Trim()); _pending.RetailPointId = SelectedPoint.Id; _svc.Tokens.Save(_pending); _svc.Session = _pending; RequirePointSelection = false; OnLoggedIn?.Invoke(); } + // ── Быстрый вход по PIN ─────────────────────────────────────────────── + [RelayCommand] private void PinDigit(string d) { if (PinEntry.Length < 6) PinEntry += d; } + [RelayCommand] private void PinBack() { if (PinEntry.Length > 0) PinEntry = PinEntry[..^1]; } + [RelayCommand] private void UsePassword() { PinMode = false; Error = null; } + [RelayCommand] private void UsePin() { if (DeviceRegistered) { PinMode = true; Error = null; } } + + [RelayCommand] + private async Task PinLogin() + { + if (_svc is null || _devicePoint is null) { PinMode = false; return; } + if (PinEntry.Length < 4) { Error = "Введите PIN (4–6 цифр)"; return; } + Error = null; Busy = true; + try + { + var t = await _svc.Auth.PinLoginAsync(ServerUrl.Trim(), _devicePoint.Value, PinEntry); + var session = new PosSession { ServerUrl = ServerUrl.Trim(), AccessToken = t.AccessToken, RefreshToken = t.RefreshToken, RetailPointId = _devicePoint }; + _svc.ConfigureApi(session); + try + { + var me = await _svc.Api!.MeAsync(); + session.CashierName = me.name ?? "кассир"; + session.Roles = me.roles; + if (Guid.TryParse(me.sub, out var uid)) session.CashierUserId = uid; + } + catch { session.CashierName = "кассир"; } + _svc.Tokens.Save(session); _svc.Session = session; + OnLoggedIn?.Invoke(); + } + catch (AuthException ex) { Error = ex.Message; PinEntry = ""; } + catch (Exception ex) { Error = "Нет связи с сервером: " + ex.Message; PinEntry = ""; } + finally { Busy = false; } + } + /// Сбросить регистрацию устройства (сменить точку). Новую точку /// сможет выбрать только администратор (кассир после сброса получит /// «устройство не зарегистрировано»). @@ -263,6 +315,7 @@ private async Task LoadActivePointsAsync() } public static LoginViewModel Sample() => new() { Username = "cashier@food-market.kz" }; + public static LoginViewModel SamplePin() => new() { PinMode = true, DeviceRegistered = true, PinEntry = "1234" }; } public partial class SaleLineVm : ObservableObject diff --git a/src/food-market.pos/Views/LoginView.axaml b/src/food-market.pos/Views/LoginView.axaml index 818f60f..c79798d 100644 --- a/src/food-market.pos/Views/LoginView.axaml +++ b/src/food-market.pos/Views/LoginView.axaml @@ -8,6 +8,31 @@ + + + + + + + + + + diff --git a/tests/food-market.IntegrationTests/PinLoginTests.cs b/tests/food-market.IntegrationTests/PinLoginTests.cs new file mode 100644 index 0000000..e7ed1aa --- /dev/null +++ b/tests/food-market.IntegrationTests/PinLoginTests.cs @@ -0,0 +1,121 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using FluentAssertions; +using foodmarket.IntegrationTests.Support; +using Xunit; + +namespace foodmarket.IntegrationTests; + +/// Быстрый вход кассира по PIN (grant_type=pin) на зарегистрированном +/// устройстве: PIN задаёт админ, касса входит по (точка + PIN); 5 неверных → блок. +[Collection(ApiCollection.Name)] +public class PinLoginTests +{ + private readonly ApiFactory _factory; + public PinLoginTests(ApiFactory factory) => _factory = factory; + + private async Task PinLoginAsync(HttpClient client, string pointId, string pin) + => await client.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary + { + ["grant_type"] = "pin", ["client_id"] = "food-market-web", + ["retail_point_id"] = pointId, ["pin"] = pin, + ["scope"] = "openid profile email roles api offline_access", + })); + + [Fact] + public async Task Cashier_logs_in_by_pin_on_assigned_point() + { + var admin = new ApiActor(_factory.CreateClient()); + await admin.SignupAndLoginAsync($"pin-{Guid.NewGuid():N}"); + var refs = await admin.LoadRefsAsync(); + var point = await admin.CreateRetailPointAsync(refs.StoreId, $"Точка-{Guid.NewGuid():N}"); + + var roleResp = await admin.Http.PostAsJsonAsync("/api/organization/employee-roles", new + { + name = $"Касса-{Guid.NewGuid():N}", + permissions = new { retailSalesOperate = true, cashShiftOperate = true, productsView = true }, + }); + roleResp.EnsureSuccessStatusCode(); + var roleId = (await roleResp.Content.ReadFromJsonAsync()).GetProperty("id").GetString(); + + var email = $"pin-c-{Guid.NewGuid():N}@example.kz"; + var empResp = await admin.Http.PostAsJsonAsync("/api/organization/employees", new + { + lastName = "Кассиров", firstName = "Пётр", roleId, isActive = true, createAccount = true, email, + retailPointIds = new[] { point }, + }); + empResp.EnsureSuccessStatusCode(); + var empId = (await empResp.Content.ReadFromJsonAsync()).GetProperty("employee").GetProperty("id").GetString(); + + // Админ задаёт PIN. + var setPin = await admin.Http.PostAsJsonAsync($"/api/organization/employees/{empId}/pin", new { pin = "4321" }); + setPin.StatusCode.Should().Be(HttpStatusCode.OK); + + // Вход по PIN (точка + PIN) → токен. + var anon = _factory.CreateClient(); + using var ok = await PinLoginAsync(anon, point, "4321"); + ok.StatusCode.Should().Be(HttpStatusCode.OK, await ok.Content.ReadAsStringAsync()); + var token = (await ok.Content.ReadFromJsonAsync()).GetProperty("access_token").GetString(); + token.Should().NotBeNullOrEmpty(); + + // Токен рабочий: /api/me отдаёт точку кассира. + anon.DefaultRequestHeaders.Authorization = new("Bearer", token); + var me = await anon.GetFromJsonAsync("/api/me"); + me.GetProperty("retailPointIds").EnumerateArray().Select(x => x.GetString()).Should().Contain(point); + } + + [Fact] + public async Task Wrong_pin_is_rejected_and_locks_after_five() + { + var admin = new ApiActor(_factory.CreateClient()); + await admin.SignupAndLoginAsync($"pinlock-{Guid.NewGuid():N}"); + var refs = await admin.LoadRefsAsync(); + var point = await admin.CreateRetailPointAsync(refs.StoreId, $"Точка-{Guid.NewGuid():N}"); + var roleResp = await admin.Http.PostAsJsonAsync("/api/organization/employee-roles", new + { name = $"Касса-{Guid.NewGuid():N}", permissions = new { cashShiftOperate = true } }); + var roleId = (await roleResp.Content.ReadFromJsonAsync()).GetProperty("id").GetString(); + var email = $"pinlock-c-{Guid.NewGuid():N}@example.kz"; + var empResp = await admin.Http.PostAsJsonAsync("/api/organization/employees", new + { lastName = "К", firstName = "П", roleId, isActive = true, createAccount = true, email, retailPointIds = new[] { point } }); + var empId = (await empResp.Content.ReadFromJsonAsync()).GetProperty("employee").GetProperty("id").GetString(); + (await admin.Http.PostAsJsonAsync($"/api/organization/employees/{empId}/pin", new { pin = "1111" })).EnsureSuccessStatusCode(); + + var anon = _factory.CreateClient(); + // 5 неверных попыток. + for (int i = 0; i < 5; i++) + { + using var bad = await PinLoginAsync(anon, point, "0000"); + bad.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + // Теперь точка заблокирована: даже верный PIN не проходит. + using var locked = await PinLoginAsync(anon, point, "1111"); + locked.StatusCode.Should().Be(HttpStatusCode.BadRequest); + (await locked.Content.ReadAsStringAsync()).Should().Contain("pin_locked"); + } + + [Fact] + public async Task Duplicate_pin_on_same_point_is_rejected() + { + var admin = new ApiActor(_factory.CreateClient()); + await admin.SignupAndLoginAsync($"pindup-{Guid.NewGuid():N}"); + var refs = await admin.LoadRefsAsync(); + var point = await admin.CreateRetailPointAsync(refs.StoreId, $"Точка-{Guid.NewGuid():N}"); + var roleResp = await admin.Http.PostAsJsonAsync("/api/organization/employee-roles", new + { name = $"Касса-{Guid.NewGuid():N}", permissions = new { cashShiftOperate = true } }); + var roleId = (await roleResp.Content.ReadFromJsonAsync()).GetProperty("id").GetString(); + + async Task MakeEmp(string ln) + { + var r = await admin.Http.PostAsJsonAsync("/api/organization/employees", new + { lastName = ln, firstName = "П", roleId, isActive = true, createAccount = true, email = $"dup-{Guid.NewGuid():N}@example.kz", retailPointIds = new[] { point } }); + r.EnsureSuccessStatusCode(); + return (await r.Content.ReadFromJsonAsync()).GetProperty("employee").GetProperty("id").GetString()!; + } + var e1 = await MakeEmp("Первый"); + var e2 = await MakeEmp("Второй"); + (await admin.Http.PostAsJsonAsync($"/api/organization/employees/{e1}/pin", new { pin = "2468" })).EnsureSuccessStatusCode(); + using var dup = await admin.Http.PostAsJsonAsync($"/api/organization/employees/{e2}/pin", new { pin = "2468" }); + dup.StatusCode.Should().Be(HttpStatusCode.Conflict); + } +} diff --git a/tests/food-market.UnitTests/PinHasherTests.cs b/tests/food-market.UnitTests/PinHasherTests.cs new file mode 100644 index 0000000..51f0a1d --- /dev/null +++ b/tests/food-market.UnitTests/PinHasherTests.cs @@ -0,0 +1,40 @@ +using FluentAssertions; +using foodmarket.Application.Common; +using Xunit; + +namespace foodmarket.UnitTests; + +public class PinHasherTests +{ + [Fact] + public void Hash_then_verify_roundtrips() + { + var hash = PinHasher.Hash("1234"); + PinHasher.Verify("1234", hash).Should().BeTrue(); + PinHasher.Verify("1235", hash).Should().BeFalse(); + } + + [Fact] + public void Hash_is_salted_different_each_time() + { + PinHasher.Hash("4321").Should().NotBe(PinHasher.Hash("4321")); + } + + [Fact] + public void Verify_handles_null_and_garbage() + { + PinHasher.Verify("1234", null).Should().BeFalse(); + PinHasher.Verify("1234", "").Should().BeFalse(); + PinHasher.Verify("1234", "not-a-hash").Should().BeFalse(); + } + + [Theory] + [InlineData("1234", true)] + [InlineData("123456", true)] + [InlineData("123", false)] + [InlineData("1234567", false)] + [InlineData("12a4", false)] + [InlineData(null, false)] + public void IsValidPin_rules(string? pin, bool expected) + => PinHasher.IsValidPin(pin).Should().Be(expected); +}