Web身份验证
Web身份验证的两种方式:Session vs Token
我们开发了一个 Web 应用,比如开发了几个 Flask API,我们不想让所有人都能调用这些接口。在这种情况下,我们一般会让用户填写用户名+密码,身份认证成功之后,才允许用户访问 API。但是不能用户每次调用接口都需要输入用户名和密码,一方面这么做太麻烦了,另一方面每一次请求都明文附带用户名和密码也会造成安全隐患。为此,我们需要采用某种身份认证机制,允许用户在输入一次用户名+密码后保持身份,并且使用安全的方式在多次请求之中保留这种身份验证信息。
但这种在 Web 中的状态保持并不是自然的,我们在 Web 中发送或者接收消息,采用的都是 HTTP(HyperText Transfer Protocol,超文本传输协议)。HTTP 定义了 Web 服务器和客户端之间通信的规则,可以把它看成是一种信件格式约定。HTTP 协议规定了我们发送的 HTTP 请求以及服务器返回的 HTTP 响应的格式,比如请求由请求行(Request Line)、请求头(Headers)、请求体(Body)三部分组成,响应由状态行(Status Line)、响应头(Headers)、响应体(Body)三部分组成。HTTP 协议是一种无状态(Stateless)协议,每个请求-响应都会被视为独立的一次会话。无状态也就意味着服务器不会记住之前的请求信息,假如我们想要在多次请求中保留用户的身份验证信息,就必须设计某种方案来帮助服务器识别用户。在无状态的协议中想保持状态,肯定需要在请求报文中附加额外的参数来去标志某个记忆,否则信息就不守恒了。
根据附加参数相关的机制不同,目前有两种主要的身份认证的保持方式:基于 Session(会话)的认证和基于 Token(令牌) 的认证,这篇博客主要介绍一下这两种方法的基本图像。
基于 Session 的认证
基本图像
Session 是服务器端用于跟踪和存储用户状态的一种机制。在服务器启用了 session 机制时,用户登录后服务器会创建一个 session 对象并且返回给用户这个 session 的 ID,session 对象储存了用户的信息。用户需要在后续的 HTTP 请求中携带 session ID,服务器根据 session ID 查找对应的 session 来识别用户。服务器可以在 session 中存储用户ID、角色、或者之前交互的信息(比如购物车)等等数据。下图展示了 session 的典型工作流程:
1. 用户登录:
- 用户提交 用户名和密码。
- 服务器验证凭据,并在服务器端创建 Session,存储用户身份信息(如
user_id
)。 - 服务器生成一个唯一的 Session ID 并存入 Cookie,发送给客户端。
2. 用户访问受保护资源:
- 客户端在请求时自动携带 Cookie(Session ID)。
- 服务器收到请求后,通过 Session ID 查询会话信息,确认用户身份。
3. 用户登出或 Session 过期:
- 服务器可以手动删除 Session,或设定 Session 过期时间(如 30 分钟)。
- 过期后,用户需要重新登录。
sequenceDiagram participant Client participant Server participant Database Client->>Server: 发送用户名 & 密码 Server->>Database: 验证凭证 Database-->>Server: 返回用户信息(如果有效) Server-->>Client: 创建 Session,返回 Set-Cookie(Session ID) Client->>Server: 携带 Cookie(Session ID)访问资源 Server->>Database: 校验 Session 是否有效 Database-->>Server: 返回 Session 状态 Server-->>Client: 返回请求资源
Cookie
上野宣. 图解 HTTP[M]. 人民邮电出版社, 2014.
这里涉及到了 Cookie 技术,Cookie 就是服务器在响应报文中增加一个叫作 Set-Cookie
的首部字段信息。用户的客户端收到了响应报文后,会按照 Set-Cookie
的指令保存 Cookie 值。当下次客户端再次往服务器发送请求的时候,就会自动在请求报文中加入 Cookie 值。服务器端发现客户端发送过来的 Cookie 后,会去根据 Cookie 值查询服务器上的记录,就像根据字典的键查到字典的值一样,得到之前的状态信息。

HTTP 请求报文和响应报文的内容如下:
请求报文(没有 Cookie 信息的状态)
1
2
3GET /reader/
Host: hackr.jp
* 首部字段里没有Cookie的相关信息响应报文(服务器端生成 Cookie 信息)
1
2
3
4
5
6200 OK
Date: Thu, 12 Jul 2012 07:12:20 GMT
Server: Apache
<Set-Cookie: sid=1342077140226724; path=/; expires=Wed,
10-Oct-12 07:12:20 GMT>
Content-Type: text/plain; charset=UTF-8这里的 sid 就是 session ID。
请求报文(自动发送保存着的 Cookie 信息)
1
2
3GET /image/
Host: hackr.jp
Cookie: sid=1342077140226724
浏览器会自动识别 Set-Cookie
指令,自动地存储和发送 Cookie。而如果我们是通过 curl
或者 requests
库等进行 API 调用,我们需要手动添加 Cookie。比如在 curl 里,我们可以用 -v
参数显示响应的详细内容,之后再通过 -b
参数为请求添加 Cookie:
1 | curl -X GET http://127.0.0.1:5000/protected -b "session=abcd1234xyz" |
Flask 实现
1 | from flask import Flask, request, jsonify, session |
在 Flask 中使用 session 功能,需要安装 flask-session
扩展。在这里,app.config
是 Flask 的全局配置对象,所有的 Flask 组件(比如 flask-session
、flasgger
、JTWManager
等)都可以从 app.config
中读取相关的配置。在 Flask 中,所有的 session 数据都会被加密,SECRET_KEY
是 session 的加密密钥,类似于随机数的种子,根据 SECRET_KEY
的值的不同加密出来的 session 数据也不同。
SESSION_TYPE
是指 session 数据的存储方式。在默认情况下,flask-session 不会在服务器端存储用户的 session 数据,而是直接把用户的 session 数据用 SECRET_KEY
进行加密和签名,将 session 数据直接储存到 Cookie 值中,返回给客户端。客户端之后的每次请求都附带这个 Cookie 值,服务器端再用 SECRET_KEY
将 Cookie 值进行解码和验证,读取 session 数据。这样做有一些问题,第一就是 Cookie 中直接包含了所有的 session 数据,一旦服务器端的 SECRET_KEY
被盗取,那么 session 数据就会泄露。其次 Cookie 的大小通常限制在 4KB,无法存储太大量的数据。我们可以把 session 数据直接存储到服务器端,服务器把 session 数据存储在数据库或者文件中,并生成一个唯一的 session_id
。这样服务器只需要把 session_id
存储在 Cookie 里,Cookie 本身可以不包含任何用户数据。客户端之后的请求只需要附带 session_id
,服务器拿到 session_id
就可以在数据库或者文件中查找用户数据了。这样的方式更安全,即使 session_id
泄露,攻击者也无法获取具体的 session 数据,此外也可以存储更加大量的数据。在 flask-session 中,SESSION_TYPE
可以选择 null
(Cookie)、filesystem
(文件系统)、redis
(Redis存储)、memcached
(Memcached存储)、mongodb
(MongoDB存储)、sqlalchemy
(数据库存储)几种。当选取 filesystem
时,如果不配置 SESSION_FILE_DIR
,Flask 会默认存储到主程序文件同目录的 flask_session
目录下面。
在这个简单的例子里,当访问 /login
接口登录验证成功后,服务器会创建 session["user"]
,在服务器端存储 session 数据,这里存储的数据就是 user
字段的值。服务器会返回 Set-Cookie
,存储 session ID 到客户端。后续的请求只有附带 Cookie 后才能访问 /protected
接口。 这里需要注意的是只要 Flask 发现 session 数据又变化,就会返回 Set-Cookie
响应头,更新 session ID。不论是第一次写入 session["user"] = "admin"
还是存入新的键值对时 session["user_id"] = 42
或者修改数据时 session.pop("user", None)
。另外,在不同用户访问 API 时,他们的 session
是独立的,每个用户的 session
存储的数据是不同的。每个用户都有自己的 Session ID
,服务器会根据 session ID 识别不同的用户,并存储与之对应的数据。
这里的例子只是为了介绍 session 的使用,直接存储了明文的密码。在实际应用中服务器不应该直接存储明文密码,否则一旦数据库被破解那么所有的用户密码就都泄露了。一般情况下,服务器会存储哈希(Hash)后的用户密码。当用户登陆时,服务器会哈希(Hash)用户输入的明文密码,然后与存储的哈希密码进行比对。如果匹配,则认证成功。哈希是一种将任意长度的输入数据转换为固定长度输出(哈希值)的算法。哈希函数是不可逆的,无法从哈希值还原原始数据。在 Python 中,我们可以使用 bcrypt
库进行密码的存储和验证。此时
1 | # 模拟数据库(已存储 bcrypt 加密的密码) |
这里还是简单地将明文密码放到了程序中。在实际应用中,系统可以预先定义一个默认密码,例如 "admin123"
或 "password"
,然后在首次登录后要求用户修改密码。或者可以在每次运行时,都生成一个新的初始密码,仅将密码输出到控制台中。也可以通过环境变量作为种子配置初始的超级用户密码。或者要求用户在首次启动时手动设置超级用户密码。其他的用户,在运行时通过注册接口注册后,保存哈希密码即可。
基于 Token 的认证
基本图像
在上一部分介绍了 Session 作为 Web 身份认证的传统方式。Session 通过在服务器端存储用户状态,并在客户端以 Cookie 方式存储会话 ID 来维持登录状态。随着前后端分离架构的流行,基于 Token 的身份认证方式逐渐成为主流。其中,JWT(JSON Web Token) 作为一种轻量级的无状态认证方案,被广泛应用于 RESTful API 认证。JWT 的流程其实和 session 很像:
- 用户使用用户名和密码登录。
- 服务器验证用户信息正确后,生成 JWT,并返回给客户端。
- 客户端在后续请求时,携带 JWT(通常放在 HTTP 头
Authorization: Bearer <JWT>
)。 - 服务器收到请求后,验证 JWT,如果有效,则允许访问,否则拒绝。
JWT 和 session 的流程是很类似的,session 的方式是客户端把 session ID 放在 Cookie 里,然后服务器验证 Session ID。JWT 的方式就是客户端把 JWT 放在请求里,然后服务器验证 JWT。JWT 由 Header(头部)、Payload(负载)、Signature(签名) 三部分组成。服务器生成 JWT 时,它会将 Header、Payload 和 Signature 连接起来,形成一个字符串 Token:
1 | Header.Payload.Signature |
其中 Header、Payload 经过 Base64Url 编码,Signature 由服务器的私钥签名,用于验证 Token 的合法性,和在 session 中的 SECRET_KEY
的作用是一致的。经编码后,JWT 的 Token 就可能长成这样:
1 | eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 |
客户端后续请求时把这个 Token 附带在 HTTP 头中,就可以完成验证了。
sequenceDiagram participant Client participant Server participant Database Client->>Server: 发送用户名 & 密码 Server->>Database: 验证用户凭证 Database-->>Server: 返回用户信息(如果有效) Server-->>Client: 生成 JWT 并返回(客户端存储) Client->>Server: 携带 JWT 访问资源(Authorization 头) Server->>Server: 校验 JWT 签名 & 解码用户信息 Server-->>Client: 返回请求资源
JWT 和 session 的核心区别是服务器是否储存会话数据。在 session 的方案中,服务器在验证用户信息之后,会在服务器端创建一个 session,并生成一个唯一的 session ID。后续依据请求的 session ID 在 session 中查找信息,完成验证。而 JWT 则是直接生成一个签名加密后的 Token 返回给客户端,服务器不存储 JWT。后续服务器直接解析客户端请求中的 Token,无需查询数据库即可完成身份验证。在 JWT 中每次请求都是独立的,适合分布式架构。JWT 实际上类似于 flask-session 中的 SESSION_TYPE = 'null'
模式,将所有 session 数据都存储在客户端 Cookie 里,而不是服务器端。但是相比于 SESSION_TYPE = 'null'
,JWT 的存储不局限于在 Cookie 中,也因此克服了 Cookie 在跨域认证等方面的限制,更加适合于前后端分离的应用场景。
Flask 实现
1 | from flask import Flask, jsonify, request |
在 Flask 中使用 JWT,需要安装 flask_jwt_extended
扩展。和 flask-session 一样,首先在 app.config
里,需要设置 JWT_SECRET_KEY
密钥,用于对 JWT 进行签名和验证。在 /login
端点中,用户提交用户名和密码,服务器在验证用户及密码后,调用 create_access_token()
生成 JWT,返回给客户端。这里的 identity
是指定生成的 Token 的用户信息,在其他端点调用 get_jwt_identity()
就可以提取出用户请求中 Token 里的 identity
字段,它会自动解析 HTTP 请求并解析 JWT。Flask 的 jsonify()
会自动地将关键字参数转换为 JSON,同时设置 JSON 格式的 HTTP 头部。如果不传递关键字参数而是直接传递字典,那么 jsonify()
就会直接把字典转换成 JSON。@app.before_request
装饰器下面的函数在每个请求处理前都会执行。在这里,check_jwt
函数可以进行统一的 JWT 检查,除了 /login
,所有的路由都必须验证 JWT。flask_jwt_extended 提供了 verify_jwt_in_request()
函数,它可以直接检查 JWT 的有效性。