v3 : relecture de cf634c2 — renouvellement sans effet sur la réponse, lien en attente fermé avec les sessions

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-10-23 09:02:26 +02:00
co-authored by Claude Opus 5.5
parent cf634c25d2
commit decbc0ba7b
9 changed files with 107 additions and 30 deletions
+26 -7
View File
@@ -222,7 +222,8 @@ pub async fn set_admin(
}
/// A lost device, a person who left: every session of this identity closes,
/// but the one this request comes with.
/// but the one this request comes with, and so does a sign-in link still
/// waiting for it (it may sit on the lost device).
pub async fn close_sessions(
State(state): State<AppState>,
headers: HeaderMap,
@@ -231,14 +232,32 @@ pub async fn close_sessions(
) -> 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 != ?")
let current = auth::session_hash(&state, &headers).unwrap_or_default();
// Counted as the overview counts them; the expired ones go too.
let open: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM sessions WHERE identity_id = ? AND token_hash != ? AND expires_at > ?",
)
.bind(&id)
.bind(&current)
.bind(util::now())
.fetch_one(&state.db)
.await?;
sqlx::query("DELETE FROM sessions WHERE identity_id = ? AND token_hash != ?")
.bind(&id)
.bind(auth::session_hash(&state, &headers).unwrap_or_default())
.bind(&current)
.execute(&state.db)
.await?
.rows_affected();
tracing::info!(identity = %id, closed, by = %user.id, "sessions closed");
Ok(Json(json!({ "closed": closed })))
.await?;
let links = sqlx::query(
"UPDATE invitations SET expires_at = ? WHERE identity_id = ? AND used_at IS NULL AND expires_at > ?",
)
.bind(util::now())
.bind(&id)
.bind(util::now())
.execute(&state.db)
.await?
.rows_affected();
tracing::info!(identity = %id, closed = open, links, by = %user.id, "sessions closed");
Ok(Json(json!({ "closed": open, "links": links })))
}
#[derive(Deserialize)]
+29 -14
View File
@@ -186,27 +186,42 @@ pub(crate) fn session_hash(state: &AppState, headers: &HeaderMap) -> Option<Stri
}
/// 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> {
/// again, in the database and in the browser. A renewal that fails is logged,
/// never answered as signed out.
pub async fn me(State(state): State<AppState>, headers: HeaderMap, Viewer(viewer): Viewer) -> 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 < ?")
match renew(&state, &me.id, &token).await {
Ok(true) => {
res.headers_mut()
.append(SET_COOKIE, cookie(&state, session_cookie(&state), &token, "/", SESSION_DAYS * 86_400));
}
Ok(false) => {}
Err(e) => tracing::warn!(error = %e, "session not renewed"),
}
}
res
}
/// Whether the session was renewed: still open, and renewed more than a day ago.
async fn renew(state: &AppState, identity_id: &str, token: &str) -> AppResult<bool> {
let renewed =
sqlx::query("UPDATE sessions SET expires_at = ? WHERE token_hash = ? AND expires_at > ? AND expires_at < ?")
.bind(util::in_days(SESSION_DAYS))
.bind(util::sha256_hex(&token))
.bind(util::sha256_hex(token))
.bind(util::now())
.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));
}
if renewed.rows_affected() != 1 {
return Ok(false);
}
Ok(res)
sqlx::query("UPDATE identities SET last_seen_at = ? WHERE id = ?")
.bind(util::now())
.bind(identity_id)
.execute(&state.db)
.await?;
Ok(true)
}
/// Only local paths, never `//host` nor a scheme.
+39 -2
View File
@@ -497,6 +497,27 @@ async fn a_session_lasts_until_it_is_closed() {
.await
.expect("count");
assert_eq!(far, 1);
let seen: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM identities WHERE id = 'dev:chiara' AND last_seen_at > strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-1 minute')",
)
.fetch_one(&state.db)
.await
.expect("count");
assert_eq!(seen, 1, "the renewal says when the person was last seen");
// A session already over does not come back.
let mut old = chiara.with(None);
old.login("Chiara", false).await;
let old_cookie = old.cookie.clone();
sqlx::query(
"UPDATE sessions SET expires_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-1 day')
WHERE rowid = (SELECT MAX(rowid) FROM sessions)",
)
.execute(&state.db)
.await
.expect("end the session");
let (_, v) = old.call("GET", "/api/me", None).await;
assert_eq!((v["me"].clone(), old.set_cookie.clone()), (Value::Null, None));
assert_eq!(old.cookie, old_cookie);
// Until an administrator closes them: a lost device, a person who left.
let mut phone = chiara.with(None);
@@ -508,15 +529,31 @@ async fn a_session_lasts_until_it_is_closed() {
let sessions_of = |ov: &Value, id: &str| {
ov["identities"].as_array().expect("i").iter().find(|i| i["id"] == id).expect("identity")["sessions"].clone()
};
// A sign-in link sent to the lost device goes with the sessions.
sqlx::query(
"INSERT INTO invitations (token_hash, label, admin, groups, created_by, created_at, expires_at, identity_id)
VALUES ('waiting', 'Chiara', 0, '[]', 'dev:yvv', strftime('%Y-%m-%dT%H:%M:%SZ', 'now'),
strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '+14 days'), 'dev:chiara')",
)
.execute(&state.db)
.await
.expect("a sign-in link waiting");
let (_, ov) = admin.call("GET", "/api/access", None).await;
assert_eq!(sessions_of(&ov, "dev:chiara"), 2);
assert_eq!(sessions_of(&ov, "dev:chiara"), 2, "the session already over is not counted");
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)));
assert_eq!((s, v["closed"].as_u64(), v["links"].as_u64()), (StatusCode::OK, Some(2), Some(1)));
for c in [&mut chiara, &mut phone] {
let (_, v) = c.call("GET", "/api/me", None).await;
assert_eq!(v["me"], Value::Null);
}
let waiting: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM invitations WHERE token_hash = 'waiting' AND expires_at > strftime('%Y-%m-%dT%H:%M:%SZ', 'now')",
)
.fetch_one(&state.db)
.await
.expect("count");
assert_eq!(waiting, 0);
// An administrator's own: the others close, this one stays open.
let mut laptop = admin.with(None);
laptop.login("Yvv", true).await;
+3 -1
View File
@@ -30,7 +30,9 @@ Duniter peut se loger et tout faire — sauf la gestion des accès », qui revie
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
identité (appareil perdu, départ), jamais celle d'où il agit, avec le lien de connexion qui
l'attend encore. Une personne non administratrice qui perd un appareil le demande à un
administrateur. (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
@@ -17,8 +17,9 @@ 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, 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
ce navigateur. Elle reste ouverte tant que tu reviens au moins une fois par an, jusqu'à ce que
tu te déconnectes ou qu'un administrateur la ferme (appareil perdu, par exemple). 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
+2 -1
View File
@@ -306,7 +306,8 @@ export const en: Messages = {
'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.sessions.closed.one': '{n} session closed.',
'access.sessions.closed.other': '{n} sessions closed.',
'access.kind.g1': 'Ğ1',
'access.kind.invite': 'invitation',
'access.kind.dev': 'dev',
+2 -1
View File
@@ -306,7 +306,8 @@ export const es: Messages = {
'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.sessions.closed.one': '{n} sesión cerrada.',
'access.sessions.closed.other': '{n} sesiones cerradas.',
'access.kind.g1': 'Ğ1',
'access.kind.invite': 'invitación',
'access.kind.dev': 'dev',
+2 -1
View File
@@ -309,7 +309,8 @@ export const fr = {
'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.sessions.closed.one': '{n} session fermée.',
'access.sessions.closed.other': '{n} sessions fermées.',
'access.kind.g1': 'Ğ1',
'access.kind.invite': 'invitation',
'access.kind.dev': 'dev',
+1 -1
View File
@@ -81,7 +81,7 @@
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 }));
toasts.show(i18n.plural('access.sessions.closed', r.closed));
});
}