如何通过特定用户的 Bearer 令牌访问与 S3 Bucket 连接的 AWS CloudFront(JWT 自定义身份验证)

Mar*_*ouk 5 amazon-s3 amazon-web-services oauth-2.0 amazon-cloudfront aws-lambda

我正在使用无服务器框架将无服务器堆栈部署到 AWS。我的堆栈由一些 lambda 函数、DynamoDB 表和 API 网关组成。

我使用所谓的lambda Authorizer来保护 API 网关。另外,我有一个可以生成令牌的自定义独立自托管身份验证服务。

因此,场景是用户可以从此服务(托管在 Azure 上的 IdentityServer4)请求令牌,然后用户可以使用不记名令牌向 API 网关发送请求,这样 API 网关将要求 lambda 授权者生成 iam 角色,如果令牌是正确的。所有这些都是有效的并且按预期工作。

以下是我的 serverless.yml 中 lambda 授权者定义的示例,以及我如何使用它来保护其他 API 网关端点:(您可以看到 addUserInfo 函数具有使用自定义授权者保护的 API)


functions:
    # =================================================================
    # API Gateway event handlers
    # ================================================================
  auth:
    handler: api/auth/mda-auth-server.handler

  addUserInfo:
     handler: api/user/create-replace-user-info.handler
     description: Create Or Replace user section
     events:
       - http:
           path: user
           method: post
           authorizer: 
             name: auth
             resultTtlInSeconds: ${self:custom.resultTtlInSeconds}
             identitySource: method.request.header.Authorization
             type: token
           cors:
             origin: '*'
             headers: ${self:custom.allowedHeaders}
Run Code Online (Sandbox Code Playgroud)

现在我想扩展我的 API,以便允许用户添加图像,所以我遵循了这种方法。因此,在这种方法中,用户将启动所谓的签名 S3 URL,我可以使用此 S3 签名 URL 将图像放入我的存储桶中。

此外,S3 存储桶不可公开访问,但它连接到 CloudFront 分配。现在我错过了这里的东西,我不明白如何保护我的图像。无论如何,我可以使用自定义身份验证服务保护 CloudFront CDN 中的图像,以便拥有有效令牌的用户可以访问这些资源吗?如何使用自定义身份验证服务保护我的 CDN (CloudFront) 并使用无服务器框架进行配置?

Mar*_*ouk 2

这有点棘手,我需要大约一天的时间才能完成所有设置。

首先我们这里有选项:

  • 我们可以对 URL 进行签名并返回签名的 CloudFront URL 或签名的 S3 URL,而不是进行身份验证,这非常简单,但显然这不是我想要的。
  • 第二个选项是使用 Lambda@Edge 来授权 CloudFront 的请求,这就是我所遵循的。

因此,我最终创建了一个单独的堆栈来处理所有 S3、CloudFront 和 Lambda@Edge 内容,因为它们都部署在边缘上,这意味着该区域并不重要,但对于 lambda Edge,我们需要将其部署到主 AWS地区((弗吉尼亚北部),us-east-1)所以我最终为所有这些创建了一个堆栈。

首先,我的 auth-service.js 中有以下代码(这只是一些帮助程序,允许我验证我的自定义 jwt):

import * as jwtDecode from 'jwt-decode';
import * as util from 'util';
import * as jwt from 'jsonwebtoken';
import * as jwksClient from 'jwks-rsa';


export function getToken(bearerToken) {
    if(bearerToken && bearerToken.startsWith("Bearer "))
    {
        return bearerToken.replace(/^Bearer\s/, '');
    }
    throw new Error("Invalid Bearer Token.");
};

export function getDecodedHeader(token) {
        return jwtDecode(token, { header: true });
};

export async function getSigningKey(decodedJwtTokenHeader, jwksclient){
    const key = await util.promisify(jwksclient.getSigningKey)(decodedJwtTokenHeader.kid);
    const signingKey = key.publicKey || key.rsaPublicKey;
    if (!signingKey) {
        throw new Error('could not get signing key');
    }
    return signingKey;
  };

export async function verifyToken(token,signingKey){
    return await jwt.verify(token, signingKey);
};

export function getJwksClient(jwksEndpoint){
    return jwksClient({
        cache: true,
        rateLimit: true,
        jwksRequestsPerMinute: 10,
        jwksUri: jwksEndpoint
      });
};
Run Code Online (Sandbox Code Playgroud)

然后在 serverless.yml 中这是我的文件:

service: mda-app-uploads

plugins:
  - serverless-offline
  - serverless-pseudo-parameters
  - serverless-iam-roles-per-function
  - serverless-bundle


custom:
  stage: ${opt:stage, self:provider.stage}
  resourcesBucketName: ${self:custom.stage}-mda-resources-bucket
  resourcesStages:
    prod: prod
    dev: dev
  resourcesStage: ${self:custom.resourcesStages.${self:custom.stage}, self:custom.resourcesStages.dev}


provider:
  name: aws
  runtime: nodejs12.x
  stage: ${opt:stage, 'dev'}
  region: us-east-1
  versionFunctions: true

functions: 
  oauthEdge:
    handler: src/mda-edge-auth.handler
    role: LambdaEdgeFunctionRole
    memorySize: 128
    timeout: 5


resources:
  - ${file(resources/s3-cloudfront.yml)}
Run Code Online (Sandbox Code Playgroud)

快速要点在这里:

  • us-east-1 在这里很重要。
  • 使用无服务器框架创建任何 lambda 边缘有点棘手且不切实际,因此我用它来配置函数,然后在这个云形成模板中resources/s3-cloudfront.yml添加了所有需要的位。

那么这里的内容是resources/s3-cloudfront.yml

Resources:

    AuthEdgeLambdaVersion:
        Type: Custom::LatestLambdaVersion
        Properties:
            ServiceToken: !GetAtt PublishLambdaVersion.Arn
            FunctionName: !Ref OauthEdgeLambdaFunction
            Nonce: "Test"

    PublishLambdaVersion:
        Type: AWS::Lambda::Function
        Properties:
            Handler: index.handler
            Runtime: nodejs12.x
            Role: !GetAtt PublishLambdaVersionRole.Arn
            Code:
                ZipFile: |
                    const {Lambda} = require('aws-sdk')
                    const {send, SUCCESS, FAILED} = require('cfn-response')
                    const lambda = new Lambda()
                    exports.handler = (event, context) => {
                        const {RequestType, ResourceProperties: {FunctionName}} = event
                        if (RequestType == 'Delete') return send(event, context, SUCCESS)
                        lambda.publishVersion({FunctionName}, (err, {FunctionArn}) => {
                        err
                            ? send(event, context, FAILED, err)
                            : send(event, context, SUCCESS, {FunctionArn})
                        })
                    }

    PublishLambdaVersionRole:
        Type: AWS::IAM::Role
        Properties:
            AssumeRolePolicyDocument:
                Version: '2012-10-17'
                Statement:
                - Effect: Allow
                  Principal:
                    Service: lambda.amazonaws.com
                  Action: sts:AssumeRole
            ManagedPolicyArns:
            - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
            Policies:
            - PolicyName: PublishVersion
              PolicyDocument:
                Version: '2012-10-17'
                Statement:
                - Effect: Allow
                  Action: lambda:PublishVersion
                  Resource: '*'

    LambdaEdgeFunctionRole:
        Type: "AWS::IAM::Role"
        Properties:
            Path: "/"
            ManagedPolicyArns:
                - "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
            AssumeRolePolicyDocument:
                Version: "2012-10-17"
                Statement:
                -
                    Sid: "AllowLambdaServiceToAssumeRole"
                    Effect: "Allow"
                    Action: 
                        - "sts:AssumeRole"
                    Principal:
                        Service: 
                            - "lambda.amazonaws.com"
                            - "edgelambda.amazonaws.com"
    LambdaEdgeFunctionPolicy:
        Type: "AWS::IAM::Policy"
        Properties:
            PolicyName: MainEdgePolicy
            PolicyDocument:
                Version: "2012-10-17"
                Statement:
                    Effect: "Allow"
                    Action: 
                        - "lambda:GetFunction"
                        - "lambda:GetFunctionConfiguration"
                    Resource: !GetAtt AuthEdgeLambdaVersion.FunctionArn
            Roles:
                - !Ref LambdaEdgeFunctionRole


    ResourcesBucket:
        Type: AWS::S3::Bucket
        Properties:
            BucketName: ${self:custom.resourcesBucketName}
            AccessControl: Private
            CorsConfiguration:
                CorsRules:
                -   AllowedHeaders: ['*']
                    AllowedMethods: ['PUT']
                    AllowedOrigins: ['*']

    ResourcesBucketPolicy:
        Type: AWS::S3::BucketPolicy
        Properties:
            Bucket:
                Ref: ResourcesBucket
            PolicyDocument:
                Statement:
                # Read permission for CloudFront
                -   Action: s3:GetObject
                    Effect: "Allow"
                    Resource: 
                        Fn::Join: 
                            - ""
                            - 
                                - "arn:aws:s3:::"
                                - 
                                    Ref: "ResourcesBucket"
                                - "/*"
                    Principal:
                        CanonicalUser: !GetAtt CloudFrontOriginAccessIdentity.S3CanonicalUserId
                -   Action: s3:PutObject
                    Effect: "Allow"
                    Resource: 
                        Fn::Join: 
                            - ""
                            - 
                                - "arn:aws:s3:::"
                                - 
                                    Ref: "ResourcesBucket"
                                - "/*"
                    Principal:
                        AWS: !GetAtt LambdaEdgeFunctionRole.Arn

                -   Action: s3:GetObject
                    Effect: "Allow"
                    Resource: 
                        Fn::Join: 
                            - ""
                            - 
                                - "arn:aws:s3:::"
                                - 
                                    Ref: "ResourcesBucket"
                                - "/*"
                    Principal:
                        AWS: !GetAtt LambdaEdgeFunctionRole.Arn

    
    CloudFrontOriginAccessIdentity:
        Type: AWS::CloudFront::CloudFrontOriginAccessIdentity
        Properties:
            CloudFrontOriginAccessIdentityConfig:
                Comment:
                    Fn::Join: 
                        - ""
                        -
                            - "Identity for accessing CloudFront from S3 within stack "
                            - 
                                Ref: "AWS::StackName"
                            - ""


    # Cloudfront distro backed by ResourcesBucket
    ResourcesCdnDistribution:
        Type: AWS::CloudFront::Distribution
        Properties:
            DistributionConfig:
                Origins:
                    # S3 origin for private resources
                    -   DomainName: !Sub '${self:custom.resourcesBucketName}.s3.amazonaws.com'
                        Id: S3OriginPrivate
                        S3OriginConfig:
                            OriginAccessIdentity: !Sub 'origin-access-identity/cloudfront/#{CloudFrontOriginAccessIdentity}'
                    # S3 origin for public resources           
                    -   DomainName: !Sub '${self:custom.resourcesBucketName}.s3.amazonaws.com'
                        Id: S3OriginPublic
                        S3OriginConfig:
                            OriginAccessIdentity: !Sub 'origin-access-identity/cloudfront/#{CloudFrontOriginAccessIdentity}'
                Enabled: true
                Comment: CDN for public and provate static content.
                DefaultRootObject: index.html
                HttpVersion: http2
                DefaultCacheBehavior:
                    AllowedMethods:
                        - DELETE
                        - GET
                        - HEAD
                        - OPTIONS
                        - PATCH
                        - POST
                        - PUT
                    Compress: true
                    TargetOriginId: S3OriginPublic
                    ForwardedValues:
                        QueryString: false
                        Headers:
                        - Origin
                        Cookies:
                            Forward: none
                    ViewerProtocolPolicy: redirect-to-https
                CacheBehaviors:
                    - 
                        PathPattern: 'private/*'
                        TargetOriginId: S3OriginPrivate
                        AllowedMethods:
                        - DELETE
                        - GET
                        - HEAD
                        - OPTIONS
                        - PATCH
                        - POST
                        - PUT
                        Compress: true
                        LambdaFunctionAssociations:
                            - 
                                EventType: viewer-request
                                LambdaFunctionARN: !GetAtt AuthEdgeLambdaVersion.FunctionArn
                        ForwardedValues:
                            QueryString: false
                            Headers:
                                - Origin
                            Cookies:
                                Forward: none
                        ViewerProtocolPolicy: redirect-to-https
                    - 
                        PathPattern: 'public/*'
                        TargetOriginId: S3OriginPublic
                        AllowedMethods:
                        - DELETE
                        - GET
                        - HEAD
                        - OPTIONS
                        - PATCH
                        - POST
                        - PUT
                        Compress: true
                        ForwardedValues:
                            QueryString: false
                            Headers:
                                - Origin
                            Cookies:
                                Forward: none
                        ViewerProtocolPolicy: redirect-to-https

                PriceClass: PriceClass_200
Run Code Online (Sandbox Code Playgroud)

与此文件相关的一些要点:

  • 在这里,我创建了 S3 存储桶,其中将包含我的所有私有和公共资源。
  • 该存储桶是私有的且不可访问,您会发现一个角色只向 CDN 和 lambda 边缘提供对其的访问权限。
  • 我决定创建一个具有两个源的 CloudFront (CDN):public 指向 S3 的公共文件夹,private 指向 S3 的私有文件夹,并配置 CloudFront 私有源的行为以使用我的 lambda 边缘函数进行身份验证查看者请求事件类型。
  • 您还会发现用于创建函数版本的代码以及PublishLambdaVersion以其角色调用的另一个函数,它有助于在部署时为 lambda 边缘提供正确的权限。

最后,这是用于 CDN 身份验证的 lambda 边缘函数的实际代码:

import {getJwksClient, getToken, getDecodedHeader, getSigningKey, verifyToken} from '../../../../libs/services/auth-service';
import config from '../../../../config';

const response401 = {
    status: '401',
    statusDescription: 'Unauthorized'
};

exports.handler = async (event) => {
    try{
        const cfrequest = event.Records[0].cf.request;
        const headers = cfrequest.headers;
        if(!headers.authorization) {
            console.log("no auth header");
            return response401;
        }
        const jwtValue = getToken(headers.authorization);
        const client = getJwksClient(`https://${config.authDomain}/.well-known/openid-configuration/jwks`);
        const decodedJwtHeader = getDecodedHeader(jwtValue);
        if(decodedJwtHeader)
        {
          const signingKey = await getSigningKey(decodedJwtHeader, client);
          const verifiedToken = await verifyToken(jwtValue, signingKey);
          if(verifiedToken)
          {
            return cfrequest;
          }
      }else{
        throw Error("Unauthorized");
      }

    }catch(err){
      console.log(err);
      return response401;
    }
};
Run Code Online (Sandbox Code Playgroud)

如果您感兴趣,我正在使用 IdentityServer4 并将其作为 Docker 映像托管在 Azure 中,并将其用作自定义授权者。

现在我们有了一个完全私有的 S3 存储桶,这是完整的场景。它只能通过 CloudFront 源进行访问。如果请求是通过公共源提供的,那么不需要身份验证,但如果它是通过私有源提供的,那么我将触发所谓的 lambda 边缘来对其进行身份验证并验证不记名令牌。

在深入了解所有这些内容之前,我对 AWS 堆栈完全陌生,但 AWS 非常简单,所以我最终以完美的方式配置了所有内容。如果有不清楚的地方或有任何疑问,请告诉我。