3333@ Service
3434public 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 );
0 commit comments