公開日 2026-10-01

PHPで有効期限付き共有リンクを設計する(URLを知られただけではダウンロードさせない)

PHPで有効期限付きの共有リンクを設計する。random_bytes(32)のトークンは共有の識別までに限定し、ダウンロードの可否はOTP検証済みセッションへのグラントに束ねる。URLが漏れても本人確認なしでは取れない設計をテスト付きで示す。

目次

  1. 動作を確認した構成
  2. 脅威モデル — URLは「識別」、ダウンロードは「認可」
  3. トークンの発行と共有の特定
  4. コードのポイント
  5. リポジトリ
  6. ダウンロードの可否をグラントに束ねる
  7. コードのポイント
  8. テストで振る舞いを固定する
  9. コードのポイント
  10. まとめ

対象読者: PHP で「URLを知っている人だけがファイルを受け取れる」共有リンクを組む方。フレームワークの認可機能に頼らず、素の PHP と PostgreSQL でトークンとダウンロード可否の設計を自分で決める場面を想定します。

機密ファイルの共有リンクを random_bytes() の長いトークンで作れば、URL は推測できません。ただし推測できないことと、URL が漏れても安全なことは別です。トークンをそのままダウンロードの合言葉にすると、リンクが転送メールやアクセスログから漏れた瞬間に、第三者がその URL を開くだけでファイルを取れてしまう。この記事では、トークンの役割を「どの共有か」を指すところまでに限定し、ダウンロードの可否は別途「本人確認を通したセッションへのグラント」に束ねる設計を、コードで示します。

本人確認そのもの(メールに送るワンタイムキーの自作)は PHPでワンタイム認証キー(OTP)を自作する(HMAC + pepper + 原子的UPDATE) を前提にする。ファイルの返し方(大きなファイルの逐次配信)は PHPで大容量ファイルを安全にストリーミング配信する、保存場所(docroot 外へ置いて直リンクを塞ぐ)は PHPでアップロードファイルをdocroot外に保存し直リンクを塞ぐ(fail-closed設計) へ委譲し、ここでは扱いません。ダウンロード後に即失効させるライフサイクル(使い切り・自動削除)も範囲外です。

動作を確認した構成

コードを動かして確認したのは、次の構成です。

  • PHP 8.4(pdo_pgsql 拡張、テストは PHPUnit 12)
  • PostgreSQL 17
  • Composer プロジェクト(App\ → src/ の PSR-4 オートロード)。本番コードはフレームワーク非依存

共有リンクの保存先として、次のテーブルを前提にします。

CREATE TABLE shares (
    id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    file_id bigint NOT NULL,
    token_hash text NOT NULL,
    recipient_email text NOT NULL,
    expires_at timestamptz NOT NULL,
    created_at timestamptz NOT NULL DEFAULT now()
);

CREATE UNIQUE INDEX shares_token_hash_idx ON shares (token_hash);

平文のトークンは列に持ちません。保存するのは token_hash(SHA-256 のハッシュ)と有効期限、そして recipient_email です。recipient_email は送信者が指定した宛先で、後述のとおり本人確認のワンタイムキーはこのアドレスにだけ送ります。file_id は配信するファイルの識別子で、実体の保存やアクセス制御は別レイヤーの役割になります。

脅威モデル — URLは「識別」、ダウンロードは「認可」

設計の芯は、トークンに1つの仕事しかさせないことです。トークンが答えるのは「どの共有か」まで。「この要求者にダウンロードを許すか」には答えさせません。ダウンロードの可否は、本人確認(OTP)を通したセッションに対して発行するグラントが握ります。

本人確認を成り立たせる要が、宛先の固定です。ワンタイムキーは、要求者が画面で入力したアドレスではなく、送信者が共有発行時に登録した shares.recipient_email へだけ送る。入力欄を設けるなら、打たれた値は登録宛先との照合にのみ使い、一致しなくても「送信しました」と同じ画面を返します(アドレスの当たり外れを教えない)。こうすると、ダウンロードにたどり着ける条件は「URL を持っていること」ではなく「登録宛先の受信箱を開けること」に変わります。ここを外すと、URL を拾った第三者が自分のアドレスを入力して自分宛に OTP を受け取れてしまい、「URL は識別・ダウンロードは認可」の切り分けが破れる。

sequenceDiagram
    actor S as 送信者
    actor R as 受信者
    actor A as 第三者(URL入手)
    participant App as アプリ
    participant DB as PostgreSQL
    S->>App: ファイル・宛先・期限を登録
    App->>DB: token_hash / recipient_email / 期限を保存
    App-->>S: 共有URL(token)を発行
    S-->>R: URLをメールなどで渡す
    R->>App: 共有URLを開く(token)
    App->>DB: token_hash で照合し期限を確認
    App-->>R: 共有を特定(この時点ではDL不可)
    Note over R,App: 入力を登録宛先と照合 → 登録宛先へOTP送信 → 検証(前提記事)
    R->>App: OTP検証に成功
    App->>App: session_regenerate_id → この共有のDLグラントを発行
    R->>App: ダウンロード要求(token)
    App->>DB: token_hash で再特定し期限を再確認
    App->>App: その共有のグラントを確認
    App-->>R: 配信(認可済みfileIdだけを逐次配信へ)
    A->>App: 漏れたURLを開く(token)
    App-->>A: 共有を特定(DL不可のまま)
    A->>App: ダウンロード要求(token)
    App--xA: グラント無し → 拒否

この分離が効くのは、URL が漏れた場合です。トークンだけを手に入れた第三者は、共有URLを開いて「共有が存在する」ところまでは到達できます。しかしダウンロードにはグラントが要り、グラントは登録宛先へ送った OTP を通さないと発行されません。第三者が自分のアドレスを入力しても、OTP は登録宛先へ飛ぶだけで攻撃者の受信箱には届かない。攻撃者のセッションにはグラントが立たないので、同じ URL を開いてもダウンロードは拒否されます。「URL 漏洩=即ダウンロード」という等式を、宛先の固定とグラントで断ち切ります。

トークンの発行と共有の特定

まず、失敗を表す例外です。src/Share/ShareException.php に置きます。

<?php
declare(strict_types=1);

namespace App\Share;

use RuntimeException;

final class ShareException extends RuntimeException
{
}

発行結果と、特定した共有を表す小さな値オブジェクトを2つ用意します。src/Share/IssuedShareLink.php と src/Share/Share.php です。

<?php
declare(strict_types=1);

namespace App\Share;

final readonly class IssuedShareLink
{
    public function __construct(
        public int $shareId,
        public string $token,
    ) {
    }
}
<?php
declare(strict_types=1);

namespace App\Share;

final readonly class Share
{
    public function __construct(
        public int $id,
        public int $fileId,
        public string $recipientEmail,
    ) {
    }
}

本体の ShareLinkService を src/Share/ShareLinkService.php に置きます。トークンを発行する issue() と、トークンから共有を特定する resolve() の2つを持ちます。永続化はリポジトリへ委ねる。

<?php
declare(strict_types=1);

namespace App\Share;

use SensitiveParameter;

final class ShareLinkService
{
    public function __construct(
        private readonly ShareRepository $repository,
        private readonly int $ttlMinutes = 60,
    ) {
        if ($this->ttlMinutes < 1) {
            throw new ShareException('Share link TTL must be at least 1 minute.');
        }
    }

    public function issue(int $fileId, string $recipientEmail): IssuedShareLink
    {
        $token = bin2hex(random_bytes(32));
        $shareId = $this->repository->insert($this->hash($token), $fileId, $recipientEmail, $this->ttlMinutes);

        return new IssuedShareLink(shareId: $shareId, token: $token);
    }

    public function resolve(#[SensitiveParameter] string $token): Share
    {
        if (preg_match('/\A[0-9a-f]{64}\z/', $token) !== 1) {
            throw new ShareException('Malformed share token.');
        }

        $share = $this->repository->findValidByTokenHash($this->hash($token));
        if ($share === null) {
            throw new ShareException('Share link is invalid or has expired.');
        }

        return $share;
    }

    private function hash(#[SensitiveParameter] string $token): string
    {
        return hash('sha256', $token);
    }
}

コードのポイント

① トークンは random_bytes(32) で作り、URL には平文・DB にはハッシュ

        $token = bin2hex(random_bytes(32));
        $shareId = $this->repository->insert($this->hash($token), $fileId, $recipientEmail, $this->ttlMinutes);

random_bytes(32) は暗号論的に安全な32バイト=256ビットの乱数で、bin2hex() で64文字の16進文字列にします。総当たりや連番からの推測が成り立たない空間の広さが、URL を知らない第三者を締め出す土台です。URL に載せるのはこの平文トークンですが、DB へ渡すのは hash() を通した SHA-256 だけ。DB のスナップショットが漏れても、そこにあるのはハッシュで、まだ失効していないトークンをそのまま使われることはありません。

OTP 記事では6桁コードのハッシュに HMAC + pepper を使いました。ここで pepper を足していないのは、守る対象の広さが違うからです。6桁は100万通りで、DB が漏れれば総当たりで平文へ戻せるため、pepper でその総当たりを止める必要がありました。256ビットのトークンは総当たりが成り立たないので、素の SHA-256 でも DB 漏洩から平文へは戻せない。OTP で pepper が担っていた「DB 漏洩から平文を守る」役目を、ここでは256ビットのエントロピーが代わりに果たします。

比較を定数時間にする hash_equals() も使っていません。タイミングで漏れうるのは保存済みの token_hash であって URL に載る平文ではなく、SHA-256 の原像計算が現実的でない以上、ハッシュのバイトを削り取れても平文トークンには戻せない。しかも照合は PHP のコードではなくインデックス済みの token_hash 列への等価検索の中で起きるので、hash_equals() を差し込む場所自体がありません。なお、平文トークンを受け取る resolve() / open() / authorizeDownload() には #[SensitiveParameter] を付けています。例外時にスタックトレースの引数へ平文トークンが載ってログへ流れ込むのを防ぐためで、OTP 記事の入力コードと同じ扱いです。

② resolve() は「識別」だけを担い、認可はしない

        if (preg_match('/\A[0-9a-f]{64}\z/', $token) !== 1) {
            throw new ShareException('Malformed share token.');
        }

        $share = $this->repository->findValidByTokenHash($this->hash($token));

resolve() が返すのは Share(どの共有・どのファイルか)で、ダウンロードの許可ではありません。先頭の正規表現は「16進64文字ちょうど」以外を早く弾く入口の門で、URL ルート側でも同じ形に絞れます。有効期限の判定は次に見るリポジトリの SQL に置き、サーバーの時計(now())で判断する。期限切れやハッシュ不一致では Share を返さず、resolve() は例外にする。ここを通っても、まだ何もダウンロードできません。なお例外のメッセージは利用者へそのまま出さず、画面には共通の「このリンクは利用できません」だけを返し、不正形式・期限切れ・グラント無しの区別はログにだけ残す。

リポジトリ

インターフェースを先に定義します。src/Share/ShareRepository.php です。サービスはこの2メソッドしか知りません。

<?php
declare(strict_types=1);

namespace App\Share;

interface ShareRepository
{
    public function insert(string $tokenHash, int $fileId, string $recipientEmail, int $ttlMinutes): int;

    public function findValidByTokenHash(string $tokenHash): ?Share;
}

PostgreSQL 実装を src/Share/DbShareRepository.php に置きます。有効期限の判定を findValidByTokenHash() の SQL に閉じ込めた形です。

<?php
declare(strict_types=1);

namespace App\Share;

use PDO;

final class DbShareRepository implements ShareRepository
{
    public function __construct(
        private readonly PDO $pdo,
    ) {
    }

    public function insert(string $tokenHash, int $fileId, string $recipientEmail, int $ttlMinutes): int
    {
        $statement = $this->pdo->prepare(<<<'SQL'
            INSERT INTO shares (file_id, token_hash, recipient_email, expires_at)
            VALUES (:file_id, :token_hash, :recipient_email, now() + make_interval(mins => :ttl_minutes))
            RETURNING id
            SQL);
        $statement->bindValue(':file_id', $fileId, PDO::PARAM_INT);
        $statement->bindValue(':token_hash', $tokenHash);
        $statement->bindValue(':recipient_email', $recipientEmail);
        $statement->bindValue(':ttl_minutes', $ttlMinutes, PDO::PARAM_INT);
        $statement->execute();

        return (int) $statement->fetchColumn();
    }

    public function findValidByTokenHash(string $tokenHash): ?Share
    {
        $statement = $this->pdo->prepare(<<<'SQL'
            SELECT id, file_id, recipient_email
              FROM shares
             WHERE token_hash = :token_hash
               AND expires_at > now()
            SQL);
        $statement->bindValue(':token_hash', $tokenHash);
        $statement->execute();

        $row = $statement->fetch(PDO::FETCH_ASSOC);
        if ($row === false) {
            return null;
        }

        return new Share(
            id: (int) $row['id'],
            fileId: (int) $row['file_id'],
            recipientEmail: (string) $row['recipient_email'],
        );
    }
}

findValidByTokenHash() は WHERE token_hash = :token_hash AND expires_at > now() の1文で、ハッシュ一致と未失効を同時に判定します。期限切れの行はここで結果から外れ、null になる。有効期限を PHP 側で読み比べず、サーバーの時計を持つ DB に判断させているのが要点です。token_hash の一意インデックスは、照合をインデックス検索で済ませるためのもの。256ビットの偶発衝突は現実には起こらないので、これは衝突を防ぐためというより、二重発行という異常を DB 側で検知する保険です。

ダウンロードの可否をグラントに束ねる

トークンの特定とダウンロードの認可を、別のオブジェクトに分ける。まず、グラントの置き場所を表すインターフェースを src/Share/DownloadGrantStore.php に置きます。

<?php
declare(strict_types=1);

namespace App\Share;

interface DownloadGrantStore
{
    public function grant(int $shareId): void;

    public function isGranted(int $shareId): bool;
}

本番の実装は、グラントをサーバー側のセッションに持ちます。src/Share/SessionDownloadGrantStore.php です。

<?php
declare(strict_types=1);

namespace App\Share;

final class SessionDownloadGrantStore implements DownloadGrantStore
{
    private const SESSION_KEY = 'download_grants';

    /** 呼び出し前に session_start() 済みであることを前提にする。 */
    public function grant(int $shareId): void
    {
        // グラント発行は権限昇格。固定・盗用されたセッションIDを引き継がせない。
        session_regenerate_id(true);

        $_SESSION[self::SESSION_KEY][$shareId] = true;
    }

    public function isGranted(int $shareId): bool
    {
        return isset($_SESSION[self::SESSION_KEY][$shareId]);
    }
}

グラントをセッションに載せる以上、セッションの強度がそのままダウンロード認可の強度になります。前提は2つ。ひとつは、このメソッドを呼ぶ前に session_start() を済ませ、Cookie を HttpOnly / Secure / SameSite=Lax、session.use_strict_mode=1 で発行しておくこと。もうひとつは、grant() の中で session_regenerate_id(true) を呼ぶこと。OTP を通した瞬間は匿名から認可済みへの権限昇格なので、ここでセッション ID を振り直します。これを省くと、攻撃者があらかじめ用意したセッション ID を受信者に握らせておき(セッション固定)、受信者が正規に OTP を通した瞬間に、そのセッションへ載ったグラントを攻撃者が使う、という経路が開く。

特定(ShareLinkService)とグラント(DownloadGrantStore)を組み合わせ、共有を開く・認可する・ダウンロードを認めるの3手順を1か所にまとめます。src/Share/ShareAccess.php です。

<?php
declare(strict_types=1);

namespace App\Share;

use SensitiveParameter;

final class ShareAccess
{
    public function __construct(
        private readonly ShareLinkService $links,
        private readonly DownloadGrantStore $grants,
    ) {
    }

    public function open(#[SensitiveParameter] string $token): Share
    {
        return $this->links->resolve($token);
    }

    public function grantAfterVerification(Share $share, bool $verified): void
    {
        if (!$verified) {
            throw new ShareException('Cannot grant a download without a successful verification.');
        }

        $this->grants->grant($share->id);
    }

    public function authorizeDownload(#[SensitiveParameter] string $token): Share
    {
        $share = $this->links->resolve($token);

        if (!$this->grants->isGranted($share->id)) {
            throw new ShareException('Download is not granted for this session.');
        }

        return $share;
    }
}

コードのポイント

① グラントは OTP 検証の成功を引数で要求する

    public function grantAfterVerification(Share $share, bool $verified): void
    {
        if (!$verified) {
            throw new ShareException('Cannot grant a download without a successful verification.');
        }

grantAfterVerification() は OTP 検証の結果を bool $verified で受け取り、false なら例外にしてグラントを立てません。「OTP が通った直後にだけ呼ぶ」という約束をコメントで書くだけでは、open() の戻り値をそのまま渡す誤用を止められない。引数で成功を要求すれば、検証を通さずにグラントへ到達する道が塞がります。呼び出し箇所は OTP 検証の成功分岐の中だけに絞り、そこで得た Share(=この共有)を渡す。グラントは共有の id 単位で、session_regenerate_id() 後のセッションに紐づきます。URL のトークンではなく、本人確認を通したこのセッションだけが持つ状態です。

この引数が保証するのは「検証を通さずには呼べない」ところまでで、宛先の固定そのものはこのクラスの外にあります。受信者束縛が成立するのは、bool $verified の出所を「$share->recipientEmail へ送った OTP を、その受信箱を開けた本人が入力して検証した結果」に限る配線を、呼び出し側が守ったときだけです。要求者が画面に打ったアドレス宛へ OTP を送って検証する組み方だと、$verified が true でも URL を拾った第三者が自分で通せてしまう。OTP の送り先を登録宛先へ固定する部分(前掲の recipient_email)と、検証成功を grantAfterVerification() へ渡す配線は、このクラスではなく呼び出し側で組む前提です。OTP 自体の作り方は ワンタイムキーの自作(HMAC + pepper + 原子的 UPDATE)を土台にします。

② ダウンロードはトークンではなくグラントで判断する

        $share = $this->links->resolve($token);

        if (!$this->grants->isGranted($share->id)) {
            throw new ShareException('Download is not granted for this session.');
        }

authorizeDownload() は、トークンを resolve() で特定し直したうえで、その共有にグラントがあるかを確認します。トークンが有効でも、グラントが無ければ例外にして配信へ進ませない。これが「URL を知っているだけではダウンロードさせない」の実装です。resolve() を毎回通すので、有効期限もダウンロードのたびに再確認され、グラント済みのセッションでもリンクが失効すればそこで止まる。グラントは共有 id に紐づくため、共有Aで得たグラントは共有Bのトークンを認可しません。

配信へつなぐときは、ファイルの識別子を authorizeDownload() の戻り値 Share::$fileId からだけ取ります。リクエストの ?file_id=... や保存 ID を配信層へ直接渡すと、共有 X で正規に得たグラント1つで file_id を差し替え、別のファイルを取れてしまう(横取り=IDOR)。最小の配信ルートはこの形になります。

$share = $access->authorizeDownload($_GET['token'] ?? '');
// 配信に使う識別子は $share->fileId だけ。リクエストの file_id やパスは混ぜない
$path = $files->pathFor($share->fileId);   // サーバー側リポジトリで解決する
streamDownload($path);                       // 逐次配信は委譲先の記事へ

保存場所を docroot の外へ置くこと、実体を逐次配信することは、それぞれ委譲先の記事の担当です。本記事の担当は、配信判断に渡す fileId を必ず認可の戻り値から取り、クライアント入力から取らない一点に絞られる。

テストで振る舞いを固定する

DB につなぐテストで、特定とグラントの性質を固定します。tests/ShareAccessTest.php です。グラントの置き場所はテスト用に配列で差し替え、トークン側は実際の DbShareRepository を使います。

<?php
declare(strict_types=1);

namespace Tests;

use App\Share\DbShareRepository;
use App\Share\DownloadGrantStore;
use App\Share\ShareAccess;
use App\Share\ShareException;
use App\Share\ShareLinkService;
use PDO;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

/** テスト用のグラント置き場(本番は SessionDownloadGrantStore が $_SESSION を使う)。 */
final class ArrayGrantStore implements DownloadGrantStore
{
    /** @var array<int, true> */
    private array $granted = [];

    public function grant(int $shareId): void
    {
        $this->granted[$shareId] = true;
    }

    public function isGranted(int $shareId): bool
    {
        return isset($this->granted[$shareId]);
    }
}

final class ShareAccessTest extends TestCase
{
    private PDO $pdo;

    private ShareLinkService $links;

    private ArrayGrantStore $grants;

    private ShareAccess $access;

    protected function setUp(): void
    {
        $this->pdo = new PDO(
            (string) getenv('DB_DSN'),
            (string) getenv('DB_USER'),
            (string) getenv('DB_PASSWORD'),
            [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION],
        );
        $this->pdo->exec('TRUNCATE shares RESTART IDENTITY');

        $this->links = new ShareLinkService(new DbShareRepository($this->pdo));
        $this->grants = new ArrayGrantStore();
        $this->access = new ShareAccess($this->links, $this->grants);
    }

    public function testStoredValueIsHashNotRawToken(): void
    {
        $issued = $this->links->issue(fileId: 1, recipientEmail: 'alice@example.com');

        $tokenHash = $this->fetchTokenHash($issued->shareId);

        self::assertNotSame($issued->token, $tokenHash);
        self::assertSame(hash('sha256', $issued->token), $tokenHash);
        self::assertMatchesRegularExpression('/\A[0-9a-f]{64}\z/', $issued->token);
    }

    public function testResolveReturnsRegisteredRecipient(): void
    {
        $issued = $this->links->issue(fileId: 1, recipientEmail: 'alice@example.com');

        $share = $this->access->open($issued->token);

        // OTP の送付先は共有の登録宛先に固定する(要求者の入力ではない)。
        self::assertSame('alice@example.com', $share->recipientEmail);
    }

    public function testExpiredTokenIsRejected(): void
    {
        $issued = $this->links->issue(fileId: 1, recipientEmail: 'alice@example.com');
        $this->expire($issued->shareId);

        $this->expectException(ShareException::class);
        $this->access->open($issued->token);
    }

    public function testUnknownTokenIsRejected(): void
    {
        // 発行していない正しい形式のトークンは特定できない。
        $this->expectException(ShareException::class);
        $this->access->open(bin2hex(random_bytes(32)));
    }

    public function testDownloadRefusedWithoutGrant(): void
    {
        $issued = $this->links->issue(fileId: 1, recipientEmail: 'alice@example.com');

        // トークンは有効(open は通る)が、グラントが無いのでダウンロードは拒否。
        self::assertSame($issued->shareId, $this->access->open($issued->token)->id);

        $this->expectException(ShareException::class);
        $this->access->authorizeDownload($issued->token);
    }

    public function testDownloadAllowedAfterGrant(): void
    {
        $issued = $this->links->issue(fileId: 7, recipientEmail: 'alice@example.com');
        $share = $this->access->open($issued->token);

        $this->access->grantAfterVerification($share, verified: true); // OTP 検証成功を渡す

        $authorized = $this->access->authorizeDownload($issued->token);
        self::assertSame(7, $authorized->fileId);
    }

    public function testGrantRequiresSuccessfulVerification(): void
    {
        $issued = $this->links->issue(fileId: 1, recipientEmail: 'alice@example.com');
        $share = $this->access->open($issued->token);

        // OTP 検証が false なら grant は発行されず例外。
        $this->expectException(ShareException::class);
        $this->access->grantAfterVerification($share, verified: false);
    }

    public function testGrantIsScopedToShare(): void
    {
        $a = $this->links->issue(fileId: 1, recipientEmail: 'alice@example.com');
        $b = $this->links->issue(fileId: 2, recipientEmail: 'bob@example.com');

        $this->access->grantAfterVerification($this->access->open($a->token), verified: true);

        // A のグラントで B はダウンロードできない。
        $this->expectException(ShareException::class);
        $this->access->authorizeDownload($b->token);
    }

    public function testExpiredTokenRejectedEvenAfterGrant(): void
    {
        $issued = $this->links->issue(fileId: 1, recipientEmail: 'alice@example.com');
        $this->access->grantAfterVerification($this->access->open($issued->token), verified: true);

        $this->expire($issued->shareId);

        // グラント済みでも、期限切れは毎回の resolve で弾かれる。
        $this->expectException(ShareException::class);
        $this->access->authorizeDownload($issued->token);
    }

    public function testTtlMustBePositive(): void
    {
        // 0 や負値は「発行直後に失効するリンク」を黙って作るので、発行段で止める。
        $this->expectException(ShareException::class);
        new ShareLinkService(new DbShareRepository($this->pdo), ttlMinutes: 0);
    }

    /**
     * @return array<string, array{string}>
     */
    public static function malformedTokens(): array
    {
        return [
            'traversal' => ['../../etc/passwd'],
            'too-short' => ['abcdef'],
            'too-long'  => [str_repeat('a', 65)],
            'uppercase' => [str_repeat('A', 64)],
            'non-hex'   => [str_repeat('g', 64)],
            'empty'     => [''],
        ];
    }

    #[DataProvider('malformedTokens')]
    public function testMalformedTokenIsRejected(string $token): void
    {
        $this->expectException(ShareException::class);
        $this->access->open($token);
    }

    private function fetchTokenHash(int $shareId): mixed
    {
        $statement = $this->pdo->prepare('SELECT token_hash FROM shares WHERE id = :id');
        $statement->execute(['id' => $shareId]);

        return $statement->fetchColumn();
    }

    private function expire(int $shareId): void
    {
        $statement = $this->pdo->prepare(
            "UPDATE shares SET expires_at = now() - interval '1 second' WHERE id = :id"
        );
        $statement->execute(['id' => $shareId]);
    }
}

コードのポイント

① 「トークンは有効でもグラント無しは拒否」を実際に投げて固定する

        // トークンは有効(open は通る)が、グラントが無いのでダウンロードは拒否。
        self::assertSame($issued->shareId, $this->access->open($issued->token)->id);

        $this->expectException(ShareException::class);
        $this->access->authorizeDownload($issued->token);

open() が通る(トークンは有効)ことを確かめたうえで、同じトークンでの authorizeDownload() が例外になることを押さえます。「トークンが認可を兼ねる」実装へ戻す変更を、このテストが止める。testGrantRequiresSuccessfulVerification は verified: false でグラントが立たないこと、testGrantIsScopedToShare は共有Aのグラントで共有Bを取れないこと、testExpiredTokenRejectedEvenAfterGrant はグラント済みでも失効後は拒否されることを確認しています。testStoredValueIsHashNotRawToken は DB に平文でなく SHA-256 が入ること、testResolveReturnsRegisteredRecipient は登録宛先が特定結果に載ることを固定するものです。

グラントの置き場所は ArrayGrantStore に差し替えているので、この一群は本番の SessionDownloadGrantStore を通りません。セッションを跨いでグラントが引き継がれないこと(別セッションでは false)と、session_regenerate_id() で ID が振り直ること自体は、session_start() を伴う別プロセスのテストで別に押さえる領域です。ここで固定しているのは、DbShareRepository と ShareAccess の組み合わせの挙動になります。

実行結果は次のとおりです。

PHPUnit 12.5.31 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.4.23
Configuration: /app/phpunit.xml

................                                                  16 / 16 (100%)

Time: 00:00.23, Memory: 27.98 MB

OK (16 tests, 19 assertions)

発行後の行を覗くと、列に入っているのは64桁のハッシュと登録宛先だけで、平文トークンは DB の列には残らない。

 id | file_id |  recipient_email  | token_hash_head | hash_len
----+---------+-------------------+-----------------+----------
  1 |       1 | alice@example.com | 13ac7ecd0dd6... |       64

まとめ

有効期限付き共有リンクを「URL を知られただけでは破られない」ように設計する判断を、コードで示しました。

  • トークンは random_bytes(32) の256ビット。URL には平文、DB には SHA-256 だけを置く。漏れうるのはハッシュだけなので pepper や定数時間比較は要らない
  • トークンの役割は「どの共有か」の特定までに限定し、有効期限はサーバーの時計で毎回確認する
  • ダウンロードの可否は、トークンではなく OTP 検証済みセッションへのグラントに束ねる。OTP は要求者の入力ではなく登録宛先へ送るので、URL を拾っただけの第三者は本人確認を通れず、ダウンロードは許可されない
  • グラントはセッションに載る。session_start() の安全な設定と、権限昇格時の session_regenerate_id() が前提。配信へ渡す fileId は認可の戻り値からだけ取る

一度グラントを得たセッションは、有効期限が切れるまで同じ共有を何度でもダウンロードできます。回数を1回に絞る使い切りと自動削除は、この設計の次の層です。本人確認の中身は PHPでワンタイム認証キー(OTP)を自作する(HMAC + pepper + 原子的UPDATE)、実体の返し方は PHPで大容量ファイルを安全にストリーミング配信する と組み合わせます。