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

SOLR-17309: Enhance certificate based authentication plugin with flexible cert principal resolution #3029

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ public class SolrResourceLoader
"handler.admin.",
"security.jwt.",
"security.hadoop.",
"security.cert.",
"handler.sql.",
"hdfs.",
"hdfs.update.",
Expand Down
86 changes: 80 additions & 6 deletions solr/core/src/java/org/apache/solr/security/CertAuthPlugin.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,86 @@
*/
package org.apache.solr.security;

import java.io.IOException;
import java.lang.invoke.MethodHandles;
import java.security.cert.X509Certificate;
import java.util.Map;
import javax.servlet.FilterChain;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.apache.http.HttpHeaders;
import org.apache.solr.common.util.StrUtils;
import org.apache.solr.core.CoreContainer;
import org.apache.solr.security.cert.CertPrincipalResolver;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/** An authentication plugin that sets principal based on the certificate subject */
public class CertAuthPlugin extends AuthenticationPlugin {

private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());

private static final String PARAM_PRINCIPAL_RESOLVER = "principalResolver";
private static final String PARAM_CLASS = "class";
private static final String PARAM_PARAMS = "params";

private static final CertPrincipalResolver DEFAULT_PRINCIPAL_RESOLVER =
certificate -> certificate.getSubjectX500Principal();
protected final CoreContainer coreContainer;
private CertPrincipalResolver principalResolver;

public CertAuthPlugin() {
this(null);
}

public CertAuthPlugin(CoreContainer coreContainer) {
this.coreContainer = coreContainer;
}

@Override
public void init(Map<String, Object> pluginConfig) {}
public void init(Map<String, Object> pluginConfig) {
principalResolver =
resolveComponent(
pluginConfig,
PARAM_PRINCIPAL_RESOLVER,
CertPrincipalResolver.class,
DEFAULT_PRINCIPAL_RESOLVER,
"principalResolver");
}

@SuppressWarnings("unchecked")
private <T> T resolveComponent(
Map<String, Object> pluginConfig,
String configKey,
Class<T> clazz,
T defaultInstance,
String componentName) {
Map<String, Object> configMap = (Map<String, Object>) pluginConfig.get(configKey);
if (this.coreContainer == null) {
log.warn("No coreContainer configured. Using the default {}", componentName);
return defaultInstance;
}
if (configMap == null) {
log.warn("No {} configured. Using the default one", componentName);
return defaultInstance;
}

String className = (String) configMap.get(PARAM_CLASS);
if (StrUtils.isNullOrEmpty(className)) {
log.warn("No {} class configured. Using the default one", componentName);
return defaultInstance;
}
Map<String, Object> params = (Map<String, Object>) configMap.get(PARAM_PARAMS);
if (params == null) {
log.warn("No params found for {}. Using the default class", componentName);
return defaultInstance;
}

log.info("Found a {} class: {}", componentName, className);
return this.coreContainer
.getResourceLoader()
.newInstance(className, clazz, null, new Class<?>[] {Map.class}, new Object[] {params});
}

@Override
public boolean doAuthenticate(
Expand All @@ -35,15 +104,20 @@ public boolean doAuthenticate(
X509Certificate[] certs =
(X509Certificate[]) request.getAttribute("javax.servlet.request.X509Certificate");
if (certs == null || certs.length == 0) {
numMissingCredentials.inc();
response.setHeader(HttpHeaders.WWW_AUTHENTICATE, "Certificate");
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "require certificate");
return false;
return sendError(response, "require certificate");
}

HttpServletRequest wrapped = wrapWithPrincipal(request, certs[0].getSubjectX500Principal());
HttpServletRequest wrapped =
wrapWithPrincipal(request, principalResolver.resolvePrincipal(certs[0]));
numAuthenticated.inc();
filterChain.doFilter(wrapped, response);
return true;
}

private boolean sendError(HttpServletResponse response, String msg) throws IOException {
numMissingCredentials.inc();
response.setHeader(HttpHeaders.WWW_AUTHENTICATE, "Certificate");
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, msg);
return false;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.solr.security.cert;

import java.security.Principal;
import java.security.cert.CertificateParsingException;
import java.security.cert.X509Certificate;
import javax.net.ssl.SSLPeerUnverifiedException;

/**
* Defines the interface for resolving a {@link Principal} from an X509 certificate. Implementations
* of this interface are responsible for extracting a specific piece of information from the
* certificate and converting it into a {@link Principal}.
*/
public interface CertPrincipalResolver {
/**
* Resolves a {@link Principal} from the given X509 certificate.
*
* <p>This method is intended to extract principal information, such as a common name (CN) or an
* email address, from the specified certificate and encapsulate it into a {@link Principal}
* object. The specific field or attribute of the certificate to be used as the principal, and the
* logic for its extraction, is defined by the implementation.
*
* <p>@param certificate The X509Certificate from which to resolve the principal.
*
* @return A {@link Principal} object representing the resolved principal from the certificate.
* @throws SSLPeerUnverifiedException If the peer's identity has not been verified.
* @throws CertificateParsingException If an error occurs while parsing the certificate for
* principal information.
*/
Principal resolvePrincipal(X509Certificate certificate)
throws SSLPeerUnverifiedException, CertificateParsingException;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.solr.security.cert;

import java.lang.invoke.MethodHandles;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
* Represents a pattern for resolving certificate information, specifying the criteria for
* extracting and matching values from certificates.
*/
public class CertResolverPattern {

private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());

private final String name;
private final String path;
private final CheckType checkType;
private final Set<String> filterValues;

/**
* Constructs a CertResolverPattern with specified parameters.
*
* @param name The name associated with this pattern.
* @param path The certificate field path this pattern applies to.
* @param checkType The type of check to perform on extracted values.
* @param filterValues The set of values to check against the extracted certificate field.
*/
public CertResolverPattern(String name, String path, String checkType, Set<String> filterValues) {
this.name = name;
this.path = path;
this.checkType = CheckType.fromString(checkType);
this.filterValues = filterValues;
}

public String getName() {
return name;
}

public String getPath() {
return path;
}

public CheckType getCheckType() {
return checkType;
}

public Set<String> getFilterValues() {
return filterValues;
}

public static boolean matchesPattern(String value, CertResolverPattern pattern) {
return matchesPattern(value, pattern.getCheckType(), pattern.getFilterValues());
}

/**
* Determines if a given value matches the specified filter values, depending on the check type.
*
* @param value The value to check.
* @param checkType The type of check to perform.
* @param values The set of values to check against.
* @return True if the value matches the criteria; false otherwise.
*/
public static boolean matchesPattern(String value, CheckType checkType, Set<String> values) {
log.debug("matchesPattern value:{} checkType:{} values:{}", value, checkType, values);
String lowerValue =
value.toLowerCase(Locale.ROOT); // lowercase for case-insensitive comparisons
switch (checkType) {
case EQUALS:
return values.contains(lowerValue);
case STARTS_WITH:
return values.stream().anyMatch(lowerValue::startsWith);
case ENDS_WITH:
return values.stream().anyMatch(lowerValue::endsWith);
case CONTAINS:
return values.stream().anyMatch(lowerValue::contains);
case WILDCARD:
return true;
default:
return false;
}
}

/** Enum defining the types of checks that can be performed on extracted certificate values. */
public enum CheckType {
STARTS_WITH,
ENDS_WITH,
CONTAINS,
EQUALS,
WILDCARD;
// TODO: add regex support

private static final Map<String, CheckType> lookup =
Map.of(
"equals", EQUALS,
"startswith", STARTS_WITH,
"endswith", ENDS_WITH,
"contains", CONTAINS,
"*", WILDCARD,
"wildcard", WILDCARD);

public static CheckType fromString(String checkType) {
if (checkType == null) {
throw new IllegalArgumentException("CheckType cannot be null");
}
CheckType result = lookup.get(checkType.toLowerCase(Locale.ROOT));
if (result == null) {
throw new IllegalArgumentException("No CheckType with text '" + checkType + "' found");
}
return result;
}
}
}
Loading
Loading