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