如何自己实现一个健壮的 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 协议》,转载必须注明作者和本文链接
本帖由系统于 4年前 自动加精
《L05 电商实战》
从零开发一个电商项目,功能包括电商后台、商品 & SKU 管理、购物车、订单管理、支付宝支付、微信支付、订单退款流程、优惠券等
《L01 基础入门》
我们将带你从零开发一个项目并部署到线上,本课程教授 Web 开发中专业、实用的技能,如 Git 工作流、Laravel Mix 前端工作流等。
讨论数量: 8

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

4年前 评论
Image 邢闯洋 (楼主) 4年前

后端demo可以分享吗?

4年前 评论
Image 邢闯洋 (楼主) 4年前

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

4年前 评论
Image 邢闯洋 (楼主) 4年前
Image 邢闯洋 (楼主) 4年前

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

3年前 评论

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