一、序言
在传统的SpringBoot项目中,优秀的用户认证和鉴权开源框架有SpringSecurity和Shiro,网上已经有大量的教程和参考资料以供学习使用,在SpringCloud分布式项目中,主流的两种网关技术为Zuul1.x和Gateway,两者最主要的区别在于Gateway是基于Spring5.x的WebFlux实现的,而WebFlux底层采用的是高性能的Reactor模式通信的Netty,反观Zuul1.x则是基于servlet3.1之前的阻塞式IO。
简单对比的话,就是Gateway使用NIO,Zuul1.x使用BIO,笔者基于"学旧不如学新"的宗旨,在尝试搭建RPC系统架构时选用了Gateway作为网关并负责对用户进行认证和鉴权。
注:为了避免重心偏移,本文并不会对NIO、Netty、Gateway以及WebFlux(实际上是笔者也没整明白WebFlux)的基本知识进行介绍,如果读者阅读时感到不易理解的话,可自行搜索这部分前驱知识进行了解
二、需求
1.第一次登陆时根据用户的账号密码进行登陆认证,如果正确则生成Token存储在Redis中
2.当请求头中携带了Token时,则判断Token是否合法,是否过期,通过认证则放行请求
3.根据用户判断是否具备访问制定请求的权限,有则放行,无则警告
三、实现思路
先说结论:笔者虽然尝试过,但最终没有使用开源框架,因为缺乏案例,查找到的资料水平也都参差不齐,不能完美实现笔者的业务需求,具体原因后文会细说
1.使用SpringSecurity实现,泳道图如下
2.笔者使用SpringSecurity时遇到的几个问题
(1).新的学习门槛:Security主配置文件中不再是@Security,而是@SecurityReactor,显然,是Security专门为Reactor单独实现的一套方法
(2).后台解析login参数难:Gateway是从一个叫ServerWebExchange的对象中获取Request和Response,获取到的request类名为ServerHttpRequest,这个类很坑,超级坑,从中获取POST请求携带的参数需要开启流,并且只可以读一次,第二次再读数据就丢失了,并且将用户名和密码取出做持久化操作也是一个很让人困扰的问题。所以笔者直接把/login改为了Get请求(逃
(3).认证结果无法获取:认证成功后Authenticate对象中的isAuthenticated值无法直接修改
3.基于上述原因,SpringSecurity在Gateway中的使用过于艰难,于是笔者根据啃源码过程中的学习成果,决定自己手写一套简单易用的认证鉴权,设计并不高明,只是能用而已
四、代码
Don`t talk too much, show me the code !
1.目录结构:
2.详细代码
代码中的注释已经写得很详细了,笔者就不做过多解释了,如下:
(1).主过滤器,采用简单暴力的if/else判断,决定下一步的程序走向,
/**
* @author 郭超
* Date:2020-09-29 9:50
* Description: 认证授权主配置类,使用过滤器链需要中间存储对象
*/
@Slf4j
@Component
@Configuration
@Order(Ordered.HIGHEST_PRECEDENCE + 2)
public class SecurityFilter implements WebFilter {
/**
* 处理直接放行的请求
*/
@Resource
private ReleaseRequestHandler releaseRequestHandler;
/**
* 登陆请求处理类
*/
@Resource
private UserPassAuthenticationHandler userPassAuthenticationHandler;
/**
* 根据Token完成认证的处理类
*/
@Resource
private TokenAuthenticationHandler tokenAuthenticationHandler;
/**
* 用户鉴权处理类
*/
@Resource
private DynamicVerification dynamicPermission;
/**
* 鉴权成功处理类
*/
@Resource
private AuthorizationSuccessHandler authorizationSuccessHandler;
/**
* 认证失败异常处理类
*/
@Resource
private AuthenticationExceptionHandler authenticationExceptionHandler;
/**
* 鉴权失败异常处理类
*/
@Resource
private AuthorizationExceptionHandler authorizationExceptionHandler;
@Override
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
// 0. 判断是否为直接放行的请求
boolean isReleaseRequest = releaseRequestHandler.isReleaseRequest(request);
if (isReleaseRequest) {
// 如果是则直接放行
return chain.filter(exchange);
}
// 1. 为response装填JSONHeader
ServerHttpResponse response = SecurityHelper.setResponseHeader(exchange.getResponse());
// 2. 拦截请求,判断是否为第一次登陆
boolean isLoginPath = userPassAuthenticationHandler.isLoginPath(request);
boolean isStaticResourcePath = tokenAuthenticationHandler.isResourceRequest(request);
Authentication authentication;
if (isLoginPath) {
// 2.1 如果是,则进入通过账号密码认证的逻辑
authentication = userPassAuthenticationHandler.authenticate(exchange);
} else {
// 2.2 再判断是否访问静态资源
if (isStaticResourcePath) {
// 2.2.1 如果是,则直接放行
return chain.filter(exchange);
} else {
// 2.2.2 如果都不是,则进入通过token认证的逻辑
authentication = tokenAuthenticationHandler.authenticate(request);
}
}
int authenticatedStatus = authentication.getAuthenticatedStatus();
String userName = authentication.getUserName();
// 3 根据认证结果,判断是执行鉴权还是回写警告
DataBuffer dataBuffer;
if (authenticatedStatus == AuthenticatedStatus.AUTHENTICATION_SUCCESS ||
authenticatedStatus == AuthenticatedStatus.FIRST_LOGIN_SUCCESS) {
// 3.1 认证成功,进入鉴权逻辑
int authorizeStatus = dynamicPermission.check(userName, request);
if (authorizeStatus == AuthorizeStatus.AUTHORIZE_SUCCESS) {
if (authenticatedStatus == AuthenticatedStatus.AUTHENTICATION_SUCCESS) {
// 3.1.1 鉴权成功,且为Token,则直接放行
return chain.filter(exchange);
}
dataBuffer = authorizationSuccessHandler.onAuthorizationSuccess(userName, response);
} else {
// 3.1.2 鉴权失败,返回警告信息
dataBuffer = authorizationExceptionHandler.getWarningInfo(authorizeStatus, response);
}
} else {
// 3.2 认证失败,则根据返回状态,返回警告信息
dataBuffer = authenticationExceptionHandler.getWarningInfo(authenticatedStatus, response);
}
return response.writeWith(Mono.just(dataBuffer));
}
}
复制代码
(2).放行请求配置
/**
* @author 郭超
* Date:2020-11-03 9:40
* Description: 处理直接放行的请求
*/
@Slf4j
@Component
public class ReleaseRequestHandler {
private List<String> releaseRequestPath = new ArrayList<>(Arrays.asList("/agent/"));
/**
* 判断是否为代理模块发送的请求
*
* @param request request
* @return boolean
*/
public boolean isReleaseRequest2(ServerHttpRequest request) {
// 获取request请求的Path
String path = request.getPath().toString();
AntPathMatcher antPathMatcher = new AntPathMatcher();
boolean result = releaseRequestPath.stream().anyMatch(item -> {
return antPathMatcher.match(item, path);
});
log.info("path.equals(agentPath) = " + result);
return result;
}
}
复制代码
(3).第一次登陆,账号密码登陆请求处理类
/**
* @author 郭超
* Date:2020-09-29 10:35
* Description: 登陆请求处理类
*/
@Slf4j
@Component
public class UserPassAuthenticationHandler {
@Resource
private SysUserService userService;
/**
* 根据请求路径判断是否为第一次登陆
* 需满足以下两个条件才可认为是第一次登陆
* 1.请求路径为"/login"
* 2.ContentType为JSON
*
* @param request 当前请求
* @return result
*/
public boolean isLoginPath(ServerHttpRequest request) {
// 获取request请求的Path和ContentType
String path = request.getPath().toString();
HttpHeaders headers = request.getHeaders();
MediaType contentType = headers.getContentType();
log.info("path 0 =========== " + request.getPath());
/*if (contentType != null) {
log.info("contentType ============ " + contentType);
if (contentType.toString().equals(MediaType.APPLICATION_JSON_VALUE)
|| contentType.toString().equals(MediaType.APPLICATION_JSON_UTF8_VALUE)) {*/
String loginPath = "/login";
boolean result = path.equals(loginPath);
log.info("path.equals(loginPath) = " + result);
return result;
/*}
}
return false;*/
}
/**
* 账号密码登陆认证
*
* @param exchange ServerWebExchange
* @return 认证结果:是否存在该用户
*/
public Authentication authenticate(ServerWebExchange exchange) {
Authentication authentication = new Authentication();
MultiValueMap<String, String> params = exchange.getRequest().getQueryParams();
log.info("userMap.toString()" + params.toString());
String userName = params.get("username").toString();
if (userName != null && !"".equals(userName)) {
userName = userName.replaceAll("[\\[\\]]", "");
authentication.setUserName(userName);
String password = params.get("password").toString().replaceAll("[\\[\\]]", "");
log.info("<===========用户:" + userName);
// 先判断用户是否存在
SysUser user = userService.getUserByUserName(userName);
if (user != null) {
// 执行认证
try {
if (userService.checkLogin(userName, password)) {
log.info("<===========用户:" + userName + "存在,身份验证通过!===========>");
authentication.setAuthenticatedStatus(AuthenticatedStatus.FIRST_LOGIN_SUCCESS);
} else {
authentication.setAuthenticatedStatus(AuthenticatedStatus.PASSWORD_NOT_MATCH);
}
} catch (Exception e) {
authentication.setAuthenticatedStatus(AuthenticatedStatus.UNKNOWN_EXCEPTION);
e.printStackTrace();
}
} else {
authentication.setAuthenticatedStatus(AuthenticatedStatus.USERNAME_NOT_EXSIT);
}
} else {
authentication.setAuthenticatedStatus(AuthenticatedStatus.NULL_USERNAME);
}
log.info("认证后得到的Authentication对象 = " + authentication.toString());
return authentication;
}
private Map<String, Object> decodeBody(String body) {
return Arrays.stream(body.split("&"))
.map(s -> s.split("="))
.collect(Collectors.toMap(arr -> arr[0], arr -> arr[1]));
}
private String encodeBody(Map<String, Object> map) {
return map.entrySet().stream().map(e -> e.getKey() + "=" + e.getValue()).collect(Collectors.joining("&"));
}
}
复制代码
(4).根据Token完成认证的处理类
/**
* @author 郭超
* Date:2020-09-29 10:42
* Description: 根据Token完成认证的处理类
*/
@Slf4j
@Component
public class TokenAuthenticationHandler {
@Resource
private JwtTokenUtil jwtTokenUtil;
/**
* 判断请求是否为访问静态资源的请求
*
* @param request request
* @return 判断结果
*/
public boolean isResourceRequest(ServerHttpRequest request) {
String path = request.getPath().toString();
log.info("path =========== " + request.getPath());
String staticResourcePath = ".*/images/.*";
boolean result = Pattern.matches(staticResourcePath, path);
log.info("判断当前路径是否为静态资源的结果为: " + result);
return result;
}
/**
* 根据token执行认证
*
* @param request ServerHttpRequest
* @return 认证结果
*/
public Authentication authenticate(ServerHttpRequest request) {
List<String> auth = request.getHeaders().get(HttpHeaders.AUTHORIZATION);
Authentication authentication = new Authentication();
if (auth != null && auth.size() > 0 && !StringUtils.isEmpty(auth.get(0))) {
log.info("获取到的 headerToken = " + auth.get(0));
String headerToken = auth.get(0);
// postMan测试时,自动加入的前缀,要去掉。
String token = headerToken.replace("Bearer", "").trim();
// 先判断令牌是否过期
boolean isExpired = jwtTokenUtil.isTokenExpired(token);
if (isExpired) {
// 过期刷新
jwtTokenUtil.refreshToken(token);
}
// 通过令牌获取用户名称
String username = jwtTokenUtil.getUsernameFromToken(token);
log.info("从token令牌中获取到的username = " + username);
if (username != null) {
// 没过期则刷新令牌,重置有效期,然后放行
jwtTokenUtil.refreshToken(token);
authentication.setUserName(username);
authentication.setAuthenticatedStatus(AuthenticatedStatus.AUTHENTICATION_SUCCESS);
log.info("认证成功:Token认证成功!");
} else {
authentication.setAuthenticatedStatus(AuthenticatedStatus.INVALID_TOKEN);
log.info("认证失败:无效Token,无法从中获取Token!");
}
} else {
authentication.setAuthenticatedStatus(AuthenticatedStatus.NULL_TOKEN);
log.info("认证失败:当前请求头没有Token信息!");
}
return authentication;
}
}
复制代码
(5).用户鉴权处理类
/**
* @author 郭超
* Date:2020-09-29 10:45
* Description: 用户鉴权处理类
*/
@Slf4j
@Component
public class DynamicVerification {
@Resource
private SysBackendApiService apiService;
/**
* 动态鉴权,根据用户名查询可访问API集合,然后与当前访问API进行匹配
*
* @param userName 当前登陆用户名
* @param request 当前请求
* @return 鉴权结果
*/
public int check(String userName, ServerHttpRequest request) {
log.info("动态权限校验 DynamicPermission");
// 通过账号获取资源鉴权
List<SysBackendApi> apiUrls = apiService.getApiUrlByUserName(userName);
if (apiUrls != null) {
AntPathMatcher antPathMatcher = new AntPathMatcher();
// 当前访问路径
RequestPath requestUri = request.getPath();
log.info("动态权限校验 => 当前访问路径 = " + requestUri);
// 提交类型
HttpMethod urlMethod = request.getMethod();
// 判断当前路径中是否在资源鉴权中
boolean rs = apiUrls.stream().anyMatch(item -> {
// 判断URL是否匹配
boolean hashAntPath = antPathMatcher.match(item.getApiUrl(), String.valueOf(requestUri));
// 判断请求方式是否和数据库中匹配(数据库存储:GET,POST,PUT,DELETE)
String dbMethod = item.getApiMethod();
// 处理null,万一数据库存null值
dbMethod = (dbMethod == null) ? "" : dbMethod;
int hasMethod = dbMethod.indexOf(String.valueOf(urlMethod));
log.info("hashAntPath = " + hashAntPath);
log.info("hasMethod = " + hasMethod);
log.info("hashAntPath && hasMethod = " + (hashAntPath && hasMethod != -1));
// 两者都成立,返回真,否则返回假
boolean result = hashAntPath && (hasMethod != -1);
if (result) {
log.info("result == " + true + "<==============URL匹配且用户具有访问权限,权限校验通过!============>");
}
return result;
});
if (rs) {
return AuthorizeStatus.AUTHORIZE_SUCCESS;
} else {
return AuthorizeStatus.AUTHORIZE_INSUFFICIENT;
}
} else {
return AuthorizeStatus.NULL_AUTHORIZATION;
}
}
}
复制代码
(6).鉴权成功处理类
/**
* @author 郭超
* Date:2020-09-29 11:00
* Description: 鉴权成功处理类
*/
@Slf4j
@Component
public class AuthorizationSuccessHandler {
@Resource
private SysFrontendMenuService menuService;
@Resource
private JwtTokenUtil jwtTokenUtil;
@Resource
private RedisUtil redisUtil;
/**
* 根据用户名查询出用户详情,并转换为DataBuffer返回
*
* @param userName 用户名
* @param response 响应
* @return 用户详细信息流
*/
public DataBuffer onAuthorizationSuccess(String userName, ServerHttpResponse response) {
String userToken = (String) redisUtil.get(userName);
if (userToken == null || "".equals(userToken.trim())) {
// 如果token为空,则通过JWT创建一个新的token
userToken = jwtTokenUtil.generateToken(userName);
// 把新的token存储到Redis中
redisUtil.set(userName, userToken);
log.info("第一次登陆,token为空,创建并存储到Redis中的Token:" + userToken);
} else {
log.info("3.2 Redis中已有该用户, 根据用户名从Redis中取到的token = " + userToken);
}
// 设置body,封装数据
List<SysFrontendMenu> menus = menuService.getMenusByUserName(userName);
Map<String, Object> map = new HashMap<>(8);
map.put("username", userName);
map.put("menus", menus);
map.put("token", userToken);
log.info("4. 存入Map中返回到前台的数据内容为: " + map);
ResponseResult result = ResponseResult.success(map);
byte[] dataBytes;
ObjectMapper mapper = new ObjectMapper();
try {
dataBytes = mapper.writeValueAsBytes(result);
} catch (JsonProcessingException e) {
dataBytes = ResponseResult.fail("授权异常").toString().getBytes();
}
return response.bufferFactory().wrap(dataBytes);
}
}
复制代码
(7).认证失败异常定义类
/**
* @author 郭超
* Date:2020-09-29 11:09
* Description: 异常定义类
*/
@Slf4j
@Component
public class AuthenticationExceptionHandler {
/**
* 根据认证时返回的状态值不同,返回不同的警告信息
*
* @param authenticatedStatus 认证异常状态
* @param response 响应
* @return 警告信息Buffer
*/
public DataBuffer getWarningInfo(int authenticatedStatus, ServerHttpResponse response) {
String exceptionMessage;
switch (authenticatedStatus) {
case AuthenticatedStatus.USERNAME_NOT_EXSIT:
exceptionMessage = "认证失败: 用户不存在!";
break;
case AuthenticatedStatus.PASSWORD_NOT_MATCH:
exceptionMessage = "认证失败: 密码不正确!";
break;
case AuthenticatedStatus.LOGIN_CONTENT_TYPE_NOT_JSON:
exceptionMessage = "认证失败: 登陆请求必须为JSON方式!";
break;
case AuthenticatedStatus.NULL_USERNAME:
exceptionMessage = "认证失败: 未能从JSON中获取用户名!";
break;
case AuthenticatedStatus.UNKNOWN_EXCEPTION:
exceptionMessage = "认证失败: 登陆发生未知异常!";
break;
case AuthenticatedStatus.NULL_TOKEN:
exceptionMessage = "认证失败: Token为空,请重新登陆!";
break;
case AuthenticatedStatus.INVALID_TOKEN:
exceptionMessage = "认证失败: 无效Token,请尝试重新登陆!";
break;
case AuthenticatedStatus.TOKEN_EXPIRED:
exceptionMessage = "认证失败: Token已过期,请重新登陆!";
break;
default:
exceptionMessage = "";
break;
}
return response.bufferFactory().wrap(exceptionMessage.getBytes());
}
}
复制代码
(8).鉴权失败异常定义类
/**
* @author 郭超
* Date:2020-09-29 11:06
* Description: 鉴权失败异常处理类
*/
@Slf4j
@Component
public class AuthorizationExceptionHandler {
/**
* 根据鉴权时返回的状态值不同,返回不同的警告信息
*
* @param authorizeStatus 鉴权异常状态
* @param response 响应
* @return 警告信息Buffer
*/
public DataBuffer getWarningInfo(int authorizeStatus, ServerHttpResponse response) {
String exceptionMessage;
switch (authorizeStatus) {
case AuthorizeStatus.AUTHORIZE_INSUFFICIENT:
exceptionMessage = "访问失败,权限不足!";
break;
case AuthorizeStatus.NULL_AUTHORIZATION:
exceptionMessage = "访问失败,权限为空!";
break;
default:
exceptionMessage = "";
break;
}
return response.bufferFactory().wrap(exceptionMessage.getBytes());
}
}
复制代码
(9).认证结果状态码
/**
* @author 郭超
* Date:2020-09-29 11:33
* Description: 认证结果状态码
*/
public interface AuthenticatedStatus {
/**
* 第一次登陆成功,特用于成功后存放用户详细信息
*/
int FIRST_LOGIN_SUCCESS = 0;
/**
* 认证成功
*/
int AUTHENTICATION_SUCCESS = 1;
/**
* 用户名不存在
*/
int USERNAME_NOT_EXSIT = 2;
/**
* 密码不正确
*/
int PASSWORD_NOT_MATCH = 3;
/**
* Login请求类型不为JSON
*/
int LOGIN_CONTENT_TYPE_NOT_JSON = 4;
/**
* 未能从requestBody中获取用户名
*/
int NULL_USERNAME = 5;
/**
* 未知认证异常
*/
int UNKNOWN_EXCEPTION = 6;
//========================以下为Token认证的异常==========================
/**
* 未能从header中获取Token
*/
int NULL_TOKEN = 7;
/**
* 无效TOKEN,无法从中获取Token
*/
int INVALID_TOKEN = 8;
/**
* Token已过期
*/
int TOKEN_EXPIRED = 9;
}
复制代码
(10).授权结果状态码
/**
* @author 郭超
* Date:2020-09-29 11:25
* Description: 授权状态码
*/
public interface AuthorizeStatus {
/**
* 授权成功
*/
int AUTHORIZE_SUCCESS = 1;
/**
* 权限不足
*/
int AUTHORIZE_INSUFFICIENT = 2;
/**
* 空权限
*/
int NULL_AUTHORIZATION = 3;
}
复制代码
(11).自定义认证对象
/**
* @author 郭超
* Date:2020-09-29 11:13
* Description: 自定义认证对象
*/
@Data
public class Authentication {
/**
* 认证状态
*/
private int authenticatedStatus;
/**
* 正在认证的用户名
*/
private String userName;
}
复制代码