From 1856cb76def6eca3fe00566fde37ee11a9b6c65e Mon Sep 17 00:00:00 2001 From: Kai Vogelgesang Date: Mon, 9 Oct 2023 23:44:32 +0200 Subject: [PATCH] Implement gitea login backend --- backend/.gitignore | 2 + backend/backend/app.py | 9 +++ backend/backend/auth.py | 78 ++++++++++++++++++ backend/backend/settings.py | 12 +++ backend/poetry.lock | 156 +++++++++++++++++++++++++++++++++++- backend/pyproject.toml | 4 + backend/secret.env.example | 3 + 7 files changed, 263 insertions(+), 1 deletion(-) create mode 100644 backend/backend/auth.py create mode 100644 backend/backend/settings.py create mode 100644 backend/secret.env.example diff --git a/backend/.gitignore b/backend/.gitignore index 68bc17f..31edafd 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -1,3 +1,5 @@ +secret.env + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] diff --git a/backend/backend/app.py b/backend/backend/app.py index c8d84a6..84640db 100644 --- a/backend/backend/app.py +++ b/backend/backend/app.py @@ -1,11 +1,19 @@ from fastapi import FastAPI from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles +from starlette.middleware.sessions import SessionMiddleware + +from .settings import settings +from .auth import auth, Moderator app = FastAPI() +app.add_middleware(SessionMiddleware, secret_key=settings.session_secret_key) + +app.mount("/user/", auth) frontend = FastAPI() + @frontend.middleware("http") async def index_catch_all(request, call_next): response = await call_next(request) @@ -15,5 +23,6 @@ async def index_catch_all(request, call_next): return response + frontend.mount("/", StaticFiles(directory="frontend/static")) app.mount("/", frontend) diff --git a/backend/backend/auth.py b/backend/backend/auth.py new file mode 100644 index 0000000..4b6b0fa --- /dev/null +++ b/backend/backend/auth.py @@ -0,0 +1,78 @@ +from typing import Annotated +from http import HTTPStatus + +from fastapi import FastAPI, Request, Depends, HTTPException +from pydantic import BaseModel, HttpUrl +from starlette.responses import RedirectResponse +from authlib.integrations.starlette_client import OAuth, OAuthError + +from .settings import settings + +oauth = OAuth() +oauth.register( + name="gitea", + client_id=settings.oauth_client_id, + client_secret=settings.oauth_client_secret, + server_metadata_url="https://git.leafbla.de/.well-known/openid-configuration", +) + +auth = FastAPI() + + +@auth.get("/login") +async def login(request: Request): + redirect_uri = request.url_for("authenticate") + return await oauth.gitea.authorize_redirect(request, redirect_uri) + + +@auth.get("/auth") +async def authenticate(request: Request): + try: + token = await oauth.gitea.authorize_access_token(request) + except OAuthError as e: + return RedirectResponse(url="/") + + user = await oauth.gitea.userinfo(token=token) + if user: + request.session["user"] = dict(user) + return RedirectResponse(url="/") + + +@auth.get("/logout") +async def logout(request: Request): + request.session.pop("user", None) + return RedirectResponse(url="/") + + +class MeResponse(BaseModel): + name: str + picture: HttpUrl + is_moderator: bool + + +@auth.get("/me", response_model=MeResponse | None) +async def user_info(request: Request): + user = request.session.get("user") + if user is None: + return None + return MeResponse( + name=user["preferred_username"], + picture=user["picture"], + is_moderator=is_moderator(request), + ) + + +def is_moderator(request: Request): + try: + user = request.session["user"] + return "Everest:stream-moderators" in user["groups"] + except Exception as _: + return False + + +def moderator_or_raise(request: Request): + if not is_moderator(request): + raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED) + + +Moderator = Annotated[None, Depends(moderator_or_raise)] diff --git a/backend/backend/settings.py b/backend/backend/settings.py new file mode 100644 index 0000000..4baba4d --- /dev/null +++ b/backend/backend/settings.py @@ -0,0 +1,12 @@ +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + session_secret_key: str + oauth_client_id: str + oauth_client_secret: str + + model_config = SettingsConfigDict(env_file=(".env", "secret.env")) + + +settings = Settings() diff --git a/backend/poetry.lock b/backend/poetry.lock index c09a9dc..0599439 100644 --- a/backend/poetry.lock +++ b/backend/poetry.lock @@ -49,6 +49,31 @@ docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib- tests = ["attrs[tests-no-zope]", "zope-interface"] tests-no-zope = ["cloudpickle", "hypothesis", "mypy (>=1.1.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +[[package]] +name = "authlib" +version = "1.2.1" +description = "The ultimate Python library in building OAuth and OpenID Connect servers and clients." +optional = false +python-versions = "*" +files = [ + {file = "Authlib-1.2.1-py2.py3-none-any.whl", hash = "sha256:c88984ea00149a90e3537c964327da930779afa4564e354edfd98410bea01911"}, + {file = "Authlib-1.2.1.tar.gz", hash = "sha256:421f7c6b468d907ca2d9afede256f068f87e34d23dd221c07d13d4c234726afb"}, +] + +[package.dependencies] +cryptography = ">=3.2" + +[[package]] +name = "certifi" +version = "2023.7.22" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.6" +files = [ + {file = "certifi-2023.7.22-py3-none-any.whl", hash = "sha256:92d6037539857d8206b8f6ae472e8b77db8058fec5937a1ef3f54304089edbb9"}, + {file = "certifi-2023.7.22.tar.gz", hash = "sha256:539cc1d13202e33ca466e88b2807e29f4c13049d6d87031a3c110744495cb082"}, +] + [[package]] name = "cffi" version = "1.16.0" @@ -113,6 +138,51 @@ files = [ [package.dependencies] pycparser = "*" +[[package]] +name = "cryptography" +version = "41.0.4" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +optional = false +python-versions = ">=3.7" +files = [ + {file = "cryptography-41.0.4-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:80907d3faa55dc5434a16579952ac6da800935cd98d14dbd62f6f042c7f5e839"}, + {file = "cryptography-41.0.4-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:35c00f637cd0b9d5b6c6bd11b6c3359194a8eba9c46d4e875a3660e3b400005f"}, + {file = "cryptography-41.0.4-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cecfefa17042941f94ab54f769c8ce0fe14beff2694e9ac684176a2535bf9714"}, + {file = "cryptography-41.0.4-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e40211b4923ba5a6dc9769eab704bdb3fbb58d56c5b336d30996c24fcf12aadb"}, + {file = "cryptography-41.0.4-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:23a25c09dfd0d9f28da2352503b23e086f8e78096b9fd585d1d14eca01613e13"}, + {file = "cryptography-41.0.4-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2ed09183922d66c4ec5fdaa59b4d14e105c084dd0febd27452de8f6f74704143"}, + {file = "cryptography-41.0.4-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:5a0f09cefded00e648a127048119f77bc2b2ec61e736660b5789e638f43cc397"}, + {file = "cryptography-41.0.4-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:9eeb77214afae972a00dee47382d2591abe77bdae166bda672fb1e24702a3860"}, + {file = "cryptography-41.0.4-cp37-abi3-win32.whl", hash = "sha256:3b224890962a2d7b57cf5eeb16ccaafba6083f7b811829f00476309bce2fe0fd"}, + {file = "cryptography-41.0.4-cp37-abi3-win_amd64.whl", hash = "sha256:c880eba5175f4307129784eca96f4e70b88e57aa3f680aeba3bab0e980b0f37d"}, + {file = "cryptography-41.0.4-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:004b6ccc95943f6a9ad3142cfabcc769d7ee38a3f60fb0dddbfb431f818c3a67"}, + {file = "cryptography-41.0.4-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:86defa8d248c3fa029da68ce61fe735432b047e32179883bdb1e79ed9bb8195e"}, + {file = "cryptography-41.0.4-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:37480760ae08065437e6573d14be973112c9e6dcaf5f11d00147ee74f37a3829"}, + {file = "cryptography-41.0.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:b5f4dfe950ff0479f1f00eda09c18798d4f49b98f4e2006d644b3301682ebdca"}, + {file = "cryptography-41.0.4-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:7e53db173370dea832190870e975a1e09c86a879b613948f09eb49324218c14d"}, + {file = "cryptography-41.0.4-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5b72205a360f3b6176485a333256b9bcd48700fc755fef51c8e7e67c4b63e3ac"}, + {file = "cryptography-41.0.4-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:93530900d14c37a46ce3d6c9e6fd35dbe5f5601bf6b3a5c325c7bffc030344d9"}, + {file = "cryptography-41.0.4-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:efc8ad4e6fc4f1752ebfb58aefece8b4e3c4cae940b0994d43649bdfce8d0d4f"}, + {file = "cryptography-41.0.4-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c3391bd8e6de35f6f1140e50aaeb3e2b3d6a9012536ca23ab0d9c35ec18c8a91"}, + {file = "cryptography-41.0.4-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:0d9409894f495d465fe6fda92cb70e8323e9648af912d5b9141d616df40a87b8"}, + {file = "cryptography-41.0.4-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:8ac4f9ead4bbd0bc8ab2d318f97d85147167a488be0e08814a37eb2f439d5cf6"}, + {file = "cryptography-41.0.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:047c4603aeb4bbd8db2756e38f5b8bd7e94318c047cfe4efeb5d715e08b49311"}, + {file = "cryptography-41.0.4.tar.gz", hash = "sha256:7febc3094125fc126a7f6fb1f420d0da639f3f32cb15c8ff0dc3997c4549f51a"}, +] + +[package.dependencies] +cffi = ">=1.12" + +[package.extras] +docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"] +docstest = ["pyenchant (>=1.6.11)", "sphinxcontrib-spelling (>=4.0.1)", "twine (>=1.12.0)"] +nox = ["nox"] +pep8test = ["black", "check-sdist", "mypy", "ruff"] +sdist = ["build"] +ssh = ["bcrypt (>=3.1.5)"] +test = ["pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] +test-randomorder = ["pytest-randomly"] + [[package]] name = "fastapi" version = "0.103.2" @@ -170,6 +240,50 @@ files = [ {file = "hpack-4.0.0.tar.gz", hash = "sha256:fc41de0c63e687ebffde81187a948221294896f6bdc0ae2312708df339430095"}, ] +[[package]] +name = "httpcore" +version = "0.18.0" +description = "A minimal low-level HTTP client." +optional = false +python-versions = ">=3.8" +files = [ + {file = "httpcore-0.18.0-py3-none-any.whl", hash = "sha256:adc5398ee0a476567bf87467063ee63584a8bce86078bf748e48754f60202ced"}, + {file = "httpcore-0.18.0.tar.gz", hash = "sha256:13b5e5cd1dca1a6636a6aaea212b19f4f85cd88c366a2b82304181b769aab3c9"}, +] + +[package.dependencies] +anyio = ">=3.0,<5.0" +certifi = "*" +h11 = ">=0.13,<0.15" +sniffio = "==1.*" + +[package.extras] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] + +[[package]] +name = "httpx" +version = "0.25.0" +description = "The next generation HTTP client." +optional = false +python-versions = ">=3.8" +files = [ + {file = "httpx-0.25.0-py3-none-any.whl", hash = "sha256:181ea7f8ba3a82578be86ef4171554dd45fec26a02556a744db029a0a27b7100"}, + {file = "httpx-0.25.0.tar.gz", hash = "sha256:47ecda285389cb32bb2691cc6e069e3ab0205956f681c5b2ad2325719751d875"}, +] + +[package.dependencies] +certifi = "*" +httpcore = ">=0.18.0,<0.19.0" +idna = "*" +sniffio = "*" + +[package.extras] +brotli = ["brotli", "brotlicffi"] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] + [[package]] name = "hypercorn" version = "0.14.4" @@ -216,6 +330,17 @@ files = [ {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, ] +[[package]] +name = "itsdangerous" +version = "2.1.2" +description = "Safely pass data to untrusted environments and back." +optional = false +python-versions = ">=3.7" +files = [ + {file = "itsdangerous-2.1.2-py3-none-any.whl", hash = "sha256:2c2349112351b88699d8d4b6b075022c0808887cb7ad10069318a8b0bc88db44"}, + {file = "itsdangerous-2.1.2.tar.gz", hash = "sha256:5dbbc68b317e5e42f327f9021763545dc3fc3bfe22e6deb96aaf1fc38874156a"}, +] + [[package]] name = "outcome" version = "1.2.0" @@ -389,6 +514,35 @@ files = [ [package.dependencies] typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" +[[package]] +name = "pydantic-settings" +version = "2.0.3" +description = "Settings management using Pydantic" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pydantic_settings-2.0.3-py3-none-any.whl", hash = "sha256:ddd907b066622bd67603b75e2ff791875540dc485b7307c4fffc015719da8625"}, + {file = "pydantic_settings-2.0.3.tar.gz", hash = "sha256:962dc3672495aad6ae96a4390fac7e593591e144625e5112d359f8f67fb75945"}, +] + +[package.dependencies] +pydantic = ">=2.0.1" +python-dotenv = ">=0.21.0" + +[[package]] +name = "python-dotenv" +version = "1.0.0" +description = "Read key-value pairs from a .env file and set them as environment variables" +optional = false +python-versions = ">=3.8" +files = [ + {file = "python-dotenv-1.0.0.tar.gz", hash = "sha256:a8df96034aae6d2d50a4ebe8216326c61c3eb64836776504fcca410e5937a3ba"}, + {file = "python_dotenv-1.0.0-py3-none-any.whl", hash = "sha256:f5971a9226b701070a4bf2c38c89e5a3f0d64de8debda981d1db98583009122a"}, +] + +[package.extras] +cli = ["click (>=5.0)"] + [[package]] name = "sniffio" version = "1.3.0" @@ -475,4 +629,4 @@ h11 = ">=0.9.0,<1" [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "7bef64f3147f62c61bef9d9f007339b036469f5039443240d9c6e3103840c09c" +content-hash = "e5d7da4973e7e20b9f60f8fb4dbd534ddc2adcb22eef9085495a301e49b6b8a7" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index b93f91d..9ded57d 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -9,6 +9,10 @@ readme = "README.md" python = "^3.11" hypercorn = {extras = ["trio"], version = "^0.14.4"} fastapi = "^0.103.2" +authlib = "^1.2.1" +itsdangerous = "^2.1.2" +pydantic-settings = "^2.0.3" +httpx = "^0.25.0" [build-system] diff --git a/backend/secret.env.example b/backend/secret.env.example new file mode 100644 index 0000000..2bda128 --- /dev/null +++ b/backend/secret.env.example @@ -0,0 +1,3 @@ +OAUTH_CLIENT_ID="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" +OAUTH_CLIENT_SECRET="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" +SESSION_SECRET_KEY="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \ No newline at end of file