diff options
| author | Paul-Christian Volkmer | 2024-03-01 14:09:06 +0100 |
|---|---|---|
| committer | GitHub | 2024-03-01 14:09:06 +0100 |
| commit | 5928d52237cd5935fb751b3f667617e84b5bbae2 (patch) | |
| tree | 9ab899a2581ebd037320a7c951db1711392e129c /src | |
| parent | 0b6decf88d9084616874d65827e7eb1e8050d1c5 (diff) | |
| parent | 1eb40b40c99b4aed31da983332e8b44275c19dd9 (diff) | |
Merge pull request #48 from CCC-MF/issue_36
Freigabe und Berechtigung für OIDC-Benutzer
Diffstat (limited to 'src')
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="" /> |
