From 41bad2b8b0a9eaa34f03b6071ad7f301f9e9ebda Mon Sep 17 00:00:00 2001 From: Cara Salter Date: Fri, 30 Dec 2022 14:35:39 -0500 Subject: [PATCH 1/5] models: Add PwResetRequest model --- goathacks/models.py | 5 +++++ migrations/versions/db38c3deb0b9_.py | 31 ++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 migrations/versions/db38c3deb0b9_.py diff --git a/goathacks/models.py b/goathacks/models.py index b368749..fb6d7ac 100644 --- a/goathacks/models.py +++ b/goathacks/models.py @@ -50,3 +50,8 @@ def user_loader(user_id): def unauth(): flash("Please login first") return redirect(url_for("registration.register")) + + +class PwResetRequest(db.Model): + id = Column(String, primary_key=True) + user_id = db.relationship("User") diff --git a/migrations/versions/db38c3deb0b9_.py b/migrations/versions/db38c3deb0b9_.py new file mode 100644 index 0000000..bdb87c2 --- /dev/null +++ b/migrations/versions/db38c3deb0b9_.py @@ -0,0 +1,31 @@ +"""empty message + +Revision ID: db38c3deb0b9 +Revises: a14a95ec57b0 +Create Date: 2022-12-30 14:35:27.652423 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'db38c3deb0b9' +down_revision = 'a14a95ec57b0' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('pw_reset_request', + sa.Column('id', sa.String(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('pw_reset_request') + # ### end Alembic commands ### From 952df13136f339635fa11807112d7beb93fe46a0 Mon Sep 17 00:00:00 2001 From: Cara Salter Date: Fri, 30 Dec 2022 14:38:01 -0500 Subject: [PATCH 2/5] meta: Add ulid dependency --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index 107cdcf..f4c8239 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,3 +21,4 @@ SQLAlchemy==1.4.44 uWSGI==2.0.21 Werkzeug==2.2.2 WTForms==3.0.1 +ulid From 60953074e790466684870a4fb8b6fd49c7dee928 Mon Sep 17 00:00:00 2001 From: Cara Salter Date: Tue, 3 Jan 2023 17:43:17 -0500 Subject: [PATCH 3/5] 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? --- goathacks/__init__.py | 7 +- goathacks/models.py | 4 +- goathacks/registration/__init__.py | 69 ++++++++++++++++++- goathacks/registration/forms.py | 8 +++ goathacks/templates/emails/password_reset.txt | 13 ++++ goathacks/templates/login.html | 4 ++ goathacks/templates/password_reset.html | 27 ++++++++ goathacks/templates/pw_reset.html | 24 +++++++ migrations/versions/8a0c9c00f04c_.py | 34 +++++++++ 9 files changed, 184 insertions(+), 6 deletions(-) create mode 100644 goathacks/templates/emails/password_reset.txt create mode 100644 goathacks/templates/password_reset.html create mode 100644 goathacks/templates/pw_reset.html create mode 100644 migrations/versions/8a0c9c00f04c_.py diff --git a/goathacks/__init__.py b/goathacks/__init__.py index 6530201..d11608c 100644 --- a/goathacks/__init__.py +++ b/goathacks/__init__.py @@ -4,7 +4,7 @@ from flask_migrate import Migrate from flask_login import LoginManager from flask_assets import Bundle, Environment from flask_cors import CORS -from flask_mail import Mail +from flask_mail import Mail, email_dispatched db = SQLAlchemy() @@ -75,6 +75,11 @@ def create_app(): def 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 diff --git a/goathacks/models.py b/goathacks/models.py index fb6d7ac..2eb9b50 100644 --- a/goathacks/models.py +++ b/goathacks/models.py @@ -1,6 +1,6 @@ from flask import flash, redirect, url_for 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 login @@ -54,4 +54,4 @@ def unauth(): class PwResetRequest(db.Model): id = Column(String, primary_key=True) - user_id = db.relationship("User") + user_id = Column(Integer, ForeignKey('user.id'), nullable=False) diff --git a/goathacks/registration/__init__.py b/goathacks/registration/__init__.py index 671cdd3..c48a04e 100644 --- a/goathacks/registration/__init__.py +++ b/goathacks/registration/__init__.py @@ -1,13 +1,14 @@ 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 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 flask_mail import Message +import ulid 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") @@ -81,6 +82,9 @@ def login(): password = request.form.get('password') 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): flask_login.login_user(user) @@ -92,3 +96,62 @@ def login(): flash("Incorrect password") 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/", 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) diff --git a/goathacks/registration/forms.py b/goathacks/registration/forms.py index 3fe74c5..7898b00 100644 --- a/goathacks/registration/forms.py +++ b/goathacks/registration/forms.py @@ -23,3 +23,11 @@ class LoginForm(FlaskForm): password = PasswordField("Password", validators=[DataRequired()]) 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") diff --git a/goathacks/templates/emails/password_reset.txt b/goathacks/templates/emails/password_reset.txt new file mode 100644 index 0000000..bfc2ae5 --- /dev/null +++ b/goathacks/templates/emails/password_reset.txt @@ -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 diff --git a/goathacks/templates/login.html b/goathacks/templates/login.html index 3366ab9..57a3abe 100644 --- a/goathacks/templates/login.html +++ b/goathacks/templates/login.html @@ -25,6 +25,10 @@

Don't have an account? Register here.

+ +

Forgot your password? Head over here + to reset it.

{% endblock %} diff --git a/goathacks/templates/password_reset.html b/goathacks/templates/password_reset.html new file mode 100644 index 0000000..582fb82 --- /dev/null +++ b/goathacks/templates/password_reset.html @@ -0,0 +1,27 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+
+

Reset Password

+
+
+
+
+
+
+ {{ form.csrf_token }} +
+ {{form.password}}
{{form.password.label}} +
+
+ {{form.password_confirm}}
{{form.password_confirm.label}} +
+
+ {{form.submit}} +
+
+
+
+{% endblock %} diff --git a/goathacks/templates/pw_reset.html b/goathacks/templates/pw_reset.html new file mode 100644 index 0000000..15722ab --- /dev/null +++ b/goathacks/templates/pw_reset.html @@ -0,0 +1,24 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+
+

Reset Password

+
+
+
+
+
+
+ {{ form.csrf_token }} +
+ {{ form.email }}
{{ form.email.label}} +
+
+ {{form.submit}} +
+
+
+
+{% endblock %} diff --git a/migrations/versions/8a0c9c00f04c_.py b/migrations/versions/8a0c9c00f04c_.py new file mode 100644 index 0000000..dd7524f --- /dev/null +++ b/migrations/versions/8a0c9c00f04c_.py @@ -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 ### From e49e329f688b82f0f010c5a4d5411a72d7173427 Mon Sep 17 00:00:00 2001 From: Cara Salter Date: Tue, 3 Jan 2023 18:00:41 -0500 Subject: [PATCH 4/5] registration: Expire password requests after 30 minutes Need to amend email to make this clear --- goathacks/models.py | 3 ++- goathacks/registration/__init__.py | 11 ++++++++-- migrations/versions/261c004968a4_.py | 32 ++++++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 3 deletions(-) create mode 100644 migrations/versions/261c004968a4_.py diff --git a/goathacks/models.py b/goathacks/models.py index 2eb9b50..03d206d 100644 --- a/goathacks/models.py +++ b/goathacks/models.py @@ -1,6 +1,6 @@ from flask import flash, redirect, url_for from flask_login import UserMixin -from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String +from sqlalchemy import Boolean, Column, Date, DateTime, ForeignKey, Integer, String from . import db from . import login @@ -55,3 +55,4 @@ def unauth(): class PwResetRequest(db.Model): id = Column(String, primary_key=True) user_id = Column(Integer, ForeignKey('user.id'), nullable=False) + expires = Column(DateTime, nullable=False) diff --git a/goathacks/registration/__init__.py b/goathacks/registration/__init__.py index c48a04e..63b7888 100644 --- a/goathacks/registration/__init__.py +++ b/goathacks/registration/__init__.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime, timedelta from flask import Blueprint, abort, config, current_app, flash, redirect, render_template, request, url_for import flask_login from flask_login import current_user @@ -112,7 +112,8 @@ def reset(): else: r = PwResetRequest( id=str(ulid.ulid()), - user_id=user.id + user_id=user.id, + expires=datetime.now() + timedelta(minutes=30) ) db.session.add(r) db.session.commit() @@ -136,6 +137,12 @@ def do_reset(id): flash("Invalid request") return redirect(url_for("registration.login")) + if req.expires < datetime.now(): + db.session.delete(req) + db.session.commit() + 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") diff --git a/migrations/versions/261c004968a4_.py b/migrations/versions/261c004968a4_.py new file mode 100644 index 0000000..bfddb2b --- /dev/null +++ b/migrations/versions/261c004968a4_.py @@ -0,0 +1,32 @@ +"""empty message + +Revision ID: 261c004968a4 +Revises: 8a0c9c00f04c +Create Date: 2023-01-03 17:58:35.801660 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '261c004968a4' +down_revision = '8a0c9c00f04c' +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('expires', sa.DateTime(), nullable=False)) + + # ### 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_column('expires') + + # ### end Alembic commands ### From 5cb4b7582dafced618808e58e862d9081cd65d0f Mon Sep 17 00:00:00 2001 From: Cara Salter Date: Tue, 3 Jan 2023 18:01:23 -0500 Subject: [PATCH 5/5] registration: Amend password reset email to include expiration --- goathacks/templates/emails/password_reset.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/goathacks/templates/emails/password_reset.txt b/goathacks/templates/emails/password_reset.txt index bfc2ae5..a669997 100644 --- a/goathacks/templates/emails/password_reset.txt +++ b/goathacks/templates/emails/password_reset.txt @@ -5,6 +5,8 @@ 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)}} +This link will expire in 30 minutes. + Happy Hacking! GoatHacks Team