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, my $ruid, 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 my $domain_cfg = cfs_read_file
('domains.cfg');
115 my $cfg = $domain_cfg->{ids
}->{$realm};
116 die "auth domain '$realm' does not exist\n" if !$cfg;
117 my $plugin = PVE
::Auth
::Plugin-
>lookup($cfg->{type
});
118 $plugin->authenticate_user($cfg, $realm, $ruid, $password);
121 return wantarray ?
($userid, $realm) : $userid;
124 # Set TFA to enabled if $tfa_cfg is passed, or to disabled if $tfa_cfg is undef,
125 # When enabling we also merge the old user.cfg keys into the $tfa_cfg.
126 my sub set_user_tfa_enabled
: prototype($$$) {
127 my ($userid, $realm, $tfa_cfg) = @_;
129 PVE
::AccessControl
::lock_user_config
(sub {
130 my $user_cfg = cfs_read_file
('user.cfg');
131 my $user = $user_cfg->{users
}->{$userid};
132 my $keys = $user->{keys};
133 # When enabling, we convert old-old keys,
134 # When disabling, we shouldn't actually have old keys anymore, so if they are there,
135 # they'll be removed.
136 if ($tfa_cfg && $keys && $keys !~ /^x(?:!.*)?$/) {
137 my $domain_cfg = cfs_read_file
('domains.cfg');
138 my $realm_cfg = $domain_cfg->{ids
}->{$realm};
139 die "auth domain '$realm' does not exist\n" if !$realm_cfg;
141 my $realm_tfa = $realm_cfg->{tfa
};
142 $realm_tfa = PVE
::Auth
::Plugin
::parse_tfa_config
($realm_tfa) if $realm_tfa;
144 PVE
::AccessControl
::add_old_keys_to_realm_tfa
($userid, $tfa_cfg, $realm_tfa, $keys);
146 $user->{keys} = $tfa_cfg ?
'x' : undef;
147 cfs_write_file
("user.cfg", $user_cfg);
148 }, "enabling TFA for the user failed");
153 __PACKAGE__-
>register_method({
154 name
=> 'verify_tfa',
157 permissions
=> { user
=> 'all' },
158 protected
=> 1, # else we can't access shadow files
159 allowtoken
=> 0, # we don't want tokens to access TFA information
160 description
=> 'Finish a u2f challenge.',
162 additionalProperties
=> 0,
166 description
=> 'The response to the current authentication challenge.',
173 ticket
=> { type
=> 'string' },
180 my $rpcenv = PVE
::RPCEnvironment
::get
();
181 my $authuser = $rpcenv->get_user();
182 my ($username, undef, $realm) = PVE
::AccessControl
::verify_username
($authuser);
184 my ($tfa_type, $tfa_data) = PVE
::AccessControl
::user_get_tfa
($username, $realm, 0);
185 if (!defined($tfa_type)) {
186 raise
('no u2f data available');
190 if ($tfa_type eq 'u2f') {
191 my $challenge = $rpcenv->get_u2f_challenge()
192 or raise
('no active challenge');
194 my $keyHandle = $tfa_data->{keyHandle
};
195 my $publicKey = $tfa_data->{publicKey
};
196 raise
("incomplete u2f setup")
197 if !defined($keyHandle) || !defined($publicKey);
199 my $u2f = PVE
::API2
::AccessControl
::get_u2f_instance
($rpcenv, $publicKey, $keyHandle);
200 $u2f->set_challenge($challenge);
202 my ($counter, $present) = $u2f->auth_verify($param->{response
});
203 # Do we want to do anything with these?
205 # sanity check before handing off to the verification code:
206 my $keys = $tfa_data->{keys} or die "missing tfa keys\n";
207 my $config = $tfa_data->{config
} or die "bad tfa entry\n";
208 PVE
::AccessControl
::verify_one_time_pw
($tfa_type, $authuser, $keys, $config, $param->{response
});
212 my $clientip = $rpcenv->get_client_ip() || '';
213 syslog
('err', "authentication verification failure; rhost=$clientip user=$authuser msg=$err");
214 die PVE
::Exception-
>new("authentication failure\n", code
=> 401);
218 ticket
=> PVE
::AccessControl
::assemble_ticket
($authuser),
219 cap
=> $rpcenv->compute_api_permission($authuser),
225 __PACKAGE__-
>register_method ({
226 name
=> 'list_user_tfa',
231 ['userid-param', 'self'],
232 ['userid-group', ['User.Modify', 'Sys.Audit']],
235 protected
=> 1, # else we can't access shadow files
236 allowtoken
=> 0, # we don't want tokens to change the regular user's TFA settings
237 description
=> 'List TFA configurations of users.',
239 additionalProperties
=> 0,
241 userid
=> get_standard_option
('userid', {
242 completion
=> \
&PVE
::AccessControl
::complete_username
,
247 description
=> "A list of the user's TFA entries.",
249 items
=> $TYPED_TFA_ENTRY_SCHEMA,
253 my $tfa_cfg = cfs_read_file
('priv/tfa.cfg');
254 return $tfa_cfg->api_list_user_tfa($param->{userid
});
257 __PACKAGE__-
>register_method ({
258 name
=> 'get_tfa_entry',
259 path
=> '{userid}/{id}',
263 ['userid-param', 'self'],
264 ['userid-group', ['User.Modify', 'Sys.Audit']],
267 protected
=> 1, # else we can't access shadow files
268 allowtoken
=> 0, # we don't want tokens to change the regular user's TFA settings
269 description
=> 'Fetch a requested TFA entry if present.',
271 additionalProperties
=> 0,
273 userid
=> get_standard_option
('userid', {
274 completion
=> \
&PVE
::AccessControl
::complete_username
,
276 id
=> $TFA_ID_SCHEMA,
279 returns
=> $TYPED_TFA_ENTRY_SCHEMA,
282 my $tfa_cfg = cfs_read_file
('priv/tfa.cfg');
283 my $id = $param->{id
};
284 my $entry = $tfa_cfg->api_get_tfa_entry($param->{userid
}, $id);
285 raise
("No such tfa entry '$id'", code
=> HTTP
::Status
::HTTP_NOT_FOUND
) if !$entry;
289 __PACKAGE__-
>register_method ({
290 name
=> 'delete_tfa',
291 path
=> '{userid}/{id}',
295 ['userid-param', 'self'],
296 ['userid-group', ['User.Modify']],
299 protected
=> 1, # else we can't access shadow files
300 allowtoken
=> 0, # we don't want tokens to change the regular user's TFA settings
301 description
=> 'Delete a TFA entry by ID.',
303 additionalProperties
=> 0,
305 userid
=> get_standard_option
('userid', {
306 completion
=> \
&PVE
::AccessControl
::complete_username
,
308 id
=> $TFA_ID_SCHEMA,
309 password
=> $OPTIONAL_PASSWORD_SCHEMA,
312 returns
=> { type
=> 'null' },
316 PVE
::AccessControl
::assert_new_tfa_config_available
();
318 my $rpcenv = PVE
::RPCEnvironment
::get
();
319 my $authuser = $rpcenv->get_user();
321 root_permission_check
($rpcenv, $authuser, $param->{userid
}, $param->{password
});
323 my $has_entries_left = PVE
::AccessControl
::lock_tfa_config
(sub {
324 my $tfa_cfg = cfs_read_file
('priv/tfa.cfg');
325 my $has_entries_left = $tfa_cfg->api_delete_tfa($userid, $param->{id
});
326 cfs_write_file
('priv/tfa.cfg', $tfa_cfg);
327 return $has_entries_left;
329 if (!$has_entries_left) {
330 set_user_tfa_enabled
($userid, undef, undef);
334 __PACKAGE__-
>register_method ({
339 description
=> "Returns all or just the logged-in user, depending on privileges.",
342 protected
=> 1, # else we can't access shadow files
343 allowtoken
=> 0, # we don't want tokens to change the regular user's TFA settings
344 description
=> 'List TFA configurations of users.',
346 additionalProperties
=> 0,
350 description
=> "The list tuples of user and TFA entries.",
357 description
=> 'User this entry belongs to.',
361 items
=> $TYPED_TFA_ENTRY_SCHEMA,
369 my $rpcenv = PVE
::RPCEnvironment
::get
();
370 my $authuser = $rpcenv->get_user();
371 my $top_level_allowed = ($authuser eq 'root@pam');
373 my $tfa_cfg = cfs_read_file
('priv/tfa.cfg');
374 return $tfa_cfg->api_list_tfa($authuser, $top_level_allowed);
377 __PACKAGE__-
>register_method ({
378 name
=> 'add_tfa_entry',
383 ['userid-param', 'self'],
384 ['userid-group', ['User.Modify']],
387 protected
=> 1, # else we can't access shadow files
388 allowtoken
=> 0, # we don't want tokens to change the regular user's TFA settings
389 description
=> 'Add a TFA entry for a user.',
391 additionalProperties
=> 0,
393 userid
=> get_standard_option
('userid', {
394 completion
=> \
&PVE
::AccessControl
::complete_username
,
396 type
=> $TFA_TYPE_SCHEMA,
399 description
=> 'A description to distinguish multiple entries from one another',
405 description
=> "A totp URI.",
411 'The current value for the provided totp URI, or a Webauthn/U2F'
412 .' challenge response',
417 description
=> 'When responding to a u2f challenge: the original challenge string',
420 password
=> $OPTIONAL_PASSWORD_SCHEMA,
423 returns
=> $TFA_UPDATE_INFO_SCHEMA,
427 PVE
::AccessControl
::assert_new_tfa_config_available
();
429 my $rpcenv = PVE
::RPCEnvironment
::get
();
430 my $authuser = $rpcenv->get_user();
431 my ($userid, $realm) =
432 root_permission_check
($rpcenv, $authuser, $param->{userid
}, $param->{password
});
434 my $type = delete $param->{type
};
435 my $value = delete $param->{value
};
436 if ($type eq 'yubico') {
437 $value = validate_yubico_otp
($userid, $realm, $value);
440 return PVE
::AccessControl
::lock_tfa_config
(sub {
441 my $tfa_cfg = cfs_read_file
('priv/tfa.cfg');
443 set_user_tfa_enabled
($userid, $realm, $tfa_cfg);
445 PVE
::AccessControl
::configure_u2f_and_wa
($tfa_cfg);
447 my $response = $tfa_cfg->api_add_tfa_entry(
449 $param->{description
},
456 cfs_write_file
('priv/tfa.cfg', $tfa_cfg);
462 sub validate_yubico_otp
: prototype($$) {
463 my ($userid, $realm, $value) = @_;
465 my $domain_cfg = cfs_read_file
('domains.cfg');
466 my $realm_cfg = $domain_cfg->{ids
}->{$realm};
467 die "auth domain '$realm' does not exist\n" if !$realm_cfg;
469 my $realm_tfa = $realm_cfg->{tfa
};
470 die "no yubico otp configuration available for realm $realm\n"
473 $realm_tfa = PVE
::Auth
::Plugin
::parse_tfa_config
($realm_tfa);
474 die "realm is not setup for Yubico OTP\n"
475 if !$realm_tfa || $realm_tfa->{type
} ne 'yubico';
477 my $public_key = substr($value, 0, 12);
479 PVE
::AccessControl
::authenticate_yubico_do
($value, $public_key, $realm_tfa);
484 __PACKAGE__-
>register_method ({
485 name
=> 'update_tfa_entry',
486 path
=> '{userid}/{id}',
490 ['userid-param', 'self'],
491 ['userid-group', ['User.Modify']],
494 protected
=> 1, # else we can't access shadow files
495 allowtoken
=> 0, # we don't want tokens to change the regular user's TFA settings
496 description
=> 'Add a TFA entry for a user.',
498 additionalProperties
=> 0,
500 userid
=> get_standard_option
('userid', {
501 completion
=> \
&PVE
::AccessControl
::complete_username
,
503 id
=> $TFA_ID_SCHEMA,
506 description
=> 'A description to distinguish multiple entries from one another',
512 description
=> 'Whether the entry should be enabled for login.',
515 password
=> $OPTIONAL_PASSWORD_SCHEMA,
518 returns
=> { type
=> 'null' },
522 PVE
::AccessControl
::assert_new_tfa_config_available
();
524 my $rpcenv = PVE
::RPCEnvironment
::get
();
525 my $authuser = $rpcenv->get_user();
527 root_permission_check
($rpcenv, $authuser, $param->{userid
}, $param->{password
});
529 PVE
::AccessControl
::lock_tfa_config
(sub {
530 my $tfa_cfg = cfs_read_file
('priv/tfa.cfg');
532 $tfa_cfg->api_update_tfa_entry(
535 $param->{description
},
539 cfs_write_file
('priv/tfa.cfg', $tfa_cfg);