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 # Set TFA to enabled if $tfa_cfg is passed, or to disabled if $tfa_cfg is undef,
123 # When enabling we also merge the old user.cfg keys into the $tfa_cfg.
124 my sub set_user_tfa_enabled
: prototype($$$) {
125 my ($userid, $realm, $tfa_cfg) = @_;
127 PVE
::AccessControl
::lock_user_config
(sub {
128 my $user_cfg = cfs_read_file
('user.cfg');
129 my $user = $user_cfg->{users
}->{$userid};
130 my $keys = $user->{keys};
131 # When enabling, we convert old-old keys,
132 # When disabling, we shouldn't actually have old keys anymore, so if they are there,
133 # they'll be removed.
134 if ($tfa_cfg && $keys && $keys !~ /^x(?:!.*)?$/) {
135 my $domain_cfg = cfs_read_file
('domains.cfg');
136 my $realm_cfg = $domain_cfg->{ids
}->{$realm};
137 die "auth domain '$realm' does not exist\n" if !$realm_cfg;
139 my $realm_tfa = $realm_cfg->{tfa
};
140 $realm_tfa = PVE
::Auth
::Plugin
::parse_tfa_config
($realm_tfa) if $realm_tfa;
142 PVE
::AccessControl
::add_old_keys_to_realm_tfa
($userid, $tfa_cfg, $realm_tfa, $keys);
144 $user->{keys} = $tfa_cfg ?
'x' : undef;
145 cfs_write_file
("user.cfg", $user_cfg);
146 }, "enabling TFA for the user failed");
151 __PACKAGE__-
>register_method({
152 name
=> 'verify_tfa',
155 permissions
=> { user
=> 'all' },
156 protected
=> 1, # else we can't access shadow files
157 allowtoken
=> 0, # we don't want tokens to access TFA information
158 description
=> 'Finish a u2f challenge.',
160 additionalProperties
=> 0,
164 description
=> 'The response to the current authentication challenge.',
171 ticket
=> { type
=> 'string' },
178 my $rpcenv = PVE
::RPCEnvironment
::get
();
179 my $authuser = $rpcenv->get_user();
180 my ($username, undef, $realm) = PVE
::AccessControl
::verify_username
($authuser);
182 my ($tfa_type, $tfa_data) = PVE
::AccessControl
::user_get_tfa
($username, $realm, 0);
183 if (!defined($tfa_type)) {
184 raise
('no u2f data available');
188 if ($tfa_type eq 'u2f') {
189 my $challenge = $rpcenv->get_u2f_challenge()
190 or raise
('no active challenge');
192 my $keyHandle = $tfa_data->{keyHandle
};
193 my $publicKey = $tfa_data->{publicKey
};
194 raise
("incomplete u2f setup")
195 if !defined($keyHandle) || !defined($publicKey);
197 my $u2f = PVE
::API2
::AccessControl
::get_u2f_instance
($rpcenv, $publicKey, $keyHandle);
198 $u2f->set_challenge($challenge);
200 my ($counter, $present) = $u2f->auth_verify($param->{response
});
201 # Do we want to do anything with these?
203 # sanity check before handing off to the verification code:
204 my $keys = $tfa_data->{keys} or die "missing tfa keys\n";
205 my $config = $tfa_data->{config
} or die "bad tfa entry\n";
206 PVE
::AccessControl
::verify_one_time_pw
($tfa_type, $authuser, $keys, $config, $param->{response
});
210 my $clientip = $rpcenv->get_client_ip() || '';
211 syslog
('err', "authentication verification failure; rhost=$clientip user=$authuser msg=$err");
212 die PVE
::Exception-
>new("authentication failure\n", code
=> 401);
216 ticket
=> PVE
::AccessControl
::assemble_ticket
($authuser),
217 cap
=> $rpcenv->compute_api_permission($authuser),
223 __PACKAGE__-
>register_method ({
224 name
=> 'list_user_tfa',
229 ['userid-param', 'self'],
230 ['userid-group', ['User.Modify', 'Sys.Audit']],
233 protected
=> 1, # else we can't access shadow files
234 allowtoken
=> 0, # we don't want tokens to change the regular user's TFA settings
235 description
=> 'List TFA configurations of users.',
237 additionalProperties
=> 0,
239 userid
=> get_standard_option
('userid', {
240 completion
=> \
&PVE
::AccessControl
::complete_username
,
245 description
=> "A list of the user's TFA entries.",
247 items
=> $TYPED_TFA_ENTRY_SCHEMA,
251 my $tfa_cfg = cfs_read_file
('priv/tfa.cfg');
252 return $tfa_cfg->api_list_user_tfa($param->{userid
});
255 __PACKAGE__-
>register_method ({
256 name
=> 'get_tfa_entry',
257 path
=> '{userid}/{id}',
261 ['userid-param', 'self'],
262 ['userid-group', ['User.Modify', 'Sys.Audit']],
265 protected
=> 1, # else we can't access shadow files
266 allowtoken
=> 0, # we don't want tokens to change the regular user's TFA settings
267 description
=> 'Fetch a requested TFA entry if present.',
269 additionalProperties
=> 0,
271 userid
=> get_standard_option
('userid', {
272 completion
=> \
&PVE
::AccessControl
::complete_username
,
274 id
=> $TFA_ID_SCHEMA,
277 returns
=> $TYPED_TFA_ENTRY_SCHEMA,
280 my $tfa_cfg = cfs_read_file
('priv/tfa.cfg');
281 my $id = $param->{id
};
282 my $entry = $tfa_cfg->api_get_tfa_entry($param->{userid
}, $id);
283 raise
("No such tfa entry '$id'", 404) if !$entry;
287 __PACKAGE__-
>register_method ({
288 name
=> 'delete_tfa',
289 path
=> '{userid}/{id}',
293 ['userid-param', 'self'],
294 ['userid-group', ['User.Modify']],
297 protected
=> 1, # else we can't access shadow files
298 allowtoken
=> 0, # we don't want tokens to change the regular user's TFA settings
299 description
=> 'Delete a TFA entry by ID.',
301 additionalProperties
=> 0,
303 userid
=> get_standard_option
('userid', {
304 completion
=> \
&PVE
::AccessControl
::complete_username
,
306 id
=> $TFA_ID_SCHEMA,
307 password
=> $OPTIONAL_PASSWORD_SCHEMA,
310 returns
=> { type
=> 'null' },
314 PVE
::AccessControl
::assert_new_tfa_config_available
();
316 my $rpcenv = PVE
::RPCEnvironment
::get
();
317 my $authuser = $rpcenv->get_user();
319 root_permission_check
($rpcenv, $authuser, $param->{userid
}, $param->{password
});
321 my $has_entries_left = PVE
::AccessControl
::lock_tfa_config
(sub {
322 my $tfa_cfg = cfs_read_file
('priv/tfa.cfg');
323 my $has_entries_left = $tfa_cfg->api_delete_tfa($userid, $param->{id
});
324 cfs_write_file
('priv/tfa.cfg', $tfa_cfg);
325 return $has_entries_left;
327 if (!$has_entries_left) {
328 set_user_tfa_enabled
($userid, undef, undef);
332 __PACKAGE__-
>register_method ({
337 description
=> "Returns all or just the logged-in user, depending on privileges.",
340 protected
=> 1, # else we can't access shadow files
341 allowtoken
=> 0, # we don't want tokens to change the regular user's TFA settings
342 description
=> 'List TFA configurations of users.',
344 additionalProperties
=> 0,
348 description
=> "The list tuples of user and TFA entries.",
355 description
=> 'User this entry belongs to.',
359 items
=> $TYPED_TFA_ENTRY_SCHEMA,
367 my $rpcenv = PVE
::RPCEnvironment
::get
();
368 my $authuser = $rpcenv->get_user();
369 my $top_level_allowed = ($authuser eq 'root@pam');
371 my $tfa_cfg = cfs_read_file
('priv/tfa.cfg');
372 return $tfa_cfg->api_list_tfa($authuser, $top_level_allowed);
375 __PACKAGE__-
>register_method ({
376 name
=> 'add_tfa_entry',
381 ['userid-param', 'self'],
382 ['userid-group', ['User.Modify']],
385 protected
=> 1, # else we can't access shadow files
386 allowtoken
=> 0, # we don't want tokens to change the regular user's TFA settings
387 description
=> 'Add a TFA entry for a user.',
389 additionalProperties
=> 0,
391 userid
=> get_standard_option
('userid', {
392 completion
=> \
&PVE
::AccessControl
::complete_username
,
394 type
=> $TFA_TYPE_SCHEMA,
397 description
=> 'A description to distinguish multiple entries from one another',
403 description
=> "A totp URI.",
409 'The current value for the provided totp URI, or a Webauthn/U2F'
410 .' challenge response',
415 description
=> 'When responding to a u2f challenge: the original challenge string',
418 password
=> $OPTIONAL_PASSWORD_SCHEMA,
421 returns
=> $TFA_UPDATE_INFO_SCHEMA,
425 PVE
::AccessControl
::assert_new_tfa_config_available
();
427 my $rpcenv = PVE
::RPCEnvironment
::get
();
428 my $authuser = $rpcenv->get_user();
429 my ($userid, $realm) =
430 root_permission_check
($rpcenv, $authuser, $param->{userid
}, $param->{password
});
432 my $type = delete $param->{type
};
433 my $value = delete $param->{value
};
434 if ($type eq 'yubico') {
435 $value = validate_yubico_otp
($userid, $realm, $value);
438 return PVE
::AccessControl
::lock_tfa_config
(sub {
439 my $tfa_cfg = cfs_read_file
('priv/tfa.cfg');
441 set_user_tfa_enabled
($userid, $realm, $tfa_cfg);
443 PVE
::AccessControl
::configure_u2f_and_wa
($tfa_cfg);
445 my $response = $tfa_cfg->api_add_tfa_entry(
447 $param->{description
},
454 cfs_write_file
('priv/tfa.cfg', $tfa_cfg);
460 sub validate_yubico_otp
: prototype($$) {
461 my ($userid, $realm, $value) = @_;
463 my $domain_cfg = cfs_read_file
('domains.cfg');
464 my $realm_cfg = $domain_cfg->{ids
}->{$realm};
465 die "auth domain '$realm' does not exist\n" if !$realm_cfg;
467 my $realm_tfa = $realm_cfg->{tfa
};
468 die "no yubico otp configuration available for realm $realm\n"
471 $realm_tfa = PVE
::Auth
::Plugin
::parse_tfa_config
($realm_tfa);
472 die "realm is not setup for Yubico OTP\n"
473 if !$realm_tfa || $realm_tfa->{type
} ne 'yubico';
475 my $public_key = substr($value, 0, 12);
477 PVE
::AccessControl
::authenticate_yubico_do
($value, $public_key, $realm_tfa);
482 __PACKAGE__-
>register_method ({
483 name
=> 'update_tfa_entry',
484 path
=> '{userid}/{id}',
488 ['userid-param', 'self'],
489 ['userid-group', ['User.Modify']],
492 protected
=> 1, # else we can't access shadow files
493 allowtoken
=> 0, # we don't want tokens to change the regular user's TFA settings
494 description
=> 'Add a TFA entry for a user.',
496 additionalProperties
=> 0,
498 userid
=> get_standard_option
('userid', {
499 completion
=> \
&PVE
::AccessControl
::complete_username
,
501 id
=> $TFA_ID_SCHEMA,
504 description
=> 'A description to distinguish multiple entries from one another',
510 description
=> 'Whether the entry should be enabled for login.',
513 password
=> $OPTIONAL_PASSWORD_SCHEMA,
516 returns
=> { type
=> 'null' },
520 PVE
::AccessControl
::assert_new_tfa_config_available
();
522 my $rpcenv = PVE
::RPCEnvironment
::get
();
523 my $authuser = $rpcenv->get_user();
525 root_permission_check
($rpcenv, $authuser, $param->{userid
}, $param->{password
});
527 PVE
::AccessControl
::lock_tfa_config
(sub {
528 my $tfa_cfg = cfs_read_file
('priv/tfa.cfg');
530 $tfa_cfg->api_update_tfa_entry(
533 $param->{description
},
537 cfs_write_file
('priv/tfa.cfg', $tfa_cfg);