1. 程式人生 > >建立部落格-使用者認證(下)

建立部落格-使用者認證(下)

註冊新使用者

如果新使用者想成為程式的成員,必須在程式中註冊,這樣程式才能識別並登入使用者,程式的登陸頁面中要顯示一個連結,把使用者帶到註冊頁面,讓使用者輸入電子郵件地址,使用者名稱和密碼

新增使用者登錄檔單

註冊頁面使用的表單要求使用者輸入電子郵件地址、使用者名稱和密碼,如下:

from flask_wtf import Form 
from wtforms import StringField, PasswordField, BooleanField, SubmitField
from wtforms.validators import Required, Length, Email, Regexp, EqualTo
from
wtforms import ValidationError from ..models import User class RegistrationForm(Form): email = StringField('Email', validators=[Required(), Length(1, 64) Email()]) username = StringField('Username', validators=[ Required(), Length(1, 64), Regexp('^[A-Za-z][A-Za-z0-9.]*$'
, 0, 'Usernames must have only letters,' 'numbers, dots or underscores')]) password = PasswordField('Password', validators=[ Required(), EqualTo('password2', message='Pass word must match.')]) password2 = PasswordField('Confirm password'
, validators=[Required()]) submit = SubmitField('Register') def validate_email(self, field): if User.query.filter_by(email=field.data).first(): raise ValidationError('Email already registered') def validate_username(self, field): if User.query.filter_by(username=field.data).first() raise ValidationError('Username already in use')

這個表單使用WTForms提供的Regexp驗證函式,確保username欄位只包含字母、數字、下劃線和句號,這個驗證函式中正則表示式後面的兩個引數分別是正則表示式的旗標和驗證失敗時顯示的錯誤資訊

安全起見,密碼要輸入兩次,此時要驗證兩個密碼欄位中的值是否一致,這種驗證可使用WTForms提供的另一驗證函式實現,即EqualTo,這個驗證函式要附屬到兩個密碼欄位中的一個上,另一個欄位則作為引數傳入

這個表單還有兩個自定義的驗證函式,以方法的形式實現,如果表單類中定義了以validate_開頭且後面跟著欄位名的方法,這個方法就和常規的驗證函式一起呼叫,本例分別為email和username欄位定義了驗證函式,確保填寫的值在資料庫中沒出現過,自定義的驗證函式要想表示驗證失敗,可以丟擲ValidationError異常,其引數就是錯誤訊息

顯示這個表單的模板是/templates/auth/register.html,和登入模板一樣,這個模板也使用wtf.quick_form()渲染表單,註冊頁面如下所示
這裡寫圖片描述
登入頁面要顯示一個指向註冊頁面的連結,讓沒有賬戶的使用者能輕易找到註冊頁面,改動如下:

# app/templates/auth/login.html
# ...
<p>
    New User?
    <a href='{{ url_for("auth.register") }}'>
    Click here to register
    </a>
    </p>

註冊新使用者

處理使用者註冊的過程沒有什麼難點,提交登錄檔單,通過驗證後,系統就使用使用者填寫的資訊在資料庫中新增一個新使用者,處理這個任務的檢視函式如下:

# app/auth/views.py
@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)
        flash('You can now Login')
        return redirect(url_for('auth.login'))
    return render_template('auth/register.html', form=form)

確認賬戶

對於某些特定型別的程式,有必要確認註冊時使用者提供的資訊是否正確,常見要求是能通過提供過的電子郵件地址與使用者取得聯絡

為驗證電子郵件地址,使用者註冊後,程式會立即傳送一封確認郵件,新賬戶先被標記成待確認狀態,使用者按照郵件中的說明操作後,才能證明自己可以被聯絡上,賬戶確認過程中,往往會要求使用者點選一個包含確認令牌的特殊URL連結

使用itsdangerous生成確認令牌

確認郵件中最簡單的確認連結是http://www.example.com/auth/confirm/<id>這種形式的URL,其中id是資料庫分配給使用者的數字id,使用者點選連結後,處理這個路由的檢視函式就將收到的使用者id作為引數進行確認,然後將使用者狀態更新為已確認

但這種實現方式顯然不是很安全,只要使用者能判斷確認連結的格式,就可以隨便指定URL中的數字,從而確認任意賬戶,解決方法是把URL中的id換成將相同資訊保安加密後得到的令牌

之前提到的對使用者會話的討論,Flask使用加密的簽名cookie保護使用者會話,防止被篡改,這種安全的cookie使用itsdangerous包簽名,同樣的方法也可用於確認令牌上

下面這個簡短的shell會話顯示瞭如何使用itsdangerous包生成包含使用者id的安全令牌:

>>> 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
'eyJhbGciOiJIUzI1NiIsImV4cCI6MTQ3MTc1NDA3OSwiaWF0IjoxNDcxNzUwNDc5fQ.eyJjb25maXJtIjoyM30.Ucw75bXsyTr5iT6ktRR
wNv00aSe6vQ7LKPCRqemSUiE'
>>> data = s.loads(token)
>>> data
{u'confirm': 23}

itsdangerous提供了多種生成令牌的方法,其中TimedJSONWebSignatureSerializer類生成具有過期時間的JSON Web簽名(JSON Web Signatures, JWS),這個類的建構函式接收的引數是一個金鑰,在Flask程式中可是使用SECRET_KEY設定

dumps()方法為指定的資料生成一個加密簽名,然後再對資料和簽名進行序列化,生成令牌字串,expires_in引數設定令牌的過期時間,單位為

為了解碼令牌,序列化物件提供了loads()方法,其唯一的引數是令牌字串,這個方法會檢驗簽名和過期時間,如果通過,返回原始資料,如果提供給loads()方法的令牌不正確或過期,則丟擲異常

我們可以將這種生成和檢驗令牌的功能新增到User模型中,如下:

# app/models.py
from werkzeug.security import generate_password_hash, check_password_hash
from flask_login import UserMixin
from .import db, login_manager
from itsdangerous import TimedJSONWebSignatureSerializer as Serializer
from flask import current_app


class User(UserMixin, db.Model):
    # ...
    confirmed = db.Column(db.Boolean, default=False)

    def generate_confirmation_token(self, expiration=3600):
        s = Serializer(current_app.config['SECRET_KEY'], expiration)
        return s.dumps({ 'confirm': self.id })

    def confirm(self, token):
        s = Serializer(current_app.config['SECRET_KEY'])
        try:
            data = s.loads(token)
        except:
            return False
        if data.get('confirm') != self.id:
            return False
        self.confirmed= True
        db.sesiion.add(self)
        return True    

generate_confirmation_token()方法生成一個令牌,有效期預設為一小時,confirm()方法檢驗令牌,如果檢驗通過,則把新新增的confirmed屬性設為True

除了檢驗令牌,confirm()方法還檢查令牌中的id是否和儲存在current_user中的已登入使用者匹配,如此依賴,即使惡意使用者知道如何生成簽名令牌,也無法確認別人的賬戶

由於模型中新加入了一個列用來儲存賬戶的確認狀態,因此要生成並執行一個新資料庫遷移

User模型中新新增的兩個方法也可以進行單元測試

傳送確認郵件

當前的/register路由把新使用者新增到資料庫中後,會重定向到/index,在重定向之前,這個路由需要傳送確認郵件,改動如下:

from ..email import send_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.genarate_confirmation_token()
        send_email(user.email, 'Confirm Your Account',
                   'auth/email/confirm', user=user, token=token)
        flash('A confirmation email has been sent you by email.')


        return redirect(url_for('main.index'))
    return render_template('auth/register.html', form=form)

注意,即便通過配置,程式已經可以在請求末尾自動提交資料庫變化,這裡也要新增db.session.commit()呼叫,問題在於提交資料庫之後才能賦予新使用者id值,而確認令牌需要用到id,所以不能延後提交

認證藍本使用的電子郵件模板儲存在templates/auth/email資料夾中以便和HTML模板區分開來,之前介紹過,一個電子郵件需要兩個模板,分別用於渲染純文字正文和富文字正文,舉個栗子,確認郵件的純文字版本:

Dear {{ user.username }},

Welcome to Flasky!

To confirm your account please click on the following link:

{{url_for('auth.confirm'), token=token, _external=True }}

Sincerely,

The Flasky Team

PS: replies to thi

預設情況下,url_for()生成相對URL,例如url_for('auth.confirm', token='abc'),返回的字串是'/auth/confirm/abc',這顯然不是能夠在電子郵件中傳送的正確URL,相對URL在網頁的上下文中可以正常使用,因為通過添加當前頁面的主機名和埠號,瀏覽器會將其轉換成絕對URL,但通過電子郵件傳送URL時,並沒有這種上下文,新增到url_for()函式中的_external=True引數要求程式生成完整的URL,其中包含協議(http://https://)、主機名和埠

新增確認賬戶的檢視函式如下:

from flask_login import current_user
@auth.route('/confirm/<token>')
@login_required
def confirm(token):
    if current_user.confirmed:
        return redirect(url_for('main.index'))
    if current_user.confirm(token):
        flash('You have confirmd your account. Thanks')
    else:
        flash('The confirmation link is invalid or has expired')    
    return redirect(url_for('main.index'))

Flask-Login提供的login_required裝飾器會保護這個路由,因此,使用者點選確認郵件中的連結後,要先登入,然後才能執行這個檢視函式

這個函式先檢查已登入的使用者是否已經確認過,如果確認過,則重定向到首頁,因為很顯然此時不用做什麼操作,這樣處理可以避免使用者不小心多次點選確認令牌帶來的額外工作

由於令牌確認完全在User模型中完成,所以檢視函式只需呼叫confirm()方法即可,然後再根據確認結果顯示不同的Flash訊息,確認成功後,User模型中的confirmed屬性的值會被修改並新增到會話中,請求處理完後,這兩個操作被提交到資料庫

每個程式都可以決定使用者確認賬戶之前可以做哪些操作,比如允許未確認的使用者登入,但只顯示一個頁面,這個頁面要求使用者在獲取許可權之前先確認賬戶

這一步可使用Flask提供的before_request鉤子完成,對藍本來說,before_request鉤子只能應用到屬於藍本的請求上,若想在藍本中使用針對程式全域性請求的鉤子,必須使用before_app_request裝飾器,下例展示瞭如何實現這個處理程式:

# app/auth/views.py

# ...
@auth.before_app_request
def before_request():
    if current_user.is_authenticated \
            and not current_user.confirmed \
            and request.endpoint[:5] != 'auth.' \
            and request.endpoint != 'static':
        return redirect(url_for('auth.unconfirmed'))

@auth.route('/unconfirmed')
def unconfirmed():
    if current_user.is_anonymous or current_user.confirmed:
        return redirect(url_for('main.index'))
    return render_template('auth/unconfirmed.html')

同時滿足以下3個條件時,before_app_request處理程式會攔截請求

1.使用者已登入(current_user.is_authenticated()必須返回True)
2.使用者的賬戶還未確認
3.請求的端點(使用request.endpoint獲取)不在認證藍本中,訪問認證路由要獲取許可權,因為這些路由的作用是讓使用者確認賬戶執行其他賬戶管理操作

如果請求滿足以上3個條件,則會被重定向到/auth/unconfirmed路由,顯示一個確認賬戶相關資訊的頁面

如果before_requestbefore_app_request的回撥返回響應或重定向,Flask會直接將其傳送至客戶端,而不會呼叫請求的檢視函式,因此,這些回撥可在必要時攔截請求

顯示給未確認使用者的頁面只渲染一個模板,其中有如何確認賬戶的說明,此外還提供了一個連結,用於請求傳送新的確認郵件,以防之前的郵件丟失,重新發送確認郵件的路由如下

# app/auth/views.py

@auth.route('/confirm')
@login_required
def resend_confirmation():
    token = current_user.generate_confirmation_token()
    send_mail(current_user.mail, 'Cpmfor, Upir Account!',
              'auth/email/confirm', user=current_user, token=token)
    flash('A new confirmation email has been sent to you by email!')
    return redirect(url_for('main.index'))                              

這個路由為current_user(即已經登陸的使用者,也就是目標使用者)重做了一遍註冊路由中的操作,這個路由也用login_required保護,確保訪問時程式知道請求再次傳送郵件的是哪個使用者

管理賬戶

擁有程式賬戶的使用者有時可能需要修改賬戶資訊,下面這些操作可以使用學過的技術新增到驗證藍本中

修改密碼

安全意識強的使用者可能希望定期修改密碼,這是一個很容易實現的功能,只要使用者處於登入狀態,就可以放心顯示一個表單,要求使用者輸入舊密碼和替換的新密碼

views.py部分

from .forms import ChangePasswordForm
#...

@auth.route('/change-password', methods=['GET', 'POST'])
@login_required
def change_password():
    form = ChangePasswordForm()
    if form.validate_on_submit():
        if current_user.verify_password(form.old_password.data):
            current_user.password = form.password.data
            db.session.add(current_user)
            flash('Your password has been updated.')
            return redirect(url_for('main.index'))
        else:
            flash('Invalid password.')
    return render_template('auth/change_password.html', form=form)

forms.py部分

#app/auth/forms.py

class ChangePasswordForm(Form):
    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')    

html頁面部分

#app/templates/base.html
    #...
<ul class='nav navbar-nav navbar-right'>
          {% if current_user.is_authenticated %}
          <li class='dropdown'>
          <a href="#" class="dropdown-toggle" data-toggle="dropdown">個人中心<b class="caret"></b></a>
          <ul class='dropdown-menu'>
          <li><a href="{{ url_for('auth.change_password') }}">
          修改密碼</a></li>

          <li><a href="{{ url_for('auth.logout') }}">登出</a></li>
          </ul></li>


          {% else %}
          <li><a href="{{ url_for('auth.login') }}">登入
          </a></li>
          {% endif %}
          </ul>
    #...

重設密碼

為了避免使用者忘記密碼無法登入的情況,程式可以提供重設密碼功能,安全起見,有必要使用類似於確認賬戶時用到的令牌,使用者請求重設密碼後,程式會向用戶註冊時提供的電子郵件地址傳送一封包含重設令牌的郵件,使用者點選郵件中的連結,令牌驗證後,會顯示一個用於輸入新密碼的表單
實現此功能的改動如下:

forms.py部分

# ...
class PasswordResetRequestForm(Form):
    email = StringField('Email', validators=[Required(), Length(1, 64), Email()])

    submit = SubmitField('Reset Password')

class PasswordResetForm(Form):
    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 Passwrord')

    def validate_email(self, field):
        if User.query.filter_by(email=field.data).first() is None:
            raise ValidationError('Unknown email address.')

Models.py部分

# ...
    def generate_reset_token(self, expiration=3600):
        s = Serializer(current_app.config['SECRET_KEY'], expiration)
        return s.dumps({'reset': self.id})

    def reset_password(self, token, new_password):
        s = Serializer(current_app.config[SECRET_KEY])    
        try:
            data = s.loads(token)
        except:
            return False
        if data.get('reset') != self.id:
            return False
        self.password = new_password
        db.session.add(self)
        return True

views.py部分

@auth.route('/reset', methods=['GET', 'POST'])
def password_reset_request():
    if not current_user.is_anonymous:
        return redirect(url_for('main.index'))
    form = PasswordResetRequestForm()
    if form.validate_on_submit():
        user = User.query.filter_by(email=form.email.data).first()
        if user:
            token = user.generate_reset_token()
            send_mail(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')
        return redirect(url_for('auth.login'))
    return render_template('auth/reset_password.html', form=form)




@auth.route('/reset/<token>', methods=['GET', 'POST'])
def password_reset(token):
    if not current_user.id_anonymous:
        return redirect(url_for('main.index'))
    form = PasswordResetForm()
    if form.validate_on_submit():
        user = User.query.filter_by(email=form.email.data).first()
        if user is None:
            return redirect(url_for('main.index'))

        if user.reset_password(token, form.password.data):
            flash('Your password has been updated.')
            return redirect(url_for('auth.login'))
        else:
            return redirect(url_for('main.index'))
    return render_template('auth/reset_password.html')

html部分也要新增

<p>忘記密碼?<a href="{{ url_for('auth.password_reset_request') }}">找回密碼</a></p>

修改電子郵件地址
程式可以提供修改電子郵件地址的功能,不過接受新地址之前,必須使用確認郵件進行驗證,使用這個功能時,使用者在表單中輸入新的電子郵件地址,為了驗證這個地址,程式會發送一封包含令牌的郵件,伺服器收到令牌後再更新使用者物件,伺服器收到令牌之前,可以把新電子郵件地址儲存在一個新資料庫欄位中作為待定地址,或者將其和id一起儲存在令牌中

forms.py部分

class ChangeEmailForm(Form):
    email = StringField('New Email', validators=[Required(), Length(1, 64), Email()])

    password = PasswordField('Password', validators=[Required()])
    submit = SubmitField('Update Email Address')

    def vilidate_emial(self, field):
        if User.query.filter_by(email=field.data).first():
            raise ValidationError('Email already registered.')

models.py部分

    def generate_email_change_token(self, new_email, expiration=3600):
        s = Serializer(current_app.config['SECRET_KEY'], expiration)
        return s.dumps({'change_email': self.id, 'new_email': new_email})

    def change_email(self, token):
        s = Serializer(current_app.config['SECRET_KEY'])
        try:
            data = s.loads(token)
        except:
            return False
        if data.get('change_email') != self.id:
            return False
        new_email = data.get('new_email')
        if new_email is None:
            return False
        if self.query.filter_by(email=new_email).first() is not None:
            return False
        self.email = new_email
        sb.session.add(self)
        return True

views.py部分

@auth.route('/change-email', methods=['GET', 'POST'])
@login_required
def change_email_request():
    form = ChangeEmailForm()
    if form.validate_on_submit():
        if current_user.verify_password(form.password.data):
            new_email = form.email.data
            token = current_user.generate_email_change(new_email)
            send_mail(new_email, 'Confirm your email address',
                      'auth/email/change_email',
                      user=current_user, token=token)
            flash('An email with instructions to confirm your new email')
            return redirect(url_for('main.index'))
        else:
            flash('Invalid email or password')
    return render_template('auth/change_email.html', form=form)

@auth.route('/change/email/<token>')
@login_required
def change_email(token):
    if current_user.change_email(token):
        flash('Your email address has been updated.')
    else:
        flash('Invalid request')
    return redirect(url_for('main.index'))

html部分同之前,就不贅述了