docs(auth): spec connexion LinkedIn (OIDC)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,145 @@
|
|||||||
|
# Connexion avec LinkedIn (OIDC) — Design
|
||||||
|
|
||||||
|
Date : 2026-06-24
|
||||||
|
Branche : `dev`
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
Permettre aux utilisateurs de créer un compte / se connecter à decp.info via
|
||||||
|
LinkedIn, comme les boutons « Se connecter avec Google/GitHub » d'autres sites.
|
||||||
|
Beaucoup d'utilisateurs viennent de LinkedIn ; réduire la friction d'inscription.
|
||||||
|
|
||||||
|
Portée : **LinkedIn uniquement** (pas d'abstraction multi-provider pour
|
||||||
|
l'instant). Code simple et direct.
|
||||||
|
|
||||||
|
## Mécanisme
|
||||||
|
|
||||||
|
LinkedIn fournit _« Sign In with LinkedIn using OpenID Connect »_ (OIDC).
|
||||||
|
On utilise la bibliothèque **Authlib** (intégration Flask) : elle gère la
|
||||||
|
découverte OIDC, la redirection, l'échange `code → token`, la validation du
|
||||||
|
token et le `state` anti-CSRF. Scopes demandés : `openid profile email`.
|
||||||
|
|
||||||
|
On ne stocke que l'**email** (comme l'auth existante), pas le nom ni la photo.
|
||||||
|
|
||||||
|
## Flux
|
||||||
|
|
||||||
|
1. L'utilisateur clique sur **« Connexion avec LinkedIn »** (présent sur
|
||||||
|
`/connexion` et `/inscription`).
|
||||||
|
2. `GET /auth/linkedin` → Authlib redirige vers LinkedIn.
|
||||||
|
3. L'utilisateur autorise → LinkedIn redirige vers
|
||||||
|
`GET /auth/linkedin/callback?code=...&state=...`.
|
||||||
|
4. Authlib échange le `code`, valide le token, expose l'identité OIDC :
|
||||||
|
`sub`, `email`, `email_verified`.
|
||||||
|
5. Résolution / liaison du compte (voir ci-dessous).
|
||||||
|
6. `login_user(user, remember=True)` → redirection vers `/compte/admin`
|
||||||
|
(ou le `next` validé via `safe_next`).
|
||||||
|
|
||||||
|
## Schéma (migrations additives, dans `db._migrate`)
|
||||||
|
|
||||||
|
- `users.password_hash` devient **nullable** : un compte créé via LinkedIn n'a
|
||||||
|
pas de mot de passe. Un tel utilisateur pourra s'en créer un plus tard via
|
||||||
|
le flux « mot de passe oublié ».
|
||||||
|
|
||||||
|
- SQLite ne permet pas de retirer un `NOT NULL` par `ALTER COLUMN`. La
|
||||||
|
migration recrée la table `users` sans la contrainte `NOT NULL` sur
|
||||||
|
`password_hash` si elle est encore présente (copie des données,
|
||||||
|
`PRAGMA table_info` pour détecter l'état). Les nouvelles installations
|
||||||
|
créent directement le schéma sans `NOT NULL` sur `password_hash`.
|
||||||
|
|
||||||
|
- Nouvelle table :
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS oauth_identities (
|
||||||
|
provider TEXT NOT NULL, -- 'linkedin'
|
||||||
|
subject TEXT NOT NULL, -- le 'sub' OIDC stable
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (provider, subject),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Résolution / liaison du compte (au callback)
|
||||||
|
|
||||||
|
Logique extraite dans une fonction testable `resolve_oauth_user(provider, subject, email, email_verified)` :
|
||||||
|
|
||||||
|
1. Si `(linkedin, sub)` existe dans `oauth_identities` → on connecte le
|
||||||
|
`user_id` lié.
|
||||||
|
2. Sinon, si un `user` existe déjà avec cet **email** → **liaison
|
||||||
|
automatique** : insert dans `oauth_identities`, et si `email_verified`
|
||||||
|
était `0`, on le passe à `1` (LinkedIn garantit un email vérifié).
|
||||||
|
3. Sinon → **création** d'un nouvel utilisateur (`email`,
|
||||||
|
`password_hash = NULL`, `email_verified = 1`) + insert `oauth_identities`.
|
||||||
|
Aucun email de vérification Brevo n'est envoyé.
|
||||||
|
|
||||||
|
Décision produit : la liaison par email est automatique (l'email LinkedIn est
|
||||||
|
vérifié, le risque est faible).
|
||||||
|
|
||||||
|
## Fichiers touchés
|
||||||
|
|
||||||
|
- **`src/auth/oauth.py`** (nouveau) : `init_oauth(app)` — initialise Authlib et
|
||||||
|
enregistre le provider LinkedIn (config OIDC, client id/secret depuis l'env).
|
||||||
|
- **`src/auth/routes.py`** : deux routes
|
||||||
|
- `GET /auth/linkedin` — démarre le flux (`authorize_redirect`).
|
||||||
|
- `GET /auth/linkedin/callback` — récupère l'identité, appelle
|
||||||
|
`resolve_oauth_user`, `login_user`, redirige.
|
||||||
|
- `resolve_oauth_user(...)` — logique de résolution (testable).
|
||||||
|
- **`src/auth/db.py`** : schéma `oauth_identities`, migration `password_hash`
|
||||||
|
nullable, helpers `get_oauth_identity(provider, subject)`,
|
||||||
|
`link_oauth_identity(provider, subject, user_id)`,
|
||||||
|
`create_oauth_user(email)`.
|
||||||
|
- **`src/auth/setup.py`** : appel à `init_oauth(app)`.
|
||||||
|
- **`src/pages/connexion.py`** et **`src/pages/inscription.py`** : bouton
|
||||||
|
« Connexion avec LinkedIn ».
|
||||||
|
- **`pyproject.toml`** : ajout de la dépendance `authlib`.
|
||||||
|
- **`.template.env`** : `LINKEDIN_CLIENT_ID`, `LINKEDIN_CLIENT_SECRET`.
|
||||||
|
|
||||||
|
## UI du bouton
|
||||||
|
|
||||||
|
- Libellé : **« Connexion avec LinkedIn »**.
|
||||||
|
- Style : texte blanc sur fond `rgb(10, 102, 194)` (bleu de marque LinkedIn,
|
||||||
|
`#0A66C2`).
|
||||||
|
- Un lien `<a href="/auth/linkedin">` (initiation par GET, pas de CSRF requis),
|
||||||
|
placé sous le formulaire email/mot de passe, séparé par un « ou ».
|
||||||
|
- Présent sur `/connexion` et `/inscription`.
|
||||||
|
|
||||||
|
## Gestion d'erreurs
|
||||||
|
|
||||||
|
- L'utilisateur annule / refuse sur LinkedIn → `/connexion?error=oauth_cancelled`.
|
||||||
|
- Échange de token échoué, ou pas d'email renvoyé par LinkedIn →
|
||||||
|
`/connexion?error=oauth_failed` (loggé via `logger.exception`).
|
||||||
|
- `state` invalide → détecté par Authlib → `oauth_failed`.
|
||||||
|
- Messages ajoutés dans `ERROR_MESSAGES` de `connexion.py`.
|
||||||
|
|
||||||
|
## Tests (dans `tests/auth/`)
|
||||||
|
|
||||||
|
Sans appel réseau réel à LinkedIn : on **mocke** le retour `userinfo` /
|
||||||
|
l'identité OIDC exposée par Authlib.
|
||||||
|
|
||||||
|
- `resolve_oauth_user` :
|
||||||
|
- nouvel utilisateur (création + `email_verified = 1`, pas de password).
|
||||||
|
- liaison par email à un compte existant (insert identity, promotion
|
||||||
|
`email_verified`).
|
||||||
|
- identité déjà liée (retour du même `user_id`, pas de doublon).
|
||||||
|
- Migration : `password_hash` accepte `NULL` ; table `oauth_identities` créée.
|
||||||
|
- Helpers db : `get_oauth_identity`, `link_oauth_identity`, `create_oauth_user`.
|
||||||
|
|
||||||
|
## Prérequis hors code (côté admin du projet)
|
||||||
|
|
||||||
|
1. Créer une application sur le **LinkedIn Developer Portal**.
|
||||||
|
2. Activer le produit _« Sign In with LinkedIn using OpenID Connect »_.
|
||||||
|
3. Déclarer les redirect URIs autorisées :
|
||||||
|
- `http://localhost:8050/auth/linkedin/callback` (dev)
|
||||||
|
- `https://test.decp.info/auth/linkedin/callback` (test.decp.info)
|
||||||
|
- `https://decp.info/auth/linkedin/callback` (prod)
|
||||||
|
4. Récupérer le **Client ID** et le **Client Secret**, les renseigner dans
|
||||||
|
`.env` (`LINKEDIN_CLIENT_ID`, `LINKEDIN_CLIENT_SECRET`).
|
||||||
|
|
||||||
|
L'URL de callback est construite à partir de `APP_BASE_URL` (déjà présent dans
|
||||||
|
l'env).
|
||||||
|
|
||||||
|
## Hors périmètre (YAGNI)
|
||||||
|
|
||||||
|
- Google / GitHub / autres providers.
|
||||||
|
- Stockage du nom ou de la photo de profil LinkedIn.
|
||||||
|
- Page de gestion « délier mon compte LinkedIn » (pourra venir plus tard).
|
||||||
Reference in New Issue
Block a user