Skip to content

Commit

Permalink
User management: finish test suite, fix bugs, add verify email flow, …
Browse files Browse the repository at this point in the history
…add route documentation.
  • Loading branch information
fpereiro committed Dec 7, 2020
1 parent c9964cc commit 2adb822
Show file tree
Hide file tree
Showing 5 changed files with 244 additions and 43 deletions.
5 changes: 2 additions & 3 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -346,9 +346,8 @@ def render_main_menu(current_page):

# *** AUTH ***

#Disable auth routes until they are ready
#import auth
#auth.routes(app)
import auth
auth.routes(app)

# *** START SERVER ***

Expand Down
118 changes: 96 additions & 22 deletions auth.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import bcrypt
import redis
import re
from flask import request, make_response, jsonify
from flask import request, make_response, jsonify, redirect
from utils import type_check, object_check, timems
from functools import wraps
from config import config
Expand All @@ -12,6 +12,9 @@
cookie_name = config ['session'] ['cookie_name']
session_length = config ['session'] ['session_length'] * 60

# TODO: determine environment properly
env = 'local'

def check_password(password, hash):
return bcrypt.checkpw(bytes (password, 'utf-8'), bytes (hash, 'utf-8'))

Expand Down Expand Up @@ -54,7 +57,7 @@ def login():
return 'password must be a string', 400

# If username has an @-sign, then it's an email
if re.match ('@', body ['username']):
if '@' in body ['username']:
username = r.hget ('emails', body ['username'])
if not username:
return 'invalid username/password', 403
Expand All @@ -66,12 +69,14 @@ def login():
user = r.hgetall ('user:' + username)
if not user:
return 'invalid username/password', 403
if 'verification_pending' in user:
return 'email verification pending', 403
if not check_password (body ['password'], user ['password']):
return 'invalid username/password', 403

cookie = make_salt ()
r.setex ('sess:' + cookie, session_length, body ['username'])
r.hset ('user:' + body ['username'], 'lastAccess', timems ())
r.setex ('sess:' + cookie, session_length, username)
r.hset ('user:' + username, 'lastAccess', timems ())
resp = make_response({})
resp.set_cookie(cookie_name, value=cookie, httponly=True, path='/')
return resp
Expand All @@ -84,9 +89,9 @@ def signup():
return 'body must be an object', 400
if not object_check (body, 'username', 'str'):
return 'username must be a string', 400
if re.match ('@', body ['username']):
if '@' in body ['username']:
return 'username cannot contain an @-sign', 400
if re.match (':', body ['username']):
if ':' in body ['username']:
return 'username cannot contain a colon', 400
if not object_check (body, 'password', 'str'):
return 'password must be a string', 400
Expand Down Expand Up @@ -116,11 +121,16 @@ def signup():

hashed = hash(body ['password'], make_salt ())

token = make_salt ()
hashed_token = hash(token, make_salt ())
username = body ['username'].strip ().lower ()

user = {
'username': body ['username'].strip ().lower (),
'username': username,
'password': hashed,
'email': body ['email'].strip ().lower (),
'created': timems ()
'created': timems (),
'verification_pending': hashed_token
}

if 'country' in body:
Expand All @@ -130,10 +140,39 @@ def signup():
if 'gender' in body:
user ['gender'] = body ['gender']

r.hmset ('user:' + body ['username'], user);
r.hset ('emails', body ['email'], body ['username'])
r.hmset ('user:' + username, user);
r.hset ('emails', body ['email'].strip ().lower (), username)

return '', 200
if env == 'local':
# If on local environment, we return email verification token directly instead of emailing it, for test purposes.
return jsonify({'username': username, 'token': hashed_token}), 200
else:
# TODO: when in non-local environment, email the username & token instead of returning them.
return '', 200

@app.route('/auth/verify', methods=['GET'])
def verify_email():
username = request.args.get("username", None)
token = request.args.get("token", None)
if not token:
return 'No token sent', 403
if not username:
return 'No username sent', 403

user = r.hgetall ('user:' + username)

if not user:
return 'Invalid username', 403

# If user is verified, succeed anyway
if not 'verification_pending' in user:
return redirect('/')

if token != user ['verification_pending']:
return 'Invalid token', 403

r.hdel ('user:' + username, 'verification_pending')
return redirect('/')

@app.route('/auth/logout', methods=['POST'])
def logout():
Expand All @@ -146,6 +185,8 @@ def logout():
def destroy(user):
r.delete ('sess:' + request.cookies.get(cookie_name))
r.delete ('user:' + user ['username'])
# The recover password token may exist, so we delete it
r.delete ('token:' + user ['username'])
r.hdel ('emails', user ['email'])
return '', 200

Expand All @@ -161,6 +202,9 @@ def change_password(user):
if not object_check (body, 'newPassword', 'str'):
return 'body.newPassword must be a string', 400

if len (body ['newPassword']) < 6:
return 'password must be at least six characters long', 400

if not check_password (body ['oldPassword'], user ['password']):
return 'invalid username/password', 403

Expand Down Expand Up @@ -191,7 +235,10 @@ def update_profile(user):
if body ['gender'] != 'm' and body ['gender'] != 'f':
return 'body.gender must be m/f', 400

if 'email' in body:
if 'email' in body and body ['email'] != user ['email']:
email = r.hget ('emails', body ['email'])
if email:
return 'email exists', 403
r.hdel ('emails', user ['email'])
r.hset ('emails', body ['email'], user ['username'])
r.hset ('user:' + user ['username'], 'email', body ['email'])
Expand Down Expand Up @@ -228,7 +275,7 @@ def recover():
return 'body.username must be a string', 400

# If username has an @-sign, then it's an email
if re.match ('@', body ['username']):
if '@' in body ['username']:
username = r.hget ('emails', body ['username'])
if not username:
return 'invalid username/password', 403
Expand All @@ -245,26 +292,53 @@ def recover():
token = make_salt ()
hashed = hash(token, make_salt ())

r.setex ('token:' + hashed, session_length, body ['username'])
# TODO: when in non-local environment, email the token instead of returning it
return token
r.setex ('token:' + username, session_length, hashed)

if env == 'local':
# If on local environment, we return email verification token directly instead of emailing it, for test purposes.
return jsonify({'token': token}), 200
else:
# TODO: when in non-local environment, email the token instead of returning it.
return '', 200

@app.route('/auth/reset', methods=['POST'])
def reset():
body = request.json
# Validations
if not type_check (body, 'dict'):
return 'body must be an object', 400
if not object_check (body, 'username', 'str'):
return 'body.username must be a string', 400
if not object_check (body, 'token', 'str'):
return 'body.token must be a string', 400
if not object_check (body, 'password', 'str'):
return 'body.password be a string', 400

username = r.get ('token:' + body ['token'])
if not username:
return 'invalid token', 403
if len (body ['password']) < 6:
return 'password must be at least six characters long', 400

# If username has an @-sign, then it's an email
if '@' in body ['username']:
username = r.hget ('emails', body ['username'])
if not username:
return 'invalid username/password', 403
else:
username = body ['username']

username = username.strip ().lower ()

hashed = r.get ('token:' + username)
if not hashed:
return 'invalid username', 403
if not check_password(body ['token'], hashed):
return 'invalid token', 403

hashed = hash(body ['password'], make_salt ())
r.delete ('token:' + body ['token'])
r ['hset'] ('user:' + username, 'password', hashed)
return {}, 200
r.delete ('token:' + username);
r.hset ('user:' + username, 'password', hashed)

if env != 'local':
# TODO: when in non-local environment, send email
'foobar'

return '', 200
82 changes: 82 additions & 0 deletions doc/backend.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# Backend documentation

## Routes

### Auth

- `POST /auth/login`
- This route creates a new session for an existing user.
- Requires a body of the form `{username: STRING, password: STRING}`. Otherwise, the route returns 400.
- If `username` contains an `@`, it is considered an email.
- `username` is stripped of any whitespace at the beginning or the end; it is also lowercased.
- If successful, the route returns 200 and a `cookie` header containing the session. Otherwise, the route returns 403.
- For the route to be successful, the user should have already verified their account through `GET /auth/verify`.

- `POST /auth/signup`
- This route creates a new user.
- Requires a body of the form `{username: STRING, password: STRING, email: STRING, country: STRING|UNDEFINED, age: INTEGER|UNDEFINED, gender: m|f|UNDEFINED}`. Otherwise, the route returns 400.
- If present, `country` must be a valid [ISO 3166 Alpha-2 country code](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2#Officially_assigned_code_elements).
- If present, `age` must be an integer larger than 0.
- If present, `gender` must be either `m` or `f`.
- `email` must be a valid email.
- `password` must be at least six characters long.
- If `username` contains an `@`, it is considered an email.
- Both `username` and `email` are stripped of any whitespace at the beginning or the end; they are also lowercased.
- Both `username` and `email` should not be in use by an existing user. Otherwise, the route returns 403.
- If successful, the route returns 200. It also will send a verification email to the provided `email`.

- `GET /auth/verify?username=USERNAME&token=TOKEN`
- This route verifies ownership of the email address of a new user.
- If the query parameters `username` or `token` are missing, the route returns 400.
- If the `token` doesn't correspond to `username`, the route returns 403.
- If successful, the route returns a 302 redirecting to `/`.

- `POST /auth/logout`
- This route destroys the current session.
- This route is always successful and returns 200. It will only destroy a session only if a valid cookie is set.

- `POST /auth/destroy`
- This route destroys the user's account.
- This route requires a session, otherwise it returns 403.
- If successful, the route returns 200.

- `POST /auth/changePassword`
- This route changes the user's password.
- Requires a body of the form `{oldPassword: STRING, newPassword: STRING}`. Otherwise, the route returns 400.
- `newPassword` must be at least six characters long.
- If successful, the route returns 200.

- `POST /auth/recover`
- This route sends a password recovery email to the user.
- Requires a body of the form `{username: STRING}`. Otherwise, the route returns 400.
- If `username` contains an `@`, it is considered an email.
- `username` or `email` must belong to an existing user. Otherwise, the route returns 403.
- `username` is stripped of any whitespace at the beginning or the end; it is also lowercased.
- If successful, the route returns 200 and sends a recovery password email to the user.

- `POST /auth/reset`
- This route allows an user to set a new password using a password recovery token.
- Requires a body of the form `{username: STRING, token: STRING, password: STRING}`. Otherwise, the route returns 400.
- If `username` contains an `@`, it is considered an email.
- `username` is stripped of any whitespace at the beginning or the end; it is also lowercased.
- `password` must be at least six characters long.
- If the `username`/`token` combination is not correct, the route returns 403.
- If successful, the route returns 200 and sends an email to notify the user that their password has been changed.

### Profile

- `GET /profile`
- This route allows the user to retrieve their profile.
- This route requires a session, otherwise it returns 403.
- If successful, this route returns 200 with a body of the shape `{username: STRING, email: STRING, age: INTEGER|UNDEFINED, country: STRING|UNDEFINED, gender: m|f|UNDEFINED}`.

- `POST /profile`
- This route allows the user to change its `email`, `age`, `gender` and/or `country`.
- This route requires a session, otherwise it returns 403.
- Requires a body of the form `{email: STRING|UNDEFINED, country: STRING|UNDEFINED, age: INTEGER|UNDEFINED, gender: m|f|UNDEFINED}`. Otherwise, the route returns 400.
- If present, `country` must be a valid [ISO 3166 Alpha-2 country code](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2#Officially_assigned_code_elements).
- If present, `age` must be an integer larger than 0.
- If present, `gender` must be either `m` or `f`.
- If present, `email` must be a valid email.
- `email` should not be in use by an existing user. Otherwise, the route returns 403.
- If successful, the route returns 200.
Loading

0 comments on commit 2adb822

Please sign in to comment.