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