| @@ -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. | |||||
| @@ -3,6 +3,7 @@ CREATE TABLE users ( | |||||
| userid INT UNSIGNED AUTO_INCREMENT, | userid INT UNSIGNED AUTO_INCREMENT, | ||||
| username VARCHAR(45) UNIQUE NOT NULL, | username VARCHAR(45) UNIQUE NOT NULL, | ||||
| password VARCHAR(60) NOT NULL, | password VARCHAR(60) NOT NULL, | ||||
| temporary_password VARCHAR(60) NOT NULL DEFAULT "", | |||||
| full_name VARCHAR(200) NOT NULL, | full_name VARCHAR(200) NOT NULL, | ||||
| company VARCHAR(50), | company VARCHAR(50), | ||||
| email VARCHAR(50) NOT NULL, | email VARCHAR(50) NOT NULL, | ||||
| @@ -11,8 +12,11 @@ CREATE TABLE users ( | |||||
| state VARCHAR(50), | state VARCHAR(50), | ||||
| postal_code VARCHAR(50), | postal_code VARCHAR(50), | ||||
| country 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) | PRIMARY KEY (userid) | ||||
| ); | ); | ||||
| @@ -6,7 +6,7 @@ logger = logging.getLogger(__name__) | |||||
| def set_user(username, password, full_name, company, email, | 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 | Register a new user in the database | ||||
| :param username: The users unique user name | :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 state: The state where the user lives | ||||
| :param postal_code: The corresponding postal code | :param postal_code: The corresponding postal code | ||||
| :param country: The users country | :param country: The users country | ||||
| :param token: The account verification token | |||||
| :type username: str | :type username: str | ||||
| :type password: str | :type password: str | ||||
| :type full_name: str | :type full_name: str | ||||
| @@ -29,13 +30,19 @@ def set_user(username, password, full_name, company, email, | |||||
| :type state: str | :type state: str | ||||
| :type postal_code: str | :type postal_code: str | ||||
| :type country: str | :type country: str | ||||
| :type token: str | |||||
| """ | """ | ||||
| db.connect() | db.connect() | ||||
| cursor = db.cursor() | 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: | 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() | db.commit() | ||||
| except mysql.connector.Error as err: | except mysql.connector.Error as err: | ||||
| logger.error("Failed executing query: %s", err) | logger.error("Failed executing query: %s", err) | ||||
| @@ -11,17 +11,20 @@ def get_user(username): | |||||
| :param username: The username | :param username: The username | ||||
| :type username: str | :type username: str | ||||
| :return: user | |||||
| :return: A dictionary of user attributes | |||||
| """ | """ | ||||
| db.connect() | db.connect() | ||||
| cursor = db.cursor() | 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: | try: | ||||
| cursor.execute(query, (username,)) | cursor.execute(query, (username,)) | ||||
| users = cursor.fetchall() | users = cursor.fetchall() | ||||
| if len(users): | if len(users): | ||||
| user = users[0] | |||||
| return {fields[i]: users[0][i] for i in range(len(fields))} | |||||
| except mysql.connector.Error as err: | except mysql.connector.Error as err: | ||||
| logger.error("Failed executing query: %s", err) | logger.error("Failed executing query: %s", err) | ||||
| cursor.fetchall() | cursor.fetchall() | ||||
| @@ -29,7 +32,8 @@ def get_user(username): | |||||
| finally: | finally: | ||||
| cursor.close() | cursor.close() | ||||
| db.close() | db.close() | ||||
| return user | |||||
| return None | |||||
| def get_users(): | def get_users(): | ||||
| @@ -54,6 +58,63 @@ def get_users(): | |||||
| return 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): | def set_login_attempts(userid, num, timestamp): | ||||
| """ | """ | ||||
| Set the number and timestamp of the failed login attempts for the given user. | 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() | cursor.close() | ||||
| db.close() | db.close() | ||||
| return username | 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 | |||||
| @@ -1,4 +1,5 @@ | |||||
| web.py==0.40 | web.py==0.40 | ||||
| mysql-connector-python==8.0.* | |||||
| mysql-connector-python==8.0.5 | |||||
| python-dotenv | python-dotenv | ||||
| bcrypt | bcrypt | ||||
| qrcode[pil] | |||||
| @@ -17,6 +17,7 @@ $def with (nav, login_form, message) | |||||
| <form method="POST"> | <form method="POST"> | ||||
| $:csrf_field() | $:csrf_field() | ||||
| $:login_form.render() | $:login_form.render() | ||||
| <a href="/request_reset">Forgot your password?</a> | |||||
| </form> | </form> | ||||
| $else: | $else: | ||||
| @@ -0,0 +1,25 @@ | |||||
| $def with (nav, reset_form, message) | |||||
| <head> | |||||
| <title>Beelance2</title> | |||||
| <meta charset="utf-8"> | |||||
| <link rel="stylesheet" type="text/css" href="static/stylesheet.css"> | |||||
| <link rel="shortcut icon" type="image/png" href="static/honeybee.png"/> | |||||
| </head> | |||||
| <body> | |||||
| $:nav | |||||
| <h2>Reset password</h2> | |||||
| <form method="POST"> | |||||
| $:csrf_field() | |||||
| $:reset_form.render() | |||||
| </form> | |||||
| <p>$message</p> | |||||
| </body> | |||||
| <footer></footer> | |||||
| @@ -0,0 +1,25 @@ | |||||
| $def with (nav, reset_request_form, message) | |||||
| <head> | |||||
| <title>Beelance2</title> | |||||
| <meta charset="utf-8"> | |||||
| <link rel="stylesheet" type="text/css" href="static/stylesheet.css"> | |||||
| <link rel="shortcut icon" type="image/png" href="static/honeybee.png"/> | |||||
| </head> | |||||
| <body> | |||||
| $:nav | |||||
| <h2>Reset password</h2> | |||||
| <form method="POST"> | |||||
| $:csrf_field() | |||||
| $:reset_request_form.render() | |||||
| </form> | |||||
| <p>$message</p> | |||||
| </body> | |||||
| <footer></footer> | |||||
| @@ -0,0 +1,24 @@ | |||||
| $def with (nav, success, secret, qr, message) | |||||
| <head> | |||||
| <title>Beelance2</title> | |||||
| <meta charset="utf-8"> | |||||
| <link rel="stylesheet" type="text/css" href="static/stylesheet.css"> | |||||
| <link rel="shortcut icon" type="image/png" href="static/honeybee.png"/> | |||||
| </head> | |||||
| <body> | |||||
| $:nav | |||||
| <h2>$message</h2> | |||||
| $if success: | |||||
| <p>We require two-factor authentication on this site.</p> | |||||
| <p>Please scan the QR code, or enter the following code into your authenticator: $secret</p> | |||||
| <p>This code will only be displayed once.</p> | |||||
| <img src="data:image/png;base64,$qr" /> | |||||
| </body> | |||||
| <footer></footer> | |||||
| @@ -2,32 +2,25 @@ import os | |||||
| import web | import web | ||||
| from views.login import Login | from views.login import Login | ||||
| from views.logout import Logout | 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.new_project import New_project | ||||
| from views.open_projects import Open_projects | from views.open_projects import Open_projects | ||||
| from views.project import Project | from views.project import Project | ||||
| from views.index import Index | from views.index import Index | ||||
| from views.apply import Apply | 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", "<username>@stud.ntnu.no", "Hello", "Grz, the beelance app is running") | |||||
| # Disable the debug error page | # Disable the debug error page | ||||
| web.config.debug = False | |||||
| web.config.debug = True | |||||
| # Define application routes | # Define application routes | ||||
| urls = ( | urls = ( | ||||
| '/', 'Index', | '/', 'Index', | ||||
| '/login', 'Login', | '/login', 'Login', | ||||
| '/logout', 'Logout', | '/logout', 'Logout', | ||||
| '/verify', 'Verify', | |||||
| '/reset', 'Reset', | |||||
| '/request_reset', 'RequestReset', | |||||
| '/register', 'Register', | '/register', 'Register', | ||||
| '/new_project', 'New_project', | '/new_project', 'New_project', | ||||
| '/open_projects', 'Open_projects', | '/open_projects', 'Open_projects', | ||||
| @@ -1,5 +1,5 @@ | |||||
| from web import form | 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 | 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") | number = form.regexp(r"^[0-9]+$", "- Must be a number") | ||||
| not_empty = form.regexp(r".+", "- This field is required") | not_empty = form.regexp(r".+", "- This field is required") | ||||
| # Define the login form | |||||
| # Define the login form | |||||
| login_form = form.Form( | login_form = form.Form( | ||||
| form.Textbox("username", description="Username"), | form.Textbox("username", description="Username"), | ||||
| form.Password("password", description="Password"), | form.Password("password", description="Password"), | ||||
| form.Textbox("authenticator_secret", description="Authenticator code"), | |||||
| form.Checkbox("remember", description= "Remember me", checked=True, value=False), | form.Checkbox("remember", description= "Remember me", checked=True, value=False), | ||||
| form.Button("Log In", type="submit", description="Login"), | form.Button("Log In", type="submit", description="Login"), | ||||
| ) | ) | ||||
| # Define the register form | |||||
| # Define the register form | |||||
| register_form = form.Form( | register_form = form.Form( | ||||
| form.Textbox("username", not_empty, description="Username"), | form.Textbox("username", not_empty, description="Username"), | ||||
| form.Textbox("full_name", not_empty, description="Full name"), | form.Textbox("full_name", not_empty, description="Full name"), | ||||
| @@ -32,6 +33,20 @@ register_form = form.Form( | |||||
| form.Button("Register", type="submit", description="Register") | 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 | # Define the project view form | ||||
| project_form = form.Form( | project_form = form.Form( | ||||
| form.Input("myfile", type="file"), | 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 | Generate a set of task form elements | ||||
| :param identifier: The id of the task | :param identifier: The id of the task | ||||
| :param task_title: Task title | :param task_title: Task title | ||||
| :param task_description: Task description | |||||
| :param task_description: Task description | |||||
| :param budget: Task budget | :param budget: Task budget | ||||
| :type identifier: int, str | :type identifier: int, str | ||||
| :type task_title: 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 | Generate a set of project form elements | ||||
| :param project_title: Project title | :param project_title: Project title | ||||
| :param project_description: Project description | |||||
| :param project_description: Project description | |||||
| :param category_name: Name of the belonging category | :param category_name: Name of the belonging category | ||||
| :type project_title: str | :type project_title: str | ||||
| :type project_description: str | :type project_description: str | ||||
| :type category_name: str | :type category_name: str | ||||
| :return: A set of project form elements | |||||
| :return: A set of project form elements | |||||
| """ | """ | ||||
| categories = get_categories() | categories = get_categories() | ||||
| project_form_elements = ( | 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 | :return: The form elements to add users to a project | ||||
| """ | """ | ||||
| user_form_elements = ( | 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("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("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) | 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("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("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) | form.Checkbox("modify_permission_" + str(identifier), description="Modify Permission", checked=(modify_permission=="TRUE"), value=True) | ||||
| ) | |||||
| ) | |||||
| return user_permissions | return user_permissions | ||||
| @@ -1,12 +1,11 @@ | |||||
| import web | import web | ||||
| from views.forms import login_form | 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.session | ||||
| import models.user | import models.user | ||||
| import logging | import logging | ||||
| import random | import random | ||||
| import string | import string | ||||
| import bcrypt | |||||
| import time | import time | ||||
| logger = logging.getLogger(__name__) | logger = logging.getLogger(__name__) | ||||
| @@ -55,22 +54,32 @@ class Login(): | |||||
| if user is None: | if user is None: | ||||
| return render.login(nav, login_form, "- User authentication failed") | 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.") | 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("/") | raise web.seeother("/") | ||||
| elif two_factor and check_password(data.password, user["temporary_password"]): | |||||
| session.temporary_userid = user["userid"] | |||||
| raise web.seeother("/reset") | |||||
| else: | 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.") | return render.login(nav, login_form, "- Too many incorrect login attempts. You have to wait a minute before trying again.") | ||||
| else: | else: | ||||
| return render.login(nav, login_form, "- User authentication failed") | return render.login(nav, login_form, "- User authentication failed") | ||||
| @@ -1,10 +1,14 @@ | |||||
| import web | import web | ||||
| import io | |||||
| import base64 | |||||
| from views.forms import register_form | 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.register | ||||
| import models.user | import models.user | ||||
| import qrcode | |||||
| import logging | import logging | ||||
| import bcrypt | |||||
| import re | import re | ||||
| logger = logging.getLogger(__name__) | logger = logging.getLogger(__name__) | ||||
| @@ -47,11 +51,70 @@ class Register: | |||||
| if weakness is not None: | if weakness is not None: | ||||
| return render.register(nav, register, weakness) | 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) | 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.") | |||||
| @@ -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.") | |||||
| @@ -1,6 +1,43 @@ | |||||
| import web | 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 | 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): | def get_render(path='templates/', globals={}, **kwargs): | ||||
| default_globals = { | default_globals = { | ||||
| @@ -149,3 +186,67 @@ def password_weakness(password, username): | |||||
| return "The password is too common. Choose something more unique." | return "The password is too common. Choose something more unique." | ||||
| return None | 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)) | |||||