pythonflaskitsdangerous

Flask Webapp - Verify Email after Registration - Best Practice


I've been following along to Corey Schafer's awesome youtube tutorial on the basic flaskblog. In addition to Corey's code, I`d like to add a logic, where users have to verify their email-address before being able to login. I've figured to do this with the URLSafeTimedSerializer from itsdangerous, like suggested by prettyprinted here. The whole token creation and verification process seems to work. Unfortunately due to my very fresh python knowledge in general, I can't figure out a clean way on my own how to get that saved into the sqlite3 db. In my models I've created a Boolean Column email_confirmed with default=False which I am intending to change to True after the verification process. My question is: how do I best identify the user (for whom to alter the email_confirmed Column) when he clicks on his custom url? Would it be a good practice to also save the token inside a db Column and then filter by that token to identify the user?
Here is some of the relevant code:

User Class in my modely.py

class User(db.Model, UserMixin):
    id = db.Column(db.Integer, primary_key=True)
    username = db.Column(db.String(20), unique=True, nullable=False)
    email = db.Column(db.String(120), unique=True, nullable=False)
    image_file = db.Column(db.String(20), nullable=False, default='default_profile.jpg')
    password = db.Column(db.String(60), nullable=False)
    date_registered = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
    email_confirmed = db.Column(db.Boolean(), nullable=False, default=False)
    email_confirm_date = db.Column(db.DateTime)
    projects = db.relationship('Project', backref='author', lazy=True)


    def get_mail_confirm_token(self, expires_sec=1800):
        s = URLSafeTimedSerializer(current_app.config['SECRET_KEY'], expires_sec)
        return s.dumps(self.email, salt='email-confirm')


    @staticmethod
    def verify_mail_confirm_token(token):
        s = URLSafeTimedSerializer(current_app.config['SECRET_KEY'])
        try: 
            return s.loads(token, salt='email-confirm', max_age=60)
        except SignatureExpired:
            return "PROBLEM" 

Registration Logic in my routes (using a users blueprint):

@users.route('/register', methods=['GET', 'POST'])
def register():
    if current_user.is_authenticated: 
        return redirect(url_for('dash.dashboard'))
    form = RegistrationForm()
    if form.validate_on_submit():
        hashed_password = bcrypt.generate_password_hash(form.password.data).decode('utf-8')
        user = User(username=form.username.data, email=form.email.data, password=hashed_password)
        db.session.add(user)
        db.session.commit()
        send_mail_confirmation(user)
        return redirect(url_for('users.welcome'))
    return render_template('register.html', form=form)


@users.route('/welcome')
def welcome():
    return render_template('welcome.html')


@users.route('/confirm_email/<token>')
def confirm_email(token):
    user = User.verify_mail_confirm_token(token)
    current_user.email_confirmed = True
    current_user.email_confirm_date = datetime.utcnow 
    return user

The last parts current_user.email_confirmed = True and current_user.email_confirm_date =datetime.utcnow are probably the lines in question. Like stated above the desired entries aren't made because the user is not logged in at this stage, yet. Im grateful for any help on this! Thanks a lot in advance!


Solution

  • The key to your question is this:

    My question is: how do I best identify the user (for whom to alter the email_confirmed Column) when he clicks on his custom url?

    The answer can be seen in the example on URL safe serialisation using itsdangerous.

    The token itself contains the e-mail address, because that's what you are using inside your get_mail_confirm_token() function.

    You can then use the serialiser to retrieve the e-mail address from that token. You can do that inside your verify_mail_confirm_token() function, but, because it's a static-method you still need a session. You can pass this in as a separate argument though without problem. You also should treat the BadSignature exception from itsdangerous. It would then become:

    @staticmethod
    def verify_mail_confirm_token(session, token):
        s = URLSafeTimedSerializer(current_app.config['SECRET_KEY'])
        try: 
            email = s.loads(token, salt='email-confirm', max_age=60)
        except (BadSignature, SignatureExpired):
            return "PROBLEM"
    
        user = session.query(User).filter(User.email == email).one_or_none()
        return user
    

    Would it be a good practice to also save the token inside a db Column and then filter by that token to identify the user?

    No. The token should be short-lived and should not be kept around.

    Finally, in your get_mail_confirm_token implementation you are not using the URLSafeTimedSerializer class correctly. You pass in a second argument called expires_sec, but if you look at the docs you will see that the second argument is the salt, which might lead to unintended problems.