]> git.proxmox.com Git - pve-access-control.git/blob - src/PVE/API2/TFA.pm
62b9653e24487ac03668c97a91b47b1ecdba4949
[pve-access-control.git] / src / PVE / API2 / TFA.pm
1 package PVE::API2::TFA;
2
3 use strict;
4 use warnings;
5
6 use HTTP::Status qw(:constants);
7
8 use PVE::AccessControl;
9 use PVE::Cluster qw(cfs_read_file cfs_write_file);
10 use PVE::Exception qw(raise raise_perm_exc raise_param_exc);
11 use PVE::JSONSchema qw(get_standard_option);
12 use PVE::RPCEnvironment;
13 use PVE::SafeSyslog;
14
15 use PVE::API2::AccessControl; # for old login api get_u2f_instance method
16
17 use PVE::RESTHandler;
18
19 use base qw(PVE::RESTHandler);
20
21 my $OPTIONAL_PASSWORD_SCHEMA = {
22 description => "The current password.",
23 type => 'string',
24 optional => 1, # Only required if not root@pam
25 minLength => 5,
26 maxLength => 64
27 };
28
29 my $TFA_TYPE_SCHEMA = {
30 type => 'string',
31 description => 'TFA Entry Type.',
32 enum => [qw(totp u2f webauthn recovery yubico)],
33 };
34
35 my %TFA_INFO_PROPERTIES = (
36 id => {
37 type => 'string',
38 description => 'The id used to reference this entry.',
39 },
40 description => {
41 type => 'string',
42 description => 'User chosen description for this entry.',
43 },
44 created => {
45 type => 'integer',
46 description => 'Creation time of this entry as unix epoch.',
47 },
48 enable => {
49 type => 'boolean',
50 description => 'Whether this TFA entry is currently enabled.',
51 optional => 1,
52 default => 1,
53 },
54 );
55
56 my $TYPED_TFA_ENTRY_SCHEMA = {
57 type => 'object',
58 description => 'TFA Entry.',
59 properties => {
60 type => $TFA_TYPE_SCHEMA,
61 %TFA_INFO_PROPERTIES,
62 },
63 };
64
65 my $TFA_ID_SCHEMA = {
66 type => 'string',
67 description => 'A TFA entry id.',
68 };
69
70 my $TFA_UPDATE_INFO_SCHEMA = {
71 type => 'object',
72 properties => {
73 id => {
74 type => 'string',
75 description => 'The id of a newly added TFA entry.',
76 },
77 challenge => {
78 type => 'string',
79 optional => 1,
80 description =>
81 'When adding u2f entries, this contains a challenge the user must respond to in order'
82 .' to finish the registration.'
83 },
84 recovery => {
85 type => 'array',
86 optional => 1,
87 description =>
88 'When adding recovery codes, this contains the list of codes to be displayed to'
89 .' the user',
90 items => {
91 type => 'string',
92 description => 'A recovery entry.'
93 },
94 },
95 },
96 };
97
98 # Only root may modify root, regular users need to specify their password.
99 #
100 # Returns the userid returned from `verify_username`.
101 # Or ($userid, $realm) in list context.
102 my sub root_permission_check : prototype($$$$) {
103 my ($rpcenv, $authuser, $userid, $password) = @_;
104
105 ($userid, undef, my $realm) = PVE::AccessControl::verify_username($userid);
106 $rpcenv->check_user_exist($userid);
107
108 raise_perm_exc() if $userid eq 'root@pam' && $authuser ne 'root@pam';
109
110 # Regular users need to confirm their password to change TFA settings.
111 if ($authuser ne 'root@pam') {
112 raise_param_exc({ 'password' => 'password is required to modify TFA data' })
113 if !defined($password);
114
115 ($authuser, my $auth_username, my $auth_realm) =
116 PVE::AccessControl::verify_username($authuser);
117
118 my $domain_cfg = cfs_read_file('domains.cfg');
119 my $cfg = $domain_cfg->{ids}->{$auth_realm};
120 die "auth domain '$auth_realm' does not exist\n" if !$cfg;
121 my $plugin = PVE::Auth::Plugin->lookup($cfg->{type});
122 $plugin->authenticate_user($cfg, $auth_realm, $auth_username, $password);
123 }
124
125 return wantarray ? ($userid, $realm) : $userid;
126 }
127
128 # Set TFA to enabled if $tfa_cfg is passed, or to disabled if $tfa_cfg is undef,
129 # When enabling we also merge the old user.cfg keys into the $tfa_cfg.
130 my sub set_user_tfa_enabled : prototype($$$) {
131 my ($userid, $realm, $tfa_cfg) = @_;
132
133 PVE::AccessControl::lock_user_config(sub {
134 my $user_cfg = cfs_read_file('user.cfg');
135 my $user = $user_cfg->{users}->{$userid};
136 my $keys = $user->{keys};
137 # When enabling, we convert old-old keys,
138 # When disabling, we shouldn't actually have old keys anymore, so if they are there,
139 # they'll be removed.
140 if ($tfa_cfg && $keys && $keys !~ /^x(?:!.*)?$/) {
141 my $domain_cfg = cfs_read_file('domains.cfg');
142 my $realm_cfg = $domain_cfg->{ids}->{$realm};
143 die "auth domain '$realm' does not exist\n" if !$realm_cfg;
144
145 my $realm_tfa = $realm_cfg->{tfa};
146 $realm_tfa = PVE::Auth::Plugin::parse_tfa_config($realm_tfa) if $realm_tfa;
147
148 PVE::AccessControl::add_old_keys_to_realm_tfa($userid, $tfa_cfg, $realm_tfa, $keys);
149 }
150 $user->{keys} = $tfa_cfg ? 'x' : undef;
151 cfs_write_file("user.cfg", $user_cfg);
152 }, "enabling TFA for the user failed");
153 }
154
155 __PACKAGE__->register_method ({
156 name => 'list_user_tfa',
157 path => '{userid}',
158 method => 'GET',
159 permissions => {
160 check => [ 'or',
161 ['userid-param', 'self'],
162 ['userid-group', ['User.Modify', 'Sys.Audit']],
163 ],
164 },
165 protected => 1, # else we can't access shadow files
166 description => 'List TFA configurations of users.',
167 parameters => {
168 additionalProperties => 0,
169 properties => {
170 userid => get_standard_option('userid', {
171 completion => \&PVE::AccessControl::complete_username,
172 }),
173 }
174 },
175 returns => {
176 description => "A list of the user's TFA entries.",
177 type => 'array',
178 items => $TYPED_TFA_ENTRY_SCHEMA,
179 },
180 code => sub {
181 my ($param) = @_;
182 my $tfa_cfg = cfs_read_file('priv/tfa.cfg');
183 return $tfa_cfg->api_list_user_tfa($param->{userid});
184 }});
185
186 __PACKAGE__->register_method ({
187 name => 'get_tfa_entry',
188 path => '{userid}/{id}',
189 method => 'GET',
190 permissions => {
191 check => [ 'or',
192 ['userid-param', 'self'],
193 ['userid-group', ['User.Modify', 'Sys.Audit']],
194 ],
195 },
196 protected => 1, # else we can't access shadow files
197 description => 'Fetch a requested TFA entry if present.',
198 parameters => {
199 additionalProperties => 0,
200 properties => {
201 userid => get_standard_option('userid', {
202 completion => \&PVE::AccessControl::complete_username,
203 }),
204 id => $TFA_ID_SCHEMA,
205 }
206 },
207 returns => $TYPED_TFA_ENTRY_SCHEMA,
208 code => sub {
209 my ($param) = @_;
210 my $tfa_cfg = cfs_read_file('priv/tfa.cfg');
211 my $id = $param->{id};
212 my $entry = $tfa_cfg->api_get_tfa_entry($param->{userid}, $id);
213 raise("No such tfa entry '$id'", code => HTTP::Status::HTTP_NOT_FOUND) if !$entry;
214 return $entry;
215 }});
216
217 __PACKAGE__->register_method ({
218 name => 'delete_tfa',
219 path => '{userid}/{id}',
220 method => 'DELETE',
221 permissions => {
222 check => [ 'or',
223 ['userid-param', 'self'],
224 ['userid-group', ['User.Modify']],
225 ],
226 },
227 protected => 1, # else we can't access shadow files
228 allowtoken => 0, # we don't want tokens to change the regular user's TFA settings
229 description => 'Delete a TFA entry by ID.',
230 parameters => {
231 additionalProperties => 0,
232 properties => {
233 userid => get_standard_option('userid', {
234 completion => \&PVE::AccessControl::complete_username,
235 }),
236 id => $TFA_ID_SCHEMA,
237 password => $OPTIONAL_PASSWORD_SCHEMA,
238 }
239 },
240 returns => { type => 'null' },
241 code => sub {
242 my ($param) = @_;
243
244 PVE::AccessControl::assert_new_tfa_config_available();
245
246 my $rpcenv = PVE::RPCEnvironment::get();
247 my $authuser = $rpcenv->get_user();
248 my $userid =
249 root_permission_check($rpcenv, $authuser, $param->{userid}, $param->{password});
250
251 my $has_entries_left = PVE::AccessControl::lock_tfa_config(sub {
252 my $tfa_cfg = cfs_read_file('priv/tfa.cfg');
253 my $has_entries_left = $tfa_cfg->api_delete_tfa($userid, $param->{id});
254 cfs_write_file('priv/tfa.cfg', $tfa_cfg);
255 return $has_entries_left;
256 });
257 if (!$has_entries_left) {
258 set_user_tfa_enabled($userid, undef, undef);
259 }
260 }});
261
262 __PACKAGE__->register_method ({
263 name => 'list_tfa',
264 path => '',
265 method => 'GET',
266 permissions => {
267 description => "Returns all or just the logged-in user, depending on privileges.",
268 user => 'all',
269 },
270 protected => 1, # else we can't access shadow files
271 description => 'List TFA configurations of users.',
272 parameters => {
273 additionalProperties => 0,
274 properties => {}
275 },
276 returns => {
277 description => "The list tuples of user and TFA entries.",
278 type => 'array',
279 items => {
280 type => 'object',
281 properties => {
282 userid => {
283 type => 'string',
284 description => 'User this entry belongs to.',
285 },
286 entries => {
287 type => 'array',
288 items => $TYPED_TFA_ENTRY_SCHEMA,
289 },
290 'totp-locked' => {
291 type => 'boolean',
292 optional => 1,
293 description => 'True if the user is currently locked out of TOTP factors.',
294 },
295 'tfa-locked-until' => {
296 type => 'integer',
297 optional => 1,
298 description =>
299 'Contains a timestamp until when a user is locked out of 2nd factors.',
300 },
301 },
302 },
303 },
304 code => sub {
305 my ($param) = @_;
306
307 my $rpcenv = PVE::RPCEnvironment::get();
308 my $authuser = $rpcenv->get_user();
309
310 my $tfa_cfg = cfs_read_file('priv/tfa.cfg');
311 my $entries = $tfa_cfg->api_list_tfa($authuser, 1);
312
313 my $privs = [ 'User.Modify', 'Sys.Audit' ];
314 if ($rpcenv->check_any($authuser, "/access/groups", $privs, 1)) {
315 # can modify all
316 return $entries;
317 }
318
319 my $groups = $rpcenv->filter_groups($authuser, $privs, 1);
320 my $allowed_users = $rpcenv->group_member_join([keys %$groups]);
321 return [
322 grep {
323 my $userid = $_->{userid};
324 $userid eq $authuser || $allowed_users->{$userid}
325 } $entries->@*
326 ];
327 }});
328
329 __PACKAGE__->register_method ({
330 name => 'add_tfa_entry',
331 path => '{userid}',
332 method => 'POST',
333 permissions => {
334 check => [ 'or',
335 ['userid-param', 'self'],
336 ['userid-group', ['User.Modify']],
337 ],
338 },
339 protected => 1, # else we can't access shadow files
340 allowtoken => 0, # we don't want tokens to change the regular user's TFA settings
341 description => 'Add a TFA entry for a user.',
342 parameters => {
343 additionalProperties => 0,
344 properties => {
345 userid => get_standard_option('userid', {
346 completion => \&PVE::AccessControl::complete_username,
347 }),
348 type => $TFA_TYPE_SCHEMA,
349 description => {
350 type => 'string',
351 description => 'A description to distinguish multiple entries from one another',
352 maxLength => 255,
353 optional => 1,
354 },
355 totp => {
356 type => 'string',
357 description => "A totp URI.",
358 optional => 1,
359 },
360 value => {
361 type => 'string',
362 description =>
363 'The current value for the provided totp URI, or a Webauthn/U2F'
364 .' challenge response',
365 optional => 1,
366 },
367 challenge => {
368 type => 'string',
369 description => 'When responding to a u2f challenge: the original challenge string',
370 optional => 1,
371 },
372 password => $OPTIONAL_PASSWORD_SCHEMA,
373 },
374 },
375 returns => $TFA_UPDATE_INFO_SCHEMA,
376 code => sub {
377 my ($param) = @_;
378
379 PVE::AccessControl::assert_new_tfa_config_available();
380
381 my $rpcenv = PVE::RPCEnvironment::get();
382 my $authuser = $rpcenv->get_user();
383 my ($userid, $realm) =
384 root_permission_check($rpcenv, $authuser, $param->{userid}, $param->{password});
385
386 my $type = delete $param->{type};
387 my $value = delete $param->{value};
388 if ($type eq 'yubico') {
389 $value = validate_yubico_otp($userid, $realm, $value);
390 }
391
392 return PVE::AccessControl::lock_tfa_config(sub {
393 my $tfa_cfg = cfs_read_file('priv/tfa.cfg');
394
395 set_user_tfa_enabled($userid, $realm, $tfa_cfg);
396
397 PVE::AccessControl::configure_u2f_and_wa($tfa_cfg);
398
399 my $response = $tfa_cfg->api_add_tfa_entry(
400 $userid,
401 $param->{description},
402 $param->{totp},
403 $value,
404 $param->{challenge},
405 $type,
406 );
407
408 cfs_write_file('priv/tfa.cfg', $tfa_cfg);
409
410 return $response;
411 });
412 }});
413
414 sub validate_yubico_otp : prototype($$) {
415 my ($userid, $realm, $value) = @_;
416
417 my $domain_cfg = cfs_read_file('domains.cfg');
418 my $realm_cfg = $domain_cfg->{ids}->{$realm};
419 die "auth domain '$realm' does not exist\n" if !$realm_cfg;
420
421 my $realm_tfa = $realm_cfg->{tfa};
422 die "no yubico otp configuration available for realm $realm\n"
423 if !$realm_tfa;
424
425 $realm_tfa = PVE::Auth::Plugin::parse_tfa_config($realm_tfa);
426 die "realm is not setup for Yubico OTP\n"
427 if !$realm_tfa || $realm_tfa->{type} ne 'yubico';
428
429 my $public_key = substr($value, 0, 12);
430
431 PVE::AccessControl::authenticate_yubico_do($value, $public_key, $realm_tfa);
432
433 return $public_key;
434 }
435
436 __PACKAGE__->register_method ({
437 name => 'update_tfa_entry',
438 path => '{userid}/{id}',
439 method => 'PUT',
440 permissions => {
441 check => [ 'or',
442 ['userid-param', 'self'],
443 ['userid-group', ['User.Modify']],
444 ],
445 },
446 protected => 1, # else we can't access shadow files
447 allowtoken => 0, # we don't want tokens to change the regular user's TFA settings
448 description => 'Add a TFA entry for a user.',
449 parameters => {
450 additionalProperties => 0,
451 properties => {
452 userid => get_standard_option('userid', {
453 completion => \&PVE::AccessControl::complete_username,
454 }),
455 id => $TFA_ID_SCHEMA,
456 description => {
457 type => 'string',
458 description => 'A description to distinguish multiple entries from one another',
459 maxLength => 255,
460 optional => 1,
461 },
462 enable => {
463 type => 'boolean',
464 description => 'Whether the entry should be enabled for login.',
465 optional => 1,
466 },
467 password => $OPTIONAL_PASSWORD_SCHEMA,
468 },
469 },
470 returns => { type => 'null' },
471 code => sub {
472 my ($param) = @_;
473
474 PVE::AccessControl::assert_new_tfa_config_available();
475
476 my $rpcenv = PVE::RPCEnvironment::get();
477 my $authuser = $rpcenv->get_user();
478 my $userid =
479 root_permission_check($rpcenv, $authuser, $param->{userid}, $param->{password});
480
481 PVE::AccessControl::lock_tfa_config(sub {
482 my $tfa_cfg = cfs_read_file('priv/tfa.cfg');
483
484 $tfa_cfg->api_update_tfa_entry(
485 $userid,
486 $param->{id},
487 $param->{description},
488 $param->{enable},
489 );
490
491 cfs_write_file('priv/tfa.cfg', $tfa_cfg);
492 });
493 }});
494
495 1;