AWS Lambda 上无服务器 API 的 JWT 授权
无服务器函数允许我们为应用程序编写小型、独立的 API 端点。在本文中,我们将学习如何使用基于 JSON Web 令牌 ( JWT ) 的授权来保护我们的无服务器 API 端点。
太长不看
如果您想直接查看最终代码,可以在这里找到代码仓库:https://github.com/tmaximini/serverless-jwt-authorizer
请继续阅读,了解这里究竟发生了什么。
JWT授权步骤
以下是我们为保护 API 端点安全所需采取的大致步骤:
- 使用用户名、密码注册,密码哈希值将存储在数据库中。
- 使用用户名/密码登录
- 如果密码哈希值与用户存储的 passwordHash 匹配,则根据用户 ID 及其授权范围生成 JWT 令牌。
- 把令牌保存到 Cookie 里 🍪
- 使用此令牌对 HTTP Authorization 标头中的每个请求进行签名
- 设置授权函数,用于验证此令牌(在请求安全 API 路由时)。授权响应可以缓存一段时间,以提高 API 吞吐量。
- 授权器生成一个策略文档,用于允许或拒绝访问服务。
规划我们的应用程序
我们需要一种registerUser方法loginUser。我们还会有一个受保护的/me端点,如果用户身份验证成功,该端点会返回当前用户对象。
这verifyToken是一个额外的 lambda 函数,它被定义为 API 网关授权器,每当我们尝试访问受保护的端点时,它都会在后台被调用/me。
所以我们总共有 4 个 lambda 函数:
使用 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
无服务器 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
数据库层
现在,在授权用户之前,我们需要一种方法来创建用户并将其保存到数据库中。我们选择 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" }
}
});
我们显然不会将密码以明文形式存储在数据库中,因此我们使用 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
用户注册
为了让用户注册我们的服务,我们需要将他们的数据存储在我们的数据库中。有了数据模型,我们现在可以使用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
};
这足以在数据库端创建我们的用户注册信息。
现在让我们添加实际 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 }
};
});
};
我们正在尝试创建用户,如果一切顺利,我们会将用户对象连同 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
};
现在我们可以在用户 lambda 函数中导入并使用此函数。
让我们来分解一下用户登录所需的步骤:
- 从请求有效负载中获取电子邮件和密码
- 尝试从数据库中获取电子邮件用户记录。
- 如果找到,则对密码进行哈希处理,并与用户记录中的 passwordHash 进行比较
- 如果密码正确,则创建有效的 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);
}
注册和登录功能到位后,我们现在可以着手实现受保护的 API 端点了。
受保护的端点
假设我们的 API 中有一个受保护的资源,例如用户个人资料。我们只希望已登录用户能够查看和更新他们的个人资料信息。让我们实现一个/me端点,该端点仅从数据库中返回当前登录用户的用户记录。
以下是我们需要实施的步骤:
- 验证 JWT 令牌(由我们的 Lambda 授权函数完成)
- 从数据库中获取相关用户
- 返回用户
听起来很简单,对吧?我们来看看:
// ./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;
}
实现过程/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));
}
};
部署和测试
现在,让我们运行sls deploy并将最终服务部署到 AWS。输出结果应如下所示:
您将拥有 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
我们可以使用相同的 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
这应该会返回一个令牌:
{"auth":true,"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InRtYXhpbWluaUBnbWFpbC5jb20iLCJpZCI6ImI5Zjc2ZjUzLWVkNjUtNDk5Yi04ZTBmLTY0YWI5NzI4NTE0MCIsInJvbGVzIjpbIlVTRVIiXSwiaWF0IjoxNTgzMjE4OTk4LCJleHAiOjE1ODMzMDUzOTh9.noxR1hV4VIdnVKREkMUXvnUVUbDZzZH_-LYnjMGZcVY","status":"SUCCESS"}
这是我们将用于向受保护的 API 端点发出请求的令牌。通常,您会将其存储在客户端 cookie 中,并将其作为 Authorization 标头添加到后续请求中。
最后,让我们使用令牌来测试受保护的端点。我们可以使用以下-H选项将自定义标头传递给 curl:
curl -H "Authorization: <your token>" https://myn3t4rsij.execute-api.eu-central-1.amazonaws.com/dev/me
一切顺利的话,它应该返回我们的用户记录:
{"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"}
结论
恭喜!您已经学会了如何使用 JWT 授权设计微服务并将其部署到 AWS Lambda。如果您已经学到这里,请考虑在Twitter上关注我。
文章来源:https://dev.to/tmaximini/jwt-authorization-for-serverless-apis-on-aws-lambda-31h9

