add CAS authentication (#138)
Co-authored-by: Michael Hoennig <michael@hoennig.de> Reviewed-on: https://dev.hostsharing.net/hostsharing/hs.hsadmin.ng/pulls/138 Reviewed-by: Timotheus Pokorra <timotheus.pokorra@hostsharing.net>
This commit is contained in:
@ -0,0 +1,54 @@
|
||||
package net.hostsharing.hsadminng.config;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletRequestWrapper;
|
||||
import java.util.*;
|
||||
|
||||
public class AuthenticatedHttpServletRequestWrapper extends HttpServletRequestWrapper {
|
||||
|
||||
private final Map<String, String> customHeaders = new HashMap<>();
|
||||
|
||||
public AuthenticatedHttpServletRequestWrapper(HttpServletRequest request) {
|
||||
super(request);
|
||||
}
|
||||
|
||||
public void addHeader(final String name, final String value) {
|
||||
customHeaders.put(name, value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getHeader(final String name) {
|
||||
// Check custom headers first
|
||||
final var customHeaderValue = customHeaders.get(name);
|
||||
if (customHeaderValue != null) {
|
||||
return customHeaderValue;
|
||||
}
|
||||
// Fall back to the original headers
|
||||
return super.getHeader(name);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Enumeration<String> getHeaderNames() {
|
||||
// Combine original headers and custom headers
|
||||
final var headerNames = new HashSet<>(customHeaders.keySet());
|
||||
final var originalHeaderNames = super.getHeaderNames();
|
||||
while (originalHeaderNames.hasMoreElements()) {
|
||||
headerNames.add(originalHeaderNames.nextElement());
|
||||
}
|
||||
return Collections.enumeration(headerNames);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Enumeration<String> getHeaders(final String name) {
|
||||
// Combine original headers and custom header
|
||||
final var values = new HashSet<String>();
|
||||
if (customHeaders.containsKey(name)) {
|
||||
values.add(customHeaders.get(name));
|
||||
}
|
||||
final var originalValues = super.getHeaders(name);
|
||||
while (originalValues.hasMoreElements()) {
|
||||
values.add(originalValues.nextElement());
|
||||
}
|
||||
return Collections.enumeration(values);
|
||||
}
|
||||
}
|
@ -0,0 +1,39 @@
|
||||
package net.hostsharing.hsadminng.config;
|
||||
|
||||
import jakarta.servlet.Filter;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletRequest;
|
||||
import jakarta.servlet.ServletResponse;
|
||||
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;
|
||||
|
||||
@Component
|
||||
public class AuthenticationFilter implements Filter {
|
||||
|
||||
@Autowired
|
||||
private Authenticator authenticator;
|
||||
|
||||
@Override
|
||||
@SneakyThrows
|
||||
public void doFilter(final ServletRequest request, final ServletResponse response, final FilterChain chain) {
|
||||
final var httpRequest = (HttpServletRequest) request;
|
||||
final var httpResponse = (HttpServletResponse) response;
|
||||
|
||||
try {
|
||||
final var currentSubject = authenticator.authenticate(httpRequest);
|
||||
|
||||
final var authenticatedRequest = new AuthenticatedHttpServletRequestWrapper(httpRequest);
|
||||
authenticatedRequest.addHeader("current-subject", currentSubject);
|
||||
|
||||
chain.doFilter(authenticatedRequest, response);
|
||||
} catch (final BadCredentialsException exc) {
|
||||
// TODO.impl: should not be necessary if ResponseStatusException worked
|
||||
httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
package net.hostsharing.hsadminng.config;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
public interface Authenticator {
|
||||
|
||||
String authenticate(final HttpServletRequest httpRequest);
|
||||
}
|
@ -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;
|
||||
|
||||
public class CasAuthenticator implements Authenticator {
|
||||
|
||||
@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 {
|
||||
|
||||
System.err.println("CasAuthenticator.casValidation using CAS-server: " + casServerUrl);
|
||||
|
||||
final var ticket = httpRequest.getHeader("Authorization");
|
||||
final var url = casServerUrl + "/p3/serviceValidate" +
|
||||
"?service=" + serviceUrl +
|
||||
"&ticket=" + ticket;
|
||||
|
||||
final var response = 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;
|
||||
}
|
||||
}
|
@ -5,6 +5,7 @@ import org.springframework.context.annotation.Configuration;
|
||||
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;
|
||||
|
||||
@Configuration
|
||||
@ -22,6 +23,14 @@ public class WebSecurityConfig {
|
||||
.requestMatchers("/actuator/**").permitAll()
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
.csrf(AbstractHttpConfigurer::disable)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@Profile("!test")
|
||||
public Authenticator casServiceTicketValidator() {
|
||||
return new CasAuthenticator();
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -1,16 +1,22 @@
|
||||
package net.hostsharing.hsadminng.ping;
|
||||
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMethod;
|
||||
import org.springframework.web.bind.annotation.ResponseBody;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
@Controller
|
||||
public class PingController {
|
||||
|
||||
@ResponseBody
|
||||
@RequestMapping(value = "/api/ping", method = RequestMethod.GET)
|
||||
public String ping() {
|
||||
return "pong\n";
|
||||
public String ping(
|
||||
@RequestHeader(name = "current-subject") @NotNull String currentSubject,
|
||||
@RequestHeader(name = "assumed-roles", required = false) String assumedRoles
|
||||
) {
|
||||
return "pong " + currentSubject + "\n";
|
||||
}
|
||||
}
|
||||
|
@ -36,6 +36,9 @@ liquibase:
|
||||
hsadminng:
|
||||
postgres:
|
||||
leakproof:
|
||||
cas:
|
||||
server: https://login.hostsharing.net/cas # use empty string to bypass CAS-validation and directly use current-subject
|
||||
service: https://hsadminng.hostsharing.net:443 # TODO.conf: deployment target + matching CAS service ID
|
||||
|
||||
metrics:
|
||||
distribution:
|
||||
|
Reference in New Issue
Block a user