Masonite 权威指南(二)
五、使用数据库
在前一章中,我们学习了所有关于表格的知识。我们创建了一些,甚至让它们从远程数据源获取数据。这很有用,但除非我们还可以从自己的数据源中存储和检索数据,否则用处不大。因此,在这一章中,我们将学习如何建立一个数据库,如何在其中存储数据,以及如何从数据库中取出相同的数据。
我如何存储数据?
我已经暗示了一种方法,但实际上还有许多其他方法来存储和检索数据。我们可以走“老路”,使用 XML 或 JSON 数据的平面文件。这当然是最简单的方法之一,但是它也有一些问题,比如文件锁和有限的分发。
我们可以使用类似 Firebase 的东西,它仍然是一个数据库,但我们不必管理和控制它。它的成本也比在同一台服务器上使用一个数据库要高。管理起来有点困难,也没有它可能达到的速度快。
相反,我们将使用一个本地 MySQL 数据库(和一些 SQL 引导)来存储我们的数据。Masonite 对 MySQL 数据库有很大的支持,甚至有一些工具可以帮助我们构建数据库。这会很有趣的!
用代码保存数据库
此代码可在 https://github.com/assertchris/friday-server/tree/chapter-6 找到。
通常,在一本书的这一点上,作者可能会要求你跳出到另一个应用。他们可能会要求您直接开始规划和构建您的数据库,并完全断开与代码编辑器的连接。出于几个原因,我不会要求您这样做:
-
我相信数据库能够并且应该在你的应用代码中表现出来,因为那是它们被测试的地方,也是你需要理解它们的首要地方。
-
Masonite 提供了实现这一目标的工具。我喜欢使用的所有框架都提供了这些工具。这是一个已经解决的问题!
假设我们想开始存储播客(通过我们现有的 UI“订阅”它们的结果)。我们可能会决定将这些播客 URL 一起存储在 users 表中。可能在文本字段中,用逗号分隔。
或者,我们可能希望创建一个新表,并将其命名为 subscriptions。对我来说,第二种方法感觉更干净,因为有些用户可能根本就不想订阅播客。相反,他们可能想听音乐!
首先,我们需要使用 craft 创建一个叫做迁移的东西:
craft migration create_subscriptions_table
这将创建一个新的空迁移:
from orator.migrations import Migration
class CreateSubscriptionsTable(Migration):
def up(self):
"""Run the migrations."""
pass
def down(self):
"""Revert the migrations."""
pass
这是来自database/migrations/x_create_subscriptions_table.py。
迁移分为两个部分:
-
up–对现有数据库结构进行新的添加/更改 -
down–这些新增/变更可以回滚,以防出现问题或迁移发生得太快
让我们定义一个新表:
from orator.migrations import Migration
class CreateSubscriptionsTable(Migration):
def up(self):
with self.schema.create('subscriptions') as table:
table.increments('id')
table.string('url')
table.string('title')
table.timestamps()
def down(self):
self.schema.drop('subscriptions')
这是来自database/migrations/x_create_subscriptions_table.py。
首先,我们的订阅表有点小而且简单。我们将存储播客的标题和可以找到播客详细信息的 URL。我们通过调用schema.create方法创建一个表。这将返回一个新的 table 对象,我们可以对它调用各种方法,以便在表中创建字段。
有几个领域非常普遍和重要:
-
increments–一个自动编号的整数字段,是表格的主键 -
timestamps–几个时间戳字段,用于记住特定事件发生的时间(比如记录创建到最后更新的时间)
还有许多其他字段类型:
-
string–长度受限的字符串字段 -
text–可变长度字符串字段 -
integer–一个整数字段 -
float–十进制字段 -
timestamp–时间戳字段
字段上还可能有修饰符,这会影响字段的元数据。例如,我们可以应用其中的一个:
-
nullable–当字段允许包含值NULL时 -
default(value)–不可空字段应具有的默认值 -
unsigned–用于任何数值字段,因此它们可以存储两倍的非负数这里有很多我没有提到的字段类型。如果你在寻找缺失的东西,你可以参考演说家的文档。astorar 是底层数据库库的名字,它使得所有这一切成为可能。
创建新表是进行迁移的一个原因,但是您可能还想改变表的结构。在这种情况下,您可以使用schema.table方法:
from orator.migrations import Migration
class ChangeSubscriptionsTable(Migration):
def up(self):
with self.schema.table('subscriptions') as table:
table.string('title', 200).change()
def down(self):
with self.schema.table('subscriptions') as table:
table.string('title').change()
这是来自database/migrations/x_change_subscriptions_table.py。
除了改变一个字段,这也是一个如何使用down方法的好例子。这个想法是,您在数据库中添加或更改的任何内容都在down方法中被“还原”。我们将 title 字段更改为有长度限制,因此这一回滚将删除 200 个字符的限制。
类似地,我们也可以调用一个table.dropColumn(name)方法来删除一个字段,或者调用一个schema.drop(name)方法来完全删除这个表。
以这种方式思考数据库表需要一点时间。我鼓励你通读一下关于管理迁移的 astral 文档,这样你就可以熟悉在迁移中你可以做的所有不同的事情。
在运行这些迁移之前,我们可能需要确保一切都已设置好。您应该安装了 MySQL 数据库。如果您在 macOS 上(并且安装了 Homebrew),您可以这样做:
brew install mysql
对于其他系统和配置,请查看 astorar 配置文档。
您还需要安装一个数据库依赖项:
pip install mysqlclient
最后,您需要确保您的.env数据库凭证与您已经创建的数据库相匹配:
DB_DATABASE=Friday
DB_USERNAME=<username>
DB_PASSWORD=<password>
家酿默认使用用户名【root】和密码。这些不是我所说的安全凭证,但是如果这是你第一次在你的系统上使用 MySQL,了解它们是有好处的。当然,您可以根据自己的需要进行更改。即使有了这些凭证,您仍然需要确保 MySQL 正在运行,并且您已经创建了一个与您配置的数据库相匹配的数据库。
用虚拟数据填充数据库
有些人用空数据库测试他们的应用,或者通过使用站点手动插入数据。这可能有点像陷阱,因为这意味着他们插入的数据符合他们对网站使用方式的预期,并且很少涵盖应用特定部分可能处于的所有重要状态。让我们考虑一下我们的应用可能处于的不同状态:
-
空的搜索屏幕,在我们搜索播客之前
-
当没有找到结果时,空的搜索屏幕
-
“细节”屏幕,显示播客的细节
-
“订阅”屏幕,显示某人订阅的所有播客
-
当用户没有订阅任何播客时,一个空的“订阅”屏幕
更不用说订阅和取消订阅播客的所有确认屏幕了。
而且,这只是可能成为一个巨大应用的一种数据类型!想象一下,尝试手动测试所有这些东西。您可能会忘记大约一半的页面,并且手工测试会花费很长时间(或者根本不会发生)。
除了这些问题,想象一下你在应用中拥有的数据类型:
-
你会迎合大标题的播客吗?
-
你会满足数百个搜索结果吗?
-
您的应用可以处理播客标题中的 Unicode 字符吗?
用测试数据填充数据库(或者通常所说的播种)是一个重要的设计步骤,因为它帮助你记住所有你需要考虑的边缘情况和状态。当与测试结合时(我们将在第十五章中讲到),种子数据迫使设计变得健壮。
问题变成了:我们如何播种数据库数据?有一个工艺指令:
craft seed subscriptions
这将创建一个新的种子(er)文件,如下所示:
from orator.seeds import Seeder
class SubscriptionsTableSeeder(Seeder):
def run(self):
pass
这是来自database/seeds/subscriptions_table_seeder.py。
我们可以稍微改变一下,这样我们就可以确定它正在运行:
from orator.seeds import Seeder
class SubscriptionsTableSeeder(Seeder):
def run(self):
print('in the seeder')
这是来自database/seeds/subscriptions_table_seeder.py。
在运行之前,我们需要将它添加到“基础”种子:
from orator.seeds import Seeder
# from .user_table_seeder import UserTableSeeder
from .subscriptions_table_seeder import SubscriptionsTableSeeder
class DatabaseSeeder(Seeder):
def run(self):
# self.call(UserTableSeeder)
self.call(SubscriptionsTableSeeder)
这是来自database/seeds/database_seeder.py。
这个播种机是 craft 运行所有其他播种机的入口点。我已经注释掉了用户的东西,因为在第八章之前我们不需要它。我还添加了订阅种子,并使用self.call方法调用它。
让我们播种数据库,看看订阅播种程序是否正在运行:
craft seed:run
> in the seeder
> Seeded: SubscriptionsTableSeeder
> Database seeded!
如果您还看到“在种子中”文本,则订阅种子正在工作。让我们了解一下如何读写数据库。
写入数据库
运行一个数据库 UI 应用会很有帮助,这样您就可以看到我们将要对数据库做的事情。强烈推荐 TablePlus 或者 Navicat。如果你正在寻找更便宜的东西,请查看 HeidiSQL。
我们将学习如何与数据库交互,而 astorar 将生成并使用 SQL 来完成这一任务。不需要懂 SQL,但无疑会有帮助。在 www.apress.com/us/databases/mysql 查看阿普瑞斯关于这个主题的书籍。
让我们通过假装订阅开始向数据库写入数据。我们的订阅表有几个需要填写的材料字段:
from config.database import DB
from orator.seeds import Seeder
class SubscriptionsTableSeeder(Seeder):
def run(self):
DB.table('subscriptions').insert({
'url': 'http://thepodcast.com',
'title': 'The podcast you need to listen to',
})
这是来自database/seeds/subscriptions_table_seeder.py。
数据库连接是在应用的 config 部分定义的,我们可以从那里提取连接实例,并写入其中。如果您已经打开了数据库 GUI,现在您应该在 subscriptions 表中看到一个订阅。您还应该在控制台中看到相应的 SQL 语句。
有用的是,我们不需要写出完整的 SQL 语句来执行它。这是 astorar 试图构建能在它支持的任何引擎中工作的 SQL 语句的副作用。这个想法是,我们应该能够转移到一个不同的(受支持的)引擎,并且我们所有抽象的 SQL 语句应该继续工作。
我们还可以做其他类型的操作,但是我们一会儿会讲到这些操作的例子。
这段代码只是第一步。如果我们希望我们的播种者真的有用(并且我们的设计是健壮的),我们需要在播种阶段使用随机数据。演说家自动安装了一个名为 Faker 的软件包。这是一个随机的假数据发生器,我们可以用在我们的播种机上:
from config.database import DB
from faker import Faker
from orator.seeds import Seeder
class SubscriptionsTableSeeder(Seeder):
def run(self):
fake = Faker()
DB.table('subscriptions').insert({
'url': fake.uri(),
'title': fake.sentence(),
})
这是来自database/seeds/subscriptions_table_seeder.py。
现在,我们可以为设计中不同种类和数量的数据做好准备,因为我们无法准确控制要输入哪些数据。我们不仅仅是按照我们期望的数据填充它们。Faker 提供了很多有用的数据类型,所以我不打算一一介绍。足以说明,Faker 文档是惊人的,你一定要去看看: https://faker.readthedocs.io/en/stable/providers.html 。
从数据库中读取
插入数据很酷,但是我们如何将数据从数据库中取出,以便在应用需要它的部分显示它呢?让我们做一个页面来列出我们的订阅。
from config.database import DB
# ...snip
class PodcastController(Controller):
# ...snip
def show_subscriptions(self, view: View):
subscriptions = DB.table('subscriptions').get()
return view.render('podcasts.subscriptions', {
'subscriptions': subscriptions,
})
这是来自app/http/controllers/PodcastController.py。
@extends 'layout.html'
@block content
<h1 class="pb-2">Subscriptions</h1>
<div class="flex flex-row flex-wrap">
@if subscriptions|length > 0
@for subscription in subscriptions
@include 'podcasts/_subscription.html'
@endfor
@else
No subscriptions
@endif
</div>
@endblock
这是来自resources/templates/podcasts/subscriptions.html。
<div class="w-full flex flex-col pb-2">
<div class="text-grey-darker">{{ subscription.title }}</div>
<div class="text-sm text-grey">{{ subscription.url }}</div>
</div>
这是来自resources/templates/podcasts/_subscription.html。
RouteGroup(
[
# ...snip
Get('/subscriptions',
'PodcastController@show_subscriptions').name('-show-subscriptions')
],
prefix='/podcasts',
name='podcasts',
),
这是来自routes/web.py。
这四个文件你现在应该比较熟悉了。第一个是附加的控制器动作,它响应我们在第四个中创建的路由。第二个和第三个文件是显示订阅列表的标记(视图)。在浏览器中,它应该类似于图 5-1 。
图 5-1
列出存储在数据库中的订阅
隐藏在新控制器动作中的是从数据库中提取订阅的数据库代码:DB.table('subscriptions').get()。
过滤数据库数据
如果我们想过滤那个列表呢?首先,我们需要添加筛选依据字段。最有用的是添加“收藏”订阅的功能,这样它就会出现在列表的顶部。为此,我们需要创建另一个迁移:
from orator.migrations import Migration
class AddFavoriteToSubscriptionsTable(Migration):
def up(self):
with self.schema.table('subscriptions') as table:
table.boolean('favorite').index()
def down(self):
with self.schema.table('subscriptions') as table:
table.drop_column('favorite')
这是来自database/migrations/x_add_favorite_to_subscriptions_table.py。
在这个新的迁移中,我们添加了一个名为favorite的boolean字段,并为它创建了一个索引。notes 迁移是反向的;我们还会删除这个专栏,这样它就像从未存在过一样。知道您可以使用 craft 回滚所有迁移并再次运行它们可能是有用的:
craft migrate:refresh --seed
我们可能还需要更新种子来考虑这个新字段,因为我们不允许该字段为空,并且我们也没有指定默认值:
from config.database import DB
from faker import Faker
from orator.seeds import Seeder
class SubscriptionsTableSeeder(Seeder):
def run(self):
fake = Faker()
DB.table('subscriptions').insert({
'url': fake.uri(),
'title': fake.sentence(),
'favorite': fake.boolean(),
})
这是来自database/seeds/subscriptions_table_seeder.py。
现在我们有了一个新的可过滤字段,我们可以将订阅分成“普通订阅”和“收藏订阅”列表:
@extends 'layout.html'
@block content
<h1 class="pb-2">Favorites</h1>
<div class="flex flex-row flex-wrap">
@if favorites|length > 0
@for subscription in favorites
@include 'podcasts/_subscription.html'
@endfor
@else
No subscriptions
@endif
</div>
<h1 class="pb-2">Subscriptions</h1>
<div class="flex flex-row flex-wrap">
@if subscriptions|length > 0
@for subscription in subscriptions
@include 'podcasts/_subscription.html'
@endfor
@else
No subscriptions
@endif
</div>
@endblock
这是来自resources/templates/podcasts/subscriptions.html。
我们可以复制基于订阅的代码块(也许稍后,我们可以包含另一个),这样我们就可以使用不同的订阅项目源。我们可以称之为收藏夹,但这也意味着我们需要从控制器提供:
def show_subscriptions(self, view: View):
favorites = DB.table('subscriptions').where('favorite', True).get()
subscriptions = DB.table('subscriptions').where(
'favorite', '!=', True).get()
return view.render('podcasts.subscriptions', {
'favorites': favorites,
'subscriptions': subscriptions,
})
这是来自app/http/controllers/PodcastController.py。
在这里,我们使用where方法根据他们最喜欢的字段是否有真值来过滤订阅。这是许多有用的查询方法之一,包括
-
where有两个参数,第一个是字段,第二个是值 -
where有三个参数,其中中间的参数是比较运算符(就像我们如何使用!=来表示“不等于”) -
where_exists使用单个查询对象,以便外部查询仅在内部查询返回结果时返回结果(类似于左连接) -
where_raw带有一个原始的 where 子句字符串(如subscriptions.favorite = 1)
这些有一些小标题,你可以通过阅读 https://orator-orm.com/docs/0.9/query_builder.html#advanced-where 的文档找到。记住确切的语法并不重要,重要的是要知道这些方法的存在,这样你就知道在文档的什么地方可以学到更多关于它们的知识。
如果我们要让 favorite 字段为空,那么第二个查询将捕获 favorite 没有设置为True的所有记录,包括 favorite 为False和Null的记录。我们可以说得更明确一点,比如说where('favorite', False),但是如果我们曾经将 favorite 字段设置为可空,我们就必须记住修改它。
更新数据库数据
让我们添加喜欢(和不喜欢)数据库记录的功能。我们需要几个新的控制器动作和路由:
def do_favorite(self, request: Request):
DB.table('subscriptions').where('id', request.param('id')).update({
'favorite': True,
})
return request.redirect_to('podcasts-show-subscriptions')
def do_unfavorite(self, request: Request):
DB.table('subscriptions').where('id', request.param('id')).update({
'favorite': False,
})
return request.redirect_to('podcasts-show-subscriptions')
这是来自app/http/controllers/PodcastController.py。
除了一个insert方法,我们还可以使用一个update方法来影响数据库记录。这两个动作非常相似,但是我认为最好不要将它们抽象成一个方法,因为对于哪个动作做什么,这是不可否认的清楚。
更新订阅后,我们还将重定向回订阅页面。我们需要设置路由并更改订阅,包括:
from masonite.routes import Get, Patch, Post, Match, RouteGroup
ROUTES = [
# ...snip
RouteGroup(
[
# ...snip
Patch('/subscriptions/@id/favorite', 'PodcastController@do_favorite').name('-favorite- subscription'),
Patch('/subscriptions/@id/unfavorite', 'PodcastController@do_unfavorite').name('-unfavorite- subscription'),
],
prefix='/podcasts',
name='podcasts',
),
]
这是来自routes/web.py。
<div class="w-full flex flex-col pb-2">
<div class="text-grey-darker">{{ subscription.title }}</div>
<div class="text-sm text-grey">{{ subscription.url }}</div>
<div class="text-sm text-grey">
<form class="inline-flex" action="{{ route('podcasts-favorite- subscription', {'id': subscription.id}) }}" method="POST">
{{ csrf_field }}
{{ request_method('PATCH') }}
<button onclick="event.preventDefault(); this.form.submit()">favorite</button>
</form>
<form class="inline-flex" action="{{ route('podcasts-unfavorite- subscription', {'id': subscription.id}) }}" method="POST">
{{ csrf_field }}
{{ request_method('PATCH') }}
<button onclick="event.preventDefault(); this.form.submit()">unfavorite</button>
</form>
</div>
</div>
这是来自resources/templates/podcasts/_subscription.html。
因为我们使用了非 GET 和非 POST 请求方法(用于路由),所以我们需要使用表单来启动喜欢/不喜欢的操作。我们使用request_method视图助手告诉 Masonite 这些是PATCH请求。我们应该能够使用按钮在我们创建的列表之间切换订阅。
删除数据库数据
我希望我们添加的最后一点功能是取消订阅播客的能力。
这需要的代码比我们已经制作和学习的代码多一点:
<form class="inline-flex" action="{{ route('podcasts-unsubscribe', {'id': subscription.id}) }}" method="POST">
{{ csrf_field }}
{{ request_method('DELETE') }}
<button onclick="event.preventDefault(); this.form.submit()">unsubscribe</button>
</form>
这是来自resources/templates/podcasts/_subscription.html。
这类似于我们的补丁路由,但是我们需要的适当方法(对于“取消订阅”)是 DELETE。同样,我们需要使用Delete路由方法,在定义路由时:
from masonite.routes import Delete, Get, Patch, Post, Match, RouteGroup
ROUTES = [
# ...snip
RouteGroup(
[
# ...snip
Delete('/subscriptions/@id/unsubscribe', 'PodcastController@do_unsubscribe').name('-unsubscribe'),
],
prefix='/podcasts',
name='podcasts',
),
]
这是来自routes/web.py。
而且,我们可以使用delete方法从 subscriptions 表中删除记录:
def do_unsubscribe(self, request: Request):
DB.table('subscriptions').where('id', request.param('id')).delete()
return request.redirect_to('podcasts-show-subscriptions')
这是来自app/http/controllers/PodcastController.py。
《梅森尼特》的这一部分有如此多的深度,以至于没有哪一章能够做到公正。这是一种尝试,但你要掌握所有的演讲人必须提供的,这里的唯一方法是深入挖掘文档,并实际使用演讲人做不同的和复杂的事情。
您可以在 https://orator-orm.com/docs/0.9/query_builder.html#introduction 找到这些 DB 语句的详细文档。
通过模型简化代码
既然我们已经掌握了如何编写抽象的数据库查询,我想让我们看看如何通过明智地使用模型来简化这些查询。模型就是我们所说的遵循活动记录数据库模式的对象。起初,这是一个有点棘手的概念。基本思想是我们将数据库表定义为类,用静态方法引用表级动作,用实例方法引用行级动作。
我们可以定义一个新的模型,使用 craft:
craft model Subscription
这会产生一个新的类,如下所示:
from config.database import Model
class Subscription(Model):
"""Subscription Model."""
pass
这是来自app/Subscription.py。
这个Subscription类扩展了演说家Model类,这意味着它已经有了很多减少我们已经编写的代码的魔法。我们可以通过直接引用模型来简化我们的初始检索查询集:
from app.Subscription import Subscription
# ...later
def show_subscriptions(self, view: View):
# favorites = DB.table('subscriptions').where('favorite', True).get()
favorites = Subscription.where('favorite', True).get()
# subscriptions = DB.table('subscriptions').where(
# 'favorite', '!=', True).get()
subscriptions = Subscription.where(
'favorite', '!=', True).get()
return view.render('podcasts.subscriptions', {
'favorites': favorites,
'subscriptions': subscriptions,
})
这是来自app/http/controllers/PodcastController.py。
类似地,我们可以通过直接引用模型来简化播种、更新和删除:
from app.Subscription import Subscription
# from config.database import DB
from faker import Faker
from orator.seeds import Seeder
class SubscriptionsTableSeeder(Seeder):
def run(self):
fake = Faker()
# DB.table('subscriptions').insert({
# 'url': fake.uri(),
# 'title': fake.sentence(),
# 'favorite': fake.boolean(),
# })
Subscription.create(
url=fake.uri(),
title=fake.sentence(),
favorite=fake.boolean(),
)
# ...or
Subscription.create({
'url': fake.uri(),
'title': fake.sentence(),
'favorite': fake.boolean(),
})
这是来自database/seeds/subscriptions_table_seeder.py。
第一次运行时,您可能会遇到一个MassAssignmentError。这是因为 Masonite 可以防止意外的记录批量更新。我们可以通过向模型添加一个特殊属性来绕过这一点:
class Subscription(Model):
__fillable__ = ['title', 'url', 'favorite']
这是来自app/Subscription.py。
def do_favorite(self, request: Request):
# DB.table('subscriptions').where('id', request.param('id')).update({
# 'favorite': True,
# })
subscription = Subscription.find(request.param('id'))
subscription.favorite = True
subscription.save()
return request.redirect_to('podcasts-show-subscriptions')
def do_unfavorite(self, request: Request):
# DB.table('subscriptions').where('id', request.param('id')).update({
# 'favorite': False,
# })
subscription = Subscription.find(request.param('id'))
subscription.favorite = False
subscription.save()
return request.redirect_to('podcasts-show-subscriptions')
def do_unsubscribe(self, request: Request):
# DB.table('subscriptions').where('id', request.param('id')).delete()
subscription = Subscription.find(request.param('id'))
subscription.delete()
return request.redirect_to('podcasts-show-subscriptions')
这是来自app/http/controllers/PodcastController.py。
我把前面的 DB 调用留在这里,但是注释掉了,所以我们可以把它们与基于模型的代码进行比较。在某些情况下,使用模型的代码会稍微多一点,但是结果会清晰得多。随着我们继续阅读本书的其余部分,您将会看到更多的模型代码和更少的低级查询代码。
摘要
在这一章中,我们首先了解了如何使用数据库。我们从定义数据库结构一直到以模型的形式表示表和行。这有点像旋风之旅,但也是本书其余部分的基础。
花些时间试验不同的数据库查询和操作,看看它们如何在模型形式中使用。尝试创建一个“订阅”操作,这样在搜索结果中返回的播客会保存到数据库中。如果你能做到这一点,根据你在本章中学到的,那么你就在掌握 Masonite 的火箭船上了!
六、安全
Masonite 的开发考虑到了应用的安全性。当一个版本准备好了,维护者会检查它是否有安全漏洞。Masonite 还利用了诸如 DeepSource 之类的服务,这些服务将扫描每个 pull 请求,查找可能的安全漏洞、代码气味、可能的 bug 和其他代码问题。
然而,认为所有的安全漏洞永远不会进入代码库是愚蠢的,特别是因为攻击应用的新方法可以被发现或发明。还有其他方法来处理这种情况,我们将在本章后面讨论。
另一个重要的提醒是,当我们谈论 Masonite 和安全性时,我们真正谈论的是应用安全性。Masonite 是应用,我们只能保护应用免受漏洞。还有许多其他类型的漏洞是 Masonite 无法控制的。例如,您托管 Masonite 的特定操作系统版本上可能存在漏洞,从而导致攻击。
因此,一定要注意,仅仅因为您的应用是安全的,并不意味着您不容易受到攻击。您将需要了解许多不同的攻击途径,并确保您受到保护。
CSRF 保护
CSRF 代表跨站点请求伪造。最简单地说,它在两个方面有所帮助:
-
防止不良行为者代表用户发出请求。
-
防止恶意代码潜入您的站点,使其看起来像是来自您的站点的按钮或警报,但它实际上是去往另一个站点。
让我们以登录表单为例。用户输入电子邮件和密码,然后点击提交。提交到一个端点,在这里我们检查用户名和密码是否正确,然后让用户登录。但是,是什么阻止了人们简单地向该页面发送 POST 请求呢?现在,任何人都可以通过 Postman 或 cURL 一遍又一遍地点击端点来强行通过。
另一种保护是防止恶意代码被保存到数据库中,然后显示在您的站点上。如果有人可以保存一个 JavaScript <script>标签,简单地隐藏你的登录按钮并显示他们自己的登录按钮,除非你的输入被发送到他们的服务器,那么这将是对安全性的毁灭性打击。Masonite,以及,通过扩展,演说家和 Jinja,通过清除输入的每一步来防止这种攻击。
让我们更多地讨论 Masonite 如何防范这些攻击。
清洗请求输入
默认情况下,Masonite 会为您清除请求输入,因此您不必担心用户提交恶意的 JavaScript 代码。Masonite 会清理掉<、>和&角色。这都是在幕后完成的。例如,如果我们有这段代码
"""
POST {
'bad': '<script>alert('Give me your bank information')</script>'
}
"""
def show(self, request: Request):
request.input('bad')
那么我们实际上会得到这样一个值
<script>alert('Give me your bank information')</script>
HTML 实体现在被转义了,如果它出现在您的网页上,您的浏览器会简单地将它显示为文本,而不是执行脚本。
你可以通过传入clean=False来选择不清理某个东西,但是如果你选择这样做的话,风险自负。通过阅读本章的其余部分,您应该成为 Masonite 应用安全方面的专家。
CSRF 代币
我们将在本节中多次讨论 CSRF 代币,所以让我们花几段时间来讨论它实际上是什么以及它来自哪里。
CSRF 令牌背后的技巧非常简单:我们给用户一个令牌,用户将令牌发送回来。令牌只是给客户机的一个不可预测的秘密字符串,服务器也知道它。这允许客户端接收令牌并发回令牌。如果我们给客户端的令牌与我们得到的令牌不同,那么我们将拒绝请求。
我们怎么知道它来自我们的网站?因为 CSRF 令牌只是注册给来自我们网站的用户的一个 cookie。在用户会话开始时(比如当用户第一次访问我们的网站时),会创建一个名为csrf_token的 cookie,它只是生成一个完全随机的字符串,这个字符串是我们的常量,我们可以稍后检查。
当用户提交表单时,他们也将提交这个 CSRF 令牌。我们将获取该令牌,并将其与我们保存在 cookie 中的令牌进行核对。如果 cookie 值和他们提交的令牌值都匹配,那么我们可以非常安全地假设注册了 cookie 的用户和提交表单的用户是同一个人。
表单提交
Masonite 保护表单提交免受 CSRF 攻击。前面描述的 CSRF 流程正是 CSRF 表单提交保护的工作方式。
<form action=".." method="..">
{{ csrf_field }}
<input ..>
</form>
你在表格中看到的实际上就是
<input type="hidden" name=" token" value="906b697ba9dbc5675739b6fced6394">
当页面完全呈现时。让我们解释一下这是怎么回事,因为它很重要。
首先,它创建了一个隐藏的输入;这意味着它实际上不会显示在表单上,但是它会在那里,除了用户提交的输入之外,它还会提交这个输入。
它做的第二件事实际上是提交名为 __ token的 CSRF 令牌,这样我们就可以从后端获取它来进行验证。
最后,它将值设置为等于 CSRF 令牌。默认情况下,每个用户会话生成一次 CSRF 令牌(您可以在本章后面的每个请求中添加这个令牌)。
当用户提交表单时,它将检查 cookie 和用户提交的令牌,并验证它们是否匹配。如果匹配,它会让请求通过;如果不匹配,那么它将阻塞请求,因为如果值不匹配,那么用户要么恶意提交表单,要么恶意操纵令牌值。不管怎样,我们都会阻止这个请求。
AJAX 调用
AJAX 调用与表单提交略有不同。AJAX 调用不利用表单提交的相同类型的请求。AJAX 调用通常是通过 JavaScript 完成的,执行所谓的“异步请求”,并在重定向或在 JavaScript 中执行其他逻辑之前等待响应。
因此,前面发送隐藏输入的方法不再有效。但是不要担心,因为您仍然可以在发送请求的同时发送令牌,但是您只是在 Ajax 调用中发送它。
类似于如何使用{{ csrf_field }}内置变量,您可以使用{{ csrf_token }}变量来获取令牌。然后,我们可以将该令牌放入 meta 标记中,并将其放入 HTML 的 head 部分:
<head>
<title>Page Title</title>
<meta name="csrf-token" content="{{ csrf_token }}">
</head>
现在,为了让令牌与我们的调用一起传递,我们可以简单地从这个元字段中获取令牌:
let token = document.head.querySelector('meta[name="csrf-token"]').content;
剩下的取决于您使用的 JavaScript 框架。如果您使用的是 jQuery,它可能看起来像这样:
let token = document.head.querySelector('meta[name="csrf-token"]').content;
$.ajax({
type: "POST",
data: {
'email': 'user@example.com',
'password': 'secret',
'__token': token
},
success: success,
dataType: dataType
});
请注意,我们传递 __ token的方式与我们传递表单请求的方式非常相似,但是现在我们需要更加手动地传递它。
有些人错误地试图在服务器之间或应用之间使用 CSRF 令牌。请记住,CSRF 令牌只能在同一应用中使用。如果您需要在应用之间或服务器之间发出请求,您应该考虑使用 JWT 令牌和 API 认证方法或类似的方法,而不是 CSRF 令牌。
密码重置
如果您是第一次开始一个项目,建议运行craft auth命令,它将为您搭建几个视图、路径和控制器,处理登录、注册、认证和密码重置。您不需要这样做,但这将防止您需要自己构建它,并且它是 100%可定制的。
应用最容易受到攻击的部分之一是密码重置功能。由于应用的这一部分处理的是像密码这样的敏感数据,所以它很容易成为恶意参与者的攻击目标。保护您的密码重置是至关重要的。
重置密码有几种不同的最佳方法,但是让我们解释一下 Masonite 是如何做的,并且我们会解释一些关键点。
表单
密码重置过程的第一部分是进入密码重置表单。默认情况下,如果你使用默认脚手架,这是在/password路由下。该表单是一个用于输入电子邮件的提交输入和一个简单的提交按钮。
当用户输入他们的电子邮件并点击提交时,我们在数据库中查找该电子邮件,如果它存在,我们使用我们数据库中的电子邮件地址向他们发送一封电子邮件,并附上一个带有令牌的 URL。他们将收到一个成功通知,告知他们一封电子邮件已经发送给他们,并按照那里的指示进行操作。
你可能错过的第二个重要部分是,我们向从我们的数据库中获得的用户的电子邮件发送电子邮件,而我们不会向用户****提交的电子邮件发送电子邮件。换句话说,我们使用用户提交的电子邮件地址在数据库中查找用户,但是我们向数据库中的用户发送电子邮件。乍一看,这似乎很奇怪;我的意思是,他们是相同的电子邮件地址,对不对?不完全是。Unicode 字符和非 Unicode 字符之间存在差异。对盲人来说,它们可能看起来是一样的。
Unicode 攻击
让我们来谈谈 Unicode 攻击,这是密码重置方面最危险的攻击之一。
首先,让我们解释一下什么是 Unicode。Unicode 中的每个字符都有一个数字。这些数字以类似于U+0348的格式存储,然后可以在所有支持 Unicode 的系统(几乎是所有系统)上解码。它基本上是一个字符编码标准。问题是一些 Unicode 字符与其他 Unicode 字符非常相似。
让我们看看这两个电子邮件地址:
John@Gıthub.com
John@Github.com
看起来有点相似,对吧?如果你仔细想想,你可能会发现GitHub中的i有些奇怪;上面没有点。
现在让我们试着做一个比较:
>>> 'ı' == 'i'
False
进行比较会返回 False,但现在让我们将它们都转换为 upper:
>>> 'ı'.upper() == 'i'.upper()
True
这是因为一些 Unicode 字符有“大小写冲突”,这意味着当i被转换为大写,而被转换为大写时,它们都是I。现在它们匹配了。更可怕的是,我们可以用之前开始的原始电子邮件地址进行同样的比较:
>>> 'John@Gıthub.com'.upper() == 'John@Github.com'.upper()
True
这些电子邮件地址看起来不同,但实际上是真实的。这就是攻击的来源。
用户可以提交电子邮件地址John@G来重置密码;我们可能会在数据库中找到John@Github.com的匹配项,然后发送一个电子邮件地址到不正确的John@G``thub.com地址,从而利用我们的密码重置表单。
这就是为什么我们确保发送电子邮件到我们的数据库中的地址,因为它确保我们将重置指令发送到正确的电子邮件地址。
SQL 注入
既然我们已经谈到了攻击的话题,我们还应该谈谈另一种非常常见的攻击,叫做 SQL 注入。这可能是最常见的攻击,如果你从事软件开发超过 5 分钟,你就会听说过这种攻击。
SQL 注入其实很简单。当您没有正确整理传入的用户数据,然后使用这些数据进行查询时,就会发生 SQL 注入。让我们来看一个简单的代码片段,它可能看起来像 Masonite 和 astorar:
def show(self, request: Request):
User.where_raw(f"email = {request.input('email')}").first()
在正常情况下,这可能会生成如下查询:
SELECT * FROM `users` WHERE `email` = 'user@example.com'
看起来很天真,但这是假设请求输入等于user@example.com。如果它等于更恶意的东西,比如说,user@example.com; drop table users;?
现在这个查询看起来像这样
SELECT * FROM `users` WHERE `email` = user@example.com; drop table users;
这实际上是两个查询。SQL 将尝试运行第一个查询(可能会抛出语法错误),然后尝试运行第二个有效的查询,并实际删除 users 表。
用户现在将查询“注入”到我们的数据库中,因为我们有一个代码漏洞。
查询绑定
注意,在前面的代码示例中,我们使用了一个原始查询。我演示这个示例是因为代码示例可能如下所示:
def show(self, request: Request):
User.where('email', request.input('email')).first()
在这个例子中,查询实际上有点不同。演说家现在将生成这样一个查询:
SELECT * FROM `users` WHERE `email` = ?
然后,作为第二步的一部分,它会将输入内容发送到数据库。然后,底层数据库包将负责净化输入,以确保在发送到数据库之前没有恶意行为。
批量分配
astorar 有一种非常特殊的与类和数据库交互的方式。你会注意到,演说家模型非常简单,因为演说家为你处理所有的重担。
首先,让我们谈谈两种方法,它们实际上是对演说家的大规模分配。批量赋值是指从大量输入中更新一个表。
例如,这两行代码是批量赋值的:
def show(self, request: Request):
User.create(request.all())
User.find(1).update(request.all())
这段代码是而不是批量赋值:
def show(self, request: Request):
user = User.find(1)
user.admin = request.input('is_admin')
user.save()
演说家的设计模式为一种称为大规模任务攻击的攻击打开了大门。
让我们先来看看这段代码,然后我们再来浏览一下:
"""
POST {
'email': 'joe@masoniteproject.com',
'name': 'Joe Mancuso',
'password': 'secret'
}
"""
def show(self, request: Request):
User.create(request.all())
如果我们有一个简单的请求输入,这个查询可能如下所示:
INSERT INTO `users` (`email`, `name`, `password`)
VALUES ('joe@masoniteproject.com', 'Joe Mancuso', 'secret')
这看起来很天真,但是它为用户传递任何信息打开了大门。例如,如果他们是管理员,他们可能会传入,他们所要做的就是传入这些值:
"""
POST {
'email': 'joe@masoniteproject.com',
'name': 'Joe Mancuso',
'password': 'secret',
'admin': 1
}
"""
def show(self, request: Request):
User.create(request.all())
这将生成如下查询:
INSERT INTO `users` (`email`, `name`, `password`, `admin`)
VALUES ('joe@masoniteproject.com', 'Joe Mancuso', 'secret', '1')
现在,用户只需非常简单地让自己成为管理员。
可填充
为了防止这种攻击,演说家制作了一个 __ fillable__属性,你可以把它放在你的模型上。现在我们可以这样做:
class User:
___fillable__ = ['email', 'name', 'password']
现在,它将忽略任何试图进行质量分配的字段。回到易受攻击的代码片段:
"""
POST {
'email': 'joe@masoniteproject.com',
'name': 'Joe Mancuso',
'password': 'secret',
'admin': 1
}
"""
def show(self, request: Request):
User.create(request.all())
它现在将正确地生成如下查询:
INSERT INTO `users` (`email`, `name`, `password`)
VALUES ('joe@masoniteproject.com', 'Joe Mancuso', 'secret')
它将忽略不在 __ fillable__属性内的所有内容。
克-奥二氏分级量表
大多数人与 CORS 的互动都是试图访问实现 CORS 的服务器,然后试图绕过 CORS,因为人们不太理解它。
CORS 代表跨源资源共享,它允许服务器通过 HTTP 头告诉浏览器允许访问哪些特定资源以及应该如何访问这些资源。例如,如果请求来自site.com,服务器可能会告诉浏览器只向example.com发送请求。也许我们有某种微服务,我们想确保只有来自我们在site.com的应用。
浏览器处理这种情况的方式是,它们做一些称为预检请求的事情,这是一个简单的 HTTP 请求,它们在发送有效载荷之前发送。该飞行前请求实质上用于“侦察”服务器,并检查 CORS 指令是否与它们将要发送的内容相匹配。如果它们不匹配,那么浏览器将抛出一个与 CORS 指令无效相关的错误。
这不是保护应用的可靠方法,但它确实增加了一层安全性和请求验证。
CORS 提供商
Masonite 允许您的应用返回 CORS 标头,因此我们可以帮助保护我们的应用。这样做很简单。我们只需将该提供商添加到您的AppProvider下方的提供商配置列表中:
# config/providers.py
# ...
from masonite.providers import CorsProvider
PROVIDERS = [
AppProvider
CorsProvider,
# ...
]
现在,您的服务器将开始返回以下 CORS 标题,浏览器将开始执行您的规则。
最后,你可以在config/middleware.py文件的底部添加一些合理的默认值:
# config/middleware.py
# ...
CORS = {
'Access-Control-Allow-Origin': "*",
"Access-Control-Allow-Methods": "DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT",
"Access-Control-Allow-Headers": "Content-Type, Accept, X-Requested-With",
"Access-Control-Max-Age": "3600",
"Access-Control-Allow-Credentials": "true"
}
这将在人们访问您的应用时设置这些标题:
Access-Control-Allow-Origin: *,
Access-Control-Allow-Methods: DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT,
Access-Control-Allow-Headers: Content-Type, Accept, X-Requested-With,
Access-Control-Max-Age: 3600,
Access-Control-Allow-Credentials: true
您可以通过修改中间件文件中的CORS变量的键和值来修改头。如果看不到它,您需要创建它:
# config/middleware.py
# ...
CORS = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT",
"Access-Control-Allow-Headers": "Content-Type, Accept, X- Requested-With",
"Access-Control-Max-Age": "3600",
"Access-Control-Allow-Credentials": "true"
}
请随意修改这些标题,甚至查找您可以添加的其他标题。
安全标题
类似于 CORS 头为源之间的 HTTP 请求设置规则,安全头为所有 HTTP 请求设置规则。
Masonite 在使用SecureHeadersMiddleware中间件时内置的一些头文件包括
-
严格的运输安全
-
x-框架-选项
-
X-XSS 保护
-
x-内容-类型-选项
-
推荐人-策略
-
缓存控制
-
杂注
这些都很神秘,所以让我们试着逐一解释它们的用途。因为有很多值,所以我不会重复每个值的含义。我会解释每个选项的用途,你可以研究一下你需要为你的情况设置什么值。
标题的含义
Strict-Transport-Security头告诉浏览器应该通过 HTTPS 而不是 HTTP 发出请求。这也被称为 HSTS 头球攻门(“??”HTTPStrictTtransportSsecurity”)。
X-Frame-Options头告诉浏览器它是否应该呈现<iframe>、<frame>、<embded>或<object>中的页面。这些选项存在已知的漏洞,因为用户可以将自己的网站注入这些 iFrames。如果用户在其中一个被劫持的框架中输入信息,这可能会导致用户的凭据被盗。只要你的网站不容易受到 CSRF 的攻击,那么你应该没问题。
如果浏览器本身检测到任何跨站脚本攻击的迹象,那么X-XSS-Protection头告诉浏览器阻止任何请求。
X-Content-Type-Options头防止嗅探 MIME 类型(比如图像)。一些 MIME 类型包含可执行代码,有助于防止这种情况。
Referrer-Policy标题详细说明了当一个请求从你的网页转到另一个网页时,Referrer 标题中应该有多少信息。通常网站可以通过阅读这个标题来判断用户来自哪里。
Cache-Control为浏览器提供了关于应该为请求和响应缓存多少信息和什么类型的信息的指令。
最后,Pragma头与Cache-Control头本质上是一样的,但用于向后兼容。
使用安全中间件
您可以通过将这个中间件导入到您的config/middleware.py文件中来轻松使用它:
from masonite.middleware import SecureHeadersMiddleware
HTTP_MIDDLEWARE = [
# ...
SecureHeadersMiddleware,
]
还可以通过在中间件配置文件的底部添加一个SECURE_HEADERS变量来随意覆盖任何默认值:
from masonite.middleware import SecureHeadersMiddleware
HTTP_MIDDLEWARE = [
# ...
SecureHeadersMiddleware,
]
SECURE_HEADERS = {
'X-Frame-Options' : 'deny'
}
放
当在社区中发现安全漏洞时,维护人员会意识到这一点,并创建一个安全版本。这些可能是次要版本中的突破性变化,这通常违反了 Masonite 的发布策略,但应用突破比用户数据被盗或服务器、应用或数据库被破坏要好。
这就是为什么在社区中很重要,这样每个人都可以知道发生了什么,我们都可以在必要的时候尽可能地保持透明。
还会有关于安全版本是什么、我们如何到达这里以及如何前进的文档。一些安全版本可以在 Masonite 端通过简单地创建一个新版本来完成,而一些可能需要在应用本身中打补丁。
当您使用 Masonite 进行开发时,只需确保您使用的是最新的次要版本。例如,如果您使用 Masonite 2.2 进行开发,那么请确保第三个数字始终是最新的,比如从 2.2.10 升级到 2.2.11。这些对保持最新很重要。次要版本有时每隔几天或几周发布一次,没有固定的时间表。您应该经常检查 Masonite 是否创建了一个版本,以及那个版本是什么,并且您应该在将更改转移到生产服务器之前进行升级和测试。
CVE 警报
既然我们在讨论发布和警报,那么就有必要提一下 CVE 警报。CVE 代表共同的弱点和暴露。它基本上是在软件中发现的许多暴露的巨大档案。如果一个备受关注的 Python 包发现了漏洞,他们将创建一个 CVE,并为其分配一个每个人都可以引用的标识号。
如果你在 GitHub 上托管你的代码,那么当你的任何包符合最近发布的 CVE 时,他们会给你发送通知,并推荐解决方案。这些都不应该被忽略,如果您使用的软件包有漏洞,您应该尽快升级或修复问题。
大多数解决方案都是简单的升级,但是您应该阅读附加文档的链接,以了解您的应用是否存在风险,以及是否有任何数据可能已经暴露。
如果你想关注他们,他们甚至有一个推特页面。
加密
加密是一个有趣的话题,因为它是一种屏蔽值的艺术,只允许特定的应用、用户或服务器看到该值。
Masonite 使用著名的加密软件包进行加密。这个包在 Python 社区中非常有名,有大量的包需要它。
Masonite 使用这个包中的 Fernet 加密算法来进行大多数加密。下面是他们的文档中关于 Fernet 是什么的一段:
Fernet 保证使用它加密的消息在没有密钥的情况下不能被操纵或读取。Fernet 是对称(也称为“密钥”)认证加密的实现。
你可能已经意识到梅索尼特有一把秘密钥匙。您可能在第一次安装 Masonite 时就已经看到了,或者您可能需要使用craft key命令生成一个。
这个秘密密钥不应该公开。如果这个密钥是公开的,您应该立即生成一个新的密钥。Masonite 中有许多面向公众并使用该密钥加密的内容,如果恶意用户获得了该密钥,他们就能够解密一些敏感信息。
Masonite 本身没有像 cookie 一样公开的内容,但是如果您作为开发人员创建了一个包含敏感信息的 cookie(您可能不应该这样做),那么这些信息可能会有风险。
您可以在您的.env文件中找到您的密钥,它看起来像这样:
-4gZFiEz_RQClTjr9CQMvgZfv0-YnTuO485GG_BMecs=
事实上,如果不会对用户造成太大伤害,您应该定期轮换您的密钥。默认情况下,最糟糕的情况是您的用户将被注销,因为 Masonite 将删除它加密但无法解密的任何 cookies。有了新的密钥,它将无法解密用不同密钥加密的值。
如果这对你来说没问题,那么你应该尽可能每周轮换你的钥匙。
编码与加密
这是一个非常重要的话题,因为许多人不知道这两者的区别。编码的内容通常使用某种标准编码算法,如 Base64。
任何人都可以使用几乎任何程序对编码值进行解码。
不应编码敏感信息;应该是加密的。
加密通常使用通过秘密密钥的加密。这意味着一个值被转换(加密)成一个散列,并且不能被解密,除非使用相同的加密密钥将其解密。
一些加密被称为单向加密,只能转换成散列,而永远不能转换回来。所有的查找都需要进行相同的单向转换,转换成相同的散列,并检查相似性。一些单向散列是像 MD5 和 SHA1 这样的算法,它们已经不再受更好的可用加密算法的青睐。
因此,如果我说的是加密或编码,那么如果你不知道我的意思,请务必参考这里的这一部分。
密码加密
Masonite 使用 Bcrypt 进行密码加密。这是密码常用的最强加密方法之一。
要在 Masonite 中使用 Bcrypt,您可以使用一个非常有用的助手:
>>> from masonite.helpers import password
>>> password('secret')
'$2b$12$DFBQYaF6SFZZAcAp.0tN3uWHc5ngCtqZsov3TkIt30Q91Utcf9RAW'
现在,您可以自由地将该密码存储在数据库中,并使用 Masonite 的身份验证类来验证它。
饼干
Cookies 也是另一个加密点。默认情况下,Masonite 甚至会加密应用生成的所有 cookies。这可以防止任何恶意用户看到您的 cookies 的值。如果您下载的一个 JavaScipt 包碰巧可以访问您的 cookie(它不能,稍后将详细介绍),我们仍然可以,因为他们将获得您的 cookie 的加密版本,并且不能对它们做任何事情。
JavaScript 无法访问我们的 cookie 的原因是因为我们在 cookie 上设置了一个HTTPOnly标志,这意味着我们的 cookie 只能通过 HTTP 请求读取。换句话说,应该只有我们的应用和服务器能够读取它们。这是另一个安全点。仅仅因为您的代码没有任何漏洞并不意味着您可能从节点包管理器下载的 1000 个包中的任何一个没有漏洞。您引入到应用中的任何 JavaScript 代码(或者实际上是任何代码)都可能成为恶意参与者的攻击点。
自己签名
如果你想完全像 Masonite 那样做,你甚至可以签署你自己的代码。
默认情况下,KEY环境变量用于签名:
from masonite.auth import Sign
sign = Sign()
signed = sign.sign('value') # PSJDUudbs87SB....
sign.unsign(signed) # 'value'
在幕后,Masonite 调用这个类。如果你需要自己签名,比如用户提供的信息,你可以。如果你可以说连你自己都看不到用户的信息,这可能是一个很好的广告标志。
一旦某个值被加密,就必须使用加密它的同一个密钥对它进行解密。如果某些值是用已撤销的密钥签名的,那么您可能无法使用新的密钥解密这些值。该值将永远被加密,直到用来加密它的密钥被用来解密它。
在对值进行签名时请记住这一点,否则您的密钥就会泄露。您将需要更改您的密钥,但是所有已签名的字符串将永远无法被签名。
七、认证
在前几章中,我们学习了很多关于创建表单和使用它们填充数据库的知识。对于 web 应用来说,很常见的一件事是尝试将这种数据发送给用户帐户。
对于你所使用的服务,你可能有很多不同的“用户账户”。也许你在推特或脸书上。这些将你发布的数据和你喜欢看的东西与个性化的“身份”联系起来。
Masonite 在这方面提供了很多工具,这样我们就不必每次构建这些用户注册系统时都做繁重的工作。因此,在这一章中,我们将学习如何使用这个工具。
我如何认证用户?
您可能会惊讶地发现,我们已经使用了认证用户所需的所有不同位。需要明确的是,有许多不同种类的身份验证。
我们将要学习的这种方法将用户凭据与数据库记录进行比较,并记住有效凭据的存在,这样用户就可以在未经身份验证的用户无法到达的地方移动。
为此,我们将了解 Masonite 如何帮助我们建立接受这些凭证的安全表单。我们将查看一些代码,这些代码将凭据与数据库中的凭据进行比较,并告诉我们用户凭据是否有效。
最后,我们将看到成功的登录是如何被“记住”的,并且还可以与其他数据库记录结合使用。
使用提供的工具
此代码可在 https://github.com/assertchris/friday-server/tree/chapter-8 找到。
我们需要做的第一件事是使用 craft 命令生成一些新的与 auth 相关的文件:
craft auth
这做了很多,所以让我们来看一下每一部分。首先,它创建了一系列新路由。我们可以在已经定义的路由之后看到它们:
ROUTES = ROUTES + [
Get().route('/login', 'LoginController@show').name('login'),
Get().route('/logout', 'LoginController@logout').name('logout'),
Post().route('/login', 'LoginController@store'),
Get().route('/register', 'RegisterController@show').name('register'),
Post().route('/register', 'RegisterController@store'),
# Get().route('/home', 'HomeController@show').name('home'),
Get().route('/email/verify',
'ConfirmController@verify_show').name('verify'),
Get().route('/email/verify/send', 'ConfirmController@send_verify_email'),
Get().route('/email/verify/@id:signed', 'ConfirmController@confirm_email'),
Get().route('/password', 'PasswordController@forget').name('forgot.password'),
Post().route('/password', 'PasswordController@send'),
Get().route('/password/@token/reset', 'PasswordController@reset').name('password.reset'),
Post().route('/password/@token/reset', 'PasswordController@update'),
]
这是来自routes/web.py。
使用ROUTES = ROUTES + [...]插入代码的方式意味着它不会覆盖现有的路由。您可能希望更改这些内容的添加格式。
例如,您可能更喜欢将它们放在一个组中。没关系。只要确保它们指向相同的控制器,并相应地调整您调用的 URL。
auth命令做的下一件事是生成这些控制器。主要的是RegisterController和LoginController,主要是因为大部分“认证”工作都是在其中完成的。RegisterController提供显示注册表单和保存用户帐户的操作。
LoginController提供显示登录表单和根据数据库记录检查凭证的操作。
奇怪的是“检查这些凭证”方法被命名为store,但是这可能是为了与 POST 请求使用的其他动作保持一致。
其他控制器用于密码重置和电子邮件验证——内置的强大功能,但完全是可选的。
请随意重命名这些控制器及其动作,使它们更容易被您理解。只要记得更新相关的路由,所以一切都保持工作。
第三个变化是auth添加的视图。它们都放在resources/templates/auth文件夹中,并与新控制器中的动作相对应。花些时间浏览一下控制器和模板,感受一下它们是如何组合在一起的。
等等,我不是已经看过“用户”的东西了吗?
您可能已经注意到了代码库中一些与用户相关的文件,特别是User模型和CreateUsersTable迁移。这些在所有新的 Masonite 安装中都存在,部分原因是 auth 配置也存在(并且依赖于模型,而模型依赖于迁移的存在)。
这是一个奇怪的依赖链,但它意味着新鲜的应用包括拼图的一小部分,其余部分来自craft auth。
在使用注册和登录表单之前,我们需要确保所有内容都已迁移:
craft migrate:refresh --seed
如果您在这里遇到错误,请记住您需要安装一个 MySQL 驱动程序(使用pip install mysqlclient)并在.env中配置正确的数据库细节。
您现在应该会看到一个users表,这是User模型获取和放置数据的地方。让我们创建一个新账户,运行craft serve,进入注册页面,如图 7-1 所示。
图 7-1
创建新用户
这应该会让我们自动登录,但是万一没有登录(或者你想稍后登录),我们可以转到/login页面(图 7-2 )并在那里使用相同的凭证。
图 7-2
登录
注意在users表中有一条新记录。那是你!
我如何使用不同的字段?
默认情况下,email字段用于识别用户。这是将在登录方法中使用的字段名称:
auth.login(request.input('email'), request.input('password'))
这是来自app/http/controllers/LoginController.py。
根据您使用的 Masonite 版本,您的模型可能为此定义了一个常数:
__auth__ = 'email'
这是来自app/User.py。
同样,根据您的版本,各种新控制器可能会使用此常量作为字段名称:
auth.login(
request.input(auth_config.AUTH['model']. __auth__),
request.input('password')
)
这是来自app/http/controllers/RegisterController.py。
我更喜欢所有的引用都使用相同的值,所以如果我看到多种变化,我会把它们都改为使用常量或硬编码的值。我建议您也这样做,这样代码更容易理解。
在任何情况下,这都是 Masonite 将登录凭证与现有数据库记录进行比较的方式。这也意味着我们可以把“电子邮件”换成另一个领域。核心身份验证代码引用常量,因此我们可以更改它:
__auth__ = 'name'
这是来自app/User.py。
if auth.login(
request.input(auth_config.AUTH['model']. __auth__),
request.input('password')
):
return request.redirect('/home')
这是来自app/http/controllers/LoginController.py。
如果您要更改这个字段,我建议您将任何字段切换为唯一字段。您可以在迁移级别和验证级别做到这一点。
如何让用户自动登录?
RegisterController是让用户自动登录的一个很好的例子,但这不是唯一的方法。假设我们知道用户是谁,但是手头没有他们的登录凭证。在这种情况下,我们可以通过他们的 ID 登录他们:
auth.login_by_id(user.id)
原来,可能有这样的情况,您拥有用户(和他们的 ID),他们需要做一些需要认证的事情,但是您不希望他们保持登录状态。
auth对象也有一个once方法,它让用户登录,而不“记得”他们已经登录,在后续请求中:
auth.once().login_by_id(user.id)
# ...or
auth.once().login(
request.input('email'),
request.input('password')
)
注销用户
虽然我们的主题是让用户登录,但是知道用户可以用另一种方法注销也是有用的:
def logout(self, request: Request, auth: Auth):
auth.logout()
return request.redirect('/login')
这是来自app/http/controllers/LoginController.py。
使用令牌(JWT)而不是凭据
Masonite 提供不同的认证方式。我们的应用实际上只需要凭证身份验证,但是知道 JWT(基于令牌)身份验证也受支持是很有用的。
查看官方文档了解更多关于配置它的信息: https://docs.masoniteproject.com/security/authentication#jwt-driver 。
如何保护我的部分应用?
让用户登录到您的应用与保护部分内容不被未登录的用户看到和/或使用密切相关。让我们来看看几种“保护”应用的方法。
第一种方法是在我们运行craft auth命令时介绍给我们的。再看HomeController:
def show(self, request: Request, view: View, auth: Auth):
if not auth.user():
request.redirect('/login')
return view.render('home', {
'name':
request.param('name') or request.input('name'),
})
这是来自app/http/controllers/HomeController.py。
当我们对用户是否登录感到好奇时,我们可以将auth: Auth对象引入到我们的操作中。auth().user()要么为空,要么有一个与当前登录用户相关联的User模型对象。
这是有效的,但我猜它会导致大量的重复。此外,我们可能会忘记将它添加到所有需要它的操作中。当我定义路由时,我发现决定一个动作是否应该被“保护”要容易得多:
RouteGroup(
[
Match(['GET', 'POST'], '/@name', 'HomeController@show').name('with-name'),
Match(['GET', 'POST'], '/', 'HomeController@show').name('without-name'),
],
prefix='/home',
name='home-',
middleware=['auth'],
),
这是来自routes/web.py。
我们可以在路由组中组织受保护的路由,并为整个组定义一个middleware参数。auth中间件内置在 Masonite 中,所以我们不必自己定义。
我们没有花太多时间学习中间件,但这是下一章的主题,所以我们将深入探讨。
或者,我们可以保护个别路由:
Match(['GET', 'POST'], '/home', 'HomeController@show')
.name('without-name')
.middleware('auth'),
这是来自routes/web.py。
这两种方法(命名参数和middleware方法)都接受一个中间件列表或一个单独的中间件字符串。这取决于你是否想要采取声明性的方法来保护路由,或者命令性的方法来保护动作本身。
我如何确保有效的电子邮件?
我希望我们以简单了解一下电子邮件验证来结束本章。我不想深究其中的机制,因为我觉得 Masonite 自动生成的代码对于大多数情况来说都是惊人的。
当我们从用户那里寻找“有效”的电子邮件地址时,只有一个有效的解决方案。当然,我们可以使用表单验证来判断一个电子邮件地址看起来是否有效,但是知道它是否有效的唯一有效方法是向它发送电子邮件。
这就是电子邮件验证的目的。这是一种确保用户的电子邮件地址是他们所说的那个地址的方法,这样我们就可以与他们进行有效的沟通。
为此,CreateUsersTable迁移包含一个名为verified_at的时间戳,auth命令生成路由、控制器和视图,以允许用户验证他们的电子邮件地址。
要启用它,我们需要更改默认的User模型:
from config.database import Model
from masonite.auth import MustVerifyEmail
class User(Model, MustVerifyEmail):
__fillable__ = ['name', 'email', 'password']
__auth__ = 'email'
这是来自app/User.py。
此外,我们需要引入一种新的中间件,它将促使用户验证他们的电子邮件地址:
RouteGroup(
[
Match(['GET', 'POST'], '/@name', 'HomeController@show').name('with- name'),
],
prefix='/home',
name='home-',
middleware=['auth', 'verified'],
),
Match(['GET', 'POST'], '/home', 'HomeController@show')
.name('without-name')
.middleware('auth', 'verified'),
这是来自routes/web.py。
现在,当用户的verified_at字段为空时,他们将被提示验证他们的电子邮件地址,即使他们已经登录,如图 7-3 所示。
图 7-3
提示电子邮件验证
您可能需要配置 Masonite 发送电子邮件的方式,然后才能看到这些验证电子邮件: https://docs.masoniteproject.com/useful-features/mail#configuring-drivers 。在第十一章中,当我们在应用中添加通知时,我们会深入研究这个配置。
摘要
在这一章中,我们学习了 Masonite 提供的认证工具,如何使用它,以及如何定制体验以适应我们的应用。
这是很强大的东西,毫无疑问,您将需要在您可能要构建的一些应用中使用它。最好现在就抓住它!
在下一章,我们将更深入地研究中间件。我们将了解它是如何工作的,有多少是新应用自带的,以及如何制作自己的应用。
八、创建中间件
现在我们知道了什么是中间件,让我们看看中间件是如何创建的。
让我们创建一个中间件,然后讨论中间件的每个部分。我们暂时保持简单,只创建一个简单的 Hello World 中间件,并通过它进行讨论。然后,我们将进入更复杂的特定应用的中间件。
如果你还不明白,我们将为此使用一个 craft 命令:
$ craft middleware HelloWorld
这将在app/http/middleware目录中为您创建一个HelloWorldMiddleware.py文件。
这个目录没有什么特别的,所以如果您愿意,可以将您的中间件移出这个目录。只需确保您的config/middleware.py文件中的任何导入都指向新位置。这是更多的背景信息,所以不要觉得你需要移动它们;这个目录很好。
构建中间件
如果我们看这个类,你会发现中间件是一个非常简单的类,有三个部分。让我们看一下每个部分,这样我们就知道每个部分在做什么以及可以用它做什么。
值得注意的是,HTTP 中间件和路由中间件的构造完全相同。使它成为 HTTP 或路由中间件的唯一因素是我们如何向 Masonite 注册它,这将在下一节中讨论。
初始化程序
class HelloWorldMiddleware:
def __init__(self, request: Request):
self.request = request
# ...
初始化器是一个简单的__init__方法,就像任何其他类一样。唯一特别的是它是由容器来解析的。因此,您可以在您的__init__方法中键入提示应用依赖关系,这将像您的控制器方法一样解析类。
由于许多中间件需要请求类,Masonite 将为您键入提示请求类。如果您的特定中间件不需要它,那么您可以毫无问题地移除它。
before 方法
class HelloWorldMiddleware:
#...
def before(self):
print("Hello World")
before 方法是另一个简单的方法。此方法中的任何代码都将负责在调用控制器方法之前运行。在内置的auth中间件中,这是用来检查用户是否被认证并告诉请求类重定向回来的方法。
这个方法也可以接受我们从 routes 文件传入的变量。我们将在本章的后面讨论这一点。
after法
class HelloWorldMiddleware:
#...
def after(self):
print('Goodbye World')
除了代码是在控制器方法被调用后运行之外,after方法与before方法非常相似。例如,如果我们想缩小 HTML 响应,这就是逻辑的走向。
这个方法也可以接受我们从 routes 文件传入的变量。我们将在本章的后面讨论这一点。
注册中间件
既然我们已经创建了中间件类,我们可以向 Masonite 注册它。我们可以将它导入到我们的config/middleware.py文件中,并放入两个列表中的一个。我们可以把它放在HTTP_MIDDLEWARE列表或者ROUTE_MIDDLEWARE字典里。
HTTP 中间件
还记得之前我们说过两个中间件的构造是一样的,所以如果你想让中间件在每个请求上运行,就把它放在HTTP_MIDDLEWARE类中。
这看起来会像
from app.http.middleware.HelloWorldMiddleware import
HelloWorldMiddleware
HTTP_MIDDLEWARE = [
LoadUserMiddleware,
CsrfMiddleware,
ResponseMiddleware,
MaintenanceModeMiddleware,
HelloWorldMiddleware, # New Middleware
]
注意 HTTP 中间件只是一个列表,所以您可以将它添加到列表中。您的中间件的顺序可能并不重要,但实际上可能很重要。
中间件的运行顺序与您将它放入列表的顺序相同。因此LoadUserMiddleware将首先运行,然后HelloWorldMiddleware将最后运行。因为我们的HelloWorldMiddleware只是打印一些文本到终端,我们可以把它添加到列表的底部,因为它实际上不依赖于任何东西。
另一方面,如果中间件依赖于用户,那么我们应该确保我们的中间件在LoadUserMiddleware之后。这样,用户被加载到请求类中,然后我们的中间件可以访问它。如你所知,LoadUserMiddleware正是因为这个原因而成为第一。
现在,HTTP 中间件已经完全注册到 Masonite,它现在可以在每个请求上运行。稍后,我们将看到输出是什么样子的。在此之前,我们将讨论如何注册路由中间件。
路由中间件
现在,路由中间件再次与 HTTP 中间件相同,但是注册它有点不同。我们可以马上注意到路由中间件是一个字典。这意味着我们需要将它绑定到某个键上。
这个密钥是我们将用来把中间件附加到我们的路由上的。我们想给这个中间件起一个简短而甜蜜的名字。我们可以使用键helloworld作为键,并使中间件成为字典中的值。这将看起来像
from app.http.middleware.HelloWorldMiddleware import
HelloWorldMiddleware
ROUTE_MIDDLEWARE = {
'auth': AuthenticationMiddleware,
'verified': VerifyEmailMiddleware,
'helloworld': HelloWorldMiddleware,
}
命名惯例由你决定,但我喜欢尽量用一个词来命名。如果你需要拆分成一个以上的单词,我们可以将其命名为hello.world或hello-world之类的东西。只是一定不要使用:字符,因为 Masonite 将拼接我们的 routes 文件中的那个键。稍后,您将在“路由”部分看到更多这方面的内容。
使用中间件
我们已经讨论了中间件的用途,我们可以创建的不同类型的中间件,如何创建这两种中间件,以及如何向 Masonite 注册这两种中间件。
现在我们将最终了解如何使用我们创建的中间件。现在 HTTP 中间件,也就是我们放在列表中的那个,已经准备好了。我们实际上不需要做任何进一步的工作。
如果我们开始导航我们的应用并打开我们的终端,那么我们可能会看到类似于
hello world
INFO:root:"GET /login HTTP/1.1" 200 10931
goodbye world
hello world
INFO:root:"GET /register HTTP/1.1" 200 12541
goodbye world
hello world
INFO:root:"GET /dashboard HTTP/1.1" 200 4728
goodbye world
请注意,在我们的控制器方法被点击之前和之后,我们开始看到hello world和goodbye world打印语句。
另一方面,路由中间件有点不同。我们需要通过在 routes 文件中指定密钥来使用这个中间件。
例如,如果我们想要使用我们之前制作的helloworld中间件,我们可以将它添加到一个看起来像这样的路由中
Get('/dashboard', 'YourController@show').middleware('helloworld')
这将只为这个路由运行中间件,而不为任何其他路由运行。
回顾我们以前的终端输出,我们的新应用将类似于这样:
INFO:root:"GET /login HTTP/1.1" 200 10931
INFO:root:"GET /register HTTP/1.1" 200 12541
hello world
INFO:root:"GET /dashboard HTTP/1.1" 200 4728
goodbye world
请注意,我们只将中间件放在了/dashboard路由上,因此它将只为该特定路由执行:
Get('/dashboard',
'YourController@show').middleware('helloworld:Hello,Joe')
还记得我们之前说过要确保你的中间件别名中没有一个:吗,因为 Masonite 会拼接在那个上面?这就是它的意思。Masonite 将拼接在:字符上,并将其后的所有变量传递给中间件。
既然我们已经说过要将这些值传递给中间件,那么让我们看看中间件将会是什么样子:
class HelloWorldMiddleware:
#...
def before(*self*, *greeting*, *name*):
pass
def before(*self*, *greeting*, *name*):
pass
无论我们传递给路由什么,before和after中间件都需要这两个参数。
正如您可能已经猜到的,参数的顺序与您在路由中指定的顺序相同。所以greeting将会是Hello,name将会是Joe。
中间件堆栈
中间件堆栈是另一个简单的概念。有时候,您的一些路由看起来非常重复,一遍又一遍地使用同一个中间件。我们可以将中间件分组到中间件“栈”或中间件列表中,以便在一个别名下运行所有这些中间件。
例如,假设我们有一些中间件,我们想在一个别名下运行。我们可以用一个更好的例子,我们可能会看到自己一遍又一遍地使用非常相似的中间件:
ROUTES = [
(Get('/dashboard', 'YourController@show')
.middleware('auth', 'trim', 'admin')),
(Get('/dashboard/user', 'YourController@show')
.middleware('auth', 'trim', 'admin')),
]
注意中间件似乎有点重复。我们在这里可以做的是创建一个中间件堆栈来对它们进行分组。这看起来像
ROUTE_MIDDLEWARE = {
'auth': AuthenticationMiddleware,
'verified': VerifyEmailMiddleware,
'dashboard': [
AuthenticationMiddleware,
TrimStringsMiddleware,
AdminMiddleware,
]
}
然后,我们可以稍微重构一下我们的路由,以使用这个堆栈:
ROUTES = [
(Get('/dashboard', 'YourController@show')
.middleware('dashboard')),
(Get('/dashboard/user', 'YourController@show')
.middleware('dashboard')),
]
九、使用助手
我们已经谈了很多,所以我们要换个话题,谈谈助手。简而言之,助手是我们可以在任何地方使用的功能,它比我们原本可以做的更快或更有效地为我们做事。不看代码很难解释它们的用法,所以这就是我们要做的。
这些助手在全球范围内都是可用的,因此您不需要导入其中的大部分。重要的时候,我会告诉你例外是什么。
请求和验证助手
此代码可在 https://github.com/assertchris/friday-server/tree/chapter-10 找到。
我们对请求类并不陌生。我们通常将它注入到控制器动作中:
def show(self, request: Request, view: View):
return view.render('home', {
'name': request.param('name'),
})
如果我们想使用来自其他地方的请求呢?我不是在说我们是否应该,而是在说,“我们可以吗?”
我们可能想用它的一个明显的地方是视图:
@extends 'layout.html'
@block content
hello {{ request().param('name') }}
@endblock
如果您喜欢这种风格,您可能也喜欢在操作中使用请求帮助器:
from masonite.auth import Auth
from masonite.view import View
class HomeController:
def show(self, view: View, auth: Auth):
return view.render('home', {
'name': request().param('name') or request().input('name'),
})
类似地,我们可以通过使用 Auth helper 来缩短授权代码:
from masonite.view import View
class HomeController:
def show(self, view: View):
return view.render('home', {
'name': request().param('name') or auth().email,
})
这是来自app/http/controllers/HomeController.py。
auth()功能非常有用,但是要小心使用。如果用户没有登录,那么auth()将返回False。您的代码应该考虑到这一点。它在视图层也很棒:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<link href="/static/style.css" rel="stylesheet" type="text/css">
</head>
<body>
<div class="container mx-auto p-4">
@if (auth())
<a href="{{ route('logout') }}">log out</a>
@else
<a href="{{ route('login') }}">log in</a>
@endif
@block content
<!-- template content will be put here-->
@endblock
</div>
</body>
</html>
这是来自resources/templates/layout.html。
如果用户没有登录,auth()函数将返回None,可以根据用户会话的存在重用该函数来切换 UI。这也是另一个帮手的例子。
路由助手
路径助手对于较大的应用是必不可少的,但是您必须命名您的路径才能让它工作:
Get('/profile/@id', 'ProfileController@show').name('profile')
我们可以使用route()函数构建任何命名的路由,包括我们已经为其定义了参数的路由:
<a href="{{ route('profile', { 'id': auth().id }) }}">public profile</a>
容器和解析助手
有时候,我们可能想在服务容器中添加我们自己的类和函数,我们在第四章中已经了解到了。您可能并不总是处于可以访问“应用”的操作中,但是您可以访问容器助手:
from app.Service import Service
container().bind('Service', Service)
# ...later
container().make('Service')
Service就是一个例子,这里。可以把它看作是下一个应用可能需要的自定义类的占位符。
这对于扩展容器中已经存在的内容以及从其他上下文(如视图和模型)访问存储在容器中的内容非常有用。同样,解析函数的依赖关系也很有用,就像 Masonite 自动解析动作一样。
下面是一个如何自动解析控制器动作的request参数的示例:
from masonite.helpers import compact
# ...later
def respond(request: Request):
action = request.param('action')
name = request.param('name')
if (action == 'read'):
return view('read.html', compact(name))
if (action == 'write'):
return view('write.html', compact(name))
def handle(self):
return resolve(respond)
resolve helper 使用另一个函数,从容器中解析出它需要的参数。这也是我们的第一个非全局助手(compact()函数)的例子,它接受一个变量列表并返回一个字典,其中每个键是字符串变量名,每个值是变量值。
非全局助手只是你仍然需要在每个使用它们的文件中导入的助手。在这里,我们正在导入 compact helper。您可以通过以下类似模式使非全局助手全局可用: https://docs.masoniteproject.com/the-basics/helper-functions#introduction 。
环境和配置助手
env()和config()是密切相关的助手,它们分别从环境(或.env文件)和config文件夹中的文件中提取配置变量。除了它们在不同的文件中查找之外,它们之间的主要区别是从env()返回的数据被主动缓存,而从config()返回的数据可以被更改和延迟加载。
当使用这些时,最好只在配置文件中使用env(),在其他地方只使用config():
FROM = {
'address': env('MAIL_FROM_ADDRESS', 'hello@example.com'),
'name': env('MAIL_FROM_NAME', 'Masonite')
}
这是来自config/mail.py。
如前所述,env()函数的第二个参数是默认值。如果或当环境变量不能保证存在时,这是很好的。一旦配置变量在一个配置文件中,我们可以用config()函数把它们取出来:
from config import mail as mail_config
# ...later
print(mail_config.FROM['address'])
弃尸助手
就像许多梅森奈特人一样,这个抛弃并死去的帮手也受到了《拉韦尔》中相同帮手的启发: https://laravel.com/docs/6.x/helpers#method-dd 。这是一种快速停止正在发生的事情的方法,因此您可以检查多个变量的内容:
dd(User.find(1).email, request().params('email'))
它不是一个步骤调试器,但在紧要关头它很棒!
摘要
在这一章中,我们已经了解了最流行的帮助函数。它们肯定不是唯一的帮助函数,所以我鼓励你看一看文档来了解更多: https://docs.masoniteproject.com/the-basics/helper-functions 。有可能你会在那里找到你喜欢的东西。
在下一章,我们将学习从应用发送通知的所有不同方式。