config: make the /w/ prefix optional for gitweb URLs
[girocco.git] / Girocco / User.pm
blob39a1d2466c80f8f8a0a07ca0e51bd6e17794f31b
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";
55 sub _passwd_add {
56 my $self = shift;
57 my (undef, undef, $gid) = getgrnam($Girocco::Config::owning_group||'');
58 my $owngroupid = $gid ? $gid : 65534;
59 Girocco::User->load($self->{name}) and die "User $self->{name} already exists";
60 $self->{uuid} = $self->_gen_uuid;
61 my $email_uuid = join ',', $self->{email}, $self->{uuid};
62 filedb_atomic_append(jailed_file('/etc/passwd'),
63 join(':', $self->{name}, 'x', '\i', $owngroupid, $email_uuid, '/', '/bin/git-shell-verify'),
64 $self->{name});
65 $self->_remove_ssh_leftovers;
68 sub _passwd_update {
69 my $self = shift;
70 filedb_atomic_edit(jailed_file('/etc/passwd'),
71 sub {
72 $_ = $_[0];
73 chomp;
74 if ($self->{name} eq (split /:/)[0]) {
75 # preserve all but login name and comment field
76 my @fields=split(/:/, $_, -1);
77 $fields[0] = $self->{name};
78 $self->{uuid} = (split(',', $fields[4]))[1] || '';
79 $self->{uuid} or $self->{uuid} = $self->_gen_uuid;
80 $fields[4] = join(',', $self->{email}, $self->{uuid});
81 return join(':', @fields)."\n";
82 } else {
83 return "$_\n";
86 $self->{name}
90 sub _passwd_remove {
91 my $self = shift;
92 $self->_remove_ssh_leftovers;
93 filedb_atomic_edit(jailed_file('/etc/passwd'),
94 sub {
95 $self->{name} ne (split /:/)[0] and return $_;
97 $self->{name}
101 sub _sshkey_path {
102 my $self = shift;
103 '/etc/sshkeys/'.$self->{name};
106 sub _sshkey_load {
107 my $self = shift;
108 open F, '<', jailed_file($self->_sshkey_path) or die "sshkey load failed: $!";
109 my @keys = ();
110 my $auth = '';
111 my $authtype = '';
112 while (<F>) {
113 chomp;
114 if (/^ssh-(?:dss|rsa) /) {
115 push @keys, $_;
116 } elsif (/^# ([A-Z]+)AUTH ([0-9a-f]+) (\d+)/) {
117 my $expire = $3;
118 $auth = $2 unless (time >= $expire);
119 $authtype = $1 if $auth;
122 close F;
123 my $keys = join("\n", @keys); chomp $keys;
124 ($keys, $auth, $authtype);
127 sub _trimkeys {
128 my $keys = shift;
129 my @lines = ();
130 foreach (split /\r\n|\r|\n/, $keys) {
131 next if /^[ \t]*$/ || /^[ \t]*#/;
132 push(@lines, $_);
134 return join("\n", @lines);
137 sub _sshkey_save {
138 my $self = shift;
139 $self->{keys} = _trimkeys($self->{keys} || '');
140 open F, '>', jailed_file($self->_sshkey_path) or die "sshkey save failed: $!";
141 if (defined($self->{auth}) && $self->{auth}) {
142 my $expire = time + 24 * 3600;
143 my $typestr = $self->{authtype} ? uc($self->{authtype}) : 'REPO';
144 print F "# ${typestr}AUTH $self->{auth} $expire\n";
146 print F $self->{keys};
147 print F "\n" if $self->{keys};
148 close F;
149 chmod 0664, jailed_file($self->_sshkey_path);
152 # private constructor, do not use
153 sub _new {
154 my $class = shift;
155 my ($name) = @_;
156 Girocco::User::valid_name($name) or die "refusing to create user with invalid name ($name)!";
157 my $proj = { name => $name };
159 bless $proj, $class;
162 # public constructor #0
163 # creates a virtual user not connected to disk record
164 # you can conjure() it later to disk
165 sub ghost {
166 my $class = shift;
167 my ($name) = @_;
168 my $self = $class->_new($name);
169 $self;
172 # public constructor #1
173 sub load {
174 my $class = shift;
175 my ($name) = @_;
177 open F, '<', jailed_file("/etc/passwd") or die "user load failed: $!";
178 while (<F>) {
179 chomp;
180 @_ = split /:/;
181 next unless (shift eq $name);
183 my $self = $class->_new($name);
185 my $email_uuid;
186 (undef, $self->{uid}, undef, $email_uuid) = @_;
187 ($self->{keys}, $self->{auth}, $self->{authtype}) = $self->_sshkey_load;
188 ($self->{email}, $self->{uuid}) = split ',', $email_uuid;
190 close F;
191 $self->{uuid} or $self->_passwd_update;
192 return $self;
194 close F;
195 undef;
198 # public constructor #2
199 sub load_by_uid {
200 my $class = shift;
201 my ($uid) = @_;
203 open F, '<', jailed_file("/etc/passwd") or die "user load failed: $!";
204 while (<F>) {
205 chomp;
206 @_ = split /:/;
207 next unless ($_[2] eq $uid);
209 my $self = $class->_new($_[0]);
211 my $email_uuid;
212 (undef, undef, $self->{uid}, undef, $email_uuid) = @_;
213 ($self->{keys}, $self->{auth}, $self->{authtype}) = $self->_sshkey_load;
214 ($self->{email}, $self->{uuid}) = split ',', $email_uuid;
216 close F;
217 $self->{uuid} or $self->_passwd_update;
218 return $self;
220 close F;
221 undef;
224 # $user may not be in sane state if this returns false!
225 sub cgi_fill {
226 my $self = shift;
227 my ($gcgi) = @_;
228 my $cgi = $gcgi->cgi;
230 $self->{name} = $gcgi->wparam('name');
231 Girocco::User::valid_name($self->{name})
232 or $gcgi->err("Name contains invalid characters.");
234 $self->{email} = $gcgi->wparam('email');
235 valid_email($self->{email})
236 or $gcgi->err("Your email sure looks weird...?");
238 $self->keys_fill($gcgi);
241 sub update_email {
242 my $self = shift;
243 my $gcgi = shift;
244 my $email = shift || '';
246 if (valid_email($email)) {
247 $self->{email} = $email;
248 $self->_passwd_update;
249 } else {
250 $gcgi->err("Your email sure looks weird...?");
253 not $gcgi->err_check;
256 sub _checkkey {
257 my $key = shift;
258 my ($type, $bits, $fingerprint, $comment) = sshpub_validate($key);
259 return $type ? 1 : 0;
262 sub keys_fill {
263 my $self = shift;
264 my ($gcgi) = @_;
265 my $cgi = $gcgi->cgi;
267 $self->{keys} = _trimkeys($cgi->param('keys'));
268 length($self->{keys}) <= 4096
269 or $gcgi->err("The list of keys is more than 4kb. Do you really need that much?");
270 foreach my $key (split /\r?\n/, $self->{keys}) {
271 my ($type, $bits, $fingerprint, $comment);
272 ($type, $bits, $fingerprint, $comment) = sshpub_validate($key)
273 if $key =~ /^ssh-(?:dss|rsa) [0-9A-Za-z+\/=]+ \S+@\S+$/;
274 if (!$type) {
275 my $keyval = CGI::escapeHTML($key);
276 my $dsablurb = '';
277 $dsablurb = ' or ssh-dss' unless $Girocco::Config::disable_dsa;
278 $gcgi->err(<<EOT);
279 Your ssh key ("$keyval") appears to have an invalid format
280 (does not start with ssh-rsa$dsablurb or does not end with <tt>\@</tt>-identifier) -
281 maybe your browser has split a single key onto multiple lines?
283 } elsif ($Girocco::Config::disable_dsa && $type eq 'ssh-dss') {
284 my $keyval = CGI::escapeHTML($key);
285 $gcgi->err(<<EOT);
286 Your ssh key ("$keyval") appears to be of type dsa but only rsa keys are
287 supported - please generate an rsa key (starts with ssh-rsa) and try again
289 } elsif ($Girocco::Config::min_key_length && $bits < $Girocco::Config::min_key_length) {
290 my $keyval = CGI::escapeHTML($key);
291 $gcgi->err(<<EOT);
292 Your ssh key ("$keyval") appears to have only $bits bit(s) but at least
293 $Girocco::Config::min_key_length are required - please generate a longer key
298 not $gcgi->err_check;
301 sub keys_save {
302 my $self = shift;
304 $self->_sshkey_save;
307 sub keys_html_list {
308 my $self = shift;
309 my @keys = split(/\r?\n/, $self->{keys});
310 return '' if !@keys;
311 my $html = "<ol>\n";
312 my %types = ('ssh-dss' => 'DSA', 'ssh-rsa' => 'RSA');
313 my $line = 0;
314 foreach (@keys) {
315 ++$line;
316 my ($type, $bits, $fingerprint, $comment) = sshpub_validate($_);
317 next unless $type && $types{$type};
318 my $euser = CGI::escapeHTML(CGI::Util::escape($self->{name}));
319 $html .= "<li>$bits <tt>$fingerprint</tt> ($types{$type}) $comment";
320 $html .= "<br /><a target=\"_blank\" ".
321 "href=\"@{[url_path($Girocco::Config::webadmurl)]}/usercert.cgi/$euser/$line/".
322 $Girocco::Config::nickname."_${euser}_user_$line.pem\">".
323 "download https push user authentication certificate</a> <sup>".
324 "<a target=\"_blank\" href=\"@{[url_path($Girocco::Config::htmlurl)]}/httpspush.html\">".
325 "(learn more)</a></sup>"
326 if $type eq 'ssh-rsa' && $Girocco::Config::httpspushurl &&
327 $Girocco::Config::clientcert &&
328 $Girocco::Config::clientkey;
329 $html .= "</li>\n";
331 $html .= "</ol>\n";
332 return $html;
335 sub gen_auth {
336 my $self = shift;
337 my ($type) = @_;
338 $type = 'REPO' unless $type && $type =~ /^[A-Z]+$/;
340 $self->{authtype} = $type;
341 $self->{auth} = sha1_hex(time . $$ . rand() . $self->{keys});
342 $self->_sshkey_save;
343 $self->{auth};
346 sub del_auth {
347 my $self = shift;
349 delete $self->{auth};
350 delete $self->{authtype};
353 sub get_projects {
354 my $self = shift;
356 return @{$self->{projects}} if defined($self->{projects});
357 my @projects = filedb_atomic_grep(jailed_file('/etc/group'),
358 sub {
359 $_ = $_[0];
360 chomp;
361 my ($group, $users) = (split /:/)[0,3];
362 $group if $users && $users =~ /(^|,)\Q$self->{name}\E(,|$)/;
365 $self->{projects} = \@projects;
366 @{$self->{projects}};
369 sub conjure {
370 my $self = shift;
372 $self->_passwd_add;
373 $self->_sshkey_save;
376 sub remove {
377 my $self = shift;
379 require Girocco::Project;
380 foreach ($self->get_projects) {
381 if (Girocco::Project::does_exist($_)) {
382 my $project = Girocco::Project->load($_);
383 $project->update if $project->remove_user($self->{name});
387 $self->_passwd_remove;
390 ### static methods
392 sub valid_name {
393 $_ = $_[0];
394 /^[a-zA-Z0-9+._-]+$/;
397 sub does_exist {
398 my ($name) = @_;
399 Girocco::User::valid_name($name) or die "tried to query for user with invalid name $name!";
400 (-e jailed_file("/etc/sshkeys/$name"));
403 sub resolve_uid {
404 my ($name) = @_;
405 $Girocco::Config::chrooted and undef; # TODO for ACLs within chroot
406 scalar(getpwnam($name));