From 9b03ab83d2b448a162283c53f74512fa4fce9bf4 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Sat, 14 Jun 2025 13:39:37 +0000 Subject: [PATCH] setup fastapi auth using authentik instance --- crawler/api/app.py | 32 ++++++++--------- crawler/api/auth.py | 64 +++++++++++++++++++++++++++++++++ crawler/api/config.py | 16 +++++++++ crawler/poetry.lock | 80 +++++++++++++++++++++++++++++++++++++++++- crawler/pyproject.toml | 2 ++ 5 files changed, 175 insertions(+), 19 deletions(-) create mode 100644 crawler/api/auth.py create mode 100644 crawler/api/config.py diff --git a/crawler/api/app.py b/crawler/api/app.py index 3a4d096..a4f8420 100644 --- a/crawler/api/app.py +++ b/crawler/api/app.py @@ -1,31 +1,27 @@ from typing import Annotated -from fastapi import Depends, FastAPI, HTTPException, status -from fastapi.security import OAuth2PasswordBearer -from models.user import User +from api.auth import get_current_user +from api.config import DEV_TIER_ORIGINS, PROD_TIER_ORIGINS +from fastapi import Depends, FastAPI +from api.auth import User from repositories.listing_repository import ListingRepository from repositories.listing_repository import ListingRepository from database import engine -from repositories.user_repository import UserRepository +from fastapi.middleware.cors import CORSMiddleware app = FastAPI() -oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") - -async def decode_token(token: Annotated[str, Depends(oauth2_scheme)]): - repository = UserRepository(engine) - user = await repository.get_user_from_token(token) - if not user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid authentication credentials", - headers={"WWW-Authenticate": "Bearer"}, - ) - return user +# Allow CORS (for React frontend) +app.add_middleware( + CORSMiddleware, + allow_origins=[*DEV_TIER_ORIGINS, *PROD_TIER_ORIGINS], + allow_methods=["*"], + allow_headers=["*"], +) @app.get("/listing") -async def get_listing(user: Annotated[User | None, Depends(decode_token)]): +async def get_listing(user: Annotated[User, Depends(get_current_user)]): repository = ListingRepository(engine) - listings = await repository.get_listings() + listings = await repository.get_listings(limit=5) return {"listings": listings} diff --git a/crawler/api/auth.py b/crawler/api/auth.py new file mode 100644 index 0000000..b510957 --- /dev/null +++ b/crawler/api/auth.py @@ -0,0 +1,64 @@ +from datetime import timedelta +from api.config import AUTHENTIK_URL, OIDC_CACHE_TTL, OIDC_CLIENT_ID, OIDC_METADATA_URL +from cachetools import TTLCache +from fastapi import Depends, HTTPException +from fastapi.security import OAuth2AuthorizationCodeBearer +from httpx import AsyncClient +import jwt +from pydantic import BaseModel + + +# OAuth2 Scheme +oauth2_scheme = OAuth2AuthorizationCodeBearer( + authorizationUrl=f"{AUTHENTIK_URL}/application/o/authorize/", + tokenUrl=f"{AUTHENTIK_URL}/application/o/token/", +) + +JWKS_CACHE = TTLCache(maxsize=1, ttl=OIDC_CACHE_TTL) +OIDC_METADATA_CACHE = TTLCache(maxsize=1, ttl=OIDC_CACHE_TTL) + + +class User(BaseModel): + sub: str # User ID + email: str + name: str + + +async def get_oidc_metadata(): + if "oidc_metadata" not in OIDC_METADATA_CACHE: + async with AsyncClient() as client: + resp = await client.get(OIDC_METADATA_URL, follow_redirects=True) + OIDC_METADATA_CACHE["oidc_metadata"] = resp.json() + return OIDC_METADATA_CACHE["oidc_metadata"] + + +async def get_cached_jwks_client() -> jwt.PyJWKClient: + if "jwks_client" not in JWKS_CACHE: + metadata = await get_oidc_metadata() + jwks_url = metadata["jwks_uri"] + JWKS_CACHE["jwks_client"] = jwt.PyJWKClient( + jwks_url, + cache_keys=True, # PyJWT's built-in key caching + max_cached_keys=5, + ) + return JWKS_CACHE["jwks_client"] + + +async def get_current_user(token: str = Depends(oauth2_scheme)) -> User: + try: + # Fetch JWKS keys from Authentik + metadata = await get_oidc_metadata() + signing_key = (await get_cached_jwks_client()).get_signing_key_from_jwt(token) + + # Decode and verify JWT + payload = jwt.decode( + token, + signing_key, + algorithms=["RS256"], + audience=OIDC_CLIENT_ID, + issuer=metadata["issuer"], + options={"verify_exp": False}, + ) + return User(**payload) + except jwt.PyJWTError as e: + raise HTTPException(status_code=401, detail=f"Invalid token: {e}") diff --git a/crawler/api/config.py b/crawler/api/config.py new file mode 100644 index 0000000..c7cfaae --- /dev/null +++ b/crawler/api/config.py @@ -0,0 +1,16 @@ +from datetime import timedelta + + +# Authentik OIDC Configuration +AUTHENTIK_URL = "https://authentik.viktorbarzin.me/" +OIDC_CLIENT_ID = "5AJKRgcdgVm1OyApBzFkadDFfStW9a555zwv2MOe" +OIDC_METADATA_URL = ( + f"{AUTHENTIK_URL}/application/o/wrongmove/.well-known/openid-configuration" +) + +OIDC_CACHE_TTL = timedelta( + hours=1 +).total_seconds() # Cache to avoid spamming authentik with requests + +DEV_TIER_ORIGINS = ["https://devvm.viktorbarzin.lan/"] +PROD_TIER_ORIGINS = ["https://wrongmove.viktorbarzin.me/"] diff --git a/crawler/poetry.lock b/crawler/poetry.lock index ecd7ca1..27117b9 100644 --- a/crawler/poetry.lock +++ b/crawler/poetry.lock @@ -707,6 +707,66 @@ mypy = ["contourpy[bokeh,docs]", "docutils-stubs", "mypy (==1.11.1)", "types-Pil test = ["Pillow", "contourpy[test-no-images]", "matplotlib"] test-no-images = ["pytest", "pytest-cov", "pytest-rerunfailures", "pytest-xdist", "wurlitzer"] +[[package]] +name = "cryptography" +version = "45.0.4" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +optional = false +python-versions = "!=3.9.0,!=3.9.1,>=3.7" +groups = ["main"] +files = [ + {file = "cryptography-45.0.4-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:425a9a6ac2823ee6e46a76a21a4e8342d8fa5c01e08b823c1f19a8b74f096069"}, + {file = "cryptography-45.0.4-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:680806cf63baa0039b920f4976f5f31b10e772de42f16310a6839d9f21a26b0d"}, + {file = "cryptography-45.0.4-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4ca0f52170e821bc8da6fc0cc565b7bb8ff8d90d36b5e9fdd68e8a86bdf72036"}, + {file = "cryptography-45.0.4-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f3fe7a5ae34d5a414957cc7f457e2b92076e72938423ac64d215722f6cf49a9e"}, + {file = "cryptography-45.0.4-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:25eb4d4d3e54595dc8adebc6bbd5623588991d86591a78c2548ffb64797341e2"}, + {file = "cryptography-45.0.4-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ce1678a2ccbe696cf3af15a75bb72ee008d7ff183c9228592ede9db467e64f1b"}, + {file = "cryptography-45.0.4-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:49fe9155ab32721b9122975e168a6760d8ce4cffe423bcd7ca269ba41b5dfac1"}, + {file = "cryptography-45.0.4-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2882338b2a6e0bd337052e8b9007ced85c637da19ef9ecaf437744495c8c2999"}, + {file = "cryptography-45.0.4-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:23b9c3ea30c3ed4db59e7b9619272e94891f8a3a5591d0b656a7582631ccf750"}, + {file = "cryptography-45.0.4-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b0a97c927497e3bc36b33987abb99bf17a9a175a19af38a892dc4bbb844d7ee2"}, + {file = "cryptography-45.0.4-cp311-abi3-win32.whl", hash = "sha256:e00a6c10a5c53979d6242f123c0a97cff9f3abed7f064fc412c36dc521b5f257"}, + {file = "cryptography-45.0.4-cp311-abi3-win_amd64.whl", hash = "sha256:817ee05c6c9f7a69a16200f0c90ab26d23a87701e2a284bd15156783e46dbcc8"}, + {file = "cryptography-45.0.4-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:964bcc28d867e0f5491a564b7debb3ffdd8717928d315d12e0d7defa9e43b723"}, + {file = "cryptography-45.0.4-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6a5bf57554e80f75a7db3d4b1dacaa2764611ae166ab42ea9a72bcdb5d577637"}, + {file = "cryptography-45.0.4-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:46cf7088bf91bdc9b26f9c55636492c1cce3e7aaf8041bbf0243f5e5325cfb2d"}, + {file = "cryptography-45.0.4-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:7bedbe4cc930fa4b100fc845ea1ea5788fcd7ae9562e669989c11618ae8d76ee"}, + {file = "cryptography-45.0.4-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:eaa3e28ea2235b33220b949c5a0d6cf79baa80eab2eb5607ca8ab7525331b9ff"}, + {file = "cryptography-45.0.4-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:7ef2dde4fa9408475038fc9aadfc1fb2676b174e68356359632e980c661ec8f6"}, + {file = "cryptography-45.0.4-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:6a3511ae33f09094185d111160fd192c67aa0a2a8d19b54d36e4c78f651dc5ad"}, + {file = "cryptography-45.0.4-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:06509dc70dd71fa56eaa138336244e2fbaf2ac164fc9b5e66828fccfd2b680d6"}, + {file = "cryptography-45.0.4-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5f31e6b0a5a253f6aa49be67279be4a7e5a4ef259a9f33c69f7d1b1191939872"}, + {file = "cryptography-45.0.4-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:944e9ccf67a9594137f942d5b52c8d238b1b4e46c7a0c2891b7ae6e01e7c80a4"}, + {file = "cryptography-45.0.4-cp37-abi3-win32.whl", hash = "sha256:c22fe01e53dc65edd1945a2e6f0015e887f84ced233acecb64b4daadb32f5c97"}, + {file = "cryptography-45.0.4-cp37-abi3-win_amd64.whl", hash = "sha256:627ba1bc94f6adf0b0a2e35d87020285ead22d9f648c7e75bb64f367375f3b22"}, + {file = "cryptography-45.0.4-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a77c6fb8d76e9c9f99f2f3437c1a4ac287b34eaf40997cfab1e9bd2be175ac39"}, + {file = "cryptography-45.0.4-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7aad98a25ed8ac917fdd8a9c1e706e5a0956e06c498be1f713b61734333a4507"}, + {file = "cryptography-45.0.4-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:3530382a43a0e524bc931f187fc69ef4c42828cf7d7f592f7f249f602b5a4ab0"}, + {file = "cryptography-45.0.4-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:6b613164cb8425e2f8db5849ffb84892e523bf6d26deb8f9bb76ae86181fa12b"}, + {file = "cryptography-45.0.4-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:96d4819e25bf3b685199b304a0029ce4a3caf98947ce8a066c9137cc78ad2c58"}, + {file = "cryptography-45.0.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:b97737a3ffbea79eebb062eb0d67d72307195035332501722a9ca86bab9e3ab2"}, + {file = "cryptography-45.0.4-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:4828190fb6c4bcb6ebc6331f01fe66ae838bb3bd58e753b59d4b22eb444b996c"}, + {file = "cryptography-45.0.4-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:03dbff8411206713185b8cebe31bc5c0eb544799a50c09035733716b386e61a4"}, + {file = "cryptography-45.0.4-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:51dfbd4d26172d31150d84c19bbe06c68ea4b7f11bbc7b3a5e146b367c311349"}, + {file = "cryptography-45.0.4-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:0339a692de47084969500ee455e42c58e449461e0ec845a34a6a9b9bf7df7fb8"}, + {file = "cryptography-45.0.4-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:0cf13c77d710131d33e63626bd55ae7c0efb701ebdc2b3a7952b9b23a0412862"}, + {file = "cryptography-45.0.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bbc505d1dc469ac12a0a064214879eac6294038d6b24ae9f71faae1448a9608d"}, + {file = "cryptography-45.0.4.tar.gz", hash = "sha256:7405ade85c83c37682c8fe65554759800a4a8c54b2d96e0f8ad114d31b808d57"}, +] + +[package.dependencies] +cffi = {version = ">=1.14", markers = "platform_python_implementation != \"PyPy\""} + +[package.extras] +docs = ["sphinx (>=5.3.0)", "sphinx-inline-tabs ; python_full_version >= \"3.8.0\"", "sphinx-rtd-theme (>=3.0.0) ; python_full_version >= \"3.8.0\""] +docstest = ["pyenchant (>=3)", "readme-renderer (>=30.0)", "sphinxcontrib-spelling (>=7.3.1)"] +nox = ["nox (>=2024.4.15)", "nox[uv] (>=2024.3.2) ; python_full_version >= \"3.8.0\""] +pep8test = ["check-sdist ; python_full_version >= \"3.8.0\"", "click (>=8.0.1)", "mypy (>=1.4)", "ruff (>=0.3.6)"] +sdist = ["build (>=1.0.0)"] +ssh = ["bcrypt (>=3.1.5)"] +test = ["certifi (>=2024)", "cryptography-vectors (==45.0.4)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] +test-randomorder = ["pytest-randomly"] + [[package]] name = "cycler" version = "0.12.1" @@ -3093,6 +3153,24 @@ files = [ [package.extras] windows-terminal = ["colorama (>=0.4.6)"] +[[package]] +name = "pyjwt" +version = "2.10.1" +description = "JSON Web Token implementation in Python" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb"}, + {file = "pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953"}, +] + +[package.extras] +crypto = ["cryptography (>=3.4.0)"] +dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=6.0.0,<7.0.0)", "sphinx", "sphinx-rtd-theme", "zope.interface"] +docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] +tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"] + [[package]] name = "pyparsing" version = "3.2.1" @@ -4757,4 +4835,4 @@ propcache = ">=0.2.1" [metadata] lock-version = "2.1" python-versions = ">3.11" -content-hash = "fc802e0f8dd4f7ff26fbead06859e20e2efa5b08c4889eb2bc134010ec1ff5a2" +content-hash = "d540a53d0e998203fde20855741d4b47f37a6f7b575e263cd6e506356c63b53b" diff --git a/crawler/pyproject.toml b/crawler/pyproject.toml index 9c3af78..711f61d 100644 --- a/crawler/pyproject.toml +++ b/crawler/pyproject.toml @@ -26,6 +26,8 @@ alembic = "^1.16.1" sqlalchemy = {extras = ["asyncio"], version = "^2.0.41"} tenacity = "^9.1.2" fastapi = {extras = ["standard"], version = "^0.115.12"} +pyjwt = "^2.10.1" +cryptography = "^45.0.4" [tool.poetry.group.dev.dependencies] ipdb = "^0.13.13"