]> git.proxmox.com Git - pmg-api.git/blame - src/PMG/UserConfig.pm
subscription: handle missing subscription info
[pmg-api.git] / src / PMG / UserConfig.pm
CommitLineData
62ebb4bc
DM
1package PMG::UserConfig;
2
62ebb4bc
DM
3use strict;
4use warnings;
2741f0b0 5
fff8e89c 6use Clone 'clone';
2741f0b0 7use Scalar::Util 'weaken';
62ebb4bc
DM
8
9use PVE::Tools;
10use PVE::INotify;
c861cb3a
DM
11use PVE::JSONSchema qw(get_standard_option);
12use PVE::Exception qw(raise);
62ebb4bc
DM
13
14use PMG::Utils;
15
16my $inotify_file_id = 'pmg-user.conf';
17my $config_filename = '/etc/pmg/user.conf';
18
2741f0b0
WB
19my $tfa_inotify_file_id = 'pmg-tfa.json';
20my $tfa_config_filename = '/etc/pmg/tfa.json';
21
62ebb4bc
DM
22sub 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
32sub write {
33 my ($self) = @_;
34
35 PVE::INotify::write_file($inotify_file_id, $self);
36}
37
38my $lockfile = "/var/lock/pmguser.lck";
2741f0b0 39my $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!
45my $user_config_locked;
62ebb4bc
DM
46sub 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.
61sub 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 75my $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
142our $create_schema = clone($schema);
143delete $create_schema->{properties}->{username};
144delete $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 153our $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 162my $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
181my $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
194sub 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
261sub 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
312PVE::INotify::register_file($inotify_file_id, $config_filename,
313 \&read_user_conf,
314 \&write_user_conf,
315 undef,
316 always_call_parser => 1);
317
318sub 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
328sub 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 350sub 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
3621;