621 lines
22 KiB
Rust
621 lines
22 KiB
Rust
//! 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:<index>`, 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<String> {
|
|
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<String>,
|
|
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<IdentityRow>);
|
|
|
|
/// The signed-in person; 401 otherwise.
|
|
pub struct User(pub IdentityRow);
|
|
|
|
impl FromRequestParts<AppState> for Viewer {
|
|
type Rejection = AppError;
|
|
|
|
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
|
|
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<AppState> for User {
|
|
type Rejection = AppError;
|
|
|
|
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
|
|
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<HeaderValue> {
|
|
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<IdentityRow> {
|
|
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<AppState>) -> Json<serde_json::Value> {
|
|
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<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. 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))) {
|
|
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<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::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<String>,
|
|
}
|
|
|
|
fn redirect(to: &str, cookies: Vec<HeaderValue>) -> 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<AppState>, Query(q): Query<StartQuery>) -> AppResult<Response> {
|
|
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::<String>());
|
|
Ok(redirect(url.as_str(), vec![cookie(&state, oauth_cookie(&state), &stash, "/api/auth/g1", 600)]))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct CallbackQuery {
|
|
code: Option<String>,
|
|
state: Option<String>,
|
|
error: Option<String>,
|
|
}
|
|
|
|
#[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<String>,
|
|
#[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<AppState>,
|
|
headers: HeaderMap,
|
|
Query(q): Query<CallbackQuery>,
|
|
) -> 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<UserInfo> {
|
|
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<HeaderValue> {
|
|
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<String>,
|
|
identity_id: Option<String>,
|
|
}
|
|
|
|
pub async fn accept_invitation(State(state): State<AppState>, Json(body): Json<InvitationBody>) -> AppResult<Response> {
|
|
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<String> = 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<String> {
|
|
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<AppState>, Json(body): Json<DevBody>) -> AppResult<Response> {
|
|
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<String> = 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<AppState>, headers: HeaderMap) -> AppResult<Response> {
|
|
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 `<data>/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<String> = 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);
|
|
}
|
|
}
|