new proxmox-login package

Author: Wofgang Bumiller <w.bumiller@proxmox.com>
This commit is contained in:
Dietmar Maurer 2023-05-04 09:09:08 +02:00
parent 12674a37e0
commit 26f586d5eb
11 changed files with 1195 additions and 0 deletions

25
proxmox-login/Cargo.toml Normal file
View File

@ -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"]

View File

@ -0,0 +1,6 @@
rust-proxmox-login (0.1.0-1) unstable; urgency=medium
* initial Debian package
-- Proxmox Support Team <support@proxmox.com> Thu, 04 May 2023 08:40:38 +0200

View File

@ -0,0 +1,74 @@
Source: rust-proxmox-login
Section: rust
Priority: optional
Build-Depends: debhelper (>= 12),
dh-cargo (>= 25),
cargo:native <!nocheck>,
rustc:native <!nocheck>,
libstd-rust-dev <!nocheck>,
librust-base64-0.13+default-dev <!nocheck>,
librust-percent-encoding-2+default-dev (>= 2.1-~~) <!nocheck>,
librust-serde-1+default-dev <!nocheck>,
librust-serde-1+derive-dev <!nocheck>,
librust-serde-json-1+default-dev <!nocheck>
Maintainer: Proxmox Support Team <support@proxmox.com>
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.

View File

@ -0,0 +1,16 @@
Copyright (C) 2023 Proxmox Server Solutions GmbH
This software is written by Proxmox Server Solutions GmbH <support@proxmox.com>
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 <http://www.gnu.org/licenses/>.

View File

@ -0,0 +1,7 @@
overlay = "."
crate_src_path = ".."
maintainer = "Proxmox Support Team <support@proxmox.com>"
[source]
#vcs_git = "git://git.proxmox.com/git/proxmox.git"
#vcs_browser = "https://git.proxmox.com/?p=proxmox.git"

71
proxmox-login/src/api.rs Normal file
View File

@ -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<bool>,
/// One-time password for Two-factor authentication.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub otp: Option<String>,
/// 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<String>,
/// Verify ticket, and check if user have access 'privs' on 'path'
#[serde(default, skip_serializing_if = "Option::is_none")]
pub privs: Option<String>,
/// You can optionally pass the realm using this parameter. Normally the
/// realm is simply added to the username <username>@<relam>.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub realm: Option<String>,
/// 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<String>,
/// 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<String>,
/// The cluster's visual name.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub clustername: Option<String>,
/// The ticket as is supposed to be used in the authentication header.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ticket: Option<String>,
/// The full userid with the `@realm` part.
pub username: String,
}
#[derive(Deserialize)]
pub struct ApiResponse<T> {
pub data: Option<T>,
}

View File

@ -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<serde_json::Error> 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<TicketError> 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<serde_json::Error> for TfaError {
fn from(err: serde_json::Error) -> Self {
TfaError::Json(err)
}
}

292
proxmox-login/src/lib.rs Normal file
View File

@ -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<String>, ticket: impl Into<String>) -> Result<Self, TicketError> {
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<String>) {
self.api_url = api_url.into();
}
/// Prepare a request given an already parsed ticket.
pub fn renew_ticket(api_url: impl Into<String>, 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<String>, userid: impl Into<String>, password: impl Into<String>) -> 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<TicketResult, ResponseError> {
use ticket::TicketResponse;
let response: api::ApiResponse<api::CreateTicketResponse> = 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<Request, TfaError> {
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<Request, TfaError> {
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<Request, TfaError> {
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<Request, TfaError> {
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<Authentication, ResponseError> {
let response: api::ApiResponse<api::CreateTicketResponse> = 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(),
})
}
}

239
proxmox-login/src/parse.rs Normal file
View File

@ -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<bool> {
fn from_bool(value: bool) -> Self {
Some(value)
}
}
pub fn deserialize_bool<'de, D, T>(deserializer: D) -> Result<T, D::Error>
where
D: serde::Deserializer<'de>,
T: FromBool,
{
deserializer.deserialize_any(BoolVisitor::<T>::new())
}
struct BoolVisitor<T>(std::marker::PhantomData<T>);
impl<T> BoolVisitor<T> {
fn new() -> Self {
Self(std::marker::PhantomData)
}
}
impl<'de, T: FromBool> serde::de::DeserializeSeed<'de> for BoolVisitor<T> {
type Value = T;
fn deserialize<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
where
D: serde::Deserializer<'de>,
{
deserialize_bool(deserializer)
}
}
impl<'de, T> serde::de::Visitor<'de> for BoolVisitor<T>
where
T: FromBool,
{
type Value = T;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("a boolean-ish...")
}
fn visit_some<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
where
D: serde::Deserializer<'de>,
{
deserializer.deserialize_any(self)
}
fn visit_none<E>(self) -> Result<Self::Value, E> {
Ok(Default::default())
}
fn visit_bool<E: serde::de::Error>(self, value: bool) -> Result<Self::Value, E> {
Ok(Self::Value::from_bool(value))
}
fn visit_i128<E: serde::de::Error>(self, value: i128) -> Result<Self::Value, E> {
Ok(Self::Value::from_bool(value != 0))
}
fn visit_i64<E: serde::de::Error>(self, value: i64) -> Result<Self::Value, E> {
Ok(Self::Value::from_bool(value != 0))
}
fn visit_u64<E: serde::de::Error>(self, value: u64) -> Result<Self::Value, E> {
Ok(Self::Value::from_bool(value != 0))
}
fn visit_u128<E: serde::de::Error>(self, value: u128) -> Result<Self::Value, E> {
Ok(Self::Value::from_bool(value != 0))
}
fn visit_str<E: serde::de::Error>(self, value: &str) -> Result<Self::Value, E> {
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<T, D::Error>
where
D: serde::Deserializer<'de>,
T: $trait,
{
deserializer.deserialize_any($visitor::<T>::new())
}
struct $visitor<T>(std::marker::PhantomData<T>);
impl<T> $visitor<T> {
fn new() -> Self {
Self(std::marker::PhantomData)
}
}
impl<'de, T: $trait> serde::de::DeserializeSeed<'de> for $visitor<T> {
type Value = T;
fn deserialize<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
where
D: serde::Deserializer<'de>,
{
$deserialize_name(deserializer)
}
}
impl<'de, T> serde::de::Visitor<'de> for $visitor<T>
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<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
where
D: serde::Deserializer<'de>,
{
deserializer.deserialize_any(self)
}
fn visit_none<E>(self) -> Result<Self::Value, E> {
Ok(Default::default())
}
fn visit_i128<E: serde::de::Error>(self, value: i128) -> Result<Self::Value, E> {
$ty::try_from(value)
.map_err(|_| E::invalid_value(Unexpected::Other("i128"), &self))
.map(Self::Value::$from_name)
}
fn visit_i64<E: serde::de::Error>(self, value: i64) -> Result<Self::Value, E> {
$ty::try_from(value)
.map_err(|_| E::invalid_value(Unexpected::Signed(value), &self))
.map(Self::Value::$from_name)
}
fn visit_u64<E: serde::de::Error>(self, value: u64) -> Result<Self::Value, E> {
$ty::try_from(value)
.map_err(|_| E::invalid_value(Unexpected::Unsigned(value), &self))
.map(Self::Value::$from_name)
}
fn visit_u128<E: serde::de::Error>(self, value: u128) -> Result<Self::Value, E> {
$ty::try_from(value)
.map_err(|_| E::invalid_value(Unexpected::Other("u128"), &self))
.map(Self::Value::$from_name)
}
fn visit_str<E: serde::de::Error>(self, value: &str) -> Result<Self::Value, E> {
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);

128
proxmox-login/src/tfa.rs Normal file
View File

@ -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<webauthn_rs::proto::RequestChallengeResponse>,
/// 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<usize>);
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<u8>,
pub version: String,
}
mod bytes_as_base64url_nopad {
use serde::{Deserialize, Deserializer};
pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<u8>, 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<serde_json::Error> 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<Self, Self::Err> {
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);
})
}
}

252
proxmox-login/src/ticket.rs Normal file
View File

@ -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<Self, TicketError> {
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<str>,
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 `<PRODUCT>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<Self, TicketError> {
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<Ticket> for String {
fn from(ticket: Ticket) -> String {
ticket.data.into()
}
}
impl From<Ticket> for Box<str> {
fn from(ticket: Ticket) -> Box<str> {
ticket.data
}
}
impl Serialize for Ticket {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.data)
}
}
impl<'de> Deserialize<'de> for Ticket {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
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<String>,
/// The CSRFPreventionToken header.
#[serde(rename = "CSRFPreventionToken")]
pub csrfprevention_token: String,
}
impl Authentication {
/// Get the ticket cookie in the form `<PRODUCT>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)
}
}