WebAuthn Basic Web Client/Server
In this tutorial we’ll build a basic WebAuthn web client/server in go using Duo Labs’ awesome WebAuthn library. We’ll also briefly go over the WebAuthn API. All code for this tutorial can be found here. Note this tutorial is not meant to be used in production but more as a starting point to building a functioning WebAuthn server and client, and as an introduction to the WebAuthn API.
What Is WebAuthn
WebAuthn is a standard that enables passwordless authentication (aka login) in the browser (and beyond). It essentially defines a common API for web applications to handle passwordless technologies via FIDO2 which includes things like public key cryptography, security keys (i.e. Yubikey), and biometrics (i.e. Touch ID/Face ID, Windows Hello).
What You Need
- a WebAuthn compatible browser (list via Firefox and list via Duo)
- a security key such as a Yubikey OR a biometric enabled device such as a MacBook with Touch ID or a Microsoft Surface Book with Windows Hello
Resources
Incase you want to nerd out, here are some great WebAuthn resources
- overview (high level, with code): Duo, Auth0, Firefox
- demo: Duo, Auth0
- official spec
- detailed blog post by Adam Langley
Code
Now for the fun part! Let’s code…
Simple Client
Let’s start with a super simple index.html
that takes a username and performs Register and Login actions.
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>WebAuthn Demo</title>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.4.0/jquery.min.js"></script>
</head>
<body>
Username:
<br>
<input type="text" name="username" id="email" placeholder="i.e. foo@bar.com">
<br>
<br>
<button onclick="registerUser()">Register</button>
<button onclick="loginUser()">Login</button>
<script>
$(document).ready(function () {
// TODO
});
</script>
</body>
</html>
index.html
should look something like this (let’s call this style ‘minimalist’ 🙃)
Browser Support Check
Before we do anything let’s make sure the browser we’re using supports WebAuthn. Inside the $(document).ready
function add
$(document).ready(function () {
// check whether current browser supports WebAuthn
if (!window.PublicKeyCredential) {
alert("Error: this browser does not support WebAuthn");
return;
}
});
Register
At a high level, WebAuthn registration involves an authenticator generating a public/private key pair that is bound to information provided by the server (i.e. user info, organization info). An authenticator is an entity - could be a physical separate security key like a Yubikey, could be built into a device like Touch ID/Face ID/Windows Hello, could be an app on a phone - that performs cryptographic operations such as key generation and signing (technically authenticators do a little more than just crypto).
Begin Register
Part 1
All registration code will live in the registerUser()
function (inside the <script>
tag). First we’ll get the username from the text input field. Then to kick off the registration process, we’ll make a GET
call to /register/begin/{username}
. This will return some information required to generate the public/private key pair.
function registerUser() {
username = $("#email").val()
if (username === "") {
alert("please enter a username");
return;
}
$.get(
'/register/begin/' + username,
null,
function (data) {
return data
},
'json')
.then((credentialCreationOptions) => {
// TODO
});
}
Now let’s implement the server portion of Begin Register.
Create a new file called server.go
(I put all the files in the root directory for simplicity/laziness) with the following:
package main
import (
"log"
"net/http"
"github.com/duo-labs/webauthn.io/session"
"github.com/duo-labs/webauthn/webauthn"
"github.com/gorilla/mux"
)
var webAuthn *webauthn.WebAuthn
var sessionStore *session.Store
var userDB *userdb
func main() {
var err error
webAuthn, err = webauthn.New(&webauthn.Config{
RPDisplayName: "Foobar Corp.", // display name for your site
RPID: "localhost", // generally the domain name for your site
})
if err != nil {
log.Fatal("failed to create WebAuthn from config:", err)
}
userDB = DB()
sessionStore, err = session.NewStore()
if err != nil {
log.Fatal("failed to create session store:", err)
}
r := mux.NewRouter()
r.HandleFunc("/register/begin/{username}", BeginRegistration).Methods("GET")
r.PathPrefix("/").Handler(http.FileServer(http.Dir("./")))
serverAddress := ":8080"
log.Println("starting server at", serverAddress)
log.Fatal(http.ListenAndServe(serverAddress, r))
}
Let’s break down what we just added.
webAuthn (type webauthn.WebAuthn
)
The primary interface that the github.com/duo-labs/webauthn
package exposes. To initialize it, we provide a config with two fields (there are more optional fields), all related to the Relying Party (RP) which is the web application/entity that is registering and authenticating the user. The fields are RPDisplayName: the display name for the RP’s site (i.e. Foobar Corp.) and RPID: based on the RP’s domain name (i.e. foobar.com).
sessionStore (type *session.Store
)
A nice wrapper around a cookie store via github.com/duo-labs/webauthn.io
userDB (type *userdb
)
Provides a mock database that stores and retrieves users by their username. Add the following userdb.go
file
package main
import (
"fmt"
"sync"
)
type userdb struct {
users map[string]*User
mu sync.RWMutex
}
var db *userdb
// DB returns a userdb singleton
func DB() *userdb {
if db == nil {
db = &userdb{
users: make(map[string]*User),
}
}
return db
}
// GetUser returns a *User by the user's username
func (db *userdb) GetUser(name string) (*User, error) {
db.mu.Lock()
defer db.mu.Unlock()
user, ok := db.users[name]
if !ok {
return &User{}, fmt.Errorf("error getting user '%s': does not exist", name)
}
return user, nil
}
// PutUser stores a new user by the user's username
func (db *userdb) PutUser(user *User) {
db.mu.Lock()
defer db.mu.Unlock()
db.users[user.name] = user
}
You’ll notice reference to User
, which is what github.com/duo-labs/webauthn
uses to interact with WebAuthn users. To implement User
, we need to implement duo-lab’s user interface. In a new user.go
file, add
package main
import (
"crypto/rand"
"encoding/binary"
"github.com/duo-labs/webauthn/protocol"
"github.com/duo-labs/webauthn/webauthn"
)
// User represents the user model
type User struct {
id uint64
name string
displayName string
credentials []webauthn.Credential
}
// NewUser creates and returns a new User
func NewUser(name string, displayName string) *User {
user := &User{}
user.id = randomUint64()
user.name = name
user.displayName = displayName
// user.credentials = []webauthn.Credential{}
return user
}
func randomUint64() uint64 {
buf := make([]byte, 8)
rand.Read(buf)
return binary.LittleEndian.Uint64(buf)
}
// WebAuthnID returns the user's ID
func (u User) WebAuthnID() []byte {
buf := make([]byte, binary.MaxVarintLen64)
binary.PutUvarint(buf, uint64(u.id))
return buf
}
// WebAuthnName returns the user's username
func (u User) WebAuthnName() string {
return u.name
}
// WebAuthnDisplayName returns the user's display name
func (u User) WebAuthnDisplayName() string {
return u.displayName
}
// WebAuthnIcon is not (yet) implemented
func (u User) WebAuthnIcon() string {
return ""
}
// AddCredential associates the credential to the user
func (u *User) AddCredential(cred webauthn.Credential) {
u.credentials = append(u.credentials, cred)
}
// WebAuthnCredentials returns credentials owned by the user
func (u User) WebAuthnCredentials() []webauthn.Credential {
return u.credentials
}
Now back to server.go
. Let’s implement the BeginRegistration
handler. We’ll start by pulling the username
path variable from the request
func BeginRegistration(w http.ResponseWriter, r *http.Request) {
// get username
vars := mux.Vars(r)
username, ok := vars["username"]
if !ok {
jsonResponse(w, fmt.Errorf("must supply a valid username i.e. foo@bar.com"), http.StatusBadRequest)
return
}
}
Also add the jsonResponse()
helper method (from duo-labs/webauthn.io)
func jsonResponse(w http.ResponseWriter, d interface{}, c int) {
dj, err := json.Marshal(d)
if err != nil {
http.Error(w, "Error creating JSON response", http.StatusInternalServerError)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(c)
fmt.Fprintf(w, "%s", dj)
}
Now let’s attempt to get the user and create a new user if they don’t exist. It might seem weird to check for an existing user, but remember we’re registering an authenticator to a user who can exist already. For example, Bob can register a MacBook to his account, then register a Yubikey to that same account. Below the code we just added, add
// get user
user, err := userDB.GetUser(username)
// user doesn't exist, create new user
if err != nil {
displayName := strings.Split(username, "@")[0]
user = NewUser(username, displayName)
userDB.PutUser(user)
}
Note how we’re using the email prefix as the display name - this is out of convenience (and laziness). duo-labs/webauthn
has a high level function BeginRegistration()
that handles - you guessed it - the beginning of the registration process.
// generate PublicKeyCredentialCreationOptions, session data
options, sessionData, err := webAuthn.BeginRegistration(user)
if err != nil {
log.Println(err)
jsonResponse(w, err.Error(), http.StatusInternalServerError)
return
}
Let’s break this down
options (type protocol.CredentialCreation
)
An implementation of PublicKeyCredentialCreationOptions
which contains information necessary for the authenticator to generate the public/private key pair such as Relying Party info (i.e. what we set in webauthn.Config
), user info (i.e. a unique user handle ID), and a randomly generated challenge (to be signed by the authenticator)*.
sessionData (type webauthn.SessionData
)
Contains information required to verify the signed challenge, like the challenge itself as well as the user’s id.
Putting it all together, BeginRegistration()
looks like this
func BeginRegistration(w http.ResponseWriter, r *http.Request) {
// get username
vars := mux.Vars(r)
username, ok := vars["username"]
if !ok {
jsonResponse(w, fmt.Errorf("must supply a valid username i.e. foo@bar.com"), http.StatusBadRequest)
return
}
// get user
user, err := userDB.GetUser(username)
// user doesn't exist, create new user
if err != nil {
displayName := strings.Split(username, "@")[0]
user = NewUser(username, displayName)
userDB.PutUser(user)
}
// generate PublicKeyCredentialCreationOptions, session data
options, sessionData, err := webAuthn.BeginRegistration(
user,
)
if err != nil {
log.Println(err)
jsonResponse(w, err.Error(), http.StatusInternalServerError)
return
}
// store session data as marshaled JSON
err = sessionStore.SaveWebauthnSession("registration", sessionData, r, w)
if err != nil {
log.Println(err)
jsonResponse(w, err.Error(), http.StatusInternalServerError)
return
}
jsonResponse(w, options, http.StatusOK)
}
Now back to the JS client, in registerUser()
the credentialCreationOptions
that gets returned looks like this
{
"publicKey": {
"challenge": "FsxBWwUb1jOFRA3ILdkCsPdCZkzohvd3JrCNeDqWpJQ=",
"rp": {
"name": "Foobar Corp.",
"id": "localhost"
},
"user": {
"name": "foo@bar.com",
"displayName": "foo",
"id": "xOywiL6f3q9EAA=="
},
"pubKeyCredParams": [
{
"type": "public-key",
"alg": -7
},
{
"type": "public-key",
"alg": -35
},
...
{
"type": "public-key",
"alg": -8
}
],
"authenticatorSelection": {
"userVerification": "preferred"
},
"timeout": 60000,
"attestation": "direct"
}
}
The publicKey (type PublicKeyCredentialCreationOptions
) contains a lot of what we set, like the relying party (rp
field), user (user
field), and challenge (challenge
field). github.com/duo-labs/webauthn
autoset some properties we don’t really have to worry about.
Part 2
Back with registerUser()
in index.html
, to finish the Begin Register process, we call navigator.credentials.create()
(part of the Credential Management API) with credentialCreationOptions.publicKey
.then((credentialCreationOptions) => {
credentialCreationOptions.publicKey.challenge = bufferDecode(credentialCreationOptions.publicKey.challenge);
credentialCreationOptions.publicKey.user.id = bufferDecode(credentialCreationOptions.publicKey.user.id);
return navigator.credentials.create({
publicKey: credentialCreationOptions.publicKey
})
})
.then((credential) => {
// TODO
})
To help us convert base64 encoded data to an ArrayBuffer
add
// Base64 to ArrayBuffer
function bufferDecode(value) {
return Uint8Array.from(atob(value), c => c.charCodeAt(0));
}
Part 3
Your browser and authenticator take over from here. Implementations vary, for example your browser might ask you to choose an authenticator, then your authenticator could prompt you to perform a gesture (i.e. press button, scan finger).
Finish Register
Part 4
If all goes well the authenticator/browser will return an AuthenticatorAttestationResponse
that looks like this
{
"id": "ABJcniVJwrH45aueEJJsD0LFTGMUxot1sHCOttym_p7rNPF72Zc_NcGo05j3KzDkU5fWELqRz7h9",
"rawId": "ABJcniVJwrH45aueEJJsD0LFTGMUxot1sHCOttym_p7rNPF72Zc_NcGo05j3KzDkU5fWELqRz7h9",
"type": "public-key",
"response": {
"attestationObject": "o2NmbXRmcGFja2VkZ2F0dFN0bXSiY2FsZyZjc2lnWEcwRQIhAIMEvEj1LKkmGNQuL0zeiv7dAzQmZynWkIuzuoGKAus8AiBUQBxCjGwCenhRWbJut2PyeF_7A3FJ3Jx8PVDOhuqUFGhhdXRoRGF0YVi9SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NFXMPTf63OAAI1vMYKZIsLJfHwVQMAOQASXJ4lScKx-OWrnhCSbA9CxUxjFMaLdbBwjrbcpv6e6zTxe9mXPzXBqNOY9ysw5FOX1hC6kc-4faUBAgMmIAEhWCC6NdvZp8idlfpaTuREhZ3YHOx2QNam7HQI5-uDx1JFRiJYIKe0q8Ax92EtplrfOtvfIxtOt7_XEvHYRQy16O-MpDjk",
"clientDataJSON": "eyJjaGFsbGVuZ2UiOiJGc3hCV3dVYjFqT0ZSQTNJTGRrQ3NQZENaa3pvaHZkM0pyQ05lRHFXcEpRIiwib3JpZ2luIjoiaHR0cDovL2xvY2FsaG9zdDo4MDgwIiwidHlwZSI6IndlYmF1dGhuLmNyZWF0ZSJ9"
}
}
The AuthenticatorAttestationResponse
contains attestation information such as the newly generated public key, the attestation response, as well as information necessary for the server to verify the attestation/response (i.e. user id, client data).
Part 5
As our last step in the registration process we return the AuthenticatorAttestationResponse
to the server by performing a POST
to /register/finish/{username}
.then((credential) => {
let attestationObject = credential.response.attestationObject;
let clientDataJSON = credential.response.clientDataJSON;
let rawId = credential.rawId;
$.post(
'/register/finish/' + username,
JSON.stringify({
id: credential.id,
rawId: bufferEncode(rawId),
type: credential.type,
response: {
attestationObject: bufferEncode(attestationObject),
clientDataJSON: bufferEncode(clientDataJSON),
},
}),
function (data) {
return data
},
'json')
})
We need to add a helper function bufferEncode()
to convert ArrayBuffers to URLBase64
// ArrayBuffer to URLBase64
function bufferEncode(value) {
return btoa(String.fromCharCode.apply(null, new Uint8Array(value)))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");;
}
Adding some success/fail handling and putting it all together, registerUser()
looks like this
function registerUser() {
username = $("#email").val()
if (username === "") {
alert("please enter a username");
return;
}
$.get(
'/register/begin/' + username,
null,
function (data) {
return data
},
'json')
.then((credentialCreationOptions) => {
credentialCreationOptions.publicKey.challenge = bufferDecode(credentialCreationOptions.publicKey.challenge);
credentialCreationOptions.publicKey.user.id = bufferDecode(credentialCreationOptions.publicKey.user.id);
return navigator.credentials.create({
publicKey: credentialCreationOptions.publicKey
})
})
.then((credential) => {
let attestationObject = credential.response.attestationObject;
let clientDataJSON = credential.response.clientDataJSON;
let rawId = credential.rawId;
$.post(
'/register/finish/' + username,
JSON.stringify({
id: credential.id,
rawId: bufferEncode(rawId),
type: credential.type,
response: {
attestationObject: bufferEncode(attestationObject),
clientDataJSON: bufferEncode(clientDataJSON),
},
}),
function (data) {
return data
},
'json')
})
.then((success) => {
alert("successfully registered " + username + "!")
return
})
.catch((error) => {
console.log(error)
alert("failed to register " + username)
})
}
Part 6
The server side portion of registering a new credential involves 19 steps! Luckily github.com/duo-labs/webauthn
exposes a simple FinishRegistration()
function. Briefly, these steps include validating client data (i.e. domain name), authenticator data, and the attestation statement (i.e. the signature). FinishRegistration()
takes the user, sessionData (what we stored in BeginRegister()
), and the *http.Request
, and returns a webauthn.Credential
if everything goes well (and an error
if it doesn’t). Finally we’ll store the credential with the user. To actually implement this server side, add a handler in main()
(inside server.go
)
r := mux.NewRouter()
r.HandleFunc("/register/begin/{username}", BeginRegistration).Methods("GET")
r.HandleFunc("/register/finish/{username}", FinishRegistration).Methods("POST")
r.PathPrefix("/").Handler(http.FileServer(http.Dir("./")))
and implement the function FinishRegistration()
func FinishRegistration(w http.ResponseWriter, r *http.Request) {
// get username
vars := mux.Vars(r)
username := vars["username"]
// get user
user, err := userDB.GetUser(username)
// user doesn't exist
if err != nil {
log.Println(err)
jsonResponse(w, err.Error(), http.StatusBadRequest)
return
}
// load the session data
sessionData, err := sessionStore.GetWebauthnSession("registration", r)
if err != nil {
log.Println(err)
jsonResponse(w, err.Error(), http.StatusBadRequest)
return
}
credential, err := webAuthn.FinishRegistration(user, sessionData, r)
if err != nil {
log.Println(err)
jsonResponse(w, err.Error(), http.StatusBadRequest)
return
}
user.AddCredential(*credential)
jsonResponse(w, "Registration Success", http.StatusOK)
return
}
And just like that, we finished implementing WebAuthn registration! Here’s roughly what we just built looks like in Chrome
Login
At a high level, WebAuthn login (aka authentication) involves an authenticator signing a challenge provided by the server using the previously generated private key.
Begin Login
Part 1
Let’s start with loginUser()
in index.html
by getting the username and making a GET
call to /login/begin/{username}
function loginUser() {
username = $("#email").val()
if (username === "") {
alert("please enter a username");
return;
}
$.get(
'/login/begin/' + username,
null,
function (data) {
return data
},
'json')
.then((credentialRequestOptions) => {
// TODO
})
}
In server.go
, add a Begin Login handler
r.HandleFunc("/register/begin/{username}", BeginRegistration).Methods("GET")
r.HandleFunc("/register/finish/{username}", FinishRegistration).Methods("POST")
r.HandleFunc("/login/begin/{username}", BeginLogin).Methods("GET")
Begin Login is very similar to Begin Register. First we call duo-labs/webAuthn
s BeginLogin()
with a user (and optional LoginOption
s) and get returned options (implementation of PublicKeyCredentialRequestOptions
discussed below), sessionData (used to verify the returned assertion), and an error if anything goes wrong. We store the session data and return the options to the user
func BeginLogin(w http.ResponseWriter, r *http.Request) {
// get username
vars := mux.Vars(r)
username := vars["username"]
// get user
user, err := userDB.GetUser(username)
// user doesn't exist
if err != nil {
log.Println(err)
jsonResponse(w, err.Error(), http.StatusBadRequest)
return
}
// generate PublicKeyCredentialRequestOptions, session data
options, sessionData, err := webAuthn.BeginLogin(user)
if err != nil {
log.Println(err)
jsonResponse(w, err.Error(), http.StatusInternalServerError)
return
}
// store session data as marshaled JSON
err = sessionStore.SaveWebauthnSession("authentication", sessionData, r, w)
if err != nil {
log.Println(err)
jsonResponse(w, err.Error(), http.StatusInternalServerError)
return
}
jsonResponse(w, options, http.StatusOK)
}
The credentialRequestOptions (type PublicKeyCredentialRequestOptions
) only requires a challenge
but has additional option fields, and looks like this
{
"credentialRequestOptions": {
"publicKey": {
"challenge": "AZeYdiLc1hGDU0wo9NOZmZG9vu+aPnff2aPFgJEw1HI=",
"timeout": 60000,
"rpId": "localhost",
"allowCredentials": [
{
"type": "public-key",
"id": "ABJcniVJwrH45aueEJJsD0LFTGMUxot1sHCOttym/p7rNPF72Zc/NcGo05j3KzDkU5fWELqRz7h9"
}
]
}
}
}
Important fields to take note of are challenge
, random bits (different than the challenge in Register) that the authenticator will sign over using the private key created in the Register process, and allowCredentials
which specifies which public key credentials are acceptable (i.e. previously registered ones).
Part 2
To finish the Begin Login process, we call navigator.credentials.get()
(part of the Credential Management API) with credentialCreationOptions.publicKey (remember to decode encoded fields into ArrayBuffers), and the browser takes care of the rest. Back in index.html
inside loginUser()
.then((credentialRequestOptions) => {
credentialRequestOptions.publicKey.challenge = bufferDecode(credentialRequestOptions.publicKey.challenge);
credentialRequestOptions.publicKey.allowCredentials.forEach(function (listItem) {
listItem.id = bufferDecode(listItem.id)
});
return navigator.credentials.get({
publicKey: credentialRequestOptions.publicKey
})
})
.then((assertion) => {
// TODO
})
Part 3
Your browser and authenticator take over from here. Implementations vary, for example your authenticator could prompt you to perform a gesture (i.e. press button, scan finger).
Finish Login
Part 4
If all goes well the browser will return an AuthenticatorAssertionResponse
which looks like this
{
"id": "ABJcniVJwrH45aueEJJsD0LFTGMUxot1sHCOttym_p7rNPF72Zc_NcGo05j3KzDkU5fWELqRz7h9",
"rawId": "ABJcniVJwrH45aueEJJsD0LFTGMUxot1sHCOttym_p7rNPF72Zc_NcGo05j3KzDkU5fWELqRz7h9",
"type": "public-key",
"response": {
"authenticatorData": "SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NFXMPTha3OAAI1vMYKZIsLJfHwVQMAOQASXJ4lScKx-OWrnhCSbA9CxUxjFMaLdbBwjrbcpv6e6zTxe9mXPzXBqNOY9ysw5FOX1hC6kc-4faUBAgMmIAEhWCC6NdvZp8idlfpaTuREhZ3YHOx2QNam7HQI5-uDx1JFRiJYIKe0q8Ax92EtplrfOtvfIxtOt7_XEvHYRQy16O-MpDjk",
"clientDataJSON": "eyJjaGFsbGVuZ2UiOiJBWmVZZGlMYzFoR0RVMHdvOU5PWm1aRzl2dS1hUG5mZjJhUEZnSkV3MUhJIiwib3JpZ2luIjoiaHR0cDovL2xvY2FsaG9zdDo4MDgwIiwidHlwZSI6IndlYmF1dGhuLmdldCJ9",
"signature": "MEQCIFq-Q4v8DK89OUAyyOowjKMc6umCNLaQR1ry8obvFtRAAiBRf5c1ufdFR9w-nTsLm5NG_KtycZ0bWFLj5h-uRiYLCw",
"userHandle": "xOywiL6f3q9EAA"
}
}
Let’s break some of this down.
signature
Raw signature from the authenticator (using the registered private key) over the challenge.
userHandle
User id supplied by the Relying Party (same value as user.id
from the credentialCreationOptions
example in Begin Register - Part 1).
The rest should look familiar from the AuthenticatorAttestationResponse
in Finish Register - Part 4.
Part 5
To finalize Finish Login, we need to base64 encode various ArrayBuffers, then send the AuthenticatorAssertionResponse
back to the server via a POST
to /login/finish/{username}
.then((assertion) => {
let authData = assertion.response.authenticatorData;
let clientDataJSON = assertion.response.clientDataJSON;
let rawId = assertion.rawId;
let sig = assertion.response.signature;
let userHandle = assertion.response.userHandle;
$.post(
'/login/finish/' + username,
JSON.stringify({
id: assertion.id,
rawId: bufferEncode(rawId),
type: assertion.type,
response: {
authenticatorData: bufferEncode(authData),
clientDataJSON: bufferEncode(clientDataJSON),
signature: bufferEncode(sig),
userHandle: bufferEncode(userHandle),
},
}),
function (data) {
return data
},
'json')
})
Adding some success/fail handling and putting it all together, loginUser()
looks like this
function loginUser() {
username = $("#email").val()
if (username === "") {
alert("please enter a username");
return;
}
$.get(
'/login/begin/' + username,
null,
function (data) {
return data
},
'json')
.then((credentialRequestOptions) => {
credentialRequestOptions.publicKey.challenge = bufferDecode(credentialRequestOptions.publicKey.challenge);
credentialRequestOptions.publicKey.allowCredentials.forEach(function (listItem) {
listItem.id = bufferDecode(listItem.id)
});
return navigator.credentials.get({
publicKey: credentialRequestOptions.publicKey
})
})
.then((assertion) => {
let authData = assertion.response.authenticatorData;
let clientDataJSON = assertion.response.clientDataJSON;
let rawId = assertion.rawId;
let sig = assertion.response.signature;
let userHandle = assertion.response.userHandle;
$.post(
'/login/finish/' + username,
JSON.stringify({
id: assertion.id,
rawId: bufferEncode(rawId),
type: assertion.type,
response: {
authenticatorData: bufferEncode(authData),
clientDataJSON: bufferEncode(clientDataJSON),
signature: bufferEncode(sig),
userHandle: bufferEncode(userHandle),
},
}),
function (data) {
return data
},
'json')
})
.then((success) => {
alert("successfully logged in " + username + "!")
return
})
.catch((error) => {
console.log(error)
alert("failed to register " + username)
})
}
Part 6
Finish Login - Part 6 is almost identical code wise to Finish Register - Part 6 except instead we call webAuthn.FinishLogin()
The server side portion of verifying a login assertion involves 18 steps! Again, luckily we can take advantage of duo-labs/webauthn
’s simple FinishLogin()
function. Briefly, these steps include looking up the user’s public key, validating client data (i.e. domain name, challenge), and validating the signature. Back in server.go
func FinishLogin(w http.ResponseWriter, r *http.Request) {
// get username
vars := mux.Vars(r)
username := vars["username"]
// get user
user, err := userDB.GetUser(username)
// user doesn't exist
if err != nil {
log.Println(err)
jsonResponse(w, err.Error(), http.StatusBadRequest)
return
}
// load the session data
sessionData, err := sessionStore.GetWebauthnSession("authentication", r)
if err != nil {
log.Println(err)
jsonResponse(w, err.Error(), http.StatusBadRequest)
return
}
// in an actual implementation we should perform additional
// checks on the returned 'credential'
_, err = webAuthn.FinishLogin(user, sessionData, r)
if err != nil {
log.Println(err)
jsonResponse(w, err.Error(), http.StatusBadRequest)
return
}
// handle successful login
jsonResponse(w, "Login Success", http.StatusOK)
}
Also make sure to add the handlers in main()
r.HandleFunc("/register/begin/{username}", BeginRegistration).Methods("GET")
r.HandleFunc("/register/finish/{username}", FinishRegistration).Methods("POST")
r.HandleFunc("/login/begin/{username}", BeginLogin).Methods("GET")
r.HandleFunc("/login/finish/{username}", FinishLogin).Methods("POST")
We just successfully finished WebAuthn login! Here’s roughly what we just built looks like in Chrome
More
If we want to dive a little deeper, there are a few things we can do to touch up our implementation.
Exclude Credentials
If you play around and hit register
multiple times using the same username + authenticator pair (i.e. foo@bar.com and a MacBook), you’ll notice that multiple keys get registered! This is not ideal behavior since we only really need one key per user/authenticator for a relying party. In PublicKeyCredentialCreationOptions
we can fill an optional list excludeCredentialDescriptorList
with credentials (IDs and types). The browser can then use this list to block the creation of credentials that already exist on the authenticator for that user. To implement this, add the following function to user.go
// CredentialExcludeList returns a CredentialDescriptor array filled
// with all a user's credentials
func (u User) CredentialExcludeList() []protocol.CredentialDescriptor {
credentialExcludeList := []protocol.CredentialDescriptor{}
for _, cred := range u.credentials {
descriptor := protocol.CredentialDescriptor{
Type: protocol.PublicKeyCredentialType,
CredentialID: cred.ID,
}
credentialExcludeList = append(credentialExcludeList, descriptor)
}
return credentialExcludeList
}
Next, in server.go
inside BeginRegistration()
, add the following
registerOptions := func(credCreationOpts *protocol.PublicKeyCredentialCreationOptions) {
credCreationOpts.CredentialExcludeList = user.CredentialExcludeList()
}
options, sessionData, err := webAuthn.BeginRegistration(
user,
registerOptions,
)
Finally, add this code inside registerUser()
in index.html
.then((credentialCreationOptions) => {
credentialCreationOptions.publicKey.challenge = bufferDecode(credentialCreationOptions.publicKey.challenge);
credentialCreationOptions.publicKey.user.id = bufferDecode(credentialCreationOptions.publicKey.user.id);
if (credentialCreationOptions.publicKey.excludeCredentials) {
for (var i = 0; i < credentialCreationOptions.publicKey.excludeCredentials.length; i++) {
credentialCreationOptions.publicKey.excludeCredentials[i].id = bufferDecode(credentialCreationOptions.publicKey.excludeCredentials[i].id);
}
}
return navigator.credentials.create({
publicKey: credentialCreationOptions.publicKey
})
})
Now if you try to register twice, you’ll see an error like this
Signature Counter
In an actual implementation, we would verify the sign counter of the credential returned in the AuthenitcatorAttestationResponse
(from Finish Login) is greater than the sign counter we have stored for that credential. This is to check for cloned authenticators. Here’s an example implementation, and below is where it’s mentioned in the spec
- https://www.w3.org/TR/webauthn/#sign-counter
- https://www.w3.org/TR/webauthn/#op-get-assertion (step 9)
- https://www.w3.org/TR/webauthn/#verifying-assertion (step 17)
Properly Storing Users
For simplicity sake we stored/retrieved users via their username in userdb
. To do this properly you should store/retrieve users via the PublicKeyCredential
’s id
or rawID
as per the spec’s verifying assertion step 3. For reference implementation, view
- GetAssertion()/MakeAssertion() in assertion.go (from
github.com/duo-labs/webauthn.io
) - RequestNewCredential()/MakeNewCredential() in credential.go (from
github.com/duo-labs/webauthn.io
)
Assessing Attestation Statements
As per the specs registering a new credential steps 15 and 16, inside FinishRegister()
we should analyze, assess, and verify an attestation statement against some policy. For example, we can weigh trust by which attestation type (i.e. none, self, basic) was used, as well as which trust anchor was used. For more on trust anchors take a look at the FIDO Metadata Service.
Credential Exists Check
According to the specs registering a new credential step 17 inside FinishRegister()
we should check that the credential’s credentialId
isn’t registered yet to any other user. If it is we could decide to fail the registration, or we could do something like accept the registration and delete the old credential/user association.
Notes
* technically there’s an attestation option ‘none’ (see here) where the challenge isn’t actually signed