['required', 'string', 'max:190'], 'password' => ['required', 'string', 'max:255'], 'remember' => ['nullable'], ]; if (app()->environment('production')) { $rules['g-recaptcha-response'] = ['required', new RecaptchaV3Rule('admin_login')]; } $data = $request->validate($rules); $email = strtolower(trim((string) $data['login_id'])); $remember = (bool) $request->boolean('remember'); $ip = (string) $request->ip(); $res = $this->authService->startLogin( email: $email, password: (string) $data['password'], remember: $remember, ip: $ip ); $state = (string) ($res['state'] ?? ''); // 1) 계정 잠김 if ($state === 'locked') { $msg = '계정이 잠금 상태입니다. 최고관리자에게 잠금 해제를 요청해 주세요.'; return back() ->withInput() ->withErrors(['login_id' => $msg]) ->with('toast', [ 'type' => 'danger', 'title' => '계정 잠김', 'message' => $msg, ]); } // 2) 비번 불일치/계정없음 (남은 시도 횟수 포함) if ($state === 'invalid') { $left = $res['attempts_left'] ?? null; $msg = '이메일 또는 비밀번호를 확인하세요.'; if ($left !== null && is_numeric($left)) { $msg .= ' (남은 시도 ' . (int)$left . '회)'; } return back() ->withInput() ->withErrors(['login_id' => $msg]) ->with('toast', [ 'type' => 'danger', 'title' => '로그인 실패', 'message' => $msg, ]); } // 3) 차단/비활성 계정 if ($state === 'blocked') { $msg = '로그인 할 수 없는 계정입니다.'; return back() ->withInput() ->withErrors(['login_id' => $msg]) ->with('toast', [ 'type' => 'danger', 'title' => '접근 불가', 'message' => $msg, ]); } // 4) SMS 발송 실패 if ($state === 'sms_error') { $msg = '인증 SMS 발송에 실패했습니다. 잠시 후 다시 시도해 주세요.'; return back() ->withInput() ->withErrors(['login_id' => $msg]) ->with('toast', [ 'type' => 'danger', 'title' => 'SMS 오류', 'message' => $msg, ]); } // 5) 비번 재설정 강제 if ($state === 'must_reset') { $request->session()->put('admin_pwreset', [ 'admin_id' => (int) ($res['admin_id'] ?? 0), 'email' => $email, 'expires_at' => time() + 600, 'ip' => $ip, ]); return redirect() ->route('admin.password.reset.form') ->with('toast', [ 'type' => 'info', 'title' => '비밀번호 변경 필요', 'message' => '비밀번호 초기화가 필요합니다. 새 비밀번호를 설정해 주세요.', ]); } if ($state === 'totp_required') { $adminId = (int) ($res['admin_id'] ?? 0); if ($adminId <= 0) { return back() ->withInput() ->withErrors(['login_id' => '로그인 처리 중 오류가 발생했습니다. 다시 시도해 주세요.']); } // (권장) 세션 고정 공격 방지 $request->session()->regenerate(); $ttl = (int) config('admin.totp_ttl', 300); $request->session()->put('admin_totp', [ 'admin_id' => $adminId, 'remember' => $remember, 'ip' => $ip, 'attempts' => 0, 'max_attempts' => (int) config('admin.totp_max_attempts', 5), 'expires_at' => time() + $ttl, ]); return redirect() ->route('admin.totp.form') ->with('toast', [ 'type' => 'info', 'title' => 'Google OTP 인증', 'message' => 'Google Authenticator 앱의 6자리 코드를 입력해 주세요.', ]); } // 6) OTP 발송 성공 if ($state === 'otp_sent') { $request->session()->put('admin_2fa', [ 'challenge_id' => (string) ($res['challenge_id'] ?? ''), 'masked_phone' => (string) ($res['masked_phone'] ?? ''), 'expires_at' => time() + (int) config('admin.sms_ttl', 180), ]); // (권장) 세션 고정 공격 방지 $request->session()->regenerate(); return redirect() ->route('admin.otp.form') ->with('toast', [ 'type' => 'success', 'title' => '인증번호 발송', 'message' => '인증번호를 문자로 발송했습니다.', ]); } // 방어: 예상치 못한 상태 return back() ->withInput() ->withErrors(['login_id' => '로그인 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.']) ->with('toast', [ 'type' => 'danger', 'title' => '처리 오류', 'message' => '로그인 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.', ]); } // 비밀번호 초기화 폼 public function showForceReset(Request $request) { $pending = (array) $request->session()->get('admin_pwreset', []); if (empty($pending['admin_id'])) { return redirect()->route('admin.login.form') ->withErrors(['login_id' => '비밀번호 초기화 세션이 없습니다. 다시 로그인해 주세요.']); } if ((int)($pending['expires_at'] ?? 0) < time()) { $request->session()->forget('admin_pwreset'); return redirect()->route('admin.login.form') ->withErrors(['login_id' => '비밀번호 초기화가 만료되었습니다. 다시 로그인해 주세요.']); } return view('admin.auth.password_reset', [ 'email' => (string)($pending['email'] ?? ''), ]); } // 비밀번호 초기화 저장 public function storeForceReset(Request $request) { $pending = (array) $request->session()->get('admin_pwreset', []); if (empty($pending['admin_id'])) { return redirect()->route('admin.login.form') ->withErrors(['login_id' => '비밀번호 초기화 세션이 없습니다. 다시 로그인해 주세요.']); } if ((int)($pending['expires_at'] ?? 0) < time()) { $request->session()->forget('admin_pwreset'); return redirect()->route('admin.login.form') ->withErrors(['login_id' => '비밀번호 초기화가 만료되었습니다. 다시 로그인해 주세요.']); } $data = $request->validate([ 'password' => ['required', 'string', 'min:10', 'max:255', 'confirmed'], ]); $ip = (string) $request->ip(); $ua = (string) $request->userAgent(); $res = $this->authService->resetPassword( adminId: (int) $pending['admin_id'], newPassword: (string) $data['password'], ip: $ip, ua: $ua, ); if (!($res['ok'] ?? false)) { $request->session()->forget('admin_pwreset'); return redirect()->route('admin.login.form') ->withErrors(['login_id' => '비밀번호 변경에 실패했습니다. 다시 로그인해 주세요.']); } $request->session()->forget('admin_pwreset'); return redirect()->route('admin.login.form') ->with('status', '비밀번호가 변경되었습니다. 다시 로그인해 주세요.'); } public function showOtp(Request $request) { $pending = (array) $request->session()->get('admin_2fa', []); if (empty($pending['challenge_id'])) { return redirect()->route('admin.login.form') ->withErrors(['login_id' => '인증 정보가 없습니다. 다시 로그인해 주세요.']); } if ((int)($pending['expires_at'] ?? 0) < time()) { $request->session()->forget('admin_2fa'); return redirect()->route('admin.login.form') ->withErrors(['login_id' => '인증이 만료되었습니다. 다시 로그인해 주세요.']); } return view('admin.auth.otp', [ 'masked_phone' => (string)($pending['masked_phone'] ?? ''), ]); } public function verifyOtp(Request $request) { $data = $request->validate([ 'otp' => ['required', 'digits:6'], ]); $pending = (array) $request->session()->get('admin_2fa', []); $challengeId = (string)($pending['challenge_id'] ?? ''); if ($challengeId === '') { return redirect()->route('admin.login.form') ->withErrors(['login_id' => '인증 정보가 없습니다. 다시 로그인해 주세요.']); } $res = $this->authService->verifyOtp( challengeId: $challengeId, otp: (string) $data['otp'], ip: (string) $request->ip() ); if (!($res['ok'] ?? false)) { $reason = (string)($res['reason'] ?? ''); $msg = match ($reason) { 'expired' => '인증이 만료되었습니다. 다시 로그인해 주세요.', 'attempts' => '시도 횟수를 초과했습니다. 다시 로그인해 주세요.', 'ip' => '접속 정보가 변경되었습니다. 다시 로그인해 주세요.', 'blocked' => '로그인 할 수 없는 계정입니다.', default => '인증번호가 올바르지 않습니다.', }; if ($reason !== 'invalid') { $request->session()->forget('admin_2fa'); return redirect()->route('admin.login.form')->withErrors(['login_id' => $msg]); } return back()->withErrors(['otp' => $msg]); } /** @var \App\Models\AdminUser $admin */ $admin = $res['admin']; Auth::guard('admin')->login($admin, (bool)($res['remember'] ?? false)); // 권한/관리자 정보 세션 적재 $ctx = $this->authService->buildAdminSessionContext((int)$admin->id); if (!empty($ctx)) $request->session()->put('admin_ctx', $ctx); $request->session()->forget('admin_2fa'); return redirect()->route('admin.home'); } public function logout(Request $request) { Auth::guard('admin')->logout(); $request->session()->forget('admin_ctx'); $request->session()->invalidate(); $request->session()->regenerateToken(); return redirect()->route('admin.login.form'); } public function security(Request $request) { $adminId = (int) auth()->guard('admin')->id(); $vm = $this->authService->totpViewModel($adminId); abort_unless(($vm['ok'] ?? false), 404); return view('admin.me.security', $vm); } public function totpStart(Request $request) { $adminId = (int) auth()->guard('admin')->id(); $res = $this->authService->totpStart( $adminId, false, (string)$request->ip(), (string)($request->userAgent() ?? '') ); return back()->with($res['ok'] ? 'status' : 'error', $res['message']); } public function totpConfirm(Request $request) { $adminId = (int) auth()->guard('admin')->id(); $data = $request->validate([ 'code' => ['required', 'string', 'max:10'], ]); $res = $this->authService->totpConfirm( $adminId, (string) $data['code'], (string)$request->ip(), (string)($request->userAgent() ?? '') ); return redirect()->route('admin.security') ->with($res['ok'] ? 'status' : 'error', $res['message']); } public function totpDisable(Request $request) { $adminId = (int) auth()->guard('admin')->id(); $res = $this->authService->totpDisable( $adminId, (string)$request->ip(), (string)($request->userAgent() ?? '') ); return redirect()->route('admin.security') ->with($res['ok'] ? 'status' : 'error', $res['message']); } public function totpReset(Request $request) { $adminId = (int) auth()->guard('admin')->id(); $res = $this->authService->totpReset($adminId); return redirect()->route('admin.security') ->with($res['ok'] ? 'status' : 'error', $res['message']); } public function totpMode(Request $request) { $adminId = (int) auth()->guard('admin')->id(); $data = $request->validate([ 'totp_enabled' => ['required', 'in:0,1'], ]); $res = $this->authService->totpMode( $adminId, (int) $data['totp_enabled'], (string)$request->ip(), (string)($request->userAgent() ?? '') ); return redirect()->route('admin.security') ->with($res['ok'] ? 'status' : 'error', $res['message']); } public function showTotp(Request $request) { $pending = (array) $request->session()->get('admin_totp', []); if (empty($pending['admin_id'])) { return redirect()->route('admin.login.form') ->withErrors(['login_id' => '인증 정보가 없습니다. 다시 로그인해 주세요.']); } if ((int)($pending['expires_at'] ?? 0) < time()) { $request->session()->forget('admin_totp'); return redirect()->route('admin.login.form') ->withErrors(['login_id' => '인증이 만료되었습니다. 다시 로그인해 주세요.']); } return view('admin.auth.totp'); } public function verifyTotp(Request $request) { $data = $request->validate([ 'code' => ['required', 'digits:6'], ]); $pending = (array) $request->session()->get('admin_totp', []); $adminId = (int)($pending['admin_id'] ?? 0); if ($adminId <= 0) { return redirect()->route('admin.login.form') ->withErrors(['login_id' => '인증 정보가 없습니다. 다시 로그인해 주세요.']); } if ((int)($pending['expires_at'] ?? 0) < time()) { $request->session()->forget('admin_totp'); return redirect()->route('admin.login.form') ->withErrors(['login_id' => '인증이 만료되었습니다. 다시 로그인해 주세요.']); } // IP 고정 (SMS와 동일 정책) $ip = (string) $request->ip(); if (!hash_equals((string)($pending['ip'] ?? ''), $ip)) { $request->session()->forget('admin_totp'); return redirect()->route('admin.login.form') ->withErrors(['login_id' => '접속 정보가 변경되었습니다. 다시 로그인해 주세요.']); } // 시도 횟수 제한 $attempts = (int)($pending['attempts'] ?? 0); $max = (int)($pending['max_attempts'] ?? 5); if ($attempts >= $max) { $request->session()->forget('admin_totp'); return redirect()->route('admin.login.form') ->withErrors(['login_id' => '시도 횟수를 초과했습니다. 다시 로그인해 주세요.']); } $pending['attempts'] = $attempts + 1; $request->session()->put('admin_totp', $pending); $res = $this->authService->verifyTotp( adminId: $adminId, code: (string) $data['code'], ip: $ip ); if (!($res['ok'] ?? false)) { $reason = (string)($res['reason'] ?? ''); $msg = match ($reason) { 'blocked' => '로그인 할 수 없는 계정입니다.', 'not_registered' => 'OTP 등록 상태가 올바르지 않습니다. 최고관리자에게 문의해 주세요.', default => '인증코드가 올바르지 않습니다.', }; // invalid는 페이지에서 재입력, 나머지는 로그인으로 if ($reason !== 'invalid') { $request->session()->forget('admin_totp'); return redirect()->route('admin.login.form')->withErrors(['login_id' => $msg]); } return back()->withErrors(['code' => $msg]); } /** @var \App\Models\AdminUser $admin */ $admin = $res['admin']; Auth::guard('admin')->login($admin, (bool)($pending['remember'] ?? false)); $request->session()->forget('admin_totp'); $ctx = $this->authService->buildAdminSessionContext((int)$admin->id); if (!empty($ctx)) $request->session()->put('admin_ctx', $ctx); return redirect()->route('admin.home'); } }