Bỏ qua, tới nội dung
Kỹ thuật
7 phút đọc

Bỏ BFF: admin SPA gọi thẳng Spring Boot

Chuyển admin Saramin Vietnam từ Next.js + Prisma sang SPA Vite + antd dùng cookie httpOnly và CSRF, cùng ba bug auth đã qua mọi lượt typecheck.

Dao Van Thuong
Mobile & Fullstack Engineer
Read in English
Ảnh bìa: admin SPA gọi thẳng backend Spring Boot, không còn BFF ở giữa
Trong bài này
  1. Có gì để xoá
  2. Hình dạng mới
  3. Một cái bẫy nginx nên biết
  4. Ba bug qua mặt mọi lượt check
  5. 1. Vòng lặp reload khi refresh session
  6. 2. Race khi redirect lúc logout
  7. 3. queryClient.clear() chẳng báo cho ai
  8. Giờ verify thế nào
  9. Giữ cho bản viết lại khỏi mục
  10. Lần sau mình vẫn sẽ làm

Admin console của Saramin Vietnam ban đầu là một app Next.js có backend-for-frontend riêng. Nó có 72 route handler, khoảng 2.300 dòng, và gần như cái nào cũng khai báo lại một endpoint mà backend Spring Boot đã có sẵn. Đăng nhập đi qua BFF: BFF đọc header Set-Cookie của backend rồi set lại đúng những giá trị đó thành cookie của chính nó. Trang danh sách nào cũng đi qua một handler chỉ để chuyển tiếp request và nắn lại response.

Lúc viết kế hoạch migration, mình cứ quay lại một câu hỏi: lớp này đang bảo vệ cái gì? Hoá ra câu trả lời là "một ranh giới origin". Admin và API nằm ở hai origin khác nhau, nên browser không thể giữ cookie của backend cho admin, và BFF sinh ra để che chỗ đó. Đặt cả hai sau cùng một origin thì cả màn nghi lễ ấy biến mất mà không mất gì về bảo mật. Chính code cookie của backend cũng đã ghi rõ là nó coi admin là same-site.

Thế là bỏ. Bài này kể cái gì thay vào chỗ BFF, và ba bug auth mình chỉ bắt được khi tự click qua kết quả trên browser thật, sau khi tsc, lint và build đều đã xanh.

Có gì để xoá

Kế hoạch bắt đầu bằng việc đo app cũ chứ không đoán. Nó khoảng 49.700 dòng trong 360 file: Next.js 16, next-intl, Prisma chạy trên một bản prototype SQLite, shadcn và Base UI, react-hook-form. Bản thân BFF, tức route handler cộng mấy helper token, proxy và audit, chỉ chiếm 6,2%.

Con số đó đổi hẳn cách nhìn dự án. Bỏ BFF là phần rẻ. 93,8% còn lại là UI, và chuyển sang một SPA thuần dùng antd nghĩa là viết lại phần đó. Team chọn antd thay vì giữ shadcn, chấp nhận dùng một thư viện component khác với các app còn lại của công ty. Có hai thứ được bê nguyên sang: message catalog (2.630 key cho ba ngôn ngữ) và helper format ngày giờ ghim cứng giờ Việt Nam, vì nó đã chứa sẵn một bug đã sửa mà không ai muốn sửa lại lần hai.

Migration lên ngày 01/08/2026 trong một commit: 781 file thay đổi, thêm khoảng 23.000 dòng và xoá khoảng 72.000 dòng, port xong cả chín module đang theo dõi. Những màn hình chỉ chạy được vì Prisma có một bảng local phía sau thì không mang sang. Chúng thành các module bật/tắt bằng feature flag, chưa có API, chờ backend có endpoint.

Hình dạng mới

Trước: browser qua BFF Next.js tới Spring Boot; sau: browser tới một origin, file tĩnh cộng proxy /api
BFF sinh ra để vượt ranh giới origin. Một origin thì không còn ranh giới.

Trên production, một nginx phục vụ thư mục dist/ đã build ở / và reverse-proxy /api/* sang service Spring Boot. Browser chỉ thấy một origin, nên cookie của backend là first-party với admin:

  • access_token: httpOnly, SameSite=Lax, 30 phút.
  • refresh_token: httpOnly, SameSite=Lax, 14 ngày.
  • csrf_token: JavaScript đọc được, SameSite=Lax, 14 ngày.

SPA không bao giờ tự lưu token. Không có header Authorization, không có gì trong localStorage, không có gì trong store Zustand. Mọi request đi qua một axios client duy nhất với withCredentials: true, và đó là chỗ duy nhất biết chuyện auth.

CSRF theo kiểu double-submit. Với các method không an toàn, client đọc cookie csrf_token rồi gửi lại trong header:

ts
api.interceptors.request.use((config) => {
  const method = (config.method ?? 'get').toUpperCase();
  if (UNSAFE.has(method)) {
    const token = readCookie('csrf_token');
    if (token) config.headers['X-CSRF-TOKEN'] = token; // giá trị gốc, không mask
  }
  return config;
});

Chữ "gốc" quan trọng. Spring Security có một handler chờ token đã XOR-mask và một handler chờ giá trị thô. Backend dùng loại thô, nên client mà mask thì mọi lệnh ghi đều bị từ chối.

Luồng refresh là single-flight. Khi nhiều request cùng dính 401 một lúc, tất cả cùng chờ một lần gọi refresh thay vì mỗi request tự refresh riêng:

ts
let refreshInFlight: Promise<void> | null = null;

async function refreshOnce() {
  refreshInFlight ??= rawAxios.post('/api/auth/refresh').finally(() => {
    refreshInFlight = null;
  });
  return refreshInFlight;
}

Chỉ hai mã lỗi "unauthorized" và "invalid token" của backend mới kích hoạt refresh. Sai mật khẩu hay tài khoản bị khoá cũng trả 401, nhưng refresh mấy cái đó là vô nghĩa. Login, refresh, logout và set-password không bao giờ refresh. Mỗi request chỉ retry một lần, và chính lời gọi refresh dùng axios trần để không chui ngược lại vào interceptor. Token CSRF bị từ chối cũng được refresh một lần, vì refresh sẽ cấp cookie CSRF mới.

Một cái bẫy nginx nên biết

Bản config nginx đầu tiên proxy thẳng tới tên service của backend. Sau một lần redeploy backend, mọi request /api/* đều trả 502. nginx resolve hostname upstream đúng một lần lúc khởi động rồi giữ IP đó mãi, mà IP của container cũ thì đã mất. Cách sửa là đưa upstream vào một biến và cho nginx một resolver với valid ngắn, để nó resolve lại theo từng request:

nginx
location /api/ {
  resolver 127.0.0.11 valid=10s;   # DNS nội bộ của Docker
  set $upstream http://backend:8080;
  proxy_pass $upstream$request_uri;
}

Phía SPA thì vẫn là try_files $uri /index.html quen thuộc, index.html trả no-cache, còn /assets/ có hash thì trả immutable.

Ba bug qua mặt mọi lượt check

Cả ba bug này mình bắt được trong lúc tự kiểm tra migration bằng tay, trước khi commit migration được merge. Không cái nào hiện ra với type system. Đó là lý do repo giờ có hẳn một quy tắc nói thẳng ra: typecheck xanh không phải là verify.

Ba bug auth chỉ lộ ra khi chạy thật: vòng reload, race lúc logout, và clear cache mà query đang mount không hề biết
Cái nào cũng compile, lint và build sạch sẽ

1. Vòng lặp reload khi refresh session

Bản đầu của handler xử lý refresh thất bại làm điều nghe rất tự nhiên: window.location.assign('/login'). Với một lượt vào trang khi chưa đăng nhập, chuyện diễn ra thế này. /me trả 401, client thử refresh, refresh cũng 401, vậy là trang hard-reload về /login. App boot lại, hỏi /me lại, lại 401, lại reload. Mãi mãi.

Cách sửa là không bao giờ reload. Khi refresh thất bại, interceptor ghi null vào query user hiện tại rồi throw:

ts
} catch {
  queryClient.setQueryData(ME_QUERY_KEY, null);
  throw new ApiError('UNAUTHORIZED');
}

React Router sau đó redirect ngay trong app đang chạy, và vòng lặp không còn chỗ nào để bắt đầu lại. Sau này khi thêm xử lý cho lỗi "token này thuộc về một user khác", mình cố tình dùng lại đúng con đường này.

2. Race khi redirect lúc logout

logout() gọi navigate('/login', { replace: true }). Trong khi đó ProtectedRoute render <Navigate to="/login" replace state={{ from }} /> ngay khi user thành null. Cả hai cùng phản ứng với một lần đổi state, và cái nào chạy sau thì thắng. Cái nào chạy sau lại tuỳ timing, nên mỗi lần bấm logout hành xử một kiểu.

Quy tắc rút ra giờ là non-negotiable thứ hai trong hướng dẫn cho agent của repo: mỗi lần chuyển trạng thái auth chỉ có một cơ chế redirect. Component tự redirect kiểu declarative khi state của chính nó đổi. ProtectedRoute chịu trách nhiệm cho chuyện "user không còn nữa". Logout thì không navigate gì cả.

3. queryClient.clear() chẳng báo cho ai

Bỏ navigate() đi rồi, logout gọi API xong thì gọi queryClient.clear(). Click không có tác dụng gì. Trang đứng yên tại chỗ.

clear() làm rỗng map cache của TanStack Query, nhưng không báo cho các observer đang mount. useQuery cho /me trong layout vẫn trả về giá trị cuối cùng của nó, một user đang đăng nhập, nên ProtectedRoute chẳng có lý do gì để redirect. Cách sửa là invalidate:

ts
async function logout() {
  await authApi.logout();          // backend xoá cookie
  await queryClient.invalidateQueries();
}

Invalidate làm query /me đang active fetch lại. Nó nhận 401 thật, refresh cũng thất bại, interceptor set user về null từ ngoài call stack ban đầu, và ProtectedRoute thực hiện đúng một lần redirect. Ba mảnh, mỗi mảnh một việc.

Giờ verify thế nào

Ba bug có chung một dạng: type đúng, code trông hợp lý, còn hành vi thì hỏng, và chỉ lộ ra như một chuỗi request thật trong browser thật. Nên bất kỳ thay đổi nào chạm vào auth context, API client, route config hay permission gate đều phải chạy thử với backend thật trước khi được gọi là xong:

  1. Bật dev server và đăng nhập thật.
  2. Chạy luồng vừa đổi, rồi logout.
  3. Nhìn console xem có lỗi lặp lại không, đó là dấu hiệu của vòng reload. Một cặp 401 /me cộng /refresh khi vào trang lúc chưa đăng nhập là bình thường.
  4. Xem tab network và so shape response với cái backend thực sự trả về, không phải cái mà type đang giả định.

Cũng thói quen đó bắt được một cái bẫy nhỏ hơn: npx tsc --noEmit ở root chẳng check gì cả trong một setup project references. Cổng thật là tsc -b, thứ mà pnpm build chạy.

Giữ cho bản viết lại khỏi mục

Một lần viết lại cỡ này sẽ trôi về đống lộn xộn nếu cấu trúc không bị ép. Code chia hai lớp. src/core là design system, không biết gì về app. src/shared là hạ tầng của app: API client, auth, layout, và các hook biết shape của backend. Phép thử để biết một thứ thuộc bên nào: mang nó sang một project admin khác thì có phải sửa dù chỉ một dòng không. ESLint no-restricted-imports ép điều đó theo từng thư mục: core/ không được import từ shared/, configs/, modules/, i18n/ hay store/, và module của domain này không được thò tay vào phần ruột của domain khác.

Từ sau migration, console đã lớn lên thành chín nhóm domain và hơn năm mươi thư mục feature, cái nào cũng có changelog riêng. Chính sự tăng trưởng đó làm mình tin quyết định hồi đó là đúng.

Lần sau mình vẫn sẽ làm

  • Đo BFF trước khi xoá. "6,2% code" biến một cuộc tranh luận kiến trúc thành một kế hoạch có giá rõ ràng.
  • Cùng origin trước, cookie sau. Một origin thì cookie httpOnly của backend cứ thế chạy, frontend không bao giờ chạm vào token.
  • Gửi CSRF token đúng dạng backend chờ. Kiểm tra xem Spring Security đang dùng handler nào.
  • Refresh single-flight, có danh sách mã lỗi được phép, và không bao giờ để xử lý refresh thất bại reload trang.
  • Mỗi lần chuyển trạng thái auth chỉ một chủ redirect. Logout dọn state; route guard lo redirect.
  • Dùng invalidateQueries() khi query đang mount cần phản ứng. clear() là cho lúc không ai đang nhìn.
  • Cho nginx một resolver nếu upstream có thể đổi IP.
  • Type xanh chỉ là điểm xuất phát. Auth, data fetching và navigation phải được click thử trên browser rồi mới tính là xong.

Toàn bộ dự án, kể cả phần call-center và vòng đời tin tuyển dụng làm sau migration, nằm trong case study Saramin Vietnam.

  • #Saramin Vietnam
  • #React
  • #Spring Boot
  • #Auth
  • #Architecture
Chia sẻXLinkedInFacebook
Dao Van Thuong

Kỹ sư mobile và fullstack ở TP. Hồ Chí Minh. Mình tự xây và phát hành các app iOS indie — Lockboxy, Linkeeper, Minivid, Ringsy, Talkzy, Baton và Stampzy.