diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2914716 --- /dev/null +++ b/.gitignore @@ -0,0 +1,179 @@ +.env + +# Created by https://www.toptal.com/developers/gitignore/api/python +# Edit at https://www.toptal.com/developers/gitignore?templates=python + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +### Python Patch ### +# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration +poetry.toml + +# ruff +.ruff_cache/ + +# LSP config files +pyrightconfig.json + +# End of https://www.toptal.com/developers/gitignore/api/python + diff --git a/auth/oidc.py b/auth/oidc.py new file mode 100644 index 0000000..70c1408 --- /dev/null +++ b/auth/oidc.py @@ -0,0 +1,66 @@ +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import RedirectResponse +from authlib.integrations.starlette_client import OAuth +from starlette.requests import Request + +from dotenv import load_dotenv +import os + +oauth = OAuth() +router = APIRouter() + +# OIDC-Provider konfigurieren (hier als Beispiel Auth0) +# oauth.register( +# name="auth0", +# client_id="ZUZYpdYmqjMwdBDb2GdWWX4xkASNe2gsYqLlF9dy", +# client_secret="o6LXTspeaiAMhvPyX2vplQ0RUsRGhthFadg1M5LOJylpQXm9A0d8YJ4CeNwq0kAg2BrdCM7UyfZFOlnVjrJS2o4fBvvhLWDfbd7LhScCzde4Heh5P3C26ZWCRGQppJhb", +# authorize_url="https://login.fs.cs.uni-frankfurt.de/application/o/authorize/", +# authorize_params=None, +# access_token_url="https://login.fs.cs.uni-frankfurt.de/application/o/token/", +# access_token_params=None, +# client_kwargs={"scope": "openid profile email"}, +# server_metadata_url="https://login.fs.cs.uni-frankfurt.de/application/o/testkicker/.well-known/openid-configuration", +# api_base_url="https://login.fs.cs.uni-frankfurt.de/application/o/testkicker/", +# jwks_uri="https://login.fs.cs.uni-frankfurt.de/application/o/testkicker/jwks/", +# userinfo_endpoint="https://login.fs.cs.uni-frankfurt.de/application/o/userinfo/", +# ) + +load_dotenv() + +oauth.register( + name="auth0", + client_id=os.getenv("OIDC_CLIENT_ID"), + client_secret=os.getenv("OIDC_CLIENT_SECRET"), + server_metadata_url=os.getenv("OIDC_CONFIG_URL"), + client_kwargs={ + "scope": "openid email profile" + }, +) + +@router.get("/login/oidc") +async def login(request: Request): + auth0_client = oauth.create_client("auth0") + redirect_uri = os.getenv("OIDC_REDIRECT_URI") + return await auth0_client.authorize_redirect(request, redirect_uri) + +@router.route("/authorize") +async def authorize(request: Request): + token = await oauth.auth0.authorize_access_token(request) + userinfo_endpoint = oauth.auth0.server_metadata.get("userinfo_endpoint") + resp = await oauth.auth0.get(userinfo_endpoint, token=token) + resp.raise_for_status() + profile = resp.json() + print("Profile:", profile) + + # save user info in session + request.session["user"] = profile + + return RedirectResponse(url="/", status_code=303) + +@router.get("/logout") +async def logout(request: Request): + request.session.pop("user", None) + logout_url = oauth.auth0.server_metadata.get("end_session_endpoint") + if not logout_url: + logout_url = os.getenv("OIDC_LOGOUT_URL") + return RedirectResponse(url=logout_url, status_code=303) diff --git a/auth/session.py b/auth/session.py new file mode 100644 index 0000000..c47312a --- /dev/null +++ b/auth/session.py @@ -0,0 +1,31 @@ +from fastapi import Depends, HTTPException +from starlette.requests import Request +from db.models import User, SessionLocal +from sqlalchemy.orm import Session + +from starlette.requests import Request +from db.models import User, get_db +from sqlalchemy.orm import Session + +SESSION_KEY = "user" + + +def login_user(request: Request, db: Session, username: str): + user = db.query(User).filter(User.username == username).first() + if user: + request.session[SESSION_KEY] = user.id + return user + return None + + +def logout_user(request: Request): + request.session.pop(SESSION_KEY, None) + + +def get_current_user(request: Request, db: Session = Depends(get_db)): + user_id = request.session.get(SESSION_KEY) + # if user_id is None: + # raise HTTPException(status_code=401, detail="Nicht eingeloggt") + # user = db.query(User).filter(User.id == user_id).first() + user = user_id + return user diff --git a/auth/webauthn.py b/auth/webauthn.py new file mode 100644 index 0000000..a44247d --- /dev/null +++ b/auth/webauthn.py @@ -0,0 +1,86 @@ +from fastapi import APIRouter, Request, Response, HTTPException +from fastapi.responses import JSONResponse, RedirectResponse +from webauthn import ( + generate_authentication_options, + verify_authentication_response, +) +from webauthn import ( + generate_registration_options, + verify_registration_response, + options_to_json, + base64url_to_bytes, +) +from webauthn.helpers.cose import COSEAlgorithmIdentifier +from webauthn.helpers.structs import ( + AttestationConveyancePreference, + AuthenticatorAttachment, + AuthenticatorSelectionCriteria, + PublicKeyCredentialDescriptor, + PublicKeyCredentialHint, + ResidentKeyRequirement, +) + +import os + +router = APIRouter() + +# Simulierte Userdatenbank (nur zum Testen!) +fake_users = { + "admin@example.com": { + "id": b"user-id-in-bytes", + "credential_id": b"credential-id-in-bytes", + "public_key": b"public-key-in-bytes", + "sign_count": 0 + } +} + +RP_ID = "localhost" # Oder deine Domain bei Produktivbetrieb +ORIGIN = "http://localhost:8000" + +@router.get("/login/webauthn/start") +async def start_webauthn(request: Request): + email = "admin@example.com" # Hardcoded Demo-User + + if email not in fake_users: + raise HTTPException(status_code=404, detail="User nicht gefunden") + + user = fake_users[email] + + options = PublicKeyCredentialRequestOptions( + challenge=os.urandom(32), + rp_id=RP_ID, + allow_credentials=[...], + timeout=60000, +) + + # Speichere Challenge für später + request.session["challenge"] = options.challenge + return JSONResponse(content=options.model_dump()) + +@router.post("/login/webauthn/finish") +async def finish_webauthn(request: Request): + body = await request.json() + email = "admin@example.com" # Again, Demo-User + + if email not in fake_users: + raise HTTPException(status_code=404, detail="User nicht gefunden") + + user = fake_users[email] + + try: + verified_auth = verify_authentication_response( + credential=AuthenticationCredential.parse_obj(body), + expected_challenge=request.session.get("challenge"), + expected_rp_id=RP_ID, + expected_origin=ORIGIN, + credential_public_key=user["public_key"], + credential_current_sign_count=user["sign_count"], + credential_id=user["credential_id"] + ) + + # Erfolg – setze Session + request.session["user"] = email + return RedirectResponse(url="/", status_code=303) + + except Exception as e: + return JSONResponse({"detail": f"WebAuthn fehlgeschlagen: {str(e)}"}, status_code=400) diff --git a/db/models.py b/db/models.py new file mode 100644 index 0000000..c2f66cc --- /dev/null +++ b/db/models.py @@ -0,0 +1,27 @@ +from sqlalchemy import Column, Integer, String +from sqlalchemy.orm import declarative_base, sessionmaker +from sqlalchemy import create_engine +from fastapi import Depends + +DATABASE_URL = "sqlite:///./test.db" + +engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False}) +SessionLocal = sessionmaker(bind=engine) + +Base = declarative_base() + + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True) + username = Column(String, unique=True, index=True) + role = Column(String) # z. B. "admin" oder "user" + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/main.py b/main.py new file mode 100644 index 0000000..142a8fb --- /dev/null +++ b/main.py @@ -0,0 +1,38 @@ +from fastapi import FastAPI, Request, Depends, Form, HTTPException +from fastapi.responses import RedirectResponse, HTMLResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates +from starlette.middleware.sessions import SessionMiddleware + +from db.models import Base, engine, SessionLocal, get_db, User + +from auth.session import get_current_user, login_user, logout_user + +from auth import webauthn, oidc + +import uvicorn + + +app = FastAPI() +app.add_middleware(SessionMiddleware, secret_key="my_secret_key") +app.include_router(webauthn.router) +app.include_router(oidc.router) + +app.mount("/static", StaticFiles(directory="static"), name="static") +templates = Jinja2Templates(directory="templates") + +# DB +Base.metadata.create_all(bind=engine) + +@app.get("/", response_class=HTMLResponse) +def home(request: Request, user: User = Depends(get_current_user)): + if not user: + return RedirectResponse(url="/login", status_code=303) + return templates.TemplateResponse("index.html", {"request": request, "user": user}) + +@app.get("/login", response_class=HTMLResponse) +def login_form(request: Request): + return templates.TemplateResponse("login.html", {"request": request}) + +if __name__ == "__main__": + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b0bf877 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,11 @@ +fastapi +uvicorn +sqlalchemy +jinja2 +python-multipart +itsdangerous +webauthn +passlib +authlib +httpx +python-dotenv diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..41c05a1 --- /dev/null +++ b/static/style.css @@ -0,0 +1,11 @@ +body { + font-family: sans-serif; + max-width: 800px; + margin: auto; + padding: 1em; +} +header { + background-color: #eee; + padding: 1em; + margin-bottom: 1em; +} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..88f79c8 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,19 @@ + + +
+ +Angemeldet als {{ user.preferred_username }} ({{ user.groups }}) – Logout
+ {% endif %} +Dies ist eine einfache geschützte Seite.
+{% endblock %} diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..a6d25b2 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} +{% block title %}Login{% endblock %} +{% block content %} +