From e82fe7e87eee8d0eecc63f500d5aa56efd45c210 Mon Sep 17 00:00:00 2001 From: jebus Date: Wed, 10 Sep 2025 08:29:27 +1200 Subject: [PATCH] [Feature] Initial Active Directory / Entra Login (#101) Co-authored-by: Tim Lorsbach Reviewed-on: https://git.envipath.com/enviPath/enviPy/pulls/101 --- envipath/settings.py | 14 +++++++- envipath/urls.py | 1 + epauth/__init__.py | 0 epauth/admin.py | 3 ++ epauth/apps.py | 6 ++++ epauth/migrations/__init__.py | 0 epauth/models.py | 3 ++ epauth/tests.py | 3 ++ epauth/urls.py | 8 +++++ epauth/views.py | 66 +++++++++++++++++++++++++++++++++++ epdb/logic.py | 14 +++++--- pyproject.toml | 3 ++ uv.lock | 30 ++++++++++++++++ 13 files changed, 146 insertions(+), 5 deletions(-) create mode 100644 epauth/__init__.py create mode 100644 epauth/admin.py create mode 100644 epauth/apps.py create mode 100644 epauth/migrations/__init__.py create mode 100644 epauth/models.py create mode 100644 epauth/tests.py create mode 100644 epauth/urls.py create mode 100644 epauth/views.py diff --git a/envipath/settings.py b/envipath/settings.py index baf7aa8a..af75b626 100644 --- a/envipath/settings.py +++ b/envipath/settings.py @@ -47,6 +47,7 @@ INSTALLED_APPS = [ # Custom 'epdb', 'migration', + 'epauth', ] AUTHENTICATION_BACKENDS = [ @@ -351,5 +352,16 @@ LOGIN_EXEMPT_URLS = [ '/o/token/', '/o/userinfo/', '/password_reset/', - '/reset/' + '/reset/', + '/microsoft/', ] + +# MS AD/Entra +MS_ENTRA_ENABLED = os.environ.get('MS_ENTRA_ENABLED', 'False') == 'True' +if MS_ENTRA_ENABLED: + MS_ENTRA_CLIENT_ID = os.environ['MS_CLIENT_ID'] + MS_ENTRA_CLIENT_SECRET = os.environ['MS_CLIENT_SECRET'] + MS_ENTRA_TENANT_ID = os.environ['MS_TENANT_ID'] + MS_ENTRA_AUTHORITY = f"https://login.microsoftonline.com/{MS_ENTRA_TENANT_ID}" + MS_ENTRA_REDIRECT_URI = os.environ['MS_REDIRECT_URI'] + MS_ENTRA_SCOPES = os.environ.get('MS_SCOPES', '').split(',') diff --git a/envipath/urls.py b/envipath/urls.py index 7876cdd0..d38dfda5 100644 --- a/envipath/urls.py +++ b/envipath/urls.py @@ -20,6 +20,7 @@ from django.urls import include, path from .api import api_v1, api_legacy urlpatterns = [ + path("", include("epauth.urls")), path("", include("epdb.urls")), path("", include("migration.urls")), path("admin/", admin.site.urls), diff --git a/epauth/__init__.py b/epauth/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/epauth/admin.py b/epauth/admin.py new file mode 100644 index 00000000..8c38f3f3 --- /dev/null +++ b/epauth/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/epauth/apps.py b/epauth/apps.py new file mode 100644 index 00000000..29d64f2d --- /dev/null +++ b/epauth/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class EpauthConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'epauth' diff --git a/epauth/migrations/__init__.py b/epauth/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/epauth/models.py b/epauth/models.py new file mode 100644 index 00000000..71a83623 --- /dev/null +++ b/epauth/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/epauth/tests.py b/epauth/tests.py new file mode 100644 index 00000000..7ce503c2 --- /dev/null +++ b/epauth/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/epauth/urls.py b/epauth/urls.py new file mode 100644 index 00000000..853e7add --- /dev/null +++ b/epauth/urls.py @@ -0,0 +1,8 @@ +from django.urls import path + +from . import views + +urlpatterns = [ + path("microsoft/login/", views.microsoft_login, name="microsoft_login"), + path("microsoft/callback/", views.microsoft_callback, name="microsoft_callback"), +] diff --git a/epauth/views.py b/epauth/views.py new file mode 100644 index 00000000..f6253cb1 --- /dev/null +++ b/epauth/views.py @@ -0,0 +1,66 @@ +import msal +from django.conf import settings as s +from django.contrib.auth import login +from django.shortcuts import redirect +from django.contrib.auth import get_user_model + +from epdb.logic import UserManager + + +def microsoft_login(request): + msal_app = msal.ConfidentialClientApplication( + client_id=s.MS_ENTRA_CLIENT_ID, + client_credential=s.MS_ENTRA_CLIENT_SECRET, + authority=s.MS_ENTRA_AUTHORITY + ) + + flow = msal_app.initiate_auth_code_flow( + scopes=s.MS_ENTRA_SCOPES, + redirect_uri=s.MS_ENTRA_REDIRECT_URI + ) + + request.session["msal_auth_flow"] = flow + return redirect(flow["auth_uri"]) + + +def microsoft_callback(request): + msal_app = msal.ConfidentialClientApplication( + client_id=s.MS_ENTRA_CLIENT_ID, + client_credential=s.MS_ENTRA_CLIENT_SECRET, + authority=s.MS_ENTRA_AUTHORITY + ) + + flow = request.session.pop("msal_auth_flow", None) + if not flow: + return redirect("/") + + # Acquire token using the flow and callback request + result = msal_app.acquire_token_by_auth_code_flow(flow, request.GET) + + if "access_token" in result: + # Optional: Fetch user info from Microsoft Graph + import requests + resp = requests.get( + "https://graph.microsoft.com/v1.0/me", + headers={"Authorization": f"Bearer {result['access_token']}"} + ) + user_info = resp.json() + + user_name = user_info["displayName"] + user_email = user_info["mail"] + user_oid = user_info["id"] + + # Get implementing class + User = get_user_model() + + if User.objects.filter(uuid=user_oid).exists(): + login(request, User.objects.get(uuid=user_oid)) + else: + u = UserManager.create_user(user_name, user_email, None, uuid=user_oid, is_active=True) + login(request, u) + + # TODO Group Sync + + return redirect("/") + + return redirect("/") # Handle errors diff --git a/epdb/logic.py b/epdb/logic.py index 6d5b930f..99b6f8a5 100644 --- a/epdb/logic.py +++ b/epdb/logic.py @@ -153,11 +153,17 @@ class UserManager(object): # avoid circular import :S from .tasks import send_registration_mail - is_active = not s.ADMIN_APPROVAL_REQUIRED - if 'is_active' in kwargs: - is_active = kwargs['is_active'] + extra_fields = { + 'is_active': not s.ADMIN_APPROVAL_REQUIRED + } - u = get_user_model().objects.create_user(username, email, password, is_active=is_active) + if 'is_active' in kwargs: + extra_fields['is_active'] = kwargs['is_active'] + + if 'uuid' in kwargs: + extra_fields['uuid'] = kwargs['uuid'] + + u = get_user_model().objects.create_user(username, email, password, **kwargs) # Create package package_name = f"{u.username}{'’' if u.username[-1] in 'sxzß' else 's'} Package" diff --git a/pyproject.toml b/pyproject.toml index 1270052f..565ced3b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,3 +31,6 @@ dependencies = [ enviformer = { git = "ssh://git@git.envipath.com/enviPath/enviformer.git", rev = "v0.1.0" } envipy-plugins = { git = "ssh://git@git.envipath.com/enviPath/enviPy-plugins.git", rev = "v0.1.0" } envipy-additional-information = { git = "ssh://git@git.envipath.com/enviPath/enviPy-additional-information.git", rev = "v0.1.4"} + +[project.optional-dependencies] +ms-login = ["msal>=1.33.0"] \ No newline at end of file diff --git a/uv.lock b/uv.lock index 056076c3..b050a987 100644 --- a/uv.lock +++ b/uv.lock @@ -527,6 +527,7 @@ dependencies = [ { name = "envipy-plugins" }, { name = "epam-indigo" }, { name = "gunicorn" }, + { name = "msal" }, { name = "psycopg2-binary" }, { name = "python-dotenv" }, { name = "rdkit" }, @@ -551,6 +552,7 @@ requires-dist = [ { name = "envipy-plugins", git = "ssh://git@git.envipath.com/enviPath/enviPy-plugins.git?rev=v0.1.0" }, { name = "epam-indigo", specifier = ">=1.30.1" }, { name = "gunicorn", specifier = ">=23.0.0" }, + { name = "msal", specifier = ">=1.33.0" }, { name = "psycopg2-binary", specifier = ">=2.9.10" }, { name = "python-dotenv", specifier = ">=1.1.0" }, { name = "rdkit", specifier = ">=2025.3.2" }, @@ -854,6 +856,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198 }, ] +[[package]] +name = "msal" +version = "1.33.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/da/81acbe0c1fd7e9e4ec35f55dadeba9833a847b9a6ba2e2d1e4432da901dd/msal-1.33.0.tar.gz", hash = "sha256:836ad80faa3e25a7d71015c990ce61f704a87328b1e73bcbb0623a18cbf17510", size = 153801 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/5b/fbc73e91f7727ae1e79b21ed833308e99dc11cc1cd3d4717f579775de5e9/msal-1.33.0-py3-none-any.whl", hash = "sha256:c0cd41cecf8eaed733ee7e3be9e040291eba53b0f262d3ae9c58f38b04244273", size = 116853 }, +] + [[package]] name = "multidict" version = "6.4.4" @@ -1538,6 +1554,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/56/8a7ca5d2cd2cda1d245d34b1c9a942920a718082ae8e54e5f3e5a58b7add/pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1", size = 2066757 }, ] +[[package]] +name = "pyjwt" +version = "2.10.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997 }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0"