本项目代码已开源,具体见:
后端工程:express-blog-backend
数据库初始化脚本:关注公众号程序员白彬,回复关键字“博客数据库脚本”,即可获取。
前端走向全栈,从这个项目开始准没错!
前言
你或许作为前端的课代表,开发了很多高级的登录功能,比如扫码登录、手机号一键登录、第三方授权登录、生物认证登录等,对 OAuth、JWT、双 Token 等关键词也能脱口而出,但是你可能还从未以一个全栈的身份完整地开发过一个登录功能。没关系,本文的目的就是解决你的这份忧虑,从一个极简的全栈登录认证功能入手,打破这个枷锁!
前几篇文章提到过,在博客系统中,我们需要后台管理功能去维护我们的文章、留言评论等内容。既然是这样,后台管理的安全问题也得考虑,登录和权限设计是非常有必要的。
登录认证
常规的认证方式就是基于 Session 会话的认证,用户的身份信息是与 Web Session 绑定在一起的,这是至今依然流行的认证方案,经得起时间的考验!我们来梳理一下。
首先,HTTP 是无状态的,HTTP 协议本身并不保存客户端与服务器之间交互的状态信息。每当客户端向服务器发出请求时,服务器都会独立处理这个请求,不会考虑上下文关系。
而基于 HTTP 的 Web 应用,通常有着身份认证类需求,通过身份信息去识别用户,这其实是有状态的。
Cookie 的设计就是为了解决 HTTP 无状态的问题,通过头部字段 Cookie 和 Set-Cookie 来携带一些状态信息,这些状态信息与服务端的 Session 结合在一起,就可以让 HTTP 请求变成有状态的请求。
基于此,我们可以完成登录认证等功能。
以本项目为例,使用的数据库是 MySQL,user 表中设计了 token 这个字段用于用户会话身份的识别。
由于 Session 有唯一性,可以唯一标识一个客户端会话,Session 中有唯一的 Session ID,这个 Session ID 可以与数据库 user 表的 token 字段结合起来使用,如果二者是匹配的,就找到了对应的用户。
因此,我们只要在用户登录认证的时候,把这个 Session ID 写入到 token 字段中,后续鉴权时就可以依据 Session ID 查询用户表。
当然也不一定非得将 Session ID 作为 token,只要是能和 Session 联系起来的唯一值,理论上都可以作为 token。
权限
再来说权限,我们知道,为了实现一个产品功能,背后可能会设计很多个后端接口,前端会调用这些接口,进行逻辑和数据处理,最终呈现出产品界面效果。
但是,有的功能并不希望开放给普通用户使用,比如发布文章、审核评论等,这应该是博主才能使用的功能。
这就需要权限的控制,权限的控制会涉及到前端和后端。
比如发布文章这个功能,如果你仅仅是在前端拦截了用户进入发布文章界面,也并非是安全的,因为有意要攻击系统的人会尝试直接调接口攻击后端,甚至是入侵数据库。
既然可以直接攻击后端和数据库,那么前端的防护还有意义吗?有的,防不了小人,先防君子。除此之外,逻辑上理应如此,该拦截的还是得拦截,用户体验也会好一点。
举个例子,假设一个普通用户通过一个链接就进入了你的后台管理系统,虽然接口没调成功,但是是不是也很尬?用户会觉得你的前端开发很水。
具体怎么做呢?我们来看具体实现。
登录的实现
先看登录是如何实现的。由于博客系统暂时不对外开放注册,只需要一个管理员账号即可,直接插入到用户表中即可,不需要增加业务控制器来维护。
我们使用传统的账号密码登录方式,用户名对应 user 表中的 user_name 字段,密码对应 password 字段,是经过了 SHA256 HASH 得到,保证密码不容易被逆运算破解。
为了提高安全性,我们还需要引入一个图片验证码的功能。
图片验证码的基本原理是:
- 当用户请求验证码时,后端在指定的字符序列中生成一串随机字符,并依据其内容生成一张验证码图片,图片中包含生成的随机字符。
- 将随机字符串保存在 Session 中,并把验证码图片返回给前端展示。
- 前端只能拿到验证码图片,并不知道验证码的值,需要通过输入去校验,所以一定程度上是安全的。
- 用户观察验证码,手动输入内容,最后将账号、密码、验证码一起发送到后端进行认证。
- 后端将客户端发送过来的验证码和 Session 中的验证码进行对比,一致后才验证账密的有效性。
graph TD
A["用户请求验证码"] --> B["后端生成随机字符"]
B --> C["生成验证码图片"]
C --> D["保存随机字符串到Session"]
D --> E["返回验证码图片给前端"]
E --> F["前端展示验证码图片"]
F --> G["用户观察并输入验证码"]
G --> H["前端发送账号、密码、验证码"]
H --> I["后端验证验证码"]
I --> J["验证码一致?"]
J --是--> K["验证账密有效性"]
J --否--> L["验证码错误,重试"]
K --> M["认证成功/失败"]
L --> F
那么验证账号密码的流程是什么样的呢?
- 首先通过账号密码查询数据库 user 表中是否有对应的记录。
- 如果没有记录,则有两种可能,一种是用户不存在,另一种是账号或密码输错了。但是为了不给用户留下猜测的空间,错误提示语都用统一的“用户名或密码输入有误”。
- 如果有记录,说明账号密码正确,此时更新这条 user 记录,用 Session ID 更新 token 字段。
- 将脱敏的用户信息返回给前端,同时 Set-Cookie 更新 cookie 中的 token 信息。
res.cookie(
'token',
req.session.id,
{
expires: expireTime,
httpOnly: true,
sameSite: 'lax',
secure: true
}
);
graph TB
A["用户输入账号密码"]
A --> B{查询user表记录}
B --无记录--> C["返回:用户名或密码输入有误"]
B --有记录--> D["账号密码正确"]
D --> E["更新user记录token字段为Session ID"]
E --> F["返回脱敏用户信息"]
F --> G["Set-Cookie更新cookie中的token信息"]
G --> H["流程结束"]
权限设计的具体实现
我们知道,有的接口是需要鉴权的,只有管理员能访问,而有的接口是开放的,人人都能访问,这就需要权限的设计。
我们的博客系统中暂时还只有一个管理员账号,按这个设定,我们可以认为:如果 Cookie 中的 token 校验通过,就认为这个用户是管理员账号,可以访问需要鉴权的接口。
但是这并非一个优雅的设计,RBAC(Role-based access control)是一种更容易扩展的方式。我们还是预留了用户/角色/权限的关系。
通过用户的 role_id,我们能关联查询到这个用户的角色,基于角色,再去找到这个用户的权限。
由于目前,博客系统中用到的权限管理只涉及到接口权限,这里我就没有过度设计了,直接把接口权限和角色的关系写死在后端代码里。
PS: 这里用 Map 还是用什么数据结构不是很重要,能对应上角色和权限的关系更重要...
访问链路分析
知道角色和权限的关系后,我们再来完整看看不需要鉴权的接口和需要鉴权的接口走过的链路分别是什么样的?
我们来到后端代码的 base.js 控制器,这是验证权限的入口。一个接口是否要验证权限,暂时是维护在前面提到的 authMap 中,如果以 req.path(也就是接口请求路径)作为 key 能在 authMap 中找到值,说明这是一个需要验证权限的接口。
如果在 authMap 中找不到对应的值,说明这个接口是不需要鉴权的,base 控制器直接放行请求即可。
如果一个接口需要验证权限,会首先取出 Cookie 中的 token,查数据库 user 表。
如果没找到记录(也就是下图中的 results.length === 0 这个条件),可以考虑返回前端“授权已过期”或者“未授权”之类的信息。
如果 token 是有效的,还需要验证 user 信息中的 role_name 是否和该接口限制的角色一致。
如果不一致,说明该用户不具备这个接口的访问权限,返回前端“抱歉,您没有权限访问该内容”这样的信息即可。
如果一致,说明该用户有这个接口的访问权限,把执行权交给后续中间件即可。
博客后台管理类接口只有管理员能访问,这些是需要鉴权的。
首页的文章分页数据是所有访问者都能查看的,这就是一个典型的不需要鉴权的接口。
小结
博客系统中,对于游客而言,可以查看文章、留言等开放性的数据;对于管理员而言,还需要管理后台功能来维护文章、审核评论等,这就需要一个登录认证的能力。在 Web 项目中,常用的认证方案就是基于 Cookie + Session 的认证。有了登录认证后,其实还应该根据角色去区分用户的权限,虽然本系统中只有一个管理员存在,但是我们还是预留了 RBAC(Role-based access control) 的能力。
码字分享不易,多多点赞关注,项目给个 star,多谢啦,宝子!
- 开源地址:vue3-ts-blog-frontend
- 专栏导航:Vue3+TS+Node打造个人博客(总览篇)