giftcon_dev/app/Http/Controllers/Admin/Auth/AdminAuthController.php
2026-03-03 15:13:16 +09:00

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');
}
}