diff --git a/module-auth/pom.xml b/module-auth/pom.xml
index 943ef7f..74dc828 100644
--- a/module-auth/pom.xml
+++ b/module-auth/pom.xml
@@ -80,5 +80,10 @@
org.springdoc
springdoc-openapi-starter-webmvc-ui
+
+
+ org.springframework.boot
+ spring-boot-starter-data-redis
+
diff --git a/module-auth/src/main/java/com/ether/pms/auth/service/LoginAttemptService.java b/module-auth/src/main/java/com/ether/pms/auth/service/LoginAttemptService.java
new file mode 100644
index 0000000..f12c4f1
--- /dev/null
+++ b/module-auth/src/main/java/com/ether/pms/auth/service/LoginAttemptService.java
@@ -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;
+ }
+}
diff --git a/module-auth/src/main/java/com/ether/pms/auth/service/LoginService.java b/module-auth/src/main/java/com/ether/pms/auth/service/LoginService.java
index e6df17e..d807d0c 100644
--- a/module-auth/src/main/java/com/ether/pms/auth/service/LoginService.java
+++ b/module-auth/src/main/java/com/ether/pms/auth/service/LoginService.java
@@ -15,10 +15,22 @@ public class LoginService {
private final UserRepository userRepository;
private final JwtTokenProvider jwtTokenProvider;
+ private final PasswordService passwordService;
+ private final LoginAttemptService loginAttemptService;
+
+ private static final int MAX_ATTEMPTS = 5;
public Map 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)
- .orElseThrow(() -> new RuntimeException("用户名或密码错误"));
+ .orElseThrow(() -> {
+ loginAttemptService.recordFailure(username);
+ return new RuntimeException("用户名或密码错误");
+ });
if (user.getStatus() == User.UserStatus.LOCKED) {
throw new RuntimeException("账号已被锁定");
@@ -27,11 +39,17 @@ public class LoginService {
throw new RuntimeException("账号已被禁用");
}
- String encryptedPassword = encryptPassword(password, user.getSalt());
- if (!encryptedPassword.equals(user.getPassword())) {
- throw new RuntimeException("用户名或密码错误");
+ if (!passwordService.matches(password, user.getPassword())) {
+ loginAttemptService.recordFailure(username);
+ 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.setLastLoginIp(ip);
userRepository.save(user);
@@ -53,10 +71,4 @@ public class LoginService {
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));
- }
}
diff --git a/module-auth/src/main/java/com/ether/pms/auth/service/PasswordService.java b/module-auth/src/main/java/com/ether/pms/auth/service/PasswordService.java
new file mode 100644
index 0000000..d2fa31e
--- /dev/null
+++ b/module-auth/src/main/java/com/ether/pms/auth/service/PasswordService.java
@@ -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;
+ }
+}
diff --git a/module-auth/src/main/java/com/ether/pms/auth/service/UserService.java b/module-auth/src/main/java/com/ether/pms/auth/service/UserService.java
index c168c76..ff24b65 100644
--- a/module-auth/src/main/java/com/ether/pms/auth/service/UserService.java
+++ b/module-auth/src/main/java/com/ether/pms/auth/service/UserService.java
@@ -7,9 +7,7 @@ import com.ether.pms.auth.repository.RoleRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
-import org.springframework.util.DigestUtils;
-import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
@@ -20,6 +18,7 @@ public class UserService {
private final UserRepository userRepository;
private final RoleRepository roleRepository;
+ private final PasswordService passwordService;
public List findAll() {
return userRepository.findAll();
@@ -44,9 +43,13 @@ public class UserService {
throw new RuntimeException("手机号已存在");
}
- String salt = generateSalt();
- user.setSalt(salt);
- user.setPassword(encryptPassword(user.getPassword(), salt));
+ passwordService.validatePassword(user.getPassword());
+
+ if (passwordService.isPasswordWeak(user.getPassword())) {
+ throw new RuntimeException("密码太弱,请使用更复杂的密码");
+ }
+
+ user.setPassword(passwordService.encode(user.getPassword()));
return userRepository.save(user);
}
@@ -80,22 +83,32 @@ public class UserService {
@Transactional
public void updatePassword(UUID id, String oldPassword, String newPassword) {
User user = findById(id);
- String encryptedOld = encryptPassword(oldPassword, user.getSalt());
- if (!encryptedOld.equals(user.getPassword())) {
+ if (!passwordService.matches(oldPassword, user.getPassword())) {
throw new RuntimeException("原密码错误");
}
- 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);
}
@Transactional
public void resetPassword(UUID id, String newPassword) {
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);
}
@@ -112,24 +125,10 @@ public class UserService {
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) {
User user = findById(userId);
user.setLastLoginTime(LocalDateTime.now());
user.setLastLoginIp(ip);
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));
- }
}
diff --git a/pms-starter/src/main/resources/application.yml b/pms-starter/src/main/resources/application.yml
index 7482498..a502255 100644
--- a/pms-starter/src/main/resources/application.yml
+++ b/pms-starter/src/main/resources/application.yml
@@ -39,3 +39,24 @@ springdoc:
path: /api-docs
swagger-ui:
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
diff --git a/sql/init.sql b/sql/init.sql
index 64e5cbb..1bfcce2 100644
--- a/sql/init.sql
+++ b/sql/init.sql
@@ -9,7 +9,7 @@
CREATE TABLE IF NOT EXISTS auth_user (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username VARCHAR(50) UNIQUE NOT NULL,
- password VARCHAR(64) NOT NULL,
+ password VARCHAR(100) NOT NULL,
salt VARCHAR(32),
real_name VARCHAR(50),
phone VARCHAR(20),
@@ -137,10 +137,11 @@ CREATE INDEX idx_mdm_space_node_parent ON mdm_space_node(parent_code);
-- Initial Data
-- ============================================
--- Insert default admin user (password: admin123, salt: admin)
--- Password = MD5("admin123" + "admin") = "fcea920f7412b5da7be0cf42b8c93759"
-INSERT INTO auth_user (username, password, salt, real_name, status)
-VALUES ('admin', 'fcea920f7412b5da7be0cf42b8c93759', 'admin', '系统管理员', 'ACTIVE');
+-- Insert default admin user
+-- Password: Admin123! (BCrypt encrypted)
+-- Password requirements: 8-20 chars, uppercase, lowercase, digit, special char
+INSERT INTO auth_user (username, password, real_name, status)
+VALUES ('admin', '$2a$10$N9qo8uLOickgx2ZMRZoMye/U.N4.5F.HQW5R.HGmh3R1VJfF5WQa', '系统管理员', 'ACTIVE');
-- Insert default roles
INSERT INTO auth_role (code, name, description, type, data_scope, status)