From df6d9dacf8027f2c3d1e246a8870674a7c42c7cc Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra Date: Sun, 15 Jan 2017 20:27:06 +0100 Subject: [PATCH] implement LoginAttemptHandler for scm-manager 2 --- .../sonia/scm/security/DAORealmHelper.java | 119 ++++++----- .../scm/security/DAORealmHelperFactory.java | 7 +- .../scm/security/LoginAttemptHandler.java | 77 ++++++++ .../java/sonia/scm/legacy/LegacyRealm.java | 76 +++---- .../sonia/scm/legacy/LegacyRealmTest.java | 4 + .../main/java/sonia/scm/ScmServletModule.java | 3 + .../java/sonia/scm/security/BearerRealm.java | 11 +- .../ConfigurableLoginAttemptHandler.java | 186 ++++++++++++++++++ .../java/sonia/scm/security/DefaultRealm.java | 2 +- .../sonia/scm/security/BearerRealmTest.java | 9 +- .../ConfigurableLoginAttemptHandlerTest.java | 114 +++++++++++ .../sonia/scm/security/DefaultRealmTest.java | 4 +- 12 files changed, 494 insertions(+), 118 deletions(-) create mode 100644 scm-core/src/main/java/sonia/scm/security/LoginAttemptHandler.java create mode 100644 scm-webapp/src/main/java/sonia/scm/security/ConfigurableLoginAttemptHandler.java create mode 100644 scm-webapp/src/test/java/sonia/scm/security/ConfigurableLoginAttemptHandlerTest.java diff --git a/scm-core/src/main/java/sonia/scm/security/DAORealmHelper.java b/scm-core/src/main/java/sonia/scm/security/DAORealmHelper.java index d080cbc85a..7309228edd 100644 --- a/scm-core/src/main/java/sonia/scm/security/DAORealmHelper.java +++ b/scm-core/src/main/java/sonia/scm/security/DAORealmHelper.java @@ -44,6 +44,7 @@ import org.apache.shiro.authc.DisabledAccountException; import org.apache.shiro.authc.SimpleAuthenticationInfo; import org.apache.shiro.authc.UnknownAccountException; import org.apache.shiro.authc.UsernamePasswordToken; +import org.apache.shiro.authc.credential.CredentialsMatcher; import org.apache.shiro.subject.SimplePrincipalCollection; import org.slf4j.Logger; @@ -70,21 +71,29 @@ public final class DAORealmHelper /** * the logger for DAORealmHelper */ - private static final Logger logger = - LoggerFactory.getLogger(DAORealmHelper.class); + private static final Logger LOG = LoggerFactory.getLogger(DAORealmHelper.class); + private final LoginAttemptHandler loginAttemptHandler; + + private final UserDAO userDAO; + + private final GroupDAO groupDAO; + + private final String realm; + //~--- constructors --------------------------------------------------------- - + /** - * Constructs ... + * Constructs a new instance. Consider to use {@link DAORealmHelperFactory} which + * handles dependency injection. * - * - * @param realm - * @param userDAO - * @param groupDAO + * @param loginAttemptHandler login attempt handler for wrapping credentials matcher + * @param userDAO user dao + * @param groupDAO group dao + * @param realm name of realm */ - public DAORealmHelper(String realm, UserDAO userDAO, GroupDAO groupDAO) - { + public DAORealmHelper(LoginAttemptHandler loginAttemptHandler, UserDAO userDAO, GroupDAO groupDAO, String realm) { + this.loginAttemptHandler = loginAttemptHandler; this.realm = realm; this.userDAO = userDAO; this.groupDAO = groupDAO; @@ -92,6 +101,17 @@ public final class DAORealmHelper //~--- get methods ---------------------------------------------------------- + /** + * Wraps credentials matcher and applies login attempt policies. + * + * @param credentialsMatcher credentials matcher to wrap + * + * @return wrapped credentials matcher + */ + public CredentialsMatcher wrapCredentialsMatcher(CredentialsMatcher credentialsMatcher) { + return new RetryLimitPasswordMatcher(loginAttemptHandler, credentialsMatcher); + } + /** * Method description * @@ -102,11 +122,8 @@ public final class DAORealmHelper * * @throws AuthenticationException */ - public AuthenticationInfo getAuthenticationInfo(AuthenticationToken token) - throws AuthenticationException - { - checkArgument(token instanceof UsernamePasswordToken, "%s is required", - UsernamePasswordToken.class); + public AuthenticationInfo getAuthenticationInfo(AuthenticationToken token) throws AuthenticationException { + checkArgument(token instanceof UsernamePasswordToken, "%s is required", UsernamePasswordToken.class); UsernamePasswordToken upt = (UsernamePasswordToken) token; String principal = upt.getUsername(); @@ -123,31 +140,18 @@ public final class DAORealmHelper * * @return */ - public AuthenticationInfo getAuthenticationInfo(String principal, - String credentials) - { + public AuthenticationInfo getAuthenticationInfo(String principal, String credentials) { checkArgument(!Strings.isNullOrEmpty(principal), "username is required"); - logger.debug("try to authenticate {}", principal); + LOG.debug("try to authenticate {}", principal); User user = userDAO.get(principal); - - if (user == null) - { - //J- - throw new UnknownAccountException( - String.format("unknown account %s", principal) - ); - //J+ + if (user == null) { + throw new UnknownAccountException(String.format("unknown account %s", principal)); } - if (!user.isActive()) - { - //J- - throw new DisabledAccountException( - String.format("account %s is disabled", principal) - ); - //J+ + if (!user.isActive()) { + throw new DisabledAccountException(String.format("account %s is disabled", principal)); } SimplePrincipalCollection collection = new SimplePrincipalCollection(); @@ -158,8 +162,7 @@ public final class DAORealmHelper String creds = credentials; - if (credentials == null) - { + if (credentials == null) { creds = user.getPassword(); } @@ -168,36 +171,44 @@ public final class DAORealmHelper //~--- methods -------------------------------------------------------------- - private GroupNames collectGroups(String principal) - { + private GroupNames collectGroups(String principal) { Builder builder = ImmutableSet.builder(); builder.add(GroupNames.AUTHENTICATED); - for (Group group : groupDAO.getAll()) - { - if (group.isMember(principal)) - { + for (Group group : groupDAO.getAll()) { + if (group.isMember(principal)) { builder.add(group.getName()); } } GroupNames groups = new GroupNames(builder.build()); - - logger.debug("collected following groups for principal {}: {}", principal, - groups); - + LOG.debug("collected following groups for principal {}: {}", principal, groups); return groups; } - //~--- fields --------------------------------------------------------------- + private static class RetryLimitPasswordMatcher implements CredentialsMatcher { - /** Field description */ - private final GroupDAO groupDAO; + private final LoginAttemptHandler loginAttemptHandler; + private final CredentialsMatcher credentialsMatcher; - /** Field description */ - private final String realm; - - /** Field description */ - private final UserDAO userDAO; + private RetryLimitPasswordMatcher(LoginAttemptHandler loginAttemptHandler, CredentialsMatcher credentialsMatcher) { + this.loginAttemptHandler = loginAttemptHandler; + this.credentialsMatcher = credentialsMatcher; + } + + @Override + public boolean doCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) { + loginAttemptHandler.beforeAuthentication(token); + boolean result = credentialsMatcher.doCredentialsMatch(token, info); + if ( result ) { + loginAttemptHandler.onSuccessfulAuthentication(token, info); + } else { + loginAttemptHandler.onUnsuccessfulAuthentication(token, info); + } + return result; + } + + } + } diff --git a/scm-core/src/main/java/sonia/scm/security/DAORealmHelperFactory.java b/scm-core/src/main/java/sonia/scm/security/DAORealmHelperFactory.java index 99d0195d5f..803ae5d714 100644 --- a/scm-core/src/main/java/sonia/scm/security/DAORealmHelperFactory.java +++ b/scm-core/src/main/java/sonia/scm/security/DAORealmHelperFactory.java @@ -42,17 +42,20 @@ import sonia.scm.user.UserDAO; */ public final class DAORealmHelperFactory { + private final LoginAttemptHandler loginAttemptHandler; private final UserDAO userDAO; private final GroupDAO groupDAO; /** * Constructs a new instance. * + * @param loginAttemptHandler login attempt handler * @param userDAO user dao * @param groupDAO group dao */ @Inject - public DAORealmHelperFactory(UserDAO userDAO, GroupDAO groupDAO) { + public DAORealmHelperFactory(LoginAttemptHandler loginAttemptHandler, UserDAO userDAO, GroupDAO groupDAO) { + this.loginAttemptHandler = loginAttemptHandler; this.userDAO = userDAO; this.groupDAO = groupDAO; } @@ -65,7 +68,7 @@ public final class DAORealmHelperFactory { * @return new {@link DAORealmHelper} instance. */ public DAORealmHelper create(String realm) { - return new DAORealmHelper(realm, userDAO, groupDAO); + return new DAORealmHelper(loginAttemptHandler, userDAO, groupDAO, realm); } } diff --git a/scm-core/src/main/java/sonia/scm/security/LoginAttemptHandler.java b/scm-core/src/main/java/sonia/scm/security/LoginAttemptHandler.java new file mode 100644 index 0000000000..1290522ee4 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/security/LoginAttemptHandler.java @@ -0,0 +1,77 @@ +/** + * Copyright (c) 2014, Sebastian Sdorra + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * 3. Neither the name of SCM-Manager; nor the names of its + * contributors may be used to endorse or promote products derived from this + * software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY + * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * + * http://bitbucket.org/sdorra/scm-manager + * + */ +package sonia.scm.security; + +import org.apache.shiro.authc.AuthenticationException; +import org.apache.shiro.authc.AuthenticationInfo; +import org.apache.shiro.authc.AuthenticationToken; + +/** + * Login attempt handler. + * + * @author Sebastian Sdorra + * @since 1.34 + */ +public interface LoginAttemptHandler +{ + + /** + * This method is called before the authentication procedure is invoked. + * + * @param token authentication token + * + * @throws AuthenticationException + */ + public void beforeAuthentication(AuthenticationToken token) throws AuthenticationException; + + /** + * Handle successful authentication. + * + * @param token authentication token + * @param info successful authentication result + * + * @throws AuthenticationException + */ + public void onSuccessfulAuthentication(AuthenticationToken token, AuthenticationInfo info) + throws AuthenticationException; + + /** + * Handle unsuccessful authentication. + * + * + * @param token authentication token + * @param info unsuccessful authentication result + * + * @throws AuthenticationException + */ + public void onUnsuccessfulAuthentication(AuthenticationToken token, AuthenticationInfo info) + throws AuthenticationException; +} \ No newline at end of file diff --git a/scm-plugins/scm-legacy-plugin/src/main/java/sonia/scm/legacy/LegacyRealm.java b/scm-plugins/scm-legacy-plugin/src/main/java/sonia/scm/legacy/LegacyRealm.java index a5b119acbc..be2b597aaf 100644 --- a/scm-plugins/scm-legacy-plugin/src/main/java/sonia/scm/legacy/LegacyRealm.java +++ b/scm-plugins/scm-legacy-plugin/src/main/java/sonia/scm/legacy/LegacyRealm.java @@ -73,30 +73,28 @@ public class LegacyRealm extends AuthenticatingRealm @VisibleForTesting static final String REALM = "LegacyRealm"; - /** Field description */ - //J- - private static final CharMatcher HEX_MATCHER = CharMatcher.inRange('0', '9') + private static final CharMatcher HEX_MATCHER = CharMatcher + .inRange('0', '9') .or(CharMatcher.inRange('a', 'f')) - .or(CharMatcher.inRange('A', 'F')); - //J+ + .or(CharMatcher.inRange('A', 'F') + ); /** - * the logger for LegacyRealm + * the logger for LegacyRealm */ - private static final Logger logger = - LoggerFactory.getLogger(LegacyRealm.class); + private static final Logger LOG = LoggerFactory.getLogger(LegacyRealm.class); + private final DAORealmHelper helper; + //~--- constructors --------------------------------------------------------- - + /** - * Constructs ... + * Constructs a new instance. * - * - * @param helperFactory + * @param helperFactory dao realm helper factory */ @Inject - public LegacyRealm(DAORealmHelperFactory helperFactory) - { + public LegacyRealm(DAORealmHelperFactory helperFactory) { this.helper = helperFactory.create(REALM); setAuthenticationTokenClass(UsernamePasswordToken.class); @@ -105,64 +103,36 @@ public class LegacyRealm extends AuthenticatingRealm matcher.setHashAlgorithmName(Sha1Hash.ALGORITHM_NAME); matcher.setHashIterations(1); matcher.setStoredCredentialsHexEncoded(true); - setCredentialsMatcher(matcher); + setCredentialsMatcher(helper.wrapCredentialsMatcher(matcher)); } //~--- methods -------------------------------------------------------------- - /** - * Method description - * - * - * @param token - * - * @return - * - * @throws AuthenticationException - */ @Override - protected AuthenticationInfo doGetAuthenticationInfo( - AuthenticationToken token) - throws AuthenticationException - { - Preconditions.checkArgument(token instanceof UsernamePasswordToken, - "unsupported token"); - + protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException { + Preconditions.checkArgument(token instanceof UsernamePasswordToken, "unsupported token"); return returnOnHexCredentials(helper.getAuthenticationInfo(token)); } - private AuthenticationInfo returnOnHexCredentials(AuthenticationInfo info) - { + private AuthenticationInfo returnOnHexCredentials(AuthenticationInfo info) { AuthenticationInfo result = null; - if (info != null) - { + if (info != null) { Object credentials = info.getCredentials(); - if (credentials instanceof String) - { + if (credentials instanceof String) { String password = (String) credentials; - if (HEX_MATCHER.matchesAllOf(password)) - { + if (HEX_MATCHER.matchesAllOf(password)) { result = info; + } else { + LOG.debug("hash contains non hex chars"); } - else - { - logger.debug("hash contains non hex chars"); - } - } - else - { - logger.debug("non string crendentials found"); + } else { + LOG.debug("non string crendentials found"); } } - return result; } - //~--- fields --------------------------------------------------------------- - - /** Field description */ - private final DAORealmHelper helper; } diff --git a/scm-plugins/scm-legacy-plugin/src/test/java/sonia/scm/legacy/LegacyRealmTest.java b/scm-plugins/scm-legacy-plugin/src/test/java/sonia/scm/legacy/LegacyRealmTest.java index fd6b482a93..f06c87293b 100644 --- a/scm-plugins/scm-legacy-plugin/src/test/java/sonia/scm/legacy/LegacyRealmTest.java +++ b/scm-plugins/scm-legacy-plugin/src/test/java/sonia/scm/legacy/LegacyRealmTest.java @@ -58,6 +58,7 @@ import org.junit.Before; import static org.mockito.Mockito.*; import sonia.scm.security.DAORealmHelperFactory; +import sonia.scm.security.LoginAttemptHandler; /** * @@ -144,6 +145,9 @@ public class LegacyRealmTest //~--- fields --------------------------------------------------------------- + @Mock + private LoginAttemptHandler loginAttemptHandler; + @Mock private UserDAO userDAO; diff --git a/scm-webapp/src/main/java/sonia/scm/ScmServletModule.java b/scm-webapp/src/main/java/sonia/scm/ScmServletModule.java index c621a3a7ec..8a577b6993 100644 --- a/scm-webapp/src/main/java/sonia/scm/ScmServletModule.java +++ b/scm-webapp/src/main/java/sonia/scm/ScmServletModule.java @@ -135,6 +135,8 @@ import sonia.scm.net.ahc.JsonContentTransformer; import sonia.scm.net.ahc.XmlContentTransformer; import sonia.scm.schedule.QuartzScheduler; import sonia.scm.schedule.Scheduler; +import sonia.scm.security.ConfigurableLoginAttemptHandler; +import sonia.scm.security.LoginAttemptHandler; import sonia.scm.web.UserAgentParser; /** @@ -273,6 +275,7 @@ public class ScmServletModule extends JerseyServletModule pluginLoader.getExtensionProcessor().processAutoBindExtensions(binder()); // bind security stuff + bind(LoginAttemptHandler.class).to(ConfigurableLoginAttemptHandler.class); bind(SecuritySystem.class).to(DefaultSecuritySystem.class); bind(AdministrationContext.class, DefaultAdministrationContext.class); diff --git a/scm-webapp/src/main/java/sonia/scm/security/BearerRealm.java b/scm-webapp/src/main/java/sonia/scm/security/BearerRealm.java index 8a3109e83a..e0e8b4b8c9 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/BearerRealm.java +++ b/scm-webapp/src/main/java/sonia/scm/security/BearerRealm.java @@ -84,18 +84,17 @@ public class BearerRealm extends AuthenticatingRealm /** * Constructs ... * - * + * @param helperFactory dao realm helper factory * @param resolver key resolver - * @param userDAO user dao - * @param groupDAO group dao * @param validators token claims validators */ @Inject - public BearerRealm(SecureKeyResolver resolver, UserDAO userDAO, - GroupDAO groupDAO, Set validators) + public BearerRealm( + DAORealmHelperFactory helperFactory, SecureKeyResolver resolver, Set validators + ) { + this.helper = helperFactory.create(REALM); this.resolver = resolver; - this.helper = new DAORealmHelper(REALM, userDAO, groupDAO); this.validators = validators; setCredentialsMatcher(new AllowAllCredentialsMatcher()); diff --git a/scm-webapp/src/main/java/sonia/scm/security/ConfigurableLoginAttemptHandler.java b/scm-webapp/src/main/java/sonia/scm/security/ConfigurableLoginAttemptHandler.java new file mode 100644 index 0000000000..39c3c64d5d --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/security/ConfigurableLoginAttemptHandler.java @@ -0,0 +1,186 @@ +/** + * Copyright (c) 2014, Sebastian Sdorra + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * 3. Neither the name of SCM-Manager; nor the names of its + * contributors may be used to endorse or promote products derived from this + * software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY + * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * + * http://bitbucket.org/sdorra/scm-manager + * + */ +package sonia.scm.security; + +import com.google.common.base.Objects; +import com.google.inject.Inject; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.TimeUnit; +import javax.inject.Singleton; +import org.apache.shiro.authc.AuthenticationException; +import org.apache.shiro.authc.AuthenticationInfo; +import org.apache.shiro.authc.AuthenticationToken; +import org.apache.shiro.authc.ExcessiveAttemptsException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sonia.scm.config.ScmConfiguration; + +/** + * Configurable implementation of {@link LoginAttemptHandler}. + * + * @author Sebastian Sdorra + * @since 1.34 + */ +@Singleton +public class ConfigurableLoginAttemptHandler implements LoginAttemptHandler { + + /** + * the logger for ConfigurableLoginAttemptHandler + */ + private static final Logger LOG = + LoggerFactory.getLogger(ConfigurableLoginAttemptHandler.class); + + //~--- fields --------------------------------------------------------------- + + private final ConcurrentMap attempts = new ConcurrentHashMap<>(); + + private final ScmConfiguration configuration; + + //~--- constructors --------------------------------------------------------- + + /** + * Constructs a new instance. + * + * @param configuration scm main configuration + */ + @Inject + public ConfigurableLoginAttemptHandler(ScmConfiguration configuration) { + this.configuration = configuration; + } + + //~--- methods -------------------------------------------------------------- + + @Override + public void beforeAuthentication(AuthenticationToken token) throws AuthenticationException { + if (isEnabled()) { + handleBeforeAuthentication(token); + } else { + LOG.trace("LoginAttemptHandler is disabled"); + } + } + + @Override + public void onSuccessfulAuthentication(AuthenticationToken token, AuthenticationInfo info) + throws AuthenticationException { + if (isEnabled()) { + handleOnSuccessfulAuthentication(token); + } else { + LOG.trace("LoginAttemptHandler is disabled"); + } + } + + @Override + public void onUnsuccessfulAuthentication(AuthenticationToken token, AuthenticationInfo info) + throws AuthenticationException { + if (isEnabled()) { + handleOnUnsuccessfulAuthentication(token); + } else { + LOG.trace("LoginAttemptHandler is disabled"); + } + } + + private void handleBeforeAuthentication(AuthenticationToken token) { + LoginAttempt attempt = getAttempt(token); + long time = System.currentTimeMillis() - attempt.lastAttempt; + + if (time > getLoginAttemptLimitTimeout()) { + LOG.debug("login attempts {} of {} are timetout", attempt, token.getPrincipal()); + attempt.reset(); + } else if (attempt.counter >= configuration.getLoginAttemptLimit()) { + LOG.warn("account {} is temporary locked, because of {}", token.getPrincipal(), attempt); + attempt.increase(); + + throw new ExcessiveAttemptsException("account is temporary locked"); + } + } + + private void handleOnSuccessfulAuthentication(AuthenticationToken token) throws AuthenticationException { + LoginAttempt attempt = getAttempt(token); + LOG.debug("reset login attempts {} for {}, because of successful login", attempt, token.getPrincipal()); + attempt.reset(); + } + + private void handleOnUnsuccessfulAuthentication(AuthenticationToken token) throws AuthenticationException { + LoginAttempt attempt = getAttempt(token); + LOG.debug("increase failed login attempts {} for {}", attempt, token.getPrincipal()); + attempt.increase(); + } + + //~--- get methods ---------------------------------------------------------- + + private LoginAttempt getAttempt(AuthenticationToken token){ + LoginAttempt freshAttempt = new LoginAttempt(); + LoginAttempt attempt = attempts.putIfAbsent(token.getPrincipal(), freshAttempt); + + if (attempt == null) { + attempt = freshAttempt; + } + + return attempt; + } + + private long getLoginAttemptLimitTimeout() { + return TimeUnit.SECONDS.toMillis( configuration.getLoginAttemptLimitTimeout() ); + } + + private boolean isEnabled() { + return (configuration.getLoginAttemptLimit() > 0) && (configuration.getLoginAttemptLimitTimeout() > 0l); + } + + //~--- inner classes -------------------------------------------------------- + + private static class LoginAttempt { + + private int counter = 0; + private long lastAttempt = -1l; + + synchronized void increase() { + counter++; + lastAttempt = System.currentTimeMillis(); + } + + synchronized void reset() { + lastAttempt = -1l; + counter = 0; + } + + @Override + public String toString() { + return Objects.toStringHelper(this) + .add("counter", counter) + .add("lastAttempt", lastAttempt) + .toString(); + } + + } + +} diff --git a/scm-webapp/src/main/java/sonia/scm/security/DefaultRealm.java b/scm-webapp/src/main/java/sonia/scm/security/DefaultRealm.java index db4dc17950..9981493f7d 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/DefaultRealm.java +++ b/scm-webapp/src/main/java/sonia/scm/security/DefaultRealm.java @@ -89,7 +89,7 @@ public class DefaultRealm extends AuthorizingRealm PasswordMatcher matcher = new PasswordMatcher(); matcher.setPasswordService(service); - setCredentialsMatcher(matcher); + setCredentialsMatcher(helper.wrapCredentialsMatcher(matcher)); setAuthenticationTokenClass(UsernamePasswordToken.class); } diff --git a/scm-webapp/src/test/java/sonia/scm/security/BearerRealmTest.java b/scm-webapp/src/test/java/sonia/scm/security/BearerRealmTest.java index 6e99a057d1..e873e72f79 100644 --- a/scm-webapp/src/test/java/sonia/scm/security/BearerRealmTest.java +++ b/scm-webapp/src/test/java/sonia/scm/security/BearerRealmTest.java @@ -73,6 +73,7 @@ import javax.crypto.spec.SecretKeySpec; import org.hamcrest.Matchers; import org.junit.Rule; import org.junit.rules.ExpectedException; +import org.mockito.InjectMocks; import org.mockito.Mockito; /** @@ -215,7 +216,7 @@ public class BearerRealmTest { when(validator.validate(Mockito.anyMap())).thenReturn(true); Set validators = Sets.newHashSet(validator); - realm = new BearerRealm(keyResolver, userDAO, groupDAO, validators); + realm = new BearerRealm(helperFactory, keyResolver, validators); } //~--- methods -------------------------------------------------------------- @@ -300,6 +301,12 @@ public class BearerRealmTest /** Field description */ private final SecureRandom random = new SecureRandom(); + @InjectMocks + private DAORealmHelperFactory helperFactory; + + @Mock + private LoginAttemptHandler loginAttemptHandler; + @Mock private TokenClaimsValidator validator; diff --git a/scm-webapp/src/test/java/sonia/scm/security/ConfigurableLoginAttemptHandlerTest.java b/scm-webapp/src/test/java/sonia/scm/security/ConfigurableLoginAttemptHandlerTest.java new file mode 100644 index 0000000000..714438b8fe --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/security/ConfigurableLoginAttemptHandlerTest.java @@ -0,0 +1,114 @@ +/** + * Copyright (c) 2014, Sebastian Sdorra + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * 3. Neither the name of SCM-Manager; nor the names of its + * contributors may be used to endorse or promote products derived from this + * software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY + * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * + * http://bitbucket.org/sdorra/scm-manager + * + */ + +package sonia.scm.security; + +import java.util.concurrent.TimeUnit; +import org.apache.shiro.authc.ExcessiveAttemptsException; +import org.apache.shiro.authc.SimpleAuthenticationInfo; +import org.apache.shiro.authc.UsernamePasswordToken; +import org.junit.Test; +import sonia.scm.config.ScmConfiguration; + +/** + * Unit tests for {@link ConfigurableLoginAttemptHandler}. + * + * @author Sebastian Sdorra + */ +public class ConfigurableLoginAttemptHandlerTest { + + /** + * Tests login attempt limit reached. + */ + @Test(expected = ExcessiveAttemptsException.class) + public void testLoginAttemptLimitReached() { + LoginAttemptHandler handler = createHandler(2, 2); + UsernamePasswordToken token = new UsernamePasswordToken("hansolo", "hobbo"); + + handler.beforeAuthentication(token); + handler.onUnsuccessfulAuthentication(token, new SimpleAuthenticationInfo()); + handler.beforeAuthentication(token); + handler.onUnsuccessfulAuthentication(token, new SimpleAuthenticationInfo()); + handler.beforeAuthentication(token); + } + + /** + * Tests login attempts limit timeout. + * + * @throws InterruptedException + */ + @Test + public void testLoginAttemptLimitTimeout() throws InterruptedException { + LoginAttemptHandler handler = createHandler(2, 1); + UsernamePasswordToken token = new UsernamePasswordToken("hansolo", "hobbo"); + + handler.beforeAuthentication(token); + handler.onUnsuccessfulAuthentication(token, new SimpleAuthenticationInfo()); + handler.beforeAuthentication(token); + handler.onUnsuccessfulAuthentication(token, new SimpleAuthenticationInfo()); + Thread.sleep(TimeUnit.MILLISECONDS.toMillis(1200l)); + handler.beforeAuthentication(token); + } + + /** + * Tests login attempt limit reset on success + * + * + * @throws InterruptedException + */ + @Test + public void testLoginAttemptResetOnSuccess() throws InterruptedException { + LoginAttemptHandler handler = createHandler(2, 1); + UsernamePasswordToken token = new UsernamePasswordToken("hansolo", "hobbo"); + + handler.beforeAuthentication(token); + handler.onUnsuccessfulAuthentication(token, new SimpleAuthenticationInfo()); + handler.beforeAuthentication(token); + handler.onUnsuccessfulAuthentication(token, new SimpleAuthenticationInfo()); + + handler.onSuccessfulAuthentication(token, new SimpleAuthenticationInfo()); + + handler.beforeAuthentication(token); + handler.onUnsuccessfulAuthentication(token, new SimpleAuthenticationInfo()); + handler.beforeAuthentication(token); + handler.onUnsuccessfulAuthentication(token, new SimpleAuthenticationInfo()); + } + + private LoginAttemptHandler createHandler(int loginAttemptLimit, long loginAttemptLimitTimeout) { + ScmConfiguration configuration = new ScmConfiguration(); + + configuration.setLoginAttemptLimit(loginAttemptLimit); + configuration.setLoginAttemptLimitTimeout(loginAttemptLimitTimeout); + + return new ConfigurableLoginAttemptHandler(configuration); + } + +} \ No newline at end of file diff --git a/scm-webapp/src/test/java/sonia/scm/security/DefaultRealmTest.java b/scm-webapp/src/test/java/sonia/scm/security/DefaultRealmTest.java index 3ea6c55a5e..dfbc13e428 100644 --- a/scm-webapp/src/test/java/sonia/scm/security/DefaultRealmTest.java +++ b/scm-webapp/src/test/java/sonia/scm/security/DefaultRealmTest.java @@ -293,7 +293,9 @@ public class DefaultRealmTest @Mock private DefaultAuthorizationCollector collector; - /** Field description */ + @Mock + private LoginAttemptHandler loginAttemptHandler; + @Mock private GroupDAO groupDAO;