前端页面
如何能够保证在未登录的情况下跳转到登录页面,这里使用了vue的路由守卫。
路由守卫(如果未登录,强制跳转到登录页面)
在src/permission.ts文件中通过对路由添加前置守卫,首先通过@/utils/auth中的getToken()方法判断tokenStorage中是否存在token,
如果已经存在token说明已经登录,如果没有token,则判断是否在白名单中,如果在白名单中,则直接跳转到页面,如果不在白名单中,则强制转到登录页面,即执行如下语句:
next(`/login?redirect=${redirect}`); // 否则全部重定向到登录页
根据src/router/index.ts中的路由配置,login路由对应@/views/login,即/views/login.vue文件。# 路由守卫(如果未登录,强制跳转到登录页面)
在src/permission.ts文件中通过对路由添加前置守卫,首先通过@/utils/auth中的getToken()方法判断cookie中是否存在token,
如果已经存在token说明已经登录,如果没有token,则判断是否在白名单中,如果在白名单中,则直接跳转到页面,如果不在白名单中,则强制转到登录页面,即执行如下语句:
next(`/login?redirect=${redirect}`); // 否则全部重定向到登录页
根据src/router/index.ts中的路由配置,login路由对应@/views/login,即/views/login.vue文件。
{
path: '/login',
component: () => import('@/views/login.vue'),
hidden: true
}
登录页面显示逻辑
登录页面有一个form表单,绑定的变量是loginForm,ref绑定的是loginRef。登录页面有3个输入框:用户名和密码必填,验证码根据后台captchaEnabled配置决定是否显示。
当页面加载的时候会调用3个函数:
onMounted(() => {
getCode();
initTenantList();
getLoginData();
});
- getCode()的作用是判断是否显示验证码:如果captchaEnabled为true,则显示验证码,并且提交的时候后台也会验证验证码,如果captchaEnabled为false,则不显示验证码,后台也不会验证验证码,验证码验证逻辑后面详细分析。
-
initTenantList()的作用是加载租户列表,如果tenant.enable配置为true,则加载租户列表,显示在下拉框中;
-
getLoginData()的作用是从localStorage中获取tenantId、username、password、rememberMe的值设置到loginForm中。
以下详述这几个方法。
getCode()获取验证码
验证码由auth生成,get请求auth的/code路径,即/auth/code,会返回一个captchaEnabled的配置信息,如果captchaEnabled为false,即nacos中auth配置如下:
# 安全配置
security:
# 验证码
captcha:
# 是否开启验证码
enabled: false
请求/auth/code返回信息如下:
{
"code": 200,
"msg": "操作成功",
"data": {
"captchaEnabled": false,
"uuid": null,
"img": null
}
}
如果captchaEnabled配置为true,get请求/auth/code返回信息如下:
{
"code": 200,
"msg": "操作成功",
"data": {
"captchaEnabled": true,
"uuid": "e0839a56af2245c4b818bd4613fab2dc",
"img": "iVBORw0KGgoAAAANSUhEUgAAAKAA……=="
}
}
会返回一个图片,和uuid,此uuid会作为redis key的一部分保存到redis中,以备后续验证,redis的value保存图片验证的计算结果以与用户输入的结果相比较。图片会显示在验证码输入框后面。
initTenantList()获取租户列表
请求/auth/tenant/list可以获取租户列表信息,在application-common.yml中配置了是否启用多租户以及相关信息,配置信息如下:
# 多租户配置
tenant:
# 是否开启
enable: true
# 排除表
excludes:
- sys_menu
- sys_tenant
- sys_tenant_package
- sys_role_dept
- sys_role_menu
- sys_user_post
- sys_user_role
- sys_client
- sys_oss_config
如果不启用多租户,接口返回内容如下:
{
"code": 200,
"msg": "操作成功",
"data": {
"tenantEnabled": false,
"voList": null
}
}
如果tenantEnabled默认为true,如果通过接口获取到的tenantEnabled为false,则会隐藏多租户的下拉框。
如果启用了多租户,则接口返回内容如下:
{
"code": 200,
"msg": "操作成功",
"data": {
"tenantEnabled": true,
"voList": [
{
"tenantId": "000000",
"companyName": "XXX有限公司",
"domain": null
}
]
}
}
租户列表可以有多个,用户可以手动选择。
getLoginData()获取用户的登录数据
该方法从localStorage中获取tenantId、username、password、rememberMe四个参数,如果不为空,则直接填入到对应到表单中。
登录页面验证逻辑
通过分析登录页面逻辑,状态处理以及后台处理几部分进行分析
页面验证处理
登录验证逻辑在login.vue中由handleLogin函数处理。首先调用loginRef.value.validate()方法进行表单验证,如果验证通过,进行如下逻辑:
判断是否选中了“记住密码”,如果选中,则在localStorage中记录租户id、用户名和密码和“记住密码”的值;
如果未选中“记住密码”,则清除localStorage中租户id、用户名、密码和“记住密码”的值。
通过调用userStore中的action方法,调用登录验证逻辑,即调用userStore.login方法,参数是LoginData,即登录表单内容。
const login = async (userInfo: LoginData): Promise<void> => {
const [err, res] = await to(loginApi(userInfo));
if (res) {
const data = res.data;
setToken(data.access_token);
token.value = data.access_token;
return Promise.resolve();
}
return Promise.reject(err);
};
userStore中的login方法调用loginApi中的登录方法获取登录结果。
export function login(data: LoginData): AxiosPromise<LoginResult> {
const params = {
...data,
clientId: data.clientId || clientId,
grantType: data.grantType || 'password'
};
return request({
url: '/auth/login',
headers: {
isToken: false,
isEncrypt: true,
repeatSubmit: false
},
method: 'post',
data: params
});
}
isEncrypt为true表示对参数进行加密,即在数据传输的过程中参数是加密的,由后台解密后再进行处理。前端参数加密逻辑放在src\utils\request.ts中的请求拦截器中处理:
// 是否需要加密
const isEncrypt = config.headers?.isEncrypt === 'true';
……
if (import.meta.env.VITE_APP_ENCRYPT === 'true') {
// 当开启参数加密
if (isEncrypt && (config.method === 'post' || config.method === 'put')) {
// 生成一个 AES 密钥
const aesKey = generateAesKey();
config.headers[encryptHeader] = encrypt(encryptBase64(aesKey));
config.data = typeof config.data === 'object' ? encryptWithAes(JSON.stringify(config.data), aesKey) : encryptWithAes(config.data, aesKey);
}
}
前端和后端传递参数的时候使用AES对称加密,秘钥保存在header的encrypt-key中。后端收到请求之后,首先进行解密操作,解密由ruoyi-common-encrypt的CryptoFilter实现,有加密头则解密请求参数:
// 是否为 put 或者 post 请求
if (HttpMethod.PUT.matches(servletRequest.getMethod()) || HttpMethod.POST.matches(servletRequest.getMethod())) {
// 是否存在加密标头
String headerValue = servletRequest.getHeader(properties.getHeaderFlag());
if (StringUtils.isNotBlank(headerValue)) {
// 请求解密
requestWrapper = new DecryptRequestBodyWrapper(servletRequest, properties.getPrivateKey(), properties.getHeaderFlag());
} else {
// 是否有注解,有就报错,没有放行
if (ObjectUtil.isNotNull(apiEncrypt)) {
HandlerExceptionResolver exceptionResolver = SpringUtils.getBean("handlerExceptionResolver", HandlerExceptionResolver.class);
exceptionResolver.resolveException(
servletRequest, servletResponse, null,
new ServiceException("没有访问权限,请联系管理员授权", HttpStatus.FORBIDDEN));
return;
}
}
}
接口是否加密由@ApiEncrypt注解实现,由于login方法有@ApiEncrypt注解,如果没有加密头,则直接报错。
@ApiEncrypt
@PostMapping("/login")
public R<LoginVo> login(@RequestBody String body) {
报错响应如下:
{
"code": 403,
"msg": "没有访问权限,请联系管理员授权",
"data": null
}
因此请求有@ApiEncrypt注解的请求必须有加密头,即在header中添加encrypt-key的值,且请求内容必须是加密的。响应是否加密,在@ApiEncrypt注解注解中配置,默认不加密。登录成功之后的返回信息如下:
{
"code": 200,
"msg": "操作成功",
"data": {
"scope": null,
"openid": null,
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJI……s",
"refresh_token": null,
"expire_in": 604799,
"refresh_expire_in": null,
"client_id": "e5cd7e4891bf95d1d19206ce24a7b32e"
}
}
成功之后通过setToken(data.access_token);方法把token保存到tokenStorage中。如果登录成功则跳转到根路径"/",如果失败,则重新获取验证码继续登录。
后端验证逻辑
前端登录请求加密前的参数如下:
{
"tenantId": "000000",
"username": "admin",
"password": "admin123",
"rememberMe": false,
"clientId": "e5cd7e4891bf95d1d19206ce24a7b32e",
"grantType": "password"
}
验证参数
后端获取到请求参数之后,首先对参数进行验证,由LoginBody中的属性注解实现。
/**
* 客户端id
*/
@NotBlank(message = "{auth.clientid.not.blank}")
private String clientId;
/**
* 授权类型
*/
@NotBlank(message = "{auth.grant.type.not.blank}")
private String grantType;
如果参数为空,则直接报错:
{
"code": 500,
"msg": "认证客户端id不能为空",
"data": null
}
验证客户端
首先根据clientId远程查询客户端,如果客户端id找不到获取客户端的认证类型不包含grantType中的参数,返回报错响应:
{
"code": 500,
"msg": "认证权限类型错误",
"data": null
}
如果客户端已停用,则返回报错响应如下:
{
"code": 500,
"msg": "认证权限类型已禁用",
"data": null
}
验证租户信息
如果一切正常,则继续验证租户信息:
// 验证租户
sysLoginService.checkTenant(loginBody.getTenantId());
验证多租户是否启用、租户是否存在、状态是否正常、是否超过期限。
如果租户验证通过,则进入登录验证逻辑:
// 登录
LoginVo loginVo = IAuthStrategy.login(body, clientVo, grantType);
验证策略
IAuthStrategy.login是个接口中的静态方法:
/**
* 登录
*
* @param body 登录对象
* @param client 授权管理视图对象
* @param grantType 授权类型
* @return 登录验证信息
*/
static LoginVo login(String body, RemoteClientVo client, String grantType) {
// 授权类型和客户端id
String beanName = grantType + BASE_NAME;
if (!SpringUtils.containsBean(beanName)) {
throw new ServiceException("授权类型不正确!");
}
IAuthStrategy instance = SpringUtils.getBean(beanName);
return instance.login(body, client);
}
根据授权类型获取处理bean,比如授权类型是password,则获取到的登录类是PasswordAuthStrategy,调用PasswordAuthStrategy的login方法。
PasswordAuthStrategy验证逻辑
参数验证
首先从请求参数中构造出PasswordLoginBody,PasswordLoginBody继承了LoginBody,添加了用户名和密码不能为空和长度的限制。
@Data
@EqualsAndHashCode(callSuper = true)
public class PasswordLoginBody extends LoginBody {
/**
* 用户名
*/
@NotBlank(message = "{user.username.not.blank}")
@Length(min = USERNAME_MIN_LENGTH, max = USERNAME_MAX_LENGTH, message = "{user.username.length.valid}")
private String username;
/**
* 用户密码
*/
@NotBlank(message = "{user.password.not.blank}")
@Length(min = PASSWORD_MIN_LENGTH, max = PASSWORD_MAX_LENGTH, message = "{user.password.length.valid}")
private String password;
}
用户名:最少2个字符,最多20个字符;
密码:最少5个字符,最对20个字符;
由系统中的常量定义:
/**
* 用户常量信息
*
* @author ruoyi
*/
public interface UserConstants {
/**
* 用户名长度限制
*/
int USERNAME_MIN_LENGTH = 2;
int USERNAME_MAX_LENGTH = 20;
/**
* 密码长度限制
*/
int PASSWORD_MIN_LENGTH = 5;
int PASSWORD_MAX_LENGTH = 20;
}
验证码验证
参数验证完成之后,完成验证码的验证,如果启用的验证码,则验证验证码:
// 验证码开关
if (captchaProperties.getEnabled()) {
validateCaptcha(tenantId, username, code, uuid);
}
验证码的验证逻辑是从redis中根据生成图片时的uuid获取缓存的值:
/**
* 验证验证码
*
* @param username 用户名
* @param code 验证码
* @param uuid 唯一标识
*/
private void validateCaptcha(String tenantId, String username, String code, String uuid) {
String verifyKey = GlobalConstants.CAPTCHA_CODE_KEY + StringUtils.blankToDefault(uuid, "");
String captcha = RedisUtils.getCacheObject(verifyKey);
RedisUtils.deleteObject(verifyKey);
if (captcha == null) {
loginService.recordLogininfor(tenantId, username, Constants.LOGIN_FAIL, MessageUtils.message("user.jcaptcha.expire"));
throw new CaptchaExpireException();
}
if (!code.equalsIgnoreCase(captcha)) {
loginService.recordLogininfor(tenantId, username, Constants.LOGIN_FAIL, MessageUtils.message("user.jcaptcha.error"));
throw new CaptchaException();
}
}
如果未获取到缓存说明验证码已失效,记录登录日志,抛出验证码已失效的异常(CaptchaExpireException)。如果用户输入的值与缓存中的结果不一致,则记录登录日志,抛出验证码错误的异常。
登录密码验证
通过调用租户方法TenantHelper获取登录用户
LoginUser loginUser = TenantHelper.dynamic(tenantId, () -> {
LoginUser user = remoteUserService.getUserInfo(username, tenantId);
loginService.checkLogin(LoginType.PASSWORD, tenantId, username, () -> !BCrypt.checkpw(password, user.getPassword()));
return user;
});
在dynamic方法中调用函数中的代码:
LoginUser user = remoteUserService.getUserInfo(username, tenantId);
loginService.checkLogin(LoginType.PASSWORD, tenantId, username, () -> !BCrypt.checkpw(password, user.getPassword()));
首先通过远程调用获取登录用户,然后调用loginService.checkLogin方法验证用户的密码,以下详细分析loginService.checkLogin方法:
/**
* 登录验证
*/
public void checkLogin(LoginType loginType, String tenantId, String username, Supplier<Boolean> supplier) {
String errorKey = CacheConstants.PWD_ERR_CNT_KEY + username;
String loginFail = Constants.LOGIN_FAIL;
Integer maxRetryCount = userPasswordProperties.getMaxRetryCount();
Integer lockTime = userPasswordProperties.getLockTime();
// 获取用户登录错误次数,默认为0 (可自定义限制策略 例如: key + username + ip)
int errorNumber = ObjectUtil.defaultIfNull(RedisUtils.getCacheObject(errorKey), 0);
// 锁定时间内登录 则踢出
if (errorNumber >= maxRetryCount) {
recordLogininfor(tenantId, username, loginFail, MessageUtils.message(loginType.getRetryLimitExceed(), maxRetryCount, lockTime));
throw new UserException(loginType.getRetryLimitExceed(), maxRetryCount, lockTime);
}
if (supplier.get()) {
// 错误次数递增
errorNumber++;
RedisUtils.setCacheObject(errorKey, errorNumber, Duration.ofMinutes(lockTime));
// 达到规定错误次数 则锁定登录
if (errorNumber >= maxRetryCount) {
recordLogininfor(tenantId, username, loginFail, MessageUtils.message(loginType.getRetryLimitExceed(), maxRetryCount, lockTime));
throw new UserException(loginType.getRetryLimitExceed(), maxRetryCount, lockTime);
} else {
// 未达到规定错误次数
recordLogininfor(tenantId, username, loginFail, MessageUtils.message(loginType.getRetryLimitCount(), errorNumber));
throw new UserException(loginType.getRetryLimitCount(), errorNumber);
}
}
// 登录成功 清空错误次数
RedisUtils.deleteObject(errorKey);
}
登录检查过程中会检查输入密码错误次数是否已经超过了限制,redis中的key为"pwd_err_cnt:+账号名",如:"pwd_err_cnt:admin"。密码的最大错误次数和锁定时间,在nacos中通过以下属性配置:
# 用户配置
user:
password:
# 密码最大错误次数
maxRetryCount: 5
# 密码锁定时间(默认10分钟)
lockTime: 10
配置的意思是如果在10分钟内错误次数超过了5,那么提示“密码输入错误{0}次,帐户锁定{1}分钟”。如果密码未达到5次,则提示“密码输入错误{0}次”。、
登录成功,则清除登录错误次数。
记录登录日志
检查过程中,如果失败,则记录失败登录日志。例如失败次数超过最大次数,则记录日志:
if (errorNumber >= maxRetryCount) {
recordLogininfor(tenantId, username, loginFail, MessageUtils.message(loginType.getRetryLimitExceed(), maxRetryCount, lockTime));
throw new UserException(loginType.getRetryLimitExceed(), maxRetryCount, lockTime);
}recordLogininfor(tenantId, username, loginFail, MessageUtils.message(loginType.getRetryLimitExceed(), maxRetryCount, lockTime));
记录日志方法如下:
/**
* 记录登录信息
*
* @param username 用户名
* @param status 状态
* @param message 消息内容
* @return
*/
public void recordLogininfor(String tenantId, String username, String status, String message) {
// 封装对象
LogininforEvent logininforEvent = new LogininforEvent();
logininforEvent.setTenantId(tenantId);
logininforEvent.setUsername(username);
logininforEvent.setStatus(status);
logininforEvent.setMessage(message);
SpringUtils.context().publishEvent(logininforEvent);
}
日志记录了以下信息:租户、用户名、登录状态、登录消息。通过SpringUtils.context().publishEvent(logininforEvent);发布事件。发布的事件由ruoyi-common-log中的LogEventListener处理,LogininforEvent类型的日志由以下方法处理:
/**
* 保存系统访问记录
*/
@EventListener
public void saveLogininfor(LogininforEvent logininforEvent) {
HttpServletRequest request = ServletUtils.getRequest();
final UserAgent userAgent = UserAgentUtil.parse(request.getHeader("User-Agent"));
final String ip = ServletUtils.getClientIP(request);
// 客户端信息
String clientId = request.getHeader(LoginHelper.CLIENT_KEY);
RemoteClientVo clientVo = null;
if (StringUtils.isNotBlank(clientId)) {
clientVo = remoteClientService.queryByClientId(clientId);
}
String address = AddressUtils.getRealAddressByIP(ip);
StringBuilder s = new StringBuilder();
s.append(getBlock(ip));
s.append(address);
s.append(getBlock(logininforEvent.getUsername()));
s.append(getBlock(logininforEvent.getStatus()));
s.append(getBlock(logininforEvent.getMessage()));
// 打印信息到日志
log.info(s.toString(), logininforEvent.getArgs());
// 获取客户端操作系统
String os = userAgent.getOs().getName();
// 获取客户端浏览器
String browser = userAgent.getBrowser().getName();
// 封装对象
RemoteLogininforBo logininfor = new RemoteLogininforBo();
logininfor.setTenantId(logininforEvent.getTenantId());
logininfor.setUserName(logininforEvent.getUsername());
if (ObjectUtil.isNotNull(clientVo)) {
logininfor.setClientKey(clientVo.getClientKey());
logininfor.setDeviceType(clientVo.getDeviceType());
}
logininfor.setIpaddr(ip);
logininfor.setLoginLocation(address);
logininfor.setBrowser(browser);
logininfor.setOs(os);
logininfor.setMsg(logininforEvent.getMessage());
// 日志状态
if (StringUtils.equalsAny(logininforEvent.getStatus(), Constants.LOGIN_SUCCESS, Constants.LOGOUT, Constants.REGISTER)) {
logininfor.setStatus(Constants.SUCCESS);
} else if (Constants.LOGIN_FAIL.equals(logininforEvent.getStatus())) {
logininfor.setStatus(Constants.FAIL);
}
remoteLogService.saveLogininfor(logininfor);
}
登录日志处理逻辑如下:
- 获取HttpServletRequest请求,从HttpServletRequest中获取User-Agent,使用hutool的UserAgentUtil进行解析获取到UserAgent详细信息;
-
通过hutool获取终端IP地址的方法获取到客户端的IP;
-
从http请求header中获取clientId,通过远程调用获取远程客户端的详情;
-
通过AddressUtils.getRealAddressByIP(ip)方法获取登录IP所在的省市信息,记录到loginLocation字段;根据IP获取省市信息的方法使用IPv4离线库ip2region,不支持IPv6获取地址,目前IPv6只能原样展示;
-
通过User-Agent获取操作系统和浏览器信息设置到os和browser字段;
-
保存IP、登录账号、登录消息、状态等信息之后,远程调用保存日志的方法remoteLogService.saveLogininfor(logininfor)保存日志。远程保存auth模块为dubbo客户端
@DubboReference private RemoteLogService remoteLogService;system模块为dubbo服务端:
@Service @DubboService public class RemoteLogServiceImpl implements RemoteLogService { private final ISysOperLogService operLogService; private final ISysLogininforService logininforService; …… /** * 保存访问记录 * * @param remoteLogininforBo 访问实体 */ @Async @Override public void saveLogininfor(RemoteLogininforBo remoteLogininforBo) { SysLogininforBo sysLogininforBo = MapstructUtils.convert(remoteLogininforBo, SysLogininforBo.class); logininforService.insertLogininfor(sysLogininforBo); }
设置登录信息
根据登录用户信息构建SaLoginModel:
1. 登录设备:model.setDevice(client.getDeviceType());
2. 超时时间:不同用户体系 不同 token 授权时间 不设置默认走全局 yml 配置,例如: 后台用户30分钟过期 app用户1天过期,model.setTimeout(client.getTimeout());
3. 登录 token 最低活跃频率:model.setActiveTimeout(client.getActiveTimeout());
4. clientId设置到附加参数中:model.setExtra(LoginHelper.CLIENT_KEY, client.getClientId());
根据loginUser和SaLoginModel生成token,生成token逻辑后续分析。
LoginHelper.login(loginUser, model);
根据生成的token构造LoginVo返回前端:
LoginVo loginVo = new LoginVo();
loginVo.setAccessToken(StpUtil.getTokenValue());
loginVo.setExpireIn(StpUtil.getTokenTimeout());
loginVo.setClientId(client.getClientId());
return loginVo;
生成token逻辑
LoginHelper使用sa-token的StpUtil生成token:
/**
* 登录系统 基于 设备类型
* 针对相同用户体系不同设备
*
* @param loginUser 登录用户信息
* @param model 配置参数
*/
public static void login(LoginUser loginUser, SaLoginModel model) {
model = ObjectUtil.defaultIfNull(model, new SaLoginModel());
StpUtil.login(loginUser.getLoginId(),
model.setExtra(TENANT_KEY, loginUser.getTenantId())
.setExtra(USER_KEY, loginUser.getUserId())
.setExtra(USER_NAME_KEY, loginUser.getUsername())
.setExtra(DEPT_KEY, loginUser.getDeptId())
.setExtra(DEPT_NAME_KEY, loginUser.getDeptName())
.setExtra(DEPT_CATEGORY_KEY, loginUser.getDeptCategory())
);
StpUtil.getTokenSession().set(LOGIN_USER_KEY, loginUser);
}
同样,获取token相关信息也是使用StpUtil,如上一步使用StpUtil.getTokenValue()获取token的值,StpUtil.getTokenTimeout()获取token剩余有效时间。
首页
上一步返回的访问令牌通过以下方法设置到userStore中:
const login = async (userInfo: LoginData): Promise<void> => {
const [err, res] = await to(loginApi(userInfo));
if (res) {
const data = res.data;
setToken(data.access_token);
token.value = data.access_token;
return Promise.resolve();
}
return Promise.reject(err);
};
setToken方法定义在@/utils/auth中,把获取到的令牌保存到tokenStorage中:
export const setToken = (access_token: string) => (tokenStorage.value = access_token);
根据src/permission.ts文件中对路由添加的前置守卫,首先@/utils/auth中的getToken()方法判断tokenStorage中是否存在token,
如果已经存在token说明已经登录,就可以调用后端的接口获取用户信息,如果用户角色列表为空,则调用用户信息接口获取用户信息:
if (useUserStore().roles.length === 0) {
isRelogin.show = true;
// 判断当前用户是否已拉取完user_info信息
const [err] = await tos(useUserStore().getInfo());
if (err) {
await useUserStore().logout();
ElMessage.error(err);
next({ path: '/' });
} else {
isRelogin.show = false;
const accessRoutes = await usePermissionStore().generateRoutes();
// 根据roles权限生成可访问的路由表
accessRoutes.forEach((route) => {
if (!isHttp(route.path)) {
router.addRoute(route); // 动态添加可访问路由表
}
});
// @ts-ignore
next({ path: to.path, replace: true, params: to.params, query: to.query, hash: to.hash, name: to.name as string }); // hack方法 确保addRoutes已完成
}
}
用户信息获取之后调用usePermissionStore().generateRoutes()更新用户路由列表,最后跳转到首页。
文章评论