summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorPaul-Christian Volkmer2024-03-01 14:09:06 +0100
committerGitHub2024-03-01 14:09:06 +0100
commit5928d52237cd5935fb751b3f667617e84b5bbae2 (patch)
tree9ab899a2581ebd037320a7c951db1711392e129c /src
parent0b6decf88d9084616874d65827e7eb1e8050d1c5 (diff)
parent1eb40b40c99b4aed31da983332e8b44275c19dd9 (diff)
Merge pull request #48 from CCC-MF/issue_36
Freigabe und Berechtigung für OIDC-Benutzer
Diffstat (limited to 'src')
-rw-r--r--src/main/kotlin/dev/dnpm/etl/processor/config/AppConfigProperties.kt4
-rw-r--r--src/main/kotlin/dev/dnpm/etl/processor/config/AppSecurityConfiguration.kt58
-rw-r--r--src/main/kotlin/dev/dnpm/etl/processor/security/UserRole.kt44
-rw-r--r--src/main/kotlin/dev/dnpm/etl/processor/services/UserRoleService.kt61
-rw-r--r--src/main/kotlin/dev/dnpm/etl/processor/web/ConfigController.kt44
-rw-r--r--src/main/resources/db/migration/mariadb/V0_3_0__UserRoles.sql7
-rw-r--r--src/main/resources/db/migration/postgresql/V0_3_0__UserRoles.sql8
-rw-r--r--src/main/resources/static/style.css26
-rw-r--r--src/main/resources/templates/configs.html3
-rw-r--r--src/main/resources/templates/configs/userroles.html39
-rw-r--r--src/main/resources/templates/index.html10
-rw-r--r--src/main/resources/templates/login.html1
12 files changed, 292 insertions, 13 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 e8d6bfc..d951c60 100644
--- a/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfigProperties.kt
+++ b/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfigProperties.kt
@@ -19,6 +19,7 @@
package dev.dnpm.etl.processor.config
+import dev.dnpm.etl.processor.security.Role
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.boot.context.properties.DeprecatedConfigurationProperty
@@ -102,7 +103,8 @@ data class SecurityConfigProperties(
val adminUser: String?,
val adminPassword: String?,
val enableTokens: Boolean = false,
- val enableOidc: Boolean = false
+ val enableOidc: Boolean = false,
+ val defaultNewUserRole: Role = Role.USER
) {
companion object {
const val NAME = "app.security"
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 6017aab..ca511a7 100644
--- a/src/main/kotlin/dev/dnpm/etl/processor/config/AppSecurityConfiguration.kt
+++ b/src/main/kotlin/dev/dnpm/etl/processor/config/AppSecurityConfiguration.kt
@@ -19,6 +19,9 @@
package dev.dnpm.etl.processor.config
+import dev.dnpm.etl.processor.security.UserRole
+import dev.dnpm.etl.processor.security.UserRoleRepository
+import dev.dnpm.etl.processor.services.UserRoleService
import org.slf4j.LoggerFactory
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.boot.context.properties.EnableConfigurationProperties
@@ -27,10 +30,15 @@ import org.springframework.context.annotation.Configuration
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.core.authority.SimpleGrantedAuthority
+import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper
+import org.springframework.security.core.session.SessionRegistry
+import org.springframework.security.core.session.SessionRegistryImpl
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.oauth2.core.oidc.user.OidcUserAuthority
import org.springframework.security.provisioning.InMemoryUserDetailsManager
import org.springframework.security.web.SecurityFilterChain
import java.util.*
@@ -77,12 +85,19 @@ class AppSecurityConfiguration(
@Bean
@ConditionalOnProperty(value = ["app.security.enable-oidc"], havingValue = "true")
- fun filterChainOidc(http: HttpSecurity, passwordEncoder: PasswordEncoder): SecurityFilterChain {
+ fun filterChainOidc(http: HttpSecurity, passwordEncoder: PasswordEncoder, userRoleRepository: UserRoleRepository, sessionRegistry: SessionRegistry): SecurityFilterChain {
http {
authorizeRequests {
authorize("/configs/**", hasRole("ADMIN"))
authorize("/mtbfile/**", hasAnyRole("MTBFILE"))
- authorize("/report/**", fullyAuthenticated)
+ authorize("/report/**", hasAnyRole("ADMIN", "USER"))
+ authorize("*.css", permitAll)
+ authorize("*.ico", permitAll)
+ authorize("*.jpeg", permitAll)
+ authorize("*.js", permitAll)
+ authorize("*.svg", permitAll)
+ authorize("*.css", permitAll)
+ authorize("/login/**", permitAll)
authorize(anyRequest, permitAll)
}
httpBasic {
@@ -94,12 +109,40 @@ class AppSecurityConfiguration(
oauth2Login {
loginPage = "/login"
}
+ sessionManagement {
+ sessionConcurrency {
+ maximumSessions = 1
+ maxSessionsPreventsLogin = true
+ expiredUrl = "/login?expired"
+ }
+ sessionFixation {
+ newSession()
+ }
+ }
csrf { disable() }
}
return http.build()
}
@Bean
+ @ConditionalOnProperty(value = ["app.security.enable-oidc"], havingValue = "true")
+ fun grantedAuthoritiesMapper(userRoleRepository: UserRoleRepository, appSecurityConfigProperties: SecurityConfigProperties): GrantedAuthoritiesMapper {
+ return GrantedAuthoritiesMapper { grantedAuthority ->
+ grantedAuthority.filterIsInstance<OidcUserAuthority>()
+ .onEach {
+ val userRole = userRoleRepository.findByUsername(it.userInfo.preferredUsername)
+ if (userRole.isEmpty) {
+ userRoleRepository.save(UserRole(null, it.userInfo.preferredUsername, appSecurityConfigProperties.defaultNewUserRole))
+ }
+ }
+ .map {
+ val userRole = userRoleRepository.findByUsername(it.userInfo.preferredUsername)
+ SimpleGrantedAuthority("ROLE_${userRole.get().role.toString().uppercase()}")
+ }
+ }
+ }
+
+ @Bean
@ConditionalOnProperty(value = ["app.security.enable-oidc"], havingValue = "false", matchIfMissing = true)
fun filterChain(http: HttpSecurity, passwordEncoder: PasswordEncoder): SecurityFilterChain {
http {
@@ -121,9 +164,18 @@ class AppSecurityConfiguration(
}
@Bean
+ fun sessionRegistry(): SessionRegistry {
+ return SessionRegistryImpl()
+ }
+
+ @Bean
fun passwordEncoder(): PasswordEncoder {
return PasswordEncoderFactories.createDelegatingPasswordEncoder()
}
+ @Bean
+ @ConditionalOnProperty(value = ["app.security.enable-oidc"], havingValue = "true")
+ fun userRoleService(userRoleRepository: UserRoleRepository, sessionRegistry: SessionRegistry): UserRoleService {
+ return UserRoleService(userRoleRepository, sessionRegistry)
+ }
}
-
diff --git a/src/main/kotlin/dev/dnpm/etl/processor/security/UserRole.kt b/src/main/kotlin/dev/dnpm/etl/processor/security/UserRole.kt
new file mode 100644
index 0000000..4de31f5
--- /dev/null
+++ b/src/main/kotlin/dev/dnpm/etl/processor/security/UserRole.kt
@@ -0,0 +1,44 @@
+/*
+ * 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.security
+
+import org.springframework.data.annotation.Id
+import org.springframework.data.relational.core.mapping.Table
+import org.springframework.data.repository.CrudRepository
+import java.util.*
+
+@Table("user_role")
+data class UserRole(
+ @Id val id: Long? = null,
+ val username: String,
+ var role: Role = Role.GUEST
+)
+
+enum class Role(val value: String) {
+ GUEST("guest"),
+ USER("user")
+}
+
+interface UserRoleRepository : CrudRepository<UserRole, Long> {
+
+ fun findByUsername(username: String): Optional<UserRole>
+
+} \ No newline at end of file
diff --git a/src/main/kotlin/dev/dnpm/etl/processor/services/UserRoleService.kt b/src/main/kotlin/dev/dnpm/etl/processor/services/UserRoleService.kt
new file mode 100644
index 0000000..6649f7d
--- /dev/null
+++ b/src/main/kotlin/dev/dnpm/etl/processor/services/UserRoleService.kt
@@ -0,0 +1,61 @@
+/*
+ * 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 dev.dnpm.etl.processor.security.Role
+import dev.dnpm.etl.processor.security.UserRole
+import dev.dnpm.etl.processor.security.UserRoleRepository
+import org.springframework.data.repository.findByIdOrNull
+import org.springframework.security.core.session.SessionRegistry
+import org.springframework.security.oauth2.core.oidc.user.OidcUser
+
+class UserRoleService(
+ private val userRoleRepository: UserRoleRepository,
+ private val sessionRegistry: SessionRegistry
+) {
+ fun updateUserRole(id: Long, role: Role) {
+ val userRole = userRoleRepository.findByIdOrNull(id) ?: return
+ userRole.role = role
+ userRoleRepository.save(userRole)
+ expireSessionFor(userRole.username)
+ }
+
+ fun deleteUserRole(id: Long) {
+ val userRole = userRoleRepository.findByIdOrNull(id) ?: return
+ userRoleRepository.delete(userRole)
+ expireSessionFor(userRole.username)
+ }
+
+ fun findAll(): List<UserRole> {
+ return userRoleRepository.findAll().toList()
+ }
+
+ private fun expireSessionFor(username: String) {
+ sessionRegistry.allPrincipals
+ .filterIsInstance<OidcUser>()
+ .filter { it.preferredUsername == username }
+ .flatMap {
+ sessionRegistry.getAllSessions(it, true)
+ }
+ .onEach {
+ it.expireNow()
+ }
+ }
+} \ 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 dbedee5..44ea400 100644
--- a/src/main/kotlin/dev/dnpm/etl/processor/web/ConfigController.kt
+++ b/src/main/kotlin/dev/dnpm/etl/processor/web/ConfigController.kt
@@ -22,9 +22,12 @@ 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.security.Role
+import dev.dnpm.etl.processor.security.UserRole
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.UserRoleService
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.http.MediaType
import org.springframework.http.codec.ServerSentEvent
@@ -43,7 +46,8 @@ class ConfigController(
private val pseudonymGenerator: Generator,
private val mtbFileSender: MtbFileSender,
private val connectionCheckService: ConnectionCheckService,
- private val tokenService: TokenService?
+ private val tokenService: TokenService?,
+ private val userRoleService: UserRoleService?
) {
@GetMapping
@@ -56,10 +60,16 @@ class ConfigController(
if (tokenService != null) {
model.addAttribute("tokens", tokenService.findAll())
} else {
- model.addAttribute("tokens", listOf<Token>())
+ model.addAttribute("tokens", emptyList<Token>())
}
model.addAttribute("transformations", transformationService.getTransformations())
-
+ if (userRoleService != null) {
+ model.addAttribute("userRolesEnabled", true)
+ model.addAttribute("userRoles", userRoleService.findAll())
+ } else {
+ model.addAttribute("userRolesEnabled", false)
+ model.addAttribute("userRoles", emptyList<UserRole>())
+ }
return "configs"
}
@@ -112,6 +122,34 @@ class ConfigController(
return "configs/tokens"
}
+ @DeleteMapping(path = ["userroles/{id}"])
+ fun deleteUserRole(@PathVariable id: Long, model: Model): String {
+ if (userRoleService != null) {
+ userRoleService.deleteUserRole(id)
+
+ model.addAttribute("userRolesEnabled", true)
+ model.addAttribute("userRoles", userRoleService.findAll())
+ } else {
+ model.addAttribute("userRolesEnabled", false)
+ model.addAttribute("userRoles", emptyList<UserRole>())
+ }
+ return "configs/userroles"
+ }
+
+ @PutMapping(path = ["userroles/{id}"])
+ fun updateUserRole(@PathVariable id: Long, @ModelAttribute("role") role: Role, model: Model): String {
+ if (userRoleService != null) {
+ userRoleService.updateUserRole(id, role)
+
+ model.addAttribute("userRolesEnabled", true)
+ model.addAttribute("userRoles", userRoleService.findAll())
+ } else {
+ model.addAttribute("userRolesEnabled", false)
+ model.addAttribute("userRoles", emptyList<UserRole>())
+ }
+ return "configs/userroles"
+ }
+
@GetMapping(path = ["events"], produces = [MediaType.TEXT_EVENT_STREAM_VALUE])
fun events(): Flux<ServerSentEvent<Any>> {
return configsUpdateProducer.asFlux().map {
diff --git a/src/main/resources/db/migration/mariadb/V0_3_0__UserRoles.sql b/src/main/resources/db/migration/mariadb/V0_3_0__UserRoles.sql
new file mode 100644
index 0000000..99399fd
--- /dev/null
+++ b/src/main/resources/db/migration/mariadb/V0_3_0__UserRoles.sql
@@ -0,0 +1,7 @@
+CREATE TABLE IF NOT EXISTS user_role
+(
+ id int auto_increment primary key,
+ username varchar(255) not null unique,
+ role 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_3_0__UserRoles.sql b/src/main/resources/db/migration/postgresql/V0_3_0__UserRoles.sql
new file mode 100644
index 0000000..7dbfc08
--- /dev/null
+++ b/src/main/resources/db/migration/postgresql/V0_3_0__UserRoles.sql
@@ -0,0 +1,8 @@
+CREATE TABLE IF NOT EXISTS user_role
+(
+ id serial,
+ username varchar(255) not null unique,
+ role 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/style.css b/src/main/resources/static/style.css
index c7a0b38..0dd5820 100644
--- a/src/main/resources/static/style.css
+++ b/src/main/resources/static/style.css
@@ -202,6 +202,17 @@ form.samplecode-input input:focus-visible {
background: none;
}
+.userrole-form form {
+ margin: 0;
+ padding: 0;
+
+ border: none;
+ border-radius: 0;
+ background: none;
+
+ text-align: inherit;
+}
+
.login-form form *,
.token-form form * {
padding: 0.5em;
@@ -210,7 +221,8 @@ form.samplecode-input input:focus-visible {
}
.login-form form hr,
-.token-form form hr {
+.token-form form hr,
+.userrole-form form hr {
padding: 0;
width: 100%;
}
@@ -224,6 +236,14 @@ form.samplecode-input input:focus-visible {
border: none;
}
+.userrole-form form select {
+ padding: 0.5em;
+ border: none;
+ border-radius: 3px;
+ line-height: 1.2rem;
+ font-size: 0.8rem;
+}
+
.border {
padding: 1.5em;
border: 1px solid var(--table-border);
@@ -527,6 +547,10 @@ input.inline:focus-visible {
color: var(--bg-green);
}
+.notification.notice {
+ color: var(--bg-yellow);
+}
+
.notification.error {
color: var(--bg-red);
}
diff --git a/src/main/resources/templates/configs.html b/src/main/resources/templates/configs.html
index ebef7ca..2103b0b 100644
--- a/src/main/resources/templates/configs.html
+++ b/src/main/resources/templates/configs.html
@@ -40,6 +40,9 @@
<section th:insert="~{configs/tokens.html}">
</section>
+ <section th:insert="~{configs/userroles.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>
diff --git a/src/main/resources/templates/configs/userroles.html b/src/main/resources/templates/configs/userroles.html
new file mode 100644
index 0000000..23cc5f2
--- /dev/null
+++ b/src/main/resources/templates/configs/userroles.html
@@ -0,0 +1,39 @@
+<div th:if="${not userRolesEnabled}">
+ <h2><span>⛔</span> Benutzerberechtigungen</h2>
+ <p>Die Verwendung von rollenbasierten Benutzerberechtigungen ist nicht aktiviert.</p>
+</div>
+
+<div id="userroles" th:if="${userRolesEnabled}">
+ <h2><span>✅</span> Benutzerberechtigungen</h2>
+ <div class="border">
+ <div th:if="${userRoles.isEmpty()}">Noch keine Benutzerberechtigungen vorhanden.</div>
+ <table th:if="${not userRoles.isEmpty()}">
+ <thead>
+ <tr>
+ <th>Benutzername</th>
+ <th>Rolle</th>
+ <th></th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr th:each="userRole : ${userRoles}">
+ <td>[[ ${userRole.username} ]]</td>
+ <td>
+ <div class="userrole-form">
+ <form th:hx-put="@{/configs/userroles/{id}(id=${userRole.id})}" hx-target="#userroles">
+ <select name="role">
+ <option th:selected="${userRole.role.value == 'guest'}" value="GUEST">Gast</option>
+ <option th:selected="${userRole.role.value == 'user'}" value="USER">Benutzer</option>
+ </select>
+ <button class="btn btn-blue">Übernehmen</button>
+ </form>
+ </div>
+ </td>
+ <td>
+ <button class="btn btn-red" th:hx-delete="@{/configs/userroles/{id}(id=${userRole.id})}" hx-target="#userroles">Löschen</button>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+</div> \ No newline at end of file
diff --git a/src/main/resources/templates/index.html b/src/main/resources/templates/index.html
index 3951f66..be3123b 100644
--- a/src/main/resources/templates/index.html
+++ b/src/main/resources/templates/index.html
@@ -53,17 +53,17 @@
<td th:style="${request.type.value == 'delete'} ? 'color: red;'"><small>[[ ${request.type} ]]</small></td>
<td th:if="not ${request.report}">[[ ${request.uuid} ]]</td>
<td th:if="${request.report}">
- <th:block sec:authorize="not authenticated">[[ ${request.uuid} ]]</th:block>
- <a th:href="@{/report/{id}(id=${request.uuid})}" sec:authorize="authenticated">[[ ${request.uuid} ]]</a>
+ <a th:href="@{/report/{id}(id=${request.uuid})}" sec:authorize="hasRole('USER') or hasRole('ADMIN')">[[ ${request.uuid} ]]</a>
+ <th:block sec:authorize="not (hasRole('USER') or hasRole('ADMIN'))">[[ ${request.uuid} ]]</th:block>
</td>
<td><time th:datetime="${request.processedAt}">[[ ${request.processedAt} ]]</time></td>
- <td class="patient-id" th:if="${patientId != null}" sec:authorize="authenticated">
+ <td class="patient-id" th:if="${patientId != null}" sec:authorize="hasRole('USER') or hasRole('ADMIN')">
[[ ${request.patientId} ]]
</td>
- <td class="patient-id" th:if="${patientId == null}" sec:authorize="authenticated">
+ <td class="patient-id" th:if="${patientId == null}" sec:authorize="hasRole('USER') or hasRole('ADMIN')">
<a th:href="@{/patient/{pid}(pid=${request.patientId})}">[[ ${request.patientId} ]]</a>
</td>
- <td class="patient-id" sec:authorize="not authenticated">***</td>
+ <td class="patient-id" sec:authorize="not (hasRole('USER') or hasRole('ADMIN'))">***</td>
</tr>
</tbody>
</table>
diff --git a/src/main/resources/templates/login.html b/src/main/resources/templates/login.html
index 4ef8ec9..75a3681 100644
--- a/src/main/resources/templates/login.html
+++ b/src/main/resources/templates/login.html
@@ -11,6 +11,7 @@
<div class="login-form">
<h2 class="centered">Anmelden</h2>
<div class="centered notification error" th:if="${param.error}">Anmeldung nicht erfolgreich</div>
+ <div class="centered notification notice" th:if="${param.expired}">Sitzung abgelaufen oder von einem Administrator beendet.</div>
<div class="centered notification success" th:if="${param.logout}">Sie haben sich abgemeldet</div>
<form method="post" th:action="@{/login}">
<input type="text" id="username" name="username" class="form-control" placeholder="Username" required="" autofocus="" />