helidon-io / helidon

Java libraries for writing microservices
https://helidon.io
Apache License 2.0
3.52k stars 564 forks source link

Client Certificate ABAC #8012

Open boris-senapt opened 1 year ago

boris-senapt commented 1 year ago

We can configure Helidon 4 to require client certificate authentication with the following

server:
  port: 8443
  host: 0.0.0.0
  tls:
    client-auth: REQUIRED
    endpoint-identification-algorithm: NONE # This is a bit counter-intuitive
    private-key:
      keystore:
        ...
    trust:
      keystore:
        ...
        trust-store: true

Ignoring the need to set endpoint-identification-algorithm: NONE which is not very intuitive, this works well.

However, coming from a Servlet background I would then expect to be able to do the following

There is no way to achieve this with Helidon.

Environment Details


Problem Description

I would like to be able to implement security authorisations using ABAC against clients authenticated using client certificate authentication.

Given Helidon has ABAC the Certificate properties could be exposed to ABDC for dynamic checking against attributes of the Certificate.

In order to get this working in my test lab I have had to do the following

1) Register a io.helidon.webserver.http.Filter to add the certificate to the request

    @Override
    public void filter(
            final FilterChain chain,
            final RoutingRequest req,
            final RoutingResponse res) {
        final var context = req.context();
        context.get(SecurityContext.class).ifPresent(sec -> {
            final var envBuilder = sec.env().derive();
            req.remotePeer().tlsCertificates().ifPresent(certs -> envBuilder.addAttribute(X509_CERTIFICATE, certs));
            sec.env(envBuilder);
        });
        chain.proceed();
    }

This would seem to be a core feature missing from io.helidon.webserver.security.SecurityContextFilter

2) Implement an io.helidon.security.spi.AuthenticationProvider

This takes the code from io.helidon.security.providers.httpauth.HttpBasicAuthProvider to configure SecureUserStore instances but then looks up the user by the certificate DN

    @Override
    public AuthenticationResponse authenticate(final ProviderRequest providerRequest) {
        final var foundUser = X509SecurtyFilter.certificates(providerRequest.env())
                .stream()
                .flatMap(Arrays::stream)
                .flatMap(cert -> findUser(cert).stream().map(user -> new SimpleImmutableEntry<>(user, cert)))
                .findFirst();

        return foundUser.map(e -> {
            if (subjectType == SubjectType.USER) {
                return AuthenticationResponse.success(buildSubject(e.getKey(), e.getValue()));
            }
            return AuthenticationResponse.successService(buildSubject(e.getKey(), e.getValue()));
        }).orElseGet(this::invalidUser);
    }

    private Optional<SecureUserStore.User> findUser(final Certificate certificate) {
        if(!(certificate instanceof X509Certificate)) {
            return Optional.empty();
        }
        final var dn = ((X509Certificate) certificate).getSubjectX500Principal();
        return userStores.stream()
                .flatMap(userStore -> userStore.user(dn.getName()).stream())
                .findFirst();
    }

    private Subject buildSubject(final SecureUserStore.User user, final Certificate certificate) {
        Subject.Builder builder = Subject.builder()
                .principal(Principal.builder()
                        .name(user.login())
                        .build())
                .addPublicCredential(
                        X509Credentials.class,
                        new X509Credentials(user.login(), certificate));
        user.roles()
                .forEach(role -> builder.addGrant(Role.create(role)));
        return builder.build();
    }

    private static final class X509Credentials {
        private final String username;
        private final Certificate certificate;

        private X509Credentials(String username, Certificate certificate) {
            this.username = username;
            this.certificate = certificate;
        }
    }
tomas-langer commented 1 year ago

Hello the endpoint-identification-algorithm: NONE should not be required at all if you have correctly defined certificates. It is what is called "endpoint verification" in other places, and it is related to the common name of the server certificate - if that is set correctly to the host name used, you do not need to specify this option.

Regarding the feature request, we will investigate it. Right now you can only use the common name from certificate to assert user identity through our header based security provider (we use X_HELIDON_CN as the generated header for common name from client certificate)

boris-senapt commented 1 year ago

@tomas-langer I understand that - but what endpoint-identification-algorithm does when enabled for client certificate authentication on the server side is that it tries to identify the client by its SAN. As a client certificate will identify a user and not a machine, and moreover the user's machine could have any IP address and likely not a domain name, this verification will always fail.

As an example, my WAN ip address might now be 1.2.3.4 - I have no reverse DNS for that IP as it's given to me by my ISP by DHCP. I make a request with my certificate for DN=CN=boris.morris provided for me by some public CA which issues client certificates. The server, if endpoint verification is enabled, will try and verify that there is an IP SAN for 1.2.3.4 in my certificate - which there obviously can never be.

Endpoint identification is used when the client talks to a server - which will have some fixed address(es) that can be named in a SAN.

For the server side of a mutual TLS connection, endpoint verification is normally disabled for this reason.