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, undef, 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 ($authuser, my $auth_username, my $auth_realm) =
115 PVE
::AccessControl
::verify_username
($authuser);
117 my $domain_cfg = cfs_read_file
('domains.cfg');
118 my $cfg = $domain_cfg->{ids
}->{$auth_realm};
119 die "auth domain '$auth_realm' does not exist\n" if !$cfg;
120 my $plugin = PVE
::Auth
::Plugin-
>lookup($cfg->{type
});
121 $plugin->authenticate_user($cfg, $auth_realm, $auth_username, $password);
124 return wantarray ?
($userid, $realm) : $userid;
127 # Set TFA to enabled if $tfa_cfg is passed, or to disabled if $tfa_cfg is undef,
128 # When enabling we also merge the old user.cfg keys into the $tfa_cfg.
129 my sub set_user_tfa_enabled
: prototype($$$) {
130 my ($userid, $realm, $tfa_cfg) = @_;
132 PVE
::AccessControl
::lock_user_config
(sub {
133 my $user_cfg = cfs_read_file
('user.cfg');
134 my $user = $user_cfg->{users
}->{$userid};
135 my $keys = $user->{keys};
136 # When enabling, we convert old-old keys,
137 # When disabling, we shouldn't actually have old keys anymore, so if they are there,
138 # they'll be removed.
139 if ($tfa_cfg && $keys && $keys !~ /^x(?:!.*)?$/) {
140 my $domain_cfg = cfs_read_file
('domains.cfg');
141 my $realm_cfg = $domain_cfg->{ids
}->{$realm};
142 die "auth domain '$realm' does not exist\n" if !$realm_cfg;
144 my $realm_tfa = $realm_cfg->{tfa
};
145 $realm_tfa = PVE
::Auth
::Plugin
::parse_tfa_config
($realm_tfa) if $realm_tfa;
147 PVE
::AccessControl
::add_old_keys_to_realm_tfa
($userid, $tfa_cfg, $realm_tfa, $keys);
149 $user->{keys} = $tfa_cfg ?
'x' : undef;
150 cfs_write_file
("user.cfg", $user_cfg);
151 }, "enabling TFA for the user failed");
156 __PACKAGE__-
>register_method({
157 name
=> 'verify_tfa',
160 permissions
=> { user
=> 'all' },
161 protected
=> 1, # else we can't access shadow files
162 allowtoken
=> 0, # we don't want tokens to access TFA information
163 description
=> 'Finish a u2f challenge.',
165 additionalProperties
=> 0,
169 description
=> 'The response to the current authentication challenge.',
176 ticket
=> { type
=> 'string' },
183 my $rpcenv = PVE
::RPCEnvironment
::get
();
184 my $authuser = $rpcenv->get_user();
185 my ($username, undef, $realm) = PVE
::AccessControl
::verify_username
($authuser);
187 my ($tfa_type, $tfa_data) = PVE
::AccessControl
::user_get_tfa
($username, $realm, 0);
188 if (!defined($tfa_type)) {
189 raise
('no u2f data available');
191 if ($tfa_type eq 'incompatible') {
192 raise
('tfa entries incompatible with old login api');
196 if ($tfa_type eq 'u2f') {
197 my $challenge = $rpcenv->get_u2f_challenge()
198 or raise
('no active challenge');
200 my $keyHandle = $tfa_data->{keyHandle
};
201 my $publicKey = $tfa_data->{publicKey
};
202 raise
("incomplete u2f setup")
203 if !defined($keyHandle) || !defined($publicKey);
205 my $u2f = PVE
::API2
::AccessControl
::get_u2f_instance
($rpcenv, $publicKey, $keyHandle);
206 $u2f->set_challenge($challenge);
208 my ($counter, $present) = $u2f->auth_verify($param->{response
});
209 # Do we want to do anything with these?
211 # sanity check before handing off to the verification code:
212 my $keys = $tfa_data->{keys} or die "missing tfa keys\n";
213 my $config = $tfa_data->{config
} or die "bad tfa entry\n";
214 PVE
::AccessControl
::verify_one_time_pw
($tfa_type, $authuser, $keys, $config, $param->{response
});
218 my $clientip = $rpcenv->get_client_ip() || '';
219 syslog
('err', "authentication verification failure; rhost=$clientip user=$authuser msg=$err");
220 die PVE
::Exception-
>new("authentication failure\n", code
=> 401);
224 ticket
=> PVE
::AccessControl
::assemble_ticket
($authuser),
225 cap
=> $rpcenv->compute_api_permission($authuser),
231 __PACKAGE__-
>register_method ({
232 name
=> 'list_user_tfa',
237 ['userid-param', 'self'],
238 ['userid-group', ['User.Modify', 'Sys.Audit']],
241 protected
=> 1, # else we can't access shadow files
242 allowtoken
=> 0, # we don't want tokens to change the regular user's TFA settings
243 description
=> 'List TFA configurations of users.',
245 additionalProperties
=> 0,
247 userid
=> get_standard_option
('userid', {
248 completion
=> \
&PVE
::AccessControl
::complete_username
,
253 description
=> "A list of the user's TFA entries.",
255 items
=> $TYPED_TFA_ENTRY_SCHEMA,
259 my $tfa_cfg = cfs_read_file
('priv/tfa.cfg');
260 return $tfa_cfg->api_list_user_tfa($param->{userid
});
263 __PACKAGE__-
>register_method ({
264 name
=> 'get_tfa_entry',
265 path
=> '{userid}/{id}',
269 ['userid-param', 'self'],
270 ['userid-group', ['User.Modify', 'Sys.Audit']],
273 protected
=> 1, # else we can't access shadow files
274 allowtoken
=> 0, # we don't want tokens to change the regular user's TFA settings
275 description
=> 'Fetch a requested TFA entry if present.',
277 additionalProperties
=> 0,
279 userid
=> get_standard_option
('userid', {
280 completion
=> \
&PVE
::AccessControl
::complete_username
,
282 id
=> $TFA_ID_SCHEMA,
285 returns
=> $TYPED_TFA_ENTRY_SCHEMA,
288 my $tfa_cfg = cfs_read_file
('priv/tfa.cfg');
289 my $id = $param->{id
};
290 my $entry = $tfa_cfg->api_get_tfa_entry($param->{userid
}, $id);
291 raise
("No such tfa entry '$id'", code
=> HTTP
::Status
::HTTP_NOT_FOUND
) if !$entry;
295 __PACKAGE__-
>register_method ({
296 name
=> 'delete_tfa',
297 path
=> '{userid}/{id}',
301 ['userid-param', 'self'],
302 ['userid-group', ['User.Modify']],
305 protected
=> 1, # else we can't access shadow files
306 allowtoken
=> 0, # we don't want tokens to change the regular user's TFA settings
307 description
=> 'Delete a TFA entry by ID.',
309 additionalProperties
=> 0,
311 userid
=> get_standard_option
('userid', {
312 completion
=> \
&PVE
::AccessControl
::complete_username
,
314 id
=> $TFA_ID_SCHEMA,
315 password
=> $OPTIONAL_PASSWORD_SCHEMA,
318 returns
=> { type
=> 'null' },
322 PVE
::AccessControl
::assert_new_tfa_config_available
();
324 my $rpcenv = PVE
::RPCEnvironment
::get
();
325 my $authuser = $rpcenv->get_user();
327 root_permission_check
($rpcenv, $authuser, $param->{userid
}, $param->{password
});
329 my $has_entries_left = PVE
::AccessControl
::lock_tfa_config
(sub {
330 my $tfa_cfg = cfs_read_file
('priv/tfa.cfg');
331 my $has_entries_left = $tfa_cfg->api_delete_tfa($userid, $param->{id
});
332 cfs_write_file
('priv/tfa.cfg', $tfa_cfg);
333 return $has_entries_left;
335 if (!$has_entries_left) {
336 set_user_tfa_enabled
($userid, undef, undef);
340 __PACKAGE__-
>register_method ({
345 description
=> "Returns all or just the logged-in user, depending on privileges.",
348 protected
=> 1, # else we can't access shadow files
349 allowtoken
=> 0, # we don't want tokens to change the regular user's TFA settings
350 description
=> 'List TFA configurations of users.',
352 additionalProperties
=> 0,
356 description
=> "The list tuples of user and TFA entries.",
363 description
=> 'User this entry belongs to.',
367 items
=> $TYPED_TFA_ENTRY_SCHEMA,
375 my $rpcenv = PVE
::RPCEnvironment
::get
();
376 my $authuser = $rpcenv->get_user();
378 my $tfa_cfg = cfs_read_file
('priv/tfa.cfg');
379 my $entries = $tfa_cfg->api_list_tfa($authuser, 1);
381 my $privs = [ 'User.Modify', 'Sys.Audit' ];
382 if ($rpcenv->check_any($authuser, "/access/groups", $privs, 1)) {
387 my $groups = $rpcenv->filter_groups($authuser, $privs, 1);
388 my $allowed_users = $rpcenv->group_member_join([keys %$groups]);
391 my $userid = $_->{userid
};
392 $userid eq $authuser || $allowed_users->{$userid}
397 __PACKAGE__-
>register_method ({
398 name
=> 'add_tfa_entry',
403 ['userid-param', 'self'],
404 ['userid-group', ['User.Modify']],
407 protected
=> 1, # else we can't access shadow files
408 allowtoken
=> 0, # we don't want tokens to change the regular user's TFA settings
409 description
=> 'Add a TFA entry for a user.',
411 additionalProperties
=> 0,
413 userid
=> get_standard_option
('userid', {
414 completion
=> \
&PVE
::AccessControl
::complete_username
,
416 type
=> $TFA_TYPE_SCHEMA,
419 description
=> 'A description to distinguish multiple entries from one another',
425 description
=> "A totp URI.",
431 'The current value for the provided totp URI, or a Webauthn/U2F'
432 .' challenge response',
437 description
=> 'When responding to a u2f challenge: the original challenge string',
440 password
=> $OPTIONAL_PASSWORD_SCHEMA,
443 returns
=> $TFA_UPDATE_INFO_SCHEMA,
447 PVE
::AccessControl
::assert_new_tfa_config_available
();
449 my $rpcenv = PVE
::RPCEnvironment
::get
();
450 my $authuser = $rpcenv->get_user();
451 my ($userid, $realm) =
452 root_permission_check
($rpcenv, $authuser, $param->{userid
}, $param->{password
});
454 my $type = delete $param->{type
};
455 my $value = delete $param->{value
};
456 if ($type eq 'yubico') {
457 $value = validate_yubico_otp
($userid, $realm, $value);
460 return PVE
::AccessControl
::lock_tfa_config
(sub {
461 my $tfa_cfg = cfs_read_file
('priv/tfa.cfg');
463 set_user_tfa_enabled
($userid, $realm, $tfa_cfg);
465 PVE
::AccessControl
::configure_u2f_and_wa
($tfa_cfg);
467 my $response = $tfa_cfg->api_add_tfa_entry(
469 $param->{description
},
476 cfs_write_file
('priv/tfa.cfg', $tfa_cfg);
482 sub validate_yubico_otp
: prototype($$) {
483 my ($userid, $realm, $value) = @_;
485 my $domain_cfg = cfs_read_file
('domains.cfg');
486 my $realm_cfg = $domain_cfg->{ids
}->{$realm};
487 die "auth domain '$realm' does not exist\n" if !$realm_cfg;
489 my $realm_tfa = $realm_cfg->{tfa
};
490 die "no yubico otp configuration available for realm $realm\n"
493 $realm_tfa = PVE
::Auth
::Plugin
::parse_tfa_config
($realm_tfa);
494 die "realm is not setup for Yubico OTP\n"
495 if !$realm_tfa || $realm_tfa->{type
} ne 'yubico';
497 my $public_key = substr($value, 0, 12);
499 PVE
::AccessControl
::authenticate_yubico_do
($value, $public_key, $realm_tfa);
504 __PACKAGE__-
>register_method ({
505 name
=> 'update_tfa_entry',
506 path
=> '{userid}/{id}',
510 ['userid-param', 'self'],
511 ['userid-group', ['User.Modify']],
514 protected
=> 1, # else we can't access shadow files
515 allowtoken
=> 0, # we don't want tokens to change the regular user's TFA settings
516 description
=> 'Add a TFA entry for a user.',
518 additionalProperties
=> 0,
520 userid
=> get_standard_option
('userid', {
521 completion
=> \
&PVE
::AccessControl
::complete_username
,
523 id
=> $TFA_ID_SCHEMA,
526 description
=> 'A description to distinguish multiple entries from one another',
532 description
=> 'Whether the entry should be enabled for login.',
535 password
=> $OPTIONAL_PASSWORD_SCHEMA,
538 returns
=> { type
=> 'null' },
542 PVE
::AccessControl
::assert_new_tfa_config_available
();
544 my $rpcenv = PVE
::RPCEnvironment
::get
();
545 my $authuser = $rpcenv->get_user();
547 root_permission_check
($rpcenv, $authuser, $param->{userid
}, $param->{password
});
549 PVE
::AccessControl
::lock_tfa_config
(sub {
550 my $tfa_cfg = cfs_read_file
('priv/tfa.cfg');
552 $tfa_cfg->api_update_tfa_entry(
555 $param->{description
},
559 cfs_write_file
('priv/tfa.cfg', $tfa_cfg);