PR TIMESのログインに二要素認証を導入した話

  • URLをコピーしました!

こんにちは!PR TIMES 開発本部のエンジニア、トゥ(codyzard)です。

今回のブログでは、パスワードと Email ワンタイムパスコードを組み合わせた二要素認証(Multi-Factor Authentication、以下「MFA」)の導入についてご紹介します。

セキュリティ強化の取り組みの一環として、どのように設計・実装したのか、技術的な詳細を交えながら解説していきます。

目次

背景

2025年4月、PR TIMESのサーバーが不正アクセスを受け、情報漏えいの可能性があることが判明しました。なお本件不正アクセスの発覚以降、今日に至るまでも、お客様情報の不正利用などの事実は確認されておりません。

PR TIMES、不正アクセスによる情報漏えいの可能性に関するお詫びとご報告

PR TIMES 不正アクセスの再発防止策の追加と実施予定について

この事案を受け、PR TIMES では従来のパスワード認証を見直し、Email を用いたワンタイムパスコード(OTP)による二要素認証を導入しました。

導入は一度にまとめてではなく、企業ユーザー向けを 2025 年 8 月、メディアユーザー向けを同年 9 月、個人ユーザー向けを 10 月と、ユーザー種別ごとに段階的に展開しています。

開発プロセス

二要素認証は任意

今回導入した二要素認証は、すべてのユーザーに対して必須としたわけではなく、任意で設定できるようにしました。

いきなり必須化してしまうと、ログイン操作が煩雑になるなど、利用者の負担が大きくなってしまいます。そのため、まずは「使いたい企業ユーザー・メディアユーザー・個人ユーザーが自ら有効化できるオプション」として提供し、利便性とのバランスを取りながら段階的に広げていく方針にしました。

認証方式の選定

MFAの方式としては、Email を用いたワンタイムパスコード(Email OTP)を採用しました。

社内ではすでに SendGrid を用いたメール送信基盤を運用しており、この仕組みをそのまま流用することで、新たなインフラ構築コストをかけずに、スムーズかつ低コストで導入できると判断したためです。

具体的な SendGrid 連携については、こちらのブログでも紹介しています。

あわせて読みたい
PR TIMESにおけるメールをSendGridで送信するように実装しました こんにちは、PR TIMESのバックエンドエンジニアのSongです。今回はPR TIMESにおけるメールの一部をSendGridで送信するようにしたことについて紹介します。 【背景】 PR ...

システムアーキテクチャ

MFA 導入後のログインフロー、従来のパスワード認証の後ろに OTP 認証を挟む形で構成しています。

まず、ユーザーが ID とパスワードを入力すると、バックエンド側で認証情報の検証を行います。認証に成功したタイミングで、そのユーザーが属する企業に MFA が設定されているかどうかを DB から取得し、以降の挙動を分岐させます。

ここで、ログインが完了した通常状態のセッションを 「Full Session」、OTP 入力待ちの一時的な状態を表すセッションを 「Temporary Session」 と呼ぶことにします。Full Session が発行されている状態では、従来どおりダッシュボードや各機能にアクセスできますが、Temporary Session の状態では OTP 入力ページのみを表示し、それ以外の画面には遷移できない想定です。

MFA が未設定の企業に属するユーザーの場合は、これまで通りすぐに Full Session を生成し、そのままダッシュボードへ遷移します。一方で、MFA が有効化されている企業のユーザーであれば、6 桁の OTP コードを生成し(有効期限は 1 時間)、OTP 入力待機用の Temporary Session を発行したうえで、SendGrid 経由で OTP をメール送信します。

有効期限については、本来であれば 10 分程度でも十分ですが、 状況によってはメール配信に遅延が生じる可能性もあるため、少し余裕をもって 1 時間に設定しています。その代わりに、後述する「セッション管理と Rate Limiting」のとおり OTP の再送間隔や認証の試行回数には制限を設け、長めの有効期限でも安全性を担保できるようにしています。

その後、フロントエンド側では OTP の入力画面を表示し、ユーザーにワンタイムコードの入力を促します。ユーザーが入力した OTP はバックエンド側で検証され、正しい場合には Temporary Session を Full Session に昇格させてダッシュボードへリダイレクトします。誤っている場合はエラーメッセージを表示し、必要に応じて OTP の再送を行えるようにしています。

技術的な実装詳細

データベース設計

今回の実装では、ユーザータイプごとに独立したテーブル構造を採用しています。

ここでは例として、企業ユーザー(company_user)の場合のテーブル設計を紹介します。

まず、どの認証方式を提供するかを管理するために、mfa_method テーブルを用意しています。

今回の実装では Email OTP のみを利用していますが、将来的に SMS や TOTP など他の方式も採用できるよう、あらかじめ認証方式の種類をテーブルで定義する設計としました。

CREATE TABLE mfa_method (
     id SERIAL PRIMARY KEY,
     method_name VARCHAR(20) NOT NULL UNIQUE, -- email, sms, app
     created_at      TIMESTAMP   NOT NULL   DEFAULT CURRENT_TIMESTAMP
);

次に、企業ごとに MFA を有効化するかどうかを管理するのが company_mfa_setting テーブルです。
このテーブルに該当の company_idmfa_method_id のレコードが存在する場合、その企業では MFA(今回の追加認証ステップ)が有効とみなし、ログイン時に OTP の入力を必須にします。逆にレコードが存在しない企業については、MFA は無効、つまり従来通りパスワードのみでログインできる状態として扱います。企業が MFA を有効化すると、その企業に属するすべての企業ユーザーに対して一括で MFA が適用される設計です。

CREATE TABLE company_mfa_setting
(
    company_id INT PRIMARY KEY,
    mfa_method_id      INT NOT NULL,
    created_at      TIMESTAMP   NOT NULL   DEFAULT CURRENT_TIMESTAMP
);

最後に、Email OTP の発行と検証に関する情報を保存するのがcompany_user_mail_otp_authentication テーブルです。ここでは、どの企業ユーザーに対して、どの OTP を、いつまで有効なものとして発行したか、そしてそれが使用済みかどうかを管理しています。

各 OTP には有効期限を表す expired_at を持たせており、現在時刻と比較することで期限切れかどうかを判定します。また、このテーブル自体を expired_at でパーティション分割することで、期限切れレコードの整理や長期運用時のパフォーマンスを意識した構成としています。

CREATE TABLE company_user_mail_otp_authentication
(
    company_user_id INT         NOT NULL,
    otp_code        VARCHAR(10) NOT NULL,
    expired_at      TIMESTAMP   NOT NULL,
    used_at         TIMESTAMP,
    created_at      TIMESTAMP   NOT NULL DEFAULT CURRENT_TIMESTAMP,

    PRIMARY KEY (company_user_id, expired_at)
) PARTITION BY RANGE (expired_at);

CREATE TABLE company_user_mail_otp_authentication_default PARTITION OF company_user_mail_otp_authentication DEFAULT;

二要素認証の設定管理

MFA の有効・無効を切り替える処理は、アプリケーション側からはリポジトリ経由で行います。企業ごとに 1 レコードを持つ設計なので、いわゆる UPSERT で「既に設定があれば更新、なければ新規作成」という形をとっています。

// MFA有効化
public function enableMfa($companyId, $methodId) {
    // UPSERT処理: 既存設定があれば更新、なければ新規作成
    return CompanyMfaSettingRepo::upsert($companyId, $methodId);
}

// MFA無効化
public function disableMfa($companyId) {
    return CompanyMfaSettingRepo::delete($companyId);
}

企業アカウントにはメインユーザーとサブユーザーが存在しますが、MFA 設定の変更はメインユーザーのみが行えるようにしています。

OTP 生成処理

OTP の生成には、暗号学的に安全な乱数生成器を利用し、6 桁の数値コードを生成しています。桁数をパラメータとして受け取る汎用的な関数として実装することで、将来的に桁数を変更したい場合にも柔軟に対応できるようにしています。

public function generateNumeric(int $length = 6): string
{
    if ($length <= 0) {
        throw new \InvalidArgumentException('Length must be greater than 0');
    }

    $otp = '';
    for ($i = 0; $i < $length; $i++) {
        $otp .= (string)Random::int(0, 9);  // 暗号学的に安全な乱数
    }

    return $otp;
}

セッション管理とRate Limiting

OTP 認証のフローを安全に扱うため、セッション管理とアクセス制御も工夫しています。

まず、OTP ページへのアクセスは、必ずメールアドレスとパスワードによる一次認証を通過したユーザーに限定しています。正しい認証情報が入力された場合にのみ、OTP 待機用の Temporary Session を発行し、その情報をもとに OTP 入力ページへ遷移させます。これにより、URL を直接叩いて OTP ページにアクセスするといった不正な遷移を防止しています。

// 正しい認証情報入力後のみセッション生成
public static function startCompanyUserOtpLoginSession(SessionCompanyUser $session_company_user, ?string $callback, ?string $page)
{
    $_SESSION[self::OTP_LOGIN_SESSION_KEY]['company_user_id'] = (string)$session_company_user->company_user_id;
    $_SESSION[self::OTP_LOGIN_SESSION_KEY]['company_id'] = (string)$session_company_user->company_id;
    $_SESSION[self::OTP_LOGIN_SESSION_KEY]['is_otp_login_pending'] = true;
    $_SESSION[self::OTP_LOGIN_SESSION_KEY]['callback'] = $callback ?? '';
    $_SESSION[self::OTP_LOGIN_SESSION_KEY]['page'] = $page ?? '';
}
// OTPページでのアクセス検証
if (!isset($_SESSION['CORP_OTP_FLOW']['is_otp_login_pending'])) {
    // 直接URLアクセスを拒否 → ログインページへリダイレクト
    header('Location: /login');
    exit;
}

また、Temporary Session とは別に、OTP の試行回数や再送回数もセッションベースで管理しています。具体的には、OTP の再送は 1 分に 1 回まで、認証の試行は 5 回連続で失敗した時点でブロックする、という制限を設けています。これにより、総当たり攻撃の難易度を上げつつ、正規ユーザーの利便性も損なわないバランスを目指しています。

/**
 * Minimum delay in seconds before a user can request a new OTP.
 * This is used to prevent abuse of the OTP request functionality.
 */
private  const MIN_REISSUE_DELAY_SECONDS = 60; // 1 minute

/**
 * OTPを再発行できるかどうかを判定する
 * @param DateTimeImmutable $now 現在の日時
 * @return bool
 */
public function canIssue(DateTimeImmutable $now): bool
{
    if ($this->isUsed() || $this->isExpired($now)) {
        return true;
    }

    $diff = $now->getTimestamp() - $this->created_at->getTimestamp();
    return $diff >= OtpTimeInterval::MIN_REISSUE_DELAY_SECONDS;
}
// セッションで試行回数管理
$_SESSION['CORP_OTP_FLOW']['attempt_count']++;
if ($_SESSION['CORP_OTP_FLOW']['attempt_count'] >= 5) {
    // ブロック処理
}

まとめ

実装の成果

今回の追加認証ステップの導入により、まずセキュリティ面では、パスワードのみだった認証フローに OTP による確認を 1 段挟むことで、不正アクセスが成立するリスクを下げることができました。また、MFA を企業単位で任意に有効化できる設計としたことで、必要な企業だけがまずは試してみるといった段階的な導入が可能になり、利便性とセキュリティのバランスを取りやすくなっています。さらに、認証方式をテーブルで管理するモジュラーな設計にしたことで、将来的に SMS やアプリベースの認証方式を追加したい場合にも、同じ枠組みの中で拡張していける見通しが立ちました。

運用開始後の状況

リリース後は、二要素認証を有効化する企業やユーザーが徐々に増えています。導入前と比べて、不正ログインが成立するリスクも低減できており、現時点では大きな障害や想定外のトラブルもなく安定して稼働しています。

最後に

本記事では、PR TIMES における二要素認証の実装について、設計の考え方やデータベース構成、セッション管理の工夫などを交えながら紹介しました。Email OTP を採用することで、既存のメール基盤を活用しつつ、比較的短期間で追加認証ステップを提供できたのは、良い成果だったと考えています。

  • URLをコピーしました!

この記事を書いた人

ベトナムエンジニアとして株式会社PR TIMESの開発の本部でフロントエンドの仕事をしています。

目次