diff --git a/Cargo.lock b/Cargo.lock index 308e4af..e16d1ac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1124,6 +1124,7 @@ dependencies = [ "minreq", "once_cell", "ping", + "qrcode", "regex", "reqwest", "rust-ini", @@ -2137,6 +2138,12 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e9e1dcb320d6839f6edb64f7a4a59d39b30480d4d1765b56873f7c858538a5fe" +[[package]] +name = "qrcode" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" + [[package]] name = "quickcheck" version = "1.0.3" diff --git a/Cargo.toml b/Cargo.toml index f4e9342..c647267 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ path = "src/utils.rs" hbb_common = { path = "libs/hbb_common" } tokio = { version = "1", features = ["fs", "io-util"] } totp-rs = { version = "5.4", default-features = false } +qrcode = { version = "0.14", default-features = false, features = ["svg"] } lettre = { version = "0.10", default-features = false, features = ["smtp-transport", "tokio1-rustls-tls", "builder"] } toml = "0.7" serde_derive = "1.0" diff --git a/admin_ui/index.html b/admin_ui/index.html index fbcbdb2..e01622b 100644 --- a/admin_ui/index.html +++ b/admin_ui/index.html @@ -46,7 +46,9 @@ Deploy -
+
+ My profile + +"##.to_string() + }; + + let totp_section = if let Some(panel) = totp_panel_override { + panel + } else if oidc_linked { + r##"
+

Two-factor authentication

+

+ MFA is managed by your identity provider — local TOTP isn't used for OIDC sign-ins. +

+
"## + .to_string() + } else if has_totp { + format!( + r##"
+

Two-factor authentication

+
+ enrolled + Sign-ins require a 6-digit code from your authenticator. +
+
+ + +
+
"## + ) + } else { + r##"
+

Two-factor authentication

+

+ Add a TOTP authenticator (1Password, Authy, Google Authenticator, etc.) so sign-ins also require a 6-digit code. +

+ +
"##.to_string() + }; + + Ok(format!( + r##"
+
+

Profile

+

Signed in as {username}

+
+ {notice} + +
+

Profile info

+
+ + + +
+
+ + {password_section} + + {totp_section} +
"##, + username = html_escape(&user.name), + display_name = html_escape(&row.display_name), + email = html_escape(&row.email), + notice = notice_block, + password_section = password_section, + totp_section = totp_section, + )) +} + +async fn render_totp_enroll_panel( + state: &Arc, + user: &AuthedUser, + secret_b32: &str, + qr_svg: &str, + notice: Option<(&str, &str)>, +) -> Result { + let panel = format!( + r##"
+

Confirm TOTP enrollment

+

+ Scan the QR code with your authenticator app, then enter the 6-digit code it shows to confirm. + Nothing is enrolled until you submit a valid code. +

+
+
{qr}
+
+
+ Secret (manual entry): + {secret} +
+
+ + + +
+
+
+
"##, + qr = qr_svg, + secret = html_escape(secret_b32), + ); + render_full_page_with_totp_override(state, user, notice, Some(panel)).await +} + +fn render_qr_svg(payload: &str) -> String { + // qrcode 0.14: build the QR, then render with the SVG renderer. + // .min_dimensions caps how big the SVG-pixel grid is; the actual + // CSS size is handled by inline width/height, but the underlying + // module size needs to be reasonable so it stays crisp on retina. + use qrcode::render::svg; + use qrcode::QrCode; + match QrCode::new(payload.as_bytes()) { + Ok(code) => code + .render::>() + .min_dimensions(180, 180) + .dark_color(svg::Color("#000")) + .light_color(svg::Color("#fff")) + .build(), + Err(_) => "
QR encode failed
".to_string(), + } +} + +fn notice_html(kind: &str, msg: &str) -> String { + let (border, bg, text) = match kind { + "ok" => ("emerald-700/50", "emerald-900/30", "emerald-300"), + _ => ("rose-700/50", "rose-900/30", "rose-300"), + }; + format!( + r##"
{msg}
"##, + border = border, + bg = bg, + text = text, + msg = html_escape(msg), + ) +} + +fn html_escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + +fn url_encode(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for b in s.as_bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(*b as char); + } + _ => { + let _ = write!(out, "%{:02X}", b); + } + } + } + out +} diff --git a/src/api/admin/pages/users.rs b/src/api/admin/pages/users.rs index 6bc2c84..97f996b 100644 --- a/src/api/admin/pages/users.rs +++ b/src/api/admin/pages/users.rs @@ -90,6 +90,34 @@ async fn set_email_inline( // ---------- per-row actions ---------- +#[derive(Debug, Deserialize)] +pub struct UpdateInfoForm { + #[serde(default)] + pub display_name: String, + #[serde(default)] + pub email: String, +} + +pub async fn update_info( + Extension(state): Extension>, + admin: AuthedUser, + Path(id): Path, + Form(form): Form, +) -> Result, ApiError> { + require_admin(&admin)?; + state + .db + .user_set_display_name(id, form.display_name.trim()) + .await + .map_err(|e| ApiError::Internal(e.to_string()))?; + state + .db + .raw_update_user_email(id, form.email.trim()) + .await + .map_err(|e| ApiError::Internal(e.to_string()))?; + notice_then_table(&state, "ok", "Profile updated.").await +} + #[derive(Debug, Deserialize)] pub struct PasswordResetForm { pub password: String, @@ -102,6 +130,24 @@ pub async fn reset_password( Form(form): Form, ) -> Result, ApiError> { require_admin(&admin)?; + // Server-side guard: even though the UI hides the form for OIDC + // accounts, refuse to set a local password on them. Letting a local + // password slip in would silently re-enable password sign-in and + // bypass any MFA the IdP enforces. + let target = state + .db + .user_find_by_id(id) + .await + .map_err(|e| ApiError::Internal(e.to_string()))? + .ok_or(ApiError::NotFound)?; + if target.is_oidc_linked() { + return notice_then_table( + &state, + "error", + "This account is linked to OIDC — set the password at the identity provider instead.", + ) + .await; + } if form.password.len() < 4 { return notice_then_table( &state, @@ -334,6 +380,13 @@ async fn render_table(state: &Arc) -> Result { totp.insert(u.id, b); } } + // Single GROUP BY query for the whole "last seen" column, derived + // from MAX(tokens.last_used_at) per user. + let last_seen = state + .db + .users_last_seen_map() + .await + .unwrap_or_default(); let mut s = String::new(); // No `overflow-hidden` on the table wrapper: the per-row action menu is // an absolutely-positioned `
` popover inside a , and the @@ -349,19 +402,25 @@ async fn render_table(state: &Arc) -> Result { Status Admin TOTP + Last seen Actions "##, ); for u in &users { - render_user_row(&mut s, u, *totp.get(&u.id).unwrap_or(&false)); + render_user_row( + &mut s, + u, + *totp.get(&u.id).unwrap_or(&false), + last_seen.get(&u.id).map(String::as_str), + ); } s.push_str(" \n
"); Ok(s) } -fn render_user_row(s: &mut String, u: &UserRow, has_totp: bool) { +fn render_user_row(s: &mut String, u: &UserRow, has_totp: bool, last_seen: Option<&str>) { let status_badge = match u.status { 1 => r#"active"#, 0 => r#"disabled"#, @@ -373,11 +432,56 @@ fn render_user_row(s: &mut String, u: &UserRow, has_totp: bool) { } else { "" }; - let totp_badge = if has_totp { + // The TOTP column doubles as an "auth path" indicator: OIDC-linked + // users get an "OIDC" badge (their MFA lives at the IdP — local + // TOTP is moot), and OIDC takes precedence over local TOTP if both + // somehow exist. + let totp_badge = if u.is_oidc_linked() { + r#"OIDC"# + } else if has_totp { r#"enrolled"# } else { "" }; + let oidc_linked = u.is_oidc_linked(); + // OIDC-linked users sign in via the IdP — adding a local password + // would let them bypass the IdP (and any MFA enforced there). Show + // a note instead of the password-reset form for these accounts. + let password_form = if oidc_linked { + r##"
+ Linked to OIDC — password sign-in is disabled. +
"## + .to_string() + } else { + format!( + r##"
+ + +
"##, + id = u.id, + ) + }; + let (last_seen_rel, last_seen_abs) = match last_seen { + Some(ts) => (relative_ts(ts), html_escape(ts)), + None => ("never".to_string(), String::new()), + }; + // TOTP enrollment is self-service (the user does it on their + // profile page so they can scan the QR + verify a code before + // we store the secret). Admin-side action is reset/disable only, + // and only relevant when the user has it enrolled. + let totp_button = if has_totp { + format!( + r##""##, + id = u.id, + username = html_escape(&u.username), + ) + } else { + String::new() + }; let _ = write!( s, r##" @@ -387,14 +491,17 @@ fn render_user_row(s: &mut String, u: &UserRow, has_totp: bool) { {status} {admin} {totp} + {last_seen_rel}
··· -
-
- - +
+ + + + + {password_form} - + {totp_button}