1. 实现目标
在现代Web应用中,我们需要一个既安全又用户友好的认证系统。具体来说,我们要实现:
- 安全的身份认证
- 无感知的token刷新
- 防范常见的安全威胁(XSS、CSRF等)
- 支持多端同步登录状态
- 支持主动登出和token失效
该文章中的完整代码在ibuy-portal-backend
2. 方案选择
2.1 基础JWT认证
我们知道JWT是无状态的,他一般会经过后端setCookie到浏览器,或者经过后端api交给前端,使用localStorage保存。并且为了保证安全,一般都会将token的过期时间设置的尽量短,也会带来用户体验问题。下面是总结的问题。
- 安全性问题
-
- token存储在前端,容易被XSS攻击窃取
- 一旦泄露,在过期前都是有效的
- 无法主动使token失效, 难以实现主动踢出用户
- 用户体验问题
-
- token过期后用户需要重新登录
- 多个请求同时失效时会重复刷新
2.2 双Token + Redis方案的优势
通过双token + token的版本控制 + redis我们可以从下面三个方面来改善问题
- 提升安全性
-
- Refresh Token通过httpOnly cookie存储,避免XSS攻击
- 采用Redis白名单机制,支持token主动失效
- 通过Token版本控制,支持强制登出
- 优化用户体验
-
- Access Token过期时自动使用Refresh Token刷新
- 并发请求队列,避免重复刷新
3. 图解流程
3.1. 整体架构
graph TD
subgraph 前端
A[用户界面] -->|1.提交账号密码| B[登录请求]
B -->|2.存储到本地| C[[AccessToken]]
C -->|3.携带请求| D[业务API]
D -->|4.返回401| E[触发刷新]
E -->|5.读取Cookie| F[[RefreshToken]]
F -->|6.获取新Token| C
end
subgraph 后端服务
G[认证模块] -->|a.验证用户| H[(数据库)]
G -->|b.生成令牌| I[[JWT模块]]
G -->|c.存储白名单| J[(Redis)]
J -->|d.校验有效性| G
end
subgraph 安全机制
K[HTTP-only] --> F
L[SameSite=lax] --> F
M[HTTPS] --> B
N[Token版本控制] --> H
end
B --> G
E --> G
G -->|Set-Cookie| F
D --> G
J -.->|自动清理| G
3.2. 登录流程
sequenceDiagram
participant 用户
participant 前端
participant 后端
participant Redis
用户->>前端: 提交账号密码
前端->>后端: POST /auth/login
后端->>数据库: 验证用户凭证
数据库-->>后端: 返回用户数据
后端->>JWT: 生成AccessToken(30m)
后端->>JWT: 生成RefreshToken(7d)
后端->>Redis: 存储RefreshToken
后端->>前端: 返回AccessToken + Set-Cookie
前端->>本地存储: 保存AccessToken
3.3. 登出流程
sequenceDiagram
participant 用户
participant 前端
participant 后端
participant Redis
用户->>前端: 点击登出
前端->>后端: POST /auth/logout
后端->>Redis: 删除RefreshToken记录
后端->>数据库: 递增tokenVersion
后端-->>前端: 清除Cookie指令
前端->>本地存储: 清除AccessToken
3.4. token自动刷新流程
sequenceDiagram
participant 前端
participant 后端
participant Redis
前端->>后端: 携带过期AccessToken发起请求
后端-->>前端: 返回401状态码
前端->>后端: 携带RefreshToken请求刷新
后端->>Redis: 验证RefreshToken有效性
Redis-->>后端: 返回验证结果
后端->>JWT: 生成新AccessToken
后端->>JWT: 生成新RefreshToken
后端->>Redis: 更新RefreshToken
后端->>前端: 返回新AccessToken + Set-Cookie
前端->>本地存储: 更新AccessToken
4. 代码整体实现
4.1 后端实现
4.1.1 用户实体设计
// src/mall-service/mall-service-system/users/entitys/sys-user.entity.ts
@Entity('ibuy_admin')
export class SysUsersEntity {
@PrimaryGeneratedColumn()
id: number;
@Column({ name: 'login_name', unique: true })
loginName: string;
@Column()
password: string;
@Column()
status: string;
@Column({ default: 0 })
tokenVersion: number; // token版本号,用于使token失效
}
4.1.2 认证服务核心实现
// src/mall-service/mall-service-system/auth/auth.service.ts
@Injectable()
export class AuthService {
constructor(
private readonly usersService: UsersService,
private readonly jwtService: JwtService,
@InjectRedis() private readonly redis: Redis,
private configService: ConfigService,
) {
this.accessTokenExpiresIn = this.configService.get('JWT_ACCESS_EXPIRES_IN');
this.refreshTokenExpiresIn = this.configService.get(
'JWT_REFRESH_EXPIRES_IN',
);
}
// Redis白名单管理
private async storeRefreshToken(
userId: number,
refreshToken: string,
): Promise<void> {
const key = `refresh_token:${userId}`;
await this.redis.set(
key,
refreshToken,
'EX',
parseTimeToSeconds(this.refreshTokenExpiresIn),
);
}
private async isRefreshTokenValid(
userId: number,
refreshToken: string,
): Promise<boolean> {
const storedToken = await this.redis.get(`refresh_token:${userId}`);
return storedToken === refreshToken;
}
// Cookie安全配置
private setCookies(res: Response, refreshToken: string): void {
res.cookie('refresh_token', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'development' ? false : true,
sameSite: 'lax',
maxAge: parseTimeToSeconds(this.refreshTokenExpiresIn) * 1000,
});
}
// 登录实现
async signIn(
loginName: string,
pass: string,
res: Response,
): Promise<Result<{ access_token: string; userInfo: any }>> {
// 验证用户
const { data: user } = await this.usersService.findByLoginName(loginName);
const isMatch = await bcrypt.compare(pass, user?.password);
if (!isMatch) throw new UnauthorizedException();
// 生成token
const payload = {
user_id: user.id,
loginName: user.loginName,
tokenVersion: user.tokenVersion,
};
const access_token = await this.jwtService.signAsync(payload, {
expiresIn: this.accessTokenExpiresIn,
});
const refresh_token = await this.jwtService.sign(payload, {
expiresIn: this.refreshTokenExpiresIn,
});
// 存储refresh token
await this.storeRefreshToken(user.id, refresh_token);
this.setCookies(res, refresh_token);
return new Result(
{
access_token,
userInfo: {
id: user.id,
loginName: user.loginName,
roles: userRoles,
},
},
'登录成功',
);
}
// 刷新token
async refreshToken(refreshToken: string, res: Response) {
try {
// 验证token
const decoded = this.jwtService.verify(refreshToken);
const userId = decoded.user_id;
// 验证白名单和版本号
const isValid = await this.isRefreshTokenValid(userId, refreshToken);
if (!isValid) {
throw new UnauthorizedException('Refresh token不在白名单中');
}
const { data: user } = await this.usersService.findById(userId);
if (decoded.tokenVersion !== user.tokenVersion) {
throw new UnauthorizedException('Token版本已失效');
}
// 生成新token
const payload = {
user_id: user.id,
loginName: user.loginName,
tokenVersion: user.tokenVersion,
};
const access_token = this.jwtService.sign(payload, {
expiresIn: this.accessTokenExpiresIn,
});
const new_refresh_token = this.jwtService.sign(payload, {
expiresIn: this.refreshTokenExpiresIn,
});
// 更新Redis和Cookie
await this.invalidateRefreshToken(userId);
await this.storeRefreshToken(userId, new_refresh_token);
this.setCookies(res, new_refresh_token);
return new Result({ access_token }, 'Token刷新成功');
} catch (e) {
throw new UnauthorizedException(
e instanceof TokenExpiredError ? 'token已过期' : e,
);
}
}
// 登出实现
async logout(userId: number): Promise<void> {
await this.invalidateRefreshToken(userId);
await this.usersService.incrementTokenVersion(userId);
}
}
4.1.3 控制器实现
// src/mall-service/mall-service-system/auth/auth.controller.ts
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}
@Public()
@HttpCode(HttpStatus.OK)
@Post('login')
async signIn(
@Body() user: Record<string, any>,
@Res({ passthrough: true }) res: Response,
) {
return this.authService.signIn(user.loginName, user.password, res);
}
@Public()
@Get('refresh')
async refresh(@Request() req, @Res({ passthrough: true }) res: Response) {
const refreshToken = req.cookies.refresh_token;
if (!refreshToken) {
throw new UnauthorizedException('No refresh token provided');
}
return this.authService.refreshToken(refreshToken, res);
}
@Post('logout')
async logout(@Request() req, @Res({ passthrough: true }) res: Response) {
const userId = req.user.user_id;
await this.authService.logout(userId);
res.clearCookie('refresh_token');
return new Result(null, '退出登录成功');
}
}
4.1.4 工具类
export default function parseTimeToSeconds(time) {
const units = {
s: 1,
m: 60,
h: 60 * 60,
d: 24 * 60 * 60,
};
const unit = time.slice(-1);
const value = parseInt(time.slice(0, -1), 10);
if (isNaN(value) || !units[unit]) {
throw new Error('Invalid time format');
}
return value * units[unit];
}
4.2. 前端实现
4.2.1 请求拦截器
// 请求拦截器:自动添加token
const authHeaderInterceptor = (url: string, options: RequestConfig) => {
const Token = localStorage.getItem('token');
const authHeader = { Authorization: `Bearer ${Token}` };
return {
url: `${url}`,
options: { ...options, interceptors: true, headers: authHeader },
};
};
// 响应拦截器配置
export const request: RequestConfig = {
timeout: 1000 * 30,
errorConfig: {
errorHandler: async (error: any) => {
if (error.response?.status === 401) {
return handleRefreshToken(error);
}
throw error;
},
},
requestInterceptors: [authHeaderInterceptor],
};
4.2.2 Token刷新处理
let isRefreshing = false;
let requests: any[] = [];
const handleRefreshToken = async (error: any) => {
const { response } = error;
if (!isRefreshing) {
isRefreshing = true;
try {
// 刷新token
const result = await refreshToken();
if (result?.data?.access_token) {
localStorage.setItem('token', result.data.access_token);
// 重试当前请求
const originalRequest = response.config;
originalRequest.headers.Authorization = `Bearer ${localStorage.getItem('token')}`;
const retryResponse = await axios(originalRequest.url, originalRequest);
// 重试队列中的请求
requests.forEach((cb) => cb());
requests = [];
return retryResponse;
}
} catch (refreshError) {
history.push(loginPath);
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
// 将请求加入队列
return new Promise((resolve) => {
requests.push(async () => {
try {
const originalRequest = response.config;
originalRequest.headers.Authorization = `Bearer ${localStorage.getItem('token')}`;
const retryResponse = await axios(originalRequest.url, originalRequest);
resolve(retryResponse);
} catch (retryError) {
resolve(Promise.reject(retryError));
}
});
});
};
4.2.3 路由守卫
export const layout: RunTimeLayoutConfig = ({ initialState }) => {
return {
onPageChange: () => {
const { location } = history;
// 检查登录状态
if (!localStorage.getItem('token') && location.pathname !== loginPath) {
history.push(loginPath);
}
},
// ... 其他配置
};
};
4.3. 相关配置
4.3.1 环境变量
# JWT配置
JWT_SECRET=your-secret-key
JWT_ACCESS_EXPIRES_IN=30m
JWT_REFRESH_EXPIRES_IN=7d
# Redis配置
REDIS_HOST=localhost
REDIS_PORT=6379
4.3.2 NestJS模块配置
// src/mall-service/mall-service-system/auth/auth.module.ts
@Module({
imports: [
SysUserModule,
JwtModule.registerAsync({
inject: [ConfigService],
useFactory: async (configService: ConfigService) => ({
global: true,
secret: configService.get('JWT_SECRET'),
}),
}),
],
providers: [AuthService],
controllers: [AuthController],
})
export class AuthModule {}
5. 后续优化方向
-
功能扩展
- 支持多设备登录管理
- 添加登录设备限制
- 实现登录日志追踪
-
性能优化
- 引入token缓存机制
- 优化并发请求处理
- 添加限流保护
-
安全加强
- 添加设备指纹验证
- 支持2FA认证