パスワードを持たないログイン設計:「コードがあれば通す、無ければ作る」を安全に実装する
はじめに
ある子ども向けアミューズメント系システムの案件で、通常とはかなり毛色の違う認証要件に出会いました。
- 要件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つの落とし穴
- 同時アクセスで同一
user_codeが二重登録される(レースコンディション) user_codeの形式が検証されず、任意文字列でアカウントが無限生成される- 「認証」と「登録」がコントローラで混ざり、テストも運用も追えなくなる
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はパスワードではなく識別子と割り切る- 「無ければ作る」はレース対策とバリデーションをセットで
- 検証・解決・発行を分離し、副作用をテストで固定する
- 使わない認証機能はテンプレートから削り切る
要件が「変」に見えたときほど、まず利用シーンから性質を捉え直すと、実装が素直になります。
