记录一次完整的flask小型应用开发(2)

这一次,我们完成用户认证的功能:

程序要进行用户追踪,程序知道用户是谁之后,就能针对性的提供体验。需要用户提供用户名和密码。

要是想保证数据库中存放密码的安全性,那么就不存放明文密码,存放密码的散列值,我们使用Werkzeug来实现密码散列:

所以我们改变models.py中的User模型来支持密码散列

#coding: utf-8
from . import db
from werkzeug.security import generate_password_hash, check_password_hash


class Role(db.Model):
    __tablename__ = 'roles'
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(64), unique=True)
    users = db.relationship('User', backref='role', lazy='dynamic')

    def __repr__(self):
        return '<Role %r>' % self.name


class User(db.Model):
    __tablename__ = 'users'
    id = db.Column(db.Integer, primary_key=True)
    username = db.Column(db.String(64), unique=True, index=True)
    role_id = db.Column(db.Integer, db.ForeignKey('roles.id'))
    password_hash = db.Column(db.String(128))

    @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)  # 对比数据库中密码散列值和用户输入的密码,正确返回True

    def __repr__(self):
        return '<User %r>' % self.username

创建认证蓝本:

与用户认证相关的路由可以在auth蓝本中定义,对于不同程序功能,我们尽量使用不同的蓝本。所以创建app目录下的auth文件夹,前面创建的main文件夹是主页基础功能。
这里创建蓝本的方式与前面差不多:
在auth/init.py中:

from flask import Blueprint

auth = Blueprint('auth', __name__)
from . import views

在auth/views.py中:

from flask import render_template
from . import auth

@auth.route('/login')
def login():
    return render_template('auth/login.html')
# 这里需要注意了,这个模板文件需要保存在auth这个文件夹中
# 但是这个文件夹又需要保存在app/templates中
# flask认为模板的路径是相对于程序模板文件夹而言的。

最后需要在create_app()中将蓝本注册到程序上:

from .auth import auth as auth_blueprint
    app.register_blueprint(auth_blueprint, url_prefix='/auth')
    # 这里加上了prefix,注册后蓝本中定义的所有路由都会加上这个前缀
    # 所有views中定义的/login会变成/auth/login

使用Flask-Login来认证用户:

用户登录程序后,他们的认证状态要被记录下来,这样浏览不同的页面时才能记住这个状态。
要想使用Flask-Login,程序User模型必须实现几个方法,这个拓展提供了一个UserMixin类,包含了方法的默认实现。
修改User模型:

class User(UserMixin, db.Model):  # 下面保持不变,新增一个email字段

然后我们需要在工厂函数中初始化flask-login:

login_manager = LoginManager()  # 创建一个登录实例
login_manager.session_protection = 'strong'
login_manager.login_view = 'auth.login'  # 设置登录页面的端点,路由在蓝本中定义,所以要加上蓝本的名字

最后在create_app()中初始化app:
login_manager.init_app(app)

最后flask-login要求程序使用指定的标识符加载用户:这是一个回调函数

@login_manager.user_loader
def load_user(user_id):
    return User.query.get(int(user_id))

然后我们需要创建登录时用到的表单: 因为属于auth功能,所以写入auth/forms.py:

from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, BooleanField, SubmitField
from wtforms.validators import DataRequired, Length, Email


class LoginForm(FlaskForm):
    email = StringField('Email', validators=[DataRequired(), Length(1, 64),
                                             Email()])
    password = PasswordField('Password', validators=[DataRequired()])
    remember_me = BooleanField('Keep me logged in')
    submit = SubmitField('Log In')

这里只是表单的功能,我们还需要一个html来展示表单的页面,我们放在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 %}

然后我们需要在auth的视图函数中关联上表单:

@auth.route('/login', methods=['GET', 'POST'])
def login():
    form = LoginForm()  # 创建一个对象
    # get请求时,视图函数直接渲染模板显示表单
    # POST请求时,拓展的下面这个函数会验证表单数据
    if form.validate_on_submit():
        user = User.query.filter_by(email=form.email.data).first()
        if user is not None and user.verify_password(form.password.data):
            login_user(user, form.remember_me.data)
            next = request.args.get('next')
            if next is None or not next.startswith('/'):
                next = url_for('main.index')
            return redirect(next)
        flash('Invalid username or password.')
    return render_template('auth/login.html', form=form)

然后我们运行,manager.py发现报错:'A secret key is required to use CSRF.',所以我们需要在config文件中加上SECRET_KEY = ‘’
结果运行成功!!!!!
然后我们需要一个登出的路由,重定向到首页:

@auth.route('/logout')
@login_required
def logout():
    logout_user()
    flash('you logged out')
    return redirect(url_for('main.index'))

下面我们实现注册新用户的功能:
添加用户注册表单:

class RegistrationForm(FlaskForm):
    email = StringField('Email', validators=[DataRequired(), Length(1, 64),
                                             Email()])
    username = StringField('Username', validators=[
        DataRequired(), 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=[
        DataRequired(), EqualTo('password2', message='Passwords must match.')])
    password2 = PasswordField('Confirm password', validators=[DataRequired()])
    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.')

这还是只实现了注册新用户的功能,我们需要一个注册新功能的展示页面:

{% extends "base.html" %}
{% import "bootstrap/wtf.html" as wtf %}

{% block title %}Flasky - Register{% endblock %}

{% block page_content %}
<div class="page-header">
    <h1>Register</h1>
</div>
<div class="col-md-4">
    {{ wtf.quick_form(form) }}
</div>
{% endblock %}

然后我们还需要登录页面能有一个按钮能让用户跳转到注册页面:

<br>
<p>New user? <a href="{{ url_for('auth.register') }}">Click here to register</a>.</p>

最后别忘了,我们什么都准备好了,只差完成注册部分的路由:

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

向用户邮箱发送确认注册邮件:
用户注册后,新账户首先被标记为待确认状态,需要用户按照收到邮件中的说明操作后,才可以证明自己被联系上。往往用户只需要点击一个包含确认令牌的特殊URL:

使用itsdangerous生成确认令牌:
这玩意儿有很多生成令牌的方法,其中TimedJSONWebSignatureSerializer类生成具有过期时间的JSON web签名

所以我们将生成和检验这种令牌的功能添加到User模型:

from itsdangerous import TimedJSONWebSignatureSerializer
from flask import current_app

数据表新增一个字段:
confirmed = db.Column(db.Boolean, default=False)

# 生成一个令牌,有效期默认一小时
def generate_confirmation_token(self, expiration=3600):
    # 下面生成具有过期时间的JSON web签名
    s = TimedJSONWebSignatureSerializer(current_app.config['SECRET_KEY'], expiration)
    return s.dumps({'confirm': self.id})  # 为指定的数据生成一个加密签名,然后生成令牌字符串

# 检验令牌
def confirm(self, token):
    s = TimedJSONWebSignatureSerializer(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.session.add(self)
    return True

现在令牌已经做出来了,我们需要向用户发送确认邮件,之前我们注册后就直接重定向到index,现在我们需要在这之前,发送一封确认邮件:

@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()
        # flash('You can now login.')
        # return redirect(url_for('auth.login'))

        # 现在我们要生成令牌然后发送邮件
        token = user.generate_confirmation()
        send_email(user.email, 'Confirmation of your new account', 'auth/email/confirm', user=user, token=token)
        # 这里给用户发送邮件:收件人,邮件标题,邮件模板,给模板传的参数
        # 一个电子邮件需要两个模板,分别用于渲染纯文本正文和富文本正文
        flash('we have sent a confirmation email to you, please confirm it!!!')
        return redirect(url_for('main.index'))

    return render_template('auth/register.html', form=form)

邮件模板内容:

<p>Dear {{ user.username }},</p>
<p>Welcome to <b>Flasky</b>!</p>
<p>To confirm your account please <a href="{{ url_for('auth.confirm', token=token, _external=True) }}">click here</a>.</p>
<p>Alternatively, you can paste the following link in your browser's address bar:</p>
<p>{{ url_for('auth.confirm', token=token, _external=True) }}</p>
<p>Sincerely,</p>
<p>The Flasky Team</p>
<p><small>Note: replies to this email address are not monitored.</small></p>

这里在邮件里面用户点击按钮会打开网页,路由为auth.confirm。
所以接下来,我们还需要实现这个路由功能:

# 这个是发送给用户的邮件中的路由链接
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.confirmed(token):
        # 直接调用user模型中的验证令牌方法,直接使用flash来显示验证结果。
        flash('you have confirmed your acount!')
    else:
        flash('The confirmation link is invalid or it has expired')
    return redirect(url_for('main.index'))

这里有一个问题,如果用户没有在邮件里面确认注册就直接登录,我们可以让用户登录到一个页面,在页面里面告诉用户需要去邮箱里面确认

这个步骤我们可以使用before_request来完成,但是这个只能应用到属于蓝本的请求上,如果要在蓝本中使用针对全局请求的钩子,则需要使用before_app_request修饰器:

# 这个部分处理请求前验证账号是否被激活
@auth.before_app_request
def before_request():
    if current_user.is_authenticated \
            and not current_user.confirmed \
            and request.endpoint \
            and request.blueprint != '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')

这只是实现了功能,我们还是需要模板:

{% extends "base.html" %}
{% block title %}Flasky - Confirm your account{% endblock %}
{% block page_content %}
<div class="page-header">
    <h1>
        Hello, {{ current_user.username }}!
    </h1>
    <h3>You have not confirmed your account yet.</h3>
    <p>
        Before you can access this site you need to confirm your account.
        Check your inbox, you should have received an email with a confirmation link.
    </p>
    <p>
        Need another confirmation email?
        <a href="{{ url_for('auth.resend_confirmation') }}">Click here</a>
    </p>
</div>
{% endblock %}

可以看到这个模板有一个链接,功能是再次发送邮件,我们来定义这个路由:

# 这个部分是告诉账户未激活账户页面的链接,用于再次发送确认邮件
@auth.route('/confirm')
@login_required
def resend_confirmation():
    token = current_user.generate_confirmation_token()
    send_email(current_user.email, 'Confirm Your 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'))

这一部分算是彻底完成了,然后我们试着运行一下,发现config中差配置信息,所以加上:

MAIL_SERVER = os.environ.get('MAIL_SERVER', 'smtp.googlemail.com')
    MAIL_PORT = int(os.environ.get('MAIL_PORT', '587'))
    MAIL_USE_TLS = os.environ.get('MAIL_USE_TLS', 'true').lower() in \
        ['true', 'on', '1']
    MAIL_USERNAME = os.environ.get('MAIL_USERNAME')
    MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD')
    FLASKY_MAIL_SUBJECT_PREFIX = '[Flasky]'
    FLASKY_MAIL_SENDER = 'Flasky Admin <[email protected]>'
    FLASKY_ADMIN = os.environ.get('FLASKY_ADMIN')
    SQLALCHEMY_TRACK_MODIFICATIONS = False

最后这里还是有个坑,那就是在设置里面配置的是gmail,但是国内会出现无网络连接的错误,所以可以使用qq邮箱。我们这里先跳过这个错误,大家可在网上自行搜索如何配置qq邮箱。

用户角色

因为在web程序中,并不是所有的用户都有同样的地位,就比如管理员有着supreme的地位。
首先改进Role模型:

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

然后列出用户的权限,权限用数字表示

class Permission:
    FOLLOW = 1
    COMMENT = 2
    WRITE = 4
    MODERATE = 8
    ADMIN = 16

现在我们可以创建角色了,但是将角色手动添加到数据库很麻烦,所以我们在Role类中添加一个方法来完成这个功能。

@staticmethod
    def insert_roles():
        # 这个函数并不是直接创建新的角色,而是通过角色名来查询现有的角色,再进行更新
        roles = {
            'User': [Permission.FOLLOW, Permission.COMMENT, Permission.WRITE],
            'Moderator': [Permission.FOLLOW, Permission.COMMENT,
                          Permission.WRITE, Permission.MODERATE],
            'Administrator': [Permission.FOLLOW, Permission.COMMENT,
                              Permission.WRITE, Permission.MODERATE,
                              Permission.ADMIN],
        }
        default_role = 'User'
        for r in roles:
            role = Role.query.filter_by(name=r).first()
            if role is None:
                role = Role(name=r)
            role.reset_permissions()
            for perm in roles[r]:
                role.add_permission(perm)
            role.default = (role.name == default_role)
            db.session.add(role)
        db.session.commit()
        
    def add_permission(self, perm):
        if not self.has_permission(perm):
            self.permissions += perm

    def remove_permission(self, perm):
        if self.has_permission(perm):
            self.permissions -= perm

    def reset_permissions(self):
        self.permissions = 0

    def has_permission(self, perm):
        return self.permissions & perm == perm

下面我们给用户赋予角色,大多数用户在注册时候被赋予的角色就是普通用户,但是当某一个邮箱出现用来注册的时候,那就直接赋予管理员权限,所以给User类添加一个初始化方法:

    def __init__(self, **kwargs):
        super(User, self).__init__(**kwargs)
        if self.role is None:
            if self.email == current_app.config['FLASKY_ADMIN']:
                self.role = Role.query.filter_by(name='Administrator').first()
            if self.role is None:
                self.role = Role.query.filter_by(default=True).first()

下面我们实现角色验证功能,即特定的路由只能让拥有特定权限的用户才可以使用。
首先我们在User中增加一个方法用来检查是否有指定的权限:

    def can(self, perm):
        return self.role is not None and self.role.has_permission(perm)

    def is_administrator(self):
        return self.can(Permission.ADMIN)
class AnonymousUser(AnonymousUserMixin):
    def can(self, permissions):
        return False

    def is_administrator(self):
        return False

login_manager.anonymous_user = AnonymousUser

现在如果我们想让特定的视图函数只对特定的用户开放,那么我们就需要自定义一个装饰器
在app目录下新建一个decorators.py文件:

from functools import wraps
from flask import abort
from flask_login import current_user
from .models import Permission


def permission_required(permission):
    # 用来检查常规权限
    def decorator(f):
        @wraps(f)
        # 使用装饰器时,被装饰后的函数已经是另外一个函数了,函数名等函数属性会发生变化
        # 这样的改变会对测试有所影响,所以这个wraps装饰器可以消除这样的副作用。
        def decorated_function(*args, **kwargs):
            if not current_user.can(permission):
                abort(403)  # 用户没有权限就返回403错误码,即HTTP禁止错误
            return f(*args, **kwargs)
        return decorated_function
    return decorator


def admin_required(f):
    # 专门用来检查管理员权限
    return permission_required(Permission.ADMIN)(f)

那么如何使用我们刚刚定义的装饰器呢?

from decorators import admin_requires, permission_required
from .models import Permission

@main.route('/admin')
@login_required
@admin_required
def for_admins_only():
	return 'For administrators!'

猜你喜欢

转载自blog.csdn.net/qq_43355223/article/details/84643562