背景
🤔 企业多应用的登录问题?单点登录有什么好处呢?
单应用的登录流程我们都了解,第一次登录前端将用户输入的账号密码发送给后台,后台如果验证通过,就返回一个凭证 token,之后前端在每次请求中携带这个 token 就可以正常访问了。
但是对于企业中多应用的场景,如果每个应用仍然使用上面的登录流程,则会显得繁琐。一是各个应用后台都需要维护用户的账号密码信息,二是用户对各个应用都需要输入账号密码登录 。而使用单点登录( Single Sign On ,简称 SSO),用户只需要登录一次就可以访问所有相互信任的应用系统。
单点登录示意
下面演示了单点登录的流程。视频中有应用A,应用B,以及统一登录服务 CAS(Central Authentication Service)。
用流程图来描述就是下面这样。需要注意的是,第一次应用A跳转CAS,需要用户输入用户名密码,而第二次应用B跳转CAS,就直接登录成功了。
具体流程
第一次访问应用1
- 用户访问应用1,应用1的服务器验证用户是否登录,未登录则重定向到 CAS 登录页面
- 用户没有 CAS 的权限,需要填写登录表单,将用户名和密码发送到 CAS 的后端
- CAS 的后端对用户信息进行验证,验证通过则返回 CAS 的 token(表示用户已登录 CAS 系统),同时返回一个ticket 。
- CAS 登录成功后,又重定向到应用1的页面,同时在 URL 中带上上一步的ticket 。应用1页面再次访问应用1的服务器,服务器获取这个ticket,并向 CAS 进行验证。
- 若 CAS 验证 ticket 有效,则向应用服务器发送这个用户的必要属性(如 userId),并表示验证成功。这时应用服务器可以明确当前访问用户可信,并生成临时登录凭证 token 返回给前端。
- 之后应用1的前端可直接通过这个 token 访问应用1服务器。
第一次访问应用2
第一次访问应用2与前面的流程差别不大,唯一的不同是在跳转 CAS 时,CAS 的前端已经存有 CAS 的登录凭证token ,这时就不需要用户再次输入用户名密码了。其中需要关注两点:
- 所有的登录过程都依赖于 CAS 服务,包含用户登录页面、ticket 生成及验证。
- 为了保证 ST(上文的 ticket) 的安全性,一般 ST 都是随机生成的,没有规律性。CAS 规定 ST 只能保留一定的时间,之后 CAS 服务会让它失效,而且,CAS 协议规定 ST 只能使用一次,无论 ST 验证是否成功,CAS 服务都会清除服务端缓存中的该 ST,从而规避同一个 ST 被使用两次或被窃取的风险。
代码实现
我们需要关注两个点,一是 CAS 相对于普通登录需要做哪些工作,二是接入应用相对于普通登录需要做哪些改变。
我们还是根据上文的流程来看一步步如何实现(后端使用 Node.js)。
应用跳转 CAS
第一次访问应用(未登录),需要跳转 CAS 进行登录。在示例中是后端进行重定向,但是由于我们是单页应用,静态文件和接口服务实际上是不同的地址,所以不能在访问 example.com 时后端直接重定向到 cas.com。 替代方案是,后端判断未登录返回跳转地址,在前端进行重定向。
CAS 登录
其他应用在跳转 CAS 时会携带一个回调url,用于在登录成功后返回原应用。在 CAS 的登录接口中,验证用户信息成功后不仅需要返回 token,还需要返回回调的 url,并带上一个 passport 参数 (用于之后的验证)。
JS
const redirectUrl = req.query.redirect_uri;
if (redirectUrl) {
response.result.redirectUrl = appendQueryToUrl(redirectUrl, {
passport: createPassport(user.userId), // 在回调地址中添加 passport 参数
});
}
CAS 前端如果接收到返回中存在 redirectUrl,则直接跳转。
JS
if (data.result.redirectUrl) {
window.location.replace(data.result.redirectUrl);
}
passport 验证
CAS 签发的 passport 是一个临时凭证,应用后端可以通过这个 passport 向 CAS 进行验证,如果验证成功,CAS 会返回这个用户的必要信息。
JS
const { passport } = req.query;
if (passport) {
try {
const data = await verifyPassport(passport); // 向 CAS 验证 passport 是否有效
if (data.status.code === 0) {
// passport 有效,用户认证成功
const { result } = data;
req.currentUser = {
userId: result.userId,
username: result.username,
};
// 设置浏览器端 cookie
const accessToken = sign(req.currentUser);
res.cookie('app_a_access_token', accessToken, {
expires: new Date(Date.now() + 8 * 3600000),
});
return next();
}
} catch (err) {
console.error(err);
}
}
CAS 中,passport 被设计为只能使用一次,并且具有时效。
JS
const passports = {};
// 创建 passport
const createPassport = (userId) => {
const expireTime = new Date().getTime() + 1000 * 60; // 设置一分钟后过期
const pId = uuid();
passports[pId] = {
userId,
expireTime,
};
return pId;
};
// 获取 passport 内容
const getPassportContent = (pid) => {
if (!pid || !passports[pid]) return;
if (passports[pid].expireTime < new Date().getTime()) {
// 凭证过期
delete passports[pid]; // 删除凭证
return;
}
const res = {
userId: passports[pid].userId,
};
delete passports[pid]; // 删除凭证
return res;
};