refactor(auth): 密码策略和登录限制改为配置化

- PasswordService 支持配置化
- LoginAttemptService 支持配置化
- 配置文件添加password和login配置项
This commit is contained in:
chiguyong 2026-03-17 22:38:56 +08:00
parent 20bed35f64
commit 2f8ac15434
7 changed files with 297 additions and 40 deletions

View File

@ -80,5 +80,10 @@
<groupId>org.springdoc</groupId> <groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId> <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
</dependencies> </dependencies>
</project> </project>

View File

@ -0,0 +1,79 @@
package com.ether.pms.auth.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.time.Duration;
@Service
@RequiredArgsConstructor
@ConfigurationProperties(prefix = "login")
@Slf4j
public class LoginAttemptService {
private int maxAttempts = 5;
private int lockoutDurationMinutes = 10;
private final StringRedisTemplate redisTemplate;
private static final String KEY_PREFIX = "login:attempt:";
public void recordFailure(String username) {
String key = KEY_PREFIX + username;
Long attempts = redisTemplate.opsForValue().increment(key);
if (attempts != null && attempts == 1) {
redisTemplate.expire(key, Duration.ofMinutes(lockoutDurationMinutes));
}
log.warn("用户 {} 登录失败,当前失败次数: {}", username, attempts);
}
public void recordSuccess(String username) {
String key = KEY_PREFIX + username;
redisTemplate.delete(key);
log.info("用户 {} 登录成功,已清除登录失败记录", username);
}
public boolean isLockedOut(String username) {
String key = KEY_PREFIX + username;
String value = redisTemplate.opsForValue().get(key);
if (value == null) {
return false;
}
int attempts = Integer.parseInt(value);
return attempts >= maxAttempts;
}
public int getRemainingAttempts(String username) {
String key = KEY_PREFIX + username;
String value = redisTemplate.opsForValue().get(key);
if (value == null) {
return maxAttempts;
}
int attempts = Integer.parseInt(value);
return Math.max(0, maxAttempts - attempts);
}
public void unlock(String username) {
String key = KEY_PREFIX + username;
redisTemplate.delete(key);
}
public int getMaxAttempts() {
return maxAttempts;
}
public void setMaxAttempts(int maxAttempts) {
this.maxAttempts = maxAttempts;
}
public int getLockoutDurationMinutes() {
return lockoutDurationMinutes;
}
public void setLockoutDurationMinutes(int lockoutDurationMinutes) {
this.lockoutDurationMinutes = lockoutDurationMinutes;
}
}

View File

@ -15,10 +15,22 @@ public class LoginService {
private final UserRepository userRepository; private final UserRepository userRepository;
private final JwtTokenProvider jwtTokenProvider; private final JwtTokenProvider jwtTokenProvider;
private final PasswordService passwordService;
private final LoginAttemptService loginAttemptService;
private static final int MAX_ATTEMPTS = 5;
public Map<String, Object> login(String username, String password, String ip) { public Map<String, Object> login(String username, String password, String ip) {
if (loginAttemptService.isLockedOut(username)) {
int remaining = loginAttemptService.getRemainingAttempts(username);
throw new RuntimeException("账号已被锁定,请" + 10 + "分钟后再试");
}
User user = userRepository.findByUsername(username) User user = userRepository.findByUsername(username)
.orElseThrow(() -> new RuntimeException("用户名或密码错误")); .orElseThrow(() -> {
loginAttemptService.recordFailure(username);
return new RuntimeException("用户名或密码错误");
});
if (user.getStatus() == User.UserStatus.LOCKED) { if (user.getStatus() == User.UserStatus.LOCKED) {
throw new RuntimeException("账号已被锁定"); throw new RuntimeException("账号已被锁定");
@ -27,11 +39,17 @@ public class LoginService {
throw new RuntimeException("账号已被禁用"); throw new RuntimeException("账号已被禁用");
} }
String encryptedPassword = encryptPassword(password, user.getSalt()); if (!passwordService.matches(password, user.getPassword())) {
if (!encryptedPassword.equals(user.getPassword())) { loginAttemptService.recordFailure(username);
throw new RuntimeException("用户名或密码错误"); int remaining = loginAttemptService.getRemainingAttempts(username);
if (remaining <= 0) {
throw new RuntimeException("账号已被锁定请10分钟后再试");
}
throw new RuntimeException("用户名或密码错误,剩余尝试次数: " + remaining);
} }
loginAttemptService.recordSuccess(username);
user.setLastLoginTime(java.time.LocalDateTime.now()); user.setLastLoginTime(java.time.LocalDateTime.now());
user.setLastLoginIp(ip); user.setLastLoginIp(ip);
userRepository.save(user); userRepository.save(user);
@ -53,10 +71,4 @@ public class LoginService {
return result; return result;
} }
private String encryptPassword(String password, String salt) {
String combined = password + salt;
return org.springframework.util.DigestUtils.md5DigestAsHex(
combined.getBytes(java.nio.charset.StandardCharsets.UTF_8));
}
} }

View File

@ -0,0 +1,140 @@
package com.ether.pms.auth.service;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
import java.util.regex.Pattern;
@Component
@ConfigurationProperties(prefix = "password")
@Slf4j
public class PasswordService {
private int minLength = 8;
private int maxLength = 20;
private boolean requireUppercase = true;
private boolean requireLowercase = true;
private boolean requireDigit = true;
private boolean requireSpecial = true;
private String specialChars = "!@#$%^&*()_+-=[]{}|;':\",./<>?";
private final PasswordEncoder passwordEncoder;
public PasswordService() {
this.passwordEncoder = new BCryptPasswordEncoder();
}
public String encode(String rawPassword) {
return passwordEncoder.encode(rawPassword);
}
public boolean matches(String rawPassword, String encodedPassword) {
return passwordEncoder.matches(rawPassword, encodedPassword);
}
public void validatePassword(String password) {
if (password == null || password.isEmpty()) {
throw new IllegalArgumentException("密码不能为空");
}
if (password.length() < minLength || password.length() > maxLength) {
throw new IllegalArgumentException("密码长度必须在" + minLength + "-" + maxLength + "位之间");
}
if (requireUppercase && !containsAny(password, 'A', 'Z')) {
throw new IllegalArgumentException("密码必须包含大写字母");
}
if (requireLowercase && !containsAny(password, 'a', 'z')) {
throw new IllegalArgumentException("密码必须包含小写字母");
}
if (requireDigit && !containsAny(password, '0', '9')) {
throw new IllegalArgumentException("密码必须包含数字");
}
if (requireSpecial && !containsAny(password, specialChars.toCharArray())) {
throw new IllegalArgumentException("密码必须包含特殊字符(" + specialChars + ")");
}
}
public boolean isPasswordWeak(String password) {
if (password == null || password.length() < 8) {
return true;
}
String lower = password.toLowerCase();
return lower.contains("password") ||
lower.contains("123456") ||
lower.contains("admin") ||
lower.contains("qwerty");
}
private boolean containsAny(String str, char... chars) {
for (char c : chars) {
if (str.indexOf(c) >= 0) {
return true;
}
}
return false;
}
public int getMinLength() {
return minLength;
}
public void setMinLength(int minLength) {
this.minLength = minLength;
}
public int getMaxLength() {
return maxLength;
}
public void setMaxLength(int maxLength) {
this.maxLength = maxLength;
}
public boolean isRequireUppercase() {
return requireUppercase;
}
public void setRequireUppercase(boolean requireUppercase) {
this.requireUppercase = requireUppercase;
}
public boolean isRequireLowercase() {
return requireLowercase;
}
public void setRequireLowercase(boolean requireLowercase) {
this.requireLowercase = requireLowercase;
}
public boolean isRequireDigit() {
return requireDigit;
}
public void setRequireDigit(boolean requireDigit) {
this.requireDigit = requireDigit;
}
public boolean isRequireSpecial() {
return requireSpecial;
}
public void setRequireSpecial(boolean requireSpecial) {
this.requireSpecial = requireSpecial;
}
public String getSpecialChars() {
return specialChars;
}
public void setSpecialChars(String specialChars) {
this.specialChars = specialChars;
}
}

View File

@ -7,9 +7,7 @@ import com.ether.pms.auth.repository.RoleRepository;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.DigestUtils;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
@ -20,6 +18,7 @@ public class UserService {
private final UserRepository userRepository; private final UserRepository userRepository;
private final RoleRepository roleRepository; private final RoleRepository roleRepository;
private final PasswordService passwordService;
public List<User> findAll() { public List<User> findAll() {
return userRepository.findAll(); return userRepository.findAll();
@ -44,9 +43,13 @@ public class UserService {
throw new RuntimeException("手机号已存在"); throw new RuntimeException("手机号已存在");
} }
String salt = generateSalt(); passwordService.validatePassword(user.getPassword());
user.setSalt(salt);
user.setPassword(encryptPassword(user.getPassword(), salt)); if (passwordService.isPasswordWeak(user.getPassword())) {
throw new RuntimeException("密码太弱,请使用更复杂的密码");
}
user.setPassword(passwordService.encode(user.getPassword()));
return userRepository.save(user); return userRepository.save(user);
} }
@ -80,22 +83,32 @@ public class UserService {
@Transactional @Transactional
public void updatePassword(UUID id, String oldPassword, String newPassword) { public void updatePassword(UUID id, String oldPassword, String newPassword) {
User user = findById(id); User user = findById(id);
String encryptedOld = encryptPassword(oldPassword, user.getSalt());
if (!encryptedOld.equals(user.getPassword())) { if (!passwordService.matches(oldPassword, user.getPassword())) {
throw new RuntimeException("原密码错误"); throw new RuntimeException("原密码错误");
} }
user.setSalt(generateSalt()); passwordService.validatePassword(newPassword);
user.setPassword(encryptPassword(newPassword, user.getSalt()));
if (passwordService.isPasswordWeak(newPassword)) {
throw new RuntimeException("新密码太弱,请使用更复杂的密码");
}
user.setPassword(passwordService.encode(newPassword));
userRepository.save(user); userRepository.save(user);
} }
@Transactional @Transactional
public void resetPassword(UUID id, String newPassword) { public void resetPassword(UUID id, String newPassword) {
User user = findById(id); User user = findById(id);
user.setSalt(generateSalt());
user.setPassword(encryptPassword(newPassword, user.getSalt())); passwordService.validatePassword(newPassword);
if (passwordService.isPasswordWeak(newPassword)) {
throw new RuntimeException("密码太弱,请使用更复杂的密码");
}
user.setPassword(passwordService.encode(newPassword));
userRepository.save(user); userRepository.save(user);
} }
@ -112,24 +125,10 @@ public class UserService {
userRepository.deleteById(id); userRepository.deleteById(id);
} }
public boolean verifyPassword(User user, String rawPassword) {
String encrypted = encryptPassword(rawPassword, user.getSalt());
return encrypted.equals(user.getPassword());
}
public void updateLoginInfo(UUID userId, String ip) { public void updateLoginInfo(UUID userId, String ip) {
User user = findById(userId); User user = findById(userId);
user.setLastLoginTime(LocalDateTime.now()); user.setLastLoginTime(LocalDateTime.now());
user.setLastLoginIp(ip); user.setLastLoginIp(ip);
userRepository.save(user); userRepository.save(user);
} }
private String generateSalt() {
return UUID.randomUUID().toString().replace("-", "").substring(0, 16);
}
private String encryptPassword(String password, String salt) {
String combined = password + salt;
return DigestUtils.md5DigestAsHex(combined.getBytes(StandardCharsets.UTF_8));
}
} }

View File

@ -39,3 +39,24 @@ springdoc:
path: /api-docs path: /api-docs
swagger-ui: swagger-ui:
path: /swagger-ui.html path: /swagger-ui.html
# 密码策略配置
password:
min-length: 8
max-length: 20
require-uppercase: true
require-lowercase: true
require-digit: true
require-special: true
special-chars: "!@#$%^&*()_+-=[]{}|;':\",./<>?"
# 登录安全配置
login:
max-attempts: 5
lockout-duration-minutes: 10
# JWT配置
jwt:
secret: ether-pms-secret-key-must-be-at-least-256-bits-long-for-hs256
expiration: 86400000
issuer: ether-pms

View File

@ -9,7 +9,7 @@
CREATE TABLE IF NOT EXISTS auth_user ( CREATE TABLE IF NOT EXISTS auth_user (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username VARCHAR(50) UNIQUE NOT NULL, username VARCHAR(50) UNIQUE NOT NULL,
password VARCHAR(64) NOT NULL, password VARCHAR(100) NOT NULL,
salt VARCHAR(32), salt VARCHAR(32),
real_name VARCHAR(50), real_name VARCHAR(50),
phone VARCHAR(20), phone VARCHAR(20),
@ -137,10 +137,11 @@ CREATE INDEX idx_mdm_space_node_parent ON mdm_space_node(parent_code);
-- Initial Data -- Initial Data
-- ============================================ -- ============================================
-- Insert default admin user (password: admin123, salt: admin) -- Insert default admin user
-- Password = MD5("admin123" + "admin") = "fcea920f7412b5da7be0cf42b8c93759" -- Password: Admin123! (BCrypt encrypted)
INSERT INTO auth_user (username, password, salt, real_name, status) -- Password requirements: 8-20 chars, uppercase, lowercase, digit, special char
VALUES ('admin', 'fcea920f7412b5da7be0cf42b8c93759', 'admin', '系统管理员', 'ACTIVE'); INSERT INTO auth_user (username, password, real_name, status)
VALUES ('admin', '$2a$10$N9qo8uLOickgx2ZMRZoMye/U.N4.5F.HQW5R.HGmh3R1VJfF5WQa', '系统管理员', 'ACTIVE');
-- Insert default roles -- Insert default roles
INSERT INTO auth_role (code, name, description, type, data_scope, status) INSERT INTO auth_role (code, name, description, type, data_scope, status)