feat(pos): быстрый вход кассира по PIN (grant_type=pin) + lockout
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions

- Сервер: Employee.PinHash (PBKDF2, миграция Phase36); POST
  /api/organization/employees/{id}/pin — админ задаёт/снимает PIN (4–6 цифр,
  уникален среди активных сотрудников на пересекающихся точках). Кастомный
  OpenIddict-грант grant_type=pin (retail_point_id + pin): находит кассира,
  привязанного к точке, по PIN и выдаёт токены. Брутфорс-защита — in-memory по
  точке (5 неверных → блок 5 мин). Клиент-сидер досоздаёт gt:pin существующим
  клиентам (без пересоздания). Пароль остаётся запасным.
- Касса: PIN-пад на зарегистрированном устройстве (маскированный ввод, 0–9/⌫/
  Войти), переключение на вход по логину; AuthService.PinLoginAsync.
- Тесты: unit PinHasher (9), integration (3): вход по PIN выдаёт рабочий токен;
  5 неверных → блок; дубль PIN на точке → 409. + скриншот PIN-пада в галерее.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
nns 2026-10-08 09:38:36 +05:00
parent ff69dec527
commit 6b959ac59d
13 changed files with 507 additions and 38 deletions

View file

@ -138,9 +138,89 @@ public async Task<IActionResult> 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 не поддерживается.");
}
/// <summary>In-memory брутфорс-защита PIN-входа по точке продаж: 5 неверных
/// подряд → блок на 5 минут. Сбрасывается при успешном входе. Процесс-локально
/// (стенд — один инстанс); достаточно против перебора 4–6-значного PIN.</summary>
private static class PinLockout
{
private const int MaxFails = 5;
private static readonly TimeSpan LockFor = TimeSpan.FromMinutes(5);
private static readonly System.Collections.Concurrent.ConcurrentDictionary<Guid, (int Fails, DateTime? Until)> 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;
/// <summary>Регистрирует неудачу; возвращает true если точка теперь заблокирована.</summary>
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 _);
}
/// <summary>SuperAdmin-у org не нужна — он работает на уровне платформы.
/// Любой другой пользователь обязан указывать на живую (не архивную)
/// Organization. Если связь оборвана (org удалена / архивирована) —

View file

@ -219,6 +219,50 @@ public async Task<ActionResult<EmployeeCreateResult>> Create([FromBody] Employee
return new EmployeeCreateResult(dto!, tempPassword);
}
public record SetPinInput(string? Pin);
/// <summary>Задать/сменить/снять PIN кассира (быстрый вход на кассе).
/// Пустой PIN — снять. PIN — 4–6 цифр, уникален среди активных сотрудников
/// на пересекающихся точках продаж (иначе вход по PIN неоднозначен).</summary>
[HttpPost("{id:guid}/pin"), Authorize(Roles = "SuperAdmin,Admin")]
public async Task<IActionResult> 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<IActionResult> Update(Guid id, [FromBody] EmployeeInput input, CancellationToken ct)
{

View file

@ -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");

View file

@ -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<IOpenIddictApplicationManager>();
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);
}
/// <summary>Создаёт клиента, либо (если уже есть) досоздаёт недостающие
/// permissions — например, gt:pin на уже засиженных стендах/проде, чтобы
/// добавление нового grant-type не требовало пересоздания клиента.</summary>
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;

View file

@ -0,0 +1,37 @@
using System.Security.Cryptography;
using System.Text;
namespace foodmarket.Application.Common;
/// <summary>Хэширование PIN-кода кассира (PBKDF2-HMAC-SHA256 со случайной солью).
/// PIN низкоэнтропийный (4–6 цифр), поэтому защита — хэш + серверный lockout
/// (5 неверных → блок). Формат хранения: «base64(salt).base64(key)».</summary>
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);
}
/// <summary>PIN — 4–6 цифр.</summary>
public static bool IsValidPin(string? pin)
=> pin is not null && pin.Length is >= 4 and <= 6 && pin.All(char.IsDigit);
}

View file

@ -46,6 +46,13 @@ public class Employee : TenantEntity
public ICollection<EmployeeRetailPointAssignment> RetailPointAssignments { get; set; }
= new List<EmployeeRetailPointAssignment>();
// ─ Быстрый вход кассира по PIN на зарегистрированном устройстве ─────
/// <summary>Хэш PIN-кода (PBKDF2 с солью, base64). null — PIN не задан,
/// вход только по паролю. Задаёт администратор в карточке сотрудника.
/// Брутфорс-защита (5 неверных → блок 5 мин) — на уровне точки (in-memory),
/// т.к. вход по PIN без логина не идентифицирует конкретного сотрудника.</summary>
public string? PinHash { get; set; }
}
/// <summary>Привязка сотрудника к кассе (для роли Кассир): к каким RetailPoint'ам

View file

@ -0,0 +1,23 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using foodmarket.Infrastructure.Persistence;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <summary>Phase36 — быстрый вход кассира по PIN на зарегистрированном
/// устройстве. Добавляет в <c>employees</c> <c>PinHash</c> text (PBKDF2-хэш
/// PIN, null = не задан). Идемпотентно. Вход по паролю не меняется.
/// Брутфорс-защита (5 неверных → блок 5 мин) — in-memory на уровне точки.</summary>
[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"";");
}
}

View file

@ -30,6 +30,30 @@ public async Task<AuthTokens> LoginAsync(string serverUrl, string username, stri
return Parse(body);
}
/// <summary>Быстрый вход кассира по PIN (grant_type=pin) на зарегистрированном
/// устройстве: сервер находит кассира, привязанного к точке, по PIN.</summary>
public async Task<AuthTokens> PinLoginAsync(string serverUrl, Guid retailPointId, string pin, CancellationToken ct = default)
{
var form = new FormUrlEncodedContent(new Dictionary<string, string>
{
["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<AuthTokens> RefreshAsync(string serverUrl, string refreshToken, CancellationToken ct = default)
{
var form = new FormUrlEncodedContent(new Dictionary<string, string>

View file

@ -30,7 +30,8 @@ public static int Run(string[] args)
var shots = new List<Shot>
{
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() }),

View file

@ -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<RetailPointOption> 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; }
}
/// <summary>Сбросить регистрацию устройства (сменить точку). Новую точку
/// сможет выбрать только администратор (кассир после сброса получит
/// «устройство не зарегистрировано»).</summary>
@ -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

View file

@ -8,6 +8,31 @@
<StackPanel Spacing="14">
<TextBlock Text="FOOD MARKET" Foreground="#22c55e" FontWeight="Bold" FontSize="22" HorizontalAlignment="Center"/>
<TextBlock Text="Вход кассира" Foreground="#f8fafc" FontSize="18" HorizontalAlignment="Center"/>
<!-- Быстрый вход по PIN (зарегистрированное устройство) -->
<StackPanel Spacing="10" IsVisible="{Binding PinMode}">
<Border Background="#0b1220" CornerRadius="8" Height="56">
<TextBlock Text="{Binding PinMasked}" Foreground="#f8fafc" FontSize="30" LetterSpacing="6" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<TextBlock Text="{Binding Error}" Foreground="#fca5a5" TextWrapping="Wrap" HorizontalAlignment="Center" IsVisible="{Binding Error, Converter={x:Static ObjectConverters.IsNotNull}}"/>
<Grid ColumnDefinitions="*,*,*" RowDefinitions="Auto,Auto,Auto,Auto" >
<Button Grid.Row="0" Grid.Column="0" Classes="pos" Margin="3" Height="56" FontSize="22" Content="1" Command="{Binding PinDigitCommand}" CommandParameter="1"/>
<Button Grid.Row="0" Grid.Column="1" Classes="pos" Margin="3" Height="56" FontSize="22" Content="2" Command="{Binding PinDigitCommand}" CommandParameter="2"/>
<Button Grid.Row="0" Grid.Column="2" Classes="pos" Margin="3" Height="56" FontSize="22" Content="3" Command="{Binding PinDigitCommand}" CommandParameter="3"/>
<Button Grid.Row="1" Grid.Column="0" Classes="pos" Margin="3" Height="56" FontSize="22" Content="4" Command="{Binding PinDigitCommand}" CommandParameter="4"/>
<Button Grid.Row="1" Grid.Column="1" Classes="pos" Margin="3" Height="56" FontSize="22" Content="5" Command="{Binding PinDigitCommand}" CommandParameter="5"/>
<Button Grid.Row="1" Grid.Column="2" Classes="pos" Margin="3" Height="56" FontSize="22" Content="6" Command="{Binding PinDigitCommand}" CommandParameter="6"/>
<Button Grid.Row="2" Grid.Column="0" Classes="pos" Margin="3" Height="56" FontSize="22" Content="7" Command="{Binding PinDigitCommand}" CommandParameter="7"/>
<Button Grid.Row="2" Grid.Column="1" Classes="pos" Margin="3" Height="56" FontSize="22" Content="8" Command="{Binding PinDigitCommand}" CommandParameter="8"/>
<Button Grid.Row="2" Grid.Column="2" Classes="pos" Margin="3" Height="56" FontSize="22" Content="9" Command="{Binding PinDigitCommand}" CommandParameter="9"/>
<Button Grid.Row="3" Grid.Column="0" Classes="pos" Margin="3" Height="56" FontSize="18" Content="⌫" Command="{Binding PinBackCommand}"/>
<Button Grid.Row="3" Grid.Column="1" Classes="pos" Margin="3" Height="56" FontSize="22" Content="0" Command="{Binding PinDigitCommand}" CommandParameter="0"/>
<Button Grid.Row="3" Grid.Column="2" Classes="pos primary" Margin="3" Height="56" FontSize="18" Content="Войти" Command="{Binding PinLoginCommand}" IsEnabled="{Binding !Busy}"/>
</Grid>
<Button Classes="pos" HorizontalAlignment="Stretch" Height="40" FontSize="13" Content="Вход по логину и паролю" Command="{Binding UsePasswordCommand}"/>
</StackPanel>
<StackPanel Spacing="14" IsVisible="{Binding !PinMode}">
<StackPanel Spacing="4">
<TextBlock Text="Адрес сервера" Foreground="#94a3b8"/>
<TextBox Classes="pos" Text="{Binding ServerUrl}" FontSize="16"/>
@ -30,9 +55,12 @@
<Button Classes="pos primary" HorizontalAlignment="Stretch" Height="52" Content="Зарегистрировать на этой точке" Command="{Binding ConfirmPointCommand}"/>
</StackPanel>
<Button Classes="pos" HorizontalAlignment="Stretch" Height="40" FontSize="13" Content="Быстрый вход по PIN" Command="{Binding UsePinCommand}" IsVisible="{Binding DeviceRegistered}"/>
<Button Classes="pos" HorizontalAlignment="Stretch" Height="40" FontSize="13" Command="{Binding ResetDeviceCommand}" ToolTip.Tip="Сбросить регистрацию устройства. Новую точку выберет только администратор.">
<TextBlock Text="Сменить точку продаж (админ)" TextWrapping="Wrap" TextAlignment="Center"/>
</Button>
</StackPanel>
</StackPanel>
</Border>
</Grid>

View file

@ -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;
/// <summary>Быстрый вход кассира по PIN (grant_type=pin) на зарегистрированном
/// устройстве: PIN задаёт админ, касса входит по (точка + PIN); 5 неверных → блок.</summary>
[Collection(ApiCollection.Name)]
public class PinLoginTests
{
private readonly ApiFactory _factory;
public PinLoginTests(ApiFactory factory) => _factory = factory;
private async Task<HttpResponseMessage> PinLoginAsync(HttpClient client, string pointId, string pin)
=> await client.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary<string, string>
{
["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<JsonElement>()).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<JsonElement>()).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<JsonElement>()).GetProperty("access_token").GetString();
token.Should().NotBeNullOrEmpty();
// Токен рабочий: /api/me отдаёт точку кассира.
anon.DefaultRequestHeaders.Authorization = new("Bearer", token);
var me = await anon.GetFromJsonAsync<JsonElement>("/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<JsonElement>()).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<JsonElement>()).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<JsonElement>()).GetProperty("id").GetString();
async Task<string> 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<JsonElement>()).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);
}
}

View file

@ -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);
}