ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: Tasker OIDC handoff и автопровижининг
This commit is contained in:
parent
a111574039
commit
73f0e41e79
|
|
@ -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:-}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue