]>
Commit | Line | Data |
---|---|---|
26f586d5 DM |
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 | ||
d73eb3dc | 243 | #[cfg(target_arch = "wasm32")] |
ffa64bea | 244 | fn epoch_i64() -> i64 { |
b7a64cd4 | 245 | (js_sys::Date::now() / 1000.0) as i64 |
ffa64bea DM |
246 | } |
247 | ||
d73eb3dc | 248 | #[cfg(not(target_arch = "wasm32"))] |
26f586d5 DM |
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 | } |