568 lines
19 KiB
Python
568 lines
19 KiB
Python
import base64
|
|
import hashlib
|
|
import logging
|
|
import os
|
|
import secrets
|
|
from urllib.parse import urlencode
|
|
|
|
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.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):
|
|
def get(self, request):
|
|
config = get_oidc_config()
|
|
next_path = validate_next_path(request.GET.get("next_path", ""))
|
|
discovery = load_discovery(config["issuer"])
|
|
state = secrets.token_urlsafe(32)
|
|
nonce = secrets.token_urlsafe(32)
|
|
code_verifier = secrets.token_urlsafe(64)
|
|
code_challenge = base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode()).digest()).decode().rstrip("=")
|
|
|
|
request.session[OIDC_SESSION_KEY] = {
|
|
"state": state,
|
|
"nonce": nonce,
|
|
"code_verifier": code_verifier,
|
|
"next_path": next_path,
|
|
}
|
|
request.session.save()
|
|
|
|
params = {
|
|
"response_type": "code",
|
|
"client_id": config["client_id"],
|
|
"redirect_uri": config["redirect_uri"],
|
|
"scope": config["scope"],
|
|
"state": state,
|
|
"nonce": nonce,
|
|
"code_challenge": code_challenge,
|
|
"code_challenge_method": "S256",
|
|
}
|
|
|
|
if request.GET.get("prompt") == "login":
|
|
params["prompt"] = "login"
|
|
params["max_age"] = "0"
|
|
|
|
return HttpResponseRedirect(f"{discovery['authorization_endpoint']}?{urlencode(params)}")
|
|
|
|
|
|
class NodeDCOIDCCallbackEndpoint(View):
|
|
def get(self, request):
|
|
config = get_oidc_config()
|
|
oidc_session = request.session.get(OIDC_SESSION_KEY) or {}
|
|
next_path = oidc_session.get("next_path", "")
|
|
base_url = base_host(request=request, is_app=True)
|
|
|
|
if request.GET.get("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:
|
|
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)
|
|
|
|
groups = normalize_groups(claims.get("groups"))
|
|
|
|
if not has_required_group(groups):
|
|
return oidc_error_redirect(base_url, next_path, "oidc_access_denied")
|
|
|
|
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, "oidc_user_not_linked")
|
|
|
|
request.session.pop(OIDC_SESSION_KEY, None)
|
|
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={}))
|
|
|
|
|
|
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()
|
|
client_secret = os.environ.get("PLANE_OIDC_CLIENT_SECRET", "").strip()
|
|
redirect_uri = os.environ.get("PLANE_OIDC_REDIRECT_URI", "").strip()
|
|
|
|
if not issuer or not client_id or not client_secret or not redirect_uri:
|
|
raise RuntimeError("Plane OIDC is not configured")
|
|
|
|
return {
|
|
"issuer": issuer.rstrip("/") + "/",
|
|
"client_id": client_id,
|
|
"client_secret": client_secret,
|
|
"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()
|
|
return response.json()
|
|
|
|
|
|
def exchange_code(discovery, config, code, code_verifier):
|
|
response = requests.post(
|
|
discovery["token_endpoint"],
|
|
data={
|
|
"grant_type": "authorization_code",
|
|
"code": code,
|
|
"redirect_uri": config["redirect_uri"],
|
|
"code_verifier": code_verifier,
|
|
},
|
|
auth=(config["client_id"], config["client_secret"]),
|
|
timeout=10,
|
|
)
|
|
response.raise_for_status()
|
|
token_set = response.json()
|
|
|
|
if not token_set.get("id_token"):
|
|
raise RuntimeError("OIDC token response does not contain id_token")
|
|
|
|
return token_set
|
|
|
|
|
|
def verify_id_token(discovery, config, id_token, nonce):
|
|
jwks_client = jwt.PyJWKClient(discovery["jwks_uri"])
|
|
signing_key = jwks_client.get_signing_key_from_jwt(id_token)
|
|
claims = jwt.decode(
|
|
id_token,
|
|
signing_key.key,
|
|
algorithms=["RS256"],
|
|
audience=config["client_id"],
|
|
issuer=discovery.get("issuer", config["issuer"]),
|
|
)
|
|
|
|
if claims.get("nonce") != nonce:
|
|
raise RuntimeError("OIDC nonce validation failed")
|
|
|
|
return claims
|
|
|
|
|
|
def normalize_groups(groups):
|
|
if isinstance(groups, list):
|
|
return list(dict.fromkeys(group for group in groups if isinstance(group, str)))
|
|
if isinstance(groups, str) and groups:
|
|
return [groups]
|
|
return []
|
|
|
|
|
|
def has_required_group(groups):
|
|
required_groups = {
|
|
group.strip()
|
|
for group in os.environ.get("PLANE_OIDC_REQUIRED_GROUPS", DEFAULT_REQUIRED_GROUPS).split(",")
|
|
if group.strip()
|
|
}
|
|
return bool(required_groups.intersection(set(groups)))
|
|
|
|
|
|
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 or not email:
|
|
return None
|
|
|
|
link = ExternalIdentityLink.objects.select_related("user").filter(
|
|
provider=OIDC_PROVIDER,
|
|
subject=subject,
|
|
).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:
|
|
link, _ = ExternalIdentityLink.objects.get_or_create(
|
|
provider=OIDC_PROVIDER,
|
|
subject=subject,
|
|
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
|
|
link.groups = groups
|
|
link.last_login_at = timezone.now()
|
|
link.save(update_fields=["email", "groups", "last_login_at", "updated_at"])
|
|
|
|
user = link.user
|
|
user.last_login_medium = OIDC_PROVIDER
|
|
user.last_login_time = timezone.now()
|
|
update_fields = ["last_login_medium", "last_login_time", "updated_at"]
|
|
|
|
if 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")
|
|
given_name = first_string_claim(claims, "given_name")
|
|
family_name = first_string_claim(claims, "family_name")
|
|
avatar_url = first_string_claim(claims, "picture", "avatar_url", "avatar")
|
|
|
|
if display_name and user.display_name != display_name:
|
|
user.display_name = display_name
|
|
updated_fields.append("display_name")
|
|
|
|
if not given_name and display_name:
|
|
name_parts = display_name.split(" ", 1)
|
|
given_name = name_parts[0]
|
|
family_name = family_name or (name_parts[1] if len(name_parts) > 1 else "")
|
|
|
|
if given_name and user.first_name != given_name:
|
|
user.first_name = given_name
|
|
updated_fields.append("first_name")
|
|
|
|
if family_name is not None and user.last_name != family_name:
|
|
user.last_name = family_name
|
|
updated_fields.append("last_name")
|
|
|
|
if avatar_url and user.avatar != avatar_url:
|
|
user.avatar = avatar_url
|
|
updated_fields.append("avatar")
|
|
|
|
return updated_fields
|
|
|
|
|
|
def first_string_claim(claims, *keys):
|
|
for key in keys:
|
|
value = claims.get(key)
|
|
if isinstance(value, str) and value:
|
|
return value
|
|
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 ""
|
|
|
|
|
|
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}")
|