Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 140 additions & 0 deletions src/main/java/de/rwth/idsg/steve/config/ApiAuthenticationManager.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
/*
* 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.config;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.base.Strings;
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
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.User;
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;
import java.util.Collections;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;

/**
* @author Sevket Goekay <[email protected]>
* @since 17.08.2024
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ApiAuthenticationManager implements AuthenticationManager, AuthenticationEntryPoint {

// Because Guava's cache does not accept a null value
private static final UserDetails DUMMY_USER = new User("#", "#", Collections.emptyList());

private final WebUserService webUserService;
private final PasswordEncoder passwordEncoder;
private final ObjectMapper jacksonObjectMapper;

private final Cache<String, UserDetails> userCache = CacheBuilder.newBuilder()
.expireAfterWrite(10, TimeUnit.MINUTES) // TTL
.maximumSize(100)
.build();

@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 = getFromCacheOrDatabase(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 UserDetails getFromCacheOrDatabase(String username) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do you set the cache logic here instead of webUserService.loadUserByUsernameForApi?

I don't know if it is a choice to not use it but Spring has its own way of doing caches: https://spring.io/guides/gs/caching
And you can still use guava under to wood if you want it: https://docs.spring.io/spring-framework/docs/4.2.x/javadoc-api/org/springframework/cache/guava/GuavaCacheManager.html

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@goekay Just for my technical knowledge because I didn't use them before: why do you use guava directly and not hidden behind spring cache?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a combination of multiple reasons actually (disclaimer: i have been using both of them for a long time)

  • stylistic preference if it is just about a localized cache usage, instead of something big or application-wide
  • tighter control i can have with guava. this does not matter when using GuavaCacheManager, since the same can be done with that... but then, if you control guava like this, why introduce spring magic? which brings me to my next point.
  • absence of multiple spring layers, abstractions, which can lead to weird misbehaviour and gotchas
  • to be consistent with the codebase: steve has this direct usage of Guava at some other places, there is no usage of Spring cache

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the answer! 👍

why introduce spring magic?

That could be true for almost every spring import ;)

try {
return userCache.get(username, () -> {
UserDetails user = webUserService.loadUserByUsernameForApi(username);
return (user == null) ? DUMMY_USER : user;
});
} catch (ExecutionException e) {
throw new RuntimeException(e);
}
}

private static boolean areValuesSet(UserDetails userDetails) {
if (userDetails == null || userDetails == DUMMY_USER) {
return false;
}
if (!userDetails.isEnabled()) {
return false;
}
if (Strings.isNullOrEmpty(userDetails.getPassword())) {
return false;
}
return true;
}

}
98 changes: 3 additions & 95 deletions src/main/java/de/rwth/idsg/steve/config/SecurityConfiguration.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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));
}
}
}
29 changes: 29 additions & 0 deletions src/main/java/de/rwth/idsg/steve/service/WebUserService.java
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,11 @@ public class WebUserService implements UserDetailsManager {

@EventListener
public void afterStart(ContextRefreshedEvent event) {
moveUserFromConfigToDatabase();
moveApiTokenFromConfigToDatabase();
}

private void moveUserFromConfigToDatabase() {
if (this.hasUserWithAuthority("ADMIN")) {
return;
}
Expand All @@ -77,6 +82,10 @@ public void afterStart(ContextRefreshedEvent event) {
this.createUser(user);
}

private void moveApiTokenFromConfigToDatabase() {
// TODO
}

@Override
public void createUser(UserDetails user) {
validateUserDetails(user);
Expand Down Expand Up @@ -140,6 +149,26 @@ public UserDetails loadUserByUsername(String username) throws UsernameNotFoundEx
.build();
}

public UserDetails loadUserByUsernameForApi(String username) {
WebUserRecord record = webUserRepository.loadUserByUsername(username);
if (record == null) {
return null;
}

// the builder User.password(..) does not allow null values
String apiPassword = record.getApiToken();
if (apiPassword == null) {
apiPassword = "";
}

return User
.withUsername(record.getUsername())
.password(apiPassword)
.disabled(!record.getEnabled())
.authorities(fromJson(record.getAuthorities()))
.build();
}

public void deleteUser(int webUserPk) {
webUserRepository.deleteUser(webUserPk);
}
Expand Down