АРХ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: Plane OIDC mapping для существующего пользователя

This commit is contained in:
DCCONSTRUCTIONS
2026-05-04 13:01:21 +03:00
parent 119d503d96
commit 561d1eeef5
10 changed files with 1244 additions and 1 deletions
@@ -21,6 +21,8 @@ from .views import (
MagicGenerateEndpoint,
MagicSignInEndpoint,
MagicSignUpEndpoint,
NodeDCOIDCCallbackEndpoint,
NodeDCOIDCInitiateEndpoint,
SignInAuthEndpoint,
SignOutAuthEndpoint,
SignUpAuthEndpoint,
@@ -50,6 +52,9 @@ urlpatterns = [
# credentials
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"),
path("oidc/callback/", NodeDCOIDCCallbackEndpoint.as_view(), name="nodedc-oidc-callback"),
path("oidc/callback", NodeDCOIDCCallbackEndpoint.as_view(), name="nodedc-oidc-callback-no-slash"),
path("spaces/sign-in/", SignInAuthSpaceEndpoint.as_view(), name="space-sign-in"),
path("spaces/sign-up/", SignUpAuthSpaceEndpoint.as_view(), name="space-sign-up"),
# signout
@@ -12,6 +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.signout import SignOutAuthEndpoint
@@ -0,0 +1,214 @@
import base64
import hashlib
import os
import secrets
from urllib.parse import urlencode
import jwt
import requests
from django.http import HttpResponseRedirect
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.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"
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"
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"):
return oidc_error_redirect(base_url, next_path, "oidc_provider_error")
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")
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):
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"])
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={}))
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",
}
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):
subject = str(claims.get("sub") or "")
email = str(claims.get("email") or "").strip().lower()
if not subject:
return None
link = ExternalIdentityLink.objects.select_related("user").filter(
provider=OIDC_PROVIDER,
subject=subject,
status=ExternalIdentityLink.Status.ACTIVE,
).first()
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:
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()
user.save(update_fields=["last_login_medium", "last_login_time", "updated_at"])
return user
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}))
@@ -0,0 +1,48 @@
from django.core.management import BaseCommand, CommandError
from plane.db.models import ExternalIdentityLink, User
class Command(BaseCommand):
help = "Link an existing Plane user to an Authentik OIDC subject"
def add_arguments(self, parser):
parser.add_argument("--email", required=True, help="Existing Plane user email")
parser.add_argument("--sub", required=True, help="Authentik OIDC subject")
parser.add_argument("--dry-run", action="store_true", help="Validate without writing")
def handle(self, *args, **options):
email = options["email"].strip().lower()
subject = options["sub"].strip()
dry_run = options["dry_run"]
if not email or not subject:
raise CommandError("--email and --sub are required")
user = User.objects.filter(email__iexact=email).first()
if user is None:
raise CommandError(f"Plane user not found: {email}")
existing_subject_link = ExternalIdentityLink.objects.filter(provider="authentik", subject=subject).first()
if existing_subject_link and existing_subject_link.user_id != user.id:
raise CommandError(f"Subject is already linked to another Plane user: {existing_subject_link.user.email}")
existing_user_link = ExternalIdentityLink.objects.filter(provider="authentik", user=user).exclude(subject=subject).first()
if existing_user_link:
raise CommandError(f"Plane user is already linked to another Authentik subject: {existing_user_link.subject}")
if dry_run:
self.stdout.write(self.style.SUCCESS(f"Dry run OK: {email} can be linked to {subject}"))
return
link, created = ExternalIdentityLink.objects.update_or_create(
provider="authentik",
subject=subject,
defaults={
"user": user,
"email": email,
"status": ExternalIdentityLink.Status.ACTIVE,
},
)
action = "created" if created else "updated"
self.stdout.write(self.style.SUCCESS(f"Authentik link {action}: {user.email} -> {link.subject}"))
@@ -0,0 +1,79 @@
import uuid
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("db", "0136_workspace_member_ban"),
]
operations = [
migrations.CreateModel(
name="ExternalIdentityLink",
fields=[
(
"created_at",
models.DateTimeField(auto_now_add=True, verbose_name="Created At"),
),
(
"updated_at",
models.DateTimeField(auto_now=True, verbose_name="Last Modified At"),
),
("deleted_at", models.DateTimeField(blank=True, null=True)),
(
"id",
models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True, db_index=True),
),
("provider", models.CharField(max_length=64)),
("subject", models.CharField(max_length=255)),
("email", models.CharField(max_length=255)),
("groups", models.JSONField(default=list)),
(
"status",
models.CharField(
choices=[("active", "Active"), ("disabled", "Disabled")],
default="active",
max_length=32,
),
),
("last_login_at", models.DateTimeField(blank=True, null=True)),
(
"created_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="externalidentitylink_created_by",
to="db.user",
),
),
(
"updated_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="externalidentitylink_updated_by",
to="db.user",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="external_identity_links",
to="db.user",
),
),
],
options={
"verbose_name": "External Identity Link",
"verbose_name_plural": "External Identity Links",
"db_table": "external_identity_links",
"ordering": ("-created_at",),
"unique_together": {("provider", "subject")},
},
),
]
@@ -0,0 +1,14 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("db", "0137_external_identity_link"),
]
operations = [
migrations.AlterUniqueTogether(
name="externalidentitylink",
unique_together={("provider", "subject"), ("provider", "user")},
),
]
@@ -62,7 +62,7 @@ from .project import (
from .session import Session
from .social_connection import SocialLoginConnection
from .state import State, StateGroup, DEFAULT_STATES
from .user import Account, Profile, User, BotTypeEnum
from .user import Account, ExternalIdentityLink, Profile, User, BotTypeEnum
from .view import IssueView
from .webhook import Webhook, WebhookLog
from .voice_tasker import VoiceTaskSession, WorkspaceAICredential, WorkspaceAISettings
@@ -295,6 +295,31 @@ class Account(TimeAuditModel):
ordering = ("-created_at",)
class ExternalIdentityLink(TimeAuditModel):
class Status(models.TextChoices):
ACTIVE = "active", "Active"
DISABLED = "disabled", "Disabled"
id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True)
provider = models.CharField(max_length=64)
subject = models.CharField(max_length=255)
user = models.ForeignKey("db.User", on_delete=models.CASCADE, related_name="external_identity_links")
email = models.CharField(max_length=255)
groups = models.JSONField(default=list)
status = models.CharField(max_length=32, choices=Status.choices, default=Status.ACTIVE)
last_login_at = models.DateTimeField(blank=True, null=True)
class Meta:
unique_together = [
["provider", "subject"],
["provider", "user"],
]
verbose_name = "External Identity Link"
verbose_name_plural = "External Identity Links"
db_table = "external_identity_links"
ordering = ("-created_at",)
@receiver(post_save, sender=User)
def create_user_notification(sender, instance, created, **kwargs):
# create preferences