This commit is contained in:
Jens
2026-07-24 22:46:37 +02:00
parent 0101660bd7
commit bdf44eec04
26 changed files with 1770 additions and 24 deletions
+48
View File
@@ -0,0 +1,48 @@
"""Authenticatie-backends voor VacatureRadar.
Sta inloggen toe met e-mailadres *of* gebruikersnaam, zodat het Stitch-loginscherm
(dat om een e-mailadres vraagt) blijft werken zonder het bestaande admin-login op
gebruikersnaam te breken.
"""
from __future__ import annotations
from django.contrib.auth import get_user_model
from django.contrib.auth.backends import ModelBackend
from django.db.models import Q
class EmailOrUsernameModelBackend(ModelBackend):
"""Zoekt de gebruiker op via gebruikersnaam of (case-insensitief) e-mailadres."""
def authenticate(self, request, username=None, password=None, **kwargs):
user_model = get_user_model()
if username is None:
username = kwargs.get(user_model.USERNAME_FIELD)
if username is None or password is None:
return None
try:
user = user_model.objects.get(
Q(**{f"{user_model.USERNAME_FIELD}__iexact": username})
| Q(email__iexact=username)
)
except user_model.DoesNotExist:
# Draai toch een hash om timing-aanvallen (user-enumeratie) te beperken.
user_model().set_password(password)
return None
except user_model.MultipleObjectsReturned:
# Meerdere accounts met dezelfde e-mail: val terug op exacte username-match.
user = (
user_model.objects.filter(
**{f"{user_model.USERNAME_FIELD}__iexact": username}
)
.order_by("id")
.first()
)
if user is None:
return None
if user.check_password(password) and self.user_can_authenticate(user):
return user
return None
+107
View File
@@ -0,0 +1,107 @@
"""Microsoft Entra ID (Azure AD) SSO via de OAuth2 authorization-code flow.
De ``msal``-bibliotheek wordt bewust *lazy* geïmporteerd: zolang SSO uit staat
(``ENTRA_ID_ENABLED=0``) hoeft het pakket niet geïnstalleerd te zijn en blijven de
bestaande tests en de kernapplicatie volledig werken.
"""
from __future__ import annotations
from dataclasses import dataclass
from django.conf import settings
class EntraConfigError(RuntimeError):
"""Entra ID is ingeschakeld maar niet (volledig) geconfigureerd."""
@dataclass(frozen=True)
class EntraConfig:
client_id: str
client_secret: str
tenant_id: str
scopes: list[str]
allowed_domains: tuple[str, ...]
redirect_uri: str | None
@property
def authority(self) -> str:
return f"https://login.microsoftonline.com/{self.tenant_id}"
def entra_enabled() -> bool:
return bool(getattr(settings, "ENTRA_ID_ENABLED", False))
def load_config() -> EntraConfig:
client_id = getattr(settings, "ENTRA_CLIENT_ID", "") or ""
client_secret = getattr(settings, "ENTRA_CLIENT_SECRET", "") or ""
tenant_id = getattr(settings, "ENTRA_TENANT_ID", "") or ""
if not (client_id and client_secret and tenant_id):
raise EntraConfigError(
"Entra ID staat aan maar ENTRA_CLIENT_ID, ENTRA_CLIENT_SECRET en "
"ENTRA_TENANT_ID zijn niet allemaal ingesteld."
)
scopes = list(getattr(settings, "ENTRA_SCOPES", ["User.Read"]))
allowed = tuple(
d.lower().lstrip("@")
for d in getattr(settings, "ENTRA_ALLOWED_DOMAINS", [])
if d
)
return EntraConfig(
client_id=client_id,
client_secret=client_secret,
tenant_id=tenant_id,
scopes=scopes,
allowed_domains=allowed,
redirect_uri=getattr(settings, "ENTRA_REDIRECT_URI", "") or None,
)
def _build_app(config: EntraConfig):
try:
import msal # lazy import: alleen nodig als SSO aan staat
except ModuleNotFoundError as exc: # pragma: no cover - hangt van omgeving af
raise EntraConfigError(
"Het pakket 'msal' is niet geïnstalleerd. Voeg het toe (zie pyproject.toml) "
"om Entra ID SSO te gebruiken."
) from exc
return msal.ConfidentialClientApplication(
client_id=config.client_id,
client_credential=config.client_secret,
authority=config.authority,
)
def build_auth_flow(config: EntraConfig, redirect_uri: str) -> dict:
"""Start de authorization-code flow en geef het flow-dict terug (bewaar in sessie)."""
app = _build_app(config)
return app.initiate_auth_code_flow(config.scopes, redirect_uri=redirect_uri)
def redeem_auth_code(config: EntraConfig, flow: dict, auth_response: dict) -> dict:
"""Wissel de teruggekeerde code in voor tokens en claims."""
app = _build_app(config)
return app.acquire_token_by_auth_code_flow(flow, auth_response)
def extract_identity(token_result: dict) -> tuple[str, str]:
"""Haal (e-mailadres, weergavenaam) uit de id-token-claims."""
claims = token_result.get("id_token_claims") or {}
email = (
claims.get("preferred_username")
or claims.get("email")
or claims.get("upn")
or ""
).strip()
name = (claims.get("name") or "").strip()
return email, name
def domain_allowed(config: EntraConfig, email: str) -> bool:
if not config.allowed_domains:
return True
domain = email.rsplit("@", 1)[-1].lower()
return domain in config.allowed_domains
+33
View File
@@ -0,0 +1,33 @@
"""Formulieren voor authenticatie."""
from __future__ import annotations
from django.contrib.auth.forms import AuthenticationForm
from django.utils.translation import gettext_lazy as _
class EmailAuthenticationForm(AuthenticationForm):
"""Loginformulier dat het ``username``-veld presenteert als e-mailadres.
Het veld blijft ``username`` heten zodat ``LoginView`` en de authenticatie-backend
onveranderd werken; alleen label, invoertype en foutmelding zijn aangepast.
"""
error_messages = {
**AuthenticationForm.error_messages,
"invalid_login": _("E-mailadres of wachtwoord klopt niet."),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
field = self.fields["username"]
field.label = _("E-mailadres")
field.widget.attrs.update(
{
"type": "email",
"autocomplete": "username",
"autocapitalize": "none",
"spellcheck": "false",
"placeholder": "naam@organisatie.nl",
}
)
+168
View File
@@ -1,12 +1,21 @@
from __future__ import annotations
import logging
from django.conf import settings
from django.contrib import messages
from django.contrib.auth import get_user_model, login
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.views import LoginView
from django.http import JsonResponse
from django.shortcuts import redirect
from django.urls import reverse
from django.utils.http import url_has_allowed_host_and_scheme
from django.views.decorators.http import require_POST
from django.views.generic import TemplateView
from apps.core import entra as entra_sso
from apps.core.forms import EmailAuthenticationForm
from apps.core.rate_limit import clear_rate_limit, is_rate_limited, register_rate_limit_failure
from apps.jobs.services.cockpit import build_dashboard_cockpit
from apps.profiles.models import SearchProfile
@@ -15,9 +24,32 @@ from apps.sources.services.health import collect_source_health
from .health import readiness
from .services.automation import build_automation_cockpit
logger = logging.getLogger(__name__)
SSO_BACKEND = "apps.core.auth_backends.EmailOrUsernameModelBackend"
SESSION_ENTRA_FLOW = "entra_auth_flow"
SESSION_ENTRA_NEXT = "entra_next"
def _safe_next(request, candidate: str | None) -> str | None:
if candidate and url_has_allowed_host_and_scheme(
candidate,
allowed_hosts={request.get_host()},
require_https=request.is_secure(),
):
return candidate
return None
class SecurityAwareLoginView(LoginView):
template_name = "registration/login.html"
authentication_form = EmailAuthenticationForm
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["entra_enabled"] = entra_sso.entra_enabled()
context["demo_enabled"] = bool(getattr(settings, "DEMO_MODE_ENABLED", False))
return context
@staticmethod
def _remaining_minutes(seconds: int) -> str:
@@ -68,6 +100,142 @@ class SecurityAwareLoginView(LoginView):
return super().form_valid(form)
def _split_name(full_name: str) -> tuple[str, str]:
parts = full_name.split()
if not parts:
return "", ""
if len(parts) == 1:
return parts[0][:150], ""
return parts[0][:150], " ".join(parts[1:])[:150]
def _unique_username(base: str) -> str:
user_model = get_user_model()
base = (base or "gebruiker")[:140].strip() or "gebruiker"
candidate = base
suffix = 1
while user_model.objects.filter(username__iexact=candidate).exists():
suffix += 1
candidate = f"{base}-{suffix}"
return candidate
def _get_or_create_sso_user(email: str, full_name: str):
user_model = get_user_model()
user = user_model.objects.filter(email__iexact=email).order_by("id").first()
first, last = _split_name(full_name)
if user is None:
user = user_model(
username=_unique_username(email.split("@", 1)[0]),
email=email,
first_name=first,
last_name=last,
)
user.set_unusable_password()
user.save()
elif full_name and not (user.first_name or user.last_name):
user.first_name, user.last_name = first, last
user.save(update_fields=["first_name", "last_name"])
return user
@require_POST
def entra_login(request):
"""Start de Entra ID authorization-code flow."""
if not entra_sso.entra_enabled():
messages.error(request, "Inloggen via Entra ID is niet ingeschakeld.")
return redirect("login")
try:
config = entra_sso.load_config()
redirect_uri = config.redirect_uri or request.build_absolute_uri(
reverse("entra-callback")
)
flow = entra_sso.build_auth_flow(config, redirect_uri)
except entra_sso.EntraConfigError as exc:
logger.warning("Entra ID niet beschikbaar: %s", exc)
messages.error(request, "Inloggen via Entra ID is nu niet beschikbaar.")
return redirect("login")
request.session[SESSION_ENTRA_FLOW] = flow
next_url = _safe_next(request, request.POST.get("next"))
if next_url:
request.session[SESSION_ENTRA_NEXT] = next_url
return redirect(flow["auth_uri"])
def entra_callback(request):
"""Verwerk de terugkeer van Microsoft na de SSO-flow."""
if not entra_sso.entra_enabled():
return redirect("login")
flow = request.session.pop(SESSION_ENTRA_FLOW, None)
next_url = request.session.pop(SESSION_ENTRA_NEXT, None)
if not flow:
messages.error(request, "De Entra ID-sessie is verlopen. Probeer opnieuw.")
return redirect("login")
try:
config = entra_sso.load_config()
result = entra_sso.redeem_auth_code(config, flow, request.GET.dict())
except entra_sso.EntraConfigError as exc:
logger.warning("Entra ID niet beschikbaar: %s", exc)
messages.error(request, "Inloggen via Entra ID is nu niet beschikbaar.")
return redirect("login")
if "error" in result:
logger.warning(
"Entra ID-fout: %s", result.get("error_description") or result.get("error")
)
messages.error(request, "Inloggen via Entra ID is mislukt.")
return redirect("login")
email, name = entra_sso.extract_identity(result)
if not email:
messages.error(request, "Entra ID gaf geen e-mailadres terug.")
return redirect("login")
if not entra_sso.domain_allowed(config, email):
messages.error(request, "Dit Entra ID-account mag hier niet inloggen.")
return redirect("login")
user = _get_or_create_sso_user(email, name)
login(request, user, backend=SSO_BACKEND)
clear_rate_limit(request, namespace="login")
return redirect(next_url or settings.LOGIN_REDIRECT_URL)
@require_POST
def demo_login(request):
"""Log in als afgeschermd demo-account."""
if not getattr(settings, "DEMO_MODE_ENABLED", False):
messages.error(request, "De demomodus is niet ingeschakeld.")
return redirect("login")
user_model = get_user_model()
user, created = user_model.objects.get_or_create(
username=settings.DEMO_USERNAME,
defaults={
"email": settings.DEMO_EMAIL,
"first_name": "Demo",
"last_name": "Gebruiker",
},
)
if created:
user.set_unusable_password()
user.save()
try:
from apps.jobs.services.demo_seed import seed_demo_environment
seed_demo_environment(user)
except Exception:
# De demo mag nooit de login blokkeren.
logger.exception("Kon de demo-omgeving niet seeden")
login(request, user, backend=SSO_BACKEND)
next_url = _safe_next(request, request.POST.get("next"))
return redirect(next_url or settings.LOGIN_REDIRECT_URL)
def health_live(request):
return JsonResponse({"ok": True, "service": "vacatureradar"})
+269
View File
@@ -0,0 +1,269 @@
"""Vult een fictieve demo-omgeving voor het demo-account.
Wordt aangeroepen bij 'Bekijk demo' zodat de gebruiker meteen een geloofwaardige,
gevulde cockpit ziet (profiel, vacatures, matchscores en een sollicitatiepijplijn)
in plaats van een leeg dashboard. Volledig idempotent: eenmaal geseed, wordt er
niet opnieuw geseed.
"""
from __future__ import annotations
import hashlib
from datetime import timedelta
from decimal import Decimal
from django.utils import timezone
from apps.profiles.models import SearchProfile
_DEMO_PROFILE_NAME = "Demo · IT-infrastructuur Limburg"
# (titel, werkgever, domein, gemeente, regio, werkplek, skills_required,
# skills_preferred, score, recommendation, positives, concerns)
_DEMO_JOBS = [
(
"Infrastructure Engineer",
"CloudNexus",
"cloudnexus.example",
"Hasselt",
"Limburg",
"hybrid",
["Microsoft 365", "VMware", "Azure"],
["Terraform"],
Decimal("92.4"),
"strong",
["Dichtbij Hasselt", "Sterke match op Microsoft 365 en VMware", "Hybride werk"],
[],
),
(
"Cloud Architect",
"Skylink Solutions",
"skylink.example",
"Remote",
"Vlaanderen",
"remote",
["Azure", "Kubernetes"],
["Bicep", "Landing Zones"],
Decimal("88.1"),
"strong",
["Volledig remote", "Azure-zwaartepunt sluit aan"],
["Iets seniorder dan gevraagd"],
),
(
"Systeembeheerder Microsoft 365",
"Gemeente Genk",
"genk.example",
"Genk",
"Limburg",
"on_site",
["Microsoft 365", "Active Directory"],
["Intune"],
Decimal("83.6"),
"strong",
["Publieke sector met stabiliteit", "M365-focus"],
["Volledig op locatie"],
),
(
"DevOps Engineer",
"Datastroom BV",
"datastroom.example",
"Leuven",
"Vlaams-Brabant",
"hybrid",
["CI/CD", "Docker", "Azure"],
["GitLab", "Observability"],
Decimal("77.9"),
"possible",
["Moderne toolchain", "Hybride"],
["Reistijd naar Leuven"],
),
(
"Network Engineer",
"Noorder Telecom",
"noordertelecom.example",
"Antwerpen",
"Antwerpen",
"on_site",
["Cisco", "Netwerkbeheer"],
["Fortinet"],
Decimal("70.4"),
"possible",
["Stevige netwerkrol"],
["Weinig cloudcomponent", "Op locatie in Antwerpen"],
),
(
"IT Support Specialist",
"MediCare Groep",
"medicare.example",
"Diepenbeek",
"Limburg",
"on_site",
["Servicedesk", "Windows"],
["ITIL"],
Decimal("62.3"),
"weak",
["Dichtbij"],
["Sterk supportgericht", "Weinig infrastructuurverdieping"],
),
]
def _digest(*parts: str) -> str:
return hashlib.sha256("::".join(parts).encode("utf-8")).hexdigest()
def demo_environment_seeded(user) -> bool:
profile = SearchProfile.objects.filter(user=user, name=_DEMO_PROFILE_NAME).first()
if profile is None:
return False
from apps.jobs.models import ScoreRun
return ScoreRun.objects.filter(profile=profile).exists()
def seed_demo_environment(user) -> None:
"""Maak (idempotent) een fictieve demo-omgeving voor ``user``."""
if demo_environment_seeded(user):
return
from apps.jobs.models import Application, Employer, JobPosting, ScoreRun
profile, _ = SearchProfile.objects.get_or_create(
user=user,
name=_DEMO_PROFILE_NAME,
defaults={
"is_active": True,
"home_municipality": "Hasselt",
"home_latitude": Decimal("50.930700"),
"home_longitude": Decimal("5.332500"),
"max_distance_km": 60,
"experience_years": 5,
"learning_enabled": True,
"desired_titles": ["infrastructure engineer", "systeembeheerder", "cloud engineer"],
"excluded_titles": ["sales", "recruiter"],
"desired_skills": ["Microsoft 365", "VMware", "Azure"],
"allowed_employment_types": ["full_time", "permanent"],
"preferred_workplace": ["hybrid", "remote", "on_site"],
"preferred_regions": ["Limburg", "Vlaams-Brabant"],
},
)
SearchProfile.objects.filter(user=user).exclude(pk=profile.pk).update(is_active=False)
if not profile.is_active:
profile.is_active = True
profile.save(update_fields=["is_active"])
now = timezone.now()
workplace_map = {
"hybrid": JobPosting.Workplace.HYBRID,
"remote": JobPosting.Workplace.REMOTE,
"on_site": JobPosting.Workplace.ON_SITE,
}
created_jobs: list = []
for index, (
title,
employer_name,
domain,
municipality,
region,
workplace,
req_skills,
pref_skills,
score,
recommendation,
positives,
concerns,
) in enumerate(_DEMO_JOBS):
employer, _ = Employer.objects.get_or_create(
normalized_name=employer_name.lower(),
defaults={
"name": employer_name,
"domain": domain,
"is_direct_employer": True,
"is_recruiter": False,
"confidence": 0.92,
},
)
slug = _digest("demo-job", str(index), title, employer_name)
job, job_created = JobPosting.objects.get_or_create(
canonical_key=slug,
defaults={
"employer": employer,
"original_title": title,
"normalized_title": title.lower(),
"job_family": "infrastructure",
"canonical_url": f"https://{domain}/vacatures/{slug[:12]}",
"content_hash": _digest("demo-content", slug),
"description_text": (
f"{title} bij {employer_name} in {municipality}. "
"Fictieve demovacature voor de VacatureRadar-demo-omgeving."
),
"raw_location": f"{municipality}, {region}",
"region": region,
"municipality": municipality,
"workplace_type": workplace_map[workplace],
"employment_types": ["full_time", "permanent"],
"skills_required": req_skills,
"skills_preferred": pref_skills,
"direct_employer": True,
"recruiter": False,
"extraction_confidence": 0.9,
"analysis_features": {
"support_ratio": 0.1,
"public_sector_signal": 1.0 if "Gemeente" in employer_name else 0.0,
"experience_years_max": 6,
},
"status": JobPosting.Status.ACTIVE,
"first_seen": now - timedelta(days=index + 1),
"last_seen": now,
"date_posted": now - timedelta(days=index + 1),
},
)
created_jobs.append(job)
ScoreRun.objects.create(
job=job,
profile=profile,
profile_version=profile.version,
score=score,
confidence=Decimal("0.78"),
recommendation=recommendation,
components={
"content": float(score) * 0.45,
"skills": float(score) * 0.30,
"location": float(score) * 0.25,
},
positives=positives,
concerns=concerns,
hard_exclusions=[],
evidence={"demo": True},
model_version="demo-seed",
prompt_version="1.0.0",
created_at=now - timedelta(hours=index),
)
# Een kleine sollicitatiepijplijn voor de demo.
if created_jobs:
Application.objects.get_or_create(
user=user,
job=created_jobs[0],
defaults={
"status": Application.Status.INTERVIEW,
"applied_at": now - timedelta(days=6),
"follow_up_date": (now + timedelta(days=2)).date(),
"contact_name": "Sofie Vandael",
"contact_email": "sofie.vandael@cloudnexus.example",
"notes": "Eerste gesprek gepland. Demo-data.",
},
)
if len(created_jobs) > 3:
Application.objects.get_or_create(
user=user,
job=created_jobs[3],
defaults={
"status": Application.Status.APPLIED,
"applied_at": now - timedelta(days=2),
"follow_up_date": (now + timedelta(days=5)).date(),
"notes": "Sollicitatie verstuurd. Demo-data.",
},
)