前言
最近在项目中遇到了一个认证相关的问题:
当前系统中,已经登录的用户仍然可以访问登录页 /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 用来描述你是谁当两者被清晰地区分以后,路由守卫、权限控制等功能都会变得简单且一致。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用。你还可以使用@来通知其他用户。