refactor: 多租户开发和代码规范调整

This commit is contained in:
Ray.Hao
2025-12-11 21:13:52 +08:00
parent 47cabcbcfc
commit 51d8220a18
67 changed files with 922 additions and 1157 deletions

View File

@@ -2,38 +2,45 @@ package com.youlai.boot.security.filter;
import cn.hutool.captcha.generator.CodeGenerator;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.youlai.boot.common.constant.RedisConstants;
import com.youlai.boot.common.constant.SecurityConstants;
import com.youlai.boot.core.web.ResultCode;
import com.youlai.boot.core.web.WebResponseHelper;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletInputStream;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequestWrapper;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher;
import org.springframework.security.web.util.matcher.RequestMatcher;
import org.springframework.util.StreamUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import org.springframework.web.util.ContentCachingRequestWrapper;
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
/**
* 图形验证码校验过滤器
*
* @author haoxr
* @since 2022/10/1
*/
public class CaptchaValidationFilter extends OncePerRequestFilter {
private static final RequestMatcher LOGIN_PATH_REQUEST_MATCHER = PathPatternRequestMatcher.withDefaults().matcher(HttpMethod.POST,SecurityConstants.LOGIN_PATH);
private static final RequestMatcher LOGIN_PATH_REQUEST_MATCHER = PathPatternRequestMatcher.withDefaults()
.matcher(HttpMethod.POST, SecurityConstants.LOGIN_PATH);
public static final String CAPTCHA_CODE_PARAM_NAME = "captchaCode";
public static final String CAPTCHA_KEY_PARAM_NAME = "captchaKey";
public static final String CAPTCHA_ID_PARAM_NAME = "captchaId";
private final RedisTemplate<String, Object> redisTemplate;
private final CodeGenerator codeGenerator;
public CaptchaValidationFilter(RedisTemplate<String, Object> redisTemplate, CodeGenerator codeGenerator) {
@@ -41,37 +48,111 @@ public class CaptchaValidationFilter extends OncePerRequestFilter {
this.codeGenerator = codeGenerator;
}
@Override
public void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
// 检验登录接口的验证码
if (LOGIN_PATH_REQUEST_MATCHER.matches(request)) {
// 请求中的验证码
String captchaCode = request.getParameter(CAPTCHA_CODE_PARAM_NAME);
// TODO 兼容没有验证码的版本(线上请移除这个判断)
if (StrUtil.isBlank(captchaCode)) {
chain.doFilter(request, response);
return;
}
// 缓存中的验证码
String verifyCodeKey = request.getParameter(CAPTCHA_KEY_PARAM_NAME);
String cacheVerifyCode = (String) redisTemplate.opsForValue().get(
StrUtil.format(RedisConstants.Captcha.IMAGE_CODE, verifyCodeKey)
);
if (cacheVerifyCode == null) {
WebResponseHelper.writeError(response, ResultCode.USER_VERIFICATION_CODE_EXPIRED);
} else {
// 验证码比对
if (codeGenerator.verify(cacheVerifyCode, captchaCode)) {
chain.doFilter(request, response);
} else {
WebResponseHelper.writeError(response, ResultCode.USER_VERIFICATION_CODE_ERROR);
}
}
} else {
// 非登录接口放行
public void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
// 非登录接口直接放行
if (!LOGIN_PATH_REQUEST_MATCHER.matches(request)) {
chain.doFilter(request, response);
return;
}
// 仅支持 JSON 登录
String contentType = request.getContentType();
if (contentType == null || !contentType.contains(MediaType.APPLICATION_JSON_VALUE)) {
WebResponseHelper.writeError(response, ResultCode.USER_VERIFICATION_CODE_ERROR);
return;
}
// 包装请求,确保下游还能读取 body
ContentCachingRequestWrapper requestWrapper = new ContentCachingRequestWrapper(request);
byte[] bodyBytes = StreamUtils.copyToByteArray(requestWrapper.getInputStream());
String body = new String(bodyBytes, StandardCharsets.UTF_8);
String captchaCode = null;
String captchaId = null;
if (StrUtil.isNotBlank(body)) {
JSONObject jsonObject = JSONUtil.parseObj(body);
captchaCode = jsonObject.getStr(CAPTCHA_CODE_PARAM_NAME);
captchaId = jsonObject.getStr(CAPTCHA_ID_PARAM_NAME);
}
if (StrUtil.isBlank(captchaCode) || StrUtil.isBlank(captchaId)) {
WebResponseHelper.writeError(response, ResultCode.USER_VERIFICATION_CODE_ERROR);
return;
}
String cacheVerifyCode = (String) redisTemplate.opsForValue().get(
StrUtil.format(RedisConstants.Captcha.IMAGE_CODE, captchaId)
);
if (cacheVerifyCode == null) {
WebResponseHelper.writeError(response, ResultCode.USER_VERIFICATION_CODE_EXPIRED);
return;
}
if (codeGenerator.verify(cacheVerifyCode, captchaCode)) {
HttpServletRequest repeatableRequest = new RepeatableReadRequestWrapper(requestWrapper, bodyBytes);
chain.doFilter(repeatableRequest, response);
} else {
WebResponseHelper.writeError(response, ResultCode.USER_VERIFICATION_CODE_ERROR);
}
}
/**
* Simple wrapper to allow repeated reads of the request body after we've parsed it here.
*/
private static class RepeatableReadRequestWrapper extends HttpServletRequestWrapper {
private final byte[] cachedBody;
RepeatableReadRequestWrapper(HttpServletRequest request, byte[] cachedBody) {
super(request);
this.cachedBody = cachedBody != null ? cachedBody : new byte[0];
}
@Override
public ServletInputStream getInputStream() {
ByteArrayInputStream bais = new ByteArrayInputStream(cachedBody);
return new ServletInputStream() {
@Override
public int read() {
return bais.read();
}
@Override
public boolean isFinished() {
return bais.available() == 0;
}
@Override
public boolean isReady() {
return true;
}
@Override
public void setReadListener(jakarta.servlet.ReadListener readListener) {
// no-op
}
};
}
@Override
public BufferedReader getReader() {
return new BufferedReader(new InputStreamReader(getInputStream(), StandardCharsets.UTF_8));
}
@Override
public int getContentLength() {
return cachedBody.length;
}
@Override
public long getContentLengthLong() {
return cachedBody.length;
}
}
}

View File

@@ -38,6 +38,11 @@ public class OnlineUser {
*/
private Integer dataScope;
/**
* 租户ID
*/
private Long tenantId;
/**
* 角色权限集合
*/

View File

@@ -56,6 +56,11 @@ public class SysUserDetails implements UserDetails {
*/
private Integer dataScope;
/**
* 租户ID
*/
private Long tenantId;
/**
* 用户角色权限集合
*/
@@ -73,6 +78,7 @@ public class SysUserDetails implements UserDetails {
this.enabled = ObjectUtil.equal(user.getStatus(), 1);
this.deptId = user.getDeptId();
this.dataScope = user.getDataScope();
this.tenantId = user.getTenantId();
// 初始化角色权限集合
this.authorities = CollectionUtil.isNotEmpty(user.getRoles())

View File

@@ -54,4 +54,9 @@ public class UserAuthCredentials {
*/
private Integer dataScope;
/**
* 租户ID从登录上下文中获取
*/
private Long tenantId;
}

View File

@@ -15,8 +15,8 @@ import java.util.*;
/**
* SpringSecurity 权限校验
*
* @author haoxr
* @since 2022/2/22
* @author Ray.Hao
* @since 0.0.1
*/
@Component("ss")
@RequiredArgsConstructor

View File

@@ -1,5 +1,6 @@
package com.youlai.boot.security.service;
import com.youlai.boot.common.tenant.TenantContextHolder;
import com.youlai.boot.security.model.SysUserDetails;
import com.youlai.boot.security.model.UserAuthCredentials;
import com.youlai.boot.system.service.UserService;
@@ -37,6 +38,8 @@ public class SysUserDetailsService implements UserDetailsService {
if (userAuthCredentials == null) {
throw new UsernameNotFoundException(username);
}
// 将当前上下文中的租户ID写入认证凭证便于后续 Token 携带租户信息
userAuthCredentials.setTenantId(TenantContextHolder.getTenantId());
return new SysUserDetails(userAuthCredentials);
} catch (Exception e) {
// 记录异常日志

View File

@@ -91,6 +91,7 @@ public class JwtTokenManager implements TokenManager {
userDetails.setUserId(payloads.getLong(JwtClaimConstants.USER_ID)); // 用户ID
userDetails.setDeptId(payloads.getLong(JwtClaimConstants.DEPT_ID)); // 部门ID
userDetails.setDataScope(payloads.getInt(JwtClaimConstants.DATA_SCOPE)); // 数据权限范围
userDetails.setTenantId(payloads.getLong(JwtClaimConstants.TENANT_ID)); // 租户ID
userDetails.setUsername(payloads.getStr(JWTPayload.SUBJECT)); // 用户名
// 角色集合
@@ -275,6 +276,7 @@ public class JwtTokenManager implements TokenManager {
payload.put(JwtClaimConstants.USER_ID, userDetails.getUserId()); // 用户ID
payload.put(JwtClaimConstants.DEPT_ID, userDetails.getDeptId()); // 部门ID
payload.put(JwtClaimConstants.DATA_SCOPE, userDetails.getDataScope()); // 数据权限范围
payload.put(JwtClaimConstants.TENANT_ID, userDetails.getTenantId()); // 租户ID
// claims 中添加角色信息
Set<String> roles = authentication.getAuthorities().stream()

View File

@@ -61,6 +61,7 @@ public class RedisTokenManager implements TokenManager {
user.getUsername(),
user.getDeptId(),
user.getDataScope(),
user.getTenantId(),
user.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toSet())
@@ -268,6 +269,7 @@ public class RedisTokenManager implements TokenManager {
userDetails.setUsername(onlineUser.getUsername());
userDetails.setDeptId(onlineUser.getDeptId());
userDetails.setDataScope(onlineUser.getDataScope());
userDetails.setTenantId(onlineUser.getTenantId());
userDetails.setAuthorities(authorities);
return userDetails;
}