簡単ログインのセキュリティ強化事例

簡単ログインのセキュリティ強化事例

-電話番号の下4桁がパスワードになる場合-

1. 背景:リンクログインという利便性と、その裏側

私が参加したプロジェクトは、大規模総合病院の患者向け電子問診システムです。患者は診療前に病院から送信された問診(アンケート)をモバイル端末で入力し、医療スタッフはその結果を診療に活用します。システムはKeycloakベースの認証、Java/Springマイクロサービスバックエンド、Reactフロントエンドで構成されており、私は運用中だったこのシステムに、不具合修正と運用支援の担当として参加しました。この記事は、その保守業務で遭遇した問題から始まる話です。

このシステムの主要な入口は「リンクログイン」です。病院が通知メッセージで送信した問診リンクを開き、自分の電話番号の下4桁だけを入力すれば、会員登録やパスワードなしですぐに問診を入力できます。

image1.png

図1.リンクログインの導入フロー

発端は顧客企業によるセキュリティ点検でした。「リンクログインで入ったユーザーが正規会員なら、会員画面全体にアクセスできるのではないか」という指摘が寄せられ、再現してみると事実でした。リンクの受信者がすでにID/PWで登録した正規会員である場合、Keycloakはその会員アカウントでトークンを発行しますが、画面とAPIがログイン方式をまったく区別していなかったため、電話番号の下4桁だけを入力したセッションから、マイページ・通知・すべての問診履歴までアクセスできました。正規会員にとっては、実質的に下4桁がパスワードになる構造であり、メッセージを閲覧できる第三者が、1万通りの数字だけで他人の医療関連情報にアクセスできることを意味します。顧客企業から正式に改善要求が提出されました。

2. 要件定義:「リンクから入ったユーザーはゲストと同じ扱いにする」

最も単純な解決策は、正規会員のリンクログインを禁止することですが、これは「リンクを押すだけで問診を入力できる」というサービスの中核的な利便性を損ないます。顧客企業との協議の結果、方向性は遮断ではなく権限の格下げに整理されました。「リンクログインセッションは、アカウントが正規会員であっても、非会員(ゲスト)と完全に同じように扱う」というものです。

  • リンクセッションでは、今回送信された問診の入力画面だけを基本的に提供します。

  • 過去の問診履歴は、[過去の履歴を見る]ボタンから、氏名・生年月日の本人確認を通過した場合にのみ参照できます。

  • マイページ・通知などの会員専用メニューは表示しません。(キオスク経路を除く)

このように要件が整理されると、技術的な課題は一つに絞られました。システムが「このセッションはどの方式でログインしたのか」を信頼できる形で把握することです。

3. 技術選定:ログイン方式をどこに記録するか

ログイン方式の情報をどこに置き、どのように伝達するかについて、3つの案を検討しました。

  • ①フロントエンドの状態(sessionStorageなど):実装は簡単ですが、開発者ツールで改ざんできるため、セキュリティ判定の根拠としては信頼できません。

  • ②サーバーセッション:JWTベースのステートレス構造と合わず、複数のマイクロサービスがそれぞれセッションストレージを参照しなければならない結合が生じます。

  • ③トークンclaim:認証機関であるKeycloakが署名したトークンにログイン方式を載せる方法で、改ざんできず、フロントエンド・バックエンドのどこからでも追加の照会なしに判定できます。

結論は③でした。「誰がログインしたのか」だけでなく、「どのようにログインしたのか」も認証機関が保証すべき情報だと判断しました。今回は画面制限にのみ使用するとしても、後からバックエンドAPIの遮断へ拡張する際に、そのまま再利用できるためです。

3-1. Keycloakの実装:User Session Noteとmapper

このシステムでは、リンクログインのためにすでにKeycloak SPIによるカスタムAuthenticatorを運用していました。ログインフォームのパラメータに応じて、リンク/キオスク/通常のID/PWログインに分岐する構造だったため、リンクログインの成功時にのみUser Session Noteを記録するコードを追加しました。

// LoginAuthenticator.java
if (formData.containsKey(FIELD_PHONE_LAST_4)) {
    // [CASE 1] 링크(전화번호 뒤 4자리) 로그인
    user = handlePhoneLogin(context, formData);
    if (user != null) {
        // User Session Note mapper를 통해 토큰 claim으로 발급된다
        context.getAuthenticationSession()
            .setUserSessionNote("login_method", "link");
    }
} else {
    // 키오스크 / 일반 ID/PW 로그인 — note를 기록하지 않는다
    ...
}

User Session NoteはKeycloakのセッション内部にのみ存在する値であるため、トークンclaimとして出力するには、クライアントのdedicated scopeにprotocol mapperを登録する必要があります。

# Keycloak 관리 콘솔 — 클라이언트 dedicated scope > Add mapper (By configuration)
Mapper type          : User Session Note
User Session Note    : login_method
Token Claim Name     : login_method
Claim JSON Type      : String
Add to ID token      : On
Add to access token  : On

適用後に2つのログイン方式のトークンを比較すると、リンクログインのトークンにのみclaimが存在し、ID/PWトークンにはclaim自体がありません。

// 링크 로그인 토큰 payload (일부)
{
  "preferred_username": "u-93f2...",
  "login_method": "link",   // <- mapper가 발급한 claim
  ...
}

// ID/PW 로그인 토큰 payload (일부) - login_method claim 자체가 없다
{
  "preferred_username": "u-93f2...",
  ...
}

これは意図したfail-safe設計です。判定ロジックは「claimの値がlinkの場合のみ制限する」ため、判定コードに欠陥があったり、トークンの解析に失敗したりしても、結果は常に「制限しない」、つまり正規会員の既存の導線が壊れない方向に倒れます。

4. フロントエンドへの適用:判定ユーティリティ1つ、ゲート6か所

フロントエンドには、claimを読み取る判定関数を共通ライブラリに1つだけ置き、各画面のゲートから参照するようにしました。

/** 링크 로그인 세션 여부 — ID/PW 토큰에는 claim 자체가 없다 */
export const isLinkLoginSession = (): boolean => {
  const token = getAccessToken();
  if (!token) return false;
  try {
    return JSON.parse(atob(token.split('.')[1]))?.login_method === 'link';
  } catch {
    return false;   // 파싱 실패 시에도 "제한하지 않음" (fail-safe)
  }
};

既存の画面では、非会員かどうか(isGuest)によってゲストモードを処理していたため、リンクセッションを加えた統合概念isRestrictedを権限チェックフックに導入し、画面側ではこの値だけを見るようにしました。

const isGuest = user?.isGuest ?? false;
const isLinkSession = isLinkLoginSession();
const isRestricted = isGuest || isLinkSession;  // 제한 세션

この基準を、ルーティングレイアウト、メニューフック、初回導入ページ、患者ダッシュボード、共通ヘッダー、問診一覧の6か所のゲートに適用し、制限セッションには送信された問診と[過去の履歴を見る]だけを残しました。本人確認を通過した後は、ホーム・問診タブ・詳細画面からの退出のどこからでも同じように過去履歴ページへ移動できるよう導線を統一し、モバイル下部ナビゲーションは本人確認の完了時にのみ表示しました。

5. APIレイヤー:開放と防御のバランス

画面をゲストレベルに合わせると、反対方向の問題が生じました。ゲスト画面が呼び出す3つのAPI(本人確認の検証、ゲスト問診一覧、ゲスト履歴照会)がROLE_GUEST専用にロックされていたため、ROLE_USERトークンを持つリンクセッションの会員にも開放する必要がありました。権限を広げる変更であるため安全性を別途検証しましたが、3つのAPIはいずれも照会対象の患者をクライアント入力ではなくトークンの認証主体から逆算し、Redisに記録された本人確認通過フラグ(TTL 30分)を再検証した後にのみデータを返す設計になっていたため、他人のデータにアクセスする経路は生じませんでした。

一方、「リンクセッションの場合は会員専用APIをバックエンドで根本的に遮断する」作業は、今回の範囲から除外することにしました。従来から画面レベルの制限しかなかった領域であり、会員専用APIも同様の自己防御を備えていたため、実質的なリスクが低かったからです。ただしclaimはAccess Tokenに載っているため、必要になればバックエンドの認可ロジックがclaimを読み取って遮断する方式へ拡張できる基盤は整いました。

image2.png

図2.全体アーキテクチャ — ログイン方式claimの伝播と判定ポイント

6. 本番デプロイで学んだこと:静かに無効化される設定

最も大きく学んだのは、コードではなくデプロイでした。Authenticator(SPI)はコードなのでデプロイパイプラインを通りますが、mapperはKeycloak realmの「設定」であり、管理コンソールから環境ごとに手動登録する必要があります。問題は、この設定が欠落していても何のエラーも発生しない点です。claimが発行されないだけで、判定関数はfail-safe設計に従って静かにfalseを返すため、すべてが正常に見える状態で画面制限だけが失われます。このような「静かな失敗」は、ユーザーからの報告でも発見されません。

そこで、デプロイチェックリストに次の項目を明記しました。

  • 本番反映時にKeycloakコンソールへUser Session Note mapperを手動登録(DEV/STGと同一設定)

  • デプロイ直後に、リンクログインのトークンにlogin_method claimが存在すること、ID/PWトークンには存在しないことを相互確認

  • リンクセッションで会員専用メニューが表示されないことを画面で最終確認

それ以降は、コードのデプロイだけでは完了しない変更、特に失敗が静かな設定ほど、デプロイ直後の能動的な検証手順までチェックリストで管理しています。

7. まとめ

不具合修正の支援として始まった作業でしたが、結果的には認証構造を改善する取り組みになりました。リンクログインの利便性を維持しながら、電話番号の下4桁だけで個人情報画面にアクセスできていた脆弱性を取り除き、その過程で得た視点は他のプロジェクトでも有効だと考えています。

  • 認証の「方式」も、認証機関が保証すべき情報です。ログイン方式を署名済みトークンのclaimに載せるパターンは、簡易認証・ソーシャルログインなど、認証経路が複数あるシステムであればどこにでも適用できます。

  • 失敗の方向を先に設計する必要があります。「claimがある場合のみ制限する」構造のおかげで、どのような欠陥も既存ユーザーを壊す方向には波及しませんでした。

  • コード外の設定はチェックリストで管理する必要があります。欠落してもエラーなしに静かに無効化される設定は、必ずデプロイ直後の検証手順とともに文書化しなければなりません。

eunice

Site footer