vue-element-admin集成Keycloak实现统一身份验证、权限控制

10,804 阅读6分钟

vue-element-admin是一个在github拥有极高star数的后台前端解决方案,基于vueelement-ui实现。vue本身易上手,element-ui组件丰富,即便后端开发人员使用vue-element-admin也能较快的开发出不错的管理后台。但是,当公司内部有多个后台系统的时候,直接使用vue-element-admin内置的登录、身份验证功能时,无法达到统一管理用户、权限的目的,这时统一的SSO登录、身份验证、权限控制就显得尤为重要和方便。本文便讲解如何将vue-element-admin集成Keycloak的方法,从而实现统一的SSO登录、身份验证、权限控制。如果对Keycloak还不太了解的话,可以参考我之前写的一篇Keycloak快速上手指南,先对基本概念进行初步的认识,相信对本文后续的理解会有所帮助。

vue-element-admin身份验证、权限控制核心代码分析

既然是要将vue-element-admin与Keycloak进行集成,那么有必要先来对vue-element-admin本身的登录、身份验证、权限控制相关的功能是如何实现的进行一个了解。核心代码主要位于如下几个文件中:

  • src/permission.js:针对vue-router进行的全局导航守卫配置,身份验证、权限控制最为核心的逻辑都在这个文件中
  • src/router/index.jsvue-router相关的路由配置,其中asyncRoutes是与roles相关的动态路由配置
  • src/modules/permission.js:根据roles进行权限控制、生成动态路由的vuex相关操作
  • src/modules/user.js:登录登出、用户信息、Token等的vuex相关操作
  • src/api/user.js:登录登出、用户信息的API操作
  • src/views/login/index.vue:登录页面
  • src/layout/components/Navbar.vue:登出入口所在组件

接下来就对上面这些文件中部分的核心代码进行下分析。

src/permission.js核心代码分析

大部分代码都比较简单,token是从cookie中获取,当然也可以自定义token的存储,比如存在localStorage中,重点看下动态路由部分的代码:

  // determine whether the user has obtained his permission roles through getInfo
  const hasRoles = store.getters.roles && store.getters.roles.length > 0
  if (hasRoles) {
    next()
  } else {
    try {
      // get user info
      // note: roles must be a object array! such as: ['admin'] or ,['developer','editor']
      const { roles } = await store.dispatch('user/getInfo')

      // generate accessible routes map based on roles
      const accessRoutes = await store.dispatch('permission/generateRoutes', roles)

      // 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()
    }
  }

调用vuex中的user/getInfo获取用户用户角色,并根据用户角色生成相应的路由动态添加

src/router/index.js核心代码分析

  {
    path: '/permission',
    component: Layout,
    redirect: '/permission/page',
    alwaysShow: true, // will always show the root menu
    name: 'Permission',
    meta: {
      title: 'Permission',
      icon: 'lock',
      roles: ['admin', 'editor'] // you can set roles in root nav
    },
    children: [
      {
        path: 'page',
        component: () => import('@/views/permission/page'),
        name: 'PagePermission',
        meta: {
          title: 'Page Permission',
          roles: ['admin'] // or you can only set roles in sub nav
        }
      },
      {
        path: 'directive',
        component: () => import('@/views/permission/directive'),
        name: 'DirectivePermission',
        meta: {
          title: 'Directive Permission'
          // if do not set roles, means: this page does not require permission
        }
      },
      {
        path: 'role',
        component: () => import('@/views/permission/role'),
        name: 'RolePermission',
        meta: {
          title: 'Role Permission',
          roles: ['admin']
        }
      }
    ]
  }

上面是asyncRoutes中的部分定义,可以看到,meta中的roles便是对相应路由的权限控制

src/modules/permission.js核心代码分析

主要是根据roles以及asyncRoutes中定义的路由,来生成有访问权限的路由

  generateRoutes({ commit }, roles) {
    return new Promise(resolve => {
      let accessedRoutes
      if (roles.includes('admin')) {
        accessedRoutes = asyncRoutes || []
      } else {
        accessedRoutes = filterAsyncRoutes(asyncRoutes, roles)
      }
      commit('SET_ROUTES', accessedRoutes)
      resolve(accessedRoutes)
    })
  }

src/modules/user.js核心代码分析

login action

调用API登录成功后保存token

  login({ commit }, userInfo) {
    const { username, password } = userInfo
    return new Promise((resolve, reject) => {
      login({ username: username.trim(), password: password }).then(response => {
        const { data } = response
        commit('SET_TOKEN', data.token)
        setToken(data.token)
        resolve()
      }).catch(error => {
        reject(error)
      })
    })
  }

getInfo action

获取用户信息并进行保存,主要是roles、name、avatar、introduction,自己需要额外的信息在这里可以扩展

  getInfo({ commit, state }) {
    return new Promise((resolve, reject) => {
      getInfo(state.token).then(response => {
        const { data } = response

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

        const { roles, name, avatar, introduction } = data

        // roles must be a non-empty array
        if (!roles || roles.length <= 0) {
          reject('getInfo: roles must be a non-null array!')
        }

        commit('SET_ROLES', roles)
        commit('SET_NAME', name)
        commit('SET_AVATAR', avatar)
        commit('SET_INTRODUCTION', introduction)
        resolve(data)
      }).catch(error => {
        reject(error)
      })
    })
  }

logout action

登出并清空保存的token、roles等

  logout({ commit, state, dispatch }) {
    return new Promise((resolve, reject) => {
      logout(state.token).then(() => {
        commit('SET_TOKEN', '')
        commit('SET_ROLES', [])
        removeToken()
        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)
      })
    })
  }

src/views/login/index.vue核心代码分析

调用vuex中的user/loginaction进行登录、保存token操作

  this.$refs.loginForm.validate(valid => {
    if (valid) {
      this.loading = true
      this.$store.dispatch('user/login', this.loginForm)
        .then(() => {
          this.$router.push({ path: this.redirect || '/', query: this.otherQuery })
          this.loading = false
        })
        .catch(() => {
          this.loading = false
        })
    } else {
      console.log('error submit!!')
      return false
    }
  })

src/layout/components/Navbar.vue登出代码分析

调用vuex中的user/logoutaction进行登出、清空token操作,并跳转登录页

  async logout() {
    await this.$store.dispatch('user/logout')
    this.$router.push(`/login?redirect=${this.$route.fullPath}`)
  }

vue-element-admin集成Keycloak整合思路

有了上面的身份验证、权限控制相关的文件及代码分析,接下来我们就知道应该改动哪些地方去集成Keycloak了。事实上,需要做的最关键的事就是这3件:

  • 身份验证交给Keycloak,验证不通过时,用户登录直接到Keycloak的登录页

  • token及用户相关的信息(name、roles等)从原来调用独立API的方式改为从Keycloak直接获取

  • 用户登出改为使用Keycloak进行统一登出

说白了,就是登录、用户认证信息获取、登出全都交给Keycloak。

Keycloak后台配置准备

在正式开始对相关的文件进行修改前,我们要先在Keycloak的管理后台创建好相关的client、roles、users等资源,以便给后续vue-element-admin集成Keycloak时使用。

创建客户端

keycloak-vue-element-admin-client-1

创建2个角色

keycloak-vue-element-admin-role

创建2个用户

创建用户admin

创建admin用户并添加2个attributes:avatar、introduction,Keycloak中默认的用户信息比较少,需要扩展用户信息可在attributes中添加

keycloak-vue-element-admin-user-admin-1

为admin用户添加角色admin

keycloak-vue-element-admin-user-admin-2

创建用户editor

创建editor用户并添加2个attributes:avatar、introduction

keycloak-vue-element-admin-user-editor-1

为editor用户添加角色editor

keycloak-vue-element-admin-user-editor-2

客户端设置attributes mappers

设置attributes mappers之后,idtoken中会包含对应的attributes信息,方便js客户端直接获取用户信息

keycloak-vue-element-admin-client-2

vue-element-admin集成Keycloak代码详解

经过上面的分析,我们知道需要将登录、用户认证信息获取、登出部分进行改动,交给Keycloak处理。我们将对如下文件进行修改:

  • src/store/modules/user.js:将vuex中登录、获取用户信息、登出相关的action改为通过Keycloak进行管理
  • src/main.js:添加Keycloak初始化集成,将身份验证及登录部分交给Keycloak接管
  • src/permission.js:获取roles部分改为从Keycloak获取
  • src/layout/components/Navbar.vue:登出逻辑改为调用Keycloak的登出

下面将展示这4个文件改动的具体代码

src/store/modules/user.js代码改动

不直接改动原有vuex的action,新增3个Keycloak相关的action

增加keycloakLogin action

keycloakLogin action主要是在Keycloak登录成功后设置token

  keycloakLogin({ commit }, accessToken) {
    return new Promise((resolve, reject) => {
      commit('SET_TOKEN', accessToken)
      setToken(accessToken)
      resolve()
    })
  }

增加getKeycloakInfo action

getKeycloakInfo action用来从Keycloak中获取用户相关的信息,此示例中包括roles、name、avatar、introduction这4项用户信息

  getKeycloakInfo({ commit, state }) {
    return new Promise((resolve, reject) => {
      if (!Vue.prototype.$keycloak) {
        reject('keycloak not init')
      }

      if (!Vue.prototype.$keycloak.authenticated) {
        reject('Verification failed, please Login again.')
      }

      const roles = Vue.prototype.$keycloak.realmAccess.roles
      const name = Vue.prototype.$keycloak.idTokenParsed.preferred_username
      const avatar = Vue.prototype.$keycloak.idTokenParsed.avatar
      const introduction = Vue.prototype.$keycloak.idTokenParsed.introduction

      // roles must be a non-empty array
      if (!roles || roles.length <= 0) {
        reject('getKeycloakInfo: roles must be a non-null array!')
      }

      // you can also use the method loadUserProfile() to get user attributes
      // Vue.prototype.$keycloak.loadUserProfile().then(profile => {
      //   let avatar = profile.attributes.avatar[0]
      //   let introduction = profile.attributes.introduction[0]
      // })

      const data = {
        roles,
        name,
        avatar,
        introduction
      }

      commit('SET_ROLES', roles)
      commit('SET_NAME', name)
      commit('SET_AVATAR', avatar)
      commit('SET_INTRODUCTION', introduction)
      resolve(data)
    })
  }

需要说明的是,这里直接从keycloak的idTokenParsed获取到了自定义的attributes:avatar、introduction,是因为上面在Keycloak的后台进行了attributes mappers的设置,如果不进行这项设置,可以通过keycloak的loadUserProfile()方法获取到自定义的attributes

增加keycloakLogout action

keycloakLogout action是通过Keycloak进行登出操作,成功后便清除本地保存的信息

  keycloakLogout({ commit, state }) {
    return new Promise((resolve, reject) => {
      Vue.prototype.$keycloak.logout().then(() => {
        removeToken() // must remove  token  first
        resetRouter()
        commit('RESET_STATE')
        resolve()
      }).catch(error => {
        reject(error)
      })
    })
  }

src/main.js 代码改动

入口文件main.js中主要是与Keycloak进行初始化集成,Keycloak身份验证通过后调用user/keycloakLoginaction保存token

// keycloak init options
const initOptions = {
  url: process.env.VUE_APP_KEYCLOAK_OPTIONS_URL,
  realm: process.env.VUE_APP_KEYCLOAK_OPTIONS_REALM,
  clientId: process.env.VUE_APP_KEYCLOAK_OPTIONS_CLIENTID,
  onLoad: process.env.VUE_APP_KEYCLOAK_OPTIONS_ONLOAD
}

const keycloak = Keycloak(initOptions)

keycloak.init({ onLoad: initOptions.onLoad }).then(async authenticated => {
  if (!authenticated) {
    window.location.reload()
    return
  } else {
    Vue.prototype.$keycloak = keycloak
    await store.dispatch('user/keycloakLogin', keycloak.token)
    console.log('Authenticated', keycloak)
  }

  setInterval(() => {
    keycloak.updateToken(70).then((refreshed) => {
      if (refreshed) {
        console.log('Token refreshed')
      } else {
        console.log('Token not refreshed, valid for ' +
          Math.round(keycloak.tokenParsed.exp + keycloak.timeSkew - new Date().getTime() / 1000) + ' seconds')
      }
    }).catch(error => {
      console.log('Failed to refresh token', error)
    })
  }, 60000)

  new Vue({
    el: '#app',
    router,
    store,
    render: h => h(App)
  })
}).catch(error => {
  console.log('Authenticated Failed', error)
})

src/permission.js代码改动

roles获取改为调用user/getKeycloakInfoaction从Keycloak获取

const { roles } = await store.dispatch('user/getKeycloakInfo')

src/layout/components/Navbar.vue代码改动

登出逻辑改为调用user/keycloakLogoutaction通过Keycloak进行统一的登出处理,并去掉往vue-element-admin自带的login跳转的逻辑

async logout() {
  await this.$store.dispatch('user/keycloakLogout')
  // this.$router.push(`/login?redirect=${this.$route.fullPath}`)
}

vue-element-admin集成Keycloak后效果演示

本地npm run dev启动页面后,首次会进入Keycloak登录页,通过上面创建的admin、editor用户登录后,效果如下

admin用户登录效果

admin用户登录后,Dashboard能看到完整的图表,且Permission菜单下能看到3个子菜单

keycloak-vue-element-admin-result-1

editor用户登录效果

editor用户登录后,Dashboard只展示了基本的信息,且Permission菜单下只能看到1个子菜单

keycloak-vue-element-admin-result-2

总结

本文先对vue-element-admin原始的身份验证、权限控制逻辑进行了分析,接着给出了与Keycloak集成的思路,最后通过具体的代码展示了vue-element-admin与Keycloak集成的方法。对于其他的前端应用,与Keycloak集成的思路也是相通的,都是将登录、获取用户信息、登出的部分交给Keycloak统一处理。

集成的项目地址:vue-element-admin-keycloak