fix(security): Wave 2 — fix 8 P1 frontend security & reliability issues

SEC-W-11: Remove hardcoded OAuth2 client_id/client_secret from Blazor WASM.
  - Create BffAuthController (POST /api/bff/auth/login|logout, GET /api/bff/auth/session)
  - BFF exchanges credentials with IS4 using server-side config (IdentityServer:ClientId/Secret)
  - Add IdentityServer config block to appsettings.json / appsettings.Development.json

SEC-W-12: Migrate password grant — token exchange now happens server-side in BFF, not WASM.
  - AuthService.LoginAsync() POSTs to /api/bff/auth/login (no IS4 call from WASM)

SEC-W-01: JWT in localStorage — migrate to httpOnly SameSite=Strict BFF session cookie.
  - BffAuthController sets cookie on login, clears on logout
  - AuthStateService no longer stores raw token (Token property removed)
  - AuthService only stores non-sensitive metadata (email, role) in localStorage
  - TryRestoreSessionAsync now calls GET /api/bff/auth/session instead of localStorage
  - AuthForwardingHandler reads token from bff_session cookie (legacy header fallback kept)

FRONT-W-01: Token refresh not implemented — add TokenExpiry tracking + proactive refresh timer.
  - AuthStateService: add TokenExpiry, OnTokenExpiring event, IDisposable Timer
  - Login() schedules a Timer that fires OnTokenExpiring 2 min before expiry

FRONT-W-02: DefaultRequestHeaders race condition — use per-request HttpRequestMessage.
  - PosDataService: remove AttachToken() (mutated shared DefaultRequestHeaders)
  - All HTTP helpers (PostAsync, PutAsync, PostAndGetAsync, GetListFromApiAsync,
    GetObjectFromApiAsync) now use HttpRequestMessage per-request
  - Auth handled automatically by browser cookie (same-origin, httpOnly BFF cookie)

FRONT-C-04: No route guard on AdminLayout — add auth redirect.
  - AdminLayout.OnAfterRenderAsync: after TryRestoreSessionAsync, redirect to /auth/login
    if still unauthenticated (with returnUrl param)

FRONT-C-05: shopId not validated against user permissions — add BFF verification.
  - AdminLayout: call PosData.GetShopByIdAsync(shopId) after detecting shop context
  - Redirect to /admin if BFF returns null (403/404 = no access, prevents IDOR)
  - Populate _shopName/_shopCategory from verified backend data (not just URL)

SEC-W-13: No CDN SRI for Lucide icons — add integrity hash + crossorigin attribute.
  - index.html: add integrity="sha256-NBFpKCDLjUdUP2lJaqJf1gOjWPRJgEb0HFCKWjNCIQ4="
    crossorigin="anonymous" to lucide@0.468.0 script tag

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-03-23 09:57:11 +07:00
parent 619a06fafe
commit 90434acbde
9 changed files with 655 additions and 263 deletions

View File

@@ -11,6 +11,7 @@
@inject IJSRuntime JS
@inject WebClientTpos.Client.Services.AuthStateService AuthState
@inject WebClientTpos.Client.Services.AuthService AuthSvc
@inject WebClientTpos.Client.Services.PosDataService PosData
@inject Microsoft.Extensions.Localization.IStringLocalizer<AdminLayout> L
@using WebClientTpos.Client.Services
@@ -197,29 +198,58 @@
if (firstRender)
{
// EN: Restore session from localStorage if AuthState lost after forceLoad navigation.
// JS interop is only available in OnAfterRenderAsync, not OnInitializedAsync.
// VI: Khôi phục session từ localStorage nếu AuthState mất sau forceLoad navigation.
// JS interop chỉ khả dụng trong OnAfterRenderAsync, không phải OnInitializedAsync.
// EN: Restore session from BFF if AuthState is lost after forceLoad navigation.
// TryRestoreSessionAsync calls /api/bff/auth/session — reads the httpOnly cookie server-side.
// VI: Khôi phục session từ BFF nếu AuthState mất sau forceLoad navigation.
// TryRestoreSessionAsync gọi /api/bff/auth/session — đọc httpOnly cookie phía server.
if (!AuthState.IsAuthenticated)
{
try { await AuthSvc.TryRestoreSessionAsync("owner"); } catch { }
try { await AuthSvc.TryRestoreSessionAsync(); } catch { }
}
// EN: Resolve user display name from localStorage fallback
// VI: Lấy tên hiển thị từ localStorage nếu cần
if (_resolvedUserName == null)
// EN: FRONT-C-04 — Route guard: redirect unauthenticated users to login.
// Checked after session restore so valid sessions are not wrongly rejected.
// VI: FRONT-C-04 — Bảo vệ route: chuyển hướng user chưa xác thực về trang đăng nhập.
// Kiểm tra sau khi khôi phục session để tránh từ chối sai các session hợp lệ.
if (!AuthState.IsAuthenticated)
{
var returnUrl = Uri.EscapeDataString(NavigationManager.Uri);
NavigationManager.NavigateTo($"/auth/login?returnUrl={returnUrl}", forceLoad: false);
return;
}
// EN: FRONT-C-05 — Validate shopId against user permissions via BFF.
// If the shop is not accessible, redirect to admin index to prevent IDOR.
// VI: FRONT-C-05 — Xác thực shopId với quyền user qua BFF.
// Nếu shop không có quyền truy cập, chuyển hướng về admin để ngăn IDOR.
if (_isShopContext && !string.IsNullOrEmpty(_shopId) && Guid.TryParse(_shopId, out var shopGuid))
{
try
{
var email = await JS.InvokeAsync<string?>("localStorage.getItem", "aPOS_email_owner")
?? await JS.InvokeAsync<string?>("localStorage.getItem", "aPOS_email");
if (!string.IsNullOrEmpty(email))
_resolvedUserName = email.Split('@').FirstOrDefault() ?? "Admin";
else if (AuthState.IsAuthenticated && !string.IsNullOrEmpty(AuthState.UserEmail))
_resolvedUserName = AuthState.UserEmail.Split('@').FirstOrDefault() ?? "Admin";
var shop = await PosData.GetShopByIdAsync(shopGuid);
if (shop == null)
{
// EN: BFF returned 404/403 — user has no access to this shop.
// VI: BFF trả về 404/403 — user không có quyền truy cập shop này.
NavigationManager.NavigateTo("/admin", forceLoad: false);
return;
}
// EN: Populate shop context from verified backend data (not just URL).
// VI: Điền thông tin context shop từ dữ liệu backend đã xác thực (không chỉ từ URL).
if (string.IsNullOrEmpty(_shopName) || _shopName.StartsWith("Shop #"))
_shopName = shop.Name;
if (_shopCategory == null && !string.IsNullOrEmpty(shop.Category))
_shopCategory = shop.Category.ToLowerInvariant();
}
catch { /* JS interop not ready */ }
catch { /* Network error — allow render, BFF will 401 on subsequent calls */ }
}
// EN: Resolve user display name from AuthState (restored from BFF session).
// VI: Lấy tên hiển thị từ AuthState (khôi phục từ BFF session).
if (_resolvedUserName == null && AuthState.IsAuthenticated && !string.IsNullOrEmpty(AuthState.UserEmail))
{
_resolvedUserName = AuthState.UserEmail.Split('@').FirstOrDefault() ?? "Admin";
}
StateHasChanged();

View File

@@ -1,9 +1,10 @@
// EN: Authentication service — handles register, login, token management.
// VI: Service xác thực — xử lý đăng ký, đăng nhập, quản lý token.
// EN: Authentication service — delegates all token exchange to the BFF server.
// WASM never handles raw access tokens; they are stored in httpOnly cookies by the BFF.
// VI: Service xác thực — ủy thác trao đổi token cho BFF server.
// WASM không bao giờ xử lý access token thô; chúng được BFF lưu trong httpOnly cookie.
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.JSInterop;
using WebClientTpos.Shared.DTOs;
using WebClientTpos.Shared;
@@ -11,8 +12,10 @@ using WebClientTpos.Shared;
namespace WebClientTpos.Client.Services;
/// <summary>
/// EN: Authentication service for Duende IdentityServer integration.
/// VI: Service xác thực tích hợp Duende IdentityServer.
/// EN: Authentication service — proxies all auth operations through the BFF server.
/// No OAuth2 credentials or raw tokens are present in the WASM client.
/// VI: Service xác thực — proxy tất cả thao tác auth qua BFF server.
/// Không có OAuth2 credentials hay token thô trong WASM client.
/// </summary>
public class AuthService
{
@@ -20,28 +23,15 @@ public class AuthService
private readonly IJSRuntime _js;
private readonly AuthStateService _authState;
// EN: Role-based localStorage keys to prevent token conflict between admin/staff sessions.
// VI: Dùng key localStorage theo role để tránh xung đột token giữa admin/staff.
private const string TokenKeyPrefix = "aPOS_token";
private const string UserEmailKeyPrefix = "aPOS_email";
private const string UserNameKeyPrefix = "aPOS_name";
// EN: Legacy keys for backward compatibility (read-only fallback).
// VI: Key cũ để tương thích ngược (chỉ đọc fallback).
private const string LegacyTokenKey = "aPOS_token";
private const string LegacyEmailKey = "aPOS_email";
private static string TokenKey(string role) => $"{TokenKeyPrefix}_{role}";
private static string UserEmailKey(string role) => $"{UserEmailKeyPrefix}_{role}";
private static string UserNameKey(string role) => $"{UserNameKeyPrefix}_{role}";
// EN: Duende IdentityServer client config
// VI: Cấu hình client Duende IdentityServer
private const string ClientId = "password-client";
private const string ClientSecret = "password-client-secret";
// EN: localStorage keys for non-sensitive session metadata (email/name for UI display only).
// The access token is NEVER stored in localStorage — it lives in an httpOnly BFF cookie.
// VI: Các key localStorage cho metadata session không nhạy cảm (email/name chỉ để hiển thị UI).
// Access token KHÔNG BAO GIỜ được lưu trong localStorage — nó ở trong httpOnly BFF cookie.
private const string StoredEmailKey = "aPOS_email";
private const string StoredRoleKey = "aPOS_role";
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
PropertyNameCaseInsensitive = true
};
@@ -53,59 +43,40 @@ public class AuthService
}
/// <summary>
/// EN: Register a new user account.
/// VI: Đăng ký tài khoản mới.
/// EN: Register a new user account via BFF → IAM service.
/// VI: Đăng ký tài khoản mới qua BFF → IAM service.
/// </summary>
public async Task<(bool Success, string? Error)> RegisterAsync(RegisterDto dto)
{
try
{
// EN: Build payload with FirstName/LastName from form (IAM requires these)
// VI: Tạo payload với FirstName/LastName từ form (IAM yêu cầu)
var payload = new
{
dto.Email,
dto.Password,
dto.FirstName,
dto.LastName
};
// EN: Use PascalCase serialization to match backend RegisterUserCommand record params
// VI: Dùng PascalCase serialization để khớp với record params backend
// EN: PascalCase serialization to match IAM RegisterUserCommand record params.
// VI: Serialization PascalCase để khớp với record params IAM RegisterUserCommand.
var payload = new { dto.Email, dto.Password, dto.FirstName, dto.LastName };
var jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = null };
var jsonContent = JsonContent.Create(payload, options: jsonOptions);
var response = await _http.PostAsync("/api/auth/register", jsonContent);
if (response.IsSuccessStatusCode)
{
return (true, null);
}
var content = await response.Content.ReadAsStringAsync();
try
{
// EN: Try to parse structured validation errors from IAM
// VI: Parse lỗi validation có cấu trúc từ IAM
using var doc = JsonDocument.Parse(content);
if (doc.RootElement.TryGetProperty("errors", out var errors))
{
var msgs = new List<string>();
foreach (var prop in errors.EnumerateObject())
{
foreach (var err in prop.Value.EnumerateArray())
msgs.Add(err.GetString() ?? prop.Name);
}
return (false, string.Join("; ", msgs));
}
if (doc.RootElement.TryGetProperty("title", out var title))
return (false, title.GetString());
return (false, content.Length > 200 ? "Đăng ký thất bại" : content);
}
catch
{
return (false, "Đăng ký thất bại");
}
catch { return (false, "Đăng ký thất bại"); }
}
catch (Exception ex)
{
@@ -114,51 +85,59 @@ public class AuthService
}
/// <summary>
/// EN: Login via Duende IdentityServer password grant.
/// VI: Đăng nhập qua Duende IdentityServer password grant.
/// EN: Login via BFF — sends email/password to BFF; BFF exchanges with IS4 using server-side
/// credentials and sets an httpOnly session cookie. WASM never sees the access token.
/// VI: Đăng nhập qua BFF — gửi email/password đến BFF; BFF trao đổi với IS4 dùng
/// server-side credentials và đặt httpOnly session cookie. WASM không thấy access token.
/// </summary>
public async Task<(bool Success, string? Error)> LoginAsync(string email, string password, string role = "owner")
{
try
{
// EN: Build form-urlencoded body for token endpoint
// VI: Tạo body form-urlencoded cho token endpoint
var formData = new FormUrlEncodedContent(new[]
{
new KeyValuePair<string, string>("grant_type", "password"),
new KeyValuePair<string, string>("client_id", ClientId),
new KeyValuePair<string, string>("client_secret", ClientSecret),
new KeyValuePair<string, string>("username", email),
new KeyValuePair<string, string>("password", password),
new KeyValuePair<string, string>("scope", "openid profile email api"),
});
var response = await _http.PostAsync("/api/iam/connect/token", formData);
var response = await _http.PostAsJsonAsync("/api/bff/auth/login", new { email, password }, JsonOptions);
if (response.IsSuccessStatusCode)
{
var json = await response.Content.ReadAsStringAsync();
var token = JsonSerializer.Deserialize<TokenResponse>(json, JsonOptions);
using var doc = JsonDocument.Parse(json);
if (token != null && !string.IsNullOrEmpty(token.AccessToken))
// EN: BFF returns session info (email, role, expiresAt) — not the raw token.
// VI: BFF trả về thông tin session (email, role, expiresAt) — không phải token thô.
DateTime? expiresAt = null;
string? resolvedRole = role;
if (doc.RootElement.TryGetProperty("data", out var data))
{
// EN: Save token with role-specific key to prevent admin/staff conflict.
// VI: Lưu token với key theo role để tránh xung đột admin/staff.
await _js.InvokeVoidAsync("localStorage.setItem", TokenKey(role), token.AccessToken);
await _js.InvokeVoidAsync("localStorage.setItem", UserEmailKey(role), email);
_authState.Login(email, token.AccessToken, role);
return (true, null);
if (data.TryGetProperty("expiresAt", out var expEl) && expEl.ValueKind != JsonValueKind.Null)
expiresAt = expEl.GetDateTime();
if (data.TryGetProperty("role", out var roleEl) && roleEl.ValueKind == JsonValueKind.String)
resolvedRole = roleEl.GetString() ?? role;
if (data.TryGetProperty("email", out var emailEl) && emailEl.ValueKind == JsonValueKind.String)
email = emailEl.GetString() ?? email;
}
return (false, "Token không hợp lệ");
// EN: Persist non-sensitive metadata for UI (no token stored in localStorage).
// VI: Lưu metadata không nhạy cảm cho UI (không lưu token trong localStorage).
try
{
await _js.InvokeVoidAsync("localStorage.setItem", StoredEmailKey, email);
await _js.InvokeVoidAsync("localStorage.setItem", StoredRoleKey, resolvedRole);
}
catch { /* localStorage not available on pre-render */ }
_authState.Login(email, resolvedRole, expiresAt);
return (true, null);
}
var errorContent = await response.Content.ReadAsStringAsync();
if (errorContent.Contains("invalid_grant"))
try
{
return (false, "Email hoặc mật khẩu không đúng");
using var errDoc = JsonDocument.Parse(errorContent);
if (errDoc.RootElement.TryGetProperty("message", out var msg))
return (false, msg.GetString());
}
catch { }
return (false, $"Đăng nhập thất bại ({response.StatusCode})");
}
catch (Exception ex)
@@ -168,72 +147,69 @@ public class AuthService
}
/// <summary>
/// EN: Get stored access token.
/// VI: Lấy access token đã lưu.
/// </summary>
public async Task<string?> GetTokenAsync(string role = "owner")
{
try
{
var token = await _js.InvokeAsync<string?>("localStorage.getItem", TokenKey(role));
if (string.IsNullOrEmpty(token))
token = await _js.InvokeAsync<string?>("localStorage.getItem", LegacyTokenKey);
return token;
}
catch
{
return _authState.Token;
}
}
/// <summary>
/// EN: Try to restore session from localStorage on app start.
/// VI: Khôi phục session từ localStorage khi app khởi động.
/// </summary>
/// <summary>
/// EN: Try to restore session from localStorage. Uses role-specific keys, falls back to legacy keys.
/// VI: Khôi phục session từ localStorage. Dùng key theo role, fallback sang key cũ.
/// EN: Try to restore session from the BFF session endpoint (reads httpOnly cookie server-side).
/// Falls back to localStorage metadata for email/role display only.
/// VI: Khôi phục session từ BFF session endpoint (đọc httpOnly cookie phía server).
/// Fallback sang localStorage metadata chỉ để hiển thị email/role.
/// </summary>
public async Task TryRestoreSessionAsync(string role = "owner")
{
try
{
// EN: Try role-specific keys first, then fall back to legacy keys.
// VI: Thử key theo role trước, rồi fallback sang key cũ.
var token = await _js.InvokeAsync<string?>("localStorage.getItem", TokenKey(role));
var email = await _js.InvokeAsync<string?>("localStorage.getItem", UserEmailKey(role));
if (string.IsNullOrEmpty(token) || string.IsNullOrEmpty(email))
// EN: Ask BFF whether we have a valid session (reads httpOnly cookie).
// VI: Hỏi BFF xem có session hợp lệ không (đọc httpOnly cookie).
var response = await _http.GetAsync("/api/bff/auth/session");
if (response.IsSuccessStatusCode)
{
// EN: Fallback to legacy keys for backward compatibility.
// VI: Fallback sang key cũ để tương thích ngược.
token = await _js.InvokeAsync<string?>("localStorage.getItem", LegacyTokenKey);
email = await _js.InvokeAsync<string?>("localStorage.getItem", LegacyEmailKey);
}
var json = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(json);
if (!string.IsNullOrEmpty(token) && !string.IsNullOrEmpty(email))
{
_authState.Login(email, token, role);
if (doc.RootElement.TryGetProperty("data", out var data))
{
var email = data.TryGetProperty("email", out var eProp) ? eProp.GetString() : null;
var resolvedRole = data.TryGetProperty("role", out var rProp) ? rProp.GetString() : role;
DateTime? expiresAt = null;
if (data.TryGetProperty("expiresAt", out var expEl) && expEl.ValueKind != JsonValueKind.Null)
expiresAt = expEl.GetDateTime();
// EN: Fallback to localStorage for email if BFF didn't return it (shouldn't happen).
// VI: Fallback localStorage để lấy email nếu BFF không trả về (không nên xảy ra).
if (string.IsNullOrEmpty(email))
{
try { email = await _js.InvokeAsync<string?>("localStorage.getItem", StoredEmailKey); }
catch { }
}
if (!string.IsNullOrEmpty(email))
_authState.Login(email, resolvedRole ?? role, expiresAt);
}
}
// EN: 401 from BFF means no valid session — this is expected (not an error).
// VI: 401 từ BFF nghĩa là không có session hợp lệ — đây là bình thường (không phải lỗi).
}
catch { /* localStorage not available */ }
catch { /* Network unavailable or pre-render — silently skip */ }
}
/// <summary>
/// EN: Logout and clear stored data.
/// VI: Đăng xuất xóa dữ liệu đã lưu.
/// EN: Logout clears the BFF session cookie and local state.
/// VI: Đăng xuất xóa BFF session cookie và trạng thái cục bộ.
/// </summary>
public async Task LogoutAsync(string role = "owner")
{
try
{
await _js.InvokeVoidAsync("localStorage.removeItem", TokenKey(role));
await _js.InvokeVoidAsync("localStorage.removeItem", UserEmailKey(role));
await _js.InvokeVoidAsync("localStorage.removeItem", UserNameKey(role));
// EN: Also clear legacy keys on logout.
// VI: Cũng xóa key cũ khi logout.
await _js.InvokeVoidAsync("localStorage.removeItem", LegacyTokenKey);
await _js.InvokeVoidAsync("localStorage.removeItem", LegacyEmailKey);
// EN: Tell BFF to clear the httpOnly session cookie.
// VI: Yêu cầu BFF xóa httpOnly session cookie.
await _http.PostAsync("/api/bff/auth/logout", null);
}
catch { /* Best-effort: proceed with local logout even if BFF is unavailable */ }
// EN: Clear non-sensitive localStorage metadata.
// VI: Xóa metadata localStorage không nhạy cảm.
try
{
await _js.InvokeVoidAsync("localStorage.removeItem", StoredEmailKey);
await _js.InvokeVoidAsync("localStorage.removeItem", StoredRoleKey);
}
catch { }

View File

@@ -1,41 +1,175 @@
// EN: Auth state service — holds current session state in memory.
// Token is NOT stored here (it lives in the httpOnly BFF cookie).
// Tracks token expiry and fires OnTokenExpiring 2 minutes before expiry for proactive refresh.
// VI: Service trạng thái auth — lưu trạng thái session hiện tại trong memory.
// Token KHÔNG được lưu ở đây (nó ở trong httpOnly BFF cookie).
// Theo dõi thời hạn token và kích hoạt OnTokenExpiring trước 2 phút để refresh chủ động.
namespace WebClientTpos.Client.Services;
public class AuthStateService
/// <summary>
/// EN: Singleton auth state service. Tracks authentication state, role, and token expiry.
/// Does NOT store the raw access token — that is managed server-side via httpOnly cookie.
/// VI: Singleton service trạng thái auth. Theo dõi trạng thái xác thực, role, và thời hạn token.
/// KHÔNG lưu access token thô — được quản lý phía server qua httpOnly cookie.
/// </summary>
public class AuthStateService : IDisposable
{
// ═══════════════════════════════════════════════════════════════════
// EN: Public state
// VI: Trạng thái công khai
// ═══════════════════════════════════════════════════════════════════
public bool IsAuthenticated { get; private set; }
public string? UserEmail { get; private set; }
public string? UserRole { get; private set; } // "owner", "staff", "customer", "branch"
public string? Token { get; private set; }
/// <summary>
/// EN: User role — "owner", "staff", "customer", "branch".
/// VI: Vai trò người dùng — "owner", "staff", "customer", "branch".
/// </summary>
public string? UserRole { get; private set; }
/// <summary>
/// EN: UTC time when the session cookie expires. Null if unknown.
/// Used to schedule proactive token refresh.
/// VI: Thời điểm UTC khi session cookie hết hạn. Null nếu không biết.
/// Dùng để lên lịch refresh token chủ động.
/// </summary>
public DateTime? TokenExpiry { get; private set; }
// ═══════════════════════════════════════════════════════════════════
// EN: Events
// VI: Sự kiện
// ═══════════════════════════════════════════════════════════════════
/// <summary>
/// EN: Fired whenever authentication state changes (login, logout, refresh).
/// VI: Kích hoạt khi trạng thái xác thực thay đổi (đăng nhập, đăng xuất, refresh).
/// </summary>
public event Action? OnChange;
public void Login(string email, string token, string role)
/// <summary>
/// EN: Fired ~2 minutes before token expiry to trigger a proactive refresh.
/// Subscribers (e.g. AuthService) should call TryRestoreSessionAsync to renew the cookie.
/// VI: Kích hoạt ~2 phút trước khi token hết hạn để trigger refresh chủ động.
/// Subscriber (ví dụ AuthService) nên gọi TryRestoreSessionAsync để gia hạn cookie.
/// </summary>
public event Action? OnTokenExpiring;
// ═══════════════════════════════════════════════════════════════════
// EN: Timer for proactive refresh
// VI: Timer để refresh chủ động
// ═══════════════════════════════════════════════════════════════════
private Timer? _refreshTimer;
// EN: Fire refresh event this many seconds before actual expiry.
// VI: Kích hoạt sự kiện refresh trước thời điểm hết hạn thực tế bao nhiêu giây.
private const int RefreshLeadSeconds = 120;
// ═══════════════════════════════════════════════════════════════════
// EN: State mutations
// VI: Thay đổi trạng thái
// ═══════════════════════════════════════════════════════════════════
/// <summary>
/// EN: Record a successful login. Token is managed by the BFF httpOnly cookie — not stored here.
/// VI: Ghi nhận đăng nhập thành công. Token được quản lý bởi BFF httpOnly cookie — không lưu ở đây.
/// </summary>
public void Login(string email, string role, DateTime? expiresAt = null)
{
if (IsAuthenticated && Token == token && UserEmail == email)
if (IsAuthenticated && UserEmail == email && UserRole == role && TokenExpiry == expiresAt)
return;
IsAuthenticated = true;
UserEmail = email;
Token = token;
UserRole = role;
TokenExpiry = expiresAt;
ScheduleRefresh(expiresAt);
OnChange?.Invoke();
}
/// <summary>
/// EN: Clear session state on logout or expiry.
/// VI: Xóa trạng thái session khi đăng xuất hoặc hết hạn.
/// </summary>
public void Logout()
{
IsAuthenticated = false;
UserEmail = null;
Token = null;
UserRole = null;
TokenExpiry = null;
CancelRefreshTimer();
OnChange?.Invoke();
}
// ═══════════════════════════════════════════════════════════════════
// EN: Token expiry scheduling
// VI: Lên lịch hết hạn token
// ═══════════════════════════════════════════════════════════════════
private void ScheduleRefresh(DateTime? expiresAt)
{
CancelRefreshTimer();
if (!expiresAt.HasValue)
return;
// EN: Calculate when to fire the refresh event (lead time before actual expiry).
// VI: Tính thời điểm kích hoạt sự kiện refresh (trước thời điểm hết hạn thực tế).
var now = DateTime.UtcNow;
var fireAt = expiresAt.Value.AddSeconds(-RefreshLeadSeconds);
var delay = fireAt - now;
// EN: If already within lead window (or expired), fire immediately.
// VI: Nếu đã trong cửa sổ lead (hoặc hết hạn), kích hoạt ngay lập tức.
if (delay <= TimeSpan.Zero)
{
OnTokenExpiring?.Invoke();
return;
}
_refreshTimer = new Timer(_ =>
{
OnTokenExpiring?.Invoke();
CancelRefreshTimer();
}, null, delay, Timeout.InfiniteTimeSpan);
}
private void CancelRefreshTimer()
{
_refreshTimer?.Dispose();
_refreshTimer = null;
}
// ═══════════════════════════════════════════════════════════════════
// EN: Navigation helpers
// VI: Helper điều hướng
// ═══════════════════════════════════════════════════════════════════
/// <summary>
/// EN: Returns the portal URL for the current user role.
/// VI: Trả về URL portal cho vai trò người dùng hiện tại.
/// </summary>
public string GetPortalUrl() => UserRole switch
{
"owner" or "admin" => "/admin",
"staff" => "/staff/dashboard",
"branch" => "/admin",
"customer" => "/app",
_ => "/auth/login"
"staff" => "/staff/dashboard",
"branch" => "/admin",
"customer" => "/app",
_ => "/auth/login"
};
// ═══════════════════════════════════════════════════════════════════
// EN: IDisposable
// VI: IDisposable
// ═══════════════════════════════════════════════════════════════════
public void Dispose()
{
CancelRefreshTimer();
GC.SuppressFinalize(this);
}
}

View File

@@ -1,16 +1,19 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace WebClientTpos.Client.Services;
// EN: POS data service — attaches auth token for multi-tenant data isolation.
// VI: POS data service — đính kèm auth token để cách ly dữ liệu multi-tenant.
// EN: POS data service — all BFF requests are authenticated via httpOnly session cookie.
// The browser automatically includes the cookie in same-origin requests (SEC-W-01 / FRONT-W-02).
// No manual token attachment — eliminates the DefaultRequestHeaders race condition.
// VI: POS data service — tất cả request BFF được xác thực qua httpOnly session cookie.
// Trình duyệt tự động đính kèm cookie cho các request cùng origin (SEC-W-01 / FRONT-W-02).
// Không gắn token thủ công — loại bỏ race condition trên DefaultRequestHeaders.
public class PosDataService
{
private readonly HttpClient _http;
private readonly AuthStateService _authState;
// EN: Read options — case-insensitive to handle camelCase responses from BFF/microservices.
// VI: Options đọc — không phân biệt hoa thường để xử lý camelCase responses từ BFF/microservices.
private static readonly JsonSerializerOptions _jsonOptions = new()
@@ -19,32 +22,25 @@ public class PosDataService
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
// EN: Write options — camelCase to match ASP.NET model binding defaults
// VI: Options ghi — camelCase để khớp với ASP.NET model binding mặc định
// EN: Write options — camelCase to match ASP.NET model binding defaults.
// VI: Options ghi — camelCase để khớp với ASP.NET model binding mặc định.
private static readonly JsonSerializerOptions _writeOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
public PosDataService(HttpClient http, AuthStateService authState)
public PosDataService(HttpClient http)
{
_http = http;
_authState = authState;
}
/// <summary>
/// EN: Ensure Authorization header is set before each BFF call.
/// VI: Đảm bảo header Authorization được đặt trước mỗi BFF call.
/// </summary>
private void AttachToken()
{
if (!string.IsNullOrEmpty(_authState.Token))
{
_http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", _authState.Token);
}
}
// EN: Helper — create a per-request HttpRequestMessage to avoid DefaultRequestHeaders mutation.
// Auth is handled automatically by the browser via the httpOnly BFF session cookie.
// VI: Helper — tạo HttpRequestMessage per-request để tránh mutation DefaultRequestHeaders.
// Auth được trình duyệt tự xử lý qua httpOnly BFF session cookie.
private static HttpRequestMessage NewRequest(HttpMethod method, string url)
=> new(method, url);
private static async Task<string> TryExtractError(HttpResponseMessage resp)
{
@@ -66,13 +62,16 @@ public class PosDataService
}
/// <summary>
/// EN: Generic POST helper — sends JSON body to BFF endpoint.
/// VI: Helper POST chung — gui JSON body den BFF endpoint.
/// EN: Generic POST helper — sends JSON body to BFF endpoint via per-request HttpRequestMessage.
/// Uses per-request message (not DefaultRequestHeaders) to avoid race conditions (FRONT-W-02).
/// VI: Helper POST chung — gửi JSON body đến BFF endpoint qua HttpRequestMessage per-request.
/// Dùng per-request message (không phải DefaultRequestHeaders) để tránh race condition (FRONT-W-02).
/// </summary>
public async Task<bool> PostAsync(string url, object body)
{
AttachToken();
var resp = await _http.PostAsJsonAsync(url, body, _writeOptions);
var request = NewRequest(HttpMethod.Post, url);
request.Content = JsonContent.Create(body, options: _writeOptions);
var resp = await _http.SendAsync(request);
return resp.IsSuccessStatusCode;
}
@@ -84,13 +83,14 @@ public class PosDataService
=> GetObjectFromApiAsync<T>(url);
/// <summary>
/// EN: Generic PUT helper — sends JSON body to BFF endpoint.
/// VI: Helper PUT chung — gửi JSON body đến BFF endpoint.
/// EN: Generic PUT helper — sends JSON body to BFF endpoint via per-request HttpRequestMessage.
/// VI: Helper PUT chung — gửi JSON body đến BFF endpoint qua HttpRequestMessage per-request.
/// </summary>
public async Task<bool> PutAsync(string url, object body)
{
AttachToken();
var resp = await _http.PutAsJsonAsync(url, body, _writeOptions);
var request = NewRequest(HttpMethod.Put, url);
request.Content = JsonContent.Create(body, options: _writeOptions);
var resp = await _http.SendAsync(request);
return resp.IsSuccessStatusCode;
}
@@ -100,8 +100,9 @@ public class PosDataService
/// </summary>
public async Task<T?> PostAndGetAsync<T>(string url, object body) where T : class
{
AttachToken();
var resp = await _http.PostAsJsonAsync(url, body, _writeOptions);
var request = NewRequest(HttpMethod.Post, url);
request.Content = JsonContent.Create(body, options: _writeOptions);
var resp = await _http.SendAsync(request);
if (!resp.IsSuccessStatusCode) return null;
var json = await resp.Content.ReadAsStringAsync();
if (string.IsNullOrWhiteSpace(json)) return null;
@@ -117,12 +118,13 @@ public class PosDataService
/// <summary>
/// EN: Robust list deserialization — handles plain arrays, PagedResult wrappers, and ApiResponse envelopes.
/// Uses per-request HttpRequestMessage to avoid DefaultRequestHeaders race condition (FRONT-W-02).
/// VI: Deserialize list linh hoạt — xử lý array thuần, PagedResult wrapper, và ApiResponse envelope.
/// Dùng HttpRequestMessage per-request để tránh race condition DefaultRequestHeaders (FRONT-W-02).
/// </summary>
public async Task<List<T>> GetListFromApiAsync<T>(string url)
{
AttachToken();
var resp = await _http.GetAsync(url);
var resp = await _http.SendAsync(NewRequest(HttpMethod.Get, url));
if (!resp.IsSuccessStatusCode) return new();
var json = await resp.Content.ReadAsStringAsync();
if (string.IsNullOrWhiteSpace(json)) return new();
@@ -160,12 +162,13 @@ public class PosDataService
/// <summary>
/// EN: Robust single-object deserialization — handles plain objects and ApiResponse envelopes.
/// Uses per-request HttpRequestMessage (FRONT-W-02).
/// VI: Deserialize đối tượng đơn linh hoạt — xử lý object thuần và ApiResponse envelope.
/// Dùng HttpRequestMessage per-request (FRONT-W-02).
/// </summary>
private async Task<T?> GetObjectFromApiAsync<T>(string url) where T : class
{
AttachToken();
var resp = await _http.GetAsync(url);
var resp = await _http.SendAsync(NewRequest(HttpMethod.Get, url));
if (!resp.IsSuccessStatusCode) return null;
var json = await resp.Content.ReadAsStringAsync();
if (string.IsNullOrWhiteSpace(json)) return null;
@@ -299,21 +302,18 @@ public class PosDataService
public async Task<bool> CreateProductAsync(CreateProductRequest req)
{
AttachToken();
var resp = await _http.PostAsJsonAsync("api/bff/products", req, _writeOptions);
return resp.IsSuccessStatusCode;
}
public async Task<bool> UpdateProductAsync(Guid productId, CreateProductRequest req)
{
AttachToken();
var resp = await _http.PutAsJsonAsync($"api/bff/products/{productId}", req, _writeOptions);
return resp.IsSuccessStatusCode;
}
public async Task<bool> DeleteProductAsync(Guid productId)
{
AttachToken();
var resp = await _http.DeleteAsync($"api/bff/products/{productId}");
return resp.IsSuccessStatusCode;
}
@@ -342,14 +342,12 @@ public class PosDataService
// VI: Tạo mặt hàng tồn kho mới (nguyên liệu, thành phẩm, hoặc vật tư).
public async Task<bool> CreateInventoryItemAsync(CreateInventoryItemRequest req)
{
AttachToken();
var r = await _http.PostAsJsonAsync("api/bff/inventory/items", req, _writeOptions);
var r= await _http.PostAsJsonAsync("api/bff/inventory/items", req, _writeOptions);
return r.IsSuccessStatusCode;
}
public async Task<bool> DeleteInventoryItemAsync(Guid inventoryItemId)
{
AttachToken();
var resp = await _http.DeleteAsync($"api/bff/inventory/items/{inventoryItemId}");
return resp.IsSuccessStatusCode;
}
@@ -374,21 +372,18 @@ public class PosDataService
public async Task<bool> CreateStaffAsync(CreateStaffRequest req)
{
AttachToken();
var resp = await _http.PostAsJsonAsync("api/bff/staff", req, _writeOptions);
return resp.IsSuccessStatusCode;
}
public async Task<bool> UpdateStaffAsync(Guid staffId, CreateStaffRequest req)
{
AttachToken();
var resp = await _http.PutAsJsonAsync($"api/bff/staff/{staffId}", req, _writeOptions);
return resp.IsSuccessStatusCode;
}
public async Task<bool> DeleteStaffAsync(Guid staffId)
{
AttachToken();
var resp = await _http.DeleteAsync($"api/bff/staff/{staffId}");
return resp.IsSuccessStatusCode;
}
@@ -397,7 +392,6 @@ public class PosDataService
public async Task<bool> UpdateInventoryAsync(Guid inventoryId, UpdateInventoryRequest req)
{
AttachToken();
var resp = await _http.PutAsJsonAsync($"api/bff/inventory/{inventoryId}", req, _writeOptions);
return resp.IsSuccessStatusCode;
}
@@ -467,28 +461,23 @@ public class PosDataService
public async Task<List<CampaignInfo>> GetCampaignsAsync()
{
AttachToken();
var resp = await _http.GetFromJsonAsync<PaginatedCampaignResponse>("api/bff/campaigns", _jsonOptions);
return resp?.Items ?? new();
return await GetListFromApiAsync<CampaignInfo>("api/bff/campaigns");
}
public async Task<bool> CreateCampaignAsync(CreateCampaignRequest req)
{
AttachToken();
var resp = await _http.PostAsJsonAsync("api/bff/campaigns", req, _writeOptions);
return resp.IsSuccessStatusCode;
}
public async Task<bool> UpdateCampaignAsync(Guid campaignId, CreateCampaignRequest req)
{
AttachToken();
var resp = await _http.PutAsJsonAsync($"api/bff/campaigns/{campaignId}", req, _writeOptions);
return resp.IsSuccessStatusCode;
}
public async Task<bool> DeleteCampaignAsync(Guid campaignId)
{
AttachToken();
var resp = await _http.DeleteAsync($"api/bff/campaigns/{campaignId}");
return resp.IsSuccessStatusCode;
}
@@ -502,7 +491,6 @@ public class PosDataService
public async Task<(bool Ok, string? Error)> CreateMemberAsync(CreateMemberRequest req)
{
AttachToken();
var resp = await _http.PostAsJsonAsync("api/bff/members", req, _writeOptions);
if (resp.IsSuccessStatusCode) return (true, null);
var err = await TryExtractError(resp);
@@ -511,14 +499,12 @@ public class PosDataService
public async Task<bool> UpdateMemberAsync(Guid memberId, UpdateMemberRequest req)
{
AttachToken();
var resp = await _http.PutAsJsonAsync($"api/bff/members/{memberId}", req, _writeOptions);
return resp.IsSuccessStatusCode;
}
public async Task<bool> DeleteMemberAsync(Guid memberId)
{
AttachToken();
var resp = await _http.DeleteAsync($"api/bff/members/{memberId}");
return resp.IsSuccessStatusCode;
}
@@ -542,7 +528,6 @@ public class PosDataService
public async Task<bool> StockInAsync(StockInRequest req)
{
AttachToken();
var resp = await _http.PostAsJsonAsync("api/bff/inventory/stock-in", req, _writeOptions);
return resp.IsSuccessStatusCode;
}
@@ -551,7 +536,6 @@ public class PosDataService
public async Task<bool> StockOutAsync(StockOutRequest req)
{
AttachToken();
var resp = await _http.PostAsJsonAsync("api/bff/inventory/stock-out", req, _writeOptions);
return resp.IsSuccessStatusCode;
}
@@ -560,7 +544,6 @@ public class PosDataService
public async Task<bool> AdjustStockAsync(AdjustStockRequest req)
{
AttachToken();
var resp = await _http.PostAsJsonAsync("api/bff/inventory/adjust", req, _writeOptions);
return resp.IsSuccessStatusCode;
}
@@ -579,7 +562,6 @@ public class PosDataService
public async Task<bool> RecordWastageAsync(RecordWastageRequest req)
{
AttachToken();
var resp = await _http.PostAsJsonAsync("api/bff/inventory/wastage", req, _writeOptions);
return resp.IsSuccessStatusCode;
}
@@ -591,7 +573,6 @@ public class PosDataService
public async Task<StocktakeResult?> StocktakeAsync(StocktakeRequest req)
{
AttachToken();
var resp = await _http.PostAsJsonAsync("api/bff/inventory/stocktake", req, _writeOptions);
if (!resp.IsSuccessStatusCode) return null;
try
@@ -645,7 +626,6 @@ public class PosDataService
public async Task<(bool Ok, string? Error)> CreateLevelAsync(CreateLevelRequest req)
{
AttachToken();
var resp = await _http.PostAsJsonAsync("api/bff/membership/levels", req, _writeOptions);
if (resp.IsSuccessStatusCode) return (true, null);
return (false, await TryExtractError(resp));
@@ -653,7 +633,6 @@ public class PosDataService
public async Task<(bool Ok, string? Error)> UpdateLevelAsync(Guid levelId, CreateLevelRequest req)
{
AttachToken();
var resp = await _http.PutAsJsonAsync($"api/bff/membership/levels/{levelId}", req, _writeOptions);
if (resp.IsSuccessStatusCode) return (true, null);
return (false, await TryExtractError(resp));
@@ -661,7 +640,6 @@ public class PosDataService
public async Task<bool> DeleteLevelAsync(Guid levelId)
{
AttachToken();
var resp = await _http.DeleteAsync($"api/bff/membership/levels/{levelId}");
return resp.IsSuccessStatusCode;
}
@@ -679,7 +657,6 @@ public class PosDataService
public async Task<AddExpResult?> AddExperienceAsync(Guid memberId, AddExpRequest req)
{
AttachToken();
var resp = await _http.PostAsJsonAsync($"api/bff/members/{memberId}/experience", req, _writeOptions);
if (resp.IsSuccessStatusCode)
return await resp.Content.ReadFromJsonAsync<AddExpResult>(_jsonOptions);
@@ -696,7 +673,8 @@ public class PosDataService
public async Task<string?> UploadImageAsync(Stream fileStream, string fileName, string contentType)
{
AttachToken();
// EN: Auth via httpOnly cookie — no manual token attachment needed.
// VI: Auth qua httpOnly cookie — không cần gắn token thủ công.
using var content = new MultipartFormDataContent();
var streamContent = new StreamContent(fileStream);
streamContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(contentType);
@@ -761,7 +739,8 @@ public class PosDataService
public async Task<PosDashboardInfo> GetPosDashboardAsync(Guid shopId, string? period = "today")
{
AttachToken();
// EN: Auth via httpOnly cookie — no manual token attachment needed.
// VI: Auth qua httpOnly cookie — không cần gắn token thủ công.
return await _http.GetFromJsonAsync<PosDashboardInfo>(
$"api/bff/pos/dashboard?shopId={shopId}&period={period ?? "today"}", _jsonOptions)
?? new(0, 0, 0, 0, new(), new(), new(), new());
@@ -778,7 +757,6 @@ public class PosDataService
public async Task<CreatePosOrderResponse?> CreatePosOrderAsync(CreatePosOrderRequest req)
{
AttachToken();
var resp = await _http.PostAsJsonAsync("api/bff/pos/orders", req, _writeOptions);
if (resp.IsSuccessStatusCode)
return await resp.Content.ReadFromJsonAsync<CreatePosOrderResponse>(_jsonOptions);
@@ -801,7 +779,8 @@ public class PosDataService
Guid orderId, Guid shopId, string paymentMethod,
decimal? amountTendered = null, string? returnUrl = null)
{
AttachToken();
// EN: Auth via httpOnly cookie — no manual token attachment needed.
// VI: Auth qua httpOnly cookie — không cần gắn token thủ công.
var body = new { PaymentMethod = paymentMethod, AmountTendered = amountTendered, ReturnUrl = returnUrl };
var resp = await _http.PostAsJsonAsync($"api/bff/orders/{orderId}/pay?shopId={shopId}", body, _writeOptions);
if (resp.IsSuccessStatusCode)
@@ -856,7 +835,6 @@ public class PosDataService
public async Task<List<ActiveTableOrderDto>> GetActiveTableOrdersAsync(Guid shopId)
{
AttachToken();
var resp = await _http.GetAsync($"api/bff/orders/active-by-table?shopId={shopId}");
if (resp.IsSuccessStatusCode)
return await resp.Content.ReadFromJsonAsync<List<ActiveTableOrderDto>>(_jsonOptions) ?? new();
@@ -871,21 +849,18 @@ public class PosDataService
public async Task<bool> CreateCategoryAsync(AdminCreateCategoryRequest req)
{
AttachToken();
var resp = await _http.PostAsJsonAsync("api/bff/categories", req, _writeOptions);
return resp.IsSuccessStatusCode;
}
public async Task<bool> UpdateCategoryAsync(Guid categoryId, AdminCreateCategoryRequest req)
{
AttachToken();
var resp = await _http.PutAsJsonAsync($"api/bff/categories/{categoryId}", req, _writeOptions);
return resp.IsSuccessStatusCode;
}
public async Task<bool> DeleteCategoryAsync(Guid categoryId)
{
AttachToken();
var resp = await _http.DeleteAsync($"api/bff/categories/{categoryId}");
return resp.IsSuccessStatusCode;
}
@@ -900,14 +875,16 @@ public class PosDataService
public async Task<OrderDetailResponse?> GetOrderDetailAsync(Guid orderId, Guid? shopId = null)
{
AttachToken();
// EN: Auth via httpOnly cookie — no manual token attachment needed.
// VI: Auth qua httpOnly cookie — không cần gắn token thủ công.
var qs = shopId.HasValue ? $"?shopId={shopId}" : "";
return await _http.GetFromJsonAsync<OrderDetailResponse>($"api/bff/orders/{orderId}{qs}", _jsonOptions);
}
public async Task<bool> CancelOrderAsync(Guid orderId)
{
AttachToken();
// EN: Auth via httpOnly cookie — no manual token attachment needed.
// VI: Auth qua httpOnly cookie — không cần gắn token thủ công.
using var req = new HttpRequestMessage(HttpMethod.Put, $"api/bff/orders/{orderId}/cancel");
req.Headers.Authorization = _http.DefaultRequestHeaders.Authorization;
var resp = await _http.SendAsync(req);
@@ -922,7 +899,6 @@ public class PosDataService
public async Task<bool> UpdateShopAsync(Guid shopId, UpdateShopRequest req)
{
AttachToken();
var resp = await _http.PutAsJsonAsync($"api/bff/shops/{shopId}", req, _writeOptions);
return resp.IsSuccessStatusCode;
}
@@ -967,7 +943,6 @@ public class PosDataService
public async Task<bool> UpdateShopSettingsAsync(Guid shopId, UpdateShopSettingsRequest req)
{
AttachToken();
var resp = await _http.PutAsJsonAsync($"api/bff/shops/{shopId}/settings", req, _writeOptions);
return resp.IsSuccessStatusCode;
}
@@ -1053,7 +1028,8 @@ public class PosDataService
// EN: Cancel appointment with reason / VI: Hủy lịch hẹn kèm lý do
public async Task<bool> CancelAppointmentWithReasonAsync(Guid apptId, string reason)
{
AttachToken();
// EN: Auth via httpOnly cookie — no manual token attachment needed.
// VI: Auth qua httpOnly cookie — không cần gắn token thủ công.
var request = new HttpRequestMessage(HttpMethod.Delete, $"api/bff/appointments/{apptId}/cancel")
{
Content = JsonContent.Create(new { reason }, options: _writeOptions)
@@ -1105,7 +1081,8 @@ public class PosDataService
public async Task<bool> UpdateTicketStatusAsync(Guid ticketId, UpdateTicketStatusRequest req)
{
AttachToken();
// EN: Auth via httpOnly cookie — no manual token attachment needed.
// VI: Auth qua httpOnly cookie — không cần gắn token thủ công.
var request = new HttpRequestMessage(HttpMethod.Patch, $"api/bff/kitchen/tickets/{ticketId}/status")
{
Content = JsonContent.Create(req, options: _writeOptions)
@@ -1152,7 +1129,6 @@ public class PosDataService
public async Task<bool> RedeemVoucherAsync(Guid voucherId, decimal amount)
{
AttachToken();
var resp = await _http.PostAsJsonAsync("api/bff/vouchers/redeem", new { voucherId, amount }, _writeOptions);
return resp.IsSuccessStatusCode;
}
@@ -1167,7 +1143,8 @@ public class PosDataService
public async Task<List<AdminVoucherInfo>> GetAdminVouchersAsync(Guid? campaignId = null, string? status = null, int pageSize = 50)
{
AttachToken();
// EN: Auth via httpOnly cookie — no manual token attachment needed.
// VI: Auth qua httpOnly cookie — không cần gắn token thủ công.
var qs = $"pageSize={pageSize}";
if (campaignId.HasValue) qs += $"&campaignId={campaignId}";
if (!string.IsNullOrEmpty(status)) qs += $"&status={status}";
@@ -1192,7 +1169,6 @@ public class PosDataService
public async Task<(bool Ok, string? Error)> InviteStaffWithAccountAsync(InviteStaffWithAccountRequest req)
{
AttachToken();
var resp = await _http.PostAsJsonAsync("api/bff/staff/invite-with-account", req, _writeOptions);
if (resp.IsSuccessStatusCode) return (true, null);
var err = await TryExtractError(resp);
@@ -1211,7 +1187,8 @@ public class PosDataService
public async Task<List<StorageFileInfo>> GetStorageFilesAsync(int skip = 0, int take = 50, string? search = null)
{
AttachToken();
// EN: Auth via httpOnly cookie — no manual token attachment needed.
// VI: Auth qua httpOnly cookie — không cần gắn token thủ công.
var qs = $"?skip={skip}&take={take}";
if (!string.IsNullOrEmpty(search)) qs += $"&search={Uri.EscapeDataString(search)}";
var wrapper = await _http.GetFromJsonAsync<StorageApiResponse<UserFilesResult>>($"api/bff/files{qs}", _jsonOptions);
@@ -1223,7 +1200,6 @@ public class PosDataService
public async Task<string?> GetDownloadUrlAsync(Guid fileId)
{
AttachToken();
var resp = await _http.GetAsync($"api/bff/files/{fileId}/download-url");
if (!resp.IsSuccessStatusCode) return null;
var json = await resp.Content.ReadFromJsonAsync<JsonElement>(_jsonOptions);
@@ -1238,7 +1214,8 @@ public class PosDataService
public async Task<List<StorageFolderInfo>> GetFoldersAsync(Guid? parentId = null)
{
AttachToken();
// EN: Auth via httpOnly cookie — no manual token attachment needed.
// VI: Auth qua httpOnly cookie — không cần gắn token thủ công.
var qs = parentId.HasValue ? $"?parentId={parentId}" : "";
var wrapper = await _http.GetFromJsonAsync<StorageApiResponse<IEnumerable<StorageFolderInfo>>>($"api/bff/folders{qs}", _jsonOptions);
return wrapper?.Data?.ToList() ?? new();
@@ -1246,7 +1223,6 @@ public class PosDataService
public async Task<(bool Ok, string? Error)> CreateFolderAsync(CreateFolderRequest req)
{
AttachToken();
var resp = await _http.PostAsJsonAsync("api/bff/folders", req, _writeOptions);
if (resp.IsSuccessStatusCode) return (true, null);
var err = await TryExtractError(resp);
@@ -1258,7 +1234,6 @@ public class PosDataService
public async Task<bool> UploadFileRawAsync(MultipartFormDataContent content)
{
AttachToken();
var resp = await _http.PostAsync("api/bff/files/upload?accessLevel=public", content);
return resp.IsSuccessStatusCode;
}
@@ -1267,7 +1242,6 @@ public class PosDataService
public async Task<string?> GenerateTableQrTokenAsync(Guid tableId)
{
AttachToken();
var resp = await _http.PostAsJsonAsync($"api/bff/tables/{tableId}/generate-qr", new { }, _writeOptions);
if (resp.IsSuccessStatusCode)
{
@@ -1282,7 +1256,6 @@ public class PosDataService
public async Task<TableByTokenInfo?> GetTableByTokenAsync(string token)
{
AttachToken();
var resp = await _http.GetAsync($"api/bff/tables/by-token/{token}");
if (resp.IsSuccessStatusCode)
{
@@ -1312,7 +1285,8 @@ public class PosDataService
public async Task<bool> UpdateReservationStatusAsync(Guid reservationId, string status)
{
AttachToken();
// EN: Auth via httpOnly cookie — no manual token attachment needed.
// VI: Auth qua httpOnly cookie — không cần gắn token thủ công.
var request = new HttpRequestMessage(HttpMethod.Patch, $"api/bff/reservations/{reservationId}/status")
{
Content = JsonContent.Create(new { status }, options: _writeOptions)
@@ -1325,7 +1299,8 @@ public class PosDataService
public async Task<bool> UpdateTableStatusAsync(Guid tableId, string status)
{
AttachToken();
// EN: Auth via httpOnly cookie — no manual token attachment needed.
// VI: Auth qua httpOnly cookie — không cần gắn token thủ công.
var request = new HttpRequestMessage(HttpMethod.Patch, $"api/bff/tables/{tableId}/status")
{
Content = JsonContent.Create(new { status }, options: _writeOptions)
@@ -1340,7 +1315,6 @@ public class PosDataService
public async Task<SessionInfo?> OpenSessionAsync(Guid tableId, Guid shopId, int guestCount = 1)
{
AttachToken();
var resp = await _http.PostAsJsonAsync("api/bff/sessions",
new { tableId, shopId, guestCount }, _writeOptions);
if (resp.IsSuccessStatusCode)
@@ -1357,7 +1331,6 @@ public class PosDataService
public async Task<SessionInfo?> GetSessionAsync(Guid sessionId)
{
AttachToken();
var resp = await _http.GetAsync($"api/bff/sessions/{sessionId}");
if (resp.IsSuccessStatusCode)
{
@@ -1370,7 +1343,6 @@ public class PosDataService
public async Task<bool> CloseSessionAsync(Guid sessionId)
{
AttachToken();
var resp = await _http.PostAsJsonAsync($"api/bff/sessions/{sessionId}/close", new { }, _writeOptions);
return resp.IsSuccessStatusCode;
}
@@ -1381,7 +1353,6 @@ public class PosDataService
/// </summary>
public async Task<bool> ExtendSessionAsync(Guid tableId, int additionalMinutes)
{
AttachToken();
var resp = await _http.PostAsJsonAsync($"api/bff/tables/{tableId}/extend",
new { additionalMinutes }, _writeOptions);
// EN: Fallback — if dedicated extend endpoint doesn't exist, use PATCH status to signal extension
@@ -1402,7 +1373,6 @@ public class PosDataService
public async Task<bool> PublishShopAsync(Guid shopId)
{
AttachToken();
var resp = await _http.PostAsync($"api/bff/shops/{shopId}/publish", null);
return resp.IsSuccessStatusCode;
}
@@ -1561,7 +1531,6 @@ public class PosDataService
/// </summary>
public async Task<List<StockLevelInfo>> GetStockLevelsAsync(Guid shopId, List<Guid> productIds)
{
AttachToken();
var resp = await _http.PostAsJsonAsync($"api/bff/shops/{shopId}/inventory/stock-levels",
new { productIds }, _writeOptions);
if (!resp.IsSuccessStatusCode) return new();
@@ -1582,7 +1551,6 @@ public class PosDataService
/// </summary>
public async Task<(bool Ok, string? Error)> CreateReturnAsync(Guid shopId, Guid originalOrderId, List<ReturnItemInfo> items, string reason)
{
AttachToken();
var resp = await _http.PostAsJsonAsync("api/bff/orders/returns",
new { shopId, originalOrderId, items, reason }, _writeOptions);
if (resp.IsSuccessStatusCode) return (true, null);
@@ -1596,7 +1564,6 @@ public class PosDataService
public async Task<(bool Ok, string? Error)> CreateExchangeAsync(Guid shopId, Guid originalOrderId,
List<ReturnItemInfo> returnItems, List<ExchangeItemInfo> newItems, string reason)
{
AttachToken();
var resp = await _http.PostAsJsonAsync("api/bff/orders/exchanges",
new { shopId, originalOrderId, returnItems, newItems, reason }, _writeOptions);
if (resp.IsSuccessStatusCode) return (true, null);

View File

@@ -15,8 +15,10 @@
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;600;700;800&display=swap"
rel="stylesheet">
<!-- Lucide Icons JS (matching Pencil design) -->
<script src="https://unpkg.com/lucide@0.468.0/dist/umd/lucide.min.js"></script>
<!-- Lucide Icons JS (version pinned + SRI hash to prevent CDN supply-chain attack — SEC-W-13) -->
<script src="https://unpkg.com/lucide@0.468.0/dist/umd/lucide.min.js"
integrity="sha256-NBFpKCDLjUdUP2lJaqJf1gOjWPRJgEb0HFCKWjNCIQ4="
crossorigin="anonymous"></script>
<script>
// EN: Auto-initialize Lucide icons via MutationObserver.
// Blazor WASM replaces DOM on navigation and async render — icons need re-init.

View File

@@ -0,0 +1,246 @@
// EN: BFF Auth controller — handles login/logout/session for WASM client.
// Token exchange uses server-side credentials; WASM never sees the raw access token.
// Access token is stored in an httpOnly SameSite=Strict cookie, not in localStorage.
// VI: BFF Auth controller — xử lý login/logout/session cho WASM client.
// Trao đổi token dùng thông tin xác thực server-side; WASM không bao giờ thấy token thô.
// Access token được lưu trong httpOnly SameSite=Strict cookie, không phải localStorage.
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
namespace WebClientTpos.Server.Controllers;
/// <summary>
/// EN: BFF (Backend-for-Frontend) authentication controller.
/// - Receives email/password from WASM, exchanges with IS4 using server-side client credentials.
/// - Stores access token in an httpOnly cookie (never sent to WASM JS).
/// - WASM reads session info (email, role, expiry) without ever seeing the raw token.
/// VI: BFF Auth controller.
/// - Nhận email/password từ WASM, trao đổi với IS4 dùng client credentials server-side.
/// - Lưu access token trong httpOnly cookie (không gửi sang WASM JS).
/// - WASM đọc thông tin session (email, role, expiry) mà không thấy token thô.
/// </summary>
[ApiController]
[Route("api/bff/auth")]
public class BffAuthController : ControllerBase
{
// EN: httpOnly cookie name — must match AuthForwardingHandler.SessionCookieName.
// VI: Tên cookie httpOnly — phải khớp với AuthForwardingHandler.SessionCookieName.
internal const string SessionCookieName = "bff_session";
private const string CookiePath = "/";
private readonly IHttpClientFactory _clientFactory;
private readonly IConfiguration _config;
private readonly ILogger<BffAuthController> _logger;
public BffAuthController(
IHttpClientFactory clientFactory,
IConfiguration config,
ILogger<BffAuthController> logger)
{
_clientFactory = clientFactory;
_config = config;
_logger = logger;
}
/// <summary>
/// EN: Login — accepts email/password from WASM, proxies token exchange to IS4 using
/// server-side client credentials. Sets an httpOnly session cookie on success.
/// Returns session info (email, role, expiresAt) — never the raw token.
/// VI: Đăng nhập — nhận email/password từ WASM, proxy trao đổi token đến IS4 dùng
/// client credentials server-side. Đặt httpOnly session cookie khi thành công.
/// Trả về thông tin session (email, role, expiresAt) — không bao giờ trả token thô.
/// </summary>
[HttpPost("login")]
public async Task<IActionResult> Login([FromBody] BffLoginRequest request, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(request.Email) || string.IsNullOrWhiteSpace(request.Password))
return BadRequest(new { success = false, message = "Email và mật khẩu là bắt buộc. / Email and password are required." });
var clientId = _config["IdentityServer:ClientId"] ?? "bff-client";
var clientSecret = _config["IdentityServer:ClientSecret"] ?? "";
var tokenEndpoint = _config["IdentityServer:TokenEndpoint"] ?? "/connect/token";
var scope = _config["IdentityServer:Scope"] ?? "openid profile email api";
var iamClient = _clientFactory.CreateClient("IamService");
// EN: Build password grant form — client credentials stay on the server, never in WASM.
// VI: Tạo form password grant — client credentials ở lại server, không bao giờ trong WASM.
var form = new FormUrlEncodedContent(
[
new KeyValuePair<string, string>("grant_type", "password"),
new KeyValuePair<string, string>("client_id", clientId),
new KeyValuePair<string, string>("client_secret", clientSecret),
new KeyValuePair<string, string>("username", request.Email),
new KeyValuePair<string, string>("password", request.Password),
new KeyValuePair<string, string>("scope", scope),
]);
HttpResponseMessage is4Response;
try
{
is4Response = await iamClient.PostAsync(tokenEndpoint, form, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "EN: Failed to reach IS4 token endpoint {Endpoint}.", tokenEndpoint);
return StatusCode(503, new { success = false, message = "Không thể kết nối dịch vụ xác thực. / Auth service unavailable." });
}
if (!is4Response.IsSuccessStatusCode)
{
var errorBody = await is4Response.Content.ReadAsStringAsync(ct);
_logger.LogWarning("EN: IS4 login failed for {Email}. Status: {Status}.", request.Email, is4Response.StatusCode);
if (errorBody.Contains("invalid_grant"))
return Unauthorized(new { success = false, message = "Email hoặc mật khẩu không đúng. / Invalid email or password." });
return Unauthorized(new { success = false, message = "Đăng nhập thất bại. / Login failed." });
}
var json = await is4Response.Content.ReadAsStringAsync(ct);
string? accessToken;
int expiresIn;
try
{
using var doc = JsonDocument.Parse(json);
accessToken = doc.RootElement.GetProperty("access_token").GetString();
expiresIn = doc.RootElement.TryGetProperty("expires_in", out var expEl) ? expEl.GetInt32() : 3600;
}
catch (Exception ex)
{
_logger.LogError(ex, "EN: Failed to parse IS4 token response.");
return StatusCode(500, new { success = false, message = "Token không hợp lệ. / Invalid token response." });
}
if (string.IsNullOrEmpty(accessToken))
return StatusCode(500, new { success = false, message = "Token rỗng từ IS4. / Empty token from IS4." });
// EN: Store token in httpOnly SameSite=Strict cookie — never accessible via WASM JavaScript.
// VI: Lưu token trong httpOnly SameSite=Strict cookie — không truy cập được từ JavaScript WASM.
var isLocalhost = HttpContext.Request.Host.Host.Equals("localhost", StringComparison.OrdinalIgnoreCase)
|| HttpContext.Request.Host.Host.Equals("127.0.0.1", StringComparison.OrdinalIgnoreCase);
Response.Cookies.Append(SessionCookieName, accessToken, new CookieOptions
{
HttpOnly = true,
Secure = !isLocalhost,
SameSite = SameSiteMode.Strict,
Path = CookiePath,
Expires = DateTimeOffset.UtcNow.AddSeconds(expiresIn),
});
// EN: Return session info (no raw token) — WASM uses this to show user name/role.
// VI: Trả về thông tin session (không có token thô) — WASM dùng để hiển thị tên/vai trò.
var sessionInfo = ExtractSessionFromJwt(accessToken, expiresIn);
_logger.LogInformation("EN: BFF login success for {Email} role={Role}.", sessionInfo?.Email, sessionInfo?.Role);
return Ok(new { success = true, data = sessionInfo });
}
/// <summary>
/// EN: Session — returns current session info decoded from the httpOnly cookie.
/// Returns 401 if no valid session cookie exists or if token is expired.
/// VI: Session — trả về thông tin session được giải mã từ httpOnly cookie.
/// Trả về 401 nếu không có cookie session hợp lệ hoặc token đã hết hạn.
/// </summary>
[HttpGet("session")]
public IActionResult GetSession()
{
if (!Request.Cookies.TryGetValue(SessionCookieName, out var token) || string.IsNullOrEmpty(token))
return Unauthorized(new { success = false, message = "No active session." });
var sessionInfo = ExtractSessionFromJwt(token, 0);
if (sessionInfo == null)
{
// EN: Session cookie exists but token is expired — clear it.
// VI: Cookie tồn tại nhưng token đã hết hạn — xóa cookie.
Response.Cookies.Delete(SessionCookieName, new CookieOptions { Path = CookiePath });
return Unauthorized(new { success = false, message = "Session hết hạn. / Session expired." });
}
return Ok(new { success = true, data = sessionInfo });
}
/// <summary>
/// EN: Logout — deletes the httpOnly session cookie.
/// VI: Đăng xuất — xóa httpOnly session cookie.
/// </summary>
[HttpPost("logout")]
public IActionResult Logout()
{
Response.Cookies.Delete(SessionCookieName, new CookieOptions { Path = CookiePath });
_logger.LogInformation("EN: BFF logout — session cookie cleared.");
return Ok(new { success = true });
}
// ═══════════════════════════════════════════════════════════════
// EN: Helpers
// VI: Hàm hỗ trợ
// ═══════════════════════════════════════════════════════════════
/// <summary>
/// EN: Decode JWT claims to get session info without exposing the raw token.
/// Returns null if token is expired or malformed.
/// VI: Giải mã JWT claims để lấy thông tin session mà không lộ token thô.
/// Trả về null nếu token hết hạn hoặc sai định dạng.
/// </summary>
private static BffSessionInfo? ExtractSessionFromJwt(string token, int expiresInSeconds)
{
try
{
var parts = token.Split('.');
if (parts.Length < 2) return null;
var payload = parts[1].Replace('-', '+').Replace('_', '/');
switch (payload.Length % 4) { case 2: payload += "=="; break; case 3: payload += "="; break; }
using var doc = JsonDocument.Parse(Convert.FromBase64String(payload));
var root = doc.RootElement;
var email = root.TryGetProperty("email", out var eProp) ? eProp.GetString() : null;
var sub = root.TryGetProperty("sub", out var sProp) ? sProp.GetString() : null;
// EN: Extract role — may be a string or array of strings.
// VI: Lấy role — có thể là string hoặc mảng string.
string? role = null;
if (root.TryGetProperty("role", out var roleProp))
{
role = roleProp.ValueKind == JsonValueKind.Array
? roleProp.EnumerateArray().FirstOrDefault().GetString()
: roleProp.GetString();
}
// EN: Determine expiry — prefer explicit expiresInSeconds, fallback to JWT 'exp' claim.
// VI: Xác định thời hạn — ưu tiên expiresInSeconds, fallback sang claim 'exp' của JWT.
DateTime? expiresAt = null;
if (expiresInSeconds > 0)
{
expiresAt = DateTime.UtcNow.AddSeconds(expiresInSeconds);
}
else if (root.TryGetProperty("exp", out var expProp))
{
expiresAt = DateTimeOffset.FromUnixTimeSeconds(expProp.GetInt64()).UtcDateTime;
}
// EN: Reject expired sessions.
// VI: Từ chối session đã hết hạn.
if (expiresAt.HasValue && expiresAt.Value <= DateTime.UtcNow)
return null;
return new BffSessionInfo(sub, email, role ?? "owner", expiresAt);
}
catch
{
return null;
}
}
}
/// <summary>
/// EN: Login request DTO — only email/password, no client credentials.
/// VI: DTO request đăng nhập — chỉ email/password, không có client credentials.
/// </summary>
public record BffLoginRequest(string Email, string Password);
/// <summary>
/// EN: Session info returned to WASM — does NOT include the raw access token.
/// VI: Thông tin session trả về cho WASM — KHÔNG bao gồm access token thô.
/// </summary>
public record BffSessionInfo(string? UserId, string? Email, string Role, DateTime? ExpiresAt);

View File

@@ -3,13 +3,19 @@ using Microsoft.AspNetCore.Mvc;
namespace WebClientTpos.Server.Infrastructure;
/// <summary>
/// EN: DelegatingHandler that forwards the Authorization header from incoming HTTP requests
/// to outgoing HttpClient requests for transparent auth forwarding to microservices.
/// VI: DelegatingHandler chuyển tiếp header Authorization từ request HTTP đến
/// sang các request HttpClient gửi đi, cho phép chuyển tiếp auth trong suốt đến microservices.
/// EN: DelegatingHandler that attaches a Bearer token to outgoing microservice requests.
/// Priority: (1) httpOnly session cookie → (2) forwarded Authorization header (legacy fallback).
/// With cookie-based BFF auth (SEC-W-01), WASM never sends raw tokens — they live in httpOnly cookies.
/// VI: DelegatingHandler đính kèm Bearer token vào request gửi đi đến microservice.
/// Ưu tiên: (1) httpOnly session cookie → (2) forwarding Authorization header (fallback cũ).
/// Với cookie-based BFF auth (SEC-W-01), WASM không bao giờ gửi token thô — chúng ở trong httpOnly cookie.
/// </summary>
public class AuthForwardingHandler : DelegatingHandler
{
// EN: Must match BffAuthController.SessionCookieName.
// VI: Phải khớp với BffAuthController.SessionCookieName.
private const string SessionCookieName = "bff_session";
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly ILogger<AuthForwardingHandler> _logger;
@@ -22,19 +28,38 @@ public class AuthForwardingHandler : DelegatingHandler
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
var incomingRequest = _httpContextAccessor.HttpContext?.Request;
if (incomingRequest?.Headers.ContainsKey("Authorization") == true)
var context = _httpContextAccessor.HttpContext;
if (context == null)
{
var authHeader = incomingRequest.Headers["Authorization"].ToString();
request.Headers.TryAddWithoutValidation("Authorization", authHeader);
_logger.LogDebug("EN: Auth header forwarded ({Length} chars) to {Uri}",
authHeader.Length, request.RequestUri);
_logger.LogWarning("EN: HttpContext is null — cannot forward auth for {Uri}.", request.RequestUri);
return base.SendAsync(request, cancellationToken);
}
else
// EN: (1) Prefer httpOnly session cookie — set by BffAuthController.Login().
// This is the secure path: WASM never handled the raw token.
// VI: (1) Ưu tiên httpOnly session cookie — được set bởi BffAuthController.Login().
// Đây là con đường bảo mật: WASM không bao giờ xử lý token thô.
if (context.Request.Cookies.TryGetValue(SessionCookieName, out var sessionToken)
&& !string.IsNullOrEmpty(sessionToken))
{
_logger.LogWarning("EN: No Authorization header found in incoming request for {Uri}. HttpContext null: {IsNull}",
request.RequestUri, _httpContextAccessor.HttpContext == null);
request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {sessionToken}");
_logger.LogDebug("EN: Auth attached from httpOnly session cookie for {Uri}.", request.RequestUri);
return base.SendAsync(request, cancellationToken);
}
// EN: (2) Legacy fallback — forward Authorization header sent by WASM client.
// Used during transition before all clients migrate to cookie-based auth.
// VI: (2) Fallback cũ — forwarding Authorization header do WASM client gửi.
// Dùng trong thời gian chuyển tiếp trước khi tất cả client chuyển sang cookie-based auth.
if (context.Request.Headers.TryGetValue("Authorization", out var authHeader)
&& !string.IsNullOrEmpty(authHeader.ToString()))
{
request.Headers.TryAddWithoutValidation("Authorization", authHeader.ToString());
_logger.LogDebug("EN: Auth header forwarded from client (legacy) for {Uri}.", request.RequestUri);
return base.SendAsync(request, cancellationToken);
}
_logger.LogWarning("EN: No auth found (no session cookie, no Authorization header) for {Uri}.", request.RequestUri);
return base.SendAsync(request, cancellationToken);
}
}

View File

@@ -4,5 +4,11 @@
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"IdentityServer": {
"ClientId": "password-client",
"ClientSecret": "password-client-secret",
"TokenEndpoint": "/connect/token",
"Scope": "openid profile email api"
}
}

View File

@@ -5,5 +5,11 @@
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
"AllowedHosts": "*",
"IdentityServer": {
"ClientId": "bff-client",
"ClientSecret": "REPLACE_WITH_SECRET_IN_USER_SECRETS_OR_ENV",
"TokenEndpoint": "/connect/token",
"Scope": "openid profile email api"
}
}