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

问题描述

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

我使用所谓的lambda authorizer来保护 API Gateway 。另外,我有一个可以生成令牌的自定义独立自托管 Auth 服务。

所以场景是用户可以从这个服务请求一个令牌(它是托管在 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}

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

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

标签: amazon-web-servicesamazon-s3oauth-2.0aws-lambdaamazon-cloudfront

解决方案


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

首先,我们在这里有选项:

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

所以我最终创建了一个单独的堆栈来处理所有 S3、CloudFront 和 Lambda@Edge 的东西,因为它们都部署在边缘上,这意味着区域无关紧要,但对于 lambda 边缘,我们需要将其部署到主 AWS region ((N. Virginia), 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
      });
};

然后在 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)}

这里的快速点:

  • 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

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

  • 在这里,我创建了包含所有私有和公共资源的 S3 存储桶。
  • 此存储桶是私有的且不可访问,您将找到一个角色,该角色仅授予 CDN 和 lambda 边缘访问它的权限。
  • 我决定创建一个 CloudFront (CDN),它有两个来源,公共指向 S3 的公用文件夹,私有指向 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;
    }
};

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

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

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


推荐阅读