[Feature] Initial Active Directory / Entra Login (#101)

Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#101
This commit is contained in:
2025-09-10 08:29:27 +12:00
parent 4463bf1bc8
commit e82fe7e87e
13 changed files with 146 additions and 5 deletions

View File

@ -47,6 +47,7 @@ INSTALLED_APPS = [
# Custom # Custom
'epdb', 'epdb',
'migration', 'migration',
'epauth',
] ]
AUTHENTICATION_BACKENDS = [ AUTHENTICATION_BACKENDS = [
@ -351,5 +352,16 @@ LOGIN_EXEMPT_URLS = [
'/o/token/', '/o/token/',
'/o/userinfo/', '/o/userinfo/',
'/password_reset/', '/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(',')

View File

@ -20,6 +20,7 @@ from django.urls import include, path
from .api import api_v1, api_legacy from .api import api_v1, api_legacy
urlpatterns = [ urlpatterns = [
path("", include("epauth.urls")),
path("", include("epdb.urls")), path("", include("epdb.urls")),
path("", include("migration.urls")), path("", include("migration.urls")),
path("admin/", admin.site.urls), path("admin/", admin.site.urls),

0
epauth/__init__.py Normal file
View File

3
epauth/admin.py Normal file
View File

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

6
epauth/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class EpauthConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'epauth'

View File

3
epauth/models.py Normal file
View File

@ -0,0 +1,3 @@
from django.db import models
# Create your models here.

3
epauth/tests.py Normal file
View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

8
epauth/urls.py Normal file
View File

@ -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"),
]

66
epauth/views.py Normal file
View File

@ -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

View File

@ -153,11 +153,17 @@ class UserManager(object):
# avoid circular import :S # avoid circular import :S
from .tasks import send_registration_mail from .tasks import send_registration_mail
is_active = not s.ADMIN_APPROVAL_REQUIRED extra_fields = {
if 'is_active' in kwargs: 'is_active': not s.ADMIN_APPROVAL_REQUIRED
is_active = kwargs['is_active'] }
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 # Create package
package_name = f"{u.username}{'' if u.username[-1] in 'sxzß' else 's'} Package" package_name = f"{u.username}{'' if u.username[-1] in 'sxzß' else 's'} Package"

View File

@ -31,3 +31,6 @@ dependencies = [
enviformer = { git = "ssh://git@git.envipath.com/enviPath/enviformer.git", rev = "v0.1.0" } 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-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"} 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"]

30
uv.lock generated
View File

@ -527,6 +527,7 @@ dependencies = [
{ name = "envipy-plugins" }, { name = "envipy-plugins" },
{ name = "epam-indigo" }, { name = "epam-indigo" },
{ name = "gunicorn" }, { name = "gunicorn" },
{ name = "msal" },
{ name = "psycopg2-binary" }, { name = "psycopg2-binary" },
{ name = "python-dotenv" }, { name = "python-dotenv" },
{ name = "rdkit" }, { 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 = "envipy-plugins", git = "ssh://git@git.envipath.com/enviPath/enviPy-plugins.git?rev=v0.1.0" },
{ name = "epam-indigo", specifier = ">=1.30.1" }, { name = "epam-indigo", specifier = ">=1.30.1" },
{ name = "gunicorn", specifier = ">=23.0.0" }, { name = "gunicorn", specifier = ">=23.0.0" },
{ name = "msal", specifier = ">=1.33.0" },
{ name = "psycopg2-binary", specifier = ">=2.9.10" }, { name = "psycopg2-binary", specifier = ">=2.9.10" },
{ name = "python-dotenv", specifier = ">=1.1.0" }, { name = "python-dotenv", specifier = ">=1.1.0" },
{ name = "rdkit", specifier = ">=2025.3.2" }, { 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 }, { 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]] [[package]]
name = "multidict" name = "multidict"
version = "6.4.4" 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 }, { 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]] [[package]]
name = "python-dateutil" name = "python-dateutil"
version = "2.9.0.post0" version = "2.9.0.post0"