Make authentication mechanisms configurable
Added configuration option "auth_mechanisms" to the config file: it is a list of mechanisms that will be tried in the specified order. Author: Andreas Mack <andreas.mack@konsec.com> Signed-off-by: Eygene Ryabinkin <rea@codelabs.ru>
This commit is contained in:
parent
968d5520da
commit
e26827c1cb
@ -19,6 +19,8 @@ WIP (add new stuff for the next release)
|
||||
* Support SASL PLAIN authentication method. (Andreas Mack)
|
||||
* Support transport-only tunnels that requre full IMAP authentication.
|
||||
(Steve Purcell)
|
||||
* Make the list of authentication mechanisms to be configurable.
|
||||
(Andreas Mack)
|
||||
|
||||
OfflineIMAP v6.5.5-rc1 (2012-09-05)
|
||||
===================================
|
||||
|
@ -361,10 +361,24 @@ remoteuser = username
|
||||
# intact, but remote identity changes.
|
||||
#
|
||||
# Currently this variable is used only for SASL PLAIN authentication
|
||||
# mechanism.
|
||||
# mechanism, so consider using auth_mechanisms to prioritize PLAIN
|
||||
# or even make it the only mechanism to be tried.
|
||||
#
|
||||
# remote_identity = authzuser
|
||||
|
||||
# Specify which authentication/authorization mechanisms we should try
|
||||
# and the order in which OfflineIMAP will try them. NOTE: any given
|
||||
# mechanism will be tried only if it is supported by the remote IMAP
|
||||
# server.
|
||||
#
|
||||
# Due to the technical limitations, if you're specifying GSSAPI
|
||||
# as the mechanism to try, it will be tried first, no matter where
|
||||
# it was specified in the list.
|
||||
#
|
||||
# Default value is
|
||||
# auth_mechanisms = GSSAPI, CRAM-MD5, PLAIN, LOGIN
|
||||
# ranged is from strongest to more weak ones.
|
||||
|
||||
########## Passwords
|
||||
|
||||
# There are six ways to specify the password for the IMAP server:
|
||||
|
@ -15,11 +15,12 @@
|
||||
# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
try:
|
||||
from ConfigParser import SafeConfigParser
|
||||
from ConfigParser import SafeConfigParser, Error, NoOptionError
|
||||
except ImportError: #python3
|
||||
from configparser import SafeConfigParser
|
||||
from configparser import SafeConfigParser, Error, NoOptionError
|
||||
from offlineimap.localeval import LocalEval
|
||||
import os
|
||||
import re
|
||||
|
||||
class CustomConfigParser(SafeConfigParser):
|
||||
def getdefault(self, section, option, default, *args, **kwargs):
|
||||
@ -48,6 +49,25 @@ class CustomConfigParser(SafeConfigParser):
|
||||
else:
|
||||
return default
|
||||
|
||||
def getlist(self, section, option, separator_re):
|
||||
"""
|
||||
Parses option as the list of values separated
|
||||
by the given regexp.
|
||||
|
||||
"""
|
||||
try:
|
||||
val = self.get(section, option).strip()
|
||||
return re.split(separator_re, val)
|
||||
except re.error as e:
|
||||
raise Error("Bad split regexp '%s': %s" % \
|
||||
(separator_re, e))
|
||||
|
||||
def getdefaultlist(self, section, option, default, separator_re):
|
||||
if self.has_option(section, option):
|
||||
return self.getlist(*(section, option, separator_re))
|
||||
else:
|
||||
return default
|
||||
|
||||
def getmetadatadir(self):
|
||||
metadatadir = os.path.expanduser(self.getdefault("general", "metadata", "~/.offlineimap"))
|
||||
if not os.path.exists(metadatadir):
|
||||
@ -97,12 +117,14 @@ class ConfigHelperMixin:
|
||||
will then return the configuration values for the ConfigParser
|
||||
object in the specific section."""
|
||||
|
||||
def _confighelper_runner(self, option, default, defaultfunc, mainfunc):
|
||||
def _confighelper_runner(self, option, default, defaultfunc, mainfunc, *args):
|
||||
"""Return config value for getsection()"""
|
||||
lst = [self.getsection(), option]
|
||||
if default == CustomConfigDefault:
|
||||
return mainfunc(*[self.getsection(), option])
|
||||
return mainfunc(*(lst + list(args)))
|
||||
else:
|
||||
return defaultfunc(*[self.getsection(), option, default])
|
||||
lst.append(default)
|
||||
return defaultfunc(*(lst + list(args)))
|
||||
|
||||
|
||||
def getconf(self, option,
|
||||
@ -125,3 +147,9 @@ class ConfigHelperMixin:
|
||||
return self._confighelper_runner(option, default,
|
||||
self.getconfig().getdefaultfloat,
|
||||
self.getconfig().getfloat)
|
||||
|
||||
def getconflist(self, option, separator_re,
|
||||
default = CustomConfigDefault):
|
||||
return self._confighelper_runner(option, default,
|
||||
self.getconfig().getdefaultlist,
|
||||
self.getconfig().getlist, separator_re)
|
||||
|
@ -67,6 +67,7 @@ class IMAPServer:
|
||||
self.username = \
|
||||
None if self.preauth_tunnel else repos.getuser()
|
||||
self.user_identity = repos.get_remote_identity()
|
||||
self.authmechs = repos.get_auth_mechanisms()
|
||||
self.password = None
|
||||
self.passworderror = None
|
||||
self.goodpassword = None
|
||||
@ -195,6 +196,73 @@ class IMAPServer:
|
||||
return base64.b64decode(response)
|
||||
|
||||
|
||||
def _start_tls(self, imapobj):
|
||||
if 'STARTTLS' in imapobj.capabilities and not self.usessl:
|
||||
self.ui.debug('imap', 'Using STARTTLS connection')
|
||||
try:
|
||||
imapobj.starttls()
|
||||
except imapobj.error as e:
|
||||
raise OfflineImapError("Failed to start "
|
||||
"TLS connection: %s" % str(e),
|
||||
OfflineImapError.ERROR.REPO)
|
||||
|
||||
|
||||
## All _authn_* procedures are helpers that do authentication.
|
||||
## They are class methods that take one parameter, IMAP object.
|
||||
##
|
||||
## Each function should return True if authentication was
|
||||
## successful and False if authentication wasn't even tried
|
||||
## for some reason (but not when IMAP has no such authentication
|
||||
## capability, calling code checks that).
|
||||
##
|
||||
## Functions can also raise exceptions; two types are special
|
||||
## and will be handled by the calling code:
|
||||
##
|
||||
## - imapobj.error means that there was some error that
|
||||
## comes from imaplib2;
|
||||
##
|
||||
## - OfflineImapError means that function detected some
|
||||
## problem by itself.
|
||||
|
||||
def _authn_gssapi(self, imapobj):
|
||||
if not have_gss:
|
||||
return False
|
||||
|
||||
self.connectionlock.acquire()
|
||||
try:
|
||||
imapobj.authenticate('GSSAPI', self.gssauth)
|
||||
return True
|
||||
except imapobj.error as e:
|
||||
self.gssapi = False
|
||||
raise
|
||||
else:
|
||||
self.gssapi = True
|
||||
kerberos.authGSSClientClean(self.gss_vc)
|
||||
self.gss_vc = None
|
||||
self.gss_step = self.GSS_STATE_STEP
|
||||
finally:
|
||||
self.connectionlock.release()
|
||||
|
||||
def _authn_cram_md5(self, imapobj):
|
||||
imapobj.authenticate('CRAM-MD5', self.md5handler)
|
||||
return True
|
||||
|
||||
def _authn_plain(self, imapobj):
|
||||
imapobj.authenticate('PLAIN', self.plainhandler)
|
||||
return True
|
||||
|
||||
def _authn_login(self, imapobj):
|
||||
# Use LOGIN command, unless LOGINDISABLED is advertized
|
||||
# (per RFC 2595)
|
||||
if 'LOGINDISABLED' in imapobj.capabilities:
|
||||
raise OfflineImapError("IMAP LOGIN is "
|
||||
"disabled by server. Need to use SSL?",
|
||||
OfflineImapError.ERROR.REPO)
|
||||
else:
|
||||
self.loginauth(imapobj)
|
||||
return True
|
||||
|
||||
|
||||
def _authn_helper(self, imapobj):
|
||||
"""
|
||||
Authentication machinery for self.acquireconnection().
|
||||
@ -209,86 +277,56 @@ class IMAPServer:
|
||||
|
||||
"""
|
||||
|
||||
# Authentication routines, hash keyed by method name
|
||||
# with value that is a tuple with
|
||||
# - authentication function,
|
||||
# - tryTLS flag,
|
||||
# - check IMAP capability flag.
|
||||
auth_methods = {
|
||||
"GSSAPI": (self._authn_gssapi, False, True),
|
||||
"CRAM-MD5": (self._authn_cram_md5, True, True),
|
||||
"PLAIN": (self._authn_plain, True, True),
|
||||
"LOGIN": (self._authn_login, True, False),
|
||||
}
|
||||
# Stack stores pairs of (method name, exception)
|
||||
exc_stack = []
|
||||
tried_to_authn = False
|
||||
tried_tls = False
|
||||
mechs = self.authmechs
|
||||
|
||||
# Try GSSAPI and continue if it fails
|
||||
if 'AUTH=GSSAPI' in imapobj.capabilities and have_gss:
|
||||
self.connectionlock.acquire()
|
||||
self.ui.debug('imap', 'Attempting GSSAPI authentication')
|
||||
tried_to_authn = True
|
||||
try:
|
||||
imapobj.authenticate('GSSAPI', self.gssauth)
|
||||
except imapobj.error as e:
|
||||
self.gssapi = False
|
||||
self.ui.warn('GSSAPI authentication failed: %s' % e)
|
||||
exc_stack.append(('GSSAPI', e))
|
||||
else:
|
||||
self.gssapi = True
|
||||
kerberos.authGSSClientClean(self.gss_vc)
|
||||
self.gss_vc = None
|
||||
self.gss_step = self.GSS_STATE_STEP
|
||||
#if we do self.password = None then the next attempt cannot try...
|
||||
#self.password = None
|
||||
return
|
||||
finally:
|
||||
self.connectionlock.release()
|
||||
# GSSAPI must be tried first: we will probably go TLS after it
|
||||
# and GSSAPI mustn't be tunneled over TLS.
|
||||
if "GSSAPI" in mechs:
|
||||
mechs.remove("GSSAPI")
|
||||
mechs.insert(0, "GSSAPI")
|
||||
|
||||
# Fire up TLS if we can and asked to: gonna to authenticate
|
||||
# via plaintext or hashed schemes, so it is best to have
|
||||
# channel that is protected from eavesdropping.
|
||||
if 'STARTTLS' in imapobj.capabilities and not self.usessl:
|
||||
self.ui.debug('imap', 'Using STARTTLS connection')
|
||||
try:
|
||||
imapobj.starttls()
|
||||
except imapobj.error as e:
|
||||
raise OfflineImapError("Failed to start "
|
||||
"TLS connection: %s" % str(e),
|
||||
OfflineImapError.ERROR.REPO)
|
||||
for m in mechs:
|
||||
if m not in auth_methods:
|
||||
raise Exception("Bad authentication method %s, "
|
||||
"please, file OfflineIMAP bug" % m)
|
||||
|
||||
func, tryTLS, check_cap = auth_methods[m]
|
||||
|
||||
# TLS must be initiated before checking capabilities:
|
||||
# they could have been changed after STARTTLS.
|
||||
if tryTLS and not tried_tls:
|
||||
tried_tls = True
|
||||
self._start_tls(imapobj)
|
||||
|
||||
if check_cap:
|
||||
cap = "AUTH=" + m
|
||||
if cap not in imapobj.capabilities:
|
||||
continue
|
||||
|
||||
# Hashed authenticators come first: they don't reveal
|
||||
# passwords.
|
||||
if 'AUTH=CRAM-MD5' in imapobj.capabilities:
|
||||
tried_to_authn = True
|
||||
self.ui.debug('imap', 'Attempting '
|
||||
'CRAM-MD5 authentication')
|
||||
'%s authentication' % m)
|
||||
try:
|
||||
imapobj.authenticate('CRAM-MD5', self.md5handler)
|
||||
if func(imapobj):
|
||||
return
|
||||
except imapobj.error as e:
|
||||
self.ui.warn('CRAM-MD5 authentication failed: %s' % e)
|
||||
exc_stack.append(('CRAM-MD5', e))
|
||||
|
||||
# Try plaintext authenticators.
|
||||
if 'AUTH=PLAIN' in imapobj.capabilities:
|
||||
tried_to_authn = True
|
||||
self.ui.debug('imap', 'Attempting '
|
||||
'PLAIN authentication')
|
||||
try:
|
||||
imapobj.authenticate('PLAIN', self.plainhandler)
|
||||
return
|
||||
except imapobj.error as e:
|
||||
self.ui.warn('PLAIN authentication failed: %s' % e)
|
||||
exc_stack.append(('PLAIN', e))
|
||||
|
||||
# Last resort: use LOGIN command,
|
||||
# unless LOGINDISABLED is advertized (RFC 2595)
|
||||
if 'LOGINDISABLED' in imapobj.capabilities:
|
||||
e = OfflineImapError("IMAP LOGIN is "
|
||||
"disabled by server. Need to use SSL?",
|
||||
OfflineImapError.ERROR.REPO)
|
||||
exc_stack.append(('IMAP LOGIN', e))
|
||||
else:
|
||||
tried_to_authn = True
|
||||
self.ui.debug('imap', 'Attempting '
|
||||
'IMAP LOGIN authentication')
|
||||
try:
|
||||
self.loginauth(imapobj)
|
||||
return
|
||||
except imapobj.error as e:
|
||||
self.ui.warn('IMAP LOGIN authentication failed: %s' % e)
|
||||
exc_stack.append(('IMAP LOGIN', e))
|
||||
except (imapobj.error, OfflineImapError) as e:
|
||||
self.ui.warn('%s authentication failed: %s' % (m, e))
|
||||
exc_stack.append((m, e))
|
||||
|
||||
if len(exc_stack):
|
||||
msg = "\n\t".join(map(
|
||||
@ -303,9 +341,10 @@ class IMAPServer:
|
||||
lambda x: x[5:], filter(lambda x: x[0:5] == "AUTH=",
|
||||
imapobj.capabilities)
|
||||
))
|
||||
raise OfflineImapError("No supported "
|
||||
"authentication mechanisms found; "
|
||||
"server advertises %s" % methods,
|
||||
raise OfflineImapError("Repository %s: no supported "
|
||||
"authentication mechanisms found; configured %s, "
|
||||
"server advertises %s" % (self.repos,
|
||||
", ".join(self.authmechs), methods),
|
||||
OfflineImapError.ERROR.REPO)
|
||||
|
||||
|
||||
|
@ -126,6 +126,26 @@ class IMAPRepository(BaseRepository):
|
||||
|
||||
return self.getconf('remote_identity', default=None)
|
||||
|
||||
def get_auth_mechanisms(self):
|
||||
supported = ["GSSAPI", "CRAM-MD5", "PLAIN", "LOGIN"]
|
||||
# Mechanisms are ranged from the strongest to the
|
||||
# weakest ones.
|
||||
# TODO: we need DIGEST-MD5, it must come before CRAM-MD5
|
||||
# TODO: due to the chosen-plaintext resistance.
|
||||
default = ["GSSAPI", "CRAM-MD5", "PLAIN", "LOGIN"]
|
||||
|
||||
mechs = self.getconflist('auth_mechanisms', r',\s*',
|
||||
default)
|
||||
|
||||
for m in mechs:
|
||||
if m not in supported:
|
||||
raise OfflineImapError("Repository %s: " % self + \
|
||||
"unknown authentication mechanism '%s'" % m,
|
||||
OfflineImapError.ERROR.REPO)
|
||||
|
||||
self.ui.debug('imap', "Using authentication mechanisms %s" % mechs)
|
||||
return mechs
|
||||
|
||||
|
||||
def getuser(self):
|
||||
user = None
|
||||
|
Loading…
Reference in New Issue
Block a user