0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AWS Cognitoを使った認証ミドルウェア

Posted at

認証ミドルウェアの実装解説

概要

このドキュメントでは、AWS Cognitoを使用したOAuth 2.0認証フローを実装した認証ミドルウェアについて説明します。
通常の認証フローに加えて、特定の機能専用の認証フロー(CSRF対策付き)の実装方法を解説します。

アーキテクチャ

認証システムは以下の3つの主要コンポーネントで構成されています:

  1. 認証ルート (routes/auth.js) - Cognitoからのコールバックを処理
  2. 認証ミドルウェア (middlewares/auth.js) - リクエストの認証チェック
  3. 認証ライブラリ (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とは

  1. CSRF対策: ランダムなstate値を生成し、CookieとURLパラメータで検証
  2. コンテキスト保持: 認証前にアクセスしていたページ情報をstateに含めて保持
  3. 有効期限: 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: 重要な操作前の再認証要求

エラーハンドリング

エラーケースと対応

  1. 認可コードなし: ログイン失敗ページへリダイレクト
  2. state不一致: CSRF攻撃の可能性があるため、ログイン失敗ページへリダイレクト
  3. state有効期限切れ: セキュリティ上の理由でログイン失敗ページへリダイレクト
  4. トークン取得失敗: ログイン失敗ページへリダイレクト
  5. トークン検証失敗: ログイン失敗ページへリダイレクト
  6. ユーザー情報取得失敗: ログイン失敗ページへリダイレクト

まとめ

この認証ミドルウェアの実装により、以下の機能を提供しています:

  1. 通常の認証フロー: 標準的なOAuth 2.0認証フロー
  2. 特定機能専用の認証フロー: CSRF対策付きで、認証後に元のページへ戻る機能
  3. トークン自動リフレッシュ: アクセストークンが無効になった場合の自動更新
  4. セキュアな実装: state検証、有効期限チェック、入力検証によるセキュリティ対策
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?