From 43f9b0d47cec867596f20606cb0f6bc3691b011b Mon Sep 17 00:00:00 2001 From: julius Date: Wed, 26 Mar 2025 17:37:02 +0100 Subject: [PATCH] feat: register new user with team-specific token revamp entire `SetPassword` page --- cutt/main.py | 2 + cutt/security.py | 188 +++++++++++++++++------- src/Session.tsx | 4 +- src/SetPassword.tsx | 351 ++++++++++++++++++++++++++++++-------------- 4 files changed, 382 insertions(+), 163 deletions(-) diff --git a/cutt/main.py b/cutt/main.py index cfc287d..5e50eb4 100644 --- a/cutt/main.py +++ b/cutt/main.py @@ -14,6 +14,7 @@ from cutt.security import ( get_current_active_user, login_for_access_token, logout, + register, set_first_password, ) from cutt.player import player_router @@ -177,6 +178,7 @@ api_router.include_router(team_router, dependencies=[Depends(get_current_active_ api_router.include_router(analysis_router) api_router.add_api_route("/token", endpoint=login_for_access_token, methods=["POST"]) api_router.add_api_route("/set_password", endpoint=set_first_password, methods=["POST"]) +api_router.add_api_route("/register", endpoint=register, methods=["POST"]) api_router.add_api_route("/logout", endpoint=logout, methods=["POST"]) app.include_router(api_router) diff --git a/cutt/security.py b/cutt/security.py index 703ec86..d26f799 100644 --- a/cutt/security.py +++ b/cutt/security.py @@ -6,7 +6,7 @@ from pydantic import BaseModel, ValidationError import jwt from jwt.exceptions import ExpiredSignatureError, InvalidTokenError from sqlmodel import Session, select -from cutt.db import TokenDB, engine, Player +from cutt.db import PlayerTeamLink, Team, TokenDB, engine, Player from fastapi.security import ( OAuth2PasswordBearer, OAuth2PasswordRequestForm, @@ -16,6 +16,8 @@ from pydantic_settings import BaseSettings, SettingsConfigDict from passlib.context import CryptContext from sqlalchemy.exc import OperationalError +P = Player + class Config(BaseSettings): secret_key: str = "" @@ -208,72 +210,94 @@ async def logout(response: Response): return {"message": "Successfully logged out"} -def generate_one_time_token(username): +def set_password_token(username: str): user = get_user(username) if user: - expire = timedelta(days=7) + expire = timedelta(days=30) token = create_access_token( - data={"sub": username, "name": user.display_name}, + data={ + "sub": "set password", + "username": username, + "name": user.display_name, + }, expires_delta=expire, ) return token +def register_token(team_id: int): + with Session(engine) as session: + team = session.exec(select(Team).where(Team.id == team_id)).one() + if team: + expire = timedelta(days=30) + token = create_access_token( + data={"sub": "register", "team_id": team_id, "name": team.name}, + expires_delta=expire, + ) + return token + + +def verify_one_time_token(token: str): + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="could not validate token", + ) + with Session(engine) as session: + token_in_db = session.exec( + select(TokenDB).where(TokenDB.token == token).where(TokenDB.used == False) + ).one_or_none() + if token_in_db: + try: + payload = jwt.decode(token, config.secret_key, algorithms=["HS256"]) + return payload + except ExpiredSignatureError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="access token expired", + ) + except (InvalidTokenError, ValidationError): + raise credentials_exception + elif session.exec( + select(TokenDB).where(TokenDB.token == token).where(TokenDB.used == True) + ).one_or_none(): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="token already used", + ) + else: + raise credentials_exception + + +def invalidate_one_time_token(token: str): + with Session(engine) as session: + token_in_db = session.exec(select(TokenDB).where(TokenDB.token == token)).one() + token_in_db.used = True + session.add(token_in_db) + session.commit() + + class FirstPassword(BaseModel): token: str password: str async def set_first_password(req: FirstPassword): - credentials_exception = HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Could not validate token", - ) + payload = verify_one_time_token(req.token) + action: str = payload.get("sub") + if action != "set password": + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="wrong type of token.", + ) + username: str = payload.get("username") with Session(engine) as session: - token_in_db = session.exec( - select(TokenDB) - .where(TokenDB.token == req.token) - .where(TokenDB.used == False) - ).one_or_none() - if token_in_db: - credentials_exception = HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Could not validate token", - ) - try: - payload = jwt.decode(req.token, config.secret_key, algorithms=["HS256"]) - username: str = payload.get("sub") - if username is None: - raise credentials_exception - except ExpiredSignatureError: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Access token expired", - ) - except (InvalidTokenError, ValidationError): - raise credentials_exception - - user = get_user(username) - if user: - user.hashed_password = get_password_hash(req.password) - session.add(user) - token_in_db.used = True - session.add(token_in_db) - session.commit() - return Response( - "Password set successfully", status_code=status.HTTP_200_OK - ) - elif session.exec( - select(TokenDB) - .where(TokenDB.token == req.token) - .where(TokenDB.used == True) - ).one_or_none(): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Token already used", - ) - else: - raise credentials_exception + user = get_user(username) + if user: + user.hashed_password = get_password_hash(req.password) + session.add(user) + session.commit() + invalidate_one_time_token(req.token) + return Response("password set successfully", status_code=status.HTTP_200_OK) class ChangedPassword(BaseModel): @@ -295,17 +319,73 @@ async def change_password( session.add(user) session.commit() return PlainTextResponse( - "Password changed successfully", + "password changed successfully", status_code=status.HTTP_200_OK, media_type="text/plain", ) else: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Wrong password", + detail="wrong password", ) +class RegisterRequest(BaseModel): + token: str + team_id: int + display_name: str + username: str + password: str + email: str | None + number: str | None + + +async def register(req: RegisterRequest): + payload = verify_one_time_token(req.token) + action: str = payload.get("sub") + if action != "register": + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="wrong type of token.", + ) + team_id: int = payload.get("team_id") + if team_id != req.team_id: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="wrong team", + ) + with Session(engine) as session: + if session.exec(select(P).where(P.username == req.username)).one_or_none(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="username exists", + ) + stmt = ( + select(P) + .join(PlayerTeamLink) + .join(Team) + .where(Team.id == team_id, P.display_name == req.display_name) + ) + if session.exec(stmt).one_or_none(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="the name is already taken on this team", + ) + team = session.exec(select(Team).where(Team.id == team_id)).one() + new_player = Player( + username=req.username, + display_name=req.display_name, + email=req.email if req.email else None, + number=req.number, + disabled=False, + teams=[team], + ) + session.add(new_player) + session.commit() + invalidate_one_time_token(req.token) + return PlainTextResponse(f"added {new_player.display_name}") + + async def read_player_me( current_user: Annotated[Player, Depends(get_current_active_user)], ): diff --git a/src/Session.tsx b/src/Session.tsx index c67d089..3774e68 100644 --- a/src/Session.tsx +++ b/src/Session.tsx @@ -62,8 +62,10 @@ export function SessionProvider(props: SessionProviderProps) { useEffect(() => { loadUser(); - loadTeam(); }, []); + useEffect(() => { + loadTeam(); + }, [user]); function onLogin(user: User) { setUser(user); diff --git a/src/SetPassword.tsx b/src/SetPassword.tsx index a3d0881..5548df2 100644 --- a/src/SetPassword.tsx +++ b/src/SetPassword.tsx @@ -1,17 +1,29 @@ import { jwtDecode, JwtPayload } from "jwt-decode"; -import { useEffect, useState } from "react"; -import { apiAuth, baseUrl } from "./api"; +import { ReactNode, useEffect, useState } from "react"; +import { apiAuth, baseUrl, User } from "./api"; import { useNavigate } from "react-router"; import { Eye, EyeSlash } from "./Icons"; import { useSession } from "./Session"; +import { relative } from "path"; +import Header from "./Header"; -interface SetPassToken extends JwtPayload { +interface PassToken extends JwtPayload { + username: string; name: string; + team_id: number; +} + +enum Mode { + register = "register", + set = "set password", + change = "change password", } export const SetPassword = () => { + const [mode, setMode] = useState(); const [name, setName] = useState("after getting your token."); const [username, setUsername] = useState(""); + const [teamID, setTeamID] = useState(); const [currentPassword, setCurrentPassword] = useState(""); const [password, setPassword] = useState(""); const [passwordr, setPasswordr] = useState(""); @@ -19,36 +31,22 @@ export const SetPassword = () => { const [error, setError] = useState(""); const [loading, setLoading] = useState(false); const [visible, setVisible] = useState(false); + const newPlayerTemplate = { + username: "", + display_name: "", + number: "", + email: "", + } as User; + const [player, setPlayer] = useState(newPlayerTemplate); const navigate = useNavigate(); const { user } = useSession(); - useEffect(() => { - if (user) { - setUsername(user.username); - setName(user.display_name); - } else { - const params = new URLSearchParams(window.location.search); - const token = params.get("token"); - if (token) { - setToken(token); - try { - const payload = jwtDecode(token); - if (payload.name) setName(payload.name); - else if (payload.sub) setName(payload.sub); - else setName("Mr. I-have-no Token"); - payload.sub && setUsername(payload.sub); - } catch (InvalidTokenError) { - setName("Mr. I-have-no-valid Token"); - } - } - } - }, []); - async function handleSubmit(e: React.FormEvent) { e.preventDefault(); if (password === passwordr) { setLoading(true); - if (user) { + if (mode === Mode.change) { + //====CHANGING PASSWORD==== const resp = await apiAuth( "player/change_password", { current_password: currentPassword, new_password: password }, @@ -60,7 +58,8 @@ export const SetPassword = () => { setError(resp); setTimeout(() => navigate("/"), 2000); } - } else { + } else if (mode === Mode.set) { + //====SETTING PASSWORD==== const req = new Request(`${baseUrl}api/set_password`, { method: "POST", headers: { @@ -92,106 +91,240 @@ export const SetPassword = () => { throw new Error("Unauthorized"); } } + } else if (mode === Mode.register) { + //====REGISTER NEW USER==== + const req = new Request(`${baseUrl}api/register`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + ...player, + team_id: teamID, + token: token, + password: password, + }), + }); + let resp: Response; + try { + resp = await fetch(req); + } catch (e) { + throw new Error(`request failed: ${e}`); + } + setLoading(false); + + if (resp.ok) { + console.log(resp); + navigate("/", { + replace: true, + state: { username: player.username, password: password }, + }); + } + + if (!resp.ok) { + const { detail } = await resp.json(); + if (detail) setError(detail); + else setError("unauthorized"); + throw new Error("Unauthorized"); + } } } else setError("passwords are not the same"); } - return ( + useEffect(() => { + if (user) { + setUsername(user.username); + setName(user.display_name); + setMode(Mode.change); + } else { + const params = new URLSearchParams(window.location.search); + const token = params.get("token"); + if (token) { + setToken(token); + try { + const payload = jwtDecode(token); + console.log(payload); + switch (payload.sub) { + case "register": + setMode(Mode.register); + if (payload.team_id) setTeamID(payload.team_id); + break; + case "set password": + setMode(Mode.set); + if (payload.username) setUsername(payload.username); + break; + } + if (payload.name) setName(payload.name); + } catch (InvalidTokenError) { + setName("Mr. I-have-no-valid Token"); + } + } + } + }, []); + + let header: ReactNode; + switch (mode) { + case Mode.change: + header =

change your password, {name}

; + break; + case Mode.set: + header = ( + <> +
+

set your password, {name}

+ + ); + break; + case Mode.register: + header = ( + <> +
+

+ register as a member of {name} +

+ + ); + } + + let textInputs: ReactNode; + switch (mode) { + case Mode.change: + textInputs = ( +
+ { + setError(""); + setCurrentPassword(evt.target.value); + }} + /> +
+
+ ); + break; + case Mode.register: + textInputs = ( +
+
+ + { + setPlayer({ + ...player, + display_name: e.target.value, + username: e.target.value.toLowerCase().replace(/\W/g, ""), + }); + }} + /> +
+
+ + { + setPlayer({ ...player, username: e.target.value }); + }} + /> +
+
+ + { + setPlayer({ ...player, number: e.target.value }); + }} + /> +
+
+ + { + setPlayer({ ...player, email: e.target.value }); + }} + /> +
+
+
+ ); + break; + } + + let passwordInputs = ( <> - {user ? ( -

change your password

- ) : ( -

- set your password, -
- {name} -

- )} - {!user && username && ( - - your username is: {username} - - )} +
+ { + setError(""); + setPassword(evt.target.value); + }} + /> +
+
+ { + setError(""); + setPasswordr(evt.target.value); + }} + /> +
+ + ); + + return mode ? ( + <> + {header} +
-
- {user && ( -
- { - setError(""); - setCurrentPassword(evt.target.value); - }} - /> -
-
- )} -
- { - setError(""); - setPassword(evt.target.value); - }} - /> -
-
- { - setError(""); - setPasswordr(evt.target.value); - }} - /> -
-
+ {textInputs} + {passwordInputs}
setVisible(!visible)} > - {visible ? : } + {visible ? : } show passwords
{error && {error}}
@@ -201,5 +334,7 @@ export const SetPassword = () => { {loading && } + ) : ( + ); };