]>
Commit | Line | Data |
---|---|---|
83ac3450 WB |
1 | //! This implements the `tfa.cfg` parser & TFA API calls for PMG. |
2 | //! | |
3 | //! The exported `PMG::RS::TFA` perl package provides access to rust's `TfaConfig`. | |
4 | //! Contrary to the PVE implementation, this does not need to provide any backward compatible | |
5 | //! entries. | |
6 | //! | |
7 | //! NOTE: In PMG the tfa config is behind `PVE::INotify`'s `ccache`, so PMG sets it to `noclone` in | |
8 | //! order to avoid losing the rust magic-ref. | |
9 | ||
10 | use std::fs::File; | |
11 | use std::io::{self, Read}; | |
12 | use std::os::unix::fs::OpenOptionsExt; | |
13 | use std::os::unix::io::{AsRawFd, RawFd}; | |
14 | use std::path::{Path, PathBuf}; | |
15 | ||
16 | use anyhow::{bail, format_err, Error}; | |
17 | use nix::errno::Errno; | |
18 | use nix::sys::stat::Mode; | |
19 | ||
20 | pub(self) use proxmox_tfa::api::{ | |
9fdb289d WB |
21 | RecoveryState, TfaChallenge, TfaConfig, TfaResponse, U2fConfig, UserChallengeAccess, |
22 | WebauthnConfig, | |
83ac3450 WB |
23 | }; |
24 | ||
25 | #[perlmod::package(name = "PMG::RS::TFA")] | |
26 | mod export { | |
aed16575 | 27 | use std::collections::HashMap; |
83ac3450 WB |
28 | use std::convert::TryInto; |
29 | use std::sync::Mutex; | |
30 | ||
31 | use anyhow::{bail, format_err, Error}; | |
32 | use serde_bytes::ByteBuf; | |
33 | use url::Url; | |
34 | ||
35 | use perlmod::Value; | |
72140ad5 | 36 | use proxmox_tfa::api::{methods, TfaResult}; |
83ac3450 WB |
37 | |
38 | use super::{TfaConfig, UserAccess}; | |
39 | ||
40 | perlmod::declare_magic!(Box<Tfa> : &Tfa as "PMG::RS::TFA"); | |
41 | ||
42 | /// A TFA Config instance. | |
43 | pub struct Tfa { | |
44 | inner: Mutex<TfaConfig>, | |
45 | } | |
46 | ||
47 | /// Prevent 'dclone'. | |
48 | #[export(name = "STORABLE_freeze", raw_return)] | |
49 | fn storable_freeze(#[try_from_ref] _this: &Tfa, _cloning: bool) -> Result<Value, Error> { | |
50 | bail!("freezing TFA config not supported!"); | |
51 | } | |
52 | ||
53 | /// Parse a TFA configuration. | |
54 | #[export(raw_return)] | |
55 | fn new(#[raw] class: Value, config: &[u8]) -> Result<Value, Error> { | |
56 | let mut inner: TfaConfig = serde_json::from_slice(config) | |
57 | .map_err(|err| format_err!("failed to parse TFA file: {}", err))?; | |
58 | ||
59 | // PMG does not support U2F. | |
60 | inner.u2f = None; | |
61 | Ok(perlmod::instantiate_magic!( | |
62 | &class, MAGIC => Box::new(Tfa { inner: Mutex::new(inner) }) | |
63 | )) | |
64 | } | |
65 | ||
66 | /// Write the configuration out into a JSON string. | |
67 | #[export] | |
68 | fn write(#[try_from_ref] this: &Tfa) -> Result<serde_bytes::ByteBuf, Error> { | |
69 | let inner = this.inner.lock().unwrap(); | |
70 | Ok(ByteBuf::from(serde_json::to_vec(&*inner)?)) | |
71 | } | |
72 | ||
73 | /// Debug helper: serialize the TFA user data into a perl value. | |
74 | #[export] | |
75 | fn to_perl(#[try_from_ref] this: &Tfa) -> Result<Value, Error> { | |
76 | let inner = this.inner.lock().unwrap(); | |
77 | Ok(perlmod::to_value(&*inner)?) | |
78 | } | |
79 | ||
80 | /// Get a list of all the user names in this config. | |
81 | /// PMG uses this to verify users and purge the invalid ones. | |
82 | #[export] | |
83 | fn users(#[try_from_ref] this: &Tfa) -> Result<Vec<String>, Error> { | |
84 | Ok(this.inner.lock().unwrap().users.keys().cloned().collect()) | |
85 | } | |
86 | ||
87 | /// Remove a user from the TFA configuration. | |
88 | #[export] | |
89 | fn remove_user(#[try_from_ref] this: &Tfa, userid: &str) -> Result<bool, Error> { | |
90 | Ok(this.inner.lock().unwrap().users.remove(userid).is_some()) | |
91 | } | |
92 | ||
93 | /// Get the TFA data for a specific user. | |
94 | #[export(raw_return)] | |
95 | fn get_user(#[try_from_ref] this: &Tfa, userid: &str) -> Result<Value, perlmod::Error> { | |
96 | perlmod::to_value(&this.inner.lock().unwrap().users.get(userid)) | |
97 | } | |
98 | ||
99 | /// Add a u2f registration. This modifies the config (adds the user to it), so it needs be | |
100 | /// written out. | |
101 | #[export] | |
102 | fn add_u2f_registration( | |
103 | #[raw] raw_this: Value, | |
104 | //#[try_from_ref] this: &Tfa, | |
105 | userid: &str, | |
106 | description: String, | |
107 | ) -> Result<String, Error> { | |
108 | let this: &Tfa = (&raw_this).try_into()?; | |
109 | let mut inner = this.inner.lock().unwrap(); | |
9fdb289d | 110 | inner.u2f_registration_challenge(&UserAccess::new(&raw_this)?, userid, description) |
83ac3450 WB |
111 | } |
112 | ||
113 | /// Finish a u2f registration. This updates temporary data in `/run` and therefore the config | |
114 | /// needs to be written out! | |
115 | #[export] | |
116 | fn finish_u2f_registration( | |
117 | #[raw] raw_this: Value, | |
118 | //#[try_from_ref] this: &Tfa, | |
119 | userid: &str, | |
120 | challenge: &str, | |
121 | response: &str, | |
122 | ) -> Result<String, Error> { | |
123 | let this: &Tfa = (&raw_this).try_into()?; | |
124 | let mut inner = this.inner.lock().unwrap(); | |
9fdb289d | 125 | inner.u2f_registration_finish(&UserAccess::new(&raw_this)?, userid, challenge, response) |
83ac3450 WB |
126 | } |
127 | ||
128 | /// Check if a user has any TFA entries of a given type. | |
129 | #[export] | |
130 | fn has_type(#[try_from_ref] this: &Tfa, userid: &str, typename: &str) -> Result<bool, Error> { | |
131 | Ok(match this.inner.lock().unwrap().users.get(userid) { | |
132 | Some(user) => match typename { | |
133 | "totp" | "oath" => !user.totp.is_empty(), | |
134 | "u2f" => !user.u2f.is_empty(), | |
135 | "webauthn" => !user.webauthn.is_empty(), | |
136 | "yubico" => !user.yubico.is_empty(), | |
137 | "recovery" => match &user.recovery { | |
138 | Some(r) => r.count_available() > 0, | |
139 | None => false, | |
140 | }, | |
141 | _ => bail!("unrecognized TFA type {:?}", typename), | |
142 | }, | |
143 | None => false, | |
144 | }) | |
145 | } | |
146 | ||
147 | /// Generates a space separated list of yubico keys of this account. | |
148 | #[export] | |
149 | fn get_yubico_keys(#[try_from_ref] this: &Tfa, userid: &str) -> Result<Option<String>, Error> { | |
150 | Ok(this.inner.lock().unwrap().users.get(userid).map(|user| { | |
151 | user.enabled_yubico_entries() | |
152 | .fold(String::new(), |mut s, k| { | |
153 | if !s.is_empty() { | |
154 | s.push(' '); | |
155 | } | |
156 | s.push_str(k); | |
157 | s | |
158 | }) | |
159 | })) | |
160 | } | |
161 | ||
162 | #[export] | |
163 | fn set_u2f_config(#[try_from_ref] this: &Tfa, config: Option<super::U2fConfig>) { | |
164 | this.inner.lock().unwrap().u2f = config; | |
165 | } | |
166 | ||
167 | #[export] | |
168 | fn set_webauthn_config( | |
169 | #[try_from_ref] this: &Tfa, | |
170 | config: Option<super::WebauthnConfig>, | |
171 | ) -> Result<(), Error> { | |
172 | this.inner.lock().unwrap().webauthn = config.map(TryInto::try_into).transpose()?; | |
173 | Ok(()) | |
174 | } | |
175 | ||
176 | #[export] | |
177 | fn get_webauthn_config( | |
178 | #[try_from_ref] this: &Tfa, | |
179 | ) -> Result<(Option<String>, Option<super::WebauthnConfig>), Error> { | |
180 | Ok(match this.inner.lock().unwrap().webauthn.clone() { | |
181 | Some(config) => (Some(hex::encode(&config.digest())), Some(config.into())), | |
182 | None => (None, None), | |
183 | }) | |
184 | } | |
185 | ||
186 | #[export] | |
187 | fn has_webauthn_origin(#[try_from_ref] this: &Tfa) -> bool { | |
188 | match &this.inner.lock().unwrap().webauthn { | |
189 | Some(wa) => wa.origin.is_some(), | |
190 | None => false, | |
191 | } | |
192 | } | |
193 | ||
194 | /// Create an authentication challenge. | |
195 | /// | |
196 | /// Returns the challenge as a json string. | |
197 | /// Returns `undef` if no second factor is configured. | |
198 | #[export] | |
199 | fn authentication_challenge( | |
200 | #[raw] raw_this: Value, | |
201 | //#[try_from_ref] this: &Tfa, | |
202 | userid: &str, | |
203 | origin: Option<Url>, | |
204 | ) -> Result<Option<String>, Error> { | |
205 | let this: &Tfa = (&raw_this).try_into()?; | |
206 | let mut inner = this.inner.lock().unwrap(); | |
207 | match inner.authentication_challenge( | |
9fdb289d | 208 | &UserAccess::new(&raw_this)?, |
83ac3450 WB |
209 | userid, |
210 | origin.as_ref(), | |
211 | )? { | |
212 | Some(challenge) => Ok(Some(serde_json::to_string(&challenge)?)), | |
213 | None => Ok(None), | |
214 | } | |
215 | } | |
216 | ||
217 | /// Get the recovery state (suitable for a challenge object). | |
218 | #[export] | |
219 | fn recovery_state(#[try_from_ref] this: &Tfa, userid: &str) -> Option<super::RecoveryState> { | |
220 | this.inner | |
221 | .lock() | |
222 | .unwrap() | |
223 | .users | |
224 | .get(userid) | |
72140ad5 | 225 | .and_then(|user| user.recovery_state()) |
83ac3450 WB |
226 | } |
227 | ||
228 | /// Takes the TFA challenge string (which is a json object) and verifies ther esponse against | |
229 | /// it. | |
230 | /// | |
231 | /// NOTE: This returns a boolean whether the config data needs to be *saved* after this call | |
232 | /// (to use up recovery keys!). | |
233 | #[export] | |
234 | fn authentication_verify( | |
235 | #[raw] raw_this: Value, | |
236 | //#[try_from_ref] this: &Tfa, | |
237 | userid: &str, | |
238 | challenge: &str, //super::TfaChallenge, | |
239 | response: &str, | |
240 | origin: Option<Url>, | |
241 | ) -> Result<bool, Error> { | |
242 | let this: &Tfa = (&raw_this).try_into()?; | |
243 | let challenge: super::TfaChallenge = serde_json::from_str(challenge)?; | |
244 | let response: super::TfaResponse = response.parse()?; | |
245 | let mut inner = this.inner.lock().unwrap(); | |
72140ad5 WB |
246 | let result = inner.verify( |
247 | &UserAccess::new(&raw_this)?, | |
248 | userid, | |
249 | &challenge, | |
250 | response, | |
251 | origin.as_ref(), | |
252 | ); | |
253 | match result { | |
254 | TfaResult::Success { needs_saving } => Ok(needs_saving), | |
255 | _ => bail!("TFA authentication failed"), | |
256 | } | |
83ac3450 WB |
257 | } |
258 | ||
e8857729 WB |
259 | /// Takes the TFA challenge string (which is a json object) and verifies ther esponse against |
260 | /// it. | |
261 | /// | |
262 | /// Returns a result hash of the form: | |
263 | /// ```text | |
264 | /// { | |
265 | /// "result": bool, // whether TFA was successful | |
266 | /// "needs-saving": bool, // whether the user config needs saving | |
267 | /// "tfa-limit-reached": bool, // whether the TFA limit was reached (config needs saving) | |
268 | /// "totp-limit-reached": bool, // whether the TOTP limit was reached (config needs saving) | |
269 | /// } | |
270 | /// ``` | |
271 | #[export] | |
272 | fn authentication_verify2( | |
273 | #[raw] raw_this: Value, | |
274 | //#[try_from_ref] this: &Tfa, | |
275 | userid: &str, | |
276 | challenge: &str, //super::TfaChallenge, | |
277 | response: &str, | |
278 | origin: Option<Url>, | |
279 | ) -> Result<TfaReturnValue, Error> { | |
280 | let this: &Tfa = (&raw_this).try_into()?; | |
281 | let challenge: super::TfaChallenge = serde_json::from_str(challenge)?; | |
282 | let response: super::TfaResponse = response.parse()?; | |
283 | let mut inner = this.inner.lock().unwrap(); | |
284 | let result = inner.verify( | |
285 | &UserAccess::new(&raw_this)?, | |
286 | userid, | |
287 | &challenge, | |
288 | response, | |
289 | origin.as_ref(), | |
290 | ); | |
291 | Ok(match result { | |
292 | TfaResult::Success { needs_saving } => TfaReturnValue { | |
293 | result: true, | |
294 | needs_saving, | |
295 | ..Default::default() | |
296 | }, | |
297 | TfaResult::Locked => TfaReturnValue::default(), | |
298 | TfaResult::Failure { | |
299 | needs_saving, | |
300 | totp_limit_reached, | |
301 | tfa_limit_reached, | |
302 | } => TfaReturnValue { | |
303 | result: false, | |
304 | needs_saving, | |
305 | totp_limit_reached, | |
306 | tfa_limit_reached, | |
307 | }, | |
308 | }) | |
309 | } | |
310 | ||
311 | #[derive(Default, serde::Serialize)] | |
312 | #[serde(rename_all = "kebab-case")] | |
313 | struct TfaReturnValue { | |
314 | result: bool, | |
315 | needs_saving: bool, | |
316 | totp_limit_reached: bool, | |
317 | tfa_limit_reached: bool, | |
318 | } | |
319 | ||
83ac3450 WB |
320 | /// DEBUG HELPER: Get the current TOTP value for a given TOTP URI. |
321 | #[export] | |
322 | fn get_current_totp_value(otp_uri: &str) -> Result<String, Error> { | |
323 | let totp: proxmox_tfa::totp::Totp = otp_uri.parse()?; | |
324 | Ok(totp.time(std::time::SystemTime::now())?.to_string()) | |
325 | } | |
326 | ||
327 | #[export] | |
328 | fn api_list_user_tfa( | |
329 | #[try_from_ref] this: &Tfa, | |
330 | userid: &str, | |
331 | ) -> Result<Vec<methods::TypedTfaInfo>, Error> { | |
332 | methods::list_user_tfa(&this.inner.lock().unwrap(), userid) | |
333 | } | |
334 | ||
335 | #[export] | |
336 | fn api_get_tfa_entry( | |
337 | #[try_from_ref] this: &Tfa, | |
338 | userid: &str, | |
339 | id: &str, | |
340 | ) -> Option<methods::TypedTfaInfo> { | |
341 | methods::get_tfa_entry(&this.inner.lock().unwrap(), userid, id) | |
342 | } | |
343 | ||
344 | /// Returns `true` if the user still has other TFA entries left, `false` if the user has *no* | |
345 | /// more tfa entries. | |
346 | #[export] | |
347 | fn api_delete_tfa(#[try_from_ref] this: &Tfa, userid: &str, id: String) -> Result<bool, Error> { | |
348 | let mut this = this.inner.lock().unwrap(); | |
349 | match methods::delete_tfa(&mut this, userid, &id) { | |
350 | Ok(has_entries_left) => Ok(has_entries_left), | |
351 | Err(methods::EntryNotFound) => bail!("no such entry"), | |
352 | } | |
353 | } | |
354 | ||
355 | #[export] | |
356 | fn api_list_tfa( | |
357 | #[try_from_ref] this: &Tfa, | |
358 | authid: &str, | |
359 | top_level_allowed: bool, | |
360 | ) -> Result<Vec<methods::TfaUser>, Error> { | |
361 | methods::list_tfa(&this.inner.lock().unwrap(), authid, top_level_allowed) | |
362 | } | |
363 | ||
364 | #[export] | |
365 | fn api_add_tfa_entry( | |
366 | #[raw] raw_this: Value, | |
367 | //#[try_from_ref] this: &Tfa, | |
368 | userid: &str, | |
369 | description: Option<String>, | |
370 | totp: Option<String>, | |
371 | value: Option<String>, | |
372 | challenge: Option<String>, | |
373 | ty: methods::TfaType, | |
374 | origin: Option<Url>, | |
375 | ) -> Result<methods::TfaUpdateInfo, Error> { | |
376 | let this: &Tfa = (&raw_this).try_into()?; | |
377 | methods::add_tfa_entry( | |
378 | &mut this.inner.lock().unwrap(), | |
9fdb289d | 379 | &UserAccess::new(&raw_this)?, |
83ac3450 WB |
380 | userid, |
381 | description, | |
382 | totp, | |
383 | value, | |
384 | challenge, | |
385 | ty, | |
386 | origin.as_ref(), | |
387 | ) | |
388 | } | |
389 | ||
390 | /// Add a totp entry without validating it, used for user.cfg keys. | |
391 | /// Returns the ID. | |
392 | #[export] | |
393 | fn add_totp_entry( | |
394 | #[try_from_ref] this: &Tfa, | |
395 | userid: &str, | |
396 | description: String, | |
397 | totp: String, | |
398 | ) -> Result<String, Error> { | |
399 | Ok(this | |
400 | .inner | |
401 | .lock() | |
402 | .unwrap() | |
403 | .add_totp(userid, description, totp.parse()?)) | |
404 | } | |
405 | ||
406 | /// Add a yubico entry without validating it, used for user.cfg keys. | |
407 | /// Returns the ID. | |
408 | #[export] | |
409 | fn add_yubico_entry( | |
410 | #[try_from_ref] this: &Tfa, | |
411 | userid: &str, | |
412 | description: String, | |
413 | yubico: String, | |
414 | ) -> String { | |
415 | this.inner | |
416 | .lock() | |
417 | .unwrap() | |
418 | .add_yubico(userid, description, yubico) | |
419 | } | |
420 | ||
421 | #[export] | |
422 | fn api_update_tfa_entry( | |
423 | #[try_from_ref] this: &Tfa, | |
424 | userid: &str, | |
425 | id: &str, | |
426 | description: Option<String>, | |
427 | enable: Option<bool>, | |
428 | ) -> Result<(), Error> { | |
429 | match methods::update_tfa_entry( | |
430 | &mut this.inner.lock().unwrap(), | |
431 | userid, | |
432 | id, | |
433 | description, | |
434 | enable, | |
435 | ) { | |
436 | Ok(()) => Ok(()), | |
437 | Err(methods::EntryNotFound) => bail!("no such entry"), | |
438 | } | |
439 | } | |
aed16575 WB |
440 | |
441 | #[export] | |
442 | fn api_unlock_tfa(#[try_from_ref] this: &Tfa, userid: &str) -> Result<bool, Error> { | |
443 | Ok(methods::unlock_tfa( | |
444 | &mut this.inner.lock().unwrap(), | |
445 | userid, | |
446 | )?) | |
447 | } | |
448 | ||
449 | #[derive(serde::Serialize)] | |
450 | #[serde(rename_all = "kebab-case")] | |
451 | struct TfaLockStatus { | |
452 | /// Once a user runs into a TOTP limit they get locked out of TOTP until they successfully use | |
453 | /// a recovery key. | |
454 | #[serde(skip_serializing_if = "bool_is_false", default)] | |
455 | totp_locked: bool, | |
456 | ||
457 | /// If a user hits too many 2nd factor failures, they get completely blocked for a while. | |
458 | #[serde(skip_serializing_if = "Option::is_none", default)] | |
459 | #[serde(deserialize_with = "filter_expired_timestamp")] | |
460 | tfa_locked_until: Option<i64>, | |
461 | } | |
462 | ||
463 | impl From<&proxmox_tfa::api::TfaUserData> for TfaLockStatus { | |
464 | fn from(data: &proxmox_tfa::api::TfaUserData) -> Self { | |
465 | Self { | |
466 | totp_locked: data.totp_locked, | |
467 | tfa_locked_until: data.tfa_locked_until, | |
468 | } | |
469 | } | |
470 | } | |
471 | ||
472 | fn bool_is_false(b: &bool) -> bool { | |
473 | !*b | |
474 | } | |
475 | ||
476 | #[export] | |
477 | fn tfa_lock_status( | |
478 | #[try_from_ref] this: &Tfa, | |
479 | userid: Option<&str>, | |
480 | ) -> Result<Option<perlmod::Value>, Error> { | |
481 | let this = this.inner.lock().unwrap(); | |
482 | if let Some(userid) = userid { | |
483 | if let Some(user) = this.users.get(userid) { | |
484 | Ok(Some(perlmod::to_value(&TfaLockStatus::from(user))?)) | |
485 | } else { | |
486 | Ok(None) | |
487 | } | |
488 | } else { | |
489 | Ok(Some(perlmod::to_value( | |
490 | &HashMap::<String, TfaLockStatus>::from_iter( | |
491 | this.users | |
492 | .iter() | |
493 | .map(|(uid, data)| (uid.clone(), TfaLockStatus::from(data))), | |
494 | ), | |
495 | )?)) | |
496 | } | |
497 | } | |
83ac3450 WB |
498 | } |
499 | ||
500 | /// Attach the path to errors from [`nix::mkir()`]. | |
501 | pub(crate) fn mkdir<P: AsRef<Path>>(path: P, mode: libc::mode_t) -> Result<(), Error> { | |
502 | let path = path.as_ref(); | |
503 | match nix::unistd::mkdir(path, unsafe { Mode::from_bits_unchecked(mode) }) { | |
504 | Ok(()) => Ok(()), | |
b67ff27d | 505 | Err(Errno::EEXIST) => Ok(()), |
83ac3450 WB |
506 | Err(err) => bail!("failed to create directory {:?}: {}", path, err), |
507 | } | |
508 | } | |
509 | ||
510 | #[cfg(debug_assertions)] | |
511 | #[derive(Clone)] | |
512 | #[repr(transparent)] | |
513 | pub struct UserAccess(perlmod::Value); | |
514 | ||
515 | #[cfg(debug_assertions)] | |
516 | impl UserAccess { | |
517 | #[inline] | |
518 | fn new(value: &perlmod::Value) -> Result<Self, Error> { | |
519 | value | |
520 | .dereference() | |
521 | .ok_or_else(|| format_err!("bad TFA config object")) | |
522 | .map(Self) | |
523 | } | |
524 | ||
525 | #[inline] | |
526 | fn is_debug(&self) -> bool { | |
527 | self.0 | |
528 | .as_hash() | |
529 | .and_then(|v| v.get("-debug")) | |
530 | .map(|v| v.iv() != 0) | |
531 | .unwrap_or(false) | |
532 | } | |
533 | } | |
534 | ||
535 | #[cfg(not(debug_assertions))] | |
536 | #[derive(Clone, Copy)] | |
537 | #[repr(transparent)] | |
538 | pub struct UserAccess; | |
539 | ||
540 | #[cfg(not(debug_assertions))] | |
541 | impl UserAccess { | |
542 | #[inline] | |
543 | const fn new(_value: &perlmod::Value) -> Result<Self, std::convert::Infallible> { | |
544 | Ok(Self) | |
545 | } | |
546 | ||
547 | #[inline] | |
548 | const fn is_debug(&self) -> bool { | |
549 | false | |
550 | } | |
551 | } | |
552 | ||
553 | /// Build the path to the challenge data file for a user. | |
554 | fn challenge_data_path(userid: &str, debug: bool) -> PathBuf { | |
555 | if debug { | |
556 | PathBuf::from(format!("./local-tfa-challenges/{}", userid)) | |
557 | } else { | |
558 | PathBuf::from(format!("/run/pmg-private/tfa-challenges/{}", userid)) | |
559 | } | |
560 | } | |
561 | ||
562 | impl proxmox_tfa::api::OpenUserChallengeData for UserAccess { | |
9fdb289d | 563 | fn open(&self, userid: &str) -> Result<Box<dyn UserChallengeAccess>, Error> { |
83ac3450 WB |
564 | if self.is_debug() { |
565 | mkdir("./local-tfa-challenges", 0o700)?; | |
566 | } else { | |
567 | mkdir("/run/pmg-private", 0o700)?; | |
568 | mkdir("/run/pmg-private/tfa-challenges", 0o700)?; | |
569 | } | |
570 | ||
571 | let path = challenge_data_path(userid, self.is_debug()); | |
572 | ||
573 | let mut file = std::fs::OpenOptions::new() | |
574 | .create(true) | |
575 | .read(true) | |
576 | .write(true) | |
577 | .truncate(false) | |
578 | .mode(0o600) | |
579 | .open(&path) | |
580 | .map_err(|err| format_err!("failed to create challenge file {:?}: {}", &path, err))?; | |
581 | ||
582 | UserChallengeData::lock_file(file.as_raw_fd())?; | |
583 | ||
584 | // the file may be empty, so read to a temporary buffer first: | |
585 | let mut data = Vec::with_capacity(4096); | |
586 | ||
587 | file.read_to_end(&mut data).map_err(|err| { | |
588 | format_err!("failed to read challenge data for user {}: {}", userid, err) | |
589 | })?; | |
590 | ||
591 | let inner = if data.is_empty() { | |
592 | Default::default() | |
593 | } else { | |
594 | match serde_json::from_slice(&data) { | |
595 | Ok(inner) => inner, | |
596 | Err(err) => { | |
597 | eprintln!( | |
598 | "failed to parse challenge data for user {}: {}", | |
599 | userid, err | |
600 | ); | |
601 | Default::default() | |
602 | } | |
603 | } | |
604 | }; | |
605 | ||
9fdb289d | 606 | Ok(Box::new(UserChallengeData { |
83ac3450 WB |
607 | inner, |
608 | path, | |
609 | lock: file, | |
9fdb289d | 610 | })) |
83ac3450 WB |
611 | } |
612 | ||
613 | /// `open` without creating the file if it doesn't exist, to finish WA authentications. | |
9fdb289d | 614 | fn open_no_create(&self, userid: &str) -> Result<Option<Box<dyn UserChallengeAccess>>, Error> { |
83ac3450 WB |
615 | let path = challenge_data_path(userid, self.is_debug()); |
616 | ||
617 | let mut file = match std::fs::OpenOptions::new() | |
618 | .read(true) | |
619 | .write(true) | |
620 | .truncate(false) | |
621 | .mode(0o600) | |
622 | .open(&path) | |
623 | { | |
624 | Ok(file) => file, | |
625 | Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None), | |
626 | Err(err) => return Err(err.into()), | |
627 | }; | |
628 | ||
629 | UserChallengeData::lock_file(file.as_raw_fd())?; | |
630 | ||
631 | let inner = serde_json::from_reader(&mut file).map_err(|err| { | |
632 | format_err!("failed to read challenge data for user {}: {}", userid, err) | |
633 | })?; | |
634 | ||
9fdb289d | 635 | Ok(Some(Box::new(UserChallengeData { |
83ac3450 WB |
636 | inner, |
637 | path, | |
638 | lock: file, | |
9fdb289d | 639 | }))) |
83ac3450 WB |
640 | } |
641 | ||
642 | fn remove(&self, userid: &str) -> Result<bool, Error> { | |
643 | let path = challenge_data_path(userid, self.is_debug()); | |
644 | match std::fs::remove_file(&path) { | |
645 | Ok(()) => Ok(true), | |
646 | Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(false), | |
647 | Err(err) => Err(err.into()), | |
648 | } | |
649 | } | |
72140ad5 | 650 | |
e8857729 WB |
651 | // TODO: enable once we have UI/API admin stuff to unlock locked accounts |
652 | fn enable_lockout(&self) -> bool { | |
653 | false | |
72140ad5 | 654 | } |
83ac3450 WB |
655 | } |
656 | ||
657 | /// Container of `TfaUserChallenges` with the corresponding file lock guard. | |
658 | /// | |
659 | /// Basically provides the TFA API to the REST server by persisting, updating and verifying active | |
660 | /// challenges. | |
661 | pub struct UserChallengeData { | |
662 | inner: proxmox_tfa::api::TfaUserChallenges, | |
663 | path: PathBuf, | |
664 | lock: File, | |
665 | } | |
666 | ||
667 | impl proxmox_tfa::api::UserChallengeAccess for UserChallengeData { | |
668 | fn get_mut(&mut self) -> &mut proxmox_tfa::api::TfaUserChallenges { | |
669 | &mut self.inner | |
670 | } | |
671 | ||
9fdb289d | 672 | fn save(&mut self) -> Result<(), Error> { |
83ac3450 WB |
673 | UserChallengeData::save(self) |
674 | } | |
675 | } | |
676 | ||
677 | impl UserChallengeData { | |
678 | fn lock_file(fd: RawFd) -> Result<(), Error> { | |
679 | let rc = unsafe { libc::flock(fd, libc::LOCK_EX) }; | |
680 | ||
681 | if rc != 0 { | |
682 | let err = io::Error::last_os_error(); | |
683 | bail!("failed to lock tfa user challenge data: {}", err); | |
684 | } | |
685 | ||
686 | Ok(()) | |
687 | } | |
688 | ||
689 | /// Rewind & truncate the file for an update. | |
690 | fn rewind(&mut self) -> Result<(), Error> { | |
691 | use std::io::{Seek, SeekFrom}; | |
692 | ||
693 | let pos = self.lock.seek(SeekFrom::Start(0))?; | |
694 | if pos != 0 { | |
695 | bail!( | |
696 | "unexpected result trying to rewind file, position is {}", | |
697 | pos | |
698 | ); | |
699 | } | |
700 | ||
701 | let rc = unsafe { libc::ftruncate(self.lock.as_raw_fd(), 0) }; | |
702 | if rc != 0 { | |
703 | let err = io::Error::last_os_error(); | |
704 | bail!("failed to truncate challenge data: {}", err); | |
705 | } | |
706 | ||
707 | Ok(()) | |
708 | } | |
709 | ||
710 | /// Save the current data. Note that we do not replace the file here since we lock the file | |
711 | /// itself, as it is in `/run`, and the typical error case for this particular situation | |
712 | /// (machine loses power) simply prevents some login, but that'll probably fail anyway for | |
713 | /// other reasons then... | |
714 | /// | |
715 | /// This currently consumes selfe as we never perform more than 1 insertion/removal, and this | |
716 | /// way also unlocks early. | |
9fdb289d | 717 | fn save(&mut self) -> Result<(), Error> { |
83ac3450 WB |
718 | self.rewind()?; |
719 | ||
720 | serde_json::to_writer(&mut &self.lock, &self.inner).map_err(|err| { | |
721 | format_err!("failed to update challenge file {:?}: {}", self.path, err) | |
722 | })?; | |
723 | ||
724 | Ok(()) | |
725 | } | |
726 | } |