272 lines
9.6 KiB
Python
272 lines
9.6 KiB
Python
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
|