]>
Commit | Line | Data |
---|---|---|
313d0a6b WB |
1 | //! TFA configuration and user data. |
2 | //! | |
3 | //! This is the same as used in PBS but without the `#[api]` type. | |
4 | //! | |
5 | //! We may want to move this into a shared crate making the `#[api]` macro feature-gated! | |
6 | ||
7 | use std::collections::HashMap; | |
a3448feb | 8 | use std::fmt; |
313d0a6b WB |
9 | |
10 | use anyhow::{bail, format_err, Error}; | |
11 | use serde::{Deserialize, Serialize}; | |
12 | use serde_json::Value; | |
637188d4 | 13 | use url::Url; |
313d0a6b | 14 | |
313d0a6b WB |
15 | use webauthn_rs::{proto::UserVerificationPolicy, Webauthn}; |
16 | ||
17 | use crate::totp::Totp; | |
18 | use proxmox_uuid::Uuid; | |
19 | ||
313d0a6b WB |
20 | mod serde_tools; |
21 | ||
22 | mod recovery; | |
23 | mod u2f; | |
24 | mod webauthn; | |
25 | ||
26 | pub mod methods; | |
27 | ||
28 | pub use recovery::RecoveryState; | |
29 | pub use u2f::U2fConfig; | |
637188d4 | 30 | use webauthn::WebauthnConfigInstance; |
148950fd | 31 | pub use webauthn::{WebauthnConfig, WebauthnCredential}; |
313d0a6b WB |
32 | |
33 | #[cfg(feature = "api-types")] | |
34 | pub use webauthn::WebauthnConfigUpdater; | |
35 | ||
0d942e81 WB |
36 | pub use crate::types::TfaInfo; |
37 | ||
313d0a6b WB |
38 | use recovery::Recovery; |
39 | use u2f::{U2fChallenge, U2fChallengeEntry, U2fRegistrationChallenge}; | |
40 | use webauthn::{WebauthnAuthChallenge, WebauthnRegistrationChallenge}; | |
41 | ||
42 | trait IsExpired { | |
43 | fn is_expired(&self, at_epoch: i64) -> bool; | |
44 | } | |
45 | ||
5349ae20 WB |
46 | pub trait OpenUserChallengeData { |
47 | fn open(&self, userid: &str) -> Result<Box<dyn UserChallengeAccess>, Error>; | |
313d0a6b | 48 | |
5349ae20 | 49 | fn open_no_create(&self, userid: &str) -> Result<Option<Box<dyn UserChallengeAccess>>, Error>; |
313d0a6b WB |
50 | |
51 | /// Should return `true` if something was removed, `false` if no data existed for the user. | |
52 | fn remove(&self, userid: &str) -> Result<bool, Error>; | |
a3448feb WB |
53 | |
54 | /// This allows overriding the number of TOTP failures allowed before locking a user out of | |
55 | /// TOTP. | |
56 | fn totp_failure_limit(&self) -> u32 { | |
57 | 8 | |
58 | } | |
59 | ||
60 | /// This allows overriding the number of consecutive TFA failures before an account gets rate | |
61 | /// limited. | |
62 | fn tfa_failure_limit(&self) -> u32 { | |
63 | 100 | |
64 | } | |
65 | ||
66 | /// This allows overriding the time users are locked out when reaching the tfa failure limit. | |
67 | fn tfa_failure_lock_time(&self) -> i64 { | |
68 | 3600 * 12 | |
69 | } | |
70 | ||
71 | /// Since PVE needs cluster-wide package upgrades for new entries in [`TfaUserData`], TOTP code | |
72 | /// reuse checks can be configured here. | |
73 | fn enable_lockout(&self) -> bool { | |
74 | true | |
75 | } | |
76 | } | |
77 | ||
78 | #[test] | |
79 | fn ensure_open_user_challenge_data_is_dyn_safe() { | |
80 | let _: Option<&dyn OpenUserChallengeData> = None; | |
313d0a6b WB |
81 | } |
82 | ||
5349ae20 | 83 | pub trait UserChallengeAccess { |
313d0a6b | 84 | fn get_mut(&mut self) -> &mut TfaUserChallenges; |
5349ae20 | 85 | fn save(&mut self) -> Result<(), Error>; |
313d0a6b WB |
86 | } |
87 | ||
88 | const CHALLENGE_TIMEOUT_SECS: i64 = 2 * 60; | |
89 | ||
90 | /// TFA Configuration for this instance. | |
91 | #[derive(Clone, Default, Deserialize, Serialize)] | |
92 | pub struct TfaConfig { | |
93 | #[serde(skip_serializing_if = "Option::is_none")] | |
94 | pub u2f: Option<U2fConfig>, | |
95 | ||
96 | #[serde(skip_serializing_if = "Option::is_none")] | |
97 | pub webauthn: Option<WebauthnConfig>, | |
98 | ||
99 | #[serde(skip_serializing_if = "TfaUsers::is_empty", default)] | |
100 | pub users: TfaUsers, | |
101 | } | |
102 | ||
103 | /// Helper to get a u2f instance from a u2f config, or `None` if there isn't one configured. | |
104 | fn get_u2f(u2f: &Option<U2fConfig>) -> Option<u2f::U2f> { | |
54e97d35 WB |
105 | u2f.as_ref().map(|cfg| { |
106 | u2f::U2f::new( | |
107 | cfg.appid.clone(), | |
108 | cfg.origin.clone().unwrap_or_else(|| cfg.appid.clone()), | |
109 | ) | |
110 | }) | |
313d0a6b WB |
111 | } |
112 | ||
113 | /// Helper to get a u2f instance from a u2f config. | |
114 | /// | |
115 | /// This is outside of `TfaConfig` to not borrow its `&self`. | |
116 | fn check_u2f(u2f: &Option<U2fConfig>) -> Result<u2f::U2f, Error> { | |
117 | get_u2f(u2f).ok_or_else(|| format_err!("no u2f configuration available")) | |
118 | } | |
119 | ||
120 | /// Helper to get a `Webauthn` instance from a `WebauthnConfig`, or `None` if there isn't one | |
121 | /// configured. | |
637188d4 WB |
122 | fn get_webauthn<'a, 'config: 'a, 'origin: 'a>( |
123 | waconfig: &'config Option<WebauthnConfig>, | |
124 | origin: Option<&'origin Url>, | |
b6840e95 WB |
125 | ) -> Option<Webauthn<WebauthnConfigInstance<'a>>> { |
126 | match waconfig.as_ref()?.instantiate(origin) { | |
127 | Ok(wa) => Some(Webauthn::new(wa)), | |
128 | Err(err) => { | |
129 | log::error!("webauthn error: {err}"); | |
130 | None | |
131 | } | |
132 | } | |
313d0a6b WB |
133 | } |
134 | ||
d0b4f0bf | 135 | /// Helper to get a `WebauthnConfigInstance` from a `WebauthnConfig` |
313d0a6b WB |
136 | /// |
137 | /// This is outside of `TfaConfig` to not borrow its `&self`. | |
637188d4 WB |
138 | fn check_webauthn<'a, 'config: 'a, 'origin: 'a>( |
139 | waconfig: &'config Option<WebauthnConfig>, | |
140 | origin: Option<&'origin Url>, | |
141 | ) -> Result<Webauthn<WebauthnConfigInstance<'a>>, Error> { | |
b6840e95 | 142 | get_webauthn(waconfig, origin).ok_or_else(|| format_err!("no webauthn configuration available")) |
313d0a6b WB |
143 | } |
144 | ||
145 | impl TfaConfig { | |
39017fa3 | 146 | /// Unlock a user's 2nd factor authentication (including TOTP). |
a26ec45d WB |
147 | /// Returns whether the user was locked before calling this method. |
148 | pub fn unlock_tfa(&mut self, userid: &str) -> Result<bool, Error> { | |
39017fa3 WB |
149 | match self.users.get_mut(userid) { |
150 | Some(user) => { | |
a26ec45d | 151 | let ret = user.totp_locked || user.tfa_is_locked(); |
39017fa3 WB |
152 | user.totp_locked = false; |
153 | user.tfa_locked_until = None; | |
a26ec45d | 154 | Ok(ret) |
39017fa3 | 155 | } |
a26ec45d | 156 | None => bail!("no such user"), |
39017fa3 WB |
157 | } |
158 | } | |
159 | ||
160 | /// Unlock a user's TOTP challenges. | |
161 | pub fn unlock_totp(&mut self, userid: &str) -> Result<(), Error> { | |
162 | match self.users.get_mut(userid) { | |
163 | Some(user) => { | |
164 | user.totp_locked = false; | |
165 | Ok(()) | |
166 | } | |
167 | None => bail!("no such challenge"), | |
168 | } | |
169 | } | |
170 | ||
171 | /// Get a u2f registration challenge. | |
5349ae20 | 172 | pub fn u2f_registration_challenge<A: ?Sized + OpenUserChallengeData>( |
313d0a6b | 173 | &mut self, |
5349ae20 | 174 | access: &A, |
313d0a6b WB |
175 | userid: &str, |
176 | description: String, | |
177 | ) -> Result<String, Error> { | |
178 | let u2f = check_u2f(&self.u2f)?; | |
179 | ||
180 | self.users | |
181 | .entry(userid.to_owned()) | |
182 | .or_default() | |
183 | .u2f_registration_challenge(access, userid, &u2f, description) | |
184 | } | |
185 | ||
186 | /// Finish a u2f registration challenge. | |
5349ae20 | 187 | pub fn u2f_registration_finish<A: ?Sized + OpenUserChallengeData>( |
313d0a6b | 188 | &mut self, |
5349ae20 | 189 | access: &A, |
313d0a6b WB |
190 | userid: &str, |
191 | challenge: &str, | |
192 | response: &str, | |
193 | ) -> Result<String, Error> { | |
194 | let u2f = check_u2f(&self.u2f)?; | |
195 | ||
196 | match self.users.get_mut(userid) { | |
197 | Some(user) => user.u2f_registration_finish(access, userid, &u2f, challenge, response), | |
198 | None => bail!("no such challenge"), | |
199 | } | |
200 | } | |
201 | ||
202 | /// Get a webauthn registration challenge. | |
5349ae20 | 203 | pub fn webauthn_registration_challenge<A: ?Sized + OpenUserChallengeData>( |
313d0a6b | 204 | &mut self, |
5349ae20 | 205 | access: &A, |
313d0a6b WB |
206 | user: &str, |
207 | description: String, | |
637188d4 | 208 | origin: Option<&Url>, |
313d0a6b | 209 | ) -> Result<String, Error> { |
637188d4 | 210 | let webauthn = check_webauthn(&self.webauthn, origin)?; |
313d0a6b WB |
211 | |
212 | self.users | |
213 | .entry(user.to_owned()) | |
214 | .or_default() | |
215 | .webauthn_registration_challenge(access, webauthn, user, description) | |
216 | } | |
217 | ||
218 | /// Finish a webauthn registration challenge. | |
5349ae20 | 219 | pub fn webauthn_registration_finish<A: ?Sized + OpenUserChallengeData>( |
313d0a6b | 220 | &mut self, |
5349ae20 | 221 | access: &A, |
313d0a6b WB |
222 | userid: &str, |
223 | challenge: &str, | |
224 | response: &str, | |
637188d4 | 225 | origin: Option<&Url>, |
313d0a6b | 226 | ) -> Result<String, Error> { |
637188d4 | 227 | let webauthn = check_webauthn(&self.webauthn, origin)?; |
313d0a6b WB |
228 | |
229 | let response: webauthn_rs::proto::RegisterPublicKeyCredential = | |
230 | serde_json::from_str(response) | |
231 | .map_err(|err| format_err!("error parsing challenge response: {}", err))?; | |
232 | ||
233 | match self.users.get_mut(userid) { | |
234 | Some(user) => { | |
235 | user.webauthn_registration_finish(access, webauthn, userid, challenge, response) | |
236 | } | |
237 | None => bail!("no such challenge"), | |
238 | } | |
239 | } | |
240 | ||
241 | /// Add a TOTP entry for a user. | |
242 | /// | |
243 | /// Unlike U2F/WA, this does not require a challenge/response. The user can choose their secret | |
244 | /// themselves. | |
245 | pub fn add_totp(&mut self, userid: &str, description: String, value: Totp) -> String { | |
246 | self.users | |
247 | .entry(userid.to_owned()) | |
248 | .or_default() | |
249 | .add_totp(description, value) | |
250 | } | |
251 | ||
252 | /// Add a Yubico key to a user. | |
253 | /// | |
254 | /// Unlike U2F/WA, this does not require a challenge/response. The user can choose their secret | |
255 | /// themselves. | |
256 | pub fn add_yubico(&mut self, userid: &str, description: String, key: String) -> String { | |
257 | self.users | |
258 | .entry(userid.to_owned()) | |
259 | .or_default() | |
260 | .add_yubico(description, key) | |
261 | } | |
262 | ||
263 | /// Add a new set of recovery keys. There can only be 1 set of keys at a time. | |
264 | pub fn add_recovery(&mut self, userid: &str) -> Result<Vec<String>, Error> { | |
265 | self.users | |
266 | .entry(userid.to_owned()) | |
267 | .or_default() | |
268 | .add_recovery() | |
269 | } | |
270 | ||
271 | /// Get a two factor authentication challenge for a user, if the user has TFA set up. | |
5349ae20 | 272 | pub fn authentication_challenge<A: ?Sized + OpenUserChallengeData>( |
313d0a6b | 273 | &mut self, |
5349ae20 | 274 | access: &A, |
313d0a6b | 275 | userid: &str, |
637188d4 | 276 | origin: Option<&Url>, |
313d0a6b WB |
277 | ) -> Result<Option<TfaChallenge>, Error> { |
278 | match self.users.get_mut(userid) { | |
279 | Some(udata) => udata.challenge( | |
280 | access, | |
281 | userid, | |
637188d4 | 282 | get_webauthn(&self.webauthn, origin), |
313d0a6b WB |
283 | get_u2f(&self.u2f).as_ref(), |
284 | ), | |
285 | None => Ok(None), | |
286 | } | |
287 | } | |
288 | ||
289 | /// Verify a TFA challenge. | |
5349ae20 | 290 | pub fn verify<A: ?Sized + OpenUserChallengeData>( |
313d0a6b | 291 | &mut self, |
5349ae20 | 292 | access: &A, |
313d0a6b WB |
293 | userid: &str, |
294 | challenge: &TfaChallenge, | |
295 | response: TfaResponse, | |
637188d4 | 296 | origin: Option<&Url>, |
a3448feb WB |
297 | ) -> TfaResult { |
298 | let user = match self.users.get_mut(userid) { | |
299 | Some(user) => user, | |
300 | None => { | |
301 | // This should not be reachable, as an API should not try to verify a 2nd factor | |
302 | // of a user that doesn't have any 2nd factors. | |
303 | log::error!("no 2nd factor available for user '{userid}'"); | |
304 | return TfaResult::failure(false); | |
305 | } | |
306 | }; | |
307 | ||
308 | if user.tfa_is_locked() { | |
309 | log::error!("refusing 2nd factor for user '{userid}'"); | |
310 | return TfaResult::Locked; | |
311 | } | |
312 | ||
313 | let mut was_totp = false; | |
314 | let result = match response { | |
315 | TfaResponse::Totp(value) => { | |
316 | was_totp = true; | |
317 | if user.totp_locked { | |
318 | log::error!("TOTP of user '{userid}' is locked"); | |
319 | return TfaResult::Locked; | |
320 | } | |
321 | user.verify_totp(access, userid, &value) | |
322 | .map(|needs_saving| TfaResult::Success { needs_saving }) | |
323 | } | |
324 | TfaResponse::U2f(value) => match &challenge.u2f { | |
325 | Some(challenge) => user | |
326 | .verify_u2f(access, userid, &self.u2f, &challenge.challenge, value) | |
327 | .map(|()| TfaResult::Success { | |
328 | needs_saving: false, | |
329 | }), | |
330 | None => Err(format_err!("no u2f factor available for user '{}'", userid)), | |
331 | }, | |
332 | TfaResponse::Webauthn(value) => user | |
333 | .verify_webauthn(access, userid, &self.webauthn, origin, value) | |
334 | .map(|()| TfaResult::Success { | |
335 | needs_saving: false, | |
336 | }), | |
337 | TfaResponse::Recovery(value) => { | |
338 | // recovery keys get used up so they always persist data: | |
339 | user.verify_recovery(access, userid, &value) | |
340 | .map(|()| TfaResult::Success { needs_saving: true }) | |
341 | } | |
342 | }; | |
343 | ||
344 | match result { | |
345 | Ok(r @ TfaResult::Success { .. }) => { | |
346 | // reset tfa failure count on success: | |
347 | let mut data = match access.open(userid) { | |
348 | Ok(data) => data, | |
349 | Err(err) => { | |
350 | log::error!("failed to access user challenge data for '{userid}': {err}"); | |
351 | return r; | |
313d0a6b | 352 | } |
a3448feb WB |
353 | }; |
354 | ||
355 | let access = data.get_mut(); | |
356 | let mut save = false; | |
357 | if was_totp && access.totp_failures != 0 { | |
358 | access.totp_failures = 0; | |
359 | save = true; | |
313d0a6b | 360 | } |
a3448feb WB |
361 | |
362 | if access.tfa_failures != 0 { | |
363 | access.tfa_failures = 0; | |
364 | save = true; | |
313d0a6b | 365 | } |
313d0a6b | 366 | |
a3448feb WB |
367 | if save { |
368 | if let Err(err) = data.save() { | |
369 | log::error!("failed to store user challenge data: {err}"); | |
370 | } | |
371 | } | |
372 | r | |
373 | } | |
374 | Ok(r) => r, | |
375 | Err(err) => { | |
376 | log::error!("error in 2nd factor authentication for user '{userid}': {err}"); | |
377 | let mut data = match access.open(userid) { | |
378 | Ok(data) => data, | |
379 | Err(err) => { | |
380 | log::error!("failed to access user challenge data for '{userid}': {err}"); | |
381 | return TfaResult::failure(false); | |
382 | } | |
383 | }; | |
384 | ||
385 | let data_mut = data.get_mut(); | |
386 | data_mut.tfa_failures += 1; | |
387 | // totp failures are counted in `verify_totp` | |
388 | ||
389 | let tfa_limit_reached = data_mut.tfa_failures >= access.tfa_failure_limit(); | |
390 | let totp_limit_reached = | |
391 | was_totp && data_mut.totp_failures >= access.totp_failure_limit(); | |
392 | ||
393 | if !tfa_limit_reached && !totp_limit_reached { | |
394 | if let Err(err) = data.save() { | |
395 | log::error!("failed to store user challenge data: {err}"); | |
396 | } | |
397 | return TfaResult::failure(false); | |
398 | } | |
399 | ||
400 | if let Err(err) = data.save() { | |
401 | log::error!("failed to store user challenge data: {err}"); | |
402 | } | |
403 | drop(data); | |
404 | ||
405 | if totp_limit_reached { | |
406 | user.totp_locked = access.enable_lockout(); | |
407 | } | |
408 | ||
409 | if tfa_limit_reached && access.enable_lockout() { | |
410 | user.tfa_locked_until = | |
411 | Some(proxmox_time::epoch_i64() + access.tfa_failure_lock_time()); | |
412 | } | |
413 | ||
414 | return TfaResult::Failure { | |
415 | needs_saving: true, | |
416 | tfa_limit_reached, | |
417 | totp_limit_reached, | |
418 | }; | |
419 | } | |
420 | } | |
313d0a6b WB |
421 | } |
422 | ||
5349ae20 | 423 | pub fn remove_user<A: ?Sized + OpenUserChallengeData>( |
313d0a6b | 424 | &mut self, |
5349ae20 | 425 | access: &A, |
313d0a6b WB |
426 | userid: &str, |
427 | ) -> Result<NeedsSaving, Error> { | |
428 | let mut save = access.remove(userid)?; | |
429 | if self.users.remove(userid).is_some() { | |
430 | save = true; | |
431 | } | |
a3448feb WB |
432 | Ok(if save { |
433 | NeedsSaving::Yes | |
434 | } else { | |
435 | NeedsSaving::No | |
436 | }) | |
437 | } | |
438 | } | |
439 | ||
440 | #[must_use = "must save the config in order to ensure one-time use of recovery keys"] | |
441 | #[derive(Debug)] | |
442 | pub enum TfaResult { | |
443 | /// Login succeeded. The user file might need updating. | |
444 | Success { needs_saving: bool }, | |
445 | /// Login failed. The user file might need updating. | |
446 | Failure { | |
447 | needs_saving: bool, | |
448 | totp_limit_reached: bool, | |
449 | tfa_limit_reached: bool, | |
450 | }, | |
451 | /// The current method is blocked. | |
452 | Locked, | |
453 | } | |
454 | ||
455 | impl TfaResult { | |
456 | const fn failure(needs_saving: bool) -> Self { | |
457 | Self::Failure { | |
458 | needs_saving, | |
459 | totp_limit_reached: false, | |
460 | tfa_limit_reached: false, | |
461 | } | |
313d0a6b WB |
462 | } |
463 | } | |
464 | ||
465 | #[must_use = "must save the config in order to ensure one-time use of recovery keys"] | |
466 | #[derive(Clone, Copy)] | |
467 | pub enum NeedsSaving { | |
468 | No, | |
469 | Yes, | |
470 | } | |
471 | ||
472 | impl NeedsSaving { | |
473 | /// Convenience method so we don't need to import the type name. | |
474 | pub fn needs_saving(self) -> bool { | |
475 | matches!(self, NeedsSaving::Yes) | |
476 | } | |
477 | } | |
478 | ||
313d0a6b WB |
479 | /// Mapping of userid to TFA entry. |
480 | pub type TfaUsers = HashMap<String, TfaUserData>; | |
481 | ||
482 | /// TFA data for a user. | |
483 | #[derive(Clone, Default, Deserialize, Serialize)] | |
484 | #[serde(deny_unknown_fields)] | |
485 | #[serde(rename_all = "kebab-case")] | |
313d0a6b WB |
486 | pub struct TfaUserData { |
487 | /// Totp keys for a user. | |
488 | #[serde(skip_serializing_if = "Vec::is_empty", default)] | |
a3448feb | 489 | pub totp: Vec<TfaEntry<TotpEntry>>, |
313d0a6b WB |
490 | |
491 | /// Registered u2f tokens for a user. | |
492 | #[serde(skip_serializing_if = "Vec::is_empty", default)] | |
493 | pub u2f: Vec<TfaEntry<u2f::Registration>>, | |
494 | ||
495 | /// Registered webauthn tokens for a user. | |
496 | #[serde(skip_serializing_if = "Vec::is_empty", default)] | |
497 | pub webauthn: Vec<TfaEntry<WebauthnCredential>>, | |
498 | ||
499 | /// Recovery keys. (Unordered OTP values). | |
ea1d023a | 500 | #[serde(skip_serializing_if = "Option::is_none", default)] |
313d0a6b WB |
501 | pub recovery: Option<Recovery>, |
502 | ||
503 | /// Yubico keys for a user. NOTE: This is not directly supported currently, we just need this | |
504 | /// available for PVE, where the yubico API server configuration is part if the realm. | |
505 | #[serde(skip_serializing_if = "Vec::is_empty", default)] | |
506 | pub yubico: Vec<TfaEntry<String>>, | |
50b793db WB |
507 | |
508 | /// Once a user runs into a TOTP limit they get locked out of TOTP until they successfully use | |
509 | /// a recovery key. | |
510 | #[serde(skip_serializing_if = "bool_is_false", default)] | |
511 | pub totp_locked: bool, | |
512 | ||
513 | /// If a user hits too many 2nd factor failures, they get completely blocked for a while. | |
514 | #[serde(skip_serializing_if = "Option::is_none", default)] | |
a3448feb WB |
515 | #[serde(deserialize_with = "filter_expired_timestamp")] |
516 | pub tfa_locked_until: Option<i64>, | |
517 | } | |
518 | ||
519 | /// Serde helper to filter out an optional timestamp that should be removed. | |
520 | fn filter_expired_timestamp<'de, D>(deserializer: D) -> Result<Option<i64>, D::Error> | |
521 | where | |
522 | D: serde::Deserializer<'de>, | |
523 | { | |
524 | match Option::<i64>::deserialize(deserializer)? { | |
525 | Some(t) if t < proxmox_time::epoch_i64() => Ok(None), | |
526 | other => Ok(other), | |
527 | } | |
313d0a6b WB |
528 | } |
529 | ||
530 | impl TfaUserData { | |
313d0a6b WB |
531 | /// `true` if no second factors exist |
532 | pub fn is_empty(&self) -> bool { | |
533 | self.totp.is_empty() | |
534 | && self.u2f.is_empty() | |
535 | && self.webauthn.is_empty() | |
536 | && self.yubico.is_empty() | |
ea1d023a | 537 | && self.recovery.is_none() |
313d0a6b WB |
538 | } |
539 | ||
540 | /// Find an entry by id, except for the "recovery" entry which we're currently treating | |
541 | /// specially. | |
542 | pub fn find_entry_mut<'a>(&'a mut self, id: &str) -> Option<&'a mut TfaInfo> { | |
543 | for entry in &mut self.totp { | |
544 | if entry.info.id == id { | |
545 | return Some(&mut entry.info); | |
546 | } | |
547 | } | |
548 | ||
549 | for entry in &mut self.webauthn { | |
550 | if entry.info.id == id { | |
551 | return Some(&mut entry.info); | |
552 | } | |
553 | } | |
554 | ||
555 | for entry in &mut self.u2f { | |
556 | if entry.info.id == id { | |
557 | return Some(&mut entry.info); | |
558 | } | |
559 | } | |
560 | ||
561 | for entry in &mut self.yubico { | |
562 | if entry.info.id == id { | |
563 | return Some(&mut entry.info); | |
564 | } | |
565 | } | |
566 | ||
567 | None | |
568 | } | |
569 | ||
570 | /// Create a u2f registration challenge. | |
571 | /// | |
572 | /// The description is required at this point already mostly to better be able to identify such | |
573 | /// challenges in the tfa config file if necessary. The user otherwise has no access to this | |
574 | /// information at this point, as the challenge is identified by its actual challenge data | |
575 | /// instead. | |
5349ae20 | 576 | fn u2f_registration_challenge<A: ?Sized + OpenUserChallengeData>( |
313d0a6b | 577 | &mut self, |
5349ae20 | 578 | access: &A, |
313d0a6b WB |
579 | userid: &str, |
580 | u2f: &u2f::U2f, | |
581 | description: String, | |
582 | ) -> Result<String, Error> { | |
583 | let challenge = serde_json::to_string(&u2f.registration_challenge()?)?; | |
584 | ||
585 | let mut data = access.open(userid)?; | |
586 | data.get_mut() | |
587 | .u2f_registrations | |
588 | .push(U2fRegistrationChallenge::new( | |
589 | challenge.clone(), | |
590 | description, | |
591 | )); | |
592 | data.save()?; | |
593 | ||
594 | Ok(challenge) | |
595 | } | |
596 | ||
5349ae20 | 597 | fn u2f_registration_finish<A: ?Sized + OpenUserChallengeData>( |
313d0a6b | 598 | &mut self, |
5349ae20 | 599 | access: &A, |
313d0a6b WB |
600 | userid: &str, |
601 | u2f: &u2f::U2f, | |
602 | challenge: &str, | |
603 | response: &str, | |
604 | ) -> Result<String, Error> { | |
605 | let mut data = access.open(userid)?; | |
606 | let entry = data | |
607 | .get_mut() | |
608 | .u2f_registration_finish(u2f, challenge, response)?; | |
609 | data.save()?; | |
610 | ||
611 | let id = entry.info.id.clone(); | |
612 | self.u2f.push(entry); | |
613 | Ok(id) | |
614 | } | |
615 | ||
616 | /// Create a webauthn registration challenge. | |
617 | /// | |
618 | /// The description is required at this point already mostly to better be able to identify such | |
619 | /// challenges in the tfa config file if necessary. The user otherwise has no access to this | |
620 | /// information at this point, as the challenge is identified by its actual challenge data | |
621 | /// instead. | |
5349ae20 | 622 | fn webauthn_registration_challenge<A: ?Sized + OpenUserChallengeData>( |
313d0a6b | 623 | &mut self, |
5349ae20 | 624 | access: &A, |
637188d4 | 625 | webauthn: Webauthn<WebauthnConfigInstance>, |
313d0a6b WB |
626 | userid: &str, |
627 | description: String, | |
628 | ) -> Result<String, Error> { | |
629 | let cred_ids: Vec<_> = self | |
630 | .enabled_webauthn_entries() | |
631 | .map(|cred| cred.cred_id.clone()) | |
632 | .collect(); | |
633 | ||
634 | let (challenge, state) = webauthn.generate_challenge_register_options( | |
635 | userid.as_bytes().to_vec(), | |
636 | userid.to_owned(), | |
637 | userid.to_owned(), | |
638 | Some(cred_ids), | |
639 | Some(UserVerificationPolicy::Discouraged), | |
91932da1 | 640 | None, |
313d0a6b WB |
641 | )?; |
642 | ||
643 | let challenge_string = challenge.public_key.challenge.to_string(); | |
644 | let challenge = serde_json::to_string(&challenge)?; | |
645 | ||
646 | let mut data = access.open(userid)?; | |
647 | data.get_mut() | |
648 | .webauthn_registrations | |
649 | .push(WebauthnRegistrationChallenge::new( | |
650 | state, | |
651 | challenge_string, | |
652 | description, | |
653 | )); | |
654 | data.save()?; | |
655 | ||
656 | Ok(challenge) | |
657 | } | |
658 | ||
659 | /// Finish a webauthn registration. The challenge should correspond to an output of | |
660 | /// `webauthn_registration_challenge`. The response should come directly from the client. | |
5349ae20 | 661 | fn webauthn_registration_finish<A: ?Sized + OpenUserChallengeData>( |
313d0a6b | 662 | &mut self, |
5349ae20 | 663 | access: &A, |
637188d4 | 664 | webauthn: Webauthn<WebauthnConfigInstance>, |
313d0a6b WB |
665 | userid: &str, |
666 | challenge: &str, | |
667 | response: webauthn_rs::proto::RegisterPublicKeyCredential, | |
668 | ) -> Result<String, Error> { | |
669 | let mut data = access.open(userid)?; | |
670 | let entry = data.get_mut().webauthn_registration_finish( | |
671 | webauthn, | |
672 | challenge, | |
673 | response, | |
674 | &self.webauthn, | |
675 | )?; | |
676 | data.save()?; | |
677 | ||
678 | let id = entry.info.id.clone(); | |
679 | self.webauthn.push(entry); | |
680 | Ok(id) | |
681 | } | |
682 | ||
683 | fn add_totp(&mut self, description: String, totp: Totp) -> String { | |
a3448feb | 684 | let entry = TfaEntry::new(description, TotpEntry::new(totp)); |
313d0a6b WB |
685 | let id = entry.info.id.clone(); |
686 | self.totp.push(entry); | |
687 | id | |
688 | } | |
689 | ||
690 | fn add_yubico(&mut self, description: String, key: String) -> String { | |
691 | let entry = TfaEntry::new(description, key); | |
692 | let id = entry.info.id.clone(); | |
693 | self.yubico.push(entry); | |
694 | id | |
695 | } | |
696 | ||
697 | /// Add a new set of recovery keys. There can only be 1 set of keys at a time. | |
698 | fn add_recovery(&mut self) -> Result<Vec<String>, Error> { | |
699 | if self.recovery.is_some() { | |
700 | bail!("user already has recovery keys"); | |
701 | } | |
702 | ||
703 | let (recovery, original) = Recovery::generate()?; | |
704 | ||
705 | self.recovery = Some(recovery); | |
706 | ||
707 | Ok(original) | |
708 | } | |
709 | ||
710 | /// Helper to iterate over enabled totp entries. | |
a3448feb WB |
711 | /// Here we also need access to the ID. |
712 | fn enabled_totp_entries_mut(&mut self) -> impl Iterator<Item = &mut TfaEntry<TotpEntry>> { | |
713 | self.totp.iter_mut().filter(|e| e.info.enable) | |
313d0a6b WB |
714 | } |
715 | ||
716 | /// Helper to iterate over enabled u2f entries. | |
717 | fn enabled_u2f_entries(&self) -> impl Iterator<Item = &u2f::Registration> { | |
718 | self.u2f | |
719 | .iter() | |
720 | .filter_map(|e| if e.info.enable { Some(&e.entry) } else { None }) | |
721 | } | |
722 | ||
723 | /// Helper to iterate over enabled u2f entries. | |
724 | fn enabled_webauthn_entries(&self) -> impl Iterator<Item = &WebauthnCredential> { | |
725 | self.webauthn | |
726 | .iter() | |
727 | .filter_map(|e| if e.info.enable { Some(&e.entry) } else { None }) | |
728 | } | |
729 | ||
730 | /// Helper to iterate over enabled yubico entries. | |
731 | pub fn enabled_yubico_entries(&self) -> impl Iterator<Item = &str> { | |
732 | self.yubico.iter().filter_map(|e| { | |
733 | if e.info.enable { | |
734 | Some(e.entry.as_str()) | |
735 | } else { | |
736 | None | |
737 | } | |
738 | }) | |
739 | } | |
740 | ||
741 | /// Verify a totp challenge. The `value` should be the totp digits as plain text. | |
a3448feb WB |
742 | /// |
743 | /// TOTP keys are stored in the user data, so we always need to save afterwards. | |
744 | fn verify_totp<A: ?Sized + OpenUserChallengeData>( | |
745 | &mut self, | |
746 | access: &A, | |
747 | userid: &str, | |
748 | value: &str, | |
749 | ) -> Result<bool, Error> { | |
313d0a6b WB |
750 | let now = std::time::SystemTime::now(); |
751 | ||
a3448feb WB |
752 | let needs_saving = access.enable_lockout(); |
753 | for entry in self.enabled_totp_entries_mut() { | |
754 | if let Some(current) = entry.entry.verify(value, now, -1..=1)? { | |
755 | if needs_saving { | |
756 | if current <= entry.entry.last_count { | |
757 | let mut data = access.open(userid)?; | |
758 | let data_access = data.get_mut(); | |
759 | data_access.totp_failures += 1; | |
760 | data.save()?; | |
761 | bail!("rejecting reused TOTP value"); | |
762 | } | |
763 | ||
764 | entry.entry.last_count = current; | |
765 | } | |
766 | ||
767 | let mut data = access.open(userid)?; | |
768 | let data_access = data.get_mut(); | |
769 | data_access.totp_failures = 0; | |
770 | data.save()?; | |
771 | return Ok(needs_saving); | |
313d0a6b WB |
772 | } |
773 | } | |
774 | ||
a3448feb WB |
775 | let mut data = access.open(userid)?; |
776 | let data_access = data.get_mut(); | |
777 | data_access.totp_failures += 1; | |
778 | data.save()?; | |
779 | ||
313d0a6b WB |
780 | bail!("totp verification failed"); |
781 | } | |
782 | ||
783 | /// Generate a generic TFA challenge. See the [`TfaChallenge`] description for details. | |
5349ae20 | 784 | fn challenge<A: ?Sized + OpenUserChallengeData>( |
313d0a6b | 785 | &mut self, |
5349ae20 | 786 | access: &A, |
313d0a6b | 787 | userid: &str, |
b6840e95 | 788 | webauthn: Option<Webauthn<WebauthnConfigInstance>>, |
313d0a6b WB |
789 | u2f: Option<&u2f::U2f>, |
790 | ) -> Result<Option<TfaChallenge>, Error> { | |
791 | if self.is_empty() { | |
792 | return Ok(None); | |
793 | } | |
794 | ||
b6840e95 WB |
795 | // Since we don't bail out when failing to generate WA or U2F challenges, we keep track of |
796 | // whether we tried here, otherwise `challenge.check()` would consider these to be not | |
797 | // configured by the user and might allow logging in without them on error. | |
798 | let mut not_empty = false; | |
799 | ||
4b3d171b | 800 | let challenge = TfaChallenge { |
313d0a6b | 801 | totp: self.totp.iter().any(|e| e.info.enable), |
ea1d023a | 802 | recovery: self.recovery_state(), |
313d0a6b | 803 | webauthn: match webauthn { |
b6840e95 WB |
804 | Some(webauthn) => match self.webauthn_challenge(access, userid, webauthn) { |
805 | Ok(wa) => wa, | |
806 | Err(err) => { | |
807 | not_empty = true; | |
808 | log::error!("failed to generate webauthn challenge: {err}"); | |
809 | None | |
810 | } | |
811 | }, | |
313d0a6b WB |
812 | None => None, |
813 | }, | |
814 | u2f: match u2f { | |
b6840e95 WB |
815 | Some(u2f) => match self.u2f_challenge(access, userid, u2f) { |
816 | Ok(u2f) => u2f, | |
817 | Err(err) => { | |
818 | not_empty = true; | |
819 | log::error!("failed to generate u2f challenge: {err}"); | |
820 | None | |
821 | } | |
822 | }, | |
313d0a6b WB |
823 | None => None, |
824 | }, | |
825 | yubico: self.yubico.iter().any(|e| e.info.enable), | |
4b3d171b WB |
826 | }; |
827 | ||
828 | // This happens if 2nd factors exist but are all disabled. | |
b6840e95 | 829 | if challenge.is_empty() && !not_empty { |
4b3d171b WB |
830 | return Ok(None); |
831 | } | |
832 | ||
833 | Ok(Some(challenge)) | |
313d0a6b WB |
834 | } |
835 | ||
836 | /// Get the recovery state. | |
ea1d023a WB |
837 | pub fn recovery_state(&self) -> Option<RecoveryState> { |
838 | self.recovery.as_ref().map(RecoveryState::from) | |
313d0a6b WB |
839 | } |
840 | ||
841 | /// Generate an optional webauthn challenge. | |
5349ae20 | 842 | fn webauthn_challenge<A: ?Sized + OpenUserChallengeData>( |
313d0a6b | 843 | &mut self, |
5349ae20 | 844 | access: &A, |
313d0a6b | 845 | userid: &str, |
637188d4 | 846 | webauthn: Webauthn<WebauthnConfigInstance>, |
313d0a6b WB |
847 | ) -> Result<Option<webauthn_rs::proto::RequestChallengeResponse>, Error> { |
848 | if self.webauthn.is_empty() { | |
849 | return Ok(None); | |
850 | } | |
851 | ||
148950fd FG |
852 | let creds: Vec<_> = self |
853 | .enabled_webauthn_entries() | |
854 | .map(|cred| cred.clone().into()) | |
855 | .collect(); | |
313d0a6b WB |
856 | |
857 | if creds.is_empty() { | |
858 | return Ok(None); | |
859 | } | |
860 | ||
91932da1 FG |
861 | let (challenge, state) = webauthn.generate_challenge_authenticate(creds)?; |
862 | ||
313d0a6b WB |
863 | let challenge_string = challenge.public_key.challenge.to_string(); |
864 | let mut data = access.open(userid)?; | |
865 | data.get_mut() | |
866 | .webauthn_auths | |
867 | .push(WebauthnAuthChallenge::new(state, challenge_string)); | |
868 | data.save()?; | |
869 | ||
870 | Ok(Some(challenge)) | |
871 | } | |
872 | ||
873 | /// Generate an optional u2f challenge. | |
5349ae20 | 874 | fn u2f_challenge<A: ?Sized + OpenUserChallengeData>( |
313d0a6b | 875 | &self, |
5349ae20 | 876 | access: &A, |
313d0a6b WB |
877 | userid: &str, |
878 | u2f: &u2f::U2f, | |
879 | ) -> Result<Option<U2fChallenge>, Error> { | |
880 | if self.u2f.is_empty() { | |
881 | return Ok(None); | |
882 | } | |
883 | ||
884 | let keys: Vec<crate::u2f::RegisteredKey> = self | |
885 | .enabled_u2f_entries() | |
886 | .map(|registration| registration.key.clone()) | |
887 | .collect(); | |
888 | ||
889 | if keys.is_empty() { | |
890 | return Ok(None); | |
891 | } | |
892 | ||
893 | let challenge = U2fChallenge { | |
894 | challenge: u2f.auth_challenge()?, | |
895 | keys, | |
896 | }; | |
897 | ||
898 | let mut data = access.open(userid)?; | |
899 | data.get_mut() | |
900 | .u2f_auths | |
901 | .push(U2fChallengeEntry::new(&challenge)); | |
902 | data.save()?; | |
903 | ||
904 | Ok(Some(challenge)) | |
905 | } | |
906 | ||
907 | /// Verify a u2f response. | |
5349ae20 | 908 | fn verify_u2f<A: ?Sized + OpenUserChallengeData>( |
313d0a6b | 909 | &self, |
5349ae20 | 910 | access: &A, |
313d0a6b | 911 | userid: &str, |
a3448feb | 912 | u2f: &Option<U2fConfig>, |
313d0a6b WB |
913 | challenge: &crate::u2f::AuthChallenge, |
914 | response: Value, | |
915 | ) -> Result<(), Error> { | |
a3448feb WB |
916 | let u2f = check_u2f(u2f)?; |
917 | ||
313d0a6b WB |
918 | let expire_before = proxmox_time::epoch_i64() - CHALLENGE_TIMEOUT_SECS; |
919 | ||
920 | let response: crate::u2f::AuthResponse = serde_json::from_value(response) | |
921 | .map_err(|err| format_err!("invalid u2f response: {}", err))?; | |
922 | ||
923 | if let Some(entry) = self | |
924 | .enabled_u2f_entries() | |
925 | .find(|e| e.key.key_handle == response.key_handle()) | |
926 | { | |
927 | if u2f | |
928 | .auth_verify_obj(&entry.public_key, &challenge.challenge, response)? | |
929 | .is_some() | |
930 | { | |
931 | let mut data = match access.open_no_create(userid)? { | |
932 | Some(data) => data, | |
933 | None => bail!("no such challenge"), | |
934 | }; | |
935 | let index = data | |
936 | .get_mut() | |
937 | .u2f_auths | |
938 | .iter() | |
939 | .position(|r| r == challenge) | |
940 | .ok_or_else(|| format_err!("no such challenge"))?; | |
941 | let entry = data.get_mut().u2f_auths.remove(index); | |
942 | if entry.is_expired(expire_before) { | |
943 | bail!("no such challenge"); | |
944 | } | |
945 | data.save() | |
946 | .map_err(|err| format_err!("failed to save challenge file: {}", err))?; | |
947 | ||
948 | return Ok(()); | |
949 | } | |
950 | } | |
951 | ||
952 | bail!("u2f verification failed"); | |
953 | } | |
954 | ||
955 | /// Verify a webauthn response. | |
5349ae20 | 956 | fn verify_webauthn<A: ?Sized + OpenUserChallengeData>( |
313d0a6b | 957 | &mut self, |
5349ae20 | 958 | access: &A, |
313d0a6b | 959 | userid: &str, |
a3448feb WB |
960 | webauthn: &Option<WebauthnConfig>, |
961 | origin: Option<&Url>, | |
313d0a6b WB |
962 | mut response: Value, |
963 | ) -> Result<(), Error> { | |
a3448feb WB |
964 | let webauthn = check_webauthn(webauthn, origin)?; |
965 | ||
313d0a6b WB |
966 | let expire_before = proxmox_time::epoch_i64() - CHALLENGE_TIMEOUT_SECS; |
967 | ||
968 | let challenge = match response | |
969 | .as_object_mut() | |
970 | .ok_or_else(|| format_err!("invalid response, must be a json object"))? | |
971 | .remove("challenge") | |
972 | .ok_or_else(|| format_err!("missing challenge data in response"))? | |
973 | { | |
974 | Value::String(s) => s, | |
975 | _ => bail!("invalid challenge data in response"), | |
976 | }; | |
977 | ||
978 | let response: webauthn_rs::proto::PublicKeyCredential = serde_json::from_value(response) | |
979 | .map_err(|err| format_err!("invalid webauthn response: {}", err))?; | |
980 | ||
981 | let mut data = match access.open_no_create(userid)? { | |
982 | Some(data) => data, | |
983 | None => bail!("no such challenge"), | |
984 | }; | |
985 | ||
986 | let index = data | |
987 | .get_mut() | |
988 | .webauthn_auths | |
989 | .iter() | |
990 | .position(|r| r.challenge == challenge) | |
991 | .ok_or_else(|| format_err!("no such challenge"))?; | |
992 | ||
993 | let challenge = data.get_mut().webauthn_auths.remove(index); | |
994 | if challenge.is_expired(expire_before) { | |
995 | bail!("no such challenge"); | |
996 | } | |
997 | ||
998 | // we don't allow re-trying the challenge, so make the removal persistent now: | |
999 | data.save() | |
1000 | .map_err(|err| format_err!("failed to save challenge file: {}", err))?; | |
1001 | ||
91932da1 FG |
1002 | webauthn.authenticate_credential(&response, &challenge.state)?; |
1003 | ||
1004 | Ok(()) | |
313d0a6b WB |
1005 | } |
1006 | ||
1007 | /// Verify a recovery key. | |
1008 | /// | |
1009 | /// NOTE: If successful, the key will automatically be removed from the list of available | |
1010 | /// recovery keys, so the configuration needs to be saved afterwards! | |
a3448feb WB |
1011 | fn verify_recovery<A: ?Sized + OpenUserChallengeData>( |
1012 | &mut self, | |
1013 | access: &A, | |
1014 | userid: &str, | |
1015 | value: &str, | |
1016 | ) -> Result<(), Error> { | |
313d0a6b WB |
1017 | if let Some(r) = &mut self.recovery { |
1018 | if r.verify(value)? { | |
a3448feb WB |
1019 | // On success we reset the failure state. |
1020 | self.totp_locked = false; | |
1021 | self.tfa_locked_until = None; | |
1022 | ||
1023 | let mut data = access.open(userid)?; | |
1024 | let access = data.get_mut(); | |
1025 | if access.totp_failures != 0 { | |
1026 | access.totp_failures = 0; | |
1027 | data.save()?; | |
1028 | } | |
313d0a6b WB |
1029 | return Ok(()); |
1030 | } | |
1031 | } | |
1032 | bail!("recovery verification failed"); | |
1033 | } | |
a3448feb WB |
1034 | |
1035 | fn tfa_is_locked(&self) -> bool { | |
1036 | match self.tfa_locked_until { | |
1037 | Some(locked_until) => proxmox_time::epoch_i64() < locked_until, | |
1038 | None => false, | |
1039 | } | |
1040 | } | |
313d0a6b WB |
1041 | } |
1042 | ||
1043 | /// A TFA entry for a user. | |
1044 | /// | |
1045 | /// This simply connects a raw registration to a non optional descriptive text chosen by the user. | |
1046 | #[derive(Clone, Deserialize, Serialize)] | |
1047 | #[serde(deny_unknown_fields)] | |
1048 | pub struct TfaEntry<T> { | |
1049 | #[serde(flatten)] | |
1050 | pub info: TfaInfo, | |
1051 | ||
1052 | /// The actual entry. | |
1053 | pub entry: T, | |
1054 | } | |
1055 | ||
1056 | impl<T> TfaEntry<T> { | |
1057 | /// Create an entry with a description. The id will be autogenerated. | |
1058 | fn new(description: String, entry: T) -> Self { | |
1059 | Self { | |
1060 | info: TfaInfo { | |
1061 | id: Uuid::generate().to_string(), | |
1062 | enable: true, | |
1063 | description, | |
1064 | created: proxmox_time::epoch_i64(), | |
1065 | }, | |
1066 | entry, | |
1067 | } | |
1068 | } | |
1069 | ||
1070 | /// Create a raw entry from a `TfaInfo` and the corresponding entry data. | |
1071 | pub fn from_parts(info: TfaInfo, entry: T) -> Self { | |
1072 | Self { info, entry } | |
1073 | } | |
1074 | } | |
1075 | ||
a3448feb WB |
1076 | #[derive(Clone)] |
1077 | pub struct TotpEntry { | |
1078 | pub totp: Totp, | |
1079 | pub last_count: i64, | |
1080 | } | |
1081 | ||
1082 | impl TotpEntry { | |
1083 | pub fn new(totp: Totp) -> Self { | |
1084 | Self { | |
1085 | totp, | |
1086 | last_count: i64::MIN, | |
1087 | } | |
1088 | } | |
1089 | } | |
1090 | ||
1091 | impl std::ops::Deref for TotpEntry { | |
1092 | type Target = Totp; | |
1093 | ||
1094 | fn deref(&self) -> &Totp { | |
1095 | &self.totp | |
1096 | } | |
1097 | } | |
1098 | ||
1099 | impl Serialize for TotpEntry { | |
1100 | fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> | |
1101 | where | |
1102 | S: serde::Serializer, | |
1103 | { | |
1104 | use serde::ser::SerializeStruct; | |
1105 | ||
1106 | if self.last_count == i64::MIN { | |
1107 | return self.totp.serialize(serializer); | |
1108 | } | |
1109 | ||
1110 | let mut map = serializer.serialize_struct("TotpEntry", 2)?; | |
1111 | map.serialize_field("totp", &self.totp)?; | |
1112 | map.serialize_field("last-count", &self.last_count)?; | |
1113 | map.end() | |
1114 | } | |
1115 | } | |
1116 | ||
1117 | impl<'de> Deserialize<'de> for TotpEntry { | |
1118 | fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> | |
1119 | where | |
1120 | D: serde::Deserializer<'de>, | |
1121 | { | |
1122 | use serde::de::Error; | |
1123 | ||
1124 | struct V; | |
1125 | ||
1126 | impl<'de> serde::de::Visitor<'de> for V { | |
1127 | type Value = TotpEntry; | |
1128 | ||
1129 | fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { | |
1130 | write!(f, "a totp string or a TotpEntry struct") | |
1131 | } | |
1132 | ||
1133 | fn visit_str<E: Error>(self, s: &str) -> Result<TotpEntry, E> { | |
1134 | Ok(TotpEntry::new(s.parse().map_err(|err| E::custom(err))?)) | |
1135 | } | |
1136 | ||
1137 | fn visit_map<A>(self, mut map: A) -> Result<TotpEntry, A::Error> | |
1138 | where | |
1139 | A: serde::de::MapAccess<'de>, | |
1140 | { | |
1141 | use std::borrow::Cow; | |
1142 | ||
1143 | let mut totp = None; | |
1144 | let mut last_count = None; | |
1145 | ||
1146 | loop { | |
1147 | let key: Cow<'de, str> = match map.next_key()? { | |
1148 | Some(k) => k, | |
1149 | None => break, | |
1150 | }; | |
1151 | ||
1152 | match key.as_ref() { | |
1153 | "totp" if totp.is_some() => return Err(A::Error::duplicate_field("totp")), | |
1154 | "totp" => totp = Some(map.next_value()?), | |
1155 | "last-count" if last_count.is_some() => { | |
1156 | return Err(A::Error::duplicate_field("last-count")) | |
1157 | } | |
1158 | "last-count" => last_count = Some(map.next_value()?), | |
1159 | other => { | |
1160 | return Err(A::Error::unknown_field(other, &["totp", "last-count"])) | |
1161 | } | |
1162 | } | |
1163 | } | |
1164 | ||
1165 | Ok(TotpEntry { | |
1166 | totp: totp.ok_or_else(|| A::Error::missing_field("totp"))?, | |
1167 | last_count: last_count.unwrap_or(i64::MIN), | |
1168 | }) | |
1169 | } | |
1170 | } | |
1171 | ||
1172 | deserializer.deserialize_any(V) | |
1173 | } | |
1174 | } | |
1175 | ||
313d0a6b WB |
1176 | /// When sending a TFA challenge to the user, we include information about what kind of challenge |
1177 | /// the user may perform. If webauthn credentials are available, a webauthn challenge will be | |
1178 | /// included. | |
1179 | #[derive(Deserialize, Serialize)] | |
1180 | #[serde(rename_all = "kebab-case")] | |
1181 | pub struct TfaChallenge { | |
1182 | /// True if the user has TOTP devices. | |
1183 | #[serde(skip_serializing_if = "bool_is_false", default)] | |
e5a43afe | 1184 | pub totp: bool, |
313d0a6b WB |
1185 | |
1186 | /// Whether there are recovery keys available. | |
ea1d023a WB |
1187 | #[serde(skip_serializing_if = "Option::is_none", default)] |
1188 | pub recovery: Option<RecoveryState>, | |
313d0a6b WB |
1189 | |
1190 | /// If the user has any u2f tokens registered, this will contain the U2F challenge data. | |
1191 | #[serde(skip_serializing_if = "Option::is_none")] | |
e5a43afe | 1192 | pub u2f: Option<U2fChallenge>, |
313d0a6b WB |
1193 | |
1194 | /// If the user has any webauthn credentials registered, this will contain the corresponding | |
1195 | /// challenge data. | |
86f3c907 | 1196 | #[serde(skip_serializing_if = "Option::is_none")] |
e5a43afe | 1197 | pub webauthn: Option<webauthn_rs::proto::RequestChallengeResponse>, |
313d0a6b WB |
1198 | |
1199 | /// True if the user has yubico keys configured. | |
1200 | #[serde(skip_serializing_if = "bool_is_false", default)] | |
e5a43afe | 1201 | pub yubico: bool, |
313d0a6b WB |
1202 | } |
1203 | ||
4b3d171b WB |
1204 | impl TfaChallenge { |
1205 | pub fn is_empty(&self) -> bool { | |
1206 | !self.totp | |
1207 | && self.recovery.is_none() | |
1208 | && self.u2f.is_none() | |
1209 | && self.webauthn.is_none() | |
1210 | && !self.yubico | |
1211 | } | |
1212 | } | |
1213 | ||
313d0a6b WB |
1214 | fn bool_is_false(v: &bool) -> bool { |
1215 | !v | |
1216 | } | |
1217 | ||
1218 | /// A user's response to a TFA challenge. | |
1219 | pub enum TfaResponse { | |
1220 | Totp(String), | |
1221 | U2f(Value), | |
1222 | Webauthn(Value), | |
1223 | Recovery(String), | |
1224 | } | |
1225 | ||
1226 | /// This is part of the REST API: | |
1227 | impl std::str::FromStr for TfaResponse { | |
1228 | type Err = Error; | |
1229 | ||
1230 | fn from_str(s: &str) -> Result<Self, Error> { | |
1231 | Ok(if let Some(totp) = s.strip_prefix("totp:") { | |
1232 | TfaResponse::Totp(totp.to_string()) | |
1233 | } else if let Some(u2f) = s.strip_prefix("u2f:") { | |
1234 | TfaResponse::U2f(serde_json::from_str(u2f)?) | |
1235 | } else if let Some(webauthn) = s.strip_prefix("webauthn:") { | |
1236 | TfaResponse::Webauthn(serde_json::from_str(webauthn)?) | |
1237 | } else if let Some(recovery) = s.strip_prefix("recovery:") { | |
1238 | TfaResponse::Recovery(recovery.to_string()) | |
1239 | } else { | |
1240 | bail!("invalid tfa response"); | |
1241 | }) | |
1242 | } | |
1243 | } | |
1244 | ||
1245 | /// Active TFA challenges per user, stored in a restricted temporary file on the machine handling | |
1246 | /// the current user's authentication. | |
1247 | #[derive(Default, Deserialize, Serialize)] | |
1248 | pub struct TfaUserChallenges { | |
1249 | /// Active u2f registration challenges for a user. | |
1250 | /// | |
1251 | /// Expired values are automatically filtered out while parsing the tfa configuration file. | |
1252 | #[serde(skip_serializing_if = "Vec::is_empty", default)] | |
1253 | #[serde(deserialize_with = "filter_expired_challenge")] | |
1254 | u2f_registrations: Vec<U2fRegistrationChallenge>, | |
1255 | ||
1256 | /// Active u2f authentication challenges for a user. | |
1257 | /// | |
1258 | /// Expired values are automatically filtered out while parsing the tfa configuration file. | |
1259 | #[serde(skip_serializing_if = "Vec::is_empty", default)] | |
1260 | #[serde(deserialize_with = "filter_expired_challenge")] | |
1261 | u2f_auths: Vec<U2fChallengeEntry>, | |
1262 | ||
1263 | /// Active webauthn registration challenges for a user. | |
1264 | /// | |
1265 | /// Expired values are automatically filtered out while parsing the tfa configuration file. | |
1266 | #[serde(skip_serializing_if = "Vec::is_empty", default)] | |
1267 | #[serde(deserialize_with = "filter_expired_challenge")] | |
1268 | webauthn_registrations: Vec<WebauthnRegistrationChallenge>, | |
1269 | ||
1270 | /// Active webauthn authentication challenges for a user. | |
1271 | /// | |
1272 | /// Expired values are automatically filtered out while parsing the tfa configuration file. | |
1273 | #[serde(skip_serializing_if = "Vec::is_empty", default)] | |
1274 | #[serde(deserialize_with = "filter_expired_challenge")] | |
1275 | webauthn_auths: Vec<WebauthnAuthChallenge>, | |
50b793db WB |
1276 | |
1277 | /// Number of consecutive TOTP failures. Too many of those will lock out a user. | |
1278 | #[serde(skip_serializing_if = "u32_is_zero", default)] | |
1279 | totp_failures: u32, | |
1280 | ||
1281 | /// Number of consecutive 2nd factor failures. When the limit is reached, the user is locked | |
1282 | /// out for 12 hours. | |
1283 | #[serde(skip_serializing_if = "u32_is_zero", default)] | |
1284 | tfa_failures: u32, | |
1285 | } | |
1286 | ||
1287 | fn u32_is_zero(n: &u32) -> bool { | |
1288 | *n == 0 | |
313d0a6b WB |
1289 | } |
1290 | ||
1291 | /// Serde helper using our `FilteredVecVisitor` to filter out expired entries directly at load | |
1292 | /// time. | |
1293 | fn filter_expired_challenge<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error> | |
1294 | where | |
1295 | D: serde::Deserializer<'de>, | |
1296 | T: Deserialize<'de> + IsExpired, | |
1297 | { | |
1298 | let expire_before = proxmox_time::epoch_i64() - CHALLENGE_TIMEOUT_SECS; | |
1299 | deserializer.deserialize_seq(serde_tools::fold( | |
1300 | "a challenge entry", | |
1301 | |cap| cap.map(Vec::with_capacity).unwrap_or_else(Vec::new), | |
1302 | move |out, reg: T| { | |
1303 | if !reg.is_expired(expire_before) { | |
1304 | out.push(reg); | |
1305 | } | |
1306 | }, | |
1307 | )) | |
1308 | } | |
1309 | ||
1310 | impl TfaUserChallenges { | |
1311 | /// Finish a u2f registration. The challenge should correspond to an output of | |
1312 | /// `u2f_registration_challenge` (which is a stringified `RegistrationChallenge`). The response | |
1313 | /// should come directly from the client. | |
1314 | fn u2f_registration_finish( | |
1315 | &mut self, | |
1316 | u2f: &u2f::U2f, | |
1317 | challenge: &str, | |
1318 | response: &str, | |
1319 | ) -> Result<TfaEntry<u2f::Registration>, Error> { | |
1320 | let expire_before = proxmox_time::epoch_i64() - CHALLENGE_TIMEOUT_SECS; | |
1321 | ||
1322 | let index = self | |
1323 | .u2f_registrations | |
1324 | .iter() | |
1325 | .position(|r| r.challenge == challenge) | |
1326 | .ok_or_else(|| format_err!("no such challenge"))?; | |
1327 | ||
1328 | let reg = &self.u2f_registrations[index]; | |
1329 | if reg.is_expired(expire_before) { | |
1330 | bail!("no such challenge"); | |
1331 | } | |
1332 | ||
1333 | // the verify call only takes the actual challenge string, so we have to extract it | |
1334 | // (u2f::RegistrationChallenge did not always implement Deserialize...) | |
1335 | let chobj: Value = serde_json::from_str(challenge) | |
1336 | .map_err(|err| format_err!("error parsing original registration challenge: {}", err))?; | |
1337 | let challenge = chobj["challenge"] | |
1338 | .as_str() | |
1339 | .ok_or_else(|| format_err!("invalid registration challenge"))?; | |
1340 | ||
1341 | let (mut reg, description) = match u2f.registration_verify(challenge, response)? { | |
1342 | None => bail!("verification failed"), | |
1343 | Some(reg) => { | |
1344 | let entry = self.u2f_registrations.remove(index); | |
1345 | (reg, entry.description) | |
1346 | } | |
1347 | }; | |
1348 | ||
1349 | // we do not care about the attestation certificates, so don't store them | |
1350 | reg.certificate.clear(); | |
1351 | ||
1352 | Ok(TfaEntry::new(description, reg)) | |
1353 | } | |
1354 | ||
1355 | /// Finish a webauthn registration. The challenge should correspond to an output of | |
1356 | /// `webauthn_registration_challenge`. The response should come directly from the client. | |
1357 | fn webauthn_registration_finish( | |
1358 | &mut self, | |
637188d4 | 1359 | webauthn: Webauthn<WebauthnConfigInstance>, |
313d0a6b WB |
1360 | challenge: &str, |
1361 | response: webauthn_rs::proto::RegisterPublicKeyCredential, | |
1362 | existing_registrations: &[TfaEntry<WebauthnCredential>], | |
1363 | ) -> Result<TfaEntry<WebauthnCredential>, Error> { | |
1364 | let expire_before = proxmox_time::epoch_i64() - CHALLENGE_TIMEOUT_SECS; | |
1365 | ||
1366 | let index = self | |
1367 | .webauthn_registrations | |
1368 | .iter() | |
1369 | .position(|r| r.challenge == challenge) | |
1370 | .ok_or_else(|| format_err!("no such challenge"))?; | |
1371 | ||
1372 | let reg = self.webauthn_registrations.remove(index); | |
1373 | if reg.is_expired(expire_before) { | |
1374 | bail!("no such challenge"); | |
1375 | } | |
1376 | ||
91932da1 FG |
1377 | let (credential, _authenticator) = |
1378 | webauthn.register_credential(&response, ®.state, |id| -> Result<bool, ()> { | |
313d0a6b WB |
1379 | Ok(existing_registrations |
1380 | .iter() | |
1381 | .any(|cred| cred.entry.cred_id == *id)) | |
1382 | })?; | |
1383 | ||
148950fd | 1384 | Ok(TfaEntry::new(reg.description, credential.into())) |
313d0a6b WB |
1385 | } |
1386 | } |