]>
Commit | Line | Data |
---|---|---|
dc547a13 WB |
1 | package PVE::API2::TFA; |
2 | ||
3 | use strict; | |
4 | use warnings; | |
5 | ||
b3dae5dd WB |
6 | use HTTP::Status qw(:constants); |
7 | ||
dc547a13 | 8 | use PVE::AccessControl; |
07692c72 | 9 | use PVE::Cluster qw(cfs_read_file cfs_write_file); |
dc547a13 | 10 | use PVE::Exception qw(raise raise_perm_exc raise_param_exc); |
2974aa33 | 11 | use PVE::JSONSchema qw(get_standard_option); |
dc547a13 | 12 | use PVE::RPCEnvironment; |
2974aa33 | 13 | use PVE::SafeSyslog; |
dc547a13 WB |
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 | ||
07692c72 WB |
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 | ||
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. | |
130 | my 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 |
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 | ||
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 | 491 | 1; |