1
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?

mTLSのクライアント証明書で証明書別アクセスコントロールをしてみる。

Posted at

はじめに

IDトークンを使えばABACできるけど、mTLSのみで認証をする場合、どうやるんだろう?と思い検証してみました。

Claudeが以下の記事を見つけてくれたので、今回はこれを参考に検証してみます!

この記事では以下のようにBackendがEC2の構成ですが、今回はLambdaにしています。

image.png

やってみた。

資材はここに格納していますので、ポイント部分のみ説明します。

証明書の作成

最初にmTLSで必要な証明書を作っていきます。

CA証明書

まずは、クライアント証明書を署名する認証局用の証明書です。

## CA(認証局)証明書作成
### CA用の秘密鍵の生成
openssl genrsa -out ca.key 4096

### CA証明書の作成
openssl req -x509 -new -nodes -key ca.key -sha256 -days 3650 -out ca.crt -subj "/
C=JP/ST=Hokkaido/L=Sapporo/O=MyCompany/OU=IT/CN=MyPrivateCA"

CA証明書はバージョニングを有効にしたS3バケット(template_s3.yaml)へアップロードしておきます。

クライアント証明書

次にクライアント証明書です。

クライアント証明書のサブジェクトに/C=JP/ST=Hokkaido/L=Sapporo/O=MyCompany/OU=Finance/CN=tenant-001といった組織情報を付与します。

この組織ベースにアクセスコントロールがしたいのです。

## クライアント証明書の作成
### tenant-001用の秘密鍵の生成
openssl genrsa -out tenant-001.key 2048

### 証明書署名要求(CSR)の作成
openssl req -new -key tenant-001.key -out tenant-001.csr -subj "/C=JP/ST=Hokkaido/L=Sapporo/O=MyCompany/OU=Finance/CN=tenant-001"

### CAによるクライアント証明書への署名
openssl x509 -req -in tenant-001.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out tenant-001.crt -days 365 -sha256

カスタムドメインとmTLSの設定

AWS::Serverless::ApiのプロパティDomainを設定し、それぞれカスタムドメインとmTLSを設定します。

templete.yaml
  MyApi:
    Type: AWS::Serverless::Api
    Properties:
      StageName: !Ref StageName
+      Domain:
+        DomainName: !Ref DomainName
+        CertificateArn: !Ref CertificateArn
+        EndpointConfiguration: REGIONAL
+        Route53:
+          HostedZoneId: !Ref HostedZoneId
+        BasePath:
+          - !Ref BasePath
+        MutualTlsAuthentication:
+          TruststoreUri: !Ref TrustStoreBucketURI
+          TruststoreVersion: !Ref TrustStoreObjectKey

Lambdaオーサライザの設定

mTLSが有効な状態でバックエンド(プロキシ統合をしたLambda)からevent情報を確認すると、requestContext.identity.clientCertにクライアント証明書の情報が付与されているのが分かります。

{
    "resource": "/hello",
    "path": "/v1/hello",
    "httpMethod": "GET",
    "headers": {
        "省略"
    },
    "multiValueHeaders": {
        "省略"
    },
    "queryStringParameters": null,
    "multiValueQueryStringParameters": null,
    "pathParameters": null,
    "stageVariables": null,
    "requestContext": {
        "resourceId": "u28775",
        "resourcePath": "/hello",
        "httpMethod": "GET",
        "extendedRequestId": "KUgdTGoCNjMFTcA=",
        "requestTime": "09/May/2025:21:59:23 +0000",
        "path": "/v1/hello",
        "accountId": "111122223333",
        "protocol": "HTTP/1.1",
        "stage": "Prod",
        "domainPrefix": "mtls",
        "requestTimeEpoch": 1746827963135,
        "requestId": "28509ceb-1631-4adc-ad9d-8fae03926aa8",
        "identity": {
            "cognitoIdentityPoolId": null,
            "clientCert": {
                "clientCertPem": "-----BEGIN CERTIFICATE-----\nMIIEpjCCAo6gAwIBAgIUd7zhSbkJfqFr9n9sEVDRZgpDq64wDQYJKoZIhvcNAQEL\nBQAwaTELMAkGA1UEBhMCSlAxETAPBgNVBAgMCEhva2thaWRvMRAwDgYDVQQHDAdT\nYXBwb3JvMRIwEAYDVQQKDAlNeUNvbXBhbnkxCzAJBgNVBAsMAklUMRQwEgYDVQQD\nDAtNeVByaXZhdGVDQTAeFw0yNTA1MDcyMDAyNTlaFw0yNjA1MDcyMDAyNTlaMG0x\nCzAJBgNVBAYTAkpQMREwDwYDVQQIDAhIb2trYWlkbzEQMA4GA1UEBwwHU2FwcG9y\nbzESMBAGA1UECgwJTXlDb21wYW55MRAwDgYDVQQLDAdGaW5hbmNlMRMwEQYDVQQD\nDAp0ZW5hbnQtMDAxMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwK8t\npy1PPCqwylMUlo6ashii3OkPEYnDlGArk/c4amb1qniQWkqvI5SEds+GLlyi57Dh\nGB6NXcJSBaxX/N6Xl9TVbsHMcDxdYSBJktKczV/QHXZ4bL9zK8oCtKCdeB4p6AAY\nFciwra0FyngIRMnEmoB2ZwQWu91u4xtmCzmzQrr4ThcgFdHi2yyjolf7p3rsSUUy\nScHfd0GrILLLIsiAwyeZbD4ZhCBl0IXvVUgrmmU67nf10yUbYTjCqcv2Hq5o7pjH\nnc2gK/eZFYUaoOLATqQV4Pmn51ykn43wYvuh/resh0UrA4M3MpYQyariflumQNkM\n1coIF0SN086D7VUZnQIDAQABo0IwQDAdBgNVHQ4EFgQUylCeTMX5c3IKpuGrXhn8\nSqYmIhAwHwYDVR0jBBgwFoAUHRD+/MD344HIc/m7n85+R8PWLYUwDQYJKoZIhvcN\nAQELBQADggIBAEp7AaNSmNptwi5uzqpKDqiNdh60z7VzrF6mRLYXTrLsMmILz9pw\nrlZn8GmrYjJFoT9rGAx5ChZDOO+O9pWZdstq9nnj9Ma8Tjj231gKyOzgLVs8ha2B\nz0LZPGjA7t9hj/RtqG7ABW0DhR/skDxIvhFubkglrJaIjbS7eQa8tVk8gJIncLC4\ncHxjq+IpMiJXJmVJyatN0/lJnrTOS0a1H8H6kcNAyHsWe9SrswXICX0iGZbcbE1n\noJXdw3ImOUn8GGjHAueK74SGfaXPQkt1ViD8YpA5iAG2CyEXCqN8HHLghasWe9o7\nIzo0CCPuGwPMZMsQ3t6jbdx+yrNJeez04f4SK8NeI40ori04Q4a6pl7RvHaYEGZY\n+xphRQSGwlQCGJIFMjNLNgGz3adpobpdGKD8fi0iSM7SvdaVGfZgQ3MEzN4ogTla\nG6rhgTYtmr19aRCtvoKiEqn2X0SYaPSkrhDF5EtGQLKmx5MsRr/2xOXfnVV193m5\n96E1ouKqNF613ILCpggNvtRIKRKcsk9IXBeIV7tgBdoDIhxHCieO0eiajP9rnU53\nWjYkFn50lwU/vf8zubLAx+a3yQaHEeeecFAmPVw87X5+tG2AGPXfIK4pmA6L0UP6\nwtqPujLxQfhoR+AL4az0FD4OG+TXIa66eDssdSrDc6xzH5V5cw9OKjfc\n-----END CERTIFICATE-----\n",
                "serialNumber": "683582067176264570083049528076393372651509492654",
                "issuerDN": "C=JP,ST=Hokkaido,L=Sapporo,O=MyCompany,OU=IT,CN=MyPrivateCA",
                "validity": {
                    "notAfter": "May  7 20:02:59 2026 GMT",
                    "notBefore": "May  7 20:02:59 2025 GMT"
                },
                "subjectDN": "C=JP,ST=Hokkaido,L=Sapporo,O=MyCompany,OU=Finance,CN=tenant-001"
            },
            "cognitoIdentityId": null,
            "principalOrgId": null,
            "cognitoAuthenticationType": null,
            "userArn": null,
            "userAgent": "PostmanRuntime/7.43.0",
            "accountId": null,
            "caller": null,
            "sourceIp": "60.69.219.227",
            "accessKey": null,
            "cognitoAuthenticationProvider": null,
            "user": null
        },
        "domainName": "aaa.vvv.com",
        "deploymentId": "tkl1xs",
        "apiId": "56gdg6d"
    },
    "body": null,
    "isBase64Encoded": false
}

requestContext.identity.clientCertの情報をそのままAPI Gatewayの統合リクエストでバックエンドに流せばいいのですが、プロキシ統合を使わないとなぜか情報が取得できません。。
(理由誰か教えて!)

なので、Lambdaオーソライザで明示的にクライアント証明書の情報をprincipalIdとして付与します。

authorizer/app.mjs
export const authorizerHandler = async (event) => {
  console.log("> handler", JSON.stringify(event, null, 4));

  const clientCertSub = event.requestContext.identity.clientCert.subjectDN;

  const response = {
    principalId: clientCertSub,
    policyDocument: {
      Version: "2012-10-17",
      Statement: [
        {
          Action: "execute-api:Invoke",
          Effect: "allow",
          Resource: event.methodArn,
        },
      ],
    },
  };

  console.log("Authorizer Response", JSON.stringify(response, null, 4));
  return response;
};

統合リクエストの設定

Lambdaオーソライザで指定した情報をリクエストヘッダーに付与するため、統合リクエストの設定をします。

プロキシ統合だと付与できないみたいなので、記述が複雑になりますが、DefinitionBody属性を使って指定してきます。(少し記載は省略しています。)

requestTemplates内の
"X-Client-Cert-Subject":"$context.authorizer.principalId"が実際にヘッダを付与している部分です。

templete.yaml
  MyApi:
    Type: AWS::Serverless::Api
    Properties:
      StageName: !Ref StageName
      Domain:
        DomainName: !Ref DomainName
        CertificateArn: !Ref CertificateArn
        EndpointConfiguration: REGIONAL
        Route53:
          HostedZoneId: !Ref HostedZoneId
        BasePath:
          - !Ref BasePath
        MutualTlsAuthentication:
          TruststoreUri: !Ref TrustStoreBucketURI
          TruststoreVersion: !Ref TrustStoreObjectKey
+      DefinitionBody:
+        swagger: "2.0"
+        info:
+          title: "MyMtlsApi"
+          version: "1.0"
+        securityDefinitions:
+          mTLSAuthorizer:
+            type: "apiKey"
+            name: "Authorization"
+            in: "header"
+            x-amazon-apigateway-authtype: "custom"
+            x-amazon-apigateway-authorizer:
+              type: "request"
+              authorizerUri: !Sub "arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${AuthorizerFunction.Arn}/invocations"
+              authorizerResultTtlInSeconds: 300
+              identitySource: "method.request.header.Host"
+        paths:
+          /hello:
+            get:
+              security:
+                - mTLSAuthorizer: []
+              produces:
+                - "application/json"
+              responses:
+                "200":
+                  description: "成功レスポンス"
+                  schema:
+                    type: "object"
+              x-amazon-apigateway-integration:
+                type: "aws"
+                uri: !Sub "arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${HelloWorldFunction.Arn}/invocations"
+                httpMethod: "POST"
+                passthroughBehavior: "when_no_templates"
+                contentHandling: "CONVERT_TO_TEXT"
+                timeoutInMillis: 29000
+                requestTemplates:
+                  application/json: |
+                    {
+                      "body": $input.json('$'),
+                      "headers": {
+                        #foreach($header in $input.params().header.keySet())
+                       "$header": "$util.escapeJavaScript($input.params().header.get($header))"#if($foreach.hasNext),#end
+                       #end,
+                        "X-Client-Cert-Subject":"$context.authorizer.principalId"
+                     }
+                    }
+                responses:
+                  default:
+                    statusCode: "200"
+                    responseTemplates:
+                      application/json: |
+                        $input.json('$')

Backendの作成

バックエンドは、リクエストヘッダX-Client-Cert-Subjectの値を、clientCertSubjectとして応答するだけです。

clientCertSubjectをキーにデータ取得をするなどでアクセスコントロールをすることを考えています。

hello-world/app.mjs
export const lambdaHandler = async (event, context) => {
  console.log("Event:", JSON.stringify(event, null, 2));

  const subject = event.headers && event.headers["X-Client-Cert-Subject"];
  const response = {
    clientCertSubject: subject || "Not available",
  };

  return response;
};

動作確認

最後にPostmanで動作確認してみます。

クライアント証明書を利用する場合、'Settings'->'Certificates'からドメイン単位でクライアント証明書及び、ペアの秘密鍵を設定しておきます。

スクリーンショット 2025-05-11 10.12.32.png

これで、クライアント証明書の情報が取得できました。

スクリーンショット 2025-05-11 10.20.08.png

最後に

いつもプロキシ統合を使ってたので、統合リクエスト・レスポンスの設定で時間を溶かしてしまいました。

API Gatewayのことも勉強しないとなぁ。

後この方法って一般的なのかな?

1
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
1
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?