django-lasuite 0.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,53 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ build/
8
+ develop-eggs/
9
+ dist/
10
+ downloads/
11
+ eggs/
12
+ .eggs/
13
+ lib/
14
+ lib64/
15
+ parts/
16
+ sdist/
17
+ var/
18
+ wheels/
19
+ *.egg-info/
20
+ .installed.cfg
21
+ *.egg
22
+
23
+ # Django
24
+ *.log
25
+ local_settings.py
26
+ db.sqlite3
27
+ db.sqlite3-journal
28
+ media/
29
+ staticfiles/
30
+
31
+ # Virtual Environment
32
+ venv/
33
+ env/
34
+ ENV/
35
+
36
+ # Unit test / coverage reports
37
+ htmlcov/
38
+ .tox/
39
+ .coverage
40
+ .coverage.*
41
+ .cache
42
+ coverage.xml
43
+ *.cover
44
+
45
+ # Editor/IDE specific
46
+ .idea/
47
+ .vscode/
48
+ *.swp
49
+ *.swo
50
+
51
+ # OS specific files
52
+ .DS_Store
53
+ Thumbs.db
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 La Suite numérique
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,102 @@
1
+ Metadata-Version: 2.4
2
+ Name: django-lasuite
3
+ Version: 0.0.1
4
+ Summary: Django La Suite - A Django library
5
+ Project-URL: Homepage, https://github.com/suitenumerique/django-lasuite
6
+ Project-URL: Bug Tracker, https://github.com/suitenumerique/django-lasuite/issues
7
+ Author-email: DINUM <dev@mail.numerique.gouv.fr>
8
+ License: MIT License
9
+
10
+ Copyright (c) 2025 La Suite numérique
11
+
12
+ Permission is hereby granted, free of charge, to any person obtaining a copy
13
+ of this software and associated documentation files (the "Software"), to deal
14
+ in the Software without restriction, including without limitation the rights
15
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16
+ copies of the Software, and to permit persons to whom the Software is
17
+ furnished to do so, subject to the following conditions:
18
+
19
+ The above copyright notice and this permission notice shall be included in all
20
+ copies or substantial portions of the Software.
21
+
22
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
25
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
27
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
28
+ SOFTWARE.
29
+ License-File: LICENSE
30
+ Classifier: Development Status :: 3 - Alpha
31
+ Classifier: Framework :: Django
32
+ Classifier: Framework :: Django :: 5.0
33
+ Classifier: Intended Audience :: Developers
34
+ Classifier: License :: OSI Approved :: MIT License
35
+ Classifier: Programming Language :: Python :: 3
36
+ Classifier: Programming Language :: Python :: 3.10
37
+ Classifier: Programming Language :: Python :: 3.11
38
+ Requires-Python: >=3.10
39
+ Requires-Dist: django>=5.0
40
+ Requires-Dist: djangorestframework>=3.15.2
41
+ Requires-Dist: joserfc>=1.0.4
42
+ Requires-Dist: mozilla-django-oidc>=4.0.1
43
+ Requires-Dist: pyjwt>=2.10.1
44
+ Requires-Dist: requests>=2.32.3
45
+ Provides-Extra: build
46
+ Requires-Dist: setuptools; extra == 'build'
47
+ Requires-Dist: wheel; extra == 'build'
48
+ Provides-Extra: dev
49
+ Requires-Dist: factory-boy; extra == 'dev'
50
+ Requires-Dist: pytest; extra == 'dev'
51
+ Requires-Dist: pytest-django; extra == 'dev'
52
+ Requires-Dist: responses; extra == 'dev'
53
+ Requires-Dist: ruff; extra == 'dev'
54
+ Description-Content-Type: text/markdown
55
+
56
+ # Django La Suite
57
+
58
+ A Django library for the common requirement for the "La Suite numérique" projects.
59
+
60
+ ## Installation
61
+
62
+ ```bash
63
+ pip install django-lasuite
64
+ ```
65
+
66
+ ## Quick Start
67
+
68
+ To be documented with the first implementations.
69
+
70
+ ## Development
71
+
72
+ ### Requirements
73
+
74
+ For this project, we use `uv` Python package manager.
75
+ Please follow the installation guidelines on the [uv documentation](https://docs.astral.sh/uv/getting-started/installation/).
76
+
77
+ ### Setup
78
+
79
+ ```bash
80
+ # Clone the repository
81
+ git clone https://github.com/suitenumerique/django-lasuite.git
82
+ cd django-lasuite
83
+
84
+ # Install development dependencies
85
+ make install-dev
86
+ ```
87
+
88
+ ### Testing
89
+
90
+ ```bash
91
+ make test
92
+ ```
93
+
94
+ ### Code Quality
95
+
96
+ ```bash
97
+ make lint
98
+ ```
99
+
100
+ ## License
101
+
102
+ MIT License
@@ -0,0 +1,47 @@
1
+ # Django La Suite
2
+
3
+ A Django library for the common requirement for the "La Suite numérique" projects.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install django-lasuite
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ To be documented with the first implementations.
14
+
15
+ ## Development
16
+
17
+ ### Requirements
18
+
19
+ For this project, we use `uv` Python package manager.
20
+ Please follow the installation guidelines on the [uv documentation](https://docs.astral.sh/uv/getting-started/installation/).
21
+
22
+ ### Setup
23
+
24
+ ```bash
25
+ # Clone the repository
26
+ git clone https://github.com/suitenumerique/django-lasuite.git
27
+ cd django-lasuite
28
+
29
+ # Install development dependencies
30
+ make install-dev
31
+ ```
32
+
33
+ ### Testing
34
+
35
+ ```bash
36
+ make test
37
+ ```
38
+
39
+ ### Code Quality
40
+
41
+ ```bash
42
+ make lint
43
+ ```
44
+
45
+ ## License
46
+
47
+ MIT License
@@ -0,0 +1,128 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "django-lasuite"
7
+ version = "0.0.1"
8
+ description = "Django La Suite - A Django library"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = {file = "LICENSE"}
12
+ authors = [
13
+ {name = "DINUM", email = "dev@mail.numerique.gouv.fr"},
14
+ ]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Framework :: Django",
18
+ "Framework :: Django :: 5.0",
19
+ "Intended Audience :: Developers",
20
+ "License :: OSI Approved :: MIT License",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ ]
25
+ dependencies = [
26
+ "django>=5.0",
27
+ "djangorestframework>=3.15.2",
28
+ "mozilla-django-oidc>=4.0.1",
29
+ "joserfc>=1.0.4",
30
+ "requests>=2.32.3",
31
+ "PyJWT>=2.10.1",
32
+ ]
33
+
34
+ [project.urls]
35
+ "Homepage" = "https://github.com/suitenumerique/django-lasuite"
36
+ "Bug Tracker" = "https://github.com/suitenumerique/django-lasuite/issues"
37
+
38
+ [project.optional-dependencies]
39
+ build = [
40
+ "setuptools",
41
+ "wheel",
42
+ ]
43
+ dev = [
44
+ "factory_boy",
45
+ "pytest",
46
+ "pytest-django",
47
+ "responses",
48
+ "ruff",
49
+ ]
50
+
51
+ [tool.hatch.build.targets.sdist]
52
+ only-include = ["src"]
53
+
54
+ [tool.hatch.build.targets.wheel]
55
+ packages = ["src/lasuite"]
56
+
57
+ [tool.pytest.ini_options]
58
+ python_files = "test_*.py"
59
+ testpaths = ["tests"]
60
+
61
+ [tool.ruff]
62
+ line-length = 120
63
+ target-version = "py310"
64
+ lint.select = [
65
+ # pycodestyle
66
+ "E", "W",
67
+ # Pyflakes
68
+ "F",
69
+ # pyupgrade
70
+ "UP",
71
+ # flake8-bugbear
72
+ "B",
73
+ # flake8-simplify
74
+ "SIM",
75
+ # isort
76
+ "I",
77
+ # flake8-logging-format
78
+ "G",
79
+ # flake8-pie
80
+ "PIE",
81
+ # flake8-comprehensions
82
+ "C4",
83
+ # flake8-django
84
+ "DJ",
85
+ # flake8-bandit
86
+ "S",
87
+ # flake8-builtins
88
+ "A",
89
+ # flake8-datetimez
90
+ "DTZ",
91
+ # flake8-gettext
92
+ "INT",
93
+ # Pylint
94
+ "PL",
95
+ # flake8-fixme
96
+ "FIX",
97
+ # flake8-self
98
+ "SLF",
99
+ # flake8-return
100
+ "RET",
101
+ # pep8-naming (N)
102
+ "N",
103
+ # pydocstyle
104
+ "D",
105
+ # flake8-pytest-style (PT)
106
+ "PT",
107
+ ]
108
+ lint.ignore = [
109
+ # incorrect-blank-line-before-class
110
+ "D203",
111
+ # missing-blank-line-after-summary
112
+ "D205",
113
+ # multi-line-summary-first-line
114
+ "D212",
115
+ ]
116
+ lint.per-file-ignores = {"**/tests/*"= [
117
+ # flake8-bandit
118
+ "S",
119
+ # flake8-self
120
+ "SLF",
121
+ # magic-value-comparison
122
+ "PLR2004",
123
+ ]}
124
+
125
+
126
+
127
+ [tool.ruff.lint.isort]
128
+ known-first-party = ["lasuite"]
@@ -0,0 +1 @@
1
+ """Django La Suite library."""
@@ -0,0 +1 @@
1
+ """OIDC authentication module."""
@@ -0,0 +1,246 @@
1
+ """Authentication Backends for OIDC."""
2
+
3
+ import logging
4
+ from functools import lru_cache
5
+
6
+ import requests
7
+ from cryptography.fernet import Fernet
8
+ from django.conf import settings
9
+ from django.core.exceptions import SuspiciousOperation
10
+ from django.utils.translation import gettext_lazy as _
11
+ from mozilla_django_oidc.auth import (
12
+ OIDCAuthenticationBackend as MozillaOIDCAuthenticationBackend,
13
+ )
14
+ from mozilla_django_oidc.utils import import_from_settings
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ OIDC_USER_SUB_FIELD = getattr(
20
+ settings,
21
+ "OIDC_USER_SUB_FIELD",
22
+ "sub",
23
+ ) # Default to 'sub' if not set in settings
24
+
25
+
26
+ @lru_cache(maxsize=1)
27
+ def get_cipher_suite():
28
+ """Return a Fernet cipher suite."""
29
+ key = import_from_settings("OIDC_STORE_REFRESH_TOKEN_KEY", None)
30
+ if not key:
31
+ raise ValueError("OIDC_STORE_REFRESH_TOKEN_KEY setting is required.")
32
+ return Fernet(key)
33
+
34
+
35
+ def store_oidc_refresh_token(session, refresh_token):
36
+ """Store the encrypted OIDC refresh token in the session if enabled in settings."""
37
+ if import_from_settings("OIDC_STORE_REFRESH_TOKEN", False):
38
+ encrypted_token = get_cipher_suite().encrypt(refresh_token.encode())
39
+ session["oidc_refresh_token"] = encrypted_token.decode()
40
+
41
+
42
+ def get_oidc_refresh_token(session):
43
+ """Retrieve and decrypt the OIDC refresh token from the session."""
44
+ encrypted_token = session.get("oidc_refresh_token")
45
+ if encrypted_token:
46
+ return get_cipher_suite().decrypt(encrypted_token.encode()).decode()
47
+ return None
48
+
49
+
50
+ def store_tokens(session, access_token, id_token, refresh_token):
51
+ """Store tokens in the session if enabled in settings."""
52
+ if import_from_settings("OIDC_STORE_ACCESS_TOKEN", False):
53
+ session["oidc_access_token"] = access_token
54
+
55
+ if import_from_settings("OIDC_STORE_ID_TOKEN", False):
56
+ session["oidc_id_token"] = id_token
57
+
58
+ store_oidc_refresh_token(session, refresh_token)
59
+
60
+
61
+ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
62
+ """
63
+ Custom OpenID Connect (OIDC) Authentication Backend.
64
+
65
+ This class overrides the default OIDC Authentication Backend to accommodate differences
66
+ in the User model, and handles signed and/or encrypted UserInfo response.
67
+ """
68
+
69
+ def __init__(self, *args, **kwargs):
70
+ """
71
+ Initialize the OIDC Authentication Backend.
72
+ Adds an internal attribute to store the token_info dictionary.
73
+ The purpose of `self._token_info` is to not duplicate code from
74
+ the original `authenticate` method.
75
+ This won't be needed after https://github.com/mozilla/mozilla-django-oidc/pull/377
76
+ is merged.
77
+ """
78
+ super().__init__(*args, **kwargs)
79
+ self._token_info = None
80
+
81
+ def get_token(self, payload):
82
+ """
83
+ Return token object as a dictionary.
84
+ Store the value to extract the refresh token in the `authenticate` method.
85
+ """
86
+ self._token_info = super().get_token(payload)
87
+ return self._token_info
88
+
89
+ def authenticate(self, request, **kwargs):
90
+ """Authenticate a user based on the OIDC code flow."""
91
+ user = super().authenticate(request, **kwargs)
92
+
93
+ if user is not None:
94
+ # Then the user successfully authenticated
95
+ store_oidc_refresh_token(request.session, self._token_info.get("refresh_token"))
96
+
97
+ return user
98
+
99
+ def get_extra_claims(self, user_info):
100
+ """
101
+ Return extra claims from user_info.
102
+
103
+ Args:
104
+ user_info (dict): The user information dictionary.
105
+
106
+ Returns:
107
+ dict: A dictionary of extra claims.
108
+
109
+ """
110
+ return {
111
+ "name": self.compute_full_name(user_info),
112
+ }
113
+
114
+ def post_get_or_create_user(self, user, claims):
115
+ """
116
+ Post-processing after user creation or retrieval.
117
+
118
+ Args:
119
+ user (User): The user instance.
120
+ claims (dict): The claims dictionary.
121
+
122
+ Returns:
123
+ - None
124
+
125
+ """
126
+
127
+ def get_userinfo(self, access_token, id_token, payload):
128
+ """
129
+ Return user details dictionary.
130
+
131
+ Args:
132
+ access_token (str): The access token.
133
+ id_token (str): The id token (unused).
134
+ payload (dict): The token payload (unused).
135
+
136
+ Note: The id_token and payload parameters are unused in this implementation,
137
+ but were kept to preserve base method signature.
138
+
139
+ Note: It handles signed and/or encrypted UserInfo Response. It is required by
140
+ Agent Connect, which follows the OIDC standard. It forces us to override the
141
+ base method, which deal with 'application/json' response.
142
+
143
+ Returns:
144
+ dict: User details dictionary obtained from the OpenID Connect user endpoint.
145
+
146
+ """
147
+ user_response = requests.get(
148
+ self.OIDC_OP_USER_ENDPOINT,
149
+ headers={"Authorization": f"Bearer {access_token}"},
150
+ verify=self.get_settings("OIDC_VERIFY_SSL", True),
151
+ timeout=self.get_settings("OIDC_TIMEOUT", None),
152
+ proxies=self.get_settings("OIDC_PROXY", None),
153
+ )
154
+ user_response.raise_for_status()
155
+ return self.verify_token(user_response.text)
156
+
157
+ def get_or_create_user(self, access_token, id_token, payload):
158
+ """
159
+ Return a User based on userinfo. Create a new user if no match is found.
160
+
161
+ Args:
162
+ access_token (str): The access token.
163
+ id_token (str): The ID token.
164
+ payload (dict): The user payload.
165
+
166
+ Returns:
167
+ User: An existing or newly created User instance.
168
+
169
+ Raises:
170
+ Exception: Raised when user creation is not allowed and no existing user is found.
171
+
172
+ """
173
+ user_info = self.get_userinfo(access_token, id_token, payload)
174
+
175
+ sub = user_info.get("sub")
176
+ if not sub:
177
+ raise SuspiciousOperation(_("User info contained no recognizable user identification"))
178
+
179
+ # Get user's full name from OIDC fields defined in settings
180
+ email = user_info.get("email")
181
+
182
+ claims = {
183
+ OIDC_USER_SUB_FIELD: sub,
184
+ "email": email,
185
+ }
186
+ claims.update(**self.get_extra_claims(user_info))
187
+
188
+ # if sub is absent, try matching on email
189
+ user = self.get_existing_user(sub, email)
190
+
191
+ if user:
192
+ if not user.is_active:
193
+ raise SuspiciousOperation(_("User account is disabled"))
194
+ self.update_user_if_needed(user, claims)
195
+
196
+ elif self.get_settings("OIDC_CREATE_USER", True):
197
+ user = self.create_user(claims)
198
+
199
+ self.post_get_or_create_user(user, claims)
200
+ return user
201
+
202
+ def create_user(self, claims):
203
+ """Return a newly created User instance."""
204
+ sub = claims.get("sub")
205
+ if sub is None:
206
+ raise SuspiciousOperation(_("Claims contained no recognizable user identification"))
207
+
208
+ logger.info("Creating user %s", sub)
209
+
210
+ user = self.UserModel(**claims)
211
+ user.set_unusable_password()
212
+ user.save()
213
+
214
+ return user
215
+
216
+ def compute_full_name(self, user_info):
217
+ """Compute user's full name based on OIDC fields in settings."""
218
+ name_fields = settings.USER_OIDC_FIELDS_TO_FULLNAME
219
+ full_name = " ".join(user_info[field] for field in name_fields if user_info.get(field))
220
+ return full_name or None
221
+
222
+ def get_existing_user(self, sub, email):
223
+ """Fetch existing user by sub or email."""
224
+ try:
225
+ return self.UserModel.objects.get(sub=sub)
226
+ except self.UserModel.DoesNotExist:
227
+ if email and settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION:
228
+ try:
229
+ return self.UserModel.objects.get(email=email)
230
+ except self.UserModel.DoesNotExist:
231
+ pass
232
+ return None
233
+
234
+ def update_user_if_needed(self, user, claims):
235
+ """Update user claims if they have changed."""
236
+ updated_claims = {}
237
+ for key in claims:
238
+ if not hasattr(user, key):
239
+ continue
240
+
241
+ claim_value = claims.get(key)
242
+ if claim_value and claim_value != getattr(user, key):
243
+ updated_claims[key] = claim_value
244
+
245
+ if updated_claims:
246
+ self.UserModel.objects.filter(sub=user.sub).update(**updated_claims)
@@ -0,0 +1,11 @@
1
+ """
2
+ Decorators for the authentication app.
3
+ We don't want (yet) to enforce the OIDC access token to be "fresh" for all
4
+ views, so we provide a decorator to refresh the access token only when needed.
5
+ """
6
+
7
+ from django.utils.decorators import decorator_from_middleware
8
+
9
+ from .middleware import RefreshOIDCAccessToken
10
+
11
+ refresh_oidc_access_token = decorator_from_middleware(RefreshOIDCAccessToken)