diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/system/SysAccountController.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/system/SysAccountController.java index 50b8537c..9afeb2d0 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/system/SysAccountController.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/system/SysAccountController.java @@ -15,6 +15,7 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; import tech.easyflow.auth.entity.EncryptedCredentialDTO; import tech.easyflow.auth.service.AuthCredentialKeyService; +import tech.easyflow.auth.service.AuthService; import tech.easyflow.common.constant.enums.EnumAccountType; import tech.easyflow.common.constant.enums.EnumDataStatus; import tech.easyflow.common.domain.Result; @@ -30,6 +31,7 @@ import tech.easyflow.system.entity.vo.SysAccountImportResultVo; import tech.easyflow.system.service.SysAccountService; import tech.easyflow.system.util.SysPasswordPolicy; +import javax.annotation.Resource; import java.io.Serializable; import java.math.BigInteger; import java.net.URLEncoder; @@ -48,6 +50,8 @@ import java.util.Map; @RequestMapping("/api/v1/sysAccount") public class SysAccountController extends BaseCurdController { private final AuthCredentialKeyService credentialKeyService; + @Resource + private AuthService authService; public SysAccountController(SysAccountService service, AuthCredentialKeyService credentialKeyService) { super(service); @@ -150,7 +154,8 @@ public class SysAccountController extends BaseCurdController updatePassword(@JsonBody EncryptedCredentialDTO encryptedCredential) { @@ -159,25 +164,13 @@ public class SysAccountController extends BaseCurdController updatePassword(@JsonBody EncryptedCredentialDTO encryptedCredential) { @@ -75,25 +78,13 @@ public class UcSysAccountController { String newPassword = payload.getString("newPassword"); String confirmPassword = payload.getString("confirmPassword"); BigInteger loginAccountId = SaTokenUtil.getLoginAccount().getId(); - SysAccount record = service.getById(loginAccountId); - if (record == null) { - return Result.fail("修改失败"); - } - String pwdDb = record.getPassword(); - if (!BCrypt.checkpw(password, pwdDb)) { - return Result.fail(1, "密码不正确"); - } - if (!newPassword.equals(confirmPassword)) { - return Result.fail(2, "两次密码不一致"); - } - SysPasswordPolicy.validateStrongPassword(newPassword); - SysAccount update = new SysAccount(); - update.setId(loginAccountId); - update.setPassword(BCrypt.hashpw(newPassword)); - update.setPasswordResetRequired(false); - update.setModified(new Date()); - update.setModifiedBy(loginAccountId); - service.updateById(update); + authService.updateOwnPassword( + loginAccountId, + password, + newPassword, + confirmPassword, + StpUtil.getLoginDevice() + ); return Result.ok(); } } diff --git a/easyflow-modules/easyflow-module-auth/pom.xml b/easyflow-modules/easyflow-module-auth/pom.xml index 1a28f1d8..e1431120 100644 --- a/easyflow-modules/easyflow-module-auth/pom.xml +++ b/easyflow-modules/easyflow-module-auth/pom.xml @@ -38,5 +38,11 @@ ${junit.version} test + + org.mockito + mockito-core + 5.12.0 + test + diff --git a/easyflow-modules/easyflow-module-auth/src/main/java/tech/easyflow/auth/service/AuthService.java b/easyflow-modules/easyflow-module-auth/src/main/java/tech/easyflow/auth/service/AuthService.java index 63eb9b93..802b2341 100644 --- a/easyflow-modules/easyflow-module-auth/src/main/java/tech/easyflow/auth/service/AuthService.java +++ b/easyflow-modules/easyflow-module-auth/src/main/java/tech/easyflow/auth/service/AuthService.java @@ -5,6 +5,9 @@ import tech.easyflow.auth.entity.LoginVO; import java.math.BigInteger; +/** + * 账号认证与登录服务。 + */ public interface AuthService { /** * 登录 @@ -25,4 +28,21 @@ public interface AuthService { * 通过账号ID登录 */ LoginVO loginByAccountId(BigInteger accountId, Long timeoutSeconds); + + /** + * 修改当前账号密码。 + * + * @param accountId 账号 ID + * @param currentPassword 当前密码;强制重置密码时可为空 + * @param newPassword 新密码 + * @param confirmPassword 确认密码 + * @param loginDevice 当前登录会话设备类型 + */ + void updateOwnPassword( + BigInteger accountId, + String currentPassword, + String newPassword, + String confirmPassword, + String loginDevice + ); } diff --git a/easyflow-modules/easyflow-module-auth/src/main/java/tech/easyflow/auth/service/impl/AuthServiceImpl.java b/easyflow-modules/easyflow-module-auth/src/main/java/tech/easyflow/auth/service/impl/AuthServiceImpl.java index 204511bb..6c09fe60 100644 --- a/easyflow-modules/easyflow-module-auth/src/main/java/tech/easyflow/auth/service/impl/AuthServiceImpl.java +++ b/easyflow-modules/easyflow-module-auth/src/main/java/tech/easyflow/auth/service/impl/AuthServiceImpl.java @@ -4,6 +4,7 @@ import cn.dev33.satoken.stp.SaLoginModel; import cn.dev33.satoken.stp.StpInterface; import cn.dev33.satoken.stp.StpUtil; import cn.hutool.core.bean.BeanUtil; +import cn.hutool.crypto.digest.BCrypt; import com.mybatisflex.core.query.QueryWrapper; import com.mybatisflex.core.tenant.TenantManager; import org.springframework.stereotype.Service; @@ -23,15 +24,19 @@ import tech.easyflow.system.service.SysApiKeyService; import tech.easyflow.system.service.SysAccountService; import tech.easyflow.system.service.SysMenuService; import tech.easyflow.system.service.SysRoleService; -import cn.hutool.crypto.digest.BCrypt; +import tech.easyflow.system.util.SysPasswordPolicy; import javax.annotation.Resource; import java.math.BigInteger; import java.time.Duration; import java.util.Date; import java.util.List; +import java.util.Objects; import java.util.stream.Collectors; +/** + * 账号认证、会话创建与凭证更新服务。 + */ @Service public class AuthServiceImpl implements AuthService, StpInterface { @@ -100,6 +105,56 @@ public class AuthServiceImpl implements AuthService, StpInterface { return createApiKeyLoginVO(record, timeoutSeconds); } + /** + * 修改当前账号密码,并按数据库中的强制重置状态决定是否校验当前密码。 + * + * @param accountId 账号 ID + * @param currentPassword 当前密码;强制重置密码时可为空 + * @param newPassword 新密码 + * @param confirmPassword 确认密码 + * @param loginDevice 当前登录会话设备类型 + * @throws BusinessException 账号不存在、凭证不正确、会话来源不可信或密码更新失败时抛出 + */ + @Override + public void updateOwnPassword( + BigInteger accountId, + String currentPassword, + String newPassword, + String confirmPassword, + String loginDevice + ) { + SysAccount record = sysAccountService.getById(accountId); + if (record == null) { + throw new BusinessException(404, 1, "账号不存在"); + } + + boolean passwordResetRequired = Boolean.TRUE.equals(record.getPasswordResetRequired()); + if (passwordResetRequired) { + if (!AuthLoginSessionPolicy.WEB_DEVICE.equals(loginDevice)) { + throw new BusinessException(403, 3, "请使用账号密码登录后重置密码"); + } + } else if (currentPassword == null + || record.getPassword() == null + || !BCrypt.checkpw(currentPassword, record.getPassword())) { + throw new BusinessException(400, 1, "密码不正确"); + } + + if (!Objects.equals(newPassword, confirmPassword)) { + throw new BusinessException(400, 2, "两次密码不一致"); + } + SysPasswordPolicy.validateStrongPassword(newPassword); + + SysAccount update = new SysAccount(); + update.setId(accountId); + update.setPassword(BCrypt.hashpw(newPassword)); + update.setPasswordResetRequired(false); + update.setModified(new Date()); + update.setModifiedBy(accountId); + if (!sysAccountService.updateById(update)) { + throw new BusinessException(500, 4, "密码更新失败"); + } + } + @Override public List getPermissionList(Object loginId, String loginType) { List menus = sysMenuService.getMenusByAccountId(new SysMenu(), BigInteger.valueOf(Long.parseLong(loginId.toString()))); diff --git a/easyflow-modules/easyflow-module-auth/src/test/java/tech/easyflow/auth/service/impl/AuthServiceImplPasswordTest.java b/easyflow-modules/easyflow-module-auth/src/test/java/tech/easyflow/auth/service/impl/AuthServiceImplPasswordTest.java new file mode 100644 index 00000000..ce656394 --- /dev/null +++ b/easyflow-modules/easyflow-module-auth/src/test/java/tech/easyflow/auth/service/impl/AuthServiceImplPasswordTest.java @@ -0,0 +1,176 @@ +package tech.easyflow.auth.service.impl; + +import cn.hutool.crypto.digest.BCrypt; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.system.entity.SysAccount; +import tech.easyflow.system.service.SysAccountService; + +import java.lang.reflect.Field; +import java.math.BigInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * {@link AuthServiceImpl} 密码修改规则测试。 + */ +public class AuthServiceImplPasswordTest { + + private static final BigInteger ACCOUNT_ID = BigInteger.valueOf(10); + private static final String CURRENT_PASSWORD = "Current!123"; + private static final String NEW_PASSWORD = "Changed!456"; + + /** + * 验证强制重置状态下,Web 会话无需提交当前密码即可更新。 + * + * @throws Exception 注入测试依赖失败 + */ + @Test + public void shouldAllowForcedResetWithoutCurrentPasswordForWebSession() throws Exception { + SysAccountService accountService = mock(SysAccountService.class); + AuthServiceImpl authService = createService(accountService); + SysAccount account = buildAccount(true); + when(accountService.getById(ACCOUNT_ID)).thenReturn(account); + when(accountService.updateById(any(SysAccount.class))).thenReturn(true); + + authService.updateOwnPassword( + ACCOUNT_ID, + null, + NEW_PASSWORD, + NEW_PASSWORD, + AuthLoginSessionPolicy.WEB_DEVICE + ); + + ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(SysAccount.class); + verify(accountService).updateById(updateCaptor.capture()); + SysAccount update = updateCaptor.getValue(); + assertTrue(BCrypt.checkpw(NEW_PASSWORD, update.getPassword())); + assertFalse(update.getPasswordResetRequired()); + assertEquals(ACCOUNT_ID, update.getModifiedBy()); + } + + /** + * 验证普通改密仍需校验当前密码。 + * + * @throws Exception 注入测试依赖失败 + */ + @Test + public void shouldRequireCurrentPasswordForRegularChange() throws Exception { + SysAccountService accountService = mock(SysAccountService.class); + AuthServiceImpl authService = createService(accountService); + when(accountService.getById(ACCOUNT_ID)).thenReturn(buildAccount(false)); + + BusinessException exception = expectBusinessException(() -> authService.updateOwnPassword( + ACCOUNT_ID, + "Wrong!123", + NEW_PASSWORD, + NEW_PASSWORD, + AuthLoginSessionPolicy.WEB_DEVICE + )); + + assertEquals(400, exception.getHttpStatus()); + assertEquals("密码不正确", exception.getMessage()); + verify(accountService, never()).updateById(any(SysAccount.class)); + } + + /** + * 验证 API Key 会话不能使用免当前密码的强制重置流程。 + * + * @throws Exception 注入测试依赖失败 + */ + @Test + public void shouldRejectApiKeyForcedReset() throws Exception { + SysAccountService accountService = mock(SysAccountService.class); + AuthServiceImpl authService = createService(accountService); + when(accountService.getById(ACCOUNT_ID)).thenReturn(buildAccount(true)); + + BusinessException exception = expectBusinessException(() -> authService.updateOwnPassword( + ACCOUNT_ID, + null, + NEW_PASSWORD, + NEW_PASSWORD, + AuthLoginSessionPolicy.API_KEY_DEVICE + )); + + assertEquals(403, exception.getHttpStatus()); + assertEquals("请使用账号密码登录后重置密码", exception.getMessage()); + verify(accountService, never()).updateById(any(SysAccount.class)); + } + + /** + * 验证普通改密在当前密码正确时仍可成功更新。 + * + * @throws Exception 注入测试依赖失败 + */ + @Test + public void shouldUpdateRegularPasswordWithCorrectCurrentPassword() throws Exception { + SysAccountService accountService = mock(SysAccountService.class); + AuthServiceImpl authService = createService(accountService); + when(accountService.getById(ACCOUNT_ID)).thenReturn(buildAccount(false)); + when(accountService.updateById(any(SysAccount.class))).thenReturn(true); + + authService.updateOwnPassword( + ACCOUNT_ID, + CURRENT_PASSWORD, + NEW_PASSWORD, + NEW_PASSWORD, + AuthLoginSessionPolicy.WEB_DEVICE + ); + + verify(accountService).updateById(any(SysAccount.class)); + } + + /** + * 构造测试账号。 + * + * @param passwordResetRequired 是否要求重置密码 + * @return 测试账号 + */ + private SysAccount buildAccount(boolean passwordResetRequired) { + SysAccount account = new SysAccount(); + account.setId(ACCOUNT_ID); + account.setPassword(BCrypt.hashpw(CURRENT_PASSWORD)); + account.setPasswordResetRequired(passwordResetRequired); + return account; + } + + /** + * 创建并注入账号服务。 + * + * @param accountService 账号服务 + * @return 认证服务 + * @throws Exception 注入测试依赖失败 + */ + private AuthServiceImpl createService(SysAccountService accountService) throws Exception { + AuthServiceImpl authService = new AuthServiceImpl(); + Field field = AuthServiceImpl.class.getDeclaredField("sysAccountService"); + field.setAccessible(true); + field.set(authService, accountService); + return authService; + } + + /** + * 执行操作并返回业务异常。 + * + * @param action 待执行操作 + * @return 捕获的业务异常 + */ + private BusinessException expectBusinessException(Runnable action) { + try { + action.run(); + fail("预期抛出 BusinessException"); + return null; + } catch (BusinessException exception) { + return exception; + } + } +} diff --git a/easyflow-ui-admin/app/src/locales/langs/en-US/sysAccount.json b/easyflow-ui-admin/app/src/locales/langs/en-US/sysAccount.json index 9b361a04..ba03a89f 100644 --- a/easyflow-ui-admin/app/src/locales/langs/en-US/sysAccount.json +++ b/easyflow-ui-admin/app/src/locales/langs/en-US/sysAccount.json @@ -25,6 +25,7 @@ "repeatPwd": "Please confirm your password again", "notSamePwd": "The two passwords are inconsistent", "passwordStrongTip": "Password must be at least 8 characters and include uppercase, lowercase, numbers, and special characters", + "passwordStrengthInvalid": "Password strength requirements are not met", "forceChangePasswordNavigateTip": "For account security, please change your password before visiting other pages.", "resetPassword": "Reset Password", "resetPasswordConfirm": "Reset this account password to the system default strong password? The user will be required to change it on next login.", diff --git a/easyflow-ui-admin/app/src/locales/langs/zh-CN/sysAccount.json b/easyflow-ui-admin/app/src/locales/langs/zh-CN/sysAccount.json index acecda0e..d45652aa 100644 --- a/easyflow-ui-admin/app/src/locales/langs/zh-CN/sysAccount.json +++ b/easyflow-ui-admin/app/src/locales/langs/zh-CN/sysAccount.json @@ -26,6 +26,7 @@ "repeatPwd": "请再次输入密码", "notSamePwd": "两次输入的密码不一致", "passwordStrongTip": "密码必须至少 8 位,且包含大写字母、小写字母、数字和特殊字符", + "passwordStrengthInvalid": "密码强度不足", "forceChangePasswordNavigateTip": "为了账户安全,请先修改密码后再继续访问其他页面", "resetPassword": "重置密码", "resetPasswordConfirm": "确认将该用户密码重置为系统默认强密码吗?重置后用户下次登录必须先修改密码。", diff --git a/easyflow-ui-admin/app/src/utils/__tests__/password-reset.test.ts b/easyflow-ui-admin/app/src/utils/__tests__/password-reset.test.ts new file mode 100644 index 00000000..86d4e7b0 --- /dev/null +++ b/easyflow-ui-admin/app/src/utils/__tests__/password-reset.test.ts @@ -0,0 +1,65 @@ +import { createMemoryHistory, createRouter } from 'vue-router'; + +import { describe, expect, it, vi } from 'vitest'; + +import { + buildForcePasswordRoute, + isForcePasswordRoute, + isPasswordResetRequired, + resolveForcePasswordPath, +} from '#/utils/password-reset'; + +vi.mock('element-plus', () => ({ + ElMessage: vi.fn(), +})); + +vi.mock('#/locales', () => ({ + $t: (key: string) => key, +})); + +function createTestRouter() { + return createRouter({ + history: createMemoryHistory(), + routes: [ + { + name: 'FallbackNotFound', + path: '/:path(.*)*', + component: { template: '
' }, + }, + ], + }); +} + +describe('password reset route', () => { + it('can resolve the forced password path before Profile is registered', () => { + const router = createTestRouter(); + const route = router.resolve(buildForcePasswordRoute()); + + expect(route.fullPath).toBe('/profile?force=1&tab=password'); + expect(isForcePasswordRoute(route)).toBe(true); + expect(resolveForcePasswordPath(router)).toBe( + '/profile?force=1&tab=password', + ); + }); + + it('matches Profile after the dynamic route is registered', () => { + const router = createTestRouter(); + const targetPath = router.resolve(buildForcePasswordRoute()).fullPath; + + router.addRoute({ + name: 'Profile', + path: '/profile', + component: { template: '
' }, + }); + + expect(router.resolve(targetPath).name).toBe('Profile'); + }); + + it('uses the account state as the authoritative password reset signal', () => { + expect(isPasswordResetRequired({ passwordResetRequired: true })).toBe(true); + expect(isPasswordResetRequired({ passwordResetRequired: false })).toBe( + false, + ); + expect(isPasswordResetRequired()).toBe(false); + }); +}); diff --git a/easyflow-ui-admin/app/src/utils/password-reset.ts b/easyflow-ui-admin/app/src/utils/password-reset.ts index e6992643..d5b9f027 100644 --- a/easyflow-ui-admin/app/src/utils/password-reset.ts +++ b/easyflow-ui-admin/app/src/utils/password-reset.ts @@ -5,12 +5,13 @@ import { ElMessage } from 'element-plus'; import { $t } from '#/locales'; const FORCE_PASSWORD_NOTICE_INTERVAL = 1500; +const PROFILE_PATH = '/profile'; let lastForcePasswordNoticeAt = 0; export function buildForcePasswordRoute() { return { - name: 'Profile', + path: PROFILE_PATH, query: { force: '1', tab: 'password', @@ -19,19 +20,20 @@ export function buildForcePasswordRoute() { } export function isForcePasswordRoute( - route: Pick< - { name?: null | string | symbol; query?: Record }, - 'name' | 'query' - >, + route: Pick<{ path?: string; query?: Record }, 'path' | 'query'>, ) { - return route.name === 'Profile' && route.query?.tab === 'password'; + return route.path === PROFILE_PATH && route.query?.tab === 'password'; } export function shouldForcePasswordChange( userInfo?: null | Record, forceChangePassword?: boolean, ) { - return !!forceChangePassword || !!userInfo?.passwordResetRequired; + return !!forceChangePassword || isPasswordResetRequired(userInfo); +} + +export function isPasswordResetRequired(userInfo?: null | Record) { + return !!userInfo?.passwordResetRequired; } export function resolveForcePasswordPath(router: Router) { diff --git a/easyflow-ui-admin/app/src/views/_core/profile/password-setting.vue b/easyflow-ui-admin/app/src/views/_core/profile/password-setting.vue index f83a8200..0c1b2396 100644 --- a/easyflow-ui-admin/app/src/views/_core/profile/password-setting.vue +++ b/easyflow-ui-admin/app/src/views/_core/profile/password-setting.vue @@ -1,44 +1,34 @@