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 # Set TFA to enabled if $tfa_cfg is passed, or to disabled if $tfa_cfg is undef,
99 # When enabling we also merge the old user.cfg keys into the $tfa_cfg.
100 my sub set_user_tfa_enabled
: prototype($$$) {
101 my ($userid, $realm, $tfa_cfg) = @_;
103 PVE
::AccessControl
::lock_user_config
(sub {
104 my $user_cfg = cfs_read_file
('user.cfg');
105 my $user = $user_cfg->{users
}->{$userid};
106 my $keys = $user->{keys};
107 # When enabling, we convert old-old keys,
108 # When disabling, we shouldn't actually have old keys anymore, so if they are there,
109 # they'll be removed.
110 if ($tfa_cfg && $keys && $keys !~ /^x(?:!.*)?$/) {
111 my $domain_cfg = cfs_read_file
('domains.cfg');
112 my $realm_cfg = $domain_cfg->{ids
}->{$realm};
113 die "auth domain '$realm' does not exist\n" if !$realm_cfg;
115 my $realm_tfa = $realm_cfg->{tfa
};
116 $realm_tfa = PVE
::Auth
::Plugin
::parse_tfa_config
($realm_tfa) if $realm_tfa;
118 PVE
::AccessControl
::add_old_keys_to_realm_tfa
($userid, $tfa_cfg, $realm_tfa, $keys);
120 $user->{keys} = $tfa_cfg ?
'x' : undef;
121 cfs_write_file
("user.cfg", $user_cfg);
122 }, "enabling TFA for the user failed");
125 __PACKAGE__-
>register_method ({
126 name
=> 'list_user_tfa',
131 ['userid-param', 'self'],
132 ['userid-group', ['User.Modify', 'Sys.Audit']],
135 protected
=> 1, # else we can't access shadow files
136 description
=> 'List TFA configurations of users.',
138 additionalProperties
=> 0,
140 userid
=> get_standard_option
('userid', {
141 completion
=> \
&PVE
::AccessControl
::complete_username
,
146 description
=> "A list of the user's TFA entries.",
148 items
=> $TYPED_TFA_ENTRY_SCHEMA,
149 links
=> [ { rel
=> 'child', href
=> "{id}" } ],
153 my $tfa_cfg = cfs_read_file
('priv/tfa.cfg');
154 return $tfa_cfg->api_list_user_tfa($param->{userid
});
157 __PACKAGE__-
>register_method ({
158 name
=> 'get_tfa_entry',
159 path
=> '{userid}/{id}',
163 ['userid-param', 'self'],
164 ['userid-group', ['User.Modify', 'Sys.Audit']],
167 protected
=> 1, # else we can't access shadow files
168 description
=> 'Fetch a requested TFA entry if present.',
170 additionalProperties
=> 0,
172 userid
=> get_standard_option
('userid', {
173 completion
=> \
&PVE
::AccessControl
::complete_username
,
175 id
=> $TFA_ID_SCHEMA,
178 returns
=> $TYPED_TFA_ENTRY_SCHEMA,
181 my $tfa_cfg = cfs_read_file
('priv/tfa.cfg');
182 my $id = $param->{id
};
183 my $entry = $tfa_cfg->api_get_tfa_entry($param->{userid
}, $id);
184 raise
("No such tfa entry '$id'", code
=> HTTP
::Status
::HTTP_NOT_FOUND
) if !$entry;
188 __PACKAGE__-
>register_method ({
189 name
=> 'delete_tfa',
190 path
=> '{userid}/{id}',
194 ['userid-param', 'self'],
195 ['userid-group', ['User.Modify']],
198 protected
=> 1, # else we can't access shadow files
199 allowtoken
=> 0, # we don't want tokens to change the regular user's TFA settings
200 description
=> 'Delete a TFA entry by ID.',
202 additionalProperties
=> 0,
204 userid
=> get_standard_option
('userid', {
205 completion
=> \
&PVE
::AccessControl
::complete_username
,
207 id
=> $TFA_ID_SCHEMA,
208 password
=> $OPTIONAL_PASSWORD_SCHEMA,
211 returns
=> { type
=> 'null' },
215 my $rpcenv = PVE
::RPCEnvironment
::get
();
216 my $authuser = $rpcenv->get_user();
217 my $userid = $rpcenv->reauth_user_for_user_modification(
223 my $has_entries_left = PVE
::AccessControl
::lock_tfa_config
(sub {
224 my $tfa_cfg = cfs_read_file
('priv/tfa.cfg');
225 my $has_entries_left = $tfa_cfg->api_delete_tfa($userid, $param->{id
});
226 cfs_write_file
('priv/tfa.cfg', $tfa_cfg);
227 return $has_entries_left;
229 if (!$has_entries_left) {
230 set_user_tfa_enabled
($userid, undef, undef);
234 __PACKAGE__-
>register_method ({
239 description
=> "Returns all or just the logged-in user, depending on privileges.",
242 protected
=> 1, # else we can't access shadow files
243 description
=> 'List TFA configurations of users.',
245 additionalProperties
=> 0,
249 description
=> "The list tuples of user and TFA entries.",
256 description
=> 'User this entry belongs to.',
260 items
=> $TYPED_TFA_ENTRY_SCHEMA,
265 description
=> 'True if the user is currently locked out of TOTP factors.',
267 'tfa-locked-until' => {
271 'Contains a timestamp until when a user is locked out of 2nd factors.',
275 links
=> [ { rel
=> 'child', href
=> "{userid}" } ],
280 my $rpcenv = PVE
::RPCEnvironment
::get
();
281 my $authuser = $rpcenv->get_user();
283 my $tfa_cfg = cfs_read_file
('priv/tfa.cfg');
284 my $entries = $tfa_cfg->api_list_tfa($authuser, 1);
286 my $privs = [ 'User.Modify', 'Sys.Audit' ];
287 if ($rpcenv->check_any($authuser, "/access/groups", $privs, 1)) {
292 my $groups = $rpcenv->filter_groups($authuser, $privs, 1);
293 my $allowed_users = $rpcenv->group_member_join([keys %$groups]);
296 my $userid = $_->{userid
};
297 $userid eq $authuser || $allowed_users->{$userid}
302 __PACKAGE__-
>register_method ({
303 name
=> 'add_tfa_entry',
308 ['userid-param', 'self'],
309 ['userid-group', ['User.Modify']],
312 protected
=> 1, # else we can't access shadow files
313 allowtoken
=> 0, # we don't want tokens to change the regular user's TFA settings
314 description
=> 'Add a TFA entry for a user.',
316 additionalProperties
=> 0,
318 userid
=> get_standard_option
('userid', {
319 completion
=> \
&PVE
::AccessControl
::complete_username
,
321 type
=> $TFA_TYPE_SCHEMA,
324 description
=> 'A description to distinguish multiple entries from one another',
330 description
=> "A totp URI.",
336 'The current value for the provided totp URI, or a Webauthn/U2F'
337 .' challenge response',
342 description
=> 'When responding to a u2f challenge: the original challenge string',
345 password
=> $OPTIONAL_PASSWORD_SCHEMA,
348 returns
=> $TFA_UPDATE_INFO_SCHEMA,
352 my $rpcenv = PVE
::RPCEnvironment
::get
();
353 my $authuser = $rpcenv->get_user();
354 my ($userid, undef, $realm) = $rpcenv->reauth_user_for_user_modification(
360 my $type = delete $param->{type
};
361 my $value = delete $param->{value
};
362 if ($type eq 'yubico') {
363 $value = validate_yubico_otp
($userid, $realm, $value);
366 return PVE
::AccessControl
::lock_tfa_config
(sub {
367 my $tfa_cfg = cfs_read_file
('priv/tfa.cfg');
369 set_user_tfa_enabled
($userid, $realm, $tfa_cfg);
371 PVE
::AccessControl
::configure_u2f_and_wa
($tfa_cfg);
373 my $response = $tfa_cfg->api_add_tfa_entry(
375 $param->{description
},
382 cfs_write_file
('priv/tfa.cfg', $tfa_cfg);
388 sub validate_yubico_otp
: prototype($$$) {
389 my ($userid, $realm, $value) = @_;
391 my $domain_cfg = cfs_read_file
('domains.cfg');
392 my $realm_cfg = $domain_cfg->{ids
}->{$realm};
393 die "auth domain '$realm' does not exist\n" if !$realm_cfg;
395 my $realm_tfa = $realm_cfg->{tfa
};
396 die "no yubico otp configuration available for realm $realm\n"
399 $realm_tfa = PVE
::Auth
::Plugin
::parse_tfa_config
($realm_tfa);
400 die "realm is not setup for Yubico OTP\n"
401 if !$realm_tfa || $realm_tfa->{type
} ne 'yubico';
403 my $public_key = substr($value, 0, 12);
405 PVE
::AccessControl
::authenticate_yubico_do
($value, $public_key, $realm_tfa);
410 __PACKAGE__-
>register_method ({
411 name
=> 'update_tfa_entry',
412 path
=> '{userid}/{id}',
416 ['userid-param', 'self'],
417 ['userid-group', ['User.Modify']],
420 protected
=> 1, # else we can't access shadow files
421 allowtoken
=> 0, # we don't want tokens to change the regular user's TFA settings
422 description
=> 'Add a TFA entry for a user.',
424 additionalProperties
=> 0,
426 userid
=> get_standard_option
('userid', {
427 completion
=> \
&PVE
::AccessControl
::complete_username
,
429 id
=> $TFA_ID_SCHEMA,
432 description
=> 'A description to distinguish multiple entries from one another',
438 description
=> 'Whether the entry should be enabled for login.',
441 password
=> $OPTIONAL_PASSWORD_SCHEMA,
444 returns
=> { type
=> 'null' },
448 my $rpcenv = PVE
::RPCEnvironment
::get
();
449 my $authuser = $rpcenv->get_user();
450 my $userid = $rpcenv->reauth_user_for_user_modification(
456 PVE
::AccessControl
::lock_tfa_config
(sub {
457 my $tfa_cfg = cfs_read_file
('priv/tfa.cfg');
459 $tfa_cfg->api_update_tfa_entry(
462 $param->{description
},
466 cfs_write_file
('priv/tfa.cfg', $tfa_cfg);