diff --git a/README.md b/README.md index 2751a1ed..fe94d213 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,33 @@ When the submodule is updated, to get the newest version of inserter you need to ] } ``` + +## Authentication + +Authentication is done via the existing Hasura webhook in the Jore4 Auth -service. The client holds a Spring session +cookie which is used to verify their identity. Each request accessing a protected endpoint must also have a role header +which includes which role they are requesting. + +Example request: + +``` +GET http://jore4-auth:8080/public/v1/hasura/webhook + +headers { + cookie: "SESSION=1234567890abcdefghijklmnopqrstuvwxyz" + x-hasura-role: "admin" +} +``` + +Will return either `HTTP 401 Unauthorized` or a response with `HTTP 200` and +``` +headers { + x-hasura-role: "granted role here" + x-hasura-id: "ID of user" +} +``` +Which can be used in the security context to grant access to protected endpoints and log user actions using the ID. + ## Technical Documentation jore4-timetables-api is a Spring Boot application written in Kotlin, which implements a REST API for accessing the timetables database and creating more complicated updates in one transaction than is possible with the graphQL interface. diff --git a/development.sh b/development.sh index d5c31e36..c608cf76 100755 --- a/development.sh +++ b/development.sh @@ -29,7 +29,7 @@ download_docker_bundle() { start_all() { download_docker_bundle - $DOCKER_COMPOSE_CMD up -d jore4-hasura jore4-testdb + $DOCKER_COMPOSE_CMD up -d jore4-hasura jore4-testdb jore4-auth $DOCKER_COMPOSE_CMD up --build -d jore4-timetables-api prepare_timetables_data_inserter } diff --git a/profiles/dev/config.properties b/profiles/dev/config.properties index dc2b738e..fa943d1a 100644 --- a/profiles/dev/config.properties +++ b/profiles/dev/config.properties @@ -9,3 +9,6 @@ jore4.db.max.connections=5 # jOOQ code generation configuration jooq.generator.db.dialect=org.jooq.meta.postgres.PostgresDatabase jooq.sql.dialect=POSTGRES + +# Remote authentication URL +authentication.url=http://jore4-auth:8080/public/v1/hasura/webhook diff --git a/src/main/kotlin/fi/hsl/jore4/timetables/TimetablesApiApplication.kt b/src/main/kotlin/fi/hsl/jore4/timetables/TimetablesApiApplication.kt index ea9dc52b..92e5c4d5 100644 --- a/src/main/kotlin/fi/hsl/jore4/timetables/TimetablesApiApplication.kt +++ b/src/main/kotlin/fi/hsl/jore4/timetables/TimetablesApiApplication.kt @@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule import com.fasterxml.jackson.module.kotlin.KotlinFeature import com.fasterxml.jackson.module.kotlin.KotlinModule +import fi.hsl.jore4.timetables.config.AuthenticationProperties import fi.hsl.jore4.timetables.config.DatabaseProperties import fi.hsl.jore4.timetables.config.JOOQProperties import org.springframework.boot.autoconfigure.SpringBootApplication @@ -21,7 +22,7 @@ fun main(args: Array) { * Spring boot application definition. */ @SpringBootApplication -@EnableConfigurationProperties(DatabaseProperties::class, JOOQProperties::class) +@EnableConfigurationProperties(AuthenticationProperties::class, DatabaseProperties::class, JOOQProperties::class) class TimetablesApiApplication { @Bean diff --git a/src/main/kotlin/fi/hsl/jore4/timetables/config/AuthenticationProperties.kt b/src/main/kotlin/fi/hsl/jore4/timetables/config/AuthenticationProperties.kt new file mode 100644 index 00000000..34af457a --- /dev/null +++ b/src/main/kotlin/fi/hsl/jore4/timetables/config/AuthenticationProperties.kt @@ -0,0 +1,8 @@ +package fi.hsl.jore4.timetables.config + +import org.springframework.boot.context.properties.ConfigurationProperties + +@ConfigurationProperties(prefix = "authentication") +data class AuthenticationProperties( + val url: String +) diff --git a/src/main/kotlin/fi/hsl/jore4/timetables/config/RemoteAuthenticationProvider.kt b/src/main/kotlin/fi/hsl/jore4/timetables/config/RemoteAuthenticationProvider.kt new file mode 100644 index 00000000..e96c35ae --- /dev/null +++ b/src/main/kotlin/fi/hsl/jore4/timetables/config/RemoteAuthenticationProvider.kt @@ -0,0 +1,78 @@ +package fi.hsl.jore4.timetables.config + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import mu.KotlinLogging +import org.springframework.context.annotation.Configuration +import org.springframework.security.authentication.AuthenticationProvider +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.Authentication +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse + +@Configuration +class RemoteAuthenticationProvider( + val authenticationProperties: AuthenticationProperties +) : AuthenticationProvider { + + companion object { + val logger = KotlinLogging.logger {} + + private const val ROLE_HEADER = "X-Hasura-Role" + private const val ID_HEADER = "X-Hasura-Id" + private val objectMapper = ObjectMapper() + private val httpClient = HttpClient.newHttpClient() + + private fun creteAuthenticationToken(authResponse: HttpResponse): Authentication { + val authResponseMap = objectMapper.readValue>(authResponse.body()) + + logger.debug(authResponse.toString()) + logger.debug(authResponseMap.toString()) + + return UsernamePasswordAuthenticationToken.authenticated( + authResponseMap[ID_HEADER], + "", // Credentials not used + listOf(SimpleGrantedAuthority(authResponseMap[ROLE_HEADER].toString())) + ) + } + + fun sendRequest(authRequest: HttpRequest): HttpResponse { + return httpClient.send(authRequest, HttpResponse.BodyHandlers.ofString()).also { + logger.debug("Authorization response $it") + } + } + } + + private fun authenticateWithHasuraWebhook(authentication: Authentication?): HttpResponse { + val authRequest = HttpRequest.newBuilder().run { + uri(URI(authenticationProperties.url)) + headers("cookie", "SESSION=${authentication?.principal}", ROLE_HEADER, authentication?.credentials.toString(), "Accept", "application/json") + header("cookie", "SESSION=${authentication?.principal}") + header(ROLE_HEADER, authentication?.credentials.toString()) + header("Accept", "application/json") + GET() + build() + } + + logger.debug("Sending authorization request to $authRequest") + logger.debug("Authorization headers ${authRequest.headers()}") + + return sendRequest(authRequest) + } + + override fun authenticate(authentication: Authentication?): Authentication { + val authResponse = authenticateWithHasuraWebhook(authentication) + if (authResponse.body().isBlank()) { + return UsernamePasswordAuthenticationToken.unauthenticated("", "") + } + return creteAuthenticationToken(authResponse) + } + + override fun supports(authentication: Class<*>?): Boolean { + return authentication?.equals(PreAuthenticatedAuthenticationToken::class.java) ?: false + } +} diff --git a/src/main/kotlin/fi/hsl/jore4/timetables/config/WebSecurityConfig.kt b/src/main/kotlin/fi/hsl/jore4/timetables/config/WebSecurityConfig.kt index 418c552e..50623fe5 100644 --- a/src/main/kotlin/fi/hsl/jore4/timetables/config/WebSecurityConfig.kt +++ b/src/main/kotlin/fi/hsl/jore4/timetables/config/WebSecurityConfig.kt @@ -1,21 +1,65 @@ package fi.hsl.jore4.timetables.config +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import mu.KotlinLogging import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.http.HttpMethod +import org.springframework.security.authentication.AuthenticationProvider import org.springframework.security.config.annotation.web.builders.HttpSecurity import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity import org.springframework.security.config.http.SessionCreationPolicy +import org.springframework.security.core.context.SecurityContextHolder import org.springframework.security.web.SecurityFilterChain +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter +import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken +import org.springframework.web.filter.OncePerRequestFilter @Configuration @EnableWebSecurity class WebSecurityConfig { + val logger = KotlinLogging.logger {} + + inner class HasuraFilter(private val authenticationProvider: AuthenticationProvider) : OncePerRequestFilter() { + + /* Every request passes through the authentication filter, whether they try to access protected endpoints + * or not. Authentication is only attempted for requests with a SESSION cookie and a defined Hasura role, + * the rest pass through with no authority added to the request. + */ + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain + ) { + val sessionCookie = request.cookies?.find { it.name.lowercase() == "session" } + val roleHeader = request.getHeader("X-Hasura-Role") + + if ("OPTIONS" == request.method) { + response.status = HttpServletResponse.SC_OK + } else if (sessionCookie == null || sessionCookie.value.isBlank()) { + // No session cookie means no added authority + logger.debug("No session cookie in request http request") + } else if (roleHeader == null) { + // No role in request means no added authority + logger.debug("No role header in http request") + } else { + logger.debug("cookie value ${sessionCookie.value}") + + val preAuth = PreAuthenticatedAuthenticationToken(sessionCookie.value, roleHeader) + SecurityContextHolder.getContext().authentication = authenticationProvider.authenticate(preAuth) + } + filterChain.doFilter(request, response) + } + } + @Bean @Throws(Exception::class) - fun configure(httpSecurity: HttpSecurity): SecurityFilterChain { + fun configure(httpSecurity: HttpSecurity, authentication: RemoteAuthenticationProvider): SecurityFilterChain { return httpSecurity + .addFilterBefore(HasuraFilter(authentication), UsernamePasswordAuthenticationFilter::class.java) .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.NEVER) } .csrf { it.disable() } .cors {} @@ -24,16 +68,19 @@ class WebSecurityConfig { .requestMatchers( HttpMethod.GET, "/actuator/health", - "/error", - "/hello", - "/hello/test", - "/timetables/to-replace" + "/error" ) .permitAll() + .requestMatchers( + HttpMethod.GET, + "/timetables/to-replace" + ) + .hasAuthority("admin") .requestMatchers( HttpMethod.POST, "/timetables/*" - ).permitAll() + ) + .hasAuthority("admin") .anyRequest().denyAll() } .build() diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index eada907c..336ef5b4 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -5,3 +5,4 @@ jore4.db.password=@jore4.db.password@ jore4.db.minConnections=@jore4.db.min.connections@ jore4.db.maxConnections=@jore4.db.max.connections@ jooq.sql.dialect=@jooq.sql.dialect@ +authentication.url=@authentication.url@ diff --git a/src/test/kotlin/fi/hsl/jore4/timetables/api/TimetablesCombineApiTest.kt b/src/test/kotlin/fi/hsl/jore4/timetables/api/TimetablesCombineApiTest.kt index 8dce318c..cea6670f 100644 --- a/src/test/kotlin/fi/hsl/jore4/timetables/api/TimetablesCombineApiTest.kt +++ b/src/test/kotlin/fi/hsl/jore4/timetables/api/TimetablesCombineApiTest.kt @@ -25,7 +25,7 @@ import org.springframework.test.web.servlet.result.MockMvcResultMatchers import java.util.UUID @ExtendWith(MockKExtension::class) -@AutoConfigureMockMvc +@AutoConfigureMockMvc(addFilters = false) @SpringBootTest @ActiveProfiles("test") class TimetablesCombineApiTest(@Autowired val mockMvc: MockMvc) { diff --git a/src/test/kotlin/fi/hsl/jore4/timetables/api/TimetablesReplaceApiTest.kt b/src/test/kotlin/fi/hsl/jore4/timetables/api/TimetablesReplaceApiTest.kt index bb8b09a2..ad5d2af4 100644 --- a/src/test/kotlin/fi/hsl/jore4/timetables/api/TimetablesReplaceApiTest.kt +++ b/src/test/kotlin/fi/hsl/jore4/timetables/api/TimetablesReplaceApiTest.kt @@ -27,7 +27,7 @@ import java.util.UUID private val LOGGER = KotlinLogging.logger {} @ExtendWith(MockKExtension::class) -@AutoConfigureMockMvc +@AutoConfigureMockMvc(addFilters = false) @SpringBootTest @ActiveProfiles("test") class TimetablesReplaceApiTest(@Autowired val mockMvc: MockMvc) { diff --git a/src/test/kotlin/fi/hsl/jore4/timetables/api/TimetablesToReplaceApiTest.kt b/src/test/kotlin/fi/hsl/jore4/timetables/api/TimetablesToReplaceApiTest.kt index 6b689bca..5cc2df18 100644 --- a/src/test/kotlin/fi/hsl/jore4/timetables/api/TimetablesToReplaceApiTest.kt +++ b/src/test/kotlin/fi/hsl/jore4/timetables/api/TimetablesToReplaceApiTest.kt @@ -23,7 +23,7 @@ import java.time.LocalDate import java.util.UUID @ExtendWith(MockKExtension::class) -@AutoConfigureMockMvc +@AutoConfigureMockMvc(addFilters = false) @SpringBootTest @ActiveProfiles("test") class TimetablesToReplaceApiTest(@Autowired val mockMvc: MockMvc) { diff --git a/src/test/kotlin/fi/hsl/jore4/timetables/config/RemoteAuthenticationProviderTest.kt b/src/test/kotlin/fi/hsl/jore4/timetables/config/RemoteAuthenticationProviderTest.kt new file mode 100644 index 00000000..40177589 --- /dev/null +++ b/src/test/kotlin/fi/hsl/jore4/timetables/config/RemoteAuthenticationProviderTest.kt @@ -0,0 +1,153 @@ +package fi.hsl.jore4.timetables.config + +import io.mockk.every +import io.mockk.mockkObject +import org.junit.jupiter.api.Test +import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpHeaders +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.util.Optional +import javax.net.ssl.SSLSession +import kotlin.jvm.optionals.getOrDefault +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class RemoteAuthenticationProviderTest { + + companion object { + private const val requestUrl = "http://testing:123" + private const val ROLE_HEADER = "X-Hasura-Role" + private const val ID_HEADER = "X-Hasura-Id" + } + + private val authenticationProperties = AuthenticationProperties(requestUrl) + private val remoteAuthenticationProvider = RemoteAuthenticationProvider(authenticationProperties) + + class CustomHttpResponse( + private val status: Int, + private val uri: URI, + private val id: String, + private val role: String + ) : HttpResponse { + + override fun statusCode(): Int { + return status + } + + override fun request(): HttpRequest { + TODO("Not implemented") + } + + override fun previousResponse(): Optional> { + return Optional.empty() + } + + override fun headers(): HttpHeaders { + return HttpHeaders.of(mutableMapOf("Content-Type" to listOf("application/json"))) { _, _ -> true } + } + + override fun body(): String { + if (status == 401) { + return "" + } + return """ + { + "$ID_HEADER": "$id", + "$ROLE_HEADER": "$role" + } + """.trimIndent() + } + + override fun sslSession(): Optional { + return Optional.empty() + } + + override fun uri(): URI { + return uri + } + + override fun version(): HttpClient.Version { + return HttpClient.Version.HTTP_2 + } + } + + private fun setupResponseForToken( + sessionToken: String, + userName: String, + role: String + ) { + mockkObject(RemoteAuthenticationProvider) + every { + RemoteAuthenticationProvider.sendRequest( + match { request -> + request.headers() + .firstValue("cookie") + .map { it.substringAfter("SESSION=") } + .map { it == sessionToken } + .getOrDefault(false) + } + ) + } returns CustomHttpResponse( + 200, + URI(requestUrl), + userName, + role + ) + + every { + RemoteAuthenticationProvider.sendRequest( + match { request -> + request.headers() + .firstValue("cookie") + .map { it.substringAfter("SESSION=") } + .map { it != sessionToken } + .getOrDefault(true) + } + ) + } returns CustomHttpResponse( + 401, + URI(requestUrl), + "", + "" + ) + } + + @Test + fun `should authenticate the user`() { + val requestedRole = "admin" + val userName = "user123" + val sessionToken = "sessionToken123" + + setupResponseForToken(sessionToken, userName, requestedRole) + + val preAuth = PreAuthenticatedAuthenticationToken(sessionToken, requestedRole) + + val value = remoteAuthenticationProvider.authenticate(preAuth) + + assertTrue(value.isAuthenticated) + assertEquals(userName, value.principal) + assertEquals(1, value.authorities.size) + assertEquals(requestedRole, value.authorities.first().authority) + } + + @Test + fun `should fail to authenticate`() { + val requestedRole = "admin" + val userName = "user123" + val sessionToken = "sessionToken123" + + setupResponseForToken(sessionToken, userName, requestedRole) + + val preAuth = PreAuthenticatedAuthenticationToken("wrong token", requestedRole) + + val value = remoteAuthenticationProvider.authenticate(preAuth) + + assertFalse(value.isAuthenticated) + assertEquals("", value.principal) + assertEquals(0, value.authorities.size) + } +}