shou2017.com
JP / EN

ApiGatewayのアクセス制御をlambdaオーソライザーでやってみる

Fri May 31, 2024
Sat Aug 10, 2024

はじめに

ApiGatewayのアクセス制御をしたい場合は、Cognitoかlambdaオーソライザーを使うことで実現できます。公式にもドキュメントがありますが、ざっくりしたした理解だと図のような感じです。

ApiGatewayのアクセス制御

ApiGatewayのアクセス制御は例えばログインしたユーザーしかapiを呼ぶことできないようにしたい場合などに使用します。このような場合、簡単な実装方法はCognitoを使用することです。

使用しているCognitoのUserPoolを指定するだけで実装できちゃいます。AWS CDKなどを使用している場合は以下のような感じで実装できます。

// CDK(Typescript)の場合
const userPool = new cognito.UserPool(this, 'UserPool');

const auth = new apigateway.CognitoUserPoolsAuthorizer(this, 'booksAuthorizer', {
  cognitoUserPools: [userPool]
});

declare const books: apigateway.Resource;
books.addMethod('GET', new apigateway.HttpIntegration('http://amazon.com'), {
  authorizer: auth,
  authorizationType: apigateway.AuthorizationType.COGNITO,
});

Cognitoは認証に成功するとフロントにトークンを返し、そのトークンを使ってApiGatewayにアクセスすることができます。ただし、この方法には問題があります。 CookieにHttpOnly属性 を設定できないということです。

実装する要件によっては、CookieにHttpOnly属性 を設定したい場合があります。このほかにもカスタムで何かを設定したい場合があってもAuthorizationType.COGNITOだと難しいです。その場合はlambdaオーソライザーを使用することで実現できます。

lambdaオーソライザー

lambdaオーソライザーと言っても実際にはlambda関数を使用してアクセス制御を行うことです。なのでかなり柔軟にアクセス制御を行うことができます。今回は認証にはCognitoを使用し、アクセス制御にlambdaオーソライザーを使用する方法を紹介します。

api gatewayとlambdaの紐付け

今回の要件は認証を受けたapiを呼び出すことです。まずはapi gatewayとlambdaを紐付けます。

lambdaはapi gatewayを呼び出すためその権限を持っている必要があります。 そして、今回はCookieにトークンをセットするので、method.request.header.CookieをidentitySourceに指定しています。

CookieにはhttpOnly属性を設定するためフロントでCookieのトークンを取得することはできないのでapi gatewayにヘッダーにCookieをセットする必要があります。api gatewayのrequestParameters"integration.request.header.Cookie": "method.request.header.Cookie",を設定します。

これで紐付けされたlambdaオーソライザーにCookieが渡されるので、あとはlambda関数で認証を行い、アクセス制御を行います。

// CDK(Typescript)の場合
// lambdaAuthorizerのロールを作成
const lambdaAuthorizerRole = new Role(this, "LambdaAuthorizerRole", {
  roleName: LambdaAuthorizerRole,
  assumedBy: new ServicePrincipal("apigateway.amazonaws.com"),
  description: Lambda Authorizer Role,
});
// lambdaAuhorizerを設定
const lambdaAuthorizer = new TokenAuthorizer(this, "LambdaAuthorizer", {
  handler: authorizerFunc,lambda関数
  identitySource: "method.request.header.Cookie",
  assumeRole: lambdaAuthorizerRole,
});

// このようにlambdaAuthorizerを指定することでapi gatewayにアクセス制御をかけることができる
ApiEndPoint.addMethod(
"POST",
  new LambdaIntegration(Lambda),
  {
    authorizer: lambdaAuthorizer,
    authorizationType: AuthorizationType.CUSTOM,
  }
);

これでapi gatewayとlambdaが紐付けられました。次はlambda関数を作成します。

lambda関数

lambda関数は以下のような感じで実装できます。ここではGetUserCommandを使用してユーザー情報を取得しています。

GetUserCommandのparamsにはAccessTokenが必要です。単純にAccessTokenだけをGetUserCommandに渡して終わりみたいな処理をしてしまっていると他で発行されてたAccessTokenでもこの認証を通り抜けてしまうのでセキュリティ上よろしくないです。

なので、きちんとAccessTokenを検証する必要があります。有名どころのjsonwebtokenを使用する方法もありますが、ここではAWSLabsが提供しているaws-jwt-verifyを使用しています。

import {
  CognitoIdentityProvider,
  GetUserCommand,
  GetUserCommandInput,
  GetUserCommandOutput,
} from "@aws-sdk/client-cognito-identity-provider";
import { CognitoJwtVerifier } from "aws-jwt-verify";
import { apiGatewayTokenAuthorizerEvent } from "aws-lambda";

async function handler(event: apiGatewayTokenAuthorizerEvent) {
  // Get the cookies
  const cookies = event.authorizationToken || "";
  const userPoolId = process.env.USER_POOL_ID;
  const clientId = process.env.CLIENT_ID;
  const client = new CognitoIdentityProvider({});
  // accessTokenを取得
  const accessToken = cookies
    .split(";")
    .find((row) => row.trim().startsWith("accessToken"))
    ?.split("=")[1];
  // 値の取得ができなかった場合
  if (!accessToken || !userPoolId || !clientId) {
    return {
      principalId: "user",
      policyDocument: {
        Version: "2012-10-17",
        Statement: [
          {
            Action: "execute-api:Invoke",
            Effect: "Deny",
            Resource: "*",
          },
        ],
      },
    };
  }
  try {
    // accessTokenの検証
    const verify = CognitoJwtVerifier.create({
      userPoolId: userPoolId,
      tokenUse: "access",
      clientId: clientId,
    });
    const payload = await verify.verify(accessToken);
    console.log("Token is valid. Payload:", payload);
    // ユーザー情報の取得
    const params: GetUserCommandInput = {
      AccessToken: accessToken,
    };
    const command = new GetUserCommand(params);
    const response: GetUserCommandOutput = await client.send(command);
    const emailAttribute = response.UserAttributes?.find(
      ({ Name }) => Name === "email",
    );
    const email = emailAttribute?.Value;
    return {
      principalId: "user",
      policyDocument: {
        Version: "2012-10-17",
        Statement: [
          {
            Action: "execute-api:Invoke",
            Effect: "Allow",
            Resource: "*",
          },
        ],
      },
      context: {
        email: email,
      },
    };
  } catch (error) {
    console.error("Error:", error);
    return {
      principalId: "user",
      policyDocument: {
        Version: "2012-10-17",
        Statement: [
          {
            Action: "execute-api:Invoke",
            Effect: "Deny",
            Resource: "*",
          },
        ],
      },
    };
  }
}

export { handler };

ハマりどころ

AccessTokenの検証とポリシーの設定

今回のケースはlambdaオーソライザーでcognitoのGetUserCommandを使用していますが、lambdaオーソライザーにはGetUserCommandの権限は付与していません。ですが、GetUserCommandは問題なく動作します。

なぜなら、GetUserCammandはAccessTokenのパラメータからユーザーを取得するだけで、特にUserPoolなどの情報や認証情報は不要です。 最初はなんで動いているのか不思議で、アレコレIAMロールの設定を見たりしてました。

要はAccessTokenはきちんと検証しておきましょうということです。

execute-api:Invokeのリソース

lambdaオーソライザーで認証が通った場合は以下のように返してますが、Resourceには*を指定しています。

return {
  principalId: "user",
  policyDocument: {
    Version: "2012-10-17",
    Statement: [
      {
        Action: "execute-api:Invoke",
        Effect: "Allow",
        Resource: "*",
      },
    ],
  },
  context: {
    email: email,
  },
};

最初は、api GatewayのARNを指定する必要があるのではないかと思い、api GatewayのARNを指定していましたがベストプラクティスに従い最小限のポリシーを付与しようと思い、テストで誤ったapi GatewayのARNを指定してみましたが、あら不思議、動作しました。

あれ、これってセキュリティ的に問題なのでは?と思い。また、アレコレ見てましたが、原因がわかりました。

lambdaオーソライザーとapi gatewayを紐づけるときにapiを指定しているので、Resourceに誤ったapi GatewayのARNを指定しても動作するのです。

ApiEndPoint.addMethod(
"POST",
  new LambdaIntegration(Lambda),
  {
    authorizer: lambdaAuthorizer,
    authorizationType: AuthorizationType.CUSTOM,
  }
);

よくよく考えれば、この記述だと指定したapiに対してのみauthorizerが適用されるので、Resource*を指定しても問題ないですね。

See Also