1. 鉴权流程
浏览器发送请求时。请求头会携带键值对"authorization":jwt
网关先解析jwt令牌,做第一次鉴权,鉴权完成后将解析的user对象的id添加到请求头中:user-info = 用户id;
微服务的拦截器会获取请求头中的user-info,然后存入到UserContext(底层基于ThreadLocal),这样后续的业务处理时就能直接从UserContext中获取用户了。
网关鉴权后,微服务为什么还要做鉴权?防止请求越过网关直接发给微服务
2. 网关鉴权过滤器
2.1 filter方法
java
@Override
public 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校验成功,尝试更新请求头,传递用户id
if(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;
}
@Override
public int getOrder() {
// 越大优先级越低
return 1000;
}
2.2 jwt解析工具方法
java
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.JWT
jwt = JWT.of(token).setSigner(jwtSignerHolder.getJwtSigner());
} catch (Exception e) {
return R.error(INVALID_TOKEN_CODE, INVALID_TOKEN);
}
// 2.校验jwt是否有效 cn.hutool.jwt.JWT
if (!jwt.verify()) {
// 验证失败,返回空
return R.error(INVALID_TOKEN_CODE, INVALID_TOKEN);
}
// 3.校验是否过期 cn.hutool.jwt.JWTValidator
try {
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.JSON
userDTO = ((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.AntPathMatcher
if(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,注册拦截器
java
@Configuration
@EnableConfigurationProperties(ResourceAuthProperties.class)
public class ResourceInterceptorConfiguration implements WebMvcConfigurer {
private final ResourceAuthProperties authProperties;
@Autowired
public ResourceInterceptorConfiguration(ResourceAuthProperties resourceAuthProperties) {
this.authProperties = resourceAuthProperties;
}
@Override
public 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"
);
}
}
各个微服务中,对登录拦截器的需求不同
用户拦截,实现鉴权
java
@Slf4j
public class UserInfoInterceptor implements HandlerInterceptor {
@Override
public 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;
}
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 清理用户信息
UserContext.removeUser();
}
}
登录拦截
java
@Slf4j
public class LoginAuthInterceptor implements HandlerInterceptor {
@Override
public 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
java
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生成工具类
java
@Override
public 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.生成token
String token = generateToken(detail);
// 3.计入登录信息表
loginRecordService.loginSuccess(loginDTO.getCellPhone(), detail.getUserId());
// 4.返回结果
return token;
}
private String generateToken(LoginUserDTO detail) {
// 2.2.生成access-token
String token = jwtTool.createToken(detail);
// 2.3.生成refresh-token,将refresh-token的JTI 保存到Redis
String refreshToken = jwtTool.createRefreshToken(detail);
// 2.4.将refresh-token写入用户cookie,并设置HttpOnly为true
int 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;
}
@Override
public void logout() {
// 删除jti
jwtTool.cleanJtiCache();
// 删除cookie
WebUtils.cookieBuilder()
.name(JwtConstants.REFRESH_HEADER)
.value("")
.maxAge(0)
.httpOnly(true)
.build();
}
JwtTool
java
// 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.生成jws
return 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.生成 JTI
String 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.生成token
String 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.校验并解析jwt
JWT 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());
}
}