Web::Passwd - Web-based htpasswd Management


Web-Passwd documentation Contained in the Web-Passwd distribution.

Index


Code Index:

NAME

Top

Web::Passwd - Web-based htpasswd Management

VERSION

Top

Version 0.03

SYNOPSIS

Top

Web::Passwd is a web-based utility for managing Apache htpasswd files. It uses the CGI::Application (CGI::Application) framework, so functionality is encapsulated in the module and very little code is required to create an instance:

    use Web::Passwd;

    my $webapp = Web::Passwd->new();
    $webapp->run();

That's it. Drop that script in a web-accessible cgi directory and give it execute permissions, and (assuming a default config file is found), you're good to go. If you'd rather explicity define a configuration file to use, you can pass it through an extra parameter:

    my $webapp = Web::Passwd->new( PARAMS => { config => '/home/evan/custom_webpasswd.conf' } );

CONFIGURATION

Top

If not explicitly provided, a configuration file will be searched for in the following locations (in order). If a valid configuration file is not found, the script will die with errors.

  ./webpasswd.conf    (the current directory)
  ../webpasswd.conf   (the parent directory)
  /etc/webpasswd.conf

The configuration file can be used to specify a directory of templates in the HTML::Template format. If no templates are found, default templates are used (see the /example/templates directory of the distribution).

  tmpl_path = /var/www/cgi-bin/webpasswd/

The htpasswd command can also be specified. If no htpasswd command is provided, the default is used. Note that, on some systems, you must specify the absolute path to the htpasswd binary.

  htpasswd_command = htpasswd

The configuration file can specify whether to use the GET (data encoded into the URL) or POST (data encoded into the message body) form request method. Defaults to using the generally more secure POST.

  form_method = POST

The configuration file should also contain a section for each htpasswd file it will be used to maintain, using the following format:

  [Descriptive Name]
  path = /system/path/to/passwdfile
  algorithm = {crypt|md5|sha|plain}

TECHNICAL NOTE: The default algorithm Apache uses is crypt under Linux, and MD5 under Windows.

PITFALL: Enclosing values in quotes within the config file does not have the expected effect! It simply includes the literal quote characters in the config value.

SECURITY

Top

It is *imperitive* that the Web::Passwd instance script itself be htpasswd protected, as it includes no access control mechanism.

Understand that putting the ability to manage htpasswd files via a web-based utility carries an inherent security risk, in that anyone who gains access to the utility is potentially given access to any of the managed htpasswd-protected resources.

Any htpasswd files to be managed with this utility MUST be owned by whatever user apache runs as. Usually, this is 'apache' or 'nobody'.

COMPATABILITY

Top

This was written expressly for Apache webserver 1.3 or higher running under Linux. However, there is nothing as far as I am aware that would prevent execution on a higher version of Apache, or on Apache under Windows.

DEPENDENCIES

Top

A Perl version of 5.6.1 or higher is recommended, and the following modules are required:

  CGI::Application
  Config::Tiny
  HTML::Template

AUTHOR

Top

Evan Kaufman, <evank at cpan.org>

SUPPORT

Top

You can find documentation for this module with the perldoc command.

    perldoc Web::Passwd

ACKNOWLEDGEMENTS

Top

Written for BCD Music Group.

COPYRIGHT & LICENSE

Top


Web-Passwd documentation Contained in the Web-Passwd distribution.
# $Date: 2007-02-07 15:54:57 -0600 (Wed, 07 Feb 2007) $
# $Revision: 19 $

package Web::Passwd;
use base 'CGI::Application';

use strict;
use warnings;
use CGI::Carp qw/fatalsToBrowser warningsToBrowser/;
use Config::Tiny;

# set up application framework, including mode parameter and dispatch table
sub setup {
  my $self = shift;
  $self->start_mode('index');
  $self->mode_param('mode');
  $self->run_modes(
    'index' => 'display_index',
    'view' => 'display_htfile',
    'adduser' => 'user_op',
    'changepw' => 'user_op',
    'deluser' => 'user_op',
  );
  
  # return just to be tidy
  return;
}

# perform final actions after all other processing
sub teardown {
  # trigger the printing of any warnings to browser as HTML comments
  warningsToBrowser(1);
  
  # return just to be tidy
  return;
}

# display index page with no actions to be performed
sub display_index {
  my $self = shift;
  
  # load configuration as a hash ref
  $self->param('act_config', load_config( $self->param('config') ) );
  
  # load template file with HTML::Template
  my $tmpl_obj;
  if(-e $self->param('act_config')->{'_'}->{'tmpl_path'} . 'index.tmpl' ) {
    $tmpl_obj = $self->load_tmpl( $self->param('act_config')->{'_'}->{'tmpl_path'} . 'index.tmpl' );
  }
  else {
    $tmpl_obj = $self->load_tmpl( \$Web::Passwd::INDEX_TEMPLATE );
  }
  
  # get list of htpasswd config blocks
  my @htfiles;
  for my $key (keys %{$self->param('act_config')}) {
    if($key ne '_') {
      push(@htfiles, {'TITLE' => $key});
    }
  }
  
  # pass template parameters
  $tmpl_obj->param(
    'HTFILES' => \@htfiles,
    'IS_WARNINGS' => $#CGI::Carp::WARNINGS + 1,
    'FORM_METHOD' => $self->param('act_config')->{'_'}->{'form_method'},
  );
  
  # return template-generated output
  return $tmpl_obj->output;
}

# display page to view/manage a specific htpasswd file
sub display_htfile {
  my $self = shift;
  
  # get CGI query object
  my $query_obj = $self->query();
  
  # load configuration as a hash ref
  $self->param('act_config', load_config( $self->param('config') ) );
  
  # load template file with HTML::Template
  my $tmpl_obj;
  if(-e $self->param('act_config')->{'_'}->{'tmpl_path'} . 'view.tmpl' ) {
    $tmpl_obj = $self->load_tmpl( $self->param('act_config')->{'_'}->{'tmpl_path'} . 'view.tmpl' );
  }
  else {
    $tmpl_obj = $self->load_tmpl( \$Web::Passwd::VIEW_TEMPLATE );
  }
  
  # get user list and format for template processing
  my @users;
  for my $user ( htfile_listusers( $self->param('act_config')->{ $query_obj->param('htfile') }->{'path'} ) ) {
    push(@users, {'USERNAME' => $user});
  }
  
  # pass template parameters
  $tmpl_obj->param(
    'HTFILENAME' => $query_obj->param('htfile'),
    'USER_LOOP' => \@users,
    'IS_WARNINGS' => $#CGI::Carp::WARNINGS + 1,
    'FORM_METHOD' => $self->param('act_config')->{'_'}->{'form_method'},
  );
  
  # return template-generated output
  return $tmpl_obj->output;
}

# display the status of an operation
sub display_status {
  my($self, $mode, $htfile, @users) = @_;
  
  # load template file with HTML::Template
  my $tmpl_obj;
  if(-d $self->param('act_config')->{'_'}->{'tmpl_path'}.'status.tmpl' ) {
    $tmpl_obj = $self->load_tmpl( $self->param('act_config')->{'_'}->{'tmpl_path'}.'status.tmpl' );
  }
  else {
    $tmpl_obj = $self->load_tmpl( \$Web::Passwd::STATUS_TEMPLATE );
  }
  
  # build action status header
  my $act_stat = ($mode eq 'adduser')  ? "Addition Successful"
               : ($mode eq 'changepw') ? "Modification Successful"
               : ($mode eq 'deluser')  ? "Deletion Successful"
               : "Action Successful";
  
  # build action message
  my $act_msg = ($mode eq 'adduser')                 ? sprintf("User '%s' added.", $users[0])
              : ($mode eq 'changepw')                ? sprintf("Password changed for user '%s'.", $users[0])
              : ($mode eq 'deluser' && $#users == 0) ? sprintf("User '%s' deleted.", $users[0])
              : ($mode eq 'deluser' && $#users > 0)  ? sprintf("Users '%s' deleted.", join "','", @users)
              : 'Unknown operation...Check error logs.';
  
  # pass template parameters
  $tmpl_obj->param(
    'ACTION_STATUS' => $act_stat,
    'ACTION_MESSAGE' => $act_msg,
    'HTFILENAME' => $htfile,
    'IS_WARNINGS' => $#CGI::Carp::WARNINGS + 1,
    'FORM_METHOD' => $self->param('act_config')->{'_'}->{'form_method'},
  );
  
  # return template-generated output
  return $tmpl_obj->output;
}

# perform an operation
sub user_op {
  my $self = shift;
  
  # get CGI query object
  my $query_obj = $self->query();
  
  # create lexical copy of mode
  my $user_mode = lc $query_obj->param('mode');
  
  # if adding or modifying user, check that passwords match
  if($user_mode eq 'adduser' || $user_mode eq 'changepw') {
    if($query_obj->param('pass') ne $query_obj->param('pass_confirm')) {
      die 'passwords did not match';
    }
  }
  
  # load configuration as a hash ref
  $self->param('act_config', load_config( $self->param('config') ) );
  
  # add new or change existing user/pass
  my @users = $query_obj->param('user');
  if($user_mode eq 'adduser' || $user_mode eq 'changepw') {
    htfile_moduser(
      $self->param('act_config')->{'_'}->{'htpasswd_command'},
      $self->param('act_config')->{ $query_obj->param('htfile') }->{'path'},
      $users[0],
      $query_obj->param('pass'),
      $self->param('act_config')->{ $query_obj->param('htfile') }->{'algorithm'}
    );
  }
  # or delete existing user(s)
  elsif($user_mode eq 'deluser') {
    for my $user (@users) {
      htfile_deluser(
        $self->param('act_config')->{'_'}->{'htpasswd_command'},
        $self->param('act_config')->{ $query_obj->param('htfile') }->{'path'},
        $user
      );
    }
  }
  
  # generate operation status page from template
  my $tmpl_output = display_status( $self, $user_mode, $query_obj->param('htfile'), @users );
  
  # return template-generated output
  return $tmpl_output;
}

# load the app configuration, returning a hash reference
sub load_config {
  my $conf_file = shift;
  
  # if custom configuration not provided, search for a default config file
  if(!defined $conf_file) {
    # expected filename of the config
    my $CONFIG_FILENAME = 'webpasswd.conf';
    
    # search for config file in current, parent, and /etc directories
    $conf_file = (-e "./$CONFIG_FILENAME")    ? "./$CONFIG_FILENAME"
               : (-e "../$CONFIG_FILENAME")   ? "../$CONFIG_FILENAME"
               : (-e "/etc/$CONFIG_FILENAME") ? "/etc/$CONFIG_FILENAME"
               : undef;
    
    # die if config file was not found
    if(! defined $conf_file) {
      die "configuration file not found"
    }
  }
  
  # load configuration, or die on error
  my $config_obj = Config::Tiny->read($conf_file) or die Config::Tiny::errstr();
  
  # if no htpasswd command supplied, default to 'htpasswd'
  if(!exists $config_obj->{'_'}->{'htpasswd_command'}) {
    $config_obj->{'_'}->{'htpasswd_command'} = 'htpasswd';
    warn "missing 'htpasswd_command' configuration option, using default of 'htpasswd'";
  }
  
  # if template path doesnt exist, try root path
  if(!exists $config_obj->{'_'}->{'tmpl_path'}) {
    $config_obj->{'_'}->{'tmpl_path'} = '/';
  }
  
  # if template path doesnt end with a fore-slash, append one
  if(substr($config_obj->{'_'}->{'tmpl_path'}, -1) ne '/') {
    $config_obj->{'_'}->{'tmpl_path'} .= '/';
  }
  
  # if form method not provided or not GET, default to POST
  if(!exists $config_obj->{'_'}->{'form_method'} || uc($config_obj->{'_'}->{'form_method'}) ne 'GET') {
    $config_obj->{'_'}->{'form_method'} = 'POST';
  }
  
  # ensure valid attributes for each configured section
  for my $section (keys %{$config_obj}) {
    # if not root section
    if($section ne '_') {
      # if missing path, remove from active config and issue warning
      my $file_path = $config_obj->{$section}->{'path'};
      if(!defined $file_path || $file_path =~ m/\A\s*\z/ || ! -e $file_path) {
        delete $config_obj->{$section};
        warn "invalid path for config block [$section]";
        next;
      }
      
      # if missing or invalid algorithm, default to 'crypt' and issue warning
      my $pass_alg = lc $config_obj->{$section}->{'algorithm'};
      if($pass_alg !~ m/\s*(?:crypt|md5|sha|plain)\s*/i) {
        warn "invalid password algorithm '$pass_alg' for config block [$section], using 'crypt' instead";
        $pass_alg = 'crypt';
      }
      $config_obj->{$section}->{'algorithm'} = $pass_alg;
    }
  }
  
  # return config
  return $config_obj;
}

# list the users in a given htfile
sub htfile_listusers {
  my $htfile = shift;

  # declare array to hold usernames
  my @users;

  # read htfile in as text
  open(my $HTFILE, '<', $htfile) or die $!;
  my @file = <$HTFILE>;
  close($HTFILE);

  # parse off usernames, add to array
  for my $line (@file) {
    my($user) = split /:/, $line, 2;
    push @users, $user;
  }

  # return username array
  return @users;
}

# add/modify a user in a given htfile
sub htfile_moduser {
  my($htcmd,$htfile,$user,$pass,$algorithm) = @_;
  
  # translate algorithm to appropriate flag
  $algorithm = ($algorithm eq 'plain') ? 'p'
             : ($algorithm eq 'md5')   ? 'm'
             : ($algorithm eq 'sha')   ? 's'
             : ($algorithm eq 'crypt') ? 'd'
             : '';
  
  # assemble command
  my $command = sprintf "%s -b%s %s %s %s",
                $htcmd,
                $algorithm,
                $htfile,
                $user,
                $pass;
  
  # execute, or die on unsuccessful return value
  if(system($command) != 0) {
    die "htpasswd command failed: $?";
  }
  
  # return just to be tidy
  return;
}

# delete a user in a given htfile
sub htfile_deluser {
  my($htcmd,$htfile,$user) = @_;
  
  # assemble command
  my $command = sprintf "%s -D %s %s",
                $htcmd,
                $htfile,
                $user;
  
  # try to make htpasswd do the work with the -D flag (apache 2.x)
  if(system($command) == 0) {
    return 1;
  }
  # otherwise, do the damn thing by hand (apache 1.3.x)
  else {
    # read in htfile contents
    open(my $HTIN, '<', $htfile) or die $!;
    my @file = <$HTIN>;
    close($HTIN);
    
    # search for, and remove, offending user line
    my $deleted = 0;
    for my $ln (0..$#file) {
      no warnings; # bypass a puzzling warning of uninitialized value in m//
      if($file[$ln] =~ m/\A$user\:/) {
        splice @file, $ln, 1;
        $deleted++;
      }
    }
    
    # write changes back to htfile
    open(my $HTOUT, '>', $htfile) or die $!;
    print {$HTOUT} @file;
    close($HTOUT);
    
    # set error string
    $! = ($deleted) ? undef : "remove of line '$user' failed";
    
    return $deleted;
  }
}


$Web::Passwd::INDEX_TEMPLATE = <<'HTML_CODE';
<html>

<head>
<title>Web Htpasswd Management</title>
<style>
body { font-family: Arial }
table { font-family: Arial }
</style>
</head>

<body>

<center>

<h1>Web Htpasswd Management</h1>

<hr>

<p>
<form method="<TMPL_VAR NAME="FORM_METHOD" DEFAULT="POST">">
<input type="hidden" name="mode" value="view">

Select Htpasswd File:&nbsp;&nbsp;

<select name="htfile">
<TMPL_LOOP NAME="HTFILES">
  <option value="<TMPL_VAR NAME="TITLE">"><TMPL_VAR NAME="TITLE"></option>
</TMPL_LOOP>
</select>

<input type="submit" value="Manage File">

</form>
</p>

<TMPL_IF NAME="IS_WARNINGS">
<hr>
<span style="color: red">Warnings were encountered...Please check error log.</span>
</TMPL_IF>

</center>

</body>

</html>
<!--
<TMPL_VAR NAME="DEBUG_DUMP" DEFAULT="">
-->
HTML_CODE

$Web::Passwd::VIEW_TEMPLATE = <<'HTML_CODE';
<html>

<head>
<title>Web Htpasswd Management</title>
<style>
body { font-family: Arial }
table { font-family: Arial }
</style>
</head>

<body>

<center>

<h1>Managing Htpasswd File:</h1>
<h3><TMPL_VAR NAME="HTFILENAME"></h3>

<hr>

<p>
<form method="<TMPL_VAR NAME="FORM_METHOD" DEFAULT="POST">">
<input type="hidden" name="htfile" value="<TMPL_VAR NAME="HTFILENAME">">
<input type="hidden" name="mode" value="adduser">
<fieldset style="width: 400">
  <legend style="font-weight: bold">Add User</legend>
  <table width="350">
    <tr>
      <td width="150">Username:&nbsp;&nbsp;</td>
      <td width="200"><input type="text" name="user" size=255 style="width: 200"></td>
    </tr>
    <tr>
      <td>Password:&nbsp;&nbsp;</td>
      <td><input type="password" name="pass" size=255 style="width: 200"></td>
    </tr>
    <tr>
      <td>Retype Password:&nbsp;&nbsp;</td>
      <td><input type="password" name="pass_confirm" size=255 style="width: 200"></td>
    </tr>
    <tr>
      <td></td>
      <td><input type="submit" value="Add User"></td>
    </tr>
  </table>
</fieldset>
</form>
</p>

<p>
<form method="<TMPL_VAR NAME="FORM_METHOD" DEFAULT="POST">">
<input type="hidden" name="htfile" value="<TMPL_VAR NAME="HTFILENAME">">
<input type="hidden" name="mode" value="changepw">
<fieldset style="width: 400">
  <legend style="font-weight: bold">Modify User</legend>
  <table width="350">
    <tr>
      <td width="150">Username:&nbsp;&nbsp;</td>
      <td width="200">
        <select name="user" style="width: 200">
<TMPL_LOOP NAME="USER_LOOP">
          <option value="<TMPL_VAR NAME="USERNAME">"><TMPL_VAR NAME="USERNAME"></option>
</TMPL_LOOP>
        </select>
      </td>
    </tr>
    <tr>
      <td>Password:&nbsp;&nbsp;</td>
      <td><input type="password" name="pass" size=255 style="width: 200"></td>
    </tr>
    <tr>
      <td>Retype Password:&nbsp;&nbsp;</td>
      <td><input type="password" name="pass_confirm" size=255 style="width: 200"></td>
    </tr>
    <tr>
      <td></td>
      <td><input type="submit" value="Change Password"></td>
    </tr>
  </table>
</fieldset>
</form>
</p>

<p>
<form method="<TMPL_VAR NAME="FORM_METHOD" DEFAULT="POST">">
<input type="hidden" name="htfile" value="<TMPL_VAR NAME="HTFILENAME">">
<input type="hidden" name="mode" value="deluser">
<fieldset style="width: 400">
  <legend style="font-weight: bold">Delete Users</legend>
  <table width="350">
    <tr>
      <td width="150" valign="top">Usernames:&nbsp;&nbsp;</td>
      <td width="200">
        <select name="user" size="5" multiple  style="width: 200px">
<TMPL_LOOP NAME="USER_LOOP">
          <option value="<TMPL_VAR NAME="USERNAME">"><TMPL_VAR NAME="USERNAME"></option>
</TMPL_LOOP>
        </select>
      </td>
    </tr>
    <tr>
      <td></td>
      <td><input type="submit" value="Delete User"></td>
    </tr>
  </table>
</fieldset>
</form>
</p>

<hr>
<a href="?">Back to Main</a>
<TMPL_IF NAME="IS_WARNINGS">
<hr>
<span style="color: red">Warnings were encountered...Please check error log.</span>
</TMPL_IF>

</center>

</body>

</html>
<!--
<TMPL_VAR NAME="DEBUG_DUMP" DEFAULT="">
-->
HTML_CODE

$Web::Passwd::STATUS_TEMPLATE = <<'HTML_CODE';
<html>

<head>
<title>Web Htpasswd Management</title>
<style>
body { font-family: Arial }
table { font-family: Arial }
</style>
</head>

<body>

<center>

<h1><TMPL_VAR NAME="ACTION_STATUS"></h1>

<p>
<form method="<TMPL_VAR NAME="FORM_METHOD" DEFAULT="POST">">
<TMPL_VAR NAME="ACTION_MESSAGE"><br>&nbsp;<br>
<input type="hidden" name="htfile" value="<TMPL_VAR NAME="HTFILENAME">">
<input type="hidden" name="mode" value="view">
<input type="submit" value="Go Back">
</form>
</p>

<TMPL_IF NAME="IS_WARNINGS">
<hr>
<span style="color: red">Warnings were encountered...Please check error log.</span>
</TMPL_IF>

</center>

</body>

</html>
<!--
<TMPL_VAR NAME="DEBUG_DUMP" DEFAULT="">
-->
HTML_CODE

our $VERSION = "0.03";

# we're a good little module
1;