| @@ -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, | |||
| 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) | |||
| ); | |||
| @@ -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) | |||
| @@ -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 | |||
| @@ -1,4 +1,5 @@ | |||
| web.py==0.40 | |||
| mysql-connector-python==8.0.* | |||
| mysql-connector-python==8.0.5 | |||
| python-dotenv | |||
| bcrypt | |||
| qrcode[pil] | |||
| @@ -17,6 +17,7 @@ $def with (nav, login_form, message) | |||
| <form method="POST"> | |||
| $:csrf_field() | |||
| $:login_form.render() | |||
| <a href="/request_reset">Forgot your password?</a> | |||
| </form> | |||
| $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 | |||
| 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", "<username>@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', | |||
| @@ -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 | |||
| @@ -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") | |||
| @@ -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.") | |||
| @@ -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 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)) | |||