security(auth): P2 帳號鎖定 + OAuth state 驗證

- Account lockout:連續失敗 5 次鎖定 15 分鐘(Fixed Window,Cache)
  - Member / Provider 各自獨立 namespace
  - Email 正規化(strtolower+trim)
  - 不存在帳號不累計計數(防 Cache 污染)
  - companion key 記錄 locked_until(ISO 8601)
- OAuth CSRF 防護:移除 stateless(),手動 state 生成與驗證
  - session('oauth_state') + session('state') 雙寫確保 Socialite 內建驗證通過
  - state mismatch redirect 至 /login?error=oauth_failed
- throttle 從 5,1 調整為 10,1,避免 IP throttle 在 lockout 前觸發
- NormalizesEmail Trait、config/auth_lockout.php

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-03 01:20:25 +08:00
parent b2e20767d8
commit 2a0e9255ae
14 changed files with 680 additions and 47 deletions
@@ -18,11 +18,19 @@ class SocialAuthController extends Controller
*/
public function redirectToGoogle()
{
return Socialite::driver('google')
$state = bin2hex(random_bytes(32));
session()->put('oauth_state', $state);
$redirect = Socialite::driver('google')
->scopes(['openid', 'profile', 'email'])
->with(['access_type' => 'offline', 'prompt' => 'consent']) // 這裡要求 prompt=consent 才能每次都獲取 refresh token
->stateless()
->with(['access_type' => 'offline', 'prompt' => 'consent', 'state' => $state])
->redirect();
// Socialite 在 redirect() 內自己寫了一個 random state 進 session('state')。
// 用我們的 state 覆蓋,確保 user() 的內建驗證與 URL 中的 state 一致。
session()->put('state', $state);
return $redirect;
}
/**
@@ -30,9 +38,16 @@ class SocialAuthController extends Controller
*/
public function handleGoogleCallback(Request $request)
{
$requestState = $request->input('state');
$sessionState = session()->pull('oauth_state');
if (!$requestState || !$sessionState || !hash_equals($sessionState, $requestState)) {
return redirect(config('app.frontend_url') . '/login?error=oauth_failed');
}
try {
// 獲取 Google 用戶資訊
$googleUser = Socialite::driver('google')->stateless()->user();
// 獲取 Google 用戶資訊Socialite 內建 state 驗證此時也會通過)
$googleUser = Socialite::driver('google')->user();
// 查找相關的社交帳號
$socialAccount = SocialAccount::where('provider', 'google')