認証ミドルウェアの実装解説
概要
このドキュメントでは、AWS Cognitoを使用したOAuth 2.0認証フローを実装した認証ミドルウェアについて説明します。
通常の認証フローに加えて、特定の機能専用の認証フロー(CSRF対策付き)の実装方法を解説します。
アーキテクチャ
認証システムは以下の3つの主要コンポーネントで構成されています:
-
認証ルート (
routes/auth.js) - Cognitoからのコールバックを処理 -
認証ミドルウェア (
middlewares/auth.js) - リクエストの認証チェック -
認証ライブラリ (
lib/Auth.js) - Cognitoとの通信ロジック
通常の認証フロー
フロー概要
ユーザー → ログインページ → Cognito認証 → コールバックエンドポイント → アプリケーション
実装詳細
1. コールバックエンドポイント (/auth/callback)
Cognitoから認可コードを受け取り、アクセストークンとリフレッシュトークンを取得します。
router.get('/auth/callback', (req, res, next) => {
// 1. 認可コードの検証
if (!has(req.query, 'code') || !req.query.code) {
return res.redirect(REDIRECT_FAILURE_URL);
}
// 2. 認可コードをアクセストークンに交換
const tokens = await auth.getTokenFromConsols(req.query.code);
// 3. IDトークンの検証
const decoded = auth.verifyToken(tokens.id_token);
// 4. トークンをCookieに設定
res.cookie(HEADER_ACCESSTOKEN_NAME, tokens[HEADER_ACCESSTOKEN_NAME], cookieOptions);
res.cookie(HEADER_REFRESHTOKEN_NAME, tokens[HEADER_REFRESHTOKEN_NAME], cookieOptions);
// 5. ユーザー情報を取得
const response = await api.get('/userinfo', config);
// 6. 適切なページへリダイレクト
res.redirect(successUrl);
});
2. 認証チェックミドルウェア (checkAccessToken)
保護されたルートへのアクセス時に、トークンの有効性を確認します。
module.exports.checkAccessToken = async (req, res, next) => {
// 1. Cookieにトークンが存在するか確認
if (!has(req.cookies, HEADER_ACCESSTOKEN_NAME) ||
!has(req.cookies, HEADER_REFRESHTOKEN_NAME)) {
return res.redirect('/login');
}
try {
// 2. トークンの検証とユーザー情報の取得
const { user, accessToken, refreshToken } = await validateTokenAndGetUserInfo(req, res);
// 3. Cookieにユーザー情報を設定
setUserCookies(res, user, accessToken, refreshToken, req);
next();
} catch (error) {
if (error.message === REDIRECT_LOGIN) {
return res.redirect('/login');
}
throw error;
}
}
3. トークン検証とリフレッシュ
アクセストークンが無効な場合、リフレッシュトークンを使用して新しいトークンを取得します。
const validateTokenAndGetUserInfo = async (req, res) => {
let accessToken = req.cookies[HEADER_ACCESSTOKEN_NAME];
let refreshToken = req.cookies[HEADER_REFRESHTOKEN_NAME];
try {
// アクセストークンでユーザー情報を取得
response = await getUserInfo(accessToken);
} catch (error) {
// 401エラーの場合、リフレッシュトークンで更新
if (error.response && error.response.status === 401) {
const refreshResponse = await auth.getRefreshTokenFromConsols(refreshToken);
// 新しいトークンをCookieに設定
accessToken = refreshResponse.data[HEADER_ACCESSTOKEN_NAME];
refreshToken = refreshResponse.data[HEADER_REFRESHTOKEN_NAME];
res.cookie(HEADER_ACCESSTOKEN_NAME, accessToken, cookieOptions);
res.cookie(HEADER_REFRESHTOKEN_NAME, refreshToken, cookieOptions);
// 再度ユーザー情報を取得
response = await getUserInfo(accessToken);
} else {
throw new Error(REDIRECT_LOGIN);
}
}
return { user: response.data.user, accessToken, refreshToken };
};
特定機能専用の認証フロー(CSRF対策付き)
特定の機能(例:外部システム連携ページ)では、認証後に元のページに戻る必要があります。
この場合、CSRF攻撃を防ぐためにstateパラメータを使用した追加のセキュリティ対策を実装します。
フロー概要
ユーザー → 特定機能ページ → 認証チェック → Cognito認証(state付き) → コールバック → 元のページ
実装の特徴
-
CSRF対策: ランダムな
state値を生成し、CookieとURLパラメータで検証 -
コンテキスト保持: 認証前にアクセスしていたページ情報を
stateに含めて保持 -
有効期限:
stateは5分間のみ有効
実装詳細
1. 認証チェックミドルウェア(特定機能専用)
module.exports.checkAccessTokenForSpecificFeature = async (req, res, next) => {
const resourceId = req.params.resource_id;
// リソースIDの検証(数値のみ許可)
if (!resourceId || !/^\d+$/.test(resourceId)) {
return res.redirect('/error/404');
}
// トークンが存在しない場合、Cognito認証へリダイレクト
if (!has(req.cookies, HEADER_ACCESSTOKEN_NAME) ||
!has(req.cookies, HEADER_REFRESHTOKEN_NAME)) {
// Cognito認証URLを構築(stateを含む)
const { authUrl, state } = auth.buildCognitoAuthUrlForSpecificFeature();
// stateとリソースIDの紐付けをCookieに保存(5分間有効)
const stateData = {
state,
resourceId,
timestamp: Date.now()
};
res.cookie('feature_state', JSON.stringify(stateData), {
...cookieOptions,
httpOnly: true,
maxAge: 300000, // 5分
sameSite: 'lax'
});
return res.redirect(authUrl);
}
// トークンが存在する場合は通常の検証処理
try {
const { user, accessToken, refreshToken } = await validateTokenAndGetUserInfo(req, res);
setUserCookies(res, user, accessToken, refreshToken, req);
next();
} catch (error) {
if (error.message === REDIRECT_LOGIN) {
return res.redirect('/login');
}
throw error;
}
}
2. Cognito認証URLの構築
buildCognitoAuthUrlForSpecificFeature() {
const cognitoAuthorizeURL = this.config.cognitoAuthorizeURL;
// ランダムなstate ID生成(CSRF対策)
const state = crypto.randomBytes(16).toString('hex');
// Cognito認証URL構築
const redirectUri = `${APP_URL}/auth/callback/feature`;
const authUrl = `${cognitoAuthorizeURL}?` +
`response_type=code&` +
`client_id=${COGNITO_CLIENT_ID}&` +
`redirect_uri=${encodeURIComponent(redirectUri)}&` +
`state=${state}`;
return { authUrl, state };
}
3. コールバックエンドポイント(特定機能専用)
router.get('/auth/callback/feature', (req, res, next) => {
(async () => {
// 1. codeパラメータの検証
if (!has(req.query, 'code') || !req.query.code) {
return res.redirect(REDIRECT_FAILURE_URL);
}
// 2. stateパラメータの検証
if (!has(req.query, 'state') || !req.query.state) {
return res.redirect(REDIRECT_FAILURE_URL);
}
const receivedState = req.query.state;
// 3. Cookieからstate情報を取得
if (!has(req.cookies, 'feature_state') || !req.cookies.feature_state) {
return res.redirect(REDIRECT_FAILURE_URL);
}
let stateData;
try {
stateData = JSON.parse(req.cookies.feature_state);
} catch (error) {
return res.redirect(REDIRECT_FAILURE_URL);
}
// 4. state検証(CSRF対策)
if (stateData.state !== receivedState) {
console.error(`State mismatch. Expected: ${stateData.state}, Received: ${receivedState}`);
return res.redirect(REDIRECT_FAILURE_URL);
}
// 5. タイムスタンプ検証(5分以内)
const now = Date.now();
const elapsed = now - stateData.timestamp;
if (elapsed < 0 || elapsed > 300000) { // 5分 = 300000ms
console.error(`State expired. Elapsed: ${elapsed}ms`);
return res.redirect(REDIRECT_FAILURE_URL);
}
const resourceId = stateData.resourceId;
// 6. リソースID検証(数値のみ)
if (!resourceId || !/^\d+$/.test(resourceId)) {
return res.redirect('/error/404');
}
// 7. 認可コードをトークンに交換
const redirectUri = `${process.env.APP_URL}/auth/callback/feature`;
const tokens = await auth.getTokenFromConsols(req.query.code, redirectUri);
// 8. トークン検証
const decoded = auth.verifyToken(tokens.id_token);
// 9. トークンをCookieに設定
res.cookie(HEADER_ACCESSTOKEN_NAME, tokens[HEADER_ACCESSTOKEN_NAME], cookieOptions);
res.cookie(HEADER_REFRESHTOKEN_NAME, tokens[HEADER_REFRESHTOKEN_NAME], cookieOptions);
// 10. ユーザー情報を取得
const response = await api.get('/userinfo', config);
// 11. 使用済みのstate Cookieを削除
res.clearCookie('feature_state', cookieOptions);
// 12. 元のページへリダイレクト
const redirectUrl = `${process.env.APP_URL}/feature/${resourceId}`;
res.redirect(redirectUrl);
})().catch((error) => {
console.error('Callback error:', error.message);
res.redirect(REDIRECT_FAILURE_URL);
});
});
セキュリティ対策
1. CSRF対策
- stateパラメータ: ランダムな値を生成し、CookieとURLパラメータで二重検証
- 有効期限: stateは5分間のみ有効
- タイムスタンプ検証: 経過時間をチェックしてリプレイ攻撃を防止
2. トークン管理
- HttpOnly Cookie: リフレッシュトークンはHttpOnly Cookieに保存(XSS対策)
- Secure Cookie: HTTPS接続時のみCookieを送信
- SameSite属性: CSRF攻撃を軽減
3. 入力検証
- リソースID検証: 数値のみを許可する正規表現で検証
- パラメータ存在チェック: 必須パラメータの存在を確認
AWS Cognitoのpromptパラメータ
Cognitoの認証エンドポイントでは、promptパラメータを使用して認証動作を制御できます。
パラメータ値と動作
| 値 | 動作 |
|---|---|
| (なし) | セッションがあればログイン画面がスキップされる。セッションがない場合はログイン画面が表示される |
none |
セッションがあればそのまま処理される。セッションがない場合はlogin_requiredエラーになる |
login |
セッションの有無に関わらずログイン画面が表示される(再認証) |
使用例
サイレント認証(prompt=none)
既存のセッションがある場合、ユーザーにログイン画面を表示せずに認証を完了します。
GET /oauth2/authorize?
response_type=code&
client_id=YOUR_CLIENT_ID&
redirect_uri=https://example.com/callback&
state=YOUR_STATE&
prompt=none
強制再認証(prompt=login)
既存のセッションがあっても、ユーザーに再度ログインを要求します。
GET /oauth2/authorize?
response_type=code&
client_id=YOUR_CLIENT_ID&
redirect_uri=https://example.com/callback&
state=YOUR_STATE&
prompt=login
実装での活用
現在の実装ではpromptパラメータは使用していませんが、以下のような用途で活用できます:
-
prompt=none: バックグラウンドでの認証状態確認 -
prompt=login: 重要な操作前の再認証要求
エラーハンドリング
エラーケースと対応
- 認可コードなし: ログイン失敗ページへリダイレクト
- state不一致: CSRF攻撃の可能性があるため、ログイン失敗ページへリダイレクト
- state有効期限切れ: セキュリティ上の理由でログイン失敗ページへリダイレクト
- トークン取得失敗: ログイン失敗ページへリダイレクト
- トークン検証失敗: ログイン失敗ページへリダイレクト
- ユーザー情報取得失敗: ログイン失敗ページへリダイレクト
まとめ
この認証ミドルウェアの実装により、以下の機能を提供しています:
- 通常の認証フロー: 標準的なOAuth 2.0認証フロー
- 特定機能専用の認証フロー: CSRF対策付きで、認証後に元のページへ戻る機能
- トークン自動リフレッシュ: アクセストークンが無効になった場合の自動更新
- セキュアな実装: state検証、有効期限チェック、入力検証によるセキュリティ対策