パスワードを持たないログイン設計:「コードがあれば通す、無ければ作る」を安全に実装する

はじめに

ある子ども向けアミューズメント系システムの案件で、通常とはかなり毛色の違う認証要件に出会いました。

  • 要件1: user_code だけで認証する。パスワードは求めない
  • 要件2: 存在しない user_code が渡された場合は、その場で自動的に新規登録して認証を通す

一般的な Web アプリの感覚では「パスワード無し?」「存在しないIDで自動登録?」と身構える要件です。しかし利用シーンを踏まえると、これはむしろ理にかなった設計でした。本記事では、なぜこの設計になるのか、そして Laravel + Sanctum でどう安全側に倒して実装するかを整理します。

環境

  • PHP 8.2 系 / Laravel 10 系
  • 認証: Laravel Sanctum(APIトークン方式)
  • DB: MySQL 8.0
  • 静的解析: PHPStan (Larastan) Level 6

なぜ「パスワード無し」「自動登録」なのか

このアプリのフロントは、店頭に置かれた端末(キオスク)上で動く SPA です。利用者は小さな子どもで、その場で発行された user_code を使って遊びます。

この文脈で通常のメール+パスワード認証を採用すると、以下がすべて破綻します。

  • 子どもがパスワードを覚えられない・管理できない
  • メールアドレスを持っていない利用者が多い
  • 端末前で登録フォームを埋める時間が体験を殺す

つまり、アカウントは「体験を継続させるための軽い識別子」であって、「秘匿すべき資産」ではないのです。ここを取り違えると、要件と実装が最後まで噛み合いません。

要件を言い換えると、こうなります。

> user_code はパスワードではなく識別子。初回接触時に識別子を発行し、以降は同じ識別子で「続きから」遊べるようにする。

この整理ができると、「パスワード無し=手抜き」ではなく「認証情報の性質が違うだけ」だと納得できます。

素朴に実装するとハマる4つの落とし穴

  1. 同時アクセスで同一 user_code が二重登録される(レースコンディション)
  2. user_code の形式が検証されず、任意文字列でアカウントが無限生成される
  3. 「認証」と「登録」がコントローラで混ざり、テストも運用も追えなくなる
  4. firstOrCreate の内部挙動を理解せず、想定外の初期値でユーザーが作られる

核心は「認証フローの中に副作用(新規作成)が埋め込まれている」ことです。そこで責務を次のように分離します。

  • 入力検証(FormRequest): user_code の形式・長さを保証する
  • ユーザー解決(UseCase / Repository): 「取得、無ければ作成」をアトミックに行う
  • トークン発行(Controller): 解決済みユーザーに対して Sanctum トークンを返す

実装例

1. 入力検証:作ってよい形式かを先に固める

「無ければ作る」を許すからこそ、作ってよい入力かどうかを厳格に見ます。

<?php

namespace App\Http\Requests\Api;

use Illuminate\Foundation\Http\FormRequest;

class LoginRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        return [
            // 英数16文字固定。桁と文字種を縛り、無限生成を入口で止める
            'user_code' => ['required', 'string', 'regex:/\A[0-9a-zA-Z]{16}\z/'],
        ];
    }
}

自動登録を許す前提だからこそ、バリデーションが最後の砦になります。

2. ユーザー解決:レース対策込みで「取得 or 作成」

firstOrCreate は便利ですが、高並列下では二重 INSERT が起こりえます。user_code にユニーク制約を張り、衝突時のリトライで吸収します。

<?php

namespace App\UseCases\Interactors\Auth;

use App\Models\User;
use App\UseCases\Repositories\User\UserRepositoryInterface;
use Illuminate\Database\QueryException;

class ResolveUserInteractor
{
    public function __construct(
        private readonly UserRepositoryInterface $users,
    ) {
    }

    public function handle(string $userCode): User
    {
        if ($user = $this->users->findByUserCode($userCode)) {
            return $user;
        }

        try {
            return $this->users->create($userCode);
        } catch (QueryException $e) {
            // 重複キー(23000)は「他プロセスが先に作った」とみなして再取得
            if ((string) ($e->errorInfo[0] ?? '') === '23000') {
                return $this->users->findByUserCode($userCode) ?? throw $e;
            }
            throw $e;
        }
    }
}
// migration:一意性の最終担保はDB制約で
$table->string('user_code', 16)->unique();

DB 側のユニーク制約が本丸です。アプリのチェックはあくまで補助で、最終的な一意性は制約で担保します。

3. トークン発行:認証の入口だけを担う

<?php

namespace App\Http\Controllers\Api\Auth;

use App\Http\Controllers\Controller;
use App\Http\Requests\Api\LoginRequest;
use App\UseCases\Interactors\Auth\ResolveUserInteractor;
use Illuminate\Http\JsonResponse;

class LoginController extends Controller
{
    public function __construct(
        private readonly ResolveUserInteractor $resolveUser,
    ) {
    }

    public function __invoke(LoginRequest $request): JsonResponse
    {
        $user = $this->resolveUser->handle($request->string('user_code')->value());

        $user->tokens()->delete(); // 端末=1トークン想定
        $token = $user->createToken('device')->plainTextToken;

        return response()->json([
            'token'  => $token,
            'is_new' => $user->wasRecentlyCreated,
        ]);
    }
}

wasRecentlyCreated を返しておくと、フロント側で「初回だけチュートリアルを出す」といった分岐に使えます。

確認方法

自動登録は、正常系より副作用の有無をテストで固定するのが重要です。

public function test_既存user_codeではユーザーが増えない(): void
{
    $user = User::factory()->create(['user_code' => str_repeat('a', 16)]);
    $before = User::count();

    $this->postJson('/api/login', ['user_code' => $user->user_code])
        ->assertOk()
        ->assertJsonPath('is_new', false);

    $this->assertSame($before, User::count());
}

public function test_未知のuser_codeは自動登録される(): void
{
    $this->postJson('/api/login', ['user_code' => str_repeat('b', 16)])
        ->assertOk()
        ->assertJsonPath('is_new', true);

    $this->assertDatabaseHas('users', ['user_code' => str_repeat('b', 16)]);
}

public function test_不正な形式は登録もされず弾かれる(): void
{
    $this->postJson('/api/login', ['user_code' => 'short'])
        ->assertStatus(422);

    $this->assertDatabaseMissing('users', ['user_code' => 'short']);
}

「不正入力ではユーザーが作られないこと」を必ず入れておくのがコツです。自動登録系の事故はたいていここから漏れます。

注意点

  • パスワード無し=認可を捨てていい、ではない。user_code は識別子なので、他人のコードを入力すれば他人として振る舞えます。推測されにくい長さ・エントロピーと、必要ならレート制限やデバイス紐付けを併用します。
  • 自動登録はDoS面が弱点。桁固定+レート制限で「無限にアカウントを作らせない」入口設計が要ります。
  • 使わない機能を削る判断も設計の一部。この案件では、パスワード変更・リセット・そのためのメール送信・開発用メールコンテナまで丸ごと削除しました。使わない認証機能を残すと、攻撃面と保守コストだけが積み上がります。

まとめ

「パスワードを持たないログイン」は手抜きではなく、認証情報の性質を利用シーンに合わせて再定義した結果です。

  • user_code はパスワードではなく識別子と割り切る
  • 「無ければ作る」はレース対策とバリデーションをセットで
  • 検証・解決・発行を分離し、副作用をテストで固定する
  • 使わない認証機能はテンプレートから削り切る

要件が「変」に見えたときほど、まず利用シーンから性質を捉え直すと、実装が素直になります。

\ 最新情報をチェック /

コメントを残す

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください。