SameOS ~/tools/passwordless-otp-login.md

비밀번호를 없앤 관리자 로그인 - 메신저 일회용 코드 방식

작성일 2026-07-08 · SameOS Tools

개인 서버의 관리자 페이지는 보통 비밀번호 하나로 지킵니다. 문제는 그 비밀번호를 계속 기억해야 하고, 한 번 유출되면 끝이라는 점입니다. 저는 운영 중인 사이트의 관리자 로그인에서 고정 비밀번호를 아예 없앴습니다. 대신 로그인 화면의 "코드 받기" 버튼을 누르면 제 메신저 채널로 일회용 코드가 도착하고, 그 코드를 입력해야만 들어갈 수 있습니다. 은행 앱의 문자 인증과 같은 방식인데, 웹훅(외부 프로그램이 메신저 채널로 메시지를 보낼 수 있게 해 주는 전용 주소 - 디스코드, 슬랙 등에서 무료로 만들 수 있습니다) 하나만 있으면 유료 문자 서비스 없이 만들 수 있습니다.

동작 원리

핵심은 "코드를 받아볼 수 있는 사람 = 그 메신저 채널의 주인 = 관리자"라는 등식입니다. 코드 받기 버튼을 누르면 서버가 무작위 코드를 만들어서, 원문은 관리자의 메신저로만 보내고 서버에는 해시(원래 값을 복원할 수 없게 뭉갠 값 - 서버가 털려도 코드를 알아낼 수 없습니다)만 저장합니다. 공격자가 관리자 페이지 주소를 알아내도 코드가 도착하는 메신저를 볼 수 없으니 들어올 방법이 없습니다. 사실상 메신저 계정이 열쇠가 되는 2단계 인증입니다.

안전을 위한 규칙 네 가지를 함께 둡니다. 코드는 10분 뒤 만료, 한 번 쓰면 즉시 폐기(1회용), 5번 틀리면 폐기, 재전송은 30초에 한 번. 여기에 코드를 틀릴 때마다 2초씩 기다리게 하는 지연을 더하면, 숫자를 무작위로 대입해 뚫는 공격은 사실상 불가능해집니다. 로그인에 성공했을 때도 메신저로 알림을 보내 두면, 내가 하지 않은 로그인이 발생했을 때 즉시 알아챌 수 있습니다.

주의할 점

첫째, 웹훅이 고장 나면 코드를 못 받아 나 자신도 잠기므로, 설정 파일에 비상용 고정 비밀번호 자리를 비워 두고 필요할 때만 채우는 탈출구를 마련합니다. 둘째, 로그인 상태를 기억하는 세션 쿠키에는 httponly와 samesite 속성을 반드시 줍니다(악성 스크립트가 쿠키를 훔쳐 가는 것을 막는 브라우저 안전장치입니다). 셋째, 파이썬 등에서 디스코드 웹훅을 호출할 때 기본 User-Agent가 403으로 거부되는 경우가 있으니 User-Agent 헤더를 지정합니다. 넷째, 관리자 페이지 주소는 추측하기 어렵게 짓고 robots.txt(검색엔진에 사이트 구조를 알려 주는 안내 파일)에는 절대 적지 않습니다 - 적는 순간 누구나 볼 수 있는 곳에 주소를 공개하는 셈입니다.

실전 예제 코드

// 1) 코드 발급 - "코드 받기" 버튼 (30초 재전송 제한)
if (isset($_POST['send_code'])) {
    if ((int)get_setting('otp_sent_at') > time() - 30) {
        $notice = '방금 전송했습니다. 30초 후 다시 시도하세요.';
    } else {
        $code = str_pad((string)random_int(0, 99999999), 8, '0', STR_PAD_LEFT);
        set_setting('otp_hash', password_hash($code, PASSWORD_DEFAULT)); // 해시만 저장
        set_setting('otp_exp',  (string)(time() + 600));   // 10분 유효
        set_setting('otp_tries', '0');
        set_setting('otp_sent_at', (string)time());
        send_webhook("관리자 일회용 코드: {$code} (10분 유효, 1회용)");
    }
}

// 2) 코드 검증 - 만료·횟수·해시 모두 통과해야 로그인
if (isset($_POST['code'])) {
    $ok = get_setting('otp_hash') !== ''
       && (int)get_setting('otp_exp') > time()
       && (int)get_setting('otp_tries') < 5
       && password_verify(trim($_POST['code']), get_setting('otp_hash'));
    if ($ok) {
        session_regenerate_id(true);          // 세션 고정 공격 방지
        $_SESSION['admin'] = true;
        set_setting('otp_hash', '');          // 1회용 - 즉시 폐기
        send_webhook('관리자 로그인 - IP ' . $_SERVER['REMOTE_ADDR']);
    } else {
        set_setting('otp_tries', (string)((int)get_setting('otp_tries') + 1));
        sleep(2);                             // 무차별 대입 지연
    }
}

// 3) 웹훅 전송 (디스코드 기준 - User-Agent 필수)
function send_webhook(string $msg): void {
    $ctx = stream_context_create(['http' => [
        'method'  => 'POST',
        'header'  => "Content-Type: application/json\r\nUser-Agent: my-server/1.0",
        'content' => json_encode(['content' => $msg], JSON_UNESCAPED_UNICODE),
        'timeout' => 10,
    ]]);
    @file_get_contents(WEBHOOK_URL, false, $ctx);
}

// 4) 세션 쿠키 강화 (session_start 전에)
session_set_cookie_params([
    'httponly' => true, 'samesite' => 'Lax', 'secure' => true,
]);

이 구조의 좋은 점은 비밀번호를 기억할 필요가 없어진다는 것, 그리고 로그인 시도가 전부 메신저에 흔적을 남긴다는 것입니다. 서버 상태 알림을 웹훅으로 받는 방법과 비공개 파일을 보호하는 구조는 별도 글에서 다룹니다.

서버 상태 웹훅 알림 글 보기 비공개 파일 보호 글 보기