CHARLIE SAYS

查理如是说
DATE 2026-08-24
THEME
SERIES / JHIPSTER / P-229 · JHipster 项目开发

JHipster 开发 14:从登录页读懂前端安全流

第 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.tsauth-server.provider.tsaccount.service.tsauth.interceptor.tsuser-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),登录成功后取回来跳转。用户体验上就是”登录完回到你刚才想去的那一页”。

AuthServerProvider 拿到 token 后写到 LocalStorageService(生成问答里可选 localStorage 或 sessionStorage)。这是前端安全里最经典的取舍题:

维度localStorageCookie(HttpOnly)
XSS 可窃取是(JS 可读)否(HttpOnly 对 JS 不可见)
CSRF 防护负担低(不自动携带)高(需 CSRF token/SameSite)
跨子域共享需代码显式传递设 domain 即天然共享
过期管理前端自行判断浏览器自动清理
子域劫持面有(兄弟子域可写 cookie)

JHipster 默认 localStorage + 防御性缓解:CSP 收紧、依赖扫描、token 有效期短(默认 12h,rememberMe 延长到更长)。我们的团队结论:

  1. 纯同源 SPA:默认方案可接受,把 token 有效期压短是收益最大的单项改进;
  2. 多子域生态/有iframe 嵌入:改用 HttpOnly cookie 方案,后端 Spring Security 换 token 端点 + cookie 写出,同时把 SameSite 设为 Lax 并显式开 CSRF;
  3. 什么都别放 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,处理链路:

  1. ErrorInterceptor(在 auth 拦截器外层)捕获非 2xx 响应;
  2. 识别 401 且当前确实”已登录”——说明是会话失效而非”本来就没登录”;
  3. 广播登出事件(事件总线),AccountService 清空账户快照;
  4. 导航到登录页,带上当前 URL 供登录后回跳;
  5. 弹一条本地化的”会话已过期”提示。

为什么前端不做静默刷新? 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],
}

三层防线缺一不可,只做任何一层都不算完:

  1. 路由守卫:管”页面能不能进”(体验层);
  2. 模板指令:管”按钮能不能看”(*jhiHasAnyAuthority,基于 account 信号的 structural directive);
  3. 后端 @PreAuthorize:管”API 能不能调”(安全层,第 9 篇)。

前端两层都能被绕过(改 devtools、直接 curl),它们的价值是体验与指引,不是安全。安全只存在于后端,这是每次安全评审都要重申的原则。

CSRF 与 SameSite

JWT 存 localStorage 时,CSRF 面天然很小——浏览器不会自动附加凭证。但两种情况下必须处理 CSRF:

  1. 切到 cookie 存 token;
  2. 使用 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 模式。

系列导航

← 软件工程 014:开发实践:测试驱动开发(TDD) 目录 开发工具 014:正则表达式:常用正则表达式 →
← 返回文章列表