diff --git a/.env.example b/.env.example index 8f79895..2ed9561 100644 --- a/.env.example +++ b/.env.example @@ -36,6 +36,10 @@ SMTP_PORT=587 SMTP_USER= SMTP_PASSWORD= SMTP_USE_TLS=1 +M365_TENANT_ID= +M365_CLIENT_ID= +M365_CLIENT_SECRET= +M365_MAILBOX_USER= # Roteerbare Fernet-sleutels voor app-wachtwoorden; actieve sleutel eerst. # Genereer met: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" diff --git a/.env.unraid.example b/.env.unraid.example index 892f509..c5c76e3 100644 --- a/.env.unraid.example +++ b/.env.unraid.example @@ -74,6 +74,10 @@ SMTP_PORT=587 SMTP_USER= SMTP_PASSWORD= SMTP_USE_TLS=1 +M365_TENANT_ID= +M365_CLIENT_ID= +M365_CLIENT_SECRET= +M365_MAILBOX_USER= # Genereer met: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" MAILBOX_CREDENTIAL_KEYS= diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..e6d7b7f --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,47 @@ +name: release + +on: + push: + tags: + - "v*.*.*" + +jobs: + verify-publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Verify release + run: ./scripts/codex_verify.sh + - name: Build immutable image + env: + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + REGISTRY_USER: ${{ secrets.REGISTRY_USER }} + run: | + set -euo pipefail + version="${GITHUB_REF_NAME#v}" + registry="gitea.itworx.tech" + image="${registry}/jens/vacatureradar:${version}" + printf '%s' "$REGISTRY_TOKEN" | docker login "$registry" --username "$REGISTRY_USER" --password-stdin + docker build --file Dockerfile.unraid \ + --build-arg "VCS_REF=${GITHUB_SHA}" \ + --build-arg "APP_VERSION=${version}" \ + --tag "$image" . + docker push "$image" + docker image inspect "$image" --format '{{index .RepoDigests 0}}' > image-digest.txt + docker sbom "$image" --format spdx-json > sbom.spdx.json + cat > RELEASE_NOTES.md < SHA256SUMS + - name: Publish release evidence + uses: actions/upload-artifact@v4 + with: + name: release-evidence-${{ github.ref_name }} + path: | + image-digest.txt + sbom.spdx.json + SHA256SUMS + RELEASE_NOTES.md diff --git a/Dockerfile.unraid b/Dockerfile.unraid index 55b1c38..a5d59c1 100644 --- a/Dockerfile.unraid +++ b/Dockerfile.unraid @@ -1,6 +1,12 @@ # syntax=docker/dockerfile:1.7 FROM python:3.13-slim-trixie +ARG VCS_REF=unknown +ARG APP_VERSION=dev +LABEL org.opencontainers.image.source="https://gitea.itworx.tech/Jens/VacatureRadar" \ + org.opencontainers.image.revision=$VCS_REF \ + org.opencontainers.image.version=$APP_VERSION + ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ UV_COMPILE_BYTECODE=1 \ diff --git a/apps/core/services/microsoft365.py b/apps/core/services/microsoft365.py new file mode 100644 index 0000000..9d6c4f3 --- /dev/null +++ b/apps/core/services/microsoft365.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import base64 + +from django.conf import settings + + +class Microsoft365OAuthError(RuntimeError): + """Safe OAuth boundary error that never includes tokens or client secrets.""" + + +def acquire_exchange_access_token(*, client_factory=None) -> str: + tenant_id = settings.M365_TENANT_ID.strip() + client_id = settings.M365_CLIENT_ID.strip() + client_secret = settings.M365_CLIENT_SECRET.strip() + if not all((tenant_id, client_id, client_secret)): + raise Microsoft365OAuthError("Microsoft 365 OAuth is niet volledig geconfigureerd.") + + if client_factory is None: + import msal + + client_factory = msal.ConfidentialClientApplication + factory = client_factory + client = factory( + client_id, + authority=f"https://login.microsoftonline.com/{tenant_id}", + client_credential=client_secret, + ) + result = client.acquire_token_for_client(scopes=[settings.M365_EXCHANGE_SCOPE]) + token = result.get("access_token") if isinstance(result, dict) else None + if not isinstance(token, str) or not token: + category = "unknown" + if isinstance(result, dict): + category = str(result.get("error") or "unknown")[:80] + raise Microsoft365OAuthError( + f"Microsoft 365 OAuth-token kon niet worden verkregen ({category})." + ) + return token + + +def xoauth2_bytes(username: str, access_token: str) -> bytes: + if not username or "\x00" in username or "\r" in username or "\n" in username: + raise Microsoft365OAuthError("Ongeldig Microsoft 365-mailboxadres.") + return f"user={username}\x01auth=Bearer {access_token}\x01\x01".encode() + + +def xoauth2_b64(username: str, access_token: str) -> str: + return base64.b64encode(xoauth2_bytes(username, access_token)).decode("ascii") diff --git a/apps/notifications/backends.py b/apps/notifications/backends.py new file mode 100644 index 0000000..b11dd8c --- /dev/null +++ b/apps/notifications/backends.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import smtplib +from email.utils import parseaddr + +from django.conf import settings +from django.core.mail.backends.base import BaseEmailBackend + +from apps.core.services.microsoft365 import ( + Microsoft365OAuthError, + acquire_exchange_access_token, + xoauth2_b64, +) +from apps.sources.services.tls import trusted_tls_context + + +class Microsoft365OAuthEmailBackend(BaseEmailBackend): + """Send mail through Exchange Online with app-only SASL XOAUTH2.""" + + def send_messages(self, email_messages) -> int: + if not email_messages: + return 0 + sent = 0 + try: + token = acquire_exchange_access_token() + username = settings.M365_MAILBOX_USER.strip() + if not username: + raise Microsoft365OAuthError( + "M365_MAILBOX_USER is verplicht voor Microsoft 365-mailuitvoer." + ) + with smtplib.SMTP( + settings.EMAIL_HOST or "smtp.office365.com", + settings.EMAIL_PORT, + timeout=settings.M365_SMTP_TIMEOUT_SECONDS, + ) as client: + client.ehlo() + client.starttls(context=trusted_tls_context()) + client.ehlo() + code, _ = client.docmd("AUTH", f"XOAUTH2 {xoauth2_b64(username, token)}") + if code != 235: + raise Microsoft365OAuthError("Microsoft 365 SMTP OAuth geweigerd.") + for message in email_messages: + recipients = message.recipients() + if not recipients: + continue + sender = parseaddr(message.from_email or settings.DEFAULT_FROM_EMAIL)[1] + client.sendmail( + sender or username, + recipients, + message.message().as_bytes(linesep="\r\n"), + ) + sent += 1 + except Exception: + if self.fail_silently: + return sent + raise + return sent diff --git a/apps/sources/forms.py b/apps/sources/forms.py index 5cc00f1..bc31f5d 100644 --- a/apps/sources/forms.py +++ b/apps/sources/forms.py @@ -9,7 +9,10 @@ class MailboxConnectionForm(forms.ModelForm): password = forms.CharField( required=False, label="App-wachtwoord", - help_text="Laat leeg bij wijzigen om het bestaande app-wachtwoord te behouden.", + help_text=( + "Alleen voor Gmail of een aangepaste provider. Microsoft 365 gebruikt " + "de centrale OAuth-configuratie." + ), widget=forms.PasswordInput( attrs={"autocomplete": "new-password", "placeholder": "App-wachtwoord"}, render_value=False, @@ -50,7 +53,8 @@ class MailboxConnectionForm(forms.ModelForm): data = super().clean() if data.get("provider") != MailboxConnection.Provider.CUSTOM: data["custom_host"] = "" - if not self.instance.pk and not data.get("password"): + uses_oauth = data.get("provider") == MailboxConnection.Provider.OUTLOOK + if not self.instance.pk and not uses_oauth and not data.get("password"): self.add_error("password", "Een app-wachtwoord is verplicht voor een nieuwe koppeling.") return data diff --git a/apps/sources/migrations/0008_alter_mailboxconnection_encrypted_password.py b/apps/sources/migrations/0008_alter_mailboxconnection_encrypted_password.py new file mode 100644 index 0000000..ccb84ab --- /dev/null +++ b/apps/sources/migrations/0008_alter_mailboxconnection_encrypted_password.py @@ -0,0 +1,13 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [("sources", "0007_alter_mailboxconnection_platform")] + + operations = [ + migrations.AlterField( + model_name="mailboxconnection", + name="encrypted_password", + field=models.TextField(blank=True), + ) + ] diff --git a/apps/sources/models.py b/apps/sources/models.py index dd69fe3..807f958 100644 --- a/apps/sources/models.py +++ b/apps/sources/models.py @@ -357,7 +357,7 @@ class MailboxConnection(TimeStampedModel): custom_host = models.CharField(max_length=255, blank=True) port = models.PositiveIntegerField(default=993) username = models.CharField(max_length=320) - encrypted_password = models.TextField() + encrypted_password = models.TextField(blank=True) mailbox = models.CharField(max_length=255, default="INBOX") enabled = models.BooleanField(default=True) poll_interval_minutes = models.PositiveIntegerField( diff --git a/apps/sources/services/fetcher.py b/apps/sources/services/fetcher.py index 610debc..65f5320 100644 --- a/apps/sources/services/fetcher.py +++ b/apps/sources/services/fetcher.py @@ -74,14 +74,19 @@ def _validate_connected_peer( *, before: ValidatedUrl, after: ValidatedUrl, + require_peer: bool, ) -> None: peer_ip = _connected_peer_ip(response) if peer_ip is not None: if not peer_ip.is_global: raise FetchError(f"Niet-publiek verbonden IP-adres geblokkeerd: {peer_ip}") return + if require_peer: + raise FetchError( + "Verbonden IP-adres kon niet veilig worden vastgesteld; aanvraag geblokkeerd." + ) # Mock/custom transports do not always expose the peer socket. Keep the conservative - # DNS-overlap fallback for those transports. + # DNS-overlap fallback only for explicitly injected test/custom clients. if not set(before.addresses).intersection(after.addresses): raise FetchError("DNS-rebindcontrole faalde bij het benaderen van bron.") @@ -175,6 +180,7 @@ def fetch_url( response, before=validation, after=post_validation, + require_peer=own_client, ) if response.status_code in {301, 302, 303, 307, 308}: location = response.headers.get("location") diff --git a/apps/sources/services/imap_import.py b/apps/sources/services/imap_import.py index 08ea214..96b634c 100644 --- a/apps/sources/services/imap_import.py +++ b/apps/sources/services/imap_import.py @@ -7,6 +7,7 @@ from typing import Any from django.conf import settings +from apps.core.services.microsoft365 import acquire_exchange_access_token, xoauth2_bytes from apps.sources.models import EmailMessageRecord, MailboxConnection from apps.sources.services.email_import import ingest_email, message_identity from apps.sources.services.mailbox_connections import decrypt_mailbox_password @@ -30,7 +31,11 @@ def poll_imap_mailbox( if client_factory is None: client_factory = imaplib.IMAP4_SSL host_validator(f"https://{connection.imap_host}") - password = decrypt_mailbox_password(connection) + password = ( + "" + if connection.provider == MailboxConnection.Provider.OUTLOOK + else decrypt_mailbox_password(connection) + ) client_options = {"timeout": settings.IMAP_CONNECT_TIMEOUT_SECONDS} if client_factory is imaplib.IMAP4_SSL: client_options["ssl_context"] = trusted_tls_context() @@ -42,7 +47,13 @@ def poll_imap_mailbox( "failed": 0, } try: - client.login(connection.username, password) + if connection.provider == MailboxConnection.Provider.OUTLOOK: + access_token = acquire_exchange_access_token() + client.authenticate( + "XOAUTH2", lambda _challenge: xoauth2_bytes(connection.username, access_token) + ) + else: + client.login(connection.username, password) status, _ = client.select(connection.mailbox, readonly=True) if status != "OK": raise RuntimeError("IMAP-mailbox kon niet worden geopend") diff --git a/apps/sources/services/mailbox_connections.py b/apps/sources/services/mailbox_connections.py index 49a22a0..5e51fa7 100644 --- a/apps/sources/services/mailbox_connections.py +++ b/apps/sources/services/mailbox_connections.py @@ -48,7 +48,9 @@ def decrypt_mailbox_password(connection: MailboxConnection) -> str: def rotate_mailbox_credentials() -> int: """Re-encrypt every mailbox password with the first configured key.""" rotated = 0 - for connection in MailboxConnection.objects.select_for_update().all(): + for connection in MailboxConnection.objects.select_for_update().exclude( + provider=MailboxConnection.Provider.OUTLOOK + ): password = decrypt_mailbox_password(connection) connection.encrypted_password = encrypt_mailbox_password(password) connection.save(update_fields=["encrypted_password", "updated_at"]) @@ -79,7 +81,10 @@ def save_mailbox_connection( "poll_interval_minutes", ): setattr(connection, field, cleaned_data[field]) - if password: + uses_oauth = connection.provider == MailboxConnection.Provider.OUTLOOK + if uses_oauth: + connection.encrypted_password = "" + elif password: connection.encrypted_password = encrypt_mailbox_password(password) elif not connection.encrypted_password: raise MailboxCredentialError("Een app-wachtwoord is verplicht.") diff --git a/config/settings.py b/config/settings.py index 425a010..10d596f 100644 --- a/config/settings.py +++ b/config/settings.py @@ -298,6 +298,12 @@ EMAIL_PORT = int(os.getenv("SMTP_PORT", "587")) EMAIL_HOST_USER = os.getenv("SMTP_USER", "") EMAIL_HOST_PASSWORD = os.getenv("SMTP_PASSWORD", "") EMAIL_USE_TLS = env_bool("SMTP_USE_TLS", True) +M365_TENANT_ID = os.getenv("M365_TENANT_ID", "") +M365_CLIENT_ID = os.getenv("M365_CLIENT_ID", "") +M365_CLIENT_SECRET = os.getenv("M365_CLIENT_SECRET", "") +M365_MAILBOX_USER = os.getenv("M365_MAILBOX_USER", EMAIL_HOST_USER) +M365_EXCHANGE_SCOPE = "https://outlook.office365.com/.default" +M365_SMTP_TIMEOUT_SECONDS = env_int_range("M365_SMTP_TIMEOUT_SECONDS", 20, minimum=3, maximum=120) SESSION_COOKIE_HTTPONLY = True SESSION_COOKIE_SAMESITE = "Lax" diff --git a/docker-compose.unraid.yml b/docker-compose.unraid.yml index 84bfbcc..14e73ec 100644 --- a/docker-compose.unraid.yml +++ b/docker-compose.unraid.yml @@ -12,7 +12,7 @@ services: build: context: . dockerfile: Dockerfile.unraid - image: vacatureradar:unraid + image: ${VACATURERADAR_IMAGE:-vacatureradar:unraid} container_name: VacatureRadar env_file: - ${VACATURERADAR_ENV_FILE:-.env} diff --git a/docs/ai/BACKLOG.yaml b/docs/ai/BACKLOG.yaml index 086d9d9..f5c9117 100644 --- a/docs/ai/BACKLOG.yaml +++ b/docs/ai/BACKLOG.yaml @@ -1356,22 +1356,25 @@ tasks: summary: Configureer de gewenste afzonderlijke platformmailboxen via de beveiligde UI, de Fernet-secretkey en digestrecipient; test read-only import en mailverzending zonder credentials in Git/logs. acceptance_criteria: - - Iedere platformmailbox gebruikt TLS, een dedicated account/app-wachtwoord en read-only mailboxselectie. + - Iedere platformmailbox gebruikt TLS, een dedicated alias en read-only mailboxselectie; Microsoft 365 gebruikt + app-only OAuth/XOAUTH2 en geen Basic Auth of app-wachtwoord. - Dezelfde live testmail importeert per mailbox idempotent en alle geconfigureerde intervallen worden door de scheduler gerespecteerd. - SMTP verstuurt één testdigest en outboxstatus klopt. - - De encryptiesleutel staat alleen in de productie-secretstore/.env; app-wachtwoorden zijn als ciphertext in de - database aanwezig en sleutelrotatie is live getest. + - OAuth-clientcredentials en de encryptiesleutel staan alleen in de productie-secretstore/.env; eventuele + Gmail/custom-app-wachtwoorden zijn als ciphertext opgeslagen en sleutelrotatie is live getest. verification: - Volg Unraid mailboxchecklist - Inspecteer logs op afwezigheid van secret/payload primary_paths: - /mnt/user/appdata/vacatureradar/config/.env - Bronnen -> Platformmailboxen - external_blocker: Vereist mailbox-/SMTP-account en app-password van de gebruiker. + external_blocker: De dedicated productie-alias, Entra-appregistratie, admin consent en Exchange-mailboxscope zijn + op 2026-07-30 live ingericht; resterend zijn de productie-uitrol en echte platformalertmails voor de + import-/digestproef. - id: VR-203 title: Productiedomein, TLS en reverse proxy activeren - status: blocked-external + status: done priority: P0 requirement_ids: - NFR-001 @@ -1393,7 +1396,11 @@ tasks: primary_paths: - Unraid reverse proxy - /mnt/user/appdata/vacatureradar/config/.env - external_blocker: Vereist gekozen domein/DNS en toegang tot de Unraid reverse proxy. + result: + completed_at: '2026-07-29' + note: Publiek domein, HTTP-naar-HTTPS, TLS/securityheaders, DEBUG=False, secure cookies, CSRF-gastlogin, kernroutes + en readiness live bewezen in docs/audit/FINAL_ACCEPTANCE.md op 2026-07-29; interne diensten zijn uitsluitend + containerintern bereikbaar. - id: VR-204 title: Immutable containerimage publiceren status: blocked-external diff --git a/docs/ai/PROJECT_STATE.md b/docs/ai/PROJECT_STATE.md index b830acc..ac9a9f8 100644 --- a/docs/ai/PROJECT_STATE.md +++ b/docs/ai/PROJECT_STATE.md @@ -30,7 +30,37 @@ Productieoplevering 0.3.14 op 2026-07-29: - de pre-releasecustomdump, media en composeconfig staan checksum-geldig onder `/mnt/user/appdata/VacatureRadar/backups/release-0.3.14-20260729T164500Z`; rollbackimage `vacatureradar:rollback-0.3.13` wijst naar `sha256:34aef8853f77a3f311b119bd67d9f1160a97fd4a21e62c9776222be289bf3b88`; - productie is healthy zonder restarts, versie 0.3.14 is publiek zichtbaar, alle gastkernroutes antwoorden 200 en een echte importsmoke creëerde één job binnen een transactie waarna rollback expliciet bevestigd werd. -Volgende uitvoerbare taak: geen na afronding van VR-228. Alleen de bestaande optionele externe identity- en mailboxacties blijven open. +Volgende uitvoerbare taak: geen na afronding van VR-228. Alleen de optionele +mailbox-/identityconfiguratie (`VR-202`) en publicatie naar een externe private +registry (`VR-204`) blijven extern geblokkeerd. + +## Laatste risicohardening + +Live mailboxvoorbereiding op 2026-07-30: + +- Exchange Online bevestigde dat de dedicated VacatureRadar-alias aan de productie-mailbox is toegevoegd; +- de alias is niet primair gemaakt en bestaande adressen zijn ongewijzigd gebleven; +- de Entra-appregistratie, admin consent en Exchange-mailboxscope zijn live ingericht + met app-only OAuth; tijdelijke bootstraprechten en de bootstrap-app zijn na afloop verwijderd; +- `VR-202` resteert alleen nog op de productie-uitrol en echte platformalert-/digestproeven; + Basic Auth wordt niet geactiveerd; +- de volledige lokale gate is groen met 324 tests, 0 failures, 0 skips en 84,05% + branch-aware dekking. + +Risicohardening op 2026-07-29: + +- de productiefetcher blokkeert nu fail-closed wanneer de HTTP-transportlaag het + werkelijk verbonden peer-IP niet beschikbaar stelt; ieder niet-publiek peer-IP + bleef al geblokkeerd; +- alleen expliciet geïnjecteerde test/custom clients mogen nog de begrensde + DNS-overlapfallback gebruiken; een negatieve regressietest dekt de + productiegrens; +- `VR-203` is administratief afgerond op basis van het bestaande live bewijs in + `docs/audit/FINAL_ACCEPTANCE.md`: publieke HTTP→HTTPS, TLS/securityheaders, + veilige cookies, CSRF-gastlogin, kernroutes en readiness zijn aantoonbaar + geslaagd; +- verificatie: Ruff groen, 8 gerichte fetchertests geslaagd en de volledige gate + groen met 319 tests, 0 failures, 0 skips en 84,21% branch-aware dekking. Productieoplevering 0.3.13 op 2026-07-29: @@ -554,13 +584,18 @@ Deze blokkeren onafhankelijke code niet: - verdere persoonlijke werkgevers-/bronnenkeuze en actuele menselijke review buiten de actieve VITO-/Deliverect-startercatalogus; - live IMAP/SMTP-account en app-password; -- Unraid-domein, DNS, TLS en reverse proxy; - containerregistry en credentials; - actieve/voor deze repository geregistreerde Gitea Actions-runner; ## Bekende verificatiebeperking van de aangeleverde basis -De Pythonapplicatie, Dockerimage en PostgreSQL/Redis/Unraid-stack zijn geverifieerd. De optionele pytest-Playwrightvarianten vallen lokaal terug op de HTML/a11y-probe; VR-119 is aanvullend met de geïntegreerde browser op alle kernroutes uitgevoerd, VR-120 op het begeleide profiel en de live vacaturefeed en VR-124 op de acht platformmailboxen en alertkaarten. Een publieke HTTPS-smoke blijft afhankelijk van domein-, DNS- en reverse-proxyconfiguratie. +De Pythonapplicatie, Dockerimage en PostgreSQL/Redis/Unraid-stack zijn +geverifieerd. De verplichte Playwright-gate draait lokaal met Chromium; VR-119 is +aanvullend met de geïntegreerde browser op alle kernroutes uitgevoerd, VR-120 op +het begeleide profiel en de live vacaturefeed en VR-124 op de acht +platformmailboxen en alertkaarten. Publieke HTTPS, proxyheaders en de gastflow +zijn op 2026-07-29 live geaccepteerd en vastgelegd in +`docs/audit/FINAL_ACCEPTANCE.md`. ## Hervatten diff --git a/docs/operations/RUNBOOK.md b/docs/operations/RUNBOOK.md index fecef72..e96c138 100644 --- a/docs/operations/RUNBOOK.md +++ b/docs/operations/RUNBOOK.md @@ -77,6 +77,11 @@ celery -A config inspect reserved ## Digest ontbreekt of dubbel - Controleer actief profiel, digesttijd/zone en `DIGEST_RECIPIENT`. +- Microsoft 365 gebruikt uitsluitend app-only OAuth. Configureer `M365_TENANT_ID`, + `M365_CLIENT_ID`, `M365_CLIENT_SECRET`, `M365_MAILBOX_USER` en + `EMAIL_BACKEND=apps.notifications.backends.Microsoft365OAuthEmailBackend`. + Verleen alleen `IMAP.AccessAsApp` en `SMTP.SendAsApp`, admin consent en mailboxscope; + Basic Auth en Microsoft 365-app-wachtwoorden worden niet ondersteund. - Controleer `DigestOutbox` op datum en status. - Bij SMTP-fout: corrigeer configuratie en retry dezelfde outbox gecontroleerd. - Maak geen tweede outboxrecord handmatig; uniciteit per profiel/datum is de duplicaatbarrière. diff --git a/docs/operations/UNRAID_DEPLOYMENT.md b/docs/operations/UNRAID_DEPLOYMENT.md index 87d3dac..8c80983 100644 --- a/docs/operations/UNRAID_DEPLOYMENT.md +++ b/docs/operations/UNRAID_DEPLOYMENT.md @@ -164,7 +164,9 @@ IMAP_MAX_MESSAGES_PER_POLL=200 IMAP_MAX_MESSAGE_BYTES=1000000 ``` -Herstart web, worker en scheduler. Open daarna **Bronnen → Platformmailboxen** en koppel ieder gewenst platform afzonderlijk: VDAB, Indeed, LinkedIn, ictjob.be, Jobat, StepStone, Careerjet, Randstad of Robert Half. Kies Gmail, Outlook / Microsoft 365 of een aangepaste publieke IMAP-host, gebruik een app-wachtwoord en laat `INBOX` geselecteerd tenzij de alert naar een aparte map wordt gefilterd. De scheduler controleert iedere vijf minuten welke mailbox volgens haar eigen interval verschuldigd is. +Herstart web, worker en scheduler. Open daarna **Bronnen → Platformmailboxen** en koppel ieder gewenst platform afzonderlijk: VDAB, Indeed, LinkedIn, ictjob.be, Jobat, StepStone, Careerjet, Randstad of Robert Half. Gmail en aangepaste publieke IMAP-hosts gebruiken een app-wachtwoord. Outlook / Microsoft 365 gebruikt centraal app-only OAuth via de vier `M365_*`-waarden en `EMAIL_BACKEND=apps.notifications.backends.Microsoft365OAuthEmailBackend`; geef de Entra-app alleen `IMAP.AccessAsApp` en `SMTP.SendAsApp` en beperk de Exchange-serviceprincipal tot de bedoelde mailbox. Laat `INBOX` geselecteerd tenzij de alert naar een aparte map wordt gefilterd. De scheduler controleert iedere vijf minuten welke mailbox volgens haar eigen interval verschuldigd is. + +Voor een immutable registryrelease zet je `VACATURERADAR_IMAGE=gitea.itworx.tech/jens/vacatureradar:@sha256:` en start je met `docker compose -f docker-compose.unraid.yml up -d --no-build`. Voor sleutelrotatie zet je `nieuwe-sleutel,oude-sleutel` in `MAILBOX_CREDENTIAL_KEYS`, herstart je de processen en draai je: diff --git a/docs/quality/THREAT_MODEL.md b/docs/quality/THREAT_MODEL.md index 142c8cc..47a8feb 100644 --- a/docs/quality/THREAT_MODEL.md +++ b/docs/quality/THREAT_MODEL.md @@ -115,7 +115,10 @@ Een wijziging aan netwerk, mail, AI, rendering, auth, exports of uploads vereist ## Resterende risico's -- De fetcher controleert het werkelijk verbonden peer-IP wanneer httpcore dat exposeert. Een custom transport dat geen peerinformatie aanbiedt valt terug op DNS-set-overlap; volledige IP-pinning blijft een aanvullend egress-hardeningniveau. +- De productiefetcher blokkeert fail-closed wanneer httpcore het werkelijk verbonden + peer-IP niet exposeert en weigert ieder niet-publiek peer-IP. Alleen een expliciet + geïnjecteerde test/custom client mag terugvallen op DNS-set-overlap; die clientgrens + mag daarom niet voor productie-fetches worden gebruikt. - Brute-force of herhaalde foutieve mutatiepogingen blijven mogelijk, maar zijn in productie begrensd via gedeelde Redis-rate-limiting en blokkades op login/manual-import. Correcte individuele client-IPherkenning vereist het exacte proxy-CIDR. - Bronvoorwaarden vereisen menselijke/externe review per domein; automatisering kan dat niet juridisch beslissen. - Fuzzy dedupe en featureheuristieken kunnen inhoudelijk verkeerd zijn; provenance, feedback en benchmarkevaluaties beperken maar elimineren dit niet. @@ -150,3 +153,8 @@ Een wijziging aan netwerk, mail, AI, rendering, auth, exports of uploads vereist - Impact: herhaalde herstarts van dezelfde bron veroorzaken extra belasting - Mitigatie: cooldown plus metadataflag voor eenmalige canary, en trialstatus met beperkte runtrigger - Verificatie: `tests/integration/test_source_health.py` +# Microsoft 365 OAuth + +- Exchange Online gebruikt app-only XOAUTH2; Basic Auth en app-wachtwoorden zijn uitgesloten. +- Client secrets leven alleen in de productie-secretstore en tokenfouten bevatten geen token of secret. +- De serviceprincipal krijgt uitsluitend IMAP/SMTP-toegang tot de bedoelde mailbox; IMAP-selectie blijft read-only. diff --git a/tests/integration/test_mailbox_connections.py b/tests/integration/test_mailbox_connections.py index 86021a1..96e3b45 100644 --- a/tests/integration/test_mailbox_connections.py +++ b/tests/integration/test_mailbox_connections.py @@ -61,6 +61,7 @@ def test_user_can_link_edit_pause_and_sync_separate_platform_mailboxes(client, u ) assert response.status_code == 302 indeed = MailboxConnection.objects.get(user=user, platform="indeed") + assert indeed.encrypted_password == "" edit_response = client.get(reverse("sources:mailbox_edit", args=[vdab.pk])) assert edit_response.status_code == 200 diff --git a/tests/unit/test_fetcher.py b/tests/unit/test_fetcher.py index e545b30..144c869 100644 --- a/tests/unit/test_fetcher.py +++ b/tests/unit/test_fetcher.py @@ -8,6 +8,7 @@ from apps.sources.services.fetcher import ( FetchTimeoutError, PolicyBlockedError, RateLimitedError, + _validate_connected_peer, fetch_url, ) from apps.sources.services.url_security import ValidatedUrl @@ -100,6 +101,24 @@ def test_fetcher_rejects_dns_rebinding(monkeypatch): client.close() +def test_production_transport_fails_closed_without_connected_peer(): + validated = ValidatedUrl( + url="https://example.org/jobs", + hostname="example.org", + port=443, + addresses=("93.184.216.34",), + ) + response = httpx.Response(200) + + with pytest.raises(FetchError, match="Verbonden IP-adres"): + _validate_connected_peer( + response, + before=validated, + after=validated, + require_peer=True, + ) + + class _PeerStream: def __init__(self, address: str) -> None: self.address = address diff --git a/tests/unit/test_mailbox_credentials.py b/tests/unit/test_mailbox_credentials.py index c6de5fe..2461b28 100644 --- a/tests/unit/test_mailbox_credentials.py +++ b/tests/unit/test_mailbox_credentials.py @@ -89,6 +89,19 @@ def test_rotation_reencrypts_existing_credentials_with_primary_key(user): assert decrypt_mailbox_password(connection) == "rotate-me" +@pytest.mark.django_db +def test_rotation_skips_outlook_oauth_connections(user): + MailboxConnection.objects.create( + user=user, + platform=MailboxConnection.Platform.INDEED, + provider=MailboxConnection.Provider.OUTLOOK, + username="vacatureradar@example.invalid", + encrypted_password="", + ) + + assert rotate_mailbox_credentials() == 0 + + @pytest.mark.django_db def test_private_imap_host_is_blocked_before_connection(user): key = Fernet.generate_key().decode("ascii") diff --git a/tests/unit/test_microsoft365_oauth.py b/tests/unit/test_microsoft365_oauth.py new file mode 100644 index 0000000..1a914a9 --- /dev/null +++ b/tests/unit/test_microsoft365_oauth.py @@ -0,0 +1,108 @@ +import base64 + +import pytest +from django.test import override_settings + +from apps.core.services.microsoft365 import ( + Microsoft365OAuthError, + acquire_exchange_access_token, + xoauth2_b64, +) +from apps.notifications.backends import Microsoft365OAuthEmailBackend + + +@override_settings( + M365_TENANT_ID="tenant-id", + M365_CLIENT_ID="client-id", + M365_CLIENT_SECRET="client-secret", + M365_EXCHANGE_SCOPE="https://outlook.office365.com/.default", +) +def test_app_only_token_uses_tenant_authority_and_exchange_scope(): + calls = {} + + class Client: + def acquire_token_for_client(self, *, scopes): + calls["scopes"] = scopes + return {"access_token": "access-token"} + + def factory(client_id, *, authority, client_credential): + calls.update( + client_id=client_id, + authority=authority, + client_credential=client_credential, + ) + return Client() + + assert acquire_exchange_access_token(client_factory=factory) == "access-token" + assert calls == { + "client_id": "client-id", + "authority": "https://login.microsoftonline.com/tenant-id", + "client_credential": "client-secret", + "scopes": ["https://outlook.office365.com/.default"], + } + + +@override_settings(M365_TENANT_ID="", M365_CLIENT_ID="", M365_CLIENT_SECRET="") +def test_missing_oauth_configuration_fails_without_secret_material(): + with pytest.raises(Microsoft365OAuthError, match="niet volledig") as error: + acquire_exchange_access_token() + assert "secret" not in str(error.value).lower() + + +def test_xoauth2_payload_is_encoded_exactly(): + encoded = xoauth2_b64("mailbox@example.invalid", "token-value") + assert base64.b64decode(encoded) == ( + b"user=mailbox@example.invalid\x01auth=Bearer token-value\x01\x01" + ) + + +@override_settings( + M365_MAILBOX_USER="vacatureradar@example.invalid", + EMAIL_HOST="smtp.office365.com", + EMAIL_PORT=587, + DEFAULT_FROM_EMAIL="VacatureRadar ", + M365_SMTP_TIMEOUT_SECONDS=10, +) +def test_oauth_smtp_backend_authenticates_and_sends(monkeypatch): + calls = [] + + class SMTP: + def __init__(self, host, port, *, timeout): + calls.append(("connect", host, port, timeout)) + + def __enter__(self): + return self + + def __exit__(self, *args): + return None + + def ehlo(self): + calls.append(("ehlo",)) + + def starttls(self, *, context): + calls.append(("starttls", bool(context))) + + def docmd(self, command, argument): + calls.append((command, argument)) + return 235, b"ok" + + def sendmail(self, sender, recipients, message): + calls.append(("sendmail", sender, recipients, message)) + + monkeypatch.setattr("apps.notifications.backends.smtplib.SMTP", SMTP) + monkeypatch.setattr( + "apps.notifications.backends.acquire_exchange_access_token", + lambda: "token-value", + ) + from django.core.mail import EmailMessage as DjangoEmailMessage + + django_message = DjangoEmailMessage( + subject="Test", + body="test", + from_email="vacatureradar@example.invalid", + to=["recipient@example.invalid"], + ) + assert Microsoft365OAuthEmailBackend().send_messages([django_message]) == 1 + assert calls[0] == ("connect", "smtp.office365.com", 587, 10) + assert any(call[0] == "AUTH" and call[1].startswith("XOAUTH2 ") for call in calls) + assert any(call[0] == "sendmail" for call in calls)