Django时区安全问题 Django中和时区相关的安全问题详解
PHITHON 人气:0在开发国际化网站的时候,难免会与时区打交道,通用CMS更是如此,毕竟其潜在用户可能是来自于全球各地的。Django在时区这个问题上下了不少功夫,但是很多资深的开发者都有可能尚未完全屡清楚Django中各种时间的实际意义和使用方法,导致写出错误的代码;作为安全研究人员,时区问题也可能和一些安全问题挂钩,比如优惠券的过期时间、订单的下单与取消时间等,如果没有考虑时区问题,有可能将导致一些逻辑漏洞。
本文就从多个常用模块开始,了解一下Django中的时区究竟是怎么回事,以及在时间的比较中可能出现的一些逻辑错误。
从“两种时间”说起
我们都知道,在Python中表示“时间”的对象是datetime.datetime
。
其实在Python中,这个对象被分成了两个类型:
- aware datetime
- naive datetime
他们的区别是:如果datetime
对象的tzinfo
属性有设置时区值,则这个对象是一个aware datime;否则它是一个naive datetime。
举个例子,我们平时在编写Python脚本的时候,使用下面这行代码获取当前时间:
from datetime import datetime t = datetime.now()
此时,t是一个naive datetime,因为我们没有给他设置时区:
naive的中文意思大家应该都很熟悉,这里的大概意思就是“simple”,这是一个很简单、原始的时间对象。实际上就是指,计算机不知道这个时间,他的时区究竟是什么,它可能代表着北京时间,也可能是UTC时间,因为我们没有指定时区,我们无法“假设”其是计算机系统所在的时区,也无法“假设”其是UTC时区。也就是说,计算机拿到了一个naive datetime,是无法准确地定位到某一个时间点的,也无法直接转换成一个unix时间戳。
那么相对的,aware datetime就是计算机能准确知道其时区的时间对象,他是一个准确的时间点,就落在时间轴上的某个地方,不管从哪个时区看,这个点都是绝对固定的。所以,我们可以将一个aware datetime转换成unix时间戳。
有的同学可能比较好奇,你说naive datetime无法转换成时间戳,那么为什么这个对象有一个timestamp()
方法呢:
原因我们查文档可以得出结论,如果对象是naive datetime,则会以当前系统本地时区为准。
Django的时区配置
回到Django。由于Django是一个国际化框架,时区相关处理自然是其必不可少的组成部分。Django的配置项中,有下面两个选项与时区相关:
USE_TZ
TIME_ZONE
USE_TZ
用来指定整个项目是否使用时区,TIME_ZONE
是默认时区的值。
如果USE_TZ
的值设置为False,那么Django项目中所有时间都使用naive datetime(除非有明确指定时区的情况)。也就是说,网站内存储和使用的时间全部是TIME_ZONE
的值所指定的时区。
这样做有一些弊端:
- 数据库中保存的是naive datetime,导致在跨区域迁移数据的时候,可能无法准确定位到某个时间点
- 国际化企业可能面向不同国家有不同的网站,但后台数据库相同,此时究竟使用哪个时区保存和展示时间,将引起混乱
- 即使是同一个网站的用户,他们可能来自于全球各地,查看到的时间却是统一的服务器时间,对于高交互式的应用十分不友好
- 即使网站面向的用户仅来自于某一个地区,也会涉及到“夏时令”(Daylight Saving Time)相关的问题,每年可能将会导致两次时间误差
默认情况下,用django-admin
生成的项目,其设置中USE_TZ
等于True,这也是Django官方建议的配置。此时,在网站内部存储与使用的是UTC时间,而与用户交互时使用TIME_ZONE
或手工的时区。
我们后文中也以Django的默认配置USE_TZ=True
为前提条件,否则也没有讨论的必要了。
Django的时间函数
Django的包django.utils.timezone
中有下面几个常用的时间相关函数:
- now(),返回当前的UTC时间
- localtime(),返回当前的本地时间(默认是TIME_ZONE配置指定的时区时间)
- is_aware(),传入的时间是否是aware datetime
- is_naive(),传入的时间是否是naive datetime
- make_aware(),将naive时间转换成aware时间
- make_naive(),将aware时间转换成naive时间
因为开启了USE_TZ
,Django内部操作时间时都应该使用aware时间,否则会出现异常。所以,我们在获取当前时间的时候,一定要使用Django自带的now()
或localtime()
函数,而不能使用Python的datetime.datetime.now()
函数。
数据库存储的时间
我们在使用ORM的DatetimeField时,常常会有这样的疑虑:我们究竟应该给DatetimeField传入哪个时区的时间呢?
可以做个试验,编写下面这个model:
class Archive(models.Model): title = models.CharField('title', max_length=256) now_time = models.DateTimeField(default=timezone.now) local_time = models.DateTimeField(default=timezone.localtime)
这个model有三个属性,title是他的名字,now_time和local_time是两个时间,他们的默认值分别是timezone.now和timezone.localtime。
也就是说,默认情况下,now_time字段传入的是UTC时区的当前时间,local_time字段传入的是本地时区的当前时间,我这里是Asia/Shanghai
。
然后,我们创建一个Archive对象:
可以发现,不管我们使用a.now_time
还是a.local_time
,读取到的datetime对象的tzinfo都是UTC。
这也印证了Django文档中说到的,不管传入的时间对象时区是什么,其内部存储的时间均为UTC时区。但是,值得注意的是,如果我们传入了一个不带时区的naive datetime,将会出现一个警告,并使用默认时区填充其tzinfo:
模板中展示的时间
对于网站的用户来说,他们想看到的时间显然不是UTC时间,而是某一个具体时区的时间。比如,我的网站几乎全部是中国用户,那么展示时使用的时区应该是Asia/Shanghai
。
这一部分的转换,Django放在的模板引擎中。
Django在渲染模板变量时,将会遇到两种与时间有关的情况:
<p>origin value: {{ object.now_time }}</p> <p>date filter: {{ object.now_time | date:'Y-m-d H:i:s' }}</p>
前者是直接将时间渲染到页面中,后者是通过date这样的模板filter处理后渲染在页面中。这两种情况在内部处理方式略有不同此处不细表,总体而言,任意模板中变量的渲染,都会被转换时区。
那么,脱离模板引擎,我们会得到怎样的结果呢?
在流行的前后端分离架构中,后端服务器通常只提供JSON格式的接口给前端,那么,我们编写下面这样一个view,看看返回值是什么:
from django.shortcuts import get_object_or_404 from django.http.response import JsonResponse from django.utils import timezone from . import models def json(request): object = get_object_or_404(models.Archive, pk=1) data = dict( id=object.pk, now_time=object.now_time, local_time=timezone.localtime(object.local_time) ) return JsonResponse(data=data)
返回对象的now_time,我直接将object.now_time
返回;返回对象的local_time,我将数据库值转换成本地时间timezone.localtime(object.local_time)
返回。
我前文说过,这两个值在数据库中的值是完全相等的,不过在json返回中,now_time是UTC时间,而local_time是北京时间:
也就是说,在前后端分离的网站中,如果直接使用Model的字段,那么前端需要负责进行时区的转换,否则将会出现时间的偏差。
时间的校验和比较
在一些业务场景下,我们可能会涉及到时间的校验和比较,如:
- 付费服务、商品、用户的有效期检查
- 活动的开始与结束时间检查
- 订单、商品的收货、取消时间检查
我们就以付费用户为例:用户购买了30天的VIP会员,我们需要给用户表中设置一个过期时间,比如下面这个model。
from django.db import models from django.utils import timezone class Account(models.Model): username = models.CharField(max_length=256) password = models.CharField(max_length=64) created_time = models.DateTimeField(default=timezone.now) expired_time = models.DateTimeField()
如果某个用户某一个时刻对网站进行访问,我们如何判断他是否具有VIP权限呢?
通常情况下我们有两种常见的判断方法。一是,用户访问时,直接从model中取出这个对象,然后和now()
进行比较:
这种情况下,当前时间不管是now()
还是localtime()
都不影响比较的结果,因为两个datetime对象在比较时会考虑时差。
另一种情况是,通过ORM的queryset进行比较,等于在数据库层面进行操作:
if models.Account.objects.filter(expired_time__gt=timezone.now()).exists(): # doing sth
Django也帮我们考虑过这种情况,即使此时我们使用本地时间timezone.localtime()
进行查询,系统也会将其转换成UTC时间传入SQL语句:
但是,如果我们使用到了和日期、时间有关的lookups,将产生相反的结果。
怎么理解这个问题呢,我们还是来举个例子。比如,网站以用户注册当天的日子作为“会员日”(比如1月2日注册的会员,以后每月的2日都是他的会员日),会员日这一天会给这个用户赠送优惠券。
那么,发送优惠券时,我们如何筛选网站内会员日是今日的所有用户?
下面这个filter是否正确?
models.Account.objects.filter(created_time__day=timezone.now().day).all()
答案是否定的,我们应该使用timezone.localtime()
表示今天,而非timezone.now()
:
models.Account.objects.filter(created_time__day=timezone.localtime().day).all()
这是为什么呢?你不是说数据库中存储的都是UTC时间吗,为何会使用到timezone.localtime()
?
原因是,Django在使用日期、时间有关的lookups时,会在数据库层面对时间进行时区的转换再进行比较,所以我们需要使用本地时间而不是UTC时间。
可以看看原始的SQL语句:
可见,SQL语句中使用了django_datetime_extract('day', "sample_account"."created_time", 'Asia/Shanghai', 'UTC')
将UTC时间转换成了北京时间,因此后面比较的时候,也应该使用北京时间。
这一点需要格外注意。时间比较的不谨慎,说小点是一个Bug,说大点就是漏洞,毕竟很多涉及到时间比较的情景,都是非常需要严谨的。
所以,我们总结一下:
- 任何比较都使用aware时间,不能使用naive时间
- 时间属性直接比较时,使用任何aware时间均可(会被自动转换成UTC)
- queryset查询,不涉及__day、__date、__year等时间lookups时,使用任何aware时间均可(会被自动转换成UTC)
- queryset查询,涉及到时间lookups时,使用本地时间
加载全部内容