create-nevela 0.2.1 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -6
- package/index.mjs +89 -17
- package/package.json +2 -1
- package/project.mjs +9 -4
- package/selfupdate.mjs +13 -3
- package/template/laravel/README.md +1 -1
- package/template/laravel/composer.json +2 -1
- package/template/laravel/config/nevela.php +66 -1
- package/template/laravel/database/migrations/2026_10_05_000000_create_nevela_auth_tables.php +66 -0
- package/template/laravel/src/Auth/Account.php +140 -0
- package/template/laravel/src/Auth/AuthMail.php +77 -0
- package/template/laravel/src/Auth/Challenges.php +135 -0
- package/template/laravel/src/Auth/Passkeys.php +206 -0
- package/template/laravel/src/Auth/Totp.php +106 -0
- package/template/laravel/src/Auth/Web.php +75 -0
- package/template/laravel/src/Console/GenerateCommand.php +1 -0
- package/template/laravel/src/Console/StatusCommand.php +2 -2
- package/template/laravel/src/Console/UpdateCommand.php +67 -9
- package/template/laravel/src/Generator/ResourceGenerator.php +63 -2
- package/template/laravel/src/Http/Auth/AccountController.php +252 -0
- package/template/laravel/src/Http/Auth/Answers.php +33 -0
- package/template/laravel/src/Http/Auth/SecurityController.php +188 -0
- package/template/laravel/src/Http/Auth/SignInController.php +302 -0
- package/template/laravel/src/Http/TokenController.php +19 -37
- package/template/laravel/src/Http/UploadController.php +24 -12
- package/template/laravel/src/Models/Passkey.php +38 -0
- package/template/laravel/src/Models/TwoFactor.php +64 -0
- package/template/laravel/src/Nevela.php +1 -1
- package/template/laravel/src/NevelaServiceProvider.php +72 -4
- package/template/laravel/src/Support/Descriptor.php +3 -3
- package/template/laravel/src/Support/Field.php +29 -2
- package/template/web/app/api/[...path]/route.ts +5 -1
- package/template/web/app/api/auth/[...action]/route.ts +196 -0
- package/template/web/app/auth-actions.ts +4 -43
- package/template/web/app/dashboard/account/layout.tsx +29 -0
- package/template/web/app/dashboard/account/page.tsx +19 -0
- package/template/web/app/dashboard/account/password/page.tsx +10 -0
- package/template/web/app/dashboard/account/security/page.tsx +19 -0
- package/template/web/app/dashboard/account/sessions/page.tsx +9 -0
- package/template/web/app/dashboard/layout.tsx +2 -1
- package/template/web/app/forgot-password/page.tsx +22 -0
- package/template/web/app/reset-password/page.tsx +31 -0
- package/template/web/app/sign-in/page.tsx +34 -4
- package/template/web/app/sign-up/page.tsx +43 -0
- package/template/web/app/two-factor/page.tsx +34 -0
- package/template/web/app/verify-email/page.tsx +33 -0
- package/template/web/components/account/account-nav.tsx +39 -0
- package/template/web/components/account/sections.tsx +573 -0
- package/template/web/components/auth/otp-input.tsx +95 -0
- package/template/web/components/auth/password-field.tsx +116 -0
- package/template/web/components/auth/password-forms.tsx +76 -0
- package/template/web/components/auth/provider-icons.tsx +39 -0
- package/template/web/components/auth/sign-in-flow.tsx +307 -0
- package/template/web/components/auth/sign-up-form.tsx +93 -0
- package/template/web/components/auth/social-buttons.tsx +56 -0
- package/template/web/components/auth/two-factor-form.tsx +144 -0
- package/template/web/components/auth/use-resend.ts +44 -0
- package/template/web/components/auth/verify-email-form.tsx +147 -0
- package/template/web/components/dashboard/dashboard-sidebar.tsx +2 -2
- package/template/web/components/dashboard/user-menu.tsx +33 -1
- package/template/web/lib/auth-client.ts +279 -0
- package/template/web/lib/auth-config.ts +43 -0
- package/template/web/lib/laravel.ts +26 -1
- package/template/web/lib/password-rules.ts +167 -0
- package/template/web/lib/second-step.ts +35 -0
- package/template/web/lib/session.ts +12 -4
- package/template/web/package.json +3 -2
- package/template.mjs +1 -1
- package/tool.mjs +58 -0
- package/template/web/components/auth/sign-in-form.tsx +0 -50
|
@@ -0,0 +1,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
|
|
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
|
}
|