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 HttpResponse, 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 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: minutes = max(1, (seconds + 59) // 60) return f"{minutes} minuut" if minutes == 1 else f"{minutes} minuten" def dispatch(self, request, *args, **kwargs): if request.method == "POST": state = is_rate_limited( request, namespace="login", max_attempts=settings.AUTH_LOGIN_RATE_LIMIT_MAX_ATTEMPTS, window_seconds=settings.AUTH_LOGIN_RATE_LIMIT_WINDOW_SECONDS, block_seconds=settings.AUTH_LOGIN_RATE_LIMIT_BLOCK_SECONDS, ) if state.is_blocked: messages.error( request, ( "Te veel inlogpogingen op dit account. Wacht " f"{self._remaining_minutes(state.remaining_seconds)} en probeer daarna " "opnieuw." ), ) return self.form_invalid(self.get_form()) return super().dispatch(request, *args, **kwargs) def form_invalid(self, form): state = register_rate_limit_failure( self.request, namespace="login", max_attempts=settings.AUTH_LOGIN_RATE_LIMIT_MAX_ATTEMPTS, window_seconds=settings.AUTH_LOGIN_RATE_LIMIT_WINDOW_SECONDS, block_seconds=settings.AUTH_LOGIN_RATE_LIMIT_BLOCK_SECONDS, ) if state.is_blocked: messages.error( self.request, ( "Te veel inlogpogingen. Wacht " f"{self._remaining_minutes(state.remaining_seconds)} en probeer daarna opnieuw." ), ) return super().form_invalid(form) def form_valid(self, form): clear_rate_limit(self.request, namespace="login") 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) request.session.set_expiry(settings.DEMO_SESSION_SECONDS) next_url = _safe_next(request, request.POST.get("next")) return redirect(next_url or settings.LOGIN_REDIRECT_URL) def robots_txt(request): if getattr(settings, "SEARCH_ENGINE_INDEXING_ENABLED", False): content = "User-agent: *\nAllow: /\n" else: content = "User-agent: *\nDisallow: /\n" return HttpResponse(content, content_type="text/plain; charset=utf-8") def health_live(request): return JsonResponse({"ok": True, "service": "vacatureradar"}) def health_ready(request): result = readiness() return JsonResponse(result.to_dict(), status=200 if result.ok else 503) class TodayView(LoginRequiredMixin, TemplateView): template_name = "dashboard/today.html" def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) profile = SearchProfile.objects.filter(user=self.request.user, is_active=True).first() context.update(build_dashboard_cockpit(self.request.user, profile)) context["active_profile"] = profile return context class SystemStatusView(LoginRequiredMixin, TemplateView): template_name = "system/status.html" def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context.update(build_automation_cockpit()) context["source_health"] = collect_source_health() return context