Tantangan Kode Email
Email Code Challenge adalah second factor bawaan PandaBear: kode enam digit dikirim ke alamat email account dan harus dijawab satu kali per session.
Fitur ini ditujukan bagi user yang tidak menggunakan authenticator app.
Secara keamanan, kode email lebih lemah dibanding TOTP, tetapi jauh lebih kuat dibanding tidak menggunakan second factor sama sekali.
Gunakan dokumentasi ini untuk memahami:
- bagaimana kode dibuat;
- bagaimana kode disimpan;
- bagaimana kode diverifikasi;
- rate limiting;
- session marker;
- middleware;
- dan bagaimana menggunakan mekanismenya secara manual.
Contoh Minimal
Tidak ada route yang perlu Anda register sendiri.
Setiap Panel sudah memiliki route berikut:
php artisan route:list --name=panel.admin.auth.two-factorGET admin/two-factor/challenge panel.admin.auth.two-factor.challenge
POST admin/two-factor/send panel.admin.auth.two-factor.send
POST admin/two-factor/verify panel.admin.auth.two-factor.verify
POST admin/two-factor/enable panel.admin.auth.two-factor.enable
POST admin/two-factor/disable panel.admin.auth.two-factor.disable2
3
4
5
User dapat mengaktifkan Email Code dari Security Settings.
Middleware kemudian menangani enforcement-nya.
Mengaktifkannya langsung melalui code cukup dengan satu column:
use App\Models\User;
$user = User::query()
->where(
'email',
'ada@example.com'
)
->firstOrFail();
$user
->forceFill([
'two_factor_email_confirmed_at'
=> now(),
])
->save();2
3
4
5
6
7
8
9
10
11
12
13
14
15
Mulai dari session berikutnya, setiap Panel Page akan mengarahkan user ke:
/{panel}/two-factor/challengesampai kode berhasil diverifikasi.
Mengapa Menggunakan Session Challenge, Bukan Login Step?
Fortify bertanggung jawab atas proses login, termasuk:
- rate limiting;
- passkeys;
- TOTP;
- session handling.
Memasukkan Email Code ke pipeline login Fortify berarti PandaBear harus mengambil alih atau memodifikasi pipeline tersebut.
Sebagai gantinya, Email Code bekerja seperti password confirmation.
Setelah kode berhasil dijawab, session menyimpan sebuah marker.
Middleware Panel akan menahan setiap request sampai marker tersebut tersedia.
Keuntungannya:
- device/session baru tetap diminta kode walaupun password benar;
- marker hilang ketika session berakhir;
- logout menghilangkan bukti second factor;
- Fortify login POST tetap tidak berubah;
- semua entry point login tetap memiliki behavior yang sama.
Lifecycle
Security Page
│
└─ POST enable
↓
two_factor_email_confirmed_at = now()
session langsung diberi marker
Session Baru
│
└─ Membuka Panel
↓
RequireEmailCode
↓
marker belum ada
↓
simpan url.intended
↓
redirect challenge
Challenge Page
│
└─ GET
↓
issue() jika belum ada pending code
↓
kirim email
↓
render panel/auth/EmailCode
POST verify
↓
verify()
↓
kode benar?
↓
regenerate session
↓
simpan marker
↓
redirect()->intended()2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
EmailCodeChallenge
Class:
PandaPanel\Auth\EmailCodeChallengedapat di-resolve dari container.
Semua method menerima:
Illuminate\Contracts\Auth\Authenticatable| Method | Signature | Return |
|---|---|---|
issue() | issue(Authenticatable $user): ?string | Plain six-digit code, atau null jika send limit sudah habis |
verify() | verify(Authenticatable $user, string $code): bool | Apakah kode benar; kode dihapus setelah berhasil |
pending() | pending(Authenticatable $user): bool | Apakah masih ada outstanding code |
secondsUntilNextSend() | secondsUntilNextSend(Authenticatable $user): int | 0 jika boleh mengirim lagi sekarang |
forget() | forget(Authenticatable $user): void | Menghapus outstanding challenge |
Contoh lengkap:
use PandaPanel\Auth\EmailCodeChallenge;
use PandaPanel\Notifications\TwoFactorCode;
$challenge =
app(EmailCodeChallenge::class);
$code =
$challenge->issue($user);
if ($code === null) {
$wait =
$challenge
->secondsUntilNextSend(
$user
);
return back()
->withErrors([
'code' =>
"Try again in {$wait}s.",
]);
}
$user->notify(
new TwoFactorCode(
$code
)
);
$challenge->pending($user);
// true
$challenge->verify(
$user,
$code
);
// true — kode langsung dihabiskan
$challenge->verify(
$user,
$code
);
// false — kode yang sama tidak dapat digunakan dua kali
$challenge->forget(
$user
);2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
issue() mengembalikan plain code agar caller dapat mengirimkannya.
Kode plain tidak disimpan.
Yang masuk ke cache adalah:
Hash::make($code)Aturan Credential
Behavior berikut tidak configurable karena berkaitan dengan credential security:
| Aturan | Value | Implementasi |
|---|---|---|
| Bentuk kode | enam digit, zero-padded, dari random_int() | issue() |
| Masa berlaku | 10 menit | TTL_MINUTES |
| Storage | Cache dengan key panel.mfa.email.code.{auth id}, value hash | issue() |
| Send limit | 5 kali/jam/user | SEND_LIMIT |
| Guess limit | 5 kali/menit/user | ATTEMPT_LIMIT |
| Single use | kode benar langsung dihapus; tebakan salah tidak menghapus kode | verify() |
Send limit dan guess limit adalah dua protection berbeda.
Send limit mencegah flooding mailbox.
Guess limit mencegah brute force terhadap ruang kode enam digit.
Kode disimpan di Cache, bukan database, karena expiration menjadi bagian dari storage itu sendiri.
Kode juga disimpan dalam bentuk hash karena seseorang yang dapat membaca cache tidak seharusnya dapat menggunakan kode sebagai credential.
Konstanta security tersebut bersifat private dan tidak configurable.
EmailCodeFactor
Untuk memeriksa apakah account mengaktifkan factor:
use PandaPanel\Auth\EmailCodeFactor;
public static function isEnabledFor(
?object $user
): bool;2
3
4
5
Contoh:
EmailCodeFactor::isEnabledFor(
$user
);
// true setelah column terisi
EmailCodeFactor::isEnabledFor(
null
);
// false
EmailCodeFactor::isEnabledFor(
new stdClass
);
// false2
3
4
5
6
7
8
9
10
11
12
13
14
Method membaca raw attribute:
return (
$user
->getAttributes()[
'two_factor_email_confirmed_at'
]
?? null
) !== null;2
3
4
5
6
7
Ia tidak menggunakan:
getAttribute()Hal ini penting jika application menggunakan:
Model::preventAccessingMissingAttributes()dan User Model diambil menggunakan narrowed select.
Jika column tidak ikut di-select, jawaban aman adalah:
factor dianggap tidak aktifbukan exception.
Controller
Class:
PandaPanel\Http\Controllers\PanelTwoFactorControllermemiliki lima method dan satu session key:
public const SESSION_KEY =
'panel.mfa.email.confirmed_at';
public function challenge(
Request $request
): Inertia\Response;
public function send(
Request $request
): RedirectResponse;
public function verify(
Request $request
): RedirectResponse;
public function enable(
Request $request
): RedirectResponse;
public function disable(
Request $request
): RedirectResponse;2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
challenge()
Merender:
panel/auth/EmailCodeJika tidak ada outstanding code, controller melakukan issue() terlebih dahulu.
Artinya membuka Page sudah cukup untuk mengirim kode.
Refresh tidak otomatis mengirim kode kedua selama kode sebelumnya masih pending.
Props:
| Prop | Type | Arti |
|---|---|---|
panel | PanelDefinition | Panel::toSharedArray() |
sentTo | string | Email yang disamarkan |
retryAfter | int | Detik sampai kode berikutnya boleh dikirim |
Contoh email:
alexandra@example.testmenjadi:
al*******@example.testTujuannya cukup untuk membantu user mengenali inbox tanpa menampilkan alamat penuh.
Nilai tersebut dihitung setiap render dan tidak disimpan.
send()
Menghasilkan kode baru.
Jika account sudah melewati send limit:
throw ValidationException::withMessages([
'code' =>
'Too many codes requested. Try again later.',
]);2
3
4
Jika User Model tidak memiliki:
notify()controller abort:
500
The user model is not notifiable.2
Email Code membutuhkan Laravel Notifiable.
verify()
Validation:
$request->validate([
'code' => [
'required',
'string',
'digits:6',
],
]);2
3
4
5
6
7
Setelah kode benar:
$request
->session()
->regenerate();
$request
->session()
->put(
self::SESSION_KEY,
now()->timestamp
);2
3
4
5
6
7
8
9
10
Session diregenerate sebelum diberi marker second-factor agar fixed session id tidak dapat diwarisi pihak lain.
Kemudian:
return redirect()
->intended(
route(
$this
->panel()
->routeName(
'dashboard'
),
absolute: false
)
);2
3
4
5
6
7
8
9
10
11
Kode salah atau expired menghasilkan validation error:
That code is wrong or has expired.Framework sengaja tidak membedakan pesan "salah" dan "expired" agar tidak mengungkap apakah sebuah code sedang tersedia.
enable()
$user
->forceFill([
'two_factor_email_confirmed_at'
=> now(),
])
->save();
$request
->session()
->put(
self::SESSION_KEY,
now()->timestamp
);2
3
4
5
6
7
8
9
10
11
12
13
Current session langsung dianggap sudah memenuhi challenge karena user baru saja melewati password confirmation.
disable()
Behavior:
two_factor_email_confirmed_at = null
↓
forget outstanding code
↓
hapus session marker2
3
4
5
enable() dan disable() berada di balik:
Illuminate\Auth\Middleware\RequirePasswordRequireEmailCode
Middleware:
PandaPanel\Http\Middleware\RequireEmailCodediregistrasikan pada setiap Panel route group.
Signature:
public function handle(
Request $request,
Closure $next,
?string $panelId = null
): Response;2
3
4
5
Middleware stack:
'middleware' => [
...$panel->getMiddleware(),
ResolvePanel::class
. ':'
. $panel->getId(),
RequireTwoFactor::class
. ':'
. $panel->getId(),
RequireEmailCode::class
. ':'
. $panel->getId(),
],2
3
4
5
6
7
8
9
10
11
12
13
14
15
Panel id diberikan eksplisit agar middleware tidak menebak Panel dari URL.
Anda juga dapat menggunakannya pada route sendiri:
use PandaPanel\Http\Middleware\RequireEmailCode;
Route::get(
'/exports/payroll',
PayrollController::class
)
->middleware([
'auth',
RequireEmailCode::class
. ':admin',
]);2
3
4
5
6
7
8
9
10
11
Middleware melewatkan request tanpa challenge jika:
- tidak ada current Panel;
- tidak ada signed-in user;
- account belum mengaktifkan Email Code;
- session sudah memiliki marker;
- Panel tidak memiliki challenge route;
- request sedang menuju salah satu
auth.two-factor.*route.
Jika challenge dibutuhkan:
$request
->session()
->put(
'url.intended',
$request->fullUrl()
);
return redirect()
->route(
$panel->routeName(
'auth.two-factor.challenge'
)
);2
3
4
5
6
7
8
9
10
11
12
13
Setelah challenge selesai user kembali ke URL yang semula ingin dibuka.
RequireEmailCode dan RequireTwoFactor adalah dua mekanisme berbeda.
RequireTwoFactor bertanya:
Apakah account memiliki second factor?
RequireEmailCode bertanya:
Jika account mengaktifkan Email Code, apakah session ini sudah menjawab challenge?
Notification TwoFactorCode
PandaPanel\Notifications\TwoFactorCodeAPI:
public function __construct(
private readonly string $code
);
public function via(
object $notifiable
): array;
// ['mail']
public function toMail(
object $notifiable
): MailMessage;2
3
4
5
6
7
8
9
10
11
12
Contoh:
$user->notify(
new PandaPanel\Notifications\TwoFactorCode(
'123456'
)
);2
3
4
5
Notification mengimplementasikan:
Illuminate\Contracts\Queue\ShouldQueueEmail dikirim melalui queue agar user tidak harus menunggu SMTP handshake saat authentication.
Kode sudah berada di cache sebelum notification didispatch.
Jika queue terlambat, login ikut terlambat tetapi state challenge tetap valid.
Queue worker harus berjalan jika menggunakan asynchronous queue.
Notification ini bukan Panel Notification karena kode merupakan credential, bukan item yang seharusnya tersimpan di Notification Centre.
Column Database
Package menyediakan migration:
two_factor_email_confirmed_atpada table:
usersColumn nullable dan ditempatkan setelah:
two_factor_confirmed_atjika column Fortify tersebut tersedia.
Migration otomatis dimuat kecuali:
panda-panel.load_migrations = falsePublish manual:
php artisan vendor:publish --tag=panda-panel-migrations
php artisan migrate2
Timestamp digunakan, bukan boolean, agar application juga mengetahui kapan factor diaktifkan.
Cast bila dibutuhkan:
protected function casts(): array
{
return [
'two_factor_email_confirmed_at'
=> 'datetime',
];
}2
3
4
5
6
7
Challenge Page
Component:
resources/js/pages/panel/auth/EmailCode.vueberada di dalam PanelAuthLayout.
Form verify:
<Form
:action="`/${panel.path}/two-factor/verify`"
method="post"
>
<Input
name="code"
inputmode="numeric"
autocomplete="one-time-code"
maxlength="6"
/>
</Form>2
3
4
5
6
7
8
9
10
11
Resend:
<Form
:action="`/${panel.path}/two-factor/send`"
method="post"
>
<Button
:disabled="
processing
|| retryAfter > 0
"
>
{{
retryAfter > 0
? `Wait ${retryAfter}s before asking again`
: 'Send another code'
}}
</Button>
</Form>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Path dibangun dari:
panel.pathkarena route tersebut merupakan route dinamis milik Panel.
autocomplete="one-time-code" memungkinkan iOS menawarkan kode langsung dari notification banner.
Referensi Route
| Route | Verb | Path | Method | Middleware tambahan |
|---|---|---|---|---|
panel.{id}.auth.two-factor.challenge | GET | {panel}/two-factor/challenge | challenge | — |
panel.{id}.auth.two-factor.send | POST | {panel}/two-factor/send | send | — |
panel.{id}.auth.two-factor.verify | POST | {panel}/two-factor/verify | verify | — |
panel.{id}.auth.two-factor.enable | POST | {panel}/two-factor/enable | enable | RequirePassword |
panel.{id}.auth.two-factor.disable | POST | {panel}/two-factor/disable | disable | RequirePassword |
Semua berada di dalam Panel middleware group dan hanya ditujukan untuk signed-in user.
Semua route auth.two-factor.* dikecualikan dari RequireEmailCode agar challenge tidak diblokir oleh middleware yang sedang ingin dipenuhi.
Testing
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\RateLimiter;
use PandaPanel\Auth\EmailCodeChallenge;
use PandaPanel\Http\Controllers\PanelTwoFactorController;
it(
'holds a session that has not answered a code',
function (): void {
Notification::fake();
$this->user
->forceFill([
'two_factor_email_confirmed_at'
=> now(),
])
->save();
$this
->actingAs($this->user)
->get('/coded')
->assertRedirect(
route(
'panel.coded.auth.two-factor.challenge',
absolute: false
)
);
expect(
session(
'url.intended'
)
)->toContain(
'/coded'
);
}
);2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
Menandai session sudah menyelesaikan challenge:
$this
->actingAs($user)
->withSession([
PanelTwoFactorController::SESSION_KEY
=> now()->timestamp,
])
->get('/admin')
->assertOk();2
3
4
5
6
7
8
Memenuhi RequirePassword:
$this
->actingAs($user)
->withSession([
'auth.password_confirmed_at'
=> now()->timestamp,
])
->post(
'/admin/two-factor/disable'
)
->assertRedirect();2
3
4
5
6
7
8
9
10
Rate limit perlu dibersihkan antar-test jika test membuat kode:
afterEach(
function (): void {
RateLimiter::clear(
'panel.mfa.email.send.'
. $this
->user
->getKey()
);
RateLimiter::clear(
'panel.mfa.email.attempt.'
. $this
->user
->getKey()
);
}
);2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Suite utama:
tests/Feature/Panel/EmailCodeTest.phpHal yang Perlu Diperhatikan
- Membuka challenge Page mengirim email jika belum ada pending code.
- Refresh tidak mengirim ulang selama existing code masih pending.
- Enable tidak menantang current session. Session langsung diberi marker.
- Marker berlaku per-session, bukan per-Panel. Menyelesaikan challenge di
/adminjuga memenuhi/apppada session yang sama. - Queue worker harus berjalan. Pada queue real tanpa worker, kode dibuat tetapi email tidak sampai.
- Cache adalah storage challenge.
php artisan cache:clearmembatalkan semua outstanding code. - Rate limit menggunakan auth identifier, sehingga berlaku per-account lintas browser/device.
send()menghasilkan 500 jika model tidak Notifiable.- Disable tidak melakukan logout. Existing session tetap aktif.