feat: Sammelpostfach per SSO (OAuth/XOAUTH2) statt Passwort

Einmal "Connect mailbox" (OIDC mit PKCE, prompt=login, Anmeldung als technisches Konto des
Postfachs), Prüfung der Mail-Adresse im Token, Refresh-Token verschlüsselt in DATA_DIR,
Access-Token im Speicher; ein gunicorn-Worker wegen der Token-Rotation. Ohne OIDC_* weiter mit
EMAIL_PASSWORD.

Claude-Session: https://claude.ai/code/session_01SqzF6qT3TQ8ej3wigEuMSu
This commit is contained in:
Rsclub2_2 committed 2026-10-29 14:29:22 +01:00
1 parent b741389754
commit 68940e831a
7 files changed
+404 -6

No files matched your search

+4 -3
View File
@@ -5,7 +5,7 @@ WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py index.html result.html ./
COPY app.py oauth.py index.html result.html ./
COPY static ./static
RUN useradd -m -u 1000 app
@@ -15,5 +15,6 @@ EXPOSE 5000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:5000/health')"
# gunicorn instead of the Flask development server (no Werkzeug debugger)
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "2", "--timeout", "120", "app:app"]
# gunicorn instead of the Flask development server (no Werkzeug debugger); one worker, because the
# OAuth refresh token rotates and must not be refreshed by two processes at once
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "1", "--threads", "4", "--timeout", "120", "app:app"]
+4
View File
@@ -27,6 +27,10 @@ This project is licensed under the GPLv3 License - see the [LICENSE](LICENSE) fi
Konfiguration per Umgebung: `EMAIL_SERVER`, `EMAIL_PORT` (993), `EMAIL_USERNAME`, `EMAIL_PASSWORD`
(Sammelpostfach), optional `EMAIL_FOLDER` (`INBOX`) und `TRASH_FOLDER` (`Trash`).
Statt `EMAIL_PASSWORD` geht SSO (OAuth/XOAUTH2): `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`,
`OIDC_REDIRECT_URI` (`https://<host>/oidc/callback`), `TOKEN_KEY` (Fernet-Schlüssel) und `DATA_DIR` (`/data`).
Dann zeigt die Startseite „Connect mailbox“: einmal im privaten Fenster als technisches Konto des
Sammelpostfachs am SSO anmelden; das Refresh-Token liegt verschlüsselt in `DATA_DIR/oauth-token`.
Der Container startet gunicorn auf Port 5000 (kein Flask-Debugger) und gehört hinter einen
Reverse Proxy mit Anmeldung (bei Wagnersnetz Caddy `forward_auth` über Authelia).
Tests: `pytest tests`.
+55 -3
View File
@@ -15,7 +15,9 @@ from email.header import decode_header, make_header
from bs4 import BeautifulSoup
from dotenv import load_dotenv
from flask import Flask, redirect, render_template, request, url_for
from flask import Flask, abort, redirect, render_template, request, url_for
from oauth import MailboxOAuth, OAuthError, xoauth2_string
load_dotenv() # optional .env for local runs
@@ -32,6 +34,9 @@ logger = logging.getLogger(__name__)
template_dir = os.path.abspath(os.path.dirname(__file__))
app = Flask(__name__, template_folder=template_dir)
# OAuth (SSO) for the collection mailbox if OIDC_* and TOKEN_KEY are set; otherwise EMAIL_PASSWORD
OAUTH = MailboxOAuth.from_env()
UNSUBSCRIBE_PATTERN = re.compile(
r"\babbestellen\b|\bunsubscribe\b|\bdeabonnieren\b|\babbestellung\b|\babmelden\b", re.IGNORECASE
)
@@ -78,8 +83,12 @@ def find_unsubscribe_links(text: str) -> list[str]:
def connect() -> imaplib.IMAP4_SSL:
token = OAUTH.access_token() if OAUTH else None # before connecting: fail fast without a token
mail = imaplib.IMAP4_SSL(EMAIL_SERVER, EMAIL_PORT)
mail.login(EMAIL_USERNAME, EMAIL_PASSWORD)
if token:
mail.authenticate("XOAUTH2", lambda _: xoauth2_string(EMAIL_USERNAME, token).encode())
else:
mail.login(EMAIL_USERNAME, EMAIL_PASSWORD)
mail.select(EMAIL_FOLDER)
return mail
@@ -119,7 +128,50 @@ def move_emails_to_trash(uids: list[str]) -> int:
@app.route("/", methods=["GET"])
def index():
return render_template("index.html", email_address=EMAIL_USERNAME)
return render_template(
"index.html",
email_address=EMAIL_USERNAME,
oauth=OAUTH is not None,
connected=OAUTH.connected() if OAUTH else True,
error=request.args.get("error", ""),
)
@app.route("/connect")
def connect_mailbox():
"""Connect the collection mailbox: sign in at the SSO as its technical account (private window)."""
if not OAUTH:
abort(404)
return redirect(OAUTH.start())
@app.route("/oidc/callback")
def oidc_callback():
if not OAUTH:
abort(404)
if request.args.get("error"):
return redirect(url_for("index", error=request.args["error"]))
try:
OAUTH.finish(request.args.get("state", ""), request.args.get("code", ""))
except OAuthError as e:
logger.warning("Mailbox connect failed: %s", e)
return redirect(url_for("index", error=str(e)))
logger.info("Collection mailbox %s connected via OAuth", EMAIL_USERNAME)
return redirect(url_for("index"))
@app.route("/disconnect", methods=["POST"])
def disconnect_mailbox():
if not OAUTH:
abort(404)
OAUTH.disconnect()
return redirect(url_for("index"))
@app.errorhandler(OAuthError)
def oauth_error(e):
logger.warning("Mailbox access failed: %s", e)
return redirect(url_for("index", error="Mailbox not connected or token expired – connect again"))
@app.route("/result")
+14
View File
@@ -8,9 +8,23 @@
<div class="container">
<h1>E-Mail Unsubscribe Link Finder</h1>
<p>Email your emails to: {{ email_address }}</p>
{% if error %}<p class="error">{{ error }}</p>{% endif %}
{% if connected %}
<form action="/result" method="get">
<button type="submit" class="btn-primary">Start</button>
</form>
{% if oauth %}
<form action="/disconnect" method="post">
<button type="submit" class="btn-delete">Disconnect mailbox</button>
</form>
{% endif %}
{% else %}
<p>The collection mailbox is not connected yet. Open this page in a private window and sign in
as <strong>{{ email_address }}</strong> (technical account) – not with your own account.</p>
<form action="/connect" method="get">
<button type="submit" class="btn-primary">Connect mailbox</button>
</form>
{% endif %}
</div>
</body>
</html>
+184
View File
@@ -0,0 +1,184 @@
"""
OAuth access to the collection mailbox (XOAUTH2) instead of a stored password.
An admin connects the mailbox once: OIDC code flow with PKCE, signing in at the SSO as the technical
account of the mailbox. The refresh token is stored encrypted in DATA_DIR; access tokens stay in memory.
"""
import base64
import hashlib
import json
import os
import secrets
import threading
import time
from pathlib import Path
from urllib.parse import urlencode
import httpx
from cryptography.fernet import Fernet, InvalidToken
SCOPES = "openid profile email offline_access"
REFRESH_MARGIN_SEC = 60
PENDING_TTL_SEC = 600
class OAuthError(Exception):
pass
def _b64url(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
def new_pkce() -> tuple[str, str]:
verifier = _b64url(secrets.token_bytes(48))
return verifier, _b64url(hashlib.sha256(verifier.encode()).digest())
def jwt_claims(token: str) -> dict:
try:
payload = token.split(".")[1]
return json.loads(base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4)))
except (IndexError, ValueError) as e:
raise OAuthError("Access token is not a JWT") from e
def xoauth2_string(user: str, token: str) -> str:
return f"user={user}\x01auth=Bearer {token}\x01\x01"
class MailboxOAuth:
"""Token handling for exactly one mailbox."""
def __init__(self, issuer, client_id, client_secret, redirect_uri, mailbox, data_dir, key,
transport=None, clock=time.time):
self.client_id = client_id
self.client_secret = client_secret
self.redirect_uri = redirect_uri
self.mailbox = mailbox
self.authorization_endpoint = issuer.rstrip("/") + "/api/oidc/authorization"
self.token_endpoint = issuer.rstrip("/") + "/api/oidc/token"
self.token_file = Path(data_dir) / "oauth-token"
self.cipher = Fernet(key.encode() if isinstance(key, str) else key)
self.transport = transport
self.clock = clock
self._lock = threading.Lock()
self._access: tuple[str, float] | None = None
self._pending: dict[str, tuple[str, float]] = {}
@classmethod
def from_env(cls):
names = ["OIDC_ISSUER", "OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET", "OIDC_REDIRECT_URI", "TOKEN_KEY"]
if not all(os.environ.get(n) for n in names):
return None
return cls(
issuer=os.environ["OIDC_ISSUER"],
client_id=os.environ["OIDC_CLIENT_ID"],
client_secret=os.environ["OIDC_CLIENT_SECRET"],
redirect_uri=os.environ["OIDC_REDIRECT_URI"],
mailbox=os.environ.get("EMAIL_USERNAME", ""),
data_dir=os.environ.get("DATA_DIR", "/data"),
key=os.environ["TOKEN_KEY"],
)
# --- stored refresh token ---------------------------------------------------------------
def connected(self) -> bool:
return self._read_refresh() is not None
def _read_refresh(self) -> str | None:
try:
return self.cipher.decrypt(self.token_file.read_bytes()).decode()
except (FileNotFoundError, InvalidToken):
return None
def _write_refresh(self, token: str | None) -> None:
if token is None:
self.token_file.unlink(missing_ok=True)
return
tmp = self.token_file.with_suffix(".tmp")
tmp.write_bytes(self.cipher.encrypt(token.encode()))
tmp.chmod(0o600)
tmp.replace(self.token_file)
# --- connect flow -------------------------------------------------------------------------
def start(self) -> str:
verifier, challenge = new_pkce()
state = secrets.token_urlsafe(32)
now = self.clock()
with self._lock:
self._pending = {k: v for k, v in self._pending.items() if now - v[1] < PENDING_TTL_SEC}
self._pending[state] = (verifier, now)
query = {
"response_type": "code",
"client_id": self.client_id,
"redirect_uri": self.redirect_uri,
"scope": SCOPES,
"state": state,
"code_challenge": challenge,
"code_challenge_method": "S256",
"prompt": "login", # sign in as the mailbox account, not with the admin's session
"login_hint": self.mailbox,
}
return self.authorization_endpoint + "?" + urlencode(query)
def finish(self, state: str, code: str) -> None:
with self._lock:
entry = self._pending.pop(state, None)
if not entry or self.clock() - entry[1] > PENDING_TTL_SEC or not code:
raise OAuthError("Invalid or expired request")
tokens = self._token_request({
"grant_type": "authorization_code",
"code": code,
"redirect_uri": self.redirect_uri,
"code_verifier": entry[0],
})
email = jwt_claims(tokens["access_token"]).get("email", "")
if email.lower() != self.mailbox.lower():
raise OAuthError(f"Signed in as {email or 'unknown'}, expected {self.mailbox}")
if not tokens.get("refresh_token"):
raise OAuthError("SSO returned no refresh token")
with self._lock:
self._store(tokens)
def disconnect(self) -> None:
with self._lock:
self._write_refresh(None)
self._access = None
# --- tokens -------------------------------------------------------------------------------
def _token_request(self, data: dict) -> dict:
try:
with httpx.Client(timeout=20.0, transport=self.transport) as client:
r = client.post(self.token_endpoint, data=data, auth=(self.client_id, self.client_secret))
except httpx.HTTPError as e:
raise OAuthError(f"Token endpoint unreachable: {e}") from e
try:
body = r.json()
except ValueError:
body = {}
if r.status_code != 200 or not body.get("access_token"):
raise OAuthError(f"Token request failed ({r.status_code}): {body.get('error', 'unknown')}")
return body
def _store(self, tokens: dict) -> None:
if tokens.get("refresh_token"):
self._write_refresh(tokens["refresh_token"])
self._access = (tokens["access_token"], self.clock() + float(tokens.get("expires_in") or 300))
def access_token(self) -> str:
with self._lock:
if self._access and self._access[1] - REFRESH_MARGIN_SEC > self.clock():
return self._access[0]
refresh = self._read_refresh()
if not refresh:
raise OAuthError("Mailbox not connected")
try:
tokens = self._token_request({"grant_type": "refresh_token", "refresh_token": refresh})
except OAuthError as e:
if "invalid_grant" in str(e):
self._write_refresh(None) # revoked/expired: connect again
self._access = None
raise
self._store(tokens)
return tokens["access_token"]
+2
View File
@@ -2,3 +2,5 @@ Flask==3.1.2
python-dotenv==1.2.1
beautifulsoup4==4.14.2
gunicorn==23.0.0
httpx==0.28.1
cryptography==46.0.3
+141
View File
@@ -0,0 +1,141 @@
"""Tests for the OAuth connection of the collection mailbox."""
import base64
import json
from urllib.parse import parse_qs, urlparse
import httpx
import pytest
from cryptography.fernet import Fernet
import oauth
def _jwt(claims):
enc = lambda d: base64.urlsafe_b64encode(json.dumps(d).encode()).rstrip(b"=").decode() # noqa: E731
return f"{enc({'alg': 'RS256'})}.{enc(claims)}.sig"
class Clock:
now = 1000.0
def __call__(self):
return self.now
def _mb(tmp_path, responses, seen=None, clock=None):
def handler(request):
if seen is not None:
seen.append(parse_qs(request.content.decode()))
return responses.pop(0)
return oauth.MailboxOAuth(
"https://sso.example.org", "unsubscriber", "secret", "https://u.example.org/oidc/callback",
"unsubscribe-bot@example.org", tmp_path, Fernet.generate_key().decode(),
transport=httpx.MockTransport(handler), clock=clock or Clock(),
)
def _start_state(mb):
url = urlparse(mb.start())
q = parse_qs(url.query)
assert q["prompt"] == ["login"] and q["login_hint"] == ["unsubscribe-bot@example.org"]
assert "offline_access" in q["scope"][0] and q["code_challenge_method"] == ["S256"]
return q["state"][0]
def test_connect_stores_encrypted_refresh_token(tmp_path):
mb = _mb(tmp_path, [httpx.Response(200, json={
"access_token": _jwt({"email": "Unsubscribe-Bot@example.org"}), "refresh_token": "rt-1", "expires_in": 3600})])
mb.finish(_start_state(mb), "code")
assert mb.connected()
assert b"rt-1" not in (tmp_path / "oauth-token").read_bytes()
assert oct((tmp_path / "oauth-token").stat().st_mode)[-3:] == "600"
def test_connect_with_wrong_account_is_rejected(tmp_path):
mb = _mb(tmp_path, [httpx.Response(200, json={
"access_token": _jwt({"email": "philipp@example.org"}), "refresh_token": "rt", "expires_in": 3600})])
with pytest.raises(oauth.OAuthError, match="expected unsubscribe-bot"):
mb.finish(_start_state(mb), "code")
assert not mb.connected()
def test_unknown_state_is_rejected(tmp_path):
with pytest.raises(oauth.OAuthError):
_mb(tmp_path, []).finish("forged", "code")
def test_access_token_refreshes_and_rotates(tmp_path):
seen, clock = [], Clock()
mb = _mb(tmp_path, [
httpx.Response(200, json={"access_token": _jwt({"email": "unsubscribe-bot@example.org"}),
"refresh_token": "rt-1", "expires_in": 3600}),
httpx.Response(200, json={"access_token": "at-2", "refresh_token": "rt-2", "expires_in": 3600}),
], seen, clock)
mb.finish(_start_state(mb), "code")
first = mb.access_token()
assert len(seen) == 1 # cached from the code exchange
clock.now += 3600
assert mb.access_token() == "at-2" != first
assert seen[1]["refresh_token"] == ["rt-1"]
assert mb._read_refresh() == "rt-2"
def test_revoked_token_disconnects(tmp_path):
clock = Clock()
mb = _mb(tmp_path, [
httpx.Response(200, json={"access_token": _jwt({"email": "unsubscribe-bot@example.org"}),
"refresh_token": "rt-1", "expires_in": 10}),
httpx.Response(400, json={"error": "invalid_grant"}),
], clock=clock)
mb.finish(_start_state(mb), "code")
clock.now += 100
with pytest.raises(oauth.OAuthError):
mb.access_token()
assert not mb.connected()
def test_not_connected(tmp_path):
with pytest.raises(oauth.OAuthError):
_mb(tmp_path, []).access_token()
def test_app_uses_xoauth2_when_oauth_configured(monkeypatch):
import app as unsub
calls = {}
class FakeIMAP:
def __init__(self, host, port):
pass
def authenticate(self, mech, cb):
calls["auth"] = (mech, cb(b""))
def login(self, *a):
calls["login"] = a
def select(self, folder):
pass
class FakeOAuth:
def access_token(self):
return "tok"
monkeypatch.setattr(unsub.imaplib, "IMAP4_SSL", FakeIMAP)
monkeypatch.setattr(unsub, "OAUTH", FakeOAuth())
monkeypatch.setattr(unsub, "EMAIL_USERNAME", "unsubscribe-bot@example.org")
unsub.connect()
assert calls == {"auth": ("XOAUTH2", oauth.xoauth2_string("unsubscribe-bot@example.org", "tok").encode())}
def test_index_shows_connect_when_not_connected(monkeypatch):
import app as unsub
class FakeOAuth:
def connected(self):
return False
monkeypatch.setattr(unsub, "OAUTH", FakeOAuth())
html = unsub.app.test_client().get("/").get_data(as_text=True)
assert 'action="/connect"' in html and 'action="/result"' not in html