Skip to content

Commit

Permalink
Adapter for Django
Browse files Browse the repository at this point in the history
  • Loading branch information
rayluo committed Jan 12, 2024
1 parent 865d99b commit 80e00d5
Show file tree
Hide file tree
Showing 6 changed files with 300 additions and 6 deletions.
233 changes: 233 additions & 0 deletions identity/django.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
from functools import partial, wraps
from html import escape
from typing import List # Needed in Python 3.7 & 3.8

from django.shortcuts import redirect, render
from django.urls import path, reverse

from .web import Auth as _Auth


class Auth(object):
_name_of_auth_response_view = f"{__name__}.auth_response" # Presumably unique

def __init__(
self,
client_id: str,
*,
client_credential=None,
redirect_view: str=None,
scopes: List[str]=None,
authority: str=None,

# We end up accepting Microsoft Entra ID B2C parameters rather than generic urls
# because it is troublesome to build those urls in settings.py or templates
b2c_tenant_name: str=None,
b2c_signup_signin_user_flow: str=None,
b2c_edit_profile_user_flow: str=None,
b2c_reset_password_user_flow: str=None,
):
"""Create an identity helper for a Django web project.
This instance is expected to be long-lived with the web project.
:param str client_id:
The client_id of your web application, issued by its authority.
:param str client_credential:
It is somtimes a string.
The actual format is decided by the underlying auth library. TBD.
:param str redirect_view:
This will be used as the last segment to form your project's redirect_uri.
For example, if you provide an input here as "auth_response",
and your Django project mounts this ``Auth`` object's ``urlpatterns``
by ``path("prefix/", include(auth.urlpatterns))``,
then the actual redirect_uri will become ``.../prefix/auth_response``
which MUST match what you have registered for your web application.
Typically, if your application uses a flat redirect_uri as
``https://example.com/auth_response``,
your shall use an redirect_view value as ``auth_response``,
and then mount it by ``path("", include(auth.urlpatterns))``.
:param list[str] scopes:
A list of strings representing the scopes used during login.
:param str authority:
The authority which your application registers with.
For example, ``https://example.com/foo``.
This is a required parameter unless you the following B2C parameters.
:param str b2c_tenant_name:
The tenant name of your Microsoft Entra ID tenant, such as "contoso".
Required if your project is using Microsoft Entra ID B2C.
:param str b2c_signup_signin_user_flow:
The name of your Microsoft Entra ID tenant's sign-in flow,
such as "B2C_1_signupsignin1".
Required if your project is using Microsoft Entra ID B2C.
:param str b2c_edit_profile_user_flow:
The name of your Microsoft Entra ID tenant's edit-profile flow,
such as "B2C_1_profile_editing".
Optional.
:param str b2c_edit_profile_user_flow:
The name of your Microsoft Entra ID tenant's reset-password flow,
such as "B2C_1_reset_password".
Optional.
"""
self._client_id = client_id
self._client_credential = client_credential
if redirect_view and "/" in redirect_view:
raise ValueError("redirect_view shall not contain slash")
self._redirect_view = redirect_view
self._scopes = scopes
self.urlpatterns = [ # Note: path(..., view, ...) does not accept classmethod
path('login', self.login),
path('logout', self.logout),
path(
redirect_view or 'auth_response', # The latter is used by device code flow
self.auth_response,
name=self._name_of_auth_response_view,
),
]
self._http_cache = {} # All subsequent _Auth instances will share this

# Note: We do not use overload, because we want to allow the caller to
# have only one code path that relay in all the optional parameters.
if b2c_tenant_name and b2c_signup_signin_user_flow:
b2c_authority_template = ( # TODO: Support custom domain
"https://{tenant}.b2clogin.com/{tenant}.onmicrosoft.com/{user_flow}")
self._authority = b2c_authority_template.format(
tenant=b2c_tenant_name,
user_flow=b2c_signup_signin_user_flow,
)
self._edit_profile_auth = _Auth(
session={},
authority=b2c_authority_template.format(
tenant=b2c_tenant_name,
user_flow=b2c_edit_profile_user_flow,
),
client_id=client_id,
) if b2c_edit_profile_user_flow else None
self._reset_password_auth = _Auth(
session={},
authority=b2c_authority_template.format(
tenant=b2c_tenant_name,
user_flow=b2c_reset_password_user_flow,
),
client_id=client_id,
) if b2c_reset_password_user_flow else None
else:
self._authority = authority
self._edit_profile_auth = None
self._reset_password_auth = None
if not self._authority:
raise ValueError(
"Either authority or b2c_tenant_name and b2c_signup_signin_user_flow "
"must be provided")

def _build_auth(self, request):
return _Auth(
session=request.session,
authority=self._authority,
client_id=self._client_id,
client_credential=self._client_credential,
http_cache=self._http_cache,
)

def _get_reset_password_url(self, request):
return self._reset_password_auth.log_in(
redirect_uri=request.build_absolute_uri(self._redirect_view)
)["auth_uri"] if self._reset_password_auth and self._redirect_view else None

def get_edit_profile_url(self, request):
return self._edit_profile_auth.log_in(
redirect_uri=request.build_absolute_uri(self._redirect_view)
)["auth_uri"] if self._edit_profile_auth and self._redirect_view else None

def login(self, request):
"""The login view"""
if not self._client_id:
return self._render_auth_error(
request,
error="configuration_error",
error_description="Did you forget to setup CLIENT_ID (and other configuration)?",
)
redirect_uri = request.build_absolute_uri(
self._redirect_view) if self._redirect_view else None
log_in_result = self._build_auth(request).log_in(
scopes=self._scopes, # Have user consent to scopes during log-in
redirect_uri=redirect_uri, # Optional. If present, this absolute URL must match your app's redirect_uri registered in Azure Portal
prompt="select_account", # Optional. More values defined in https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest
)
if "error" in log_in_result:
return self._render_auth_error(
request,
error=log_in_result["error"],
error_description=log_in_result.get("error_description"),
)
return render(request, "identity/login.html", dict(
log_in_result,
reset_password_url=self._get_reset_password_url(request),
auth_response_url=reverse(self._name_of_auth_response_view),
))

def _render_auth_error(self, request, error, error_description=None):
return render(request, "identity/auth_error.html", dict(
# Use flat data types so that the template can be as simple as possible
error=escape(error),
error_description=escape(error_description or ""),
reset_password_url=self._get_reset_password_url(request),
))

def auth_response(self, request):
"""The auth_response view"""
result = self._build_auth(request).complete_log_in(request.GET)
if "error" in result:
return self._render_auth_error(
request,
error=result["error"],
error_description=result.get("error_description"),
)
return redirect("index") # TODO: Go back to a customizable url

def logout(self, request):
"""The logout view"""
return redirect(
self._build_auth(request).log_out(request.build_absolute_uri("/")))

def get_user(self, request):
return self._build_auth(request).get_user()

def get_token_for_user(self, request, scopes: List[str]):
return self._build_auth(request).get_token_for_user(scopes)

def login_required(
self,
function=None, # TODO: /, *, redirect_field_name=None, login_url=None,
):
# With or without parameter. Inspired by https://stackoverflow.com/a/39335652

# With parameter
if function is None:
return partial(
self.login_required,
#redirect_field_name=redirect_field_name,
#login_url=login_url,
)

# Without parameter
@wraps(function)
def wrapper(request, *args, **kwargs):
auth = self._build_auth(request)
if not auth.get_user():
return redirect(self.login)
return function(request, *args, **kwargs)
return wrapper

22 changes: 22 additions & 0 deletions identity/templates/identity/auth_error.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<!DOCTYPE html>
{# The template uses only a common subset of Django and Flask syntax. #}
{# See also https://jinja.palletsprojects.com/en/latest/switching/#django #}
<html lang="en">
<head>
<meta charset="UTF-8">
{% if reset_password_url and error_description and "AADB2C90118" in error_description %}<!-- This will be reached when user forgot their password -->
<!-- See also https://docs.microsoft.com/en-us/azure/active-directory-b2c/active-directory-b2c-reference-policies#linking-user-flows -->
<meta http-equiv="refresh" content='5;{{reset_password_url}}'>
{% endif %}
<title>Auth: Error</title>
</head>
<body>
<h2>Login Failure</h2>
<dl>
<dt>{{error}}</dt>
<dd>{{error_description}}</dd>
</dl>
<hr>
<a href="/">Homepage</a>
</body>
</html>
30 changes: 30 additions & 0 deletions identity/templates/identity/login.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<!DOCTYPE html>
{# The template uses only a common subset of Django and Flask syntax. #}
{# See also https://jinja.palletsprojects.com/en/latest/switching/#django #}
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Login</title>
</head>
<body>
<h1>Login</h1>

{% if user_code %}
<ol>
<li>To sign in, type <b>{{ user_code }}</b> into
<a href='{{ auth_uri }}' target=_blank>{{ auth_uri }}</a>
to authenticate.
</li>
<li>And then <a href="{{ auth_response_url }}">proceed</a>.</li>
</ol>
{% else %}
<ul><li><a href='{{ auth_uri }}'>Sign In</a></li></ul>
{% endif %}

{% if reset_password_url %}
<hr>
<a href='{{ reset_password_url }}'>Reset Password</a>
{% endif %}
</body>
</html>

2 changes: 1 addition & 1 deletion identity/version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.3.2"
__version__ = "0.4.0a2" # Note: Perhaps update ReadTheDocs and README.md too?
5 changes: 4 additions & 1 deletion identity/web.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ def __init__(
authority,
client_id,
client_credential=None,
http_cache=None,
):
"""Create an identity helper for a web app.
Expand All @@ -44,7 +45,7 @@ def __init__(
self._authority = authority
self._client_id = client_id
self._client_credential = client_credential
self._http_cache = {} # All subsequent MSAL instances will share this
self._http_cache = {} if http_cache is None else http_cache # All subsequent MSAL instances will share this

def _load_cache(self):
cache = msal.SerializableTokenCache()
Expand Down Expand Up @@ -100,6 +101,8 @@ def log_in(self, scopes=None, redirect_uri=None, state=None, prompt=None):
If your app has no redirect uri, this method will also return a ``user_code``
which you shall also display to end user for them to use during log-in.
"""
if not self._client_id:
raise ValueError("client_id must be provided")
_scopes = scopes or []
app = self._build_msal_app() # Only need a PCA at this moment
if redirect_uri:
Expand Down
14 changes: 10 additions & 4 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -21,24 +21,29 @@ classifiers =
Programming Language :: Python :: 3.8
Programming Language :: Python :: 3.9
Programming Language :: Python :: 3.10
Programming Language :: Python :: 3.11
Programming Language :: Python :: 3.12
# NOTE: Settings of this section below this line should not need to be changed
long_description = file: README.md
long_description_content_type = text/markdown
[options]
python_requires = >=3.7
install_requires =
msal>=1.16,<2
# requests>=2.0.0,<3
# importlib; python_version == "2.6"
# See also https://setuptools.readthedocs.io/en/latest/userguide/quickstart.html#dependency-management
# NOTE: Settings of this section below this line should not need to be changed
packages = find:
#packages = find:
# If this project ships namespace package, then use the next line instead.
# See also https://setuptools.readthedocs.io/en/latest/userguide/package_discovery.html#using-find-namespace-or-find-namespace-packages
#packages = find_namespace:
#
# "Treat data as a namespace package" - https://setuptools.pypa.io/en/latest/userguide/datafiles.html#subdirectory-for-data-files
packages = find_namespace:
include_package_data = True
Expand All @@ -59,8 +64,9 @@ gui_scripts =
exclude = tests
[options.package_data]
* = LICENSE,
identity.templates.identity =
*.html
[bdist_wheel]
universal=1
universal=0

0 comments on commit 80e00d5

Please sign in to comment.