轉載於: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)