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:
@@ -7,15 +7,19 @@ use App\Models\User;
|
||||
use App\Models\AdminProfile;
|
||||
use App\Models\ProviderProfile;
|
||||
use App\Models\MemberProfile;
|
||||
use App\Traits\NormalizesEmail;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
|
||||
class AuthController extends Controller
|
||||
{
|
||||
// 科定規範角色
|
||||
use NormalizesEmail;
|
||||
|
||||
private const ROLE_MEMBER = 'member';
|
||||
private const ROLE_PROVIDER = 'provider';
|
||||
private const ROLE_ADMIN = 'admin';
|
||||
@@ -131,6 +135,50 @@ class AuthController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
private function isAccountLocked(string $role, string $email): bool
|
||||
{
|
||||
$count = Cache::get("login_failures:{$role}:{$email}", 0);
|
||||
return $count >= config('auth_lockout.max_attempts');
|
||||
}
|
||||
|
||||
private function recordLoginFailure(string $role, string $email): int
|
||||
{
|
||||
$ttl = config('auth_lockout.decay_minutes') * 60;
|
||||
$countKey = "login_failures:{$role}:{$email}";
|
||||
$expiresKey = "login_expires_at:{$role}:{$email}";
|
||||
|
||||
if (!Cache::has($countKey)) {
|
||||
Cache::put($countKey, 1, $ttl);
|
||||
Cache::put($expiresKey, now()->addSeconds($ttl)->toIso8601String(), $ttl);
|
||||
return 1;
|
||||
}
|
||||
|
||||
return Cache::increment($countKey);
|
||||
}
|
||||
|
||||
private function clearLoginFailures(string $role, string $email): void
|
||||
{
|
||||
Cache::forget("login_failures:{$role}:{$email}");
|
||||
Cache::forget("login_expires_at:{$role}:{$email}");
|
||||
}
|
||||
|
||||
private function buildLockedResponse(string $role, string $email): \Illuminate\Http\JsonResponse
|
||||
{
|
||||
$decayMinutes = config('auth_lockout.decay_minutes');
|
||||
$lockedUntil = Cache::get("login_expires_at:{$role}:{$email}");
|
||||
|
||||
if (!$lockedUntil) {
|
||||
Log::warning("auth_lockout: expires_at key missing for {$role}:{$email}");
|
||||
$lockedUntil = now()->addMinutes($decayMinutes)->toIso8601String();
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'status' => false,
|
||||
'message' => "帳號已暫時鎖定,請於 {$decayMinutes} 分鐘後再試",
|
||||
'locked_until' => $lockedUntil,
|
||||
], 423);
|
||||
}
|
||||
|
||||
/**
|
||||
* 標準化的登出方法
|
||||
*/
|
||||
@@ -268,53 +316,60 @@ class AuthController extends Controller
|
||||
*/
|
||||
public function loginMember(Request $request)
|
||||
{
|
||||
// 驗證請求數據
|
||||
$validator = Validator::make($request->all(), [
|
||||
'email' => 'required|string|email',
|
||||
'email' => 'required|string|email',
|
||||
'password' => 'required|string',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'status' => false,
|
||||
'status' => false,
|
||||
'message' => '驗證失敗',
|
||||
'errors' => $validator->errors()
|
||||
'errors' => $validator->errors()
|
||||
], 422);
|
||||
}
|
||||
|
||||
// 檢查用戶是否存在
|
||||
$user = User::where('email', $request->email)
|
||||
->where('role', 'member') // 只驗證會員
|
||||
->first();
|
||||
$email = $this->normalizeEmail($request->email);
|
||||
|
||||
// 檢查密碼
|
||||
// 1st: 帳號鎖定檢查
|
||||
if ($this->isAccountLocked(self::ROLE_MEMBER, $email)) {
|
||||
return $this->buildLockedResponse(self::ROLE_MEMBER, $email);
|
||||
}
|
||||
|
||||
// 2nd: 帳號存在確認
|
||||
$user = User::where('email', $email)->where('role', self::ROLE_MEMBER)->first();
|
||||
|
||||
// 3rd: 密碼驗證(帳號不存在與密碼錯誤回傳相同訊息)
|
||||
if (!$user || !Hash::check($request->password, $user->password)) {
|
||||
if ($user) {
|
||||
$count = $this->recordLoginFailure(self::ROLE_MEMBER, $email);
|
||||
if ($count >= config('auth_lockout.max_attempts')) {
|
||||
return $this->buildLockedResponse(self::ROLE_MEMBER, $email);
|
||||
}
|
||||
}
|
||||
return response()->json([
|
||||
'status' => false,
|
||||
'status' => false,
|
||||
'message' => '電子郵件或密碼錯誤'
|
||||
], 401);
|
||||
}
|
||||
|
||||
// 檢查用戶是否啟用
|
||||
if (!$user->is_active) {
|
||||
return response()->json([
|
||||
'status' => false,
|
||||
'status' => false,
|
||||
'message' => '帳號已被停用'
|
||||
], 403);
|
||||
}
|
||||
|
||||
// 創建 API token
|
||||
$this->clearLoginFailures(self::ROLE_MEMBER, $email);
|
||||
$token = $user->createToken('auth_token')->plainTextToken;
|
||||
|
||||
// 加載會員資料
|
||||
$user->load('memberProfile');
|
||||
|
||||
return response()->json([
|
||||
'status' => true,
|
||||
'status' => true,
|
||||
'message' => '登入成功',
|
||||
'data' => [
|
||||
'user' => $user,
|
||||
'token' => $token,
|
||||
'data' => [
|
||||
'user' => $user,
|
||||
'token' => $token,
|
||||
'token_type' => 'Bearer',
|
||||
]
|
||||
]);
|
||||
@@ -531,53 +586,60 @@ class AuthController extends Controller
|
||||
*/
|
||||
public function loginProvider(Request $request)
|
||||
{
|
||||
// 驗證請求數據
|
||||
$validator = Validator::make($request->all(), [
|
||||
'email' => 'required|string|email',
|
||||
'email' => 'required|string|email',
|
||||
'password' => 'required|string',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'status' => false,
|
||||
'status' => false,
|
||||
'message' => '驗證失敗',
|
||||
'errors' => $validator->errors()
|
||||
'errors' => $validator->errors()
|
||||
], 422);
|
||||
}
|
||||
|
||||
// 檢查用戶是否存在
|
||||
$user = User::where('email', $request->email)
|
||||
->where('role', 'provider') // 只驗證服務提供者
|
||||
->first();
|
||||
$email = $this->normalizeEmail($request->email);
|
||||
|
||||
// 檢查密碼
|
||||
// 1st: 帳號鎖定檢查
|
||||
if ($this->isAccountLocked(self::ROLE_PROVIDER, $email)) {
|
||||
return $this->buildLockedResponse(self::ROLE_PROVIDER, $email);
|
||||
}
|
||||
|
||||
// 2nd: 帳號存在確認
|
||||
$user = User::where('email', $email)->where('role', self::ROLE_PROVIDER)->first();
|
||||
|
||||
// 3rd: 密碼驗證(帳號不存在與密碼錯誤回傳相同訊息)
|
||||
if (!$user || !Hash::check($request->password, $user->password)) {
|
||||
if ($user) {
|
||||
$count = $this->recordLoginFailure(self::ROLE_PROVIDER, $email);
|
||||
if ($count >= config('auth_lockout.max_attempts')) {
|
||||
return $this->buildLockedResponse(self::ROLE_PROVIDER, $email);
|
||||
}
|
||||
}
|
||||
return response()->json([
|
||||
'status' => false,
|
||||
'status' => false,
|
||||
'message' => '電子郵件或密碼錯誤'
|
||||
], 401);
|
||||
}
|
||||
|
||||
// 檢查用戶是否啟用
|
||||
if (!$user->is_active) {
|
||||
return response()->json([
|
||||
'status' => false,
|
||||
'status' => false,
|
||||
'message' => '帳號已被停用'
|
||||
], 403);
|
||||
}
|
||||
|
||||
// 創建 API token
|
||||
$this->clearLoginFailures(self::ROLE_PROVIDER, $email);
|
||||
$token = $user->createToken('auth_token')->plainTextToken;
|
||||
|
||||
// 加載服務提供者資料
|
||||
$user->load('providerProfile');
|
||||
|
||||
return response()->json([
|
||||
'status' => true,
|
||||
'status' => true,
|
||||
'message' => '登入成功',
|
||||
'data' => [
|
||||
'user' => $user,
|
||||
'token' => $token,
|
||||
'data' => [
|
||||
'user' => $user,
|
||||
'token' => $token,
|
||||
'token_type' => 'Bearer',
|
||||
]
|
||||
]);
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user