diff --git a/backend/README.md b/backend/README.md index eb08029..9068460 100644 --- a/backend/README.md +++ b/backend/README.md @@ -7,15 +7,27 @@ - Run development backend with `pdm run dev` - Run tests with `pdm run test` -### Setup for Developmentk +### Setup for Development - run `eval $(pdm venv activate in-project)` to activate the virtual env. ## Structure - Use FastAPI's `router` to organize different API routes + - `member` route for getting the current member/user + - `todo` route to create/read/update/delete todos + - `token` route for authentication (login/logout) - Separate folder for PostgreSQL migrations: Might need a better migration tool. Right now, `alembic` only works with SQLalchemy. - Use Pydantic data validation always ## TODO list - [ ] Setup Docker image and k8s for the API: 3 containers: API, Redis and PostgreSQL. + +## Authentication notes + +This API uses OAuth2 (an authorization framework) with JWTs as the format for access token. + +When a user logs in and is granted an access token by an OAuth 2.0 server, the token is often a JWT. This token can then be sent with requests to access protected resources, and the server can verify the token's authenticity and permissions based on the JWT's contents. + +- The flow used was: Password flow but instead of username, we use the user's email instead +- In the Oauth2 spec, the `scope` part is a string of permission(s) diff --git a/backend/pdm.lock b/backend/pdm.lock index ad8a011..35c5abd 100644 --- a/backend/pdm.lock +++ b/backend/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "dev"] strategy = ["cross_platform"] lock_version = "4.4.1" -content_hash = "sha256:a705bf9c8354b802c6aa86d8d35e46e83b9137cf3663c22e7f1442c07eecc38a" +content_hash = "sha256:a69b9b70f0bb65c59605a80b08a73a6464df2a6407212c0094d6adef0e858584" [[package]] name = "annotated-types" @@ -593,6 +593,16 @@ files = [ {file = "python_dotenv-1.0.0-py3-none-any.whl", hash = "sha256:f5971a9226b701070a4bf2c38c89e5a3f0d64de8debda981d1db98583009122a"}, ] +[[package]] +name = "python-multipart" +version = "0.0.9" +requires_python = ">=3.8" +summary = "A streaming multipart parser for Python" +files = [ + {file = "python_multipart-0.0.9-py3-none-any.whl", hash = "sha256:97ca7b8ea7b05f977dc3849c3ba99d51689822fab725c3703af7c866a0c2b215"}, + {file = "python_multipart-0.0.9.tar.gz", hash = "sha256:03f54688c663f1b7977105f021043b0793151e4cb1c1a9d4a11fc13d622c4026"}, +] + [[package]] name = "pyyaml" version = "6.0.1" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 128d098..f651665 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ "psycopg[pool]>=3.1.18", "fastapi-limiter>=0.1.6", "pytest-cov>=4.1.0", + "python-multipart>=0.0.9", ] requires-python = ">=3.11" readme = "README.md" @@ -67,6 +68,7 @@ lint = {composite = ["lint-ruff"]} start = {cmd = "uvicorn --workers 2 neo_neo_todo.main:app", env_file = "development.env"} dev = {cmd = "uvicorn neo_neo_todo.main:app --reload", env_file = "development.env"} +dev-test = {composite = ["recreate-db-base", "migration-base", "generate-test-data", "dev"], env_file = "development.env"} recreate-db-base = "python3 src/neo_neo_todo/utils/database.py" migration-base = "python3 src/neo_neo_todo/migrations/0.py" diff --git a/backend/src/neo_neo_todo/main.py b/backend/src/neo_neo_todo/main.py index 388ebc8..c917e62 100644 --- a/backend/src/neo_neo_todo/main.py +++ b/backend/src/neo_neo_todo/main.py @@ -4,7 +4,7 @@ import redis.asyncio as redis from fastapi import FastAPI from fastapi_limiter import FastAPILimiter -from src.neo_neo_todo.routes import control, sessions +from src.neo_neo_todo.routes import control, members, token from src.neo_neo_todo.utils.database import pool @@ -27,4 +27,5 @@ app = FastAPI(lifespan=lifespan) # include API routes app.include_router(control.router) -app.include_router(sessions.router) +app.include_router(token.router) +app.include_router(members.router) diff --git a/backend/src/neo_neo_todo/migrations/test_data.py b/backend/src/neo_neo_todo/migrations/test_data.py index d9a0ba5..994db80 100644 --- a/backend/src/neo_neo_todo/migrations/test_data.py +++ b/backend/src/neo_neo_todo/migrations/test_data.py @@ -34,7 +34,7 @@ def generate_test_data() -> None: """ INSERT INTO members (email, password_hash) VALUES ('member@todo.test', - '$2b$14$6yXjNza30kPCg3LhzZJfqeCWOLM.zyTiQFD4rdWlFHBTfYzzKJMJe') + '$2a$12$EDo2Sr6B1sptfbKK7DqMnO3VNZuNfVuDbpjJa3uUO9S9/lYpu2wzK') """ ) ) diff --git a/backend/src/neo_neo_todo/models/session.py b/backend/src/neo_neo_todo/models/session.py deleted file mode 100644 index e182059..0000000 --- a/backend/src/neo_neo_todo/models/session.py +++ /dev/null @@ -1,10 +0,0 @@ -from dataclasses import dataclass - -from pydantic import BaseModel, EmailStr - - -@dataclass -class LoginData(BaseModel): - email: EmailStr - password: str - remember: bool = False diff --git a/backend/src/neo_neo_todo/routes/members.py b/backend/src/neo_neo_todo/routes/members.py new file mode 100644 index 0000000..7dfb70b --- /dev/null +++ b/backend/src/neo_neo_todo/routes/members.py @@ -0,0 +1,42 @@ +from datetime import datetime +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer + +from src.neo_neo_todo.models.member import Member + +router = APIRouter( + prefix="/members", + tags=["members"], +) + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") + + +def fake_decode_token(_): + return Member( + id=1, + email="fake@token.com", + password_hash="asdfasdffqwerqwe!@#09098@%)(*)", + created=datetime.now(), + email_verified=None, + ) + + +async def get_current_member(token: Annotated[str, Depends(oauth2_scheme)]): + member = fake_decode_token(token) + + if not member: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid authentication credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + return member + + +@router.get("/me") +async def read_users_me(current_user: Annotated[Member, Depends(get_current_member)]): + return current_user diff --git a/backend/src/neo_neo_todo/routes/sessions.py b/backend/src/neo_neo_todo/routes/sessions.py deleted file mode 100644 index 8f4e133..0000000 --- a/backend/src/neo_neo_todo/routes/sessions.py +++ /dev/null @@ -1,19 +0,0 @@ -from fastapi import APIRouter, Depends -from fastapi_limiter.depends import RateLimiter - -from src.neo_neo_todo.models.session import LoginData - -router = APIRouter( - prefix="/sessions", - tags=["sessions"], -) - - -@router.post("", dependencies=[Depends(RateLimiter(times=100, seconds=600))]) -async def login(login_data: LoginData): - """ - Login to the todo app - - If successful, save the returned cookie - """ - pass diff --git a/backend/src/neo_neo_todo/routes/todo.py b/backend/src/neo_neo_todo/routes/todo.py new file mode 100644 index 0000000..1d2c6a3 --- /dev/null +++ b/backend/src/neo_neo_todo/routes/todo.py @@ -0,0 +1,6 @@ +from fastapi import APIRouter + +router = APIRouter( + prefix="/todos", + tags=["todos"], +) diff --git a/backend/src/neo_neo_todo/routes/token.py b/backend/src/neo_neo_todo/routes/token.py new file mode 100644 index 0000000..3661fa1 --- /dev/null +++ b/backend/src/neo_neo_todo/routes/token.py @@ -0,0 +1,56 @@ +from typing import Annotated + +import bcrypt +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import OAuth2PasswordRequestForm +from fastapi_limiter.depends import RateLimiter +from psycopg_pool import AsyncConnectionPool + +from src.neo_neo_todo.models.member import select_member_by_email +from src.neo_neo_todo.utils.database import get_pool + +router = APIRouter( + prefix="/token", + tags=["token"], +) + + +@router.post( + "", + dependencies=[ + Depends(RateLimiter(times=100, seconds=600)) + ], # rate limit login route + status_code=status.HTTP_200_OK, # default status code when login successful +) +async def login( + form_data: Annotated[OAuth2PasswordRequestForm, Depends()], + db_pool: AsyncConnectionPool = Depends(get_pool), +): + """ + Login to the todo app + + If successful, save the returned cookie + """ + member = await select_member_by_email(db_pool, form_data.username) + + if member is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Incorrect email or password", + ) + + passwords_match = bcrypt.checkpw( + form_data.password.encode("utf-8"), + member.password_hash.encode("utf-8"), + ) + + if passwords_match: + # to handle login logic here + pass + else: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Incorrect email or password", + ) + + return {"access_token": member.email, "token_type": "bearer"} diff --git a/backend/src/neo_neo_todo/utils/database.py b/backend/src/neo_neo_todo/utils/database.py index fbb6382..8c8e259 100644 --- a/backend/src/neo_neo_todo/utils/database.py +++ b/backend/src/neo_neo_todo/utils/database.py @@ -10,7 +10,11 @@ try: except KeyError: raise KeyError("Can't find postgres DB URL") -pool = AsyncConnectionPool(postgres_db_url, open=False) +pool: AsyncConnectionPool = AsyncConnectionPool(postgres_db_url, open=False) + + +def get_pool() -> AsyncConnectionPool: + return pool def recreate_db() -> None: