【SpringCloud微服务项目实战-mall4cloud项目(3)】——mall4cloud-auth

目前项目登录使用的认证授权方式较为简单,认证通过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模块的代码并不是主流的用户认证授权功能。更像一个单体架构的服务。下面介绍一下有疑问的地方。

  1. 首先是token的生成,这里直接使用uuid方式。这样做具有了可预测性以及只当作随机字符串不能存储任何信息。当然,这里使用了分布式redis缓存了用户信息,也是合理的。

上面的方式可以增加登录时获取随机码,然后再通过随机码加密的方式生成token
为了让token存储用户信息,或者增加用户的权限,可以使用jwt方式。这也是一种用于分布式

  1. 授权时使用的过滤器是servlet下的Filter,这个大多数的逻辑操作需要手动编写和指定,并没有使用一些流行的权限认证框架,比如SpringSecrity或者shiro等。这作为学习项目可以学习其中的授权认证逻辑,但是作为企业级项目,认证授权并不够精细化。但是总的逻辑一致。

比如过滤器中的一些放行接口,不是基于配置,而是在代码中写死的。或者对于接口的访问,如果是未授权,正常应该是跳转到指定登录页面提示,不是直接返回未授权。这些通过SpringSecrity框架可能做的更精细一些。

后续补充一个使用SpringSecrity或者shiro框架项目做用户认证授权的文章。

猜你喜欢

转载自blog.csdn.net/qq_40454136/article/details/132861108