疑問
Web APIの認証と認可を実装するには、どのような方法があるのでしょうか?JWTとOAuth 2.0の使い方について一緒に学んでいきましょう。
導入
Web APIの認証と認可は、セキュアなアプリケーションを構築するための重要な要素です。適切な認証・認可メカニズムを実装することで、不正アクセスを防ぎ、ユーザーデータを保護できます。
本記事では、認証と認可の基本概念から、JWT、OAuth 2.0の実装方法、セキュリティのベストプラクティスまで、詳しく解説していきます。
解説
1. 認証と認可の違い
認証と認可は、セキュリティの重要な概念ですが、しばしば混同されます。認証は「あなたは誰ですか?」という質問に答えるプロセスで、ユーザーの身元を確認します。一方、認可は「あなたは何ができますか?」という質問に答えるプロセスで、ユーザーの権限を確認します。この2つの違いを理解することで、適切なセキュリティ実装が可能になります。
認証(Authentication)
認証は、「あなたは誰ですか?」という質問に答えるプロセスです。ユーザーの身元を確認します。
- ログイン: ユーザー名とパスワードで認証
- 多要素認証: パスワード + SMS/メール認証
- 生体認証: 指紋、顔認証など
認可(Authorization)
認可は、「あなたは何ができますか?」という質問に答えるプロセスです。ユーザーの権限を確認します。
- ロールベース: 管理者、一般ユーザーなど
- 権限ベース: 特定のリソースへのアクセス権限
- スコープベース: 特定の操作への権限
参考リンク: OWASP - Authentication
2. JWT(JSON Web Token)
JWT(JSON Web Token)は、認証情報を安全に伝達するためのトークンベースの認証方式です。JWTは、ヘッダー、ペイロード、署名の3つの部分で構成される自己完結型のトークンで、サーバー側でセッションを管理する必要がありません。これにより、スケーラブルで分散システムに適した認証方式を実現できます。
JWTの構造
JWTは、3つの部分で構成されます:
1. ヘッダー(Header): トークンのタイプと署名アルゴリズム
{
"alg": "HS256",
"typ": "JWT"
}2. ペイロード(Payload): クレーム(情報)
{
"sub": "1234567890",
"name": "太郎",
"iat": 1516239022,
"exp": 1516242622
}3. 署名(Signature): ヘッダーとペイロードを署名
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret
)JWTの形式:
header.payload.signature例:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5cJWTの実装(Node.js)
Node.jsでJWTを実装する方法:
必要なパッケージ: jsonwebtoken
npm install jsonwebtokenトークンの生成: ログイン時にトークンを発行
- ユーザーID、ロール、有効期限を含める
- 秘密鍵で署名
トークンの検証: リクエスト時にトークンを検証
- 署名を確認
- 有効期限を確認
- ユーザー情報を抽出
実装のポイント:
- 秘密鍵は環境変数で管理
- 有効期限は適切に設定(通常15分〜1時間)
- トークンには機密情報を含めない
JWTのリフレッシュトークン
リフレッシュトークンの仕組み:
問題: アクセストークンの有効期限が短いと、ユーザーが頻繁にログインし直す必要がある
解決策: リフレッシュトークンを使用
- アクセストークン: 短い有効期限(15分〜1時間)
- リフレッシュトークン: 長い有効期限(7日〜30日)
フロー:
1. ログイン時にアクセストークンとリフレッシュトークンを発行
2. アクセストークンでAPIにアクセス
3. アクセストークンが期限切れになったら、リフレッシュトークンで新しいアクセストークンを取得
4. リフレッシュトークンも期限切れになったら、再ログイン
セキュリティ:
- リフレッシュトークンはデータベースに保存
- トークンの無効化機能を実装
- トークンのローテーションを検討
JWTの実装例(Node.js)
JWTの生成、検証、リフレッシュトークンの実装例です。
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');
// 秘密鍵(環境変数から取得)
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';
const REFRESH_SECRET = process.env.REFRESH_SECRET || 'your-refresh-secret';
// 1. ログイン(トークン発行)
async function login(email, password) {
// ユーザーを検証
const user = await findUserByEmail(email);
if (!user) {
throw new Error('ユーザーが見つかりません');
}
// パスワードを検証
const isValid = await bcrypt.compare(password, user.password);
if (!isValid) {
throw new Error('パスワードが正しくありません');
}
// アクセストークンを生成
const accessToken = jwt.sign(
{ userId: user.id, email: user.email, role: user.role },
JWT_SECRET,
{ expiresIn: '15m' }
);
// リフレッシュトークンを生成
const refreshToken = jwt.sign(
{ userId: user.id },
REFRESH_SECRET,
{ expiresIn: '7d' }
);
// リフレッシュトークンをデータベースに保存
await saveRefreshToken(user.id, refreshToken);
return {
accessToken,
refreshToken,
expiresIn: 900 // 15分(秒)
};
}
// 2. トークンの検証(ミドルウェア)
function authenticateToken(req, res, next) {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; // Bearer <token>
if (!token) {
return res.status(401).json({ error: 'トークンが提供されていません' });
}
jwt.verify(token, JWT_SECRET, (err, user) => {
if (err) {
return res.status(403).json({ error: 'トークンが無効です' });
}
req.user = user;
next();
});
}
// 3. リフレッシュトークンで新しいアクセストークンを取得
async function refreshAccessToken(refreshToken) {
// リフレッシュトークンを検証
const decoded = jwt.verify(refreshToken, REFRESH_SECRET);
// データベースでリフレッシュトークンを確認
const storedToken = await findRefreshToken(refreshToken);
if (!storedToken || storedToken.userId !== decoded.userId) {
throw new Error('リフレッシュトークンが無効です');
}
// ユーザー情報を取得
const user = await findUserById(decoded.userId);
// 新しいアクセストークンを生成
const accessToken = jwt.sign(
{ userId: user.id, email: user.email, role: user.role },
JWT_SECRET,
{ expiresIn: '15m' }
);
return { accessToken, expiresIn: 900 };
}
// 4. 使用例
app.post('/api/auth/login', async (req, res) => {
try {
const { email, password } = req.body;
const tokens = await login(email, password);
res.json(tokens);
} catch (error) {
res.status(401).json({ error: error.message });
}
});
app.get('/api/users/me', authenticateToken, (req, res) => {
res.json({ user: req.user });
});
app.post('/api/auth/refresh', async (req, res) => {
try {
const { refreshToken } = req.body;
const tokens = await refreshAccessToken(refreshToken);
res.json(tokens);
} catch (error) {
res.status(403).json({ error: error.message });
}
});3. OAuth 2.0
OAuth 2.0は、サードパーティアプリケーションがユーザーのリソースにアクセスするための認可フレームワークです。ユーザーがパスワードを共有することなく、アプリケーションに権限を付与できます。Google、GitHub、Facebookなどの主要なサービスで使用されており、現代のWebアプリケーションでは標準的な認証方式となっています。
OAuth 2.0のフロー
OAuth 2.0の認可コードフロー:
主要な役割:
- リソースオーナー: ユーザー
- クライアント: アプリケーション
- 認可サーバー: 認証・認可を行うサーバー
- リソースサーバー: 保護されたリソースを提供するサーバー
フロー:
1. 認可リクエスト: クライアントがユーザーを認可サーバーにリダイレクト
2. 認可: ユーザーがログインし、権限を付与
3. 認可コード: 認可サーバーが認可コードをクライアントに返す
4. トークンリクエスト: クライアントが認可コードをトークンと交換
5. アクセストークン: クライアントがアクセストークンを受け取る
6. リソースアクセス: クライアントがアクセストークンでリソースにアクセス
スコープ: アクセスの範囲を定義(例: read:user, write:post)
OAuth 2.0の実装(簡易版)
OAuth 2.0の簡易実装例:
実装のポイント:
- 認可コードを一時的に保存(通常5〜10分の有効期限)
- 認可コードは一度だけ使用可能
- アクセストークンとリフレッシュトークンを発行
- スコープを検証
セキュリティ:
- リダイレクトURIを検証
- クライアントIDとシークレットを検証
- PKCE(Proof Key for Code Exchange)を使用(推奨)
- HTTPSを使用(必須)
OAuth 2.0の実装例
OAuth 2.0の認可コードフローの実装例です。
// OAuth 2.0の簡易実装例
// 1. 認可エンドポイント(ユーザーをリダイレクト)
app.get('/oauth/authorize', (req, res) => {
const { client_id, redirect_uri, response_type, scope, state } = req.query;
// クライアントを検証
const client = findClientById(client_id);
if (!client || client.redirect_uri !== redirect_uri) {
return res.status(400).json({ error: 'invalid_client' });
}
// ログインページにリダイレクト(実際の実装では認証が必要)
// ここでは簡易的に認可ページを表示
res.render('authorize', {
client_id,
redirect_uri,
scope,
state
});
});
// 2. 認可の承認
app.post('/oauth/authorize', (req, res) => {
const { client_id, redirect_uri, scope, state } = req.body;
const user = req.user; // 認証済みユーザー
// 認可コードを生成
const authCode = generateAuthCode();
// 認可コードを一時的に保存(5分の有効期限)
authCodes.set(authCode, {
client_id,
redirect_uri,
scope,
userId: user.id,
expiresAt: Date.now() + 5 * 60 * 1000
});
// リダイレクトURIに認可コードを付けてリダイレクト
const redirectUrl = new URL(redirect_uri);
redirectUrl.searchParams.set('code', authCode);
if (state) {
redirectUrl.searchParams.set('state', state);
}
res.redirect(redirectUrl.toString());
});
// 3. トークンエンドポイント(認可コードをトークンと交換)
app.post('/oauth/token', (req, res) => {
const { grant_type, code, redirect_uri, client_id, client_secret } = req.body;
if (grant_type !== 'authorization_code') {
return res.status(400).json({ error: 'unsupported_grant_type' });
}
// クライアントを検証
const client = findClientById(client_id);
if (!client || client.secret !== client_secret) {
return res.status(401).json({ error: 'invalid_client' });
}
// 認可コードを検証
const authCodeData = authCodes.get(code);
if (!authCodeData) {
return res.status(400).json({ error: 'invalid_grant' });
}
if (authCodeData.expiresAt < Date.now()) {
authCodes.delete(code);
return res.status(400).json({ error: 'invalid_grant' });
}
if (authCodeData.redirect_uri !== redirect_uri) {
return res.status(400).json({ error: 'invalid_grant' });
}
// 認可コードを削除(一度だけ使用可能)
authCodes.delete(code);
// アクセストークンとリフレッシュトークンを生成
const accessToken = generateAccessToken(authCodeData.userId, authCodeData.scope);
const refreshToken = generateRefreshToken(authCodeData.userId);
res.json({
access_token: accessToken,
token_type: 'Bearer',
expires_in: 3600,
refresh_token: refreshToken,
scope: authCodeData.scope
});
});
// 4. リソースエンドポイント(アクセストークンで保護)
app.get('/api/user', authenticateToken, (req, res) => {
// req.user にはトークンから抽出したユーザー情報が含まれる
res.json({ user: req.user });
});
// 5. トークン検証ミドルウェア
function authenticateToken(req, res, next) {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'unauthorized' });
}
// トークンを検証
const tokenData = verifyAccessToken(token);
if (!tokenData) {
return res.status(403).json({ error: 'invalid_token' });
}
req.user = tokenData;
next();
}参考リンク: OAuth 2.0公式仕様
4. セッションベース認証
セッションベース認証は、サーバー側でセッションを管理する伝統的な認証方式です。ユーザーがログインすると、サーバーはセッションIDを生成し、クッキーでクライアントに送信します。以降のリクエストでは、このセッションIDを使用してユーザーを識別します。セッション情報はサーバー側(メモリ、データベース、Redisなど)に保存されます。
Express Session
Express.jsでセッションベース認証を実装する方法:
必要なパッケージ: express-session
npm install express-sessionセッションストア: セッション情報の保存先
- メモリストア: 開発環境用(本番環境では使用しない)
- Redis: 本番環境で推奨
- MongoDB: データベースに保存
セキュリティ設定:
-
httpOnly: true: JavaScriptからアクセス不可-
secure: true: HTTPSのみで送信(本番環境)-
sameSite: 'strict': CSRF攻撃を防ぐ-
secret: セッションIDの署名に使用(環境変数で管理)メリット:
- サーバー側でセッションを完全に制御
- セッションの無効化が容易
- 機密情報をクライアントに送信しない
デメリット:
- サーバー側でセッションを管理する必要がある
- スケーラビリティの課題(複数サーバー間でセッションを共有)
5. ロールベースアクセス制御(RBAC)
ロールベースアクセス制御(RBAC: Role-Based Access Control)は、ユーザーにロールを割り当て、ロールに基づいてアクセス権限を制御する方式です。管理者、編集者、閲覧者などのロールを定義し、各ロールに適切な権限を付与します。これにより、ユーザーごとに個別に権限を設定する必要がなくなり、管理が容易になります。
RBACの基本概念
RBACの基本概念:
ロール: ユーザーの役割(例: admin, editor, viewer)
権限: 実行可能な操作(例: read:user, write:post, delete:comment)
リソース: アクセス対象(例: users, posts, comments)
ロールの階層:
- 管理者(admin): すべての権限
- 編集者(editor): 作成・編集・削除の権限
- 閲覧者(viewer): 読み取りのみの権限
実装方法:
- データベースでロールと権限を管理
- ミドルウェアでロールを検証
- エンドポイントごとに必要なロールを指定
RBACの実装例
RBACの実装のポイント:
データモデル:
- ユーザーテーブル: ユーザー情報
- ロールテーブル: ロール定義
- 権限テーブル: 権限定義
- ユーザーロールテーブル: ユーザーとロールの関連
- ロール権限テーブル: ロールと権限の関連
ミドルウェア:
- 認証ミドルウェア: ユーザーを認証
- 認可ミドルウェア: ロールと権限を検証
エンドポイント保護:
- 必要なロールを指定
- 必要な権限を指定
RBACの実装例
ロールベースアクセス制御の実装例です。
// RBACの実装例
// 1. ロール定義
const ROLES = {
ADMIN: 'admin',
EDITOR: 'editor',
VIEWER: 'viewer'
};
// 2. 権限定義
const PERMISSIONS = {
READ_USER: 'read:user',
WRITE_USER: 'write:user',
DELETE_USER: 'delete:user',
READ_POST: 'read:post',
WRITE_POST: 'write:post',
DELETE_POST: 'delete:post'
};
// 3. ロールと権限のマッピング
const rolePermissions = {
[ROLES.ADMIN]: [
PERMISSIONS.READ_USER,
PERMISSIONS.WRITE_USER,
PERMISSIONS.DELETE_USER,
PERMISSIONS.READ_POST,
PERMISSIONS.WRITE_POST,
PERMISSIONS.DELETE_POST
],
[ROLES.EDITOR]: [
PERMISSIONS.READ_USER,
PERMISSIONS.READ_POST,
PERMISSIONS.WRITE_POST
],
[ROLES.VIEWER]: [
PERMISSIONS.READ_USER,
PERMISSIONS.READ_POST
]
};
// 4. ロールチェックミドルウェア
function requireRole(...roles) {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({ error: '認証が必要です' });
}
if (!roles.includes(req.user.role)) {
return res.status(403).json({ error: '権限がありません' });
}
next();
};
}
// 5. 権限チェックミドルウェア
function requirePermission(permission) {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({ error: '認証が必要です' });
}
const userRole = req.user.role;
const permissions = rolePermissions[userRole] || [];
if (!permissions.includes(permission)) {
return res.status(403).json({ error: '権限がありません' });
}
next();
};
}
// 6. エンドポイントの保護
// 管理者のみアクセス可能
app.delete('/api/users/:id',
authenticateToken,
requireRole(ROLES.ADMIN),
async (req, res) => {
// ユーザー削除の処理
}
);
// 編集者以上がアクセス可能
app.post('/api/posts',
authenticateToken,
requireRole(ROLES.ADMIN, ROLES.EDITOR),
async (req, res) => {
// 投稿作成の処理
}
);
// 権限ベースの保護
app.delete('/api/posts/:id',
authenticateToken,
requirePermission(PERMISSIONS.DELETE_POST),
async (req, res) => {
// 投稿削除の処理
}
);
// 7. ユーザーのロールを取得する関数(データベースから)
async function getUserRole(userId) {
const user = await findUserById(userId);
return user.role;
}
// 8. ユーザーに権限があるかチェックする関数
function hasPermission(userRole, permission) {
const permissions = rolePermissions[userRole] || [];
return permissions.includes(permission);
}6. セキュリティのベストプラクティス
認証・認可システムのセキュリティは、アプリケーション全体の安全性に直結します。パスワードのハッシュ化、HTTPSの使用、レート制限、適切なCORS設定など、多層的なセキュリティ対策を実装することで、安全な認証システムを構築できます。
パスワードのハッシュ化
パスワードのハッシュ化:
重要性: パスワードを平文で保存してはいけません。データベースが漏洩した場合、すべてのユーザーのパスワードが危険にさらされます。
推奨アルゴリズム:
- bcrypt: 最も一般的(Node.jsではbcryptパッケージ)
- Argon2: より新しいアルゴリズム(推奨)
- PBKDF2: 標準的なアルゴリズム
実装のポイント:
- ソルト(Salt)を使用(自動生成)
- コストファクターを適切に設定(10〜12が推奨)
- パスワードの検証時もハッシュ化して比較
パスワードポリシー:
- 最小長(8文字以上)
- 複雑さの要件(大文字、小文字、数字、記号)
- 定期的な変更(オプション)
HTTPSの使用
HTTPSの使用:
重要性: 認証情報やトークンは機密情報です。HTTPで送信すると、中間者攻撃で盗聴される可能性があります。
実装:
- TLS 1.2以上を使用
- 有効なSSL証明書を使用
- HSTS(HTTP Strict Transport Security)ヘッダーを設定
HSTSヘッダー:
Strict-Transport-Security: max-age=31536000; includeSubDomains注意点:
- 本番環境では必ずHTTPSを使用
- 開発環境でもHTTPSの使用を推奨
- 証明書の有効期限を監視
レート制限
レート制限:
目的: ブルートフォース攻撃やアカウント列挙攻撃を防ぐ
実装:
- ログイン試行回数を制限(例: 5回/15分)
- IPアドレスベースの制限
- アカウントベースの制限
レスポンス:
- レート制限超過時は429 Too Many Requestsを返す
- Retry-Afterヘッダーで再試行可能な時間を通知
- アカウントロックアウト機能(オプション)
実装例:
- Redisを使用したレート制限
- express-rate-limitパッケージの使用
CORS設定
CORS設定:
問題: ブラウザの同一オリジンポリシーにより、異なるオリジンからのリクエストが制限される
適切な設定:
- 必要なオリジンのみを許可
- 認証情報を含むリクエストにはcredentials: trueを設定
- プリフライトリクエストを適切に処理
設定例:
Access-Control-Allow-Origin: https://example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Allow-Credentials: true注意点:
-
Access-Control-Allow-Origin: *は認証が必要なAPIでは使用しない- 必要なメソッドとヘッダーのみを許可
7. ベストプラクティスまとめ
本記事で学んだ認証・認可システムの実装におけるベストプラクティスをまとめます。実践的なプロジェクトで認証・認可を実装する際のチェックリストとして活用してください。
認証方式の選択
認証方式の選択ガイド:
JWT:
- ✅ スケーラブルな分散システム
- ✅ サーバー側でセッション管理が不要
- ✅ モバイルアプリとの連携
- ❌ トークンの無効化が困難
- ❌ トークンサイズが大きくなる可能性
セッションベース認証:
- ✅ サーバー側で完全に制御可能
- ✅ セッションの無効化が容易
- ✅ 機密情報をクライアントに送信しない
- ❌ サーバー側でセッション管理が必要
- ❌ 複数サーバー間でセッション共有が必要
OAuth 2.0:
- ✅ サードパーティアプリとの連携
- ✅ ユーザーがパスワードを共有しない
- ✅ 標準的なプロトコル
- ❌ 実装が複雑
- ❌ 追加のインフラが必要
セキュリティチェックリスト
セキュリティチェックリスト:
認証:
- [ ] パスワードをハッシュ化して保存
- [ ] 強力なパスワードポリシーを実装
- [ ] レート制限を実装
- [ ] アカウントロックアウト機能を実装
- [ ] 多要素認証を検討
トークン管理:
- [ ] トークンに有効期限を設定
- [ ] リフレッシュトークンを実装
- [ ] トークンの無効化機能を実装
- [ ] トークンを安全に保存(クライアント側)
通信:
- [ ] HTTPSを使用(本番環境)
- [ ] HSTSヘッダーを設定
- [ ] 適切なCORS設定
- [ ] セキュリティヘッダーを設定
実装のベストプラクティス
実装のベストプラクティス:
コード品質:
- 認証ロジックをミドルウェアとして分離
- エラーメッセージは情報漏洩を防ぐ
- ログに機密情報を出力しない
- 環境変数で機密情報を管理
テスト:
- 認証・認可の単体テスト
- 統合テスト
- セキュリティテスト(ペネトレーションテスト)
- レート制限のテスト
監視:
- ログイン試行の監視
- 異常なアクセスパターンの検出
- トークンの使用状況の監視
- エラー率の監視
継続的な改善
継続的な改善:
セキュリティアップデート:
- 依存パッケージの定期的な更新
- セキュリティパッチの適用
- 脆弱性の監視
ユーザー体験:
- ログイン体験の改善
- エラーメッセージの明確化
- パスワードリセット機能の改善
パフォーマンス:
- 認証処理の最適化
- セッション管理の最適化
- キャッシングの活用
まとめ
Web APIの認証と認可は、セキュアなアプリケーションを構築するための重要な要素です。JWTはトークンベースの認証に適し、OAuth 2.0はサードパーティアプリケーションとの連携に適しています。
適切なセキュリティ対策(パスワードのハッシュ化、HTTPS、レート制限など)を実装することで、安全なAPIを構築できます。プロジェクトの要件に応じて適切な認証方式を選択し、継続的にセキュリティを改善していくことが重要です。
実践的なプロジェクトで認証・認可を実装し、経験を積むことで、より安全なWebアプリケーションを構築できるようになります。