1 package PVE
::API2
::TFA
;
6 use PVE
::AccessControl
;
7 use PVE
::Cluster
qw(cfs_read_file cfs_write_file);
8 use PVE
::JSONSchema
qw(get_standard_option);
9 use PVE
::Exception
qw(raise raise_perm_exc raise_param_exc);
10 use PVE
::RPCEnvironment
;
12 use PVE
::API2
::AccessControl
; # for old login api get_u2f_instance method
16 use base
qw(PVE::RESTHandler);
18 my $OPTIONAL_PASSWORD_SCHEMA = {
19 description
=> "The current password.",
21 optional
=> 1, # Only required if not root@pam
26 my $TFA_TYPE_SCHEMA = {
28 description
=> 'TFA Entry Type.',
29 enum
=> [qw(totp u2f webauthn recovery yubico)],
32 my %TFA_INFO_PROPERTIES = (
35 description
=> 'The id used to reference this entry.',
39 description
=> 'User chosen description for this entry.',
43 description
=> 'Creation time of this entry as unix epoch.',
47 description
=> 'Whether this TFA entry is currently enabled.',
53 my $TYPED_TFA_ENTRY_SCHEMA = {
55 description
=> 'TFA Entry.',
57 type
=> $TFA_TYPE_SCHEMA,
64 description
=> 'A TFA entry id.',
67 my $TFA_UPDATE_INFO_SCHEMA = {
72 description
=> 'The id of a newly added TFA entry.',
78 'When adding u2f entries, this contains a challenge the user must respond to in order'
79 .' to finish the registration.'
85 'When adding recovery codes, this contains the list of codes to be displayed to'
89 description
=> 'A recovery entry.'
95 # Only root may modify root, regular users need to specify their password.
97 # Returns the userid returned from `verify_username`.
98 # Or ($userid, $realm) in list context.
99 my sub root_permission_check
: prototype($$$$) {
100 my ($rpcenv, $authuser, $userid, $password) = @_;
102 ($userid, my $ruid, my $realm) = PVE
::AccessControl
::verify_username
($userid);
103 $rpcenv->check_user_exist($userid);
105 raise_perm_exc
() if $userid eq 'root@pam' && $authuser ne 'root@pam';
107 # Regular users need to confirm their password to change TFA settings.
108 if ($authuser ne 'root@pam') {
109 raise_param_exc
({ 'password' => 'password is required to modify TFA data' })
110 if !defined($password);
112 my $domain_cfg = cfs_read_file
('domains.cfg');
113 my $cfg = $domain_cfg->{ids
}->{$realm};
114 die "auth domain '$realm' does not exist\n" if !$cfg;
115 my $plugin = PVE
::Auth
::Plugin-
>lookup($cfg->{type
});
116 $plugin->authenticate_user($cfg, $realm, $ruid, $password);
119 return wantarray ?
($userid, $realm) : $userid;
122 my sub set_user_tfa_enabled
: prototype($$) {
123 my ($userid, $enabled) = @_;
125 PVE
::AccessControl
::lock_user_config
(sub {
126 my $user_cfg = cfs_read_file
('user.cfg');
127 my $user = $user_cfg->{users
}->{$userid};
128 my $keys = $user->{keys};
129 if ($keys && $keys !~ /^x(?:!.*)?$/) {
130 die "user contains tfa keys directly in user.cfg,"
131 ." please remove them and add them via the TFA panel instead\n";
133 $user->{keys} = $enabled ?
'x' : undef;
134 cfs_write_file
("user.cfg", $user_cfg);
135 }, "enabling TFA for the user failed");
140 __PACKAGE__-
>register_method({
141 name
=> 'verify_tfa',
144 permissions
=> { user
=> 'all' },
145 protected
=> 1, # else we can't access shadow files
146 allowtoken
=> 0, # we don't want tokens to access TFA information
147 description
=> 'Finish a u2f challenge.',
149 additionalProperties
=> 0,
153 description
=> 'The response to the current authentication challenge.',
160 ticket
=> { type
=> 'string' },
167 my $rpcenv = PVE
::RPCEnvironment
::get
();
168 my $authuser = $rpcenv->get_user();
169 my ($username, undef, $realm) = PVE
::AccessControl
::verify_username
($authuser);
171 my ($tfa_type, $tfa_data) = PVE
::AccessControl
::user_get_tfa
($username, $realm, 0);
172 if (!defined($tfa_type)) {
173 raise
('no u2f data available');
177 if ($tfa_type eq 'u2f') {
178 my $challenge = $rpcenv->get_u2f_challenge()
179 or raise
('no active challenge');
181 my $keyHandle = $tfa_data->{keyHandle
};
182 my $publicKey = $tfa_data->{publicKey
};
183 raise
("incomplete u2f setup")
184 if !defined($keyHandle) || !defined($publicKey);
186 my $u2f = PVE
::API2
::AccessControl
::get_u2f_instance
($rpcenv, $publicKey, $keyHandle);
187 $u2f->set_challenge($challenge);
189 my ($counter, $present) = $u2f->auth_verify($param->{response
});
190 # Do we want to do anything with these?
192 # sanity check before handing off to the verification code:
193 my $keys = $tfa_data->{keys} or die "missing tfa keys\n";
194 my $config = $tfa_data->{config
} or die "bad tfa entry\n";
195 PVE
::AccessControl
::verify_one_time_pw
($tfa_type, $authuser, $keys, $config, $param->{response
});
199 my $clientip = $rpcenv->get_client_ip() || '';
200 syslog
('err', "authentication verification failure; rhost=$clientip user=$authuser msg=$err");
201 die PVE
::Exception-
>new("authentication failure\n", code
=> 401);
205 ticket
=> PVE
::AccessControl
::assemble_ticket
($authuser),
206 cap
=> $rpcenv->compute_api_permission($authuser),
212 __PACKAGE__-
>register_method ({
213 name
=> 'list_user_tfa',
218 ['userid-param', 'self'],
219 ['userid-group', ['User.Modify', 'Sys.Audit']],
222 protected
=> 1, # else we can't access shadow files
223 allowtoken
=> 0, # we don't want tokens to change the regular user's TFA settings
224 description
=> 'List TFA configurations of users.',
226 additionalProperties
=> 0,
228 userid
=> get_standard_option
('userid', {
229 completion
=> \
&PVE
::AccessControl
::complete_username
,
234 description
=> "A list of the user's TFA entries.",
236 items
=> $TYPED_TFA_ENTRY_SCHEMA,
240 my $tfa_cfg = cfs_read_file
('priv/tfa.cfg');
241 return $tfa_cfg->api_list_user_tfa($param->{userid
});
244 __PACKAGE__-
>register_method ({
245 name
=> 'get_tfa_entry',
246 path
=> '{userid}/{id}',
250 ['userid-param', 'self'],
251 ['userid-group', ['User.Modify', 'Sys.Audit']],
254 protected
=> 1, # else we can't access shadow files
255 allowtoken
=> 0, # we don't want tokens to change the regular user's TFA settings
256 description
=> 'Fetch a requested TFA entry if present.',
258 additionalProperties
=> 0,
260 userid
=> get_standard_option
('userid', {
261 completion
=> \
&PVE
::AccessControl
::complete_username
,
263 id
=> $TFA_ID_SCHEMA,
266 returns
=> $TYPED_TFA_ENTRY_SCHEMA,
269 my $tfa_cfg = cfs_read_file
('priv/tfa.cfg');
270 my $id = $param->{id
};
271 my $entry = $tfa_cfg->api_get_tfa_entry($param->{userid
}, $id);
272 raise
("No such tfa entry '$id'", 404) if !$entry;
276 __PACKAGE__-
>register_method ({
277 name
=> 'delete_tfa',
278 path
=> '{userid}/{id}',
282 ['userid-param', 'self'],
283 ['userid-group', ['User.Modify']],
286 protected
=> 1, # else we can't access shadow files
287 allowtoken
=> 0, # we don't want tokens to change the regular user's TFA settings
288 description
=> 'Delete a TFA entry by ID.',
290 additionalProperties
=> 0,
292 userid
=> get_standard_option
('userid', {
293 completion
=> \
&PVE
::AccessControl
::complete_username
,
295 id
=> $TFA_ID_SCHEMA,
296 password
=> $OPTIONAL_PASSWORD_SCHEMA,
299 returns
=> { type
=> 'null' },
303 PVE
::AccessControl
::assert_new_tfa_config_available
();
305 my $rpcenv = PVE
::RPCEnvironment
::get
();
306 my $authuser = $rpcenv->get_user();
308 root_permission_check
($rpcenv, $authuser, $param->{userid
}, $param->{password
});
310 my $has_entries_left = PVE
::AccessControl
::lock_tfa_config
(sub {
311 my $tfa_cfg = cfs_read_file
('priv/tfa.cfg');
312 my $has_entries_left = $tfa_cfg->api_delete_tfa($userid, $param->{id
});
313 cfs_write_file
('priv/tfa.cfg', $tfa_cfg);
314 return $has_entries_left;
316 if (!$has_entries_left) {
317 set_user_tfa_enabled
($userid, 0);
321 __PACKAGE__-
>register_method ({
326 description
=> "Returns all or just the logged-in user, depending on privileges.",
329 protected
=> 1, # else we can't access shadow files
330 allowtoken
=> 0, # we don't want tokens to change the regular user's TFA settings
331 description
=> 'List TFA configurations of users.',
333 additionalProperties
=> 0,
337 description
=> "The list tuples of user and TFA entries.",
344 description
=> 'User this entry belongs to.',
348 items
=> $TYPED_TFA_ENTRY_SCHEMA,
356 my $rpcenv = PVE
::RPCEnvironment
::get
();
357 my $authuser = $rpcenv->get_user();
358 my $top_level_allowed = ($authuser eq 'root@pam');
360 my $tfa_cfg = cfs_read_file
('priv/tfa.cfg');
361 return $tfa_cfg->api_list_tfa($authuser, $top_level_allowed);
364 __PACKAGE__-
>register_method ({
365 name
=> 'add_tfa_entry',
370 ['userid-param', 'self'],
371 ['userid-group', ['User.Modify']],
374 protected
=> 1, # else we can't access shadow files
375 allowtoken
=> 0, # we don't want tokens to change the regular user's TFA settings
376 description
=> 'Add a TFA entry for a user.',
378 additionalProperties
=> 0,
380 userid
=> get_standard_option
('userid', {
381 completion
=> \
&PVE
::AccessControl
::complete_username
,
383 type
=> $TFA_TYPE_SCHEMA,
386 description
=> 'A description to distinguish multiple entries from one another',
392 description
=> "A totp URI.",
398 'The current value for the provided totp URI, or a Webauthn/U2F'
399 .' challenge response',
404 description
=> 'When responding to a u2f challenge: the original challenge string',
407 password
=> $OPTIONAL_PASSWORD_SCHEMA,
410 returns
=> $TFA_UPDATE_INFO_SCHEMA,
414 PVE
::AccessControl
::assert_new_tfa_config_available
();
416 my $rpcenv = PVE
::RPCEnvironment
::get
();
417 my $authuser = $rpcenv->get_user();
418 my ($userid, $realm) =
419 root_permission_check
($rpcenv, $authuser, $param->{userid
}, $param->{password
});
421 my $type = delete $param->{type
};
422 my $value = delete $param->{value
};
423 if ($type eq 'yubico') {
424 $value = validate_yubico_otp
($userid, $realm, $value);
427 set_user_tfa_enabled
($userid, 1);
429 return PVE
::AccessControl
::lock_tfa_config
(sub {
430 my $tfa_cfg = cfs_read_file
('priv/tfa.cfg');
431 PVE
::AccessControl
::configure_u2f_and_wa
($tfa_cfg);
433 my $response = $tfa_cfg->api_add_tfa_entry(
435 $param->{description
},
442 cfs_write_file
('priv/tfa.cfg', $tfa_cfg);
448 sub validate_yubico_otp
: prototype($$) {
449 my ($userid, $realm, $value) = @_;
451 my $domain_cfg = cfs_read_file
('domains.cfg');
452 my $realm_cfg = $domain_cfg->{ids
}->{$realm};
453 die "auth domain '$realm' does not exist\n" if !$realm_cfg;
455 my $realm_tfa = $realm_cfg->{tfa
};
456 die "no yubico otp configuration available for realm $realm\n"
459 $realm_tfa = PVE
::Auth
::Plugin
::parse_tfa_config
($realm_tfa);
460 die "realm is not setup for Yubico OTP\n"
461 if !$realm_tfa || $realm_tfa->{type
} ne 'yubico';
463 my $public_key = substr($value, 0, 12);
465 PVE
::AccessControl
::authenticate_yubico_do
($value, $public_key, $realm_tfa);
470 __PACKAGE__-
>register_method ({
471 name
=> 'update_tfa_entry',
472 path
=> '{userid}/{id}',
476 ['userid-param', 'self'],
477 ['userid-group', ['User.Modify']],
480 protected
=> 1, # else we can't access shadow files
481 allowtoken
=> 0, # we don't want tokens to change the regular user's TFA settings
482 description
=> 'Add a TFA entry for a user.',
484 additionalProperties
=> 0,
486 userid
=> get_standard_option
('userid', {
487 completion
=> \
&PVE
::AccessControl
::complete_username
,
489 id
=> $TFA_ID_SCHEMA,
492 description
=> 'A description to distinguish multiple entries from one another',
498 description
=> 'Whether the entry should be enabled for login.',
501 password
=> $OPTIONAL_PASSWORD_SCHEMA,
504 returns
=> { type
=> 'null' },
508 PVE
::AccessControl
::assert_new_tfa_config_available
();
510 my $rpcenv = PVE
::RPCEnvironment
::get
();
511 my $authuser = $rpcenv->get_user();
513 root_permission_check
($rpcenv, $authuser, $param->{userid
}, $param->{password
});
515 PVE
::AccessControl
::lock_tfa_config
(sub {
516 my $tfa_cfg = cfs_read_file
('priv/tfa.cfg');
518 $tfa_cfg->api_update_tfa_entry(
521 $param->{description
},
525 cfs_write_file
('priv/tfa.cfg', $tfa_cfg);