Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion development.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
3 changes: 3 additions & 0 deletions profiles/dev/config.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -21,7 +22,7 @@ fun main(args: Array<String>) {
* Spring boot application definition.
*/
@SpringBootApplication
@EnableConfigurationProperties(DatabaseProperties::class, JOOQProperties::class)
@EnableConfigurationProperties(AuthenticationProperties::class, DatabaseProperties::class, JOOQProperties::class)
class TimetablesApiApplication {

@Bean
Expand Down
Original file line number Diff line number Diff line change
@@ -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
)
Original file line number Diff line number Diff line change
@@ -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<String>): Authentication {
val authResponseMap = objectMapper.readValue<MutableMap<Any, Any>>(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<String> {
return httpClient.send(authRequest, HttpResponse.BodyHandlers.ofString()).also {
logger.debug("Authorization response $it")
}
}
}

private fun authenticateWithHasuraWebhook(authentication: Authentication?): HttpResponse<String> {
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
}
}
Original file line number Diff line number Diff line change
@@ -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 {}
Expand All @@ -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()
Expand Down
1 change: 1 addition & 0 deletions src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ [email protected]@
[email protected]@
[email protected]@
[email protected]@
[email protected]@
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading