From 3ec499d22cfccbef47928973faddfa3a659fffcf Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch Date: Wed, 24 Mar 2021 08:50:14 +0100 Subject: [PATCH] Authentication metrics (#1595) Expose metrics about: - User login attempts - Failed user logins - User logouts - General successful accesses to SCM-Manager via any authentication realm - General failed accesses to SCM-Manager Co-authored-by: Sebastian Sdorra --- gradle/changelog/authentication_metrics.yaml | 2 + .../main/java/sonia/scm/metrics/Metrics.java | 1 - .../java/sonia/scm/legacy/LegacyRealm.java | 22 +- .../sonia/scm/legacy/LegacyRealmTest.java | 95 ++----- .../v2/resources/AuthenticationResource.java | 21 +- .../scm/metrics/AuthenticationMetrics.java | 139 ++++++++++ .../sonia/scm/security/AnonymousRealm.java | 6 - .../java/sonia/scm/security/BearerRealm.java | 10 +- .../java/sonia/scm/security/DefaultRealm.java | 76 +----- .../ScmAtLeastOneSuccessfulStrategy.java | 21 ++ .../scm/web/security/TokenRefreshFilter.java | 16 +- .../resources/AuthenticationResourceTest.java | 42 ++- .../scm/security/AnonymousRealmTest.java | 2 - .../sonia/scm/security/BearerRealmTest.java | 2 - .../sonia/scm/security/DefaultRealmTest.java | 248 +++++------------- .../ScmAtLeastOneSuccessfulStrategyTest.java | 68 ++++- .../web/security/TokenRefreshFilterTest.java | 29 +- 17 files changed, 425 insertions(+), 375 deletions(-) create mode 100644 gradle/changelog/authentication_metrics.yaml create mode 100644 scm-webapp/src/main/java/sonia/scm/metrics/AuthenticationMetrics.java diff --git a/gradle/changelog/authentication_metrics.yaml b/gradle/changelog/authentication_metrics.yaml new file mode 100644 index 0000000000..0b2adf528a --- /dev/null +++ b/gradle/changelog/authentication_metrics.yaml @@ -0,0 +1,2 @@ +- type: added + description: Authentication and access metrics ([#1595](https://github.com/scm-manager/scm-manager/pull/1595)) diff --git a/scm-core/src/main/java/sonia/scm/metrics/Metrics.java b/scm-core/src/main/java/sonia/scm/metrics/Metrics.java index 7ca4d1a47f..ba615eba91 100644 --- a/scm-core/src/main/java/sonia/scm/metrics/Metrics.java +++ b/scm-core/src/main/java/sonia/scm/metrics/Metrics.java @@ -56,5 +56,4 @@ public final class Metrics { Collections.singleton(Tag.of("type", type)) ).bindTo(registry); } - } 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 3bae36d9e1..60fab3b9a6 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 @@ -21,15 +21,12 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - -package sonia.scm.legacy; -//~--- non-JDK imports -------------------------------------------------------- +package sonia.scm.legacy; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.CharMatcher; import com.google.common.base.Preconditions; - import org.apache.shiro.authc.AuthenticationException; import org.apache.shiro.authc.AuthenticationInfo; import org.apache.shiro.authc.AuthenticationToken; @@ -37,17 +34,12 @@ import org.apache.shiro.authc.UsernamePasswordToken; import org.apache.shiro.authc.credential.HashedCredentialsMatcher; import org.apache.shiro.crypto.hash.Sha1Hash; import org.apache.shiro.realm.AuthenticatingRealm; - import org.slf4j.Logger; import org.slf4j.LoggerFactory; - import sonia.scm.plugin.Extension; - import sonia.scm.security.DAORealmHelper; import sonia.scm.security.DAORealmHelperFactory; -//~--- JDK imports ------------------------------------------------------------ - import javax.inject.Inject; import javax.inject.Singleton; @@ -59,10 +51,8 @@ import javax.inject.Singleton; */ @Extension @Singleton -public class LegacyRealm extends AuthenticatingRealm -{ +public class LegacyRealm extends AuthenticatingRealm { - /** Field description */ @VisibleForTesting static final String REALM = "LegacyRealm"; @@ -70,7 +60,7 @@ public class LegacyRealm extends AuthenticatingRealm .inRange('0', '9') .or(CharMatcher.inRange('a', 'f')) .or(CharMatcher.inRange('A', 'F') - ); + ); /** * the logger for LegacyRealm @@ -78,9 +68,7 @@ public class LegacyRealm extends AuthenticatingRealm private static final Logger LOG = LoggerFactory.getLogger(LegacyRealm.class); private final DAORealmHelper helper; - - //~--- constructors --------------------------------------------------------- - + /** * Constructs a new instance. * @@ -99,8 +87,6 @@ public class LegacyRealm extends AuthenticatingRealm setCredentialsMatcher(helper.wrapCredentialsMatcher(matcher)); } - //~--- methods -------------------------------------------------------------- - @Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException { Preconditions.checkArgument(token instanceof UsernamePasswordToken, "unsupported token"); 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 04a2b3a835..1eacfd12c1 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 @@ -21,65 +21,60 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.legacy; -//~--- non-JDK imports -------------------------------------------------------- import org.apache.shiro.authc.AuthenticationInfo; import org.apache.shiro.authc.AuthenticationToken; import org.apache.shiro.authc.UsernamePasswordToken; import org.apache.shiro.crypto.hash.Sha1Hash; - +import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; - import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.MockitoJUnitRunner; - import sonia.scm.group.GroupDAO; import sonia.scm.security.BearerToken; +import sonia.scm.security.DAORealmHelperFactory; +import sonia.scm.security.LoginAttemptHandler; import sonia.scm.user.User; import sonia.scm.user.UserDAO; import sonia.scm.user.UserTestData; -import static org.junit.Assert.*; -import org.junit.Before; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.mockito.Mockito.when; -import static org.mockito.Mockito.*; -import sonia.scm.security.DAORealmHelperFactory; -import sonia.scm.security.LoginAttemptHandler; - -/** - * - * @author Sebastian Sdorra - */ @RunWith(MockitoJUnitRunner.class) -public class LegacyRealmTest -{ +public class LegacyRealmTest { - /** Field description */ private static final String NEW_PASSWORD = "$shiro1$SHA-512$8192$$yrNahBVDa4Gz+y5gat4msdjyvjtHlVE+N5nTl4WIDhtBFwhSIib13mKJt1sWmVqgHDWi3VwX7fkdkJ2+WToTbw=="; - //~--- methods -------------------------------------------------------------- + @Mock + private LoginAttemptHandler loginAttemptHandler; + + @Mock + private UserDAO userDAO; + + @Mock + private GroupDAO groupDAO; + + @InjectMocks + private DAORealmHelperFactory helperFactory; + + private LegacyRealm realm; - /** - * Prepare object under test. - */ @Before public void prepareObjectUnderTest() { - this.realm = new LegacyRealm(helperFactory); + realm = new LegacyRealm(helperFactory); } - - /** - * Method description - * - */ + @Test - public void testDoGetAuthenticationInfo() - { + public void testDoGetAuthenticationInfo() { User user = UserTestData.createTrillian(); user.setPassword(new Sha1Hash("secret").toHex()); @@ -92,31 +87,21 @@ public class LegacyRealmTest assertEquals("tricia", authInfo.getPrincipals().getPrimaryPrincipal()); } - /** - * Method description - * - */ @Test - public void testDoGetAuthenticationInfoWithNewPasswords() - { + public void testDoGetAuthenticationInfoWithNewPasswords() { User user = UserTestData.createTrillian(); user.setPassword(NEW_PASSWORD); when(userDAO.get("tricia")).thenReturn(user); AuthenticationToken token = new UsernamePasswordToken("tricia", - NEW_PASSWORD); + NEW_PASSWORD); assertNull(realm.doGetAuthenticationInfo(token)); } - /** - * Method description - * - */ @Test - public void testDoGetAuthenticationInfoWithNullPassword() - { + public void testDoGetAuthenticationInfoWithNullPassword() { when(userDAO.get("tricia")).thenReturn(UserTestData.createTrillian()); AuthenticationToken token = new UsernamePasswordToken("tricia", "secret"); @@ -124,30 +109,8 @@ public class LegacyRealmTest assertNull(realm.doGetAuthenticationInfo(token)); } - /** - * Method description - * - */ @Test(expected = IllegalArgumentException.class) - public void testDoGetAuthenticationInfoWrongToken() - { + public void testDoGetAuthenticationInfoWrongToken() { realm.doGetAuthenticationInfo(BearerToken.valueOf("test")); } - - //~--- fields --------------------------------------------------------------- - - @Mock - private LoginAttemptHandler loginAttemptHandler; - - @Mock - private UserDAO userDAO; - - @Mock - private GroupDAO groupDAO; - - @InjectMocks - private DAORealmHelperFactory helperFactory; - - private LegacyRealm realm; - } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AuthenticationResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AuthenticationResource.java index 173774f7e0..3c4a92cdd1 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AuthenticationResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AuthenticationResource.java @@ -25,6 +25,8 @@ package sonia.scm.api.v2.resources; import com.google.inject.Inject; +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; import io.swagger.v3.oas.annotations.OpenAPIDefinition; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.enums.SecuritySchemeType; @@ -41,6 +43,7 @@ import org.apache.shiro.authc.AuthenticationException; import org.apache.shiro.subject.Subject; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import sonia.scm.metrics.AuthenticationMetrics; import sonia.scm.security.AccessToken; import sonia.scm.security.AccessTokenBuilder; import sonia.scm.security.AccessTokenBuilderFactory; @@ -89,17 +92,24 @@ public class AuthenticationResource { private static final Logger LOG = LoggerFactory.getLogger(AuthenticationResource.class); static final String PATH = "v2/auth"; + private static final String AUTH_METRIC_TYPE = "UI/REST"; private final AccessTokenBuilderFactory tokenBuilderFactory; private final AccessTokenCookieIssuer cookieIssuer; + private final Counter loginAttemptsCounter; + private final Counter loginFailedCounter; + private final Counter logoutCounter; @Inject(optional = true) private LogoutRedirection logoutRedirection; @Inject - public AuthenticationResource(AccessTokenBuilderFactory tokenBuilderFactory, AccessTokenCookieIssuer cookieIssuer) { + public AuthenticationResource(AccessTokenBuilderFactory tokenBuilderFactory, AccessTokenCookieIssuer cookieIssuer, MeterRegistry meterRegistry) { this.tokenBuilderFactory = tokenBuilderFactory; this.cookieIssuer = cookieIssuer; + this.loginAttemptsCounter = AuthenticationMetrics.loginAttempts(meterRegistry, AUTH_METRIC_TYPE); + this.loginFailedCounter = AuthenticationMetrics.loginFailed(meterRegistry, AUTH_METRIC_TYPE); + this.logoutCounter = AuthenticationMetrics.logout(meterRegistry, AUTH_METRIC_TYPE); } @POST @@ -177,7 +187,10 @@ public class AuthenticationResource { HttpServletResponse response, AuthenticationRequestDto authentication ) { + loginAttemptsCounter.increment(); + if (!authentication.isValid()) { + loginFailedCounter.increment(); return Response.status(Response.Status.BAD_REQUEST).build(); } @@ -201,6 +214,7 @@ public class AuthenticationResource { res = Response.ok(token.compact()).build(); } } catch (AuthenticationException ex) { + loginFailedCounter.increment(); if (LOG.isTraceEnabled()) { LOG.trace("authentication failed for user ".concat(authentication.getUsername()), ex); } else { @@ -222,10 +236,9 @@ public class AuthenticationResource { @ApiResponse(responseCode = "204", description = "success") @ApiResponse(responseCode = "500", description = "internal server error") public Response logout(@Context HttpServletRequest request, @Context HttpServletResponse response) { - Subject subject = SecurityUtils.getSubject(); - - subject.logout(); + logoutCounter.increment(); + SecurityUtils.getSubject().logout(); // remove authentication cookie cookieIssuer.invalidate(request, response); diff --git a/scm-webapp/src/main/java/sonia/scm/metrics/AuthenticationMetrics.java b/scm-webapp/src/main/java/sonia/scm/metrics/AuthenticationMetrics.java new file mode 100644 index 0000000000..c6d69931a1 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/metrics/AuthenticationMetrics.java @@ -0,0 +1,139 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.metrics; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; + +public class AuthenticationMetrics { + + private AuthenticationMetrics() { + } + + /** + * Creates counter to track amount of login attempts to SCM-Manager. + * + * @param registry meter registry + * @param type type of login e.g.: api_key, bearer_token, etc. + * @return new {@link Counter} + */ + public static Counter loginAttempts(MeterRegistry registry, String type) { + return Counter + .builder("scm.auth.login.attempts") + .description("The amount of login attempts to SCM-Manager") + .tags("type", type) + .register(registry); + } + + /** + * Creates counter to track amount of failed logins to SCM-Manager. + * + * @param registry meter registry + * @param type type of failed login, e.g.: UI/REST + * @return new {@link Counter} + */ + public static Counter loginFailed(MeterRegistry registry, String type) { + return Counter + .builder("scm.auth.login.failed") + .tags("type", type) + .description("The amount of failed logins to SCM-Manager") + .register(registry); + } + + /** + * Creates counter to track amount of logouts to SCM-Manager. + * + * @param registry meter registry + * @param type type of logout, e.g.: UI/REST + * @return new {@link Counter} + */ + public static Counter logout(MeterRegistry registry, String type) { + return Counter + .builder("scm.auth.logout") + .description("The amount of logouts from SCM-Manager") + .tags("type", type) + .register(registry); + } + + /** + * Creates counter to track amount of token refreshes by SCM-Manager. + * + * @param registry meter registry + * @param type type of refreshed token, e.g.: JWT + * @return new {@link Counter} + */ + public static Counter tokenRefresh(MeterRegistry registry, String type) { + return Counter + .builder("scm.auth.token.refresh") + .description("The amount of authentication token refreshes") + .tags("type", type) + .register(registry); + } + + /** + * Creates counter to track amount of successful accesses to SCM-Manager realms with token. + * + * @param registry meter registry + * @param realm type of realm e.g.: {@link sonia.scm.security.BearerRealm} + * @param token type of token e.g.: {@link org.apache.shiro.authc.UsernamePasswordToken}, + * @return new {@link Counter} + */ + public static Counter accessRealmSuccessful(MeterRegistry registry, String realm, String token) { + return Counter + .builder("scm.auth.realm.successful") + .description("The amount of successful login to the realm") + .tags("realm", realm, "token", token) + .register(registry); + } + + /** + * Creates counter to track amount of successful accesses to SCM-Manager. + * + * @param registry meter registry + * @return new {@link Counter} + */ + public static Counter accessSuccessful(MeterRegistry registry, String tokenType) { + return Counter + .builder("scm.auth.access.successful") + .description("The amount of successful accesses to SCM-Manager") + .tags("token", tokenType) + .register(registry); + } + + /** + * Creates counter to track amount of failed accesses to SCM-Manager. + * + * @param registry meter registry + * @return new {@link Counter} + */ + public static Counter accessFailed(MeterRegistry registry, String tokenType) { + return Counter + .builder("scm.auth.access.failed") + .description("The amount of failed accesses to SCM-Manager") + .tags("token", tokenType) + .register(registry); + } + +} diff --git a/scm-webapp/src/main/java/sonia/scm/security/AnonymousRealm.java b/scm-webapp/src/main/java/sonia/scm/security/AnonymousRealm.java index ab8d9dd88b..b5dc06c5e4 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/AnonymousRealm.java +++ b/scm-webapp/src/main/java/sonia/scm/security/AnonymousRealm.java @@ -43,15 +43,9 @@ import static com.google.common.base.Preconditions.checkArgument; @Extension public class AnonymousRealm extends AuthenticatingRealm { - /** - * realm name - */ @VisibleForTesting static final String REALM = "AnonymousRealm"; - /** - * dao realm helper - */ private final DAORealmHelper helper; private final UserDAO userDAO; 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 f554c92d04..a587d35709 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/BearerRealm.java +++ b/scm-webapp/src/main/java/sonia/scm/security/BearerRealm.java @@ -49,19 +49,14 @@ import static com.google.common.base.Preconditions.checkArgument; */ @Singleton @Extension -public class BearerRealm extends AuthenticatingRealm -{ +public class BearerRealm extends AuthenticatingRealm { - /** realm name */ @VisibleForTesting static final String REALM = "BearerRealm"; private static final Logger LOG = LoggerFactory.getLogger(BearerRealm.class); - /** dao realm helper */ private final DAORealmHelper helper; - - /** access token resolver **/ private final AccessTokenResolver tokenResolver; /** @@ -95,9 +90,7 @@ public class BearerRealm extends AuthenticatingRealm * Validates the given bearer token and retrieves authentication data from * {@link UserDAO} and {@link GroupDAO}. * - * * @param token bearer token - * * @return authentication data from user and group dao */ @Override @@ -114,5 +107,4 @@ public class BearerRealm extends AuthenticatingRealm .withSessionId(bt.getPrincipal()) .build(); } - } 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 864aac3c98..63696bc6c4 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/DefaultRealm.java +++ b/scm-webapp/src/main/java/sonia/scm/security/DefaultRealm.java @@ -45,8 +45,6 @@ import javax.inject.Inject; import javax.inject.Singleton; import java.util.Set; -//~--- JDK imports ------------------------------------------------------------ - /** * Default authorizing realm. * @@ -55,34 +53,21 @@ import java.util.Set; */ @Extension @Singleton -public class DefaultRealm extends AuthorizingRealm -{ +public class DefaultRealm extends AuthorizingRealm { private static final String SEPARATOR = System.getProperty("line.separator", "\n"); - - /** - * the logger for DefaultRealm - */ private static final Logger LOG = LoggerFactory.getLogger(DefaultRealm.class); - /** Field description */ @VisibleForTesting static final String REALM = "DefaultRealm"; private final ScmPermissionResolver permissionResolver; + private final Set authorizationCollectors; + private final DAORealmHelper helper; - //~--- constructors --------------------------------------------------------- - - /** - * Constructs ... - * - * - * @param service - * @param authorizationCollectors - * @param helperFactory - */ @Inject - public DefaultRealm(PasswordService service, Set authorizationCollectors, DAORealmHelperFactory helperFactory) - { + public DefaultRealm(PasswordService service, + Set authorizationCollectors, + DAORealmHelperFactory helperFactory) { this.authorizationCollectors = authorizationCollectors; this.helper = helperFactory.create(REALM); @@ -103,40 +88,16 @@ public class DefaultRealm extends AuthorizingRealm return permissionResolver; } - //~--- methods -------------------------------------------------------------- - - /** - * Method description - * - * - * @param token - * - * @return - * - * @throws AuthenticationException - */ @Override - protected AuthenticationInfo doGetAuthenticationInfo( - AuthenticationToken token) - throws AuthenticationException - { + protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException { return helper.getAuthenticationInfo(token); } - /** - * Method description - * - * - * @param principals - * - * @return - */ @Override - protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) - { + protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { AuthorizationInfo info = collectors(principals); Scope scope = principals.oneByType(Scope.class); - if (scope != null && ! scope.isEmpty()) { + if (scope != null && !scope.isEmpty()) { LOG.trace("filter permissions by scope {}", scope); AuthorizationInfo filtered = Scopes.filter(getPermissionResolver(), info, scope); if (LOG.isTraceEnabled()) { @@ -174,7 +135,7 @@ public class DefaultRealm extends AuthorizingRealm } } - private void log( PrincipalCollection collection, AuthorizationInfo original, AuthorizationInfo filtered ) { + private void log(PrincipalCollection collection, AuthorizationInfo original, AuthorizationInfo filtered) { StringBuilder buffer = new StringBuilder("authorization summary: "); buffer.append(SEPARATOR).append("username : ").append(collection.getPrimaryPrincipal()); @@ -183,7 +144,7 @@ public class DefaultRealm extends AuthorizingRealm buffer.append(SEPARATOR).append("scope : "); append(buffer, collection.oneByType(Scope.class)); - if ( filtered != null ) { + if (filtered != null) { buffer.append(SEPARATOR).append("permissions (filtered by scope): "); append(buffer, filtered); buffer.append(SEPARATOR).append("permissions (unfiltered): "); @@ -200,20 +161,11 @@ public class DefaultRealm extends AuthorizingRealm append(buffer, authz.getObjectPermissions()); } - private void append(StringBuilder buffer, Iterable iterable){ - if (iterable != null){ - for ( Object item : iterable ) - { + private void append(StringBuilder buffer, Iterable iterable) { + if (iterable != null) { + for (Object item : iterable) { buffer.append(SEPARATOR).append(" - ").append(item); } } } - - //~--- fields --------------------------------------------------------------- - - /** set of authorization collector */ - private final Set authorizationCollectors; - - /** realm helper */ - private final DAORealmHelper helper; } diff --git a/scm-webapp/src/main/java/sonia/scm/security/ScmAtLeastOneSuccessfulStrategy.java b/scm-webapp/src/main/java/sonia/scm/security/ScmAtLeastOneSuccessfulStrategy.java index 818777e671..c470829be4 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/ScmAtLeastOneSuccessfulStrategy.java +++ b/scm-webapp/src/main/java/sonia/scm/security/ScmAtLeastOneSuccessfulStrategy.java @@ -24,13 +24,16 @@ package sonia.scm.security; +import io.micrometer.core.instrument.MeterRegistry; import org.apache.shiro.authc.AuthenticationException; import org.apache.shiro.authc.AuthenticationInfo; import org.apache.shiro.authc.AuthenticationToken; import org.apache.shiro.authc.pam.AbstractAuthenticationStrategy; import org.apache.shiro.realm.Realm; import org.apache.shiro.subject.PrincipalCollection; +import sonia.scm.metrics.AuthenticationMetrics; +import javax.inject.Inject; import java.util.ArrayList; import java.util.Collection; import java.util.List; @@ -40,6 +43,13 @@ public class ScmAtLeastOneSuccessfulStrategy extends AbstractAuthenticationStrat final ThreadLocal> threadLocal = new ThreadLocal<>(); + private final MeterRegistry meterRegistry; + + @Inject + public ScmAtLeastOneSuccessfulStrategy(MeterRegistry meterRegistry) { + this.meterRegistry = meterRegistry; + } + @Override public AuthenticationInfo beforeAllAttempts(Collection realms, AuthenticationToken token) throws AuthenticationException { this.threadLocal.set(new ArrayList<>()); @@ -51,6 +61,11 @@ public class ScmAtLeastOneSuccessfulStrategy extends AbstractAuthenticationStrat if (t != null) { this.threadLocal.get().add(t); } + + if (isAuthenticationSuccessful(singleRealmInfo)) { + AuthenticationMetrics.accessRealmSuccessful(meterRegistry, realm.getClass().getName(), token.getClass().getName()).increment(); + } + return super.afterAttempt(realm, token, singleRealmInfo, aggregateInfo, t); } @@ -58,9 +73,15 @@ public class ScmAtLeastOneSuccessfulStrategy extends AbstractAuthenticationStrat public AuthenticationInfo afterAllAttempts(AuthenticationToken token, AuthenticationInfo aggregate) { final List throwables = threadLocal.get(); threadLocal.remove(); + + String tokenType = token.getClass().getName(); + if (isAuthenticationSuccessful(aggregate)) { + AuthenticationMetrics.accessSuccessful(meterRegistry, tokenType).increment(); return aggregate; } + AuthenticationMetrics.accessFailed(meterRegistry, tokenType).increment(); + Optional specializedException = findSpecializedException(throwables); if (specializedException.isPresent()) { diff --git a/scm-webapp/src/main/java/sonia/scm/web/security/TokenRefreshFilter.java b/scm-webapp/src/main/java/sonia/scm/web/security/TokenRefreshFilter.java index be691da1d8..8023ca6743 100644 --- a/scm-webapp/src/main/java/sonia/scm/web/security/TokenRefreshFilter.java +++ b/scm-webapp/src/main/java/sonia/scm/web/security/TokenRefreshFilter.java @@ -24,6 +24,8 @@ package sonia.scm.web.security; +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; import org.apache.shiro.authc.AuthenticationException; import org.apache.shiro.authc.AuthenticationToken; import org.slf4j.Logger; @@ -31,6 +33,7 @@ import org.slf4j.LoggerFactory; import sonia.scm.Priority; import sonia.scm.filter.Filters; import sonia.scm.filter.WebElement; +import sonia.scm.metrics.AuthenticationMetrics; import sonia.scm.security.AccessToken; import sonia.scm.security.AccessTokenCookieIssuer; import sonia.scm.security.AccessTokenResolver; @@ -54,7 +57,7 @@ import static java.util.Optional.of; @Priority(Filters.PRIORITY_POST_AUTHENTICATION) @WebElement(value = Filters.PATTERN_RESTAPI, - morePatterns = { Filters.PATTERN_DEBUG }) + morePatterns = {Filters.PATTERN_DEBUG}) public class TokenRefreshFilter extends HttpFilter { private static final Logger LOG = LoggerFactory.getLogger(TokenRefreshFilter.class); @@ -63,13 +66,15 @@ public class TokenRefreshFilter extends HttpFilter { private final JwtAccessTokenRefresher refresher; private final AccessTokenResolver resolver; private final AccessTokenCookieIssuer issuer; + private final Counter tokenRefreshCounter; @Inject - public TokenRefreshFilter(Set tokenGenerators, JwtAccessTokenRefresher refresher, AccessTokenResolver resolver, AccessTokenCookieIssuer issuer) { + public TokenRefreshFilter(Set tokenGenerators, JwtAccessTokenRefresher refresher, AccessTokenResolver resolver, AccessTokenCookieIssuer issuer, MeterRegistry meterRegistry) { this.tokenGenerators = tokenGenerators; this.refresher = refresher; this.resolver = resolver; this.issuer = issuer; + this.tokenRefreshCounter = AuthenticationMetrics.tokenRefresh(meterRegistry, "JWT"); } @Override @@ -102,12 +107,13 @@ public class TokenRefreshFilter extends HttpFilter { } if (accessToken instanceof JwtAccessToken) { refresher.refresh((JwtAccessToken) accessToken) - .ifPresent(jwtAccessToken -> refreshToken(request, response, jwtAccessToken)); + .ifPresent(jwtAccessToken -> refreshJwtToken(request, response, jwtAccessToken)); } } - private void refreshToken(HttpServletRequest request, HttpServletResponse response, JwtAccessToken jwtAccessToken) { - LOG.debug("refreshing authentication token"); + private void refreshJwtToken(HttpServletRequest request, HttpServletResponse response, JwtAccessToken jwtAccessToken) { + tokenRefreshCounter.increment(); + LOG.debug("refreshing JWT authentication token"); issuer.authenticate(request, response, jwtAccessToken); } } diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AuthenticationResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AuthenticationResourceTest.java index 2fe891e4cd..059242beb5 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AuthenticationResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AuthenticationResourceTest.java @@ -26,6 +26,9 @@ package sonia.scm.api.v2.resources; import com.github.sdorra.shiro.ShiroRule; import com.github.sdorra.shiro.SubjectAware; +import io.micrometer.core.instrument.Meter; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import org.jboss.resteasy.mock.MockHttpRequest; import org.jboss.resteasy.mock.MockHttpResponse; import org.junit.Before; @@ -47,14 +50,15 @@ import javax.servlet.http.HttpServletResponse; import javax.ws.rs.core.MediaType; import java.io.UnsupportedEncodingException; import java.net.URISyntaxException; -import java.util.Date; +import java.util.List; import java.util.Optional; +import java.util.stream.Stream; import static java.net.URI.create; import static java.util.Optional.of; -import static org.hamcrest.Matchers.containsString; +import static org.assertj.core.api.Assertions.assertThat; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertThat; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -75,6 +79,8 @@ public class AuthenticationResourceTest { @Mock private AccessTokenBuilder accessTokenBuilder; + private MeterRegistry meterRegistry; + private final AccessTokenCookieIssuer cookieIssuer = new DefaultAccessTokenCookieIssuer(mock(ScmConfiguration.class)); private final MockHttpResponse response = new MockHttpResponse(); @@ -135,7 +141,8 @@ public class AuthenticationResourceTest { @Before public void prepareEnvironment() { - authenticationResource = new AuthenticationResource(accessTokenBuilderFactory, cookieIssuer); + meterRegistry = new SimpleMeterRegistry(); + authenticationResource = new AuthenticationResource(accessTokenBuilderFactory, cookieIssuer, meterRegistry); dispatcher.addSingletonResource(authenticationResource); AccessToken accessToken = mock(AccessToken.class); @@ -157,6 +164,11 @@ public class AuthenticationResourceTest { dispatcher.invoke(request, response); assertEquals(HttpServletResponse.SC_NO_CONTENT, response.getStatus()); + List meters = meterRegistry.getMeters(); + assertThat(meters).hasSize(3); + Optional loginAttemptMeter = meters.stream().filter(m -> m.getId().getName().equals("scm.auth.login.attempts")).findFirst(); + assertThat(loginAttemptMeter).isPresent(); + assertThat(loginAttemptMeter.get().measure().iterator().next().getValue()).isEqualTo(1); } @Test @@ -167,6 +179,12 @@ public class AuthenticationResourceTest { dispatcher.invoke(request, response); assertEquals(HttpServletResponse.SC_NO_CONTENT, response.getStatus()); + + List meters = meterRegistry.getMeters(); + assertThat(meters).hasSize(3); + Optional loginAttemptMeter = meters.stream().filter(m -> m.getId().getName().equals("scm.auth.login.attempts")).findFirst(); + assertThat(loginAttemptMeter).isPresent(); + assertThat(loginAttemptMeter.get().measure().iterator().next().getValue()).isEqualTo(1); } @@ -178,6 +196,10 @@ public class AuthenticationResourceTest { dispatcher.invoke(request, response); assertEquals(HttpServletResponse.SC_UNAUTHORIZED, response.getStatus()); + + List meters = meterRegistry.getMeters(); + assertThat(meters).hasSize(3); + assertThat(meters.stream().map(m -> m.getId().getName())).contains("scm.auth.login.failed", "scm.auth.login.attempts", "scm.auth.logout"); } @Test @@ -187,6 +209,10 @@ public class AuthenticationResourceTest { dispatcher.invoke(request, response); assertEquals(HttpServletResponse.SC_UNAUTHORIZED, response.getStatus()); + + List meters = meterRegistry.getMeters(); + assertThat(meters).hasSize(3); + assertThat(meters.stream().map(m -> m.getId().getName())).contains("scm.auth.login.failed", "scm.auth.login.attempts", "scm.auth.logout"); } @Test @@ -216,6 +242,12 @@ public class AuthenticationResourceTest { dispatcher.invoke(request, response); assertEquals(HttpServletResponse.SC_NO_CONTENT, response.getStatus()); + + List meters = meterRegistry.getMeters(); + assertThat( meters).hasSize(3); + Optional logoutMeter = meters.stream().filter(m -> m.getId().getName().equals("scm.auth.logout")).findFirst(); + assertThat(logoutMeter).isPresent(); + assertThat(logoutMeter.get().measure().iterator().next().getValue()).isEqualTo(1); } @Test @@ -227,7 +259,7 @@ public class AuthenticationResourceTest { dispatcher.invoke(request, response); assertEquals(HttpServletResponse.SC_OK, response.getStatus()); - assertThat(response.getContentAsString(), containsString("http://example.com/cas/logout")); + assertThat(response.getContentAsString()).contains("http://example.com/cas/logout"); } @Test diff --git a/scm-webapp/src/test/java/sonia/scm/security/AnonymousRealmTest.java b/scm-webapp/src/test/java/sonia/scm/security/AnonymousRealmTest.java index a355171fd6..310316eaaf 100644 --- a/scm-webapp/src/test/java/sonia/scm/security/AnonymousRealmTest.java +++ b/scm-webapp/src/test/java/sonia/scm/security/AnonymousRealmTest.java @@ -29,7 +29,6 @@ import org.apache.shiro.authc.UsernamePasswordToken; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import sonia.scm.SCMContext; @@ -56,7 +55,6 @@ class AnonymousRealmTest { @Mock private UserDAO userDAO; - @InjectMocks private AnonymousRealm realm; @Mock 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 c9d834cdbc..1f55dfdb27 100644 --- a/scm-webapp/src/test/java/sonia/scm/security/BearerRealmTest.java +++ b/scm-webapp/src/test/java/sonia/scm/security/BearerRealmTest.java @@ -29,7 +29,6 @@ import org.apache.shiro.authc.UsernamePasswordToken; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -62,7 +61,6 @@ class BearerRealmTest { @Mock private AccessTokenResolver accessTokenResolver; - @InjectMocks private BearerRealm realm; @Mock 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 08b3d8df7e..8a2d7ba82c 100644 --- a/scm-webapp/src/test/java/sonia/scm/security/DefaultRealmTest.java +++ b/scm-webapp/src/test/java/sonia/scm/security/DefaultRealmTest.java @@ -21,10 +21,8 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - -package sonia.scm.security; -//~--- non-JDK imports -------------------------------------------------------- +package sonia.scm.security; import com.google.common.collect.Collections2; import org.apache.shiro.authc.AuthenticationInfo; @@ -41,7 +39,6 @@ import org.apache.shiro.authz.permission.WildcardPermissionResolver; import org.apache.shiro.crypto.hash.DefaultHashService; import org.apache.shiro.subject.PrincipalCollection; import org.apache.shiro.subject.SimplePrincipalCollection; -import org.hamcrest.Matchers; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; @@ -56,38 +53,38 @@ import sonia.scm.user.UserTestData; import java.util.HashSet; import java.util.Set; -import static org.hamcrest.Matchers.allOf; -import static org.hamcrest.Matchers.contains; -import static org.hamcrest.Matchers.containsInAnyOrder; -import static org.hamcrest.Matchers.hasItem; -import static org.hamcrest.Matchers.hasSize; -import static org.hamcrest.Matchers.is; -import static org.hamcrest.Matchers.not; -import static org.hamcrest.Matchers.nullValue; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertThat; +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -//~--- JDK imports ------------------------------------------------------------ - -/** - * - * @author Sebastian Sdorra - */ @RunWith(MockitoJUnitRunner.class) -public class DefaultRealmTest -{ +public class DefaultRealmTest { + + @Mock + private DefaultAuthorizationCollector collector; + + private Set authorizationCollectors; + + @Mock + private LoginAttemptHandler loginAttemptHandler; + + @Mock + private GroupDAO groupDAO; + + @Mock + private UserDAO userDAO; + + @InjectMocks + private DAORealmHelperFactory helperFactory; + + private DefaultRealm realm; + + private DefaultPasswordService service; + - /** - * Method description - * - */ @Test(expected = DisabledAccountException.class) - public void testDisabledAccount() - { + public void testDisabledAccount() { User user = UserTestData.createMarvin(); user.setActive(false); @@ -97,37 +94,29 @@ public class DefaultRealmTest realm.getAuthenticationInfo(token); } - /** - * Method description - * - */ @Test - public void testGetAuthorizationInfo() - { + public void testGetAuthorizationInfo() { SimplePrincipalCollection col = new SimplePrincipalCollection(); realm.doGetAuthorizationInfo(col); verify(collector, times(1)).collect(col); } - - /** - * Tests {@link DefaultRealm#doGetAuthorizationInfo(PrincipalCollection)} without scope. - */ + @Test - public void testGetAuthorizationInfoWithoutScope(){ + public void testGetAuthorizationInfoWithoutScope() { SimplePrincipalCollection col = new SimplePrincipalCollection(); - + SimpleAuthorizationInfo collectorsAuthz = new SimpleAuthorizationInfo(); collectorsAuthz.addStringPermission("repository:*"); when(collector.collect(col)).thenReturn(collectorsAuthz); - + AuthorizationInfo realmsAutz = realm.doGetAuthorizationInfo(col); - assertThat(realmsAutz.getObjectPermissions(), is(nullValue())); - assertThat(realmsAutz.getStringPermissions(), Matchers.contains("repository:*")); + assertThat(realmsAutz.getObjectPermissions()).isNull(); + assertThat(realmsAutz.getStringPermissions()).contains("repository:*"); } @Test - public void testGetAuthorizationInfoWithMultipleAuthorizationCollectors(){ + public void testGetAuthorizationInfoWithMultipleAuthorizationCollectors() { SimplePrincipalCollection col = new SimplePrincipalCollection(); col.add(Scope.empty(), DefaultRealm.REALM); @@ -151,124 +140,81 @@ public class DefaultRealmTest authorizationCollectors.add(thirdCollector); AuthorizationInfo realmsAuthz = realm.doGetAuthorizationInfo(col); - assertThat(realmsAuthz.getObjectPermissions(), contains(permission)); - assertThat(realmsAuthz.getStringPermissions(), containsInAnyOrder("repository:*", "user:*")); - assertThat(realmsAuthz.getRoles(), Matchers.contains("awesome")); + assertThat(realmsAuthz.getObjectPermissions()).contains(permission); + assertThat(realmsAuthz.getStringPermissions()).containsExactlyInAnyOrder("repository:*", "user:*"); + assertThat(realmsAuthz.getRoles()).contains("awesome"); } - /** - * Tests {@link DefaultRealm#doGetAuthorizationInfo(PrincipalCollection)} with empty scope. - */ @Test - public void testGetAuthorizationInfoWithEmptyScope(){ + public void testGetAuthorizationInfoWithEmptyScope() { SimplePrincipalCollection col = new SimplePrincipalCollection(); col.add(Scope.empty(), DefaultRealm.REALM); - + SimpleAuthorizationInfo collectorsAuthz = new SimpleAuthorizationInfo(); collectorsAuthz.addStringPermission("repository:*"); when(collector.collect(col)).thenReturn(collectorsAuthz); - + AuthorizationInfo realmsAutz = realm.doGetAuthorizationInfo(col); - assertThat(realmsAutz.getObjectPermissions(), is(nullValue())); - assertThat(realmsAutz.getStringPermissions(), Matchers.contains("repository:*")); + assertThat(realmsAutz.getObjectPermissions()).isNull(); + ; + assertThat(realmsAutz.getStringPermissions()).contains("repository:*"); } - - /** - * Tests {@link DefaultRealm#doGetAuthorizationInfo(PrincipalCollection)} with scope. - */ + @Test - public void testGetAuthorizationInfoWithScope(){ + public void testGetAuthorizationInfoWithScope() { SimplePrincipalCollection col = new SimplePrincipalCollection(); col.add(Scope.valueOf("user:*:me"), DefaultRealm.REALM); - + SimpleAuthorizationInfo collectorsAuthz = new SimpleAuthorizationInfo(); collectorsAuthz.addStringPermission("repository:*"); collectorsAuthz.addStringPermission("user:*:me"); when(collector.collect(col)).thenReturn(collectorsAuthz); - + AuthorizationInfo realmsAutz = realm.doGetAuthorizationInfo(col); - assertThat( - Collections2.transform(realmsAutz.getObjectPermissions(), Permission::toString), - allOf( - Matchers.contains("user:*:me"), - not(Matchers.contains("repository:*")) - ) - ); + assertThat(Collections2.transform(realmsAutz.getObjectPermissions(), Permission::toString)).contains("user:*:me").doesNotContain("repository:*"); } - /** - * Method description - * - */ @Test - public void testSimpleAuthentication() - { + public void testSimpleAuthentication() { User user = UserTestData.createTrillian(); UsernamePasswordToken token = daoUser(user, "secret"); AuthenticationInfo info = realm.getAuthenticationInfo(token); - assertNotNull(info); + assertThat(info).isNotNull(); PrincipalCollection collection = info.getPrincipals(); - assertEquals(token.getUsername(), collection.getPrimaryPrincipal()); - assertThat(collection.getRealmNames(), hasSize(1)); - assertThat(collection.getRealmNames(), hasItem(DefaultRealm.REALM)); - assertEquals(user, collection.oneByType(User.class)); + assertThat(token.getUsername()).isEqualTo(collection.getPrimaryPrincipal()); + assertThat(collection.getRealmNames()).hasSize(1); + assertThat(collection.getRealmNames()).contains(DefaultRealm.REALM); + assertThat(user).isEqualTo(collection.oneByType(User.class)); } - /** - * Method description - * - */ @Test(expected = UnknownAccountException.class) - public void testUnknownAccount() - { + public void testUnknownAccount() { realm.getAuthenticationInfo(new UsernamePasswordToken("tricia", "secret")); } - /** - * Method description - * - */ @Test(expected = IllegalArgumentException.class) - public void testWithoutUsername() - { + public void testWithoutUsername() { realm.getAuthenticationInfo(new UsernamePasswordToken(null, "secret")); } - /** - * Method description - * - */ @Test(expected = IncorrectCredentialsException.class) - public void testWrongCredentials() - { + public void testWrongCredentials() { UsernamePasswordToken token = daoUser(UserTestData.createDent(), "secret"); token.setPassword("secret123".toCharArray()); realm.getAuthenticationInfo(token); } - /** - * Method description - * - */ @Test(expected = IllegalArgumentException.class) - public void testWrongToken() - { + public void testWrongToken() { realm.getAuthenticationInfo(new OtherAuthenticationToken()); } - //~--- set methods ---------------------------------------------------------- - - /** - * Method description - * - */ @Before - public void setUp() - { + public void setUp() { service = new DefaultPasswordService(); DefaultHashService hashService = new DefaultHashService(); @@ -281,96 +227,30 @@ public class DefaultRealmTest authorizationCollectors.add(collector); realm = new DefaultRealm(service, authorizationCollectors, helperFactory); - + // set permission resolver realm.setPermissionResolver(new WildcardPermissionResolver()); } - //~--- methods -------------------------------------------------------------- - - /** - * Method description - * - * - * @param user - * @param password - * - * @return - */ - private UsernamePasswordToken daoUser(User user, String password) - { + private UsernamePasswordToken daoUser(User user, String password) { user.setPassword(service.encryptPassword(password)); when(userDAO.get(user.getName())).thenReturn(user); return new UsernamePasswordToken(user.getName(), password); } - //~--- inner classes -------------------------------------------------------- + private static class OtherAuthenticationToken implements AuthenticationToken { - /** - * Class description - * - * - * @version Enter version here..., 14/12/13 - * @author Enter your name here... - */ - private static class OtherAuthenticationToken implements AuthenticationToken - { - - /** Field description */ private static final long serialVersionUID = 8891352342377018022L; - //~--- get methods -------------------------------------------------------- - - /** - * Method description - * - * - * @return - */ @Override - public Object getCredentials() - { + public Object getCredentials() { throw new UnsupportedOperationException("Not supported yet."); // To change body of generated methods, choose Tools | Templates. } - /** - * Method description - * - * - * @return - */ @Override - public Object getPrincipal() - { + public Object getPrincipal() { throw new UnsupportedOperationException("Not supported yet."); // To change body of generated methods, choose Tools | Templates. } } - - - //~--- fields --------------------------------------------------------------- - - /** Field description */ - @Mock - private DefaultAuthorizationCollector collector; - - private Set authorizationCollectors; - - @Mock - private LoginAttemptHandler loginAttemptHandler; - - @Mock - private GroupDAO groupDAO; - - @Mock - private UserDAO userDAO; - - @InjectMocks - private DAORealmHelperFactory helperFactory; - - /** Field description */ - private DefaultRealm realm; - - /** Field description */ - private DefaultPasswordService service; } diff --git a/scm-webapp/src/test/java/sonia/scm/security/ScmAtLeastOneSuccessfulStrategyTest.java b/scm-webapp/src/test/java/sonia/scm/security/ScmAtLeastOneSuccessfulStrategyTest.java index 21cefb6cfb..be1e60e9e2 100644 --- a/scm-webapp/src/test/java/sonia/scm/security/ScmAtLeastOneSuccessfulStrategyTest.java +++ b/scm-webapp/src/test/java/sonia/scm/security/ScmAtLeastOneSuccessfulStrategyTest.java @@ -24,10 +24,14 @@ package sonia.scm.security; +import io.micrometer.core.instrument.Meter; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Tag; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import org.apache.shiro.authc.AuthenticationException; import org.apache.shiro.authc.AuthenticationInfo; -import org.apache.shiro.authc.AuthenticationToken; import org.apache.shiro.authc.MergableAuthenticationInfo; +import org.apache.shiro.authc.UsernamePasswordToken; import org.apache.shiro.realm.Realm; import org.apache.shiro.subject.PrincipalCollection; import org.junit.Test; @@ -37,9 +41,11 @@ import org.mockito.junit.MockitoJUnitRunner; import java.util.ArrayList; import java.util.Arrays; +import java.util.Optional; import static java.util.Collections.singletonList; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @RunWith(MockitoJUnitRunner.class) @@ -49,7 +55,7 @@ public class ScmAtLeastOneSuccessfulStrategyTest { private Realm realm; @Mock - private AuthenticationToken token; + private UsernamePasswordToken token; @Mock MergableAuthenticationInfo singleRealmInfo; @@ -71,7 +77,7 @@ public class ScmAtLeastOneSuccessfulStrategyTest { @Test public void shouldAddNonNullThrowableToList() { - final ScmAtLeastOneSuccessfulStrategy strategy = new ScmAtLeastOneSuccessfulStrategy(); + final ScmAtLeastOneSuccessfulStrategy strategy = new ScmAtLeastOneSuccessfulStrategy(new SimpleMeterRegistry()); strategy.threadLocal.set(new ArrayList<>()); strategy.afterAttempt(realm, token, singleRealmInfo, aggregateInfo, tokenExpiredException); @@ -82,7 +88,7 @@ public class ScmAtLeastOneSuccessfulStrategyTest { @Test(expected = TokenExpiredException.class) public void shouldRethrowTokenExpiredException() { - final ScmAtLeastOneSuccessfulStrategy strategy = new ScmAtLeastOneSuccessfulStrategy(); + final ScmAtLeastOneSuccessfulStrategy strategy = new ScmAtLeastOneSuccessfulStrategy(new SimpleMeterRegistry()); strategy.threadLocal.set(singletonList(tokenExpiredException)); strategy.afterAllAttempts(token, aggregateInfo); @@ -90,7 +96,7 @@ public class ScmAtLeastOneSuccessfulStrategyTest { @Test(expected = TokenValidationFailedException.class) public void shouldRethrowTokenValidationFailedException() { - final ScmAtLeastOneSuccessfulStrategy strategy = new ScmAtLeastOneSuccessfulStrategy(); + final ScmAtLeastOneSuccessfulStrategy strategy = new ScmAtLeastOneSuccessfulStrategy(new SimpleMeterRegistry()); strategy.threadLocal.set(singletonList(tokenValidationFailedException)); strategy.afterAllAttempts(token, aggregateInfo); @@ -98,7 +104,7 @@ public class ScmAtLeastOneSuccessfulStrategyTest { @Test(expected = TokenExpiredException.class) public void shouldPrioritizeRethrowingTokenExpiredExceptionOverTokenValidationFailedException() { - final ScmAtLeastOneSuccessfulStrategy strategy = new ScmAtLeastOneSuccessfulStrategy(); + final ScmAtLeastOneSuccessfulStrategy strategy = new ScmAtLeastOneSuccessfulStrategy(new SimpleMeterRegistry()); strategy.threadLocal.set(Arrays.asList(tokenValidationFailedException, tokenExpiredException)); strategy.afterAllAttempts(token, aggregateInfo); @@ -106,7 +112,7 @@ public class ScmAtLeastOneSuccessfulStrategyTest { @Test(expected = AuthenticationException.class) public void shouldThrowGenericErrorIfNonTokenExpiredExceptionWasCaught() { - final ScmAtLeastOneSuccessfulStrategy strategy = new ScmAtLeastOneSuccessfulStrategy(); + final ScmAtLeastOneSuccessfulStrategy strategy = new ScmAtLeastOneSuccessfulStrategy(new SimpleMeterRegistry()); strategy.threadLocal.set(singletonList(authenticationException)); strategy.afterAllAttempts(token, aggregateInfo); @@ -114,7 +120,7 @@ public class ScmAtLeastOneSuccessfulStrategyTest { @Test() public void shouldNotRethrowExceptionIfAuthenticationSuccessful() { - final ScmAtLeastOneSuccessfulStrategy strategy = new ScmAtLeastOneSuccessfulStrategy(); + final ScmAtLeastOneSuccessfulStrategy strategy = new ScmAtLeastOneSuccessfulStrategy(new SimpleMeterRegistry()); strategy.threadLocal.set(singletonList(tokenExpiredException)); when(aggregateInfo.getPrincipals()).thenReturn(principalCollection); when(principalCollection.isEmpty()).thenReturn(false); @@ -124,4 +130,50 @@ public class ScmAtLeastOneSuccessfulStrategyTest { assertThat(authenticationInfo).isNotNull(); } + @Test() + public void shouldTrackSuccessfulRealmAuthenticationMetrics() { + MeterRegistry meterRegistry = new SimpleMeterRegistry(); + final ScmAtLeastOneSuccessfulStrategy strategy = new ScmAtLeastOneSuccessfulStrategy(meterRegistry); + strategy.threadLocal.set(singletonList(tokenExpiredException)); + when(aggregateInfo.getPrincipals()).thenReturn(principalCollection); + when(principalCollection.isEmpty()).thenReturn(false); + + DefaultRealm realm = mock(DefaultRealm.class); + + strategy.afterAttempt(realm, token, aggregateInfo, null, null); + + assertThat(meterRegistry.getMeters()).hasSize(1); + Optional realmAccessMeter = meterRegistry.getMeters() + .stream() + .filter(m -> m.getId().getName().equals("scm.auth.realm.successful")) + .findFirst(); + assertThat(realmAccessMeter).isPresent(); + assertThat(realmAccessMeter.get().measure().iterator().next().getValue()).isEqualTo(1); + assertThat(realmAccessMeter.get().getId().getTags()).contains( + Tag.of("realm", "sonia.scm.security.DefaultRealm"), + Tag.of("token", "org.apache.shiro.authc.UsernamePasswordToken") + ); + } + + @Test() + public void shouldTrackGeneralSuccessfulAuthenticationMetrics() { + MeterRegistry meterRegistry = new SimpleMeterRegistry(); + final ScmAtLeastOneSuccessfulStrategy strategy = new ScmAtLeastOneSuccessfulStrategy(meterRegistry); + strategy.threadLocal.set(singletonList(tokenExpiredException)); + when(aggregateInfo.getPrincipals()).thenReturn(principalCollection); + when(principalCollection.isEmpty()).thenReturn(false); + + strategy.afterAllAttempts(token, aggregateInfo); + + assertThat(meterRegistry.getMeters()).hasSize(1); + Optional accessMeter = meterRegistry.getMeters() + .stream() + .filter(m -> m.getId().getName().equals("scm.auth.access.successful")) + .findFirst(); + assertThat(accessMeter).isPresent(); + assertThat(accessMeter.get().measure().iterator().next().getValue()).isEqualTo(1); + assertThat(accessMeter.get().getId().getTags()).contains( + Tag.of("token", "org.apache.shiro.authc.UsernamePasswordToken") + ); + } } diff --git a/scm-webapp/src/test/java/sonia/scm/web/security/TokenRefreshFilterTest.java b/scm-webapp/src/test/java/sonia/scm/web/security/TokenRefreshFilterTest.java index 410eb1f368..140ac4b260 100644 --- a/scm-webapp/src/test/java/sonia/scm/web/security/TokenRefreshFilterTest.java +++ b/scm-webapp/src/test/java/sonia/scm/web/security/TokenRefreshFilterTest.java @@ -24,11 +24,13 @@ package sonia.scm.web.security; +import io.micrometer.core.instrument.Meter; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import org.apache.shiro.authc.AuthenticationToken; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import sonia.scm.security.AccessTokenCookieIssuer; @@ -47,6 +49,7 @@ import java.util.Set; import static java.util.Collections.singleton; import static java.util.Optional.of; +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -68,7 +71,6 @@ class TokenRefreshFilterTest { @Mock private AccessTokenCookieIssuer issuer; - @InjectMocks private TokenRefreshFilter filter; @Mock @@ -78,9 +80,13 @@ class TokenRefreshFilterTest { @Mock private FilterChain filterChain; + private MeterRegistry meterRegistry; + @BeforeEach - void initGenerators() { + void init() { when(tokenGenerators.iterator()).thenReturn(singleton(tokenGenerator).iterator()); + meterRegistry = new SimpleMeterRegistry(); + filter = new TokenRefreshFilter(tokenGenerators, refresher, resolver, issuer, meterRegistry); } @Test @@ -130,6 +136,23 @@ class TokenRefreshFilterTest { verify(filterChain).doFilter(request, response); } + @Test + void shouldTrackMetricIfTokenWasRefreshed() throws IOException, ServletException { + BearerToken token = createValidToken(); + JwtAccessToken jwtToken = mock(JwtAccessToken.class); + JwtAccessToken newJwtToken = mock(JwtAccessToken.class); + when(tokenGenerator.createToken(request)).thenReturn(token); + when(resolver.resolve(token)).thenReturn(jwtToken); + when(refresher.refresh(jwtToken)).thenReturn(of(newJwtToken)); + + filter.doFilter(request, response, filterChain); + + assertThat(meterRegistry.getMeters()).hasSize(1); + Meter.Id meterId = meterRegistry.getMeters().get(0).getId(); + assertThat(meterId.getName()).isEqualTo("scm.auth.token.refresh"); + assertThat(meterId.getTag("type")).isEqualTo("JWT"); + } + BearerToken createValidToken() { return valueOf("some.jwt.token"); }