Repository: fail2ban/fail2ban

Issue:
Use variable `__pam_auth` instead of `pam_unix`
This will take into account the configured PAM backend in `filder.d/common.conf`

Before submitting your PR, please review the following checklist:

- [x] **CHOOSE CORRECT BRANCH**: if filing a bugfix/enhancement
      against 0.9.x series, choose `master` branch
- [ ] **CONSIDER adding a unit test** if your PR resolves an issue
- [x] **LIST ISSUES** this PR resolves
- [x] **MAKE SURE** this PR doesn't break existing tests
- [x] **KEEP PR small** so it could be easily reviewed.
- [x] **AVOID** making unnecessary stylistic changes in unrelated code
- [ ] **ACCOMPANY** each new `failregex` for filter `X` with sample log lines
      within `fail2ban/tests/files/logs/X` file

**Issues this PR resolves:**

On Debian "Stretch" or Ubuntu "Xenial", when `pam_unix` and `pam_ldap` are used in combination, a successful `ssh` authentication against `pam_ldap` will cause a filter detection because the PAM stack first tries `pam_unix`:

```
Mar  7 18:53:20 bar sshd[1556]: pam_unix(sshd:auth): authentication failure; logname= uid=0 euid=0 tty=ssh ruser= rhost=1.2.3.4  user=rda
Mar  7 18:53:20 bar sshd[1556]: Accepted password for rda from 1.2.3.4 port 52100 ssh2
Mar  7 18:53:20 bar sshd[1556]: pam_unix(sshd:session): session opened for user rda by (uid=0)
```

Using this patch and configuring `pam_ldap` backend in `filter.d/common.conf`:

```
__pam_auth = pam_ldap
```

fixes the issue.

Failed password-based authentication against a local-only user is still registered in this case:

```
==> auth.log <==
Mar  7 19:12:59 bar sshd[3615]: pam_unix(sshd:auth): authentication failure; logname= uid=0 euid=0 tty=ssh ruser= rhost=1.2.3.4  user=rdalocal
Mar  7 19:13:02 bar sshd[3615]: Failed password for rdalocal from 1.2.3.4 port 52132 ssh2
==> fail2ban.log <==
2018-03-07 19:13:02,351 fail2ban.filter         [3601]: INFO    [sshd] Found 1.2.3.4
```


Discussion:
Thanks for PR.

> Using this patch and configuring pam_ldap backend in filter.d/common.conf fixes the issue.

I don't think it can be seen as fix - you'll just ignore the authentication failures via pam_unix.
The problem is your PR affects all people using pam_unix only (not in combination like you). 
Could we somehow differentiate between regular pam_unix auth-attempt and such "combined" from pam_ldap?

Please do not hesitate to correct me if I'm wrong, but IMHO, the issue belong rather to already known multiple authentication methods problem (see for example #1263).
Which was fixed for 0.10 within #2014.
Following regex-line allows fail2ban to forget the failures of this connections:
https://github.com/fail2ban/fail2ban/blob/1c0fc73e48ab006cc3f5bd4ac10b8540b450149d/config/filter.d/sshd.conf#L54
Thus for exactly this case we could simply extend it like here:
```diff
- ^<F-MLFFORGET><F-NOFAIL>Accepted publickey</F-NOFAIL></F-MLFFORGET> for \S+ from <HOST>(?:\s|$)
+ ^<F-MLFFORGET><F-NOFAIL>Accepted \w+</F-NOFAIL></F-MLFFORGET> for \S+ from <HOST>(?:\s|$)
```
Note the syntax with MLFFORGET works >=0.10 only.

For early versions it can be implemented through adding `ignoreregex` as suggested in https://github.com/fail2ban/fail2ban/issues/1263#issuecomment-356691933.
> Failed password-based authentication against a local-only user is still registered in this case

Only someone who can read, will has a clear advantage... :smile:
I'll take a look in sshd source code, how it is currently implemented there. 
Possibly this line `authentication failure; logname= ...` causes anyway a double counting in sshd-filter (then it should be removed completely). 

Thanks again.
Thanks for the explanations.

Clearly I do not understand all the implications this would have. I just saw #929 and how the variable was added to (for example) [filter.d/dovecote.conf](https://github.com/fail2ban/fail2ban/pull/929/files#diff-ce1a137aff77b5f399a6c3f4f5bffed9) and thought that it was maybe just forgotten for `sshd.conf`. And that because `__pam_auth = pam_unix` is the default, there would be no change for people using `pam_unix` only.

> the issue belong rather to already known multiple authentication methods problem

This solution as in #2014 sounds great. 

> For early versions it can be implemented through adding ignoreregex as suggested in #1263 (comment).

OK, then we will use this to patch our systems running with `0.9.x` from packaged version in Debian/Ubuntu.
> Possibly this line `authentication failure; logname= ...` causes anyway a double counting in sshd-filter (then it should be removed completely).

This could be the case. I noticed in our setup (using `pam_unix` and `pam_ldap`):

- when authenticating using correct credentials, ban after 6 attempts
- when authenticating using incorrect credentials, ban after 3 attempts
> when authenticating using incorrect credentials, ban after 3 attempts

How large is value of `maxretry` configured?
`maxretry` = `5`

Using Debian 9.3, fail2ban 0.9.6-2 and the default settings.

> when authenticating using incorrect credentials, ban after 3 attempts

That happens when opening a new ssh session per attempt:

```
==> auth.log <==
Mar  8 08:30:51 bar sshd[21180]: pam_unix(sshd:auth): authentication failure; logname= uid=0 euid=0 tty=ssh ruser= rhost=1.2.3.4  user=rda
Mar  8 08:30:51 bar sshd[21180]: pam_ldap(sshd:auth): Authentication failure; user=rda
==> fail2ban.log <==
2018-03-08 08:30:51,768 fail2ban.filter         [20844]: INFO    [sshd] Found 1.2.3.4
==> auth.log <==
Mar  8 08:30:53 bar sshd[21180]: Failed password for rda from 1.2.3.4 port 52232 ssh2
==> fail2ban.log <==
2018-03-08 08:30:53,772 fail2ban.filter         [20844]: INFO    [sshd] Found 1.2.3.4
==> auth.log <==
Mar  8 08:30:54 bar sshd[21180]: Connection closed by 1.2.3.4 port 52232 [preauth]
Mar  8 08:30:58 bar sshd[21353]: pam_unix(sshd:auth): authentication failure; logname= uid=0 euid=0 tty=ssh ruser= rhost=1.2.3.4  user=rda
Mar  8 08:30:58 bar sshd[21353]: pam_ldap(sshd:auth): Authentication failure; user=rda
==> fail2ban.log <==
2018-03-08 08:30:58,781 fail2ban.filter         [20844]: INFO    [sshd] Found 1.2.3.4
==> auth.log <==
Mar  8 08:31:00 bar sshd[21353]: Failed password for rda from 1.2.3.4 port 52234 ssh2
==> fail2ban.log <==
2018-03-08 08:31:00,785 fail2ban.filter         [20844]: INFO    [sshd] Found 1.2.3.4
==> auth.log <==
Mar  8 08:31:03 bar sshd[21353]: Connection closed by 1.2.3.4 port 52234 [preauth]
Mar  8 08:31:07 bar sshd[21565]: pam_unix(sshd:auth): authentication failure; logname= uid=0 euid=0 tty=ssh ruser= rhost=1.2.3.4  user=rda
Mar  8 08:31:07 bar sshd[21565]: pam_ldap(sshd:auth): Authentication failure; user=rda
==> fail2ban.log <==
2018-03-08 08:31:07,796 fail2ban.filter         [20844]: INFO    [sshd] Found 1.2.3.4
2018-03-08 08:31:07,899 fail2ban.actions        [20844]: NOTICE  [sshd] Ban 1.2.3.4
==> auth.log <==
Mar  8 08:31:08 bar sshd[21565]: Failed password for rda from 1.2.3.4 port 52236 ssh2
==> fail2ban.log <==
2018-03-08 08:31:09,802 fail2ban.filter         [20844]: INFO    [sshd] Found 1.2.3.4
```

I was also pretty sure, that I noticed being banned after less than 5 attempts even when using correct credentials. But at the moment I am not able to reproduce it.
As suggested in #1263 (comment), adding an `ignoreregex` to `filter.d/sshd.local`:

```
[Definition]
ignoreregex = ^%(__prefix_line)spam_unix\(sshd:auth\):\s+authentication failure;\s*logname=\S*\s*uid=\d*\s*euid=\d*\s*tty=\S*\s*ruser=\S*\s*rhost=<HOST>\s.*$
```

fixes our issue, while authentication using incorrect credentials against `pam_ldap` AND `pam_unix` is still correctly detected. I think it is this filter line:

```
^%(__prefix_line)sFailed \S+ for (?P<cond_inv>invalid user )?(?P<user>(?P<cond_user>\S+)|(?(cond_inv)(?:(?! from ).)*?|[^:]+)) from <HOST>(?: port \d+)?(?: ssh\d*)?(?(cond_user):|(?:(?:(?! from ).)*)$)
```

that will match (against `pam_ldap`):

```
Mar  8 09:35:47 bar sshd[16274]: pam_ldap(sshd:auth): Authentication failure; user=rda
Mar  8 09:35:48 bar sshd[16274]: Failed password for rda from 1.2.3.4 port 52346 ssh2
```

and (against `pam_unix`):

```
Mar  8 09:36:09 bar sshd[16897]: pam_unix(sshd:auth): authentication failure; logname= uid=0 euid=0 tty=ssh ruser= rhost=1.2.3.4  user=rdalocal
Mar  8 09:35:48 bar sshd[16274]: Failed password for rda from 1.2.3.4 port 52346 ssh2
```

It is also the exact same behaviour, if I do not use the `ignoreregex`, but remove the `failregex` line with `pam_unix` from `filter.d/sshd.conf`.

So it seems like this line is not needed at all in our case, or I just do not understand when it would be needed.

We use the following packages:

- fail2ban: 0.9.6-2
- openssh-server: 1:7.4p1-10+deb9u2
- nslcd: 0.9.7-2
- libnss-ldapd: 0.9.7-2
- libpam-ldapd: 0.9.7-2

and the following `/etc/ssh/sshd_config` :

```
PermitRootLogin without-password
AuthorizedKeysFile	.ssh/authorized_keys
ChallengeResponseAuthentication no
UsePAM yes
X11Forwarding yes
PrintMotd no
Subsystem sftp internal-sftp
```
The issue is still there.
We cannot change PR to issue on github, thus I reopen is as an issue.

Resolving patch:
diff --git a/fail2ban/server/failregex.py b/fail2ban/server/failregex.py
--- a/fail2ban/server/failregex.py
+++ b/fail2ban/server/failregex.py
@@ -89,6 +89,11 @@ def mapTag2Opt(tag):
 	except KeyError:
 		return tag.lower()
 
+
+# alternate names to be merged, e. g. alt_user_1 -> user ...
+ALTNAME_PRE = 'alt_'
+ALTNAME_CRE = re.compile(r'^' + ALTNAME_PRE + r'(.*)(?:_\d+)?$')
+
 ##
 # Regular expression class.
 #
@@ -114,6 +119,14 @@ def __init__(self, regex, multiline=False, **kwargs):
 		try:
 			self._regexObj = re.compile(regex, re.MULTILINE if multiline else 0)
 			self._regex = regex
+			self._altValues = {}
+			for k in filter(
+				lambda k: len(k) > len(ALTNAME_PRE) and k.startswith(ALTNAME_PRE),
+				self._regexObj.groupindex
+			):
+				n = ALTNAME_CRE.match(k).group(1)
+				self._altValues[k] = n
+			self._altValues = list(self._altValues.items()) if len(self._altValues) else None
 		except sre_constants.error:
 			raise RegexException("Unable to compile regular expression '%s'" %
 								 regex)
@@ -257,7 +270,16 @@ def hasMatched(self):
 	#
 
 	def getGroups(self):
-		return self._matchCache.groupdict()
+		if not self._altValues:
+			return self._matchCache.groupdict()
+		# merge alternate values (e. g. 'alt_user_1' -> 'user' or 'alt_host' -> 'host'):
+		fail = self._matchCache.groupdict()
+		#fail = fail.copy()
+		for k,n in self._altValues:
+			v = fail.get(k)
+			if v and not fail.get(n):
+				fail[n] = v
+		return fail
 
 	##
 	# Returns skipped lines.
diff --git a/fail2ban/server/filter.py b/fail2ban/server/filter.py
--- a/fail2ban/server/filter.py
+++ b/fail2ban/server/filter.py
@@ -589,31 +589,86 @@ def ignoreLine(self, tupleLines):
 				return ignoreRegexIndex
 		return None
 
+	def _updateUsers(self, fail, user=()):
+		users = fail.get('users')
+		# only for regex contains user:
+		if user:
+			if not users:
+				fail['users'] = users = set()
+			users.add(user)
+			return users
+		return None
+
+	# # ATM incremental (non-empty only) merge deactivated ...
+	# @staticmethod
+	# def _updateFailure(self, mlfidGroups, fail):
+	# 	# reset old failure-ids when new types of id available in this failure:
+	# 	fids = set()
+	# 	for k in ('fid', 'ip4', 'ip6', 'dns'):
+	# 		if fail.get(k):
+	# 			fids.add(k)
+	# 	if fids:
+	# 		for k in ('fid', 'ip4', 'ip6', 'dns'):
+	# 			if k not in fids:
+	# 				try:
+	# 					del mlfidGroups[k]
+	# 				except:
+	# 					pass
+	# 	# update not empty values:
+	# 	mlfidGroups.update(((k,v) for k,v in fail.iteritems() if v))
+
 	def _mergeFailure(self, mlfid, fail, failRegex):
 		mlfidFail = self.mlfidCache.get(mlfid) if self.__mlfidCache else None
+		users = None
+		nfflgs = 0
+		if fail.get('nofail'): nfflgs |= 1
+		if fail.get('mlfforget'): nfflgs |= 2
 		# if multi-line failure id (connection id) known:
 		if mlfidFail:
 			mlfidGroups = mlfidFail[1]
-			# update - if not forget (disconnect/reset):
-			if not fail.get('mlfforget'):
-				mlfidGroups.update(fail)
-			else:
-				self.mlfidCache.unset(mlfid) # remove cached entry
-			# merge with previous info:
-			fail2 = mlfidGroups.copy()
-			fail2.update(fail)
-			if not fail.get('nofail'): # be sure we've correct current state
-				try:
-					del fail2['nofail']
-				except KeyError:
-					pass
-			fail2["matches"] = fail.get("matches", []) + failRegex.getMatchedTupleLines()
-			fail = fail2
-		elif not fail.get('mlfforget'):
+			# update users set (hold all users of connect):
+			users = self._updateUsers(mlfidGroups, fail.get('user'))
+			# be sure we've correct current state ('nofail' only from last failure)
+			try:
+				del mlfidGroups['nofail']
+			except KeyError:
+				pass
+			# # ATM incremental (non-empty only) merge deactivated (for future version only),
+			# # it can be simulated using alternate value tags, like <F-ALT_VAL>...</F-ALT_VAL>,
+			# # so previous value 'val' will be overwritten only if 'alt_val' is not empty...		
+			# _updateFailure(mlfidGroups, fail)
+			#
+			# overwrite multi-line failure with all values, available in fail:
+			mlfidGroups.update(fail)
+			# new merged failure data:
+			fail = mlfidGroups
+			# if forget (disconnect/reset) - remove cached entry:
+			if nfflgs & 2:
+				self.mlfidCache.unset(mlfid)
+		elif not (nfflgs & 2): # not mlfforget
+			users = self._updateUsers(fail, fail.get('user'))
 			mlfidFail = [self.__lastDate, fail]
 			self.mlfidCache.set(mlfid, mlfidFail)
-			if fail.get('nofail'):
-				fail["matches"] = failRegex.getMatchedTupleLines()
+		# check users in order to avoid reset failure by multiple logon-attempts:
+		if users and len(users) > 1:
+			# we've new user, reset 'nofail' because of multiple users attempts:
+			try:
+				del fail['nofail']
+			except KeyError:
+				pass
+		# merge matches:
+		if not fail.get('nofail'): # current state (corresponding users)
+			try:
+				m = fail.pop("nofail-matches")
+				m += fail.get("matches", [])
+			except KeyError:
+				m = fail.get("matches", [])
+			if not (nfflgs & 2): # not mlfforget:
+				m += failRegex.getMatchedTupleLines()
+			fail["matches"] = m
+		elif not (nfflgs & 2) and (nfflgs & 1): # not mlfforget and nofail:
+			fail["nofail-matches"] = fail.get("nofail-matches", []) + failRegex.getMatchedTupleLines()
+		# return merged:
 		return fail
 
 
@@ -702,8 +757,10 @@ def findFailure(self, tupleLine, date=None):
 				failRegex.search(buf, orgBuffer)
 				if not failRegex.hasMatched():
 					continue
+				# current failure data (matched group dict):
+				fail = failRegex.getGroups()
 				# The failregex matched.
-				if ll <= 7: logSys.log(7, "  Matched failregex %d: %s", failRegexIndex, failRegex.getGroups())
+				if ll <= 7: logSys.log(7, "  Matched failregex %d: %s", failRegexIndex, fail)
 				# Checks if we must ignore this match.
 				if self.ignoreLine(failRegex.getMatchedTupleLines()) \
 						is not None:
@@ -725,14 +782,14 @@ def findFailure(self, tupleLine, date=None):
 						"in order to get support for this format.",
 						 "\n".join(failRegex.getMatchedLines()), timeText)
 					continue
-				self.__lineBuffer, buf = failRegex.getUnmatchedTupleLines(), None
-				# retrieve failure-id, host, etc from failure match:
+				# we should check all regex (bypass on multi-line, otherwise too complex):
+				if not self.checkAllRegex or self.getMaxLines() > 1:
+					self.__lineBuffer, buf = failRegex.getUnmatchedTupleLines(), None
+				# merge data if multi-line failure:
 				raw = returnRawHost
 				if preGroups:
-					fail = preGroups.copy()
-					fail.update(failRegex.getGroups())
-				else:
-					fail = failRegex.getGroups()
+					currFail, fail = fail, preGroups.copy()
+					fail.update(currFail)
 				# first try to check we have mlfid case (caching of connection id by multi-line):
 				mlfid = fail.get('mlfid')
 				if mlfid is not None:


Test patch:
diff --git a/fail2ban/tests/config/filter.d/zzz-sshd-obsolete-multiline.conf b/fail2ban/tests/config/filter.d/zzz-sshd-obsolete-multiline.conf
--- a/fail2ban/tests/config/filter.d/zzz-sshd-obsolete-multiline.conf
+++ b/fail2ban/tests/config/filter.d/zzz-sshd-obsolete-multiline.conf
@@ -14,8 +14,8 @@ _daemon = sshd
 # optional prefix (logged from several ssh versions) like "error: ", "error: PAM: " or "fatal: "
 __pref = (?:(?:error|fatal): (?:PAM: )?)?
 # optional suffix (logged from several ssh versions) like " [preauth]"
-__suff = (?: \[preauth\])?\s*
-__on_port_opt = (?: port \d+)?(?: on \S+(?: port \d+)?)?
+__suff = (?: (?:port \d+|on \S+|\[preauth\])){0,3}\s*
+__on_port_opt = (?: (?:port \d+|on \S+)){0,2}
 
 # single line prefix:
 __prefix_line_sl = %(__prefix_line)s%(__pref)s
@@ -27,22 +27,25 @@ __prefix_line_ml2 = %(__suff)s$<SKIPLINES>^(?P=__prefix)%(__pref)s
 # see ssherr.c for all possible SSH_ERR_..._ALG_MATCH errors.
 __alg_match = (?:(?:\w+ (?!found\b)){0,2}\w+)
 
+# PAM authentication mechanism, can be overridden, e. g. `filter = sshd[__pam_auth='pam_ldap']`:
+__pam_auth = pam_[a-z]+
+
 [Definition]
 
 cmnfailre = ^%(__prefix_line_sl)s[aA]uthentication (?:failure|error|failed) for .* from <HOST>( via \S+)?\s*%(__suff)s$
          ^%(__prefix_line_sl)sUser not known to the underlying authentication module for .* from <HOST>\s*%(__suff)s$
          ^%(__prefix_line_sl)sFailed \S+ for invalid user <F-USER>(?P<cond_user>\S+)|(?:(?! from ).)*?</F-USER> from <HOST>%(__on_port_opt)s(?: ssh\d*)?(?(cond_user): |(?:(?:(?! from ).)*)$)
          ^%(__prefix_line_sl)sFailed \b(?!publickey)\S+ for (?P<cond_inv>invalid user )?<F-USER>(?P<cond_user>\S+)|(?(cond_inv)(?:(?! from ).)*?|[^:]+)</F-USER> from <HOST>%(__on_port_opt)s(?: ssh\d*)?(?(cond_user): |(?:(?:(?! from ).)*)$)
-         ^%(__prefix_line_sl)sROOT LOGIN REFUSED.* FROM <HOST>\s*%(__suff)s$
-         ^%(__prefix_line_sl)s[iI](?:llegal|nvalid) user .*? from <HOST>%(__on_port_opt)s\s*$
+         ^%(__prefix_line_sl)sROOT LOGIN REFUSED FROM <HOST>
+         ^%(__prefix_line_sl)s[iI](?:llegal|nvalid) user .*? from <HOST>%(__suff)s$
          ^%(__prefix_line_sl)sUser .+ from <HOST> not allowed because not listed in AllowUsers\s*%(__suff)s$
          ^%(__prefix_line_sl)sUser .+ from <HOST> not allowed because listed in DenyUsers\s*%(__suff)s$
          ^%(__prefix_line_sl)sUser .+ from <HOST> not allowed because not in any group\s*%(__suff)s$
-         ^%(__prefix_line_sl)srefused connect from \S+ \(<HOST>\)\s*%(__suff)s$
+         ^%(__prefix_line_sl)srefused connect from \S+ \(<HOST>\)
          ^%(__prefix_line_sl)sReceived disconnect from <HOST>%(__on_port_opt)s:\s*3: .*: Auth fail%(__suff)s$
          ^%(__prefix_line_sl)sUser .+ from <HOST> not allowed because a group is listed in DenyGroups\s*%(__suff)s$
          ^%(__prefix_line_sl)sUser .+ from <HOST> not allowed because none of user's groups are listed in AllowGroups\s*%(__suff)s$
-         ^%(__prefix_line_sl)spam_unix\(sshd:auth\):\s+authentication failure;\s*logname=\S*\s*uid=\d*\s*euid=\d*\s*tty=\S*\s*ruser=\S*\s*rhost=<HOST>\s.*%(__suff)s$
+         ^%(__prefix_line_ml1)s%(__pam_auth)s\(sshd:auth\):\s+authentication failure;\s*logname=\S*\s*uid=\d*\s*euid=\d*\s*tty=\S*\s*ruser=\S*\s*rhost=<HOST>\s.*%(__suff)s$%(__prefix_line_ml2)sConnection closed
          ^%(__prefix_line_sl)s(error: )?maximum authentication attempts exceeded for .* from <HOST>%(__on_port_opt)s(?: ssh\d*)? \[preauth\]$
          ^%(__prefix_line_ml1)sUser .+ not allowed because account is locked%(__prefix_line_ml2)sReceived disconnect from <HOST>%(__on_port_opt)s:\s*11: .+%(__suff)s$
          ^%(__prefix_line_ml1)sDisconnecting: Too many authentication failures(?: for .+?)?%(__suff)s%(__prefix_line_ml2)sConnection closed by <HOST>%(__suff)s$
@@ -50,13 +53,13 @@ cmnfailre = ^%(__prefix_line_sl)s[aA]uthentication (?:failure|error|failed) for
 
 mdre-normal =
 
-mdre-ddos =  ^%(__prefix_line_sl)sDid not receive identification string from <HOST>%(__on_port_opt)s%(__suff)s
-             ^%(__prefix_line_sl)sConnection reset by <HOST>%(__on_port_opt)s%(__suff)s
+mdre-ddos =  ^%(__prefix_line_sl)sDid not receive identification string from <HOST>
+             ^%(__prefix_line_sl)sConnection reset by <HOST>
              ^%(__prefix_line_ml1)sSSH: Server;Ltype: (?:Authname|Version|Kex);Remote: <HOST>-\d+;[A-Z]\w+:.*%(__prefix_line_ml2)sRead from socket failed: Connection reset by peer%(__suff)s$
 
-mdre-extra = ^%(__prefix_line_sl)sReceived disconnect from <HOST>%(__on_port_opt)s:\s*14: No supported authentication methods available%(__suff)s$
+mdre-extra = ^%(__prefix_line_sl)sReceived disconnect from <HOST>%(__on_port_opt)s:\s*14: No supported authentication methods available
              ^%(__prefix_line_sl)sUnable to negotiate with <HOST>%(__on_port_opt)s: no matching <__alg_match> found.
-             ^%(__prefix_line_ml1)sConnection from <HOST>%(__on_port_opt)s%(__prefix_line_ml2)sUnable to negotiate a <__alg_match>%(__suff)s$
+             ^%(__prefix_line_ml1)sConnection from <HOST>%(__on_port_opt)s%(__prefix_line_ml2)sUnable to negotiate a <__alg_match>
              ^%(__prefix_line_ml1)sConnection from <HOST>%(__on_port_opt)s%(__prefix_line_ml2)sno matching <__alg_match> found:
 
 mdre-aggressive = %(mdre-ddos)s
diff --git a/fail2ban/tests/files/logs/sshd b/fail2ban/tests/files/logs/sshd
--- a/fail2ban/tests/files/logs/sshd
+++ b/fail2ban/tests/files/logs/sshd
@@ -24,6 +24,8 @@ Feb 25 14:34:11 belka sshd[31603]: Failed password for invalid user ROOT from aa
 # failJSON: { "time": "2005-01-05T01:31:41", "match": true , "host": "1.2.3.4" }
 Jan  5 01:31:41 www sshd[1643]: ROOT LOGIN REFUSED FROM 1.2.3.4
 # failJSON: { "time": "2005-01-05T01:31:41", "match": true , "host": "1.2.3.4" }
+Jan  5 01:31:41 www sshd[1643]: ROOT LOGIN REFUSED FROM 1.2.3.4 port 12345 [preauth]
+# failJSON: { "time": "2005-01-05T01:31:41", "match": true , "host": "1.2.3.4" }
 Jan  5 01:31:41 www sshd[1643]: ROOT LOGIN REFUSED FROM ::ffff:1.2.3.4
 
 #4
@@ -210,9 +212,46 @@ Apr 27 13:02:04 host sshd[29116]: input_userauth_request: invalid user root [pre
 # failJSON: { "time": "2005-04-27T13:02:04", "match": true , "host": "1.2.3.4", "desc": "No Bye-Bye" }
 Apr 27 13:02:04 host sshd[29116]: Received disconnect from 1.2.3.4: 11: Normal Shutdown, Thank you for playing [preauth]
 
-# Match sshd auth errors on OpenSUSE systems
-# failJSON: { "time": "2015-04-16T20:02:50", "match": true , "host": "222.186.21.217", "desc": "Authentication for user failed" }
-2015-04-16T18:02:50.321974+00:00 host sshd[2716]: pam_unix(sshd:auth): authentication failure; logname= uid=0 euid=0 tty=ssh ruser= rhost=222.186.21.217  user=root
+# Match sshd auth errors on OpenSUSE systems (gh-1024)
+# failJSON: { "match": false, "desc": "No failure until closed or another fail (e. g. F-MLFFORGET by success/accepted password can avoid failure, see gh-2070)" }
+2015-04-16T18:02:50.321974+00:00 host sshd[2716]: pam_unix(sshd:auth): authentication failure; logname= uid=0 euid=0 tty=ssh ruser= rhost=192.0.2.112  user=root
+# failJSON: { "time": "2015-04-16T20:02:50", "match": true , "host": "192.0.2.112", "desc": "Should catch failure - no success/no accepted password" }
+2015-04-16T18:02:50.568798+00:00 host sshd[2716]: Connection closed by 192.0.2.112
+
+# disable this test-cases block for obsolete multi-line filter (zzz-sshd-obsolete...):
+# filterOptions: [{"test.condition":"name=='sshd'"}]
+
+# 2 methods auth: pam_unix and pam_ldap are used in combination (gh-2070), succeeded after "failure" in first method:
+# failJSON: { "match": false , "desc": "No failure" }
+Mar  7 18:53:20 bar sshd[1556]: pam_unix(sshd:auth): authentication failure; logname= uid=0 euid=0 tty=ssh ruser= rhost=192.0.2.113  user=rda
+# failJSON: { "match": false , "desc": "No failure" }
+Mar  7 18:53:20 bar sshd[1556]: pam_unix(sshd:auth): authentication failure; logname= uid=0 euid=0 tty=ssh ruser=rda rhost=192.0.2.113 [preauth]
+# failJSON: { "match": false , "desc": "No failure" }
+Mar  7 18:53:20 bar sshd[1556]: Accepted password for rda from 192.0.2.113 port 52100 ssh2
+# failJSON: { "match": false , "desc": "No failure" }
+Mar  7 18:53:20 bar sshd[1556]: pam_unix(sshd:session): session opened for user rda by (uid=0)
+# failJSON: { "match": false , "desc": "No failure" }
+Mar  7 18:53:20 bar sshd[1556]: Connection closed by 192.0.2.113
+
+# several attempts, intruder tries to "forget" failed attempts by success login (all 3 attempts with different users):
+# failJSON: { "match": false , "desc": "Still no failure (first try)" }
+Mar  7 18:53:22 bar sshd[1558]: pam_unix(sshd:auth): authentication failure; logname= uid=0 euid=0 tty=ssh ruser=root rhost=192.0.2.114
+# failJSON: { "time": "2005-03-07T18:53:23", "match": true , "attempts": 2, "users": ["root", "sudoer"], "host": "192.0.2.114", "desc": "Failure: attempt 2nd user" }
+Mar  7 18:53:23 bar sshd[1558]: pam_unix(sshd:auth): authentication failure; logname= uid=0 euid=0 tty=ssh ruser=sudoer rhost=192.0.2.114
+# failJSON: { "time": "2005-03-07T18:53:24", "match": true , "attempts": 2, "users": ["root", "sudoer", "known"], "host": "192.0.2.114", "desc": "Failure: attempt 3rd user" }
+Mar  7 18:53:24 bar sshd[1558]: Accepted password for known from 192.0.2.114 port 52100 ssh2
+# failJSON: { "match": false , "desc": "No failure" }
+Mar  7 18:53:24 bar sshd[1558]: pam_unix(sshd:session): session opened for user known by (uid=0)
+
+# several attempts, intruder tries to "forget" failed attempts by success login (accepted for other user as in first failed attempt):
+# failJSON: { "match": false , "desc": "Still no failure (first try)" }
+Mar  7 18:53:32 bar sshd[1559]: pam_unix(sshd:auth): authentication failure; logname= uid=0 euid=0 tty=ssh ruser=root rhost=192.0.2.116
+# failJSON: { "match": false , "desc": "Still no failure (second try, same user)" }
+Mar  7 18:53:32 bar sshd[1559]: pam_unix(sshd:auth): authentication failure; logname= uid=0 euid=0 tty=ssh ruser=root rhost=192.0.2.116
+# failJSON: { "time": "2005-03-07T18:53:34", "match": true , "attempts": 2, "users": ["root", "known"], "host": "192.0.2.116", "desc": "Failure: attempt 2nd user" }
+Mar  7 18:53:34 bar sshd[1559]: Accepted password for known from 192.0.2.116 port 52100 ssh2
+# failJSON: { "match": false , "desc": "No failure" }
+Mar  7 18:53:38 bar sshd[1559]: Connection closed by 192.0.2.116
 
 # filterOptions: [{"mode": "ddos"}, {"mode": "aggressive"}]
 
diff --git a/fail2ban/tests/samplestestcase.py b/fail2ban/tests/samplestestcase.py
--- a/fail2ban/tests/samplestestcase.py
+++ b/fail2ban/tests/samplestestcase.py
@@ -144,12 +144,14 @@ def testFilter(self):
 		regexsUsedRe = set()
 
 		# process each test-file (note: array filenames can grow during processing):
+		faildata = {}
 		i = 0
 		while i < len(filenames):
 			filename = filenames[i]; i += 1;
 			logFile = fileinput.FileInput(os.path.join(TEST_FILES_DIR, "logs",
 				filename))
 
+			ignoreBlock = False
 			for line in logFile:
 				jsonREMatch = re.match("^#+ ?(failJSON|filterOptions|addFILE):(.+)$", line)
 				if jsonREMatch:
@@ -159,9 +161,13 @@ def testFilter(self):
 						if jsonREMatch.group(1) == 'filterOptions':
 							# following lines with another filter options:
 							self._filterTests = []
+							ignoreBlock = False
 							for opts in (faildata if isinstance(faildata, list) else [faildata]):
 								# unique filter name (using options combination):
 								self.assertTrue(isinstance(opts, dict))
+								if opts.get('test.condition'):
+									ignoreBlock = not eval(opts.get('test.condition'))
+									del opts['test.condition']
 								fltName = opts.get('filterName')
 								if not fltName: fltName = str(opts) if opts else ''
 								fltName = name + fltName
@@ -178,10 +184,11 @@ def testFilter(self):
 						raise ValueError("%s: %s:%i" %
 							(e, logFile.filename(), logFile.filelineno()))
 					line = next(logFile)
-				elif line.startswith("#") or not line.strip():
+				elif ignoreBlock or line.startswith("#") or not line.strip():
 					continue
 				else: # pragma: no cover - normally unreachable
 					faildata = {}
+				if ignoreBlock: continue
 
 				# if filter options was not yet specified:
 				if not self._filterTests:
@@ -195,6 +202,7 @@ def testFilter(self):
 					regexList = flt.getFailRegex()
 
 					try:
+						fail = {}
 						ret = flt.processLine(line)
 						if not ret:
 							# Bypass if filter constraint specified:
@@ -222,9 +230,17 @@ def testFilter(self):
 						for k, v in faildata.iteritems():
 							if k not in ("time", "match", "desc", "filter"):
 								fv = fail.get(k, None)
-								# Fallback for backwards compatibility (previously no fid, was host only):
-								if k == "host" and fv is None:
-									fv = fid
+								if fv is None:
+									# Fallback for backwards compatibility (previously no fid, was host only):
+									if k == "host":
+										fv = fid
+									# special case for attempts counter:
+									if k == "attempts":
+										fv = len(fail.get('matches', {}))
+								# compare sorted (if set)
+								if isinstance(fv, (set, list, dict)):
+									self.assertSortedEqual(fv, v)
+									continue
 								self.assertEqual(fv, v)
 
 						t = faildata.get("time", None)
@@ -246,8 +262,12 @@ def testFilter(self):
 						regexsUsedIdx.add(failregex)
 						regexsUsedRe.add(regexList[failregex])
 					except AssertionError as e: # pragma: no cover
-						raise AssertionError("%s: %s on: %s:%i, line:\n%s" % (
-									fltName, e, logFile.filename(), logFile.filelineno(), line))
+						import pprint
+						raise AssertionError("%s: %s on: %s:%i, line:\n%s\n"
+							"faildata: %s\nfail: %s" % (
+								fltName, e, logFile.filename(), logFile.filelineno(), line,
+								'\n'.join(pprint.pformat(faildata).splitlines()),
+								'\n'.join(pprint.pformat(fail).splitlines())))
 
 		# check missing samples for regex using each filter-options combination:
 		for fltName, flt in self._filters.iteritems():