Skip to content

Commit 91c3275

Browse files
authored
Merge pull request #52 from yyyCode/feat/slider-captcha
feat(security): 发码接口接入滑动验证码(自研加固)
2 parents c87bc6c + ddbbc22 commit 91c3275

21 files changed

Lines changed: 1905 additions & 75 deletions

‎OpenBlog-business/src/main/java/com/yqz/openblog/config/AuthSecurityProperties.java‎

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,15 +45,46 @@ public void setEmailCode(EmailCode emailCode) {
4545
this.emailCode = emailCode;
4646
}
4747

48+
/**
49+
* 滑动验证码:服务端随机生成缺口图,客户端算出缺口 x 并提交拖动轨迹。
50+
* 强度上限为「拦掉不做图像分析的脚本、抬高随手写脚本的成本」——缺口位置存在 Redis,
51+
* 客户端只能从像素里解出来;而会写局部统计或接缝分析的人仍能解出,这是滑块验证码这一
52+
* 形态的固有上限。真正的门禁是坐标容差 + 一次性消费 + 网关限流,轨迹阈值一律<b>偏向不误伤真人</b>。
53+
*/
4854
public static class Slider {
4955
/**
50-
* 是否要求登录/注册前先完成「滑到尽头」验证(Redis 记录一次性凭证)。
56+
* 是否要求发码前先完成滑动验证(Redis 记录一次性凭证)。
5157
*/
5258
private boolean enabled = false;
5359
/**
5460
* challenge / 通过标记有效时间(秒)。
5561
*/
5662
private int ttlSeconds = 300;
63+
/**
64+
* 允许的落点误差(像素)。容差 6px / 随机空间 176px → 盲猜单次命中率约 3.4%。
65+
*/
66+
private int tolerancePx = 6;
67+
/**
68+
* 拖动最短耗时(毫秒),低于此值判为机器瞬移。
69+
* <p>
70+
* 刻意取 100 而非更高:滑块最远要拖 272 图像像素(约 340 CSS px),快甩一下就能在
71+
* 200ms 内完成,阈值取 200 会误伤真实用户。100ms 仍远高于脚本瞬移的耗时。
72+
*/
73+
private int minDurationMs = 100;
74+
/**
75+
* 拖动最长耗时(毫秒),超时判为非人工(也防慢速重放)。
76+
*/
77+
private int maxDurationMs = 30000;
78+
/**
79+
* 轨迹最少采样点数。
80+
*/
81+
private int minTrailPoints = 5;
82+
/**
83+
* 速度变异系数下限。取相邻采样点间平均速度(px/ms)序列,要求 标准差/均值 >= 本值,
84+
* 即拒绝完全匀速的「机器直线」。启发式阈值,非强保证——前端采样位置取整后,恒速拖动
85+
* 的速度也会自然出现起伏,故它只拦得住数学上完美匀速的提交。
86+
*/
87+
private double minSpeedCv = 0.05;
5788

5889
public boolean isEnabled() {
5990
return enabled;
@@ -70,6 +101,46 @@ public int getTtlSeconds() {
70101
public void setTtlSeconds(int ttlSeconds) {
71102
this.ttlSeconds = ttlSeconds;
72103
}
104+
105+
public int getTolerancePx() {
106+
return tolerancePx;
107+
}
108+
109+
public void setTolerancePx(int tolerancePx) {
110+
this.tolerancePx = tolerancePx;
111+
}
112+
113+
public int getMinDurationMs() {
114+
return minDurationMs;
115+
}
116+
117+
public void setMinDurationMs(int minDurationMs) {
118+
this.minDurationMs = minDurationMs;
119+
}
120+
121+
public int getMaxDurationMs() {
122+
return maxDurationMs;
123+
}
124+
125+
public void setMaxDurationMs(int maxDurationMs) {
126+
this.maxDurationMs = maxDurationMs;
127+
}
128+
129+
public int getMinTrailPoints() {
130+
return minTrailPoints;
131+
}
132+
133+
public void setMinTrailPoints(int minTrailPoints) {
134+
this.minTrailPoints = minTrailPoints;
135+
}
136+
137+
public double getMinSpeedCv() {
138+
return minSpeedCv;
139+
}
140+
141+
public void setMinSpeedCv(double minSpeedCv) {
142+
this.minSpeedCv = minSpeedCv;
143+
}
73144
}
74145

75146
public static class LoginLockout {

‎OpenBlog-business/src/main/java/com/yqz/openblog/user/controller/AuthController.java‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,14 +45,15 @@ public ApiResponse<SliderChallengeResponse> sliderChallenge(HttpServletRequest r
4545
public ApiResponse<Void> sliderComplete(
4646
@RequestBody @Valid SliderCompleteRequest req,
4747
HttpServletRequest request) {
48-
sliderVerificationService.complete(request, req.getChallengeId());
48+
sliderVerificationService.complete(request, req);
4949
return ApiResponse.ok();
5050
}
5151

5252
@PostMapping("/auth/email-code")
5353
public ApiResponse<EmailCodeResponse> emailCode(@RequestBody @Valid EmailCodeRequest req) {
5454
EmailCodeResponse resp = new EmailCodeResponse();
55-
resp.setCooldownSeconds(emailCodeService.sendCode(req.getEmail(), req.getPurpose()));
55+
resp.setCooldownSeconds(emailCodeService.sendCode(
56+
req.getEmail(), req.getPurpose(), req.getSliderChallengeId()));
5657
return ApiResponse.ok(resp);
5758
}
5859

‎OpenBlog-business/src/main/java/com/yqz/openblog/user/dto/EmailCodeRequest.java‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ public class EmailCodeRequest {
2020

2121
private String purpose;
2222

23+
/**
24+
* 滑动验证通过后的一次性凭证。滑块开关关闭时可不传(服务端直接放行)。
25+
*/
26+
private String sliderChallengeId;
27+
2328
public String getEmail() {
2429
return email;
2530
}
@@ -35,4 +40,12 @@ public String getPurpose() {
3540
public void setPurpose(String purpose) {
3641
this.purpose = purpose;
3742
}
43+
44+
public String getSliderChallengeId() {
45+
return sliderChallengeId;
46+
}
47+
48+
public void setSliderChallengeId(String sliderChallengeId) {
49+
this.sliderChallengeId = sliderChallengeId;
50+
}
3851
}

‎OpenBlog-business/src/main/java/com/yqz/openblog/user/dto/LoginRequest.java‎

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,6 @@ public class LoginRequest {
99
@NotBlank
1010
private String password;
1111

12-
/**
13-
* 滑动验证通过后得到的 challengeId({@code openblog.auth-security.slider.enabled=false} 时可不传)。
14-
*/
15-
private String sliderChallengeId;
16-
1712
public String getAccount() {
1813
return account;
1914
}
@@ -29,13 +24,5 @@ public String getPassword() {
2924
public void setPassword(String password) {
3025
this.password = password;
3126
}
32-
33-
public String getSliderChallengeId() {
34-
return sliderChallengeId;
35-
}
36-
37-
public void setSliderChallengeId(String sliderChallengeId) {
38-
this.sliderChallengeId = sliderChallengeId;
39-
}
4027
}
4128

‎OpenBlog-business/src/main/java/com/yqz/openblog/user/dto/RegisterRequest.java‎

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,6 @@ public class RegisterRequest {
2929
@Pattern(regexp = "^\\d{6}$", message = "验证码格式不正确")
3030
private String code;
3131

32-
private String sliderChallengeId;
33-
3432
public String getUsername() {
3533
return username;
3634
}
@@ -62,13 +60,5 @@ public String getCode() {
6260
public void setCode(String code) {
6361
this.code = code;
6462
}
65-
66-
public String getSliderChallengeId() {
67-
return sliderChallengeId;
68-
}
69-
70-
public void setSliderChallengeId(String sliderChallengeId) {
71-
this.sliderChallengeId = sliderChallengeId;
72-
}
7363
}
7464

‎OpenBlog-business/src/main/java/com/yqz/openblog/user/dto/SliderChallengeResponse.java‎

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,21 @@
11
package com.yqz.openblog.user.dto;
22

33
/**
4-
* 滑动验证:先拉取 challengeId,滑到尽头后调用 {@code /auth/slider-complete},再登录时带上同一 id。
4+
* 滑动验证:拉取 challengeId 与图片,拖动完成后调用 {@code /auth/slider-complete} 提交落点与轨迹。
5+
* <p>
6+
* {@code enabled=false} 时其余字段均为 null,前端据此不渲染滑块直接发码。
7+
* 缺口真值坐标<b>不在响应中</b>——只存服务端 Redis,客户端需从底图像素自行解出。
58
*/
69
public class SliderChallengeResponse {
710

811
private boolean enabled;
912
private String challengeId;
13+
/** 带缺口的底图,data URI。 */
14+
private String background;
15+
/** 滑块块图,data URI。 */
16+
private String slider;
17+
/** 滑块块的初始纵坐标(缺口 y 与之一致,故纵向无需拖动)。 */
18+
private Integer sliderY;
1019

1120
public boolean isEnabled() {
1221
return enabled;
@@ -23,4 +32,28 @@ public String getChallengeId() {
2332
public void setChallengeId(String challengeId) {
2433
this.challengeId = challengeId;
2534
}
35+
36+
public String getBackground() {
37+
return background;
38+
}
39+
40+
public void setBackground(String background) {
41+
this.background = background;
42+
}
43+
44+
public String getSlider() {
45+
return slider;
46+
}
47+
48+
public void setSlider(String slider) {
49+
this.slider = slider;
50+
}
51+
52+
public Integer getSliderY() {
53+
return sliderY;
54+
}
55+
56+
public void setSliderY(Integer sliderY) {
57+
this.sliderY = sliderY;
58+
}
2659
}

‎OpenBlog-business/src/main/java/com/yqz/openblog/user/dto/SliderCompleteRequest.java‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,69 @@
22

33
import jakarta.validation.constraints.NotBlank;
44

5+
import java.util.List;
6+
7+
/**
8+
* 滑动验证提交:落点 x 与拖动轨迹。
9+
* <p>
10+
* {@code x} 用包装类型而非 {@code int}:缺字段时得到 4001「请先完成滑动验证」,
11+
* 与其它滑块失败同码,而不是落到参数校验的通用错误码上。轨迹各字段同理。
12+
*/
513
public class SliderCompleteRequest {
614

715
@NotBlank
816
private String challengeId;
917

18+
/** 滑块最终横向落点(像素,原点为底图左边缘)。 */
19+
private Integer x;
20+
21+
/** 拖动采样轨迹,按时间递增。用于识别"算出答案后瞬移过去"的机器行为。 */
22+
private List<TrailPoint> trail;
23+
1024
public String getChallengeId() {
1125
return challengeId;
1226
}
1327

1428
public void setChallengeId(String challengeId) {
1529
this.challengeId = challengeId;
1630
}
31+
32+
public Integer getX() {
33+
return x;
34+
}
35+
36+
public void setX(Integer x) {
37+
this.x = x;
38+
}
39+
40+
public List<TrailPoint> getTrail() {
41+
return trail;
42+
}
43+
44+
public void setTrail(List<TrailPoint> trail) {
45+
this.trail = trail;
46+
}
47+
48+
/** 轨迹采样点:{@code t} 为相对拖动开始的毫秒偏移。 */
49+
public static class TrailPoint {
50+
51+
private Integer x;
52+
private Long t;
53+
54+
public Integer getX() {
55+
return x;
56+
}
57+
58+
public void setX(Integer x) {
59+
this.x = x;
60+
}
61+
62+
public Long getT() {
63+
return t;
64+
}
65+
66+
public void setT(Long t) {
67+
this.t = t;
68+
}
69+
}
1770
}

‎OpenBlog-business/src/main/java/com/yqz/openblog/user/service/AuthService.java‎

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,6 @@ public class AuthService {
4545
private final JwtService jwtService;
4646
private final com.yqz.openblog.security.JwtProperties jwtProperties;
4747
private final CurrentUser currentUser;
48-
private final SliderVerificationService sliderVerificationService;
4948
private final LoginLockoutService loginLockoutService;
5049
private final AccountDeviceService accountDeviceService;
5150
private final MediaService mediaService;
@@ -65,7 +64,6 @@ public AuthService(UserMapper userMapper,
6564
JwtService jwtService,
6665
com.yqz.openblog.security.JwtProperties jwtProperties,
6766
CurrentUser currentUser,
68-
SliderVerificationService sliderVerificationService,
6967
LoginLockoutService loginLockoutService,
7068
AccountDeviceService accountDeviceService,
7169
MediaService mediaService,
@@ -77,7 +75,6 @@ public AuthService(UserMapper userMapper,
7775
this.jwtService = jwtService;
7876
this.jwtProperties = jwtProperties;
7977
this.currentUser = currentUser;
80-
this.sliderVerificationService = sliderVerificationService;
8178
this.loginLockoutService = loginLockoutService;
8279
this.accountDeviceService = accountDeviceService;
8380
this.mediaService = mediaService;
@@ -88,7 +85,6 @@ public AuthService(UserMapper userMapper,
8885
}
8986

9087
public AuthResponse register(RegisterRequest req) {
91-
sliderVerificationService.verifyAndConsume(req.getSliderChallengeId());
9288
// 用户名归一化:去掉首尾空白(DTO 层 @Pattern 已禁空白字符,此处为绕过校验的内部调用兜底)
9389
String username = req.getUsername() == null ? null : req.getUsername().trim();
9490

@@ -141,7 +137,6 @@ public AuthResponse login(LoginRequest req) {
141137
String fp = currentDeviceFingerprint();
142138
loginLockoutService.assertNotLocked(ipSeg);
143139
loginLockoutService.assertNotDeviceLocked(fp);
144-
sliderVerificationService.verifyAndConsume(req.getSliderChallengeId());
145140

146141
User user = userMapper.selectOne(Wrappers.lambdaQuery(User.class).eq(User::getUsername, req.getAccount()));
147142
if (user == null) {

‎OpenBlog-business/src/main/java/com/yqz/openblog/user/service/EmailCodeService.java‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ public class EmailCodeService {
5252
private final AuthSecurityProperties authSecurityProperties;
5353
private final UserMapper userMapper;
5454
private final EmailValidator emailValidator;
55+
private final SliderVerificationService sliderVerificationService;
5556

5657
/** Dubbo 调统一通知服务。retries=0:同步链路 messageId 为空 → 渠道每次生成新幂等键,重试会重复发信。 */
5758
@DubboReference(retries = 0, timeout = 5000)
@@ -60,21 +61,28 @@ public class EmailCodeService {
6061
public EmailCodeService(RedisOps redisOps,
6162
AuthSecurityProperties authSecurityProperties,
6263
UserMapper userMapper,
63-
EmailValidator emailValidator) {
64+
EmailValidator emailValidator,
65+
SliderVerificationService sliderVerificationService) {
6466
this.redisOps = redisOps;
6567
this.authSecurityProperties = authSecurityProperties;
6668
this.userMapper = userMapper;
6769
this.emailValidator = emailValidator;
70+
this.sliderVerificationService = sliderVerificationService;
6871
}
6972

7073
/**
7174
* 发送验证码到指定邮箱,返回冷却秒数(供前端倒计时)。
7275
* purpose=register:邮箱需未注册(注册流程);purpose=reset:邮箱需已注册(找回/修改密码流程),
73-
* 主题与邮件模板随用途区分。前置流程:邮箱格式白名单 → 注册态校验 → 冷却检查 →
76+
* 主题与邮件模板随用途区分。前置流程:滑动验证 → 邮箱格式白名单 → 注册态校验 → 冷却检查 →
7477
* 生成 6 位码入 Redis → 通知层发信(Email 渠道 → Dubbo)。
7578
* 发信失败时删除已落库的验证码与冷却键,让用户可立即重试。
79+
* <p>
80+
* 滑动验证必须排在冷却 SETNX <b>之前</b>:冷却键是「发信前占位」,若滑块在占位之后再校验,
81+
* 一次失败的滑块会白占满一个冷却周期,用户需空等才能重试(与下方两个 catch 释放冷却同源)。
82+
* 置于最前则滑块失败直接抛 4001,完全不触碰冷却键。
7683
*/
77-
public int sendCode(String rawEmail, String purpose) {
84+
public int sendCode(String rawEmail, String purpose, String sliderChallengeId) {
85+
sliderVerificationService.verifyAndConsume(sliderChallengeId);
7886
String email = normalizeEmail(rawEmail);
7987

8088
String emailError = emailValidator.validate(email);

0 commit comments

Comments
 (0)