From 62938bf8c044a92f46a1c163130f6703d68ef564 Mon Sep 17 00:00:00 2001 From: Jason Lee Miller Date: Fri, 4 Oct 2024 15:59:10 -0700 Subject: [PATCH] Changing basic things like auth for api and laying out basic structure. --- .python-version | 1 + dev.sh | 4 +- pyproject.toml | 2 + requirements.txt | 1 + tread/__init__.py | 9 ++- tread/config.py | 53 ++++++++------ tread/database/user.py | 34 +++++++-- tread/jwt/__init__.py | 57 +++++---------- tread/routes/api.py | 137 +++++++++++++------------------------ tread/routes/frontend.py | 12 ---- tread/routes/webui.py | 13 ++++ tread/templates/base.html | 53 ++++++++++++++ tread/templates/index.html | 15 ++++ 13 files changed, 218 insertions(+), 173 deletions(-) create mode 100644 .python-version delete mode 100644 tread/routes/frontend.py create mode 100644 tread/routes/webui.py create mode 100644 tread/templates/base.html create mode 100644 tread/templates/index.html diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..2c07333 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.11 diff --git a/dev.sh b/dev.sh index 14d9e13..827f21f 100755 --- a/dev.sh +++ b/dev.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash PORT="${PORT:-7080}" -export FLASK_APP=tpnewsletter +export FLASK_APP=tread export FLASK_ENV=development -flask run --port $PORT --host 0.0.0.0 --debug \ No newline at end of file +flask run --port $PORT --host 0.0.0.0 --debug diff --git a/pyproject.toml b/pyproject.toml index 489ba31..b5c6137 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,8 @@ license = { file = "LICENSE" } dependencies = [ "flask", "flask-bcrypt", + "flask-cors", + "flask-jwt-extended[asymmetric_crypto]", "flask-sqlalchemy", "flask-wtf", "flask-migrate", diff --git a/requirements.txt b/requirements.txt index 8025080..796f94b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,6 +7,7 @@ passlib[bcrypt]==1.7.4 requests==2.32.3 +flask-jwt-extended[asymmetric_crypto] flask-sqlalchemy flask-bcrypt flask-mail diff --git a/tread/__init__.py b/tread/__init__.py index b11fba2..75ec330 100644 --- a/tread/__init__.py +++ b/tread/__init__.py @@ -2,7 +2,8 @@ from pathlib import Path from flask import Flask from .config import Config -from .routes.frontend import frontend +from .jwt import jwt +from .routes.webui import webui from .routes.api import api from .bcrypt import bcrypt from .database import db @@ -18,7 +19,6 @@ def create_app(test_config=None): # load the test config if passed in app.config.from_mapping(test_config) - print(str(app.config)) try: Path(app.config['DATA_DIR']).mkdir(parents=True, exist_ok=True) except OSError: @@ -49,17 +49,16 @@ def create_app(test_config=None): except OSError: pass - app.register_blueprint(frontend) + app.register_blueprint(webui) app.register_blueprint(api) bcrypt.init_app(app) + jwt.init_app(app) db.init_app(app) with app.app_context(): db.create_all() - TOKEN_EXPIRATION_MINUTES = app.config['TOKEN_EXPIRATION_MINUTES'] - return app diff --git a/tread/config.py b/tread/config.py index 4c228a9..0e86359 100644 --- a/tread/config.py +++ b/tread/config.py @@ -4,7 +4,9 @@ import random from pathlib import Path from unittest.mock import DEFAULT +import logging +log = logging.getLogger(__name__) class Config: if platform.system() == 'Darwin' or platform.system() == 'Windows': @@ -25,32 +27,43 @@ class Config: BUILD_DIR: str = str(Path(os.environ.get("BUILD_DIR", default=APP_ROOT + "/frontend/public"))) KEY_FILE: str = APP_DIR + "/.secret_key" - - if os.environ.get("SECRET_KEY") is None: - if not KEY_FILE.exists(): - with open(KEY_FILE, "wb") as f: - f.write(base64.b64encode(os.urandom(32)).decode("utf-8")) - f.close() - - with open(KEY_FILE, "r") as f: - SECRET_KEY = f.read() - f.close() - - SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_URL", default="sqlite:///" + DB_DIR + "/tpnewsletter.db") - JWT_COOKIE_NAME = os.environ.get("JWT_COOKIE_NAME", default="jwt_token") - JWT_COOKIE_SECURE = bool(os.environ.get("JWT_COOKIE_SECURE", default=False)) - JWT_COOKIE_HTTPONLY = bool(os.environ.get("JWT_COOKIE_HTTPONLY", default=True)) + SECRET_KEY = os.environ.get("SECRET_KEY", default=None) + + SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_URL", default="sqlite:///" + DB_DIR + "/tread.db") + + JWT_SECRET_KEY = SECRET_KEY - TOKEN_EXPIRATION_MINUTES = int(os.environ.get("TOKEN_EXPIRATION_MINUTES", default=60)) + JWT_COOKIE_NAME = "jwt_tread" + JWT_COOKIE_SECURE = os.environ.get("JWT_COOKIE_SECURE", default=False) + JWT_COOKIE_HTTPONLY = os.environ.get("JWT_COOKIE_HTTPONLY", default=True) + + # in hours but converted to minutes for more accurate token expiration + JWT_TOKEN_EXPIRATION = os.environ.get("JWT_TOKEN_EXPIRATION", default=(24 * 60)) ADMIN_USERNAME = os.environ.get("ADMIN_USERNAME", default="admin") ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD", default="P@assw0rd") + UPLOAD_MAX_SIZE = int(os.environ.get("UPLOAD_MAX_SIZE", default=1000000000)) - DEFAULT_USER_ROLE = os.environ.get("DEFAULT_USER_ROLE", default="user") - DEFAULT_USER_THEME = os.environ.get("DEFAULT_USER_THEME", default="light") - DEFAULT_USER_PROFILE_PICTURE_URL = os.environ.get("DEFAULT_USER_PROFILE_PICTURE_URL", default="https://i.imgur.com/6kOZ4H1.png") - + def __init__(self): + if self.SECRET_KEY==None: + self.SECRET_KEY: None = Config.get_secret_key() + + log.info(msg="SECRET_KEY: " + self.SECRET_KEY) + @staticmethod + def get_secret_key(): + if os.environ.get("SECRET_KEY") is None: + if not Config.KEY_FILE.exists(): + with open(Config.KEY_FILE, "wb") as f: + f.write(base64.b64encode(os.urandom(32)).decode("utf-8")) + f.close() + log.info(msg="Secret key created.") + + with open(Config.KEY_FILE, "r") as f: + secret_key = f.read() + f.close() + return secret_key + \ No newline at end of file diff --git a/tread/database/user.py b/tread/database/user.py index d2f8c41..13a3948 100644 --- a/tread/database/user.py +++ b/tread/database/user.py @@ -1,18 +1,42 @@ from typing import Any from . import db from ..bcrypt import bcrypt +import logging + +log = logging.getLogger(__name__) +log.setLevel(logging.WARN) class User(db.Model): id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(150), unique=True, nullable=False) - password_hash: Any = db.Column(db.String(150), nullable=False) + password: Any = db.Column(db.String(150), nullable=False) + full_name = db.Column(db.String(150), nullable=False) role = db.Column(db.String(50), nullable=False) - theme: Any = db.Column(db.String(50), nullable=True) - profile_picture_url: Any = db.Column(db.String(250), nullable=True) + email = db.Column(db.String(150), nullable=False) created_at = db.Column(db.DateTime, nullable=False, default=db.func.now()) + updated_at = db.Column(db.DateTime, nullable=False, default=db.func.now(), onupdate=db.func.now()) + def __init__(self, username, password, full_name, role, email): + self.username = username + self.set_password(password) # hash the password + self.full_name = full_name + self.role = role + self.email = email + def set_password(self, password): - self.password_hash = bcrypt.generate_password_hash(password).decode("utf-8") + self.password = bcrypt.generate_password_hash(password).decode("utf-8") def check_password(self, password): - return bcrypt.check_password_hash(self.password_hash, password) \ No newline at end of file + return bcrypt.check_password_hash(self.password_hash, password) + + def save(self): + try: + db.session.add(self) + db.session.commit() + log.info(msg="User saved to database.") + except: + db.session.rollback() + log.error(msg="Error saving user to database, rolling back.") + return False + + return True \ No newline at end of file diff --git a/tread/jwt/__init__.py b/tread/jwt/__init__.py index 2b60365..a560cf9 100644 --- a/tread/jwt/__init__.py +++ b/tread/jwt/__init__.py @@ -1,38 +1,19 @@ -from typing import Any -from flask import request, current_app, jsonify -from functools import wraps -import logging -import jwt -import datetime - -log = logging.getLogger(__name__) -log.setLevel(logging.WARN) - -# Decorator to check the JWT in cookies -def token_required(f): - @wraps(f) - def decorated(*args, **kwargs): - token = request.cookies.get(current_app.config['JWT_COOKIE_NAME']) - - if not token: - log.info(msg="Token is missing. Returning 403.") - return jsonify({'message': 'Token is missing'}), 403 - - try: - # Decode the token and extract user information - data = jwt.decode(token, current_app.config['SECRET_KEY'], algorithms=['HS256']) - current_user = data['user'] - role: str = data['role'] - created_at: Any = data['created_at'] - exp: datetime.datetime = data['exp'] - except: - log.info(msg='Token is invalid. Returning 403.') - return jsonify({'message': 'Token is invalid'}), 403 - - if exp < datetime.datetime.now(datetime.timezone.utc): - log.info(msg='Token has expired. Returning 403.') - return jsonify({'message': 'Token has expired'}), 403 - - return f(current_user, role, created_at, exp, *args, **kwargs) - - return decorated +from flask_jwt_extended import JWTManager +from flask_jwt_extended import current_user +from ..database.user import User + +jwt = JWTManager() + +@jwt.user_identity_loader +def user_identity_lookup(user): + return user.id + +@jwt.user_lookup_loader +def user_lookup_callback(_jwt_header, jwt_data): + identity = jwt_data["sub"] + return User.query.filter_by(id=identity).one_or_none() + + + + + diff --git a/tread/routes/api.py b/tread/routes/api.py index fe2d5c6..45d1a55 100644 --- a/tread/routes/api.py +++ b/tread/routes/api.py @@ -1,9 +1,11 @@ +import email from flask import Blueprint, current_app, jsonify, request, make_response +from flask_jwt_extended import create_access_token, create_refresh_token, get_jwt_identity, jwt_required, current_user import datetime import logging + import jwt from ..database.user import User -from ..jwt import token_required log = logging.getLogger(__name__) log.setLevel(logging.WARN) @@ -14,7 +16,10 @@ def get_api_v1(): return jsonify({ "version": "1.0.0", - "endpoints": ["/auth", "/users", "/roles", "/config"] + "endpoints": [ + "/auth", + "/user" + ], }) @api.route("/v1/auth", methods=['GET']) @@ -23,47 +28,13 @@ def get_v1_auth(): "status": "success", "endpoints": [ "/login", - "/register", "/logout", - "/check", "/refresh", ], }) - -@api.route("/v1/auth/register", methods=["POST"]) -def post_v1_auth_register(): - data: dict = request.get_json() - username: str | None = data.get("username") - password: str | None = data.get("password") - role: str | None = current_app.config['DEFAULT_USER_ROLE'] - created_at: datetime.datetime = datetime.datetime.now(datetime.timezone.utc) - - if not username or not password: - log.info('Missing username or password. Returning 401.') - return jsonify({ - "status": "error", - "message": "Missing username or password." - }), 401 - - user = User(username, password, role, created_at) - - try: - user.save() - except Exception as e: - log.info(msg=f"{str(e)}. Returning 409.") - return jsonify({ - "status": "error", - "message": f"{str(e)}" - }), 409 - - log.info(msg=f"User created successfully. Returning 201.") - return jsonify({ - "status": "success", - "message": "User created successfully." - }), 201 @api.route("/v1/auth/login", methods=["POST"]) -def post_v1_auth_login(): +def login(): data = request.get_json() username = data.get("username") password = data.get("password") @@ -76,64 +47,48 @@ def post_v1_auth_login(): 'message': 'Invalid username or password.' }), 401 - token = jwt.encode({ - 'user': user.username, - 'role': user.role, - 'created_at': user.created_at, - 'exp': datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(minutes=current_app.config.TOKEN_EXPIRATION_MINUTES) - }, current_app.config['SECRET_KEY'], algorithm='HS256') - - resp = make_response(jsonify({ - "status": "success", - "message": "Logged in successfully."})) - resp.set_cookie(current_app.config['JWT_COOKIE_NAME'], token, httponly=current_app.config['JWT_COOKIE_HTTPONLY'], secure=current_app.config['JWT_COOKIE_SECURE']) + access_token = create_access_token(identity=username) + return jsonify(access_token=access_token) - log.info(msg="Logged in successfully. Returning 200.") - - return resp +api.route("/v1/auth/refresh", methods=["GET"]) +@jwt_required(refresh=True) +def refresh_token(): + return jsonify(access_token=create_access_token(identity=get_jwt_identity())) -@api.route("/v1/auth/logout", methods=["GET"]) -@token_required -def get_v1_auth_logout(current_user): - resp = make_response(jsonify({ - "status": "success", - "message": "Logged out successfully."})) - resp.set_cookie(current_app.config['JWT_COOKIE_NAME'], '', expires=0) + return None, 200 - log.info(msg="Logged out successfully. Returning 200.") +# TODO: Add get all users endpoint +@api.route("/v1/user", methods=["GET"]) +@jwt_required() +def get_users(): + return None, 200 - return resp +# TODO: Add user creation endpoint +@api.route("/v1/user", methods=["POST"]) +@jwt_required() +def create_user(): + return None, 200 + +# TODO: Add get user endpoint +@api.route("/v1/user/", methods=["GET"]) +@jwt_required() +def get_user(user_id): + return None, 200 + +# TODO: Add delete user endpoint +@api.route("/v1/user/", methods=["DELETE"]) +@jwt_required() +def delete_user(user_id): + return None, 200 + +# TODO: Add update user endpoint +@api.route("/v1/user/", methods=["PUT"]) +@jwt_required() +def update_user(user_id): + return None, 200 + + -@api.route("/v1/auth/check", methods=["GET"]) -@token_required -def get_v1_auth_check(current_user, role, created_at, exp): - log.info(msg="Logged in. Returning 200.") - return jsonify({ - "status": "success", - "isAuthenticated": True, - "message": f"Logged in as {current_user}.", - "user": current_user, - "role": role, - "created_at": created_at, - "exp": str(exp) - }) - -api.route("/v1/auth/refresh", methods=["GET"]) -@token_required -def get_v1_auth_refresh(current_user, role, theme, profile_picture_url, created_at, exp): - token = jwt.encode({ - 'user': current_user, - 'role': role, - 'theme': theme, - 'profile_picture_url': profile_picture_url, - 'created_at': created_at, - 'exp': datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(minutes=current_app.config.TOKEN_EXPIRATION_MINUTES)}, current_app.config['SECRET_KEY'], algorithm='HS256') - - resp = make_response(jsonify({ - "status": "success", - "message": "Token refreshed successfully."})) - resp.set_cookie(current_app.config['JWT_COOKIE_NAME'], token, httponly=current_app.config['JWT_COOKIE_HTTPONLY'], secure=current_app.config['JWT_COOKIE_SECURE']) - log.info(msg="Token refreshed successfully. Returning 200.") - return resp \ No newline at end of file + \ No newline at end of file diff --git a/tread/routes/frontend.py b/tread/routes/frontend.py deleted file mode 100644 index 70ac7de..0000000 --- a/tread/routes/frontend.py +++ /dev/null @@ -1,12 +0,0 @@ -from flask import Blueprint, current_app, send_from_directory - -frontend = Blueprint("frontend", __name__) - -@frontend.route("/") -def serve_index(): - return send_from_directory(current_app.config['BUILD_DIR'], "index.html") - -@frontend.route("/") -def serve_static_files(path): - return send_from_directory(current_app.config['BUILD_DIR'], path) - \ No newline at end of file diff --git a/tread/routes/webui.py b/tread/routes/webui.py new file mode 100644 index 0000000..7f62077 --- /dev/null +++ b/tread/routes/webui.py @@ -0,0 +1,13 @@ +from flask import Blueprint, current_app, send_from_directory, render_template, redirect, url_for, request +from flask_jwt_extended import jwt_required, get_jwt_identity + +webui = Blueprint("frontend", __name__) + +@webui.route("/") +def index(): + return send_from_directory(current_app.config['BUILD_DIR'], "index.html") + +@webui.route("/") +def serve_static_files(path): + return send_from_directory(current_app.config['BUILD_DIR'], path) + \ No newline at end of file diff --git a/tread/templates/base.html b/tread/templates/base.html new file mode 100644 index 0000000..415ae52 --- /dev/null +++ b/tread/templates/base.html @@ -0,0 +1,53 @@ + + + + + + {% block title %}{% endblock %} + + + +
+ {% block header %} + {% endblock %} +
+
+ + {% block content %} + {% endblock %} +
+
+ {% block footer %} + {% endblock %} +
+ + + \ No newline at end of file diff --git a/tread/templates/index.html b/tread/templates/index.html new file mode 100644 index 0000000..9efb187 --- /dev/null +++ b/tread/templates/index.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} + +{% block title %}Tread - Home{% endblock %} + +{% block header %} +

Welcome to Tread

+{% endblock %} + +{% block content %} +

This is the home page of Tread.

+{% endblock %} + +{% block footer %} +

© 2024 Tread

+{% endblock %} \ No newline at end of file