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 <sebastian.sdorra@cloudogu.com>
This commit is contained in:
Eduard Heimbuch
2021-03-24 08:50:14 +01:00
committed by GitHub
parent 97bad3e3a5
commit 3ec499d22c
17 changed files with 425 additions and 375 deletions

View File

@@ -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);

View File

@@ -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);
}
}

View File

@@ -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;

View File

@@ -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();
}
}

View File

@@ -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<AuthorizationCollector> authorizationCollectors;
private final DAORealmHelper helper;
//~--- constructors ---------------------------------------------------------
/**
* Constructs ...
*
*
* @param service
* @param authorizationCollectors
* @param helperFactory
*/
@Inject
public DefaultRealm(PasswordService service, Set<AuthorizationCollector> authorizationCollectors, DAORealmHelperFactory helperFactory)
{
public DefaultRealm(PasswordService service,
Set<AuthorizationCollector> 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<AuthorizationCollector> authorizationCollectors;
/** realm helper */
private final DAORealmHelper helper;
}

View File

@@ -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<List<Throwable>> threadLocal = new ThreadLocal<>();
private final MeterRegistry meterRegistry;
@Inject
public ScmAtLeastOneSuccessfulStrategy(MeterRegistry meterRegistry) {
this.meterRegistry = meterRegistry;
}
@Override
public AuthenticationInfo beforeAllAttempts(Collection<? extends Realm> 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<Throwable> 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<? extends AuthenticationException> specializedException = findSpecializedException(throwables);
if (specializedException.isPresent()) {

View File

@@ -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<WebTokenGenerator> tokenGenerators, JwtAccessTokenRefresher refresher, AccessTokenResolver resolver, AccessTokenCookieIssuer issuer) {
public TokenRefreshFilter(Set<WebTokenGenerator> 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);
}
}