]> git.proxmox.com Git - pve-access-control.git/blob - src/PVE/API2/TFA.pm
bump version to 8.1.4
[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 our $OPTIONAL_PASSWORD_SCHEMA = {
22 description => "The current password of the user performing the change.",
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 # Set TFA to enabled if $tfa_cfg is passed, or to disabled if $tfa_cfg is undef,
99 # When enabling we also merge the old user.cfg keys into the $tfa_cfg.
100 my sub set_user_tfa_enabled : prototype($$$) {
101 my ($userid, $realm, $tfa_cfg) = @_;
102
103 PVE::AccessControl::lock_user_config(sub {
104 my $user_cfg = cfs_read_file('user.cfg');
105 my $user = $user_cfg->{users}->{$userid};
106 my $keys = $user->{keys};
107 # When enabling, we convert old-old keys,
108 # When disabling, we shouldn't actually have old keys anymore, so if they are there,
109 # they'll be removed.
110 if ($tfa_cfg && $keys && $keys !~ /^x(?:!.*)?$/) {
111 my $domain_cfg = cfs_read_file('domains.cfg');
112 my $realm_cfg = $domain_cfg->{ids}->{$realm};
113 die "auth domain '$realm' does not exist\n" if !$realm_cfg;
114
115 my $realm_tfa = $realm_cfg->{tfa};
116 $realm_tfa = PVE::Auth::Plugin::parse_tfa_config($realm_tfa) if $realm_tfa;
117
118 PVE::AccessControl::add_old_keys_to_realm_tfa($userid, $tfa_cfg, $realm_tfa, $keys);
119 }
120 $user->{keys} = $tfa_cfg ? 'x' : undef;
121 cfs_write_file("user.cfg", $user_cfg);
122 }, "enabling TFA for the user failed");
123 }
124
125 __PACKAGE__->register_method ({
126 name => 'list_user_tfa',
127 path => '{userid}',
128 method => 'GET',
129 permissions => {
130 check => [ 'or',
131 ['userid-param', 'self'],
132 ['userid-group', ['User.Modify', 'Sys.Audit']],
133 ],
134 },
135 protected => 1, # else we can't access shadow files
136 description => 'List TFA configurations of users.',
137 parameters => {
138 additionalProperties => 0,
139 properties => {
140 userid => get_standard_option('userid', {
141 completion => \&PVE::AccessControl::complete_username,
142 }),
143 }
144 },
145 returns => {
146 description => "A list of the user's TFA entries.",
147 type => 'array',
148 items => $TYPED_TFA_ENTRY_SCHEMA,
149 links => [ { rel => 'child', href => "{id}" } ],
150 },
151 code => sub {
152 my ($param) = @_;
153 my $tfa_cfg = cfs_read_file('priv/tfa.cfg');
154 return $tfa_cfg->api_list_user_tfa($param->{userid});
155 }});
156
157 __PACKAGE__->register_method ({
158 name => 'get_tfa_entry',
159 path => '{userid}/{id}',
160 method => 'GET',
161 permissions => {
162 check => [ 'or',
163 ['userid-param', 'self'],
164 ['userid-group', ['User.Modify', 'Sys.Audit']],
165 ],
166 },
167 protected => 1, # else we can't access shadow files
168 description => 'Fetch a requested TFA entry if present.',
169 parameters => {
170 additionalProperties => 0,
171 properties => {
172 userid => get_standard_option('userid', {
173 completion => \&PVE::AccessControl::complete_username,
174 }),
175 id => $TFA_ID_SCHEMA,
176 }
177 },
178 returns => $TYPED_TFA_ENTRY_SCHEMA,
179 code => sub {
180 my ($param) = @_;
181 my $tfa_cfg = cfs_read_file('priv/tfa.cfg');
182 my $id = $param->{id};
183 my $entry = $tfa_cfg->api_get_tfa_entry($param->{userid}, $id);
184 raise("No such tfa entry '$id'", code => HTTP::Status::HTTP_NOT_FOUND) if !$entry;
185 return $entry;
186 }});
187
188 __PACKAGE__->register_method ({
189 name => 'delete_tfa',
190 path => '{userid}/{id}',
191 method => 'DELETE',
192 permissions => {
193 check => [ 'or',
194 ['userid-param', 'self'],
195 ['userid-group', ['User.Modify']],
196 ],
197 },
198 protected => 1, # else we can't access shadow files
199 allowtoken => 0, # we don't want tokens to change the regular user's TFA settings
200 description => 'Delete a TFA entry by ID.',
201 parameters => {
202 additionalProperties => 0,
203 properties => {
204 userid => get_standard_option('userid', {
205 completion => \&PVE::AccessControl::complete_username,
206 }),
207 id => $TFA_ID_SCHEMA,
208 password => $OPTIONAL_PASSWORD_SCHEMA,
209 }
210 },
211 returns => { type => 'null' },
212 code => sub {
213 my ($param) = @_;
214
215 my $rpcenv = PVE::RPCEnvironment::get();
216 my $authuser = $rpcenv->get_user();
217 my $userid = $rpcenv->reauth_user_for_user_modification(
218 $authuser,
219 $param->{userid},
220 $param->{password},
221 );
222
223 my $has_entries_left = PVE::AccessControl::lock_tfa_config(sub {
224 my $tfa_cfg = cfs_read_file('priv/tfa.cfg');
225 my $has_entries_left = $tfa_cfg->api_delete_tfa($userid, $param->{id});
226 cfs_write_file('priv/tfa.cfg', $tfa_cfg);
227 return $has_entries_left;
228 });
229 if (!$has_entries_left) {
230 set_user_tfa_enabled($userid, undef, undef);
231 }
232 }});
233
234 __PACKAGE__->register_method ({
235 name => 'list_tfa',
236 path => '',
237 method => 'GET',
238 permissions => {
239 description => "Returns all or just the logged-in user, depending on privileges.",
240 user => 'all',
241 },
242 protected => 1, # else we can't access shadow files
243 description => 'List TFA configurations of users.',
244 parameters => {
245 additionalProperties => 0,
246 properties => {}
247 },
248 returns => {
249 description => "The list tuples of user and TFA entries.",
250 type => 'array',
251 items => {
252 type => 'object',
253 properties => {
254 userid => {
255 type => 'string',
256 description => 'User this entry belongs to.',
257 },
258 entries => {
259 type => 'array',
260 items => $TYPED_TFA_ENTRY_SCHEMA,
261 },
262 'totp-locked' => {
263 type => 'boolean',
264 optional => 1,
265 description => 'True if the user is currently locked out of TOTP factors.',
266 },
267 'tfa-locked-until' => {
268 type => 'integer',
269 optional => 1,
270 description =>
271 'Contains a timestamp until when a user is locked out of 2nd factors.',
272 },
273 },
274 },
275 links => [ { rel => 'child', href => "{userid}" } ],
276 },
277 code => sub {
278 my ($param) = @_;
279
280 my $rpcenv = PVE::RPCEnvironment::get();
281 my $authuser = $rpcenv->get_user();
282
283 my $tfa_cfg = cfs_read_file('priv/tfa.cfg');
284 my $entries = $tfa_cfg->api_list_tfa($authuser, 1);
285
286 my $privs = [ 'User.Modify', 'Sys.Audit' ];
287 if ($rpcenv->check_any($authuser, "/access/groups", $privs, 1)) {
288 # can modify all
289 return $entries;
290 }
291
292 my $groups = $rpcenv->filter_groups($authuser, $privs, 1);
293 my $allowed_users = $rpcenv->group_member_join([keys %$groups]);
294 return [
295 grep {
296 my $userid = $_->{userid};
297 $userid eq $authuser || $allowed_users->{$userid}
298 } $entries->@*
299 ];
300 }});
301
302 __PACKAGE__->register_method ({
303 name => 'add_tfa_entry',
304 path => '{userid}',
305 method => 'POST',
306 permissions => {
307 check => [ 'or',
308 ['userid-param', 'self'],
309 ['userid-group', ['User.Modify']],
310 ],
311 },
312 protected => 1, # else we can't access shadow files
313 allowtoken => 0, # we don't want tokens to change the regular user's TFA settings
314 description => 'Add a TFA entry for a user.',
315 parameters => {
316 additionalProperties => 0,
317 properties => {
318 userid => get_standard_option('userid', {
319 completion => \&PVE::AccessControl::complete_username,
320 }),
321 type => $TFA_TYPE_SCHEMA,
322 description => {
323 type => 'string',
324 description => 'A description to distinguish multiple entries from one another',
325 maxLength => 255,
326 optional => 1,
327 },
328 totp => {
329 type => 'string',
330 description => "A totp URI.",
331 optional => 1,
332 },
333 value => {
334 type => 'string',
335 description =>
336 'The current value for the provided totp URI, or a Webauthn/U2F'
337 .' challenge response',
338 optional => 1,
339 },
340 challenge => {
341 type => 'string',
342 description => 'When responding to a u2f challenge: the original challenge string',
343 optional => 1,
344 },
345 password => $OPTIONAL_PASSWORD_SCHEMA,
346 },
347 },
348 returns => $TFA_UPDATE_INFO_SCHEMA,
349 code => sub {
350 my ($param) = @_;
351
352 my $rpcenv = PVE::RPCEnvironment::get();
353 my $authuser = $rpcenv->get_user();
354 my ($userid, undef, $realm) = $rpcenv->reauth_user_for_user_modification(
355 $authuser,
356 $param->{userid},
357 $param->{password},
358 );
359
360 my $type = delete $param->{type};
361 my $value = delete $param->{value};
362 if ($type eq 'yubico') {
363 $value = validate_yubico_otp($userid, $realm, $value);
364 }
365
366 return PVE::AccessControl::lock_tfa_config(sub {
367 my $tfa_cfg = cfs_read_file('priv/tfa.cfg');
368
369 set_user_tfa_enabled($userid, $realm, $tfa_cfg);
370
371 PVE::AccessControl::configure_u2f_and_wa($tfa_cfg);
372
373 my $response = $tfa_cfg->api_add_tfa_entry(
374 $userid,
375 $param->{description},
376 $param->{totp},
377 $value,
378 $param->{challenge},
379 $type,
380 );
381
382 cfs_write_file('priv/tfa.cfg', $tfa_cfg);
383
384 return $response;
385 });
386 }});
387
388 sub validate_yubico_otp : prototype($$$) {
389 my ($userid, $realm, $value) = @_;
390
391 my $domain_cfg = cfs_read_file('domains.cfg');
392 my $realm_cfg = $domain_cfg->{ids}->{$realm};
393 die "auth domain '$realm' does not exist\n" if !$realm_cfg;
394
395 my $realm_tfa = $realm_cfg->{tfa};
396 die "no yubico otp configuration available for realm $realm\n"
397 if !$realm_tfa;
398
399 $realm_tfa = PVE::Auth::Plugin::parse_tfa_config($realm_tfa);
400 die "realm is not setup for Yubico OTP\n"
401 if !$realm_tfa || $realm_tfa->{type} ne 'yubico';
402
403 my $public_key = substr($value, 0, 12);
404
405 PVE::AccessControl::authenticate_yubico_do($value, $public_key, $realm_tfa);
406
407 return $public_key;
408 }
409
410 __PACKAGE__->register_method ({
411 name => 'update_tfa_entry',
412 path => '{userid}/{id}',
413 method => 'PUT',
414 permissions => {
415 check => [ 'or',
416 ['userid-param', 'self'],
417 ['userid-group', ['User.Modify']],
418 ],
419 },
420 protected => 1, # else we can't access shadow files
421 allowtoken => 0, # we don't want tokens to change the regular user's TFA settings
422 description => 'Add a TFA entry for a user.',
423 parameters => {
424 additionalProperties => 0,
425 properties => {
426 userid => get_standard_option('userid', {
427 completion => \&PVE::AccessControl::complete_username,
428 }),
429 id => $TFA_ID_SCHEMA,
430 description => {
431 type => 'string',
432 description => 'A description to distinguish multiple entries from one another',
433 maxLength => 255,
434 optional => 1,
435 },
436 enable => {
437 type => 'boolean',
438 description => 'Whether the entry should be enabled for login.',
439 optional => 1,
440 },
441 password => $OPTIONAL_PASSWORD_SCHEMA,
442 },
443 },
444 returns => { type => 'null' },
445 code => sub {
446 my ($param) = @_;
447
448 my $rpcenv = PVE::RPCEnvironment::get();
449 my $authuser = $rpcenv->get_user();
450 my $userid = $rpcenv->reauth_user_for_user_modification(
451 $authuser,
452 $param->{userid},
453 $param->{password},
454 );
455
456 PVE::AccessControl::lock_tfa_config(sub {
457 my $tfa_cfg = cfs_read_file('priv/tfa.cfg');
458
459 $tfa_cfg->api_update_tfa_entry(
460 $userid,
461 $param->{id},
462 $param->{description},
463 $param->{enable},
464 );
465
466 cfs_write_file('priv/tfa.cfg', $tfa_cfg);
467 });
468 }});
469
470 1;