FIDO协议:为网站添加用户管理功能,支持FIDO2认证

FIDO协议基础

FIDO(Fast IDentity Online,快速在线身份认证)协议是一种开放标准,旨在实现更加安全、便捷的无密码身份验证机制,减少传统口令认证带来的风险,如密码泄露、重放攻击及钓鱼攻击等问题。该标准由FIDO联盟(FIDO Alliance) 推动,目前主要包括三种规范:FIDO UAF、FIDO U2F与FIDO2。FIDO UAF(Universal Authentication Framework)

(1)FIDO UAF(通用认证框架)

FIDO UAF支持用户通过设备上的本地生物识别技术(如指纹、人脸识别等)进行身份验证,而无需输入传统密码。在注册阶段,用户设备会创建一对密钥,并将私钥安全地保存于本地,仅将公钥提交到认证服务器。当用户再次登录时,设备利用私钥生成数字签名,服务器通过验证该签名来确认用户身份,从而实现无密码认证。

(2)FIDO U2F(通用第二因素)

FIDO U2F主要用于双因素认证(2FA)场景,通常与用户名及密码共同使用。在注册过程中,用户的安全密钥生成一对密钥,公钥存储于服务器端。当用户登录时,在输入密码后,系统会提示插入硬件密钥,密钥通过私钥对服务器提供的挑战值进行签名,从而完成身份验证。这种方式能够显著提升账号安全性。

(3)FIDO2(FIDO2 = WebAuthn+CTAP2)

FIDO2是该系列标准的最新版本,由WebAuthn与CTAP2两部分组成,可同时支持无密码登录与双因素认证。

WebAuthn是由W3C与FIDO联盟联合制定的基于公钥加密的Web认证标准,使网站能够调用系统或外部认证设备(如指纹识别器、USB安全密钥)作为登录凭证。

CTAP2则定义了浏览器/操作系统与认证器(例如指纹模块、YubiKey等硬件密钥)之间的通信方式。借助FIDO2,用户可在手机、电脑等多平台上使用安全设备进行登录,从而摆脱对传统密码的依赖。

FIDO2工作流程

FIDO2的整个身份认证流程分为注册阶段和登录阶段两部分。以下是其详细机制说明。

(1)注册阶段:当用户首次访问网站时,需要完成FIDO2凭证注册,过程如下。

a.用户发起注册请求:用户在网站注册界面(/register)输入用户名和密码等基础信息,并请求启用FIDO2认证。前端通过表单提交用户名和密码到后端。

b.服务器生成挑战值:网站服务器接收注册请求后,首先验证用户名是否已存在。若不存在,使用generate_password_hash()对密码进行加密存储。然后在/fido_register路由中,调用server.register_begin(user_entity)创建一个随机的挑战(challenge),确保认证过程的唯一性与安全性。服务器将挑战值和用户信息(用户ID、用户名、显示名称)、RP信息(应用名称和ID)、支持的公钥算法等打包成注册选项,并将其存储到会话中。

c.调用WebAuthn API:前端页面接收服务器返回的注册选项后,将Base64编码的challenge和user.id转换为ArrayBuffer格式,然后调用navigator.credentials.create()方法,并将服务器生成的挑战传递给浏览器。

d.生成密钥对:用户的认证设备(如指纹传感器、手机、硬件密钥等)接收该请求,并提示用户进行身份验证(如指纹或PIN码)。验证通过后,设备会生成独立的公私钥对。私钥:保存在本地设备,绝不会上传。公钥:将返回服务器用于后续验证。设备生成的凭证包含rawId(凭证ID)、response.clientDataJSON(客户端数据)和response.attestationObject(证明对象)。

e.服务器存储凭证:设备把公钥及相关元信息(如凭证ID、算法类型等)传回服务器。前端将凭证数据(Base64编码)发送到/fido_complete_register路由,服务器调用server.register_complete()验证凭证数据的有效性。验证成功后,服务器提取凭证ID和公钥,使用CBOR编码公钥,并将其与用户账户关联保存到users[username][“credentials”]中。

(2)登录阶段:当用户下次访问该网站时,可以直接通过FIDO2设备登录,过程如下。

a.发起登录请求:用户在/login页面输入用户名和密码,并请求使用FIDO2登录。前端通过表单提交用户名和密码到后端。

b.生成挑战信息:服务器接收登录请求后,首先使用check_password_hash()验证用户名和密码是否正确。验证成功后,将用户名存储到会话中。然后在 /fido_auth路由中,服务器为该会话生成新的挑战,从用户账户中读取已保存的凭证ID,调用server.authenticate_begin([credential_descriptor])生成认证选项,并将与用户关联的公钥元数据(凭证ID、challenge、rpId等)发送到客户端。

c.调用WebAuthn API:前端调用navigator.credentials.get()方法,将Base64编码的challenge和allowCredentials中的凭证ID转换为ArrayBuffer格式,将挑战值传递给认证设备。

d.签名生成:认证设备提示用户再次进行身份验证(如指纹识别),验证通过后,使用私钥对挑战进行签名,并返回签名数据。设备返回的断言包含rawId(凭证ID)、response.clientDataJSON(客户端数据)、response.authenticatorData(认证器数据)和response.signature(签名)。

e.验证签名:前端将签名数据(Base64编码)发送到/fido_login路由。服务器接收签名结果后,从会话中获取之前存储的认证状态,从用户账户中读取已保存的公钥,调用server.authenticate_complete()通过已保存的公钥进行验证,确认签名真实有效。若验证成功,即表明用户确实持有对应的私钥,登录被批准。服务器在会话中设置authenticated标志为True。

这一流程实现了真正的“无密码安全登录”,同时兼顾了跨平台兼容性和强身份保障,使FIDO2成为现代网络认证体系的核心标准之一。

项目实现

项目设计结构图如下:

图1

1.首先配置环境,确保浏览器和设备支持FIDO2(WebAuthn)认证功能

pip install -r requirements.txt

图2

图3

图4

2.编写程序

(1)首先在app.py中导入实现FIDO2认证所需的所有模块

1
2
3
4
5
6
7
8
from flask import Flask, render_template, request, jsonify, session, redirect, url_for
from werkzeug.security import generate_password_hash, check_password_hash
from fido2.server import Fido2Server
from fido2.webauthn import PublicKeyCredentialRpEntity, PublicKeyCredentialUserEntity, PublicKeyCredentialDescriptor
from fido2.utils import websafe_encode, websafe_decode
import os
import json
import base64

模块说明

Flask相关:用于构建We 应用和路由处理

werkzeug.security:用于密码加密和验证

fido2.server.Fido2Server:实现WebAuthn协议的FIDO2认证服务,用于生成和验证FIDO2注册和认证请求

fido2.webauthn:包含FIDO2凭证相关的数据结构

base64:用于编码/解码FIDO2数据

(2)初始化Flask应用和FIDO2服务器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 启用 WebAuthn JSON 映射
import fido2.features
fido2.features.webauthn_json_mapping.enabled = True

app = Flask(\__name_\_)
app.secret_key = os.urandom(24)

# 配置 RP Entity(Relying Party),使用正确的 origin
rp = PublicKeyCredentialRpEntity(id="localhost", name="Example App")

# 定义 origin 验证函数
def verify_origin(origin): # 允许 localhost 的所有端口
return origin in \["http://localhost:5000", "https://localhost:5000"\]

# 创建 Fido2Server,指定 origin 验证函数
server = Fido2Server(rp, attestation="none", verify_origin=verify_origin)

# 全局用户存储(实际应用应使用数据库)
users = {}

配置说明

RP Entity:代表依赖方(应用程序),用于标识应用

origin 验证函数:确保FIDO2请求来自正确的源

users字典:存储用户信息,包括密码哈希和FIDO2凭证

(3)用户注册界面

后端路由 (/register):

1
2
3
4
5
6
7
8
9
10
11
@app.route("/register", methods=["GET", "POST"])
def register():
if request.method == "POST":
username = request.form["username"]
password = request.form["password"]
if username in users:
return "User already exists", 400
hashed_password = generate_password_hash(password)
users[username] = {"password": hashed_password, "credentials": None}
return redirect(url_for("fido_setup", username=username))
return render_template("register.html")

前端界面 (register.html):

用户输入用户名和密码

前端验证两次密码输入是否一致

提交表单后,后端存储加密的密码,并重定向到FIDO2设置页面

(4)FIDO2设置页面

后端路由(/fido_setup):

1
2
3
4
5
@app.route("/fido_setup/<username>", methods=["GET"])
def fido_setup(username):
if username not in users:
return "User not found", 404
return render_template("fido_setup.html", username=username)

前端交互 (fido_setup.html):

显示FIDO2设置步骤指引

用户点击”开始FIDO2注册”按钮

前端向/fido_register发送请求获取注册选项

(5)生成FIDO2注册选项

后端路由(/fido_register):

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
@app.route("/fido_register", methods=["POST"])
def fido_register():
username = request.form.get("username")
if not username or username not in users:
return jsonify({"error": "User not found"}), 404

try:
user_entity = PublicKeyCredentialUserEntity(
id=username.encode(),
name=username,
display_name=username
)
registration_data, state = server.register_begin(user_entity)

# 存储状态到会话
session["fido_registration_state"] = state

# 将 registration_data 转换为可序列化的格式
pk = registration_data.public_key
challenge = base64.b64encode(pk.challenge).decode()
user_id = base64.b64encode(pk.user.id).decode()

response_data = {
"publicKey": {
"challenge": challenge,
"rp": {"name": pk.rp.name, "id": pk.rp.id},
"user": {
"id": user_id,
"name": pk.user.name,
"displayName": pk.user.display_name
},
"pubKeyCredParams": [
{"type": "public-key", "alg": param.alg}
for param in pk.pub_key_cred_params
],
"timeout": 60000,
"attestation": "direct"
}
}
return jsonify(response_data)
except Exception as e:
return jsonify({"error": str(e)}), 400

前端处理 (fido_setup.html):

接收注册选项,将 Base64 编码的数据转换为ArrayBuffer

调用navigator.credentials.create()创建FIDO2凭证

用户使用生物识别或硬件密钥完成认证

(6)完成FIDO2注册

后端路由 (/fido_complete_register):

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
@app.route("/fido_complete_register", methods=["POST"])
def fido_complete_register():
data = request.get_json()
username = data.get("username")

if not username or username not in users:
return jsonify({"error": "User not found"}), 404

try:
state = session.pop("fido_registration_state", None)
if not state:
return jsonify({"error": "Registration state not found"}), 400

from fido2.webauthn import CollectedClientData, AttestationObject

# 解码 Base64 数据
client_data_bytes = base64.b64decode(data["response"]["clientDataJSON"])
attestation_object_bytes = base64.b64decode(data["response"]["attestationObject"])

# 创建对象并完成注册
client_data = CollectedClientData(client_data_bytes)
attestation_object = AttestationObject(attestation_object_bytes)
auth_data = server.register_complete(state, client_data, attestation_object)

# 存储凭证数据
from fido2 import cbor
cred_id = base64.b64encode(auth_data.credential_data.credential_id).decode()
public_key = auth_data.credential_data.public_key
public_key_bytes = cbor.encode(public_key)

users[username]["credentials"] = json.dumps({
"credential_id": cred_id,
"public_key": base64.b64encode(public_key_bytes).decode()
})

return jsonify({"status": "registered"})
except Exception as e:
return jsonify({"error": str(e)}), 400

(7)用户登录

后端路由(/login):

1
2
3
4
5
6
7
8
9
10
@app.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
username = request.form["username"]
password = request.form["password"]
if username not in users or not check_password_hash(users[username]["password"], password):
return "Invalid username or password", 400
session["username"] = username
return redirect(url_for("fido_auth"))
return render_template("login.html")

前端交互 (login.html):

用户输入用户名和密码

前端提交表单到后端验证

验证成功后,自动请求FIDO2认证选项

(8)FIDO2认证

后端路由 (/fido_auth):

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
@app.route("/fido_auth", methods=["GET"])
def fido_auth():
username = session.get("username")
if not username or username not in users:
return "Unauthorized", 401

if users[username].get("credentials") is None:
return "User has no FIDO2 credentials", 400

try:
cred_data = json.loads(users[username]["credentials"])
credential_id = base64.b64decode(cred_data["credential_id"])

credential_descriptor = PublicKeyCredentialDescriptor(
type="public-key",
id=credential_id
)

authentication_data, state = server.authenticate_begin([credential_descriptor])
session["fido_authentication_state"] = state

pk = authentication_data.public_key
challenge = base64.b64encode(pk.challenge).decode()
cred_id_encoded = base64.b64encode(credential_id).decode()

response_data = {
"publicKey": {
"challenge": challenge,
"timeout": 60000,
"rpId": pk.rp_id,
"userVerification": "preferred",
"allowCredentials": [
{"type": "public-key", "id": cred_id_encoded}
]
}
}

return jsonify(response_data)
except Exception as e:
return jsonify({"error": str(e)}), 400

(9)完成FIDO2登录

后端路由 (/fido_login):

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
47
48
49
50
51
52
@app.route("/fido_login", methods=["POST"])
def fido_login():
username = session.get("username")
if not username or username not in users:
return jsonify({"error": "Unauthorized"}), 401

data = request.get_json()
try:
state = session.pop("fido_authentication_state", None)
if not state:
return jsonify({"error": "Authentication state not found"}), 400

cred_data = json.loads(users[username]["credentials"])
stored_cred_id = base64.b64decode(cred_data["credential_id"])

from fido2 import cbor
public_key_bytes = base64.b64decode(cred_data["public_key"])
public_key = cbor.decode(public_key_bytes)

from fido2.webauthn import CollectedClientData, AuthenticatorData, AttestedCredentialData, Aaguid

# 解码 Base64 数据
credential_id = base64.b64decode(data["id"])
client_data_bytes = base64.b64decode(data["response"]["clientDataJSON"])
authenticator_data_bytes = base64.b64decode(data["response"]["authenticatorData"])
signature = base64.b64decode(data["response"]["signature"])

# 创建对象
client_data = CollectedClientData(client_data_bytes)
authenticator_data = AuthenticatorData(authenticator_data_bytes)

# 重建 AttestedCredentialData 对象用于验证
attested_cred_data = AttestedCredentialData.create(
aaguid=Aaguid.NONE,
credential_id=stored_cred_id,
public_key=public_key
)

# 完成认证
server.authenticate_complete(
state,
[attested_cred_data],
credential_id,
client_data,
authenticator_data,
signature
)

session["authenticated"] = True
return jsonify({"status": "authenticated"})
except Exception as e:
return jsonify({"error": str(e)}), 400

(10)仪表板和登出

后端路由

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@app.route("/", methods=["GET"])
def index():
if session.get("authenticated"):
return redirect(url_for("dashboard"))
return render_template("index.html")

@app.route("/dashboard", methods=["GET"])
def dashboard():
if not session.get("authenticated"):
return redirect(url_for("login"))
username = session.get("username")
return render_template("dashboard.html", username=username)

@app.route("/logout", methods=["GET"])
def logout():
session.clear()
return redirect(url_for("index"))

3.最后运行本项目,执行python app.py

图5

前往http://localhost:5000/register注册新用户

前往http://localhost:5000/login进行登录

4.以下是界面操作展示

(1)网站界面

图6

(2)创建新用户

图7

图8

图9

(3)用户登录

图10

图11

(4)登录界面

图12

四、总结实现原理

用户注册:在/register页面,用户提供用户名和密码进行注册。注册成功后,系统将用户信息(用户名和加密后的密码)存储到内存中,并将用户重定向到 /fido_setup 页面进行FIDO2凭证绑定。

FIDO2设置:在/fido_setup路由,用户点击”开始FIDO2注册”按钮后,前端向 /fido_register路由发送请求。后端生成注册选项(包含challenge、用户信息、RP信息等),并将其转换为JSON格式返回给前端。前端通过 navigator.credentials.create()调用用户的FIDO2设备(如生物识别或硬件密钥)创建凭证。生成的凭证数据(包含clientDataJSON和attestationObject)通过 /fido_complete_register路由发送回服务器,服务器验证并保存凭证信息(credential_id和public_key)到用户账户中。

用户登录:用户在/login页面输入用户名和密码。后端验证用户名和密码是否正确。若正确,系统将用户名存储到会话中,并将用户重定向到FIDO2认证流程。

FIDO2验证:登录表单提交后,前端向/fido_auth路由请求认证选项。后端根据存储的凭证信息生成认证选项(包含challenge和allowCredentials),返回给前端。前端调用navigator.credentials.get()完成FIDO2设备验证。验证成功后,生成的凭证数据(包含clientDataJSON、authenticatorData和signature)通过/fido_login路由发送到服务器进行最终验证。服务器验证签名和数据的完整性,若通过,则在会话中设置authenticated标志,用户便可访问/dashboard仪表盘。