From 6f725ad4bc49965d6f024e77c5f24419415cc048 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sevket=20G=C3=B6kay?= Date: Sun, 11 Aug 2024 00:00:53 +0200 Subject: [PATCH 01/11] add UserDetailsService impl using Jooq --- .../steve/config/SecurityConfiguration.java | 14 +- .../steve/repository/WebUserRepository.java | 8 + .../impl/WebUserRepositoryImpl.java | 183 ++++++++++++++++++ .../resources/db/migration/V1_0_6__update.sql | 10 + 4 files changed, 204 insertions(+), 11 deletions(-) create mode 100644 src/main/java/de/rwth/idsg/steve/repository/WebUserRepository.java create mode 100644 src/main/java/de/rwth/idsg/steve/repository/impl/WebUserRepositoryImpl.java create mode 100644 src/main/resources/db/migration/V1_0_6__update.sql diff --git a/src/main/java/de/rwth/idsg/steve/config/SecurityConfiguration.java b/src/main/java/de/rwth/idsg/steve/config/SecurityConfiguration.java index 4687cce91..d71274bc0 100644 --- a/src/main/java/de/rwth/idsg/steve/config/SecurityConfiguration.java +++ b/src/main/java/de/rwth/idsg/steve/config/SecurityConfiguration.java @@ -20,6 +20,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.google.common.base.Strings; +import de.rwth.idsg.steve.repository.impl.WebUserRepositoryImpl; import de.rwth.idsg.steve.web.api.ApiControllerAdvice; import lombok.extern.slf4j.Slf4j; import org.springframework.context.annotation.Bean; @@ -34,13 +35,10 @@ import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; -import org.springframework.security.core.userdetails.User; -import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.crypto.factory.PasswordEncoderFactories; import org.springframework.security.crypto.password.DelegatingPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.security.provisioning.InMemoryUserDetailsManager; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.preauth.AbstractPreAuthenticatedProcessingFilter; @@ -75,14 +73,8 @@ public PasswordEncoder passwordEncoder() { } @Bean - public UserDetailsService userDetailsService() { - UserDetails webPageUser = User.builder() - .username(CONFIG.getAuth().getUserName()) - .password(CONFIG.getAuth().getEncodedPassword()) - .roles("ADMIN") - .build(); - - return new InMemoryUserDetailsManager(webPageUser); + public UserDetailsService userDetailsService(WebUserRepositoryImpl webUserRepository) { + return webUserRepository; } @Bean diff --git a/src/main/java/de/rwth/idsg/steve/repository/WebUserRepository.java b/src/main/java/de/rwth/idsg/steve/repository/WebUserRepository.java new file mode 100644 index 000000000..8c9fd71dc --- /dev/null +++ b/src/main/java/de/rwth/idsg/steve/repository/WebUserRepository.java @@ -0,0 +1,8 @@ +package de.rwth.idsg.steve.repository; + +import org.springframework.security.provisioning.UserDetailsManager; + +public interface WebUserRepository extends UserDetailsManager { + + void changeStatusOfUser(String username, boolean enabled); +} diff --git a/src/main/java/de/rwth/idsg/steve/repository/impl/WebUserRepositoryImpl.java b/src/main/java/de/rwth/idsg/steve/repository/impl/WebUserRepositoryImpl.java new file mode 100644 index 000000000..7762623e7 --- /dev/null +++ b/src/main/java/de/rwth/idsg/steve/repository/impl/WebUserRepositoryImpl.java @@ -0,0 +1,183 @@ +package de.rwth.idsg.steve.repository.impl; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import de.rwth.idsg.steve.repository.WebUserRepository; +import jooq.steve.db.tables.records.WebUserRecord; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.jooq.DSLContext; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.context.SecurityContextHolderStrategy; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.provisioning.JdbcUserDetailsManager; +import org.springframework.stereotype.Repository; +import org.springframework.util.Assert; + +import java.util.Collection; +import java.util.List; + +import static jooq.steve.db.Tables.WEB_USER; + +/** + * Inspired by {@link org.springframework.security.provisioning.JdbcUserDetailsManager} + * + * @author Sevket Goekay + * @since 10.08.2024 + */ +@Slf4j +@Repository +@RequiredArgsConstructor +public class WebUserRepositoryImpl implements WebUserRepository { + + private final DSLContext ctx; + private final ObjectMapper jacksonObjectMapper; + private final SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder.getContextHolderStrategy(); + + @Override + public void createUser(UserDetails user) { + validateUserDetails(user); + + ctx.insertInto(WEB_USER) + .set(WEB_USER.USERNAME, user.getUsername()) + .set(WEB_USER.PASSWORD, user.getPassword()) + .set(WEB_USER.ENABLED, user.isEnabled()) + .set(WEB_USER.AUTHORITIES, toJson(user.getAuthorities())) + .execute(); + } + + @Override + public void updateUser(UserDetails user) { + validateUserDetails(user); + + ctx.update(WEB_USER) + .set(WEB_USER.PASSWORD, user.getPassword()) + .set(WEB_USER.ENABLED, user.isEnabled()) + .set(WEB_USER.AUTHORITIES, toJson(user.getAuthorities())) + .where(WEB_USER.USERNAME.eq(user.getUsername())) + .execute(); + } + + @Override + public void deleteUser(String username) { + ctx.delete(WEB_USER) + .where(WEB_USER.USERNAME.eq(username)) + .execute(); + } + + @Override + public void changeStatusOfUser(String username, boolean enabled) { + ctx.update(WEB_USER) + .set(WEB_USER.ENABLED, enabled) + .where(WEB_USER.USERNAME.eq(username)) + .execute(); + } + + /** + * Not only just an SQL Update. The flow is inspired by {@link JdbcUserDetailsManager#changePassword(String, String)} + */ + @Override + public void changePassword(String oldPassword, String newPassword) { + Authentication currentUser = this.securityContextHolderStrategy.getContext().getAuthentication(); + if (currentUser == null) { + // This would indicate bad coding somewhere + throw new AccessDeniedException("Can't change password as no Authentication object found in context " + "for current user."); + } + + String username = currentUser.getName(); + + ctx.update(WEB_USER) + .set(WEB_USER.PASSWORD, newPassword) + .where(WEB_USER.USERNAME.eq(username)) + .execute(); + + Authentication authentication = createNewAuthentication(currentUser, newPassword); + SecurityContext context = this.securityContextHolderStrategy.createEmptyContext(); + context.setAuthentication(authentication); + this.securityContextHolderStrategy.setContext(context); + } + + @Override + public boolean userExists(String username) { + return ctx.selectOne() + .from(WEB_USER) + .where(WEB_USER.USERNAME.eq(username)) + .fetchOptional() + .isPresent(); + } + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + WebUserRecord record = ctx.selectFrom(WEB_USER) + .where(WEB_USER.USERNAME.eq(username)) + .fetchOne(); + + if (record == null) { + throw new UsernameNotFoundException(username); + } + + return User + .withUsername(record.getUsername()) + .password(record.getPassword()) + .disabled(!record.getEnabled()) + .authorities(fromJson(record.getAuthorities())) + .build(); + } + + private String[] fromJson(String jsonArray) { + try { + return jacksonObjectMapper.readValue(jsonArray, String[].class); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + + private String toJson(Collection authorities) { + List auths = authorities.stream() + .map(GrantedAuthority::getAuthority) + .toList(); + + try { + return jacksonObjectMapper.writeValueAsString(auths); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + + /** + * Lifted from {@link JdbcUserDetailsManager#validateUserDetails(UserDetails)} + */ + private void validateUserDetails(UserDetails user) { + Assert.hasText(user.getUsername(), "Username may not be empty or null"); + validateAuthorities(user.getAuthorities()); + } + + /** + * Lifted from {@link JdbcUserDetailsManager#validateAuthorities(Collection)} + */ + private void validateAuthorities(Collection authorities) { + Assert.notNull(authorities, "Authorities list must not be null"); + for (GrantedAuthority authority : authorities) { + Assert.notNull(authority, "Authorities list contains a null entry"); + Assert.hasText(authority.getAuthority(), "getAuthority() method must return a non-empty string"); + } + } + + /** + * Lifted from {@link JdbcUserDetailsManager#createNewAuthentication(Authentication, String)} + */ + private Authentication createNewAuthentication(Authentication currentAuth, String newPassword) { + UserDetails user = loadUserByUsername(currentAuth.getName()); + UsernamePasswordAuthenticationToken newAuthentication = UsernamePasswordAuthenticationToken.authenticated(user, null, user.getAuthorities()); + newAuthentication.setDetails(currentAuth.getDetails()); + return newAuthentication; + } + +} diff --git a/src/main/resources/db/migration/V1_0_6__update.sql b/src/main/resources/db/migration/V1_0_6__update.sql new file mode 100644 index 000000000..4c7f82236 --- /dev/null +++ b/src/main/resources/db/migration/V1_0_6__update.sql @@ -0,0 +1,10 @@ +CREATE TABLE web_user +( + web_user_pk INT NOT NULL AUTO_INCREMENT, + username varchar(500) NOT NULL, + password varchar(500) NOT NULL, + enabled BOOLEAN NOT NULL, + authorities JSON NOT NULL, + PRIMARY KEY (web_user_pk), + UNIQUE KEY (username) +); From cc31d245ecc3cb78ebb636dbe7c6a4b4270f3d7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sevket=20G=C3=B6kay?= Date: Sun, 11 Aug 2024 13:02:49 +0200 Subject: [PATCH 02/11] improve impl such that it is in a working condition --- .../steve/config/SecurityConfiguration.java | 34 ++++++++++++++----- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/src/main/java/de/rwth/idsg/steve/config/SecurityConfiguration.java b/src/main/java/de/rwth/idsg/steve/config/SecurityConfiguration.java index d71274bc0..e000e299b 100644 --- a/src/main/java/de/rwth/idsg/steve/config/SecurityConfiguration.java +++ b/src/main/java/de/rwth/idsg/steve/config/SecurityConfiguration.java @@ -20,8 +20,9 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.google.common.base.Strings; -import de.rwth.idsg.steve.repository.impl.WebUserRepositoryImpl; +import de.rwth.idsg.steve.SteveConfiguration; import de.rwth.idsg.steve.web.api.ApiControllerAdvice; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -35,14 +36,16 @@ import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; -import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.User; import org.springframework.security.crypto.factory.PasswordEncoderFactories; import org.springframework.security.crypto.password.DelegatingPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.provisioning.UserDetailsManager; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.preauth.AbstractPreAuthenticatedProcessingFilter; +import jakarta.annotation.PostConstruct; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -56,10 +59,30 @@ * @since 07.01.2015 */ @Slf4j +@RequiredArgsConstructor @Configuration @EnableWebSecurity public class SecurityConfiguration { + private final UserDetailsManager userDetailsManager; + + @PostConstruct + public void postConstruct() { + String userName = SteveConfiguration.CONFIG.getAuth().getUserName(); + if (userDetailsManager.userExists(userName)) { + return; + } + + var user = User + .withUsername(userName) + .password(SteveConfiguration.CONFIG.getAuth().getEncodedPassword()) + .disabled(false) + .authorities("ADMIN") + .build(); + + userDetailsManager.createUser(user); + } + /** * Password encoding changed with spring-security 5.0.0. We either have to use a prefix before the password to * indicate which actual encoder {@link DelegatingPasswordEncoder} should use [1, 2] or specify the encoder as we do. @@ -72,11 +95,6 @@ public PasswordEncoder passwordEncoder() { return CONFIG.getAuth().getPasswordEncoder(); } - @Bean - public UserDetailsService userDetailsService(WebUserRepositoryImpl webUserRepository) { - return webUserRepository; - } - @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { final String prefix = CONFIG.getSpringManagerMapping(); @@ -90,7 +108,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti WebSocketConfiguration.PATH_INFIX + "**", "/WEB-INF/views/**" // https://github.com/spring-projects/spring-security/issues/13285#issuecomment-1579097065 ).permitAll() - .requestMatchers(prefix + "/**").hasRole("ADMIN") + .requestMatchers(prefix + "/**").hasAuthority("ADMIN") ) // SOAP stations are making POST calls for communication. even though the following path is permitted for // all access, there is a global default behaviour from spring security: enable CSRF for all POSTs. From 3184cc1c7c72292d13245ce7c963e444c5de0233 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sevket=20G=C3=B6kay?= Date: Sun, 11 Aug 2024 13:38:32 +0200 Subject: [PATCH 03/11] refactor: make github action checks happy --- .../steve/repository/WebUserRepository.java | 18 ++++++++++ .../impl/WebUserRepositoryImpl.java | 35 +++++++++++++++---- 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/src/main/java/de/rwth/idsg/steve/repository/WebUserRepository.java b/src/main/java/de/rwth/idsg/steve/repository/WebUserRepository.java index 8c9fd71dc..6979d8aed 100644 --- a/src/main/java/de/rwth/idsg/steve/repository/WebUserRepository.java +++ b/src/main/java/de/rwth/idsg/steve/repository/WebUserRepository.java @@ -1,3 +1,21 @@ +/* + * SteVe - SteckdosenVerwaltung - https://github.com/steve-community/steve + * Copyright (C) 2013-2024 SteVe Community Team + * All Rights Reserved. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ package de.rwth.idsg.steve.repository; import org.springframework.security.provisioning.UserDetailsManager; diff --git a/src/main/java/de/rwth/idsg/steve/repository/impl/WebUserRepositoryImpl.java b/src/main/java/de/rwth/idsg/steve/repository/impl/WebUserRepositoryImpl.java index 7762623e7..2e06879d3 100644 --- a/src/main/java/de/rwth/idsg/steve/repository/impl/WebUserRepositoryImpl.java +++ b/src/main/java/de/rwth/idsg/steve/repository/impl/WebUserRepositoryImpl.java @@ -1,3 +1,21 @@ +/* + * SteVe - SteckdosenVerwaltung - https://github.com/steve-community/steve + * Copyright (C) 2013-2024 SteVe Community Team + * All Rights Reserved. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ package de.rwth.idsg.steve.repository.impl; import com.fasterxml.jackson.core.JsonProcessingException; @@ -8,11 +26,9 @@ import lombok.extern.slf4j.Slf4j; import org.jooq.DSLContext; import org.springframework.security.access.AccessDeniedException; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.context.SecurityContext; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolderStrategy; import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.UserDetails; @@ -25,6 +41,8 @@ import java.util.List; import static jooq.steve.db.Tables.WEB_USER; +import static org.springframework.security.authentication.UsernamePasswordAuthenticationToken.authenticated; +import static org.springframework.security.core.context.SecurityContextHolder.getContextHolderStrategy; /** * Inspired by {@link org.springframework.security.provisioning.JdbcUserDetailsManager} @@ -39,7 +57,7 @@ public class WebUserRepositoryImpl implements WebUserRepository { private final DSLContext ctx; private final ObjectMapper jacksonObjectMapper; - private final SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder.getContextHolderStrategy(); + private final SecurityContextHolderStrategy securityContextHolderStrategy = getContextHolderStrategy(); @Override public void createUser(UserDetails user) { @@ -81,14 +99,17 @@ public void changeStatusOfUser(String username, boolean enabled) { } /** - * Not only just an SQL Update. The flow is inspired by {@link JdbcUserDetailsManager#changePassword(String, String)} + * Not only just an SQL Update. + * The flow is inspired by {@link JdbcUserDetailsManager#changePassword(String, String)} */ @Override public void changePassword(String oldPassword, String newPassword) { Authentication currentUser = this.securityContextHolderStrategy.getContext().getAuthentication(); if (currentUser == null) { // This would indicate bad coding somewhere - throw new AccessDeniedException("Can't change password as no Authentication object found in context " + "for current user."); + throw new AccessDeniedException( + "Can't change password as no Authentication object found in context for current user." + ); } String username = currentUser.getName(); @@ -174,8 +195,8 @@ private void validateAuthorities(Collection authorit * Lifted from {@link JdbcUserDetailsManager#createNewAuthentication(Authentication, String)} */ private Authentication createNewAuthentication(Authentication currentAuth, String newPassword) { - UserDetails user = loadUserByUsername(currentAuth.getName()); - UsernamePasswordAuthenticationToken newAuthentication = UsernamePasswordAuthenticationToken.authenticated(user, null, user.getAuthorities()); + var user = this.loadUserByUsername(currentAuth.getName()); + var newAuthentication = authenticated(user, null, user.getAuthorities()); newAuthentication.setDetails(currentAuth.getDetails()); return newAuthentication; } From c082c0d63e4d91b0c64f4d3f21b182d397cd3f75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sevket=20G=C3=B6kay?= Date: Sun, 11 Aug 2024 14:29:35 +0200 Subject: [PATCH 04/11] force data type JSON in Jooq for web_user.authorities reason: our build matrix fails for mysql, but succeeds for mariadb. Jooq infers data type org.jooq.JSON for web_user.authorities for mysql. on the other hand, it is String for mariadb. example: https://github.com/steve-community/steve/actions/runs/10339451112 --- pom.xml | 4 ++++ .../steve/repository/impl/WebUserRepositoryImpl.java | 10 ++++++---- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/pom.xml b/pom.xml index 99017942d..6ee130431 100644 --- a/pom.xml +++ b/pom.xml @@ -434,6 +434,10 @@ BOOLEAN .*\.OCPP_TAG_ACTIVITY\.(IN_TRANSACTION|BLOCKED) + + JSON + .*\.WEB_USER\.(AUTHORITIES) + org.joda.time.DateTime de.rwth.idsg.steve.utils.DateTimeConverter diff --git a/src/main/java/de/rwth/idsg/steve/repository/impl/WebUserRepositoryImpl.java b/src/main/java/de/rwth/idsg/steve/repository/impl/WebUserRepositoryImpl.java index 2e06879d3..4ae3e54e0 100644 --- a/src/main/java/de/rwth/idsg/steve/repository/impl/WebUserRepositoryImpl.java +++ b/src/main/java/de/rwth/idsg/steve/repository/impl/WebUserRepositoryImpl.java @@ -25,6 +25,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.jooq.DSLContext; +import org.jooq.JSON; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; @@ -152,21 +153,22 @@ public UserDetails loadUserByUsername(String username) throws UsernameNotFoundEx .build(); } - private String[] fromJson(String jsonArray) { + private String[] fromJson(JSON jsonArray) { try { - return jacksonObjectMapper.readValue(jsonArray, String[].class); + return jacksonObjectMapper.readValue(jsonArray.data(), String[].class); } catch (JsonProcessingException e) { throw new RuntimeException(e); } } - private String toJson(Collection authorities) { + private JSON toJson(Collection authorities) { List auths = authorities.stream() .map(GrantedAuthority::getAuthority) .toList(); try { - return jacksonObjectMapper.writeValueAsString(auths); + String str = jacksonObjectMapper.writeValueAsString(auths); + return JSON.jsonOrNull(str); } catch (JsonProcessingException e) { throw new RuntimeException(e); } From cfe8f4a9b930ec9e4e75e2eee5f39a4a0ce8d5d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sevket=20G=C3=B6kay?= Date: Sun, 11 Aug 2024 14:39:42 +0200 Subject: [PATCH 05/11] tighten json logic * add check for validating that "authorities" is an array * store a sorted set of authorities without duplicates --- .../idsg/steve/repository/impl/WebUserRepositoryImpl.java | 8 +++++--- src/main/resources/db/migration/V1_0_6__update.sql | 5 ++++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/main/java/de/rwth/idsg/steve/repository/impl/WebUserRepositoryImpl.java b/src/main/java/de/rwth/idsg/steve/repository/impl/WebUserRepositoryImpl.java index 4ae3e54e0..c183afe83 100644 --- a/src/main/java/de/rwth/idsg/steve/repository/impl/WebUserRepositoryImpl.java +++ b/src/main/java/de/rwth/idsg/steve/repository/impl/WebUserRepositoryImpl.java @@ -39,7 +39,8 @@ import org.springframework.util.Assert; import java.util.Collection; -import java.util.List; +import java.util.LinkedHashSet; +import java.util.stream.Collectors; import static jooq.steve.db.Tables.WEB_USER; import static org.springframework.security.authentication.UsernamePasswordAuthenticationToken.authenticated; @@ -162,9 +163,10 @@ private String[] fromJson(JSON jsonArray) { } private JSON toJson(Collection authorities) { - List auths = authorities.stream() + Collection auths = authorities.stream() .map(GrantedAuthority::getAuthority) - .toList(); + .sorted() // keep a stable order of entries + .collect(Collectors.toCollection(LinkedHashSet::new)); // prevent duplicates try { String str = jacksonObjectMapper.writeValueAsString(auths); diff --git a/src/main/resources/db/migration/V1_0_6__update.sql b/src/main/resources/db/migration/V1_0_6__update.sql index 4c7f82236..a9a694501 100644 --- a/src/main/resources/db/migration/V1_0_6__update.sql +++ b/src/main/resources/db/migration/V1_0_6__update.sql @@ -5,6 +5,9 @@ CREATE TABLE web_user password varchar(500) NOT NULL, enabled BOOLEAN NOT NULL, authorities JSON NOT NULL, + PRIMARY KEY (web_user_pk), - UNIQUE KEY (username) + UNIQUE KEY (username), + + CONSTRAINT authorities_must_be_array CHECK (json_type(authorities) = convert('ARRAY' using utf8)) ); From b5f75de3c4f0b80aacf7d6262e186f16054cf64b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sevket=20G=C3=B6kay?= Date: Sun, 11 Aug 2024 15:05:32 +0200 Subject: [PATCH 06/11] add method to delete web user by database id reason: to be used by web pages. a better way than doing with username, and is consistent with other delete operations we do. --- .../de/rwth/idsg/steve/repository/WebUserRepository.java | 2 ++ .../idsg/steve/repository/impl/WebUserRepositoryImpl.java | 7 +++++++ 2 files changed, 9 insertions(+) diff --git a/src/main/java/de/rwth/idsg/steve/repository/WebUserRepository.java b/src/main/java/de/rwth/idsg/steve/repository/WebUserRepository.java index 6979d8aed..26af60b26 100644 --- a/src/main/java/de/rwth/idsg/steve/repository/WebUserRepository.java +++ b/src/main/java/de/rwth/idsg/steve/repository/WebUserRepository.java @@ -22,5 +22,7 @@ public interface WebUserRepository extends UserDetailsManager { + void deleteUser(int webUserPk); + void changeStatusOfUser(String username, boolean enabled); } diff --git a/src/main/java/de/rwth/idsg/steve/repository/impl/WebUserRepositoryImpl.java b/src/main/java/de/rwth/idsg/steve/repository/impl/WebUserRepositoryImpl.java index c183afe83..399a185d0 100644 --- a/src/main/java/de/rwth/idsg/steve/repository/impl/WebUserRepositoryImpl.java +++ b/src/main/java/de/rwth/idsg/steve/repository/impl/WebUserRepositoryImpl.java @@ -92,6 +92,13 @@ public void deleteUser(String username) { .execute(); } + @Override + public void deleteUser(int webUserPk) { + ctx.delete(WEB_USER) + .where(WEB_USER.WEB_USER_PK.eq(webUserPk)) + .execute(); + } + @Override public void changeStatusOfUser(String username, boolean enabled) { ctx.update(WEB_USER) From 09a5f0d04e501304acd01ee8aa770626b1114579 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sevket=20G=C3=B6kay?= Date: Mon, 12 Aug 2024 23:56:16 +0200 Subject: [PATCH 07/11] PR feedback: skip default admin user creation, if "any" admin already exists --- .../rwth/idsg/steve/config/SecurityConfiguration.java | 11 +++++------ .../rwth/idsg/steve/repository/WebUserRepository.java | 2 ++ .../steve/repository/impl/WebUserRepositoryImpl.java | 11 +++++++++++ 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/main/java/de/rwth/idsg/steve/config/SecurityConfiguration.java b/src/main/java/de/rwth/idsg/steve/config/SecurityConfiguration.java index e000e299b..805809b64 100644 --- a/src/main/java/de/rwth/idsg/steve/config/SecurityConfiguration.java +++ b/src/main/java/de/rwth/idsg/steve/config/SecurityConfiguration.java @@ -21,6 +21,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.google.common.base.Strings; import de.rwth.idsg.steve.SteveConfiguration; +import de.rwth.idsg.steve.repository.WebUserRepository; import de.rwth.idsg.steve.web.api.ApiControllerAdvice; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -40,7 +41,6 @@ import org.springframework.security.crypto.factory.PasswordEncoderFactories; import org.springframework.security.crypto.password.DelegatingPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.security.provisioning.UserDetailsManager; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.preauth.AbstractPreAuthenticatedProcessingFilter; @@ -64,23 +64,22 @@ @EnableWebSecurity public class SecurityConfiguration { - private final UserDetailsManager userDetailsManager; + private final WebUserRepository webUserRepository; @PostConstruct public void postConstruct() { - String userName = SteveConfiguration.CONFIG.getAuth().getUserName(); - if (userDetailsManager.userExists(userName)) { + if (webUserRepository.hasUserWithAuthority("ADMIN")) { return; } var user = User - .withUsername(userName) + .withUsername(SteveConfiguration.CONFIG.getAuth().getUserName()) .password(SteveConfiguration.CONFIG.getAuth().getEncodedPassword()) .disabled(false) .authorities("ADMIN") .build(); - userDetailsManager.createUser(user); + webUserRepository.createUser(user); } /** diff --git a/src/main/java/de/rwth/idsg/steve/repository/WebUserRepository.java b/src/main/java/de/rwth/idsg/steve/repository/WebUserRepository.java index 26af60b26..488a6d4f6 100644 --- a/src/main/java/de/rwth/idsg/steve/repository/WebUserRepository.java +++ b/src/main/java/de/rwth/idsg/steve/repository/WebUserRepository.java @@ -25,4 +25,6 @@ public interface WebUserRepository extends UserDetailsManager { void deleteUser(int webUserPk); void changeStatusOfUser(String username, boolean enabled); + + boolean hasUserWithAuthority(String authority); } diff --git a/src/main/java/de/rwth/idsg/steve/repository/impl/WebUserRepositoryImpl.java b/src/main/java/de/rwth/idsg/steve/repository/impl/WebUserRepositoryImpl.java index 399a185d0..b3d2b951e 100644 --- a/src/main/java/de/rwth/idsg/steve/repository/impl/WebUserRepositoryImpl.java +++ b/src/main/java/de/rwth/idsg/steve/repository/impl/WebUserRepositoryImpl.java @@ -43,6 +43,7 @@ import java.util.stream.Collectors; import static jooq.steve.db.Tables.WEB_USER; +import static org.jooq.impl.DSL.condition; import static org.springframework.security.authentication.UsernamePasswordAuthenticationToken.authenticated; import static org.springframework.security.core.context.SecurityContextHolder.getContextHolderStrategy; @@ -107,6 +108,16 @@ public void changeStatusOfUser(String username, boolean enabled) { .execute(); } + @Override + public boolean hasUserWithAuthority(String authority) { + JSON authValue = JSON.json("\"" + authority + "\""); + return ctx.selectOne() + .from(WEB_USER) + .where(condition("json_contains({0}, {1})", WEB_USER.AUTHORITIES, authValue)) + .fetchOptional() + .isPresent(); + } + /** * Not only just an SQL Update. * The flow is inspired by {@link JdbcUserDetailsManager#changePassword(String, String)} From f759291440798967f801ec8a5af84cfbf35964fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sevket=20G=C3=B6kay?= Date: Tue, 13 Aug 2024 19:42:27 +0200 Subject: [PATCH 08/11] refactor: PR feedback --- .../idsg/steve/repository/impl/WebUserRepositoryImpl.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/de/rwth/idsg/steve/repository/impl/WebUserRepositoryImpl.java b/src/main/java/de/rwth/idsg/steve/repository/impl/WebUserRepositoryImpl.java index b3d2b951e..a5963592d 100644 --- a/src/main/java/de/rwth/idsg/steve/repository/impl/WebUserRepositoryImpl.java +++ b/src/main/java/de/rwth/idsg/steve/repository/impl/WebUserRepositoryImpl.java @@ -197,7 +197,7 @@ private JSON toJson(Collection authorities) { /** * Lifted from {@link JdbcUserDetailsManager#validateUserDetails(UserDetails)} */ - private void validateUserDetails(UserDetails user) { + private static void validateUserDetails(UserDetails user) { Assert.hasText(user.getUsername(), "Username may not be empty or null"); validateAuthorities(user.getAuthorities()); } @@ -205,7 +205,7 @@ private void validateUserDetails(UserDetails user) { /** * Lifted from {@link JdbcUserDetailsManager#validateAuthorities(Collection)} */ - private void validateAuthorities(Collection authorities) { + private static void validateAuthorities(Collection authorities) { Assert.notNull(authorities, "Authorities list must not be null"); for (GrantedAuthority authority : authorities) { Assert.notNull(authority, "Authorities list contains a null entry"); From 163de000a75d6de0ca1c68397629d1ce69dd382d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sevket=20G=C3=B6kay?= Date: Wed, 14 Aug 2024 00:42:28 +0200 Subject: [PATCH 09/11] prepare database for #1540 --- src/main/resources/db/migration/V1_0_6__update.sql | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/resources/db/migration/V1_0_6__update.sql b/src/main/resources/db/migration/V1_0_6__update.sql index a9a694501..6f5417ef2 100644 --- a/src/main/resources/db/migration/V1_0_6__update.sql +++ b/src/main/resources/db/migration/V1_0_6__update.sql @@ -3,6 +3,7 @@ CREATE TABLE web_user web_user_pk INT NOT NULL AUTO_INCREMENT, username varchar(500) NOT NULL, password varchar(500) NOT NULL, + api_token varchar(500) NULL, enabled BOOLEAN NOT NULL, authorities JSON NOT NULL, From 0992d5cdc3f13ead84e1d09157b8d1bfdd80bfde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sevket=20G=C3=B6kay?= Date: Thu, 15 Aug 2024 22:52:50 +0200 Subject: [PATCH 10/11] PR feedback --- .../steve/config/SecurityConfiguration.java | 22 -- .../steve/repository/WebUserRepository.java | 18 +- .../impl/WebUserRepositoryImpl.java | 134 ++---------- .../idsg/steve/service/WebUserService.java | 196 ++++++++++++++++++ 4 files changed, 224 insertions(+), 146 deletions(-) create mode 100644 src/main/java/de/rwth/idsg/steve/service/WebUserService.java diff --git a/src/main/java/de/rwth/idsg/steve/config/SecurityConfiguration.java b/src/main/java/de/rwth/idsg/steve/config/SecurityConfiguration.java index 805809b64..1520849ab 100644 --- a/src/main/java/de/rwth/idsg/steve/config/SecurityConfiguration.java +++ b/src/main/java/de/rwth/idsg/steve/config/SecurityConfiguration.java @@ -20,8 +20,6 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.google.common.base.Strings; -import de.rwth.idsg.steve.SteveConfiguration; -import de.rwth.idsg.steve.repository.WebUserRepository; import de.rwth.idsg.steve.web.api.ApiControllerAdvice; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -37,7 +35,6 @@ import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; -import org.springframework.security.core.userdetails.User; import org.springframework.security.crypto.factory.PasswordEncoderFactories; import org.springframework.security.crypto.password.DelegatingPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; @@ -45,7 +42,6 @@ import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.preauth.AbstractPreAuthenticatedProcessingFilter; -import jakarta.annotation.PostConstruct; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -64,24 +60,6 @@ @EnableWebSecurity public class SecurityConfiguration { - private final WebUserRepository webUserRepository; - - @PostConstruct - public void postConstruct() { - if (webUserRepository.hasUserWithAuthority("ADMIN")) { - return; - } - - var user = User - .withUsername(SteveConfiguration.CONFIG.getAuth().getUserName()) - .password(SteveConfiguration.CONFIG.getAuth().getEncodedPassword()) - .disabled(false) - .authorities("ADMIN") - .build(); - - webUserRepository.createUser(user); - } - /** * Password encoding changed with spring-security 5.0.0. We either have to use a prefix before the password to * indicate which actual encoder {@link DelegatingPasswordEncoder} should use [1, 2] or specify the encoder as we do. diff --git a/src/main/java/de/rwth/idsg/steve/repository/WebUserRepository.java b/src/main/java/de/rwth/idsg/steve/repository/WebUserRepository.java index 488a6d4f6..00eb361f8 100644 --- a/src/main/java/de/rwth/idsg/steve/repository/WebUserRepository.java +++ b/src/main/java/de/rwth/idsg/steve/repository/WebUserRepository.java @@ -18,13 +18,25 @@ */ package de.rwth.idsg.steve.repository; -import org.springframework.security.provisioning.UserDetailsManager; +import jooq.steve.db.tables.records.WebUserRecord; -public interface WebUserRepository extends UserDetailsManager { +public interface WebUserRepository { + + void createUser(WebUserRecord user); + + void updateUser(WebUserRecord user); + + void deleteUser(String username); void deleteUser(int webUserPk); void changeStatusOfUser(String username, boolean enabled); - boolean hasUserWithAuthority(String authority); + Integer getUserCountWithAuthority(String authority); + + void changePassword(String username, String newPassword); + + boolean userExists(String username); + + WebUserRecord loadUserByUsername(String username); } diff --git a/src/main/java/de/rwth/idsg/steve/repository/impl/WebUserRepositoryImpl.java b/src/main/java/de/rwth/idsg/steve/repository/impl/WebUserRepositoryImpl.java index a5963592d..6e48ee5dc 100644 --- a/src/main/java/de/rwth/idsg/steve/repository/impl/WebUserRepositoryImpl.java +++ b/src/main/java/de/rwth/idsg/steve/repository/impl/WebUserRepositoryImpl.java @@ -18,38 +18,19 @@ */ package de.rwth.idsg.steve.repository.impl; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; import de.rwth.idsg.steve.repository.WebUserRepository; import jooq.steve.db.tables.records.WebUserRecord; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.jooq.DSLContext; import org.jooq.JSON; -import org.springframework.security.access.AccessDeniedException; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.context.SecurityContext; -import org.springframework.security.core.context.SecurityContextHolderStrategy; -import org.springframework.security.core.userdetails.User; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.security.core.userdetails.UsernameNotFoundException; -import org.springframework.security.provisioning.JdbcUserDetailsManager; import org.springframework.stereotype.Repository; -import org.springframework.util.Assert; - -import java.util.Collection; -import java.util.LinkedHashSet; -import java.util.stream.Collectors; import static jooq.steve.db.Tables.WEB_USER; import static org.jooq.impl.DSL.condition; -import static org.springframework.security.authentication.UsernamePasswordAuthenticationToken.authenticated; -import static org.springframework.security.core.context.SecurityContextHolder.getContextHolderStrategy; +import static org.jooq.impl.DSL.count; /** - * Inspired by {@link org.springframework.security.provisioning.JdbcUserDetailsManager} - * * @author Sevket Goekay * @since 10.08.2024 */ @@ -59,29 +40,23 @@ public class WebUserRepositoryImpl implements WebUserRepository { private final DSLContext ctx; - private final ObjectMapper jacksonObjectMapper; - private final SecurityContextHolderStrategy securityContextHolderStrategy = getContextHolderStrategy(); @Override - public void createUser(UserDetails user) { - validateUserDetails(user); - + public void createUser(WebUserRecord user) { ctx.insertInto(WEB_USER) .set(WEB_USER.USERNAME, user.getUsername()) .set(WEB_USER.PASSWORD, user.getPassword()) - .set(WEB_USER.ENABLED, user.isEnabled()) - .set(WEB_USER.AUTHORITIES, toJson(user.getAuthorities())) + .set(WEB_USER.ENABLED, user.getEnabled()) + .set(WEB_USER.AUTHORITIES, user.getAuthorities()) .execute(); } @Override - public void updateUser(UserDetails user) { - validateUserDetails(user); - + public void updateUser(WebUserRecord user) { ctx.update(WEB_USER) .set(WEB_USER.PASSWORD, user.getPassword()) - .set(WEB_USER.ENABLED, user.isEnabled()) - .set(WEB_USER.AUTHORITIES, toJson(user.getAuthorities())) + .set(WEB_USER.ENABLED, user.getEnabled()) + .set(WEB_USER.AUTHORITIES, user.getAuthorities()) .where(WEB_USER.USERNAME.eq(user.getUsername())) .execute(); } @@ -109,40 +84,20 @@ public void changeStatusOfUser(String username, boolean enabled) { } @Override - public boolean hasUserWithAuthority(String authority) { + public Integer getUserCountWithAuthority(String authority) { JSON authValue = JSON.json("\"" + authority + "\""); - return ctx.selectOne() + return ctx.selectCount() .from(WEB_USER) .where(condition("json_contains({0}, {1})", WEB_USER.AUTHORITIES, authValue)) - .fetchOptional() - .isPresent(); + .fetchOne(count()); } - /** - * Not only just an SQL Update. - * The flow is inspired by {@link JdbcUserDetailsManager#changePassword(String, String)} - */ @Override - public void changePassword(String oldPassword, String newPassword) { - Authentication currentUser = this.securityContextHolderStrategy.getContext().getAuthentication(); - if (currentUser == null) { - // This would indicate bad coding somewhere - throw new AccessDeniedException( - "Can't change password as no Authentication object found in context for current user." - ); - } - - String username = currentUser.getName(); - + public void changePassword(String username, String newPassword) { ctx.update(WEB_USER) .set(WEB_USER.PASSWORD, newPassword) .where(WEB_USER.USERNAME.eq(username)) .execute(); - - Authentication authentication = createNewAuthentication(currentUser, newPassword); - SecurityContext context = this.securityContextHolderStrategy.createEmptyContext(); - context.setAuthentication(authentication); - this.securityContextHolderStrategy.setContext(context); } @Override @@ -155,72 +110,9 @@ public boolean userExists(String username) { } @Override - public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { - WebUserRecord record = ctx.selectFrom(WEB_USER) + public WebUserRecord loadUserByUsername(String username) { + return ctx.selectFrom(WEB_USER) .where(WEB_USER.USERNAME.eq(username)) .fetchOne(); - - if (record == null) { - throw new UsernameNotFoundException(username); - } - - return User - .withUsername(record.getUsername()) - .password(record.getPassword()) - .disabled(!record.getEnabled()) - .authorities(fromJson(record.getAuthorities())) - .build(); } - - private String[] fromJson(JSON jsonArray) { - try { - return jacksonObjectMapper.readValue(jsonArray.data(), String[].class); - } catch (JsonProcessingException e) { - throw new RuntimeException(e); - } - } - - private JSON toJson(Collection authorities) { - Collection auths = authorities.stream() - .map(GrantedAuthority::getAuthority) - .sorted() // keep a stable order of entries - .collect(Collectors.toCollection(LinkedHashSet::new)); // prevent duplicates - - try { - String str = jacksonObjectMapper.writeValueAsString(auths); - return JSON.jsonOrNull(str); - } catch (JsonProcessingException e) { - throw new RuntimeException(e); - } - } - - /** - * Lifted from {@link JdbcUserDetailsManager#validateUserDetails(UserDetails)} - */ - private static void validateUserDetails(UserDetails user) { - Assert.hasText(user.getUsername(), "Username may not be empty or null"); - validateAuthorities(user.getAuthorities()); - } - - /** - * Lifted from {@link JdbcUserDetailsManager#validateAuthorities(Collection)} - */ - private static void validateAuthorities(Collection authorities) { - Assert.notNull(authorities, "Authorities list must not be null"); - for (GrantedAuthority authority : authorities) { - Assert.notNull(authority, "Authorities list contains a null entry"); - Assert.hasText(authority.getAuthority(), "getAuthority() method must return a non-empty string"); - } - } - - /** - * Lifted from {@link JdbcUserDetailsManager#createNewAuthentication(Authentication, String)} - */ - private Authentication createNewAuthentication(Authentication currentAuth, String newPassword) { - var user = this.loadUserByUsername(currentAuth.getName()); - var newAuthentication = authenticated(user, null, user.getAuthorities()); - newAuthentication.setDetails(currentAuth.getDetails()); - return newAuthentication; - } - } diff --git a/src/main/java/de/rwth/idsg/steve/service/WebUserService.java b/src/main/java/de/rwth/idsg/steve/service/WebUserService.java new file mode 100644 index 000000000..27c3cc928 --- /dev/null +++ b/src/main/java/de/rwth/idsg/steve/service/WebUserService.java @@ -0,0 +1,196 @@ +package de.rwth.idsg.steve.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import de.rwth.idsg.steve.SteveConfiguration; +import de.rwth.idsg.steve.repository.WebUserRepository; +import jooq.steve.db.tables.records.WebUserRecord; +import lombok.RequiredArgsConstructor; +import org.jooq.JSON; +import org.springframework.context.event.ContextRefreshedEvent; +import org.springframework.context.event.EventListener; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolderStrategy; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.provisioning.JdbcUserDetailsManager; +import org.springframework.security.provisioning.UserDetailsManager; +import org.springframework.stereotype.Service; +import org.springframework.util.Assert; + +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.stream.Collectors; + +import static org.springframework.security.authentication.UsernamePasswordAuthenticationToken.authenticated; +import static org.springframework.security.core.context.SecurityContextHolder.getContextHolderStrategy; + +/** + * Inspired by {@link org.springframework.security.provisioning.JdbcUserDetailsManager} + * + * @author Sevket Goekay + * @since 15.08.2024 + */ +@Service +@RequiredArgsConstructor +public class WebUserService implements UserDetailsManager { + + private final ObjectMapper jacksonObjectMapper; + private final WebUserRepository webUserRepository; + private final SecurityContextHolderStrategy securityContextHolderStrategy = getContextHolderStrategy(); + + @EventListener + public void afterStart(ContextRefreshedEvent event) { + if (this.hasUserWithAuthority("ADMIN")) { + return; + } + + var user = User + .withUsername(SteveConfiguration.CONFIG.getAuth().getUserName()) + .password(SteveConfiguration.CONFIG.getAuth().getEncodedPassword()) + .disabled(false) + .authorities("ADMIN") + .build(); + + this.createUser(user); + } + + @Override + public void createUser(UserDetails user) { + validateUserDetails(user); + var record = toWebUserRecord(user); + webUserRepository.createUser(record); + } + + @Override + public void updateUser(UserDetails user) { + validateUserDetails(user); + var record = toWebUserRecord(user); + webUserRepository.updateUser(record); + } + + @Override + public void deleteUser(String username) { + webUserRepository.deleteUser(username); + } + + /** + * Not only just an SQL Update. + * The flow is inspired by {@link JdbcUserDetailsManager#changePassword(String, String)} + */ + @Override + public void changePassword(String oldPassword, String newPassword) { + Authentication currentUser = this.securityContextHolderStrategy.getContext().getAuthentication(); + if (currentUser == null) { + // This would indicate bad coding somewhere + throw new AccessDeniedException( + "Can't change password as no Authentication object found in context for current user." + ); + } + + String username = currentUser.getName(); + webUserRepository.changePassword(username, newPassword); + + Authentication authentication = createNewAuthentication(currentUser, newPassword); + SecurityContext context = this.securityContextHolderStrategy.createEmptyContext(); + context.setAuthentication(authentication); + this.securityContextHolderStrategy.setContext(context); + } + + @Override + public boolean userExists(String username) { + return webUserRepository.userExists(username); + } + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + WebUserRecord record = webUserRepository.loadUserByUsername(username); + + if (record == null) { + throw new UsernameNotFoundException(username); + } + + return User + .withUsername(record.getUsername()) + .password(record.getPassword()) + .disabled(!record.getEnabled()) + .authorities(fromJson(record.getAuthorities())) + .build(); + } + + public void deleteUser(int webUserPk) { + webUserRepository.deleteUser(webUserPk); + } + + public void changeStatusOfUser(String username, boolean enabled) { + webUserRepository.changeStatusOfUser(username, enabled); + } + + public boolean hasUserWithAuthority(String authority) { + Integer count = webUserRepository.getUserCountWithAuthority(authority); + return count != null && count > 0; + } + + private WebUserRecord toWebUserRecord(UserDetails user) { + return new WebUserRecord() + .setUsername(user.getUsername()) + .setPassword(user.getPassword()) + .setEnabled(user.isEnabled()) + .setAuthorities(toJson(user.getAuthorities())); + } + + private String[] fromJson(JSON jsonArray) { + try { + return jacksonObjectMapper.readValue(jsonArray.data(), String[].class); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + + private JSON toJson(Collection authorities) { + Collection auths = authorities.stream() + .map(GrantedAuthority::getAuthority) + .sorted() // keep a stable order of entries + .collect(Collectors.toCollection(LinkedHashSet::new)); // prevent duplicates + + try { + String str = jacksonObjectMapper.writeValueAsString(auths); + return JSON.jsonOrNull(str); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + + /** + * Lifted from {@link JdbcUserDetailsManager#validateUserDetails(UserDetails)} + */ + private static void validateUserDetails(UserDetails user) { + Assert.hasText(user.getUsername(), "Username may not be empty or null"); + validateAuthorities(user.getAuthorities()); + } + + /** + * Lifted from {@link JdbcUserDetailsManager#validateAuthorities(Collection)} + */ + private static void validateAuthorities(Collection authorities) { + Assert.notNull(authorities, "Authorities list must not be null"); + for (GrantedAuthority authority : authorities) { + Assert.notNull(authority, "Authorities list contains a null entry"); + Assert.hasText(authority.getAuthority(), "getAuthority() method must return a non-empty string"); + } + } + + /** + * Lifted from {@link JdbcUserDetailsManager#createNewAuthentication(Authentication, String)} + */ + private Authentication createNewAuthentication(Authentication currentAuth, String newPassword) { + var user = this.loadUserByUsername(currentAuth.getName()); + var newAuthentication = authenticated(user, null, user.getAuthorities()); + newAuthentication.setDetails(currentAuth.getDetails()); + return newAuthentication; + } +} From 8e95e2961b8dd804cf686d2de787961a42f25fa4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sevket=20G=C3=B6kay?= Date: Thu, 15 Aug 2024 22:57:24 +0200 Subject: [PATCH 11/11] add license header where missing --- .../idsg/steve/service/WebUserService.java | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/main/java/de/rwth/idsg/steve/service/WebUserService.java b/src/main/java/de/rwth/idsg/steve/service/WebUserService.java index 27c3cc928..257217ab0 100644 --- a/src/main/java/de/rwth/idsg/steve/service/WebUserService.java +++ b/src/main/java/de/rwth/idsg/steve/service/WebUserService.java @@ -1,3 +1,21 @@ +/* + * SteVe - SteckdosenVerwaltung - https://github.com/steve-community/steve + * Copyright (C) 2013-2024 SteVe Community Team + * All Rights Reserved. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ package de.rwth.idsg.steve.service; import com.fasterxml.jackson.core.JsonProcessingException;