Skip to content

Commit 14bb773

Browse files
authored
Have the library handle challenge management (#35)
The examples so far have all used sessions to manage the active challenges, but not all applications are stateful in this way - namely, most APIs will not be session-based. Instead, this creates a new `ChallengeManagerInterface` that handles this for applications. For now there's a single implementation that's still session-based, though (via #30 which I'm reworking) other implementations will be provided (e.g. a cache pool). The majority of the change here is updating examples and adding tests. Note that this would be a BC break but since the library is still pre-1.0 it's not a concern for practical purposes.
1 parent ae5e600 commit 14bb773

21 files changed

+371
-75
lines changed

README.md

Lines changed: 36 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,15 @@ The protocol is always required; the port must only be present if using a non-st
3939
$rp = new \Firehed\WebAuthn\RelyingParty('https://www.example.com');
4040
```
4141

42+
Also create a `ChallengeManagerInterface`.
43+
This will store and validate the one-time use challenges that are central to the WebAuthn protocol.
44+
See the [Challenge Management](#challenge-management) section below for more information.
45+
46+
```php
47+
session_start();
48+
$challengeManager = new \Firehed\WebAuthn\SessionChallengeManager();
49+
```
50+
4251
> [!IMPORTANT]
4352
> WebAuthn will only work in a "secure context".
4453
> This means that the domain MUST run over `https`, with a sole exception for `localhost`.
@@ -49,20 +58,13 @@ $rp = new \Firehed\WebAuthn\RelyingParty('https://www.example.com');
4958
This step takes place either when a user is first registering, or later on to supplement or replace their password.
5059

5160
1) Create an endpoint that will return a new, random Challenge.
52-
This may be stored in a user's session or equivalent; it needs to be kept statefully server-side.
5361
Send it to the user as base64.
5462

5563
```php
5664
<?php
5765

58-
use Firehed\WebAuthn\ExpiringChallenge;
59-
6066
// Generate challenge
61-
$challenge = ExpiringChallenge::withLifetime(120);
62-
63-
// Store server-side; adjust to your app's needs
64-
session_start();
65-
$_SESSION['webauthn_challenge'] = $challenge;
67+
$challenge = $challengeManager->createChallenge();
6668

6769
// Send to user
6870
header('Content-type: application/json');
@@ -154,11 +156,9 @@ $data = json_decode($json, true);
154156
$parser = new ResponseParser();
155157
$createResponse = $parser->parseCreateResponse($data);
156158

157-
$rp = $valueFromSetup; // e.g. $psr11Container->get(RelyingParty::class);
158-
$challenge = $_SESSION['webauthn_challenge'];
159-
160159
try {
161-
$credential = $createResponse->verify($challenge, $rp);
160+
// $challengeManager and $rp are the values from the setup step
161+
$credential = $createResponse->verify($challengeManager, $rp);
162162
} catch (Throwable) {
163163
// Verification failed. Send an error to the user?
164164
header('HTTP/1.1 403 Unauthorized');
@@ -205,10 +205,7 @@ This assumes the same schema from the previous Registration example.
205205
```php
206206
<?php
207207

208-
use Firehed\WebAuthn\{
209-
Codecs,
210-
ExpiringChallenge,
211-
};
208+
use Firehed\WebAuthn\Codecs;
212209

213210
session_start();
214211

@@ -223,8 +220,7 @@ $_SESSION['authenticating_user_id'] = $user['id'];
223220
// See examples/functions.php for how this works
224221
$credentialContainer = getCredentialsForUserId($pdo, $user['id']);
225222

226-
$challenge = ExpiringChallenge::withLifetime(120);
227-
$_SESSION['webauthn_challenge'] = $challenge;
223+
$challenge = $challengeManager->createChallenge();
228224

229225
// Send to user
230226
header('Content-type: application/json');
@@ -310,13 +306,11 @@ $data = json_decode($json, true);
310306
$parser = new ResponseParser();
311307
$getResponse = $parser->parseGetResponse($data);
312308

313-
$rp = $valueFromSetup; // e.g. $psr11Container->get(RelyingParty::class);
314-
$challenge = $_SESSION['webauthn_challenge'];
315-
316309
$credentialContainer = getCredentialsForUserId($pdo, $_SESSION['authenticating_user_id']);
317310

318311
try {
319-
$updatedCredential = $getResponse->verify($challenge, $rp, $credentialContainer);
312+
// $challengeManager and $rp are the values from the setup step
313+
$updatedCredential = $getResponse->verify($challengeManager, $rp, $credentialContainer);
320314
} catch (Throwable) {
321315
// Verification failed. Send an error to the user?
322316
header('HTTP/1.1 403 Unauthorized');
@@ -440,6 +434,26 @@ Those wire formats are covered by semantic versioning and guaranteed to not have
440434

441435
Similarly, for data storage, the output of `Codecs\Credential::encode()` are also covered.
442436

437+
### Challenge management
438+
439+
Challenges are a [cryptographic nonce](https://en.wikipedia.org/wiki/Cryptographic_nonce) that ensure a login attempt works only once.
440+
Their single-use nature is critical to the security of the WebAuthn protocol.
441+
442+
Your application SHOULD use one of the library-provided `ChallengeManagerInterface` implementations to ensure the correct behavior.
443+
444+
| Implementation | Usage |
445+
| --- | --- |
446+
| `SessionChallengeManager` | Manages challenges through native PHP [Sessions](https://www.php.net/manual/en/intro.session.php). |
447+
448+
If one of the provided options is not suitable, you MAY implement the interface yourself or manage challenges manually.
449+
In the event you find this necessary, you SHOULD open an Issue and/or Pull Request for the library that indicates the shortcoming.
450+
451+
> [!WARNING]
452+
> You MUST validate that the challenge was generated by your server recently and has not already been used.
453+
> **Failing to do so will compromise the security of the protocol!**
454+
> Implementations MUST NOT trust a client-provided value.
455+
> The built-in `ChallengeManagerInterface` implementations will handle this for you.
456+
443457
Challenges generated by your server SHOULD expire after a short amount of time.
444458
You MAY use the `ExpiringChallenge` class for convenience (e.g. `$challenge = ExpiringChallenge::withLifetime(60);`), which will throw an exception if the specified expiration window has been exceeded.
445459
It is RECOMMENDED that your javascript code uses the `timeout` setting (denoted in milliseconds) and matches the server-side challenge expiration, give or take a few seconds.

composer-require-checker.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"symbol-whitelist": [
3+
"PHP_SESSION_ACTIVE",
4+
"session_status"
5+
]
6+
}

examples/functions.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@
33
declare(strict_types=1);
44

55
use Firehed\WebAuthn\{
6+
ChallengeManagerInterface,
67
Codecs,
78
CredentialContainer,
89
RelyingParty,
10+
SessionChallengeManager,
911
};
1012

1113
/**
@@ -28,6 +30,11 @@ function createUser(PDO $pdo, string $username): array
2830
return $response;
2931
}
3032

33+
function getChallengeManager(): ChallengeManagerInterface
34+
{
35+
return new SessionChallengeManager();
36+
}
37+
3138
function getCredentialsForUserId(PDO $pdo, string $userId): CredentialContainer
3239
{
3340
$stmt = $pdo->prepare('SELECT * FROM user_credentials WHERE user_id = ?');

examples/readmeLoginStep1.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@
1919

2020
$credentialContainer = getCredentialsForUserId($pdo, $user['id']);
2121

22-
$challenge = ExpiringChallenge::withLifetime(120);
23-
$_SESSION['webauthn_challenge'] = $challenge;
22+
$challengeManager = getChallengeManager();
23+
$challenge = $challengeManager->createChallenge();
2424

2525
// Send to user
2626
header('Content-type: application/json');

examples/readmeLoginStep3.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,12 @@
2020
$getResponse = $parser->parseGetResponse($data);
2121

2222
$rp = getRelyingParty();
23-
$challenge = $_SESSION['webauthn_challenge'];
2423

2524
$credentialContainer = getCredentialsForUserId($pdo, $_SESSION['authenticating_user_id']);
25+
$challengeManager = getChallengeManager();
2626

2727
try {
28-
$updatedCredential = $getResponse->verify($challenge, $rp, $credentialContainer);
28+
$updatedCredential = $getResponse->verify($challengeManager, $rp, $credentialContainer);
2929
} catch (Throwable) {
3030
// Verification failed. Send an error to the user?
3131
header('HTTP/1.1 403 Unauthorized');

examples/readmeRegisterStep1.php

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,8 @@
1313
$_SESSION['user_id'] = $user['id'];
1414

1515
// Generate challenge
16-
$challenge = ExpiringChallenge::withLifetime(120);
17-
18-
// Store server-side; adjust to your app's needs
19-
$_SESSION['webauthn_challenge'] = $challenge;
16+
$challengeManager = getChallengeManager();
17+
$challenge = $challengeManager->createChallenge();
2018

2119
// Send to user
2220
header('Content-type: application/json');

examples/readmeRegisterStep3.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,10 @@
1818
$createResponse = $parser->parseCreateResponse($data);
1919

2020
$rp = getRelyingParty();
21-
$challenge = $_SESSION['webauthn_challenge'];
21+
$challengeManager = getChallengeManager();
2222

2323
try {
24-
$credential = $createResponse->verify($challenge, $rp);
24+
$credential = $createResponse->verify($challengeManager, $rp);
2525
} catch (Throwable) {
2626
// Verification failed. Send an error to the user?
2727
header('HTTP/1.1 403 Unauthorized');

phpunit.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<?xml version="1.0" encoding="UTF-8"?>
22
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
33
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd"
4-
bootstrap="vendor/autoload.php"
4+
bootstrap="tests/bootstrap.php"
55
executionOrder="depends,defects"
66
forceCoversAnnotation="true"
77
beStrictAboutCoversAnnotation="false"

src/ChallengeManagerInterface.php

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Firehed\WebAuthn;
6+
7+
interface ChallengeManagerInterface
8+
{
9+
/**
10+
* Generates a new Challenge, stores it in the backing mechanism, and
11+
* returns it.
12+
*
13+
* @api
14+
*/
15+
public function createChallenge(): ChallengeInterface;
16+
17+
/**
18+
* Consumes the challenge associated with the ClientDataJSON value from the
19+
* underlying storage mechanism, and returns that challenge if found.
20+
*
21+
* Implementations MUST ensure that subsequent calls to this method with
22+
* the same value return `null`, regardless of whether the initial call
23+
* returned a value or null. Failure to do so will compromise the security
24+
* of the webauthn protocol.
25+
*
26+
* Implementations MUST NOT use the ClientDataJSON value to construct
27+
* a challenge. They MUST return a previously-stored value if one is found,
28+
* and MAY use $base64Url to search the storage mechanism.
29+
*
30+
* @internal
31+
*/
32+
public function useFromClientDataJSON(string $base64Url): ?ChallengeInterface;
33+
}

src/CreateResponse.php

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ public function __construct(
2727
* @link https://www.w3.org/TR/webauthn-2/#sctn-registering-a-new-credential
2828
*/
2929
public function verify(
30-
ChallengeInterface $challenge,
30+
ChallengeManagerInterface $challenge,
3131
RelyingParty $rp,
3232
UserVerificationRequirement $uv = UserVerificationRequirement::Preferred,
3333
): CredentialInterface {
@@ -47,8 +47,14 @@ public function verify(
4747
}
4848

4949
// 7.1.8
50+
$cdjChallenge = $C['challenge'];
51+
$challenge = $challenge->useFromClientDataJSON($cdjChallenge);
52+
if ($challenge === null) {
53+
$this->fail('7.1.8', 'C.challenge');
54+
}
55+
5056
$b64u = Codecs\Base64Url::encode($challenge->getBinary()->unwrap());
51-
if (!hash_equals($b64u, $C['challenge'])) {
57+
if (!hash_equals($b64u, $cdjChallenge)) {
5258
$this->fail('7.1.8', 'C.challenge');
5359
}
5460

0 commit comments

Comments
 (0)