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