2a0e9255ae
- 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>
130 lines
5.0 KiB
PHP
130 lines
5.0 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\API;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\User;
|
|
use App\Models\MemberProfile;
|
|
use App\Models\SocialAccount;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Hash;
|
|
use Illuminate\Support\Str;
|
|
use Laravel\Socialite\Facades\Socialite;
|
|
|
|
class SocialAuthController extends Controller
|
|
{
|
|
/**
|
|
* 重定向到 Google 登入頁面
|
|
*/
|
|
public function redirectToGoogle()
|
|
{
|
|
$state = bin2hex(random_bytes(32));
|
|
session()->put('oauth_state', $state);
|
|
|
|
$redirect = Socialite::driver('google')
|
|
->scopes(['openid', 'profile', 'email'])
|
|
->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;
|
|
}
|
|
|
|
/**
|
|
* 處理 Google 回調
|
|
*/
|
|
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 用戶資訊(Socialite 內建 state 驗證此時也會通過)
|
|
$googleUser = Socialite::driver('google')->user();
|
|
|
|
// 查找相關的社交帳號
|
|
$socialAccount = SocialAccount::where('provider', 'google')
|
|
->where('provider_id', $googleUser->getId())
|
|
->first();
|
|
|
|
if ($socialAccount) {
|
|
// 已存在社交帳號,直接獲取用戶
|
|
$user = $socialAccount->user;
|
|
|
|
// 如果用戶不是會員,拒絕登入
|
|
if ($user->role !== 'member') {
|
|
return response()->json([
|
|
'status' => false,
|
|
'message' => '只有會員可以使用 Google 登入'
|
|
], 403);
|
|
}
|
|
} else {
|
|
// 檢查是否有相同 email 的用戶
|
|
$user = User::where('email', $googleUser->getEmail())->first();
|
|
|
|
if ($user) {
|
|
// 已存在用戶,但沒有連結社交帳號
|
|
// 檢查是否為會員
|
|
if ($user->role !== 'member') {
|
|
return response()->json([
|
|
'status' => false,
|
|
'message' => '只有會員可以使用 Google 登入'
|
|
], 403);
|
|
}
|
|
} else {
|
|
// 建立新用戶
|
|
$user = User::create([
|
|
'name' => $googleUser->getName(),
|
|
'email' => $googleUser->getEmail(),
|
|
'password' => Hash::make(Str::random(24)),
|
|
'role' => 'member', // 強制為會員角色
|
|
'is_active' => true,
|
|
]);
|
|
|
|
// 建立會員資料
|
|
try {
|
|
MemberProfile::create([
|
|
'user_id' => $user->id,
|
|
// 可以選擇性地從 Google 獲取更多資訊
|
|
]);
|
|
} catch (\Exception $e) {
|
|
// 記錄錯誤,但不中斷整個登入流程
|
|
\Log::error('Google 登入建立會員資料失敗: ' . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
// 建立社交帳號連結
|
|
$socialAccountData = [
|
|
'user_id' => $user->id,
|
|
'provider' => 'google',
|
|
'provider_id' => $googleUser->getId(),
|
|
'provider_email' => $googleUser->getEmail(),
|
|
'access_token' => $googleUser->token,
|
|
'expires_in' => $googleUser->expiresIn ?? null,
|
|
];
|
|
|
|
// 確保如果有 refreshToken 就正確地儲存
|
|
if (!empty($googleUser->refreshToken)) {
|
|
$socialAccountData['refresh_token'] = $googleUser->refreshToken;
|
|
}
|
|
|
|
$socialAccount = SocialAccount::create($socialAccountData);
|
|
}
|
|
|
|
// 生成 Sanctum token
|
|
$token = $user->createToken('google-auth')->plainTextToken;
|
|
|
|
return redirect(config('app.frontend_url') . '/auth/callback#token=' . $token);
|
|
} catch (\Exception $e) {
|
|
return redirect(config('app.frontend_url') . '/login?error=oauth_failed');
|
|
}
|
|
}
|
|
}
|