АРХ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: Plane OIDC mapping для существующего пользователя
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user