1
0
mirror of https://github.com/pcvolkmer/etl-processor.git synced 2025-04-20 17:56:50 +00:00

Merge pull request #48 from CCC-MF/issue_36

Freigabe und Berechtigung für OIDC-Benutzer
This commit is contained in:
Paul-Christian Volkmer 2024-03-01 14:09:06 +01:00 committed by GitHub
commit 5928d52237
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 309 additions and 13 deletions

View File

@ -114,6 +114,23 @@ Weitere Informationen zur Konfiguration des OIDC-Providers
sind [hier](https://docs.spring.io/spring-security/reference/servlet/oauth2/index.html#oauth2-client) sind [hier](https://docs.spring.io/spring-security/reference/servlet/oauth2/index.html#oauth2-client)
zu finden. zu finden.
#### Rollenbasierte Berechtigungen
Wird OpenID Connect verwendet, gibt es eine rollenbasierte Berechtigungszuweisung.
Die Standardrolle für neue OIDC-Benutzer kann mit der Option `APP_SECURITY_DEFAULT_USER_ROLE` festgelegt werden.
Mögliche Werte sind `user` oder `guest`. Standardwert ist `user`.
Benutzer mit der Rolle "Gast" sehen nur die Inhalte, die auch nicht angemeldete Benutzer sehen.
Hierdurch ist es möglich, einzelne Benutzer einzuschränken oder durch Änderung der Standardrolle auf `guest` nur
einzelne Benutzer als vollwertige Nutzer zuzulassen.
![Rollenverwaltung](docs/userroles.png)
Benutzer werden nach dem Entfernen oder der Änderung der vergebenen Rolle automatisch abgemeldet und müssen sich neu anmelden.
Sie bekommen dabei wieder die Standardrolle zugewiesen.
#### Auswirkungen auf den dargestellten Inhalt #### Auswirkungen auf den dargestellten Inhalt
Nur Administratoren haben Zugriff auf den Konfigurationsbereich, nur angemeldete Benutzer können die anonymisierte oder Nur Administratoren haben Zugriff auf den Konfigurationsbereich, nur angemeldete Benutzer können die anonymisierte oder

BIN
docs/userroles.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -19,6 +19,7 @@
package dev.dnpm.etl.processor.config 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.ConfigurationProperties
import org.springframework.boot.context.properties.DeprecatedConfigurationProperty import org.springframework.boot.context.properties.DeprecatedConfigurationProperty
@ -102,7 +103,8 @@ data class SecurityConfigProperties(
val adminUser: String?, val adminUser: String?,
val adminPassword: String?, val adminPassword: String?,
val enableTokens: Boolean = false, val enableTokens: Boolean = false,
val enableOidc: Boolean = false val enableOidc: Boolean = false,
val defaultNewUserRole: Role = Role.USER
) { ) {
companion object { companion object {
const val NAME = "app.security" const val NAME = "app.security"

View File

@ -19,6 +19,9 @@
package dev.dnpm.etl.processor.config 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.slf4j.LoggerFactory
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.boot.context.properties.EnableConfigurationProperties 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.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.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.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.oauth2.core.oidc.user.OidcUserAuthority
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 java.util.* import java.util.*
@ -77,12 +85,19 @@ class AppSecurityConfiguration(
@Bean @Bean
@ConditionalOnProperty(value = ["app.security.enable-oidc"], havingValue = "true") @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 { http {
authorizeRequests { authorizeRequests {
authorize("/configs/**", hasRole("ADMIN")) authorize("/configs/**", hasRole("ADMIN"))
authorize("/mtbfile/**", hasAnyRole("MTBFILE")) 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) authorize(anyRequest, permitAll)
} }
httpBasic { httpBasic {
@ -94,11 +109,39 @@ class AppSecurityConfiguration(
oauth2Login { oauth2Login {
loginPage = "/login" loginPage = "/login"
} }
sessionManagement {
sessionConcurrency {
maximumSessions = 1
maxSessionsPreventsLogin = true
expiredUrl = "/login?expired"
}
sessionFixation {
newSession()
}
}
csrf { disable() } csrf { disable() }
} }
return http.build() 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 @Bean
@ConditionalOnProperty(value = ["app.security.enable-oidc"], havingValue = "false", matchIfMissing = true) @ConditionalOnProperty(value = ["app.security.enable-oidc"], havingValue = "false", matchIfMissing = true)
fun filterChain(http: HttpSecurity, passwordEncoder: PasswordEncoder): SecurityFilterChain { fun filterChain(http: HttpSecurity, passwordEncoder: PasswordEncoder): SecurityFilterChain {
@ -120,10 +163,19 @@ class AppSecurityConfiguration(
return http.build() return http.build()
} }
@Bean
fun sessionRegistry(): SessionRegistry {
return SessionRegistryImpl()
}
@Bean @Bean
fun passwordEncoder(): PasswordEncoder { fun passwordEncoder(): PasswordEncoder {
return PasswordEncoderFactories.createDelegatingPasswordEncoder() return PasswordEncoderFactories.createDelegatingPasswordEncoder()
} }
@Bean
@ConditionalOnProperty(value = ["app.security.enable-oidc"], havingValue = "true")
fun userRoleService(userRoleRepository: UserRoleRepository, sessionRegistry: SessionRegistry): UserRoleService {
return UserRoleService(userRoleRepository, sessionRegistry)
}
} }

View File

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

View File

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

View File

@ -22,9 +22,12 @@ 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.security.Role
import dev.dnpm.etl.processor.security.UserRole
import dev.dnpm.etl.processor.services.Token import dev.dnpm.etl.processor.services.Token
import dev.dnpm.etl.processor.services.TokenService import dev.dnpm.etl.processor.services.TokenService
import dev.dnpm.etl.processor.services.TransformationService import dev.dnpm.etl.processor.services.TransformationService
import dev.dnpm.etl.processor.services.UserRoleService
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
@ -43,7 +46,8 @@ class ConfigController(
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? private val tokenService: TokenService?,
private val userRoleService: UserRoleService?
) { ) {
@GetMapping @GetMapping
@ -56,10 +60,16 @@ class ConfigController(
if (tokenService != null) { if (tokenService != null) {
model.addAttribute("tokens", tokenService.findAll()) model.addAttribute("tokens", tokenService.findAll())
} else { } else {
model.addAttribute("tokens", listOf<Token>()) model.addAttribute("tokens", emptyList<Token>())
} }
model.addAttribute("transformations", transformationService.getTransformations()) 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" return "configs"
} }
@ -112,6 +122,34 @@ class ConfigController(
return "configs/tokens" 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]) @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

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

View File

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

View File

@ -202,6 +202,17 @@ form.samplecode-input input:focus-visible {
background: none; background: none;
} }
.userrole-form form {
margin: 0;
padding: 0;
border: none;
border-radius: 0;
background: none;
text-align: inherit;
}
.login-form form *, .login-form form *,
.token-form form * { .token-form form * {
padding: 0.5em; padding: 0.5em;
@ -210,7 +221,8 @@ form.samplecode-input input:focus-visible {
} }
.login-form form hr, .login-form form hr,
.token-form form hr { .token-form form hr,
.userrole-form form hr {
padding: 0; padding: 0;
width: 100%; width: 100%;
} }
@ -224,6 +236,14 @@ form.samplecode-input input:focus-visible {
border: none; border: none;
} }
.userrole-form form select {
padding: 0.5em;
border: none;
border-radius: 3px;
line-height: 1.2rem;
font-size: 0.8rem;
}
.border { .border {
padding: 1.5em; padding: 1.5em;
border: 1px solid var(--table-border); border: 1px solid var(--table-border);
@ -527,6 +547,10 @@ input.inline:focus-visible {
color: var(--bg-green); color: var(--bg-green);
} }
.notification.notice {
color: var(--bg-yellow);
}
.notification.error { .notification.error {
color: var(--bg-red); color: var(--bg-red);
} }

View File

@ -40,6 +40,9 @@
<section th:insert="~{configs/tokens.html}"> <section th:insert="~{configs/tokens.html}">
</section> </section>
<section th:insert="~{configs/userroles.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>

View File

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

View File

@ -53,17 +53,17 @@
<td th:style="${request.type.value == 'delete'} ? 'color: red;'"><small>[[ ${request.type} ]]</small></td> <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="not ${request.report}">[[ ${request.uuid} ]]</td>
<td th:if="${request.report}"> <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="hasRole('USER') or hasRole('ADMIN')">[[ ${request.uuid} ]]</a>
<a th:href="@{/report/{id}(id=${request.uuid})}" sec:authorize="authenticated">[[ ${request.uuid} ]]</a> <th:block sec:authorize="not (hasRole('USER') or hasRole('ADMIN'))">[[ ${request.uuid} ]]</th:block>
</td> </td>
<td><time th:datetime="${request.processedAt}">[[ ${request.processedAt} ]]</time></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} ]] [[ ${request.patientId} ]]
</td> </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> <a th:href="@{/patient/{pid}(pid=${request.patientId})}">[[ ${request.patientId} ]]</a>
</td> </td>
<td class="patient-id" sec:authorize="not authenticated">***</td> <td class="patient-id" sec:authorize="not (hasRole('USER') or hasRole('ADMIN'))">***</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>

View File

@ -11,6 +11,7 @@
<div class="login-form"> <div class="login-form">
<h2 class="centered">Anmelden</h2> <h2 class="centered">Anmelden</h2>
<div class="centered notification error" th:if="${param.error}">Anmeldung nicht erfolgreich</div> <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> <div class="centered notification success" th:if="${param.logout}">Sie haben sich abgemeldet</div>
<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="" />