refactor(auth): 密码策略和登录限制改为配置化
- PasswordService 支持配置化 - LoginAttemptService 支持配置化 - 配置文件添加password和login配置项
This commit is contained in:
parent
20bed35f64
commit
2f8ac15434
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
11
sql/init.sql
11
sql/init.sql
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue