Skip to content

Commit ffb4c6e

Browse files
authored
Implement basic authentication (#59)
* feat: moved advent folder -> puzzles, added some comments * feat(docker): start separation of dev and prod builds, add pytest functionality to backend * feat(docker): added dev/prod to frontend, transition frontend to yarn * fix: remove .vscode folder * fix(makefile): restructured makefile a bit * feat: removed .vscode folder from git * feat(auth): get rudimentary autotesting in place, created clear_database function * feat(test): added all tests for auth/register * fix(puzzle): changed blueprint in routes/puzzle.py * feat(auth): refactored registration system, database connections * fix(auth): minor changes to constructor * feat(auth): implement email verification endpoints * feat(test): using fixtures * feat(auth): finish autotests, still needs commenting * feat(auth): finished writing tests for the most part * feat(auth): complete tests for basic auth system * fix(auth): removed duplicate clear_database function
1 parent c90da6f commit ffb4c6e

File tree

2,166 files changed

+498
-255216
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

2,166 files changed

+498
-255216
lines changed

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,4 +39,4 @@ restart:
3939
.PHONY: test-backend
4040

4141
test-backend:
42-
docker-compose exec backend pytest .
42+
docker-compose exec backend pytest . $(args)

backend/Pipfile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,14 @@ redis = "*"
1515
flask-cors = "*"
1616
flask-mail = "*"
1717
gunicorn = "*"
18+
itsdangerous = "*"
1819

1920
[dev-packages]
2021
pytest = "*"
2122
requests = "*"
23+
freezegun = "*"
24+
fakeredis = "*"
25+
pytest-mock = "*"
2226

2327
[requires]
2428
python_version = "3.8"

backend/Pipfile.lock

Lines changed: 0 additions & 669 deletions
This file was deleted.

backend/app.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ def handle_exception(error):
2323

2424
return response
2525

26+
2627
def create_app():
2728
app = Flask(__name__)
2829
CORS(app)
@@ -60,6 +61,7 @@ def create_app():
6061

6162
return app
6263

64+
6365
if __name__ == "__main__":
6466
app = create_app()
6567
app.run(host="0.0.0.0", port=5001)

backend/auth/__init__.py

Whitespace-only changes.

backend/common/database.py

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -203,15 +203,3 @@ def dropDatabase():
203203
"""
204204
cur.execute(query)
205205
conn.commit()
206-
207-
def clear_database():
208-
conn = get_connection()
209-
cursor = conn.cursor()
210-
211-
for table in TABLES:
212-
cursor.execute(f"TRUNCATE TABLE {table} CASCADE")
213-
214-
conn.commit()
215-
216-
cursor.close()
217-
conn.close()

backend/common/plugins.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from flask_jwt_extended import JWTManager
22
from flask_mail import Mail
33

4-
from auth.user import User
4+
from models.user import User
55

66
# JWT plugin
77

backend/database/database.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import os
2+
from psycopg2.pool import ThreadedConnectionPool
3+
4+
user = os.environ["POSTGRES_USER"]
5+
password = os.environ["POSTGRES_PASSWORD"]
6+
host = os.environ["POSTGRES_HOST"]
7+
port = os.environ["POSTGRES_PORT"]
8+
database = os.environ["POSTGRES_DB"]
9+
10+
TABLES = ["Users", "Questions", "Competitions", "Inputs", "Solves"]
11+
12+
db = ThreadedConnectionPool(
13+
1, 20,
14+
user=user,
15+
password=password,
16+
host=host,
17+
port=port,
18+
database=database
19+
)
20+
21+
def clear_database():
22+
conn = db.getconn()
23+
24+
with conn.cursor() as cursor:
25+
for table in TABLES:
26+
cursor.execute(f"TRUNCATE TABLE {table} CASCADE")
27+
28+
conn.commit()
29+
30+
db.putconn(conn)

backend/database/user.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
from database.database import db
2+
3+
4+
def add_user(email, username, password, stars, score) -> int:
5+
"""Adds a user to the database, returning their ID."""
6+
7+
conn = db.getconn()
8+
9+
with conn.cursor() as cursor:
10+
cursor.execute("INSERT INTO Users (email, username, password, numStars, score) VALUES (%s, %s, %s, %s, %s)",
11+
(email, username, password, stars, score))
12+
conn.commit()
13+
14+
cursor.execute("SELECT uid FROM Users WHERE email = %s", (email,))
15+
id = cursor.fetchone()[0]
16+
17+
db.putconn(conn)
18+
return id
19+
20+
21+
def fetch_user(email: str):
22+
"""Given a user's email, fetches their content from the database."""
23+
24+
conn = db.getconn()
25+
26+
with conn.cursor() as cursor:
27+
cursor.execute("SELECT * FROM Users WHERE email = %s", (email,))
28+
result = cursor.fetchone()
29+
30+
db.putconn(conn)
31+
return result
32+
33+
34+
def email_exists(email: str) -> bool:
35+
"""Checks if an email exists in the users table."""
36+
37+
conn = db.getconn()
38+
39+
with conn.cursor() as cursor:
40+
cursor.execute("SELECT * FROM Users WHERE email = %s", (email,))
41+
results = cursor.fetchall()
42+
43+
db.putconn(conn)
44+
return results != []
45+
46+
47+
def username_exists(username: str) -> bool:
48+
"""Checks if a username is already used."""
49+
50+
conn = db.getconn()
51+
52+
with conn.cursor() as cursor:
53+
cursor.execute("SELECT * FROM Users WHERE username = %s", (username,))
54+
results = cursor.fetchall()
55+
56+
db.putconn(conn)
57+
return results != []

backend/models/user.py

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
from datetime import timedelta
2+
import os
3+
4+
from argon2 import PasswordHasher
5+
from argon2.exceptions import VerificationError
6+
from email_validator import validate_email, EmailNotValidError
7+
from itsdangerous import URLSafeTimedSerializer
8+
9+
from common.exceptions import AuthError, InvalidError, RequestError
10+
from common.redis import cache
11+
from database.database import db
12+
from database.user import add_user, email_exists, fetch_user, username_exists
13+
14+
hasher = PasswordHasher(
15+
time_cost=2,
16+
memory_cost=2**15,
17+
parallelism=1
18+
)
19+
20+
verify_serialiser = URLSafeTimedSerializer(os.environ["FLASK_SECRET"], salt="verify")
21+
22+
class User:
23+
def __init__(self, id, email, username, password, stars=0, score=0):
24+
self.id = id
25+
self.email = email
26+
self.username = username
27+
self.password = password
28+
self.stars = stars
29+
self.score = score
30+
31+
# Helper methods
32+
33+
@staticmethod
34+
def hash_password(password):
35+
return hasher.hash(password)
36+
37+
# API-facing methods
38+
39+
@staticmethod
40+
def register(email, username, password):
41+
"""Given an email, username and password, creates a verification code
42+
for that user in Redis such that we can verify that user's email."""
43+
44+
# Check for malformed input
45+
try:
46+
normalised = validate_email(email).email
47+
except EmailNotValidError as e:
48+
raise RequestError(description="Invalid email") from e
49+
50+
if email_exists(normalised):
51+
raise RequestError(description="Email already registered")
52+
53+
if username_exists(username):
54+
raise RequestError(description="Username already used")
55+
56+
# Our account is good, we hash the password
57+
hashed = hasher.hash(password)
58+
59+
# Add verification code to Redis cache, with expiry date of 1 hour
60+
code = verify_serialiser.dumps(normalised)
61+
62+
data = {
63+
"email": normalised,
64+
"username": username,
65+
"password": hashed
66+
}
67+
68+
# We use a pipeline here to ensure these instructions are atomic
69+
pipeline = cache.pipeline()
70+
71+
pipeline.hset(f"register:{code}", mapping=data)
72+
pipeline.expire(f"register:{code}", timedelta(hours=1))
73+
74+
pipeline.execute()
75+
76+
return code
77+
78+
@staticmethod
79+
def register_verify(token):
80+
cache_key = f"register:{token}"
81+
82+
if not cache.exists(cache_key):
83+
raise AuthError("Token expired or does not correspond to registering user")
84+
85+
result = cache.hgetall(cache_key)
86+
stringified = {}
87+
88+
for key, value in result.items():
89+
stringified[key.decode()] = value.decode()
90+
91+
id = add_user(stringified["email"], stringified["username"], stringified["password"], 0, 0)
92+
return User(id, stringified["email"], stringified["username"], stringified["password"])
93+
94+
@staticmethod
95+
def login(email, password):
96+
"""Logs user in with their credentials (currently email and password)."""
97+
try:
98+
normalised = validate_email(email).email
99+
except EmailNotValidError as e:
100+
raise AuthError(description="Invalid email or password") from e
101+
102+
result = fetch_user(normalised)
103+
104+
try:
105+
id, email, username, stars, score, hashed = result
106+
hasher.verify(hashed, password)
107+
except (TypeError, VerificationError) as e:
108+
raise AuthError(description="Invalid email or password") from e
109+
110+
return User(id, email, username, hashed, stars, score)
111+
112+
@staticmethod
113+
def get(id):
114+
"""Given a user's ID, fetches all of their information from the database."""
115+
conn = db.getconn()
116+
117+
with conn.cursor() as cursor:
118+
cursor.execute("SELECT * FROM Users WHERE uid = %s", (id,))
119+
fetched = cursor.fetchall()
120+
121+
if fetched == []:
122+
raise InvalidError(description=f"Requested user ID {id} doesn't exist")
123+
124+
id, email, username, stars, score, password = fetched[0]
125+
126+
db.putconn(conn)
127+
128+
return User(id, email, username, password, stars, score)

backend/routes/auth.py

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
1-
from flask import Blueprint, request, jsonify
1+
import os
2+
from flask import Blueprint, render_template, request, jsonify
23
from flask_mail import Message
3-
from flask_jwt_extended import jwt_required, create_access_token, set_access_cookies, unset_jwt_cookies, verify_jwt_in_request
4-
5-
from auth.user import User
4+
from flask_jwt_extended import create_access_token, set_access_cookies, unset_jwt_cookies, verify_jwt_in_request
65
from common.exceptions import AuthError
76
from common.plugins import mail
7+
from models.user import User
88

99
# Constants
1010

1111
auth = Blueprint("auth", __name__)
1212

1313
# Routes (fairly temporary here)
14-
# TODO: update once CSESoc federated auth is set up, do proper research
14+
# TODO: add invalid login attempt protection
1515

1616
@auth.route("/login", methods=["POST"])
1717
def login():
@@ -32,16 +32,18 @@ def register():
3232

3333
# Fetch verification code
3434
code = User.register(json["email"], json["username"], json["password"])
35+
# TODO: convert to domain of verification page once we have its address
36+
url = f"{os.environ['TESTING_ADDRESS']}/verify/{code}"
37+
38+
html = render_template("activate.html", confirm_url=url)
3539

3640
# Send it over to email
3741
message = Message(
3842
"Account registered for Week in Wonderland",
3943
40-
recipients=[json["email"]]
44+
recipients=[json["email"]],
45+
html=html
4146
)
42-
43-
# TODO: convert to HTML message
44-
message.body = f"Your code is: {code}"
4547

4648
mail.send(message)
4749

@@ -51,8 +53,15 @@ def register():
5153

5254
@auth.route("/register/verify", methods=["POST"])
5355
def register_verify():
54-
# TODO: fill in once we get custom email address
55-
pass
56+
json = request.get_json()
57+
58+
user = User.register_verify(json["token"])
59+
cookie = create_access_token(identity=user)
60+
61+
response = jsonify({})
62+
set_access_cookies(response, cookie)
63+
64+
return response, 200
5665

5766
@auth.route("/verify_token", methods=["GET"])
5867
def verify_token():
@@ -63,8 +72,7 @@ def verify_token():
6372

6473
return jsonify({}), 200
6574

66-
@auth.route("/logout", methods=["DELETE"])
67-
@jwt_required()
75+
@auth.route("/logout", methods=["POST"])
6876
def logout():
6977
response = jsonify({})
7078
unset_jwt_cookies(response)

backend/templates/activate.html

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
<p>Welcome! Thanks for signing up. Please follow this link to activate your account:</p>
2+
<p><a href="{{ confirm_url }}">{{ confirm_url }}</a></p>
3+
<br>
4+
<p>Cheers!</p>

0 commit comments

Comments
 (0)