1. 鉴权流程
浏览器发送请求时。请求头会携带键值对"authorization":jwt
网关先解析jwt令牌,做第一次鉴权,鉴权完成后将解析的user对象的id添加到请求头中:user-info = 用户id;
微服务的拦截器会获取请求头中的user-info,然后存入到UserContext(底层基于ThreadLocal),这样后续的业务处理时就能直接从UserContext中获取用户了。
网关鉴权后,微服务为什么还要做鉴权?防止请求越过网关直接发给微服务
2. 网关鉴权过滤器
2.1 filter方法
@Overridepublic Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {// 1.获取请求request信息ServerHttpRequest request = exchange.getRequest();String method = request.getMethodValue();String path = request.getPath().toString();String antPath = method + ":" + path;// 2.判断是否是无需登录的路径if(isExcludePath(antPath)){// 直接放行return chain.filter(exchange);}// 3.尝试获取用户信息 AUTHORIZATION_HEADER --- "authorization"List<String> authHeaders = exchange.getRequest().getHeaders().get(AUTHORIZATION_HEADER);String token = authHeaders == null ? "" : authHeaders.get(0);R<LoginUserDTO> r = authUtil.parseToken(token);// 4.如果用户是登录状态即jwt校验成功,尝试更新请求头,传递用户idif(r.success()){exchange.mutate()// USER_HEADER --- "user-info".request(builder -> builder.header(USER_HEADER, r.getData().getUserId().toString())).build();}// 5.校验权限authUtil.checkAuth(antPath, r);// 6.放行return chain.filter(exchange);}private boolean isExcludePath(String antPath) {for (String pathPattern : authProperties.getExcludePath()) {if(antPathMatcher.match(pathPattern, antPath)){return true;}}return false;}@Overridepublic int getOrder() {// 越大优先级越低return 1000;}
2.2 jwt解析工具方法
public R<LoginUserDTO> parseToken(String token) {// 1.校验token是否为空if(StringUtils.isBlank(token)){return R.error(INVALID_TOKEN_CODE, INVALID_TOKEN);}JWT jwt = null;try {// cn.hutool.jwt.JWTjwt = JWT.of(token).setSigner(jwtSignerHolder.getJwtSigner());} catch (Exception e) {return R.error(INVALID_TOKEN_CODE, INVALID_TOKEN);}// 2.校验jwt是否有效 cn.hutool.jwt.JWTif (!jwt.verify()) {// 验证失败,返回空return R.error(INVALID_TOKEN_CODE, INVALID_TOKEN);}// 3.校验是否过期 cn.hutool.jwt.JWTValidatortry {JWTValidator.of(jwt).validateDate();} catch (ValidateException e) {return R.error(EXPIRED_TOKEN_CODE, EXPIRED_TOKEN);}// 4.数据格式校验 cn.hutool.jwt.JWT PAYLOAD_USER_KEY --- "user"Object userPayload = jwt.getPayload(PAYLOAD_USER_KEY);if (userPayload == null) {// 数据为空return R.error(INVALID_TOKEN_CODE, INVALID_TOKEN_PAYLOAD);}// 5.数据解析LoginUserDTO userDTO;try {// cn.hutool.json.JSONuserDTO = ((JSONObject)userPayload).toBean(LoginUserDTO.class);} catch (RuntimeException e) {// token格式有误return R.error(INVALID_TOKEN_CODE, INVALID_TOKEN_PAYLOAD);}// 6.返回return R.ok(userDTO);}public void checkAuth(String antPath, R<LoginUserDTO> r){// 1.判断是否是需要权限的路径String matchPath = findMatchPath(antPath);if(matchPath == null){// 没有权限限制,直接放行return;}// 2.判断是否登录成功if(!r.success()){// 未登录,直接报错throw new UnauthorizedException(r.getCode(), r.getMsg());}// 3.获取当前路径所需权限PrivilegeRoleDTO pathPrivilege = findPathPrivilege(matchPath);// 4.权限判断Set<Long> requiredRoles = pathPrivilege.getRoles();if (!CollectionUtil.contains(requiredRoles, r.getData().getRoleId())) {// 没有访问权限throw new ForbiddenException(FORBIDDEN);}}private String findMatchPath(String antPath){String matchPath = null;for (String pathPattern : paths) {// org.springframework.util.AntPathMatcherif(antPathMatcher.match(pathPattern, antPath)){matchPath = pathPattern;break;}}return matchPath;}private PrivilegeRoleDTO findPathPrivilege(String path){return privileges.get(path);}
3. 微服务拦截器
每个微服务对鉴权都有需求,所以抽取出来放到common中,每个微服务在pom文件中引入该模块。
拦截器包括用户拦截(即鉴权)和登录拦截。
spring会根据当前微服务的bootstrap.yml,决定是否配置登录拦截器,并且配置需要登录的路径和不需要登录的路径。
当UserInfoInterceptor从请求头中取出user-info时,会存入ThreadLocal,再放行。
当网关中判断是无需登录的路径做出放行(鉴权通过)时,UserInfoInterceptor也会放行;然后根据各个微服务的配置判断当前路径是否需要登录。
WebMvcConfigurer,注册拦截器
@Configuration
@EnableConfigurationProperties(ResourceAuthProperties.class)
public class ResourceInterceptorConfiguration implements WebMvcConfigurer {private final ResourceAuthProperties authProperties;@Autowiredpublic ResourceInterceptorConfiguration(ResourceAuthProperties resourceAuthProperties) {this.authProperties = resourceAuthProperties;}@Overridepublic void addInterceptors(InterceptorRegistry registry) {// 1.添加用户信息拦截器registry.addInterceptor(new UserInfoInterceptor()).order(0);// 2.是否需要做登录拦截if(!authProperties.getEnable()){// 无需登录拦截return;}// 2.添加登录拦截器InterceptorRegistration registration = registry.addInterceptor(new LoginAuthInterceptor()).order(1);// 2.1.添加拦截器路径if(CollUtil.isNotEmpty(authProperties.getIncludeLoginPaths())){registration.addPathPatterns(authProperties.getIncludeLoginPaths());}// 2.2.添加排除路径if(CollUtil.isNotEmpty(authProperties.getExcludeLoginPaths())){registration.excludePathPatterns(authProperties.getExcludeLoginPaths());}// 2.3.排除swagger路径registration.excludePathPatterns("/v2/**","/v3/**","/swagger-resources/**","/webjars/**","/doc.html");}
}
各个微服务中,对登录拦截器的需求不同
用户拦截,实现鉴权
@Slf4j
public class UserInfoInterceptor implements HandlerInterceptor {@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {// 1.尝试获取头信息中的用户信息String authorization = request.getHeader(JwtConstants.USER_HEADER);// 2.判断是否为空if (authorization == null) {return true;}// 3.转为用户id并保存try {Long userId = Long.valueOf(authorization);UserContext.setUser(userId);return true;} catch (NumberFormatException e) {log.error("用户身份信息格式不正确,{}, 原因:{}", authorization, e.getMessage());return true;}}@Overridepublic void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {// 清理用户信息UserContext.removeUser();}
}
登录拦截
@Slf4j
public class LoginAuthInterceptor implements HandlerInterceptor {@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {// 1.尝试获取用户信息Long userId = UserContext.getUser();// 2.判断是否登录if (userId == null) {response.setStatus(401);response.sendError(401, "未登录用户无法访问!");// 2.3.未登录,直接拦截return false;}// 3.登录则放行return true;}
}
UserContext,底层使用ThreadLocal
public class UserContext {private static final ThreadLocal<Long> TL = new ThreadLocal<>();/*** 保存用户信息* @param userId 用户id*/public static void setUser(Long userId){TL.set(userId);}/*** 获取用户* @return 用户id*/public static Long getUser(){return TL.get();}/*** 移除用户信息*/public static void removeUser(){TL.remove();}
}
4. 登录相关接口,jwt生成工具类
@Overridepublic String login(LoginFormDTO loginDTO, boolean isStaff) {// 1.查询并校验用户信息LoginUserDTO detail = userClient.queryUserDetail(loginDTO, isStaff);if (detail == null) {throw new BadRequestException("登录信息有误");}// 2.基于JWT生成登录token// 2.1.设置记住我标记detail.setRememberMe(loginDTO.getRememberMe());// 2.2.生成tokenString token = generateToken(detail);// 3.计入登录信息表loginRecordService.loginSuccess(loginDTO.getCellPhone(), detail.getUserId());// 4.返回结果return token;}private String generateToken(LoginUserDTO detail) {// 2.2.生成access-tokenString token = jwtTool.createToken(detail);// 2.3.生成refresh-token,将refresh-token的JTI 保存到RedisString refreshToken = jwtTool.createRefreshToken(detail);// 2.4.将refresh-token写入用户cookie,并设置HttpOnly为trueint maxAge = BooleanUtils.isTrue(detail.getRememberMe()) ?(int) JwtConstants.JWT_REMEMBER_ME_TTL.toSeconds() : -1;WebUtils.cookieBuilder().name(detail.getRoleId() == 2 ? JwtConstants.REFRESH_HEADER : JwtConstants.ADMIN_REFRESH_HEADER).value(refreshToken).maxAge(maxAge).httpOnly(true).build();return token;}@Overridepublic void logout() {// 删除jtijwtTool.cleanJtiCache();// 删除cookieWebUtils.cookieBuilder().name(JwtConstants.REFRESH_HEADER).value("").maxAge(0).httpOnly(true).build();}
JwtTool
// public static final Duration JWT_REFRESH_TTL = Duration.ofMinutes(30);
import static com.tianji.auth.common.constants.JwtConstants.JWT_REFRESH_TTL;// public static final Duration JWT_TOKEN_TTL = Duration.ofMinutes(5);
import static com.tianji.auth.common.constants.JwtConstants.JWT_TOKEN_TTL;@Component
public class JwtTool {private final StringRedisTemplate stringRedisTemplate;private final JWTSigner jwtSigner;public JwtTool(StringRedisTemplate stringRedisTemplate, KeyPair keyPair) {this.stringRedisTemplate = stringRedisTemplate;this.jwtSigner = JWTSignerUtil.createSigner("rs256", keyPair);}/*** 创建 access-token** @param userDTO 用户信息* @return access-token*/public String createToken(LoginUserDTO userDTO) {// 1.生成jwsreturn JWT.create().setPayload(JwtConstants.PAYLOAD_USER_KEY, userDTO).setExpiresAt(new Date(System.currentTimeMillis() + JWT_TOKEN_TTL.toMillis())).setSigner(jwtSigner).sign();}/*** 创建刷新token,并将token的JTI记录到Redis中** @param userDetail 用户信息* @return 刷新token*/public String createRefreshToken(LoginUserDTO userDetail) {// 1.生成 JTIString jti = UUID.randomUUID().toString(true);// 2.生成jwt// 2.1.如果是记住我,则有效期7天,否则30分钟Duration ttl = BooleanUtils.isTrue(userDetail.getRememberMe()) ?JwtConstants.JWT_REMEMBER_ME_TTL : JWT_REFRESH_TTL;// 2.2.生成tokenString token = JWT.create().setJWTId(jti).setPayload(JwtConstants.PAYLOAD_USER_KEY, userDetail).setExpiresAt(new Date(System.currentTimeMillis() + ttl.toMillis())).setSigner(jwtSigner).sign();// 3.缓存jti,有效期与token一致,过期或删除JTI后,对应的refresh-token失效stringRedisTemplate.opsForValue().set(JwtConstants.JWT_REDIS_KEY_PREFIX + userDetail.getUserId(), jti, ttl);return token;}/*** 解析刷新token** @param refreshToken 刷新token* @return 解析刷新token得到的用户信息*/public LoginUserDTO parseRefreshToken(String refreshToken) {// 1.校验token是否为空AssertUtils.isNotNull(refreshToken, AuthErrorInfo.Msg.INVALID_TOKEN);// 2.校验并解析jwtJWT jwt;try {jwt = JWT.of(refreshToken).setSigner(jwtSigner);} catch (Exception e) {throw new BadRequestException(400, AuthErrorInfo.Msg.INVALID_TOKEN, e);}// 2.校验jwt是否有效if (!jwt.verify()) {// 验证失败throw new BadRequestException(400, AuthErrorInfo.Msg.INVALID_TOKEN);}// 3.校验是否过期try {JWTValidator.of(jwt).validateDate();} catch (ValidateException e) {throw new BadRequestException(400, AuthErrorInfo.Msg.EXPIRED_TOKEN);}// 4.数据格式校验Object userPayload = jwt.getPayload(JwtConstants.PAYLOAD_USER_KEY);Object jtiPayload = jwt.getPayload(JwtConstants.PAYLOAD_JTI_KEY);if (jtiPayload == null || userPayload == null) {// 数据为空throw new BadRequestException(400, AuthErrorInfo.Msg.INVALID_TOKEN);}// 5.数据解析LoginUserDTO userDTO;try {userDTO = ((JSONObject) userPayload).toBean(LoginUserDTO.class);} catch (RuntimeException e) {// 数据格式有误throw new BadRequestException(400, AuthErrorInfo.Msg.INVALID_TOKEN);}// 6.JTI校验String jti = stringRedisTemplate.opsForValue().get(JwtConstants.JWT_REDIS_KEY_PREFIX + userDTO.getUserId());if (!StringUtils.equals(jti, jtiPayload.toString())) {// jti不一致throw new BadRequestException(400, AuthErrorInfo.Msg.INVALID_TOKEN);}return userDTO;}/*** 清理刷新refresh-token的jti,本质是refresh-token作废*/public void cleanJtiCache() {stringRedisTemplate.delete(JwtConstants.JWT_REDIS_KEY_PREFIX + UserContext.getUser());}
}