ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: Tasker OIDC handoff и автопровижининг

This commit is contained in:
DCCONSTRUCTIONS 2026-05-06 08:59:01 +03:00
parent a111574039
commit 73f0e41e79
5 changed files with 223 additions and 5 deletions

View File

@ -61,7 +61,9 @@ x-app-env: &app-env
PLANE_OIDC_SCOPE: ${PLANE_OIDC_SCOPE:-openid email profile groups}
PLANE_OIDC_REQUIRED_GROUPS: ${PLANE_OIDC_REQUIRED_GROUPS:-nodedc:superadmin,nodedc:taskmanager:admin,nodedc:taskmanager:user}
PLANE_OIDC_AUTO_LINK_EMAIL: ${PLANE_OIDC_AUTO_LINK_EMAIL:-0}
PLANE_OIDC_AUTO_CREATE_USER: ${PLANE_OIDC_AUTO_CREATE_USER:-0}
PLANE_OIDC_SYNC_PROFILE: ${PLANE_OIDC_SYNC_PROFILE:-1}
PLANE_NODEDC_SKIP_PROFILE_ONBOARDING: ${PLANE_NODEDC_SKIP_PROFILE_ONBOARDING:-0}
PLANE_NODEDC_ACCESS_ENFORCEMENT: ${PLANE_NODEDC_ACCESS_ENFORCEMENT:-0}
PLANE_NODEDC_ACCESS_CHECK_URL: ${PLANE_NODEDC_ACCESS_CHECK_URL:-}
PLANE_NODEDC_ACCESS_TOKEN: ${PLANE_NODEDC_ACCESS_TOKEN:-}
@ -70,6 +72,8 @@ x-app-env: &app-env
PLANE_NODEDC_ACCESS_CACHE_SECONDS: ${PLANE_NODEDC_ACCESS_CACHE_SECONDS:-0}
PLANE_NODEDC_ACCESS_DENIED_REDIRECT_URL: ${PLANE_NODEDC_ACCESS_DENIED_REDIRECT_URL:-http://launcher.local.nodedc/}
PLANE_NODEDC_GLOBAL_LOGOUT_URL: ${PLANE_NODEDC_GLOBAL_LOGOUT_URL:-http://launcher.local.nodedc/auth/logout?global=1&returnTo=/}
PLANE_NODEDC_HANDOFF_URL: ${PLANE_NODEDC_HANDOFF_URL:-http://launcher.local.nodedc/api/internal/handoff/consume}
PLANE_NODEDC_HANDOFF_TIMEOUT_SECONDS: ${PLANE_NODEDC_HANDOFF_TIMEOUT_SECONDS:-3}
GUNICORN_WORKERS: 1
POSTHOG_API_KEY: ${POSTHOG_API_KEY:-}
POSTHOG_HOST: ${POSTHOG_HOST:-}

View File

@ -101,6 +101,8 @@ PLANE_OIDC_REDIRECT_URI=http://task.local.nodedc/auth/oidc/callback
PLANE_OIDC_SCOPE=openid email profile groups
PLANE_OIDC_REQUIRED_GROUPS=nodedc:superadmin,nodedc:taskmanager:admin,nodedc:taskmanager:user
PLANE_OIDC_AUTO_LINK_EMAIL=1
PLANE_OIDC_AUTO_CREATE_USER=1
PLANE_NODEDC_SKIP_PROFILE_ONBOARDING=1
PLANE_NODEDC_ACCESS_ENFORCEMENT=1
PLANE_NODEDC_ACCESS_CHECK_URL=http://launcher.local.nodedc/api/internal/access/check
PLANE_NODEDC_ACCESS_TOKEN=
@ -109,3 +111,5 @@ PLANE_NODEDC_ACCESS_TIMEOUT_SECONDS=3
PLANE_NODEDC_ACCESS_CACHE_SECONDS=0
PLANE_NODEDC_ACCESS_DENIED_REDIRECT_URL=http://launcher.local.nodedc/
PLANE_NODEDC_GLOBAL_LOGOUT_URL=http://launcher.local.nodedc/auth/logout?global=1&returnTo=/
PLANE_NODEDC_HANDOFF_URL=http://launcher.local.nodedc/api/internal/handoff/consume
PLANE_NODEDC_HANDOFF_TIMEOUT_SECONDS=3

View File

@ -21,6 +21,7 @@ from .views import (
MagicGenerateEndpoint,
MagicSignInEndpoint,
MagicSignUpEndpoint,
NodeDCHandoffEndpoint,
NodeDCOIDCCallbackEndpoint,
NodeDCOIDCInitiateEndpoint,
SignInAuthEndpoint,
@ -56,6 +57,7 @@ urlpatterns = [
path("oidc/login/", NodeDCOIDCInitiateEndpoint.as_view(), name="nodedc-oidc-login"),
path("oidc/callback/", NodeDCOIDCCallbackEndpoint.as_view(), name="nodedc-oidc-callback"),
path("oidc/callback", NodeDCOIDCCallbackEndpoint.as_view(), name="nodedc-oidc-callback-no-slash"),
path("nodedc/handoff/", NodeDCHandoffEndpoint.as_view(), name="nodedc-handoff"),
path("spaces/sign-in/", SignInAuthSpaceEndpoint.as_view(), name="space-sign-in"),
path("spaces/sign-up/", SignUpAuthSpaceEndpoint.as_view(), name="space-sign-up"),
# signout

View File

@ -12,7 +12,7 @@ from .app.gitlab import GitLabCallbackEndpoint, GitLabOauthInitiateEndpoint
from .app.gitea import GiteaCallbackEndpoint, GiteaOauthInitiateEndpoint
from .app.google import GoogleCallbackEndpoint, GoogleOauthInitiateEndpoint
from .app.magic import MagicGenerateEndpoint, MagicSignInEndpoint, MagicSignUpEndpoint
from .app.oidc import NodeDCOIDCCallbackEndpoint, NodeDCOIDCInitiateEndpoint
from .app.oidc import NodeDCHandoffEndpoint, NodeDCOIDCCallbackEndpoint, NodeDCOIDCInitiateEndpoint
from .app.signout import SignOutAuthEndpoint

View File

@ -1,5 +1,6 @@
import base64
import hashlib
import logging
import os
import secrets
from urllib.parse import urlencode
@ -8,19 +9,22 @@ import jwt
import requests
from django.core.cache import cache
from django.http import HttpResponseRedirect
from django.utils.crypto import get_random_string
from django.utils import timezone
from django.views import View
from plane.authentication.utils.host import base_host
from plane.authentication.utils.login import user_login
from plane.authentication.utils.redirection_path import get_redirection_path
from plane.db.models import ExternalIdentityLink, User
from plane.authentication.views.nodedc_logout import get_nodedc_internal_token
from plane.db.models import ExternalIdentityLink, Profile, User
from plane.utils.path_validator import get_safe_redirect_url, validate_next_path
OIDC_SESSION_KEY = "nodedc_oidc"
OIDC_PROVIDER = "authentik"
DEFAULT_REQUIRED_GROUPS = "nodedc:superadmin,nodedc:taskmanager:admin,nodedc:taskmanager:user"
logger = logging.getLogger(__name__)
class NodeDCOIDCInitiateEndpoint(View):
@ -98,7 +102,9 @@ class NodeDCOIDCCallbackEndpoint(View):
claims=claims,
groups=groups,
auto_link=config["auto_link_email"],
auto_create=config["auto_create_user"],
sync_profile=config["sync_profile"],
skip_profile_onboarding=config["skip_profile_onboarding"],
)
if user is None or not user.is_active:
@ -111,6 +117,53 @@ class NodeDCOIDCCallbackEndpoint(View):
return HttpResponseRedirect(get_safe_redirect_url(base_url=base_url, next_path=path, params={}))
class NodeDCHandoffEndpoint(View):
def get(self, request):
config = get_oidc_config()
base_url = base_host(request=request, is_app=True)
next_path = validate_next_path(request.GET.get("next_path", ""))
token = request.GET.get("token", "")
try:
handoff = consume_launcher_handoff(token)
except (RuntimeError, requests.RequestException, ValueError):
logger.warning("NODEDC handoff failed", exc_info=True)
return oidc_login_redirect(base_url, next_path)
handoff_user = handoff.get("user") or {}
groups = normalize_groups(handoff_user.get("groups"))
if not has_required_group(groups):
return oidc_error_redirect(base_url, next_path, "handoff_access_denied")
claims = {
"sub": str(handoff_user.get("subject") or handoff_user.get("authentikUserId") or handoff_user.get("id") or ""),
"email": str(handoff_user.get("email") or "").strip().lower(),
"name": handoff_user.get("name"),
"preferred_username": handoff_user.get("email"),
"picture": handoff_user.get("avatarUrl"),
"groups": groups,
"email_verified": True,
}
user = resolve_linked_user(
claims=claims,
groups=groups,
auto_link=config["auto_link_email"],
auto_create=config["auto_create_user"],
sync_profile=config["sync_profile"],
skip_profile_onboarding=config["skip_profile_onboarding"],
)
if user is None or not user.is_active:
return oidc_error_redirect(base_url, next_path, "handoff_user_not_linked")
user_login(request=request, user=user, is_app=True)
path = next_path or get_redirection_path(user=user)
return HttpResponseRedirect(get_safe_redirect_url(base_url=base_url, next_path=path, params={}))
def get_oidc_config():
issuer = os.environ.get("PLANE_OIDC_ISSUER", "").strip()
client_id = os.environ.get("PLANE_OIDC_CLIENT_ID", "").strip()
@ -127,10 +180,44 @@ def get_oidc_config():
"redirect_uri": redirect_uri,
"scope": os.environ.get("PLANE_OIDC_SCOPE", "openid email profile groups"),
"auto_link_email": os.environ.get("PLANE_OIDC_AUTO_LINK_EMAIL", "0") == "1",
"auto_create_user": os.environ.get("PLANE_OIDC_AUTO_CREATE_USER", "0") == "1",
"sync_profile": os.environ.get("PLANE_OIDC_SYNC_PROFILE", "1") == "1",
"skip_profile_onboarding": os.environ.get("PLANE_NODEDC_SKIP_PROFILE_ONBOARDING", "0") == "1",
"handoff_url": os.environ.get(
"PLANE_NODEDC_HANDOFF_URL",
"http://launcher.local.nodedc/api/internal/handoff/consume",
).strip(),
}
def consume_launcher_handoff(token):
if not token:
raise RuntimeError("NODEDC handoff token is missing")
config = get_oidc_config()
internal_token = get_nodedc_internal_token()
if not config["handoff_url"] or not internal_token:
raise RuntimeError("NODEDC handoff is not configured")
response = requests.post(
config["handoff_url"],
json={
"token": token,
"serviceSlug": os.environ.get("PLANE_NODEDC_ACCESS_SERVICE_SLUG", "task-manager"),
},
headers={"Authorization": f"Bearer {internal_token}"},
timeout=float(os.environ.get("PLANE_NODEDC_HANDOFF_TIMEOUT_SECONDS", "3")),
)
response.raise_for_status()
payload = response.json()
if not payload.get("ok"):
raise RuntimeError("NODEDC handoff was rejected")
return payload
def load_discovery(issuer):
response = requests.get(f"{issuer}.well-known/openid-configuration", timeout=10)
response.raise_for_status()
@ -192,19 +279,26 @@ def has_required_group(groups):
return bool(required_groups.intersection(set(groups)))
def resolve_linked_user(claims, groups, auto_link, sync_profile):
def resolve_linked_user(claims, groups, auto_link, auto_create, sync_profile, skip_profile_onboarding):
subject = str(claims.get("sub") or "")
email = str(claims.get("email") or "").strip().lower()
if not subject:
if not subject or not email:
return None
link = ExternalIdentityLink.objects.select_related("user").filter(
provider=OIDC_PROVIDER,
subject=subject,
status=ExternalIdentityLink.Status.ACTIVE,
).first()
if link and link.status != ExternalIdentityLink.Status.ACTIVE:
logger.warning(
"NODEDC OIDC denied disabled external identity link: provider=%s subject_hash=%s",
OIDC_PROVIDER,
hash_subject(subject),
)
return None
if link is None and auto_link and email:
user = User.objects.filter(email__iexact=email, is_active=True).first()
if user:
@ -214,7 +308,28 @@ def resolve_linked_user(claims, groups, auto_link, sync_profile):
defaults={"user": user, "email": email, "groups": groups},
)
if link is None and auto_create and email:
user, user_created = get_or_create_oidc_user(email=email, claims=claims)
link, _ = ExternalIdentityLink.objects.get_or_create(
provider=OIDC_PROVIDER,
subject=subject,
defaults={"user": user, "email": email, "groups": groups},
)
if user_created:
logger.info(
"NODEDC OIDC provisioned Tasker user: user_id=%s email_hash=%s subject_hash=%s",
user.id,
hash_email(email),
hash_subject(subject),
)
if link is None:
logger.warning(
"NODEDC OIDC user is not linked: provider=%s email_hash=%s subject_hash=%s",
OIDC_PROVIDER,
hash_email(email),
hash_subject(subject),
)
return None
link.email = email or link.email
@ -231,9 +346,92 @@ def resolve_linked_user(claims, groups, auto_link, sync_profile):
update_fields.extend(sync_user_profile_from_claims(user, claims))
user.save(update_fields=list(dict.fromkeys(update_fields)))
if skip_profile_onboarding:
ensure_nodedc_profile_onboarded(user)
return user
def get_or_create_oidc_user(email, claims):
user = User.objects.filter(email__iexact=email).first()
if user:
return user, False
username = build_unique_username(email)
display_name = first_string_claim(claims, "name", "preferred_username") or User.get_display_name(email)
given_name = first_string_claim(claims, "given_name") or ""
family_name = first_string_claim(claims, "family_name") or ""
avatar_url = first_string_claim(claims, "picture", "avatar_url", "avatar") or ""
user = User.objects.create_user(
email=email,
username=username,
display_name=display_name,
first_name=given_name,
last_name=family_name,
avatar=avatar_url,
is_active=True,
is_managed=True,
is_password_autoset=True,
is_email_verified=bool(claims.get("email_verified", False)),
last_login_medium=OIDC_PROVIDER,
last_login_time=timezone.now(),
)
return user, True
def ensure_nodedc_profile_onboarded(user):
profile, _ = Profile.objects.get_or_create(user=user)
onboarding_step = dict(profile.onboarding_step or {})
required_onboarding_step = {
"profile_complete": True,
"workspace_create": True,
"workspace_invite": True,
"workspace_join": True,
}
next_onboarding_step = {**onboarding_step, **required_onboarding_step}
update_fields = []
if not profile.is_onboarded:
profile.is_onboarded = True
update_fields.append("is_onboarded")
if not profile.is_tour_completed:
profile.is_tour_completed = True
update_fields.append("is_tour_completed")
if profile.onboarding_step != next_onboarding_step:
profile.onboarding_step = next_onboarding_step
update_fields.append("onboarding_step")
if update_fields:
update_fields.append("updated_at")
profile.save(update_fields=list(dict.fromkeys(update_fields)))
def build_unique_username(email):
base_username = email.split("@", 1)[0].strip().lower() or "nodedc-user"
username = normalize_username(base_username)
if not User.objects.filter(username=username).exists():
return username
for _ in range(10):
candidate = f"{username}-{get_random_string(6).lower()}"
if not User.objects.filter(username=candidate).exists():
return candidate
return f"{username}-{get_random_string(12).lower()}"
def normalize_username(value):
normalized = "".join(char if char.isalnum() or char in {"_", "-", "."} else "-" for char in value)
normalized = normalized.strip("-._")
return normalized[:96] or "nodedc-user"
def sync_user_profile_from_claims(user, claims):
updated_fields = []
display_name = first_string_claim(claims, "name", "preferred_username")
@ -273,6 +471,16 @@ def first_string_claim(claims, *keys):
return None
def hash_email(email):
normalized_email = str(email or "").strip().lower()
return hashlib.sha256(normalized_email.encode()).hexdigest()[:12] if normalized_email else ""
def hash_subject(subject):
normalized_subject = str(subject or "").strip()
return hashlib.sha256(normalized_subject.encode()).hexdigest()[:12] if normalized_subject else ""
def normalize_logout_guard_value(value):
return value.strip().lower() if isinstance(value, str) else ""