Flask Password Reset
Password reset functionality is an essential feature for any modern web application. It allows users who have forgotten their passwords to regain access to their accounts securely. In this tutorial, we'll walk through the process of implementing a robust password reset system in a Flask application.
Introduction
Implementing password reset functionality involves several key components:
- Creating a form for users to request a password reset
- Generating and sending a secure, time-limited token via email
- Validating the token when the user clicks the reset link
- Providing a form to set a new password
- Updating the user's password in the database
This tutorial assumes you have a basic Flask application with user authentication already set up. We'll build on that foundation to add password reset functionality.
Prerequisites
Before we begin, make sure you have:
- A Flask application with user registration and login
- A database to store user information (we'll use SQLAlchemy)
- Flask-Mail configured for sending emails
- Flask-WTF for form handling
If you haven't set up these components yet, consider checking out the previous tutorials in the Flask Authentication section.
Step 1: Setting Up the Database Model
First, we need to add a field to our User model to store the password reset token and its expiration time:
from datetime import datetime, timedelta
import secrets
from flask_sqlalchemy import SQLAlchemy
db = SQLAlchemy()
class User(db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(100), unique=True, nullable=False)
email = db.Column(db.String(120), unique=True, nullable=False)
password = db.Column(db.String(200), nullable=False)
reset_token = db.Column(db.String(100), nullable=True)
reset_token_expiry = db.Column(db.DateTime, nullable=True)
def set_reset_token(self):
"""Generate a secure token and set its expiration time (valid for 1 hour)"""
self.reset_token = secrets.token_urlsafe(32) # Generate a secure random token
self.reset_token_expiry = datetime.utcnow() + timedelta(hours=1)
return self.reset_token
def verify_reset_token(self, token):
"""Verify if the reset token is valid and not expired"""
if token != self.reset_token:
return False
if datetime.utcnow() > self.reset_token_expiry:
return False
return True
Don't forget to update your database after adding these new fields:
with app.app_context():
db.create_all() # This will update your existing database schema
Step 2: Creating the Forms
Let's create two forms: one for requesting a password reset (entering email) and another for setting a new password.
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, SubmitField
from wtforms.validators import DataRequired, Email, EqualTo, Length
class RequestResetForm(FlaskForm):
email = StringField('Email', validators=[DataRequired(), Email()])
submit = SubmitField('Request Password Reset')
class ResetPasswordForm(FlaskForm):
password = PasswordField('New Password', validators=[
DataRequired(),
Length(min=8, message='Password must be at least 8 characters long')
])
confirm_password = PasswordField('Confirm New Password', validators=[
DataRequired(),
EqualTo('password', message='Passwords must match')
])
submit = SubmitField('Reset Password')
Step 3: Setting Up Email Functionality
We'll use Flask-Mail to send the password reset emails:
from flask_mail import Mail, Message
# Configure mail settings (typically in your app's config)
app.config['MAIL_SERVER'] = 'smtp.example.com'
app.config['MAIL_PORT'] = 587
app.config['MAIL_USE_TLS'] = True
app.config['MAIL_USERNAME'] = '[email protected]'
app.config['MAIL_PASSWORD'] = 'your-email-password'
app.config['MAIL_DEFAULT_SENDER'] = ('Your App Name', '[email protected]')
mail = Mail(app)
def send_reset_email(user):
"""Send a password reset email to the user"""
token = user.set_reset_token()
db.session.commit() # Save the token to database
reset_url = url_for('reset_password', token=token, _external=True)
msg = Message('Password Reset Request',
recipients=[user.email])
msg.body = f'''To reset your password, visit the following link:
{reset_url}
If you did not make this request, simply ignore this email and no changes will be made.
This link will expire in 1 hour.
'''
mail.send(msg)
Step 4: Creating the Routes
Now, let's create the routes for our password reset functionality:
from flask import render_template, url_for, flash, redirect, request
@app.route('/reset_password_request', methods=['GET', 'POST'])
def reset_request():
# If user is already logged in, redirect to home
if current_user.is_authenticated:
return redirect(url_for('home'))
form = RequestResetForm()
if form.validate_on_submit():
user = User.query.filter_by(email=form.email.data).first()
if user:
send_reset_email(user)
flash('An email has been sent with instructions to reset your password.', 'info')
return redirect(url_for('login'))
else:
flash('There is no account with that email. Please register first.', 'warning')
return render_template('reset_request.html', title='Reset Password', form=form)
@app.route('/reset_password/<token>', methods=['GET', 'POST'])
def reset_password(token):
# If user is already logged in, redirect to home
if current_user.is_authenticated:
return redirect(url_for('home'))
# Find the user with this token
user = User.query.filter_by(reset_token=token).first()
# If token is invalid or expired
if user is None or not user.verify_reset_token(token):
flash('That is an invalid or expired token', 'warning')
return redirect(url_for('reset_request'))
form = ResetPasswordForm()
if form.validate_on_submit():
# Update password (assuming you have a set_password method that hashes the password)
user.password = generate_password_hash(form.password.data)
# Clear the reset token
user.reset_token = None
user.reset_token_expiry = None
db.session.commit()
flash('Your password has been updated! You are now able to log in', 'success')
return redirect(url_for('login'))
return render_template('reset_password.html', title='Reset Password', form=form)
Step 5: Creating the HTML Templates
Now we need to create the templates for our password reset pages. First, let's create the template for requesting a password reset:
reset_request.html
{% extends "layout.html" %}
{% block content %}
<div class="content-section">
<form method="POST" action="">
{{ form.hidden_tag() }}
<fieldset class="form-group">
<legend class="border-bottom mb-4">Reset Password</legend>
<div class="form-group">
{{ form.email.label(class="form-control-label") }}
{% if form.email.errors %}
{{ form.email(class="form-control form-control-lg is-invalid") }}
<div class="invalid-feedback">
{% for error in form.email.errors %}
<span>{{ error }}</span>
{% endfor %}
</div>
{% else %}
{{ form.email(class="form-control form-control-lg") }}
{% endif %}
</div>
</fieldset>
<div class="form-group">
{{ form.submit(class="btn btn-outline-info") }}
</div>
</form>
</div>
{% endblock content %}
reset_password.html
Now let's create the template for setting a new password:
{% extends "layout.html" %}
{% block content %}
<div class="content-section">
<form method="POST" action="">
{{ form.hidden_tag() }}
<fieldset class="form-group">
<legend class="border-bottom mb-4">Reset Password</legend>
<div class="form-group">
{{ form.password.label(class="form-control-label") }}
{% if form.password.errors %}
{{ form.password(class="form-control form-control-lg is-invalid") }}
<div class="invalid-feedback">
{% for error in form.password.errors %}
<span>{{ error }}</span>
{% endfor %}
</div>
{% else %}
{{ form.password(class="form-control form-control-lg") }}
{% endif %}
</div>
<div class="form-group">
{{ form.confirm_password.label(class="form-control-label") }}
{% if form.confirm_password.errors %}
{{ form.confirm_password(class="form-control form-control-lg is-invalid") }}
<div class="invalid-feedback">
{% for error in form.confirm_password.errors %}
<span>{{ error }}</span>
{% endfor %}
</div>
{% else %}
{{ form.confirm_password(class="form-control form-control-lg") }}
{% endif %}
</div>
</fieldset>
<div class="form-group">
{{ form.submit(class="btn btn-outline-info") }}
</div>
</form>
</div>
{% endblock content %}
Step 6: Adding a Link to the Login Page
Don't forget to add a link to the password reset form on your login page:
<div class="border-top pt-3">
<small class="text-muted">
<a href="{{ url_for('reset_request') }}">Forgot Password?</a>
</small>
</div>
Complete Example - Bringing It All Together
Let's see how all these components fit into a complete Flask application:
from flask import Flask, render_template, url_for, flash, redirect, request
from flask_sqlalchemy import SQLAlchemy
from flask_bcrypt import Bcrypt
from flask_login import LoginManager, UserMixin, login_user, current_user, logout_user, login_required
from flask_mail import Mail, Message
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, SubmitField
from wtforms.validators import DataRequired, Email, EqualTo, Length
import secrets
from datetime import datetime, timedelta
app = Flask(__name__)
app.config['SECRET_KEY'] = 'your-secret-key'
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///site.db'
# Mail configurations
app.config['MAIL_SERVER'] = 'smtp.gmail.com'
app.config['MAIL_PORT'] = 587
app.config['MAIL_USE_TLS'] = True
app.config['MAIL_USERNAME'] = '[email protected]' # Update with your email
app.config['MAIL_PASSWORD'] = 'your-password' # Update with your password or app password
app.config['MAIL_DEFAULT_SENDER'] = ('Your App Name', '[email protected]')
# Initialize extensions
db = SQLAlchemy(app)
bcrypt = Bcrypt(app)
login_manager = LoginManager(app)
mail = Mail(app)
login_manager.login_view = 'login'
login_manager.login_message_category = 'info'
@login_manager.user_loader
def load_user(user_id):
return User.query.get(int(user_id))
# User model
class User(db.Model, UserMixin):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(100), unique=True, nullable=False)
email = db.Column(db.String(120), unique=True, nullable=False)
password = db.Column(db.String(200), nullable=False)
reset_token = db.Column(db.String(100), nullable=True)
reset_token_expiry = db.Column(db.DateTime, nullable=True)
def set_reset_token(self):
"""Generate a secure token and set its expiration time (valid for 1 hour)"""
self.reset_token = secrets.token_urlsafe(32)
self.reset_token_expiry = datetime.utcnow() + timedelta(hours=1)
return self.reset_token
def verify_reset_token(self, token):
"""Verify if the reset token is valid and not expired"""
if token != self.reset_token:
return False
if datetime.utcnow() > self.reset_token_expiry:
return False
return True
# Forms
class RequestResetForm(FlaskForm):
email = StringField('Email', validators=[DataRequired(), Email()])
submit = SubmitField('Request Password Reset')
class ResetPasswordForm(FlaskForm):
password = PasswordField('New Password', validators=[
DataRequired(),
Length(min=8, message='Password must be at least 8 characters long')
])
confirm_password = PasswordField('Confirm New Password', validators=[
DataRequired(),
EqualTo('password', message='Passwords must match')
])
submit = SubmitField('Reset Password')
# Helper function to send email
def send_reset_email(user):
"""Send a password reset email to the user"""
token = user.set_reset_token()
db.session.commit()
reset_url = url_for('reset_password', token=token, _external=True)
msg = Message('Password Reset Request',
recipients=[user.email])
msg.body = f'''To reset your password, visit the following link:
{reset_url}
If you did not make this request, simply ignore this email and no changes will be made.
This link will expire in 1 hour.
'''
mail.send(msg)
# Routes
@app.route('/reset_password_request', methods=['GET', 'POST'])
def reset_request():
if current_user.is_authenticated:
return redirect(url_for('home'))
form = RequestResetForm()
if form.validate_on_submit():
user = User.query.filter_by(email=form.email.data).first()
if user:
send_reset_email(user)
flash('An email has been sent with instructions to reset your password.', 'info')
return redirect(url_for('login'))
else:
flash('There is no account with that email. Please register first.', 'warning')
return render_template('reset_request.html', title='Reset Password', form=form)
@app.route('/reset_password/<token>', methods=['GET', 'POST'])
def reset_password(token):
if current_user.is_authenticated:
return redirect(url_for('home'))
# Find the user with this token
user = User.query.filter_by(reset_token=token).first()
# If token is invalid or expired
if user is None or not user.verify_reset_token(token):
flash('That is an invalid or expired token', 'warning')
return redirect(url_for('reset_request'))
form = ResetPasswordForm()
if form.validate_on_submit():
# Update password
hashed_password = bcrypt.generate_password_hash(form.password.data).decode('utf-8')
user.password = hashed_password
# Clear the reset token
user.reset_token = None
user.reset_token_expiry = None
db.session.commit()
flash('Your password has been updated! You are now able to log in', 'success')
return redirect(url_for('login'))
return render_template('reset_password.html', title='Reset Password', form=form)
# Sample home and login routes for completeness
@app.route('/')
@app.route('/home')
def home():
return render_template('home.html', title='Home')
@app.route('/login', methods=['GET', 'POST'])
def login():
# Login logic here
return render_template('login.html', title='Login')
if __name__ == '__main__':
with app.app_context():
db.create_all()
app.run(debug=True)
Security Considerations
When implementing password reset functionality, keep these security considerations in mind:
- Token Expiration: Always set an expiration time for reset tokens to limit the window of vulnerability.
- One-Time Use: Reset tokens should be invalidated after use to prevent reuse.
- Secure Communication: Always use HTTPS to protect sensitive information during transmission.
- Rate Limiting: Implement rate limiting to prevent brute force attacks on the password reset system.
- Account Enumeration: Be careful not to reveal whether an email exists in your system. Consider always showing a success message regardless of whether the email exists.
Troubleshooting Common Issues
Email Not Sending
- Check your mail server configuration
- Verify your credentials
- Some email providers like Gmail require "Less secure app access" or an "App Password"
Token Invalid or Expired
- Check if the token in the URL matches the one in the database
- Make sure the token hasn't expired
- Ensure the database is being correctly updated when tokens are created
Database Errors
- Make sure your database schema is up to date
- Check for migration issues if you're using Flask-Migrate
Summary
In this tutorial, you learned how to implement a secure password reset system in a Flask application. We covered:
- Creating the necessary database fields to store reset tokens
- Building forms for requesting a reset and setting a new password
- Implementing token generation and validation
- Sending reset emails to users
- Creating the routes and templates for the reset workflow
- Important security considerations
This implementation follows best practices for security and user experience, providing a robust solution that you can adapt to your specific application needs.
Additional Resources and Exercises
Resources
Exercises
- Enhance Security: Modify the example to use a signed token (using itsdangerous package) instead of storing the raw token in the database.
- Email Template: Create an HTML email template instead of plain text for a more professional look.
- Password Requirements: Add more sophisticated password requirements (special characters, numbers, etc.) to the password reset form.
- Account Lockout: Implement a system that temporarily locks an account after multiple failed password reset attempts.
- Event Logging: Add logging to record password reset events for security audit purposes.
By implementing a secure password reset system, you've added an essential feature to your Flask application that improves both security and user experience.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)