//! Login Flow v2 (FR-NC-1). //! //! The app never sees the user's password. It asks the server for a login URL, //! opens that in the **system browser**, and polls until the server hands back //! an app password scoped to this device. use std::time::Duration; use dr_sync::RemoteError; use serde::{Deserialize, Serialize}; /// The flow's poll token is valid for 20 minutes. const FLOW_TIMEOUT: Duration = Duration::from_secs(20 * 60); const POLL_INTERVAL: Duration = Duration::from_secs(2); /// What the server returns when a flow is started. #[derive(Debug, Clone, Deserialize)] pub struct LoginFlow { /// Open this in the system browser — never an embedded webview, which /// would defeat the point of not handling the password. #[serde(rename = "login")] pub login_url: String, #[serde(rename = "poll")] pub poll: PollInfo, /// The server the flow was started against — the address the user typed, /// already normalised. Not part of the response: [`begin`] fills it in so /// [`poll`] can put it in the credentials instead of the server's answer. #[serde(skip)] pub server: String, } #[derive(Debug, Clone, Deserialize)] pub struct PollInfo { pub token: String, pub endpoint: String, } /// Credentials issued at the end of the flow. #[derive(Debug, Clone, Deserialize, Serialize)] pub struct AppCredentials { pub server: String, #[serde(rename = "loginName")] pub login_name: String, /// Device-scoped and individually revocable — not the user's password. #[serde(rename = "appPassword")] pub app_password: String, } /// TRACES: FR-NC-1 | M-1 /// Begin a login flow. /// /// The `User-Agent` names the resulting app password in the user's security /// settings, so it should identify the device for per-device revocation. pub async fn begin( client: &reqwest::Client, server: &str, user_agent: &str, ) -> Result { let url = format!("{}/index.php/login/v2", server.trim_end_matches('/')); let resp = client .post(&url) .header(reqwest::header::USER_AGENT, user_agent) .send() .await .map_err(super::map_send_error)?; if !resp.status().is_success() { return Err(RemoteError::Server { status: resp.status().as_u16(), detail: "login flow could not be started".into(), }); } let mut flow = resp .json::() .await .map_err(|e| RemoteError::Protocol(e.to_string()))?; // Both URLs are the server's to choose, and neither may be trusted as // sent. The login URL is handed to the operating system to open, where a // `file:` or UNC path is a program launch rather than a web page; the poll // endpoint is where the app password comes back from. flow.login_url = upgraded("login URL", &flow.login_url)?; flow.poll.endpoint = upgraded("poll endpoint", &flow.poll.endpoint)?; flow.server = server.trim_end_matches('/').to_string(); Ok(flow) } /// TRACES: NFR-SEC-3 /// A URL the server sent, upgraded to HTTPS, or refused. /// /// `http` is upgraded rather than refused: a Nextcloud behind a TLS-terminating /// proxy without `overwriteprotocol` builds every absolute URL it returns with /// `http`, and the same path over `https` is the one that works. Any other /// scheme is refused, because the only thing it could be for is reaching /// something that is not this server. /// /// The host is not checked. A server reached by its LAN address may answer with /// its public name, and nothing here is safer for refusing that: the account /// is stored under the address the user typed (see [`poll`]), not under /// anything the server said. pub(crate) fn upgraded(what: &str, url: &str) -> Result { let mut parsed = url::Url::parse(url).map_err(|e| { RemoteError::Protocol(format!( "the server sent a {what} that is not a URL ({e}): {url}" )) })?; match parsed.scheme() { "https" => {} "http" => parsed.set_scheme("https").map_err(|()| { RemoteError::Protocol(format!("{what} cannot be upgraded to https: {url}")) })?, other => { return Err(RemoteError::Protocol(format!( "the server sent a {what} using {other}:, and only https is accepted: {url}" ))) } } if parsed.host_str().is_none_or(str::is_empty) { return Err(RemoteError::Protocol(format!( "the server sent a {what} with no host: {url}" ))); } Ok(parsed.into()) } /// Poll until the user finishes authenticating in the browser. /// /// The server returns 404 while pending and 200 exactly once — so a dropped /// success response means restarting the flow, and the result must be /// persisted immediately. pub async fn poll( client: &reqwest::Client, flow: &LoginFlow, ) -> Result { let deadline = std::time::Instant::now() + FLOW_TIMEOUT; while std::time::Instant::now() < deadline { let resp = client .post(&flow.poll.endpoint) // One field; encoding it by hand avoids pulling in reqwest's // form feature for a single call. .header( reqwest::header::CONTENT_TYPE, "application/x-www-form-urlencoded", ) .body(format!("token={}", urlencode(&flow.poll.token))) .send() .await .map_err(super::map_send_error)?; match resp.status().as_u16() { 200 => { let creds = resp .json::() .await .map_err(|e| RemoteError::Protocol(e.to_string()))?; return Ok(under_typed_server(creds, &flow.server)); } // Still waiting for the user. 404 => tokio::time::sleep(POLL_INTERVAL).await, s => { return Err(RemoteError::Server { status: s, detail: "unexpected status while polling".into(), }) } } } Err(RemoteError::AuthFailed) } /// TRACES: NFR-SEC-3 /// Credentials filed under the address the user typed, not the one the server /// reports. /// /// That report is the server's idea of its own URL, and behind a proxy /// without `overwriteprotocol` it says `http://` — which, stored, would send /// the app password in the clear on every request from then on. The typed /// address has just carried the whole flow, so it is known to reach the /// server. fn under_typed_server(mut creds: AppCredentials, server: &str) -> AppCredentials { if creds.server.trim_end_matches('/') != server { log::info!( "server reports itself as {}; keeping {server}", creds.server ); } creds.server = server.to_string(); creds } /// Percent-encode a form value. fn urlencode(s: &str) -> String { s.bytes() .map(|b| match b { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { (b as char).to_string() } _ => format!("%{b:02X}"), }) .collect() } /// TRACES: FR-NC-1 | M-4 /// Revoke the app password on logout (FR-NC-1). pub async fn revoke( client: &reqwest::Client, server: &str, login: &str, password: &str, ) -> Result<(), RemoteError> { let url = format!( "{}/ocs/v2.php/core/apppassword", server.trim_end_matches('/') ); client .delete(&url) .basic_auth(login, Some(password)) .header("OCS-APIRequest", "true") .send() .await .map_err(super::map_send_error)?; Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn login_flow_response_deserialises() { // The shape Nextcloud actually returns. let json = r#"{ "poll": {"token": "abc", "endpoint": "https://cloud.example/login/v2/poll"}, "login": "https://cloud.example/login/v2/flow/xyz" }"#; let flow: LoginFlow = serde_json::from_str(json).unwrap(); assert_eq!(flow.poll.token, "abc"); assert!(flow.login_url.contains("/login/v2/flow/")); } /// TRACES: NFR-SEC-3 #[test] fn server_urls_are_upgraded_to_https_or_refused() { assert_eq!( upgraded("login URL", "http://cloud.example/login/v2/flow/xyz").unwrap(), "https://cloud.example/login/v2/flow/xyz" ); // A non-default port stays, and only the scheme changes. assert_eq!( upgraded("poll endpoint", "http://cloud.example:8443/login/v2/poll").unwrap(), "https://cloud.example:8443/login/v2/poll" ); assert_eq!( upgraded("login URL", "https://cloud.example/x").unwrap(), "https://cloud.example/x" ); // What `rundll32 url.dll,FileProtocolHandler` would run, and what // `xdg-open` would hand to whatever claims it. for hostile in [ "file:///C:/Windows/System32/calc.exe", "\\\\evil\\share\\x.exe", "C:\\x.exe", "javascript:alert(1)", "-v", "", ] { assert!(upgraded("login URL", hostile).is_err(), "{hostile:?}"); } } /// TRACES: NFR-SEC-3 #[test] fn credentials_keep_the_typed_server_not_the_reported_one() { let reported = AppCredentials { server: "http://cloud.example".into(), login_name: "duncan".into(), app_password: "secret-token".into(), }; let c = under_typed_server(reported, "https://cloud.example"); assert_eq!(c.server, "https://cloud.example"); assert_eq!(c.app_password, "secret-token"); } #[test] fn form_values_are_encoded() { assert_eq!(urlencode("abc123"), "abc123"); assert_eq!(urlencode("a b+c"), "a%20b%2Bc"); } #[test] fn credentials_deserialise_with_camel_case_keys() { let json = r#"{ "server": "https://cloud.example", "loginName": "duncan", "appPassword": "secret-token" }"#; let c: AppCredentials = serde_json::from_str(json).unwrap(); assert_eq!(c.login_name, "duncan"); assert_eq!(c.app_password, "secret-token"); } }