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;