use secrecy::{ExposeSecret, SecretString};
use serde_json::json;
use tracing::debug;
use url::Url;
use crate::auth::ControllerPlatform;
use crate::error::Error;
use crate::legacy::client::LegacyClient;
#[derive(serde::Deserialize)]
struct MfaChallengeResponse {
token: Option<String>,
}
impl LegacyClient {
pub async fn login(
&self,
username: &str,
password: &SecretString,
totp_token: Option<&SecretString>,
) -> Result<(), Error> {
let login_path = self
.platform()
.login_path()
.ok_or_else(|| Error::Authentication {
message: "login not supported on cloud platform".into(),
})?;
let url = self
.base_url()
.join(login_path)
.map_err(Error::InvalidUrl)?;
debug!("logging in at {}", url);
let body = json!({
"username": username,
"password": password.expose_secret(),
});
let resp = self
.http()
.post(url.clone())
.json(&body)
.send()
.await
.map_err(Error::Transport)?;
let status = resp.status();
if status.as_u16() == 499 {
return self
.handle_mfa_challenge(resp, &url, username, password, totp_token)
.await;
}
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(Error::Authentication {
message: format!("login failed (HTTP {status}): {body}"),
});
}
self.capture_csrf(&resp);
debug!("login successful");
Ok(())
}
async fn handle_mfa_challenge(
&self,
resp: reqwest::Response,
login_url: &Url,
username: &str,
password: &SecretString,
totp_token: Option<&SecretString>,
) -> Result<(), Error> {
let totp = totp_token.ok_or(Error::TwoFactorRequired)?;
let code = totp.expose_secret();
if code.len() != 6 || !code.chars().all(|c| c.is_ascii_digit()) {
return Err(Error::Authentication {
message: "TOTP token must be exactly 6 digits".into(),
});
}
debug!("MFA challenge received — completing 2FA handshake");
let challenge: MfaChallengeResponse =
resp.json().await.map_err(|_| Error::Authentication {
message: "failed to parse MFA challenge response".into(),
})?;
if let Some(cookie_value) = challenge.token {
if !cookie_value.starts_with("UBIC_2FA=") {
return Err(Error::Authentication {
message: format!(
"unexpected MFA cookie format (expected UBIC_2FA=...): {cookie_value}"
),
});
}
self.add_cookie(&cookie_value, login_url)?;
}
let body = json!({
"username": username,
"password": password.expose_secret(),
"ubic_2fa_token": code,
});
let resp = self
.http()
.post(login_url.clone())
.json(&body)
.send()
.await
.map_err(Error::Transport)?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(Error::Authentication {
message: format!("MFA login failed (HTTP {status}): {body}"),
});
}
self.capture_csrf(&resp);
debug!("MFA login successful");
Ok(())
}
fn capture_csrf(&self, resp: &reqwest::Response) {
if let Some(token) = resp
.headers()
.get("X-CSRF-Token")
.or_else(|| resp.headers().get("x-csrf-token"))
.and_then(|v| v.to_str().ok())
{
self.set_csrf_token(token.to_owned());
}
}
pub async fn login_with_cache(
&self,
username: &str,
password: &secrecy::SecretString,
totp_token: Option<&secrecy::SecretString>,
cache: &super::session_cache::SessionCache,
) -> Result<(), Error> {
use super::session_cache::{EXPIRY_MARGIN_SECS, fallback_expiry, jwt_expiry};
if let Some((cookie, csrf)) = cache.load() {
self.add_cookie(&cookie, self.base_url())?;
if let Some(token) = csrf {
self.set_csrf_token(token);
}
if self.validate_session().await {
debug!("restored session from cache");
return Ok(());
}
debug!("cached session invalid, performing fresh login");
}
self.login(username, password, totp_token).await?;
if let Some(cookie) = self.cookie_header() {
let csrf = self.csrf_token_value();
let expires_at = jwt_expiry(&cookie).map_or_else(fallback_expiry, |exp| {
exp.saturating_sub(EXPIRY_MARGIN_SECS)
});
cache.save(&cookie, csrf.as_deref(), expires_at);
}
Ok(())
}
async fn validate_session(&self) -> bool {
let prefix = self.platform().legacy_prefix().unwrap_or("");
let base = self.base_url().as_str().trim_end_matches('/');
let prefix = prefix.trim_end_matches('/');
let probe_url = format!("{base}{prefix}/api/s/{}/self", self.site());
let Ok(url) = url::Url::parse(&probe_url) else {
return false;
};
match self.http().get(url).send().await {
Ok(resp) => resp.status().is_success(),
Err(_) => false,
}
}
pub async fn logout(&self) -> Result<(), Error> {
let logout_path = self
.platform()
.logout_path()
.ok_or_else(|| Error::Authentication {
message: "logout not supported on cloud platform".into(),
})?;
let url = self
.base_url()
.join(logout_path)
.map_err(Error::InvalidUrl)?;
debug!("logging out at {}", url);
let _resp = self
.http()
.post(url)
.send()
.await
.map_err(Error::Transport)?;
debug!("logout complete");
Ok(())
}
pub async fn detect_platform(base_url: &Url) -> Result<ControllerPlatform, Error> {
let http = reqwest::Client::builder()
.danger_accept_invalid_certs(true)
.build()
.map_err(Error::Transport)?;
let unifi_os_url = base_url
.join("/api/auth/login")
.map_err(Error::InvalidUrl)?;
debug!("probing UniFi OS at {}", unifi_os_url);
if let Ok(resp) = http.get(unifi_os_url).send().await {
if resp.status() != reqwest::StatusCode::NOT_FOUND {
debug!("detected UniFi OS platform");
return Ok(ControllerPlatform::UnifiOs);
}
}
let standalone_url = base_url.join("/api/login").map_err(Error::InvalidUrl)?;
debug!("probing standalone at {}", standalone_url);
match http.get(standalone_url).send().await {
Ok(_) => {
debug!("detected standalone (classic) controller");
Ok(ControllerPlatform::ClassicController)
}
Err(e) => Err(Error::Transport(e)),
}
}
}