]> git.proxmox.com Git - pve-access-control.git/blob - src/PVE/API2/TFA.pm
bump version to 8.1.2
[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 links => [ { rel => 'child', href => "{id}" } ],
180 },
181 code => sub {
182 my ($param) = @_;
183 my $tfa_cfg = cfs_read_file('priv/tfa.cfg');
184 return $tfa_cfg->api_list_user_tfa($param->{userid});
185 }});
186
187 __PACKAGE__->register_method ({
188 name => 'get_tfa_entry',
189 path => '{userid}/{id}',
190 method => 'GET',
191 permissions => {
192 check => [ 'or',
193 ['userid-param', 'self'],
194 ['userid-group', ['User.Modify', 'Sys.Audit']],
195 ],
196 },
197 protected => 1, # else we can't access shadow files
198 description => 'Fetch a requested TFA entry if present.',
199 parameters => {
200 additionalProperties => 0,
201 properties => {
202 userid => get_standard_option('userid', {
203 completion => \&PVE::AccessControl::complete_username,
204 }),
205 id => $TFA_ID_SCHEMA,
206 }
207 },
208 returns => $TYPED_TFA_ENTRY_SCHEMA,
209 code => sub {
210 my ($param) = @_;
211 my $tfa_cfg = cfs_read_file('priv/tfa.cfg');
212 my $id = $param->{id};
213 my $entry = $tfa_cfg->api_get_tfa_entry($param->{userid}, $id);
214 raise("No such tfa entry '$id'", code => HTTP::Status::HTTP_NOT_FOUND) if !$entry;
215 return $entry;
216 }});
217
218 __PACKAGE__->register_method ({
219 name => 'delete_tfa',
220 path => '{userid}/{id}',
221 method => 'DELETE',
222 permissions => {
223 check => [ 'or',
224 ['userid-param', 'self'],
225 ['userid-group', ['User.Modify']],
226 ],
227 },
228 protected => 1, # else we can't access shadow files
229 allowtoken => 0, # we don't want tokens to change the regular user's TFA settings
230 description => 'Delete a TFA entry by ID.',
231 parameters => {
232 additionalProperties => 0,
233 properties => {
234 userid => get_standard_option('userid', {
235 completion => \&PVE::AccessControl::complete_username,
236 }),
237 id => $TFA_ID_SCHEMA,
238 password => $OPTIONAL_PASSWORD_SCHEMA,
239 }
240 },
241 returns => { type => 'null' },
242 code => sub {
243 my ($param) = @_;
244
245 my $rpcenv = PVE::RPCEnvironment::get();
246 my $authuser = $rpcenv->get_user();
247 my $userid =
248 root_permission_check($rpcenv, $authuser, $param->{userid}, $param->{password});
249
250 my $has_entries_left = PVE::AccessControl::lock_tfa_config(sub {
251 my $tfa_cfg = cfs_read_file('priv/tfa.cfg');
252 my $has_entries_left = $tfa_cfg->api_delete_tfa($userid, $param->{id});
253 cfs_write_file('priv/tfa.cfg', $tfa_cfg);
254 return $has_entries_left;
255 });
256 if (!$has_entries_left) {
257 set_user_tfa_enabled($userid, undef, undef);
258 }
259 }});
260
261 __PACKAGE__->register_method ({
262 name => 'list_tfa',
263 path => '',
264 method => 'GET',
265 permissions => {
266 description => "Returns all or just the logged-in user, depending on privileges.",
267 user => 'all',
268 },
269 protected => 1, # else we can't access shadow files
270 description => 'List TFA configurations of users.',
271 parameters => {
272 additionalProperties => 0,
273 properties => {}
274 },
275 returns => {
276 description => "The list tuples of user and TFA entries.",
277 type => 'array',
278 items => {
279 type => 'object',
280 properties => {
281 userid => {
282 type => 'string',
283 description => 'User this entry belongs to.',
284 },
285 entries => {
286 type => 'array',
287 items => $TYPED_TFA_ENTRY_SCHEMA,
288 },
289 'totp-locked' => {
290 type => 'boolean',
291 optional => 1,
292 description => 'True if the user is currently locked out of TOTP factors.',
293 },
294 'tfa-locked-until' => {
295 type => 'integer',
296 optional => 1,
297 description =>
298 'Contains a timestamp until when a user is locked out of 2nd factors.',
299 },
300 },
301 },
302 links => [ { rel => 'child', href => "{userid}" } ],
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 my $rpcenv = PVE::RPCEnvironment::get();
380 my $authuser = $rpcenv->get_user();
381 my ($userid, $realm) =
382 root_permission_check($rpcenv, $authuser, $param->{userid}, $param->{password});
383
384 my $type = delete $param->{type};
385 my $value = delete $param->{value};
386 if ($type eq 'yubico') {
387 $value = validate_yubico_otp($userid, $realm, $value);
388 }
389
390 return PVE::AccessControl::lock_tfa_config(sub {
391 my $tfa_cfg = cfs_read_file('priv/tfa.cfg');
392
393 set_user_tfa_enabled($userid, $realm, $tfa_cfg);
394
395 PVE::AccessControl::configure_u2f_and_wa($tfa_cfg);
396
397 my $response = $tfa_cfg->api_add_tfa_entry(
398 $userid,
399 $param->{description},
400 $param->{totp},
401 $value,
402 $param->{challenge},
403 $type,
404 );
405
406 cfs_write_file('priv/tfa.cfg', $tfa_cfg);
407
408 return $response;
409 });
410 }});
411
412 sub validate_yubico_otp : prototype($$) {
413 my ($userid, $realm, $value) = @_;
414
415 my $domain_cfg = cfs_read_file('domains.cfg');
416 my $realm_cfg = $domain_cfg->{ids}->{$realm};
417 die "auth domain '$realm' does not exist\n" if !$realm_cfg;
418
419 my $realm_tfa = $realm_cfg->{tfa};
420 die "no yubico otp configuration available for realm $realm\n"
421 if !$realm_tfa;
422
423 $realm_tfa = PVE::Auth::Plugin::parse_tfa_config($realm_tfa);
424 die "realm is not setup for Yubico OTP\n"
425 if !$realm_tfa || $realm_tfa->{type} ne 'yubico';
426
427 my $public_key = substr($value, 0, 12);
428
429 PVE::AccessControl::authenticate_yubico_do($value, $public_key, $realm_tfa);
430
431 return $public_key;
432 }
433
434 __PACKAGE__->register_method ({
435 name => 'update_tfa_entry',
436 path => '{userid}/{id}',
437 method => 'PUT',
438 permissions => {
439 check => [ 'or',
440 ['userid-param', 'self'],
441 ['userid-group', ['User.Modify']],
442 ],
443 },
444 protected => 1, # else we can't access shadow files
445 allowtoken => 0, # we don't want tokens to change the regular user's TFA settings
446 description => 'Add a TFA entry for a user.',
447 parameters => {
448 additionalProperties => 0,
449 properties => {
450 userid => get_standard_option('userid', {
451 completion => \&PVE::AccessControl::complete_username,
452 }),
453 id => $TFA_ID_SCHEMA,
454 description => {
455 type => 'string',
456 description => 'A description to distinguish multiple entries from one another',
457 maxLength => 255,
458 optional => 1,
459 },
460 enable => {
461 type => 'boolean',
462 description => 'Whether the entry should be enabled for login.',
463 optional => 1,
464 },
465 password => $OPTIONAL_PASSWORD_SCHEMA,
466 },
467 },
468 returns => { type => 'null' },
469 code => sub {
470 my ($param) = @_;
471
472 my $rpcenv = PVE::RPCEnvironment::get();
473 my $authuser = $rpcenv->get_user();
474 my $userid =
475 root_permission_check($rpcenv, $authuser, $param->{userid}, $param->{password});
476
477 PVE::AccessControl::lock_tfa_config(sub {
478 my $tfa_cfg = cfs_read_file('priv/tfa.cfg');
479
480 $tfa_cfg->api_update_tfa_entry(
481 $userid,
482 $param->{id},
483 $param->{description},
484 $param->{enable},
485 );
486
487 cfs_write_file('priv/tfa.cfg', $tfa_cfg);
488 });
489 }});
490
491 1;