diff --git a/finance/app/auth.py b/finance/app/auth.py new file mode 100644 index 0000000..0373645 --- /dev/null +++ b/finance/app/auth.py @@ -0,0 +1,53 @@ +import hashlib +import hmac +import secrets + +from fastapi import HTTPException, Request +from itsdangerous import BadSignature, TimestampSigner + +from app.config import get_settings + +COOKIE = "fb_session" +MAX_AGE = 60 * 60 * 12 # 12 h + + +def hash_password(pw: str, salt: str | None = None) -> str: + salt = salt or secrets.token_hex(16) + digest = hashlib.pbkdf2_hmac("sha256", pw.encode(), salt.encode(), 200_000).hex() + return f"{salt}${digest}" + + +def verify_password(pw: str, stored: str) -> bool: + try: + salt, digest = stored.split("$", 1) + except ValueError: + return False + return hmac.compare_digest(hash_password(pw, salt).split("$", 1)[1], digest) + + +def _signer() -> TimestampSigner: + return TimestampSigner(get_settings().session_secret) + + +def make_session_token() -> str: + return _signer().sign(b"gui").decode() + + +def session_valid(token: str | None) -> bool: + if not token: + return False + try: + _signer().unsign(token, max_age=MAX_AGE) + return True + except BadSignature: + return False + + +def require_auth(request: Request) -> None: + settings = get_settings() + header = request.headers.get("authorization", "") + if settings.api_key and header == f"Bearer {settings.api_key}": + return + if session_valid(request.cookies.get(COOKIE)): + return + raise HTTPException(status_code=401, detail="Nicht angemeldet") diff --git a/finance/app/main.py b/finance/app/main.py new file mode 100644 index 0000000..854c16d --- /dev/null +++ b/finance/app/main.py @@ -0,0 +1,41 @@ +import hmac + +from fastapi import Depends, FastAPI, Form, Request +from fastapi.responses import HTMLResponse, RedirectResponse + +from app import auth +from app.config import get_settings + +app = FastAPI(title="Finanzberatungs-Tool") + + +@app.get("/login", response_class=HTMLResponse) +def login_form(): + return """
+ + +
""" + + +@app.post("/login") +def login(username: str = Form(...), password: str = Form(...)): + s = get_settings() + if not (hmac.compare_digest(username, s.gui_user) + and auth.verify_password(password, s.gui_password_hash)): + return HTMLResponse("Login fehlgeschlagen", status_code=401) + resp = RedirectResponse("/", status_code=303) + resp.set_cookie(auth.COOKIE, auth.make_session_token(), httponly=True, + max_age=auth.MAX_AGE, samesite="lax") + return resp + + +@app.post("/logout") +def logout(): + resp = RedirectResponse("/login", status_code=303) + resp.delete_cookie(auth.COOKIE) + return resp + + +@app.get("/api/accounts", dependencies=[Depends(auth.require_auth)]) +def _placeholder_accounts(): # wird in Task 9 durch Router ersetzt + return [] diff --git a/finance/tests/conftest.py b/finance/tests/conftest.py index 57d036b..020d044 100644 --- a/finance/tests/conftest.py +++ b/finance/tests/conftest.py @@ -1,9 +1,10 @@ import pytest +from fastapi.testclient import TestClient from sqlalchemy import create_engine from sqlalchemy.orm import Session from sqlalchemy.pool import StaticPool -from app.db import Base +from app.db import Base, get_session @pytest.fixture @@ -15,3 +16,19 @@ def db(): Base.metadata.create_all(engine) with Session(engine) as session: yield session + + +@pytest.fixture +def client(db, monkeypatch): + monkeypatch.setenv("FB_API_KEY", "test-key") + from app.config import get_settings + get_settings.cache_clear() + from app.auth import hash_password + monkeypatch.setenv("FB_GUI_PASSWORD_HASH", hash_password("geheim")) + get_settings.cache_clear() + from app.main import app + app.dependency_overrides[get_session] = lambda: iter([db]) + with TestClient(app) as c: + yield c + app.dependency_overrides.clear() + get_settings.cache_clear() diff --git a/finance/tests/test_auth.py b/finance/tests/test_auth.py new file mode 100644 index 0000000..eedfcd7 --- /dev/null +++ b/finance/tests/test_auth.py @@ -0,0 +1,14 @@ +def test_api_requires_key(client): + assert client.get("/api/accounts").status_code == 401 + r = client.get("/api/accounts", headers={"Authorization": "Bearer test-key"}) + assert r.status_code == 200 + + +def test_login_flow(client): + r = client.post("/login", data={"username": "admin", "password": "falsch"}, + follow_redirects=False) + assert r.status_code == 401 + r = client.post("/login", data={"username": "admin", "password": "geheim"}, + follow_redirects=False) + assert r.status_code == 303 + assert client.get("/api/accounts").status_code == 200 # Cookie reicht