1

前言

最近在项目中遇到了一个认证相关的问题:

当前系统中,已经登录的用户仍然可以访问登录页 /login

虽然不会影响功能,但从业务角度来看并不合理:

  • 已登录用户应该直接进入系统首页
  • 未登录用户才应该看到登录界面

因此决定对此进行优化。


现有认证方案

项目当前已经存在一个根路由守卫:

{
  path: '',
  component: LayoutComponent,
  canActivate: [() => inject(UserService).getCurrentLoginUser().pipe(
    filter(v => typeof v !== 'undefined'),
    catchError(() => {
      return of(inject(Router).createUrlTree(['/login']));
    })
  )],
  children: [...]
}

其核心逻辑是:

进入系统页面
↓
请求当前用户信息
↓
存在用户
↓
允许访问

请求失败
↓
跳转登录页

因此系统已经具备了基础认证能力。


第一版方案:登录页增加路由守卫

最自然的想法是:

进入 /login 时检查当前用户是否已经登录。

如果已登录,则直接跳转首页。

canActivate: [() => {
  const userService = inject(UserService);
  const router = inject(Router);

  return userService.getCurrentLoginUser().pipe(
    map(user => {
      if (user) {
        return router.createUrlTree(['/dashboard']);
      }

      return true;
    }),
    catchError(() => of(true))
  );
}]

逻辑非常简单:

进入登录页
↓
请求当前用户信息

已登录
↓
跳转首页

未登录
↓
显示登录页

看起来似乎没有问题。


实际运行后出现的问题

测试过程中发现:

系统开始不断请求 getCurrentLoginUser() 接口。

浏览器 Network 面板中不断出现几百次:

401
401
401
401
401
...

最开始以为是路由守卫的问题。

后来排查发现,真正的问题出在全局 HTTP 异常拦截器。


问题根源:401 自动跳转登录页

项目中存在如下逻辑:

case 401:
  const userService = this.injector.get(UserService);
  userService.setCurrentLoginUser(undefined);
  this.router.navigateByUrl('/login').then();
  break;

其含义非常明确:

任何请求
↓
返回401
↓
强制跳转登录页

单独看没有问题。

问题在于:

登录页本身的守卫也会请求当前用户信息。

于是形成了如下调用链:

访问 /login
↓
执行 Login Guard
↓
调用 getCurrentLoginUser()
↓
接口返回401
↓
HttpInterceptor 捕获401
↓
navigate('/login')
↓
再次进入 /login
↓
执行 Login Guard
↓
调用 getCurrentLoginUser()
↓
接口返回401
...

最终形成无限循环。


第二版方案:直接判断 Token

既然请求接口会产生循环,那么是否可以直接判断本地 Token?

例如:

canActivate: [() => {
  if (sessionStorage.getItem('x-auth-token')) {
    return inject(Router).createUrlTree(['/dashboard']);
  }

  return true;
}]

从功能角度来看,这个方案能够正常工作。

存在 Token
↓
跳转首页

不存在 Token
↓
显示登录页

但是很快发现这里存在一个设计问题。


Token 存在,不代表用户已登录

老师指出:

不应该依赖缓存中的 Token 判断登录状态。

原因很简单:

Token存在
≠
Token有效

例如:

用户昨天登录
↓
浏览器保存Token
↓
Token今天已经过期
↓
sessionStorage中仍然存在Token

此时:

sessionStorage.getItem('x-auth-token')

仍然能够获取到值。

但实际上:

用户已经处于未登录状态

因此:

Token 是凭证
不是登录状态

这是认证系统设计中一个非常重要的区别。


更合理的方案

老师给出的建议是:

系统启动时请求一次 Me 接口。

登录页守卫根据当前用户状态进行判断,而不是根据 Token 判断。

也就是说:

应用启动
↓
请求 /me
↓
获取当前用户
↓
写入 UserService
↓
后续所有页面共享该状态

这样路由守卫就不需要再次请求接口。


最终方案

1. 应用启动时初始化用户信息

利用 APP_INITIALIZER:

{
  provide: APP_INITIALIZER,
  multi: true,
  useFactory: () => {
    const userService = inject(UserService);

    return () => firstValueFrom(
      userService.getCurrentLoginUser()
    );
  }
}

启动流程:

Angular启动
↓
执行 APP_INITIALIZER
↓
请求当前用户信息
↓
写入 UserService
↓
应用开始渲染

2. 登录页守卫只读取状态

canActivate: [() => {
  const state = inject(UserService).getState();

  if (state.currentUser) {
    return inject(Router).createUrlTree(['/dashboard']);
  }

  return true;
}]

此时:

进入登录页
↓
读取 currentUser

存在
↓
跳转首页

不存在
↓
显示登录页

整个过程不再产生额外网络请求。


一个被忽略的问题:根路由守卫真的可以直接读取 State 吗?

当我把登录页守卫改为读取 currentUser 后,一个新的想法出现了:

既然当前用户信息已经保存在 State 中,那么根路由守卫是否也可以直接读取 State,而不再请求 /me 接口?

于是我将根路由守卫改成了这样:

canActivate: [() => {
  const state = inject(UserService).getState();

  if (state.currentUser) {
    return true;
  }

  return inject(Router).createUrlTree(['/login']);
}]

理论上看起来完全合理。

但是实际测试时却出现了一个奇怪的现象:

点击登录
↓
login接口返回 200
↓
页面没有跳转
↓
仍然停留在登录页

登录接口明明已经成功:

@RequestMapping("login")
public void login() {
}

后端使用的是 Spring Security,登录成功后 Session 已经建立。

那么问题来了:

用户明明已经登录成功了,为什么路由守卫仍然认为用户未登录?

问题定位

登录成功后打印当前状态:

onLogin = (user: {username: string; password: string}) => {
  return this.userService.login(user).pipe(
    tap(() => {
      console.log(this.userService.getState().currentUser);
      this.router.navigate(['/dashboard']);
    })
  );
};

输出结果:

undefined

真相开始浮出水面。

登录接口成功以后:

Spring Security Session 已建立

但是:

currentUser 并没有更新

此时系统出现了一种非常有意思的状态:

后端认为你已经登录
前端认为你还没有登录

于是当路由跳转到:

/dashboard

时:

根路由守卫
↓
读取 currentUser
↓
发现是 undefined
↓
重定向回 /login

最终表现出来就是:

登录成功
↓
无法进入系统

为什么点击两次登录却能成功?

为了验证猜想,我尝试主动调用:

this.userService.getCurrentLoginUser();
/**
 * 设置当前登录用户
 * @param user 登录用户
 */
setCurrentLoginUser(user: User | undefined | null): void {
  const state = this.getState();
  state.currentUser = user;
  this.next(state);
}

@Action()
getCurrentLoginUser(): Observable<User | undefined> {
  return this.httpClient.get<User>(`${this.url}/me`)
    .pipe(tap(user => this.setCurrentLoginUser(user)),
      catchError((error: HttpErrorResponse) => {
        const state = this.getState();
        state.currentUser = undefined;
        this.next(state);
        return of(undefined);
      }));
}

随后发现一个更加诡异的现象:

第一次点击登录
↓
失败

第二次点击登录
↓
成功

原因其实很简单。

getCurrentLoginUser() 内部会调用:

setCurrentLoginUser(user)

将用户信息写入 State。

tap(user => this.setCurrentLoginUser(user))

于是第一次登录时:

登录成功
↓
Session 建立
↓
请求 /me
↓
currentUser 被更新

此时虽然当前导航已经失败,但 State 已经拥有用户信息。

第二次点击登录:

登录成功
↓
根路由守卫读取 currentUser
↓
通过校验
↓
成功进入系统

至此问题彻底定位。


真正的问题是什么?

表面上看:

根路由守卫读取 State 失效

实际上根本原因是:

State 与认证状态没有同步

更准确地说:

Session 已建立
≠
CurrentUser 已建立

系统中的登录态出现了短暂的不一致。

而路由守卫恰恰读取的是:

currentUser

而不是:

Session

因此它会做出完全合理但不符合预期的判断。


总结

这次看似是在实现一个登录页路由守卫。

实际上却让我重新理解了认证系统中的几个概念:

Token 是认证凭证

Session 是认证结果

CurrentUser 是业务状态

三者并不等价。

而前端真正应该依赖的,往往是最后一个。

所以最终的设计原则可以总结为:

Token 用来证明你是谁

CurrentUser 用来描述你是谁

当两者被清晰地区分以后,路由守卫、权限控制等功能都会变得简单且一致。



姜姜
56 声望9 粉丝

行百里者半九十