Skip to content

Commit

Permalink
switch to basic auth for API access (#1545)
Browse files Browse the repository at this point in the history
* switch to basic auth for API access

* PR feedback

* add cache for API users

* PR feedback

* start setting/updating api_password

* refactor: undo moveApiTokenFromConfigToDatabase prep
  • Loading branch information
goekay authored Aug 21, 2024
1 parent a970b80 commit 4b63474
Show file tree
Hide file tree
Showing 5 changed files with 168 additions and 95 deletions.
115 changes: 115 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,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 <https://www.gnu.org/licenses/>.
*/
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 <sevketgokay@gmail.com>
* @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;
}

}
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));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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()))
Expand Down
47 changes: 47 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 @@ -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;
Expand All @@ -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;
Expand All @@ -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<String, UserDetails> userCache = CacheBuilder.newBuilder()
.expireAfterWrite(10, TimeUnit.MINUTES) // TTL
.maximumSize(100)
.build();

@EventListener
public void afterStart(ContextRefreshedEvent event) {
if (this.hasUserWithAuthority("ADMIN")) {
Expand Down Expand Up @@ -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);
}
Expand All @@ -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())
Expand Down
1 change: 1 addition & 0 deletions src/main/resources/db/migration/V1_0_7__update.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE web_user CHANGE COLUMN api_token api_password varchar(500) NULL;

0 comments on commit 4b63474

Please sign in to comment.