6248e51797
Signed-off-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
435 lines
13 KiB
Rust
435 lines
13 KiB
Rust
//! Access control (Users, Permissions and Authentication)
|
|
|
|
use anyhow::{bail, format_err, Error};
|
|
|
|
use serde_json::{json, Value};
|
|
use std::collections::HashMap;
|
|
use std::collections::HashSet;
|
|
|
|
use proxmox::api::router::{Router, SubdirMap};
|
|
use proxmox::api::{api, Permission, RpcEnvironment};
|
|
use proxmox::{http_err, list_subdirs_api_method};
|
|
use proxmox::{identity, sortable};
|
|
|
|
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_PERMISSIONS_MODIFY, PRIV_SYS_AUDIT};
|
|
use crate::config::cached_user_info::CachedUserInfo;
|
|
use crate::config::tfa::TfaChallenge;
|
|
|
|
pub mod acl;
|
|
pub mod domain;
|
|
pub mod role;
|
|
pub mod tfa;
|
|
pub mod user;
|
|
|
|
#[allow(clippy::large_enum_variant)]
|
|
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),
|
|
}
|
|
|
|
fn authenticate_user(
|
|
userid: &Userid,
|
|
password: &str,
|
|
path: Option<String>,
|
|
privs: Option<String>,
|
|
port: Option<u16>,
|
|
tfa_challenge: Option<String>,
|
|
) -> Result<AuthResult, Error> {
|
|
let user_info = CachedUserInfo::new()?;
|
|
|
|
let auth_id = Authid::from(userid.clone());
|
|
if !user_info.is_active_auth_id(&auth_id) {
|
|
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(AuthResult::CreateTicket);
|
|
}
|
|
bail!("ticket login failed - wrong userid");
|
|
}
|
|
} else if password.starts_with("PBSTERM:") {
|
|
if path.is_none() || privs.is_none() || port.is_none() {
|
|
bail!("cannot check termnal ticket without path, priv and port");
|
|
}
|
|
|
|
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 port = port.ok_or_else(|| format_err!("missing port for termproxy ticket"))?;
|
|
|
|
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();
|
|
for part in path.split('/') {
|
|
if part != "" {
|
|
path_vec.push(part);
|
|
}
|
|
}
|
|
user_info.check_privs(&auth_id, &path_vec, *privilege, false)?;
|
|
return Ok(AuthResult::Success);
|
|
}
|
|
}
|
|
|
|
bail!("No such privilege");
|
|
}
|
|
}
|
|
|
|
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()), -60..600)?
|
|
.require_partial()?;
|
|
|
|
let _: () = crate::config::tfa::verify_challenge(userid, &challenge, response.parse()?)?;
|
|
|
|
Ok(AuthResult::CreateTicket)
|
|
}
|
|
|
|
#[api(
|
|
input: {
|
|
properties: {
|
|
username: {
|
|
type: Userid,
|
|
},
|
|
password: {
|
|
schema: PASSWORD_SCHEMA,
|
|
},
|
|
path: {
|
|
type: String,
|
|
description: "Path for verifying terminal tickets.",
|
|
optional: true,
|
|
},
|
|
privs: {
|
|
type: String,
|
|
description: "Privilege for verifying terminal tickets.",
|
|
optional: true,
|
|
},
|
|
port: {
|
|
type: Integer,
|
|
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: {
|
|
properties: {
|
|
username: {
|
|
type: String,
|
|
description: "User name.",
|
|
},
|
|
ticket: {
|
|
type: String,
|
|
description: "Auth ticket.",
|
|
},
|
|
CSRFPreventionToken: {
|
|
type: String,
|
|
description:
|
|
"Cross Site Request Forgery Prevention Token. \
|
|
For partial tickets this is the string \"invalid\".",
|
|
},
|
|
},
|
|
},
|
|
protected: true,
|
|
access: {
|
|
permission: &Permission::World,
|
|
},
|
|
)]
|
|
/// Create or verify authentication ticket.
|
|
///
|
|
/// Returns: An authentication ticket with additional infos.
|
|
pub fn create_ticket(
|
|
username: Userid,
|
|
password: String,
|
|
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, 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));
|
|
|
|
Ok(json!({
|
|
"username": username,
|
|
"ticket": ticket,
|
|
"CSRFPreventionToken": token,
|
|
}))
|
|
}
|
|
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,
|
|
"CSRFPreventionToken": "invalid",
|
|
}))
|
|
}
|
|
Err(err) => {
|
|
let client_ip = match rpcenv.get_client_ip().map(|addr| addr.ip()) {
|
|
Some(ip) => format!("{}", ip),
|
|
None => "unknown".into(),
|
|
};
|
|
|
|
let msg = format!(
|
|
"authentication failure; rhost={} user={} msg={}",
|
|
client_ip,
|
|
username,
|
|
err.to_string()
|
|
);
|
|
crate::server::rest::auth_logger()?.log(&msg);
|
|
log::error!("{}", msg);
|
|
|
|
Err(http_err!(UNAUTHORIZED, "permission check failed."))
|
|
}
|
|
}
|
|
}
|
|
|
|
#[api(
|
|
protected: true,
|
|
input: {
|
|
properties: {
|
|
userid: {
|
|
type: Userid,
|
|
},
|
|
password: {
|
|
schema: PASSWORD_SCHEMA,
|
|
},
|
|
},
|
|
},
|
|
access: {
|
|
description: "Everybody is allowed to change their own password. In addition, users with 'Permissions:Modify' privilege may change any password on @pbs realm.",
|
|
permission: &Permission::Anybody,
|
|
},
|
|
)]
|
|
/// Change user password
|
|
///
|
|
/// Each user is allowed to change his own password. Superuser
|
|
/// can change all passwords.
|
|
pub fn change_password(
|
|
userid: Userid,
|
|
password: String,
|
|
rpcenv: &mut dyn RpcEnvironment,
|
|
) -> Result<Value, Error> {
|
|
let current_auth: Authid = rpcenv
|
|
.get_auth_id()
|
|
.ok_or_else(|| format_err!("no authid available"))?
|
|
.parse()?;
|
|
|
|
if current_auth.is_token() {
|
|
bail!("API tokens cannot access this API endpoint");
|
|
}
|
|
|
|
let current_user = current_auth.user();
|
|
|
|
let mut allowed = userid == *current_user;
|
|
|
|
if !allowed {
|
|
let user_info = CachedUserInfo::new()?;
|
|
let privs = user_info.lookup_privs(¤t_auth, &[]);
|
|
if user_info.is_superuser(¤t_auth) {
|
|
allowed = true;
|
|
}
|
|
if (privs & PRIV_PERMISSIONS_MODIFY) != 0 && userid.realm() != "pam" {
|
|
allowed = true;
|
|
}
|
|
};
|
|
|
|
if !allowed {
|
|
bail!("you are not authorized to change the password.");
|
|
}
|
|
|
|
let authenticator = crate::auth::lookup_authenticator(userid.realm())?;
|
|
authenticator.store_password(userid.name(), &password)?;
|
|
|
|
Ok(Value::Null)
|
|
}
|
|
|
|
#[api(
|
|
input: {
|
|
properties: {
|
|
"auth-id": {
|
|
type: Authid,
|
|
optional: true,
|
|
},
|
|
path: {
|
|
schema: ACL_PATH_SCHEMA,
|
|
optional: true,
|
|
},
|
|
},
|
|
},
|
|
access: {
|
|
permission: &Permission::Anybody,
|
|
description: "Requires Sys.Audit on '/access', limited to own privileges otherwise.",
|
|
},
|
|
returns: {
|
|
description: "Map of ACL path to Map of privilege to propagate bit",
|
|
type: Object,
|
|
properties: {},
|
|
additional_properties: true,
|
|
},
|
|
)]
|
|
/// List permissions of given or currently authenticated user / API token.
|
|
///
|
|
/// Optionally limited to specific path.
|
|
pub fn list_permissions(
|
|
auth_id: Option<Authid>,
|
|
path: Option<String>,
|
|
rpcenv: &dyn RpcEnvironment,
|
|
) -> Result<HashMap<String, HashMap<String, bool>>, Error> {
|
|
let current_auth_id: Authid = rpcenv.get_auth_id().unwrap().parse()?;
|
|
|
|
let user_info = CachedUserInfo::new()?;
|
|
let user_privs = user_info.lookup_privs(¤t_auth_id, &["access"]);
|
|
|
|
let auth_id = match auth_id {
|
|
Some(auth_id) if auth_id == current_auth_id => current_auth_id,
|
|
Some(auth_id) => {
|
|
if user_privs & PRIV_SYS_AUDIT != 0
|
|
|| (auth_id.is_token()
|
|
&& !current_auth_id.is_token()
|
|
&& auth_id.user() == current_auth_id.user())
|
|
{
|
|
auth_id
|
|
} else {
|
|
bail!("not allowed to list permissions of {}", auth_id);
|
|
}
|
|
},
|
|
None => current_auth_id,
|
|
};
|
|
|
|
fn populate_acl_paths(
|
|
mut paths: HashSet<String>,
|
|
node: acl_config::AclTreeNode,
|
|
path: &str,
|
|
) -> HashSet<String> {
|
|
for (sub_path, child_node) in node.children {
|
|
let sub_path = format!("{}/{}", path, &sub_path);
|
|
paths = populate_acl_paths(paths, child_node, &sub_path);
|
|
paths.insert(sub_path);
|
|
}
|
|
paths
|
|
}
|
|
|
|
let paths = match path {
|
|
Some(path) => {
|
|
let mut paths = HashSet::new();
|
|
paths.insert(path);
|
|
paths
|
|
}
|
|
None => {
|
|
let mut paths = HashSet::new();
|
|
|
|
let (acl_tree, _) = acl_config::config()?;
|
|
paths = populate_acl_paths(paths, acl_tree.root, "");
|
|
|
|
// default paths, returned even if no ACL exists
|
|
paths.insert("/".to_string());
|
|
paths.insert("/access".to_string());
|
|
paths.insert("/datastore".to_string());
|
|
paths.insert("/remote".to_string());
|
|
paths.insert("/system".to_string());
|
|
|
|
paths
|
|
}
|
|
};
|
|
|
|
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
|
|
});
|
|
|
|
map.insert(path, priv_map);
|
|
map
|
|
}
|
|
}
|
|
},
|
|
);
|
|
|
|
Ok(map)
|
|
}
|
|
|
|
#[sortable]
|
|
const SUBDIRS: SubdirMap = &sorted!([
|
|
("acl", &acl::ROUTER),
|
|
("password", &Router::new().put(&API_METHOD_CHANGE_PASSWORD)),
|
|
(
|
|
"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()
|
|
.get(&list_subdirs_api_method!(SUBDIRS))
|
|
.subdirs(SUBDIRS);
|