Hi! 告诉你一个超酷的web登陆方式

前言

各位先思考几个问题:

  1. 你的web系统登陆真的安全吗?
  2. 用户真的愿意用大脑记住每个系统的账号密码吗?
  3. 我们怎么确保账号密码在传输过程中不会被窃取?

思考一下再去读下文…

web传统登录方式

1.账号密码

这是最常见的用户登录方式, 用户进入系统之前, 先校验用户的登录状态, 根据状态服务器通知客户端是否重定向到登录页, 用户输入账号密码传输到服务器, 服务器验证之后通知客户端校验是否通过;

2.短信验证码

用户在登录页先输入手机号, 输入手机号点击发送验证码, 客户端将需要验证的手机号发送到服务器, 服务器接收到请求随机生成一组数字, 已短信形式发送到用户的手机上, 服务器将这个验证码在缓存里与这个账号存在映射关系, 用户只需要在表单输入这个验证码即可;

(邮件二维码登陆虽然在技术实现上不同, 但是在形式上和短信类似, 所以在此不做赘述)

3.账号密码 + 短信或邮件等等…

这种方式也比较常见, 也可以称作多因素认证, 一般会在你提交了正确的账号密码之后, 仍需要向你发送一个验证信息, 常见的有短信和邮件或者U盾或者安全证书一类的东西.

真的只有这些嘛?

当然不!

还有一种超酷的登录方式 — WebAuthn;

WebAuthn是什么呢?

Web Authentication API(也称作WebAuthn)使用asymmetric (public-key) cryptography (非对称加密)替代密码或 SMS 短信在网站上注册、登录、second-factor authentication(双因素验证)。 解决了 phishing(钓鱼)、data breaches(数据破坏)、SMS 文本攻击、其它双因素验证等重大安全问题,同时显著提高了易用性(因为用户不必管理许多越来越复杂的密码)。–引自MDN

其实很简单, 就是通过API调用用户设备上的验证器(TouchID, FaceID, Windows Hello, U盘验证器等等), 采用非对称加密的方式, 取消了密码的传输, 规避了明文传输密码的风险, 从而也节省了用户需要记住密码这些恼人的传统操作;

我们在享受互联网带来的便利的同时, 也需要用到不同的供应商提供的服务, 每个服务都需要有自己的账号体系(毕竟谁都是小马哥, 一个qq号通吃游戏). 由于账号过多, 导致你就需要建立不同的账号和密码, 有的服务安全级别比较高的还需要你定期修改密码(例如企业邮箱的密码, 每次改密码真是绞尽脑汁啊啊啊啊…).

这样下来, 你的大脑就需要记住很多的密码(SSO的除外啊, 不要杠, 你要杠就是你赢).

什么时候出现的WebAuthn?

WebAuthn的出现呢, 其实也就是前两年的事情, WebAuthn1级标准于2019年3月4日作为W3C推荐标准发布。2级规范正在开发中。

它的出现解决了什么问题呢?

首当其冲的就是减少了密码的泄漏导致盗用的问题, 通常状况下, 我们密码泄露会有以下几种方式:

  1. 网络钓鱼;
  2. 键盘记录;
  3. 数据泄露;

通常有91%的信息安全攻击始于网络钓鱼, 80%针对企业的攻击包括网络钓鱼.

我们来看一组数据, 网络钓鱼取代exploit-based恶意软件的走势图:

image.png
利用每周检测到的恶意软件和钓鱼网站

其实一个设计良好的钓鱼网站钓鱼的成功率在43%, 而且由于76%的账号由于密码强度弱而被盗;

所以此时, W3C出了一套标准的API, 与谷歌, Mozilla, 微软, Yubico等, 和其他人的参与。API允许服务器使用公钥加密而不是密码来注册和验证用户.

它允许服务器与现在内置于设备中的强身份验证器集成, 例如 Windows Hello 或 Apple 的 Touch ID. 为网站创建了一对公私钥(称为凭证)而不是密码. 私钥安全地存储在用户的设备上; 将公钥和随机生成的凭据 ID 发送到服务器进行存储, 然后服务器可以使用该公钥来证明用户的身份.

webAuthn拿什么来保障安全的呢?

我认为有三个要素:

  1. 硬件保证: 是由硬件安全模块提供支持, 该模块可以安全地存储私钥并执行 WebAuthn 所需的加密操作;
  2. 明确范围: 密钥对仅对特定来源有用, 例如浏览器 cookie. 在 ‘webauthn.juejin’ 注册的密钥对不能在 ‘evil-webauthn.juejin’中使用, 从而减轻网络钓鱼的威胁;
  3. 可靠证明: 身份验证器可以提供一个证书, 帮助服务器验证公钥确实来自他们信任的验证器, 而不是虚假来源.

如何工作的?

下面我们来深入了解一下WebAuthn的工作原理, 他是如何不在网络上传输密码就可以验证是本人登陆的.

注册

WX20210612-014533@2x.png

图片来自MDN, 侵删! (画的太好了, 实在是想不出更容易理解的方式表达出来了, 偷个懒(#^.^#))

开始之前, 我们需要验证当前客户端是否支持这个API:

PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()

PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()

// 这个API会返回一个布尔值来通知你当前客户端是否支持这个功能
复制代码

首先, 如果支持的话, 我们会向服务器发起一个注册请求, 通常会带上我们的账号一类的用户ID, 服务器会去检索数据库, 查找是否有相同的ID, 如果没有那么恭喜你, 你可以使用你的花式ID, 而且与此同时服务器会对这个请求作出响应, 会返回用户一个大概这样的JSON.

const publicKeyCredentialCreationOptions = {
  challenge: Uint8Array.from(
        randomStringFromServer, c => c.charCodeAt(0)),
  rp: {
    name: "Example CORP",
    id  : "login.example.com"
  },
  user: {
    id: new Uint8Array(16),
    name: "jdoe@example.com",
    displayName: "John Doe"
  },
  pubKeyCredParams: [
    {
      type: "public-key",
      alg: -7
    }
  ],
  timeout: 60000,
  attestation: "direct"
};

复制代码

challenge: 挑战是在服务器上生成的加密随机字节缓冲区,需要防止“重放攻击”。W3C规范

rp: 这代表“依赖方”;它可以被视为描述 负责注册和验证用户的组织。 在id当前必须在浏览器中域的一个子集。 例如,id此页面的有效值为。W3C规范

user: 这是有关当前注册用户的信息。 身份验证器使用 将id凭据与用户相关联。建议不要使用个人识别信息作为id,因为它可能存储在身份验证器中。W3C规范

pubKeyCredParams: 这是一个对象数组,描述服务器可以接受哪些公钥类型。 的alg是在所描述的一个数COSE注册表; 例如,-7 表示服务器接受使用 SHA-256 签名算法的椭圆曲线公钥。W3C规范

authenticatorSelection: 此可选对象有助于依赖方对允许注册的身份验证器类型进行进一步限制。在这个例子中,我们表明我们想要注册一个cross-platform身份验证器(如 Yubikey)而不是像 Windows Hello 或 Touch ID 这样的platform身份验证器。W3C规范

timeout: 在 返回错误之前用户必须响应注册提示的时间(以毫秒为单位)。W3C规范

attestation: 从验证器返回的证明数据具有可用于跟踪用户的信息。 此选项允许服务器指示证明数据对于此注册事件的重要性。值”none”表示服务器不关心证明。值”indirect”表示服务器将允许匿名证明数据。direct意味着服务器希望从验证器接收证明数据。W3C规范

一般这个JSON我们是不需要修改的, 拿到这个JSON之后, 我们就开始调用验证器:

const credential = await navigator.credentials.create({
    publicKey: publicKeyCredentialCreationOptions
});
复制代码

浏览器向认证器调用 authenticatorMakeCredential(), 在浏览器内部,浏览器将验证参数并用默认值补全缺少的参数,然后这些参数会变为 AuthenticatorResponse.clientDataJSON. 其中最重要的参数之一是 origin, 它是 clientData 的一部分, 同时服务器将能在稍后验证它. 调用 create() 的参数与clientDataJSON 的 SHA-256 哈希一起传递到身份验证器(只有哈希被发送是因为与认证器的连接可能是低带宽的 NFC 或蓝牙连接, 之后认证器只需对哈希签名以确保它不会被篡改).

认证器创建新的密钥对和证明 – 在进行下一步之前,认证器通常会以某种形式要求用户确认,如输入 PIN,使用指纹,进行虹膜扫描等,以证明用户在场并同意注册。之后,认证器将创建一个新的非对称密钥对,并安全地存储私钥以供将来验证使用。公钥则将成为证明的一部分,被在制作过程中烧录于认证器内的私钥进行签名, 这个私钥会具有可以被验证的证书链.

调用create()返回一个credential给浏览器, 它是一个包含公钥和其他用于验证注册事件属性的对象:

console.log(credential);

PublicKeyCredential {
    id: 'ADSUllKQmbqdGtpu4sjseh4cg2TxSvrbcHDTBsv4NSSX9...',
    rawId: ArrayBuffer(59),
    response: AuthenticatorAttestationResponse {
        clientDataJSON: ArrayBuffer(121),
        attestationObject: ArrayBuffer(306),
    },
    type: 'public-key'
}
复制代码

id: 新生成的凭证的ID; 它将用于在对用户进行身份验证时识别凭据, ID在此处作为 base64编码字符串提供. W3C规范

rawId: 再次是 ID, 但采用二进制形式. W3C规范

clientDataJSON: 这表示从浏览器传递到身份验证器的数据, 以便将新凭据与服务器和浏览器相关联. 身份验证器将其作为 UTF-8 字节数组提供. W3C规范

示例: 解析clientDataJSON

const utf8Decoder = new TextDecoder('utf-8');
const decodedClientData = utf8Decoder.decode(
    credential.response.clientDataJSON)

const clientDataObj = JSON.parse(decodedClientData);

console.log(clientDataObj)

{
    challenge: "p5aV2uHXr0AOqUk7HQitvi-Ny1....",
    origin: "https://juejin.cn",
    type: "juejin.cn"
}
复制代码

在clientDataJSON由UTF-8字节数组变换解析 由认证提供成JSON可解析的字符串。在此服务器上,PublicKeyCredential将验证此(和其他数据)以确保注册事件有效。

challenge: 这与传递到create()调用中的挑战相同。服务器必须验证此返回的质询是否与为此注册事件生成的质询匹配。
origin: 服务器必须验证此“来源”字符串是否与应用程序的来源相匹配。
type: 服务器验证该字符串实际上是”webauthn.create”。如果提供了另一个字符串,则表明身份验证器执行了错误的操作。

attestationObject: 该对象包含凭证公钥可选的证明证书和其他也用于验证注册事件的元数据. 它是用 CBOR 编码的二进制数据. W3C规范

示例:解析 attestationObject

const decodedAttestationObj = CBOR.decode(
    credential.response.attestationObject);

console.log(decodedAttestationObject);
{
    authData: Uint8Array(196),
    fmt: "fido-u2f",
    attStmt: {
        sig: Uint8Array(70),
        x5c: Array(1),
    },
}
复制代码

authData: 这里的身份验证器数据是一个字节数组,其中包含有关注册事件的元数据,以及我们将用于未来身份验证的公钥。

fmt: 这表示证明格式。认证者可以通过多种方式提供证明数据;这指示服务器应如何解析和验证证明数据。

attStmt: 这是证明声明。根据指示的证明格式,此对象看起来会有所不同。在这种情况下,我们会获得签名sig和证明证书x5c。服务器使用此数据以加密方式验证来自身份验证器的凭证公钥。此外,服务器可以使用证书来拒绝被认为是弱的身份验证器。

验证器会将这组数据返回给客户端, 最后:

服务器验证数据并完成注册 – 服务器需要执行一系列检查以确保注册完成且数据未被篡改。步骤包括:

  1. 验证接收到的挑战与发送的挑战相同
  2. 确保 origin 与预期的一致
  3. 使用对应认证器型号的证书链验证 clientDataHash 的签名和证明

验证步骤的完整列表可以在 WebAuthn 规范中找到。一旦验证成功,服务器将会把新的公钥与用户帐户相关联以供将来用户希望使用公钥进行身份验证时使用。

登陆

老规矩先上图:

image.png

同样卑微的侵删啊….!

登陆时, 用户线请求服务器说, 宝宝要登陆啦, 服务器会下发一个challenge挑战书和之前账号关联的公钥给客户端.

客户端接收到服务器吐出的数据后开始调用验证器: navigator.credentials.get()
在身份验证期间,用户证明他们拥有他们注册的私钥。 他们通过提供一个assertion,这是通过调用navigator.credentials.get()客户端生成的。这将检索注册期间生成的包含签名的凭据。

const credential = await navigator.credentials.get({
    publicKey: publicKeyCredentialRequestOptions
});

复制代码

该publicKeyCredentialCreationOptions对象包含许多必需和可选字段,服务器指定这些字段为用户创建新凭证。

const publicKeyCredentialRequestOptions = {
    challenge: Uint8Array.from(
        randomStringFromServer, c => c.charCodeAt(0)),
    allowCredentials: [{
        id: Uint8Array.from(
            credentialId, c => c.charCodeAt(0)),
        type: 'public-key',
        transports: ['usb', 'ble', 'nfc'],
    }],
    timeout: 60000,
}

const assertion = await navigator.credentials.get({
    publicKey: publicKeyCredentialRequestOptions
});
复制代码

challenge: 就像在注册期间一样,这必须是在服务器上生成的加密随机字节。W3C规范

allowCredentials: 该数组告诉浏览器服务器希望用户使用哪些凭据进行身份验证。在credentialId检索并在这里传递注册过程中保存。服务器可以选择指示它喜欢的传输方式,如 USB、NFC 和蓝牙。W3C规范

timeout: 与注册期间一样,这可选地指示用户必须响应身份验证提示的时间(以毫秒为单位)。W3C规范

assertion从调用返回的对象又是一个对象。与我们在注册时收到的对象略有不同;特别是,它包括一个成员,而不包括公钥。 get() PublicKeyCredentialsignature

console.log(assertion);

PublicKeyCredential {
    id: 'ADSUllKQmbqdGtpu4sjseh4cg2TxSvrbcHDTBsv4NSSX9...',
    rawId: ArrayBuffer(59),
    response: AuthenticatorAssertionResponse {
        authenticatorData: ArrayBuffer(191),
        clientDataJSON: ArrayBuffer(118),
        signature: ArrayBuffer(70),
        userHandle: ArrayBuffer(10),
    },
    type: 'public-key'
}
复制代码

id: 用于生成身份验证断言的凭据的标识符。W3C规范

rawId: 标识符再次,但以二进制形式。W3C规范

authenticatorData: 该认证数据的类似的authData注册时收到,有一个明显例外公钥不包括在内。它是身份验证期间用作源字节以生成断言签名的另一项。W3C规范

clientDataJSON: 在注册期间,clientDataJSON是从浏览器传递到身份验证器的数据的集合。它是身份验证期间用作生成签名的源字节的项目之一。W3C规范

signature: 由与此凭据关联的私钥生成的签名。在服务器上,公钥将用于验证此签名是否有效。W3C规范

userHandle: 此字段由身份验证器可选地提供,并表示user.id 在注册期间提供的 。它可用于将此断言与服务器上的用户相关联。它在此处编码为 UTF-8 字节数组。W3C规范

获得断言后,将其发送到服务器进行验证。在验证数据完全验证后,使用注册期间存储在数据库中的公钥来验证签名。

示例:验证服务器上的断言签名(伪代码)

const storedCredential = await getCredentialFromDatabase(
    userHandle, credentialId);

const signedData = (
    authenticatorDataBytes +
    hashedClientDataJSON);

const signatureIsValid = storedCredential.publicKey.verify(
    signature, signedData);

if (signatureIsValid) {
    return "验证成功! ?";
} else {
    return "验证失败. ?"
}
复制代码

根据服务器上使用的语言和加密库,验证看起来会有所不同。但是,一般程序保持不变。

  • 服务器检索与用户关联的公钥对象;
  • 服务器使用公钥来验证签名,该签名是使用authenticatorData字节和 SHA-256 哈希生成的clientDataJSON;

这就是大概用户和登陆的流程.

总结

虽然 Web 身份验证是一个重要的工具,但请务必记住,安全性不是一项单一的技术;它是一种思维方式,应该融入软件设计和开发的每一步。Web 身份验证可能是此过程的重要组成部分,它迫使 80% 的黑客攻击要么适应要么消失。

参考

有一些好的文档供大家参考:

为了准确性, 我参考了这些非常好的文章来写这篇文章:

最后推荐大家一篇非常不错的博客:

juejin.cn/post/691389…

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享