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:
@@ -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();
|
||||
|
||||
@@ -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 và 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 { }
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user