第 8、9 篇讲过后端认证与授权,这一篇从登录页这个”截面”把前端的安全链路串起来。选它做切入点是因为登录流几乎是所有安全机制的汇合点:凭证提交、token 存储、请求附加、失效处理、路由保护、CSRF 防御全在这条线上。读懂这一条流,前端安全的八成就通了。版本口径:JHipster 9 + Angular 21,JWT 模式为主,OAuth2/OIDC 差异在文末对照。
全景:登录时序图
sequenceDiagram
participant U as 用户
participant L as LoginComponent
participant LS as LoginService
participant A as AuthServerProvider
participant B as 后端 /api/authentication
participant I as AuthInterceptor
participant G as userRouteAccessGuard
participant AC as AccountService
U->>L: 输入账号密码
L->>LS: login(credentials)
LS->>A: login()
A->>B: POST /api/authentication
B-->>A: 200 OK(Set-Cookie XSRF-TOKEN)
A->>A: token 写入 LocalStorageService
A-->>LS: 成功
LS->>AC: identity(true) 拉取账户
B-->>AC: GET /api/account(带 Bearer)
AC-->>L: 账户就绪
L->>G: 导航到目标路由
G->>AC: isAuthenticated() / 权限检查
G-->>U: 放行进入业务页面
Note over I,B: 后续每个请求经拦截器附加 Authorization: Bearer <jwt>
对应的源码目录在 core/auth/:login.service.ts、auth-server.provider.ts、account.service.ts、auth.interceptor.ts、user-route-access-guard.ts。下面逐段拆。
登录组件与 auth service
登录组件本身很薄:表单 + 提交 + 跳转,重活都在 service。
export class LoginComponent {
isLoggingIn = signal(false);
authenticationError = signal(false);
login(): void {
this.isLoggingIn.set(true);
this.authenticationError.set(false);
this.loginService
.login({ username: this.username(), password: this.password(), rememberMe: this.rememberMe() })
.subscribe({
next: () => {
this.router.navigateByUrl(this.redirectUrl ?? '/');
},
error: () => this.authenticationError.set(true),
});
}
}
LoginService.login() 委托给 AuthServerProvider,后者发 POST /api/authentication(注意:凭证只走这一次 HTTP,绝不落地到组件状态之外)。成功后立刻调 AccountService.identity(true) 拉取 /api/account——前端从此持有用户 id、login、authorities、语言等账户快照,后续所有权限判断读的是这份内存快照,不再重复请求。
redirectUrl 的来历值得知道:守卫拦截未登录访问时,把原始 URL 暂存在 StateStorageService(sessionStorage),登录成功后取回来跳转。用户体验上就是”登录完回到你刚才想去的那一页”。
JWT 存储:localStorage 还是 cookie
AuthServerProvider 拿到 token 后写到 LocalStorageService(生成问答里可选 localStorage 或 sessionStorage)。这是前端安全里最经典的取舍题:
| 维度 | localStorage | Cookie(HttpOnly) |
|---|---|---|
| XSS 可窃取 | 是(JS 可读) | 否(HttpOnly 对 JS 不可见) |
| CSRF 防护负担 | 低(不自动携带) | 高(需 CSRF token/SameSite) |
| 跨子域共享 | 需代码显式传递 | 设 domain 即天然共享 |
| 过期管理 | 前端自行判断 | 浏览器自动清理 |
| 子域劫持面 | 无 | 有(兄弟子域可写 cookie) |
JHipster 默认 localStorage + 防御性缓解:CSP 收紧、依赖扫描、token 有效期短(默认 12h,rememberMe 延长到更长)。我们的团队结论:
- 纯同源 SPA:默认方案可接受,把 token 有效期压短是收益最大的单项改进;
- 多子域生态/有iframe 嵌入:改用 HttpOnly cookie 方案,后端 Spring Security 换 token 端点 + cookie 写出,同时把 SameSite 设为
Lax并显式开 CSRF; - 什么都别放 sessionStorage 当”安全加强”——XSS 面一样大,还多了关标签页即登出的骚扰。
无论哪种存储,都必须假设”token 可能被偷”,防线在纵深:短有效期、敏感操作二次确认、服务端可吊销(登出黑名单)。
请求拦截器:Bearer 的统一附加点
AuthInterceptor 是所有出站请求的咽喉:
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const localStorageService = inject(LocalStorageService);
const token = localStorageService.retrieve('authenticationToken');
if (token && !isAnonymousRequest(req.url)) {
const authReq = req.clone({ setHeaders: { Authorization: `Bearer ${token}` } });
return next(authReq);
}
return next(req);
};
细节里的门道:isAnonymousRequest 排除了登录本身、公共资源等匿名端点——往这些请求上附加无效 token 反而会触发不必要的 401 循环。
401 处理:失效之后发生什么
token 过期后第一个业务请求返回 401,处理链路:
ErrorInterceptor(在 auth 拦截器外层)捕获非 2xx 响应;- 识别 401 且当前确实”已登录”——说明是会话失效而非”本来就没登录”;
- 广播登出事件(事件总线),
AccountService清空账户快照; - 导航到登录页,带上当前 URL 供登录后回跳;
- 弹一条本地化的”会话已过期”提示。
为什么前端不做静默刷新? JWT 模式下 JHipster 后端默认不提供 refresh token 端点,刷新意味着延长同一凭证的生命周期。我们的选择是保持短有效期 + 明确的重新登录,安全模型更简单。真正需要”无感续期”的场景(长时间填写的表单页),解法是在保存草稿的 API 层做心跳保活,而不是给 token 续命。
与 403 的区分要清楚:401 是”没登录或凭证失效”,403 是”登录了但没权限”。403 不触发登出,只弹权限提示——把 403 当 401 处理是常见 bug,用户会被莫名踢出登录态。
路由守卫:AuthenticatedGuard 与权限守卫
Angular 21 的函数式守卫,生成的 userRouteAccessGuard 同时承担认证与授权:
export const userRouteAccessGuard: CanActivateFn = async (route, state) => {
const accountService = inject(AccountService);
const router = inject(Router);
const stateStorageService = inject(StateStorageService);
if (accountService.isAuthenticated()) {
const account = await accountService.identity().toPromise();
const authorities = route.data['authorities'] as string[] | undefined;
if (!authorities || authorities.length === 0) return true;
if (account?.authorities.some(a => authorities.includes(a))) return true;
router.navigate(['accessdenied']);
return false;
}
stateStorageService.storeUrl(state.url); // 记住来意
router.navigate(['/forbidden']); // 或跳登录
return false;
};
路由上的声明式用法(第 13 篇已见):
{
path: 'admin/user-management',
data: { authorities: ['ROLE_ADMIN'] },
canActivate: [userRouteAccessGuard],
}
三层防线缺一不可,只做任何一层都不算完:
- 路由守卫:管”页面能不能进”(体验层);
- 模板指令:管”按钮能不能看”(
*jhiHasAnyAuthority,基于 account 信号的 structural directive); - 后端
@PreAuthorize:管”API 能不能调”(安全层,第 9 篇)。
前端两层都能被绕过(改 devtools、直接 curl),它们的价值是体验与指引,不是安全。安全只存在于后端,这是每次安全评审都要重申的原则。
CSRF 与 SameSite
JWT 存 localStorage 时,CSRF 面天然很小——浏览器不会自动附加凭证。但两种情况下必须处理 CSRF:
- 切到 cookie 存 token;
- 使用 OIDC 模式(授权码存在 cookie 里)。
Spring Security 的默认方案是 double-submit cookie:后端发 XSRF-TOKEN cookie,Angular 的 HttpClient 自带 HttpClientXsrfModule 会在同源请求时把这个 cookie 的值复制到 X-XSRF-TOKEN header 回传——对 JS 可读、对攻击者跨站不可读,服务端比对两者一致。
server:
servlet:
session:
cookie:
same-site: lax
SameSite=Lax 挡掉绝大多数跨站自动携带,作为纵深一层与 CSRF token 叠加。另注意 JHipster 生成的后端在 API 分支禁用了 CSRF(JWT 无 cookie 场景),切 cookie 方案时要自己把它打开——这是改造登录存储时最容易漏的一步,漏了就是把门拆了。
OIDC 分支速览
问答里选 OAuth 2.0/OIDC 后,前端链路换成 Keycloak/Okta 标准 flow:OAuth2AuthService 走授权码 + PKCE 重定向,token 存内存,刷新令牌在 HttpOnly cookie 由后端会话管理,登出走 OIDC end-session 端点。生成的守卫与拦截器接口不变,业务组件无感知。什么时候选它:已有企业 IdP、需要 SSO、需要 MFA——这三个任意一个成立,就直接 OIDC,不要自建密码体系。
小结
- 登录流 = 凭证提交(仅一次 HTTP)→ token 存储 → 账户快照 → 守卫放行,读
core/auth/五个文件即可通盘理解; - 存储取舍:同源 SPA 默认 localStorage 可接受;多子域/嵌入场景换 HttpOnly cookie 并补齐 CSRF 与 SameSite;
- 401 触发登出回跳、403 只弹提示,两者混处理是高频 bug;
- 前端守卫与指令是体验层,安全边界只认后端
@PreAuthorize; - 企业 IdP/SSO/MFA 任一需求出现,选 OIDC 模式。
系列导航
- 上一篇:Angular 端架构——生成的代码怎么读
- 下一篇:改造 UI——不动生成代码的优雅方式
- 相关阅读:认证机制选型与实践 · 权限模型设计 · 表单与表格的工程化