refactor(auth): upgrade email verification hash to sha256
Move the email-verification URL hash from sha1 to sha256 and verify it directly in the controller using hash_equals, instead of going through Laravel's EmailVerificationRequest (which only compares against sha1). The signed URL still carries the authoritative HMAC; the hash upgrade keeps the identity binding aligned with modern hashing guidance. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
9b37a1a7eb
commit
49b5472961
3 changed files with 97 additions and 4 deletions
|
|
@ -6,8 +6,8 @@
|
||||||
use App\Models\TeamInvitation;
|
use App\Models\TeamInvitation;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Providers\RouteServiceProvider;
|
use App\Providers\RouteServiceProvider;
|
||||||
|
use Illuminate\Auth\Events\Verified;
|
||||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
use Illuminate\Foundation\Auth\EmailVerificationRequest;
|
|
||||||
use Illuminate\Foundation\Validation\ValidatesRequests;
|
use Illuminate\Foundation\Validation\ValidatesRequests;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Routing\Controller as BaseController;
|
use Illuminate\Routing\Controller as BaseController;
|
||||||
|
|
@ -39,9 +39,29 @@ public function verify()
|
||||||
return view('auth.verify-email');
|
return view('auth.verify-email');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function email_verify(EmailVerificationRequest $request)
|
public function email_verify(Request $request)
|
||||||
{
|
{
|
||||||
$request->fulfill();
|
if (! $request->hasValidSignature()) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = auth()->user();
|
||||||
|
if (! $user) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! hash_equals((string) $request->route('id'), (string) $user->getKey())) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! hash_equals((string) $request->route('hash'), hash('sha256', $user->getEmailForVerification()))) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->hasVerifiedEmail()) {
|
||||||
|
$user->markEmailAsVerified();
|
||||||
|
event(new Verified($user));
|
||||||
|
}
|
||||||
|
|
||||||
return redirect(RouteServiceProvider::HOME);
|
return redirect(RouteServiceProvider::HOME);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -257,7 +257,7 @@ public function sendVerificationEmail()
|
||||||
Carbon::now()->addMinutes(Config::get('auth.verification.expire', 60)),
|
Carbon::now()->addMinutes(Config::get('auth.verification.expire', 60)),
|
||||||
[
|
[
|
||||||
'id' => $this->getKey(),
|
'id' => $this->getKey(),
|
||||||
'hash' => sha1($this->getEmailForVerification()),
|
'hash' => hash('sha256', $this->getEmailForVerification()),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
$mail->view('emails.email-verification', [
|
$mail->view('emails.email-verification', [
|
||||||
|
|
|
||||||
73
tests/Feature/EmailVerificationHashTest.php
Normal file
73
tests/Feature/EmailVerificationHashTest.php
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Middleware\CheckForcePasswordReset;
|
||||||
|
use App\Http\Middleware\DecideWhatToDoWithUser;
|
||||||
|
use App\Models\InstanceSettings;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\URL;
|
||||||
|
use Illuminate\Support\Once;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
$this->withoutMiddleware([DecideWhatToDoWithUser::class, CheckForcePasswordReset::class]);
|
||||||
|
Once::flush();
|
||||||
|
if (! InstanceSettings::find(0)) {
|
||||||
|
$settings = new InstanceSettings;
|
||||||
|
$settings->id = 0;
|
||||||
|
$settings->saveQuietly();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('email verification hash', function () {
|
||||||
|
test('sha256 hash is accepted and marks the user verified', function () {
|
||||||
|
$user = User::factory()->create([
|
||||||
|
'email' => 'verify-me@example.com',
|
||||||
|
'email_verified_at' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$url = URL::temporarySignedRoute('verify.verify', now()->addHour(), [
|
||||||
|
'id' => $user->getKey(),
|
||||||
|
'hash' => hash('sha256', $user->getEmailForVerification()),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)->get($url)->assertRedirect();
|
||||||
|
|
||||||
|
$user->refresh();
|
||||||
|
expect($user->email_verified_at)->not->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('legacy sha1 hash is rejected', function () {
|
||||||
|
$user = User::factory()->create([
|
||||||
|
'email' => 'legacy-sha1@example.com',
|
||||||
|
'email_verified_at' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$url = URL::temporarySignedRoute('verify.verify', now()->addHour(), [
|
||||||
|
'id' => $user->getKey(),
|
||||||
|
'hash' => sha1($user->getEmailForVerification()),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)->get($url)->assertStatus(403);
|
||||||
|
|
||||||
|
$user->refresh();
|
||||||
|
expect($user->email_verified_at)->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tampered signature is rejected', function () {
|
||||||
|
$user = User::factory()->create([
|
||||||
|
'email' => 'tampered@example.com',
|
||||||
|
'email_verified_at' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$url = URL::temporarySignedRoute('verify.verify', now()->addHour(), [
|
||||||
|
'id' => $user->getKey(),
|
||||||
|
'hash' => hash('sha256', $user->getEmailForVerification()),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tampered = $url.'x';
|
||||||
|
|
||||||
|
$this->actingAs($user)->get($tampered)->assertStatus(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue