背景故事
前司有一个 PWA 项目,用户可以在浏览器上,将 PWA 添加到iOS和安卓的桌面中,而无需从应用商店下载。不少用户反馈,希望能像原生应用那样使用Face ID或指纹进行登录,节省输入密码的时间。查阅资料后,Web Authentication API能完全满足要求。
前置知识:公私钥 和 账号密码模式 的用户认证
在账号密码模式下。用户的密码会在互联网的链路上传输。这种传输方式,即使在HTTPS下,也可能发生泄漏。比如某些公司会在员工的电脑上装上自己公司的HTTPS证书以监控员工。
公私钥模式,会生成一对密钥而不是一个密钥( KPrivate 和 KPublic)。
KPrivate 加密的信息需要用 KPublic 解密,反过来 KPublic 加密的信息需要用 KPrivate 解密。
其中 KPublic 密钥公开在互联网上传输,称为公钥, KPrivate 则存储在本地不在互联网上传输。
服务器会事先存储 KPublic 。需要鉴定用户身份时,会发送给用户一段随机的字符串 challenge 。客户端收到 challenge 后,会用 KPrivate 加密 challenge 。生成密文 signature ,这个过程称为签名。客户端将 signature 发给服务器。
收到 signature 后,服务器会用 KPublic 解密 signature ,得到challenge2
,若challenge2 === challenge
,则验明正身,登录成功。
Web Authentication
Web Authentication 是公私钥的认证模式在浏览器上的实现。
用户操作系统中,存在管理公私钥认证的部分,称为凭证管理器。凭证管理器能在浏览器外存储私钥并在使用指纹,Face ID,锁屏密码等确认用户身份后,才使用私钥签名。
使用凭证管理器管理用户登录。杜绝了明文密码在传输链路上的泄漏风险。若凭证管理器具有Face ID,指纹等生物认证能力,还能免去用户每次输入密码的时间。
注册与登录
注册流程
注册流程中,公私钥的模式,体现在步骤7中签名了步骤2给浏览器的随机challenge字符串。
之后在步骤10中,确认用户注册有效。接着在步骤11中,存储了凭证ID和公钥,将他们与用户信息进行了绑定。这一步是实现用户登录的前提。
登录流程
登录流程与注册流程类似。在步骤6中签名步骤2返回的随机Challenge。在步骤10中,校验该签名通过公钥解密后是否与注册时保留的公钥相同,鉴定用户身份。
概念是地基,程序设计是蓝图,程序实现是前两者的具象化
实现
流程图描述的内容不是具体的代码,但它提供了代码执行的顺序,指引编码的方向。下文代码删去了一些无关紧要的细节的核心实现,减少代码噪音,完整版在文末会贴出Gitee和Github的仓库地址
浏览器原生的Webauthn API的参数繁多,且校验签名的流程实现繁琐。这都会带来额外的代码噪音干扰学习。 例子中用@passwordless-id/webauthn作为其上层封装,能更快速地实现注册和登录的功能。
浏览器端
@passwordless-id/webauthn 在浏览器端使用。需要引入它名为client
的export
。
ts
import { client } from '@passwordless-id/webauthn'
实现注册功能
请求凭证管理器注册,需要调用client.register
,它有下列三个参数。
- username. 通过凭证管理器,告诉用户注册的凭证代表哪个用户
- challenge。这是注册时给凭证管理器签名的随机字符串
- 可选的options,一些注册时额外的配置。在本例中省略
ts
register(
username: string,
challenge: string,
options?: RegisterOptions
): Promise<RegistrationEncoded>
之后,将register
方法的返回值传递给服务器,交由服务器校验。
从上面的时序图中,可知注册流程中浏览器需要做四件事。
- 从服务端请求 challenge
- 调用浏览器的 WebAuthn 的创建凭证 API ,请求凭证管理器创建新的凭证并签名 challenge 。
- 将凭证管理器返回的信息传递给服务端校验并持久化,得到注册结果。
- 注册成功后在本地持久化凭证的公开部分,便于下次登录。
代码的实现如下。
ts
async function register(user: string) {
// 获取随机challenge供凭证管理器签名
const { challenge } = await fetchChallenge();
console.debug("challenge: " + challenge);
// 请求凭证管理器创建凭证并签名challenge
const registration = await client.register(username, challenge);
console.debug("registration: \n", registration);
// 将凭证管理器签名后的凭证信息发送给服务器
const response = await fetch("/api/register", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ registration, username }),
});
// 从服务器接收注册结果
const res = (await response.json()) as Promise<{
success: boolean;
}>;
// 保存凭证信息到本地
localStorage.setItem(
LOCAL_CREDENTIAL_KEY,
JSON.stringify(registration.credential)
);
console.debug("save credential: \n", registration.credential);
return res;
}
实现登录功能
实现登录功能需要调用client.authenticate
。它也需要传递三个参数
- credentialIds : 这是一个数组。需要给定注册时获得的凭证ID。即
credential.id
- challenge: 从服务器取得交由凭证管理器随机字符串
- 可选的options: 一些登录时可能用到的额外配置项,本例子省略
同样从时序图中知晓代码的流程就是:
- 从服务端请求用户当前 session 登录的 challenge 。
- 操作系统调用浏览器 WebAuthn 认证API,请求凭证管理器使用已存在的凭证签名 challenge
- 将 challenge 的签名 传递给服务端验证,得到登录结果
ts
async function login() {
// 从本地取出凭证信息
const credential = JSON.parse(
localStorage.getItem(LOCAL_CREDENTIAL_KEY) || "null"
) as CredentialKey | null;
// 从服务器获取随机challenge供凭证管理器签名
const { challenge } = await fetchChallenge();
console.debug("challenge: " + challenge);
if (!credential?.id) throw new Error("No credential found");
// 请求凭证管理器签名challenge以实现登录
const authentication = await client.authenticate(
[credential.id],
challenge
);
// 获取登录结果,如果成功则返回凭证ID和用户名
const response = await fetch("/api/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ authentication }),
});
return response.json() as Promise<{
success: boolean;
credentialId: string;
username: string;
}>;
}
服务端
@passwordless-id/webauthn 在服务端使用需要引入它名为server
的export
。
ts
import { server } from '@passwordless-id/webauthn'
创建challenge
注册和登录的第一步都是生成一个随机的challenge交给客户端签名。因此使用一个/challenge
接口。
为了让服务器在校验时能取得期望的challenge, challenge需要保存在服务器的session中 。本例使用@koa/session
来管理session。
实际项目中,为避免服务器重启导致session丢失,需要将session持久化到redis或数据库中。
ts
import Koa from 'koa';
import router from 'router';
import session from "koa-session";
// 这是node自带的Crypto库,用于快速生成随机challenge
import crypto from "node:crypto";
const app = new Koa();
const router = new Router();
//....
router.get("/challenge", async (ctx) => {
const challenge = crypto.randomUUID();
// 将challenge设置到服务器的session中
ctx.session.challenge = challenge;
// HTTP响应中返回challenge
ctx.response.body = {
challenge,
};
})
注册接口
注册接口的职责是完成下列三件事
- 接收来自请求数据的凭证信息和签名
- 在请求的 session 中取得 challenge 的原文
- 根据非对称加密的原理,使用凭证信息的公钥校验签名和 challenge 是否匹配
- 如步骤3匹配,对凭证信息进行持久化存储
在本例中,数据库使用基于 JSON 的 LowDB 。减少代码噪音。
ts
router.post("/register", async (ctx) => {
const db = await Database.getInstance();
// 从服务器的session中获取challenge
const challenge: string | undefined = ctx.session.challenge;
if (!challenge) {
ctx.throw(400, "No challenge in session");
}
await db.read();
let registrationParsed: RegistrationParsed;
// 从请求body中取出username和registration
// registration是客户端生成的,包含了客户端生成的公钥和签名
const {
username,
registration,
}: { username: string; registration: RegistrationEncoded } =
ctx.request.body;
try {
// 验证registration是否与challenge匹配
registrationParsed = await webauthn.verifyRegistration(registration, {
challenge,
// 同时会验证请求的origin是否符合预期
origin: DEFAULT_ORIGIN,
});
} catch (e) {
// 不匹配,返回401
ctx.throw(401, "Invalid registration");
return;
}
// 匹配,将用户信息写入数据库
db.data.users[registration.credential.id] = {
...registrationParsed.credential,
username,
};
await db.write();
// 返回注册结果
ctx.body = {
success: true,
};
})
登录接口
登录接口与注册接口大同小异,唯一的区别是,注册接口是校验签名和凭证后将凭证持久化,而登录接口则是将持久化的凭证从数据库中取出以校验签名。
它需要完成以下步骤:
- 在请求的 session 中取得 challenge 的原文
- 接收请求数据中的签名信息
- 从数据库中根据凭证 ID 取出凭证
- 校验签名,凭证,challenge 三者是否对应
- 若校验通过,使用 JWT 或 cookie 等技术将用户状态改为已登录
ts
router.post("/login", async (ctx) => {
const challenge: string | undefined = ctx.session.challenge;
if (!challenge) {
ctx.throw(400, "No challenge in session");
}
const db = await Database.getInstance();
// 前端需要校验的authentication对象,里面含有签名和凭证ID
const {
authentication,
}: {
authentication: AuthenticationEncoded;
} = ctx.request.body;
await db.read();
// 从数据库中取出凭证ID对应的用户信息
const { username, ...credential } =
db.data.users[authentication.credentialId];
try {
// 验证authentication是否与challenge匹配
const result = await webauthn.verifyAuthentication(
authentication,
credential,
{
challenge,
origin: DEFAULT_ORIGIN,
userVerified: true,
counter: -5,
}
);
// 此处还应该有JWT, 设置Cookie,session等操作来使用户保持登录状态
// 匹配,返回登录结果
ctx.response.body = {
success: true,
username,
credentialId: result.credentialId,
};
} catch (e) {
ctx.throw(401, "Invalid authentication");
}
})
完整实现
Webauthn-demo: 基于@password-id/webauthn实现无密码注册和登录功能的例子代码 (gitee.com) 本文作者使用桌面端打开网页并注册时,在注册和登录时,会要求按下指纹确认
QA
Q: Webauthn是否是一套新的身份认证模式,它和网站原本的账号密码体系是什么关系?
A: Aebauthn是原有账号体系的一种扩充。在注册流程的最后一步,在数据库中,可以将用户设备凭证和原有的用户账户体系进行关联。
同样,如果网页应用开发者比较激进,也可以完全不使用账号、密码模式认证身份。让Webauthn独自撑起用户身份认证的大旗。
Q: 我的后端是Go、Java、Python,也可以使用WebAuthn么?
A: 没问题,Webauthn验证的步骤是开源的,并且社区已经有了很多语言的实现。比如WebAuthn.io下Using Webauthn
章节。
Q: WebAuthn是否支持跨设备调用凭证管理器?
A: 支持。将client.register
第三个参数的option.authenticatorType
设置为roaming
可要求用户保存在另一台手机或USB硬件上。设置为local
则会使用本机的 face id、指纹、锁屏密码等。