diff --git a/src/main/docs/guide/retrievingAuthenticatedUser/customAuthenticatedUser.adoc b/src/main/docs/guide/retrievingAuthenticatedUser/customAuthenticatedUser.adoc new file mode 100644 index 0000000000..5fa21d6c9d --- /dev/null +++ b/src/main/docs/guide/retrievingAuthenticatedUser/customAuthenticatedUser.adoc @@ -0,0 +1,14 @@ + +You can create a custom argument binder to bind the authenticated user to a custom class tailored to your application needs. + +If, in your application, the authenticated user has an email address, you can create a class such as: + +snippet::io.micronaut.security.docs.customauthentication.AuthenticationWithEmail[] + +and then a `TypedRequestArgumentBinder`: + +snippet::io.micronaut.security.docs.customauthentication.AuthenticationWithEmailArgumentBinder[] + +Then you can bind it in a controller method parameter: + +snippet::io.micronaut.security.docs.customauthentication.CustomAuthenticationTest[tags="method"] \ No newline at end of file diff --git a/src/main/docs/guide/toc.yml b/src/main/docs/guide/toc.yml index a5d0b9432f..3a937ad6f0 100644 --- a/src/main/docs/guide/toc.yml +++ b/src/main/docs/guide/toc.yml @@ -69,6 +69,7 @@ endpoints: introspectionendpoint: Introspection Endpoint retrievingAuthenticatedUser: title: Retrieve the authenticated user + customAuthenticatedUser: Custom Binding securityService: User outside of a controller securityEvents: Security Events oauth: diff --git a/test-suite-groovy/src/test/groovy/io/micronaut/security/docs/customauthentication/AuthenticationWithEmail.groovy b/test-suite-groovy/src/test/groovy/io/micronaut/security/docs/customauthentication/AuthenticationWithEmail.groovy new file mode 100644 index 0000000000..bbea51ef6b --- /dev/null +++ b/test-suite-groovy/src/test/groovy/io/micronaut/security/docs/customauthentication/AuthenticationWithEmail.groovy @@ -0,0 +1,16 @@ +package io.micronaut.security.docs.customauthentication + +import groovy.transform.Canonical +import io.micronaut.security.authentication.Authentication +import io.micronaut.serde.annotation.Serdeable + +@Canonical +@Serdeable +class AuthenticationWithEmail { + String username + String email + + static AuthenticationWithEmail of(Authentication authentication) { + new AuthenticationWithEmail(authentication.getName(), authentication.getAttributes().get("email")?.toString()) + } +} diff --git a/test-suite-groovy/src/test/groovy/io/micronaut/security/docs/customauthentication/AuthenticationWithEmailArgumentBinder.groovy b/test-suite-groovy/src/test/groovy/io/micronaut/security/docs/customauthentication/AuthenticationWithEmailArgumentBinder.groovy new file mode 100644 index 0000000000..7d5760141c --- /dev/null +++ b/test-suite-groovy/src/test/groovy/io/micronaut/security/docs/customauthentication/AuthenticationWithEmailArgumentBinder.groovy @@ -0,0 +1,33 @@ +package io.micronaut.security.docs.customauthentication + +import io.micronaut.context.annotation.Requires +import io.micronaut.core.convert.ArgumentConversionContext +import io.micronaut.core.type.Argument +import io.micronaut.http.HttpRequest +import io.micronaut.http.bind.binders.TypedRequestArgumentBinder +import io.micronaut.security.authentication.Authentication +import io.micronaut.security.filters.SecurityFilter +import jakarta.inject.Singleton + +@Requires(property = "spec.name", value = "CustomAuthenticationTest") +@Singleton +class AuthenticationWithEmailArgumentBinder implements TypedRequestArgumentBinder { + private final Argument argumentType; + AuthenticationWithEmailArgumentBinder() { + argumentType = Argument.of(AuthenticationWithEmail.class); + } + + @Override + Argument argumentType() { + return argumentType; + } + + @Override + BindingResult bind(ArgumentConversionContext context, HttpRequest source) { + if (!source.getAttributes().contains(SecurityFilter.KEY)) { + return BindingResult.UNSATISFIED + } + final Optional existing = source.getUserPrincipal(Authentication.class) + existing.isPresent() ? (() -> existing.map(AuthenticationWithEmail::of)) : BindingResult.EMPTY + } +} diff --git a/test-suite-groovy/src/test/groovy/io/micronaut/security/docs/customauthentication/CustomAuthenticationTest.groovy b/test-suite-groovy/src/test/groovy/io/micronaut/security/docs/customauthentication/CustomAuthenticationTest.groovy new file mode 100644 index 0000000000..e98be49ce6 --- /dev/null +++ b/test-suite-groovy/src/test/groovy/io/micronaut/security/docs/customauthentication/CustomAuthenticationTest.groovy @@ -0,0 +1,70 @@ +package io.micronaut.security.docs.customauthentication + +import io.micronaut.context.annotation.Property +import io.micronaut.context.annotation.Requires +import io.micronaut.core.util.StringUtils +import io.micronaut.http.HttpRequest +import io.micronaut.http.MediaType +import io.micronaut.http.annotation.Controller +import io.micronaut.http.annotation.Get +import io.micronaut.http.annotation.Produces +import io.micronaut.http.client.BlockingHttpClient +import io.micronaut.http.client.HttpClient +import io.micronaut.http.client.annotation.Client +import io.micronaut.security.annotation.Secured +import io.micronaut.security.authentication.Authentication +import io.micronaut.security.rules.SecurityRule +import io.micronaut.security.token.validator.TokenValidator +import io.micronaut.test.extensions.spock.annotation.MicronautTest +import jakarta.inject.Inject +import org.reactivestreams.Publisher +import reactor.core.publisher.Mono +import spock.lang.Specification + + +@Property(name = "spec.name", value = "CustomAuthenticationTest") +@Property(name = "micronaut.security.reject-not-found", value = StringUtils.FALSE) +@MicronautTest +class CustomAuthenticationTest extends Specification { + + @Inject + @Client("/") + HttpClient httpClient; + + void "customAuthentication"() { + given: + BlockingHttpClient client = httpClient.toBlocking() + + when: + HttpRequest request = HttpRequest.GET("/custom-authentication") + .accept(MediaType.TEXT_PLAIN) + .bearerAuth("xxx") + String email = client.retrieve(request) + + then: + noExceptionThrown() + "sherlock@micronaut.example" == email + } + + @Requires(property = "spec.name", value = "CustomAuthenticationTest") + @Controller + static class CustomAuthenticationController { +//tag::method[] + @Secured(SecurityRule.IS_AUTHENTICATED) + @Produces(MediaType.TEXT_PLAIN) + @Get("/custom-authentication") + String index(AuthenticationWithEmail authentication) { + authentication.email + } +//end::method[] + } + + @Requires(property = "spec.name", value = "CustomAuthenticationTest") + @Controller + static class CustomAuthenticationProvider implements TokenValidator> { + @Override + Publisher validateToken(String token, HttpRequest request) { + Mono.just(Authentication.build("sherlock", [email: "sherlock@micronaut.example"])) + } + } +} diff --git a/test-suite-kotlin/src/test/kotlin/io/micronaut/security/docs/customauthentication/AuthenticationWithEmail.kt b/test-suite-kotlin/src/test/kotlin/io/micronaut/security/docs/customauthentication/AuthenticationWithEmail.kt new file mode 100644 index 0000000000..9e9958a9b9 --- /dev/null +++ b/test-suite-kotlin/src/test/kotlin/io/micronaut/security/docs/customauthentication/AuthenticationWithEmail.kt @@ -0,0 +1,15 @@ +package io.micronaut.security.docs.customauthentication + +import io.micronaut.security.authentication.Authentication +import io.micronaut.serde.annotation.Serdeable + +@Serdeable +data class AuthenticationWithEmail(val username: String, val email: String?) { + companion object { + fun of(authentication: Authentication): AuthenticationWithEmail { + val obj = authentication.getAttributes()["email"] + val email = obj?.toString() + return AuthenticationWithEmail(authentication.name, email) + } + } +} diff --git a/test-suite-kotlin/src/test/kotlin/io/micronaut/security/docs/customauthentication/AuthenticationWithEmailArgumentBinder.kt b/test-suite-kotlin/src/test/kotlin/io/micronaut/security/docs/customauthentication/AuthenticationWithEmailArgumentBinder.kt new file mode 100644 index 0000000000..7b1940111a --- /dev/null +++ b/test-suite-kotlin/src/test/kotlin/io/micronaut/security/docs/customauthentication/AuthenticationWithEmailArgumentBinder.kt @@ -0,0 +1,29 @@ +package io.micronaut.security.docs.customauthentication + +import io.micronaut.context.annotation.Requires +import io.micronaut.core.bind.ArgumentBinder +import io.micronaut.core.convert.ArgumentConversionContext +import io.micronaut.core.type.Argument +import io.micronaut.http.HttpRequest +import io.micronaut.http.bind.binders.TypedRequestArgumentBinder +import io.micronaut.security.authentication.Authentication +import io.micronaut.security.filters.SecurityFilter +import jakarta.inject.Singleton + +@Requires(property = "spec.name", value = "CustomAuthenticationTest") +@Singleton +class AuthenticationWithEmailArgumentBinder : TypedRequestArgumentBinder { + override fun bind(context: ArgumentConversionContext, source: HttpRequest<*>): ArgumentBinder.BindingResult { + if (!source.attributes.contains(SecurityFilter.KEY)) { + return ArgumentBinder.BindingResult.unsatisfied() + } + val existing = source.getUserPrincipal(Authentication::class.java) + return if (existing.isPresent) ArgumentBinder.BindingResult { + existing.map(AuthenticationWithEmail::of) + } else ArgumentBinder.BindingResult.empty() + } + + override fun argumentType(): Argument { + return Argument.of(AuthenticationWithEmail::class.java) + } +} diff --git a/test-suite-kotlin/src/test/kotlin/io/micronaut/security/docs/customauthentication/CustomAuthenticationTest.kt b/test-suite-kotlin/src/test/kotlin/io/micronaut/security/docs/customauthentication/CustomAuthenticationTest.kt new file mode 100644 index 0000000000..4ff1cbaaa4 --- /dev/null +++ b/test-suite-kotlin/src/test/kotlin/io/micronaut/security/docs/customauthentication/CustomAuthenticationTest.kt @@ -0,0 +1,57 @@ +package io.micronaut.security.docs.customauthentication + +import io.micronaut.context.annotation.Property +import io.micronaut.context.annotation.Requires +import io.micronaut.core.util.StringUtils +import io.micronaut.http.HttpRequest +import io.micronaut.http.MediaType +import io.micronaut.http.annotation.Controller +import io.micronaut.http.annotation.Get +import io.micronaut.http.annotation.Produces +import io.micronaut.http.client.HttpClient +import io.micronaut.http.client.annotation.Client +import io.micronaut.security.annotation.Secured +import io.micronaut.security.authentication.Authentication +import io.micronaut.security.rules.SecurityRule +import io.micronaut.security.token.validator.TokenValidator +import io.micronaut.test.extensions.junit5.annotation.MicronautTest +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Test +import org.reactivestreams.Publisher +import reactor.core.publisher.Mono + +@Property(name = "spec.name", value = "CustomAuthenticationTest") +@Property(name = "micronaut.security.reject-not-found", value = StringUtils.FALSE) +@MicronautTest +internal class CustomAuthenticationTest { + @Test + fun customAuthentication(@Client("/") httpClient: HttpClient) { + val client = httpClient.toBlocking() + val request: HttpRequest<*> = HttpRequest.GET("/custom-authentication") + .accept(MediaType.TEXT_PLAIN) + .bearerAuth("xxx") + val email = Assertions.assertDoesNotThrow { + client.retrieve(request) + } + Assertions.assertEquals("sherlock@micronaut.example", email) + } + + @Requires(property = "spec.name", value = "CustomAuthenticationTest") + @Controller + internal class CustomAuthenticationController { +//tag::method[] + @Secured(SecurityRule.IS_AUTHENTICATED) + @Produces(MediaType.TEXT_PLAIN) + @Get("/custom-authentication") + fun index(authentication: AuthenticationWithEmail) = authentication.email +//end::method[] + } + + @Requires(property = "spec.name", value = "CustomAuthenticationTest") + @Controller + internal class CustomAuthenticationProvider : TokenValidator> { + override fun validateToken(token: String, request: HttpRequest<*>): Publisher { + return Mono.just(Authentication.build("sherlock", mapOf("email" to "sherlock@micronaut.example"))) + } + } +} diff --git a/test-suite/src/test/java/io/micronaut/security/docs/customauthentication/AuthenticationWithEmail.java b/test-suite/src/test/java/io/micronaut/security/docs/customauthentication/AuthenticationWithEmail.java new file mode 100644 index 0000000000..e9c59511da --- /dev/null +++ b/test-suite/src/test/java/io/micronaut/security/docs/customauthentication/AuthenticationWithEmail.java @@ -0,0 +1,14 @@ +package io.micronaut.security.docs.customauthentication; + +import io.micronaut.security.authentication.Authentication; +import io.micronaut.serde.annotation.Serdeable; + +@Serdeable +public record AuthenticationWithEmail(String username, + String email) { + public static AuthenticationWithEmail of(Authentication authentication) { + Object obj = authentication.getAttributes().get("email"); + String email = obj == null ? null : obj.toString(); + return new AuthenticationWithEmail(authentication.getName(), email); + } +} diff --git a/test-suite/src/test/java/io/micronaut/security/docs/customauthentication/AuthenticationWithEmailArgumentBinder.java b/test-suite/src/test/java/io/micronaut/security/docs/customauthentication/AuthenticationWithEmailArgumentBinder.java new file mode 100644 index 0000000000..cf8036f297 --- /dev/null +++ b/test-suite/src/test/java/io/micronaut/security/docs/customauthentication/AuthenticationWithEmailArgumentBinder.java @@ -0,0 +1,34 @@ +package io.micronaut.security.docs.customauthentication; + +import io.micronaut.context.annotation.Requires; +import io.micronaut.core.convert.ArgumentConversionContext; +import io.micronaut.core.type.Argument; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.bind.binders.TypedRequestArgumentBinder; +import io.micronaut.security.authentication.Authentication; +import io.micronaut.security.filters.SecurityFilter; +import jakarta.inject.Singleton; +import java.util.Optional; + +@Requires(property = "spec.name", value = "CustomAuthenticationTest") +@Singleton +public class AuthenticationWithEmailArgumentBinder implements TypedRequestArgumentBinder { + private final Argument argumentType; + public AuthenticationWithEmailArgumentBinder() { + argumentType = Argument.of(AuthenticationWithEmail.class); + } + + @Override + public Argument argumentType() { + return argumentType; + } + + @Override + public BindingResult bind(ArgumentConversionContext context, HttpRequest source) { + if (!source.getAttributes().contains(SecurityFilter.KEY)) { + return BindingResult.UNSATISFIED; + } + final Optional existing = source.getUserPrincipal(Authentication.class); + return existing.isPresent() ? (() -> existing.map(AuthenticationWithEmail::of)) : BindingResult.EMPTY; + } +} diff --git a/test-suite/src/test/java/io/micronaut/security/docs/customauthentication/CustomAuthenticationTest.java b/test-suite/src/test/java/io/micronaut/security/docs/customauthentication/CustomAuthenticationTest.java new file mode 100644 index 0000000000..74b2069ad6 --- /dev/null +++ b/test-suite/src/test/java/io/micronaut/security/docs/customauthentication/CustomAuthenticationTest.java @@ -0,0 +1,64 @@ +package io.micronaut.security.docs.customauthentication; + +import io.micronaut.context.annotation.Property; +import io.micronaut.context.annotation.Requires; +import io.micronaut.core.util.StringUtils; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.MediaType; +import io.micronaut.http.annotation.Controller; +import io.micronaut.http.annotation.Get; +import io.micronaut.http.annotation.Produces; +import io.micronaut.http.client.BlockingHttpClient; +import io.micronaut.http.client.HttpClient; +import io.micronaut.http.client.annotation.Client; +import io.micronaut.security.annotation.Secured; +import io.micronaut.security.authentication.Authentication; +import io.micronaut.security.rules.SecurityRule; +import io.micronaut.security.token.validator.TokenValidator; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import org.junit.jupiter.api.Test; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Mono; + +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; + +@Property(name = "spec.name", value = "CustomAuthenticationTest") +@Property(name = "micronaut.security.reject-not-found", value = StringUtils.FALSE) +@MicronautTest +class CustomAuthenticationTest { + + @Test + void customAuthentication(@Client("/") HttpClient httpClient) { + BlockingHttpClient client = httpClient.toBlocking(); + HttpRequest request = HttpRequest.GET("/custom-authentication") + .accept(MediaType.TEXT_PLAIN) + .bearerAuth("xxx"); + String email = assertDoesNotThrow(() -> client.retrieve(request)); + assertEquals("sherlock@micronaut.example", email); + } + + @Requires(property = "spec.name", value = "CustomAuthenticationTest") + @Controller + static class CustomAuthenticationController { +//tag::method[] + @Secured(SecurityRule.IS_AUTHENTICATED) + @Produces(MediaType.TEXT_PLAIN) + @Get("/custom-authentication") + String index(AuthenticationWithEmail authentication) { + return authentication.email(); + } +//end::method[] + } + + @Requires(property = "spec.name", value = "CustomAuthenticationTest") + @Controller + static class CustomAuthenticationProvider implements TokenValidator> { + @Override + public Publisher validateToken(String token, HttpRequest request) { + return Mono.just(Authentication.build("sherlock", Collections.singletonMap("email", "sherlock@micronaut.example"))); + } + } +}