HTTP Auth From BasicAuth to WebAuthn

在 Web 开发中,身份认证是一个很常见的诉求,而平时设计中并没有好好研究整体的 Auth 体系,今天就从头开始研究一下 Auth 这个东西。

MDN 对 HTTP Auth 有所总结:

RFC 7235 定义了一个 HTTP 身份验证框架,服务器可以用来质询(challenge)客户端的请求,客户端则可以提供身份验证凭据。

质询与响应的工作流程如下:

  1. 服务器端向客户端返回 401(Unauthorized,未被授权的)响应状态码,并在 WWW-Authenticate 响应标头提供如何进行验证的信息,其中至少包含有一种质询方式。
  2. 之后,想要使用服务器对自己身份进行验证的客户端,可以通过包含凭据的 Authorization 请求标头进行验证。
  3. 通常,客户端会向用户显示密码提示,然后发送包含正确的 Authorization 标头的请求。

而大部分 HTTP 验证都遵循这一流程,只是加密算法、验证实现可能有所区别。

Basic Auth

下图就是一个 Basic Auth 的流程。

Pasted image 20240930215856.png

当然,对于 Basic Auth 来说,因为使用的是 base64,因此安全性很差。

我们可以这样新建一个 HTTP Server 来实现一个 basic auth:

func homeHandler(w http.ResponseWriter, r *http.Request) {
    u, p, _ := r.BasicAuth()
    fmt.Println("auth", r.Header.Get("Authorization"), "username=", u, "password=", p)

    if u == "admin" && p == "admin" {
        fmt.Fprintf(w, "Welcome to the Home Page!")
        return
    }
    w.Header().Set("WWW-Authenticate", `Basic realm="helloworld"`)
    w.WriteHeader(http.StatusUnauthorized)
}

func serve() {
    http.HandleFunc("/", homeHandler)
    fmt.Println("Starting server on :8080...")
    if err := http.ListenAndServe(":8080", nil); err != nil {
        fmt.Println("Error starting server:", err)
    }
}

打印日志中输出:

auth Basic YWRtaW46YWRtaW4= username= admin password= admin

表示这确实是个 base64。

WebAuthn

WebAuthn 是一种新的标准,它不再使用用户名+密码的形式,而是改用了生物识别或者实体秘钥来作为凭证。

WebAuthn 体验:https://webauthn.io/

实际上我们已经可以在不少网站中看到这样的效果了,有了这种认证方式,我们甚至可以解决《密码怎么存》、《怎么防止机器人登录》等问题。

当然,在实际场景中,如果没有一些跨设备的 webauth 存储能力,那就只能把它当做一种二次验证,而并非登录注册的场景。

不过可以说,这还是一种未来可期的方式。

整体流程

首先,我们来看一下大致的使用流程:

  1. 填入用户的基本信息,通常也就是 username
  2. 选择注册的情况下,服务器先暂存提交信息并且生成 Challenge 和 UserID,并返回给客户端
  3. 客户端收到后把数据发给验证器,由验证器验证并生成密钥对,将结果凭证返回给客户端
  4. 客户端将结果发给服务端
  5. 服务端核验信息,如果通过则存储对应的用户和公钥信息
  6. 而如果是登录的场景下,浏览器将 Challenge 和 UserID 发给验证器,验证器加密 Challenge 之后由客户端提交给服务端,服务端验证通过则登陆成功

这里我们提到了几个名词,在这里一一说明:

  1. Challenge:一段由服务端生成的加密随机字符串。
  2. UserID:用户身份的唯一标识符
  3. 验证器(Authenticator):可以理解为「TouchID」「Windows Hello」「Pin」甚至是物理硬件等身份认证设备

而客户端到验证器的交互是由浏览器本身来实现的,我们无需关心,只要调用浏览器的 API 就可以了。

我们需要实现的也就是客户端到服务端的交互,接下来会挨个的对注册和登录进行实现。在本例中,依然以 Golang 作为后端语言,而使用简单的 JS + HTML 作为前端实现。

本文注重核心代码的实现,而不是全部代码,因此不包括前端外围界面,输入框和提交,也不包括 DB 落库操作等。

注册流程

注册流程步骤如下:

WebAuthn 注册流程.png

其中我们将 Session Storage 和持久化 Storage(比如存进 MySQL)都抽象成 Storage 来简化这张图。

接下来我们将根据这张图来依次实现,在实现之前,我们先准备好基础工具,也就是需要使用的工具库,有助于我们更快的了解整个流程,而不用实现其加解密和验证过程:

  1. 前端:直接使用 navigator.credentials.createnavigator.credentials.get,相关文档参考:

  2. 后端:

开始注册

从上图中,我们可以看出,一共发送给了服务端两次请求,因此可以定义两个接口,一个叫 begin、一个叫 finish,下文中你看到的所有带 begin 词缀的都代表第一次请求,而带 finish词缀的则是第二次请求。

首先,用户应该填表输入用户基本信息,这里我们定义用户必须输入信息 usernamenickname进行注册:

const response = await fetch('/register/begin', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ username, nickname })
});

然后实现第三步和第四步的后端接口:生成 Challenge & UserID 并暂存。

/// --- authn.go
var (
    authn *webauthn.WebAuthn
)

// 初始化 webauthn
func NewAuthn() {
    var err error
    wconfig := &webauthn.Config{
        RPID:          "localhost",
        RPDisplayName: "WebAuthn Demo",
        RPOrigins:     []string{"http://localhost:8080"},
    }
    if authn, err = webauthn.New(wconfig); err != nil {
        panic("WebAuthn NewError: " + err.Error())
    }
    return
}


/// --- handler/user.go
type BeginRegisterReq struct {
    Username string `json:"username" binding:"required"`
    Nickname string `json:"nickname" binding:"required"`
}

type User struct {
    ID             int64           `json:"id"`               // 用户唯一标识符
    Username       string          `json:"username"`         // 用户名
    DisplayName    string          `json:"display_name"`     // 用户显示名称
    CredentialIDs  []string        `json:"credential_ids"`   // 存储用户所有的 WebAuthn 凭证 ID(允许多个设备)
    PublicKeyCreds []PublicKeyCred `json:"public_key_creds"` // 存储 WebAuthn 公钥凭证信息
    RegisteredAt   int64           `json:"registered_at"`    // 注册时间戳
}

func BeginRegister(c *gin.Context) {
    // 解析入参
    var req BeginRegisterReq
    session := sessions.Default(c)
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(400, gin.H{"error": err.Error()})
        return
    }
    // 校验用户是否已注册
    user, err := service.GetUser(req.Username)
    if err != nil {
        fmt.Println("[handler][BeginRegister] service.GetUser error", err)
        c.JSON(500, gin.H{"error": err.Error()})
        return
    }
    if user != nil {
        c.JSON(400, gin.H{"error": "user already exists"})
        return
    }

    // 生成 User 信息
    user = &models.User{}
    // gen random int id
    user.ID = user.GenUserID()
    user.Username = req.Username
    user.DisplayName = req.Nickname

    // 生成认证信息
    registerOptions := func(credCreationOpts *protocol.PublicKeyCredentialCreationOptions) {
        credCreationOpts.CredentialExcludeList = user.CredentialExcludeList()
    }

    // 生成带 Challenge 和 UserID 信息的 options 对象
    options, sessionData, err := authn.GetAuthn().BeginRegistration(user, registerOptions)
    if err != nil {
        fmt.Println("[handler][BeginRegister] authn.BeginRegistration error", err)
        c.JSON(500, gin.H{"error": err.Error()})
        return
    }

    // 暂存内容到 Session
    if sessionDataStr, err := json.Marshal(&sessionData); err != nil {
        fmt.Println("[handler][BeginRegister] json.Marshal error", err)
        c.JSON(500, gin.H{"error": err.Error()})
        return
    } else {
        session.Set(RegSessionDataKey, sessionDataStr)
    }
    
    if userStr, err := json.Marshal(&user); err != nil {
        fmt.Println("[handler][BeginRegister] json.Marshal error", err)
        c.JSON(500, gin.H{"error": err.Error()})
        return
    } else {
        session.Set(RegUserTempDataKey, userStr)
    }
    err = session.Save()
    
    if err != nil {
        fmt.Println("[handler][BeginRegister] session.Save error", err)
        c.JSON(500, gin.H{"error": err.Error()})
        return
    }
    // 返回值
    c.JSON(200, gin.H{"success": true, "data": options})
}

这里的前置解析参数、校验用户是否存在和后置的写入 Session 都是 Web 的常规操作,不多做说明,核心块在这里:

    // 生成认证信息
    registerOptions := func(credCreationOpts *protocol.PublicKeyCredentialCreationOptions) {
        credCreationOpts.CredentialExcludeList = user.CredentialExcludeList()
    }

    // 生成带 Challenge 和 UserID 信息的 options 对象
    options, sessionData, err := authn.GetAuthn().BeginRegistration(user, registerOptions)
    if err != nil {
        fmt.Println("[handler][BeginRegister] authn.BeginRegistration error", err)
        c.JSON(500, gin.H{"error": err.Error()})
        return
    }

这里有几个问题:

  1. CredentialExcludeList 是做什么的
  2. BeginRegistration做了什么,为什么这样就能签下 Challenge 和 UserID

关于第一个问题,在WebAuthn 标准中有言:

This member is intended for use by Relying Parties that wish to limit the creation of multiple credentials for the same account on a single authenticator. The client is requested to return an error if the new credential would be created on an authenticator that also contains one of the credentials enumerated in this parameter.

也叫做:

Don’t re-register any authenticator that has one of these credentials.

也就是为了避免重复注册做的一种措施。

第二个问题中,我们需要看下 webauthn 这个 Golang 库的内部大概是怎么实现的:

// User is an interface with the Relying Party's User entry and provides the fields and methods needed for WebAuthn
// registration operations.
type User interface {
    // WebAuthnID provides the user handle of the user account. A user handle is an opaque byte sequence with a maximum
    // size of 64 bytes, and is not meant to be displayed to the user.
    //
    // To ensure secure operation, authentication and authorization decisions MUST be made on the basis of this id
    // member, not the displayName nor name members. See Section 6.1 of [RFC8266].
    //
    // It's recommended this value is completely random and uses the entire 64 bytes.
    //
    // Specification: §5.4.3. User Account Parameters for Credential Generation (https://w3c.github.io/webauthn/#dom-publickeycredentialuserentity-id)
    WebAuthnID() []byte

    // WebAuthnName provides the name attribute of the user account during registration and is a human-palatable name for the user
    // account, intended only for display. For example, "Alex Müller" or "田中倫". The Relying Party SHOULD let the user
    // choose this, and SHOULD NOT restrict the choice more than necessary.
    //
    // Specification: §5.4.3. User Account Parameters for Credential Generation (https://w3c.github.io/webauthn/#dictdef-publickeycredentialuserentity)
    WebAuthnName() string

    // WebAuthnDisplayName provides the name attribute of the user account during registration and is a human-palatable
    // name for the user account, intended only for display. For example, "Alex Müller" or "田中倫". The Relying Party
    // SHOULD let the user choose this, and SHOULD NOT restrict the choice more than necessary.
    //
    // Specification: §5.4.3. User Account Parameters for Credential Generation (https://www.w3.org/TR/webauthn/#dom-publickeycredentialuserentity-displayname)
    WebAuthnDisplayName() string

    // WebAuthnCredentials provides the list of Credential objects owned by the user.
    WebAuthnCredentials() []Credential
}

func (webauthn *WebAuthn) BeginRegistration(user User, opts ...RegistrationOption) (creation *protocol.CredentialCreation, session *SessionData, err error) 

也就是说,我们先需要实现一下这个 interface,这对我们自然不在话下:

type User struct {
    ID             int64           `json:"id"`               // 用户唯一标识符
    Username       string          `json:"username"`         // 用户名
    DisplayName    string          `json:"display_name"`     // 用户显示名称
    CredentialIDs  []string        `json:"credential_ids"`   // 存储用户所有的 WebAuthn 凭证 ID(允许多个设备)
    PublicKeyCreds []PublicKeyCred `json:"public_key_creds"` // 存储 WebAuthn 公钥凭证信息
    RegisteredAt   int64           `json:"registered_at"`    // 注册时间戳
}

func (u *User) GenUserID() int64 {
    return rand.Int63()
}

func (u *User) WebAuthnID() []byte {
    if u == nil {
        return []byte{}
    }
    buf := make([]byte, binary.MaxVarintLen64)
    binary.PutUvarint(buf, uint64(u.ID))
    return buf
}

func (u *User) WebAuthnName() string {
    if u == nil {
        return ""
    }
    return u.Username
}

func (u *User) WebAuthnDisplayName() string {
    if u == nil {
        return ""
    }
    return u.DisplayName
}

func (u *User) WebAuthnCredentials() []webauthn.Credential {
    creds := make([]webauthn.Credential, 0)
    if u == nil {
        return creds
    }
    for _, cred := range u.PublicKeyCreds {
        credential, err := cred.ToWebAuthnCredential()
        if err != nil {
            continue
        }
        creds = append(creds, credential)
    }
    return creds
}

func (u *User) CredentialExcludeList() []protocol.CredentialDescriptor {
    var excludeList = make([]protocol.CredentialDescriptor, 0)
    if u == nil {
        return excludeList
    }
    for _, cred := range u.WebAuthnCredentials() {
        excludeList = append(excludeList, protocol.CredentialDescriptor{
            Type:         protocol.PublicKeyCredentialType,
            CredentialID: cred.ID,
        })
    }

    return excludeList
}

接下来会随机生成 challenge,并根据你实现的 User interface 对信息进行组装再设置超时时间(这里就不 copy 内部代码了)。

接下来,我们就要开始进行第六步,带 Challenge 和 User 信息去请求验证器验证,也就是 navigator.credentials.create

const data = await response.json();
if (!data.success) throw new Error(data.error);

const publicKey = data.data.publicKey;
publicKey.challenge = bufferDecode(publicKey.challenge);
publicKey.user.id = bufferDecode(publicKey.user.id);

const credential = await navigator.credentials.create({ publicKey });

这里需要注意的是,challengeuser.id 因为都需要 Uint8Array,所以需要从 string进行转换。

(这里因为没有在后端的 Config 中传入 EncodeUserIDAsString,所以惹了不少麻烦,还得自己实现一个 decode,可以参考,如果传了这个,应该就可以直接使用 TextEncoderTextDecoder 了:

function bufferDecode(value) {
    return Uint8Array.from(atob(urlSafeBase64ToStandard(value)), c => c.charCodeAt(0));
}

function bufferEncode(value) {
    return btoa(String.fromCharCode(...new Uint8Array(value)))
        .replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}

function urlSafeBase64ToStandard(base64) {
    return base64.replace(/-/g, '+').replace(/_/g, '/').replace(/=/g, '');
}

此时验证器会要求用户进行验证,并来到第 9 步返回凭证。

完成注册

然后客户端会带着返回的 credential再次请求服务端,完成验证+注册流程,和之前想的一样,提交时我们我们需要将 Buffer 转成 string 并提交。

const registrationResponse = await fetch('/register/finish', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
        id: credential.id,
        rawId: bufferEncode(credential.rawId),
        type: credential.type,
        response: {
            attestationObject: bufferEncode(credential.response.attestationObject),
            clientDataJSON: bufferEncode(credential.response.clientDataJSON)
        }
    })
});

接下来服务端需要处理带过来的凭证

func FinishRegister(c *gin.Context) {
    var (

        sessionData     = webauthn.SessionData{}
        user            = models.User{}
        credential      *webauthn.Credential
        err             error
        ok              bool
        registerDataStr []byte
        userStr         []byte
    )
    // 获取 session
    session := sessions.Default(c)
    // 获取 sesson 中存储的内容
    if registerDataStr, ok = session.Get(RegSessionDataKey).([]byte); !ok {
        c.JSON(400, gin.H{"error": "register_data not found"})
        return
    }
    if userStr, ok = session.Get(RegUserTempDataKey).([]byte); !ok {
        c.JSON(400, gin.H{"error": "temp_user not found"})
        return
    }
    if err = json.Unmarshal(registerDataStr, &sessionData); err != nil {
        fmt.Println("[handler][FinishRegister] json.Unmarshal error", err)
        c.JSON(500, gin.H{"error": err.Error()})
        return
    }
    if err = json.Unmarshal(userStr, &user); err != nil {
        fmt.Println("[handler][FinishRegister] json.Unmarshal error", err)
        c.JSON(500, gin.H{"error": err.Error()})
        return
    }
    // 验证并获得凭证
    if credential, err = authn.GetAuthn().FinishRegistration(&user, sessionData, c.Request); err != nil {
        fmt.Printf("[handler][FinishRegister] authn.FinishRegistration error: %+v\n", err)
        c.JSON(500, gin.H{"error": err.Error()})
        return
    }
    // 保存凭证到数据库
    if err = service.CreateUser(&user, credential); err != nil {
        fmt.Println("[handler][FinishRegister] service.CreateUser error", err)
        c.JSON(500, gin.H{"error": err.Error()})
        return
    }
    // 删除 session
    session.Delete(RegSessionDataKey)
    session.Delete(RegUserTempDataKey)
    if err = session.Save(); err != nil {
        fmt.Println("[handler][FinishRegister] session.Save error", err)
    }
    c.JSON(200, gin.H{"success": true})
}

这里我们把 BeginRegister 里的存储的值都拿了出来,丢进了 FinishRegistration,这是 webauthn 库提供给我们的一个方法,你甚至不用定义 Request,内部已经对 Request 有标准化的定义了:

// 更多直接看上下文,再粘贴文章就太长了
type CredentialCreationResponse struct {
    PublicKeyCredential

    AttestationResponse AuthenticatorAttestationResponse `json:"response"`
}

然后可以看到内部实现再解析后进行了验证:


// CreateCredential verifies a parsed response against the user's credentials and session data.
func (webauthn *WebAuthn) CreateCredential(user User, session SessionData, parsedResponse *protocol.ParsedCredentialCreationData) (credential *Credential, err error) {
    if !bytes.Equal(user.WebAuthnID(), session.UserID) {
        return nil, protocol.ErrBadRequest.WithDetails("ID mismatch for User and Session")
    }

    if !session.Expires.IsZero() && session.Expires.Before(time.Now()) {
        return nil, protocol.ErrBadRequest.WithDetails("Session has Expired")
    }

    shouldVerifyUser := session.UserVerification == protocol.VerificationRequired

    var clientDataHash []byte

    if clientDataHash, err = parsedResponse.Verify(session.Challenge, shouldVerifyUser, webauthn.Config.RPID, webauthn.Config.RPOrigins, webauthn.Config.RPTopOrigins, webauthn.Config.RPTopOriginVerificationMode, webauthn.Config.MDS); err != nil {
        return nil, err
    }

    return NewCredential(clientDataHash, parsedResponse)
}

这样就注册成功了,如果注册完直接登录,那么我们直接将用户信息写进 Session 就行了。

登录流程

登录流程相比注册流程来说非常类似,步骤如下:

WebAuthn-登录流程.drawio.png
这里只是把凭证换成了 navigator.credentials.get 所需要的 publicKey,再将凭证换成断言信息。

publicKey 中的信息包括了:

  • challenge:和注册一样的随机字符串
  • allowCredentials:告诉浏览器服务器希望用户使用哪些凭据进行身份验证。这里传入注册时保存的credentialId 。
  • timeout:超时时间

get 返回的断言中主要包括了签名信息(不包含公钥)来帮助我们进一步认证。关于更详细的解释可以参考:https://webauthn.guide/#authentication

服务端用签名和公钥进行验证,验证成功则表示登录成功。

接下来我们开始按照步骤实现,得益于注册和登录的流程几乎一致,我们可以缩短这部分的讲解流程:

开始登录

首先前端发起请求:

const response = await fetch('/login/begin', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ username: loginUsername })
});

服务端负责提供 publicKey 信息:

func BeginLogin(c *gin.Context) {
    var req = BeginLoginReq{}
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(400, gin.H{"error": err.Error()})
        return
    }

    // 获取用户信息
    user, err := service.GetUser(req.Username)
    if err != nil {
        fmt.Println("[handler][BeginLogin] service.GetUser error", err)
        c.JSON(500, gin.H{"error": err.Error()})
        return
    }
    // 获取 publicKey 信息
    options, sessionData, err := authn.GetAuthn().BeginLogin(user)
    if err != nil {
        fmt.Println("[handler][BeginLogin] authn.BeginLogin error", err)
        c.JSON(500, gin.H{"error": err.Error()})
        return
    }

    // 暂存数据
    session := sessions.Default(c)
    if sessionDataStr, err := json.Marshal(&sessionData); err != nil {
        fmt.Println("[handler][BeginLogin] json.Marshal error", err)
        c.JSON(500, gin.H{"error": err.Error()})
        return
    } else {
        session.Set(LoginSessionDataKey, sessionDataStr)
    }
    if userStr, err := json.Marshal(&user); err != nil {
        fmt.Println("[handler][BeginRegister] json.Marshal error", err)
        c.JSON(500, gin.H{"error": err.Error()})
        return
    } else {
        session.Set(LoginUserTempDataKey, userStr)
    }
    if err = session.Save(); err != nil {
        fmt.Println("[handler][BeginLogin] session.Save error", err)
        c.JSON(500, gin.H{"error": err.Error()})
        return
    }
    c.JSON(200, gin.H{"success": true, "data": options})
}

其中唯一要关注的是 BeginLogin的实现:

// BeginLogin creates the *protocol.CredentialAssertion data payload that should be sent to the user agent for beginning
// the login/assertion process. The format of this data can be seen in §5.5 of the WebAuthn specification. These default
// values can be amended by providing additional LoginOption parameters. This function also returns sessionData, that
// must be stored by the RP in a secure manner and then provided to the FinishLogin function. This data helps us verify
// the ownership of the credential being retrieved.
//
// Specification: §5.5. Options for Assertion Generation (https://www.w3.org/TR/webauthn/#dictionary-assertion-options)
func (webauthn *WebAuthn) BeginLogin(user User, opts ...LoginOption) (*protocol.CredentialAssertion, *SessionData, error) {
    credentials := user.WebAuthnCredentials()

    if len(credentials) == 0 { // If the user does not have any credentials, we cannot perform an assertion.
        return nil, nil, protocol.ErrBadRequest.WithDetails("Found no credentials for user")
    }

    var allowedCredentials = make([]protocol.CredentialDescriptor, len(credentials))

    for i, credential := range credentials {
        allowedCredentials[i] = credential.Descriptor()
    }

    return webauthn.beginLogin(user.WebAuthnID(), allowedCredentials, opts...)
}

上文我们也已经介绍了 publicKey 参数的组成,我们获取到 user 对象之后他会拼装成对象返回,包括前端需要的参数。

完成登录

完成登录阶段需要前端拿着上面返回的参数调用 navigator.credentials.get

const publicKey = data.data.publicKey;
publicKey.challenge = bufferDecode(publicKey.challenge);
publicKey.allowCredentials.forEach(listItem => listItem.id = bufferDecode(listItem.id));

const assertion = await navigator.credentials.get({ publicKey });

const loginResponse = await fetch('/login/finish', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
        id: assertion.id,
        rawId: bufferEncode(assertion.rawId),
        type: assertion.type,
        response: {
            authenticatorData: bufferEncode(assertion.response.authenticatorData),
            clientDataJSON: bufferEncode(assertion.response.clientDataJSON),
            signature: bufferEncode(assertion.response.signature),
            userHandle: bufferEncode(assertion.response.userHandle),
        },
    })
});

然后服务端负责验证:


func FinishLogin(c *gin.Context) {
    var (
        sessionData    = webauthn.SessionData{}
        user           = models.User{}
        err            error
        userStr        []byte
        sessionDataStr []byte
        ok             bool
    )
    session := sessions.Default(c)
    if sessionDataStr, ok = session.Get(LoginSessionDataKey).([]byte); !ok {
        c.JSON(400, gin.H{"error": "login_data not found"})
        return
    } else {
        if err = json.Unmarshal(sessionDataStr, &sessionData); err != nil {
            fmt.Println("[handler][FinishLogin] json.Unmarshal error", err)
            c.JSON(500, gin.H{"error": err.Error()})
            return
        }
    }
    if userStr, ok = session.Get(LoginUserTempDataKey).([]byte); !ok {
        c.JSON(400, gin.H{"error": "temp_user not found"})
        return
    } else {
        if err = json.Unmarshal(userStr, &user); err != nil {
            fmt.Println("[handler][FinishLogin] json.Unmarshal error", err)
            c.JSON(500, gin.H{"error": err.Error()})
            return
        }
    }
    _, err = authn.GetAuthn().FinishLogin(&user, sessionData, c.Request)
    if err != nil {
        fmt.Println("[handler][FinishLogin] authn.FinishLogin error", err)
        c.JSON(500, gin.H{"error": err.Error()})
        return
    }
    session.Delete(LoginSessionDataKey)
    session.Delete(LoginUserTempDataKey)
    session.Set(UserKey, userStr)
    if err = session.Save(); err != nil {
        fmt.Println("[handler][FinishLogin] session.Save error", err)
        c.JSON(500, gin.H{"error": err.Error()})
        return
    }
    c.JSON(200, gin.H{"success": true, "data": user})
}

再熟悉不过了,核心点在于 FinishLogin,验证签名有效性后完成登录,这里就不概述了。

二步验证?

当然,真的跳过密码环节怎么想怎么别扭,除了一些跨设备的验证器以外,如果用的是 FaceID、指纹、PIN,总会有一个疑问:换设备怎么办?

因此现在这类技术更多的场景仍然是用于二步验证,此时就跟手机上的人脸解锁一样,用户名已知,只需要进行 WebAuthn 认证就行了,本身代码实现是一样的。

总结

这篇文章开头本来想写 Web Auth 的方方面面,结果发现麻了,然后又开始研究 WebAuthn。

另外再最后运行 Demo 期间发现 AdGuard 可能会拦截这类操作,如果遇到 Error 可以考虑关闭广告过滤。

如果需要 Demo 可以参考:https://github.com/hbolimovsky/webauthn-example

因为本人的 Demo 放在了混合服务中,就不展示了,核心代码基本上也算是带着大家做了一遍了。

植入部分

如果您觉得文章不错,可以通过赞助支持我。

如果您不希望打赏,也可以通过关闭广告屏蔽插件的形式帮助网站运作。

标签: none

添加新评论