转载于:http://pdf.us/2017/10/05/492.html,感谢这位大神
《Flask Web开发:基于Python的Web应用开发实战》学习笔记
这里是第二部分的学习笔记。第二部分:实例:社交博客程序
第八章 用户认证
用到的扩展
Werkzeug:计算密码散列并核对
istdangerous:生成并核对加密安全令牌Flask-Mail:发送与认证相关的密码
Flask-Bootstrap:HTML模板
Flask-WTF:Web表单
使用Werkzeug实现密码散列
Werkzeug的security模块可以实现计算密码散列。主要用于用户注册和验证用户。
generate_password_hash(password,method=pbkdf2:sha1,salt_length=8) 以密码作为输入,输出密码的散列值
check_password_hash(hash,password) 返回True即表示验证通过
程序从7a版本开始推进。数据库改用mysql。先不要建表,db init;db migrate;db upgrade生成当前数据库。
对app/models.py中User模型做改造:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | fromwerkzeug.securityimportgenerate_password_hash,check_password_hash #... #class User(db.Model): # __tablename__ = 'users' # id = db.Column(db.Integer, primary_key=True) # username = db.Column(db.String(64), unique=True, index=True) password_hash=db.Column(db.String(128)) # role_id = db.Column(db.Integer, db.ForeignKey('roles.id')) # def __repr__(self): # return '<User %r>' % self.username @property defpassword(self): raiseAttributeError('password is not a readable attribute') @password.setter defpassword(self,password): self.password_hash=generate_password_hash(password) defverify_password(self,password): returncheck_password_hash(self.password_hash,password) |
把一个getter方法变成属性,只需要加上@property就可以了,此时,@property本身又创建了另一个装饰器@password.setter,负责把一个setter方法变成属性赋值
简单讲,@property附加到那个方法上,该方法变为同名属性,并只具有读属性
而要设置属性的值,需要使用另外一个方法,并附加@方法名.setter,这样提供了写属性
一句话就是对属性读写分别处理,如果没有setter,则属性为只读
hash后的加密串,即使相同的密码加密,hash串也不相同
该功能的单元测试用例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | importunittest fromapp.modelsimportUser classUserModelTestCase(unittest.TestCase): deftest_password_setter(self): u=User(password='cat') self.assertTrue(u.password_hashisnotNone) deftest_password_getter(self): u=User(password='cat') withself.assertRaises(AttributeError): u.password deftest_password_verfication(self): u=User(password='cat') self.assertTrue(u.verify_password('cat')) self.assertFalse(u.verify_password('dog')) deftest_password_salts_are_random(self): u1=User(password='cat') u2=User(password='cat') self.assertTrue(u1.password_hash!=u2.password_hash) |
创建认证蓝本
对于不同的程序功能,使用不同的蓝本,这样可以使代码保持整齐有序。
蓝本:auth/__init__.py
1 2 3 | fromflaskimportBlueprint auth=Blueprint('auth',__name__) from.importviews |
auth/views.py
1 2 3 4 5 6 | fromflaskimportrender_template from.importauth @auth.route('/login') deflogin(): returnrender_template('auth/login.html') |
auth/login.html位于app/templates/目录下。当然,蓝本也可以定义自己的模板文件夹,此时,render_template()会先搜索程序文件夹,再搜索蓝本配置的模板文件夹。
在create_app函数中附加蓝本auth到程序:app/__init__.py
1 2 3 4 5 6 7 8 9 10 | #... #def create_app(config_name): #... # from .main import main as main_blueprint # app.register_blueprint(main_blueprint) from.authimportauthasauth_blueprint app.register_blueprint(auth_blueprint,url_prefix='/auth') # return app |
url_prefix是可选参数,使用该参数后,蓝本中定义的所有路由都会加上指定前缀,这里,/login变成了/auth/login。
使用Flask-Login认证用户
pip install flask-login
使用Flask-Login扩展,User模型需要实现如下几个方法:
属性/方法 | 说明 |
is_authenticated | 若用户已登录,则返回True,否则返回False |
is_active | 若允许用户登录,则返回True,否则返回False;禁用用户,可返回False |
is_anonymous | 对普通用户返回False |
get_id() | 必须返回用户唯一标识符,使用Unicode编码 |
这四个方法可以直接在User类中实现,更简单的方法是使用Flask-Login提供的UserMixin类。
app/modles.py
1 2 3 4 5 6 7 8 9 10 11 | #... fromflask_loginimportUserMixin #... classUser(UserMixin,db.Model): # __tablename__ = 'users' # id = db.Column(db.Integer, primary_key=True) email=db.Column(db.String(64),unique=True,index=True) # username = db.Column(db.String(64), unique=True, index=True) # password_hash=db.Column(db.String(128)) # role_id = db.Column(db.Integer, db.ForeignKey('roles.id')) #... |
初始化:app/__init__.py
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 | #from flask import Flask #from flask_bootstrap import Bootstrap #from flask_mail import Mail #from flask_moment import Moment #from flask_sqlalchemy import SQLAlchemy #from config import config fromflask_loginimportLoginManager #bootstrap = Bootstrap() #mail = Mail() #moment = Moment() #db = SQLAlchemy() login_manager=LoginManager() login_manager.session_protection='strong' login_manager.login_view='auth.login' #def create_app(config_name): # app = Flask(__name__) # app.config.from_object(config[config_name]) # config[config_name].init_app(app) # bootstrap.init_app(app) # mail.init_app(app) # moment.init_app(app) # db.init_app(app) login_manager.init_app(app) # from .main import main as main_blueprint # app.register_blueprint(main_blueprint) # from .auth import auth as auth_blueprint # app.register_blueprint(auth_blueprint,url_prefix='/auth') # return app |
session_protection可设置为None,'basic','strong',当设置为‘strong'时,会记录客户端IP和浏览器用户代理信息,发现异动就登出用户。
Flask-Login要求实现一个回调函数,使用指定的标识符加载用户:app/models.py
1 2 3 4 5 | from.importlogin_manager #... @login_manager.user_loader defload_user(user_id): returnUser.query.get(int(user_id)) |
回调函数接收以Unicode字符串形式表示的用户标识符,若存在该用户,则返回用户对象,否则返回None
保护路由
让一个路由仅让认证的用户能访问,未认证用户访问,Flask-Login会拦截请求,把用户发往登录页面,示例如下:
1 2 3 4 5 6 | fromflask_loginimportlogin_required @app.route('/secret') @login_required defsecret(): return'only authenticated users are allowed!' |
添加登录表单
app/auth/forms.py
1 2 3 4 5 6 7 8 9 | fromflask_wtfimportForm fromwtformsimportStringField,PasswordField,BooleanField,SubmitField fromwtforms.validatorsimportRequired,Length,Email classLoginForm(Form): email=StringField('Email',validators=[Required(),Length(1,64),Email()]) password=PasswordField('Password',validators=[Required()]) remeber_me=BooleanField('Keep me logged in') submit=SubmitField('Log in') |
app/templates/base.html
{% if current_user.is_authenticated %}
<li><a href="{{ url_for('auth.logout') }} ">Sign Out</a></li>
{% else %}
<li><a href="{{ url_for('auth.login') }}">Sign In</a></li>
{% endif %}
</ul>
current_user由Flask-Login定义, 在视图函数和模板中自动可用,这个变量的值是当前登录的用户,若未登录,则是匿名用户代理对像。
登入用户
app/auth/views.py
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | fromflaskimportrender_template,redirect,request,url_for,flash fromflask_loginimportlogin_user from.importauth from..modelsimportUser from.formsimportLoginForm @auth.route('/login',methods=['GET','POST']) deflogin(): form=LoginForm() ifform.validate_on_submit(): user=User.query.filter_by(email=form.email.data).first() ifuserisnotNoneanduser.verify_password(form.password.data): login_user(user,form.remember_me.data) returnredirect(request.args.get('next')orurl_for('main.index')) flash('Invalid username or password.') returnrender_template('auth/login.html',form=form) |
app/templates/auth/login.html
{% extends "base.html" %}
{% import "bootstrap/wtf.html" as wtf %}
{% block title %}Flasky - Login{% endblock %}
{% block page_content %}
<div class="page-header">
<h1>Login</h1>
</div>
<div class="col-md-4">
{{ wtf.quick_form(form) }}
</div>
{% endblock %}
登出用户
app/auth/views.py
1 2 3 4 5 6 7 8 | fromflask_loginimportlogout_user,login_required #... @auth.route('/logout') @login_required #保护路由 deflogout(): logout_user() flash('You have been logged out.') returnredirect(url_for('main.index')) |
测试登录
shell中注册新用户
>>> db.session.add(u)
>>> db.session.commit()
{{ current_user.username }}
{% else %}
Stranger
{% endif %}
注册新用户
用户注册表单:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | fromflask_wtfimportFlaskForm fromwtformsimportStringField,PasswordField,SubmitField fromwtforms.validatorsimportRequired,Length,Email,Regexp,EqualTo fromwtformsimportValidationError from..modelsimportUser #... classRegistrationForm(FlaskForm): email=StringField('Email',validators=[Required(),Length(1,64),Email()]) username=StringField('Username',validators=[Required(),Length(1,64),Regexp('^[A-Za-z0-9_.]*$',0,'Usernames must have only letters,numbers,dots or underscores')]) password=PasswordField('Password',validators=[Required(),EqualTo('password2',message='Passwords must match.')]) password2=PasswordField('Confirm password',validators=[Required()]) submit=SubmitField('Register') defvalidate_email(self,field): ifUser.query.filter_by(email=field.data).first(): raiseValidationError('Email already registed.') defvalidate_username(self,field): ifUser.query.filter_by(username=field.data).first(): raiseValidationError('Username already in use.') |
注意,validator是复数:validators
其中,验证函数Regexp是正则表达式验证,第一个参数是正则表达式(包含字母、数字、下划线和点),第二个是表达式的旗标(通常为0),第三个是匹配失败时的错误消息。
密码的验证使用EqualTo,放到任意一个就可以,另一个字段做为参数传入。
自定义的验证函数:以validate_开头,后面跟字段名的方法。该方法会和常规验证函数一起调用。
注册表单的渲染:
{{ wtf.quick_form(form) }}
注册新用户的视图函数:
1 2 3 4 5 6 7 8 9 10 11 12 | from.formsimportRegistrationForm from..importdb #... @auth.route('/register',methods=['GET','POST']) defregister(): form=RegistrationForm() ifform.validate_on_submit(): user=User(email=form.email.data,username=form.username.data,password=form.password.data) db.session.add(user) flash('You can now login.') returnredirect(url_for('auth.login')) returnrender_template('auth/register.html',form=form) |
确认帐户
验证邮箱,通过点击包含令牌的URL,修改标记状态。
itsdangerous提供多种生成令牌方法,其中TimedJSONWebSignatureSerializer类生成具有过期时间的JSON Web签名,该类构造函数接收参数是一个密钥和过期时间(秒)。dumps方法为指定数据生成加密的令牌字符串,load方法解码令牌。
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}
修改模型:app/models.py
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | fromitsdangerousimportTimedJSONWebSignatureSerializerasSerializer fromflaskimportcurrent_app from.importdb #... classUser(UserMixin,db.Model): #... confirmed=db.Column(db.Boolean,default=False) defgenerate_confirmation_token(self,expiration=3600): s=Serializer(current_app.config['SECRET_KEY'],expiration) returns.dumps({'confirm':self.id}) defconfirm(self,token): s=Serializer(current_app.config['SECRET_KEY']) try: data=s.loads(token) except: returnFalse ifdata.get('confirm')!=self.id: returnFalse self.confirmed=True db.session.add(self) returnTrue |
发送确认邮件
app/auth/views.py
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | #... from..emailimportsend_email #... #@auth.route('/register',methods=['GET','POST']) #def register(): # form = RegistrationForm() # if form.validate_on_submit(): # user=User(email=form.email.data,username=form.username.data,password=form.password.data) # db.session.add(user) db.session.commit() token=user.generate_confirmation_token() #send_email(user.email,'Confirm your account','auth/email/confirm',user=user,token=token) 发送邮件不好模拟,暂用print替代 #print url_for('auth.confirm',token=token,_external=True) flash('A confirmation email has been sent to you by email') # return redirect(url_for('auth.login')) # return render_template('auth/register.html',form=form) |
因为只有提交数据库后才能够得到新用户id,而生成token需要用到用户id,所以需要添加db.session.commit()
模板:{{ url_for('auth.confirm',token=token,_external=True) }}
确认token:
1 2 3 4 5 6 7 8 9 10 11 12 | fromflask_loginimportcurrent_user #... @auth.route('/confirm/<token>') @login_required defconfirm(token): ifcurrent_user.confirmed: returnredirect(url_for('main.index')) ifcurrent_user.confirm(token): flash('You have confirmed your account.Thanks!') else: flash('The confirmation link is ivalid or has expired.') returnredirect(url_for('main.index')) |
蓝本中的程序全局请求钩子-before_app_request
1 2 3 4 5 6 7 8 9 10 11 12 | @auth.before_app_request defbefore_request(): ifcurrent_user.is_authenticatedandnotcurrent_user.confirmedand\ request.endpoint[:5]!='auth.'andrequest.endpoint!='static': returnredirect(url_for('auth.unconfirmed')) #如果当前用户已登录and帐号未激活and请求端点不在认证蓝本中and不是静态文件,则跳转 @auth.route('/unconfirmed') defunconfirmed(): ifcurrent_user.is_anonymousorcurrent_user.confirmed: returnredirect(url_for('main.index')) returnrender_template('auth/unconfirmed.html') #如果是匿名用户or帐号已激活则正常跳转 |
重新发送确认邮件
1 2 3 4 5 6 7 8 | @auth.route('/confirm') @login_required defresend_confirmation(): token=current_user.generate_confirmation_token() #send_email(current_user.email,'Confirm Your Account','auth/email/confirm',user=current_user,token=token) printurl_for('auth.confirm',token=token,_external=True) flash('A new confirmation email has been sent to you by email.') returnredirect(url_for('main.index')) |
管理帐户
修改密码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | #表单 classChangePasswordForm(FlaskForm): old_password=PasswordField('Old password',validators=[Required()]) password=PasswordField('New password',validators=[ Required(),EqualTo('password2',message='Passwords must match')]) password2=PasswordField('Confirm new password',validators=[Required()]) submit=SubmitField('Update Password') #视图 @auth.route('/change-password',methods=['GET','POST']) @login_required defchange_password(): form=ChangePasswordForm() ifform.validate_on_submit(): ifcurrent_user.verify_password(form.old_password.data): current_user.password=form.password.data db.session.add(current_user) flash('Your password has been updated.') returnredirect(url_for('main.index')) else: flash('Invalid password.') returnrender_template("auth/change_password.html",form=form) |
重设密码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 | #模型: defreset_password(self,token,new_password): s=Serializer(current_app.config['SECRET_KEY']) try: data=s.loads(token) except: returnFalse ifdata.get('reset')!=self.id: returnFalse self.password=new_password db.session.add(self) returnTrue #表单 classPasswordResetRequestForm(FlaskForm): email=StringField('Email',validators=[Required(),Length(1,64),Email()]) submit=SubmitField('Reset Password') classPasswordResetForm(FlaskForm): email=StringField('Email',validators=[Required(),Length(1,64),Email()]) password=PasswordField('New Password',validators=[Required(),EqualTo('password2',message='Passwords must match')]) password2=PasswordField('Confirm password',validators=[Required()]) submit=SubmitField('Reset Password') defvalidate_email(self,field): ifUser.query.filter_by(email=field.data).first()isNone: raiseValidationError('Unknown email address.') #视图 @auth.route('/reset',methods=['GET','POST']) defpassword_reset_request(): ifnotcurrent_user.is_anonymous: returnredirect(url_for('main.index')) form=PasswordResetRequestForm() ifform.validate_on_submit(): user=User.query.filter_by(email=form.email.data).first() ifuser: token=user.generate_reset_token() send_email(user.email,'Reset Your Password', 'auth/email/reset_password', user=user,token=token, next=request.args.get('next')) flash('An email with instructions to reset your password has been ' 'sent to you.') returnredirect(url_for('auth.login')) returnrender_template('auth/reset_password.html',form=form) @auth.route('/reset/<token>',methods=['GET','POST']) defpassword_reset(token): ifnotcurrent_user.is_anonymous: returnredirect(url_for('main.index')) form=PasswordResetForm() ifform.validate_on_submit(): user=User.query.filter_by(email=form.email.data).first() ifuserisNone: returnredirect(url_for('main.index')) ifuser.reset_password(token,form.password.data): flash('Your password has been updated.') returnredirect(url_for('auth.login')) else: returnredirect(url_for('main.index')) returnrender_template('auth/reset_password.html',form=form) |
修改电子邮件
先确认邮件进行确认,输入新邮件地址后,向该邮件地址发送一封包含令牌的邮件。服务器发送令牌前,可先将邮件地址存到临时表或者是直接存到token中。
第九章 用户角色
角色在数据库中的表示
app/models.py,添加两个属性
permissions = db.Column(db.Integer)
其中permissions字段使用二进制位表示不同的权限。
权限常量:
FOLLOW=0x01 #0b00000001关注其它用户
COMMENT=0x02 #0b00000010在他人文章后发表评论
WRITE_ARTICLES=0x04 #0b00000100写文章
MODERATE_COMMENTS=0x08 #0b00001000管理他人发表的评论
ADMINISTER=0x80 #0b10000000管理员
用户角色:
用户角色 | 权限 | 权限 | 说明 |
匿名 | 0b00000000 | 0x00 | 未登录用户,仅阅读权限 |
用户 | 0b00000111 | 0x07 | 写文章,写评论,关注其他用户 |
协管员 | 0b00001111 | 0x0f | 增加管理他人评论功能 |
管理员 | 0b11111111 | 0xff | 所有权限,包括修改其它用户权限 |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | #class Role(db.Model): # __tablename__ = 'roles' # id = db.Column(db.Integer, primary_key=True) # name = db.Column(db.String(64), unique=True) default=db.Column(db.Boolean,default=False,index=True) permissions=db.Column(db.Integer) # users = db.relationship('User', backref='role', lazy='dynamic') # def __repr__(self): # return '<Role %r>' % self.name @staticmethod definsert_roles(): roles={ 'User':(Permission.FOLLOW|Permission.COMMENT|Permission.WRITE_ARTICLES,True), 'Moderator':(Permission.FOLLOW|Permission.COMMENT|Permission.WRITE_ARTICLES|Permission.MODERATE_COMMENTS,False), 'Administrator':(0xff,False) } forrinroles: role=Role.query.filter_by(name=r).first() ifroleisNone: role=Role(name=r) role.permissions=roles[r][0] role.default=roles[r][1] db.session.add(role) db.session.commit() |
通过insert_roles方法添加角色,使用shell操作,Role.insert_roles()
赋予角色
app/models.py
1 2 3 4 5 6 7 8 9 10 | classUser(UserMixin,db.Model): #... def__init__(self,**kwargs): super(User,self).__init__(**kwargs) ifself.roleisNone: ifself.email==current_app.config['FLASKY_ADMIN']: self.role=Role.query.filter_by(permissions=0xff).first() ifself.roleisNone: self.role=Role.query.filter_by(default=True).first() #... |
角色验证
添加辅助方法:app/models.py
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | #... fromflask_loginimportUserMixin,AnonymousUserMixin #... classUser(UserMixin,db.Model): #... defcan(self,permissions): returnself.roleisnotNoneand(self.role.permissions&permissions)==permissions defis_administrator(self): returnself.can(Permission.ADMINISTER) #... classAnonymousUser(AnonymousUserMixin): defcan(self,permissions): returnFalse defis_administrator(self): returnFalse #... login_manager.anonymous_user=AnonymousUser |
can方法使用位与操作,检查用户权限。Anonymous类出于一致性考虑,无论用户是否登录,均可使用current_user.can()和current_user.is_administrator()方法来验证用户权限。
检查用户权限的自定义修饰器
app/decorators.py
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | fromfunctoolsimportwraps fromflaskimportabort fromflask_loginimportcurrent_user defpermission_required(permission): defdecorator(f): @wraps(f) defdecorated_function(*args,**kwargs): ifnotcurrent_user.can(permission): abort(403) returnf(*args,**kwargs) returndecorated_function returndecorator defadmin_required(f): returnpermission_required(Permission.ADMINISTER)(f) |
自定义修饰器的使用方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | fromdecoratorsimportadmin_required,permission_required from.modelsimportPermission @main.route('/admin') @login_required @admin_required deffor_admins_only(): return"For administrator" @main.route('/moderator') @login_required @permission_required(Permission.MODERATE_COMMENTS) deffor_moderators_only(): return"For comment moderators!" |
模板中也需要检查权限,为避免每次调用render_template()时都多添加一个模板参数,可以使用上下文处理器,上下文处理器能让变量在所有模板中全局可访问。
app/main/__init__.py
def inject_permissions():
return dict(Permission=Permission)