[pmg-devel] [PATCH api 3/6] add TFA API

Stoiko Ivanov s.ivanov at proxmox.com
Fri Nov 26 18:29:57 CET 2021


two small notes inline:


On Fri, 26 Nov 2021 14:55:07 +0100
Wolfgang Bumiller <w.bumiller at proxmox.com> wrote:

> Signed-off-by: Wolfgang Bumiller <w.bumiller at proxmox.com>
> ---
>  src/Makefile                  |   1 +
>  src/PMG/API2/AccessControl.pm |   6 +
>  src/PMG/API2/TFA.pm           | 462 ++++++++++++++++++++++++++++++++++
>  3 files changed, 469 insertions(+)
>  create mode 100644 src/PMG/API2/TFA.pm
> 
> diff --git a/src/Makefile b/src/Makefile
> index de05aa0..c2bf2c9 100644
> --- a/src/Makefile
> +++ b/src/Makefile
> @@ -148,6 +148,7 @@ LIBSOURCES =				\
>  	PMG/API2/Postfix.pm		\
>  	PMG/API2/Quarantine.pm		\
>  	PMG/API2/AccessControl.pm	\
> +	PMG/API2/TFA.pm			\
>  	PMG/API2/ObjectGroupHelpers.pm	\
>  	PMG/API2/Rules.pm		\
>  	PMG/API2/RuleDB.pm		\
> diff --git a/src/PMG/API2/AccessControl.pm b/src/PMG/API2/AccessControl.pm
> index cb8daa5..942f8dc 100644
> --- a/src/PMG/API2/AccessControl.pm
> +++ b/src/PMG/API2/AccessControl.pm
> @@ -13,6 +13,7 @@ use PMG::Utils;
>  use PMG::UserConfig;
>  use PMG::AccessControl;
>  use PMG::API2::Users;
> +use PMG::API2::TFA;
>  
>  use Data::Dumper;
>  
> @@ -23,6 +24,11 @@ __PACKAGE__->register_method ({
>      path => 'users',
>  });
>  
> +__PACKAGE__->register_method ({
> +    subclass => "PMG::API2::TFA",
> +    path => 'tfa',
> +});
> +
>  __PACKAGE__->register_method ({
>      name => 'index',
>      path => '',
> diff --git a/src/PMG/API2/TFA.pm b/src/PMG/API2/TFA.pm
> new file mode 100644
> index 0000000..626d4f8
> --- /dev/null
> +++ b/src/PMG/API2/TFA.pm
> @@ -0,0 +1,462 @@
> +package PMG::API2::TFA;
> +
> +use strict;
> +use warnings;
> +
> +use HTTP::Status qw(:constants);
> +
> +use PVE::Exception qw(raise raise_perm_exc raise_param_exc);
> +use PVE::JSONSchema qw(get_standard_option);
> +use PVE::RESTHandler;
> +
> +use PMG::AccessControl;
> +use PMG::RESTEnvironment;
> +use PMG::TFAConfig;
> +use PMG::UserConfig;
> +use PMG::Utils;
> +
> +use base qw(PVE::RESTHandler);
> +
> +my $OPTIONAL_PASSWORD_SCHEMA = {
> +    description => "The current password.",
> +    type => 'string',
> +    optional => 1, # Only required if not root at pam
> +    minLength => 5,
> +    maxLength => 64
> +};
> +
> +my $TFA_TYPE_SCHEMA = {
> +    type => 'string',
> +    description => 'TFA Entry Type.',
> +    enum => [qw(totp u2f webauthn recovery)],
> +};
> +
> +my %TFA_INFO_PROPERTIES = (
> +    id => {
> +	type => 'string',
> +	description => 'The id used to reference this entry.',
> +    },
> +    description => {
> +	type => 'string',
> +	description => 'User chosen description for this entry.',
> +    },
> +    created => {
> +	type => 'integer',
> +	description => 'Creation time of this entry as unix epoch.',
> +    },
> +    enable => {
> +	type => 'boolean',
> +	description => 'Whether this TFA entry is currently enabled.',
> +	optional => 1,
> +	default => 1,
> +    },
> +);
> +
> +my $TYPED_TFA_ENTRY_SCHEMA = {
> +    type => 'object',
> +    description => 'TFA Entry.',
> +    properties => {
> +	type => $TFA_TYPE_SCHEMA,
> +	%TFA_INFO_PROPERTIES,
> +    },
> +};
> +
> +my $TFA_ID_SCHEMA = {
> +    type => 'string',
> +    description => 'A TFA entry id.',
> +};
> +
> +my $TFA_UPDATE_INFO_SCHEMA = {
> +    type => 'object',
> +    properties => {
> +	id => {
> +	    type => 'string',
> +	    description => 'The id of a newly added TFA entry.',
> +	},
> +	challenge => {
> +	    type => 'string',
> +	    optional => 1,
> +	    description =>
> +		'When adding u2f entries, this contains a challenge the user must respond to in'
> +		.' order to finish the registration.'
> +	},
> +	recovery => {
> +	    type => 'array',
> +	    optional => 1,
> +	    description =>
> +		'When adding recovery codes, this contains the list of codes to be displayed to'
> +		.' the user',
> +	    items => {
> +		type => 'string',
> +		description => 'A recovery entry.'
> +	    },
> +	},
> +    },
> +};
> +
> +# Set TFA to enabled if $tfa_cfg is passed, or to disabled if $tfa_cfg is undef,
> +# When enabling we also merge the old user.cfg keys into the $tfa_cfg.
> +my sub set_user_tfa_enabled : prototype($$$) {
> +    my ($userid, $realm, $tfa_cfg) = @_;
> +
> +    PMG::UserConfig::lock_config(sub {
> +	my $cfg = PMG::UserConfig->new();
> +	my $user = $cfg->lookup_user_data($userid);
> +
> +	# We had the 'keys' property available in PMG for a while, but never used it.
> +	# If the keys property had been used by someone, let's just error out here.
Wasn't aware of the 'keys' property until now - but just so that it's not
forgotten - we should remove it from the User Add/Edit window as well



> +	my $keys = $user->{keys};
> +	die "user has an unsupported 'keys' value, please remove\n"
> +	    if defined($keys) && $keys ne 'x';
> +
> +	$user->{keys} = $tfa_cfg ? 'x' : undef;
> +
> +	$cfg->write();
> +    }, "enabling/disabling TFA for the user failed");
> +}
> +
> +# Only root may modify root, regular users need to specify their password.
> +#
> +# Returns the userid returned from `verify_username`.
> +# Or ($userid, $realm) in list context.
> +my sub check_permission_password : prototype($$$$) {
> +    my ($rpcenv, $authuser, $userid, $password) = @_;
> +
> +    ($userid, my $ruid, my $realm) = PMG::Utils::verify_username($userid);
> +    raise("no access from quarantine\n") if $realm eq 'quarantine';
> +
> +    raise_perm_exc() if $userid eq 'root at pam' && $authuser ne 'root at pam';
> +
> +    # Regular users need to confirm their password to change TFA settings.
> +    if ($authuser ne 'root at pam') {
> +	raise_param_exc({ 'password' => 'password is required to modify TFA data' })
> +	    if !defined($password);
> +
> +	PMG::AccessControl::authenticate_user($userid, $password);
> +    }
> +
> +    return wantarray ? ($userid, $realm) : $userid;
> +}
> +
> +my sub check_permission_self : prototype($$) {
> +    my ($rpcenv, $userid) = @_;
> +
> +    my $authuser = $rpcenv->get_user();
> +
> +    ($userid, my $ruid, my $realm) = PMG::Utils::verify_username($userid);
> +    raise("no access from quarantine\n") if $realm eq 'quarantine';
> +
> +    if ($authuser eq 'root at pam') {
> +	# OK - root can change anything
> +    } else {
> +	if ($realm eq 'pmg' && $authuser eq $userid) {
> +	    # OK - each enable user can see their own data
> +	    PMG::AccessControl::check_user_enabled($rpcenv->{usercfg}, $userid);
> +	} else {
> +	    raise_perm_exc();
> +	}
> +    }
> +}
> +
> +__PACKAGE__->register_method ({
> +    name => 'list_user_tfa',
> +    path => '{userid}',
> +    method => 'GET',
> +    proxyto => 'master',
> +    permissions => {
> +	description => 
> +	    'Each user is allowed to view their own TFA entries.'
> +	    .' Only root can view entries of another user.',
> +	user => 'all',
> +    },
most other API methods use
`permissions => { check => [ 'admin', 'qmanager', 'audit'] },`
to exclude quarantine users from it
(but this can easily be fixed at a later point as well (not necessarily by
you), and is not material to the series)



> +    protected => 1, # else we can't access shadow files
> +    allowtoken => 0, # we don't want tokens to change the regular user's TFA settings
> +    description => 'List TFA configurations of users.',
> +    parameters => {
> +	additionalProperties => 0,
> +	properties => {
> +	    userid => get_standard_option('userid'),
> +	}
> +    },
> +    returns => {
> +	description => "A list of the user's TFA entries.",
> +	type => 'array',
> +	items => $TYPED_TFA_ENTRY_SCHEMA,
> +    },
> +    code => sub {
> +	my ($param) = @_;
> +
> +	my $rpcenv = PMG::RESTEnvironment->get();
> +	check_permission_self($rpcenv, $param->{userid});
> +
> +	my $tfa_cfg = PMG::TFAConfig->new();
> +	return $tfa_cfg->api_list_user_tfa($param->{userid});
> +    }});
> +
> +__PACKAGE__->register_method ({
> +    name => 'get_tfa_entry',
> +    path => '{userid}/{id}',
> +    method => 'GET',
> +    proxyto => 'master',
> +    permissions => {
> +	description => 
> +	    'Each user is allowed to view their own TFA entries.'
> +	    .' Only root can view entries of another user.',
> +	user => 'all',
> +    },
> +    protected => 1, # else we can't access shadow files
> +    allowtoken => 0, # we don't want tokens to change the regular user's TFA settings
> +    description => 'Fetch a requested TFA entry if present.',
> +    parameters => {
> +	additionalProperties => 0,
> +	properties => {
> +	    userid => get_standard_option('userid'),
> +	    id => $TFA_ID_SCHEMA,
> +	}
> +    },
> +    returns => $TYPED_TFA_ENTRY_SCHEMA,
> +    code => sub {
> +	my ($param) = @_;
> +
> +	my $rpcenv = PMG::RESTEnvironment->get();
> +	check_permission_self($rpcenv, $param->{userid});
> +
> +	my $tfa_cfg = PMG::TFAConfig->new();
> +	my $id = $param->{id};
> +	my $entry = $tfa_cfg->api_get_tfa_entry($param->{userid}, $id);
> +	raise("No such tfa entry '$id'", code => HTTP::Status::HTTP_NOT_FOUND) if !$entry;
> +	return $entry;
> +    }});
> +
> +__PACKAGE__->register_method ({
> +    name => 'delete_tfa',
> +    path => '{userid}/{id}',
> +    method => 'DELETE',
> +    proxyto => 'master',
> +    permissions => {
> +	description => 
> +	    'Each user is allowed to modify their own TFA entries.'
> +	    .' Only root can modify entries of another user.',
> +	user => 'all',
> +    },
> +    protected => 1, # else we can't access shadow files
> +    allowtoken => 0, # we don't want tokens to change the regular user's TFA settings
> +    description => 'Delete a TFA entry by ID.',
> +    parameters => {
> +	additionalProperties => 0,
> +	properties => {
> +	    userid => get_standard_option('userid'),
> +	    id => $TFA_ID_SCHEMA,
> +	    password => $OPTIONAL_PASSWORD_SCHEMA,
> +	}
> +    },
> +    returns => { type => 'null' },
> +    code => sub {
> +	my ($param) = @_;
> +
> +	my $rpcenv = PMG::RESTEnvironment->get();
> +	check_permission_self($rpcenv, $param->{userid});
> +
> +	my $authuser = $rpcenv->get_user();
> +	my $userid =
> +	    check_permission_password($rpcenv, $authuser, $param->{userid}, $param->{password});
> +
> +	my $has_entries_left = PMG::TFAConfig::lock_config(sub {
> +	    my $tfa_cfg = PMG::TFAConfig->new();
> +	    my $has_entries_left = $tfa_cfg->api_delete_tfa($userid, $param->{id});
> +	    $tfa_cfg->write();
> +	    return $has_entries_left;
> +	});
> +
> +	if (!$has_entries_left) {
> +	    set_user_tfa_enabled($userid, undef, undef);
> +	}
> +    }});
> +
> +__PACKAGE__->register_method ({
> +    name => 'list_tfa',
> +    path => '',
> +    method => 'GET',
> +    proxyto => 'master',
> +    permissions => {
> +	description => "Returns all or just the logged-in user, depending on privileges.",
> +	user => 'all',
> +    },
> +    protected => 1, # else we can't access shadow files
> +    allowtoken => 0, # we don't want tokens to change the regular user's TFA settings
> +    description => 'List TFA configurations of users.',
> +    parameters => {
> +	additionalProperties => 0,
> +	properties => {}
> +    },
> +    returns => {
> +	description => "The list tuples of user and TFA entries.",
> +	type => 'array',
> +	items => {
> +	    type => 'object',
> +	    properties => {
> +		userid => {
> +		    type => 'string',
> +		    description => 'User this entry belongs to.',
> +		},
> +		entries => {
> +		    type => 'array',
> +		    items => $TYPED_TFA_ENTRY_SCHEMA,
> +		},
> +	    },
> +	},
> +    },
> +    code => sub {
> +	my ($param) = @_;
> +
> +	my $rpcenv = PMG::RESTEnvironment->get();
> +	my $authuser = $rpcenv->get_user();
> +	my $top_level_allowed = ($authuser eq 'root at pam');
> +
> +	my $tfa_cfg = PMG::TFAConfig->new();
> +	return $tfa_cfg->api_list_tfa($authuser, $top_level_allowed);
> +    }});
> +
> +__PACKAGE__->register_method ({
> +    name => 'add_tfa_entry',
> +    path => '{userid}',
> +    method => 'POST',
> +    proxyto => 'master',
> +    permissions => {
> +	description => 
> +	    'Each user is allowed to modify their own TFA entries.'
> +	    .' Only root can modify entries of another user.',
> +	user => 'all',
> +    },
> +    protected => 1, # else we can't access shadow files
> +    allowtoken => 0, # we don't want tokens to change the regular user's TFA settings
> +    description => 'Add a TFA entry for a user.',
> +    parameters => {
> +	additionalProperties => 0,
> +	properties => {
> +	    userid => get_standard_option('userid'),
> +            type => $TFA_TYPE_SCHEMA,
> +	    description => {
> +		type => 'string',
> +		description => 'A description to distinguish multiple entries from one another',
> +		maxLength => 255,
> +		optional => 1,
> +	    },
> +	    totp => {
> +		type => 'string',
> +		description => "A totp URI.",
> +		optional => 1,
> +	    },
> +	    value => {
> +		type => 'string',
> +		description =>
> +		    'The current value for the provided totp URI, or a Webauthn/U2F'
> +		    .' challenge response',
> +		optional => 1,
> +	    },
> +	    challenge => {
> +		type => 'string',
> +		description => 'When responding to a u2f challenge: the original challenge string',
> +		optional => 1,
> +	    },
> +	    password => $OPTIONAL_PASSWORD_SCHEMA,
> +	},
> +    },
> +    returns => $TFA_UPDATE_INFO_SCHEMA,
> +    code => sub {
> +	my ($param) = @_;
> +
> +	my $rpcenv = PMG::RESTEnvironment->get();
> +	check_permission_self($rpcenv, $param->{userid});
> +	my $authuser = $rpcenv->get_user();
> +	my ($userid, $realm) =
> +	    check_permission_password($rpcenv, $authuser, $param->{userid}, $param->{password});
> +
> +	my $type = delete $param->{type};
> +	my $value = delete $param->{value};
> +
> +	return PMG::TFAConfig::lock_config(sub {
> +	    my $tfa_cfg = PMG::TFAConfig->new();
> +
> +	    set_user_tfa_enabled($userid, $realm, $tfa_cfg);
> +	    my $origin = undef;
> +	    if (!$tfa_cfg->has_webauthn_origin()) {
> +		$origin = $rpcenv->get_request_host(1);
> +	    }
> +
> +	    my $response = $tfa_cfg->api_add_tfa_entry(
> +		$userid,
> +		$param->{description},
> +		$param->{totp},
> +		$value,
> +		$param->{challenge},
> +		$type,
> +		$origin,
> +	    );
> +
> +	    $tfa_cfg->write();
> +
> +	    return $response;
> +	});
> +    }});
> +
> +__PACKAGE__->register_method ({
> +    name => 'update_tfa_entry',
> +    path => '{userid}/{id}',
> +    method => 'PUT',
> +    proxyto => 'master',
> +    permissions => {
> +	description => 
> +	    'Each user is allowed to modify their own TFA entries.'
> +	    .' Only root can modify entries of another user.',
> +	user => 'all',
> +    },
> +    protected => 1, # else we can't access shadow files
> +    allowtoken => 0, # we don't want tokens to change the regular user's TFA settings
> +    description => 'Add a TFA entry for a user.',
> +    parameters => {
> +	additionalProperties => 0,
> +	properties => {
> +	    userid => get_standard_option('userid', {
> +		completion => \&PVE::AccessControl::complete_username,
> +	    }),
> +	    id => $TFA_ID_SCHEMA,
> +	    description => {
> +		type => 'string',
> +		description => 'A description to distinguish multiple entries from one another',
> +		maxLength => 255,
> +		optional => 1,
> +	    },
> +	    enable => {
> +		type => 'boolean',
> +		description => 'Whether the entry should be enabled for login.',
> +		optional => 1,
> +	    },
> +	    password => $OPTIONAL_PASSWORD_SCHEMA,
> +	},
> +    },
> +    returns => { type => 'null' },
> +    code => sub {
> +	my ($param) = @_;
> +
> +	my $rpcenv = PMG::RESTEnvironment->get();
> +	check_permission_self($rpcenv, $param->{userid});
> +	my $authuser = $rpcenv->get_user();
> +	my $userid =
> +	    check_permission_password($rpcenv, $authuser, $param->{userid}, $param->{password});
> +
> +	PMG::TFAConfig::lock_config(sub {
> +	    my $tfa_cfg = PMG::TFAConfig->new();
> +
> +	    $tfa_cfg->api_update_tfa_entry(
> +		$userid,
> +		$param->{id},
> +		$param->{description},
> +		$param->{enable},
> +	    );
> +
> +	    $tfa_cfg->write();
> +	});
> +    }});
> +
> +1;





More information about the pmg-devel mailing list