//! `POST /api/2fa/enroll` — admin-only TOTP enrollment. //! //! Generates a fresh 20-byte (160-bit) base32 secret, stores it for the //! target user, and returns: //! - `secret_b32` — the literal secret to enter into an authenticator app. //! - `otpauth_url` — the standard `otpauth://totp/...` URL the same apps //! accept as a QR-code or pasted-string. //! //! There is no client-facing UI for this in the desktop app; operators run it //! by curl after creating the user. M4's `--bootstrap-admin-username` admin //! is the natural caller. use crate::api::error::ApiError; use crate::api::middleware::AuthedUser; use crate::api::state::AppState; use axum::extract::Extension; use axum::Json; use serde::Deserialize; use serde_json::{json, Value}; use std::sync::Arc; use totp_rs::Secret; #[derive(Debug, Deserialize)] pub struct EnrollBody { /// Either `user_id` or `username` is required. `user_id` wins if both /// are present. #[serde(default)] pub user_id: Option, #[serde(default)] pub username: Option, /// Issuer name shown in the authenticator app. Defaults to "RustDesk". #[serde(default)] pub issuer: Option, } #[derive(Debug, Deserialize)] pub struct UnenrollBody { #[serde(default)] pub user_id: Option, #[serde(default)] pub username: Option, } pub async fn enroll( Extension(state): Extension>, caller: AuthedUser, Json(body): Json, ) -> Result, ApiError> { if !caller.is_admin { return Err(ApiError::Forbidden("admin required".into())); } let user = resolve_target(&state, body.user_id, body.username.as_deref()).await?; // 20 random bytes -> base32 (the standard size for SHA1 TOTP). let raw = sodiumoxide::randombytes::randombytes(20); let secret_b32 = Secret::Raw(raw.clone()).to_encoded().to_string(); state .db .totp_enroll(user.id, &secret_b32) .await .map_err(|e| ApiError::Internal(e.to_string()))?; let issuer = body .issuer .as_deref() .filter(|s| !s.is_empty()) .unwrap_or("RustDesk"); // Build the otpauth:// URL manually rather than depend on totp-rs's // URL helpers (their API has shifted between minor versions). Format // per https://github.com/google/google-authenticator/wiki/Key-Uri-Format. let otpauth_url = format!( "otpauth://totp/{issuer}:{account}?secret={secret}&issuer={issuer}&algorithm=SHA1&digits=6&period=30", issuer = url_encode(issuer), account = url_encode(&user.username), secret = url_encode(&secret_b32), ); Ok(Json(json!({ "user_id": user.id, "username": user.username, "secret_b32": secret_b32, "otpauth_url": otpauth_url, }))) } pub async fn unenroll( Extension(state): Extension>, caller: AuthedUser, Json(body): Json, ) -> Result, ApiError> { if !caller.is_admin { return Err(ApiError::Forbidden("admin required".into())); } let user = resolve_target(&state, body.user_id, body.username.as_deref()).await?; let removed = state .db .totp_unenroll(user.id) .await .map_err(|e| ApiError::Internal(e.to_string()))?; Ok(Json(json!({ "removed": removed }))) } /// Minimal percent-encoder for the otpauth URL fields. Encodes anything /// outside the unreserved URL set (`A-Za-z0-9-_.~`) — keeps the URL short /// and avoids pulling in `urlencoding` just for this single call site. 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); } _ => { use std::fmt::Write; let _ = write!(out, "%{:02X}", b); } } } out } async fn resolve_target( state: &AppState, user_id: Option, username: Option<&str>, ) -> Result { if let Some(id) = user_id { return state .db .user_find_by_id(id) .await .map_err(|e| ApiError::Internal(e.to_string()))? .ok_or(ApiError::NotFound); } if let Some(name) = username.filter(|s| !s.is_empty()) { return state .db .user_find_by_username(name) .await .map_err(|e| ApiError::Internal(e.to_string()))? .ok_or(ApiError::NotFound); } Err(ApiError::BadRequest( "user_id or username required".into(), )) }