refactor(security): Ports & Adapters 架构重构|解除反向依赖|抽离 Security Starter 准备

- 新增 2 个端口接口(UserAuthenticationPort / PermissionPort)+ 1 个适配器(PermissionAdapter)
- SecurityUserDetails 用 roles(Set<String>) 替代 authorities,消除 Jackson 序列化问题
- 删除 UserSession,Redis / JWT 直存 SecurityUserDetails
- SecurityUserDetailsService / PermissionService 注入 Port 替代 system Service
This commit is contained in:
Ray.Hao
2026-07-05 16:53:46 +08:00
parent b17bd6bf7c
commit 2441efa9ba
46 changed files with 2917 additions and 938 deletions

View File

@@ -1,17 +1,19 @@
package com.youlai.boot.framework.security.config;
package com.youlai.boot.auth.security.config;
import cn.binarywang.wx.miniapp.api.WxMaService;
import cn.hutool.core.util.ArrayUtil;
import com.youlai.boot.framework.captcha.service.CaptchaService;
import com.youlai.boot.framework.security.filter.CaptchaValidationFilter;
import com.youlai.boot.framework.security.config.SecurityProperties;
import com.youlai.boot.framework.security.filter.TokenAuthenticationFilter;
import com.youlai.boot.framework.security.handler.MyAccessDeniedHandler;
import com.youlai.boot.framework.security.handler.MyAuthenticationEntryPoint;
import com.youlai.boot.framework.security.provider.SmsAuthenticationProvider;
import com.youlai.boot.framework.security.provider.WxMaAuthenticationProvider;
import com.youlai.boot.framework.security.port.UserAuthenticationPort;
import com.youlai.boot.framework.security.service.SecurityUserDetailsService;
import com.youlai.boot.framework.security.token.TokenManager;
import com.youlai.boot.framework.security.service.SysUserDetailsService;
import com.youlai.boot.system.service.UserService;
import com.youlai.boot.auth.security.filter.CaptchaValidationFilter;
import com.youlai.boot.auth.security.handler.JsonAccessDeniedHandler;
import com.youlai.boot.auth.security.handler.JsonAuthenticationEntryPoint;
import com.youlai.boot.auth.security.provider.SmsAuthenticationProvider;
import com.youlai.boot.auth.security.provider.WxMaAuthenticationProvider;
import com.youlai.boot.system.service.UserSocialService;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -29,9 +31,13 @@ import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.access.intercept.AuthorizationFilter;
/**
* Spring Security 配置类
* Spring Security 配置类
* <p>
* 归使用方auth 模块安全规则放行路径CORSProvider 装配响应格式
* 因项目而异不应由框架层强制装配
*
* @author Ray.Hao
* @since 4.3.1
@@ -44,57 +50,41 @@ public class SecurityConfig {
private final RedisTemplate<String, Object> redisTemplate;
private final PasswordEncoder passwordEncoder;
private final TokenManager tokenManager;
private final UserService userService;
private final SysUserDetailsService userDetailsService;
private final SecurityUserDetailsService userDetailsService;
private final CaptchaService captchaService;
private final SecurityProperties securityProperties;
/**
* 配置安全过滤链 SecurityFilterChain
*/
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(requestMatcherRegistry -> {
// 配置无需登录即可访问的公开接口配置文件方式
String[] ignoreUrls = securityProperties.getIgnoreUrls();
if (ArrayUtil.isNotEmpty(ignoreUrls)) {
requestMatcherRegistry.requestMatchers(ignoreUrls).permitAll();
}
// 其他所有请求需登录后访问
requestMatcherRegistry.anyRequest().authenticated();
}
)
.exceptionHandling(configurer ->
configurer
.authenticationEntryPoint(new MyAuthenticationEntryPoint()) // 未认证异常处理器
.accessDeniedHandler(new MyAccessDeniedHandler()) // 无权限访问异常处理器
.authenticationEntryPoint(new JsonAuthenticationEntryPoint())
.accessDeniedHandler(new JsonAccessDeniedHandler())
)
// 禁用默认的 Spring Security 特性适用于前后端分离架构
.sessionManagement(configurer ->
configurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 无状态认证不使用 Session
configurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.csrf(AbstractHttpConfigurer::disable) // 禁用 CSRF 防护前后端分离无需此防护机制
.formLogin(AbstractHttpConfigurer::disable) // 禁用默认的表单登录功能前后端分离采用 Token 认证方式
.httpBasic(AbstractHttpConfigurer::disable) // 禁用 HTTP Basic 认证避免弹窗式登录
// 禁用 X-Frame-Options 响应头允许页面被嵌套到 iframe
.csrf(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.headers(headers -> headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::disable))
// 验证码校验过滤器
// 验证码校验使用方过滤器直接写 JSON 响应
.addFilterBefore(new CaptchaValidationFilter(captchaService), UsernamePasswordAuthenticationFilter.class)
// 验证和解析过滤器
.addFilterBefore(new TokenAuthenticationFilter(tokenManager), UsernamePasswordAuthenticationFilter.class)
// Token 认证Starter 过滤器 AuthenticationException 交给 ExceptionTranslationFilter 处理
.addFilterBefore(new TokenAuthenticationFilter(tokenManager), AuthorizationFilter.class)
.build();
}
/**
* 配置Web安全自定义器以忽略特定请求路径的安全性检查
* <p>
* 该配置用于指定哪些请求路径不经过Spring Security过滤器链通常用于静态资源文件
*/
@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
return (web) -> {
@@ -105,38 +95,27 @@ public class SecurityConfig {
};
}
/**
* 默认密码认证的 Provider
*/
@Bean
public DaoAuthenticationProvider daoAuthenticationProvider() {
DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider(userDetailsService);
daoAuthenticationProvider.setPasswordEncoder(passwordEncoder);
return daoAuthenticationProvider;
DaoAuthenticationProvider provider = new DaoAuthenticationProvider(userDetailsService);
provider.setPasswordEncoder(passwordEncoder);
return provider;
}
/**
* 短信验证码认证 Provider
*/
@Bean
public SmsAuthenticationProvider smsAuthenticationProvider() {
return new SmsAuthenticationProvider(userService, redisTemplate);
public SmsAuthenticationProvider smsAuthenticationProvider(UserAuthenticationPort userAuthPort) {
return new SmsAuthenticationProvider(userAuthPort, redisTemplate);
}
/**
* 微信小程序认证 Provider
*/
@Bean
public WxMaAuthenticationProvider wechatMiniAuthenticationProvider(
WxMaService wxMaService,
SysUserDetailsService sysUserDetailsService
UserAuthenticationPort userAuthenticationPort,
UserSocialService userSocialService
) {
return new WxMaAuthenticationProvider(wxMaService, sysUserDetailsService);
return new WxMaAuthenticationProvider(wxMaService, userAuthenticationPort, userSocialService);
}
/**
* 认证管理器
*/
@Bean
public AuthenticationManager authenticationManager(
DaoAuthenticationProvider daoAuthenticationProvider,
@@ -149,5 +128,4 @@ public class SecurityConfig {
wxMaAuthenticationProvider
);
}
}

View File

@@ -1,17 +1,16 @@
package com.youlai.boot.framework.security.exception;
package com.youlai.boot.auth.security.exception;
import org.springframework.security.core.AuthenticationException;
/**
* 需要绑定手机号异常
* 需要绑定手机号异常微信小程序登录未绑定手机号时抛出
*/
public class NeedBindMobileException extends AuthenticationException {
public class MobileNotBoundException extends AuthenticationException {
private final String openid;
private final String sessionKey;
public NeedBindMobileException(String openid, String sessionKey) {
public MobileNotBoundException(String openid, String sessionKey) {
super("需要绑定手机号");
this.openid = openid;
this.sessionKey = sessionKey;
@@ -24,5 +23,4 @@ public class NeedBindMobileException extends AuthenticationException {
public String getSessionKey() {
return sessionKey;
}
}

View File

@@ -1,4 +1,4 @@
package com.youlai.boot.framework.security.exception;
package com.youlai.boot.auth.security.exception;
import org.springframework.security.core.AuthenticationException;
@@ -9,6 +9,7 @@ import org.springframework.security.core.AuthenticationException;
* @since 2025/3/1
*/
public class SmsCaptchaException extends AuthenticationException {
public SmsCaptchaException(String msg) {
super(msg);
}

View File

@@ -1,4 +1,4 @@
package com.youlai.boot.framework.security.filter;
package com.youlai.boot.auth.security.filter;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONObject;
@@ -29,7 +29,9 @@ import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
/**
* 图形验证码校验过滤器
* 图形验证码校验过滤器
* <p>
* 归使用方因为验证码规则哪些接口需要验证码验证码类型因项目而异
*/
public class CaptchaValidationFilter extends OncePerRequestFilter {
@@ -49,20 +51,17 @@ public class CaptchaValidationFilter extends OncePerRequestFilter {
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)) {
ResponseWriter.writeError(response, ResultCode.USER_VERIFICATION_CODE_ERROR);
return;
}
// 包装请求确保下游还能读取 body
ContentCachingRequestWrapper requestWrapper = new ContentCachingRequestWrapper(request, -1);
byte[] bodyBytes = StreamUtils.copyToByteArray(requestWrapper.getInputStream());
@@ -85,9 +84,6 @@ public class CaptchaValidationFilter extends OncePerRequestFilter {
}
}
/**
* 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;
@@ -139,5 +135,3 @@ public class CaptchaValidationFilter extends OncePerRequestFilter {
}
}
}

View File

@@ -1,4 +1,4 @@
package com.youlai.boot.framework.security.handler;
package com.youlai.boot.auth.security.handler;
import com.youlai.boot.common.result.ResultCode;
import com.youlai.boot.framework.web.util.ResponseWriter;
@@ -9,17 +9,18 @@ import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
/**
* 无权限访问处理器
* 无权限访问处理器
* <p>
* 归使用方因为 JSON 响应格式因项目而异
*
* @author Ray.Hao
* @since 2.0.0
*/
public class MyAccessDeniedHandler implements AccessDeniedHandler {
public class JsonAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) {
// 权限不足返回 403 Forbidden
public void handle(HttpServletRequest request, HttpServletResponse response,
AccessDeniedException accessDeniedException) {
ResponseWriter.writeError(response, ResultCode.ACCESS_PERMISSION_EXCEPTION);
}
}

View File

@@ -1,4 +1,4 @@
package com.youlai.boot.framework.security.handler;
package com.youlai.boot.auth.security.handler;
import com.youlai.boot.common.result.ResultCode;
import com.youlai.boot.framework.web.util.ResponseWriter;
@@ -14,35 +14,24 @@ import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* 统一处理 Spring Security 认证失败响应
* 统一处理 Spring Security 认证失败响应
* <p>
* 归使用方因为 JSON 响应格式Result 结构错误码因项目而异
*
* @author Ray.Hao
* @since 2.0.0
*/
public class MyAuthenticationEntryPoint implements AuthenticationEntryPoint {
public class JsonAuthenticationEntryPoint implements AuthenticationEntryPoint {
/**
* 认证失败处理入口方法
*
* @param request 触发异常的请求对象可用于获取请求头参数等
* @param response 响应对象用于写入错误信息
* @param authException 认证异常对象包含具体失败原因
*/
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException, ServletException {
if (authException instanceof BadCredentialsException) {
// 用户名或密码错误
ResponseWriter.writeError(response, ResultCode.USER_PASSWORD_ERROR);
} else if(authException instanceof InsufficientAuthenticationException){
// 请求头缺失AuthorizationToken格式错误Token过期签名验证失败
} else if (authException instanceof InsufficientAuthenticationException) {
ResponseWriter.writeError(response, ResultCode.ACCESS_TOKEN_INVALID);
} else {
// 其他未明确处理的认证异常如账户被锁定账户禁用等
ResponseWriter.writeError(response, ResultCode.USER_LOGIN_EXCEPTION, authException.getMessage());
}
}
}

View File

@@ -1,4 +1,4 @@
package com.youlai.boot.framework.security.model;
package com.youlai.boot.auth.security.model;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
@@ -8,13 +8,9 @@ import java.io.Serial;
import java.util.Collection;
/**
* 短信验证码认证 Token
* 短信验证码认证 Token
* <p>
* 用于短信验证码登录场景遵循 Spring Security 认证模型
* <ul>
* <li>未认证状态principal 为手机号credentials 为验证码</li>
* <li>已认证状态principal 为用户详情credentials null</li>
* </ul>
* 未认证principal=手机号credentials=验证码已认证principal=SecurityUserDetailscredentials=null
*
* @author Ray.Hao
* @since 2.20.0
@@ -24,30 +20,9 @@ public class SmsAuthenticationToken extends AbstractAuthenticationToken {
@Serial
private static final long serialVersionUID = 621L;
/**
* 认证信息
* <ul>
* <li>未认证时手机号</li>
* <li>已认证时SysUserDetails 用户详情</li>
* </ul>
*/
private final Object principal;
/**
* 凭证信息
* <ul>
* <li>未认证时短信验证码</li>
* <li>已认证时null</li>
* </ul>
*/
private final Object credentials;
/**
* 创建未认证的 Token
*
* @param mobile 手机号
* @param verifyCode 短信验证码
*/
public SmsAuthenticationToken(String mobile, String verifyCode) {
super(AuthorityUtils.NO_AUTHORITIES);
this.principal = mobile;
@@ -55,12 +30,6 @@ public class SmsAuthenticationToken extends AbstractAuthenticationToken {
setAuthenticated(false);
}
/**
* 创建已认证的 Token
*
* @param principal 用户详情SysUserDetails
* @param authorities 授权信息
*/
public SmsAuthenticationToken(Object principal, Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
@@ -68,13 +37,6 @@ public class SmsAuthenticationToken extends AbstractAuthenticationToken {
super.setAuthenticated(true);
}
/**
* 创建已认证的 Token静态工厂方法
*
* @param principal 用户详情SysUserDetails
* @param authorities 授权信息
* @return 已认证的 SmsAuthenticationToken
*/
public static SmsAuthenticationToken authenticated(Object principal, Collection<? extends GrantedAuthority> authorities) {
return new SmsAuthenticationToken(principal, authorities);
}

View File

@@ -1,4 +1,4 @@
package com.youlai.boot.framework.security.model;
package com.youlai.boot.auth.security.model;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
@@ -8,7 +8,9 @@ import java.io.Serial;
import java.util.Collection;
/**
* 微信小程序认证 Token
* 微信小程序认证 Token
* <p>
* 未认证principal=微信code已认证principal=SecurityUserDetails
*
* @author Ray.Hao
* @since 4.0.0
@@ -18,25 +20,9 @@ public class WxMaAuthenticationToken extends AbstractAuthenticationToken {
@Serial
private static final long serialVersionUID = 622L;
/**
* 认证信息
* 未认证时微信code
* 已认证时SysUserDetails 用户详情
*/
private final Object principal;
/**
* 凭证信息
* 未认证时null
* 已认证时null
*/
private final Object credentials;
/**
* 创建未认证的 Token
*
* @param code 微信小程序code
*/
public WxMaAuthenticationToken(String code) {
super(AuthorityUtils.NO_AUTHORITIES);
this.principal = code;
@@ -44,12 +30,6 @@ public class WxMaAuthenticationToken extends AbstractAuthenticationToken {
setAuthenticated(false);
}
/**
* 创建已认证的 Token
*
* @param principal 用户详情SysUserDetails
* @param authorities 授权信息
*/
public WxMaAuthenticationToken(Object principal, Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
@@ -57,9 +37,6 @@ public class WxMaAuthenticationToken extends AbstractAuthenticationToken {
super.setAuthenticated(true);
}
/**
* 创建已认证的 Token静态工厂方法
*/
public static WxMaAuthenticationToken authenticated(Object principal, Collection<? extends GrantedAuthority> authorities) {
return new WxMaAuthenticationToken(principal, authorities);
}

View File

@@ -1,13 +1,13 @@
package com.youlai.boot.framework.security.provider;
package com.youlai.boot.auth.security.provider;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import com.youlai.boot.common.constant.RedisConstants;
import com.youlai.boot.framework.security.exception.SmsCaptchaException;
import com.youlai.boot.framework.security.model.SmsAuthenticationToken;
import com.youlai.boot.framework.security.model.SysUserDetails;
import com.youlai.boot.framework.security.model.UserAuthInfo;
import com.youlai.boot.system.service.UserService;
import com.youlai.boot.framework.security.model.SecurityUser;
import com.youlai.boot.framework.security.model.SecurityUserDetails;
import com.youlai.boot.framework.security.port.UserAuthenticationPort;
import com.youlai.boot.auth.security.exception.SmsCaptchaException;
import com.youlai.boot.auth.security.model.SmsAuthenticationToken;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.authentication.AuthenticationProvider;
@@ -19,47 +19,34 @@ import org.springframework.security.core.userdetails.UsernameNotFoundException;
/**
* 短信验证码认证 Provider
* <p>
* 实现 Spring Security {@link AuthenticationProvider} 接口处理短信验证码登录认证
* <p>
* 认证流程
* <ol>
* <li>根据手机号查询用户信息</li>
* <li>校验用户状态是否禁用</li>
* <li>校验用户状态</li>
* <li>校验短信验证码 Redis 缓存比对</li>
* <li>验证成功后删除验证码防止重复使用</li>
* <li>验证成功后删除验证码</li>
* <li>返回已认证的 Authentication</li>
* </ol>
*
* @author Ray.Hao
* @since 2.17.0
* @see SmsAuthenticationToken
* @see AuthenticationProvider
*/
@Slf4j
public class SmsAuthenticationProvider implements AuthenticationProvider {
private final UserService userService;
private final UserAuthenticationPort userAuthPort;
private final RedisTemplate<String, Object> redisTemplate;
public SmsAuthenticationProvider(UserService userService, RedisTemplate<String, Object> redisTemplate) {
this.userService = userService;
public SmsAuthenticationProvider(UserAuthenticationPort userAuthPort, RedisTemplate<String, Object> redisTemplate) {
this.userAuthPort = userAuthPort;
this.redisTemplate = redisTemplate;
}
/**
* 执行短信验证码认证
*
* @param authentication 未认证的 {@link SmsAuthenticationToken}
* @return 已认证的 {@link SmsAuthenticationToken}
* @throws AuthenticationException 认证失败异常
*/
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String mobile = (String) authentication.getPrincipal();
String inputVerifyCode = (String) authentication.getCredentials();
// 参数校验
if (StrUtil.isBlank(mobile)) {
log.warn("短信验证码登录失败:手机号为空");
throw new SmsCaptchaException("手机号不能为空");
@@ -69,21 +56,18 @@ public class SmsAuthenticationProvider implements AuthenticationProvider {
throw new SmsCaptchaException("验证码不能为空");
}
// 根据手机号获取用户信息
UserAuthInfo userAuthInfo = userService.getAuthInfoByMobile(mobile);
SecurityUser securityUser = userAuthPort.getAuthInfoByMobile(mobile);
if (userAuthInfo == null) {
if (securityUser == null) {
log.warn("短信验证码登录失败:用户不存在,手机号={}", mobile);
throw new UsernameNotFoundException("用户不存在");
}
// 检查用户状态是否有效
if (ObjectUtil.notEqual(userAuthInfo.getStatus(), 1)) {
log.warn("短信验证码登录失败:用户已禁用,用户名={}", userAuthInfo.getUsername());
if (ObjectUtil.notEqual(securityUser.getStatus(), 1)) {
log.warn("短信验证码登录失败:用户已禁用,用户名={}", securityUser.getUsername());
throw new DisabledException("用户已被禁用");
}
// 校验短信验证码
String cacheKey = StrUtil.format(RedisConstants.Captcha.SMS_LOGIN_CODE, mobile);
String cachedVerifyCode = (String) redisTemplate.opsForValue().get(cacheKey);
@@ -97,24 +81,13 @@ public class SmsAuthenticationProvider implements AuthenticationProvider {
throw new SmsCaptchaException("验证码错误");
}
// 验证成功后删除验证码防止重复使用
redisTemplate.delete(cacheKey);
// 构建认证后的用户详情信息
SysUserDetails userDetails = new SysUserDetails(userAuthInfo);
log.info("短信验证码登录成功:用户名={},手机号={}", userAuthInfo.getUsername(), mobile);
// 创建已认证的 SmsAuthenticationToken
SecurityUserDetails userDetails = new SecurityUserDetails(securityUser);
log.info("短信验证码登录成功:用户名={},手机号={}", securityUser.getUsername(), mobile);
return SmsAuthenticationToken.authenticated(userDetails, userDetails.getAuthorities());
}
/**
* 支持的认证类型
*
* @param authentication 认证类型
* @return 是否支持该认证类型
*/
@Override
public boolean supports(Class<?> authentication) {
return SmsAuthenticationToken.class.isAssignableFrom(authentication);

View File

@@ -1,14 +1,16 @@
package com.youlai.boot.framework.security.provider;
package com.youlai.boot.auth.security.provider;
import cn.binarywang.wx.miniapp.api.WxMaService;
import cn.binarywang.wx.miniapp.bean.WxMaJscode2SessionResult;
import cn.hutool.core.util.ObjectUtil;
import com.youlai.boot.framework.security.exception.NeedBindMobileException;
import com.youlai.boot.framework.security.model.SysUserDetails;
import com.youlai.boot.framework.security.model.UserAuthInfo;
import com.youlai.boot.framework.security.model.WxMaAuthenticationToken;
import com.youlai.boot.framework.security.service.SysUserDetailsService;
import com.youlai.boot.common.enums.SocialPlatformEnum;
import com.youlai.boot.framework.security.model.SecurityUser;
import com.youlai.boot.framework.security.model.SecurityUserDetails;
import com.youlai.boot.framework.security.port.UserAuthenticationPort;
import com.youlai.boot.auth.security.exception.MobileNotBoundException;
import com.youlai.boot.auth.security.model.WxMaAuthenticationToken;
import com.youlai.boot.system.model.entity.UserSocial;
import com.youlai.boot.system.service.UserSocialService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import me.chanjar.weixin.common.error.WxErrorException;
@@ -26,7 +28,8 @@ import org.springframework.security.core.userdetails.UsernameNotFoundException;
public class WxMaAuthenticationProvider implements AuthenticationProvider {
private final WxMaService wxMaService;
private final SysUserDetailsService sysUserDetailsService;
private final UserAuthenticationPort userAuthPort;
private final UserSocialService userSocialService;
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
@@ -38,44 +41,35 @@ public class WxMaAuthenticationProvider implements AuthenticationProvider {
}
try {
// 1. code 换取 openid
WxMaJscode2SessionResult session = wxMaService.jsCode2SessionInfo(code);
String openid = session.getOpenid();
String sessionKey = session.getSessionKey();
log.info("微信小程序登录openid={}", openid);
// 2. 根据 openid 查询绑定信息
UserSocial userSocial = sysUserDetailsService.getWechatMiniBindInfo(openid);
UserSocial userSocial = userSocialService.getByPlatformAndOpenid(SocialPlatformEnum.WECHAT_MINI, openid);
if (userSocial == null) {
// 未绑定抛出异常提示需要绑定手机号
log.info("微信小程序登录用户未绑定手机号openid={}", openid);
throw new NeedBindMobileException(openid, sessionKey);
throw new MobileNotBoundException(openid, sessionKey);
}
// 3. 获取用户认证信息
UserAuthInfo userAuthInfo = sysUserDetailsService.getAuthInfoByWechatOpenid(openid);
SecurityUser securityUser = userAuthPort.getAuthInfoByOpenid(SocialPlatformEnum.WECHAT_MINI, openid);
if (userAuthInfo == null) {
if (securityUser == null) {
log.warn("微信小程序登录失败用户不存在openid={}", openid);
throw new UsernameNotFoundException("用户不存在");
}
// 4. 检查用户状态
if (ObjectUtil.notEqual(userAuthInfo.getStatus(), 1)) {
log.warn("微信小程序登录失败用户已禁用username={}", userAuthInfo.getUsername());
if (ObjectUtil.notEqual(securityUser.getStatus(), 1)) {
log.warn("微信小程序登录失败用户已禁用username={}", securityUser.getUsername());
throw new DisabledException("用户已被禁用");
}
// 5. 更新 session_key
sysUserDetailsService.updateWechatSessionKey(userSocial.getId(), sessionKey);
// 6. 构建已认证 Token
SysUserDetails userDetails = new SysUserDetails(userAuthInfo);
log.info("微信小程序登录成功username={}, openid={}", userAuthInfo.getUsername(), openid);
userSocialService.updateSessionKey(userSocial.getId(), sessionKey);
SecurityUserDetails userDetails = new SecurityUserDetails(securityUser);
log.info("微信小程序登录成功username={}, openid={}", securityUser.getUsername(), openid);
return WxMaAuthenticationToken.authenticated(userDetails, userDetails.getAuthorities());
} catch (WxErrorException e) {
@@ -88,5 +82,4 @@ public class WxMaAuthenticationProvider implements AuthenticationProvider {
public boolean supports(Class<?> authentication) {
return WxMaAuthenticationToken.class.isAssignableFrom(authentication);
}
}

View File

@@ -6,8 +6,8 @@ import com.youlai.boot.common.constant.RedisConstants;
import com.youlai.boot.framework.captcha.model.CaptchaInfo;
import com.youlai.boot.framework.captcha.service.CaptchaService;
import com.youlai.boot.framework.security.model.AuthenticationToken;
import com.youlai.boot.framework.security.model.SmsAuthenticationToken;
import com.youlai.boot.framework.security.token.TokenManager;
import com.youlai.boot.auth.security.model.SmsAuthenticationToken;
import com.youlai.boot.framework.security.util.SecurityUtils;
import com.youlai.boot.framework.integration.sms.enums.SmsTypeEnum;
import com.youlai.boot.framework.integration.sms.service.SmsService;
@@ -58,9 +58,9 @@ public class AuthServiceImpl implements AuthService {
// 2. 执行认证(认证中)
// 说明:这里的认证流程由 Spring Security 提供的 AuthenticationManager 执行。
// 默认情况下会委托给 DaoAuthenticationProvider
// 1) retrieveUser(...):内部通过 UserDetailsService.loadUserByUsername(...) 获取用户信息(本项目为 SysUserDetailsService 实现)
// 1) retrieveUser(...):内部通过 UserDetailsService.loadUserByUsername(...) 获取用户信息(本项目为 SecurityUserDetailsService 实现)
// 2) additionalAuthenticationChecks(...):对比请求密码与用户存储密码(由 PasswordEncoder 完成匹配)
// 认证通过后返回已认证的 Authenticationprincipal 为 SysUserDetailsauthorities 为角色/权限集合)。
// 认证通过后返回已认证的 Authenticationprincipal 为 SecurityUserDetailsauthorities 为角色/权限集合)。
Authentication authentication = authenticationManager.authenticate(authenticationToken);
// 3. 认证成功后生成 JWT 令牌,并存入 Security 上下文,供登录日志 AOP 使用(已认证)

View File

@@ -8,12 +8,12 @@ import cn.hutool.core.util.StrUtil;
import com.youlai.boot.auth.model.vo.WxMaLoginVO;
import com.youlai.boot.auth.service.WxMaAuthService;
import com.youlai.boot.common.constant.RedisConstants;
import com.youlai.boot.framework.security.exception.NeedBindMobileException;
import com.youlai.boot.framework.security.model.AuthenticationToken;
import com.youlai.boot.framework.security.model.SysUserDetails;
import com.youlai.boot.framework.security.model.WxMaAuthenticationToken;
import com.youlai.boot.framework.security.model.SecurityUserDetails;
import com.youlai.boot.framework.security.token.TokenManager;
import com.youlai.boot.system.enums.SocialPlatformEnum;
import com.youlai.boot.auth.security.exception.MobileNotBoundException;
import com.youlai.boot.auth.security.model.WxMaAuthenticationToken;
import com.youlai.boot.common.enums.SocialPlatformEnum;
import com.youlai.boot.system.model.entity.SysUser;
import com.youlai.boot.system.service.UserSocialService;
import com.youlai.boot.system.service.UserService;
@@ -69,7 +69,7 @@ public class WxMaAuthServiceImpl implements WxMaAuthService {
.tokenType(authToken.getTokenType())
.expiresIn(authToken.getExpiresIn())
.build();
} catch (NeedBindMobileException e) {
} catch (MobileNotBoundException e) {
return WxMaLoginVO.builder()
.isNewUser(true)
.needBindMobile(true)
@@ -234,7 +234,7 @@ public class WxMaAuthServiceImpl implements WxMaAuthService {
* 生成认证令牌
*/
private AuthenticationToken generateAuthToken(String mobile) {
SysUserDetails userDetails = new SysUserDetails(userService.getAuthInfoByMobile(mobile));
SecurityUserDetails userDetails = new SecurityUserDetails(userService.getAuthInfoByMobile(mobile));
Authentication authentication = new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities()
);

View File

@@ -33,9 +33,9 @@ public interface JwtClaimConstants {
String DATA_SCOPES = "dataScopes";
/**
* 权限(角色Code)集合
* 角色编码集合(不带 ROLE_ 前缀)
*/
String AUTHORITIES = "authorities";
String ROLES = "roles";
/**
* Token 版本号

View File

@@ -1,4 +1,4 @@
package com.youlai.boot.system.enums;
package com.youlai.boot.common.enums;
import com.baomidou.mybatisplus.annotation.EnumValue;
import com.youlai.boot.common.base.IBaseEnum;

View File

@@ -7,7 +7,7 @@ import com.baomidou.mybatisplus.extension.plugins.handler.DataPermissionHandler;
import com.youlai.boot.common.annotation.DataPermission;
import com.youlai.boot.common.enums.DataScopeEnum;
import com.youlai.boot.framework.security.model.RoleDataScope;
import com.youlai.boot.framework.security.model.SysUserDetails;
import com.youlai.boot.framework.security.model.SecurityUserDetails;
import com.youlai.boot.framework.security.util.SecurityUtils;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
@@ -58,7 +58,7 @@ public class MyDataPermissionHandler implements DataPermissionHandler {
// 获取当前用户的数据权限列表
List<RoleDataScope> dataScopes = SecurityUtils.getUser()
.map(SysUserDetails::getDataScopes)
.map(SecurityUserDetails::getDataScopes)
.orElse(List.of());
// 如果任一角色是 ALL则跳过数据权限过滤并集策略

View File

@@ -2,78 +2,72 @@ package com.youlai.boot.framework.security.filter;
import cn.hutool.core.util.StrUtil;
import com.youlai.boot.common.constant.SecurityConstants;
import com.youlai.boot.common.result.ResultCode;
import com.youlai.boot.framework.web.util.ResponseWriter;
import com.youlai.boot.framework.security.token.TokenManager;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpHeaders;
import org.springframework.security.authentication.InsufficientAuthenticationException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
/**
* Token 认证校验过滤器
* Token 认证过滤器
* <p>
* 仅负责解析 Token 和填充 {@link SecurityContextHolder}。
* 无效 Token 抛出 {@link AuthenticationException},由使用方的
* {@code AuthenticationEntryPoint} 统一处理响应格式。
* <p>
* 必须注册在 {@code ExceptionTranslationFilter} 之后(即 {@code AuthorizationFilter} 之前),
* 这样抛出的异常才能被 {@code ExceptionTranslationFilter} 捕获。
*
* @author wangtao
* @since 2025/3/6 16:50
* @since 2025/3/6
*/
public class TokenAuthenticationFilter extends OncePerRequestFilter {
/**
* Token 管理器
*/
private final TokenManager tokenManager;
public TokenAuthenticationFilter(TokenManager tokenManager) {
this.tokenManager = tokenManager;
}
/**
* 校验 Token ,包括验签和是否过期
* 如果 Token 有效,将 Token 解析为 Authentication 对象,并设置到 Spring Security 上下文中
*/
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String rawToken = resolveToken(request);
try {
if (StrUtil.isNotBlank(rawToken)) {
// 执行令牌有效性检查(包含密码学验签和过期时间验证)
boolean isValidToken = tokenManager.validateToken(rawToken);
if (!isValidToken) {
ResponseWriter.writeError(response, ResultCode.ACCESS_TOKEN_INVALID);
return;
if (StrUtil.isNotBlank(rawToken)) {
try {
boolean isValid = tokenManager.validateToken(rawToken);
if (!isValid) {
SecurityContextHolder.clearContext();
throw new InsufficientAuthenticationException("Token 无效或已过期");
}
// 将令牌解析为 Spring Security 上下文认证对象
Authentication authentication = tokenManager.parseToken(rawToken);
SecurityContextHolder.getContext().setAuthentication(authentication);
} catch (AuthenticationException ex) {
SecurityContextHolder.clearContext();
throw ex;
} catch (Exception ex) {
SecurityContextHolder.clearContext();
throw new InsufficientAuthenticationException("Token 认证失败", ex);
}
} catch (Exception ex) {
// 安全上下文清除保障(防止上下文残留)
SecurityContextHolder.clearContext();
ResponseWriter.writeError(response, ResultCode.ACCESS_TOKEN_INVALID);
return;
}
// 继续后续过滤器链执行
filterChain.doFilter(request, response);
}
/**
* 从请求中解析 Token仅支持 Authorization Header
*/
private String resolveToken(HttpServletRequest request) {
String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
if (StrUtil.isNotBlank(authorizationHeader)
&& authorizationHeader.startsWith(SecurityConstants.BEARER_TOKEN_PREFIX)) {
return authorizationHeader.substring(SecurityConstants.BEARER_TOKEN_PREFIX.length());
String header = request.getHeader(HttpHeaders.AUTHORIZATION);
if (StrUtil.isNotBlank(header) && header.startsWith(SecurityConstants.BEARER_TOKEN_PREFIX)) {
return header.substring(SecurityConstants.BEARER_TOKEN_PREFIX.length());
}
return null;
}

View File

@@ -6,16 +6,16 @@ import java.util.List;
import java.util.Set;
/**
* 用户认证信息
* 安全模块用户数据 POJO
* <p>
* 用于登录认证过程中的用户信息承载包含用户名密码状态角色等与认证/授权相关的数据
* </p>
* 作为端口接口的返回类型承载用户认证所需的全部数据
* JDK 类型 system 模块依赖可直接序列化
*
* @author Ray.Hao
* @since 2025/12/16
*/
@Data
public class UserAuthInfo {
public class SecurityUser {
/**
* 用户ID
@@ -48,7 +48,7 @@ public class UserAuthInfo {
private Integer status;
/**
* 角色集合
* 角色编码集合
*/
private Set<String> roles;

View File

@@ -13,17 +13,18 @@ import java.util.*;
import java.util.stream.Collectors;
/**
* Spring Security 用户认证对象
* Spring Security 用户认证对象
* <p>
* 封装了用户的基本信息和权限信息 Spring Security 进行用户认证与授权
* 实现了 {@link UserDetails} 接口提供用户的核心信息
* 实现 {@link UserDetails}封装用户标识角色数据权限等认证信息
* {@link #roles} 字段存储角色编码不带 ROLE_ 前缀
* {@link #getAuthorities()} 运行时补前缀并转为 {@link SimpleGrantedAuthority}
*
* @author Ray.Hao
* @version 3.0.0
*/
@Data
@NoArgsConstructor
public class SysUserDetails implements UserDetails {
public class SecurityUserDetails implements UserDetails {
/**
* 用户ID
@@ -52,42 +53,35 @@ public class SysUserDetails implements UserDetails {
/**
* 数据权限列表
* <p>
* 存储用户所有角色的数据权限范围用于实现多角色权限合并并集策略
*/
private List<RoleDataScope> dataScopes;
/**
* 用户角色权限集合
* 角色编码集合不带 ROLE_ 前缀
*/
private Collection<SimpleGrantedAuthority> authorities;
private Set<String> roles;
/**
* 构造函数根据用户认证信息初始化用户详情对象
*
* @param user 用户认证信息对象 {@link UserAuthInfo}
* 构造函数根据 {@link SecurityUser} 初始化
*/
public SysUserDetails(UserAuthInfo user) {
public SecurityUserDetails(SecurityUser user) {
this.userId = user.getUserId();
this.username = user.getUsername();
this.password = user.getPassword();
this.enabled = ObjectUtil.equal(user.getStatus(), 1);
this.deptId = user.getDeptId();
this.dataScopes = user.getDataScopes();
// 初始化角色权限集合
this.authorities = CollectionUtil.isNotEmpty(user.getRoles())
? user.getRoles().stream()
// 角色名加上前缀 "ROLE_"用于区分角色 (ROLE_ADMIN) 和权限 (user:add)
.map(role -> new SimpleGrantedAuthority(SecurityConstants.ROLE_PREFIX + role))
.collect(Collectors.toSet())
: Collections.emptySet();
this.roles = user.getRoles();
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return this.authorities;
if (CollectionUtil.isEmpty(roles)) {
return Collections.emptySet();
}
return roles.stream()
.map(role -> new SimpleGrantedAuthority(SecurityConstants.ROLE_PREFIX + role))
.collect(Collectors.toSet());
}
@Override
@@ -107,8 +101,6 @@ public class SysUserDetails implements UserDetails {
/**
* 判断是否包含"全部数据"权限
*
* @return 是否有全部数据权限
*/
public boolean hasAllDataScope() {
if (CollectionUtil.isEmpty(dataScopes)) {
@@ -119,9 +111,7 @@ public class SysUserDetails implements UserDetails {
}
/**
* 获取数据权限列表
*
* @return 数据权限列表永不为null
* 获取数据权限列表永不为 null
*/
public List<RoleDataScope> getDataScopes() {
return dataScopes != null ? dataScopes : Collections.emptyList();

View File

@@ -1,49 +0,0 @@
package com.youlai.boot.framework.security.model;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
import java.util.Set;
/**
* 用户会话信息
* <p>
* 存储在Token中的用户会话快照包含用户身份、数据权限和角色权限信息。
* 用于Redis-Token模式下的会话管理支持在线用户查询和会话控制。
*
* @author wangtao
* @since 2025/2/27 10:31
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class UserSession {
/**
* 用户ID
*/
private Long userId;
/**
* 用户名
*/
private String username;
/**
* 部门ID
*/
private Long deptId;
/**
* 数据权限列表
*/
private List<RoleDataScope> dataScopes;
/**
* 角色权限集合
*/
private Set<String> roles;
}

View File

@@ -0,0 +1,22 @@
package com.youlai.boot.framework.security.port;
import java.util.Set;
/**
* 权限查询端口。
* <p>
* 由 system 模块提供适配器实现framework 层通过此接口获取角色权限集合,
* 不直接依赖 system 模块的 {@code RoleMenuService}。
*
* @see com.youlai.boot.system.security.adapter.PermissionAdapter
*/
public interface PermissionPort {
/**
* 根据角色编码集合查询权限标识集合。
*
* @param roleCodes 角色编码集合
* @return 权限标识集合,如 "sys:user:create"
*/
Set<String> getRolePerms(Set<String> roleCodes);
}

View File

@@ -0,0 +1,40 @@
package com.youlai.boot.framework.security.port;
import com.youlai.boot.common.enums.SocialPlatformEnum;
import com.youlai.boot.framework.security.model.SecurityUser;
/**
* 用户认证信息查询端口。
* <p>
* 由 system 模块提供适配器实现framework 层通过此接口获取认证数据,
* 不直接依赖 system 模块的 {@code UserService} / {@code UserSocialService}。
*
* @see com.youlai.boot.system.security.adapter.UserAuthenticationAdapter
*/
public interface UserAuthenticationPort {
/**
* 根据用户名查询认证信息。
*
* @param username 用户名
* @return 认证信息,不存在返回 null
*/
SecurityUser getAuthInfoByUsername(String username);
/**
* 根据手机号查询认证信息。
*
* @param mobile 手机号
* @return 认证信息,不存在返回 null
*/
SecurityUser getAuthInfoByMobile(String mobile);
/**
* 根据第三方平台 openid 查询认证信息。
*
* @param platform 第三方平台
* @param openid openid
* @return 认证信息,未绑定返回 null
*/
SecurityUser getAuthInfoByOpenid(SocialPlatformEnum platform, String openid);
}

View File

@@ -2,8 +2,8 @@ package com.youlai.boot.framework.security.service;
import cn.hutool.core.collection.CollectionUtil;
import cn.hutool.core.util.StrUtil;
import com.youlai.boot.framework.security.port.PermissionPort;
import com.youlai.boot.framework.security.util.SecurityUtils;
import com.youlai.boot.system.service.RoleMenuService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
@@ -12,11 +12,10 @@ import org.springframework.util.PatternMatchUtils;
import java.util.Set;
/**
* Spring Security 权限校验组件
* Spring Security 权限校验组件
* <p>
* 用于 SpEL 表达式权限校验,如:@PreAuthorize("@ss.hasPerm('sys:user:create')")
* <p>
* 权限数据来源:{@link RoleMenuService#getRolePermsByRoleCodes}(带 Redis 缓存)
* 用于 SpEL 表达式{@code @PreAuthorize("@ss.hasPerm('sys:user:create')")}。
* 通过 {@link PermissionPort} 查询角色权限,不直接依赖 system 模块。
*
* @author Ray.Hao
* @since 0.0.1
@@ -26,39 +25,30 @@ import java.util.Set;
@Slf4j
public class PermissionService {
private final RoleMenuService roleMenuService;
private final PermissionPort permissionPort;
/**
* 判断当前登录用户是否拥有操作权限
* <p>
* 支持通配符匹配,如:权限码 "sys:user:*" 可匹配 "sys:user:create"、"sys:user:delete" 等
*
* @param requiredPerm 所需权限
* @return 是否有权限
* 判断当前用户是否拥有操作权限,支持通配符匹配。
*/
public boolean hasPerm(String requiredPerm) {
if (StrUtil.isBlank(requiredPerm)) {
return false;
}
// 超级管理员放行
if (SecurityUtils.isRoot()) {
return true;
}
// 获取当前登录用户的角色编码集合
Set<String> roleCodes = SecurityUtils.getRoles();
if (CollectionUtil.isEmpty(roleCodes)) {
return false;
}
// 获取当前登录用户的所有角色的权限列表(从缓存读取)
Set<String> rolePerms = roleMenuService.getRolePermsByRoleCodes(roleCodes);
Set<String> rolePerms = permissionPort.getRolePerms(roleCodes);
if (CollectionUtil.isEmpty(rolePerms)) {
return false;
}
// 判断权限列表中是否包含所需权限(支持通配符)
boolean hasPermission = rolePerms.stream()
.anyMatch(rolePerm -> PatternMatchUtils.simpleMatch(rolePerm, requiredPerm));

View File

@@ -0,0 +1,41 @@
package com.youlai.boot.framework.security.service;
import com.youlai.boot.framework.security.model.SecurityUser;
import com.youlai.boot.framework.security.model.SecurityUserDetails;
import com.youlai.boot.framework.security.port.UserAuthenticationPort;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
/**
* 系统用户认证 DetailsService。
* <p>
* 通过 {@link UserAuthenticationPort} 获取认证信息,不直接依赖 system 模块。
*
* @author Ray.Hao
* @since 2021/10/19
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class SecurityUserDetailsService implements UserDetailsService {
private final UserAuthenticationPort userAuthPort;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
try {
SecurityUser securityUser = userAuthPort.getAuthInfoByUsername(username);
if (securityUser == null) {
throw new UsernameNotFoundException(username);
}
return new SecurityUserDetails(securityUser);
} catch (Exception e) {
log.error("认证异常:{}", e.getMessage());
throw e;
}
}
}

View File

@@ -1,82 +0,0 @@
package com.youlai.boot.framework.security.service;
import com.youlai.boot.framework.security.model.SysUserDetails;
import com.youlai.boot.framework.security.model.UserAuthInfo;
import com.youlai.boot.system.enums.SocialPlatformEnum;
import com.youlai.boot.system.model.entity.UserSocial;
import com.youlai.boot.system.service.UserSocialService;
import com.youlai.boot.system.service.UserService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
/**
* 系统用户认证 DetailsService
*
* @author Ray.Hao
* @since 2021/10/19
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class SysUserDetailsService implements UserDetailsService {
private final UserService userService;
private final UserSocialService userSocialService;
/**
* 根据用户名获取用户信息
*
* @param username 用户名
* @return 用户信息
* @throws UsernameNotFoundException 用户名未找到异常
*/
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
try {
UserAuthInfo userAuthInfo = userService.getAuthInfoByUsername(username);
if (userAuthInfo == null) {
throw new UsernameNotFoundException(username);
}
return new SysUserDetails(userAuthInfo);
} catch (Exception e) {
// 记录异常日志
log.error("认证异常:{}", e.getMessage());
// 抛出异常
throw e;
}
}
/**
* 根据微信小程序openid查询绑定信息
*
* @param openid 微信小程序openid
* @return 绑定信息未绑定返回null
*/
public UserSocial getWechatMiniBindInfo(String openid) {
return userSocialService.getByPlatformAndOpenid(SocialPlatformEnum.WECHAT_MINI, openid);
}
/**
* 根据微信小程序openid获取用户认证信息
*
* @param openid 微信小程序openid
* @return 用户认证信息用户不存在返回null
*/
public UserAuthInfo getAuthInfoByWechatOpenid(String openid) {
return userSocialService.getAuthInfoByOpenid(SocialPlatformEnum.WECHAT_MINI, openid);
}
/**
* 更新微信小程序session_key
*
* @param bindId 绑定记录ID
* @param sessionKey session_key
*/
public void updateWechatSessionKey(Long bindId, String sessionKey) {
userSocialService.updateSessionKey(bindId, sessionKey);
}
}

View File

@@ -17,30 +17,25 @@ import com.youlai.boot.framework.security.config.SecurityProperties;
import com.youlai.boot.framework.security.exception.TokenInvalidException;
import com.youlai.boot.framework.security.model.AuthenticationToken;
import com.youlai.boot.framework.security.model.RoleDataScope;
import com.youlai.boot.framework.security.model.SecurityUserDetails;
import org.apache.commons.lang3.StringUtils;
import com.youlai.boot.framework.security.model.SysUserDetails;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.stereotype.Service;
import java.util.*;
import java.util.concurrent.TimeUnit; // Import TimeUnit
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
/**
* JWT Token 管理器
* JWT Token 管理器
* <p>
* 实现基于JWT的无状态认证支持
* <ul>
* <li>Access Token + Refresh Token 双令牌机制</li>
* <li>Token 撤销jti黑名单</li>
* <li>用户级会话失效tokenVersion</li>
* <li>多角色数据权限存储</li>
* </ul>
* 基于 JWT 的无状态认证,支持 Access + Refresh 双令牌、Token 撤销jti 黑名单)、
* 用户级会话失效tokenVersion、多角色数据权限存储。
* <p>
* JWT claims 中存储角色编码(不带 ROLE_ 前缀),解析后由 {@link SecurityUserDetails#getAuthorities()} 运行时补前缀。
*
* @author Ray.Hao
* @since 2024/11/15
@@ -59,12 +54,6 @@ public class JwtTokenManager implements TokenManager {
this.secretKey = securityProperties.getSession().getJwt().getSecretKey().getBytes();
}
/**
* 生成令牌
*
* @param authentication 认证信息
* @return 令牌响应对象
*/
@Override
public AuthenticationToken generateToken(Authentication authentication) {
int accessTokenTimeToLive = securityProperties.getSession().getAccessTokenTimeToLive();
@@ -81,22 +70,15 @@ public class JwtTokenManager implements TokenManager {
.build();
}
/**
* 解析令牌
*
* @param token JWT Token
* @return Authentication 对象
*/
@Override
public Authentication parseToken(String token) {
JWT jwt = JWTUtil.parseToken(token);
JSONObject payloads = jwt.getPayloads();
SysUserDetails userDetails = new SysUserDetails();
userDetails.setUserId(payloads.getLong(JwtClaimConstants.USER_ID)); // 用户ID
userDetails.setDeptId(payloads.getLong(JwtClaimConstants.DEPT_ID)); // 部门ID
SecurityUserDetails userDetails = new SecurityUserDetails();
userDetails.setUserId(payloads.getLong(JwtClaimConstants.USER_ID));
userDetails.setDeptId(payloads.getLong(JwtClaimConstants.DEPT_ID));
// 解析数据权限列表
// 数据权限
JSONArray dataScopesArray = payloads.getJSONArray(JwtClaimConstants.DATA_SCOPES);
if (dataScopesArray != null && !dataScopesArray.isEmpty()) {
List<RoleDataScope> dataScopes = dataScopesArray.stream()
@@ -115,88 +97,60 @@ public class JwtTokenManager implements TokenManager {
userDetails.setDataScopes(dataScopes);
}
userDetails.setUsername(payloads.getStr(JWTPayload.SUBJECT)); // 用户名
// 角色集合
Set<SimpleGrantedAuthority> authorities = payloads.getJSONArray(JwtClaimConstants.AUTHORITIES)
.stream()
.map(authority -> new SimpleGrantedAuthority(Convert.toStr(authority)))
.collect(Collectors.toSet());
userDetails.setUsername(payloads.getStr(JWTPayload.SUBJECT));
return new UsernamePasswordAuthenticationToken(userDetails, "", authorities);
// 角色编码(不带 ROLE_ 前缀)
JSONArray rolesArray = payloads.getJSONArray(JwtClaimConstants.ROLES);
if (rolesArray != null && !rolesArray.isEmpty()) {
Set<String> roles = rolesArray.stream()
.map(Convert::toStr)
.collect(Collectors.toSet());
userDetails.setRoles(roles);
}
return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
}
/**
* 校验令牌
*
* @param token JWT Token
* @return 是否有效
*/
@Override
public boolean validateToken(String token) {
return validateToken(token, false);
}
/**
* 校验刷新令牌
*
* @param refreshToken JWT Token
* @return 验证结果
*/
@Override
public boolean validateRefreshToken(String refreshToken) {
return validateToken(refreshToken, true);
}
/**
* 校验令牌
* <p>
* 校验流程(按顺序执行):
* <ol>
* <li>签名验证 + 过期时间检查</li>
* <li>刷新令牌类型校验(仅刷新场景)</li>
* <li>tokenVersion 校验(用户级会话失效)</li>
* <li>jti 黑名单校验单Token撤销</li>
* </ol>
*
* @param token JWT Token
* @param validateRefreshToken 是否校验刷新令牌类型
* @return 是否有效
*/
private boolean validateToken(String token, boolean validateRefreshToken) {
JWT jwt = JWTUtil.parseToken(token);
// 检查 Token 是否有效(验签 + 是否过期)
boolean isValid = jwt.setKey(secretKey).validate(0);
if (isValid) {
JSONObject payloads = jwt.getPayloads();
// 1. 校验刷新令牌类型(仅在校验刷新令牌场景启用)
// 刷新令牌类型校验
String jti = payloads.getStr(JWTPayload.JWT_ID);
if (validateRefreshToken) {
//刷新token需要校验token类别
boolean isRefreshToken = payloads.getBool(JwtClaimConstants.TOKEN_TYPE);
if (!isRefreshToken) {
return false;
}
}
// 2. 校验 tokenVersion用于按用户维度失效历史 Token
// 场景示例:用户修改密码、被管理员强制下线、手动"踢所有端"后,递增 tokenVersion
// 之前签发的 Token 因版本号不匹配而失效
// tokenVersion 校验(用户维度 Token 失效)
Long userId = payloads.getLong(JwtClaimConstants.USER_ID);
if (userId != null) {
Integer tokenVersion = payloads.getInt(JwtClaimConstants.TOKEN_VERSION);
String versionKey = StrUtil.format(RedisConstants.Auth.USER_TOKEN_VERSION, userId);
Object currentVersionObj = redisTemplate.opsForValue().get(versionKey);
int currentVersion = currentVersionObj != null ? Convert.toInt(currentVersionObj) : 0;
// 版本号不匹配则 Token 无效(新签发的 Token 版本号必须 >= Redis 中的版本号)
if (tokenVersion == null || tokenVersion < currentVersion) {
return false;
}
}
// 3. 判断 Token 是否已被撤销(单端退出/会话注销)
// 场景示例:单点退出登录、后台手动注销某个会话、封禁账号后立即阻断当前 Token 等
// jti 黑名单校验
if (isTokenRevoked(jti)) {
return false;
}
@@ -204,17 +158,11 @@ public class JwtTokenManager implements TokenManager {
return isValid;
}
/**
* 将令牌加入黑名单
*
* @param token JWT Token
*/
@Override
public void invalidateToken(String token) {
if (StringUtils.isBlank(token)) {
return;
}
if (token.startsWith(SecurityConstants.BEARER_TOKEN_PREFIX)) {
token = token.substring(SecurityConstants.BEARER_TOKEN_PREFIX.length());
}
@@ -225,77 +173,15 @@ public class JwtTokenManager implements TokenManager {
revokeTokenByJti(jti, expirationAt);
}
/**
* 检查Token是否已被撤销
*
* @param jti Token唯一标识
* @return true-已撤销false-未撤销
*/
private boolean isTokenRevoked(String jti) {
if (StringUtils.isBlank(jti)) {
return false;
}
return Boolean.TRUE.equals(redisTemplate.hasKey(StrUtil.format(RedisConstants.Auth.REVOKED_JTI, jti)));
}
/**
* 将Token加入撤销黑名单
* <p>
* 黑名单有效期与Token剩余有效期一致避免永久存储
*
* @param jti Token唯一标识
* @param expirationAt Token过期时间戳
*/
private void revokeTokenByJti(String jti, Integer expirationAt) {
if (StringUtils.isBlank(jti)) {
return;
}
String revokedJtiKey = StrUtil.format(RedisConstants.Auth.REVOKED_JTI, jti);
if (expirationAt != null) {
int currentTimeSeconds = Convert.toInt(System.currentTimeMillis() / 1000);
if (expirationAt < currentTimeSeconds) {
return;
}
int expirationIn = expirationAt - currentTimeSeconds;
redisTemplate.opsForValue().set(revokedJtiKey, Boolean.TRUE, expirationIn, TimeUnit.SECONDS);
} else {
redisTemplate.opsForValue().set(revokedJtiKey, Boolean.TRUE);
}
}
/**
* 失效指定用户的所有会话
* <p>
* 通过递增用户 tokenVersion使该用户之前签发的所有 Token 因版本号不匹配而失效。
* <p>
* 适用场景:
* <ul>
* <li>用户修改密码</li>
* <li>管理员强制下线用户</li>
* <li>用户主动踢出所有设备</li>
* <li>用户被禁用</li>
* </ul>
*
* @param userId 用户ID
*/
@Override
public void invalidateUserSessions(Long userId) {
if (userId == null) {
return;
}
String versionKey = StrUtil.format(RedisConstants.Auth.USER_TOKEN_VERSION, userId);
// 递增版本号,无需设置 TTL版本号永久有效避免 TTL 过期导致的安全问题)
redisTemplate.opsForValue().increment(versionKey);
}
/**
* 刷新令牌
*
* @param refreshToken 刷新令牌
* @return 令牌响应对象
*/
@Override
public AuthenticationToken refreshToken(String refreshToken) {
boolean isValid = validateRefreshToken(refreshToken);
@@ -313,45 +199,19 @@ public class JwtTokenManager implements TokenManager {
.build();
}
/**
* 生成 JWT Token
*
* @param authentication 认证信息
* @param ttl 过期时间(秒),-1表示永不过期
* @return JWT Token字符串
*/
// ======================== private ========================
private String generateToken(Authentication authentication, int ttl) {
return generateToken(authentication, ttl, false);
}
/**
* 生成 JWT Token
* <p>
* Payload包含
* <ul>
* <li>userId - 用户ID</li>
* <li>deptId - 部门ID</li>
* <li>dataScopes - 数据权限列表</li>
* <li>authorities - 角色权限集合</li>
* <li>tokenType - 是否为刷新令牌</li>
* <li>tokenVersion - Token版本号用于会话失效控制</li>
* <li>iat/exp - 签发/过期时间</li>
* <li>jti - Token唯一标识用于撤销</li>
* </ul>
*
* @param authentication 认证信息
* @param ttl 过期时间(秒)
* @param isRefreshToken 是否为刷新令牌
* @return JWT Token字符串
*/
private String generateToken(Authentication authentication, int ttl, boolean isRefreshToken) {
SysUserDetails userDetails = (SysUserDetails) authentication.getPrincipal();
SecurityUserDetails userDetails = (SecurityUserDetails) authentication.getPrincipal();
Map<String, Object> payload = new HashMap<>();
payload.put(JwtClaimConstants.USER_ID, userDetails.getUserId()); // 用户ID
payload.put(JwtClaimConstants.DEPT_ID, userDetails.getDeptId()); // 部门ID
payload.put(JwtClaimConstants.USER_ID, userDetails.getUserId());
payload.put(JwtClaimConstants.DEPT_ID, userDetails.getDeptId());
// 存储数据权限列表
// 数据权限
List<RoleDataScope> dataScopes = userDetails.getDataScopes();
if (dataScopes != null && !dataScopes.isEmpty()) {
List<Map<String, Object>> scopesList = dataScopes.stream()
@@ -366,13 +226,12 @@ public class JwtTokenManager implements TokenManager {
payload.put(JwtClaimConstants.DATA_SCOPES, scopesList);
}
// claims 中添加角色信息
Set<String> roles = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toSet());
payload.put(JwtClaimConstants.AUTHORITIES, roles);
// 角色编码(不带 ROLE_ 前缀)
Set<String> roles = userDetails.getRoles();
if (roles != null && !roles.isEmpty()) {
payload.put(JwtClaimConstants.ROLES, roles);
}
// 获取当前用户的 Token 版本号,用于会话失效控制
Long userId = userDetails.getUserId();
int tokenVersion = 0;
if (userId != null) {
@@ -384,12 +243,8 @@ public class JwtTokenManager implements TokenManager {
Date now = new Date();
payload.put(JWTPayload.ISSUED_AT, now);
payload.put(JwtClaimConstants.TOKEN_TYPE, false);
if (isRefreshToken) {
payload.put(JwtClaimConstants.TOKEN_TYPE, true);
}
payload.put(JwtClaimConstants.TOKEN_TYPE, isRefreshToken);
// 设置过期时间 -1 表示永不过期
if (ttl != -1) {
Date expiresAt = DateUtil.offsetSecond(now, ttl);
payload.put(JWTPayload.EXPIRES_AT, expiresAt);
@@ -400,4 +255,27 @@ public class JwtTokenManager implements TokenManager {
return JWTUtil.createToken(payload, secretKey);
}
private boolean isTokenRevoked(String jti) {
if (StringUtils.isBlank(jti)) {
return false;
}
return Boolean.TRUE.equals(redisTemplate.hasKey(StrUtil.format(RedisConstants.Auth.REVOKED_JTI, jti)));
}
private void revokeTokenByJti(String jti, Integer expirationAt) {
if (StringUtils.isBlank(jti)) {
return;
}
String revokedJtiKey = StrUtil.format(RedisConstants.Auth.REVOKED_JTI, jti);
if (expirationAt != null) {
int currentTimeSeconds = Convert.toInt(System.currentTimeMillis() / 1000);
if (expirationAt < currentTimeSeconds) {
return;
}
int expirationIn = expirationAt - currentTimeSeconds;
redisTemplate.opsForValue().set(revokedJtiKey, Boolean.TRUE, expirationIn, TimeUnit.SECONDS);
} else {
redisTemplate.opsForValue().set(revokedJtiKey, Boolean.TRUE);
}
}
}

View File

@@ -1,6 +1,5 @@
package com.youlai.boot.framework.security.token;
import cn.hutool.core.collection.CollectionUtil;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.StrUtil;
import com.youlai.boot.common.constant.RedisConstants;
@@ -9,35 +8,25 @@ import com.youlai.boot.common.result.ResultCode;
import com.youlai.boot.framework.security.config.SecurityProperties;
import com.youlai.boot.framework.security.exception.TokenInvalidException;
import com.youlai.boot.framework.security.model.AuthenticationToken;
import com.youlai.boot.framework.security.model.UserSession;
import com.youlai.boot.framework.security.model.SysUserDetails;
import com.youlai.boot.framework.security.model.SecurityUserDetails;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.stereotype.Service;
import tools.jackson.databind.json.JsonMapper;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
/**
* Redis Token 管理器
* Redis Token 管理器
* <p>
* 实现基于Redis的有状态认证,支持:
* <ul>
* <li>Access Token + Refresh Token 双令牌机制</li>
* <li>单设备/多设备登录控制</li>
* <li>用户级会话失效</li>
* <li>在线用户管理</li>
* </ul>
* 基于 Redis 的有状态 Token 认证,支持 Access + Refresh 双令牌、
* 单/多设备登录控制、用户级会话失效。
* <p>
* 与JWT模式相比Redis模式支持主动踢人、在线用户查询等功能
* 直接存取 {@link SecurityUserDetails}password 置 null无需 UserSession 中间层。
*
* @author Ray.Hao
* @since 2024/11/15
@@ -58,33 +47,16 @@ public class RedisTokenManager implements TokenManager {
this.jsonMapper = jsonMapper;
}
/**
* 生成 Token
*
* @param authentication 用户认证信息
* @return 生成的 AuthenticationToken 对象
*/
@Override
public AuthenticationToken generateToken(Authentication authentication) {
SysUserDetails user = (SysUserDetails) authentication.getPrincipal();
SecurityUserDetails user = (SecurityUserDetails) authentication.getPrincipal();
String accessToken = IdUtil.fastSimpleUUID();
String refreshToken = IdUtil.fastSimpleUUID();
// 构建用户会话信息
UserSession userSession = new UserSession(
user.getUserId(),
user.getUsername(),
user.getDeptId(),
user.getDataScopes(),
user.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toSet())
);
// 构建会话快照(不存密码)
SecurityUserDetails sessionUser = buildSessionUser(user);
// 存储访问令牌、刷新令牌和刷新令牌映射
storeTokensInRedis(accessToken, refreshToken, userSession);
// 单设备登录控制
storeTokensInRedis(accessToken, refreshToken, sessionUser);
handleSingleDeviceLogin(user.getUserId(), accessToken);
return AuthenticationToken.builder()
@@ -94,61 +66,24 @@ public class RedisTokenManager implements TokenManager {
.build();
}
/**
* 根据 token 解析用户信息
*
* @param token Redis Token
* @return 构建的 Authentication 对象
*/
@Override
public Authentication parseToken(String token) {
Object raw = redisTemplate.opsForValue().get(formatTokenKey(token));
if (raw == null) return null;
UserSession userSession = jsonMapper.convertValue(raw, UserSession.class);
// 构建用户权限集合
Set<SimpleGrantedAuthority> authorities = null;
Set<String> roles = userSession.getRoles();
if (CollectionUtil.isNotEmpty(roles)) {
authorities = roles.stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toSet());
}
// 构建用户详情对象
SysUserDetails userDetails = buildUserDetails(userSession, authorities);
return new UsernamePasswordAuthenticationToken(userDetails, null, authorities);
SecurityUserDetails userDetails = jsonMapper.convertValue(raw, SecurityUserDetails.class);
return new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
}
/**
* 校验 Token 是否有效
*
* @param token 访问令牌
* @return 是否有效
*/
@Override
public boolean validateToken(String token) {
return redisTemplate.hasKey(formatTokenKey(token));
}
/**
* 校验 RefreshToken 是否有效
*
* @param refreshToken 访问令牌
* @return 是否有效
*/
@Override
public boolean validateRefreshToken(String refreshToken) {
return redisTemplate.hasKey(formatRefreshTokenKey(refreshToken));
}
/**
* 刷新令牌
*
* @param refreshToken 刷新令牌
* @return 新生成的 AuthenticationToken 对象
*/
@Override
public AuthenticationToken refreshToken(String refreshToken) {
Object raw = redisTemplate.opsForValue()
@@ -156,16 +91,16 @@ public class RedisTokenManager implements TokenManager {
if (raw == null) {
throw new TokenInvalidException(ResultCode.REFRESH_TOKEN_INVALID);
}
UserSession userSession = jsonMapper.convertValue(raw, UserSession.class);
Object oldAccessTokenValue = redisTemplate.opsForValue().get(StrUtil.format(RedisConstants.Auth.USER_ACCESS_TOKEN, userSession.getUserId()));
// 删除旧的访问令牌记录
SecurityUserDetails sessionUser = jsonMapper.convertValue(raw, SecurityUserDetails.class);
Object oldAccessTokenValue = redisTemplate.opsForValue()
.get(StrUtil.format(RedisConstants.Auth.USER_ACCESS_TOKEN, sessionUser.getUserId()));
Optional.of(oldAccessTokenValue)
.map(String.class::cast)
.ifPresent(oldAccessToken -> redisTemplate.delete(formatTokenKey(oldAccessToken)));
// 生成新访问令牌并存储
String newAccessToken = IdUtil.fastSimpleUUID();
storeAccessToken(newAccessToken, userSession);
storeAccessToken(newAccessToken, sessionUser);
int accessTtl = securityProperties.getSession().getAccessTokenTimeToLive();
return AuthenticationToken.builder()
@@ -175,164 +110,91 @@ public class RedisTokenManager implements TokenManager {
.build();
}
/**
* Make access token invalid
* <p>
* Only deletes the current token, not all sessions for the user.
* This ensures single-device logout doesn't affect other devices when allowMultiLogin=true.
*
* @param token Access token
*/
@Override
public void invalidateToken(String token) {
String cleanToken = cleanBearerPrefix(token);
// Only delete the current token, not all user sessions
redisTemplate.delete(formatTokenKey(cleanToken));
}
/**
* 使指定用户的所有会话失效
* <p>
* 适用场景:用户修改密码、管理员强制下线、账号封禁等
*
* @param userId 用户ID
*/
@Override
public void invalidateUserSessions(Long userId) {
if (userId == null) {
return;
}
// 1. 删除访问令牌相关
String userAccessKey = StrUtil.format(RedisConstants.Auth.USER_ACCESS_TOKEN, userId);
Object accessTokenValue = redisTemplate.opsForValue().get(userAccessKey);
if (accessTokenValue instanceof String accessToken) {
redisTemplate.delete(formatTokenKey(accessToken));
}
// 无论是否存在访问令牌映射,都尝试删除 userAccessKey
redisTemplate.delete(userAccessKey);
// 2. 删除刷新令牌相关
String userRefreshKey = StrUtil.format(RedisConstants.Auth.USER_REFRESH_TOKEN, userId);
Object refreshTokenValue = redisTemplate.opsForValue().get(userRefreshKey);
if (refreshTokenValue instanceof String refreshToken) {
redisTemplate.delete(StrUtil.format(RedisConstants.Auth.REFRESH_TOKEN_USER, refreshToken));
}
// 同样清理 userRefreshKey 本身
redisTemplate.delete(userRefreshKey);
}
/**
* 将访问令牌和刷新令牌存储至 Redis
*
* @param accessToken 访问令牌
* @param refreshToken 刷新令牌
* @param userSession 用户会话信息
*/
private void storeTokensInRedis(String accessToken, String refreshToken, UserSession userSession) {
// 访问令牌 -> 用户信息
setRedisValue(formatTokenKey(accessToken), userSession, securityProperties.getSession().getAccessTokenTimeToLive());
// ======================== private ========================
// 刷新令牌 -> 用户信息
String refreshTokenKey = StrUtil.format(RedisConstants.Auth.REFRESH_TOKEN_USER, refreshToken);
setRedisValue(refreshTokenKey, userSession, securityProperties.getSession().getRefreshTokenTimeToLive());
// 用户ID -> 刷新令牌
setRedisValue(StrUtil.format(RedisConstants.Auth.USER_REFRESH_TOKEN, userSession.getUserId()),
refreshToken,
securityProperties.getSession().getRefreshTokenTimeToLive());
private SecurityUserDetails buildSessionUser(SecurityUserDetails user) {
SecurityUserDetails sessionUser = new SecurityUserDetails();
sessionUser.setUserId(user.getUserId());
sessionUser.setUsername(user.getUsername());
sessionUser.setDeptId(user.getDeptId());
sessionUser.setDataScopes(user.getDataScopes());
sessionUser.setRoles(user.getRoles());
sessionUser.setEnabled(user.isEnabled());
sessionUser.setPassword(null);
return sessionUser;
}
private void storeTokensInRedis(String accessToken, String refreshToken, SecurityUserDetails sessionUser) {
setRedisValue(formatTokenKey(accessToken), sessionUser,
securityProperties.getSession().getAccessTokenTimeToLive());
String refreshTokenKey = StrUtil.format(RedisConstants.Auth.REFRESH_TOKEN_USER, refreshToken);
setRedisValue(refreshTokenKey, sessionUser,
securityProperties.getSession().getRefreshTokenTimeToLive());
setRedisValue(StrUtil.format(RedisConstants.Auth.USER_REFRESH_TOKEN, sessionUser.getUserId()),
refreshToken, securityProperties.getSession().getRefreshTokenTimeToLive());
}
/**
* 处理单设备登录控制
* <p>
* 当配置不允许多设备登录时新登录会使旧Token失效
*
* @param userId 用户ID
* @param accessToken 新生成的访问令牌
*/
private void handleSingleDeviceLogin(Long userId, String accessToken) {
Boolean allowMultiLogin = securityProperties.getSession().getRedisToken().getAllowMultiLogin();
String userAccessKey = StrUtil.format(RedisConstants.Auth.USER_ACCESS_TOKEN, userId);
// 单设备登录控制,删除旧的访问令牌
if (!allowMultiLogin) {
Object oldAccessTokenValue = redisTemplate.opsForValue().get(userAccessKey);
if (oldAccessTokenValue instanceof String oldAccessToken) {
redisTemplate.delete(formatTokenKey(oldAccessToken));
}
}
// 存储访问令牌映射用户ID -> 访问令牌),用于单设备登录控制删除旧的访问令牌和刷新令牌时删除旧令牌
setRedisValue(userAccessKey, accessToken, securityProperties.getSession().getAccessTokenTimeToLive());
}
/**
* 存储新的访问令牌
*
* @param newAccessToken 新访问令牌
* @param userSession 用户会话信息
*/
private void storeAccessToken(String newAccessToken, UserSession userSession) {
setRedisValue(StrUtil.format(RedisConstants.Auth.ACCESS_TOKEN_USER, newAccessToken), userSession, securityProperties.getSession().getAccessTokenTimeToLive());
String userAccessKey = StrUtil.format(RedisConstants.Auth.USER_ACCESS_TOKEN, userSession.getUserId());
private void storeAccessToken(String newAccessToken, SecurityUserDetails sessionUser) {
setRedisValue(StrUtil.format(RedisConstants.Auth.ACCESS_TOKEN_USER, newAccessToken), sessionUser,
securityProperties.getSession().getAccessTokenTimeToLive());
String userAccessKey = StrUtil.format(RedisConstants.Auth.USER_ACCESS_TOKEN, sessionUser.getUserId());
setRedisValue(userAccessKey, newAccessToken, securityProperties.getSession().getAccessTokenTimeToLive());
}
/**
* 构建用户详情对象
*
* @param userSession 用户会话信息
* @param authorities 权限集合
* @return SysUserDetails 用户详情
*/
private SysUserDetails buildUserDetails(UserSession userSession, Set<SimpleGrantedAuthority> authorities) {
SysUserDetails userDetails = new SysUserDetails();
userDetails.setUserId(userSession.getUserId());
userDetails.setUsername(userSession.getUsername());
userDetails.setDeptId(userSession.getDeptId());
userDetails.setDataScopes(userSession.getDataScopes());
userDetails.setAuthorities(authorities);
return userDetails;
}
/**
* 格式化访问令牌的 Redis 键
*
* @param token 访问令牌
* @return 格式化后的 Redis 键
*/
private String formatTokenKey(String token) {
return StrUtil.format(RedisConstants.Auth.ACCESS_TOKEN_USER, token);
}
/**
* 格式化刷新令牌的 Redis 键
*
* @param refreshToken 访问令牌
* @return 格式化后的 Redis 键
*/
private String formatRefreshTokenKey(String refreshToken) {
return StrUtil.format(RedisConstants.Auth.REFRESH_TOKEN_USER, refreshToken);
}
/**
* 将值存储到 Redis
*
* @param key 键
* @param value 值
* @param ttl 过期时间(秒),-1表示永不过期
*/
private void setRedisValue(String key, Object value, int ttl) {
if (ttl != -1) {
redisTemplate.opsForValue().set(key, value, ttl, TimeUnit.SECONDS);
} else {
redisTemplate.opsForValue().set(key, value); // ttl=-1时永不过期
redisTemplate.opsForValue().set(key, value);
}
}
/**
* 清理 Bearer 前缀
*/
private String cleanBearerPrefix(String token) {
if (token.startsWith(SecurityConstants.BEARER_TOKEN_PREFIX)) {
return token.substring(SecurityConstants.BEARER_TOKEN_PREFIX.length()).trim();

View File

@@ -1,21 +1,16 @@
package com.youlai.boot.framework.security.util;
import cn.hutool.core.collection.CollectionUtil;
import cn.hutool.core.util.StrUtil;
import com.youlai.boot.common.constant.SecurityConstants;
import com.youlai.boot.common.constant.SystemConstants;
import com.youlai.boot.framework.security.model.RoleDataScope;
import com.youlai.boot.framework.security.model.SysUserDetails;
import com.youlai.boot.framework.security.model.SecurityUserDetails;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpHeaders;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import java.util.*;
import java.util.stream.Collectors;
/**
* Spring Security 工具类
@@ -26,101 +21,73 @@ import java.util.stream.Collectors;
public class SecurityUtils {
/**
* 获取当前登录人信息
*
* @return Optional<SysUserDetails>
* 获取当前登录用户
*/
public static Optional<SysUserDetails> getUser() {
public static Optional<SecurityUserDetails> getUser() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null) {
Object principal = authentication.getPrincipal();
if (principal instanceof SysUserDetails) {
return Optional.of((SysUserDetails) principal);
if (principal instanceof SecurityUserDetails) {
return Optional.of((SecurityUserDetails) principal);
}
}
return Optional.empty();
}
/**
* 获取用户ID
*
* @return Long
*/
public static Long getUserId() {
return getUser().map(SysUserDetails::getUserId).orElse(null);
return getUser().map(SecurityUserDetails::getUserId).orElse(null);
}
/**
* 获取用户账号
*
* @return String 用户账号
*/
public static String getUsername() {
return getUser().map(SysUserDetails::getUsername).orElse(null);
return getUser().map(SecurityUserDetails::getUsername).orElse(null);
}
/**
* 获取部门ID
*
* @return Long
*/
public static Long getDeptId() {
return getUser().map(SysUserDetails::getDeptId).orElse(null);
return getUser().map(SecurityUserDetails::getDeptId).orElse(null);
}
/**
* 获取数据权限列表
*
* @return 数据权限列表
*/
public static List<RoleDataScope> getDataScopes() {
return getUser().map(SysUserDetails::getDataScopes).orElse(List.of());
return getUser().map(SecurityUserDetails::getDataScopes).orElse(List.of());
}
/**
* 获取角色集合
*
* @return 角色集合
* 获取角色编码集合,不带 ROLE_ 前缀。
* <p>
* 直接从 {@link SecurityUserDetails#getRoles()} 取值,不做 stripping 和过滤。
*/
public static Set<String> getRoles() {
return Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication())
.map(Authentication::getAuthorities)
.filter(CollectionUtil::isNotEmpty)
.stream()
.flatMap(Collection::stream)
.map(GrantedAuthority::getAuthority)
// 筛选角色,authorities 中的角色都是以 ROLE_ 开头
.filter(authority -> authority.startsWith(SecurityConstants.ROLE_PREFIX))
.map(authority -> StrUtil.removePrefix(authority, SecurityConstants.ROLE_PREFIX))
.collect(Collectors.toSet());
return getUser().map(SecurityUserDetails::getRoles).orElse(Set.of());
}
/**
* 是否超级管理员
* <p>
* 超级管理员忽视任何权限判断
*/
public static boolean isRoot() {
Set<String> roles = getRoles();
return roles.contains(SystemConstants.ROOT_ROLE_CODE);
return getRoles().contains(SystemConstants.ROOT_ROLE_CODE);
}
/**
* 获取请求中的 Token
*
* @return Token 字符串
* 从请求头获取 Token
*/
public static String getAccessToken() {
ServletRequestAttributes servletRequestAttributes = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes());
if(Objects.isNull(servletRequestAttributes)) {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes == null) {
return null;
}
HttpServletRequest request = servletRequestAttributes.getRequest();
HttpServletRequest request = attributes.getRequest();
return request.getHeader(HttpHeaders.AUTHORIZATION);
}
}

View File

@@ -1,7 +1,7 @@
package com.youlai.boot.message.controller;
import com.youlai.boot.common.result.Result;
import com.youlai.boot.framework.security.model.SysUserDetails;
import com.youlai.boot.framework.security.model.SecurityUserDetails;
import com.youlai.boot.framework.security.util.SecurityUtils;
import com.youlai.boot.message.service.SseService;
import io.swagger.v3.oas.annotations.Operation;
@@ -29,7 +29,7 @@ public class SseController {
@Operation(summary = "建立SSE连接")
@GetMapping(value = "/connect", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter connect() {
SysUserDetails user = SecurityUtils.getUser().orElse(null);
SecurityUserDetails user = SecurityUtils.getUser().orElse(null);
if (user == null) {
log.warn("SSE连接失败未获取到当前用户");
return null;

View File

@@ -6,7 +6,7 @@ import com.youlai.boot.system.model.entity.SysUser;
import com.youlai.boot.system.model.query.UserQuery;
import com.youlai.boot.system.model.form.UserForm;
import com.youlai.boot.common.annotation.DataPermission;
import com.youlai.boot.framework.security.model.UserAuthInfo;
import com.youlai.boot.framework.security.model.SecurityUser;
import com.youlai.boot.system.model.vo.UserExportVO;
import com.youlai.boot.system.model.vo.UserPageVO;
import com.youlai.boot.system.model.vo.UserProfileVO;
@@ -48,9 +48,9 @@ public interface UserMapper extends BaseMapper<SysUser> {
* @param username 用户名
* @return 认证信息
*/
UserAuthInfo getAuthInfoByUsername(String username);
SecurityUser getAuthInfoByUsername(String username);
default UserAuthInfo getAuthCredentialsByUsername(String username) {
default SecurityUser getAuthCredentialsByUsername(String username) {
return getAuthInfoByUsername(username);
}
@@ -60,9 +60,9 @@ public interface UserMapper extends BaseMapper<SysUser> {
* @param mobile 手机号
* @return 认证信息
*/
UserAuthInfo getAuthInfoByMobile(String mobile);
SecurityUser getAuthInfoByMobile(String mobile);
default UserAuthInfo getAuthCredentialsByMobile(String mobile) {
default SecurityUser getAuthCredentialsByMobile(String mobile) {
return getAuthInfoByMobile(mobile);
}

View File

@@ -1,7 +1,7 @@
package com.youlai.boot.system.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.youlai.boot.framework.security.model.UserAuthInfo;
import com.youlai.boot.framework.security.model.SecurityUser;
import com.youlai.boot.system.model.entity.UserSocial;
import org.apache.ibatis.annotations.Mapper;
@@ -17,6 +17,6 @@ public interface UserSocialMapper extends BaseMapper<UserSocial> {
* @param userId 用户ID
* @return 认证信息
*/
UserAuthInfo getAuthInfoByUserId(Long userId);
SecurityUser getAuthInfoByUserId(Long userId);
}

View File

@@ -2,7 +2,7 @@ package com.youlai.boot.system.model.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.youlai.boot.common.base.BaseEntity;
import com.youlai.boot.system.enums.SocialPlatformEnum;
import com.youlai.boot.common.enums.SocialPlatformEnum;
import lombok.Getter;
import lombok.Setter;

View File

@@ -0,0 +1,27 @@
package com.youlai.boot.system.security.adapter;
import com.youlai.boot.framework.security.port.PermissionPort;
import com.youlai.boot.system.service.RoleMenuService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.util.Set;
/**
* 权限查询适配器。
* <p>
* 实现 framework 层的 {@link PermissionPort},委托 {@link RoleMenuService} 查询权限集合。
*
* @see PermissionPort
*/
@Component
@RequiredArgsConstructor
public class PermissionAdapter implements PermissionPort {
private final RoleMenuService roleMenuService;
@Override
public Set<String> getRolePerms(Set<String> roleCodes) {
return roleMenuService.getRolePermsByRoleCodes(roleCodes);
}
}

View File

@@ -0,0 +1,39 @@
package com.youlai.boot.system.security.adapter;
import com.youlai.boot.common.enums.SocialPlatformEnum;
import com.youlai.boot.framework.security.model.SecurityUser;
import com.youlai.boot.framework.security.port.UserAuthenticationPort;
import com.youlai.boot.system.service.UserService;
import com.youlai.boot.system.service.UserSocialService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
/**
* 用户认证信息查询适配器。
* <p>
* 实现 framework 层的 {@link UserAuthenticationPort},委托 system 层服务完成查询。
*
* @see UserAuthenticationPort
*/
@Component
@RequiredArgsConstructor
public class UserAuthenticationAdapter implements UserAuthenticationPort {
private final UserService userService;
private final UserSocialService userSocialService;
@Override
public SecurityUser getAuthInfoByUsername(String username) {
return userService.getAuthInfoByUsername(username);
}
@Override
public SecurityUser getAuthInfoByMobile(String mobile) {
return userService.getAuthInfoByMobile(mobile);
}
@Override
public SecurityUser getAuthInfoByOpenid(SocialPlatformEnum platform, String openid) {
return userSocialService.getAuthInfoByOpenid(platform, openid);
}
}

View File

@@ -3,7 +3,7 @@ package com.youlai.boot.system.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.service.IService;
import com.youlai.boot.common.model.Option;
import com.youlai.boot.framework.security.model.UserAuthInfo;
import com.youlai.boot.framework.security.model.SecurityUser;
import com.youlai.boot.system.model.vo.CurrentUserVO;
import com.youlai.boot.system.model.vo.UserExportVO;
import com.youlai.boot.system.model.entity.SysUser;
@@ -78,11 +78,11 @@ public interface UserService extends IService<SysUser> {
* 根据用户名获取认证信息
*
* @param username 用户名
* @return {@link UserAuthInfo}
* @return {@link SecurityUser}
*/
UserAuthInfo getAuthInfoByUsername(String username);
SecurityUser getAuthInfoByUsername(String username);
default UserAuthInfo getAuthCredentialsByUsername(String username) {
default SecurityUser getAuthCredentialsByUsername(String username) {
return getAuthInfoByUsername(username);
}
@@ -194,11 +194,11 @@ public interface UserService extends IService<SysUser> {
* 根据手机号获取用户认证信息
*
* @param mobile 手机号
* @return {@link UserAuthInfo}
* @return {@link SecurityUser}
*/
UserAuthInfo getAuthInfoByMobile(String mobile);
SecurityUser getAuthInfoByMobile(String mobile);
default UserAuthInfo getAuthCredentialsByMobile(String mobile) {
default SecurityUser getAuthCredentialsByMobile(String mobile) {
return getAuthInfoByMobile(mobile);
}

View File

@@ -1,8 +1,8 @@
package com.youlai.boot.system.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.youlai.boot.framework.security.model.UserAuthInfo;
import com.youlai.boot.system.enums.SocialPlatformEnum;
import com.youlai.boot.framework.security.model.SecurityUser;
import com.youlai.boot.common.enums.SocialPlatformEnum;
import com.youlai.boot.system.model.entity.UserSocial;
/**
@@ -57,7 +57,7 @@ public interface UserSocialService extends IService<UserSocial> {
* @param openid openid
* @return 用户认证信息
*/
UserAuthInfo getAuthInfoByOpenid(SocialPlatformEnum platform, String openid);
SecurityUser getAuthInfoByOpenid(SocialPlatformEnum platform, String openid);
/**
* 更新session_key

View File

@@ -16,7 +16,7 @@ import com.youlai.boot.framework.integration.mail.service.MailService;
import com.youlai.boot.framework.integration.sms.enums.SmsTypeEnum;
import com.youlai.boot.framework.integration.sms.service.SmsService;
import com.youlai.boot.framework.security.model.RoleDataScope;
import com.youlai.boot.framework.security.model.UserAuthInfo;
import com.youlai.boot.framework.security.model.SecurityUser;
import com.youlai.boot.framework.security.token.TokenManager;
import com.youlai.boot.framework.security.util.SecurityUtils;
import com.youlai.boot.system.converter.UserConverter;
@@ -206,39 +206,34 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, SysUser> implements
* 根据用户名获取认证凭证信息
*
* @param username 用户名
* @return 用户认证凭证信息 {@link UserAuthInfo}
* @return 用户认证凭证信息 {@link SecurityUser}
*/
@Override
public UserAuthInfo getAuthInfoByUsername(String username) {
UserAuthInfo userAuthInfo = this.baseMapper.getAuthInfoByUsername(username);
if (userAuthInfo != null) {
Set<String> roles = userAuthInfo.getRoles();
// 获取数据权限列表(用于并集策略)
public SecurityUser getAuthInfoByUsername(String username) {
SecurityUser securityUser = this.baseMapper.getAuthInfoByUsername(username);
if (securityUser != null) {
Set<String> roles = securityUser.getRoles();
List<RoleDataScope> dataScopes = roleService.getRoleDataScopes(roles);
userAuthInfo.setDataScopes(dataScopes);
securityUser.setDataScopes(dataScopes);
}
return userAuthInfo;
return securityUser;
}
/**
* 根据手机号获取用户认证信息
*
* @param mobile 手机号
* @return 用户认证信息
*/
@Override
public UserAuthInfo getAuthInfoByMobile(String mobile) {
public SecurityUser getAuthInfoByMobile(String mobile) {
if (StrUtil.isBlank(mobile)) {
return null;
}
UserAuthInfo userAuthInfo = this.baseMapper.getAuthInfoByMobile(mobile);
if (userAuthInfo != null) {
Set<String> roles = userAuthInfo.getRoles();
// 获取数据权限列表(用于并集策略)
SecurityUser securityUser = this.baseMapper.getAuthInfoByMobile(mobile);
if (securityUser != null) {
Set<String> roles = securityUser.getRoles();
List<RoleDataScope> dataScopes = roleService.getRoleDataScopes(roles);
userAuthInfo.setDataScopes(dataScopes);
securityUser.setDataScopes(dataScopes);
}
return userAuthInfo;
return securityUser;
}
/**

View File

@@ -3,8 +3,8 @@ package com.youlai.boot.system.service.impl;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.youlai.boot.framework.security.model.UserAuthInfo;
import com.youlai.boot.system.enums.SocialPlatformEnum;
import com.youlai.boot.framework.security.model.SecurityUser;
import com.youlai.boot.common.enums.SocialPlatformEnum;
import com.youlai.boot.system.mapper.UserSocialMapper;
import com.youlai.boot.system.model.entity.UserSocial;
import com.youlai.boot.system.service.UserSocialService;
@@ -86,7 +86,7 @@ public class UserSocialServiceImpl extends ServiceImpl<UserSocialMapper, UserSoc
}
@Override
public UserAuthInfo getAuthInfoByOpenid(SocialPlatformEnum platform, String openid) {
public SecurityUser getAuthInfoByOpenid(SocialPlatformEnum platform, String openid) {
UserSocial userSocial = getByPlatformAndOpenid(platform, openid);
if (userSocial == null) {
return null;