]> git.proxmox.com Git - proxmox-backup.git/blob - src/api2/access/tfa.rs
ba4bb960564433058804d766786df6905c81368f
[proxmox-backup.git] / src / api2 / access / tfa.rs
1 use anyhow::{bail, format_err, Error};
2 use serde::{Deserialize, Serialize};
3
4 use proxmox::api::{api, Permission, Router, RpcEnvironment};
5 use proxmox::tools::tfa::totp::Totp;
6 use proxmox::{http_bail, http_err};
7
8 use crate::api2::types::{Authid, Userid, PASSWORD_SCHEMA};
9 use crate::config::acl::{PRIV_PERMISSIONS_MODIFY, PRIV_SYS_AUDIT};
10 use crate::config::cached_user_info::CachedUserInfo;
11 use crate::config::tfa::{TfaInfo, TfaUserData};
12
13 /// Perform first-factor (password) authentication only. Ignore password for the root user.
14 /// Otherwise check the current user's password.
15 ///
16 /// This means that user admins need to type in their own password while editing a user, and
17 /// regular users, which can only change their own TFA settings (checked at the API level), can
18 /// change their own settings using their own password.
19 fn tfa_update_auth(
20 rpcenv: &mut dyn RpcEnvironment,
21 userid: &Userid,
22 password: Option<String>,
23 ) -> Result<(), Error> {
24 let authid: Authid = rpcenv.get_auth_id().unwrap().parse()?;
25
26 if authid.user() != Userid::root_userid() {
27 let password = password.ok_or_else(|| format_err!("missing password"))?;
28 let _: () = crate::auth::authenticate_user(authid.user(), &password)?;
29 }
30
31 // After authentication, verify that the to-be-modified user actually exists:
32 if authid.user() != userid {
33 let (config, _digest) = crate::config::user::config()?;
34
35 if config.sections.get(userid.as_str()).is_none() {
36 bail!("user '{}' does not exists.", userid);
37 }
38 }
39
40 Ok(())
41 }
42
43 #[api]
44 /// A TFA entry type.
45 #[derive(Deserialize, Serialize)]
46 #[serde(rename_all = "lowercase")]
47 enum TfaType {
48 /// A TOTP entry type.
49 Totp,
50 /// A U2F token entry.
51 U2f,
52 /// A Webauthn token entry.
53 Webauthn,
54 /// Recovery tokens.
55 Recovery,
56 }
57
58 #[api(
59 properties: {
60 type: { type: TfaType },
61 info: { type: TfaInfo },
62 },
63 )]
64 /// A TFA entry for a user.
65 #[derive(Deserialize, Serialize)]
66 #[serde(deny_unknown_fields)]
67 struct TypedTfaInfo {
68 #[serde(rename = "type")]
69 pub ty: TfaType,
70
71 #[serde(flatten)]
72 pub info: TfaInfo,
73 }
74
75 fn to_data(data: TfaUserData) -> Vec<TypedTfaInfo> {
76 let mut out = Vec::with_capacity(
77 data.totp.len()
78 + data.u2f.len()
79 + data.webauthn.len()
80 + if data.has_recovery() { 1 } else { 0 },
81 );
82 if data.has_recovery() {
83 out.push(TypedTfaInfo {
84 ty: TfaType::Recovery,
85 info: TfaInfo::recovery(),
86 })
87 }
88 for entry in data.totp {
89 out.push(TypedTfaInfo {
90 ty: TfaType::Totp,
91 info: entry.info,
92 });
93 }
94 for entry in data.webauthn {
95 out.push(TypedTfaInfo {
96 ty: TfaType::Webauthn,
97 info: entry.info,
98 });
99 }
100 for entry in data.u2f {
101 out.push(TypedTfaInfo {
102 ty: TfaType::U2f,
103 info: entry.info,
104 });
105 }
106 out
107 }
108
109 /// Iterate through tuples of `(type, index, id)`.
110 fn tfa_id_iter(data: &TfaUserData) -> impl Iterator<Item = (TfaType, usize, &str)> {
111 data.totp
112 .iter()
113 .enumerate()
114 .map(|(i, entry)| (TfaType::Totp, i, entry.info.id.as_str()))
115 .chain(
116 data.webauthn
117 .iter()
118 .enumerate()
119 .map(|(i, entry)| (TfaType::Webauthn, i, entry.info.id.as_str())),
120 )
121 .chain(
122 data.u2f
123 .iter()
124 .enumerate()
125 .map(|(i, entry)| (TfaType::U2f, i, entry.info.id.as_str())),
126 )
127 .chain(
128 data.recovery
129 .iter()
130 .map(|_| (TfaType::Recovery, 0, "recovery")),
131 )
132 }
133
134 #[api(
135 protected: true,
136 input: {
137 properties: { userid: { type: Userid } },
138 },
139 access: {
140 permission: &Permission::Or(&[
141 &Permission::Privilege(&["access", "users"], PRIV_PERMISSIONS_MODIFY, false),
142 &Permission::UserParam("userid"),
143 ]),
144 },
145 )]
146 /// Add a TOTP secret to the user.
147 fn list_user_tfa(userid: Userid) -> Result<Vec<TypedTfaInfo>, Error> {
148 let _lock = crate::config::tfa::read_lock()?;
149
150 Ok(match crate::config::tfa::read()?.users.remove(&userid) {
151 Some(data) => to_data(data),
152 None => Vec::new(),
153 })
154 }
155
156 #[api(
157 protected: true,
158 input: {
159 properties: {
160 userid: { type: Userid },
161 id: { description: "the tfa entry id" }
162 },
163 },
164 access: {
165 permission: &Permission::Or(&[
166 &Permission::Privilege(&["access", "users"], PRIV_PERMISSIONS_MODIFY, false),
167 &Permission::UserParam("userid"),
168 ]),
169 },
170 )]
171 /// Get a single TFA entry.
172 fn get_tfa_entry(userid: Userid, id: String) -> Result<TypedTfaInfo, Error> {
173 let _lock = crate::config::tfa::read_lock()?;
174
175 if let Some(user_data) = crate::config::tfa::read()?.users.remove(&userid) {
176 match {
177 // scope to prevent the temprary iter from borrowing across the whole match
178 let entry = tfa_id_iter(&user_data).find(|(_ty, _index, entry_id)| id == *entry_id);
179 entry.map(|(ty, index, _)| (ty, index))
180 } {
181 Some((TfaType::Recovery, _)) => {
182 return Ok(TypedTfaInfo {
183 ty: TfaType::Recovery,
184 info: TfaInfo::recovery(),
185 })
186 }
187 Some((TfaType::Totp, index)) => {
188 return Ok(TypedTfaInfo {
189 ty: TfaType::Totp,
190 // `into_iter().nth()` to *move* out of it
191 info: user_data.totp.into_iter().nth(index).unwrap().info,
192 });
193 }
194 Some((TfaType::Webauthn, index)) => {
195 return Ok(TypedTfaInfo {
196 ty: TfaType::Webauthn,
197 info: user_data.webauthn.into_iter().nth(index).unwrap().info,
198 });
199 }
200 Some((TfaType::U2f, index)) => {
201 return Ok(TypedTfaInfo {
202 ty: TfaType::U2f,
203 info: user_data.u2f.into_iter().nth(index).unwrap().info,
204 });
205 }
206 None => (),
207 }
208 }
209
210 http_bail!(NOT_FOUND, "no such tfa entry: {}/{}", userid, id);
211 }
212
213 #[api(
214 protected: true,
215 input: {
216 properties: {
217 userid: { type: Userid },
218 id: {
219 description: "the tfa entry id",
220 },
221 password: {
222 schema: PASSWORD_SCHEMA,
223 optional: true,
224 },
225 },
226 },
227 access: {
228 permission: &Permission::Or(&[
229 &Permission::Privilege(&["access", "users"], PRIV_PERMISSIONS_MODIFY, false),
230 &Permission::UserParam("userid"),
231 ]),
232 },
233 )]
234 /// Get a single TFA entry.
235 fn delete_tfa(
236 userid: Userid,
237 id: String,
238 password: Option<String>,
239 rpcenv: &mut dyn RpcEnvironment,
240 ) -> Result<(), Error> {
241 tfa_update_auth(rpcenv, &userid, password)?;
242
243 let _lock = crate::config::tfa::write_lock()?;
244
245 let mut data = crate::config::tfa::read()?;
246
247 let user_data = data
248 .users
249 .get_mut(&userid)
250 .ok_or_else(|| http_err!(NOT_FOUND, "no such entry: {}/{}", userid, id))?;
251
252 match {
253 // scope to prevent the temprary iter from borrowing across the whole match
254 let entry = tfa_id_iter(&user_data).find(|(_, _, entry_id)| id == *entry_id);
255 entry.map(|(ty, index, _)| (ty, index))
256 } {
257 Some((TfaType::Recovery, _)) => user_data.recovery = None,
258 Some((TfaType::Totp, index)) => drop(user_data.totp.remove(index)),
259 Some((TfaType::Webauthn, index)) => drop(user_data.webauthn.remove(index)),
260 Some((TfaType::U2f, index)) => drop(user_data.u2f.remove(index)),
261 None => http_bail!(NOT_FOUND, "no such tfa entry: {}/{}", userid, id),
262 }
263
264 if user_data.is_empty() {
265 data.users.remove(&userid);
266 }
267
268 crate::config::tfa::write(&data)?;
269
270 Ok(())
271 }
272
273 #[api(
274 properties: {
275 "userid": { type: Userid },
276 "entries": {
277 type: Array,
278 items: { type: TypedTfaInfo },
279 },
280 },
281 )]
282 #[derive(Deserialize, Serialize)]
283 #[serde(deny_unknown_fields)]
284 /// Over the API we only provide the descriptions for TFA data.
285 struct TfaUser {
286 /// The user this entry belongs to.
287 userid: Userid,
288
289 /// TFA entries.
290 entries: Vec<TypedTfaInfo>,
291 }
292
293 #[api(
294 protected: true,
295 input: {
296 properties: {},
297 },
298 access: {
299 permission: &Permission::Anybody,
300 description: "Returns all or just the logged-in user, depending on privileges.",
301 },
302 returns: {
303 description: "The list tuples of user and TFA entries.",
304 type: Array,
305 items: { type: TfaUser }
306 },
307 )]
308 /// List user TFA configuration.
309 fn list_tfa(rpcenv: &mut dyn RpcEnvironment) -> Result<Vec<TfaUser>, Error> {
310 let authid: Authid = rpcenv.get_auth_id().unwrap().parse()?;
311 let user_info = CachedUserInfo::new()?;
312
313 let top_level_privs = user_info.lookup_privs(&authid, &["access", "users"]);
314 let top_level_allowed = (top_level_privs & PRIV_SYS_AUDIT) != 0;
315
316 let _lock = crate::config::tfa::read_lock()?;
317 let tfa_data = crate::config::tfa::read()?.users;
318
319 let mut out = Vec::<TfaUser>::new();
320 if top_level_allowed {
321 for (user, data) in tfa_data {
322 out.push(TfaUser {
323 userid: user,
324 entries: to_data(data),
325 });
326 }
327 } else {
328 if let Some(data) = { tfa_data }.remove(authid.user()) {
329 out.push(TfaUser {
330 userid: authid.into(),
331 entries: to_data(data),
332 });
333 }
334 }
335
336 Ok(out)
337 }
338
339 #[api(
340 properties: {
341 recovery: {
342 description: "A list of recovery codes as integers.",
343 type: Array,
344 items: {
345 type: Integer,
346 description: "A one-time usable recovery code entry.",
347 },
348 },
349 },
350 )]
351 /// The result returned when adding TFA entries to a user.
352 #[derive(Default, Serialize)]
353 struct TfaUpdateInfo {
354 /// The id if a newly added TFA entry.
355 id: Option<String>,
356
357 /// When adding u2f entries, this contains a challenge the user must respond to in order to
358 /// finish the registration.
359 #[serde(skip_serializing_if = "Option::is_none")]
360 challenge: Option<String>,
361
362 /// When adding recovery codes, this contains the list of codes to be displayed to the user
363 /// this one time.
364 #[serde(skip_serializing_if = "Vec::is_empty", default)]
365 recovery: Vec<String>,
366 }
367
368 impl TfaUpdateInfo {
369 fn id(id: String) -> Self {
370 Self {
371 id: Some(id),
372 ..Default::default()
373 }
374 }
375 }
376
377 #[api(
378 protected: true,
379 input: {
380 properties: {
381 userid: { type: Userid },
382 description: {
383 description: "A description to distinguish multiple entries from one another",
384 type: String,
385 max_length: 255,
386 optional: true,
387 },
388 "type": { type: TfaType },
389 totp: {
390 description: "A totp URI.",
391 optional: true,
392 },
393 value: {
394 description:
395 "The current value for the provided totp URI, or a Webauthn/U2F challenge response",
396 optional: true,
397 },
398 challenge: {
399 description: "When responding to a u2f challenge: the original challenge string",
400 optional: true,
401 },
402 password: {
403 schema: PASSWORD_SCHEMA,
404 optional: true,
405 },
406 },
407 },
408 returns: { type: TfaUpdateInfo },
409 access: {
410 permission: &Permission::Or(&[
411 &Permission::Privilege(&["access", "users"], PRIV_PERMISSIONS_MODIFY, false),
412 &Permission::UserParam("userid"),
413 ]),
414 },
415 )]
416 /// Add a TFA entry to the user.
417 fn add_tfa_entry(
418 userid: Userid,
419 description: Option<String>,
420 totp: Option<String>,
421 value: Option<String>,
422 challenge: Option<String>,
423 password: Option<String>,
424 r#type: TfaType,
425 rpcenv: &mut dyn RpcEnvironment,
426 ) -> Result<TfaUpdateInfo, Error> {
427 tfa_update_auth(rpcenv, &userid, password)?;
428
429 let need_description =
430 move || description.ok_or_else(|| format_err!("'description' is required for new entries"));
431
432 match r#type {
433 TfaType::Totp => match (totp, value) {
434 (Some(totp), Some(value)) => {
435 if challenge.is_some() {
436 bail!("'challenge' parameter is invalid for 'totp' entries");
437 }
438 let description = need_description()?;
439
440 let totp: Totp = totp.parse()?;
441 if totp
442 .verify(&value, std::time::SystemTime::now(), -1..=1)?
443 .is_none()
444 {
445 bail!("failed to verify TOTP challenge");
446 }
447 crate::config::tfa::add_totp(&userid, description, totp).map(TfaUpdateInfo::id)
448 }
449 _ => bail!("'totp' type requires both 'totp' and 'value' parameters"),
450 },
451 TfaType::Webauthn => {
452 if totp.is_some() {
453 bail!("'totp' parameter is invalid for 'totp' entries");
454 }
455
456 match challenge {
457 None => crate::config::tfa::add_webauthn_registration(&userid, need_description()?)
458 .map(|c| TfaUpdateInfo {
459 challenge: Some(c),
460 ..Default::default()
461 }),
462 Some(challenge) => {
463 let value = value.ok_or_else(|| {
464 format_err!(
465 "missing 'value' parameter (webauthn challenge response missing)"
466 )
467 })?;
468 crate::config::tfa::finish_webauthn_registration(&userid, &challenge, &value)
469 .map(TfaUpdateInfo::id)
470 }
471 }
472 }
473 TfaType::U2f => {
474 if totp.is_some() {
475 bail!("'totp' parameter is invalid for 'totp' entries");
476 }
477
478 match challenge {
479 None => crate::config::tfa::add_u2f_registration(&userid, need_description()?).map(
480 |c| TfaUpdateInfo {
481 challenge: Some(c),
482 ..Default::default()
483 },
484 ),
485 Some(challenge) => {
486 let value = value.ok_or_else(|| {
487 format_err!("missing 'value' parameter (u2f challenge response missing)")
488 })?;
489 crate::config::tfa::finish_u2f_registration(&userid, &challenge, &value)
490 .map(TfaUpdateInfo::id)
491 }
492 }
493 }
494 TfaType::Recovery => {
495 if totp.or(value).or(challenge).is_some() {
496 bail!("generating recovery tokens does not allow additional parameters");
497 }
498
499 let recovery = crate::config::tfa::add_recovery(&userid)?;
500
501 Ok(TfaUpdateInfo {
502 id: Some("recovery".to_string()),
503 recovery,
504 ..Default::default()
505 })
506 }
507 }
508 }
509
510 #[api(
511 protected: true,
512 input: {
513 properties: {
514 userid: { type: Userid },
515 id: {
516 description: "the tfa entry id",
517 },
518 description: {
519 description: "A description to distinguish multiple entries from one another",
520 type: String,
521 max_length: 255,
522 optional: true,
523 },
524 enable: {
525 description: "Whether this entry should currently be enabled or disabled",
526 optional: true,
527 },
528 password: {
529 schema: PASSWORD_SCHEMA,
530 optional: true,
531 },
532 },
533 },
534 access: {
535 permission: &Permission::Or(&[
536 &Permission::Privilege(&["access", "users"], PRIV_PERMISSIONS_MODIFY, false),
537 &Permission::UserParam("userid"),
538 ]),
539 },
540 )]
541 /// Update user's TFA entry description.
542 fn update_tfa_entry(
543 userid: Userid,
544 id: String,
545 description: Option<String>,
546 enable: Option<bool>,
547 password: Option<String>,
548 rpcenv: &mut dyn RpcEnvironment,
549 ) -> Result<(), Error> {
550 tfa_update_auth(rpcenv, &userid, password)?;
551
552 let _lock = crate::config::tfa::write_lock()?;
553
554 let mut data = crate::config::tfa::read()?;
555
556 let mut entry = data
557 .users
558 .get_mut(&userid)
559 .and_then(|user| user.find_entry_mut(&id))
560 .ok_or_else(|| http_err!(NOT_FOUND, "no such entry: {}/{}", userid, id))?;
561
562 if let Some(description) = description {
563 entry.description = description;
564 }
565
566 if let Some(enable) = enable {
567 entry.enable = enable;
568 }
569
570 crate::config::tfa::write(&data)?;
571 Ok(())
572 }
573
574 pub const ROUTER: Router = Router::new()
575 .get(&API_METHOD_LIST_TFA)
576 .match_all("userid", &USER_ROUTER);
577
578 const USER_ROUTER: Router = Router::new()
579 .get(&API_METHOD_LIST_USER_TFA)
580 .post(&API_METHOD_ADD_TFA_ENTRY)
581 .match_all("id", &ITEM_ROUTER);
582
583 const ITEM_ROUTER: Router = Router::new()
584 .get(&API_METHOD_GET_TFA_ENTRY)
585 .put(&API_METHOD_UPDATE_TFA_ENTRY)
586 .delete(&API_METHOD_DELETE_TFA);