Skip to content

Commit

Permalink
Provide a way to observe security events
Browse files Browse the repository at this point in the history
  • Loading branch information
michalvavrik committed Dec 3, 2023
1 parent 842924c commit f058d6d
Show file tree
Hide file tree
Showing 50 changed files with 2,200 additions and 77 deletions.
45 changes: 45 additions & 0 deletions docs/src/main/asciidoc/security-customization.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -620,6 +620,51 @@ are using an executor that is capable of propagating the identity (e.g. no `Comp
to make sure that Quarkus can propagate it. For more information see the
xref:context-propagation.adoc[Context Propagation Guide].

[[observe-security-events]]

Check warning on line 623 in docs/src/main/asciidoc/security-customization.adoc

View workflow job for this annotation

GitHub Actions / Linting with Vale

[vale] reported by reviewdog 🐶 [Quarkus.TermsSuggestions] Depending on the context, consider using 'because' or 'while' rather than 'as'. Raw Output: {"message": "[Quarkus.TermsSuggestions] Depending on the context, consider using 'because' or 'while' rather than 'as'.", "location": {"path": "docs/src/main/asciidoc/security-customization.adoc", "range": {"start": {"line": 623, "column": 27}}}, "severity": "INFO"}
== Observe security events

Quarkus fires security events, such as authentication or authorization failure, with the `io.quarkus.security.spi.runtime.SecurityEventProducer` CDI bean.
Default `SecurityEventProducer` implementation fires security events as CDI events.
This way, you can define an observer method that will be notified when security event was fired.

[source,java]
----
package org.acme.security;
import io.quarkus.security.identity.SecurityIdentity;
import io.quarkus.security.spi.runtime.AuthenticationEvent;
import io.quarkus.security.spi.runtime.AuthorizationEvent;
import io.quarkus.security.spi.runtime.SecurityEvent;
import io.vertx.ext.web.RoutingContext;
import jakarta.enterprise.event.Observes;
import jakarta.enterprise.event.ObservesAsync;
import java.util.Map;
public class SecurityEventObserver {
void observe(@Observes SecurityEvent event) { <1>
SecurityIdentity identity = event.getSecurityIdentity();
}
void observe(@Observes AuthenticationEvent event) { <2>
Throwable failure = event.getAuthenticationFailure();
}
void observe(@Observes AuthorizationEvent event) { <3>
RoutingContext ctx = (RoutingContext) event.getEventProperties().get(RoutingContext.class.getName());
}
void observeAsync(@ObservesAsync SecurityEvent event) { <4>
Map<String, Object> eventProperties = event.getEventProperties();
}
}
----
<1> This observer will be notified with all the security events.
<2> The `AuthenticationEvent` event is fired whenever authentication failed.
<3> The `AuthorizationEvent` event is used for failed security checks and HTTP permission checks.
<4> Both synchronous and asynchronous CDI observer can be used to observe security events.

== References

* xref:security-overview.adoc[Quarkus Security overview]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1243,6 +1243,8 @@ public class SecurityEventListener {
}
----

TIP: You can listen to other security events as described in the xref:security-customization.adoc#observe-security-events[Observe security events] section of the Security Tips and Tricks guide.

=== Propagating tokens to downstream services

For information about Authorization Code Flow access token propagation to downstream services, see the xref:security-openid-connect-client-reference.adoc#token-propagation[Token Propagation] section.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@
import io.quarkus.arc.deployment.AdditionalBeanBuildItem;
import io.quarkus.arc.deployment.BeanDiscoveryFinishedBuildItem;
import io.quarkus.arc.deployment.QualifierRegistrarBuildItem;
import io.quarkus.arc.deployment.SynthesisFinishedBuildItem;
import io.quarkus.arc.deployment.SyntheticBeanBuildItem;
import io.quarkus.arc.processor.InjectionPointInfo;
import io.quarkus.arc.processor.QualifierRegistrar;
Expand All @@ -37,12 +36,10 @@
import io.quarkus.deployment.annotations.BuildProducer;
import io.quarkus.deployment.annotations.BuildStep;
import io.quarkus.deployment.annotations.BuildSteps;
import io.quarkus.deployment.annotations.Consume;
import io.quarkus.deployment.annotations.ExecutionTime;
import io.quarkus.deployment.annotations.Record;
import io.quarkus.deployment.builditem.CombinedIndexBuildItem;
import io.quarkus.deployment.builditem.ExtensionSslNativeSupportBuildItem;
import io.quarkus.deployment.builditem.RuntimeConfigSetupCompleteBuildItem;
import io.quarkus.deployment.builditem.nativeimage.ReflectiveClassBuildItem;
import io.quarkus.oidc.SecurityEvent;
import io.quarkus.oidc.Tenant;
Expand Down Expand Up @@ -224,18 +221,6 @@ public SyntheticBeanBuildItem setup(
.done();
}

// Note that DefaultTenantConfigResolver injects quarkus.http.proxy.enable-forwarded-prefix
@Consume(RuntimeConfigSetupCompleteBuildItem.class)
@BuildStep
@Record(ExecutionTime.RUNTIME_INIT)
public void findSecurityEventObservers(
OidcRecorder recorder,
SynthesisFinishedBuildItem synthesisFinished) {
boolean isSecurityEventObserved = synthesisFinished.getObservers().stream()
.anyMatch(observer -> observer.asObserver().getObservedType().name().equals(DOTNAME_SECURITY_EVENT));
recorder.setSecurityEventObserved(isSecurityEventObserved);
}

@BuildStep
@Record(ExecutionTime.STATIC_INIT)
public void produceTenantResolverInterceptors(CombinedIndexBuildItem indexBuildItem,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
* Security event.
*
*/
public class SecurityEvent {
public class SecurityEvent implements io.quarkus.security.spi.runtime.SecurityEvent {
public static final String SESSION_TOKENS_PROPERTY = "session-tokens";

public enum Type {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ public void accept(MultiMap form) {
tokens.addTokenVerification(key, result);

if (resolver.isSecurityEventObserved()) {
resolver.getSecurityEvent().fire(
resolver.fireSecurityEvent(
new SecurityEvent(Type.OIDC_BACKCHANNEL_LOGOUT_INITIATED,
Map.of(OidcConstants.BACK_CHANNEL_LOGOUT_TOKEN, result)));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -987,13 +987,13 @@ public Void apply(String cookieValue) {

private void fireEvent(SecurityEvent.Type eventType, SecurityIdentity securityIdentity) {
if (resolver.isSecurityEventObserved()) {
resolver.getSecurityEvent().fire(new SecurityEvent(eventType, securityIdentity));
resolver.fireSecurityEvent(new SecurityEvent(eventType, securityIdentity));
}
}

private void fireEvent(SecurityEvent.Type eventType, Map<String, Object> properties) {
if (resolver.isSecurityEventObserved()) {
resolver.getSecurityEvent().fire(new SecurityEvent(eventType, properties));
resolver.fireSecurityEvent(new SecurityEvent(eventType, properties));
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@

import jakarta.annotation.PostConstruct;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.event.Event;
import jakarta.enterprise.inject.Instance;
import jakarta.inject.Inject;

Expand All @@ -25,6 +24,7 @@
import io.quarkus.oidc.UserInfo;
import io.quarkus.oidc.UserInfoCache;
import io.quarkus.security.spi.runtime.BlockingSecurityExecutor;
import io.quarkus.security.spi.runtime.SecurityEventProducer;
import io.smallrye.mutiny.Uni;
import io.vertx.ext.web.RoutingContext;

Expand All @@ -36,7 +36,7 @@ public class DefaultTenantConfigResolver {
private static final String CURRENT_STATIC_TENANT_ID_NULL = "static.tenant.id.null";
private static final String CURRENT_DYNAMIC_TENANT_CONFIG = "dynamic.tenant.config";

private DefaultStaticTenantResolver defaultStaticTenantResolver = new DefaultStaticTenantResolver();
private final DefaultStaticTenantResolver defaultStaticTenantResolver = new DefaultStaticTenantResolver();

@Inject
Instance<TenantResolver> tenantResolver;
Expand All @@ -59,21 +59,19 @@ public class DefaultTenantConfigResolver {
@Inject
Instance<UserInfoCache> userInfoCache;

@Inject
Event<SecurityEvent> securityEvent;

@Inject
@ConfigProperty(name = "quarkus.http.proxy.enable-forwarded-prefix")
boolean enableHttpForwardedPrefix;

private final BlockingTaskRunner<OidcTenantConfig> blockingRequestContext;
private final boolean securityEventObserved;
private final SecurityEventProducer securityEventProducer;
private final ConcurrentHashMap<String, BackChannelLogoutTokenCache> backChannelLogoutTokens = new ConcurrentHashMap<>();

private volatile boolean securityEventObserved;

private ConcurrentHashMap<String, BackChannelLogoutTokenCache> backChannelLogoutTokens = new ConcurrentHashMap<>();

public DefaultTenantConfigResolver(BlockingSecurityExecutor blockingExecutor) {
public DefaultTenantConfigResolver(BlockingSecurityExecutor blockingExecutor, SecurityEventProducer securityEventProducer) {
this.blockingRequestContext = new BlockingTaskRunner<OidcTenantConfig>(blockingExecutor);
this.securityEventObserved = securityEventProducer.isObserved(SecurityEvent.class);
this.securityEventProducer = securityEventProducer;
}

@PostConstruct
Expand Down Expand Up @@ -189,12 +187,8 @@ boolean isSecurityEventObserved() {
return securityEventObserved;
}

void setSecurityEventObserved(boolean securityEventObserved) {
this.securityEventObserved = securityEventObserved;
}

Event<SecurityEvent> getSecurityEvent() {
return securityEvent;
void fireSecurityEvent(SecurityEvent securityEvent) {
securityEventProducer.fire(securityEvent);
}

TokenStateManager getTokenStateManager() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -352,11 +352,6 @@ private static TenantConfigContext createTenantContextToVerifyCertChain(OidcTena
new OidcProvider(null, oidcConfig, readTokenDecryptionKey(oidcConfig)), oidcConfig);
}

public void setSecurityEventObserved(boolean isSecurityEventObserved) {
DefaultTenantConfigResolver bean = Arc.container().instance(DefaultTenantConfigResolver.class).get();
bean.setSecurityEventObserved(isSecurityEventObserved);
}

public static Optional<ProxyOptions> toProxyOptions(OidcCommonConfig.Proxy proxyConfig) {
return OidcCommonUtils.toProxyOptions(proxyConfig);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
package io.quarkus.resteasy.test.security;

import static org.hamcrest.Matchers.is;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.time.Duration;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;

import jakarta.enterprise.event.Observes;
import jakarta.enterprise.event.ObservesAsync;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;

import org.awaitility.Awaitility;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import io.quarkus.security.AuthenticationFailedException;
import io.quarkus.security.identity.SecurityIdentity;
import io.quarkus.security.spi.runtime.AuthenticationEvent;
import io.quarkus.security.spi.runtime.AuthorizationEvent;
import io.quarkus.security.spi.runtime.SecurityEvent;
import io.quarkus.security.test.utils.TestIdentityController;
import io.quarkus.security.test.utils.TestIdentityProvider;
import io.restassured.RestAssured;
import io.vertx.ext.web.RoutingContext;

public class AbstractSecurityEventTest {

protected static final Class<?>[] TEST_CLASSES = {
RolesAllowedResource.class, TestIdentityProvider.class, TestIdentityController.class,
UnsecuredResource.class, UnsecuredSubResource.class, EventObserver.class
};

@Inject
EventObserver observer;

@BeforeEach
public void clean() {
observer.getAsyncEvents().clear();
observer.getSyncEvents().clear();
observer.getAuthEvents().clear();
}

@BeforeAll
public static void setupUsers() {
TestIdentityController.resetRoles()
.add("admin", "admin", "admin")
.add("user", "user", "user");
}

@Test
public void testAuthenticationEvent() {
RestAssured.given().auth().preemptive().basic("unknown", "unknown").get("/unsecured/authenticated").then()
.statusCode(401);
assertEquals(1, observer.authEvents.size());
AuthenticationEvent event = observer.authEvents.stream().findFirst().get();
assertNull(event.getSecurityIdentity());
assertTrue(event.getAuthenticationFailure() instanceof AuthenticationFailedException);
assertNotNull(event.getEventProperties().get(RoutingContext.class.getName()));
}

@Test
public void testRolesAllowed() {
RestAssured.get("/roles").then().statusCode(401);
assertSyncObserved(1);
assertAsyncObserved(1);
SecurityIdentity anonymousIdentity = observer.syncEvents.stream().findFirst().get().getSecurityIdentity();
assertNotNull(anonymousIdentity);
assertTrue(anonymousIdentity.isAnonymous());
RestAssured.given().auth().preemptive().basic("user", "user").get("/roles/admin").then().statusCode(403);
assertSyncObserved(2);
assertAsyncObserved(2);
}

@Test
public void testAuthenticated() {
RestAssured.given().auth().preemptive().basic("admin", "admin").get("/unsecured/authenticated").then().statusCode(200)
.body(is("authenticated"));
assertSyncObserved(0);
assertAsyncObserved(0);
RestAssured.given().get("/unsecured/authenticated").then().statusCode(401);
assertSyncObserved(1);
assertAsyncObserved(1);
SecurityIdentity anonymousIdentity = observer.asyncEvents.stream().findFirst().get().getSecurityIdentity();
assertNotNull(anonymousIdentity);
assertTrue(anonymousIdentity.isAnonymous());
}

@Test
public void testDenyAll() {
RestAssured.given().auth().preemptive().basic("admin", "admin").get("/unsecured/denyAll").then().statusCode(403);
assertSyncObserved(1);
assertAsyncObserved(1);
SecurityIdentity adminIdentity = observer.getSyncEvents().stream().findFirst().get().getSecurityIdentity();
assertNotNull(adminIdentity);
assertEquals("admin", adminIdentity.getPrincipal().getName());
assertTrue(adminIdentity.hasRole("admin"));
assertEquals(adminIdentity, observer.asyncEvents.stream().findFirst().get().getSecurityIdentity());
RestAssured.given().get("/unsecured/authenticated").then().statusCode(401);
assertSyncObserved(2);
assertAsyncObserved(2);
}

@Test
public void testPermitAll() {
RestAssured.given().auth().preemptive().basic("admin", "admin").get("/unsecured/permitAll").then().statusCode(200)
.body(is("permitAll"));
assertSyncObserved(0);
assertAsyncObserved(0);
RestAssured.given().get("/unsecured/permitAll").then().statusCode(200).body(is("permitAll"));
assertSyncObserved(0);
assertAsyncObserved(0);
}

private void assertSyncObserved(int count) {
assertEquals(count, observer.getSyncEvents().size());
if (count > 0) {
assertTrue(observer.asyncEvents.stream().allMatch(e -> e.getSecurityIdentity() != null));
}
}

private void assertAsyncObserved(int count) {
Awaitility.await().atMost(Duration.ofSeconds(2))
.untilAsserted(() -> assertEquals(count, observer.getAsyncEvents().size()));
if (count > 0) {
assertTrue(observer.asyncEvents.stream().allMatch(e -> e.getSecurityIdentity() != null));
}
}

@Singleton
public static class EventObserver {

private final Set<SecurityEvent> syncEvents = ConcurrentHashMap.newKeySet();
private final Set<AuthorizationEvent> asyncEvents = ConcurrentHashMap.newKeySet();
private final Set<AuthenticationEvent> authEvents = ConcurrentHashMap.newKeySet();

void observe(@Observes SecurityEvent securityEvent) {
syncEvents.add(securityEvent);
}

void observe(@Observes AuthenticationEvent authenticationEvent) {
authEvents.add(authenticationEvent);
}

void observe(@ObservesAsync AuthorizationEvent authorizationEvent) {
asyncEvents.add(authorizationEvent);
}

Set<SecurityEvent> getSyncEvents() {
return syncEvents;
}

Set<AuthorizationEvent> getAsyncEvents() {
return asyncEvents;
}

Set<AuthenticationEvent> getAuthEvents() {
return authEvents;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package io.quarkus.resteasy.test.security;

import org.junit.jupiter.api.extension.RegisterExtension;

import io.quarkus.test.QuarkusUnitTest;

public class EagerAuthSecurityEventTest extends AbstractSecurityEventTest {

@RegisterExtension
static QuarkusUnitTest test = new QuarkusUnitTest()
.withApplicationRoot(jar -> jar.addClasses(TEST_CLASSES));

}
Loading

0 comments on commit f058d6d

Please sign in to comment.