网站用户管理
不知道为什么很多学习Flask的人都是从搭博客开始的(大概是因为那本书的案例是博客,同时对bootstrap支持良好,bootstrap又是twitter开发的吧。。)既然是搭建博客,就免不了要对博客的用户进行管理。我今天照着书整理了下对于这个管理的一些小技巧和小坑。下面来说明一下
基本的目录结构可以参考Flask项目目录结构那篇文章,就不再多言
■ 用密码进行验证
既然有用户,那么肯定有密码。最朴素的密码认证想法就是把密码明文存在数据库里面,但是这样太不安全了。安装flask时会自动安装的一个依赖包Werkzeug中,有Werkzeug.security可以专门拿来处理密码验证的一些工作。
Werkzeug的工作原理是把接收到的密码明文通过一定散列算法算出一个散列值,然后把这个值存进数据库。下次用户来验证的时候,就把他提供的明文用同样算法算的散列值,比对数据库中的值是否一致。这至少从端的层面上加强了安全性(不过如果不用HTTPS传输,那么传输过程中密码明文还是会被截获。)。这种对密码的散列操作和验证密码散列值的操作可以一起封装到一个统一的用户类当中去,比如:
from werkzeug.security import generate_password_hash from werkzeug.security import check_password_hash class User(db.model): #... 一些字段的设置 password_hash = db.Column(db.String(128),nullable=False) @property def password(self): raise AttributeError('password is not a readable attribute') @password.setter def password(self,password): self.password_hash = generate_password_hash(password) def verify_password(self,password): return check_password_hash(self.password_hash,password)
这里巧妙地运用了python的property装饰器,来把password变成一个只可写不可读的属性。而且password明文都只在内存中做一个中间变量,真正落地到对象的属性中去的是password_hash,也就是一个hash值的变量。经过测试还可以发现,即便两个用户密码设置得一样,只要用户名不同,最终得到的password_hash还是不一样的。
■ 用蓝本管理用户相关操作
在项目结构中介绍过蓝本的用法。在用户管理的时候,我们可以单独的建立一个蓝本来对用户的登入登出以及注册行为做管理。因此自然的,就需要把这个auth蓝本从main蓝本中独立出来成一个单独的目录。比如我按照书上写的一个测试项目的目录是这样的。
在auth这个蓝本被独立出来之后,为了管理方便,把templates中也按照蓝本分成了几个目录来分别存放不同蓝本的模板文件。在app/__init__.py中注册auth蓝本时的语句是app.register_blueprint(auth, url_prefix='/auth')。后一个参数url_prefix是指在这个蓝本的views中注册的所有路径默认前面都是有一个/auth的。比如在auth/views中我注册了@auth.route('/login'),而项目启动之后访问这个路径要写/auth/login这样子。
因为蓝本技术的相关信息在flask项目目录结构那张里讲过了,就不再重复。
■ 利用flask-login来进行用户登录的管理
真是不得不佩服flask插件的齐全程度,居然还有专门管理用户登录的插件。。在pip install flask-login之后,就可以使用了,这是一个很小的插件。
首先我们要调整的是用户这个类的模型,也就是models.py中的User这个类。要用flask-login的一些功能,要求这个类实现is_authenticated,is_active,is_anonymous三个属性和get_id()一个方法。当然自己实现起来麻烦的话可以直接让User类继承flask_login.UserMixin这个类即可。由于User类本身是个表模型类,所以它会继承两个父类,也就是class User(UserMixin,db.model)这样子。
接下来需要改造的是app/__init__.py。和其他的插件一样,flask-login需要在这里进行对app的初始化。简单来说可以像下面这样进行初始化工作:
from flask_login import LoginManager #...其他一些初始化语句 login_manager = LoginManager() login_manager.session_protection = 'strong' login_manager.login_view = 'auth.login' login_manager.login_message = u'您还没有登录,请先登录' def create_app(config_mode): #... login_manager.init_app(app) #...
请注意flask_login插件导入的不是Login而是LoginManager。然后是在对app初始化之前,login_manager需要进行一些参数的设置,session_protection设置的是对用户会话级别的安全程度,可选值有None,'basic'和'strong'三种。当设置为'strong'时,程序会对客户端IP及浏览器的代理信息做记录,一旦发生异动,就会立刻登出用户,以此保证用户账号的安全性。login_view这个属性跟后续设置的login_required装饰器有关。login_message是指当未登录状态访问@login_required的页面时会强制跳转到login_view指定的页面上来,然后会想这个页面flash(login_message)。这个message的默认值是一串英文提示,换成中文更好。另外要看到这串提示的话必须在前端加上get_flashed_messages()来展现flash消息。
关于login_required:有些路由,我们期望用户没有登录,即没有确切身份时不能访问。这时就可以在路由的route方法下面加上一个@login_required。这样当用户在未登录状态下试图访问这个路由时,就会跳转到让他登录的界面去。而这个跳转过去到哪个界面就是由login_view属性来决定的。其设置为某个路由的endpoint,即响应函数名作为参数。像上面的话,因为是在auth蓝本中,所以函数名前面还要加上auth.。
接下来,继续回到models.py中。flask-login插件要求使用者必须实现一个回调方法,是一个用@login_manager.user_loader装饰起来的方法。这个方法同时接收一个用户标识符(通常情况下是用户id或类似的主键),然后返回一个用户对象或者None。这个方法主要被用到的时候是,用户请求进入@login_requrired的路由时,程序将加载由这个方法返回的用户对象作为当前已经登录的用户对象。如果返回的是None那么就说明当前没有用户已登录状态的用户,就禁止访问了。
这里的用户表示符,其实是用户类继承或重载实现的get_id()方法的返回值。UserMixin类的这个方法默认是返回一条记录的主键的值,我们也可以通过重载这个方法来自己制定一个想作为标准判断的值。
@login_manager.user_loader装饰的这个回调方法之所以写在models里面,大概只是考虑到了在models里面的话不用太复杂的import 关系,只需要from . import login_manager即可(login_manager是在app/__init__.py中定义的)。下面是一个这部分代码的示例:
from . import login_manager @login_manager.user_loader def load_user(user_id) return User.query.get(user_id) #要根据具体的get_id方法的返回值调整return到底是什么东西。比如get_id方法返回的是字段name的值的话 #就要return User.query.filter_by(name=user_id).first() 不要忘了first(),否则返回的仅仅是Query对象,会报错。
实现完这个回调方法之后我们就可以调整forms.py和views.py这两个文件的内容了。在forms.py中,我们可以考虑加上一个登录的表单和一个注册的表单。在登录的表单中加上一个BooleanField的remember_me,这个字段的值可以在后面的视图中用到,后面细说。此外表单本身没什么可说的,可以说下的是表单的前端,为了让前端知道当前是否有用户登录,我们可以在模板里面用{{ current_user.is_authenticated }}这个属性。current_user是由flask-login已经实现的一个对象,就是当前用户。一个常见的实践就是在导航栏的最右边加上一个登录/注销字样:
<ul class="nav navbar-nav">......</ul> <ul class="nav navbar-nav navbar-right"> {% if current_user.is_authenticated %} <li><a href="{{ url_for('auth.logout') }}">注销</a></li> {% else %} <li><a href="{{ url_for('auth.login') }}">登录</a></li> {% endif %} </ul>
在views中,似乎操作复杂很多,我们既要添加用户登录登出的操作,又要加上新用户注册的一些操作。不过好在既然我们用了flask-login这个东西,就省事很多了。从flask_login中可以import 到login_user和logout_user来一键解决登入登出问题,而注册问题则只是一个数据库写入的操作,只是要在写入之前验证一下用户是不是已经存在之类的。下面是一段示例代码:
1 @auth.route('/login', methods=['GET', 'POST']) 2 def login(): 3 loginForm = LoginForm() 4 if loginForm.validate_on_submit(): 5 user = User.query.filter_by(name=loginForm.name.data).first() 6 if not user: 7 flash(u'没有找到相关用户,请先注册') 8 return redirect(url_for('auth.register')) 9 elif not user.verify_passwd(loginForm.passwd.data): 10 flash(u'密码错误') 11 else: 12 login_user(user, loginForm.remember_me.data) # 如果remember_me是True,那么走cookie保存信息,如果是False走session保存信息 13 return redirect(request.args.get('next') or url_for('main.index')) 14 15 return render_template('auth/login.html', form=loginForm) 16 17 18 @auth.route('/logout') 19 @login_required 20 def logout(): 21 logout_user() 22 return redirect(url_for('main.index')) 23 24 25 @auth.route('/register', methods=['GET', 'POST']) 26 def register(): 27 userForm = UserForm() 28 if userForm.validate_on_submit(): 29 name = User.query.filter_by(name=userForm.name.data).first() 30 if name: 31 flash(u'该用户名已经被注册') 32 return redirect(url_for('auth.register')) 33 else: 34 new_user = User(id=userForm.id.data, name=userForm.name.data, age=userForm.age.data, 35 phone=userForm.phone.data) 36 new_user.password = userForm.passwd.data 37 db.session.add(new_user) 38 db.session.commit() 39 return render_template('auth/success.html') 40 return render_template('auth/register.html', form=userForm)
从上往下看下来,几个注意点。第9行,用了用户实例调用了verify_password方法来验证用户在表单中输入的密码睁不正确。
第12行,login_user这里给了两个参数,第一个自然是用户实例,第二个则是remember_me的布尔值。当这个值是True,说明用户在表单中的记住我上打了勾,此时程序会把用户名以及用户密码的token以cookie的形式保存到客户端本地,这样下次再开浏览器时就可以直接登录了。如果是False的话那么用户的信息有效的生命周期是会话期间,也就和用flask.session来维护这个信息差不多了。
第13行,flask-login在从@login_required的页面跳转到login_view的页面来的时候,URL后面会家一串GET请求的参数,其中有next参数,next其实就是记录了在跳转到login_view之前我是从什么页面过来的,这样在login_view成功登录之后我可以借此信息返回原页面。在这里,redirect先尝试能不能返回原页面,不能再返回默认的index页面去。
第19行,/logout看上去虽然像是一个登出的操作,但是其实质还是需要访问一个页面来完成的。而在登出之前必然是要有已经登录的用户的,所以用了login_required。顺便一提,下面的logout_user()没有参数也没关系。
第36行,因为在定义User类的时候我们用了@property的方法设置了password属性,所以在这里直接user.password=xxx来赋值是可以的,并且这个赋的值还会自动经过@password.setter函数的处理,变成hash传入passwd_hash这个属性中。不过在此时我们直接user.password去获取密码明文的话还是会报错的。
■ 利用itsdangerous进行用户注册验证
上面提到的用户注册还是一个比较朴素的过程。只要有不重名的用户就可以成功注册到数据库中并且进行登录。这么做当然不是很好。回想一下我们平时在注册的时候碰到的场景基本上都是这样的:
用邮箱名注册(一方面考虑到用户不太容易忘记自己的邮箱是啥,另一方面方便我们对用户进行验证),点击注册之后我们的邮箱会接收到一封验证邮件。在进入验证邮件进行验证之前,我们的账号还处于一个未生效的状态,无法正常登陆。当我们点击了邮件中给出的验证链接之后,账号得到验证,然后就可以正常登录了。
这方面工作看起来比较复杂,好在flask自带的itsdangerous这个模块可以帮助我们。
在应用itsdangerous之前,先来看看点击链接验证账号的原理是什么。一般这样的一个链接是http://domainname/auth/confirm/<id>,其中id是用户注册的账号的唯一标识。当访问这个链接,这个路由下的视图函数可以把id作为一个变量读取并处理,根据ID找到用户的账号,然后把这个账号在数据库中的验证标记成已验证即可。当然这样做很不安全,一个解决的办法是把url中的id进行加密。
itsdangerous对id的加密是基于app.config['SECRET_KEY']的。我们可以用有上下文的shell来进行测试:
#python manage.py shell >>>from manage import app >>>from itsdangerous import TimedJSONWebSignatureSerializer as Serializer >>>s = Serializer(app.config['SECRET_KEY'],expires_in=3600) >>>token = s.dumps({'confirm':23}) >>>token >>>一串已经加密的字符串 >>>data = s.loads(token) >>>data >>>{u'confirm':23}
上面基本上可以看到itsdangerous.TimedJSONWebSignatureSerializer的用法。创建这样一个Serializer需要一个秘钥参数,另外可以指定过期时间(秒数)。创建完成后用dumps把一个可JSON序列化的python对象变成一串字符串,loads一串字符串可以解析出来这个python对象。这个过程必须在Serializer被创建后expires_in秒数内完成。否则将serializer失效。所谓可JSON序列化,基本上就是说这个对象得是python中的一些基本类型如各种数字类型,字符串,简单字典和简单列表等等。
这个序列化对象不比hashlib中hash值的操作,那个是只针对字符串且不可逆的。而这个token是可逆的,所以可以拿来被服务器解析,并利用其中的信息。
这部分生成和验证token的工作又可以放到User这个模型中去,比如按照书上我添加了generate_confirmation_token和confirm两个方法:
class User(UserMixin,db.Model) #... confirmed = db.Column(db.Boolean, default=False) #... def generate_confirmation_token(self,expiration=3600): s = Serializer(app.config['SECRET_KEY'],expires_in=expiration) return s.dumps({'confirm':self.id}) def confirm(self, token): s = Serializer(app.config['SECRET_KEY']) #解析用的不用设置过期时间 try: data = s.loads(token) except Exception,e: return False if data.get('confirm') != self.id: #这个判断是说,保证通过token验证的这个id必须是当前使用中用户的id,这样验证就有了密码和加密token的双重保障 return False self.confirmed = True db.session.add(self)
db.session.commit() return True
生成token之后,加到链接中,以邮件的形式发送给用户用于验证即可。因为之前没有看flask-email的内容,反正用python自带的email模块也勉强能干这事,这里就不多说了。在视图中,我们要添加上相关的验证token的路由,比如:
####app/auth/views.py#### #... @auth.route('/confirm/<token>') @login_required #保证访问这个端点时要有登录用户,结合User.confirm方法中对self.id的判断又要求这个用户不能是其他人只能是要验证的用户本人 def confirm(token): if current_user.confirmed: return redirect(url_for('main.index')) if current_user.confirm(token): flash(u'已完成账号认证') else: flash(u'账号认证失败,凭证不正确或已过期') return redirect(url_for('main.index'))
至此,一个完整的验证过程写好了。不过你可能会疑惑,这样子情况下,未验证但已登录的用户和正常用户有什么差别。答案就是没有差别,因为我们在代码中还没对已登录但未验证的用户做出访问限制。实现这种限制的一个笨办法是在每个路由前都加上一个current_user.confirmed的判断。不过这很麻烦。书上给出的一个解决办法是利用@before_app_request这个回调点。另外对于未验证的用户要提示他去验证,所以还需要在视图中加上一个unconfirmed的路由。加上了这两部分的views如下:
@auth.before_app_request def before_request(): if (current_user.is_authenticated) and (not current_user.confirmed) and (not request.endpoint.startswith('auth')): return redirect(url_for('auth.unconfirmed')) @auth.route('/unconfirmed_user') @login_required def unconfirmed(): if current_user.confirmed: return redirect(url_for('main.index')) import re flag = re.match('^.+@(w+?).[a-z]+$',current_user.mailAdd).group(1) mail_site = MailSiteMap.get(flag) return render_template('auth/unconfirmed.html',mail_site=mail_site)
下面的那个MailSiteMap是我分析用户的注册邮箱之后把相关邮箱服务提供商的网站以超链接的形式整合到返回的页面中了,一个小改进而已。真正想说的一个是在上面,之前我觉得if的判断条件太长了,想着is_authenticated可以转化成@login_required放在函数外面。但是这里面有个致命错误,就是当用户处于匿名状态(即没有用户登录时)去请求/login界面时,首先调用了@before_app_request。然后重定向到/login界面,然后这个本身又是一个请求,又来调用before_app_request,导致了无限重定向。
■ 几个面生的属性/方法的利用
讲到这里,其实已经有点晕了,做个回顾,把用到的一些面生的属性和方法都拿出来回顾下。
flask.request.endpoint
flask_login.current_user //是不是就是@login_manager.load_user返回的那个对象作为current_user的?
current_user.is_anonymous
current_user.is_authenticated
url_for('end.point',key=value,_external=True)