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

简介

因公司后台按照业务划分,不同的业务需要有不同的后台,越来越多的时候每次登录后台都要重新输入账号密码实在是不方便,所以需要实现一个 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'))
    }
本作品采用《CC 协议》,转载必须注明作者和本文链接
本帖由系统于 2年前 自动加精
《L01 基础入门》
我们将带你从零开发一个项目并部署到线上,本课程教授 Web 开发中专业、实用的技能,如 Git 工作流、Laravel Mix 前端工作流等。
《L02 从零构建论坛系统》
以构建论坛项目 LaraBBS 为线索,展开对 Laravel 框架的全面学习。应用程序架构思路贴近 Laravel 框架的设计哲学。
讨论数量: 8

laravel 自带有passport ,这个是在passport基础上修改?还是自己做的类似与passport的功能

2年前 评论
邢闯洋 (楼主) 2年前

后端demo可以分享吗?

2年前 评论
邢闯洋 (楼主) 2年前

用户中心,是把所有用户放在一起吗?如果是这样,原来各自的用户怎样整合呢?楼主分享下思路

2年前 评论
邢闯洋 (楼主) 2年前
邢闯洋 (楼主) 2年前

这里demo有个问题,通过redis共享token,没有考虑redis缓存命中率对token的影响

1年前 评论

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!