From 80897e62eb96685eefd38affb99429322fda60f0 Mon Sep 17 00:00:00 2001 From: ansengarvin <45224464+ansengarvin@users.noreply.github.com> Date: Fri, 26 Jan 2024 18:35:37 -0800 Subject: [PATCH] Login Endpoint (#148) * Beginning to add authentication stuff for login. Code is broken right now. * Authentication token generation seems to be functional, based on a quick online test. Going to see about decoding. * Changed expiration on token to 24 hours * Added ability to authenticate tokens. Will be useful for locking endpoints. * User login route theoretically working. Need to test now. * Login endpoint appears functional * Cleaned up users API code * Added TODO reminder for authentication * Removed unused imports to appease ruff. * Ran Ruff linter to make code look better * Ran ruff linter again --- backend/init.sql | 2 +- backend/poetry.lock | 38 +++++++++++++++++++++++++++++++++++-- backend/pyproject.toml | 2 ++ backend/src/api/users.py | 41 ++++++++++++++++++++++++++++++++++++++++ backend/src/api_types.py | 16 ++++++++++++++++ backend/src/auth.py | 27 ++++++++++++++++++++++++++ backend/src/server.py | 3 +++ 7 files changed, 126 insertions(+), 3 deletions(-) create mode 100644 backend/src/api/users.py create mode 100644 backend/src/auth.py diff --git a/backend/init.sql b/backend/init.sql index ae26fadf..afb7be22 100644 --- a/backend/init.sql +++ b/backend/init.sql @@ -68,4 +68,4 @@ INSERT INTO species(name) VALUES ('unknown'); /* * Inserts test user into user table */ -INSERT INTO users(username, email, pword, admin) VALUES ('test', 'garvina@oregonstate.edu', 'password', '1'); \ No newline at end of file +INSERT INTO users(username, email, pword, admin) VALUES ('test', 'garvina@oregonstate.edu', '$2b$12$2Z74k3vqzchWB..McZbdUOp4BXd6RGsWcS0atZJfVVheGexvH7i0O', '1'); \ No newline at end of file diff --git a/backend/poetry.lock b/backend/poetry.lock index 42cdcd50..b641bb52 100644 --- a/backend/poetry.lock +++ b/backend/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.7.0 and should not be changed by hand. [[package]] name = "annotated-types" @@ -216,6 +216,23 @@ files = [ {file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"}, ] +[[package]] +name = "passlib" +version = "1.7.4" +description = "comprehensive password hashing framework supporting over 30 schemes" +optional = false +python-versions = "*" +files = [ + {file = "passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1"}, + {file = "passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04"}, +] + +[package.extras] +argon2 = ["argon2-cffi (>=18.2.0)"] +bcrypt = ["bcrypt (>=3.1.0)"] +build-docs = ["cloud-sptheme (>=1.10.1)", "sphinx (>=1.6)", "sphinxcontrib-fulltoc (>=1.2.0)"] +totp = ["cryptography"] + [[package]] name = "pluggy" version = "1.3.0" @@ -405,6 +422,23 @@ files = [ [package.dependencies] typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" +[[package]] +name = "pyjwt" +version = "2.8.0" +description = "JSON Web Token implementation in Python" +optional = false +python-versions = ">=3.7" +files = [ + {file = "PyJWT-2.8.0-py3-none-any.whl", hash = "sha256:59127c392cc44c2da5bb3192169a91f429924e17aff6534d70fdc02ab3e04320"}, + {file = "PyJWT-2.8.0.tar.gz", hash = "sha256:57e28d156e3d5c10088e0c68abb90bfac3df82b40a71bd0daa20c65ccd5c23de"}, +] + +[package.extras] +crypto = ["cryptography (>=3.4.0)"] +dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=6.0.0,<7.0.0)", "sphinx (>=4.5.0,<5.0.0)", "sphinx-rtd-theme", "zope.interface"] +docs = ["sphinx (>=4.5.0,<5.0.0)", "sphinx-rtd-theme", "zope.interface"] +tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"] + [[package]] name = "pytest" version = "7.4.3" @@ -511,4 +545,4 @@ standard = ["colorama (>=0.4)", "httptools (>=0.5.0)", "python-dotenv (>=0.13)", [metadata] lock-version = "2.0" python-versions = ">=3.9,<3.13" -content-hash = "5205b8a79d683ad48c7f3d8792b12ae0229e567d835e8bdf198d39f2cf764ef8" +content-hash = "31741f9165cc8c8ac8edd53ac269cf5fd49014a12d078ae86c171cfd4f8e7219" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index b5ec2b65..a02ee58d 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -13,6 +13,8 @@ fastapi = "^0.104.0" psycopg = "^3.1.12" psycopg-pool = "^3.1.8" biopython = "^1.81" +PyJWT = "^2.8.0" +passlib = "^1.7.4" [tool.poetry.group.dev.dependencies] diff --git a/backend/src/api/users.py b/backend/src/api/users.py new file mode 100644 index 00000000..bf6639fc --- /dev/null +++ b/backend/src/api/users.py @@ -0,0 +1,41 @@ +from fastapi import APIRouter +import logging as log +from passlib.hash import bcrypt +from ..api_types import LoginBody, LoginError, ResponseToken +from ..db import Database +from ..auth import generateAuthToken + +router = APIRouter() + + +@router.post("/users/login", response_model=ResponseToken | LoginError) +def login(body: LoginBody): + with Database() as db: + try: + email = body.email + password = body.password + + query = ( + """SELECT users.pword, users.admin FROM users WHERE users.email = %s;""" + ) + entry_sql = db.execute_return(query, [email]) + + # Returns "incorrect email/password" message if there is no such account. + if entry_sql is None or len(entry_sql) == 0: + return LoginError.INCORRECT + + # Grabs the stored hash and admin. + password_hash, admin = entry_sql[0] + + # Returns "incorrect email/password" message if password is incorrect. + if not bcrypt.verify(password, password_hash): + return LoginError.INCORRECT + + # Generates the token and returns + token = generateAuthToken(email, admin) + return ResponseToken(token=token) + + except Exception as e: + log.error(e) + # TODO: Return something better than query error + return LoginError.QUERY_ERROR diff --git a/backend/src/api_types.py b/backend/src/api_types.py index 69152eac..ae9db3ac 100644 --- a/backend/src/api_types.py +++ b/backend/src/api_types.py @@ -62,3 +62,19 @@ class EditBody(CamelModel): new_species_name: str new_content: str | None = None new_refs: str | None = None + + +class LoginBody(CamelModel): + email: str + password: str + + +class LoginError(str, enum.Enum): + DEBUG_ACCOUNT = "Debug: Account Not Found" + DEBUG_PASSWORD = "Debug: Incorrect password" + INCORRECT = "Invalid Email or Password" + QUERY_ERROR = "QUERY_ERROR" + + +class ResponseToken(CamelModel): + token: str diff --git a/backend/src/auth.py b/backend/src/auth.py new file mode 100644 index 00000000..2a1437cd --- /dev/null +++ b/backend/src/auth.py @@ -0,0 +1,27 @@ +import jwt +from datetime import datetime, timezone, timedelta + +# TODO: This method of secret key generation is, obviously, extremely unsafe. +# This needs to be changed. +secret_key = "SuperSecret" + + +def generateAuthToken(userId, admin): + payload = { + "email": userId, + "admin": admin, + "exp": datetime.now(tz=timezone.utc) + timedelta(hours=24), + } + return jwt.encode(payload, secret_key, algorithm="HS256") + + +# TODO: Find out how FastAPI handles middleware functions, and turn this into one. +def authenticateToken(token): + # Return the decoded token if it's valid. + try: + decoded = jwt.decode(token, secret_key, algorithms="HS256") + return decoded + + # If the token is invalid, return None. + except Exception: + return None diff --git a/backend/src/server.py b/backend/src/server.py index 953c4676..2c4badb2 100644 --- a/backend/src/server.py +++ b/backend/src/server.py @@ -6,11 +6,14 @@ from .db import Database, bytea_to_str, str_to_bytea from .protein import parse_protein_pdb, pdb_file_name, protein_name_found, pdb_to_fasta from .setup import disable_cors, init_fastapi_app +from .api import users app = init_fastapi_app() disable_cors(app, origins=[os.environ["PUBLIC_FRONTEND_URL"]]) +app.include_router(users.router) + @app.get("/pdb/{protein_name:str}") def get_pdb_file(protein_name: str):