セキュリティポリシー Security policies

認証・アカウント管理に関わるセキュリティポリシーを定義します。 パスワードポリシー、ログイン試行回数制限、アカウントステータス管理、 重複メール検出 preflight、OAuth 自動登録抑止、エラーコード体系を網羅します。

Defines security policies related to authentication and account management. Covers password policy, login attempt rate limiting, account status management, duplicate email preflight, OAuth auto-registration prevention, and error code taxonomy.

モバイル経路の注意: Mobile-path note: 本ページの POST /v1/auth/preflightweb/portal 用 endpoint。Flutter アプリは直接叩かず、 POST /v1/mobile/actions/auth/sign-up(およびログイン UX 用に GET /v1/mobile/views/auth-config) を経由する。preflight 判定は api/src/bff/mobile/actions/auth.tsassertSignupPreflightOk() が内部で必ず実行する設計。 locked_until などのカウンタ情報は ActionEnvelope の states.error[].metadata 経由で同 endpoint から返る。 The POST /v1/auth/preflight calls referenced below are the web/portal endpoint. The Flutter app must not call them directly; it goes through POST /v1/mobile/actions/auth/sign-up (and GET /v1/mobile/views/auth-config for the login UX). Preflight evaluation is enforced server-side by assertSignupPreflightOk() in api/src/bff/mobile/actions/auth.ts. Counter data such as locked_until is surfaced via the same action's ActionEnvelope states.error[].metadata.

7.1 パスワードポリシー 7.1 Password policy

パスワードポリシーの SSoT(Single Source of Truth)は parky/shared/password-policy.json です。 Flutter アプリ、Portal フロントエンド、API バリデーション、DB チェック制約の 3 層すべてがこのファイルを参照します。 ポリシー変更はこのファイルの 1 箇所を編集するだけで全層に伝播します。

The SSoT for password policy is parky/shared/password-policy.json. The Flutter app, Portal frontend, API validation, and DB check constraints all reference this single file. Changing the policy in one place propagates to all layers.

ルールRule Value 備考Notes
min_length 8 最小文字数Minimum character count
max_length 128 最大文字数(bcrypt の 72 バイト制限よりも前段で切り捨て)Maximum character count (truncated before bcrypt's 72-byte limit)
require_lowercase true 英小文字を 1 文字以上含むMust contain at least one lowercase letter
require_uppercase true 英大文字を 1 文字以上含むMust contain at least one uppercase letter
require_digit true 数字を 1 文字以上含むMust contain at least one digit
require_symbol true 記号を 1 文字以上含む (!@#$%^&* 等)Must contain at least one symbol (e.g. !@#$%^&*)

インラインバリデーション Inline validation

Flutter の PasswordField ウィジェットおよび Portal のパスワード入力コンポーネントは password-policy.json を読み込み、タイプごとにリアルタイムでルールの充足状況を表示します。 未充足ルールは赤色、充足済みルールは緑色のチェックマークで示します。

The Flutter PasswordField widget and the Portal password input component load password-policy.json and display rule satisfaction status in real time as the user types. Unsatisfied rules are shown in red; satisfied rules are shown with a green checkmark.

API 動的取得 API dynamic retrieval

クライアントは GET /v1/auth/config でポリシーを動的取得できます。 バックエンドが返すレスポンスは password-policy.json の内容を含み、 アプリのコールドスタート時にキャッシュして使用します。

Clients can dynamically fetch the policy via GET /v1/auth/config. The backend returns a response containing the contents of password-policy.json, which the app caches on cold start.

7.2 ログイン試行回数制限(soft-lock) 7.2 Login attempt limiting (soft-lock)

Cloudflare Rate Limiter(ネットワーク層) Cloudflare Rate Limiter (network layer)

ルール名Rule name 制限Limit Key 対象エンドポイントTarget endpoint
login 10 req / 60s IP + user_id POST /v1/auth/sign-in
otp 5 req / 60s IP + user_id POST /v1/auth/verify-otp

制限超過時は HTTP 429 を返します。Retry-After ヘッダーで再試行可能時刻を通知します。

When exceeded, returns HTTP 429 with a Retry-After header indicating when the client may retry.

per-account soft-lock(アプリケーション層) Per-account soft-lock (application layer)

アカウントごとの連続失敗回数に基づいた指数バックオフで一時ロックします。 ロック状態は app_users.locked_until に格納します。

Temporarily locks based on exponential back-off per account's consecutive failure count. Lock state is stored in app_users.locked_until.

連続失敗回数Consecutive failures ロック時間Lock duration
5 回 15 分
10 回 1 時間
15 回以上 24 時間

7.3 アカウントステータス 7.3 Account statuses

ステータスStatus ログインSign-in アプリ利用App usage 再登録Re-registration 管理画面表示Admin visibility 設定主体Set by
active Allowed 全機能All features 表示Visible サインアップ時に自動Auto on sign-up
withdrawn 不可Denied 不可Denied 同メールで可(メール匿名化後)Allowed with same email (after anonymization) 匿名化で表示Shown anonymized ユーザー退会User withdrawal
suspended 成功後にサインアウト+通知表示Signed out after success + notice shown 不可(駐車中セッションは保護)Denied (active parking sessions preserved) 不可Denied 表示(停止理由付き)Visible (with suspension reason) 管理者Admin
blocked 不可(preflight で検知)Denied (detected at preflight) 不可Denied 不可(メールハッシュでブロック)Denied (email hash block) 表示(ブロック理由付き)Visible (with block reason) 管理者Admin

7.4 重複メール検出 preflight 7.4 Duplicate email preflight

サインアップ・パスワードリセット画面でメールアドレスを入力した後、 フォーム送信前に POST /v1/auth/preflight を呼び出して状態を確認します。

After entering an email address on the sign-up or password-reset screen, POST /v1/auth/preflight is called before form submission to check the state.

レスポンス Response

status 意味Meaning クライアント対応Client action
"available" 未登録(登録可能)Not registered (available) 登録フォームを続行Continue registration form
"exists_with_password" パスワード登録済みRegistered with password 「このメールは登録済みです」→ サインイン画面へ誘導"This email is already registered" → redirect to sign-in
"exists_with_oauth" OAuth 登録済みRegistered with OAuth provider フィールドを使い「Google でサインイン」等を表示Use provider field to show "Sign in with Google" etc.
"withdrawn_rejoinable" 退会済み(再登録可)Withdrawn (re-registration allowed) 「以前ご利用いただいたメールです。新規登録を続けますか?」"This email was used before. Would you like to register again?"
"blocked" ブロック済み(登録不可)Blocked (registration denied) 「このアカウントは利用できません」エラー表示(詳細は非公開)Show "This account cannot be used" error (no detail disclosed)

追加フィールド Additional fields

Enumeration 攻撃対策 Enumeration attack mitigations

7.5 preflight フロー 7.5 Preflight flow

sequenceDiagram
  participant U as ユーザー
  participant App as モバイルアプリ
  participant CF as Cloudflare Workers
  participant API as Supabase API
  participant DB as Database

  U->>App: メールアドレスを入力
  App->>App: onBlur / 次へボタンタップ
  App->>CF: POST /v1/auth/preflight {email}
  CF->>CF: Rate limit チェック (10/60s)
  alt レート制限超過
    CF-->>App: 429 Too Many Requests
    App->>U: 「しばらく待ってから再試行してください」
  else 通過
    CF->>API: preflight リクエスト転送
    API->>DB: admin.is_email_blocked(email) 確認
    alt blocked
      API-->>App: {status: "blocked"} (200, 一定時間後)
      App->>U: 「このアカウントは利用できません」
    else not blocked
      API->>DB: app_users + auth.users 照合
      API-->>App: {status, provider?, locked_until?} (200, 一定時間後)
      alt available
        App->>U: 登録フォームを続行
      else exists_with_password
        App->>U: 「登録済み」→ サインイン画面へ誘導
      else exists_with_oauth
        App->>U: provider に応じた OAuth ボタン表示
      else withdrawn_rejoinable
        App->>U: 再登録確認ダイアログ
      end
    end
  end

7.6 ブロック情報の保持 7.6 Block information storage

ブロックされたメールアドレスは PII(個人情報)の直接保持を避けるため、 SHA-256 ハッシュで admin.blocked_email_hashes テーブルに格納します。

Blocked email addresses are stored as SHA-256 hashes in the admin.blocked_email_hashes table to avoid holding PII directly.

カラムColumn Type 説明Description
email_hash text PRIMARY KEY メールアドレスの SHA-256 ハッシュ(lowercase 正規化後)SHA-256 hash of the email address (after lowercase normalisation)
reason text ブロック理由(管理者のみ参照可)Block reason (accessible to admins only)
blocked_at timestamptz ブロック日時Timestamp when blocked
blocked_by uuid REFERENCES portal_users 操作した管理者 IDAdmin user ID who performed the action

メール照合は admin.is_email_blocked(email text) RETURNS boolean 関数経由で行います。 この関数は service_role のみ実行可能とし、一般ユーザーからは直接呼び出せません。

Email lookup is performed via the admin.is_email_blocked(email text) RETURNS boolean function. This function is executable only by service_role and cannot be called directly by regular users.

7.7 OAuth 自動登録抑止 7.7 OAuth auto-registration prevention

Supabase Auth の before_user_created Auth Hook を使い、 OAuth 経由で未登録のメールアドレスがサインアップしようとした場合にブロックします。 これにより、「Google でサインイン」が実質的に新規登録にならないようにします。

Uses Supabase Auth's before_user_created Auth Hook to block OAuth sign-ups for email addresses that are not already registered. This prevents "Sign in with Google" from effectively becoming a new registration.

項目Item Value
Hook 種別Hook type before_user_created
適用条件Trigger condition プロバイダが email 以外(OAuth 経由) かつ app_users に該当メールが存在しないProvider is not email (i.e. OAuth) and the email does not exist in app_users
エラーコードError code parky.oauth.not_registered
クライアント対応Client action 「先にメールアドレスで登録してください」→ 登録画面へ誘導"Please register with your email address first" → redirect to registration

7.8 エラーコード一覧 7.8 Error code reference

エラーコードError code HTTP 意味Meaning クライアント対応Client action
parky.oauth.not_registered 403 OAuth 経由だが Parky アカウント未作成OAuth attempt but no Parky account exists 登録画面へ誘導し「先にメール登録を」と表示Redirect to registration; show "Please register with email first"
parky.account.blocked 403 管理者によるアカウントブロックAccount blocked by admin 「このアカウントは利用できません」(理由非開示)"This account cannot be used" (reason not disclosed)
parky.account.locked 429 soft-lock 中(試行回数超過)Account soft-locked (too many attempts) locked_until をもとにカウントダウンタイマーを表示Show countdown timer based on locked_until
parky.email.exists_with_password 409 同メールがパスワード方式で登録済みSame email already registered with password method 「このメールは登録済みです」→ サインイン画面へ"This email is already registered" → sign-in screen
parky.email.exists_with_oauth 409 同メールが OAuth 方式で登録済みSame email already registered with OAuth method provider フィールドに応じた OAuth ボタンを表示Show OAuth button based on provider field

7.9 認証フロー全体図 7.9 Full authentication flow

sequenceDiagram
  participant U as ユーザー
  participant App as モバイルアプリ
  participant CF as Cloudflare Workers
  participant Hook as Auth Hook
  participant DB as Database

  U->>App: サインアップ画面でメール入力
  App->>CF: POST /v1/auth/preflight {email}
  CF-->>App: {status: "available"}

  U->>App: パスワード入力・登録ボタンタップ
  App->>App: パスワードポリシーバリデーション (クライアント)
  App->>CF: POST /v1/auth/sign-up {email, password}
  CF->>DB: パスワードポリシー DB チェック制約
  CF->>DB: app_users INSERT
  CF-->>App: 登録成功 + session

  Note over App: ---- サインイン ----

  U->>App: サインイン画面でメール・パスワード入力
  App->>CF: POST /v1/auth/preflight {email}
  CF-->>App: {status: "exists_with_password", locked_until?}
  alt locked_until あり
    App->>U: カウントダウンタイマー表示
  else ロックなし
    App->>CF: POST /v1/auth/sign-in {email, password}
    CF->>CF: Cloudflare Rate Limit チェック
    CF->>DB: 認証照合 + 失敗カウント更新
    alt 認証成功
      CF->>DB: 失敗カウントリセット
      CF-->>App: session
      App->>U: ホーム画面へ
    else 認証失敗
      CF->>DB: 失敗カウントインクリメント → locked_until 更新
      CF-->>App: 401 + parky.account.locked (カウント≥5)
      App->>U: エラー表示 / カウントダウン
    end
  end

7.10 セキュリティチェックリスト 7.10 Security checklist

# チェック項目Check item 確認方法How to verify 担当Owner
1 パスワードポリシー 3 層一貫(クライアント・API・DB) Password policy consistent across 3 layers (client / API / DB) 各層で同ポリシーを password-policy.json から参照していることを確認Confirm each layer references the same password-policy.json Dev
2 soft-lock 動作テスト Soft-lock behaviour test 5 / 10 / 15 回の連続失敗でそれぞれ 15 分 / 1 時間 / 24 時間ロックされることを確認Confirm lock durations of 15 min / 1 hr / 24 hr at 5 / 10 / 15 failures respectively QA
3 OAuth Hook 動作テスト OAuth Hook behaviour test 未登録メールで Google サインインを試みたとき parky.oauth.not_registered が返ることを確認Confirm parky.oauth.not_registered is returned when Google sign-in is attempted with an unregistered email QA
4 preflight Enumeration 防御テスト Preflight enumeration defence test 60 秒以内に 11 件以上 preflight を呼び出したとき 429 を確認。応答時間の分散が ±50ms 以内であることを確認Confirm 429 on 11+ preflight calls within 60 seconds. Confirm response time variance is within ±50ms QA
5 ブロックメール登録試行テスト Blocked email registration test ブロック済みメールのハッシュで登録・OAuth・パスワードリセットを試みたとき parky.account.blocked が返ることを確認Confirm parky.account.blocked is returned when registration / OAuth / password reset is attempted with a blocked email hash QA
6 Cloudflare Rate Limiter 設定確認 Cloudflare Rate Limiter configuration check login 10/60s / otp 5/60s が prod Worker に設定されていることを Cloudflare ダッシュボードで確認Verify login 10/60s / otp 5/60s are configured on the prod Worker in the Cloudflare dashboard Infra