Working prototype
This commit is contained in:
179
.gitignore
vendored
Normal file
179
.gitignore
vendored
Normal file
@@ -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
|
||||
|
||||
66
auth/oidc.py
Normal file
66
auth/oidc.py
Normal file
@@ -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)
|
||||
31
auth/session.py
Normal file
31
auth/session.py
Normal file
@@ -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
|
||||
86
auth/webauthn.py
Normal file
86
auth/webauthn.py
Normal file
@@ -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)
|
||||
27
db/models.py
Normal file
27
db/models.py
Normal file
@@ -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()
|
||||
38
main.py
Normal file
38
main.py
Normal file
@@ -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)
|
||||
11
requirements.txt
Normal file
11
requirements.txt
Normal file
@@ -0,0 +1,11 @@
|
||||
fastapi
|
||||
uvicorn
|
||||
sqlalchemy
|
||||
jinja2
|
||||
python-multipart
|
||||
itsdangerous
|
||||
webauthn
|
||||
passlib
|
||||
authlib
|
||||
httpx
|
||||
python-dotenv
|
||||
11
static/style.css
Normal file
11
static/style.css
Normal file
@@ -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;
|
||||
}
|
||||
19
templates/base.html
Normal file
19
templates/base.html
Normal file
@@ -0,0 +1,19 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>{% block title %}Meine Seite{% endblock %}</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Meine Beispielseite</h1>
|
||||
{% if user %}
|
||||
<p>Angemeldet als {{ user.preferred_username }} ({{ user.groups }}) – <a href="/logout">Logout</a></p>
|
||||
{% endif %}
|
||||
</header>
|
||||
<main>
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
6
templates/index.html
Normal file
6
templates/index.html
Normal file
@@ -0,0 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Startseite{% endblock %}
|
||||
{% block content %}
|
||||
<h2>Willkommen, {{ user.username }}!</h2>
|
||||
<p>Dies ist eine einfache geschützte Seite.</p>
|
||||
{% endblock %}
|
||||
17
templates/login.html
Normal file
17
templates/login.html
Normal file
@@ -0,0 +1,17 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Login{% endblock %}
|
||||
{% block content %}
|
||||
<h2>Login</h2>
|
||||
|
||||
<!-- SSO-Button -->
|
||||
<form method="get" action="/login/oidc">
|
||||
<button type="submit">🔐 Login with Authentik</button>
|
||||
</form>
|
||||
|
||||
<!-- WebAuthn-Button -->
|
||||
<!-- <form method="get" action="/login/webauthn">
|
||||
<button type="submit">🧷 Mit WebAuthn anmelden</button>
|
||||
</form> -->
|
||||
|
||||
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user