Devin.KR

종합 실습 - 예약 주문 게시판

개발자KR 조회 0

이 장에서 배우는 것

이 책의 마지막 장이다. 앞 장까지 배운 함수, 클래스, 예외, 폼 처리, PDO를 모아 동네 빵집의 예약 주문 게시판을 완성한다. 새로 배우는 도구는 네 가지다. 로그인 상태를 기억하는 세션, 위조된 제출을 걸러내는 CSRF 토큰, 비밀번호를 안전하게 저장하는 password_hash, 그리고 파일 하나에 몰려 있던 코드를 나누는 작은 라우터와 템플릿이다. 웹 서버 없이 php main.php 한 번으로 전체 흐름을 확인하고, 마지막에 php -S로 브라우저에서 같은 코드를 열어 본다.

  • 세션(session)에 로그인 정보를 저장하고, 로그인 직후 세션 ID를 새로 발급하는 이유를 설명할 수 있다.
  • CSRF 토큰을 만들고 hash_equals로 검증해 위조된 POST 요청을 거절할 수 있다.
  • password_hash와 password_verify로 비밀번호를 저장하고 확인할 수 있다.
  • 요청 배열을 흉내 내는 방식으로 웹 코드를 CLI에서 검증하고, 라우터와 템플릿으로 코드를 역할별로 나눌 수 있다.

문제 상황

빵집 사장님이 예약 주문 게시판을 열고 싶어 한다. 손님은 빵 종류와 수량, 짧은 메모를 남기고, 주문 목록은 누구나 볼 수 있다. 주문은 로그인한 손님만 넣을 수 있다. 앞 장까지의 방식으로 index.php 하나에 모두 넣으면 곧 세 가지 문제가 생긴다.

  • 로그인 여부를 요청마다 기억할 방법이 없다. HTTP는 요청 사이에 아무것도 기억하지 않는다.
  • 손님이 다른 사이트의 링크를 눌렀을 뿐인데, 그 사이트가 숨겨 둔 폼이 우리 사이트에 주문을 넣을 수 있다. 브라우저가 로그인 쿠키를 자동으로 붙여 보내기 때문이다.
  • 비밀번호를 그대로 저장한 표가 유출되면 손님이 다른 사이트에서 쓰는 비밀번호까지 함께 노출된다.

여기에 화면을 그리는 HTML과 저장하는 SQL, 주소별 분기가 한 파일에 섞이면 수정할 때마다 다른 기능이 깨진다. 이 장은 위 문제를 하나씩 막고, 파일을 역할별로 나누는 과정을 따라간다.

세션, CSRF 토큰, 비밀번호 해시

세션과 로그인 상태

세션은 서버 쪽에 손님별 저장 공간을 두고, 브라우저에는 그 공간의 번호(세션 ID)만 쿠키(cookie)로 건네는 방식이다. 브라우저는 요청마다 쿠키를 자동으로 보내고, PHP는 그 번호로 저장 공간을 찾아 $_SESSION 배열에 채워 준다. 로그인 정보 자체는 서버에만 있고, 브라우저가 가진 것은 번호뿐이다.

쿠키, 세션, 데이터베이스가 각각 맡는 일
저장소놓이는 곳이 장에서 담는 것수명
쿠키브라우저세션 ID 하나브라우저를 닫을 때까지
세션서버로그인한 사용자, CSRF 토큰로그아웃하거나 만료될 때까지
데이터베이스서버 디스크사용자, 비밀번호 해시, 주문지우기 전까지

CLI에는 브라우저도 쿠키도 없다. 그래서 세션을 Session 인터페이스 뒤로 숨기고 구현을 둘 만든다. 브라우저용 NativeSession은 PHP의 세션 함수를 감싸고, 시험용 MemorySession은 배열 하나로 같은 일을 한다. 앞 장에서 배운 인터페이스가 실제로 쓰이는 자리다. 컨트롤러는 어느 쪽이 들어왔는지 모르고, 어느 쪽이 들어와도 똑같이 동작한다.

로그인에 성공한 직후에는 세션 ID를 새로 발급한다(session_regenerate_id). 공격자가 미리 알아낸 세션 ID를 손님에게 심어 두면, 손님이 로그인하는 순간 그 번호가 로그인된 세션이 되어 버리기 때문이다. 번호를 바꾸면 미리 알아낸 값이 쓸모없어진다. 쿠키에는 httponly를 켜서 자바스크립트가 읽지 못하게 하고, samesite를 Lax로 두어 다른 사이트에서 시작된 POST에는 쿠키가 실리지 않게 한다.

CSRF 토큰

CSRF(cross-site request forgery)는 사용자가 로그인한 상태를 이용해 다른 사이트가 요청을 대신 보내게 만드는 공격이다. 쿠키는 브라우저가 알아서 붙이므로 서버는 그 요청이 우리 폼에서 왔는지 다른 사이트에서 왔는지 쿠키만으로는 구별하지 못한다. 구별 수단은 서버가 폼에 심어 두는 예측할 수 없는 값, 즉 토큰(token)이다.

서버가 폼에 넣어 준 토큰이 세션의 토큰과 같아야만 요청이 처리되고, 토큰을 모르는 위조 요청은 403으로 거절된다.

토큰은 random_bytes로 만든 무작위 값이다. 세션에 한 부 저장하고, 같은 값을 폼의 hidden 입력에 넣는다. 제출이 오면 두 값을 비교한다. 비교에는 === 대신 hash_equals를 쓴다. hash_equals는 앞부분이 다르다고 일찍 멈추지 않아서 비교 시간으로 값을 추측당할 여지가 없다. 상태를 바꾸는 요청은 모두 POST로 받고 토큰을 확인한다. 로그아웃도 상태를 바꾸므로 예외가 아니다.

비밀번호 해시

해시(hash)는 입력에서 고정 길이 값을 계산하는 한 방향 함수다. 결과에서 원래 비밀번호를 거꾸로 계산할 수 없다. password_hash는 비밀번호마다 무작위 솔트(salt)를 섞고, 일부러 느리게 계산하도록 설계된 알고리즘으로 해시를 만든다. 같은 비밀번호도 호출할 때마다 다른 결과가 나오며, 솔트와 계산 비용이 결과 문자열 안에 함께 들어 있다. 그래서 검증은 password_verify($입력, $저장된해시) 한 번이면 된다. PHP 8.4에서 PASSWORD_DEFAULT는 bcrypt이고, 결과는 $2y$로 시작한다. 기본 알고리즘은 앞으로 바뀔 수 있으므로 저장 열은 여유 있게 잡는다. 공식 문서는 255자를 권한다. 알고리즘이나 비용을 올린 뒤에는 password_needs_rehash로 옛 해시를 찾아 로그인 때 새로 저장한다. 자세한 내용은 PHP 매뉴얼의 Password Hashing에 있다.

작은 라우터와 템플릿 분리

라우터(router)는 요청의 메서드와 경로를 보고 어떤 함수가 처리할지 정하는 표다. 이 장의 라우터는 "GET /login" 같은 문자열을 열쇠로 하는 배열 하나가 전부다. 템플릿(template)은 HTML을 만드는 일만 하는 파일이다. 컨트롤러는 데이터를 모아 배열로 넘기고, 템플릿은 그 배열을 화면으로 바꾼다. 화면을 바꿀 때 저장 코드를 건드리지 않고, 저장 방식을 바꿀 때 HTML을 건드리지 않는다.

요청은 라우터를 거쳐 App의 핸들러에 도착하고, 핸들러가 세션과 SQLite를 다룬 뒤 View가 만든 HTML이나 이동 응답으로 끝난다.

POST를 처리한 뒤에는 화면을 바로 그리지 않고 303 응답으로 다른 주소로 보낸다. 제출 후 이동(Post/Redirect/Get)이라 부르는 방식이다. 이동하지 않으면 손님이 새로고침할 때 브라우저가 같은 POST를 다시 보내 주문이 두 번 들어간다. 템플릿에서 값을 찍을 때는 언제나 앞 장의 출력 이스케이프 함수를 거친다. 이 장에서는 그 함수를 e()라는 짧은 이름으로 만들어 쓴다.

요청 배열을 흉내 내는 방법은 간단하다. Request 객체가 메서드, 경로, POST 값을 생성자로 받는다. 브라우저에서는 $_SERVER와 $_POST로 채우고, CLI에서는 main.php가 직접 배열을 만들어 넣는다. 이 객체만 바꿔 끼우면 나머지 코드는 어느 환경인지 알 필요가 없다.

완성 코드

파일 구성은 다음과 같다. 모든 .php 파일 첫머리에 declare(strict_types=1)가 있고, 외부 패키지는 쓰지 않는다. main.php는 SQLite 메모리 데이터베이스를 쓰므로 실행할 때마다 처음 상태에서 시작한다.

main.php

<?php
declare(strict_types=1);

require __DIR__ . '/src/bootstrap.php';

function visit(App $app, string $method, string $path, array $post = []): Response
{
    $response = $app->handle(new Request($method, $path, $post));
    $where = $response->location !== null ? ' -> ' . $response->location : '';
    printf("%-5s %-9s %d%s\n", $method, $path, $response->status, $where);
    return $response;
}

function tokenOf(Response $response): string
{
    return preg_match('/name="csrf" value="([0-9a-f]+)"/', $response->body, $m) === 1 ? $m[1] : '';
}

$store = Store::open('sqlite::memory:');
$store->addUser('minsu', 'bread-1234');

$session = new MemorySession();
$app = new App($store, $session, new View(__DIR__ . '/templates'));

visit($app, 'GET', '/');
visit($app, 'GET', '/nowhere');
visit($app, 'POST', '/orders', ['bread' => '식빵', 'quantity' => '2', 'memo' => '']);

$token = tokenOf(visit($app, 'GET', '/login'));
visit($app, 'POST', '/login', ['csrf' => $token, 'name' => 'minsu', 'password' => 'wrong']);
visit($app, 'POST', '/login', ['name' => 'minsu', 'password' => 'bread-1234']);
visit($app, 'POST', '/login', ['csrf' => $token, 'name' => 'minsu', 'password' => 'bread-1234']);

$order = ['bread' => '식빵', 'quantity' => '2', 'memo' => '토요일 오전 픽업'];
visit($app, 'POST', '/orders', $order);
visit($app, 'POST', '/orders', ['csrf' => $token] + $order);
visit($app, 'POST', '/orders', [
    'csrf' => $token, 'bread' => '소보로빵', 'quantity' => '1', 'memo' => '<b>강조</b> & 포장',
]);
visit($app, 'POST', '/orders', ['csrf' => $token, 'bread' => '바게트', 'quantity' => '1', 'memo' => '']);

$board = visit($app, 'GET', '/');
foreach (explode("\n", $board->body) as $line) {
    if (str_contains($line, '<li>')) {
        echo '  ', trim($line), "\n";
    }
}

visit($app, 'POST', '/logout', ['csrf' => $token]);
visit($app, 'GET', '/');
printf("세션 ID 재발급: %d회\n", $session->regenerateCount);

$h1 = password_hash('bread-1234', PASSWORD_DEFAULT);
$h2 = password_hash('bread-1234', PASSWORD_DEFAULT);
printf(
    "해시 접두어 %s, 두 해시 %s, 검증 %s\n",
    substr($h1, 0, 4),
    $h1 === $h2 ? '같음' : '다름',
    password_verify('bread-1234', $h1) && password_verify('bread-1234', $h2) ? 'ok' : 'fail'
);

src/bootstrap.php

<?php
declare(strict_types=1);

foreach (['Http', 'Session', 'Csrf', 'Store', 'View', 'App'] as $name) {
    require_once __DIR__ . '/' . $name . '.php';
}

src/Http.php

<?php
declare(strict_types=1);

final class Request
{
    public function __construct(
        public readonly string $method,
        public readonly string $path,
        public readonly array $post = [],
    ) {
    }

    public static function fromGlobals(): self
    {
        $path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
        return new self($_SERVER['REQUEST_METHOD'] ?? 'GET', is_string($path) ? $path : '/', $_POST);
    }

    public function input(string $key): string
    {
        $value = $this->post[$key] ?? '';
        return is_string($value) ? $value : '';
    }
}

final class Response
{
    public function __construct(
        public readonly int $status,
        public readonly string $body = '',
        public readonly ?string $location = null,
    ) {
    }

    public static function redirect(string $to): self
    {
        return new self(303, '', $to);
    }

    public function send(): void
    {
        http_response_code($this->status);
        if ($this->location !== null) {
            header('Location: ' . $this->location);
        }
        echo $this->body;
    }
}

final class Router
{
    private array $routes = [];

    public function add(string $method, string $path, callable $handler): void
    {
        $this->routes[$method . ' ' . $path] = $handler;
    }

    public function dispatch(Request $request): Response
    {
        $handler = $this->routes[$request->method . ' ' . $request->path] ?? null;
        if ($handler === null) {
            return new Response(404, '찾을 수 없는 주소다');
        }
        return $handler($request);
    }
}

src/Session.php

<?php
declare(strict_types=1);

interface Session
{
    public function get(string $key, mixed $default = null): mixed;
    public function set(string $key, mixed $value): void;
    public function regenerate(): void;
    public function destroy(): void;
}

final class MemorySession implements Session
{
    private array $data = [];
    public int $regenerateCount = 0;

    public function get(string $key, mixed $default = null): mixed
    {
        return $this->data[$key] ?? $default;
    }

    public function set(string $key, mixed $value): void
    {
        $this->data[$key] = $value;
    }

    public function regenerate(): void
    {
        $this->regenerateCount++;
    }

    public function destroy(): void
    {
        $this->data = [];
    }
}

final class NativeSession implements Session
{
    public function __construct()
    {
        session_set_cookie_params(['httponly' => true, 'samesite' => 'Lax']);
        session_start();
    }

    public function get(string $key, mixed $default = null): mixed
    {
        return $_SESSION[$key] ?? $default;
    }

    public function set(string $key, mixed $value): void
    {
        $_SESSION[$key] = $value;
    }

    public function regenerate(): void
    {
        session_regenerate_id(true);
    }

    public function destroy(): void
    {
        $_SESSION = [];
        session_destroy();
    }
}

src/Csrf.php

<?php
declare(strict_types=1);

final class Csrf
{
    private const KEY = 'csrf_token';

    public function __construct(private readonly Session $session)
    {
    }

    public function token(): string
    {
        $token = $this->session->get(self::KEY);
        if (!is_string($token)) {
            $token = bin2hex(random_bytes(32));
            $this->session->set(self::KEY, $token);
        }
        return $token;
    }

    public function verify(string $given): bool
    {
        $token = $this->session->get(self::KEY);
        return is_string($token) && $given !== '' && hash_equals($token, $given);
    }
}

src/Store.php

<?php
declare(strict_types=1);

final class Store
{
    public function __construct(private readonly PDO $pdo)
    {
    }

    public static function open(string $dsn): self
    {
        $pdo = new PDO($dsn, options: [
            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        ]);
        $pdo->exec('CREATE TABLE IF NOT EXISTS users (
            id INTEGER PRIMARY KEY,
            name TEXT NOT NULL UNIQUE,
            password_hash TEXT NOT NULL
        )');
        $pdo->exec('CREATE TABLE IF NOT EXISTS orders (
            id INTEGER PRIMARY KEY,
            user_id INTEGER NOT NULL,
            bread TEXT NOT NULL,
            quantity INTEGER NOT NULL,
            memo TEXT NOT NULL
        )');
        return new self($pdo);
    }

    public function hasUsers(): bool
    {
        return (int) $this->pdo->query('SELECT COUNT(*) FROM users')->fetchColumn() > 0;
    }

    public function addUser(string $name, string $password): void
    {
        $stmt = $this->pdo->prepare('INSERT INTO users (name, password_hash) VALUES (:name, :hash)');
        $stmt->execute(['name' => $name, 'hash' => password_hash($password, PASSWORD_DEFAULT)]);
    }

    public function authenticate(string $name, string $password): ?array
    {
        $stmt = $this->pdo->prepare('SELECT id, name, password_hash FROM users WHERE name = :name');
        $stmt->execute(['name' => $name]);
        $row = $stmt->fetch();
        if ($row === false || !password_verify($password, $row['password_hash'])) {
            return null;
        }
        return ['id' => (int) $row['id'], 'name' => $row['name']];
    }

    public function addOrder(int $userId, string $bread, int $quantity, string $memo): void
    {
        $stmt = $this->pdo->prepare(
            'INSERT INTO orders (user_id, bread, quantity, memo) VALUES (:u, :b, :q, :m)'
        );
        $stmt->execute(['u' => $userId, 'b' => $bread, 'q' => $quantity, 'm' => $memo]);
    }

    public function orders(): array
    {
        return $this->pdo->query(
            'SELECT o.id, u.name AS customer, o.bread, o.quantity, o.memo
             FROM orders o JOIN users u ON u.id = o.user_id ORDER BY o.id'
        )->fetchAll();
    }
}

src/View.php

<?php
declare(strict_types=1);

function e(string $text): string
{
    return htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

final class View
{
    public function __construct(private readonly string $dir)
    {
    }

    public function render(string $template, array $vars): string
    {
        $content = $this->capture($template, $vars);
        return $this->capture('layout', ['title' => $vars['title'] ?? '', 'content' => $content]);
    }

    private function capture(string $template, array $vars): string
    {
        extract($vars, EXTR_SKIP);
        ob_start();
        require $this->dir . '/' . $template . '.php';
        return (string) ob_get_clean();
    }
}

src/App.php

<?php
declare(strict_types=1);

final class App
{
    private const BREADS = ['식빵', '소보로빵', '단팥빵'];

    private readonly Csrf $csrf;
    private readonly Router $router;

    public function __construct(
        private readonly Store $store,
        private readonly Session $session,
        private readonly View $view,
    ) {
        $this->csrf = new Csrf($session);
        $this->router = new Router();
        $this->router->add('GET', '/', $this->board(...));
        $this->router->add('GET', '/login', $this->loginForm(...));
        $this->router->add('POST', '/login', $this->login(...));
        $this->router->add('POST', '/logout', $this->logout(...));
        $this->router->add('POST', '/orders', $this->createOrder(...));
    }

    public function handle(Request $request): Response
    {
        return $this->router->dispatch($request);
    }

    private function currentUser(): ?array
    {
        $id = $this->session->get('user_id');
        $name = $this->session->get('user_name');
        return is_int($id) && is_string($name) ? ['id' => $id, 'name' => $name] : null;
    }

    private function page(string $template, array $vars, int $status): Response
    {
        return new Response($status, $this->view->render($template, $vars));
    }

    private function forbidden(): Response
    {
        return new Response(403, '요청을 확인할 수 없다');
    }

    private function board(Request $request, ?string $error = null, int $status = 200): Response
    {
        $user = $this->currentUser();
        return $this->page('board', [
            'title' => '예약 주문 게시판',
            'user' => $user['name'] ?? null,
            'orders' => $this->store->orders(),
            'breads' => self::BREADS,
            'csrf' => $this->csrf->token(),
            'error' => $error,
        ], $status);
    }

    private function loginForm(Request $request, ?string $error = null, int $status = 200): Response
    {
        return $this->page('login', [
            'title' => '로그인',
            'csrf' => $this->csrf->token(),
            'error' => $error,
        ], $status);
    }

    private function login(Request $request): Response
    {
        if (!$this->csrf->verify($request->input('csrf'))) {
            return $this->forbidden();
        }
        $user = $this->store->authenticate(trim($request->input('name')), $request->input('password'));
        if ($user === null) {
            return $this->loginForm($request, '이름 또는 비밀번호가 맞지 않다', 401);
        }
        $this->session->regenerate();
        $this->session->set('user_id', $user['id']);
        $this->session->set('user_name', $user['name']);
        return Response::redirect('/');
    }

    private function logout(Request $request): Response
    {
        if (!$this->csrf->verify($request->input('csrf'))) {
            return $this->forbidden();
        }
        $this->session->destroy();
        return Response::redirect('/');
    }

    private function createOrder(Request $request): Response
    {
        $user = $this->currentUser();
        if ($user === null) {
            return Response::redirect('/login');
        }
        if (!$this->csrf->verify($request->input('csrf'))) {
            return $this->forbidden();
        }
        $bread = $request->input('bread');
        $quantity = filter_var(
            $request->input('quantity'),
            FILTER_VALIDATE_INT,
            ['options' => ['min_range' => 1, 'max_range' => 20]]
        );
        $memo = trim($request->input('memo'));
        if (!in_array($bread, self::BREADS, true) || $quantity === false || mb_strlen($memo) > 40) {
            return $this->board($request, '입력값을 확인해 달라', 422);
        }
        $this->store->addOrder($user['id'], $bread, $quantity, $memo);
        return Response::redirect('/');
    }
}

templates/layout.php

<?php declare(strict_types=1); ?>
<!doctype html>
<html lang="ko">
<head>
  <meta charset="utf-8">
  <title><?= e($title) ?></title>
</head>
<body>
<?= $content ?>
</body>
</html>

templates/login.php

<?php declare(strict_types=1); ?>
<h1>로그인</h1>
<?php if ($error !== null): ?>
<p><?= e($error) ?></p>
<?php endif; ?>
<form method="post" action="/login">
  <input type="hidden" name="csrf" value="<?= e($csrf) ?>">
  <input name="name">
  <input name="password" type="password">
  <button>로그인</button>
</form>

templates/board.php

<?php declare(strict_types=1); ?>
<h1>동네 빵집 예약 주문</h1>
<?php if ($user === null): ?>
<p><a href="/login">로그인</a>하면 주문할 수 있다.</p>
<?php else: ?>
<p><?= e($user) ?> 님, 안녕하세요.</p>
<form method="post" action="/logout">
  <input type="hidden" name="csrf" value="<?= e($csrf) ?>">
  <button>로그아웃</button>
</form>
<form method="post" action="/orders">
  <input type="hidden" name="csrf" value="<?= e($csrf) ?>">
  <select name="bread">
<?php foreach ($breads as $bread): ?>
    <option><?= e($bread) ?></option>
<?php endforeach; ?>
  </select>
  <input name="quantity" type="number" value="1" min="1">
  <input name="memo">
  <button>예약</button>
</form>
<?php endif; ?>
<?php if ($error !== null): ?>
<p><?= e($error) ?></p>
<?php endif; ?>
<ul>
<?php foreach ($orders as $order): ?>
  <li>#<?= e((string) $order['id']) ?> <?= e($order['customer']) ?> - <?= e($order['bread']) ?> <?= e((string) $order['quantity']) ?>개 (<?= e($order['memo']) ?>)</li>
<?php endforeach; ?>
</ul>

public/index.php

<?php
declare(strict_types=1);

require __DIR__ . '/../src/bootstrap.php';

$store = Store::open('sqlite:' . dirname(__DIR__) . '/orders.sqlite');
if (!$store->hasUsers()) {
    $store->addUser('minsu', 'bread-1234');
}

$app = new App($store, new NativeSession(), new View(dirname(__DIR__) . '/templates'));
$app->handle(Request::fromGlobals())->send();

줄별 해설

main.php는 브라우저 역할을 한다. visit()은 Request 객체를 만들어 앱에 넘기고, 응답의 상태 코드와 이동 주소를 한 줄로 찍는다. tokenOf()는 로그인 폼 HTML에서 hidden 입력의 값을 정규식으로 꺼낸다. 실제 브라우저가 폼을 읽어 제출하는 흐름을 그대로 흉내 낸 것이다. 세션은 MemorySession이므로 $session 변수 하나가 브라우저 한 대의 쿠키 상자 역할을 한다. 토큰 없이 보낸 두 번째 로그인 요청과 주문 요청은 위조된 제출을 흉내 낸 것이다. ['csrf' => $token] + $order는 배열 합집합으로 토큰만 덧붙인 복사본을 만든다.

Http.php의 Request는 값을 바꿀 수 없는 객체다. input()은 없는 키나 배열로 들어온 값을 빈 문자열로 바꿔 타입 오류를 막는다. 여기서 공백을 자르지 않는 이유는 비밀번호의 앞뒤 공백도 비밀번호의 일부이기 때문이다. 이름과 메모는 컨트롤러에서 trim()한다. Response::redirect()는 303과 location을 담는다. Router::dispatch()는 "메서드 경로" 열쇠로 배열을 찾아 핸들러를 호출하고, 없으면 404를 돌려준다.

Session.php의 MemorySession은 regenerate() 호출 횟수를 세어 로그인 때 정말 호출되는지 확인할 수 있게 한다. NativeSession은 생성자에서 쿠키 옵션을 정한 뒤 session_start()를 부른다. 옵션은 세션을 시작하기 전에 정해야 적용된다. destroy()는 배열을 비우고 세션을 끝낸다.

Csrf.php의 token()은 세션에 토큰이 없을 때만 새로 만든다. 그래서 한 손님이 여러 폼을 열어도 같은 토큰이 나간다. verify()는 세션에 토큰이 있고 제출 값이 비어 있지 않을 때만 hash_equals로 비교한다. 두 값 중 하나라도 없으면 곧바로 거짓이다.

Store.php는 모든 SQL을 한곳에 모은다. 값은 전부 준비된 쿼리의 자리표시자로 넘긴다. addUser()는 저장 직전에 해시를 만들고, authenticate()는 이름으로 행을 찾은 뒤 password_verify로 확인한다. 실패 이유가 이름인지 비밀번호인지는 호출한 쪽에 알리지 않고 null 하나로 돌려준다. 그래서 로그인 화면의 오류 문구도 둘을 구별하지 않는다.

View.php의 capture()는 extract()로 배열 열쇠를 변수로 풀고, 출력 버퍼링(ob_start)으로 템플릿이 찍은 HTML을 문자열로 붙잡는다. render()는 먼저 본문 템플릿을 그리고 그 결과를 layout에 $content로 끼운다. 본문은 이미 이스케이프된 HTML이므로 레이아웃에서는 $content를 그대로 찍는다. 이 한 곳을 제외한 모든 값은 e()를 거친다.

App.php의 생성자는 라우트 다섯 개를 등록한다. $this->board(...)는 메서드를 함수 값으로 만드는 표기다. createOrder()는 로그인 확인, CSRF 확인, 입력 검증, 저장, 이동 순서로 진행하며 각 단계에서 실패하면 바로 돌려보낸다. 수량은 filter_var로 1에서 20 사이 정수만 받고, 빵 종류는 in_array의 엄격 비교로 목록 안의 값만 받는다. 검증에 실패하면 422와 함께 오류 문구가 담긴 게시판을 다시 그린다. login()은 성공했을 때만 세션 ID를 새로 발급하고 사용자 정보를 저장한다.

templates의 <?php foreach ... ?> 같은 대체 문법(endforeach, endif)은 HTML 사이에 섞여도 짝을 찾기 쉽다. 각 ?> 바로 뒤의 줄바꿈 하나는 PHP가 지운다. board.php는 $user가 null인지에 따라 로그인 링크나 주문 폼을 보여 준다. public/index.php는 파일 데이터베이스를 열고 사용자가 없을 때만 시험 계정을 만든 뒤, 실제 요청으로 앱을 한 번 실행한다.

실행 결과

프로젝트 폴더에서 다음 명령을 실행한다. 해시를 여러 번 계산하므로 끝나기까지 1~2초 걸릴 수 있다. 출력은 실행할 때마다 같다. 토큰과 해시는 매번 달라지지만 값을 출력하지 않기 때문이다.

$ php main.php
GET   /         200
GET   /nowhere  404
POST  /orders   303 -> /login
GET   /login    200
POST  /login    401
POST  /login    403
POST  /login    303 -> /
POST  /orders   403
POST  /orders   303 -> /
POST  /orders   303 -> /
POST  /orders   422
GET   /         200
  <li>#1 minsu - 식빵 2개 (토요일 오전 픽업)</li>
  <li>#2 minsu - 소보로빵 1개 (&lt;b&gt;강조&lt;/b&gt; &amp; 포장)</li>
POST  /logout   303 -> /
GET   /         200
세션 ID 재발급: 1회
해시 접두어 $2y$, 두 해시 다름, 검증 ok

결과에서 볼 곳은 세 군데다. 로그인 전 주문은 로그인 화면으로 이동했고(303 -> /login), 토큰이 없는 주문과 로그인은 403으로 거절됐다. 메모에 넣은 <b> 태그는 글자 그대로 화면에 나가도록 이스케이프됐다. 이스케이프된 결과가 출력 목록에서 &lt;로 보이는 것은 그 때문이다.

브라우저에서 보기

같은 코드를 브라우저로 보려면 프로젝트 폴더에서 내장 웹 서버를 띄운다. 라우터 스크립트로 public/index.php를 지정하면 모든 경로가 이 파일로 들어온다. 서버는 Ctrl+C로 끝낸다.

$ php -S 127.0.0.1:8000 public/index.php

브라우저에서 http://127.0.0.1:8000/를 열고 이름 minsu, 비밀번호 bread-1234로 로그인한다. 주문 데이터는 프로젝트 폴더의 orders.sqlite 파일에 남는다. 처음부터 다시 시작하려면 서버를 끄고 이 파일을 지우면 된다. 시험용 계정과 비밀번호는 연습을 위한 값이므로 실제 서비스에서는 쓰지 않는다.

실무에서 자주 틀리는 것

1. 비밀번호를 빠른 해시나 평문으로 저장한다

md5나 sha1은 초당 수십억 번 계산할 수 있어서, 유출된 값에서 흔한 비밀번호를 금방 찾아낸다. 솔트도 없어 같은 비밀번호는 같은 값이 된다.

// 틀린 코드
$hash = md5($password);
$ok = md5($input) === $hash;

// 고친 코드
$hash = password_hash($password, PASSWORD_DEFAULT);
$ok = password_verify($input, $hash);

2. 로그인해도 세션 ID를 그대로 둔다

로그인 전에 알려진 세션 ID가 로그인 뒤에도 유효하면, 그 번호를 아는 사람이 손님의 로그인 상태를 그대로 쓴다.

// 틀린 코드
$_SESSION['user_id'] = $user['id'];

// 고친 코드
session_regenerate_id(true);
$_SESSION['user_id'] = $user['id'];

3. 토큰을 ==로 비교한다

세션에 토큰이 아직 없고 폼에서도 토큰이 빠지면 양쪽이 모두 null이 되고, null == null은 참이다. 토큰 없는 요청이 검사를 통과한다.

// 틀린 코드
if (($_POST['csrf'] ?? null) == ($_SESSION['csrf_token'] ?? null)) {
    // 두 값이 모두 없어도 여기에 들어온다
}

// 고친 코드
$token = $_SESSION['csrf_token'] ?? null;
$given = $_POST['csrf'] ?? '';
if (is_string($token) && is_string($given) && $given !== '' && hash_equals($token, $given)) {
    // 두 값이 모두 있고 같을 때만
}

4. 템플릿에서 이스케이프를 빼먹는다

컨트롤러에서 한 번 걸러 두었다고 믿고 템플릿에서 그대로 찍으면, 저장된 메모의 태그가 다른 손님의 화면에서 실행된다. 출력하는 자리마다 이스케이프하는 것이 원칙이다.

<!-- 틀린 코드 -->
<li><?= $order['memo'] ?></li>

<!-- 고친 코드 -->
<li><?= e($order['memo']) ?></li>

한눈에 보기

이 장의 도구와 그것이 막는 문제
도구핵심 코드막는 문제
세션session_start(), $_SESSION요청 사이에 로그인 상태를 잃는 것
세션 ID 재발급session_regenerate_id(true)미리 알아 둔 세션 ID의 도용
CSRF 토큰random_bytes, hash_equals다른 사이트가 대신 보내는 제출
비밀번호 해시password_hash, password_verify유출 시 비밀번호 노출
라우터"메서드 경로" 열쇠의 배열주소별 분기가 한 파일에 뒤섞이는 것
템플릿과 e()ob_start, htmlspecialchars화면과 로직의 뒤엉킴, 스크립트 삽입
요청 결과와 응답 상태 코드
상황상태처리이 장의 위치
정상 조회200HTML 응답board()
저장 성공303목록으로 이동createOrder()
로그인 실패401폼과 오류 문구login()
토큰 불일치403처리 없이 거절forbidden()
입력 오류422오류 문구와 함께 다시 그림createOrder()
없는 주소404안내 문구Router

연습 문제

  1. 로그인에 성공했을 때 저장된 해시가 현재 설정보다 약하면 새 해시로 바꿔 저장하도록 Store::authenticate()를 고쳐 보자. 비용 옵션은 ['cost' => 13]으로 한다.
  2. 경로는 있지만 메서드가 다른 요청(예: GET /orders)에 404 대신 405를 돌려주도록 Router::dispatch()를 고쳐 보자.
  3. 본인이 넣은 주문만 지울 수 있는 POST /orders/delete를 추가하려 한다. 확인해야 할 조건과 순서를 문장으로 설계해 보자.
  4. 이 장은 로그아웃을 링크(GET)가 아니라 토큰이 든 폼(POST)으로 만들었다. 링크로 만들면 어떤 문제가 생기는지 설명해 보자.

정답과 해설

1. 검증에 성공한 직후에 password_needs_rehash로 확인하고, 필요하면 갱신한다. 원래 비밀번호는 이 순간에만 손에 있으므로 로그인 때가 해시를 바꿀 수 있는 유일한 기회다.

if ($row === false || !password_verify($password, $row['password_hash'])) {
    return null;
}
if (password_needs_rehash($row['password_hash'], PASSWORD_DEFAULT, ['cost' => 13])) {
    $update = $this->pdo->prepare('UPDATE users SET password_hash = :hash WHERE id = :id');
    $update->execute([
        'hash' => password_hash($password, PASSWORD_DEFAULT, ['cost' => 13]),
        'id' => $row['id'],
    ]);
}
return ['id' => (int) $row['id'], 'name' => $row['name']];

2. 열쇠를 못 찾았을 때 다른 메서드로 같은 경로가 등록돼 있는지 확인한다.

if ($handler === null) {
    foreach (['GET', 'POST'] as $method) {
        if (isset($this->routes[$method . ' ' . $request->path])) {
            return new Response(405, '허용되지 않는 메서드다');
        }
    }
    return new Response(404, '찾을 수 없는 주소다');
}

3. 순서는 다음과 같다. 먼저 로그인 여부를 확인하고 없으면 로그인 화면으로 보낸다. 다음으로 CSRF 토큰을 확인하고 틀리면 403을 돌려준다. 그다음 주문 번호를 정수로 검증한다. 마지막으로 DELETE FROM orders WHERE id = :id AND user_id = :user_id처럼 번호와 소유자를 함께 조건에 넣어 지운다. 소유자 조건을 SQL에 넣으면 남의 주문 번호를 보내도 지워지는 행이 없다. 성공하든 못 지우든 목록으로 303 이동한다.

4. GET 링크는 다른 사이트의 이미지 태그나 링크만으로도 호출된다. 브라우저가 쿠키를 붙여 보내므로 손님은 눌렀다는 사실도 모른 채 로그아웃된다. 상태를 바꾸는 요청을 POST로 받고 토큰을 확인하면 이런 호출이 통하지 않는다. 검색 엔진이나 미리보기 기능이 링크를 자동으로 따라가는 경우에도 같은 문제가 생긴다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.