98b55e138e
This commit lights up the missing pieces of the admin dashboard and the
OIDC flow that the desktop client already speaks. It bundles several
independent fixes that share enough touch points (oidc/callback,
admin/mod, database schema) that splitting was more churn than help.
OIDC — desktop client
- /api/login: when TOTP is enrolled, return type:"email_check"
+ tfa_type:"tfa_check" instead of type:"tfa_check". The Flutter
client's switch only branches on access_token / email_check; the
prior shape silently fell into "bad response from server".
- /api/login dispatcher: route the second leg to login_tfa_code when
tfaCode + secret are both present, regardless of the declared type.
The desktop client sends type:"email_code" for both email-code AND
TOTP second legs and distinguishes by which field is set.
- /api/oidc/auth-query: drop the bogus extra {"body": "..."} envelope.
The desktop client's http_request_sync already wraps every response
in {status_code, headers, body}, and HbbHttpResponse::parse expects
the auth payload at that level. Our extra envelope made the parser
fail silently as DataTypeFormat and the poll loop spun until the
180 s client timeout.
- UserPayload: add a required info: {} field; the Rust-side polling
deserializer at src/hbbs_http/account.rs expects it (no
#[serde(default)]). Without it the AuthBody parse failed on every
poll, producing the same forever-pending symptom as above.
- Add an always-on info-level log line at the poll handler so this
family of "client never advances" bugs is observable from hbbs.log.
OIDC — admin dashboard
- New unauthenticated entry points:
GET /admin/oidc/providers JSON list for login.html
GET /admin/login/oidc/:provider 302 → IdP authorization endpoint
The session is marked admin-flow via a sentinel ("__admin_ui__") in
client_id_str / client_uuid so the existing /oidc/callback can tell
it apart from a desktop device flow.
- /oidc/callback finishes admin sessions by setting the
rd_admin_session cookie + 303 to /admin/. Non-admin users get a
helpful error page instead of a session.
- Admin-flow callbacks SKIP device_claim() so the dashboard sign-in
no longer inserts a phantom "__admin_ui__" device row in
device_sysinfo, and the token's peer_id / peer_uuid columns stay
blank instead of carrying the sentinel.
- admin_ui/login.html fetches the providers list on load and renders
one button per enabled provider beneath the password form.
OIDC — role-based admin sync
- New per-provider config fields admin_role + roles_claim (in
oidc.toml AND oidc_providers, via soft ALTER TABLE). When set, the
callback evaluates the userinfo claim and forces users.is_admin
accordingly on every login. Promotion AND demotion at the IdP
propagate. Two claim shapes supported:
- object key match (Zitadel:
urn:zitadel:iam:org:project:roles -> { "admin": {...} })
- string-array contains (generic: roles -> ["admin","user"])
- user_upsert_oidc gains a desired_admin: Option<bool> arg so the same
upsert path handles "leave admin alone" (desired_admin = None) and
"force from IdP" (Some(bool)). Three unit tests cover both shapes
plus the missing-claim case.
Admin dashboard — Address books
- Full CRUD for shared books from the dashboard:
create, list shares, add/upgrade/remove a per-user share with
read / read+write / full rules, delete the book.
- Personal books also get a Delete action — confirms with a stronger
message that the user's desktop client will recreate an empty
personal book on next sync if it's still signed in (deletion is
effectively "reset to empty", not "permanently revoked"). Use in
combination with user-delete to fully revoke.
- New DB methods: ab_create_shared, ab_delete (cascades peers/tags/
peer_tags/shares), ab_get_owner_kind, ab_list_shares, ab_share_set
(idempotent upsert), ab_share_remove.
Admin dashboard — Devices
- Delete action in the per-row menu. device_delete cascades through
device_sysinfo, peer (rendezvous identity), heartbeat_commands and
peer-scoped strategy_assignments. Audit logs, recordings, and AB
entries that reference the peer are intentionally preserved
(historical/manual data).
Admin dashboard — Deploy page
- New page that generates the unsigned CustomServer blob the desktop
client accepts via `rustdesk --config <blob>` (see
rustdesk/src/custom_server.rs:get_custom_server_from_config_string;
the unsigned-JSON path is a real codepath, no Pro signing key
needed). Form prefills the public key from id_ed25519.pub in CWD.
- Also emits the equivalent renamed-installer filename
(rustdesk-host=...,key=... .exe). Strips api= from the filename
when it equals the default http://<host>:21114 (Windows can't store
: or / in filenames); warns when the API URL is non-default.
Login form fixes
- TOTP form field: rename serde wire field from tfa_code to tfaCode
so the dashboard's HTMX form (input name="tfaCode") actually
populates it. The previous mismatch silently dropped the code and
the server kept asking for it.
- TOTP redirect guard: only redirect on empty 2xx body (real login).
The TOTP-required path returns 2xx with an HTML prompt fragment
that must NOT be redirected away from.
Docs
- New docs/CONFIGURATION.md covering all CLI flags, OIDC setup
(generic + Zitadel walk-through), role-based admin sync, TOTP,
strategies, address books, dashboard URL map, DB notes, and a
pre-prod security checklist.
Schema
- soft ALTER TABLE oidc_providers ADD COLUMN admin_role / roles_claim
(guarded by the duplicate-column-name swallower for SQLite < 3.35).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
175 lines
6.1 KiB
Rust
175 lines
6.1 KiB
Rust
//! `/admin/login` (POST form) and `/admin/logout` (POST). On success login
|
|
//! sets an HttpOnly + SameSite=Strict cookie containing the freshly-minted
|
|
//! Bearer token; the browser carries it on every subsequent request to
|
|
//! `/admin/*` and `/api/*`. The middleware in `api::middleware` already
|
|
//! accepts both `Authorization: Bearer …` and the cookie.
|
|
|
|
use crate::api::auth::mint_token;
|
|
use crate::api::middleware::{sha256_token, SESSION_COOKIE};
|
|
use crate::api::state::AppState;
|
|
use crate::api::users::verify_password;
|
|
use axum::extract::{Extension, Form};
|
|
use axum::http::header::{COOKIE, SET_COOKIE};
|
|
use axum::http::{HeaderMap, HeaderValue, StatusCode};
|
|
use axum::response::{Html, IntoResponse, Response};
|
|
use serde::Deserialize;
|
|
use std::sync::Arc;
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct LoginForm {
|
|
pub username: String,
|
|
pub password: String,
|
|
/// 6-digit TOTP code, present on the second leg when the first leg
|
|
/// returned `tfa_check`. The HTML input is `name="tfaCode"` (camelCase)
|
|
/// to match the rest of the dashboard's form conventions, so we rename
|
|
/// the wire field rather than renaming the input.
|
|
#[serde(default, rename = "tfaCode")]
|
|
pub tfa_code: String,
|
|
/// Echo of the TOTP nonce the first-leg response set on the form.
|
|
#[serde(default)]
|
|
pub secret: String,
|
|
}
|
|
|
|
pub async fn login(
|
|
Extension(state): Extension<Arc<AppState>>,
|
|
Form(form): Form<LoginForm>,
|
|
) -> Response {
|
|
// First leg: password verify. Same DB call paths as `/api/login` —
|
|
// we re-use the existing helpers so the dashboard can't accidentally
|
|
// diverge from the API's auth contract.
|
|
let user = match state.db.user_find_by_username(&form.username).await {
|
|
Ok(Some(u)) => u,
|
|
Ok(None) => return error_fragment("Bad credentials"),
|
|
Err(e) => return error_fragment(&format!("internal: {}", e)),
|
|
};
|
|
let pw_ok = match verify_password(user.password_hash.clone(), form.password.clone()).await {
|
|
Ok(b) => b,
|
|
Err(e) => return error_fragment(&format!("internal: {}", e)),
|
|
};
|
|
if !pw_ok {
|
|
return error_fragment("Bad credentials");
|
|
}
|
|
if user.status == 0 {
|
|
return error_fragment("Account disabled");
|
|
}
|
|
if !user.is_admin {
|
|
// Only admins can use the dashboard. Non-admin users still get
|
|
// tokens via `/api/login` for the desktop client; they just don't
|
|
// see the management surface.
|
|
return error_fragment("Admin access required");
|
|
}
|
|
// Optional second leg: TOTP. If the user has a secret enrolled and the
|
|
// form didn't carry a code, return a fragment that asks for one.
|
|
let totp_secret = state
|
|
.db
|
|
.totp_get_secret(user.id)
|
|
.await
|
|
.ok()
|
|
.flatten();
|
|
if let Some(secret_b32) = totp_secret {
|
|
if form.tfa_code.is_empty() {
|
|
// Shape used by the JS in login.html to switch to the second
|
|
// leg: it watches for the special marker via HX-Trigger and
|
|
// reveals the #tfa-section.
|
|
let frag = format!(
|
|
r#"<span data-tfa-required="1" class="text-amber-300">Enter your 6-digit authenticator code.</span>
|
|
<script>
|
|
document.getElementById('tfa-section').classList.remove('hidden');
|
|
document.getElementById('tfaCode').focus();
|
|
</script>"#
|
|
);
|
|
// We don't need a session yet — caller will resubmit with the
|
|
// same username/password plus the code. (No nonce involved on
|
|
// the dashboard path: the password is already in scope, so
|
|
// tfa_check / tfa_code are folded into one form.)
|
|
let _ = secret_b32;
|
|
return Html(frag).into_response();
|
|
}
|
|
// Verify the supplied code.
|
|
let ok = match crate::api::auth::verify_totp(&secret_b32, &form.tfa_code) {
|
|
Ok(b) => b,
|
|
Err(_) => return error_fragment("Internal TOTP error"),
|
|
};
|
|
if !ok {
|
|
return error_fragment("Bad TOTP code");
|
|
}
|
|
}
|
|
|
|
// Mint + persist a token, set the cookie.
|
|
let token = mint_token();
|
|
let sha = sha256_token(&token);
|
|
if let Err(e) = state
|
|
.db
|
|
.token_insert(
|
|
user.id,
|
|
&sha,
|
|
"",
|
|
"",
|
|
r#"{"source":"admin-ui"}"#,
|
|
state.cfg.session_ttl_secs,
|
|
)
|
|
.await
|
|
{
|
|
return error_fragment(&format!("internal: {}", e));
|
|
}
|
|
let cookie = format!(
|
|
"{name}={token}; HttpOnly; Path=/; SameSite=Strict; Max-Age={ttl}",
|
|
name = SESSION_COOKIE,
|
|
token = token,
|
|
ttl = state.cfg.session_ttl_secs,
|
|
);
|
|
let mut headers = HeaderMap::new();
|
|
if let Ok(v) = HeaderValue::from_str(&cookie) {
|
|
headers.insert(SET_COOKIE, v);
|
|
}
|
|
// 200 with empty body; the form's hx-on::after-request redirects on
|
|
// success.
|
|
(StatusCode::OK, headers, "").into_response()
|
|
}
|
|
|
|
pub async fn logout(
|
|
Extension(state): Extension<Arc<AppState>>,
|
|
headers: HeaderMap,
|
|
) -> Response {
|
|
// Best-effort: pull the token out of the cookie, drop the row.
|
|
if let Some(tok) = cookie_token(&headers) {
|
|
let sha = sha256_token(&tok);
|
|
let _ = state.db.token_delete(&sha).await;
|
|
}
|
|
let mut out = HeaderMap::new();
|
|
let clear = format!(
|
|
"{name}=; HttpOnly; Path=/; SameSite=Strict; Max-Age=0",
|
|
name = SESSION_COOKIE
|
|
);
|
|
if let Ok(v) = HeaderValue::from_str(&clear) {
|
|
out.insert(SET_COOKIE, v);
|
|
}
|
|
(StatusCode::OK, out, "").into_response()
|
|
}
|
|
|
|
fn cookie_token(headers: &HeaderMap) -> Option<String> {
|
|
let s = headers.get(COOKIE)?.to_str().ok()?;
|
|
for pair in s.split(';') {
|
|
if let Some((name, value)) = pair.trim().split_once('=') {
|
|
if name.trim() == SESSION_COOKIE {
|
|
let v = value.trim();
|
|
if !v.is_empty() {
|
|
return Some(v.to_string());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn error_fragment(msg: &str) -> Response {
|
|
let html = format!("<span>{}</span>", html_escape(msg));
|
|
(StatusCode::UNAUTHORIZED, Html(html)).into_response()
|
|
}
|
|
|
|
fn html_escape(s: &str) -> String {
|
|
s.replace('&', "&")
|
|
.replace('<', "<")
|
|
.replace('>', ">")
|
|
}
|