//! Sign-in: the Ğ1 access of the Monnaie libre forum, invitation links, and a //! local development login. //! //! **Ğ1.** libreDecision is an OAuth2 client of `connect.monnaie-libre.fr` //! (sso-connect): the person signs a one-time challenge in Ğecko, the service //! reads the chain and answers with the on-chain identity. The account is //! keyed on `idty:`, which survives a change of wallet key. Members of //! the web of trust only — sso-connect refuses the others before we see them. //! //! **Invitation.** A single-use link created in trustinWallet by an access //! administrator; it signs a person in as an invited identity (tests, or a //! person without Ğ1 sign-in yet), with the groups the link grants. //! //! **Dev.** `LD_DEV_LOGIN=1` only, never in production. use crate::AppState; use crate::error::{AppError, AppResult}; use crate::util; use axum::Json; use axum::extract::{FromRequestParts, Query, State}; use axum::http::header::{COOKIE, LOCATION, SET_COOKIE}; use axum::http::request::Parts; use axum::http::{HeaderMap, HeaderValue, StatusCode}; use axum::response::{IntoResponse, Response}; use serde::{Deserialize, Serialize}; use serde_json::json; use trustin_access::{Identity, IdentityKind}; pub const SESSION_COOKIE: &str = "ld_session"; const OAUTH_COOKIE: &str = "ld_oauth"; /// Over https, the prefixed names: a neighbouring site under the same parent /// domain cannot plant a `__Host-` cookie (session fixation), nor a /// `__Secure-` one without https. fn session_cookie(state: &AppState) -> &'static str { if state.cfg.secure_cookies() { "__Host-ld_session" } else { SESSION_COOKIE } } fn oauth_cookie(state: &AppState) -> &'static str { if state.cfg.secure_cookies() { "__Secure-ld_oauth" } else { OAUTH_COOKIE } } /// 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 { headers .get_all(COOKIE) .iter() .filter_map(|v| v.to_str().ok()) .flat_map(|v| v.split(';')) .filter_map(|pair| pair.trim().split_once('=')) .find(|(k, _)| *k == name) .map(|(_, v)| v.to_owned()) } fn cookie(state: &AppState, name: &str, value: &str, path: &str, max_age: i64) -> HeaderValue { let secure = if state.cfg.secure_cookies() { "; Secure" } else { "" }; let raw = format!("{name}={value}; Path={path}; HttpOnly; SameSite=Lax; Max-Age={max_age}{secure}"); HeaderValue::from_str(&raw).unwrap_or_else(|_| HeaderValue::from_static("")) } #[derive(Serialize, sqlx::FromRow)] #[serde(rename_all = "camelCase")] pub struct IdentityRow { pub id: String, pub kind: String, pub display_name: String, pub address: Option, pub g1_member: bool, pub admin: bool, } impl IdentityRow { pub fn to_identity(&self) -> Identity { Identity { id: self.id.clone(), kind: IdentityKind::parse(&self.kind).unwrap_or(IdentityKind::Invite), display_name: self.display_name.clone(), g1_member: self.g1_member, admin: self.admin, } } } pub const IDENTITY_COLUMNS: &str = "id, kind, display_name, address, g1_member, admin"; /// The signed-in person, if any. pub struct Viewer(pub Option); /// The signed-in person; 401 otherwise. pub struct User(pub IdentityRow); impl FromRequestParts for Viewer { type Rejection = AppError; async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result { let Some(token) = cookie_value(&parts.headers, session_cookie(state)) else { return Ok(Self(None)); }; let row = sqlx::query_as::<_, IdentityRow>(&format!( "SELECT i.{} FROM sessions s JOIN identities i ON i.id = s.identity_id WHERE s.token_hash = ? AND s.expires_at > ?", IDENTITY_COLUMNS.replace(", ", ", i.") )) .bind(util::sha256_hex(&token)) .bind(util::now()) .fetch_optional(&state.db) .await?; Ok(Self(row)) } } impl FromRequestParts for User { type Rejection = AppError; async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result { match Viewer::from_request_parts(parts, state).await?.0 { Some(row) => Ok(Self(row)), None => Err(AppError::Unauthenticated), } } } pub(crate) async fn open_session(state: &AppState, identity_id: &str) -> AppResult { let token = util::token(); sqlx::query("INSERT INTO sessions (token_hash, identity_id, created_at, expires_at) VALUES (?, ?, ?, ?)") .bind(util::sha256_hex(&token)) .bind(identity_id) .bind(util::now()) .bind(util::in_days(SESSION_DAYS)) .execute(&state.db) .await?; sqlx::query("UPDATE identities SET last_seen_at = ? WHERE id = ?") .bind(util::now()) .bind(identity_id) .execute(&state.db) .await?; Ok(cookie(state, session_cookie(state), &token, "/", SESSION_DAYS * 86_400)) } pub async fn load_identity(state: &AppState, id: &str) -> AppResult { sqlx::query_as::<_, IdentityRow>(&format!("SELECT {IDENTITY_COLUMNS} FROM identities WHERE id = ?")) .bind(id) .fetch_optional(&state.db) .await? .ok_or(AppError::NotFound) } pub(crate) async fn grant_groups( state: &AppState, identity_id: &str, groups: &[String], by: Option<&str>, ) -> AppResult<()> { for slug in groups { sqlx::query( "INSERT OR IGNORE INTO memberships (group_slug, identity_id, granted_by, granted_at) SELECT slug, ?, ?, ? FROM groups WHERE slug = ?", ) .bind(identity_id) .bind(by) .bind(util::now()) .bind(slug) .execute(&state.db) .await?; } Ok(()) } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct AuthConfig { g1: bool, dev: bool, } pub async fn config(State(state): State) -> Json { Json(json!(AuthConfig { g1: state.cfg.connect.is_some(), dev: state.cfg.dev_login })) } /// The hash of the session this request comes with, if any. pub(crate) fn session_hash(state: &AppState, headers: &HeaderMap) -> Option { 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. A renewal that fails is logged, /// never answered as signed out. pub async fn me(State(state): State, 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))) { match renew(&state, &token).await { Ok(true) => { res.headers_mut() .append(SET_COOKIE, cookie(&state, session_cookie(&state), &token, "/", SESSION_DAYS * 86_400)); let seen = sqlx::query("UPDATE identities SET last_seen_at = ? WHERE id = ?") .bind(util::now()) .bind(&me.id) .execute(&state.db) .await; if let Err(e) = seen { tracing::warn!(error = %e, "last visit not recorded"); } } 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, token: &str) -> AppResult { 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::now()) .bind(util::in_days(SESSION_DAYS - 1)) .execute(&state.db) .await?; Ok(renewed.rows_affected() == 1) } /// Only local paths, never `//host` nor a scheme. fn safe_return(raw: Option<&str>) -> String { match raw { // Browsers drop tabs and newlines in URLs: "/\t/evil" would become "//evil". Some(p) if p.starts_with('/') && !p.starts_with("//") && !p.contains('\\') && !p.chars().any(char::is_control) && p.len() < 512 => { p.to_owned() } _ => "/groupes".to_owned(), } } #[derive(Deserialize)] pub struct StartQuery { #[serde(rename = "return")] return_to: Option, } fn redirect(to: &str, cookies: Vec) -> Response { let mut res = StatusCode::FOUND.into_response(); if let Ok(v) = HeaderValue::from_str(to) { res.headers_mut().insert(LOCATION, v); } for c in cookies { res.headers_mut().append(SET_COOKIE, c); } res } /// Step 1: send the browser to the approval page of connect.monnaie-libre.fr. pub async fn g1_start(State(state): State, Query(q): Query) -> AppResult { let connect = state.cfg.connect.as_ref().ok_or(AppError::Unavailable("g1_not_configured"))?; let csrf = util::token(); let back = safe_return(q.return_to.as_deref()); let mut url = url::Url::parse(&format!("{}/oauth/authorize", connect.base_url)).map_err(anyhow::Error::from)?; url.query_pairs_mut() .append_pair("response_type", "code") .append_pair("client_id", &connect.client_id) .append_pair("redirect_uri", &state.cfg.connect_callback()) .append_pair("state", &csrf); let stash = format!("{csrf}.{}", url::form_urlencoded::byte_serialize(back.as_bytes()).collect::()); Ok(redirect(url.as_str(), vec![cookie(&state, oauth_cookie(&state), &stash, "/api/auth/g1", 600)])) } #[derive(Deserialize)] pub struct CallbackQuery { code: Option, state: Option, error: Option, } #[derive(Deserialize)] struct TokenResponse { access_token: String, } /// What sso-connect's `/oauth/userinfo` answers — and nothing else. #[derive(Deserialize)] pub struct UserInfo { pub id: String, pub username: String, pub address: Option, #[serde(default)] pub member: bool, } /// Step 3: check the state, exchange the code, read the identity, open a session. pub async fn g1_callback( State(state): State, headers: HeaderMap, Query(q): Query, ) -> Response { let clear = cookie(&state, oauth_cookie(&state), "", "/api/auth/g1", 0); let stash = cookie_value(&headers, oauth_cookie(&state)).unwrap_or_default(); let (expected, back) = stash.split_once('.').unwrap_or(("", "")); let back = safe_return(url::form_urlencoded::parse(format!("p={back}").as_bytes()).next().map(|(_, v)| v).as_deref()); let fail = |code: &str| redirect(&format!("/connexion?erreur={code}"), vec![clear.clone()]); if let Some(err) = q.error.as_deref() { tracing::warn!(error = err, "connect returned an error"); return fail("refus"); } let (Some(code), Some(got)) = (q.code, q.state) else { return fail("incomplet") }; if expected.is_empty() || expected != got { return fail("etat"); } match exchange(&state, &code).await { Ok(info) => match sign_in_g1(&state, info).await { Ok(session) => redirect(&back, vec![clear, session]), Err(e) => { tracing::error!(error = %e, "g1 sign-in"); fail("interne") } }, Err(e) => { tracing::warn!(error = %e, "code exchange"); fail("echange") } } } async fn exchange(state: &AppState, code: &str) -> anyhow::Result { let connect = state.cfg.connect.as_ref().ok_or_else(|| anyhow::anyhow!("not configured"))?; let token: TokenResponse = state .http .post(format!("{}/oauth/token", connect.base_url)) .basic_auth(&connect.client_id, Some(&connect.client_secret)) .form(&[("grant_type", "authorization_code"), ("code", code), ("redirect_uri", &state.cfg.connect_callback())]) .send() .await? .error_for_status()? .json() .await?; let info: UserInfo = state .http .get(format!("{}/oauth/userinfo", connect.base_url)) .bearer_auth(token.access_token) .send() .await? .error_for_status()? .json() .await?; anyhow::ensure!(info.id.starts_with("idty:"), "unexpected identity key"); Ok(info) } pub async fn sign_in_g1(state: &AppState, info: UserInfo) -> AppResult { let admin = state.cfg.admin_g1_ids.iter().any(|a| a == &info.id); sqlx::query( "INSERT INTO identities (id, kind, display_name, address, g1_member, admin, created_at) VALUES (?, 'g1', ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET display_name = excluded.display_name, address = excluded.address, g1_member = excluded.g1_member, admin = MAX(identities.admin, excluded.admin)", ) .bind(&info.id) .bind(&info.username) .bind(&info.address) .bind(info.member) .bind(admin) .bind(util::now()) .execute(&state.db) .await?; open_session(state, &info.id).await } #[derive(Deserialize)] pub struct InvitationBody { token: String, } #[derive(sqlx::FromRow)] struct InvitationRow { token_hash: String, label: String, admin: bool, groups: String, created_by: Option, identity_id: Option, } pub async fn accept_invitation(State(state): State, Json(body): Json) -> AppResult { let hash = util::sha256_hex(body.token.trim()); let now = util::now(); // Reserve the link first: of two simultaneous acceptances, one only goes on. let reserved = sqlx::query("UPDATE invitations SET used_at = ? WHERE token_hash = ? AND used_at IS NULL AND expires_at > ?") .bind(&now) .bind(&hash) .bind(&now) .execute(&state.db) .await?; if reserved.rows_affected() != 1 { return Err(AppError::Invalid("invitation_invalid")); } let inv = sqlx::query_as::<_, InvitationRow>( "SELECT token_hash, label, admin, groups, created_by, identity_id FROM invitations WHERE token_hash = ?", ) .bind(&hash) .fetch_optional(&state.db) .await? .ok_or(AppError::Invalid("invitation_invalid"))?; let identity_id = match inv.identity_id.clone() { Some(existing) => { if load_identity(&state, &existing).await?.kind != "invite" { return Err(AppError::Invalid("invitation_invalid")); } existing } None => { let id = format!("inv:{}", util::id()); sqlx::query( "INSERT INTO identities (id, kind, display_name, g1_member, admin, created_at) VALUES (?, 'invite', ?, 0, ?, ?)", ) .bind(&id) .bind(&inv.label) .bind(inv.admin) .bind(util::now()) .execute(&state.db) .await?; id } }; if inv.admin { sqlx::query("UPDATE identities SET admin = 1 WHERE id = ?").bind(&identity_id).execute(&state.db).await?; } let groups: Vec = serde_json::from_str(&inv.groups).unwrap_or_default(); grant_groups(&state, &identity_id, &groups, inv.created_by.as_deref()).await?; sqlx::query("UPDATE invitations SET identity_id = ? WHERE token_hash = ?") .bind(&identity_id) .bind(&inv.token_hash) .execute(&state.db) .await?; let session = open_session(&state, &identity_id).await?; let me = load_identity(&state, &identity_id).await?; let mut res = Json(json!({ "me": me })).into_response(); res.headers_mut().append(SET_COOKIE, session); Ok(res) } /// Create an invitation; returns the raw token, shown once. pub async fn create_invitation( state: &AppState, label: &str, admin: bool, groups: &[String], created_by: Option<&str>, identity_id: Option<&str>, ) -> AppResult { let token = util::token(); sqlx::query( "INSERT INTO invitations (token_hash, label, admin, groups, created_by, created_at, expires_at, identity_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(util::sha256_hex(&token)) .bind(label) .bind(admin) .bind(serde_json::to_string(groups).unwrap_or_else(|_| "[]".into())) .bind(created_by) .bind(util::now()) .bind(util::in_days(INVITATION_DAYS)) .bind(identity_id) .execute(&state.db) .await?; Ok(token) } pub fn invitation_link(state: &AppState, token: &str) -> String { format!("{}/invitation#{token}", state.cfg.public_url) } #[derive(Deserialize)] pub struct DevBody { name: String, #[serde(default)] admin: bool, } /// Local development: any name, member of every group. Refused unless LD_DEV_LOGIN=1. pub async fn dev_login(State(state): State, Json(body): Json) -> AppResult { if !state.cfg.dev_login { return Err(AppError::NotFound); } let name = body.name.trim(); if name.is_empty() || name.len() > 40 { return Err(AppError::Invalid("name")); } let slug: String = name.to_lowercase().chars().map(|c| if c.is_alphanumeric() { c } else { '-' }).collect(); let id = format!("dev:{slug}"); sqlx::query( "INSERT INTO identities (id, kind, display_name, g1_member, admin, created_at) VALUES (?, 'dev', ?, 0, ?, ?) ON CONFLICT(id) DO UPDATE SET admin = excluded.admin", ) .bind(&id) .bind(name) .bind(body.admin) .bind(util::now()) .execute(&state.db) .await?; let all: Vec = sqlx::query_scalar("SELECT slug FROM groups").fetch_all(&state.db).await?; grant_groups(&state, &id, &all, Some("dev")).await?; let session = open_session(&state, &id).await?; let me = load_identity(&state, &id).await?; let mut res = Json(json!({ "me": me })).into_response(); res.headers_mut().append(SET_COOKIE, session); Ok(res) } pub async fn logout(State(state): State, headers: HeaderMap) -> AppResult { if let Some(token) = cookie_value(&headers, session_cookie(&state)) { sqlx::query("DELETE FROM sessions WHERE token_hash = ?") .bind(util::sha256_hex(&token)) .execute(&state.db) .await?; } let mut res = Json(json!({ "me": null })).into_response(); res.headers_mut().append(SET_COOKIE, cookie(&state, session_cookie(&state), "", "/", 0)); Ok(res) } /// First start with LD_BOOTSTRAP_ADMIN=1 and no administrator yet: write a /// single-use administrator invitation to `/bootstrap-admin.txt` (0600). /// Nothing is printed to the logs. pub async fn bootstrap(state: &AppState) -> anyhow::Result<()> { if !state.cfg.bootstrap_admin { return Ok(()); } let admins: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM identities WHERE admin = 1").fetch_one(&state.db).await?; let pending: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM invitations WHERE admin = 1 AND used_at IS NULL AND expires_at > ?") .bind(util::now()) .fetch_one(&state.db) .await?; if admins > 0 || pending > 0 { return Ok(()); } let groups: Vec = sqlx::query_scalar("SELECT slug FROM groups").fetch_all(&state.db).await?; let token = create_invitation(state, "Yvv", true, &groups, None, None) .await .map_err(|e| anyhow::anyhow!("bootstrap invitation: {e}"))?; let path = state.cfg.data_dir.join("bootstrap-admin.txt"); write_private(&path, &format!("{}\n", invitation_link(state, &token)))?; tracing::info!(path = %path.display(), "administrator invitation written"); Ok(()) } fn write_private(path: &std::path::Path, content: &str) -> std::io::Result<()> { use std::io::Write; #[cfg(unix)] { use std::os::unix::fs::OpenOptionsExt; let mut f = std::fs::OpenOptions::new().write(true).create(true).truncate(true).mode(0o600).open(path)?; f.write_all(content.as_bytes()) } #[cfg(not(unix))] { std::fs::write(path, content) } } #[cfg(test)] mod tests { use super::*; #[test] fn return_paths_stay_local() { assert_eq!(safe_return(Some("/g/monnaie-libre")), "/g/monnaie-libre"); assert_eq!(safe_return(Some("//evil.example")), "/groupes"); assert_eq!(safe_return(Some("https://evil.example")), "/groupes"); assert_eq!(safe_return(Some("/\\evil")), "/groupes"); assert_eq!(safe_return(Some("/\t/evil.example")), "/groupes"); assert_eq!(safe_return(Some("/\n/evil.example")), "/groupes"); assert_eq!(safe_return(None), "/groupes"); } #[tokio::test] async fn a_session_over_is_never_renewed() { let db = crate::open_db("sqlite::memory:").await.expect("db"); let state = AppState::new(crate::Config::for_tests(), db).await.expect("state"); sqlx::query("INSERT INTO identities (id, kind, display_name, g1_member, admin, created_at) VALUES ('dev:a', 'dev', 'A', 0, 0, ?)") .bind(util::now()) .execute(&state.db) .await .expect("identity"); for (token, expires) in [("over", util::in_seconds(-1)), ("open", util::in_days(10))] { sqlx::query( "INSERT INTO sessions (token_hash, identity_id, created_at, expires_at) VALUES (?, 'dev:a', ?, ?)", ) .bind(util::sha256_hex(token)) .bind(util::now()) .bind(expires) .execute(&state.db) .await .expect("session"); } assert!(!renew(&state, "over").await.expect("renew")); assert!(renew(&state, "open").await.expect("renew")); assert!(!renew(&state, "open").await.expect("renew"), "once a day at most"); } #[test] fn cookie_parsing() { let mut h = HeaderMap::new(); h.insert(COOKIE, HeaderValue::from_static("a=1; ld_session=abc; b=2")); assert_eq!(cookie_value(&h, SESSION_COOKIE).as_deref(), Some("abc")); assert_eq!(cookie_value(&h, "zz"), None); } }