fix: 修复强制重置密码流程与表单状态

- 修正强制改密跳转及免旧密码的服务端校验

- 统一双端表单错误布局与更新按钮加载状态

- 补充认证服务和强制改密路由测试
This commit is contained in:
2026-07-30 15:03:53 +08:00
parent 03f45212ef
commit 1b40829135
20 changed files with 535 additions and 117 deletions

View File

@@ -15,6 +15,7 @@ import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import tech.easyflow.auth.entity.EncryptedCredentialDTO; import tech.easyflow.auth.entity.EncryptedCredentialDTO;
import tech.easyflow.auth.service.AuthCredentialKeyService; 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.EnumAccountType;
import tech.easyflow.common.constant.enums.EnumDataStatus; import tech.easyflow.common.constant.enums.EnumDataStatus;
import tech.easyflow.common.domain.Result; 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.service.SysAccountService;
import tech.easyflow.system.util.SysPasswordPolicy; import tech.easyflow.system.util.SysPasswordPolicy;
import javax.annotation.Resource;
import java.io.Serializable; import java.io.Serializable;
import java.math.BigInteger; import java.math.BigInteger;
import java.net.URLEncoder; import java.net.URLEncoder;
@@ -48,6 +50,8 @@ import java.util.Map;
@RequestMapping("/api/v1/sysAccount") @RequestMapping("/api/v1/sysAccount")
public class SysAccountController extends BaseCurdController<SysAccountService, SysAccount> { public class SysAccountController extends BaseCurdController<SysAccountService, SysAccount> {
private final AuthCredentialKeyService credentialKeyService; private final AuthCredentialKeyService credentialKeyService;
@Resource
private AuthService authService;
public SysAccountController(SysAccountService service, AuthCredentialKeyService credentialKeyService) { public SysAccountController(SysAccountService service, AuthCredentialKeyService credentialKeyService) {
super(service); super(service);
@@ -150,7 +154,8 @@ public class SysAccountController extends BaseCurdController<SysAccountService,
/** /**
* 修改密码,用于修改用户自己的密码 * 修改密码,用于修改用户自己的密码
* *
* @param encryptedCredential 加密后的密码、新密码与确认密码 * @param encryptedCredential 加密后的当前密码、新密码与确认密码
* @return 密码修改结果
*/ */
@PostMapping("/updatePassword") @PostMapping("/updatePassword")
public Result<Void> updatePassword(@JsonBody EncryptedCredentialDTO encryptedCredential) { public Result<Void> updatePassword(@JsonBody EncryptedCredentialDTO encryptedCredential) {
@@ -159,25 +164,13 @@ public class SysAccountController extends BaseCurdController<SysAccountService,
String newPassword = payload.getString("newPassword"); String newPassword = payload.getString("newPassword");
String confirmPassword = payload.getString("confirmPassword"); String confirmPassword = payload.getString("confirmPassword");
BigInteger loginAccountId = SaTokenUtil.getLoginAccount().getId(); BigInteger loginAccountId = SaTokenUtil.getLoginAccount().getId();
SysAccount record = service.getById(loginAccountId); authService.updateOwnPassword(
if (record == null) { loginAccountId,
return Result.fail("修改失败"); password,
} newPassword,
String pwdDb = record.getPassword(); confirmPassword,
if (!BCrypt.checkpw(password, pwdDb)) { StpUtil.getLoginDevice()
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);
return Result.ok(); return Result.ok();
} }

View File

@@ -1,6 +1,6 @@
package tech.easyflow.usercenter.controller.system; package tech.easyflow.usercenter.controller.system;
import cn.hutool.crypto.digest.BCrypt; import cn.dev33.satoken.stp.StpUtil;
import com.alibaba.fastjson2.JSONObject; import com.alibaba.fastjson2.JSONObject;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
@@ -8,13 +8,13 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import tech.easyflow.auth.entity.EncryptedCredentialDTO; import tech.easyflow.auth.entity.EncryptedCredentialDTO;
import tech.easyflow.auth.service.AuthCredentialKeyService; import tech.easyflow.auth.service.AuthCredentialKeyService;
import tech.easyflow.auth.service.AuthService;
import tech.easyflow.common.domain.Result; import tech.easyflow.common.domain.Result;
import tech.easyflow.common.entity.LoginAccount; import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil; import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.jsonbody.JsonBody; import tech.easyflow.common.web.jsonbody.JsonBody;
import tech.easyflow.system.entity.SysAccount; import tech.easyflow.system.entity.SysAccount;
import tech.easyflow.system.service.SysAccountService; import tech.easyflow.system.service.SysAccountService;
import tech.easyflow.system.util.SysPasswordPolicy;
import javax.annotation.Resource; import javax.annotation.Resource;
import java.math.BigInteger; import java.math.BigInteger;
@@ -34,6 +34,8 @@ public class UcSysAccountController {
private SysAccountService service; private SysAccountService service;
@Resource @Resource
private AuthCredentialKeyService credentialKeyService; private AuthCredentialKeyService credentialKeyService;
@Resource
private AuthService authService;
/** /**
* 获取用户的信息 * 获取用户的信息
@@ -66,7 +68,8 @@ public class UcSysAccountController {
/** /**
* 修改密码 * 修改密码
* *
* @param encryptedCredential 加密后的密码、新密码与确认密码 * @param encryptedCredential 加密后的当前密码、新密码与确认密码
* @return 密码修改结果
*/ */
@PostMapping("/updatePassword") @PostMapping("/updatePassword")
public Result<Void> updatePassword(@JsonBody EncryptedCredentialDTO encryptedCredential) { public Result<Void> updatePassword(@JsonBody EncryptedCredentialDTO encryptedCredential) {
@@ -75,25 +78,13 @@ public class UcSysAccountController {
String newPassword = payload.getString("newPassword"); String newPassword = payload.getString("newPassword");
String confirmPassword = payload.getString("confirmPassword"); String confirmPassword = payload.getString("confirmPassword");
BigInteger loginAccountId = SaTokenUtil.getLoginAccount().getId(); BigInteger loginAccountId = SaTokenUtil.getLoginAccount().getId();
SysAccount record = service.getById(loginAccountId); authService.updateOwnPassword(
if (record == null) { loginAccountId,
return Result.fail("修改失败"); password,
} newPassword,
String pwdDb = record.getPassword(); confirmPassword,
if (!BCrypt.checkpw(password, pwdDb)) { StpUtil.getLoginDevice()
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);
return Result.ok(); return Result.ok();
} }
} }

View File

@@ -38,5 +38,11 @@
<version>${junit.version}</version> <version>${junit.version}</version>
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>5.12.0</version>
<scope>test</scope>
</dependency>
</dependencies> </dependencies>
</project> </project>

View File

@@ -5,6 +5,9 @@ import tech.easyflow.auth.entity.LoginVO;
import java.math.BigInteger; import java.math.BigInteger;
/**
* 账号认证与登录服务。
*/
public interface AuthService { public interface AuthService {
/** /**
* 登录 * 登录
@@ -25,4 +28,21 @@ public interface AuthService {
* 通过账号ID登录 * 通过账号ID登录
*/ */
LoginVO loginByAccountId(BigInteger accountId, Long timeoutSeconds); 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
);
} }

View File

@@ -4,6 +4,7 @@ import cn.dev33.satoken.stp.SaLoginModel;
import cn.dev33.satoken.stp.StpInterface; import cn.dev33.satoken.stp.StpInterface;
import cn.dev33.satoken.stp.StpUtil; import cn.dev33.satoken.stp.StpUtil;
import cn.hutool.core.bean.BeanUtil; import cn.hutool.core.bean.BeanUtil;
import cn.hutool.crypto.digest.BCrypt;
import com.mybatisflex.core.query.QueryWrapper; import com.mybatisflex.core.query.QueryWrapper;
import com.mybatisflex.core.tenant.TenantManager; import com.mybatisflex.core.tenant.TenantManager;
import org.springframework.stereotype.Service; 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.SysAccountService;
import tech.easyflow.system.service.SysMenuService; import tech.easyflow.system.service.SysMenuService;
import tech.easyflow.system.service.SysRoleService; import tech.easyflow.system.service.SysRoleService;
import cn.hutool.crypto.digest.BCrypt; import tech.easyflow.system.util.SysPasswordPolicy;
import javax.annotation.Resource; import javax.annotation.Resource;
import java.math.BigInteger; import java.math.BigInteger;
import java.time.Duration; import java.time.Duration;
import java.util.Date; import java.util.Date;
import java.util.List; import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/**
* 账号认证、会话创建与凭证更新服务。
*/
@Service @Service
public class AuthServiceImpl implements AuthService, StpInterface { public class AuthServiceImpl implements AuthService, StpInterface {
@@ -100,6 +105,56 @@ public class AuthServiceImpl implements AuthService, StpInterface {
return createApiKeyLoginVO(record, timeoutSeconds); 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 @Override
public List<String> getPermissionList(Object loginId, String loginType) { public List<String> getPermissionList(Object loginId, String loginType) {
List<SysMenu> menus = sysMenuService.getMenusByAccountId(new SysMenu(), BigInteger.valueOf(Long.parseLong(loginId.toString()))); List<SysMenu> menus = sysMenuService.getMenusByAccountId(new SysMenu(), BigInteger.valueOf(Long.parseLong(loginId.toString())));

View File

@@ -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<SysAccount> 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;
}
}
}

View File

@@ -25,6 +25,7 @@
"repeatPwd": "Please confirm your password again", "repeatPwd": "Please confirm your password again",
"notSamePwd": "The two passwords are inconsistent", "notSamePwd": "The two passwords are inconsistent",
"passwordStrongTip": "Password must be at least 8 characters and include uppercase, lowercase, numbers, and special characters", "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.", "forceChangePasswordNavigateTip": "For account security, please change your password before visiting other pages.",
"resetPassword": "Reset Password", "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.", "resetPasswordConfirm": "Reset this account password to the system default strong password? The user will be required to change it on next login.",

View File

@@ -26,6 +26,7 @@
"repeatPwd": "请再次输入密码", "repeatPwd": "请再次输入密码",
"notSamePwd": "两次输入的密码不一致", "notSamePwd": "两次输入的密码不一致",
"passwordStrongTip": "密码必须至少 8 位,且包含大写字母、小写字母、数字和特殊字符", "passwordStrongTip": "密码必须至少 8 位,且包含大写字母、小写字母、数字和特殊字符",
"passwordStrengthInvalid": "密码强度不足",
"forceChangePasswordNavigateTip": "为了账户安全,请先修改密码后再继续访问其他页面", "forceChangePasswordNavigateTip": "为了账户安全,请先修改密码后再继续访问其他页面",
"resetPassword": "重置密码", "resetPassword": "重置密码",
"resetPasswordConfirm": "确认将该用户密码重置为系统默认强密码吗?重置后用户下次登录必须先修改密码。", "resetPasswordConfirm": "确认将该用户密码重置为系统默认强密码吗?重置后用户下次登录必须先修改密码。",

View File

@@ -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: '<div />' },
},
],
});
}
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: '<div />' },
});
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);
});
});

View File

@@ -5,12 +5,13 @@ import { ElMessage } from 'element-plus';
import { $t } from '#/locales'; import { $t } from '#/locales';
const FORCE_PASSWORD_NOTICE_INTERVAL = 1500; const FORCE_PASSWORD_NOTICE_INTERVAL = 1500;
const PROFILE_PATH = '/profile';
let lastForcePasswordNoticeAt = 0; let lastForcePasswordNoticeAt = 0;
export function buildForcePasswordRoute() { export function buildForcePasswordRoute() {
return { return {
name: 'Profile', path: PROFILE_PATH,
query: { query: {
force: '1', force: '1',
tab: 'password', tab: 'password',
@@ -19,19 +20,20 @@ export function buildForcePasswordRoute() {
} }
export function isForcePasswordRoute( export function isForcePasswordRoute(
route: Pick< route: Pick<{ path?: string; query?: Record<string, any> }, 'path' | 'query'>,
{ name?: null | string | symbol; query?: Record<string, any> },
'name' | 'query'
>,
) { ) {
return route.name === 'Profile' && route.query?.tab === 'password'; return route.path === PROFILE_PATH && route.query?.tab === 'password';
} }
export function shouldForcePasswordChange( export function shouldForcePasswordChange(
userInfo?: null | Record<string, any>, userInfo?: null | Record<string, any>,
forceChangePassword?: boolean, forceChangePassword?: boolean,
) { ) {
return !!forceChangePassword || !!userInfo?.passwordResetRequired; return !!forceChangePassword || isPasswordResetRequired(userInfo);
}
export function isPasswordResetRequired(userInfo?: null | Record<string, any>) {
return !!userInfo?.passwordResetRequired;
} }
export function resolveForcePasswordPath(router: Router) { export function resolveForcePasswordPath(router: Router) {

View File

@@ -2,7 +2,7 @@
import type { EasyFlowFormSchema } from '#/adapter/form'; import type { EasyFlowFormSchema } from '#/adapter/form';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import {useRoute, useRouter} from 'vue-router'; import { useRouter } from 'vue-router';
import { ProfilePasswordSetting, z } from '@easyflow/common-ui'; import { ProfilePasswordSetting, z } from '@easyflow/common-ui';
import { preferences } from '@easyflow/preferences'; import { preferences } from '@easyflow/preferences';
@@ -16,29 +16,19 @@ import {$t} from '#/locales';
import { useAuthStore } from '#/store'; import { useAuthStore } from '#/store';
import { encryptCredentialPayload } from '#/utils/credential-encryption'; import { encryptCredentialPayload } from '#/utils/credential-encryption';
import { isStrongPassword } from '#/utils/password-policy'; import { isStrongPassword } from '#/utils/password-policy';
import { isPasswordResetRequired } from '#/utils/password-reset';
const profilePasswordSettingRef = ref(); const profilePasswordSettingRef = ref();
const authStore = useAuthStore(); const authStore = useAuthStore();
const route = useRoute();
const router = useRouter(); const router = useRouter();
const userStore = useUserStore(); const userStore = useUserStore();
const isForcedPasswordChange = computed(() => { const isForcedPasswordChange = computed(() => {
return ( return isPasswordResetRequired(userStore.userInfo);
!!userStore.userInfo?.passwordResetRequired || route.query.force === '1'
);
}); });
const formSchema = computed((): EasyFlowFormSchema[] => { const formSchema = computed((): EasyFlowFormSchema[] => {
return [ const schema: EasyFlowFormSchema[] = [
{
fieldName: 'password',
label: $t('sysAccount.oldPwd'),
component: 'EasyFlowInputPassword',
componentProps: {
placeholder: $t('sysAccount.oldPwd') + $t('common.isRequired'),
},
},
{ {
fieldName: 'newPassword', fieldName: 'newPassword',
label: $t('sysAccount.newPwd'), label: $t('sysAccount.newPwd'),
@@ -58,7 +48,7 @@ const formSchema = computed((): EasyFlowFormSchema[] => {
}) })
.min(1, { message: $t('sysAccount.newPwd') + $t('common.isRequired') }) .min(1, { message: $t('sysAccount.newPwd') + $t('common.isRequired') })
.refine((value) => isStrongPassword(value), { .refine((value) => isStrongPassword(value), {
message: $t('sysAccount.passwordStrongTip'), message: $t('sysAccount.passwordStrengthInvalid'),
}), }),
}, },
{ {
@@ -83,10 +73,29 @@ const formSchema = computed((): EasyFlowFormSchema[] => {
}, },
}, },
]; ];
if (!isForcedPasswordChange.value) {
schema.unshift({
fieldName: 'password',
label: $t('sysAccount.oldPwd'),
component: 'EasyFlowInputPassword',
componentProps: {
placeholder: $t('sysAccount.oldPwd') + $t('common.isRequired'),
},
rules: z
.string({
required_error: $t('sysAccount.oldPwd') + $t('common.isRequired'),
})
.min(1, { message: $t('sysAccount.oldPwd') + $t('common.isRequired') }),
});
}
return schema;
}); });
const updateLoading = ref(false); const updateLoading = ref(false);
async function handleSubmit(values: any) { async function handleSubmit(values: any) {
const wasForcedPasswordChange = isForcedPasswordChange.value;
updateLoading.value = true; updateLoading.value = true;
try { try {
const encryptedPayload = await encryptCredentialPayload( const encryptedPayload = await encryptCredentialPayload(
@@ -100,7 +109,7 @@ async function handleSubmit(values: any) {
if (res.errorCode === 0) { if (res.errorCode === 0) {
ElMessage.success($t('message.success')); ElMessage.success($t('message.success'));
const userInfo = await authStore.fetchUserInfo(); const userInfo = await authStore.fetchUserInfo();
if (isForcedPasswordChange.value) { if (wasForcedPasswordChange) {
await router.replace( await router.replace(
userInfo?.homePath || preferences.app.defaultHomePath || '/', userInfo?.homePath || preferences.app.defaultHomePath || '/',
); );

View File

@@ -392,7 +392,7 @@ onUnmounted(() => {
</div> </div>
<Transition name="slide-up" v-if="!compact"> <Transition name="slide-up" v-if="!compact">
<FormMessage class="absolute" /> <FormMessage />
</Transition> </Transition>
</div> </div>
</FormItem> </FormItem>

View File

@@ -56,7 +56,7 @@ defineExpose({
<EasyFlowButton <EasyFlowButton
:loading="buttonLoading" :loading="buttonLoading"
type="submit" type="submit"
class="mx-auto mt-4 block h-8 w-[106px] p-0" class="mx-auto mt-4 flex h-8 w-28 p-0"
@click="handleSubmit" @click="handleSubmit"
> >
{{ buttonText }} {{ buttonText }}

View File

@@ -25,5 +25,6 @@
"repeatPwd": "Please confirm your password again", "repeatPwd": "Please confirm your password again",
"notSamePwd": "The two passwords are inconsistent", "notSamePwd": "The two passwords are inconsistent",
"passwordStrongTip": "Password must be at least 8 characters and include uppercase, lowercase, numbers, and special characters", "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." "forceChangePasswordNavigateTip": "For account security, please change your password before visiting other pages."
} }

View File

@@ -25,5 +25,6 @@
"repeatPwd": "请再次输入密码", "repeatPwd": "请再次输入密码",
"notSamePwd": "两次输入的密码不一致", "notSamePwd": "两次输入的密码不一致",
"passwordStrongTip": "密码必须至少 8 位,且包含大写字母、小写字母、数字和特殊字符", "passwordStrongTip": "密码必须至少 8 位,且包含大写字母、小写字母、数字和特殊字符",
"passwordStrengthInvalid": "密码强度不足",
"forceChangePasswordNavigateTip": "为了账户安全,请先修改密码后再继续访问其他页面" "forceChangePasswordNavigateTip": "为了账户安全,请先修改密码后再继续访问其他页面"
} }

View File

@@ -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: '<div />' },
},
],
});
}
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: '<div />' },
});
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);
});
});

View File

@@ -5,12 +5,13 @@ import { ElMessage } from 'element-plus';
import { $t } from '#/locales'; import { $t } from '#/locales';
const FORCE_PASSWORD_NOTICE_INTERVAL = 1500; const FORCE_PASSWORD_NOTICE_INTERVAL = 1500;
const PROFILE_PATH = '/profile';
let lastForcePasswordNoticeAt = 0; let lastForcePasswordNoticeAt = 0;
export function buildForcePasswordRoute() { export function buildForcePasswordRoute() {
return { return {
name: 'Profile', path: PROFILE_PATH,
query: { query: {
force: '1', force: '1',
tab: 'password', tab: 'password',
@@ -19,16 +20,20 @@ export function buildForcePasswordRoute() {
} }
export function isForcePasswordRoute( export function isForcePasswordRoute(
route: Pick<{ name?: string | symbol | null; query?: Record<string, any> }, 'name' | 'query'>, route: Pick<{ path?: string; query?: Record<string, any> }, 'path' | 'query'>,
) { ) {
return route.name === 'Profile' && route.query?.tab === 'password'; return route.path === PROFILE_PATH && route.query?.tab === 'password';
} }
export function shouldForcePasswordChange( export function shouldForcePasswordChange(
userInfo?: null | Record<string, any>, userInfo?: null | Record<string, any>,
forceChangePassword?: boolean, forceChangePassword?: boolean,
) { ) {
return !!forceChangePassword || !!userInfo?.passwordResetRequired; return !!forceChangePassword || isPasswordResetRequired(userInfo);
}
export function isPasswordResetRequired(userInfo?: null | Record<string, any>) {
return !!userInfo?.passwordResetRequired;
} }
export function resolveForcePasswordPath(router: Router) { export function resolveForcePasswordPath(router: Router) {

View File

@@ -2,7 +2,7 @@
import type { EasyFlowFormSchema } from '#/adapter/form'; import type { EasyFlowFormSchema } from '#/adapter/form';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import {useRoute, useRouter} from 'vue-router'; import { useRouter } from 'vue-router';
import { ProfilePasswordSetting, z } from '@easyflow/common-ui'; import { ProfilePasswordSetting, z } from '@easyflow/common-ui';
import { preferences } from '@easyflow/preferences'; import { preferences } from '@easyflow/preferences';
@@ -16,27 +16,19 @@ import {$t} from '#/locales';
import { useAuthStore } from '#/store'; import { useAuthStore } from '#/store';
import { encryptCredentialPayload } from '#/utils/credential-encryption'; import { encryptCredentialPayload } from '#/utils/credential-encryption';
import { isStrongPassword } from '#/utils/password-policy'; import { isStrongPassword } from '#/utils/password-policy';
import { isPasswordResetRequired } from '#/utils/password-reset';
const profilePasswordSettingRef = ref(); const profilePasswordSettingRef = ref();
const authStore = useAuthStore(); const authStore = useAuthStore();
const route = useRoute();
const router = useRouter(); const router = useRouter();
const userStore = useUserStore(); const userStore = useUserStore();
const isForcedPasswordChange = computed(() => { const isForcedPasswordChange = computed(() => {
return !!userStore.userInfo?.passwordResetRequired || route.query.force === '1'; return isPasswordResetRequired(userStore.userInfo);
}); });
const formSchema = computed((): EasyFlowFormSchema[] => { const formSchema = computed((): EasyFlowFormSchema[] => {
return [ const schema: EasyFlowFormSchema[] = [
{
fieldName: 'password',
label: $t('sysAccount.oldPwd'),
component: 'EasyFlowInputPassword',
componentProps: {
placeholder: $t('sysAccount.oldPwd') + $t('common.isRequired'),
},
},
{ {
fieldName: 'newPassword', fieldName: 'newPassword',
label: $t('sysAccount.newPwd'), label: $t('sysAccount.newPwd'),
@@ -51,10 +43,12 @@ const formSchema = computed((): EasyFlowFormSchema[] => {
}; };
}, },
rules: z rules: z
.string({ required_error: $t('sysAccount.newPwd') + $t('common.isRequired') }) .string({
required_error: $t('sysAccount.newPwd') + $t('common.isRequired'),
})
.min(1, { message: $t('sysAccount.newPwd') + $t('common.isRequired') }) .min(1, { message: $t('sysAccount.newPwd') + $t('common.isRequired') })
.refine((value) => isStrongPassword(value), { .refine((value) => isStrongPassword(value), {
message: $t('sysAccount.passwordStrongTip'), message: $t('sysAccount.passwordStrengthInvalid'),
}), }),
}, },
{ {
@@ -79,10 +73,29 @@ const formSchema = computed((): EasyFlowFormSchema[] => {
}, },
}, },
]; ];
if (!isForcedPasswordChange.value) {
schema.unshift({
fieldName: 'password',
label: $t('sysAccount.oldPwd'),
component: 'EasyFlowInputPassword',
componentProps: {
placeholder: $t('sysAccount.oldPwd') + $t('common.isRequired'),
},
rules: z
.string({
required_error: $t('sysAccount.oldPwd') + $t('common.isRequired'),
})
.min(1, { message: $t('sysAccount.oldPwd') + $t('common.isRequired') }),
});
}
return schema;
}); });
const updateLoading = ref(false); const updateLoading = ref(false);
async function handleSubmit(values: any) { async function handleSubmit(values: any) {
const wasForcedPasswordChange = isForcedPasswordChange.value;
updateLoading.value = true; updateLoading.value = true;
try { try {
const encryptedPayload = await encryptCredentialPayload( const encryptedPayload = await encryptCredentialPayload(
@@ -96,7 +109,7 @@ async function handleSubmit(values: any) {
if (res.errorCode === 0) { if (res.errorCode === 0) {
ElMessage.success($t('message.success')); ElMessage.success($t('message.success'));
const userInfo = await authStore.fetchUserInfo(); const userInfo = await authStore.fetchUserInfo();
if (isForcedPasswordChange.value) { if (wasForcedPasswordChange) {
await router.replace( await router.replace(
userInfo?.homePath || preferences.app.defaultHomePath || '/', userInfo?.homePath || preferences.app.defaultHomePath || '/',
); );

View File

@@ -7,15 +7,20 @@ import { computed, nextTick, onUnmounted, useTemplateRef, watch } from 'vue';
import { CircleAlert } from '@easyflow-core/icons'; import { CircleAlert } from '@easyflow-core/icons';
import { import {
EasyFlowRenderContent,
EasyFlowTooltip,
FormControl, FormControl,
FormDescription, FormDescription,
FormField, FormField,
FormItem, FormItem,
FormMessage, FormMessage,
EasyFlowRenderContent,
EasyFlowTooltip,
} from '@easyflow-core/shadcn-ui'; } from '@easyflow-core/shadcn-ui';
import { cn, isFunction, isObject, isString } from '@easyflow-core/shared/utils'; import {
cn,
isFunction,
isObject,
isString,
} from '@easyflow-core/shared/utils';
import { toTypedSchema } from '@vee-validate/zod'; import { toTypedSchema } from '@vee-validate/zod';
import { useFieldError, useFormValues } from 'vee-validate'; import { useFieldError, useFormValues } from 'vee-validate';
@@ -387,7 +392,7 @@ onUnmounted(() => {
</div> </div>
<Transition name="slide-up" v-if="!compact"> <Transition name="slide-up" v-if="!compact">
<FormMessage class="absolute" /> <FormMessage />
</Transition> </Transition>
</div> </div>
</FormItem> </FormItem>

View File

@@ -10,10 +10,14 @@ import { EasyFlowButton } from '@easyflow-core/shadcn-ui';
interface Props { interface Props {
formSchema?: EasyFlowFormSchema[]; formSchema?: EasyFlowFormSchema[];
buttonText?: string;
buttonLoading?: boolean;
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
formSchema: () => [], formSchema: () => [],
buttonText: '更新密码',
buttonLoading: false,
}); });
const emit = defineEmits<{ const emit = defineEmits<{
@@ -49,8 +53,13 @@ defineExpose({
<template> <template>
<div> <div>
<Form /> <Form />
<EasyFlowButton type="submit" class="mt-4" @click="handleSubmit"> <EasyFlowButton
更新密码 :loading="buttonLoading"
type="submit"
class="mt-4 flex h-8 w-28 p-0"
@click="handleSubmit"
>
{{ buttonText }}
</EasyFlowButton> </EasyFlowButton>
</div> </div>
</template> </template>