aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/access.md4
-rw-r--r--docs/dashboard.md6
-rw-r--r--docs/openapi.yaml3
-rw-r--r--src/api/dashboard/dashboard.html17
-rw-r--r--src/api/dashboard/login.html18
-rw-r--r--src/api/login.rs26
6 files changed, 62 insertions, 12 deletions
diff --git a/docs/access.md b/docs/access.md
index 44c89f3..1ee9994 100644
--- a/docs/access.md
+++ b/docs/access.md
@@ -99,7 +99,9 @@ curl -H "Authorization: Bearer ct1.…" http://other-node:3000/store
a capability granted or revoked counts on a node as soon as the change reaches it.
* **How long**: `--token-lifetime` (12 hours), refreshed (`POST /auth/refresh`, on any
node, which signs the new one) until `--session-lifetime` (7 days) after the login;
- then the user logs in again. A refreshed token may say less (`limit`), never more.
+ then the user logs in again. A refreshed token may say less (`limit`), never more,
+ and live less (`lifetime`, in seconds): a dashboard opening another member's hands it
+ one reading only, for five minutes ([`dashboard.md`](dashboard.md)).
* **Revoking**: a user disabled or removed is refused at once; all of a user's tokens,
by the user (`POST /auth/logout`) or by one who may manage users (`DELETE
/admin/users/{name}/tokens`), which sets `_continuum/logouts/<name>`: tokens issued
diff --git a/docs/dashboard.md b/docs/dashboard.md
index 271592a..322abc6 100644
--- a/docs/dashboard.md
+++ b/docs/dashboard.md
@@ -200,8 +200,10 @@ measured.
* **Right-click a node**: its menu: subscribing, and opening its dashboard.
* **Open a node's dashboard** (its menu): in this page, where the node publishes one
(`--dashboard-url`, in the member list; e.g. behind a reverse proxy,
- [`operations.md`](operations.md)). The other dashboard asks for its own credentials
- where its proxy or node does. It opens as this one was left: the map's nodes where
+ [`operations.md`](operations.md)). A token from this node goes along, reading only,
+ for five minutes: where the browser has no session at the other dashboard, its login
+ page begins one with it (reading only; log in there to do more), unless its proxy asks
+ for credentials of its own. It opens as this one was left: the map's nodes where
they were (on a dynamic map they then move to their places among the other node's
links, smoothly; new ones are placed among them), the view, the pins, what is in
focus, the legend shown or hidden, what is open in the figures and in the document,
diff --git a/docs/openapi.yaml b/docs/openapi.yaml
index 9b75618..d3715b1 100644
--- a/docs/openapi.yaml
+++ b/docs/openapi.yaml
@@ -1962,6 +1962,9 @@ components:
limit:
type: array
items: { $ref: '#/components/schemas/CapabilityRequest' }
+ lifetime:
+ type: integer
+ description: Seconds the token lives, if less than `token-lifetime` (a token to hand over, say).
TokenAnswer:
type: object
properties:
diff --git a/src/api/dashboard/dashboard.html b/src/api/dashboard/dashboard.html
index fa4d436..17ff44b 100644
--- a/src/api/dashboard/dashboard.html
+++ b/src/api/dashboard/dashboard.html
@@ -2058,7 +2058,7 @@ if (handOver) {
// The fragment that opens another node's dashboard (`id`'s) as this page is now
// (`handOver`). What is in focus stays, but `id` itself: its page is its own. Also this
// node's own page, reloaded for a new version (`id` null).
-function handOff(id) {
+function handOff(id, handed) {
const settings = {};
try {
for (let i = 0; i < localStorage.length; i++) {
@@ -2076,6 +2076,9 @@ function handOff(id) {
legend: !document.getElementById("legends").hidden,
figures: [...open],
document: [...documentOpen],
+ // A token reading only, for five minutes: the other node's login page begins a
+ // session with it (`login.html`), where the browser has none there.
+ token: handed || undefined,
}));
}
// `[x, y]` turned by `angle`.
@@ -2446,11 +2449,19 @@ addEventListener("keydown", event => {
// opened in this page; else its halo flashes red, larger than usual, and "no dashboard"
// rises from it in the same red, both fading out. This node's own: here already.
const FLASH_MS = 1200;
-function openDashboard(id) {
+const READ_ALL = [{ path: [], rights: ["read"] }, { path: ["_continuum"], rights: ["read"] }];
+async function openDashboard(id) {
const member = state.graph.members[id];
if (!member || member.self) return;
if (member.dashboard) {
- location.assign(member.dashboard.split("#")[0] + handOff(id));
+ let handed = null;
+ if (token) {
+ try {
+ handed = (await tokenCall("../auth/refresh", token.token,
+ { limit: READ_ALL, lifetime: 300 })).token;
+ } catch (_) { /* the other dashboard asks for a login then */ }
+ }
+ location.assign(member.dashboard.split("#")[0] + handOff(id, handed));
return;
}
const layer = document.getElementById("flashes");
diff --git a/src/api/dashboard/login.html b/src/api/dashboard/login.html
index 77668ee..e09d31c 100644
--- a/src/api/dashboard/login.html
+++ b/src/api/dashboard/login.html
@@ -73,6 +73,24 @@ document.getElementById("token").addEventListener("submit", async event => {
why.textContent = "";
try { await begin(event.target.token.value.trim()); } catch (error) { failed(error); }
});
+// Opened from another member's dashboard (`handOff`), sent here for want of a session:
+// its token, reading only, begins one; the rest goes on to the dashboard.
+(async () => {
+ const HAND_OFF = "#continuum=";
+ if (!location.hash.startsWith(HAND_OFF)) return;
+ const fragment = location.hash;
+ try { history.replaceState(null, "", location.pathname + location.search); } catch (_) {}
+ let handed;
+ try { handed = JSON.parse(decodeURIComponent(fragment.slice(HAND_OFF.length))); } catch (_) { return; }
+ if (!handed || typeof handed.token !== "string") return;
+ try {
+ await call("auth/session", "POST", "Bearer " + handed.token);
+ delete handed.token;
+ location.replace("admin/dashboard" + HAND_OFF + encodeURIComponent(JSON.stringify(handed)));
+ } catch (error) {
+ why.textContent = "The token handed over isn't taken (" + error.message + "): log in.";
+ }
+})();
document.getElementById("out").addEventListener("click", async () => {
await fetch("auth/session", { method: "DELETE", credentials: "same-origin" }).catch(() => {});
why.textContent = "Logged out of this browser.";
diff --git a/src/api/login.rs b/src/api/login.rs
index 1907bac..3b4ee32 100644
--- a/src/api/login.rs
+++ b/src/api/login.rs
@@ -146,6 +146,9 @@ pub(crate) async fn bearer(
pub(crate) struct LimitRequest {
#[serde(default)]
limit: Option<Vec<CapRequest>>,
+ /// A shorter life than `--token-lifetime`, in seconds: a token to hand over, say.
+ #[serde(default)]
+ lifetime: Option<u64>,
}
impl LimitRequest {
@@ -209,7 +212,8 @@ pub(crate) async fn login(
Some("log in with a password or through the proxy; a token is refreshed at /auth/refresh".to_owned()),
));
}
- let limit = body.map(|Json(body)| body).unwrap_or_default().limit()?;
+ let body = body.map(|Json(body)| body).unwrap_or_default();
+ let limit = body.limit()?;
let (swarm, now) = issuing(&state).await?;
{
let replica = state.doc.read().await;
@@ -222,11 +226,17 @@ pub(crate) async fn login(
));
}
}
- let claims = new_login(&state, swarm, now, &user.0, via.0, limit);
+ let claims = new_login(&state, swarm, now, &user.0, via.0, limit, body.lifetime);
info!(user = %user.0, login = via.0, session = %claims.session, "Logged in");
answer(&state, &claims)
}
+/// How long a token lives: `--token-lifetime`, or less if `asked` (seconds) is less.
+fn lifetime_ms(state: &AppState, asked: Option<u64>) -> u64 {
+ let lifetime = state.token_lifetime.as_millis() as u64;
+ asked.map_or(lifetime, |secs| secs.saturating_mul(1000).min(lifetime))
+}
+
/// The claims of a new login of `user`, by `login`, now: a session of its own.
fn new_login(
state: &AppState,
@@ -235,6 +245,7 @@ fn new_login(
user: &str,
login: &str,
limit: Option<Vec<Capability>>,
+ lifetime: Option<u64>,
) -> Claims {
let session_exp = now.saturating_add(state.session_lifetime.as_millis() as u64);
Claims {
@@ -245,7 +256,7 @@ fn new_login(
login: login.to_owned(),
iat: now,
exp: now
- .saturating_add(state.token_lifetime.as_millis() as u64)
+ .saturating_add(lifetime_ms(state, lifetime))
.min(session_exp),
session: token::new_session(),
session_exp,
@@ -292,6 +303,7 @@ pub(crate) async fn login_key(
) -> Result<Response, ApiError> {
let limit = LimitRequest {
limit: request.limit,
+ lifetime: None,
}
.limit()?;
let (swarm, now) = issuing(&state).await?;
@@ -322,7 +334,7 @@ pub(crate) async fn login_key(
ApiError(StatusCode::UNAUTHORIZED, Some(why))
})?;
}
- let claims = new_login(&state, swarm, now, &request.user, "key", limit);
+ let claims = new_login(&state, swarm, now, &request.user, "key", limit, None);
info!(user = %request.user, login = "key", session = %claims.session, "Logged in");
answer(&state, &claims)
}
@@ -396,7 +408,9 @@ async fn refreshed(
Some("the login ended: log in again".to_owned()),
));
}
- let limit = match body.map(|Json(body)| body).unwrap_or_default().limit()? {
+ let body = body.map(|Json(body)| body).unwrap_or_default();
+ let lifetime = body.lifetime;
+ let limit = match body.limit()? {
None => earlier.limit.clone(),
Some(limit) => {
if earlier
@@ -418,7 +432,7 @@ async fn refreshed(
issuer: state.local_id().to_owned(),
iat: now,
exp: now
- .saturating_add(state.token_lifetime.as_millis() as u64)
+ .saturating_add(lifetime_ms(state, lifetime))
.min(earlier.session_exp),
limit,
..(**earlier).clone()