发布于 2026-01-06 1 阅读
0

AWS Lambda 上无服务器 API 的 JWT 授权

AWS Lambda 上无服务器 API 的 JWT 授权

无服务器函数允许我们为应用程序编写小型、独立的 API 端点。在本文中,我们将学习如何使用基于 JSON Web 令牌 ( JWT ) 的授权来保护我们的无服务器 API 端点。

太长不看

如果您想直接查看最终代码,可以在这里找到代码仓库:https://github.com/tmaximini/serverless-jwt-authorizer

请继续阅读,了解这里究竟发生了什么。

JWT授权步骤

以下是我们为保护 API 端点安全所需采取的大致步骤:

  1. 使用用户名、密码注册,密码哈希值将存储在数据库中。
  2. 使用用户名/密码登录
  3. 如果密码哈希值与用户存储的 passwordHash 匹配,则根据用户 ID 及其授权范围生成 JWT 令牌。
  4. 把令牌保存到 Cookie 里 🍪
  5. 使用此令牌对 HTTP Authorization 标头中的每个请求进行签名
  6. 设置授权函数,用于验证此令牌(在请求安全 API 路由时)。授权响应可以缓存一段时间,以提高 API 吞吐量。
  7. 授权器生成一个策略文档,用于允许或拒绝访问服务。

规划我们的应用程序

我们需要一种registerUser方法loginUser。我们还会有一个受保护的/me端点,如果用户身份验证成功,该端点会返回当前用户对象。

verifyToken是一个额外的 lambda 函数,它被定义为 API 网关授权器,每当我们尝试访问受保护的端点时,它都会在后台被调用/me

所以我们总共有 4 个 lambda 函数:

excalidraw 图表

使用 Serverless Framework 搭建我们的应用程序

现在我们来初始化应用。示例的最终代码可以在 GitHub 上找到。我们可以运行它serverless init --template aws-nodejs来启动一个基于 Node.js 的项目。请确保您之前已经配置好 AWS CLI,或者至少创建了一个~/.aws/credentials文件夹,因为 Serverless 将从该文件夹中提取您的信息。

现在我们来更新生成的serverless.yml文件。我们将把第一步中的所有函数(register、login、me、verifyToken)添加到其中。它应该类似于这样:



    org: your-org

    service: serverless-jwt-authorizer
    provider:
      name: aws
      runtime: nodejs12.x
      region: eu-central-1
    functions:
      verify-token:
        handler: functions/authorize.handler

      me:
        handler: functions/me.handler
        events:
          - http:
              path: me
              method: get
              cors: true
              authorizer:
                name: verify-token
                            # this tells the lambda where to take the information from, 
                            # in our case the HTTP Authorization header
                identitySource: method.request.header.Authorization 
                resultTtlInSeconds: 3600 # cache the result for 1 hour
      login:
        handler: functions/login.handler
        events:
          - http:
              path: login
              method: post
              cors: true
      register:
        handler: functions/register.handler
        events:
          - http:
              path: register
              method: post
              cors: true


Enter fullscreen mode Exit fullscreen mode

无服务器 API 的文件夹结构

我的做法是./functions为每个 Lambda 函数创建一个单独的文件。当然,你也可以从同一个文件中导出多个函数,但这样可以保持代码的清晰易懂,也方便命名(每个文件导出一个处理函数,我将其用作 serverless.yml 中的处理程序)。

所有辅助函数和非 lambda 函数都放在该./lib文件夹中。



    .
    ├── Readme.md
    ├── functions
    │   ├── authorize.js
    │   ├── login.js
    │   ├── me.js
    │   └── register.js
    ├── handler.js
    ├── lib
    │   ├── db.js
    │   └── utils.js
    ├── package.json
    ├── secrets.json
    ├── serverless.yml
    └── yarn.lock


Enter fullscreen mode Exit fullscreen mode

数据库层

现在,在授权用户之前,我们需要一种方法来创建用户并将其保存到数据库中。我们选择 DynamoDB 作为数据库,因为它本身就是一个无服务器数据库,是无服务器架构的绝佳选择。当然,您也可以使用任何其他数据库。

DynamoDB

Amazon DynamoDB 是一款键值和文档数据库,可在任何规模下提供个位数毫秒级的性能。

DynamoDB 采用单表设计。在我们的案例中,我们只需要一个用户表。我选择 DynamoDB 是因为它是无服务器 API 的知名可靠之选,尤其因为它“按需付费,随业务增长而扩展”的理念。

如果你想了解 DynamoDB 的来龙去脉,我建议你访问@alexbdebrie的网站https://www.dynamodbguide.com/

数据库模型

在设计服务或 API 时,我喜欢从数据模型入手。这一点在 DynamoDB 中尤为重要,因为 DynamoDB 受限于单表设计。因此,DynamoDB 专家 建议首先列出所有访问模式以及查询数据的方式。基于此,你就可以构建表模型了。

目前,我们的数据模式比较简单,但为了方便日后扩展,我们保留了足够的通用性。我在这里使用了dynamodb-toolbox包来定义数据模型并简化查询编写。



    const { Model } = require("dynamodb-toolbox");
    const User = new Model("User", {
      // Specify table name
      table: "test-users-table",

      // Define partition and sort keys
      partitionKey: "pk",
      sortKey: "sk",

      // Define schema
      schema: {
        pk: { type: "string", alias: "email" },
        sk: { type: "string", hidden: true, alias: "type" },
        id: { type: "string" },
        passwordHash: { type: "string" },
        createdAt: { type: "string" }
      }
    });


Enter fullscreen mode Exit fullscreen mode

我们显然不会将密码以明文形式存储在数据库中,因此我们使用 bcrypt(脚注:bcryptjs 是 lambda 上的更好选择)来创建一个密码passwordHash,然后在将其分发给用户之前,从 props 对象中删除原始的明文密码。

我在这里选择邮箱地址而不是ID作为主键,因为我用邮箱地址来查询单个项目。你也可以使用用户ID或它们的任意组合。

需要注意的是,DynamoDB 无法通过非键属性获取单个项目,例如,在上面的例子中,我无法直接使用 `[]` 来获取项目getById(id)。我必须先获取所有项目,然后使用 `FilterExpression` 进行筛选。

DynamoDB 等 NoSQL 数据库的优势在于其列和字段是动态的。因此,如果我们决定向该方法发送更多数据,它们都会被添加到数据库中(不过我们必须先createDbUser数据库进行调整)。Modeldynamodb-toolkit

在 serverless.yml 中定义资源

确定数据模型和表名后,最好重新serverless.yml配置 DynamoDB 资源,这样就无需在 AWS 控制台中进行任何手动操作。Serverless 框架允许我们直接在配置serverless.yml文件中定义资源和权限。

我们还需要一些秘密环境变量。定义它们的简单方法是在项目根目录下创建一个secrets.json文件(确保将其添加到 .gitignore 文件中!),并以 JSON 格式定义它们。



    org: your-org

    custom:
      secrets: ${file(secrets.json)}
      tableName: "test-users-table"

    service: serverless-jwt-authorizer
    provider:
      name: aws
      runtime: nodejs12.x
      region: eu-central-1
      environment:
        JWT_SECRET: ${self:custom.secrets.JWT_SECRET}
        AWS_ID: ${self:custom.secrets.AWS_ID}
      iamRoleStatements:
        - Effect: "Allow"
          Action:
            - "dynamodb:GetItem"
            - "dynamodb:PutItem"
          Resource: "arn:aws:dynamodb:eu-central-1:${self:custom.secrets.AWS_ID}:table/${self:custom.tableName}"
    functions:
      verify-token:
        handler: functions/authorize.handler

      me:
        handler: functions/me.handler
        events:
          - http:
              path: me
              method: get
              cors: true
              authorizer:
                name: verify-token
                identitySource: method.request.header.Authorization
                resultTtlInSeconds: 3600
      login:
        handler: functions/login.handler
        events:
          - http:
              path: login
              method: post
              cors: true
      register:
        handler: functions/register.handler
        events:
          - http:
              path: register
              method: post
              cors: true
    resources:
      Resources:
        usersTable:
          Type: AWS::DynamoDB::Table
          Properties:
            TableName: ${self:custom.tableName}
            AttributeDefinitions:
              - AttributeName: pk
                AttributeType: S
              - AttributeName: sk
                AttributeType: S
            KeySchema:
              - AttributeName: pk
                KeyType: HASH
              - AttributeName: sk
                KeyType: RANGE
            ProvisionedThroughput:
              ReadCapacityUnits: 1
              WriteCapacityUnits: 1


Enter fullscreen mode Exit fullscreen mode

用户注册

为了让用户注册我们的服务,我们需要将他们的数据存储在我们的数据库中。有了数据模型,我们现在可以使用AWS DynamoDB DocumentClient和我们的 dynamodb-toolkit 来简化这个过程。请查看以下代码:



    // lib/db.js

    const AWS = require("aws-sdk");
    const bcrypt = require("bcryptjs");
    const { Model } = require("dynamodb-toolbox");
    const { v4: uuidv4 } = require("uuid");

    const User = new Model("User", {
      // Specify table name
      table: "test-users-table",

      // Define partition and sort keys
      partitionKey: "pk",
      sortKey: "sk",

      // Define schema
      schema: {
        pk: { type: "string", alias: "email" },
        sk: { type: "string", hidden: true, alias: "type" },
        id: { type: "string" },
        passwordHash: { type: "string" },
        createdAt: { type: "string" }
      }
    });

    // INIT AWS
    AWS.config.update({
      region: "eu-central-1"
    });
    // init DynamoDB document client
    const docClient = new AWS.DynamoDB.DocumentClient();

    const createDbUser = async props => {
      const passwordHash = await bcrypt.hash(props.password, 8); // hash the pass
      delete props.password; // don't save it in clear text

      const params = User.put({
        ...props,
        id: uuidv4(),
        type: "User",
        passwordHash,
        createdAt: new Date()
      });

      const response = await docClient.put(params).promise();

      return User.parse(response);
    };

    // export it so we can use it in our lambda
    module.exports = {
      createDbUser
    };


Enter fullscreen mode Exit fullscreen mode

这足以在数据库端创建我们的用户注册信息。

现在让我们添加实际 lambda 端点的实现。

当收到 HTTP POST 请求时,我们希望从请求正文中提取用户数据,并将其传递给createDbUserlib/db.js 中的方法。

我们来创建一个名为 `.yml` 的文件functions/register.js,内容如下:



    // functions/register.js

    const { createDbUser } = require("../lib/db");

    module.exports.handler = async function registerUser(event) {
      const body = JSON.parse(event.body);

      return createDbUser(body)
        .then(user => ({
          statusCode: 200,
          body: JSON.stringify(user)
        }))
        .catch(err => {
          console.log({ err });

          return {
            statusCode: err.statusCode || 500,
            headers: { "Content-Type": "text/plain" },
            body: { stack: err.stack, message: err.message }
          };
        });
    };


Enter fullscreen mode Exit fullscreen mode

我们正在尝试创建用户,如果一切顺利,我们会将用户对象连同 200 成功状态码一起发送回去,否则我们会发送错误响应。

接下来,我们将着手实现登录功能。

用户登录

首先,我们需要在 lib/db.js 辅助文件中添加一个函数,该函数可以通过电子邮件检索用户,以便我们可以检查用户是否存在,如果存在,则将 passwordHash 与请求中发送的密码的哈希值进行比较。



    //...

    const getUserByEmail = async email => {
      const params = User.get({ email, sk: "User" });
      const response = await docClient.get(params).promise();

      return User.parse(response);
    };

    // don't forget to export it
    module.exports = {
      createDbUser,
      getUserByEmail
    };


Enter fullscreen mode Exit fullscreen mode

现在我们可以在用户 lambda 函数中导入并使用此函数。

让我们来分解一下用户登录所需的步骤:

  1. 从请求有效负载中获取电子邮件和密码
  2. 尝试从数据库中获取电子邮件用户记录。
  3. 如果找到,则对密码进行哈希处理,并与用户记录中的 passwordHash 进行比较
  4. 如果密码正确,则创建有效的 JWT 会话令牌并将其发送回客户端。

以下是处理程序的实现login



    // ./functions/login.js
    const { login } = require("../lib/utils");

    module.exports.handler = async function signInUser(event) {
      const body = JSON.parse(event.body);

      return login(body)
        .then(session => ({
          statusCode: 200,
          body: JSON.stringify(session)
        }))
        .catch(err => {
          console.log({ err });

          return {
            statusCode: err.statusCode || 500,
            headers: { "Content-Type": "text/plain" },
            body: { stack: err.stack, message: err.message }
          };
        });
    };

    // ./lib/utils.js
    async function login(args) {
      try {
        const user = await getUserByEmail(args.email);

        const isValidPassword = await comparePassword(
          args.password,
          user.passwordHash
        );

        if (isValidPassword) {
          const token = await signToken(user);
          return Promise.resolve({ auth: true, token: token, status: "SUCCESS" });
        }
      } catch (err) {
        console.info("Error login", err);
        return Promise.reject(new Error(err));
      }
    }

    function comparePassword(eventPassword, userPassword) {
      return bcrypt.compare(eventPassword, userPassword);
    }


Enter fullscreen mode Exit fullscreen mode

注册和登录功能到位后,我们现在可以着手实现受保护的 API 端点了。

受保护的端点

假设我们的 API 中有一个受保护的资源,例如用户个人资料。我们只希望已登录用户能够查看和更新​​他们的个人资料信息。让我们实现一个/me端点,该端点仅从数据库中返回当前登录用户的用户记录。

以下是我们需要实施的步骤:

  1. 验证 JWT 令牌(由我们的 Lambda 授权函数完成
  2. 从数据库中获取相关用户
  3. 返回用户

听起来很简单,对吧?我们来看看:



    // ./functions/me.js
    const { getUserByEmail } = require("../lib/db");
    const { getUserFromToken } = require("../lib/utils");

    module.exports.handler = async function(event) {
      const userObj = await getUserFromToken(event.headers.Authorization);

      const dbUser = await getUserByEmail(userObj.email);

      return {
        statusCode: 200,
        headers: {},
        body: JSON.stringify(dbUser)
      };
    };


    // ./lib/utils.js
    async function getUserFromToken(token) {
      const secret = Buffer.from(process.env.JWT_SECRET, "base64");

      const decoded = jwt.verify(token.replace("Bearer ", ""), secret);

      return decoded;
    }


Enter fullscreen mode Exit fullscreen mode

实现过程/me相当简短直接。AWS 授权器的工作方式是使用策略文档

策略文档必须包含以下信息:

  • 资源(ARN或 Amazon 资源名称,AWS 资源的唯一标识符)
  • 效果(二选"allow""deny"
  • 操作(一个描述所需操作的关键字,在本例中)"execute-api:Invoke"

授权器功能



    const jwt = require("jsonwebtoken");

    function generateAuthResponse(principalId, effect, methodArn) {
      const policyDocument = generatePolicyDocument(effect, methodArn);

      return {
        principalId,
        policyDocument
      };
    }

    function generatePolicyDocument(effect, methodArn) {
      if (!effect || !methodArn) return null;

      const policyDocument = {
        Version: "2012-10-17",
        Statement: [
          {
            Action: "execute-api:Invoke",
            Effect: effect,
            Resource: methodArn
          }
        ]
      };

      return policyDocument;
    }

    module.exports.verifyToken = (event, context, callback) => {
      const token = event.authorizationToken.replace("Bearer ", "");
      const methodArn = event.methodArn;

      if (!token || !methodArn) return callback(null, "Unauthorized");

      const secret = Buffer.from(process.env.JWT_SECRET, "base64");

      // verifies token
      const decoded = jwt.verify(token, secret);

      if (decoded && decoded.id) {
        return callback(null, generateAuthResponse(decoded.id, "Allow", methodArn));
      } else {
        return callback(null, generateAuthResponse(decoded.id, "Deny", methodArn));
      }
    };


Enter fullscreen mode Exit fullscreen mode

部署和测试

现在,让我们运行sls deploy并将最终服务部署到 AWS。输出结果应如下所示:

sls部署

您将拥有 3 个端点,正如我们定义的那样,一个用于 /register,一个用于 /login,一个用于 /me。

首先,让我们使用 cURL 注册一个用户:



    curl -H "Content-Type: application/json" -X POST -d "{\"email\": \"test@example.com\", \"password\": \"test123\"}" https://abc1234567.execute-api.eu-central-1.amazonaws.com/dev/register


Enter fullscreen mode Exit fullscreen mode

我们可以使用相同的 cURL 命令进行登录,只需将末尾的 /register 改为 /login 即可:



    curl -H "Content-Type: application/json" -X POST -d "{\"email\": \"test@example.com\", \"password\": \"test123\"}" https://abc1234567.execute-api.eu-central-1.amazonaws.com/dev/login


Enter fullscreen mode Exit fullscreen mode

这应该会返回一个令牌:



{"auth":true,"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InRtYXhpbWluaUBnbWFpbC5jb20iLCJpZCI6ImI5Zjc2ZjUzLWVkNjUtNDk5Yi04ZTBmLTY0YWI5NzI4NTE0MCIsInJvbGVzIjpbIlVTRVIiXSwiaWF0IjoxNTgzMjE4OTk4LCJleHAiOjE1ODMzMDUzOTh9.noxR1hV4VIdnVKREkMUXvnUVUbDZzZH_-LYnjMGZcVY","status":"SUCCESS"}


Enter fullscreen mode Exit fullscreen mode

这是我们将用于向受保护的 API 端点发出请求的令牌。通常,您会将其存储在客户端 cookie 中,并将其作为 Authorization 标头添加到后续请求中。

最后,让我们使用令牌来测试受保护的端点。我们可以使用以下-H选项将自定义标头传递给 curl:

 curl -H "Authorization: <your token>" https://myn3t4rsij.execute-api.eu-central-1.amazonaws.com/dev/me
Enter fullscreen mode Exit fullscreen mode

一切顺利的话,它应该返回我们的用户记录:



{"passwordHash":"$2a$08$8bcT0Uvx.jMPBSc.n4qsD.6Ynb1s1qXu97iM9eGbDBxrcEze71rlK","createdAt":"Wed Mar 04 2020 12:25:52 GMT+0000 (Coordinated Universal Time)","email":"test@example.com","id":"2882851c-5f0a-479a-81a4-e709baf67383"}

Enter fullscreen mode Exit fullscreen mode




结论

恭喜!您已经学会了如何使用 JWT 授权设计微服务并将其部署到 AWS Lambda。如果您已经学到这里,请考虑在Twitter上关注我。

文章来源:https://dev.to/tmaximini/jwt-authorization-for-serverless-apis-on-aws-lambda-31h9