1
0
mirror of https://github.com/pcvolkmer/etl-processor.git synced 2025-04-19 17:26:51 +00:00

Merge pull request #30 from CCC-MF/issue_29

Issue #29: Unterstützung für Endpoint-Tokens
This commit is contained in:
Paul-Christian Volkmer 2024-01-18 14:29:52 +01:00 committed by GitHub
commit 358373cf70
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 282 additions and 12 deletions

View File

@ -81,6 +81,26 @@ pseudonymisierte Patienten-ID einsehen.
Wurde kein Administrator-Account konfiguriert, sind diese Inhalte generell nicht verfügbar. 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 ### Transformation von Werten
In Onkostar kann es vorkommen, dass ein Wert eines Merkmalskatalogs an einem Standort angepasst wurde und dadurch nicht dem Wert entspricht, In Onkostar kann es vorkommen, dass ein Wert eines Merkmalskatalogs an einem Standort angepasst wurde und dadurch nicht dem Wert entspricht,

View File

@ -86,6 +86,7 @@ data class KafkaTargetProperties(
data class SecurityConfigProperties( data class SecurityConfigProperties(
val adminUser: String?, val adminUser: String?,
val adminPassword: String?, val adminPassword: String?,
val enableTokens: Boolean = false
) { ) {
companion object { companion object {
const val NAME = "app.security" const val NAME = "app.security"

View File

@ -25,6 +25,8 @@ import dev.dnpm.etl.processor.pseudonym.AnonymizingGenerator
import dev.dnpm.etl.processor.pseudonym.Generator import dev.dnpm.etl.processor.pseudonym.Generator
import dev.dnpm.etl.processor.pseudonym.GpasPseudonymGenerator import dev.dnpm.etl.processor.pseudonym.GpasPseudonymGenerator
import dev.dnpm.etl.processor.pseudonym.PseudonymizeService 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.Transformation
import dev.dnpm.etl.processor.services.TransformationService import dev.dnpm.etl.processor.services.TransformationService
import org.slf4j.LoggerFactory 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.RetryTemplate
import org.springframework.retry.support.RetryTemplateBuilder import org.springframework.retry.support.RetryTemplateBuilder
import org.springframework.scheduling.annotation.EnableScheduling 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 reactor.core.publisher.Sinks
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
import kotlin.time.toJavaDuration import kotlin.time.toJavaDuration
@ -114,6 +119,12 @@ class AppConfiguration {
.build() .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 @Bean
fun statisticsUpdateProducer(): Sinks.Many<Any> { fun statisticsUpdateProducer(): Sinks.Many<Any> {
return Sinks.many().multicast().directBestEffort() return Sinks.many().multicast().directBestEffort()

View File

@ -24,15 +24,21 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration 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.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.config.annotation.web.invoke 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.User
import org.springframework.security.core.userdetails.UserDetails import org.springframework.security.core.userdetails.UserDetails
import org.springframework.security.crypto.factory.PasswordEncoderFactories import org.springframework.security.crypto.factory.PasswordEncoderFactories
import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.security.provisioning.InMemoryUserDetailsManager import org.springframework.security.provisioning.InMemoryUserDetailsManager
import org.springframework.security.web.SecurityFilterChain import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy
import java.util.* import java.util.*
@ -76,12 +82,16 @@ class AppSecurityConfiguration(
} }
@Bean @Bean
fun filterChain(http: HttpSecurity): SecurityFilterChain { fun filterChain(http: HttpSecurity, passwordEncoder: PasswordEncoder): SecurityFilterChain {
http { http {
authorizeRequests { authorizeRequests {
authorize("/configs/**", hasRole("ADMIN")) authorize("/configs/**", hasRole("ADMIN"))
authorize("/mtbfile/**", hasAnyRole("MTBFILE"))
authorize(anyRequest, permitAll) authorize(anyRequest, permitAll)
} }
httpBasic {
realmName = "ETL-Processor"
}
formLogin { formLogin {
loginPage = "/login" loginPage = "/login"
} }

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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<String> {
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<Token> {
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<Token, Long>

View File

@ -22,14 +22,15 @@ package dev.dnpm.etl.processor.web
import dev.dnpm.etl.processor.monitoring.ConnectionCheckService import dev.dnpm.etl.processor.monitoring.ConnectionCheckService
import dev.dnpm.etl.processor.output.MtbFileSender import dev.dnpm.etl.processor.output.MtbFileSender
import dev.dnpm.etl.processor.pseudonym.Generator 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 dev.dnpm.etl.processor.services.TransformationService
import org.springframework.beans.factory.annotation.Qualifier import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.http.MediaType import org.springframework.http.MediaType
import org.springframework.http.codec.ServerSentEvent import org.springframework.http.codec.ServerSentEvent
import org.springframework.stereotype.Controller import org.springframework.stereotype.Controller
import org.springframework.ui.Model import org.springframework.ui.Model
import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.*
import org.springframework.web.bind.annotation.RequestMapping
import reactor.core.publisher.Flux import reactor.core.publisher.Flux
import reactor.core.publisher.Sinks import reactor.core.publisher.Sinks
@ -41,8 +42,8 @@ class ConfigController(
private val transformationService: TransformationService, private val transformationService: TransformationService,
private val pseudonymGenerator: Generator, private val pseudonymGenerator: Generator,
private val mtbFileSender: MtbFileSender, private val mtbFileSender: MtbFileSender,
private val connectionCheckService: ConnectionCheckService private val connectionCheckService: ConnectionCheckService,
private val tokenService: TokenService?
) { ) {
@GetMapping @GetMapping
@ -51,6 +52,12 @@ class ConfigController(
model.addAttribute("mtbFileSender", mtbFileSender.javaClass.simpleName) model.addAttribute("mtbFileSender", mtbFileSender.javaClass.simpleName)
model.addAttribute("mtbFileEndpoint", mtbFileSender.endpoint()) model.addAttribute("mtbFileEndpoint", mtbFileSender.endpoint())
model.addAttribute("connectionAvailable", connectionCheckService.connectionAvailable()) model.addAttribute("connectionAvailable", connectionCheckService.connectionAvailable())
model.addAttribute("tokensEnabled", tokenService != null)
if (tokenService != null) {
model.addAttribute("tokens", tokenService.findAll())
} else {
model.addAttribute("tokens", listOf<Token>())
}
model.addAttribute("transformations", transformationService.getTransformations()) model.addAttribute("transformations", transformationService.getTransformations())
return "configs" return "configs"
@ -61,10 +68,50 @@ class ConfigController(
model.addAttribute("mtbFileSender", mtbFileSender.javaClass.simpleName) model.addAttribute("mtbFileSender", mtbFileSender.javaClass.simpleName)
model.addAttribute("mtbFileEndpoint", mtbFileSender.endpoint()) model.addAttribute("mtbFileEndpoint", mtbFileSender.endpoint())
model.addAttribute("connectionAvailable", connectionCheckService.connectionAvailable()) model.addAttribute("connectionAvailable", connectionCheckService.connectionAvailable())
if (tokenService != null) {
model.addAttribute("tokensEnabled", true)
model.addAttribute("tokens", tokenService.findAll())
} else {
model.addAttribute("tokens", listOf<Token>())
}
return "configs/connectionAvailable" 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<Token>())
}
return "configs/tokens"
}
@GetMapping(path = ["events"], produces = [MediaType.TEXT_EVENT_STREAM_VALUE]) @GetMapping(path = ["events"], produces = [MediaType.TEXT_EVENT_STREAM_VALUE])
fun events(): Flux<ServerSentEvent<Any>> { fun events(): Flux<ServerSentEvent<Any>> {
return configsUpdateProducer.asFlux().map { return configsUpdateProducer.asFlux().map {

View File

@ -27,13 +27,19 @@ import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.* import org.springframework.web.bind.annotation.*
@RestController @RestController
@RequestMapping(path = ["mtbfile"])
class MtbFileRestController( class MtbFileRestController(
private val requestProcessor: RequestProcessor, private val requestProcessor: RequestProcessor,
) { ) {
private val logger = LoggerFactory.getLogger(MtbFileRestController::class.java) private val logger = LoggerFactory.getLogger(MtbFileRestController::class.java)
@PostMapping(path = ["/mtbfile"]) @GetMapping
fun info(): ResponseEntity<String> {
return ResponseEntity.ok("Test")
}
@PostMapping
fun mtbFile(@RequestBody mtbFile: MtbFile): ResponseEntity<Void> { fun mtbFile(@RequestBody mtbFile: MtbFile): ResponseEntity<Void> {
if (mtbFile.consent.status == Consent.Status.ACTIVE) { if (mtbFile.consent.status == Consent.Status.ACTIVE) {
logger.debug("Accepted MTB File for processing") logger.debug("Accepted MTB File for processing")
@ -45,7 +51,7 @@ class MtbFileRestController(
return ResponseEntity.accepted().build() return ResponseEntity.accepted().build()
} }
@DeleteMapping(path = ["/mtbfile/{patientId}"]) @DeleteMapping(path = ["{patientId}"])
fun deleteData(@PathVariable patientId: String): ResponseEntity<Void> { fun deleteData(@PathVariable patientId: String): ResponseEntity<Void> {
logger.debug("Accepted patient ID to process deletion") logger.debug("Accepted patient ID to process deletion")
requestProcessor.processDeletion(patientId) requestProcessor.processDeletion(patientId)

View File

@ -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
);

View File

@ -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)
);

View File

@ -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 dateTimeFormatOptions = { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: 'numeric', second: 'numeric' };
const dateTimeFormat = new Intl.DateTimeFormat('de-DE', dateTimeFormatOptions); const dateTimeFormat = new Intl.DateTimeFormat('de-DE', dateTimeFormatOptions);
window.addEventListener('load', () => { const formatTimeElements = () => {
Array.from(document.getElementsByTagName('time')).forEach((timeTag) => { Array.from(document.getElementsByTagName('time')).forEach((timeTag) => {
let date = Date.parse(timeTag.getAttribute('datetime')); let date = Date.parse(timeTag.getAttribute('datetime'));
if (! isNaN(date)) { if (! isNaN(date)) {
timeTag.innerText = dateTimeFormat.format(date); timeTag.innerText = dateTimeFormat.format(date);
} }
}); });
}); };
window.addEventListener('load', formatTimeElements);
window.addEventListener('htmx:afterRequest', formatTimeElements);
function drawPieChart(url, elemId, title, data) { function drawPieChart(url, elemId, title, data) {
if (data) { if (data) {

View File

@ -202,13 +202,15 @@ form.samplecode-input input:focus-visible {
background: none; background: none;
} }
.login-form form * { .login-form form *,
.token-form form * {
padding: 0.5em; padding: 0.5em;
border: 1px solid var(--table-border); border: 1px solid var(--table-border);
border-radius: 3px; border-radius: 3px;
} }
.login-form button { .login-form button,
.token-form button {
margin: 1em 0; margin: 1em 0;
background: var(--bg-blue); background: var(--bg-blue);
color: white; color: white;
@ -535,4 +537,22 @@ a.reload {
font-size: .6em; font-size: .6em;
align-content: center; align-content: center;
justify-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);
} }

View File

@ -37,6 +37,9 @@
</table> </table>
</section> </section>
<section th:insert="~{configs/tokens.html}">
</section>
<section hx-ext="sse" th:sse-connect="@{/configs/events}"> <section hx-ext="sse" th:sse-connect="@{/configs/events}">
<div th:insert="~{configs/connectionAvailable.html}" th:hx-get="@{/configs?connectionAvailable}" hx-trigger="sse:connection-available"> <div th:insert="~{configs/connectionAvailable.html}" th:hx-get="@{/configs?connectionAvailable}" hx-trigger="sse:connection-available">
</div> </div>
@ -86,6 +89,7 @@
</th:block> </th:block>
</section> </section>
</main> </main>
<script th:src="@{/scripts.js}"></script>
<script th:src="@{/webjars/htmx.org/dist/htmx.min.js}"></script> <script th:src="@{/webjars/htmx.org/dist/htmx.min.js}"></script>
<script th:src="@{/webjars/htmx.org/dist/ext/sse.js}"></script> <script th:src="@{/webjars/htmx.org/dist/ext/sse.js}"></script>
</body> </body>

View File

@ -0,0 +1,39 @@
<div th:if="${not tokensEnabled}">
<h2><span></span> Tokens</h2>
<p>Die Verwendung von Tokens ist nicht aktiviert.</p>
</div>
<div id="tokens" th:if="${tokensEnabled}">
<h2><span></span> Tokens</h2>
<div class="border">
<div th:if="${tokens.isEmpty()}">Noch keine Tokens vorhanden.</div>
<table th:if="${not tokens.isEmpty()}">
<thead>
<tr>
<th>Name</th>
<th>Erstellt</th>
<th></th>
</tr>
</thead>
<tbody>
<tr th:each="token : ${tokens}">
<td>[[ ${token.name} ]]</td>
<td><time th:datetime="${token.createdAt}">[[ ${token.createdAt} ]]</time></td>
<td><button class="btn btn-red" th:hx-delete="@{/configs/tokens/{id}(id=${token.id})}" hx-target="#tokens">Löschen</button></td>
</tr>
</tbody>
</table>
<div th:if="${newTokenValue != null and success}" class="new-token">
Verwendung über HTTP-Basic. Bitte notieren, wird nicht erneut angezeigt: <pre>[[ ${newTokenValue} ]]</pre>
</div>
<div th:if="${success != null and not success}" class="no-token">
Das Token konnte nicht erzeugt werden. Versuchen Sie einen anderen Namen.
</div>
<div class="token-form">
<form th:hx-post="@{/configs/tokens}" hx-target="#tokens">
<input placeholder="Token-Name" name="name" required />
<button>Token Erstellen</button>
</form>
</div>
</div>
</div>

View File

@ -15,7 +15,7 @@
<form method="post" th:action="@{/login}"> <form method="post" th:action="@{/login}">
<input type="text" id="username" name="username" class="form-control" placeholder="Username" required="" autofocus=""> <input type="text" id="username" name="username" class="form-control" placeholder="Username" required="" autofocus="">
<input type="password" id="password" name="password" class="form-control" placeholder="Password" required=""> <input type="password" id="password" name="password" class="form-control" placeholder="Password" required="">
<button class="" type="submit">Anmelden</button> <button type="submit">Anmelden</button>
</form> </form>
</div> </div>
</main> </main>