From 4848a9e9394b283022085a6305d00f94b11cd703 Mon Sep 17 00:00:00 2001 From: l3wdfut4pwr Date: Mon, 27 Apr 2026 13:45:09 +0300 Subject: add username change and logout --- app/routes/__init__.py | 19 +++--- app/routes/auth.py | 58 ------------------ app/routes/auth/__init__.py | 0 app/routes/auth/auth.py | 58 ++++++++++++++++++ app/routes/auth/logout.py | 35 +++++++++++ app/routes/auth/register.py | 119 +++++++++++++++++++++++++++++++++++++ app/routes/me.py | 51 ---------------- app/routes/register.py | 119 ------------------------------------- app/routes/user.py | 21 ------- app/routes/users/__init__.py | 0 app/routes/users/changeusername.py | 40 +++++++++++++ app/routes/users/me.py | 17 ++++++ app/routes/users/security.py | 0 app/routes/users/user.py | 21 +++++++ 14 files changed, 302 insertions(+), 256 deletions(-) delete mode 100644 app/routes/auth.py create mode 100644 app/routes/auth/__init__.py create mode 100644 app/routes/auth/auth.py create mode 100644 app/routes/auth/logout.py create mode 100644 app/routes/auth/register.py delete mode 100644 app/routes/me.py delete mode 100644 app/routes/register.py delete mode 100644 app/routes/user.py create mode 100644 app/routes/users/__init__.py create mode 100644 app/routes/users/changeusername.py create mode 100644 app/routes/users/me.py create mode 100644 app/routes/users/security.py create mode 100644 app/routes/users/user.py (limited to 'app/routes') diff --git a/app/routes/__init__.py b/app/routes/__init__.py index a57869a..65d9c36 100644 --- a/app/routes/__init__.py +++ b/app/routes/__init__.py @@ -1,13 +1,18 @@ from fastapi import APIRouter -from .auth import router as auth_router -from .me import router as me_router -from .register import router as register_router -from .user import router as user_router +from app.routes.auth.auth import router as auth_router +from app.routes.auth.logout import router as logout_router +from app.routes.auth.register import router as register_router +from app.routes.users.changeusername import router as changeusername_router +from app.routes.users.me import router as me_router +from app.routes.users.user import router as user_router router = APIRouter() -router.include_router(register_router, prefix="/auth") -router.include_router(auth_router, prefix="/auth") -router.include_router(user_router) +router.include_router(changeusername_router) router.include_router(me_router) +router.include_router(user_router) + +router.include_router(auth_router, prefix="/auth") +router.include_router(register_router, prefix="/auth") +router.include_router(logout_router, prefix="/auth") diff --git a/app/routes/auth.py b/app/routes/auth.py deleted file mode 100644 index 6e0d410..0000000 --- a/app/routes/auth.py +++ /dev/null @@ -1,58 +0,0 @@ -from fastapi import APIRouter, Depends, HTTPException, Response -from fastapi.security import OAuth2PasswordRequestForm -from sqlalchemy.ext.asyncio import AsyncSession - -from app.auth.jwt import create_access_token, create_refresh_token -from app.models.user import User -from app.utils.db import get_async_session -from app.utils.hash_cfg import verify_password -from app.utils.logger_cfg import logger - -router = APIRouter(tags=["auth"]) - - -@router.post("/login") -async def login( - response: Response, - form_data: OAuth2PasswordRequestForm = Depends(), - session: AsyncSession = Depends(get_async_session), -): - user = await User.get_user_by_email(form_data.username, session=session) - if not user: - user = await User.get_user_by_username( - form_data.username, session=session - ) - - if not user or not user.password: - logger.warning("Login failed | username/email={}", form_data.username) - raise HTTPException(status_code=401, detail="Invalid credentials") - - if not verify_password(form_data.password, user.password): - logger.warning("Login failed | username/email={}", form_data.username) - raise HTTPException(status_code=401, detail="Invalid credentials") - - access_token = create_access_token( - {"sub": str(user.id), "token_version": user.token_version} - ) - refresh_token = create_refresh_token( - {"sub": str(user.id), "token_version": user.token_version} - ) - - response.set_cookie( - key="access_token", - value=access_token, - httponly=True, - secure=False, - samesite="lax", - max_age=60 * 60, - ) - response.set_cookie( - key="refresh_token", - value=refresh_token, - httponly=True, - secure=True, - samesite="lax", - max_age=30 * 24 * 60 * 60, - ) - logger.info("User logged in | id={} username={}", user.id, user.username) - return {"message": "Logged in successfully"} diff --git a/app/routes/auth/__init__.py b/app/routes/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/routes/auth/auth.py b/app/routes/auth/auth.py new file mode 100644 index 0000000..6e0d410 --- /dev/null +++ b/app/routes/auth/auth.py @@ -0,0 +1,58 @@ +from fastapi import APIRouter, Depends, HTTPException, Response +from fastapi.security import OAuth2PasswordRequestForm +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth.jwt import create_access_token, create_refresh_token +from app.models.user import User +from app.utils.db import get_async_session +from app.utils.hash_cfg import verify_password +from app.utils.logger_cfg import logger + +router = APIRouter(tags=["auth"]) + + +@router.post("/login") +async def login( + response: Response, + form_data: OAuth2PasswordRequestForm = Depends(), + session: AsyncSession = Depends(get_async_session), +): + user = await User.get_user_by_email(form_data.username, session=session) + if not user: + user = await User.get_user_by_username( + form_data.username, session=session + ) + + if not user or not user.password: + logger.warning("Login failed | username/email={}", form_data.username) + raise HTTPException(status_code=401, detail="Invalid credentials") + + if not verify_password(form_data.password, user.password): + logger.warning("Login failed | username/email={}", form_data.username) + raise HTTPException(status_code=401, detail="Invalid credentials") + + access_token = create_access_token( + {"sub": str(user.id), "token_version": user.token_version} + ) + refresh_token = create_refresh_token( + {"sub": str(user.id), "token_version": user.token_version} + ) + + response.set_cookie( + key="access_token", + value=access_token, + httponly=True, + secure=False, + samesite="lax", + max_age=60 * 60, + ) + response.set_cookie( + key="refresh_token", + value=refresh_token, + httponly=True, + secure=True, + samesite="lax", + max_age=30 * 24 * 60 * 60, + ) + logger.info("User logged in | id={} username={}", user.id, user.username) + return {"message": "Logged in successfully"} diff --git a/app/routes/auth/logout.py b/app/routes/auth/logout.py new file mode 100644 index 0000000..a55ea9e --- /dev/null +++ b/app/routes/auth/logout.py @@ -0,0 +1,35 @@ +from fastapi import APIRouter, Depends, Response +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth.dependencies import get_current_user +from app.models.user import User +from app.utils.db import get_async_session +from app.utils.logger_cfg import logger + +router = APIRouter(tags=["auth"]) + + +COOKIE_KWARGS = { + "httponly": True, + "secure": False, + "samesite": "lax", + "path": "/", +} + + +@router.post("/logout") +async def logout( + response: Response, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(get_current_user), +): + response.delete_cookie("access_token", **COOKIE_KWARGS) + response.delete_cookie("refresh_token", **COOKIE_KWARGS) + + user.token_version += 1 + session.add(user) + await session.commit() + + logger.info("User logged out everywhere | user_id={}", user.id) + + return {"message": "Logged out successfully"} diff --git a/app/routes/auth/register.py b/app/routes/auth/register.py new file mode 100644 index 0000000..f0b36ed --- /dev/null +++ b/app/routes/auth/register.py @@ -0,0 +1,119 @@ +import re +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Response +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select + +from app.auth.jwt import create_access_token, create_refresh_token +from app.models.integrations import UserIntegration +from app.models.profile import Profile +from app.models.user import User +from app.schemas.user import UserCreate, UserRead +from app.utils.db import get_async_session +from app.utils.hash_cfg import hash_password +from app.utils.logger_cfg import logger + +router = APIRouter(tags=["auth"]) + + +@router.post("/register", response_model=UserRead) +async def register_user( + user: UserCreate, + response: Response, + session: AsyncSession = Depends(get_async_session), +): + logger.debug("Register request received") + + email: Optional[str] = user.email.strip() if user.email else None + logger.debug("Normalized email value: {}", email) + logger.info( + "Registration attempt | username={} email={}", user.username, email + ) + + if not ( + re.search(r"[A-Za-z]", user.password) + and re.search(r"\d", user.password) + and re.search(r"[^\w\s]", user.password) + ): + logger.warning( + "Registration failed | password complexity requirement not met | username={}", + user.username, + ) + raise HTTPException( + status_code=400, + detail={ + "field": "password", + "message": "Попробуйте сочетание букв, цифр и символов.", + }, + ) + + result = await session.execute( + select(User).where(User.username == user.username) + ) + if result.scalars().first(): + logger.warning( + "Registration failed | username already exists | username={}", + user.username, + ) + raise HTTPException( + status_code=400, + detail={"field": "username", "message": "Никнейм уже занят."}, + ) + + if email: + result = await session.execute(select(User).where(User.email == email)) + if result.scalars().first(): + logger.warning( + "Registration failed | email already exists | email={}", email + ) + raise HTTPException( + status_code=400, + detail={"field": "email", "message": "Адрес уже занят."}, + ) + + hashed_password = hash_password(user.password) + + new_user = User( + username=user.username, + email=email, + password=hashed_password, + profile=Profile(), + integrations=UserIntegration(), + ) + + session.add(new_user) + await session.commit() + await session.refresh(new_user, ["profile", "integrations"]) + + logger.success( + "User successfully registered | id={} username={} email={}", + new_user.id, + new_user.username, + new_user.email, + ) + access_token = create_access_token( + {"sub": str(new_user.id), "token_version": new_user.token_version} + ) + refresh_token = create_refresh_token( + {"sub": str(new_user.id), "token_version": new_user.token_version} + ) + + response.set_cookie( + key="access_token", + value=access_token, + httponly=True, + secure=False, + samesite="lax", + max_age=60 * 60, + ) + response.set_cookie( + key="refresh_token", + value=refresh_token, + httponly=True, + secure=False, + samesite="lax", + max_age=30 * 24 * 60 * 60, + ) + + return new_user diff --git a/app/routes/me.py b/app/routes/me.py deleted file mode 100644 index 6d28a80..0000000 --- a/app/routes/me.py +++ /dev/null @@ -1,51 +0,0 @@ -from fastapi import APIRouter, Depends, Request -from sqlalchemy.ext.asyncio import AsyncSession - -from app.auth.jwt import decode_token -from app.models.user import User -from app.utils.db import get_async_session - -router = APIRouter(tags=["auth"]) - - -async def get_current_user_from_cookie( - request: Request, - session: AsyncSession = Depends(get_async_session), -) -> dict: - token = request.cookies.get("access_token") - if not token: - return {"authenticated": False, "user": None} - - try: - payload = decode_token(token) - sub = payload.get("sub") - if sub is None: - return {"authenticated": False, "user": None} - user_id = int(sub) - except ValueError, TypeError: - return {"authenticated": False, "user": None} - - user = await User.get_user_by_id(user_id, session=session) - if not user or user.token_version != payload.get("token_version"): - return {"authenticated": False, "user": None} - - return { - "authenticated": True, - "user": { - "id": user.id, - "username": user.username, - "password": user.has_password, - "google_id": user.google_id, - "email": user.email, - "premium": user.premium, - "is_banned": user.is_banned, - "is_moderator": user.is_moderator, - }, - } - - -@router.get("/me") -async def read_current_user( - user_info: dict = Depends(get_current_user_from_cookie), -): - return user_info diff --git a/app/routes/register.py b/app/routes/register.py deleted file mode 100644 index f0b36ed..0000000 --- a/app/routes/register.py +++ /dev/null @@ -1,119 +0,0 @@ -import re -from typing import Optional - -from fastapi import APIRouter, Depends, HTTPException, Response -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.future import select - -from app.auth.jwt import create_access_token, create_refresh_token -from app.models.integrations import UserIntegration -from app.models.profile import Profile -from app.models.user import User -from app.schemas.user import UserCreate, UserRead -from app.utils.db import get_async_session -from app.utils.hash_cfg import hash_password -from app.utils.logger_cfg import logger - -router = APIRouter(tags=["auth"]) - - -@router.post("/register", response_model=UserRead) -async def register_user( - user: UserCreate, - response: Response, - session: AsyncSession = Depends(get_async_session), -): - logger.debug("Register request received") - - email: Optional[str] = user.email.strip() if user.email else None - logger.debug("Normalized email value: {}", email) - logger.info( - "Registration attempt | username={} email={}", user.username, email - ) - - if not ( - re.search(r"[A-Za-z]", user.password) - and re.search(r"\d", user.password) - and re.search(r"[^\w\s]", user.password) - ): - logger.warning( - "Registration failed | password complexity requirement not met | username={}", - user.username, - ) - raise HTTPException( - status_code=400, - detail={ - "field": "password", - "message": "Попробуйте сочетание букв, цифр и символов.", - }, - ) - - result = await session.execute( - select(User).where(User.username == user.username) - ) - if result.scalars().first(): - logger.warning( - "Registration failed | username already exists | username={}", - user.username, - ) - raise HTTPException( - status_code=400, - detail={"field": "username", "message": "Никнейм уже занят."}, - ) - - if email: - result = await session.execute(select(User).where(User.email == email)) - if result.scalars().first(): - logger.warning( - "Registration failed | email already exists | email={}", email - ) - raise HTTPException( - status_code=400, - detail={"field": "email", "message": "Адрес уже занят."}, - ) - - hashed_password = hash_password(user.password) - - new_user = User( - username=user.username, - email=email, - password=hashed_password, - profile=Profile(), - integrations=UserIntegration(), - ) - - session.add(new_user) - await session.commit() - await session.refresh(new_user, ["profile", "integrations"]) - - logger.success( - "User successfully registered | id={} username={} email={}", - new_user.id, - new_user.username, - new_user.email, - ) - access_token = create_access_token( - {"sub": str(new_user.id), "token_version": new_user.token_version} - ) - refresh_token = create_refresh_token( - {"sub": str(new_user.id), "token_version": new_user.token_version} - ) - - response.set_cookie( - key="access_token", - value=access_token, - httponly=True, - secure=False, - samesite="lax", - max_age=60 * 60, - ) - response.set_cookie( - key="refresh_token", - value=refresh_token, - httponly=True, - secure=False, - samesite="lax", - max_age=30 * 24 * 60 * 60, - ) - - return new_user diff --git a/app/routes/user.py b/app/routes/user.py deleted file mode 100644 index 1eb096d..0000000 --- a/app/routes/user.py +++ /dev/null @@ -1,21 +0,0 @@ -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.ext.asyncio import AsyncSession - -from app.models.user import User -from app.schemas.user import UserRead -from app.utils.db import get_async_session - -router = APIRouter(prefix="/users", tags=["users"]) - - -@router.get("/{username}", response_model=UserRead) -async def get_user( - username: str, - session: AsyncSession = Depends(get_async_session), -): - user = await User.get_user_by_username(username, session=session) - - if not user: - raise HTTPException(status_code=404, detail="User not found") - - return UserRead.model_validate(user) diff --git a/app/routes/users/__init__.py b/app/routes/users/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/routes/users/changeusername.py b/app/routes/users/changeusername.py new file mode 100644 index 0000000..66ba8da --- /dev/null +++ b/app/routes/users/changeusername.py @@ -0,0 +1,40 @@ +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth.dependencies import get_current_user +from app.models.user import User +from app.utils.db import get_async_session + +router = APIRouter() + + +class ChangeUsernameRequest(BaseModel): + username: str + + +@router.patch("/users/change-username") +async def change_username( + data: ChangeUsernameRequest, + user_info: dict = Depends(get_current_user), + session: AsyncSession = Depends(get_async_session), +): + if not user_info["authenticated"]: + raise HTTPException(status_code=401, detail="Not authenticated") + + user = user_info["user"] + + if len(data.username) < 3: + raise HTTPException(status_code=400, detail="Username too short") + + db_user = await session.get(User, user["id"]) + + if not db_user: + raise HTTPException(status_code=404, detail="User not found") + + db_user.username = data.username + + await session.commit() + await session.refresh(db_user) + + return {"success": True, "username": db_user.username} diff --git a/app/routes/users/me.py b/app/routes/users/me.py new file mode 100644 index 0000000..a54fbfe --- /dev/null +++ b/app/routes/users/me.py @@ -0,0 +1,17 @@ +from fastapi import APIRouter, Depends + +from app.auth.dependencies import get_optional_user +from app.models.user import User +from app.schemas.user import MeResponse, UserRead + +router = APIRouter(tags=["auth"]) + + +@router.get("/me", response_model=MeResponse) +async def me( + user: User | None = Depends(get_optional_user), +): + return MeResponse( + authenticated=user is not None, + user=UserRead.model_validate(user) if user else None, + ) diff --git a/app/routes/users/security.py b/app/routes/users/security.py new file mode 100644 index 0000000..e69de29 diff --git a/app/routes/users/user.py b/app/routes/users/user.py new file mode 100644 index 0000000..1eb096d --- /dev/null +++ b/app/routes/users/user.py @@ -0,0 +1,21 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.user import User +from app.schemas.user import UserRead +from app.utils.db import get_async_session + +router = APIRouter(prefix="/users", tags=["users"]) + + +@router.get("/{username}", response_model=UserRead) +async def get_user( + username: str, + session: AsyncSession = Depends(get_async_session), +): + user = await User.get_user_by_username(username, session=session) + + if not user: + raise HTTPException(status_code=404, detail="User not found") + + return UserRead.model_validate(user) -- cgit v1.3-3-g829e