registration: Basic password reset

I think expiration should be pending on a proper way to do recurring
tasks -- I'm personally in favor of a CLI command that can be run from a
cronjob or systemd timer that will do things like auto-expire password
reset requests and send the daily registration reports.

Now that I'm thinking about it, this does need at least a rudimentary
system to make sure that it actually expires. If the expiration is
invalid at the time of reset, then the request can just be invalidated
and deleted. There's no pressing need for automatic removal until it's
implemented.

Thoughts @willhockey20?
This commit is contained in:
Cara Salter 2023-01-03 17:43:17 -05:00
parent 952df13136
commit 60953074e7
No known key found for this signature in database
GPG key ID: 90C66610C82B29CA
9 changed files with 184 additions and 6 deletions

View file

@ -4,7 +4,7 @@ from flask_migrate import Migrate
from flask_login import LoginManager from flask_login import LoginManager
from flask_assets import Bundle, Environment from flask_assets import Bundle, Environment
from flask_cors import CORS from flask_cors import CORS
from flask_mail import Mail from flask_mail import Mail, email_dispatched
db = SQLAlchemy() db = SQLAlchemy()
@ -75,6 +75,11 @@ def create_app():
def assets(path): def assets(path):
return send_from_directory('templates/home/assets', path) return send_from_directory('templates/home/assets', path)
def log_message(message, app):
app.logger.debug(message)
email_dispatched.connect(log_message)
return app return app

View file

@ -1,6 +1,6 @@
from flask import flash, redirect, url_for from flask import flash, redirect, url_for
from flask_login import UserMixin from flask_login import UserMixin
from sqlalchemy import Boolean, Column, DateTime, Integer, String from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String
from . import db from . import db
from . import login from . import login
@ -54,4 +54,4 @@ def unauth():
class PwResetRequest(db.Model): class PwResetRequest(db.Model):
id = Column(String, primary_key=True) id = Column(String, primary_key=True)
user_id = db.relationship("User") user_id = Column(Integer, ForeignKey('user.id'), nullable=False)

View file

@ -1,13 +1,14 @@
from datetime import datetime from datetime import datetime
from flask import Blueprint, config, current_app, flash, redirect, render_template, request, url_for from flask import Blueprint, abort, config, current_app, flash, redirect, render_template, request, url_for
import flask_login import flask_login
from flask_login import current_user from flask_login import current_user
from goathacks.registration.forms import LoginForm, RegisterForm from goathacks.registration.forms import LoginForm, PwResetForm, RegisterForm, ResetForm
from werkzeug.security import check_password_hash, generate_password_hash from werkzeug.security import check_password_hash, generate_password_hash
from flask_mail import Message from flask_mail import Message
import ulid
from goathacks import db, mail as app_mail from goathacks import db, mail as app_mail
from goathacks.models import User from goathacks.models import PwResetRequest, User
bp = Blueprint('registration', __name__, url_prefix="/registration") bp = Blueprint('registration', __name__, url_prefix="/registration")
@ -81,6 +82,9 @@ def login():
password = request.form.get('password') password = request.form.get('password')
user = User.query.filter_by(email=email).first() user = User.query.filter_by(email=email).first()
if user == None:
flash("Email or password incorrect")
return render_template("login.html", form=form)
if check_password_hash(user.password, password): if check_password_hash(user.password, password):
flask_login.login_user(user) flask_login.login_user(user)
@ -92,3 +96,62 @@ def login():
flash("Incorrect password") flash("Incorrect password")
return render_template("login.html", form=form) return render_template("login.html", form=form)
@bp.route("/reset", methods=["GET", "POST"])
def reset():
form = ResetForm(request.form)
if request.method == 'POST':
email = request.form.get('email')
user = User.query.filter_by(email=email).first()
if user == None:
flash("If that email has an account here, we've just sent it a link to reset your password.")
return redirect(url_for("registration.login"))
else:
r = PwResetRequest(
id=str(ulid.ulid()),
user_id=user.id
)
db.session.add(r)
db.session.commit()
msg = Message("GoatHacks - Password Reset Request")
msg.add_recipient(user.email)
msg.body = render_template("emails/password_reset.txt", code=r.id)
app_mail.send(msg)
flash("If that email has an account here, we've just sent it a link to reset your password.")
return redirect(url_for("registration.login"))
else:
return render_template("pw_reset.html", form=form)
@bp.route("/reset/complete/<string:id>", methods=["GET", "POST"])
def do_reset(id):
form = PwResetForm(request.form)
req = PwResetRequest.query.filter_by(id=id).first()
if req == None:
flash("Invalid request")
return redirect(url_for("registration.login"))
if request.method == "POST":
password = request.form.get("password")
password_c = request.form.get("password_confirm")
if password == password_c:
user = User.query.filter_by(id=req.user_id).first()
if user == None:
flash("Invalid user")
return redirect(url_for("registration.login"))
user.password = generate_password_hash(password)
db.session.delete(req)
db.session.commit()
flash("Password successfully reset")
return redirect(url_for("registration.login"))
else:
flash("Passwords do not match!")
return render_template("password_reset.html", form=form)
else:
return render_template("password_reset.html", form=form)

View file

@ -23,3 +23,11 @@ class LoginForm(FlaskForm):
password = PasswordField("Password", validators=[DataRequired()]) password = PasswordField("Password", validators=[DataRequired()])
submit = SubmitField("Sign in") submit = SubmitField("Sign in")
class ResetForm(FlaskForm):
email = StringField("Email", validators=[DataRequired()])
submit = SubmitField("Request reset")
class PwResetForm(FlaskForm):
password = PasswordField("Password")
password_confirm = PasswordField("Confirm Password")
submit = SubmitField("Submit")

View file

@ -0,0 +1,13 @@
Hello!
Someone just requested a password reset for your GoatHacks account. If this
wasn't you, don't worry. Just ignore this request, and nothing will happen.
If this was you, please follow this link: {{url_for("registration.do_reset", id=code, _external=True)}}
Happy Hacking!
GoatHacks Team
This is an automated message. Please email hack@wpi.edu with any questions or
concerns

View file

@ -25,6 +25,10 @@
<span><p><em>Don't have an account? <a <span><p><em>Don't have an account? <a
href="{{url_for('registration.register')}}">Register href="{{url_for('registration.register')}}">Register
here</a>.</em></p></span> here</a>.</em></p></span>
<span><p><em>Forgot your password? Head over <a
href="{{url_for('registration.reset')}}">here</a>
to reset it.</em></p></span>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View file

@ -0,0 +1,27 @@
{% extends 'base.html' %}
{% block content %}
<div style="height: 100%;">
<div id="registration-banner" class="parallax-container valign-wrapper">
<div class="section">
<h3 class="header-center text-darken-2">Reset Password</h3>
</div>
</div>
</div>
<div class="container">
<div class="section" style="background-color: #974355; padding: 20px;">
<form method="post">
{{ form.csrf_token }}
<div>
{{form.password}}<br/>{{form.password.label}}
</div>
<div>
{{form.password_confirm}}<br/>{{form.password_confirm.label}}
</div>
<div>
{{form.submit}}
</div>
</form>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,24 @@
{% extends 'base.html' %}
{% block content %}
<div style="height: 100%">
<div id="registration-banner" class="parallax-container valign-wrapper">
<div class="section">
<h3 class="header-center text-darken-2">Reset Password</h3>
</div>
</div>
</div>
<div class="container">
<div class="section" style="background-color: #974355; padding: 20px;">
<form method="post">
{{ form.csrf_token }}
<div>
{{ form.email }}<br/>{{ form.email.label}}
</div>
<div>
{{form.submit}}
</div>
</form>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,34 @@
"""empty message
Revision ID: 8a0c9c00f04c
Revises: db38c3deb0b9
Create Date: 2023-01-03 16:59:23.201953
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '8a0c9c00f04c'
down_revision = 'db38c3deb0b9'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('pw_reset_request', schema=None) as batch_op:
batch_op.add_column(sa.Column('user_id', sa.Integer(), nullable=False))
batch_op.create_foreign_key(None, 'user', ['user_id'], ['id'])
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('pw_reset_request', schema=None) as batch_op:
batch_op.drop_constraint(None, type_='foreignkey')
batch_op.drop_column('user_id')
# ### end Alembic commands ###