目前项目登录使用的认证授权方式较为简单,认证通过token令牌方式,授权通过用户名密码方式,并且结合了captcha验证码登录。下面的介绍中会增加OAuth2的授权方式。
pom依赖
先看一下auth模块的相关依赖
①:common下的database模块,主要是关于分页工具、mybatis配置、分布式id等一些数据库相关内容
②:cache:主要是操作redis相关内容,像key、crud工具、redis分布式锁等
③:封装了授权过滤一些配置和过滤器实现
④:验证码相关
⑤:fegin调用的内部api接口
nacos配置
nacos配置如下内容,用于token生成。
令牌认证
介绍
令牌认证主要用于验证用户的身份。
通常,用户提供用户名和密码进行身份验证,服务器验证后颁发一个访问令牌(Token)给客户端。客户端可以在后续请求中使用这个令牌来证明其身份,而不需要再次提供用户名和密码。
令牌通常是一串字符,可以包含用户信息和权限信息。
项目代码
@PostMapping("/ua/login")
@Operation(summary = "账号密码" , description = "通过账号登录,还要携带用户的类型,也就是用户所在的系统")
public ServerResponseEntity<TokenInfoVO> login(
@Valid @RequestBody AuthenticationDTO authenticationDTO) {
// 这边获取了用户的用户信息,那么根据sessionid对应一个user的原则,我应该要把这个东西存起来,然后校验,那么存到哪里呢?
// redis,redis有天然的自动过期的机制,有key value的形式
ServerResponseEntity<UserInfoInTokenBO> userInfoInTokenResponse = authAccountService
.getUserInfoInTokenByInputUserNameAndPassword(authenticationDTO.getPrincipal(),
authenticationDTO.getCredentials(), authenticationDTO.getSysType());
if (!userInfoInTokenResponse.isSuccess()) {
return ServerResponseEntity.transform(userInfoInTokenResponse);
}
UserInfoInTokenBO data = userInfoInTokenResponse.getData();
ClearUserPermissionsCacheDTO clearUserPermissionsCacheDTO = new ClearUserPermissionsCacheDTO();
clearUserPermissionsCacheDTO.setSysType(data.getSysType());
clearUserPermissionsCacheDTO.setUserId(data.getUserId());
// 将以前的权限清理了,以免权限有缓存
ServerResponseEntity<Void> clearResponseEntity = permissionFeignClient.clearUserPermissionsCache(clearUserPermissionsCacheDTO);
if (!clearResponseEntity.isSuccess()) {
return ServerResponseEntity.fail(ResponseEnum.UNAUTHORIZED);
}
// 保存token,返回token数据给前端,这里是最重要的
return ServerResponseEntity.success(tokenStore.storeAndGetVo(data));
}
完成登录后,获取一个token令牌返回到前端。再看一下用户名密码的校验
if (StrUtil.isBlank(inputUserName)) {
return ServerResponseEntity.showFailMsg("用户名不能为空");
}
if (StrUtil.isBlank(password)) {
return ServerResponseEntity.showFailMsg("密码不能为空");
}
InputUserNameEnum inputUserNameEnum = null;
// 用户名
if (PrincipalUtil.isUserName(inputUserName)) {
inputUserNameEnum = InputUserNameEnum.USERNAME;
}
if (inputUserNameEnum == null) {
return ServerResponseEntity.showFailMsg("请输入正确的用户名");
}
AuthAccountInVerifyBO authAccountInVerifyBO = authAccountMapper
.getAuthAccountInVerifyByInputUserName(inputUserNameEnum.value(), inputUserName, sysType);
if (authAccountInVerifyBO == null) {
prepareTimingAttackProtection();
// 再次进行运算,防止计时攻击
// 计时攻击(Timing
// attack),通过设备运算的用时来推断出所使用的运算操作,或者通过对比运算的时间推定数据位于哪个存储设备,或者利用通信的时间差进行数据窃取。
mitigateAgainstTimingAttack(password);
return ServerResponseEntity.showFailMsg("用户名或密码不正确");
}
if (Objects.equals(authAccountInVerifyBO.getStatus(), AuthAccountStatusEnum.DISABLE.value())) {
return ServerResponseEntity.showFailMsg("用户已禁用,请联系客服");
}
if (!passwordEncoder.matches(password, authAccountInVerifyBO.getPassword())) {
return ServerResponseEntity.showFailMsg("用户名或密码不正确");
}
return ServerResponseEntity.success(BeanUtil.map(authAccountInVerifyBO, UserInfoInTokenBO.class));
通过用户名获取用户信息,并通过passwordEncoder.matches()校验了密码,密码如果成功返回成功状态,userInfoInTokenResponse.isSuccess()。向下进行,调用permissionFeignClient清理权限。最后获取token代码如下:
public TokenInfoVO storeAndGetVo(UserInfoInTokenBO userInfoInToken) {
TokenInfoBO tokenInfoBO = storeAccessToken(userInfoInToken);
TokenInfoVO tokenInfoVO = new TokenInfoVO();
tokenInfoVO.setAccessToken(tokenInfoBO.getAccessToken());
tokenInfoVO.setRefreshToken(tokenInfoBO.getRefreshToken());
tokenInfoVO.setExpiresIn(tokenInfoBO.getExpiresIn());
return tokenInfoVO;
}
/**
* 将用户的部分信息存储在token中,并返回token信息
* @param userInfoInToken 用户在token中的信息
* @return token信息
*/
public TokenInfoBO storeAccessToken(UserInfoInTokenBO userInfoInToken) {
TokenInfoBO tokenInfoBO = new TokenInfoBO();
String accessToken = IdUtil.simpleUUID();
String refreshToken = IdUtil.simpleUUID();
tokenInfoBO.setUserInfoInToken(userInfoInToken);
tokenInfoBO.setExpiresIn(getExpiresIn(userInfoInToken.getSysType()));
String uidToAccessKeyStr = getUidToAccessKey(getApprovalKey(userInfoInToken));
String accessKeyStr = getAccessKey(accessToken);
String refreshToAccessKeyStr = getRefreshToAccessKey(refreshToken);
// 一个用户会登陆很多次,每次登陆的token都会存在 uid_to_access里面
// 但是每次保存都会更新这个key的时间,而key里面的token有可能会过期,过期就要移除掉
List<String> existsAccessTokens = new ArrayList<>();
// 新的token数据
existsAccessTokens.add(accessToken + StrUtil.COLON + refreshToken);
Long size = redisTemplate.opsForSet().size(uidToAccessKeyStr);
if (size != null && size != 0) {
List<String> tokenInfoBoList = stringRedisTemplate.opsForSet().pop(uidToAccessKeyStr, size);
if (tokenInfoBoList != null) {
for (String accessTokenWithRefreshToken : tokenInfoBoList) {
String[] accessTokenWithRefreshTokenArr = accessTokenWithRefreshToken.split(StrUtil.COLON);
String accessTokenData = accessTokenWithRefreshTokenArr[0];
if (BooleanUtil.isTrue(stringRedisTemplate.hasKey(getAccessKey(accessTokenData)))) {
existsAccessTokens.add(accessTokenWithRefreshToken);
}
}
}
}
redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
long expiresIn = tokenInfoBO.getExpiresIn();
byte[] uidKey = uidToAccessKeyStr.getBytes(StandardCharsets.UTF_8);
byte[] refreshKey = refreshToAccessKeyStr.getBytes(StandardCharsets.UTF_8);
byte[] accessKey = accessKeyStr.getBytes(StandardCharsets.UTF_8);
for (String existsAccessToken : existsAccessTokens) {
connection.sAdd(uidKey, existsAccessToken.getBytes(StandardCharsets.UTF_8));
}
// 通过uid + sysType 保存access_token,当需要禁用用户的时候,可以根据uid + sysType 禁用用户
connection.expire(uidKey, expiresIn);
// 通过refresh_token获取用户的access_token从而刷新token
connection.setEx(refreshKey, expiresIn, accessToken.getBytes(StandardCharsets.UTF_8));
// 通过access_token保存用户的租户id,用户id,uid
connection.setEx(accessKey, expiresIn, Objects.requireNonNull(redisSerializer.serialize(userInfoInToken)));
return null;
});
// 返回给前端是加密的token
tokenInfoBO.setAccessToken(encryptToken(accessToken,userInfoInToken.getSysType()));
tokenInfoBO.setRefreshToken(encryptToken(refreshToken,userInfoInToken.getSysType()));
return tokenInfoBO;
}
对上面的token存储代码解释:
1、创建TokenInfoBO对象:首先,方法创建了一个TokenInfoBO对象,这个对象用于存储令牌相关的信息。
2、生成Access Token和Refresh Token:使用IdUtil.simpleUUID()生成了一个随机的Access Token和Refresh Token。
3、设置Token信息:将userInfoInToken对象设置到tokenInfoBO中,并设置了令牌的过期时间(expiresIn),该过期时间是根据userInfoInToken的sysType来确定的。
4、获取相关Key:获取了与令牌相关的一些键值(Key),如uidToAccessKeyStr,accessKeyStr,和refreshToAccessKeyStr。
处理已存在的令牌:通过查询Redis中的数据,检查是否已经存在相同用户的令牌。如果存在,将新生成的Access Token和Refresh Token添加到已存在令牌的列表中。
5、使用Redis Pipelining保存数据:使用Redis的Pipelining机制来一次性执行多个Redis命令,将令牌和相关信息存储到Redis中。这些命令包括将Access Token和Refresh Token与用户关联,设置它们的过期时间,并存储用户的信息。
6、加密令牌:使用encryptToken方法对Access Token和Refresh Token进行加密,然后将加密后的令牌设置到tokenInfoBO中。
返回Token信息:最后,返回包含Access Token和Refresh Token信息的tokenInfoBO对象,供前端使用。
后续的token解密防止攻击、token刷新代码通过代码注释可以看到相关逻辑,就不做一 一解释。
过滤器校验
前端获取到token后,会在访问接口中携带这个信息,之后后端服务通过过滤器来校验,看访问的接口是否能通过认证。主要代码在这个包下。
@Component
public class AuthFilter implements Filter {
private static Logger logger = LoggerFactory.getLogger(AuthFilter.class);
@Autowired
private AuthConfigAdapter authConfigAdapter;
@Autowired
private HttpHandler httpHandler;
@Autowired
private TokenFeignClient tokenFeignClient;
@Autowired
private PermissionFeignClient permissionFeignClient;
@Autowired
private FeignInsideAuthConfig feignInsideAuthConfig;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) request;
HttpServletResponse resp = (HttpServletResponse) response;
if (!feignRequestCheck(req)) {
httpHandler.printServerResponseToWeb(ServerResponseEntity.fail(ResponseEnum.UNAUTHORIZED));
return;
}
if (Auth.CHECK_TOKEN_URI.equals(req.getRequestURI())) {
chain.doFilter(req, resp);
return;
}
List<String> excludePathPatterns = authConfigAdapter.excludePathPatterns();
// 如果匹配不需要授权的路径,就不需要校验是否需要授权
if (CollectionUtil.isNotEmpty(excludePathPatterns)) {
for (String excludePathPattern : excludePathPatterns) {
AntPathMatcher pathMatcher = new AntPathMatcher();
if (pathMatcher.match(excludePathPattern, req.getRequestURI())) {
chain.doFilter(req, resp);
return;
}
}
}
String accessToken = req.getHeader("Authorization");
if (StrUtil.isBlank(accessToken)) {
httpHandler.printServerResponseToWeb(ServerResponseEntity.fail(ResponseEnum.UNAUTHORIZED));
return;
}
// 校验token,并返回用户信息
ServerResponseEntity<UserInfoInTokenBO> userInfoInTokenVoServerResponseEntity = tokenFeignClient
.checkToken(accessToken);
if (!userInfoInTokenVoServerResponseEntity.isSuccess()) {
httpHandler.printServerResponseToWeb(ServerResponseEntity.fail(ResponseEnum.UNAUTHORIZED));
return;
}
UserInfoInTokenBO userInfoInToken = userInfoInTokenVoServerResponseEntity.getData();
// 需要用户角色权限,就去根据用户角色权限判断是否
if (!checkRbac(userInfoInToken,req.getRequestURI(), req.getMethod())) {
httpHandler.printServerResponseToWeb(ServerResponseEntity.fail(ResponseEnum.UNAUTHORIZED));
return;
}
try {
// 保存上下文
AuthUserContext.set(userInfoInToken);
chain.doFilter(req, resp);
}
finally {
AuthUserContext.clean();
}
}
private boolean feignRequestCheck(HttpServletRequest req) {
// 不是feign请求,不用校验
if (!req.getRequestURI().startsWith(FeignInsideAuthConfig.FEIGN_INSIDE_URL_PREFIX)) {
return true;
}
String feignInsideSecret = req.getHeader(feignInsideAuthConfig.getKey());
// 校验feign 请求携带的key 和 value是否正确
if (StrUtil.isBlank(feignInsideSecret) || !Objects.equals(feignInsideSecret,feignInsideAuthConfig.getSecret())) {
return false;
}
// ip白名单
List<String> ips = feignInsideAuthConfig.getIps();
// 移除无用的空ip
ips.removeIf(StrUtil::isBlank);
// 有ip白名单,且ip不在白名单内,校验失败
if (CollectionUtil.isNotEmpty(ips)
&& !ips.contains(IpHelper.getIpAddr())) {
logger.error("ip not in ip White list: {}, ip, {}", ips, IpHelper.getIpAddr());
return false;
}
return true;
}
/**
* 用户角色权限校验
* @param uri uri
* @return 是否校验成功
*/
public boolean checkRbac(UserInfoInTokenBO userInfoInToken, String uri, String method) {
if (!Objects.equals(SysTypeEnum.PLATFORM.value(), userInfoInToken.getSysType()) && !Objects.equals(SysTypeEnum.MULTISHOP.value(), userInfoInToken.getSysType())) {
return true;
}
ServerResponseEntity<Boolean> booleanServerResponseEntity = permissionFeignClient
.checkPermission(userInfoInToken.getUserId(), userInfoInToken.getSysType(),uri,userInfoInToken.getIsAdmin(),HttpMethodEnum.valueOf(method.toUpperCase()).value() );
if (!booleanServerResponseEntity.isSuccess()) {
return false;
}
return booleanServerResponseEntity.getData();
}
}
下面详细说明过滤逻辑
①:
这段代码中的 doFilter 方法是实现了 Filter 接口的一个方法,用于处理 HTTP 请求的过滤逻辑。它是一个回调方法,当有请求到达时,容器会调用这个方法来执行一些预处理和后处理的操作。
方法的参数包括:
request: 表示 HTTP 请求对象,通常是 ServletRequest 类型,可以用于获取请求的信息和数据。
response: 表示 HTTP 响应对象,通常是 ServletResponse 类型,用于生成和发送响应数据。
chain: 表示过滤器链(FilterChain),可以用于继续处理请求或将请求传递给下一个过滤器。
③:内部请求校验
private boolean feignRequestCheck(HttpServletRequest req) {
// 不是feign请求,返回true
if (!req.getRequestURI().startsWith(FeignInsideAuthConfig.FEIGN_INSIDE_URL_PREFIX)) {
return true;
}
//获取fegin的value密钥,这个在nacos中已配置
String feignInsideSecret = req.getHeader(feignInsideAuthConfig.getKey());
// 校验feign 请求携带的key 和 value是否正确,不正确返回false
if (StrUtil.isBlank(feignInsideSecret) || !Objects.equals(feignInsideSecret,feignInsideAuthConfig.getSecret())) {
return false;
}
// ip白名单
List<String> ips = feignInsideAuthConfig.getIps();
// 移除无用的空ip
ips.removeIf(StrUtil::isBlank);
// 有ip白名单,且ip不在白名单内,校验失败。不为空或者获取当前用户真实ip不在白名单中,返回false
if (CollectionUtil.isNotEmpty(ips)
&& !ips.contains(IpHelper.getIpAddr())) {
logger.error("ip not in ip White list: {}, ip, {}", ips, IpHelper.getIpAddr());
return false;
}
return true;
}
上面如果返回了false,是fegin请求但是不通过就走判断中的代码,意思是发送的相应为“未授权”。fegin请求校验不通过是认为失败的
if (!feignRequestCheck(req)) {
httpHandler.printServerResponseToWeb(ServerResponseEntity.fail(ResponseEnum.UNAUTHORIZED));
return;
}
④:如果为token校验请求,这个过滤器就不管了,发到下一个过滤器中,这也是chain.doFilter(request, response)的作用。但是可以看到,目前系统过滤器链上只有一个过滤器。在AuthConfig中。
@ConditionalOnMissingBean 是一个常用于 Spring 框架应用中,特别是在 Spring Boot 中的注解,用于根据应用上下文中是否已存在相同类型的其他 Bean,有条件地配置一个 Bean。这个注解是 Spring 的基于注解的配置的一部分,用于控制 Bean 的实例化
⑤:目前这个list有下面的url需排除,然后这个是通过bean的注入实现的
还是这个config代码,第一个bean,下面的截图说明了哪些需要排除,
@Configuration
public class AuthConfig {
@Bean
@ConditionalOnMissingBean
public AuthConfigAdapter authConfigAdapter() {
return new DefaultAuthConfigAdapter();
}
@Bean
@Lazy
public FilterRegistrationBean<AuthFilter> filterRegistration(AuthConfigAdapter authConfigAdapter, AuthFilter authFilter) {
FilterRegistrationBean<AuthFilter> registration = new FilterRegistrationBean<>();
// 添加过滤器
registration.setFilter(authFilter);
// 设置过滤路径,/*所有路径
registration.addUrlPatterns(ArrayUtil.toArray(authConfigAdapter.pathPatterns(), String.class));
registration.setName("authFilter");
// 设置优先级
registration.setOrder(0);
registration.setDispatcherTypes(DispatcherType.REQUEST);
return registration;
}
}
⑥之后循环这个list,做了一个正则的匹配。来排除不需要经过这个auth过滤器的url。
⑦⑧:这里终于到了正式的请求了,看他是否携带了Authorization这个内容,为空则直接返回未授权。
接下来的代码如下
①检查接口代码如下,是调用了OpenFegin接口,这里顺便看一下项目对fegin的实践方式。
首先是在mall4cloud-api包下建立了某个模块要提供的api接口,在fegin包下,选择其中一个接口查看
可以看到通过@FeignClient(value = “mall4cloud-auth”,contextId =“token”)注解修饰:value指定了服务的名称,contextId指定了这个接口的唯一标识。@GetMapping(value = Auth.CHECK_TOKEN_URI)指定了访问的uri
之后是对接口的实现
实现都是在各个模块的fegin包下,且实现是通过@RestController注解实现。这样的作法类似于@DubboService注解修饰api实现类。
‘’‘’‘’‘
而通常的feign接口是直接通过http请求调用的,当调用 checkToken() 方法时,实际上调用的是 Feign 生成的代理对象的方法。
代理对象会根据方法的定义和注解(例如 @GetMapping(value = Auth.CHECK_TOKEN_URI))生成一个 HTTP 请求,并将请求发送到远程服务的相应路径。
远程服务响应后,代理对象会将响应解析为ServerResponseEntity< UserInfoInTokenBO > 类型,并将其返回。
但是mall4cloud-auth这个模块代码中没有“/feign/token/checkToken”这个请求uri和对应的controller
同时,“/feign/token/checkToken”这个请求也是被拦截器放行的,这个在上面已说明。
②③权限校验
这里分为了商家端、平台端、用户端,之后检查是否有某个uri的权限,主要代码如下,这个权限校验属于rbac模块,下一章节再详细介绍
如果校验失败,就返回未授权状态码
④:使用ThreadLocal
保存用户信息。可以做到线程间隔离作用,以及在整个线程中传递上下文信息。
线程安全的数据隔离:ThreadLocal 可以用于在多线程环境中隔离数据,确保每个线程都有自己独立的数据副本,从而避免多个线程之间的数据共享和竞态条件。这对于一些上下文相关的数据非常有用,例如用户登录信息、会话信息等。
传递上下文信息:有些情况下,您可能需要在整个线程上下文中传递某些信息,而不必在每个方法调用中显式传递这些信息作为参数。ThreadLocal 可以用于存储和访问这些上下文信息。
之后进入下一个过滤器(可以扩展),用户信息用完以后释放即可。
总结
auth模块的代码逻辑主要流程是登录接口/ua/login->tokenStore下的storeAccessToken()方法,令牌存储完成后,就是过滤器AuthFilter类的实现。其中的doFilter()方法进行了访问路径(请求)授权,用户角色授权(这里主要调用了rbac模块的服务)。
这个auth模块的代码并不是主流的用户认证授权功能。更像一个单体架构的服务。下面介绍一下有疑问的地方。
- 首先是token的生成,这里直接使用uuid方式。这样做具有了可预测性以及只当作随机字符串不能存储任何信息。当然,这里使用了分布式redis缓存了用户信息,也是合理的。
上面的方式可以增加登录时获取随机码,然后再通过随机码加密的方式生成token
为了让token存储用户信息,或者增加用户的权限,可以使用jwt方式。这也是一种用于分布式
- 授权时使用的过滤器是servlet下的Filter,这个大多数的逻辑操作需要手动编写和指定,并没有使用一些流行的权限认证框架,比如SpringSecrity或者shiro等。这作为学习项目可以学习其中的授权认证逻辑,但是作为企业级项目,认证授权并不够精细化。但是总的逻辑一致。
比如过滤器中的一些放行接口,不是基于配置,而是在代码中写死的。或者对于接口的访问,如果是未授权,正常应该是跳转到指定登录页面提示,不是直接返回未授权。这些通过SpringSecrity框架可能做的更精细一些。
后续补充一个使用SpringSecrity或者shiro框架项目做用户认证授权的文章。