1 package PVE
::API2
::TFA
;
6 use HTTP
::Status
qw(:constants);
8 use PVE
::AccessControl
;
9 use PVE
::Cluster
qw(cfs_read_file cfs_write_file);
10 use PVE
::Exception
qw(raise raise_perm_exc raise_param_exc);
11 use PVE
::JSONSchema
qw(get_standard_option);
12 use PVE
::RPCEnvironment
;
15 use PVE
::API2
::AccessControl
; # for old login api get_u2f_instance method
19 use base
qw(PVE::RESTHandler);
21 my $OPTIONAL_PASSWORD_SCHEMA = {
22 description
=> "The current password.",
24 optional
=> 1, # Only required if not root@pam
29 my $TFA_TYPE_SCHEMA = {
31 description
=> 'TFA Entry Type.',
32 enum
=> [qw(totp u2f webauthn recovery yubico)],
35 my %TFA_INFO_PROPERTIES = (
38 description
=> 'The id used to reference this entry.',
42 description
=> 'User chosen description for this entry.',
46 description
=> 'Creation time of this entry as unix epoch.',
50 description
=> 'Whether this TFA entry is currently enabled.',
56 my $TYPED_TFA_ENTRY_SCHEMA = {
58 description
=> 'TFA Entry.',
60 type
=> $TFA_TYPE_SCHEMA,
67 description
=> 'A TFA entry id.',
70 my $TFA_UPDATE_INFO_SCHEMA = {
75 description
=> 'The id of a newly added TFA entry.',
81 'When adding u2f entries, this contains a challenge the user must respond to in order'
82 .' to finish the registration.'
88 'When adding recovery codes, this contains the list of codes to be displayed to'
92 description
=> 'A recovery entry.'
98 # Only root may modify root, regular users need to specify their password.
100 # Returns the userid returned from `verify_username`.
101 # Or ($userid, $realm) in list context.
102 my sub root_permission_check
: prototype($$$$) {
103 my ($rpcenv, $authuser, $userid, $password) = @_;
105 ($userid, undef, my $realm) = PVE
::AccessControl
::verify_username
($userid);
106 $rpcenv->check_user_exist($userid);
108 raise_perm_exc
() if $userid eq 'root@pam' && $authuser ne 'root@pam';
110 # Regular users need to confirm their password to change TFA settings.
111 if ($authuser ne 'root@pam') {
112 raise_param_exc
({ 'password' => 'password is required to modify TFA data' })
113 if !defined($password);
115 ($authuser, my $auth_username, my $auth_realm) =
116 PVE
::AccessControl
::verify_username
($authuser);
118 my $domain_cfg = cfs_read_file
('domains.cfg');
119 my $cfg = $domain_cfg->{ids
}->{$auth_realm};
120 die "auth domain '$auth_realm' does not exist\n" if !$cfg;
121 my $plugin = PVE
::Auth
::Plugin-
>lookup($cfg->{type
});
122 $plugin->authenticate_user($cfg, $auth_realm, $auth_username, $password);
125 return wantarray ?
($userid, $realm) : $userid;
128 # Set TFA to enabled if $tfa_cfg is passed, or to disabled if $tfa_cfg is undef,
129 # When enabling we also merge the old user.cfg keys into the $tfa_cfg.
130 my sub set_user_tfa_enabled
: prototype($$$) {
131 my ($userid, $realm, $tfa_cfg) = @_;
133 PVE
::AccessControl
::lock_user_config
(sub {
134 my $user_cfg = cfs_read_file
('user.cfg');
135 my $user = $user_cfg->{users
}->{$userid};
136 my $keys = $user->{keys};
137 # When enabling, we convert old-old keys,
138 # When disabling, we shouldn't actually have old keys anymore, so if they are there,
139 # they'll be removed.
140 if ($tfa_cfg && $keys && $keys !~ /^x(?:!.*)?$/) {
141 my $domain_cfg = cfs_read_file
('domains.cfg');
142 my $realm_cfg = $domain_cfg->{ids
}->{$realm};
143 die "auth domain '$realm' does not exist\n" if !$realm_cfg;
145 my $realm_tfa = $realm_cfg->{tfa
};
146 $realm_tfa = PVE
::Auth
::Plugin
::parse_tfa_config
($realm_tfa) if $realm_tfa;
148 PVE
::AccessControl
::add_old_keys_to_realm_tfa
($userid, $tfa_cfg, $realm_tfa, $keys);
150 $user->{keys} = $tfa_cfg ?
'x' : undef;
151 cfs_write_file
("user.cfg", $user_cfg);
152 }, "enabling TFA for the user failed");
155 __PACKAGE__-
>register_method ({
156 name
=> 'list_user_tfa',
161 ['userid-param', 'self'],
162 ['userid-group', ['User.Modify', 'Sys.Audit']],
165 protected
=> 1, # else we can't access shadow files
166 description
=> 'List TFA configurations of users.',
168 additionalProperties
=> 0,
170 userid
=> get_standard_option
('userid', {
171 completion
=> \
&PVE
::AccessControl
::complete_username
,
176 description
=> "A list of the user's TFA entries.",
178 items
=> $TYPED_TFA_ENTRY_SCHEMA,
182 my $tfa_cfg = cfs_read_file
('priv/tfa.cfg');
183 return $tfa_cfg->api_list_user_tfa($param->{userid
});
186 __PACKAGE__-
>register_method ({
187 name
=> 'get_tfa_entry',
188 path
=> '{userid}/{id}',
192 ['userid-param', 'self'],
193 ['userid-group', ['User.Modify', 'Sys.Audit']],
196 protected
=> 1, # else we can't access shadow files
197 description
=> 'Fetch a requested TFA entry if present.',
199 additionalProperties
=> 0,
201 userid
=> get_standard_option
('userid', {
202 completion
=> \
&PVE
::AccessControl
::complete_username
,
204 id
=> $TFA_ID_SCHEMA,
207 returns
=> $TYPED_TFA_ENTRY_SCHEMA,
210 my $tfa_cfg = cfs_read_file
('priv/tfa.cfg');
211 my $id = $param->{id
};
212 my $entry = $tfa_cfg->api_get_tfa_entry($param->{userid
}, $id);
213 raise
("No such tfa entry '$id'", code
=> HTTP
::Status
::HTTP_NOT_FOUND
) if !$entry;
217 __PACKAGE__-
>register_method ({
218 name
=> 'delete_tfa',
219 path
=> '{userid}/{id}',
223 ['userid-param', 'self'],
224 ['userid-group', ['User.Modify']],
227 protected
=> 1, # else we can't access shadow files
228 allowtoken
=> 0, # we don't want tokens to change the regular user's TFA settings
229 description
=> 'Delete a TFA entry by ID.',
231 additionalProperties
=> 0,
233 userid
=> get_standard_option
('userid', {
234 completion
=> \
&PVE
::AccessControl
::complete_username
,
236 id
=> $TFA_ID_SCHEMA,
237 password
=> $OPTIONAL_PASSWORD_SCHEMA,
240 returns
=> { type
=> 'null' },
244 PVE
::AccessControl
::assert_new_tfa_config_available
();
246 my $rpcenv = PVE
::RPCEnvironment
::get
();
247 my $authuser = $rpcenv->get_user();
249 root_permission_check
($rpcenv, $authuser, $param->{userid
}, $param->{password
});
251 my $has_entries_left = PVE
::AccessControl
::lock_tfa_config
(sub {
252 my $tfa_cfg = cfs_read_file
('priv/tfa.cfg');
253 my $has_entries_left = $tfa_cfg->api_delete_tfa($userid, $param->{id
});
254 cfs_write_file
('priv/tfa.cfg', $tfa_cfg);
255 return $has_entries_left;
257 if (!$has_entries_left) {
258 set_user_tfa_enabled
($userid, undef, undef);
262 __PACKAGE__-
>register_method ({
267 description
=> "Returns all or just the logged-in user, depending on privileges.",
270 protected
=> 1, # else we can't access shadow files
271 description
=> 'List TFA configurations of users.',
273 additionalProperties
=> 0,
277 description
=> "The list tuples of user and TFA entries.",
284 description
=> 'User this entry belongs to.',
288 items
=> $TYPED_TFA_ENTRY_SCHEMA,
293 description
=> 'True if the user is currently locked out of TOTP factors.',
295 'tfa-locked-until' => {
299 'Contains a timestamp until when a user is locked out of 2nd factors.',
307 my $rpcenv = PVE
::RPCEnvironment
::get
();
308 my $authuser = $rpcenv->get_user();
310 my $tfa_cfg = cfs_read_file
('priv/tfa.cfg');
311 my $entries = $tfa_cfg->api_list_tfa($authuser, 1);
313 my $privs = [ 'User.Modify', 'Sys.Audit' ];
314 if ($rpcenv->check_any($authuser, "/access/groups", $privs, 1)) {
319 my $groups = $rpcenv->filter_groups($authuser, $privs, 1);
320 my $allowed_users = $rpcenv->group_member_join([keys %$groups]);
323 my $userid = $_->{userid
};
324 $userid eq $authuser || $allowed_users->{$userid}
329 __PACKAGE__-
>register_method ({
330 name
=> 'add_tfa_entry',
335 ['userid-param', 'self'],
336 ['userid-group', ['User.Modify']],
339 protected
=> 1, # else we can't access shadow files
340 allowtoken
=> 0, # we don't want tokens to change the regular user's TFA settings
341 description
=> 'Add a TFA entry for a user.',
343 additionalProperties
=> 0,
345 userid
=> get_standard_option
('userid', {
346 completion
=> \
&PVE
::AccessControl
::complete_username
,
348 type
=> $TFA_TYPE_SCHEMA,
351 description
=> 'A description to distinguish multiple entries from one another',
357 description
=> "A totp URI.",
363 'The current value for the provided totp URI, or a Webauthn/U2F'
364 .' challenge response',
369 description
=> 'When responding to a u2f challenge: the original challenge string',
372 password
=> $OPTIONAL_PASSWORD_SCHEMA,
375 returns
=> $TFA_UPDATE_INFO_SCHEMA,
379 PVE
::AccessControl
::assert_new_tfa_config_available
();
381 my $rpcenv = PVE
::RPCEnvironment
::get
();
382 my $authuser = $rpcenv->get_user();
383 my ($userid, $realm) =
384 root_permission_check
($rpcenv, $authuser, $param->{userid
}, $param->{password
});
386 my $type = delete $param->{type
};
387 my $value = delete $param->{value
};
388 if ($type eq 'yubico') {
389 $value = validate_yubico_otp
($userid, $realm, $value);
392 return PVE
::AccessControl
::lock_tfa_config
(sub {
393 my $tfa_cfg = cfs_read_file
('priv/tfa.cfg');
395 set_user_tfa_enabled
($userid, $realm, $tfa_cfg);
397 PVE
::AccessControl
::configure_u2f_and_wa
($tfa_cfg);
399 my $response = $tfa_cfg->api_add_tfa_entry(
401 $param->{description
},
408 cfs_write_file
('priv/tfa.cfg', $tfa_cfg);
414 sub validate_yubico_otp
: prototype($$) {
415 my ($userid, $realm, $value) = @_;
417 my $domain_cfg = cfs_read_file
('domains.cfg');
418 my $realm_cfg = $domain_cfg->{ids
}->{$realm};
419 die "auth domain '$realm' does not exist\n" if !$realm_cfg;
421 my $realm_tfa = $realm_cfg->{tfa
};
422 die "no yubico otp configuration available for realm $realm\n"
425 $realm_tfa = PVE
::Auth
::Plugin
::parse_tfa_config
($realm_tfa);
426 die "realm is not setup for Yubico OTP\n"
427 if !$realm_tfa || $realm_tfa->{type
} ne 'yubico';
429 my $public_key = substr($value, 0, 12);
431 PVE
::AccessControl
::authenticate_yubico_do
($value, $public_key, $realm_tfa);
436 __PACKAGE__-
>register_method ({
437 name
=> 'update_tfa_entry',
438 path
=> '{userid}/{id}',
442 ['userid-param', 'self'],
443 ['userid-group', ['User.Modify']],
446 protected
=> 1, # else we can't access shadow files
447 allowtoken
=> 0, # we don't want tokens to change the regular user's TFA settings
448 description
=> 'Add a TFA entry for a user.',
450 additionalProperties
=> 0,
452 userid
=> get_standard_option
('userid', {
453 completion
=> \
&PVE
::AccessControl
::complete_username
,
455 id
=> $TFA_ID_SCHEMA,
458 description
=> 'A description to distinguish multiple entries from one another',
464 description
=> 'Whether the entry should be enabled for login.',
467 password
=> $OPTIONAL_PASSWORD_SCHEMA,
470 returns
=> { type
=> 'null' },
474 PVE
::AccessControl
::assert_new_tfa_config_available
();
476 my $rpcenv = PVE
::RPCEnvironment
::get
();
477 my $authuser = $rpcenv->get_user();
479 root_permission_check
($rpcenv, $authuser, $param->{userid
}, $param->{password
});
481 PVE
::AccessControl
::lock_tfa_config
(sub {
482 my $tfa_cfg = cfs_read_file
('priv/tfa.cfg');
484 $tfa_cfg->api_update_tfa_entry(
487 $param->{description
},
491 cfs_write_file
('priv/tfa.cfg', $tfa_cfg);