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,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
|
|
9
|
-
use Illuminate\Validation\ValidationException;
|
|
10
|
-
use RuntimeException;
|
|
8
|
+
use Nevela\Laravel\Auth\Account;
|
|
11
9
|
|
|
12
10
|
/**
|
|
13
|
-
* Sanctum
|
|
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
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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()
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
79
|
+
return self::refuse(422, 'The upload arrived empty. Try again.');
|
|
66
80
|
}
|
|
67
81
|
if ($written > $limit) {
|
|
68
|
-
return
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
21
|
+
public const VERSION = '0.4.1';
|
|
22
22
|
|
|
23
23
|
/** @var array<string, Descriptor> */
|
|
24
24
|
private static array $descriptors = [];
|