]> git.proxmox.com Git - proxmox.git/blob - proxmox-login/src/ticket.rs
b37952c5b194cd8b27e605b7dfffbda0ef5b9bd6
[proxmox.git] / proxmox-login / src / ticket.rs
1 //! Ticket related data.
2
3 use std::fmt;
4
5 use serde::{Deserialize, Serialize};
6
7 use crate::error::TicketError;
8 use crate::tfa::TfaChallenge;
9
10 /// The repsonse to a ticket call can either be a complete ticket, or a TFA challenge.
11 #[derive(Clone, Debug, Serialize, Deserialize)]
12 pub(crate) enum TicketResponse {
13 Full(Ticket),
14 Tfa(String, TfaChallenge),
15 }
16
17 impl std::str::FromStr for TicketResponse {
18 type Err = TicketError;
19
20 fn from_str(ticket: &str) -> Result<Self, TicketError> {
21 let pos = ticket.find(':').ok_or(TicketError)?;
22 match ticket[pos..].strip_prefix(":!tfa!") {
23 Some(challenge) => match challenge.find(':') {
24 Some(pos) => {
25 let challenge: std::borrow::Cow<[u8]> =
26 percent_encoding::percent_decode_str(&challenge[..pos]).into();
27 let challenge = serde_json::from_slice(&challenge).map_err(|_| TicketError)?;
28 Ok(TicketResponse::Tfa(ticket.to_string(), challenge))
29 }
30 None => Err(TicketError),
31 },
32 None => ticket.parse().map(TicketResponse::Full),
33 }
34 }
35 }
36
37 /// An API ticket string. Serializable so it can be stored for later reuse.
38 #[derive(Clone, Debug)]
39 pub struct Ticket {
40 data: Box<str>,
41 timestamp: i64,
42 product_len: u16,
43 userid_len: u16,
44 // timestamp_len: u16,
45 }
46
47 /// Tickets are valid for 2 hours.
48 const TICKET_LIFETIME: i64 = 2 * 3600;
49 /// We refresh during the last half hour.
50 const REFRESH_EARLY_BY: i64 = 1800;
51
52 impl Ticket {
53 /// The ticket's product prefix.
54 pub fn product(&self) -> &str {
55 &self.data[..usize::from(self.product_len)]
56 }
57
58 /// The userid contained in the ticket.
59 pub fn userid(&self) -> &str {
60 let start = usize::from(self.product_len) + 1;
61 let len = usize::from(self.userid_len);
62 &self.data[start..(start + len)]
63 }
64
65 /// Thet ticket's timestamp as a UNIX epoch.
66 pub fn timestamp(&self) -> i64 {
67 self.timestamp
68 }
69
70 /// The ticket age in seconds.
71 pub fn age(&self) -> i64 {
72 epoch_i64() - self.timestamp
73 }
74
75 /// This is a convenience check for the ticket's validity assuming the usual ticket lifetime of
76 /// 2 hours.
77 pub fn validity(&self) -> Validity {
78 let age = self.age();
79 if age > TICKET_LIFETIME {
80 Validity::Expired
81 } else if age >= TICKET_LIFETIME - REFRESH_EARLY_BY {
82 Validity::Refresh
83 } else {
84 Validity::Valid
85 }
86 }
87
88 /// Get the cookie in the form `<PRODUCT>AuthCookie=Ticket`.
89 pub fn cookie(&self) -> String {
90 format!("{}AuthCookie={}", self.product(), self.data)
91 }
92 }
93
94 /// Whether a ticket should be refreshed or is already invalid and needs to be completely renewed.
95 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
96 pub enum Validity {
97 /// The ticket is still valid for longer than half an hour.
98 Valid,
99
100 /// The ticket is within its final half hour validity period and should be renewed with the
101 /// ticket as password.
102 Refresh,
103
104 /// The ticket is already invalid and a new ticket needs to be created.
105 Expired,
106 }
107
108 impl Validity {
109 /// Simply check whether the ticket is considered valid even if it should be renewed.
110 pub fn is_valid(self) -> bool {
111 matches!(self, Validity::Valid | Validity::Refresh)
112 }
113 }
114
115 impl std::str::FromStr for Ticket {
116 type Err = TicketError;
117
118 fn from_str(s: &str) -> Result<Self, TicketError> {
119 let data = s;
120
121 // get product:
122 let product_len = s.find(':').ok_or(TicketError)?;
123 if product_len >= 10 {
124 // weird product
125 return Err(TicketError);
126 }
127 let s = &s[(product_len + 1)..];
128
129 // get userid:
130 let userid_len = s.find(':').ok_or(TicketError)?;
131 if !s[..userid_len].contains('@') {
132 return Err(TicketError);
133 }
134 let s = &s[(userid_len + 1)..];
135
136 // timestamp
137 let timestamp_len = s.find(':').ok_or(TicketError)?;
138 let timestamp = i64::from_str_radix(&s[..timestamp_len], 16).map_err(|_| TicketError)?;
139
140 let s = &s[(timestamp_len + 1)..];
141
142 let s = s.strip_prefix(':').ok_or(TicketError)?;
143 if s.is_empty() {
144 return Err(TicketError);
145 }
146
147 Ok(Self {
148 product_len: u16::try_from(product_len).map_err(|_| TicketError)?,
149 userid_len: u16::try_from(userid_len).map_err(|_| TicketError)?,
150 //timestamp_len: u16::try_from(timestamp_len).map_err(|_| TicketError)?,
151 timestamp,
152 data: data.into(),
153 })
154 }
155 }
156
157 impl fmt::Display for Ticket {
158 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
159 f.write_str(&self.data)
160 }
161 }
162
163 impl From<Ticket> for String {
164 fn from(ticket: Ticket) -> String {
165 ticket.data.into()
166 }
167 }
168
169 impl From<Ticket> for Box<str> {
170 fn from(ticket: Ticket) -> Box<str> {
171 ticket.data
172 }
173 }
174
175 impl Serialize for Ticket {
176 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
177 where
178 S: serde::Serializer,
179 {
180 serializer.serialize_str(&self.data)
181 }
182 }
183
184 impl<'de> Deserialize<'de> for Ticket {
185 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
186 where
187 D: serde::Deserializer<'de>,
188 {
189 use serde::de::Error;
190
191 std::borrow::Cow::<'de, str>::deserialize(deserializer)?
192 .parse()
193 .map_err(D::Error::custom)
194 }
195 }
196
197 /// A finished authentication state.
198 ///
199 /// This is serializable / deserializable in order to be able to easily store it.
200 #[derive(Clone, Debug, Serialize, Deserialize)]
201 #[serde(rename_all = "kebab-case")]
202 pub struct Authentication {
203 /// The API URL this authentication info belongs to.
204 pub api_url: String,
205
206 /// The user id in the form of `username@realm`.
207 pub userid: String,
208
209 /// The authentication ticket.
210 pub ticket: Ticket,
211
212 /// The cluster name (if any)
213 #[serde(default, skip_serializing_if = "Option::is_none")]
214 pub clustername: Option<String>,
215
216 /// The CSRFPreventionToken header.
217 #[serde(rename = "CSRFPreventionToken")]
218 pub csrfprevention_token: String,
219 }
220
221 impl Authentication {
222 /// Get the ticket cookie in the form `<PRODUCT>AuthCookie=Ticket`.
223 pub fn cookie(&self) -> String {
224 self.ticket.cookie()
225 }
226
227 #[cfg(feature = "http")]
228 /// Add authentication headers to a request.
229 ///
230 /// This is equivalent to doing:
231 /// ```ignore
232 /// request
233 /// .header(http::header::COOKIE, auth.cookie())
234 /// .header(proxmox_login::CSRF_HEADER_NAME, &auth.csrfprevention_token)
235 /// ```
236 pub fn set_auth_headers(&self, request: http::request::Builder) -> http::request::Builder {
237 request
238 .header(http::header::COOKIE, self.cookie())
239 .header(crate::CSRF_HEADER_NAME, &self.csrfprevention_token)
240 }
241 }
242
243 #[cfg(target_arch="wasm32")]
244 fn epoch_i64() -> i64 {
245 (js_sys::Date::now() / 1000.0) as i64
246 }
247
248 #[cfg(not(target_arch="wasm32"))]
249 fn epoch_i64() -> i64 {
250 use std::time::{SystemTime, UNIX_EPOCH};
251
252 let now = SystemTime::now();
253 if now > UNIX_EPOCH {
254 i64::try_from(now.duration_since(UNIX_EPOCH).unwrap().as_secs()).unwrap_or(0)
255 } else {
256 -i64::try_from(UNIX_EPOCH.duration_since(now).unwrap().as_secs()).unwrap_or(0)
257 }
258 }