On Jun 16, 2004, at 4:28 PM, Robert Spier wrote:
Here is a complete implementation of SMTP AUTH
Could you s/\t/ /g; and resend? Tabs don't work well in open environments.
Eww. 4 spaces per tab please!
Whatever makes it align properly.
There shouldn't be any tabs, and if there are any they should be 8.
I don't want to get into this argument. My personal copy of vi is set to produce Perl source formatting (indents of 4, tabs of 8). And in fact at least lib/Qpsmtpd/SMTP.pm contains tabs that *I* didn't create. However, the attached eliminates all tabs in any existing file I was patching and I ran all new files I created through perltidy with the defaults, just for the fun of it.
Nyahhh
John
=== plugins/authdeny
==================================================================
--- plugins/authdeny (/cvs/trunk) (revision 438)
+++ plugins/authdeny (/local/trunk) (revision 438)
@@ -0,0 +1,23 @@
+#!/usr/bin/perl
+#
+# This plugin doesn't actually check anything and will fail any
+# user no matter what they type. It is strictly a proof of concept for
+# the Qpsmtpd::Auth module. Don't run this in production!!!
+#
+
+sub register {
+ my ( $self, $qp ) = @_;
+ $self->register_hook( "auth", "authdeny" );
+}
+
+sub authdeny {
+ my ( $self, $transaction, $method, $user, $passClear, $passHash, $ticket ) =
+ @_;
+
+ # $DB::single = 1;
+
+ $self->log( LOGWARN, "Cannot authenticate using authdeny" );
+
+ return ( DECLINED, "$user is not free to abuse my relay" );
+}
+
=== plugins/authsql
==================================================================
--- plugins/authsql (/cvs/trunk) (revision 438)
+++ plugins/authsql (/local/trunk) (revision 438)
@@ -0,0 +1,116 @@
+#!/usr/bin/perl -w
+
+=head1 NAME
+
+authsql - Authenticate to vpopmail via MySQL
+
+=head1 DESCRIPTION
+
+This plugin authenticates vpopmail users directly against a standard
+vpopmail MySQL database. It makes the not-unreasonable assumption that
+both pw_name and pw_domain are lowercase only (qmail doesn't actually care).
+It also requires that vpopmail be built with the recommended
+'--enable-clear-passwd=y' option, because there is no other way to compare
+the password with CRAM-MD5.
+
+=head1 CONFIGURATION
+
+Decide which authentication methods you are willing to support and uncomment
+the lines in the register() sub. See the POD for Qspmtpd::Auth for more
+details on the ramifications of supporting various authentication methods.
+Then, change the database information at the top of the authsql() sub so that
+the module can access the database. This can be a read-only account since
+the plugin does not update the last accessed time (yet, see below).
+
+The remote user must login with a fully qualified e-mail address (i.e. both
+account name and domain), even if they don't normally need to. This is
+because the vpopmail table has a unique index on pw_name/pw_domain, and this
+module requires that only a single record be returned from the database.
+
+=head1 FUTURE DIRECTION
+
+The default MySQL configuration for vpopmail includes a table to log access,
+lastauth, which could conceivably be updated upon sucessful authentication.
+The addition of this feature is left as an exercise for someone who cares. ;)
+
+=head1 AUTHOR
+
+John Peacock <[EMAIL PROTECTED]>
+
+=head1 COPYRIGHT AND LICENSE
+
+Copyright (c) 2004 John Peacock
+
+This plugin is licensed under the same terms as the qpsmtpd package itself.
+Please see the LICENSE file included with qpsmtpd for details.
+
+
+=cut
+
+sub register {
+ my ( $self, $qp ) = @_;
+
+ $self->register_hook( "auth-plain", "authsql" );
+
+ # $self->register_hook("auth-cram-md5", "authsql");
+
+}
+
+sub authsql {
+ use DBI;
+ use Qpsmtpd::Constants;
+ use Digest::HMAC_MD5 qw(hmac_md5_hex);
+
+# $DB::single = 1;
+
+ my $connect = "dbi:mysql:dbname=vpopmail";
+ my $dbuser = "vpopmailuser";
+ my $dbpasswd = "**********";
+
+ my $dbh = DBI->connect( $connect, $dbuser, $dbpasswd );
+ $dbh->{ShowErrorStatement} = 1;
+
+ my ( $self, $transaction, $method, $user, $passClear, $passHash, $ticket ) =
+ @_;
+ my ( $pw_name, $pw_domain ) = split "@", lc($user);
+
+ unless ( defined $pw_domain ) {
+ return DECLINED;
+ }
+
+ my $sth = $dbh->prepare(<<SQL);
+select pw_clear_passwd
+from vpopmail
+where pw_name = ? and pw_domain = ?
+SQL
+
+ $sth->execute( $pw_name, $pw_domain );
+
+ my ($pw_clear_passwd) = $sth->fetchrow_array;
+
+ $sth->finish;
+ $dbh->disconnect;
+
+ unless ( defined $pw_clear_passwd ) {
+
+ # if this isn't defined then the user doesn't exist here
+ # or the administrator forgot to build with --enable-clear-passwd=y
+ return ( DECLINED, "authsql/$method" );
+ }
+
+ # at this point we can assume the user name matched
+ if (
+ ( defined $passClear
+ and $pw_clear_passwd eq $passClear ) or
+ ( defined $passHash
+ and $passHash eq hmac_md5_hex( $ticket, $pw_clear_passwd ) )
+ )
+ {
+
+ return ( OK, "authsql/$method" );
+ }
+ else {
+ return ( DENY, "authsql/$method - wrong password" );
+ }
+}
+
=== plugins/authnull
==================================================================
--- plugins/authnull (/cvs/trunk) (revision 438)
+++ plugins/authnull (/local/trunk) (revision 438)
@@ -0,0 +1,27 @@
+#!/usr/bin/perl
+#
+# This plugin doesn't actually check anything and will authenticate any
+# user no matter what they type. It is strictly a proof of concept for
+# the Qpsmtpd::Auth module. Don't run this in production!!!
+#
+
+sub register {
+ my ( $self, $qp ) = @_;
+
+ # $self->register_hook("auth-plain", "authnull");
+ # $self->register_hook("auth-login", "authnull");
+ # $self->register_hook("auth-cram-md5", "authnull");
+
+ $self->register_hook( "auth", "authnull" );
+}
+
+sub authnull {
+ my ( $self, $transaction, $method, $user, $passClear, $passHash, $ticket ) =
+ @_;
+
+ # $DB::single = 1;
+ $self->log( LOGERROR, "authenticating $user using $method" );
+
+ return ( OK, "$user is free to abuse my relay" );
+}
+
=== lib/Qpsmtpd/Plugin.pm
==================================================================
--- lib/Qpsmtpd/Plugin.pm (/cvs/trunk) (revision 438)
+++ lib/Qpsmtpd/Plugin.pm (/local/trunk) (revision 438)
@@ -3,6 +3,7 @@
my %hooks = map { $_ => 1 } qw(
config queue data_post quit rcpt mail ehlo helo
+ auth auth-plain auth-login auth-cram-md5
connect reset_transaction unrecognized_command disconnect
);
=== lib/Qpsmtpd/Auth.pm
==================================================================
--- lib/Qpsmtpd/Auth.pm (/cvs/trunk) (revision 438)
+++ lib/Qpsmtpd/Auth.pm (/local/trunk) (revision 438)
@@ -0,0 +1,347 @@
+#!/usr/bin/perl -w
+
+=head1 NAME
+
+Qpsmtpd::Auth - Authentication framework for qpsmtpd
+
+=head1 DESCRIPTION
+
+Provides support for SMTP AUTH within qpsmtpd transactions, see
+
+ L<http://www.faqs.org/rfcs/rfc2222.html>
+ L<http://www.faqs.org/rfcs/rfc2554.html>
+
+for more details.
+
+=head1 USAGE
+
+This module is automatically loaded by Qpsmtpd::SMTP only if a plugin
+providing one of the defined L<Auth Hooks> is loaded. The only
+time this can happen is if the client process employs the EHLO command to
+initiate the SMTP session. If the client uses HELO, the AUTH command is
+not available and this module isn't even loaded.
+
+=head2 Plugin Design
+
+An authentication plugin can bind to one or more auth hooks or bind to all
+of them at once. See L<Multiple Hook Behavior> for more details.
+
+All plugins must provide two functions:
+
+=over 4
+
+=item * register()
+
+This is the standard function which is called by qpsmtpd for any plugin
+listed in config/plugins. Typically, an auth plugin should register at
+least one hook, like this:
+
+
+ sub register {
+ my ($self, $qp) = @_;
+
+ $self->register_hook("auth", "authfunction");
+ }
+
+where in this case "auth" means this plugin expects to support any of
+the defined authentication methods.
+
+=item * authfunction()
+
+The plugin must provide an authentication function which is part of
+the register_hook call. That function will receive the following
+six parameters when called:
+
+=over 4
+
+=item $self
+
+A Qpsmtpd::Plugin object, which can be used, for example, to emit log
+entries or to send responses to the remote SMTP client.
+
+=item $transaction
+
+A Qpsmtpd::Transaction object which can be used to examine information
+about the current SMTP session like the remote IP address.
+
+=item $user
+
+Whatever the remote SMTP client sent to identify the user (may be bare
+name or fully qualified e-mail address).
+
+=item $clearPassword
+
+If the particular authentication method supports unencrypted passwords
+(currently PLAIN and LOGIN), which will be the plaintext password sent
+by the remote SMTP client.
+
+=item $hashPassword
+
+An encrypted form of the remote user's password, using the MD-5 algorithm
+(see also the $ticket parameter).
+
+=item $ticket
+
+This is the cryptographic challenge which was sent to the client as part
+of a CRAM-MD5 transaction. Since the MD-5 algorithm is one-way, the same
+$ticket value must be used on the backend to compare with the encrypted
+password sent in $hashPassword.
+
+=back
+
+=back
+
+Plugins should perform whatever checking they want and then return one
+of the following values (taken from Qpsmtpd::Constants):
+
+=over 4
+
+=item OK
+
+If the authentication has succeeded, the plugin can return this value and
+all subsequently registered hooks will be skipped.
+
+=item DECLINE
+
+If the authentication has failed, but any additional plugins should be run,
+this value will be returned. If none of the registered plugins succeed, the
+overall authentication will fail.
+
+=item DENY
+
+If the authentication has failed, and the plugin wishes this to short circuit
+any further testing, it should return this value. For example, a plugin could
+register the L<auth-plain> hook and immediately fail any connection which is
+not trusted (i.e. not in the same network).
+
+Another reason to return DENY over DECLINE would be if the user name matched
+an existing account but the password failed to match. This would make a
+dictionary-based attack much harder to accomplish. See the example authsql
+plugin for how this might be accomplished
+
+By returning DENY, no further authentication attempts will be made using the
+current method and data. A remote SMTP client is free to attempt a second
+auth method if the first one fails.
+
+=back
+
+Plugins may also return an optional message with the return code, e.g.
+
+ return (DENY, "If you forgot your password, contact your admin");
+
+and this will be appended to whatever response is sent to the remote SMTP
+client. There is no guarantee that the end user will see this information,
+though, since some prominent MTA's (produced by M$oft) I<helpfully>
+hide this information under the default configuration. This message will
+be logged locally, if appropriate based on the configured log level. If
+you are running multiple auth plugins, it is helpful to include at least
+the plugin name in the returned message (for debugging purposes).
+
+=head1 Auth Hooks
+
+The currently defined authentication methods are:
+
+=over 4
+
+=item * auth-plain
+
+Any plugin which registers an auth-plain hook will engage in a plaintext
+prompted negotiation. This is the least secure authentication method since
+both the user name and password are visible in plaintext. Most SMTP clients
+will preferentially chose a more secure method if it is advertised by the
+server.
+
+=item * auth-login
+
+A slightly more secure method where the username and password are Base-64
+encoded before sending. This is still an insecure method, since it is
+trivial to decode the Base-64 data. Again, it will not normally be chosen
+by SMTP clients unless a more secure method is not available (or if it fails).
+CURRENTLY NOT SUPPORTED DUE TO LACK OF DOCUMENTATION ON FUNCTIONALITY
+
+=item * auth-cram-md5
+
+A cryptographically secure authentication method which employs a one-way
+hashing function to transmit the secret information without significant
+risk between the client and server. The server provides a challenge key
+L<$ticket>, which the client uses to encrypt the user's password.
+Then both user name and password are concatenated and Base-64 encoded before
+transmission.
+
+This hook must normally have access to the user's plaintext password,
+since there is no way to extract that information from the transmitted data.
+Since the CRAM-MD5 scheme requires that the server send the challenge
+L<$ticket> before knowing what user is attempting to log in, there is no way
+to use any existing MD5-encrypted password (like is frequently used with MySQL).
+
+=item * auth
+
+A catch-all hook which requires that the plugin support all three preceeding
+authentication methods. Any plugins registering the auth hook will be run
+only after all other plugins registered for the specific authentication
+method which was requested. This allows you to move from more specific
+plugins to more general plugins (e.g. local accounts first vs replicated
+accounts with expensive network access later).
+
+=back
+
+=head2 Multiple Hook Behavior
+
+If more than one hook is registered for a given authentication method, then
+they will be tried in the order that they appear in the config/plugins file
+unless one of the plugins returns DENY, which will immediately cease all
+authentication attempts for this transaction.
+
+In addition, all plugins that are registered for a specific auth hook will
+be tried before any plugins which are registered for the general auth hook.
+
+=head1 AUTHOR
+
+John Peacock <[EMAIL PROTECTED]>
+
+=head1 COPYRIGHT AND LICENSE
+
+Copyright (c) 2004 John Peacock
+
+Portions based on original code by Ask Bjoern Hansen and Guillaume Filion
+
+This plugin is licensed under the same terms as the qpsmtpd package itself.
+Please see the LICENSE file included with qpsmtpd for details.
+
+=cut
+
+package Qpsmtpd::Auth;
+use Qpsmtpd::Constants;
+use MIME::Base64;
+
+sub auth {
+ my ( $self, $arg, @stuff ) = @_;
+
+ #they AUTH'd once already
+ return $self->respond( 503, "but you already said AUTH ..." )
+ if ( defined $self->{_auth}
+ and $self->{_auth} == OK );
+
+ return $self->{_auth} = Qpsmtpd::Auth::SASL( $self, $arg, @stuff );
+}
+
+sub SASL {
+
+ # $DB::single = 1;
+ my ( $session, $mechanism, $prekey ) = @_;
+ my ( $user, $passClear, $passHash, $ticket );
+ $mechanism = lc($mechanism);
+
+ if ( $mechanism eq "plain" ) {
+ if ($prekey) {
+ ( $passHash, $user, $passClear ) = split /\x0/,
+ decode_base64($prekey);
+ }
+ else {
+
+ $session->respond( 334, "Username:" );
+
+ # We read the username and password from STDIN
+ $user = <>;
+ chop($user);
+ chop($user);
+ if ( $user eq '*' ) {
+ $session->respond( 501, "Authentification canceled" );
+ return DECLINED;
+ }
+
+ $session->respond( 334, "Password:" );
+ $passClear = <>;
+ chop($passClear);
+ chop($passClear);
+ if ( $passClear eq '*' ) {
+ $session->respond( 501, "Authentification canceled" );
+ return DECLINED;
+ }
+ }
+
+ }
+
+ # elsif ($mechanism eq "login") {
+ # if ( $prekey ) {
+ # ($passHash, $user, $passClear) = split /\x0/, decode_base64($prekey);
+ # }
+ # else {
+ #
+ # $session->respond(334, encode_base64("User Name:"));
+ # $user = decode_base64(<>);
+ # #warn("Debug: User: '$user'");
+ # if ($user eq '*') {
+ # $session->respond(501, "Authentification canceled");
+ # return DECLINED;
+ # }
+ #
+ # $session->respond(334, encode_base64("Password:"));
+ # $passClear = <>;
+ # $passClear = decode_base64($passClear);
+ # #warn("Debug: Pass: '$pass'");
+ # if ($passClear eq '*') {
+ # $session->respond(501, "Authentification canceled");
+ # return DECLINED;
+ # }
+ # }
+ # }
+ elsif ( $mechanism eq "cram-md5" ) {
+
+ # rand() is not cryptographic, but we only need to generate a globally
+ # unique number. The rand() is there in case the user logs in more than
+ # once in the same second, of if the clock is skewed.
+ $ticket = sprintf( "<%x.%x\@" . $session->config("me") . ">",
+ rand(1000000), time() );
+
+ # We send the ticket encoded in Base64
+ $session->respond( 334, encode_base64( $ticket, "" ) );
+ my $line = <>;
+ chop($line);
+ chop($line);
+
+ if ( $line eq '*' ) {
+ $session->respond( 501, "Authentification canceled" );
+ return DECLINED;
+ }
+
+ ( $user, $passHash ) = split( ' ', decode_base64($line) );
+
+ }
+ else {
+ $session->respond( 500, "Unrecognized authentification mechanism" );
+ return DECLINED;
+ }
+
+ # try running the specific hooks first
+ my ( $rc, $msg ) =
+ $session->run_hooks( "auth-$mechanism", $mechanism, $user, $passClear,
+ $passHash, $ticket );
+
+ # try running the polymorphous hooks next
+ if ( $rc == DECLINED ) {
+ ( $rc, $msg ) =
+ $session->run_hooks( "auth", $mechanism, $user, $passClear, $passHash,
+ $ticket );
+ }
+
+ if ( $rc == OK ) {
+ $msg = "Authentication successful" .
+ ( defined $msg ? " - " . $msg : "" );
+ $session->respond( 235, $msg );
+ $ENV{RELAYCLIENT} = 1;
+ $session->log( LOGINFO, $msg );
+ return OK;
+ }
+ else {
+ $msg = "Authentication failed" .
+ ( defined $msg ? " - " . $msg : "" );
+ $session->respond( 535, $msg );
+ $session->log( LOGERROR, $msg );
+ return DENY;
+ }
+}
+
+# tag: qpsmtpd plugin that sets RELAYCLIENT when the user authentifies
+
+1;
=== lib/Qpsmtpd/SMTP.pm
==================================================================
--- lib/Qpsmtpd/SMTP.pm (/cvs/trunk) (revision 438)
+++ lib/Qpsmtpd/SMTP.pm (/local/trunk) (revision 438)
@@ -166,6 +166,27 @@
? @{ $self->transaction->notes('capabilities') }
: ();
+ # Check for possible AUTH mechanisms
+ my %auth_mechanisms;
+HOOK: foreach my $hook ( keys %{$self->{_hooks}} ) {
+ if ( $hook =~ m/^auth-?(.+)?$/ ) {
+ if ( defined $1 ) {
+ $auth_mechanisms{uc($1)} = 1;
+ }
+ else { # at least one polymorphous auth provider
+ %auth_mechanisms = map {$_,1} qw(PLAIN CRAM-MD5);
+ last HOOK;
+ }
+ }
+ }
+
+ if ( %auth_mechanisms ) {
+ push @capabilities, 'AUTH '.join(" ",keys(%auth_mechanisms));
+ $self->{_commands}->{'auth'} = "";
+ require Qpsmtpd::Auth;
+ *auth = \&Qpsmtpd::Auth::auth;
+ }
+
$self->respond(250,
$self->config("me") . " Hi " . $conn->remote_info . " [" .
$conn->remote_ip ."]",
"PIPELINING",
@@ -415,6 +436,10 @@
my $smtp = $self->connection->hello eq "ehlo" ? "ESMTP" : "SMTP";
+ # only true if client authenticated
+ if ( defined $self->{_auth} and $self->{_auth} == OK ) {
+ $header->add("X-Qpsmtpd-Auth","True");
+ }
$header->add("Received", "from ".$self->connection->remote_info
." (HELO ".$self->connection->hello_host . ")
(".$self->connection->remote_ip
