unauthenticated swagger-ui on- server-port and proper security filter integration into Spring Security #163

Merged
hsh-michaelhoennig merged 13 commits from feature/unauthenticated-swagger-ui-on-server-port into master 2025-03-17 12:59:53 +01:00
10 changed files with 140 additions and 150 deletions
Showing only changes of commit 158e279aeb - Show all commits

View File

@ -1,52 +0,0 @@
package net.hostsharing.hsadminng.config;
import jakarta.servlet.FilterChain;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.SneakyThrows;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import org.springframework.web.filter.OncePerRequestFilter;
import static java.util.Arrays.stream;
import static net.hostsharing.hsadminng.config.WebSecurityConfig.AUTHENTICATED_PATHS;
import static net.hostsharing.hsadminng.config.WebSecurityConfig.PERMITTED_PATHS;
@Component
public class AuthenticationFilter extends OncePerRequestFilter {
private static final AntPathMatcher PATH_MATCHER = new AntPathMatcher();
@Autowired
private Authenticator authenticator;
@Override
@SneakyThrows
protected void doFilterInternal(
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) {
final var authenticatedRequest = new AuthenticatedHttpServletRequestWrapper(request);
// TODO.impl: Make request matchers work via Spring Security, maybe use Spring Security CAS support directly?
if (stream(PERMITTED_PATHS).anyMatch(path -> PATH_MATCHER.match(path, request.getRequestURI()))) {
authenticatedRequest.addHeader("current-subject", "nobody");
filterChain.doFilter(authenticatedRequest, response);
} else if (stream(AUTHENTICATED_PATHS).anyMatch(path -> PATH_MATCHER.match(path, request.getRequestURI()))) {
try {
final var currentSubject = authenticator.authenticate(request);
authenticatedRequest.addHeader("current-subject", currentSubject);
filterChain.doFilter(authenticatedRequest, response);
} catch (final BadCredentialsException exc) {
// TODO.impl: should not be necessary if ResponseStatusException worked - FIXME: try removing
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
}
} else {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
}
}
}

View File

@ -1,8 +0,0 @@
package net.hostsharing.hsadminng.config;
import jakarta.servlet.http.HttpServletRequest;
public interface Authenticator {
String authenticate(final HttpServletRequest httpRequest);
}

View File

@ -0,0 +1,34 @@
package net.hostsharing.hsadminng.config;
import lombok.AllArgsConstructor;
import lombok.SneakyThrows;
import org.springframework.web.filter.OncePerRequestFilter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
// Do NOT use @Component (or similar) here, this would register the filter directly.
// But we need to register it in the SecurityFilterChain created by WebSecurityConfig.
// The bean gets created in net.hostsharing.hsadminng.config.WebSecurityConfig.authenticationFilter.
@AllArgsConstructor
public class CasAuthenticationFilter extends OncePerRequestFilter {
private CasAuthenticator authenticator;
@Override
@SneakyThrows
protected void doFilterInternal(
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) {
final var authenticatedRequest = new AuthenticatedHttpServletRequestWrapper(request);
if (request.getHeader("Authorization") != null) {
final var currentSubject = authenticator.authenticate(request);
authenticatedRequest.addHeader("current-subject", currentSubject);
} else {
authenticatedRequest.addHeader("current-subject", "nobody");
}
filterChain.doFilter(authenticatedRequest, response);
}
}

View File

@ -1,81 +1,8 @@
package net.hostsharing.hsadminng.config;
import io.micrometer.core.annotation.Timed;
import lombok.SneakyThrows;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.client.RestTemplate;
import org.xml.sax.SAXException;
import jakarta.servlet.http.HttpServletRequest;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import java.io.IOException;
import java.util.function.Supplier;
public class CasAuthenticator implements Authenticator {
public interface CasAuthenticator {
@Value("${hsadminng.cas.server}")
private String casServerUrl;
@Value("${hsadminng.cas.service}")
private String serviceUrl;
private final RestTemplate restTemplate = new RestTemplate();
@SneakyThrows
@Timed("app.cas.authenticate")
public String authenticate(final HttpServletRequest httpRequest) {
final var userName = StringUtils.isBlank(casServerUrl)
? bypassCurrentSubject(httpRequest)
: casValidation(httpRequest);
final var authentication = new UsernamePasswordAuthenticationToken(userName, null, null);
SecurityContextHolder.getContext().setAuthentication(authentication);
return authentication.getName();
}
private static String bypassCurrentSubject(final HttpServletRequest httpRequest) {
final var userName = httpRequest.getHeader("current-subject");
System.err.println("CasAuthenticator.bypassCurrentSubject: " + userName);
return userName;
}
private String casValidation(final HttpServletRequest httpRequest)
throws SAXException, IOException, ParserConfigurationException {
final var ticket = httpRequest.getHeader("Authorization");
final var url = casServerUrl + "/p3/serviceValidate" +
"?service=" + serviceUrl +
"&ticket=" + ticket;
System.err.println("CasAuthenticator.casValidation using URL: " + url);
final var response = tryTo( () -> restTemplate.getForObject(url, String.class));
final var doc = DocumentBuilderFactory.newInstance().newDocumentBuilder()
.parse(new java.io.ByteArrayInputStream(response.getBytes()));
if (doc.getElementsByTagName("cas:authenticationSuccess").getLength() == 0) {
// TODO.impl: for unknown reasons, this results in a 403 FORBIDDEN
// throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "CAS service ticket could not be validated");
System.err.println("CAS service ticket could not be validated");
System.err.println("CAS-validation-URL: " + url);
System.err.println(response);
throw new BadCredentialsException("CAS service ticket could not be validated");
}
final var userName = doc.getElementsByTagName("cas:user").item(0).getTextContent();
System.err.println("CAS-user: " + userName);
return userName;
}
private <T> T tryTo(final Supplier<T> code) {
try {
final T resultValue = code.get();
return resultValue;
} catch (final Exception e) {
throw e;
}
}
String authenticate(final HttpServletRequest httpRequest);
}

View File

@ -0,0 +1,71 @@
package net.hostsharing.hsadminng.config;
import io.micrometer.core.annotation.Timed;
import lombok.SneakyThrows;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.client.RestTemplate;
import org.xml.sax.SAXException;
import jakarta.servlet.http.HttpServletRequest;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import java.io.IOException;
import java.util.function.Supplier;
public class RealCasAuthenticator implements CasAuthenticator {
@Value("${hsadminng.cas.server}")
private String casServerUrl;
@Value("${hsadminng.cas.service}")
private String serviceUrl;
private final RestTemplate restTemplate = new RestTemplate();
@SneakyThrows
@Timed("app.cas.authenticate")
public String authenticate(final HttpServletRequest httpRequest) {
final var userName = StringUtils.isBlank(casServerUrl)
? bypassCurrentSubject(httpRequest)
: casValidation(httpRequest);
final var authentication = new UsernamePasswordAuthenticationToken(userName, null, null);
SecurityContextHolder.getContext().setAuthentication(authentication);
return authentication.getName();
}
private static String bypassCurrentSubject(final HttpServletRequest httpRequest) {
final var userName = httpRequest.getHeader("current-subject");
System.err.println("CasAuthenticator.bypassCurrentSubject: " + userName);
return userName;
}
private String casValidation(final HttpServletRequest httpRequest)
throws SAXException, IOException, ParserConfigurationException {
final var ticket = httpRequest.getHeader("Authorization");
final var url = casServerUrl + "/p3/serviceValidate" +
"?service=" + serviceUrl +
"&ticket=" + ticket;
System.err.println("CasAuthenticator.casValidation using URL: " + url);
final var response = ((Supplier<String>) () -> restTemplate.getForObject(url, String.class)).get();
final var doc = DocumentBuilderFactory.newInstance().newDocumentBuilder()
.parse(new java.io.ByteArrayInputStream(response.getBytes()));
if (doc.getElementsByTagName("cas:authenticationSuccess").getLength() == 0) {
System.err.println("CAS service ticket could not be validated");
System.err.println("CAS-validation-URL: " + url);
System.err.println(response);
throw new BadCredentialsException("CAS service ticket could not be validated");
}
final var userName = doc.getElementsByTagName("cas:user").item(0).getTextContent();
System.err.println("CAS-user: " + userName);
return userName;
}
}

View File

@ -1,40 +1,58 @@
package net.hostsharing.hsadminng.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Lazy;
import org.springframework.context.annotation.Profile;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.AuthenticationFilter;
import jakarta.servlet.http.HttpServletResponse;
@Configuration
@EnableWebSecurity
public class WebSecurityConfig {
public static final String[] PERMITTED_PATHS = new String[]{"/swagger-ui/**", "/v3/api-docs/**", "/actuator/**"};
public static final String[] AUTHENTICATED_PATHS = new String[]{"/api/**"};
private static final String[] PERMITTED_PATHS = new String[] { "/swagger-ui/**", "/v3/api-docs/**", "/actuator/**" };
private static final String[] AUTHENTICATED_PATHS = new String[] { "/api/**" };
@Lazy
@Autowired
private CasAuthenticationFilter authenticationFilter;
@Bean
@Profile("!test")
public SecurityFilterChain securityFilterChain(final HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(authorize -> authorize
// TODO.impl: implement CAS authentication via Spring Security
// .requestMatchers(PERMITTED_PATHS).permitAll()
// .requestMatchers(AUTHENTICATED_PATHS).authenticated()
// .anyRequest().denyAll()
.anyRequest().permitAll()
.requestMatchers(PERMITTED_PATHS).permitAll()
.requestMatchers(AUTHENTICATED_PATHS).authenticated()
.anyRequest().denyAll()
)
.addFilterBefore(authenticationFilter, AuthenticationFilter.class)
.csrf(AbstractHttpConfigurer::disable)
.exceptionHandling(exception -> exception
.authenticationEntryPoint((request, response, authException) ->
// For unknown reasons Spring security returns 403 FORBIDDEN for a BadCredentialsException.
// But it should return 401 UNAUTHORIZED.
response.sendError(HttpServletResponse.SC_UNAUTHORIZED)
)
)
.build();
}
@Bean
@Profile("!test")
public Authenticator casServiceTicketValidator() {
return new CasAuthenticator();
public CasAuthenticator casServiceTicketValidator() {
return new RealCasAuthenticator();
}
@Bean
public CasAuthenticationFilter authenticationFilter(final CasAuthenticator authenticator) {
return new CasAuthenticationFilter(authenticator);
}
}

View File

@ -37,7 +37,7 @@ class CasAuthenticationFilterIntegrationTest {
private WireMockServer wireMockServer;
@Test
public void shouldAcceptRequest() {
public void shouldAcceptRequestWithValidCasTicket() {
// given
final var username = "test-user-" + randomAlphanumeric(4);
wireMockServer.stubFor(get(urlEqualTo("/cas/p3/serviceValidate?service=" + serviceUrl + "&ticket=valid"))
@ -66,7 +66,7 @@ class CasAuthenticationFilterIntegrationTest {
}
@Test
public void shouldRejectRequest() {
public void shouldRejectRequestWithInvalidCasTicket() {
// given
wireMockServer.stubFor(get(urlEqualTo("/cas/p3/serviceValidate?service=" + serviceUrl + "&ticket=invalid"))
.willReturn(aResponse()

View File

@ -10,7 +10,7 @@ import static org.mockito.Mockito.mock;
class CasAuthenticatorUnitTest {
final CasAuthenticator casAuthenticator = new CasAuthenticator();
final RealCasAuthenticator casAuthenticator = new RealCasAuthenticator();
@Test
void bypassesAuthenticationIfNoCasServerIsConfigured() {

View File

@ -21,7 +21,7 @@ public class DisableSecurityConfig {
@Bean
@Profile("test")
public Authenticator fakeAuthenticator() {
return new FakeAuthenticator();
public CasAuthenticator fakeAuthenticator() {
return new FakeCasAuthenticator();
}
}

View File

@ -4,7 +4,7 @@ import lombok.SneakyThrows;
import jakarta.servlet.http.HttpServletRequest;
public class FakeAuthenticator implements Authenticator {
public class FakeCasAuthenticator implements CasAuthenticator {
@Override
@SneakyThrows