セキュリティポリシー 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.
POST /v1/auth/preflight は web/portal 用 endpoint。Flutter アプリは直接叩かず、
POST /v1/mobile/actions/auth/sign-up(およびログイン UX 用に GET /v1/mobile/views/auth-config)
を経由する。preflight 判定は api/src/bff/mobile/actions/auth.ts の assertSignupPreflightOk() が内部で必ず実行する設計。
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 時間 |
- ログイン成功時に失敗カウンターをリセット
- The failure counter is reset on successful login
locked_untilはPOST /v1/auth/preflightレスポンスに含まれ、クライアント UI がカウントダウンを表示locked_untilis included in thePOST /v1/auth/preflightresponse so the client UI can show a countdown
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
provider?: "google" | "apple" | "facebook"—exists_with_oauth時に返却locked_until?: ISO8601— ロック中の場合に返却。クライアントがカウントダウン表示に利用
Enumeration 攻撃対策 Enumeration attack mitigations
- Cloudflare Rate Limiter: 10 req / 60s(IP key)
- Cloudflare Rate Limiter: 10 req / 60s (IP key)
- 応答時間を一定化(timing attack 対策): DB ヒット・ミス問わず最低 200ms 待機してからレスポンス
- Constant response time (timing attack mitigation): minimum 200ms wait before responding regardless of DB hit/miss
"blocked"レスポンスはブロック理由を返さない(情報漏洩防止)"blocked"response does not disclose the block reason (information leak prevention)
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 |