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.
- django_lasuite-0.0.1/.gitignore +53 -0
- django_lasuite-0.0.1/LICENSE +21 -0
- django_lasuite-0.0.1/PKG-INFO +102 -0
- django_lasuite-0.0.1/README.md +47 -0
- django_lasuite-0.0.1/pyproject.toml +128 -0
- django_lasuite-0.0.1/src/lasuite/__init__.py +1 -0
- django_lasuite-0.0.1/src/lasuite/oidc_login/__init__.py +1 -0
- django_lasuite-0.0.1/src/lasuite/oidc_login/backends.py +246 -0
- django_lasuite-0.0.1/src/lasuite/oidc_login/decorators.py +11 -0
- django_lasuite-0.0.1/src/lasuite/oidc_login/middleware.py +194 -0
- django_lasuite-0.0.1/src/lasuite/oidc_login/urls.py +17 -0
- django_lasuite-0.0.1/src/lasuite/oidc_login/views.py +136 -0
- django_lasuite-0.0.1/src/lasuite/oidc_resource_server/__init__.py +1 -0
- django_lasuite-0.0.1/src/lasuite/oidc_resource_server/authentication.py +78 -0
- django_lasuite-0.0.1/src/lasuite/oidc_resource_server/backend.py +306 -0
- django_lasuite-0.0.1/src/lasuite/oidc_resource_server/clients.py +92 -0
- django_lasuite-0.0.1/src/lasuite/oidc_resource_server/mixins.py +45 -0
- django_lasuite-0.0.1/src/lasuite/oidc_resource_server/urls.py +9 -0
- django_lasuite-0.0.1/src/lasuite/oidc_resource_server/utils.py +46 -0
- django_lasuite-0.0.1/src/lasuite/oidc_resource_server/views.py +41 -0
- django_lasuite-0.0.1/src/lasuite/tools/__init__.py +1 -0
- django_lasuite-0.0.1/src/lasuite/tools/email.py +19 -0
|
@@ -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)
|