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
::JSONSchema
qw(get_standard_option);
11 use PVE
::Exception
qw(raise raise_perm_exc raise_param_exc);
12 use PVE
::RPCEnvironment
;
14 use PVE
::API2
::AccessControl
; # for old login api get_u2f_instance method
18 use base
qw(PVE::RESTHandler);
20 my $OPTIONAL_PASSWORD_SCHEMA = {
21 description
=> "The current password.",
23 optional
=> 1, # Only required if not root@pam
28 my $TFA_TYPE_SCHEMA = {
30 description
=> 'TFA Entry Type.',
31 enum
=> [qw(totp u2f webauthn recovery yubico)],
34 my %TFA_INFO_PROPERTIES = (
37 description
=> 'The id used to reference this entry.',
41 description
=> 'User chosen description for this entry.',
45 description
=> 'Creation time of this entry as unix epoch.',
49 description
=> 'Whether this TFA entry is currently enabled.',
55 my $TYPED_TFA_ENTRY_SCHEMA = {
57 description
=> 'TFA Entry.',
59 type
=> $TFA_TYPE_SCHEMA,
66 description
=> 'A TFA entry id.',
69 my $TFA_UPDATE_INFO_SCHEMA = {
74 description
=> 'The id of a newly added TFA entry.',
80 'When adding u2f entries, this contains a challenge the user must respond to in order'
81 .' to finish the registration.'
87 'When adding recovery codes, this contains the list of codes to be displayed to'
91 description
=> 'A recovery entry.'
97 # Only root may modify root, regular users need to specify their password.
99 # Returns the userid returned from `verify_username`.
100 # Or ($userid, $realm) in list context.
101 my sub root_permission_check
: prototype($$$$) {
102 my ($rpcenv, $authuser, $userid, $password) = @_;
104 ($userid, undef, my $realm) = PVE
::AccessControl
::verify_username
($userid);
105 $rpcenv->check_user_exist($userid);
107 raise_perm_exc
() if $userid eq 'root@pam' && $authuser ne 'root@pam';
109 # Regular users need to confirm their password to change TFA settings.
110 if ($authuser ne 'root@pam') {
111 raise_param_exc
({ 'password' => 'password is required to modify TFA data' })
112 if !defined($password);
114 ($authuser, my $auth_username, my $auth_realm) =
115 PVE
::AccessControl
::verify_username
($authuser);
117 my $domain_cfg = cfs_read_file
('domains.cfg');
118 my $cfg = $domain_cfg->{ids
}->{$auth_realm};
119 die "auth domain '$auth_realm' does not exist\n" if !$cfg;
120 my $plugin = PVE
::Auth
::Plugin-
>lookup($cfg->{type
});
121 $plugin->authenticate_user($cfg, $auth_realm, $auth_username, $password);
124 return wantarray ?
($userid, $realm) : $userid;
127 # Set TFA to enabled if $tfa_cfg is passed, or to disabled if $tfa_cfg is undef,
128 # When enabling we also merge the old user.cfg keys into the $tfa_cfg.
129 my sub set_user_tfa_enabled
: prototype($$$) {
130 my ($userid, $realm, $tfa_cfg) = @_;
132 PVE
::AccessControl
::lock_user_config
(sub {
133 my $user_cfg = cfs_read_file
('user.cfg');
134 my $user = $user_cfg->{users
}->{$userid};
135 my $keys = $user->{keys};
136 # When enabling, we convert old-old keys,
137 # When disabling, we shouldn't actually have old keys anymore, so if they are there,
138 # they'll be removed.
139 if ($tfa_cfg && $keys && $keys !~ /^x(?:!.*)?$/) {
140 my $domain_cfg = cfs_read_file
('domains.cfg');
141 my $realm_cfg = $domain_cfg->{ids
}->{$realm};
142 die "auth domain '$realm' does not exist\n" if !$realm_cfg;
144 my $realm_tfa = $realm_cfg->{tfa
};
145 $realm_tfa = PVE
::Auth
::Plugin
::parse_tfa_config
($realm_tfa) if $realm_tfa;
147 PVE
::AccessControl
::add_old_keys_to_realm_tfa
($userid, $tfa_cfg, $realm_tfa, $keys);
149 $user->{keys} = $tfa_cfg ?
'x' : undef;
150 cfs_write_file
("user.cfg", $user_cfg);
151 }, "enabling TFA for the user failed");
156 __PACKAGE__-
>register_method({
157 name
=> 'verify_tfa',
160 permissions
=> { user
=> 'all' },
161 protected
=> 1, # else we can't access shadow files
162 allowtoken
=> 0, # we don't want tokens to access TFA information
163 description
=> 'Finish a u2f challenge.',
165 additionalProperties
=> 0,
169 description
=> 'The response to the current authentication challenge.',
176 ticket
=> { type
=> 'string' },
183 my $rpcenv = PVE
::RPCEnvironment
::get
();
184 my $authuser = $rpcenv->get_user();
185 my ($username, undef, $realm) = PVE
::AccessControl
::verify_username
($authuser);
187 my ($tfa_type, $tfa_data) = PVE
::AccessControl
::user_get_tfa
($username, $realm, 0);
188 if (!defined($tfa_type)) {
189 raise
('no u2f data available');
191 if ($tfa_type eq 'incompatible') {
192 raise
('tfa entries incompatible with old login api');
196 if ($tfa_type eq 'u2f') {
197 my $challenge = $rpcenv->get_u2f_challenge()
198 or raise
('no active challenge');
200 my $keyHandle = $tfa_data->{keyHandle
};
201 my $publicKey = $tfa_data->{publicKey
};
202 raise
("incomplete u2f setup")
203 if !defined($keyHandle) || !defined($publicKey);
205 my $u2f = PVE
::API2
::AccessControl
::get_u2f_instance
($rpcenv, $publicKey, $keyHandle);
206 $u2f->set_challenge($challenge);
208 my ($counter, $present) = $u2f->auth_verify($param->{response
});
209 # Do we want to do anything with these?
211 # sanity check before handing off to the verification code:
212 my $keys = $tfa_data->{keys} or die "missing tfa keys\n";
213 my $config = $tfa_data->{config
} or die "bad tfa entry\n";
214 PVE
::AccessControl
::verify_one_time_pw
($tfa_type, $authuser, $keys, $config, $param->{response
});
218 my $clientip = $rpcenv->get_client_ip() || '';
219 syslog
('err', "authentication verification failure; rhost=$clientip user=$authuser msg=$err");
220 die PVE
::Exception-
>new("authentication failure\n", code
=> 401);
224 ticket
=> PVE
::AccessControl
::assemble_ticket
($authuser),
225 cap
=> $rpcenv->compute_api_permission($authuser),
231 __PACKAGE__-
>register_method ({
232 name
=> 'list_user_tfa',
237 ['userid-param', 'self'],
238 ['userid-group', ['User.Modify', 'Sys.Audit']],
241 protected
=> 1, # else we can't access shadow files
242 allowtoken
=> 0, # we don't want tokens to change the regular user's TFA settings
243 description
=> 'List TFA configurations of users.',
245 additionalProperties
=> 0,
247 userid
=> get_standard_option
('userid', {
248 completion
=> \
&PVE
::AccessControl
::complete_username
,
253 description
=> "A list of the user's TFA entries.",
255 items
=> $TYPED_TFA_ENTRY_SCHEMA,
259 my $tfa_cfg = cfs_read_file
('priv/tfa.cfg');
260 return $tfa_cfg->api_list_user_tfa($param->{userid
});
263 __PACKAGE__-
>register_method ({
264 name
=> 'get_tfa_entry',
265 path
=> '{userid}/{id}',
269 ['userid-param', 'self'],
270 ['userid-group', ['User.Modify', 'Sys.Audit']],
273 protected
=> 1, # else we can't access shadow files
274 allowtoken
=> 0, # we don't want tokens to change the regular user's TFA settings
275 description
=> 'Fetch a requested TFA entry if present.',
277 additionalProperties
=> 0,
279 userid
=> get_standard_option
('userid', {
280 completion
=> \
&PVE
::AccessControl
::complete_username
,
282 id
=> $TFA_ID_SCHEMA,
285 returns
=> $TYPED_TFA_ENTRY_SCHEMA,
288 my $tfa_cfg = cfs_read_file
('priv/tfa.cfg');
289 my $id = $param->{id
};
290 my $entry = $tfa_cfg->api_get_tfa_entry($param->{userid
}, $id);
291 raise
("No such tfa entry '$id'", code
=> HTTP
::Status
::HTTP_NOT_FOUND
) if !$entry;
295 __PACKAGE__-
>register_method ({
296 name
=> 'delete_tfa',
297 path
=> '{userid}/{id}',
301 ['userid-param', 'self'],
302 ['userid-group', ['User.Modify']],
305 protected
=> 1, # else we can't access shadow files
306 allowtoken
=> 0, # we don't want tokens to change the regular user's TFA settings
307 description
=> 'Delete a TFA entry by ID.',
309 additionalProperties
=> 0,
311 userid
=> get_standard_option
('userid', {
312 completion
=> \
&PVE
::AccessControl
::complete_username
,
314 id
=> $TFA_ID_SCHEMA,
315 password
=> $OPTIONAL_PASSWORD_SCHEMA,
318 returns
=> { type
=> 'null' },
322 PVE
::AccessControl
::assert_new_tfa_config_available
();
324 my $rpcenv = PVE
::RPCEnvironment
::get
();
325 my $authuser = $rpcenv->get_user();
327 root_permission_check
($rpcenv, $authuser, $param->{userid
}, $param->{password
});
329 my $has_entries_left = PVE
::AccessControl
::lock_tfa_config
(sub {
330 my $tfa_cfg = cfs_read_file
('priv/tfa.cfg');
331 my $has_entries_left = $tfa_cfg->api_delete_tfa($userid, $param->{id
});
332 cfs_write_file
('priv/tfa.cfg', $tfa_cfg);
333 return $has_entries_left;
335 if (!$has_entries_left) {
336 set_user_tfa_enabled
($userid, undef, undef);
340 __PACKAGE__-
>register_method ({
345 description
=> "Returns all or just the logged-in user, depending on privileges.",
348 protected
=> 1, # else we can't access shadow files
349 allowtoken
=> 0, # we don't want tokens to change the regular user's TFA settings
350 description
=> 'List TFA configurations of users.',
352 additionalProperties
=> 0,
356 description
=> "The list tuples of user and TFA entries.",
363 description
=> 'User this entry belongs to.',
367 items
=> $TYPED_TFA_ENTRY_SCHEMA,
375 my $rpcenv = PVE
::RPCEnvironment
::get
();
376 my $authuser = $rpcenv->get_user();
377 my $top_level_allowed = ($authuser eq 'root@pam');
379 my $tfa_cfg = cfs_read_file
('priv/tfa.cfg');
380 return $tfa_cfg->api_list_tfa($authuser, $top_level_allowed);
383 __PACKAGE__-
>register_method ({
384 name
=> 'add_tfa_entry',
389 ['userid-param', 'self'],
390 ['userid-group', ['User.Modify']],
393 protected
=> 1, # else we can't access shadow files
394 allowtoken
=> 0, # we don't want tokens to change the regular user's TFA settings
395 description
=> 'Add a TFA entry for a user.',
397 additionalProperties
=> 0,
399 userid
=> get_standard_option
('userid', {
400 completion
=> \
&PVE
::AccessControl
::complete_username
,
402 type
=> $TFA_TYPE_SCHEMA,
405 description
=> 'A description to distinguish multiple entries from one another',
411 description
=> "A totp URI.",
417 'The current value for the provided totp URI, or a Webauthn/U2F'
418 .' challenge response',
423 description
=> 'When responding to a u2f challenge: the original challenge string',
426 password
=> $OPTIONAL_PASSWORD_SCHEMA,
429 returns
=> $TFA_UPDATE_INFO_SCHEMA,
433 PVE
::AccessControl
::assert_new_tfa_config_available
();
435 my $rpcenv = PVE
::RPCEnvironment
::get
();
436 my $authuser = $rpcenv->get_user();
437 my ($userid, $realm) =
438 root_permission_check
($rpcenv, $authuser, $param->{userid
}, $param->{password
});
440 my $type = delete $param->{type
};
441 my $value = delete $param->{value
};
442 if ($type eq 'yubico') {
443 $value = validate_yubico_otp
($userid, $realm, $value);
446 return PVE
::AccessControl
::lock_tfa_config
(sub {
447 my $tfa_cfg = cfs_read_file
('priv/tfa.cfg');
449 set_user_tfa_enabled
($userid, $realm, $tfa_cfg);
451 PVE
::AccessControl
::configure_u2f_and_wa
($tfa_cfg);
453 my $response = $tfa_cfg->api_add_tfa_entry(
455 $param->{description
},
462 cfs_write_file
('priv/tfa.cfg', $tfa_cfg);
468 sub validate_yubico_otp
: prototype($$) {
469 my ($userid, $realm, $value) = @_;
471 my $domain_cfg = cfs_read_file
('domains.cfg');
472 my $realm_cfg = $domain_cfg->{ids
}->{$realm};
473 die "auth domain '$realm' does not exist\n" if !$realm_cfg;
475 my $realm_tfa = $realm_cfg->{tfa
};
476 die "no yubico otp configuration available for realm $realm\n"
479 $realm_tfa = PVE
::Auth
::Plugin
::parse_tfa_config
($realm_tfa);
480 die "realm is not setup for Yubico OTP\n"
481 if !$realm_tfa || $realm_tfa->{type
} ne 'yubico';
483 my $public_key = substr($value, 0, 12);
485 PVE
::AccessControl
::authenticate_yubico_do
($value, $public_key, $realm_tfa);
490 __PACKAGE__-
>register_method ({
491 name
=> 'update_tfa_entry',
492 path
=> '{userid}/{id}',
496 ['userid-param', 'self'],
497 ['userid-group', ['User.Modify']],
500 protected
=> 1, # else we can't access shadow files
501 allowtoken
=> 0, # we don't want tokens to change the regular user's TFA settings
502 description
=> 'Add a TFA entry for a user.',
504 additionalProperties
=> 0,
506 userid
=> get_standard_option
('userid', {
507 completion
=> \
&PVE
::AccessControl
::complete_username
,
509 id
=> $TFA_ID_SCHEMA,
512 description
=> 'A description to distinguish multiple entries from one another',
518 description
=> 'Whether the entry should be enabled for login.',
521 password
=> $OPTIONAL_PASSWORD_SCHEMA,
524 returns
=> { type
=> 'null' },
528 PVE
::AccessControl
::assert_new_tfa_config_available
();
530 my $rpcenv = PVE
::RPCEnvironment
::get
();
531 my $authuser = $rpcenv->get_user();
533 root_permission_check
($rpcenv, $authuser, $param->{userid
}, $param->{password
});
535 PVE
::AccessControl
::lock_tfa_config
(sub {
536 my $tfa_cfg = cfs_read_file
('priv/tfa.cfg');
538 $tfa_cfg->api_update_tfa_entry(
541 $param->{description
},
545 cfs_write_file
('priv/tfa.cfg', $tfa_cfg);