Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions docs/purchase-server-verification-setup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# 購入サーバー検証のセットアップ手順(Issue #163)

クライアント改竄によるプレミアム不正取得を防ぐため、購入レシートを
Cloud Functions(`verifyPurchase`)でサーバー検証する仕組みを追加した。
コードは実装・テスト済みだが、**本番で動かすには以下の手動作業が必要**。

## 全体像

```
アプリ(購入成功)
→ verifyPurchase(platform, productId, purchaseToken) ← Cloud Function
→ Google Play Developer API で購入トークンを検証
→ 検証OKなら users/{uid}/purchases/premium_entitlement に isPremium:true を書込
(このドキュメントは Firestore ルールでクライアント書込禁止 = サーバー専用)
アプリ(起動時)
→ premium_entitlement を読んでプレミアム判定(信頼できる唯一のソース)
```

## 手動作業(IK が実施)

### 1. Google Cloud サービスアカウントの用意

1. Firebase プロジェクトの GCP コンソール → IAM と管理 → サービスアカウント。
2. サービスアカウントを新規作成(例: `play-purchase-verifier`)。
- 既存の Functions 実行用 SA を使い回さず、専用 SA を推奨。
3. そのサービスアカウントの **JSON 鍵** を作成・ダウンロード。

### 2. Google Play Console 側で権限付与

1. [Google Play Console](https://play.google.com/console) → 「ユーザーと権限」。
2. 上記サービスアカウントのメールアドレスを招待。
3. アプリ権限で以下を付与(購入状態の参照に必要):
- 「財務データ、注文、キャンセル調査レポートを表示」
- (または「注文と定期購入を管理」)
4. Google Cloud コンソールで **Google Play Android Developer API** を有効化。

> 反映に最大24〜48時間かかることがある。

### 3. Secret Manager に鍵を登録

ダウンロードした JSON 鍵の中身を、シークレット名 `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` で登録する。

```bash
# 例: JSONファイルからシークレットを作成
firebase functions:secrets:set GOOGLE_PLAY_SERVICE_ACCOUNT_JSON
# プロンプトに JSON 全文を貼り付け(または < でファイルを流し込む)
```

### 4. デプロイ

```bash
# Cloud Functions(verifyPurchase を含む)
cd functions
npm install
firebase deploy --only functions

# Firestore ルール(premium_entitlement をサーバー専用書込にする)
cd ..
firebase deploy --only firestore:rules
```

## 動作確認

- 実機(Android)で購入 → プレミアムが有効になること。
- Functions ログに「購入検証成功・エンタイトルメント付与」が出ること。
- Firestore で `users/{uid}/purchases/premium_entitlement` が作成され、
クライアントからは書き込めない(ルールで拒否される)こと。
- 既存のプレミアム購入者: アプリ起動時に自動で `restorePurchases` が走り、
サーバー再検証 → `premium_entitlement` 付与でシームレスに移行する。

## 既知の制約・TODO

- **iOS は未対応**。`verifyPurchase` は iOS に対して `unimplemented` を返す。
iOS リリース時に App Store Server API(JWT署名)で同等の検証を実装し、
`_handleSuccessfulPurchase` の iOS 分岐をサーバー検証必須に切り替える。
現状 iOS はクライアント一次検証(`PurchaseValidator`)のみで付与している。
- **Firestore ルールの emulator テスト**(`test/firestore_rules/purchases.test.js`)は
**JDK 21 以上が必要**。ローカルが JDK 17 の場合は実行できない(CI / JDK21+ 環境で実行)。

## 関連ファイル

| 役割 | ファイル |
|---|---|
| Android 検証ロジック(純粋・テスト済み) | `functions/purchase/android_verifier.js` |
| Play API グルー(googleapis) | `functions/purchase/android_publisher_client.js` |
| Cloud Function 本体 | `functions/index.js`(`exports.verifyPurchase`) |
| Firestore ルール | `firestore.rules`(`premium_entitlement` 保護) |
| クライアント検証ラッパー | `lib/services/purchase/purchase_verifier.dart` |
| 購入処理・移行 | `lib/services/one_time_purchase_service.dart` |
10 changes: 8 additions & 2 deletions firestore.rules
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,14 @@ service cloud.firestore {

// ユーザーの購入データのルール
match /purchases/{purchaseId} {
// 認証されたユーザーは自分の購入データのみ読み書き可能
allow read, write: if request.auth != null && request.auth.uid == userId;
// 読み取りは本人のみ
allow read: if request.auth != null && request.auth.uid == userId;
// 書き込みは本人のみ。ただし premium_entitlement(サーバー検証済みの
// プレミアムフラグ)は Cloud Functions(admin SDK)専用とし、クライアントの
// 書き込みを禁止する。改竄によるプレミアム不正取得を防ぐ(Issue #163)。
allow write: if request.auth != null
&& request.auth.uid == userId
&& purchaseId != 'premium_entitlement';
}
}

Expand Down
120 changes: 120 additions & 0 deletions functions/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ const logger = require('firebase-functions/logger');
const admin = require('firebase-admin');
const vision = require('@google-cloud/vision');
const openai = require('openai');
const { verifyAndroidPurchase } = require('./purchase/android_verifier');
const {
createAndroidPublisherDeps,
} = require('./purchase/android_publisher_client');

admin.initializeApp();

Expand All @@ -20,6 +24,23 @@ function getVisionClient() {
// Secret Manager でAPIキーを管理(2nd Gen関数用)
const openaiApiKey = defineSecret('OPENAI_API_KEY');

// Google Play Developer API 用サービスアカウント鍵(JSON文字列)。
// 購入レシート検証(Issue #163)で使用。Secret Manager で管理。
const googlePlayServiceAccount = defineSecret('GOOGLE_PLAY_SERVICE_ACCOUNT_JSON');

// Android 購入検証用クライアントを遅延初期化
let _androidPurchaseDeps = null;
function getAndroidPurchaseDeps() {
if (!_androidPurchaseDeps) {
// 環境変数(.env)を優先し、Secret Manager にフォールバック
const json =
process.env.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON ||
googlePlayServiceAccount.value();
_androidPurchaseDeps = createAndroidPublisherDeps(json);
}
return _androidPurchaseDeps;
}

// OpenAI APIクライアントを遅延初期化
let _openaiClient = null;
function getOpenAIClient() {
Expand Down Expand Up @@ -486,3 +507,102 @@ exports.checkIngredientSimilarity = onCall(
}
}
);

// Cloud Function: 購入レシートをサーバー側で検証する(Issue #163 対応案#1/#3)
//
// クライアントの PurchaseDetails を信頼せず、Google Play Developer API で
// 購入トークンを検証する。検証成功時のみ、サーバー専用ドキュメント
// users/{uid}/purchases/premium_entitlement に isPremium:true を書き込む
// (admin SDK は Firestore ルールを迂回するため、クライアントは書き込めない)。
exports.verifyPurchase = onCall(
{ memory: '256MiB', timeoutSeconds: 30, secrets: [googlePlayServiceAccount] },
async (request) => {
// 認証チェック
if (!request.auth) {
throw new HttpsError('unauthenticated', '認証が必要です');
}

// レート制限チェック(既存のOCR等と共有)
await checkRateLimit(request.auth.uid);

const { platform, productId, purchaseToken } = request.data || {};
if (!platform || !productId || !purchaseToken) {
throw new HttpsError(
'invalid-argument',
'platform / productId / purchaseToken が必要です'
);
}

if (platform === 'ios') {
// TODO(#163): App Store Server API による iOS 検証は未対応。
// iOS リリース時に android_verifier.js と同様の ios_verifier.js を追加する。
throw new HttpsError('unimplemented', 'iOSのサーバー検証は未対応です');
}
if (platform !== 'android') {
throw new HttpsError(
'invalid-argument',
`未対応のプラットフォーム: ${platform}`
);
}

const uid = request.auth.uid;

let result;
try {
result = await verifyAndroidPurchase(
{ productId, purchaseToken },
getAndroidPurchaseDeps()
);
} catch (error) {
// 検証ロジックは例外を握って reason に変換するため、ここに来るのは
// クライアント構築失敗(鍵不正)等の想定外エラーのみ。
logger.error('購入検証で予期せぬエラー', {
uid,
error: error.message,
});
throw new HttpsError('internal', '購入検証に失敗しました');
}

if (!result.valid) {
logger.warn('購入検証に失敗(プレミアム付与せず)', {
uid,
productId,
reason: result.reason,
});
// API一時障害は再試行可能なエラーとして返す
if (result.reason === 'api_error') {
throw new HttpsError(
'unavailable',
'検証サーバーに接続できませんでした。時間をおいて再試行してください'
);
}
throw new HttpsError('permission-denied', '購入を検証できませんでした');
}

// サーバー専用ドキュメントにエンタイトルメントを書き込む
await admin
.firestore()
.collection('users')
.doc(uid)
.collection('purchases')
.doc('premium_entitlement')
.set(
{
isPremium: true,
productId,
platform: 'android',
orderId: result.orderId || null,
verifiedAt: admin.firestore.FieldValue.serverTimestamp(),
},
{ merge: true }
);

logger.info('購入検証成功・エンタイトルメント付与', {
uid,
productId,
orderId: result.orderId,
});

return { verified: true };
}
);
Loading