KEYCLOAK-10158 Use PEM cert as X.509 user identity

Allows to use the full PEM encoded X.509 certificate from client cert
authentication as a user identity. Also allows to validate that user's
identity against LDAP in PEM (String and binary format). In addition,
a new custom attribute mapper allows to validate against LDAP when
certificate is stored in DER format (binay, Octet-String).

KEYCLOAK-10158 Allow lookup of certs in binary adn DER format from LDAP
This commit is contained in:
Sven-Torben Janus 2019-04-29 16:50:46 +02:00 committed by Marek Posolda
parent ca4e14fbfa
commit c883c11e7e
22 changed files with 320 additions and 23 deletions

View file

@ -142,9 +142,13 @@ public final class PemUtils {
}
}
private static byte[] pemToDer(String pem) throws IOException {
public static byte[] pemToDer(String pem) {
try {
pem = removeBeginEnd(pem);
return Base64.decode(pem);
} catch (IOException ioe) {
throw new PemException(ioe);
}
}
public static String removeBeginEnd(String pem) {
@ -155,11 +159,11 @@ public final class PemUtils {
return pem.trim();
}
public static String generateThumbprint(String[] certChain, String encoding) throws NoSuchAlgorithmException, IOException {
public static String generateThumbprint(String[] certChain, String encoding) throws NoSuchAlgorithmException {
return Base64Url.encode(generateThumbprintBytes(certChain, encoding));
}
static byte[] generateThumbprintBytes(String[] certChain, String encoding) throws NoSuchAlgorithmException, IOException {
static byte[] generateThumbprintBytes(String[] certChain, String encoding) throws NoSuchAlgorithmException {
return MessageDigest.getInstance(encoding).digest(pemToDer(certChain[0]));
}

View file

@ -73,7 +73,7 @@ public class RSAPublicJWK extends JWK {
try {
sha1x509Thumbprint = PemUtils.generateThumbprint(x509CertificateChain, "SHA-1");
sha256x509Thumbprint = PemUtils.generateThumbprint(x509CertificateChain, "SHA-256");
} catch (NoSuchAlgorithmException | IOException e) {
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException(e);
}
}

View file

@ -39,4 +39,8 @@ public interface Condition {
void applyCondition(StringBuilder filter);
void setBinary(boolean binary);
boolean isBinary();
}

View file

@ -84,12 +84,33 @@ public enum EscapeStrategy {
}
}
},
// Escaping value as Octet-String
OCTET_STRING {
@Override
public String escape(String input) {
byte[] bytes;
try {
bytes = input.getBytes("UTF-8");
} catch (UnsupportedEncodingException e) {
throw new RuntimeException(e);
}
return escapeHex(bytes);
}
};
public static String escapeHex(byte[] bytes) {
StringBuilder sb = new StringBuilder();
for (byte b : bytes) {
sb.append(String.format("\\%02x", b));
}
return sb.toString();
}
public abstract String escape(String input);
protected void appendByte(byte b, StringBuilder output) {
if (b >= 0) {
output.append((char) b);

View file

@ -48,4 +48,13 @@ class CustomLDAPFilter implements Condition {
public void applyCondition(StringBuilder filter) {
filter.append(customFilter);
}
@Override
public void setBinary(boolean binary) {
}
@Override
public boolean isBinary() {
return false;
}
}

View file

@ -28,8 +28,8 @@ import java.util.Date;
*/
public class EqualCondition extends NamedParameterCondition {
private final Object value;
private final EscapeStrategy escapeStrategy;
private Object value;
public EqualCondition(String name, Object value, EscapeStrategy escapeStrategy) {
super(name);
@ -41,6 +41,10 @@ public class EqualCondition extends NamedParameterCondition {
return this.value;
}
public void setValue(Object value) {
this.value = value;
}
public EscapeStrategy getEscapeStrategy() {
return escapeStrategy;
}
@ -52,7 +56,7 @@ public class EqualCondition extends NamedParameterCondition {
parameterValue = LDAPUtil.formatDate((Date) parameterValue);
}
String escaped = escapeStrategy.escape(parameterValue.toString());
String escaped = new OctetStringEncoder(escapeStrategy).encode(parameterValue, isBinary());
filter.append("(").append(getParameterName()).append(LDAPConstants.EQUAL).append(escaped).append(")");
}

View file

@ -50,4 +50,13 @@ class GreaterThanCondition extends NamedParameterCondition {
filter.append("(").append(getParameterName()).append(">").append(parameterValue).append(")");
}
}
@Override
public void setBinary(boolean binary) {
}
@Override
public boolean isBinary() {
return false;
}
}

View file

@ -37,7 +37,7 @@ class InCondition extends NamedParameterCondition {
filter.append("(&(");
for (int i = 0; i< valuesToCompare.length; i++) {
Object value = valuesToCompare[i];
Object value = new OctetStringEncoder().encode(valuesToCompare[i], isBinary());
filter.append("(").append(getParameterName()).append(LDAPConstants.EQUAL).append(value).append(")");
}

View file

@ -25,6 +25,7 @@ import org.keycloak.storage.ldap.idm.query.Condition;
public abstract class NamedParameterCondition implements Condition {
private String parameterName;
private boolean binary;
public NamedParameterCondition(String parameterName) {
this.parameterName = parameterName;
@ -47,4 +48,14 @@ public abstract class NamedParameterCondition implements Condition {
this.parameterName = ldapParamName;
}
}
@Override
public void setBinary(boolean binary) {
this.binary = binary;
}
@Override
public boolean isBinary() {
return binary;
}
}

View file

@ -0,0 +1,41 @@
package org.keycloak.storage.ldap.idm.query.internal;
import org.keycloak.storage.ldap.idm.query.EscapeStrategy;
class OctetStringEncoder {
private final EscapeStrategy fallback;
OctetStringEncoder() {
this(null);
}
OctetStringEncoder(EscapeStrategy fallback) {
this.fallback = fallback;
}
public String encode(Object parameterValue, boolean isBinary) {
String escaped;
if (parameterValue instanceof byte[]) {
escaped = EscapeStrategy.escapeHex((byte[]) parameterValue);
} else {
escaped = escapeAsString(parameterValue, isBinary);
}
return escaped;
}
private String escapeAsString(Object parameterValue, boolean isBinary) {
String escaped;
String stringValue = parameterValue.toString();
if (isBinary) {
escaped = EscapeStrategy.OCTET_STRING.escape(stringValue);
} else if (fallback == null){
escaped = stringValue;
} else {
escaped = fallback.escape(stringValue);
}
return escaped;
}
}

View file

@ -56,4 +56,13 @@ class OrCondition implements Condition {
filter.append(")");
}
@Override
public void setBinary(boolean binary) {
}
@Override
public boolean isBinary() {
return false;
}
}

View file

@ -0,0 +1,39 @@
package org.keycloak.storage.ldap.mappers;
import org.keycloak.common.util.PemUtils;
import org.keycloak.component.ComponentModel;
import org.keycloak.storage.ldap.LDAPStorageProvider;
import org.keycloak.storage.ldap.idm.query.Condition;
import org.keycloak.storage.ldap.idm.query.internal.EqualCondition;
import org.keycloak.storage.ldap.idm.query.internal.LDAPQuery;
public class CertificateLDAPStorageMapper extends UserAttributeLDAPStorageMapper {
public static final String IS_DER_FORMATTED = "is.der.formatted";
public CertificateLDAPStorageMapper(ComponentModel mapperModel, LDAPStorageProvider ldapProvider) {
super(mapperModel, ldapProvider);
}
@Override
public void beforeLDAPQuery(LDAPQuery query) {
super.beforeLDAPQuery(query);
String ldapAttrName = getLdapAttributeName();
if (isDerFormatted()) {
for (Condition condition : query.getConditions()) {
if (condition instanceof EqualCondition &&
condition.getParameterName().equalsIgnoreCase(ldapAttrName)) {
EqualCondition equalCondition = ((EqualCondition) condition);
equalCondition.setValue(PemUtils.pemToDer(equalCondition.getValue().toString()));
}
}
}
}
private boolean isDerFormatted() {
return mapperModel.get(IS_DER_FORMATTED, false);
}
}

View file

@ -0,0 +1,75 @@
package org.keycloak.storage.ldap.mappers;
import java.util.ArrayList;
import java.util.List;
import org.keycloak.component.ComponentModel;
import org.keycloak.component.ComponentValidationException;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.provider.ProviderConfigurationBuilder;
import org.keycloak.storage.ldap.LDAPStorageProvider;
public class CertificateLDAPStorageMapperFactory extends UserAttributeLDAPStorageMapperFactory {
public static final String PROVIDER_ID = "certificate-ldap-mapper";
private static final List<ProviderConfigProperty> certificateConfigProperties;
static {
certificateConfigProperties = getCertificateConfigProperties(null);
}
private static List<ProviderConfigProperty> getCertificateConfigProperties(ComponentModel p) {
List<ProviderConfigProperty> configProps = new ArrayList<>(getConfigProps(null));
ProviderConfigurationBuilder config = ProviderConfigurationBuilder.create()
.property()
.name(CertificateLDAPStorageMapper.IS_DER_FORMATTED)
.label("DER Formatted")
.helpText("Activate this if the certificate is DER formatted in LDAP and not PEM formatted.")
.type(ProviderConfigProperty.BOOLEAN_TYPE)
.add();
configProps.addAll(config.build());
return configProps;
}
@Override
public String getHelpText() {
return "Used to map single attribute which contains a certificate from LDAP user to attribute of UserModel in Keycloak DB";
}
@Override
public List<ProviderConfigProperty> getConfigProperties() {
return certificateConfigProperties;
}
@Override
public String getId() {
return PROVIDER_ID;
}
@Override
public void validateConfiguration(KeycloakSession session, RealmModel realm, ComponentModel config) throws ComponentValidationException {
super.validateConfiguration(session, realm, config);
boolean isBinaryAttribute = config.get(UserAttributeLDAPStorageMapper.IS_BINARY_ATTRIBUTE, false);
boolean isDerFormatted = config.get(CertificateLDAPStorageMapper.IS_DER_FORMATTED, false);
if (isDerFormatted && !isBinaryAttribute) {
throw new ComponentValidationException("With DER formatted certificate enabled, the ''Is Binary Attribute'' option must be enabled too");
}
}
@Override
protected AbstractLDAPStorageMapper createMapper(ComponentModel mapperModel, LDAPStorageProvider federationProvider) {
return new CertificateLDAPStorageMapper(mapperModel, federationProvider);
}
@Override
public List<ProviderConfigProperty> getConfigProperties(RealmModel realm, ComponentModel parent) {
return getCertificateConfigProperties(parent);
}
}

View file

@ -89,12 +89,11 @@ public class UserAttributeLDAPStorageMapper extends AbstractLDAPStorageMapper {
@Override
public void onImportUserFromLDAP(LDAPObject ldapUser, UserModel user, RealmModel realm, boolean isCreate) {
String userModelAttrName = mapperModel.getConfig().getFirst(USER_MODEL_ATTRIBUTE);
String ldapAttrName = mapperModel.getConfig().getFirst(LDAP_ATTRIBUTE);
String userModelAttrName = getUserModelAttribute();
String ldapAttrName = getLdapAttributeName();
// We won't update binary attributes to Keycloak DB. They might be too big
boolean isBinaryAttribute = mapperModel.get(IS_BINARY_ATTRIBUTE, false);
if (isBinaryAttribute) {
if (isBinaryAttribute()) {
return;
}
@ -122,8 +121,8 @@ public class UserAttributeLDAPStorageMapper extends AbstractLDAPStorageMapper {
@Override
public void onRegisterUserToLDAP(LDAPObject ldapUser, UserModel localUser, RealmModel realm) {
String userModelAttrName = mapperModel.getConfig().getFirst(USER_MODEL_ATTRIBUTE);
String ldapAttrName = mapperModel.getConfig().getFirst(LDAP_ATTRIBUTE);
String userModelAttrName = getUserModelAttribute();
String ldapAttrName = getLdapAttributeName();
boolean isMandatoryInLdap = parseBooleanParameter(mapperModel, IS_MANDATORY_IN_LDAP);
Property<Object> userModelProperty = userModelProperties.get(userModelAttrName.toLowerCase());
@ -201,8 +200,8 @@ public class UserAttributeLDAPStorageMapper extends AbstractLDAPStorageMapper {
@Override
public UserModel proxy(final LDAPObject ldapUser, UserModel delegate, RealmModel realm) {
final String userModelAttrName = mapperModel.getConfig().getFirst(USER_MODEL_ATTRIBUTE);
final String ldapAttrName = mapperModel.getConfig().getFirst(LDAP_ATTRIBUTE);
final String userModelAttrName = getUserModelAttribute();
final String ldapAttrName = getLdapAttributeName();
boolean isAlwaysReadValueFromLDAP = parseBooleanParameter(mapperModel, ALWAYS_READ_VALUE_FROM_LDAP);
final boolean isMandatoryInLdap = parseBooleanParameter(mapperModel, IS_MANDATORY_IN_LDAP);
final boolean isBinaryAttribute = parseBooleanParameter(mapperModel, IS_BINARY_ATTRIBUTE);
@ -416,8 +415,8 @@ public class UserAttributeLDAPStorageMapper extends AbstractLDAPStorageMapper {
@Override
public void beforeLDAPQuery(LDAPQuery query) {
String userModelAttrName = mapperModel.getConfig().getFirst(USER_MODEL_ATTRIBUTE);
String ldapAttrName = mapperModel.getConfig().getFirst(LDAP_ATTRIBUTE);
String userModelAttrName = getUserModelAttribute();
String ldapAttrName = getLdapAttributeName();
// Add mapped attribute to returning ldap attributes
query.addReturningLdapAttribute(ldapAttrName);
@ -428,8 +427,24 @@ public class UserAttributeLDAPStorageMapper extends AbstractLDAPStorageMapper {
// Change conditions and use ldapAttribute instead of userModel
for (Condition condition : query.getConditions()) {
condition.updateParameterName(userModelAttrName, ldapAttrName);
String parameterName = condition.getParameterName();
if (parameterName != null && (parameterName.equalsIgnoreCase(userModelAttrName) || parameterName.equalsIgnoreCase(ldapAttrName))) {
condition.setBinary(isBinaryAttribute());
}
}
}
private String getUserModelAttribute() {
return mapperModel.getConfig().getFirst(USER_MODEL_ATTRIBUTE);
}
String getLdapAttributeName() {
return mapperModel.getConfig().getFirst(LDAP_ATTRIBUTE);
}
private boolean isBinaryAttribute() {
return mapperModel.get(IS_BINARY_ATTRIBUTE, false);
}
private boolean isReadOnly() {
return parseBooleanParameter(mapperModel, READ_ONLY);

View file

@ -43,7 +43,7 @@ public class UserAttributeLDAPStorageMapperFactory extends AbstractLDAPStorageMa
configProperties = props;
}
private static List<ProviderConfigProperty> getConfigProps(ComponentModel p) {
static List<ProviderConfigProperty> getConfigProps(ComponentModel p) {
String readOnly = "false";
UserStorageProviderModel parent = new UserStorageProviderModel();
if (p != null) {

View file

@ -24,3 +24,4 @@ org.keycloak.storage.ldap.mappers.membership.role.RoleLDAPStorageMapperFactory
org.keycloak.storage.ldap.mappers.msad.MSADUserAccountControlStorageMapperFactory
org.keycloak.storage.ldap.mappers.msadlds.MSADLDSUserAccountControlStorageMapperFactory
org.keycloak.storage.ldap.mappers.UserAttributeLDAPStorageMapperFactory
org.keycloak.storage.ldap.mappers.CertificateLDAPStorageMapperFactory

View file

@ -60,6 +60,10 @@
<groupId>org.glassfish</groupId>
<artifactId>javax.json</artifactId>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-common</artifactId>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-server-spi</artifactId>

View file

@ -68,6 +68,7 @@ public abstract class AbstractX509ClientCertificateAuthenticator implements Auth
public static final String MAPPING_SOURCE_CERT_ISSUERDN_EMAIL = "Issuer's e-mail";
public static final String MAPPING_SOURCE_CERT_ISSUERDN_CN = "Issuer's Common Name";
public static final String MAPPING_SOURCE_CERT_SERIALNUMBER = "Certificate Serial Number";
public static final String MAPPING_SOURCE_CERT_CERTIFICATE_PEM = "Full Certificate in PEM format";
public static final String USER_MAPPER_SELECTION = "x509-cert-auth.mapper-selection";
public static final String USER_ATTRIBUTE_MAPPER = "Custom Attribute Mapper";
public static final String USERNAME_EMAIL_MAPPER = "Username or Email";
@ -174,6 +175,9 @@ public abstract class AbstractX509ClientCertificateAuthenticator implements Auth
.either(UserIdentityExtractor.getX500NameExtractor(BCStyle.EmailAddress, issuer))
.or(UserIdentityExtractor.getX500NameExtractor(BCStyle.E, issuer));
break;
case CERTIFICATE_PEM:
extractor = UserIdentityExtractor.getCertificatePemIdentityExtractor(config);
break;
default:
logger.warnf("[UserIdentityExtractorBuilder:fromConfig] Unknown or unsupported user identity source: \"%s\"", userIdentitySource.getName());
break;

View file

@ -79,7 +79,8 @@ public abstract class AbstractX509ClientCertificateAuthenticatorFactory implemen
MAPPING_SOURCE_CERT_ISSUERDN,
MAPPING_SOURCE_CERT_ISSUERDN_EMAIL,
MAPPING_SOURCE_CERT_ISSUERDN_CN,
MAPPING_SOURCE_CERT_SERIALNUMBER
MAPPING_SOURCE_CERT_SERIALNUMBER,
MAPPING_SOURCE_CERT_CERTIFICATE_PEM
};
private static final String[] userModelMappers = {

View file

@ -28,6 +28,7 @@ import org.bouncycastle.asn1.DERUTF8String;
import org.bouncycastle.asn1.x500.RDN;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.asn1.x500.style.IETFUtils;
import org.keycloak.common.util.PemUtils;
import org.keycloak.services.ServicesLogger;
import java.io.ByteArrayInputStream;
@ -275,4 +276,19 @@ public abstract class UserIdentityExtractor {
public static OrBuilder either(UserIdentityExtractor extractor) {
return new OrBuilder(extractor);
}
public static UserIdentityExtractor getCertificatePemIdentityExtractor(X509AuthenticatorConfigModel config) {
return new UserIdentityExtractor() {
@Override
public Object extractUserIdentity(X509Certificate[] certs) {
if (certs == null || certs.length == 0) {
throw new IllegalArgumentException();
}
String pem = PemUtils.encodeCertificate(certs[0]);
logger.debugf("Using PEM certificate \"%s\" as user identity.", pem);
return pem;
}
};
}
}

View file

@ -62,7 +62,8 @@ public class X509AuthenticatorConfigModel extends AuthenticatorConfigModel {
SUBJECTDN_EMAIL(MAPPING_SOURCE_CERT_SUBJECTDN_EMAIL),
SUBJECTALTNAME_EMAIL(MAPPING_SOURCE_CERT_SUBJECTALTNAME_EMAIL),
SUBJECTALTNAME_OTHERNAME(MAPPING_SOURCE_CERT_SUBJECTALTNAME_OTHERNAME),
SUBJECTDN(MAPPING_SOURCE_CERT_SUBJECTDN);
SUBJECTDN(MAPPING_SOURCE_CERT_SUBJECTDN),
CERTIFICATE_PEM(MAPPING_SOURCE_CERT_CERTIFICATE_PEM);
private String name;
MappingSourceType(String name) {

View file

@ -0,0 +1,29 @@
package org.keycloak.authentication.authenticators.x509;
import static org.junit.Assert.assertEquals;
import java.io.InputStream;
import java.nio.charset.Charset;
import java.security.cert.X509Certificate;
import org.junit.Test;
import org.keycloak.common.util.PemUtils;
import org.keycloak.common.util.StreamUtil;
public class CertificatePemIdentityExtractorTest {
@Test
public void testExtractsCertInPemFormat() throws Exception {
InputStream is = getClass().getResourceAsStream("/certs/UPN-cert.pem");
X509Certificate x509Certificate = PemUtils.decodeCertificate(StreamUtil.readString(is, Charset.defaultCharset()));
String certificatePem = PemUtils.encodeCertificate(x509Certificate);
X509AuthenticatorConfigModel config = new X509AuthenticatorConfigModel();
UserIdentityExtractor extractor = UserIdentityExtractor.getCertificatePemIdentityExtractor(config);
String userIdentity = (String) extractor.extractUserIdentity(new X509Certificate[]{x509Certificate});
assertEquals(certificatePem, userIdentity);
}
}