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');
188 if ($tfa_type eq 'incompatible') {
189 raise
('tfa entries incompatible with old login api');
193 if ($tfa_type eq 'u2f') {
194 my $challenge = $rpcenv->get_u2f_challenge()
195 or raise
('no active challenge');
197 my $keyHandle = $tfa_data->{keyHandle
};
198 my $publicKey = $tfa_data->{publicKey
};
199 raise
("incomplete u2f setup")
200 if !defined($keyHandle) || !defined($publicKey);
202 my $u2f = PVE
::API2
::AccessControl
::get_u2f_instance
($rpcenv, $publicKey, $keyHandle);
203 $u2f->set_challenge($challenge);
205 my ($counter, $present) = $u2f->auth_verify($param->{response
});
206 # Do we want to do anything with these?
208 # sanity check before handing off to the verification code:
209 my $keys = $tfa_data->{keys} or die "missing tfa keys\n";
210 my $config = $tfa_data->{config
} or die "bad tfa entry\n";
211 PVE
::AccessControl
::verify_one_time_pw
($tfa_type, $authuser, $keys, $config, $param->{response
});
215 my $clientip = $rpcenv->get_client_ip() || '';
216 syslog
('err', "authentication verification failure; rhost=$clientip user=$authuser msg=$err");
217 die PVE
::Exception-
>new("authentication failure\n", code
=> 401);
221 ticket
=> PVE
::AccessControl
::assemble_ticket
($authuser),
222 cap
=> $rpcenv->compute_api_permission($authuser),
228 __PACKAGE__-
>register_method ({
229 name
=> 'list_user_tfa',
234 ['userid-param', 'self'],
235 ['userid-group', ['User.Modify', 'Sys.Audit']],
238 protected
=> 1, # else we can't access shadow files
239 allowtoken
=> 0, # we don't want tokens to change the regular user's TFA settings
240 description
=> 'List TFA configurations of users.',
242 additionalProperties
=> 0,
244 userid
=> get_standard_option
('userid', {
245 completion
=> \
&PVE
::AccessControl
::complete_username
,
250 description
=> "A list of the user's TFA entries.",
252 items
=> $TYPED_TFA_ENTRY_SCHEMA,
256 my $tfa_cfg = cfs_read_file
('priv/tfa.cfg');
257 return $tfa_cfg->api_list_user_tfa($param->{userid
});
260 __PACKAGE__-
>register_method ({
261 name
=> 'get_tfa_entry',
262 path
=> '{userid}/{id}',
266 ['userid-param', 'self'],
267 ['userid-group', ['User.Modify', 'Sys.Audit']],
270 protected
=> 1, # else we can't access shadow files
271 allowtoken
=> 0, # we don't want tokens to change the regular user's TFA settings
272 description
=> 'Fetch a requested TFA entry if present.',
274 additionalProperties
=> 0,
276 userid
=> get_standard_option
('userid', {
277 completion
=> \
&PVE
::AccessControl
::complete_username
,
279 id
=> $TFA_ID_SCHEMA,
282 returns
=> $TYPED_TFA_ENTRY_SCHEMA,
285 my $tfa_cfg = cfs_read_file
('priv/tfa.cfg');
286 my $id = $param->{id
};
287 my $entry = $tfa_cfg->api_get_tfa_entry($param->{userid
}, $id);
288 raise
("No such tfa entry '$id'", code
=> HTTP
::Status
::HTTP_NOT_FOUND
) if !$entry;
292 __PACKAGE__-
>register_method ({
293 name
=> 'delete_tfa',
294 path
=> '{userid}/{id}',
298 ['userid-param', 'self'],
299 ['userid-group', ['User.Modify']],
302 protected
=> 1, # else we can't access shadow files
303 allowtoken
=> 0, # we don't want tokens to change the regular user's TFA settings
304 description
=> 'Delete a TFA entry by ID.',
306 additionalProperties
=> 0,
308 userid
=> get_standard_option
('userid', {
309 completion
=> \
&PVE
::AccessControl
::complete_username
,
311 id
=> $TFA_ID_SCHEMA,
312 password
=> $OPTIONAL_PASSWORD_SCHEMA,
315 returns
=> { type
=> 'null' },
319 PVE
::AccessControl
::assert_new_tfa_config_available
();
321 my $rpcenv = PVE
::RPCEnvironment
::get
();
322 my $authuser = $rpcenv->get_user();
324 root_permission_check
($rpcenv, $authuser, $param->{userid
}, $param->{password
});
326 my $has_entries_left = PVE
::AccessControl
::lock_tfa_config
(sub {
327 my $tfa_cfg = cfs_read_file
('priv/tfa.cfg');
328 my $has_entries_left = $tfa_cfg->api_delete_tfa($userid, $param->{id
});
329 cfs_write_file
('priv/tfa.cfg', $tfa_cfg);
330 return $has_entries_left;
332 if (!$has_entries_left) {
333 set_user_tfa_enabled
($userid, undef, undef);
337 __PACKAGE__-
>register_method ({
342 description
=> "Returns all or just the logged-in user, depending on privileges.",
345 protected
=> 1, # else we can't access shadow files
346 allowtoken
=> 0, # we don't want tokens to change the regular user's TFA settings
347 description
=> 'List TFA configurations of users.',
349 additionalProperties
=> 0,
353 description
=> "The list tuples of user and TFA entries.",
360 description
=> 'User this entry belongs to.',
364 items
=> $TYPED_TFA_ENTRY_SCHEMA,
372 my $rpcenv = PVE
::RPCEnvironment
::get
();
373 my $authuser = $rpcenv->get_user();
374 my $top_level_allowed = ($authuser eq 'root@pam');
376 my $tfa_cfg = cfs_read_file
('priv/tfa.cfg');
377 return $tfa_cfg->api_list_tfa($authuser, $top_level_allowed);
380 __PACKAGE__-
>register_method ({
381 name
=> 'add_tfa_entry',
386 ['userid-param', 'self'],
387 ['userid-group', ['User.Modify']],
390 protected
=> 1, # else we can't access shadow files
391 allowtoken
=> 0, # we don't want tokens to change the regular user's TFA settings
392 description
=> 'Add a TFA entry for a user.',
394 additionalProperties
=> 0,
396 userid
=> get_standard_option
('userid', {
397 completion
=> \
&PVE
::AccessControl
::complete_username
,
399 type
=> $TFA_TYPE_SCHEMA,
402 description
=> 'A description to distinguish multiple entries from one another',
408 description
=> "A totp URI.",
414 'The current value for the provided totp URI, or a Webauthn/U2F'
415 .' challenge response',
420 description
=> 'When responding to a u2f challenge: the original challenge string',
423 password
=> $OPTIONAL_PASSWORD_SCHEMA,
426 returns
=> $TFA_UPDATE_INFO_SCHEMA,
430 PVE
::AccessControl
::assert_new_tfa_config_available
();
432 my $rpcenv = PVE
::RPCEnvironment
::get
();
433 my $authuser = $rpcenv->get_user();
434 my ($userid, $realm) =
435 root_permission_check
($rpcenv, $authuser, $param->{userid
}, $param->{password
});
437 my $type = delete $param->{type
};
438 my $value = delete $param->{value
};
439 if ($type eq 'yubico') {
440 $value = validate_yubico_otp
($userid, $realm, $value);
443 return PVE
::AccessControl
::lock_tfa_config
(sub {
444 my $tfa_cfg = cfs_read_file
('priv/tfa.cfg');
446 set_user_tfa_enabled
($userid, $realm, $tfa_cfg);
448 PVE
::AccessControl
::configure_u2f_and_wa
($tfa_cfg);
450 my $response = $tfa_cfg->api_add_tfa_entry(
452 $param->{description
},
459 cfs_write_file
('priv/tfa.cfg', $tfa_cfg);
465 sub validate_yubico_otp
: prototype($$) {
466 my ($userid, $realm, $value) = @_;
468 my $domain_cfg = cfs_read_file
('domains.cfg');
469 my $realm_cfg = $domain_cfg->{ids
}->{$realm};
470 die "auth domain '$realm' does not exist\n" if !$realm_cfg;
472 my $realm_tfa = $realm_cfg->{tfa
};
473 die "no yubico otp configuration available for realm $realm\n"
476 $realm_tfa = PVE
::Auth
::Plugin
::parse_tfa_config
($realm_tfa);
477 die "realm is not setup for Yubico OTP\n"
478 if !$realm_tfa || $realm_tfa->{type
} ne 'yubico';
480 my $public_key = substr($value, 0, 12);
482 PVE
::AccessControl
::authenticate_yubico_do
($value, $public_key, $realm_tfa);
487 __PACKAGE__-
>register_method ({
488 name
=> 'update_tfa_entry',
489 path
=> '{userid}/{id}',
493 ['userid-param', 'self'],
494 ['userid-group', ['User.Modify']],
497 protected
=> 1, # else we can't access shadow files
498 allowtoken
=> 0, # we don't want tokens to change the regular user's TFA settings
499 description
=> 'Add a TFA entry for a user.',
501 additionalProperties
=> 0,
503 userid
=> get_standard_option
('userid', {
504 completion
=> \
&PVE
::AccessControl
::complete_username
,
506 id
=> $TFA_ID_SCHEMA,
509 description
=> 'A description to distinguish multiple entries from one another',
515 description
=> 'Whether the entry should be enabled for login.',
518 password
=> $OPTIONAL_PASSWORD_SCHEMA,
521 returns
=> { type
=> 'null' },
525 PVE
::AccessControl
::assert_new_tfa_config_available
();
527 my $rpcenv = PVE
::RPCEnvironment
::get
();
528 my $authuser = $rpcenv->get_user();
530 root_permission_check
($rpcenv, $authuser, $param->{userid
}, $param->{password
});
532 PVE
::AccessControl
::lock_tfa_config
(sub {
533 my $tfa_cfg = cfs_read_file
('priv/tfa.cfg');
535 $tfa_cfg->api_update_tfa_entry(
538 $param->{description
},
542 cfs_write_file
('priv/tfa.cfg', $tfa_cfg);