Skip to content

Commit

Permalink
Add User Authentication (#24)
Browse files Browse the repository at this point in the history
  • Loading branch information
flavius-t authored Sep 24, 2023
1 parent fc4edee commit d7e323b
Show file tree
Hide file tree
Showing 12 changed files with 404 additions and 41 deletions.
5 changes: 3 additions & 2 deletions .github/workflows/pytest-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,11 @@ jobs:
name: tests
runs-on: ubuntu-latest
env:
DB_NAME: 'test_db'
USERS_COLLECTION: 'users'
MONGO_USER: ${{ secrets.MONGO_USER }}
MONGO_PASSWORD: ${{ secrets.MONGO_PASSWORD }}
MONGO_URI: 'mongodb://localhost:27017'
FLASK_ENV: 'development'
FLASK_SECRET_KEY: 'top-secret-key'
strategy:
fail-fast: false
matrix:
Expand Down
24 changes: 20 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,31 @@ This is the Flask backend for an image EXIF metadata viewing web application.
## Setup

### Environment Variables
Ensure `.env` is present in repo root directory with the following fields:
Ensure `.env` is present in local repo root directory with the following fields:
```
DB_NAME=<database-name>
USERS_COLLECTION=<collection-name>
MONGO_USER=<mongo-server-root-username>
MONGO_PASSWORD=<mongo-server-root-password>
FLASK_ENV=<development/production>
FLASK_SECRET_KEY=<your-flask-secret-key>
```

Note, authentication env vars for MongoDB for connecting to the MongoDB server must match those from `.env` in `exif-app-docker` repo.
## MongoDB Connections
The backend must connect to two different MongoDB servers, depending on the situation, as shown in the diagram below.
![exif-backend-mongo](https://github.com/flavius-t/exif-app-backend/assets/77416463/d8bc7d07-d894-481e-9020-723208b82642)

The following environment variables must be defined:
- `MONGO_URI`:
* Used for connecting to local or remote MongoDB servers. The local server is accessed over Docker network, while the remote CI server is accessed over local host network:
* connecting to local server: defined in `exif-app-docker/docker-compose.yml`
* connecting to remote server: defined in `.github/workflows/pytest-tests.yml`
- `DB_NAME`: `config.py`
* defines the name of the database to be used and/or created by the app, both on local and remote (CI) environments. Note, this defaults to the same development database name for both local dev and remote CI environments.
- `USERS_COLLECTION`: `config.py`
* defines the name of the collection storing users. It is not required for the local dev/prod environments to define different names for this.
- `MONGO_USER`, `MONGO_PASSWORD`: `.env`, `.github/workflows/pytest-tests.yml`
* used in `utils.mongo_utils.py` for authenticating against the local/remote mongoDB servers. These do not need to match each other.
* Auth details for the local server must exactly match those defined in the `exif-app-docker` repository.
---

### Dockerization
It is not recommended to build and run the container independently, as it depends on a MongoDB container as defined in `docker-compose.yaml` in the `exif-app-docker` repository.
Expand Down
16 changes: 16 additions & 0 deletions config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
class Config:
USERS_COLLECTION = "users"
JWT_EXPIRATION_DELTA_MINS = 30
JWT_REFRESH_WINDOW_MINS = 15


class DevelopmentConfig(Config):
DEBUG = True
TESTING = True
DB_NAME = "dev_exif_db"


class ProductionConfig(Config):
DEBUG = False
TESTING = False
DB_NAME = "exif_db"
164 changes: 156 additions & 8 deletions exif.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,22 @@
import logging
import zipfile
import os
import datetime
from io import BytesIO

from dotenv import load_dotenv
from flask import Flask, request, send_file, make_response
from flask import Flask, request, send_file, make_response, jsonify
from flask_cors import CORS
from flask_jwt_extended import (
JWTManager,
jwt_required,
create_access_token,
get_jwt_identity,
get_jwt,
set_access_cookies,
unset_jwt_cookies,
)
from flask_bcrypt import Bcrypt

from utils.constants import ZIP_NAME
from utils.extract_meta import extract_metadata, ExtractMetaError
Expand All @@ -27,27 +38,54 @@
SaveZipFileError,
ZIP_SIZE_LIMIT_MB,
)
from utils.mongo_utils import create_mongo_client, create_db, create_collection, close_connection
from utils.mongo_utils import (
create_mongo_client,
create_db,
create_collection,
close_connection,
add_user,
get_user,
)
from utils.file_permissions import restrict_file_permissions
from models.users import User, USERNAME_FIELD, PASSWORD_FIELD


load_dotenv()
JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY")
MONGO_URI = os.getenv("MONGO_URI")
DB_NAME = os.getenv("DB_NAME")
USERS_COLLECTION = os.getenv("USERS_COLLECTION")


# Flask app and api setup
app = Flask(__name__)
CORS(app)
ACCEPT_ORIGINS = ["http://localhost:3000"]
CORS(app, origins=ACCEPT_ORIGINS, supports_credentials=True)
bcrypt = Bcrypt(app)


FLASK_ENV = os.getenv("FLASK_ENV")
if FLASK_ENV == "production":
app.config.from_object("config.ProductionConfig")
else:
app.config.from_object("config.DevelopmentConfig")


# MongoDB setup
DB_NAME = app.config["DB_NAME"]
USERS_COLLECTION = app.config["USERS_COLLECTION"]
mongo_client = create_mongo_client(MONGO_URI)
db = create_db(mongo_client, DB_NAME)
users = create_collection(db, USERS_COLLECTION)

# JWT setup
app.config["JWT_COOKIE_SECURE"] = False # TODO: set True for production
app.config["JWT_TOKEN_LOCATION"] = ["headers", "cookies"]
app.secret_key = os.getenv("FLASK_SECRET_KEY")
JWT_EXPIRES_MINS = app.config["JWT_EXPIRATION_DELTA_MINS"]
app.config["JWT_ACCESS_TOKEN_EXPIRES"] = datetime.timedelta(minutes=JWT_EXPIRES_MINS)
app.config["JWT_COOKIE_REFRESH_WINDOW"] = app.config[
"JWT_REFRESH_WINDOW_MINS"
] # issues a new cookie if the user is within 15 minutes of expiry
jwt = JWTManager(app)


# logging setup
logging.basicConfig(
Expand All @@ -56,6 +94,11 @@
log = logging.getLogger(__name__)


# generic endpoint responses
ERR_NO_JSON = "Request contains no json", 400


# /upload endpoint responses
ERR_NO_FILES = "No files contained in request", 400
ERR_FILE_NAME = "Expected attached file to be named 'file'", 400
ERR_NO_ZIP = "Request is missing zipfile", 400
Expand Down Expand Up @@ -84,7 +127,18 @@
ERR_SAVE_ZIP = "Internal error occured while processing images: failed to save zipfile", 500


@app.route("/profile", methods=["GET"])
@jwt_required()
def get_profile():
"""
Returns the current user's profile.
"""
user_id = get_jwt_identity()
return jsonify(message=f"Hello, {user_id}!"), 200


@app.route("/upload", methods=["POST"])
@jwt_required()
def handle_upload():
"""
Handles image processing requests.
Expand Down Expand Up @@ -180,11 +234,105 @@ def handle_upload():
return response


ERR_MISSING_CREDENTIALS = "Missing username or password", 400
ERR_USER_NOT_EXIST = "User does not exist", 400
ERR_WRONG_PASSWORD = "Wrong password", 401
ERR_CREATE_JWT = "JWT creation failed", 500
ERR_USER_EXISTS = "User already exists", 409
ERR_CREATE_USER = "Failed to create new user", 500
ERR_INTERNAL = "Login failed due to internal error", 500

REGISTER_SUCCESS = "User registration successful", 201
LOGIN_SUCCESS = "User login successful", 200
LOGOUT_SUCCESS = "User logout successful", 200


@app.route("/login", methods=["POST"])
def login():
if request.method == "POST":
data = request.get_json()
username = data.get(USERNAME_FIELD)
password = data.get(PASSWORD_FIELD)

if username is None or password is None:
return jsonify(message=ERR_MISSING_CREDENTIALS[0]), ERR_MISSING_CREDENTIALS[1]

user = get_user(users, username)

if not user:
return jsonify(message=ERR_USER_NOT_EXIST[0]), ERR_USER_NOT_EXIST[1]

try:
is_valid_password = bcrypt.check_password_hash(user[PASSWORD_FIELD], password)
except ValueError as e:
log.error(f"Failed to decode hashed password for user {user} --> {e}")
return jsonify(message=ERR_INTERNAL[0]), ERR_INTERNAL[1]

if is_valid_password:
try:
response = jsonify(message=LOGIN_SUCCESS[0])
access_token = create_access_token(identity=user[USERNAME_FIELD])
set_access_cookies(response, access_token)
return response, LOGIN_SUCCESS[1]
except Exception:
return jsonify(message=ERR_CREATE_JWT[0]), ERR_CREATE_JWT[1]
else:
return jsonify(message=ERR_WRONG_PASSWORD[0]), ERR_WRONG_PASSWORD[1]


@app.route("/register", methods=["POST"])
def register():
if request.method == "POST":
data = request.get_json()
username = data.get(USERNAME_FIELD)
password = data.get(PASSWORD_FIELD)

if username is None or password is None:
return jsonify(message=ERR_MISSING_CREDENTIALS[0]), ERR_MISSING_CREDENTIALS[1]

if get_user(users, username):
return jsonify(message=ERR_USER_EXISTS[0]), ERR_USER_EXISTS[1]

try:
hashed_password = bcrypt.generate_password_hash(password).decode("utf-8")
user = User(username=username, password=hashed_password)
add_user(users, user.username, user.password)
except Exception:
return jsonify(message=ERR_CREATE_USER[0]), ERR_CREATE_USER[1]

return jsonify(message=REGISTER_SUCCESS[0]), REGISTER_SUCCESS[1]


@app.route("/logout", methods=["GET"])
@jwt_required()
def logout():
response = jsonify(message=LOGOUT_SUCCESS[0])
unset_jwt_cookies(response)
return response, LOGOUT_SUCCESS[1]


@app.after_request
def refresh_expiring_jwts(response):
try:
exp_timestamp = get_jwt()["exp"]
now = datetime.datetime.now()
target_timestamp = datetime.datetime.timestamp(
now + datetime.timedelta(minutes=app.config["JWT_COOKIE_REFRESH_WINDOW"])
)
if target_timestamp > exp_timestamp:
access_token = create_access_token(identity=get_jwt_identity())
set_access_cookies(response, access_token)
return response
except (RuntimeError, KeyError):
# not a valid JWT
return response


@app.teardown_appcontext
def clean_up_resources(exception):
if isinstance(exception, KeyboardInterrupt):
if isinstance(exception, KeyboardInterrupt):
close_connection(mongo_client)


if __name__ == "__main__":
app.run(host='0.0.0.0', port=5000, debug=True)
app.run(host="0.0.0.0", port=5000)
12 changes: 12 additions & 0 deletions models/users.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@

USERNAME_FIELD = "username"
PASSWORD_FIELD = "password"


class User:
def __init__(self, username, password):
self.username = username
self.password = password

def __repr__(self) -> str:
return f"User({self.username}, {self.password})"
Binary file modified requirements.txt
Binary file not shown.
Loading

0 comments on commit d7e323b

Please sign in to comment.