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,
179 links
=> [ { rel
=> 'child', href
=> "{id}" } ],
183 my $tfa_cfg = cfs_read_file
('priv/tfa.cfg');
184 return $tfa_cfg->api_list_user_tfa($param->{userid
});
187 __PACKAGE__-
>register_method ({
188 name
=> 'get_tfa_entry',
189 path
=> '{userid}/{id}',
193 ['userid-param', 'self'],
194 ['userid-group', ['User.Modify', 'Sys.Audit']],
197 protected
=> 1, # else we can't access shadow files
198 description
=> 'Fetch a requested TFA entry if present.',
200 additionalProperties
=> 0,
202 userid
=> get_standard_option
('userid', {
203 completion
=> \
&PVE
::AccessControl
::complete_username
,
205 id
=> $TFA_ID_SCHEMA,
208 returns
=> $TYPED_TFA_ENTRY_SCHEMA,
211 my $tfa_cfg = cfs_read_file
('priv/tfa.cfg');
212 my $id = $param->{id
};
213 my $entry = $tfa_cfg->api_get_tfa_entry($param->{userid
}, $id);
214 raise
("No such tfa entry '$id'", code
=> HTTP
::Status
::HTTP_NOT_FOUND
) if !$entry;
218 __PACKAGE__-
>register_method ({
219 name
=> 'delete_tfa',
220 path
=> '{userid}/{id}',
224 ['userid-param', 'self'],
225 ['userid-group', ['User.Modify']],
228 protected
=> 1, # else we can't access shadow files
229 allowtoken
=> 0, # we don't want tokens to change the regular user's TFA settings
230 description
=> 'Delete a TFA entry by ID.',
232 additionalProperties
=> 0,
234 userid
=> get_standard_option
('userid', {
235 completion
=> \
&PVE
::AccessControl
::complete_username
,
237 id
=> $TFA_ID_SCHEMA,
238 password
=> $OPTIONAL_PASSWORD_SCHEMA,
241 returns
=> { type
=> 'null' },
245 my $rpcenv = PVE
::RPCEnvironment
::get
();
246 my $authuser = $rpcenv->get_user();
248 root_permission_check
($rpcenv, $authuser, $param->{userid
}, $param->{password
});
250 my $has_entries_left = PVE
::AccessControl
::lock_tfa_config
(sub {
251 my $tfa_cfg = cfs_read_file
('priv/tfa.cfg');
252 my $has_entries_left = $tfa_cfg->api_delete_tfa($userid, $param->{id
});
253 cfs_write_file
('priv/tfa.cfg', $tfa_cfg);
254 return $has_entries_left;
256 if (!$has_entries_left) {
257 set_user_tfa_enabled
($userid, undef, undef);
261 __PACKAGE__-
>register_method ({
266 description
=> "Returns all or just the logged-in user, depending on privileges.",
269 protected
=> 1, # else we can't access shadow files
270 description
=> 'List TFA configurations of users.',
272 additionalProperties
=> 0,
276 description
=> "The list tuples of user and TFA entries.",
283 description
=> 'User this entry belongs to.',
287 items
=> $TYPED_TFA_ENTRY_SCHEMA,
292 description
=> 'True if the user is currently locked out of TOTP factors.',
294 'tfa-locked-until' => {
298 'Contains a timestamp until when a user is locked out of 2nd factors.',
302 links
=> [ { rel
=> 'child', href
=> "{userid}" } ],
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 my $rpcenv = PVE
::RPCEnvironment
::get
();
380 my $authuser = $rpcenv->get_user();
381 my ($userid, $realm) =
382 root_permission_check
($rpcenv, $authuser, $param->{userid
}, $param->{password
});
384 my $type = delete $param->{type
};
385 my $value = delete $param->{value
};
386 if ($type eq 'yubico') {
387 $value = validate_yubico_otp
($userid, $realm, $value);
390 return PVE
::AccessControl
::lock_tfa_config
(sub {
391 my $tfa_cfg = cfs_read_file
('priv/tfa.cfg');
393 set_user_tfa_enabled
($userid, $realm, $tfa_cfg);
395 PVE
::AccessControl
::configure_u2f_and_wa
($tfa_cfg);
397 my $response = $tfa_cfg->api_add_tfa_entry(
399 $param->{description
},
406 cfs_write_file
('priv/tfa.cfg', $tfa_cfg);
412 sub validate_yubico_otp
: prototype($$) {
413 my ($userid, $realm, $value) = @_;
415 my $domain_cfg = cfs_read_file
('domains.cfg');
416 my $realm_cfg = $domain_cfg->{ids
}->{$realm};
417 die "auth domain '$realm' does not exist\n" if !$realm_cfg;
419 my $realm_tfa = $realm_cfg->{tfa
};
420 die "no yubico otp configuration available for realm $realm\n"
423 $realm_tfa = PVE
::Auth
::Plugin
::parse_tfa_config
($realm_tfa);
424 die "realm is not setup for Yubico OTP\n"
425 if !$realm_tfa || $realm_tfa->{type
} ne 'yubico';
427 my $public_key = substr($value, 0, 12);
429 PVE
::AccessControl
::authenticate_yubico_do
($value, $public_key, $realm_tfa);
434 __PACKAGE__-
>register_method ({
435 name
=> 'update_tfa_entry',
436 path
=> '{userid}/{id}',
440 ['userid-param', 'self'],
441 ['userid-group', ['User.Modify']],
444 protected
=> 1, # else we can't access shadow files
445 allowtoken
=> 0, # we don't want tokens to change the regular user's TFA settings
446 description
=> 'Add a TFA entry for a user.',
448 additionalProperties
=> 0,
450 userid
=> get_standard_option
('userid', {
451 completion
=> \
&PVE
::AccessControl
::complete_username
,
453 id
=> $TFA_ID_SCHEMA,
456 description
=> 'A description to distinguish multiple entries from one another',
462 description
=> 'Whether the entry should be enabled for login.',
465 password
=> $OPTIONAL_PASSWORD_SCHEMA,
468 returns
=> { type
=> 'null' },
472 my $rpcenv = PVE
::RPCEnvironment
::get
();
473 my $authuser = $rpcenv->get_user();
475 root_permission_check
($rpcenv, $authuser, $param->{userid
}, $param->{password
});
477 PVE
::AccessControl
::lock_tfa_config
(sub {
478 my $tfa_cfg = cfs_read_file
('priv/tfa.cfg');
480 $tfa_cfg->api_update_tfa_entry(
483 $param->{description
},
487 cfs_write_file
('priv/tfa.cfg', $tfa_cfg);