fix(auth): 隔离验证码缓存并强制校验短信登录凭据

(cherry picked from commit 9468a3efae7c9b678d074697eced50a175bea1f6)
This commit is contained in:
PIGCLOUD committed 2026-11-03 14:56:26 +08:00
1 parent 0974a56294
commit b003a1b444
9 files changed
+229 -51

No files matched your search

@@ -59,7 +59,7 @@ public class AppMobileServiceImpl implements AppMobileService {
*/
@Override
public R<Boolean> sendSmsCode(String mobile) {
String codeObj = RedisUtils.get(CacheConstants.DEFAULT_CODE_KEY + LoginTypeEnum.APPSMS.getType() + StringPool.AT + mobile);
String codeObj = RedisUtils.get(CacheConstants.SMS_CODE_KEY + LoginTypeEnum.APPSMS.getType() + StringPool.AT + mobile);
if (StrUtil.isNotBlank(codeObj)) {
log.info("手机号验证码未过期:{},{}", mobile, codeObj);
@@ -68,7 +68,7 @@ public class AppMobileServiceImpl implements AppMobileService {
String code = RandomUtil.randomNumbers(Integer.parseInt(SecurityConstants.CODE_SIZE));
log.debug("手机号生成验证码成功:{},{}", mobile, code);
RedisUtils.set(CacheConstants.DEFAULT_CODE_KEY + LoginTypeEnum.APPSMS.getType() + StringPool.AT + mobile, code, SecurityConstants.CODE_TIME);
RedisUtils.set(CacheConstants.SMS_CODE_KEY + LoginTypeEnum.APPSMS.getType() + StringPool.AT + mobile, code, SecurityConstants.CODE_TIME);
return R.ok(Boolean.TRUE);
}
@@ -50,7 +50,7 @@ public class ImageCodeEndpoint {
return;
}
RedisUtils.set(CacheConstants.DEFAULT_CODE_KEY + randomStr, result, SecurityConstants.CODE_TIME);
RedisUtils.set(CacheConstants.IMAGE_CODE_KEY + randomStr, result, SecurityConstants.CODE_TIME);
// 转换流信息写出
captcha.out(response.getOutputStream());
}
@@ -6,6 +6,7 @@ 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.constant.enums.LoginTypeEnum;
import com.pig4cloud.pigx.common.core.exception.ValidateCodeException;
import com.pig4cloud.pigx.common.core.util.MsgUtils;
import com.pig4cloud.pigx.common.data.cache.RedisUtils;
@@ -75,7 +76,7 @@ public class AuthCaptchaSupport {
*/
public boolean isCaptchaEnabled(String tenantId, String clientId) {
if (StrUtil.isBlank(clientId)) {
return false;
return true;
}
String finalTenantId = StrUtil.isBlank(tenantId) ? CommonConstants.TENANT_ID_1.toString() : tenantId;
@@ -103,12 +104,6 @@ public class AuthCaptchaSupport {
}
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;
@@ -122,6 +117,36 @@ public class AuthCaptchaSupport {
}
}
/**
* 按完整类型前缀判断是否使用短信登录,不校验手机号格式或验证码。
*
* @param mobile 登录标识,格式为 {@code SMS@手机号} 或 {@code APP-SMS@手机号};允许为 null
* @return 匹配短信类型前缀时为 true;null、空串或其他类型返回 false
*/
public boolean isSmsLogin(String mobile) {
return StrUtil.startWith(mobile, LoginTypeEnum.SMS.getType() + StrUtil.AT)
|| StrUtil.startWith(mobile, LoginTypeEnum.APPSMS.getType() + StrUtil.AT);
}
/**
* 校验请求中的短信身份凭据,不接受图形或行为验证码作为替代。
* 类型合法且验证码非空时委托缓存校验;读取缓存后无论匹配与否均消费该验证码。
*
* @param request 非 null 请求,mobile 参数为 {@code SMS@手机号} 或
* {@code APP-SMS@手机号},code 参数为短信验证码
* @throws ValidateCodeException 验证码为空、登录类型不合法,或验证码不存在、过期、不匹配
*/
public void validateSmsCode(HttpServletRequest request) {
String mobile = request.getParameter("mobile");
String code = request.getParameter("code");
if (StrUtil.isBlank(code)) {
throw new ValidateCodeException(MsgUtils.getMessage(AuthErrorCodes.AUTH_CAPTCHA_EMPTY));
}
if (!isSmsLogin(mobile) || !captchaValidator.validateSms(mobile, code).isOk()) {
throw new ValidateCodeException(MsgUtils.getMessage(AuthErrorCodes.AUTH_CAPTCHA_INVALID));
}
}
private String resolveAuthorizationParameter(HttpServletRequest request, HttpServletResponse response,
String parameterName, boolean includeCurrentRequestId) {
if (includeCurrentRequestId) {
@@ -12,20 +12,22 @@ 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.exception.ValidateCodeException;
import com.pig4cloud.pigx.common.core.util.WebUtils;
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.http.HttpHeaders;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientAuthenticationToken;
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.Objects;
/**
* @author lbw
@@ -54,45 +56,30 @@ public class ValidateCodeFilter extends OncePerRequestFilter {
// 如果登录URL 但是刷新token的请求,直接向下执行
String grantType = request.getParameter(OAuth2ParameterNames.GRANT_TYPE);
if (StrUtil.containsAny(grantType, SecurityConstants.REFRESH_TOKEN)) {
if (StrUtil.equals(grantType, SecurityConstants.REFRESH_TOKEN)) {
filterChain.doFilter(request, response);
return;
}
// mobile模式, 如果请求不包含mobile 参数直接
String mobile = request.getParameter(SecurityConstants.GRANT_MOBILE);
if (StrUtil.equals(SecurityConstants.GRANT_MOBILE, grantType) && StrUtil.isBlank(mobile)) {
throw new OAuth2AuthenticationException(SecurityConstants.GRANT_MOBILE);
}
// mobile模式, 社交登录模式不校验验证码直接跳过
if (StrUtil.equals(SecurityConstants.GRANT_MOBILE, grantType) && !StrUtil.contains(mobile, "SMS")) {
filterChain.doFilter(request, response);
return;
}
// 判断客户端是否跳过检验
if (!isCheckCaptchaClient(request)) {
filterChain.doFilter(request, response);
return;
}
// 校验验证码 1. 客户端开启验证码 2. 短信模式
try {
checkCode();
if (StrUtil.equals(SecurityConstants.GRANT_MOBILE, grantType)) {
String mobile = request.getParameter(SecurityConstants.GRANT_MOBILE);
if (StrUtil.isBlank(mobile)) {
throw new OAuth2AuthenticationException(SecurityConstants.GRANT_MOBILE);
}
// 短信是身份凭据,不能随客户端图形验证码开关关闭。
if (authCaptchaSupport.isSmsLogin(mobile)) {
authCaptchaSupport.validateSmsCode(request);
}
} else if (isCheckCaptchaClient(request)) {
authCaptchaSupport.validateCode(request);
}
filterChain.doFilter(request, response);
} catch (ValidateCodeException validateCodeException) {
throw new OAuth2AuthenticationException(validateCodeException.getMessage());
}
}
/**
* 校验验证码
*/
private void checkCode() throws ValidateCodeException {
authCaptchaSupport.validateCode(WebUtils.getRequest());
}
/**
* 是否需要校验客户端,根据client 查询客户端配置
*
@@ -100,8 +87,12 @@ public class ValidateCodeFilter extends OncePerRequestFilter {
* @return true 需要校验, false 不需要校验
*/
private boolean isCheckCaptchaClient(HttpServletRequest request) {
String header = request.getHeader(HttpHeaders.AUTHORIZATION);
String clientId = WebUtils.extractClientId(header).orElse(null);
// 客户端认证过滤器在本过滤器之前执行,兼容 Basic 和 client_secret_post,
// 不信任额外传入的 client_id 来决定验证码策略。
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
String clientId = authentication instanceof OAuth2ClientAuthenticationToken client
&& client.isAuthenticated() && Objects.nonNull(client.getRegisteredClient())
? client.getRegisteredClient().getClientId() : null;
// 获取租户拼接区分租户的key
String tenantId = request.getHeader(CommonConstants.TENANT_ID);
return authCaptchaSupport.isCaptchaEnabled(tenantId, clientId);
@@ -0,0 +1,139 @@
package com.pig4cloud.pigx.auth.support.filter;
import com.pig4cloud.pigx.auth.endpoint.ImageCodeEndpoint;
import com.pig4cloud.pigx.auth.support.core.AuthCaptchaSupport;
import com.pig4cloud.pigx.common.core.constant.CacheConstants;
import com.pig4cloud.pigx.common.core.util.SpringContextHolder;
import com.pig4cloud.pigx.common.security.captcha.CaptchaValidator;
import jakarta.servlet.FilterChain;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.springframework.beans.factory.support.DefaultListableBeanFactory;
import org.springframework.context.support.StaticMessageSource;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientAuthenticationToken;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class LoginCaptchaSecurityTests {
private final Map<String, Object> cache = new HashMap<>();
private final RedisTemplate<String, Object> redis = mock(RedisTemplate.class);
private final ValueOperations<String, Object> values = mock(ValueOperations.class);
private final ValidateCodeFilter filter = new ValidateCodeFilter(
new AuthCaptchaSupport(new CaptchaValidator(null, redis)));
private final MockHttpServletRequest request = new MockHttpServletRequest("POST", "/oauth2/token");
private final MockHttpServletResponse response = new MockHttpServletResponse();
private final FilterChain chain = mock(FilterChain.class);
@BeforeEach
void setup() {
when(redis.opsForValue()).thenReturn(values);
when(values.get(anyString())).thenAnswer(invocation -> cache.get(invocation.getArgument(0)));
when(values.getAndDelete(anyString())).thenAnswer(invocation -> cache.remove(invocation.getArgument(0)));
doAnswer(invocation -> {
cache.put(invocation.getArgument(0), invocation.getArgument(1));
return null;
}).when(values).set(anyString(), any(), anyLong(), any(TimeUnit.class));
DefaultListableBeanFactory factory = new DefaultListableBeanFactory();
factory.registerSingleton("redisTemplate", redis);
StaticMessageSource messages = new StaticMessageSource();
messages.setUseCodeAsDefaultMessage(true);
factory.registerSingleton("pigxMessageSource", messages);
new SpringContextHolder().postProcessBeanFactory(factory);
request.setServletPath("/oauth2/token");
request.addHeader("TENANT-ID", "1");
}
@AfterEach
void cleanup() {
SecurityContextHolder.clearContext();
new SpringContextHolder().postProcessBeanFactory(null);
SpringContextHolder.clearHolder();
}
static Stream<Arguments> validCases() {
return Stream.of(Arguments.of("SMS", false, false), Arguments.of("SMS", true, false),
Arguments.of("SMS", false, true), Arguments.of("SMS", true, true),
Arguments.of("APP-SMS", true, true), Arguments.of("image", false, true),
Arguments.of("image", true, true));
}
@ParameterizedTest
@MethodSource("validCases")
void acceptsOnlyTheExpectedCaptchaAndConsumesIt(String type, boolean post, boolean enabled) throws Exception {
configureClient(post, enabled);
String key;
if (type.equals("image")) {
request.setParameter("grant_type", "password");
request.setParameter("randomStr", "image-test");
new ImageCodeEndpoint().image("image-test", response);
key = CacheConstants.IMAGE_CODE_KEY + "image-test";
} else {
request.setParameter("grant_type", "mobile");
request.setParameter("mobile", type + "@13800138000");
key = CacheConstants.SMS_CODE_KEY + type + "@13800138000";
cache.put(key, "654321");
}
request.setParameter("code", cache.get(key).toString());
filter.doFilter(request, response, chain);
verify(chain).doFilter(request, response);
assertThat(cache).doesNotContainKey(key);
}
static Stream<Arguments> invalidCases() {
Stream<Arguments> smsCases = Stream.of(false, true).flatMap(post -> Stream.of(false, true).flatMap(enabled ->
Stream.of("missing", "image", "legacy").map(kind -> Arguments.of(post, enabled, kind))));
return Stream.concat(smsCases, Stream.of(Arguments.of(false, true, "password"),
Arguments.of(true, true, "password")));
}
@ParameterizedTest
@MethodSource("invalidCases")
void rejectsMissingOrSubstitutedIdentityProof(boolean post, boolean enabled, String kind) throws Exception {
configureClient(post, enabled);
String mobile = "SMS@13800138000";
request.setParameter("grant_type", kind.equals("password") ? "password" : "mobile");
request.setParameter("mobile", mobile);
if (kind.equals("image")) {
new ImageCodeEndpoint().image(mobile, response);
assertThat(cache).doesNotContainKey(CacheConstants.SMS_CODE_KEY + mobile);
request.setParameter("code", cache.get(CacheConstants.IMAGE_CODE_KEY + mobile).toString());
} else if (kind.equals("legacy")) {
cache.put(CacheConstants.DEFAULT_CODE_KEY + mobile, "123456");
request.setParameter("code", "123456");
}
assertThatThrownBy(() -> filter.doFilter(request, response, chain))
.isInstanceOf(OAuth2AuthenticationException.class);
verifyNoInteractions(chain);
}
private void configureClient(boolean post, boolean enabled) {
RegisteredClient client = RegisteredClient.withId("test-client").clientId("test-client")
.authorizationGrantType(new AuthorizationGrantType("password")).build();
SecurityContextHolder.getContext().setAuthentication(new OAuth2ClientAuthenticationToken(client,
post ? ClientAuthenticationMethod.CLIENT_SECRET_POST : ClientAuthenticationMethod.CLIENT_SECRET_BASIC,
"test-secret"));
// 策略只取已认证客户端,不使用请求额外指定的客户端。
request.setParameter("client_id", "untrusted-client");
cache.put("1:client_config_flag:test-client", "{\"captcha_flag\":\"" + (enabled ? "1" : "0") + "\"}");
cache.put("1:client_config_flag:untrusted-client", "{\"captcha_flag\":\"0\"}");
}
}
@@ -20,6 +20,12 @@ public interface CacheConstants {
*/
String DEFAULT_CODE_KEY = "DEFAULT_CODE_KEY:";
/** 图形验证码独立命名空间,不能写入短信验证码缓存。 */
String IMAGE_CODE_KEY = "IMAGE_CODE_KEY:";
/** 短信验证码独立命名空间,后接登录类型与手机号。 */
String SMS_CODE_KEY = "SMS_CODE_KEY:";
/**
* 菜单信息缓存
*/
@@ -68,14 +68,31 @@ public class CaptchaValidator {
return CaptchaResult.failed("验证码参数不能为空");
}
String key = CacheConstants.DEFAULT_CODE_KEY + randomStr;
Object codeObj = redisTemplate.opsForValue().get(key);
return validateCachedCode(CacheConstants.IMAGE_CODE_KEY + randomStr, code);
}
/**
* 从短信专用缓存校验身份凭据,不读取图形或行为验证码缓存。
* 非空参数触发原子读取并删除,无论匹配与否均消费该缓存中的验证码。
*
* @param mobile 短信登录标识,格式为类型@手机号;调用方负责校验允许的登录类型
* @param code 待校验的短信验证码
* @return 匹配时返回成功;任一参数为 null、空白,或验证码不存在、过期、不匹配时返回失败;
* 参数为空时不访问缓存
*/
public CaptchaResult validateSms(String mobile, String code) {
if (StrUtil.hasBlank(mobile, code)) {
return CaptchaResult.failed("验证码参数不能为空");
}
return validateCachedCode(CacheConstants.SMS_CODE_KEY + mobile, code);
}
private CaptchaResult validateCachedCode(String key, String code) {
Object codeObj = redisTemplate.opsForValue().getAndDelete(key);
if (codeObj == null || StrUtil.isBlank(codeObj.toString()) || !StrUtil.equals(codeObj.toString(), code)) {
redisTemplate.delete(key);
return CaptchaResult.failed("验证码错误或已过期");
}
redisTemplate.delete(key);
return CaptchaResult.ok();
}
@@ -329,7 +329,7 @@ public class SysMessageServiceImpl extends ServiceImpl<SysMessageMapper, SysMess
}
String codeObj = RedisUtils
.get(CacheConstants.DEFAULT_CODE_KEY + LoginTypeEnum.SMS.getType() + StringPool.AT + mobile);
.get(CacheConstants.SMS_CODE_KEY + LoginTypeEnum.SMS.getType() + StringPool.AT + mobile);
if (StrUtil.isNotBlank(codeObj)) {
log.info("手机号验证码未过期:{},{}", mobile, codeObj);
@@ -338,7 +338,7 @@ public class SysMessageServiceImpl extends ServiceImpl<SysMessageMapper, SysMess
String code = RandomUtil.randomNumbers(Integer.parseInt(SecurityConstants.CODE_SIZE));
log.info("手机号生成验证码成功:{},{}", mobile, code);
RedisUtils.set(CacheConstants.DEFAULT_CODE_KEY + LoginTypeEnum.SMS.getType() + StringPool.AT + mobile, code,
RedisUtils.set(CacheConstants.SMS_CODE_KEY + LoginTypeEnum.SMS.getType() + StringPool.AT + mobile, code,
SecurityConstants.CODE_TIME);
// 发送
@@ -332,7 +332,7 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> impl
// 校验手机号
if (StrUtil.isNotBlank(userDto.getPhone())) {
Object codeObj = RedisUtils.get(CacheConstants.DEFAULT_CODE_KEY + LoginTypeEnum.SMS.getType()
Object codeObj = RedisUtils.get(CacheConstants.SMS_CODE_KEY + LoginTypeEnum.SMS.getType()
+ StringPool.AT + userDto.getPhone());
if (!StrUtil.equals(userDto.getCode(), codeObj.toString())) {
return R.failed(MsgUtils.getMessage(UpmsErrorCodes.SYS_APP_SMS_ERROR));
@@ -608,7 +608,7 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> impl
return R.failed(MsgUtils.getMessage(UpmsErrorCodes.SYS_PARAM_ILLEGAL));
}
String codeObj = RedisUtils
.get(CacheConstants.DEFAULT_CODE_KEY + LoginTypeEnum.SMS.getType() + StringPool.AT + userDto.getPhone());
.get(CacheConstants.SMS_CODE_KEY + LoginTypeEnum.SMS.getType() + StringPool.AT + userDto.getPhone());
if (!StrUtil.equals(codeObj, userDto.getCode())) {
return R.failed(MsgUtils.getMessage(UpmsErrorCodes.SYS_APP_SMS_ERROR));
}
@@ -773,7 +773,7 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> impl
}
String codeObj = RedisUtils
.get(CacheConstants.DEFAULT_CODE_KEY + LoginTypeEnum.SMS.getType() + StringPool.AT + userDto.getPhone());
.get(CacheConstants.SMS_CODE_KEY + LoginTypeEnum.SMS.getType() + StringPool.AT + userDto.getPhone());
if (!StrUtil.equals(codeObj, code)) {
return R.failed(MsgUtils.getMessage(UpmsErrorCodes.SYS_APP_SMS_ERROR));
}