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 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
12
backend/pdm.lock
generated
@ -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"
|
||||||
|
@ -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"
|
||||||
|
@ -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)
|
||||||
|
@ -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')
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
@ -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:
|
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:
|
||||||
|
Loading…
x
Reference in New Issue
Block a user