什么是JWT
以下是官方的解释
JSON Web Token (JWT) is an open standard (RFC 7519) that defines a compact and self-contained way for securely transmitting information between parties as a JSON object. This information can be verified and trusted because it is digitally signed. JWTs can be signed using a secret (with the HMAC algorithm) or a public/private key pair using RSA or ECDSA.
通俗来说就是一个被加密的JSON字符串,可以用来做授权、信息交换。
为什么要使用JWT
● 可以跨域使用
● 开销较小,减少服务端压力。
● 实现较为简单
如何在项目中集成
这里我记录一下使用 SpringCloud Gateway + SaTokenJwt + Angular 实现用户信息认证和 Token 自动刷新的功能。当然 SaToken 可以换成任意权限框架比如 Shiro、SpringSecurity 或者自己手写校验也可以。
逻辑
● 用户登录后利用 JWT 生成 AccessToken 和 Refresh Token。
● AccessToken 每次请求时都要携带,存活时间较短,一般为30分钟。
● Refresh Token 在 AccessToken 失效时使用,用来刷新AccessToken。
(这里有人问为什么不直接去使用Refresh Token或者把AccessToken时间设置成长一些,因为在网络传输中AccessToken可能会被窃取,如果 AccessToken 存活时间较长,那么盗窃者可以长时间利用 AccessToken 请求服务器资源,如果 AccessToken 时间较短,并且没有 Refresh Token 那么就有可能造成用户正常浏览时突然掉线,体验极差。这里网上也有挺多其他做法,比如使用redis保存Refresh Token ,但这样无疑有事加重了服务端压力,所以双token的优势就体现出来。当然如果 Refresh Token 被盗取也是很危险的,最好使用https或者定期更换WT的secret key来保证服务端的接口的安全。)
● Gateway中拦截url,验证header中 JWT token 有效性。
● JWT无效,后端返回403状态码,要求前端重新登陆。
● JWT过期,后端返回401状态码,要求前端使用 Refresh Token 换取新的 AccessToken 并且续签 Refresh Token,然后前端重新请求上一次的接口。
实现(代码顺序为逻辑顺序,部分代码已省略,只展示主要代码部分):
● 后台用户登录接口
/**
* 认证
*
* @return json
*/
@PostMapping("/authentication")
public AjaxResult authentication(@Validated @RequestBody LoginBody loginBody) {
try {
LoginUser loginUser = loginService.login(loginBody);
HttpHeaders headers = new HttpHeaders();
// 使用JWT获取AccessToken
String accessToken = tokenService.getAccessToken(loginUser.getUserid(), loginUser.getUsername());
// 使用JWT获取获取RefreshToken
String refreshToken = tokenService.getRefreshToken(loginUser.getUsername(),systemProperties.getRefreshTimeout());
headers.add(systemProperties.getSaTokenName(), accessToken);
headers.add(systemProperties.getSaRefreshTokenName(), refreshToken);
// 服务器向客户端暴露的header字段,用于客户端获取response的头部信息
headers.add(HttpHeaders.ACCESS_CONTROL_EXPOSE_HEADERS, systemProperties.getSaTokenName());
headers.add(HttpHeaders.ACCESS_CONTROL_EXPOSE_HEADERS, systemProperties.getSaRefreshTokenName());
return AjaxResult.success(messageSource.getMessage("info.login.success"), headers, null);
} catch (ServiceException e) {
return AjaxResult.fail(e.getMessage());
} catch (Exception e) {
log.error(e.getMessage(), e);
return AjaxResult.fail(messageSource.getMessage("error.login.api"));
}
}
● 前端登陆并获取token存入storage中:
login(credentials: any): Promise<any> {
const that = this;
return firstValueFrom(this.http
.post(`${environment.api}/auth/authentication`, credentials, {observe: 'response'})
.pipe(map(authenticateSuccess.bind(this))));
function authenticateSuccess(resp: any) {
const token = resp.headers.get('token');
const refreshToken = resp.headers.get('refreshToken');
if (token && refreshToken) {
that.storeAuthenticationToken('token', token);
that.storeAuthenticationToken('refreshToken', refreshToken);
}
return resp;
}
}
● 前端请求时header中携带token,Angular中需要实现HttpInterceptor接口并重写intercept方法:
export class AuthInterceptor implements HttpInterceptor {
intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
// 添加token
const header = {};
this.addToken(header, request);
request = request.clone({setHeaders: header});
return next.handle(request);
}
addToken(header, request) {
// 排除不需要认证的api
if (!this.needIdentity(request.url)) {
return;
}
const token = localStorage.getItem('token')
if (!!token) {
header['token'] = token;
}
}
}
● 后台Gateway拦截器。这里使用了SaToken并集成了JWT 查看文档。使用StpUtil.checkLogin()校验JWT有效性,如果抛出TOKEN_TIMEOUT异常则判断本次请求的token是否为RefreshToken,如果是则说明RefreshToken已过期,需要重新认证返回403状态码,否则直接抛出TOKEN_TIMEOUT异常,让全局异常拦截器识别并且返回401状态码。
public class AuthFilter implements GlobalFilter, Ordered {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
// 跳过不需要验证的路径
if (StringUtil.matches(request.getURI().getPath(), ignoreWhite.getWhites())) {
return chain.filter(exchange);
}
try {
// 写入全局上下文 (同步)
SaReactorSyncHolder.setContext(exchange);
// 调用satoken认证用户登录
StpUtil.checkLogin();
} catch (NotLoginException exception) {
// 判断token是否过期
if (NotLoginException.TOKEN_TIMEOUT.equals(exception.getType())) {
// 如果是refresh token 过期 直接返回INVALID_TOKEN异常
if (SessionUtil.isRefreshToken()) {
throw NotLoginException.newInstance(NotLoginException.INVALID_TOKEN, NotLoginException.INVALID_TOKEN);
}
}
log.error(exception.getMessage());
throw exception;
} finally {
// 清除上下文
SaReactorSyncHolder.clearContext();
}
// 写入全局上下文 (同步)
SaReactorSyncHolder.setContext(exchange);
// 执行
return chain.filter(exchange).contextWrite(ctx -> {
// 写入全局上下文 (异步)
ctx = ctx.put(SaReactorHolder.CONTEXT_KEY, exchange);
return ctx;
}).doFinally(r -> {
// 清除上下文
SaReactorSyncHolder.clearContext();
});
}
}
// token无效或者refresh token过期抛出403异常
if (NotLoginException.INVALID_TOKEN.equals(notLoginException.getType())) {
return ServletUtils.webFluxResponseWriter(response, AjaxResult.fail(), HttpStatus.FORBIDDEN);
}
// 刷新token返回401装状态码
if (NotLoginException.TOKEN_TIMEOUT.equals(notLoginException.getType())) {
return ServletUtils.webFluxResponseWriter(response, AjaxResult.fail(), HttpStatus.UNAUTHORIZED);
}
● 前端需要拦截api异常,获取状态码,并处理接下来的操作。这里依旧是用Angular的HttpInterceptor拦截异常,在intercept利用RXJS的pipe管道捕获HttpErrorResponse状态码
● 如果状态码为401则使用Refresh Token代替Access Token
● 请求后台刷新token接口,在管道中通过switchMap重新发送上一次请求,这次请求是用新的Access Token
● 如果状态码为403这直接返回登陆页面并清除所有storage中的token。
● 其余看代码和注释理解就可以。
export class ExceptionHandlerInterceptor implements HttpInterceptor {
private router: Router;
private principalService: PrincipalService;
private authService: AuthService;
private messageService: MessageService;
private unResolveError = [];
constructor(private injector: Injector) {
this.router = this.injector.get(Router);
this.principalService = this.injector.get(PrincipalService);
this.router = this.injector.get(Router);
this.messageService = this.injector.get(MessageService);
this.authService = this.injector.get(AuthService);
}
intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
return next.handle(request).pipe(
catchError((error: HttpErrorResponse): Observable<any> => {
// 后台返回401异常 需要拦截并且刷新token
if (error.status === 401) {
// 调用后台刷新token接口
return this.authService.refreshToken().pipe(
// 使用switchMap来刷新token
switchMap((res: any) => {
const body = res.body;
// 如果refreshToken 业务状态码不为200 则抛出异常 移除所有token 不再做任何处理
if (body && body.code && body.code !== ResponseEnum.SUCCESS) {
this.principalService.removeToken();
return throwError(res);
}
// 使用新的token
const updatedRequest = request.clone({
headers: request.headers.set('token', localStorage.getItem('token'))
});
// 重新发送请求
return next.handle(updatedRequest);
}),
// 如果refresh token异常 则抛出异常
catchError(refreshErr => {
return throwError(refreshErr);
})
);
}
// 抛出403异常表示所有token都过期 需要重新登录
else if (error.status === 403) {
// 这里使用一个数组储存所有未处理完的403异常
this.unResolveError.push(1);
this.principalService.removeToken();
// 如果有正在处理的异常则丢弃本次新加入的异常
if (this.unResolveError.length === 1) {
this.principalService.login().then(() => {
this.unResolveError = [];
});
}
}
// 如果请求超时则通知用户
else if (error instanceof TimeoutError) {
this.notifyErrorToUser('Timeout');
} else {
this.notifyErrorToUser(error.status);
}
return of(error);
})
);
}
}
/**
* 刷新token
*/
refreshToken() {
const token = localStorage.getItem('refreshtoken');
let headers = new HttpHeaders();
// 注意,这里的key依旧为token
headers = headers.set('token', token);
return this.http.post(`${environment.api}/auth/refresh`, null, {
headers: headers,
observe: 'response'
}).pipe(
tap(response => {
const token = response.headers.get('token');
const refreshToken = response.headers.get('refreshtoken');
this.storeAuthenticationToken('token', token);
this.storeAuthenticationToken('refreshtoken', refreshToken);
})
)
}
● 后端刷新token接口
/**
* 刷新token
*
* @return json
*/
@PostMapping("/refresh")
public AjaxResult refresh() {
HttpHeaders headers = new HttpHeaders();
LoginUser loginUser = SessionUtil.getUserInfo();
// 访问用的token
String accessToken = tokenService.getAccessToken(loginUser.getUserid(), loginUser.getUsername());
// 刷新用的token
String refreshToken = tokenService.getRefreshToken(loginUser.getUsername(),systemProperties.getRefreshTimeout());
// 下发续签token
headers.add(systemProperties.getSaTokenName(), accessToken);
headers.add(systemProperties.getSaRefreshTokenName(), refreshToken);
headers.add(HttpHeaders.ACCESS_CONTROL_EXPOSE_HEADERS, systemProperties.getSaTokenName());
headers.add(HttpHeaders.ACCESS_CONTROL_EXPOSE_HEADERS, systemProperties.getSaRefreshTokenName());
return AjaxResult.success(headers);
}