ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: 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")),
]
+2
View File
@@ -15,6 +15,8 @@
reverse_proxy /auth/* api:8000
reverse_proxy /logout api:8000
reverse_proxy /static/* api:8000
reverse_proxy /{$BUCKET_NAME}/* plane-minio:9000
+3 -1
View File
@@ -7,8 +7,10 @@
import React from "react";
// components
import { AuthBase } from "@/components/auth-screens/auth-base";
import { NodeDCAuthRedirect } from "@/components/auth-screens/nodedc-auth-redirect";
// helpers
import { EAuthModes, EPageTypes } from "@/helpers/authentication.helper";
import { shouldUseNodeDCOIDC } from "@/helpers/nodedc-auth";
// layouts
import DefaultLayout from "@/layouts/default-layout";
// wrappers
@@ -18,7 +20,7 @@ function HomePage() {
return (
<DefaultLayout>
<AuthenticationWrapper pageType={EPageTypes.NON_AUTHENTICATED}>
<AuthBase authType={EAuthModes.SIGN_IN} />
{shouldUseNodeDCOIDC() ? <NodeDCAuthRedirect /> : <AuthBase authType={EAuthModes.SIGN_IN} />}
</AuthenticationWrapper>
</DefaultLayout>
);
@@ -0,0 +1,27 @@
import { useEffect } from "react";
import { buildNodeDCOIDCLoginUrl, buildNodeDCLauncherUrl, sanitizeNextPath } from "@/helpers/nodedc-auth";
export function NodeDCAuthRedirect() {
useEffect(() => {
const currentUrl = new URL(window.location.href);
const oidcError = currentUrl.searchParams.get("error");
const nextPath = sanitizeNextPath(currentUrl.searchParams.get("next_path") || window.location.pathname);
if (oidcError === "oidc_access_denied" || oidcError === "nodedc_access_revoked") {
window.location.replace(buildNodeDCLauncherUrl());
return;
}
window.location.replace(buildNodeDCOIDCLoginUrl(nextPath));
}, []);
return (
<div className="relative z-10 flex h-screen w-screen flex-col items-center justify-center overflow-hidden bg-canvas px-8 py-12">
<div className="nodedc-auth-shell flex w-full max-w-[28rem] flex-col gap-4 text-center">
<div className="text-2xl font-semibold text-custom-text-100">Переходим в NODE.DC</div>
<div className="text-sm text-custom-text-300">Проверяем платформенную сессию и доступ к рабочему пространству.</div>
</div>
</div>
);
}
@@ -12,6 +12,7 @@ import useSWR from "swr";
import { LogoSpinner } from "@/components/common/logo-spinner";
// helpers
import { EPageTypes } from "@/helpers/authentication.helper";
import { buildNodeDCOIDCLoginUrl, getCurrentRelativePath, shouldUseNodeDCOIDC } from "@/helpers/nodedc-auth";
// hooks
import { useWorkspace } from "@/hooks/store/use-workspace";
import { useUser, useUserProfile, useUserSettings } from "@/hooks/store/user";
@@ -82,6 +83,15 @@ export const AuthenticationWrapper = observer(function AuthenticationWrapper(pro
return redirectionRoute;
};
const redirectToPlatformLogin = () => {
if (shouldUseNodeDCOIDC()) {
window.location.replace(buildNodeDCOIDCLoginUrl(getCurrentRelativePath()));
return;
}
router.push(`/${pathname ? `?next_path=${pathname}` : ``}`);
};
if ((isUserSWRLoading || isUserLoading || workspacesLoader) && !currentUser?.id)
return (
<div className="relative flex h-screen w-full items-center justify-center">
@@ -107,7 +117,7 @@ export const AuthenticationWrapper = observer(function AuthenticationWrapper(pro
if (pageType === EPageTypes.ONBOARDING) {
if (!currentUser?.id) {
router.push(`/${pathname ? `?next_path=${pathname}` : ``}`);
redirectToPlatformLogin();
return <></>;
} else {
if (currentUser && currentUserProfile?.id && isUserOnboard) {
@@ -120,7 +130,7 @@ export const AuthenticationWrapper = observer(function AuthenticationWrapper(pro
if (pageType === EPageTypes.SET_PASSWORD) {
if (!currentUser?.id) {
router.push(`/${pathname ? `?next_path=${pathname}` : ``}`);
redirectToPlatformLogin();
return <></>;
} else {
if (currentUser && !currentUser?.is_password_autoset && currentUserProfile?.id && isUserOnboard) {
@@ -139,7 +149,7 @@ export const AuthenticationWrapper = observer(function AuthenticationWrapper(pro
return <></>;
}
} else {
router.push(`/${pathname ? `?next_path=${pathname}` : ``}`);
redirectToPlatformLogin();
return <></>;
}
}
@@ -8,6 +8,8 @@
import type { AxiosInstance, AxiosRequestConfig } from "axios";
import axios from "axios";
import { buildNodeDCOIDCLoginUrl, getCurrentRelativePath, shouldUseNodeDCOIDC } from "@/helpers/nodedc-auth";
export abstract class APIService {
protected baseURL: string;
private axiosInstance: AxiosInstance;
@@ -26,9 +28,17 @@ export abstract class APIService {
this.axiosInstance.interceptors.response.use(
(response) => response,
(error) => {
if (error.response && error.response.status === 401) {
const status = error.response?.status;
const responseError = error.response?.data?.error;
if (status === 401 || (status === 403 && responseError === "nodedc_access_revoked")) {
const currentPath = window.location.pathname;
window.location.replace(`/${currentPath ? `?next_path=${currentPath}` : ``}`);
if (shouldUseNodeDCOIDC()) {
window.location.replace(buildNodeDCOIDCLoginUrl(getCurrentRelativePath()));
} else {
window.location.replace(`/${currentPath ? `?next_path=${currentPath}` : ``}`);
}
}
return Promise.reject(error);
}
+50
View File
@@ -0,0 +1,50 @@
export function shouldUseNodeDCOIDC(): boolean {
const flag = process.env.VITE_NODEDC_OIDC_LOGIN_ENABLED;
if (flag === "1" || flag === "true") {
return true;
}
if (flag === "0" || flag === "false") {
return false;
}
if (typeof window === "undefined") {
return false;
}
const hostname = window.location.hostname.toLowerCase();
return hostname.endsWith(".local.nodedc") || hostname.endsWith(".notdc.ru") || hostname.endsWith(".nodedc.ru");
}
export function buildNodeDCOIDCLoginUrl(nextPath?: string | null): string {
const configuredUrl = process.env.VITE_NODEDC_OIDC_LOGIN_URL || "/auth/oidc/login/";
const url = new URL(configuredUrl, window.location.origin);
const safeNextPath = sanitizeNextPath(nextPath || getCurrentRelativePath());
if (safeNextPath) {
url.searchParams.set("next_path", safeNextPath);
}
return url.toString();
}
export function buildNodeDCLauncherUrl(): string {
return process.env.VITE_NODEDC_LAUNCHER_URL || "http://launcher.local.nodedc/";
}
export function getCurrentRelativePath(): string {
if (typeof window === "undefined") {
return "/";
}
return `${window.location.pathname}${window.location.search}`;
}
export function sanitizeNextPath(value?: string | null): string {
if (!value || !value.startsWith("/") || value.startsWith("//")) {
return "/";
}
return value;
}