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.
Files changed (61) hide show
  1. package/index.mjs +12 -1
  2. package/package.json +1 -1
  3. package/template/laravel/composer.json +2 -1
  4. package/template/laravel/config/nevela.php +66 -1
  5. package/template/laravel/database/migrations/2026_10_05_000000_create_nevela_auth_tables.php +66 -0
  6. package/template/laravel/src/Auth/Account.php +140 -0
  7. package/template/laravel/src/Auth/AuthMail.php +77 -0
  8. package/template/laravel/src/Auth/Challenges.php +135 -0
  9. package/template/laravel/src/Auth/Passkeys.php +206 -0
  10. package/template/laravel/src/Auth/Totp.php +106 -0
  11. package/template/laravel/src/Auth/Web.php +75 -0
  12. package/template/laravel/src/Console/GenerateCommand.php +1 -0
  13. package/template/laravel/src/Console/UpdateCommand.php +56 -4
  14. package/template/laravel/src/Generator/ResourceGenerator.php +60 -0
  15. package/template/laravel/src/Http/Auth/AccountController.php +252 -0
  16. package/template/laravel/src/Http/Auth/Answers.php +33 -0
  17. package/template/laravel/src/Http/Auth/SecurityController.php +188 -0
  18. package/template/laravel/src/Http/Auth/SignInController.php +302 -0
  19. package/template/laravel/src/Http/TokenController.php +19 -37
  20. package/template/laravel/src/Http/UploadController.php +24 -12
  21. package/template/laravel/src/Models/Passkey.php +38 -0
  22. package/template/laravel/src/Models/TwoFactor.php +64 -0
  23. package/template/laravel/src/Nevela.php +1 -1
  24. package/template/laravel/src/NevelaServiceProvider.php +72 -4
  25. package/template/web/app/api/[...path]/route.ts +5 -1
  26. package/template/web/app/api/auth/[...action]/route.ts +196 -0
  27. package/template/web/app/auth-actions.ts +4 -43
  28. package/template/web/app/dashboard/account/layout.tsx +29 -0
  29. package/template/web/app/dashboard/account/page.tsx +19 -0
  30. package/template/web/app/dashboard/account/password/page.tsx +10 -0
  31. package/template/web/app/dashboard/account/security/page.tsx +19 -0
  32. package/template/web/app/dashboard/account/sessions/page.tsx +9 -0
  33. package/template/web/app/dashboard/layout.tsx +2 -1
  34. package/template/web/app/forgot-password/page.tsx +22 -0
  35. package/template/web/app/reset-password/page.tsx +31 -0
  36. package/template/web/app/sign-in/page.tsx +34 -4
  37. package/template/web/app/sign-up/page.tsx +43 -0
  38. package/template/web/app/two-factor/page.tsx +34 -0
  39. package/template/web/app/verify-email/page.tsx +33 -0
  40. package/template/web/components/account/account-nav.tsx +39 -0
  41. package/template/web/components/account/sections.tsx +573 -0
  42. package/template/web/components/auth/otp-input.tsx +95 -0
  43. package/template/web/components/auth/password-field.tsx +116 -0
  44. package/template/web/components/auth/password-forms.tsx +76 -0
  45. package/template/web/components/auth/provider-icons.tsx +39 -0
  46. package/template/web/components/auth/sign-in-flow.tsx +307 -0
  47. package/template/web/components/auth/sign-up-form.tsx +93 -0
  48. package/template/web/components/auth/social-buttons.tsx +56 -0
  49. package/template/web/components/auth/two-factor-form.tsx +144 -0
  50. package/template/web/components/auth/use-resend.ts +44 -0
  51. package/template/web/components/auth/verify-email-form.tsx +147 -0
  52. package/template/web/components/dashboard/dashboard-sidebar.tsx +2 -2
  53. package/template/web/components/dashboard/user-menu.tsx +33 -1
  54. package/template/web/lib/auth-client.ts +279 -0
  55. package/template/web/lib/auth-config.ts +43 -0
  56. package/template/web/lib/laravel.ts +26 -1
  57. package/template/web/lib/password-rules.ts +167 -0
  58. package/template/web/lib/second-step.ts +35 -0
  59. package/template/web/lib/session.ts +12 -4
  60. package/template/web/package.json +3 -2
  61. package/template/web/components/auth/sign-in-form.tsx +0 -50
@@ -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
  }
@@ -0,0 +1,38 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Models;
4
+
5
+ use Illuminate\Database\Eloquent\Concerns\HasUuids;
6
+ use Illuminate\Database\Eloquent\Model;
7
+
8
+ /**
9
+ * A passkey: the public half of a key pair whose private half never leaves the person's
10
+ * device. Nothing here is secret.
11
+ *
12
+ * @property string $user_id
13
+ * @property string|null $name
14
+ * @property string $credential_id base64url
15
+ * @property string $public_key PEM
16
+ * @property int $counter
17
+ * @property list<string>|null $transports
18
+ * @property \Illuminate\Support\Carbon|null $last_used_at
19
+ */
20
+ class Passkey extends Model
21
+ {
22
+ use HasUuids;
23
+
24
+ protected $table = 'nevela_passkeys';
25
+
26
+ protected $guarded = [];
27
+
28
+ protected $hidden = ['public_key'];
29
+
30
+ protected function casts(): array
31
+ {
32
+ return [
33
+ 'counter' => 'integer',
34
+ 'transports' => 'array',
35
+ 'last_used_at' => 'datetime',
36
+ ];
37
+ }
38
+ }
@@ -0,0 +1,64 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Models;
4
+
5
+ use Illuminate\Database\Eloquent\Concerns\HasUuids;
6
+ use Illuminate\Database\Eloquent\Model;
7
+
8
+ /**
9
+ * One person's second step for signing in.
10
+ *
11
+ * The authenticator app's secret and the backup codes are encrypted with the app key:
12
+ * a copy of the database alone is not enough to produce a code. Backup codes are also
13
+ * only kept as hashes.
14
+ *
15
+ * @property string $user_id
16
+ * @property string|null $secret
17
+ * @property \Illuminate\Support\Carbon|null $totp_confirmed_at
18
+ * @property list<string>|null $backup_codes
19
+ * @property \Illuminate\Support\Carbon|null $enabled_at
20
+ */
21
+ class TwoFactor extends Model
22
+ {
23
+ use HasUuids;
24
+
25
+ protected $table = 'nevela_two_factor';
26
+
27
+ protected $guarded = [];
28
+
29
+ protected $hidden = ['secret', 'backup_codes'];
30
+
31
+ protected function casts(): array
32
+ {
33
+ return [
34
+ 'secret' => 'encrypted',
35
+ 'backup_codes' => 'encrypted:array',
36
+ 'totp_confirmed_at' => 'datetime',
37
+ 'enabled_at' => 'datetime',
38
+ ];
39
+ }
40
+
41
+ /**
42
+ * Accept a code from the authenticator app, once. False when the code is wrong, or
43
+ * when it is one that was already used (or older than one that was).
44
+ */
45
+ public function spendCode(string $code): bool
46
+ {
47
+ $step = $this->secret === null ? null : \Nevela\Laravel\Auth\Totp::step((string) $this->secret, $code);
48
+ if ($step === null) {
49
+ return false;
50
+ }
51
+ // One statement decides it, so two requests with the same code can't both win.
52
+ $taken = static::query()->whereKey($this->getKey())
53
+ ->where(fn ($query) => $query->whereNull('totp_last_step')->orWhere('totp_last_step', '<', $step))
54
+ ->update(['totp_last_step' => $step]);
55
+
56
+ return $taken === 1;
57
+ }
58
+
59
+ /** Whether codes from an authenticator app are set up and confirmed. */
60
+ public function usesAuthenticator(): bool
61
+ {
62
+ return $this->secret !== null && $this->totp_confirmed_at !== null;
63
+ }
64
+ }
@@ -18,7 +18,7 @@ use Nevela\Laravel\Support\Naming;
18
18
  final class Nevela
19
19
  {
20
20
  /** Written by `pnpm release`; the same number as the repository's git tag. */
21
- public const VERSION = '0.3.0';
21
+ public const VERSION = '0.4.1';
22
22
 
23
23
  /** @var array<string, Descriptor> */
24
24
  private static array $descriptors = [];