ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: устойчивый global logout и auth fallback
This commit is contained in:
parent
ed18a07154
commit
4400b7f438
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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")),
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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" />;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue