Skip to content

Commit 458d1b2

Browse files
authored
Merge pull request #47 from yyyCode/harden/login-and-sensitive-word
harden(security): 登录失败不泄露账号状态,敏感词白名单豁免词库误伤
2 parents 97e7118 + e0b424c commit 458d1b2

8 files changed

Lines changed: 376 additions & 16 deletions

File tree

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import com.yqz.openblog.config.AuthSecurityProperties;
44
import com.yqz.openblog.config.CorsProperties;
5+
import com.yqz.openblog.config.SensitiveWordProperties;
56
import com.yqz.openblog.config.SiteProperties;
67
import com.yqz.openblog.seo.SeoProperties;
78
import org.apache.dubbo.config.spring.context.annotation.EnableDubbo;
@@ -29,7 +30,8 @@
2930
SiteProperties.class,
3031
CorsProperties.class,
3132
AuthSecurityProperties.class,
32-
SeoProperties.class
33+
SeoProperties.class,
34+
SensitiveWordProperties.class
3335
})
3436
@ComponentScan(
3537
basePackages = "com.yqz.openblog",
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package com.yqz.openblog.config;
2+
3+
import org.springframework.boot.context.properties.ConfigurationProperties;
4+
5+
import java.util.ArrayList;
6+
import java.util.List;
7+
8+
/**
9+
* 敏感词过滤配置。
10+
*/
11+
@ConfigurationProperties(prefix = "openblog.sensitive-word")
12+
public class SensitiveWordProperties {
13+
14+
/**
15+
* 白名单:命中内置词库但属于误伤的词,在此逐条豁免(词库是通用词表,误伤只能靠这里兜)。
16+
* 在启动时并入 DFA,改动需重启生效。
17+
*/
18+
private List<String> allowWords = new ArrayList<>();
19+
20+
public List<String> getAllowWords() {
21+
return allowWords;
22+
}
23+
24+
public void setAllowWords(List<String> allowWords) {
25+
this.allowWords = allowWords;
26+
}
27+
}

‎OpenBlog-business/src/main/java/com/yqz/openblog/forum/filter/SensitiveWordFilter.java‎

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import com.github.houbb.sensitive.word.bs.SensitiveWordBs;
44
import com.github.houbb.sensitive.word.support.allow.WordAllows;
55
import com.github.houbb.sensitive.word.support.deny.WordDenys;
6+
import com.yqz.openblog.config.SensitiveWordProperties;
67
import org.slf4j.Logger;
78
import org.slf4j.LoggerFactory;
89
import org.springframework.stereotype.Component;
@@ -18,35 +19,48 @@
1819
* <li>{@link #findAll(String)} — 返回所有命中的敏感词</li>
1920
* <li>{@link #replace(String)} — 用 {@code *} 替换敏感词</li>
2021
* </ul>
22+
* <p>
23+
* 内置词库(6W+)是通用词表,必然带误伤:正常词被单独收录,正常语境下也会拦下来
24+
* (例:「长期」在词库里是一条裸词,同段落邻居是「找长期小姐」「长期出售手枪」这类垃圾短语)。
25+
* 词库自带的 allow 表只有十来条,兜不住,故白名单由
26+
* {@code openblog.sensitive-word.allow-words} 配置补充;白名单在 {@code init()} 时并入 DFA,
27+
* 改动需重启生效。命中会打 WARN 日志(命中词 + 局部上下文),事后按日志补白名单即可。
2128
*/
2229
@Component
2330
public class SensitiveWordFilter {
2431

2532
private static final Logger log = LoggerFactory.getLogger(SensitiveWordFilter.class);
2633

34+
/** 命中日志里上下文片段前后各留的字数。 */
35+
private static final int CONTEXT_PADDING = 12;
36+
2737
private final SensitiveWordBs sw;
2838

29-
public SensitiveWordFilter() {
30-
// 使用内置词库(6W+),后续可扩展从 DB/文件加载自定义词库
39+
public SensitiveWordFilter(SensitiveWordProperties properties) {
40+
List<String> allowWords = properties.getAllowWords() == null ? List.of() : properties.getAllowWords();
41+
// 内置词库 + 配置白名单叠加:白名单是「命中词库但属误伤」的唯一出口
3142
this.sw = SensitiveWordBs.newInstance()
3243
.wordDeny(WordDenys.defaults())
33-
.wordAllow(WordAllows.defaults())
44+
.wordAllow(WordAllows.chains(WordAllows.defaults(), () -> allowWords))
3445
.ignoreCase(true)
3546
.ignoreWidth(true)
3647
.ignoreNumStyle(true)
3748
.ignoreChineseStyle(true)
3849
.ignoreEnglishStyle(true)
3950
.ignoreRepeat(false)
4051
.init();
41-
log.info("SensitiveWordFilter initialized");
52+
log.info("SensitiveWordFilter initialized, allowWords={}", allowWords);
4253
}
4354

4455
/**
45-
* 是否包含敏感词。
56+
* 是否包含敏感词。命中时打一条 WARN(命中词 + 局部上下文)——只记录片段不记录全文。
4657
*/
4758
public boolean contains(String text) {
4859
if (text == null || text.isBlank()) return false;
49-
return sw.contains(text);
60+
if (!sw.contains(text)) return false;
61+
List<String> words = findAll(text).stream().distinct().toList();
62+
log.warn("敏感词命中 words={} context={}", words, context(text, words));
63+
return true;
5064
}
5165

5266
/**
@@ -64,4 +78,15 @@ public String replace(String text) {
6478
if (text == null || text.isBlank()) return text;
6579
return sw.replace(text);
6680
}
81+
82+
/** 取首个命中词前后各 {@value #CONTEXT_PADDING} 字、空白压成单空格;无命中返回空串。 */
83+
private String context(String text, List<String> words) {
84+
if (words.isEmpty()) return "";
85+
String first = words.get(0);
86+
int idx = text.indexOf(first);
87+
if (idx < 0) return "";
88+
int start = Math.max(0, idx - CONTEXT_PADDING);
89+
int end = Math.min(text.length(), idx + first.length() + CONTEXT_PADDING);
90+
return text.substring(start, end).replaceAll("\\s+", " ");
91+
}
6792
}

‎OpenBlog-business/src/main/java/com/yqz/openblog/forum/service/ForumService.java‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -275,8 +275,9 @@ private void ensureUserActive(Long uid) {
275275
private void checkSensitive(String... texts) {
276276
for (String text : texts) {
277277
if (text == null || text.isBlank()) continue;
278+
// 命中详情(哪个词、哪段上下文)由 SensitiveWordFilter 内部打 WARN 日志;
279+
// 对外仍是通用提示,不回显命中词(那等于把词库送给用户)
278280
if (sensitiveWordFilter.contains(text)) {
279-
List<String> words = sensitiveWordFilter.findAll(text);
280281
throw new BizException(4003, "内容包含敏感词,请修改后重试");
281282
}
282283
}

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

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@
3333
@Service
3434
public class AuthService {
3535

36+
/**
37+
* 哨兵口令:仅用于启动时生成一个合法 BCrypt 哈希做恒定开销陪跑(见 {@link #absentAccountHash})。
38+
* 它的明文与哈希都不落库、不回显,也永远不会成为任何账号的凭据。
39+
*/
40+
private static final String ABSENT_ACCOUNT_SENTINEL = "openblog-absent-account-sentinel";
41+
3642
private final UserMapper userMapper;
3743
private final RefreshTokenMapper refreshTokenMapper;
3844
private final PasswordEncoder passwordEncoder;
@@ -46,6 +52,13 @@ public class AuthService {
4652
private final EmailValidator emailValidator;
4753
private final EmailCodeService emailCodeService;
4854

55+
/**
56+
* 账号不存在时用来陪跑的 BCrypt 哈希(启动时由 {@link #ABSENT_ACCOUNT_SENTINEL} 生成)。
57+
* BCrypt 比对是有意设计的慢操作(约几十毫秒),若账号不存在时直接返回,攻击者按响应时间
58+
* 就能区分「账号不存在」与「密码错误」——错误提示统一也挡不住这个信道,故补一次等开销比对。
59+
*/
60+
private final String absentAccountHash;
61+
4962
public AuthService(UserMapper userMapper,
5063
RefreshTokenMapper refreshTokenMapper,
5164
PasswordEncoder passwordEncoder,
@@ -70,6 +83,8 @@ public AuthService(UserMapper userMapper,
7083
this.mediaService = mediaService;
7184
this.emailValidator = emailValidator;
7285
this.emailCodeService = emailCodeService;
86+
// 启动时生成一次(约一次 BCrypt 的开销),此后每条「账号不存在」的登录请求复用同一个哈希
87+
this.absentAccountHash = passwordEncoder.encode(ABSENT_ACCOUNT_SENTINEL);
7388
}
7489

7590
public AuthResponse register(RegisterRequest req) {
@@ -132,25 +147,26 @@ public AuthResponse login(LoginRequest req) {
132147
if (user == null) {
133148
user = userMapper.selectOne(Wrappers.lambdaQuery(User.class).eq(User::getEmail, req.getAccount()));
134149
}
135-
if (user == null) {
150+
151+
// 账号不存在时也跑一次 BCrypt(哨兵哈希),与「密码错误」保持等开销,堵住按响应时间枚举账号的信道
152+
boolean passwordOk = passwordEncoder.matches(req.getPassword(),
153+
user != null && user.getPasswordHash() != null ? user.getPasswordHash() : absentAccountHash);
154+
if (user == null || !passwordOk) {
136155
loginLockoutService.recordPasswordFailure(ipSeg);
137156
loginLockoutService.recordDevicePasswordFailure(fp);
138157
throw new BizException(clientErrorCode(), "账号或密码错误");
139158
}
140159

160+
// 账号状态必须在密码校验之后判定:放在前面则不需要正确密码,就能从响应区分出
161+
// 「账号不存在 / 待审核 / 已封禁」,等于白送一份账号枚举与状态探测接口。
162+
// 走到这里密码已证明正确,故不再计失败次数——那是「密码错误」的计数,误计会让同 NAT 出口
163+
// 上无关用户被牵连锁定(封禁态本身已是拒绝登录的终态)。
141164
if ("PENDING".equals(user.getStatus())) {
142165
throw new BizException(4014, "账号待管理员审核通过后方可登录");
143166
}
144167
if ("BANNED".equals(user.getStatus())) {
145-
loginLockoutService.recordPasswordFailure(ipSeg);
146-
loginLockoutService.recordDevicePasswordFailure(fp);
147168
throw new BizException(4011, "账号已被封禁");
148169
}
149-
if (!passwordEncoder.matches(req.getPassword(), user.getPasswordHash())) {
150-
loginLockoutService.recordPasswordFailure(ipSeg);
151-
loginLockoutService.recordDevicePasswordFailure(fp);
152-
throw new BizException(clientErrorCode(), "账号或密码错误");
153-
}
154170

155171
loginLockoutService.clearFailures(ipSeg);
156172
loginLockoutService.clearDeviceFailures(fp);

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,13 @@ openblog:
122122
issuer: openblog
123123
access-token-expire-seconds: 7200
124124
refresh-token-expire-seconds: 2592000
125+
# 敏感词过滤白名单:内置词库是通用表,正常词也会被误收(comment/forum 提交时 4003 拦截)。
126+
# 「长期」在词库里是一条裸词(邻居是「找长期小姐」「长期出售手枪」这类垃圾短语),
127+
# 正常语境(如「长期共事」)会被误伤,故豁免。新增条目依据服务日志的
128+
# "敏感词命中 words=... context=..." 行判断;改此项需重启服务。
129+
sensitive-word:
130+
allow-words:
131+
- "长期"
125132
storage:
126133
# 存储类型:local(本地磁盘)| minio(对象存储,本地与服务器共用同一 MinIO 即可访问相同图片)
127134
type: minio
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package com.yqz.openblog.forum.filter;
2+
3+
import com.yqz.openblog.config.SensitiveWordProperties;
4+
import org.junit.jupiter.api.Test;
5+
6+
import java.util.List;
7+
8+
import static org.junit.jupiter.api.Assertions.assertFalse;
9+
import static org.junit.jupiter.api.Assertions.assertTrue;
10+
11+
/**
12+
* 敏感词过滤器回归测试:锁住「内置词库误伤正常表达」这条修复路径。
13+
* <p>
14+
* 背景:内置词库把「长期」单独收成一条敏感词(词库里它的邻居是「找长期小姐」「长期出售手枪」
15+
* 这类垃圾短语,这条裸词疑似切词残留),于是正常语境(「长期共事」)也会被 4003 拦下。
16+
* 出口是配置白名单 {@code openblog.sensitive-word.allow-words}。
17+
*/
18+
class SensitiveWordFilterTest {
19+
20+
/** 实际被误拦的一段文字(面试经验分享),正文里除「长期」外没有任何命中。 */
21+
private static final String INTERVIEW_PARAGRAPH = """
22+
面试中,技术能力决定你能否进入候选池,而非技术因素往往决定你能否最终拿到 Offer。其中最重要的是语言流畅与表达清晰,回答要有结构、少卡顿、语速适中;其次是普通话标准或口音可懂,不要求播音员水平,但必须让面试官轻松听懂。仪容仪表与精神面貌同样关键,着装得体、整洁清爽、视频面试背景干净光线充足,能体现你对机会的重视。此外,礼貌与职业素养、情绪稳定、自信真诚、时间观念也直接影响印象分——准时到场、不打断对方、遇到难题不慌乱、不会的问题坦诚说,都比硬撑或夸大更加分。
23+
24+
团队协作与沟通意愿是技术岗常被考察的隐性指标,讲述项目时能体现与产品、测试、运维的配合,面对分歧能平和表达,会让面试官觉得你善于协作。学习意愿与成长潜力、文化匹配度与长期性虽然权重稍低,但对初级岗位和长期共事判断很重要,准备一个“最近在学什么”的具体例子、离职原因聚焦成长方向而非抱怨,都能加分。
25+
26+
总的来说,技术决定你能不能进面试,非技术因素决定你能不能拿 Offer。其中表达清晰、普通话可懂、仪表得体、情绪稳定、真诚自信,是性价比最高的五个发力点,提前练习远比临场紧张更有用。""";
27+
28+
@Test
29+
void interviewParagraph_withAllowWord_notFlagged() {
30+
assertFalse(filterWith(List.of("长期")).contains(INTERVIEW_PARAGRAPH));
31+
}
32+
33+
/**
34+
* 反证:白名单是这段文字唯一的解药——去掉「长期」后同一段文字立即命中。
35+
* 本用例依赖内置词库收录了「长期」;若日后升级词库它开始失败,说明上游已自行修掉该误伤,
36+
* 可连同配置里的白名单条目一起删掉。
37+
*/
38+
@Test
39+
void sameParagraphWithoutAllowWord_flaggedByBuiltinDict() {
40+
assertTrue(filterWith(List.of()).contains(INTERVIEW_PARAGRAPH));
41+
}
42+
43+
/** 白名单只豁免列出的词,不影响词库对真正违规内容的拦截。 */
44+
@Test
45+
void realDenyWord_stillFlaggedDespiteAllowWords() {
46+
assertTrue(filterWith(List.of("长期")).contains("本店代开发票,长期有效"));
47+
}
48+
49+
/** 豁免是精确匹配:「长期」被加白后,词库里含它的垃圾短语仍然照拦。 */
50+
@Test
51+
void spamPhraseBuiltFromAllowWord_stillFlagged() {
52+
assertTrue(filterWith(List.of("长期")).contains("找长期小姐"));
53+
}
54+
55+
/** 空/空白输入不命中(contains 的边界)。 */
56+
@Test
57+
void blankText_notFlagged() {
58+
SensitiveWordFilter filter = filterWith(List.of());
59+
assertFalse(filter.contains(null));
60+
assertFalse(filter.contains(""));
61+
assertFalse(filter.contains(" "));
62+
}
63+
64+
private SensitiveWordFilter filterWith(List<String> allowWords) {
65+
SensitiveWordProperties properties = new SensitiveWordProperties();
66+
properties.setAllowWords(allowWords);
67+
return new SensitiveWordFilter(properties);
68+
}
69+
}

0 commit comments

Comments
 (0)