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 a25a17b..480cfd1 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,8 +12,11 @@ CREATE TABLE users ( state VARCHAR(50), postal_code VARCHAR(50), country VARCHAR(50), - login_attempts INT UNSIGNED, - last_login_attempt INT UNSIGNED, + login_attempts INT UNSIGNED DEFAULT 0, + 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/register.py b/src/app/models/register.py index 2be8e1f..9782fde 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, token) + VALUES (NULL, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %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..a8c84b2 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, 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(): @@ -54,6 +58,63 @@ 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_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. @@ -122,3 +183,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/requirements.txt b/src/app/requirements.txt index 172cf6d..9b130fc 100755 --- a/src/app/requirements.txt +++ b/src/app/requirements.txt @@ -1,4 +1,5 @@ web.py==0.40 -mysql-connector-python==8.0.* +mysql-connector-python==8.0.5 python-dotenv bcrypt +qrcode[pil] 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/templates/verify.html b/src/app/templates/verify.html new file mode 100644 index 0000000..b11b32c --- /dev/null +++ b/src/app/templates/verify.html @@ -0,0 +1,24 @@ +$def with (nav, success, secret, qr, message) + + + Beelance2 + + + + + + + + + $:nav + +

$message

+ + $if success: +

We require two-factor authentication on this site.

+

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/app.py b/src/app/views/app.py index 8baa2b2..9bb9b3a 100755 --- a/src/app/views/app.py +++ b/src/app/views/app.py @@ -2,32 +2,25 @@ 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.reset import RequestReset, Reset from views.new_project import New_project from views.open_projects import Open_projects 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 +web.config.debug = True # Define application routes urls = ( '/', 'Index', '/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..3b0332c 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,15 +9,16 @@ 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"), + 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"), ) -# 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 +33,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 +62,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 +81,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 +112,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 +162,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 605ea08..825ebf3 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,22 +54,32 @@ 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 - - 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) + 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"]) + + if not models.user.is_verified(user["userid"]): + return render.login(nav, login_form, "- User not authenticated yet. Please check you email.") + + 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(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 two_factor and 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 a7bef39..7d15669 100755 --- a/src/app/views/register.py +++ b/src/app/views/register.py @@ -1,10 +1,14 @@ 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 +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 bcrypt import re logger = logging.getLogger(__name__) @@ -47,11 +51,70 @@ 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) - 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) + 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, img, "Your email has been verified.") + else: + return render.verify(nav, False, "", "", "Invalid token. Please try again.") diff --git a/src/app/views/reset.py b/src/app/views/reset.py new file mode 100644 index 0000000..b20f39f --- /dev/null +++ b/src/app/views/reset.py @@ -0,0 +1,98 @@ +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, hash_password, check_password) +import models.user +import logging + +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["email"] == data.email: + password = uuid4().hex + password_hash = hash_password(password) + models.user.set_temporary_password(user["userid"], password_hash) + + sendmail( + 'Reset your Beelance password', + """Hi! + +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), + "", + 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) + + # Check that the temporary password is correct + if not check_password(data.temporary, user["temporary_password"]): + 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 = hash_password(data.password) + 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.") diff --git a/src/app/views/utils.py b/src/app/views/utils.py index 4955ac7..941d61e 100755 --- a/src/app/views/utils.py +++ b/src/app/views/utils.py @@ -1,6 +1,43 @@ import web +import os +import hmac +import base64 +import struct +import hashlib +import random +import string +import time +import bcrypt +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=2): + 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 = { @@ -149,3 +186,67 @@ 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')) + + +# 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))