From 2f8ac15434a398ce0526077f535866ab1cf47eef Mon Sep 17 00:00:00 2001 From: chiguyong Date: Tue, 17 Mar 2026 22:38:56 +0800 Subject: [PATCH] =?UTF-8?q?refactor(auth):=20=E5=AF=86=E7=A0=81=E7=AD=96?= =?UTF-8?q?=E7=95=A5=E5=92=8C=E7=99=BB=E5=BD=95=E9=99=90=E5=88=B6=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E9=85=8D=E7=BD=AE=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PasswordService 支持配置化 - LoginAttemptService 支持配置化 - 配置文件添加password和login配置项 --- module-auth/pom.xml | 5 + .../pms/auth/service/LoginAttemptService.java | 79 ++++++++++ .../ether/pms/auth/service/LoginService.java | 32 ++-- .../pms/auth/service/PasswordService.java | 140 ++++++++++++++++++ .../ether/pms/auth/service/UserService.java | 49 +++--- .../src/main/resources/application.yml | 21 +++ sql/init.sql | 11 +- 7 files changed, 297 insertions(+), 40 deletions(-) create mode 100644 module-auth/src/main/java/com/ether/pms/auth/service/LoginAttemptService.java create mode 100644 module-auth/src/main/java/com/ether/pms/auth/service/PasswordService.java 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)