feat(pigx-auth): 增加 SM4 国密算法支持并优化密码解密逻辑

This commit is contained in:
PIGCLOUD committed 2026-05-03 21:25:09 +08:00
1 parent 32c6e81b3a
commit c48d5f753c
21 files changed
+599 -200

No files matched your search

@@ -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()));
}
/**
@@ -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;
}
}
@@ -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];
}
}
@@ -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");
@@ -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
}
}
@@ -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()));
}
}
}
@@ -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);
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
@@ -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
@@ -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);
}
}
}
@@ -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() {
}
@@ -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);
}
}
@@ -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 +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">