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> {
267 let http = reqwest::Client::builder()
268 .danger_accept_invalid_certs(true)
269 .build()
270 .map_err(Error::Transport)?;
271
272 let unifi_os_url = base_url
274 .join("/api/auth/login")
275 .map_err(Error::InvalidUrl)?;
276
277 debug!("probing UniFi OS at {}", unifi_os_url);
278
279 if let Ok(resp) = http.get(unifi_os_url).send().await {
280 if resp.status() != reqwest::StatusCode::NOT_FOUND {
283 debug!("detected UniFi OS platform");
284 return Ok(ControllerPlatform::UnifiOs);
285 }
286 }
287 let standalone_url = base_url.join("/api/login").map_err(Error::InvalidUrl)?;
291
292 debug!("probing standalone at {}", standalone_url);
293
294 match http.get(standalone_url).send().await {
295 Ok(_) => {
296 debug!("detected standalone (classic) controller");
297 Ok(ControllerPlatform::ClassicController)
298 }
299 Err(e) => Err(Error::Transport(e)),
300 }
301 }
302}