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.
This commit is contained in:
Sage Vaillancourt 2022-09-26 01:33:09 +00:00
parent 98e385376c
commit 0f73db5e45
5 changed files with 113 additions and 25 deletions

View File

@ -7,6 +7,7 @@ Flask-SQLAlchemy==2.5.1
gunicorn==20.1.0 gunicorn==20.1.0
itsdangerous==2.1.2 itsdangerous==2.1.2
Jinja2==3.1.2 Jinja2==3.1.2
mailjet-rest==1.3.4
MarkupSafe==2.1.1 MarkupSafe==2.1.1
psycopg==3.1.2 psycopg==3.1.2
pytest==7.1.3 pytest==7.1.3

View File

@ -1,3 +1,4 @@
from uuid import uuid4, UUID
import os import os
from dataclasses import dataclass from dataclasses import dataclass
from typing import Optional from typing import Optional
@ -49,15 +50,18 @@ def login(user_email: str, password: str):
return False return False
def add_user(username: str, password: str): def __gen_pw_hash(password: str):
pw_bytes = password.encode('utf-8') pw_bytes = password.encode('utf-8')
salt = bcrypt.gensalt() salt = bcrypt.gensalt()
pw_hash = bcrypt.hashpw(pw_bytes, salt) 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: with connect() as con:
cur = con.cursor() 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() con.commit()
@ -112,7 +116,7 @@ def __get_user(email: str) -> Optional[UserWithHash]:
""" """
with connect() as con: with connect() as con:
cur = con.cursor() 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() row = cur.fetchone()
if row: if row:
user_id, password = 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()) 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__": if __name__ == "__main__":
add_user("hash_man", "hashword") add_user("hash_man", "hashword")
print("Can pull correctly: " + str(login("hash_man", "hashword"))) print("Can pull correctly: " + str(login("hash_man", "hashword")))

View File

@ -27,6 +27,8 @@ base_tex_text = open(proj_dir + "/letter_templates/base.tex", "r").read()
@dataclass @dataclass
class CLData: class CLData:
selectedLetter: int # Metadata
username: str username: str
company: str company: str
jobAndPronoun: str jobAndPronoun: str

View File

@ -7,16 +7,23 @@ import threading
import urllib.parse import urllib.parse
from flask import (Blueprint, render_template, request, make_response, session, redirect, jsonify) 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 from email_validator import validate_email, EmailNotValidError
import undercover.db as db import undercover.db as db
import undercover.email as email
from undercover.pdf_builder import CLData from undercover.pdf_builder import CLData
writing_blueprint = Blueprint('writing', __name__,) writing_blueprint = Blueprint('writing', __name__,)
class CLForm(Form): class CLForm(Form):
letterName = SelectField(
'Letter Name:',
[validators.optional()],
choices=[(1, 'LETTER TITLE')]
)
username = StringField( username = StringField(
'Username:', 'Username:',
[validators.Length(min=4, max=99)], [validators.Length(min=4, max=99)],
@ -71,7 +78,12 @@ def login():
if db.login(username, request.form['password']): if db.login(username, request.form['password']):
session['username'] = username session['username'] = username
return redirect('/') return redirect('/')
return make_response("", 401) return make_response(
render_template(
'writing.jinja2',
form=CLForm(),
error="Invalid username or password"
), 401)
return ''' return '''
<form method="post"> <form method="post">
@ -90,15 +102,19 @@ def logout():
@writing_blueprint.route('/', methods=['GET']) @writing_blueprint.route('/', methods=['GET'])
def index_get(): def index_get():
email = session.get('username') email_address = session.get('username')
form = CLForm() form = CLForm()
if email: if email_address:
user = db.get_user(email) user = db.get_user(email_address)
letters = db.get_user_letters(user.id) letters = db.get_user_letters(user.id)
if len(letters) > 0: 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 # TODO: Load this data more dynamically
# I.e. use a dictionary instead of explicitly-defined fields
data = json.loads(letters[0].contents) data = json.loads(letters[0].contents)
form.letterName.data = 1
form.company.data = data['company'] form.company.data = data['company']
form.body.data = data['body'] form.body.data = data['body']
form.closingText.data = data['closingText'] form.closingText.data = data['closingText']
@ -121,21 +137,49 @@ def create_account_page():
@writing_blueprint.route('/create_account', methods=['POST']) @writing_blueprint.route('/create_account', methods=['POST'])
def create_account(): def create_account():
email = request.form['login'] email_address = request.form['login']
try: try:
validate_email(email, check_deliverability=True) validate_email(email_address, check_deliverability=True)
except EmailNotValidError as e: except EmailNotValidError as e:
form = CLForm(request.form) return make_response(render_template('writing.jinja2', form=CLForm(), error=str(e)), 401)
return make_response(render_template('writing.jinja2', form=form, error=str(e)), 401)
if db.get_user(email): if db.get_user(email_address):
return make_response("A user with that email already exists!", 401) 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']) db.add_user(email_address, request.form['password'])
session['username'] = email session['username'] = email_address
return redirect('/') 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'''
<form formaction="/reset" method="post">
<p><input type=hidden name=reset_id value="{query_reset_id}"></p>
<label>New Password:</label>
<p><input type=password name=password></p>
<p><input type=submit value="Save Password"></p>
</form>
'''
@writing_blueprint.route('/dbtest', methods=['GET']) @writing_blueprint.route('/dbtest', methods=['GET'])
def db_test_get(): def db_test_get():
response = make_response(db.get_user_letters(1)[0].contents, 200) response = make_response(db.get_user_letters(1)[0].contents, 200)
@ -170,7 +214,10 @@ def git_update():
def index_post(): def index_post():
form = CLForm(request.form) form = CLForm(request.form)
if form.validate(): if form.validate():
selected_letter = form.letterName.data or '1'
data = CLData( data = CLData(
selectedLetter=int(selected_letter) - 1,
username=form.username.data, username=form.username.data,
company=form.company.data, company=form.company.data,
jobAndPronoun=form.jobAndPronoun.data, jobAndPronoun=form.jobAndPronoun.data,
@ -180,17 +227,16 @@ def index_post():
body=form.body.data, body=form.body.data,
) )
email = session.get('username') email_address = session.get('username')
if email: if email_address:
user = db.get_user(email) user = db.get_user(email_address)
letters = db.get_user_letters(user.id) letters = db.get_user_letters(user.id)
letter_json = jsonify(data).get_data(True) letter_json = jsonify(data).get_data(True)
if len(letters) == 0: if len(letters) == 0:
db.add_letter(user.id, 'LETTER_TITLE', letter_json) db.add_letter(user.id, 'My Cover Letter', letter_json)
else: else:
# TODO: Add letter editing! letter = letters[data.selectedLetter]
# For now, edit just the one existing letter # TODO: Support title editing
letter = letters[0]
db.edit_letter(letter.id, letter.title, letter_json) db.edit_letter(letter.id, letter.title, letter_json)
(resp, errors) = data.generate_pdf() (resp, errors) = data.generate_pdf()

View File

@ -1,4 +1,5 @@
{% extends "base.jinja2" %} {% extends "base.jinja2" %}
{% from "_formhelpers.jinja2" import render_field %}
{% block title %}UnderCover{% endblock %} {% block title %}UnderCover{% endblock %}
@ -51,15 +52,18 @@
<div> <div>
<input class="black-white-button" type="submit" value="Create Account" formaction="/create_account"> <input class="black-white-button" type="submit" value="Create Account" formaction="/create_account">
<input class="black-white-button" type="submit" value="Forgot Password" formaction="/reset">
<input class="white-black-button login-button" type="submit" value="Login"> <input class="white-black-button login-button" type="submit" value="Login">
</div> </div>
</form> </form>
</div> </div>
{% endif %} {% endif %}
{% from "_formhelpers.jinja2" import render_field %}
<form method=post id="letter-form"> <form method=post id="letter-form">
<dl> <dl>
{% if username == 'sage@sagev.space' %}
{{ render_field(form.letterName) }}
{% endif %}
{{ render_field(form.username) }} {{ render_field(form.username) }}
{{ render_field(form.company) }} {{ render_field(form.company) }}
{{ render_field(form.jobAndPronoun) }} {{ render_field(form.jobAndPronoun) }}