api: tfa management and login
Signed-off-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
This commit is contained in:
parent
dc1fdd6267
commit
027ef213aa
@ -68,6 +68,7 @@ udev = ">= 0.3, <0.5"
|
|||||||
url = "2.1"
|
url = "2.1"
|
||||||
#valgrind_request = { git = "https://github.com/edef1c/libvalgrind_request", version = "1.1.0", optional = true }
|
#valgrind_request = { git = "https://github.com/edef1c/libvalgrind_request", version = "1.1.0", optional = true }
|
||||||
walkdir = "2"
|
walkdir = "2"
|
||||||
|
webauthn-rs = "0.2.5"
|
||||||
xdg = "2.2"
|
xdg = "2.2"
|
||||||
zstd = { version = "0.4", features = [ "bindgen" ] }
|
zstd = { version = "0.4", features = [ "bindgen" ] }
|
||||||
nom = "5.1"
|
nom = "5.1"
|
||||||
|
@ -4,33 +4,46 @@ use serde_json::{json, Value};
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
use proxmox::api::{api, RpcEnvironment, Permission};
|
|
||||||
use proxmox::api::router::{Router, SubdirMap};
|
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::{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::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 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::cached_user_info::CachedUserInfo;
|
||||||
|
use crate::config::tfa::TfaChallenge;
|
||||||
|
|
||||||
pub mod user;
|
|
||||||
pub mod domain;
|
|
||||||
pub mod acl;
|
pub mod acl;
|
||||||
|
pub mod domain;
|
||||||
pub mod role;
|
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(
|
fn authenticate_user(
|
||||||
userid: &Userid,
|
userid: &Userid,
|
||||||
password: &str,
|
password: &str,
|
||||||
path: Option<String>,
|
path: Option<String>,
|
||||||
privs: Option<String>,
|
privs: Option<String>,
|
||||||
port: Option<u16>,
|
port: Option<u16>,
|
||||||
) -> Result<bool, Error> {
|
tfa_challenge: Option<String>,
|
||||||
|
) -> Result<AuthResult, Error> {
|
||||||
let user_info = CachedUserInfo::new()?;
|
let user_info = CachedUserInfo::new()?;
|
||||||
|
|
||||||
let auth_id = Authid::from(userid.clone());
|
let auth_id = Authid::from(userid.clone());
|
||||||
@ -38,12 +51,16 @@ fn authenticate_user(
|
|||||||
bail!("user account disabled or expired.");
|
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 password.starts_with("PBS:") {
|
||||||
if let Ok(ticket_userid) = Ticket::<Userid>::parse(password)
|
if let Ok(ticket_userid) = Ticket::<Userid>::parse(password)
|
||||||
.and_then(|ticket| ticket.verify(public_auth_key(), "PBS", None))
|
.and_then(|ticket| ticket.verify(public_auth_key(), "PBS", None))
|
||||||
{
|
{
|
||||||
if *userid == ticket_userid {
|
if *userid == ticket_userid {
|
||||||
return Ok(true);
|
return Ok(AuthResult::CreateTicket);
|
||||||
}
|
}
|
||||||
bail!("ticket login failed - wrong userid");
|
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 path = path.ok_or_else(|| format_err!("missing path for termproxy ticket"))?;
|
||||||
let privilege_name = privs
|
let privilege_name =
|
||||||
.ok_or_else(|| format_err!("missing privilege name for termproxy ticket"))?;
|
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"))?;
|
let port = port.ok_or_else(|| format_err!("missing port for termproxy ticket"))?;
|
||||||
|
|
||||||
if let Ok(Empty) = Ticket::parse(password)
|
if let Ok(Empty) = Ticket::parse(password).and_then(|ticket| {
|
||||||
.and_then(|ticket| ticket.verify(
|
ticket.verify(
|
||||||
public_auth_key(),
|
public_auth_key(),
|
||||||
ticket::TERM_PREFIX,
|
ticket::TERM_PREFIX,
|
||||||
Some(&ticket::term_aad(userid, &path, port)),
|
Some(&ticket::term_aad(userid, &path, port)),
|
||||||
))
|
)
|
||||||
{
|
}) {
|
||||||
for (name, privilege) in PRIVILEGES {
|
for (name, privilege) in PRIVILEGES {
|
||||||
if *name == privilege_name {
|
if *name == privilege_name {
|
||||||
let mut path_vec = Vec::new();
|
let mut path_vec = Vec::new();
|
||||||
@ -73,7 +90,7 @@ fn authenticate_user(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
user_info.check_privs(&auth_id, &path_vec, *privilege, false)?;
|
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)?;
|
let _: () = crate::auth::authenticate_user(userid, password)?;
|
||||||
Ok(true)
|
|
||||||
|
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(
|
#[api(
|
||||||
@ -109,6 +144,11 @@ fn authenticate_user(
|
|||||||
description: "Port for verifying terminal tickets.",
|
description: "Port for verifying terminal tickets.",
|
||||||
optional: true,
|
optional: true,
|
||||||
},
|
},
|
||||||
|
"tfa-challenge": {
|
||||||
|
type: String,
|
||||||
|
description: "The signed TFA challenge string the user wants to respond to.",
|
||||||
|
optional: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
returns: {
|
returns: {
|
||||||
@ -141,15 +181,18 @@ fn create_ticket(
|
|||||||
path: Option<String>,
|
path: Option<String>,
|
||||||
privs: Option<String>,
|
privs: Option<String>,
|
||||||
port: Option<u16>,
|
port: Option<u16>,
|
||||||
|
tfa_challenge: Option<String>,
|
||||||
rpcenv: &mut dyn RpcEnvironment,
|
rpcenv: &mut dyn RpcEnvironment,
|
||||||
) -> Result<Value, Error> {
|
) -> Result<Value, Error> {
|
||||||
match authenticate_user(&username, &password, path, privs, port) {
|
match authenticate_user(&username, &password, path, privs, port, tfa_challenge) {
|
||||||
Ok(true) => {
|
Ok(AuthResult::Success) => Ok(json!({ "username": username })),
|
||||||
let ticket = Ticket::new("PBS", &username)?.sign(private_auth_key(), None)?;
|
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);
|
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!({
|
Ok(json!({
|
||||||
"username": username,
|
"username": username,
|
||||||
@ -157,9 +200,15 @@ fn create_ticket(
|
|||||||
"CSRFPreventionToken": token,
|
"CSRFPreventionToken": token,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
Ok(false) => Ok(json!({
|
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,
|
"username": username,
|
||||||
})),
|
"ticket": ticket,
|
||||||
|
}))
|
||||||
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
let client_ip = match rpcenv.get_client_ip().map(|addr| addr.ip()) {
|
let client_ip = match rpcenv.get_client_ip().map(|addr| addr.ip()) {
|
||||||
Some(ip) => format!("{}", ip),
|
Some(ip) => format!("{}", ip),
|
||||||
@ -219,12 +268,16 @@ fn change_password(
|
|||||||
|
|
||||||
let mut allowed = userid == *current_user;
|
let mut allowed = userid == *current_user;
|
||||||
|
|
||||||
if current_user == "root@pam" { allowed = true; }
|
if current_user == "root@pam" {
|
||||||
|
allowed = true;
|
||||||
|
}
|
||||||
|
|
||||||
if !allowed {
|
if !allowed {
|
||||||
let user_info = CachedUserInfo::new()?;
|
let user_info = CachedUserInfo::new()?;
|
||||||
let privs = user_info.lookup_privs(¤t_auth, &[]);
|
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 {
|
if !allowed {
|
||||||
@ -281,12 +334,13 @@ pub fn list_permissions(
|
|||||||
auth_id
|
auth_id
|
||||||
} else if auth_id.is_token()
|
} else if auth_id.is_token()
|
||||||
&& !current_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
|
auth_id
|
||||||
} else {
|
} else {
|
||||||
bail!("not allowed to list permissions of {}", auth_id);
|
bail!("not allowed to list permissions of {}", auth_id);
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
None => current_auth_id,
|
None => current_auth_id,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -296,11 +350,10 @@ pub fn list_permissions(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
fn populate_acl_paths(
|
fn populate_acl_paths(
|
||||||
mut paths: HashSet<String>,
|
mut paths: HashSet<String>,
|
||||||
node: acl_config::AclTreeNode,
|
node: acl_config::AclTreeNode,
|
||||||
path: &str
|
path: &str,
|
||||||
) -> HashSet<String> {
|
) -> HashSet<String> {
|
||||||
for (sub_path, child_node) in node.children {
|
for (sub_path, child_node) in node.children {
|
||||||
let sub_path = format!("{}/{}", path, &sub_path);
|
let sub_path = format!("{}/{}", path, &sub_path);
|
||||||
@ -315,7 +368,7 @@ pub fn list_permissions(
|
|||||||
let mut paths = HashSet::new();
|
let mut paths = HashSet::new();
|
||||||
paths.insert(path);
|
paths.insert(path);
|
||||||
paths
|
paths
|
||||||
},
|
}
|
||||||
None => {
|
None => {
|
||||||
let mut paths = HashSet::new();
|
let mut paths = HashSet::new();
|
||||||
|
|
||||||
@ -330,31 +383,35 @@ pub fn list_permissions(
|
|||||||
paths.insert("/system".to_string());
|
paths.insert("/system".to_string());
|
||||||
|
|
||||||
paths
|
paths
|
||||||
},
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let map = paths
|
let map = paths.into_iter().fold(
|
||||||
.into_iter()
|
HashMap::new(),
|
||||||
.fold(HashMap::new(), |mut map: HashMap<String, HashMap<String, bool>>, path: String| {
|
|mut map: HashMap<String, HashMap<String, bool>>, path: String| {
|
||||||
let split_path = acl_config::split_acl_path(path.as_str());
|
let split_path = acl_config::split_acl_path(path.as_str());
|
||||||
let (privs, propagated_privs) = user_info.lookup_privs_details(&auth_id, &split_path);
|
let (privs, propagated_privs) = user_info.lookup_privs_details(&auth_id, &split_path);
|
||||||
|
|
||||||
match privs {
|
match privs {
|
||||||
0 => map, // Don't leak ACL paths where we don't have any privileges
|
0 => map, // Don't leak ACL paths where we don't have any privileges
|
||||||
_ => {
|
_ => {
|
||||||
let priv_map = PRIVILEGES
|
let priv_map =
|
||||||
|
PRIVILEGES
|
||||||
.iter()
|
.iter()
|
||||||
.fold(HashMap::new(), |mut priv_map, (name, value)| {
|
.fold(HashMap::new(), |mut priv_map, (name, value)| {
|
||||||
if value & privs != 0 {
|
if value & privs != 0 {
|
||||||
priv_map.insert(name.to_string(), value & propagated_privs != 0);
|
priv_map
|
||||||
|
.insert(name.to_string(), value & propagated_privs != 0);
|
||||||
}
|
}
|
||||||
priv_map
|
priv_map
|
||||||
});
|
});
|
||||||
|
|
||||||
map.insert(path, priv_map);
|
map.insert(path, priv_map);
|
||||||
map
|
map
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}});
|
);
|
||||||
|
|
||||||
Ok(map)
|
Ok(map)
|
||||||
}
|
}
|
||||||
@ -362,21 +419,16 @@ pub fn list_permissions(
|
|||||||
#[sortable]
|
#[sortable]
|
||||||
const SUBDIRS: SubdirMap = &sorted!([
|
const SUBDIRS: SubdirMap = &sorted!([
|
||||||
("acl", &acl::ROUTER),
|
("acl", &acl::ROUTER),
|
||||||
|
("password", &Router::new().put(&API_METHOD_CHANGE_PASSWORD)),
|
||||||
(
|
(
|
||||||
"password", &Router::new()
|
"permissions",
|
||||||
.put(&API_METHOD_CHANGE_PASSWORD)
|
&Router::new().get(&API_METHOD_LIST_PERMISSIONS)
|
||||||
),
|
|
||||||
(
|
|
||||||
"permissions", &Router::new()
|
|
||||||
.get(&API_METHOD_LIST_PERMISSIONS)
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"ticket", &Router::new()
|
|
||||||
.post(&API_METHOD_CREATE_TICKET)
|
|
||||||
),
|
),
|
||||||
|
("ticket", &Router::new().post(&API_METHOD_CREATE_TICKET)),
|
||||||
("domains", &domain::ROUTER),
|
("domains", &domain::ROUTER),
|
||||||
("roles", &role::ROUTER),
|
("roles", &role::ROUTER),
|
||||||
("users", &user::ROUTER),
|
("users", &user::ROUTER),
|
||||||
|
("tfa", &tfa::ROUTER),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
pub const ROUTER: Router = Router::new()
|
pub const ROUTER: Router = Router::new()
|
||||||
|
575
src/api2/access/tfa.rs
Normal file
575
src/api2/access/tfa.rs
Normal file
@ -0,0 +1,575 @@
|
|||||||
|
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.
|
||||||
|
/// Otherwise check the current user's password.
|
||||||
|
///
|
||||||
|
/// This means that user admins need to type in their own password while editing a user, and
|
||||||
|
/// regular users, which can only change their own TFA settings (checked at the API level), can
|
||||||
|
/// change their own settings using their own password.
|
||||||
|
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() {
|
||||||
|
let password = password.ok_or_else(|| format_err!("missing password"))?;
|
||||||
|
let _: () = crate::auth::authenticate_user(authid.user(), &password)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// After authentication, verify that the to-be-modified user actually exists:
|
||||||
|
if authid.user() != userid {
|
||||||
|
let (config, _digest) = crate::config::user::config()?;
|
||||||
|
|
||||||
|
if config.sections.get(userid.as_str()).is_none() {
|
||||||
|
bail!("user '{}' does not exists.", userid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.has_recovery() { 1 } else { 0 },
|
||||||
|
);
|
||||||
|
if data.has_recovery() {
|
||||||
|
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.has_recovery() {
|
||||||
|
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.has_recovery();
|
||||||
|
user_data.recovery = None;
|
||||||
|
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 user this entry belongs to.
|
||||||
|
userid: Userid,
|
||||||
|
|
||||||
|
/// TFA entries.
|
||||||
|
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": { 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);
|
File diff suppressed because it is too large
Load Diff
@ -87,3 +87,5 @@ pub use email_notifications::*;
|
|||||||
|
|
||||||
mod report;
|
mod report;
|
||||||
pub use report::*;
|
pub use report::*;
|
||||||
|
|
||||||
|
pub mod ticket;
|
||||||
|
@ -600,8 +600,9 @@ fn check_auth(
|
|||||||
let ticket = user_auth_data.ticket.clone();
|
let ticket = user_auth_data.ticket.clone();
|
||||||
let ticket_lifetime = tools::ticket::TICKET_LIFETIME;
|
let ticket_lifetime = tools::ticket::TICKET_LIFETIME;
|
||||||
|
|
||||||
let userid: Userid = Ticket::parse(&ticket)?
|
let userid: Userid = Ticket::<super::ticket::ApiTicket>::parse(&ticket)?
|
||||||
.verify_with_time_frame(public_auth_key(), "PBS", None, -300..ticket_lifetime)?;
|
.verify_with_time_frame(public_auth_key(), "PBS", None, -300..ticket_lifetime)?
|
||||||
|
.require_full()?;
|
||||||
|
|
||||||
let auth_id = Authid::from(userid.clone());
|
let auth_id = Authid::from(userid.clone());
|
||||||
if !user_info.is_active_auth_id(&auth_id) {
|
if !user_info.is_active_auth_id(&auth_id) {
|
||||||
|
77
src/server/ticket.rs
Normal file
77
src/server/ticket.rs
Normal file
@ -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()?))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user