diff --git a/plane-src/apps/api/plane/authentication/urls.py b/plane-src/apps/api/plane/authentication/urls.py index ec44e24..1e595fd 100644 --- a/plane-src/apps/api/plane/authentication/urls.py +++ b/plane-src/apps/api/plane/authentication/urls.py @@ -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"), diff --git a/plane-src/apps/api/plane/authentication/views/app/oidc.py b/plane-src/apps/api/plane/authentication/views/app/oidc.py index 7ca52b2..7db7677 100644 --- a/plane-src/apps/api/plane/authentication/views/app/oidc.py +++ b/plane-src/apps/api/plane/authentication/views/app/oidc.py @@ -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}") diff --git a/plane-src/apps/api/plane/authentication/views/nodedc_logout.py b/plane-src/apps/api/plane/authentication/views/nodedc_logout.py index 2adf281..ba5445e 100644 --- a/plane-src/apps/api/plane/authentication/views/nodedc_logout.py +++ b/plane-src/apps/api/plane/authentication/views/nodedc_logout.py @@ -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( - "
NODE.DC Task session closed.", + "" + "NODE.DC Task session closed.", 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, + }, + } + ) diff --git a/plane-src/apps/api/plane/urls.py b/plane-src/apps/api/plane/urls.py index 788957e..0975316 100644 --- a/plane-src/apps/api/plane/urls.py +++ b/plane-src/apps/api/plane/urls.py @@ -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")), ] diff --git a/plane-src/apps/web/core/components/auth-screens/nodedc-auth-redirect.tsx b/plane-src/apps/web/core/components/auth-screens/nodedc-auth-redirect.tsx index 2930651..74589d1 100644 --- a/plane-src/apps/web/core/components/auth-screens/nodedc-auth-redirect.tsx +++ b/plane-src/apps/web/core/components/auth-screens/nodedc-auth-redirect.tsx @@ -16,12 +16,5 @@ export function NodeDCAuthRedirect() { window.location.replace(buildNodeDCOIDCLoginUrl(nextPath)); }, []); - return ( -