From 6c3474e4d6b6796aa3e8fdb1201fd70522b446f6 Mon Sep 17 00:00:00 2001 From: waner Date: Fri, 24 Apr 2026 10:06:31 +0800 Subject: [PATCH] =?UTF-8?q?feat=EF=BC=9A=E5=AF=86=E7=A0=81=E6=94=B9?= =?UTF-8?q?=E5=AF=86=E5=A4=8D=E6=9D=82=E5=BA=A6=E8=A6=81=E6=B1=82=E5=92=8C?= =?UTF-8?q?=E5=AE=9A=E6=9C=9F30=E5=A4=A9=E8=BF=87=E6=9C=9F=E7=AD=96?= =?UTF-8?q?=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/dto/AdminChangePasswordRequest.java | 2 +- .../auth/dto/ChangePasswordRequest.java | 2 +- .../auth/entity/AuthFullAccountEntity.java | 9 ++ .../auth/entity/AuthUserAccountEntity.java | 9 ++ .../service/PasswordComplexityValidator.java | 45 ++++++ .../service/impl/AuthAdminServiceImpl.java | 8 ++ .../auth/service/impl/AuthServiceImpl.java | 28 +++- .../db/migration/V1__tms_schema_full.sql | 14 ++ .../mapper/auth/AuthFullAccountMapper.xml | 3 + .../mapper/auth/AuthUserAccountMapper.xml | 3 + .../auth/controller/AuthControllerTest.java | 16 +-- .../auth/service/AuthAdminServiceTest.java | 49 ++++++- .../modules/auth/service/AuthServiceTest.java | 134 +++++++++++++++++- 13 files changed, 298 insertions(+), 24 deletions(-) create mode 100644 src/main/java/com/cisd/tms/modules/auth/service/PasswordComplexityValidator.java diff --git a/src/main/java/com/cisd/tms/modules/auth/dto/AdminChangePasswordRequest.java b/src/main/java/com/cisd/tms/modules/auth/dto/AdminChangePasswordRequest.java index f4d211a..4d0ca87 100644 --- a/src/main/java/com/cisd/tms/modules/auth/dto/AdminChangePasswordRequest.java +++ b/src/main/java/com/cisd/tms/modules/auth/dto/AdminChangePasswordRequest.java @@ -11,7 +11,7 @@ public class AdminChangePasswordRequest { private String oldPassword; @NotBlank(message = "newPassword is required") - @Schema(description = "新口令", example = "87654321") + @Schema(description = "新口令", example = "中Abc1234") private String newPassword; public String getOldPassword() { diff --git a/src/main/java/com/cisd/tms/modules/auth/dto/ChangePasswordRequest.java b/src/main/java/com/cisd/tms/modules/auth/dto/ChangePasswordRequest.java index 5f3ab10..e030c71 100644 --- a/src/main/java/com/cisd/tms/modules/auth/dto/ChangePasswordRequest.java +++ b/src/main/java/com/cisd/tms/modules/auth/dto/ChangePasswordRequest.java @@ -13,7 +13,7 @@ public class ChangePasswordRequest { private String oldPassword; @NotBlank(message = "newPassword is required") - @Schema(description = "新口令", example = "87654321") + @Schema(description = "新口令", example = "中Abc1234") private String newPassword; public String getOldPassword() { diff --git a/src/main/java/com/cisd/tms/modules/auth/entity/AuthFullAccountEntity.java b/src/main/java/com/cisd/tms/modules/auth/entity/AuthFullAccountEntity.java index 3bb78e3..4c6c083 100644 --- a/src/main/java/com/cisd/tms/modules/auth/entity/AuthFullAccountEntity.java +++ b/src/main/java/com/cisd/tms/modules/auth/entity/AuthFullAccountEntity.java @@ -15,6 +15,7 @@ public class AuthFullAccountEntity extends BaseEntity { private String passwordSalt; private String status; private Boolean needChangePassword; + private LocalDateTime passwordChangedAt; private Integer failedCount; private LocalDateTime lockedUntil; private LocalDateTime lastLoginAt; @@ -84,6 +85,14 @@ public class AuthFullAccountEntity extends BaseEntity { this.needChangePassword = needChangePassword; } + public LocalDateTime getPasswordChangedAt() { + return passwordChangedAt; + } + + public void setPasswordChangedAt(LocalDateTime passwordChangedAt) { + this.passwordChangedAt = passwordChangedAt; + } + public Integer getFailedCount() { return failedCount; } diff --git a/src/main/java/com/cisd/tms/modules/auth/entity/AuthUserAccountEntity.java b/src/main/java/com/cisd/tms/modules/auth/entity/AuthUserAccountEntity.java index 63e68e4..139daeb 100644 --- a/src/main/java/com/cisd/tms/modules/auth/entity/AuthUserAccountEntity.java +++ b/src/main/java/com/cisd/tms/modules/auth/entity/AuthUserAccountEntity.java @@ -14,6 +14,7 @@ public class AuthUserAccountEntity extends BaseEntity { private String passwordSalt; private String status; private Boolean needChangePassword; + private LocalDateTime passwordChangedAt; private Integer failedCount; private LocalDateTime lockedUntil; private LocalDateTime lastLoginAt; @@ -75,6 +76,14 @@ public class AuthUserAccountEntity extends BaseEntity { this.needChangePassword = needChangePassword; } + public LocalDateTime getPasswordChangedAt() { + return passwordChangedAt; + } + + public void setPasswordChangedAt(LocalDateTime passwordChangedAt) { + this.passwordChangedAt = passwordChangedAt; + } + public Integer getFailedCount() { return failedCount; } diff --git a/src/main/java/com/cisd/tms/modules/auth/service/PasswordComplexityValidator.java b/src/main/java/com/cisd/tms/modules/auth/service/PasswordComplexityValidator.java new file mode 100644 index 0000000..185d1ff --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/auth/service/PasswordComplexityValidator.java @@ -0,0 +1,45 @@ +package com.cisd.tms.modules.auth.service; + +import com.cisd.tms.common.enums.ErrorCode; +import com.cisd.tms.common.exception.BizException; + +public final class PasswordComplexityValidator { + + public static final String MESSAGE = "密码复杂度不符合要求:需包含中文、英文、数字或特殊字符,长度不少于八位"; + + private PasswordComplexityValidator() { + } + + public static void validate(String password) { + String value = password == null ? "" : password; + if (value.length() < 8 || !containsChinese(value) || !containsEnglish(value) || !containsDigitOrSpecial(value)) { + throw new BizException(ErrorCode.VALIDATE_FAILED.getCode(), MESSAGE); + } + } + + private static boolean containsChinese(String value) { + return value.codePoints().anyMatch(codePoint -> Character.UnicodeScript.of(codePoint) == Character.UnicodeScript.HAN); + } + + private static boolean containsEnglish(String value) { + return value.codePoints().anyMatch(codePoint -> + (codePoint >= 'A' && codePoint <= 'Z') || (codePoint >= 'a' && codePoint <= 'z')); + } + + private static boolean containsDigitOrSpecial(String value) { + return value.codePoints().anyMatch(codePoint -> + Character.isDigit(codePoint) || isSpecial(codePoint)); + } + + private static boolean isSpecial(int codePoint) { + return !Character.isWhitespace(codePoint) + && !Character.isDigit(codePoint) + && !isChineseOrEnglish(codePoint); + } + + private static boolean isChineseOrEnglish(int codePoint) { + return Character.UnicodeScript.of(codePoint) == Character.UnicodeScript.HAN + || (codePoint >= 'A' && codePoint <= 'Z') + || (codePoint >= 'a' && codePoint <= 'z'); + } +} diff --git a/src/main/java/com/cisd/tms/modules/auth/service/impl/AuthAdminServiceImpl.java b/src/main/java/com/cisd/tms/modules/auth/service/impl/AuthAdminServiceImpl.java index 5416f46..8afcede 100644 --- a/src/main/java/com/cisd/tms/modules/auth/service/impl/AuthAdminServiceImpl.java +++ b/src/main/java/com/cisd/tms/modules/auth/service/impl/AuthAdminServiceImpl.java @@ -14,6 +14,7 @@ import com.cisd.tms.modules.auth.repository.AuthUserAccountRepository; import com.cisd.tms.modules.auth.repository.RoleAccountRepository; import com.cisd.tms.modules.auth.repository.RoleUkeyBindingRepository; import com.cisd.tms.modules.auth.service.AuthAdminService; +import com.cisd.tms.modules.auth.service.PasswordComplexityValidator; import com.cisd.tms.modules.auth.service.PasswordHasher; import com.cisd.tms.modules.auth.service.PasswordSaltGenerator; import com.cisd.tms.modules.mk.dto.MasterKeyBackupPacket; @@ -87,6 +88,7 @@ public class AuthAdminServiceImpl implements AuthAdminService { RoleAccountEntity target = loadRole(targetRoleCode); target.setStatus(RoleAccountStatus.ACTIVE.name()); roleAccountRepository.update(target); + LocalDateTime current = now(); List fullAccounts = authFullAccountRepository.findByRoleCode(targetRoleCode); if (fullAccounts.isEmpty()) { @@ -98,6 +100,7 @@ public class AuthAdminServiceImpl implements AuthAdminService { account.setPasswordHash(passwordHasher.hash(DEFAULT_PASSWORD, newSalt)); account.setStatus(RoleAccountStatus.ACTIVE.name()); account.setNeedChangePassword(Boolean.TRUE); + account.setPasswordChangedAt(current); account.setFailedCount(0); account.setLockedUntil(null); account.setLastActiveAt(null); @@ -115,6 +118,7 @@ public class AuthAdminServiceImpl implements AuthAdminService { account.setPasswordHash(passwordHasher.hash(DEFAULT_PASSWORD, newSalt)); account.setStatus(RoleAccountStatus.ACTIVE.name()); account.setNeedChangePassword(Boolean.TRUE); + account.setPasswordChangedAt(current); account.setFailedCount(0); account.setLockedUntil(null); account.setLastActiveAt(null); @@ -132,6 +136,7 @@ public class AuthAdminServiceImpl implements AuthAdminService { String oldPassword, String newPassword ) { + PasswordComplexityValidator.validate(newPassword); loadRole(targetRoleCode); RoleCode targetRole = resolveRoleCode(targetRoleCode); if (uid == null || uid < 1 || uid > targetRole.getRequiredUkeyCount()) { @@ -155,6 +160,7 @@ public class AuthAdminServiceImpl implements AuthAdminService { String oldPassword, String newPassword ) { + PasswordComplexityValidator.validate(newPassword); loadRole(targetRoleCode); AuthUserAccountEntity account = authUserAccountRepository.findByUsername(username) .orElseThrow(() -> new BizException(ErrorCode.VALIDATE_FAILED.getCode(), "target role user account not found")); @@ -262,6 +268,7 @@ public class AuthAdminServiceImpl implements AuthAdminService { account.setPasswordSalt(newSalt); account.setPasswordHash(passwordHasher.hash(newPassword, newSalt)); account.setNeedChangePassword(Boolean.TRUE); + account.setPasswordChangedAt(now()); account.setFailedCount(0); account.setLockedUntil(null); if (RoleAccountStatus.LOCKED.name().equals(account.getStatus())) { @@ -274,6 +281,7 @@ public class AuthAdminServiceImpl implements AuthAdminService { account.setPasswordSalt(newSalt); account.setPasswordHash(passwordHasher.hash(newPassword, newSalt)); account.setNeedChangePassword(Boolean.TRUE); + account.setPasswordChangedAt(now()); account.setFailedCount(0); account.setLockedUntil(null); if (RoleAccountStatus.LOCKED.name().equals(account.getStatus())) { diff --git a/src/main/java/com/cisd/tms/modules/auth/service/impl/AuthServiceImpl.java b/src/main/java/com/cisd/tms/modules/auth/service/impl/AuthServiceImpl.java index 1b34d94..8145275 100644 --- a/src/main/java/com/cisd/tms/modules/auth/service/impl/AuthServiceImpl.java +++ b/src/main/java/com/cisd/tms/modules/auth/service/impl/AuthServiceImpl.java @@ -35,6 +35,7 @@ import com.cisd.tms.modules.auth.service.AuthService; import com.cisd.tms.modules.auth.service.CaptchaService; import com.cisd.tms.modules.auth.service.CompatUkeyVerifier; import com.cisd.tms.modules.auth.service.PasswordHasher; +import com.cisd.tms.modules.auth.service.PasswordComplexityValidator; import com.cisd.tms.modules.auth.service.PasswordSaltGenerator; import com.cisd.tms.modules.auth.service.SessionTokenGenerator; import com.cisd.tms.modules.auth.service.UkeyLoginRandomService; @@ -65,6 +66,7 @@ public class AuthServiceImpl implements AuthService { private static final int MAX_FAILED_ATTEMPTS = 5; private static final int IDLE_TIMEOUT_MINUTES = 10; + private static final int PASSWORD_EXPIRE_DAYS = 30; private final RoleAccountRepository roleAccountRepository; private final AuthUserRepository authUserRepository; @@ -236,6 +238,7 @@ public class AuthServiceImpl implements AuthService { @Override public void changeFullAccountPassword(String sessionToken, Integer uid, String currentPassword, String newPassword) { + PasswordComplexityValidator.validate(newPassword); AuthSessionEntity session = requireActiveSession(sessionToken); if (!AuthLevel.FULL.name().equals(session.getAuthLevel())) { throw new BizException(ErrorCode.UNAUTHORIZED.getCode(), "full session is required"); @@ -258,6 +261,7 @@ public class AuthServiceImpl implements AuthService { fullAccount.setLockedUntil(null); fullAccount.setStatus(RoleAccountStatus.ACTIVE.name()); fullAccount.setNeedChangePassword(Boolean.FALSE); + fullAccount.setPasswordChangedAt(current); fullAccount.setLastLoginAt(current); fullAccount.setLastActiveAt(current); authFullAccountRepository.update(fullAccount); @@ -269,6 +273,7 @@ public class AuthServiceImpl implements AuthService { @Override public void changeLimitedAccountPassword(String sessionToken, String username, String currentPassword, String newPassword) { + PasswordComplexityValidator.validate(newPassword); AuthSessionEntity session = requireActiveSession(sessionToken); if (!AuthLevel.LIMITED.name().equals(session.getAuthLevel())) { throw new BizException(ErrorCode.UNAUTHORIZED.getCode(), "limited session is required"); @@ -292,6 +297,7 @@ public class AuthServiceImpl implements AuthService { userAccount.setLockedUntil(null); userAccount.setStatus(RoleAccountStatus.ACTIVE.name()); userAccount.setNeedChangePassword(Boolean.FALSE); + userAccount.setPasswordChangedAt(current); userAccount.setLastLoginAt(current); userAccount.setLastActiveAt(current); authUserAccountRepository.update(userAccount); @@ -652,11 +658,11 @@ public class AuthServiceImpl implements AuthService { } private boolean needsPasswordChangeForLimitedAccounts(List accounts) { - return accounts != null && accounts.stream().anyMatch(account -> Boolean.TRUE.equals(account.getNeedChangePassword())); + return accounts != null && accounts.stream().anyMatch(this::needsPasswordChange); } private boolean needsPasswordChangeForFullAccounts(List accounts) { - return accounts != null && accounts.stream().anyMatch(account -> Boolean.TRUE.equals(account.getNeedChangePassword())); + return accounts != null && accounts.stream().anyMatch(this::needsPasswordChange); } private boolean needsPasswordChange(AuthSessionEntity session) { @@ -672,14 +678,28 @@ public class AuthServiceImpl implements AuthService { return accounts.stream() .filter(account -> principals.stream() .anyMatch(principal -> "FULL".equals(principal.getType()) && account.getUid().equals(principal.getUid()))) - .anyMatch(account -> Boolean.TRUE.equals(account.getNeedChangePassword())); + .anyMatch(this::needsPasswordChange); } List accounts = authUserAccountRepository.findByRoleCode(session.getRoleCode()); return accounts.stream() .filter(account -> principals.stream() .anyMatch(principal -> "LIMITED".equals(principal.getType()) && account.getUsername().equals(principal.getUsername()))) - .anyMatch(account -> Boolean.TRUE.equals(account.getNeedChangePassword())); + .anyMatch(this::needsPasswordChange); + } + + private boolean needsPasswordChange(AuthFullAccountEntity account) { + return Boolean.TRUE.equals(account.getNeedChangePassword()) + || isPasswordExpired(account.getPasswordChangedAt()); + } + + private boolean needsPasswordChange(AuthUserAccountEntity account) { + return Boolean.TRUE.equals(account.getNeedChangePassword()) + || isPasswordExpired(account.getPasswordChangedAt()); + } + + private boolean isPasswordExpired(LocalDateTime passwordChangedAt) { + return passwordChangedAt == null || !passwordChangedAt.isAfter(now().minusDays(PASSWORD_EXPIRE_DAYS)); } private AuthSessionEntity requireActiveSession(String sessionToken) { diff --git a/src/main/resources/db/migration/V1__tms_schema_full.sql b/src/main/resources/db/migration/V1__tms_schema_full.sql index 7f7c9bc..6146194 100644 --- a/src/main/resources/db/migration/V1__tms_schema_full.sql +++ b/src/main/resources/db/migration/V1__tms_schema_full.sql @@ -275,6 +275,7 @@ CREATE TABLE IF NOT EXISTS tms_auth_user_account ( password_salt VARCHAR(128) NOT NULL, status VARCHAR(32) NOT NULL, need_change_password TINYINT(1) NOT NULL DEFAULT 1, + password_changed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), failed_count INT NOT NULL DEFAULT 0, locked_until DATETIME(3) NULL, last_login_at DATETIME(3) NULL, @@ -295,6 +296,7 @@ CREATE TABLE IF NOT EXISTS tms_auth_full_account ( password_salt VARCHAR(128) NOT NULL, status VARCHAR(32) NOT NULL, need_change_password TINYINT(1) NOT NULL DEFAULT 1, + password_changed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), failed_count INT NOT NULL DEFAULT 0, locked_until DATETIME(3) NULL, last_login_at DATETIME(3) NULL, @@ -445,6 +447,7 @@ INSERT IGNORE INTO tms_auth_user_account ( password_salt, status, need_change_password, + password_changed_at, failed_count, locked_until, last_login_at, @@ -462,6 +465,7 @@ VALUES 'init-super-admin-salt-20260325', 'ACTIVE', 1, + CURRENT_TIMESTAMP(3), 0, NULL, NULL, @@ -478,6 +482,7 @@ VALUES 'init-super-admin-salt-20260325', 'ACTIVE', 1, + CURRENT_TIMESTAMP(3), 0, NULL, NULL, @@ -494,6 +499,7 @@ VALUES 'init-key-admin-salt-20260325', 'ACTIVE', 1, + CURRENT_TIMESTAMP(3), 0, NULL, NULL, @@ -510,6 +516,7 @@ VALUES 'init-audit-admin-salt-20260325', 'ACTIVE', 1, + CURRENT_TIMESTAMP(3), 0, NULL, NULL, @@ -526,6 +533,7 @@ VALUES 'init-ops-admin-salt-20260325', 'ACTIVE', 1, + CURRENT_TIMESTAMP(3), 0, NULL, NULL, @@ -544,6 +552,7 @@ INSERT IGNORE INTO tms_auth_full_account ( password_salt, status, need_change_password, + password_changed_at, failed_count, locked_until, last_login_at, @@ -562,6 +571,7 @@ VALUES 'init-super-admin-salt-20260325', 'ACTIVE', 1, + CURRENT_TIMESTAMP(3), 0, NULL, NULL, @@ -579,6 +589,7 @@ VALUES 'init-super-admin-salt-20260325', 'ACTIVE', 1, + CURRENT_TIMESTAMP(3), 0, NULL, NULL, @@ -596,6 +607,7 @@ VALUES 'init-key-admin-salt-20260325', 'ACTIVE', 1, + CURRENT_TIMESTAMP(3), 0, NULL, NULL, @@ -613,6 +625,7 @@ VALUES 'init-audit-admin-salt-20260325', 'ACTIVE', 1, + CURRENT_TIMESTAMP(3), 0, NULL, NULL, @@ -630,6 +643,7 @@ VALUES 'init-ops-admin-salt-20260325', 'ACTIVE', 1, + CURRENT_TIMESTAMP(3), 0, NULL, NULL, diff --git a/src/main/resources/mapper/auth/AuthFullAccountMapper.xml b/src/main/resources/mapper/auth/AuthFullAccountMapper.xml index 2b3414a..c9c71bd 100644 --- a/src/main/resources/mapper/auth/AuthFullAccountMapper.xml +++ b/src/main/resources/mapper/auth/AuthFullAccountMapper.xml @@ -14,6 +14,7 @@ + @@ -32,6 +33,7 @@ password_salt, status, need_change_password, + password_changed_at, failed_count, locked_until, last_login_at, @@ -54,6 +56,7 @@ password_salt, status, need_change_password, + password_changed_at, failed_count, locked_until, last_login_at, diff --git a/src/main/resources/mapper/auth/AuthUserAccountMapper.xml b/src/main/resources/mapper/auth/AuthUserAccountMapper.xml index 279e606..9d505d9 100644 --- a/src/main/resources/mapper/auth/AuthUserAccountMapper.xml +++ b/src/main/resources/mapper/auth/AuthUserAccountMapper.xml @@ -13,6 +13,7 @@ + @@ -30,6 +31,7 @@ password_salt, status, need_change_password, + password_changed_at, failed_count, locked_until, last_login_at, @@ -50,6 +52,7 @@ password_salt, status, need_change_password, + password_changed_at, failed_count, locked_until, last_login_at, diff --git a/src/test/java/com/cisd/tms/modules/auth/controller/AuthControllerTest.java b/src/test/java/com/cisd/tms/modules/auth/controller/AuthControllerTest.java index 04ccb6a..83af17f 100644 --- a/src/test/java/com/cisd/tms/modules/auth/controller/AuthControllerTest.java +++ b/src/test/java/com/cisd/tms/modules/auth/controller/AuthControllerTest.java @@ -214,13 +214,13 @@ class AuthControllerTest { .content(""" { "oldPassword": "12345678", - "newPassword": "87654321" + "newPassword": "中Abc1234" } """)) .andExpect(status().isOk()) .andExpect(content().string(containsString("\"success\":true"))); - Mockito.verify(authService).changeFullAccountPassword("token-change-001", 1, "12345678", "87654321"); + Mockito.verify(authService).changeFullAccountPassword("token-change-001", 1, "12345678", "中Abc1234"); } @Test @@ -239,13 +239,13 @@ class AuthControllerTest { .content(""" { "oldPassword": "12345678", - "newPassword": "87654321" + "newPassword": "中Abc1234" } """)) .andExpect(status().isOk()) .andExpect(content().string(containsString("\"success\":true"))); - Mockito.verify(authService).changeLimitedAccountPassword("token-change-limited-001", "audit-admin-01", "12345678", "87654321"); + Mockito.verify(authService).changeLimitedAccountPassword("token-change-limited-001", "audit-admin-01", "12345678", "中Abc1234"); } @Test @@ -312,13 +312,13 @@ class AuthControllerTest { .content(""" { "oldPassword": "12345678", - "newPassword": "87654321" + "newPassword": "中Abc1234" } """)) .andExpect(status().isOk()) .andExpect(content().string(containsString("\"success\":true"))); - Mockito.verify(authAdminService).changeFullAccountPassword("SUPER_ADMIN", "FULL", "KEY_ADMIN", 1, "12345678", "87654321"); + Mockito.verify(authAdminService).changeFullAccountPassword("SUPER_ADMIN", "FULL", "KEY_ADMIN", 1, "12345678", "中Abc1234"); } @Test @@ -338,13 +338,13 @@ class AuthControllerTest { .content(""" { "oldPassword": "12345678", - "newPassword": "87654321" + "newPassword": "中Abc1234" } """)) .andExpect(status().isOk()) .andExpect(content().string(containsString("\"success\":true"))); - Mockito.verify(authAdminService).changeLimitedAccountPassword("SUPER_ADMIN", "FULL", "AUDIT_ADMIN", "audit-admin-01", "12345678", "87654321"); + Mockito.verify(authAdminService).changeLimitedAccountPassword("SUPER_ADMIN", "FULL", "AUDIT_ADMIN", "audit-admin-01", "12345678", "中Abc1234"); } @Test diff --git a/src/test/java/com/cisd/tms/modules/auth/service/AuthAdminServiceTest.java b/src/test/java/com/cisd/tms/modules/auth/service/AuthAdminServiceTest.java index 1f23163..37fdad3 100644 --- a/src/test/java/com/cisd/tms/modules/auth/service/AuthAdminServiceTest.java +++ b/src/test/java/com/cisd/tms/modules/auth/service/AuthAdminServiceTest.java @@ -107,6 +107,8 @@ class AuthAdminServiceTest { Assertions.assertEquals(RoleAccountStatus.ACTIVE.name(), fullSecond.getStatus()); Assertions.assertTrue(Boolean.TRUE.equals(fullFirst.getNeedChangePassword())); Assertions.assertTrue(Boolean.TRUE.equals(fullSecond.getNeedChangePassword())); + Assertions.assertEquals(LocalDateTime.of(2026, 3, 23, 3, 0), fullFirst.getPasswordChangedAt()); + Assertions.assertEquals(LocalDateTime.of(2026, 3, 23, 3, 0), fullSecond.getPasswordChangedAt()); Assertions.assertEquals(0, fullFirst.getFailedCount()); Assertions.assertEquals(0, fullSecond.getFailedCount()); Assertions.assertNull(fullFirst.getLockedUntil()); @@ -119,6 +121,8 @@ class AuthAdminServiceTest { Assertions.assertEquals(RoleAccountStatus.ACTIVE.name(), second.getStatus()); Assertions.assertTrue(Boolean.TRUE.equals(first.getNeedChangePassword())); Assertions.assertTrue(Boolean.TRUE.equals(second.getNeedChangePassword())); + Assertions.assertEquals(LocalDateTime.of(2026, 3, 23, 3, 0), first.getPasswordChangedAt()); + Assertions.assertEquals(LocalDateTime.of(2026, 3, 23, 3, 0), second.getPasswordChangedAt()); Assertions.assertEquals(0, first.getFailedCount()); Assertions.assertEquals(0, second.getFailedCount()); Assertions.assertNull(first.getLockedUntil()); @@ -155,14 +159,15 @@ class AuthAdminServiceTest { RoleCode.KEY_ADMIN.getCode(), 1, "12345678", - "87654321" + "中Abc1234" ); AuthFullAccountEntity changed = fullAccounts.findByRoleCodeAndUid(RoleCode.KEY_ADMIN.getCode(), 1).orElseThrow(); Assertions.assertEquals("salt-full-admin", changed.getPasswordSalt()); - Assertions.assertEquals("HASH:87654321:salt-full-admin", changed.getPasswordHash()); + Assertions.assertEquals("HASH:中Abc1234:salt-full-admin", changed.getPasswordHash()); Assertions.assertEquals(RoleAccountStatus.ACTIVE.name(), changed.getStatus()); Assertions.assertTrue(Boolean.TRUE.equals(changed.getNeedChangePassword())); + Assertions.assertEquals(LocalDateTime.of(2026, 3, 23, 3, 0), changed.getPasswordChangedAt()); Assertions.assertEquals(0, changed.getFailedCount()); Assertions.assertNull(changed.getLockedUntil()); } @@ -193,14 +198,15 @@ class AuthAdminServiceTest { RoleCode.AUDIT_ADMIN.getCode(), "audit-admin-01", "12345678", - "87654321" + "中Abc1234" ); AuthUserAccountEntity changed = userAccounts.findByUsername("audit-admin-01").orElseThrow(); Assertions.assertEquals("salt-limited-admin", changed.getPasswordSalt()); - Assertions.assertEquals("HASH:87654321:salt-limited-admin", changed.getPasswordHash()); + Assertions.assertEquals("HASH:中Abc1234:salt-limited-admin", changed.getPasswordHash()); Assertions.assertEquals(RoleAccountStatus.ACTIVE.name(), changed.getStatus()); Assertions.assertTrue(Boolean.TRUE.equals(changed.getNeedChangePassword())); + Assertions.assertEquals(LocalDateTime.of(2026, 3, 23, 3, 0), changed.getPasswordChangedAt()); Assertions.assertEquals(0, changed.getFailedCount()); Assertions.assertNull(changed.getLockedUntil()); } @@ -229,7 +235,7 @@ class AuthAdminServiceTest { RoleCode.AUDIT_ADMIN.getCode(), "audit-admin-01", "bad-password", - "87654321" + "中Abc1234" ) ); @@ -239,6 +245,37 @@ class AuthAdminServiceTest { Assertions.assertEquals("HASH:12345678:OLD-SALT", unchanged.getPasswordHash()); } + @Test + void shouldRejectAdminPasswordChangeWhenNewPasswordIsTooSimple() { + InMemoryRoleAccountRepository roleAccounts = new InMemoryRoleAccountRepository(); + InMemoryAuthUserAccountRepository userAccounts = new InMemoryAuthUserAccountRepository(); + roleAccounts.save(role(RoleCode.AUDIT_ADMIN, RoleAccountStatus.ACTIVE)); + userAccounts.save(user("audit-admin-01", RoleCode.AUDIT_ADMIN, "HASH:12345678:OLD-SALT", "OLD-SALT")); + + AuthAdminService service = newAuthAdminService( + roleAccounts, + new InMemoryAuthFullAccountRepository(), + userAccounts, + new InMemoryRoleUkeyBindingRepository(), + FIXED_CLOCK, + new FixedSaltSupplier("salt-unused") + ); + + com.cisd.tms.common.exception.BizException exception = Assertions.assertThrows( + com.cisd.tms.common.exception.BizException.class, + () -> service.changeLimitedAccountPassword( + RoleCode.SUPER_ADMIN.getCode(), + AuthLevel.FULL.name(), + RoleCode.AUDIT_ADMIN.getCode(), + "audit-admin-01", + "12345678", + "87654321" + ) + ); + + Assertions.assertEquals("密码复杂度不符合要求:需包含中文、英文、数字或特殊字符,长度不少于八位", exception.getMessage()); + } + @Test void shouldReplaceActiveBindingInSameUidSeat() { InMemoryRoleUkeyBindingRepository bindings = new InMemoryRoleUkeyBindingRepository(); @@ -324,6 +361,7 @@ class AuthAdminServiceTest { entity.setPasswordSalt(passwordSalt); entity.setStatus(RoleAccountStatus.ACTIVE.name()); entity.setNeedChangePassword(Boolean.FALSE); + entity.setPasswordChangedAt(LocalDateTime.of(2026, 3, 23, 2, 0)); entity.setFailedCount(0); return entity; } @@ -339,6 +377,7 @@ class AuthAdminServiceTest { entity.setPasswordSalt(passwordSalt); entity.setStatus(RoleAccountStatus.ACTIVE.name()); entity.setNeedChangePassword(Boolean.FALSE); + entity.setPasswordChangedAt(LocalDateTime.of(2026, 3, 23, 2, 0)); entity.setFailedCount(0); return entity; } diff --git a/src/test/java/com/cisd/tms/modules/auth/service/AuthServiceTest.java b/src/test/java/com/cisd/tms/modules/auth/service/AuthServiceTest.java index c54c456..b16202d 100644 --- a/src/test/java/com/cisd/tms/modules/auth/service/AuthServiceTest.java +++ b/src/test/java/com/cisd/tms/modules/auth/service/AuthServiceTest.java @@ -83,6 +83,68 @@ class AuthServiceTest { Assertions.assertEquals("[{\"type\":\"LIMITED\",\"uid\":null,\"username\":\"audit-admin-01\"}]", session.getAuthenticatedPrincipalsJson()); } + @Test + void shouldRequirePasswordChangeWhenLimitedAccountPasswordIsExpired() { + InMemoryRoleAccountRepository roleAccounts = new InMemoryRoleAccountRepository(); + InMemoryAuthUserAccountRepository userAccounts = new InMemoryAuthUserAccountRepository(); + roleAccounts.save(activeRole(RoleCode.AUDIT_ADMIN)); + AuthUserAccountEntity account = activeUserAccount("audit-admin-01", RoleCode.AUDIT_ADMIN, "12345678", "SALT-A", 0, null); + account.setPasswordChangedAt(LocalDateTime.of(2026, 2, 21, 2, 0)); + userAccounts.save(account); + + AuthService service = newAuthService( + roleAccounts, + userAccounts, + new InMemoryAuthFullAccountRepository(), + new InMemoryAuthSessionRepository(), + new InMemoryRoleUkeyBindingRepository(), + FIXED_CLOCK, + () -> "token-limited-expired-001" + ); + + LoginRequest request = new LoginRequest(); + request.setRoleCode(RoleCode.AUDIT_ADMIN.getCode()); + request.setAccounts(List.of(account("audit-admin-01", "12345678"))); + + LoginResponse response = service.login(request); + + Assertions.assertEquals("token-limited-expired-001", response.getToken()); + Assertions.assertTrue(response.getNeedChangePassword()); + } + + @Test + void shouldRequirePasswordChangeWhenFullAccountPasswordIsExpired() { + InMemoryRoleAccountRepository roleAccounts = new InMemoryRoleAccountRepository(); + InMemoryAuthFullAccountRepository fullAccounts = new InMemoryAuthFullAccountRepository(); + InMemoryRoleUkeyBindingRepository ukeyBindings = new InMemoryRoleUkeyBindingRepository(); + roleAccounts.save(activeRole(RoleCode.KEY_ADMIN)); + AuthFullAccountEntity account = activeFullAccount(RoleCode.KEY_ADMIN, 1, "key-admin-full-01", "12345678", "SALT-K", 0, null); + account.setPasswordChangedAt(LocalDateTime.of(2026, 2, 21, 2, 0)); + fullAccounts.save(account); + ukeyBindings.save(activeBinding(RoleCode.KEY_ADMIN, 1, "UK-1", "PUB-1")); + + AuthService service = newAuthService( + roleAccounts, + new InMemoryAuthUserAccountRepository(), + fullAccounts, + new InMemoryAuthSessionRepository(), + ukeyBindings, + FIXED_CLOCK, + () -> "token-full-expired-001" + ); + + LoginRequest request = new LoginRequest(); + request.setRoleCode(RoleCode.KEY_ADMIN.getCode()); + request.setUkeySerials(List.of("UK-1")); + request.setFullAccounts(List.of(fullAccount(1, "12345678"))); + + LoginResponse response = service.login(request); + + Assertions.assertEquals("token-full-expired-001", response.getToken()); + Assertions.assertEquals(AuthLevel.FULL.name(), response.getAuthLevel()); + Assertions.assertTrue(response.getNeedChangePassword()); + } + @Test void shouldRequireTwoAccountsForSuperAdminPasswordLogin() { InMemoryRoleAccountRepository roleAccounts = new InMemoryRoleAccountRepository(); @@ -177,6 +239,35 @@ class AuthServiceTest { Assertions.assertTrue(response.getNeedChangePassword()); } + @Test + void shouldExposePasswordExpiredFlagFromMeEndpoint() { + InMemoryRoleAccountRepository roleAccounts = new InMemoryRoleAccountRepository(); + InMemoryAuthUserAccountRepository userAccounts = new InMemoryAuthUserAccountRepository(); + InMemoryAuthSessionRepository sessions = new InMemoryAuthSessionRepository(); + roleAccounts.save(activeRole(RoleCode.AUDIT_ADMIN)); + AuthUserAccountEntity account = activeUserAccount("audit-admin-01", RoleCode.AUDIT_ADMIN, "12345678", "SALT-A", 0, null); + account.setPasswordChangedAt(LocalDateTime.of(2026, 2, 21, 2, 0)); + userAccounts.save(account); + AuthSessionEntity session = session("token-me-expired-001", RoleCode.AUDIT_ADMIN.getCode(), AuthLevel.LIMITED.name()); + session.setAuthenticatedPrincipalsJson("[{\"type\":\"LIMITED\",\"uid\":null,\"username\":\"audit-admin-01\"}]"); + sessions.save(session); + + AuthService service = newAuthService( + roleAccounts, + userAccounts, + new InMemoryAuthFullAccountRepository(), + sessions, + new InMemoryRoleUkeyBindingRepository(), + FIXED_CLOCK, + () -> "unused" + ); + + CurrentUserResponse response = service.me(RoleCode.AUDIT_ADMIN.getCode(), "token-me-expired-001"); + + Assertions.assertEquals(RoleCode.AUDIT_ADMIN.getCode(), response.getRole()); + Assertions.assertTrue(response.getNeedChangePassword()); + } + @Test void shouldChangeCurrentFullAccountPasswordForActiveSession() { InMemoryRoleAccountRepository roleAccounts = new InMemoryRoleAccountRepository(); @@ -198,14 +289,15 @@ class AuthServiceTest { () -> "unused" ); - service.changeFullAccountPassword("token-change-001", 1, "12345678", "87654321"); + service.changeFullAccountPassword("token-change-001", 1, "12345678", "中Abc1234"); AuthFullAccountEntity changed = fullAccounts.findByRoleCodeAndUid(RoleCode.KEY_ADMIN.getCode(), 1).orElseThrow(); Assertions.assertEquals("salt-test", changed.getPasswordSalt()); - Assertions.assertEquals("HASH:87654321:salt-test", changed.getPasswordHash()); + Assertions.assertEquals("HASH:中Abc1234:salt-test", changed.getPasswordHash()); Assertions.assertEquals(0, changed.getFailedCount()); Assertions.assertNull(changed.getLockedUntil()); Assertions.assertFalse(Boolean.TRUE.equals(changed.getNeedChangePassword())); + Assertions.assertEquals(LocalDateTime.of(2026, 3, 23, 2, 0), changed.getPasswordChangedAt()); Assertions.assertEquals(LocalDateTime.of(2026, 3, 23, 2, 0), changed.getLastLoginAt()); Assertions.assertEquals(LocalDateTime.of(2026, 3, 23, 2, 0), changed.getLastActiveAt()); } @@ -231,18 +323,48 @@ class AuthServiceTest { () -> "unused" ); - service.changeLimitedAccountPassword("token-change-limited-001", "audit-admin-01", "12345678", "87654321"); + service.changeLimitedAccountPassword("token-change-limited-001", "audit-admin-01", "12345678", "中Abc1234"); AuthUserAccountEntity changed = userAccounts.findByUsername("audit-admin-01").orElseThrow(); Assertions.assertEquals("salt-test", changed.getPasswordSalt()); - Assertions.assertEquals("HASH:87654321:salt-test", changed.getPasswordHash()); + Assertions.assertEquals("HASH:中Abc1234:salt-test", changed.getPasswordHash()); Assertions.assertEquals(0, changed.getFailedCount()); Assertions.assertNull(changed.getLockedUntil()); Assertions.assertFalse(Boolean.TRUE.equals(changed.getNeedChangePassword())); + Assertions.assertEquals(LocalDateTime.of(2026, 3, 23, 2, 0), changed.getPasswordChangedAt()); Assertions.assertEquals(LocalDateTime.of(2026, 3, 23, 2, 0), changed.getLastLoginAt()); Assertions.assertEquals(LocalDateTime.of(2026, 3, 23, 2, 0), changed.getLastActiveAt()); } + @Test + void shouldRejectCurrentPasswordChangeWhenNewPasswordIsTooSimple() { + InMemoryRoleAccountRepository roleAccounts = new InMemoryRoleAccountRepository(); + InMemoryAuthUserAccountRepository userAccounts = new InMemoryAuthUserAccountRepository(); + InMemoryAuthSessionRepository sessions = new InMemoryAuthSessionRepository(); + roleAccounts.save(activeRole(RoleCode.AUDIT_ADMIN)); + userAccounts.save(activeUserAccount("audit-admin-01", RoleCode.AUDIT_ADMIN, "12345678", "LIMITED-SALT-A", 0, null, true)); + AuthSessionEntity session = session("token-change-limited-001", RoleCode.AUDIT_ADMIN.getCode(), AuthLevel.LIMITED.name()); + session.setAuthenticatedPrincipalsJson("[{\"type\":\"LIMITED\",\"uid\":null,\"username\":\"audit-admin-01\"}]"); + sessions.save(session); + + AuthService service = newAuthService( + roleAccounts, + userAccounts, + new InMemoryAuthFullAccountRepository(), + sessions, + new InMemoryRoleUkeyBindingRepository(), + FIXED_CLOCK, + () -> "unused" + ); + + BizException exception = Assertions.assertThrows( + BizException.class, + () -> service.changeLimitedAccountPassword("token-change-limited-001", "audit-admin-01", "12345678", "87654321") + ); + + Assertions.assertEquals("密码复杂度不符合要求:需包含中文、英文、数字或特殊字符,长度不少于八位", exception.getMessage()); + } + @Test void shouldThrowSessionInvalidWhenChangingFullAccountPasswordWithExpiredSession() { InMemoryRoleAccountRepository roleAccounts = new InMemoryRoleAccountRepository(); @@ -266,7 +388,7 @@ class AuthServiceTest { ); BizException exception = Assertions.assertThrows(BizException.class, - () -> service.changeFullAccountPassword("token-expired-001", 1, "12345678", "87654321")); + () -> service.changeFullAccountPassword("token-expired-001", 1, "12345678", "中Abc1234")); Assertions.assertEquals(ErrorCode.SESSION_INVALID.getCode(), exception.getCode()); Assertions.assertEquals("session expired", exception.getMessage()); @@ -667,6 +789,7 @@ class AuthServiceTest { entity.setPasswordHash("HASH:" + password + ":" + salt); entity.setStatus(RoleAccountStatus.ACTIVE.name()); entity.setNeedChangePassword(needChangePassword); + entity.setPasswordChangedAt(LocalDateTime.of(2026, 3, 23, 1, 0)); entity.setFailedCount(failedCount); entity.setLockedUntil(lockedUntil); return entity; @@ -704,6 +827,7 @@ class AuthServiceTest { entity.setPasswordHash("HASH:" + password + ":" + salt); entity.setStatus(RoleAccountStatus.ACTIVE.name()); entity.setNeedChangePassword(needChangePassword); + entity.setPasswordChangedAt(LocalDateTime.of(2026, 3, 23, 1, 0)); entity.setFailedCount(failedCount); entity.setLockedUntil(lockedUntil); return entity;