简介
因公司后台按照业务划分,不同的业务需要有不同的后台,越来越多的时候每次登录后台都要重新输入账号密码实在是不方便,所以需要实现一个 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')) | |
} |