diff --git a/proxmox-login/Cargo.toml b/proxmox-login/Cargo.toml new file mode 100644 index 00000000..82f69cdd --- /dev/null +++ b/proxmox-login/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "proxmox-login" +version = "0.1.0" +description = "proxmox product authentication api" +edition.workspace = true +license.workspace = true + +exclude.workspace = true + +[dependencies] +base64.workspace = true +percent-encoding.workspace = true +serde = { workspace = true, features = [ "derive" ] } +serde_json.workspace = true + +# For webauthn types +webauthn-rs = { workspace = true, optional = true } + +# For `Authentication::set_auth_headers` +http = { version = "0.2.4", optional = true } + +[features] +default = [] +webauthn = [ "dep:webauthn-rs" ] +http = ["dep:http"] diff --git a/proxmox-login/debian/changelog b/proxmox-login/debian/changelog new file mode 100644 index 00000000..ea624179 --- /dev/null +++ b/proxmox-login/debian/changelog @@ -0,0 +1,6 @@ +rust-proxmox-login (0.1.0-1) unstable; urgency=medium + + * initial Debian package + + -- Proxmox Support Team Thu, 04 May 2023 08:40:38 +0200 + diff --git a/proxmox-login/debian/control b/proxmox-login/debian/control new file mode 100644 index 00000000..d4b15190 --- /dev/null +++ b/proxmox-login/debian/control @@ -0,0 +1,74 @@ +Source: rust-proxmox-login +Section: rust +Priority: optional +Build-Depends: debhelper (>= 12), + dh-cargo (>= 25), + cargo:native , + rustc:native , + libstd-rust-dev , + librust-base64-0.13+default-dev , + librust-percent-encoding-2+default-dev (>= 2.1-~~) , + librust-serde-1+default-dev , + librust-serde-1+derive-dev , + librust-serde-json-1+default-dev +Maintainer: Proxmox Support Team +Standards-Version: 4.6.1 +Vcs-Git: https://salsa.debian.org/rust-team/debcargo-conf.git [src/proxmox-login] +Vcs-Browser: https://salsa.debian.org/rust-team/debcargo-conf/tree/master/src/proxmox-login +X-Cargo-Crate: proxmox-login +Rules-Requires-Root: no + +Package: librust-proxmox-login-dev +Architecture: any +Multi-Arch: same +Depends: + ${misc:Depends}, + librust-base64-0.13+default-dev, + librust-percent-encoding-2+default-dev (>= 2.1-~~), + librust-serde-1+default-dev, + librust-serde-1+derive-dev, + librust-serde-json-1+default-dev +Suggests: + librust-proxmox-login+http-dev (= ${binary:Version}), + librust-proxmox-login+webauthn-dev (= ${binary:Version}) +Provides: + librust-proxmox-login+default-dev (= ${binary:Version}), + librust-proxmox-login-0-dev (= ${binary:Version}), + librust-proxmox-login-0+default-dev (= ${binary:Version}), + librust-proxmox-login-0.1-dev (= ${binary:Version}), + librust-proxmox-login-0.1+default-dev (= ${binary:Version}), + librust-proxmox-login-0.1.0-dev (= ${binary:Version}), + librust-proxmox-login-0.1.0+default-dev (= ${binary:Version}) +Description: Proxmox product authentication api - Rust source code + This package contains the source for the Rust proxmox-login crate, packaged by + debcargo for use with cargo and dh-cargo. + +Package: librust-proxmox-login+http-dev +Architecture: any +Multi-Arch: same +Depends: + ${misc:Depends}, + librust-proxmox-login-dev (= ${binary:Version}), + librust-http-0.2+default-dev (>= 0.2.4-~~) +Provides: + librust-proxmox-login-0+http-dev (= ${binary:Version}), + librust-proxmox-login-0.1+http-dev (= ${binary:Version}), + librust-proxmox-login-0.1.0+http-dev (= ${binary:Version}) +Description: Proxmox product authentication api - feature "http" + This metapackage enables feature "http" for the Rust proxmox-login crate, by + pulling in any additional dependencies needed by that feature. + +Package: librust-proxmox-login+webauthn-dev +Architecture: any +Multi-Arch: same +Depends: + ${misc:Depends}, + librust-proxmox-login-dev (= ${binary:Version}), + librust-webauthn-rs-0.3+default-dev +Provides: + librust-proxmox-login-0+webauthn-dev (= ${binary:Version}), + librust-proxmox-login-0.1+webauthn-dev (= ${binary:Version}), + librust-proxmox-login-0.1.0+webauthn-dev (= ${binary:Version}) +Description: Proxmox product authentication api - feature "webauthn" + This metapackage enables feature "webauthn" for the Rust proxmox-login crate, + by pulling in any additional dependencies needed by that feature. diff --git a/proxmox-login/debian/copyright b/proxmox-login/debian/copyright new file mode 100644 index 00000000..4fce23a5 --- /dev/null +++ b/proxmox-login/debian/copyright @@ -0,0 +1,16 @@ +Copyright (C) 2023 Proxmox Server Solutions GmbH + +This software is written by Proxmox Server Solutions GmbH + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . diff --git a/proxmox-login/debian/debcargo.toml b/proxmox-login/debian/debcargo.toml new file mode 100644 index 00000000..14ad8000 --- /dev/null +++ b/proxmox-login/debian/debcargo.toml @@ -0,0 +1,7 @@ +overlay = "." +crate_src_path = ".." +maintainer = "Proxmox Support Team " + +[source] +#vcs_git = "git://git.proxmox.com/git/proxmox.git" +#vcs_browser = "https://git.proxmox.com/?p=proxmox.git" diff --git a/proxmox-login/src/api.rs b/proxmox-login/src/api.rs new file mode 100644 index 00000000..44232b6f --- /dev/null +++ b/proxmox-login/src/api.rs @@ -0,0 +1,71 @@ +//! API types used during authentication. + +use serde::{Deserialize, Serialize}; + +/// The JSON parameter object for the `/api2/access/ticket` API call. +/// +/// Note that for Proxmox VE up to including version 7 the `new_format` parameter has to be used, +/// if TFA should be supported, as this crate does not support the old TFA login mechanism. +#[derive(Debug, Default, Deserialize, Serialize)] +pub struct CreateTicket { + /// With webauthn the format of half-authenticated tickts changed. New + /// clients should pass 1 here and not worry about the old format. The old + /// format is deprecated and will be retired with PVE-8.0 + #[serde(deserialize_with = "crate::parse::deserialize_bool")] + #[serde(default, skip_serializing_if = "Option::is_none")] + #[serde(rename = "new-format")] + pub new_format: Option, + + /// One-time password for Two-factor authentication. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub otp: Option, + + /// The secret password. This can also be a valid ticket. + pub password: String, + + /// Verify ticket, and check if user have access 'privs' on 'path' + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path: Option, + + /// Verify ticket, and check if user have access 'privs' on 'path' + #[serde(default, skip_serializing_if = "Option::is_none")] + pub privs: Option, + + /// You can optionally pass the realm using this parameter. Normally the + /// realm is simply added to the username @. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub realm: Option, + + /// The signed TFA challenge string the user wants to respond to. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[serde(rename = "tfa-challenge")] + pub tfa_challenge: Option, + + /// User name + pub username: String, +} + +/// The API response for a *complete* (both factors) `api2/access/ticket` call. +#[derive(Debug, Deserialize, Serialize)] +pub struct CreateTicketResponse { + /// The CSRF prevention token. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[serde(rename = "CSRFPreventionToken")] + pub csrfprevention_token: Option, + + /// The cluster's visual name. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub clustername: Option, + + /// The ticket as is supposed to be used in the authentication header. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ticket: Option, + + /// The full userid with the `@realm` part. + pub username: String, +} + +#[derive(Deserialize)] +pub struct ApiResponse { + pub data: Option, +} diff --git a/proxmox-login/src/error.rs b/proxmox-login/src/error.rs new file mode 100644 index 00000000..1ee6b620 --- /dev/null +++ b/proxmox-login/src/error.rs @@ -0,0 +1,85 @@ +//! Error types. + +use std::fmt; + +/// Ticket parsing error. +#[derive(Clone, Copy, Debug)] +pub struct TicketError; + +impl std::error::Error for TicketError {} + +impl fmt::Display for TicketError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("invalid ticket") + } +} + +/// Error parsing an API response. +#[derive(Debug)] +pub enum ResponseError { + /// An error happened when decoding the JSON response. + Json(serde_json::Error), + + /// Some unexpected error occurred. + Msg(&'static str), + + /// Failed to parse the ticket contained in the response. + Ticket(TicketError), +} + +impl std::error::Error for ResponseError {} + +impl fmt::Display for ResponseError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + ResponseError::Json(err) => write!(f, "bad ticket response: {err}"), + ResponseError::Msg(err) => write!(f, "bad ticket response: {err}"), + ResponseError::Ticket(err) => write!(f, "failed to parse ticket in response: {err}"), + } + } +} + +impl From for ResponseError { + fn from(err: serde_json::Error) -> Self { + ResponseError::Json(err) + } +} + +impl From<&'static str> for ResponseError { + fn from(err: &'static str) -> Self { + ResponseError::Msg(err) + } +} + +impl From for ResponseError { + fn from(err: TicketError) -> Self { + ResponseError::Ticket(err) + } +} + +/// Error creating a request for Two-Factor-Authentication. +#[derive(Debug)] +pub enum TfaError { + /// The chosen TFA method is not available. + Unavailable, + + /// A serialization error occurred. + Json(serde_json::Error), +} + +impl std::error::Error for TfaError {} + +impl fmt::Display for TfaError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + TfaError::Unavailable => f.write_str("the chosen TFA method is not available"), + TfaError::Json(err) => write!(f, "a serialization error occurred: {err}"), + } + } +} + +impl From for TfaError { + fn from(err: serde_json::Error) -> Self { + TfaError::Json(err) + } +} diff --git a/proxmox-login/src/lib.rs b/proxmox-login/src/lib.rs new file mode 100644 index 00000000..e03cd366 --- /dev/null +++ b/proxmox-login/src/lib.rs @@ -0,0 +1,292 @@ +//! This package provides helpers for logging into the APIs of Proxmox products such as Proxmox VE +//! or Proxmox Backup. + +use serde::{Deserialize, Serialize}; + +pub mod parse; + +pub mod api; +pub mod error; +pub mod tfa; +pub mod ticket; + +const CONTENT_TYPE_JSON: &str = "application/json"; + +#[doc(inline)] +pub use ticket::{Authentication, Ticket}; + +use error::{ResponseError, TfaError, TicketError}; + +/// The header name for the CSRF prevention token. +pub const CSRF_HEADER_NAME: &str = "CSRFPreventionToken"; + +/// A request to be sent to the ticket API call. +/// +/// Note that the body is always JSON (`application/json`) and request method is POST. +#[derive(Clone, Debug)] +pub struct Request { + pub url: String, + + /// This is always `application/json`. + pub content_type: &'static str, + + /// The `Content-length` header field. + pub content_length: usize, + + /// The body. + pub body: String, +} + +/// Login or ticket renewal request builder. +/// +/// This takes an API URL and either a valid ticket or a userid (name + real) and password in order +/// to create an HTTP [`Request`] to renew or create a new API ticket. +/// +/// Note that for Proxmox VE versions up to including 7, a compatibility flag is required to +/// support Two-Factor-Authentication. +#[derive(Debug)] +pub struct Login { + api_url: String, + userid: String, + password: String, + pve_compat: bool, +} + +fn normalize_url(mut api_url: String) -> String { + api_url.truncate(api_url.trim_end_matches('/').len()); + api_url +} + +impl Login { + /// Prepare a request given an existing ticket string. + pub fn renew(api_url: impl Into, ticket: impl Into) -> Result { + Ok(Self::renew_ticket(api_url, ticket.into().parse()?)) + } + + /// Switch to a different url on the same server. + pub fn set_url(&mut self, api_url: impl Into) { + self.api_url = api_url.into(); + } + + /// Prepare a request given an already parsed ticket. + pub fn renew_ticket(api_url: impl Into, ticket: Ticket) -> Self { + Self { + api_url: normalize_url(api_url.into()), + pve_compat: ticket.product() == "PVE", + userid: ticket.userid().to_string(), + password: ticket.into(), + } + } + + /// Prepare a request given a userid and password. + pub fn new(api_url: impl Into, userid: impl Into, password: impl Into) -> Self { + Self { + api_url: normalize_url(api_url.into()), + userid: userid.into(), + password: password.into(), + pve_compat: false, + } + } + + /// Set the Proxmox VE compatibility parameter for Two-Factor-Authentication support. + pub fn pve_compatibility(mut self, compatibility: bool) -> Self { + self.pve_compat = compatibility; + self + } + + /// Create an HTTP [`Request`] from the current data. + /// + /// If the request returns a successful result, the response's body should be passed to the + /// [`response`](Login::response) method in order to extract the validated ticket or + /// Two-Factor-Authentication challenge. + pub fn request(&self) -> Request { + let request = api::CreateTicket { + new_format: self.pve_compat.then_some(true), + username: self.userid.clone(), + password: self.password.clone(), + ..Default::default() + }; + + let body = serde_json::to_string(&request) + .unwrap(); // this can never fail + + Request { + url: format!("{}/api2/json/access/ticket", self.api_url), + content_type: CONTENT_TYPE_JSON, + content_length: body.len(), + body, + } + } + + /// Parse the result body of a [`CreateTicket`](api::CreateTicket) API request. + /// + /// On success, this will either yield an [`Authentication`] or a [`SecondFactorChallenge`] if + /// Two-Factor-Authentication is required. + pub fn response(&self, body: &str) -> Result { + use ticket::TicketResponse; + + let response: api::ApiResponse = serde_json::from_str(body)?; + let response = response.data.ok_or("missing response data")?; + + if response.username != self.userid { + return Err("ticket response contained unexpected userid".into()); + } + + let ticket: TicketResponse = match response.ticket { + Some(ticket) => ticket.parse()?, + None => return Err("missing ticket".into()), + }; + + Ok(match ticket { + TicketResponse::Full(ticket) => { + if ticket.userid() != self.userid { + return Err("returned ticket contained unexpected userid".into()); + } + TicketResult::Full(Authentication { + csrfprevention_token: response + .csrfprevention_token + .ok_or("missing CSRFPreventionToken in ticket response")?, + clustername: response.clustername, + api_url: self.api_url.clone(), + userid: response.username, + ticket, + }) + } + + TicketResponse::Tfa(ticket, challenge) => { + TicketResult::TfaRequired(SecondFactorChallenge { + api_url: self.api_url.clone(), + pve_compat: self.pve_compat, + userid: response.username, + ticket, + challenge, + }) + } + }) + } +} + +/// This is the result of a ticket call. It will either yield a final ticket, or a TFA challenge. +/// +/// This is serializable in order to easily store it for later reuse. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub enum TicketResult { + /// The response contained a valid ticket. + Full(Authentication), + + /// The response returned a Two-Factor-Authentication challenge. + TfaRequired(SecondFactorChallenge), +} + +/// A ticket call can returned a TFA challenge. The user should inspect the +/// [`challenge`](tfa::TfaChallenge) member and call one of the `respond_*` methods which will +/// yield a HTTP [`Request`] which should be used to finish the authentication. +/// +/// Finally, the response should be passed to the [`response`](SecondFactorChallenge::response) +/// method to get the ticket. +/// +/// This is serializable in order to easily store it for later reuse. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub struct SecondFactorChallenge { + api_url: String, + pve_compat: bool, + userid: String, + ticket: String, + pub challenge: tfa::TfaChallenge, +} + +impl SecondFactorChallenge { + /// Create a HTTP request responding to a Yubico OTP challenge. + /// + /// Errors with `TfaError::Unavailable` if Yubic OTP is not available. + pub fn respond_yubico(&self, code: &str) -> Result { + if !self.challenge.yubico { + Err(TfaError::Unavailable) + } else { + Ok(self.respond_raw(&format!("yubico:{code}"))) + } + } + + /// Create a HTTP request responding with a TOTP value. + /// + /// Errors with `TfaError::Unavailable` if TOTP is not available. + pub fn respond_totp(&self, code: &str) -> Result { + if !self.challenge.totp { + Err(TfaError::Unavailable) + } else { + Ok(self.respond_raw(&format!("totp:{code}"))) + } + } + + /// Create a HTTP request responding with a recovery code. + /// + /// Errors with `TfaError::Unavailable` if no recovery codes are available. + pub fn respond_recovery(&self, code: &str) -> Result { + if !self.challenge.recovery.is_available() { + Err(TfaError::Unavailable) + } else { + Ok(self.respond_raw(&format!("recovery:{code}"))) + } + } + + #[cfg(feature = "webauthn")] + /// Create a HTTP request responding with a FIDO2/webauthn result JSON string. + /// + /// Errors with `TfaError::Unavailable` if no webauthn challenge was available. + pub fn respond_webauthn(&self, json_string: &str) -> Result { + if self.challenge.webauthn.is_none() { + Err(TfaError::Unavailable) + } else { + Ok(self.respond_raw(&format!("webauthn:{json_string}"))) + } + } + + /// Create a HTTP request using a raw response. + /// + /// A raw response is the response string prefixed with its challenge type and a colon. + pub fn respond_raw(&self, data: &str) -> Request { + let request = api::CreateTicket { + new_format: self.pve_compat.then_some(true), + username: self.userid.clone(), + password: data.to_string(), + tfa_challenge: Some(self.ticket.clone()), + ..Default::default() + }; + + let body = serde_json::to_string(&request).unwrap(); + + Request { + url: format!("{}/api2/json/access/ticket", self.api_url), + content_type: CONTENT_TYPE_JSON, + content_length: body.len(), + body, + } + } + + /// Deal with the API's response object to extract the ticket. + pub fn response(&self, body: &[u8]) -> Result { + let response: api::ApiResponse = serde_json::from_slice(body)?; + let response = response.data.ok_or("missing response data")?; + + if response.username != self.userid { + return Err("ticket response contained unexpected userid".into()); + } + + let ticket: Ticket = response.ticket.ok_or("no ticket in response")?.parse()?; + + if ticket.userid() != self.userid { + return Err("returned ticket contained unexpected userid".into()); + } + + Ok(Authentication { + ticket, + csrfprevention_token: response + .csrfprevention_token + .ok_or("missing CSRFPreventionToken in ticket response")?, + clustername: response.clustername, + userid: response.username, + api_url: self.api_url.clone(), + }) + } +} diff --git a/proxmox-login/src/parse.rs b/proxmox-login/src/parse.rs new file mode 100644 index 00000000..b2653521 --- /dev/null +++ b/proxmox-login/src/parse.rs @@ -0,0 +1,239 @@ +//! Some parsing helpers for the PVE API, mainly to deal with perl's untypedness. + +use std::fmt; + +use serde::de::Unexpected; + +// Boolean: + +pub trait FromBool: Sized + Default { + fn from_bool(value: bool) -> Self; +} + +impl FromBool for bool { + fn from_bool(value: bool) -> Self { + value + } +} + +impl FromBool for Option { + fn from_bool(value: bool) -> Self { + Some(value) + } +} + +pub fn deserialize_bool<'de, D, T>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, + T: FromBool, +{ + deserializer.deserialize_any(BoolVisitor::::new()) +} + +struct BoolVisitor(std::marker::PhantomData); + +impl BoolVisitor { + fn new() -> Self { + Self(std::marker::PhantomData) + } +} + +impl<'de, T: FromBool> serde::de::DeserializeSeed<'de> for BoolVisitor { + type Value = T; + + fn deserialize(self, deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + deserialize_bool(deserializer) + } +} + +impl<'de, T> serde::de::Visitor<'de> for BoolVisitor +where + T: FromBool, +{ + type Value = T; + + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("a boolean-ish...") + } + + fn visit_some(self, deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + deserializer.deserialize_any(self) + } + + fn visit_none(self) -> Result { + Ok(Default::default()) + } + + fn visit_bool(self, value: bool) -> Result { + Ok(Self::Value::from_bool(value)) + } + + fn visit_i128(self, value: i128) -> Result { + Ok(Self::Value::from_bool(value != 0)) + } + + fn visit_i64(self, value: i64) -> Result { + Ok(Self::Value::from_bool(value != 0)) + } + + fn visit_u64(self, value: u64) -> Result { + Ok(Self::Value::from_bool(value != 0)) + } + + fn visit_u128(self, value: u128) -> Result { + Ok(Self::Value::from_bool(value != 0)) + } + + fn visit_str(self, value: &str) -> Result { + let value = if value.eq_ignore_ascii_case("true") + || value.eq_ignore_ascii_case("yes") + || value.eq_ignore_ascii_case("on") + { + true + } else if value.eq_ignore_ascii_case("false") + || value.eq_ignore_ascii_case("no") + || value.eq_ignore_ascii_case("off") + { + false + } else { + return Err(E::invalid_value( + serde::de::Unexpected::Str(value), + &"a boolean-like value", + )); + }; + Ok(Self::Value::from_bool(value)) + } +} + +// integer helpers: + +macro_rules! integer_helper { + ($ty:ident, $deserialize_name:ident, $trait: ident, $from_name:ident, $visitor:ident) => { + pub trait $trait: Sized + Default { + fn $from_name(value: $ty) -> Self; + } + + impl $trait for $ty { + fn $from_name(value: $ty) -> Self { + value + } + } + + impl $trait for Option<$ty> { + fn $from_name(value: $ty) -> Self { + Some(value) + } + } + + pub fn $deserialize_name<'de, D, T>(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + T: $trait, + { + deserializer.deserialize_any($visitor::::new()) + } + + struct $visitor(std::marker::PhantomData); + + impl $visitor { + fn new() -> Self { + Self(std::marker::PhantomData) + } + } + + impl<'de, T: $trait> serde::de::DeserializeSeed<'de> for $visitor { + type Value = T; + + fn deserialize(self, deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + $deserialize_name(deserializer) + } + } + + impl<'de, T> serde::de::Visitor<'de> for $visitor + where + T: $trait, + { + type Value = T; + + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str(concat!("a ", stringify!($ty), "-ish...")) + } + + fn visit_some(self, deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + deserializer.deserialize_any(self) + } + + fn visit_none(self) -> Result { + Ok(Default::default()) + } + + fn visit_i128(self, value: i128) -> Result { + $ty::try_from(value) + .map_err(|_| E::invalid_value(Unexpected::Other("i128"), &self)) + .map(Self::Value::$from_name) + } + + fn visit_i64(self, value: i64) -> Result { + $ty::try_from(value) + .map_err(|_| E::invalid_value(Unexpected::Signed(value), &self)) + .map(Self::Value::$from_name) + } + + fn visit_u64(self, value: u64) -> Result { + $ty::try_from(value) + .map_err(|_| E::invalid_value(Unexpected::Unsigned(value), &self)) + .map(Self::Value::$from_name) + } + + fn visit_u128(self, value: u128) -> Result { + $ty::try_from(value) + .map_err(|_| E::invalid_value(Unexpected::Other("u128"), &self)) + .map(Self::Value::$from_name) + } + + fn visit_str(self, value: &str) -> Result { + let value = value + .parse() + .map_err(|_| E::invalid_value(Unexpected::Str(value), &self))?; + self.visit_i64(value) + } + } + }; +} + +integer_helper!( + isize, + deserialize_isize, + FromIsize, + from_isize, + IsizeVisitor +); + +integer_helper!( + usize, + deserialize_usize, + FromUsize, + from_usize, + UsizeVisitor +); + +integer_helper!(u8, deserialize_u8, FromU8, from_u8, U8Visitor); +integer_helper!(u16, deserialize_u16, FromU16, from_u16, U16Visitor); +integer_helper!(u32, deserialize_u32, FromU32, from_u32, U32Visitor); +integer_helper!(u64, deserialize_u64, FromU64, from_u64, U64Visitor); +integer_helper!(i8, deserialize_i8, FromI8, from_i8, I8Visitor); +integer_helper!(i16, deserialize_i16, FromI16, from_i16, I16Visitor); +integer_helper!(i32, deserialize_i32, FromI32, from_i32, I32Visitor); +integer_helper!(i64, deserialize_i64, FromI64, from_i64, I64Visitor); diff --git a/proxmox-login/src/tfa.rs b/proxmox-login/src/tfa.rs new file mode 100644 index 00000000..90215573 --- /dev/null +++ b/proxmox-login/src/tfa.rs @@ -0,0 +1,128 @@ +//! These types are from the `proxmox-tfa` crate. Currently the 'api' feature is required for this, +//! but we should add a feature that exposes the types without the api implementation and drop the +//! types from here. + +use std::fmt; + +use serde::{Deserialize, Serialize}; + +/// When sending a TFA challenge to the user, we include information about what kind of challenge +/// the user may perform. If webauthn credentials are available, a webauthn challenge will be +/// included. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct TfaChallenge { + /// True if the user has TOTP devices. + #[serde(skip_serializing_if = "bool_is_false", default)] + pub totp: bool, + + /// Whether there are recovery keys available. + #[serde(skip_serializing_if = "RecoveryState::is_unavailable", default)] + pub recovery: RecoveryState, + + #[cfg(feature = "webauthn")] + /// If the user has any webauthn credentials registered, this will contain the corresponding + /// challenge data. + #[serde(skip_serializing_if = "Option::is_none")] + pub webauthn: Option, + + /// True if the user has yubico keys configured. + #[serde(skip_serializing_if = "bool_is_false", default)] + pub yubico: bool, +} + +fn bool_is_false(b: &bool) -> bool { + !b +} + +/// Used to inform the user about the recovery code status. +/// +/// This contains the available key indices. +#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)] +pub struct RecoveryState(Vec); + +impl RecoveryState { + pub fn is_available(&self) -> bool { + !self.is_unavailable() + } + + pub fn is_unavailable(&self) -> bool { + self.0.is_empty() + } +} + +/// The "key" part of a registration, passed to `u2f.sign` in the registered keys list. +/// +/// Part of the U2F API, therefore `camelCase` and base64url without padding. +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RegisteredKey { + /// Identifies the key handle on the client side. Used to create authentication challenges, so + /// the client knows which key to use. Must be remembered. + #[serde(with = "bytes_as_base64url_nopad")] + pub key_handle: Vec, + + pub version: String, +} + +mod bytes_as_base64url_nopad { + use serde::{Deserialize, Deserializer}; + + pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result, D::Error> { + use serde::de::Error; + String::deserialize(deserializer).and_then(|string| { + base64::decode_config(&string, base64::URL_SAFE_NO_PAD) + .map_err(|err| Error::custom(err.to_string())) + }) + } +} + +/// A user's response to a TFA challenge. +pub enum TfaResponse { + Totp(String), + U2f(serde_json::Value), + Webauthn(serde_json::Value), + Recovery(String), +} + +#[derive(Debug)] +pub enum InvalidTfaResponse { + Unknown, + BadJson(serde_json::Error), +} + +impl fmt::Display for InvalidTfaResponse { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + InvalidTfaResponse::Unknown => f.write_str("unrecognized tfa response type"), + InvalidTfaResponse::BadJson(err) => fmt::Display::fmt(err, f), + } + } +} + +impl std::error::Error for InvalidTfaResponse {} + +impl From for InvalidTfaResponse { + fn from(err: serde_json::Error) -> Self { + InvalidTfaResponse::BadJson(err) + } +} + +/// This is part of the REST API: +impl std::str::FromStr for TfaResponse { + type Err = InvalidTfaResponse; + + fn from_str(s: &str) -> Result { + Ok(if let Some(totp) = s.strip_prefix("totp:") { + TfaResponse::Totp(totp.to_string()) + } else if let Some(u2f) = s.strip_prefix("u2f:") { + TfaResponse::U2f(serde_json::from_str(u2f)?) + } else if let Some(webauthn) = s.strip_prefix("webauthn:") { + TfaResponse::Webauthn(serde_json::from_str(webauthn)?) + } else if let Some(recovery) = s.strip_prefix("recovery:") { + TfaResponse::Recovery(recovery.to_string()) + } else { + return Err(InvalidTfaResponse::Unknown); + }) + } +} diff --git a/proxmox-login/src/ticket.rs b/proxmox-login/src/ticket.rs new file mode 100644 index 00000000..5784ef9c --- /dev/null +++ b/proxmox-login/src/ticket.rs @@ -0,0 +1,252 @@ +//! Ticket related data. + +use std::fmt; + +use serde::{Deserialize, Serialize}; + +use crate::error::TicketError; +use crate::tfa::TfaChallenge; + +/// The repsonse to a ticket call can either be a complete ticket, or a TFA challenge. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub(crate) enum TicketResponse { + Full(Ticket), + Tfa(String, TfaChallenge), +} + +impl std::str::FromStr for TicketResponse { + type Err = TicketError; + + fn from_str(ticket: &str) -> Result { + let pos = ticket.find(':').ok_or(TicketError)?; + match ticket[pos..].strip_prefix(":!tfa!") { + Some(challenge) => match challenge.find(':') { + Some(pos) => { + let challenge: std::borrow::Cow<[u8]> = + percent_encoding::percent_decode_str(&challenge[..pos]).into(); + let challenge = serde_json::from_slice(&challenge).map_err(|_| TicketError)?; + Ok(TicketResponse::Tfa(ticket.to_string(), challenge)) + } + None => Err(TicketError), + }, + None => ticket.parse().map(TicketResponse::Full), + } + } +} + +/// An API ticket string. Serializable so it can be stored for later reuse. +#[derive(Clone, Debug)] +pub struct Ticket { + data: Box, + timestamp: i64, + product_len: u16, + userid_len: u16, + // timestamp_len: u16, +} + +/// Tickets are valid for 2 hours. +const TICKET_LIFETIME: i64 = 2 * 3600; +/// We refresh during the last half hour. +const REFRESH_EARLY_BY: i64 = 1800; + +impl Ticket { + /// The ticket's product prefix. + pub fn product(&self) -> &str { + &self.data[..usize::from(self.product_len)] + } + + /// The userid contained in the ticket. + pub fn userid(&self) -> &str { + let start = usize::from(self.product_len) + 1; + let len = usize::from(self.userid_len); + &self.data[start..(start + len)] + } + + /// Thet ticket's timestamp as a UNIX epoch. + pub fn timestamp(&self) -> i64 { + self.timestamp + } + + /// The ticket age in seconds. + pub fn age(&self) -> i64 { + epoch_i64() - self.timestamp + } + + /// This is a convenience check for the ticket's validity assuming the usual ticket lifetime of + /// 2 hours. + pub fn validity(&self) -> Validity { + let age = self.age(); + if age > TICKET_LIFETIME { + Validity::Expired + } else if age >= TICKET_LIFETIME - REFRESH_EARLY_BY { + Validity::Refresh + } else { + Validity::Valid + } + } + + /// Get the cookie in the form `AuthCookie=Ticket`. + pub fn cookie(&self) -> String { + format!("{}AuthCookie={}", self.product(), self.data) + } +} + +/// Whether a ticket should be refreshed or is already invalid and needs to be completely renewed. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Validity { + /// The ticket is still valid for longer than half an hour. + Valid, + + /// The ticket is within its final half hour validity period and should be renewed with the + /// ticket as password. + Refresh, + + /// The ticket is already invalid and a new ticket needs to be created. + Expired, +} + +impl Validity { + /// Simply check whether the ticket is considered valid even if it should be renewed. + pub fn is_valid(self) -> bool { + matches!(self, Validity::Valid | Validity::Refresh) + } +} + +impl std::str::FromStr for Ticket { + type Err = TicketError; + + fn from_str(s: &str) -> Result { + let data = s; + + // get product: + let product_len = s.find(':').ok_or(TicketError)?; + if product_len >= 10 { + // weird product + return Err(TicketError); + } + let s = &s[(product_len + 1)..]; + + // get userid: + let userid_len = s.find(':').ok_or(TicketError)?; + if !s[..userid_len].contains('@') { + return Err(TicketError); + } + let s = &s[(userid_len + 1)..]; + + // timestamp + let timestamp_len = s.find(':').ok_or(TicketError)?; + let timestamp = i64::from_str_radix(&s[..timestamp_len], 16).map_err(|_| TicketError)?; + + let s = &s[(timestamp_len + 1)..]; + + let s = s.strip_prefix(':').ok_or(TicketError)?; + if s.is_empty() { + return Err(TicketError); + } + + Ok(Self { + product_len: u16::try_from(product_len).map_err(|_| TicketError)?, + userid_len: u16::try_from(userid_len).map_err(|_| TicketError)?, + //timestamp_len: u16::try_from(timestamp_len).map_err(|_| TicketError)?, + timestamp, + data: data.into(), + }) + } +} + +impl fmt::Display for Ticket { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str(&self.data) + } +} + +impl From for String { + fn from(ticket: Ticket) -> String { + ticket.data.into() + } +} + +impl From for Box { + fn from(ticket: Ticket) -> Box { + ticket.data + } +} + +impl Serialize for Ticket { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.data) + } +} + +impl<'de> Deserialize<'de> for Ticket { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + use serde::de::Error; + + std::borrow::Cow::<'de, str>::deserialize(deserializer)? + .parse() + .map_err(D::Error::custom) + } +} + +/// A finished authentication state. +/// +/// This is serializable / deserializable in order to be able to easily store it. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub struct Authentication { + /// The API URL this authentication info belongs to. + pub api_url: String, + + /// The user id in the form of `username@realm`. + pub userid: String, + + /// The authentication ticket. + pub ticket: Ticket, + + /// The cluster name (if any) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub clustername: Option, + + /// The CSRFPreventionToken header. + #[serde(rename = "CSRFPreventionToken")] + pub csrfprevention_token: String, +} + +impl Authentication { + /// Get the ticket cookie in the form `AuthCookie=Ticket`. + pub fn cookie(&self) -> String { + self.ticket.cookie() + } + + #[cfg(feature = "http")] + /// Add authentication headers to a request. + /// + /// This is equivalent to doing: + /// ```ignore + /// request + /// .header(http::header::COOKIE, auth.cookie()) + /// .header(proxmox_login::CSRF_HEADER_NAME, &auth.csrfprevention_token) + /// ``` + pub fn set_auth_headers(&self, request: http::request::Builder) -> http::request::Builder { + request + .header(http::header::COOKIE, self.cookie()) + .header(crate::CSRF_HEADER_NAME, &self.csrfprevention_token) + } +} + +fn epoch_i64() -> i64 { + use std::time::{SystemTime, UNIX_EPOCH}; + + let now = SystemTime::now(); + if now > UNIX_EPOCH { + i64::try_from(now.duration_since(UNIX_EPOCH).unwrap().as_secs()).unwrap_or(0) + } else { + -i64::try_from(UNIX_EPOCH.duration_since(now).unwrap().as_secs()).unwrap_or(0) + } +}