get('logged_in')) { return redirect()->to('/'); } return view('auth/login'); } public function login() { $rules = [ 'login_id' => 'required|max_length[50]', 'password' => 'required|max_length[255]', ]; $messages = [ 'login_id' => [ 'required' => '아이디를 입력해 주세요.', 'max_length' => '아이디는 50자 이하여야 합니다.', ], 'password' => [ 'required' => '비밀번호를 입력해 주세요.', 'max_length' => '비밀번호는 255자 이하여야 합니다.', ], ]; if (! $this->validate($rules, $messages)) { return redirect()->back() ->withInput() ->with('errors', $this->validator->getErrors()); } $loginId = trim($this->request->getPost('login_id')); $password = $this->request->getPost('password'); $memberModel = model(MemberModel::class); $member = $memberModel->findByLoginId($loginId); $approvalModel = model(MemberApprovalRequestModel::class); $logData = $this->buildLogData($loginId, $member?->mb_idx); if ($member === null) { $this->insertMemberLog($logData, false, '회원 정보를 찾을 수 없습니다.'); return redirect()->back() ->withInput() ->with('error', '아이디 또는 비밀번호가 올바르지 않습니다.'); } if ($member->mb_state === self::MB_STATE_LEAVE) { $this->insertMemberLog($logData, false, '탈퇴한 회원입니다.'); return redirect()->back() ->withInput() ->with('error', '탈퇴한 회원입니다.'); } if ($member->mb_state === self::MB_STATE_BANNED) { $this->insertMemberLog($logData, false, '정지된 회원입니다.'); return redirect()->back() ->withInput() ->with('error', '정지된 회원입니다.'); } // P2-21: 로그인 잠금 체크 (5회 실패 시 30분 lock) if (!empty($member->mb_locked_until) && strtotime($member->mb_locked_until) > time()) { $remaining = ceil((strtotime($member->mb_locked_until) - time()) / 60); $this->insertMemberLog($logData, false, '계정 잠금 상태'); return redirect()->back() ->withInput() ->with('error', '로그인 시도 횟수 초과로 계정이 잠겼습니다. 약 ' . $remaining . '분 후 다시 시도해 주세요.'); } if (! password_verify($password, $member->mb_passwd)) { // 실패 횟수 증가 $failCount = ((int) ($member->mb_login_fail_count ?? 0)) + 1; $updateData = ['mb_login_fail_count' => $failCount]; if ($failCount >= 5) { $updateData['mb_locked_until'] = date('Y-m-d H:i:s', strtotime('+30 minutes')); } $memberModel->update($member->mb_idx, $updateData); $this->insertMemberLog($logData, false, '비밀번호 불일치 (' . $failCount . '회)'); $msg = '아이디 또는 비밀번호가 올바르지 않습니다.'; if ($failCount >= 5) { $msg .= ' 5회 연속 실패로 계정이 30분간 잠깁니다.'; } elseif ($failCount >= 3) { $msg .= ' (실패 ' . $failCount . '/5회)'; } return redirect()->back()->withInput()->with('error', $msg); } // 승인 요청 상태 확인(공개 회원가입 사용자) $latestApproval = $approvalModel->getLatestByMember((int) $member->mb_idx); if ($latestApproval !== null) { if ($latestApproval->mar_status === MemberApprovalRequestModel::STATUS_PENDING) { $this->insertMemberLog($logData, false, '승인 대기 상태'); return redirect()->back() ->withInput() ->with('error', '관리자 승인 후 로그인 가능합니다.'); } if ($latestApproval->mar_status === MemberApprovalRequestModel::STATUS_REJECTED) { $this->insertMemberLog($logData, false, '승인 반려 상태'); return redirect()->back() ->withInput() ->with('error', '승인이 반려되었습니다. 관리자에게 문의해 주세요.'); } } if ($this->needsTotpStep($member)) { $this->beginPending2faSession((int) $member->mb_idx); $enabled = (int) ($member->mb_totp_enabled ?? 0) === 1; if ($enabled) { return redirect()->to(site_url('login/two-factor')); } session()->set('pending_totp_setup', true); return redirect()->to(site_url('login/totp-setup')); } return $this->completeLogin($member, $logData); } public function showTwoFactor() { if (session()->get('logged_in')) { return redirect()->to('/'); } $member = $this->ensurePending2faContext(); if ($member === null) { return redirect()->to(site_url('login'))->with('error', '로그인 세션이 만료되었습니다. 다시 로그인해 주세요.'); } if (session()->get('pending_totp_setup')) { return redirect()->to(site_url('login/totp-setup')); } if ((int) ($member->mb_totp_enabled ?? 0) !== 1) { return redirect()->to(site_url('login/totp-setup')); } return view('auth/login_two_factor', [ 'memberId' => $member->mb_id, ]); } public function verifyTwoFactor() { if (session()->get('logged_in')) { return redirect()->to('/'); } $member = $this->ensurePending2faContext(); if ($member === null) { return redirect()->to(site_url('login'))->with('error', '로그인 세션이 만료되었습니다. 다시 로그인해 주세요.'); } if (session()->get('pending_totp_setup') || (int) ($member->mb_totp_enabled ?? 0) !== 1) { return redirect()->to(site_url('login/totp-setup')); } $rules = [ 'totp_code' => 'required|exact_length[6]|numeric', ]; $messages = [ 'totp_code' => [ 'required' => '인증 코드 6자리를 입력해 주세요.', 'exact_length' => '인증 코드는 6자리 숫자입니다.', 'numeric' => '인증 코드는 숫자만 입력해 주세요.', ], ]; if (! $this->validate($rules, $messages)) { return redirect()->back()->withInput()->with('errors', $this->validator->getErrors()); } $code = (string) $this->request->getPost('totp_code'); helper('pii_encryption'); $secret = pii_decrypt((string) ($member->mb_totp_secret ?? '')); if ($secret === '') { $this->clearPending2faSession(); return redirect()->to(site_url('login'))->with('error', '2차 인증 설정이 올바르지 않습니다. 관리자에게 문의해 주세요.'); } $totp = new TotpService(); if (! $totp->verify($secret, $code)) { return $this->handleTotpFailure($member, $this->buildLogData($member->mb_id, (int) $member->mb_idx)); } return $this->completeLogin($member, $this->buildLogData($member->mb_id, (int) $member->mb_idx)); } public function showTotpSetup() { if (session()->get('logged_in')) { return redirect()->to('/'); } $member = $this->ensurePending2faContext(); if ($member === null) { return redirect()->to(site_url('login'))->with('error', '로그인 세션이 만료되었습니다. 다시 로그인해 주세요.'); } if (! session()->get('pending_totp_setup')) { if ((int) ($member->mb_totp_enabled ?? 0) === 1) { return redirect()->to(site_url('login/two-factor')); } return redirect()->to(site_url('login')); } $totp = new TotpService(); $secret = session()->get('pending_totp_secret'); if (! is_string($secret) || $secret === '') { $secret = $totp->createSecret(); session()->set('pending_totp_secret', $secret); } $qrDataUri = null; try { $qrDataUri = $totp->getQrDataUri((string) $member->mb_id, $secret); } catch (\Throwable) { $qrDataUri = null; } return view('auth/totp_setup', [ 'memberId' => $member->mb_id, 'qrDataUri' => $qrDataUri, 'secret' => $secret, ]); } public function completeTotpSetup() { if (session()->get('logged_in')) { return redirect()->to('/'); } $member = $this->ensurePending2faContext(); if ($member === null) { return redirect()->to(site_url('login'))->with('error', '로그인 세션이 만료되었습니다. 다시 로그인해 주세요.'); } if (! session()->get('pending_totp_setup')) { return redirect()->to(site_url('login/two-factor')); } $rules = [ 'totp_code' => 'required|exact_length[6]|numeric', ]; $messages = [ 'totp_code' => [ 'required' => '인증 코드 6자리를 입력해 주세요.', 'exact_length' => '인증 코드는 6자리 숫자입니다.', 'numeric' => '인증 코드는 숫자만 입력해 주세요.', ], ]; if (! $this->validate($rules, $messages)) { return redirect()->back()->withInput()->with('errors', $this->validator->getErrors()); } $secret = session()->get('pending_totp_secret'); if (! is_string($secret) || $secret === '') { return redirect()->to(site_url('login/totp-setup'))->with('error', '설정 정보가 없습니다. 페이지를 새로고침해 주세요.'); } $code = (string) $this->request->getPost('totp_code'); $totp = new TotpService(); if (! $totp->verify($secret, $code)) { return $this->handleTotpFailure($member, $this->buildLogData($member->mb_id, (int) $member->mb_idx)); } helper('pii_encryption'); model(MemberModel::class)->update((int) $member->mb_idx, [ 'mb_totp_secret' => pii_encrypt($secret), 'mb_totp_enabled' => 1, ]); session()->remove('pending_totp_setup'); session()->remove('pending_totp_secret'); $fresh = model(MemberModel::class)->find((int) $member->mb_idx); if ($fresh === null) { $this->clearPending2faSession(); return redirect()->to(site_url('login'))->with('error', '회원 정보를 다시 확인할 수 없습니다.'); } return $this->completeLogin($fresh, $this->buildLogData($fresh->mb_id, (int) $fresh->mb_idx)); } public function logout() { if (session()->get('logged_in')) { $mbIdx = session()->get('mb_idx'); $mbId = session()->get('mb_id'); $log = model(MemberLogModel::class) ->where('mb_idx', $mbIdx) ->where('mll_success', 1) ->orderBy('mll_idx', 'DESC') ->first(); if ($log !== null) { model(MemberLogModel::class)->update($log->mll_idx, [ 'mll_logout_date' => date('Y-m-d H:i:s'), ]); } else { model(MemberLogModel::class)->insert([ 'mll_success' => 1, 'mb_idx' => $mbIdx, 'mb_id' => $mbId ?? '', 'mll_regdate' => date('Y-m-d H:i:s'), 'mll_ip' => $this->request->getIPAddress(), 'mll_msg' => '로그아웃', 'mll_useragent' => substr((string) $this->request->getUserAgent(), 0, 500), 'mll_logout_date' => date('Y-m-d H:i:s'), ]); } } $this->clearPending2faSession(); session()->destroy(); return redirect()->to('login')->with('success', '로그아웃되었습니다.'); } public function showRegisterForm() { $localGovernments = model(LocalGovernmentModel::class) ->where('lg_state', 1) ->orderBy('lg_name', 'ASC') ->findAll(); return view('auth/register', [ 'localGovernments' => $localGovernments, ]); } public function register() { $rules = [ 'mb_id' => 'required|min_length[4]|max_length[50]|is_unique[member.mb_id]', 'mb_passwd' => 'required|min_length[4]|max_length[255]', 'mb_passwd_confirm' => 'required|matches[mb_passwd]', 'mb_name' => 'required|max_length[100]', 'mb_email' => 'permit_empty|valid_email|max_length[100]', 'mb_phone' => 'permit_empty|max_length[20]', 'mb_lg_idx' => 'permit_empty|is_natural_no_zero', 'mb_level' => 'required|in_list[1,2,3]', ]; $messages = [ 'mb_id' => [ 'required' => '아이디를 입력해 주세요.', 'min_length' => '아이디는 4자 이상이어야 합니다.', 'max_length' => '아이디는 50자 이하여야 합니다.', 'is_unique' => '이미 사용 중인 아이디입니다.', ], 'mb_passwd' => [ 'required' => '비밀번호를 입력해 주세요.', 'min_length' => '비밀번호는 4자 이상이어야 합니다.', 'max_length' => '비밀번호는 255자 이하여야 합니다.', ], 'mb_passwd_confirm' => [ 'required' => '비밀번호 확인을 입력해 주세요.', 'matches' => '비밀번호가 일치하지 않습니다.', ], 'mb_name' => [ 'required' => '이름을 입력해 주세요.', 'max_length' => '이름은 100자 이하여야 합니다.', ], 'mb_email' => [ 'valid_email' => '올바른 이메일 형식이 아닙니다.', 'max_length' => '이메일은 100자 이하여야 합니다.', ], 'mb_phone' => [ 'max_length' => '연락처는 20자 이하여야 합니다.', ], 'mb_level' => [ 'required' => '사용자 역할을 선택해 주세요.', 'in_list' => '유효하지 않은 역할입니다.', ], ]; if (! $this->validate($rules, $messages)) { return redirect()->back() ->withInput() ->with('errors', $this->validator->getErrors()); } $mbLevel = (int) $this->request->getPost('mb_level'); if (! config('Roles')->isValidLevel($mbLevel)) { $mbLevel = config('Roles')->defaultLevelForSelfRegister; } $lgIdx = $this->request->getPost('mb_lg_idx'); $mbLgIdx = ($lgIdx !== null && $lgIdx !== '' && (int) $lgIdx > 0) ? (int) $lgIdx : null; helper('pii_encryption'); $data = [ 'mb_id' => $this->request->getPost('mb_id'), 'mb_passwd' => password_hash($this->request->getPost('mb_passwd'), PASSWORD_DEFAULT), 'mb_name' => $this->request->getPost('mb_name'), 'mb_email' => pii_encrypt($this->request->getPost('mb_email') ?? ''), 'mb_phone' => pii_encrypt($this->request->getPost('mb_phone') ?? ''), 'mb_lang' => 'ko', // 공개 회원가입 시점에는 역할을 활성화하지 않고 기본 레벨로 생성(승인 후 requested_level 반영) 'mb_level' => \Config\Roles::LEVEL_CITIZEN, 'mb_group' => '', 'mb_lg_idx' => $mbLgIdx, 'mb_state' => 1, 'mb_regdate' => date('Y-m-d H:i:s'), ]; $memberModel = model(MemberModel::class); if (! $memberModel->insert($data)) { return redirect()->back() ->withInput() ->with('error', '회원가입 처리 중 오류가 발생했습니다. 다시 시도해 주세요.'); } $newMemberIdx = (int) $memberModel->getInsertID(); $approvalModel = model(MemberApprovalRequestModel::class); $approvalModel->insert([ 'mb_idx' => $newMemberIdx, 'mar_requested_level' => $mbLevel, 'mar_status' => MemberApprovalRequestModel::STATUS_PENDING, 'mar_request_note' => '', 'mar_reject_reason' => null, 'mar_requested_at' => date('Y-m-d H:i:s'), 'mar_requested_by' => $newMemberIdx, 'mar_processed_at' => null, 'mar_processed_by' => null, ]); return redirect()->to('login')->with('success', '회원가입이 완료되었습니다. 관리자 승인 후 로그인 가능합니다.'); } private function needsTotpStep(object $member): bool { if (! config('Auth')->requireTotp) { return false; } return \Config\Roles::requiresTotp((int) $member->mb_level); } private function beginPending2faSession(int $mbIdx): void { session()->set([ 'pending_2fa' => true, 'pending_mb_idx' => $mbIdx, 'pending_2fa_started' => time(), 'totp_attempts' => 0, ]); session()->remove('pending_totp_setup'); session()->remove('pending_totp_secret'); } private function clearPending2faSession(): void { session()->remove([ 'pending_2fa', 'pending_mb_idx', 'pending_2fa_started', 'pending_totp_setup', 'pending_totp_secret', 'totp_attempts', ]); } private function pending2faExpired(): bool { $started = (int) session()->get('pending_2fa_started'); if ($started <= 0) { return true; } $ttl = config('Auth')->pending2faTtlSeconds; return (time() - $started) > $ttl; } private function ensurePending2faContext(): ?object { if (! session()->get('pending_2fa')) { return null; } if ($this->pending2faExpired()) { $this->clearPending2faSession(); return null; } $mbIdx = (int) session()->get('pending_mb_idx'); if ($mbIdx <= 0) { $this->clearPending2faSession(); return null; } $member = model(MemberModel::class)->find($mbIdx); if ($member === null) { $this->clearPending2faSession(); return null; } return $member; } /** * @param array $logData */ private function handleTotpFailure(object $member, array $logData): RedirectResponse { $this->insertMemberLog($logData, false, '2차 인증 실패', (int) $member->mb_idx); $attempts = (int) session()->get('totp_attempts') + 1; session()->set('totp_attempts', $attempts); $max = config('Auth')->totpMaxAttempts; if ($attempts >= $max) { $this->clearPending2faSession(); return redirect()->to(site_url('login'))->with('error', "인증 코드가 {$max}회 틀려 세션이 종료되었습니다. 처음부터 로그인해 주세요."); } return redirect()->back() ->withInput() ->with('error', '인증 코드가 올바르지 않습니다.'); } /** * @param array $logData */ private function completeLogin(object $member, array $logData): RedirectResponse { $this->clearPending2faSession(); $sessionData = [ 'mb_idx' => $member->mb_idx, 'mb_id' => $member->mb_id, 'mb_name' => $member->mb_name, 'mb_level' => $member->mb_level, 'mb_lg_idx' => $member->mb_lg_idx ?? null, 'logged_in' => true, ]; session()->set($sessionData); model(MemberModel::class)->update($member->mb_idx, [ 'mb_latestdate' => date('Y-m-d H:i:s'), 'mb_login_fail_count' => 0, 'mb_locked_until' => null, ]); $this->insertMemberLog($logData, true, '로그인 성공', (int) $member->mb_idx); if ((int) $member->mb_level === \Config\Roles::LEVEL_LOCAL_ADMIN) { return redirect()->to(site_url('admin'))->with('success', '로그인되었습니다.'); } if (\Config\Roles::isSuperAdminEquivalent((int) $member->mb_level)) { return redirect()->to(site_url('admin/select-local-government'))->with('success', '로그인되었습니다.'); } return redirect()->to(site_url('/'))->with('success', '로그인되었습니다.'); } private function buildLogData(string $mbId, ?int $mbIdx): array { return [ 'mb_idx' => $mbIdx, 'mb_id' => $mbId, 'mll_regdate' => date('Y-m-d H:i:s'), 'mll_ip' => $this->request->getIPAddress(), 'mll_useragent' => substr((string) $this->request->getUserAgent(), 0, 500), 'mll_url' => current_url(), 'mll_referer' => $this->request->getServer('HTTP_REFERER'), ]; } private function insertMemberLog(array $data, bool $success, string $msg, ?int $mbIdx = null): void { $data['mll_success'] = $success ? 1 : 0; $data['mll_msg'] = $msg; if ($mbIdx !== null) { $data['mb_idx'] = $mbIdx; } model(MemberLogModel::class)->insert($data); } }