KEYCLOAK-7969 - SAML users should not be identified by SAML:NameID

This commit is contained in:
Dmitry Telegin 2020-02-03 16:22:43 +03:00 committed by Hynek Mlnařík
parent 7dec314ed0
commit b6c5acef25
9 changed files with 256 additions and 14 deletions

View file

@ -16,9 +16,12 @@
*/
package org.keycloak.saml.processing.core.saml.v2.constants;
import org.keycloak.dom.saml.v2.assertion.AttributeType;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
/**
* X500 SAML Profile Constants Adapted from
@ -147,6 +150,12 @@ public enum X500SAMLProfileConstants {
return friendlyName;
}
public boolean correspondsTo(AttributeType attribute) {
return attribute != null
? Objects.equals(this.uri, attribute.getName()) || Objects.equals(this.friendlyName, attribute.getFriendlyName())
: false;
}
public static String getOID(final String key) {
return lookup.get(key);
}

View file

@ -85,7 +85,9 @@ import java.security.Key;
import java.security.cert.X509Certificate;
import java.util.LinkedList;
import java.util.List;
import java.util.function.Predicate;
import org.keycloak.protocol.saml.SamlPrincipalType;
import org.keycloak.rotation.HardcodedKeyLocator;
import org.keycloak.rotation.KeyLocator;
import org.keycloak.saml.processing.core.util.KeycloakKeySamlExtensionGenerator;
@ -414,15 +416,24 @@ public class SAMLEndpoint {
SubjectType subject = assertion.getSubject();
SubjectType.STSubType subType = subject.getSubType();
NameIDType subjectNameID = (NameIDType) subType.getBaseID();
String principal = getPrincipal(assertion);
if (principal == null) {
logger.errorf("no principal in assertion; expected: %s", expectedPrincipalType());
event.event(EventType.IDENTITY_PROVIDER_RESPONSE);
event.error(Errors.INVALID_SAML_RESPONSE);
return ErrorPage.error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REQUESTER);
}
//Map<String, String> notes = new HashMap<>();
BrokeredIdentityContext identity = new BrokeredIdentityContext(subjectNameID.getValue());
BrokeredIdentityContext identity = new BrokeredIdentityContext(principal);
identity.getContextData().put(SAML_LOGIN_RESPONSE, responseType);
identity.getContextData().put(SAML_ASSERTION, assertion);
if (clientId != null && ! clientId.trim().isEmpty()) {
identity.getContextData().put(SAML_IDP_INITIATED_CLIENT_ID, clientId);
}
identity.setUsername(subjectNameID.getValue());
identity.setUsername(principal);
//SAML Spec 2.2.2 Format is optional
if (subjectNameID.getFormat() != null && subjectNameID.getFormat().toString().equals(JBossSAMLURIConstants.NAMEID_FORMAT_EMAIL.get())) {
@ -461,19 +472,12 @@ public class SAMLEndpoint {
}
}
if (assertion.getAttributeStatements() != null ) {
for (AttributeStatementType attrStatement : assertion.getAttributeStatements()) {
for (AttributeStatementType.ASTChoiceType choice : attrStatement.getAttributes()) {
AttributeType attribute = choice.getAttribute();
if (X500SAMLProfileConstants.EMAIL.getFriendlyName().equals(attribute.getFriendlyName())
|| X500SAMLProfileConstants.EMAIL.get().equals(attribute.getName())) {
if (!attribute.getAttributeValue().isEmpty()) identity.setEmail(attribute.getAttributeValue().get(0).toString());
}
}
}
String email = getX500Attribute(assertion, X500SAMLProfileConstants.EMAIL);
if (email != null)
identity.setEmail(email);
}
String brokerUserId = config.getAlias() + "." + subjectNameID.getValue();
String brokerUserId = config.getAlias() + "." + principal;
identity.setBrokerUserId(brokerUserId);
identity.setIdpConfig(config);
identity.setIdp(provider);
@ -632,4 +636,59 @@ public class SAMLEndpoint {
}
private String getX500Attribute(AssertionType assertion, X500SAMLProfileConstants attribute) {
return getFirstMatchingAttribute(assertion, attribute::correspondsTo);
}
private String getAttributeByName(AssertionType assertion, String name) {
return getFirstMatchingAttribute(assertion, attribute -> Objects.equals(attribute.getName(), name));
}
private String getAttributeByFriendlyName(AssertionType assertion, String friendlyName) {
return getFirstMatchingAttribute(assertion, attribute -> Objects.equals(attribute.getFriendlyName(), friendlyName));
}
private String getPrincipal(AssertionType assertion) {
SamlPrincipalType principalType = config.getPrincipalType();
if (principalType == null || principalType.equals(SamlPrincipalType.SUBJECT)) {
SubjectType subject = assertion.getSubject();
SubjectType.STSubType subType = subject.getSubType();
NameIDType subjectNameID = (NameIDType) subType.getBaseID();
return subjectNameID.getValue();
} else if (principalType.equals(SamlPrincipalType.ATTRIBUTE)) {
return getAttributeByName(assertion, config.getPrincipalAttribute());
} else {
return getAttributeByFriendlyName(assertion, config.getPrincipalAttribute());
}
}
private String getFirstMatchingAttribute(AssertionType assertion, Predicate<AttributeType> predicate) {
return assertion.getAttributeStatements().stream()
.map(AttributeStatementType::getAttributes)
.flatMap(Collection::stream)
.map(AttributeStatementType.ASTChoiceType::getAttribute)
.filter(predicate)
.map(AttributeType::getAttributeValue)
.flatMap(Collection::stream)
.findFirst()
.map(Object::toString)
.orElse(null);
}
private String expectedPrincipalType() {
SamlPrincipalType principalType = config.getPrincipalType();
switch (principalType) {
case SUBJECT:
return principalType.name();
case ATTRIBUTE:
case FRIENDLY_ATTRIBUTE:
return String.format("%s(%s)", principalType.name(), config.getPrincipalAttribute());
default:
return null;
}
}
}

View file

@ -18,6 +18,7 @@ package org.keycloak.broker.saml;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.protocol.saml.SamlPrincipalType;
import org.keycloak.saml.common.util.XmlKeyInfoKeyNameTransformer;
/**
@ -40,6 +41,8 @@ public class SAMLIdentityProviderConfig extends IdentityProviderModel {
public static final String SINGLE_LOGOUT_SERVICE_URL = "singleLogoutServiceUrl";
public static final String SINGLE_SIGN_ON_SERVICE_URL = "singleSignOnServiceUrl";
public static final String VALIDATE_SIGNATURE = "validateSignature";
public static final String PRINCIPAL_TYPE = "principalType";
public static final String PRINCIPAL_ATTRIBUTE = "principalAttribute";
public static final String WANT_ASSERTIONS_ENCRYPTED = "wantAssertionsEncrypted";
public static final String WANT_ASSERTIONS_SIGNED = "wantAssertionsSigned";
public static final String WANT_AUTHN_REQUESTS_SIGNED = "wantAuthnRequestsSigned";
@ -253,4 +256,24 @@ public class SAMLIdentityProviderConfig extends IdentityProviderModel {
getConfig().put(ALLOWED_CLOCK_SKEW, String.valueOf(allowedClockSkew));
}
}
public SamlPrincipalType getPrincipalType() {
return SamlPrincipalType.from(getConfig().get(PRINCIPAL_TYPE), SamlPrincipalType.SUBJECT);
}
public void setPrincipalType(SamlPrincipalType principalType) {
getConfig().put(PRINCIPAL_TYPE,
principalType == null
? null
: principalType.name());
}
public String getPrincipalAttribute() {
return getConfig().get(PRINCIPAL_ATTRIBUTE);
}
public void setPrincipalAttribute(String principalAttribute) {
getConfig().put(PRINCIPAL_ATTRIBUTE, principalAttribute);
}
}

View file

@ -0,0 +1,36 @@
/*
* Copyright 2020 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed 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.keycloak.protocol.saml;
public enum SamlPrincipalType {
SUBJECT,
ATTRIBUTE,
FRIENDLY_ATTRIBUTE;
public static SamlPrincipalType from(String name, SamlPrincipalType defaultValue) {
if (name == null) {
return defaultValue;
}
try {
return valueOf(name);
} catch (IllegalArgumentException ex) {
return defaultValue;
}
}
}

View file

@ -1,18 +1,27 @@
package org.keycloak.testsuite.broker;
import org.keycloak.admin.client.resource.ClientsResource;
import org.keycloak.admin.client.resource.IdentityProviderResource;
import org.keycloak.admin.client.resource.UsersResource;
import org.keycloak.broker.saml.SAMLIdentityProviderConfig;
import org.keycloak.common.util.StreamUtil;
import org.keycloak.common.util.StringPropertyReplacer;
import org.keycloak.dom.saml.v2.assertion.AssertionType;
import org.keycloak.dom.saml.v2.assertion.AttributeStatementType;
import org.keycloak.dom.saml.v2.assertion.AttributeType;
import org.keycloak.dom.saml.v2.assertion.AudienceRestrictionType;
import org.keycloak.dom.saml.v2.assertion.StatementAbstractType;
import org.keycloak.dom.saml.v2.protocol.ResponseType;
import org.keycloak.protocol.saml.SamlPrincipalType;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.FederatedIdentityRepresentation;
import org.keycloak.representations.idm.IdentityProviderRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.representations.idm.UserSessionRepresentation;
import org.keycloak.saml.common.constants.JBossSAMLURIConstants;
import org.keycloak.saml.processing.core.saml.v2.common.SAMLDocumentHolder;
import org.keycloak.saml.processing.core.saml.v2.constants.X500SAMLProfileConstants;
import org.keycloak.saml.processing.core.saml.v2.util.AssertionUtil;
import org.keycloak.testsuite.AbstractKeycloakTest;
import org.keycloak.testsuite.Assert;
@ -109,6 +118,14 @@ public class KcSamlIdPInitiatedSsoTest extends AbstractKeycloakTest {
urlRealmConsumer2 = getAuthRoot() + "/auth/realms/" + REALM_CONS_NAME + "-2";
}
@Before
public void resetPrincipalType() {
IdentityProviderResource idp = adminClient.realm(REALM_CONS_NAME).identityProviders().get("saml-leaf");
IdentityProviderRepresentation rep = idp.toRepresentation();
rep.getConfig().put(SAMLIdentityProviderConfig.PRINCIPAL_TYPE, SamlPrincipalType.SUBJECT.name());
idp.update(rep);
}
@Override
public void addTestRealms(List<RealmRepresentation> testRealms) {
initRealmUrls();
@ -324,6 +341,62 @@ public class KcSamlIdPInitiatedSsoTest extends AbstractKeycloakTest {
);
}
// KEYCLOAK-7969
@Test
public void testProviderIdpInitiatedLoginWithPrincipalAttribute() throws Exception {
IdentityProviderResource idp = adminClient.realm(REALM_CONS_NAME).identityProviders().get("saml-leaf");
IdentityProviderRepresentation rep = idp.toRepresentation();
rep.getConfig().put(SAMLIdentityProviderConfig.PRINCIPAL_TYPE, SamlPrincipalType.ATTRIBUTE.name());
rep.getConfig().put(SAMLIdentityProviderConfig.PRINCIPAL_ATTRIBUTE, X500SAMLProfileConstants.UID.get());
idp.update(rep);
SAMLDocumentHolder samlResponse = new SamlClientBuilder()
.navigateTo(getSamlIdpInitiatedUrl(REALM_PROV_NAME, "samlbroker"))
// Login in provider realm
.login().user(PROVIDER_REALM_USER_NAME, PROVIDER_REALM_USER_PASSWORD).build()
// Send the response to the consumer realm
.processSamlResponse(Binding.POST)
.transformObject(ob -> {
assertThat(ob, Matchers.isSamlResponse(JBossSAMLURIConstants.STATUS_SUCCESS));
ResponseType resp = (ResponseType) ob;
assertThat(resp.getDestination(), is(getSamlBrokerIdpInitiatedUrl(REALM_CONS_NAME, "sales")));
assertAudience(resp, getSamlBrokerIdpInitiatedUrl(REALM_CONS_NAME, "sales"));
Set<StatementAbstractType> statements = resp.getAssertions().get(0).getAssertion().getStatements();
AttributeStatementType attributeType = (AttributeStatementType) statements.stream()
.filter(statement -> statement instanceof AttributeStatementType)
.findFirst().orElse(new AttributeStatementType());
AttributeType attr = new AttributeType(X500SAMLProfileConstants.UID.get());
attr.addAttributeValue(PROVIDER_REALM_USER_NAME);
attributeType.addAttribute(new AttributeStatementType.ASTChoiceType(attr));
resp.getAssertions().get(0).getAssertion().addStatement(attributeType);
return ob;
})
.build()
.updateProfile().username(CONSUMER_CHOSEN_USERNAME).email("test@localhost").firstName("Firstname").lastName("Lastname").build()
.followOneRedirect()
// Obtain the response sent to the app
.getSamlResponse(Binding.POST);
assertThat(samlResponse.getSamlObject(), Matchers.isSamlResponse(JBossSAMLURIConstants.STATUS_SUCCESS));
ResponseType resp = (ResponseType) samlResponse.getSamlObject();
assertThat(resp.getDestination(), is(urlRealmConsumer + "/app/auth"));
assertAudience(resp, urlRealmConsumer + "/app/auth");
UsersResource users = adminClient.realm(REALM_CONS_NAME).users();
String id = users.search(CONSUMER_CHOSEN_USERNAME).get(0).getId();
FederatedIdentityRepresentation fed = users.get(id).getFederatedIdentity().get(0);
assertThat(fed.getUserId(), is(PROVIDER_REALM_USER_NAME));
assertThat(fed.getUserName(), is(PROVIDER_REALM_USER_NAME));
}
private void assertSingleUserSession(String realmName, String userName, String... expectedClientIds) {
final UsersResource users = adminClient.realm(realmName).users();
final ClientsResource clients = adminClient.realm(realmName).clients();

View file

@ -527,6 +527,10 @@ single-logout-service-url=Адреса сервиса единого выход
saml.single-logout-service-url.tooltip=Url, который должен быть использован для отправленных запросов на выход.
nameid-policy-format=Формат политики NameID
nameid-policy-format.tooltip=Определяет ссылку URI, соответствующую формату идентификатора имени. По умолчанию urn:oasis:names:tc:SAML:2.0:nameid-format:persistent.
saml.principal-type=Тип идентификации
saml.principal-type.tooltip=Определяет, каким образом Keycloak идентифицирует внешних пользователей по SAML-сообщению. По умолчанию идентификация происходит по Subject NameID, в качестве альтернативы можно использовать атрибут-идентификатор.
saml.principal-attribute=Атрибут-идентификатор
saml.principal-attribute.tooltip=Имя (Name) или "дружественное имя" (Friendly Name) атрибута, идентифицирующего внешних пользователей.
http-post-binding-response=Привязанный ответ HTTP-POST
http-post-binding-response.tooltip=Указывает, следует ли отвечать на запросы, используя привязку HTTP-POST. Если нет, то будет использован HTTP-REDIRECT.
http-post-binding-for-authn-request=Привязывание HTTP-POST для AuthnRequest

View file

@ -643,6 +643,10 @@ single-logout-service-url=Single Logout Service URL
saml.single-logout-service-url.tooltip=The Url that must be used to send logout requests.
nameid-policy-format=NameID Policy Format
nameid-policy-format.tooltip=Specifies the URI reference corresponding to a name identifier format. Defaults to urn:oasis:names:tc:SAML:2.0:nameid-format:persistent.
saml.principal-type=Principal Type
saml.principal-type.tooltip=Way to identify and track external users from the assertion. Default is using Subject NameID, alternatively you can set up identifying attribute.
saml.principal-attribute=Principal Attribute
saml.principal-attribute.tooltip=Name or Friendly Name of the attribute used to identify external users.
http-post-binding-response=HTTP-POST Binding Response
http-post-binding-response.tooltip=Indicates whether to respond to requests using HTTP-POST binding. If false, HTTP-REDIRECT binding will be used.
http-post-binding-for-authn-request=HTTP-POST Binding for AuthnRequest

View file

@ -834,10 +834,28 @@ module.controller('RealmIdentityProviderCtrl', function($scope, $filter, $upload
"KEY_ID",
"CERT_SUBJECT"
];
$scope.principalTypes = [
{
type: "SUBJECT",
name: "Subject NameID"
},
{
type: "ATTRIBUTE",
name: "Attribute [Name]"
},
{
type: "FRIENDLY_ATTRIBUTE",
name: "Attribute [Friendly Name]"
}
];
if (instance && instance.alias) {
} else {
$scope.identityProvider.config.nameIDPolicyFormat = $scope.nameIdFormats[0].format;
$scope.identityProvider.config.principalType = $scope.principalTypes[0].type;
$scope.identityProvider.config.signatureAlgorithm = $scope.signatureAlgorithms[1];
$scope.identityProvider.config.samlXmlKeyNameTranformer = $scope.xmlKeyNameTranformers[1];
}

View file

@ -142,6 +142,22 @@
</div>
<kc-tooltip>{{:: 'nameid-policy-format.tooltip' | translate}}</kc-tooltip>
</div>
<div class="form-group clearfix">
<label class="col-md-2 control-label" for="principalType">{{:: 'saml.principal-type' | translate}}</label>
<div class="col-md-6">
<select id="principalType" ng-model="identityProvider.config.principalType"
ng-options="pType.type as pType.name for pType in principalTypes">
</select>
</div>
<kc-tooltip>{{:: 'saml.principal-type.tooltip' | translate}}</kc-tooltip>
</div>
<div class="form-group clearfix" data-ng-show="identityProvider.config.principalType.endsWith('ATTRIBUTE')">
<label class="col-md-2 control-label" for="principalAttribute">{{:: 'saml.principal-attribute' | translate}}</label>
<div class="col-md-6">
<input class="form-control" id="principalAttribute" type="text" ng-model="identityProvider.config.principalAttribute" ng-required="identityProvider.config.principalType.endsWith('ATTRIBUTE')">
</div>
<kc-tooltip>{{:: 'saml.principal-attribute.tooltip' | translate}}</kc-tooltip>
</div>
<div class="form-group">
<label class="col-md-2 control-label" for="postBindingResponse">{{:: 'http-post-binding-response' | translate}}</label>
<div class="col-md-6">