]> git.proxmox.com Git - pve-access-control.git/blame - src/PVE/API2/TFA.pm
bump version to 8.1.2
[pve-access-control.git] / src / PVE / API2 / TFA.pm
CommitLineData
dc547a13
WB
1package PVE::API2::TFA;
2
3use strict;
4use warnings;
5
b3dae5dd
WB
6use HTTP::Status qw(:constants);
7
dc547a13 8use PVE::AccessControl;
07692c72 9use PVE::Cluster qw(cfs_read_file cfs_write_file);
dc547a13 10use PVE::Exception qw(raise raise_perm_exc raise_param_exc);
2974aa33 11use PVE::JSONSchema qw(get_standard_option);
dc547a13 12use PVE::RPCEnvironment;
2974aa33 13use PVE::SafeSyslog;
dc547a13
WB
14
15use PVE::API2::AccessControl; # for old login api get_u2f_instance method
16
17use PVE::RESTHandler;
18
19use base qw(PVE::RESTHandler);
20
07692c72
WB
21my $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
29my $TFA_TYPE_SCHEMA = {
30 type => 'string',
31 description => 'TFA Entry Type.',
32 enum => [qw(totp u2f webauthn recovery yubico)],
33};
34
35my %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
56my $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
65my $TFA_ID_SCHEMA = {
66 type => 'string',
67 description => 'A TFA entry id.',
68};
69
70my $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.
102my sub root_permission_check : prototype($$$$) {
103 my ($rpcenv, $authuser, $userid, $password) = @_;
104
dd9e95b1 105 ($userid, undef, my $realm) = PVE::AccessControl::verify_username($userid);
07692c72
WB
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
dd9e95b1
WB
115 ($authuser, my $auth_username, my $auth_realm) =
116 PVE::AccessControl::verify_username($authuser);
117
07692c72 118 my $domain_cfg = cfs_read_file('domains.cfg');
dd9e95b1
WB
119 my $cfg = $domain_cfg->{ids}->{$auth_realm};
120 die "auth domain '$auth_realm' does not exist\n" if !$cfg;
07692c72 121 my $plugin = PVE::Auth::Plugin->lookup($cfg->{type});
dd9e95b1 122 $plugin->authenticate_user($cfg, $auth_realm, $auth_username, $password);
07692c72
WB
123 }
124
125 return wantarray ? ($userid, $realm) : $userid;
126}
127
0fe62fa8
WB
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.
130my sub set_user_tfa_enabled : prototype($$$) {
131 my ($userid, $realm, $tfa_cfg) = @_;
c55555ab
WB
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};
0fe62fa8
WB
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);
c55555ab 149 }
0fe62fa8 150 $user->{keys} = $tfa_cfg ? 'x' : undef;
c55555ab
WB
151 cfs_write_file("user.cfg", $user_cfg);
152 }, "enabling TFA for the user failed");
153}
154
dc547a13
WB
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
dc547a13
WB
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,
b7ab634a 179 links => [ { rel => 'child', href => "{id}" } ],
dc547a13
WB
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
07692c72 198 description => 'Fetch a requested TFA entry if present.',
dc547a13
WB
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');
07692c72
WB
212 my $id = $param->{id};
213 my $entry = $tfa_cfg->api_get_tfa_entry($param->{userid}, $id);
b3dae5dd 214 raise("No such tfa entry '$id'", code => HTTP::Status::HTTP_NOT_FOUND) if !$entry;
07692c72
WB
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
07692c72
WB
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
c55555ab 250 my $has_entries_left = PVE::AccessControl::lock_tfa_config(sub {
07692c72 251 my $tfa_cfg = cfs_read_file('priv/tfa.cfg');
c55555ab 252 my $has_entries_left = $tfa_cfg->api_delete_tfa($userid, $param->{id});
07692c72 253 cfs_write_file('priv/tfa.cfg', $tfa_cfg);
c55555ab 254 return $has_entries_left;
07692c72 255 });
c55555ab 256 if (!$has_entries_left) {
0fe62fa8 257 set_user_tfa_enabled($userid, undef, undef);
c55555ab 258 }
dc547a13
WB
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
dc547a13
WB
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 },
32893f13
WB
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 },
dc547a13
WB
300 },
301 },
b7ab634a 302 links => [ { rel => 'child', href => "{userid}" } ],
dc547a13
WB
303 },
304 code => sub {
305 my ($param) = @_;
306
307 my $rpcenv = PVE::RPCEnvironment::get();
308 my $authuser = $rpcenv->get_user();
dc547a13
WB
309
310 my $tfa_cfg = cfs_read_file('priv/tfa.cfg');
dc7ef240
WB
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 ];
dc547a13
WB
327 }});
328
07692c72
WB
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
07692c72
WB
379 my $rpcenv = PVE::RPCEnvironment::get();
380 my $authuser = $rpcenv->get_user();
8c1e3ab3 381 my ($userid, $realm) =
07692c72
WB
382 root_permission_check($rpcenv, $authuser, $param->{userid}, $param->{password});
383
8c1e3ab3
WB
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
07692c72
WB
390 return PVE::AccessControl::lock_tfa_config(sub {
391 my $tfa_cfg = cfs_read_file('priv/tfa.cfg');
0fe62fa8
WB
392
393 set_user_tfa_enabled($userid, $realm, $tfa_cfg);
394
07692c72
WB
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},
8c1e3ab3 401 $value,
07692c72 402 $param->{challenge},
8c1e3ab3 403 $type,
07692c72
WB
404 );
405
406 cfs_write_file('priv/tfa.cfg', $tfa_cfg);
407
408 return $response;
409 });
410 }});
411
8c1e3ab3
WB
412sub 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
07692c72
WB
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
07692c72
WB
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
dc547a13 4911;