diff --git a/lib/Qpsmtpd/Auth.pm b/lib/Qpsmtpd/Auth.pm new file mode 100644 index 0000000..80bb9a4 --- /dev/null +++ b/lib/Qpsmtpd/Auth.pm @@ -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 + L + +for more details. + +=head1 USAGE + +This module is automatically loaded by Qpsmtpd::SMTP only if a plugin +providing one of the defined L 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 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 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 +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 + +=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 Qpsmtpd::SMTP::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; diff --git a/lib/Qpsmtpd/Plugin.pm b/lib/Qpsmtpd/Plugin.pm index 63da30b..8d4e216 100644 --- a/lib/Qpsmtpd/Plugin.pm +++ b/lib/Qpsmtpd/Plugin.pm @@ -3,6 +3,7 @@ use strict; 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 ); diff --git a/lib/Qpsmtpd/SMTP.pm b/lib/Qpsmtpd/SMTP.pm index 1b5bb58..f22b47b 100644 --- a/lib/Qpsmtpd/SMTP.pm +++ b/lib/Qpsmtpd/SMTP.pm @@ -10,6 +10,7 @@ use Qpsmtpd::Connection; use Qpsmtpd::Transaction; use Qpsmtpd::Plugin; use Qpsmtpd::Constants; +use Qpsmtpd::Auth; use Mail::Address (); use Mail::Header (); @@ -166,6 +167,25 @@ sub ehlo { ? @{ $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'} = ""; + } + $self->respond(250, $self->config("me") . " Hi " . $conn->remote_info . " [" . $conn->remote_ip ."]", "PIPELINING", @@ -415,6 +435,10 @@ sub data { 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 diff --git a/plugins/authdeny b/plugins/authdeny new file mode 100644 index 0000000..3b1abb6 --- /dev/null +++ b/plugins/authdeny @@ -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" ); +} + diff --git a/plugins/authnull b/plugins/authnull new file mode 100644 index 0000000..58bcf8e --- /dev/null +++ b/plugins/authnull @@ -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" ); +} + diff --git a/plugins/authsql b/plugins/authsql new file mode 100644 index 0000000..9fe9916 --- /dev/null +++ b/plugins/authsql @@ -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 + +=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(<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" ); + } +} +