mirror of
https://github.com/ae-utbm/sith.git
synced 2025-11-02 18:13:05 +00:00
doc: third-party auth
This commit is contained in:
@@ -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())
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
4
api/templates/api/third_party/auth.jinja
vendored
4
api/templates/api/third_party/auth.jinja
vendored
@@ -14,8 +14,8 @@
|
||||
{% endtrans %}
|
||||
</p>
|
||||
<p class="margin-bottom">
|
||||
{% trans trimmed app=third_party_app, cgu_link=third_party_cgu, sith_cgu_link=sith_cgu %}
|
||||
The privacy policies of <a href="{{ cgu_link }}">{{ app }}</a>
|
||||
{% trans trimmed app=third_party_app, privacy_link=third_party_cgu, sith_cgu_link=sith_cgu %}
|
||||
The privacy policies of <a href="{{ privacy_link }}">{{ app }}</a>
|
||||
and of <a href="{{ sith_cgu_link }}">the Students' Association</a>
|
||||
applies as soon as the form is submitted.
|
||||
{% endtrans %}
|
||||
|
||||
@@ -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,14 @@ 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):
|
||||
|
||||
14
api/views.py
14
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),
|
||||
}
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
1
docs/reference/api/schemas.md
Normal file
1
docs/reference/api/schemas.md
Normal file
@@ -0,0 +1 @@
|
||||
::: api.schemas
|
||||
1
docs/reference/api/views.md
Normal file
1
docs/reference/api/views.md
Normal file
@@ -0,0 +1 @@
|
||||
::: api.views
|
||||
353
docs/tutorial/api/account-link.md
Normal file
353
docs/tutorial/api/account-link.md
Normal file
@@ -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<br/>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<br/>des conditions<br/>d'utilisation
|
||||
User-->>-Sith: Validation
|
||||
Sith->>+App: URL de retour<br/>avec données utilisateur
|
||||
App->>App: Traitement des <br/>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<br/>avec signature
|
||||
opt
|
||||
Sith->>+App: URL de retour<br/>avec données utilisateur
|
||||
App->>App: Traitement des <br/>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<br/>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 <br>pas signées ou sont mal signées |
|
||||
| 404 | L'URL de retour ne permet pas <br>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<SimpleHmac<Sha512>> {
|
||||
let urlencoded = serde_urlencoded::to_string(self).unwrap();
|
||||
SimpleHmac::<Sha512>::new_from_slice(key)
|
||||
.unwrap()
|
||||
.chain_update(urlencoded.as_bytes())
|
||||
.finalize()
|
||||
}
|
||||
}
|
||||
|
||||
impl Into<Url> 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 = <récupération des données POST>
|
||||
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.
|
||||
@@ -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 :
|
||||
|
||||
|
||||
@@ -144,11 +144,11 @@ msgstr ""
|
||||
#: api/templates/api/third_party/auth.jinja
|
||||
#, python-format
|
||||
msgid ""
|
||||
"The privacy policies of <a href=\"%(cgu_link)s\">%(app)s</a> and of <a "
|
||||
"The privacy policies of <a href=\"%(privacy_link)s\">%(app)s</a> and of <a "
|
||||
"href=\"%(sith_cgu_link)s\">the Students' Association</a> applies as soon as "
|
||||
"the form is submitted."
|
||||
msgstr ""
|
||||
"Les politiques de confidentialité de <a href=\"%(cgu_link)s\">%(app)s</a> et de <a "
|
||||
"Les politiques de confidentialité de <a href=\"%(privacy_link)s\">%(app)s</a> et de <a "
|
||||
"href=\"%(sith_cgu_link)s\">l'Association des Etudiants</a> s'appliquent dès la soumission "
|
||||
"du formulaire."
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user