使用Flask搭建一个校园论坛4-登录注册

108 阅读13分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第1天,点击查看活动详情

1.知识预览

在本届中将学习到以下内容的知识

  1. 如何使用wtform来渲染表单
  2. 如果使用flask-mail来发送邮件

2.用户注册

在前端中form表单是用的比较多的东西,我们可以使用wtforms这个框架,直接通过后端代码来渲染前端表单。新建bbs/forms.py文件,嵌入以下代码

from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField, SelectField, BooleanField, TextAreaField, FileField, Label, HiddenField, \
    PasswordField


class BaseUserForm(FlaskForm):
    user_name = StringField(u'用户名',
                            validators=[DataRequired(message='用户名不能为空'),
                                        Length(min=1, max=16, message='用户名长度限定在1-16位之间'),
                                        Regexp('^[a-zA-Z0-9_]*$',
                                               message='用户名只能包含数字、字母以及下划线.')],
                            render_kw={'placeholder': '请输入用户名长度1-16之间'})
    nickname = StringField(u'昵称',
                           validators=[DataRequired(message='昵称不能为空'),
                                       Length(min=1, max=20, message='昵称长度限定在1-20位之间')],
                           render_kw={'placeholder': '请输入昵称长度1-20之间'})
    user_email = StringField(u'注册邮箱',
                             render_kw={'placeholder': '请输入注册邮箱', 'type': 'email'})

    submit = SubmitField(u'注册', render_kw={'class': 'btn btn-success btn-xs'})

在上面的代码中,首先导入相关的库,然后新建了一个BaseUserForm的类,因为用户的信息在很多表单中使用到了,因此可以将共同的属性剥离出来,然后在不同的场合继承该基类,并且可以根据不同的场合在子类中定制我们的表单属性,这样就可以降低代码的冗余量。如果在每个需要使用到用户信息的表单代码中写入同样的内容,那么久显得代码很臃肿了。

BaseUserForm类中使用到了wtforms中的一些属性,比如StringField就相当于是我们前端的input标签,SubmitField就相当于是<input type="submit">,具体可以去看wtforms的官方文档。

继续在上面的文件中嵌入如下代码,新建注册表单类

class RegisterForm(FlaskForm):
    user_name = StringField(u'用户名',
                            validators=[DataRequired(message='用户名不能为空'),
                                        Length(min=1, max=16, message='用户名长度限定在1-16位之间'),
                                        Regexp('^[a-zA-Z0-9_]*$',
                                               message='用户名只能包含数字、字母以及下划线.')],
                            render_kw={'placeholder': '请输入用户名长度1-16之间'})
    nickname = StringField(u'昵称',
                           validators=[DataRequired(message='昵称不能为空'),
                                       Length(min=1, max=20, message='昵称长度限定在1-16位之间')],
                           render_kw={'placeholder': '请输入昵称长度1-20之间'})
    user_email = StringField(u'注册邮箱',
                             validators=[DataRequired(message='注册邮箱不能为空'),
                                         Length(min=4, message='注册邮箱长度必须大于4')],
                             render_kw={'placeholder': '请输入注册邮箱', 'type': 'email'})
    password = StringField(u'密码',
                           validators=[DataRequired(message='用户密码不能为空'),
                                       Length(min=8, max=40, message='用户密码长度限定在8-40位之间'),
                                       EqualTo('confirm_pwd', message='两次密码不一致')],
                           render_kw={'placeholder': '请输入密码', 'type': 'password'})
    confirm_pwd = StringField(u'确认密码',
                              validators=[DataRequired(message='用户密码不能为空'),
                                          Length(min=8, max=40, message='用户密码长度限定在8-40位之间')],
                              render_kw={'placeholder': '输入确认密码', 'type': 'password'})
    colleges = SelectField(u'学院', choices=[(1, '计算机')])
    submit = SubmitField(u'注册', render_kw={'class': 'source-button btn btn-primary btn-xs mt-2'})

    def __init__(self, *args, **kwargs):
        super(RegisterForm, self).__init__(*args, **kwargs)
        cols = College.query.all()
        self.colleges.choices = [(col.id, col.name) for col in cols]

    def validate_user_name(self, filed):
        if User.query.filter_by(username=filed.data).first():
            raise ValidationError('用户名已被注册.')

    def validate_user_email(self, filed):
        if User.query.filter_by(email=filed.data.lower()).first():
            raise ValidationError('邮箱已被注册.')

    def validate_nickname(self, filed):
        if User.query.filter_by(nickname=filed.data).first():
            raise ValidationError('昵称已被注册')

因为学院的选项有许多,我们可以在类的构造函数中通过数据库去获取数据库中已经存在的学院,然后将其设置到colleges类属性的choices值上,这样当我们打开页面渲染表单时,数据就会自动渲染到select标签option上去了,如下图

然后还新建了三个函数validate_user_namevalidate_user_email以及validate_nickname,这三个函数主要是用来判断email、username、nickname三个字段的唯一性,因为在数据库建表的时候将这三个字段设置为unique=True,因此在这里需要做一个唯一性的判断。

使用wtforms时,我们可以通过validate_加上你需要校验的属性字段名称来检验前端用户输入的数据是否符合标准。

表单类的编写已经完成,接下来就是整个注册逻辑的实现了。新建bbs/templates/frontend/register.html文件,嵌入以下代码

{% extends "frontend/base.html" %}
{% import 'bootstrap/wtf.html' as wtf %}
{% block title %}
    用户注册
{% endblock %}
{% block content %}
    <body>
    <main>
        <div class="container">

            <div class="jumbotron pt-5 pb-1 mt-2">
                <div class="row">
                    <div class="col-md-8">
                        <h3 class="text-muted"><b>欢迎注册加入狗子学院~</b></h3>
                        <hr class="bg-primary">
                        <p><b>在这里你可以:</b></p>
                        <ul>
                            <li>浏览当下校园的一些趣事、杂谈以及谁和谁的八卦</li>
                            <li>发布一些咸鱼交易、寻物启事等等</li>
                            <li>发现臭味相投的朋友、开拓自己的圈子</li>
                        </ul>
                        <img src="{{ url_for('static', filename='img/index.jpg') }}" class="rounded img-fluid">
                    </div>
                    <div class="col-md-4">
                        <div class="card mb-3 w-100 bg-light">
                            <div class="card-header"><h4 class="text-muted"><strong>用户注册</strong></h4></div>
                            <div class="card-body">
                                {% include "_flash.html" %}
                                <form class="bs-component" action="/auth/register/" method="post">
                                    {{ form.csrf_token }}
                                    {{ wtf.form_field(form.user_name) }}
                                    {{ wtf.form_field(form.nickname) }}
                                    {{ wtf.form_field(form.user_email) }}
                                    {{ wtf.form_field(form.password) }}
                                    {{ wtf.form_field(form.confirm_pwd) }}
                                    {{ wtf.form_field(form.colleges) }}
                                    <label for="captcha">验证码</label>
                                    <div class="input-group">
                                        <input type="text" class="form-control" name="captcha" id="captcha" placeholder="请输入验证码" aria-required="true" aria-describedby="captcha" required>
                                        <div class="input-group-append">
                                            <button class="btn btn-success" onclick="sendCapt()" id="sendCaptcha">发送</button>
                                        </div>
                                    </div>
                                    <p class="p-hint">验证码发送成功,10分钟内有效!</p>
                                    {{ form.submit }}
                                    <hr>
                                    <small>已有账号? <a style="text-decoration: none;" href="{{ url_for('.login') }}">登录.</a>
                                    </small>
                                </form>
                            </div>
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </main>

然后打开bbs/blueprint/frontend/auth.py文件,接着在上一节下面嵌入如下代码

@auth_bp.route('/register/', methods=['GET', 'POST'])
def register():
    colleges = College.query.all()
    form = RegisterForm()
    return render_template('frontend/register.html', colleges=colleges, form=form)

在后端代码中我们通过render_template函数返回了前端注册页面,并且携带注册表单的实例参数。在前端html文件中,我们可以通过form.参数名的方式来进行表单渲染。同时还在前端文件中导入了bootstrap/wtf.html,这样就可以将表单的样式渲染成bootstrap的样式,当然也可以不是bootstrap/wtf.html来渲染,在后端表单类中可以通过render_kw参数来指定我们表单的一些特定参数。

在前端页面中我们还手动加入了一行验证码输入框,点击发送按钮就可以将验证码发送到用户填写的邮箱当中去了。为什么不将此输入框写到后端表单中去?因为那样不好处理前端样式了。

访问http://127.0.0.1/auth/register/ 将会看到如下页面:

在注册页面中是需要用户填写邮箱收到的验证码,因此我们需要在后端代码中实现发送邮件的功能。发送邮件的功能是通过flask-email来实现的,打开bbs/extensions.py文件,加入下面的代码,然后在__init__.py文件中进行注册。

from flask_mail import Mail
mail = Mail()

在使用发送邮件功能之前,首先我们需要到qq邮箱或者网易邮箱或者其他可以使用的邮箱申请SMTP服务,具体流程可以某度某歌搜索一下,这里就不再累述。将申请到的私密信填入到.env文件中

MAIL_SERVER='smtp.qq.com'
MAIL_USERNAME='你的qq邮箱名'
MAIL_PASSWORD='qq邮箱秘钥不是登录密码是申请SMTP那串无规则秘钥'

然后在bbs/setting.py文件中加入下面的代码

class BaseConfig(object):    
    # 省略之前代码
    BBS_MAIL_SUBJECT_PRE = '[狗子学院]'
    MAIL_SERVER = os.getenv('MAIL_SERVER')
    MAIL_PORT = 465
    MAIL_USE_SSL = True
    MAIL_USERNAME = os.getenv('MAIL_USERNAME')
    MAIL_PASSWORD = os.getenv('MAIL_PASSWORD')
    MAIL_DEFAULT_SENDER = ('BBS Admin', MAIL_USERNAME)

新建bbs/email.py文件,并将下面代码写入其中。

from threading import Thread

from bbs.extensions import mail
from flask_mail import Message
from flask import current_app, render_template


def async_send_mail(app, msg):
    with app.app_context():
        mail.send(msg)


def send_email(to_mail, subject, template, **kwargs):
    message = Message(current_app.config['BBS_MAIL_SUBJECT_PRE'] + subject,
                      recipients=[to_mail],
                      sender=current_app.config['MAIL_USERNAME'])
    message.body = render_template(template + '.txt', **kwargs)
    message.html = render_template(template + '.html', **kwargs)
    th = Thread(target=async_send_mail, args=(current_app._get_current_object(), message))
    th.start()
    return th

在send_email函数中,使用了render_template()来渲染了邮件消息body以及html参数,因此需要先将这两个模板准备好。新建bbs/templates/email/verifyCode.html 与bbs/templates/email/verifyCode.txt 文件,将下面的代码写到文件中去

verifyCode.html

<h3 style="font-weight: bold;font-size: 18px">Hello {{ username }},</h3>
<p>Welcome to join the <a href="http://bbs.2dogz.cn">狗子学院</a>!This is your register captcha below here.</p>
<h1><strong>{{ ver_code }}</strong></h1>
<h5><b><i>The captcha will expire after 10 minutes.</i></b></h5>
<p style="color: red; font-style: italic"> If this operate is not by yourself, please change your password right now!Maybe your account was cracked.</p>
<small>(Please do not reply to this notification, this inbox is not monitored.)</small>

verifyCode.txt

Hello {{ username }}
Welcome to Blogin!
Welcome to join the 狗子学院!This is your register captcha below here.
{{ ver_code }}
The captcha will expire after 10 minutes.
If this operate is not by yourself, please change your password right now!Maybe your account was cracked.</p>

(Please do not reply to this notification, this inbox is not monitored.)

然后开始编写发送邮件的后端逻辑代码,新建bbs/blueprint/frontend/normal.py 文件,因为发送邮件属于通用行为,因此将其放入normal.py模块中,将以下代码嵌入其中

from flask import Blueprint, send_from_directory, request, jsonify
from bbs.extensions import db
from bbs.email import send_email
from bbs.models import VerifyCode, Gender, Role, College

@normal_bp.route('/send-email/', methods=['POST'])
def send():
    to_email = request.form.get('user_email')
    username = request.form.get('user_name')
    ver_code = generate_ver_code()
    send_email(to_mail=to_email, subject='Captcha', template='email/verifyCode', username=username,
               ver_code=ver_code)

    # 判断是否已经存在一个最新的可用的验证码,以确保生效的验证码是用户收到最新邮件中的验证码
    exist_code = VerifyCode.query.filter(VerifyCode.who == to_email, VerifyCode.is_work == 1).order_by(
        VerifyCode.timestamps.desc()).first()
    if exist_code:
        exist_code.is_work = False
    nt = datetime.datetime.now()
    et = nt + datetime.timedelta(minutes=10)
    verify_code = VerifyCode(val=ver_code, who=to_email, expire_time=et)
    db.session.add(verify_code)
    db.session.commit()
    return jsonify({'tag': 1, 'info': '邮件发送成功!'})

我们根据请求中的keyword来获取前端发送过来的请求参数,然后调用send_email()函数进行发送邮件,同时将生成的随机验证码放入到邮件消息体中去。

然后判断数据库中是否已经存在了属于该注册用户的验证码,如果有则将它设置为过期的,然后将新的验证码存入到数据库中,并设置过期时间为10分钟。这里是通过MySQL来保存的验证码信息,也有其他方法来保存验证码信息,比如使用redis来保存,redis可以设置字段过期时间,如果达到了这个时间,再去取这个字段的值就会为None。

接着我们来处理前端发送邮件的代码,打开bbs/templates/frontend/register.html文件,加入下面的代码

<main>
...
</main>
<script>
        let time = 60;
        let reg = /^\w+((-\w+)|(\.\w+))*\@[A-Za-z0-9]+((\.|-)[A-Za-z0-9]+)*\.[A-Za-z0-9]+$/;

        function sendCapt(){
            let $sendBtn = $("#sendCaptcha");
            let $email = $("#user_email");
            let $username = $("#user_name");
            if ($username.val() === '' || $email.val() === '' || !reg.test($email.val())){
                return false;
            }
            $sendBtn.attr('disabled', true);
            getRandomCode($sendBtn);
            $.ajax({
                url: '/normal/send-email/',
                type: 'post',
                data: {'user_name': $username.val(), 'user_email': $email.val()},
                success: function (res){
                    if (res.tag){
                        $(".p-hint").slideDown(500).delay(3000).hide(500);
                    }
                }
            })
        }

        //倒计时
        function getRandomCode(obj) {
            if (time === 0) {
                time = 60;
                obj.text('发送');
                obj.attr('disabled', false);
                return;
            } else {
                time--;
                obj.text(time+'(秒)');
            }
            setTimeout(function() {
                getRandomCode(obj);
            },1000);
        }
    </script>

通过ajax向后端/normal/send-email/发送请求,同时将emailusername传递到后端,同时将发送验证码按钮置为不可点击状态,间隔60s才能发送一次,并在前端页面给用户一个提示信息。这样发送验证码邮件的整个流程就完成了,接下里要处理用户点击注册按钮之后的逻辑。

我们使用的是wtfforms来渲染的后端表单,并且在某些表单字段中加入了一些限制信息。在后端代码中,我们可以通过wtfforms的示例来验证我们的表单,打开bbs/blueprint/frontend/auth.py 加入下面的代码

@auth_bp.route('/register/', methods=['GET', 'POST'])
def register():    
    ...
    if form.validate_on_submit():
        username = form.user_name.data
        nickname = form.nickname.data
        password = form.confirm_pwd.data
        email = form.user_email.data
        college = form.colleges.data
        captcha = request.form.get('captcha')
        code = VerifyCode.query.filter(VerifyCode.who == email, VerifyCode.is_work == 1).order_by(
            VerifyCode.timestamps.desc()).first()
        if code:
            if code.val != int(captcha):
                flash('验证码错误!', 'danger')
                return redirect(request.referrer)
            elif code.expire_time < datetime.datetime.now():
                flash('验证码已过期!', 'danger')
                return redirect(request.referrer)
        else:
            flash('请先发送验证码到邮箱!', 'info')
            return redirect(request.referrer)

        user = User(username=username,
                    college_id=college,
                    nickname=nickname,
                    email=email,
                    password=password,
                    status_id=1)
        user.generate_avatar()
        user.set_password(password)
        code.is_work = False
        db.session.add(user)
        db.session.commit()
        flash('注册成功,欢迎加入二狗学院!', 'success')
        return redirect(url_for('.login'))

通过form.validate_on_submit()来判断提交的表单是否通过了验证,通过验证之后通过form.字段名.data来获取对应表单字段的值,然后根据邮箱来查找上一步数据库保存的验证码,如果不存在验证码,则提示用户发送验证码到邮箱,因为存在着一种可能用户乱填一个验证码,而不发送验证码到邮箱。然后判断验证码是否正确或者过期,如果未通过,则发送对应的提示消息提示用户,如果通过,则将用户信息保存到数据库中,然后重定向到登录页面。至此,用户注册的功能就已经完成了。

用户登录

相比注册功能,用户登录就比较简单了。新建bbs/templates/frontend/login.html文件,该文件为用户登录的前端页面模板文件,同样的我们使用wtfforms来渲染表单,在文件中嵌入下面的代码

{% extends "frontend/base.html" %}
{% import 'bootstrap/wtf.html' as wtf %}
{% block title %}
    用户登录
{% endblock %}
{% block content %}
    <body>
    <main>
        <div class="container">
            {% include "_flash.html" %}
            <div class="jumbotron pt-5 pb-3 mt-5">
                <div class="row">
                    <div class="col-md-8">
                        <img src="{{ url_for('static', filename='img/index.jpg') }}" class="rounded img-fluid">
                    </div>
                    <div class="col-md-4">
                        <div class="card mb-3 w-100 bg-light align-self-center">
                            <div class="card-header"><h4 class="text-muted"><strong>用户登录</strong></h4></div>
                            <div class="card-body ">
                                <form class="bs-component" action="/auth/login/" method="post">
                                    {{ form.csrf_token }}
                                    {{ wtf.form_field(form.usr_email) }}
                                    {{ wtf.form_field(form.password) }}
                                    {{ wtf.form_field(form.remember_me) }}
                                    {{ form.submit }}
                                    <hr>
                                    <small>没有账号? <a style="text-decoration: none;" href="{{ url_for('.register') }}">注册.</a>
                                    </small>
                                </form>
                            </div>
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </main>
    </body>
{% endblock %}

代码中的form.csrf_token 是一种防止csrf攻击的手段,关于csrf攻击具体可以百度,之后的代码就跟注册模板一样,通过wtfforms进行表单渲染,因此我们需要新建一个渲染登录表单的类,打开bbs/forms.py模块,加入新建登录表单的代码,如下所示

class LoginForm(FlaskForm):
    usr_email = StringField(u'邮箱/用户名', validators=[DataRequired(message='用户名或邮箱不能为空')],
                            render_kw={'placeholder': '请输入邮箱或用户名'})
    password = StringField(u'登录密码',
                           validators=[DataRequired(message='登录密码不能为空'),
                                       Length(min=8, max=40, message='登录密码必须在8-40位之间')],
                           render_kw={'type': 'password', 'placeholder': '请输入用户密码'})
    remember_me = BooleanField(u'记住我')
    submit = SubmitField(u'登录', render_kw={'class': 'source-button btn btn-primary btn-xs'})

接着需要处理后端的登录视图函数,打开bbs/blueprint/frontend/auth.py模块,新建一个视图函数,代码如下所示

from flask_login import current_user, login_user, logout_user
@auth_bp.route('/login/', methods=['GET', 'POST'])
def login():
    if current_user.is_authenticated:
        return redirect(url_for('index_bp.index'))
    form = LoginForm()
    if form.validate_on_submit():
        usr = form.usr_email.data
        pwd = form.password.data
        user = User.query.filter(or_(User.username == usr, User.email == usr.lower())).first()
        if user is not None and user.status.name == '禁用':
            flash('您的账号处于封禁状态,禁止登陆!联系管理员解除封禁!', 'danger')
            return redirect(url_for('.login'))

        if user is not None and user.check_password(pwd):
            if login_user(user, form.remember_me.data):
                flash('登录成功!', 'success')
                return redirect(url_for('index_bp.index'))
        elif user is None:
            flash('无效的邮箱或用户名.', 'danger')
        else:
            flash('无效的密码', 'danger')
    return render_template('frontend/login.html', form=form)

关于登录逻辑处理,flask有一个十分流行好用的第三方库flask-login,使用该第三库我们可以很方便的处理登录、退出权限控制等操作。

首先通过current_user来判断用户是否已经登录了,如果登录则返回主页面。接着通过LoginForm示例来获取前端登录页面传递过来的值,从数据库获取用户的相关信息,首先判断账号是否被禁用了,如果被禁用则弹出提示信息,并返回给前端页面。接着判断用户密码是否匹配,如果不匹配则返回登录页面,并提示用户密码不匹配,反之则重定向到主页。

这里的登录成功重定向其实可以做的更加人性化,当用户进入到需要登录才能操作的页面时候,这时候会自动跳转到登录页面。如果用户登录成功,应该是返回前一个页面而不是固定返回主页。flask_login的login_required装饰器重定向到登录页面的时候会带一个next参数,因此我们可以通过此参数来让用户登录成功之后重定向到上一页,具体实现很简单,就请读者自主开发吧!

这时候我们打开登录页面,可以看到如下页面

到此,用户登录注册功能就已经全部实现了,下一节将开始讲述论坛主页的实现。