0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

はじめに

個人開発のWebアプリにフリーミアム(無料/有料)の仕組みを入れたいと思ったとき、認証をどう実装するかが悩みどころです。

  • サーバーを立てると費用がかかる
  • クライアントサイドだけだとソースを見れば突破される
  • Firebase AuthやAuth0は個人アプリには大げさ

この記事では、Cloudflare Workers(無料枠)とJWT(RS256) を組み合わせて、サーバー費用ゼロでライセンスキー認証を実装する方法を紹介します。

実際に文字なぞり練習アプリ「なぞりや」の有料機能(漢字)の解放に使っています。


アーキテクチャ

[クライアント] → キー入力 → [Cloudflare Workers /verify]
               ← JWT(RS256署名済み・有効期限10年) ←
[クライアント] → 公開鍵でJWT署名検証(オフライン可)→ 有料機能解放

ポイント

  • 秘密鍵はCloudflare Workersの環境変数(Secrets)にのみ存在
  • クライアントには公開鍵(JWK)のみ埋め込む → ソースを見ても偽造不可能
  • 一度JWTを取得すればlocalStorageに保存してオフラインでも動作
  • Cloudflare Workersの無料枠(10万req/日)で個人アプリなら十分

なぜRS256(非対称暗号)なのか

HS256(共通鍵)の場合、検証に使う鍵 = 署名に使う鍵なので、クライアントに鍵を埋め込むと誰でもJWTを偽造できてしまいます。

RS256(非対称暗号)なら:

場所 用途
秘密鍵 Cloudflare Workers(Secrets) JWT署名
公開鍵 クライアント(HTML内) JWT検証のみ

公開鍵を埋め込んでも署名はできないため、クライアントからのJWT偽造が不可能です。


実装手順

1. RS256鍵ペアの生成

Node.jsで秘密鍵・公開鍵を生成します。

// generate_keys.js
const { generateKeyPairSync } = require('crypto');
const fs = require('fs');

const { privateKey, publicKey } = generateKeyPairSync('rsa', {
  modulusLength: 2048,
  publicKeyEncoding:  { type: 'spki',  format: 'pem' },
  privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
});

fs.writeFileSync('private_key.pem', privateKey);
fs.writeFileSync('public_key.pem',  publicKey);

// クライアント用にJWK形式でも出力
const pub = require('crypto').createPublicKey(publicKey);
const jwk = pub.export({ format: 'jwk' });
fs.writeFileSync('public_key.jwk.json', JSON.stringify(jwk, null, 2));

console.log('public_key.jwk.json をHTMLに埋め込んでください');
console.log('private_key.pem をCloudflare WorkersのSecretsに設定してください');
node generate_keys.js

生成されるファイル:

  • private_key.pem → Cloudflare WorkersのSecretsに設定(絶対にGitにpushしない
  • public_key.jwk.json → クライアントのHTMLに埋め込む

2. Cloudflare Workers の実装

// worker.js
const ALGORITHM = { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' };
const CORS_HEADERS = {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Methods': 'POST, OPTIONS',
  'Access-Control-Allow-Headers': 'Content-Type',
};

export default {
  async fetch(request, env) {
    if (request.method === 'OPTIONS') {
      return new Response(null, { headers: CORS_HEADERS });
    }
    const url = new URL(request.url);
    if (request.method === 'POST' && url.pathname === '/verify') {
      return handleVerify(request, env);
    }
    return new Response('Not Found', { status: 404 });
  }
};

async function handleVerify(request, env) {
  try {
    const { key } = await request.json();

    // ライセンスキーの検証
    if (!key || key.trim() !== env.LICENSE_KEY) {
      return json({ ok: false, error: 'invalid_key' }, 401);
    }

    // JWT発行
    const privateKey = await importPrivateKey(env.JWT_PRIVATE_KEY);
    const token = await createJWT(privateKey);
    return json({ ok: true, token }, 200);
  } catch (e) {
    return json({ ok: false, error: 'server_error' }, 500);
  }
}

// JWT生成(RS256・有効期限10年)
async function createJWT(privateKey) {
  const header  = b64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));
  const now     = Math.floor(Date.now() / 1000);
  const payload = b64url(JSON.stringify({
    iss: 'your-app',
    sub: 'premium',
    iat: now,
    exp: now + 60 * 60 * 24 * 365 * 10, // 10年
  }));
  const data = `${header}.${payload}`;
  const sig  = await crypto.subtle.sign(
    ALGORITHM,
    privateKey,
    new TextEncoder().encode(data)
  );
  return `${data}.${b64url(sig)}`;
}

async function importPrivateKey(pem) {
  const der = pemToDer(pem);
  return crypto.subtle.importKey('pkcs8', der, ALGORITHM, false, ['sign']);
}

function pemToDer(pem) {
  const b64 = pem.replace(/-----[^-]+-----/g, '').replace(/\s/g, '');
  const bin = atob(b64);
  const buf = new Uint8Array(bin.length);
  for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i);
  return buf.buffer;
}

function b64url(data) {
  if (typeof data === 'string') {
    return btoa(unescape(encodeURIComponent(data)))
      .replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
  }
  const bytes = new Uint8Array(data);
  let str = '';
  for (const b of bytes) str += String.fromCharCode(b);
  return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}

function json(data, status = 200) {
  return new Response(JSON.stringify(data), {
    status,
    headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' },
  });
}
# wrangler.toml
name = "your-app-license"
main = "worker.js"
compatibility_date = "2024-01-01"

デプロイ

npm install -g wrangler
wrangler login
wrangler deploy

環境変数の設定

Cloudflare Dashboard → Workers → your-app-license → Settings → Environment Variables

# CLIから設定する場合
wrangler secret put LICENSE_KEY
# → 販売するキー文字列を入力

# PowerShellの場合
Get-Content private_key.pem -Raw | wrangler secret put JWT_PRIVATE_KEY

3. クライアント側の実装(Vanilla JS)

// public_key.jwk.json の内容を埋め込む
const JWT_PUBLIC_KEY_JWK = {
  "kty": "RSA",
  "n": "your-public-key-n-value",
  "e": "AQAB",
  "alg": "RS256",
  "use": "sig"
};

const LICENSE_API = 'https://your-app-license.your-subdomain.workers.dev/verify';

const License = (() => {
  const STORAGE_KEY = 'your_app_jwt';

  function b64urlDecode(str) {
    const b64 = str.replace(/-/g, '+').replace(/_/g, '/')
      + '=='.slice(0, (4 - str.length % 4) % 4);
    const bin = atob(b64);
    const buf = new Uint8Array(bin.length);
    for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i);
    return buf.buffer;
  }

  async function getPublicKey() {
    return crypto.subtle.importKey(
      'jwk', JWT_PUBLIC_KEY_JWK,
      { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
      false, ['verify']
    );
  }

  // JWTの署名を検証
  async function verifyJWT(token) {
    try {
      const parts = token.split('.');
      if (parts.length !== 3) return false;
      const [header, payload, sig] = parts;
      const pubKey = await getPublicKey();
      const data   = new TextEncoder().encode(`${header}.${payload}`);
      const valid  = await crypto.subtle.verify(
        { name: 'RSASSA-PKCS1-v1_5' },
        pubKey,
        b64urlDecode(sig),
        data
      );
      if (!valid) return false;
      // 有効期限チェック
      const claims = JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')));
      return claims.sub === 'premium' && claims.exp > Date.now() / 1000;
    } catch(e) { return false; }
  }

  let _unlocked = false;

  async function init() {
    try {
      const token = localStorage.getItem(STORAGE_KEY);
      if (token) _unlocked = await verifyJWT(token);
    } catch(e) {}
  }

  function isUnlocked() { return _unlocked; }

  // キー入力 → Workers APIで検証 → JWT保存
  async function tryUnlock(key) {
    try {
      const res  = await fetch(LICENSE_API, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ key: key.trim() }),
      });
      const data = await res.json();
      if (data.ok && data.token) {
        localStorage.setItem(STORAGE_KEY, data.token);
        _unlocked = await verifyJWT(data.token);
        return _unlocked;
      }
      return false;
    } catch(e) {
      // オフライン時は保存済みJWTで判断
      return _unlocked;
    }
  }

  return { init, isUnlocked, tryUnlock };
})();

// 使い方
window.addEventListener('load', async () => {
  await License.init(); // 起動時にJWT検証
  if (License.isUnlocked()) {
    // 有料機能を解放
  }
});

// キー入力フォームの送信
async function onSubmitLicenseKey(key) {
  const ok = await License.tryUnlock(key);
  if (ok) {
    console.log('解放成功!');
  } else {
    console.log('キーが正しくありません');
  }
}

4. 動作確認

# 正しいキーで確認
curl -X POST https://your-app-license.your-subdomain.workers.dev/verify \
  -H "Content-Type: application/json" \
  -d '{"key":"your-license-key"}'

# 期待するレスポンス
# {"ok":true,"token":"eyJhbGciOiJSUzI1NiJ9..."}

# 誤ったキーで確認
curl -X POST https://your-app-license.your-subdomain.workers.dev/verify \
  -H "Content-Type: application/json" \
  -d '{"key":"wrong-key"}'

# 期待するレスポンス
# {"ok":false,"error":"invalid_key"}

セキュリティについて

防げること

  • JWTの偽造: 公開鍵だけでは署名できないためRS256により防止
  • キーの推測: Cloudflare Workers側でのみ検証するため直接確認不可

防げないこと(許容範囲)

  • キーの共有: 購入者が第三者にキーを教えてしまうケース
  • localStorageの改ざん: JWTを直接書き換えようとするケース(署名検証で防止)
  • ネットワーク傍受: HTTPS使用で対策済み

個人開発のアプリであれば、このレベルのセキュリティで十分実用的です。厳密なライセンス管理が必要な場合はサーバーサイドでの都度検証が必要です。


コスト

サービス 用途 費用
Cloudflare Workers JWT発行API 無料(10万req/日)
Cloudflare Pages ホスティング 無料

合計: $0/月

個人開発アプリの規模であれば無料枠で十分です。将来アクセスが増えてもWorkers Paidは$5/月と安価です。


まとめ

Cloudflare Workers × JWT(RS256)を使うことで:

  • 秘密鍵をサーバーのみに保持 → クライアントに公開鍵のみ埋め込み
  • 一度認証すればオフラインでも動作 → localStorageにJWTを保存して起動時に検証
  • サーバー費用ゼロ → Cloudflare Workersの無料枠で運用

個人開発のフリーミアムアプリに最適な認証方式だと思います。ぜひ参考にしてみてください。


参考

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?