初识ABP vNext(4):vue用户登录&菜单权限

Tips:本篇已加入系列文章阅读目录,可点击查看更多相关文章。

前言

上一篇已经创建好了前后端项目,本篇开始编码部分。

开始

几乎所有的系统都绕不开登录功能,那么就从登录开始,完成用户登录以及用户菜单权限控制。

登录

首先用户输入账号密码点击登录,然后组合以下参数调用 identityserver 的/connect/token端点获取 token:

{
  grant_type: "password",
  scope: "HelloAbp",
  username: "",
  password: "",
  client_id: "HelloAbp_App",
  client_secret: "1q2w3e*"
}

这个参数来自 ABP 模板的种子数据:

我使用的是 password flow,这个 flow 无需重定向。如果你的网站应用只有一个的话,可以这么做,如果有多个的话建议采用其他 oidc 方式,把认证界面放到 identityserver 程序里,客户端重定向到 identityserver 去认证,这样其实更安全,并且你无需在每个客户端网站都做一遍登录界面和逻辑。。。

还有一点,严格来说不应该直接访问/connect/token端点获取 token。首先应该从 identityserver 发现文档/.well-known/openid-configuration中获取配置信息,然后从/.well-known/openid-configuration/jwks端点获取公钥等信息用于校验 token 合法性,最后才是获取 token。ABP 的 Angular 版本就是这么做的,不过他是使用angular-oauth2-oidc这个库完成,我暂时没有找到其他的支持 password flow 的开源库,参考:https://github.com/IdentityModel/oidc-client-js/issues/234

前端想正常访问接口,首先需要在 HttpApi.Host,IdentityServer 增加跨域配置:

前端部分需要修改的文件太多,下面只贴出部分主要代码,需要完整源码的可以去 GitHub 拉取。

src\store\modules\user.js:

const clientSetting = {
  grant_type: "password",
  scope: "HelloAbp",
  username: "",
  password: "",
  client_id: "HelloAbp_App",
  client_secret: "1q2w3e*"
};
const actions = {
  // user login
  login({commit}, userInfo) {
    const { username, password } = userInfo
    return new Promise((resolve, reject) => {
      clientSetting.username = username.trim()
      clientSetting.password = password
      login(clientSetting)
        .then(response => {
          const data = response
          commit('SET_TOKEN', data.access_token)
          setToken(data.access_token).then(() => {
            resolve()
          })
        })
        .catch(error => {
          reject(error)
        })
    })
  },

// get user info
getInfo({commit}) {
return new Promise((resolve, reject) => {
getInfo()
.then(response => {
const data = response

      <span class="hljs-keyword">if</span> (!data) {
        <span class="hljs-title function_">reject</span>(<span class="hljs-string">'Verification failed, please Login again.'</span>)
      }

      <span class="hljs-keyword">const</span> { name } = data

      <span class="hljs-title function_">commit</span>(<span class="hljs-string">'SET_NAME'</span>, name)
      <span class="hljs-title function_">commit</span>(<span class="hljs-string">'SET_AVATAR'</span>, <span class="hljs-string">''</span>)
      <span class="hljs-title function_">commit</span>(<span class="hljs-string">'SET_INTRODUCTION'</span>, <span class="hljs-string">''</span>)
      <span class="hljs-title function_">resolve</span>(data)
    })
    .<span class="hljs-title function_">catch</span>(<span class="hljs-function"><span class="hljs-params">error</span> =&gt;</span> {
      <span class="hljs-title function_">reject</span>(error)
    })
})

},

setRoles({commit}, roles) {
commit('SET_ROLES', roles)
},

// user logout
logout({commit, dispatch}) {
return new Promise((resolve, reject) => {
logout()
.then(() => {
commit('SET_TOKEN', '')
commit('SET_NAME', '')
commit('SET_AVATAR', '')
commit('SET_INTRODUCTION', '')
commit('SET_ROLES', [])
removeToken().then(() => {
resetRouter()
// reset visited views and cached views
// to fixed https://github.com/PanJiaChen/vue-element-admin/issues/2485
dispatch('tagsView/delAllViews', null, { root: true })

        <span class="hljs-title function_">resolve</span>()
      })
    })
    .<span class="hljs-title function_">catch</span>(<span class="hljs-function"><span class="hljs-params">error</span> =&gt;</span> {
      <span class="hljs-title function_">reject</span>(error)
    })
})

},

// remove token
resetToken({commit}) {
return new Promise(resolve => {
commit('SET_TOKEN', '')
commit('SET_NAME', '')
commit('SET_AVATAR', '')
commit('SET_INTRODUCTION', '')
commit('SET_ROLES', [])
removeToken().then(() => {
resolve()
})
})
}
}

src\utils\auth.js:

export async function setToken(token) {
  const result = Cookies.set(TokenKey, token);
  await store.dispatch("app/applicationConfiguration");
  return result;
}

export async function removeToken() {
const result = Cookies.remove(TokenKey);
await store.dispatch("app/applicationConfiguration");
return result;
}

src\api\user.js:

export function login(data) {
  return request({
    baseURL: "https://localhost:44364",
    url: "/connect/token",
    method: "post",
    headers: { "content-type": "application/x-www-form-urlencoded" },
    data: qs.stringify(data),
  });
}

export function getInfo() {
return request({
url: "/api/identity/my-profile",
method: "get",
});
}

export function logout() {
return request({
baseURL: "https://localhost:44364",
url: "/api/account/logout",
method: "get",
});
}

src\utils\request.js:

service.interceptors.request.use(
  (config) => {
    // do something before request is sent
<span class="hljs-keyword">if</span> (store.<span class="hljs-property">getters</span>.<span class="hljs-property">token</span>) {
  config.<span class="hljs-property">headers</span>[<span class="hljs-string">"authorization"</span>] = <span class="hljs-string">"Bearer "</span> + <span class="hljs-title function_">getToken</span>();
}
<span class="hljs-keyword">return</span> config;

},
(error) => {
// do something with request error
console.log(error); // for debug
return Promise.reject(error);
}
);

// response interceptor
service.interceptors.response.use(
(response) => {
const res = response.data;

<span class="hljs-keyword">return</span> res;

},
(error) => {
console.log("err" + error); // for debug
Message({
message: error.message,
type: "error",
duration: 5 * 1000,
});

<span class="hljs-keyword">if</span> (error.<span class="hljs-property">status</span> === <span class="hljs-number">401</span>) {
  <span class="hljs-comment">// to re-login</span>
  <span class="hljs-title class_">MessageBox</span>.<span class="hljs-title function_">confirm</span>(
    <span class="hljs-string">"You have been logged out, you can cancel to stay on this page, or log in again"</span>,
    <span class="hljs-string">"Confirm logout"</span>,
    {
      <span class="hljs-attr">confirmButtonText</span>: <span class="hljs-string">"Re-Login"</span>,
      <span class="hljs-attr">cancelButtonText</span>: <span class="hljs-string">"Cancel"</span>,
      <span class="hljs-attr">type</span>: <span class="hljs-string">"warning"</span>,
    }
  ).<span class="hljs-title function_">then</span>(<span class="hljs-function">() =&gt;</span> {
    store.<span class="hljs-title function_">dispatch</span>(<span class="hljs-string">"user/resetToken"</span>).<span class="hljs-title function_">then</span>(<span class="hljs-function">() =&gt;</span> {
      location.<span class="hljs-title function_">reload</span>();
    });
  });
}

<span class="hljs-keyword">return</span> <span class="hljs-title class_">Promise</span>.<span class="hljs-title function_">reject</span>(error);

}
);

菜单权限

vue-element-admin 的菜单权限是使用用户角色来控制的,我们不需要 role。前面分析过,通过/api/abp/application-configuration接口的 auth.grantedPolicies 字段,与对应的菜单路由绑定,就可以实现权限控制了。

src\permission.js:

router.beforeEach(async (to, from, next) => {
  // start progress bar
  NProgress.start();

// set page title
document.title = getPageTitle(to.meta.title);

let abpConfig = store.getters.abpConfig;
if (!abpConfig) {
abpConfig = await store.dispatch("app/applicationConfiguration");
}

if (abpConfig.currentUser.isAuthenticated) {
if (to.path === "/login") {
// if is logged in, redirect to the home page
next({ path: "/" });
NProgress.done(); // hack: https://github.com/PanJiaChen/vue-element-admin/pull/2939
} else {
//user name
const name = store.getters.name;

  <span class="hljs-keyword">if</span> (name) {
    <span class="hljs-title function_">next</span>();
  } <span class="hljs-keyword">else</span> {
    <span class="hljs-keyword">try</span> {
      <span class="hljs-comment">// get user info</span>
      <span class="hljs-keyword">await</span> store.<span class="hljs-title function_">dispatch</span>(<span class="hljs-string">"user/getInfo"</span>);

      store.<span class="hljs-title function_">dispatch</span>(<span class="hljs-string">"user/setRoles"</span>, abpConfig.<span class="hljs-property">currentUser</span>.<span class="hljs-property">roles</span>);
        
      <span class="hljs-keyword">const</span> grantedPolicies = abpConfig.<span class="hljs-property">auth</span>.<span class="hljs-property">grantedPolicies</span>;

      <span class="hljs-comment">// generate accessible routes map based on grantedPolicies</span>
      <span class="hljs-keyword">const</span> accessRoutes = <span class="hljs-keyword">await</span> store.<span class="hljs-title function_">dispatch</span>(
        <span class="hljs-string">"permission/generateRoutes"</span>,
        grantedPolicies
      );

      <span class="hljs-comment">// dynamically add accessible routes</span>
      router.<span class="hljs-title function_">addRoutes</span>(accessRoutes);

      <span class="hljs-comment">// hack method to ensure that addRoutes is complete</span>
      <span class="hljs-comment">// set the replace: true, so the navigation will not leave a history record</span>
      <span class="hljs-title function_">next</span>({ ...to, <span class="hljs-attr">replace</span>: <span class="hljs-literal">true</span> });
    } <span class="hljs-keyword">catch</span> (error) {
      <span class="hljs-comment">// remove token and go to login page to re-login</span>
      <span class="hljs-keyword">await</span> store.<span class="hljs-title function_">dispatch</span>(<span class="hljs-string">"user/resetToken"</span>);
      <span class="hljs-title class_">Message</span>.<span class="hljs-title function_">error</span>(error || <span class="hljs-string">"Has Error"</span>);
      <span class="hljs-title function_">next</span>(<span class="hljs-string">`/login?redirect=<span class="hljs-subst">${to.path}</span>`</span>);
      <span class="hljs-title class_">NProgress</span>.<span class="hljs-title function_">done</span>();
    }
  }
}

} else {
if (whiteList.indexOf(to.path) !== -1) {
// in the free login whitelist, go directly
next();
} else {
// other pages that do not have permission to access are redirected to the login page.
next(/login?redirect=<span class="hljs-subst">${to.path}</span>);
NProgress.done();
}
}
});

src\store\modules\permission.js:

function hasPermission(grantedPolicies, route) {
  if (route.meta && route.meta.policy) {
    const policy = route.meta.policy;
    return grantedPolicies[policy];
  } else {
    return true;
  }
}

export function filterAsyncRoutes(routes, grantedPolicies) {
const res = [];

routes.forEach((route) => {
const tmp = {...route};
if (hasPermission(grantedPolicies, tmp)) {
if (tmp.children) {
tmp.children = filterAsyncRoutes(tmp.children, grantedPolicies);
}
res.push(tmp);
}
});

return res;
}

const state = {
routes: [],
addRoutes: [],
};

const mutations = {
SET_ROUTES: (state, routes) => {
state.addRoutes = routes;
state.routes = constantRoutes.concat(routes);
},
};

const actions = {
generateRoutes({commit}, grantedPolicies) {
return new Promise((resolve) => {
let accessedRoutes = filterAsyncRoutes(asyncRoutes, grantedPolicies);
commit("SET_ROUTES", accessedRoutes);
resolve(accessedRoutes);
});
},
};

src\router\index.js:

export const asyncRoutes = [
  {
    path: '/permission',
    component: Layout,
    redirect: '/permission/page',
    alwaysShow: true, // will always show the root menu
    name: 'Permission',
    meta: {
      title: 'permission',
      icon: 'lock',
      policy: 'AbpIdentity.Roles'
    },
    children: [
      {
        path: 'page',
        component: () => import('@/views/permission/page'),
        name: 'PagePermission',
        meta: {
          title: 'pagePermission',
          policy: 'AbpIdentity.Roles'
        }
      },
      {
        path: 'directive',
        component: () => import('@/views/permission/directive'),
        name: 'DirectivePermission',
        meta: {
          title: 'directivePermission',
          policy: 'AbpIdentity.Roles'
        }
      },
      {
        path: 'role',
        component: () => import('@/views/permission/role'),
        name: 'RolePermission',
        meta: {
          title: 'rolePermission',
          policy: 'AbpIdentity.Roles'
        }
      }
    ]
  },

。。。。。。

// 404 page must be placed at the end !!!
{ path: '*', redirect: '/404', hidden: true }
]

因为菜单太多了,就拿其中的一个“权限测试页”菜单举例,将它与 AbpIdentity.Roles 绑定测试。

运行测试

运行前后端项目,使用默认账号 admin/1q2w3E* 登录系统:

正常的话就可以进入这个界面了:

目前可以看到“权限测试页”菜单,因为现在还没有设置权限的界面,所以我手动去数据库把这条权限数据删除,然后测试一下:

但是手动去数据库改这个表的话会有很长一段时间的缓存,在 redis 中,暂时没去研究这个缓存机制,正常通过接口修改应该不会这样。。。

我手动清理了 redis,运行结果如下:

最后

本篇实现了前端部分的登录和菜单权限控制,但是还有很多细节问题需要处理。比如右上角的用户头像,ABP 的默认用户表中是没有头像和用户介绍字段的,下篇将完善这些问题,还有删除掉 vue-element-admin 多余的菜单。