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 SASL PLAIN authentication method. (Andreas Mack)
|
||||||
* Support transport-only tunnels that requre full IMAP authentication.
|
* Support transport-only tunnels that requre full IMAP authentication.
|
||||||
(Steve Purcell)
|
(Steve Purcell)
|
||||||
|
* Make the list of authentication mechanisms to be configurable.
|
||||||
|
(Andreas Mack)
|
||||||
|
|
||||||
OfflineIMAP v6.5.5-rc1 (2012-09-05)
|
OfflineIMAP v6.5.5-rc1 (2012-09-05)
|
||||||
===================================
|
===================================
|
||||||
|
@ -361,10 +361,24 @@ remoteuser = username
|
|||||||
# intact, but remote identity changes.
|
# intact, but remote identity changes.
|
||||||
#
|
#
|
||||||
# Currently this variable is used only for SASL PLAIN authentication
|
# 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
|
# 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
|
########## Passwords
|
||||||
|
|
||||||
# There are six ways to specify the password for the IMAP server:
|
# 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
|
# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from ConfigParser import SafeConfigParser
|
from ConfigParser import SafeConfigParser, Error, NoOptionError
|
||||||
except ImportError: #python3
|
except ImportError: #python3
|
||||||
from configparser import SafeConfigParser
|
from configparser import SafeConfigParser, Error, NoOptionError
|
||||||
from offlineimap.localeval import LocalEval
|
from offlineimap.localeval import LocalEval
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
class CustomConfigParser(SafeConfigParser):
|
class CustomConfigParser(SafeConfigParser):
|
||||||
def getdefault(self, section, option, default, *args, **kwargs):
|
def getdefault(self, section, option, default, *args, **kwargs):
|
||||||
@ -48,6 +49,25 @@ class CustomConfigParser(SafeConfigParser):
|
|||||||
else:
|
else:
|
||||||
return default
|
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):
|
def getmetadatadir(self):
|
||||||
metadatadir = os.path.expanduser(self.getdefault("general", "metadata", "~/.offlineimap"))
|
metadatadir = os.path.expanduser(self.getdefault("general", "metadata", "~/.offlineimap"))
|
||||||
if not os.path.exists(metadatadir):
|
if not os.path.exists(metadatadir):
|
||||||
@ -97,12 +117,14 @@ class ConfigHelperMixin:
|
|||||||
will then return the configuration values for the ConfigParser
|
will then return the configuration values for the ConfigParser
|
||||||
object in the specific section."""
|
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()"""
|
"""Return config value for getsection()"""
|
||||||
|
lst = [self.getsection(), option]
|
||||||
if default == CustomConfigDefault:
|
if default == CustomConfigDefault:
|
||||||
return mainfunc(*[self.getsection(), option])
|
return mainfunc(*(lst + list(args)))
|
||||||
else:
|
else:
|
||||||
return defaultfunc(*[self.getsection(), option, default])
|
lst.append(default)
|
||||||
|
return defaultfunc(*(lst + list(args)))
|
||||||
|
|
||||||
|
|
||||||
def getconf(self, option,
|
def getconf(self, option,
|
||||||
@ -125,3 +147,9 @@ class ConfigHelperMixin:
|
|||||||
return self._confighelper_runner(option, default,
|
return self._confighelper_runner(option, default,
|
||||||
self.getconfig().getdefaultfloat,
|
self.getconfig().getdefaultfloat,
|
||||||
self.getconfig().getfloat)
|
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 = \
|
self.username = \
|
||||||
None if self.preauth_tunnel else repos.getuser()
|
None if self.preauth_tunnel else repos.getuser()
|
||||||
self.user_identity = repos.get_remote_identity()
|
self.user_identity = repos.get_remote_identity()
|
||||||
|
self.authmechs = repos.get_auth_mechanisms()
|
||||||
self.password = None
|
self.password = None
|
||||||
self.passworderror = None
|
self.passworderror = None
|
||||||
self.goodpassword = None
|
self.goodpassword = None
|
||||||
@ -195,6 +196,73 @@ class IMAPServer:
|
|||||||
return base64.b64decode(response)
|
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):
|
def _authn_helper(self, imapobj):
|
||||||
"""
|
"""
|
||||||
Authentication machinery for self.acquireconnection().
|
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)
|
# Stack stores pairs of (method name, exception)
|
||||||
exc_stack = []
|
exc_stack = []
|
||||||
tried_to_authn = False
|
tried_to_authn = False
|
||||||
|
tried_tls = False
|
||||||
|
mechs = self.authmechs
|
||||||
|
|
||||||
# Try GSSAPI and continue if it fails
|
# GSSAPI must be tried first: we will probably go TLS after it
|
||||||
if 'AUTH=GSSAPI' in imapobj.capabilities and have_gss:
|
# and GSSAPI mustn't be tunneled over TLS.
|
||||||
self.connectionlock.acquire()
|
if "GSSAPI" in mechs:
|
||||||
self.ui.debug('imap', 'Attempting GSSAPI authentication')
|
mechs.remove("GSSAPI")
|
||||||
tried_to_authn = True
|
mechs.insert(0, "GSSAPI")
|
||||||
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()
|
|
||||||
|
|
||||||
# Fire up TLS if we can and asked to: gonna to authenticate
|
for m in mechs:
|
||||||
# via plaintext or hashed schemes, so it is best to have
|
if m not in auth_methods:
|
||||||
# channel that is protected from eavesdropping.
|
raise Exception("Bad authentication method %s, "
|
||||||
if 'STARTTLS' in imapobj.capabilities and not self.usessl:
|
"please, file OfflineIMAP bug" % m)
|
||||||
self.ui.debug('imap', 'Using STARTTLS connection')
|
|
||||||
try:
|
func, tryTLS, check_cap = auth_methods[m]
|
||||||
imapobj.starttls()
|
|
||||||
except imapobj.error as e:
|
# TLS must be initiated before checking capabilities:
|
||||||
raise OfflineImapError("Failed to start "
|
# they could have been changed after STARTTLS.
|
||||||
"TLS connection: %s" % str(e),
|
if tryTLS and not tried_tls:
|
||||||
OfflineImapError.ERROR.REPO)
|
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
|
tried_to_authn = True
|
||||||
self.ui.debug('imap', 'Attempting '
|
self.ui.debug('imap', 'Attempting '
|
||||||
'CRAM-MD5 authentication')
|
'%s authentication' % m)
|
||||||
try:
|
try:
|
||||||
imapobj.authenticate('CRAM-MD5', self.md5handler)
|
if func(imapobj):
|
||||||
return
|
return
|
||||||
except imapobj.error as e:
|
except (imapobj.error, OfflineImapError) as e:
|
||||||
self.ui.warn('CRAM-MD5 authentication failed: %s' % e)
|
self.ui.warn('%s authentication failed: %s' % (m, e))
|
||||||
exc_stack.append(('CRAM-MD5', e))
|
exc_stack.append((m, 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))
|
|
||||||
|
|
||||||
if len(exc_stack):
|
if len(exc_stack):
|
||||||
msg = "\n\t".join(map(
|
msg = "\n\t".join(map(
|
||||||
@ -303,9 +341,10 @@ class IMAPServer:
|
|||||||
lambda x: x[5:], filter(lambda x: x[0:5] == "AUTH=",
|
lambda x: x[5:], filter(lambda x: x[0:5] == "AUTH=",
|
||||||
imapobj.capabilities)
|
imapobj.capabilities)
|
||||||
))
|
))
|
||||||
raise OfflineImapError("No supported "
|
raise OfflineImapError("Repository %s: no supported "
|
||||||
"authentication mechanisms found; "
|
"authentication mechanisms found; configured %s, "
|
||||||
"server advertises %s" % methods,
|
"server advertises %s" % (self.repos,
|
||||||
|
", ".join(self.authmechs), methods),
|
||||||
OfflineImapError.ERROR.REPO)
|
OfflineImapError.ERROR.REPO)
|
||||||
|
|
||||||
|
|
||||||
|
@ -126,6 +126,26 @@ class IMAPRepository(BaseRepository):
|
|||||||
|
|
||||||
return self.getconf('remote_identity', default=None)
|
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):
|
def getuser(self):
|
||||||
user = None
|
user = None
|
||||||
|
Loading…
Reference in New Issue
Block a user