feat(api): insecure login flow skeleton

- Updated backend technical writeup
This commit is contained in:
minhtrannhat 2024-03-11 22:33:30 -04:00
parent fbba4d6d43
commit 0bd4508d11
Signed by: minhtrannhat
GPG Key ID: E13CFA85C53F8062
11 changed files with 139 additions and 35 deletions

View File

@ -7,15 +7,27 @@
- Run development backend with `pdm run dev` - Run development backend with `pdm run dev`
- Run tests with `pdm run test` - Run tests with `pdm run test`
### Setup for Developmentk ### Setup for Development
- run `eval $(pdm venv activate in-project)` to activate the virtual env. - run `eval $(pdm venv activate in-project)` to activate the virtual env.
## Structure ## Structure
- Use FastAPI's `router` to organize different API routes - 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. - Separate folder for PostgreSQL migrations: Might need a better migration tool. Right now, `alembic` only works with SQLalchemy.
- Use Pydantic data validation always - Use Pydantic data validation always
## TODO list ## TODO list
- [ ] Setup Docker image and k8s for the API: 3 containers: API, Redis and PostgreSQL. - [ ] 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)

12
backend/pdm.lock generated
View File

@ -5,7 +5,7 @@
groups = ["default", "dev"] groups = ["default", "dev"]
strategy = ["cross_platform"] strategy = ["cross_platform"]
lock_version = "4.4.1" lock_version = "4.4.1"
content_hash = "sha256:a705bf9c8354b802c6aa86d8d35e46e83b9137cf3663c22e7f1442c07eecc38a" content_hash = "sha256:a69b9b70f0bb65c59605a80b08a73a6464df2a6407212c0094d6adef0e858584"
[[package]] [[package]]
name = "annotated-types" name = "annotated-types"
@ -593,6 +593,16 @@ files = [
{file = "python_dotenv-1.0.0-py3-none-any.whl", hash = "sha256:f5971a9226b701070a4bf2c38c89e5a3f0d64de8debda981d1db98583009122a"}, {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]] [[package]]
name = "pyyaml" name = "pyyaml"
version = "6.0.1" version = "6.0.1"

View File

@ -17,6 +17,7 @@ dependencies = [
"psycopg[pool]>=3.1.18", "psycopg[pool]>=3.1.18",
"fastapi-limiter>=0.1.6", "fastapi-limiter>=0.1.6",
"pytest-cov>=4.1.0", "pytest-cov>=4.1.0",
"python-multipart>=0.0.9",
] ]
requires-python = ">=3.11" requires-python = ">=3.11"
readme = "README.md" 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"} 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 = {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" recreate-db-base = "python3 src/neo_neo_todo/utils/database.py"
migration-base = "python3 src/neo_neo_todo/migrations/0.py" migration-base = "python3 src/neo_neo_todo/migrations/0.py"

View File

@ -4,7 +4,7 @@ import redis.asyncio as redis
from fastapi import FastAPI from fastapi import FastAPI
from fastapi_limiter import FastAPILimiter 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 from src.neo_neo_todo.utils.database import pool
@ -27,4 +27,5 @@ app = FastAPI(lifespan=lifespan)
# include API routes # include API routes
app.include_router(control.router) app.include_router(control.router)
app.include_router(sessions.router) app.include_router(token.router)
app.include_router(members.router)

View File

@ -34,7 +34,7 @@ def generate_test_data() -> None:
""" """
INSERT INTO members (email, password_hash) INSERT INTO members (email, password_hash)
VALUES ('member@todo.test', VALUES ('member@todo.test',
'$2b$14$6yXjNza30kPCg3LhzZJfqeCWOLM.zyTiQFD4rdWlFHBTfYzzKJMJe') '$2a$12$EDo2Sr6B1sptfbKK7DqMnO3VNZuNfVuDbpjJa3uUO9S9/lYpu2wzK')
""" """
) )
) )

View File

@ -1,10 +0,0 @@
from dataclasses import dataclass
from pydantic import BaseModel, EmailStr
@dataclass
class LoginData(BaseModel):
email: EmailStr
password: str
remember: bool = False

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,6 @@
from fastapi import APIRouter
router = APIRouter(
prefix="/todos",
tags=["todos"],
)

View File

@ -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"}

View File

@ -10,7 +10,11 @@ try:
except KeyError: except KeyError:
raise KeyError("Can't find postgres DB URL") 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: def recreate_db() -> None: