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

switch to basic auth for API access #1545

Merged
merged 6 commits into from
Aug 21, 2024
Merged
Show file tree
Hide file tree
Changes from all 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
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);
goekay marked this conversation as resolved.
Show resolved Hide resolved
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 @@

@Bean
@Order(1)
public SecurityFilterChain apiKeyFilterChain(HttpSecurity http, ObjectMapper jacksonObjectMapper) throws Exception {
public SecurityFilterChain apiKeyFilterChain(HttpSecurity http, ApiAuthenticationManager apiAuthenticationManager) throws Exception {

Check failure on line 92 in src/main/java/de/rwth/idsg/steve/config/SecurityConfiguration.java

View workflow job for this annotation

GitHub Actions / checkstyle

[checkstyle] src/main/java/de/rwth/idsg/steve/config/SecurityConfiguration.java#L92 <com.puppycrawl.tools.checkstyle.checks.sizes.LineLengthCheck>

Line is longer than 120 characters (found 137).
Raw output
/github/workspace/./src/main/java/de/rwth/idsg/steve/config/SecurityConfiguration.java:92:0: error: Line is longer than 120 characters (found 137). (com.puppycrawl.tools.checkstyle.checks.sizes.LineLengthCheck)
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 @@
.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;

Check failure on line 161 in src/main/java/de/rwth/idsg/steve/service/WebUserService.java

View workflow job for this annotation

GitHub Actions / checkstyle

[checkstyle] src/main/java/de/rwth/idsg/steve/service/WebUserService.java#L161 <com.puppycrawl.tools.checkstyle.checks.coding.AvoidInlineConditionalsCheck>

Avoid inline conditionals.
Raw output
/github/workspace/./src/main/java/de/rwth/idsg/steve/service/WebUserService.java:161:39: error: Avoid inline conditionals. (com.puppycrawl.tools.checkstyle.checks.coding.AvoidInlineConditionalsCheck)
});
// map dummy back to null
return (userExt == DUMMY_USER) ? null : userExt;

Check failure on line 164 in src/main/java/de/rwth/idsg/steve/service/WebUserService.java

View workflow job for this annotation

GitHub Actions / checkstyle

[checkstyle] src/main/java/de/rwth/idsg/steve/service/WebUserService.java#L164 <com.puppycrawl.tools.checkstyle.checks.coding.AvoidInlineConditionalsCheck>

Avoid inline conditionals.
Raw output
/github/workspace/./src/main/java/de/rwth/idsg/steve/service/WebUserService.java:164:44: error: Avoid inline conditionals. (com.puppycrawl.tools.checkstyle.checks.coding.AvoidInlineConditionalsCheck)
} catch (ExecutionException e) {
throw new RuntimeException(e);
}
}

public void deleteUser(int webUserPk) {
webUserRepository.deleteUser(webUserPk);
}
Expand All @@ -153,6 +180,26 @@
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;