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.
### 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,

View File

@ -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"

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.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<Any> {
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.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"
}

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.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<Token>())
}
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<Token>())
}
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])
fun events(): Flux<ServerSentEvent<Any>> {
return configsUpdateProducer.asFlux().map {

View File

@ -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<String> {
return ResponseEntity.ok("Test")
}
@PostMapping
fun mtbFile(@RequestBody mtbFile: MtbFile): ResponseEntity<Void> {
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<Void> {
logger.debug("Accepted patient ID to process deletion")
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 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) {

View File

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

View File

@ -37,6 +37,9 @@
</table>
</section>
<section th:insert="~{configs/tokens.html}">
</section>
<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>
@ -86,6 +89,7 @@
</th:block>
</section>
</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/ext/sse.js}"></script>
</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}">
<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="">
<button class="" type="submit">Anmelden</button>
<button type="submit">Anmelden</button>
</form>
</div>
</main>