Web 系统登录相关知识梳理
直以来,登录都是 web 系统很基础却很重要的功能,但是其原理,却很少进行梳理。在做入职测试的时候,再一次遇到了登录的问题,碍于时间紧张,只是简单的进行了实现,而过程中遇到的一些疑惑,自己也没有进行解答。有空之余便查阅了一些相关文档,结合自己的理解,整理出本文,便于自己翻阅参考, 同时如果之后工作中有更多的领悟,也会更新到本文中。
1. 简单实用的登录
- 前端输入账号密码,发送请求到服务端
- 服务端端查询数据库,账号密码匹配则登录成功,服务器端生成并设置 Session
Session[SessionID] = 用户信息
,同时将 SessionID 通过set-cookie: SessionID
头设置到客户端的 cookie 中,这样后续客户端发起的请求,都会带上这个cookie。Session 对象存在于服务端内存中,当服务重启时,Session 失效。 - 当后续请求携带 Cookie 时,服务端验证该 SessionID 是否有效(是否存在且没有过期),如果有效则从 Session 中读取先前保存的 用户信息,以该身份进行之后的请求处理。
流程图如下:
缺点:前面也提到了,Session 对象存在服务器内存中,如果服务器出现异常导致重启,那么用户就可能突然登出。解决的办法是:引入新的存储系统如 redis 等来存储会话信息
问题:那如果 redis 也出错了怎么办,是不是 redis 要进行灾备,保证两个以上 redis 存在,通过增加数量保证一定有一个服务是好的
注意点:服务端 Session 和 客户端 Cookie 都可以设置过期时间
2. 传统 Web 应用中身份验证的最佳实践
由于第一种方案需要在服务端保存 Session,那么就会遇到需要 Session 共享的问题,比如负载均衡中,有 A,B,C,D 四个服务器,用户第一次在 A 服务器登录,Session 存在于 A 服务器中,下次如果用户访问到 B 服务器,那么就会被认为是未登录的。
解决办法是: 将登录信息之后的 Session 放在第三方的存储服务中,A.B.C.D 服务器都去访问第三方存储服务校验是否登录。
最佳实践: 参考 JSON Web Token
其实我们可以不把登录信息放在 Session 中,而是直接加密返回给客户端,(通过使用自包含的,含有加密内容的 Cookie 作为替代凭据)。
- 客户端使用账号密码登录,服务端验证账号密码匹配,则使用加密算法,加密用户信息得到一个字符串,并
set-cookie=该加密字符串
- 客户端得到该 cookie 之后,以后每次请求带上 cookie,服务端使用解密算法解析出用户的身份信息,进行下一步的请求处理
- 由于校验的过程是通过加解密算法的配合,只要算法一致,不同的服务器也能得到同样的结果,因此不存在考虑如何共享登录态的问题。
3. 单点登录
用户在一个站点登录之后,不需要在其它兄弟站点中再次登录。比如,假设 在支付宝登录之后,再进入淘宝或者阿里云,不需要再次登录。
- 客户端请求应用系统 1,没有登录,于是让客户端会访问独立的鉴权站点: IDB 身份验证系统
- 客户端在 IDB 身份验证系统登录,且 IDB 身份验证系统返回 ticket 给客户端
- 之后,客户端访问应用系统 3 的时候,会携带 ticket,而应用系统 3 则拿着 ticket 去 IDB 身份验证系统进行校验。由于客户端已经在 IDB 系统中登录,因此该请求会被允许。从而进行接下来的请求处理流程。
由于没有实践过,单点登录的知识简单整理至此,有必要可以查找相关资料深入学习。回忆下之前上家公司内网系统的 SSO 运用,第一次登录 A 系统会跳转到一个独立的页面进行登录,之后再进入 B 系统,不需要再次输入账号密码,原理估计就是如此吧。
4. 登录实践
在真实项目中,除了登录成功,前端获得 cookie ,再后续请求携带 cookie 之外。还会涉及到路由跳转拦截(比如想直接到一个需要登录的页面应该被拒绝),或者已经登录之后想主动回到登录页也应该是被拒绝,除非主动退出登录删除 cookie。
本章用 Vue-Router + Vuex 举例:
首先,对于我们开发者来说,我们是知道哪些页面需要登录态才能访问,而哪些页面不需要登录也能访问。因此我们可以在路由中添加字段以作区分:Vue-router 官方文档示例
比如我们定义如下几个路由:
[
{
//登录页面
path: '/login',
component: login,
meta: {
requiresAuth: false
}
},
{
// 下载页面
path: '/download',
component: download,
meta: {
requiresAuth: false
}
},
{
// faq 页面
path: '/faq',
component: faq,
meta: {
requiresAuth: false
}
},
{
// 列表页
path: '/list',
component: list,
meta: {
requiresAuth: true
}
},
{
// 详情页
path: '/detail',
component: detail,
meta: {
requiresAuth: true
}
},
],
如上,有登录页,下载页和 FAQ 页面不需要登录态即可访问,而详情页和列表页则需要登录态。
4.1 客户端如何保存登录态信息
客户端通过账号密码登录成功之后,服务端有可能直接set-cookie=token
设置 cookie 到了前端。也可能是将 token 信息作为返回体进行返回。
无论哪一种方式,针对 vuex 这种方案,我们可以将 token 信息存入到 vuex 的 state 中,通过内存访问 state 比每次通过 API 访问 cookie 或者 locaStorage 应该是更为高效吧(我猜的)。
为了实现关闭浏览器或者刷新浏览器之后,网站不需要再次登录,我们可以使用 cookie 或者 localStorage 来保存 token 信息,因为 vuex 是 基于内存的,刷新或者关闭浏览器都会失效。
伪代码
fetch('/login')
.then(res => {
// 如果账号密码正确
// 设置 vuex 的 token
vuex.token = token
// 设置 cookie 或 localStorage
storage.token = token
})
这样我们就保存了登录态信息。
4.2 保存登录态信息之后还要做什么
在客户端保存登录态信息之后,在每一次进行页面跳转(或者请求接口之前),都可以进行登录态的校验(只是单纯校验该 token 是否有效,而无法真正校验 token 是否有效,真正的校验还是需要后端)。
伪代码
// 检验客户端是否存在登录态信息
function checkLogged() {
const token = vuex.token;
if (!token) {
// 如果 vuex 中没有,则从 storage 中获取
// 这个场景是在,第一次打开页面,或者刷新页面时,这时候 vuex 还没有 token 信息
if (!getLocalToken()) {
// vuex 没有, storage 没有,那么就是没有登录
return false;
}
// 如果 vuex 没有, 但是 storage 里有
// 那用户是登录的状态,同时为了以后可以直接校验 vuex 里的 token
// 我们需要把 storage 中的信息设置回 vuex 里
vuex.token = token
return true;
}
return true;
}
// 导航守卫, 在每一次要跳转页面时,都会进入这段校验
router.beforeEach((to, from, next) => {
// 针对 from(从哪来),to(到哪去), checkLogged(当前是否登录) 等信息来得到结果
// 决定是否进行跳转,或者应该跳转到什么页面
// 要去的页面是否需要登录
const isToPageNeedLogin = to.matched.some(record => record.meta.requiresAuth)
// 当前是否有登录态
const isLogged = checkLogged()
});
如上所说,我们可以拿到 from, to, checkLogged 等信息,来进行判断,那我们来进行穷举吧。
4.3 页面之间跳转的 18 种情况
先规定几种标识:
from:
f1 => 当前页面需要登录(如 /list, /detail)
f2 => 当前页面不需要登录,但也不是登录页(如 /download, /faq): 之后称为普通页
f3 => 当前页面不需要登录,且是登录页
to:
t1 => 想去的页面需要登录(如 /list, /detail)
t2 => 想去的页面不需要登录,但也不是登录页(如 /download, /faq):之后称为普通页
t3 => 想去的页面不需要登录,且是登录页
checkLogged():
token0 => 用户已登录
token1 => 用户未登录
情况 | checkLogged | from | to | 描述 | 结果 |
---|---|---|---|---|---|
1 | toekn0 | f1 | t1 | 已登录用户,从需要登录页面,跳转到需要登录的页面 | next() 不做处理,放行该跳转 |
2 | token0 | f1 | t2 | 已登录用户,从需要登录的页面,跳转到普通页面 | next() 即不做处理,放行该跳转 |
3 | token0 | f1 | t3 | 已登录用户,从需要登录的页面,跳转到登录页 | next(特定页面) 不允许跳转,提醒“跳转失败,需要先退出登录” |
4 | token0 | f2 | t1 | 已登录用户,从普通页面,跳转到需要登录的页面 | next() 即不作处理,放行该跳转 |
5 | token0 | f2 | t2 | 已登录用户,从普通页面,跳转普通页面 | next() 即不作处理,放行该跳转 |
6 | token0 | f2 | t3 | 已登录用户,从普通页面,跳转登录页面 | next(特定页面) 不允许跳转,提醒“跳转失败,需要先退出登录” |
7 | token0 | f3 | t1 | 已登录用户,从登录页面,跳转到需要登录的页面 | 不考虑这种情况 一个已登录用户,不可能在登录页 |
8 | token0 | f3 | t2 | 已登录用户,从登录页面,跳转到普通页面 | 不考虑这种情况 一个已登录用户,不可能在登录页 |
9 | token0 | f3 | t3 | 已登录用户,从登录页面,跳转登录页面 | 不考虑这种情况 一个已登录用户,不可能在登录页 |
10 | toekn1 | f1 | t1 | 未登录用户,从需要登录页面,跳转到需要登录的页面 | next(登录页) 一开始用户登录了,出现在已登录页面 后来由于登陆态过期,变成未登录用户 此时跳转到需要登录的页面,则需要先跳转到登录页 |
11 | token1 | f1 | t2 | 未登录用户,从需要登录的页面,跳转到普通页面 | next() 即不做处理,放行该跳转,因为普通页面不需要登录态 |
12 | token1 | f1 | t3 | 未登录用户,从需要登录的页面,跳转到登录页 | next() 放行该跳转 |
13 | token1 | f2 | t1 | 未登录用户,从普通页面,跳转到需要登录的页面 | next(登录页) 需要先跳转到登录页面 |
14 | token1 | f2 | t2 | 未登录用户,从普通页面,跳转普通页面 | next() 直接放行 |
15 | token1 | f2 | t3 | 未登录用户,从普通页面,跳转登录页面 | next() 放行” |
16 | token1 | f3 | t1 | 未登录用户,从登录页面,跳转到需要登录的页面 | next(login) 需要先跳转到登录页面 |
17 | token1 | f3 | t2 | 未登录用户,从登录页面,跳转到普通页面 | next() 放行 |
18 | token1 | f3 | t3 | 未登录用户,从登录页面,跳转登录页面 | next() 放行 |
由以上信息可知,虽然一共有18种情况,但是实际可以总结为四种情况 (因为有些信息,比如用户当前页面的信息,其实是没有用到的):
- 用户已登录
- 用户想跳转去登录页 => 提醒跳转失败,需要先退出登录才能到登录页面,并跳转到指定页面或不进行跳转。
- 否则, 直接放行
- 用户未登录
- 用户想跳转到需要登录的页面 => 跳转到提醒用户先要进行登录,并跳转到 登录页
- 否则,直接放行
导航守卫
router.beforeEach((to, from, next) => {
// 用户是否已登录
const isLogged = checkLogged();
// 要去的页面是 需要登录页
const isToPageNeedLogin = to.matched.some(record => record.meta.requiresAuth)
// 要去的页面是 登录页
const isToLoginPage = to.path === '/login';
if(isLogged) {
if(isToLoginPage) {
// 提醒用户跳转失败,要想回到登录页需要先退出登录
// 同时可以跳转到 === 指定页面=== ,如首页
// 如果只是保持页面不跳转,会有点奇怪,用户没有操作反馈)
next(指定页面)
} else {
next();
}
} else {
if(isToPageNeedLogin) {
// 提醒用户需要先进行登录
next('login')
} else {
next();
}
}
});
5. 某公司内部某系统的登录流程梳理
权限系统:用户,角色,权限(API接口)三者之间互相多对多的关系。
5.1 登录
- 前端输入账号密码进行登录
- node 端得到账号密码之后,用账号密码请求
sso
单点登录接口,返回sso
相关信息(包含用户唯一标识)之后,md5
加密该信息得到token
- 通过用户唯一标识,查询数据库,得到该用户的详细信息,以及该用户的权限列表。
redis
缓存该用户token
,值为用户详细信息及权限列表。设置一定的有效期。并返回到前端,(如果有需要也可以将不敏感的用户信息进行返回,一般系统都有个人信息的展示)。- 前端将
token
保存到localStorage
中,封装ajax
方法,每次请求都会带上token
- 之后每次请求,后端将前端带入的
token
拿来校验,判断是否有效。有效则取出token
中的用户信息和权限列表。再判断用户是否拥有当前请求的权限,有则继续下一步,没有则返回。
5.2 用户退出登录
前端发送退出登录请求,后端执行 redis.remove(token)
清除 token
信息,同时前端清除 localStorage
中的 token
,跳转到登录页。
这样,请求后端接口时,由于没有 token
,后端不会返回数据。
而前端路由方面,可以通过全局的导航守卫设置,localStorage
没有 token
信息,就会跳转到登录页。
5.3 系统管理员强制他人退出登录
系统管理员可以查看到所有用户,可以清楚别人登录的 token
。原理是,用户在登录的时候,token
( md5
加密的用户信息和时间信息) 除了设置进 redis
, 还会更新到数据库里(如 user
表的 access_token
字段)。
那系统管理员可以知道所有用户的 id
,通过 id
查询到用户的 access_token
值,再执行 redis.remove( access_token )
进行用户 token
的删除。用户那边下一次请求时,后端校验 token
失败,则前端进行 localStorage token
的删除以及跳转到登录页。
6. 登录与安全
刚刚听了部门安全的分享,发现在记录这部分知识的时候忽略了安全,如果完全依赖于 cookie 判断登录, 那么就需要防范 CSRF 的攻击。依赖 localStorage, 也可能被 xss 攻击,暴露 localStorage。这样,登录问题又变得复杂了起来。
参考资料
- ThoughtWorks: 登录工程:传统 Web 应用中的身份验证技术
- 阮一峰: JSON Web Token 入门教程
- Vue登录注册,并保持登录状态
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
上一篇: 浏览器缓存知识梳理
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论