Building a Full Enterprise-Ready React + Spring Boot Auth Flow: An End-to-End Guide
Building a Full Enterprise-Ready React + Spring Boot Auth Flow: An End-to-End Guide
构建企业级 React + Spring Boot 完整认证流程:端到端指南
Introduction
Authentication is one of those things that looks simple in a tutorial and becomes surprisingly complex in production. Between token storage, CSRF protection, refresh flows, and protected routing, there are many places to get it wrong—and getting it wrong has real security consequences. In two earlier posts, I covered pieces of this puzzle: Enabling CSRF in a JWT-Based React + Spring Boot Application and Storing Personal Information in React: sessionStorage vs Context API. This post ties those threads together into a complete, end-to-end authentication flow you can adapt for enterprise applications. We’ll walk through the full journey: login → token issuance → secure storage → protected routes → token refresh → logout.
引言
身份验证(Authentication)属于那种在教程中看起来很简单,但在生产环境中却变得异常复杂的任务。在令牌存储、CSRF 防护、刷新流程和受保护路由之间,有很多环节容易出错,而一旦出错,就会带来严重的安全性后果。在之前的两篇文章中,我探讨了这一拼图的部分内容:《在基于 JWT 的 React + Spring Boot 应用中启用 CSRF》以及《在 React 中存储个人信息:sessionStorage 与 Context API 的对比》。本文将这些线索串联起来,形成一个完整的、可用于企业级应用的端到端认证流程。我们将走完整个旅程:登录 → 令牌签发 → 安全存储 → 受保护路由 → 令牌刷新 → 注销。
Architecture Overview
Before the code, here’s the high-level flow:
┌──────────────┐ ┌──────────────────┐
│ React │ │ Spring Boot │
│ Frontend │ │ Backend │
└──────┬───────┘ └────────┬─────────┘
│ 1. POST /login │
│──────────────────────────>│
│ validate credentials │
│ 2. JWT (httpOnly cookie) │
│<──────────────────────────│
│ issue access + refresh │
│ │
│ 3. GET /protected │
│ (+ CSRF token) │
│──────────────────────────>│
│ validate JWT + CSRF │
│ 4. Protected data │
│<──────────────────────────│
│ │
│ 5. POST /refresh │
│──────────────────────────>│
│ rotate tokens │
│ │
│ 6. POST /logout │
│──────────────────────────>│
│ invalidate session │
架构概览
在进入代码之前,先看下高层流程图: (流程图见上文)
Key Design Decisions
| Decision | Choice | Rationale |
|---|---|---|
| Token storage | httpOnly cookies | Not accessible to JavaScript → mitigates XSS token theft |
| CSRF protection | Double-submit / token pattern | Required when using cookies |
| Token type | Short-lived access + refresh | Limits exposure window |
| State management | Context API for auth status | Centralized, lightweight |
关键设计决策
| 决策项 | 选择 | 理由 |
|---|---|---|
| 令牌存储 | httpOnly Cookie | JavaScript 无法访问 → 减轻 XSS 令牌窃取风险 |
| CSRF 防护 | 双重提交 / 令牌模式 | 使用 Cookie 时必须配置 |
| 令牌类型 | 短期访问令牌 + 刷新令牌 | 限制令牌暴露的时间窗口 |
| 状态管理 | Context API (认证状态) | 集中式管理,轻量级 |
Why httpOnly cookies over localStorage? As I discussed in the storage blog, localStorage is readable by any script on the page—making it vulnerable to XSS. httpOnly cookies trade that risk for the need to handle CSRF, which we address below.
为什么选择 httpOnly Cookie 而不是 localStorage? 正如我在存储相关的博客中所讨论的,localStorage 可以被页面上的任何脚本读取,这使其容易受到 XSS 攻击。httpOnly Cookie 虽然引入了需要处理 CSRF 的额外工作,但规避了上述风险,我们将在下文解决 CSRF 问题。
Step 1: Backend — Login and Token Issuance
On successful authentication, the backend issues a JWT and sets it as an httpOnly, secure cookie rather than returning it in the response body.
第一步:后端 — 登录与令牌签发
认证成功后,后端会签发 JWT 并将其设置为 httpOnly、secure 的 Cookie,而不是将其放在响应体中返回。
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequest request, HttpServletResponse response) {
// Authenticate credentials (delegated to AuthenticationManager)
Authentication auth = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.getUsername(),
request.getPassword()));
String accessToken = jwtService.generateAccessToken(auth);
String refreshToken = jwtService.generateRefreshToken(auth);
// Set access token as httpOnly cookie
ResponseCookie accessCookie = ResponseCookie.from("access_token", accessToken)
.httpOnly(true)
.secure(true)
.path("/")
.sameSite("Strict")
.maxAge(Duration.ofMinutes(15))
.build();
response.addHeader(HttpHeaders.SET_COOKIE, accessCookie.toString());
return ResponseEntity.ok(new LoginResponse("Login successful"));
}
Key points:
httpOnly(true)prevents JavaScript access.secure(true)ensures the cookie is only sent over HTTPS.sameSite("Strict")adds a layer of CSRF defense (though we won’t rely on it alone).
关键点:
httpOnly(true)防止 JavaScript 访问。secure(true)确保 Cookie 仅通过 HTTPS 发送。sameSite("Strict")增加了一层 CSRF 防御(尽管我们不能仅依赖它)。
Step 2: Backend — CSRF Protection
Because we’re using cookies, we need CSRF protection. Spring Security supports the double-submit cookie pattern via CookieCsrfTokenRepository.
第二步:后端 — CSRF 防护
由于我们使用了 Cookie,因此需要 CSRF 防护。Spring Security 通过 CookieCsrfTokenRepository 支持双重提交 Cookie 模式。
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler()))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/login", "/api/auth/refresh").permitAll()
.anyRequest().authenticated())
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
The CSRF token cookie is intentionally not httpOnly, because the frontend must read it and echo it back in a request header. The attacker’s site can’t read your cookies cross-origin, so this remains safe.
CSRF 令牌 Cookie 特意没有设置为 httpOnly,因为前端必须读取它并将其回传到请求头中。攻击者的站点无法跨域读取你的 Cookie,因此这种方式依然是安全的。
Step 3: Frontend — Configuring the HTTP Client
Configure your HTTP client to send cookies and include the CSRF token on state-changing requests.
第三步:前端 — 配置 HTTP 客户端
配置你的 HTTP 客户端以发送 Cookie,并在状态变更请求中包含 CSRF 令牌。
import axios from "axios";
const api = axios.create({
baseURL: "/api",
withCredentials: true, // send cookies with every request
});
// Attach CSRF token from cookie to outgoing requests
api.interceptors.request.use((config) => {
const csrfToken = getCookie("XSRF-TOKEN");
if (csrfToken) {
config.headers["X-XSRF-TOKEN"] = csrfToken;
}
return config;
});
function getCookie(name) {
const match = document.cookie.match(new RegExp(`(^| )${name}=([^;]+)`));
return match ? decodeURIComponent(match[2]) : null;
}
export default api;
Step 4: Frontend — Auth State with Context API
Since the JWT lives in an httpOnly cookie (invisible to JS), we track authentication status—not the token itself—in React Context.
第四步:前端 — 使用 Context API 管理认证状态
由于 JWT 存储在 httpOnly Cookie 中(JS 不可见),我们在 React Context 中跟踪的是认证状态,而不是令牌本身。
import { createContext, useContext, useState, useEffect } from "react";
import api from "./api";
const AuthContext = createContext(null);
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
// On mount, check if an existing session is valid
useEffect(() => {
api.get("/auth/me")
.then((res) => setUser(res.data))
.catch(() => setUser(null))
.finally(() => setLoading(false));
}, []);
const login = async (credentials) => {
await api.post("/auth/login", credentials);
const res = await api.get("/auth/me");
setUser(res.data);
};
const logout = async () => {
await api.post("/auth/logout");
setUser(null);
};
return (
<AuthContext.Provider value={{ user, loading, login, logout }}>
{children}
</AuthContext.Provider>
);
}
export const useAuth = () => useContext(AuthContext);
Design note: We don’t store the token in Context or state—only the user’s authenticated status. The browser handles the cookie automatically. This is the safest pattern.
设计说明: 我们不会将令牌存储在 Context 或状态中,只存储用户的认证状态。浏览器会自动处理 Cookie。这是最安全的模式。