summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul-Christian Volkmer2024-01-18 14:13:15 +0100
committerPaul-Christian Volkmer2024-01-18 14:13:15 +0100
commit30cf0fd22e492fd2b0052ddfd5b808da51b36052 (patch)
tree5745f1a2be493e9149cc986833d904bd62e01bfc
parent531a8589db2bf170e6272602ccb4a3c4457186d8 (diff)
feat #29: add initial support for mtbfile api tokens
-rw-r--r--src/main/kotlin/dev/dnpm/etl/processor/config/AppConfigProperties.kt1
-rw-r--r--src/main/kotlin/dev/dnpm/etl/processor/config/AppConfiguration.kt11
-rw-r--r--src/main/kotlin/dev/dnpm/etl/processor/config/AppSecurityConfiguration.kt12
-rw-r--r--src/main/kotlin/dev/dnpm/etl/processor/services/TokenService.kt92
-rw-r--r--src/main/kotlin/dev/dnpm/etl/processor/web/ConfigController.kt55
-rw-r--r--src/main/kotlin/dev/dnpm/etl/processor/web/MtbFileRestController.kt10
-rw-r--r--src/main/resources/db/migration/mariadb/V0_2_0__Tokens.sql8
-rw-r--r--src/main/resources/db/migration/postgresql/V0_2_0__Tokens.sql9
-rw-r--r--src/main/resources/static/scripts.js7
-rw-r--r--src/main/resources/static/style.css24
-rw-r--r--src/main/resources/templates/configs.html4
-rw-r--r--src/main/resources/templates/configs/tokens.html39
-rw-r--r--src/main/resources/templates/login.html2
13 files changed, 262 insertions, 12 deletions
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<Any> {
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 <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> \ 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<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 {
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<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)
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 @@
</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>
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 @@
+<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> \ 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 @@
<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>