188 lines
5.6 KiB
Rust
188 lines
5.6 KiB
Rust
//! 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,
|
|
}
|
|
|
|
#[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<LoginFlow, RemoteError> {
|
|
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(),
|
|
});
|
|
}
|
|
|
|
resp.json::<LoginFlow>()
|
|
.await
|
|
.map_err(|e| RemoteError::Protocol(e.to_string()))
|
|
}
|
|
|
|
/// 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<AppCredentials, RemoteError> {
|
|
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 => {
|
|
return resp
|
|
.json::<AppCredentials>()
|
|
.await
|
|
.map_err(|e| RemoteError::Protocol(e.to_string()))
|
|
}
|
|
// 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)
|
|
}
|
|
|
|
/// 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/"));
|
|
}
|
|
|
|
#[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");
|
|
}
|
|
}
|