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
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:
parent
ff69dec527
commit
6b959ac59d
|
|
@ -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 удалена / архивирована) —
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
37
src/food-market.application/Common/PinHasher.cs
Normal file
37
src/food-market.application/Common/PinHasher.cs
Normal 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);
|
||||
}
|
||||
|
|
@ -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'ам
|
||||
|
|
|
|||
|
|
@ -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"";");
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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() }),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
121
tests/food-market.IntegrationTests/PinLoginTests.cs
Normal file
121
tests/food-market.IntegrationTests/PinLoginTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
40
tests/food-market.UnitTests/PinHasherTests.cs
Normal file
40
tests/food-market.UnitTests/PinHasherTests.cs
Normal 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);
|
||||
}
|
||||
Loading…
Reference in a new issue