Files
rustdesk-server/src/api/auth.rs
T
mike 98b55e138e feat(admin): OIDC sign-in, role sync, deploy/delete UX, and docs
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>
2026-05-02 01:05:52 +02:00

377 lines
12 KiB
Rust

use crate::api::email;
use crate::api::error::ApiError;
use crate::api::middleware::{sha256_token, AuthedUser};
use crate::api::state::AppState;
use crate::api::users::{verify_password, UserPayload};
use crate::database::UserRow;
use axum::extract::Extension;
use axum::http::StatusCode;
use axum::Json;
use serde::Deserialize;
use serde_json::{json, Value};
use std::sync::Arc;
use totp_rs::{Algorithm, Secret, TOTP};
const EMAIL_CODE_TTL_SECS: i64 = 600;
/// `LoginRequest` mirrors the Flutter client at
/// flutter/lib/common/hbbs/hbbs.dart:133. M1 only consults `username`,
/// `password`, and `type`; the other fields are tolerated for forward-compat.
#[derive(Debug, Deserialize)]
pub struct LoginRequest {
#[serde(default)]
pub username: Option<String>,
#[serde(default)]
pub password: Option<String>,
#[serde(default)]
pub id: Option<String>,
#[serde(default)]
pub uuid: Option<String>,
#[serde(default, rename = "type")]
pub kind: Option<String>,
#[serde(default, rename = "deviceInfo")]
pub device_info: Option<Value>,
// Tolerated, ignored in M1:
#[serde(default)]
pub auto_login: Option<bool>,
#[serde(default, rename = "verificationCode")]
pub verification_code: Option<String>,
#[serde(default, rename = "tfaCode")]
pub tfa_code: Option<String>,
#[serde(default)]
pub secret: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct IdUuidBody {
#[serde(default)]
pub id: Option<String>,
#[serde(default)]
pub uuid: Option<String>,
}
pub async fn login_options_head() -> StatusCode {
StatusCode::OK
}
pub async fn login_options(Extension(state): Extension<Arc<AppState>>) -> Json<Vec<String>> {
// Static base set from config (account / email_code), plus a dynamic
// `oidc/<name>` entry per enabled provider in the DB. Recomputed per
// request so adding a provider via SQL takes effect without a restart.
let mut out = state.cfg.login_options.clone();
if !state.cfg.public_base_url.is_empty() {
if let Ok(providers) = state.db.oidc_provider_list_enabled().await {
for p in providers {
out.push(format!("oidc/{}", p.name));
}
}
}
Json(out)
}
const TFA_CHALLENGE_TTL_SECS: i64 = 300;
pub async fn login(
Extension(state): Extension<Arc<AppState>>,
Json(req): Json<LoginRequest>,
) -> Result<Json<Value>, ApiError> {
// The desktop client reuses the email-code dialog for the TOTP second
// leg: it POSTs `type: "email_code"` with `tfaCode` set (and the email
// `verificationCode` field also set, but we ignore that when tfaCode is
// present). Detect that shape up-front and route to the TOTP verifier;
// otherwise dispatch on the declared `type`.
let has_tfa = req.tfa_code.as_deref().is_some_and(|s| !s.is_empty())
&& req.secret.as_deref().is_some_and(|s| !s.is_empty());
if has_tfa {
return login_tfa_code(state, req).await;
}
let kind = req.kind.as_deref().unwrap_or("account");
match kind {
"account" | "" => login_account(state, req).await,
"tfa_code" => login_tfa_code(state, req).await,
"email_code" => login_email_code(state, req).await,
other => Err(ApiError::BadRequest(format!(
"unsupported login type: {}",
other
))),
}
}
/// Two-leg passwordless login by email. Leg 1 (no `verificationCode`) mints a
/// fresh 6-digit code and emails it to the user (or logs to stdout when SMTP
/// is unconfigured). Leg 2 (with `verificationCode`) verifies the code,
/// consumes it, and issues an access token.
async fn login_email_code(
state: Arc<AppState>,
req: LoginRequest,
) -> Result<Json<Value>, ApiError> {
// The Flutter client passes the email/username in the `username` field;
// accept it either as a literal email or as a username we can map to one.
let identifier = req
.username
.as_deref()
.filter(|s| !s.is_empty())
.ok_or_else(|| ApiError::BadRequest("username (email) required".into()))?;
let user = resolve_user_by_identifier(&state, identifier).await?;
let email = if !user.email.is_empty() {
user.email.clone()
} else if user.username.contains('@') {
// Operator bootstraps users with email-as-username — accept that.
user.username.clone()
} else {
return Err(ApiError::BadRequest(
"user has no email address on file".into(),
));
};
if let Some(code) = req
.verification_code
.as_deref()
.filter(|s| !s.is_empty())
{
// Leg 2: verify.
let supplied_hash = sodiumoxide::crypto::hash::sha256::hash(code.as_bytes())
.as_ref()
.to_vec();
let ok = state
.db
.email_code_verify(&email, &supplied_hash)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
if !ok {
return Err(ApiError::BadCredentials);
}
if user.status == 0 {
return Err(ApiError::AccountDisabled);
}
if user.status == -1 {
return Err(ApiError::Unverified);
}
return issue_session(&state, &req, &user).await;
}
// Leg 1: mint + send a fresh code.
let (code, code_hash) = email::mint_code();
state
.db
.email_code_create(&email, &code_hash, EMAIL_CODE_TTL_SECS)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
if let Err(e) = email::send_login_code(state.cfg.email.as_ref(), &email, &code).await {
return Err(ApiError::Internal(e));
}
Ok(Json(json!({ "type": "email_check" })))
}
async fn resolve_user_by_identifier(
state: &AppState,
identifier: &str,
) -> Result<UserRow, ApiError> {
if identifier.contains('@') {
if let Some(u) = state
.db
.user_find_by_email(identifier)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?
{
return Ok(u);
}
}
state
.db
.user_find_by_username(identifier)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?
.ok_or(ApiError::BadCredentials)
}
async fn login_account(
state: Arc<AppState>,
req: LoginRequest,
) -> Result<Json<Value>, ApiError> {
let username = req
.username
.as_deref()
.filter(|s| !s.is_empty())
.ok_or_else(|| ApiError::BadRequest("username required".into()))?;
let password = req
.password
.as_deref()
.filter(|s| !s.is_empty())
.ok_or_else(|| ApiError::BadRequest("password required".into()))?;
let user = state
.db
.user_find_by_username(username)
.await?
.ok_or(ApiError::BadCredentials)?;
let ok = verify_password(user.password_hash.clone(), password.to_string()).await?;
if !ok {
return Err(ApiError::BadCredentials);
}
if user.status == 0 {
return Err(ApiError::AccountDisabled);
}
if user.status == -1 {
return Err(ApiError::Unverified);
}
// 2FA gate: if the user has TOTP enrolled, mint a short-lived nonce and
// tell the client we want the TOTP code in a follow-up POST. The client
// echoes the nonce back as `secret`.
//
// Wire shape matches the Flutter client's expectations
// (flutter/lib/common/widgets/login.dart:485): the outer `type` is the
// generic `email_check` envelope (the dialog the client opens for any
// second-leg challenge), and `tfa_type` distinguishes TOTP (`tfa_check`)
// from email (`email_check`). Returning `type:"tfa_check"` directly
// would miss the switch's only branch and surface as the unhelpful
// "bad response from server" toast.
if state.db.totp_get_secret(user.id).await?.is_some() {
let nonce = state
.db
.tfa_challenge_create(user.id, TFA_CHALLENGE_TTL_SECS)
.await?;
return Ok(Json(json!({
"type": "email_check",
"tfa_type": "tfa_check",
"secret": nonce,
})));
}
issue_session(&state, &req, &user).await
}
async fn login_tfa_code(
state: Arc<AppState>,
req: LoginRequest,
) -> Result<Json<Value>, ApiError> {
let nonce = req
.secret
.as_deref()
.filter(|s| !s.is_empty())
.ok_or_else(|| ApiError::BadRequest("secret required".into()))?;
let code = req
.tfa_code
.as_deref()
.filter(|s| !s.is_empty())
.ok_or_else(|| ApiError::BadRequest("tfaCode required".into()))?;
let user_id = state
.db
.tfa_challenge_lookup(nonce)
.await?
.ok_or_else(|| ApiError::BadRequest("invalid or expired challenge".into()))?;
let secret_b32 = state
.db
.totp_get_secret(user_id)
.await?
.ok_or_else(|| ApiError::BadRequest("TOTP not enrolled".into()))?;
if !verify_totp(&secret_b32, code)? {
// Leave the challenge row alive — operators may want short retries.
return Err(ApiError::BadCredentials);
}
state.db.tfa_challenge_consume(nonce).await?;
let user = state
.db
.user_find_by_id(user_id)
.await?
.ok_or(ApiError::Unauthorized)?;
issue_session(&state, &req, &user).await
}
/// Build and persist a fresh access token, claim the calling device, and
/// return the standard logged-in response shape. Shared by the password,
/// post-TOTP, post-email-code, and (later) post-OIDC paths.
async fn issue_session(
state: &AppState,
req: &LoginRequest,
user: &UserRow,
) -> Result<Json<Value>, ApiError> {
let token = mint_token();
let sha = sha256_token(&token);
let device_info_json = req
.device_info
.as_ref()
.map(|v| v.to_string())
.unwrap_or_default();
state
.db
.token_insert(
user.id,
&sha,
req.id.as_deref().unwrap_or_default(),
req.uuid.as_deref().unwrap_or_default(),
&device_info_json,
state.cfg.session_ttl_secs,
)
.await?;
// Bind the calling device to this user so /api/peers shows it correctly.
state
.db
.device_claim(
user.id,
req.id.as_deref().unwrap_or_default(),
req.uuid.as_deref().unwrap_or_default(),
)
.await;
Ok(Json(json!({
"access_token": token,
"type": "access_token",
"user": UserPayload::from(user),
})))
}
pub(crate) fn verify_totp(secret_b32: &str, code: &str) -> Result<bool, ApiError> {
let secret = Secret::Encoded(secret_b32.to_string())
.to_bytes()
.map_err(|e| ApiError::Internal(format!("bad TOTP secret: {:?}", e)))?;
let totp = TOTP::new(Algorithm::SHA1, 6, 1, 30, secret)
.map_err(|e| ApiError::Internal(format!("TOTP init: {}", e)))?;
totp.check_current(code)
.map_err(|e| ApiError::Internal(format!("TOTP check: {}", e)))
}
pub async fn current_user(
Extension(state): Extension<Arc<AppState>>,
user: AuthedUser,
// Body is required by the client but its content is purely advisory.
Json(_body): Json<IdUuidBody>,
) -> Result<Json<UserPayload>, ApiError> {
let row = state
.db
.user_find_by_id(user.user_id)
.await?
.ok_or(ApiError::Unauthorized)?;
Ok(Json(UserPayload::from(&row)))
}
pub async fn logout(
Extension(state): Extension<Arc<AppState>>,
headers: axum::http::HeaderMap,
Json(_body): Json<IdUuidBody>,
) -> StatusCode {
// Best-effort: parse the bearer ourselves so a missing/invalid token still
// returns 200 (matches the client's fire-and-forget logout flow).
if let Some(auth) = headers.get(axum::http::header::AUTHORIZATION) {
if let Ok(s) = auth.to_str() {
if let Some(tok) = s.strip_prefix("Bearer ").map(str::trim) {
if !tok.is_empty() {
let sha = sha256_token(tok);
let _ = state.db.token_delete(&sha).await;
}
}
}
}
StatusCode::OK
}
pub(crate) fn mint_token() -> String {
let bytes = sodiumoxide::randombytes::randombytes(32);
base64::encode_config(bytes, base64::URL_SAFE_NO_PAD)
}