diff --git a/README.md b/README.md index b70a631..a6395c7 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,41 @@ ERROR: Failed to open /opt/keycloak/lib/../providers/keycloak-metrics-spi.jar ``` The endpoint for the metrics is `//realms//metrics` +### External Access + +By default, the metrics endpoint returns HTTP Forbidden responses until authentication has been configured. + +To activate the metrics endpoint, one of the following settings depending on your setup must be enabled. + +#### Bearer Authentication + +Note: If you use Keycloak < 17 use the corresponding XML settings instead of the environment variables. + +You can enable protection of the metrics endpoint via Bearer authentication by setting the `KC_SPI_REALM_RESTAPI_EXTENSION_METRICS_BEARER_ENABLED` environment variable (or corresponding command line argument) to `true`. + +By default, the requesting user must be in the `master` realm and have the `prometheus-metrics` role. +However, you can modify the realm using `KC_SPI_REALM_RESTAPI_EXTENSION_METRICS_REALM` and role using `KC_SPI_REALM_RESTAPI_EXTENSION_METRICS_ROLE`. + +To configure your Prometheus instance to obtain an OAuth2 token before querying the metrics endpoint, consult the [official Prometheus OAuth2 configuration](https://prometheus.io/docs/prometheus/latest/configuration/configuration/#oauth2). +Use a client of type `confidential` that has `Service Accounts Enabled` set to `ON`. +Then, make sure to include the role configured above in the `Service Account Roles` of that client. + +#### `DISABLE_EXTERNAL_ACCESS` + +If using Bearer authentication is not an option, you can still protect the endpoint using the `DISABLE_EXTERNAL_ACCESS` environment variable. +Once set, requests in which the header 'X-Forwarded-Host' header is set will be denied. +Thus, to disable access to the metrics the header must be set in a request on your proxy. +This is enabled by default on HA Proxy on Openshift. + +#### Disable Authentication + +**NOT recommended**. + +If you are sure what you do and have considered the other authentication options, you can disable authentication completely. +However, this will make your endpoint accessible to everyone with access to your Keycloak instance, if the metrics route is not otherwise protected. + +To disable the authentication, set the `KC_SPI_REALM_RESTAPI_EXTENSION_METRICS_AUTHENTICATION_DISABLED` environment variable (or corresponding command line argument) to `true`. + ### Enable metrics-listener event - To enable the event listener via the GUI interface, go to _Manage -> Events -> Config_. The _Event Listeners_ configuration should have an entry named `metrics-listener`. @@ -424,10 +459,6 @@ keycloak_request_duration_count{code="200",method="GET",resource="admin,admin/se keycloak_request_duration_sum{code="200",method="GET",resource="admin,admin/serverinfo",uri="",} 19.0 ``` -## External Access - -To disable metrics being externally accessible to a cluster. Set the environment variable 'DISABLE_EXTERNAL_ACCESS'. Once set enable the header 'X-Forwarded-Host' on your proxy. This is enabled by default on HA Proxy on Openshift. - ## Grafana Dashboard You can use this dashboard or create yours https://grafana.com/grafana/dashboards/10441-keycloak-metrics-dashboard/ diff --git a/src/main/java/org/jboss/aerogear/keycloak/metrics/MetricsEndpoint.java b/src/main/java/org/jboss/aerogear/keycloak/metrics/MetricsEndpoint.java index 4cf1e62..9a7b1ff 100644 --- a/src/main/java/org/jboss/aerogear/keycloak/metrics/MetricsEndpoint.java +++ b/src/main/java/org/jboss/aerogear/keycloak/metrics/MetricsEndpoint.java @@ -2,20 +2,54 @@ import jakarta.ws.rs.GET; import jakarta.ws.rs.Produces; +import jakarta.ws.rs.ForbiddenException; +import jakarta.ws.rs.NotAuthorizedException; import jakarta.ws.rs.core.Context; import jakarta.ws.rs.core.HttpHeaders; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.Response.Status; import jakarta.ws.rs.core.StreamingOutput; + +import org.jboss.logging.Logger; + +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; import org.keycloak.services.resource.RealmResourceProvider; +import org.keycloak.services.managers.AppAuthManager.BearerTokenAuthenticator; +import org.keycloak.services.managers.AuthenticationManager.AuthResult; public class MetricsEndpoint implements RealmResourceProvider { // The ID of the provider is also used as the name of the endpoint public final static String ID = "metrics"; - private static final boolean DISABLE_EXTERNAL_ACCESS = Boolean.parseBoolean(System.getenv("DISABLE_EXTERNAL_ACCESS")); + private static final boolean DISABLE_EXTERNAL_ACCESS = Boolean + .parseBoolean(System.getenv("DISABLE_EXTERNAL_ACCESS")); + + private final static Logger logger = Logger.getLogger(MetricsEndpoint.class); + + private Boolean authenticationDisabled; + private Boolean bearerEnabled; + private String role; + private AuthResult auth; + + public MetricsEndpoint(KeycloakSession session, Boolean authenticationDisabled, Boolean bearerEnabled, String realm, String role) { + super(); + + this.authenticationDisabled = authenticationDisabled; + this.bearerEnabled = bearerEnabled; + if (this.bearerEnabled) { + RealmModel realmModel = session.realms().getRealmByName(realm); + if (realmModel == null) { + logger.errorf("Could not find realm with name %s", realm); + return; + } + session.getContext().setRealm(realmModel); + this.auth = new BearerTokenAuthenticator(session).authenticate(); + this.role = role; + } + } @Override public Object getResource() { @@ -25,15 +59,36 @@ public Object getResource() { @GET @Produces(MediaType.TEXT_PLAIN) public Response get(@Context HttpHeaders headers) { + checkAuthentication(headers); + + final StreamingOutput stream = output -> PrometheusExporter.instance().export(output); + return Response.ok(stream).build(); + } + + private void checkAuthentication(HttpHeaders headers) { if (DISABLE_EXTERNAL_ACCESS) { if (!headers.getRequestHeader("x-forwarded-host").isEmpty()) { // Request is being forwarded by HA Proxy on Openshift - return Response.status(Status.FORBIDDEN).build(); //(stream).build(); + throw new NotAuthorizedException("X-Forwarded-Host header is present"); } } - final StreamingOutput stream = output -> PrometheusExporter.instance().export(output); - return Response.ok(stream).build(); + if (this.bearerEnabled) { + if (this.auth == null) { + throw new NotAuthorizedException("Invalid bearer token"); + } else if (this.auth.getToken().getRealmAccess() == null + || !this.auth.getToken().getRealmAccess().isUserInRole(this.role)) { + throw new ForbiddenException("Missing required realm role"); + } + return; + } + + if (this.authenticationDisabled) { + return; + } + + logger.error("Authentication on metrics endpoint is not configured!"); + throw new ForbiddenException(); } @Override diff --git a/src/main/java/org/jboss/aerogear/keycloak/metrics/MetricsEndpointFactory.java b/src/main/java/org/jboss/aerogear/keycloak/metrics/MetricsEndpointFactory.java index 10881ea..d858970 100644 --- a/src/main/java/org/jboss/aerogear/keycloak/metrics/MetricsEndpointFactory.java +++ b/src/main/java/org/jboss/aerogear/keycloak/metrics/MetricsEndpointFactory.java @@ -9,13 +9,29 @@ public class MetricsEndpointFactory implements RealmResourceProviderFactory { + private static final String DISABLE_AUTHENTICATION = "disableAuthentication"; + private static final String BEARER_ENABLED_CONFIGURATION = "bearerEnabled"; + private static final String REALM_CONFIGURATION = "realm"; + private static final String DEFAULT_REALM = "master"; + private static final String ROLE_CONFIGURATION = "role"; + private static final String DEFAULT_ROLE = "prometheus-metrics"; + + private Boolean authenticationDisabled; + private Boolean bearerEnabled; + private String realm; + private String role; + @Override public RealmResourceProvider create(KeycloakSession session) { - return new MetricsEndpoint(); + return new MetricsEndpoint(session, this.authenticationDisabled, this.bearerEnabled, this.realm, this.role); } @Override public void init(Config.Scope config) { + this.authenticationDisabled = config.getBoolean(DISABLE_AUTHENTICATION, false); + this.bearerEnabled = config.getBoolean(BEARER_ENABLED_CONFIGURATION, false); + this.realm = config.get(REALM_CONFIGURATION, DEFAULT_REALM); + this.role = config.get(ROLE_CONFIGURATION, DEFAULT_ROLE); String resteasyVersion = ResteasyProviderFactory.class.getPackage().getImplementationVersion(); if (resteasyVersion.startsWith("3.")) {