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.
Files changed (70) hide show
  1. package/README.md +17 -6
  2. package/index.mjs +89 -17
  3. package/package.json +2 -1
  4. package/project.mjs +9 -4
  5. package/selfupdate.mjs +13 -3
  6. package/template/laravel/README.md +1 -1
  7. package/template/laravel/composer.json +2 -1
  8. package/template/laravel/config/nevela.php +66 -1
  9. package/template/laravel/database/migrations/2026_10_05_000000_create_nevela_auth_tables.php +66 -0
  10. package/template/laravel/src/Auth/Account.php +140 -0
  11. package/template/laravel/src/Auth/AuthMail.php +77 -0
  12. package/template/laravel/src/Auth/Challenges.php +135 -0
  13. package/template/laravel/src/Auth/Passkeys.php +206 -0
  14. package/template/laravel/src/Auth/Totp.php +106 -0
  15. package/template/laravel/src/Auth/Web.php +75 -0
  16. package/template/laravel/src/Console/GenerateCommand.php +1 -0
  17. package/template/laravel/src/Console/StatusCommand.php +2 -2
  18. package/template/laravel/src/Console/UpdateCommand.php +67 -9
  19. package/template/laravel/src/Generator/ResourceGenerator.php +63 -2
  20. package/template/laravel/src/Http/Auth/AccountController.php +252 -0
  21. package/template/laravel/src/Http/Auth/Answers.php +33 -0
  22. package/template/laravel/src/Http/Auth/SecurityController.php +188 -0
  23. package/template/laravel/src/Http/Auth/SignInController.php +302 -0
  24. package/template/laravel/src/Http/TokenController.php +19 -37
  25. package/template/laravel/src/Http/UploadController.php +24 -12
  26. package/template/laravel/src/Models/Passkey.php +38 -0
  27. package/template/laravel/src/Models/TwoFactor.php +64 -0
  28. package/template/laravel/src/Nevela.php +1 -1
  29. package/template/laravel/src/NevelaServiceProvider.php +72 -4
  30. package/template/laravel/src/Support/Descriptor.php +3 -3
  31. package/template/laravel/src/Support/Field.php +29 -2
  32. package/template/web/app/api/[...path]/route.ts +5 -1
  33. package/template/web/app/api/auth/[...action]/route.ts +196 -0
  34. package/template/web/app/auth-actions.ts +4 -43
  35. package/template/web/app/dashboard/account/layout.tsx +29 -0
  36. package/template/web/app/dashboard/account/page.tsx +19 -0
  37. package/template/web/app/dashboard/account/password/page.tsx +10 -0
  38. package/template/web/app/dashboard/account/security/page.tsx +19 -0
  39. package/template/web/app/dashboard/account/sessions/page.tsx +9 -0
  40. package/template/web/app/dashboard/layout.tsx +2 -1
  41. package/template/web/app/forgot-password/page.tsx +22 -0
  42. package/template/web/app/reset-password/page.tsx +31 -0
  43. package/template/web/app/sign-in/page.tsx +34 -4
  44. package/template/web/app/sign-up/page.tsx +43 -0
  45. package/template/web/app/two-factor/page.tsx +34 -0
  46. package/template/web/app/verify-email/page.tsx +33 -0
  47. package/template/web/components/account/account-nav.tsx +39 -0
  48. package/template/web/components/account/sections.tsx +573 -0
  49. package/template/web/components/auth/otp-input.tsx +95 -0
  50. package/template/web/components/auth/password-field.tsx +116 -0
  51. package/template/web/components/auth/password-forms.tsx +76 -0
  52. package/template/web/components/auth/provider-icons.tsx +39 -0
  53. package/template/web/components/auth/sign-in-flow.tsx +307 -0
  54. package/template/web/components/auth/sign-up-form.tsx +93 -0
  55. package/template/web/components/auth/social-buttons.tsx +56 -0
  56. package/template/web/components/auth/two-factor-form.tsx +144 -0
  57. package/template/web/components/auth/use-resend.ts +44 -0
  58. package/template/web/components/auth/verify-email-form.tsx +147 -0
  59. package/template/web/components/dashboard/dashboard-sidebar.tsx +2 -2
  60. package/template/web/components/dashboard/user-menu.tsx +33 -1
  61. package/template/web/lib/auth-client.ts +279 -0
  62. package/template/web/lib/auth-config.ts +43 -0
  63. package/template/web/lib/laravel.ts +26 -1
  64. package/template/web/lib/password-rules.ts +167 -0
  65. package/template/web/lib/second-step.ts +35 -0
  66. package/template/web/lib/session.ts +12 -4
  67. package/template/web/package.json +3 -2
  68. package/template.mjs +1 -1
  69. package/tool.mjs +58 -0
  70. package/template/web/components/auth/sign-in-form.tsx +0 -50
@@ -0,0 +1,188 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Http\Auth;
4
+
5
+ use Illuminate\Http\JsonResponse;
6
+ use Illuminate\Http\Request;
7
+ use Illuminate\Http\Response;
8
+ use Illuminate\Support\Str;
9
+ use Nevela\Laravel\Auth\Account;
10
+ use Nevela\Laravel\Auth\AuthMail;
11
+ use Nevela\Laravel\Auth\Challenges;
12
+ use Nevela\Laravel\Auth\Passkeys;
13
+ use Nevela\Laravel\Auth\Totp;
14
+ use Nevela\Laravel\Models\Passkey;
15
+ use Nevela\Laravel\Models\TwoFactor;
16
+ use RuntimeException;
17
+
18
+ /**
19
+ * How a signed-in person protects their account: a second step for password sign-in,
20
+ * and passkeys. Anything that weakens the account takes the password again.
21
+ */
22
+ final class SecurityController
23
+ {
24
+ use Answers;
25
+
26
+ private const BACKUP_CODES = 10;
27
+
28
+ /**
29
+ * POST auth/two-factor/enable
30
+ *
31
+ * With `method: "totp"` this starts setting up an authenticator app: it answers with
32
+ * the secret (as the address a QR code holds) and backup codes, and nothing is
33
+ * switched on until a code from the app is confirmed. With `method: "email"` codes
34
+ * are emailed at sign-in, and it is on straight away.
35
+ */
36
+ public function enable(Request $request): JsonResponse
37
+ {
38
+ $input = $request->validate(['password' => ['required', 'string'], 'method' => ['nullable', 'in:totp,email,otp']]);
39
+ if (! $this->passwordConfirmed($request)) {
40
+ return $this->refuse('INVALID_PASSWORD', "That isn't your password.", 422);
41
+ }
42
+ $user = $request->user();
43
+ $method = ($input['method'] ?? 'totp') === 'totp' ? 'totp' : 'email';
44
+ $this->enabled($method === 'totp' ? 'two_factor.authenticator' : 'two_factor.email');
45
+
46
+ [$codes, $hashes] = self::backupCodes();
47
+ $second = TwoFactor::query()->firstOrNew(['user_id' => (string) $user->getAuthIdentifier()]);
48
+
49
+ if ($method === 'email') {
50
+ $second->fill(['backup_codes' => $hashes, 'enabled_at' => $second->enabled_at ?? now()])->save();
51
+
52
+ return response()->json(['enabled' => true, 'backupCodes' => $codes]);
53
+ }
54
+
55
+ $secret = Totp::secret();
56
+ // Not confirmed yet: sign-in keeps working as it did until a code proves the app has the secret.
57
+ $second->fill(['secret' => $secret, 'totp_confirmed_at' => null, 'backup_codes' => $hashes])->save();
58
+
59
+ return response()->json([
60
+ 'enabled' => false,
61
+ 'totpURI' => Totp::uri($secret, (string) $user->email, AuthMail::app()),
62
+ 'secret' => $secret,
63
+ 'backupCodes' => $codes,
64
+ ]);
65
+ }
66
+
67
+ /** POST auth/two-factor/confirm: a code from the authenticator app switches it on. */
68
+ public function confirm(Request $request): JsonResponse
69
+ {
70
+ $input = $request->validate(['code' => ['required', 'string', 'max:16']]);
71
+ $second = Account::twoFactor($request->user());
72
+ if ($second?->secret === null) {
73
+ return $this->refuse('TWO_FACTOR_NOT_STARTED', 'Start setting up two-factor first.', 409);
74
+ }
75
+ // Spent like any other code, so the one that confirmed the app can't then sign in with it.
76
+ if (! $second->spendCode($input['code'])) {
77
+ return $this->refuse('INVALID_TWO_FACTOR_CODE', "That code isn't right. Check it and try again.", 422);
78
+ }
79
+ $second->fill(['totp_confirmed_at' => now(), 'enabled_at' => $second->enabled_at ?? now()])->save();
80
+
81
+ return response()->json(['enabled' => true, 'user' => Account::describe($request->user())]);
82
+ }
83
+
84
+ /** POST auth/two-factor/disable */
85
+ public function disable(Request $request): JsonResponse
86
+ {
87
+ if (! $this->passwordConfirmed($request)) {
88
+ return $this->refuse('INVALID_PASSWORD', "That isn't your password.", 422);
89
+ }
90
+ TwoFactor::query()->where('user_id', (string) $request->user()->getAuthIdentifier())->delete();
91
+ AuthMail::send($request->user()->email, 'Two-factor was turned off for your '.AuthMail::app().' account', ['Two-factor sign-in was just turned off for your account.', "If that wasn't you, change your password now."]);
92
+
93
+ return response()->json(['enabled' => false]);
94
+ }
95
+
96
+ /** POST auth/two-factor/backup-codes: new codes; the old ones stop working. */
97
+ public function regenerateBackupCodes(Request $request): JsonResponse
98
+ {
99
+ if (! $this->passwordConfirmed($request)) {
100
+ return $this->refuse('INVALID_PASSWORD', "That isn't your password.", 422);
101
+ }
102
+ $second = Account::twoFactor($request->user());
103
+ if ($second?->enabled_at === null) {
104
+ return $this->refuse('TWO_FACTOR_NOT_ENABLED', 'Two-factor is not turned on.', 409);
105
+ }
106
+ [$codes, $hashes] = self::backupCodes();
107
+ $second->fill(['backup_codes' => $hashes])->save();
108
+
109
+ return response()->json(['backupCodes' => $codes]);
110
+ }
111
+
112
+ /**
113
+ * Ten codes to keep somewhere safe, each good for one sign-in when the phone isn't
114
+ * to hand. Shown once; only their hashes are kept.
115
+ *
116
+ * @return array{0: list<string>, 1: list<string>}
117
+ */
118
+ private static function backupCodes(): array
119
+ {
120
+ $codes = [];
121
+ for ($i = 0; $i < self::BACKUP_CODES; $i++) {
122
+ $codes[] = strtolower(Str::random(5).'-'.Str::random(5));
123
+ }
124
+
125
+ return [$codes, array_map(Challenges::hash(...), $codes)];
126
+ }
127
+
128
+ /** GET auth/passkeys */
129
+ public function passkeys(Request $request): JsonResponse
130
+ {
131
+ $this->enabled('passkeys');
132
+
133
+ return response()->json([
134
+ 'data' => Passkey::query()->where('user_id', (string) $request->user()->getAuthIdentifier())->orderBy('created_at')->get()->map(self::describe(...))->all(),
135
+ ]);
136
+ }
137
+
138
+ /** POST auth/passkeys/options: start adding a passkey. */
139
+ public function passkeyOptions(Request $request): JsonResponse
140
+ {
141
+ $this->enabled('passkeys');
142
+
143
+ return response()->json(Passkeys::creationOptions($request->user(), $request));
144
+ }
145
+
146
+ /** POST auth/passkeys: finish adding it. */
147
+ public function addPasskey(Request $request): JsonResponse
148
+ {
149
+ $this->enabled('passkeys');
150
+ $input = $request->validate(['challenge' => ['required', 'string'], 'response' => ['required', 'array'], 'name' => ['nullable', 'string', 'max:80']]);
151
+ try {
152
+ $passkey = Passkeys::create($request->user(), $input['challenge'], $input['response'], $input['name'] ?? null, $request);
153
+ } catch (RuntimeException $e) {
154
+ return $this->refuse('PASSKEY_FAILED', $e->getMessage(), 422);
155
+ }
156
+
157
+ return response()->json(self::describe($passkey), 201);
158
+ }
159
+
160
+ /** PATCH auth/passkeys/{id}: rename it. */
161
+ public function renamePasskey(Request $request, string $id): JsonResponse
162
+ {
163
+ $input = $request->validate(['name' => ['required', 'string', 'max:80']]);
164
+ $passkey = Passkey::query()->where('user_id', (string) $request->user()->getAuthIdentifier())->findOrFail($id);
165
+ $passkey->fill(['name' => trim($input['name'])])->save();
166
+
167
+ return response()->json(self::describe($passkey));
168
+ }
169
+
170
+ /** DELETE auth/passkeys/{id} */
171
+ public function removePasskey(Request $request, string $id): Response
172
+ {
173
+ Passkey::query()->where('user_id', (string) $request->user()->getAuthIdentifier())->where('id', $id)->delete();
174
+
175
+ return response()->noContent();
176
+ }
177
+
178
+ /** @return array<string, mixed> */
179
+ private static function describe(Passkey $passkey): array
180
+ {
181
+ return [
182
+ 'id' => $passkey->id,
183
+ 'name' => $passkey->name,
184
+ 'createdAt' => $passkey->created_at?->toJSON(),
185
+ 'lastUsedAt' => $passkey->last_used_at?->toJSON(),
186
+ ];
187
+ }
188
+ }
@@ -0,0 +1,302 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Http\Auth;
4
+
5
+ use Illuminate\Http\JsonResponse;
6
+ use Illuminate\Http\Request;
7
+ use Illuminate\Support\Facades\DB;
8
+ use Illuminate\Support\Facades\Hash;
9
+ use Nevela\Laravel\Auth\Account;
10
+ use Nevela\Laravel\Auth\AuthMail;
11
+ use Nevela\Laravel\Auth\Challenges;
12
+ use Nevela\Laravel\Auth\Passkeys;
13
+ use Nevela\Laravel\Auth\Web;
14
+ use Nevela\Laravel\Models\TwoFactor;
15
+ use RuntimeException;
16
+
17
+ /**
18
+ * Getting signed in: with a password (and a second step when one is set up), a passkey,
19
+ * an emailed link or an emailed code. Each ends the same way, with a Sanctum token.
20
+ *
21
+ * Errors carry a `code` beside the message, so a client can say something more useful
22
+ * than the message, in its own words.
23
+ */
24
+ final class SignInController
25
+ {
26
+ use Answers;
27
+
28
+ /** How long a sign-in waits for its second step, and an emailed code or link lasts. */
29
+ private const SECOND_STEP = 600;
30
+
31
+ private const EMAILED = 300;
32
+
33
+ /** Which ways of signing in are switched on, for a client drawing the sign-in page. */
34
+ public function config(): JsonResponse
35
+ {
36
+ return response()->json(self::methods());
37
+ }
38
+
39
+ /** @return array<string, mixed> */
40
+ public static function methods(): array
41
+ {
42
+ return [
43
+ 'registration' => (bool) config('nevela.auth.registration', false),
44
+ 'magicLink' => (bool) config('nevela.auth.magic_link', true),
45
+ 'emailCode' => (bool) config('nevela.auth.email_code', true),
46
+ 'passkeys' => (bool) config('nevela.auth.passkeys', true),
47
+ 'twoFactor' => [
48
+ 'authenticator' => (bool) config('nevela.auth.two_factor.authenticator', true),
49
+ 'email' => (bool) config('nevela.auth.two_factor.email', true),
50
+ ],
51
+ 'requireEmailVerification' => (bool) config('nevela.auth.require_email_verification', false),
52
+ ];
53
+ }
54
+
55
+ /** POST auth/token: email and password. */
56
+ public function password(Request $request): JsonResponse
57
+ {
58
+ $input = $request->validate([
59
+ 'email' => ['required', 'email'],
60
+ 'password' => ['required', 'string'],
61
+ 'deviceName' => ['nullable', 'string', 'max:255'],
62
+ ]);
63
+
64
+ $user = Account::findByEmail($input['email']);
65
+ // A password is checked even when there is no such account, against a hash of
66
+ // nothing: otherwise "no such account" would answer faster than "wrong password",
67
+ // and the difference tells a visitor which addresses have accounts.
68
+ $right = Hash::check($input['password'], $user ? (string) $user->getAuthPassword() : self::decoy());
69
+ if (! $user || ! $right) {
70
+ return $this->refuse('INVALID_EMAIL_OR_PASSWORD', "That email and password don't match.", 401);
71
+ }
72
+ if (config('nevela.auth.require_email_verification', false) && $user->email_verified_at === null) {
73
+ AccountController::sendVerification($user, $request);
74
+
75
+ return $this->refuse('EMAIL_NOT_VERIFIED', "Verify your email first: we've sent you a new link.", 403);
76
+ }
77
+
78
+ return self::finish($user, $request);
79
+ }
80
+
81
+ /**
82
+ * The first step is done: sign the person in, or ask for the second step.
83
+ *
84
+ * When the first step was an email (a sign-in link, a sign-in code, a verification
85
+ * code), an emailed code is not offered as the second: it would be the same thing
86
+ * twice, and someone who had only got into the mailbox would be let in. So an account
87
+ * with an authenticator app is asked for the app or a backup code, and an account
88
+ * whose only second step is email is signed in, which is all its owner asked for.
89
+ */
90
+ public static function finish(mixed $user, Request $request, bool $byEmail = false): JsonResponse
91
+ {
92
+ $second = Account::twoFactor($user);
93
+ $app = $second?->usesAuthenticator() && config('nevela.auth.two_factor.authenticator', true);
94
+ if ($second?->enabled_at === null || ($byEmail && ! $app)) {
95
+ return response()->json(Account::signIn($user, $request), 201);
96
+ }
97
+
98
+ $methods = array_values(array_filter([
99
+ $app ? 'totp' : null,
100
+ ! $byEmail && config('nevela.auth.two_factor.email', true) ? 'email' : null,
101
+ ($second->backup_codes ?? []) !== [] ? 'backup' : null,
102
+ ]));
103
+
104
+ return response()->json([
105
+ 'twoFactor' => true,
106
+ 'challenge' => Challenges::start('two-factor', ['user' => (string) $user->getAuthIdentifier(), 'device' => $request->input('deviceName'), 'methods' => $methods], self::SECOND_STEP),
107
+ 'methods' => $methods,
108
+ ]);
109
+ }
110
+
111
+ /** A password hash that matches nothing, made once per process with the app's own hashing settings. */
112
+ private static function decoy(): string
113
+ {
114
+ static $hash = null;
115
+
116
+ return $hash ??= Hash::make(bin2hex(random_bytes(16)));
117
+ }
118
+
119
+ /** POST auth/two-factor/send: email a code for a sign-in that is waiting for its second step. */
120
+ public function sendSecondStep(Request $request): JsonResponse
121
+ {
122
+ $this->enabled('two_factor.email');
123
+ $challenge = (string) $request->input('challenge');
124
+ $pending = Challenges::get('two-factor', $challenge);
125
+ $user = $pending ? Account::model()::query()->find($pending['user']) : null;
126
+ if (! $user) {
127
+ return $this->refuse('SESSION_EXPIRED', 'That sign-in has expired. Start again.', 401);
128
+ }
129
+ if (! in_array('email', $pending['methods'] ?? [], true)) {
130
+ return $this->refuse('METHOD_NOT_ALLOWED', "An emailed code can't be the second step for this sign-in. Use your authenticator app or a backup code.", 422);
131
+ }
132
+
133
+ if (! Challenges::mayIssue('two-factor', (string) $pending['user'])) {
134
+ return $this->refuse('TOO_MANY_ATTEMPTS', 'Too many codes were asked for. Try again in an hour, or use another method.', 429);
135
+ }
136
+ [$code, $hash] = Challenges::code();
137
+ Challenges::update('two-factor', $challenge, ['code' => $hash]);
138
+ AuthMail::send($user->email, 'Your '.AuthMail::app().' sign-in code', ["Your sign-in code is {$code}.", 'It expires in 10 minutes.'], null, $code);
139
+
140
+ return response()->json(['sent' => true]);
141
+ }
142
+
143
+ /** POST auth/two-factor/verify: finish a password sign-in with a code. */
144
+ public function secondStep(Request $request): JsonResponse
145
+ {
146
+ $input = $request->validate([
147
+ 'challenge' => ['required', 'string'],
148
+ 'method' => ['required', 'in:totp,email,backup'],
149
+ 'code' => ['required', 'string', 'max:64'],
150
+ ]);
151
+ $pending = Challenges::get('two-factor', $input['challenge']);
152
+ $user = $pending ? Account::model()::query()->find($pending['user']) : null;
153
+ $second = $user ? Account::twoFactor($user) : null;
154
+ if (! $user || $second?->enabled_at === null) {
155
+ return $this->refuse('SESSION_EXPIRED', 'That sign-in has expired. Start again.', 401);
156
+ }
157
+
158
+ if (! in_array($input['method'], $pending['methods'] ?? [], true)) {
159
+ return $this->refuse('METHOD_NOT_ALLOWED', "That isn't a way to finish this sign-in. Use your authenticator app or a backup code.", 422);
160
+ }
161
+ $right = match ($input['method']) {
162
+ 'totp' => $second->usesAuthenticator() && $second->spendCode($input['code']),
163
+ 'email' => isset($pending['code']) && hash_equals($pending['code'], Challenges::hash($input['code'])),
164
+ 'backup' => self::spendBackupCode($second, $input['code']),
165
+ };
166
+ if (! $right) {
167
+ return Challenges::miss('two-factor', $input['challenge'])
168
+ ? $this->refuse('INVALID_TWO_FACTOR_CODE', "That code isn't right. Check it and try again.", 401)
169
+ : $this->refuse('TOO_MANY_ATTEMPTS', 'Too many attempts. Sign in again.', 429);
170
+ }
171
+
172
+ Challenges::forget('two-factor', $input['challenge']);
173
+ $request->merge(['deviceName' => $pending['device'] ?? null]);
174
+
175
+ return response()->json(Account::signIn($user, $request), 201);
176
+ }
177
+
178
+ /**
179
+ * A backup code works once. The row is locked while it is read and written, so two
180
+ * requests arriving together with the same code can't both be let in.
181
+ */
182
+ private static function spendBackupCode(TwoFactor $second, string $code): bool
183
+ {
184
+ $hash = Challenges::hash(strtolower($code));
185
+
186
+ return DB::transaction(function () use ($second, $hash) {
187
+ $row = TwoFactor::query()->whereKey($second->getKey())->lockForUpdate()->first();
188
+ $codes = $row?->backup_codes ?? [];
189
+ foreach ($codes as $index => $stored) {
190
+ if (hash_equals($stored, $hash)) {
191
+ unset($codes[$index]);
192
+ $row->forceFill(['backup_codes' => array_values($codes)])->save();
193
+
194
+ return true;
195
+ }
196
+ }
197
+
198
+ return false;
199
+ });
200
+ }
201
+
202
+ /** POST auth/magic-link: email a link that signs the person in. */
203
+ public function sendLink(Request $request): JsonResponse
204
+ {
205
+ $this->enabled('magic_link');
206
+ $input = $request->validate(['email' => ['required', 'email'], 'next' => ['nullable', 'string', 'max:2000']]);
207
+
208
+ // The answer is the same whether or not the address has an account.
209
+ if ($user = Account::findByEmail($input['email'])) {
210
+ $token = Challenges::start('magic-link', ['user' => (string) $user->getAuthIdentifier()], self::EMAILED);
211
+ $url = Web::url('/api/auth/magic-link', ['token' => $token, 'next' => Web::path($input['next'] ?? null)], $request);
212
+ AuthMail::send($user->email, 'Sign in to '.AuthMail::app(), ['Use this link to sign in. It works once and expires in 5 minutes.'], ['Sign in', $url], $url);
213
+ }
214
+
215
+ return response()->json(['sent' => true]);
216
+ }
217
+
218
+ /** POST auth/magic-link/verify */
219
+ public function link(Request $request): JsonResponse
220
+ {
221
+ $this->enabled('magic_link');
222
+ $pending = Challenges::take('magic-link', (string) $request->input('token'));
223
+ $user = $pending ? Account::model()::query()->find($pending['user']) : null;
224
+ if (! $user) {
225
+ return $this->refuse('INVALID_TOKEN', 'That sign-in link has expired or was already used. Ask for a new one.', 401);
226
+ }
227
+ // Opening a link sent to the address proves the address.
228
+ if ($user->email_verified_at === null) {
229
+ $user->forceFill(['email_verified_at' => now()])->save();
230
+ }
231
+
232
+ return self::finish($user, $request, byEmail: true);
233
+ }
234
+
235
+ /** POST auth/email-code: email a six-digit sign-in code. */
236
+ public function sendCode(Request $request): JsonResponse
237
+ {
238
+ $this->enabled('email_code');
239
+ $input = $request->validate(['email' => ['required', 'email']]);
240
+
241
+ if (($user = Account::findByEmail($input['email'])) && Challenges::mayIssue('email-code', mb_strtolower($user->email))) {
242
+ [$code, $hash] = Challenges::code();
243
+ Challenges::put('email-code', mb_strtolower($user->email), ['user' => (string) $user->getAuthIdentifier(), 'code' => $hash], self::EMAILED);
244
+ AuthMail::send($user->email, 'Your '.AuthMail::app().' sign-in code', ["Your sign-in code is {$code}.", 'It expires in 5 minutes.'], null, $code);
245
+ }
246
+
247
+ return response()->json(['sent' => true]);
248
+ }
249
+
250
+ /** POST auth/email-code/verify */
251
+ public function code(Request $request): JsonResponse
252
+ {
253
+ $this->enabled('email_code');
254
+ $input = $request->validate(['email' => ['required', 'email'], 'code' => ['required', 'string', 'max:16']]);
255
+ $id = mb_strtolower(trim($input['email']));
256
+ $pending = Challenges::get('email-code', $id);
257
+ if ($pending === null) {
258
+ return $this->refuse('OTP_EXPIRED', 'That code has expired. Ask for a new one.', 401);
259
+ }
260
+ if (! hash_equals($pending['code'], Challenges::hash($input['code']))) {
261
+ return Challenges::miss('email-code', $id)
262
+ ? $this->refuse('INVALID_OTP', "That code isn't right. Check it and try again.", 401)
263
+ : $this->refuse('TOO_MANY_ATTEMPTS', 'Too many attempts. Ask for a new code.', 429);
264
+ }
265
+ Challenges::forget('email-code', $id);
266
+ $user = Account::model()::query()->find($pending['user']);
267
+ if (! $user) {
268
+ return $this->refuse('OTP_EXPIRED', 'That code has expired. Ask for a new one.', 401);
269
+ }
270
+ if ($user->email_verified_at === null) {
271
+ $user->forceFill(['email_verified_at' => now()])->save();
272
+ }
273
+
274
+ return self::finish($user, $request, byEmail: true);
275
+ }
276
+
277
+ /** POST auth/passkey/options: what the browser needs to ask the device for a passkey. */
278
+ public function passkeyOptions(Request $request): JsonResponse
279
+ {
280
+ $this->enabled('passkeys');
281
+
282
+ return response()->json(Passkeys::requestOptions($request));
283
+ }
284
+
285
+ /** POST auth/passkey: sign in with the passkey the device answered with. */
286
+ public function passkey(Request $request): JsonResponse
287
+ {
288
+ $this->enabled('passkeys');
289
+ $input = $request->validate(['challenge' => ['required', 'string'], 'response' => ['required', 'array']]);
290
+ try {
291
+ $passkey = Passkeys::verify($input['challenge'], $input['response'], $request);
292
+ } catch (RuntimeException $e) {
293
+ return $this->refuse('PASSKEY_FAILED', $e->getMessage(), 401);
294
+ }
295
+ $user = Account::model()::query()->find($passkey->user_id);
296
+ if (! $user) {
297
+ return $this->refuse('PASSKEY_FAILED', "This passkey's account no longer exists.", 401);
298
+ }
299
+
300
+ return response()->json(Account::signIn($user, $request), 201);
301
+ }
302
+ }
@@ -5,49 +5,36 @@ namespace Nevela\Laravel\Http;
5
5
  use Illuminate\Http\JsonResponse;
6
6
  use Illuminate\Http\Request;
7
7
  use Illuminate\Http\Response;
8
- use Illuminate\Support\Facades\Hash;
9
- use Illuminate\Validation\ValidationException;
10
- use RuntimeException;
8
+ use Nevela\Laravel\Auth\Account;
11
9
 
12
10
  /**
13
- * Sanctum personal access tokens for the Next.js app.
11
+ * The Sanctum token a client is signed in with: who it belongs to, and giving it up.
14
12
  *
15
- * The Next.js server exchanges credentials for a token, keeps it in an httpOnly cookie,
16
- * and sends it as `Authorization: Bearer …` on every call to Laravel. The browser never
17
- * sees the token.
13
+ * The Next.js server keeps the token in an httpOnly cookie and sends it as
14
+ * `Authorization: Bearer …` on every call to Laravel. The browser never sees it.
15
+ * Getting a token is Auth\SignInController's job.
18
16
  */
19
17
  final class TokenController
20
18
  {
21
- public function store(Request $request): JsonResponse
22
- {
23
- $credentials = $request->validate([
24
- 'email' => ['required', 'email'],
25
- 'password' => ['required', 'string'],
26
- 'deviceName' => ['nullable', 'string', 'max:255'],
27
- ]);
28
-
29
- $model = config('auth.providers.users.model');
30
- $user = $model::query()->where('email', $credentials['email'])->first();
31
- if (! $user || ! Hash::check($credentials['password'], $user->getAuthPassword())) {
32
- throw ValidationException::withMessages(['email' => 'These credentials do not match our records.']);
33
- }
34
- if (! method_exists($user, 'createToken')) {
35
- throw new RuntimeException('Add Laravel\Sanctum\HasApiTokens to '.$model.' to issue Nevela tokens.');
36
- }
37
-
38
- $token = $user->createToken($credentials['deviceName'] ?? config('nevela.auth.token_name', 'nevela-web'));
39
-
40
- return response()->json(['token' => $token->plainTextToken, 'user' => self::user($user)], 201);
41
- }
42
-
19
+ /** GET auth/me */
43
20
  public function show(Request $request): JsonResponse
44
21
  {
45
- return response()->json(['user' => self::user($request->user())]);
22
+ $token = $request->user()->currentAccessToken();
23
+
24
+ return response()->json([
25
+ 'user' => self::user($request->user()),
26
+ // Which of the account's devices is asking: its id in GET auth/sessions.
27
+ 'session' => ['id' => $token && method_exists($token, 'getKey') ? (string) $token->getKey() : null],
28
+ ]);
46
29
  }
47
30
 
31
+ /** DELETE auth/token: sign this device out. */
48
32
  public function destroy(Request $request): Response
49
33
  {
50
- $request->user()?->currentAccessToken()?->delete();
34
+ $token = $request->user()?->currentAccessToken();
35
+ if ($token && method_exists($token, 'delete')) {
36
+ $token->delete();
37
+ }
51
38
 
52
39
  return response()->noContent();
53
40
  }
@@ -55,11 +42,6 @@ final class TokenController
55
42
  /** What the dashboard needs to know about the signed-in user. */
56
43
  public static function user(mixed $user): array
57
44
  {
58
- return [
59
- 'id' => (string) $user->getAuthIdentifier(),
60
- 'name' => $user->name ?? null,
61
- 'email' => $user->email ?? null,
62
- 'role' => $user->role ?? null,
63
- ];
45
+ return Account::describe($user);
64
46
  }
65
47
  }
@@ -12,6 +12,8 @@ use Nevela\Laravel\Media\UploadRejected;
12
12
  use Nevela\Laravel\Media\Uploads;
13
13
  use Nevela\Laravel\Models\Upload;
14
14
  use Nevela\Laravel\Nevela;
15
+ use Nevela\Laravel\Support\Descriptor;
16
+ use Nevela\Laravel\Support\Field;
15
17
  use Symfony\Component\HttpFoundation\Response;
16
18
 
17
19
  /**
@@ -30,24 +32,36 @@ final class UploadController
30
32
  try {
31
33
  $descriptor = Nevela::resource($resource);
32
34
  } catch (InvalidArgumentException) {
33
- return $this->refuse(404, "There is no resource called {$resource}.");
35
+ return self::refuse(404, "There is no resource called {$resource}.");
34
36
  }
35
37
  $definition = $descriptor->fields[$field] ?? null;
36
38
  if ($definition === null || $definition->kind !== 'file') {
37
- return $this->refuse(404, "{$resource} has no file field called {$field}.");
39
+ return self::refuse(404, "{$resource} has no file field called {$field}.");
38
40
  }
39
41
  $model = 'App\\Models\\'.$descriptor->name;
40
42
  // Storing a file is a write, so it takes the permission to create a record. Being
41
43
  // allowed to look at the resource is not enough.
42
44
  if (class_exists($model) && Gate::getPolicyFor($model) !== null && ! Gate::allows('create', $model)) {
43
- return $this->refuse(403, "You can't upload files to {$descriptor->pluralLabel}.");
45
+ return self::refuse(403, "You can't upload files to {$descriptor->pluralLabel}.");
44
46
  }
45
47
 
48
+ $upload = self::receive($request, $descriptor, $definition);
49
+
50
+ return $upload instanceof JsonResponse ? $upload : response()->json(Uploads::ref($upload->key), 201);
51
+ }
52
+
53
+ /**
54
+ * Take the file in a request's body, check it against a field and store it. Returns
55
+ * the upload, or the response that says why it was refused. Shared with the profile
56
+ * picture, which is an image field on the user without being a generated resource.
57
+ */
58
+ public static function receive(Request $request, Descriptor $descriptor, Field $definition): Upload|JsonResponse
59
+ {
46
60
  $name = basename(str_replace('\\', '/', (string) $request->query('name', 'file')));
47
61
  $limit = Uploads::maxBytes();
48
62
  $megabytes = round($limit / 1024 / 1024, 1);
49
63
  if ((int) $request->header('Content-Length', '0') > $limit) {
50
- return $this->refuse(413, "{$name} is larger than the {$megabytes} MB this field takes.");
64
+ return self::refuse(413, "{$name} is larger than the {$megabytes} MB this field takes.");
51
65
  }
52
66
 
53
67
  // Copied to a temporary file with the limit enforced while it arrives: the length
@@ -62,10 +76,10 @@ final class UploadController
62
76
  fclose($out);
63
77
  }
64
78
  if ($written === false || $written === 0) {
65
- return $this->refuse(422, 'The upload arrived empty. Try again.');
79
+ return self::refuse(422, 'The upload arrived empty. Try again.');
66
80
  }
67
81
  if ($written > $limit) {
68
- return $this->refuse(413, "{$name} is larger than the {$megabytes} MB this field takes.");
82
+ return self::refuse(413, "{$name} is larger than the {$megabytes} MB this field takes.");
69
83
  }
70
84
 
71
85
  $mime = FileTypes::sniff($path);
@@ -75,16 +89,14 @@ final class UploadController
75
89
  if (FileTypes::forbidden($mime) || ! FileTypes::accepts($definition->accept, $effective) || ! FileTypes::consistent($declared, $mime)) {
76
90
  $wanted = in_array('any', $definition->accept, true) ? 'a file this field can store' : implode(' or ', $definition->accept).' data';
77
91
 
78
- return $this->refuse(422, "{$name} doesn't contain {$wanted}. It may have been renamed; choose the original file.");
92
+ return self::refuse(422, "{$name} doesn't contain {$wanted}. It may have been renamed; choose the original file.");
79
93
  }
80
94
 
81
95
  try {
82
- $upload = Uploads::store($descriptor, $definition, $name, $path, strtolower(trim(explode(';', $effective)[0])), (string) ($request->user()?->getAuthIdentifier() ?? '') ?: null);
96
+ return Uploads::store($descriptor, $definition, $name, $path, strtolower(trim(explode(';', $effective)[0])), (string) ($request->user()?->getAuthIdentifier() ?? '') ?: null);
83
97
  } catch (UploadRejected $e) {
84
- return $this->refuse(422, $e->getMessage());
98
+ return self::refuse(422, $e->getMessage());
85
99
  }
86
-
87
- return response()->json(Uploads::ref($upload->key), 201);
88
100
  } finally {
89
101
  @unlink($path);
90
102
  }
@@ -140,7 +152,7 @@ final class UploadController
140
152
  ]);
141
153
  }
142
154
 
143
- private function refuse(int $status, string $message): JsonResponse
155
+ private static function refuse(int $status, string $message): JsonResponse
144
156
  {
145
157
  return response()->json(['error' => $message], $status);
146
158
  }