From 0f73db5e45a04f60f20edce1779a74102a1d54b2 Mon Sep 17 00:00:00 2001 From: Sage Vaillancourt Date: Mon, 26 Sep 2022 01:33:09 +0000 Subject: [PATCH] Add functionality for password-reset emails. Start building up multi-template feature. Redirect to homepage and display error instead of returning plaintext. Make usernames case-insensitive. --- requirements.txt | 1 + undercover/db.py | 43 +++++++++++++-- undercover/pdf_builder.py | 2 + undercover/routes.py | 86 ++++++++++++++++++++++------- undercover/templates/writing.jinja2 | 6 +- 5 files changed, 113 insertions(+), 25 deletions(-) diff --git a/requirements.txt b/requirements.txt index c35fc13..18bfade 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,6 +7,7 @@ Flask-SQLAlchemy==2.5.1 gunicorn==20.1.0 itsdangerous==2.1.2 Jinja2==3.1.2 +mailjet-rest==1.3.4 MarkupSafe==2.1.1 psycopg==3.1.2 pytest==7.1.3 diff --git a/undercover/db.py b/undercover/db.py index 78c27e5..e37c1b1 100644 --- a/undercover/db.py +++ b/undercover/db.py @@ -1,3 +1,4 @@ +from uuid import uuid4, UUID import os from dataclasses import dataclass from typing import Optional @@ -49,15 +50,18 @@ def login(user_email: str, password: str): return False -def add_user(username: str, password: str): +def __gen_pw_hash(password: str): pw_bytes = password.encode('utf-8') salt = bcrypt.gensalt() pw_hash = bcrypt.hashpw(pw_bytes, salt) - decoded = pw_hash.decode('utf-8') + return pw_hash.decode('utf-8') + +def add_user(username: str, password: str): + pw_hash = __gen_pw_hash(password) with connect() as con: cur = con.cursor() - cur.execute("INSERT INTO users(email, password) VALUES (%s, %s)", (username, decoded)) + cur.execute("INSERT INTO users(email, password) VALUES (%s, %s)", (username, pw_hash)) con.commit() @@ -112,7 +116,7 @@ def __get_user(email: str) -> Optional[UserWithHash]: """ with connect() as con: cur = con.cursor() - cur.execute("SELECT id, password FROM users WHERE users.email = %s", (email,)) + cur.execute("SELECT id, password FROM users WHERE users.email ILIKE %s", (email,)) row = cur.fetchone() if row: user_id, password = row @@ -127,6 +131,37 @@ def get_users() -> [UserWithHash]: return map(lambda row: UserWithHash(row[0], row[1], row[2]), cur.fetchall()) +def initiate_password_reset(email: str) -> Optional[UUID]: + user = get_user(email) + if not user: + return None + reset_id = uuid4() + with connect() as con: + cur = con.cursor() + cur.execute( + "INSERT INTO resets(user_id, id, reset_time) VALUES (%s, %s, NOW())", + (user.id, reset_id) + ) + con.commit() + return reset_id + + +def complete_reset(reset_id: str, new_password: str): + with connect() as con: + cur = con.cursor() + cur.execute("SELECT reset_time, user_id FROM resets WHERE id = %s", (reset_id,)) + row = cur.fetchone() + if row: + reset_time, user_id = row + if reset_time: # TODO: And reset_time is not too far in the past + cur.execute("DELETE FROM resets WHERE id = %s", (reset_id,)) + password_hash = __gen_pw_hash(new_password) + cur.execute("UPDATE users SET password = %s WHERE id = %s", (password_hash, user_id)) + con.commit() + return True + return False + + if __name__ == "__main__": add_user("hash_man", "hashword") print("Can pull correctly: " + str(login("hash_man", "hashword"))) diff --git a/undercover/pdf_builder.py b/undercover/pdf_builder.py index 064e974..cdc9bb0 100644 --- a/undercover/pdf_builder.py +++ b/undercover/pdf_builder.py @@ -27,6 +27,8 @@ base_tex_text = open(proj_dir + "/letter_templates/base.tex", "r").read() @dataclass class CLData: + selectedLetter: int # Metadata + username: str company: str jobAndPronoun: str diff --git a/undercover/routes.py b/undercover/routes.py index 74476f7..afa69b8 100644 --- a/undercover/routes.py +++ b/undercover/routes.py @@ -7,16 +7,23 @@ import threading import urllib.parse from flask import (Blueprint, render_template, request, make_response, session, redirect, jsonify) -from wtforms import Form, StringField, TextAreaField, validators +from wtforms import Form, SelectField, StringField, TextAreaField, validators from email_validator import validate_email, EmailNotValidError import undercover.db as db +import undercover.email as email from undercover.pdf_builder import CLData writing_blueprint = Blueprint('writing', __name__,) class CLForm(Form): + letterName = SelectField( + 'Letter Name:', + [validators.optional()], + choices=[(1, 'LETTER TITLE')] + ) + username = StringField( 'Username:', [validators.Length(min=4, max=99)], @@ -71,7 +78,12 @@ def login(): if db.login(username, request.form['password']): session['username'] = username return redirect('/') - return make_response("", 401) + return make_response( + render_template( + 'writing.jinja2', + form=CLForm(), + error="Invalid username or password" + ), 401) return '''
@@ -90,15 +102,19 @@ def logout(): @writing_blueprint.route('/', methods=['GET']) def index_get(): - email = session.get('username') + email_address = session.get('username') form = CLForm() - if email: - user = db.get_user(email) + if email_address: + user = db.get_user(email_address) letters = db.get_user_letters(user.id) if len(letters) > 0: + letter_names = [(i + 1, letter.title) for i, letter in enumerate(letters)] + form.letterName.choices = letter_names # TODO: Load this data more dynamically + # I.e. use a dictionary instead of explicitly-defined fields data = json.loads(letters[0].contents) + form.letterName.data = 1 form.company.data = data['company'] form.body.data = data['body'] form.closingText.data = data['closingText'] @@ -121,21 +137,49 @@ def create_account_page(): @writing_blueprint.route('/create_account', methods=['POST']) def create_account(): - email = request.form['login'] + email_address = request.form['login'] try: - validate_email(email, check_deliverability=True) + validate_email(email_address, check_deliverability=True) except EmailNotValidError as e: - form = CLForm(request.form) - return make_response(render_template('writing.jinja2', form=form, error=str(e)), 401) + return make_response(render_template('writing.jinja2', form=CLForm(), error=str(e)), 401) - if db.get_user(email): - return make_response("A user with that email already exists!", 401) + if db.get_user(email_address): + return make_response(render_template('writing.jinja2', form=CLForm(), error="A user with that email already exists!"), 401) - db.add_user(email, request.form['password']) - session['username'] = email + db.add_user(email_address, request.form['password']) + session['username'] = email_address return redirect('/') +@writing_blueprint.route('/reset', methods=['POST', 'GET']) +def reset_password(): + if request.method == 'POST': + email_address = request.form.get('login') + existing_reset_id = request.form.get('reset_id') + if email_address: + reset_id = db.initiate_password_reset(email_address) + if reset_id: + email.send_password_reset(email_address, 'https://undercover.cafe/reset?id=' + str(reset_id)) + # TODO: Eventually remove db entry whether or not link is clicked + elif existing_reset_id: + new_password = request.form['password'] + db.complete_reset(existing_reset_id, new_password) + # TODO: Log in? + + return redirect('/') + + query_reset_id = request.args.get('id') + # TODO: Add password validation + return f''' + +

+ +

+

+
+ ''' + + @writing_blueprint.route('/dbtest', methods=['GET']) def db_test_get(): response = make_response(db.get_user_letters(1)[0].contents, 200) @@ -170,7 +214,10 @@ def git_update(): def index_post(): form = CLForm(request.form) if form.validate(): + selected_letter = form.letterName.data or '1' + data = CLData( + selectedLetter=int(selected_letter) - 1, username=form.username.data, company=form.company.data, jobAndPronoun=form.jobAndPronoun.data, @@ -180,17 +227,16 @@ def index_post(): body=form.body.data, ) - email = session.get('username') - if email: - user = db.get_user(email) + email_address = session.get('username') + if email_address: + user = db.get_user(email_address) letters = db.get_user_letters(user.id) letter_json = jsonify(data).get_data(True) if len(letters) == 0: - db.add_letter(user.id, 'LETTER_TITLE', letter_json) + db.add_letter(user.id, 'My Cover Letter', letter_json) else: - # TODO: Add letter editing! - # For now, edit just the one existing letter - letter = letters[0] + letter = letters[data.selectedLetter] + # TODO: Support title editing db.edit_letter(letter.id, letter.title, letter_json) (resp, errors) = data.generate_pdf() diff --git a/undercover/templates/writing.jinja2 b/undercover/templates/writing.jinja2 index e1f0549..3183340 100644 --- a/undercover/templates/writing.jinja2 +++ b/undercover/templates/writing.jinja2 @@ -1,4 +1,5 @@ {% extends "base.jinja2" %} +{% from "_formhelpers.jinja2" import render_field %} {% block title %}UnderCover{% endblock %} @@ -51,15 +52,18 @@
+
{% endif %} - {% from "_formhelpers.jinja2" import render_field %}
+ {% if username == 'sage@sagev.space' %} + {{ render_field(form.letterName) }} + {% endif %} {{ render_field(form.username) }} {{ render_field(form.company) }} {{ render_field(form.jobAndPronoun) }}