diff --git a/api/forms.py b/api/forms.py index 1f6d7de0..6bd3b5f1 100644 --- a/api/forms.py +++ b/api/forms.py @@ -23,7 +23,7 @@ class ThirdPartyAuthForm(forms.Form): ) client_id = forms.IntegerField(widget=HiddenInput()) third_party_app = forms.CharField(widget=HiddenInput()) - cgu_link = forms.URLField(widget=HiddenInput()) + privacy_link = forms.URLField(widget=HiddenInput()) username = forms.CharField(widget=HiddenInput()) callback_url = forms.URLField(widget=HiddenInput()) signature = forms.CharField(widget=HiddenInput()) diff --git a/api/models.py b/api/models.py index c0d9c291..98a19d6b 100644 --- a/api/models.py +++ b/api/models.py @@ -66,7 +66,11 @@ class ApiClient(models.Model): return all(self.has_perm(perm) for perm in perm_list) def reset_hmac(self, *, commit: bool = True) -> str: - """Reset and return the HMAC key for this client.""" + """Reset and return the HMAC key for this client. + + Args: + commit: if True (the default), persist the new hmac in db. + """ self.hmac_key = get_hmac_key() if commit: self.save() diff --git a/api/schemas.py b/api/schemas.py index 376e90a9..b39bb5c3 100644 --- a/api/schemas.py +++ b/api/schemas.py @@ -1,5 +1,5 @@ -from ninja import ModelSchema -from pydantic import Field +from ninja import ModelSchema, Schema +from pydantic import Field, HttpUrl from api.models import ApiClient from core.schemas import SimpleUserSchema @@ -12,3 +12,12 @@ class ApiClientSchema(ModelSchema): owner: SimpleUserSchema permissions: list[str] = Field(alias="all_permissions") + + +class ThirdPartyAuthParamsSchema(Schema): + client_id: int + third_party_app: str + privacy_link: HttpUrl + username: str + callback_url: HttpUrl + signature: str diff --git a/api/templates/api/third_party/auth.jinja b/api/templates/api/third_party/auth.jinja index e4e9e4f6..3e712434 100644 --- a/api/templates/api/third_party/auth.jinja +++ b/api/templates/api/third_party/auth.jinja @@ -14,8 +14,8 @@ {% endtrans %}

- {% trans trimmed app=third_party_app, cgu_link=third_party_cgu, sith_cgu_link=sith_cgu %} - The privacy policies of {{ app }} + {% trans trimmed app=third_party_app, privacy_link=third_party_cgu, sith_cgu_link=sith_cgu %} + The privacy policies of {{ app }} and of the Students' Association applies as soon as the form is submitted. {% endtrans %} diff --git a/api/tests/test_third_party_auth.py b/api/tests/test_third_party_auth.py index 1d69ef0f..c1565a24 100644 --- a/api/tests/test_third_party_auth.py +++ b/api/tests/test_third_party_auth.py @@ -9,6 +9,7 @@ from pytest_django.asserts import assertRedirects from api.models import ApiClient, get_hmac_key from core.baker_recipes import subscriber_user +from core.schemas import UserProfileSchema from core.utils import hmac_hexdigest @@ -34,14 +35,16 @@ class TestThirdPartyAuth(TestCase): self.query = { "client_id": self.api_client.id, "third_party_app": "app", - "cgu_link": "https://foobar.fr/", + "privacy_link": "https://foobar.fr/", "username": "bibou", "callback_url": "https://callback.fr/", } self.query["signature"] = hmac_hexdigest(self.api_client.hmac_key, self.query) - self.callback_data = {"user_id": self.user.id} + self.callback_data = { + "user": UserProfileSchema.from_orm(self.user).model_dump() + } self.callback_data["signature"] = hmac_hexdigest( - self.api_client.hmac_key, self.callback_data + self.api_client.hmac_key, self.callback_data["user"] ) def test_auth_ok(self): diff --git a/api/views.py b/api/views.py index 34e52732..82e481cb 100644 --- a/api/views.py +++ b/api/views.py @@ -10,26 +10,16 @@ from django.core.exceptions import PermissionDenied from django.urls import reverse, reverse_lazy from django.utils.translation import gettext as _ from django.views.generic import FormView, TemplateView -from ninja import Schema from ninja_extra.shortcuts import get_object_or_none -from pydantic import HttpUrl from api.forms import ThirdPartyAuthForm from api.models import ApiClient +from api.schemas import ThirdPartyAuthParamsSchema from core.models import SithFile from core.schemas import UserProfileSchema from core.utils import hmac_hexdigest -class ThirdPartyAuthParamsSchema(Schema): - client_id: int - third_party_app: str - cgu_link: HttpUrl - username: str - callback_url: HttpUrl - signature: str - - class ThirdPartyAuthView(LoginRequiredMixin, FormView): form_class = ThirdPartyAuthForm template_name = "api/third_party/auth.jinja" @@ -93,7 +83,7 @@ class ThirdPartyAuthView(LoginRequiredMixin, FormView): def get_context_data(self, **kwargs): return super().get_context_data(**kwargs) | { "third_party_app": self.params.third_party_app, - "third_party_cgu": self.params.cgu_link, + "third_party_cgu": self.params.privacy_link, "sith_cgu": SithFile.objects.get(id=settings.SITH_CGU_FILE_ID), } diff --git a/core/utils.py b/core/utils.py index 5d5a0738..6320f8e6 100644 --- a/core/utils.py +++ b/core/utils.py @@ -210,14 +210,14 @@ def get_client_ip(request: HttpRequest) -> str | None: def hmac_hexdigest( key: str | bytes, data: Mapping[str, Any] | Sequence[tuple[str, Any]], - digest: str | Callable[[Buffer], HASH] = "sha256", + digest: str | Callable[[Buffer], HASH] = "sha512", ) -> str: """Return the hexdigest of the signature of the given data. Args: key: the HMAC key used for the signature data: the data to sign - digest: a PEP247 hashing algorithm + digest: a PEP247 hashing algorithm (by default, sha512) Examples: ```python @@ -226,7 +226,7 @@ def hmac_hexdigest( "bar": "somevalue", } hmac_key = secrets.token_hex(64) - signature = hmac_hexdigest(hmac_key, data, "sha512") + signature = hmac_hexdigest(hmac_key, data, "sha256") ``` """ if isinstance(key, str): diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md new file mode 100644 index 00000000..c0108439 --- /dev/null +++ b/docs/reference/api/schemas.md @@ -0,0 +1 @@ +::: api.schemas \ No newline at end of file diff --git a/docs/reference/api/views.md b/docs/reference/api/views.md new file mode 100644 index 00000000..2a0daef1 --- /dev/null +++ b/docs/reference/api/views.md @@ -0,0 +1 @@ +::: api.views \ No newline at end of file diff --git a/docs/tutorial/api/account-link.md b/docs/tutorial/api/account-link.md new file mode 100644 index 00000000..2a125824 --- /dev/null +++ b/docs/tutorial/api/account-link.md @@ -0,0 +1,353 @@ +Le site AE offre des mécanismes permettant aux applications tierces +de récupérer les informations sur un utilisateur du site AE. +De cette manière, il devient possible de synchroniser les informations +qu possède l'application tierce sur l'utilisateur, directement depuis +le site AE. + +## Fonctionnement général + +Pour authentifier vos utilisateurs, vous aurez besoin d'un serveur web +et d'un client d'API (celui auquel est liée votre +[clef d'API](./connect.md#obtenir-une-clef-dapi)). +Deux informations vous sont nécessaires, en plus de votre clef d'API : + +- l'id du client : vous pouvez l'obtenir soit en le demandant à l'équipe info, + soit en appelant la route `GET /client/me` avec votre clef d'API + renseignée dans le header [X-APIKey](./connect.md#x-apikey) +- la clef HMAC du client : vous devez la demander à l'équipe info. + +Grâce à ces informations, vous allez pouvoir fournir le contexte nécessaire +au site AE pour qu'il authentifie vos utilisateurs. + +En effet, la démarche d'authentification s'effectue presque entièrement +sur le site : le travail de l'application tierce consiste uniquement +à fournir à l'utilisateur une url avec les bons paramètres, puis +à recevoir la réponse du serveur si tout s'est bien passé. + +Comme un dessin vaut parfois mieux que mille mots, +voici les diagrammes décrivant le processus. +L'un montre l'entièreté de la démarche ; +l'autre dans un souci de simplicité, ne montre que ce qui est visible +directement par l'application tierce. + +=== "Intégralité du processus" + + ```mermaid + sequenceDiagram + actor User + participant App + User->>+App: Authentifie-moi, stp + App-->>-User: url de connexion
avec signature + User->>+Sith: GET url + opt Utilisateur non-connecté + Sith->>+User: Formulaire de connexion + User-->>-Sith: Connexion + end + Sith->>Sith: vérification de la signature + Sith->>+User: Formulaire
des conditions
d'utilisation + User-->>-Sith: Validation + Sith->>+App: URL de retour
avec données utilisateur + App->>App: Traitement des
données utilisateur + App-->>-Sith: 204 OK, No content + Sith-->>-User: Message de succès + App--)User: Message de succès + ``` + +=== "Point de vue de l'application tierce" + + ```mermaid + sequenceDiagram + actor User + participant App + User->>+App: Authentifie-moi, stp + App-->>-User: url de connexion
avec signature + opt + Sith->>+App: URL de retour
avec données utilisateur + App->>App: Traitement des
données utilisateur + App-->>-Sith: 204 OK, No content + App--)User: Message de succès + end + ``` + +## Données attendues + +### URL de connexion + +L'URL de connexion que vous allez fournir à l'utilisateur doit +être `https://ae.utbm.fr/api-link/auth/` +et doit contenir les données décrites dans +[`ThirdPartyAuthParamsSchema`][api.schemas.ThirdPartyAuthParamsSchema] : + +- `client_id` (integer) : l'id de votre client, que vous pouvez obtenir + de la manière décrite plus haut +- `third_party_app`(string) : le nom de la plateforme pour laquelle + l'authentification va être réalisée (si votre application est un bot + discord, mettez la valeur "discord") +- `privacy_link`(URL) : l'URL vers la page de politique de confidentialité + qui s'appliquera dans le cadre de l'application + (s'il s'agit d'un bot discord, donnez le lien vers celles de Discord) +- `username`(string) : le pseudonyme que l'utilisateur possède sur + votre application +- `callback_url`(URL) : l'URL que le site AE appellera si l'authentification + réussit +- `signature`(string) : la signature des données de la requête. + +Ces données doivent être url-encodées et passées dans les paramètres GET. + +!!!tip "URL de retour" + + Notre système n'impose aucune contrainte quant à la manière + de construire votre URL (hormis le fait que ce doit être une URL HTTPS valide), + mais il est tout de même conseillé d'utiliser l'identifiant de votre + utilisateur comme paramètre dans l'URL + (par exemple `GET /callback/{int:user_id}/`). + +???Example + + Supposons que votre client d'API soit utilisé dans le cadre d'un bot Discord, + avec les données suivantes : + + - l'id du client est 15 + - sa clef HMAC est "beb99dd53" + (c'est pour l'exemple, une vraie clef sera beaucoup plus longue) + - le pseudonyme discord de votre utilisateur est Brian + - son id sur discord est 123456789 + - votre route de callback est `GET /callback/{int:user_id}/`, + accessible au domaine `https://bot.ae.utbm.fr` + + Alors les paramètres de votre URL seront : + + | Paramètre | valeur | + |-----------------|-----------------------------------------------------------------------| + | client_id | 15 | + | third_party_app | discord | + | privacy_link | `https://discord.com/privacy` | + | username | Brian | + | callback_url | `https://bot.ae.utbm.fr/callback/123456789/` | + | signature | 1a383c51060be64f07772aa42e07
18ae096b8f21f2cdb4061c0834a416d12101 | + + Et l'url fournie à l'utilisateur sera : + + `https://ae.utbm.fr/api-link/auth/?client_id=15&third_party_app=discord + &privacy_link=https%3A%2F%2Fdiscord.com%2Fprivacy&username=Brian + &callback_url=https%3A%2F%2Fbot.ae.utbm.fr%2Fcallback%2F123456789%2F + &signature=1a383c51060be64f07772aa42e0718ae096b8f21f2cdb4061c0834a416d12101` + +### Données de retour + +Si l'authentification réussit, le site AE enverra une requête HTTP POST +à l'URL de retour fournie dans l'URL de connexion. + +Le corps de la requête de callback et au format JSON +et contient deux paires clef-valeur : + +- `user` : les données utilisateur, telles que décrites + par [UserProfileSchema][core.schemas.UserProfileSchema] +- `signature` : la signature des données utilisateur + +???Example + + En reprenant les mêmes paramètres que dans l'exemple précédent, + le site AE pourra renvoyer à l'application la requête suivante : + + ```http + POST https://bot.ae.utbm.fr/callback/123456789/ + content-type: application/json + body: { + "user": { + "id": 144131, + "nick_name": "inzekitchen", + "first_name": "Brian", + ... + }, + "signature": "f16955bab6b805f6e1abbb98a86dfee53fed0bf812aa6513ca46cfd461b70020" + } + ``` + +L'application doit répondre avec un des codes HTTP suivants : + +| Code | Raison | +|------|--------------------------------------------------------------------------------| +| 204 | Tout s'est bien passé | +| 403 | Les données de retour ne sont
pas signées ou sont mal signées | +| 404 | L'URL de retour ne permet pas
d'identifier un utilisateur de l'application | + +!!!note "Code d'erreur par défaut" + + Si l'appel de la route fait face à plusieurs problèmes en même temps + (par exemple, l'URL ne permet pas de retrouver votre utilisateur, + et en plus les données sont mal signées), + le 403 prime et doit être retourné par défaut. + +## Signature des données + +Les données de l'URL de connexion doivent être signées, +et la signature de l'URL de retour doit être vérifiée. + +Dans le deux cas, la signature est le digest HMAC-SHA512 +des données url-encodées, en utilisant la clef HMAC du client d'API. + +???Example "Signature de l'URL de connexion" + + En reprenant le même exemple que les fois précédentes, + l'url-encodage des données est : + + `client_id=15&third_party_app=discord + &privacy_link=https%3A%2F%2Fdiscord.com%2Fprivacy%2F&username=Brian + &callback_url=https%3A%2F%2Fbot.ae.utbm.fr%2Fcallback%2F123456789%2F` + + Notez que la signature n'est pas (encore) dedans. + Cette dernière peut-être obtenue avec le code suivant : + + === ":simple-python: Python" + + Dépendances : + + - `environs` (>=14.1) + + ```python + import hmac + from urllib.parse import urlencode + + from environs import Env + + env = Env() + env.read_env() + + key = env.str("HMAC_KEY").encode() + data = { + "client_id": 15, + "third_party_app": "discord", + "privacy_link": "https://discord.com/privacy/", + "username": "Brian", + "callback_url": "https://bot.ae.utbm.fr/callback/123456789/", + } + urlencoded = urlencode(data) + data["signature"] = hmac.digest(key, urlencoded.encode(), "sha512").hex() + + # URL a fournir à l'utilisateur pour son authentification + user_url = f"https://ae.ubtm.fr/api-link/auth/?{urlencode(data)}" + ``` + + === ":simple-rust: Rust" + + Dépendances : + + - `hmac` (>=0.12.1) + - `url` (>=2.5.7, features `serde`) + - `serde` (>=1.0.228, features `derive`) + - `serde_urlencoded` (>="0.7.1) + - `sha2` (>=0.10.9) + - `dotenvy` (>= 0.15) + + ```rust + use hmac::{Mac, SimpleHmac}; + use serde::Serialize; + use sha2::Sha512; + use url::Url; + + #[derive(Serialize, Debug)] + struct UrlData<'a> { + client_id: u32, + third_party_app: &'a str, + privacy_link: Url, + username: &'a str, + callback_url: Url, + } + + impl<'a> UrlData<'a> { + pub fn signature(&self, key: &[u8]) -> CtOutput> { + let urlencoded = serde_urlencoded::to_string(self).unwrap(); + SimpleHmac::::new_from_slice(key) + .unwrap() + .chain_update(urlencoded.as_bytes()) + .finalize() + } + } + + impl Into for UrlData<'_> { + fn into(self) -> Url { + let key = std::env::var("HMAC_KEY").unwrap(); + let mut url = Url::parse("http://ae.utbm.fr/api-link/auth/").unwrap(); + url.set_query(Some( + format!( + "{}&signature={:x}", + serde_urlencoded::to_string(&self).unwrap(), + self.signature(key.as_bytes()).into_bytes() + ) + .as_str(), + )); + url + } + } + + fn main() { + dotenvy::dotenv().expect("Couldn't load env"); + let data = UrlData { + client_id: 1, + third_party_app: "discord", + privacy_link: "https://discord.com/privacy/".parse().unwrap(), + username: "Brian", + callback_url: "https://bot.ae.utbm.fr/callback/123456789/" + .parse() + .unwrap(), + }; + let url: Url = data.into(); + println!("{:?}", url); + } + ``` + +???Example "Vérification de la signature de la réponse" + + Les données utilisateur peuvent ressembler à : + + ```json + { + "user": { + "display_name": "Matthieu Vincent", + "profile_url": "/user/380/", + "profile_pict": "/static/core/img/unknown.jpg", + "id": 380, + "nick_name": None, + "first_name": "Matthieu", + "last_name": "Vincent", + }, + "signature": "3802a280fbb01bd9fetc." + } + ``` + + Vous pouvez vérifier la signature ainsi : + + ```python + import hmac + from urllib.parse import urlencode + + from environs import Env + + env = Env() + env.read_env() + + def is_signature_valid(user_data: dict, signature: str) -> bool: + key = env.str("HMAC_KEY").encode() + urlencoded = urlencode(user_data) + return hmac.compare_digest( + hmac.digest(key, urlencoded.encode(), "sha512").hex(), + signature, + ) + + + post_data = + print( + "signature valide :", + is_signature_valid(post_data["user"], post_data["signature"] + ) + ``` + +!!!Warning + + Vous devez impérativement vérifier la signature + des données de la requête de callback ! + + Si l'équipe informatique se rend compte que vous ne le faites pas, + elle se réserve le droit de suspendre votre application, + immédiatement et sans préavis. diff --git a/docs/tutorial/api/connect.md b/docs/tutorial/api/connect.md index 8ce52bdd..8d1d913d 100644 --- a/docs/tutorial/api/connect.md +++ b/docs/tutorial/api/connect.md @@ -112,7 +112,7 @@ cf. [HTTP persistant connection (wikipedia)](https://en.wikipedia.org/wiki/HTTP_ Voici quelques exemples : -=== "Python (requests)" +=== ":simple-python: Python (requests)" Dépendances : @@ -132,7 +132,7 @@ Voici quelques exemples : print(response.json()) ``` -=== "Python (aiohttp)" +=== ":simple-python: Python (aiohttp)" Dépendances : @@ -158,7 +158,7 @@ Voici quelques exemples : asyncio.run(main()) ``` -=== "Javascript (axios)" +=== ":simple-javascript: Javascript (axios)" Dépendances : @@ -178,7 +178,7 @@ Voici quelques exemples : console.log(await instance.get("club/1").json()); ``` -=== "Rust (reqwest)" +=== ":simple-rust: Rust (reqwest)" Dépendances : diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index dda42b88..dbeba40b 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -144,11 +144,11 @@ msgstr "" #: api/templates/api/third_party/auth.jinja #, python-format msgid "" -"The privacy policies of %(app)s and of %(app)s and of the Students' Association applies as soon as " "the form is submitted." msgstr "" -"Les politiques de confidentialité de %(app)s et de %(app)s et de l'Association des Etudiants s'appliquent dès la soumission " "du formulaire." diff --git a/mkdocs.yml b/mkdocs.yml index ffa4a8b4..c2ee40e2 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -69,6 +69,7 @@ nav: - API: - Développement: tutorial/api/dev.md - Connexion à l'API: tutorial/api/connect.md + - Liaison avec le compte AE: tutorial/api/account-link.md - Etransactions: tutorial/etransaction.md - How-to: - L'ORM de Django: howto/querysets.md @@ -91,6 +92,8 @@ nav: - reference/api/hashers.md - reference/api/models.md - reference/api/perms.md + - reference/api/schemas.md + - reference/api/views.md - club: - reference/club/models.md - reference/club/views.md