本章中包含如下小节:
- 让表单免受跨站请求伪造(CSRF)攻击
- 使用内容安全策略(CSP)让请求安全
- 使用django-admin-honeypot
- 实现密码校验
- 下载授权文件
- 对图片添加动态水印
- 使用Auth0进行认证
- 缓存方法返回值
- 使用Memcached缓存Django视图
- 使用Redis缓存Django视图
如果软件不当地暴露敏感信息,让用户陷入漫长的等待或是消耗大量的硬件资源,那么就很难持久存在。开发者有责任保证应用的安全和高性能。本章中,我们将查看一些方式来让用户(以及你自己)在Django应用内进行操作时保持安全。然后,我们会讲解一些降低处理时间的缓存选择,并让获取用户数据在金钱和时间层面上都实现低开销。
运行本章的代码要求安装最新稳定版的Python 3、MySQL或PostgreSQL数据库以及通过虚拟环境创建的Django项目。
可在GitHub仓库的Chapter07目录中查看本章的代码。
不加以适当的预防措施,恶意站点可以对你的网站进行某些请求,进而对你的服务器进行一些预料外的修改。例如,可以影响到用户认证或在未经用户许可的情况下修改内容。Django自带有一套防止这类CSRF攻击的系统,本小节中我们就来进行了解。
使用第3章 表单和视图中使用CRUDL函数创建应用一节所创建的ideas应用。
按照如下步骤来启用Django中的CSRF防护:
-
确保在配置文件中包含了CsrfViewMiddleware,如下所示:
# myproject/settings/_base.py MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", "django.middleware.locale.LocaleMiddleware", ]
-
确保表单视图使用request上下文进行得渲染。例如,在已有的ideas应用中有如下内容:
# myproject/apps/ideas/views.py from django.contrib.auth.decorators import login_required from django.shortcuts import render @login_required def add_or_change_idea(request, pk=None): #... return render(request, "ideas/idea_form.html", context)
-
在用于表单的模板中,应用使用POST方法并包含{% csrf_token %}标签:
{# ideas/idea_form.html #} {% extends "base.html" %} {% load i18n crispy_forms_tags static %} {% block content %} <h1> {% if idea %} {% blocktrans trimmed with title=idea.translated_title %} Change Idea "{{ title }}" {% endblocktrans %} {% else %} {% trans "Add Idea" %} {% endif %} </h1> <form action="{{ request.path }}" method="post"> {% csrf_token %} {{ form.as_p }} <p> <button type="submit">{% trans "Save" %}</button> </p> </form> % endblock %}
-
如果在表单布局中使用了django-crispy-forms,默认会包含CSRF令牌:
{# ideas/idea_form.html #} {% extends "base.html" %} {% load i18n crispy_forms_tags static %} % block content %} <h1> {% if idea %} {% blocktrans trimmed with title=idea.translated_title %} Change Idea "{{ title }}" {% endblocktrans %} {% else %} {% trans "Add Idea" %} {% endif %} </h1> {% crispy form %} {% endblock %}
Django使用隐藏字段来防止CSRF攻击。由服务端根据具体请求和随机信息生成一个令牌。再借由CsrfViewMiddleware自动让该令牌在请求上下文中可用。虽然不建议禁用这一中间件,但通过应用@csrf_protect装饰器也可以标记单独的视图来实现这一行为:
from django.views.decorators.csrf import csrf_protect
@csrf_protect
def my_protected_form_view():
#...
类似地,即使启用了这一中间件,我们也可以通过使用@csrf_exempt装饰器对单独视图排除CSRF检查:
from django.views.decorators.csrf import csrf_exempt
@csrf_exempt
def my_unsecured_form_view():
#...
内置的 {% csrf_token %} 标签生成一个带有令牌的隐藏input字段,如下例所示:
<input type="hidden" name="csrfmiddlewaretoken" value="29sQH3UhogpseHH60eEaTq0xKen9TvbKe5lpT9xs30cR01dy5QVAtATWmAHvUZFk">
在使用GET、HEAD、OPTIONS或TRACE方法提交请求的表单中包含令牌视作无效,因此使用这些方法的请求首先不应产生副面效应。大多数情况下,要求进行CSRF保护的web表单使用POST请求。
受保护的表单使用不带所要求token进行提交时,Django的内置表单校验会识别出并彻底拒绝请求。仅在提交中包含具有有效值的token时才允许进行一步处理。其结果就是外部网站无法对你的服务端做出修改,因为它们无法知晓并包含当前有效的token值。
在很多情况下,改善表单让其可通过Ajax进行提交会比较好。这时还需要使用CSRF令牌进行保护,可以在每个请求中以附加数据注入这个信息,使用这一方法要求开发者记住在每个POST请求中这么操作。另一种方法是使用 已有的CSRF令牌header,这样更为高效。
首先,令牌值需要主动获取,如何获取由CSRF_USE_SESSIONS配置值所决定。在值为True时,信息存储在session中而非cookie中,因此我们必须使用 {% csrf_token %}标签并在DOM中包含它。然后我们可以在JavaScript中读取该元素来获取这一数据:
var input = document.querySelector('[name="csrfmiddlewaretoken"]');
var csrfToken = input && input.value;
CSRF_USE_SESSIONS配置默认是False状态,令牌值的获取数据源是csrftoken的cookie。虽然可以自己实现cookie操作方法,但有很多可用的工具可以简化这一过程。例如,我们可以使用js-cookie API来通过名称更新轻易地提取令牌,如下所示:
var csrfToken = Cookies.get('crsftoken');
一旦提取出令牌,需要为XmlHttpRequest将其设置为CSRF令牌头部。虽然这可以通过每次请求分别来完成,但这样会和对每个请求添加请求参数数据存在同样的缺点。我们可以使用jQuery及其功能来在发送前自动对每个请求添加数据,如下:
var CSRF_SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS', 'TRACE'];
$.ajaxSetup({
beforeSend: function(xhr, settings) {
if (CSRF_SAFE_METHODS.indexOf(settings.type) < 0
&& !this.crossDomain) {
xhr.setRequestHeader("X-CSRFToken", csrfToken);
}
}
});
- 第3章 表单和视图中的使用CRUDL函数创建应用一节
- 实现密码校验一节
- 下载授权文件一节
- 使用Auth0进行认证一节
动态多用户网站通常允许用户添加多种类型的媒体文件:图片、视频、音频、HTML、JavaScript代码段等等。这会让用户有可能通过对网站添加恶意代码来窃取cookie或个人信息、在后台调用计划外的Ajax请求或对其它用户产生不利。现代浏览器支持额外的安全层,对你的媒体资源来源设置白名单。这称之为CSP,在本节中,我们将展示如何在Django站点中使用它。
我们使用已有的Django项目,比如第3章 表单和视图中包含有ideas应用的代码。
通过如下步骤来实现CSP的保护:
-
在你的虚拟环境中安装django-csp:
(env)$ pip install django-csp==3.6
-
在配置文件中添加CSPMiddleware:
# myproject/settings/_base.py MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", "django.middleware.locale.LocaleMiddleware", "csp.middleware.CSPMiddleware", ]
-
在同一配置文件中,添加django-csp配置用于对于你所信任的媒体来源设置白名单,例如jQuery和Bootstrap的CDN(在实现原理... 一节中会进行详细讲解):
# myproject/settings/_base.py CSP_DEFAULT_SRC = [ "'self'", "https://stackpath.bootstrapcdn.com/", ] CSP_SCRIPT_SRC = [ "'self'", "https://stackpath.bootstrapcdn.com/", "https://code.jquery.com/", "https://cdnjs.cloudflare.com/", ] CSP_IMG_SRC = ["*", "data:"] CSP_FRAME_SRC = ["*"]
-
如果在模板中存在行内脚本或样式,使用加密的nonce对设置白名单,如下所示:
<script nonce="{{ request.csp_nonce }}"> window.settings = { STATIC_URL: '{{ STATIC_URL }}', MEDIA_URL: '{{ MEDIA_URL }}', } </script>
可以在head版块或响应头中添加CSP至meta标签中:
-
meta标签语法像下面这样:
<meta http-equiv="Content-Security-Policy" content="img-src * data:; default-src 'self' https://stackpath.bootstrapcdn.com/ 'nonce-WWNu7EYqfTcVVZDs'; frame-src *; script-src 'self' https://stackpath.bootstrapcdn.com/ https://code.jquery.com/ https://cdnjs.cloudflare.com/">
-
我们所选的 django-csp模块使用响应头创建希望在网站中加载的数据源列表。可以在浏览器检查器的Network标签中查看响应头,如下:
Content-Security-Policy: img-src * data:; default-src 'self' https://stackpath.bootstrapcdn.com/ 'nonce-WWNu7EYqfTcVVZDs'; frame-src *; script-src 'self' https://stackpath.bootstrapcdn.com/ https://code.jquery.com/ https://cdnjs.cloudflare.com/
CSP允许我们定义资源类型并允许以彼此为数据源。可以使用的主要指令如下:
- default-src用作所有未设置数据源的替补,由Django配置中的CSP_DEFAULT_SRC进行控制。
- script-src用于<script> 标签,由Django配置中的CSP_SCRIPT_SRC进行控制。
- style-src用于<style> 和 标签以及CSS @import语句,由配置中的CSP_STYLE_SRC进行控制。
- img-src用于 标签,由配置中的CSP_IMG_SRC控制。
- frame-src用于 和 <iframe>标签,由配置中的CSP_FRAME_SRC控制。
- media-src用于、
- font-src用于网页字体,由配置中的CSP_FONT_SRC控制。
- connect-src用于由JavaScript所加载的资源,由配置中的CSP_CONNECT_SRC控制。
ℹ️ 完整的资源类型列表及对应的配置项分别参见CSP类型和Django CSP配置。
每条指令的值可为以下列表中的一个或多个(引号应保留):
- *: 任意数据源
- 'none': 禁止任意数据源
- 'self': 允许来自相同域名的数据源
- 某一协议,如https: 或data:
- 某一域名,例如example.com 或 *.example.com
- 一个网站URL,如https://example.com
- 'unsafe-inline': 允许使用行内<script> 或 <style> 标签
- 'unsafe-eval': 允许通过eval() 函数进行脚本执行
- 'nonce-': 允许通过加密nonce后的具体标签
- 'sha256-...': 允许通过数据源哈希的资源
没有什么绝对安全的通用django-csp配置方式。这是一个试错的过程。但以下我们提供一些纲领:
-
先对已运行项目添加CSP。过度的限制只会让开发网站变得过于困难。
-
查看硬编码至模板中的脚本、样式、字体和其它静态文件并设置白名单。
-
如果允许将媒体文件嵌入到博客文章或其它动态内容中的话允许所有的图片、媒体文件及frame的数据源,如下:
# myproject/settings/_base.py CSP_IMG_SRC = ["*"] CSP_MEDIA_SRC = ["*"] CSP_FRAME_SRC = ["*"]
-
如果使用行内脚本或样式的话,对它们添加nonce="{{ request.csp_nonce }}"。
-
避免使用CSP值'unsafe-inline' 和 'unsafe-eval',除非只能选择在模板中通过硬编码将HTML添加到网站中。
-
浏览整个网站并查看有哪些没有正确加载的内容。如果在开发者控制台中看到如下消息,则表示内容受到了CSP的限制:**拒绝执行行内脚本,因为即违反了下述的内容安全策略指令:"script-src 'self' https://stackpath.bootstrapcdn.com/ https://code.jquery.com/ https://cdnjs.cloudflare.com/"。需要使用'unsafe-inline'关键词、哈希 ('sha256- P1v4zceJ/oPr/yp20lBqDnqynDQhHf76lljlXUxt7NI=')或nonce('nonce-...') 来进行行内执行。
**
这类错误通过发生在第三方工具(如django-cms、Django调试工具栏或谷歌分析)尝试通过JavaScript包含某一资源时。可以通过像在上面错误消息中所看到的数据源哈希'sha256-P1v4zceJ/oPr/yp20lBqDnqynDQhHf76lljlXUxt7NI='.那样来对这些资源添加白名单。 -
如果你在开发一个现代增强型网页应用(PWA),应检查下由CSP_MANIFEST_SRC和CSP_WORKER_SRC配置所控制的声明指令及网页worker。
- 让表单免受跨站请求伪造(CSRF)攻击一节
如果保留Django站点的默认后台访问路径,会存在让黑客进行暴力攻击以及使用他们列表中的不同密码进行登录尝试的风险。有一个名为django-admin-honeypot的应用,允许我们伪装登录页面并监测这些暴力攻击行为。本节中就学习如何来使用。
可以使用任意想要进行安全保护的Django项目。例如,我们可以对前一小节中的项目进行扩展。
按照如下步骤来配置django-admin-honeypot:
-
在虚拟环境中安装该模块:
(env)$ pip install django-admin-honeypot==1.1.0
-
将admin_honeypot添加到配置文件的INSTALLED_APPS中:
# myproject/settings/_base.py INSTALLED_APPS = ( #... "admin_honeypot", )
-
修改URL规则:
# myproject/urls.py from django.contrib import admin from django.conf.urls.i18n import i18n_patterns from django.urls import include, path urlpatterns = i18n_patterns( #... path("admin/", include("admin_honeypot.urls", namespace="admin_honeypot")), path("management/", admin.site.urls), )
此时如果访问默认的后台URLhttp://127.0.0.1:8000/en/admin/,可以看到如下登录页面,但不论输入什么内容都会返回无效密码:
TODO
现在真实的后台地址为ttp://127.0.0.1:8000/en/management/,其中可以通过蜜罐查看到所追踪的登录信息。
在编写本书时,django-admin-honeypot 在Django 3.0下还无法完好运行,后台界面对HTML进行了转义,而其实应该进行安全渲染。在django-admin-honeypot发布更新版本之前,我们可以通过一些小修改来进行解决,如下:
-
通过admin.py文件创建名为admin_honeypot_fix的应用,代码如下:
# myproject/apps/admin_honeypot_fix/admin.py from django.contrib import admin from admin_honeypot.admin import LoginAttemptAdmin from admin_honeypot.models import LoginAttempt from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ admin.site.unregister(LoginAttempt) @admin.register(LoginAttempt) class FixedLoginAttemptAdmin(LoginAttemptAdmin): def get_session_key(self, instance): return mark_safe('<a href="?session_key= %(key)s">%(key)s</a>' % {'key': instance.session_key}) get_session_key.short_description = _('Session') def get_ip_address(self, instance): return mark_safe('<a href="?ip_address=%(ip)s">%(ip)s</a>' % {'ip': instance.ip_address}) get_ip_address.short_description = _('IP Address') def get_path(self, instance): return mark_safe('<a href="?path=%(path)s">%(path)s</a>' % {'path': instance.path}) get_path.short_description = _('URL')
-
同样在该应用中,使用如下新的应用配置创建apps.py文件:
# myproject/apps/admin_honeypot_fix/apps.py from django.apps import AppConfig from django.utils.translation import gettext_lazy as _ class AdminHoneypotConfig(AppConfig): name = "admin_honeypot" verbose_name = _("Admin Honeypot") def ready(self): from .admin import FixedLoginAttemptAdmin
-
在本文中的INSTALLED_APPS替换admin_honeypot为新的应用配置:
# myproject/settings/_base.py INSTALLED_APPS = [ #... #"admin_honeypot", "myproject.apps.admin_honeypot_fix.apps.AdminHoneypotConfig", ]
蜜罐捕获的登录尝试如下所示:
TODO
- 实现密码校验一节
- 使用Auth0进行认证一节
软件安全问题中排在前列的要属用户选择了不安全的密码。本节中,我们将学习如何通过内置及自定义的密码校验器来强制实现最低密码的要求,这样用户会被引导设置更为安全的密码。
打开项目的配置文件定位到AUTH_PASSWORD_VALIDATORS配置项。同时新建一个auth_extra应用并添加password_validation.py 文件。
按照如下步骤来为项目设置等级更强的密码:
-
自定义校验器的Django配置,添加如下选项:
# myproject/settings/_base.py AUTH_PASSWORD_VALIDATORS = [ { "NAME": "django.contrib.auth.password_validation." "UserAttributeSimilarityValidator", "OPTIONS": {"max_similarity": 0.5}, }, { "NAME": "django.contrib.auth.password_validation." "MinimumLengthValidator", "OPTIONS": {"min_length": 12}, }, {"NAME": "django.contrib.auth.password_validation." "CommonPasswordValidator"}, {"NAME": "django.contrib.auth.password_validation." "NumericPasswordValidator"}, ]
-
在新的auth_extra应用的password_validation.py文件中添加MaximumLengthValidator类,如下所示:
# myproject/apps/auth_extra/password_validation.py from django.core.exceptions import ValidationError from django.utils.translation import gettext as _ class MaximumLengthValidator: def __init__(self, max_length=24): self.max_length = max_length def validate(self, password, user=None): if len(password) > self.max_length: raise ValidationError( self.get_help_text(pronoun="this"), code="password_too_long", params={'max_length': self.max_length}, ) def get_help_text(self, pronoun="your"): return _(f"{pronoun.capitalize()} password must contain " f"no more than {self.max_length} characters")
-
同样在该文件中创建SpecialCharacterInclusionValidator类:
class SpecialCharacterInclusionValidator: DEFAULT_SPECIAL_CHARACTERS = ('$', '%', ':', '#', '!') def __init__(self, special_chars=DEFAULT_SPECIAL_CHARACTERS): self.special_chars = special_chars def validate(self, password, user=None): has_specials_chars = False for char in self.special_chars: if char in password: has_specials_chars = True break if not has_specials_chars: raise ValidationError( self.get_help_text(pronoun="this"), code="password_missing_special_chars" ) def get_help_text(self, pronoun="your"): return _(f"{pronoun.capitalize()} password must contain at" " least one of the following special characters: " f"{', '.join(self.special_chars)}")
-
然后在配置中新增校验器:
# myproject/settings/_base.py from myproject.apps.auth_extra.password_validation import ( SpecialCharacterInclusionValidator, ) AUTH_PASSWORD_VALIDATORS = [ { "NAME": "django.contrib.auth.password_validation." "UserAttributeSimilarityValidator", "OPTIONS": {"max_similarity": 0.5}, }, { "NAME": "django.contrib.auth.password_validation." "MinimumLengthValidator", "OPTIONS": {"min_length": 12}, }, {"NAME": "django.contrib.auth.password_validation." "CommonPasswordValidator"}, {"NAME": "django.contrib.auth.password_validation." "NumericPasswordValidator"}, { "NAME": "myproject.apps.auth_extra.password_validation." "MaximumLengthValidator", "OPTIONS": {"max_length": 32}, }, { "NAME": "myproject.apps.auth_extra.password_validation." "SpecialCharacterInclusionValidator", "OPTIONS": { "special_chars": ("{", "}", "^", "&") + SpecialCharacterInclusionValidator .DEFAULT_SPECIAL_CHARACTERS }, }, ]
Django中包含很多默认的密码校验器:
- UserAttributeSimilarityValidator确保所选择的密码与用户的一些属性不具备相似性。默认,相似度设置为0.7,所检测的属性有用户名、姓氏和email地址。如果这此属性包含多个单词,则分别对每个单词进行检测。
- MinimumLengthValidator检查所输入的密码不小于最小字符数。默认,密码必须大于等于8个字符。
- CommonPasswordValidator指一个包含经常使用密码的列表,因此不够安全。Django所使用的默认列表中含有1000个这类密码。
- NumericPasswordValidator验证所输入密码并非全是数字。
在使用startproject管理命令新建项目时,会使用默认选项添加它们为初始校验器。本节中,我们将展示如何按我们的项目需要调整这些选项,将密码最小长度提升为12个字符。
对于UserAttributeSimilarityValidator,我们将还将max_similarity减到了0.5,这表示密码要比默认与这些用户属性具有更大的差别。
在password_validation.py中,我们定义了两个新的校验器:
- MaximumLengthValidator非常类似于内置的最小长度校验器,保证密码长度默认不超过24个字符
- SpecialCharacterInclusionValidator检测在给定的密码中是否包含一个或多个特殊字符,默认有$、%、:、#和!
每个校验器类必须要有两个方法:
- validate()方法对password参数执行实际的检测。在用户进行认证后可传递第二个可选参数user。
- 还应提供一个get_help_text()方法,返回描述用户验证要求的字符串。
最后,我们在配置中添加了一个新验证器,用于重写默认项为密码最大长度为32个字符,并在默认特殊字符列表中增加符号{、}、^和&。
AUTH_PASSWORD_VALIDATORS中所提供的验证器在执行createsuperuser和changepassword管理命令以及用内置表单修改或重置密码时会自动执行。但有时会希望对自定义的密码管理命令执行同样的验证。Django为这一级别的集成提供了一些函数,可在django.contrib.auth.password_validation模块中社区贡献的Django auth应用中查看详情。
- 下载授权文件一节
- 使用Auth0进行认证一节
有时可能希望只有指定人员下载网站上具有知识产权的内容。例如 ,音乐、视频、文学或其它艺术作品只允许付费会员访问。本节中,我们将学习如何使用社区的Django auth应用来仅允许授权的用户进行图片下载。
我们使用第3章 表单和视图中所创建的ideas应用。
逐一执行如下步骤:
-
创建一个视图要求认证后才能下载文件,如下所示:
# myproject/apps/ideas/views.py import os from django.contrib.auth.decorators import login_required from django.http import FileResponse, HttpResponseNotFound from django.shortcuts import get_object_or_404 from django.utils.text import slugify from .models import Idea @login_required def download_idea_picture(request, pk): idea = get_object_or_404(Idea, pk=pk) if idea.picture: filename, extension = os.path.splitext(idea.picture.file.name) extension = extension[1:] # remove the dot response = FileResponse( idea.picture.file, content_type=f"image/{extension}" ) slug = slugify(idea.title)[:100] response["Content-Disposition"] = ( "attachment; filename=" f"{slug}.{extension}" ) else: response = HttpResponseNotFound( content="Picture unavailable" ) return response
-
在URL配置文件中添加下载的视图:
# myproject/apps/ideas/urls.py from django.urls import path from .views import download_idea_picture urlpatterns = [ #... path( "<uuid:pk>/download-picture/", download_idea_picture, name="download_idea_picture", ), ]
-
在项目的URL配置文件中设置登录视图:
# myproject/urls.py from django.conf.urls.i18n import i18n_patterns from django.urls import include, path urlpatterns = i18n_patterns( #... path("accounts/", include("django.contrib.auth.urls")), path("ideas/", include(("myproject.apps.ideas.urls", "ideas"), namespace="ideas")), )
-
为登录表单创建一个模板,如以下代码所示:
{# registration/login.html #} {% extends "base.html" %} {% load i18n %} {% block content %} <h1>{% trans "Login" %}</h1> <form action="{{ request.path }}" method="POST"> {% csrf_token %} {{ form.as_p }} <button type="submit" class="btn btn-primary">{% trans "Log in" %}</button> </form> {% endblock %}
-
在idea详情页模板中,添加一个下载链接:
{# ideas/idea_detail.html #} {% extends "base.html" %} {% load i18n %} {% block content %} ... <a href="{% url 'ideas:download_idea_picture' pk=idea.pk %}" class="btn btn-primary">{% trans "Download picture" %}</a> {% endblock %}
应当限制用户绕过Django直接下载受限文件。如果运行 Apache 2.4,在Apache web 服务器上可以在media/ideas目录的.htaccess文件中添加如下内容:
# media/ideas/.htaccess
Require all denied
ℹ️在使用django-imagekit时,本书中一直有使用,所生成的图片存储于media/CACHE目录中对外提供服务,因为我们的.htaccess配置不会对其产生影响。
download_idea_picture视图将数据流指向具体idea的原始上传图片。设置为attachment的Content-Disposition头使得图片可以进行下载,不在浏览器中直接展示 。该头部还设置了文件名,名称类似于gamified-donation-platform.jpg。如果该idea不存在这张力片,会返回一个404页面,显示一个简单的消息:Picture unavailable。
@login_required装饰器会在用户未登录而尝试访问下载文件时将访客重定向至登录页面。默认登录页面如下:
TODO
- 第3章 表单和视图中上传图片一节
- 第3章 表单和视图中通过自定义模板创建表单布局一节
- 第3章 表单和视图中通过django-crispy-forms创建表单布局一节
- 第4章 模板和JavaScript中的编排base.html模板一节
- 实现密码校验一节
- 对图片添加动态水印一节
有时,可以让用户看到图片,但因知识产权和艺术版权等原因不允许重新发布。本节中,我们将学习如何对显示在网站上的图片应用水印。
我们使用第3章 表单和视图中的使用CRUDL函数创建应用一节中所创建的core和ideas应用。
按照如下步骤对所显示的idea图片应用水印:
-
如未安装,请先在你的虚拟环境中安装django-imagekit:
(env)$ pip install django-imagekit==4.0.2
-
将"imagekit"添加至配置文件的INSTALLED_APPS中:
# myproject/settings/_base.py INSTALLED_APPS = [ #... "imagekit", ]
-
在core应用中,创建名为processors.py的文件并在其中添加WatermarkOverlay类,如下:
# myproject/apps/core/processors.py from pilkit.lib import Image class WatermarkOverlay(object): def __init__(self, watermark_image): self.watermark_image = watermark_image def process(self, img): original = img.convert('RGBA') overlay = Image.open(self.watermark_image) img = Image.alpha_composite(original, overlay).convert('RGB') return img
-
在Idea模型中的picture下面添加watermarked_picture_large规格,如下所示:
# myproject/apps/ideas/models.py import os from imagekit.models import ImageSpecField from pilkit.processors import ResizeToFill from django.db import models from django.conf import settings from django.utils.translation import gettext_lazy as _ from django.utils.timezone import now as timezone_now from myproject.apps.core.models import CreationModificationDateBase, UrlBase from myproject.apps.core.processors import WatermarkOverlay def upload_to(instance, filename): now = timezone_now() base, extension = os.path.splitext(filename) extension = extension.lower() return f"ideas/{now:%Y/%m}/{instance.pk}{extension}" class Idea(CreationModificationDateBase, UrlBase): #... picture = models.ImageField( _("Picture"), upload_to=upload_to ) watermarked_picture_large = ImageSpecField( source="picture", processors=[ ResizeToFill(800, 400), WatermarkOverlay( watermark_image=os.path.join(settings.STATIC_ROOT, 'site', 'img', 'watermark.png'), ) ], format="PNG" )
-
使用你自己喜欢的图片处理软件,创建一个半透明的PNG图片,在透明背景上包含白色文本或logo。将尺寸设置为800 x 400 px。将图片保存至site_static/site/img/watermark.png。效果类似下面这样:
TODO -
然后运行collectstatic管理命令:
(env)$ export DJANGO_SETTINGS_MODULE=myproject.settings.dev (env)$ python manage.py collectstatic
-
编辑idea详情模板并在其中添加打水印后的图片,如下:
{# ideas/idea_detail.html #} {% extends "base.html" %} {% load i18n %} {% block content %} <a href="{% url "ideas:idea_list" %}">{% trans "List of ideas" %}</a> <h1> {% blocktrans trimmed with title=idea.translated_title %} Idea "{{ title }}" {% endblocktrans %} </h1> <img src="{{ idea.watermarked_picture_large.url }}" alt="" /> {{ idea.translated_content|linebreaks|urlize }} <p> {% for category in idea.categories.all %} <span class="badge badge-pill badge-info"> {{ category.translated_title }}</span> {% endfor %} </p> <a href="{% url 'ideas:download_idea_picture' pk=idea.pk %}" class="btn btn-primary">{% trans "Download picture" %}</a> {% endblock %}
如果访问idea详情页,会看到遮罩了水印的大图,类似下面这样:
TODO
我们来了解下如何实现的。在详情模板中,标签的src属性使用idea的图片规格,如watermarked_picture_large,位于media/CACHE/目录中创建了修改后的图片,从该处提供数据。
django-imagekit规格使用处理器(processor)来改变图片。这里使用了两个处理器:
- ResizeToFill改变图片大小为800 × 400 px
- 我们的自定义处理器,WatermarkOverlay,对其应用半透明浮层
django-imagekit处理器有一个从前置处理器获取图片的process()方法,返回新修改后的图片。本例中,我们处理原始图片及半透明遮罩获取结果。
- 下载授权文件一节
人们交互的服务数量与日俱增,他们需要记住的用户名和密码也同样在增加。除此之外,每个存储用户信息的地方从安全泄漏的角度都会增加一处被窃取的位置。为削弱这种问题,Auth0这样的服务把认证服务集中到单个安全的平台上。
除支持用户名和密码信息史上,Auth0还具体通过社交平台如Google、Facebook或Twitter进行认证的功能。可以使用通过短信或邮件发送的单次验证码实现无密码登录,甚至还有对于不同服务的企业级支持。本节中,我们将学习如何对接Auth0应用至Django中,以及如何进行集成来处理用户认证。
尚未创建Auth0应用的用户可在https://auth0.com/进行创建,按照下面的提示进行配置。在免费方案中提供了两个社交账户连接,因此我们激活Google和Twitter来实现登录。用户也可以尝试其它服务。注意有些账户要求注册一个应用并获取API key及密钥。
接下来,我们需要在项目中安装python-social-auth及一些其它依赖。在pip安装文件中添加如下依赖:
# requirements/_base.txt
social-auth-app-django~=3.1
python-jose~=3.0
python-dotenv~=0.9
ℹ️social-auth-app-django 是python- social-auth项目特别针对Django的包,它让我们可以使用某一社交账户来进行网站用户认证。
使用pip在虚拟环境中安装这些依赖。
按照如下步骤将Auth0连接到你的Django项目中:
-
在配置文件中对INSTALLED_APPS添加社交认证应用,如下:
# myproject/settings/_base.py INSTALLED_APPS = [ #... "social_django", ]
-
接着添加social_django应用所需的Auth0配置,类似下面这样:
# myproject/settings/_base.py SOCIAL_AUTH_AUTH0_DOMAIN = get_secret("AUTH0_DOMAIN") SOCIAL_AUTH_AUTH0_KEY = get_secret("AUTH0_KEY") SOCIAL_AUTH_AUTH0_SECRET = get_secret("AUTH0_SECRET") SOCIAL_AUTH_AUTH0_SCOPE = ["openid", "profile", "email"] SOCIAL_AUTH_TRAILING_SLASH = False
确保在密钥或环境变量中定义AUTH0_DOMAIN、AUTH0_KEY和AUTH0_SECRET。这些变量的值可以在本小节准备工作的第1步中所创建的Auth0应用配置中查找到。
-
我们需要为Auth0连接创建一个后台,如下例所示:
# myproject/apps/external_auth/backends.py from urllib import request from jose import jwt from social_core.backends.oauth import BaseOAuth2 class Auth0(BaseOAuth2): """Auth0 OAuth authentication backend""" name = "auth0" SCOPE_SEPARATOR = " " ACCESS_TOKEN_METHOD = "POST" REDIRECT_STATE = False EXTRA_DATA = [("picture", "picture"), ("email", "email")] def authorization_url(self): return "https://" + self.setting("DOMAIN") + "/authorize" def access_token_url(self): return "https://" + self.setting("DOMAIN") + "/oauth/token" def get_user_id(self, details, response): """Return current user id.""" return details["user_id"] def get_user_details(self, response): # Obtain JWT and the keys to validate the signature id_token = response.get("id_token") jwks = request.urlopen( "https://" + self.setting("DOMAIN") + "/.well- known/jwks.json" ) issuer = "https://" + self.setting("DOMAIN") + "/" audience = self.setting("KEY") # CLIENT_ID payload = jwt.decode( id_token, jwks.read(), algorithms=["RS256"], audience=audience, issuer=issuer, ) first_name, last_name = (payload.get("name") or " ").split(" ", 1) return { "username": payload.get("nickname") or "", "first_name": first_name, "last_name": last_name, "picture": payload.get("picture") or "", "user_id": payload.get("sub") or "", "email": payload.get("email") or "", }
-
在AUTHENTICATION_BACKENDS配置中添加这个新后台,如以下代码所示:
# myproject/settings/_base.py AUTHENTICATION_BACKENDS = { "myproject.apps.external_auth.backends.Auth0", "django.contrib.auth.backends.ModelBackend", }
-
我们希望可以在任意模板中访问这些社交认证用户。因此将为其创建一个上下文处理器:
# myproject/apps/external_auth/context_processors.py def auth0(request): data = {} if request.user.is_authenticated: auth0_user = request.user.social_auth.filter( provider="auth0", ).first() data = { "auth0_user": auth0_user, } return data
-
然后我们在配置文件中进行注册:
# myproject/settings/_base.py TEMPLATES = [ { "BACKEND": "django.template.backends.django.DjangoTemplates", "DIRS": [os.path.join(BASE_DIR, "myproject", "templates")], "APP_DIRS": True, "OPTIONS": { "context_processors": [ "django.template.context_processors.debug", "django.template.context_processors.request", "django.contrib.auth.context_processors.auth", "django.contrib.messages.context_processors.messages", "django.template.context_processors.media", "django.template.context_processors.static", "myproject.apps.core.context_processors.website_url", "myproject.apps.external_auth .context_processors.auth0", ] }, } ]
-
下面创建首页、仪表盘和登出页的视图:
# myproject/apps/external_auth/views.py from urllib.parse import urlencode from django.shortcuts import render, redirect from django.contrib.auth.decorators import login_required from django.contrib.auth import logout as log_out from django.conf import settings def index(request): user = request.user if user.is_authenticated: return redirect(dashboard) else: return render(request, "index.html") @login_required def dashboard(request): return render(request, "dashboard.html") def logout(request): log_out(request) return_to = urlencode({"returnTo": request.build_absolute_uri("/")}) logout_url = "https://%s/v2/logout?client_id=%s&%s" % ( settings.SOCIAL_AUTH_AUTH0_DOMAIN, settings.SOCIAL_AUTH_AUTH0_KEY, return_to, ) return redirect(logout_url)
-
创建首页模板如下:
{# index.html #} {% extends "base.html" %} {% load i18n utility_tags %} {% block content %} <div class="login-box auth0-box before"> <h3>{% trans "Please log in for the best user experience" %}</h3> <a class="btn btn-primary btn-lg" href="{% url "social:begin" backend="auth0" %}">{% trans "Log in" %}</a> </div> {% endblock %}
-
相应地创建一个仪表盘模板:
{# dashboard.html #} {% extends "base.html" %} {% load i18n %} {% block content %} <div class="logged-in-box auth0-box logged-in"> <img alt="{% trans 'Avatar' %}" src="{{ auth0_user.extra_data.picture }}" width="50" height="50" /> <h2>{% blocktrans with name=request.user.first_name %}Welcome, {{ name }} {% endblocktrans %}!</h2> <a class="btn btn-primary btn-logout" href="{% url "auth0_logout" %}">{% trans "Log out" %}</a> </div> {% endblock %}
-
更新URL规则:
# myproject/urls.py from django.conf.urls.i18n import i18n_patterns from django.urls import path, include from myproject.apps.external_auth import views as external_auth_views urlpatterns = i18n_patterns( path("", external_auth_views.index, name="index"), path("dashboard/", external_auth_views.dashboard, name="dashboard"), path("logout/", external_auth_views.logout, name="auth0_logout"), path("", include("social_django.urls")), #... )
-
最后添加登录URL配置:
LOGIN_URL = "/login/auth0" LOGIN_REDIRECT_URL = "dashboard"
如果在浏览器中访问项目的首页,会看到邀请登录的链接 。点击后会被重定向到Auth0的认证系统,界面像下面这样:
TODO
这是python-social-auth内部实现的,通过相关联的SOCIAL_AUTH_*设置配置一个Auth0后台。
成功完成登录后,Auth0后台接收到来自响应的数据并进行处理。相关联的数据添加到请求相关的用户对象中。在仪表盘视图中,即通过处理LOGIN_REDIRECT_URL到达的认证结果 页,会提取用户详情并添加到模板上下文中。之后渲染dashboard.html。结果如下所示:
TODO
仪表盘中展示的登出按钮,在点击后会进行登出用户的处理。
- 实现密码校验一节
- 下载授权文件一节
在请求-响应环中调用需重度计算或多次进行数据库查询的方法时,视图的性能可能会变得很低。本节中,我们将学习可用于缓存方法返回值以供稍后反复使用的模式。注意这里没有使用Django的缓存框架,它是Python默认提供的。
选择一个带有包含有耗时方法并在请求-响应环中反复使用的模型的应用。
执行如下步骤:
-
这种模式可用于缓存模型的方法返回值,供视图、表单或模板重复使用,如下所示:
class SomeModel(models.Model): def some_expensive_function(self): if not hasattr(self, "_expensive_value_cached"): # do some heavy calculations... # ... and save the result to result variable self._expensive_value_cached = result return self._expensive_value_cached
-
例如,我们为ViralVideo模型创建一个get_thumbnail_url()方法。在第10章 锦上添花中使用数据库查询表达式一节里我们会学习到更多的详情:
# myproject/apps/viral_videos/models.py import re from django.db import models from django.utils.translation import ugettext_lazy as _ from myproject.apps.core.models import CreationModificationDateBase, UrlBase class ViralVideo(CreationModificationDateBase, UrlBase): embed_code = models.TextField( _("YouTube embed code"), blank=True) #... def get_thumbnail_url(self): if not hasattr(self, "_thumbnail_url_cached"): self._thumbnail_url_cached = "" url_pattern = re.compile( r'src="https://www.youtube.com/embed/([^"]+)"' ) match = url_pattern.search(self.embed_code) if match: video_id = match.groups()[0] self._thumbnail_url_cached = ( f"https://img.youtube.com/vi/{video_id}/0.jpg" ) return self._thumbnail_url_cached
在这个通用的示例中,方法查看有没有模型的_expensive_value_cached 属性存在。如果不存在,执行耗时的运算并将结果赋值给新属性。在方法的最后,返回缓存值。当然,如果有多个比较重的方法,会需要使用不同的属性名称来保存每个运算所得的值。
此时可以在模板的头部和底部使用{{ object.some_expensive_function }}这样的表单,耗时运算只会执行一次。
在模板中,还可以在{% if %}条件表达式及值的输出中使用该函数,如下所示:
{% if object.some_expensive_function %}
<span class="special">
{{ object.some_expensive_function }}
</span>
{% endif %}
在另一个示例中,会们通过解析视频嵌入代码获取其ID并拼装出缩略图的URL,来查看YouTube视频的缩略图。这样就可以使用如下的模板:
{% if video.get_thumbnail_url %}
<figure>
<img src="{{ video.get_thumbnail_url }}"
alt="{{ video.title }}"
/>
<figcaption>{{ video.title }}</figcaption>
</figure>
{% endif %}
我们刚刚描述的方法仅在方法无需传入参数、每次结果一致时有作用。但如果输入值发生变化会怎样呢?从Python 3.2开始,我们用于提供方法调用最近最少使用(LRU)缓存的装饰器基于参数的哈希(至少对于那些可哈希的参数如此)。
例如,我们来看一个虚构的小示例,其中函数接收两个参数并返回一些复杂逻辑运算后的结果:
def busy_bee(a, b):
# expensive logic
return result
如果我们有这样一个函数,希望提供用于存储一些常用输入变化结果的缓存,可以轻松地使用functools包中的@lru_cache装饰器,如下所示:
from functools import lru_cache
@lru_cache(maxsize=100, typed=True)
def busy_bee(a, b):
# expensive logic
return result
这样我们提供了一个通过输入值哈希的键进行存储多达100个结果的缓存机制。typed选项于Python 3.3开始加入,通过将其指定为True,我们可以将a=1和b=2 与 a=1.0和b=2.0分别进行单独存储。根据逻辑运算和返回值的不同,这种变化可能适用也可能不适用。
ℹ️可以通过functools文档了解更多有关@lru_cache装饰器的知识。
我们还可以对本节前面的示例使用这一装饰器简化代码,如下所示:
# myproject/apps/viral_videos/models.py
from functools import lru_cache
#...
class ViralVideo(CreationModificationDateMixin, UrlMixin):
#...
@lru_cache
def get_thumbnail_url(self):
#...
- 第4章 模板和JavaScript
- 使用Memcached缓存Django视图一节
- 使用Redis缓存Django视图一节
Django让我们可以通过缓存开销比大的部分来提升请求-响应环的速度,比如数据库查询或模板渲染。Django原生支持的最快速、最可靠的缓存是基于内存的缓存服务端Memcached。本节中,我们将学习如何使用Memcached来缓存viral_videos应用中的视图。我们会在第10章 锦上添花中使用数据库查询表达式一节中做更进一步的了解。
我们需要做很多事来为Django项目准备缓存:
-
先安装memcached服务。例如macOS上最简单的方式是使用Homebrew:
$ brew install memcached
-
然后可以使用如下命令启动、停止或重启Memcached服务:
$ brew services start memcached $ brew services stop memcached $ brew services restart memcached
ℹ️在其它操作系统中,可以使用apt-get、yum等默认包管理工具安装Memcached。还有一种方式是通过源码进行编译安装,参见https://memcached.org/downloads。
-
在虚拟环境中安装Memcached Python包:
(env)$ pip install python-memcached==1.59
对指定的视图集成缓存,执行如下步骤:
-
在项目配置文件中进行CACHES的设置,如下:
# myproject/settings/_base.py CACHES = { "memcached": { "BACKEND": "django.core.cache.backends.memcached.MemcachedCache", "LOCATION": get_secret("CACHE_LOCATION"), "TIMEOUT": 60, # 1 minute "KEY_PREFIX": "myproject", }, } CACHES["default"] = CACHES["memcached"]
-
确保在secrets文件或环境变量中将CACHE_LOCATION设置为localhost:11211。
-
修改viral_videos应用的视图如下:
# myproject/apps/viral_videos/views.py from django.shortcuts import render from django.views.decorators.cache import cache_page from django.views.decorators.vary import vary_on_cookie @vary_on_cookie @cache_page(60) def viral_video_detail(request, pk): #... return render( request, "viral_videos/viral_video_detail.html", {'video': video} )
ℹ️如果按照下一节中的Redis配置进行操作,会发现在views.py文件中无需进行什么修改。这表示我们可以不对代码进行修改即可修改底层的缓存机制并投入使用。
稍后我们学习第10章 锦上添花中使用数据库查询表达式一节时会发现,爆款视频详情视图通过登录和匿名用户展示访问数。如果访问了某个爆款视频(如http://127.0.0.1:8000/en/videos/1/)并在启用了缓存的情况下多次刷新页面,会发现访问数每分钟仅产生一次变化 。这是因为对于每个用户响应内容会缓存60秒。我们通过使用@cache_page装饰器来对视图设置缓存。
Memcached是一种键值对存储,它默认使用完整URL来生成每一缓存页的键。在两个访客同时访问同一页面时,第一个访客会接收到Python代码所生成的页面,而第二个用户则会从Memcached服务中获取到相同的HTML代码。
在我们的示例中,要让每个访客即使访问同一URL时进行分别对待的话,可以使用@vary_on_cookie装饰器。这一装饰器查看HTTP请求中Cookie头部的唯一性。
💡可以通过官方文档学习更多有关Django缓存框架的知识。同样也可以通过https://memcached.org学习更多有关Memcached的知识。
- 缓存方法返回值一节
- 使用Redis缓存Django视图一节
- 第10章 锦上添花中使用数据库查询表达式一节
虽然作为缓存机制Memcached在市场上有着稳固的地位并且Django也可以很好的支持。Redis是一套提供有Memcached所有功能并具有更多功能的替代系统。这里我们将回顾使用Memcached缓存Django视图一节中的流程,学习如何使用Redis来实现同样的功能。
对Django项目添加缓存需要完成一些操作:
-
安装Redis服务。例如,在macOS中最简单的安装方式是使用Homebrew:
$ brew install redis
-
然后我们可以使用如下命令来启动、停止或重启Redis服务:
$ brew services start redis $ brew services stop redis $ brew services restart redis
ℹ️在其它操作系统下,可以使用apt-get、yum或其它的包管理工具来安装Redis。另一种方式是通过源码进行编译安装,参见https://redis.io/download。
-
在虚拟环境中对Django安装Redis缓存后台及其依赖,如下:
(env)$ pip install redis==3.3.11 (env)$ pip install hiredis==1.0.1 (env)$ pip install django-redis-cache==2.1.0
执行如下步骤来对指定的视图集成缓存:
-
在项目配置文件中设置CACHES,如下:
# myproject/settings/_base.py CACHES = { "redis": { "BACKEND": "redis_cache.RedisCache", "LOCATION": [get_secret("CACHE_LOCATION")], "TIMEOUT": 60, # 1 minute "KEY_PREFIX": "myproject", }, } CACHES["default"] = CACHES["redis"]
-
确保在secrets文件或环境变量中将CACHE_LOCATION设置为localhost:6379。
-
修改viral_videos应用的视图如下:
# myproject/apps/viral_videos/views.py from django.shortcuts import render from django.views.decorators.cache import cache_page from django.views.decorators.vary import vary_on_cookie @vary_on_cookie @cache_page(60) def viral_video_detail(request, pk): #... return render( request, "viral_videos/viral_video_detail.html", {'video': video} )
ℹ️如果按照前一节中的Memcached配置进行操作,会发现在views.py文件中无需进行什么修改。这表示我们可以不对代码进行修改即可修改底层的缓存机制并投入使用。
和Memcached一样,我们使用@cache_page装饰器对视图设置缓存。因此每条响应对每个用户都有60秒的缓存。爆款视频详情视图通过登录和匿名用户展示访问数。如果访问了某个爆款视频(如http://127.0.0.1:8000/en/videos/1/)并在启用了缓存的情况下多次刷新页面,会发现访问数每分钟仅产生一次变化 。
和Memcached相同,Redis是一种键值对存储,它根据完整URL生成每一缓存页的键。在两个访客同时访问同一页面时,第一个访客会接收到Python代码所生成的页面,而第二个访客则会从Redis服务端获取到相同的HTML代码。
在本例中,要让每个访客在访问同一URL时也进行分别对待的话,可以使用@vary_on_cookie装饰器。这一装饰器检测HTTP请求中Cookie头部的唯一性。
💡可以通过官方文档学习更多有关Django缓存框架的知识。同样也可以通过https://redis.io学习更多有关Redis的知识。
虽然Redis可以以Memcached相同的方式处理缓存,它还有很多其它内置在系统中的缓存算法选项。除缓存外,Redis还可用作数据库或消息存储。它支持多种数据结构、事务、pub/sub(发布/订阅)和自动故障转移(automatic failover)等等。
借助django-redis-cache后台,还可以轻松地将Redis配置为会话后台,如下:
# myproject/settings/_base.py
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
SESSION_CACHE_ALIAS = "default"
- 缓存方法返回值一节
- 使用Memcached缓存Django视图一节
- 第10章 锦上添花中使用数据库查询表达式一节