--- loncom/lond 2014/06/09 19:47:51 1.489.2.15 +++ loncom/lond 2016/09/24 19:44:15 1.489.2.24 @@ -2,7 +2,7 @@ # The LearningOnline Network # lond "LON Daemon" Server (port "LOND" 5663) # -# $Id: lond,v 1.489.2.15 2014/06/09 19:47:51 raeburn Exp $ +# $Id: lond,v 1.489.2.24 2016/09/24 19:44:15 raeburn Exp $ # # Copyright Michigan State University Board of Trustees # @@ -55,13 +55,16 @@ use LONCAPA::lonssl; use Fcntl qw(:flock); use Apache::lonnet; use Mail::Send; +use Crypt::Eksblowfish::Bcrypt; +use Digest::SHA; +use Encode; my $DEBUG = 0; # Non zero to enable debug log entries. my $status=''; my $lastlog=''; -my $VERSION='$Revision: 1.489.2.15 $'; #' stupid emacs +my $VERSION='$Revision: 1.489.2.24 $'; #' stupid emacs my $remoteVERSION; my $currenthostid="default"; my $currentdomainid; @@ -1422,6 +1425,20 @@ sub du2_handler { # selected directory the filename followed by the full output of # the stat function is returned. The returned info for each # file are separated by ':'. The stat fields are separated by &'s. +# +# If the requested path contains /../ or is: +# +# 1. for a directory, and the path does not begin with one of: +# (a) /home/httpd/html/res// +# (b) /home/httpd/html/res/userfiles/ +# (c) /home/httpd/lonUsers//<1>/<2>/<3>//userfiles +# or is: +# +# 2. for a file, and the path (after prepending) does not begin with: +# /home/httpd/lonUsers//<1>/<2>/<3>// +# +# the response will be "refused". +# # Parameters: # $cmd - The command that dispatched us (ls). # $ulsdir - The directory path to list... I'm not sure what this @@ -1443,8 +1460,17 @@ sub ls_handler { my $rights; my $ulsout=''; my $ulsfn; + if ($ulsdir =~m{/\.\./}) { + &Failure($client,"refused\n",$userinput); + return 1; + } if (-e $ulsdir) { if(-d $ulsdir) { + unless (($ulsdir =~ m{/home/httpd/html/(res/$LONCAPA::match_domain|userfiles)/}) || + ($ulsdir =~ m{/home/httpd/lonUsers/$LONCAPA::match_domain(?:/[\w\-.@]){3}/$LONCAPA::match_username/userfiles/})) { + &Failure($client,"refused\n",$userinput); + return 1; + } if (opendir(LSDIR,$ulsdir)) { while ($ulsfn=readdir(LSDIR)) { undef($obs); @@ -1468,6 +1494,10 @@ sub ls_handler { closedir(LSDIR); } } else { + unless ($ulsdir =~ m{/home/httpd/lonUsers/$LONCAPA::match_domain(?:/[\w\-.@]){3}/$LONCAPA::match_username/}) { + &Failure($client,"refused\n",$userinput); + return 1; + } my @ulsstats=stat($ulsdir); $ulsout.=$ulsfn.'&'.join('&',@ulsstats).':'; } @@ -1492,6 +1522,20 @@ sub ls_handler { # selected directory the filename followed by the full output of # the stat function is returned. The returned info for each # file are separated by ':'. The stat fields are separated by &'s. +# +# If the requested path contains /../ or is: +# +# 1. for a directory, and the path does not begin with one of: +# (a) /home/httpd/html/res// +# (b) /home/httpd/html/res/userfiles/ +# (c) /home/httpd/lonUsers//<1>/<2>/<3>//userfiles +# or is: +# +# 2. for a file, and the path (after prepending) does not begin with: +# /home/httpd/lonUsers//<1>/<2>/<3>// +# +# the response will be "refused". +# # Parameters: # $cmd - The command that dispatched us (ls). # $ulsdir - The directory path to list... I'm not sure what this @@ -1512,8 +1556,17 @@ sub ls2_handler { my $rights; my $ulsout=''; my $ulsfn; + if ($ulsdir =~m{/\.\./}) { + &Failure($client,"refused\n",$userinput); + return 1; + } if (-e $ulsdir) { if(-d $ulsdir) { + unless (($ulsdir =~ m{/home/httpd/html/(res/$LONCAPA::match_domain|userfiles)/}) || + ($ulsdir =~ m{/home/httpd/lonUsers/$LONCAPA::match_domain(?:/[\w\-.@]){3}/$LONCAPA::match_username/userfiles/})) { + &Failure($client,"refused\n","$userinput"); + return 1; + } if (opendir(LSDIR,$ulsdir)) { while ($ulsfn=readdir(LSDIR)) { undef($obs); @@ -1538,6 +1591,10 @@ sub ls2_handler { closedir(LSDIR); } } else { + unless ($ulsdir =~ m{/home/httpd/lonUsers/$LONCAPA::match_domain(?:/[\w\-.@]){3}/$LONCAPA::match_username/}) { + &Failure($client,"refused\n",$userinput); + return 1; + } my @ulsstats=stat($ulsdir); $ulsout.=$ulsfn.'&'.join('&',@ulsstats).':'; } @@ -1554,6 +1611,22 @@ sub ls2_handler { # selected directory the filename followed by the full output of # the stat function is returned. The returned info for each # file are separated by ':'. The stat fields are separated by &'s. +# +# If the requested path (after prepending) contains /../ or is: +# +# 1. for a directory, and the path does not begin with one of: +# (a) /home/httpd/html/res// +# (b) /home/httpd/html/res/userfiles/ +# (c) /home/httpd/lonUsers//<1>/<2>/<3>//userfiles +# (d) /home/httpd/html/priv// and client is the homeserver +# +# or is: +# +# 2. for a file, and the path (after prepending) does not begin with: +# /home/httpd/lonUsers//<1>/<2>/<3>// +# +# the response will be "refused". +# # Parameters: # $cmd - The command that dispatched us (ls). # $tail - The tail of the request that invoked us. @@ -1593,22 +1666,12 @@ sub ls3_handler { } my $dir_root = $perlvar{'lonDocRoot'}; - if ($getpropath) { + if (($getpropath) || ($getuserdir)) { if (($uname =~ /^$LONCAPA::match_name$/) && ($udom =~ /^$LONCAPA::match_domain$/)) { $dir_root = &propath($udom,$uname); $dir_root =~ s/\/$//; } else { - &Failure($client,"refused\n","$cmd:$tail"); - return 1; - } - } elsif ($getuserdir) { - if (($uname =~ /^$LONCAPA::match_name$/) && ($udom =~ /^$LONCAPA::match_domain$/)) { - my $subdir=$uname.'__'; - $subdir =~ s/(.)(.)(.).*/$1\/$2\/$3/; - $dir_root = $Apache::lonnet::perlvar{'lonUsersDir'} - ."/$udom/$subdir/$uname"; - } else { - &Failure($client,"refused\n","$cmd:$tail"); + &Failure($client,"refused\n",$userinput); return 1; } } elsif ($alternate_root ne '') { @@ -1621,12 +1684,28 @@ sub ls3_handler { $ulsdir = $dir_root.'/'.$ulsdir; } } + if ($ulsdir =~m{/\.\./}) { + &Failure($client,"refused\n",$userinput); + return 1; + } + my $islocal; + my @machine_ids = &Apache::lonnet::current_machine_ids(); + if (grep(/^\Q$clientname\E$/,@machine_ids)) { + $islocal = 1; + } my $obs; my $rights; my $ulsout=''; my $ulsfn; if (-e $ulsdir) { if(-d $ulsdir) { + unless (($getpropath) || ($getuserdir) || + ($ulsdir =~ m{/home/httpd/html/(res/$LONCAPA::match_domain|userfiles)/}) || + ($ulsdir =~ m{/home/httpd/lonUsers/$LONCAPA::match_domain(?:/[\w\-.@]){3}/$LONCAPA::match_username/userfiles/}) || + (($ulsdir =~ m{/home/httpd/html/priv/$LONCAPA::match_domain/}) && ($islocal))) { + &Failure($client,"refused\n",$userinput); + return 1; + } if (opendir(LSDIR,$ulsdir)) { while ($ulsfn=readdir(LSDIR)) { undef($obs); @@ -1651,6 +1730,11 @@ sub ls3_handler { closedir(LSDIR); } } else { + unless (($getpropath) || ($getuserdir) || + ($ulsdir =~ m{/home/httpd/lonUsers/$LONCAPA::match_domain(?:/[\w\-.@]){3}/$LONCAPA::match_username/})) { + &Failure($client,"refused\n",$userinput); + return 1; + } my @ulsstats=stat($ulsdir); $ulsout.=$ulsfn.'&'.join('&',@ulsstats).':'; } @@ -2017,15 +2101,14 @@ sub change_password_handler { my ($howpwd,$contentpwd)=split(/:/,$realpasswd); if ($howpwd eq 'internal') { &Debug("internal auth"); - my $salt=time; - $salt=substr($salt,6,2); - my $ncpass=crypt($npass,$salt); + my $ncpass = &hash_passwd($udom,$npass); if(&rewrite_password_file($udom, $uname, "internal:$ncpass")) { my $msg="Result of password change for $uname: pwchange_success"; if ($lonhost) { $msg .= " - request originated from: $lonhost"; } &logthis($msg); + &update_passwd_history($uname,$udom,$howpwd,$context); &Reply($client, "ok\n", $userinput); } else { &logthis("Unable to open $uname passwd " @@ -2034,6 +2117,9 @@ sub change_password_handler { } } elsif ($howpwd eq 'unix' && $context ne 'reset_by_email') { my $result = &change_unix_password($uname, $npass); + if ($result eq 'ok') { + &update_passwd_history($uname,$udom,$howpwd,$context); + } &logthis("Result of password change for $uname: ". $result); &Reply($client, \$result, $userinput); @@ -2056,6 +2142,42 @@ sub change_password_handler { } ®ister_handler("passwd", \&change_password_handler, 1, 1, 0); +sub hash_passwd { + my ($domain,$plainpass,@rest) = @_; + my ($salt,$cost); + if (@rest) { + $cost = $rest[0]; + # salt is first 22 characters, base-64 encoded by bcrypt + my $plainsalt = substr($rest[1],0,22); + $salt = Crypt::Eksblowfish::Bcrypt::de_base64($plainsalt); + } else { + my $defaultcost; + my %domconfig = + &Apache::lonnet::get_dom('configuration',['password'],$domain); + if (ref($domconfig{'password'}) eq 'HASH') { + $defaultcost = $domconfig{'password'}{'cost'}; + } + if (($defaultcost eq '') || ($defaultcost =~ /D/)) { + $cost = 10; + } else { + $cost = $defaultcost; + } + # Generate random 16-octet base64 salt + $salt = ""; + $salt .= pack("C", int rand(256)) for 1..16; + } + my $hash = &Crypt::Eksblowfish::Bcrypt::bcrypt_hash({ + key_nul => 1, + cost => $cost, + salt => $salt, + }, Digest::SHA::sha512(Encode::encode('UTF-8',$plainpass))); + + my $result = join("!", "", "bcrypt", sprintf("%02d",$cost), + &Crypt::Eksblowfish::Bcrypt::en_base64($salt). + &Crypt::Eksblowfish::Bcrypt::en_base64($hash)); + return $result; +} + # # Create a new user. User in this case means a lon-capa user. # The user must either already exist in some authentication realm @@ -2099,7 +2221,8 @@ sub add_user_handler { ."makeuser"; } unless ($fperror) { - my $result=&make_passwd_file($uname,$udom,$umode,$npass, $passfilename); + my $result=&make_passwd_file($uname,$udom,$umode,$npass, + $passfilename,'makeuser'); &Reply($client,\$result, $userinput); #BUGBUG - could be fail } else { &Failure($client, \$fperror, $userinput); @@ -2168,12 +2291,14 @@ sub change_authentication_handler { my $result = &change_unix_password($uname, $npass); &logthis("Result of password change for $uname: ".$result); if ($result eq "ok") { + &update_passwd_history($uname,$udom,$umode,'changeuserauth'); &Reply($client, \$result); } else { &Failure($client, \$result); } } else { - my $result=&make_passwd_file($uname,$udom,$umode,$npass,$passfilename); + my $result=&make_passwd_file($uname,$udom,$umode,$npass, + $passfilename,'changeuserauth'); # # If the current auth mode is internal, and the old auth mode was # unix, or krb*, and the user is an author for this domain, @@ -2194,6 +2319,17 @@ sub change_authentication_handler { } ®ister_handler("changeuserauth", \&change_authentication_handler, 1,1, 0); +sub update_passwd_history { + my ($uname,$udom,$umode,$context) = @_; + my $proname=&propath($udom,$uname); + my $now = time; + if (open(my $fh,">>$proname/passwd.log")) { + print $fh "$now:$umode:$context\n"; + close($fh); + } + return; +} + # # Determines if this is the home server for a user. The home server # for a user will have his/her lon-capa passwd file. Therefore all we need @@ -2445,11 +2581,20 @@ sub remove_user_file_handler { if (-e $file) { # # If the file is a regular file unlink is fine... - # However it's possible the client wants a dir. - # removed, in which case rmdir is more approprate: + # However it's possible the client wants a dir + # removed, in which case rmdir is more appropriate + # Note: rmdir will only remove an empty directory. # if (-f $file){ unlink($file); + # for html files remove the associated .bak file + # which may have been created by the editor. + if ($ufile =~ m{^((docs|supplemental)/(?:\d+|default)/\d+(?:|/.+)/)[^/]+\.x?html?$}i) { + my $path = $1; + if (-e $file.'.bak') { + unlink($file.'.bak'); + } + } } elsif(-d $file) { rmdir($file); } @@ -2812,6 +2957,10 @@ sub newput_user_profile_entry { foreach my $pair (@pairs) { my ($key,$value)=split(/=/,$pair); if (exists($hashref->{$key})) { + if (!&untie_user_hash($hashref)) { + &logthis("error: ".($!+0)." untie (GDBM) failed ". + "while attempting newput - early out as key exists"); + } &Failure($client, "key_exists: ".$key."\n",$userinput); return 1; } @@ -3315,6 +3464,9 @@ sub dump_with_regexp { # namespace - Name of the database being modified # rid - Resource keyword to modify. # what - new value associated with rid. +# laststore - (optional) version=timestamp +# for most recent transaction for rid +# in namespace, when cstore was called # # $client - Socket open on the client. # @@ -3323,23 +3475,47 @@ sub dump_with_regexp { # 1 (keep on processing). # Side-Effects: # Writes to the client +# Successful storage will cause either 'ok', or, if $laststore was included +# in the tail of the request, and the version number for the last transaction +# is larger than the version in $laststore, delay:$numtrans , where $numtrans +# is the number of store evevnts recorded for rid in namespace since +# lonnet::store() was called by the client. +# sub store_handler { my ($cmd, $tail, $client) = @_; my $userinput = "$cmd:$tail"; - my ($udom,$uname,$namespace,$rid,$what) =split(/:/,$tail); + chomp($tail); + my ($udom,$uname,$namespace,$rid,$what,$laststore) =split(/:/,$tail); if ($namespace ne 'roles') { - chomp($what); my @pairs=split(/\&/,$what); my $hashref = &tie_user_hash($udom, $uname, $namespace, &GDBM_WRCREAT(), "S", "$rid:$what"); if ($hashref) { my $now = time; - my @previouskeys=split(/&/,$hashref->{"keys:$rid"}); - my $key; + my $numtrans; + if ($laststore) { + my ($previousversion,$previoustime) = split(/\=/,$laststore); + my ($lastversion,$lasttime) = (0,0); + $lastversion = $hashref->{"version:$rid"}; + if ($lastversion) { + $lasttime = $hashref->{"$lastversion:$rid:timestamp"}; + } + if (($previousversion) && ($previousversion !~ /\D/)) { + if (($lastversion > $previousversion) && ($lasttime >= $previoustime)) { + $numtrans = $lastversion - $previousversion; + } + } elsif ($lastversion) { + $numtrans = $lastversion; + } + if ($numtrans) { + $numtrans =~ s/D//g; + } + } + $hashref->{"version:$rid"}++; my $version=$hashref->{"version:$rid"}; my $allkeys=''; @@ -3352,7 +3528,11 @@ sub store_handler { $allkeys.='timestamp'; $hashref->{"$version:keys:$rid"}=$allkeys; if (&untie_user_hash($hashref)) { - &Reply($client, "ok\n", $userinput); + my $msg = 'ok'; + if ($numtrans) { + $msg = 'delay:'.$numtrans; + } + &Reply($client, "$msg\n", $userinput); } else { &Failure($client, "error: ".($!+0)." untie(GDBM) Failed ". "while attempting store\n", $userinput); @@ -5231,13 +5411,59 @@ sub create_auto_enroll_password_handler ®ister_handler("autocreatepassword", \&create_auto_enroll_password_handler, 0, 1, 0); +sub auto_export_grades_handler { + my ($cmd, $tail, $client) = @_; + my $userinput = "$cmd:$tail"; + my ($cdom,$cnum,$info,$data) = split(/:/,$tail); + my $inforef = &Apache::lonnet::thaw_unescape($info); + my $dataref = &Apache::lonnet::thaw_unescape($data); + my ($outcome,$result);; + eval { + local($SIG{__DIE__})='DEFAULT'; + my %rtnhash; + $outcome=&localenroll::export_grades($cdom,$cnum,$inforef,$dataref,\%rtnhash); + if ($outcome eq 'ok') { + foreach my $key (keys(%rtnhash)) { + $result .= &escape($key).'='.&Apache::lonnet::freeze_escape($rtnhash{$key}).'&'; + } + $result =~ s/\&$//; + } + }; + if (!$@) { + if ($outcome eq 'ok') { + if ($cipher) { + my $cmdlength=length($result); + $result.=" "; + my $encresult=''; + for (my $encidx=0;$encidx<=$cmdlength;$encidx+=8) { + $encresult.= unpack("H16", + $cipher->encrypt(substr($result, + $encidx, + 8))); + } + &Reply( $client, "enc:$cmdlength:$encresult\n", $userinput); + } else { + &Failure( $client, "error:no_key\n", $userinput); + } + } else { + &Reply($client, "$outcome\n", $userinput); + } + } else { + &Failure($client,"export_error\n",$userinput); + } + return 1; +} +®ister_handler("autoexportgrades", \&auto_export_grades_handler, + 0, 1, 0); + + # Retrieve and remove temporary files created by/during autoenrollment. # # Formal Parameters: # $cmd - The command that got us dispatched. # $tail - The tail of the command. In our case this is a colon # separated list that will be split into: -# $filename - The name of the file to remove. +# $filename - The name of the file to retrieve. # The filename is given as a path relative to # the LonCAPA temp file directory. # $client - Socket open on the client. @@ -5251,7 +5477,11 @@ sub retrieve_auto_file_handler { my ($filename) = split(/:/, $tail); my $source = $perlvar{'lonDaemons'}.'/tmp/'.$filename; - if ( (-e $source) && ($filename ne '') ) { + if ($filename =~m{/\.\./}) { + &Failure($client, "refused\n", $userinput); + } elsif ($filename !~ /^$LONCAPA::match_domain\_$LONCAPA::match_courseid\_.+_classlist\.xml$/) { + &Failure($client, "refused\n", $userinput); + } elsif ( (-e $source) && ($filename ne '') ) { my $reply = ''; if (open(my $fh,$source)) { while (<$fh>) { @@ -5359,7 +5589,7 @@ sub crsreq_update_handler { $title,$code,$accessstart,$accessend, $incoming,\%rtnhash); if ($outcome eq 'ok') { - my @posskeys = qw(createdweb createdmsg queuedweb queuedmsg formitems reviewweb); + my @posskeys = qw(createdweb createdmsg createdcustomized createdactions queuedweb queuedmsg formitems reviewweb validationjs onload javascript); foreach my $key (keys(%rtnhash)) { if (grep(/^\Q$key\E/,@posskeys)) { $result .= &escape($key).'='.&Apache::lonnet::freeze_escape($rtnhash{$key}).'&'; @@ -6573,12 +6803,28 @@ sub make_new_child { # my $tmpsnum=0; # Now global #---------------------------------------------------- kerberos 5 initialization &Authen::Krb5::init_context(); - unless (($dist eq 'fedora5') || ($dist eq 'fedora4') || - ($dist eq 'fedora6') || ($dist eq 'suse9.3') || - ($dist eq 'suse12.2') || ($dist eq 'suse12.3') || - ($dist eq 'suse13.1')) { - &Authen::Krb5::init_ets(); - } + + my $no_ets; + if ($dist =~ /^(?:centos|rhes|scientific)(\d+)$/) { + if ($1 >= 7) { + $no_ets = 1; + } + } elsif ($dist =~ /^suse(\d+\.\d+)$/) { + if (($1 eq '9.3') || ($1 >= 12.2)) { + $no_ets = 1; + } + } elsif ($dist =~ /^sles(\d+)$/) { + if ($1 > 11) { + $no_ets = 1; + } + } elsif ($dist =~ /^fedora(\d+)$/) { + if ($1 < 7) { + $no_ets = 1; + } + } + unless ($no_ets) { + &Authen::Krb5::init_ets(); + } &status('Accepted connection'); # ============================================================================= @@ -6961,7 +7207,18 @@ sub validate_user { } if ($howpwd ne 'nouser') { if($howpwd eq "internal") { # Encrypted is in local password file. - $validated = (crypt($password, $contentpwd) eq $contentpwd); + if (length($contentpwd) == 13) { + $validated = (crypt($password,$contentpwd) eq $contentpwd); + if ($validated) { + my $ncpass = &hash_passwd($domain,$password); + if (&rewrite_password_file($domain,$user,"$howpwd:$ncpass")) { + &update_passwd_history($user,$domain,$howpwd,'conversion'); + &logthis("Validated password hashed with bcrypt for $user:$domain"); + } + } + } else { + $validated = &check_internal_passwd($password,$contentpwd,$domain); + } } elsif ($howpwd eq "unix") { # User is a normal unix user. $contentpwd = (getpwnam($user))[1]; @@ -7029,6 +7286,39 @@ sub validate_user { return $validated; } +sub check_internal_passwd { + my ($plainpass,$stored,$domain) = @_; + my (undef,$method,@rest) = split(/!/,$stored); + if ($method eq "bcrypt") { + my $result = &hash_passwd($domain,$plainpass,@rest); + if ($result ne $stored) { + return 0; + } + # Upgrade to a larger number of rounds if necessary + my $defaultcost; + my %domconfig = + &Apache::lonnet::get_dom('configuration',['password'],$domain); + if (ref($domconfig{'password'}) eq 'HASH') { + $defaultcost = $domconfig{'password'}{'cost'}; + } + if (($defaultcost eq '') || ($defaultcost =~ /D/)) { + $defaultcost = 10; + } + return 1 unless($rest[0]<$defaultcost); + } + return 0; +} + +sub get_last_authchg { + my ($domain,$user) = @_; + my $lastmod; + my $logname = &propath($domain,$user).'/passwd.log'; + if (-e "$logname") { + $lastmod = (stat("$logname"))[9]; + } + return $lastmod; +} + sub krb4_authen { my ($password,$null,$user,$contentpwd) = @_; my $validated = 0; @@ -7344,26 +7634,26 @@ sub change_unix_password { sub make_passwd_file { - my ($uname,$udom,$umode,$npass,$passfilename)=@_; + my ($uname,$udom,$umode,$npass,$passfilename,$action)=@_; my $result="ok"; if ($umode eq 'krb4' or $umode eq 'krb5') { { my $pf = IO::File->new(">$passfilename"); if ($pf) { print $pf "$umode:$npass\n"; + &update_passwd_history($uname,$udom,$umode,$action); } else { $result = "pass_file_failed_error"; } } } elsif ($umode eq 'internal') { - my $salt=time; - $salt=substr($salt,6,2); - my $ncpass=crypt($npass,$salt); + my $ncpass = &hash_passwd($udom,$npass); { &Debug("Creating internal auth"); my $pf = IO::File->new(">$passfilename"); if($pf) { print $pf "internal:$ncpass\n"; + &update_passwd_history($uname,$udom,$umode,$action); } else { $result = "pass_file_failed_error"; } @@ -7373,6 +7663,7 @@ sub make_passwd_file { my $pf = IO::File->new(">$passfilename"); if($pf) { print $pf "localauth:$npass\n"; + &update_passwd_history($uname,$udom,$umode,$action); } else { $result = "pass_file_failed_error"; }