在Spring Security 5中集成短信验证码 + AJ-Captcha(anji-plus/captcha) 双因素认证,且要求先通过图片验证码验证,再发送短信验证码,最后认证,需严格按顺序控制流程。以下是完整实现方案:
1. 添加依赖
<!-- Spring Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- AJ-Captcha 滑动验证码 -->
<dependency>
<groupId>com.anji-plus</groupId>
<artifactId>spring-boot-starter-captcha</artifactId>
<version>1.3.0</version>
</dependency>
<!-- 短信服务SDK(以阿里云为例) -->
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>aliyun-java-sdk-core</artifactId>
<version>4.5.0</version>
</dependency>
2. 配置AJ-Captcha
在application.yml
中配置滑动验证码参数:
aj:
captcha:
captcha-type: slider
img-width: 310
img-height: 160
expire-in: 5 # 验证码有效期5分钟
watermark: 验证码保护
aes-key: your_aes_key_123 # 可选加密密钥
3. 生成AJ-Captcha验证码
@RestController
@RequestMapping("/captcha")
public class CaptchaController {
@Autowired
private CaptchaService captchaService;
@GetMapping("/generate")
public ResponseEntity<?> generateCaptcha(HttpServletRequest request) {
// 生成唯一Token(建议用Redis存储)
String captchaToken = UUID.randomUUID().toString();
request.getSession().setAttribute("CAPTCHA_TOKEN_" + captchaToken, captchaToken);
// 返回Token给前端(用于后续校验)
Map<String, String> result = new HashMap<>();
result.put("token", captchaToken);
return ResponseEntity.ok(result);
}
@PostMapping("/validate")
public ResponseEntity<?> validateCaptcha(
@RequestParam String token,
@RequestParam String pointJson // 前端返回的滑动轨迹参数
) {
// 1. 从Redis/Session获取Token
String storedToken = (String) redisTemplate.opsForValue().get("CAPTCHA_TOKEN_" + token);
if (storedToken == null) {
return ResponseEntity.badRequest().body("验证码已过期");
}
// 2. 调用AJ-Captcha服务验证轨迹
boolean success = captchaService.validation(storedToken, pointJson);
if (!success) {
return ResponseEntity.badRequest().body("验证码错误");
}
// 3. 验证通过,清理Token
redisTemplate.delete("CAPTCHA_TOKEN_" + token);
return ResponseEntity.ok("验证成功");
}
}
4. 短信验证码服务
@Service
public class SmsService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
public void sendSms(String phone, String captchaToken) {
// 1. 先验证AJ-Captcha是否已通过(从Redis/Session检查)
String storedToken = (String) redisTemplate.opsForValue().get("CAPTCHA_TOKEN_" + captchaToken);
if (storedToken == null) {
throw new RuntimeException("请先通过图形验证码验证");
}
// 2. 生成短信验证码
String smsCode = generateSmsCode();
// 3. 调用阿里云发送短信
sendSmsToAliyun(phone, smsCode);
// 4. 存储到Redis(示例键:sms:手机号)
redisTemplate.opsForValue().set("sms:" + phone, smsCode, 5, TimeUnit.MINUTES);
}
private String generateSmsCode() {
return String.valueOf((int)((Math.random()*9+1)*100000));
}
private void sendSmsToAliyun(String phone, String code) {
// 阿里云短信服务实现(需配置AccessKey)
DefaultProfile profile = DefaultProfile.getProfile("cn-hangzhou", "AK_ID", "AK_SECRET");
IAcsClient client = new DefaultAcsClient(profile);
CommonRequest request = new CommonRequest();
request.setSysDomain("dysmsapi.aliyuncs.com");
request.setSysVersion("2017-05-25");
request.setSysAction("SendSms");
request.putQueryParameter("PhoneNumbers", phone);
request.putQueryParameter("SignName", "你的签名");
request.putQueryParameter("TemplateCode", "SMS_123456789");
request.putQueryParameter("TemplateParam", "{\"code\":\"" + code + "\"}");
client.getAcsResponse(request);
}
}
5. 自定义双因素认证流程
5.1 定义双步骤Token
// 第一步Token(仅携带手机号,待短信验证)
public class Step1Token extends AbstractAuthenticationToken {
private String phone;
// 构造方法、Getter/Setter
}
// 第二步Token(携带短信验证码)
public class Step2Token extends AbstractAuthenticationToken {
private String smsCode;
// 构造方法、Getter/Setter
}
5.2 自定义过滤器链
public class TwoFactorAuthenticationFilter extends GenericFilterBean {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
HttpServletRequest httpRequest = (HttpServletRequest) request;
String uri = httpRequest.getRequestURI();
if (uri.equals("/login/step1")) {
// 处理第一步:手机号提交(需已通过AJ-Captcha)
chain.doFilter(new Step1Filter(), response, chain);
} else if (uri.equals("/login/step2")) {
// 处理第二步:短信验证码提交
chain.doFilter(new Step2Filter(), response, chain);
} else {
chain.doFilter(request, response);
}
}
private class Step1Filter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) {
String phone = request.getParameter("phone");
String captchaToken = request.getParameter("captchaToken"); // 前端需传递此Token
// 1. 验证AJ-Captcha是否已通过(从Redis/Session检查)
String storedToken = (String) redisTemplate.opsForValue().get("CAPTCHA_TOKEN_" + captchaToken);
if (storedToken == null) {
throw new BadCredentialsException("请先通过图形验证码验证");
}
// 2. 创建临时Token(标记第一步通过)
Step1Token token = new Step1Token(phone);
token.setAuthenticated(true);
SecurityContextHolder.getContext().setAuthentication(token);
// 3. 继续过滤器链(可跳转到发送短信页面)
filterChain.doFilter(request, response);
}
}
private class Step2Filter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) {
String smsCode = request.getParameter("smsCode");
Authentication currentAuth = SecurityContextHolder.getContext().getAuthentication();
String phone = currentAuth.getName(); // 从第一步Token获取手机号
// 1. 验证短信验证码
String storedCode = redisTemplate.opsForValue().get("sms:" + phone);
if (!smsCode.equals(storedCode)) {
throw new BadCredentialsException("短信验证码错误");
}
// 2. 认证成功,创建最终Token
UsernamePasswordAuthenticationToken finalToken =
new UsernamePasswordAuthenticationToken(phone, null, AuthorityUtils.NO_AUTHORITIES);
SecurityContextHolder.getContext().setAuthentication(finalToken);
// 3. 继续过滤器链
filterChain.doFilter(request, response);
}
}
}
6. 安全配置
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/captcha/generate", "/captcha/validate", "/sms/send").permitAll()
.anyRequest().authenticated()
.and()
.addFilterBefore(twoFactorAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
.csrf().disable(); // 实际生产环境需启用CSRF保护
}
@Bean
public TwoFactorAuthenticationFilter twoFactorAuthenticationFilter() {
return new TwoFactorAuthenticationFilter();
}
}
7. 前端交互流程
-
获取AJ-Captcha配置:
- 调用
/captcha/generate
获取Token。 - 初始化AJ-Captcha组件(需传递Token)。
- 调用
-
用户滑动验证:
- 验证通过后,调用
/captcha/validate
提交轨迹参数。
- 验证通过后,调用
-
提交手机号:
- 调用
/login/step1
(携带手机号和AJ-Captcha Token)。
- 调用
-
发送短信验证码:
- 调用
/sms/send
(需携带AJ-Captcha Token,确保已通过图形验证)。
- 调用
-
提交短信验证码:
- 调用
/login/step2
完成最终认证。
- 调用
关键注意事项
-
Token生命周期:
- AJ-Captcha的Token在验证后需立即清理(Redis/Session)。
- 短信验证码Token需与手机号绑定,防止重复使用。
-
安全增强:
- 限制短信发送频率(如1分钟/次)。
- 登录失败锁定账号(需自定义
UserDetailsService
)。
-
日志记录:
- 记录所有验证码请求和认证尝试,便于审计。
通过以上步骤,即可在Spring Security 5中实现严格的AJ-Captcha + 短信验证码双因素认证流程。