【AWS CDK】AWS Client VPN + AWS SSO + AWS WAFでCloudFrontへのアクセスを制御する

【AWS CDK】AWS Client VPN + AWS SSO + AWS WAFでCloudFrontへのアクセスを制御する
この記事をシェアする

こんにちは、クラウドビルダーズのきむです。

AWS Client VPNというサービスは以前から知っていたのですが、なかなか利用する機会がなかったのでずっと触れずにいました。今回は導入に伴ってようやく触ることができたので備忘録も兼ねて記事を残そうと思います。

ちなみにAWS Client VPNの記事は弊社のKotaroも執筆しているのでぜひ合わせてチェックしてみてください。

一部手動で作成していますが、主要リソースはAWS CDKを使って作成していきます。

構成図

下記の構成を作成していきます。

最終的なフォルダ構成は下記になります。

.
├── README.md
├── bin
│   └── my-cdk.ts
├── cdk.context.json
├── cdk.json
├── dist
│   ├── assets
│   │   ├── index-DiwrgTda.css
│   │   ├── index-f40OySzR.js
│   │   └── react-CHdo91hT.svg
│   ├── index.html
│   └── vite.svg
├── eslint.config.mjs
├── jest.config.js
├── lib
│   ├── construct
│   │   ├── cdn.ts
│   │   ├── client-vpn.ts
│   │   ├── iam.ts
│   │   ├── network.ts
│   │   ├── update-ipset.ts
│   │   └── waf.ts
│   ├── metadata
│   │   ├── client-vpn.xml
│   │   └── self-portal.xml
│   ├── my-cdk-stack.ts
│   ├── my-cdk-waf-stack.ts
│   └── src
│       └── index.ts
├── package-lock.json
├── package.json
├── test
│   └── my-cdk.test.ts
├── tsconfig.json
└── tsconfig.path.json

IAM Identity Center アプリケーションの作成

IAM Identity CenterのコンソールからクライアントVPN用とセルフポータル用の2つのアプリケーションを作成します。

メタデータファイルは後ほど使うのでダウンロードしてリネームをします。

入力が必要なパラメータは下記の値としています。

項目クライアントVPN用セルフポータル用
表示名AWS Client VPNAWS Client VPN Self Portal
説明AWS Client VPNAWS Client VPN Self Portal
アプリケーション開始URL
リレー状態
アプリケーション ACS URLhttp://127.0.0.1:35001https://self-service.clientvpn.amazonaws.com/api/auth/sso/saml
アプリケーション SAML 対象者urn:amazon:webservices:clientvpnurn:amazon:webservices:clientvpn
メタデータファイル名client-vpn.xmlself-portal.xml

アプリケーションの作成が完了したら属性マッピングを編集します。編集内容は両方とも同じです。

アプリケーションのユーザー属性この文字列値または IAM Identity Center の
ユーザー属性にマッピング
形式
Subject${user:email}emailAddress
memberOf${user:groups}unspecifield

任意のユーザとグループを割り当てればIAM Identity Center側の作業は完了です。

AWS CDKの作成

AWS CDKで必要なリソースを作成します。

AWS CDK内でACMを作成していますが、DNSドメイン検証に必要なCNAMEレコードは事前に登録済みなので手順等については割愛します。

~/dist/直下に配置しているファイルはnpm create vite@latestで作成したReact Appをbuildしたものを使用しています。デフォルトで作成されたものから修正を加えずにbuildしたものなのでこちらも割愛します。

先ほどダウンロードしたメタデータを~/lib/metadata/直下に配置します。

.
└── lib
    └── metadata
        ├── client-vpn.xml
        └── self-portal.xml

エントリポイントの作成

~/bin/にcdkのエントリポイントを作成します

#!/usr/bin/env node
/* eslint-disable no-undef */
import * as cdk from 'aws-cdk-lib';

import { MyCDKStack } from '../lib/my-cdk-stack';
import { MyCDKWafStack } from '../lib/my-cdk-waf-stack';

import 'source-map-support/register';

const app = new cdk.App();

const env = {
  account: process.env.CDK_DEFAULT_ACCOUNT,
  region: process.env.CDK_DEFAULT_REGION,
} as const satisfies cdk.Environment;

const wafStack = new MyCDKWafStack(app, 'my-cdk-waf-stack', {
  env: {
    account: env.account,
    region: 'us-east-1',
  },
  crossRegionReferences: true,
});

new MyCDKStack(app, 'my-cdk-stack', {
  env,
  webAcl: wafStack.webAcl,
  ipSet: wafStack.ipSet,
  crossRegionReferences: true,
});

WAFはCloudFrontに紐づける必要があるのでus-east-1でスタックを作成します。

crossRegionReferences: trueとすることでクロスリージョンでの参照が可能となります。

CloudFrontに紐づける用のwebAclとカスタムリソースで使用するipSetをMyCDKStackに渡しています。

constructの作成

~/lib/construct/に各リソースを作成していきます。

import { Construct } from 'constructs';

import * as wafv2 from 'aws-cdk-lib/aws-wafv2';

export class WafStack extends Construct {
  public webAcl: wafv2.CfnWebACL;
  public ipSet: wafv2.CfnIPSet;

  constructor(scope: Construct, id: string) {
    super(scope, id);

    // IPSet
    const ipSet = new wafv2.CfnIPSet(this, 'WhiteListIPSet', {
      name: 'white-list-rule',
      addresses: ['255.255.255.255/32'],
      ipAddressVersion: 'IPV4',
      scope: 'CLOUDFRONT',
    });

    // WebAcl
    const webAcl = new wafv2.CfnWebACL(this, 'WebAcl', {
      name: 'web-acl',
      defaultAction: {
        block: {},
      },
      scope: 'CLOUDFRONT',
      visibilityConfig: {
        sampledRequestsEnabled: true,
        cloudWatchMetricsEnabled: true,
        metricName: 'default-block',
      },
      rules: [
        {
          name: 'allow-white-list',
          priority: 0,
          action: { allow: {} },
          visibilityConfig: {
            sampledRequestsEnabled: true,
            cloudWatchMetricsEnabled: true,
            metricName: 'allow-white-list',
          },
          statement: {
            ipSetReferenceStatement: {
              arn: ipSet.attrArn,
            },
          },
        },
      ],
    });

    this.webAcl = webAcl;
    this.ipSet = ipSet;
  }
}

WAFのデプロイ時にはNatGatewayにアタッチされるEIPがまだ生成されていないので、IPセットを仮の値で設定しています。my-cdk-stackのデプロイ時にカスタムリソースでIPセットを更新します

import { Construct } from 'constructs';

import * as iam from 'aws-cdk-lib/aws-iam';

import path from 'path';

export class IamStack extends Construct {
  public clientVPNProvider: iam.SamlProvider;
  public selfPortalProvider: iam.SamlProvider;

  constructor(scope: Construct, id: string) {
    super(scope, id);

    // ClientVPN Provider
    const clientVpnProvider = new iam.SamlProvider(this, 'ClientVPNProvider', {
      name: 'client-vpn-provider',
      metadataDocument: iam.SamlMetadataDocument.fromFile(
        path.join(__dirname, '../metadata/client-vpn.xml')
      ),
    });

    // SelfPortal Provider
    const selfPortalProvider = new iam.SamlProvider(
      this,
      'SelfPortalProvider',
      {
        name: 'self-portal-provider',
        metadataDocument: iam.SamlMetadataDocument.fromFile(
          path.join(__dirname, '../metadata/self-portal.xml')
        ),
      }
    );

    this.clientVPNProvider = clientVpnProvider;
    this.selfPortalProvider = selfPortalProvider;
  }
}

iam.tfではSSO認証に使用するIdPを作成しています。ここで先ほどダウンロードしたメタデータファイルはここで使用しています。

import { Construct } from 'constructs';

import * as ec2 from 'aws-cdk-lib/aws-ec2';

export class NetworkStack extends Construct {
  public vpc: ec2.IVpc;
  public vpnSg: ec2.ISecurityGroup;
  public eips: string[];

  constructor(scope: Construct, id: string) {
    super(scope, id);

    // VPC
    const vpc = new ec2.Vpc(this, 'Vpc', {
      vpcName: 'vpc',
      ipAddresses: ec2.IpAddresses.cidr('172.16.0.0/16'),
      maxAzs: 2,
      natGateways: 1,
      createInternetGateway: true,
      enableDnsHostnames: true,
      enableDnsSupport: true,
      ipProtocol: ec2.IpProtocol.IPV4_ONLY,
      subnetConfiguration: [
        {
          cidrMask: 24,
          name: 'public-subnet',
          subnetType: ec2.SubnetType.PUBLIC,
        },
        {
          cidrMask: 24,
          name: 'vpn-subnet',
          subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS,
        },
      ],
    });

    // SecurityGroup
    const vpnSg = new ec2.SecurityGroup(this, 'VpnSecurityGroup', {
      vpc,
      securityGroupName: 'vpn-sg',
      allowAllIpv6Outbound: true,
      allowAllOutbound: true,
    });

    // Get NatGateway EIPs
    const eips = vpc.publicSubnets
      .map((subnet) => {
        const eip = subnet.node.children.find(
          (child) => child.node.id === 'EIP'
        );
        return eip instanceof ec2.CfnEIP ? eip.ref : undefined;
      })
      .filter((ref) => ref !== undefined);

    this.vpc = vpc;
    this.vpnSg = vpnSg;
    this.eips = eips;
  }
}

NatGatewayのEIPはmapとfilterメソッドを使用して配列形式で取得します。

import * as cdk from 'aws-cdk-lib';

import { Construct } from 'constructs';

import * as acm from 'aws-cdk-lib/aws-certificatemanager';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as logs from 'aws-cdk-lib/aws-logs';

interface Props {
  vpc: ec2.IVpc;
  vpnSg: ec2.ISecurityGroup;
  clientVPNProvider: iam.SamlProvider;
  selfPortalProvider: iam.SamlProvider;
}

export class ClientVpnStack extends Construct {
  constructor(scope: Construct, id: string, props: Props) {
    super(scope, id);

    const { vpc, vpnSg, clientVPNProvider, selfPortalProvider } = props;

    // CloudWatchLogs
    const vpnLogs = new logs.LogGroup(this, 'LogGroup', {
      logGroupName: '/aws/clientvpn/connection-log',
      retention: logs.RetentionDays.ONE_WEEK,
    });

    // ACM
    const certificate = new acm.Certificate(this, 'Certificate', {
      domainName: 'vpn.example.com',
      validation: acm.CertificateValidation.fromDns(),
    });

    // ClientVPNEndpoint
    const endpoint = vpc.addClientVpnEndpoint('Endpoint', {
      cidr: '172.17.0.0/22',
      serverCertificateArn: certificate.certificateArn,
      authorizeAllUsersToVpcCidr: false,
      logGroup: vpnLogs,
      logging: true,
      port: 443,
      securityGroups: [vpnSg],
      selfServicePortal: true,
      sessionTimeout: ec2.ClientVpnSessionTimeout.EIGHT_HOURS,
      splitTunnel: false,
      transportProtocol: ec2.TransportProtocol.UDP,
      userBasedAuthentication: ec2.ClientVpnUserBasedAuthentication.federated(
        clientVPNProvider,
        selfPortalProvider
      ),
      vpcSubnets: { subnets: [vpc.privateSubnets[0]!] },
    });

    // AuthorizationRule
    endpoint.addAuthorizationRule('InternetRule', {
      cidr: '0.0.0.0/0',
    });

    // InternetRoute
    endpoint.addRoute('InternetRoute', {
      cidr: '0.0.0.0/0',
      target: vpc.privateSubnets[0]!,
    });
  }
}

ACMのドメイン名を記事の執筆用にvpn.example.comとしていますが、実際の値はRoute53に登録しているドメイン名としています。

import * as cdk from 'aws-cdk-lib';

import { Construct } from 'constructs';

import * as cloudfront from 'aws-cdk-lib/aws-cloudfront';
import * as cloudfront_origins from 'aws-cdk-lib/aws-cloudfront-origins';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as s3_deployment from 'aws-cdk-lib/aws-s3-deployment';
import * as wafv2 from 'aws-cdk-lib/aws-wafv2';

import path from 'path';

interface Props {
  webAcl: wafv2.CfnWebACL;
}

export class CdnStack extends Construct {
  constructor(scope: Construct, id: string, props: Props) {
    super(scope, id);

    const { webAcl } = props;

    // OriginS3Bucket
    const defaultOrigin = new s3.Bucket(this, 'DefaultOrigin', {
      bucketName: `web-bucket-${cdk.Stack.of(this).account}`,
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      encryption: s3.BucketEncryption.S3_MANAGED,
      enforceSSL: true,
      versioned: true,
      removalPolicy: cdk.RemovalPolicy.DESTROY,
    });

    // CloudFrontDistribution
    const distribution = new cloudfront.Distribution(this, 'Distribution', {
      defaultRootObject: 'index.html',
      defaultBehavior: {
        origin: new cloudfront_origins.S3Origin(defaultOrigin),
        allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD,
        cachedMethods: cdk.aws_cloudfront.CachedMethods.CACHE_GET_HEAD,
        cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
        compress: true,
        viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
      },
      enabled: true,
      httpVersion: cloudfront.HttpVersion.HTTP2_AND_3,
      priceClass: cloudfront.PriceClass.PRICE_CLASS_200,
      webAclId: webAcl.attrArn,
    });

    // OAC
    const cfnOriginAccessControl = new cloudfront.CfnOriginAccessControl(
      this,
      'OriginAccessControl',
      {
        originAccessControlConfig: {
          name: 'OriginAccessControlForContentsBucket',
          originAccessControlOriginType: 's3',
          signingBehavior: 'always',
          signingProtocol: 'sigv4',
          description: 'Access Control',
        },
      }
    );

    const cfnDistribution = distribution.node
      .defaultChild as cloudfront.CfnDistribution;

    cfnDistribution.addPropertyOverride(
      'DistributionConfig.Origins.0.OriginAccessControlId',
      cfnOriginAccessControl.attrId
    );

    cfnDistribution.addPropertyOverride(
      'DistributionConfig.Origins.0.DomainName',
      defaultOrigin.bucketRegionalDomainName
    );

    cfnDistribution.addPropertyOverride(
      'DistributionConfig.Origins.0.S3OriginConfig.OriginAccessIdentity',
      ''
    );

    // BucketPolicy
    const bucketPolicyStatement = new iam.PolicyStatement({
      actions: ['s3:GetObject'],
      effect: iam.Effect.ALLOW,
      principals: [new iam.ServicePrincipal('cloudfront.amazonaws.com')],
      resources: [`${defaultOrigin.bucketArn}/*`],
      conditions: {
        StringEquals: {
          'AWS:SourceArn': `arn:aws:cloudfront::${
            cdk.Stack.of(this).account
          }:distribution/${distribution.distributionId}`,
        },
      },
    });

    defaultOrigin.addToResourcePolicy(bucketPolicyStatement);

    // React Deploy
    new s3_deployment.BucketDeployment(this, 'DeployContents', {
      sources: [s3_deployment.Source.asset(path.join(__dirname, '../../dist'))],
      destinationBucket: defaultOrigin,
      distribution: distribution,
      distributionPaths: ['/*'],
    });
  }
}

import * as cdk from 'aws-cdk-lib';

import { Construct } from 'constructs';

import * as iam from 'aws-cdk-lib/aws-iam';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as nodejs from 'aws-cdk-lib/aws-lambda-nodejs';
import * as cr from 'aws-cdk-lib/custom-resources';

import { Props } from '../../lib/src';
import * as path from 'path';

export class UpdateIPSetStack extends Construct {
  constructor(scope: Construct, id: string, props: Props) {
    super(scope, id);

    const policyStatement = new iam.PolicyStatement({
      effect: iam.Effect.ALLOW,
      actions: ['wafv2:UpdateIPSet', 'wafv2:ListIPSets'],
      resources: ['*'],
    });

    const lambdaFunction = new nodejs.NodejsFunction(this, 'CustomFunction', {
      runtime: lambda.Runtime.NODEJS_20_X,
      entry: path.join(__dirname, '../src/index.ts'),
      handler: 'handler',
      initialPolicy: [policyStatement],
    });

    const provider = new cr.Provider(this, 'Provider', {
      onEventHandler: lambdaFunction,
    });

    new cdk.CustomResource(this, 'CustomResource', {
      serviceToken: provider.serviceToken,
      properties: props,
    });
  }
}

カスタムリソース用のLambdaのソースコードは~/lib/src/に配置しています。

us-east-1で実行される必要があるのでclientのregionをus-east-1としています。

CreateとUpdateは両方とも同じ処理を行います。Delete時には処理は不要なのでreturn {}としています。

import {
  ListIPSetsCommand,
  ListIPSetsCommandInput,
  ListIPSetsCommandOutput,
  Scope,
  UpdateIPSetCommand,
  UpdateIPSetCommandInput,
  UpdateIPSetCommandOutput,
  WAFV2Client,
} from '@aws-sdk/client-wafv2';
import { CdkCustomResourceHandler } from 'aws-lambda';

const client = new WAFV2Client({ region: 'us-east-1' });

export interface Props {
  eips: string[];
  ipSetName: string;
}

export const handler: CdkCustomResourceHandler = async (event) => {
  const props: Props = {
    eips: event.ResourceProperties.eips,
    ipSetName: event.ResourceProperties.ipSetName,
  };

  if (!event.RequestType) throw new Error('Failed');
  else if (event.RequestType === 'Delete') return {};

  await overRideIpSet(props);
  return {};
};

const overRideIpSet = async (
  props: Props
): Promise<UpdateIPSetCommandOutput> => {
  const res = await listIpSet();
  const response = await updateIpSet(res, props);

  return response;
};

const listIpSet = async (): Promise<ListIPSetsCommandOutput> => {
  const input = {
    Scope: Scope.CLOUDFRONT,
  } as const satisfies ListIPSetsCommandInput;

  const command = new ListIPSetsCommand(input);

  const response = await client.send(command);

  return response;
};

const updateIpSet = async (
  res: ListIPSetsCommandOutput,
  props: Props
): Promise<UpdateIPSetCommandOutput> => {
  const { ipSetName } = props;
  const eips = props.eips.map((eip) => `${eip}/32`);

  const filter = res.IPSets?.filter((ipSet) => ipSet.Name === ipSetName);
  if (!filter) throw new Error('IPSet Not Found');

  const [lockToken, id] = [filter[0]?.LockToken, filter[0]?.Id];

  const input = {
    Name: ipSetName,
    Scope: Scope.CLOUDFRONT,
    Id: id,
    Addresses: eips,
    LockToken: lockToken,
  } as const satisfies UpdateIPSetCommandInput;

  const command = new UpdateIPSetCommand(input);

  const response = await client.send(command);

  return response;
};

stackの作成

最後に~/lib/に配置しているstackファイルです。

import * as cdk from 'aws-cdk-lib';

import { Construct } from 'constructs';

import * as wafv2 from 'aws-cdk-lib/aws-wafv2';

import { WafStack } from './construct/waf';

export class MyCDKWafStack extends cdk.Stack {
  public webAcl: wafv2.CfnWebACL;
  public ipSet: wafv2.CfnIPSet;

  constructor(scope: Construct, id: string, props: cdk.StackProps) {
    super(scope, id, props);

    const waf = new WafStack(this, 'WAF');

    this.webAcl = waf.webAcl;
    this.ipSet = waf.ipSet;
  }
}

import * as cdk from 'aws-cdk-lib';

import { Construct } from 'constructs';

import * as wafv2 from 'aws-cdk-lib/aws-wafv2';

import { CdnStack } from './construct/cdn';
import { ClientVpnStack } from './construct/client-vpn';
import { IamStack } from './construct/iam';
import { NetworkStack } from './construct/network';
import { UpdateIPSetStack } from './construct/update-ipset';

interface Props extends cdk.StackProps {
  webAcl: wafv2.CfnWebACL;
  ipSet: wafv2.CfnIPSet;
}

export class MyCDKStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props: Props) {
    super(scope, id, props);

    const iam = new IamStack(this, 'Iam');

    const network = new NetworkStack(this, 'Network');

    new ClientVpnStack(this, 'ClientVPN', {
      vpc: network.vpc,
      vpnSg: network.vpnSg,
      clientVPNProvider: iam.clientVPNProvider,
      selfPortalProvider: iam.selfPortalProvider,
    });

    new CdnStack(this, 'CloudFront', { ...props });

    new UpdateIPSetStack(this, 'UpdateIpStack', {
      eips: network.eips,
      ipSetName: props.ipSet.name!,
    });
  }
}

ここまででデプロイに必要なリソースの準備は完了しました。デプロイして動作を確認してみます。

動作確認

VPCのコンソールから作成したClientVPNエンドポイントを選択しましょう。

セルフサービスポータルのURLにアクセスするとAWS SSO Loginの画面が表示されるので、アプリケーションに追加したユーザでログインします。

ログインに成功するとセルフポータルページが表示されます。VPNの設定ファイルや AWS VPN Clientのダウンロードが可能です。

設定ファイルが取得できたらクライアントにプロファイルを追加します。

VPN設定ファイルにダウンロードしたファイルを選択します。

追加したら接続を試してみましょう。

まずはVPN接続前のIPアドレスとCloudFrontへのアクセスを確認します。

403 ERRORとなってWAFによってブロックされていることがわかります。

VPNに接続して再度確認しみます。

VPN接続後のIPアドレスはNatGatewayにアタッチされているEIPのパブリックIPアドレスになりました。

ドメインもawsmazonaws.comへと変わっています。CloudFrontへのアクセスも正常に行えました。

さいごに

AWS Client VPNは初めて触ったのですが、簡単にセットアップできるのでお手軽に感じます。AWS SSOと連携できるのも嬉しいポイントですね。

AWS Client VPNはIPv4のみに対応しているのですが、利用する環境によってはVPNに接続している状態でもクライアント側のIPv6による通信が優先されてしまうことがありWAFにブロックされてしまうといったケースの時はIPv6の設定をオフにしみてください。私はこれに気づかず3時間沼ってました。

この記事をシェアする
著者:きむ
フェスが好きなエンジニア