ご覧いただきありがとうございます。21日目担当のmuranakaです。
私は現在、社内外向けに新規プロダクト開発を行っています。
社内PoCが一段落して、いよいよビジネス案件化に向けて本格始動といった状態です。そして開発したプロダクトをユーザに使っていただくうえで、避けて通れないのが認証と認可の機能です。
認証とは「ユーザが間違いなく本人であることの検証」であり、認可とは、「認証されたユーザが、どのリソースに、どのような操作を行える権限を持っているかの検証」です。
本来、「ユーザが、ログイン画面から、IDとパスワードを入力して、ログインする」という体験はプロダクトの提供価値を高めることに何ら寄与しないため、自前実装は避けるべきです。
しかしながら、認証・認可の機能を考えなしに作ってしまうとビジネスロジックと密に結合してしまって、共通部品化が難しくなります。プロダクトを作るたびに、車輪の再発明のごとくID/パスワード管理機能を作ってしまう…というのが実態ではないでしょうか。
そこで本記事では、車輪の再発明の温床となるセッションベース認証の問題点を明らかにし、トークンベースへの移行によってどのようにこれらの課題を解決できるかを解説します。
スコープ
この記事では、「ユーザIDとパスワードの組み合わせ」による認証と認可について、以下の内容を取り上げます。
- セッションベースがどういうものか、何がダメなのか
- セッションベースに代わる方法として、トークンベースの概要と、その利点について
- 認証・認可のための標準であるOpenID Connect(OIDC)について
- OIDCによる認証・認可機能をDocker環境上でのハンズオン
一方で、この記事では以下の内容は取り上げません。
- SMSなどを利用した多要素認証方式(MFA)
- 生体情報など、パスワード以外のクレデンシャルを使った認証方式
- シングルサインオン(SSO)の詳細
ただ、これらの認証方式を適切に実装するためには、前提としてトークンベースの認証基盤ができていることが必要となります。発展的な認証・認可機能を実現する前の基礎知識として、本記事でトークンベースの考え方を押さえていただければ幸いです。
ありがちな例:セッションベース認証
認証機能を「その場しのぎで」作ってしまう場合、やってしまいがちなアンチパターンがセッションベース認証です。
セッションベース認証の典型的な流れは以下の通りです。
- ユーザしか知らない情報(クレデンシャル)としてパスワードを利用
- ユーザにIDとパスワードの入力を受け付ける
- ユーザDBに登録されたパスワードのハッシュ値が一致するかを検証
- 検証OKなら、サーバ側でセッションを作成し、ユーザ本人と認証してサービスにアクセスを許可
セッションベース認証の何がダメか
一見シンプルに見えるセッションベース認証ですが、実は多くの課題を抱えています。
スケーラビリティの問題:サーバ側で状態を持つ
セッションベース認証の最大の問題は、サーバ側で認証状態を保持しなければならないという点です。
ユーザがログインすると、サーバはセッション情報をメモリやデータベースに保存します。その後のリクエストでは、毎回このセッションストアを参照してユーザを識別します。
これが何を意味するかというと:
- 水平スケール時の複雑さ: サーバを複数台にスケールアウトする際、すべてのサーバでセッション情報を共有する必要がある
- 共有ストアへの依存: 共有データベースなどの別のインフラコンポーネントが必須になる
- Sticky Sessionの制約: ロードバランサーで特定のユーザを同じサーバに振り分ける設定が必要になり、負荷分散の柔軟性が失われる
- サーバリソースの圧迫: アクティブユーザが増えるほど、セッション情報の保存に必要なメモリやストレージが増大
マイクロサービスアーキテクチャとの相性の悪さ
現代のアプリケーションは、単一のモノリシックなサーバではなく、複数のマイクロサービスで構成されることが増えています。
セッションベース認証では:
- 各マイクロサービスが同じセッションストアにアクセスする必要がある
- サービス間の認証・認可の連携が複雑になる
- 各サービスがユーザ情報を取得するために、セッションストアへの追加のネットワークコールが発生
これにより、マイクロサービスの独立性が損なわれ、システム全体の複雑さが増します。
モバイルアプリやSPAとの相性の悪さ
セッションベース認証はCookieに依存しています。しかし:
- モバイルアプリ: ネイティブアプリはブラウザのCookieを使えない
- SPA (Single Page Application): CORSの制約により、異なるドメイン間でのCookie送信が制限される
- API認証: RESTful APIの原則である「ステートレス」に反する
そのため、モバイルアプリやSPAでは、セッションIDを手動で管理する必要があり、セキュリティリスクが高まります。
ユーザはサービスごとにID・パスワードの組み合わせを管理しなくてはならない
セッションベース認証を各プロダクトで独自実装すると、ユーザは:
- プロダクトAのID・パスワード
- プロダクトBのID・パスワード
- プロダクトCのID・パスワード
といった具合に、それぞれ別のクレデンシャルを管理しなければなりません。
これはユーザビリティの低下につながり、結果として:
- パスワードの使い回し
- 簡単なパスワードの設定
- パスワード忘れによる問い合わせ増加
といったセキュリティリスクやサポートコスト増大を招きます。
認可に関する管理は別途実装が必要
認証によって「間違いなくユーザ本人である」と検証しても、その次に問題になるのは「そのユーザがどのリソースに対してどのような操作を行えるか」という認可の問題です。
認証と認可は独立した概念であり、セッションベース認証で認可を「その場しのぎで」実現しようとすれば:
- ユーザIDとユーザ属性(ロール、権限)の情報を紐づける
- ユーザ属性と、各リソースへのアクセス権限を紐づける
- これらをすべて自前で実装・管理する
という複雑な実装が必要になります。
さらに、「ユーザが組織を脱退したら?」「昇格して権限が変わったら?」といったライフサイクル管理も考慮しなければなりません。各プロダクトで独自に実装していては、変更の度に全プロダクトを修正する羽目になります。
なぜセッションベースになってしまうのか
こうした問題があるにもかかわらず、なぜセッションベース認証が繰り返し実装されてしまうのでしょうか?
理由は以下の通りです。
- 実装が簡単に見える: 多くのWebフレームワークがセッション管理機能を標準で提供している
- 短期的な開発スピード優先: 「とりあえず動けばいい」という判断で、将来のスケーラビリティを犠牲にする
- 知識不足: トークンベース認証やOIDCといった標準が知られていない
- 既存システムとの整合性: 既存のレガシーシステムがセッションベースで動いているため、新規開発も同じ方式になる
トークンベース認証への転換
セッションベース認証の問題を解決するのがトークンベース認証です。
トークンベース認証とは
トークンベース認証では、ユーザ認証後にサーバがクライアントにトークンを発行します。クライアントはこのトークンを保持し、以降のリクエストで提示することで認証を行います。
重要なのは、サーバ側でセッション情報を保持しないという点です。トークン自体に必要な情報がすべて含まれているため、サーバはトークンを検証するだけで認証・認可の判断ができます。
トークンベース認証の利点
ステートレス=スケーラビリティの向上
トークンにはユーザ情報、権限、有効期限などがすべて含まれているため、サーバは状態を保持する必要がありません。
- サーバをいくら増やしても、セッション共有の問題が発生しない
- ロードバランサーで自由に負荷分散できる
- サーバのメモリやストレージの消費を削減
マイクロサービスとの相性が良い
各マイクロサービスが、トークンの署名を検証するだけで認証・認可を判断できます。
- 共有セッションストアへの依存がない
- サービス間の独立性が保たれる
- サービスごとに必要な権限をトークンから取得可能
モバイルアプリやSPAに対応
トークンはHTTPヘッダー(Authorization: Bearer <token>)で送信できるため、Cookieに依存しません。
- ネイティブアプリでも利用可能
- CORSの制約を受けにくい
- RESTful APIの「ステートレス」原則に準拠
セキュリティの向上
- JWT (JSON Web Token) を使用すれば、トークンの改ざん検知が可能
- トークンに有効期限を設定することで、漏洩時のリスクを低減
- Refresh Tokenを使った安全なトークン更新フロー
代表的なトークン:JWT (JSON Web Token)
トークンベース認証で最もよく使われるのが**JWT(JSON Web Token)**です。
JWTは以下の3つの部分から構成されます。
- Header(ヘッダー): 署名アルゴリズムとトークンタイプ
- Payload(ペイロード): ユーザ情報、権限、有効期限などのクレーム
- Signature(署名): 改ざん検知のための署名
サーバは秘密鍵を使ってトークンに署名し、検証時にはその署名をチェックすることで、トークンが改ざんされていないことを確認できます。
JWTのペイロード例
{
"sub": "user123",
"name": "山田太郎",
"email": "yamada@example.com",
"roles": ["user", "admin"],
"iat": 1516239022,
"exp": 1516242622
}
-
sub: Subject(ユーザID) -
name,email: ユーザ情報 -
roles: 権限・ロール -
iat: Issued At(発行時刻) -
exp: Expiration(有効期限)
サーバはこのペイロードを読み取ることで、データベースにアクセスせずにユーザの認証・認可を判断できます。
OpenID Connect (OIDC):認証・認可の標準プロトコル
トークンベース認証を実装する際、業界標準として**OpenID Connect(OIDC)**というプロトコルが存在します。
OIDCとは
OIDCは、OAuth 2.0というアクセス制御(認可)のプロトコルの上に、認証の機能を追加したものです。
- OAuth 2.0: 「誰が何にアクセスできるか」を定義(認可)
- OIDC: 「ユーザが誰であるか」を検証(認証)
OIDCを使うことで、標準化された方法で認証・認可を実装でき、自前実装による脆弱性や互換性の問題を避けられます。
OIDCの主要な登場人物
OIDCには以下の3つの主要な役割があります。
- RP (Relying Party): アプリケーション
- OP (OpenID Provider): 認証サーバ(KeycloakやAuth0など)
- Resource Owner: ユーザ
OIDCで発行されるトークン
OIDCでは主に3種類のトークンが使われます。
ID Token
ユーザの認証情報を含むJWT。「このユーザは誰か」を証明します。
{
"iss": "https://auth.example.com",
"sub": "user123",
"aud": "my-app",
"exp": 1516242622,
"iat": 1516239022,
"name": "山田太郎",
"email": "yamada@example.com"
}
Access Token
リソースへのアクセス権限を示すトークン。APIアクセス時に使用します。
短い有効期限(例:15分)を設定し、漏洩時のリスクを最小化します。
Refresh Token
Access Tokenの更新に使用する長寿命のトークン。
ユーザが毎回ログインし直さなくても、新しいAccess Tokenを取得できます。ただし、厳重に管理する必要があります。
OIDCの認証フロー
OIDCでは、いくつかの認証フローが定義されていますが、最も一般的なのが**Authorization Code Flow(認可コードフロー)**です。
このフローの利点は:
- セキュリティ: クライアントシークレットをサーバ側でのみ使用
- トークンの安全な取得: トークンがブラウザのURLに露出しない
- Refresh Tokenの利用: 長期間のセッション維持
Docker環境でのOIDCハンズオン:Keycloakを使った実践
それでは、実際にOIDCを体験してみましょう。ここではKeycloakという、オープンソースの認証サーバを使います。
環境構築
docker-compose.ymlの作成
以下の内容でdocker-compose.ymlを作成します。
version: '3.8'
services:
keycloak:
image: quay.io/keycloak/keycloak:23.0
environment:
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin
ports:
- "8080:8080"
command: start-dev
app:
image: node:18
working_dir: /app
volumes:
- ./app:/app
ports:
- "3000:3000"
command: sh -c "npm install && npm start"
depends_on:
- keycloak
Keycloakの起動
docker-compose up -d
ブラウザでhttp://localhost:8080にアクセスし、管理画面にログインします。
- Username:
admin - Password:
admin
Realmの作成
Keycloakでは、Realmという単位でテナントを分離します。
- 左上の「Master」をクリック
- 「Create Realm」をクリック
- Realm名に
my-appと入力して作成
Clientの作成
アプリケーションを登録します。
- 左メニューから「Clients」を選択
- 「Create client」をクリック
- 以下のように設定:
- Client ID:
my-app-client - Client Protocol:
openid-connect - Root URL:
http://localhost:3000
- Client ID:
- 「Save」をクリック
- 「Settings」タブで以下を設定:
- Valid redirect URIs:
http://localhost:3000/callback - Web origins:
http://localhost:3000
- Valid redirect URIs:
- 「Credentials」タブでClient Secretを確認(後で使用)
5. ユーザの作成
- 左メニューから「Users」を選択
- 「Add user」をクリック
- Usernameに
testuserと入力して作成 - 「Credentials」タブでパスワードを設定(例:
password) - 「Temporary」をOFFにする
サンプルアプリケーションの実装
次に、シンプルなNode.jsアプリケーションを作成します。
app/package.json
{
"name": "oidc-demo",
"version": "1.0.0",
"dependencies": {
"express": "^4.18.2",
"express-session": "^1.17.3",
"openid-client": "^5.6.1"
},
"scripts": {
"start": "node server.js"
}
}
app/server.js
const express = require('express');
const session = require('express-session');
const { Issuer, generators } = require('openid-client');
const app = express();
// セッション設定(今回はCode Verifierの一時保存のみに使用)
app.use(session({
secret: 'some-secret',
resave: false,
saveUninitialized: true
}));
let client;
// Keycloak OIDCクライアントの初期化
async function initOIDC() {
const keycloakIssuer = await Issuer.discover('http://keycloak:8080/realms/my-app');
client = new keycloakIssuer.Client({
client_id: 'my-app-client',
client_secret: 'YOUR_CLIENT_SECRET', // KeycloakのCredentialsタブから取得
redirect_uris: ['http://localhost:3000/callback'],
response_types: ['code'],
});
}
// ホームページ
app.get('/', (req, res) => {
if (req.session.tokenSet) {
const claims = req.session.tokenSet.claims();
res.send(`
<h1>Welcome, ${claims.name || claims.preferred_username}!</h1>
<p>Email: ${claims.email}</p>
<pre>${JSON.stringify(claims, null, 2)}</pre>
<a href="/logout">Logout</a>
`);
} else {
res.send('<h1>OIDC Demo</h1><a href="/login">Login</a>');
}
});
// ログイン
app.get('/login', (req, res) => {
const code_verifier = generators.codeVerifier();
const code_challenge = generators.codeChallenge(code_verifier);
req.session.code_verifier = code_verifier;
const authUrl = client.authorizationUrl({
scope: 'openid email profile',
code_challenge,
code_challenge_method: 'S256',
});
res.redirect(authUrl);
});
// コールバック
app.get('/callback', async (req, res) => {
const params = client.callbackParams(req);
const tokenSet = await client.callback('http://localhost:3000/callback', params, {
code_verifier: req.session.code_verifier,
});
req.session.tokenSet = tokenSet;
res.redirect('/');
});
// ログアウト
app.get('/logout', (req, res) => {
const id_token = req.session.tokenSet?.id_token;
req.session.destroy();
const logoutUrl = client.endSessionUrl({
id_token_hint: id_token,
post_logout_redirect_uri: 'http://localhost:3000',
});
res.redirect(logoutUrl);
});
// サーバ起動
initOIDC().then(() => {
app.listen(3000, () => {
console.log('App running on http://localhost:3000');
});
});
動作確認
-
http://localhost:3000にアクセス - 「Login」をクリック
- Keycloakのログイン画面が表示される
-
testuser/passwordでログイン - アプリケーションにリダイレクトされ、ユーザ情報が表示される
何が起こっているか
この実装では:
- 認証リクエスト: ユーザがログインボタンをクリックすると、KeycloakにリダイレクトされOIDC認証フローが開始
- ユーザ認証: Keycloakでユーザがログイン
- 認可コード取得: 認証成功後、アプリケーションに認可コードが返される
- トークン取得: アプリケーションが認可コードを使ってトークンを取得
- ユーザ情報表示: ID Tokenからユーザ情報を取得して表示
重要なポイントは、アプリケーション自身はユーザのパスワードを一切扱っていないことです。認証はすべてKeycloakが担当し、アプリケーションはトークンを受け取るだけです。
トークンの中身を確認
取得したID Tokenをデコードしてみましょう。jwt.ioにアクセスして、トークンを貼り付けると中身が見られます。
{
"exp": 1704067200,
"iat": 1704063600,
"auth_time": 1704063600,
"jti": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"iss": "http://localhost:8080/realms/my-app",
"aud": "my-app-client",
"sub": "12345678-1234-1234-1234-123456789abc",
"typ": "ID",
"azp": "my-app-client",
"session_state": "abcdef12-3456-7890-abcd-ef1234567890",
"at_hash": "AbCdEfGhIjKlMnOp",
"acr": "1",
"email_verified": true,
"name": "Test User",
"preferred_username": "testuser",
"given_name": "Test",
"family_name": "User",
"email": "testuser@example.com"
}
これらの情報を使って、アプリケーションは:
- ユーザを識別(
sub) - ユーザ名を表示(
name、preferred_username) - メールアドレスを取得(
email) - トークンの有効期限を確認(
exp)
といった処理を、データベースにアクセスせずに実行できます。
トークンベースのメリット再確認
ハンズオンを通じて、トークンベース認証の実装を体験しました。改めてメリットを整理しましょう。
認証機能の分離と再利用
- Keycloakが認証を担当: アプリケーションはトークン検証のみ
- 複数アプリで共有可能: 同じKeycloakインスタンスを複数のアプリケーションで使用可能
- 認証ロジックの共通化: パスワードハッシュ、多要素認証、アカウントロックなどの機能をKeycloakに任せられる
ステートレスな設計
- サーバ側でセッションを保持しない
- 水平スケールが容易
- マイクロサービスアーキテクチャに適合
セキュリティの向上
- 専門的な実装: Keycloakのような実績あるソリューションを使うことで、脆弱性を減らせる
- トークンの短命化: Access Tokenは短い有効期限、Refresh Tokenで更新
- 標準プロトコル: OIDCという標準に準拠することで、セキュリティベストプラクティスを自動的に適用
ユーザ体験の向上
- シングルサインオン(SSO)への道: 複数のアプリケーションで同じKeycloakインスタンスを使えば、一度のログインで複数サービスにアクセス可能
- ソーシャルログイン: KeycloakはGoogle、GitHub、Facebookなどのソーシャルログインに対応
- 多要素認証: OTPやSMSを使った追加の認証レイヤーを簡単に追加可能
まとめ
本記事では、セッションベース認証からトークンベース認証への移行について解説しました。
セッションベース認証の課題
- サーバ側で状態を保持するため、スケーラビリティに難がある
- マイクロサービスやモバイルアプリとの相性が悪い
- 各プロダクトで認証・認可を独自実装すると、ユーザ体験が悪化し、セキュリティリスクが増大
トークンベース認証の利点
- ステートレスでスケーラブル
- 標準プロトコル(OIDC)による実装
- 認証機能の分離と再利用が容易
- セキュリティの向上とユーザ体験の改善
次のステップ
今回はOIDCの基本的な認証フローを体験しましたが、実運用ではさらに以下のような要素を検討する必要があります。
- トークンの安全な保存: ブラウザではlocalStorageよりも、HttpOnly Cookieの使用を検討
- トークンのリフレッシュ: Refresh Tokenを使った自動更新の実装
- マイクロサービスでのトークン検証: 各サービスでJWT署名を検証する方法
- シングルサインオン(SSO): 複数アプリケーション間での認証共有
- 多要素認証(MFA): より高いセキュリティレベルが必要な場合
トークンベース認証とOIDCを理解することで、車輪の再発明を避け、セキュアでスケーラブルな認証基盤を構築できます。ぜひ、次のプロダクト開発で活用してみてください!
参考リンク
最後までお読みいただき、ありがとうございました!質問やフィードバックがあれば、ぜひコメントでお知らせください。