]>
Commit | Line | Data |
---|---|---|
62ebb4bc DM |
1 | package PMG::UserConfig; |
2 | ||
62ebb4bc DM |
3 | use strict; |
4 | use warnings; | |
2741f0b0 | 5 | |
fff8e89c | 6 | use Clone 'clone'; |
2741f0b0 | 7 | use Scalar::Util 'weaken'; |
62ebb4bc DM |
8 | |
9 | use PVE::Tools; | |
10 | use PVE::INotify; | |
c861cb3a DM |
11 | use PVE::JSONSchema qw(get_standard_option); |
12 | use PVE::Exception qw(raise); | |
62ebb4bc DM |
13 | |
14 | use PMG::Utils; | |
15 | ||
16 | my $inotify_file_id = 'pmg-user.conf'; | |
17 | my $config_filename = '/etc/pmg/user.conf'; | |
18 | ||
2741f0b0 WB |
19 | my $tfa_inotify_file_id = 'pmg-tfa.json'; |
20 | my $tfa_config_filename = '/etc/pmg/tfa.json'; | |
21 | ||
62ebb4bc DM |
22 | sub new { |
23 | my ($type) = @_; | |
24 | ||
25 | my $class = ref($type) || $type; | |
26 | ||
27 | my $cfg = PVE::INotify::read_file($inotify_file_id); | |
28 | ||
29 | return bless $cfg, $class; | |
30 | } | |
31 | ||
32 | sub write { | |
33 | my ($self) = @_; | |
34 | ||
35 | PVE::INotify::write_file($inotify_file_id, $self); | |
36 | } | |
37 | ||
38 | my $lockfile = "/var/lock/pmguser.lck"; | |
2741f0b0 | 39 | my $tfa_lockfile = "/var/lock/pmgtfa.lck"; |
62ebb4bc | 40 | |
2741f0b0 WB |
41 | # Locking both config files together is only ever allowed in one order: |
42 | # 1) tfa config | |
43 | # 2) user config | |
44 | # If we permit the other way round, too, we might end up deadlocking! | |
45 | my $user_config_locked; | |
62ebb4bc DM |
46 | sub lock_config { |
47 | my ($code, $errmsg) = @_; | |
48 | ||
2741f0b0 WB |
49 | my $locked = 1; |
50 | $user_config_locked = \$locked; | |
51 | weaken $user_config_locked; # make this scope guard signal safe... | |
52 | ||
62ebb4bc | 53 | my $p = PVE::Tools::lock_file($lockfile, undef, $code); |
2741f0b0 WB |
54 | $user_config_locked = undef; |
55 | if (my $err = $@) { | |
56 | $errmsg ? die "$errmsg: $err" : die $err; | |
57 | } | |
58 | } | |
59 | ||
60 | # This lives here in order to enforce lock order. | |
61 | sub lock_tfa_config { | |
62 | my ($code, $errmsg) = @_; | |
63 | ||
64 | die "tfa config lock cannot be acquired while holding user config lock\n" | |
65 | if ($user_config_locked && $$user_config_locked); | |
66 | ||
67 | my $res = PVE::Tools::lock_file($tfa_lockfile, undef, $code); | |
62ebb4bc DM |
68 | if (my $err = $@) { |
69 | $errmsg ? die "$errmsg: $err" : die $err; | |
70 | } | |
2741f0b0 WB |
71 | |
72 | return $res; | |
62ebb4bc DM |
73 | } |
74 | ||
8333a87c | 75 | my $schema = { |
c861cb3a DM |
76 | additionalProperties => 0, |
77 | properties => { | |
4d813470 | 78 | userid => get_standard_option('userid'), |
0c7e2ca4 | 79 | username => get_standard_option('username', { optional => 1 }), |
1beed876 DM |
80 | realm => { |
81 | description => "Authentication realm.", | |
82 | type => 'string', | |
83 | enum => ['pam', 'pmg'], | |
84 | default => 'pmg', | |
85 | optional => 1, | |
86 | }, | |
c861cb3a DM |
87 | email => { |
88 | description => "Users E-Mail address.", | |
fff8e89c | 89 | type => 'string', format => 'email', |
c861cb3a DM |
90 | optional => 1, |
91 | }, | |
92 | expire => { | |
fff8e89c | 93 | description => "Account expiration date (seconds since epoch). '0' means no expiration date.", |
c861cb3a DM |
94 | type => 'integer', |
95 | minimum => 0, | |
fff8e89c DM |
96 | default => 0, |
97 | optional => 1, | |
c861cb3a DM |
98 | }, |
99 | enable => { | |
100 | description => "Flag to enable or disable the account.", | |
101 | type => 'boolean', | |
fff8e89c DM |
102 | default => 0, |
103 | optional => 1, | |
c861cb3a DM |
104 | }, |
105 | crypt_pass => { | |
106 | description => "Encrypted password (see `man crypt`)", | |
107 | type => 'string', | |
108 | pattern => '\$\d\$[a-zA-Z0-9\.\/]+\$[a-zA-Z0-9\.\/]+', | |
109 | optional => 1, | |
110 | }, | |
111 | role => { | |
1359baef | 112 | description => "User role. Role 'root' is reserved for the Unix Superuser.", |
c861cb3a | 113 | type => 'string', |
3058a948 | 114 | enum => ['root', 'admin', 'helpdesk', 'qmanager', 'audit'], |
c861cb3a | 115 | }, |
aec44b24 | 116 | firstname => { |
c861cb3a DM |
117 | description => "First name.", |
118 | type => 'string', | |
119 | maxLength => 64, | |
120 | optional => 1, | |
121 | }, | |
aec44b24 | 122 | lastname => { |
c861cb3a DM |
123 | description => "Last name.", |
124 | type => 'string', | |
125 | maxLength => 64, | |
126 | optional => 1, | |
127 | }, | |
128 | keys => { | |
fff8e89c | 129 | description => "Keys for two factor auth (yubico).", |
c861cb3a DM |
130 | type => 'string', |
131 | maxLength => 128, | |
132 | optional => 1, | |
133 | }, | |
fff8e89c DM |
134 | comment => { |
135 | description => "Comment.", | |
136 | type => 'string', | |
137 | optional => 1, | |
138 | }, | |
c861cb3a DM |
139 | }, |
140 | }; | |
141 | ||
8333a87c DM |
142 | our $create_schema = clone($schema); |
143 | delete $create_schema->{properties}->{username}; | |
144 | delete $create_schema->{properties}->{realm}; | |
ca46618a DM |
145 | $create_schema->{properties}->{password} = { |
146 | description => "Password", | |
147 | type => 'string', | |
148 | maxLength => 32, | |
149 | minLength => 5, | |
150 | optional => 1, | |
151 | }; | |
8333a87c | 152 | |
ca46618a | 153 | our $update_schema = clone($create_schema); |
fff8e89c | 154 | $update_schema->{properties}->{role}->{optional} = 1; |
0ecf02bc DM |
155 | $update_schema->{properties}->{delete} = { |
156 | type => 'string', format => 'pve-configid-list', | |
157 | description => "A list of settings you want to delete.", | |
158 | maxLength => 4096, | |
159 | optional => 1, | |
160 | }; | |
fff8e89c | 161 | |
7e37a733 | 162 | my $verify_entry = sub { |
c861cb3a DM |
163 | my ($entry) = @_; |
164 | ||
165 | my $errors = {}; | |
5ede0249 WB |
166 | my $userid = $entry->{userid}; |
167 | if (defined(my $username = $entry->{username})) { | |
168 | if ($userid !~ /^\Q$username\E\@/) { | |
169 | $errors->{'username'} = 'invalid username for userid'; | |
170 | } | |
171 | } else { | |
172 | # make sure the username's length is checked | |
173 | $entry->{username} = ($userid =~ s/\@.*$//r); | |
174 | } | |
c861cb3a DM |
175 | PVE::JSONSchema::check_prop($entry, $schema, '', $errors); |
176 | if (scalar(%$errors)) { | |
177 | raise "verify entry failed\n", errors => $errors; | |
178 | } | |
179 | }; | |
180 | ||
1461db83 DM |
181 | my $fixup_root_properties = sub { |
182 | my ($cfg) = @_; | |
183 | ||
184 | $cfg->{'root@pam'}->{userid} = 'root@pam'; | |
0c7e2ca4 | 185 | $cfg->{'root@pam'}->{username} = 'root'; |
1beed876 | 186 | $cfg->{'root@pam'}->{realm} = 'pam'; |
1461db83 DM |
187 | $cfg->{'root@pam'}->{enable} = 1; |
188 | $cfg->{'root@pam'}->{expire} = 0; | |
189 | $cfg->{'root@pam'}->{comment} = 'Unix Superuser'; | |
190 | $cfg->{'root@pam'}->{role} = 'root'; | |
191 | delete $cfg->{'root@pam'}->{crypt_pass}; | |
192 | }; | |
193 | ||
62ebb4bc DM |
194 | sub read_user_conf { |
195 | my ($filename, $fh) = @_; | |
196 | ||
197 | my $cfg = {}; | |
198 | ||
199 | if ($fh) { | |
200 | ||
201 | my $comment = ''; | |
202 | ||
203 | while (defined(my $line = <$fh>)) { | |
204 | next if $line =~ m/^\s*$/; | |
205 | if ($line =~ m/^#(.*)$/) { | |
206 | $comment = $1; | |
207 | next; | |
208 | } | |
c861cb3a DM |
209 | |
210 | if ($line =~ m/^ | |
211 | (?<userid>(?:[^\s:]+)) : | |
fff8e89c DM |
212 | (?<enable>[01]?) : |
213 | (?<expire>\d*) : | |
214 | (?<crypt_pass>(?:[^\s:]*)) : | |
c861cb3a DM |
215 | (?<role>[a-z]+) : |
216 | (?<email>(?:[^\s:]*)) : | |
aec44b24 DM |
217 | (?<firstname>(?:[^:]*)) : |
218 | (?<lastname>(?:[^:]*)) : | |
c861cb3a DM |
219 | (?<keys>(?:[^:]*)) : |
220 | $/x | |
221 | ) { | |
62ebb4bc | 222 | my $d = { |
0c7e2ca4 | 223 | username => $+{userid}, |
4d813470 | 224 | userid => $+{userid} . '@pmg', |
1beed876 | 225 | realm => 'pmg', |
fff8e89c DM |
226 | enable => $+{enable} || 0, |
227 | expire => $+{expire} || 0, | |
c861cb3a | 228 | role => $+{role}, |
62ebb4bc DM |
229 | }; |
230 | $d->{comment} = $comment if $comment; | |
231 | $comment = ''; | |
aec44b24 | 232 | foreach my $k (qw(crypt_pass email firstname lastname keys)) { |
c861cb3a DM |
233 | $d->{$k} = $+{$k} if $+{$k}; |
234 | } | |
235 | eval { | |
7e37a733 | 236 | $verify_entry->($d); |
c861cb3a | 237 | $cfg->{$d->{userid}} = $d; |
6006ffde DM |
238 | die "role 'root' is reserved\n" |
239 | if $d->{role} eq 'root' && $d->{userid} ne 'root@pmg'; | |
c861cb3a DM |
240 | }; |
241 | if (my $err = $@) { | |
242 | warn "$filename: $err"; | |
243 | } | |
62ebb4bc DM |
244 | } else { |
245 | warn "$filename: ignore invalid line $.\n"; | |
246 | $comment = ''; | |
247 | } | |
248 | } | |
249 | } | |
250 | ||
82613302 DM |
251 | # hack: we list root@pam here (root@pmg is an alias for root@pam) |
252 | $cfg->{'root@pam'} = $cfg->{'root@pmg'} // {}; | |
253 | delete $cfg->{'root@pmg'}; | |
254 | $cfg->{'root@pam'} //= {}; | |
1461db83 DM |
255 | |
256 | $fixup_root_properties->($cfg); | |
62ebb4bc DM |
257 | |
258 | return $cfg; | |
259 | } | |
260 | ||
261 | sub write_user_conf { | |
262 | my ($filename, $fh, $cfg) = @_; | |
263 | ||
264 | my $raw = ''; | |
265 | ||
1461db83 | 266 | $fixup_root_properties->($cfg); |
fff8e89c DM |
267 | |
268 | foreach my $userid (keys %$cfg) { | |
269 | my $d = $cfg->{$userid}; | |
82613302 | 270 | |
fff8e89c | 271 | $d->{userid} = $userid; |
4d813470 | 272 | |
cf009a08 | 273 | die "invalid userid '$userid'\n" if $userid eq 'root@pmg'; |
7e37a733 | 274 | $verify_entry->($d); |
830a5033 | 275 | $cfg->{$d->{userid}} = $d; |
7e818421 | 276 | |
830a5033 WB |
277 | if ($d->{userid} ne 'root@pam') { |
278 | die "role 'root' is reserved\n" if $d->{role} eq 'root'; | |
279 | die "unable to add users for realm '$d->{realm}'\n" | |
280 | if $d->{realm} && $d->{realm} ne 'pmg'; | |
fff8e89c | 281 | } |
4d813470 | 282 | |
82613302 DM |
283 | my $line; |
284 | ||
285 | if ($userid eq 'root@pam') { | |
286 | $line = 'root:'; | |
287 | $d->{crypt_pass} = '', | |
05e34f94 | 288 | $d->{expire} = '0', |
82613302 DM |
289 | $d->{role} = 'root'; |
290 | } else { | |
291 | next if $userid !~ m/^(?<username>.+)\@pmg$/; | |
292 | $line = "$+{username}:"; | |
293 | } | |
4d813470 | 294 | |
aec44b24 | 295 | for my $k (qw(enable expire crypt_pass role email firstname lastname keys)) { |
fff8e89c DM |
296 | $line .= ($d->{$k} // '') . ':'; |
297 | } | |
0ecf02bc DM |
298 | if (my $comment = $d->{comment}) { |
299 | my $firstline = (split /\n/, $comment)[0]; # only allow one line | |
300 | $raw .= "#$firstline\n"; | |
301 | } | |
fff8e89c DM |
302 | $raw .= $line . "\n"; |
303 | } | |
62ebb4bc | 304 | |
5232f267 DM |
305 | my $gid = getgrnam('www-data'); |
306 | chown(0, $gid, $fh); | |
307 | chmod(0640, $fh); | |
dc5f6d6e | 308 | |
62ebb4bc DM |
309 | PVE::Tools::safe_print($filename, $fh, $raw); |
310 | } | |
311 | ||
312 | PVE::INotify::register_file($inotify_file_id, $config_filename, | |
313 | \&read_user_conf, | |
314 | \&write_user_conf, | |
315 | undef, | |
316 | always_call_parser => 1); | |
317 | ||
318 | sub lookup_user_data { | |
319 | my ($self, $username, $noerr) = @_; | |
320 | ||
321 | return $self->{$username} if $self->{$username}; | |
322 | ||
323 | die "no such user ('$username')\n" if !$noerr; | |
324 | ||
325 | return undef; | |
326 | } | |
327 | ||
328 | sub authenticate_user { | |
329 | my ($self, $username, $password) = @_; | |
330 | ||
331 | die "no password\n" if !$password; | |
332 | ||
333 | my $data = $self->lookup_user_data($username); | |
334 | ||
c861cb3a DM |
335 | my $ctime = time(); |
336 | my $expire = $data->{expire}; | |
337 | ||
338 | die "account expired\n" if $expire && ($expire < $ctime); | |
339 | ||
62ebb4bc DM |
340 | if ($data->{crypt_pass}) { |
341 | my $encpw = crypt($password, $data->{crypt_pass}); | |
342 | die "invalid credentials\n" if ($encpw ne $data->{crypt_pass}); | |
343 | } else { | |
344 | die "no password set\n"; | |
345 | } | |
346 | ||
347 | return 1; | |
348 | } | |
349 | ||
be06bc52 | 350 | sub set_user_password { |
62ebb4bc DM |
351 | my ($class, $username, $password) = @_; |
352 | ||
353 | lock_config(sub { | |
354 | my $cfg = $class->new(); | |
355 | my $data = $cfg->lookup_user_data($username); # user exists | |
1a8170cf | 356 | my $epw = PVE::Tools::encrypt_pw($password); |
62ebb4bc DM |
357 | $data->{crypt_pass} = $epw; |
358 | $cfg->write(); | |
359 | }); | |
360 | } | |
361 | ||
362 | 1; |