如何自己实现一个健壮的 SSO 单点登录系统

Laravel框架
507
0
0
2022-05-08
标签   单点登录

简介

因公司后台按照业务划分,不同的业务需要有不同的后台,越来越多的时候每次登录后台都要重新输入账号密码实在是不方便,所以需要实现一个 SSO 单点登录,网上翻阅了一些 SSO 的实现方案,有如下几个实现方案:

  • 基于父级域名实现跨域 Cookie
  • 基于 LocalStorage 跨域
  • 基于自己搭建认证中心

本篇文章选用了搭建认证中心该方案,该实现效果和其他 SSO 单点登录一样,有如下特点:

  • 跨域名的单点登录(一个站点登录即所有站点都登录)
  • 跨域名的单点退出(一个站点退出即所有站点都退出)
  • 实时的账户信息同步(当 A 站点的 A 账户切换了 B 账户重新登录后,访问其他站点也会切换至 B 账户)

涉及技术点

  • Redis(采用 Redis 存储用户的 Token 实现多站点统一 Token)
  • JWT(采用 JWT 实现用户的 Token 加密)

实现步骤

后台实现

passport 后台

passport 后台登录需要实现的方法

  • login:账号登录(供用户登录使用)
  • authTokenLogin:授权码登录(供其他站点自动登录使用)
  • getAuthToken:获取授权 token
  • logout:退出登录
  • Middleware 中间件校验

账号登录

// TODO:: 基础逻辑,验证账号密码业务逻辑...

// 调用 jwt 生成token
$token = JWT::enToken($admin->id);

// 将用户token存入 redis
Redis::set("adminUserToken:{$admin->id}", $token);

return $this->succeed([
    'token' => $token
]);

账号登录的通用逻辑为使用 jwt 生成 token,然后将用户 token 存入 Redis。

授权码登录

// 获取到前端传来的授权token,并解密出用户ID
$adminId = CommonSupport::authcode($request->input("authToken"));

// TODO:: 验证业务逻辑...

// 获取用户当前的登录token
$redisToken = Redis::get("adminUserToken:{$adminId}");

return $this->succeed([
    'token' => $redisToken
]);

授权码登录的使用场景为当访问其他站点时,若本地 token 已过期或本地没有 token,则重定向至 passport 后台,passport 后台判断本地为已登录状态时会向接口索要 authToken,并带上 authToken 重定向至其他站点,其他站点获取到 url 上有 authToken 时,则会用 authToken 进行登录,然后下发用户当前的 token,并存储,完成了登录流程。

获取授权 token

// 获取当前已登录的用户ID
$adminId = Context::get("currentAdmin")['id'];

// 加密获取授权Token
$authToken = CommonSupport::authcode($adminId, "ENCODE");

return $this->succeed([
    "authToken" => $authToken
]);

退出登录

// 获取当前已登录的用户ID
$adminId = Context::get("currentAdmin")['id'];

// 将该用户 Token 从 redis 中删除
Redis::del("adminUserToken:{$adminId}");

return $this->succeed();

中间件校验

public function checkToken(string $token)
{
    // 解密 JWT token,验证 token 是否有效,若解密失败则报错 
    $jwt = JWT::deToken($token);
    if (!$jwt) {
        throw new ApiException(10001, "用户验证失败");
    }
    $userId = (int)$jwt->data;

    // 从 redis 中获取用户token 
    $redisToken = Redis::get("adminUserToken:{$userId}");

    // 如果用户token不存在redis或者和redis中的不相等,则报错 
    if ($redisToken != $token) {
        throw new ApiException(10001, "用户验证失败");
    }

    // 判断管理员是否存在 
    $admin = Admin::find($userId);
    if (!$admin) {
        throw new ApiException(10001, "用户验证失败");
    }

    return $this->succeed($admin->toArray());
}

业务后台

业务后台的实现就变得简单,只需要在中间件中调用 passport 后台的 checkToken 方法,具体实现可使用 Http 方式请求,或者 Rpc 调用。

前端实现

前端技术采用的是 Vue2.0,使用 vue-element-admin实现

passport 前端

permission.js 文件

permission.js 文件在每次刷新页面时都会进入该页面,在该页面通过获取 url 上特定的参数来完成特定的动作

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

  // 设置页面标题 
  document.title = getPageTitle(to.meta.title)

  // 全局获取url上的请求参数 
  const query = to.query

  // 获取本地token 
  const hasToken = getToken()

  // 如果登录了 
  if (hasToken) {
    // 如果有场景值 
    if (query.scene) {
      // 如果场景值是退出登录,并且有重定向地址,则跳转到登录界面并销毁本地token 
      if (query.scene === 'logout' && query.redirectUri) {
        const redirectUri = encodeURIComponent(query.redirectUri)
        await store.dispatch('user/logout')
        next(`/login?redirectUri=${redirectUri}`)
        NProgress.done()
        return
      }
    }

    // 如果有重定向地址 
    if (query.redirectUri) {
      // 则获取授权token 
      const authToken = await getAuthToken()
      const redirectUri = query.redirectUri 
      // 跳转到重定向地址,把授权token带过去 
      window.location = redirectUri + '?token=' + authToken.data.authToken 
      NProgress.done()
      return
    }

    // 如果没登录
  } else {

    // 如果有场景值 
    if (query.scene) {
      // 如果场景值是退出登录,并且有重定向地址,则跳转到登录界面 
      if (query.scene === 'logout' && query.redirectUri) {
        next(`/login?redirectUri=${encodeURIComponent(query.redirectUri)}`)
        NProgress.done()
        return
      }
    }

    // 如果有企业微信回调回来的code 
    if (query.code) {
      // 调用接口使用微信授权码登录 
      const tokenData = await workWechatCodeLogin({
        code: query.code
      })

      // 将 token 存入本地 
      const token = tokenData.data.token 
      setToken(token)

      // 如果有重定向地址 
      if (query.state) {
        window.location.href = process.env.VUE_APP_CURRENT_URL +
          '/home?redirectUri=' + encodeURIComponent(Base64.decode(query.state))
      } else {
        window.location.href = process.env.VUE_APP_CURRENT_URL
      }
    }

    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.

      if (query.redirectUri) {
        next(`/login?redirectUri=${query.redirectUri}`)
      } else {
        next(`/login?redirect=${to.path}`)
      }
      NProgress.done()
    }
  }
})

request.js

该文件为网络请求基础文件,该文件中需要修改当接口返回登录失效的时候,直接跳转至登录页。

  response => {
    const res = response.data

    // if the custom code is not 200, it is judged as an error. 
    if (res.code !== 200) {
      // 如果接口返回 10001 代表登录失效,则重定向至登录页 
      if (res.code === 10001) {
        // 先删除token 
        removeToken()
        // 然后跳转到登录页 
        window.location = process.env.VUE_APP_CURRENT_URL + '/login' 
        return
      }

      Message({
        message: res.msg || 'Error',
        type: 'error',
        duration: 5 * 1000
      })

      return Promise.reject(new Error(res.msg || 'Error'))
    } else {
      return res
    }
  },

业务前端

permission.js

    //TODO:: 先判断如果是没登录的话 
    // 判断是否有从 passport 后台重定向回来并带着token 
    if (query.token) {

      // 根据授权token去请求登录token 
      const tokenData = await authTokenLogin({
        authToken: query.token,
        platform_id: process.env.VUE_APP_CURRENT_PLATFORM_ID
      })
      // 将token存入本地并刷新当前页面 
      const token = tokenData.data.token 
      setToken(token)
      window.location = process.env.VUE_APP_CURRENT_HOME_URL
      location.reload()

      // 如果没有 授权token参数
    } else {

      // 则跳转到 passport 登录页面并带着当前后台的地址作为 redirectUri 参数 
      // other pages that do not have permission to access are redirected to the login page. 
      const redirectUri = encodeURIComponent(process.env.VUE_APP_CURRENT_HOME_URL)
      window.location = process.env.VUE_APP_PASSPORT_WEB_LOGIN_URL + '?redirectUri=' + redirectUri
      NProgress.done()
    }

request.js

    if (res.code !== 200) {
      // 如果接口返回 10001 代表登录失效,则重定向至登录页 
      if (res.code === 10001) {
        // 先删除token 
        removeToken()
        // 然后带着当前地址作为 redirectUri 跳转到 SSO 登录页 
        const redirectUri = encodeURIComponent(process.env.VUE_APP_CURRENT_HOME_URL)
        window.location = process.env.VUE_APP_PASSPORT_WEB_LOGIN_URL + '?scene=logout&redirectUri=' + redirectUri
      }

      Message({
        message: res.msg || 'Error',
        type: 'error',
        duration: 5 * 1000
      })

      return Promise.reject(new Error(res.msg || 'Error'))
    }