로그인 뒤 페이지 스크래핑: 쿠키 저장소와 CSRF 토큰

웹 스크래핑 강좌 4. 직접 만든 연습 사이트에 http.cookiejar 와 CSRF 토큰으로 로그인하고, 리다이렉트 뒤 로그아웃·토큰 누락·세션 만료를 실제로 일으켜 고칩니다.

시리즈: 웹 스크래핑 강좌 4/6 (중급). 필요한 데이터가 자동화해도 된다고 확인된 사이트의 로그인 뒤에 있는 분을 위한 글입니다. 사내 도구, 우리 쇼핑몰의 관리자 화면, 회사 앱의 스테이징 서버 같은 곳이요. 다 읽으면 파이썬 표준 라이브러리만으로 쿠키 저장소와 CSRF 토큰을 써서 로그인하고, 조용히 실패하는 세 가지 경우를 알아볼 수 있습니다.

흔한 상황은 이렇습니다. 아직 API 가 없는 사내 관리 화면에서 매일 아침 판매 현황을 내려받아 가격 비교표에 붙이는 일을 자동화해 달라는 요청을 받았습니다. 브라우저 개발자 도구를 보며 짠 스크립트는 200 OK 를 돌려주는데 데이터가 없습니다. 내용이 다시 로그인 폼이거나, POST 가 403 으로 돌아오거나, 한 시간쯤 잘 돌다가 조용히 멈춥니다. 이 강좌에서는 직접 띄운 연습용 쇼핑몰에서 셋을 모두 재현하고, 각각의 접근 로그를 읽고, 고칩니다.

모든 실행은 Apple Silicon Mac(M1 Pro)의 Python 3.9.6(표준 라이브러리만, 한 곳만 3.12.14 와 비교), PHP 8.5.6(php -S), curl 8.7.1 로 127.0.0.1 에 대해서만 했습니다. 경로는 ~/demo/ 로 바꿨습니다.

먼저: 누구의 로그인인가

로그인 폼은 사이트 운영자가 "여기는 공개하지 않는다"고 말하는 것입니다. 그래서 규칙은 3강보다 더 엄격해집니다.

  • 내 사이트, 또는 자동화해도 된다는 게 분명한 계정과 데이터만. 사내 도구라면 먼저 그 도구를 맡은 팀에 물어봅니다. 다른 회사 사이트라면 약관을 읽습니다. 자동 로그인이나 로그인 뒤 페이지의 자동 접근을 금지하는 약관이 많고, 금지한다면 거기서 멈추고 공식 API 나 내보내기 기능을 찾습니다. 공공기관 데이터라면 공공데이터포털에 같은 데이터가 API 로 올라와 있는지 먼저 확인하세요.
  • 남의 아이디와 비밀번호는 쓰지 않는다. 팀이 함께 쓰는 비밀번호를 스크립트에 넣지 않고, 유료 벽이나 제한을 피하려고 계정을 만들지 않습니다.
  • 개인정보. 로그인 뒤 페이지에는 다른 사람의 이름, 주소, 메시지가 가득한 경우가 많습니다. 수집하고 저장하려면 대부분의 나라에서 법적 근거가 필요합니다. 필요 없는 것은 모으지 않습니다. 회원 전용 데이터베이스를 통째로 복제하는 일은 데이터베이스 관련 권리와 약관 문제로도 이어질 수 있습니다.
  • 우회하지 않는다. CAPTCHA, 2단계 인증, 봇 확인에는 손대지 않습니다. 그게 나타났다면 그것이 답입니다.
  • 세션 쿠키는 비밀번호와 같다. 쿠키 파일을 가진 사람은 나로 로그인한 상태입니다.

이 글은 법률 자문이 아닙니다. 사이트 약관과 해당 지역 법을 확인하세요. 아래는 모두 우리가 직접 쓴 서버와 가짜 사용자 demo 가 상대입니다.

연습용 로그인 화면

아래를 ~/demo/login_shop.php 로 저장하고, 옆에 sessions 폴더를 만든 뒤 실행합니다. 숨은 CSRF 토큰이 들어간 로그인 폼, 회원 전용 /account 페이지, 20초 유휴 시간 제한, 그리고 요청마다 한 줄씩 남는 접근 로그(클라이언트가 보낸 세션 쿠키의 앞 6자리)를 갖췄습니다.

<?php
// Practice shop, lecture 4 part (login). Loopback only:
//   php -S 127.0.0.1:18166 login_shop.php
const IDLE_SEC = 20;                        // session idle timeout

$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
register_shutdown_function(function () {    // one access-log line per request
    error_log(sprintf('%s %s -> %d cookie=%s', $_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI'],
        http_response_code(), substr($_COOKIE['SHOPSESS'] ?? '-', 0, 6)));
});
function page(string $title, string $body): void {
    header('Content-Type: text/html; charset=utf-8');
    echo "<!doctype html><html><head><title>$title</title></head><body>\n$body\n</body></html>\n";
}
session_save_path(__DIR__ . '/sessions');   // mkdir sessions first
session_name('SHOPSESS');
session_set_cookie_params(['path' => '/', 'httponly' => true, 'samesite' => 'Lax']);
session_start();

if ($path === '/login' && $_SERVER['REQUEST_METHOD'] === 'GET') {
    $_SESSION['csrf'] ??= bin2hex(random_bytes(16));
    page('Log in', '<form method="post" action="/login">'
        . '<input type="hidden" name="csrf" value="' . $_SESSION['csrf'] . '">'
        . '<input name="user"><input name="password" type="password"><button>Log in</button></form>');
} elseif ($path === '/login' && $_SERVER['REQUEST_METHOD'] === 'POST') {
    if (!isset($_SESSION['csrf']) || !hash_equals($_SESSION['csrf'], $_POST['csrf'] ?? '')) {
        http_response_code(403);
        page('Forbidden', '<p>CSRF token missing or invalid</p>');
    } elseif (($_POST['user'] ?? '') !== 'demo' || ($_POST['password'] ?? '') !== 'practice') {
        http_response_code(401);
        page('Log in', '<p class="error">Wrong user or password</p>');
    } else {
        session_regenerate_id(true);        // new session id after login
        unset($_SESSION['csrf']);
        $_SESSION['user'] = 'demo';
        $_SESSION['seen'] = time();
        header('Location: /account', true, 302);
    }
} elseif ($path === '/account') {
    if (isset($_SESSION['user']) && time() - $_SESSION['seen'] > IDLE_SEC) {
        $_SESSION = [];
        session_destroy();
        header('Location: /login?expired=1', true, 302);
    } elseif (!isset($_SESSION['user'])) {
        header('Location: /login', true, 302);
    } else {
        $_SESSION['seen'] = time();
        page('Account', "<h1>Orders for {$_SESSION['user']}</h1><table class=\"orders\">"
            . '<tr><td>#1001</td><td>Desk lamp</td><td>2980</td></tr>'
            . '<tr><td>#1002</td><td>Mug</td><td>1150</td></tr></table>');
    }
} elseif ($path === '/logout') {
    $_SESSION = [];
    session_destroy();
    header('Location: /login', true, 302);
} else {
    http_response_code(404);
    page('Not found', '<p>Not found</p>');
}
return true;

용어 세 개부터 짚고 갑니다. 세션 은 요청과 요청 사이에 서버가 나를 기억하는 방법입니다. PHP 는 이를 파일에 저장하고 그 ID 를 쿠키(SHOPSESS=…)로 클라이언트에 줍니다. 쿠키 저장소(cookie jar)는 쿠키를 기억했다가 다시 보내는 클라이언트 쪽 보관함입니다. CSRF 토큰(사이트 간 요청 위조 방지)은 서버가 자기 폼에 넣어 두고 POST 와 함께 돌아오길 기대하는 무작위 값입니다. 다른 사이트가 사용자 몰래 폼을 제출하는 것을 막습니다. 우리 스크립트에게는 그냥 폼의 일부라서, 브라우저처럼 읽어서 돌려보내면 됩니다. 헤더 자체는 HTTP 헤더, 쿠키, 세션, CSRF 의 서버 쪽은 PHP 보안: XSS, CSRF, 세션에서 다룹니다.

curl 로 손으로 해 보기

-c jar.txt 는 쿠키를 파일에 쓰고, -b jar.txt 는 보냅니다.

$ curl -si -c jar.txt http://127.0.0.1:18166/login | grep -E '^(HTTP|Set-Cookie)'
HTTP/1.1 200 OK
Set-Cookie: SHOPSESS=b0956c248537b852c946671cc2a5bd54; path=/; HttpOnly; SameSite=Lax
$ TOKEN=$(curl -s -b jar.txt -c jar.txt http://127.0.0.1:18166/login | grep -o 'name="csrf" value="[0-9a-f]*"' | cut -d'"' -f4)
$ echo $TOKEN
ee10ea36fc24c96756c8d71ef3483623
$ curl -si -b jar.txt -c jar.txt -d "csrf=$TOKEN&user=demo&password=practice" http://127.0.0.1:18166/login | grep -E '^(HTTP|Location|Set-Cookie)'
HTTP/1.1 302 Found
Set-Cookie: SHOPSESS=5d8623d9da7310d3ef1440ab0dbc646e; path=/; HttpOnly; SameSite=Lax
Location: /account
$ curl -s -b jar.txt http://127.0.0.1:18166/account | sed -n 2p
<h1>Orders for demo</h1><table class="orders"><tr><td>#1001</td>…
$ curl -s -o /dev/null -w "%{http_code} -> %{redirect_url}\n" http://127.0.0.1:18166/account
302 -> http://127.0.0.1:18166/login

쿠키 값을 보세요. 로그인 전에는 b0956c…, 로그인 뒤에는 5d8623… 입니다. 서버가 로그인하는 순간 옛 세션 ID 를 버렸습니다(session_regenerate_id(true)). 세션 고정 공격을 막으려고 제대로 만든 사이트라면 다 하는 처리입니다. 이것이 첫 번째 함정의 뿌리이니 기억해 두세요. HttpOnly 와 SameSite=Lax 는 브라우저에 주는 지시(JavaScript 접근 금지, 다른 사이트에서 보낼 때 제외)라서 브라우저가 아닌 스크립트에는 영향이 없습니다.

파이썬으로: 저장소 하나, 오프너 하나

~/demo/login.py 는 curl 과 같은 일을 합니다. 폼을 불러와 숨은 input 을 모두 모으고, 아이디와 비밀번호를 더해 POST 한 뒤, 어디에 도착했는지 확인합니다.

"""Log in to OUR OWN practice shop: cookie jar + CSRF token. Python 3.9+ stdlib only."""
import http.cookiejar
import urllib.error
import urllib.parse
import urllib.request
from html.parser import HTMLParser

BASE = "http://127.0.0.1:18166"
UA = "tlab-practice-bot/0.1 (+https://example.com/bot-contact)"


class HiddenInputs(HTMLParser):
    """Collect <input type="hidden" name=... value=...> from a form."""
    def __init__(self):
        super().__init__()
        self.fields = {}

    def handle_starttag(self, tag, attrs):
        a = dict(attrs)
        if tag == "input" and a.get("type") == "hidden" and a.get("name"):
            self.fields[a["name"]] = a.get("value", "")


class Session:
    def __init__(self, user, password):
        self.user, self.password = user, password
        self.jar = http.cookiejar.CookieJar()          # one jar for the whole session
        self.opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(self.jar))
        self.opener.addheaders = [("User-Agent", UA)]

    def login(self):
        form = HiddenInputs()
        form.feed(self.opener.open(BASE + "/login", timeout=10).read().decode())
        data = dict(form.fields, user=self.user, password=self.password)
        body = urllib.parse.urlencode(data).encode()
        with self.opener.open(BASE + "/login", data=body, timeout=10) as r:
            if urllib.parse.urlparse(r.geturl()).path != "/account":   # check where we landed
                raise RuntimeError("login failed, landed on " + r.geturl())

    def get(self, path):
        """GET a members-only page; log in again once if the session expired."""
        for attempt in (1, 2):
            with self.opener.open(BASE + path, timeout=10) as r:
                html, final = r.read().decode(), r.geturl()
            if urllib.parse.urlparse(final).path != "/login":
                return html
            print(f"  (attempt {attempt}: bounced to {final}, logging in)")
            self.login()
        raise RuntimeError("still logged out after re-login")


if __name__ == "__main__":
    s = Session("demo", "practice")
    s.login()
    print([c.name + "=" + c.value[:8] + "…" for c in s.jar])
    print(s.get("/account").splitlines()[1][:60])
$ python3 login.py
['SHOPSESS=268e1ba3…']
<h1>Orders for demo</h1><table class="orders"><tr><td>#1001<

csrf 만이 아니라 숨은 input 을 모두 모으는 것이 실제 폼에서는 중요합니다. 프레임워크가 폼 ID, 타임스탬프, 돌아갈 URL 등을 여러 개 넣는 경우가 흔하기 때문입니다. 비밀번호를 코드에 적은 것은 로컬 서버의 가짜 사용자라서입니다. 실제 계정이라면 환경 변수나 OS 키체인에서 읽고, 소스 파일에는 절대 적지 않습니다.

함정을 하나씩 실행하기

~/demo/traps.py 는 같은 서버에 대고 대표적인 실수 다섯 가지를 한 뒤, 마지막에 제대로 합니다.

"""The login traps, each run against our own practice shop."""
import http.cookiejar
import re
import urllib.error
import urllib.parse
import urllib.request

BASE = "http://127.0.0.1:18166"
CREDS = {"user": "demo", "password": "practice"}


def token_of(html):
    return re.search(r'name="csrf" value="([0-9a-f]+)"', html).group(1)


def jar_opener():
    return urllib.request.build_opener(urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()))


def post(opener, fields, headers=None):
    req = urllib.request.Request(BASE + "/login", data=urllib.parse.urlencode(fields).encode(),
                                 headers=headers or {})
    try:
        with opener.open(req, timeout=10) as r:
            return f"{r.status} final URL {r.geturl()}"
    except urllib.error.HTTPError as e:
        return f"HTTPError {e.code}: {e.read().decode().splitlines()[1]}"


plain = urllib.request.build_opener()          # no cookie processor

# T1: no cookie jar at all
tok = token_of(plain.open(BASE + "/login").read().decode())
print("T1 no jar          ->", post(plain, dict(CREDS, csrf=tok)))

# T2: copy the cookie by hand, let urllib follow the 302
with plain.open(BASE + "/login") as r:
    cookie = r.headers["Set-Cookie"].split(";")[0]
    tok = token_of(r.read().decode())
print("T2 manual Cookie   ->", post(plain, dict(CREDS, csrf=tok), {"Cookie": cookie}))

# T3: a new opener (new jar) for each request
tok = token_of(jar_opener().open(BASE + "/login").read().decode())
print("T3 jar per request ->", post(jar_opener(), dict(CREDS, csrf=tok)))

# T4: one jar, but the hidden csrf field was forgotten
op = jar_opener()
op.open(BASE + "/login").read()
print("T4 no csrf field   ->", post(op, CREDS))

# T5: one jar, token sent, wrong password
op = jar_opener()
tok = token_of(op.open(BASE + "/login").read().decode())
print("T5 wrong password  ->", post(op, dict(CREDS, password="nope", csrf=tok)))

# OK: one jar for GET and POST
op = jar_opener()
tok = token_of(op.open(BASE + "/login").read().decode())
print("OK one jar         ->", post(op, dict(CREDS, csrf=tok)))

(여기서 정규식을 써도 되는 건 우리가 폼을 직접 써서 모양을 정확히 알기 때문입니다. 남의 HTML 이라면 2강처럼 파서를 쓰세요.)

$ python3 traps.py
T1 no jar          -> HTTPError 403: <p>CSRF token missing or invalid</p>
T2 manual Cookie   -> 200 final URL http://127.0.0.1:18166/login
T3 jar per request -> HTTPError 403: <p>CSRF token missing or invalid</p>
T4 no csrf field   -> HTTPError 403: <p>CSRF token missing or invalid</p>
T5 wrong password  -> HTTPError 401: <p class="error">Wrong user or password</p>
OK one jar         -> 200 final URL http://127.0.0.1:18166/account

서로 다른 세 가지 실수가 같은 403 이 됐습니다. 서버 입장에서는 똑같아 보이기 때문입니다. POST 에 담긴 토큰이 쿠키가 가리키는 세션에 저장된 토큰과 다르거나, 그런 세션이 아예 없습니다. T1 은 쿠키를 안 보냈으니 PHP 가 비어 있는 새 세션을 시작했고, T3 는 토큰을 발급한 세션과 다른 세션의 쿠키를 보냈습니다.

골치 아픈 건 T2 입니다. 200 이 오고 예외도 없습니다. 접근 로그를 보면 무슨 일이 있었는지 보입니다.

GET /login -> 200 cookie=-
POST /login -> 302 cookie=f2fa6b
GET /account -> 302 cookie=f2fa6b
GET /login -> 200 cookie=f2fa6b
…
GET /login -> 200 cookie=-
POST /login -> 302 cookie=9c07d5
GET /account -> 200 cookie=f703a0

로그인 자체는 성공했습니다(302). 그런데 302 에 새 쿠키가 실려 왔고, urllib 은 리다이렉트를 따라가면서 손으로 넣은 Cookie: SHOPSESS=f2fa6b… 를 그대로 다시 보냈습니다. 그 세션은 이미 없으니 /account 가 /login 으로 돌려보냈고, /login 이 200 으로 답했습니다. 올바른 실행(마지막 세 줄)에서는 POST 와 리다이렉트 사이에서 저장소의 쿠키가 9c07d5 에서 f703a0 으로 바뀝니다. 상태 코드만 보면 T2 는 성공입니다. 최종 URL 이나 내용을 꼭 확인하세요(r.geturl(), 또는 "로그아웃 링크나 내 아이디가 페이지에 있는가").

흐름도. GET /login 이 Set-Cookie SHOPSESS=A 와 숨은 필드 csrf 를 돌려준다. POST /login 에 쿠키 A 와 토큰을 보낸다. 서버는 /account 로 가는 302 와 Set-Cookie SHOPSESS=B 를 돌려준다. 쿠키 저장소가 있으면 B 를 /account 에 보내 주문 목록을 200 으로 받는다. 저장소가 없으면 옛 A 를 보내 /login 으로 302 를 받고, 상태 200 인 로그인 페이지에서 끝난다.
직접 만든 연습용 쇼핑몰의 로그인 흐름(접근 로그로 확인). 로그인할 때 세션 ID 가 바뀌므로(session_regenerate_id), 302 뒤에도 옛 쿠키 A 를 계속 보내는 클라이언트는 /login 으로 되돌아간다.

"리다이렉트에서 뭔가를 잃어버리는" 문제는 다른 곳에서도 나옵니다. 302 뒤에 POST 가 GET 으로 바뀌는 이야기는 HTTP 상태 코드와 리다이렉트, 리다이렉트를 아예 따라가지 않은 클라이언트 이야기는 Dart http 에서 POST 리다이렉트를 따라가지 않는 문제에 있습니다.

세션 만료와 재로그인

세션은 언젠가 끝납니다. 유휴 시간 초과, 다른 곳에서의 로그아웃, 서버 재시작, 배포. 이 쇼핑몰은 20초 동안 아무 요청이 없으면 세션을 버립니다. 위의 Session.get() 은 /login 에 도착한 걸 알아채고 한 번 더 로그인합니다. expire.py 는 두 번 읽는 사이에 21초를 기다립니다.

$ python3 expire.py
first:  <h1>Orders for demo</h1><table
after 21 s idle:
  (attempt 1: bounced to http://127.0.0.1:18166/login?expired=1, logging in)
second: <h1>Orders for demo</h1><table
GET /account -> 200 cookie=0b056d
GET /account -> 302 cookie=0b056d
GET /login?expired=1 -> 200 cookie=0b056d
GET /login -> 200 cookie=0b056d
POST /login -> 302 cookie=0b056d
GET /account -> 200 cookie=20659d

이 작은 반복문에는 규칙 두 개가 숨어 있습니다. 재로그인은 한 번만 하고, 그래도 안 되면 에러로 멈출 것. 실패하는 로그인을 반복하면 비밀번호 대입 공격처럼 보이고, 많은 사이트가 몇 번 만에 계정을 잠급니다. 그리고 재로그인을 로그에 남길 것. 매번 일어난다면 시간 제한이 생각보다 짧거나, 세션이 어떤 이유로 끊기고 있는 것이고, 그 이유는 알아 둘 가치가 있습니다.

실행 사이에 쿠키 보관하기

야간 작업에서 매번 로그인하지 않고 어제 세션을 다시 쓰고 싶을 수 있습니다. http.cookiejar.MozillaCookieJar 는 curl 의 -c 와 같은 Netscape 형식을 읽고 씁니다. 함정이 두 개 있고, 둘 다 확인했습니다.

$ python3 save_jar.py
in memory: 1 cookie, discard = [True]
save():                    0 cookie lines
save(ignore_discard=True): 1 cookie lines
load():                    0
load(ignore_discard=True): 1

이 쇼핑몰의 쿠키에는 Expires 가 없어서 세션 쿠키(discard=True)가 되고, save() 는 이를 말없이 건너뜁니다. 저장할 때도 읽을 때도 ignore_discard=True 가 필요합니다. 두 번째는 curl 이 쓴 저장소 파일을 읽을 때입니다.

3.9.6   ignore_discard: 0 | + ignore_expires: 0
3.12.14 ignore_discard: 0 | + ignore_expires: 1

curl 은 HttpOnly 쿠키를 #HttpOnly_127.0.0.1 … 형태로 쓰는데, Python 3.9 는 이 줄을 주석으로 보고 무시합니다. 이 접두사 지원은 이후 버전에서 들어왔고(정확한 버전은 확인하지 않았습니다), 3.12.14 에서는 읽혔습니다. 또 curl 은 세션 쿠키의 만료 시각을 0 으로 쓰는데, 파이썬은 이를 "1970년에 만료"로 보기 때문에 ignore_expires=True 도 필요합니다. 쿠키 파일을 저장한다면 chmod 600 으로 막고 git 에 넣지 마세요. 그 안에 든 건 로그인된 세션 그 자체입니다.

실측: 세션 재사용 vs 매번 로그인

bench_session.py 는 /account 를 20번 읽습니다. 로그인된 세션 하나로 읽는 경우와 매번 새로 로그인하는 경우를 각각 5번 돌리고, 서버에 남은 세션 파일을 셌습니다.

reuse one session  median     9.4 ms | new session files per run [1, 1, 1, 1, 1]
log in every page  median    47.7 ms | new session files per run [20, 20, 20, 20, 20]
막대그래프. 세션 재사용: 23 요청. 페이지마다 로그인: 80 요청.
연습용 쇼핑몰에서 회원 전용 페이지를 20번 읽는 데 필요한 요청 수. 세션 하나를 재사용하면 중앙값 9.4 ms, 서버에 남는 세션 파일 1개. 페이지마다 로그인하면 47.7 ms, 20개가 남았다(각 5회, Python 3.9.6, Apple M1 Pro, 127.0.0.1).

루프백이라 시간 차이의 절댓값은 작지만(약 5배), 요청 수는 23에서 80으로 늘었고 로그인할 때마다 서버에 세션 파일이 하나씩 남았습니다. 실제 네트워크에서는 요청이 늘 때마다 지연이 붙고, 한 계정에서 1초에 20번 로그인하는 것은 보안 담당자가 경보를 울리는 바로 그 패턴입니다. 로그인은 한 번, 세션은 재사용, 튕겨 나갔을 때만 재로그인합니다.

흔한 실수 → 실제로 일어난 일 → 해결

실수 실제로 일어난 일 해결
쿠키 저장소 없음 POST 가 403 CSRF HTTPCookieProcessor(CookieJar())
요청마다 새 오프너 403 CSRF: 다른 세션의 토큰 세션 전체에 오프너 하나
Cookie 헤더를 직접 작성 200 인데 최종 URL 이 /login 쿠키는 저장소에 맡기고 geturl() 확인
user 와 password 만 전송 403 CSRF 숨은 input 을 모두 돌려보내기
상태 코드만 확인 로그아웃 상태인데 "성공" 최종 URL 이나 내용 확인
반복문으로 재로그인 비밀번호 대입처럼 보임 재로그인은 한 번, 안 되면 크게 실패
기본값으로 jar.save() 저장된 쿠키 0개 save(ignore_discard=True)

판단표: 이 스크립트가 로그인해도 되는가

상황 할 일
내 사이트나 앱 괜찮음. HTML 스크래핑보다 작은 JSON 엔드포인트나 내보내기를 추가하는 편이 낫다
다른 팀이 관리하는 사내 도구 담당 팀에 문의. API 토큰이나 보고서 내보내기는 대개 쉽게 받을 수 있다
외부 사이트, 약관이 자동화를 허용 내 계정으로, 낮은 빈도로, 필요한 데이터만
외부 사이트, 약관이 금지하거나 불분명 스크립트로 로그인하지 않는다. 공식 API 를 쓰거나 문의
공공기관 데이터 공공데이터포털 API·파일 데이터가 있는지 먼저 확인
페이지에 다른 사람의 개인정보 분명한 법적 근거가 없으면 모으지 않는다
데이터 앞에 CAPTCHA 나 2단계 인증 멈춘다. 의도된 "안 됨"이다

솔직한 한계와 버전 메모

  • Python 3.9.6 에서 확인했고, 쿠키 파일 읽기 비교만 3.12.14 에서도 확인했습니다. http.cookiejar, HTTPCookieProcessor, MozillaCookieJar 는 Python 2 시절부터 표준 라이브러리에 있습니다. #HttpOnly_ 지원은 3.10 변경 기록에 근거한 것이고, 실제로 돌린 것은 3.9.6 과 3.12.14 뿐입니다.
  • 시간은 루프백, Apple M1 Pro, PHP 8.5.6 내장 서버(프로세스 1개)에서 잰 값입니다. 3강의 워커 4개짜리 쇼핑몰로 같은 비교를 하면 18.5 ms 대 60.7 ms 였고, 요청 23 대 80, 세션 파일 1 대 20 은 똑같았습니다.
  • PHP 기본값 session.use_strict_mode=0 때문에, 세션이 만료된 뒤에도 클라이언트가 계속 보낸 모르는 세션 ID 로 PHP 가 세션을 만들었습니다(로그에서 session_destroy() 뒤에도 cookie=0b056d). 그걸 무해하게 만든 것은 로그인할 때의 session_regenerate_id(true) 입니다. 서버 쪽 설정이라 스크래퍼가 기대거나 이용할 대상이 아닙니다.
  • requests.Session 을 쓰면 요청과 리다이렉트를 넘나들며 쿠키를 유지해 주고 대부분 이걸 쓰지만, 여기서는 쓰지 않았습니다. 실제 로그인 화면은 JavaScript, SSO 리다이렉트, 여러 단계의 흐름을 쓰는 경우가 많아 urllib 으로는 따라갈 수 없습니다. 그때 필요한 건 사이트의 API 이지 더 많은 스크래핑이 아닙니다.
  • 이 글은 법률 자문이 아닙니다. 사이트 약관과 해당 지역 법을 확인하세요.

연습 문제

  1. Session 에 logout() 을 추가하고, 로그아웃 뒤에는 옛 쿠키로 /account 를 열 수 없다는 것을 접근 로그로 보여 주세요.
  2. Session.get() 은 최종 URL 로 세션 만료를 판단합니다. 이 판단이 통하지 않는 사이트 구조를 하나 들고, 추가할 수 있는 두 번째 확인 방법을 생각해 보세요.

정답

1. 로그아웃하기 전에 쿠키를 복사해 두었다가 로그아웃 뒤에 써 봅니다. copy.deepcopy(jar) 는 TypeError: cannot pickle '_thread.RLock' object 로 실패하므로(저장소가 락을 갖고 있음), 쿠키를 복사합니다.

saved = [copy.copy(c) for c in s.jar]      # copy the cookies, not the jar
s.opener.open(login.BASE + "/logout").read()
old = http.cookiejar.CookieJar()
for c in saved:
    old.set_cookie(c)
op = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(old))
with op.open(login.BASE + "/account") as r:
    print("old cookie after logout ->", r.geturl())

출력은 old cookie after logout -> http://127.0.0.1:18166/login 이었고, 로그에는 GET /logout -> 302 cookie=e65654 다음에 GET /account -> 302 cookie=e65654 가 찍혔습니다. 다 쓴 세션을 끝내는 것은 좋은 습관이기도 합니다.

2. 같은 URL 에서 로그인 폼을 보여 주는 사이트(/account 가 리다이렉트 없이 200 으로 폼을 돌려줌)나, SSO 용 다른 호스트로 보내는 사이트입니다. 내용 확인을 추가합니다. 로그아웃 링크나 Orders for demo 처럼 로그인한 페이지에만 있는 것이 들어 있는지 보고, 없으면 "로그아웃 상태"로 처리합니다.

다음 글

자주 묻는 질문

로그인하면 200 이 오는데 내용은 다시 로그인 폼입니다. 왜 그런가요?

로그인 뒤 302 응답에서 서버가 새 세션 쿠키를 줬는데, 클라이언트가 리다이렉트를 따라가면서 옛 쿠키를 보냈기 때문입니다. 접근 로그에는 POST /login -> 302 다음에 같은 옛 쿠키로 GET /account -> 302 가 찍혔고, 마지막은 상태 200 인 /login 이었습니다. 모든 요청에 http.cookiejar.CookieJar 하나와 HTTPCookieProcessor 를 쓰고, 상태 코드만이 아니라 최종 URL 을 확인하세요.

로그인 POST 가 403 'CSRF token missing or invalid' 로 돌아옵니다.

폼의 토큰은 그 폼을 불러온 세션에 속합니다. 실험에서는 쿠키 저장소 없음, 요청마다 새 저장소, 숨은 필드 csrf 누락이라는 서로 다른 세 가지 실수가 모두 403 이 됐습니다. 폼을 가져오는 요청과 보내는 요청에 같은 쿠키 저장소를 쓰고, 숨은 input 을 모두 돌려보내세요.

다른 회사 사이트에 스크립트로 로그인해도 되나요?

내 계정으로, 접근이 허락된 데이터에 대해, 그리고 사이트 약관이 자동 접근을 허용할 때만입니다. 금지하는 약관이 많고, 로그인 뒤 페이지에는 개인정보가 들어 있는 경우가 흔합니다. 공식 API 나 내보내기 기능을 먼저 쓰세요. 이 글은 법률 자문이 아닙니다. 사이트 약관과 해당 지역 법을 확인하세요.

  • #스크래핑
  • #중급
  • #파이썬
  • #쿠키
  • #세션
  • #CSRF
  • #폼

t.laboratory

직접 만들고 운영하는 앱·서버·서비스에서 겪은 문제와, 그 해결에 쓴 코드를 기록하는 기술 블로그입니다.

운영자 소개 · 앱

↑