什么是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 可以换成任意权限框架比如 ShiroSpringSecurity 或者自己手写校验也可以。

逻辑

● 用户登录后利用 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);
}