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: 返回请求资源

上野宣. 图解 HTTP[M]. 人民邮电出版社, 2014.

image-20250307223404036

这里涉及到了 Cookie 技术,Cookie 就是服务器在响应报文中增加一个叫作 Set-Cookie 的首部字段信息。用户的客户端收到了响应报文后,会按照 Set-Cookie 的指令保存 Cookie 值。当下次客户端再次往服务器发送请求的时候,就会自动在请求报文中加入 Cookie 值。服务器端发现客户端发送过来的 Cookie 后,会去根据 Cookie 值查询服务器上的记录,就像根据字典的键查到字典的值一样,得到之前的状态信息。

HTTP 请求报文和响应报文的内容如下:

  1. 请求报文(没有 Cookie 信息的状态)

    1
    2
    3
    GET /reader/ HTTP/1.1
    Host: hackr.jp
    * 首部字段里没有Cookie的相关信息
  2. 响应报文(服务器端生成 Cookie 信息)

    1
    2
    3
    4
    5
    6
    HTTP/1.1 200 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。

  3. 请求报文(自动发送保存着的 Cookie 信息)

    1
    2
    3
    GET /image/ HTTP/1.1
    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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
from flask import Flask, request, jsonify, session
from flask_session import Session

app = Flask(__name__)

# 配置 Session 存储方式
app.config['SECRET_KEY'] = 'your_secret_key' # 用于加密 Session 数据
app.config['SESSION_TYPE'] = 'filesystem' # Session 存储在服务器文件系统
Session(app)

# 模拟数据库存储用户
users = {"admin": "password123"}

# 登录接口
@app.route('/login', methods=['POST'])
def login():
data = request.json
username = data.get("username")
password = data.get("password")

if users.get(username) == password:
session["user"] = username # 在 Session 中存储用户信息
return jsonify({"message": "Login successful"}), 200
return jsonify({"message": "Invalid credentials"}), 401

# 受保护接口(需要登录)
@app.route('/protected', methods=['GET'])
def protected():
if "user" not in session:
return jsonify({"message": "Unauthorized"}), 401
return jsonify({"message": f"Hello, {session['user']}! You are authenticated."})

# 退出登录(删除 Session)
@app.route('/logout', methods=['POST'])
def logout():
session.pop("user", None) # 删除用户 Session
return jsonify({"message": "Logged out"}), 200

if __name__ == '__main__':
app.run(debug=True)

在 Flask 中使用 session 功能,需要安装 flask-session 扩展。在这里,app.config 是 Flask 的全局配置对象,所有的 Flask 组件(比如 flask-sessionflasggerJTWManager等)都可以从 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 模拟数据库(已存储 bcrypt 加密的密码)
users = {
"admin": bcrypt.hashpw("password123".encode('utf-8'), bcrypt.gensalt()), # 预存一个管理员账号
}

# 用户登录(验证 bcrypt 密码)
@app.route('/login', methods=['POST'])
def login():
data = request.json
username = data.get("username")
password = data.get("password")

stored_hashed_password = users.get(username) # 取出存储的哈希密码
if not stored_hashed_password:
return jsonify({"message": "Invalid username"}), 401

# 使用 bcrypt 校验用户输入的密码是否匹配存储的哈希密码
if not bcrypt.checkpw(password.encode('utf-8'), stored_hashed_password):
return jsonify({"message": "Invalid password"}), 401

# 登录成功,存入 Session
session["user"] = username
return jsonify({"message": "Login successful"}), 200

这里还是简单地将明文密码放到了程序中。在实际应用中,系统可以预先定义一个默认密码,例如 "admin123""password",然后在首次登录后要求用户修改密码。或者可以在每次运行时,都生成一个新的初始密码,仅将密码输出到控制台中。也可以通过环境变量作为种子配置初始的超级用户密码。或者要求用户在首次启动时手动设置超级用户密码。其他的用户,在运行时通过注册接口注册后,保存哈希密码即可。

基于 Token 的认证

基本图像

在上一部分介绍了 Session 作为 Web 身份认证的传统方式。Session 通过在服务器端存储用户状态,并在客户端以 Cookie 方式存储会话 ID 来维持登录状态。随着前后端分离架构的流行,基于 Token 的身份认证方式逐渐成为主流。其中,JWT(JSON Web Token) 作为一种轻量级的无状态认证方案,被广泛应用于 RESTful API 认证。JWT 的流程其实和 session 很像:

  1. 用户使用用户名和密码登录。
  2. 服务器验证用户信息正确后,生成 JWT,并返回给客户端。
  3. 客户端在后续请求时,携带 JWT(通常放在 HTTP 头 Authorization: Bearer <JWT>)。
  4. 服务器收到请求后,验证 JWT,如果有效,则允许访问,否则拒绝。

JWT 和 session 的流程是很类似的,session 的方式是客户端把 session ID 放在 Cookie 里,然后服务器验证 Session ID。JWT 的方式就是客户端把 JWT 放在请求里,然后服务器验证 JWT。JWT 由 Header(头部)、Payload(负载)、Signature(签名) 三部分组成。服务器生成 JWT 时,它会将 HeaderPayloadSignature 连接起来,形成一个字符串 Token:

1
Header.Payload.Signature

其中 Header、Payload 经过 Base64Url 编码,Signature 由服务器的私钥签名,用于验证 Token 的合法性,和在 session 中的 SECRET_KEY 的作用是一致的。经编码后,JWT 的 Token 就可能长成这样:

1
2
3
4
5
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ
.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

客户端后续请求时把这个 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
from flask import Flask, jsonify, request
from flask_jwt_extended import JWTManager, create_access_token, get_jwt_identity, verify_jwt_in_request

app = Flask(__name__)

# 设置 JWT 密钥
app.config['JWT_SECRET_KEY'] = 'super-secret-key' # 使用更安全的密钥
jwt = JWTManager(app)

# 模拟用户数据库
users = {
"admin": {"password": "123456", "username": "admin"},
}

# 登录接口
@app.route('/login', methods=['POST'])
def login():
data = request.get_json()
username = data.get("username")
password = data.get("password")

user = users.get(username)
if user and user["password"] == password:
# 生成 JWT Token
access_token = create_access_token(identity=username)
return jsonify(access_token=access_token)

return jsonify({"msg": "用户名或密码错误"}), 401

# 受保护的 API
@app.route('/protected', methods=['GET'])
def protected():
current_user = get_jwt_identity() # 由于 before_request 里已验证 JWT,这里可以直接获取
return jsonify(logged_in_as=current_user), 200

# 统一检查请求是否有 JWT(除了 /login)
@app.before_request
def check_jwt():
if request.path != "/login": # 只允许 /login 不需要 JWT
try:
verify_jwt_in_request() # 直接验证 JWT
except Exception as e:
return jsonify({"msg": f"Token 错误: {str(e)}"}), 401

if __name__ == '__main__':
app.run(debug=True)

在 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 的有效性。