#!/usr/bin/ruby -w
#
# Copyright (c) 2002 Mark Longair.
#
# $Id: ruby-sample,v 1.2 2002/03/20 13:26:58 mark Exp $
#
# This script is an example authenticator for tpop3d.  It has not been
# heavily tested; the intention is to provide a skeleton which you
# might want to use to write real Ruby-based authenticators.
#
# Authentication is based on the contents of /etc/tpop3d.users.  Lines
# of that file are dealt with in the following way:
#
#    - If the line is empty or entirely whitespace, it is ignored.
#
#    - If the first non whitespace character of the line is # then the
#      line is ignored.
#
#    - If the line is of the form <email address>:<password>, the two 
#      parts constitute a valid username / password pair.
#
# There's no APOP support at the moment, because Digest::MD5 is only
# distributed with Ruby 1.7, which isn't released yet.  In view of
# that, adding MD5 hashing just for this script doesn't seem
# worthwhile...
#
# For more details on auth_other authenticators, see tpop3d.conf(5)
#
#-----------------------------------------------------------------------

# The mail spools are accessed by the user and group specified here:

spool_user = "vmail"
spool_group = "vmail"

# You can alter the location of the password file with this:

$password_file = "/etc/tpop3d.users"

# mailbox_dir has /<domain>/user appended to it to find the mail spool
# of an authenticated user.  If you make mailbox_dir nil, then the
# normal mechanism (via configuration directives mailbox: and
# auth-other-mailbox:) is used to find the mailbox.

mailbox_dir = "/var/spool/vmail"
# mailbox_dir = nil

# There is no logging in this authenticator, but if you need to add
# some calls to log_message for debugging purposes, then the output
# will go to this log file...

$authenticator_log = "/tmp/sample-authenticator.log"

#-----------------------------------------------------------------------

# A usage message, to remind people what the program is for...

def usage
  
  print <<EOF
This script is an authenticator for tpop3d.  It is not a useful standalone
program.  See tpop3d.conf(5) for more information.
EOF
  
end

def log_message( message )

  open( $authenticator_log, "a" ) do |f|

    f.print "[#{Time.now}] #{message}\n"

  end

end

# Add some useful methods to the IO class for dealing with null
# separated and terminated key / value pairs:

class IO
  
  def put_zero
    putc 0
  end
  
  def write_pair( key, value )
    write key
    put_zero
    write value
    put_zero
  end

# Call the supplied iterator on each key / value pair in the file.
# Each pair is of the form <key>\0<value>\0 If <key> is empty
# (i.e. the first character read is null) the method returns
# immediately.  If at any point end of file is reached, the method
# raises EOFError

  def each_pair

    loop do

      key = ""
      value = ""
      
      # Get key...

      loop do
	c = getc

	if ! c
	  raise EOFError
	end

	if c == 0
	  if key.empty? # ... and we haven't read any characters yet
	    return
	  else
	    break # That's the end of the key.
	  end
	end

	key << c
	
      end
      
      # Get value...

      loop do
	
	c = getc
	
	if ! c
	  raise EOFError
	end
	
	if c == 0

	  # Then we've found a complete pair. (n.b. value is allowed
	  # to be empty...)

	  yield key, value
	  break
	end
	
	value << c
	
      end
      
    end
  end
  
end

# An exception we will need later...

class AuthFailure < RuntimeError
end

# Look up a user, domain and password combination in the password
# file.  Returns true if the combination is in the file, and nil
# otherwise.  Can throw exceptions if the password file is missing,
# etc. etc.

def authenticates_correctly? ( user, domain, password )

  open( $password_file, "r" ) do |f| 

    f.each do |line|
      
      line.chomp!
      
      next if line =~ '^\s*$'
      next if line =~ '^\s*#'
      
      if line =~ '^\s*([\w\.\-]+)@([\w\.\-]+)\s*:\s*([\w\.\-]+)\s*$'
	
	file_user = $1
	file_domain = $2
	file_password = $3
	
	if (user == file_user) and 
	    (domain == file_domain) and
	    (password == file_password)
	  
	  return TRUE
	  
	end
	
      else
	
	raise AuthFailure.new "A line in the users file is badly formed: `#{line}'"
	
      end
      
    end
    
    return nil
    
  end

end

#-----------------------------------------------------------------------

# The main body of the code...

# Largely pointless argument parsing; in here because you might
# plausibly want to add some options to allow testing...

require "getoptlong"

options = GetoptLong.new(

  [ "--help", "-h", GetoptLong::NO_ARGUMENT  ]

)

begin
  
  options.each do |opt, arg|
    
    case opt
    when "--help"
      usage
      exit
    end
    
  end
  
rescue
  
  print "Unknown command line option.\n"
  usage
  exit
  
end

loop do

  begin

    # Now parse the authentication request...

    method = nil
    timestamp = nil
    user = nil
    password = nil
    digest = nil
    clienthost = nil
    local_part = nil
    domain = nil

    $stdin.each_pair do |key, value|
  
      case key

      when "method"
	method = value
      when "timestamp"
	timestamp = value
      when "user"
	user = value
      when "pass"
	password = value
      when "digest"
	digest = value
      when "clienthost"
	clienthost = value
      when "local_part"
	local_part = value
      when "domain"
	domain = value
      else
	raise AuthFailure.new( "Unknown key `#{key}' in pair: (#{key},#{value})" )
      end

    end

    raise AuthFailure.new( "No method specified" ) unless method
    raise AuthFailure.new( "No user specified" ) unless user
    raise AuthFailure.new( "No local_part specified" ) unless user
    raise AuthFailure.new( "No domain specified" ) unless user

    if method == "APOP"

      unless timestamp
	raise AuthFailure.new( "APOP method was chosen, but no timestamp was provided." )
      end

      unless digest
	raise AuthFailure.new( "APOP method was chosen, but no digest was provided." )
      end

      unless password
	raise AuthFailure.new( "APOP method was chosen, but a password was provided." )
      end
    
      raise AuthFailure.new( "APOP is not currently supported by this authenticator." )

    elsif method == "PASS"

      if timestamp
	raise AuthFailure.new "PASS method was chosen, but a timestamp was provided"
      end

      if digest
	raise AuthFailure.new "PASS method was chosen, but a digest was provided"
      end

      if authenticates_correctly?( local_part, domain, password )

	$stdout.write_pair "result", "YES"
	$stdout.write_pair "logmsg", "Authentication succeeded for user #{user}"
	$stdout.write_pair "uid", spool_user
	$stdout.write_pair "gid", spool_group
	$stdout.write_pair "domain", domain
	if mailbox_dir
	  $stdout.write_pair "mailbox", "#{mailbox_dir}/#{domain}/#{local_part}"
	end
	$stdout.put_zero
	$stdout.flush

	next

      else

	raise AuthFailure.new "Authentication failed for user #{user}"

      end

    elsif method == "ONLOGIN"

      # We don't do anything with the ONLOGIN information, so return an
      # empty packet.

      $stdout.put_zero
      $stdout.flush

    else

      raise AuthFailure.new "Method was `#{method}' - should be `PASS', `APOP' or `ONLOGIN'"

    end

  rescue AuthFailure => message
    
    $stdout.write_pair "result", "NO"
    $stdout.write_pair "logmsg", message
    $stdout.put_zero
    $stdout.flush

  rescue EOFError

    # We hit EOF unexpectely, so just exit...
    exit
    
  rescue
    
    $stdout.write_pair "result", "NO"
    $stdout.write_pair "logmsg", "There was an error during authentication: " + $!
    $stdout.put_zero
    $stdout.flush

  end

end


syntax highlighted by Code2HTML, v. 0.9.1