Sfoglia il codice sorgente

Merge branch 'email' of sindre/Beelance into master

master
Sindre Stephansen 5 anni fa
parent
commit
224066ff82
15 ha cambiato i file con 761 aggiunte e 53 eliminazioni
  1. +201
    -0
      LICENCE.google-authenticator
  2. +6
    -2
      mysql/sql/init.sql
  3. +11
    -4
      src/app/models/register.py
  4. +146
    -5
      src/app/models/user.py
  5. +2
    -1
      src/app/requirements.txt
  6. +1
    -0
      src/app/templates/login.html
  7. +25
    -0
      src/app/templates/reset.html
  8. +25
    -0
      src/app/templates/reset_request.html
  9. +24
    -0
      src/app/templates/verify.html
  10. +6
    -13
      src/app/views/app.py
  11. +23
    -8
      src/app/views/forms.py
  12. +22
    -13
      src/app/views/login.py
  13. +70
    -7
      src/app/views/register.py
  14. +98
    -0
      src/app/views/reset.py
  15. +101
    -0
      src/app/views/utils.py

+ 201
- 0
LICENCE.google-authenticator Vedi File

@@ -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.

+ 6
- 2
mysql/sql/init.sql Vedi File

@@ -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)
);



+ 11
- 4
src/app/models/register.py Vedi File

@@ -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)


+ 146
- 5
src/app/models/user.py Vedi File

@@ -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

+ 2
- 1
src/app/requirements.txt Vedi File

@@ -1,4 +1,5 @@
web.py==0.40
mysql-connector-python==8.0.*
mysql-connector-python==8.0.5
python-dotenv
bcrypt
qrcode[pil]

+ 1
- 0
src/app/templates/login.html Vedi File

@@ -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:


+ 25
- 0
src/app/templates/reset.html Vedi File

@@ -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>

+ 25
- 0
src/app/templates/reset_request.html Vedi File

@@ -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>

+ 24
- 0
src/app/templates/verify.html Vedi File

@@ -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>

+ 6
- 13
src/app/views/app.py Vedi File

@@ -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',


+ 23
- 8
src/app/views/forms.py Vedi File

@@ -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

+ 22
- 13
src/app/views/login.py Vedi File

@@ -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")


+ 70
- 7
src/app/views/register.py Vedi File

@@ -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.")

+ 98
- 0
src/app/views/reset.py Vedi File

@@ -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.")

+ 101
- 0
src/app/views/utils.py Vedi File

@@ -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))

Loading…
Annulla
Salva