diff --git a/src/main/java/de/rwth/idsg/steve/config/ApiAuthenticationManager.java b/src/main/java/de/rwth/idsg/steve/config/ApiAuthenticationManager.java
new file mode 100644
index 000000000..0d51bc01c
--- /dev/null
+++ b/src/main/java/de/rwth/idsg/steve/config/ApiAuthenticationManager.java
@@ -0,0 +1,115 @@
+/*
+ * 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.config;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.google.common.base.Strings;
+import de.rwth.idsg.steve.service.WebUserService;
+import de.rwth.idsg.steve.web.api.ApiControllerAdvice;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.authentication.BadCredentialsException;
+import org.springframework.security.authentication.DisabledException;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.AuthenticationException;
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.security.web.AuthenticationEntryPoint;
+import org.springframework.stereotype.Component;
+
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+
+import java.io.IOException;
+
+/**
+ * @author Sevket Goekay
+ * @since 17.08.2024
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class ApiAuthenticationManager implements AuthenticationManager, AuthenticationEntryPoint {
+
+ private final WebUserService webUserService;
+ private final PasswordEncoder passwordEncoder;
+ private final ObjectMapper jacksonObjectMapper;
+
+ @Override
+ public Authentication authenticate(Authentication authentication) throws AuthenticationException {
+ String username = (String) authentication.getPrincipal();
+ String apiPassword = (String) authentication.getCredentials();
+
+ if (Strings.isNullOrEmpty(username) || Strings.isNullOrEmpty(apiPassword)) {
+ throw new BadCredentialsException("Required parameters missing");
+ }
+
+ UserDetails userDetails = webUserService.loadUserByUsernameForApi(username);
+ if (!areValuesSet(userDetails)) {
+ throw new DisabledException("The user does not exist, exists but is disabled or has API access disabled.");
+ }
+
+ boolean match = passwordEncoder.matches(apiPassword, userDetails.getPassword());
+ if (!match) {
+ throw new BadCredentialsException("Invalid password");
+ }
+
+ return UsernamePasswordAuthenticationToken.authenticated(
+ authentication.getPrincipal(),
+ authentication.getCredentials(),
+ userDetails.getAuthorities()
+ );
+ }
+
+ @Override
+ public void commence(HttpServletRequest request,
+ HttpServletResponse response,
+ AuthenticationException authException) throws IOException, ServletException {
+ HttpStatus status = HttpStatus.UNAUTHORIZED;
+
+ var apiResponse = ApiControllerAdvice.createResponse(
+ request.getRequestURL().toString(),
+ status,
+ authException.getMessage()
+ );
+
+ response.setStatus(status.value());
+ response.setContentType(MediaType.APPLICATION_JSON_VALUE);
+ response.getWriter().print(jacksonObjectMapper.writeValueAsString(apiResponse));
+ }
+
+ private static boolean areValuesSet(UserDetails userDetails) {
+ if (userDetails == null) {
+ return false;
+ }
+ if (!userDetails.isEnabled()) {
+ return false;
+ }
+ if (Strings.isNullOrEmpty(userDetails.getPassword())) {
+ return false;
+ }
+ return true;
+ }
+
+}
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 1520849ab..2b1a91b14 100644
--- a/src/main/java/de/rwth/idsg/steve/config/SecurityConfiguration.java
+++ b/src/main/java/de/rwth/idsg/steve/config/SecurityConfiguration.java
@@ -18,35 +18,19 @@
*/
package de.rwth.idsg.steve.config;
-import com.fasterxml.jackson.databind.ObjectMapper;
-import com.google.common.base.Strings;
-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;
import org.springframework.core.annotation.Order;
-import org.springframework.http.HttpStatus;
-import org.springframework.http.MediaType;
-import org.springframework.security.authentication.AuthenticationManager;
-import org.springframework.security.authentication.DisabledException;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
-import org.springframework.security.core.Authentication;
-import org.springframework.security.core.AuthenticationException;
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.web.AuthenticationEntryPoint;
import org.springframework.security.web.SecurityFilterChain;
-import org.springframework.security.web.authentication.preauth.AbstractPreAuthenticatedProcessingFilter;
-
-import jakarta.servlet.ServletException;
-import jakarta.servlet.http.HttpServletRequest;
-import jakarta.servlet.http.HttpServletResponse;
-
-import java.io.IOException;
+import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
import static de.rwth.idsg.steve.SteveConfiguration.CONFIG;
@@ -105,88 +89,12 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
@Bean
@Order(1)
- public SecurityFilterChain apiKeyFilterChain(HttpSecurity http, ObjectMapper jacksonObjectMapper) throws Exception {
+ public SecurityFilterChain apiKeyFilterChain(HttpSecurity http, ApiAuthenticationManager apiAuthenticationManager) throws Exception {
return http.securityMatcher(CONFIG.getApiMapping() + "/**")
.csrf(k -> k.disable())
.sessionManagement(k -> k.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
- .addFilter(new ApiKeyFilter())
+ .addFilter(new BasicAuthenticationFilter(apiAuthenticationManager, apiAuthenticationManager))
.authorizeHttpRequests(k -> k.anyRequest().authenticated())
- .exceptionHandling(k -> k.authenticationEntryPoint(new ApiKeyAuthenticationEntryPoint(jacksonObjectMapper)))
.build();
}
-
- /**
- * Enable Web APIs only if both properties for API key are set. This has two consequences:
- * 1) Backwards compatibility: Existing installations with older properties file, that does not include these two
- * new keys, will not expose the APIs. Every call will be blocked by default.
- * 2) If you want to expose your APIs, you MUST set these properties. This action activates authentication (i.e.
- * APIs without authentication are not possible, and this is a good thing).
- */
- public static class ApiKeyFilter extends AbstractPreAuthenticatedProcessingFilter implements AuthenticationManager {
-
- private final String headerKey;
- private final String headerValue;
- private final boolean isApiEnabled;
-
- public ApiKeyFilter() {
- setAuthenticationManager(this);
-
- headerKey = CONFIG.getWebApi().getHeaderKey();
- headerValue = CONFIG.getWebApi().getHeaderValue();
- isApiEnabled = !Strings.isNullOrEmpty(headerKey) && !Strings.isNullOrEmpty(headerValue);
-
- if (!isApiEnabled) {
- log.warn("Web APIs will not be exposed. Reason: 'webapi.key' and 'webapi.value' are not set in config file");
- }
- }
-
- @Override
- protected Object getPreAuthenticatedPrincipal(HttpServletRequest request) {
- if (!isApiEnabled) {
- throw new DisabledException("Web APIs are not exposed");
- }
- return request.getHeader(headerKey);
- }
-
- @Override
- protected Object getPreAuthenticatedCredentials(HttpServletRequest request) {
- return null;
- }
-
- @Override
- public Authentication authenticate(Authentication authentication) throws AuthenticationException {
- if (!isApiEnabled) {
- throw new DisabledException("Web APIs are not exposed");
- }
-
- String principal = (String) authentication.getPrincipal();
- authentication.setAuthenticated(headerValue.equals(principal));
- return authentication;
- }
- }
-
- public static class ApiKeyAuthenticationEntryPoint implements AuthenticationEntryPoint {
-
- private final ObjectMapper mapper;
-
- private ApiKeyAuthenticationEntryPoint(ObjectMapper mapper) {
- this.mapper = mapper;
- }
-
- @Override
- public void commence(HttpServletRequest request, HttpServletResponse response,
- AuthenticationException authException) throws IOException, ServletException {
- HttpStatus status = HttpStatus.UNAUTHORIZED;
-
- var apiResponse = ApiControllerAdvice.createResponse(
- request.getRequestURL().toString(),
- status,
- "Full authentication is required to access this resource"
- );
-
- response.setStatus(status.value());
- response.setContentType(MediaType.APPLICATION_JSON_VALUE);
- response.getWriter().print(mapper.writeValueAsString(apiResponse));
- }
- }
}
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 6e48ee5dc..64ef3f4df 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
@@ -46,6 +46,7 @@ public void createUser(WebUserRecord user) {
ctx.insertInto(WEB_USER)
.set(WEB_USER.USERNAME, user.getUsername())
.set(WEB_USER.PASSWORD, user.getPassword())
+ .set(WEB_USER.API_PASSWORD, user.getApiPassword())
.set(WEB_USER.ENABLED, user.getEnabled())
.set(WEB_USER.AUTHORITIES, user.getAuthorities())
.execute();
@@ -55,6 +56,7 @@ public void createUser(WebUserRecord user) {
public void updateUser(WebUserRecord user) {
ctx.update(WEB_USER)
.set(WEB_USER.PASSWORD, user.getPassword())
+ .set(WEB_USER.API_PASSWORD, user.getApiPassword())
.set(WEB_USER.ENABLED, user.getEnabled())
.set(WEB_USER.AUTHORITIES, user.getAuthorities())
.where(WEB_USER.USERNAME.eq(user.getUsername()))
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 257217ab0..6fe749368 100644
--- a/src/main/java/de/rwth/idsg/steve/service/WebUserService.java
+++ b/src/main/java/de/rwth/idsg/steve/service/WebUserService.java
@@ -20,6 +20,8 @@
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
+import com.google.common.cache.Cache;
+import com.google.common.cache.CacheBuilder;
import de.rwth.idsg.steve.SteveConfiguration;
import de.rwth.idsg.steve.repository.WebUserRepository;
import jooq.steve.db.tables.records.WebUserRecord;
@@ -41,7 +43,10 @@
import org.springframework.util.Assert;
import java.util.Collection;
+import java.util.Collections;
import java.util.LinkedHashSet;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import static org.springframework.security.authentication.UsernamePasswordAuthenticationToken.authenticated;
@@ -57,10 +62,18 @@
@RequiredArgsConstructor
public class WebUserService implements UserDetailsManager {
+ // Because Guava's cache does not accept a null value
+ private static final UserDetails DUMMY_USER = new User("#", "#", Collections.emptyList());
+
private final ObjectMapper jacksonObjectMapper;
private final WebUserRepository webUserRepository;
private final SecurityContextHolderStrategy securityContextHolderStrategy = getContextHolderStrategy();
+ private final Cache userCache = CacheBuilder.newBuilder()
+ .expireAfterWrite(10, TimeUnit.MINUTES) // TTL
+ .maximumSize(100)
+ .build();
+
@EventListener
public void afterStart(ContextRefreshedEvent event) {
if (this.hasUserWithAuthority("ADMIN")) {
@@ -140,6 +153,20 @@ public UserDetails loadUserByUsername(String username) throws UsernameNotFoundEx
.build();
}
+ public UserDetails loadUserByUsernameForApi(String username) {
+ try {
+ UserDetails userExt = userCache.get(username, () -> {
+ UserDetails user = this.loadUserByUsernameForApiInternal(username);
+ // map null to dummy
+ return (user == null) ? DUMMY_USER : user;
+ });
+ // map dummy back to null
+ return (userExt == DUMMY_USER) ? null : userExt;
+ } catch (ExecutionException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
public void deleteUser(int webUserPk) {
webUserRepository.deleteUser(webUserPk);
}
@@ -153,6 +180,26 @@ public boolean hasUserWithAuthority(String authority) {
return count != null && count > 0;
}
+ private UserDetails loadUserByUsernameForApiInternal(String username) {
+ WebUserRecord record = webUserRepository.loadUserByUsername(username);
+ if (record == null) {
+ return null;
+ }
+
+ // the builder User.password(..) does not allow null values
+ String apiPassword = record.getApiPassword();
+ if (apiPassword == null) {
+ apiPassword = "";
+ }
+
+ return User
+ .withUsername(record.getUsername())
+ .password(apiPassword)
+ .disabled(!record.getEnabled())
+ .authorities(fromJson(record.getAuthorities()))
+ .build();
+ }
+
private WebUserRecord toWebUserRecord(UserDetails user) {
return new WebUserRecord()
.setUsername(user.getUsername())
diff --git a/src/main/resources/db/migration/V1_0_7__update.sql b/src/main/resources/db/migration/V1_0_7__update.sql
new file mode 100644
index 000000000..69ca4ae16
--- /dev/null
+++ b/src/main/resources/db/migration/V1_0_7__update.sql
@@ -0,0 +1 @@
+ALTER TABLE web_user CHANGE COLUMN api_token api_password varchar(500) NULL;