[SpringCloud microservice project practice-mall4cloud project (3)]——mall4cloud-auth

The current authentication and authorization method used for project login is relatively simple. Authentication is through the token method, authorization is through the username and password method, and it is combined with captcha verification code login. The OAuth2 authorization method will be added in the following introduction.

pom dependency

Let’s take a look at the related dependencies of the auth module first
Insert image description here
①: The database module under common, mainly about paging tools, mybatis configuration, distributed id and other database-related content ③: Encapsulates some authorization filtering configurations and filter implementation< /span> ⑤: Internal api interface called by fegin ④: Verification code related
②: cache: mainly operates redis-related content, such as key, crud tools, redis distributed locks, etc.


nacos configuration

Nacos is configured with the following content for token generation.
Insert image description here

Token authentication

introduce

Token authentication is mainly used to verify the user's identity.
Usually, the user provides a username and password for authentication, and the server issues an access token (Token) to the client after verification. The client can use this token to prove its identity on subsequent requests without having to provide the username and password again.
A token is usually a string of characters that can contain user information and permission information.

Project code

@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));
	}

After completing the login, obtain a token token and return it to the front end. Let’s take another look at the verification of username and password.

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));

Obtain user information through the user name, and verify the password through passwordEncoder.matches(). If the password is successful, it returns a success status, userInfoInTokenResponse.isSuccess(). Go down and call permissionFeignClient to clear the permissions. The final code to obtain the token is as follows:

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;
	}

Explanation of the above token storage code:

1. Create a TokenInfoBO object: First, the method creates a TokenInfoBO object, which is used to store token-related information.
2. Generate Access Token and Refresh Token: Use IdUtil.simpleUUID() to generate a random Access Token and Refresh Token.
3. Set Token information: Set the userInfoInToken object to tokenInfoBO, and set the token expiration time (expiresIn). The expiration time is determined based on the sysType of userInfoInToken.
4. Obtain related Key: Obtain some key values ​​(Key) related to the token, such as uidToAccessKeyStr, accessKeyStr, and refreshToAccessKeyStr.
Process existing tokens: Check whether a token for the same user already exists by querying the data in Redis. If present, add the newly generated Access Token and Refresh Token to the list of existing tokens.
5. Save data using Redis Pipelining: Use Redis's Pipelining mechanism to execute multiple Redis commands at one time and store tokens and related information in Redis. These commands include associating Access Tokens and Refresh Tokens with users, setting their expiration times, and storing the user's information.
6. Encrypt token: Use the encryptToken method to encrypt Access Token and Refresh Token, and then set the encrypted token to tokenInfoBO.
Return Token information: Finally, return the tokenInfoBO object containing Access Token and Refresh Token information for front-end use.

The subsequent token decryption to prevent attacks and the token refresh code can see the relevant logic through code comments, so I will not explain them one by one.

Filter check

After the front-end obtains the token, it will carry this information in the access interface. Then the back-end service will verify it through the filter to see whether the accessed interface can pass the authentication. The main code is under this package.
Insert image description here

@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();
	}

}

The filtering logic is explained in detail below
Insert image description here
①:

The doFilter method in this code is a method that implements the Filter interface and is used to process the filtering logic of HTTP requests. It is a callback method. When a request arrives, the container will call this method to perform some pre-processing and post-processing operations.
The parameters of the method include:
request: Represents an HTTP request object, usually a ServletRequest type, which can be used to obtain requested information and data.
response: Represents an HTTP response object, usually of type ServletResponse, used to generate and send response data.
chain: Represents the filter chain (FilterChain), which can be used to continue processing the request or pass the request to the next filter.

③: Internal request verification

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;
	}

If the above returns false, it is the fegin request but the code in the judgment is passed if it does not pass, which means that the response sent is "unauthorized". If the fegin request verification fails, it is considered a failure.

if (!feignRequestCheck(req)) {
    
    
			httpHandler.printServerResponseToWeb(ServerResponseEntity.fail(ResponseEnum.UNAUTHORIZED));
			return;
		}

④: If the request is verified for token, this filter will be ignored and sent to the next filter. This is also the role of chain.doFilter(request, response). But you can see that there is currently only one filter in the system filter chain. in AuthConfig.
Insert image description here

@ConditionalOnMissingBean is an annotation commonly used in Spring framework applications, especially in Spring Boot. It is used to conditionally configure a bean based on whether other beans of the same type already exist in the application context. This annotation is part of Spring's annotation-based configuration and is used to control the instantiation of beans.

⑤: Currently, this list has the following URLs that need to be excluded, and this is achieved through bean injection
It is still this config code, the first bean, as shown in the screenshot below What needs to be excluded?

@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;
	}

}

Insert image description here
Insert image description here
⑥Then loop this list and make a regular match. To exclude URLs that do not need to pass this auth filter.
⑦⑧: Here is the formal request. Check whether it carries the content of Authorization. If it is empty, it will directly return unauthorized.
The next code is as follows
Insert image description here
① Check the interface code as follows. It calls the OpenFegin interface. Here is a look at how the project implements fegin.
First, the api interface to be provided by a certain module is established under the mall4cloud-api package. Under the fegin package, select one of the interfaces to view

You can see that it is modified by the @FeignClient(value = “mall4cloud-auth”, contextId = “token”) annotation: value specifies the name of the service, and contextId specifies the unique identifier of this interface. @GetMapping(value = Auth.CHECK_TOKEN_URI) specifies the accessed uri
Insert image description here

Then comes the implementation of the interface

is implemented under the fegin package of each module, and is implemented through the @RestController annotation. This approach is similar to the @DubboService annotation that modifies the api implementation class.
Insert image description here
'''''''
Insert image description here
The usual feign interface is called directly through http requests. When the checkToken() method is called, it is actually called Is the method of the proxy object generated by Feign.
The proxy object will generate an HTTP request based on the method definition and annotation (such as @GetMapping(value = Auth.CHECK_TOKEN_URI)) and send the request to the corresponding path of the remote service.
After the remote service responds, the proxy object will parse the response into the ServerResponseEntity< UserInfoInTokenBO > type and return it.
But there is no "/feign/token/checkToken" request uri and corresponding controller in the mall4cloud-auth module code.
At the same time, "/feign/token/ checkToken" request is also allowed by the interceptor, as explained above.

②③Permission verification
This is divided into merchant side, platform side and user side. Then check whether there is permission for a certain uri. The main code is as follows. This permission verification belongs to rbac Module, which will be introduced in detail in the next chapter
Insert image description here
If the verification fails, an unauthorized status code will be returned
Insert image description here
④: UseThreadLocal Save user information. It can achieve isolation between threads and transfer context information throughout the thread.

Thread-safe data isolation: ThreadLocal can be used to isolate data in a multi-threaded environment, ensuring that each thread has its own independent copy of data, thereby avoiding data sharing and race conditions between multiple threads. This is very useful for some contextual data, such as user login information, session information, etc.
Passing context information: There may be situations where you need to pass certain information across the entire thread context without having to explicitly pass it as a parameter in every method call. ThreadLocal can be used to store and access this contextual information.

Then enter the next filter (can be expanded) and release it after the user information is used up.

Summarize

The main process of the code logic of the auth module is the storeAccessToken() method under the login interface /ua/login->tokenStore. After the token storage is completed, it is the implementation of the filter AuthFilter class. The doFilter() method performs access path (request) authorization and user role authorization (the service of the rbac module is mainly called here).

The code of this auth module is not a mainstream user authentication and authorization function. More like a monolithic architecture service. Here are the areas where you have questions.

  1. The first is the generation of token, where the uuid method is used directly. This makes it predictable and treats it as a random string without storing any information. Of course, it is reasonable to use distributed redis to cache user information.

The above method can be used to obtain a random code when logging in, and then generate a token by encrypting the random code
In order to allow the token to store user information or increase the user's permissions, you can use jwt way. This is also a method for distributed

  1. The filter used for authorization is Filter under servlet. Most of the logical operations need to be written and specified manually, and some popular authority authentication frameworks, such as SpringSecrity or Shiro, are not used. As a learning project, you can learn the authorization and authentication logic, but as an enterprise-level project, the authentication and authorization are not refined enough. But the overall logic is consistent.

For example, some release interfaces in filters are not based on configuration, but are hard-coded in the code. Or for interface access, if it is unauthorized, it should normally jump to the specified login page and prompt instead of directly returning to unauthorized. These may be done more elaborately through the SpringSecrity framework.

An article on using SpringSecrity or shiro framework projects for user authentication and authorization will be added later.

Guess you like

Origin blog.csdn.net/qq_40454136/article/details/132861108