v3 : une session dure jusqu'à ce qu'on la ferme, l'administration ferme celles d'une identité

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-10-23 08:45:26 +02:00
co-authored by Claude Opus 5.5
parent 422b45ab75
commit cf634c25d2
11 changed files with 163 additions and 13 deletions
+7 -2
View File
@@ -1,6 +1,6 @@
# HANDOFF — libreDecision
> Dernière mise à jour : 2026-10-22, session orch du chantier 1.
> Dernière mise à jour : 2026-10-23, session orch du chantier 1.
## Où on en est
@@ -12,13 +12,18 @@ coquilles des relectures 7 à 10, publication vers `yvv/decision-depot-textes-re
« Publier maintenant » dans `/acces`). Première publication le 2026-10-22 à 03 h 22 (commit
`4cc0fdd` : `monnaie-libre/acte-engagement-certification/` fr, en, es, document.json, README).
**Prêt, pas encore en ligne** (2026-10-23) : les sessions durent **jusqu'à ce qu'on les ferme**
(400 jours recomptés à chaque visite, au lieu de 30 jours fixes) ; dans `/acces`, « Fermer ses
sessions » par identité (appareil perdu, départ), jamais celle d'où l'on agit. Raison : une
personne invitée n'a pas de clé Ğ1 pour revenir, une session expirée lui coûtait un nouveau lien.
Incident du 2026-10-21 : Docker a redémarré sur sonic à 21 h 47 ; l'agent Woodpecker
`aya-woodpeckerci-local-agent-1` gardait l'ancienne socket (pipeline n° 31 : « Cannot connect to
the Docker daemon », rien construit). Remède : `docker restart aya-woodpeckerci-local-agent-1`
(fait avec l'accord d'Yvv), puis « Redémarrer » le pipeline dans l'interface Woodpecker.
- v3 Rust + Svelte à la racine du dépôt ; la maquette v2 intacte dans `maquette/`.
- Boucle : Rust (fmt, clippy `-D warnings`, 69 tests dont 16 scénarios d'API et la
- Boucle : Rust (fmt, clippy `-D warnings`, 70 tests dont 17 scénarios d'API et la
publication vers un dépôt nu) · interface (svelte-check 0 avertissement, 9 vitest) ·
e2e 13 / 13 (C1–C12, M1). L'image Docker rejoue tout et a été essayée localement :
santé, interface, volume non-root, clé de publication, lien admin initial (0600, absent
+35 -1
View File
@@ -7,6 +7,7 @@ use crate::error::{AppError, AppResult};
use crate::util;
use axum::Json;
use axum::extract::{Path, State};
use axum::http::HeaderMap;
use serde::Deserialize;
use serde_json::json;
@@ -14,7 +15,11 @@ fn admin_only(user: &IdentityRow) -> AppResult<()> {
if trustin_access::manages_access(&user.to_identity()) { Ok(()) } else { Err(AppError::Forbidden) }
}
pub async fn overview(State(state): State<AppState>, User(user): User) -> AppResult<Json<serde_json::Value>> {
pub async fn overview(
State(state): State<AppState>,
headers: HeaderMap,
User(user): User,
) -> AppResult<Json<serde_json::Value>> {
admin_only(&user)?;
#[derive(sqlx::FromRow)]
struct Member {
@@ -34,6 +39,14 @@ pub async fn overview(State(state): State<AppState>, User(user): User) -> AppRes
.await?;
let last_seen: Vec<(String, Option<String>)> =
sqlx::query_as("SELECT id, last_seen_at FROM identities").fetch_all(&state.db).await?;
// Open sessions, but the one this page is read with.
let sessions: Vec<(String, i64)> = sqlx::query_as(
"SELECT identity_id, COUNT(*) FROM sessions WHERE expires_at > ? AND token_hash != ? GROUP BY identity_id",
)
.bind(util::now())
.bind(auth::session_hash(&state, &headers).unwrap_or_default())
.fetch_all(&state.db)
.await?;
let came_by: Vec<(String, String)> =
sqlx::query_as("SELECT i.id, t.label FROM identities i JOIN team_links t ON t.token_hash = i.team_link")
.fetch_all(&state.db)
@@ -63,6 +76,7 @@ pub async fn overview(State(state): State<AppState>, User(user): User) -> AppRes
"id": i.id, "kind": i.kind, "displayName": i.display_name, "g1Member": i.g1_member, "admin": i.admin,
"lastSeenAt": last_seen.iter().find(|(id, _)| id == &i.id).and_then(|(_, at)| at.clone()),
"teamLink": came_by.iter().find(|(id, _)| id == &i.id).map(|(_, label)| label.clone()),
"sessions": sessions.iter().find(|(id, _)| id == &i.id).map_or(0, |(_, n)| *n),
})).collect::<Vec<_>>(),
"teamLinks": crate::team::list(&state).await?,
"invitations": invitations.iter().map(|(hash, label, admin, groups, expires, identity)| json!({
@@ -207,6 +221,26 @@ pub async fn set_admin(
Ok(Json(json!({ "admin": b.admin })))
}
/// A lost device, a person who left: every session of this identity closes,
/// but the one this request comes with.
pub async fn close_sessions(
State(state): State<AppState>,
headers: HeaderMap,
User(user): User,
Path(id): Path<String>,
) -> AppResult<Json<serde_json::Value>> {
admin_only(&user)?;
auth::load_identity(&state, &id).await?;
let closed = sqlx::query("DELETE FROM sessions WHERE identity_id = ? AND token_hash != ?")
.bind(&id)
.bind(auth::session_hash(&state, &headers).unwrap_or_default())
.execute(&state.db)
.await?
.rows_affected();
tracing::info!(identity = %id, closed, by = %user.id, "sessions closed");
Ok(Json(json!({ "closed": closed })))
}
#[derive(Deserialize)]
pub struct FeedbackBody {
route: String,
+30 -3
View File
@@ -39,7 +39,9 @@ fn session_cookie(state: &AppState) -> &'static str {
fn oauth_cookie(state: &AppState) -> &'static str {
if state.cfg.secure_cookies() { "__Secure-ld_oauth" } else { OAUTH_COOKIE }
}
const SESSION_DAYS: i64 = 30;
/// A session lasts until it is closed (sign-out, or an administrator): 400 days
/// is the longest a browser keeps a cookie, and each visit starts them again.
const SESSION_DAYS: i64 = 400;
const INVITATION_DAYS: i64 = 14;
fn cookie_value(headers: &HeaderMap, name: &str) -> Option<String> {
@@ -178,8 +180,33 @@ pub async fn config(State(state): State<AppState>) -> Json<serde_json::Value> {
Json(json!(AuthConfig { g1: state.cfg.connect.is_some(), dev: state.cfg.dev_login }))
}
pub async fn me(Viewer(viewer): Viewer) -> Json<serde_json::Value> {
Json(json!({ "me": viewer }))
/// The hash of the session this request comes with, if any.
pub(crate) fn session_hash(state: &AppState, headers: &HeaderMap) -> Option<String> {
cookie_value(headers, session_cookie(state)).map(|token| util::sha256_hex(&token))
}
/// Who is signed in. Once a day at most, the visit starts the session's days
/// again, in the database and in the browser.
pub async fn me(State(state): State<AppState>, headers: HeaderMap, Viewer(viewer): Viewer) -> AppResult<Response> {
let mut res = Json(json!({ "me": viewer })).into_response();
if let (Some(me), Some(token)) = (&viewer, cookie_value(&headers, session_cookie(&state))) {
let renewed = sqlx::query("UPDATE sessions SET expires_at = ? WHERE token_hash = ? AND expires_at < ?")
.bind(util::in_days(SESSION_DAYS))
.bind(util::sha256_hex(&token))
.bind(util::in_days(SESSION_DAYS - 1))
.execute(&state.db)
.await?;
if renewed.rows_affected() == 1 {
sqlx::query("UPDATE identities SET last_seen_at = ? WHERE id = ?")
.bind(util::now())
.bind(&me.id)
.execute(&state.db)
.await?;
res.headers_mut()
.append(SET_COOKIE, cookie(&state, session_cookie(&state), &token, "/", SESSION_DAYS * 86_400));
}
}
Ok(res)
}
/// Only local paths, never `//host` nor a scheme.
+1
View File
@@ -127,6 +127,7 @@ pub fn api_router() -> Router<AppState> {
.route("/access/team-links", post(team::create))
.route("/access/team-links/{hash}", delete(team::revoke))
.route("/access/identities/{id}/admin", post(access::set_admin))
.route("/access/identities/{id}/sessions", delete(access::close_sessions))
.route("/access/feedback", get(access::feedback_list))
.route("/access/feedback/{id}", post(access::feedback_status))
.layer(middleware::from_fn(csrf_guard))
+62 -2
View File
@@ -10,6 +10,8 @@ use tower::ServiceExt;
struct Client {
router: axum::Router,
cookie: Option<String>,
/// The `Set-Cookie` of the last answer, whole.
set_cookie: Option<String>,
}
impl Client {
@@ -17,11 +19,11 @@ impl Client {
let db = open_db("sqlite::memory:").await.expect("db");
let state = AppState::new(Config::for_tests(), db).await.expect("state");
seed::ensure(&state).await.expect("seed");
(Self { router: app(state.clone()), cookie: None }, state)
(Self { router: app(state.clone()), cookie: None, set_cookie: None }, state)
}
fn with(&self, cookie: Option<String>) -> Self {
Self { router: self.router.clone(), cookie }
Self { router: self.router.clone(), cookie, set_cookie: None }
}
async fn call(&mut self, method: &str, uri: &str, body: Option<Value>) -> (StatusCode, Value) {
@@ -36,6 +38,7 @@ impl Client {
.expect("request");
let res = self.router.clone().oneshot(req).await.expect("response");
let status = res.status();
self.set_cookie = res.headers().get(header::SET_COOKIE).map(|v| v.to_str().expect("cookie").to_owned());
if let Some(set) = res.headers().get(header::SET_COOKIE) {
let raw = set.to_str().expect("cookie").split(';').next().unwrap_or_default().to_owned();
if raw.starts_with("ld_session=") {
@@ -470,6 +473,63 @@ async fn access_management_is_for_administrators_only() {
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("publication_off")));
}
#[tokio::test]
async fn a_session_lasts_until_it_is_closed() {
const FOUR_HUNDRED_DAYS: &str = "Max-Age=34560000";
let (mut chiara, state) = Client::new().await;
chiara.login("Chiara", false).await;
assert!(chiara.set_cookie.as_deref().is_some_and(|c| c.contains(FOUR_HUNDRED_DAYS)), "{:?}", chiara.set_cookie);
// Renewed once a day at most: a visit right after changes nothing.
chiara.call("GET", "/api/me", None).await;
assert_eq!(chiara.set_cookie, None);
// Ten days left: the next visit starts the 400 days again, here and in the browser.
sqlx::query("UPDATE sessions SET expires_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '+10 days')")
.execute(&state.db)
.await
.expect("age the session");
let (_, v) = chiara.call("GET", "/api/me", None).await;
assert_eq!(v["me"]["displayName"], "Chiara");
assert!(chiara.set_cookie.as_deref().is_some_and(|c| c.contains(FOUR_HUNDRED_DAYS)), "{:?}", chiara.set_cookie);
let far: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM sessions WHERE expires_at > strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '+399 days')",
)
.fetch_one(&state.db)
.await
.expect("count");
assert_eq!(far, 1);
// Until an administrator closes them: a lost device, a person who left.
let mut phone = chiara.with(None);
phone.login("Chiara", false).await;
let (s, _) = chiara.call("DELETE", "/api/access/identities/dev:chiara/sessions", None).await;
assert_eq!(s, StatusCode::FORBIDDEN);
let mut admin = chiara.with(None);
admin.login("Yvv", true).await;
let sessions_of = |ov: &Value, id: &str| {
ov["identities"].as_array().expect("i").iter().find(|i| i["id"] == id).expect("identity")["sessions"].clone()
};
let (_, ov) = admin.call("GET", "/api/access", None).await;
assert_eq!(sessions_of(&ov, "dev:chiara"), 2);
assert_eq!(sessions_of(&ov, "dev:yvv"), 0, "the session this page is read with is not counted");
let (s, v) = admin.call("DELETE", "/api/access/identities/dev:chiara/sessions", None).await;
assert_eq!((s, v["closed"].as_u64()), (StatusCode::OK, Some(2)));
for c in [&mut chiara, &mut phone] {
let (_, v) = c.call("GET", "/api/me", None).await;
assert_eq!(v["me"], Value::Null);
}
// An administrator's own: the others close, this one stays open.
let mut laptop = admin.with(None);
laptop.login("Yvv", true).await;
let (s, v) = admin.call("DELETE", "/api/access/identities/dev:yvv/sessions", None).await;
assert_eq!((s, v["closed"].as_u64()), (StatusCode::OK, Some(1)));
let (_, v) = admin.call("GET", "/api/me", None).await;
assert_eq!(v["me"]["displayName"], "Yvv");
let (_, v) = laptop.call("GET", "/api/me", None).await;
assert_eq!(v["me"], Value::Null);
let (s, _) = admin.call("DELETE", "/api/access/identities/nobody/sessions", None).await;
assert_eq!(s, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn a_team_link_makes_one_identity_per_opening() {
let (mut admin, _) = Client::new().await;
+7 -2
View File
@@ -26,8 +26,13 @@ Duniter peut se loger et tout faire — sauf la gestion des accès », qui revie
trustinWallet montre par quel lien chaque identité est venue. Limite assumée : une même
personne qui l'ouvre deux fois, sous deux noms, a deux identités — sans effet dans Monnaie
libre (seule la toile vote), à surveiller dans le groupe d'essai.
- Sessions : cookie HttpOnly, SameSite=Lax, Secure en https, 30 jours, jeton haché en base ;
en-tête anti-CSRF sur toute écriture.
- Sessions : cookie HttpOnly, SameSite=Lax, Secure en https (`__Host-`), jeton haché en base ;
en-tête anti-CSRF sur toute écriture. Une session dure **jusqu'à ce qu'on la ferme** : 400 jours
(ce qu'un navigateur garde au plus), recomptés à chaque visite, une fois par jour au plus. On la
ferme en se déconnectant, ou un administrateur ferme dans trustinWallet les sessions d'une
identité (appareil perdu, départ), jamais celle d'où il agit. (D'abord 30 jours fixes, changé le
2026-10-23 : une personne invitée n'a pas de clé Ğ1 pour revenir, et une session expirée lui
coûtait un nouveau lien.)
- Les règles (qui voit, qui ouvre, qui administre) vivent dans `trustin-access`, futur cœur du
service trustinWallet ; le serveur ne décide jamais seul.
- **Une personne, une voix** : là où l'électorat est la toile de confiance (Monnaie libre),
@@ -17,8 +17,8 @@ les contradictions, l'inertie votée, la publication. Puis `CONTROLES.md` : ce q
| Un compte sur **git.open.us.org**, collaboratrice de `yvv/decision` | pousser tes branches, ouvrir des demandes de fusion | Yvv |
Le lien d'invitation s'ouvre **une seule fois** et expire en 14 jours ; il ouvre ta session sur
ce navigateur pour 30 jours. Sur un autre appareil, demande à Yvv un nouveau lien de
connexion. Si Yvv t'envoie plutôt un **lien d'équipe** (`/equipe#…`), il te demande ton nom et
ce navigateur, qui reste ouverte tant que tu reviens (au moins une fois par an) et que tu ne te
déconnectes pas. Sur un autre appareil, demande à Yvv un nouveau lien de connexion. Si Yvv t'envoie plutôt un **lien d'équipe** (`/equipe#…`), il te demande ton nom et
te crée ta propre identité ; ne l'ouvre qu'une fois, puis reviens par ta session.
## 3. Tester sans rien installer
+3
View File
@@ -304,6 +304,9 @@ export const en: Messages = {
'access.admin.take': 'Remove role',
'access.relink': 'New sign-in link',
'access.relink.link': 'Sign-in link for {name}, single use, valid for 14 days — to open their session on another device. It will not be shown again: copy it now.',
'access.sessions.close': 'Close their sessions ({n})',
'access.sessions.closeMine': 'Close my other sessions ({n})',
'access.sessions.closed': 'Sessions closed: {n}.',
'access.kind.g1': 'Ğ1',
'access.kind.invite': 'invitation',
'access.kind.dev': 'dev',
+3
View File
@@ -304,6 +304,9 @@ export const es: Messages = {
'access.admin.take': 'Retirar el rol',
'access.relink': 'Nuevo enlace de conexión',
'access.relink.link': 'Enlace de conexión para {name}, de un solo uso, válido 14 días — para abrir su sesión en otro dispositivo. No se volverá a mostrar: cópialo ahora.',
'access.sessions.close': 'Cerrar sus sesiones ({n})',
'access.sessions.closeMine': 'Cerrar mis otras sesiones ({n})',
'access.sessions.closed': 'Sesiones cerradas: {n}.',
'access.kind.g1': 'Ğ1',
'access.kind.invite': 'invitación',
'access.kind.dev': 'dev',
+3
View File
@@ -307,6 +307,9 @@ export const fr = {
'access.admin.take': 'Retirer le rôle',
'access.relink': 'Nouveau lien de connexion',
'access.relink.link': "Lien de connexion pour {name}, à usage unique, valable 14 jours — pour ouvrir sa session sur un autre appareil. Il ne s'affichera plus : copie-le maintenant.",
'access.sessions.close': 'Fermer ses sessions ({n})',
'access.sessions.closeMine': 'Fermer mes autres sessions ({n})',
'access.sessions.closed': 'Sessions fermées : {n}.',
'access.kind.g1': 'Ğ1',
'access.kind.invite': 'invitation',
'access.kind.dev': 'dev',
+10 -1
View File
@@ -16,7 +16,7 @@
status?: { pending: number; lastError: string | null; lastPublishedAt: string | null; lastCommit: string | null };
};
groups: { slug: string; name: string; policy: string; hidden: boolean; members: { identityId: string; grantedAt: string }[] }[];
identities: { id: string; kind: 'g1' | 'invite' | 'dev'; displayName: string; g1Member: boolean; admin: boolean; lastSeenAt: string | null; teamLink: string | null }[];
identities: { id: string; kind: 'g1' | 'invite' | 'dev'; displayName: string; g1Member: boolean; admin: boolean; lastSeenAt: string | null; teamLink: string | null; sessions: number }[];
invitations: { id: string; label: string; admin: boolean; groups: string[]; expiresAt: string }[];
teamLinks: TeamLink[];
}
@@ -77,6 +77,14 @@
});
}
// A lost device, a person who left: their sessions close (never the one in use here).
async function closeSessions(id: string) {
await act(async () => {
const r = await api.del<{ closed: number }>(`/access/identities/${encodeURIComponent(id)}/sessions`);
toasts.show(t('access.sessions.closed', { n: r.closed }));
});
}
async function status(id: number, s: 'seen' | 'done') {
await api.post(`/access/feedback/${id}`, { status: s });
await fb.load();
@@ -165,6 +173,7 @@
</span>
<span class="row">
{#if i.kind === 'invite'}<button class="btn quiet small" type="button" onclick={() => relink(i.id, i.displayName)}>{t('access.relink')}</button>{/if}
{#if i.sessions > 0}<button class="btn quiet small" type="button" onclick={() => closeSessions(i.id)}>{t(i.id === session.me?.id ? 'access.sessions.closeMine' : 'access.sessions.close', { n: i.sessions })}</button>{/if}
<button class="btn quiet small" type="button" onclick={() => act(() => api.post(`/access/identities/${encodeURIComponent(i.id)}/admin`, { admin: !i.admin }))}>{i.admin ? t('access.admin.take') : t('access.admin.give')}</button>
</span>
</li>