Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement database-based multi user system for Web UI #1539

Merged
merged 11 commits into from
Aug 16, 2024
4 changes: 4 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,10 @@
<name>BOOLEAN</name>
<includeExpression>.*\.OCPP_TAG_ACTIVITY\.(IN_TRANSACTION|BLOCKED)</includeExpression>
</forcedType>
<forcedType>
<name>JSON</name>
<includeExpression>.*\.WEB_USER\.(AUTHORITIES)</includeExpression>
</forcedType>
<forcedType>
<userType>org.joda.time.DateTime</userType>
<converter>de.rwth.idsg.steve.utils.DateTimeConverter</converter>
Expand Down
39 changes: 24 additions & 15 deletions src/main/java/de/rwth/idsg/steve/config/SecurityConfiguration.java
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@

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;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
Expand All @@ -35,16 +38,14 @@
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;

import jakarta.annotation.PostConstruct;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
Expand All @@ -58,10 +59,29 @@
* @since 07.01.2015
*/
@Slf4j
@RequiredArgsConstructor
@Configuration
@EnableWebSecurity
public class SecurityConfiguration {

private final WebUserRepository webUserRepository;

@PostConstruct
goekay marked this conversation as resolved.
Show resolved Hide resolved
public void postConstruct() {
if (webUserRepository.hasUserWithAuthority("ADMIN")) {
return;
}

var user = User
.withUsername(SteveConfiguration.CONFIG.getAuth().getUserName())
.password(SteveConfiguration.CONFIG.getAuth().getEncodedPassword())
goekay marked this conversation as resolved.
Show resolved Hide resolved
.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.
Expand All @@ -74,17 +94,6 @@ public PasswordEncoder passwordEncoder() {
return CONFIG.getAuth().getPasswordEncoder();
}

@Bean
public UserDetailsService userDetailsService() {
UserDetails webPageUser = User.builder()
.username(CONFIG.getAuth().getUserName())
.password(CONFIG.getAuth().getEncodedPassword())
.roles("ADMIN")
.build();

return new InMemoryUserDetailsManager(webPageUser);
}

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
final String prefix = CONFIG.getSpringManagerMapping();
Expand All @@ -98,7 +107,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.
Expand Down
30 changes: 30 additions & 0 deletions src/main/java/de/rwth/idsg/steve/repository/WebUserRepository.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package de.rwth.idsg.steve.repository;

import org.springframework.security.provisioning.UserDetailsManager;

public interface WebUserRepository extends UserDetailsManager {
goekay marked this conversation as resolved.
Show resolved Hide resolved

void deleteUser(int webUserPk);

void changeStatusOfUser(String username, boolean enabled);

boolean hasUserWithAuthority(String authority);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
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;

/**
* Inspired by {@link org.springframework.security.provisioning.JdbcUserDetailsManager}
*
* @author Sevket Goekay <sevketgokay@gmail.com>
* @since 10.08.2024
*/
@Slf4j
@Repository
@RequiredArgsConstructor
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);

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 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)
.set(WEB_USER.ENABLED, enabled)
.where(WEB_USER.USERNAME.eq(username))
.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()
goekay marked this conversation as resolved.
Show resolved Hide resolved
.isPresent();
}

/**
* 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(JSON jsonArray) {
try {
return jacksonObjectMapper.readValue(jsonArray.data(), String[].class);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}

private JSON toJson(Collection<? extends GrantedAuthority> authorities) {
Collection<String> 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<? extends GrantedAuthority> 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;
}

}
14 changes: 14 additions & 0 deletions src/main/resources/db/migration/V1_0_6__update.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
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,

PRIMARY KEY (web_user_pk),
UNIQUE KEY (username),

CONSTRAINT authorities_must_be_array CHECK (json_type(authorities) = convert('ARRAY' using utf8))
);