ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: устойчивый global logout и auth fallback

This commit is contained in:
DCCONSTRUCTIONS 2026-05-05 11:54:29 +03:00
parent ed18a07154
commit 4400b7f438
5 changed files with 324 additions and 19 deletions

View File

@ -50,6 +50,7 @@ from .views import (
urlpatterns = [
# credentials
path("", NodeDCOIDCInitiateEndpoint.as_view(), name="nodedc-auth-root"),
path("sign-in/", SignInAuthEndpoint.as_view(), name="sign-in"),
path("sign-up/", SignUpAuthEndpoint.as_view(), name="sign-up"),
path("oidc/login/", NodeDCOIDCInitiateEndpoint.as_view(), name="nodedc-oidc-login"),

View File

@ -6,6 +6,7 @@ from urllib.parse import urlencode
import jwt
import requests
from django.core.cache import cache
from django.http import HttpResponseRedirect
from django.utils import timezone
from django.views import View
@ -53,6 +54,7 @@ class NodeDCOIDCInitiateEndpoint(View):
if request.GET.get("prompt") == "login":
params["prompt"] = "login"
params["max_age"] = "0"
return HttpResponseRedirect(f"{discovery['authorization_endpoint']}?{urlencode(params)}")
@ -65,17 +67,28 @@ class NodeDCOIDCCallbackEndpoint(View):
base_url = base_host(request=request, is_app=True)
if request.GET.get("error"):
return oidc_error_redirect(base_url, next_path, "oidc_provider_error")
request.session.pop(OIDC_SESSION_KEY, None)
return oidc_login_redirect(base_url, next_path)
state = request.GET.get("state")
code = request.GET.get("code")
if not state or state != oidc_session.get("state") or not code:
return oidc_error_redirect(base_url, next_path, "oidc_state_failed")
request.session.pop(OIDC_SESSION_KEY, None)
return oidc_login_redirect(base_url, next_path)
try:
discovery = load_discovery(config["issuer"])
token_set = exchange_code(discovery, config, code, oidc_session.get("code_verifier"))
claims = verify_id_token(discovery, config, token_set["id_token"], oidc_session.get("nonce"))
except (KeyError, RuntimeError, requests.RequestException, jwt.PyJWTError):
request.session.pop(OIDC_SESSION_KEY, None)
return oidc_login_redirect(base_url, next_path)
if is_logout_guard_active(claims):
request.session.pop(OIDC_SESSION_KEY, None)
return oidc_login_redirect(base_url, next_path, prompt_login=True)
discovery = load_discovery(config["issuer"])
token_set = exchange_code(discovery, config, code, oidc_session.get("code_verifier"))
claims = verify_id_token(discovery, config, token_set["id_token"], oidc_session.get("nonce"))
groups = normalize_groups(claims.get("groups"))
if not has_required_group(groups):
@ -260,5 +273,87 @@ def first_string_claim(claims, *keys):
return None
def normalize_logout_guard_value(value):
return value.strip().lower() if isinstance(value, str) else ""
def get_logout_guard_cache_key(kind, value):
normalized_value = normalize_logout_guard_value(value)
return f"nodedc:logout-guard:{kind}:{normalized_value}" if normalized_value else ""
def get_logout_guard_cache_keys(claims):
keys = set()
subject_key = get_logout_guard_cache_key("subject", claims.get("sub"))
email_key = get_logout_guard_cache_key("email", claims.get("email"))
if subject_key:
keys.add(subject_key)
if email_key:
keys.add(email_key)
return sorted(keys)
def get_claim_auth_time(claims):
auth_time = claims.get("auth_time")
try:
return int(auth_time)
except (TypeError, ValueError):
return 0
def get_logout_guard_time(claims):
guard_times = []
for cache_key in get_logout_guard_cache_keys(claims):
value = cache.get(cache_key)
try:
guard_times.append(int(value))
except (TypeError, ValueError):
pass
return max(guard_times) if guard_times else 0
def clear_logout_guard(claims):
cache_keys = get_logout_guard_cache_keys(claims)
if cache_keys:
cache.delete_many(cache_keys)
def is_logout_guard_active(claims):
logout_guard_time = get_logout_guard_time(claims)
if not logout_guard_time:
return False
auth_time = get_claim_auth_time(claims)
if auth_time > logout_guard_time:
clear_logout_guard(claims)
return False
return True
def oidc_error_redirect(base_url, next_path, error_code):
return HttpResponseRedirect(get_safe_redirect_url(base_url=base_url, next_path=next_path, params={"error": error_code}))
def oidc_login_redirect(base_url, next_path, prompt_login=False):
params = {}
if validate_next_path(next_path):
params["next_path"] = next_path
if prompt_login:
params["prompt"] = "login"
query_string = f"?{urlencode(params)}" if params else ""
return HttpResponseRedirect(f"{base_url.rstrip('/')}/auth/oidc/login/{query_string}")

View File

@ -1,13 +1,23 @@
import json
import os
import time
from secrets import compare_digest
from django.contrib.auth import logout
from django.conf import settings
from django.http import HttpResponse, HttpResponseRedirect
from django.core.cache import cache
from django.http import HttpResponse, HttpResponseRedirect, JsonResponse
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.csrf import csrf_exempt
from plane.authentication.utils.host import user_ip
from plane.db.models import User
from plane.db.models import ExternalIdentityLink, Session, User
OIDC_PROVIDER = "authentik"
DEFAULT_LOGOUT_GUARD_SECONDS = 30
def get_nodedc_global_logout_url():
@ -19,10 +29,100 @@ def get_logout_redirect_url(default_url):
return get_nodedc_global_logout_url() or default_url
def get_nodedc_internal_token():
return (
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()
)
def is_internal_logout_request_authorized(request):
expected_token = get_nodedc_internal_token()
authorization = request.headers.get("Authorization", "")
bearer_token = (
authorization.split(" ", 1)[1].strip()
if authorization.lower().startswith("bearer ")
else ""
)
header_token = request.headers.get("X-NODEDC-Internal-Token", "").strip()
request_token = bearer_token or header_token
return bool(expected_token and request_token and compare_digest(request_token, expected_token))
def get_logout_guard_seconds():
value = os.environ.get("PLANE_NODEDC_LOGOUT_GUARD_SECONDS", "").strip()
try:
return max(1, int(value)) if value else DEFAULT_LOGOUT_GUARD_SECONDS
except ValueError:
return DEFAULT_LOGOUT_GUARD_SECONDS
def normalize_guard_value(value):
return value.strip().lower() if isinstance(value, str) else ""
def get_logout_guard_cache_key(kind, value):
normalized_value = normalize_guard_value(value)
return f"nodedc:logout-guard:{kind}:{normalized_value}" if normalized_value else ""
def collect_logout_guard_keys(user=None, payload=None):
payload = payload or {}
keys = set()
for kind in ("subject", "email"):
cache_key = get_logout_guard_cache_key(kind, payload.get(kind))
if cache_key:
keys.add(cache_key)
if user is None:
return keys
email_key = get_logout_guard_cache_key("email", user.email)
if email_key:
keys.add(email_key)
links = ExternalIdentityLink.objects.filter(
provider=OIDC_PROVIDER,
user=user,
status=ExternalIdentityLink.Status.ACTIVE,
).values_list("subject", "email")
for subject, email in links:
subject_key = get_logout_guard_cache_key("subject", subject)
email_key = get_logout_guard_cache_key("email", email)
if subject_key:
keys.add(subject_key)
if email_key:
keys.add(email_key)
return keys
def mark_logout_guard(user=None, payload=None):
guard_keys = collect_logout_guard_keys(user=user, payload=payload)
if not guard_keys:
return []
logout_time = int(time.time())
ttl = get_logout_guard_seconds()
for guard_key in guard_keys:
cache.set(guard_key, logout_time, timeout=ttl)
return sorted(guard_keys)
def logout_current_user(request):
if request.user and request.user.is_authenticated:
try:
user = User.objects.get(pk=request.user.id)
mark_logout_guard(user=user)
user.last_logout_ip = user_ip(request=request)
user.last_logout_time = timezone.now()
user.save()
@ -56,7 +156,8 @@ def clear_nodedc_auth_cookies(response, request=None):
if domain:
session_cookie_name = getattr(settings, "SESSION_COOKIE_NAME", "session-id")
response["Set-Cookie"] = (
f'{session_cookie_name}=""; Domain={domain}; expires=Thu, 01 Jan 1970 00:00:00 GMT; Max-Age=0; Path=/'
f'{session_cookie_name}=""; Domain={domain}; '
"expires=Thu, 01 Jan 1970 00:00:00 GMT; Max-Age=0; Path=/"
)
response["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
@ -65,11 +166,71 @@ def clear_nodedc_auth_cookies(response, request=None):
return response
def parse_json_body(request):
if not request.body:
return {}
return json.loads(request.body.decode("utf-8"))
def find_logout_user(payload):
user_id = payload.get("planeUserId")
subject = payload.get("subject")
email = payload.get("email")
user_id = user_id.strip() if isinstance(user_id, str) else ""
subject = subject.strip() if isinstance(subject, str) else ""
email = email.strip().lower() if isinstance(email, str) else ""
if user_id:
user = User.objects.filter(id=user_id).first()
if user is not None:
return user
if subject:
link = ExternalIdentityLink.objects.filter(
provider=OIDC_PROVIDER,
subject=subject,
status=ExternalIdentityLink.Status.ACTIVE,
).select_related("user").first()
if link is not None:
return link.user
if email:
user = User.objects.filter(email__iexact=email).first()
if user is not None:
return user
link = ExternalIdentityLink.objects.filter(
provider=OIDC_PROVIDER,
email__iexact=email,
status=ExternalIdentityLink.Status.ACTIVE,
).select_related("user").first()
if link is not None:
return link.user
return None
def invalidate_user_sessions(user, request):
deleted_count, _ = Session.objects.filter(user_id=str(user.id)).delete()
try:
user.last_logout_ip = user_ip(request=request)
user.last_logout_time = timezone.now()
user.save(update_fields=["last_logout_ip", "last_logout_time", "updated_at"])
except Exception:
pass
return deleted_count
class NodeDCFrontChannelLogoutEndpoint(View):
def get(self, request):
logout_current_user(request)
response = HttpResponse(
"<!doctype html><html><head><meta charset='utf-8'></head><body>NODE.DC Task session closed.</body></html>",
"<!doctype html><html><head><meta charset='utf-8'></head>"
"<body>NODE.DC Task session closed.</body></html>",
content_type="text/html",
)
return clear_nodedc_auth_cookies(response, request)
@ -78,3 +239,46 @@ class NodeDCFrontChannelLogoutEndpoint(View):
logout_current_user(request)
response = HttpResponseRedirect(get_logout_redirect_url("/"))
return clear_nodedc_auth_cookies(response, request)
@method_decorator(csrf_exempt, name="dispatch")
class NodeDCInternalSessionLogoutEndpoint(View):
http_method_names = ["post"]
def post(self, request):
if not is_internal_logout_request_authorized(request):
return JsonResponse(
{
"ok": False,
"error": "internal_logout_unauthorized"
if get_nodedc_internal_token()
else "internal_logout_not_configured",
},
status=401 if get_nodedc_internal_token() else 503,
)
try:
payload = parse_json_body(request)
except (TypeError, ValueError, json.JSONDecodeError):
return JsonResponse({"ok": False, "error": "invalid_json"}, status=400)
user = find_logout_user(payload)
if user is None:
mark_logout_guard(payload=payload)
return JsonResponse({"ok": True, "deletedSessions": 0, "user": None})
guard_keys = mark_logout_guard(user=user, payload=payload)
deleted_sessions = invalidate_user_sessions(user, request)
return JsonResponse(
{
"ok": True,
"deletedSessions": deleted_sessions,
"guardKeys": len(guard_keys),
"user": {
"id": str(user.id),
"email": user.email,
},
}
)

View File

@ -11,17 +11,29 @@ from drf_spectacular.views import (
SpectacularRedocView,
SpectacularSwaggerView,
)
from plane.authentication.views.nodedc_logout import NodeDCFrontChannelLogoutEndpoint
from plane.authentication.views.nodedc_logout import (
NodeDCFrontChannelLogoutEndpoint,
NodeDCInternalSessionLogoutEndpoint,
)
handler404 = "plane.app.views.error_404.custom_404_view"
urlpatterns = [
path(
"api/internal/nodedc/logout/",
NodeDCInternalSessionLogoutEndpoint.as_view(),
name="nodedc-internal-session-logout",
),
path("api/", include("plane.app.urls")),
path("api/public/", include("plane.space.urls")),
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(
"logout",
NodeDCFrontChannelLogoutEndpoint.as_view(),
name="nodedc-frontchannel-logout",
),
path("", include("plane.web.urls")),
]

View File

@ -16,12 +16,5 @@ export function NodeDCAuthRedirect() {
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>
);
return <div className="h-screen w-screen bg-canvas" aria-hidden="true" />;
}