[Date Prev][Date Next][Thread Prev][Thread Next][Date Index][Thread Index]

[pysieved] SASL revamp



OK, so I did get ambitious (actually, I couldn't
get any sleep :P) and I decided to take a stab
at revamping the SASL code as a pass-through
to Dovecot.

Here's the patch for the monstruosity I came up
with. It was successfully tested with CRAM-MD5
as well as PLAIN (I can't figure our DIGEST-MD5
but I think it's only a configuration issue).

The changes include :
. minor cosmetic logging changes
. the AUTHENTICATE code supports continuation
  via do_sasl_first() and do_sasl_next()
. do_sasl_first() is defined by default to be
  a wrapper around the auth() method
. do_sasl_next() should be implemented by
  plugins that want to support more complex
  SASL exchanges
. acceptable mechanisms can be overridden by
  modifying the plugin's mechanisms() method
. reorganization of the dovecot code to take
  advantage of the above framework

Thanks to the Postfix-SASL programmers whose
code I used for inspiration.

PS : line 190 of managesieve.py before patching
     (if self.debug:) causes an error ('debug'
     is not defined) if you hit that piece of
     code but I didn't take the time to figure
     that one out.

-- 
Philippe Levan - Frontier/epix Systems

diff -cr pysieved/managesieve.py pysieved.new/managesieve.py
*** pysieved/managesieve.py	Fri Jul 27 16:08:09 2007
--- pysieved.new/managesieve.py	Sun Jul 29 11:04:43 2007
***************
*** 90,95 ****
--- 90,96 ----
              elif len(a) > 200 or '"' in a:
                  out.append('{%d+}' % len(a))
                  flush(out)
+                 self.log(3, 'S: %r' % a)
                  self.write(a)
              else:
                  out.append('"%s"' % a)
***************
*** 140,146 ****
              if pos > -1:
                  self.buf = out[pos+2:]
                  s = out[:pos]
!                 self.log(3, 'C: %r' % s)
                  return s
              r = self.read(1024)
              if not r:
--- 141,147 ----
              if pos > -1:
                  self.buf = out[pos+2:]
                  s = out[:pos]
!                 self.log(3, 'C: %r' % (s + '\r\n'))
                  return s
              r = self.read(1024)
              if not r:
***************
*** 252,267 ****
  
          if not self.tls:
              return self.no(code='ENCRYPT-NEEDED')
!         if mechanism.lower() != 'plain':
!             return self.no(reason='Unsupported authentication mechanism')
!         if len(args) != 1:
!             return self.no(reason='Must provide authentication credentials')
! 
!         _, user, passwd = args[0].decode('base64').split('\0', 2)
!         if not self.authenticate(user, passwd):
!             return self.no(reason='Bad username or password')
!         home = self.get_homedir(user)
          self.storage = self.new_storage(home)
          return self.ok()
  
  
--- 253,294 ----
  
          if not self.tls:
              return self.no(code='ENCRYPT-NEEDED')
! 
!         ret = self.do_sasl_first(mechanism, *args)
! 
!         while ret['result'] == 'CONT':
!             line = ('{%d+}\r\n' % len(ret['msg']))
!             self.log(3, 'S: %r' % line)
!             self.write(line)
!             line = ('%s\r\n' % ret['msg'])
!             self.log(3, 'S: %r' % line)
!             self.write(line)
!             s = self.readline()
!             if len(s) > 3 and s[0] == '{' and s[-2:] == '+}':
!                 try:
!                     n = int(o[1:-2])
!                     s = self.bread(n)
!                 except ValueError:
!                     pass
!             elif len(s) > 1 and s[0] == '"' and s[-1] == '"':
!                 s = s[1:-1]
! 
!             ret = self.do_sasl_next(s)
! 
!         if ret['result'] == 'NO':
!             return self.no(reason=ret['msg'])
!         elif ret['result'] == 'BYE':
!             self.bye(reason=ret['msg'])
!             raise Hangup()
! 
!         home = self.get_homedir(ret['username'])
! 
!         if not home:
!             self.bye(reason='Server Error')
!             raise Hangup()
! 
          self.storage = self.new_storage(home)
+ 
          return self.ok()
  
  
***************
*** 293,299 ****
  
          self.send('IMPLEMENTATION', version)
          if self.tls:
!             self.send('SASL', 'PLAIN')
          else:
              self.send('SASL')
          self.send('SIEVE', self.capabilities)
--- 320,326 ----
  
          self.send('IMPLEMENTATION', version)
          if self.tls:
!             self.send('SASL', ' '.join(self.list_mech()))
          else:
              self.send('SASL')
          self.send('SIEVE', self.capabilities)
***************
*** 363,370 ****
              s = self.storage[name]
          except KeyError:
              return self.no(reason='No script by that name')
!         self.write('{%d+}\r\n' % len(s))
!         self.write(s + '\r\n')
          return self.ok()
  
  
--- 390,401 ----
              s = self.storage[name]
          except KeyError:
              return self.no(reason='No script by that name')
!         line = ('{%d+}\r\n' % len(s))
!         self.log(3, 'S: %r' % line)
!         self.write(line)
!         line = ('%s\r\n' % s)
!         self.log(3, 'S: %r' % line)
!         self.write(line)
          return self.ok()
  
  
diff -cr pysieved/plugins/__init__.py pysieved.new/plugins/__init__.py
*** pysieved/plugins/__init__.py	Mon Jul 23 18:24:17 2007
--- pysieved.new/plugins/__init__.py	Sun Jul 29 07:37:13 2007
***************
*** 21,26 ****
--- 21,27 ----
  class new:
      # Override this
      capabilities = 'fileinto reject'
+     mechs = [ 'PLAIN' ]
  
      def __init__(self, log_func, config):
          self.log = log_func
***************
*** 30,35 ****
--- 31,77 ----
          # Override this
          pass
  
+     def mechanisms(self):
+         return self.mechs
+ 
+     def do_sasl_first(self, mechanism, *args):
+         """Handle the initial part of the SASL dialog
+ 
+         This is just a wrapper around the auth() method
+         if the requested mechanism is PLAIN.
+ 
+         Returns a dictionary with the following elements :
+         { 'result': 'OK' (pass), 'NO' (fail), 'BYE' (fatal) or 'CONT' (more),
+           'msg': error string (fail or fatal) or server response (more),
+           'username': authorized username (pass) }
+ 
+         """
+ 
+         if mechanism.upper() not in [ mech.upper() for mech in self.mechs ]:
+             return {'result': 'NO', 'msg': 'Unsupported authentication mechanism'}
+         if len(args) != 1:
+             return {'result': 'NO', 'msg': 'Must provide authentication credentials'}
+ 
+         _, user, passwd = args[0].decode('base64').split('\0', 2)
+         params = {'username': user, 'password': passwd}
+         if self.auth(params):
+             return {'result': 'OK', 'username': user}
+         else:
+             return {'result': 'NO', 'msg': 'Bad username or password'}
+ 
+ 
+     def do_sasl_next(self, b64_string):
+         """Handle the continuation of the SASL dialog
+ 
+         Returns a dictionary with the following elements :
+         { 'result': 'OK' (pass), 'NO' (fail), 'BYE' (fatal) or 'CONT' (more),
+           'msg': error string (fail or fatal) or server response (more),
+           'username': authorized username (pass) }
+ 
+         """
+ 
+         raise NotImplementedError()
+ 
  
      def auth(self, params):
          """Authenticate a user.
diff -cr pysieved/plugins/dovecot.py pysieved.new/plugins/dovecot.py
*** pysieved/plugins/dovecot.py	Mon Jul 23 18:24:17 2007
--- pysieved.new/plugins/dovecot.py	Sun Jul 29 11:13:28 2007
***************
*** 174,179 ****
--- 174,181 ----
      capabilities = ('fileinto reject envelope vacation imapflags '
                      'notify subaddress relational '
                      'comparator-i;ascii-numeric')
+     mechs = []
+     version = [ 1, 0 ]
  
      def init(self, config):
          self.mux = config.get('Dovecot', 'mux', False)
***************
*** 191,251 ****
          if self.uid >= 0:
              os.setuid(self.uid)
  
  
-     def auth(self, params):
          # We can do this only if a MUX socket was specified
!         if self.mux:
!             self.auth_sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
!             self.auth_sock.connect(self.mux)
!             handshake_string = self.auth_sock.recv(1024)
!             self.auth_sock.sendall('VERSION\t1\t0\nCPID\t%d\n' % os.getpid())
!         else:
              raise ValueError('No MUX socket was specified')
  
!         # Since this socket will be used only once, use a hardcoded request ID
!         auth_string = ('AUTH\t%d\tPLAIN\tservice=%s\tresp=%s' %
!                        (1,
!                         self.service,
!                         base64.encodestring(params['username'] + '\0' +
!                                             params['username'] + '\0' +
!                                             params['password'])))
!         self.auth_sock.sendall(auth_string + '\n')
!         ret = self.auth_sock.recv(1024)
  
-         self.auth_sock.close();
  
          if ret.startswith('OK'):
              return True
          return False
  
  
      def lookup(self, params):
          # We can do this only if a master socket was specified
!         if self.master:
!             self.user_sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
!             self.user_sock.connect(self.master)
!             master_handshake_string = self.user_sock.recv(1024)
!             self.user_sock.sendall('VERSION\t1\t0\n')
!         else:
              raise ValueError('No master socket was specified')
  
!         # Since this socket will be used only once, use a hardcoded request ID
!         lookup_string = ('USER\t%d\t%s\tservice=%s' %
!                          (1,
                            params['username'],
                            self.service))
!         self.user_sock.sendall(lookup_string + '\n')
          ret = self.user_sock.recv(1024)
! 
!         self.user_sock.close();
  
          if ret.startswith('USER\t'):
!             uid = ret[ret.find('\tuid=')+5:]
!             uid = uid[:uid.find('\t')]
!             gid = ret[ret.find('\tgid=')+5:]
!             gid = gid[:gid.find('\t')]
!             home = ret[ret.find('\thome=')+6:]
!             home = home[:home.find('\t')]
  
              # Assuming we were started with elevated privileges, drop them now
              if (self.gid < 0) and (int(gid) >= 0):
--- 193,380 ----
          if self.uid >= 0:
              os.setuid(self.uid)
  
+         # No sockets are open
+         self.auth_sock = None
+         self.user_sock = None
+ 
+ 
+     def open_auth_socket(self):
+         # The forked child should be short-lived enough that
+         # it should be ok to open the authentication socket
+         # only once
  
          # We can do this only if a MUX socket was specified
!         if not self.mux:
              raise ValueError('No MUX socket was specified')
  
!         # Open the socket
!         self.log(7, 'Opening socket %s' % self.mux)
!         self.auth_sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
!         self.auth_sock.connect(self.mux)
! 
!         # Send our version and PID
!         init_string = ('VERSION\t%d\t%d\nCPID\t%d\n' %
!                        (self.version[0],
!                         self.version[1],
!                         os.getpid()))
!         self.log(7, '> %r' % init_string)
!         self.auth_sock.sendall(init_string)
! 
!         # Verify version
!         greet = self.auth_sock.recv(1024)
!         self.log(7, '< %r' % greet)
!         if greet.find('VERSION\t%d\t' % self.version[0]) == -1:
!             raise ValueError('Incompatible version number')
! 
!         # Grab mechanisms
!         if len(self.mechs) == 0:
!             for line in greet.splitlines():
!                 if line.startswith('MECH\t'):
!                     parts = line.split('\t')
!                     if parts[1].upper() not in [ mech.upper() for mech in self.mechs]:
!                         self.log(7, 'Adding mechanism %s' % parts[1].upper())
!                         self.mechs.append(parts[1].upper())
! 
!         # All done
!         self.reqid = 0
! 
! 
!     def mechanisms(self):
!         # When first started, no mechanisms are known
!         # Open the authentication and get a list from the daemon
!         if len(self.mechs) == 0 and not self.auth_sock:
!             self.open_auth_socket()
! 
!         return self.mechs
! 
! 
!     def do_sasl_first(self, mechanism, *args):
!         # Make sure the requested mechanism is supported
!         if mechanism.upper() not in [ mech.upper() for mech in self.mechs ]:
!             return {'result': 'NO',
!                     'msg': 'Unsupported authentication mechanism'}
! 
!         # Build authentication request
!         self.reqid = self.reqid + 1
!         if len(args) > 0:
!             auth_string = ('AUTH\t%d\t%s\tservice=%s\tresp=%s\n' %
!                            (self.reqid,
!                             mechanism.upper(),
!                             self.service,
!                             args[0]))
!         else:
!             auth_string = ('AUTH\t%d\t%s\tservice=%s\n' %
!                            (self.reqid,
!                             mechanism.upper(),
!                             self.service))
! 
!         # We should already have a socket open, just perform the dialog
!         return self.do_sasl_dialog(auth_string)
  
  
+     def do_sasl_next(self, b64_string):
+         # Build continuation request
+         cont_string = ('CONT\t%d\t%s\n' %
+                        (self.reqid,
+                         b64_string))
+ 
+         # We should already have a socket open, just perform the dialog
+         return self.do_sasl_dialog(cont_string)
+ 
+ 
+     def do_sasl_dialog(self, msg):
+         # We should have an open socket by now
+         if not self.auth_sock:
+             return {'result': 'BYE', 'msg': 'Server Error'}
+ 
+         # Dialog
+         self.log(7, '> %r' % msg)
+         self.auth_sock.sendall(msg)
+         ret = self.auth_sock.recv(1024)
+         self.log(7, '< %r' % ret)
+ 
+         # Parse result
          if ret.startswith('OK'):
+             pass
+         elif ret.startswith('FAIL'):
+             return {'result': 'NO', 'msg': 'Authentication failed'}
+         elif ret.startswith('CONT\t'):
+             ret = (ret.splitlines())[0]
+             parts = ret.split('\t')
+             if len(parts) >= 3:
+                 return {'result': 'CONT', 'msg': parts[2]}
+             else:
+                 return {'result': 'CONT', 'msg': ''}
+         else:
+             return {'result': 'BYE', 'msg': 'Unexpected result'}
+ 
+         # Extract the authorized user
+         username = None
+ 
+         ret = (ret.splitlines())[0]
+         for part in ret.split('\t'):
+             if part.startswith('user='):
+                 username = part[5:]
+ 
+         return {'result': 'OK', 'username': username}
+ 
+ 
+     def auth(self, params):
+         # Refer to do_sasl_first
+         ret = self.do_sasl_first('PLAIN',
+                                  base64.encodestring('\0' +
+                                                      params['username'] + '\0' +
+                                                      params['password']))
+ 
+         if ret['result'].startswith('OK'):
              return True
          return False
  
  
      def lookup(self, params):
          # We can do this only if a master socket was specified
!         if not self.master:
              raise ValueError('No master socket was specified')
  
!         if not self.user_sock:
!             self.log(7, 'Opening socket %s' % self.master)
!             self.user_sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
!             self.user_sock.connect(self.master)
!             init_string = ('VERSION\t%d\t%d\n' %
!                            (self.version[0],
!                            self.version[1]))
!             self.log(7, '> %r' % init_string)
!             self.user_sock.sendall(init_string)
!             greet = self.user_sock.recv(1024)
!             self.log(7, '< %r' % greet)
!             if greet.find('VERSION\t%d\t' % self.version[0]) == -1:
!                 raise ValueError('Incompatible major version number')
!             self.lookup_id = 0
! 
!         self.lookup_id = self.lookup_id + 1
!         lookup_string = ('USER\t%d\t%s\tservice=%s\n' %
!                          (self.lookup_id,
                            params['username'],
                            self.service))
!         self.log(7, '> %r' % lookup_string)
!         self.user_sock.sendall(lookup_string)
          ret = self.user_sock.recv(1024)
!         self.log(7, '< %r' % ret)
  
          if ret.startswith('USER\t'):
!             ret = (ret.splitlines())[0]
! 
!             uid = None
!             gid = None
!             home = None
! 
!             for part in ret.split('\t'):
!               if part.startswith('uid='):
!                   uid = part[4:]
!               elif part.startswith('gid='):
!                   gid = part[4:]
!               elif part.startswith('home='):
!                   home = part[5:]
  
              # Assuming we were started with elevated privileges, drop them now
              if (self.gid < 0) and (int(gid) >= 0):
diff -cr pysieved/pysieved.py pysieved.new/pysieved.py
*** pysieved/pysieved.py	Fri Jul 27 15:01:15 2007
--- pysieved.new/pysieved.py	Sun Jul 29 06:44:14 2007
***************
*** 115,120 ****
--- 115,143 ----
          def log(self, l, s):
              log(l, s)
  
+         def list_mech(self):
+             mechs = authenticate.mechanisms()
+             self.log(5, "Announcing mechanisms : %r" % mechs)
+             return mechs
+ 
+         def do_sasl_first(self, mechanism, *args):
+             self.log(5, "Starting SASL authentication (%s) : %s" % (mechanism, ' '.join(args)))
+             ret = authenticate.do_sasl_first(mechanism, *args);
+             if ret['result'] == 'CONT':
+                 self.log(5, "Need more SASL authentication : %r" % ret)
+             else:
+                 self.log(5, "Finished SASL authentication : %r" % ret)
+             return ret
+ 
+         def do_sasl_next(self, b64_string):
+             self.log(5, "Continuing SASL authentication : %s" % b64_string)
+             ret = authenticate.do_sasl_next(b64_string);
+             if ret['result'] == 'CONT':
+                 self.log(5, "Need more SASL authentication : %r" % ret)
+             else:
+                 self.log(5, "Finished SASL authentication : %r" % ret)
+             return ret
+ 
          def authenticate(self, username, passwd):
              self.log(5, "Authenticating %s" % username)
              self.params['username'] = username