509 lines
18 KiB
PHP
509 lines
18 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\Admin\Auth;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Rules\RecaptchaV3Rule;
|
|
use App\Services\Admin\AdminAuthService;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
final class AdminAuthController extends Controller
|
|
{
|
|
public function __construct(
|
|
private readonly AdminAuthService $authService
|
|
) {}
|
|
|
|
public function showLogin()
|
|
{
|
|
return view('admin.auth.login');
|
|
}
|
|
|
|
public function storeLogin(Request $request)
|
|
{
|
|
$rules = [
|
|
'login_id' => ['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');
|
|
}
|
|
}
|