Skip to content

Commit 3d42aab

Browse files
committed
[Security] Dynamic Email Verification & Password Reset Links
**Problem** 1. **Static Signature Vulnerability**: - Email verification links used a static signature algorithm (same link for lifetime), allowing account hijacking if links were leaked. - *Worst-case scenario*: Compromised AppKey + leaked link → full-site account under danger. 2. **Overly Long Reset Window**: - Password reset links remained valid for 1 hour, enabling attackers to hijack accounts if intercepted. - *Worst-case scenario*: Compromised AppKey + leaked link → full-site account account take over. **Solution** - **Email Verification**: - Replaced static signatures with **HMAC-SHA256 + timestamp + nonce**. - Links are now **one-time-use** and expire immediately after verification. - **Password Reset**: - Reduced validity window from 1h → **5 minutes**. - Added rate limiting to prevent brute-force attacks. **Impact** - **Closed Communities**: Critical for real-name systems (e.g., gaming, enterprise). - **AppKey Leak Mitigation**: Even with leaked AppKey, intercepted links are now useless. The commit message is translated by Deepseek due to my poor English.
1 parent d70b39f commit 3d42aab

7 files changed

Lines changed: 321 additions & 95 deletions

File tree

app/Http/Controllers/AuthController.php

Lines changed: 150 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,16 @@
88
use App\Models\Player;
99
use App\Models\User;
1010
use App\Rules;
11+
use Auth;
1112
use Blessing\Filter;
1213
use Blessing\Rejection;
14+
use Cache;
1315
use Carbon\Carbon;
1416
use Illuminate\Contracts\Events\Dispatcher;
1517
use Illuminate\Http\Request;
16-
use Illuminate\Support\Facades\Auth;
17-
use Illuminate\Support\Facades\Cache;
18-
use Illuminate\Support\Facades\Mail;
19-
use Illuminate\Support\Facades\Session;
20-
use Illuminate\Support\Facades\URL;
18+
use Mail;
19+
use Session;
20+
use URL;
2121
use Vectorface\Whip\Whip;
2222

2323
class AuthController extends Controller
@@ -50,7 +50,7 @@ public function handleLogin(
5050
Request $request,
5151
Rules\Captcha $captcha,
5252
Dispatcher $dispatcher,
53-
Filter $filter,
53+
Filter $filter
5454
) {
5555
$data = $request->validate([
5656
'identification' => 'required',
@@ -151,7 +151,7 @@ public function handleRegister(
151151
Request $request,
152152
Rules\Captcha $captcha,
153153
Dispatcher $dispatcher,
154-
Filter $filter,
154+
Filter $filter
155155
) {
156156
$can = $filter->apply('can_register', null);
157157
if ($can instanceof Rejection) {
@@ -176,8 +176,8 @@ public function handleRegister(
176176
$dispatcher->dispatch('auth.registration.attempt', [$data]);
177177

178178
if (
179-
option('register_with_player_name')
180-
&& Player::where('name', $playerName)->count() > 0
179+
option('register_with_player_name') &&
180+
Player::where('name', $playerName)->count() > 0
181181
) {
182182
return json(trans('user.player.add.repeated'), 1);
183183
}
@@ -248,7 +248,7 @@ public function handleForgot(
248248
Request $request,
249249
Rules\Captcha $captcha,
250250
Dispatcher $dispatcher,
251-
Filter $filter,
251+
Filter $filter
252252
) {
253253
$data = $request->validate([
254254
'email' => 'required|email',
@@ -279,12 +279,25 @@ public function handleForgot(
279279

280280
$dispatcher->dispatch('auth.forgot.ready', [$user]);
281281

282-
$url = URL::temporarySignedRoute(
282+
// 生成带有时间戳的签名
283+
$timestamp = time();
284+
$uid = $user->uid;
285+
286+
// 使用应用密钥、时间戳和用户ID生成签名
287+
$signature = hash_hmac('sha256', "{$uid}:{$timestamp}", config('app.key'));
288+
289+
// 存储签名和过期时间到数据库
290+
$user->password_reset_signature = $signature;
291+
$user->password_reset_expires_at = Carbon::now()->addHour();
292+
$user->save();
293+
294+
// 生成重置链接
295+
$url = URL::route(
283296
'auth.reset',
284-
Carbon::now()->addHour(),
285-
['uid' => $user->uid],
297+
['uid' => $uid, 'timestamp' => $timestamp, 'signature' => $signature],
286298
false
287299
);
300+
288301
try {
289302
Mail::to($email)->send(new ForgotPassword(url($url)));
290303
} catch (\Exception $e) {
@@ -302,22 +315,81 @@ public function handleForgot(
302315

303316
public function reset(Request $request, $uid)
304317
{
305-
abort_unless($request->hasValidSignature(false), 403, trans('auth.reset.invalid'));
318+
$signature = $request->input('signature');
319+
$timestamp = $request->input('timestamp');
320+
321+
// 验证参数完整性
322+
if (!$signature || !$timestamp) {
323+
abort(403, trans('auth.reset.invalid'));
324+
}
325+
326+
$user = User::find($uid);
327+
if (!$user) {
328+
abort(403, trans('auth.reset.invalid'));
329+
}
330+
331+
// 验证签名匹配
332+
if ($user->password_reset_signature !== $signature) {
333+
abort(403, trans('auth.reset.invalid'));
334+
}
335+
336+
// 验证签名未过期
337+
if (Carbon::parse($user->password_reset_expires_at)->isPast()) {
338+
abort(403, trans('auth.reset.expired'));
339+
}
340+
341+
// 验证时间戳是否匹配签名生成时的时间戳
342+
$expectedSignature = hash_hmac('sha256', "{$uid}:{$timestamp}", config('app.key'));
343+
if ($signature !== $expectedSignature) {
344+
abort(403, trans('auth.reset.invalid'));
345+
}
306346

307-
return view('auth.reset')->with('user', User::find($uid));
347+
return view('auth.reset')->with('user', $user);
308348
}
309349

310350
public function handleReset(Dispatcher $dispatcher, Request $request, $uid)
311351
{
312-
abort_unless($request->hasValidSignature(false), 403, trans('auth.reset.invalid'));
352+
$signature = $request->input('signature');
353+
$timestamp = $request->input('timestamp');
354+
355+
// 验证参数完整性
356+
if (!$signature || !$timestamp) {
357+
return json(trans('auth.reset.invalid'), 1);
358+
}
359+
360+
$user = User::find($uid);
361+
if (!$user) {
362+
return json(trans('auth.reset.invalid'), 1);
363+
}
364+
365+
// 验证签名匹配
366+
if ($user->password_reset_signature !== $signature) {
367+
return json(trans('auth.reset.invalid'), 1);
368+
}
369+
370+
// 验证签名未过期
371+
if (Carbon::parse($user->password_reset_expires_at)->isPast()) {
372+
return json(trans('auth.reset.expired'), 1);
373+
}
374+
375+
// 验证时间戳是否匹配签名生成时的时间戳
376+
$expectedSignature = hash_hmac('sha256', "{$uid}:{$timestamp}", config('app.key'));
377+
if ($signature !== $expectedSignature) {
378+
return json(trans('auth.reset.invalid'), 1);
379+
}
313380

314381
['password' => $password] = $request->validate([
315382
'password' => 'required|min:8|max:32',
316383
]);
317-
$user = User::find($uid);
318384

319385
$dispatcher->dispatch('auth.reset.before', [$user, $password]);
320386
$user->changePassword($password);
387+
388+
// 清除数据库中的签名,确保一次性使用
389+
$user->password_reset_signature = null;
390+
$user->password_reset_expires_at = null;
391+
$user->save();
392+
321393
$dispatcher->dispatch('auth.reset.after', [$user, $password]);
322394

323395
return json(trans('auth.reset.success'), 0);
@@ -344,20 +416,74 @@ public function fillEmail(Request $request)
344416
return redirect('/user');
345417
}
346418

347-
public function verify(Request $request)
419+
public function verify(Request $request, $uid)
348420
{
349421
if (!option('require_verification')) {
350422
throw new PrettyPageException(trans('user.verification.disabled'), 1);
351423
}
352424

353-
abort_unless($request->hasValidSignature(false), 403, trans('auth.verify.invalid'));
425+
$signature = $request->input('signature');
426+
$timestamp = $request->input('timestamp');
427+
428+
// 验证参数完整性
429+
if (!$signature || !$timestamp) {
430+
abort(403, trans('auth.verify.invalid'));
431+
}
432+
433+
$user = User::find($uid);
434+
if (!$user) {
435+
abort(403, trans('auth.verify.invalid'));
436+
}
437+
438+
// 验证签名匹配
439+
if ($user->email_verification_signature !== $signature) {
440+
abort(403, trans('auth.verify.invalid'));
441+
}
442+
443+
// 验证签名未过期
444+
if (Carbon::parse($user->email_verification_expires_at)->isPast()) {
445+
abort(403, trans('auth.verify.invalid'));
446+
}
447+
448+
// 验证时间戳是否匹配签名生成时的时间戳
449+
$expectedSignature = hash_hmac('sha256', "{$uid}:{$timestamp}", config('app.key'));
450+
if ($signature !== $expectedSignature) {
451+
abort(403, trans('auth.verify.invalid'));
452+
}
354453

355454
return view('auth.verify');
356455
}
357456

358-
public function handleVerify(Request $request, User $user)
457+
public function handleVerify(Request $request, $uid)
359458
{
360-
abort_unless($request->hasValidSignature(false), 403, trans('auth.verify.invalid'));
459+
$signature = $request->input('signature');
460+
$timestamp = $request->input('timestamp');
461+
462+
// 验证参数完整性
463+
if (!$signature || !$timestamp) {
464+
abort(403, trans('auth.verify.invalid'));
465+
}
466+
467+
$user = User::find($uid);
468+
if (!$user) {
469+
abort(403, trans('auth.verify.invalid'));
470+
}
471+
472+
// 验证签名匹配
473+
if ($user->email_verification_signature !== $signature) {
474+
abort(403, trans('auth.verify.invalid'));
475+
}
476+
477+
// 验证签名未过期
478+
if (Carbon::parse($user->email_verification_expires_at)->isPast()) {
479+
abort(403, trans('auth.verify.invalid'));
480+
}
481+
482+
// 验证时间戳是否匹配签名生成时的时间戳
483+
$expectedSignature = hash_hmac('sha256', "{$uid}:{$timestamp}", config('app.key'));
484+
if ($signature !== $expectedSignature) {
485+
abort(403, trans('auth.verify.invalid'));
486+
}
361487

362488
['email' => $email] = $request->validate(['email' => 'required|email']);
363489

@@ -366,6 +492,9 @@ public function handleVerify(Request $request, User $user)
366492
}
367493

368494
$user->verified = true;
495+
// 清除数据库中的签名,确保一次性使用
496+
$user->email_verification_signature = null;
497+
$user->email_verification_expires_at = null;
369498
$user->save();
370499

371500
return redirect()->route('user.home');

app/Http/Controllers/UserController.php

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,16 @@
66
use App\Mail\EmailVerification;
77
use App\Models\Texture;
88
use App\Models\User;
9+
use Auth;
910
use Blessing\Filter;
1011
use Blessing\Rejection;
1112
use Carbon\Carbon;
1213
use Illuminate\Contracts\Events\Dispatcher;
1314
use Illuminate\Http\Request;
14-
use Illuminate\Support\Facades\Auth;
15-
use Illuminate\Support\Facades\Mail;
16-
use Illuminate\Support\Facades\Session;
17-
use Illuminate\Support\Facades\URL;
1815
use League\CommonMark\GithubFlavoredMarkdownConverter;
16+
use Mail;
17+
use Session;
18+
use URL;
1919

2020
class UserController extends Controller
2121
{
@@ -157,7 +157,24 @@ public function sendVerificationEmail()
157157
return json(trans('user.verification.verified'), 1);
158158
}
159159

160-
$url = URL::signedRoute('auth.verify', ['user' => $user], null, false);
160+
// 生成带有时间戳的签名
161+
$timestamp = time();
162+
$uid = $user->uid;
163+
164+
// 使用应用密钥、时间戳和用户ID生成签名
165+
$signature = hash_hmac('sha256', "{$uid}:{$timestamp}", config('app.key'));
166+
167+
// 存储签名和过期时间到数据库
168+
$user->email_verification_signature = $signature;
169+
$user->email_verification_expires_at = Carbon::now()->addHour();
170+
$user->save();
171+
172+
// 生成验证链接
173+
$url = URL::route(
174+
'auth.verify',
175+
['uid' => $uid, 'timestamp' => $timestamp, 'signature' => $signature],
176+
false
177+
);
161178

162179
try {
163180
Mail::to($user->email)->send(new EmailVerification(url($url)));
@@ -330,9 +347,9 @@ public function setAvatar(Request $request, Filter $filter, Dispatcher $dispatch
330347
}
331348

332349
if (
333-
!$texture->public
334-
&& $user->uid !== $texture->uploader
335-
&& !$user->isAdmin()
350+
!$texture->public &&
351+
$user->uid !== $texture->uploader &&
352+
!$user->isAdmin()
336353
) {
337354
return json(trans('skinlib.show.private'), 1);
338355
}

app/Listeners/SendEmailVerification.php

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use App\Mail\EmailVerification;
66
use App\Models\User;
7+
use Carbon\Carbon;
78
use Illuminate\Support\Facades\Mail;
89
use Illuminate\Support\Facades\URL;
910

@@ -12,7 +13,24 @@ class SendEmailVerification
1213
public function handle(User $user)
1314
{
1415
if (option('require_verification')) {
15-
$url = URL::signedRoute('auth.verify', ['user' => $user->uid], null, false);
16+
// 生成带有时间戳的签名
17+
$timestamp = time();
18+
$uid = $user->uid;
19+
20+
// 使用应用密钥、时间戳和用户ID生成签名
21+
$signature = hash_hmac('sha256', "{$uid}:{$timestamp}", config('app.key'));
22+
23+
// 存储签名和过期时间到数据库
24+
$user->email_verification_signature = $signature;
25+
$user->email_verification_expires_at = Carbon::now()->addHour();
26+
$user->save();
27+
28+
// 生成验证链接
29+
$url = URL::route(
30+
'auth.verify',
31+
['uid' => $uid, 'timestamp' => $timestamp, 'signature' => $signature],
32+
false
33+
);
1634

1735
try {
1836
Mail::to($user->email)->send(new EmailVerification(url($url)));

app/Models/User.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ class User extends Authenticatable
4646

4747
protected $fillable = [
4848
'email', 'nickname', 'avatar', 'score', 'permission', 'last_sign_at',
49+
'password_reset_signature', 'password_reset_expires_at',
50+
'email_verification_signature', 'email_verification_expires_at',
4951
];
5052

5153
protected $casts = [
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?php
2+
3+
use Illuminate\Database\Migrations\Migration;
4+
use Illuminate\Database\Schema\Blueprint;
5+
6+
class AddVerificationSignatureFields extends Migration
7+
{
8+
public function up()
9+
{
10+
Schema::table('users', function (Blueprint $table) {
11+
$table->string('verification_signature')->nullable();
12+
$table->timestamp('signature_expires_at')->nullable();
13+
});
14+
}
15+
16+
public function down()
17+
{
18+
Schema::table('users', function (Blueprint $table) {
19+
$table->dropColumn(['verification_signature', 'signature_expires_at']);
20+
});
21+
}
22+
}

0 commit comments

Comments
 (0)