django-suap-auth 1.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_suap_auth-1.0.1/LICENSE.md +21 -0
- django_suap_auth-1.0.1/PKG-INFO +109 -0
- django_suap_auth-1.0.1/README.md +74 -0
- django_suap_auth-1.0.1/django_suap_auth/__init__.py +1 -0
- django_suap_auth-1.0.1/django_suap_auth/apps.py +6 -0
- django_suap_auth-1.0.1/django_suap_auth/backends.py +155 -0
- django_suap_auth-1.0.1/django_suap_auth/client.py +79 -0
- django_suap_auth-1.0.1/django_suap_auth/exceptions.py +26 -0
- django_suap_auth-1.0.1/django_suap_auth/urls.py +10 -0
- django_suap_auth-1.0.1/django_suap_auth/utils.py +130 -0
- django_suap_auth-1.0.1/django_suap_auth/views.py +148 -0
- django_suap_auth-1.0.1/django_suap_auth.egg-info/PKG-INFO +109 -0
- django_suap_auth-1.0.1/django_suap_auth.egg-info/SOURCES.txt +20 -0
- django_suap_auth-1.0.1/django_suap_auth.egg-info/dependency_links.txt +1 -0
- django_suap_auth-1.0.1/django_suap_auth.egg-info/requires.txt +14 -0
- django_suap_auth-1.0.1/django_suap_auth.egg-info/top_level.txt +1 -0
- django_suap_auth-1.0.1/pyproject.toml +67 -0
- django_suap_auth-1.0.1/setup.cfg +4 -0
- django_suap_auth-1.0.1/tests/test_backends.py +85 -0
- django_suap_auth-1.0.1/tests/test_client.py +160 -0
- django_suap_auth-1.0.1/tests/test_utils.py +147 -0
- django_suap_auth-1.0.1/tests/test_views.py +201 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 kelsoncm
|
|
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,109 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: django-suap-auth
|
|
3
|
+
Version: 1.0.1
|
|
4
|
+
Summary: Django OAuth2 authentication backend for SUAP (IFRN academic management system)
|
|
5
|
+
Author: kelsoncm
|
|
6
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
7
|
+
Classifier: Framework :: Django
|
|
8
|
+
Classifier: Framework :: Django :: 5.2
|
|
9
|
+
Classifier: Framework :: Django :: 6.0
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE.md
|
|
21
|
+
Requires-Dist: Django<7,>=5.2
|
|
22
|
+
Requires-Dist: requests>=2.31
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
25
|
+
Requires-Dist: pytest-django>=4.8; extra == "dev"
|
|
26
|
+
Requires-Dist: pytest-cov>=5.0; extra == "dev"
|
|
27
|
+
Requires-Dist: responses>=0.25; extra == "dev"
|
|
28
|
+
Requires-Dist: black>=24.0; extra == "dev"
|
|
29
|
+
Requires-Dist: ruff>=0.4; extra == "dev"
|
|
30
|
+
Requires-Dist: pre-commit>=3.7; extra == "dev"
|
|
31
|
+
Requires-Dist: python-dotenv>=1.0; extra == "dev"
|
|
32
|
+
Requires-Dist: mkdocs>=1.6; extra == "dev"
|
|
33
|
+
Requires-Dist: mkdocs-material>=9.5; extra == "dev"
|
|
34
|
+
Dynamic: license-file
|
|
35
|
+
|
|
36
|
+
# django-suap-auth
|
|
37
|
+
|
|
38
|
+
[](https://pypi.org/project/django-suap-auth/)
|
|
39
|
+
[](https://github.com/kelsoncm/django-suap-auth/actions/workflows/publish.yml)
|
|
40
|
+
[](https://pypi.org/project/django-suap-auth/)
|
|
41
|
+
[](https://www.djangoproject.com/)
|
|
42
|
+
[](https://opensource.org/licenses/MIT)
|
|
43
|
+
[](https://github.com/kelsoncm/django-suap-auth/actions/workflows/test.yml)
|
|
44
|
+
[](https://github.com/pre-commit/pre-commit)
|
|
45
|
+
[](https://codecov.io/gh/kelsoncm/django-suap-auth)
|
|
46
|
+
|
|
47
|
+
Backend de autenticação OAuth2 do Django para **SUAP** (Sistema Unificado de Administração Pública), o sistema de gestão acadêmica do [IFRN](https://www.ifrn.edu.br), hospedado em [suap.ifrn.edu.br](https://suap.ifrn.edu.br).
|
|
48
|
+
|
|
49
|
+
## Instalação
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pip install django-suap-auth
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Início Rápido
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
# settings.py
|
|
59
|
+
INSTALLED_APPS = [
|
|
60
|
+
...
|
|
61
|
+
"django_suap_auth",
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
AUTHENTICATION_BACKENDS = [
|
|
65
|
+
"django_suap_auth.backends.SuapAuthBackend",
|
|
66
|
+
"django.contrib.auth.backends.ModelBackend",
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
SUAP_CLIENT_ID = "your-client-id"
|
|
70
|
+
SUAP_CLIENT_SECRET = "your-client-secret"
|
|
71
|
+
SUAP_REDIRECT_URI = "https://yourapp.example.com/auth/suap/callback/"
|
|
72
|
+
SUAP_AUTH_SCOPES = ["identificacao", "email"] # opcional
|
|
73
|
+
LOGIN_REDIRECT_URL = "/dashboard/"
|
|
74
|
+
LOGIN_URL = "/login/"
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
# urls.py
|
|
79
|
+
from django.urls import path, include
|
|
80
|
+
|
|
81
|
+
urlpatterns = [
|
|
82
|
+
path("auth/suap/", include("django_suap_auth.urls")),
|
|
83
|
+
...
|
|
84
|
+
]
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
```html
|
|
88
|
+
<!-- template -->
|
|
89
|
+
<a href="{% url 'suap_auth:login' %}">Login com SUAP</a>
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Escopos Disponíveis
|
|
93
|
+
|
|
94
|
+
| Escopo | Descrição |
|
|
95
|
+
|--------|-----------|
|
|
96
|
+
| `identificacao` | Identificação básica (matricula, nome, campus) |
|
|
97
|
+
| `email` | Endereço de email |
|
|
98
|
+
| `documentos_pessoais` | Documentos pessoais (CPF, RG) |
|
|
99
|
+
| `dados_academicos` | Dados acadêmicos (curso, notas, situação) |
|
|
100
|
+
| `dados_pessoais` | Dados pessoais (data de nascimento, nacionalidade) |
|
|
101
|
+
| `reitoria` | Dados de nível institucional |
|
|
102
|
+
|
|
103
|
+
## Documentação
|
|
104
|
+
|
|
105
|
+
Documentação completa disponível no diretório [`docs/`](docs/) e nas GitHub Pages do projeto.
|
|
106
|
+
|
|
107
|
+
## Licença
|
|
108
|
+
|
|
109
|
+
MIT © 2026 kelsoncm
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# django-suap-auth
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/django-suap-auth/)
|
|
4
|
+
[](https://github.com/kelsoncm/django-suap-auth/actions/workflows/publish.yml)
|
|
5
|
+
[](https://pypi.org/project/django-suap-auth/)
|
|
6
|
+
[](https://www.djangoproject.com/)
|
|
7
|
+
[](https://opensource.org/licenses/MIT)
|
|
8
|
+
[](https://github.com/kelsoncm/django-suap-auth/actions/workflows/test.yml)
|
|
9
|
+
[](https://github.com/pre-commit/pre-commit)
|
|
10
|
+
[](https://codecov.io/gh/kelsoncm/django-suap-auth)
|
|
11
|
+
|
|
12
|
+
Backend de autenticação OAuth2 do Django para **SUAP** (Sistema Unificado de Administração Pública), o sistema de gestão acadêmica do [IFRN](https://www.ifrn.edu.br), hospedado em [suap.ifrn.edu.br](https://suap.ifrn.edu.br).
|
|
13
|
+
|
|
14
|
+
## Instalação
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pip install django-suap-auth
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Início Rápido
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
# settings.py
|
|
24
|
+
INSTALLED_APPS = [
|
|
25
|
+
...
|
|
26
|
+
"django_suap_auth",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
AUTHENTICATION_BACKENDS = [
|
|
30
|
+
"django_suap_auth.backends.SuapAuthBackend",
|
|
31
|
+
"django.contrib.auth.backends.ModelBackend",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
SUAP_CLIENT_ID = "your-client-id"
|
|
35
|
+
SUAP_CLIENT_SECRET = "your-client-secret"
|
|
36
|
+
SUAP_REDIRECT_URI = "https://yourapp.example.com/auth/suap/callback/"
|
|
37
|
+
SUAP_AUTH_SCOPES = ["identificacao", "email"] # opcional
|
|
38
|
+
LOGIN_REDIRECT_URL = "/dashboard/"
|
|
39
|
+
LOGIN_URL = "/login/"
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
# urls.py
|
|
44
|
+
from django.urls import path, include
|
|
45
|
+
|
|
46
|
+
urlpatterns = [
|
|
47
|
+
path("auth/suap/", include("django_suap_auth.urls")),
|
|
48
|
+
...
|
|
49
|
+
]
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
```html
|
|
53
|
+
<!-- template -->
|
|
54
|
+
<a href="{% url 'suap_auth:login' %}">Login com SUAP</a>
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Escopos Disponíveis
|
|
58
|
+
|
|
59
|
+
| Escopo | Descrição |
|
|
60
|
+
|--------|-----------|
|
|
61
|
+
| `identificacao` | Identificação básica (matricula, nome, campus) |
|
|
62
|
+
| `email` | Endereço de email |
|
|
63
|
+
| `documentos_pessoais` | Documentos pessoais (CPF, RG) |
|
|
64
|
+
| `dados_academicos` | Dados acadêmicos (curso, notas, situação) |
|
|
65
|
+
| `dados_pessoais` | Dados pessoais (data de nascimento, nacionalidade) |
|
|
66
|
+
| `reitoria` | Dados de nível institucional |
|
|
67
|
+
|
|
68
|
+
## Documentação
|
|
69
|
+
|
|
70
|
+
Documentação completa disponível no diretório [`docs/`](docs/) e nas GitHub Pages do projeto.
|
|
71
|
+
|
|
72
|
+
## Licença
|
|
73
|
+
|
|
74
|
+
MIT © 2026 kelsoncm
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
default_app_config = "django_suap_auth.apps.SuapAuthConfig"
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
from django.contrib.auth import get_user_model
|
|
2
|
+
|
|
3
|
+
from .exceptions import SuapUserNotAllowedError
|
|
4
|
+
from .utils import apply_user_attr_map, get_suap_settings
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _filter_fields(attrs, allowed):
|
|
8
|
+
"""Return a subset of *attrs* restricted to *allowed* field names.
|
|
9
|
+
|
|
10
|
+
``allowed=None`` means no restriction (return all fields).
|
|
11
|
+
``allowed=[]`` means return an empty dict.
|
|
12
|
+
"""
|
|
13
|
+
if allowed is None:
|
|
14
|
+
return dict(attrs)
|
|
15
|
+
return {k: v for k, v in attrs.items() if k in allowed}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SuapAuthBackend:
|
|
19
|
+
"""
|
|
20
|
+
Django authentication backend for SUAP OAuth2.
|
|
21
|
+
|
|
22
|
+
Looks up or creates a Django user based on the profile info returned by SUAP.
|
|
23
|
+
|
|
24
|
+
Behaviour is controlled by ``SUAP_AUTH`` settings:
|
|
25
|
+
|
|
26
|
+
* ``CREATE_USER`` (bool, default ``True``) — when ``False``, raises
|
|
27
|
+
:exc:`~django_suap_auth.exceptions.SuapUserNotAllowedError` for users
|
|
28
|
+
that do not yet have a local account.
|
|
29
|
+
* ``USER_DEFAULTS`` (dict, default ``{"is_active": True}``) — extra
|
|
30
|
+
field values applied only when creating a new user.
|
|
31
|
+
* ``UPDATE_FIELDS_ON_CREATE`` (list or ``None``, default ``None``) —
|
|
32
|
+
mapped fields written when a new user is created. ``None`` means all
|
|
33
|
+
mapped fields; ``[]`` means none.
|
|
34
|
+
* ``UPDATE_FIELDS_ON_LOGIN`` (list or ``None``, default ``None``) —
|
|
35
|
+
mapped fields synced on every subsequent login. ``None`` means all
|
|
36
|
+
mapped fields; ``[]`` means none.
|
|
37
|
+
* ``FIRST_USER_DEFAULTS`` (dict or ``None``, default ``None``) — when set,
|
|
38
|
+
these field values are used instead of ``USER_DEFAULTS`` if no users
|
|
39
|
+
exist yet (e.g. ``{"is_superuser": True, "is_staff": True}``).
|
|
40
|
+
|
|
41
|
+
Override individual methods to customise behaviour without rewriting
|
|
42
|
+
``authenticate`` entirely:
|
|
43
|
+
|
|
44
|
+
* :meth:`get_user_attrs` — map raw SUAP info to model field dict.
|
|
45
|
+
* :meth:`get_lookup_value` — extract the lookup key from mapped attrs.
|
|
46
|
+
* :meth:`get_or_create_user` — fetch or create the local user.
|
|
47
|
+
* :meth:`create_user` — instantiate and save a brand-new user.
|
|
48
|
+
* :meth:`update_user` — sync fields on an existing user.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def authenticate(self, request, suap_user_info=None, **kwargs):
|
|
52
|
+
if suap_user_info is None:
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
cfg = get_suap_settings()
|
|
56
|
+
lookup_field = cfg["user_lookup_field"]
|
|
57
|
+
|
|
58
|
+
attrs = self.get_user_attrs(suap_user_info, cfg)
|
|
59
|
+
lookup_value = self.get_lookup_value(attrs, lookup_field)
|
|
60
|
+
if not lookup_value:
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
mapped_attrs = {k: v for k, v in attrs.items() if k != lookup_field}
|
|
64
|
+
return self.get_or_create_user(lookup_field, lookup_value, mapped_attrs, cfg)
|
|
65
|
+
|
|
66
|
+
# ------------------------------------------------------------------
|
|
67
|
+
# Extension points
|
|
68
|
+
# ------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
def get_user_attrs(self, suap_user_info, cfg):
|
|
71
|
+
"""Return a dict of model-field → value built from *suap_user_info*.
|
|
72
|
+
|
|
73
|
+
Override to add, remove, or transform attributes before the user is
|
|
74
|
+
looked up or created.
|
|
75
|
+
"""
|
|
76
|
+
attrs = apply_user_attr_map(suap_user_info, cfg["user_attr_map"])
|
|
77
|
+
if cfg["json_field"]:
|
|
78
|
+
attrs[cfg["json_field"]] = suap_user_info
|
|
79
|
+
return attrs
|
|
80
|
+
|
|
81
|
+
def get_lookup_value(self, attrs, lookup_field):
|
|
82
|
+
"""Return the value used to look up the local user record.
|
|
83
|
+
|
|
84
|
+
Return ``None`` to abort authentication (backend returns ``None``).
|
|
85
|
+
"""
|
|
86
|
+
return attrs.get(lookup_field)
|
|
87
|
+
|
|
88
|
+
def get_or_create_user(self, lookup_field, lookup_value, mapped_attrs, cfg):
|
|
89
|
+
"""Fetch the existing user or create a new one.
|
|
90
|
+
|
|
91
|
+
Raises :exc:`~django_suap_auth.exceptions.SuapUserNotAllowedError`
|
|
92
|
+
when the user does not exist and ``CREATE_USER`` is ``False``.
|
|
93
|
+
"""
|
|
94
|
+
User = get_user_model()
|
|
95
|
+
try:
|
|
96
|
+
user = User.objects.get(**{lookup_field: lookup_value})
|
|
97
|
+
except User.DoesNotExist:
|
|
98
|
+
if not cfg["create_user"]:
|
|
99
|
+
raise SuapUserNotAllowedError(
|
|
100
|
+
f"No local account for SUAP user '{lookup_value}' and CREATE_USER is disabled."
|
|
101
|
+
)
|
|
102
|
+
return self.create_user(lookup_field, lookup_value, mapped_attrs, cfg)
|
|
103
|
+
|
|
104
|
+
return self.update_user(user, mapped_attrs, cfg)
|
|
105
|
+
|
|
106
|
+
def create_user(self, lookup_field, lookup_value, mapped_attrs, cfg):
|
|
107
|
+
"""Instantiate, populate, and save a brand-new local user.
|
|
108
|
+
|
|
109
|
+
When ``FIRST_USER_DEFAULTS`` is configured and no users exist yet, those
|
|
110
|
+
defaults are used instead of ``USER_DEFAULTS``.
|
|
111
|
+
|
|
112
|
+
Override to hook into the creation process (e.g. send a welcome e-mail,
|
|
113
|
+
assign groups, etc.).
|
|
114
|
+
"""
|
|
115
|
+
User = get_user_model()
|
|
116
|
+
first_defaults = cfg["first_user_defaults"]
|
|
117
|
+
if first_defaults is not None and not User.objects.exists():
|
|
118
|
+
defaults = dict(first_defaults)
|
|
119
|
+
else:
|
|
120
|
+
defaults = dict(cfg["user_defaults"])
|
|
121
|
+
defaults.update(_filter_fields(mapped_attrs, cfg["update_fields_on_create"]))
|
|
122
|
+
user = User(**{lookup_field: lookup_value}, **defaults)
|
|
123
|
+
user.save()
|
|
124
|
+
return user
|
|
125
|
+
|
|
126
|
+
def update_user(self, user, mapped_attrs, cfg):
|
|
127
|
+
"""Sync *mapped_attrs* onto an existing *user* and save if changed.
|
|
128
|
+
|
|
129
|
+
Override to add custom logic on every login (e.g. update group
|
|
130
|
+
membership based on SUAP roles).
|
|
131
|
+
"""
|
|
132
|
+
changed = False
|
|
133
|
+
|
|
134
|
+
for field, value in _filter_fields(mapped_attrs, cfg["update_fields_on_login"]).items():
|
|
135
|
+
if getattr(user, field, None) != value:
|
|
136
|
+
setattr(user, field, value)
|
|
137
|
+
changed = True
|
|
138
|
+
|
|
139
|
+
# Always enforce user_defaults (e.g. reactivate a deactivated account)
|
|
140
|
+
for field, value in cfg["user_defaults"].items():
|
|
141
|
+
if getattr(user, field, None) != value:
|
|
142
|
+
setattr(user, field, value)
|
|
143
|
+
changed = True
|
|
144
|
+
|
|
145
|
+
if changed:
|
|
146
|
+
user.save()
|
|
147
|
+
|
|
148
|
+
return user
|
|
149
|
+
|
|
150
|
+
def get_user(self, user_id):
|
|
151
|
+
User = get_user_model()
|
|
152
|
+
try:
|
|
153
|
+
return User.objects.get(pk=user_id)
|
|
154
|
+
except User.DoesNotExist:
|
|
155
|
+
return None
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from urllib.parse import urlencode
|
|
2
|
+
|
|
3
|
+
import requests
|
|
4
|
+
|
|
5
|
+
from .exceptions import SuapTokenError, SuapUserInfoError
|
|
6
|
+
|
|
7
|
+
DEFAULT_BASE_URL = "https://suap.ifrn.edu.br"
|
|
8
|
+
|
|
9
|
+
AUTHORIZE_PATH = "/o/authorize/"
|
|
10
|
+
TOKEN_PATH = "/o/token/"
|
|
11
|
+
USER_INFO_PATH = "/api/rh/eu/"
|
|
12
|
+
|
|
13
|
+
AVAILABLE_SCOPES = [
|
|
14
|
+
"identificacao",
|
|
15
|
+
"email",
|
|
16
|
+
"documentos_pessoais",
|
|
17
|
+
"dados_academicos",
|
|
18
|
+
"dados_pessoais",
|
|
19
|
+
"reitoria",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SuapOAuth2Client:
|
|
24
|
+
"""Handles the OAuth2 authorization code flow with SUAP."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, client_id, client_secret, redirect_uri, scopes=None, base_url=None):
|
|
27
|
+
self.client_id = client_id
|
|
28
|
+
self.client_secret = client_secret
|
|
29
|
+
self.redirect_uri = redirect_uri
|
|
30
|
+
self.scopes = scopes or ["identificacao", "email"]
|
|
31
|
+
self.base_url = (base_url or DEFAULT_BASE_URL).rstrip("/")
|
|
32
|
+
self._session = requests.Session()
|
|
33
|
+
|
|
34
|
+
def get_authorization_url(self, state):
|
|
35
|
+
"""Return the full authorization URL to redirect the user to."""
|
|
36
|
+
params = {
|
|
37
|
+
"response_type": "code",
|
|
38
|
+
"client_id": self.client_id,
|
|
39
|
+
"redirect_uri": self.redirect_uri,
|
|
40
|
+
"scope": " ".join(self.scopes),
|
|
41
|
+
"state": state,
|
|
42
|
+
}
|
|
43
|
+
return f"{self.base_url}{AUTHORIZE_PATH}?{urlencode(params)}"
|
|
44
|
+
|
|
45
|
+
def exchange_code_for_token(self, code, timeout=30):
|
|
46
|
+
"""Exchange an authorization code for an access token."""
|
|
47
|
+
url = f"{self.base_url}{TOKEN_PATH}"
|
|
48
|
+
data = {
|
|
49
|
+
"client_id": self.client_id,
|
|
50
|
+
"client_secret": self.client_secret,
|
|
51
|
+
"code": code,
|
|
52
|
+
"redirect_uri": self.redirect_uri,
|
|
53
|
+
"grant_type": "authorization_code",
|
|
54
|
+
}
|
|
55
|
+
try:
|
|
56
|
+
response = self._session.post(url, data=data, timeout=timeout)
|
|
57
|
+
response.raise_for_status()
|
|
58
|
+
return response.json()
|
|
59
|
+
except requests.HTTPError as exc:
|
|
60
|
+
raise SuapTokenError(f"Token exchange failed: {exc}") from exc
|
|
61
|
+
except requests.RequestException as exc:
|
|
62
|
+
raise SuapTokenError(f"Token exchange request error: {exc}") from exc
|
|
63
|
+
except Exception as exc:
|
|
64
|
+
raise SuapTokenError(f"Token exchange unexpected error: {exc}") from exc
|
|
65
|
+
|
|
66
|
+
def get_user_info(self, access_token, timeout=30):
|
|
67
|
+
"""Fetch the authenticated user's profile from SUAP."""
|
|
68
|
+
url = f"{self.base_url}{USER_INFO_PATH}"
|
|
69
|
+
headers = {"Authorization": f"Bearer {access_token}"}
|
|
70
|
+
try:
|
|
71
|
+
response = self._session.get(url, headers=headers, timeout=timeout)
|
|
72
|
+
response.raise_for_status()
|
|
73
|
+
return response.json()
|
|
74
|
+
except requests.HTTPError as exc:
|
|
75
|
+
raise SuapUserInfoError(f"Failed to fetch user info: {exc}") from exc
|
|
76
|
+
except requests.RequestException as exc:
|
|
77
|
+
raise SuapUserInfoError(f"User info request error: {exc}") from exc
|
|
78
|
+
except Exception as exc:
|
|
79
|
+
raise SuapUserInfoError(f"User info unexpected error: {exc}") from exc
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
class SuapAuthError(Exception):
|
|
2
|
+
"""Base exception for django-suap-auth."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class SuapTokenError(SuapAuthError):
|
|
6
|
+
"""Raised when the OAuth2 token exchange fails."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SuapUserInfoError(SuapAuthError):
|
|
10
|
+
"""Raised when fetching user info from SUAP fails."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SuapStateMismatchError(SuapAuthError):
|
|
14
|
+
"""Raised when the OAuth2 state parameter does not match (possible CSRF)."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SuapAPIError(SuapAuthError):
|
|
18
|
+
"""Raised when a SUAP API call fails."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, message, status_code=None):
|
|
21
|
+
super().__init__(message)
|
|
22
|
+
self.status_code = status_code
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SuapUserNotAllowedError(SuapAuthError):
|
|
26
|
+
"""Raised when a SUAP user has no local account and CREATE_USER is False."""
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import secrets
|
|
2
|
+
|
|
3
|
+
from django.core.exceptions import ImproperlyConfigured
|
|
4
|
+
|
|
5
|
+
# Default mapping: user model field → SUAP response key.
|
|
6
|
+
# A tuple key means "split the SUAP value on the first space and assign
|
|
7
|
+
# the first part to key[0] and the remainder to key[1]".
|
|
8
|
+
DEFAULT_USER_ATTR_MAP = {
|
|
9
|
+
"username": "identificacao",
|
|
10
|
+
"email": "email",
|
|
11
|
+
("first_name", "last_name"): "nome_usual",
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_suap_settings():
|
|
16
|
+
"""Read and validate SUAP settings from Django settings.
|
|
17
|
+
|
|
18
|
+
Expects a single SUAP_AUTH dictionary with all configuration:
|
|
19
|
+
|
|
20
|
+
SUAP_AUTH = {
|
|
21
|
+
'CLIENT_ID': 'your-id',
|
|
22
|
+
'CLIENT_SECRET': 'your-secret',
|
|
23
|
+
'REDIRECT_URI': 'https://example.com/callback/',
|
|
24
|
+
'BASE_URL': 'https://suap.ifrn.edu.br', # optional
|
|
25
|
+
'SCOPES': ['identificacao', 'email'], # optional
|
|
26
|
+
'USER_LOOKUP_FIELD': 'username', # optional
|
|
27
|
+
'USER_ATTR_MAP': {...}, # optional
|
|
28
|
+
'USER_JSON_FIELD': None, # optional
|
|
29
|
+
'DIRECT_REDIRECT': True, # optional
|
|
30
|
+
}
|
|
31
|
+
"""
|
|
32
|
+
from django.conf import settings
|
|
33
|
+
|
|
34
|
+
suap_auth = getattr(settings, "SUAP_AUTH", {})
|
|
35
|
+
|
|
36
|
+
# Validate required fields
|
|
37
|
+
required = ['CLIENT_ID', 'CLIENT_SECRET', 'REDIRECT_URI']
|
|
38
|
+
missing = [field for field in required if not suap_auth.get(field)]
|
|
39
|
+
|
|
40
|
+
if missing:
|
|
41
|
+
raise ImproperlyConfigured(
|
|
42
|
+
f"Missing required SUAP_AUTH settings: {', '.join(missing)}. "
|
|
43
|
+
f"Configure SUAP_AUTH dictionary in settings.py"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
"client_id": suap_auth["CLIENT_ID"],
|
|
48
|
+
"client_secret": suap_auth["CLIENT_SECRET"],
|
|
49
|
+
"redirect_uri": suap_auth["REDIRECT_URI"],
|
|
50
|
+
"scopes": suap_auth.get("SCOPES", ["identificacao", "email"]),
|
|
51
|
+
"base_url": suap_auth.get("BASE_URL", "https://suap.ifrn.edu.br"),
|
|
52
|
+
"user_lookup_field": suap_auth.get("USER_LOOKUP_FIELD", "username"),
|
|
53
|
+
"user_attr_map": suap_auth.get("USER_ATTR_MAP", DEFAULT_USER_ATTR_MAP),
|
|
54
|
+
"json_field": suap_auth.get("USER_JSON_FIELD", None),
|
|
55
|
+
"direct_redirect": suap_auth.get("DIRECT_REDIRECT", True),
|
|
56
|
+
"backend": suap_auth.get("BACKEND", "django_suap_auth.backends.SuapAuthBackend"),
|
|
57
|
+
"create_user": suap_auth.get("CREATE_USER", True),
|
|
58
|
+
"user_defaults": suap_auth.get("USER_DEFAULTS", {"is_active": True}),
|
|
59
|
+
"first_user_defaults": suap_auth.get("FIRST_USER_DEFAULTS", None),
|
|
60
|
+
# None = all mapped fields; [] = none; ["field", ...] = only those listed
|
|
61
|
+
"update_fields_on_create": suap_auth.get("UPDATE_FIELDS_ON_CREATE", None),
|
|
62
|
+
"update_fields_on_login": suap_auth.get("UPDATE_FIELDS_ON_LOGIN", None),
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _extract_nested(data, dotted_key):
|
|
67
|
+
"""Extract a value from a (possibly nested) dict using a dotted key path.
|
|
68
|
+
|
|
69
|
+
Example: _extract_nested(data, "dados_pessoais.data_nascimento")
|
|
70
|
+
"""
|
|
71
|
+
keys = dotted_key.split(".")
|
|
72
|
+
value = data
|
|
73
|
+
for key in keys:
|
|
74
|
+
if not isinstance(value, dict):
|
|
75
|
+
return None
|
|
76
|
+
value = value.get(key)
|
|
77
|
+
if value is None:
|
|
78
|
+
return None
|
|
79
|
+
return value
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def apply_user_attr_map(user_info, attr_map):
|
|
83
|
+
"""Translate a SUAP user_info dict into a flat dict of user model field→value pairs.
|
|
84
|
+
|
|
85
|
+
The ``attr_map`` uses the convention ``{model_field: suap_key}``:
|
|
86
|
+
|
|
87
|
+
- **Plain string key**: maps the SUAP field to the given user model field.
|
|
88
|
+
- **Tuple key** ``(field_a, field_b)``: splits the SUAP value on the first space;
|
|
89
|
+
the part before the space goes to ``field_a`` and everything after to ``field_b``.
|
|
90
|
+
- **Dotted SUAP key** (e.g. ``"dados_pessoais.data_nascimento"``): traverses nested
|
|
91
|
+
dicts in the SUAP response.
|
|
92
|
+
- Special key ``"fulljson"``: maps the entire raw SUAP response dict to the field.
|
|
93
|
+
Suitable for a ``JSONField`` or any field that accepts a dict.
|
|
94
|
+
- If the SUAP value is ``None`` or absent, the field is skipped.
|
|
95
|
+
"""
|
|
96
|
+
result = {}
|
|
97
|
+
for model_field, suap_key in attr_map.items():
|
|
98
|
+
if suap_key == "fulljson":
|
|
99
|
+
result[model_field] = user_info
|
|
100
|
+
continue
|
|
101
|
+
value = _extract_nested(user_info, suap_key)
|
|
102
|
+
if value is None:
|
|
103
|
+
continue
|
|
104
|
+
if isinstance(model_field, (list, tuple)) and len(model_field) == 2:
|
|
105
|
+
field_a, field_b = model_field
|
|
106
|
+
parts = str(value).split(" ", 1)
|
|
107
|
+
result[field_a] = parts[0]
|
|
108
|
+
result[field_b] = parts[1] if len(parts) > 1 else ""
|
|
109
|
+
else:
|
|
110
|
+
result[model_field] = value
|
|
111
|
+
return result
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def get_oauth2_client():
|
|
115
|
+
"""Return a SuapOAuth2Client configured from Django settings."""
|
|
116
|
+
from .client import SuapOAuth2Client
|
|
117
|
+
|
|
118
|
+
cfg = get_suap_settings()
|
|
119
|
+
return SuapOAuth2Client(
|
|
120
|
+
client_id=cfg["client_id"],
|
|
121
|
+
client_secret=cfg["client_secret"],
|
|
122
|
+
redirect_uri=cfg["redirect_uri"],
|
|
123
|
+
scopes=cfg["scopes"],
|
|
124
|
+
base_url=cfg["base_url"],
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def generate_state():
|
|
129
|
+
"""Generate a cryptographically secure random state token for OAuth2 CSRF protection."""
|
|
130
|
+
return secrets.token_urlsafe(32)
|