feat(api): insecure login flow skeleton
- Updated backend technical writeup
This commit is contained in:
parent
fbba4d6d43
commit
0bd4508d11
@ -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)
|
||||
|
12
backend/pdm.lock
generated
12
backend/pdm.lock
generated
@ -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"
|
||||
|
@ -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"
|
||||
|
@ -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)
|
||||
|
@ -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')
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
@ -1,10 +0,0 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from pydantic import BaseModel, EmailStr
|
||||
|
||||
|
||||
@dataclass
|
||||
class LoginData(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
remember: bool = False
|
42
backend/src/neo_neo_todo/routes/members.py
Normal file
42
backend/src/neo_neo_todo/routes/members.py
Normal 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
|
@ -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
|
6
backend/src/neo_neo_todo/routes/todo.py
Normal file
6
backend/src/neo_neo_todo/routes/todo.py
Normal file
@ -0,0 +1,6 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/todos",
|
||||
tags=["todos"],
|
||||
)
|
56
backend/src/neo_neo_todo/routes/token.py
Normal file
56
backend/src/neo_neo_todo/routes/token.py
Normal 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"}
|
@ -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:
|
||||
|
Loading…
x
Reference in New Issue
Block a user