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
package/index.mjs
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// No dependencies on purpose: this runs before anything is installed.
|
|
8
8
|
|
|
9
9
|
import { spawn } from 'node:child_process';
|
|
10
|
+
import crypto from 'node:crypto';
|
|
10
11
|
import fs from 'node:fs';
|
|
11
12
|
import os from 'node:os';
|
|
12
13
|
import path from 'node:path';
|
|
@@ -454,7 +455,14 @@ async function main() {
|
|
|
454
455
|
// The dashboard first, so its packages can download while Composer works on Laravel.
|
|
455
456
|
// The two don't touch each other, and Composer is the long part.
|
|
456
457
|
copyWebTemplate(web, { name, title });
|
|
457
|
-
|
|
458
|
+
// Shared by the dashboard and the API, so the API can believe what the dashboard tells it
|
|
459
|
+
// about a person's browser (for the list of devices signed in to an account). Made here,
|
|
460
|
+
// per app, and never the same twice.
|
|
461
|
+
const proxySecret = crypto.randomBytes(24).toString('hex');
|
|
462
|
+
write(
|
|
463
|
+
path.join(web, '.env.local'),
|
|
464
|
+
`# Where the Laravel API is, including its prefix.\nNEVELA_API_URL=http://127.0.0.1:8000/api\n\n# The same value as in apps/api/.env. It lets the API trust this app's word on a visitor's browser.\nNEVELA_PROXY_SECRET=${proxySecret}\n`,
|
|
465
|
+
);
|
|
458
466
|
if (pm === 'pnpm') {
|
|
459
467
|
// Its own workspace file: lets sharp build, and keeps pnpm from adopting a workspace further up.
|
|
460
468
|
write(path.join(web, 'pnpm-workspace.yaml'), 'allowBuilds:\n sharp: true\n');
|
|
@@ -510,6 +518,9 @@ async function main() {
|
|
|
510
518
|
// artisan command is slow for the same reason the index was: files read for the first time.
|
|
511
519
|
const env = path.join(api, '.env');
|
|
512
520
|
if (!fs.existsSync(env) && fs.existsSync(`${env}.example`)) fs.copyFileSync(`${env}.example`, env);
|
|
521
|
+
if (fs.existsSync(env) && !/^NEVELA_PROXY_SECRET=/m.test(fs.readFileSync(env, 'utf8'))) {
|
|
522
|
+
fs.appendFileSync(env, `\n# The same value as in apps/web/.env.local.\nNEVELA_PROXY_SECRET=${proxySecret}\n`);
|
|
523
|
+
}
|
|
513
524
|
const sqlite = path.join(api, 'database', 'database.sqlite');
|
|
514
525
|
if (/^DB_CONNECTION=sqlite\s*$/m.test(fs.readFileSync(env, 'utf8')) && !fs.existsSync(sqlite)) fs.writeFileSync(sqlite, '');
|
|
515
526
|
return addApiTokens(path.join(api, 'app', 'Models', 'User.php'));
|
package/package.json
CHANGED
|
@@ -30,7 +30,8 @@
|
|
|
30
30
|
"illuminate/support": "^11.0|^12.0|^13.0",
|
|
31
31
|
"illuminate/database": "^11.17|^12.0|^13.0",
|
|
32
32
|
"illuminate/http": "^11.0|^12.0|^13.0",
|
|
33
|
-
"illuminate/console": "^11.0|^12.0|^13.0"
|
|
33
|
+
"illuminate/console": "^11.0|^12.0|^13.0",
|
|
34
|
+
"lbuchs/webauthn": "^2.2"
|
|
34
35
|
},
|
|
35
36
|
"require-dev": {
|
|
36
37
|
"phpunit/phpunit": "^11.0"
|
|
@@ -24,10 +24,75 @@ return [
|
|
|
24
24
|
'prefix' => 'api',
|
|
25
25
|
'middleware' => ['api', 'auth:sanctum'],
|
|
26
26
|
|
|
27
|
-
/*
|
|
27
|
+
/*
|
|
28
|
+
| Signing in. Every endpoint is under {prefix}/auth. Email and password is always
|
|
29
|
+
| available; the rest can be switched off here. A method that is off is refused by the
|
|
30
|
+
| API, not just hidden, and `php artisan nevela:generate` writes these choices to the
|
|
31
|
+
| dashboard (apps/web/lib/auth-config.ts) so its screens agree.
|
|
32
|
+
*/
|
|
28
33
|
'auth' => [
|
|
29
34
|
'enabled' => true,
|
|
30
35
|
'token_name' => 'nevela-web',
|
|
36
|
+
|
|
37
|
+
/*
|
|
38
|
+
| Let anyone create their own account at /sign-up. Off by default, and accounts come
|
|
39
|
+
| from `php nevela user`: a generated policy lets every signed-in user do everything
|
|
40
|
+
| until you tighten it, so open sign-up on an untouched app would hand the dashboard
|
|
41
|
+
| to whoever finds it. Tighten app/Policies first, then set NEVELA_REGISTRATION=true.
|
|
42
|
+
*/
|
|
43
|
+
'registration' => (bool) env('NEVELA_REGISTRATION', false),
|
|
44
|
+
|
|
45
|
+
/* A sign-in link by email. */
|
|
46
|
+
'magic_link' => true,
|
|
47
|
+
|
|
48
|
+
/* A 6-digit sign-in code by email. */
|
|
49
|
+
'email_code' => true,
|
|
50
|
+
|
|
51
|
+
/* Face ID, Touch ID, Windows Hello or a security key. */
|
|
52
|
+
'passkeys' => true,
|
|
53
|
+
|
|
54
|
+
'two_factor' => [
|
|
55
|
+
/* Codes from an authenticator app (TOTP), with backup codes. */
|
|
56
|
+
'authenticator' => true,
|
|
57
|
+
/* Codes by email as the second step. */
|
|
58
|
+
'email' => true,
|
|
59
|
+
],
|
|
60
|
+
|
|
61
|
+
/* Refuse password sign-in until the email address is verified. */
|
|
62
|
+
'require_email_verification' => false,
|
|
63
|
+
|
|
64
|
+
/*
|
|
65
|
+
| Check new passwords against Have I Been Pwned's breach list. Only the first five
|
|
66
|
+
| characters of the password's hash are sent, and an outage lets the password through.
|
|
67
|
+
*/
|
|
68
|
+
'check_breached_passwords' => true,
|
|
69
|
+
|
|
70
|
+
/*
|
|
71
|
+
| Sign-in attempts are limited to ten a minute per account. This is the limit per
|
|
72
|
+
| address on top of that. It is high because the dashboard's server makes the calls:
|
|
73
|
+
| to Laravel, everyone using the dashboard comes from that one address.
|
|
74
|
+
*/
|
|
75
|
+
'attempts_per_address' => 300,
|
|
76
|
+
|
|
77
|
+
/* The role a self-registered account starts with. Null leaves the column alone. */
|
|
78
|
+
'default_role' => null,
|
|
79
|
+
|
|
80
|
+
/*
|
|
81
|
+
| Where the dashboard is, for the links in emails and for passkeys, which are tied
|
|
82
|
+
| to its address. In development any localhost port is accepted as well, because
|
|
83
|
+
| the dashboard moves to another port when 3000 is taken.
|
|
84
|
+
*/
|
|
85
|
+
'web_url' => env('NEVELA_WEB_URL', 'http://localhost:3000'),
|
|
86
|
+
|
|
87
|
+
/*
|
|
88
|
+
| A secret the dashboard's server and Laravel share, so Laravel can believe what the
|
|
89
|
+
| dashboard says about a person's browser and address (for the list of devices).
|
|
90
|
+
| Put the same value in the dashboard's .env.local. New apps get one when created.
|
|
91
|
+
*/
|
|
92
|
+
'proxy_secret' => env('NEVELA_PROXY_SECRET'),
|
|
93
|
+
|
|
94
|
+
/* The name an authenticator app and a passkey prompt show. Null: the app's name. */
|
|
95
|
+
'issuer' => null,
|
|
31
96
|
],
|
|
32
97
|
|
|
33
98
|
/* List defaults, matching Flare's client. */
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
use Illuminate\Database\Migrations\Migration;
|
|
4
|
+
use Illuminate\Database\Schema\Blueprint;
|
|
5
|
+
use Illuminate\Support\Facades\Schema;
|
|
6
|
+
|
|
7
|
+
// What two-factor sign-in, passkeys, the profile picture and the list of devices need.
|
|
8
|
+
// Columns are only added where they are missing, so an app that already has an "avatar"
|
|
9
|
+
// column, say, keeps its own.
|
|
10
|
+
return new class extends Migration
|
|
11
|
+
{
|
|
12
|
+
public function up(): void
|
|
13
|
+
{
|
|
14
|
+
if (Schema::hasTable('users') && ! Schema::hasColumn('users', 'avatar')) {
|
|
15
|
+
Schema::table('users', function (Blueprint $table) {
|
|
16
|
+
// The key of an uploaded image (see nevela_uploads), not a URL.
|
|
17
|
+
$table->string('avatar', 512)->nullable();
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// A device is a Sanctum token; these say which browser and where it signed in from.
|
|
22
|
+
if (Schema::hasTable('personal_access_tokens')) {
|
|
23
|
+
Schema::table('personal_access_tokens', function (Blueprint $table) {
|
|
24
|
+
if (! Schema::hasColumn('personal_access_tokens', 'ip_address')) {
|
|
25
|
+
$table->string('ip_address', 45)->nullable();
|
|
26
|
+
}
|
|
27
|
+
if (! Schema::hasColumn('personal_access_tokens', 'user_agent')) {
|
|
28
|
+
$table->string('user_agent', 512)->nullable();
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
Schema::create('nevela_two_factor', function (Blueprint $table) {
|
|
34
|
+
$table->uuid('id')->primary();
|
|
35
|
+
$table->string('user_id')->unique();
|
|
36
|
+
// The authenticator app's secret, encrypted. Null when only email codes are used.
|
|
37
|
+
$table->text('secret')->nullable();
|
|
38
|
+
$table->timestamp('totp_confirmed_at')->nullable();
|
|
39
|
+
// The 30-second step of the last code accepted, so the same code isn't taken twice.
|
|
40
|
+
$table->unsignedBigInteger('totp_last_step')->nullable();
|
|
41
|
+
// Hashes of the backup codes not used yet, encrypted.
|
|
42
|
+
$table->text('backup_codes')->nullable();
|
|
43
|
+
$table->timestamp('enabled_at')->nullable();
|
|
44
|
+
$table->timestamps();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
Schema::create('nevela_passkeys', function (Blueprint $table) {
|
|
48
|
+
$table->uuid('id')->primary();
|
|
49
|
+
$table->string('user_id')->index();
|
|
50
|
+
$table->string('name')->nullable();
|
|
51
|
+
$table->string('credential_id', 1024)->unique();
|
|
52
|
+
$table->text('public_key');
|
|
53
|
+
$table->unsignedBigInteger('counter')->default(0);
|
|
54
|
+
$table->json('transports')->nullable();
|
|
55
|
+
$table->string('aaguid')->nullable();
|
|
56
|
+
$table->timestamp('last_used_at')->nullable();
|
|
57
|
+
$table->timestamps();
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
public function down(): void
|
|
62
|
+
{
|
|
63
|
+
Schema::dropIfExists('nevela_passkeys');
|
|
64
|
+
Schema::dropIfExists('nevela_two_factor');
|
|
65
|
+
}
|
|
66
|
+
};
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Nevela\Laravel\Auth;
|
|
4
|
+
|
|
5
|
+
use Illuminate\Http\Request;
|
|
6
|
+
use Illuminate\Support\Facades\DB;
|
|
7
|
+
use Illuminate\Support\Facades\Schema;
|
|
8
|
+
use Illuminate\Validation\Rules\Password;
|
|
9
|
+
use Nevela\Laravel\Media\Uploads;
|
|
10
|
+
use Nevela\Laravel\Models\TwoFactor;
|
|
11
|
+
use RuntimeException;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The signed-in person, as the API describes them, and the tokens that are their devices.
|
|
15
|
+
*/
|
|
16
|
+
final class Account
|
|
17
|
+
{
|
|
18
|
+
/** The app's user model. */
|
|
19
|
+
public static function model(): string
|
|
20
|
+
{
|
|
21
|
+
return (string) config('auth.providers.users.model');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
public static function findByEmail(string $email): mixed
|
|
25
|
+
{
|
|
26
|
+
return self::model()::query()->whereRaw('lower(email) = ?', [mb_strtolower(trim($email))])->first();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* What a client needs to know about a user.
|
|
31
|
+
*
|
|
32
|
+
* @return array<string, mixed>
|
|
33
|
+
*/
|
|
34
|
+
public static function describe(mixed $user): array
|
|
35
|
+
{
|
|
36
|
+
$avatar = $user->avatar ?? null;
|
|
37
|
+
$file = is_string($avatar) && $avatar !== '' ? Uploads::ref($avatar) : null;
|
|
38
|
+
|
|
39
|
+
return [
|
|
40
|
+
'id' => (string) $user->getAuthIdentifier(),
|
|
41
|
+
'name' => $user->name ?? null,
|
|
42
|
+
'email' => $user->email ?? null,
|
|
43
|
+
'role' => $user->role ?? null,
|
|
44
|
+
'emailVerified' => ($user->email_verified_at ?? null) !== null,
|
|
45
|
+
'twoFactorEnabled' => self::twoFactor($user)?->enabled_at !== null,
|
|
46
|
+
// The key is what is stored; "image" is the small rendition, ready for an <img>.
|
|
47
|
+
'avatar' => $file ? $avatar : null,
|
|
48
|
+
'avatarFile' => $file,
|
|
49
|
+
'image' => $file ? ($file['renditions']->thumb['url'] ?? $file['url']) : null,
|
|
50
|
+
];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
public static function twoFactor(mixed $user): ?TwoFactor
|
|
54
|
+
{
|
|
55
|
+
return TwoFactor::query()->where('user_id', (string) $user->getAuthIdentifier())->first();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Sign someone in: a new Sanctum token, remembered with the browser it was made for.
|
|
60
|
+
*
|
|
61
|
+
* @return array{token: string, user: array<string, mixed>}
|
|
62
|
+
*/
|
|
63
|
+
public static function signIn(mixed $user, Request $request): array
|
|
64
|
+
{
|
|
65
|
+
if (! method_exists($user, 'createToken')) {
|
|
66
|
+
throw new RuntimeException('Add Laravel\Sanctum\HasApiTokens to '.self::model().' to issue Nevela tokens.');
|
|
67
|
+
}
|
|
68
|
+
$name = (string) ($request->input('deviceName') ?: config('nevela.auth.token_name', 'nevela-web'));
|
|
69
|
+
$token = $user->createToken(mb_substr($name, 0, 255));
|
|
70
|
+
self::remember($token->accessToken->getKey(), $request);
|
|
71
|
+
|
|
72
|
+
return ['token' => $token->plainTextToken, 'user' => self::describe($user)];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The browser's address and name. The dashboard's server makes the call to Laravel,
|
|
77
|
+
* so it passes these on from the person's own request; a direct API client is itself.
|
|
78
|
+
*/
|
|
79
|
+
private static function remember(mixed $tokenId, Request $request): void
|
|
80
|
+
{
|
|
81
|
+
if (! Schema::hasColumn('personal_access_tokens', 'user_agent')) {
|
|
82
|
+
return; // the migration hasn't been run yet; signing in still works
|
|
83
|
+
}
|
|
84
|
+
$dashboard = self::fromDashboard($request);
|
|
85
|
+
$ip = $dashboard ? (string) $request->header('X-Nevela-Ip', '') : '';
|
|
86
|
+
$agent = $dashboard ? (string) $request->header('X-Nevela-User-Agent', '') : '';
|
|
87
|
+
DB::table('personal_access_tokens')->where('id', $tokenId)->update([
|
|
88
|
+
'ip_address' => filter_var($ip, FILTER_VALIDATE_IP) ? $ip : $request->ip(),
|
|
89
|
+
'user_agent' => mb_substr($agent !== '' ? $agent : (string) $request->userAgent(), 0, 512) ?: null,
|
|
90
|
+
]);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Whether a request comes from the dashboard's server, and so may speak for a browser.
|
|
95
|
+
*
|
|
96
|
+
* Anyone can call the API directly and send the same headers, so they are believed only
|
|
97
|
+
* with the secret the dashboard and Laravel share (NEVELA_PROXY_SECRET in both). Without
|
|
98
|
+
* one configured they are believed from this machine while the app is in development,
|
|
99
|
+
* and otherwise not at all: a device then shows the address the request really came from.
|
|
100
|
+
*/
|
|
101
|
+
public static function fromDashboard(Request $request): bool
|
|
102
|
+
{
|
|
103
|
+
$secret = config('nevela.auth.proxy_secret');
|
|
104
|
+
if (is_string($secret) && $secret !== '') {
|
|
105
|
+
return hash_equals($secret, (string) $request->header('X-Nevela-Proxy-Secret', ''));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return app()->environment('local') && in_array($request->ip(), ['127.0.0.1', '::1'], true);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The rules a new password has to meet. Length is what is required; the dashboard
|
|
113
|
+
* shows the rest as advice. A password found in a breach is refused, because it is
|
|
114
|
+
* in the lists attackers try first however strong it looks.
|
|
115
|
+
*
|
|
116
|
+
* @return list<mixed>
|
|
117
|
+
*/
|
|
118
|
+
public static function passwordRules(): array
|
|
119
|
+
{
|
|
120
|
+
$rule = Password::min(8);
|
|
121
|
+
if (config('nevela.auth.check_breached_passwords', true) && ! app()->runningUnitTests()) {
|
|
122
|
+
$rule = $rule->uncompromised();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return ['required', 'string', 'max:128', $rule];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Sign every device out, except the one asking when `$except` is given. */
|
|
129
|
+
public static function revokeTokens(mixed $user, mixed $except = null): int
|
|
130
|
+
{
|
|
131
|
+
$query = DB::table('personal_access_tokens')
|
|
132
|
+
->where('tokenable_type', $user->getMorphClass())
|
|
133
|
+
->where('tokenable_id', $user->getAuthIdentifier());
|
|
134
|
+
if ($except !== null) {
|
|
135
|
+
$query->where('id', '!=', $except);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return $query->delete();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Nevela\Laravel\Auth;
|
|
4
|
+
|
|
5
|
+
use Illuminate\Notifications\Messages\MailMessage;
|
|
6
|
+
use Illuminate\Notifications\Notification;
|
|
7
|
+
use Illuminate\Support\Facades\Notification as Notifications;
|
|
8
|
+
use Throwable;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The emails signing in needs: codes, links, and notices that something changed.
|
|
12
|
+
*
|
|
13
|
+
* Sent with whatever mailer the Laravel app is configured with. In development that is
|
|
14
|
+
* usually the log, where nobody looks, so the code or link is also printed to the
|
|
15
|
+
* server's output: with `nevela dev` it appears in the terminal as it is "sent".
|
|
16
|
+
*/
|
|
17
|
+
final class AuthMail
|
|
18
|
+
{
|
|
19
|
+
/**
|
|
20
|
+
* @param list<string> $lines Paragraphs, in order
|
|
21
|
+
* @param array{0: string, 1: string}|null $action A button: [label, url]
|
|
22
|
+
* @param string|null $secret The code or link, for the development console
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* What was sent while tests run, so a test can read the code it was "emailed".
|
|
26
|
+
*
|
|
27
|
+
* @var list<array{email: string, subject: string, lines: list<string>, action: array{0: string, 1: string}|null, secret: string|null}>
|
|
28
|
+
*/
|
|
29
|
+
public static array $outbox = [];
|
|
30
|
+
|
|
31
|
+
public static function send(string $email, string $subject, array $lines, ?array $action = null, ?string $secret = null): void
|
|
32
|
+
{
|
|
33
|
+
if (app()->runningUnitTests()) {
|
|
34
|
+
self::$outbox[] = compact('email', 'subject', 'lines', 'action', 'secret');
|
|
35
|
+
}
|
|
36
|
+
if (app()->environment('local') && $secret !== null) {
|
|
37
|
+
// The dev server shows its error output; nothing else of the request is printed there.
|
|
38
|
+
@file_put_contents('php://stderr', "Nevela mail to {$email}: {$subject}\n {$secret}\n");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
Notifications::route('mail', $email)->notify(new class($subject, $lines, $action) extends Notification
|
|
43
|
+
{
|
|
44
|
+
/** @param list<string> $lines @param array{0: string, 1: string}|null $action */
|
|
45
|
+
public function __construct(private string $subject, private array $lines, private ?array $action) {}
|
|
46
|
+
|
|
47
|
+
/** @return list<string> */
|
|
48
|
+
public function via(object $notifiable): array
|
|
49
|
+
{
|
|
50
|
+
return ['mail'];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
public function toMail(object $notifiable): MailMessage
|
|
54
|
+
{
|
|
55
|
+
$message = (new MailMessage)->subject($this->subject);
|
|
56
|
+
foreach ($this->lines as $line) {
|
|
57
|
+
$message->line($line);
|
|
58
|
+
}
|
|
59
|
+
if ($this->action !== null) {
|
|
60
|
+
$message->action($this->action[0], $this->action[1]);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return $message->line("If you didn't ask for this, you can ignore this email.");
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
} catch (Throwable $e) {
|
|
67
|
+
// A mail server being down must not tell the visitor anything, or break sign-in
|
|
68
|
+
// for an address that may not even exist. It is reported for the developer.
|
|
69
|
+
report($e);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
public static function app(): string
|
|
74
|
+
{
|
|
75
|
+
return (string) (config('nevela.auth.issuer') ?: config('app.name', 'Nevela'));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Nevela\Laravel\Auth;
|
|
4
|
+
|
|
5
|
+
use Illuminate\Support\Facades\Cache;
|
|
6
|
+
use Illuminate\Support\Facades\RateLimiter;
|
|
7
|
+
use Illuminate\Support\Str;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Things that are valid once and briefly: a sign-in waiting for its second step, an
|
|
11
|
+
* emailed code, a passkey ceremony's challenge, a sign-in link.
|
|
12
|
+
*
|
|
13
|
+
* Kept in the cache, under a key made from a hash of the id, so reading the cache doesn't
|
|
14
|
+
* give anyone a usable id. Codes are kept as hashes for the same reason.
|
|
15
|
+
*/
|
|
16
|
+
final class Challenges
|
|
17
|
+
{
|
|
18
|
+
/** How many wrong codes are tolerated before the challenge is thrown away. */
|
|
19
|
+
public const ATTEMPTS = 5;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param array<string, mixed> $data
|
|
23
|
+
* @return string The id to hand to the client
|
|
24
|
+
*/
|
|
25
|
+
public static function start(string $type, array $data, int $seconds): string
|
|
26
|
+
{
|
|
27
|
+
$id = Str::random(48);
|
|
28
|
+
Cache::put(self::key($type, $id), $data + ['attempts' => 0, 'expires' => time() + $seconds], $seconds);
|
|
29
|
+
|
|
30
|
+
return $id;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The same, under an id the caller chooses: an emailed code is found again by the
|
|
35
|
+
* address it was sent to.
|
|
36
|
+
*
|
|
37
|
+
* @param array<string, mixed> $data
|
|
38
|
+
*/
|
|
39
|
+
public static function put(string $type, string $id, array $data, int $seconds): void
|
|
40
|
+
{
|
|
41
|
+
Cache::put(self::key($type, $id), $data + ['attempts' => 0, 'expires' => time() + $seconds], $seconds);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** @return array<string, mixed>|null */
|
|
45
|
+
public static function get(string $type, ?string $id): ?array
|
|
46
|
+
{
|
|
47
|
+
if ($id === null || $id === '') {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
$data = Cache::get(self::key($type, $id));
|
|
51
|
+
|
|
52
|
+
return is_array($data) && ($data['expires'] ?? 0) >= time() ? $data : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Read it and make sure it can't be read again. @return array<string, mixed>|null */
|
|
56
|
+
public static function take(string $type, ?string $id): ?array
|
|
57
|
+
{
|
|
58
|
+
$data = self::get($type, $id);
|
|
59
|
+
if ($data !== null) {
|
|
60
|
+
Cache::forget(self::key($type, (string) $id));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return $data;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** @param array<string, mixed> $changes */
|
|
67
|
+
public static function update(string $type, string $id, array $changes): void
|
|
68
|
+
{
|
|
69
|
+
$data = self::get($type, $id);
|
|
70
|
+
if ($data !== null) {
|
|
71
|
+
Cache::put(self::key($type, $id), $changes + $data, max(1, $data['expires'] - time()));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Count a wrong answer. Returns false when that was one too many, and the challenge
|
|
77
|
+
* is gone: whoever is guessing has to start again.
|
|
78
|
+
*/
|
|
79
|
+
public static function miss(string $type, string $id): bool
|
|
80
|
+
{
|
|
81
|
+
$data = self::get($type, $id);
|
|
82
|
+
if ($data === null) {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
if ($data['attempts'] + 1 >= self::ATTEMPTS) {
|
|
86
|
+
Cache::forget(self::key($type, $id));
|
|
87
|
+
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
self::update($type, $id, ['attempts' => $data['attempts'] + 1]);
|
|
91
|
+
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
public static function forget(string $type, string $id): void
|
|
96
|
+
{
|
|
97
|
+
Cache::forget(self::key($type, $id));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Whether another emailed code may be sent for this purpose to this person.
|
|
102
|
+
*
|
|
103
|
+
* A six-digit code allows five wrong guesses, so the number of codes is what limits
|
|
104
|
+
* guessing: at six codes an hour, trying every code takes several years, where
|
|
105
|
+
* unlimited codes would take months. A person who really needs a seventh waits.
|
|
106
|
+
*/
|
|
107
|
+
public static function mayIssue(string $type, string $who, int $perHour = 6): bool
|
|
108
|
+
{
|
|
109
|
+
$key = 'nevela:auth:issued:'.$type.':'.hash('sha256', $who);
|
|
110
|
+
if (RateLimiter::tooManyAttempts($key, $perHour)) {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
RateLimiter::hit($key, 3600);
|
|
114
|
+
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** A six-digit code, and the hash of it to keep. @return array{0: string, 1: string} */
|
|
119
|
+
public static function code(): array
|
|
120
|
+
{
|
|
121
|
+
$code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
|
|
122
|
+
|
|
123
|
+
return [$code, self::hash($code)];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
public static function hash(string $value): string
|
|
127
|
+
{
|
|
128
|
+
return hash_hmac('sha256', preg_replace('/\s+/', '', $value) ?? '', (string) config('app.key'));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private static function key(string $type, string $id): string
|
|
132
|
+
{
|
|
133
|
+
return 'nevela:auth:'.$type.':'.hash('sha256', $id);
|
|
134
|
+
}
|
|
135
|
+
}
|