mirror of
http://git.pig4cloud.com/pig/pigx.git
synced 2026-11-12 13:39:01 +08:00
feat(pigx-auth): 增加 SM4 国密算法支持并优化密码解密逻辑
This commit is contained in:
1 parent
32c6e81b3a
commit
c48d5f753c
21 files changed
+599
-200
No files matched your search
+8
-6
@@ -21,6 +21,7 @@ import com.pig4cloud.pigx.auth.support.core.CustomeOAuth2TokenCustomizer;
|
||||
import com.pig4cloud.pigx.auth.support.core.FormIdentityLoginConfigurer;
|
||||
import com.pig4cloud.pigx.auth.support.core.PigxDaoAuthenticationProvider;
|
||||
import com.pig4cloud.pigx.auth.support.core.PigxOAuth2RefreshTokenAuthenticationConverter;
|
||||
import com.pig4cloud.pigx.auth.support.filter.FormLoginValidateCodeFilter;
|
||||
import com.pig4cloud.pigx.auth.support.filter.PasswordDecoderFilter;
|
||||
import com.pig4cloud.pigx.auth.support.filter.ValidateCodeFilter;
|
||||
import com.pig4cloud.pigx.auth.support.handler.PigxAuthenticationFailureEventHandler;
|
||||
@@ -41,14 +42,13 @@ import org.springframework.core.annotation.Order;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.config.Customizer;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configurers.oauth2.server.authorization.OAuth2AuthorizationServerConfigurer;
|
||||
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
|
||||
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer;
|
||||
import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
|
||||
import org.springframework.security.oauth2.server.authorization.token.DelegatingOAuth2TokenGenerator;
|
||||
import org.springframework.security.oauth2.server.authorization.token.OAuth2RefreshTokenGenerator;
|
||||
import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenGenerator;
|
||||
import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2AuthorizationCodeAuthenticationConverter;
|
||||
import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2AuthorizationCodeRequestAuthenticationConverter;
|
||||
import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2ClientCredentialsAuthenticationConverter;
|
||||
import org.springframework.security.web.DefaultSecurityFilterChain;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
@@ -78,6 +78,8 @@ public class AuthorizationServerConfiguration {
|
||||
|
||||
private final ValidateCodeFilter validateCodeFilter;
|
||||
|
||||
private final FormLoginValidateCodeFilter formLoginValidateCodeFilter;
|
||||
|
||||
private final PigxBootCorsProperties corsProperties;
|
||||
|
||||
@Bean
|
||||
@@ -91,6 +93,8 @@ public class AuthorizationServerConfiguration {
|
||||
|
||||
// 增加验证码过滤器
|
||||
http.addFilterBefore(validateCodeFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
// 增加授权码登录表单验证码过滤器
|
||||
http.addFilterBefore(formLoginValidateCodeFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
// 增加密码解密过滤器
|
||||
http.addFilterBefore(passwordDecoderFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
|
||||
@@ -163,15 +167,13 @@ public class AuthorizationServerConfiguration {
|
||||
*
|
||||
* @return DelegatingAuthenticationConverter
|
||||
*/
|
||||
@Bean
|
||||
public AuthenticationConverter accessTokenRequestConverter() {
|
||||
private AuthenticationConverter accessTokenRequestConverter() {
|
||||
return new DelegatingAuthenticationConverter(
|
||||
Arrays.asList(new OAuth2ResourceOwnerPasswordAuthenticationConverter(),
|
||||
new OAuth2ResourceOwnerSmsAuthenticationConverter(),
|
||||
new PigxOAuth2RefreshTokenAuthenticationConverter(),
|
||||
new OAuth2ClientCredentialsAuthenticationConverter(),
|
||||
new OAuth2AuthorizationCodeAuthenticationConverter(),
|
||||
new OAuth2AuthorizationCodeRequestAuthenticationConverter()));
|
||||
new OAuth2AuthorizationCodeAuthenticationConverter()));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
-46
@@ -1,46 +0,0 @@
|
||||
package com.pig4cloud.pigx.auth.endpoint;
|
||||
|
||||
import com.anji.captcha.service.CaptchaCacheService;
|
||||
import com.pig4cloud.pigx.common.data.cache.RedisUtils;
|
||||
|
||||
/**
|
||||
* @author lengleng
|
||||
* @date 2020/8/27
|
||||
* <p>
|
||||
* 验证码 缓存提供支持集群,需要通过SPI
|
||||
*/
|
||||
public class CaptchaCacheServiceProvider implements CaptchaCacheService {
|
||||
|
||||
private static final String REDIS = "redis";
|
||||
|
||||
@Override
|
||||
public void set(String key, String value, long expiresInSeconds) {
|
||||
RedisUtils.set(key, value, expiresInSeconds);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean exists(String key) {
|
||||
return RedisUtils.hasKey(key);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete(String key) {
|
||||
RedisUtils.delete(key);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String get(String key) {
|
||||
return RedisUtils.get(key);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String type() {
|
||||
return REDIS;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Long increment(String key, long val) {
|
||||
return RedisUtils.increment(key, val);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -78,6 +78,6 @@ public class ImageCodeEndpoint {
|
||||
|
||||
CaptchaService captchaService = SpringContextHolder.getBean(CaptchaService.class);
|
||||
return R.ok(captchaService.check(vo));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -19,6 +19,7 @@ package com.pig4cloud.pigx.auth.endpoint;
|
||||
import cn.hutool.core.date.DatePattern;
|
||||
import cn.hutool.core.date.TemporalAccessorUtil;
|
||||
import cn.hutool.core.map.MapUtil;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.pig4cloud.pigx.admin.api.entity.SysOauthClientDetails;
|
||||
@@ -26,6 +27,7 @@ import com.pig4cloud.pigx.admin.api.entity.SysTenant;
|
||||
import com.pig4cloud.pigx.admin.api.feign.RemoteClientDetailsService;
|
||||
import com.pig4cloud.pigx.admin.api.feign.RemoteTenantService;
|
||||
import com.pig4cloud.pigx.admin.api.vo.TokenVO;
|
||||
import com.pig4cloud.pigx.auth.support.core.AuthCaptchaSupport;
|
||||
import com.pig4cloud.pigx.auth.support.core.AuthErrorCodes;
|
||||
import com.pig4cloud.pigx.common.core.constant.CacheConstants;
|
||||
import com.pig4cloud.pigx.common.core.constant.CommonConstants;
|
||||
@@ -61,6 +63,7 @@ import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.servlet.ModelAndView;
|
||||
|
||||
import java.security.Principal;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
@@ -76,6 +79,8 @@ import java.util.Set;
|
||||
@RequiredArgsConstructor
|
||||
public class PigxTokenEndpoint {
|
||||
|
||||
private static final String DEFAULT_FORM_CLIENT_ID = "pig";
|
||||
|
||||
private final OAuth2AuthorizationService authorizationService;
|
||||
|
||||
private final RemoteClientDetailsService clientDetailsService;
|
||||
@@ -84,6 +89,8 @@ public class PigxTokenEndpoint {
|
||||
|
||||
private final CacheManager cacheManager;
|
||||
|
||||
private final AuthCaptchaSupport authCaptchaSupport;
|
||||
|
||||
/**
|
||||
* 认证页面
|
||||
*
|
||||
@@ -92,13 +99,24 @@ public class PigxTokenEndpoint {
|
||||
* @return ModelAndView
|
||||
*/
|
||||
@GetMapping("/token/login")
|
||||
public ModelAndView require(ModelAndView modelAndView, @RequestParam(required = false) String error) {
|
||||
public ModelAndView require(ModelAndView modelAndView, @RequestParam(required = false) String error,
|
||||
HttpServletRequest request, HttpServletResponse response) {
|
||||
modelAndView.setViewName("ftl/login");
|
||||
// Note: XSS prevention is handled by FreeMarker template using ?html directive
|
||||
modelAndView.addObject("error", error);
|
||||
|
||||
R<List<SysTenant>> tenantList = tenantService.list();
|
||||
modelAndView.addObject("tenantList", tenantList.getData());
|
||||
List<SysTenant> tenantList = Objects.requireNonNullElse(tenantService.list().getData(), List.of());
|
||||
String authClientId = StrUtil.blankToDefault(
|
||||
authCaptchaSupport.resolveAuthorizationClientId(request, response, true), DEFAULT_FORM_CLIENT_ID);
|
||||
Long selectedTenantId = resolveSelectedTenantId(request, response, tenantList);
|
||||
Map<String, Boolean> tenantCaptchaEnabledMap = buildTenantCaptchaEnabledMap(tenantList, authClientId);
|
||||
|
||||
modelAndView.addObject("tenantList", tenantList);
|
||||
modelAndView.addObject("authClientId", authClientId);
|
||||
modelAndView.addObject("selectedTenantId", selectedTenantId);
|
||||
modelAndView.addObject("showCaptcha",
|
||||
Boolean.TRUE.equals(tenantCaptchaEnabledMap.get(String.valueOf(selectedTenantId))));
|
||||
modelAndView.addObject("tenantCaptchaEnabledJson", JSONUtil.toJsonStr(tenantCaptchaEnabledMap));
|
||||
return modelAndView;
|
||||
}
|
||||
|
||||
@@ -256,7 +274,33 @@ public class PigxTokenEndpoint {
|
||||
@GetMapping("/token/query-token")
|
||||
public R queryToken(String token) {
|
||||
OAuth2Authorization authorization = authorizationService.findByToken(token, OAuth2TokenType.ACCESS_TOKEN);
|
||||
return R.ok(authorization);
|
||||
}
|
||||
return R.ok(authorization);
|
||||
}
|
||||
|
||||
private Long resolveSelectedTenantId(HttpServletRequest request, HttpServletResponse response, List<SysTenant> tenantList) {
|
||||
String tenantId = authCaptchaSupport.resolveAuthorizationTenantId(request, response, true);
|
||||
if (StrUtil.isNotBlank(tenantId)) {
|
||||
try {
|
||||
return Long.parseLong(tenantId);
|
||||
} catch (NumberFormatException ignored) {
|
||||
// ignore invalid tenant id and fall back to the default selection below
|
||||
}
|
||||
}
|
||||
|
||||
if (!tenantList.isEmpty()) {
|
||||
return tenantList.get(0).getId();
|
||||
}
|
||||
|
||||
return CommonConstants.TENANT_ID_1;
|
||||
}
|
||||
|
||||
private Map<String, Boolean> buildTenantCaptchaEnabledMap(List<SysTenant> tenantList, String authClientId) {
|
||||
Map<String, Boolean> result = new LinkedHashMap<>(tenantList.size());
|
||||
for (SysTenant tenant : tenantList) {
|
||||
String tenantId = String.valueOf(tenant.getId());
|
||||
result.put(tenantId, authCaptchaSupport.isCaptchaEnabled(tenantId, authClientId));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
}
|
||||
+5
-5
@@ -183,7 +183,7 @@ public abstract class OAuth2ResourceOwnerBaseAuthenticationProvider<T extends OA
|
||||
private OAuth2AccessTokenAuthenticationToken generatAuthenticationToken(T resouceOwnerBaseAuthentication,
|
||||
OAuth2ClientAuthenticationToken clientPrincipal, RegisteredClient registeredClient,
|
||||
Set<String> authorizedScopes, Authentication usernamePasswordAuthentication) {
|
||||
// @formatter:off
|
||||
// @formatter:off
|
||||
DefaultOAuth2TokenContext.Builder tokenContextBuilder = DefaultOAuth2TokenContext.builder()
|
||||
.registeredClient(registeredClient)
|
||||
.principal(usernamePasswordAuthentication)
|
||||
@@ -319,10 +319,10 @@ public abstract class OAuth2ResourceOwnerBaseAuthenticationProvider<T extends OA
|
||||
}
|
||||
|
||||
if (clientPrincipal != null && clientPrincipal.isAuthenticated()) {
|
||||
return clientPrincipal;
|
||||
}
|
||||
return clientPrincipal;
|
||||
}
|
||||
|
||||
throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_CLIENT);
|
||||
}
|
||||
throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_CLIENT);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package com.pig4cloud.pigx.auth.support.core;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.json.JSONObject;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import com.pig4cloud.pigx.common.core.constant.CacheConstants;
|
||||
import com.pig4cloud.pigx.common.core.constant.CommonConstants;
|
||||
import com.pig4cloud.pigx.common.core.constant.enums.CaptchaFlagTypeEnum;
|
||||
import com.pig4cloud.pigx.common.core.exception.ValidateCodeException;
|
||||
import com.pig4cloud.pigx.common.core.util.MsgUtils;
|
||||
import com.pig4cloud.pigx.common.data.cache.RedisUtils;
|
||||
import com.pig4cloud.pigx.common.security.captcha.CaptchaResult;
|
||||
import com.pig4cloud.pigx.common.security.captcha.CaptchaValidator;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
|
||||
import org.springframework.security.web.savedrequest.HttpSessionRequestCache;
|
||||
import org.springframework.security.web.savedrequest.RequestCache;
|
||||
import org.springframework.security.web.savedrequest.SavedRequest;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* 授权登录验证码支持组件
|
||||
*
|
||||
* <p>
|
||||
* 统一处理验证码开关判断、授权客户端解析和验证码校验,避免在多个过滤器和控制器中重复实现。
|
||||
* </p>
|
||||
*
|
||||
* @author pig
|
||||
* @date 2026-03-20
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class AuthCaptchaSupport {
|
||||
|
||||
private final RequestCache requestCache = new HttpSessionRequestCache();
|
||||
|
||||
private final CaptchaValidator captchaValidator;
|
||||
|
||||
/**
|
||||
* 解析当前授权流程中的真实客户端ID
|
||||
*
|
||||
* @param request 当前请求
|
||||
* @param response 当前响应
|
||||
* @param includeCurrentRequestId 是否优先读取当前请求参数中的 client_id
|
||||
* @return 授权客户端ID
|
||||
*/
|
||||
public String resolveAuthorizationClientId(HttpServletRequest request, HttpServletResponse response,
|
||||
boolean includeCurrentRequestId) {
|
||||
return resolveAuthorizationParameter(request, response, OAuth2ParameterNames.CLIENT_ID, includeCurrentRequestId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析当前授权流程中的租户ID
|
||||
*
|
||||
* @param request 当前请求
|
||||
* @param response 当前响应
|
||||
* @param includeCurrentRequestId 是否优先读取当前请求参数中的租户ID
|
||||
* @return 租户ID
|
||||
*/
|
||||
public String resolveAuthorizationTenantId(HttpServletRequest request, HttpServletResponse response,
|
||||
boolean includeCurrentRequestId) {
|
||||
return resolveAuthorizationParameter(request, response, CommonConstants.TENANT_ID, includeCurrentRequestId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断客户端是否开启验证码
|
||||
*
|
||||
* @param tenantId 租户ID
|
||||
* @param clientId 客户端ID
|
||||
* @return true 开启验证码
|
||||
*/
|
||||
public boolean isCaptchaEnabled(String tenantId, String clientId) {
|
||||
if (StrUtil.isBlank(clientId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String finalTenantId = StrUtil.isBlank(tenantId) ? CommonConstants.TENANT_ID_1.toString() : tenantId;
|
||||
String key = String.format("%s:%s:%s", finalTenantId, CacheConstants.CLIENT_FLAG, clientId);
|
||||
String val = RedisUtils.get(key);
|
||||
|
||||
if (val == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
JSONObject information = JSONUtil.parseObj(val);
|
||||
return !StrUtil.equals(CaptchaFlagTypeEnum.OFF.getType(), information.getStr(CommonConstants.CAPTCHA_FLAG));
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验请求中的验证码
|
||||
*
|
||||
* @param request 当前请求
|
||||
* @throws ValidateCodeException 验证码校验失败
|
||||
*/
|
||||
public void validateCode(HttpServletRequest request) throws ValidateCodeException {
|
||||
String code = request.getParameter("code");
|
||||
if (StrUtil.isBlank(code)) {
|
||||
throw new ValidateCodeException(MsgUtils.getMessage(AuthErrorCodes.AUTH_CAPTCHA_EMPTY));
|
||||
}
|
||||
|
||||
String randomStr = request.getParameter("randomStr");
|
||||
// https://gitee.com/log4j/pig/issues/IWA0D 手机号场景以 mobile 作为缓存键
|
||||
String mobile = request.getParameter("mobile");
|
||||
if (StrUtil.isNotBlank(mobile)) {
|
||||
randomStr = mobile;
|
||||
}
|
||||
|
||||
boolean isBehavior = StrUtil.equalsAnyIgnoreCase(randomStr, CommonConstants.IMAGE_CODE_BLOCK_PUZZLE,
|
||||
CommonConstants.IMAGE_CODE_CLICK_WORD);
|
||||
String captchaType = isBehavior ? randomStr : CommonConstants.IMAGE_CODE_MATH;
|
||||
String captchaVerification = isBehavior ? code
|
||||
: randomStr + CommonConstants.CAPTCHA_VERIFICATION_SEPARATOR + code;
|
||||
|
||||
CaptchaResult result = captchaValidator.validate(captchaType, captchaVerification);
|
||||
if (!result.isOk()) {
|
||||
String errorCode = isBehavior ? AuthErrorCodes.AUTH_CAPTCHA_EMPTY : AuthErrorCodes.AUTH_CAPTCHA_INVALID;
|
||||
throw new ValidateCodeException(MsgUtils.getMessage(errorCode));
|
||||
}
|
||||
}
|
||||
|
||||
private String resolveAuthorizationParameter(HttpServletRequest request, HttpServletResponse response,
|
||||
String parameterName, boolean includeCurrentRequestId) {
|
||||
if (includeCurrentRequestId) {
|
||||
String parameterValue = request.getParameter(parameterName);
|
||||
if (StrUtil.isNotBlank(parameterValue)) {
|
||||
return parameterValue;
|
||||
}
|
||||
}
|
||||
|
||||
SavedRequest savedRequest = requestCache.getRequest(request, response);
|
||||
if (Objects.isNull(savedRequest)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String[] parameterValues = savedRequest.getParameterValues(parameterName);
|
||||
if (Objects.isNull(parameterValues) || parameterValues.length == 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parameterValues[0];
|
||||
}
|
||||
|
||||
}
|
||||
+1
-1
@@ -16,7 +16,7 @@ public final class FormIdentityLoginConfigurer
|
||||
extends AbstractHttpConfigurer<FormIdentityLoginConfigurer, HttpSecurity> {
|
||||
|
||||
@Override
|
||||
public void init(HttpSecurity http) throws Exception {
|
||||
public void init(HttpSecurity http) {
|
||||
http.formLogin(formLogin -> {
|
||||
formLogin.loginPage("/token/login");
|
||||
formLogin.loginProcessingUrl("/oauth2/form");
|
||||
|
||||
+30
-8
@@ -9,7 +9,7 @@ import org.springframework.stereotype.Component;
|
||||
* 安全认证配置属性类
|
||||
*
|
||||
* @author lengleng
|
||||
* @date 2025/08/06
|
||||
* @date 2026/05/03
|
||||
*/
|
||||
@Data
|
||||
@Component
|
||||
@@ -17,17 +17,39 @@ import org.springframework.stereotype.Component;
|
||||
@ConfigurationProperties("security")
|
||||
public class AuthSecurityConfigProperties {
|
||||
|
||||
/**
|
||||
* 是否是微服务架构
|
||||
*/
|
||||
private boolean isMicro;
|
||||
/**
|
||||
* 是否是微服务架构
|
||||
*/
|
||||
private boolean isMicro;
|
||||
|
||||
/**
|
||||
* 认证中心解密登录前端密码的密钥。AES 使用 16 字节字符串,SM4 使用 32 位 HEX 字符串。
|
||||
*/
|
||||
private String encodeKey;
|
||||
|
||||
/**
|
||||
* 网关解密登录前端密码 秘钥
|
||||
* 认证中心解密登录前端密码的算法,默认使用 AES 以兼容历史配置。
|
||||
*/
|
||||
private String encodeKey;
|
||||
private EncodeType encodeType = EncodeType.AES;
|
||||
|
||||
/** 是否开启密码超期强制修改密码 */
|
||||
/**
|
||||
* 是否开启密码超期强制修改密码,默认关闭。
|
||||
*/
|
||||
private boolean expirePassword = false;
|
||||
|
||||
/**
|
||||
* 登录密码加密算法枚举
|
||||
* <p>
|
||||
* AES:使用 16 字节 UTF-8 字符串作为密钥,CFB / NoPadding 模式,IV 等于密钥。<br>
|
||||
* SM4:国密 SM4 算法,使用 32 位 HEX 字符串作为密钥(解码后 16 字节)。
|
||||
*/
|
||||
public enum EncodeType {
|
||||
|
||||
/** AES 算法(默认) */
|
||||
AES,
|
||||
/** 国密 SM4 算法 */
|
||||
SM4
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
package com.pig4cloud.pigx.auth.support.filter;
|
||||
|
||||
import com.pig4cloud.pigx.auth.support.core.AuthCaptchaSupport;
|
||||
import com.pig4cloud.pigx.auth.support.handler.FormAuthenticationFailureHandler;
|
||||
import com.pig4cloud.pigx.common.core.constant.CommonConstants;
|
||||
import com.pig4cloud.pigx.common.core.exception.ValidateCodeException;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.security.authentication.AuthenticationServiceException;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* 授权码登录表单验证码过滤器
|
||||
*
|
||||
* <p>
|
||||
* 仅处理 /oauth2/form 请求,复用现有图形验证码能力,对授权码登录页补充验证码校验。
|
||||
* </p>
|
||||
*
|
||||
* @author pig
|
||||
* @date 2026-03-20
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class FormLoginValidateCodeFilter extends OncePerRequestFilter {
|
||||
|
||||
private static final String FORM_LOGIN_URL = "/oauth2/form";
|
||||
|
||||
private final AuthCaptchaSupport authCaptchaSupport;
|
||||
|
||||
private final FormAuthenticationFailureHandler failureHandler = new FormAuthenticationFailureHandler();
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
|
||||
throws ServletException, IOException {
|
||||
if (!FORM_LOGIN_URL.equals(request.getServletPath())) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
String authClientId = authCaptchaSupport.resolveAuthorizationClientId(request, response, true);
|
||||
String tenantId = authCaptchaSupport.resolveAuthorizationTenantId(request, response, true);
|
||||
|
||||
if (!authCaptchaSupport.isCaptchaEnabled(tenantId, authClientId)) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
authCaptchaSupport.validateCode(request);
|
||||
filterChain.doFilter(request, response);
|
||||
} catch (ValidateCodeException e) {
|
||||
log.debug("授权码登录验证码校验失败: {}", e.getMessage());
|
||||
failureHandler.onAuthenticationFailure(request, response, new AuthenticationServiceException(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+68
-17
@@ -17,10 +17,12 @@
|
||||
package com.pig4cloud.pigx.auth.support.filter;
|
||||
|
||||
import cn.hutool.core.util.ArrayUtil;
|
||||
import cn.hutool.core.util.HexUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.crypto.Mode;
|
||||
import cn.hutool.crypto.Padding;
|
||||
import cn.hutool.crypto.SecureUtil;
|
||||
import cn.hutool.crypto.SmUtil;
|
||||
import cn.hutool.crypto.symmetric.AES;
|
||||
import cn.hutool.json.JSONObject;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
@@ -39,6 +41,7 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import javax.crypto.spec.IvParameterSpec;
|
||||
@@ -59,6 +62,10 @@ public class PasswordDecoderFilter extends OncePerRequestFilter {
|
||||
|
||||
private static final String KEY_ALGORITHM = "AES";
|
||||
|
||||
private static final int AES_KEY_LENGTH = 16;
|
||||
|
||||
private static final int SM4_HEX_KEY_LENGTH = 32;
|
||||
|
||||
static {
|
||||
// 关闭hutool 强制关闭Bouncy Castle库的依赖
|
||||
SecureUtil.disableBouncyCastle();
|
||||
@@ -92,28 +99,72 @@ public class PasswordDecoderFilter extends OncePerRequestFilter {
|
||||
RepeatBodyRequestWrapper requestWrapper = new RepeatBodyRequestWrapper(request);
|
||||
Map<String, String[]> parameterMap = requestWrapper.getParameterMap();
|
||||
|
||||
// 构建前端对应解密AES 因子
|
||||
AES aes = new AES(Mode.CFB, Padding.NoPadding,
|
||||
new SecretKeySpec(authSecurityConfigProperties.getEncodeKey().getBytes(), KEY_ALGORITHM),
|
||||
new IvParameterSpec(authSecurityConfigProperties.getEncodeKey().getBytes()));
|
||||
|
||||
parameterMap.forEach((k, v) -> {
|
||||
String[] values = parameterMap.get(k);
|
||||
if (!PASSWORD.equals(k) || ArrayUtil.isEmpty(values)) {
|
||||
return;
|
||||
}
|
||||
|
||||
String[] values = parameterMap.get(PASSWORD);
|
||||
if (ArrayUtil.isNotEmpty(values)) {
|
||||
// 解密密码
|
||||
String decryptPassword = aes.decryptStr(values[0]);
|
||||
parameterMap.put(k, new String[]{decryptPassword});
|
||||
});
|
||||
String decryptPassword = decryptPassword(values[0]);
|
||||
parameterMap.put(PASSWORD, new String[]{decryptPassword});
|
||||
}
|
||||
chain.doFilter(requestWrapper, response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据请求的clientId 查询客户端配置是否是加密传输
|
||||
/**
|
||||
* 根据 {@code security.encode-type} 配置选择 AES 或 SM4 解密前端传入的密文。
|
||||
* <p>
|
||||
* 包级可见以便单元测试直接覆盖;当配置为空时按 AES 兜底,避免老配置升级后无法登录。
|
||||
*
|
||||
* @param password 前端加密后的密码
|
||||
* @return 解密后的明文密码
|
||||
* @throws IllegalArgumentException 当配置的密钥长度不符合所选算法要求
|
||||
*/
|
||||
String decryptPassword(String password) {
|
||||
AuthSecurityConfigProperties.EncodeType encodeType = authSecurityConfigProperties.getEncodeType();
|
||||
if (encodeType == null) {
|
||||
encodeType = AuthSecurityConfigProperties.EncodeType.AES;
|
||||
}
|
||||
return switch (encodeType) {
|
||||
case AES -> decryptByAes(password);
|
||||
case SM4 -> decryptBySm4(password);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 AES(CFB / NoPadding,IV 等于密钥)解密密码。
|
||||
*
|
||||
* @param password 前端 AES 加密后的密码
|
||||
* @return 明文密码
|
||||
*/
|
||||
private String decryptByAes(String password) {
|
||||
String encodeKey = authSecurityConfigProperties.getEncodeKey();
|
||||
Assert.hasText(encodeKey, "AES encode-key must not be empty (AES登录密码解密密钥不能为空)");
|
||||
byte[] keyBytes = encodeKey.getBytes();
|
||||
Assert.isTrue(keyBytes.length == AES_KEY_LENGTH,
|
||||
"AES encode-key must be 16 bytes (AES登录密码解密密钥必须为16字节)");
|
||||
|
||||
// 构建前端对应解密 AES 因子
|
||||
AES aes = new AES(Mode.CFB, Padding.NoPadding, new SecretKeySpec(keyBytes, KEY_ALGORITHM),
|
||||
new IvParameterSpec(keyBytes));
|
||||
return aes.decryptStr(password);
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用国密 SM4 解密密码,密钥需为 32 位 HEX 字符串。
|
||||
*
|
||||
* @param password 前端 SM4 加密后的密码
|
||||
* @return 明文密码
|
||||
*/
|
||||
private String decryptBySm4(String password) {
|
||||
String encodeKey = authSecurityConfigProperties.getEncodeKey();
|
||||
Assert.hasText(encodeKey, "SM4 encode-key must not be empty (SM4登录密码解密密钥不能为空)");
|
||||
Assert.isTrue(StrUtil.length(encodeKey) == SM4_HEX_KEY_LENGTH && HexUtil.isHexNumber(encodeKey),
|
||||
"SM4 encode-key must be 32 hex characters (SM4登录密码解密密钥必须为32位HEX字符串)");
|
||||
return SmUtil.sm4(HexUtil.decodeHex(encodeKey)).decryptStr(password);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据请求的clientId 查询客户端配置是否是加密传输
|
||||
* @param request 请求上下文
|
||||
* @return true 加密传输 、 false 原文传输
|
||||
* @return true 加密传输,false 明文传输
|
||||
*/
|
||||
private boolean isEncClient(HttpServletRequest request) {
|
||||
String header = request.getHeader(HttpHeaders.AUTHORIZATION);
|
||||
|
||||
+5
-71
@@ -8,20 +8,11 @@ package com.pig4cloud.pigx.auth.support.filter;
|
||||
*/
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.json.JSONObject;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import com.anji.captcha.model.vo.CaptchaVO;
|
||||
import com.anji.captcha.service.CaptchaService;
|
||||
import com.pig4cloud.pigx.auth.support.core.AuthErrorCodes;
|
||||
import com.pig4cloud.pigx.common.core.constant.CacheConstants;
|
||||
import com.pig4cloud.pigx.auth.support.core.AuthCaptchaSupport;
|
||||
import com.pig4cloud.pigx.common.core.constant.CommonConstants;
|
||||
import com.pig4cloud.pigx.common.core.constant.SecurityConstants;
|
||||
import com.pig4cloud.pigx.common.core.constant.enums.CaptchaFlagTypeEnum;
|
||||
import com.pig4cloud.pigx.common.core.exception.ValidateCodeException;
|
||||
import com.pig4cloud.pigx.common.core.util.MsgUtils;
|
||||
import com.pig4cloud.pigx.common.core.util.SpringContextHolder;
|
||||
import com.pig4cloud.pigx.common.core.util.WebUtils;
|
||||
import com.pig4cloud.pigx.common.data.cache.RedisUtils;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
@@ -47,6 +38,8 @@ import java.io.IOException;
|
||||
@RequiredArgsConstructor
|
||||
public class ValidateCodeFilter extends OncePerRequestFilter {
|
||||
|
||||
private final AuthCaptchaSupport authCaptchaSupport;
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
|
||||
throws ServletException, IOException {
|
||||
@@ -97,55 +90,7 @@ public class ValidateCodeFilter extends OncePerRequestFilter {
|
||||
* 校验验证码
|
||||
*/
|
||||
private void checkCode() throws ValidateCodeException {
|
||||
HttpServletRequest request = WebUtils.getRequest();
|
||||
String code = request.getParameter("code");
|
||||
|
||||
if (StrUtil.isBlank(code)) {
|
||||
throw new ValidateCodeException(MsgUtils.getMessage(AuthErrorCodes.AUTH_CAPTCHA_EMPTY));
|
||||
}
|
||||
|
||||
String randomStr = request.getParameter("randomStr");
|
||||
|
||||
// https://gitee.com/log4j/pig/issues/IWA0D
|
||||
String mobile = request.getParameter("mobile");
|
||||
if (StrUtil.isNotBlank(mobile)) {
|
||||
randomStr = mobile;
|
||||
}
|
||||
|
||||
// 若是滑块登录
|
||||
if (StrUtil.equalsAnyIgnoreCase(randomStr, CommonConstants.IMAGE_CODE_BLOCK_PUZZLE, CommonConstants.IMAGE_CODE_CLICK_WORD)) {
|
||||
CaptchaService captchaService = SpringContextHolder.getBean(CaptchaService.class);
|
||||
CaptchaVO vo = new CaptchaVO();
|
||||
vo.setCaptchaVerification(code);
|
||||
vo.setCaptchaType(randomStr);
|
||||
if (!captchaService.verification(vo).isSuccess()) {
|
||||
throw new ValidateCodeException(MsgUtils.getMessage(AuthErrorCodes.AUTH_CAPTCHA_EMPTY));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
String key = CacheConstants.DEFAULT_CODE_KEY + randomStr;
|
||||
if (!RedisUtils.hasKey(key)) {
|
||||
throw new ValidateCodeException(MsgUtils.getMessage(AuthErrorCodes.AUTH_CAPTCHA_INVALID));
|
||||
}
|
||||
|
||||
String codeObj = RedisUtils.get(key);
|
||||
|
||||
if (codeObj == null) {
|
||||
throw new ValidateCodeException(MsgUtils.getMessage(AuthErrorCodes.AUTH_CAPTCHA_INVALID));
|
||||
}
|
||||
|
||||
if (StrUtil.isBlank(codeObj)) {
|
||||
RedisUtils.delete(key);
|
||||
throw new ValidateCodeException(MsgUtils.getMessage(AuthErrorCodes.AUTH_CAPTCHA_INVALID));
|
||||
}
|
||||
|
||||
if (!StrUtil.equals(codeObj, code)) {
|
||||
RedisUtils.delete(key);
|
||||
throw new ValidateCodeException(MsgUtils.getMessage(AuthErrorCodes.AUTH_CAPTCHA_INVALID));
|
||||
}
|
||||
|
||||
RedisUtils.delete(key);
|
||||
authCaptchaSupport.validateCode(WebUtils.getRequest());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -159,18 +104,7 @@ public class ValidateCodeFilter extends OncePerRequestFilter {
|
||||
String clientId = WebUtils.extractClientId(header).orElse(null);
|
||||
// 获取租户拼接区分租户的key
|
||||
String tenantId = request.getHeader(CommonConstants.TENANT_ID);
|
||||
String key = String.format("%s:%s:%s", StrUtil.isBlank(tenantId) ? CommonConstants.TENANT_ID_1 : tenantId,
|
||||
CacheConstants.CLIENT_FLAG, clientId);
|
||||
|
||||
String val = RedisUtils.get(key);
|
||||
|
||||
// 当配置不存在时,不用校验
|
||||
if (val == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
JSONObject information = JSONUtil.parseObj(val);
|
||||
return !StrUtil.equals(CaptchaFlagTypeEnum.OFF.getType(), information.getStr(CommonConstants.CAPTCHA_FLAG));
|
||||
return authCaptchaSupport.isCaptchaEnabled(tenantId, clientId);
|
||||
}
|
||||
|
||||
}
|
||||
+14
-4
@@ -16,8 +16,11 @@
|
||||
|
||||
package com.pig4cloud.pigx.auth.support.handler;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.core.util.CharsetUtil;
|
||||
import cn.hutool.http.HttpUtil;
|
||||
import com.pig4cloud.pigx.common.core.constant.CommonConstants;
|
||||
import com.pig4cloud.pigx.common.core.constant.SecurityConstants;
|
||||
import com.pig4cloud.pigx.common.core.util.WebUtils;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
@@ -51,15 +54,22 @@ public class FormAuthenticationFailureHandler implements AuthenticationFailureHa
|
||||
|
||||
// 获取当前请求的context-path
|
||||
String contextPath = request.getContextPath();
|
||||
String tenantId = request.getParameter(CommonConstants.TENANT_ID);
|
||||
String clientId = request.getParameter(SecurityConstants.CLIENT_ID);
|
||||
String redirectUrl = String.format("%s/token/login?error=%s", contextPath, exception.getMessage());
|
||||
if (StrUtil.isNotBlank(tenantId)) {
|
||||
redirectUrl = String.format("%s&%s=%s", redirectUrl, CommonConstants.TENANT_ID, tenantId);
|
||||
}
|
||||
if (StrUtil.isNotBlank(clientId)) {
|
||||
redirectUrl = String.format("%s&%s=%s", redirectUrl, SecurityConstants.CLIENT_ID, clientId);
|
||||
}
|
||||
|
||||
// 构建重定向URL,加入context-path
|
||||
String url = HttpUtil.encodeParams(
|
||||
String.format("%s/token/login?error=%s", contextPath, exception.getMessage()),
|
||||
CharsetUtil.CHARSET_UTF_8);
|
||||
String url = HttpUtil.encodeParams(redirectUrl, CharsetUtil.CHARSET_UTF_8);
|
||||
|
||||
try {
|
||||
WebUtils.getResponse().sendRedirect(url);
|
||||
} catch (IOException e) {
|
||||
} catch (IOException e) {
|
||||
log.error("重定向失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
+37
-11
@@ -39,7 +39,6 @@ import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
|
||||
import org.springframework.http.server.ServletServerHttpResponse;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
|
||||
@@ -50,15 +49,19 @@ import org.springframework.stereotype.Component;
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* 认证失败处理器:处理用户认证失败事件
|
||||
*
|
||||
* @author lengleng
|
||||
* @date 2022-06-02
|
||||
* @date 2025/08/19
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@SuppressWarnings("removal")
|
||||
public class PigxAuthenticationFailureEventHandler implements AuthenticationFailureHandler {
|
||||
|
||||
private static final MappingJackson2HttpMessageConverter errorHttpResponseConverter = new MappingJackson2HttpMessageConverter();
|
||||
private static final org.springframework.http.converter.json.MappingJackson2HttpMessageConverter errorHttpResponseConverter =
|
||||
new org.springframework.http.converter.json.MappingJackson2HttpMessageConverter();
|
||||
|
||||
private final ApplicationEventPublisher publisher;
|
||||
|
||||
@@ -81,13 +84,11 @@ public class PigxAuthenticationFailureEventHandler implements AuthenticationFail
|
||||
log.info("登录失败,异常:{}", exception.getLocalizedMessage());
|
||||
|
||||
// 密码模式记录错误信息
|
||||
if (OAuth2ParameterNames.PASSWORD.equals(grantType)) {
|
||||
String username = request.getParameter(OAuth2ParameterNames.USERNAME);
|
||||
if (SecurityConstants.PASSWORD.equals(grantType)) {
|
||||
String username = request.getParameter(SecurityConstants.DETAILS_USERNAME);
|
||||
|
||||
// 密码错误记录错误次数 (TOC用户不记录)
|
||||
String header = WebUtils.getRequest().getHeader(SecurityConstants.HEADER_TOC);
|
||||
if (exception instanceof OAuth2AuthenticationException
|
||||
&& !SecurityConstants.HEADER_TOC_YES.equals(header)) {
|
||||
// 密码错误记录错误次数
|
||||
if (exception instanceof OAuth2AuthenticationException) {
|
||||
recordLoginFailureTimes(username);
|
||||
}
|
||||
|
||||
@@ -126,12 +127,17 @@ public class PigxAuthenticationFailureEventHandler implements AuthenticationFail
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录登录失败此处,如果操作阈值 调用接口锁定用户
|
||||
* @param username username
|
||||
* 记录登录失败次数,超过阈值时调用接口锁定用户。
|
||||
* <p>若系统参数 LOGIN_ERROR_TIMES {@literal <=} 0,则禁用锁定功能并清除已有失败计数 key。</p>
|
||||
* @param username 用户名
|
||||
*/
|
||||
private void recordLoginFailureTimes(String username) {
|
||||
String key = String.format("%s%s:%s", CacheConstants.GLOBALLY, CacheConstants.LOGIN_ERROR_TIMES, username);
|
||||
Long deltaTimes = ParamResolver.getLong("LOGIN_ERROR_TIMES", 5L);
|
||||
if (!isLoginFailureLockEnabled(deltaTimes)) {
|
||||
RedisUtils.delete(key);
|
||||
return;
|
||||
}
|
||||
|
||||
// 使用 RedisUtils 执行原生 Redis 命令进行递增操作
|
||||
Long times = RedisUtils.increment(key, 1L);// 增加登录失败次数
|
||||
@@ -145,6 +151,24 @@ public class PigxAuthenticationFailureEventHandler implements AuthenticationFail
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断登录失败锁定功能是否启用(参数值大于 0 表示启用)
|
||||
* @param deltaTimes 系统参数 LOGIN_ERROR_TIMES,0 或 null 表示禁用
|
||||
* @return 启用返回 true,禁用返回 false
|
||||
*/
|
||||
static boolean isLoginFailureLockEnabled(Long deltaTimes) {
|
||||
return deltaTimes != null && deltaTimes > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 写出认证失败响应。
|
||||
* <p>
|
||||
* OAuth2 认证异常携带标准错误码和描述,需要保留错误码给前端识别;其他认证异常只返回本地化错误信息。
|
||||
* @param request 当前认证请求
|
||||
* @param response 当前认证响应
|
||||
* @param exception 认证失败异常
|
||||
* @throws IOException 响应写出失败时抛出
|
||||
*/
|
||||
private void sendErrorResponse(HttpServletRequest request, HttpServletResponse response,
|
||||
AuthenticationException exception) throws IOException {
|
||||
ServletServerHttpResponse httpResponse = new ServletServerHttpResponse(response);
|
||||
@@ -153,6 +177,7 @@ public class PigxAuthenticationFailureEventHandler implements AuthenticationFail
|
||||
|
||||
if (exception instanceof OAuth2AuthenticationException) {
|
||||
OAuth2AuthenticationException authorizationException = (OAuth2AuthenticationException) exception;
|
||||
// 优先使用 OAuth2 错误描述;没有描述时回退到标准错误码。
|
||||
errorMessage = StrUtil.isBlank(authorizationException.getError().getDescription())
|
||||
? authorizationException.getError().getErrorCode()
|
||||
: authorizationException.getError().getDescription();
|
||||
@@ -163,6 +188,7 @@ public class PigxAuthenticationFailureEventHandler implements AuthenticationFail
|
||||
|
||||
}
|
||||
else {
|
||||
// 非 OAuth2 异常没有标准错误码,直接返回 Spring Security 的本地化异常消息。
|
||||
errorMessage = exception.getLocalizedMessage();
|
||||
this.errorHttpResponseConverter.write(R.failed(errorMessage), MediaType.APPLICATION_JSON, httpResponse);
|
||||
}
|
||||
|
||||
+3
-1
@@ -52,8 +52,10 @@ import java.time.temporal.ChronoUnit;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 认证成功事件处理器:处理用户认证成功后的相关操作
|
||||
*
|
||||
* @author lengleng
|
||||
* @date 2022-06-02
|
||||
* @date 2025/08/19
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
|
||||
+49
-1
@@ -17,16 +17,26 @@
|
||||
package com.pig4cloud.pigx.auth.support.handler;
|
||||
|
||||
import com.pig4cloud.pigx.admin.api.dto.SysLogDTO;
|
||||
import com.pig4cloud.pigx.common.core.constant.CacheConstants;
|
||||
import com.pig4cloud.pigx.common.core.util.WebUtils;
|
||||
import com.pig4cloud.pigx.common.data.cache.RedisUtils;
|
||||
import com.pig4cloud.pigx.common.log.util.SysLogUtils;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.cache.Cache;
|
||||
import org.springframework.cache.CacheManager;
|
||||
import org.springframework.context.ApplicationListener;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.security.authentication.event.LogoutSuccessEvent;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
|
||||
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
|
||||
import org.springframework.security.oauth2.server.authorization.OAuth2TokenType;
|
||||
import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* @author zhangran
|
||||
* @date 2022-06-02
|
||||
@@ -35,12 +45,22 @@ import org.springframework.stereotype.Component;
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class PigxLogoutSuccessEventHandler implements ApplicationListener<LogoutSuccessEvent> {
|
||||
|
||||
private final OAuth2AuthorizationService authorizationService;
|
||||
|
||||
private final CacheManager cacheManager;
|
||||
|
||||
@Override
|
||||
public void onApplicationEvent(LogoutSuccessEvent event) {
|
||||
Authentication authentication = (Authentication) event.getSource();
|
||||
if (authentication instanceof PreAuthenticatedAuthenticationToken) {
|
||||
// 来自 removeToken(),token 已删,仅记日志
|
||||
handle(authentication);
|
||||
} else {
|
||||
// 来自 Spring Security 标准登出,token 未删,需批量清理
|
||||
removeTokensByUsername(authentication.getName());
|
||||
handle(authentication);
|
||||
}
|
||||
}
|
||||
@@ -48,7 +68,6 @@ public class PigxLogoutSuccessEventHandler implements ApplicationListener<Logout
|
||||
/**
|
||||
* 处理退出成功方法
|
||||
* <p>
|
||||
* 获取到登录的authentication 对象
|
||||
* @param authentication 登录对象
|
||||
*/
|
||||
public void handle(Authentication authentication) {
|
||||
@@ -70,4 +89,33 @@ public class PigxLogoutSuccessEventHandler implements ApplicationListener<Logout
|
||||
logVo.setCreateBy(authentication.getName());
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 principal name 批量删除该用户在所有租户下的全部 token
|
||||
* <p>
|
||||
* Redis key 格式:token::username::{username}::{clientId}::{tenantId}::{tokenId}
|
||||
*
|
||||
* @param username 用户名
|
||||
*/
|
||||
private void removeTokensByUsername(String username) {
|
||||
Set<String> keys = RedisUtils.keys("token::username::" + username + "*");
|
||||
for (String keyName : keys) {
|
||||
String[] parts = keyName.split("::");
|
||||
if (parts.length < 6) {
|
||||
continue;
|
||||
}
|
||||
String tokenValue = parts[5];
|
||||
OAuth2Authorization authorization = authorizationService.findByToken(tokenValue,
|
||||
OAuth2TokenType.ACCESS_TOKEN);
|
||||
if (authorization == null) {
|
||||
continue;
|
||||
}
|
||||
Cache userCache = cacheManager.getCache(CacheConstants.USER_DETAILS);
|
||||
if (userCache != null) {
|
||||
userCache.evict(authorization.getPrincipalName());
|
||||
}
|
||||
authorizationService.remove(authorization);
|
||||
log.debug("已清理用户 {} 的 token: {}", username, tokenValue);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+1
-1
@@ -25,7 +25,7 @@ import java.io.IOException;
|
||||
@Slf4j
|
||||
public class TenantSavedRequestAwareAuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
|
||||
|
||||
private RequestCache requestCache = new HttpSessionRequestCache();
|
||||
private final RequestCache requestCache = new HttpSessionRequestCache();
|
||||
|
||||
public TenantSavedRequestAwareAuthenticationSuccessHandler() {
|
||||
}
|
||||
|
||||
+12
-11
@@ -1,15 +1,15 @@
|
||||
package com.pig4cloud.pigx.auth.support.password;
|
||||
|
||||
import com.pig4cloud.pigx.auth.support.base.OAuth2ResourceOwnerBaseAuthenticationConverter;
|
||||
import com.pig4cloud.pigx.common.core.constant.SecurityConstants;
|
||||
import com.pig4cloud.pigx.common.security.util.OAuth2EndpointUtils;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.oauth2.core.AuthorizationGrantType;
|
||||
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
|
||||
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
@@ -28,14 +28,15 @@ public class OAuth2ResourceOwnerPasswordAuthenticationConverter
|
||||
*/
|
||||
@Override
|
||||
public boolean support(String grantType) {
|
||||
return AuthorizationGrantType.PASSWORD.getValue().equals(grantType);
|
||||
return SecurityConstants.PASSWORD.equals(grantType);
|
||||
}
|
||||
|
||||
@Override
|
||||
public OAuth2ResourceOwnerPasswordAuthenticationToken buildToken(Authentication clientPrincipal,
|
||||
Set requestedScopes, Map additionalParameters) {
|
||||
return new OAuth2ResourceOwnerPasswordAuthenticationToken(AuthorizationGrantType.PASSWORD, clientPrincipal,
|
||||
requestedScopes, additionalParameters);
|
||||
return new OAuth2ResourceOwnerPasswordAuthenticationToken(
|
||||
new AuthorizationGrantType(SecurityConstants.PASSWORD), clientPrincipal, requestedScopes,
|
||||
additionalParameters);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -46,16 +47,16 @@ public class OAuth2ResourceOwnerPasswordAuthenticationConverter
|
||||
public void checkParams(HttpServletRequest request) {
|
||||
MultiValueMap<String, String> parameters = OAuth2EndpointUtils.getParameters(request);
|
||||
// username (REQUIRED)
|
||||
String username = parameters.getFirst(OAuth2ParameterNames.USERNAME);
|
||||
if (!StringUtils.hasText(username) || parameters.get(OAuth2ParameterNames.USERNAME).size() != 1) {
|
||||
OAuth2EndpointUtils.throwError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.USERNAME,
|
||||
String username = parameters.getFirst(SecurityConstants.DETAILS_USERNAME);
|
||||
if (!StringUtils.hasText(username) || parameters.get(SecurityConstants.DETAILS_USERNAME).size() != 1) {
|
||||
OAuth2EndpointUtils.throwError(OAuth2ErrorCodes.INVALID_REQUEST, SecurityConstants.DETAILS_USERNAME,
|
||||
OAuth2EndpointUtils.ACCESS_TOKEN_REQUEST_ERROR_URI);
|
||||
}
|
||||
|
||||
// password (REQUIRED)
|
||||
String password = parameters.getFirst(OAuth2ParameterNames.PASSWORD);
|
||||
if (!StringUtils.hasText(password) || parameters.get(OAuth2ParameterNames.PASSWORD).size() != 1) {
|
||||
OAuth2EndpointUtils.throwError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.PASSWORD,
|
||||
String password = parameters.getFirst(SecurityConstants.PASSWORD);
|
||||
if (!StringUtils.hasText(password) || parameters.get(SecurityConstants.PASSWORD).size() != 1) {
|
||||
OAuth2EndpointUtils.throwError(OAuth2ErrorCodes.INVALID_REQUEST, SecurityConstants.PASSWORD,
|
||||
OAuth2EndpointUtils.ACCESS_TOKEN_REQUEST_ERROR_URI);
|
||||
}
|
||||
}
|
||||
|
||||
+5
-4
@@ -1,6 +1,7 @@
|
||||
package com.pig4cloud.pigx.auth.support.password;
|
||||
|
||||
import com.pig4cloud.pigx.auth.support.base.OAuth2ResourceOwnerBaseAuthenticationProvider;
|
||||
import com.pig4cloud.pigx.common.core.constant.SecurityConstants;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
@@ -9,7 +10,6 @@ import org.springframework.security.oauth2.core.AuthorizationGrantType;
|
||||
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
|
||||
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
|
||||
import org.springframework.security.oauth2.core.OAuth2Token;
|
||||
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
|
||||
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
|
||||
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
|
||||
import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenGenerator;
|
||||
@@ -41,8 +41,8 @@ public class OAuth2ResourceOwnerPasswordAuthenticationProvider
|
||||
|
||||
@Override
|
||||
public UsernamePasswordAuthenticationToken buildToken(Map<String, Object> reqParameters) {
|
||||
String username = (String) reqParameters.get(OAuth2ParameterNames.USERNAME);
|
||||
String password = (String) reqParameters.get(OAuth2ParameterNames.PASSWORD);
|
||||
String username = (String) reqParameters.get(SecurityConstants.DETAILS_USERNAME);
|
||||
String password = (String) reqParameters.get(SecurityConstants.PASSWORD);
|
||||
return new UsernamePasswordAuthenticationToken(username, password);
|
||||
}
|
||||
|
||||
@@ -56,7 +56,8 @@ public class OAuth2ResourceOwnerPasswordAuthenticationProvider
|
||||
@Override
|
||||
public void checkClient(RegisteredClient registeredClient) {
|
||||
assert registeredClient != null;
|
||||
if (!registeredClient.getAuthorizationGrantTypes().contains(AuthorizationGrantType.PASSWORD)) {
|
||||
if (!registeredClient.getAuthorizationGrantTypes()
|
||||
.contains(new AuthorizationGrantType(SecurityConstants.PASSWORD))) {
|
||||
throw new OAuth2AuthenticationException(OAuth2ErrorCodes.UNAUTHORIZED_CLIENT);
|
||||
}
|
||||
}
|
||||
|
||||
-1
@@ -1 +0,0 @@
|
||||
com.pig4cloud.pigx.auth.endpoint.CaptchaCacheServiceProvider
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
~ Copyright (c) 2018-2026, lengleng All rights reserved.
|
||||
~ Copyright (c) 2018-2025, lengleng All rights reserved.
|
||||
~
|
||||
~ Redistribution and use in source and binary forms, with or without
|
||||
~ modification, are permitted provided that the following conditions are met:
|
||||
@@ -30,11 +30,11 @@
|
||||
<property name="CONSOLE_LOG_PATTERN"
|
||||
value="${CONSOLE_LOG_PATTERN:-%clr(%d{yyyy-MM-dd HH:mm:ss.SSS}){faint} %clr(${LOG_LEVEL_PATTERN:-%5p}) %clr(${PID:- }){magenta} %clr(---){faint} %clr([%15.15t]){faint} %clr(%-40.40logger{39}){cyan} %clr(:){faint} %m%n${LOG_EXCEPTION_CONVERSION_WORD:-%wEx}}"/>
|
||||
<!-- 彩色日志依赖的渲染类 -->
|
||||
<conversionRule conversionWord="clr" class="org.springframework.boot.logging.logback.ColorConverter"/>
|
||||
<conversionRule conversionWord="clr" class="org.springframework.boot.logging.logback.ColorConverter"/>
|
||||
<conversionRule conversionWord="wex"
|
||||
class="org.springframework.boot.logging.logback.WhitespaceThrowableProxyConverter"/>
|
||||
class="org.springframework.boot.logging.logback.WhitespaceThrowableProxyConverter"/>
|
||||
<conversionRule conversionWord="wEx"
|
||||
class="org.springframework.boot.logging.logback.ExtendedWhitespaceThrowableProxyConverter"/>
|
||||
class="org.springframework.boot.logging.logback.ExtendedWhitespaceThrowableProxyConverter"/>
|
||||
<!-- Console log output -->
|
||||
<appender name="console" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<encoder>
|
||||
|
||||
@@ -13,16 +13,17 @@
|
||||
</div>
|
||||
|
||||
<form class="form-signin" action="<#if request??>${request.contextPath!}</#if>/oauth2/form" method="post">
|
||||
<input type="hidden" name="client_id" value="pig">
|
||||
<input type="hidden" name="client_id" value="<#if authClientId??>${authClientId?html}<#else>pig</#if>">
|
||||
<input type="hidden" name="grant_type" value="password">
|
||||
<div class="space-y-6">
|
||||
|
||||
<div class="mb-4 relative">
|
||||
<#if tenantList??>
|
||||
<select class="w-full text-sm px-4 py-3 bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg focus:outline-none focus:border-purple-400 dark:focus:border-purple-500 dark:text-gray-300 transition-colors"
|
||||
id="tenant-id"
|
||||
name="TENANT-ID">
|
||||
<#list tenantList as tenant>
|
||||
<option value="${tenant.id?c}">${tenant.name?html}</option>
|
||||
<option value="${tenant.id?c}" <#if selectedTenantId?? && selectedTenantId == tenant.id>selected</#if>>${tenant.name?html}</option>
|
||||
</#list>
|
||||
</select>
|
||||
</#if>
|
||||
@@ -38,6 +39,23 @@
|
||||
class="w-full text-sm px-4 py-3 bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg focus:outline-none focus:border-purple-400 dark:focus:border-purple-500 dark:text-gray-300 transition-colors">
|
||||
</div>
|
||||
|
||||
<div id="captcha-container" class="space-y-3 <#if !(showCaptcha?? && showCaptcha)>hidden</#if>">
|
||||
<input id="random-str" type="hidden" name="randomStr">
|
||||
<div class="flex items-start gap-3">
|
||||
<input id="captcha-code" type="text" name="code" maxlength="6" inputmode="numeric" pattern="[0-9]{1,6}" autocomplete="off" placeholder="图形验证码"
|
||||
class="flex-1 h-[50px] text-sm px-4 py-3 bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg focus:outline-none focus:border-purple-400 dark:focus:border-purple-500 dark:text-gray-300 transition-colors"
|
||||
<#if showCaptcha?? && showCaptcha>required</#if>>
|
||||
<div class="w-28 shrink-0 flex flex-col items-end gap-2">
|
||||
<img id="captcha-image" alt="图形验证码"
|
||||
class="w-28 h-[50px] rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 object-cover cursor-pointer">
|
||||
<button id="refresh-captcha" type="button"
|
||||
class="text-xs text-right text-purple-600 dark:text-purple-400 hover:text-purple-700 dark:hover:text-purple-300 transition-colors">
|
||||
看不清,再换一张
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<#if error??>
|
||||
<div class="relative text-center">
|
||||
<span class="text-red-600 dark:text-red-400">${error?html}</span>
|
||||
@@ -52,6 +70,81 @@
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
const contextPath = "<#if request??>${(request.contextPath!)?js_string}</#if>";
|
||||
const tenantCaptchaEnabledMap = JSON.parse('<#if tenantCaptchaEnabledJson??>${tenantCaptchaEnabledJson?js_string}<#else>{}</#if>');
|
||||
const tenantSelect = document.getElementById('tenant-id');
|
||||
const captchaContainer = document.getElementById('captcha-container');
|
||||
const captchaImage = document.getElementById('captcha-image');
|
||||
const codeInput = document.getElementById('captcha-code');
|
||||
const randomStrInput = document.getElementById('random-str');
|
||||
const refreshButton = document.getElementById('refresh-captcha');
|
||||
const defaultTenantId = "<#if selectedTenantId??>${selectedTenantId?c?js_string}</#if>";
|
||||
|
||||
if (!captchaContainer || !captchaImage || !codeInput || !randomStrInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
function currentTenantId() {
|
||||
return tenantSelect ? tenantSelect.value : defaultTenantId;
|
||||
}
|
||||
|
||||
function captchaEnabled() {
|
||||
return Boolean(tenantCaptchaEnabledMap[currentTenantId()]);
|
||||
}
|
||||
|
||||
function clearCaptcha() {
|
||||
randomStrInput.value = '';
|
||||
codeInput.value = '';
|
||||
captchaImage.removeAttribute('src');
|
||||
}
|
||||
|
||||
function sanitizeCaptchaCode() {
|
||||
codeInput.value = codeInput.value.replace(/\D/g, '').slice(0, 6);
|
||||
}
|
||||
|
||||
function refreshCaptcha() {
|
||||
if (!captchaEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const randomStr = String(Date.now()) + Math.floor(Math.random() * 1000);
|
||||
randomStrInput.value = randomStr;
|
||||
captchaImage.src = contextPath + "/code/image?randomStr=" + encodeURIComponent(randomStr) + "&_="
|
||||
+ Date.now();
|
||||
}
|
||||
|
||||
function syncCaptcha() {
|
||||
const enabled = captchaEnabled();
|
||||
captchaContainer.classList.toggle('hidden', !enabled);
|
||||
codeInput.required = enabled;
|
||||
|
||||
if (enabled) {
|
||||
if (!randomStrInput.value) {
|
||||
refreshCaptcha();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
clearCaptcha();
|
||||
}
|
||||
|
||||
if (tenantSelect) {
|
||||
tenantSelect.addEventListener('change', () => {
|
||||
clearCaptcha();
|
||||
syncCaptcha();
|
||||
});
|
||||
}
|
||||
|
||||
codeInput.addEventListener('input', sanitizeCaptchaCode);
|
||||
refreshButton.addEventListener('click', refreshCaptcha);
|
||||
captchaImage.addEventListener('click', refreshCaptcha);
|
||||
|
||||
syncCaptcha();
|
||||
})();
|
||||
</script>
|
||||
</#assign>
|
||||
|
||||
<#include "layout/base.ftl">
|
||||
Reference in new issue
Block a user