Skip to main content

unifly_api/session/
auth.rs

1// Session API authentication
2//
3// Cookie-based session login/logout and controller platform detection.
4// The login endpoint sets a session cookie in the client's jar;
5// subsequent requests use that cookie automatically.
6//
7// MFA flow: when the controller requires 2FA, login returns HTTP 499
8// with `{"errors":["ubic_2fa_token required"]}` and a `token` field
9// containing a Set-Cookie value for `UBIC_2FA`. We inject that cookie,
10// then retry the login POST with `ubic_2fa_token` in the body.
11
12use 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/// Response body from a 499 MFA challenge.
22#[derive(serde::Deserialize)]
23struct MfaChallengeResponse {
24    /// Set-Cookie value for `UBIC_2FA` (e.g. `UBIC_2FA=eyJ...`).
25    token: Option<String>,
26}
27
28impl SessionClient {
29    /// Authenticate with the controller using username/password.
30    ///
31    /// If the controller has MFA enabled it returns HTTP 499. When a
32    /// `totp_token` is provided, we complete the two-step challenge
33    /// automatically. Without one, `Error::TwoFactorRequired` is returned
34    /// so the caller can prompt the user.
35    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        // HTTP 499 = MFA challenge from UniFi SSO
71        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    /// Handle a 499 MFA challenge: inject the UBIC_2FA cookie and retry
90    /// with the TOTP token in the login body.
91    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        // Validate TOTP format before sending
103        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        // Extract the MFA cookie from the challenge response
112        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        // Retry login with TOTP token
129        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    /// Extract and store CSRF token from a login response.
157    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    /// Attempt to restore a session from cache, falling back to fresh login.
169    ///
170    /// If a cached session is available and the validation probe succeeds,
171    /// the cookie and CSRF token are restored without hitting the login
172    /// endpoint. Otherwise, performs a normal login and caches the result.
173    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        // Try cached session first
183        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        // Fresh login
197        self.login(username, password, totp_token).await?;
198
199        // Cache the new session
200        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    /// Validate the current session by probing a lightweight endpoint.
212    ///
213    /// Returns `true` if the session is still alive.
214    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    /// End the current session.
231    ///
232    /// Platform-specific logout endpoint:
233    /// - UniFi OS: `POST /api/auth/logout`
234    /// - Standalone: `POST /api/logout`
235    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    /// Auto-detect the controller platform by probing login endpoints.
262    ///
263    /// Recent standalone controllers can return a misleading 401 from
264    /// `/api/auth/login`, so we need both probes before deciding.
265    ///
266    /// Observed patterns:
267    /// - Classic standalone: `/api/login` responds with 200/400, while
268    ///   `/api/auth/login` is usually 404 and may occasionally be 401.
269    /// - UniFi OS: both `/api/login` and `/api/auth/login` can return 401.
270    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}