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    /// Tries the UniFi OS endpoint first (`/api/auth/login`). If it
264    /// responds (even with an error), we're on UniFi OS. If the connection
265    /// fails or returns 404, falls back to standalone detection.
266    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        // Probe UniFi OS endpoint
273        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            // UniFi OS returns a response (even 401/405) at this path.
281            // Standalone controllers don't have this endpoint at all.
282            if resp.status() != reqwest::StatusCode::NOT_FOUND {
283                debug!("detected UniFi OS platform");
284                return Ok(ControllerPlatform::UnifiOs);
285            }
286        }
287        // Connection error -- might be standalone on a different port
288
289        // Probe standalone endpoint
290        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}