create-nevela 0.2.1 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -6
- package/index.mjs +89 -17
- package/package.json +2 -1
- package/project.mjs +9 -4
- package/selfupdate.mjs +13 -3
- package/template/laravel/README.md +1 -1
- package/template/laravel/composer.json +2 -1
- package/template/laravel/config/nevela.php +66 -1
- package/template/laravel/database/migrations/2026_10_05_000000_create_nevela_auth_tables.php +66 -0
- package/template/laravel/src/Auth/Account.php +140 -0
- package/template/laravel/src/Auth/AuthMail.php +77 -0
- package/template/laravel/src/Auth/Challenges.php +135 -0
- package/template/laravel/src/Auth/Passkeys.php +206 -0
- package/template/laravel/src/Auth/Totp.php +106 -0
- package/template/laravel/src/Auth/Web.php +75 -0
- package/template/laravel/src/Console/GenerateCommand.php +1 -0
- package/template/laravel/src/Console/StatusCommand.php +2 -2
- package/template/laravel/src/Console/UpdateCommand.php +67 -9
- package/template/laravel/src/Generator/ResourceGenerator.php +63 -2
- package/template/laravel/src/Http/Auth/AccountController.php +252 -0
- package/template/laravel/src/Http/Auth/Answers.php +33 -0
- package/template/laravel/src/Http/Auth/SecurityController.php +188 -0
- package/template/laravel/src/Http/Auth/SignInController.php +302 -0
- package/template/laravel/src/Http/TokenController.php +19 -37
- package/template/laravel/src/Http/UploadController.php +24 -12
- package/template/laravel/src/Models/Passkey.php +38 -0
- package/template/laravel/src/Models/TwoFactor.php +64 -0
- package/template/laravel/src/Nevela.php +1 -1
- package/template/laravel/src/NevelaServiceProvider.php +72 -4
- package/template/laravel/src/Support/Descriptor.php +3 -3
- package/template/laravel/src/Support/Field.php +29 -2
- package/template/web/app/api/[...path]/route.ts +5 -1
- package/template/web/app/api/auth/[...action]/route.ts +196 -0
- package/template/web/app/auth-actions.ts +4 -43
- package/template/web/app/dashboard/account/layout.tsx +29 -0
- package/template/web/app/dashboard/account/page.tsx +19 -0
- package/template/web/app/dashboard/account/password/page.tsx +10 -0
- package/template/web/app/dashboard/account/security/page.tsx +19 -0
- package/template/web/app/dashboard/account/sessions/page.tsx +9 -0
- package/template/web/app/dashboard/layout.tsx +2 -1
- package/template/web/app/forgot-password/page.tsx +22 -0
- package/template/web/app/reset-password/page.tsx +31 -0
- package/template/web/app/sign-in/page.tsx +34 -4
- package/template/web/app/sign-up/page.tsx +43 -0
- package/template/web/app/two-factor/page.tsx +34 -0
- package/template/web/app/verify-email/page.tsx +33 -0
- package/template/web/components/account/account-nav.tsx +39 -0
- package/template/web/components/account/sections.tsx +573 -0
- package/template/web/components/auth/otp-input.tsx +95 -0
- package/template/web/components/auth/password-field.tsx +116 -0
- package/template/web/components/auth/password-forms.tsx +76 -0
- package/template/web/components/auth/provider-icons.tsx +39 -0
- package/template/web/components/auth/sign-in-flow.tsx +307 -0
- package/template/web/components/auth/sign-up-form.tsx +93 -0
- package/template/web/components/auth/social-buttons.tsx +56 -0
- package/template/web/components/auth/two-factor-form.tsx +144 -0
- package/template/web/components/auth/use-resend.ts +44 -0
- package/template/web/components/auth/verify-email-form.tsx +147 -0
- package/template/web/components/dashboard/dashboard-sidebar.tsx +2 -2
- package/template/web/components/dashboard/user-menu.tsx +33 -1
- package/template/web/lib/auth-client.ts +279 -0
- package/template/web/lib/auth-config.ts +43 -0
- package/template/web/lib/laravel.ts +26 -1
- package/template/web/lib/password-rules.ts +167 -0
- package/template/web/lib/second-step.ts +35 -0
- package/template/web/lib/session.ts +12 -4
- package/template/web/package.json +3 -2
- package/template.mjs +1 -1
- package/tool.mjs +58 -0
- package/template/web/components/auth/sign-in-form.tsx +0 -50
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Nevela\Laravel\Auth;
|
|
4
|
+
|
|
5
|
+
use Illuminate\Notifications\Messages\MailMessage;
|
|
6
|
+
use Illuminate\Notifications\Notification;
|
|
7
|
+
use Illuminate\Support\Facades\Notification as Notifications;
|
|
8
|
+
use Throwable;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The emails signing in needs: codes, links, and notices that something changed.
|
|
12
|
+
*
|
|
13
|
+
* Sent with whatever mailer the Laravel app is configured with. In development that is
|
|
14
|
+
* usually the log, where nobody looks, so the code or link is also printed to the
|
|
15
|
+
* server's output: with `nevela dev` it appears in the terminal as it is "sent".
|
|
16
|
+
*/
|
|
17
|
+
final class AuthMail
|
|
18
|
+
{
|
|
19
|
+
/**
|
|
20
|
+
* @param list<string> $lines Paragraphs, in order
|
|
21
|
+
* @param array{0: string, 1: string}|null $action A button: [label, url]
|
|
22
|
+
* @param string|null $secret The code or link, for the development console
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* What was sent while tests run, so a test can read the code it was "emailed".
|
|
26
|
+
*
|
|
27
|
+
* @var list<array{email: string, subject: string, lines: list<string>, action: array{0: string, 1: string}|null, secret: string|null}>
|
|
28
|
+
*/
|
|
29
|
+
public static array $outbox = [];
|
|
30
|
+
|
|
31
|
+
public static function send(string $email, string $subject, array $lines, ?array $action = null, ?string $secret = null): void
|
|
32
|
+
{
|
|
33
|
+
if (app()->runningUnitTests()) {
|
|
34
|
+
self::$outbox[] = compact('email', 'subject', 'lines', 'action', 'secret');
|
|
35
|
+
}
|
|
36
|
+
if (app()->environment('local') && $secret !== null) {
|
|
37
|
+
// The dev server shows its error output; nothing else of the request is printed there.
|
|
38
|
+
@file_put_contents('php://stderr', "Nevela mail to {$email}: {$subject}\n {$secret}\n");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
Notifications::route('mail', $email)->notify(new class($subject, $lines, $action) extends Notification
|
|
43
|
+
{
|
|
44
|
+
/** @param list<string> $lines @param array{0: string, 1: string}|null $action */
|
|
45
|
+
public function __construct(private string $subject, private array $lines, private ?array $action) {}
|
|
46
|
+
|
|
47
|
+
/** @return list<string> */
|
|
48
|
+
public function via(object $notifiable): array
|
|
49
|
+
{
|
|
50
|
+
return ['mail'];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
public function toMail(object $notifiable): MailMessage
|
|
54
|
+
{
|
|
55
|
+
$message = (new MailMessage)->subject($this->subject);
|
|
56
|
+
foreach ($this->lines as $line) {
|
|
57
|
+
$message->line($line);
|
|
58
|
+
}
|
|
59
|
+
if ($this->action !== null) {
|
|
60
|
+
$message->action($this->action[0], $this->action[1]);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return $message->line("If you didn't ask for this, you can ignore this email.");
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
} catch (Throwable $e) {
|
|
67
|
+
// A mail server being down must not tell the visitor anything, or break sign-in
|
|
68
|
+
// for an address that may not even exist. It is reported for the developer.
|
|
69
|
+
report($e);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
public static function app(): string
|
|
74
|
+
{
|
|
75
|
+
return (string) (config('nevela.auth.issuer') ?: config('app.name', 'Nevela'));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Nevela\Laravel\Auth;
|
|
4
|
+
|
|
5
|
+
use Illuminate\Support\Facades\Cache;
|
|
6
|
+
use Illuminate\Support\Facades\RateLimiter;
|
|
7
|
+
use Illuminate\Support\Str;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Things that are valid once and briefly: a sign-in waiting for its second step, an
|
|
11
|
+
* emailed code, a passkey ceremony's challenge, a sign-in link.
|
|
12
|
+
*
|
|
13
|
+
* Kept in the cache, under a key made from a hash of the id, so reading the cache doesn't
|
|
14
|
+
* give anyone a usable id. Codes are kept as hashes for the same reason.
|
|
15
|
+
*/
|
|
16
|
+
final class Challenges
|
|
17
|
+
{
|
|
18
|
+
/** How many wrong codes are tolerated before the challenge is thrown away. */
|
|
19
|
+
public const ATTEMPTS = 5;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param array<string, mixed> $data
|
|
23
|
+
* @return string The id to hand to the client
|
|
24
|
+
*/
|
|
25
|
+
public static function start(string $type, array $data, int $seconds): string
|
|
26
|
+
{
|
|
27
|
+
$id = Str::random(48);
|
|
28
|
+
Cache::put(self::key($type, $id), $data + ['attempts' => 0, 'expires' => time() + $seconds], $seconds);
|
|
29
|
+
|
|
30
|
+
return $id;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The same, under an id the caller chooses: an emailed code is found again by the
|
|
35
|
+
* address it was sent to.
|
|
36
|
+
*
|
|
37
|
+
* @param array<string, mixed> $data
|
|
38
|
+
*/
|
|
39
|
+
public static function put(string $type, string $id, array $data, int $seconds): void
|
|
40
|
+
{
|
|
41
|
+
Cache::put(self::key($type, $id), $data + ['attempts' => 0, 'expires' => time() + $seconds], $seconds);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** @return array<string, mixed>|null */
|
|
45
|
+
public static function get(string $type, ?string $id): ?array
|
|
46
|
+
{
|
|
47
|
+
if ($id === null || $id === '') {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
$data = Cache::get(self::key($type, $id));
|
|
51
|
+
|
|
52
|
+
return is_array($data) && ($data['expires'] ?? 0) >= time() ? $data : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Read it and make sure it can't be read again. @return array<string, mixed>|null */
|
|
56
|
+
public static function take(string $type, ?string $id): ?array
|
|
57
|
+
{
|
|
58
|
+
$data = self::get($type, $id);
|
|
59
|
+
if ($data !== null) {
|
|
60
|
+
Cache::forget(self::key($type, (string) $id));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return $data;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** @param array<string, mixed> $changes */
|
|
67
|
+
public static function update(string $type, string $id, array $changes): void
|
|
68
|
+
{
|
|
69
|
+
$data = self::get($type, $id);
|
|
70
|
+
if ($data !== null) {
|
|
71
|
+
Cache::put(self::key($type, $id), $changes + $data, max(1, $data['expires'] - time()));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Count a wrong answer. Returns false when that was one too many, and the challenge
|
|
77
|
+
* is gone: whoever is guessing has to start again.
|
|
78
|
+
*/
|
|
79
|
+
public static function miss(string $type, string $id): bool
|
|
80
|
+
{
|
|
81
|
+
$data = self::get($type, $id);
|
|
82
|
+
if ($data === null) {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
if ($data['attempts'] + 1 >= self::ATTEMPTS) {
|
|
86
|
+
Cache::forget(self::key($type, $id));
|
|
87
|
+
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
self::update($type, $id, ['attempts' => $data['attempts'] + 1]);
|
|
91
|
+
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
public static function forget(string $type, string $id): void
|
|
96
|
+
{
|
|
97
|
+
Cache::forget(self::key($type, $id));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Whether another emailed code may be sent for this purpose to this person.
|
|
102
|
+
*
|
|
103
|
+
* A six-digit code allows five wrong guesses, so the number of codes is what limits
|
|
104
|
+
* guessing: at six codes an hour, trying every code takes several years, where
|
|
105
|
+
* unlimited codes would take months. A person who really needs a seventh waits.
|
|
106
|
+
*/
|
|
107
|
+
public static function mayIssue(string $type, string $who, int $perHour = 6): bool
|
|
108
|
+
{
|
|
109
|
+
$key = 'nevela:auth:issued:'.$type.':'.hash('sha256', $who);
|
|
110
|
+
if (RateLimiter::tooManyAttempts($key, $perHour)) {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
RateLimiter::hit($key, 3600);
|
|
114
|
+
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** A six-digit code, and the hash of it to keep. @return array{0: string, 1: string} */
|
|
119
|
+
public static function code(): array
|
|
120
|
+
{
|
|
121
|
+
$code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
|
|
122
|
+
|
|
123
|
+
return [$code, self::hash($code)];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
public static function hash(string $value): string
|
|
127
|
+
{
|
|
128
|
+
return hash_hmac('sha256', preg_replace('/\s+/', '', $value) ?? '', (string) config('app.key'));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private static function key(string $type, string $id): string
|
|
132
|
+
{
|
|
133
|
+
return 'nevela:auth:'.$type.':'.hash('sha256', $id);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Nevela\Laravel\Auth;
|
|
4
|
+
|
|
5
|
+
use Illuminate\Http\Request;
|
|
6
|
+
use lbuchs\WebAuthn\Binary\ByteBuffer;
|
|
7
|
+
use lbuchs\WebAuthn\WebAuthn;
|
|
8
|
+
use Nevela\Laravel\Models\Passkey;
|
|
9
|
+
use RuntimeException;
|
|
10
|
+
use Throwable;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Passkeys (WebAuthn): creating one, and signing in with one.
|
|
14
|
+
*
|
|
15
|
+
* Each is a ceremony in two halves. Laravel hands out a random challenge; the person's
|
|
16
|
+
* device signs it with a key that never leaves the device; Laravel checks the signature
|
|
17
|
+
* against the public key it was given when the passkey was made. The parsing and the
|
|
18
|
+
* cryptography are lbuchs/webauthn's. What is checked here on top of it:
|
|
19
|
+
*
|
|
20
|
+
* - the challenge is one this app issued, minutes ago, and is used once,
|
|
21
|
+
* - the page that ran the ceremony is the dashboard, compared exactly (the library
|
|
22
|
+
* accepts any host that merely ends with the right name), and
|
|
23
|
+
* - the passkey belongs to the person it is being used for.
|
|
24
|
+
*/
|
|
25
|
+
final class Passkeys
|
|
26
|
+
{
|
|
27
|
+
/** How long someone has to complete the prompt on their device. */
|
|
28
|
+
private const SECONDS = 300;
|
|
29
|
+
|
|
30
|
+
private static function server(Request $request): WebAuthn
|
|
31
|
+
{
|
|
32
|
+
// "none": no proof of the device's make is asked for. It isn't needed to sign
|
|
33
|
+
// someone in, and asking makes browsers show an extra consent prompt.
|
|
34
|
+
return new WebAuthn(AuthMail::app(), Web::host($request), ['none'], true);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Options for `navigator.credentials.create()`.
|
|
39
|
+
*
|
|
40
|
+
* @return array{challenge: string, options: mixed}
|
|
41
|
+
*/
|
|
42
|
+
public static function creationOptions(mixed $user, Request $request): array
|
|
43
|
+
{
|
|
44
|
+
$server = self::server($request);
|
|
45
|
+
$existing = Passkey::query()->where('user_id', (string) $user->getAuthIdentifier())->pluck('credential_id')
|
|
46
|
+
->map(fn (string $id) => new ByteBuffer(self::decode($id)))->all();
|
|
47
|
+
|
|
48
|
+
// A discoverable credential ("resident key"), so signing in doesn't need an email first.
|
|
49
|
+
$options = $server->getCreateArgs(
|
|
50
|
+
(string) $user->getAuthIdentifier(),
|
|
51
|
+
(string) $user->email,
|
|
52
|
+
(string) ($user->name ?: $user->email),
|
|
53
|
+
self::SECONDS,
|
|
54
|
+
true,
|
|
55
|
+
'preferred',
|
|
56
|
+
null,
|
|
57
|
+
$existing,
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
return [
|
|
61
|
+
'challenge' => Challenges::start('passkey-create', [
|
|
62
|
+
'user' => (string) $user->getAuthIdentifier(),
|
|
63
|
+
'value' => self::encode($server->getChallenge()->getBinaryString()),
|
|
64
|
+
'origin' => Web::origin($request),
|
|
65
|
+
], self::SECONDS),
|
|
66
|
+
'options' => $options->publicKey,
|
|
67
|
+
];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @param array<string, mixed> $response What the browser's credential produced
|
|
72
|
+
*
|
|
73
|
+
* @throws RuntimeException with a message for the person
|
|
74
|
+
*/
|
|
75
|
+
public static function create(mixed $user, string $challengeId, array $response, ?string $name, Request $request): Passkey
|
|
76
|
+
{
|
|
77
|
+
$challenge = Challenges::take('passkey-create', $challengeId);
|
|
78
|
+
if ($challenge === null || $challenge['user'] !== (string) $user->getAuthIdentifier()) {
|
|
79
|
+
throw new RuntimeException('That took too long. Try adding the passkey again.');
|
|
80
|
+
}
|
|
81
|
+
$clientData = self::decode((string) ($response['clientDataJSON'] ?? ''));
|
|
82
|
+
self::assertOrigin($clientData, $challenge['origin']);
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
$data = self::server($request)->processCreate(
|
|
86
|
+
$clientData,
|
|
87
|
+
self::decode((string) ($response['attestationObject'] ?? '')),
|
|
88
|
+
self::decode($challenge['value']),
|
|
89
|
+
false,
|
|
90
|
+
true,
|
|
91
|
+
false,
|
|
92
|
+
);
|
|
93
|
+
} catch (Throwable $e) {
|
|
94
|
+
report($e);
|
|
95
|
+
throw new RuntimeException("That passkey couldn't be checked. Try again.");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
$credentialId = self::encode($data->credentialId);
|
|
99
|
+
if (Passkey::query()->where('credential_id', $credentialId)->exists()) {
|
|
100
|
+
throw new RuntimeException('That passkey is already added.');
|
|
101
|
+
}
|
|
102
|
+
$transports = array_values(array_filter((array) ($response['transports'] ?? []), fn ($t) => is_string($t) && preg_match('/^[a-z-]{2,20}$/', $t)));
|
|
103
|
+
|
|
104
|
+
return Passkey::create([
|
|
105
|
+
'user_id' => (string) $user->getAuthIdentifier(),
|
|
106
|
+
'name' => $name !== null && trim($name) !== '' ? mb_substr(trim($name), 0, 80) : null,
|
|
107
|
+
'credential_id' => $credentialId,
|
|
108
|
+
'public_key' => $data->credentialPublicKey,
|
|
109
|
+
'counter' => (int) ($data->signatureCounter ?? 0),
|
|
110
|
+
'transports' => $transports ?: null,
|
|
111
|
+
'aaguid' => is_string($data->AAGUID ?? null) ? bin2hex($data->AAGUID) : null,
|
|
112
|
+
]);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Options for `navigator.credentials.get()`. No list of allowed passkeys: the device
|
|
117
|
+
* offers the ones it has for this site, which is what lets someone sign in without
|
|
118
|
+
* typing an email, and tells a visitor nothing about who has an account.
|
|
119
|
+
*
|
|
120
|
+
* @return array{challenge: string, options: mixed}
|
|
121
|
+
*/
|
|
122
|
+
public static function requestOptions(Request $request): array
|
|
123
|
+
{
|
|
124
|
+
$server = self::server($request);
|
|
125
|
+
$options = $server->getGetArgs([], self::SECONDS, true, true, true, true, true, 'preferred');
|
|
126
|
+
|
|
127
|
+
return [
|
|
128
|
+
'challenge' => Challenges::start('passkey-get', [
|
|
129
|
+
'value' => self::encode($server->getChallenge()->getBinaryString()),
|
|
130
|
+
'origin' => Web::origin($request),
|
|
131
|
+
], self::SECONDS),
|
|
132
|
+
'options' => $options->publicKey,
|
|
133
|
+
];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Check a sign-in and return whose passkey it was.
|
|
138
|
+
*
|
|
139
|
+
* @param array<string, mixed> $response
|
|
140
|
+
*
|
|
141
|
+
* @throws RuntimeException with a message for the person
|
|
142
|
+
*/
|
|
143
|
+
public static function verify(string $challengeId, array $response, Request $request): Passkey
|
|
144
|
+
{
|
|
145
|
+
$failed = "That passkey didn't work. Try again, or sign in another way.";
|
|
146
|
+
$challenge = Challenges::take('passkey-get', $challengeId);
|
|
147
|
+
if ($challenge === null) {
|
|
148
|
+
throw new RuntimeException('That took too long. Try the passkey again.');
|
|
149
|
+
}
|
|
150
|
+
$passkey = Passkey::query()->where('credential_id', (string) ($response['id'] ?? ''))->first();
|
|
151
|
+
if ($passkey === null) {
|
|
152
|
+
throw new RuntimeException("This passkey isn't registered here any more. Sign in another way.");
|
|
153
|
+
}
|
|
154
|
+
$clientData = self::decode((string) ($response['clientDataJSON'] ?? ''));
|
|
155
|
+
self::assertOrigin($clientData, $challenge['origin']);
|
|
156
|
+
|
|
157
|
+
// The device says whose passkey it is. It has to agree with our record.
|
|
158
|
+
$handle = (string) ($response['userHandle'] ?? '');
|
|
159
|
+
if ($handle !== '' && ! hash_equals($passkey->user_id, self::decode($handle))) {
|
|
160
|
+
throw new RuntimeException($failed);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
$server = self::server($request);
|
|
164
|
+
try {
|
|
165
|
+
$server->processGet(
|
|
166
|
+
$clientData,
|
|
167
|
+
self::decode((string) ($response['authenticatorData'] ?? '')),
|
|
168
|
+
self::decode((string) ($response['signature'] ?? '')),
|
|
169
|
+
$passkey->public_key,
|
|
170
|
+
self::decode($challenge['value']),
|
|
171
|
+
// A counter that goes backwards means the key was copied. Passkeys synced
|
|
172
|
+
// between someone's devices always report 0, which is not checked.
|
|
173
|
+
$passkey->counter > 0 ? $passkey->counter : null,
|
|
174
|
+
false,
|
|
175
|
+
true,
|
|
176
|
+
);
|
|
177
|
+
} catch (Throwable $e) {
|
|
178
|
+
report($e);
|
|
179
|
+
throw new RuntimeException($failed);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
$passkey->forceFill(['counter' => (int) ($server->getSignatureCounter() ?? $passkey->counter), 'last_used_at' => now()])->save();
|
|
183
|
+
|
|
184
|
+
return $passkey;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** The page that ran the ceremony has to be the dashboard, exactly. */
|
|
188
|
+
private static function assertOrigin(string $clientDataJson, string $expected): void
|
|
189
|
+
{
|
|
190
|
+
$clientData = json_decode($clientDataJson, true);
|
|
191
|
+
$origin = is_array($clientData) ? (string) ($clientData['origin'] ?? '') : '';
|
|
192
|
+
if ($origin === '' || rtrim(strtolower($origin), '/') !== $expected) {
|
|
193
|
+
throw new RuntimeException("This passkey request didn't come from the app's own address.");
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
public static function encode(string $binary): string
|
|
198
|
+
{
|
|
199
|
+
return rtrim(strtr(base64_encode($binary), '+/', '-_'), '=');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
public static function decode(string $text): string
|
|
203
|
+
{
|
|
204
|
+
return (string) base64_decode(strtr($text, '-_', '+/'), false);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Nevela\Laravel\Auth;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Time-based one-time passwords (RFC 6238), as every authenticator app implements them:
|
|
7
|
+
* SHA-1, six digits, a new code every 30 seconds. Pure PHP, no dependencies.
|
|
8
|
+
*/
|
|
9
|
+
final class Totp
|
|
10
|
+
{
|
|
11
|
+
private const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
|
12
|
+
|
|
13
|
+
private const PERIOD = 30;
|
|
14
|
+
|
|
15
|
+
private const DIGITS = 6;
|
|
16
|
+
|
|
17
|
+
/** A new secret, as the base32 text an authenticator app is given. 160 bits, as the RFC recommends. */
|
|
18
|
+
public static function secret(): string
|
|
19
|
+
{
|
|
20
|
+
return self::base32(random_bytes(20));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The code for a moment in time. */
|
|
24
|
+
public static function code(string $secret, ?int $at = null): string
|
|
25
|
+
{
|
|
26
|
+
$counter = intdiv($at ?? time(), self::PERIOD);
|
|
27
|
+
$hash = hash_hmac('sha1', pack('J', $counter), self::bytes($secret), true);
|
|
28
|
+
$offset = ord($hash[19]) & 0x0F;
|
|
29
|
+
$number = (unpack('N', substr($hash, $offset, 4))[1] & 0x7FFFFFFF) % (10 ** self::DIGITS);
|
|
30
|
+
|
|
31
|
+
return str_pad((string) $number, self::DIGITS, '0', STR_PAD_LEFT);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Whether a code is right, now or one step either side: clocks drift, and a code typed
|
|
36
|
+
* as it changes should still work.
|
|
37
|
+
*/
|
|
38
|
+
public static function verify(string $secret, string $code, ?int $at = null): bool
|
|
39
|
+
{
|
|
40
|
+
return self::step($secret, $code, $at) !== null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Which 30-second step a right code belongs to, or null when the code is wrong.
|
|
45
|
+
*
|
|
46
|
+
* A caller that signs someone in keeps the step, and refuses a code from that step or
|
|
47
|
+
* an earlier one next time: a code is a one-time password, and without this the same
|
|
48
|
+
* six digits would work again for up to ninety seconds for anyone who saw them typed.
|
|
49
|
+
*/
|
|
50
|
+
public static function step(string $secret, string $code, ?int $at = null): ?int
|
|
51
|
+
{
|
|
52
|
+
$code = preg_replace('/\s+/', '', $code) ?? '';
|
|
53
|
+
if (! preg_match('/^\d{6}$/', $code)) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
$at ??= time();
|
|
57
|
+
foreach ([0, -1, 1] as $offset) {
|
|
58
|
+
if (hash_equals(self::code($secret, $at + $offset * self::PERIOD), $code)) {
|
|
59
|
+
return intdiv($at, self::PERIOD) + $offset;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** What an authenticator app scans: the secret, and the names it files it under. */
|
|
67
|
+
public static function uri(string $secret, string $account, string $issuer): string
|
|
68
|
+
{
|
|
69
|
+
return 'otpauth://totp/'.rawurlencode($issuer).':'.rawurlencode($account)
|
|
70
|
+
.'?secret='.$secret.'&issuer='.rawurlencode($issuer).'&algorithm=SHA1&digits='.self::DIGITS.'&period='.self::PERIOD;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private static function base32(string $bytes): string
|
|
74
|
+
{
|
|
75
|
+
$bits = '';
|
|
76
|
+
foreach (str_split($bytes) as $byte) {
|
|
77
|
+
$bits .= str_pad(decbin(ord($byte)), 8, '0', STR_PAD_LEFT);
|
|
78
|
+
}
|
|
79
|
+
$text = '';
|
|
80
|
+
foreach (str_split($bits, 5) as $chunk) {
|
|
81
|
+
$text .= self::ALPHABET[bindec(str_pad($chunk, 5, '0'))];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return $text;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private static function bytes(string $secret): string
|
|
88
|
+
{
|
|
89
|
+
$bits = '';
|
|
90
|
+
foreach (str_split(strtoupper(rtrim($secret, '='))) as $char) {
|
|
91
|
+
$index = strpos(self::ALPHABET, $char);
|
|
92
|
+
if ($index === false) {
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
$bits .= str_pad(decbin($index), 5, '0', STR_PAD_LEFT);
|
|
96
|
+
}
|
|
97
|
+
$bytes = '';
|
|
98
|
+
foreach (str_split($bits, 8) as $chunk) {
|
|
99
|
+
if (strlen($chunk) === 8) {
|
|
100
|
+
$bytes .= chr(bindec($chunk));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return $bytes;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Nevela\Laravel\Auth;
|
|
4
|
+
|
|
5
|
+
use Illuminate\Http\Request;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Where the dashboard is.
|
|
9
|
+
*
|
|
10
|
+
* Emails link to it, and a passkey is tied to its address, so Laravel has to know it. The
|
|
11
|
+
* dashboard says where it is on each request (the X-Nevela-Origin header), because in
|
|
12
|
+
* development it moves to another port when 3000 is taken. What it says is only believed
|
|
13
|
+
* when it is the configured address, or a localhost address while the app is in
|
|
14
|
+
* development: otherwise anyone could have a sign-in link point at a site of their own.
|
|
15
|
+
*/
|
|
16
|
+
final class Web
|
|
17
|
+
{
|
|
18
|
+
private const LOCAL_HOSTS = ['localhost', '127.0.0.1', '[::1]'];
|
|
19
|
+
|
|
20
|
+
public static function configured(): string
|
|
21
|
+
{
|
|
22
|
+
return self::originOf((string) config('nevela.auth.web_url', 'http://localhost:3000')) ?? 'http://localhost:3000';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The dashboard's origin for this request: "https://app.example.com", no path. */
|
|
26
|
+
public static function origin(?Request $request = null): string
|
|
27
|
+
{
|
|
28
|
+
$claimed = self::originOf((string) ($request ?? request())->header('X-Nevela-Origin', ''));
|
|
29
|
+
|
|
30
|
+
return $claimed !== null && self::allowed($claimed) ? $claimed : self::configured();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
public static function allowed(string $origin): bool
|
|
34
|
+
{
|
|
35
|
+
$origin = self::originOf($origin);
|
|
36
|
+
if ($origin === null) {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
if ($origin === self::configured()) {
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return app()->environment('local', 'testing') && in_array(parse_url($origin, PHP_URL_HOST), self::LOCAL_HOSTS, true);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A page of the dashboard, e.g. url('/reset-password', ['token' => …]). */
|
|
47
|
+
public static function url(string $path, array $query = [], ?Request $request = null): string
|
|
48
|
+
{
|
|
49
|
+
return self::origin($request).'/'.ltrim($path, '/').($query === [] ? '' : '?'.http_build_query($query));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The host a passkey is bound to: the dashboard's, without scheme or port. */
|
|
53
|
+
public static function host(?Request $request = null): string
|
|
54
|
+
{
|
|
55
|
+
return (string) parse_url(self::origin($request), PHP_URL_HOST);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Only a path on the dashboard, never another site: for "where to go after signing in". */
|
|
59
|
+
public static function path(mixed $value, string $fallback = '/dashboard'): string
|
|
60
|
+
{
|
|
61
|
+
// One leading slash, and no backslash or control character anywhere: browsers drop
|
|
62
|
+
// tabs and newlines from an address, which turns "/<tab>/evil.example" into "//evil.example".
|
|
63
|
+
return is_string($value) && preg_match('#^/(?!/)[^\\\\\x00-\x1F\x7F]*$#', $value) === 1 ? $value : $fallback;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private static function originOf(string $url): ?string
|
|
67
|
+
{
|
|
68
|
+
$parts = parse_url(trim($url));
|
|
69
|
+
if (! is_array($parts) || ! in_array($parts['scheme'] ?? '', ['http', 'https'], true) || empty($parts['host'])) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return strtolower($parts['scheme'].'://'.$parts['host']).(isset($parts['port']) ? ':'.$parts['port'] : '');
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -90,6 +90,7 @@ final class GenerateCommand extends Command
|
|
|
90
90
|
}
|
|
91
91
|
$files[] = $generator->routes($all);
|
|
92
92
|
$files[] = $generator->registry($all);
|
|
93
|
+
$files[] = $generator->authConfig((array) config('nevela.auth', []));
|
|
93
94
|
|
|
94
95
|
$roots = ['api' => base_path()];
|
|
95
96
|
if (($web = config('nevela.web_path')) && is_dir($web)) {
|
|
@@ -30,7 +30,7 @@ final class StatusCommand extends Command
|
|
|
30
30
|
$latest = Releases::latest();
|
|
31
31
|
$row('Nevela', Nevela::VERSION.match (true) {
|
|
32
32
|
$latest === null => ' <fg=gray>(couldn\'t check for a newer one)</>',
|
|
33
|
-
version_compare($latest, Nevela::VERSION, '>') => " <fg=yellow>({$latest} is out: php nevela
|
|
33
|
+
version_compare($latest, Nevela::VERSION, '>') => " <fg=yellow>({$latest} is out: php nevela upgrade)</>",
|
|
34
34
|
default => ' <fg=green>(latest)</>',
|
|
35
35
|
});
|
|
36
36
|
$row('Laravel / PHP', app()->version().' / '.PHP_VERSION);
|
|
@@ -99,7 +99,7 @@ final class StatusCommand extends Command
|
|
|
99
99
|
// What this app has made its own. An update leaves every one of these alone.
|
|
100
100
|
$changes = $state->yourChanges($web);
|
|
101
101
|
if ($changes === null) {
|
|
102
|
-
$row('Your dashboard changes', '<fg=gray>not tracked yet: php nevela
|
|
102
|
+
$row('Your dashboard changes', '<fg=gray>not tracked yet: php nevela upgrade records them</>');
|
|
103
103
|
} else {
|
|
104
104
|
$count = count($changes['changed']) + count($changes['deleted']);
|
|
105
105
|
$row('Your dashboard changes', $count === 0 ? 'none' : "{$count} file(s), kept on every update");
|