Skip to content

Commit

Permalink
feat(WebAuthn): allow multiple credentials per user
Browse files Browse the repository at this point in the history
This change allows users to associate multiple credentials to their
account. When performing assertions, all the credentials are sent on the
`allowList`. When registering a new credential, existing credentials are
sent on the `excludeList`.

Updated the example to show all the credentials associated to a user.

This change is not backwards compatible.
  • Loading branch information
nsatragno committed Jan 27, 2020
1 parent d8d63d2 commit 98c0782
Show file tree
Hide file tree
Showing 7 changed files with 71 additions and 64 deletions.
3 changes: 3 additions & 0 deletions client/Client.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ class Client {
static preformatMakeCredReq (makeCredReq) {
makeCredReq.challenge = base64url.decode(makeCredReq.challenge)
makeCredReq.user.id = base64url.decode(makeCredReq.user.id)
for (let excludeCred of makeCredReq.excludeCredentials) {
excludeCred.id = base64url.decode(excludeCred.id)
}
return makeCredReq
}

Expand Down
6 changes: 2 additions & 4 deletions example/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,8 @@ const webauthn = new Webauthn({
app.use('/webauthn', webauthn.initialize())

// Endpoint without passport
app.get('/authenticators', webauthn.authenticate(), async (req, res) => {
res.status(200).json([
await webauthn.store.get(req.session.username)
].map(user => user.authenticator))
app.get('/credentials', webauthn.authenticate(), async (req, res) => {
res.status(200).json((await webauthn.store.get(req.session.username)).credentials)
})

// Debug
Expand Down
22 changes: 0 additions & 22 deletions example/src/AuthenticatorCard.js

This file was deleted.

22 changes: 22 additions & 0 deletions example/src/CredentialCard.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import React from 'react'
import { Card } from 'react-bootstrap'
import 'bootstrap/dist/css/bootstrap.min.css'

class CredentialCard extends React.Component {
render () {
return (
<Card>
<Card.Body>
<Card.Title>{this.props.credential.credID}</Card.Title>
<Card.Text>
<p><strong>Format: </strong> {this.props.credential.fmt}</p>
<p><strong>Counter: </strong> {this.props.credential.counter}</p>
<p><strong>Public key: </strong> {this.props.credential.publicKey}</p>
</Card.Text>
</Card.Body>
</Card>
)
}
}

export default CredentialCard;
22 changes: 12 additions & 10 deletions example/src/User.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React from 'react'
import { Container, Row, Col, Button } from 'react-bootstrap'
import AuthenticatorCard from './AuthenticatorCard'
import { Container, Row, Col, Button, CardColumns } from 'react-bootstrap'
import CredentialCard from './CredentialCard'
import Client from 'webauthn/client'
import 'bootstrap/dist/css/bootstrap.min.css'

Expand All @@ -9,10 +9,10 @@ class User extends React.Component {
super(props)

this.state = {
authenticators: []
credentials: []
}

fetch('authenticators', {
fetch('credentials', {
method: 'GET',
credentials: 'include',
}).then(response => {
Expand All @@ -21,8 +21,8 @@ class User extends React.Component {
return
}
return response.json()
}).then(authenticators => {
this.setState({ authenticators })
}).then(credentials => {
this.setState({ credentials })
})
}

Expand All @@ -36,15 +36,17 @@ class User extends React.Component {
<Row style={{ paddingTop: 80}}>
<Col>
<h2>Welcome {this.props.user.username}</h2>
<h3>Your authenticators:</h3>
<h3>Your credentials:</h3>
</Col>
<Col className="text-right">
<Button variant="primary" onClick={this.logout}>Log Out</Button>
</Col>
</Row>
{this.state.authenticators.map(authenticator => <Row key={authenticator.credID}>
<Col><AuthenticatorCard authenticator={authenticator} /></Col>
</Row>)}
<CardColumns>
{this.state.credentials.map(credential =>
<Col key={credential.credID}><CredentialCard credential={credential} /></Col>
)}
</CardColumns>
</Container>
)
}
Expand Down
8 changes: 6 additions & 2 deletions src/AttestationChallengeBuilder.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,11 +96,15 @@ class AttestationChallengeBuilder {
}

if (Array.isArray(options)) {
options.forEach(option => this.addCredentialRequest(option))
options.forEach(option => this.addCredentialExclusion(option))
return this
}

const { type, id, transports = [] } = options
const {
id,
type = PublicKeyCredentialType.PUBLIC_KEY,
transports = Object.values(AuthenticatorTransport),
} = options

if (
!type
Expand Down
52 changes: 26 additions & 26 deletions src/Webauthn.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,32 +95,27 @@ class Webauthn {
return res.status(400).json({ message: 'bad request' })
}

const user = {
id: base64url(crypto.randomBytes(32)),
[usernameField]: username,
}

Object.entries(this.config.userFields).forEach(([bodyKey, dbKey]) => {
user[dbKey] = req.body[bodyKey]
})

const existing = await this.store.get(username)
if (existing && existing.authenticator) {
return res.status(403).json({
'status': 'failed',
'message': `${usernameField} ${username} already exists`,
let user = await this.store.get(username)
if (!user) {
user = {
id: base64url(crypto.randomBytes(32)),
[usernameField]: username,
credentials: [],
}
Object.entries(this.config.userFields).forEach(([bodyKey, dbKey]) => {
user[dbKey] = req.body[bodyKey]
})
if (this.config.enableLogging) console.log('PUT', user)
await this.store.put(username, user)
if (this.config.enableLogging) console.log('STORED')
}

if (this.config.enableLogging) console.log('PUT', user)
await this.store.put(username, user)

if (this.config.enableLogging) console.log('STORED')

const attestation = new AttestationChallengeBuilder(this)
.setUserInfo(user)
.setAttestationType(this.config.attestation)
.setAuthenticator(this.config.authenticator)
.addCredentialExclusion(
user.credentials.map(credential => ({ id: credential.credID })))
.setRelyingPartyInfo({ name: this.config.rpName || options.rpName })
.build({ status: 'ok' })

Expand Down Expand Up @@ -160,15 +155,16 @@ class Webauthn {
})
}

if (!user.authenticator) {
if (user.credentials.length === 0) {
return res.status(401).json({
message: 'user has not registered an authenticator',
})
}

const assertion = new AssertionChallengeBuilder(this)
.addAllowedCredential({ id: user.authenticator.credID })
.build({ status: 'ok' })
.addAllowedCredential(
user.credentials.map(credential => ({ id: credential.credID })))
.build({ status: 'ok' })

req.session.challenge = assertion.challenge
req.session[usernameField] = username
Expand Down Expand Up @@ -256,18 +252,22 @@ class Webauthn {
result = this.verifyAuthenticatorAttestationResponse(response);

if (result.verified) {
user.authenticator = result.authrInfo
user.credentials.push(result.authrInfo)
await this.store.put(username, user)
}

} else if (response.authenticatorData !== undefined) {
result = Webauthn.verifyAuthenticatorAssertionResponse(response, user.authenticator, this.config.enableLogging)
let authenticator =
user.credentials.find(credential => credential.credID === id)

result = Webauthn.verifyAuthenticatorAssertionResponse(
response, authenticator, this.config.enableLogging)

if (result.verified) {
if (result.counter <= user.authenticator.counter)
if (result.counter <= authenticator.counter)
throw new Error('Authr counter did not increase!')

user.authenticator.counter = result.counter
authenticator.counter = result.counter
await this.store.put(username, user)
}

Expand Down

0 comments on commit 98c0782

Please sign in to comment.