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");
157 __PACKAGE__-
>register_method({
158 name
=> 'verify_tfa',
161 permissions
=> { user
=> 'all' },
162 protected
=> 1, # else we can't access shadow files
163 allowtoken
=> 0, # we don't want tokens to access TFA information
164 description
=> 'Finish a u2f challenge.',
166 additionalProperties
=> 0,
170 description
=> 'The response to the current authentication challenge.',
177 ticket
=> { type
=> 'string' },
184 my $rpcenv = PVE
::RPCEnvironment
::get
();
185 my $authuser = $rpcenv->get_user();
186 my ($username, undef, $realm) = PVE
::AccessControl
::verify_username
($authuser);
188 my ($tfa_type, $tfa_data) = PVE
::AccessControl
::user_get_tfa
($username, $realm, 0);
189 if (!defined($tfa_type)) {
190 raise
('no u2f data available');
192 if ($tfa_type eq 'incompatible') {
193 raise
('tfa entries incompatible with old login api');
197 if ($tfa_type eq 'u2f') {
198 my $challenge = $rpcenv->get_u2f_challenge()
199 or raise
('no active challenge');
201 my $keyHandle = $tfa_data->{keyHandle
};
202 my $publicKey = $tfa_data->{publicKey
};
203 raise
("incomplete u2f setup")
204 if !defined($keyHandle) || !defined($publicKey);
206 my $u2f = PVE
::API2
::AccessControl
::get_u2f_instance
($rpcenv, $publicKey, $keyHandle);
207 $u2f->set_challenge($challenge);
209 my ($counter, $present) = $u2f->auth_verify($param->{response
});
210 # Do we want to do anything with these?
212 # sanity check before handing off to the verification code:
213 my $keys = $tfa_data->{keys} or die "missing tfa keys\n";
214 my $config = $tfa_data->{config
} or die "bad tfa entry\n";
215 PVE
::AccessControl
::verify_one_time_pw
($tfa_type, $authuser, $keys, $config, $param->{response
});
219 my $clientip = $rpcenv->get_client_ip() || '';
220 syslog
('err', "authentication verification failure; rhost=$clientip user=$authuser msg=$err");
221 die PVE
::Exception-
>new("authentication failure\n", code
=> 401);
225 ticket
=> PVE
::AccessControl
::assemble_ticket
($authuser),
226 cap
=> $rpcenv->compute_api_permission($authuser),
232 __PACKAGE__-
>register_method ({
233 name
=> 'list_user_tfa',
238 ['userid-param', 'self'],
239 ['userid-group', ['User.Modify', 'Sys.Audit']],
242 protected
=> 1, # else we can't access shadow files
243 allowtoken
=> 0, # we don't want tokens to change the regular user's TFA settings
244 description
=> 'List TFA configurations of users.',
246 additionalProperties
=> 0,
248 userid
=> get_standard_option
('userid', {
249 completion
=> \
&PVE
::AccessControl
::complete_username
,
254 description
=> "A list of the user's TFA entries.",
256 items
=> $TYPED_TFA_ENTRY_SCHEMA,
260 my $tfa_cfg = cfs_read_file
('priv/tfa.cfg');
261 return $tfa_cfg->api_list_user_tfa($param->{userid
});
264 __PACKAGE__-
>register_method ({
265 name
=> 'get_tfa_entry',
266 path
=> '{userid}/{id}',
270 ['userid-param', 'self'],
271 ['userid-group', ['User.Modify', 'Sys.Audit']],
274 protected
=> 1, # else we can't access shadow files
275 allowtoken
=> 0, # we don't want tokens to change the regular user's TFA settings
276 description
=> 'Fetch a requested TFA entry if present.',
278 additionalProperties
=> 0,
280 userid
=> get_standard_option
('userid', {
281 completion
=> \
&PVE
::AccessControl
::complete_username
,
283 id
=> $TFA_ID_SCHEMA,
286 returns
=> $TYPED_TFA_ENTRY_SCHEMA,
289 my $tfa_cfg = cfs_read_file
('priv/tfa.cfg');
290 my $id = $param->{id
};
291 my $entry = $tfa_cfg->api_get_tfa_entry($param->{userid
}, $id);
292 raise
("No such tfa entry '$id'", code
=> HTTP
::Status
::HTTP_NOT_FOUND
) if !$entry;
296 __PACKAGE__-
>register_method ({
297 name
=> 'delete_tfa',
298 path
=> '{userid}/{id}',
302 ['userid-param', 'self'],
303 ['userid-group', ['User.Modify']],
306 protected
=> 1, # else we can't access shadow files
307 allowtoken
=> 0, # we don't want tokens to change the regular user's TFA settings
308 description
=> 'Delete a TFA entry by ID.',
310 additionalProperties
=> 0,
312 userid
=> get_standard_option
('userid', {
313 completion
=> \
&PVE
::AccessControl
::complete_username
,
315 id
=> $TFA_ID_SCHEMA,
316 password
=> $OPTIONAL_PASSWORD_SCHEMA,
319 returns
=> { type
=> 'null' },
323 PVE
::AccessControl
::assert_new_tfa_config_available
();
325 my $rpcenv = PVE
::RPCEnvironment
::get
();
326 my $authuser = $rpcenv->get_user();
328 root_permission_check
($rpcenv, $authuser, $param->{userid
}, $param->{password
});
330 my $has_entries_left = PVE
::AccessControl
::lock_tfa_config
(sub {
331 my $tfa_cfg = cfs_read_file
('priv/tfa.cfg');
332 my $has_entries_left = $tfa_cfg->api_delete_tfa($userid, $param->{id
});
333 cfs_write_file
('priv/tfa.cfg', $tfa_cfg);
334 return $has_entries_left;
336 if (!$has_entries_left) {
337 set_user_tfa_enabled
($userid, undef, undef);
341 __PACKAGE__-
>register_method ({
346 description
=> "Returns all or just the logged-in user, depending on privileges.",
349 protected
=> 1, # else we can't access shadow files
350 allowtoken
=> 0, # we don't want tokens to change the regular user's TFA settings
351 description
=> 'List TFA configurations of users.',
353 additionalProperties
=> 0,
357 description
=> "The list tuples of user and TFA entries.",
364 description
=> 'User this entry belongs to.',
368 items
=> $TYPED_TFA_ENTRY_SCHEMA,
376 my $rpcenv = PVE
::RPCEnvironment
::get
();
377 my $authuser = $rpcenv->get_user();
379 my $tfa_cfg = cfs_read_file
('priv/tfa.cfg');
380 my $entries = $tfa_cfg->api_list_tfa($authuser, 1);
382 my $privs = [ 'User.Modify', 'Sys.Audit' ];
383 if ($rpcenv->check_any($authuser, "/access/groups", $privs, 1)) {
388 my $groups = $rpcenv->filter_groups($authuser, $privs, 1);
389 my $allowed_users = $rpcenv->group_member_join([keys %$groups]);
392 my $userid = $_->{userid
};
393 $userid eq $authuser || $allowed_users->{$userid}
398 __PACKAGE__-
>register_method ({
399 name
=> 'add_tfa_entry',
404 ['userid-param', 'self'],
405 ['userid-group', ['User.Modify']],
408 protected
=> 1, # else we can't access shadow files
409 allowtoken
=> 0, # we don't want tokens to change the regular user's TFA settings
410 description
=> 'Add a TFA entry for a user.',
412 additionalProperties
=> 0,
414 userid
=> get_standard_option
('userid', {
415 completion
=> \
&PVE
::AccessControl
::complete_username
,
417 type
=> $TFA_TYPE_SCHEMA,
420 description
=> 'A description to distinguish multiple entries from one another',
426 description
=> "A totp URI.",
432 'The current value for the provided totp URI, or a Webauthn/U2F'
433 .' challenge response',
438 description
=> 'When responding to a u2f challenge: the original challenge string',
441 password
=> $OPTIONAL_PASSWORD_SCHEMA,
444 returns
=> $TFA_UPDATE_INFO_SCHEMA,
448 PVE
::AccessControl
::assert_new_tfa_config_available
();
450 my $rpcenv = PVE
::RPCEnvironment
::get
();
451 my $authuser = $rpcenv->get_user();
452 my ($userid, $realm) =
453 root_permission_check
($rpcenv, $authuser, $param->{userid
}, $param->{password
});
455 my $type = delete $param->{type
};
456 my $value = delete $param->{value
};
457 if ($type eq 'yubico') {
458 $value = validate_yubico_otp
($userid, $realm, $value);
461 return PVE
::AccessControl
::lock_tfa_config
(sub {
462 my $tfa_cfg = cfs_read_file
('priv/tfa.cfg');
464 set_user_tfa_enabled
($userid, $realm, $tfa_cfg);
466 PVE
::AccessControl
::configure_u2f_and_wa
($tfa_cfg);
468 my $response = $tfa_cfg->api_add_tfa_entry(
470 $param->{description
},
477 cfs_write_file
('priv/tfa.cfg', $tfa_cfg);
483 sub validate_yubico_otp
: prototype($$) {
484 my ($userid, $realm, $value) = @_;
486 my $domain_cfg = cfs_read_file
('domains.cfg');
487 my $realm_cfg = $domain_cfg->{ids
}->{$realm};
488 die "auth domain '$realm' does not exist\n" if !$realm_cfg;
490 my $realm_tfa = $realm_cfg->{tfa
};
491 die "no yubico otp configuration available for realm $realm\n"
494 $realm_tfa = PVE
::Auth
::Plugin
::parse_tfa_config
($realm_tfa);
495 die "realm is not setup for Yubico OTP\n"
496 if !$realm_tfa || $realm_tfa->{type
} ne 'yubico';
498 my $public_key = substr($value, 0, 12);
500 PVE
::AccessControl
::authenticate_yubico_do
($value, $public_key, $realm_tfa);
505 __PACKAGE__-
>register_method ({
506 name
=> 'update_tfa_entry',
507 path
=> '{userid}/{id}',
511 ['userid-param', 'self'],
512 ['userid-group', ['User.Modify']],
515 protected
=> 1, # else we can't access shadow files
516 allowtoken
=> 0, # we don't want tokens to change the regular user's TFA settings
517 description
=> 'Add a TFA entry for a user.',
519 additionalProperties
=> 0,
521 userid
=> get_standard_option
('userid', {
522 completion
=> \
&PVE
::AccessControl
::complete_username
,
524 id
=> $TFA_ID_SCHEMA,
527 description
=> 'A description to distinguish multiple entries from one another',
533 description
=> 'Whether the entry should be enabled for login.',
536 password
=> $OPTIONAL_PASSWORD_SCHEMA,
539 returns
=> { type
=> 'null' },
543 PVE
::AccessControl
::assert_new_tfa_config_available
();
545 my $rpcenv = PVE
::RPCEnvironment
::get
();
546 my $authuser = $rpcenv->get_user();
548 root_permission_check
($rpcenv, $authuser, $param->{userid
}, $param->{password
});
550 PVE
::AccessControl
::lock_tfa_config
(sub {
551 my $tfa_cfg = cfs_read_file
('priv/tfa.cfg');
553 $tfa_cfg->api_update_tfa_entry(
556 $param->{description
},
560 cfs_write_file
('priv/tfa.cfg', $tfa_cfg);