diff --git a/Dockerfile b/Dockerfile index 66d1f8c..248a33f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index ccc19d4..382006e 100644 --- a/README.md +++ b/README.md @@ -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:///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`. diff --git a/app.py b/app.py index b6bfc6e..7bac0e1 100644 --- a/app.py +++ b/app.py @@ -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") diff --git a/index.html b/index.html index a65b39f..d8ba050 100644 --- a/index.html +++ b/index.html @@ -8,9 +8,23 @@

E-Mail Unsubscribe Link Finder

Email your emails to: {{ email_address }}

+ {% if error %}

{{ error }}

{% endif %} + {% if connected %}
+ {% if oauth %} +
+ +
+ {% endif %} + {% else %} +

The collection mailbox is not connected yet. Open this page in a private window and sign in + as {{ email_address }} (technical account) – not with your own account.

+
+ +
+ {% endif %}
diff --git a/oauth.py b/oauth.py new file mode 100644 index 0000000..670a192 --- /dev/null +++ b/oauth.py @@ -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"] diff --git a/requirements.txt b/requirements.txt index 68fc90a..8d32d79 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/tests/test_oauth.py b/tests/test_oauth.py new file mode 100644 index 0000000..d9c4298 --- /dev/null +++ b/tests/test_oauth.py @@ -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