対象読者: 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で大容量ファイルを安全にストリーミング配信する と組み合わせます。