From c1ff2120ad22002f3ab7bef3c1b7c68a2687dbd5 Mon Sep 17 00:00:00 2001 From: julius Date: Sat, 15 Feb 2025 16:16:40 +0100 Subject: [PATCH 1/7] feat: add hint to submit --- src/Rankings.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Rankings.tsx b/src/Rankings.tsx index 6e9c775..f8fa272 100644 --- a/src/Rankings.tsx +++ b/src/Rankings.tsx @@ -334,7 +334,7 @@ export default function Rankings() { - assign as many or as few players as you want :) + assign as many or as few players as you want and don't forget to submit (💾) when you're done :)
-- 2.48.1 From 18e693bd2d9724ea6ff9ce39aa74425da7420db7 Mon Sep 17 00:00:00 2001 From: julius Date: Sun, 16 Feb 2025 16:38:55 +0100 Subject: [PATCH 2/7] feat: server-side security --- analysis.py | 118 ++-------------------------------------------------- main.py | 13 ++++-- security.py | 2 +- 3 files changed, 15 insertions(+), 118 deletions(-) diff --git a/analysis.py b/analysis.py index 9f0d88e..ea1ed7e 100644 --- a/analysis.py +++ b/analysis.py @@ -1,130 +1,20 @@ -from datetime import timedelta, timezone, datetime +from datetime import datetime import io -from typing import Annotated import base64 -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter from fastapi.responses import JSONResponse from pydantic import BaseModel, Field -import jwt -from jwt.exceptions import InvalidTokenError from sqlmodel import Session, func, select from sqlmodel.sql.expression import SelectOfScalar -from db import Chemistry, Player, engine, User +from db import Chemistry, Player, engine import networkx as nx -from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm -from pydantic_settings import BaseSettings, SettingsConfigDict -from passlib.context import CryptContext import matplotlib matplotlib.use("agg") import matplotlib.pyplot as plt -class Config(BaseSettings): - secret_key: str = "" - access_token_expire_minutes: int = 30 - model_config = SettingsConfigDict( - env_file=".env", env_file_encoding="utf-8", extra="ignore" - ) - - -config = Config() - - -class Token(BaseModel): - access_token: str - token_type: str - - -class TokenData(BaseModel): - username: str | None = None - - -pwd_context = CryptContext(schemes=["argon2"], deprecated="auto") - - -oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") - -analysis_router = APIRouter(prefix="/analysis", dependencies=[Depends(oauth2_scheme)]) - - -def verify_password(plain_password, hashed_password): - return pwd_context.verify(plain_password, hashed_password) - - -def get_password_hash(password): - return pwd_context.hash(password) - - -def get_user(username: str): - with Session(engine) as session: - return session.exec(select(User).where(User.username == username)).one_or_none() - - -def authenticate_user(username: str, password: str): - user = get_user(username) - if not user: - return False - if not verify_password(password, user.hashed_password): - return False - return user - - -def create_access_token(data: dict, expires_delta: timedelta | None = None): - to_encode = data.copy() - if expires_delta: - expire = datetime.now(timezone.utc) + expires_delta - else: - expire = datetime.now(timezone.utc) + timedelta(minutes=15) - to_encode.update({"exp": expire}) - encoded_jwt = jwt.encode(to_encode, config.secret_key, algorithm="HS256") - return encoded_jwt - - -async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]): - credentials_exception = HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Could not validate credentials", - headers={"WWW-Authenticate": "Bearer"}, - ) - try: - payload = jwt.decode(token, config.secret_key, algorithms=["HS256"]) - username: str = payload.get("sub") - if username is None: - raise credentials_exception - token_data = TokenData(username=username) - except InvalidTokenError: - raise credentials_exception - user = get_user(fake_users_db, username=token_data.username) - if user is None: - raise credentials_exception - return user - - -async def get_current_active_user( - current_user: Annotated[User, Depends(get_current_user)], -): - if current_user.disabled: - raise HTTPException(status_code=400, detail="Inactive user") - return current_user - - -@analysis_router.post("/token") -async def login_for_access_token( - form_data: Annotated[OAuth2PasswordRequestForm, Depends()], -) -> Token: - user = authenticate_user(form_data.username, form_data.password) - if not user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Incorrect username or password", - headers={"WWW-Authenticate": "Bearer"}, - ) - access_token_expires = timedelta(minutes=config.access_token_expire_minutes) - access_token = create_access_token( - data={"sub": user.username}, expires_delta=access_token_expires - ) - return Token(access_token=access_token, token_type="bearer") +analysis_router = APIRouter(prefix="/analysis") C = Chemistry diff --git a/main.py b/main.py index 301239f..3ab6c20 100644 --- a/main.py +++ b/main.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, FastAPI, status +from fastapi import APIRouter, Depends, FastAPI, status from fastapi.staticfiles import StaticFiles from db import Player, Team, Chemistry, MVPRanking, engine from sqlmodel import ( @@ -7,7 +7,12 @@ from sqlmodel import ( ) from fastapi.middleware.cors import CORSMiddleware from analysis import analysis_router -from security import login_for_access_token, read_users_me, read_own_items +from security import ( + get_current_active_user, + login_for_access_token, + read_users_me, + read_own_items, +) app = FastAPI(title="cutt") @@ -92,7 +97,9 @@ class SPAStaticFiles(StaticFiles): api_router.include_router(player_router) api_router.include_router(team_router) -api_router.include_router(analysis_router) +api_router.include_router( + analysis_router, dependencies=[Depends(get_current_active_user)] +) api_router.add_api_route("/token", endpoint=login_for_access_token, methods=["POST"]) api_router.add_api_route("/users/me/", endpoint=read_users_me, methods=["GET"]) api_router.add_api_route("/users/me/items/", endpoint=read_own_items, methods=["GET"]) diff --git a/security.py b/security.py index df250a1..dca1e37 100644 --- a/security.py +++ b/security.py @@ -34,7 +34,7 @@ class TokenData(BaseModel): pwd_context = CryptContext(schemes=["argon2"], deprecated="auto") -oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/token") +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/token") def verify_password(plain_password, hashed_password): -- 2.48.1 From fbe17479f76a1fa4d9d3f1ae1285fd6d542b890c Mon Sep 17 00:00:00 2001 From: julius Date: Sun, 16 Feb 2025 16:55:49 +0100 Subject: [PATCH 3/7] feat: react-router-dom -> react-router --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9b9d86e..c41dfec 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-refresh": "^0.4.16", "globals": "^15.14.0", - "react-router-dom": "^7.1.5", + "react-router": "^7.1.5", "typescript": "~5.6.2", "typescript-eslint": "^8.18.2", "vite": "^6.0.5" -- 2.48.1 From 15c9a64de26fe903d3eca703efbb12c1ec18a59e Mon Sep 17 00:00:00 2001 From: julius Date: Sun, 16 Feb 2025 17:22:36 +0100 Subject: [PATCH 4/7] feat: inelegant and buggy version of auth --- security.py | 7 ++-- src/Analysis.tsx | 20 +++++------ src/App.css | 17 +++++++--- src/App.tsx | 9 +++-- src/api.ts | 87 ++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 121 insertions(+), 19 deletions(-) diff --git a/security.py b/security.py index dca1e37..71e1d84 100644 --- a/security.py +++ b/security.py @@ -1,6 +1,6 @@ from datetime import timedelta, timezone, datetime from typing import Annotated -from fastapi import Depends, HTTPException, status +from fastapi import Depends, HTTPException, Response, status from pydantic import BaseModel import jwt from jwt.exceptions import InvalidTokenError @@ -102,7 +102,7 @@ async def get_current_active_user( async def login_for_access_token( - form_data: Annotated[OAuth2PasswordRequestForm, Depends()], + form_data: Annotated[OAuth2PasswordRequestForm, Depends()], response: Response ) -> Token: user = authenticate_user(form_data.username, form_data.password) if not user: @@ -115,6 +115,9 @@ async def login_for_access_token( access_token = create_access_token( data={"sub": user.username}, expires_delta=access_token_expires ) + response.set_cookie( + "Authorization", value=f"Bearer {access_token}", httponly=True, samesite="none" + ) return Token(access_token=access_token, token_type="bearer") diff --git a/src/Analysis.tsx b/src/Analysis.tsx index a60a6b3..dcb8728 100644 --- a/src/Analysis.tsx +++ b/src/Analysis.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from "react"; -import { baseUrl } from "./api"; +import { apiAuth, baseUrl, token } from "./api"; +import useAuthContext from "./AuthContext"; //const debounce = void>( // func: T, @@ -61,18 +62,14 @@ export default function Analysis() { // Function to generate and fetch the graph image async function loadImage() { setLoading(true); - await fetch(`${baseUrl}api/analysis/image`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(params) - }) - .then((resp) => resp.json()) + await apiAuth("analysis/image", params, "POST") .then((data) => { setImage(data.image); setLoading(false); - }); + }).catch((e) => { + const { checkAuth } = useAuthContext(); + checkAuth(); + }) } useEffect(() => { @@ -92,6 +89,9 @@ export default function Analysis() { } } + const { user } = useAuthContext()! + console.log(`logged in as ${user.username}`); + return (
- assign as many or as few players as you want and don't forget to submit (💾) when you're done :) + assign as many or as few players as you want
+ and don't forget to submit (💾) when you're done :)
diff --git a/src/api.ts b/src/api.ts index e90669a..3a48903 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,7 +1,3 @@ -import { useContext } from "react"; -import useAuthContext from "./AuthContext"; -import { createCookie } from "react-router"; - export const baseUrl = import.meta.env.VITE_BASE_URL as string; export const token = () => localStorage.getItem("access_token") as string; @@ -90,15 +86,8 @@ export const login = (req: LoginRequest) => { method: "POST", headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, body: new URLSearchParams(req).toString() - }).then(resp => resp.json() as unknown as Token).then(token => token ? localStorage.setItem("access_token", token.access_token) : console.log("token not acquired")).catch((e) => console.log("catch error " + e + " in login")); -} - -export const cookielogin = (req: LoginRequest) => { - fetch(`${baseUrl}api/token`, { - method: "POST", headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, body: new URLSearchParams(req).toString() - }).then(resp => { createCookie(resp.headers.getSetCookie()) }).catch((e) => console.log("catch error " + e + " in login")); + }).then(resp => resp.json() as Promise).then(token => token ? localStorage.setItem("access_token", token.access_token) : console.log("token not acquired")).catch((e) => console.log("catch error " + e + " in login")); + return Promise } export const logout = () => localStorage.removeItem("access_token"); -- 2.48.1 From df94b151a665fc70a9aac783684a473907369630 Mon Sep 17 00:00:00 2001 From: julius Date: Mon, 17 Feb 2025 23:06:18 +0100 Subject: [PATCH 6/7] feat: improve navigation in footer --- src/Analysis.tsx | 6 ++---- src/App.css | 5 +++++ src/Footer.tsx | 9 ++++++++- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/Analysis.tsx b/src/Analysis.tsx index 7eec5c3..e1b2b75 100644 --- a/src/Analysis.tsx +++ b/src/Analysis.tsx @@ -1,6 +1,5 @@ import { useEffect, useState } from "react"; -import { apiAuth, baseUrl, token } from "./api"; -import useAuthContext from "./AuthContext"; +import { apiAuth } from "./api"; //const debounce = void>( // func: T, @@ -59,7 +58,6 @@ export default function Analysis() { const [showControlPanel, setShowControlPanel] = useState(false); const [loading, setLoading] = useState(true); - const auth = useAuthContext(); // Function to generate and fetch the graph image async function loadImage() { setLoading(true); @@ -68,7 +66,7 @@ export default function Analysis() { setImage(data.image); setLoading(false); }).catch((e) => { - auth.doLogin(); + console.log("best to just reload... ", e); }) } diff --git a/src/App.css b/src/App.css index 606fd9f..7ae39f7 100644 --- a/src/App.css +++ b/src/App.css @@ -19,6 +19,7 @@ body { } footer { + margin-top: 24px; font-size: x-small; } @@ -208,6 +209,10 @@ button, } .navbar { + span { + padding: 4px; + } + button { font-size: medium; margin: 4px 0.5%; diff --git a/src/Footer.tsx b/src/Footer.tsx index 9f4ddee..481937f 100644 --- a/src/Footer.tsx +++ b/src/Footer.tsx @@ -1,6 +1,13 @@ +import { Link } from "react-router"; + export default function Footer() { return