ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: Plane live access и logout handoff

This commit is contained in:
DCCONSTRUCTIONS
2026-05-04 18:54:21 +03:00
parent 55318f14e5
commit 3b13e5be52
15 changed files with 435 additions and 36 deletions
@@ -0,0 +1,188 @@
import os
import time
import requests
from django.contrib.auth import logout
from django.http import HttpResponseRedirect, JsonResponse
from plane.db.models import ExternalIdentityLink, Session
OIDC_PROVIDER = "authentik"
class NodeDCAccessMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
response = self._enforce(request)
if response is not None:
return response
return self.get_response(request)
def _enforce(self, request):
config = get_access_config()
if not config["enabled"] or should_skip_path(request.path_info):
return None
user = getattr(request, "user", None)
if not user or not user.is_authenticated:
return None
link = ExternalIdentityLink.objects.filter(
provider=OIDC_PROVIDER,
user=user,
status=ExternalIdentityLink.Status.ACTIVE,
).first()
if link is None:
return deny_unlinked_user(request, config) if config["enforce_unlinked"] else None
cached = get_cached_access_decision(request, config["cache_seconds"])
if cached is not None:
return None if cached else revoke_session(request, user, "nodedc_access_revoked")
try:
decision = check_launcher_access(config, link, user)
except (ValueError, requests.RequestException):
return service_unavailable(request)
cache_access_decision(request, decision["allowed"], config["cache_seconds"])
if decision["groups"] is not None and decision["groups"] != link.groups:
link.groups = decision["groups"]
link.save(update_fields=["groups", "updated_at"])
if not decision["allowed"]:
return revoke_session(request, user, decision["reason"])
return None
def get_access_config():
check_url = os.environ.get("PLANE_NODEDC_ACCESS_CHECK_URL", "").strip()
token = (
os.environ.get("PLANE_NODEDC_ACCESS_TOKEN", "").strip()
or os.environ.get("NODEDC_INTERNAL_ACCESS_TOKEN", "").strip()
or os.environ.get("PLANE_OIDC_CLIENT_SECRET", "").strip()
)
return {
"enabled": is_truthy(os.environ.get("PLANE_NODEDC_ACCESS_ENFORCEMENT", "0")) and bool(check_url and token),
"check_url": check_url,
"token": token,
"service_slug": os.environ.get("PLANE_NODEDC_ACCESS_SERVICE_SLUG", "task-manager").strip() or "task-manager",
"timeout": float(os.environ.get("PLANE_NODEDC_ACCESS_TIMEOUT_SECONDS", "3") or "3"),
"cache_seconds": max(0, int(os.environ.get("PLANE_NODEDC_ACCESS_CACHE_SECONDS", "0") or "0")),
"enforce_unlinked": is_truthy(os.environ.get("PLANE_NODEDC_ACCESS_ENFORCE_UNLINKED", "0")),
}
def check_launcher_access(config, link, user):
response = requests.post(
config["check_url"],
json={
"serviceSlug": config["service_slug"],
"subject": link.subject,
"email": link.email or user.email,
"userId": None,
},
headers={
"Authorization": f"Bearer {config['token']}",
"Accept": "application/json",
},
timeout=config["timeout"],
)
response.raise_for_status()
payload = response.json()
return {
"allowed": bool(payload.get("allowed")),
"reason": payload.get("reason") or "nodedc_access_denied",
"groups": payload.get("groups") if isinstance(payload.get("groups"), list) else None,
}
def get_cached_access_decision(request, cache_seconds):
if cache_seconds <= 0:
return None
checked_at = request.session.get("nodedc_access_checked_at")
allowed = request.session.get("nodedc_access_allowed")
if not checked_at or allowed is None:
return None
try:
checked_at_value = float(checked_at)
except (TypeError, ValueError):
return None
if time.time() - checked_at_value > cache_seconds:
return None
return bool(allowed)
def cache_access_decision(request, allowed, cache_seconds):
if cache_seconds <= 0:
request.session.pop("nodedc_access_checked_at", None)
request.session.pop("nodedc_access_allowed", None)
return
request.session["nodedc_access_checked_at"] = time.time()
request.session["nodedc_access_allowed"] = bool(allowed)
def revoke_session(request, user, reason):
Session.objects.filter(user_id=str(user.id)).delete()
logout(request)
if is_api_request(request):
status_code = 200 if request.path_info == "/api/users/session/" else 403
payload = {"is_authenticated": False} if status_code == 200 else {}
payload.update({"error": "nodedc_access_revoked", "reason": reason})
return JsonResponse(payload, status=status_code)
return HttpResponseRedirect(os.environ.get("PLANE_NODEDC_ACCESS_DENIED_REDIRECT_URL", "http://launcher.local.nodedc/"))
def deny_unlinked_user(request, config):
if not config["enforce_unlinked"]:
return None
return revoke_session(request, request.user, "nodedc_identity_not_linked")
def service_unavailable(request):
if is_api_request(request):
return JsonResponse({"error": "nodedc_access_check_unavailable"}, status=503)
return JsonResponse({"error": "nodedc_access_check_unavailable"}, status=503)
def should_skip_path(path):
return path.startswith(
(
"/auth/",
"/api/public/",
"/api/schema/",
"/static/",
"/assets/",
"/robots.txt",
)
)
def is_api_request(request):
return request.path_info.startswith("/api/")
def is_truthy(value):
return str(value).strip().lower() in {"1", "true", "yes", "on"}
@@ -4,25 +4,17 @@
# Django imports
from django.views import View
from django.contrib.auth import logout
from django.http import HttpResponseRedirect
from django.utils import timezone
# Module imports
from plane.authentication.utils.host import user_ip, base_host
from plane.db.models import User
from plane.authentication.utils.host import base_host
from plane.authentication.views.nodedc_logout import get_logout_redirect_url, logout_current_user
class SignOutAuthEndpoint(View):
def post(self, request):
# Get user
try:
user = User.objects.get(pk=request.user.id)
user.last_logout_ip = user_ip(request=request)
user.last_logout_time = timezone.now()
user.save()
# Log the user out
logout(request)
return HttpResponseRedirect(base_host(request=request, is_app=True))
logout_current_user(request)
return HttpResponseRedirect(get_logout_redirect_url(base_host(request=request, is_app=True)))
except Exception:
return HttpResponseRedirect(base_host(request=request, is_app=True))
return HttpResponseRedirect(get_logout_redirect_url(base_host(request=request, is_app=True)))
@@ -0,0 +1,44 @@
import os
from django.contrib.auth import logout
from django.http import HttpResponse, HttpResponseRedirect
from django.utils import timezone
from django.views import View
from plane.authentication.utils.host import user_ip
from plane.db.models import User
def get_nodedc_global_logout_url():
value = os.environ.get("PLANE_NODEDC_GLOBAL_LOGOUT_URL", "").strip()
return value or None
def get_logout_redirect_url(default_url):
return get_nodedc_global_logout_url() or default_url
def logout_current_user(request):
if request.user and request.user.is_authenticated:
try:
user = User.objects.get(pk=request.user.id)
user.last_logout_ip = user_ip(request=request)
user.last_logout_time = timezone.now()
user.save()
except Exception:
pass
logout(request)
class NodeDCFrontChannelLogoutEndpoint(View):
def get(self, request):
logout_current_user(request)
return HttpResponse(
"<!doctype html><html><head><meta charset='utf-8'></head><body>NODE.DC Task session closed.</body></html>",
content_type="text/html",
)
def post(self, request):
logout_current_user(request)
return HttpResponseRedirect(get_logout_redirect_url("/"))
@@ -4,13 +4,11 @@
# Django imports
from django.views import View
from django.contrib.auth import logout
from django.http import HttpResponseRedirect
from django.utils import timezone
# Module imports
from plane.authentication.utils.host import base_host, user_ip
from plane.db.models import User
from plane.authentication.utils.host import base_host
from plane.authentication.views.nodedc_logout import get_logout_redirect_url, logout_current_user
from plane.utils.path_validator import get_safe_redirect_url
@@ -18,16 +16,10 @@ class SignOutAuthSpaceEndpoint(View):
def post(self, request):
next_path = request.POST.get("next_path")
# Get user
try:
user = User.objects.get(pk=request.user.id)
user.last_logout_ip = user_ip(request=request)
user.last_logout_time = timezone.now()
user.save()
# Log the user out
logout(request)
logout_current_user(request)
url = get_safe_redirect_url(base_url=base_host(request=request, is_space=True), next_path=next_path)
return HttpResponseRedirect(url)
return HttpResponseRedirect(get_logout_redirect_url(url))
except Exception:
url = get_safe_redirect_url(base_url=base_host(request=request, is_space=True), next_path=next_path)
return HttpResponseRedirect(url)
return HttpResponseRedirect(get_logout_redirect_url(url))
@@ -82,6 +82,7 @@ MIDDLEWARE = [
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"plane.authentication.middleware.nodedc_access.NodeDCAccessMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"crum.CurrentRequestUserMiddleware",
"django.middleware.gzip.GZipMiddleware",
+2
View File
@@ -11,6 +11,7 @@ from drf_spectacular.views import (
SpectacularRedocView,
SpectacularSwaggerView,
)
from plane.authentication.views.nodedc_logout import NodeDCFrontChannelLogoutEndpoint
handler404 = "plane.app.views.error_404.custom_404_view"
@@ -20,6 +21,7 @@ urlpatterns = [
path("api/instances/", include("plane.license.urls")),
path("api/v1/", include("plane.api.urls")),
path("auth/", include("plane.authentication.urls")),
path("logout", NodeDCFrontChannelLogoutEndpoint.as_view(), name="nodedc-frontchannel-logout"),
path("", include("plane.web.urls")),
]