Skip to content

Commit 5776615

Browse files
committed
Adapter for Django
1 parent 865d99b commit 5776615

File tree

7 files changed

+303
-8
lines changed

7 files changed

+303
-8
lines changed

README.md

+3-2
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
This Identity library is an authentication/authorization library that:
55

66
* Suitable for apps that are targeting end users on
7-
[Microsoft identity platform](https://learn.microsoft.com/en-us/azure/active-directory/develop/v2-overview)
7+
[Microsoft identity platform, a.k.a. Microsoft Entra ID](https://learn.microsoft.com/en-us/azure/active-directory/develop/v2-overview)
88
(which includes Work or school accounts provisioned through Azure AD,
99
and Personal Microsoft accounts such as Skype, Xbox, Outlook.com).
1010
* Currently designed for web apps,
@@ -42,7 +42,8 @@ You can find the changes for each version under
4242
## Usage
4343

4444
* Read our [docs here](https://identity-library.readthedocs.io/en/latest/)
45-
* [web app sample for Flask](https://github.com/Azure-Samples/ms-identity-python-webapp)
45+
* [Web app sample for Flask](https://github.com/Azure-Samples/ms-identity-python-webapp)
46+
* [Web app sample for Django](https://github.com/Azure-Samples/ms-identity-python-webapp-django)
4647
* If you want your samples for other web frameworks showing up here,
4748
please create an issue to let us know.
4849

identity/django.py

+233
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
from functools import partial, wraps
2+
from html import escape
3+
from typing import List # Needed in Python 3.7 & 3.8
4+
5+
from django.shortcuts import redirect, render
6+
from django.urls import path, reverse
7+
8+
from .web import Auth as _Auth
9+
10+
11+
class Auth(object):
12+
_name_of_auth_response_view = f"{__name__}.auth_response" # Presumably unique
13+
14+
def __init__(
15+
self,
16+
client_id: str,
17+
*,
18+
client_credential=None,
19+
redirect_view: str=None,
20+
scopes: List[str]=None,
21+
authority: str=None,
22+
23+
# We end up accepting Microsoft Entra ID B2C parameters rather than generic urls
24+
# because it is troublesome to build those urls in settings.py or templates
25+
b2c_tenant_name: str=None,
26+
b2c_signup_signin_user_flow: str=None,
27+
b2c_edit_profile_user_flow: str=None,
28+
b2c_reset_password_user_flow: str=None,
29+
):
30+
"""Create an identity helper for a Django web project.
31+
32+
This instance is expected to be long-lived with the web project.
33+
34+
:param str client_id:
35+
The client_id of your web application, issued by its authority.
36+
37+
:param str client_credential:
38+
It is somtimes a string.
39+
The actual format is decided by the underlying auth library. TBD.
40+
41+
:param str redirect_view:
42+
This will be used as the last segment to form your project's redirect_uri.
43+
44+
For example, if you provide an input here as "auth_response",
45+
and your Django project mounts this ``Auth`` object's ``urlpatterns``
46+
by ``path("prefix/", include(auth.urlpatterns))``,
47+
then the actual redirect_uri will become ``.../prefix/auth_response``
48+
which MUST match what you have registered for your web application.
49+
50+
Typically, if your application uses a flat redirect_uri as
51+
``https://example.com/auth_response``,
52+
your shall use an redirect_view value as ``auth_response``,
53+
and then mount it by ``path("", include(auth.urlpatterns))``.
54+
55+
56+
:param list[str] scopes:
57+
A list of strings representing the scopes used during login.
58+
59+
:param str authority:
60+
The authority which your application registers with.
61+
For example, ``https://example.com/foo``.
62+
This is a required parameter unless you the following B2C parameters.
63+
64+
:param str b2c_tenant_name:
65+
The tenant name of your Microsoft Entra ID tenant, such as "contoso".
66+
Required if your project is using Microsoft Entra ID B2C.
67+
68+
:param str b2c_signup_signin_user_flow:
69+
The name of your Microsoft Entra ID tenant's sign-in flow,
70+
such as "B2C_1_signupsignin1".
71+
Required if your project is using Microsoft Entra ID B2C.
72+
73+
:param str b2c_edit_profile_user_flow:
74+
The name of your Microsoft Entra ID tenant's edit-profile flow,
75+
such as "B2C_1_profile_editing".
76+
Optional.
77+
78+
:param str b2c_edit_profile_user_flow:
79+
The name of your Microsoft Entra ID tenant's reset-password flow,
80+
such as "B2C_1_reset_password".
81+
Optional.
82+
83+
"""
84+
self._client_id = client_id
85+
self._client_credential = client_credential
86+
if redirect_view and "/" in redirect_view:
87+
raise ValueError("redirect_view shall not contain slash")
88+
self._redirect_view = redirect_view
89+
self._scopes = scopes
90+
self.urlpatterns = [ # Note: path(..., view, ...) does not accept classmethod
91+
path('login', self.login),
92+
path('logout', self.logout),
93+
path(
94+
redirect_view or 'auth_response', # The latter is used by device code flow
95+
self.auth_response,
96+
name=self._name_of_auth_response_view,
97+
),
98+
]
99+
self._http_cache = {} # All subsequent _Auth instances will share this
100+
101+
# Note: We do not use overload, because we want to allow the caller to
102+
# have only one code path that relay in all the optional parameters.
103+
if b2c_tenant_name and b2c_signup_signin_user_flow:
104+
b2c_authority_template = ( # TODO: Support custom domain
105+
"https://{tenant}.b2clogin.com/{tenant}.onmicrosoft.com/{user_flow}")
106+
self._authority = b2c_authority_template.format(
107+
tenant=b2c_tenant_name,
108+
user_flow=b2c_signup_signin_user_flow,
109+
)
110+
self._edit_profile_auth = _Auth(
111+
session={},
112+
authority=b2c_authority_template.format(
113+
tenant=b2c_tenant_name,
114+
user_flow=b2c_edit_profile_user_flow,
115+
),
116+
client_id=client_id,
117+
) if b2c_edit_profile_user_flow else None
118+
self._reset_password_auth = _Auth(
119+
session={},
120+
authority=b2c_authority_template.format(
121+
tenant=b2c_tenant_name,
122+
user_flow=b2c_reset_password_user_flow,
123+
),
124+
client_id=client_id,
125+
) if b2c_reset_password_user_flow else None
126+
else:
127+
self._authority = authority
128+
self._edit_profile_auth = None
129+
self._reset_password_auth = None
130+
if not self._authority:
131+
raise ValueError(
132+
"Either authority or b2c_tenant_name and b2c_signup_signin_user_flow "
133+
"must be provided")
134+
135+
def _build_auth(self, request):
136+
return _Auth(
137+
session=request.session,
138+
authority=self._authority,
139+
client_id=self._client_id,
140+
client_credential=self._client_credential,
141+
http_cache=self._http_cache,
142+
)
143+
144+
def _get_reset_password_url(self, request):
145+
return self._reset_password_auth.log_in(
146+
redirect_uri=request.build_absolute_uri(self._redirect_view)
147+
)["auth_uri"] if self._reset_password_auth and self._redirect_view else None
148+
149+
def get_edit_profile_url(self, request):
150+
return self._edit_profile_auth.log_in(
151+
redirect_uri=request.build_absolute_uri(self._redirect_view)
152+
)["auth_uri"] if self._edit_profile_auth and self._redirect_view else None
153+
154+
def login(self, request):
155+
"""The login view"""
156+
if not self._client_id:
157+
return self._render_auth_error(
158+
request,
159+
error="configuration_error",
160+
error_description="Did you forget to setup CLIENT_ID (and other configuration)?",
161+
)
162+
redirect_uri = request.build_absolute_uri(
163+
self._redirect_view) if self._redirect_view else None
164+
log_in_result = self._build_auth(request).log_in(
165+
scopes=self._scopes, # Have user consent to scopes during log-in
166+
redirect_uri=redirect_uri, # Optional. If present, this absolute URL must match your app's redirect_uri registered in Azure Portal
167+
prompt="select_account", # Optional. More values defined in https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest
168+
)
169+
if "error" in log_in_result:
170+
return self._render_auth_error(
171+
request,
172+
error=log_in_result["error"],
173+
error_description=log_in_result.get("error_description"),
174+
)
175+
return render(request, "identity/login.html", dict(
176+
log_in_result,
177+
reset_password_url=self._get_reset_password_url(request),
178+
auth_response_url=reverse(self._name_of_auth_response_view),
179+
))
180+
181+
def _render_auth_error(self, request, error, error_description=None):
182+
return render(request, "identity/auth_error.html", dict(
183+
# Use flat data types so that the template can be as simple as possible
184+
error=escape(error),
185+
error_description=escape(error_description or ""),
186+
reset_password_url=self._get_reset_password_url(request),
187+
))
188+
189+
def auth_response(self, request):
190+
"""The auth_response view"""
191+
result = self._build_auth(request).complete_log_in(request.GET)
192+
if "error" in result:
193+
return self._render_auth_error(
194+
request,
195+
error=result["error"],
196+
error_description=result.get("error_description"),
197+
)
198+
return redirect("index") # TODO: Go back to a customizable url
199+
200+
def logout(self, request):
201+
"""The logout view"""
202+
return redirect(
203+
self._build_auth(request).log_out(request.build_absolute_uri("/")))
204+
205+
def get_user(self, request):
206+
return self._build_auth(request).get_user()
207+
208+
def get_token_for_user(self, request, scopes: List[str]):
209+
return self._build_auth(request).get_token_for_user(scopes)
210+
211+
def login_required(
212+
self,
213+
function=None, # TODO: /, *, redirect_field_name=None, login_url=None,
214+
):
215+
# With or without parameter. Inspired by https://stackoverflow.com/a/39335652
216+
217+
# With parameter
218+
if function is None:
219+
return partial(
220+
self.login_required,
221+
#redirect_field_name=redirect_field_name,
222+
#login_url=login_url,
223+
)
224+
225+
# Without parameter
226+
@wraps(function)
227+
def wrapper(request, *args, **kwargs):
228+
auth = self._build_auth(request)
229+
if not auth.get_user():
230+
return redirect(self.login)
231+
return function(request, *args, **kwargs)
232+
return wrapper
233+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<!DOCTYPE html>
2+
{# The template uses only a common subset of Django and Flask syntax. #}
3+
{# See also https://jinja.palletsprojects.com/en/latest/switching/#django #}
4+
<html lang="en">
5+
<head>
6+
<meta charset="UTF-8">
7+
{% if reset_password_url and error_description and "AADB2C90118" in error_description %}<!-- This will be reached when user forgot their password -->
8+
<!-- See also https://docs.microsoft.com/en-us/azure/active-directory-b2c/active-directory-b2c-reference-policies#linking-user-flows -->
9+
<meta http-equiv="refresh" content='5;{{reset_password_url}}'>
10+
{% endif %}
11+
<title>Auth: Error</title>
12+
</head>
13+
<body>
14+
<h2>Login Failure</h2>
15+
<dl>
16+
<dt>{{error}}</dt>
17+
<dd>{{error_description}}</dd>
18+
</dl>
19+
<hr>
20+
<a href="/">Homepage</a>
21+
</body>
22+
</html>
+30
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<!DOCTYPE html>
2+
{# The template uses only a common subset of Django and Flask syntax. #}
3+
{# See also https://jinja.palletsprojects.com/en/latest/switching/#django #}
4+
<html lang="en">
5+
<head>
6+
<meta charset="UTF-8">
7+
<title>Login</title>
8+
</head>
9+
<body>
10+
<h1>Login</h1>
11+
12+
{% if user_code %}
13+
<ol>
14+
<li>To sign in, type <b>{{ user_code }}</b> into
15+
<a href='{{ auth_uri }}' target=_blank>{{ auth_uri }}</a>
16+
to authenticate.
17+
</li>
18+
<li>And then <a href="{{ auth_response_url }}">proceed</a>.</li>
19+
</ol>
20+
{% else %}
21+
<ul><li><a href='{{ auth_uri }}'>Sign In</a></li></ul>
22+
{% endif %}
23+
24+
{% if reset_password_url %}
25+
<hr>
26+
<a href='{{ reset_password_url }}'>Reset Password</a>
27+
{% endif %}
28+
</body>
29+
</html>
30+

identity/version.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "0.3.2"
1+
__version__ = "0.4.0" # Note: Perhaps update ReadTheDocs and README.md too?

identity/web.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ def __init__(
1919
authority,
2020
client_id,
2121
client_credential=None,
22+
http_cache=None,
2223
):
2324
"""Create an identity helper for a web app.
2425
@@ -44,7 +45,7 @@ def __init__(
4445
self._authority = authority
4546
self._client_id = client_id
4647
self._client_credential = client_credential
47-
self._http_cache = {} # All subsequent MSAL instances will share this
48+
self._http_cache = {} if http_cache is None else http_cache # All subsequent MSAL instances will share this
4849

4950
def _load_cache(self):
5051
cache = msal.SerializableTokenCache()
@@ -100,6 +101,8 @@ def log_in(self, scopes=None, redirect_uri=None, state=None, prompt=None):
100101
If your app has no redirect uri, this method will also return a ``user_code``
101102
which you shall also display to end user for them to use during log-in.
102103
"""
104+
if not self._client_id:
105+
raise ValueError("client_id must be provided")
103106
_scopes = scopes or []
104107
app = self._build_msal_app() # Only need a PCA at this moment
105108
if redirect_uri:

setup.cfg

+10-4
Original file line numberDiff line numberDiff line change
@@ -21,24 +21,29 @@ classifiers =
2121
Programming Language :: Python :: 3.8
2222
Programming Language :: Python :: 3.9
2323
Programming Language :: Python :: 3.10
24+
Programming Language :: Python :: 3.11
25+
Programming Language :: Python :: 3.12
2426
2527
# NOTE: Settings of this section below this line should not need to be changed
2628
2729
long_description = file: README.md
2830
long_description_content_type = text/markdown
2931
3032
[options]
33+
python_requires = >=3.7
3134
install_requires =
3235
msal>=1.16,<2
3336
# requests>=2.0.0,<3
3437
# importlib; python_version == "2.6"
3538
# See also https://setuptools.readthedocs.io/en/latest/userguide/quickstart.html#dependency-management
3639
3740
# NOTE: Settings of this section below this line should not need to be changed
38-
packages = find:
41+
#packages = find:
3942
# If this project ships namespace package, then use the next line instead.
4043
# See also https://setuptools.readthedocs.io/en/latest/userguide/package_discovery.html#using-find-namespace-or-find-namespace-packages
41-
#packages = find_namespace:
44+
#
45+
# "Treat data as a namespace package" - https://setuptools.pypa.io/en/latest/userguide/datafiles.html#subdirectory-for-data-files
46+
packages = find_namespace:
4247
4348
include_package_data = True
4449
@@ -59,8 +64,9 @@ gui_scripts =
5964
exclude = tests
6065
6166
[options.package_data]
62-
* = LICENSE,
67+
identity.templates.identity =
68+
*.html
6369
6470
[bdist_wheel]
65-
universal=1
71+
universal=0
6672

0 commit comments

Comments
 (0)