Web应用程序的核心是让任何用户都能够注册账户并能够使用它,不管用户身处何方。在本章中,你将创建一些表单,让用户能够添加主题和条目,以及编辑既有的
条目。你还将学习Django如何防范对基于表单的网页发起的常见攻击,这让你无需花太多时间考虑确保应用程序安全的问题。
然后,我们将实现一个用户身份验证系统。你将创建一个注册页面,供用户创建账户,并让有些页面只能供已登录的用户访问。接下来,我们将修改一些视图函数,
使得用户只能看到自己的数据。你将学习如何确保用户数据的安全。
让用户拥有自己的数据
用户应该能够输入其专有的数据,因此我们将创建一个系统,确定各项数据所属的用户,再限制对页面的访问,让用户只能使用自己的数据。
在本节中,我们将修改模型Topic ,让每个主题都归属于特定用户。这也将影响条目,因为每个条目都属于特定的主题。我们先来限制对一些页面的访问。
使用@login_required 限制访问
Django提供了装饰器@login_required ,让你能够轻松地实现这样的目标:对于某些页面,只允许已登录的用户访问它们。装饰器 (decorator)是放在函数定义前面的指
令,Python在函数运行前,根据它来修改函数代码的行为。下面来看一个示例。
限制对topics 页面的访问
每个主题都归特定用户所有,因此应只允许已登录的用户请求topics 页面。为此,在learning_logs/views.py中添加如下代码:
--snip--
from django.core.urlresolvers import reverse
from django.contrib.auth.decorators import login_required
from .models import Topic, Entry
--snip--
@login_required
def topics(request):
"""显示所有的主题"""
--snip--
我们首先导入了函数login_required() 。我们将login_required() 作为装饰器用于视图函数topics() ——在它前面加上符号@ 和login_required ,让Python在运
行topics() 的代码前先运行login_required() 的代码。
login_required() 的代码检查用户是否已登录,仅当用户已登录时,Django才运行topics() 的代码。如果用户未登录,就重定向到登录页面。
为实现这种重定向,我们需要修改settings.py,让Django知道到哪里去查找登录页面。请在settings.py末尾添加如下代码:
"""
项目learning_log的Django设置
--snip--
# 我的设置
LOGIN_URL = '/users/login/'
现在,如果未登录的用户请求装饰器@login_required 的保护页面,Django将重定向到settings.py中的LOGIN_URL 指定的URL。
要测试这个设置,可注销并进入主页。然后,单击链接Topics,这将重定向到登录页面。接下来,使用你的账户登录,并再次单击主页中的Topics链接,你将看到topics页面。
全面限制对项目“学习笔记”的访问
Django让你能够轻松地限制对页面的访问,但你必须针对要保护哪些页面做出决定。最好先确定项目的哪些页面不需要保护,再限制对其他所有页面的访问。你可以轻松地修改
过于严格的访问限制,其风险比不限制对敏感页面的访问更低。
在项目“学习笔记”中,我们将不限制对主页、注册页面和注销页面的访问,并限制对其他所有页面的访问。
在下面的learning_logs/views.py中,对除index() 外的每个视图都应用了装饰器@login_required :
--snip--
@login_required
def topics(request):
--snip--
@login_required
def topic(request, topic_id):
--snip--
@login_required
def new_topic(request):
--snip--
@login_required
def new_entry(request, topic_id):
--snip--
@login_required
def edit_entry(request, entry_id):
--snip--
如果你在未登录的情况下尝试访问这些页面,将被重定向到登录页面。另外,你还不能单击到new_topic 等页面的链接。但如果你输入URL http://localhost:8000/new_topic/,将重
定向到登录页面。对于所有与私有用户数据相关的URL,都应限制对它们的访问。
将数据关联到用户
现在,需要将数据关联到提交它们的用户。我们只需将最高层的数据关联到用户,这样更低层的数据将自动关联到用户。例如,在项目“学习笔记”中,应用程序的最高层数据是
主题,而所有条目都与特定主题相关联。只要每个主题都归属于特定用户,我们就能确定数据库中每个条目的所有者。
下面来修改模型Topic ,在其中添加一个关联到用户的外键。这样做后,我们必须对数据库进行迁移。最后,我们必须对有些视图进行修改,使其只显示与当前登录的用户相关
联的数据。
修改模型Topic
对models.py的修改只涉及两行代码:
from django.db import models
from django.contrib.auth.models import User
class Topic(models.Model):
"""用户要学习的主题"""
text = models.CharField(max_length=200)
date_added = models.DateTimeField(auto_now_add=True)
owner = models.ForeignKey(User)
def __str__(self):
"""返回模型的字符串表示"""
return self.text
class Entry(models.Model):
--snip--
我们首先导入了django.contrib.auth 中的模型User ,然后在Topic 中添加了字段owner ,它建立到模型User 的外键关系。
确定当前有哪些用户
我们迁移数据库时,Django将对数据库进行修改,使其能够存储主题和用户之间的关联。为执行迁移,Django需要知道该将各个既有主题关联到哪个用户。最简单的办法是,将既
有主题都关联到同一个用户,如超级用户。为此,我们需要知道该用户的ID。
下面来查看已创建的所有用户的ID。为此,启动一个Django shell会话,并执行如下命令:
(venv)learning_log$ python manage.py shell
❶ >>> from django.contrib.auth.models import User
❷ >>> User.objects.all()
[<User: ll_admin>, <User: eric>, <User: willie>]
❸ >>> for user in User.objects.all():
... print(user.username, user.id)
...
ll_admin 1
eric 2
willie 3
>>>
在❶处,我们在shell会话中导入了模型User 。然后,我们查看到目前为止都创建了哪些用户(见❷)。输出中列出了三个用户:ll_admin、eric和willie。
在❸处,我们遍历用户列表,并打印每位用户的用户名和ID。Django询问要将既有主题关联到哪个用户时,我们将指定其中的一个ID值。
迁移数据库
知道用户ID后,就可以迁移数据库了。
❶ (venv)learning_log$ python manage.py makemigrations learning_logs
❷ You are trying to add a non-nullable field 'owner' to topic without a default;
we can't do that (the database needs something to populate existing rows).
❸ Please select a fix:
1) Provide a one-off default now (will be set on all existing rows)
2) Quit, and let me add a default in models.py
❹ Select an option: 1
❺ Please enter the default value now, as valid Python
The datetime and django.utils.timezone modules are available, so you can do e.g. timezone.now()
❻ >>> 1
Migrations for 'learning_logs':
0003_topic_owner.py:
- Add field owner to topic
我们首先执行了命令makemigrations (见❶)。在❷处的输出中,Django指出我们试图给既有模型Topic 添加一个必不可少(不可为空)的字段,而该字段没有默认值。在
❸处,Django给我们提供了两种选择:要么现在提供默认值,要么退出并在models.py中添加默认值。在❹处,我们选择了第一个选项,因此Django让我们输入默认值(见❺)。
为将所有既有主题都关联到管理用户ll_admin,我输入了用户ID值1(见❻)。并非必须使用超级用户,而可使用已创建的任何用户的ID。接下来,Django使用这个值来迁移数据
库,并生成了迁移文件0003_topic_owner.py,它在模型Topic 中添加字段owner 。
现在可以执行迁移了。为此,在活动的虚拟环境中执行下面的命令:
(venv)learning_log$ python manage.py migrate
Operations to perform:
Synchronize unmigrated apps: messages, staticfiles
Apply all migrations: learning_logs, contenttypes, sessions, admin, auth
--snip--
Running migrations:
Rendering model states... DONE
❶ Applying learning_logs.0003_topic_owner... OK
(venv)learning_log$
Django应用新的迁移,结果一切顺利(见❶)。
为验证迁移符合预期,可在shell会话中像下面这样做:
❶ >>> from learning_logs.models import Topic
❷ >>> for topic in Topic.objects.all():
... print(topic, topic.owner)
...
Chess ll_admin
Rock Climbing ll_admin
>>>
我们从learning_logs.models 中导入Topic (见❶),再遍历所有的既有主题,并打印每个主题及其所属的用户(见❷)。正如你看到的,现在每个主题都属于用户
ll_admin。
注意 你可以重置数据库而不是迁移它,但如果这样做,既有的数据都将丢失。一种不错的做法是,学习如何在迁移数据库的同时确保用户数据的完整性。如果你确
实想要一个全新的数据库,可执行命令python manage.py flush ,这将重建数据库的结构。如果你这样做,就必须重新创建超级用户,且原来的所有数据都将
丢失。
只允许用户访问自己的主题
当前,不管你以哪个用户的身份登录,都能够看到所有的主题。我们来改变这种情况,只向用户显示属于自己的主题。
在views.py中,对函数topics() 做如下修改:
--snip--
@login_required
def topics(request):
"""显示所有的主题"""
topics = Topic.objects.filter(owner=request.user).order_by('date_added')
context = {'topics': topics}
return render(request, 'learning_logs/topics.html', context)
--snip--
用户登录后,request 对象将有一个user 属性,这个属性存储了有关该用户的信息。代码Topic.objects.filter(owner=request.user) 让Django只从数据库中获
取owner 属性为当前用户的Topic 对象。由于我们没有修改主题的显示方式,因此无需对页面topics的模板做任何修改。
要查看结果,以所有既有主题关联到的用户的身份登录,并访问topics页面,你将看到所有的主题。然后,注销并以另一个用户的身份登录,topics页面将不会列出任何主题。
保护用户的主题
我们还没有限制对显示单个主题的页面的访问,因此任何已登录的用户都可输入类似于http://localhost:8000/topics/1/的URL,来访问显示相应主题的页面。
你自己试一试就明白了。以拥有所有主题的用户的身份登录,访问特定的主题,并复制该页面的URL,或将其中的ID记录下来。然后,注销并以另一个用户的身份登录,再输入
显示前述主题的页面的URL。虽然你是以另一个用户登录的,但依然能够查看该主题中的条目。
为修复这种问题,我们在视图函数topic() 获取请求的条目前执行检查:
from django.shortcuts import render
❶ from django.http import HttpResponseRedirect, Http404
from django.core.urlresolvers import reverse
--snip--
@login_required
def topic(request, topic_id):
"""显示单个主题及其所有的条目"""
topic = Topic.objects.get(id=topic_id)
# 确认请求的主题属于当前用户
❷ if topic.owner != request.user:
raise Http404
entries = topic.entry_set.order_by('-date_added')
context = {'topic': topic, 'entries': entries}
return render(request, 'learning_logs/topic.html', context)
--snip--
服务器上没有请求的资源时,标准的做法是返回404响应。在这里,我们导入了异常Http404 (见❶),并在用户请求它不能查看的主题时引发这个异常。收到主题请求后,我
们在渲染网页前检查该主题是否属于当前登录的用户。如果请求的主题不归当前用户所有,我们就引发Http404 异常(见❷),让Django返回一个404错误页面。
现在,如果你试图查看其他用户的主题条目,将看到Django发送的消息Page Not Found。在第20章,我们将对这个项目进行配置,让用户看到更合适的错误页面。
保护页面edit_entry
页面edit_entry 的URL为http://localhost:8000/edit_entry/entry_id / ,其中 entry_id 是一个数字。下面来保护这个页面,禁止用户通过输入类似于前面
的URL来访问其他用户的条目:
--snip--
@login_required
def edit_entry(request, entry_id):
"""编辑既有条目"""
entry = Entry.objects.get(id=entry_id)
topic = entry.topic
if topic.owner != request.user:
raise Http404
if request.method != 'POST':
# 初次请求,使用当前条目的内容填充表单
--snip--
我们获取指定的条目以及与之相关联的主题,然后检查主题的所有者是否是当前登录的用户,如果不是,就引发Http404 异常。
将新主题关联到当前用户
当前,用于添加新主题的页面存在问题,因此它没有将新主题关联到特定用户。如果你尝试添加新主题,将看到错误消息IntegrityError ,指
出learning_logs_topic.user_id 不能为NULL 。Django的意思是说,创建新主题时,你必须指定其owner 字段的值。
由于我们可以通过request 对象获悉当前用户,因此存在一个修复这种问题的简单方案。请添加下面的代码,将新主题关联到当前用户:
--snip--
@login_required
def new_topic(request):
"""添加新主题"""
if request.method != 'POST':
# 没有提交的数据,创建一个空表单
form = TopicForm()
else:
# POST提交的数据,对数据进行处理
form = TopicForm(request.POST)
if form.is_valid():
❶ new_topic = form.save(commit=False)
❷ new_topic.owner = request.user
❸ new_topic.save()
return HttpResponseRedirect(reverse('learning_logs:topics'))
context = {'form': form}
return render(request, 'learning_logs/new_topic.html', context)
--snip--
我们首先调用form.save() ,并传递实参commit=False ,这是因为我们先修改新主题,再将其保存到数据库中(见❶)。接下来,将新主题的owner 属性设置为当前用户
(见❷)。最后,对刚定义的主题实例调用save() (见❸)。现在主题包含所有必不可少的数据,将被成功地保存。
现在,这个项目允许任何用户注册,而每个用户想添加多少新主题都可以。每个用户都只能访问自己的数据,无论是查看数据、输入新数据还是修改旧数据时都如此。