unifly_api/session/
auth.rs1use secrecy::{ExposeSecret, SecretString};
13use serde_json::json;
14use tracing::debug;
15use url::Url;
16
17use crate::auth::ControllerPlatform;
18use crate::error::Error;
19use crate::session::client::SessionClient;
20
21#[derive(serde::Deserialize)]
23struct MfaChallengeResponse {
24 token: Option<String>,
26}
27
28impl SessionClient {
29 pub async fn login(
36 &self,
37 username: &str,
38 password: &SecretString,
39 totp_token: Option<&SecretString>,
40 ) -> Result<(), Error> {
41 let login_path = self
42 .platform()
43 .login_path()
44 .ok_or_else(|| Error::Authentication {
45 message: "login not supported on cloud platform".into(),
46 })?;
47
48 let url = self
49 .base_url()
50 .join(login_path)
51 .map_err(Error::InvalidUrl)?;
52
53 debug!("logging in at {}", url);
54
55 let body = json!({
56 "username": username,
57 "password": password.expose_secret(),
58 });
59
60 let resp = self
61 .http()
62 .post(url.clone())
63 .json(&body)
64 .send()
65 .await
66 .map_err(Error::Transport)?;
67
68 let status = resp.status();
69
70 if status.as_u16() == 499 {
72 return self
73 .handle_mfa_challenge(resp, &url, username, password, totp_token)
74 .await;
75 }
76
77 if !status.is_success() {
78 let body = resp.text().await.unwrap_or_default();
79 return Err(Error::Authentication {
80 message: format!("login failed (HTTP {status}): {body}"),
81 });
82 }
83
84 self.capture_csrf(&resp);
85 debug!("login successful");
86 Ok(())
87 }
88
89 async fn handle_mfa_challenge(
92 &self,
93 resp: reqwest::Response,
94 login_url: &Url,
95 username: &str,
96 password: &SecretString,
97 totp_token: Option<&SecretString>,
98 ) -> Result<(), Error> {
99 let totp = totp_token.ok_or(Error::TwoFactorRequired)?;
100 let code = totp.expose_secret();
101
102 if code.len() != 6 || !code.chars().all(|c| c.is_ascii_digit()) {
104 return Err(Error::Authentication {
105 message: "TOTP token must be exactly 6 digits".into(),
106 });
107 }
108
109 debug!("MFA challenge received — completing 2FA handshake");
110
111 let challenge: MfaChallengeResponse =
113 resp.json().await.map_err(|_| Error::Authentication {
114 message: "failed to parse MFA challenge response".into(),
115 })?;
116
117 if let Some(cookie_value) = challenge.token {
118 if !cookie_value.starts_with("UBIC_2FA=") {
119 return Err(Error::Authentication {
120 message: format!(
121 "unexpected MFA cookie format (expected UBIC_2FA=...): {cookie_value}"
122 ),
123 });
124 }
125 self.add_cookie(&cookie_value, login_url)?;
126 }
127
128 let body = json!({
130 "username": username,
131 "password": password.expose_secret(),
132 "ubic_2fa_token": code,
133 });
134
135 let resp = self
136 .http()
137 .post(login_url.clone())
138 .json(&body)
139 .send()
140 .await
141 .map_err(Error::Transport)?;
142
143 let status = resp.status();
144 if !status.is_success() {
145 let body = resp.text().await.unwrap_or_default();
146 return Err(Error::Authentication {
147 message: format!("MFA login failed (HTTP {status}): {body}"),
148 });
149 }
150
151 self.capture_csrf(&resp);
152 debug!("MFA login successful");
153 Ok(())
154 }
155
156 fn capture_csrf(&self, resp: &reqwest::Response) {
158 if let Some(token) = resp
159 .headers()
160 .get("X-CSRF-Token")
161 .or_else(|| resp.headers().get("x-csrf-token"))
162 .and_then(|v| v.to_str().ok())
163 {
164 self.set_csrf_token(token.to_owned());
165 }
166 }
167
168 pub async fn login_with_cache(
174 &self,
175 username: &str,
176 password: &secrecy::SecretString,
177 totp_token: Option<&secrecy::SecretString>,
178 cache: &super::session_cache::SessionCache,
179 ) -> Result<(), Error> {
180 use super::session_cache::{EXPIRY_MARGIN_SECS, fallback_expiry, jwt_expiry};
181
182 if let Some((cookie, csrf)) = cache.load() {
184 self.add_cookie(&cookie, self.base_url())?;
185 if let Some(token) = csrf {
186 self.set_csrf_token(token);
187 }
188
189 if self.validate_session().await {
190 debug!("restored session from cache");
191 return Ok(());
192 }
193 debug!("cached session invalid, performing fresh login");
194 }
195
196 self.login(username, password, totp_token).await?;
198
199 if let Some(cookie) = self.cookie_header() {
201 let csrf = self.csrf_token_value();
202 let expires_at = jwt_expiry(&cookie).map_or_else(fallback_expiry, |exp| {
203 exp.saturating_sub(EXPIRY_MARGIN_SECS)
204 });
205 cache.save(&cookie, csrf.as_deref(), expires_at);
206 }
207
208 Ok(())
209 }
210
211 async fn validate_session(&self) -> bool {
215 let prefix = self.platform().session_prefix().unwrap_or("");
216 let base = self.base_url().as_str().trim_end_matches('/');
217 let prefix = prefix.trim_end_matches('/');
218 let probe_url = format!("{base}{prefix}/api/s/{}/self", self.site());
219
220 let Ok(url) = url::Url::parse(&probe_url) else {
221 return false;
222 };
223
224 match self.http().get(url).send().await {
225 Ok(resp) => resp.status().is_success(),
226 Err(_) => false,
227 }
228 }
229
230 pub async fn logout(&self) -> Result<(), Error> {
236 let logout_path = self
237 .platform()
238 .logout_path()
239 .ok_or_else(|| Error::Authentication {
240 message: "logout not supported on cloud platform".into(),
241 })?;
242
243 let url = self
244 .base_url()
245 .join(logout_path)
246 .map_err(Error::InvalidUrl)?;
247
248 debug!("logging out at {}", url);
249
250 let _resp = self
251 .http()
252 .post(url)
253 .send()
254 .await
255 .map_err(Error::Transport)?;
256
257 debug!("logout complete");
258 Ok(())
259 }
260
261 pub async fn detect_platform(base_url: &Url) -> Result<ControllerPlatform, Error> {
271 let http = reqwest::Client::builder()
272 .danger_accept_invalid_certs(true)
273 .build()
274 .map_err(Error::Transport)?;
275
276 let standalone_url = base_url.join("/api/login").map_err(Error::InvalidUrl)?;
277 debug!("probing standalone at {}", standalone_url);
278 let unifi_os_url = base_url
279 .join("/api/auth/login")
280 .map_err(Error::InvalidUrl)?;
281 debug!("probing UniFi OS at {}", unifi_os_url);
282 let standalone_status = http
283 .get(standalone_url)
284 .send()
285 .await
286 .map(|resp| resp.status());
287 let unifi_os_status = http
288 .get(unifi_os_url)
289 .send()
290 .await
291 .map(|resp| resp.status());
292
293 if matches!(
294 standalone_status.as_ref(),
295 Ok(&reqwest::StatusCode::OK | &reqwest::StatusCode::BAD_REQUEST)
296 ) {
297 debug!("detected standalone (classic) controller");
298 return Ok(ControllerPlatform::ClassicController);
299 }
300
301 if matches!(
302 (standalone_status.as_ref(), unifi_os_status.as_ref()),
303 (
304 Ok(&reqwest::StatusCode::UNAUTHORIZED),
305 Ok(&reqwest::StatusCode::UNAUTHORIZED)
306 )
307 ) {
308 debug!("detected UniFi OS controller from dual-401 login probes");
309 return Ok(ControllerPlatform::UnifiOs);
310 }
311
312 if let Ok(status) = unifi_os_status.as_ref()
313 && *status != reqwest::StatusCode::NOT_FOUND
314 {
315 debug!(?status, "detected UniFi OS controller");
316 return Ok(ControllerPlatform::UnifiOs);
317 }
318
319 if let Ok(status) = standalone_status.as_ref()
320 && *status != reqwest::StatusCode::NOT_FOUND
321 {
322 debug!(?status, "detected standalone (classic) controller");
323 return Ok(ControllerPlatform::ClassicController);
324 }
325
326 if let Err(e) = standalone_status {
327 return Err(Error::Transport(e));
328 }
329
330 if let Err(e) = unifi_os_status {
331 return Err(Error::Transport(e));
332 }
333
334 Err(Error::Authentication {
335 message: "could not detect controller platform: both login probes returned 404".into(),
336 })
337 }
338}