Skip to content

Commit f3f469f

Browse files
authored
Merge pull request #41 from yyyCode/harden/device-fingerprint-layer
harden: 设备指纹防构造加固(第 1~3 层)
2 parents 479cf83 + 7c85adf commit f3f469f

27 files changed

Lines changed: 1184 additions & 43 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -250,9 +250,10 @@ jobs:
250250
with:
251251
name: gateway-jar
252252
path: gateway
253-
- name: Deploy via Docker(构建镜像 + 重启容器 + 注入 JWT secret)
253+
- name: Deploy via Docker(构建镜像 + 重启容器 + 注入 JWT / 设备令牌 secret)
254254
env:
255255
OPENBLOG_JWT_SECRET: ${{ secrets.OPENBLOG_JWT_SECRET }}
256+
OPENBLOG_DEVICE_TOKEN_SECRET: ${{ secrets.OPENBLOG_DEVICE_TOKEN_SECRET }}
256257
run: |
257258
set -euo pipefail
258259
TARGET_DIR="/www/wwwroot/java/openblog-gateway"
@@ -273,8 +274,9 @@ jobs:
273274
cp "$JAR_FILE" "$TARGET_JAR"
274275
cp deploy/gateway/Dockerfile deploy/gateway/docker-compose.yml deploy/gateway/.dockerignore "$TARGET_DIR/"
275276
276-
# 4. JWT secret 写入服务器 .env(compose 自动加载)
277-
printf 'OPENBLOG_JWT_SECRET=%s\n' "$OPENBLOG_JWT_SECRET" > "$TARGET_DIR/.env"
277+
# 4. JWT / 设备令牌 secret 写入服务器 .env(compose 自动加载)
278+
printf 'OPENBLOG_JWT_SECRET=%s\nOPENBLOG_DEVICE_TOKEN_SECRET=%s\n' \
279+
"$OPENBLOG_JWT_SECRET" "$OPENBLOG_DEVICE_TOKEN_SECRET" > "$TARGET_DIR/.env"
278280
279281
# 5. Docker 构建并启动容器
280282
cd "$TARGET_DIR"

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

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ public class AuthSecurityProperties {
1010

1111
private Slider slider = new Slider();
1212
private LoginLockout loginLockout = new LoginLockout();
13+
private DeviceLockout deviceLockout = new DeviceLockout();
1314
private EmailCode emailCode = new EmailCode();
1415

1516
public Slider getSlider() {
@@ -28,6 +29,14 @@ public void setLoginLockout(LoginLockout loginLockout) {
2829
this.loginLockout = loginLockout;
2930
}
3031

32+
public DeviceLockout getDeviceLockout() {
33+
return deviceLockout;
34+
}
35+
36+
public void setDeviceLockout(DeviceLockout deviceLockout) {
37+
this.deviceLockout = deviceLockout;
38+
}
39+
3140
public EmailCode getEmailCode() {
3241
return emailCode;
3342
}
@@ -111,6 +120,60 @@ public void setLockoutSeconds(int lockoutSeconds) {
111120
}
112121
}
113122

123+
/**
124+
* 设备级失败封禁(按设备指纹计,与 IP 锁互补)。
125+
* 指纹是网关校验过的弱信号:挡「换 IP 但设备固定」的爆破;设备指纹非权威身份,
126+
* 对「每请求换指纹」的脚本无效——那由网关层(指纹轮换守卫 + 签名设备令牌)负责。
127+
* 误伤面比 IP 锁小(只锁定单一指纹),故默认开启;IP 锁因 NAT 误伤在生产关闭。
128+
*/
129+
public static class DeviceLockout {
130+
private boolean enabled = true;
131+
/**
132+
* 同一设备(指纹)在窗口内允许的最大失败次数(仅统计密码错误)。
133+
*/
134+
private int maxFailuresPerFp = 5;
135+
/**
136+
* 失败计数滑动窗口(秒)。
137+
*/
138+
private int failureWindowSeconds = 300;
139+
/**
140+
* 触发锁定后的禁止登录时长(秒)。
141+
*/
142+
private int lockoutSeconds = 900;
143+
144+
public boolean isEnabled() {
145+
return enabled;
146+
}
147+
148+
public void setEnabled(boolean enabled) {
149+
this.enabled = enabled;
150+
}
151+
152+
public int getMaxFailuresPerFp() {
153+
return maxFailuresPerFp;
154+
}
155+
156+
public void setMaxFailuresPerFp(int maxFailuresPerFp) {
157+
this.maxFailuresPerFp = maxFailuresPerFp;
158+
}
159+
160+
public int getFailureWindowSeconds() {
161+
return failureWindowSeconds;
162+
}
163+
164+
public void setFailureWindowSeconds(int failureWindowSeconds) {
165+
this.failureWindowSeconds = failureWindowSeconds;
166+
}
167+
168+
public int getLockoutSeconds() {
169+
return lockoutSeconds;
170+
}
171+
172+
public void setLockoutSeconds(int lockoutSeconds) {
173+
this.lockoutSeconds = lockoutSeconds;
174+
}
175+
}
176+
114177
public static class EmailCode {
115178
/**
116179
* 验证码有效时间(秒),默认 5 分钟。
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package com.yqz.openblog.user.service;
2+
3+
import com.yqz.openblog.redis.core.RedisKeys;
4+
import org.slf4j.Logger;
5+
import org.slf4j.LoggerFactory;
6+
import org.springframework.data.redis.core.StringRedisTemplate;
7+
import org.springframework.stereotype.Service;
8+
9+
import java.time.Duration;
10+
11+
/**
12+
* 账号 ↔ 设备指纹绑定记录(设备指纹防构造第 3 层)。
13+
* <p>
14+
* 登录成功后把本次指纹记为该账号的「已知设备」(Redis SET,值=已过网关格式校验的指纹)。
15+
* 目的不是立刻拦截(新设备二次验证不在本期),而是留下可观测的绑定事实:日志可见「新设备首次登录」,
16+
* 后续演进(新设备 + 新 IP → 二次验证、异常设备告警)以本集合为基线。
17+
* <p>
18+
* 契约:只作记录,不做判定——Redis 故障仅打 warn、绝不阻断登录(与全局 fail-open 一致)。
19+
*/
20+
@Service
21+
public class AccountDeviceService {
22+
23+
private static final Logger log = LoggerFactory.getLogger(AccountDeviceService.class);
24+
/** 已知设备集合保留时长:指纹是弱信号,按 90 天滑动保留即可。 */
25+
private static final Duration DEVICE_TTL = Duration.ofDays(90);
26+
27+
private final StringRedisTemplate redisTemplate;
28+
29+
public AccountDeviceService(StringRedisTemplate redisTemplate) {
30+
this.redisTemplate = redisTemplate;
31+
}
32+
33+
/**
34+
* 登录成功后把指纹记为该账号已知设备。
35+
*
36+
* @param userId 账号 id
37+
* @param fp 已通过格式校验({@code ^[A-Za-z0-9-]{16,64}$})的设备指纹;null/空则跳过
38+
*/
39+
public void recordLogin(Long userId, String fp) {
40+
if (userId == null || fp == null || fp.isBlank()) {
41+
return;
42+
}
43+
try {
44+
String key = RedisKeys.accountDevices(userId);
45+
Long added = redisTemplate.opsForSet().add(key, fp);
46+
// 每次登录都滚动续 TTL:活跃账号的"已知设备"集合不会因 90 天静默消失;停用 90 天后自然过期清理
47+
redisTemplate.expire(key, DEVICE_TTL);
48+
if (added != null && added > 0) {
49+
log.info("account first-seen device login: userId={} fp={}", userId, mask(fp));
50+
}
51+
} catch (RuntimeException e) {
52+
// Redis 不可用 → 仅记录失败,登录放行(fail-open)
53+
log.warn("record account device failed, allow login: userId={} err={}", userId, e.toString());
54+
}
55+
}
56+
57+
/** 日志脱敏:只留首 6 + 末 2 字符,指纹也是标识符,不全量落日志。 */
58+
private static String mask(String fp) {
59+
return fp.length() <= 8 ? fp : fp.substring(0, 6) + ".." + fp.substring(fp.length() - 2);
60+
}
61+
}

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ public class AuthService {
4141
private final CurrentUser currentUser;
4242
private final SliderVerificationService sliderVerificationService;
4343
private final LoginLockoutService loginLockoutService;
44+
private final AccountDeviceService accountDeviceService;
4445
private final MediaService mediaService;
4546
private final EmailValidator emailValidator;
4647
private final EmailCodeService emailCodeService;
@@ -53,6 +54,7 @@ public AuthService(UserMapper userMapper,
5354
CurrentUser currentUser,
5455
SliderVerificationService sliderVerificationService,
5556
LoginLockoutService loginLockoutService,
57+
AccountDeviceService accountDeviceService,
5658
MediaService mediaService,
5759
EmailValidator emailValidator,
5860
EmailCodeService emailCodeService) {
@@ -64,6 +66,7 @@ public AuthService(UserMapper userMapper,
6466
this.currentUser = currentUser;
6567
this.sliderVerificationService = sliderVerificationService;
6668
this.loginLockoutService = loginLockoutService;
69+
this.accountDeviceService = accountDeviceService;
6770
this.mediaService = mediaService;
6871
this.emailValidator = emailValidator;
6972
this.emailCodeService = emailCodeService;
@@ -119,7 +122,10 @@ public AuthResponse register(RegisterRequest req) {
119122

120123
public AuthResponse login(LoginRequest req) {
121124
String ipSeg = currentIpKeySegment();
125+
// 设备维度(指纹经网关格式校验透传;缺失/非法 → null 不参与设备锁,仅 IP 兜底)
126+
String fp = currentDeviceFingerprint();
122127
loginLockoutService.assertNotLocked(ipSeg);
128+
loginLockoutService.assertNotDeviceLocked(fp);
123129
sliderVerificationService.verifyAndConsume(req.getSliderChallengeId());
124130

125131
User user = userMapper.selectOne(Wrappers.lambdaQuery(User.class).eq(User::getUsername, req.getAccount()));
@@ -128,6 +134,7 @@ public AuthResponse login(LoginRequest req) {
128134
}
129135
if (user == null) {
130136
loginLockoutService.recordPasswordFailure(ipSeg);
137+
loginLockoutService.recordDevicePasswordFailure(fp);
131138
throw new BizException(clientErrorCode(), "账号或密码错误");
132139
}
133140

@@ -136,14 +143,19 @@ public AuthResponse login(LoginRequest req) {
136143
}
137144
if ("BANNED".equals(user.getStatus())) {
138145
loginLockoutService.recordPasswordFailure(ipSeg);
146+
loginLockoutService.recordDevicePasswordFailure(fp);
139147
throw new BizException(4011, "账号已被封禁");
140148
}
141149
if (!passwordEncoder.matches(req.getPassword(), user.getPasswordHash())) {
142150
loginLockoutService.recordPasswordFailure(ipSeg);
151+
loginLockoutService.recordDevicePasswordFailure(fp);
143152
throw new BizException(clientErrorCode(), "账号或密码错误");
144153
}
145154

146155
loginLockoutService.clearFailures(ipSeg);
156+
loginLockoutService.clearDeviceFailures(fp);
157+
// 第 3 层:指纹记为该账号的已知设备(fail-open,Redis 故障不阻断登录)
158+
accountDeviceService.recordLogin(user.getId(), fp);
147159

148160
String accessToken = jwtService.generateAccessToken(user.getId(), user.getRole().name());
149161
String refreshToken = jwtService.generateRefreshToken(user.getId());
@@ -314,5 +326,21 @@ private String currentIpKeySegment() {
314326
String ip = ClientIpResolver.resolve(request);
315327
return ClientIpResolver.toRedisKeySegment(ip);
316328
}
329+
330+
/** 设备指纹 key 片段:网关已正则校验并透传 X-Device-Fingerprint;此处防御性复检,
331+
* 缺失/非法一律返回 null(该请求不参与设备锁 / 设备记录,仅 IP 维度兜底)。 */
332+
private String currentDeviceFingerprint() {
333+
var attrs = RequestContextHolder.getRequestAttributes();
334+
if (!(attrs instanceof ServletRequestAttributes sra)) {
335+
return null;
336+
}
337+
String raw = sra.getRequest().getHeader("X-Device-Fingerprint");
338+
if (raw == null) {
339+
return null;
340+
}
341+
String fp = raw.trim();
342+
// 与网关一致:32 位 hex 为常态;限 16~64 位字母数字-,防注入与超长 Redis key
343+
return fp.matches("^[A-Za-z0-9-]{16,64}$") ? fp : null;
344+
}
317345
}
318346

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

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,4 +71,49 @@ public void clearFailures(String ipKeySegment) {
7171
redisOps.delete(RedisKeys.loginFail(ipKeySegment));
7272
redisOps.delete(RedisKeys.loginLock(ipKeySegment));
7373
}
74+
75+
// ==================== 设备维度(与 IP 维度互补,见 AuthSecurityProperties.DeviceLockout) ====================
76+
// fpKeySegment 传 null(无指纹设备)一律跳过:该设备不参与设备锁,仍受 IP 锁兜底。
77+
78+
public void assertNotDeviceLocked(String fpKeySegment) {
79+
if (fpKeySegment == null || !authSecurityProperties.getDeviceLockout().isEnabled()) {
80+
return;
81+
}
82+
if (redisOps.hasKey(RedisKeys.deviceLock(fpKeySegment))) {
83+
throw new BizException(4292, "该设备登录尝试过于频繁,请稍后再试");
84+
}
85+
}
86+
87+
/**
88+
* 密码校验失败时调用(设备指纹维度)。
89+
*/
90+
public void recordDevicePasswordFailure(String fpKeySegment) {
91+
if (fpKeySegment == null || !authSecurityProperties.getDeviceLockout().isEnabled()) {
92+
return;
93+
}
94+
var cfg = authSecurityProperties.getDeviceLockout();
95+
String failKey = RedisKeys.deviceFail(fpKeySegment);
96+
Long c = redisOps.increment(failKey).orElse(null);
97+
if (c == null) {
98+
return;
99+
}
100+
if (c == 1L) {
101+
redisOps.expire(failKey, Duration.ofSeconds(Math.max(30, cfg.getFailureWindowSeconds())));
102+
}
103+
if (c >= cfg.getMaxFailuresPerFp()) {
104+
redisOps.set(RedisKeys.deviceLock(fpKeySegment), "1",
105+
Duration.ofSeconds(Math.max(60, cfg.getLockoutSeconds())));
106+
}
107+
}
108+
109+
/**
110+
* 登录成功时清除该设备的失败计数与锁定(若存在)。
111+
*/
112+
public void clearDeviceFailures(String fpKeySegment) {
113+
if (fpKeySegment == null || !authSecurityProperties.getDeviceLockout().isEnabled()) {
114+
return;
115+
}
116+
redisOps.delete(RedisKeys.deviceFail(fpKeySegment));
117+
redisOps.delete(RedisKeys.deviceLock(fpKeySegment));
118+
}
74119
}

‎OpenBlog-business/src/main/resources/application.yaml‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,14 @@ openblog:
105105
max-failures-per-ip: 5
106106
failure-window-seconds: 300
107107
lockout-seconds: 900
108+
# 设备级失败封禁:按设备指纹计(与 IP 锁互补,挡「换 IP 但设备固定」爆破)。
109+
# 默认开:只锁定单一指纹、误伤面远小于 IP 锁(IP 锁因 NAT 误伤在此关闭)。
110+
# 指纹是弱信号,对「每请求换指纹」脚本无效——由网关层指纹轮换守卫 + 签名设备令牌负责。
111+
device-lockout:
112+
enabled: true
113+
max-failures-per-fp: 5
114+
failure-window-seconds: 300
115+
lockout-seconds: 900
108116
email-code:
109117
code-ttl-seconds: 300
110118
resend-cooldown-seconds: 60

0 commit comments

Comments
 (0)