🛡️

不正なOAuth再登録を防ぐ - HMAC-SHA256による決定論的ユーザー識別の設計

に公開

📝 この記事について

OAuth認証(今回はGoogle)を使ったSaaSで、
一度退会したユーザーの再登録を制限したい場合の
技術的な実装パターンを検証してみました。

🤔 課題:退会後の即座な再登録

OAuth認証で退会処理を実装する際、
退会したユーザーの再登録を制限したいというニーズがあります。

想定されるユースケース

  • 無料体験の不正利用防止
  • BANユーザーの再登録防止
  • 重複アカウント防止

問題の発生

単純に実装すると以下の問題が発生します:

  1. サーバー側でユーザーレコードを物理削除
  2. 同じOAuthアカウントで再度ログイン
  3. 新規ユーザーとして登録される
  4. 制限が無効化されてしまう ⚠️

🔍 解決策の検討

案1: 論理削除で管理

deleted_atカラムを追加して、削除フラグで管理する方法。

メリット:

  • 実装がシンプル
  • ユーザー情報が残るので分析可能

デメリット(却下理由):

  • カスケード削除が使えなくなる
  • 退会ユーザーの個人情報(名前、メールアドレス等)を保持
  • プライバシー的な懸念

案2: 識別子のハッシュを保存(採用)

アプローチ:

  • ユーザー情報は物理削除
  • GoogleIDのハッシュ値だけを退会記録として保存
  • 「同一アカウントからのアクセス」だけを判定可能にする

利点:

  • ユーザーの個人情報は完全削除
  • 最小限のデータ保持
  • プライバシーに配慮しつつ不正防止も実現

GoogleIDとは:

OAuth 2.0のIDトークンに含まれるsubクレーム(Subject Identifier)のこと。
108123456789012345678のようなランダムな数値列で、
このID単体では氏名やメールアドレスは分からないが、
Googleアカウントとは一意に紐づいている。

🔑 ハッシュアルゴリズムの選択

なぜHMAC-SHA256なのか

今回の要件:

  • ✅ 同じGoogle ID → 必ず同じ値になる(決定論的)
  • ✅ データベースで検索できる(O(1))
  • ✅ 復号不可能
  • ✅ 秘密鍵で保護

SHA-256単体では不十分

GoogleIDは21桁程度の数値文字列(0-9のみ)でエントロピーが低く:

  • 約10^21通りのパターン
  • レインボーテーブルで事前計算可能
  • 総当たり攻撃のコストが低い

SHA-256単体でハッシュ化しても、この脆弱性は解消されません。

HMAC-SHA256の採用理由

  • 秘密鍵付き: 秘密鍵なしでは元のIDを復元不可能
  • レインボーテーブル耐性: 事前計算攻撃に強い
  • 決定論的: 同じ入力と秘密鍵から常に同じ出力

HMAC-SHA256の業界実績

HMAC-SHA256は、
決定論的ハッシュによるユーザー識別が必要な場面で採用される暗号化方式です。

Google Cloud

Google Cloudの公式ドキュメントで仮名化の手法としてHMAC-SHA256を紹介。

📚 Pseudonymization - Google Cloud

Elastic(GDPR対応の仮名化)

GDPR準拠のための個人データ保護手法として、HMACによる仮名化を詳解。
Logstashでの実装例とともに、キーローテーションやセキュリティ要件についても言及。

📚 GDPR Personal Data Pseudonymization - Elastic Blog

GDPR/法務的な位置づけ

GDPR第25条(Data protection by design and by default)において、
仮名化(pseudonymization)は推奨される技術的措置として明示されています。

HMAC-SHA256は、この仮名化技術の実装方法として以下の特性を持ちます:

技術的特性:

  • 秘密鍵なしでは元データへの復元が不可能
  • 同一入力に対して常に同一出力(決定論的ハッシュ)
  • レインボーテーブル攻撃への耐性

GDPR適用上のメリット:

  • データ最小化の原則に適合
  • 不正防止(fraud prevention)目的で使用可能
  • 正当な利益(legitimate interest)の根拠として扱える

参考資料

RFC標準化されている

HMACはRFC 2104で標準化されている暗号方式です。

📚 RFC 2104 - HMAC

💻 実装例(Spring Boot)

前提条件

- Spring Boot 3.x + Java 17以降
- Spring Security + OAuth 2.0 Client
- PostgreSQL

データベース

CREATE TABLE withdrawn_google_accounts (
  id UUID PRIMARY KEY,
  hashed_google_id VARCHAR(64) NOT NULL UNIQUE,
  withdrawn_at TIMESTAMP NOT NULL
);

CREATE INDEX idx_hashed_google_id 
  ON withdrawn_google_accounts(hashed_google_id);

ハッシュ化コンポーネント

@Component
public class GoogleIdHasher {
    
    @Value("${app.security.google-id-secret}")
    private String googleIdSecret;
    
    public String hashGoogleId(String googleId) {
        try {
            Mac hmac = Mac.getInstance("HmacSHA256");
            SecretKeySpec secretKey = new SecretKeySpec(
                googleIdSecret.getBytes(StandardCharsets.UTF_8),
                "HmacSHA256"
            );
            hmac.init(secretKey);
            byte[] hash = hmac.doFinal(
                googleId.getBytes(StandardCharsets.UTF_8)
            );
            return HexFormat.of().formatHex(hash);
        } catch (NoSuchAlgorithmException | InvalidKeyException e) {
            throw new RuntimeException("ハッシュ化に失敗", e);
        }
    }
}

退会時の処理

@Transactional
public void withdraw(String googleId) {
    // ユーザー削除
    userRepository.deleteByGoogleId(googleId);
    
    // ハッシュ化して退会記録を保存
    String hashedId = googleIdHasher.hashGoogleId(googleId);
    withdrawnRepository.save(hashedId, LocalDateTime.now());
}

ログイン時のチェック

@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) {
    OAuth2User oauth2User = super.loadUser(userRequest);
    String googleId = oauth2User.getAttribute("sub");
    
    // 退会記録チェック
    String hashedId = googleIdHasher.hashGoogleId(googleId);
    if (withdrawnRepository.existsByHashedGoogleId(hashedId)) {
        throw new OAuth2AuthenticationException("このアカウントは退会済みです");
    }
    
    return oauth2User;
}

設定

application.yml:

app:
  security:
    google-id-secret: ${GOOGLE_ID_SECRET:dev-secret-change-in-production}

🔒 プライバシーへの配慮

データ最小化の原則

元のGoogleIDをそのまま保存しない理由:

  • データベースから元のIDを復元できない
  • Googleアカウントとの直接的な紐付けを遮断
  • 「退会記録の確認」という目的だけに使える形で保存

HMAC-SHA256により秘密鍵付きで不可逆変換することで、
必要最小限の情報のみを保持しています。

データ保持期間の制限

一定経過したレコードはバッチ処理で定期的に削除。

無期限保持ではなく期限を設けることで、
不正利用の抑止とプライバシー保護のバランスを取っています。

プライバシーポリシーへの記載

法律上は個人データに該当する可能性があるため、
プライバシーポリシーへの記載が推奨されます。

📚 まとめ

OAuth認証での再登録制限について、実装パターンを検証しました。

今回のアプローチ:

  • ユーザー情報は物理削除
  • HMAC-SHA256で識別子をハッシュ化
  • 最小限の情報で再登録を防ぐ
  • データ保持期間を制限

今回は「同一アカウントかどうかを検索で判定する」という要件のため、
決定論的ハッシュであるHMAC-SHA256を選択しました。

パスワード保存など検索が不要なケースや、
別のセキュリティ要件がある場合は、また別の検討が必要になりそうです。

参考文献

Discussion