From dd27cb68a42c590f4839b7364995cb8a857cb3e5 Mon Sep 17 00:00:00 2001 From: Sindre Stephansen Date: Tue, 17 Mar 2020 21:23:59 +0100 Subject: [PATCH] Implement password reset Fixes #2 --- mysql/sql/init.sql | 9 +-- src/app/models/register.py | 4 +- src/app/models/user.py | 40 ++++++++++- src/app/templates/login.html | 1 + src/app/templates/reset.html | 25 +++++++ src/app/templates/reset_request.html | 25 +++++++ src/app/views/app.py | 3 + src/app/views/forms.py | 30 +++++--- src/app/views/login.py | 9 ++- src/app/views/reset.py | 100 +++++++++++++++++++++++++++ 10 files changed, 230 insertions(+), 16 deletions(-) create mode 100644 src/app/templates/reset.html create mode 100644 src/app/templates/reset_request.html create mode 100644 src/app/views/reset.py diff --git a/mysql/sql/init.sql b/mysql/sql/init.sql index b5e7fbc..b8d4a0d 100755 --- a/mysql/sql/init.sql +++ b/mysql/sql/init.sql @@ -3,6 +3,7 @@ CREATE TABLE users ( userid INT UNSIGNED AUTO_INCREMENT, username VARCHAR(45) UNIQUE NOT NULL, password VARCHAR(60) NOT NULL, + temporary_password VARCHAR(60) NOT NULL DEFAULT "", full_name VARCHAR(200) NOT NULL, company VARCHAR(50), email VARCHAR(50) NOT NULL, @@ -11,10 +12,10 @@ CREATE TABLE users ( state VARCHAR(50), postal_code VARCHAR(50), country VARCHAR(50), - login_attempts INT UNSIGNED, - last_login_attempt INT UNSIGNED, - verified BOOLEAN, - token VARCHAR(50), + login_attempts INT UNSIGNED DEFAULT 0, + last_login_attempt INT UNSIGNED DEFAULT 0, + verified BOOLEAN DEFAULT 0, + token VARCHAR(50) NOT NULL DEFAULT "", PRIMARY KEY (userid) ); diff --git a/src/app/models/register.py b/src/app/models/register.py index a01adfe..9782fde 100755 --- a/src/app/models/register.py +++ b/src/app/models/register.py @@ -37,8 +37,8 @@ def set_user(username, password, full_name, company, email, query = (""" INSERT INTO users (userid, username, password, full_name, company, email, street_address, city, state, postal_code, - country, login_attempts, last_login_attempt, verified, token) - VALUES (NULL, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 0, 0, 0, %s) + country, token) + VALUES (NULL, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) """) try: cursor.execute(query, (username, password, full_name, company, email, diff --git a/src/app/models/user.py b/src/app/models/user.py index 9b379c5..3cf3d72 100755 --- a/src/app/models/user.py +++ b/src/app/models/user.py @@ -15,7 +15,7 @@ def get_user(username): """ db.connect() cursor = db.cursor() - query = ("SELECT userid, username, password, login_attempts, last_login_attempt from users where username = %s") + query = ("SELECT userid, username, email, password, temporary_password, login_attempts, last_login_attempt from users where username = %s") user = None try: cursor.execute(query, (username,)) @@ -54,6 +54,44 @@ def get_users(): return users +def set_temporary_password(userid, password): + """ + Set a temporary password for a user + """ + db.connect() + cursor = db.cursor() + query = ("UPDATE users SET temporary_password = %s WHERE userid = %s") + try: + cursor.execute(query, (password, userid)) + db.commit() + except mysql.connector.Error as err: + logger.error("Failed executing query: %s", err) + cursor.fetchall() + exit(1) + finally: + cursor.close() + db.close() + + +def set_password(userid, password): + """ + Set the password for a user + """ + db.connect() + cursor = db.cursor() + query = ("UPDATE users SET password = %s WHERE userid = %s") + try: + cursor.execute(query, (password, userid)) + db.commit() + except mysql.connector.Error as err: + logger.error("Failed executing query: %s", err) + cursor.fetchall() + exit(1) + finally: + cursor.close() + db.close() + + def set_login_attempts(userid, num, timestamp): """ Set the number and timestamp of the failed login attempts for the given user. diff --git a/src/app/templates/login.html b/src/app/templates/login.html index c98949a..d6e1199 100755 --- a/src/app/templates/login.html +++ b/src/app/templates/login.html @@ -17,6 +17,7 @@ $def with (nav, login_form, message)
$:csrf_field() $:login_form.render() + Forgot your password?
$else: diff --git a/src/app/templates/reset.html b/src/app/templates/reset.html new file mode 100644 index 0000000..108f17c --- /dev/null +++ b/src/app/templates/reset.html @@ -0,0 +1,25 @@ +$def with (nav, reset_form, message) + + + Beelance2 + + + + + + + + + $:nav + +

Reset password

+ +
+ $:csrf_field() + $:reset_form.render() +
+ +

$message

+ + + diff --git a/src/app/templates/reset_request.html b/src/app/templates/reset_request.html new file mode 100644 index 0000000..0445b2a --- /dev/null +++ b/src/app/templates/reset_request.html @@ -0,0 +1,25 @@ +$def with (nav, reset_request_form, message) + + + Beelance2 + + + + + + + + + $:nav + +

Reset password

+ +
+ $:csrf_field() + $:reset_request_form.render() +
+ +

$message

+ + + diff --git a/src/app/views/app.py b/src/app/views/app.py index 2eee3fc..837227f 100755 --- a/src/app/views/app.py +++ b/src/app/views/app.py @@ -3,6 +3,7 @@ import web from views.login import Login from views.logout import Logout from views.register import Register, Verify +from views.reset import RequestReset, Reset from views.new_project import New_project from views.open_projects import Open_projects from views.project import Project @@ -18,6 +19,8 @@ urls = ( '/login', 'Login', '/logout', 'Logout', '/verify', 'Verify', + '/reset', 'Reset', + '/request_reset', 'RequestReset', '/register', 'Register', '/new_project', 'New_project', '/open_projects', 'Open_projects', diff --git a/src/app/views/forms.py b/src/app/views/forms.py index be9af5f..41d25a2 100755 --- a/src/app/views/forms.py +++ b/src/app/views/forms.py @@ -1,5 +1,5 @@ from web import form -from models.project import get_categories +from models.project import get_categories from models.user import get_users, get_user_id_by_name @@ -9,7 +9,7 @@ vpass = form.regexp(r".{6,100}$", '- Must be atleast 6 characters long') number = form.regexp(r"^[0-9]+$", "- Must be a number") not_empty = form.regexp(r".+", "- This field is required") -# Define the login form +# Define the login form login_form = form.Form( form.Textbox("username", description="Username"), form.Password("password", description="Password"), @@ -17,7 +17,7 @@ login_form = form.Form( form.Button("Log In", type="submit", description="Login"), ) -# Define the register form +# Define the register form register_form = form.Form( form.Textbox("username", not_empty, description="Username"), form.Textbox("full_name", not_empty, description="Full name"), @@ -32,6 +32,20 @@ register_form = form.Form( form.Button("Register", type="submit", description="Register") ) +# Define the reset forms +request_reset_form = form.Form( + form.Textbox("username", description="Username"), + form.Textbox("email", description="Email address"), + form.Button("Submit", type="submit", description="Submit"), +) + +reset_form = form.Form( + form.Password("temporary", description="Temporary password"), + form.Password("password", description="New password"), + form.Password("repeat", description="Repeat new password"), + form.Button("Change password", type="submit", description="Change password"), +) + # Define the project view form project_form = form.Form( form.Input("myfile", type="file"), @@ -47,7 +61,7 @@ def get_task_form_elements(identifier=0, task_title="", task_description="", bud Generate a set of task form elements :param identifier: The id of the task :param task_title: Task title - :param task_description: Task description + :param task_description: Task description :param budget: Task budget :type identifier: int, str :type task_title: str @@ -66,12 +80,12 @@ def get_project_form_elements(project_title="", project_description="", category """ Generate a set of project form elements :param project_title: Project title - :param project_description: Project description + :param project_description: Project description :param category_name: Name of the belonging category :type project_title: str :type project_description: str :type category_name: str - :return: A set of project form elements + :return: A set of project form elements """ categories = get_categories() project_form_elements = ( @@ -97,7 +111,7 @@ def get_user_form_elements(identifier=0, user_name="", read_permission=True, wri :return: The form elements to add users to a project """ user_form_elements = ( - form.Textbox("user_name_" + str(identifier), description="User", value=user_name, placeholder="Leave blank for open project"), + form.Textbox("user_name_" + str(identifier), description="User", value=user_name, placeholder="Leave blank for open project"), form.Checkbox("read_permission_" + str(identifier), description="Read Permission", checked=read_permission, value=True), form.Checkbox("write_permission_" + str(identifier), description="Write Permission", checked=write_permission, value=True), form.Checkbox("modify_permission_" + str(identifier), description="Modify Permission", checked=modify_permission, value=True) @@ -147,5 +161,5 @@ def get_apply_permissions_form(identifier=0, read_permission="TRUE", write_permi form.Checkbox("read_permission_" + str(identifier), description="Read Permission", checked=(read_permission=="TRUE"), value=True), form.Checkbox("write_permission_" + str(identifier), description="Write Permission", checked=(write_permission=="TRUE"), value=True), form.Checkbox("modify_permission_" + str(identifier), description="Modify Permission", checked=(modify_permission=="TRUE"), value=True) - ) + ) return user_permissions diff --git a/src/app/views/login.py b/src/app/views/login.py index 2e7426d..dfaf80e 100755 --- a/src/app/views/login.py +++ b/src/app/views/login.py @@ -55,7 +55,7 @@ class Login(): if user is None: return render.login(nav, login_form, "- User authentication failed") - userid, username, password_hash, login_attempts, last_login_attempt = user + userid, username, _, password_hash, temporary_password, login_attempts, last_login_attempt = user if login_attempts > login_attempts_threshold and last_login_attempt + login_timeout > time.time(): return render.login(nav, login_form, "- There have been too many incorrect login attempts for your account. You have to wait a minute before you can log in.") @@ -67,9 +67,16 @@ class Login(): if not models.user.is_verified(userid): return render.login(nav, login_form, "- User not authenticated yet. Please check you email.") + if temporary_password: + logger.info("A password reset was requested for user %s, but they logged in with the old password before resetting") + models.user.set_temporary_password(userid, "") + models.user.set_login_attempts(userid, 0, time.time()) self.login(username, userid, data.remember) raise web.seeother("/") + elif temporary_password and bcrypt.checkpw(data.password.encode('UTF-8'), temporary_password.encode('UTF-8')): + session.temporary_userid = userid + raise web.seeother("/reset") else: logger.warning("Incorrect login attempt on user %s by IP %s", username, web.ctx.ip) models.user.set_login_attempts(userid, login_attempts+1, time.time()) diff --git a/src/app/views/reset.py b/src/app/views/reset.py new file mode 100644 index 0000000..0dfe1dd --- /dev/null +++ b/src/app/views/reset.py @@ -0,0 +1,100 @@ +import web +from uuid import uuid4 +from views.forms import reset_form, request_reset_form +from views.utils import get_nav_bar, csrf_protected, get_render, password_weakness, sendmail +import models.user +import logging +import bcrypt + +logger = logging.getLogger(__name__) + + +class RequestReset: + def GET(self): + session = web.ctx.session + nav = get_nav_bar(session) + + return get_render().reset_request(nav, request_reset_form, "") + + @csrf_protected + def POST(self): + session = web.ctx.session + nav = get_nav_bar(session) + data = web.input(username="", email="") + render = get_render() + + user = models.user.get_user(data.username) + if user and user[2] == data.email: + password = uuid4().hex + password_hash = bcrypt.hashpw(password.encode('UTF-8'), bcrypt.gensalt()) + models.user.set_temporary_password(user[0], password_hash) + + sendmail( + 'Reset your Beelance password', + """ + Hi! + + Someone requested a password reset for your account. If that wasn't you you can ignore this email. + + If you want to reset your password, log in with this password: {password} + Then you will be able to set a new password. + """.format(password=password), + "", + data.email, + ) + + logger.info("User %s requested a password reset", data.username) + else: + logger.info("Incorrect reset request with username %s and email %s", data.username, data.email) + + return render.reset_request(nav, request_reset_form, "An email has been sent, if the username and email is correct") + + +class Reset: + def GET(self): + session = web.ctx.session + nav = get_nav_bar(session) + render = get_render() + + if 'temporary_userid' not in session or not session.temporary_userid: + return render.reset(nav, reset_form, "Something went wrong. Try logging in with the temporary password again.") + return get_render().reset(nav, reset_form, "") + + @csrf_protected + def POST(self): + session = web.ctx.session + nav = get_nav_bar(session) + data = web.input(temporary="", password="", repeat="") + render = get_render() + + if 'temporary_userid' not in session or not session.temporary_userid: + return render.reset(nav, reset_form, "Something went wrong. Try logging in with the temporary password again.") + + userid = session.temporary_userid + username = models.user.get_user_name_by_id(userid) + user = models.user.get_user(username) + temporary_password = user[4] + + # Check that the temporary password is correct + if not bcrypt.checkpw(data.temporary.encode('UTF-8'), temporary_password.encode('UTF-8')): + return render.reset(nav, reset_form, "Incorrect temporary password") + + # Check that the passwords match + if data.password != data.repeat: + return render.reset(nav, reset_form, "The repeated password doesn't match the first") + + # Check password security + weakness = password_weakness(data.password, username) + if weakness is not None: + return render.reset(nav, reset_form, weakness) + + # Set the new password and log the user in + password_hash = bcrypt.hashpw(data.password.encode('UTF-8'), bcrypt.gensalt()) + models.user.set_password(userid, password_hash) + models.user.set_temporary_password(userid, "") + + session.temporary_userid = None + + logger.info("User %s has changed their password", username) + + return get_render().reset(nav, reset_form, "Your password has been reset. You can log in again now.")