KEYCLOAK-7969 - SAML users should not be identified by SAML:NameID
This commit is contained in:
parent
7dec314ed0
commit
b6c5acef25
9 changed files with 256 additions and 14 deletions
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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();
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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];
|
||||
}
|
||||
|
|
|
@ -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">
|
||||
|
|
Loading…
Reference in a new issue