feat(api): insecure login flow skeleton
- Updated backend technical writeup
This commit is contained in:
		@@ -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:
 | 
				
			||||||
 
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user