From b0bd63d0a18998e7bbb339d4fe0ae323dfe6638e Mon Sep 17 00:00:00 2001 From: Sindre Stephansen Date: Tue, 17 Mar 2020 18:04:08 +0100 Subject: [PATCH 1/8] Implement email. It almost works The email works when sent from app.py, but not from any other file. Also, it requires mysql-connector-python version 8.0.5, for some reason. Right now the email is logged, so even if it couldn't get through the server testing works. --- src/app/requirements.txt | 2 +- src/app/views/app.py | 11 ----------- src/app/views/utils.py | 29 +++++++++++++++++++++++++++++ 3 files changed, 30 insertions(+), 12 deletions(-) diff --git a/src/app/requirements.txt b/src/app/requirements.txt index 172cf6d..86478ce 100755 --- a/src/app/requirements.txt +++ b/src/app/requirements.txt @@ -1,4 +1,4 @@ web.py==0.40 -mysql-connector-python==8.0.* +mysql-connector-python==8.0.5 python-dotenv bcrypt diff --git a/src/app/views/app.py b/src/app/views/app.py index 8baa2b2..15e6df1 100755 --- a/src/app/views/app.py +++ b/src/app/views/app.py @@ -9,17 +9,6 @@ from views.project import Project from views.index import Index from views.apply import Apply -# Connect to smtp server, enables web.sendmail() -try: - smtp_server = os.getenv("smtp_server") + ":25" - web.config.smtp_server = smtp_server -except: - smtp_server = "molde.idi.ntnu.no:25" - web.config.smtp_server = smtp_server - -# Example use of the smtp server, insert username -# web.sendmail("beelance@ntnu.no", "@stud.ntnu.no", "Hello", "Grz, the beelance app is running") - # Disable the debug error page web.config.debug = False diff --git a/src/app/views/utils.py b/src/app/views/utils.py index 4955ac7..3969ca7 100755 --- a/src/app/views/utils.py +++ b/src/app/views/utils.py @@ -1,6 +1,35 @@ import web +import os +import logging +import smtplib +from email.message import EmailMessage +from email.headerregistry import Address from uuid import uuid4 +logger = logging.getLogger(__name__) + + +def sendmail(subject, message, to_name, to_email, from_name="Beelance", from_email="beelance@ntnu.no"): + try: + msg = EmailMessage() + msg['From'] = Address(from_name, from_email) + msg['To'] = Address(to_name, to_email) + msg['Subject'] = subject + msg.set_content(message) + + logger.info("Sending email: %s", msg) + + with get_smtp() as smtp: + smtp.set_debuglevel(2) + smtp.send_message(msg) + except Exception: + logging.exception("Exception when sending email") + + +def get_smtp(timeout=3000): + smtp_server = os.getenv("smtp_server", default="molde.idi.ntnu.no") + ":25" + return smtplib.SMTP(smtp_server, timeout=timeout) + def get_render(path='templates/', globals={}, **kwargs): default_globals = { From 46394af70fb3dbe78cbf3e61709b3262ebcac10e Mon Sep 17 00:00:00 2001 From: Sindre Stephansen Date: Tue, 17 Mar 2020 18:06:34 +0100 Subject: [PATCH 2/8] Implement email registration Fixes #1 --- mysql/sql/init.sql | 2 + src/app/models/register.py | 15 +++++-- src/app/models/user.py | 80 +++++++++++++++++++++++++++++++++++ src/app/templates/verify.html | 19 +++++++++ src/app/views/app.py | 3 +- src/app/views/login.py | 3 ++ src/app/views/register.py | 58 ++++++++++++++++++++++--- 7 files changed, 170 insertions(+), 10 deletions(-) create mode 100644 src/app/templates/verify.html diff --git a/mysql/sql/init.sql b/mysql/sql/init.sql index a25a17b..b5e7fbc 100755 --- a/mysql/sql/init.sql +++ b/mysql/sql/init.sql @@ -13,6 +13,8 @@ CREATE TABLE users ( country VARCHAR(50), login_attempts INT UNSIGNED, last_login_attempt INT UNSIGNED, + verified BOOLEAN, + token VARCHAR(50), PRIMARY KEY (userid) ); diff --git a/src/app/models/register.py b/src/app/models/register.py index 2be8e1f..a01adfe 100755 --- a/src/app/models/register.py +++ b/src/app/models/register.py @@ -6,7 +6,7 @@ logger = logging.getLogger(__name__) def set_user(username, password, full_name, company, email, - street_address, city, state, postal_code, country): + street_address, city, state, postal_code, country, token): """ Register a new user in the database :param username: The users unique user name @@ -19,6 +19,7 @@ def set_user(username, password, full_name, company, email, :param state: The state where the user lives :param postal_code: The corresponding postal code :param country: The users country + :param token: The account verification token :type username: str :type password: str :type full_name: str @@ -29,13 +30,19 @@ def set_user(username, password, full_name, company, email, :type state: str :type postal_code: str :type country: str + :type token: str """ db.connect() cursor = db.cursor() - query = ("INSERT INTO users VALUES (NULL, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 0, 0)") + 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) + """) try: - cursor.execute(query, (username, password, full_name, company, email, street_address, - city, state, postal_code, country)) + cursor.execute(query, (username, password, full_name, company, email, + street_address, city, state, postal_code, country, token)) db.commit() except mysql.connector.Error as err: logger.error("Failed executing query: %s", err) diff --git a/src/app/models/user.py b/src/app/models/user.py index 24dd567..9b379c5 100755 --- a/src/app/models/user.py +++ b/src/app/models/user.py @@ -122,3 +122,83 @@ def get_user_name_by_id(userid): cursor.close() db.close() return username + + +def set_token(userid, token): + """Set the given token for the given user.""" + db.connect() + cursor = db.cursor() + query = ("UPDATE users SET token=%s WHERE userid=%s") + try: + cursor.execute(query, (token, userid)) + db.commit() + except mysql.connector.Error as err: + print("Failed executing query: {}".format(err)) + cursor.fetchall() + exit(1) + finally: + cursor.close() + db.close() + + +def get_userid_from_token(token): + """Get the user with the given verify token.""" + db.connect() + cursor = db.cursor() + query = ("SELECT userid FROM users WHERE token=%s") + try: + cursor.execute(query, (token,)) + tokens = cursor.fetchall() + if tokens: + return tokens[0][0] + except mysql.connector.Error as err: + print("Failed executing query: {}".format(err)) + cursor.fetchall() + exit(1) + finally: + cursor.close() + db.close() + + return None + + +def verify_user(userid): + """ + Mark the user as verified. + """ + db.connect() + cursor = db.cursor() + query = ("UPDATE users SET verified=1 WHERE userid=%s AND verified=0") + try: + cursor.execute(query, (userid,)) + db.commit() + except mysql.connector.Error as err: + print("Failed executing query: {}".format(err)) + cursor.fetchall() + exit(1) + finally: + cursor.close() + db.close() + + +def is_verified(userid): + """ + Check whether the user has verified + """ + db.connect() + cursor = db.cursor() + query = ("SELECT userid FROM users WHERE verified=1 AND userid=%s") + try: + cursor.execute(query, (userid,)) + users = cursor.fetchall() + if users: + return True + except mysql.connector.Error as err: + print("Failed executing query: {}".format(err)) + cursor.fetchall() + exit(1) + finally: + cursor.close() + db.close() + + return False diff --git a/src/app/templates/verify.html b/src/app/templates/verify.html new file mode 100644 index 0000000..6234b27 --- /dev/null +++ b/src/app/templates/verify.html @@ -0,0 +1,19 @@ +$def with (nav, message) + + + Beelance2 + + + + + + + + + $:nav + +

$message

+ + + +
diff --git a/src/app/views/app.py b/src/app/views/app.py index 15e6df1..2eee3fc 100755 --- a/src/app/views/app.py +++ b/src/app/views/app.py @@ -2,7 +2,7 @@ import os import web from views.login import Login from views.logout import Logout -from views.register import Register +from views.register import Register, Verify from views.new_project import New_project from views.open_projects import Open_projects from views.project import Project @@ -17,6 +17,7 @@ urls = ( '/', 'Index', '/login', 'Login', '/logout', 'Logout', + '/verify', 'Verify', '/register', 'Register', '/new_project', 'New_project', '/open_projects', 'Open_projects', diff --git a/src/app/views/login.py b/src/app/views/login.py index 605ea08..2e7426d 100755 --- a/src/app/views/login.py +++ b/src/app/views/login.py @@ -64,6 +64,9 @@ class Login(): if login_attempts > login_attempts_threshold: logger.info("User %s logged in succesfully after %s attempts", username, login_attempts) + if not models.user.is_verified(userid): + return render.login(nav, login_form, "- User not authenticated yet. Please check you email.") + models.user.set_login_attempts(userid, 0, time.time()) self.login(username, userid, data.remember) raise web.seeother("/") diff --git a/src/app/views/register.py b/src/app/views/register.py index a7bef39..697de9b 100755 --- a/src/app/views/register.py +++ b/src/app/views/register.py @@ -1,6 +1,7 @@ import web from views.forms import register_form -from views.utils import get_nav_bar, csrf_protected, password_weakness, get_render +from views.utils import get_nav_bar, csrf_protected, password_weakness, get_render, sendmail +from uuid import uuid4 import models.register import models.user import logging @@ -49,9 +50,56 @@ class Register: password_hash = bcrypt.hashpw(data.password.encode('UTF-8'), bcrypt.gensalt()) - models.register.set_user(data.username, password_hash, data.full_name, data.company, - data.email, data.street_address, data.city, data.state, - data.postal_code, data.country) + # Create a verify token + while True: + token = uuid4().hex + if models.user.get_userid_from_token(token) is None: + break + + models.register.set_user( + data.username, + password_hash, + data.full_name, + data.company, + data.email, + data.street_address, + data.city, + data.state, + data.postal_code, + data.country, + token, + ) + + verify_url = "https://{}/verify?token={}".format(web.ctx.host, token) + + sendmail( + 'Verify your Beelance account', + """ + Welcome to Beelance! + + To verify your account, please go to this link: {url} + """.format(url=verify_url), + data.full_name, + data.email, + ) logger.info("User %s registered", data.username) - return render.register(nav, register_form, "User registered!") + return render.register(nav, register_form, "User registered! We have sent you an email to verify your account.") + + +class Verify: + def GET(self): + """ + Verify the user email + """ + session = web.ctx.session + nav = get_nav_bar(session) + render = get_render() + + token = web.input(token='').token + userid = models.user.get_userid_from_token(token) + if token and userid is not None: + models.user.verify_user(userid) + return render.verify(nav, "Your email has been verified. You can log in now.") + else: + return render.verify(nav, "Invalid token. Please try again.") From dd27cb68a42c590f4839b7364995cb8a857cb3e5 Mon Sep 17 00:00:00 2001 From: Sindre Stephansen Date: Tue, 17 Mar 2020 21:23:59 +0100 Subject: [PATCH 3/8] 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.") From f7d309268fd870458aa8472c041289e17eae9aa5 Mon Sep 17 00:00:00 2001 From: Sindre Stephansen Date: Wed, 18 Mar 2020 21:09:02 +0100 Subject: [PATCH 4/8] Properly indent email messages --- src/app/views/register.py | 7 +++---- src/app/views/reset.py | 11 +++++------ 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/src/app/views/register.py b/src/app/views/register.py index 697de9b..835dfab 100755 --- a/src/app/views/register.py +++ b/src/app/views/register.py @@ -74,11 +74,10 @@ class Register: sendmail( 'Verify your Beelance account', - """ - Welcome to Beelance! + """Welcome to Beelance! - To verify your account, please go to this link: {url} - """.format(url=verify_url), +To verify your account, please go to this link: {url} +""".format(url=verify_url), data.full_name, data.email, ) diff --git a/src/app/views/reset.py b/src/app/views/reset.py index 0dfe1dd..586029f 100644 --- a/src/app/views/reset.py +++ b/src/app/views/reset.py @@ -31,14 +31,13 @@ class RequestReset: sendmail( 'Reset your Beelance password', - """ - Hi! + """Hi! - Someone requested a password reset for your account. If that wasn't you you can ignore this email. +Someone requested a password reset for your account. If you didn't request this, 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), +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, ) From d5b155a34883df70ee93ba9cb7661656cf0c04b1 Mon Sep 17 00:00:00 2001 From: Sindre Stephansen Date: Wed, 18 Mar 2020 22:34:56 +0100 Subject: [PATCH 5/8] Set SMTP timeout --- src/app/views/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/views/utils.py b/src/app/views/utils.py index 3969ca7..e055f9f 100755 --- a/src/app/views/utils.py +++ b/src/app/views/utils.py @@ -26,7 +26,7 @@ def sendmail(subject, message, to_name, to_email, from_name="Beelance", from_ema logging.exception("Exception when sending email") -def get_smtp(timeout=3000): +def get_smtp(timeout=2): smtp_server = os.getenv("smtp_server", default="molde.idi.ntnu.no") + ":25" return smtplib.SMTP(smtp_server, timeout=timeout) From ac243db11bb448406408926fcd1aea063007ebe3 Mon Sep 17 00:00:00 2001 From: Sindre Stephansen Date: Wed, 18 Mar 2020 22:39:36 +0100 Subject: [PATCH 6/8] Minor restructure to improve code usability and readability --- src/app/models/user.py | 14 +++++++++----- src/app/views/login.py | 33 +++++++++++++++------------------ src/app/views/register.py | 6 +++--- src/app/views/reset.py | 15 +++++++-------- src/app/views/utils.py | 22 ++++++++++++++++++++++ 5 files changed, 56 insertions(+), 34 deletions(-) diff --git a/src/app/models/user.py b/src/app/models/user.py index 3cf3d72..f021149 100755 --- a/src/app/models/user.py +++ b/src/app/models/user.py @@ -11,17 +11,20 @@ def get_user(username): :param username: The username :type username: str - :return: user + :return: A dictionary of user attributes """ db.connect() cursor = db.cursor() - query = ("SELECT userid, username, email, password, temporary_password, login_attempts, last_login_attempt from users where username = %s") - user = None + fields = [ + 'userid', 'username', 'email', 'password', 'temporary_password', + 'login_attempts', 'last_login_attempt', 'authenticator_secret', + ] + query = ("SELECT {} from users where username = %s".format(', '.join(fields))) try: cursor.execute(query, (username,)) users = cursor.fetchall() if len(users): - user = users[0] + return {fields[i]: users[0][i] for i in range(len(fields))} except mysql.connector.Error as err: logger.error("Failed executing query: %s", err) cursor.fetchall() @@ -29,7 +32,8 @@ def get_user(username): finally: cursor.close() db.close() - return user + + return None def get_users(): diff --git a/src/app/views/login.py b/src/app/views/login.py index dfaf80e..8634808 100755 --- a/src/app/views/login.py +++ b/src/app/views/login.py @@ -1,12 +1,11 @@ import web from views.forms import login_form -from views.utils import get_nav_bar, csrf_protected, get_render +from views.utils import get_nav_bar, csrf_protected, get_render, check_password, get_totp_token import models.session import models.user import logging import random import string -import bcrypt import time logger = logging.getLogger(__name__) @@ -55,32 +54,30 @@ class Login(): if user is None: return render.login(nav, login_form, "- User authentication failed") - 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(): + if user["login_attempts"] > login_attempts_threshold and user["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.") - if bcrypt.checkpw(data.password.encode('UTF-8'), password_hash.encode('UTF-8')): - if login_attempts > login_attempts_threshold: - logger.info("User %s logged in succesfully after %s attempts", username, login_attempts) + if check_password(data.password, user["password"]): + if user["login_attempts"] > login_attempts_threshold: + logger.info("User %s logged in succesfully after %s attempts", user["username"], user["login_attempts"]) - if not models.user.is_verified(userid): + if not models.user.is_verified(user["userid"]): return render.login(nav, login_form, "- User not authenticated yet. Please check you email.") - if temporary_password: + if user["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_temporary_password(user["userid"], "") - models.user.set_login_attempts(userid, 0, time.time()) - self.login(username, userid, data.remember) + models.user.set_login_attempts(user["userid"], 0, time.time()) + self.login(user["username"], user["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 + elif check_password(data.password, user["temporary_password"]): + session.temporary_userid = user["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()) - if login_attempts == login_attempts_threshold: + logger.warning("Incorrect login attempt on user %s by IP %s", user["username"], web.ctx.ip) + models.user.set_login_attempts(user["userid"], user["login_attempts"]+1, time.time()) + if user["login_attempts"] == login_attempts_threshold: return render.login(nav, login_form, "- Too many incorrect login attempts. You have to wait a minute before trying again.") else: return render.login(nav, login_form, "- User authentication failed") diff --git a/src/app/views/register.py b/src/app/views/register.py index 835dfab..0f22574 100755 --- a/src/app/views/register.py +++ b/src/app/views/register.py @@ -1,11 +1,11 @@ import web from views.forms import register_form -from views.utils import get_nav_bar, csrf_protected, password_weakness, get_render, sendmail +from views.utils import (get_nav_bar, csrf_protected, password_weakness, get_render, + sendmail, hash_password) from uuid import uuid4 import models.register import models.user import logging -import bcrypt import re logger = logging.getLogger(__name__) @@ -48,7 +48,7 @@ class Register: if weakness is not None: return render.register(nav, register, weakness) - password_hash = bcrypt.hashpw(data.password.encode('UTF-8'), bcrypt.gensalt()) + password_hash = hash_password(data.password) # Create a verify token while True: diff --git a/src/app/views/reset.py b/src/app/views/reset.py index 586029f..b20f39f 100644 --- a/src/app/views/reset.py +++ b/src/app/views/reset.py @@ -1,10 +1,10 @@ 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 +from views.utils import (get_nav_bar, csrf_protected, get_render, password_weakness, + sendmail, hash_password, check_password) import models.user import logging -import bcrypt logger = logging.getLogger(__name__) @@ -24,10 +24,10 @@ class RequestReset: render = get_render() user = models.user.get_user(data.username) - if user and user[2] == data.email: + if user and user["email"] == data.email: password = uuid4().hex - password_hash = bcrypt.hashpw(password.encode('UTF-8'), bcrypt.gensalt()) - models.user.set_temporary_password(user[0], password_hash) + password_hash = hash_password(password) + models.user.set_temporary_password(user["userid"], password_hash) sendmail( 'Reset your Beelance password', @@ -72,10 +72,9 @@ class Reset: 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')): + if not check_password(data.temporary, user["temporary_password"]): return render.reset(nav, reset_form, "Incorrect temporary password") # Check that the passwords match @@ -88,7 +87,7 @@ class Reset: 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()) + password_hash = hash_password(data.password) models.user.set_password(userid, password_hash) models.user.set_temporary_password(userid, "") diff --git a/src/app/views/utils.py b/src/app/views/utils.py index e055f9f..e3612fc 100755 --- a/src/app/views/utils.py +++ b/src/app/views/utils.py @@ -1,5 +1,6 @@ import web import os +import bcrypt import logging import smtplib from email.message import EmailMessage @@ -178,3 +179,24 @@ def password_weakness(password, username): return "The password is too common. Choose something more unique." return None + + +def hash_password(password): + """ + Create a hash of the given password, with a random salt. + + :param password: The password to hash + :return: The generated hash + """ + return bcrypt.hashpw(password.encode('UTF-8'), bcrypt.gensalt()) + + +def check_password(password, password_hash): + """ + Check if the entered password matches the hashed password. + + :param password: The password to check + :param password_hash: The password hash to check against + :return: True if the password matches, or False if it doesn't + """ + return password and password_hash and bcrypt.checkpw(password.encode('UTF-8'), password_hash.encode('UTF-8')) From 15384fb78d1db0566e7c62ec69fcf31e7b9f5716 Mon Sep 17 00:00:00 2001 From: Sindre Stephansen Date: Wed, 18 Mar 2020 22:40:09 +0100 Subject: [PATCH 7/8] Add two-factor authentication Fixes #4 --- LICENCE.google-authenticator | 201 ++++++++++++++++++++++++++++++++++ mysql/sql/init.sql | 1 + src/app/models/user.py | 19 ++++ src/app/templates/verify.html | 6 +- src/app/views/app.py | 2 +- src/app/views/forms.py | 1 + src/app/views/login.py | 6 +- src/app/views/register.py | 9 +- src/app/views/utils.py | 50 +++++++++ 9 files changed, 288 insertions(+), 7 deletions(-) create mode 100644 LICENCE.google-authenticator diff --git a/LICENCE.google-authenticator b/LICENCE.google-authenticator new file mode 100644 index 0000000..8dada3e --- /dev/null +++ b/LICENCE.google-authenticator @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/mysql/sql/init.sql b/mysql/sql/init.sql index b8d4a0d..480cfd1 100755 --- a/mysql/sql/init.sql +++ b/mysql/sql/init.sql @@ -16,6 +16,7 @@ CREATE TABLE users ( last_login_attempt INT UNSIGNED DEFAULT 0, verified BOOLEAN DEFAULT 0, token VARCHAR(50) NOT NULL DEFAULT "", + authenticator_secret VARCHAR(20) NOT NULL DEFAULT "", PRIMARY KEY (userid) ); diff --git a/src/app/models/user.py b/src/app/models/user.py index f021149..a8c84b2 100755 --- a/src/app/models/user.py +++ b/src/app/models/user.py @@ -96,6 +96,25 @@ def set_password(userid, password): db.close() +def set_authenticator_secret(userid, secret): + """ + Set the password for a user + """ + db.connect() + cursor = db.cursor() + query = ("UPDATE users SET authenticator_secret = %s WHERE userid = %s") + try: + cursor.execute(query, (secret, 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/verify.html b/src/app/templates/verify.html index 6234b27..ff776f5 100644 --- a/src/app/templates/verify.html +++ b/src/app/templates/verify.html @@ -1,4 +1,4 @@ -$def with (nav, message) +$def with (nav, success, secret, message) Beelance2 @@ -14,6 +14,10 @@ $def with (nav, message)

$message

+ $if success: +

We require two-factor authentication on this site.

+

Please enter the following code into your authenticator: $secret

+

This code will only be displayed once.

diff --git a/src/app/views/app.py b/src/app/views/app.py index 837227f..9bb9b3a 100755 --- a/src/app/views/app.py +++ b/src/app/views/app.py @@ -11,7 +11,7 @@ from views.index import Index from views.apply import Apply # Disable the debug error page -web.config.debug = False +web.config.debug = True # Define application routes urls = ( diff --git a/src/app/views/forms.py b/src/app/views/forms.py index 41d25a2..3b0332c 100755 --- a/src/app/views/forms.py +++ b/src/app/views/forms.py @@ -13,6 +13,7 @@ not_empty = form.regexp(r".+", "- This field is required") login_form = form.Form( form.Textbox("username", description="Username"), form.Password("password", description="Password"), + form.Textbox("authenticator_secret", description="Authenticator code"), form.Checkbox("remember", description= "Remember me", checked=True, value=False), form.Button("Log In", type="submit", description="Login"), ) diff --git a/src/app/views/login.py b/src/app/views/login.py index 8634808..825ebf3 100755 --- a/src/app/views/login.py +++ b/src/app/views/login.py @@ -57,7 +57,9 @@ class Login(): if user["login_attempts"] > login_attempts_threshold and user["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.") - if check_password(data.password, user["password"]): + two_factor = get_totp_token(user["authenticator_secret"]) == data.authenticator_secret + + if two_factor and check_password(data.password, user["password"]): if user["login_attempts"] > login_attempts_threshold: logger.info("User %s logged in succesfully after %s attempts", user["username"], user["login_attempts"]) @@ -71,7 +73,7 @@ class Login(): models.user.set_login_attempts(user["userid"], 0, time.time()) self.login(user["username"], user["userid"], data.remember) raise web.seeother("/") - elif check_password(data.password, user["temporary_password"]): + elif two_factor and check_password(data.password, user["temporary_password"]): session.temporary_userid = user["userid"] raise web.seeother("/reset") else: diff --git a/src/app/views/register.py b/src/app/views/register.py index 0f22574..0303c4b 100755 --- a/src/app/views/register.py +++ b/src/app/views/register.py @@ -1,7 +1,7 @@ import web from views.forms import register_form from views.utils import (get_nav_bar, csrf_protected, password_weakness, get_render, - sendmail, hash_password) + sendmail, hash_password, generate_authenticator_secret) from uuid import uuid4 import models.register import models.user @@ -97,8 +97,11 @@ class Verify: token = web.input(token='').token userid = models.user.get_userid_from_token(token) + if token and userid is not None: models.user.verify_user(userid) - return render.verify(nav, "Your email has been verified. You can log in now.") + secret = generate_authenticator_secret() + models.user.set_authenticator_secret(userid, secret) + return render.verify(nav, True, secret, "Your email has been verified.") else: - return render.verify(nav, "Invalid token. Please try again.") + return render.verify(nav, True, secret, "Invalid token. Please try again.") diff --git a/src/app/views/utils.py b/src/app/views/utils.py index e3612fc..941d61e 100755 --- a/src/app/views/utils.py +++ b/src/app/views/utils.py @@ -1,5 +1,12 @@ import web import os +import hmac +import base64 +import struct +import hashlib +import random +import string +import time import bcrypt import logging import smtplib @@ -200,3 +207,46 @@ def check_password(password, password_hash): :return: True if the password matches, or False if it doesn't """ return password and password_hash and bcrypt.checkpw(password.encode('UTF-8'), password_hash.encode('UTF-8')) + + +# Authenticator code copied from https://github.com/jakobsn/google-authenticator. +# The license for this code can be found in the file LICENSE.google-authenticator + +def get_hotp_token(secret, intervals_no): + """This is where the magic happens.""" + key = base64.b32decode(normalize(secret), True) # True is to fold lower into uppercase + msg = struct.pack(">Q", intervals_no) + h = bytearray(hmac.new(key, msg, hashlib.sha1).digest()) + o = h[19] & 15 + h = str((struct.unpack(">I", h[o:o+4])[0] & 0x7fffffff) % 1000000) + return prefix0(h) + + +def get_totp_token(secret): + """The TOTP token is just a HOTP token seeded with every 30 seconds.""" + return get_hotp_token(secret, intervals_no=int(time.time())//30) + + +def normalize(key): + """Normalizes secret by removing spaces and padding with = to a multiple of 8""" + k2 = key.strip().replace(' ', '') + # k2 = k2.upper() # skipped b/c b32decode has a foldcase argument + if len(k2) % 8 != 0: + k2 += '=' * (8 - len(k2) % 8) + return k2 + + +def prefix0(h): + """Prefixes code with leading zeros if missing.""" + if len(h) < 6: + h = '0'*(6-len(h)) + h + return h + +# End of code copied from https://github.com/jakobsn/google-authenticator. + + +def generate_authenticator_secret(): + length = 16 + alphabet = string.ascii_uppercase + + return ''.join(random.SystemRandom().choice(alphabet) for _ in range(length)) From 4acd2659510e1eb989981af8cf01c5e2ffa648ac Mon Sep 17 00:00:00 2001 From: Sindre Stephansen Date: Wed, 18 Mar 2020 23:11:16 +0100 Subject: [PATCH 8/8] Add QR image to set up authenticator --- src/app/requirements.txt | 1 + src/app/templates/verify.html | 5 +++-- src/app/views/register.py | 17 +++++++++++++++-- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/app/requirements.txt b/src/app/requirements.txt index 86478ce..9b130fc 100755 --- a/src/app/requirements.txt +++ b/src/app/requirements.txt @@ -2,3 +2,4 @@ web.py==0.40 mysql-connector-python==8.0.5 python-dotenv bcrypt +qrcode[pil] diff --git a/src/app/templates/verify.html b/src/app/templates/verify.html index ff776f5..b11b32c 100644 --- a/src/app/templates/verify.html +++ b/src/app/templates/verify.html @@ -1,4 +1,4 @@ -$def with (nav, success, secret, message) +$def with (nav, success, secret, qr, message) Beelance2 @@ -16,8 +16,9 @@ $def with (nav, success, secret, message) $if success:

We require two-factor authentication on this site.

-

Please enter the following code into your authenticator: $secret

+

Please scan the QR code, or enter the following code into your authenticator: $secret

This code will only be displayed once.

+ diff --git a/src/app/views/register.py b/src/app/views/register.py index 0303c4b..7d15669 100755 --- a/src/app/views/register.py +++ b/src/app/views/register.py @@ -1,10 +1,13 @@ import web +import io +import base64 from views.forms import register_form from views.utils import (get_nav_bar, csrf_protected, password_weakness, get_render, sendmail, hash_password, generate_authenticator_secret) from uuid import uuid4 import models.register import models.user +import qrcode import logging import re @@ -100,8 +103,18 @@ class Verify: if token and userid is not None: models.user.verify_user(userid) + models.user.set_token(userid, "") + username = models.user.get_user_name_by_id(userid) secret = generate_authenticator_secret() + + # Generate a base64 QR image + qr_url = "otpauth://totp/beelance.com:{}?secret={}&issuer=beelance.com".format(username, secret) + qr_img = qrcode.make(qr_url) + with io.BytesIO() as stream: + qr_img.save(stream) + img = base64.b64encode(stream.getvalue()).decode('UTF-8') + models.user.set_authenticator_secret(userid, secret) - return render.verify(nav, True, secret, "Your email has been verified.") + return render.verify(nav, True, secret, img, "Your email has been verified.") else: - return render.verify(nav, True, secret, "Invalid token. Please try again.") + return render.verify(nav, False, "", "", "Invalid token. Please try again.")