User.pm: add no-pty to each key in ssh keys file
[girocco.git] / Girocco / User.pm
blob7e875d371552e0cbb42ea912ee449f593a0297a1
1 package Girocco::User;
3 use strict;
4 use warnings;
6 use Digest::MD5 qw(md5);
8 use Girocco::Config;
9 use Girocco::CGI;
10 use Girocco::Util;
11 use Girocco::SSHUtil;
13 BEGIN {
14 eval {
15 require Digest::SHA;
16 Digest::SHA->import(
17 qw(sha1_hex)
18 );1} ||
19 eval {
20 require Digest::SHA1;
21 Digest::SHA1->import(
22 qw(sha1_hex)
23 );1} ||
24 eval {
25 require Digest::SHA::PurePerl;
26 Digest::SHA::PurePerl->import(
27 qw(sha1_hex)
28 );1} ||
29 die "One of Digest::SHA or Digest::SHA1 or Digest::SHA::PurePerl "
30 . "must be available\n";
33 sub _gen_uuid {
34 my $self = shift;
36 $self->{uuid} = '' unless $self->{uuid};
37 my @md5;
39 no warnings;
40 @md5 = unpack('C*', md5(time . $$ . rand() . join(':',%$self)));
42 $md5[6] = 0x40 | ($md5[6] & 0x0F); # Version 4 -- random
43 $md5[8] = 0x80 | ($md5[8] & 0x3F); # RFC 4122 specification
44 return sprintf(
45 '%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x',
46 @md5);
49 sub _remove_ssh_leftovers {
50 my $self = shift;
51 system "rm -f '$Girocco::Config::chroot/etc/sshkeys/$self->{name}'";
52 system "rm -f '$Girocco::Config::chroot/etc/sshcerts/${Girocco::Config::nickname}_$self->{name}'_user_*.pem";
53 system "rm -f '$Girocco::Config::chroot/etc/sshactive/$self->{name}'";
54 system "rm -f '$Girocco::Config::chroot/etc/sshactive/$self->{name}',*";
57 sub _passwd_add {
58 use POSIX qw(strftime);
59 my $self = shift;
60 my (undef, undef, $gid) = getgrnam($Girocco::Config::owning_group||'');
61 my $owngroupid = $gid ? $gid : 65534;
62 Girocco::User->load($self->{name}) and die "User $self->{name} already exists";
63 $self->{uuid} = $self->_gen_uuid;
64 my ($S,$M,$H,$d,$m,$y) = gmtime(time());
65 $self->{creationtime} = strftime("%Y%m%d_%H%M%S", $S, $M, $H, $d, $m, $y, -1, -1, -1);
66 my $email_uuid_etc = join ',', $self->{email}, $self->{uuid}, $self->{creationtime};
67 filedb_atomic_append(jailed_file('/etc/passwd'),
68 join(':', $self->{name}, 'x', '\i', $owngroupid, $email_uuid_etc, '/', '/bin/git-shell-verify'),
69 $self->{name});
70 $self->_remove_ssh_leftovers;
73 sub _passwd_update {
74 my $self = shift;
75 filedb_atomic_edit(jailed_file('/etc/passwd'),
76 sub {
77 $_ = $_[0];
78 chomp;
79 if ($self->{name} eq (split /:/)[0]) {
80 # preserve all but login name and first 2 fields of comment field
81 # creating a uuid (field 2 of comment field) if one is not already present
82 my @fields=split(/:/, $_, -1);
83 $fields[0] = $self->{name};
84 my @subfields = split(',', $fields[4]||'', -1);
85 $self->{uuid} = $subfields[1] || '';
86 $self->{uuid} or $self->{uuid} = $self->_gen_uuid;
87 $subfields[0] = $self->{email};
88 $subfields[1] = $self->{uuid};
89 $fields[4] = join(',', @subfields);
90 return join(':', @fields)."\n";
91 } else {
92 return "$_\n";
95 $self->{name}
99 sub _passwd_remove {
100 my $self = shift;
101 $self->_remove_ssh_leftovers;
102 filedb_atomic_edit(jailed_file('/etc/passwd'),
103 sub {
104 $self->{name} ne (split /:/)[0] and return $_;
106 $self->{name}
110 sub _sshkey_path {
111 my $self = shift;
112 '/etc/sshkeys/'.$self->{name};
115 sub _sshkey_load {
116 my $self = shift;
117 open F, '<', jailed_file($self->_sshkey_path) or die "sshkey load failed: $!";
118 my @keys = ();
119 my $auth = '';
120 my $authtype = '';
121 while (<F>) {
122 chomp;
123 if (/^(?:no-pty )?(ssh-(?:dss|rsa) .*)$/) {
124 push @keys, $1;
125 } elsif (/^# ([A-Z]+)AUTH ([0-9a-f]+) (\d+)/) {
126 my $expire = $3;
127 $auth = $2 unless (time >= $expire);
128 $authtype = $1 if $auth;
131 close F;
132 my $keys = join("\n", @keys); chomp $keys;
133 ($keys, $auth, $authtype);
136 sub _trimkeys {
137 my $keys = shift;
138 my @lines = ();
139 foreach (split /\r\n|\r|\n/, $keys) {
140 next if /^[ \t]*$/ || /^[ \t]*#/;
141 push(@lines, $_);
143 return join("\n", @lines);
146 sub _sshkey_save {
147 my $self = shift;
148 $self->{keys} = _trimkeys($self->{keys} || '');
149 open F, '>', jailed_file($self->_sshkey_path) or die "sshkey save failed: $!";
150 if (defined($self->{auth}) && $self->{auth}) {
151 my $expire = time + 24 * 3600;
152 my $typestr = $self->{authtype} ? uc($self->{authtype}) : 'REPO';
153 print F "# ${typestr}AUTH $self->{auth} $expire\n";
155 print F map("no-pty $_\n", split(/\n/, $self->{keys}));
156 close F;
157 chmod 0664, jailed_file($self->_sshkey_path);
160 # private constructor, do not use
161 sub _new {
162 my $class = shift;
163 my ($name) = @_;
164 Girocco::User::valid_name($name) or die "refusing to create user with invalid name ($name)!";
165 my $proj = { name => $name };
167 bless $proj, $class;
170 # public constructor #0
171 # creates a virtual user not connected to disk record
172 # you can conjure() it later to disk
173 sub ghost {
174 my $class = shift;
175 my ($name) = @_;
176 my $self = $class->_new($name);
177 $self;
180 # public constructor #1
181 sub load {
182 my $class = shift;
183 my ($name) = @_;
185 open F, '<', jailed_file("/etc/passwd") or die "user load failed: $!";
186 while (<F>) {
187 chomp;
188 @_ = split /:/;
189 next unless (shift eq $name);
191 my $self = $class->_new($name);
193 my $email_uuid_etc;
194 (undef, $self->{uid}, undef, $email_uuid_etc) = @_;
195 ($self->{keys}, $self->{auth}, $self->{authtype}) = $self->_sshkey_load;
196 ($self->{email}, $self->{uuid}, $self->{creationtime}) = split ',', $email_uuid_etc;
198 close F;
199 $self->{uuid} or !valid_email($self->{email}) or $self->_passwd_update;
200 return $self;
202 close F;
203 undef;
206 # public constructor #2
207 sub load_by_uid {
208 my $class = shift;
209 my ($uid) = @_;
211 open F, '<', jailed_file("/etc/passwd") or die "user load failed: $!";
212 while (<F>) {
213 chomp;
214 @_ = split /:/;
215 next unless ($_[2] eq $uid);
217 my $self = $class->_new($_[0]);
219 my $email_uuid_etc;
220 (undef, undef, $self->{uid}, undef, $email_uuid_etc) = @_;
221 ($self->{keys}, $self->{auth}, $self->{authtype}) = $self->_sshkey_load;
222 ($self->{email}, $self->{uuid}, $self->{creationtime}) = split ',', $email_uuid_etc;
224 close F;
225 $self->{uuid} or $self->_passwd_update;
226 return $self;
228 close F;
229 undef;
232 # $user may not be in sane state if this returns false!
233 sub cgi_fill {
234 my $self = shift;
235 my ($gcgi) = @_;
236 my $cgi = $gcgi->cgi;
238 $self->{name} = $gcgi->wparam('name');
239 Girocco::User::valid_name($self->{name})
240 or $gcgi->err("Name contains invalid characters.");
242 length($self->{name}) <= 64
243 or $gcgi->err("Your user name is longer than 64 characters. Do you really need that much?");
245 $self->{email} = $gcgi->wparam('email');
246 valid_email($self->{email})
247 or $gcgi->err("Your email sure looks weird...?");
248 length($self->{email}) <= 96
249 or $gcgi->err("Your email is longer than 96 characters. Do you really need that much?");
251 $self->keys_fill($gcgi);
254 sub update_email {
255 my $self = shift;
256 my $gcgi = shift;
257 my $email = shift || '';
259 if (valid_email($email)) {
260 $self->{email} = $email;
261 $self->_passwd_update;
262 } else {
263 $gcgi->err("Your email sure looks weird...?");
266 not $gcgi->err_check;
269 sub _checkkey {
270 my $key = shift;
271 my ($type, $bits, $fingerprint, $comment) = sshpub_validate($key);
272 return $type ? 1 : 0;
275 sub keys_fill {
276 my $self = shift;
277 my ($gcgi) = @_;
278 my $cgi = $gcgi->cgi;
280 $self->{keys} = _trimkeys($cgi->param('keys'));
281 length($self->{keys}) <= 4096
282 or $gcgi->err("The list of keys is more than 4kb. Do you really need that much?");
283 foreach my $key (split /\r?\n/, $self->{keys}) {
284 my ($type, $bits, $fingerprint, $comment);
285 ($type, $bits, $fingerprint, $comment) = sshpub_validate($key)
286 if $key =~ /^ssh-(?:dss|rsa) [0-9A-Za-z+\/=]+ \S+@\S+$/;
287 if (!$type) {
288 my $keyval = CGI::escapeHTML($key);
289 my $dsablurb = '';
290 $dsablurb = ' or ssh-dss' unless $Girocco::Config::disable_dsa;
291 $gcgi->err(<<EOT);
292 Your ssh key ("$keyval") appears to have an invalid format
293 (does not start with ssh-rsa$dsablurb or does not end with <tt>\@</tt>-identifier) -
294 maybe your browser has split a single key onto multiple lines?
296 } elsif ($Girocco::Config::disable_dsa && $type eq 'ssh-dss') {
297 my $keyval = CGI::escapeHTML($key);
298 $gcgi->err(<<EOT);
299 Your ssh key ("$keyval") appears to be of type dsa but only rsa keys are
300 supported - please generate an rsa key (starts with ssh-rsa) and try again
302 } elsif ($Girocco::Config::min_key_length && $bits < $Girocco::Config::min_key_length) {
303 my $keyval = CGI::escapeHTML($key);
304 $gcgi->err(<<EOT);
305 Your ssh key ("$keyval") appears to have only $bits bit(s) but at least
306 $Girocco::Config::min_key_length are required - please generate a longer key
311 not $gcgi->err_check;
314 sub keys_save {
315 my $self = shift;
317 $self->_sshkey_save;
320 sub keys_html_list {
321 my $self = shift;
322 my @keys = split(/\r?\n/, $self->{keys});
323 return '' if !@keys;
324 my $html = "<ol>\n";
325 my %types = ('ssh-dss' => 'DSA', 'ssh-rsa' => 'RSA');
326 my $line = 0;
327 foreach (@keys) {
328 ++$line;
329 my ($type, $bits, $fingerprint, $comment) = sshpub_validate($_);
330 next unless $type && $types{$type};
331 my $euser = CGI::escapeHTML(CGI::Util::escape($self->{name}));
332 $html .= "<li>$bits <tt>$fingerprint</tt> ($types{$type}) $comment";
333 $html .= "<br /><a target=\"_blank\" ".
334 "href=\"@{[url_path($Girocco::Config::webadmurl)]}/usercert.cgi/$euser/$line/".
335 $Girocco::Config::nickname."_${euser}_user_$line.pem\">".
336 "download https push user authentication certificate</a> <sup>".
337 "<a target=\"_blank\" href=\"@{[url_path($Girocco::Config::htmlurl)]}/httpspush.html\">".
338 "(learn more)</a></sup>"
339 if $type eq 'ssh-rsa' && $Girocco::Config::httpspushurl &&
340 $Girocco::Config::clientcert &&
341 $Girocco::Config::clientkey;
342 $html .= "</li>\n";
344 $html .= "</ol>\n";
345 return $html;
348 sub gen_auth {
349 my $self = shift;
350 my ($type) = @_;
351 $type = 'REPO' unless $type && $type =~ /^[A-Z]+$/;
353 $self->{authtype} = $type;
354 $self->{auth} = sha1_hex(time . $$ . rand() . $self->{keys});
355 $self->_sshkey_save;
356 $self->{auth};
359 sub del_auth {
360 my $self = shift;
362 delete $self->{auth};
363 delete $self->{authtype};
366 sub get_projects {
367 my $self = shift;
369 return @{$self->{projects}} if defined($self->{projects});
370 my @projects = filedb_atomic_grep(jailed_file('/etc/group'),
371 sub {
372 $_ = $_[0];
373 chomp;
374 my ($group, $users) = (split /:/)[0,3];
375 $group if $users && $users =~ /(^|,)\Q$self->{name}\E(,|$)/;
378 $self->{projects} = \@projects;
379 @{$self->{projects}};
382 sub conjure {
383 my $self = shift;
385 $self->_passwd_add;
386 $self->_sshkey_save;
389 sub remove {
390 my $self = shift;
392 require Girocco::Project;
393 foreach ($self->get_projects) {
394 if (Girocco::Project::does_exist($_)) {
395 my $project = Girocco::Project->load($_);
396 $project->update if $project->remove_user($self->{name});
400 $self->_passwd_remove;
403 ### static methods
405 # Note that 'mob' and 'everyone' are NOT reserved names per se, but they are
406 # names with special semantics and they should be allowed in project membership
407 # lists so they are therefore NOT included in the reservedusernames list.
408 # 'git' and 'lock' are reserved so that the personal mob names 'mob.git' and
409 # 'mob.lock' are not needed as they would be invalid.
410 our %reservedusernames = (
411 root => 1,
412 sshd => 1,
413 _sshd => 1,
414 nobody => 1,
415 lc($Girocco::Config::cgi_user) => 1,
416 lc($Girocco::Config::mirror_user) => 1,
417 git => 1,
418 lock => 1,
421 sub valid_name {
422 $_ = $_[0];
423 /^[a-zA-Z0-9][a-zA-Z0-9+._-]*$/
424 and (not m#\.\.#)
425 and (not m#\.$#)
426 and (not m#\.git$#i)
427 and (not m#\.lock$#i)
428 and (not m#^mob[._]#i)
429 and !exists($reservedusernames{lc($_)});
432 sub does_exist {
433 my ($name) = @_;
434 Girocco::User::valid_name($name) or die "tried to query for user with invalid name $name!";
435 (-e jailed_file("/etc/sshkeys/$name"));
438 sub resolve_uid {
439 my ($name) = @_;
440 $Girocco::Config::chrooted and undef; # TODO for ACLs within chroot
441 scalar(getpwnam($name));