diff --git a/examples/security/README.adoc b/examples/security/README.adoc new file mode 100644 index 0000000..7c7e534 --- /dev/null +++ b/examples/security/README.adoc @@ -0,0 +1,20 @@ += Micronaut Security + +This project showcases how to test secured Micronaut applications. + +We use https://micronaut-projects.github.io/micronaut-security/latest/guide/index.html[Micronaut Security] to do that. +Micronaut Security offers the possibility to secure your application on the HTTP layer. + +E.g. https://spring.io/projects/spring-security[Spring Security] offers additionally to secure you business logic and not just your Web-Controllers (See https://docs.spring.io/spring-security/reference/servlet/authorization/method-security.html#page-title[Method Security]). +If you think the author is mistaken and Micronaut Security offers this as well, do not hesitate to open a bug. + +== What do we showcase? + +We showcase how to use Basic Authentication, which is enabled by default. + +In addition to that we offer JWT based Authentication, too. + +We show two different ways to secure your endpoints: + +* `io.micronaut.security.annotation.Secured` +* https://micronaut-projects.github.io/micronaut-security/latest/guide/#interceptUrlMap[Intercept URL map] diff --git a/examples/security/build.gradle.kts b/examples/security/build.gradle.kts new file mode 100644 index 0000000..87cf93e --- /dev/null +++ b/examples/security/build.gradle.kts @@ -0,0 +1,17 @@ +plugins { + id("cnt-micronaut.micronaut-conventions") +} + +dependencies { + implementation("io.micronaut.security:micronaut-security") + implementation("io.micronaut.security:micronaut-security-jwt") + // We use this to have implementations of org.reactivestreams.Publisher + // which we will use while developing some security related things + implementation("io.micronaut.reactor:micronaut-reactor") + + testImplementation("io.micronaut.test:micronaut-test-rest-assured") +} + +application { + mainClass.set("example.micronaut.security.SecurityApplicationKt") +} diff --git a/examples/security/src/main/kotlin/example/micronaut/security/SecurityApplicationKt.kt b/examples/security/src/main/kotlin/example/micronaut/security/SecurityApplicationKt.kt new file mode 100644 index 0000000..ea42074 --- /dev/null +++ b/examples/security/src/main/kotlin/example/micronaut/security/SecurityApplicationKt.kt @@ -0,0 +1,7 @@ +package example.micronaut.security + +import io.micronaut.runtime.Micronaut.run + +fun main(args: Array) { + run(*args) +} diff --git a/examples/security/src/main/kotlin/example/micronaut/security/api/BookRepresentation.kt b/examples/security/src/main/kotlin/example/micronaut/security/api/BookRepresentation.kt new file mode 100644 index 0000000..cd93743 --- /dev/null +++ b/examples/security/src/main/kotlin/example/micronaut/security/api/BookRepresentation.kt @@ -0,0 +1,12 @@ +package example.micronaut.security.api + +import example.micronaut.security.core.Book +import java.util.UUID + +data class BookRepresentation( + val id: UUID, + val title: String, + val isbn: String +) { + constructor(book: Book) : this(book.id, book.title, book.isbn) +} diff --git a/examples/security/src/main/kotlin/example/micronaut/security/api/BooksResource.kt b/examples/security/src/main/kotlin/example/micronaut/security/api/BooksResource.kt new file mode 100644 index 0000000..9340907 --- /dev/null +++ b/examples/security/src/main/kotlin/example/micronaut/security/api/BooksResource.kt @@ -0,0 +1,49 @@ +package example.micronaut.security.api + +import example.micronaut.security.core.BookCollection +import io.micronaut.http.HttpResponse +import io.micronaut.http.annotation.Body +import io.micronaut.http.annotation.Controller +import io.micronaut.http.annotation.Get +import io.micronaut.http.annotation.Post +import io.micronaut.security.annotation.Secured +import io.micronaut.security.rules.SecurityRule +import jakarta.validation.Valid + + +@Controller("/books") +open class BooksResource(private val bookCollection: BookCollection) { + + @Get + @Secured(SecurityRule.IS_AUTHENTICATED) + open fun findBooks(): Collection { + return findBooksInternal() + } + + @Post + @Secured("BOOK_CREATOR") + open fun createBook(@Valid @Body createBookRequest: CreateBookRequest): HttpResponse { + return createBookInternal(createBookRequest) + } + + // the following two endpoint are secured via configuration of intercept URL map. See + // application.yaml for details. + @Get("/url-not-secured") + open fun findBooksUnsecured(): Collection { + return findBooksInternal() + } + + @Post("/url-not-secured") + open fun createBookUnsecured(@Valid @Body createBookRequest: CreateBookRequest): HttpResponse { + return createBookInternal(createBookRequest) + } + + private fun findBooksInternal(): List { + return bookCollection.getAll().map { BookRepresentation(it) } + } + + private fun createBookInternal(createBookRequest: CreateBookRequest): HttpResponse { + val book = bookCollection.add(createBookRequest.isbn, createBookRequest.title) + return HttpResponse.created(BookRepresentation(book)) + } +} diff --git a/examples/security/src/main/kotlin/example/micronaut/security/api/CreateBookRequest.kt b/examples/security/src/main/kotlin/example/micronaut/security/api/CreateBookRequest.kt new file mode 100644 index 0000000..b184d75 --- /dev/null +++ b/examples/security/src/main/kotlin/example/micronaut/security/api/CreateBookRequest.kt @@ -0,0 +1,11 @@ +package example.micronaut.security.api + +import io.micronaut.core.annotation.Introspected +import jakarta.validation.constraints.Pattern + +@Introspected // necessary for jakarta-validation to work at runtime +data class CreateBookRequest( + val title: String, + @field:Pattern(regexp = "[0-9]{13}") + val isbn: String +) diff --git a/examples/security/src/main/kotlin/example/micronaut/security/core/Book.kt b/examples/security/src/main/kotlin/example/micronaut/security/core/Book.kt new file mode 100755 index 0000000..0a3d36a --- /dev/null +++ b/examples/security/src/main/kotlin/example/micronaut/security/core/Book.kt @@ -0,0 +1,9 @@ +package example.micronaut.security.core + +import java.util.UUID + +data class Book( + val id: UUID, + val isbn: String, + val title: String, +) diff --git a/examples/security/src/main/kotlin/example/micronaut/security/core/BookCollection.kt b/examples/security/src/main/kotlin/example/micronaut/security/core/BookCollection.kt new file mode 100644 index 0000000..7e7076a --- /dev/null +++ b/examples/security/src/main/kotlin/example/micronaut/security/core/BookCollection.kt @@ -0,0 +1,18 @@ +package example.micronaut.security.core + +import jakarta.inject.Singleton +import java.util.UUID + +@Singleton +class BookCollection { + + private val books = mutableListOf() + + fun getAll(): Collection = books + + fun add(isbn: String, title: String): Book { + val book = Book(UUID.randomUUID(), isbn, title) + books.add(book) + return book + } +} diff --git a/examples/security/src/main/kotlin/example/micronaut/security/infrastructure/AuthenticationProvider.kt b/examples/security/src/main/kotlin/example/micronaut/security/infrastructure/AuthenticationProvider.kt new file mode 100644 index 0000000..9db5f6b --- /dev/null +++ b/examples/security/src/main/kotlin/example/micronaut/security/infrastructure/AuthenticationProvider.kt @@ -0,0 +1,38 @@ +package example.micronaut.security.infrastructure + +import io.micronaut.http.HttpRequest +import io.micronaut.security.authentication.AuthenticationProvider +import io.micronaut.security.authentication.AuthenticationRequest +import io.micronaut.security.authentication.AuthenticationResponse +import jakarta.inject.Singleton +import org.reactivestreams.Publisher +import reactor.core.publisher.Mono + +/** + * Provides a simple, in memory AuthenticationProvider that knows two pairs of credentials. + */ +@Singleton +class AuthenticationProviderUserPassword : AuthenticationProvider> { + private val knownCredentials = mapOf( + "reader" to "reader's secret", + "writer" to "writer's secret" + ) + + private val rightsPerUser = mapOf("writer" to listOf("BOOK_CREATOR")) + + override fun authenticate( + httpRequest: HttpRequest<*>, + authenticationRequest: AuthenticationRequest<*, *> + ): Publisher { + return Mono.create { + val username = authenticationRequest.identity.toString() + val password = knownCredentials[username] + if (authenticationRequest.secret == password) { + val roles = rightsPerUser[username] ?: listOf() + it.success(AuthenticationResponse.success(username, roles)) + } else { + it.error(AuthenticationResponse.exception()) + } + } + } +} diff --git a/examples/security/src/main/resources/application.yaml b/examples/security/src/main/resources/application.yaml new file mode 100644 index 0000000..3489e0f --- /dev/null +++ b/examples/security/src/main/resources/application.yaml @@ -0,0 +1,31 @@ +micronaut: + security: + # this triggers the usage of io.micronaut.security.token.bearer.AccessRefreshTokenLoginHandler + # That class generates a JWT on successful POST to /login + authentication: "bearer" + endpoints: + login: + # enables /login endpoint to be able to request a JWT + enabled: true + token: + jwt: + enabled: true + signatures: + secret: + # this triggers to sign a JWT + generator: + secret: pleaseChangeThisSecretToSomethingThatProvidesAtLeast256Bits + jws-algorithm: HS256 + intercept-url-map: + - pattern: /login + http-method: POST + access: + - isAnonymous() + - pattern: /books/url-not-secured + access: + - isAuthenticated() + - pattern: /books/url-not-secured + http-method: POST + access: + - BOOK_CREATOR + diff --git a/examples/security/src/test/kotlin/example/micronaut/security/api/BooksResourceBasicAuthTest.kt b/examples/security/src/test/kotlin/example/micronaut/security/api/BooksResourceBasicAuthTest.kt new file mode 100644 index 0000000..80503c6 --- /dev/null +++ b/examples/security/src/test/kotlin/example/micronaut/security/api/BooksResourceBasicAuthTest.kt @@ -0,0 +1,121 @@ +package example.micronaut.security.api + +import io.micronaut.test.extensions.junit5.annotation.MicronautTest +import io.restassured.http.ContentType +import io.restassured.specification.RequestSpecification +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test + +@MicronautTest +class BooksResourceBasicAuthTest( + private val spec: RequestSpecification +) { + @Nested + inner class SecuredByAnnotationOnController : BookResourceFixture(spec, "/books") + + @Nested + inner class SecuredByInterceptUrlMap : BookResourceFixture(spec, "/books/url-not-secured") + + abstract inner class BookResourceFixture( + private val spec: RequestSpecification, + private val path: String + ) { + @Test + fun `returns 401 UNAUTHORIZED if authentication is missing`() { + spec + .get(path) + .then() + .statusCode(401) + } + + @Test + fun `returns 401 UNAUTHORIZED if user name is incorrect`() { + spec + .basicAuth("unknown User", "") + .get(path) + .then() + .statusCode(401) + } + + @Test + fun `returns 401 UNAUTHORIZED if password is incorrect`() { + spec + .basicAuth("reader", "NOT reader's secret") + .get(path) + .then() + .statusCode(401) + } + + @Test + fun `returns 200 OK if authenticated and authorized`() { + spec + .basicAuth("reader", "reader's secret") + .get(path) + .then() + .statusCode(200) + } + + @Test + fun `returns 403 FORBIDDEN if not authorized`() { + spec + .basicAuth("reader", "reader's secret") + .jsonBody( + """{ + "title": "Some title", + "isbn": "0123456789123" + }""".trimIndent() + ) + .post(path) + .then() + .statusCode(403) + } + + @Test + fun `returns 400 BAD REQUEST for a malformed request body`() { + spec + .basicAuth("writer", "writer's secret") + .jsonBody( + """{ + "a body": "that is invalid" + }""".trimIndent() + ) + .post(path) + .then() + .statusCode(400) + } + + @Test + fun `returns 201 CREATED if authorized`() { + spec + .basicAuth("writer", "writer's secret") + .jsonBody( + """{ + "title": "Some title", + "isbn": "0123456789123" + }""".trimIndent() + ) + .post(path) + .then() + .statusCode(201) + } + + private fun RequestSpecification.basicAuth( + username: String, + password: String + ): RequestSpecification { + return this + .auth() + // directly sends authorization header to prevent additional round trip + .preemptive() + .basic(username, password) + } + + private fun RequestSpecification.jsonBody(body: String): RequestSpecification { + // contentType is important. + // If you do not specify it, Micronaut Security will not match the request to an endpoint + // and will always return a 403. + return this.contentType(ContentType.JSON) + .body(body) + } + } +} diff --git a/examples/security/src/test/kotlin/example/micronaut/security/api/BooksResourceJwtAuthTest.kt b/examples/security/src/test/kotlin/example/micronaut/security/api/BooksResourceJwtAuthTest.kt new file mode 100644 index 0000000..162e7a0 --- /dev/null +++ b/examples/security/src/test/kotlin/example/micronaut/security/api/BooksResourceJwtAuthTest.kt @@ -0,0 +1,181 @@ +package example.micronaut.security.api + +import io.micronaut.test.extensions.junit5.annotation.MicronautTest +import io.restassured.http.ContentType +import io.restassured.specification.RequestSpecification +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test + +@MicronautTest +class BooksResourceJwtAuthTest( + private val spec: RequestSpecification +) { + + @Nested + inner class BearerCreation { + @Test + fun `returns 401 UNAUTHORIZED if username is unknown`() { + spec + .withLoginPayload("unknown User", "wrong password") + .post("/login") + .then() + .statusCode(401) + } + + @Test + fun `returns 401 UNAUTHORIZED if password is incorrect`() { + spec + .withLoginPayload("reader", "wrong password") + .post("/login") + .then() + .statusCode(401) + } + + @Test + fun `returns 400 BAD REQUEST if credentials are missing`() { + spec + .jsonBody( + """{ + } + """.trimIndent() + ) + .post("/login") + .then() + .statusCode(400) + } + + @Test + fun `returns 200 OK if credentials are valid`() { + spec + .withLoginPayload("reader", "reader's secret") + .post("/login") + .then() + .statusCode(200) + } + } + + @Nested + inner class SecuredByAnnotationOnController : BookResourceFixture(spec, "/books") + + @Nested + inner class SecuredByInterceptUrlMap : BookResourceFixture(spec, "/books/url-not-secured") + + abstract inner class BookResourceFixture( + private val spec: RequestSpecification, + private val path: String + ) { + @Test + fun `returns 401 UNAUTHORIZED if authentication is missing`() { + spec + .get(path) + .then() + .statusCode(401) + } + + @Test + fun `returns 403 UNAUTHORIZED if bearer is invalid`() { + spec + .auth().oauth2("invalid token") + .get(path) + .then() + .statusCode(401) + } + + @Test + fun `returns 200 OK if bearer is valid`() { + spec + .withBearerToken("reader", "reader's secret") + .get(path) + .then() + .statusCode(200) + } + + @Test + fun `returns 403 FORBIDDEN if bearer misses authorization`() { + spec + .withBearerToken("reader", "reader's secret") + .jsonBody( + """{ + "title": "Some title", + "isbn": "0123456789123" + }""".trimIndent() + ) + .post(path) + .then() + .statusCode(403) + } + + @Test + fun `returns 400 BAD REQUEST for a malformed request body`() { + spec + .withBearerToken("writer", "writer's secret") + .jsonBody( + """{ + "a body": "that is invalid" + }""".trimIndent() + ) + .post(path) + .then() + .statusCode(400) + } + + @Test + fun `returns 201 CREATED if bearer has necessary authorization`() { + spec + .withBearerToken("writer", "writer's secret") + .jsonBody( + """{ + "title": "Some title", + "isbn": "0123456789123" + }""".trimIndent() + ) + .post(path) + .then() + .statusCode(201) + } + + private fun RequestSpecification.withBearerToken( + username: String, + password: String + ): RequestSpecification { + val accessToken = this + .withLoginPayload(username, password) + .post("/login") + .then() + .statusCode(200) + .extract() + .path("access_token") + + return this.withBearerToken(accessToken) + } + + private fun RequestSpecification.withBearerToken(accessToken: String): RequestSpecification { + return this + .auth() + .oauth2(accessToken) + } + } + + private fun RequestSpecification.withLoginPayload( + username: String, + password: String + ): RequestSpecification { + return this + .jsonBody( + """ + { + "username": "$username", + "password": "$password" + } + """.trimIndent() + ) + } + + private fun RequestSpecification.jsonBody(body: String): RequestSpecification { + // contentType is important. + // If you do not specify it, Micronaut Security will not match the request to an endpoint + // and will always return a 403. + return this.contentType(ContentType.JSON) + .body(body) + } +} diff --git a/examples/security/src/test/resources/logback.xml b/examples/security/src/test/resources/logback.xml new file mode 100644 index 0000000..6d668a9 --- /dev/null +++ b/examples/security/src/test/resources/logback.xml @@ -0,0 +1,19 @@ + + + + + + %cyan(%d{HH:mm:ss.SSS}) %gray([%thread]) %highlight(%-5level) %magenta(%logger{36}) - + %msg%n + + + + + + + + + + + diff --git a/settings.gradle.kts b/settings.gradle.kts index e31b037..be2a16e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -6,3 +6,4 @@ include("examples:caching") include("examples:graphql") include("examples:rabbitmq") include("examples:data-jpa") +include("examples:security")