From: Wolfgang Bumiller <w.bumiller@proxmox.com>
To: pbs-devel@lists.proxmox.com
Subject: [pbs-devel] [RFC backup 3/6] api: tfa management and login
Date: Thu, 19 Nov 2020 15:56:05 +0100 [thread overview]
Message-ID: <20201119145608.16866-4-w.bumiller@proxmox.com> (raw)
In-Reply-To: <20201119145608.16866-1-w.bumiller@proxmox.com>
Signed-off-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
---
Cargo.toml | 1 +
src/api2/access.rs | 171 ++++++++-----
src/api2/access/tfa.rs | 567 +++++++++++++++++++++++++++++++++++++++++
src/config/tfa.rs | 474 ++++++++++++++++++++++++++++++----
src/server.rs | 2 +
src/server/rest.rs | 5 +-
src/server/ticket.rs | 77 ++++++
7 files changed, 1185 insertions(+), 112 deletions(-)
create mode 100644 src/api2/access/tfa.rs
create mode 100644 src/server/ticket.rs
diff --git a/Cargo.toml b/Cargo.toml
index 87aa9cba..1a26bbd0 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -68,6 +68,7 @@ udev = ">= 0.3, <0.5"
url = "2.1"
#valgrind_request = { git = "https://github.com/edef1c/libvalgrind_request", version = "1.1.0", optional = true }
walkdir = "2"
+webauthn-rs = "0.2.5"
xdg = "2.2"
zstd = { version = "0.4", features = [ "bindgen" ] }
nom = "5.1"
diff --git a/src/api2/access.rs b/src/api2/access.rs
index 3b59b3d3..a44d21a2 100644
--- a/src/api2/access.rs
+++ b/src/api2/access.rs
@@ -4,33 +4,46 @@ use serde_json::{json, Value};
use std::collections::HashMap;
use std::collections::HashSet;
-use proxmox::api::{api, RpcEnvironment, Permission};
use proxmox::api::router::{Router, SubdirMap};
-use proxmox::{sortable, identity};
+use proxmox::api::{api, Permission, RpcEnvironment};
use proxmox::{http_err, list_subdirs_api_method};
+use proxmox::{identity, sortable};
-use crate::tools::ticket::{self, Empty, Ticket};
-use crate::auth_helpers::*;
use crate::api2::types::*;
+use crate::auth_helpers::*;
+use crate::server::ticket::ApiTicket;
+use crate::tools::ticket::{self, Empty, Ticket};
use crate::config::acl as acl_config;
-use crate::config::acl::{PRIVILEGES, PRIV_SYS_AUDIT, PRIV_PERMISSIONS_MODIFY};
+use crate::config::acl::{PRIVILEGES, PRIV_PERMISSIONS_MODIFY, PRIV_SYS_AUDIT};
use crate::config::cached_user_info::CachedUserInfo;
+use crate::config::tfa::TfaChallenge;
-pub mod user;
-pub mod domain;
pub mod acl;
+pub mod domain;
pub mod role;
+pub mod tfa;
+pub mod user;
+
+enum AuthResult {
+ /// Successful authentication which does not require a new ticket.
+ Success,
+
+ /// Successful authentication which requires a ticket to be created.
+ CreateTicket,
+
+ /// A partial ticket which requires a 2nd factor will be created.
+ Partial(TfaChallenge),
+}
-/// returns Ok(true) if a ticket has to be created
-/// and Ok(false) if not
fn authenticate_user(
userid: &Userid,
password: &str,
path: Option<String>,
privs: Option<String>,
port: Option<u16>,
-) -> Result<bool, Error> {
+ tfa_challenge: Option<String>,
+) -> Result<AuthResult, Error> {
let user_info = CachedUserInfo::new()?;
let auth_id = Authid::from(userid.clone());
@@ -38,12 +51,16 @@ fn authenticate_user(
bail!("user account disabled or expired.");
}
+ if let Some(tfa_challenge) = tfa_challenge {
+ return authenticate_2nd(userid, &tfa_challenge, password);
+ }
+
if password.starts_with("PBS:") {
if let Ok(ticket_userid) = Ticket::<Userid>::parse(password)
.and_then(|ticket| ticket.verify(public_auth_key(), "PBS", None))
{
if *userid == ticket_userid {
- return Ok(true);
+ return Ok(AuthResult::CreateTicket);
}
bail!("ticket login failed - wrong userid");
}
@@ -53,17 +70,17 @@ fn authenticate_user(
}
let path = path.ok_or_else(|| format_err!("missing path for termproxy ticket"))?;
- let privilege_name = privs
- .ok_or_else(|| format_err!("missing privilege name for termproxy ticket"))?;
+ let privilege_name =
+ privs.ok_or_else(|| format_err!("missing privilege name for termproxy ticket"))?;
let port = port.ok_or_else(|| format_err!("missing port for termproxy ticket"))?;
- if let Ok(Empty) = Ticket::parse(password)
- .and_then(|ticket| ticket.verify(
+ if let Ok(Empty) = Ticket::parse(password).and_then(|ticket| {
+ ticket.verify(
public_auth_key(),
ticket::TERM_PREFIX,
Some(&ticket::term_aad(userid, &path, port)),
- ))
- {
+ )
+ }) {
for (name, privilege) in PRIVILEGES {
if *name == privilege_name {
let mut path_vec = Vec::new();
@@ -73,7 +90,7 @@ fn authenticate_user(
}
}
user_info.check_privs(&auth_id, &path_vec, *privilege, false)?;
- return Ok(false);
+ return Ok(AuthResult::Success);
}
}
@@ -81,8 +98,26 @@ fn authenticate_user(
}
}
- let _ = crate::auth::authenticate_user(userid, password)?;
- Ok(true)
+ let _: () = crate::auth::authenticate_user(userid, password)?;
+
+ Ok(match crate::config::tfa::login_challenge(userid)? {
+ None => AuthResult::CreateTicket,
+ Some(challenge) => AuthResult::Partial(challenge),
+ })
+}
+
+fn authenticate_2nd(
+ userid: &Userid,
+ challenge_ticket: &str,
+ response: &str,
+) -> Result<AuthResult, Error> {
+ let challenge: TfaChallenge = Ticket::<ApiTicket>::parse(&challenge_ticket)?
+ .verify_with_time_frame(public_auth_key(), "PBS", Some(userid.as_str()), -120..240)?
+ .require_partial()?;
+
+ let _: () = crate::config::tfa::verify_challenge(userid, &challenge, response.parse()?)?;
+
+ Ok(AuthResult::CreateTicket)
}
#[api(
@@ -109,6 +144,11 @@ fn authenticate_user(
description: "Port for verifying terminal tickets.",
optional: true,
},
+ "tfa-challenge": {
+ type: String,
+ description: "The signed TFA challenge string the user wants to respond to.",
+ optional: true,
+ },
},
},
returns: {
@@ -141,15 +181,18 @@ fn create_ticket(
path: Option<String>,
privs: Option<String>,
port: Option<u16>,
+ tfa_challenge: Option<String>,
rpcenv: &mut dyn RpcEnvironment,
) -> Result<Value, Error> {
- match authenticate_user(&username, &password, path, privs, port) {
- Ok(true) => {
- let ticket = Ticket::new("PBS", &username)?.sign(private_auth_key(), None)?;
-
+ match authenticate_user(&username, &password, path, privs, port, tfa_challenge) {
+ Ok(AuthResult::Success) => Ok(json!({ "username": username })),
+ Ok(AuthResult::CreateTicket) => {
+ let api_ticket = ApiTicket::full(username.clone());
+ let ticket = Ticket::new("PBS", &api_ticket)?.sign(private_auth_key(), None)?;
let token = assemble_csrf_prevention_token(csrf_secret(), &username);
- crate::server::rest::auth_logger()?.log(format!("successful auth for user '{}'", username));
+ crate::server::rest::auth_logger()?
+ .log(format!("successful auth for user '{}'", username));
Ok(json!({
"username": username,
@@ -157,9 +200,15 @@ fn create_ticket(
"CSRFPreventionToken": token,
}))
}
- Ok(false) => Ok(json!({
- "username": username,
- })),
+ Ok(AuthResult::Partial(challenge)) => {
+ let api_ticket = ApiTicket::partial(challenge);
+ let ticket = Ticket::new("PBS", &api_ticket)?
+ .sign(private_auth_key(), Some(username.as_str()))?;
+ Ok(json!({
+ "username": username,
+ "ticket": ticket,
+ }))
+ }
Err(err) => {
let client_ip = match rpcenv.get_client_ip().map(|addr| addr.ip()) {
Some(ip) => format!("{}", ip),
@@ -206,7 +255,6 @@ fn change_password(
password: String,
rpcenv: &mut dyn RpcEnvironment,
) -> Result<Value, Error> {
-
let current_user: Userid = rpcenv
.get_auth_id()
.ok_or_else(|| format_err!("unknown user"))?
@@ -215,12 +263,16 @@ fn change_password(
let mut allowed = userid == current_user;
- if userid == "root@pam" { allowed = true; }
+ if userid == "root@pam" {
+ allowed = true;
+ }
if !allowed {
let user_info = CachedUserInfo::new()?;
let privs = user_info.lookup_privs(¤t_auth, &[]);
- if (privs & PRIV_PERMISSIONS_MODIFY) != 0 { allowed = true; }
+ if (privs & PRIV_PERMISSIONS_MODIFY) != 0 {
+ allowed = true;
+ }
}
if !allowed {
@@ -277,12 +329,13 @@ pub fn list_permissions(
auth_id
} else if auth_id.is_token()
&& !current_auth_id.is_token()
- && auth_id.user() == current_auth_id.user() {
+ && auth_id.user() == current_auth_id.user()
+ {
auth_id
} else {
bail!("not allowed to list permissions of {}", auth_id);
}
- },
+ }
None => current_auth_id,
}
} else {
@@ -292,11 +345,10 @@ pub fn list_permissions(
}
};
-
fn populate_acl_paths(
mut paths: HashSet<String>,
node: acl_config::AclTreeNode,
- path: &str
+ path: &str,
) -> HashSet<String> {
for (sub_path, child_node) in node.children {
let sub_path = format!("{}/{}", path, &sub_path);
@@ -311,7 +363,7 @@ pub fn list_permissions(
let mut paths = HashSet::new();
paths.insert(path);
paths
- },
+ }
None => {
let mut paths = HashSet::new();
@@ -326,31 +378,35 @@ pub fn list_permissions(
paths.insert("/system".to_string());
paths
- },
+ }
};
- let map = paths
- .into_iter()
- .fold(HashMap::new(), |mut map: HashMap<String, HashMap<String, bool>>, path: String| {
+ let map = paths.into_iter().fold(
+ HashMap::new(),
+ |mut map: HashMap<String, HashMap<String, bool>>, path: String| {
let split_path = acl_config::split_acl_path(path.as_str());
let (privs, propagated_privs) = user_info.lookup_privs_details(&auth_id, &split_path);
match privs {
0 => map, // Don't leak ACL paths where we don't have any privileges
_ => {
- let priv_map = PRIVILEGES
- .iter()
- .fold(HashMap::new(), |mut priv_map, (name, value)| {
- if value & privs != 0 {
- priv_map.insert(name.to_string(), value & propagated_privs != 0);
- }
- priv_map
- });
+ let priv_map =
+ PRIVILEGES
+ .iter()
+ .fold(HashMap::new(), |mut priv_map, (name, value)| {
+ if value & privs != 0 {
+ priv_map
+ .insert(name.to_string(), value & propagated_privs != 0);
+ }
+ priv_map
+ });
map.insert(path, priv_map);
map
- },
- }});
+ }
+ }
+ },
+ );
Ok(map)
}
@@ -358,21 +414,16 @@ pub fn list_permissions(
#[sortable]
const SUBDIRS: SubdirMap = &sorted!([
("acl", &acl::ROUTER),
+ ("password", &Router::new().put(&API_METHOD_CHANGE_PASSWORD)),
(
- "password", &Router::new()
- .put(&API_METHOD_CHANGE_PASSWORD)
- ),
- (
- "permissions", &Router::new()
- .get(&API_METHOD_LIST_PERMISSIONS)
- ),
- (
- "ticket", &Router::new()
- .post(&API_METHOD_CREATE_TICKET)
+ "permissions",
+ &Router::new().get(&API_METHOD_LIST_PERMISSIONS)
),
+ ("ticket", &Router::new().post(&API_METHOD_CREATE_TICKET)),
("domains", &domain::ROUTER),
("roles", &role::ROUTER),
("users", &user::ROUTER),
+ ("tfa", &tfa::ROUTER),
]);
pub const ROUTER: Router = Router::new()
diff --git a/src/api2/access/tfa.rs b/src/api2/access/tfa.rs
new file mode 100644
index 00000000..68ab810a
--- /dev/null
+++ b/src/api2/access/tfa.rs
@@ -0,0 +1,567 @@
+use anyhow::{bail, format_err, Error};
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+
+use proxmox::api::{api, Permission, Router, RpcEnvironment};
+use proxmox::tools::tfa::totp::Totp;
+use proxmox::{http_bail, http_err};
+
+use crate::api2::types::{Authid, Userid, PASSWORD_SCHEMA};
+use crate::config::acl::{PRIV_PERMISSIONS_MODIFY, PRIV_SYS_AUDIT};
+use crate::config::cached_user_info::CachedUserInfo;
+use crate::config::tfa::{TfaInfo, TfaUserData};
+
+/// Perform first-factor (password) authentication only. Ignore password for the root user. Make
+/// sure a user can only update its own account!
+fn tfa_update_auth(
+ rpcenv: &mut dyn RpcEnvironment,
+ userid: &Userid,
+ password: Option<String>,
+) -> Result<(), Error> {
+ let authid: Authid = rpcenv.get_auth_id().unwrap().parse()?;
+
+ if authid.user() == Userid::root_userid() {
+ return Ok(());
+ }
+
+ let password = password.ok_or_else(|| format_err!("missing password"))?;
+ let _: () = crate::auth::authenticate_user(&userid, &password)?;
+
+ Ok(())
+}
+
+#[api]
+/// A TFA entry type.
+#[derive(Deserialize, Serialize)]
+#[serde(rename_all = "lowercase")]
+pub enum TfaType {
+ /// A TOTP entry type.
+ Totp,
+ /// A U2F token entry.
+ U2f,
+ /// A Webauthn token entry.
+ Webauthn,
+ /// Recovery tokens.
+ Recovery,
+}
+
+#[api(
+ properties: {
+ type: { type: TfaType },
+ info: { type: TfaInfo },
+ },
+)]
+/// A TFA entry for a user.
+#[derive(Deserialize, Serialize)]
+#[serde(deny_unknown_fields)]
+pub struct TypedTfaInfo {
+ #[serde(rename = "type")]
+ pub ty: TfaType,
+
+ #[serde(flatten)]
+ pub info: TfaInfo,
+}
+
+fn to_data(data: TfaUserData) -> Vec<TypedTfaInfo> {
+ let mut out = Vec::with_capacity(
+ data.totp.len()
+ + data.u2f.len()
+ + data.webauthn.len()
+ + if !data.recovery.is_empty() { 1 } else { 0 },
+ );
+ if !data.recovery.is_empty() {
+ out.push(TypedTfaInfo {
+ ty: TfaType::Recovery,
+ info: TfaInfo::recovery(),
+ })
+ }
+ for entry in data.totp {
+ out.push(TypedTfaInfo {
+ ty: TfaType::Totp,
+ info: entry.info,
+ });
+ }
+ for entry in data.webauthn {
+ out.push(TypedTfaInfo {
+ ty: TfaType::Webauthn,
+ info: entry.info,
+ });
+ }
+ for entry in data.u2f {
+ out.push(TypedTfaInfo {
+ ty: TfaType::U2f,
+ info: entry.info,
+ });
+ }
+ out
+}
+
+#[api(
+ protected: true,
+ input: {
+ properties: { userid: { type: Userid } },
+ },
+ access: {
+ permission: &Permission::Or(&[
+ &Permission::Privilege(&["access", "users"], PRIV_PERMISSIONS_MODIFY, false),
+ &Permission::UserParam("userid"),
+ ]),
+ },
+)]
+/// Add a TOTP secret to the user.
+pub fn list_user_tfa(userid: Userid) -> Result<Vec<TypedTfaInfo>, Error> {
+ let _lock = crate::config::tfa::read_lock()?;
+
+ Ok(match crate::config::tfa::read()?.users.remove(&userid) {
+ Some(data) => to_data(data),
+ None => Vec::new(),
+ })
+}
+
+#[api(
+ protected: true,
+ input: {
+ properties: {
+ userid: { type: Userid },
+ id: { description: "the tfa entry id" }
+ },
+ },
+ access: {
+ permission: &Permission::Or(&[
+ &Permission::Privilege(&["access", "users"], PRIV_PERMISSIONS_MODIFY, false),
+ &Permission::UserParam("userid"),
+ ]),
+ },
+)]
+/// Get a single TFA entry.
+pub fn get_tfa(userid: Userid, id: String) -> Result<TypedTfaInfo, Error> {
+ let _lock = crate::config::tfa::read_lock()?;
+
+ if let Some(user_data) = crate::config::tfa::read()?.users.remove(&userid) {
+ if id == "recovery" {
+ if !user_data.recovery.is_empty() {
+ return Ok(TypedTfaInfo {
+ ty: TfaType::Recovery,
+ info: TfaInfo::recovery(),
+ });
+ }
+ } else {
+ for tfa in user_data.totp {
+ if tfa.info.id == id {
+ return Ok(TypedTfaInfo {
+ ty: TfaType::Totp,
+ info: tfa.info,
+ });
+ }
+ }
+
+ for tfa in user_data.webauthn {
+ if tfa.info.id == id {
+ return Ok(TypedTfaInfo {
+ ty: TfaType::Webauthn,
+ info: tfa.info,
+ });
+ }
+ }
+
+ for tfa in user_data.u2f {
+ if tfa.info.id == id {
+ return Ok(TypedTfaInfo {
+ ty: TfaType::U2f,
+ info: tfa.info,
+ });
+ }
+ }
+ }
+ }
+
+ http_bail!(NOT_FOUND, "no such tfa entry: {}/{}", userid, id);
+}
+
+#[api(
+ protected: true,
+ input: {
+ properties: {
+ userid: { type: Userid },
+ id: {
+ description: "the tfa entry id",
+ },
+ password: {
+ schema: PASSWORD_SCHEMA,
+ optional: true,
+ },
+ },
+ },
+ access: {
+ permission: &Permission::Or(&[
+ &Permission::Privilege(&["access", "users"], PRIV_PERMISSIONS_MODIFY, false),
+ &Permission::UserParam("userid"),
+ ]),
+ },
+)]
+/// Get a single TFA entry.
+pub fn delete_tfa(
+ userid: Userid,
+ id: String,
+ password: Option<String>,
+ rpcenv: &mut dyn RpcEnvironment,
+) -> Result<(), Error> {
+ tfa_update_auth(rpcenv, &userid, password)?;
+
+ let _lock = crate::config::tfa::write_lock()?;
+
+ let mut data = crate::config::tfa::read()?;
+
+ let user_data = data
+ .users
+ .get_mut(&userid)
+ .ok_or_else(|| http_err!(NOT_FOUND, "no such entry: {}/{}", userid, id))?;
+
+ let found = if id == "recovery" {
+ let found = !user_data.recovery.is_empty();
+ user_data.recovery.clear();
+ found
+ } else if let Some(i) = user_data.totp.iter().position(|entry| entry.info.id == id) {
+ user_data.totp.remove(i);
+ true
+ } else if let Some(i) = user_data
+ .webauthn
+ .iter()
+ .position(|entry| entry.info.id == id)
+ {
+ user_data.webauthn.remove(i);
+ true
+ } else if let Some(i) = user_data.u2f.iter().position(|entry| entry.info.id == id) {
+ user_data.u2f.remove(i);
+ true
+ } else {
+ false
+ };
+
+ if !found {
+ http_bail!(NOT_FOUND, "no such tfa entry: {}/{}", userid, id);
+ }
+
+ if user_data.is_empty() {
+ data.users.remove(&userid);
+ }
+
+ crate::config::tfa::write(&data)?;
+
+ Ok(())
+}
+
+#[api(
+ properties: {
+ "userid": { type: Userid },
+ "entries": {
+ type: Array,
+ items: { type: TypedTfaInfo },
+ },
+ },
+)]
+#[derive(Deserialize, Serialize)]
+#[serde(deny_unknown_fields)]
+/// Over the API we only provide the descriptions for TFA data.
+pub struct TfaUser {
+ /// The type of TFA entry this is referring to.
+ userid: Userid,
+
+ /// User chosen description for this entry.
+ entries: Vec<TypedTfaInfo>,
+}
+
+#[api(
+ protected: true,
+ input: {
+ properties: {},
+ },
+ access: {
+ permission: &Permission::Anybody,
+ description: "Returns all or just the logged-in user, depending on privileges.",
+ },
+)]
+/// List user TFA configuration.
+pub fn list_tfa(rpcenv: &mut dyn RpcEnvironment) -> Result<Value, Error> {
+ let authid: Authid = rpcenv.get_auth_id().unwrap().parse()?;
+ let user_info = CachedUserInfo::new()?;
+
+ let top_level_privs = user_info.lookup_privs(&authid, &["access", "users"]);
+ let top_level_allowed = (top_level_privs & PRIV_SYS_AUDIT) != 0;
+
+ let _lock = crate::config::tfa::read_lock()?;
+ let tfa_data = crate::config::tfa::read()?.users;
+
+ let mut out = Vec::<TfaUser>::new();
+ if top_level_allowed {
+ for (user, data) in tfa_data {
+ out.push(TfaUser {
+ userid: user,
+ entries: to_data(data),
+ });
+ }
+ } else {
+ if let Some(data) = { tfa_data }.remove(authid.user()) {
+ out.push(TfaUser {
+ userid: authid.into(),
+ entries: to_data(data),
+ });
+ }
+ }
+
+ Ok(serde_json::to_value(out)?)
+}
+
+#[api(
+ properties: {
+ recovery: {
+ description: "A list of recovery codes as integers.",
+ type: Array,
+ items: {
+ type: Integer,
+ description: "A one-time usable recovery code entry.",
+ },
+ },
+ },
+)]
+/// The result returned when adding TFA entries to a user.
+#[derive(Default, Serialize)]
+struct TfaUpdateInfo {
+ /// The id if a newly added TFA entry.
+ id: Option<String>,
+
+ /// When adding u2f entries, this contains a challenge the user must respond to in order to
+ /// finish the registration.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ challenge: Option<String>,
+
+ /// When adding recovery codes, this contains the list of codes to be displayed to the user
+ /// this one time.
+ #[serde(skip_serializing_if = "Vec::is_empty", default)]
+ recovery: Vec<String>,
+}
+
+impl TfaUpdateInfo {
+ fn id(id: String) -> Self {
+ Self {
+ id: Some(id),
+ ..Default::default()
+ }
+ }
+}
+
+#[api(
+ protected: true,
+ input: {
+ properties: {
+ userid: { type: Userid },
+ description: {
+ description: "A description to distinguish multiple entries from one another",
+ type: String,
+ max_length: 255,
+ optional: true,
+ },
+ "type": {
+ description: "The TFA type to add.",
+ type: TfaType,
+ },
+ totp: {
+ description: "A totp URI.",
+ optional: true,
+ },
+ value: {
+ description:
+ "The current value for the provided totp URI, or a Webauthn/U2F challenge response",
+ optional: true,
+ },
+ challenge: {
+ description: "When responding to a u2f challenge: the original challenge string",
+ optional: true,
+ },
+ password: {
+ schema: PASSWORD_SCHEMA,
+ optional: true,
+ },
+ },
+ },
+ returns: { type: TfaUpdateInfo },
+ access: {
+ permission: &Permission::Or(&[
+ &Permission::Privilege(&["access", "users"], PRIV_PERMISSIONS_MODIFY, false),
+ &Permission::UserParam("userid"),
+ ]),
+ },
+)]
+/// Add a TFA entry to the user.
+fn add_tfa_entry(
+ userid: Userid,
+ description: Option<String>,
+ totp: Option<String>,
+ value: Option<String>,
+ challenge: Option<String>,
+ password: Option<String>,
+ mut params: Value, // FIXME: once api macro supports raw parameters names, use `r#type`
+ rpcenv: &mut dyn RpcEnvironment,
+) -> Result<TfaUpdateInfo, Error> {
+ tfa_update_auth(rpcenv, &userid, password)?;
+
+ let tfa_type: TfaType = serde_json::from_value(params["type"].take())?;
+
+ let need_description =
+ move || description.ok_or_else(|| format_err!("'description' is required for new entries"));
+
+ match tfa_type {
+ TfaType::Totp => match (totp, value) {
+ (Some(totp), Some(value)) => {
+ if challenge.is_some() {
+ bail!("'challenge' parameter is invalid for 'totp' entries");
+ }
+ let description = need_description()?;
+
+ let totp: Totp = totp.parse()?;
+ if totp
+ .verify(&value, std::time::SystemTime::now(), -1..=1)?
+ .is_none()
+ {
+ bail!("failed to verify TOTP challenge");
+ }
+ crate::config::tfa::add_totp(&userid, description, totp).map(TfaUpdateInfo::id)
+ }
+ _ => bail!("'totp' type requires both 'totp' and 'value' parameters"),
+ },
+ TfaType::Webauthn => {
+ if totp.is_some() {
+ bail!("'totp' parameter is invalid for 'totp' entries");
+ }
+
+ match challenge {
+ None => crate::config::tfa::add_webauthn_registration(&userid, need_description()?)
+ .map(|c| TfaUpdateInfo {
+ challenge: Some(c),
+ ..Default::default()
+ }),
+ Some(challenge) => {
+ let value = value.ok_or_else(|| {
+ format_err!(
+ "missing 'value' parameter (webauthn challenge response missing)"
+ )
+ })?;
+ crate::config::tfa::finish_webauthn_registration(&userid, &challenge, &value)
+ .map(TfaUpdateInfo::id)
+ }
+ }
+ }
+ TfaType::U2f => {
+ if totp.is_some() {
+ bail!("'totp' parameter is invalid for 'totp' entries");
+ }
+
+ match challenge {
+ None => crate::config::tfa::add_u2f_registration(&userid, need_description()?).map(
+ |c| TfaUpdateInfo {
+ challenge: Some(c),
+ ..Default::default()
+ },
+ ),
+ Some(challenge) => {
+ let value = value.ok_or_else(|| {
+ format_err!("missing 'value' parameter (u2f challenge response missing)")
+ })?;
+ crate::config::tfa::finish_u2f_registration(&userid, &challenge, &value)
+ .map(TfaUpdateInfo::id)
+ }
+ }
+ }
+ TfaType::Recovery => {
+ if totp.or(value).or(challenge).is_some() {
+ bail!("generating recovery tokens does not allow additional parameters");
+ }
+
+ let recovery = crate::config::tfa::add_recovery(&userid)?;
+
+ Ok(TfaUpdateInfo {
+ id: Some("recovery".to_string()),
+ recovery,
+ ..Default::default()
+ })
+ }
+ }
+}
+
+#[api(
+ protected: true,
+ input: {
+ properties: {
+ userid: { type: Userid },
+ id: {
+ description: "the tfa entry id",
+ },
+ description: {
+ description: "A description to distinguish multiple entries from one another",
+ type: String,
+ max_length: 255,
+ optional: true,
+ },
+ enable: {
+ description: "Whether this entry should currently be enabled or disabled",
+ optional: true,
+ },
+ password: {
+ schema: PASSWORD_SCHEMA,
+ optional: true,
+ },
+ },
+ },
+ access: {
+ permission: &Permission::Or(&[
+ &Permission::Privilege(&["access", "users"], PRIV_PERMISSIONS_MODIFY, false),
+ &Permission::UserParam("userid"),
+ ]),
+ },
+)]
+/// Update user's TFA entry description.
+pub fn update_tfa_entry(
+ userid: Userid,
+ id: String,
+ description: Option<String>,
+ enable: Option<bool>,
+ password: Option<String>,
+ rpcenv: &mut dyn RpcEnvironment,
+) -> Result<(), Error> {
+ tfa_update_auth(rpcenv, &userid, password)?;
+
+ let _lock = crate::config::tfa::write_lock()?;
+
+ let mut data = crate::config::tfa::read()?;
+
+ let mut entry = data
+ .users
+ .get_mut(&userid)
+ .and_then(|user| user.find_entry_mut(&id))
+ .ok_or_else(|| http_err!(NOT_FOUND, "no such entry: {}/{}", userid, id))?;
+
+ if let Some(description) = description {
+ entry.description = description;
+ }
+
+ if let Some(enable) = enable {
+ entry.enable = enable;
+ }
+
+ crate::config::tfa::write(&data)?;
+ Ok(())
+}
+
+pub const ROUTER: Router = Router::new()
+ .get(&API_METHOD_LIST_TFA)
+ .match_all("userid", &USER_ROUTER);
+
+const USER_ROUTER: Router = Router::new()
+ .get(&API_METHOD_LIST_USER_TFA)
+ .post(&API_METHOD_ADD_TFA_ENTRY)
+ .match_all("id", &ITEM_ROUTER);
+
+const ITEM_ROUTER: Router = Router::new()
+ .get(&API_METHOD_GET_TFA)
+ .put(&API_METHOD_UPDATE_TFA_ENTRY)
+ .delete(&API_METHOD_DELETE_TFA);
diff --git a/src/config/tfa.rs b/src/config/tfa.rs
index da3a4f9a..ca39b3c0 100644
--- a/src/config/tfa.rs
+++ b/src/config/tfa.rs
@@ -5,6 +5,7 @@ use std::time::Duration;
use anyhow::{bail, format_err, Error};
use serde::{de::Deserializer, Deserialize, Serialize};
use serde_json::Value;
+use webauthn_rs::Webauthn;
use proxmox::api::api;
use proxmox::sys::error::SysError;
@@ -29,38 +30,98 @@ pub struct U2fConfig {
appid: String,
}
+#[derive(Clone, Deserialize, Serialize)]
+pub struct WebauthnConfig {
+ /// Relying party name. Any text identifier.
+ ///
+ /// Changing this *may* break existing credentials.
+ rp: String,
+
+ /// Site origin. Must be a `https://` URL (or `http://localhost`). Should contain the address
+ /// users type in their browsers to access the web interface.
+ ///
+ /// Changing this *may* break existing credentials.
+ origin: String,
+
+ /// Relying part ID. Must be the domain name without protocol, port or location.
+ ///
+ /// Changing this *will* break existing credentials.
+ id: String,
+}
+
+/// For now we just implement this on the configuration this way.
+///
+/// Note that we may consider changing this so `get_origin` returns the `Host:` header provided by
+/// the connecting client.
+impl webauthn_rs::WebauthnConfig for WebauthnConfig {
+ fn get_relying_party_name(&self) -> String {
+ self.rp.clone()
+ }
+
+ fn get_origin(&self) -> &String {
+ &self.origin
+ }
+
+ fn get_relying_party_id(&self) -> String {
+ self.id.clone()
+ }
+}
+
#[derive(Default, Deserialize, Serialize)]
pub struct TfaConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub u2f: Option<U2fConfig>,
+
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub webauthn: Option<WebauthnConfig>,
+
#[serde(skip_serializing_if = "TfaUsers::is_empty", default)]
pub users: TfaUsers,
}
/// Heper to get a u2f instance from a u2f config, or `None` if there isn't one configured.
fn get_u2f(u2f: &Option<U2fConfig>) -> Option<u2f::U2f> {
- u2f.as_ref().map(|cfg| u2f::U2f::new(cfg.appid.clone(), cfg.appid.clone()))
+ u2f.as_ref()
+ .map(|cfg| u2f::U2f::new(cfg.appid.clone(), cfg.appid.clone()))
}
/// Heper to get a u2f instance from a u2f config.
-// deduplicate error message while working around self-borrow issue
+///
+/// This is outside of `TfaConfig` to not borrow its `&self`.
fn need_u2f(u2f: &Option<U2fConfig>) -> Result<u2f::U2f, Error> {
get_u2f(u2f).ok_or_else(|| format_err!("no u2f configuration available"))
}
-impl TfaConfig {
- fn u2f(&self) -> Option<u2f::U2f> {
- get_u2f(&self.u2f)
- }
+/// Heper to get a u2f instance from a u2f config, or `None` if there isn't one configured.
+fn get_webauthn(waconfig: &Option<WebauthnConfig>) -> Option<Webauthn<WebauthnConfig>> {
+ waconfig.clone().map(Webauthn::new)
+}
+/// Heper to get a u2f instance from a u2f config.
+///
+/// This is outside of `TfaConfig` to not borrow its `&self`.
+fn need_webauthn(waconfig: &Option<WebauthnConfig>) -> Result<Webauthn<WebauthnConfig>, Error> {
+ get_webauthn(waconfig).ok_or_else(|| format_err!("no webauthn configuration available"))
+}
+
+impl TfaConfig {
+ /// Helper to get a u2f instance. Note that there's a non-&self borrowing version if needed.
fn need_u2f(&self) -> Result<u2f::U2f, Error> {
need_u2f(&self.u2f)
}
+ /// Helper to get a Webauthn instance. Note that there's a non-&self borrowing version if
+ /// needed.
+ fn need_webauthn(&self) -> Result<Webauthn<WebauthnConfig>, Error> {
+ need_webauthn(&self.webauthn)
+ }
+
/// Get a two factor authentication challenge for a user, if the user has TFA set up.
- pub fn login_challenge(&self, userid: &Userid) -> Result<Option<TfaChallenge>, Error> {
- match self.users.get(userid) {
- Some(udata) => udata.challenge(self.u2f().as_ref()),
+ pub fn login_challenge(&mut self, userid: &Userid) -> Result<Option<TfaChallenge>, Error> {
+ match self.users.get_mut(userid) {
+ Some(udata) => {
+ udata.challenge(get_webauthn(&self.webauthn), get_u2f(&self.u2f).as_ref())
+ }
None => Ok(None),
}
}
@@ -94,6 +155,39 @@ impl TfaConfig {
}
}
+ /// Get a webauthn registration challenge.
+ fn webauthn_registration_challenge(
+ &mut self,
+ user: &Userid,
+ description: String,
+ ) -> Result<String, Error> {
+ let webauthn = self.need_webauthn()?;
+
+ self.users
+ .entry(user.clone())
+ .or_default()
+ .webauthn_registration_challenge(webauthn, user, description)
+ }
+
+ /// Finish a webauthn registration challenge.
+ fn webauthn_registration_finish(
+ &mut self,
+ userid: &Userid,
+ challenge: &str,
+ response: &str,
+ ) -> Result<String, Error> {
+ let webauthn = self.need_webauthn()?;
+
+ let response: webauthn_rs::proto::RegisterPublicKeyCredential =
+ serde_json::from_str(response)
+ .map_err(|err| format_err!("error parsing challenge response: {}", err))?;
+
+ match self.users.get_mut(userid) {
+ Some(user) => user.webauthn_registration_finish(webauthn, challenge, response),
+ None => bail!("no such challenge"),
+ }
+ }
+
/// Verify a TFA response.
fn verify(
&mut self,
@@ -102,19 +196,21 @@ impl TfaConfig {
response: TfaResponse,
) -> Result<(), Error> {
match self.users.get_mut(userid) {
- Some(user) => {
- match response {
- TfaResponse::Totp(value) => user.verify_totp(&value),
- TfaResponse::U2f(value) => match &challenge.u2f {
- Some(challenge) => {
- let u2f = need_u2f(&self.u2f)?;
- user.verify_u2f(u2f, &challenge.challenge, value)
- }
- None => bail!("no u2f factor available for user '{}'", userid),
+ Some(user) => match response {
+ TfaResponse::Totp(value) => user.verify_totp(&value),
+ TfaResponse::U2f(value) => match &challenge.u2f {
+ Some(challenge) => {
+ let u2f = need_u2f(&self.u2f)?;
+ user.verify_u2f(u2f, &challenge.challenge, value)
}
- TfaResponse::Recovery(value) => user.verify_recovery(&value),
+ None => bail!("no u2f factor available for user '{}'", userid),
+ },
+ TfaResponse::Webauthn(value) => {
+ let webauthn = need_webauthn(&self.webauthn)?;
+ user.verify_webauthn(webauthn, value)
}
- }
+ TfaResponse::Recovery(value) => user.verify_recovery(&value),
+ },
None => bail!("no 2nd factor available for user '{}'", userid),
}
}
@@ -175,6 +271,10 @@ impl<T> TfaEntry<T> {
}
}
+trait IsExpired {
+ fn is_expired(&self, at_epoch: i64) -> bool;
+}
+
/// A u2f registration challenge.
#[derive(Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
@@ -197,7 +297,79 @@ impl U2fRegistrationChallenge {
created: proxmox::tools::time::epoch_i64(),
}
}
+}
+impl IsExpired for U2fRegistrationChallenge {
+ fn is_expired(&self, at_epoch: i64) -> bool {
+ self.created < at_epoch
+ }
+}
+
+/// A webauthn registration challenge.
+#[derive(Deserialize, Serialize)]
+#[serde(deny_unknown_fields)]
+pub struct WebauthnRegistrationChallenge {
+ /// Server side registration state data.
+ state: webauthn_rs::RegistrationState,
+
+ /// While this is basically the content of a `RegistrationState`, the webauthn-rs crate doesn't
+ /// make this public.
+ challenge: String,
+
+ /// The description chosen by the user for this registration.
+ description: String,
+
+ /// When the challenge was created as unix epoch. They are supposed to be short-lived.
+ created: i64,
+}
+
+impl WebauthnRegistrationChallenge {
+ pub fn new(
+ state: webauthn_rs::RegistrationState,
+ challenge: String,
+ description: String,
+ ) -> Self {
+ Self {
+ state,
+ challenge,
+ description,
+ created: proxmox::tools::time::epoch_i64(),
+ }
+ }
+}
+
+impl IsExpired for WebauthnRegistrationChallenge {
+ fn is_expired(&self, at_epoch: i64) -> bool {
+ self.created < at_epoch
+ }
+}
+
+/// A webauthn authentication challenge.
+#[derive(Deserialize, Serialize)]
+#[serde(deny_unknown_fields)]
+pub struct WebauthnAuthChallenge {
+ /// Server side authentication state.
+ state: webauthn_rs::AuthenticationState,
+
+ /// While this is basically the content of a `AuthenticationState`, the webauthn-rs crate
+ /// doesn't make this public.
+ challenge: String,
+
+ /// When the challenge was created as unix epoch. They are supposed to be short-lived.
+ created: i64,
+}
+
+impl WebauthnAuthChallenge {
+ pub fn new(state: webauthn_rs::AuthenticationState, challenge: String) -> Self {
+ Self {
+ state,
+ challenge,
+ created: proxmox::tools::time::epoch_i64(),
+ }
+ }
+}
+
+impl IsExpired for WebauthnAuthChallenge {
fn is_expired(&self, at_epoch: i64) -> bool {
self.created < at_epoch
}
@@ -216,6 +388,10 @@ pub struct TfaUserData {
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub(crate) u2f: Vec<TfaEntry<u2f::Registration>>,
+ /// Registered webauthn tokens for a user.
+ #[serde(skip_serializing_if = "Vec::is_empty", default)]
+ pub(crate) webauthn: Vec<TfaEntry<webauthn_rs::proto::Credential>>,
+
/// Recovery keys. (Unordered OTP values).
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub(crate) recovery: Vec<String>,
@@ -224,23 +400,36 @@ pub struct TfaUserData {
///
/// Expired values are automatically filtered out while parsing the tfa configuration file.
#[serde(skip_serializing_if = "Vec::is_empty", default)]
- #[serde(deserialize_with = "filter_expired_registrations")]
+ #[serde(deserialize_with = "filter_expired_challenge")]
u2f_registrations: Vec<U2fRegistrationChallenge>,
+
+ /// Active webauthn registration challenges for a user.
+ ///
+ /// Expired values are automatically filtered out while parsing the tfa configuration file.
+ #[serde(skip_serializing_if = "Vec::is_empty", default)]
+ #[serde(deserialize_with = "filter_expired_challenge")]
+ webauthn_registrations: Vec<WebauthnRegistrationChallenge>,
+
+ /// Active webauthn registration challenges for a user.
+ ///
+ /// Expired values are automatically filtered out while parsing the tfa configuration file.
+ #[serde(skip_serializing_if = "Vec::is_empty", default)]
+ #[serde(deserialize_with = "filter_expired_challenge")]
+ webauthn_auths: Vec<WebauthnAuthChallenge>,
}
/// Serde helper using our `FilteredVecVisitor` to filter out expired entries directly at load
/// time.
-fn filter_expired_registrations<'de, D>(
- deserializer: D,
-) -> Result<Vec<U2fRegistrationChallenge>, D::Error>
+fn filter_expired_challenge<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
where
D: Deserializer<'de>,
+ T: Deserialize<'de> + IsExpired,
{
let expire_before = proxmox::tools::time::epoch_i64() - CHALLENGE_TIMEOUT;
Ok(
deserializer.deserialize_seq(crate::tools::serde_filter::FilteredVecVisitor::new(
- "a u2f registration challenge entry",
- move |reg: &U2fRegistrationChallenge| !reg.is_expired(expire_before),
+ "a challenge entry",
+ move |reg: &T| !reg.is_expired(expire_before),
))?,
)
}
@@ -248,7 +437,10 @@ where
impl TfaUserData {
/// `true` if no second factors exist
pub fn is_empty(&self) -> bool {
- self.totp.is_empty() && self.u2f.is_empty() && self.recovery.is_empty()
+ self.totp.is_empty()
+ && self.u2f.is_empty()
+ && self.webauthn.is_empty()
+ && self.recovery.is_empty()
}
/// Find an entry by id, except for the "recovery" entry which we're currently treating
@@ -260,6 +452,12 @@ impl TfaUserData {
}
}
+ for entry in &mut self.webauthn {
+ if entry.info.id == id {
+ return Some(&mut entry.info);
+ }
+ }
+
for entry in &mut self.u2f {
if entry.info.id == id {
return Some(&mut entry.info);
@@ -337,8 +535,71 @@ impl TfaUserData {
Ok(id)
}
+ /// Create a webauthn registration challenge.
+ ///
+ /// The description is required at this point already mostly to better be able to identify such
+ /// challenges in the tfa config file if necessary. The user otherwise has no access to this
+ /// information at this point, as the challenge is identified by its actual challenge data
+ /// instead.
+ fn webauthn_registration_challenge(
+ &mut self,
+ mut webauthn: Webauthn<WebauthnConfig>,
+ userid: &Userid,
+ description: String,
+ ) -> Result<String, Error> {
+ let userid_str = userid.to_string();
+ let (challenge, state) = webauthn.generate_challenge_register(&userid_str, None)?;
+ let challenge_string = challenge.public_key.challenge.to_string();
+ let challenge = serde_json::to_string(&challenge)?;
+
+ self.webauthn_registrations
+ .push(WebauthnRegistrationChallenge::new(
+ state,
+ challenge_string,
+ description,
+ ));
+
+ Ok(challenge)
+ }
+
+ /// Finish a webauthn registration. The challenge should correspond to an output of
+ /// `webauthn_registration_challenge`. The response should come directly from the client.
+ fn webauthn_registration_finish(
+ &mut self,
+ webauthn: Webauthn<WebauthnConfig>,
+ challenge: &str,
+ response: webauthn_rs::proto::RegisterPublicKeyCredential,
+ ) -> Result<String, Error> {
+ let expire_before = proxmox::tools::time::epoch_i64() - CHALLENGE_TIMEOUT;
+
+ let index = self
+ .webauthn_registrations
+ .iter()
+ .position(|r| r.challenge == challenge)
+ .ok_or_else(|| format_err!("no such challenge"))?;
+
+ let reg = self.webauthn_registrations.remove(index);
+ if reg.is_expired(expire_before) {
+ bail!("no such challenge");
+ }
+
+ let credential =
+ webauthn.register_credential(response, reg.state, |id| -> Result<bool, ()> {
+ Ok(self.webauthn.iter().any(|cred| cred.entry.cred_id == *id))
+ })?;
+
+ let entry = TfaEntry::new(reg.description, credential);
+ let id = entry.info.id.clone();
+ self.webauthn.push(entry);
+ Ok(id)
+ }
+
/// Generate a generic TFA challenge. See the [`TfaChallenge`] description for details.
- pub fn challenge(&self, u2f: Option<&u2f::U2f>) -> Result<Option<TfaChallenge>, Error> {
+ pub fn challenge(
+ &mut self,
+ webauthn: Option<Webauthn<WebauthnConfig>>,
+ u2f: Option<&u2f::U2f>,
+ ) -> Result<Option<TfaChallenge>, Error> {
if self.is_empty() {
return Ok(None);
}
@@ -346,6 +607,10 @@ impl TfaUserData {
Ok(Some(TfaChallenge {
totp: self.totp.iter().any(|e| e.info.enable),
recovery: RecoveryState::from_count(self.recovery.len()),
+ webauthn: match webauthn {
+ Some(webauthn) => self.webauthn_challenge(webauthn)?,
+ None => None,
+ },
u2f: match u2f {
Some(u2f) => self.u2f_challenge(u2f)?,
None => None,
@@ -357,26 +622,21 @@ impl TfaUserData {
fn enabled_totp_entries(&self) -> impl Iterator<Item = &Totp> {
self.totp
.iter()
- .filter_map(|e| {
- if e.info.enable {
- Some(&e.entry)
- } else {
- None
- }
- })
+ .filter_map(|e| if e.info.enable { Some(&e.entry) } else { None })
}
/// Helper to iterate over enabled u2f entries.
fn enabled_u2f_entries(&self) -> impl Iterator<Item = &u2f::Registration> {
self.u2f
.iter()
- .filter_map(|e| {
- if e.info.enable {
- Some(&e.entry)
- } else {
- None
- }
- })
+ .filter_map(|e| if e.info.enable { Some(&e.entry) } else { None })
+ }
+
+ /// Helper to iterate over enabled u2f entries.
+ fn enabled_webauthn_entries(&self) -> impl Iterator<Item = &webauthn_rs::proto::Credential> {
+ self.webauthn
+ .iter()
+ .filter_map(|e| if e.info.enable { Some(&e.entry) } else { None })
}
/// Generate an optional u2f challenge.
@@ -400,6 +660,29 @@ impl TfaUserData {
}))
}
+ /// Generate an optional webauthn challenge.
+ fn webauthn_challenge(
+ &mut self,
+ mut webauthn: Webauthn<WebauthnConfig>,
+ ) -> Result<Option<webauthn_rs::proto::RequestChallengeResponse>, Error> {
+ if self.webauthn.is_empty() {
+ return Ok(None);
+ }
+
+ let creds: Vec<_> = self.enabled_webauthn_entries().map(Clone::clone).collect();
+
+ if creds.is_empty() {
+ return Ok(None);
+ }
+
+ let (challenge, state) = webauthn.generate_challenge_authenticate(creds, None)?;
+ let challenge_string = challenge.public_key.challenge.to_string();
+ self.webauthn_auths
+ .push(WebauthnAuthChallenge::new(state, challenge_string));
+
+ Ok(Some(challenge))
+ }
+
/// Verify a totp challenge. The `value` should be the totp digits as plain text.
fn verify_totp(&self, value: &str) -> Result<(), Error> {
let now = std::time::SystemTime::now();
@@ -425,9 +708,12 @@ impl TfaUserData {
if let Some(entry) = self
.enabled_u2f_entries()
- .find(|e| e.key.key_handle == response.key_handle)
+ .find(|e| e.key.key_handle == response.key_handle())
{
- if u2f.auth_verify_obj(&entry.public_key, &challenge.challenge, response)?.is_some() {
+ if u2f
+ .auth_verify_obj(&entry.public_key, &challenge.challenge, response)?
+ .is_some()
+ {
return Ok(());
}
}
@@ -435,6 +721,44 @@ impl TfaUserData {
bail!("u2f verification failed");
}
+ /// Verify a webauthn response.
+ fn verify_webauthn(
+ &mut self,
+ mut webauthn: Webauthn<WebauthnConfig>,
+ mut response: Value,
+ ) -> Result<(), Error> {
+ let expire_before = proxmox::tools::time::epoch_i64() - CHALLENGE_TIMEOUT;
+
+ let challenge = match response
+ .as_object_mut()
+ .ok_or_else(|| format_err!("invalid response, must be a json object"))?
+ .remove("challenge")
+ .ok_or_else(|| format_err!("missing challenge data in response"))?
+ {
+ Value::String(s) => s,
+ _ => bail!("invalid challenge data in response"),
+ };
+
+ let response: webauthn_rs::proto::PublicKeyCredential = serde_json::from_value(response)
+ .map_err(|err| format_err!("invalid webauthn response: {}", err))?;
+
+ let index = self
+ .webauthn_auths
+ .iter()
+ .position(|r| r.challenge == challenge)
+ .ok_or_else(|| format_err!("no such challenge"))?;
+
+ let challenge = self.webauthn_auths.remove(index);
+ if challenge.is_expired(expire_before) {
+ bail!("no such challenge");
+ }
+
+ match webauthn.authenticate_credential(response, challenge.state)? {
+ Some((_cred, _counter)) => Ok(()),
+ None => bail!("webauthn authentication failed"),
+ }
+ }
+
/// Verify a recovery key.
///
/// NOTE: If successful, the key will automatically be removed from the list of available
@@ -458,7 +782,8 @@ impl TfaUserData {
let mut key_data = [0u8; 40]; // 10 keys of 32 bits
proxmox::sys::linux::fill_with_random_data(&mut key_data)?;
for b in key_data.chunks(4) {
- self.recovery.push(format!("{:02x}{:02x}{:02x}{:02x}", b[0], b[1], b[2], b[3]));
+ self.recovery
+ .push(format!("{:02x}{:02x}{:02x}{:02x}", b[0], b[1], b[2], b[3]));
}
Ok(self.recovery.clone())
@@ -493,9 +818,23 @@ pub fn write_lock() -> Result<File, Error> {
proxmox::tools::fs::open_file_locked(LOCK_FILE, LOCK_TIMEOUT, true)
}
+/// Get an optional TFA challenge for a user.
+pub fn login_challenge(userid: &Userid) -> Result<Option<TfaChallenge>, Error> {
+ let _lock = write_lock()?;
+
+ let mut data = read()?;
+ Ok(match data.login_challenge(userid)? {
+ Some(challenge) => {
+ write(&data)?;
+ Some(challenge)
+ }
+ None => None,
+ })
+}
+
/// Add a TOTP entry for a user. Returns the ID.
pub fn add_totp(userid: &Userid, description: String, value: Totp) -> Result<String, Error> {
- let _lock = crate::config::tfa::write_lock();
+ let _lock = write_lock();
let mut data = read()?;
let entry = TfaEntry::new(description, value);
let id = entry.info.id.clone();
@@ -510,10 +849,14 @@ pub fn add_totp(userid: &Userid, description: String, value: Totp) -> Result<Str
/// Add recovery tokens for the user. Returns the token list.
pub fn add_recovery(userid: &Userid) -> Result<Vec<String>, Error> {
- let _lock = crate::config::tfa::write_lock();
+ let _lock = write_lock();
let mut data = read()?;
- let out = data.users.entry(userid.clone()).or_default().add_recovery()?;
+ let out = data
+ .users
+ .entry(userid.clone())
+ .or_default()
+ .add_recovery()?;
write(&data)?;
Ok(out)
}
@@ -535,11 +878,33 @@ pub fn finish_u2f_registration(
) -> Result<String, Error> {
let _lock = crate::config::tfa::write_lock();
let mut data = read()?;
- let challenge = data.u2f_registration_finish(userid, challenge, response)?;
+ let id = data.u2f_registration_finish(userid, challenge, response)?;
+ write(&data)?;
+ Ok(id)
+}
+
+/// Add a webauthn registration challenge for a user.
+pub fn add_webauthn_registration(userid: &Userid, description: String) -> Result<String, Error> {
+ let _lock = crate::config::tfa::write_lock();
+ let mut data = read()?;
+ let challenge = data.webauthn_registration_challenge(userid, description)?;
write(&data)?;
Ok(challenge)
}
+/// Finish a webauthn registration challenge for a user.
+pub fn finish_webauthn_registration(
+ userid: &Userid,
+ challenge: &str,
+ response: &str,
+) -> Result<String, Error> {
+ let _lock = crate::config::tfa::write_lock();
+ let mut data = read()?;
+ let id = data.webauthn_registration_finish(userid, challenge, response)?;
+ write(&data)?;
+ Ok(id)
+}
+
/// Verify a TFA challenge.
pub fn verify_challenge(
userid: &Userid,
@@ -585,7 +950,8 @@ impl Default for RecoveryState {
}
/// When sending a TFA challenge to the user, we include information about what kind of challenge
-/// the user may perform. If u2f devices are available, a u2f challenge will be included.
+/// the user may perform. If webauthn credentials are available, a webauthn challenge will be
+/// included.
#[derive(Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub struct TfaChallenge {
@@ -599,6 +965,11 @@ pub struct TfaChallenge {
/// If the user has any u2f tokens registered, this will contain the U2F challenge data.
#[serde(skip_serializing_if = "Option::is_none")]
u2f: Option<U2fChallenge>,
+
+ /// If the user has any webauthn credentials registered, this will contain the corresponding
+ /// challenge data.
+ #[serde(skip_serializing_if = "Option::is_none", skip_deserializing)]
+ webauthn: Option<webauthn_rs::proto::RequestChallengeResponse>,
}
/// Data used for u2f challenges.
@@ -615,6 +986,7 @@ pub struct U2fChallenge {
pub enum TfaResponse {
Totp(String),
U2f(Value),
+ Webauthn(Value),
Recovery(String),
}
@@ -626,6 +998,8 @@ impl std::str::FromStr for TfaResponse {
TfaResponse::Totp(s[5..].to_string())
} else if s.starts_with("u2f:") {
TfaResponse::U2f(serde_json::from_str(&s[4..])?)
+ } else if s.starts_with("webauthn:") {
+ TfaResponse::Webauthn(serde_json::from_str(&s[9..])?)
} else if s.starts_with("recovery:") {
TfaResponse::Recovery(s[9..].to_string())
} else {
diff --git a/src/server.rs b/src/server.rs
index 983a300d..7c159c23 100644
--- a/src/server.rs
+++ b/src/server.rs
@@ -87,3 +87,5 @@ pub use email_notifications::*;
mod report;
pub use report::*;
+
+pub mod ticket;
diff --git a/src/server/rest.rs b/src/server/rest.rs
index da110507..7a5bf3ea 100644
--- a/src/server/rest.rs
+++ b/src/server/rest.rs
@@ -599,8 +599,9 @@ fn check_auth(
let ticket = user_auth_data.ticket.clone();
let ticket_lifetime = tools::ticket::TICKET_LIFETIME;
- let userid: Userid = Ticket::parse(&ticket)?
- .verify_with_time_frame(public_auth_key(), "PBS", None, -300..ticket_lifetime)?;
+ let userid: Userid = Ticket::<super::ticket::ApiTicket>::parse(&ticket)?
+ .verify_with_time_frame(public_auth_key(), "PBS", None, -300..ticket_lifetime)?
+ .require_full()?;
let auth_id = Authid::from(userid.clone());
if !user_info.is_active_auth_id(&auth_id) {
diff --git a/src/server/ticket.rs b/src/server/ticket.rs
new file mode 100644
index 00000000..0142a03a
--- /dev/null
+++ b/src/server/ticket.rs
@@ -0,0 +1,77 @@
+use std::fmt;
+
+use anyhow::{bail, Error};
+use serde::{Deserialize, Serialize};
+
+use crate::api2::types::Userid;
+use crate::config::tfa;
+
+#[derive(Deserialize, Serialize)]
+#[serde(deny_unknown_fields)]
+pub struct PartialTicket {
+ #[serde(rename = "u")]
+ userid: Userid,
+
+ #[serde(rename = "c")]
+ challenge: tfa::TfaChallenge,
+}
+
+/// A new ticket struct used in rest.rs's `check_auth` - mostly for better errors than failing to
+/// parse the userid ticket content.
+pub enum ApiTicket {
+ Full(Userid),
+ Partial(tfa::TfaChallenge),
+}
+
+impl ApiTicket {
+ /// Require the ticket to be a full ticket, otherwise error with a meaningful error message.
+ pub fn require_full(self) -> Result<Userid, Error> {
+ match self {
+ ApiTicket::Full(userid) => Ok(userid),
+ ApiTicket::Partial(_) => bail!("access denied - second login factor required"),
+ }
+ }
+
+ /// Expect the ticket to contain a tfa challenge, otherwise error with a meaningful error
+ /// message.
+ pub fn require_partial(self) -> Result<tfa::TfaChallenge, Error> {
+ match self {
+ ApiTicket::Full(_) => bail!("invalid tfa challenge"),
+ ApiTicket::Partial(challenge) => Ok(challenge),
+ }
+ }
+
+ /// Create a new full ticket.
+ pub fn full(userid: Userid) -> Self {
+ ApiTicket::Full(userid)
+ }
+
+ /// Create a new partial ticket.
+ pub fn partial(challenge: tfa::TfaChallenge) -> Self {
+ ApiTicket::Partial(challenge)
+ }
+}
+
+impl fmt::Display for ApiTicket {
+ fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+ match self {
+ ApiTicket::Full(userid) => fmt::Display::fmt(userid, f),
+ ApiTicket::Partial(partial) => {
+ let data = serde_json::to_string(partial).map_err(|_| fmt::Error)?;
+ write!(f, "!tfa!{}", data)
+ }
+ }
+ }
+}
+
+impl std::str::FromStr for ApiTicket {
+ type Err = Error;
+
+ fn from_str(s: &str) -> Result<Self, Error> {
+ if s.starts_with("!tfa!") {
+ Ok(ApiTicket::Partial(serde_json::from_str(&s[5..])?))
+ } else {
+ Ok(ApiTicket::Full(s.parse()?))
+ }
+ }
+}
--
2.20.1
next prev parent reply other threads:[~2020-11-19 14:56 UTC|newest]
Thread overview: 23+ messages / expand[flat|nested] mbox.gz Atom feed top
2020-11-19 14:56 [pbs-devel] [RFC backup 0/6] Two factor authentication Wolfgang Bumiller
2020-11-19 14:56 ` [pbs-devel] [RFC backup 1/6] add tools::serde_filter submodule Wolfgang Bumiller
2020-11-19 14:56 ` [pbs-devel] [RFC backup 2/6] config: add tfa configuration Wolfgang Bumiller
2020-11-19 14:56 ` Wolfgang Bumiller [this message]
2020-11-19 14:56 ` [pbs-devel] [RFC backup 4/6] depend on libjs-qrcodejs Wolfgang Bumiller
2020-11-19 14:56 ` [pbs-devel] [RFC backup 5/6] proxy: expose qrcodejs Wolfgang Bumiller
2020-11-19 14:56 ` [pbs-devel] [RFC backup 6/6] gui: tfa support Wolfgang Bumiller
2020-11-24 9:42 ` Wolfgang Bumiller
2020-11-24 9:51 ` Thomas Lamprecht
2020-12-02 10:56 ` [pbs-devel] [RFC backup 0/6] Two factor authentication Oguz Bektas
2020-12-02 12:27 ` Thomas Lamprecht
2020-12-02 12:34 ` Thomas Lamprecht
2020-12-02 12:48 ` Oguz Bektas
2020-12-02 12:59 ` Wolfgang Bumiller
2020-12-02 13:08 ` Oguz Bektas
2020-12-02 12:35 ` Oguz Bektas
2020-12-02 12:51 ` Wolfgang Bumiller
2020-12-02 13:15 ` Thomas Lamprecht
2020-12-02 13:07 ` Thomas Lamprecht
2020-12-02 13:35 ` Oguz Bektas
2020-12-02 14:05 ` Thomas Lamprecht
2020-12-02 14:21 ` Oguz Bektas
2020-12-02 14:29 ` Wolfgang Bumiller
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=20201119145608.16866-4-w.bumiller@proxmox.com \
--to=w.bumiller@proxmox.com \
--cc=pbs-devel@lists.proxmox.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.