Browse Source
feat(security): JWT token_version 服务端撤销与登录安全加固
feat(security): JWT token_version 服务端撤销与登录安全加固
- 新增 token_version 服务端撤销机制:改密/禁用/角色变更/登出时自增,使已签发 token 立即失效 - refresh token 从 localStorage 迁移到 httpOnly Cookie,access token 有效期缩短至 15 分钟 - 新增登录失败锁定(LoginAttemptService)与密码强度校验(PasswordValidator) - SDK JWT 密钥支持环境变量注入,使用默认密钥时告警 修复: - assignRoles 角色未变化仍自增 token_version,导致用户新建用户时 token 被误失效而跳转登录 - 角色 ID 解析兼容前端字符串传输,避免雪花 ID 精度丢失导致 ClassCastException - updateFill 改用 setFieldValByName,修复 update_time 未自动填充的问题Spring-AI-1.1.2
30 changed files with 1067 additions and 205 deletions
-
30frontend/src/App.vue
-
16frontend/src/api/auth.ts
-
64frontend/src/api/request.ts
-
4frontend/src/layouts/MainLayout.vue
-
91frontend/src/layouts/Topbar.vue
-
18frontend/src/stores/auth.ts
-
6frontend/src/types/models.ts
-
4frontend/src/utils/sse.ts
-
21frontend/src/utils/token.ts
-
5frontend/src/views/LoginPage.vue
-
189src/main/java/com/wok/supportbot/auth/AuthController.java
-
21src/main/java/com/wok/supportbot/config/CorsConfig.java
-
51src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java
-
32src/main/java/com/wok/supportbot/controller/SysUserController.java
-
4src/main/java/com/wok/supportbot/entity/SysUser.java
-
5src/main/java/com/wok/supportbot/handler/MyMetaObjectHandler.java
-
25src/main/java/com/wok/supportbot/security/JwtAuthFilter.java
-
69src/main/java/com/wok/supportbot/security/JwtTokenProvider.java
-
95src/main/java/com/wok/supportbot/security/LoginAttemptService.java
-
48src/main/java/com/wok/supportbot/security/PasswordValidator.java
-
39src/main/java/com/wok/supportbot/security/SdkAuthFilter.java
-
18src/main/java/com/wok/supportbot/security/SdkJwtTokenProvider.java
-
17src/main/java/com/wok/supportbot/security/SecurityConfig.java
-
63src/main/java/com/wok/supportbot/service/SysUserService.java
-
15src/main/resources/application-dev.yml
-
16src/main/resources/application-prod.yml
-
28src/main/resources/application.yml
-
210src/main/resources/init-database.sql
-
8src/main/resources/static/sdk/test.html
-
16src/main/resources/support-bot.sql
@ -0,0 +1,95 @@ |
|||
package com.wok.supportbot.security; |
|||
|
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.springframework.beans.factory.annotation.Value; |
|||
import org.springframework.jdbc.core.JdbcTemplate; |
|||
import org.springframework.stereotype.Service; |
|||
import org.springframework.transaction.annotation.Transactional; |
|||
|
|||
import java.sql.Timestamp; |
|||
|
|||
/** |
|||
* 登录失败尝试记录服务 |
|||
* 用于防范暴力破解:同一账号连续失败达到阈值后锁定一段时间。 |
|||
*/ |
|||
@Slf4j |
|||
@Service |
|||
public class LoginAttemptService { |
|||
|
|||
private final JdbcTemplate jdbcTemplate; |
|||
private final int maxAttempts; |
|||
private final int lockMinutes; |
|||
|
|||
public LoginAttemptService(JdbcTemplate jdbcTemplate, |
|||
@Value("${security.login.max-attempts:5}") int maxAttempts, |
|||
@Value("${security.login.lock-minutes:15}") int lockMinutes) { |
|||
this.jdbcTemplate = jdbcTemplate; |
|||
this.maxAttempts = maxAttempts; |
|||
this.lockMinutes = lockMinutes; |
|||
} |
|||
|
|||
/** |
|||
* 返回账号锁定的剩余秒数;未锁定返回 0 |
|||
*/ |
|||
public int getLockedSeconds(String username) { |
|||
if (username == null || username.isBlank()) { |
|||
return 0; |
|||
} |
|||
Timestamp lockedUntil = jdbcTemplate.query( |
|||
"SELECT locked_until FROM login_attempt WHERE username = ?", |
|||
rs -> rs.next() ? rs.getTimestamp("locked_until") : null, |
|||
username.toLowerCase() |
|||
); |
|||
if (lockedUntil == null) { |
|||
return 0; |
|||
} |
|||
long remaining = lockedUntil.getTime() - System.currentTimeMillis(); |
|||
return remaining > 0 ? (int) (remaining / 1000) : 0; |
|||
} |
|||
|
|||
/** |
|||
* 记录一次登录失败,达到阈值时锁定账号 |
|||
*/ |
|||
@Transactional(rollbackFor = Exception.class) |
|||
public void recordFailure(String username) { |
|||
if (username == null || username.isBlank()) { |
|||
return; |
|||
} |
|||
// 边界保护:login_attempt.username 为 VARCHAR(64),超长用户名截断避免 DataIntegrityViolationException |
|||
String name = username.toLowerCase(); |
|||
if (name.length() > 64) { |
|||
name = name.substring(0, 64); |
|||
} |
|||
jdbcTemplate.update(""" |
|||
INSERT INTO login_attempt (username, failed_count, last_failed_time, locked_until) |
|||
VALUES (?, 1, CURRENT_TIMESTAMP, NULL) |
|||
ON CONFLICT (username) DO UPDATE SET |
|||
failed_count = CASE |
|||
WHEN login_attempt.locked_until IS NOT NULL AND login_attempt.locked_until <= CURRENT_TIMESTAMP THEN 1 |
|||
ELSE LEAST(login_attempt.failed_count + 1, ?) |
|||
END, |
|||
last_failed_time = CURRENT_TIMESTAMP, |
|||
locked_until = CASE |
|||
WHEN login_attempt.locked_until > CURRENT_TIMESTAMP THEN login_attempt.locked_until |
|||
WHEN login_attempt.locked_until IS NOT NULL AND login_attempt.locked_until <= CURRENT_TIMESTAMP THEN NULL |
|||
WHEN login_attempt.failed_count + 1 >= ? THEN CURRENT_TIMESTAMP + (? * INTERVAL '1 minute') |
|||
ELSE NULL |
|||
END, |
|||
update_time = CURRENT_TIMESTAMP |
|||
""", name, Integer.MAX_VALUE, maxAttempts, lockMinutes); |
|||
} |
|||
|
|||
/** |
|||
* 登录成功后清除失败记录 |
|||
*/ |
|||
@Transactional(rollbackFor = Exception.class) |
|||
public void recordSuccess(String username) { |
|||
if (username == null || username.isBlank()) { |
|||
return; |
|||
} |
|||
int rows = jdbcTemplate.update("DELETE FROM login_attempt WHERE username = ?", username.toLowerCase()); |
|||
if (rows > 0) { |
|||
log.info("登录成功,清除失败记录: {}", username); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,48 @@ |
|||
package com.wok.supportbot.security; |
|||
|
|||
import org.springframework.stereotype.Component; |
|||
|
|||
/** |
|||
* 密码强度校验器 |
|||
* 统一校验创建用户、修改密码、自助改密等场景的密码复杂度。 |
|||
*/ |
|||
@Component |
|||
public class PasswordValidator { |
|||
|
|||
private static final int MIN_LENGTH = 8; |
|||
private static final int MAX_LENGTH = 64; |
|||
|
|||
/** |
|||
* 校验密码强度,不通过则抛出 IllegalArgumentException |
|||
* |
|||
* 规则: |
|||
* - 长度 8-64 位 |
|||
* - 同时包含字母和数字 |
|||
*/ |
|||
public void validate(String password) { |
|||
if (password == null || password.length() < MIN_LENGTH || password.length() > MAX_LENGTH) { |
|||
throw new IllegalArgumentException("密码长度需在 8-64 位之间"); |
|||
} |
|||
if (!containsLetter(password) || !containsDigit(password)) { |
|||
throw new IllegalArgumentException("密码需同时包含字母和数字"); |
|||
} |
|||
} |
|||
|
|||
private boolean containsLetter(String s) { |
|||
for (char c : s.toCharArray()) { |
|||
if (Character.isLetter(c)) { |
|||
return true; |
|||
} |
|||
} |
|||
return false; |
|||
} |
|||
|
|||
private boolean containsDigit(String s) { |
|||
for (char c : s.toCharArray()) { |
|||
if (Character.isDigit(c)) { |
|||
return true; |
|||
} |
|||
} |
|||
return false; |
|||
} |
|||
} |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue