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:
@@ -0,0 +1,131 @@
|
||||
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.config.SecurityProperties;
|
||||
import com.youlai.boot.framework.security.filter.TokenAuthenticationFilter;
|
||||
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.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;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.authentication.ProviderManager;
|
||||
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
|
||||
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
|
||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||
import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer;
|
||||
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 配置类。
|
||||
* <p>
|
||||
* 归使用方(auth 模块),安全规则(放行路径、CORS、Provider 装配、响应格式)
|
||||
* 因项目而异,不应由框架层强制装配。
|
||||
*
|
||||
* @author Ray.Hao
|
||||
* @since 4.3.1
|
||||
*/
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@EnableMethodSecurity
|
||||
@RequiredArgsConstructor
|
||||
public class SecurityConfig {
|
||||
|
||||
private final RedisTemplate<String, Object> redisTemplate;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final TokenManager tokenManager;
|
||||
private final SecurityUserDetailsService userDetailsService;
|
||||
private final CaptchaService captchaService;
|
||||
private final SecurityProperties securityProperties;
|
||||
|
||||
@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 JsonAuthenticationEntryPoint())
|
||||
.accessDeniedHandler(new JsonAccessDeniedHandler())
|
||||
)
|
||||
.sessionManagement(configurer ->
|
||||
configurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
|
||||
)
|
||||
.csrf(AbstractHttpConfigurer::disable)
|
||||
.formLogin(AbstractHttpConfigurer::disable)
|
||||
.httpBasic(AbstractHttpConfigurer::disable)
|
||||
.headers(headers -> headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::disable))
|
||||
// 验证码校验(使用方过滤器,直接写 JSON 响应)
|
||||
.addFilterBefore(new CaptchaValidationFilter(captchaService), UsernamePasswordAuthenticationFilter.class)
|
||||
// Token 认证(Starter 过滤器,抛 AuthenticationException 交给 ExceptionTranslationFilter 处理)
|
||||
.addFilterBefore(new TokenAuthenticationFilter(tokenManager), AuthorizationFilter.class)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public WebSecurityCustomizer webSecurityCustomizer() {
|
||||
return (web) -> {
|
||||
String[] unsecuredUrls = securityProperties.getUnsecuredUrls();
|
||||
if (ArrayUtil.isNotEmpty(unsecuredUrls)) {
|
||||
web.ignoring().requestMatchers(unsecuredUrls);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Bean
|
||||
public DaoAuthenticationProvider daoAuthenticationProvider() {
|
||||
DaoAuthenticationProvider provider = new DaoAuthenticationProvider(userDetailsService);
|
||||
provider.setPasswordEncoder(passwordEncoder);
|
||||
return provider;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public SmsAuthenticationProvider smsAuthenticationProvider(UserAuthenticationPort userAuthPort) {
|
||||
return new SmsAuthenticationProvider(userAuthPort, redisTemplate);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public WxMaAuthenticationProvider wechatMiniAuthenticationProvider(
|
||||
WxMaService wxMaService,
|
||||
UserAuthenticationPort userAuthenticationPort,
|
||||
UserSocialService userSocialService
|
||||
) {
|
||||
return new WxMaAuthenticationProvider(wxMaService, userAuthenticationPort, userSocialService);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public AuthenticationManager authenticationManager(
|
||||
DaoAuthenticationProvider daoAuthenticationProvider,
|
||||
SmsAuthenticationProvider smsAuthenticationProvider,
|
||||
WxMaAuthenticationProvider wxMaAuthenticationProvider
|
||||
) {
|
||||
return new ProviderManager(
|
||||
daoAuthenticationProvider,
|
||||
smsAuthenticationProvider,
|
||||
wxMaAuthenticationProvider
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.youlai.boot.auth.security.exception;
|
||||
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
|
||||
/**
|
||||
* 需要绑定手机号异常(微信小程序登录未绑定手机号时抛出)。
|
||||
*/
|
||||
public class MobileNotBoundException extends AuthenticationException {
|
||||
|
||||
private final String openid;
|
||||
private final String sessionKey;
|
||||
|
||||
public MobileNotBoundException(String openid, String sessionKey) {
|
||||
super("需要绑定手机号");
|
||||
this.openid = openid;
|
||||
this.sessionKey = sessionKey;
|
||||
}
|
||||
|
||||
public String getOpenid() {
|
||||
return openid;
|
||||
}
|
||||
|
||||
public String getSessionKey() {
|
||||
return sessionKey;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.youlai.boot.auth.security.exception;
|
||||
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
|
||||
/**
|
||||
* 短信验证码异常
|
||||
*
|
||||
* @author Ray.Hao
|
||||
* @since 2025/3/1
|
||||
*/
|
||||
public class SmsCaptchaException extends AuthenticationException {
|
||||
|
||||
public SmsCaptchaException(String msg) {
|
||||
super(msg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package com.youlai.boot.auth.security.filter;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.json.JSONObject;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
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.captcha.exception.CaptchaException;
|
||||
import com.youlai.boot.framework.captcha.service.CaptchaService;
|
||||
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.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;
|
||||
|
||||
/**
|
||||
* 图形验证码校验过滤器。
|
||||
* <p>
|
||||
* 归使用方,因为验证码规则(哪些接口需要验证码、验证码类型)因项目而异。
|
||||
*/
|
||||
public class CaptchaValidationFilter extends OncePerRequestFilter {
|
||||
|
||||
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_ID_PARAM_NAME = "captchaId";
|
||||
|
||||
private final CaptchaService captchaService;
|
||||
|
||||
public CaptchaValidationFilter(CaptchaService captchaService) {
|
||||
this.captchaService = captchaService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
|
||||
throws ServletException, IOException {
|
||||
|
||||
if (!LOGIN_PATH_REQUEST_MATCHER.matches(request)) {
|
||||
chain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
String contentType = request.getContentType();
|
||||
if (contentType == null || !contentType.contains(MediaType.APPLICATION_JSON_VALUE)) {
|
||||
ResponseWriter.writeError(response, ResultCode.USER_VERIFICATION_CODE_ERROR);
|
||||
return;
|
||||
}
|
||||
|
||||
ContentCachingRequestWrapper requestWrapper = new ContentCachingRequestWrapper(request, -1);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
try {
|
||||
captchaService.validate(captchaId, captchaCode);
|
||||
HttpServletRequest repeatableRequest = new RepeatableReadRequestWrapper(requestWrapper, bodyBytes);
|
||||
chain.doFilter(repeatableRequest, response);
|
||||
} catch (CaptchaException e) {
|
||||
ResponseWriter.writeError(response, e.getResultCode());
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.youlai.boot.auth.security.handler;
|
||||
|
||||
import com.youlai.boot.common.result.ResultCode;
|
||||
import com.youlai.boot.framework.web.util.ResponseWriter;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.security.web.access.AccessDeniedHandler;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
/**
|
||||
* 无权限访问处理器。
|
||||
* <p>
|
||||
* 归使用方,因为 JSON 响应格式因项目而异。
|
||||
*
|
||||
* @author Ray.Hao
|
||||
* @since 2.0.0
|
||||
*/
|
||||
public class JsonAccessDeniedHandler implements AccessDeniedHandler {
|
||||
|
||||
@Override
|
||||
public void handle(HttpServletRequest request, HttpServletResponse response,
|
||||
AccessDeniedException accessDeniedException) {
|
||||
ResponseWriter.writeError(response, ResultCode.ACCESS_PERMISSION_EXCEPTION);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.youlai.boot.auth.security.handler;
|
||||
|
||||
import com.youlai.boot.common.result.ResultCode;
|
||||
import com.youlai.boot.framework.web.util.ResponseWriter;
|
||||
import org.springframework.security.authentication.BadCredentialsException;
|
||||
import org.springframework.security.authentication.InsufficientAuthenticationException;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.security.web.AuthenticationEntryPoint;
|
||||
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* 统一处理 Spring Security 认证失败响应。
|
||||
* <p>
|
||||
* 归使用方,因为 JSON 响应格式(Result 结构、错误码)因项目而异。
|
||||
*
|
||||
* @author Ray.Hao
|
||||
* @since 2.0.0
|
||||
*/
|
||||
public class JsonAuthenticationEntryPoint implements AuthenticationEntryPoint {
|
||||
|
||||
@Override
|
||||
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) {
|
||||
ResponseWriter.writeError(response, ResultCode.ACCESS_TOKEN_INVALID);
|
||||
} else {
|
||||
ResponseWriter.writeError(response, ResultCode.USER_LOGIN_EXCEPTION, authException.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.youlai.boot.auth.security.model;
|
||||
|
||||
import org.springframework.security.authentication.AbstractAuthenticationToken;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.authority.AuthorityUtils;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.util.Collection;
|
||||
|
||||
/**
|
||||
* 短信验证码认证 Token。
|
||||
* <p>
|
||||
* 未认证:principal=手机号,credentials=验证码;已认证:principal=SecurityUserDetails,credentials=null。
|
||||
*
|
||||
* @author Ray.Hao
|
||||
* @since 2.20.0
|
||||
*/
|
||||
public class SmsAuthenticationToken extends AbstractAuthenticationToken {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 621L;
|
||||
|
||||
private final Object principal;
|
||||
private final Object credentials;
|
||||
|
||||
public SmsAuthenticationToken(String mobile, String verifyCode) {
|
||||
super(AuthorityUtils.NO_AUTHORITIES);
|
||||
this.principal = mobile;
|
||||
this.credentials = verifyCode;
|
||||
setAuthenticated(false);
|
||||
}
|
||||
|
||||
public SmsAuthenticationToken(Object principal, Collection<? extends GrantedAuthority> authorities) {
|
||||
super(authorities);
|
||||
this.principal = principal;
|
||||
this.credentials = null;
|
||||
super.setAuthenticated(true);
|
||||
}
|
||||
|
||||
public static SmsAuthenticationToken authenticated(Object principal, Collection<? extends GrantedAuthority> authorities) {
|
||||
return new SmsAuthenticationToken(principal, authorities);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getCredentials() {
|
||||
return this.credentials;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getPrincipal() {
|
||||
return this.principal;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.youlai.boot.auth.security.model;
|
||||
|
||||
import org.springframework.security.authentication.AbstractAuthenticationToken;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.authority.AuthorityUtils;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.util.Collection;
|
||||
|
||||
/**
|
||||
* 微信小程序认证 Token。
|
||||
* <p>
|
||||
* 未认证:principal=微信code;已认证:principal=SecurityUserDetails。
|
||||
*
|
||||
* @author Ray.Hao
|
||||
* @since 4.0.0
|
||||
*/
|
||||
public class WxMaAuthenticationToken extends AbstractAuthenticationToken {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 622L;
|
||||
|
||||
private final Object principal;
|
||||
private final Object credentials;
|
||||
|
||||
public WxMaAuthenticationToken(String code) {
|
||||
super(AuthorityUtils.NO_AUTHORITIES);
|
||||
this.principal = code;
|
||||
this.credentials = null;
|
||||
setAuthenticated(false);
|
||||
}
|
||||
|
||||
public WxMaAuthenticationToken(Object principal, Collection<? extends GrantedAuthority> authorities) {
|
||||
super(authorities);
|
||||
this.principal = principal;
|
||||
this.credentials = null;
|
||||
super.setAuthenticated(true);
|
||||
}
|
||||
|
||||
public static WxMaAuthenticationToken authenticated(Object principal, Collection<? extends GrantedAuthority> authorities) {
|
||||
return new WxMaAuthenticationToken(principal, authorities);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getCredentials() {
|
||||
return this.credentials;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getPrincipal() {
|
||||
return this.principal;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
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.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;
|
||||
import org.springframework.security.authentication.DisabledException;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||
|
||||
/**
|
||||
* 短信验证码认证 Provider
|
||||
* <p>
|
||||
* 认证流程:
|
||||
* <ol>
|
||||
* <li>根据手机号查询用户信息</li>
|
||||
* <li>校验用户状态</li>
|
||||
* <li>校验短信验证码(与 Redis 缓存比对)</li>
|
||||
* <li>验证成功后删除验证码</li>
|
||||
* <li>返回已认证的 Authentication</li>
|
||||
* </ol>
|
||||
*
|
||||
* @author Ray.Hao
|
||||
* @since 2.17.0
|
||||
*/
|
||||
@Slf4j
|
||||
public class SmsAuthenticationProvider implements AuthenticationProvider {
|
||||
|
||||
private final UserAuthenticationPort userAuthPort;
|
||||
private final RedisTemplate<String, Object> redisTemplate;
|
||||
|
||||
public SmsAuthenticationProvider(UserAuthenticationPort userAuthPort, RedisTemplate<String, Object> redisTemplate) {
|
||||
this.userAuthPort = userAuthPort;
|
||||
this.redisTemplate = redisTemplate;
|
||||
}
|
||||
|
||||
@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("手机号不能为空");
|
||||
}
|
||||
if (StrUtil.isBlank(inputVerifyCode)) {
|
||||
log.warn("短信验证码登录失败:验证码为空,手机号={}", mobile);
|
||||
throw new SmsCaptchaException("验证码不能为空");
|
||||
}
|
||||
|
||||
SecurityUser securityUser = userAuthPort.getAuthInfoByMobile(mobile);
|
||||
|
||||
if (securityUser == null) {
|
||||
log.warn("短信验证码登录失败:用户不存在,手机号={}", mobile);
|
||||
throw new UsernameNotFoundException("用户不存在");
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
if (cachedVerifyCode == null) {
|
||||
log.warn("短信验证码登录失败:验证码已过期,手机号={}", mobile);
|
||||
throw new SmsCaptchaException("验证码已过期,请重新获取");
|
||||
}
|
||||
|
||||
if (!StrUtil.equals(inputVerifyCode, cachedVerifyCode)) {
|
||||
log.warn("短信验证码登录失败:验证码错误,手机号={}", mobile);
|
||||
throw new SmsCaptchaException("验证码错误");
|
||||
}
|
||||
|
||||
redisTemplate.delete(cacheKey);
|
||||
|
||||
SecurityUserDetails userDetails = new SecurityUserDetails(securityUser);
|
||||
log.info("短信验证码登录成功:用户名={},手机号={}", securityUser.getUsername(), mobile);
|
||||
return SmsAuthenticationToken.authenticated(userDetails, userDetails.getAuthorities());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supports(Class<?> authentication) {
|
||||
return SmsAuthenticationToken.class.isAssignableFrom(authentication);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
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.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;
|
||||
import org.springframework.security.authentication.AuthenticationProvider;
|
||||
import org.springframework.security.authentication.DisabledException;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||
|
||||
/**
|
||||
* 微信小程序认证 Provider
|
||||
*/
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class WxMaAuthenticationProvider implements AuthenticationProvider {
|
||||
|
||||
private final WxMaService wxMaService;
|
||||
private final UserAuthenticationPort userAuthPort;
|
||||
private final UserSocialService userSocialService;
|
||||
|
||||
@Override
|
||||
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
|
||||
String code = (String) authentication.getPrincipal();
|
||||
|
||||
if (code == null || code.isEmpty()) {
|
||||
log.warn("微信小程序登录失败:code为空");
|
||||
throw new IllegalArgumentException("code不能为空");
|
||||
}
|
||||
|
||||
try {
|
||||
WxMaJscode2SessionResult session = wxMaService.jsCode2SessionInfo(code);
|
||||
String openid = session.getOpenid();
|
||||
String sessionKey = session.getSessionKey();
|
||||
|
||||
log.info("微信小程序登录:openid={}", openid);
|
||||
|
||||
UserSocial userSocial = userSocialService.getByPlatformAndOpenid(SocialPlatformEnum.WECHAT_MINI, openid);
|
||||
|
||||
if (userSocial == null) {
|
||||
log.info("微信小程序登录:用户未绑定手机号,openid={}", openid);
|
||||
throw new MobileNotBoundException(openid, sessionKey);
|
||||
}
|
||||
|
||||
SecurityUser securityUser = userAuthPort.getAuthInfoByOpenid(SocialPlatformEnum.WECHAT_MINI, openid);
|
||||
|
||||
if (securityUser == null) {
|
||||
log.warn("微信小程序登录失败:用户不存在,openid={}", openid);
|
||||
throw new UsernameNotFoundException("用户不存在");
|
||||
}
|
||||
|
||||
if (ObjectUtil.notEqual(securityUser.getStatus(), 1)) {
|
||||
log.warn("微信小程序登录失败:用户已禁用,username={}", securityUser.getUsername());
|
||||
throw new DisabledException("用户已被禁用");
|
||||
}
|
||||
|
||||
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) {
|
||||
log.error("微信小程序登录失败:调用微信接口异常,code={}", code, e);
|
||||
throw new IllegalArgumentException("微信登录失败:" + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supports(Class<?> authentication) {
|
||||
return WxMaAuthenticationToken.class.isAssignableFrom(authentication);
|
||||
}
|
||||
}
|
||||
@@ -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 完成匹配)
|
||||
// 认证通过后返回已认证的 Authentication(principal 为 SysUserDetails,authorities 为角色/权限集合)。
|
||||
// 认证通过后返回已认证的 Authentication(principal 为 SecurityUserDetails,authorities 为角色/权限集合)。
|
||||
Authentication authentication = authenticationManager.authenticate(authenticationToken);
|
||||
|
||||
// 3. 认证成功后生成 JWT 令牌,并存入 Security 上下文,供登录日志 AOP 使用(已认证)
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user