create-nevela 0.3.0 → 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/index.mjs +12 -1
- package/package.json +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/UpdateCommand.php +56 -4
- package/template/laravel/src/Generator/ResourceGenerator.php +60 -0
- 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/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/web/components/auth/sign-in-form.tsx +0 -50
|
@@ -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)) {
|
|
@@ -80,6 +80,41 @@ final class UpdateCommand extends Command
|
|
|
80
80
|
return preg_match('/^\^\d+\.\d+(\.\d+)?$/', $constraint) === 1;
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Files an earlier upgrade should have removed and didn't: path => the fingerprint of
|
|
85
|
+
* the template's own version of it.
|
|
86
|
+
*
|
|
87
|
+
* An upgrade to 0.4.0 left the old sign-in form in place, importing an action that no
|
|
88
|
+
* longer exists, which stops `next build`. The record has already moved on, so the
|
|
89
|
+
* ordinary plan can't see it. It is removed here, but only when it is byte for byte
|
|
90
|
+
* the template's file: one you have edited is yours and is left alone. A copy is kept
|
|
91
|
+
* beside the backups, and an undo that goes back to a version which had the file puts
|
|
92
|
+
* it back.
|
|
93
|
+
*/
|
|
94
|
+
private const LEFTOVERS = [
|
|
95
|
+
'components/auth/sign-in-form.tsx' => 'aed2e3389ec0b85df2bbe02f9e91579982d0e5ab',
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
/** @param bool $check Say what would be removed, and remove nothing */
|
|
99
|
+
private function clearLeftovers(string $web, bool $check = false): void
|
|
100
|
+
{
|
|
101
|
+
$state = DashboardState::read($web);
|
|
102
|
+
foreach (self::LEFTOVERS as $path => $fingerprint) {
|
|
103
|
+
$file = "{$web}/{$path}";
|
|
104
|
+
if (! is_file($file) || isset($state->files[$path]) || DashboardUpdate::hash((string) file_get_contents($file)) !== $fingerprint) {
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if ($check) {
|
|
108
|
+
$this->components->twoColumnDetail($path, '<fg=gray>would remove: an earlier upgrade should have</>');
|
|
109
|
+
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
$this->put("{$web}/.nevela/backups/leftovers/{$path}", (string) file_get_contents($file));
|
|
113
|
+
unlink($file);
|
|
114
|
+
$this->components->twoColumnDetail($path, '<fg=gray>removed: an earlier upgrade should have (copy in .nevela/backups/leftovers)</>');
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
83
118
|
private function pendingMigrations(): int
|
|
84
119
|
{
|
|
85
120
|
try {
|
|
@@ -204,6 +239,7 @@ final class UpdateCommand extends Command
|
|
|
204
239
|
|
|
205
240
|
return self::SUCCESS;
|
|
206
241
|
}
|
|
242
|
+
$this->clearLeftovers($web, $check);
|
|
207
243
|
$state = DashboardState::read($web);
|
|
208
244
|
$from = $state->template ?? self::FIRST_TRACKED;
|
|
209
245
|
// In --check the package hasn't been updated, so look at the newest dashboard there is.
|
|
@@ -267,13 +303,19 @@ final class UpdateCommand extends Command
|
|
|
267
303
|
} elseif ($action === DashboardUpdate::CONFLICT) {
|
|
268
304
|
// Yours stays. The new version is put where you can compare the two.
|
|
269
305
|
$this->put("{$web}/{$incoming}/{$path}", $next[$path]);
|
|
306
|
+
} elseif ($action === DashboardUpdate::REMOVED && is_file("{$web}/{$path}")) {
|
|
307
|
+
// The template dropped it and you never changed it, so it is the template's
|
|
308
|
+
// file, not yours. Left behind, it would go on being compiled against code
|
|
309
|
+
// that has moved on, and break the build. It is in the backup.
|
|
310
|
+
$this->put("{$web}/{$backup}/{$path}", (string) file_get_contents("{$web}/{$path}"));
|
|
311
|
+
unlink("{$web}/{$path}");
|
|
270
312
|
}
|
|
271
313
|
}
|
|
272
314
|
$this->components->twoColumnDetail($path, match ($action) {
|
|
273
315
|
DashboardUpdate::UPDATE => $check ? '<fg=blue>would update</>' : '<fg=blue>updated</>',
|
|
274
316
|
DashboardUpdate::ADD => $check ? '<fg=green>would add</>' : '<fg=green>added</>',
|
|
275
317
|
DashboardUpdate::CONFLICT => '<fg=yellow>kept yours — changed in the template too</>',
|
|
276
|
-
DashboardUpdate::REMOVED => '<fg=gray>no longer in the template</>',
|
|
318
|
+
DashboardUpdate::REMOVED => $check ? '<fg=gray>would remove: no longer in the template</>' : '<fg=gray>removed: no longer in the template</>',
|
|
277
319
|
});
|
|
278
320
|
}
|
|
279
321
|
|
|
@@ -289,7 +331,7 @@ final class UpdateCommand extends Command
|
|
|
289
331
|
$this->newLine();
|
|
290
332
|
|
|
291
333
|
if (! $check) {
|
|
292
|
-
$touched = $done[DashboardUpdate::UPDATE] !== [] || $done[DashboardUpdate::ADD] !== [] || ($packages['changed'] ?? []) !== [];
|
|
334
|
+
$touched = $done[DashboardUpdate::UPDATE] !== [] || $done[DashboardUpdate::ADD] !== [] || $done[DashboardUpdate::REMOVED] !== [] || ($packages['changed'] ?? []) !== [];
|
|
293
335
|
if ($packages && $packages['changed']) {
|
|
294
336
|
$this->put("{$web}/{$backup}/package.json", (string) file_get_contents("{$web}/package.json"));
|
|
295
337
|
file_put_contents("{$web}/package.json", $packages['json']);
|
|
@@ -318,7 +360,7 @@ final class UpdateCommand extends Command
|
|
|
318
360
|
$state->write($web);
|
|
319
361
|
|
|
320
362
|
if ($touched) {
|
|
321
|
-
$this->line(" Every file this replaced was copied to <fg=cyan>{$this->relative($web)}/{$backup}</> first. To put it all back: <fg=cyan>php nevela upgrade --undo</>");
|
|
363
|
+
$this->line(" Every file this replaced or removed was copied to <fg=cyan>{$this->relative($web)}/{$backup}</> first. To put it all back: <fg=cyan>php nevela upgrade --undo</>");
|
|
322
364
|
}
|
|
323
365
|
}
|
|
324
366
|
if ($done[DashboardUpdate::CONFLICT] !== []) {
|
|
@@ -326,7 +368,7 @@ final class UpdateCommand extends Command
|
|
|
326
368
|
$this->components->warn("{$count} file(s) changed in the template and in your app. Yours were kept.".($check ? '' : " The template's new versions are in {$this->relative($web)}/{$incoming} for you to compare."));
|
|
327
369
|
}
|
|
328
370
|
if ($done[DashboardUpdate::REMOVED] !== []) {
|
|
329
|
-
$this->line(' '.count($done[DashboardUpdate::REMOVED]).
|
|
371
|
+
$this->line(' '.count($done[DashboardUpdate::REMOVED]).' file(s) the template no longer has '.($check ? 'would be' : 'were').' removed. You had not changed them; a file you changed is always kept.');
|
|
330
372
|
}
|
|
331
373
|
if (! $check && ($packages['changed'] ?? [])) {
|
|
332
374
|
$this->line(' The dashboard\'s dependencies changed. Install them: <fg=cyan>cd '.$this->relative($web).' && '.DevCommand::packageManager($web).' install</>');
|
|
@@ -383,6 +425,16 @@ final class UpdateCommand extends Command
|
|
|
383
425
|
if (is_file("{$dir}/.nevela.json")) {
|
|
384
426
|
copy("{$dir}/.nevela.json", DashboardState::path($web));
|
|
385
427
|
}
|
|
428
|
+
// A leftover cleared since belongs to the version this goes back to, so it returns.
|
|
429
|
+
$before = DashboardState::read($web);
|
|
430
|
+
foreach (array_keys(self::LEFTOVERS) as $path) {
|
|
431
|
+
$copy = "{$web}/.nevela/backups/leftovers/{$path}";
|
|
432
|
+
if (isset($before->files[$path]) && ! is_file("{$web}/{$path}") && is_file($copy)) {
|
|
433
|
+
$this->put("{$web}/{$path}", (string) file_get_contents($copy));
|
|
434
|
+
$this->components->twoColumnDetail($path, '<fg=blue>restored</>');
|
|
435
|
+
$restored++;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
386
438
|
|
|
387
439
|
$this->newLine();
|
|
388
440
|
$this->components->info("Put back {$restored} file(s). The dashboard is as it was before the update from {$last['from']} to {$last['to']}.");
|
|
@@ -314,6 +314,66 @@ final class ResourceGenerator
|
|
|
314
314
|
TS);
|
|
315
315
|
}
|
|
316
316
|
|
|
317
|
+
/**
|
|
318
|
+
* How people sign in, for the dashboard's screens: which buttons to draw. The choices
|
|
319
|
+
* are made in the Laravel app's config/nevela.php, which is also what enforces them.
|
|
320
|
+
*
|
|
321
|
+
* @param array<string, mixed> $auth The "auth" section of config/nevela.php
|
|
322
|
+
*/
|
|
323
|
+
public function authConfig(array $auth): GeneratedFile
|
|
324
|
+
{
|
|
325
|
+
$flag = fn (string $key, bool $default = true) => (bool) data_get($auth, $key, $default) ? 'true' : 'false';
|
|
326
|
+
$m = self::M;
|
|
327
|
+
$e = self::E;
|
|
328
|
+
|
|
329
|
+
return new GeneratedFile(GeneratedFile::TARGET_WEB, 'lib/auth-config.ts', <<<TS
|
|
330
|
+
/**
|
|
331
|
+
* How people sign in to this app.
|
|
332
|
+
*
|
|
333
|
+
* Written by `php nevela generate` from the "auth" section of config/nevela.php in the
|
|
334
|
+
* Laravel app. Change a setting there, then generate again: Laravel refuses a method
|
|
335
|
+
* that is switched off, and this file keeps the screens from offering it.
|
|
336
|
+
*
|
|
337
|
+
* Email and password sign-in is always available.
|
|
338
|
+
*/
|
|
339
|
+
export type SocialProvider = "google" | "github" | "apple" | "microsoft";
|
|
340
|
+
|
|
341
|
+
{$m}
|
|
342
|
+
export const authConfig = {
|
|
343
|
+
/** People can create their own account at /sign-up. */
|
|
344
|
+
registration: {$flag('registration', false)},
|
|
345
|
+
/** A sign-in link by email. */
|
|
346
|
+
magicLink: {$flag('magic_link')},
|
|
347
|
+
/** A 6-digit sign-in code by email. */
|
|
348
|
+
emailOtp: {$flag('email_code')},
|
|
349
|
+
/** Face ID, Touch ID, Windows Hello or a security key. */
|
|
350
|
+
passkeys: {$flag('passkeys')},
|
|
351
|
+
twoFactor: {
|
|
352
|
+
/** Codes from an authenticator app (TOTP), with backup codes. */
|
|
353
|
+
authenticator: {$flag('two_factor.authenticator')},
|
|
354
|
+
/** Codes by email as the second step. */
|
|
355
|
+
email: {$flag('two_factor.email')},
|
|
356
|
+
},
|
|
357
|
+
/** Refuse password sign-in until the email address is verified. */
|
|
358
|
+
requireEmailVerification: {$flag('require_email_verification', false)},
|
|
359
|
+
/** Check new passwords against Have I Been Pwned's breach list. */
|
|
360
|
+
checkBreachedPasswords: {$flag('check_breached_passwords')},
|
|
361
|
+
/** Signing in with Google, GitHub and the like isn't part of Nevela yet. */
|
|
362
|
+
social: [] as SocialProvider[],
|
|
363
|
+
};
|
|
364
|
+
{$e}
|
|
365
|
+
|
|
366
|
+
/** Whether any second factor can be set up. */
|
|
367
|
+
export const twoFactorAvailable = authConfig.twoFactor.authenticator || authConfig.twoFactor.email;
|
|
368
|
+
|
|
369
|
+
/** The social providers that are switched on and configured. None yet. */
|
|
370
|
+
export function enabledSocialProviders(): SocialProvider[] {
|
|
371
|
+
return authConfig.social;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
TS);
|
|
375
|
+
}
|
|
376
|
+
|
|
317
377
|
/** @param list<Descriptor> $all */
|
|
318
378
|
public function routes(array $all): GeneratedFile
|
|
319
379
|
{
|