diff --git a/README.md b/README.md index 06c8b79..d378fa9 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,26 @@ pseudonymisierte Patienten-ID einsehen. Wurde kein Administrator-Account konfiguriert, sind diese Inhalte generell nicht verfügbar. +### Tokenbasierte Authentifizierung für MTBFile-Endpunkt + +Die Anwendung unterstützt das Erstellen und Nutzen einer tokenbasierten Authentifizierung für den MTB-File-Endpunkt. + +Dies kann mit der Umgebungsvariable `APP_SECURITY_ENABLE_TOKENS` aktiviert (`true` oder `false`) werden +und ist als Standardeinstellung nicht aktiv. + +Ist diese Einstellung aktiviert worden, ist es Administratoren möglich, Zugriffstokens für Onkostar zu erstellen, die +zur Nutzung des MTB-File-Endpunkts eine HTTP-Basic-Authentifizierung voraussetzen. + +![Tokenverwaltung](docs/tokens.png) + +In diesem Fall können den Endpunkt für das Onkostar-Plugin **[onkostar-plugin-dnpmexport](https://github.com/CCC-MF/onkostar-plugin-dnpmexport)** wie folgt konfigurieren: + +``` +https://testonkostar:MTg1NTL...NGU4@etl.example.com/mtbfile +``` + +Ist die Verwendung von Tokens aktiv, werden Anfragen ohne die Angabe der Token-Information abgelehnt. + ### Transformation von Werten In Onkostar kann es vorkommen, dass ein Wert eines Merkmalskatalogs an einem Standort angepasst wurde und dadurch nicht dem Wert entspricht, diff --git a/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfigProperties.kt b/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfigProperties.kt index 08a45bb..aacf97d 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfigProperties.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfigProperties.kt @@ -86,6 +86,7 @@ data class KafkaTargetProperties( data class SecurityConfigProperties( val adminUser: String?, val adminPassword: String?, + val enableTokens: Boolean = false ) { companion object { const val NAME = "app.security" diff --git a/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfiguration.kt b/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfiguration.kt index 83cc568..92965a6 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfiguration.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfiguration.kt @@ -25,6 +25,8 @@ import dev.dnpm.etl.processor.pseudonym.AnonymizingGenerator import dev.dnpm.etl.processor.pseudonym.Generator import dev.dnpm.etl.processor.pseudonym.GpasPseudonymGenerator import dev.dnpm.etl.processor.pseudonym.PseudonymizeService +import dev.dnpm.etl.processor.services.TokenRepository +import dev.dnpm.etl.processor.services.TokenService import dev.dnpm.etl.processor.services.Transformation import dev.dnpm.etl.processor.services.TransformationService import org.slf4j.LoggerFactory @@ -37,6 +39,9 @@ import org.springframework.retry.policy.SimpleRetryPolicy import org.springframework.retry.support.RetryTemplate import org.springframework.retry.support.RetryTemplateBuilder import org.springframework.scheduling.annotation.EnableScheduling +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.security.provisioning.InMemoryUserDetailsManager +import org.springframework.security.provisioning.UserDetailsManager import reactor.core.publisher.Sinks import kotlin.time.Duration.Companion.seconds import kotlin.time.toJavaDuration @@ -114,6 +119,12 @@ class AppConfiguration { .build() } + @ConditionalOnProperty(value = ["app.security.enable-tokens"], havingValue = "true") + @Bean + fun tokenService(userDetailsManager: InMemoryUserDetailsManager, passwordEncoder: PasswordEncoder, tokenRepository: TokenRepository): TokenService { + return TokenService(userDetailsManager, passwordEncoder, tokenRepository) + } + @Bean fun statisticsUpdateProducer(): Sinks.Many { return Sinks.many().multicast().directBestEffort() diff --git a/src/main/kotlin/dev/dnpm/etl/processor/config/AppSecurityConfiguration.kt b/src/main/kotlin/dev/dnpm/etl/processor/config/AppSecurityConfiguration.kt index e0cff94..22a2e34 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/config/AppSecurityConfiguration.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/config/AppSecurityConfiguration.kt @@ -24,15 +24,21 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration +import org.springframework.core.Ordered +import org.springframework.core.annotation.Order +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.annotation.web.invoke +import org.springframework.security.config.http.SessionCreationPolicy import org.springframework.security.core.userdetails.User import org.springframework.security.core.userdetails.UserDetails import org.springframework.security.crypto.factory.PasswordEncoderFactories import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.security.provisioning.InMemoryUserDetailsManager import org.springframework.security.web.SecurityFilterChain +import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy import java.util.* @@ -76,12 +82,16 @@ class AppSecurityConfiguration( } @Bean - fun filterChain(http: HttpSecurity): SecurityFilterChain { + fun filterChain(http: HttpSecurity, passwordEncoder: PasswordEncoder): SecurityFilterChain { http { authorizeRequests { authorize("/configs/**", hasRole("ADMIN")) + authorize("/mtbfile/**", hasAnyRole("MTBFILE")) authorize(anyRequest, permitAll) } + httpBasic { + realmName = "ETL-Processor" + } formLogin { loginPage = "/login" } diff --git a/src/main/kotlin/dev/dnpm/etl/processor/services/TokenService.kt b/src/main/kotlin/dev/dnpm/etl/processor/services/TokenService.kt new file mode 100644 index 0000000..f084408 --- /dev/null +++ b/src/main/kotlin/dev/dnpm/etl/processor/services/TokenService.kt @@ -0,0 +1,92 @@ +/* + * This file is part of ETL-Processor + * + * Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package dev.dnpm.etl.processor.services + +import jakarta.annotation.PostConstruct +import org.springframework.data.annotation.Id +import org.springframework.data.relational.core.mapping.Table +import org.springframework.data.repository.CrudRepository +import org.springframework.data.repository.findByIdOrNull +import org.springframework.security.core.userdetails.User +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.security.provisioning.InMemoryUserDetailsManager +import java.time.Instant +import java.util.* + +class TokenService( + private val userDetailsManager: InMemoryUserDetailsManager, + private val passwordEncoder: PasswordEncoder, + private val tokenRepository: TokenRepository +) { + + @PostConstruct + fun setup() { + tokenRepository.findAll().forEach { + userDetailsManager.createUser( + User.withUsername(it.username) + .password(it.password) + .roles("MTBFILE") + .build() + ) + } + } + + fun addToken(name: String): Result { + val username = name.lowercase().replace("""[^a-z0-9]""".toRegex(), "") + if (userDetailsManager.userExists(username)) { + return Result.failure(RuntimeException("Cannot use token name")) + } + + val password = Base64.getEncoder().encodeToString(UUID.randomUUID().toString().encodeToByteArray()) + val encodedPassword = passwordEncoder.encode(password).toString() + + userDetailsManager.createUser( + User.withUsername(username) + .password(encodedPassword) + .roles("MTBFILE") + .build() + ) + + tokenRepository.save(Token(name = name, username = username, password = encodedPassword)) + + return Result.success("$username:$password") + } + + fun deleteToken(id: Long) { + val token = tokenRepository.findByIdOrNull(id) ?: return + userDetailsManager.deleteUser(token.username) + tokenRepository.delete(token) + } + + fun findAll(): List { + return tokenRepository.findAll().toList() + } +} + +@Table("token") +data class Token( + @Id val id: Long? = null, + val name: String, + val username: String, + val password: String, + val createdAt: Instant = Instant.now() +) + +interface TokenRepository : CrudRepository \ No newline at end of file diff --git a/src/main/kotlin/dev/dnpm/etl/processor/web/ConfigController.kt b/src/main/kotlin/dev/dnpm/etl/processor/web/ConfigController.kt index be291ea..dbedee5 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/web/ConfigController.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/web/ConfigController.kt @@ -22,14 +22,15 @@ package dev.dnpm.etl.processor.web import dev.dnpm.etl.processor.monitoring.ConnectionCheckService import dev.dnpm.etl.processor.output.MtbFileSender import dev.dnpm.etl.processor.pseudonym.Generator +import dev.dnpm.etl.processor.services.Token +import dev.dnpm.etl.processor.services.TokenService import dev.dnpm.etl.processor.services.TransformationService import org.springframework.beans.factory.annotation.Qualifier import org.springframework.http.MediaType import org.springframework.http.codec.ServerSentEvent import org.springframework.stereotype.Controller import org.springframework.ui.Model -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.* import reactor.core.publisher.Flux import reactor.core.publisher.Sinks @@ -41,8 +42,8 @@ class ConfigController( private val transformationService: TransformationService, private val pseudonymGenerator: Generator, private val mtbFileSender: MtbFileSender, - private val connectionCheckService: ConnectionCheckService - + private val connectionCheckService: ConnectionCheckService, + private val tokenService: TokenService? ) { @GetMapping @@ -51,6 +52,12 @@ class ConfigController( model.addAttribute("mtbFileSender", mtbFileSender.javaClass.simpleName) model.addAttribute("mtbFileEndpoint", mtbFileSender.endpoint()) model.addAttribute("connectionAvailable", connectionCheckService.connectionAvailable()) + model.addAttribute("tokensEnabled", tokenService != null) + if (tokenService != null) { + model.addAttribute("tokens", tokenService.findAll()) + } else { + model.addAttribute("tokens", listOf()) + } model.addAttribute("transformations", transformationService.getTransformations()) return "configs" @@ -61,10 +68,50 @@ class ConfigController( model.addAttribute("mtbFileSender", mtbFileSender.javaClass.simpleName) model.addAttribute("mtbFileEndpoint", mtbFileSender.endpoint()) model.addAttribute("connectionAvailable", connectionCheckService.connectionAvailable()) + if (tokenService != null) { + model.addAttribute("tokensEnabled", true) + model.addAttribute("tokens", tokenService.findAll()) + } else { + model.addAttribute("tokens", listOf()) + } return "configs/connectionAvailable" } + @PostMapping(path = ["tokens"]) + fun addToken(@ModelAttribute("name") name: String, model: Model): String { + if (tokenService == null) { + model.addAttribute("tokensEnabled", false) + model.addAttribute("success", false) + } else { + model.addAttribute("tokensEnabled", true) + val result = tokenService.addToken(name) + if (result.isSuccess) { + model.addAttribute("newTokenValue", result.getOrDefault("")) + model.addAttribute("success", true) + } else { + model.addAttribute("success", false) + } + model.addAttribute("tokens", tokenService.findAll()) + } + + return "configs/tokens" + } + + @DeleteMapping(path = ["tokens/{id}"]) + fun deleteToken(@PathVariable id: Long, model: Model): String { + if (tokenService != null) { + tokenService.deleteToken(id) + + model.addAttribute("tokensEnabled", true) + model.addAttribute("tokens", tokenService.findAll()) + } else { + model.addAttribute("tokensEnabled", false) + model.addAttribute("tokens", listOf()) + } + return "configs/tokens" + } + @GetMapping(path = ["events"], produces = [MediaType.TEXT_EVENT_STREAM_VALUE]) fun events(): Flux> { return configsUpdateProducer.asFlux().map { diff --git a/src/main/kotlin/dev/dnpm/etl/processor/web/MtbFileRestController.kt b/src/main/kotlin/dev/dnpm/etl/processor/web/MtbFileRestController.kt index 9b441f6..d417a1f 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/web/MtbFileRestController.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/web/MtbFileRestController.kt @@ -27,13 +27,19 @@ import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* @RestController +@RequestMapping(path = ["mtbfile"]) class MtbFileRestController( private val requestProcessor: RequestProcessor, ) { private val logger = LoggerFactory.getLogger(MtbFileRestController::class.java) - @PostMapping(path = ["/mtbfile"]) + @GetMapping + fun info(): ResponseEntity { + return ResponseEntity.ok("Test") + } + + @PostMapping fun mtbFile(@RequestBody mtbFile: MtbFile): ResponseEntity { if (mtbFile.consent.status == Consent.Status.ACTIVE) { logger.debug("Accepted MTB File for processing") @@ -45,7 +51,7 @@ class MtbFileRestController( return ResponseEntity.accepted().build() } - @DeleteMapping(path = ["/mtbfile/{patientId}"]) + @DeleteMapping(path = ["{patientId}"]) fun deleteData(@PathVariable patientId: String): ResponseEntity { logger.debug("Accepted patient ID to process deletion") requestProcessor.processDeletion(patientId) diff --git a/src/main/resources/db/migration/mariadb/V0_2_0__Tokens.sql b/src/main/resources/db/migration/mariadb/V0_2_0__Tokens.sql new file mode 100644 index 0000000..98e27d9 --- /dev/null +++ b/src/main/resources/db/migration/mariadb/V0_2_0__Tokens.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS token +( + id int auto_increment primary key, + name varchar(255) not null, + username varchar(255) not null unique, + password varchar(255) not null, + created_at datetime default utc_timestamp() not null +); \ No newline at end of file diff --git a/src/main/resources/db/migration/postgresql/V0_2_0__Tokens.sql b/src/main/resources/db/migration/postgresql/V0_2_0__Tokens.sql new file mode 100644 index 0000000..c89c52e --- /dev/null +++ b/src/main/resources/db/migration/postgresql/V0_2_0__Tokens.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS token +( + id serial, + name varchar(255) not null, + username varchar(255) not null unique, + password varchar(255) not null, + created_at timestamp with time zone default now() not null, + PRIMARY KEY (id) +); \ No newline at end of file diff --git a/src/main/resources/static/scripts.js b/src/main/resources/static/scripts.js index 73ad71b..fdd3f52 100644 --- a/src/main/resources/static/scripts.js +++ b/src/main/resources/static/scripts.js @@ -4,14 +4,17 @@ const dateFormat = new Intl.DateTimeFormat('de-DE', dateFormatOptions); const dateTimeFormatOptions = { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: 'numeric', second: 'numeric' }; const dateTimeFormat = new Intl.DateTimeFormat('de-DE', dateTimeFormatOptions); -window.addEventListener('load', () => { +const formatTimeElements = () => { Array.from(document.getElementsByTagName('time')).forEach((timeTag) => { let date = Date.parse(timeTag.getAttribute('datetime')); if (! isNaN(date)) { timeTag.innerText = dateTimeFormat.format(date); } }); -}); +}; + +window.addEventListener('load', formatTimeElements); +window.addEventListener('htmx:afterRequest', formatTimeElements); function drawPieChart(url, elemId, title, data) { if (data) { diff --git a/src/main/resources/static/style.css b/src/main/resources/static/style.css index e2bf65a..3249aad 100644 --- a/src/main/resources/static/style.css +++ b/src/main/resources/static/style.css @@ -202,13 +202,15 @@ form.samplecode-input input:focus-visible { background: none; } -.login-form form * { +.login-form form *, +.token-form form * { padding: 0.5em; border: 1px solid var(--table-border); border-radius: 3px; } -.login-form button { +.login-form button, +.token-form button { margin: 1em 0; background: var(--bg-blue); color: white; @@ -535,4 +537,22 @@ a.reload { font-size: .6em; align-content: center; justify-content: center; +} + +.new-token { + padding: 1em; + background: var(--bg-green-op); +} + +.new-token > pre { + margin: 0; + border: 1px solid var(--bg-green); + padding: .5em; + width: max-content; + display: inline-block; +} + +.no-token { + padding: 1em; + background: var(--bg-red-op); } \ No newline at end of file diff --git a/src/main/resources/templates/configs.html b/src/main/resources/templates/configs.html index 3c3d744..ebef7ca 100644 --- a/src/main/resources/templates/configs.html +++ b/src/main/resources/templates/configs.html @@ -37,6 +37,9 @@ +
+
+
@@ -86,6 +89,7 @@
+ diff --git a/src/main/resources/templates/configs/tokens.html b/src/main/resources/templates/configs/tokens.html new file mode 100644 index 0000000..e707fbf --- /dev/null +++ b/src/main/resources/templates/configs/tokens.html @@ -0,0 +1,39 @@ +
+

Tokens

+

Die Verwendung von Tokens ist nicht aktiviert.

+
+ +
+

Tokens

+
+
Noch keine Tokens vorhanden.
+ + + + + + + + + + + + + + + +
NameErstellt
[[ ${token.name} ]]
+
+ Verwendung über HTTP-Basic. Bitte notieren, wird nicht erneut angezeigt:
[[ ${newTokenValue} ]]
+
+
+ Das Token konnte nicht erzeugt werden. Versuchen Sie einen anderen Namen. +
+
+
+ + +
+
+
+
\ No newline at end of file diff --git a/src/main/resources/templates/login.html b/src/main/resources/templates/login.html index 018122d..9a63b46 100644 --- a/src/main/resources/templates/login.html +++ b/src/main/resources/templates/login.html @@ -15,7 +15,7 @@
- +