From 18ddb2f3328997f78d5103e016db9fc8eca49a9a Mon Sep 17 00:00:00 2001 From: c-d-p Date: Wed, 16 Apr 2025 21:32:57 +0200 Subject: [PATCH] working auth + users systems --- backend/.env | 2 + backend/.gitignore | 1 + backend/.vscode/settings.json | 7 + backend/TODO | 2 + backend/__pycache__/main.cpython-312.pyc | Bin 0 -> 1301 bytes .../__pycache__/celery_app.cpython-312.pyc | Bin 0 -> 544 bytes .../core/__pycache__/config.cpython-312.pyc | Bin 0 -> 1016 bytes .../core/__pycache__/database.cpython-312.pyc | Bin 0 -> 1633 bytes .../__pycache__/exceptions.cpython-312.pyc | Bin 0 -> 1650 bytes backend/core/celery_app.py | 10 + backend/core/config.py | 21 ++ backend/core/database.py | 36 ++++ backend/core/exceptions.py | 27 +++ backend/docker-compose.yml | 23 +++ backend/main.py | 25 +++ backend/modules/__init__.py | 0 .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 144 bytes .../admin/__pycache__/api.cpython-312.pyc | Bin 0 -> 1637 bytes backend/modules/admin/api.py | 30 +++ backend/modules/admin/services.py | 4 + backend/modules/auth/__init__.py | 0 .../auth/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 149 bytes .../auth/__pycache__/api.cpython-312.pyc | Bin 0 -> 4664 bytes .../__pycache__/dependencies.cpython-312.pyc | Bin 0 -> 1369 bytes .../auth/__pycache__/models.cpython-312.pyc | Bin 0 -> 1459 bytes .../auth/__pycache__/schemas.cpython-312.pyc | Bin 0 -> 1904 bytes .../auth/__pycache__/security.cpython-312.pyc | Bin 0 -> 8548 bytes .../auth/__pycache__/service.cpython-312.pyc | Bin 0 -> 993 bytes .../auth/__pycache__/services.cpython-312.pyc | Bin 0 -> 1637 bytes .../test_auth.cpython-312-pytest-8.3.5.pyc | Bin 0 -> 7745 bytes .../auth/__pycache__/utils.cpython-312.pyc | Bin 0 -> 1243 bytes backend/modules/auth/api.py | 74 +++++++ backend/modules/auth/dependencies.py | 18 ++ backend/modules/auth/models.py | 25 +++ backend/modules/auth/schemas.py | 33 ++++ backend/modules/auth/security.py | 172 +++++++++++++++++ backend/modules/auth/services.py | 30 +++ backend/modules/auth/tasks.py | 0 backend/modules/user/__init__.py | 0 .../user/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 149 bytes .../user/__pycache__/api.cpython-312.pyc | Bin 0 -> 4228 bytes backend/modules/user/api.py | 78 ++++++++ backend/requirements.txt | 45 +++++ backend/tests/__init__.py | 0 .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 142 bytes .../conftest.cpython-312-pytest-8.3.5.pyc | Bin 0 -> 3483 bytes .../test_admin.cpython-312-pytest-8.3.5.pyc | Bin 0 -> 5649 bytes .../test_auth.cpython-312-pytest-8.3.5.pyc | Bin 0 -> 23885 bytes .../test_conf.cpython-312-pytest-8.3.5.pyc | Bin 0 -> 1149 bytes .../test_sample.cpython-312-pytest-8.3.5.pyc | Bin 0 -> 1035 bytes ...test_security.cpython-312-pytest-8.3.5.pyc | Bin 0 -> 4988 bytes ...test_services.cpython-312-pytest-8.3.5.pyc | Bin 0 -> 3176 bytes backend/tests/conftest.py | 58 ++++++ .../__pycache__/generators.cpython-312.pyc | Bin 0 -> 2270 bytes backend/tests/helpers/generators.py | 42 ++++ backend/tests/test_auth.py | 180 ++++++++++++++++++ 56 files changed, 943 insertions(+) create mode 100644 backend/.env create mode 100644 backend/.gitignore create mode 100644 backend/.vscode/settings.json create mode 100644 backend/TODO create mode 100644 backend/__pycache__/main.cpython-312.pyc create mode 100644 backend/core/__pycache__/celery_app.cpython-312.pyc create mode 100644 backend/core/__pycache__/config.cpython-312.pyc create mode 100644 backend/core/__pycache__/database.cpython-312.pyc create mode 100644 backend/core/__pycache__/exceptions.cpython-312.pyc create mode 100644 backend/core/celery_app.py create mode 100644 backend/core/config.py create mode 100644 backend/core/database.py create mode 100644 backend/core/exceptions.py create mode 100644 backend/docker-compose.yml create mode 100644 backend/main.py create mode 100644 backend/modules/__init__.py create mode 100644 backend/modules/__pycache__/__init__.cpython-312.pyc create mode 100644 backend/modules/admin/__pycache__/api.cpython-312.pyc create mode 100644 backend/modules/admin/api.py create mode 100644 backend/modules/admin/services.py create mode 100644 backend/modules/auth/__init__.py create mode 100644 backend/modules/auth/__pycache__/__init__.cpython-312.pyc create mode 100644 backend/modules/auth/__pycache__/api.cpython-312.pyc create mode 100644 backend/modules/auth/__pycache__/dependencies.cpython-312.pyc create mode 100644 backend/modules/auth/__pycache__/models.cpython-312.pyc create mode 100644 backend/modules/auth/__pycache__/schemas.cpython-312.pyc create mode 100644 backend/modules/auth/__pycache__/security.cpython-312.pyc create mode 100644 backend/modules/auth/__pycache__/service.cpython-312.pyc create mode 100644 backend/modules/auth/__pycache__/services.cpython-312.pyc create mode 100644 backend/modules/auth/__pycache__/test_auth.cpython-312-pytest-8.3.5.pyc create mode 100644 backend/modules/auth/__pycache__/utils.cpython-312.pyc create mode 100644 backend/modules/auth/api.py create mode 100644 backend/modules/auth/dependencies.py create mode 100644 backend/modules/auth/models.py create mode 100644 backend/modules/auth/schemas.py create mode 100644 backend/modules/auth/security.py create mode 100644 backend/modules/auth/services.py create mode 100644 backend/modules/auth/tasks.py create mode 100644 backend/modules/user/__init__.py create mode 100644 backend/modules/user/__pycache__/__init__.cpython-312.pyc create mode 100644 backend/modules/user/__pycache__/api.cpython-312.pyc create mode 100644 backend/modules/user/api.py create mode 100644 backend/requirements.txt create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/__pycache__/__init__.cpython-312.pyc create mode 100644 backend/tests/__pycache__/conftest.cpython-312-pytest-8.3.5.pyc create mode 100644 backend/tests/__pycache__/test_admin.cpython-312-pytest-8.3.5.pyc create mode 100644 backend/tests/__pycache__/test_auth.cpython-312-pytest-8.3.5.pyc create mode 100644 backend/tests/__pycache__/test_conf.cpython-312-pytest-8.3.5.pyc create mode 100644 backend/tests/__pycache__/test_sample.cpython-312-pytest-8.3.5.pyc create mode 100644 backend/tests/__pycache__/test_security.cpython-312-pytest-8.3.5.pyc create mode 100644 backend/tests/__pycache__/test_services.cpython-312-pytest-8.3.5.pyc create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/helpers/__pycache__/generators.cpython-312.pyc create mode 100644 backend/tests/helpers/generators.py create mode 100644 backend/tests/test_auth.py diff --git a/backend/.env b/backend/.env new file mode 100644 index 0000000..0726ac7 --- /dev/null +++ b/backend/.env @@ -0,0 +1,2 @@ +PEPPER = "LsD7%" +JWT_SECRET_KEY="1c8cf3ca6972b365f8108dad247e61abdcb6faff5a6c8ba00cb6fa17396702bf" \ No newline at end of file diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..40bca31 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1 @@ +/env \ No newline at end of file diff --git a/backend/.vscode/settings.json b/backend/.vscode/settings.json new file mode 100644 index 0000000..9b38853 --- /dev/null +++ b/backend/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "python.testing.pytestArgs": [ + "tests" + ], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true +} \ No newline at end of file diff --git a/backend/TODO b/backend/TODO new file mode 100644 index 0000000..ffcd863 --- /dev/null +++ b/backend/TODO @@ -0,0 +1,2 @@ +Pedantic: + - Shouldn't really return a 409 Conflict when user made with same username, could be used to enumerate users. diff --git a/backend/__pycache__/main.cpython-312.pyc b/backend/__pycache__/main.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bbfcf28fb39881931257daea40dbc4245f68d6e3 GIT binary patch literal 1301 zcma)4&1)1%6o1v-Gu_jfk4zx4LJk2Fv_+;xy(vO=UB!>6K--j zCHnz;4Ia!@QBXS`(6pKaB%qD%SRvr^>Z*&VL5%M9Y~4Jg&yLM@h6@jj zr2PTbFy2k*@KUANx3H}JxC zFZ5?mqB$tYPMj$~;SwpHC`x;cED(Gi+>9Y*bhwr$^M zA`UWTJBnhVc=Y2o^L#I$rsGDY({rggIdOEtoVJ|{m>)B+y|5L{a?STcP%R;tBUzdsr(m z7@Ng1O$1Z@UI&}2!-u~h#uVGgOJ-R`kSf?$Cy<8?rNs#8Me6g?&r?&UrqaYQk#b)5 zLdWmBG}m?))^QiuAWk2(1KITl**Kb_wDDD(-phoLw=nhsc0Pwa>9_AW?0yRMH?Zd= zH0RVcXs$qW-gpEfYcRe7qgDfjiavM;;t|bm+|TPiK+;7|yXVl=TUf9y|X4 literal 0 HcmV?d00001 diff --git a/backend/core/__pycache__/celery_app.cpython-312.pyc b/backend/core/__pycache__/celery_app.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9e9d3f7d686304411ebeda974e02c1dd6585a2c0 GIT binary patch literal 544 zcmbVJF;Buk7`eVj22JgqP-!W-LLzMb12zZa;)Ky3 z!R}HfND~KR9NbKB!kBnPiMwBNcklaN-uGT|kHMe-XeB?tDq9eM7q^*}{}+sJDYya% zAgBWamLYU8qca9uW+7kzhKNNT%%1Zz>EC+|ke4!3m%pitD8t=&|E2R$9bbVD{(H~Q z&N65VV&KhfMCfo?tYgxqh_`UFsnsi%<1rLX>F@+2&DxOV3Eb!~@;W`qPRKA(OUIU^ zw3=0^saQ2jWF795m^Dls->IV3a2+{5t*+`Vg!e?w;nb-EVX2`LP)&j*)vTXs72*bR zgoB-2p>&cjmJVllzj$zX2gYu~i6mD|1IsFE$f}93yqhUx};VW2r0?W_-g^@=X1;gFyFd_~jq8*O+V!iw@ks2gY_G-G% y*n#Y5KGMAyM&pBM+>WgFlD(thT6(aSwiB6t$ezoMf-!q;<-_k4m`jc

XMi?ulst literal 0 HcmV?d00001 diff --git a/backend/core/__pycache__/config.cpython-312.pyc b/backend/core/__pycache__/config.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ccff7aa6981cf2a247eb55f9c87cb9360eb4d635 GIT binary patch literal 1016 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 Y$|XogAM?zobKf%h;Qj`*jCeu+0{Qk?vH$=8 literal 0 HcmV?d00001 diff --git a/backend/core/celery_app.py b/backend/core/celery_app.py new file mode 100644 index 0000000..8057de0 --- /dev/null +++ b/backend/core/celery_app.py @@ -0,0 +1,10 @@ +# core/celery_app.py +from celery import Celery +from core.config import settings + +celery = Celery( + "maia", + broker=f"redis://{settings.REDIS_HOST}:{settings.REDIS_PORT}/0", + backend=f"redis://{settings.REDIS_HOST}:{settings.REDIS_PORT}/1", + include=["modules.auth.tasks"], # List all task modules here +) diff --git a/backend/core/config.py b/backend/core/config.py new file mode 100644 index 0000000..b37efe8 --- /dev/null +++ b/backend/core/config.py @@ -0,0 +1,21 @@ +# core/config.py +from pydantic_settings import BaseSettings +from os import getenv +from dotenv import load_dotenv + +load_dotenv() # Load .env file + +class Settings(BaseSettings): + DB_URL: str = "postgresql://maia:maia@localhost:5432/maia" + + REDIS_HOST: str = "localhost" + REDIS_PORT: int = 6379 + + JWT_ALGORITHM: str = "HS256" + ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 + REFRESH_TOKEN_EXPIRE_DAYS: int = 7 + + PEPPER: str = getenv("PEPPER", "") + JWT_SECRET_KEY: str = getenv("JWT_SECRET_KEY", "") + +settings = Settings() diff --git a/backend/core/database.py b/backend/core/database.py new file mode 100644 index 0000000..88e5977 --- /dev/null +++ b/backend/core/database.py @@ -0,0 +1,36 @@ +# core/database.py +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, Session, declarative_base +from typing import Generator + +from core.config import settings + +Base = declarative_base() # Used for models + +_engine = None +_SessionLocal = None + +def get_engine(): + global _engine + if _engine is None: + if not settings.DB_URL: + raise ValueError("DB_URL is not set in Settings.") + print(f"Connecting to database at {settings.DB_URL}") + _engine = create_engine(settings.DB_URL) + Base.metadata.create_all(_engine) # Create tables here + return _engine + +def get_sessionmaker(): + global _SessionLocal + if _SessionLocal is None: + engine = get_engine() + _SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + return _SessionLocal + +def get_db() -> Generator[Session, None, None]: + SessionLocal = get_sessionmaker() + db = SessionLocal() + try: + yield db + finally: + db.close() \ No newline at end of file diff --git a/backend/core/exceptions.py b/backend/core/exceptions.py new file mode 100644 index 0000000..0d6a1b5 --- /dev/null +++ b/backend/core/exceptions.py @@ -0,0 +1,27 @@ +from fastapi import HTTPException +from starlette.status import ( + HTTP_400_BAD_REQUEST, + HTTP_401_UNAUTHORIZED, + HTTP_403_FORBIDDEN, + HTTP_404_NOT_FOUND, + HTTP_500_INTERNAL_SERVER_ERROR, + HTTP_409_CONFLICT, +) + +def bad_request_exception(detail: str = "Bad Request"): + return HTTPException(status_code=HTTP_400_BAD_REQUEST, detail=detail) + +def unauthorized_exception(detail: str = "Unauthorized"): + return HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail=detail) + +def forbidden_exception(detail: str = "Forbidden"): + return HTTPException(status_code=HTTP_403_FORBIDDEN, detail=detail) + +def not_found_exception(detail: str = "Not Found"): + return HTTPException(status_code=HTTP_404_NOT_FOUND, detail=detail) + +def internal_server_error_exception(detail: str = "Internal Server Error"): + return HTTPException(status_code=HTTP_500_INTERNAL_SERVER_ERROR, detail=detail) + +def conflict_exception(detail: str = "Conflict"): + return HTTPException(status_code=HTTP_409_CONFLICT, detail=detail) \ No newline at end of file diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml new file mode 100644 index 0000000..2d9c274 --- /dev/null +++ b/backend/docker-compose.yml @@ -0,0 +1,23 @@ +# docker-compose.yml +services: + postgres: + image: postgres:14 + environment: + POSTGRES_USER: maia + POSTGRES_PASSWORD: maia + POSTGRES_DB: maia + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + + redis: + image: redis:7 + ports: + - "6379:6379" + volumes: + - redis_data:/data + +volumes: + postgres_data: + redis_data: \ No newline at end of file diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..cef61bd --- /dev/null +++ b/backend/main.py @@ -0,0 +1,25 @@ +# main.py +from fastapi import FastAPI, Depends +from core.database import get_engine, Base +from modules.auth.api import router as auth_router +from modules.user.api import router as user_router +from modules.admin.api import router as admin_router +from modules.auth.dependencies import admin_only +import logging + +from modules.auth.security import get_current_user + +logging.getLogger('passlib').setLevel(logging.ERROR) # fix bc package logging is broken + +# Create DB tables (remove in production; use migrations instead) +def lifespan(app): + # Base.metadata.drop_all(bind=get_engine()) + Base.metadata.create_all(bind=get_engine()) + yield + +app = FastAPI(lifespan=lifespan) + +# Include all module routers +app.include_router(auth_router, prefix="/api/auth") +app.include_router(user_router, prefix="/api/user") +app.include_router(admin_router, prefix="/api/admin", dependencies=[Depends(admin_only)]) \ No newline at end of file diff --git a/backend/modules/__init__.py b/backend/modules/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/modules/__pycache__/__init__.cpython-312.pyc b/backend/modules/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7ed90b2a15a4c441547a5dc4a40afbbc5266312a GIT binary patch literal 144 zcmX@j%ge<81iae+(n0iN5P=Rpvj9b=GgLBYGWxA#C}INgK7-W!($~+(&rQ`&PASk& z&QD3z_jUAi)K5xG&Q8rs(a+6KDa}bO){l?R%*!l^kJl@x{Ka7d5w$B~1?p!6;$jfv NBQql-V-Yiu1prEcAZh>r literal 0 HcmV?d00001 diff --git a/backend/modules/admin/__pycache__/api.cpython-312.pyc b/backend/modules/admin/__pycache__/api.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..171f615c8de704609b66f97b7411bff25df4c98f GIT binary patch literal 1637 zcmb7E&1)M+6rb50tv*+=61k4;hAO2|trWC{e&o`$s8O8A#coO3i@=1Hc5F-Dm6X{P zL`F6?G1#GoIP~Ba0wuK2ltBKGUZlE3W@#Hk>7h3{?#Y+-%}8qpK?{AbZ{F9udGnk1 z_6JoBA)wz5{+_=oBlJ60Jo5L2!yiG|K?X9gi7cGQn9GtWS#n;sNS+8?Hhq?oSADC&-=wx(s#YL?e5l*hRpzO4%P4UDGaF(9oZqOL^ayLkUgt^XZR#opH7TsV z+jYtsjN|Vrb0w1x4q-3|j5oaN%Vei>Gpp1aX^*|LOWy5}%WZP`C-T7_Q8sdGx%H3sBEzkbR4aV3 zMJ|e2nn}d~s79?+nP>b$u76Iq9hgbRhlBBXa-;TbQ#XqX)M})w%wh^Rs>Ld!Y43#u z_AJ01XZquv*6Yqf8Wd{U0_7gAiuhSDSBC$P7<2)c_;rYa1e`~K_j+00XA_WKF}60gdFAda2NK4~Lm5f2E|TVg(w) S2*kx8#z$sGM#ds$APWEx{31&L literal 0 HcmV?d00001 diff --git a/backend/modules/auth/__pycache__/api.cpython-312.pyc b/backend/modules/auth/__pycache__/api.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c55ab03f9947376ae8c79a339a4ab3ca550e787c GIT binary patch literal 4664 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 D$LB)o literal 0 HcmV?d00001 diff --git a/backend/modules/auth/__pycache__/models.cpython-312.pyc b/backend/modules/auth/__pycache__/models.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3ae7a10bcfb64cc6b62a1c69f1a496863df63b71 GIT binary patch literal 1459 zcmbtUL2ukd6drqRuh-tqHfh?56j4B>#g&?^5R?n5lw?y9q-;>L<=~6u>`b$6Z0}}l zi~8nDJyhbdsE76#aiU6p0G#*%9En7CB!nv9zzu1aOE2)Ay&EMFH%9XFd*95wnfbmq z^JA@M5NHd(|Fa`xLVm+w+O&x?%D{O_D4~*10?C#n^krWTN_HtwY$Z@_HPCD=DBI;g zxAnZg%Tk5zIH4Ob z#}S6(1U;Jg=vN(QFLC`zkGj;{SZ@jC?$+v^=VXNIz5bxl?FFpSq5VduM_FTIX?>}& z?RIuqNE=0-sNp7Y7X#);i~W?FFflhMPgDi-h`g+x%~oHUvk#jb_n&v4IGze9w$V3W zP99GtOkpaOUz84@0I=Cwj+FsZjxhZ{uu6d)OvtkhjNvm`b&7%L)%y!Y-sAR#b|m@C zsVw!hZ0W!ga4S?~IBGwagxTj_;PTYjWhsPO5_)?HyKTur4-?;aw?URNW%t~~k6YFm zJ_D=qI+`qcEhoxU4IU-mf7Yo=g- zE+5aa5)>TE&tkfq`E%%9m>RrW(H0nxX#QfhF|5=F<^A?yP+oHa!34voD^6*(MD5`tsnxlf`d8`tJQ7u77|1&|J%!uW;H|x28(tQYVz2 zTrV-0EVq%IT#ydoT*9brU!zUz0oEJ!)hcWk4$$3pmghYG`o zMEg9=ACwY2JVim|g6DfYSqZQ8Pl{W7i{F4ucu%5_!TeQ{BLj*z;8USO79NZe-rrSNBI}}pj9IP literal 0 HcmV?d00001 diff --git a/backend/modules/auth/__pycache__/schemas.cpython-312.pyc b/backend/modules/auth/__pycache__/schemas.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..dffe61b8effc35537571c994a32cc7434b4bcdcb GIT binary patch literal 1904 zcma)6&ub(_6t3#7?&;~w`4vAdANQhj_5*aD6HYj7lMZcK6#G`&?yx4K#3COOZXXiP1Us>_HBHAv z9f-OUQP)Is5cNb(SSRdawrApbi2D<>^Cmh2(ZWR3H_;+QXD6aFgSlY-cib+Znr(Lb z9cc3(L`rNWT(sltpbRfKlCyW<`#BM$NjYg++eFxcarywFre|S~+f7H<-2N&DQN|g@ z26o_R=V|h}=;}fg$3m$vHDA7I!gSCR4PBICM+&uOv63_A($x3OOc z!!P<#`(ngasnl~}7a!>Y zHoThcs;W^x-K(m2Pjn)+-Wy1~O9Kvc)H1-gWK>#w!4^mJ<%3T~t84pP!~5Ug`tH3` zwkGE&pxIOz!$ zcoO|6XAlYqw-Aa5vj~d-nzdV?(j0yprVH3BUjlBRRsbNw<;wm?NAa+B{Midu8Qodh ze{@tEmXGVFY$;octF9$UA%M>r9(-jEOp#kn23OCtw+*zPByFLch<93D%`lHf;7pO% z&PHvk-q39O)6FN-eDA>i5&`p4nCl@K70UaYqnU+oY8g8mzQ*pAb>D`OD|R2Ik?e!w1zDy+74DaCI{3mpu5*y`}2qkzG>JMViE*xxS;1>_;Sq3=g z<_unkk;x2hGSPqrw$3B($59&ZHRLKR3fw7~P4vIH;z1JOHoOWA16*$}@7Ipr7_#Gg zr))W+ic_vpy{Jy)>|+?2q2){~W&!)4(yEtoh>{I$0&Vpv_=4fU3?n?rV$>tx}TYhT* literal 0 HcmV?d00001 diff --git a/backend/modules/auth/__pycache__/security.cpython-312.pyc b/backend/modules/auth/__pycache__/security.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f562ca5ceb6246f71046e7eb7fb9a02c72960fa6 GIT binary patch literal 8548 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 dG8A9K=b@Y=nqtyF6*RN6x>GVYAc+TCoN*|jJU zViD>;(4)r|d+^x*(2GT(GQ?9Uw6}mgc_^LPO`_5V^WOL7z4z_RxAQfZ%K~A)Zv1`Y zD**hENym&fIGqt-8(@H;2YgtDP-4YXe6_5`SoJhNRZhiN^U}Uv)&))x1EvYZbYJ7 zMLyw1cTd3!JWWX$(W;}i4=sZxmdsDNaND4+G|UhR7#oIrZTudF-KlzacU=uro!&aA ztDluUA&|6nt*$KT3BKs-T89yeZ!Kjm))Kte-iqty|MiOuVXjkO%6HItJnx(XmO8sO zIDta6fEX#VMA4$lij*uzE+yDfs5~z3l~NMi71J_!_BHY%@`TcW(zNKIQZue1jCtA# zeBTYZPRSxAtU{$6JnsjXc=IdBb1@2um5QmUd@EGmnD)pnP5vb&2a3|p?%I{WC$@uY zwj<{4=i^i3_5yO=k}9?ZW8@KLqbRJ{Vs6ECNU65UO@|WkiFwII3xZ2tf|Z49fMY$= zSZn474zAxn%#R%yW6l1d-KU5Bqm36$vv+4;|Iv~8xG{08r9Vt=Om5xVoRM&9V`}r6 z1k)SSTj7ybXqwl)o5SDC;ob1i968WNP6a!dwX!_VmObP-72>a!0_s!wLU>>Ms3m#S zQmh0^6f0b+Fgb@SW6pnAj@dl`OT^LD*-Erb!4Y~)B~Ndg?RV@|vr4{+R;okTeiVA)oj zWH}k&gq?E6%3}dm>~uMelSIWS@r;QlO(d+6L^X}aH|6qpTghc!LhYR7C$AC4EVm(` z{4FC?=yA|j32V9yM#MT(Ggxh#r7+HswH+c42Srs zDAbpObqrU0g%aMP4O85FD2hOF@f__+aBDB#QMo{&U}<}R?@C_04smY?ALfc8>&Z7l z#Bvl>PrdE+(9Mq#8tzx$j@TZU)@4toD$hg_I2P@?EAffQ>J3l5p5X69Rz4M>x)!04 zjw(d?))k(JydLrrUd&T=;@gAUt|vt|rX&AId&y|OBsy6?Ir2m(suTF%i>xF4{|x;7 z$X@>-LL)tjyoPe}nwQpvEKp)_q8oaH+}42$y4BEmjp*3mM#W$xAA}VJmk0|S5l!?8 zsk)TtjJwoo+|Vr^hF%RQ6ULfBb{r;lD|KQHg4Rwg4)W_PWU_8Cos!j-MG0P;1}*~& zb;A}Za9byLEXG+*ra%XO0=P}^klhqMMPF{UES#J4)883(i+o9`OZ~)Zi_kWOG5AW= zvN@q)ibU0-j8otq1xovIv7$f;KLL5>{J4R!ub8gmSlmxiQl*5|C=A)pIxZ&m$}PjT zM4w15738F2kPMDOfs%eEIJD@^3N)5x!8QhP1qz7c3drC-FbI%WbA_7gkb;Su1yfWg zT)wz;u~0G0o1}pYqGrn`tYEZwtsu7EvP{DA&9<+GZ4Rrz;Nb{tN4z?yKaNuA&e}ot zMEB&Uz3hB9HQ!gzvFWYi{o*gzA6(hja(h~C=j?B@k7sx1_dZ$ZX^Z<>X-_NlwB^p_ zKa$!(R_~tvtd~9CO`ZSy_?bt=-tpPaHwTkbKQC^T?w1}c_a;wwmJZa3hbNv~c&c@; z|L{!xv9tJGR(`s)ap~UN<}$#gjit@60hBgM_xLkeJ5Zb9|DH2>Q(CVFxwY&Ml9hc$1aFhKQ2kq3-r+;I(vv_4^iO| j<^M!#U(ux5hiCdos9&c7^<*JXyWa=u>8XFf7ZmspzORj0 literal 0 HcmV?d00001 diff --git a/backend/modules/auth/__pycache__/test_auth.cpython-312-pytest-8.3.5.pyc b/backend/modules/auth/__pycache__/test_auth.cpython-312-pytest-8.3.5.pyc new file mode 100644 index 0000000000000000000000000000000000000000..532d1e50bd5767ccb7068cf36567be653cdb95bf GIT binary patch literal 7745 zcmeGhU2hyka=yG@v+H$YC)f^&7i{8;2;L-4fShnb2m}s6_&SAXB{Ph7X6-S%JFA{C zi8r(0yNiS)9su#cMufXOatcWP3E|C%modh2G*YCT;3aRMAR*=9bXDCuv&+hNP7ow{ zaBHTgs=K5d5+5tW@wn6X-T>K;g70tF!SwvR^KZc!Utz`JIp!HuIk&z$V z+|2{oDXU~;^Lizlw{#=>;*mce$xdkbw~Vr$Em`_Z(Xg}Hj5C!*#wtQ1m03TIY*k%z zG|~xdw!&?vpqk^Z^(qVREXl__=@>zY^9@x2$Z=7)=~^Z><60crh}ATLZS~&FtZOx|}2{;AMzk&0d=4%4nQcctowJ6vGZQoXEqQJRgps`xa zjk>Y7xmw(f+h4WJxt48YHx9N*;)tegTE?uUnP?#2J#WL%HtVb?7Op6DL1I?ZJN2|1 zY1lAv%Qj2|oPuY=hEcF#9B#1|TgMB#zza9sf){Sy@WQ4W!0vCvy+7Iu!2|DW&Kp3X z@5$6lqpsZWzw<3S?|=JWH~63Y8~m^5cZ;ENpucrvXh;b$v`61^L7@JU)VJ#QxygpV zblkGPbOiGT&%j@5alIGf-G(M__4N2Znsi}T_Q6)Ir2y)sQ0F>Ps88?Lx4E64a-Rt% z|Hb_q@rZf4d(XWUtP_vYjd+y4?Rb>_4e@CE?-q~JK|EMD9)*+;kGB7j@n{{7*+D%f zwc#J~e$ z<&O{Cwm&|&;g3y2-Z?wYa?0TGYA4HH5o6M{9fLrIO`5i?^7XGlB!#~zjEyOLb@ZWK zmDzoYojB6~GGiHp_9*sRvYXYFrGzx;@ zG7`02N}{GnIKy77MhWI)2!0a&N{WL_ii1^|>7!^(;`F|RIlSwOR2kLHZL+{DeuRo^ zD+1|9#@pH%gG_WvKShiJXk?15ubK+p0Yy=0cnj{#9qek4!xslHUP zY{!>d?B*w)tjx0fCGj_@d97GfiBTbWt5g9o{3LX%6EkKJlCZGiq6HMT->ceF)=W{S zr0QhRn$U_WO{KwRo>jKSVLWFVZHHSH(8&_Xg; z4MJt3u$i_(Ft_V(3sb<5wl?#0Z7Ns>cQyGFP~Z-+FLq!W_=ohC56+xB<86C*L76|Z zoPJ{Nxhrz_sywhH4|vkfi=$9^^1#K>C3)wngx5-=fnj02B<-AkoMFpN@H}2Ynm=4Gc=FJygx3ng8M2HB1H&2y)C*yE0EXQF zz^H58ef5#0Ze{L;D?R=7!%IC6&%M}G>){1;G3vcMzWA=!Jx_H!3_8wv^6;vJ*NQ}OhAbn(z_9kD;d(CY4#2P*02tL=_MwHBE}vR-7t7u= zncWfUslMxT z^>TDEdHEgh&!_rYcU(yjJL#V}Ws@HZ4n3k^fbUb;F*(AH4mi|Ch;fRTj^W3{ zCO_I}Bf~f*!iM;p3mTlb6>|g+33d__{0#EtyhV(Wyj3oklLUXZ`BF(U%LI-Ijtn6l zEtsdB8F(GQpK0_Up+6|8ko4ra8GC~LiabP~1`_-Zu*abKQRI34mmM6>pXL6Zy2>e6 txy)6r=il+}50Ab-Fc-PTN8j7=*Bw6zJpU|zL*#_69~l@K;)f~w{{l-?j3xj8 literal 0 HcmV?d00001 diff --git a/backend/modules/auth/__pycache__/utils.cpython-312.pyc b/backend/modules/auth/__pycache__/utils.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..299d6bc89b7679e98561b47672e63ce556588767 GIT binary patch literal 1243 zcmZWoO=#Rk6dvuawA$7F*jXoT5?m&w2QMuv;!|itU~31OK-18;B&dg?l}5HUGt%bG zDE68ha>ya2hvZPmwS__nzT}wRdgv{enj8Xx&_gh#HwU+u97^A4ciln;>CN}PdGBf7 zkDfJ~b%N{J-G4S-l?ZvImD#eV;P6)jk4Q*DgA-wRhM{qZmqghsi;7n%^fEVv>6yav zEK&8U1y|v=sChL}_v*s&oPslXLo~f+fi2z=ZLeM6DxVYc-h6>=eoS<{PJwHDK^*sv z7r4$(h(&MFASJ@y!FO?Ydc|F10wN=H*vYWL=EElIgsq(_A(0if@0s4IajQG`r#9;v zd99x$sqz&IyQSQ|e0}{QKni(zxTU(~+z#KYrdB$Rpc z)AjYM7q++2q*l{Jv2BHV^%|2B zT@hBkl!b{cnNo4Gp~F}kOnn^=ay!Gb#K#+zP_uxk43aAWcI;?X5S8&gypQ4$xnm5c zH94|CppHCd6&srWKcC?LtcKRxr+3KBWkQbpYj0c3x^fLmy)&#+TwgE+F}uyEPjxDE zFfMkT5{)1gRBbYfW%mcZWWAXSCMl^d3rz<@7AP7=G)l81{E%M3PS6y+U@M+^bj{qn zl`$AY8!wexQOp$sIHnadib1MwsnDQc4*hZA zr==g3o-FU3`RKXx@e8NF=k%XDtG`uN57C8mPv@2zkFejMyO&pnDaO^!1l<|X4FH^C z0{VB%tC5d0Hi|))9rV8oj^^j4d{437k_?UVOk5{l337Wu$^ je0z6$xA&Tu#+va^9u)ic)ye*J{g<_$zxu1-iZA~Q{%JlP literal 0 HcmV?d00001 diff --git a/backend/modules/auth/api.py b/backend/modules/auth/api.py new file mode 100644 index 0000000..9a37b00 --- /dev/null +++ b/backend/modules/auth/api.py @@ -0,0 +1,74 @@ +# modules/auth/api.py +from fastapi import APIRouter, Cookie, Depends, HTTPException, status, Request, Response +from fastapi.security import OAuth2PasswordRequestForm +from jose import JWTError +from modules.auth.models import User +from modules.auth.schemas import UserCreate, UserResponse, Token +from modules.auth.services import create_user +from modules.auth.security import TokenType, get_current_user, oauth2_scheme, create_access_token, create_refresh_token, verify_token, authenticate_user, blacklist_tokens +from sqlalchemy.orm import Session +from typing import Annotated, Optional +from core.database import get_db +from datetime import timedelta +from core.config import settings # Assuming settings is defined in core.config +from core.exceptions import unauthorized_exception + +router = APIRouter() + +@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED) +def register(user: UserCreate, db: Annotated[Session, Depends(get_db)]): + return create_user(user.username, user.password, user.name, db) + +@router.post("/login", response_model=Token) +def login(response: Response, form_data: Annotated[OAuth2PasswordRequestForm, Depends()], db: Annotated[Session, Depends(get_db)]): + """ + Authenticate user and return JWT token. + """ + user = authenticate_user(form_data.username, form_data.password, db) + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect username or password", + ) + + access_token = create_access_token(data={"sub": user.username}, expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)) + refresh_token = create_refresh_token(data={"sub": user.username}) + + max_age = settings.REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60 + + response.set_cookie( + key="refresh_token", value=refresh_token, httponly=True, secure=True, samesite="Lax", max_age=max_age + ) + return {"access_token": access_token, "token_type": "bearer"} + +@router.post("/refresh") +def refresh_token(request: Request, db: Annotated[Session, Depends(get_db)]): + refresh_token = request.cookies.get("refresh_token") + if not refresh_token: + raise unauthorized_exception("Refresh token missing") + + + user_data = verify_token(refresh_token, expected_token_type=TokenType.REFRESH, db=db) + if not user_data: + raise unauthorized_exception("Invalid refresh token") + + + new_access_token = create_access_token(data={"sub": user_data.username}) + return {"access_token": new_access_token, "token_type": "bearer"} + +@router.post("/logout") +def logout(response: Response, db: Annotated[Session, Depends(get_db)], current_user: Annotated[User, Depends(get_current_user)], access_token: str = Depends(oauth2_scheme), refresh_token: Optional[str] = Cookie(None, alias="refresh_token")): + try: + if not refresh_token: + raise unauthorized_exception("Refresh token not found") + + blacklist_tokens( + access_token=access_token, + refresh_token=refresh_token, + db=db + ) + response.delete_cookie(key="refresh_token") + + return {"message": "Logged out successfully"} + except JWTError: + raise unauthorized_exception("Invalid token") \ No newline at end of file diff --git a/backend/modules/auth/dependencies.py b/backend/modules/auth/dependencies.py new file mode 100644 index 0000000..8b0dfb3 --- /dev/null +++ b/backend/modules/auth/dependencies.py @@ -0,0 +1,18 @@ +# modules/auth/dependencies.py +from fastapi import Depends, HTTPException, status +from modules.auth.security import get_current_user +from modules.auth.schemas import UserRole +from modules.auth.models import User +from core.exceptions import forbidden_exception + +class RoleChecker: + def __init__(self, allowed_roles: list[UserRole]): + self.allowed_roles = allowed_roles + + def __call__(self, user: User = Depends(get_current_user)): + if user.role not in self.allowed_roles: + forbidden_exception("You do not have permission to perform this action.") + return user + +admin_only = RoleChecker([UserRole.ADMIN]) +any_user = RoleChecker([UserRole.ADMIN, UserRole.USER]) \ No newline at end of file diff --git a/backend/modules/auth/models.py b/backend/modules/auth/models.py new file mode 100644 index 0000000..42b2b4f --- /dev/null +++ b/backend/modules/auth/models.py @@ -0,0 +1,25 @@ +# modules/auth/models.py +from core.database import Base +from sqlalchemy import CheckConstraint, Column, Integer, String, Enum, DateTime +from enum import Enum as PyEnum + +class UserRole(str, PyEnum): + ADMIN = "admin" + USER = "user" + +class User(Base): + __tablename__ = "users" + id = Column(Integer, primary_key=True) + uuid = Column(String, unique=True) + username = Column(String, unique=True) + hashed_password = Column(String) + role = Column(Enum(UserRole), nullable=False, default=UserRole.USER) + + name = Column(String) + +class TokenBlacklist(Base): + __tablename__ = "token_blacklist" + + id = Column(Integer, primary_key=True) + token = Column(String, unique=True) + expires_at = Column(DateTime) diff --git a/backend/modules/auth/schemas.py b/backend/modules/auth/schemas.py new file mode 100644 index 0000000..0fdfad6 --- /dev/null +++ b/backend/modules/auth/schemas.py @@ -0,0 +1,33 @@ +# modules/auth/schemas.py +from enum import Enum as PyEnum +from pydantic import BaseModel + +class Token(BaseModel): + access_token: str + token_type: str + refresh_token: str | None = None + +class TokenData(BaseModel): + username: str | None = None + scopes: list[str] = [] + +class UserRole(str, PyEnum): + ADMIN = "admin" + USER = "user" + +class UserCreate(BaseModel): + username: str + password: str + name: str + +class UserPatch(BaseModel): + name: str | None = None + +class UserResponse(BaseModel): + uuid: str + username: str + name: str + role: UserRole + + class Config: + from_attributes = True diff --git a/backend/modules/auth/security.py b/backend/modules/auth/security.py new file mode 100644 index 0000000..a14c6cb --- /dev/null +++ b/backend/modules/auth/security.py @@ -0,0 +1,172 @@ +# modules/auth/security.py + +from datetime import datetime, timedelta, timezone +from enum import Enum +from typing import Annotated +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from jose import JWTError, jwt +from argon2 import PasswordHasher +from argon2.exceptions import VerifyMismatchError +from sqlalchemy.orm import Session + +from core.database import get_db +from core.config import settings +from modules.auth.models import TokenBlacklist, User +from modules.auth.schemas import TokenData + + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login") + +class TokenType(str, Enum): + ACCESS = "access" + REFRESH = "refresh" + + +password_hasher = PasswordHasher() + +def hash_password(password: str) -> str: + """Hash a password with Argon2 (and optional pepper).""" + peppered_password = password + settings.PEPPER # Prepend/append pepper + return password_hasher.hash(peppered_password) + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """Verify a password against its hashed version using Argon2.""" + peppered_password = plain_password + settings.PEPPER + try: + return password_hasher.verify(hashed_password, peppered_password) + except VerifyMismatchError: + return False + +def authenticate_user(username: str, password: str, db: Session) -> User | None: + """ + Authenticate a user by checking username/password against the database. + Returns User object if valid, None otherwise. + """ + # Get user from database + user = db.query(User).filter(User.username == username).first() + + # If user not found or password doesn't match + if not user or not verify_password(password, user.hashed_password): + return None + + 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=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + to_encode.update({"exp": expire, "token_type": TokenType.ACCESS}) + return jwt.encode( + to_encode, + settings.JWT_SECRET_KEY, + algorithm=settings.JWT_ALGORITHM + ) + +def create_refresh_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(days=settings.REFRESH_TOKEN_EXPIRE_DAYS) + to_encode.update({"exp": expire, "token_type": TokenType.REFRESH}) + return jwt.encode( + to_encode, + settings.JWT_SECRET_KEY, + algorithm=settings.JWT_ALGORITHM + ) + +def verify_token(token: str, expected_token_type: TokenType, db: Session) -> TokenData | None: + """Verify a JWT token and return TokenData if valid. + + Parameters + ---------- + token: str + The JWT token to be verified. + expected_token_type: TokenType + The expected type of token (access or refresh) + db: Session + Database session to fetch user data. + + Returns + ------- + TokenData | None + TokenData instance if the token is valid, None otherwise. + """ + is_blacklisted = db.query(TokenBlacklist).filter(TokenBlacklist.token == token).first() is not None + if is_blacklisted: + return None + + try: + payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[settings.JWT_ALGORITHM]) + username: str = payload.get("sub") + token_type: str = payload.get("token_type") + + if username is None or token_type != expected_token_type: + return None + + return TokenData(username=username) + + except JWTError: + return None + +def get_current_user(db: Annotated[Session, Depends(get_db)], token: str = Depends(oauth2_scheme)) -> User: + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # Check if the token is blacklisted + is_blacklisted = db.query(TokenBlacklist).filter(TokenBlacklist.token == token).first() is not None + if is_blacklisted: + raise credentials_exception + try: + payload = jwt.decode( + token, + settings.JWT_SECRET_KEY, + algorithms=[settings.JWT_ALGORITHM] + ) + username: str = payload.get("sub") + if username is None: + raise credentials_exception + except JWTError: + raise credentials_exception + + user: User = db.query(User).filter(User.username == username).first() + if user is None: + raise credentials_exception + return user + +def blacklist_tokens(access_token: str, refresh_token: str, db: Session) -> None: + """Blacklist both access and refresh tokens. + + Parameters + ---------- + access_token: str + The access token to blacklist + refresh_token: str + The refresh token to blacklist + db: Session + Database session to perform the operation. + """ + for token in [access_token, refresh_token]: + payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[settings.JWT_ALGORITHM]) + expires_at = datetime.fromtimestamp(payload.get("exp")) + + # Add the token to the blacklist + blacklisted_token = TokenBlacklist(token=token, expires_at=expires_at) + db.add(blacklisted_token) + + db.commit() + +def blacklist_token(token: str, db: Session) -> None: + payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[settings.JWT_ALGORITHM]) + expires_at = datetime.fromtimestamp(payload.get("exp")) + + # Add the token to the blacklist + blacklisted_token = TokenBlacklist(token=token, expires_at=expires_at) + db.add(blacklisted_token) + db.commit() diff --git a/backend/modules/auth/services.py b/backend/modules/auth/services.py new file mode 100644 index 0000000..fadf03e --- /dev/null +++ b/backend/modules/auth/services.py @@ -0,0 +1,30 @@ +# modules/auth/services.py +from sqlalchemy.orm import Session +from modules.auth.models import User +from modules.auth.schemas import UserResponse +from modules.auth.security import hash_password +from core.exceptions import conflict_exception +import uuid + + +def create_user(username: str, password: str, name: str, db: Session) -> UserResponse: + """ + Create a new user in the database. + Hashes the password before storing it. + Returns the created user object. + """ + if db is None: + raise ValueError("Database session is required") + + # Check if the user already exists + existing_user = db.query(User).filter(User.username == username).first() + if existing_user: + raise conflict_exception("Username already exists") + + hashed_password = hash_password(password) + user_uuid = str(uuid.uuid4()) + user = User(username=username, hashed_password=hashed_password, name=name, uuid=user_uuid) + db.add(user) + db.commit() + db.refresh(user) # Loads the generated ID + return UserResponse.model_validate(user) # Converts SQLAlchemy model -> Pydantic \ No newline at end of file diff --git a/backend/modules/auth/tasks.py b/backend/modules/auth/tasks.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/modules/user/__init__.py b/backend/modules/user/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/modules/user/__pycache__/__init__.cpython-312.pyc b/backend/modules/user/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4ffe35a760414b1d23d8e9eca6bb38ffdba6323a GIT binary patch literal 149 zcmX@j%ge<81T#_I|p@<2{`wUX^%S1mTKQ~oBIi)~9 zIX@*;-`CO8Q9mg$IXg8kML#z`r8FnCSiiJ5wMaicJ~J<~BtBlRpz;@o4MfVWh!toE SBM=vZ7$2D#85xV1fh+*I%p->Y literal 0 HcmV?d00001 diff --git a/backend/modules/user/__pycache__/api.cpython-312.pyc b/backend/modules/user/__pycache__/api.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..102b33b70b5fa243f8eb1f0baf0885557e560cf1 GIT binary patch literal 4228 zcmds3OKcm*8J>NRTyZ5zvS`{;q-OnSD$0%6#zky7sSMeY93@R0$d-*VG8l?Gq)6|B zo?Y4{Lp4%iAPf?qIuwYDUKFH26*z|kx#(D+mj<~kLl<%vG3=rZ(DuaGz0{ZXpIK6( z6stB26ev2t&g=i@|7PcZe%{d$M9}{F&wpi98KFPZMYDA@CihP82)&0?q;eY4xj4ts zIj`}$5EpbYF0!?t`E-BW&*q{Q(51M<=02@W55|MK9GCU>cstwkYaMzh9@0DGoopS@ zy7caNH=9e^F1;t-!yz7F;d z@1>M>p!6_G_clrg?o9jD&{c5*E3yMt=N7Ckyyvm;B(=K*Z!h2-_HNtStKY=ig+q^x z8B}{(F#7=WNGqB*e*os=zNw^!Tk!geyQ7i6QIII-wv8EvX(w&0MtNAgIyG$;Y)st1 zIL>23wSXmX23uCvG+^q_U^}7CMFlrfFp>p(-Xz%)RulLqDV(=~$L#@WiL_ZTwrr+N zGM80VY;1B;xETwRSCe*X-fgGTX>8?9!@>a6MS)TULa<>c3P6KBk@A#;m`)i&q`{of zO%-b{ugT2VMQ}D8LTQq@}vvUNH=sAy^NyV zkHzLq9mi5?K9+*($6g+r8jH;(Qy1aMWAH--4O=lPE0)Y>hx0|3*D;B}*$@>%!^9%0 zx1;vGj=1L@tWZggy+Qenq>dL{sX0y2pKUe~ChX^3n845TxhAD$ETfBHi$u0JPO4nf zOMk|dxtydBj~KO|Ho!QNhJ-%G_;~}Ig76-MY8~ky<#YNVy3eiz8{*Q zss?LUn6*a(WG~#9P~Et`O9f1dgeuGR12=UjUph-Ho6v{(Q1o~=%)%9^!YryFdU3Lw z9+apH(0!u7k}BVtg^np+3a5IVNm!W;m{>fVClp~$GBX|7#=-WqsG5*fz zx~x=X<=5fg47@+^@nCh|v6}qcx;$2u$7=G)#qmGNo$TN^TzrY_0RXp+ zf+p0YolMaiH5*kjGareipt7)o8vm2|@c#qmBbLRT%x4~{!Ux=f&+csgv?_whQ_tPj zRNqJbrr9-99tZK{VZ6DXZGvFgH0*J70p5QWUU$L25Gec0zRR4&y(`<)99S!hup7AE za7_-q+bg1$y9OSP-*Q(zN434JHSYrd!slqV-qy-u^RIzJ1ZR^h ztg7dkt+BFnaEsp+JdxWykMzNvM(G>4A~7`#&Qr*$%%e)uiHXV6(-X&(vydV>AS-DF z6(gr2$^5W8q z75j!5-i$~O&SL$L%>Pfb7?;}!K%nhLD+?{I_rO5Y;=EoS^jvcsYxoge6DlcSeYk6S zeBVfl%r&EJkK$l{PXrl)za!s*Zd>+~J-TQ7&j9^6<;g?$Uo!qXo()#itEV^3aU&_d z5*={;b}^qdGK7VmKsssJP@EB(A_*;u+>muiOKPcktQUt(q7#}h-Hwz=@GwoLb4f_6 z0lL@8_BLu}i%{QZamOnk!i@@Wm{!=s7L}N^h)kK<>G|F*bd?qA*hpouMQByz_H3cE z5N0)s5#m+%0@N0)o^%DMNmzjaA&*w#ga#V&ecI7iCt=#rM8RUxed&2m_9&tHpyjmn zJLu~I$8lew{@duOTWJ4RNV$z>t7!H%I(iE|dkY=>Q`@e0o>=tX;R0{CKp8fQ=GjyV=oMfBMnOWyTvYN^{ z7vHF*%Fcyy^~?gJ)Db$#pXTq}VSy-K29H?=y9rEB|SQ%M8|4C*o{i*3ZJyAU{Sx2zx soJ%;btJSpb7(cD0-*jGo6JXABV*mqGfSKf{0S4w2=DDw_Jf`G-18u~%3IG5A literal 0 HcmV?d00001 diff --git a/backend/modules/user/api.py b/backend/modules/user/api.py new file mode 100644 index 0000000..c4ecf47 --- /dev/null +++ b/backend/modules/user/api.py @@ -0,0 +1,78 @@ +# modules/user/api.py +from typing import Annotated +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from core.database import get_db +from core.exceptions import unauthorized_exception, not_found_exception, forbidden_exception +from modules.auth.schemas import UserPatch, UserResponse +from modules.auth.dependencies import get_current_user +from modules.auth.models import User + +router = APIRouter() + +@router.get("/me", response_model=UserResponse) +def me(db: Annotated[Session, Depends(get_db)], current_user: Annotated[User, Depends(get_current_user)]) -> UserResponse: + """ + Get the current user. Requires user to be logged in. + Returns the user object. + """ + return current_user + +@router.get("/{username}", response_model=UserResponse) +def get_user(username: str, db: Annotated[Session, Depends(get_db)], current_user: Annotated[User, Depends(get_current_user)]) -> UserResponse: + """ + Get a user by username. + Returns the user object. + """ + if current_user.username != username: + raise forbidden_exception("You can only view your own profile") + + user = db.query(User).filter(User.username == username).first() + if not user: + raise not_found_exception("User not found") + return user + +@router.patch("/{username}", response_model=UserResponse) +def update_user(username: str, user_data: UserPatch, db: Annotated[Session, Depends(get_db)], current_user: Annotated[User, Depends(get_current_user)]) -> UserResponse: + """ + Update a user by username. + Returns the updated user object. + """ + if current_user.username != username: + raise forbidden_exception("You can only update your own profile") + + user = db.query(User).filter(User.username == username).first() + if not user: + raise not_found_exception("User not found") + + # Define fields that should not be updated + non_updateable_fields = {"uuid", "role", "username"} + + print("BEFORE: ", user_data.model_dump(exclude_unset=True)) + # Update only allowed fields + for key, value in user_data.model_dump(exclude_unset=True).items(): + if key not in non_updateable_fields: + setattr(user, key, value) + + print("AFTER:", user_data.model_dump(exclude_unset=True)) + db.commit() + db.refresh(user) + return user + +@router.delete("/{username}", response_model=UserResponse) +def delete_user(username: str, db: Annotated[Session, Depends(get_db)], current_user: Annotated[User, Depends(get_current_user)]) -> UserResponse: + """ + Delete a user by username. + Returns the deleted user object. + """ + if current_user.username != username: + raise forbidden_exception("You can only delete your own profile") + + user = db.query(User).filter(User.username == username).first() + if not user: + raise not_found_exception("User not found") + + db.delete(user) + db.commit() + return user \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..f60d7a6 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,45 @@ +amqp==5.3.1 +annotated-types==0.7.0 +anyio==4.9.0 +bcrypt==4.3.0 +billiard==4.2.1 +celery==5.5.1 +cffi==1.17.1 +click==8.1.8 +click-didyoumean==0.3.1 +click-plugins==1.1.1 +click-repl==0.3.0 +cryptography==44.0.2 +ecdsa==0.19.1 +fastapi==0.115.12 +greenlet==3.1.1 +h11==0.14.0 +idna==3.10 +iniconfig==2.1.0 +kombu==5.5.2 +packaging==24.2 +passlib==1.7.4 +pluggy==1.5.0 +prompt_toolkit==3.0.50 +psycopg2-binary==2.9.10 +pyasn1==0.4.8 +pycparser==2.22 +pydantic==2.11.3 +pydantic_core==2.33.1 +pytest==8.3.5 +python-dateutil==2.9.0.post0 +python-dotenv==1.1.0 +python-jose==3.4.0 +python-multipart==0.0.20 +redis==5.2.1 +rsa==4.9 +six==1.17.0 +sniffio==1.3.1 +SQLAlchemy==2.0.40 +starlette==0.46.2 +typing-inspection==0.4.0 +typing_extensions==4.13.2 +tzdata==2025.2 +uvicorn==0.34.1 +vine==5.1.0 +wcwidth==0.2.13 diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/__pycache__/__init__.cpython-312.pyc b/backend/tests/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f16901f28ab0143fda530ff51d47a353dd7ae536 GIT binary patch literal 142 zcmX@j%ge<81cKWC(n0iN5P=Rpvj9b=GgLBYGWxA#C}INgK7-W!($&w%&rQ`&PASk& z&QD3z_jUAi)K5xG&Q8rs(Jx6YE-BWJkI&4@EQycTE2#X%VUwGmQks)$SHud`%?QNB PAjU^#Mn=XWW*`dy{mLJv literal 0 HcmV?d00001 diff --git a/backend/tests/__pycache__/conftest.cpython-312-pytest-8.3.5.pyc b/backend/tests/__pycache__/conftest.cpython-312-pytest-8.3.5.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1cba04db6a31adb7314deefa532561c37b76349f GIT binary patch literal 3483 zcmbtXU2Gf25#GH!l1GX^`l0?PMLz4tHZ7Y+E&O5RHh9dM)8qM;ozSHjfKGay5;ew(pyk zJwu5#VU}&R1aiQP{E%4ogkB<5Ioy|hwco*03O>52)r2fgM*PR`YM{> zbH~vIw1{Q{{z`N7!)7!4yyYdI+F@U8uFv`q_QhcQ4SKJ!7lxgFCA^CPY7+b17FEOcn>C$;P4x-qmQlLb&iJ~fA zK&DxP(5jSSSqA=UQ zHmG^cU&knn76li1Ao*Xg4hR)yAu;c6O-ofg#AN<28}n!fiiL&DY4044@;k06nrf@l zszns<`YZD#`o@t2tUfS*aWFfEKajE!bZb9Q ztC7L%5png12sHQOF)%bz+wVd1iU$YC>4J_7d;t>4RaC^9cv-#x5Lm>sjRXbaUTdbO z6Tq>if`z}>==w@}(W zYo1@0TpT3)jq+|eTG;10lsTa@q=8)5=arh|@TK&_+-NI1#n%jW$pEXq!pvYW`@OjHV>XDH<= zbeQp5*s0}RCoLBE*Bh7)x?GwD?g%^_*RGrYgtXUtKPUy->QQv)*wVy?)L)bOfBU1F zH2jxv$J-Nc?W-dkNG+e)jQ4&j_dXC|QU@j372J>pYtrCn((r>2TVSFtt}h+V?#CYm zvYq1ZhJae_3}gpFtEmvsPNeyY87EswOK=c6z9;akpqDjlL@u#_Hf#n#IB1=y?jMHN04Dy0mU;RlRVI z7}bnP4a)vGI5Do`7YO&3@M0h_O(#@zKZtnhcP|XP1N`)OjQJVy3lFh1Ol?jlCutT$ zSkSF|(A7nZ@mC04MjL3fhDJX}u`M*dh0pyi50dHGG!hXJ5Z%0C_r!u>aff>d<%cYy4 zmE?-OcIesKz=?YZSWIJ(FV?$uYqa8#tMhdP!}53E&vC_(XKQ_+2rN$#sQR#gC-D0( e-JDt(y7}YU@H2J97=P$+$F)~pz0Vo^O#TgLtS}D% literal 0 HcmV?d00001 diff --git a/backend/tests/__pycache__/test_admin.cpython-312-pytest-8.3.5.pyc b/backend/tests/__pycache__/test_admin.cpython-312-pytest-8.3.5.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d8ac97943509e0f4fc6251e51318de2e5d4ffcea GIT binary patch literal 5649 zcmdrQU2hx5@s3=69zP`0l0TvkI*n8@bwpVojgv-pT_;6bw0bBSw2>PTXYR>5hU5`@ zN5^9C05u3u1t|~*d9Z=HfFCW$dFVfI-wgDH6sgEK)F^_Y59w1;_el>$XYOwAXjg{v z;37c}$JyE0+1cHho1Gc{DVR)uBJX+yPe zIV1`-vK$d{Bvg&oV#_fH4_D*0#BxFuLV^-g65FCHXE{m9`@*tBBLGwDVj=nk4lET! zJNYw3H!fCHrEWkkEH@fuca95V(BNhs{TAR2!SKf_e2YFsfKBoREx{BjaHuCAOQazy zWQu0!ohd=E!e&_i)W9{IrxNE_&{E)86)C|vtD$XACXE;p=c%MP7PQ+QdR5Se`s8N< z4vv`u-!tpy9N^~=-YihlyehhxTCGLk77q`UL+{9F4eYT({f8`{I$`x#l+6=paxUItbmzHCRSV#sQ>HMpc1dAVtB z14e9uhbtr)UiG$wi@Dl7)8Coz@65R!SJt=tJ7@dZyb)L0f_n~k&Y2lK0juTtvtKN( zX*H!-ri~&RzW9?1KfF-9ESKL=>a>Wu(H&TFKBno$x}A3whTm3+X-z{ZHS3Srn4N|N z;Ib0*Bz$=r!k@E4AGJPc-7IvoFKqnaft1^m#yiq@n@rxG1JIVnZ_jn4$vuMau19ct zKJ1Xm&9iQ=8=yUh*(L9wIG^7~fE!l)Xz_!^HUaSAd1!84YZqQ?k0E9AJhUBYagX4; z>k-_Z4?94H#gU^Mpglmnc{@1IWdIZ;`cr-6GqU&yR3enUpo_TR$Irh527w&nUsb5V ztqkE>q~R(Aa|?nw-olgs3{n3cr_sByZ7<3~q!cP>)h8a)znw&kxPD9w*!bP z@$^eD{P)=n*RkHhCeK!7(ce-&d4t%#zHc zDP0w;l$nAEA86!&EP>|C6xik{&IoMNH|HQNCi(tAhhJ|S`nEabM3R4^Auqxw=rBEI zCOjLazqAe0ToP|R8z#YqDfmUfI;1keEf=)hdh`gY)XMaE>^B20j?v@v1U+eHekc4s zq>6W+^Sw4}W}o7Wctdsk!Bj$@A1gdrD0x=pZO2-|UNopdvclN1_E zBTTC*EvbmIQR{^en}?O`I2Vm;QrVEdxDRB82?)c?!Ol4BkkYz|K9g`HivTlg zJ0+LPimsOo4e~;e5dzU(Dt`ttc(u+80JpAy6y3|}p?q*jfOw=iC^dqtzCbX6KZ9V7 zVT$1_$S~wEj5Z82NjrT3j-)ZQrjHM$yb$pLt!;l*CFrD8yfa7A1DiyE zw|;+FFvH+~mB%Qv>_ue!4g#z~SOI{J3giWX-?DE3)gFCH_K&tvDEkrc;HRU%2Os!` z@HaB`kj#B9WpAGR!`t^>?Jg{}-@Me5e!21b1Csg8(yy1U&-ci2!1u^dhYa<|F$W*+ zkm2h_kBs~)Dh!X@xb*R*+oShhY%kN!f^z**cW8BE>49`&PnzgR6K!((P7Xj@nz)ne zNT>G*zPld5?fI}nPH&!bd))x-Im|A32gUjPJ_6jZVw=p~T-h6)>5R?*3a~pm3&`fV zHnh_09>I6lBe=aT_FUA5t{8_IhC!l(_Q-5|bjHPZ5clNy(*Ob44|1pWa+96hWH&c; zXRMPuyYXYt(!j7=V%^d19lJkrKhr+{^Y%->*lcyPZ+)dD*NR(mtzgOZc6)54O;)hv zTIon|YJ7J+g4^?92gp!zAxAerdw@c1h14CK=c6Fe#rxrBWQDy1-(n$c$9WO&IGlZ= zBVieem|a$RjCIpR_4EJVzoWnUrW*AC@fHq^%FvkX8mf-@T5Eo_v10`!0@t-bGp#8Hz|G5iAkXXoofx2h{6et4~4WPgJ z-ptPI^0c};No=YVT-?pqzL}k!ot^i7@4eZ7iAKW`9H087vHxtBq<_T>KLU2*&H-7H zZb(ETa!wkSY3!S581c!{gfi@x*_ps_0M7Vw!HLjth?X_v8YjZTVVYKQ>O^EXLeu_S zbRsq!n}`p`X+DrkOf(HQK{`l6x#o%Fa1zpuT9|~1JgN}&17)~{M6`$~3y>(3wN{kH zpscN;EDmK`D#{X2)-KvoNE75cDrlOaY->eX63Vt!l(j(F_KLDrDBE#GPPP382k=}< zwgN^WQ6Kh_!beQw_=2^3m6O}34voS66cN0QO% zY9HQ_%EOdDw?|iZQ*N!KlO%_7Qikh0z4oH@@m5K&?w)qI+VtqDGpf98-Ce_@7x&hH zo(El5@u*`q@YYG_6J-YGOwmsoN!av{%QP1#?BHYLYH24gb7C{VTjKFgVfFd3T=cgFj4EiuQcsB;!)ARzp=0M88 z!hux6Ql|1oLC1H=YQ%dv`QurbqMoM5d6|ems@SOlAsjW-+L%*xeS|UAz{%)BTp}WcuBiXIX|d zb;zB0&cHRTlO1<^ddKn;TJH#%>cvOXd-lZ8iQbE_bZV2N7jM|0VHzhz&(swwN(X_@ z4!swe!ij4vLoh3?##=vp@7jB-q3z$cZ~ruQGq%*;eH&8Io6%pyZ@0AH82V`F<5zzh zRAS*Zzm(W^M+znavx931Dbli{?kcOhO3LBS4z6?^E_WS%kbz23YKUuEidf;j9O1DEbS61!Tpt z?_8i5F#TqLF7`n)2#Y0)1<*3Vmo#kJm^cDSzZ(NkwsdE2nqSM9~UHRxhrwT`nyi^V=f7yFRC z*f$oo@xgXoeLF96VzZI=lgIXA?}x=c2(v)R?sBMkia)txn5ae7RL$^@q+-O37&l#4 z^O`3>1M?(`HJo|ks^j%URo*iatNSUrXNkvCGQ+&hn)69e!c&Qn_$NNfOrs=0n#`y@ z+OfJvJI1feV|%pKVuUnrj%0B|M$!zE<_?&uMU^CrL6~bnx{l%e%(>>8*ZfrI{A|HG z4?aI#ZF|k*Ab;kh)t*Ih(nhwJaeEep>Yha*-V%@bEP{77QVfEC)_!g#?g(hP{H1X~ zJ30#WRF*+$jARNK9R>GAdFH~#3S;}y=U)vMsRTl0Bpqdb9eKZw%aRqzjEn#TOBWD) zEy|W}9KxAq?8JT$?#3snRQ4=LWl=85rLntNcLPrK^sUy0UDQIaF26bFO*(jpi%;Z1S zmjCdk^-e5)0)r`VLzr(TC)^v-)fqk9LpGE8cB2MnGIqJ z>7K#hyBO@jU@r#GLEskU_PH|$+@f5cJ9E&T={N9Au!TGk={?wVn2K<8{^)0+0B^(i zHApbC((RqyD?9tjJNv$TeQD=FS=lywsubS#4VUUVzG1T5YJ2Lp{stk#1*i-cbIEZ1 zD8u!ayZTE?Kb7J7%WD6Mg1=>6!m=VPEBz?LA<2@HyhW)65lcX$D7e|AJc*=T_C?$k zptWyWw%<7W(bQSi6S@+`B28I}|wRG529oaRY!8WK=z zRozljy00FYJ6ck^R}}m$vpmZzVTL6|2o>gziqkwPPD27}t*Y&F+so?HCFQ_EHv}d1 z>4omHdSFGt-?Cl6a!WMF(!WA_mUM@=lmq%cc;epTY!8T)4=&ES#9vz{02dI4Dcw)3 z{oK}?pzeXlbb0qf%12XyLL+a&OS3@)HkLsfkd1O$CngH<6A*cSA^nwaTqZsvc*3xZi(I4chR+d6K{y&OC9|@p_^{@FY!jKPC4p@pwvRl;3U5`6MXesWg#h zTl`6oBxx}d_GmZNJ=#tDx;(Z=J64R7*3FSDZpcWQQPN7+));9khGDLS={kn7I2KT+q0;dbdarPvptK{x@VDqm6fvy-q|?ZF}#)R{^36S zmk$A)sOR0>M(=$))_L%I2iw3#t@T2+QS;ptl<;oK|C{e7z>*#3^nO?cQLG;Y$$pF> z03Dh4-6jXG*k`Ti3D6|w_3;_1*t{D&Nd@{&fC0fVEkiW0#{@_6`OD*)L6MB!2Myg9 zcIXH3>;MMOVQ?6OqZk~+;5Y^^U~mG1lNh{+0X~uxsI8wswY2~#PcW@CfRt2{A_e^v z&{=*aD{01Kll!4mhJ3B07z!P7LZr3rPnp=r3W#mm#nkbYqH+seT^I zP|K!QMGN{Mk_=(+3I?xYa0UVcHDE&gcM=Q;G~2pd_hb73RKPJcu7RrZet_V1xMd~0 zwH)3$cVMpYY4K*U)N_0({KD*utI4+OKPe}lo;|bLvSlt_Zt0#q`>hiC^H<(~j)1tf>RWxIgomS~QpMF@2k&WQ6oDNaM;mNHNUv3v##&EtYt0EghRS8?sID!|&1ytvFO!RAW^fAR%>2!gQ5I4pedQ`2lE00vSDO#-`;W%rQVa&K&TX zjWII>XeW*}T(dE;VY5+SlbW+pP{M3XxO(k1b6t2#D0`bpQYd>9bz9>T{HgGmV@nuC zIs4s;VTLSeajzj?ve05jdi=jzJ-j*A7F%1^zB$h} zxfEMETx+8jhOfb;04H`ZoJcXvOu_+%0Iu@W1^tiUGWzQnd=CTU8r7j6z_-A-(K(q1 z_rIAo+s&wxl?Yo?(?#wE2=*6dn)2x=Kj=p=5J>TEgmwrGHoX(tqO4zl(~I)V+Z$;_ z>jRIbH_n}S>BEGxpYavTkWNV4Q4Z&7MN#1habS_N%Vop&L z2d(469ia`w9YXI~6L$zokS{_m-s*)rgzxSH;Pu}R3a*9U1=c5F8wEEKl|;-&8wH2! zMohv+P=S~NpxFKJw@la2tW%H@Hp0Agzen0Jl{Zv3!Z?$FhAHoB1N#+^M)|Sk{@o9RT zfb_hFdw4DIi$ zvwe{2(d9o}Tx|A~u`l6$;YdBC9Y?>6UH6uksi<713CHc|)yL#!lz`oiT?4h{XAtSr zu)kp%YG&bY{D)jceopRKNp_W!UGpawlwU>{qoqTGOUa>s?kOj~KYIqnq6d@f9$&|= zZ@@;;R#rPp%HH`?e>3#gLy#z`o%2Iwb?=ITzh%3C<(6oUrA0_9i}NTCW5rw07kg3gz(VC zA$BVeFzVfZ6B}o(bTb)HRvxgKOt^P>?NhjCGnp`kc!_qPV#3X2bOV~hM2S)mu3TOM zA7|;*B;m9#T(+D(J6ptnIXaXc?A)OQ=BU)56^xYl>+WfXt4)udI-|;;0GK4I_W0f! z&~tzFI00=WrGB*Jr%-iB70Q6X2*YP;V zFy~>+HU&4@1b|>9Ww3k`07@GL0Plt!v&e(vKnjcW9ISQ}+vyXSn#3TF0X1(-Vd@BfvEXN%5Qh8FwO9&*vGBFertiUygvI;d*h3(EDzjISq?jOJ=9`CiVEDLMz6;m z1mC-g;{n}40@Rr-Sm6$`acXp-Hm=Fl1Z5p+bjO*jez&*BLyhh@lW{e=O4aBa?o0-% zzo@PHLy+XPg= zG8Of*cUyksHU}&@&5YhvHF*ZAF4oz$864dz;3zh>9+3ViRQU_|8(SzJ`_^*@=IwaL zO8lvE{Hghi094-}oIQPY|LwX|cfi};xbgN!ZqtpCLd~;B z$|`JL#NRS6VObHDl_O}LMUo{ad5cmDB9?$g(I#t?@+6XW*%xtFfbuFv&d)$2^$_d4 zA4Z%WUh6BDzl(}%eGl;(PE6N#59d43w4kB`4za1xdxN46P_jpTvA*-Ii?AsYwk|p@ z!vQnkr5h^-oh@^oTNgtntl8Aa)o3=>+`1S9S79(tVjY)$u5rV*vk0>$tnxzhl=U)L z2ufJx-D|fOt>6d`u*uTB5fS}uDh_889JY<6kG>5m z2xhlNaL^5SIRlGX58qTEerlGBf`hIijH0!+Fa~X-DAaN0ON}S%m>T07Hm}^@m8m|j z1SQO?I2b$GcR(tQov4MRhwEOReKn<)6wFM#dh+{&gQ$vG=hb{TNi` znr!FRAkqzC@CpX6LQoA2CMZjto`O$E*xzyZ8PvFmwce+U)caEgx8unVuUxzG^Y>;4 zzpctY@k>_bHFLEZHe8LhTpue%cj2z3?RTWde%I2f`!fjK8ny?GV1s3K&x(S-WrY>6 z+!D>Pvo+rg=NZeBPRAJe_emIs5Pn8{y1hww#m$a#{b}5a#{8u&=)*Gv^ z-k5zm9_j(_nY?Z<&v*E{out3edJW=>O9Qr~*4TtS-ppTQ- z(_K>;ASjWY*O<+YHx_66QR?I5eEzH6{aIpH5El+@V`!ilxj);_z`)nV>ft*|@UdIQ z@ZmF>=--a$8yS9tXgRF_e(h8rn#L9#rr+Y_=wby}m51f^BX|u|vFQYZQ3z6TFVtKQ z*Z(tYqJrV$2lCe8=gv-spc%Voy=V7(6-XE0Z~VL(yRXUPi`R-(c|3YB@9^?qe$4d0 z;Dp`Mc4Oe9flp3-d~7AzT~2m?(ele3i#xvB{f~S9es4MT@=|heC3&u#Jhzm54fbbm zgHLVlg3lH80lr^R@VCtJEF;1)ma^QElf0$un(GrZBJnF_7iIjZomPl{4V4=3t&TMN zergZT^X4}3mVM}Ti7C-P)Erx`u;>!pyq()eB zcDaM4L5u`M zDd=;)OLWt!>!xoJeWrN6s8%EfMg`HFsZ z&`%i+?O)%ozt0@+OmB`Id3E^p$u|odW7pQDeVdZ9GIM+8gEan58h>_VT^iqz@}JY= z@6)-?x$lCQ%6=T${Xp4P)JMvF<)QlPo)ClIEC>DX6mhYD*CeSpiq@1pAs!tmPKs-j zBH;WmS1JwLfms#-|3#zadMg~HU@%5#_#qc4X;M%E=uT36Ajzpv93C$ll*=*X zDkx_-<{WWbkq&&QpD6g$jdX$k$)}`{=pM^7P>sKqh^yzQ3xgm!0%k`!?fO>3A!q0T z2*W>yodEM)#29}qH<; literal 0 HcmV?d00001 diff --git a/backend/tests/__pycache__/test_sample.cpython-312-pytest-8.3.5.pyc b/backend/tests/__pycache__/test_sample.cpython-312-pytest-8.3.5.pyc new file mode 100644 index 0000000000000000000000000000000000000000..93c81b18eb0b10211e11561ed139aa3e3de77574 GIT binary patch literal 1035 zcma)3zi-n(6uz^aA2e+Np#oh{29RP9aY6~Ds6k|4V4#0M7R&Wr+E6=o?p(Er9H~Mb z+ASLk{{ZTr!h%2u?*e#-2qM@+Z7jX!>#l~8Z?tn55$zPU^8;+^6H&6`&I_e0fYo77x3N^gBhVS) z7{z!9;gLqPksfQYeoz5s#0Ec!AXBY3=^N4jc%sKf2NOfobhHaY9c60fkxU7S(ZyWz zA9?sc^Yk>2&M&B>I*sb7L`${qb==RZ9I^nD8-72{wkU!8S(v z0GY*|iD+UXW@6%*)@+)1c&Bo&5)SGnFFz;?v|)0y+-yp`30O~bI2L9@n9bp;!#QP< zOxQBH2yU6VWa&jXh$xS2hb7l-*YP}?(U7@;A3BUCS8VQdsE8!1Eh*3qdM_CZSaQSe z1k86Ln;wUr({my?F$+lv1Z{O!ElVC{$>S_}k|hmAl=RbNS*R&euW>QkvRbp6wIBG@ za!F{p0io9G)~lAa>$nHBM=a41mloddhaRnlgXEg*bDw%%U>^m{BkVTliP7>sFem7H zY59|}cGefBe%3#402sFfYm8gx&%POJ@O@QOkuM@s!OsBu3pj2m_?HE(DO`nMPQ~5B zt`~KCT=lqM>$dtKRW(BxYL(F=){Wq2EJ8_P9%Cg?lYM+ZY+c}6U}nW_KOl!5ea^~2 WWs6q<{L(STKM*=aQ-twNS@0X>Rr-to literal 0 HcmV?d00001 diff --git a/backend/tests/__pycache__/test_security.cpython-312-pytest-8.3.5.pyc b/backend/tests/__pycache__/test_security.cpython-312-pytest-8.3.5.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2cc5204b0d737e2a8389ac3de908fffb9c3038be GIT binary patch literal 4988 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 VJP Generator[PostgresContainer, None, None]: + """Fixture to create a PostgreSQL container for testing.""" + print("Starting Postgres container...") + with PostgresContainer("postgres:latest") as postgres: + settings.DB_URL = postgres.get_connection_url() + print(f"Postgres container started at {settings.DB_URL}") + yield postgres + print("Postgres container stopped.") + +@pytest.fixture(scope="function") +def db(postgres_container) -> Generator[Session, None, None]: + """Function-scoped database session with rollback""" + SessionLocal = get_sessionmaker() + session = SessionLocal() + session.begin_nested() # Enable nested transaction + try: + yield session + finally: + session.rollback() + session.close() + +@pytest.fixture(scope="function") +def client(db: Session) -> Generator[TestClient, None, None]: + """Function-scoped test client with dependency override""" + from main import app + + # Override the database dependency + def override_get_db(): + try: + yield db + finally: + pass # Don't close session here + + app.dependency_overrides[get_db] = override_get_db + + with TestClient(app) as test_client: + yield test_client + + app.dependency_overrides.clear() + +def override_dependency(dependency: Callable[..., Any], mocked_response: Any) -> None: + from main import app + app.dependency_overrides[dependency] = lambda: mocked_response \ No newline at end of file diff --git a/backend/tests/helpers/__pycache__/generators.cpython-312.pyc b/backend/tests/helpers/__pycache__/generators.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b572c2094d049793ee2320bd640796973c1fe9d2 GIT binary patch literal 2270 zcmZ`)&2JM&6rcUHjlF9-u}M;B2!xMXr7=PvprWnP#-V{Gt!$8j5wSKq6USlK>&~v# zggR2FA^~kB+Ft0FRF0rTYB-Vqp%+Jq)G&3_L*c;9C`gsKv@>gOoS=53eeXAK-@G^b zzVQO%W(|z2A>ru7YxyqvXUzk4cYW% zeJ+*^zZu8|Tr3;A%wRTXhO!}3$ttFrRb9Ey2%C{?#KnFiYPMzDD2~7YlHe}X4udE# zA;ZwTl#RJE1;wFCpCF1)_+fZn%T?wX!pbicUe-9F!|lA!*k!VjE4@>Cv2HT|-3@YYH)%z{s9#N|i~C z;*w_DpII0(7tCzhG7xhypMhNg|$`+b@mUn2P8f)7L*^{-ncVe5Cm(rFJ*wPAm!V168yosZDvMJR=E$*GHa5&;b zJo!|chg!TC-u6D$l6z>8TWEgMtWW_{9iF_!D}r|?4p5D*)*BCbw{hR^@uV%j!-7!Z zC-@9V@FNL{h(*mr#Lr5=MPZg(1h=wF#B#X+iHKQtRUD!=iQh{-W`W01CJQhNIc5U0 zrlJ<K_;$8lh#FNu`OPK}clXGR=ZRe9aO|h?vD4vyhW$x%ldJ zB)3{^k-qMs>*cBBq-CO{4ogYhf+#uMKh&Qb*K}IFFzF!Mv6GX?C?RYo^Qefh=2+P7 zE6tE#v$Wa0@d0X%xwSv1WDe9i_x_^%p!^iBit$<~Qd6RLF5kZV)sjXT^^@n$w%ipG=g!OohmTOd&pH`7OG!c? zp&g@8**OD!rZ%0^@+c9&Y?6^cYFbAnhmKs#=1e1pED4x3H`&ww!3U{KCU^1U3#pM@ zYV?z#bSg(%;nKxa2D6Bhu5@ZJoyvT;Q)Qt4awg$xw8U0f(4J&P`zEt~l##b6vDr0P zOVEY)Q%oeo$`^|GIK_LJ!grL=+79mhe)Qh-Qrl|q#N5E+V7R7sR69?vs%NVHGf&zN z-@mjp@M!qqaP{c>Yw0U%(D)kw#0~M!8|(!D8SXq!S?mHo_fakO#{KdVd}KZ}t9=7& zvDDn~6Lt6R>ai8|*wXP8_2e?YqQ1SHUQyqhJ6D%M*P->U!|Uy@J@<(rxei20#=Z1f z32rdaLHsl^i2cT}8#p#AF-r(h?N1HO&?k{O(`R85v+k!2Y+=-=Tg8b&z7gbh(}HCK z*?sKq)<-wUu-y?w;yd2hNH61pGvj8ikuH{k#^8$WRAo*zyD_~~cb>Q|8EOneaon;D zcYx4RbFdrk2f+O*Hw`cSXB!sZ#qUrj?B`-%p` User: + unhashed_password = fake.password() + _user = User( + name=fake.name(), + username=fake.user_name(), + hashed_password=hash_password(unhashed_password), + uuid=uuid_pkg.uuid4(), + role=UserRole.ADMIN if is_admin else UserRole.USER, + ) + + db.add(_user) + db.commit() + db.refresh(_user) + return _user, unhashed_password # return for testing + +def login(db: Session, username: str, password: str) -> str: + user = authenticate_user(username, password, db) + if not user: + raise Exception("Incorrect username or password") + + access_token = create_access_token(data={"sub": user.username}, expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)) + refresh_token = create_refresh_token(data={"sub": user.username}) + + max_age = settings.REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60 + + return { + "access_token": access_token, + "refresh_token": refresh_token, + "max_age": max_age, + } \ No newline at end of file diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py new file mode 100644 index 0000000..10813d2 --- /dev/null +++ b/backend/tests/test_auth.py @@ -0,0 +1,180 @@ +# Main test file for the authentication process. +# uses conftest -> db_session as an in-memory db. + +# Goes through the whole authentication process: +# 1. Register a user +# 2. Login the user +# 3. Refresh the token +# 4. Logout the user +# 5. Verify that the user is logged out +# 6. Verify that the user cannot refresh the token +# 7. Verify that the user cannot login again +# 8. Verify that the user cannot register again +# 9. Verify that the user cannot access protected routes (/admin) + +import time +from fastapi import status +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session + +from modules.auth.models import TokenBlacklist, User +from tests.conftest import fake + +from .helpers import generators + + +def test_register(client: TestClient) -> None: + response = client.post( + "/api/auth/register", + json={ + "username": fake.user_name(), + "password": fake.password(), + "name": fake.name(), + }, + ) + assert response.status_code == status.HTTP_201_CREATED + +def test_login(db: Session, client: TestClient) -> None: + user, unhashed_password = generators.create_user(db) + + response = client.post( + "/api/auth/login", + data={ + "username": user.username, + "password": unhashed_password, + }, + ) + assert response.status_code == status.HTTP_200_OK + + response_data = response.json() + assert "access_token" in response_data + assert "token_type" in response_data + assert response_data["token_type"] == "bearer" + +def test_refresh_token(db: Session, client: TestClient) -> None: + user, unhashed_password = generators.create_user(db) + rsp = generators.login(db, user.username, unhashed_password) + access_token = rsp["access_token"] + refresh_token = rsp["refresh_token"] + + time.sleep(1) # Sleep to ensure tokens won't be identical + + response = client.post( + "/api/auth/refresh", + headers={"Authorization": f"Bearer {access_token}"}, + cookies={"refresh_token": refresh_token}, + ) + assert response.status_code == status.HTTP_200_OK + + response_data = response.json() + assert "access_token" in response_data + assert "token_type" in response_data + assert response_data["token_type"] == "bearer" + assert response_data["access_token"] != access_token # Ensure the token is refreshed + +def test_logout(db: Session, client: TestClient) -> None: + user, unhashed_password = generators.create_user(db) + rsp = generators.login(db, user.username, unhashed_password) + access_token = rsp["access_token"] + refresh_token = rsp["refresh_token"] + + response = client.post( + "/api/auth/logout", + headers={"Authorization": f"Bearer {access_token}"}, + cookies={"refresh_token": refresh_token}, + ) + assert response.status_code == status.HTTP_200_OK + + # Verify that the token is blacklisted + blacklisted_token = db.query(TokenBlacklist).filter(TokenBlacklist.token == access_token).first() + assert blacklisted_token is not None + + # Verify that we can't still actually do anything + response = client.get( + "/api/user/me", + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + response = client.post( + "/api/auth/refresh", + cookies={"refresh_token": refresh_token}, + ) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + +def test_get_me(db: Session, client: TestClient) -> None: + user, unhashed_password = generators.create_user(db) + access_token = generators.login(db, user.username, unhashed_password)["access_token"] + + response = client.get( + "/api/user/me", + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert response.status_code == status.HTTP_200_OK + + response_data = response.json() + + assert response_data["uuid"] == user.uuid + assert response_data["username"] == user.username + +def test_get_me_unauthorized(client: TestClient) -> None: + ### This test should fail (unauthorized) because the user isn't logged in + response = client.get("/api/user/me") + assert response.status_code == status.HTTP_401_UNAUTHORIZED + +def test_get_user(db: Session, client: TestClient) -> None: + user, unhashed_password = generators.create_user(db) + access_token = generators.login(db, user.username, unhashed_password)["access_token"] + + response = client.get( + f"/api/user/{user.username}", + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert response.status_code == status.HTTP_200_OK + + response_data = response.json() + + assert response_data["uuid"] == user.uuid + assert response_data["username"] == user.username + +def test_get_user_unauthorized(db: Session, client: TestClient) -> None: + ### This test should fail (unauthorized) because the user isn't us + user, unhashed_password = generators.create_user(db) + user2, _ = generators.create_user(db) + access_token = generators.login(db, user.username, unhashed_password)["access_token"] + + response = client.get( + f"/api/user/{user2.username}", + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + +def test_update_user(db: Session, client: TestClient) -> None: + user, unhashed_password = generators.create_user(db) + new_name = fake.name() + + access_token = generators.login(db, user.username, unhashed_password)["access_token"] + response = client.patch( + f"/api/user/{user.username}", + headers={"Authorization": f"Bearer {access_token}"}, + json={"name": new_name}, + ) + assert response.status_code == status.HTTP_200_OK + response_data = response.json() + assert response_data["name"] == new_name + + +def test_delete_user(db: Session, client: TestClient) -> None: + user, unhashed_password = generators.create_user(db) + access_token = generators.login(db, user.username, unhashed_password)["access_token"] + response = client.delete( + f"/api/user/{user.username}", + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert response.status_code == status.HTTP_200_OK + + # Verify that the user is deleted + deleted_user = db.query(User).filter(User.username == user.username).first() + assert deleted_user is None + \ No newline at end of file