初識ABP vNext(4):vue使用者登入&選單許可權

xhznl發表於2020-08-19

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

          if (!data) {
            reject('Verification failed, please Login again.')
          }

          const { name } = data

          commit('SET_NAME', name)
          commit('SET_AVATAR', '')
          commit('SET_INTRODUCTION', '')
          resolve(data)
        })
        .catch(error => {
          reject(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 })

            resolve()
          })
        })
        .catch(error => {
          reject(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

    if (store.getters.token) {
      config.headers["authorization"] = "Bearer " + getToken();
    }
    return 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;

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

    if (error.status === 401) {
      // to re-login
      MessageBox.confirm(
        "You have been logged out, you can cancel to stay on this page, or log in again",
        "Confirm logout",
        {
          confirmButtonText: "Re-Login",
          cancelButtonText: "Cancel",
          type: "warning",
        }
      ).then(() => {
        store.dispatch("user/resetToken").then(() => {
          location.reload();
        });
      });
    }

    return Promise.reject(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;

      if (name) {
        next();
      } else {
        try {
          // get user info
          await store.dispatch("user/getInfo");

          store.dispatch("user/setRoles", abpConfig.currentUser.roles);
            
          const grantedPolicies = abpConfig.auth.grantedPolicies;

          // generate accessible routes map based on grantedPolicies
          const accessRoutes = await store.dispatch(
            "permission/generateRoutes",
            grantedPolicies
          );

          // dynamically add accessible routes
          router.addRoutes(accessRoutes);

          // hack method to ensure that addRoutes is complete
          // set the replace: true, so the navigation will not leave a history record
          next({ ...to, replace: true });
        } catch (error) {
          // remove token and go to login page to re-login
          await store.dispatch("user/resetToken");
          Message.error(error || "Has Error");
          next(`/login?redirect=${to.path}`);
          NProgress.done();
        }
      }
    }
  } 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=${to.path}`);
      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多餘的選單。

相關文章