keycloak-uncached
Merge pull request #1799 from mposolda/broker-reauthentication KEYCLOAK-1750 …
Changes
broker/core/src/main/java/org/keycloak/broker/provider/IdentityProviderDataMarshaller.java 12(+12 -0)
broker/saml/pom.xml 5(+5 -0)
federation/kerberos/src/main/java/org/keycloak/federation/kerberos/impl/KerberosUsernamePasswordAuthenticator.java 11(+11 -0)
forms/common-themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties 2(+2 -0)
forms/common-themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-oidc.html 13(+13 -0)
forms/common-themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-saml.html 13(+13 -0)
forms/common-themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-social.html 13(+13 -0)
forms/common-themes/src/main/resources/theme/keycloak/email/html/identity-provider-link.ftl 5(+5 -0)
forms/common-themes/src/main/resources/theme/keycloak/email/messages/messages_en.properties 3(+3 -0)
forms/common-themes/src/main/resources/theme/keycloak/email/text/identity-provider-link.ftl 1(+1 -0)
forms/email-freemarker/src/main/java/org/keycloak/email/freemarker/FreeMarkerEmailProvider.java 39(+38 -1)
forms/login-freemarker/src/main/java/org/keycloak/login/freemarker/FreeMarkerLoginFormsProvider.java 61(+57 -4)
forms/login-freemarker/src/main/java/org/keycloak/login/freemarker/model/IdentityProviderBean.java 5(+2 -3)
saml/saml-core/pom.xml 5(+0 -5)
saml/saml-core/src/main/java/org/keycloak/saml/processing/core/saml/v2/writers/SAMLAssertionWriter.java 8(+6 -2)
services/src/main/java/org/keycloak/authentication/authenticators/broker/AbstractIdpAuthenticator.java 118(+118 -0)
services/src/main/java/org/keycloak/authentication/authenticators/broker/IdpConfirmLinkAuthenticator.java 73(+73 -0)
services/src/main/java/org/keycloak/authentication/authenticators/broker/IdpConfirmLinkAuthenticatorFactory.java 86(+86 -0)
services/src/main/java/org/keycloak/authentication/authenticators/broker/IdpDetectDuplicationsAuthenticator.java 105(+105 -0)
services/src/main/java/org/keycloak/authentication/authenticators/broker/IdpDetectDuplicationsAuthenticatorFactory.java 85(+85 -0)
services/src/main/java/org/keycloak/authentication/authenticators/broker/IdpEmailVerificationAuthenticator.java 135(+135 -0)
services/src/main/java/org/keycloak/authentication/authenticators/broker/IdpEmailVerificationAuthenticatorFactory.java 85(+85 -0)
services/src/main/java/org/keycloak/authentication/authenticators/broker/IdpUpdateProfileAuthenticator.java 118(+118 -0)
services/src/main/java/org/keycloak/authentication/authenticators/broker/IdpUpdateProfileAuthenticatorFactory.java 84(+84 -0)
services/src/main/java/org/keycloak/authentication/authenticators/broker/IdpUsernamePasswordForm.java 55(+55 -0)
services/src/main/java/org/keycloak/authentication/authenticators/broker/IdpUsernamePasswordFormFactory.java 35(+35 -0)
services/src/main/java/org/keycloak/authentication/authenticators/broker/util/ExistingUserInfo.java 62(+62 -0)
services/src/main/java/org/keycloak/authentication/authenticators/broker/util/SerializedBrokeredIdentityContext.java 327(+327 -0)
services/src/main/java/org/keycloak/authentication/authenticators/resetcred/ResetCredentialChooseUser.java 14(+14 -0)
services/src/main/java/org/keycloak/authentication/authenticators/resetcred/ResetCredentialEmail.java 7(+4 -3)
services/src/main/java/org/keycloak/authentication/requiredactions/util/UpdateProfileContext.java 38(+38 -0)
services/src/main/java/org/keycloak/authentication/requiredactions/util/UserUpdateProfileContext.java 81(+81 -0)
services/src/main/java/org/keycloak/services/resources/admin/IdentityProvidersResource.java 4(+2 -2)
services/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory 5(+5 -0)
testsuite/integration/src/test/java/org/keycloak/testsuite/broker/AbstractIdentityProviderTest.java 6(+4 -2)
Details
diff --git a/broker/core/src/main/java/org/keycloak/broker/provider/AbstractIdentityProvider.java b/broker/core/src/main/java/org/keycloak/broker/provider/AbstractIdentityProvider.java
index 6da630f..a677419 100755
--- a/broker/core/src/main/java/org/keycloak/broker/provider/AbstractIdentityProvider.java
+++ b/broker/core/src/main/java/org/keycloak/broker/provider/AbstractIdentityProvider.java
@@ -92,4 +92,9 @@ public abstract class AbstractIdentityProvider<C extends IdentityProviderModel>
public void updateBrokeredUser(KeycloakSession session, RealmModel realm, UserModel user, BrokeredIdentityContext context) {
}
+
+ @Override
+ public IdentityProviderDataMarshaller getMarshaller() {
+ return new DefaultDataMarshaller();
+ }
}
diff --git a/broker/core/src/main/java/org/keycloak/broker/provider/DefaultDataMarshaller.java b/broker/core/src/main/java/org/keycloak/broker/provider/DefaultDataMarshaller.java
new file mode 100644
index 0000000..3f8fcf2
--- /dev/null
+++ b/broker/core/src/main/java/org/keycloak/broker/provider/DefaultDataMarshaller.java
@@ -0,0 +1,40 @@
+package org.keycloak.broker.provider;
+
+import java.io.IOException;
+
+import org.keycloak.common.util.Base64Url;
+import org.keycloak.util.JsonSerialization;
+
+/**
+ * @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
+ */
+public class DefaultDataMarshaller implements IdentityProviderDataMarshaller {
+
+ @Override
+ public String serialize(Object value) {
+ if (value instanceof String) {
+ return (String) value;
+ } else {
+ try {
+ byte[] bytes = JsonSerialization.writeValueAsBytes(value);
+ return Base64Url.encode(bytes);
+ } catch (IOException ioe) {
+ throw new RuntimeException(ioe);
+ }
+ }
+ }
+
+ @Override
+ public <T> T deserialize(String serialized, Class<T> clazz) {
+ if (clazz.equals(String.class)) {
+ return clazz.cast(serialized);
+ } else {
+ byte[] bytes = Base64Url.decode(serialized);
+ try {
+ return JsonSerialization.readValue(bytes, clazz);
+ } catch (IOException ioe) {
+ throw new RuntimeException(ioe);
+ }
+ }
+ }
+}
diff --git a/broker/core/src/main/java/org/keycloak/broker/provider/IdentityProvider.java b/broker/core/src/main/java/org/keycloak/broker/provider/IdentityProvider.java
index 1d775ee..42eb6fe 100755
--- a/broker/core/src/main/java/org/keycloak/broker/provider/IdentityProvider.java
+++ b/broker/core/src/main/java/org/keycloak/broker/provider/IdentityProvider.java
@@ -103,4 +103,10 @@ public interface IdentityProvider<C extends IdentityProviderModel> extends Provi
*/
Response export(UriInfo uriInfo, RealmModel realm, String format);
+ /**
+ * Implementation of marshaller to serialize/deserialize attached data to Strings, which can be saved in clientSession
+ * @return
+ */
+ IdentityProviderDataMarshaller getMarshaller();
+
}
diff --git a/broker/core/src/main/java/org/keycloak/broker/provider/IdentityProviderDataMarshaller.java b/broker/core/src/main/java/org/keycloak/broker/provider/IdentityProviderDataMarshaller.java
new file mode 100644
index 0000000..7e57653
--- /dev/null
+++ b/broker/core/src/main/java/org/keycloak/broker/provider/IdentityProviderDataMarshaller.java
@@ -0,0 +1,12 @@
+package org.keycloak.broker.provider;
+
+/**
+ *
+ * @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
+ */
+public interface IdentityProviderDataMarshaller {
+
+ String serialize(Object obj);
+ <T> T deserialize(String serialized, Class<T> clazz);
+
+}
broker/saml/pom.xml 5(+5 -0)
diff --git a/broker/saml/pom.xml b/broker/saml/pom.xml
index 858a7ba..21a0099 100755
--- a/broker/saml/pom.xml
+++ b/broker/saml/pom.xml
@@ -45,6 +45,11 @@
<artifactId>jboss-logging</artifactId>
<scope>provided</scope>
</dependency>
+ <dependency>
+ <groupId>junit</groupId>
+ <artifactId>junit</artifactId>
+ <scope>test</scope>
+ </dependency>
</dependencies>
</project>
diff --git a/broker/saml/src/main/java/org/keycloak/broker/saml/SAMLDataMarshaller.java b/broker/saml/src/main/java/org/keycloak/broker/saml/SAMLDataMarshaller.java
new file mode 100644
index 0000000..61f4d8a
--- /dev/null
+++ b/broker/saml/src/main/java/org/keycloak/broker/saml/SAMLDataMarshaller.java
@@ -0,0 +1,88 @@
+package org.keycloak.broker.saml;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.InputStream;
+
+import javax.xml.stream.XMLEventReader;
+
+import org.keycloak.broker.provider.DefaultDataMarshaller;
+import org.keycloak.dom.saml.v2.assertion.AssertionType;
+import org.keycloak.dom.saml.v2.assertion.AuthnStatementType;
+import org.keycloak.dom.saml.v2.protocol.ResponseType;
+import org.keycloak.saml.common.exceptions.ParsingException;
+import org.keycloak.saml.common.exceptions.ProcessingException;
+import org.keycloak.saml.common.util.StaxUtil;
+import org.keycloak.saml.processing.core.parsers.saml.SAMLParser;
+import org.keycloak.saml.processing.core.parsers.util.SAMLParserUtil;
+import org.keycloak.saml.processing.core.saml.v2.writers.SAMLAssertionWriter;
+import org.keycloak.saml.processing.core.saml.v2.writers.SAMLResponseWriter;
+
+/**
+ * @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
+ */
+public class SAMLDataMarshaller extends DefaultDataMarshaller {
+
+ @Override
+ public String serialize(Object obj) {
+
+ // Lame impl, but hopefully sufficient for now. See if something better is needed...
+ if (obj.getClass().getName().startsWith("org.keycloak.dom.saml")) {
+ ByteArrayOutputStream bos = new ByteArrayOutputStream();
+
+ try {
+ if (obj instanceof ResponseType) {
+ ResponseType responseType = (ResponseType) obj;
+ SAMLResponseWriter samlWriter = new SAMLResponseWriter(StaxUtil.getXMLStreamWriter(bos));
+ samlWriter.write(responseType);
+ } else if (obj instanceof AssertionType) {
+ AssertionType assertion = (AssertionType) obj;
+ SAMLAssertionWriter samlWriter = new SAMLAssertionWriter(StaxUtil.getXMLStreamWriter(bos));
+ samlWriter.write(assertion);
+ } else if (obj instanceof AuthnStatementType) {
+ AuthnStatementType authnStatement = (AuthnStatementType) obj;
+ SAMLAssertionWriter samlWriter = new SAMLAssertionWriter(StaxUtil.getXMLStreamWriter(bos));
+ samlWriter.write(authnStatement, true);
+ } else {
+ throw new IllegalArgumentException("Don't know how to serialize object of type " + obj.getClass().getName());
+ }
+ } catch (ProcessingException pe) {
+ throw new RuntimeException(pe);
+ }
+
+ return new String(bos.toByteArray());
+ } else {
+ return super.serialize(obj);
+ }
+ }
+
+ @Override
+ public <T> T deserialize(String serialized, Class<T> clazz) {
+ if (clazz.getName().startsWith("org.keycloak.dom.saml")) {
+ String xmlString = serialized;
+
+ try {
+ if (clazz.equals(ResponseType.class) || clazz.equals(AssertionType.class)) {
+ byte[] bytes = xmlString.getBytes();
+ InputStream is = new ByteArrayInputStream(bytes);
+ Object respType = new SAMLParser().parse(is);
+ return clazz.cast(respType);
+ } else if (clazz.equals(AuthnStatementType.class)) {
+ byte[] bytes = xmlString.getBytes();
+ InputStream is = new ByteArrayInputStream(bytes);
+ XMLEventReader xmlEventReader = new SAMLParser().createEventReader(is);
+ AuthnStatementType authnStatement = SAMLParserUtil.parseAuthnStatement(xmlEventReader);
+ return clazz.cast(authnStatement);
+ } else {
+ throw new IllegalArgumentException("Don't know how to deserialize object of type " + clazz.getName());
+ }
+ } catch (ParsingException pe) {
+ throw new RuntimeException(pe);
+ }
+
+ } else {
+ return super.deserialize(serialized, clazz);
+ }
+ }
+
+}
diff --git a/broker/saml/src/main/java/org/keycloak/broker/saml/SAMLIdentityProvider.java b/broker/saml/src/main/java/org/keycloak/broker/saml/SAMLIdentityProvider.java
index 248c4de..0014470 100755
--- a/broker/saml/src/main/java/org/keycloak/broker/saml/SAMLIdentityProvider.java
+++ b/broker/saml/src/main/java/org/keycloak/broker/saml/SAMLIdentityProvider.java
@@ -22,6 +22,7 @@ import org.keycloak.broker.provider.AbstractIdentityProvider;
import org.keycloak.broker.provider.AuthenticationRequest;
import org.keycloak.broker.provider.BrokeredIdentityContext;
import org.keycloak.broker.provider.IdentityBrokerException;
+import org.keycloak.broker.provider.IdentityProviderDataMarshaller;
import org.keycloak.broker.provider.util.SimpleHttp;
import org.keycloak.dom.saml.v2.assertion.AssertionType;
import org.keycloak.dom.saml.v2.assertion.AuthnStatementType;
@@ -263,4 +264,8 @@ public class SAMLIdentityProvider extends AbstractIdentityProvider<SAMLIdentityP
return SignatureAlgorithm.RSA_SHA256;
}
+ @Override
+ public IdentityProviderDataMarshaller getMarshaller() {
+ return new SAMLDataMarshaller();
+ }
}
diff --git a/broker/saml/src/test/java/org/keycloak/broker/saml/SAMLDataMarshallerTest.java b/broker/saml/src/test/java/org/keycloak/broker/saml/SAMLDataMarshallerTest.java
new file mode 100644
index 0000000..cf6e4f8
--- /dev/null
+++ b/broker/saml/src/test/java/org/keycloak/broker/saml/SAMLDataMarshallerTest.java
@@ -0,0 +1,64 @@
+package org.keycloak.broker.saml;
+
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.keycloak.dom.saml.v2.assertion.AssertionType;
+import org.keycloak.dom.saml.v2.assertion.AuthnStatementType;
+import org.keycloak.dom.saml.v2.assertion.NameIDType;
+import org.keycloak.dom.saml.v2.protocol.ResponseType;
+
+/**
+ * @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
+ */
+public class SAMLDataMarshallerTest {
+
+ private static final String TEST_RESPONSE = "<samlp:Response xmlns:samlp=\"urn:oasis:names:tc:SAML:2.0:protocol\" xmlns:saml=\"urn:oasis:names:tc:SAML:2.0:assertion\" ID=\"ID_4804cf50-cd96-4b92-823e-89adaa0c78ba\" Version=\"2.0\" IssueInstant=\"2015-11-06T11:00:33.920Z\" Destination=\"http://localhost:8081/auth/realms/realm-with-broker/broker/kc-saml-idp-basic/endpoint\" InResponseTo=\"ID_c6b90123-f0bb-4c5c-bf9d-388d5bbe467a\"><saml:Issuer xmlns:saml=\"urn:oasis:names:tc:SAML:2.0:assertion\">http://localhost:8082/auth/realms/realm-with-saml-idp-basic</saml:Issuer><samlp:Status><samlp:StatusCode Value=\"urn:oasis:names:tc:SAML:2.0:status:Success\"></samlp:StatusCode></samlp:Status><saml:Assertion xmlns:saml=\"urn:oasis:names:tc:SAML:2.0:assertion\" xmlns=\"urn:oasis:names:tc:SAML:2.0:assertion\" ID=\"ID_29b196c2-d641-45c8-a423-8ed8e54d4cf9\" Version=\"2.0\" IssueInstant=\"2015-11-06T11:00:33.911Z\"><saml:Issuer xmlns:saml=\"urn:oasis:names:tc:SAML:2.0:assertion\">http://localhost:8082/auth/realms/realm-with-saml-idp-basic</saml:Issuer><saml:Subject><saml:NameID xmlns:saml=\"urn:oasis:names:tc:SAML:2.0:assertion\" Format=\"urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified\">test-user</saml:NameID><saml:SubjectConfirmation Method=\"urn:oasis:names:tc:SAML:2.0:cm:bearer\"><saml:SubjectConfirmationData InResponseTo=\"ID_c6b90123-f0bb-4c5c-bf9d-388d5bbe467a\" NotOnOrAfter=\"2015-11-06T11:05:31.911Z\" Recipient=\"http://localhost:8081/auth/realms/realm-with-broker/broker/kc-saml-idp-basic/endpoint\"></saml:SubjectConfirmationData></saml:SubjectConfirmation></saml:Subject><saml:Conditions NotBefore=\"2015-11-06T11:00:31.911Z\" NotOnOrAfter=\"2015-11-06T11:01:31.911Z\"><saml:AudienceRestriction><saml:Audience>http://localhost:8081/auth/realms/realm-with-broker</saml:Audience></saml:AudienceRestriction></saml:Conditions><saml:AuthnStatement AuthnInstant=\"2015-11-06T11:00:33.923Z\" SessionIndex=\"fa0f4fd3-8a11-44f4-9acb-ee30c5bb8fe5\"><saml:AuthnContext><saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:unspecified</saml:AuthnContextClassRef></saml:AuthnContext></saml:AuthnStatement><saml:AttributeStatement><saml:Attribute Name=\"mobile\" NameFormat=\"urn:oasis:names:tc:SAML:2.0:attrname-format:basic\"><saml:AttributeValue xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns:xs=\"http://www.w3.org/2001/XMLSchema\" xsi:type=\"xs:string\">617-666-7777</saml:AttributeValue></saml:Attribute><saml:Attribute Name=\"urn:oid:1.2.840.113549.1.9.1\" FriendlyName=\"email\" NameFormat=\"urn:oasis:names:tc:SAML:2.0:attrname-format:basic\"><saml:AttributeValue xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns:xs=\"http://www.w3.org/2001/XMLSchema\" xsi:type=\"xs:string\">test-user@localhost</saml:AttributeValue></saml:Attribute></saml:AttributeStatement><saml:AttributeStatement><saml:Attribute Name=\"Role\" NameFormat=\"urn:oasis:names:tc:SAML:2.0:attrname-format:basic\"><saml:AttributeValue xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns:xs=\"http://www.w3.org/2001/XMLSchema\" xsi:type=\"xs:string\">manager</saml:AttributeValue></saml:Attribute></saml:AttributeStatement></saml:Assertion></samlp:Response>";
+
+ private static final String TEST_ASSERTION = "<saml:Assertion xmlns:saml=\"urn:oasis:names:tc:SAML:2.0:assertion\" xmlns=\"urn:oasis:names:tc:SAML:2.0:assertion\" ID=\"ID_29b196c2-d641-45c8-a423-8ed8e54d4cf9\" Version=\"2.0\" IssueInstant=\"2015-11-06T11:00:33.911Z\"><saml:Issuer xmlns:saml=\"urn:oasis:names:tc:SAML:2.0:assertion\">http://localhost:8082/auth/realms/realm-with-saml-idp-basic</saml:Issuer><saml:Subject><saml:NameID xmlns:saml=\"urn:oasis:names:tc:SAML:2.0:assertion\" Format=\"urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified\">test-user</saml:NameID><saml:SubjectConfirmation Method=\"urn:oasis:names:tc:SAML:2.0:cm:bearer\"><saml:SubjectConfirmationData InResponseTo=\"ID_c6b90123-f0bb-4c5c-bf9d-388d5bbe467a\" NotOnOrAfter=\"2015-11-06T11:05:31.911Z\" Recipient=\"http://localhost:8081/auth/realms/realm-with-broker/broker/kc-saml-idp-basic/endpoint\"></saml:SubjectConfirmationData></saml:SubjectConfirmation></saml:Subject><saml:Conditions NotBefore=\"2015-11-06T11:00:31.911Z\" NotOnOrAfter=\"2015-11-06T11:01:31.911Z\"><saml:AudienceRestriction><saml:Audience>http://localhost:8081/auth/realms/realm-with-broker</saml:Audience></saml:AudienceRestriction></saml:Conditions><saml:AuthnStatement AuthnInstant=\"2015-11-06T11:00:33.923Z\" SessionIndex=\"fa0f4fd3-8a11-44f4-9acb-ee30c5bb8fe5\"><saml:AuthnContext><saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:unspecified</saml:AuthnContextClassRef></saml:AuthnContext></saml:AuthnStatement><saml:AttributeStatement><saml:Attribute Name=\"mobile\" NameFormat=\"urn:oasis:names:tc:SAML:2.0:attrname-format:basic\"><saml:AttributeValue xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns:xs=\"http://www.w3.org/2001/XMLSchema\" xsi:type=\"xs:string\">617-666-7777</saml:AttributeValue></saml:Attribute><saml:Attribute Name=\"urn:oid:1.2.840.113549.1.9.1\" FriendlyName=\"email\" NameFormat=\"urn:oasis:names:tc:SAML:2.0:attrname-format:basic\"><saml:AttributeValue xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns:xs=\"http://www.w3.org/2001/XMLSchema\" xsi:type=\"xs:string\">test-user@localhost</saml:AttributeValue></saml:Attribute></saml:AttributeStatement><saml:AttributeStatement><saml:Attribute Name=\"Role\" NameFormat=\"urn:oasis:names:tc:SAML:2.0:attrname-format:basic\"><saml:AttributeValue xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns:xs=\"http://www.w3.org/2001/XMLSchema\" xsi:type=\"xs:string\">manager</saml:AttributeValue></saml:Attribute></saml:AttributeStatement></saml:Assertion>";
+
+ private static final String TEST_AUTHN_TYPE = "<saml:AuthnStatement xmlns:saml=\"urn:oasis:names:tc:SAML:2.0:assertion\" xmlns=\"urn:oasis:names:tc:SAML:2.0:assertion\" AuthnInstant=\"2015-11-06T11:00:33.923Z\" SessionIndex=\"fa0f4fd3-8a11-44f4-9acb-ee30c5bb8fe5\"><saml:AuthnContext><saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:unspecified</saml:AuthnContextClassRef></saml:AuthnContext></saml:AuthnStatement>";
+
+ @Test
+ public void testParseResponse() throws Exception {
+ SAMLDataMarshaller serializer = new SAMLDataMarshaller();
+ ResponseType responseType = serializer.deserialize(TEST_RESPONSE, ResponseType.class);
+
+ // test ResponseType
+ Assert.assertEquals(responseType.getID(), "ID_4804cf50-cd96-4b92-823e-89adaa0c78ba");
+ Assert.assertEquals(responseType.getDestination(), "http://localhost:8081/auth/realms/realm-with-broker/broker/kc-saml-idp-basic/endpoint");
+ Assert.assertEquals(responseType.getIssuer().getValue(), "http://localhost:8082/auth/realms/realm-with-saml-idp-basic");
+ Assert.assertEquals(responseType.getAssertions().get(0).getID(), "ID_29b196c2-d641-45c8-a423-8ed8e54d4cf9");
+
+ // back to String
+ String serialized = serializer.serialize(responseType);
+ Assert.assertEquals(TEST_RESPONSE, serialized);
+ }
+
+ @Test
+ public void testParseAssertion() throws Exception {
+ SAMLDataMarshaller serializer = new SAMLDataMarshaller();
+ AssertionType assertion = serializer.deserialize(TEST_ASSERTION, AssertionType.class);
+
+ // test assertion
+ Assert.assertEquals(assertion.getID(), "ID_29b196c2-d641-45c8-a423-8ed8e54d4cf9");
+ Assert.assertEquals(((NameIDType) assertion.getSubject().getSubType().getBaseID()).getValue(), "test-user");
+
+ // back to String
+ String serialized = serializer.serialize(assertion);
+ Assert.assertEquals(TEST_ASSERTION, serialized);
+ }
+
+ @Test
+ public void testParseAuthnType() throws Exception {
+ SAMLDataMarshaller serializer = new SAMLDataMarshaller();
+ AuthnStatementType authnStatement = serializer.deserialize(TEST_AUTHN_TYPE, AuthnStatementType.class);
+
+ // test authnStatement
+ Assert.assertEquals(authnStatement.getSessionIndex(), "fa0f4fd3-8a11-44f4-9acb-ee30c5bb8fe5");
+
+ // back to String
+ String serialized = serializer.serialize(authnStatement);
+ Assert.assertEquals(TEST_AUTHN_TYPE, serialized);
+ }
+}
diff --git a/common/src/main/java/org/keycloak/common/util/ObjectUtil.java b/common/src/main/java/org/keycloak/common/util/ObjectUtil.java
index cec9ea9..1ade852 100644
--- a/common/src/main/java/org/keycloak/common/util/ObjectUtil.java
+++ b/common/src/main/java/org/keycloak/common/util/ObjectUtil.java
@@ -13,7 +13,7 @@ public class ObjectUtil {
* @param str2
* @return true if both strings are null or equal
*/
- public static boolean isEqualOrNull(Object str1, Object str2) {
+ public static boolean isEqualOrBothNull(Object str1, Object str2) {
if (str1 == null && str2 == null) {
return true;
}
diff --git a/connections/jpa/src/main/java/org/keycloak/connections/jpa/updater/JpaUpdaterProvider.java b/connections/jpa/src/main/java/org/keycloak/connections/jpa/updater/JpaUpdaterProvider.java
index 401cf74..a5429e4 100755
--- a/connections/jpa/src/main/java/org/keycloak/connections/jpa/updater/JpaUpdaterProvider.java
+++ b/connections/jpa/src/main/java/org/keycloak/connections/jpa/updater/JpaUpdaterProvider.java
@@ -12,7 +12,7 @@ public interface JpaUpdaterProvider extends Provider {
public String FIRST_VERSION = "1.0.0.Final";
- public String LAST_VERSION = "1.6.1";
+ public String LAST_VERSION = "1.7.0";
public String getCurrentVersionSql(String defaultSchema);
diff --git a/connections/jpa-liquibase/src/main/resources/META-INF/jpa-changelog-1.7.0.xml b/connections/jpa-liquibase/src/main/resources/META-INF/jpa-changelog-1.7.0.xml
new file mode 100644
index 0000000..4f7d5fc
--- /dev/null
+++ b/connections/jpa-liquibase/src/main/resources/META-INF/jpa-changelog-1.7.0.xml
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.1.xsd">
+ <changeSet author="mposolda@redhat.com" id="1.7.0">
+
+ <addColumn tableName="IDENTITY_PROVIDER">
+ <column name="FIRST_BROKER_LOGIN_FLOW_ID" type="VARCHAR(36)">
+ <constraints nullable="false"/>
+ </column>
+ </addColumn>
+
+ </changeSet>
+</databaseChangeLog>
\ No newline at end of file
diff --git a/connections/jpa-liquibase/src/main/resources/META-INF/jpa-changelog-master.xml b/connections/jpa-liquibase/src/main/resources/META-INF/jpa-changelog-master.xml
index 3010118..2acc0bb 100755
--- a/connections/jpa-liquibase/src/main/resources/META-INF/jpa-changelog-master.xml
+++ b/connections/jpa-liquibase/src/main/resources/META-INF/jpa-changelog-master.xml
@@ -10,4 +10,5 @@
<include file="META-INF/jpa-changelog-1.4.0.xml"/>
<include file="META-INF/jpa-changelog-1.5.0.xml"/>
<include file="META-INF/jpa-changelog-1.6.1.xml"/>
+ <include file="META-INF/jpa-changelog-1.7.0.xml"/>
</databaseChangeLog>
diff --git a/core/src/main/java/org/keycloak/representations/idm/IdentityProviderRepresentation.java b/core/src/main/java/org/keycloak/representations/idm/IdentityProviderRepresentation.java
index 1e74002..864d255 100755
--- a/core/src/main/java/org/keycloak/representations/idm/IdentityProviderRepresentation.java
+++ b/core/src/main/java/org/keycloak/representations/idm/IdentityProviderRepresentation.java
@@ -52,6 +52,7 @@ public class IdentityProviderRepresentation {
protected boolean storeToken;
protected boolean addReadTokenRoleOnCreate;
protected boolean authenticateByDefault;
+ protected String firstBrokerLoginFlowAlias;
protected Map<String, String> config = new HashMap<String, String>();
public String getInternalId() {
@@ -127,6 +128,14 @@ public class IdentityProviderRepresentation {
this.authenticateByDefault = authenticateByDefault;
}
+ public String getFirstBrokerLoginFlowAlias() {
+ return firstBrokerLoginFlowAlias;
+ }
+
+ public void setFirstBrokerLoginFlowAlias(String firstBrokerLoginFlowAlias) {
+ this.firstBrokerLoginFlowAlias = firstBrokerLoginFlowAlias;
+ }
+
public boolean isStoreToken() {
return this.storeToken;
}
diff --git a/events/api/src/main/java/org/keycloak/events/Errors.java b/events/api/src/main/java/org/keycloak/events/Errors.java
index d7ca253..34c5979 100755
--- a/events/api/src/main/java/org/keycloak/events/Errors.java
+++ b/events/api/src/main/java/org/keycloak/events/Errors.java
@@ -53,4 +53,5 @@ public interface Errors {
String EMAIL_SEND_FAILED = "email_send_failed";
String INVALID_EMAIL = "invalid_email";
String IDENTITY_PROVIDER_LOGIN_FAILURE = "identity_provider_login_failure";
+ String IDENTITY_PROVIDER_ERROR = "identity_provider_error";
}
diff --git a/events/api/src/main/java/org/keycloak/events/EventType.java b/events/api/src/main/java/org/keycloak/events/EventType.java
index 0a8b3ae..5cffe78 100755
--- a/events/api/src/main/java/org/keycloak/events/EventType.java
+++ b/events/api/src/main/java/org/keycloak/events/EventType.java
@@ -60,6 +60,8 @@ public enum EventType {
IDENTITY_PROVIDER_LOGIN(false),
IDENTITY_PROVIDER_LOGIN_ERROR(false),
+ IDENTITY_PROVIDER_FIRST_LOGIN(true),
+ IDENTITY_PROVIDER_FIRST_LOGIN_ERROR(true),
IDENTITY_PROVIDER_RESPONSE(false),
IDENTITY_PROVIDER_RESPONSE_ERROR(false),
IDENTITY_PROVIDER_RETRIEVE_TOKEN(false),
diff --git a/federation/kerberos/src/main/java/org/keycloak/federation/kerberos/impl/KerberosUsernamePasswordAuthenticator.java b/federation/kerberos/src/main/java/org/keycloak/federation/kerberos/impl/KerberosUsernamePasswordAuthenticator.java
index ea3f953..f38eeb5 100644
--- a/federation/kerberos/src/main/java/org/keycloak/federation/kerberos/impl/KerberosUsernamePasswordAuthenticator.java
+++ b/federation/kerberos/src/main/java/org/keycloak/federation/kerberos/impl/KerberosUsernamePasswordAuthenticator.java
@@ -17,6 +17,7 @@ import javax.security.auth.login.LoginException;
import org.jboss.logging.Logger;
import org.keycloak.federation.kerberos.CommonKerberosConfig;
+import org.keycloak.models.ModelException;
/**
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
@@ -54,6 +55,8 @@ public class KerberosUsernamePasswordAuthenticator {
String message = le.getMessage();
logger.debug("Message from kerberos: " + message);
+ checkKerberosServerAvailable(le);
+
// Bit cumbersome, but seems to work with tested kerberos servers
boolean exists = (!message.contains("Client not found"));
return exists;
@@ -74,11 +77,19 @@ public class KerberosUsernamePasswordAuthenticator {
logoutSubject();
return true;
} catch (LoginException le) {
+ checkKerberosServerAvailable(le);
+
logger.debug("Failed to authenticate user " + username, le);
return false;
}
}
+ protected void checkKerberosServerAvailable(LoginException le) {
+ if (le.getMessage().contains("Port Unreachable")) {
+ throw new ModelException("Kerberos unreachable", le);
+ }
+ }
+
/**
* Returns true if user was successfully authenticated against Kerberos
diff --git a/forms/common-themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties b/forms/common-themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties
index 8c8f074..ebf3a83 100644
--- a/forms/common-themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties
+++ b/forms/common-themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties
@@ -374,6 +374,7 @@ table-of-identity-providers=Table of identity providers
add-provider.placeholder=Add provider...
provider=Provider
gui-order=GUI order
+first-broker-login-flow=First Login Flow
redirect-uri=Redirect URI
redirect-uri.tooltip=The redirect uri to use when configuring the identity provider.
alias=Alias
@@ -393,6 +394,7 @@ update-profile-on-first-login.tooltip=Define conditions under which a user has t
trust-email=Trust Email
trust-email.tooltip=If enabled then email provided by this provider is not verified even if verification is enabled for the realm.
gui-order.tooltip=Number defining order of the provider in GUI (eg. on Login page).
+first-broker-login-flow.tooltip=Alias of authentication flow, which is triggered after first login with this identity provider.
openid-connect-config=OpenID Connect Config
openid-connect-config.tooltip=OIDC SP and external IDP configuration.
authorization-url=Authorization URL
diff --git a/forms/common-themes/src/main/resources/theme/base/admin/resources/js/app.js b/forms/common-themes/src/main/resources/theme/base/admin/resources/js/app.js
index f1d922b..96ee94d 100755
--- a/forms/common-themes/src/main/resources/theme/base/admin/resources/js/app.js
+++ b/forms/common-themes/src/main/resources/theme/base/admin/resources/js/app.js
@@ -199,6 +199,9 @@ module.config([ '$routeProvider', function($routeProvider) {
},
providerFactory : function(IdentityProviderFactoryLoader) {
return {};
+ },
+ authFlows : function(AuthenticationFlowsLoader) {
+ return {};
}
},
controller : 'RealmIdentityProviderCtrl'
@@ -217,6 +220,9 @@ module.config([ '$routeProvider', function($routeProvider) {
},
providerFactory : function(IdentityProviderFactoryLoader) {
return new IdentityProviderFactoryLoader();
+ },
+ authFlows : function(AuthenticationFlowsLoader) {
+ return AuthenticationFlowsLoader();
}
},
controller : 'RealmIdentityProviderCtrl'
@@ -235,6 +241,9 @@ module.config([ '$routeProvider', function($routeProvider) {
},
providerFactory : function(IdentityProviderFactoryLoader) {
return IdentityProviderFactoryLoader();
+ },
+ authFlows : function(AuthenticationFlowsLoader) {
+ return AuthenticationFlowsLoader();
}
},
controller : 'RealmIdentityProviderCtrl'
diff --git a/forms/common-themes/src/main/resources/theme/base/admin/resources/js/controllers/realm.js b/forms/common-themes/src/main/resources/theme/base/admin/resources/js/controllers/realm.js
index 7b32160..37ec3ab 100755
--- a/forms/common-themes/src/main/resources/theme/base/admin/resources/js/controllers/realm.js
+++ b/forms/common-themes/src/main/resources/theme/base/admin/resources/js/controllers/realm.js
@@ -594,7 +594,7 @@ module.controller('IdentityProviderTabCtrl', function(Dialog, $scope, Current, N
};
});
-module.controller('RealmIdentityProviderCtrl', function($scope, $filter, $upload, $http, $route, realm, instance, providerFactory, IdentityProvider, serverInfo, $location, Notifications, Dialog) {
+module.controller('RealmIdentityProviderCtrl', function($scope, $filter, $upload, $http, $route, realm, instance, providerFactory, IdentityProvider, serverInfo, authFlows, $location, Notifications, Dialog) {
console.log('RealmIdentityProviderCtrl');
$scope.realm = angular.copy(realm);
@@ -678,6 +678,7 @@ module.controller('RealmIdentityProviderCtrl', function($scope, $filter, $upload
$scope.identityProvider.enabled = true;
$scope.identityProvider.updateProfileFirstLoginMode = "off";
$scope.identityProvider.authenticateByDefault = false;
+ $scope.identityProvider.firstBrokerLoginFlowAlias = 'first broker login';
$scope.newIdentityProvider = true;
}
@@ -696,6 +697,13 @@ module.controller('RealmIdentityProviderCtrl', function($scope, $filter, $upload
$scope.configuredProviders = angular.copy(realm.identityProviders);
+ $scope.authFlows = [];
+ for (var i=0 ; i<authFlows.length ; i++) {
+ if (authFlows[i].providerId == 'basic-flow') {
+ $scope.authFlows.push(authFlows[i]);
+ }
+ }
+
$scope.$watch(function() {
return $location.path();
}, function() {
diff --git a/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-oidc.html b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-oidc.html
index 170bfe6..317709e 100755
--- a/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-oidc.html
+++ b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-oidc.html
@@ -79,6 +79,19 @@
</div>
<kc-tooltip>{{:: 'gui-order.tooltip' | translate}}</kc-tooltip>
</div>
+ <div class="form-group">
+ <label class="col-md-2 control-label" for="firstBrokerLoginFlowAlias">{{:: 'first-broker-login-flow' | translate}}</label>
+ <div class="col-md-6">
+ <div>
+ <select class="form-control" id="firstBrokerLoginFlowAlias"
+ ng-model="identityProvider.firstBrokerLoginFlowAlias"
+ ng-options="flow.alias as flow.alias for flow in authFlows"
+ required>
+ </select>
+ </div>
+ </div>
+ <kc-tooltip>{{:: 'first-broker-login-flow.tooltip' | translate}}</kc-tooltip>
+ </div>
</fieldset>
<fieldset>
<legend uncollapsed><span class="text">{{:: 'openid-connect-config' | translate}}</span> <kc-tooltip>{{:: 'openid-connect-config.tooltip' | translate}}</kc-tooltip></legend>
diff --git a/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-saml.html b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-saml.html
index bb9726f..53cbf3a 100755
--- a/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-saml.html
+++ b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-saml.html
@@ -79,6 +79,19 @@
</div>
<kc-tooltip>{{:: 'gui-order.tooltip' | translate}}</kc-tooltip>
</div>
+ <div class="form-group">
+ <label class="col-md-2 control-label" for="firstBrokerLoginFlowAlias">{{:: 'first-broker-login-flow' | translate}}</label>
+ <div class="col-md-6">
+ <div>
+ <select class="form-control" id="firstBrokerLoginFlowAlias"
+ ng-model="identityProvider.firstBrokerLoginFlowAlias"
+ ng-options="flow.alias as flow.alias for flow in authFlows"
+ required>
+ </select>
+ </div>
+ </div>
+ <kc-tooltip>{{:: 'first-broker-login-flow.tooltip' | translate}}</kc-tooltip>
+ </div>
</fieldset>
<fieldset>
<legend uncollapsed><span class="text">{{:: 'saml-config' | translate}}</span> <kc-tooltip>{{:: 'identity-provider.saml-config.tooltip' | translate}}</kc-tooltip></legend>
diff --git a/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-social.html b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-social.html
index 2f71d19..a4e3f13 100755
--- a/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-social.html
+++ b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-social.html
@@ -97,6 +97,19 @@
</div>
<kc-tooltip>{{:: 'gui-order.tooltip' | translate}}</kc-tooltip>
</div>
+ <div class="form-group">
+ <label class="col-md-2 control-label" for="firstBrokerLoginFlowAlias">{{:: 'first-broker-login-flow' | translate}}</label>
+ <div class="col-md-6">
+ <div>
+ <select class="form-control" id="firstBrokerLoginFlowAlias"
+ ng-model="identityProvider.firstBrokerLoginFlowAlias"
+ ng-options="flow.alias as flow.alias for flow in authFlows"
+ required>
+ </select>
+ </div>
+ </div>
+ <kc-tooltip>{{:: 'first-broker-login-flow.tooltip' | translate}}</kc-tooltip>
+ </div>
</fieldset>
<div class="form-group">
diff --git a/forms/common-themes/src/main/resources/theme/base/login/login.ftl b/forms/common-themes/src/main/resources/theme/base/login/login.ftl
index 925f99c..5786807 100755
--- a/forms/common-themes/src/main/resources/theme/base/login/login.ftl
+++ b/forms/common-themes/src/main/resources/theme/base/login/login.ftl
@@ -13,7 +13,11 @@
</div>
<div class="${properties.kcInputWrapperClass!}">
- <input id="username" class="${properties.kcInputClass!}" name="username" value="${(login.username!'')?html}" type="text" autofocus />
+ <#if usernameEditDisabled??>
+ <input id="username" class="${properties.kcInputClass!}" name="username" value="${(login.username!'')?html}" type="text" disabled />
+ <#else>
+ <input id="username" class="${properties.kcInputClass!}" name="username" value="${(login.username!'')?html}" type="text" autofocus />
+ </#if>
</div>
</div>
@@ -29,7 +33,7 @@
<div class="${properties.kcFormGroupClass!}">
<div id="kc-form-options" class="${properties.kcFormOptionsClass!}">
- <#if realm.rememberMe>
+ <#if realm.rememberMe && !usernameEditDisabled??>
<div class="checkbox">
<label>
<#if login.rememberMe??>
@@ -56,7 +60,7 @@
</form>
</#if>
<#elseif section = "info" >
- <#if realm.password && realm.registrationAllowed>
+ <#if realm.password && realm.registrationAllowed && !usernameEditDisabled??>
<div id="kc-registration">
<span>${msg("noAccount")} <a href="${url.registrationUrl}">${msg("doRegister")}</a></span>
</div>
diff --git a/forms/common-themes/src/main/resources/theme/base/login/login-idp-link-confirm.ftl b/forms/common-themes/src/main/resources/theme/base/login/login-idp-link-confirm.ftl
new file mode 100644
index 0000000..28b12d0
--- /dev/null
+++ b/forms/common-themes/src/main/resources/theme/base/login/login-idp-link-confirm.ftl
@@ -0,0 +1,21 @@
+<#import "template.ftl" as layout>
+<@layout.registrationLayout displayMessage=false; section>
+ <#if section = "title">
+ ${msg("confirmLinkIdpTitle")}
+ <#elseif section = "header">
+ ${msg("confirmLinkIdpTitle")}
+ <#elseif section = "form">
+ <div id="kc-error-message">
+ <p class="instruction">${message.summary}</p>
+ </div>
+
+ <form id="kc-register-form" class="${properties.kcFormClass!}" action="${url.loginAction}" method="post">
+
+ <div id="kc-form-buttons" class="${properties.kcFormGroupClass!}">
+ <button type="submit" class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonLargeClass!}" name="submitAction" value="updateProfile">${msg("confirmLinkIdpUpdateProfile")}</button>
+ <button type="submit" class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonLargeClass!}" name="submitAction" value="linkAccount">${msg("confirmLinkIdpContinue", idpAlias)}</button>
+ </div>
+
+ </form>
+ </#if>
+</@layout.registrationLayout>
\ No newline at end of file
diff --git a/forms/common-themes/src/main/resources/theme/base/login/login-idp-link-email.ftl b/forms/common-themes/src/main/resources/theme/base/login/login-idp-link-email.ftl
new file mode 100644
index 0000000..ab3e83e
--- /dev/null
+++ b/forms/common-themes/src/main/resources/theme/base/login/login-idp-link-email.ftl
@@ -0,0 +1,15 @@
+<#import "template.ftl" as layout>
+<@layout.registrationLayout; section>
+ <#if section = "title">
+ ${msg("emailLinkIdpTitle", idpAlias)}
+ <#elseif section = "header">
+ ${msg("emailLinkIdpTitle", idpAlias)}
+ <#elseif section = "form">
+ <p class="instruction">
+ ${msg("emailLinkIdp1", idpAlias, brokerContext.username, realm.name)}
+ </p>
+ <p class="instruction">
+ ${msg("emailLinkIdp2")} <a href="${url.firstBrokerLoginUrl}">${msg("doClickHere")}</a> ${msg("emailLinkIdp3")}
+ </p>
+ </#if>
+</@layout.registrationLayout>
\ No newline at end of file
diff --git a/forms/common-themes/src/main/resources/theme/base/login/login-update-profile.ftl b/forms/common-themes/src/main/resources/theme/base/login/login-update-profile.ftl
index 584bea3..458884c 100755
--- a/forms/common-themes/src/main/resources/theme/base/login/login-update-profile.ftl
+++ b/forms/common-themes/src/main/resources/theme/base/login/login-update-profile.ftl
@@ -6,7 +6,7 @@
${msg("loginProfileTitle")}
<#elseif section = "form">
<form id="kc-update-profile-form" class="${properties.kcFormClass!}" action="${url.loginAction}" method="post">
- <#if realm.editUsernameAllowed>
+ <#if user.editUsernameAllowed>
<div class="${properties.kcFormGroupClass!} ${messagesPerField.printIfExists('username',properties.kcFormGroupErrorClass!)}">
<div class="${properties.kcLabelWrapperClass!}">
<label for="username" class="${properties.kcLabelClass!}">${msg("username")}</label>
diff --git a/forms/common-themes/src/main/resources/theme/base/login/messages/messages_en.properties b/forms/common-themes/src/main/resources/theme/base/login/messages/messages_en.properties
index af89688..ca18e21 100644
--- a/forms/common-themes/src/main/resources/theme/base/login/messages/messages_en.properties
+++ b/forms/common-themes/src/main/resources/theme/base/login/messages/messages_en.properties
@@ -79,6 +79,11 @@ emailVerifyInstruction1=An email with instructions to verify your email address
emailVerifyInstruction2=Haven''t received a verification code in your email?
emailVerifyInstruction3=to re-send the email.
+emailLinkIdpTitle=Link {0}
+emailLinkIdp1=An email with instructions to link {0} account {1} with your {2} account has been sent to you.
+emailLinkIdp2=Haven''t received a verification code in your email?
+emailLinkIdp3=to re-send the email.
+
backToLogin=« Back to Login
emailInstruction=Enter your username or email address and we will send you instructions on how to create a new password.
@@ -132,13 +137,19 @@ invalidTotpMessage=Invalid authenticator code.
usernameExistsMessage=Username already exists.
emailExistsMessage=Email already exists.
-federatedIdentityEmailExistsMessage=User with email already exists. Please login to account management to link the account.
-federatedIdentityUsernameExistsMessage=User with username already exists. Please login to account management to link the account.
+federatedIdentityExistsMessage=User with {0} {1} already exists. Please login to account management to link the account.
+
+confirmLinkIdpTitle=Account already exists
+federatedIdentityConfirmLinkMessage=User with {0} {1} already exists. How do you want to continue?
+federatedIdentityConfirmReauthenticateMessage=Authenticate as {0} to link your account with {1}
+confirmLinkIdpUpdateProfile=Update profile info
+confirmLinkIdpContinue=Link {0} with existing account
configureTotpMessage=You need to set up Mobile Authenticator to activate your account.
updateProfileMessage=You need to update your user profile to activate your account.
updatePasswordMessage=You need to change your password to activate your account.
verifyEmailMessage=You need to verify your email address to activate your account.
+linkIdpMessage=You need to verify your email address to link your account with {0}.
emailSentMessage=You should receive an email shortly with further instructions.
emailSendErrorMessage=Failed to send email, please try again later.
@@ -181,6 +192,7 @@ couldNotObtainTokenMessage=Could not obtain token from identity provider.
unexpectedErrorRetrievingTokenMessage=Unexpected error when retrieving token from identity provider.
unexpectedErrorHandlingResponseMessage=Unexpected error when handling response from identity provider.
identityProviderAuthenticationFailedMessage=Authentication failed. Could not authenticate with identity provider.
+identityProviderDifferentUserMessage=Authenticated as {0}, but expected to be authenticated as {1}
couldNotSendAuthenticationRequestMessage=Could not send authentication request to identity provider.
unexpectedErrorHandlingRequestMessage=Unexpected error when handling authentication request to identity provider.
invalidAccessCodeMessage=Invalid access code.
@@ -188,6 +200,7 @@ sessionNotActiveMessage=Session not active.
invalidCodeMessage=An error occurred, please login again through your application.
identityProviderUnexpectedErrorMessage=Unexpected error when authenticating with identity provider
identityProviderNotFoundMessage=Could not find an identity provider with the identifier.
+identityProviderLinkSuccess=Your account was successfully linked with {0} account {1} .
realmSupportsNoCredentialsMessage=Realm does not support any credential type.
identityProviderNotUniqueMessage=Realm supports multiple identity providers. Could not determine which identity provider should be used to authenticate with.
emailVerifiedMessage=Your email address has been verified.
diff --git a/forms/common-themes/src/main/resources/theme/keycloak/email/html/identity-provider-link.ftl b/forms/common-themes/src/main/resources/theme/keycloak/email/html/identity-provider-link.ftl
new file mode 100644
index 0000000..9c2db80
--- /dev/null
+++ b/forms/common-themes/src/main/resources/theme/keycloak/email/html/identity-provider-link.ftl
@@ -0,0 +1,5 @@
+<html>
+<body>
+${msg("identityProviderLinkBodyHtml", identityProviderAlias, realmName, identityProviderContext.username, link, linkExpiration)}
+</body>
+</html>
\ No newline at end of file
diff --git a/forms/common-themes/src/main/resources/theme/keycloak/email/messages/messages_en.properties b/forms/common-themes/src/main/resources/theme/keycloak/email/messages/messages_en.properties
index fd9d909..f4a945c 100755
--- a/forms/common-themes/src/main/resources/theme/keycloak/email/messages/messages_en.properties
+++ b/forms/common-themes/src/main/resources/theme/keycloak/email/messages/messages_en.properties
@@ -1,6 +1,9 @@
emailVerificationSubject=Verify email
emailVerificationBody=Someone has created a {2} account with this email address. If this was you, click the link below to verify your email address\n\n{0}\n\nThis link will expire within {1} minutes.\n\nIf you didn''t create this account, just ignore this message.
emailVerificationBodyHtml=<p>Someone has created a {2} account with this email address. If this was you, click the link below to verify your email address</p><p><a href="{0}">{0}</a></p><p>This link will expire within {1} minutes.</p><p>If you didn''t create this account, just ignore this message.</p>
+identityProviderLinkSubject=Link {0}
+identityProviderLinkBody=Someone wants to link your "{1}" account with "{0}" account of user {2} . If this was you, click the link below to link accounts\n\n{3}\n\nThis link will expire within {4} minutes.\n\nIf you don''t want to link account, just ignore this message. If you link accounts, you will be able to login to {1} through {0}.
+identityProviderLinkBodyHtml=<p>Someone wants to link your <b>{1}</b> account with <b>{0}</b> account of user {2} . If this was you, click the link below to link accounts</p><p><a href="{3}">{3}</a></p><p>This link will expire within {4} minutes.</p><p>If you don''t want to link account, just ignore this message. If you link accounts, you will be able to login to {1} through {0}.</p>
passwordResetSubject=Reset password
passwordResetBody=Someone just requested to change your {2} account''s credentials. If this was you, click on the link below to reset them.\n\n{0}\n\nThis link and code will expire within {1} minutes.\n\nIf you don''t want to reset your credentials, just ignore this message and nothing will be changed.
passwordResetBodyHtml=<p>Someone just requested to change your {2} account''s credentials. If this was you, click on the link below to reset them.</p><p><a href="{0}">{0}</a></p><p>This link will expire within {1} minutes.</p><p>If you don''t want to reset your credentials, just ignore this message and nothing will be changed.</p>
diff --git a/forms/common-themes/src/main/resources/theme/keycloak/email/text/identity-provider-link.ftl b/forms/common-themes/src/main/resources/theme/keycloak/email/text/identity-provider-link.ftl
new file mode 100644
index 0000000..a8c0d54
--- /dev/null
+++ b/forms/common-themes/src/main/resources/theme/keycloak/email/text/identity-provider-link.ftl
@@ -0,0 +1 @@
+${msg("identityProviderLinkBody", identityProviderAlias, realmName, identityProviderContext.username, link, linkExpiration)}
\ No newline at end of file
diff --git a/forms/email-api/src/main/java/org/keycloak/email/EmailProvider.java b/forms/email-api/src/main/java/org/keycloak/email/EmailProvider.java
index 2304ce5..151eec7 100755
--- a/forms/email-api/src/main/java/org/keycloak/email/EmailProvider.java
+++ b/forms/email-api/src/main/java/org/keycloak/email/EmailProvider.java
@@ -10,10 +10,14 @@ import org.keycloak.provider.Provider;
*/
public interface EmailProvider extends Provider {
+ String IDENTITY_PROVIDER_BROKER_CONTEXT = "identityProviderBrokerCtx";
+
public EmailProvider setRealm(RealmModel realm);
public EmailProvider setUser(UserModel user);
+ public EmailProvider setAttribute(String name, Object value);
+
public void sendEvent(Event event) throws EmailException;
/**
@@ -26,6 +30,11 @@ public interface EmailProvider extends Provider {
public void sendPasswordReset(String link, long expirationInMinutes) throws EmailException;
/**
+ * Send to confirm that user wants to link his account with identity broker link
+ */
+ void sendConfirmIdentityBrokerLink(String link, long expirationInMinutes) throws EmailException;
+
+ /**
* Change password email requested by admin
*
* @param link
diff --git a/forms/email-freemarker/src/main/java/org/keycloak/email/freemarker/FreeMarkerEmailProvider.java b/forms/email-freemarker/src/main/java/org/keycloak/email/freemarker/FreeMarkerEmailProvider.java
index 9f55e37..3a04fbf 100755
--- a/forms/email-freemarker/src/main/java/org/keycloak/email/freemarker/FreeMarkerEmailProvider.java
+++ b/forms/email-freemarker/src/main/java/org/keycloak/email/freemarker/FreeMarkerEmailProvider.java
@@ -1,8 +1,11 @@
package org.keycloak.email.freemarker;
import java.text.MessageFormat;
+import java.util.Arrays;
+import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
+import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Properties;
@@ -17,6 +20,7 @@ import javax.mail.internet.MimeMessage;
import javax.mail.internet.MimeMultipart;
import org.jboss.logging.Logger;
+import org.keycloak.broker.provider.BrokeredIdentityContext;
import org.keycloak.email.EmailException;
import org.keycloak.email.EmailProvider;
import org.keycloak.email.freemarker.beans.EventBean;
@@ -28,6 +32,7 @@ import org.keycloak.freemarker.FreeMarkerUtil;
import org.keycloak.freemarker.Theme;
import org.keycloak.freemarker.ThemeProvider;
import org.keycloak.freemarker.beans.MessageFormatterMethod;
+import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
@@ -43,6 +48,7 @@ public class FreeMarkerEmailProvider implements EmailProvider {
private FreeMarkerUtil freeMarker;
private RealmModel realm;
private UserModel user;
+ private final Map<String, Object> attributes = new HashMap<String, Object>();
public FreeMarkerEmailProvider(KeycloakSession session, FreeMarkerUtil freeMarker) {
this.session = session;
@@ -62,6 +68,12 @@ public class FreeMarkerEmailProvider implements EmailProvider {
}
@Override
+ public EmailProvider setAttribute(String name, Object value) {
+ attributes.put(name, value);
+ return this;
+ }
+
+ @Override
public void sendEvent(Event event) throws EmailException {
Map<String, Object> attributes = new HashMap<String, Object>();
attributes.put("user", new ProfileBean(user));
@@ -84,6 +96,27 @@ public class FreeMarkerEmailProvider implements EmailProvider {
}
@Override
+ public void sendConfirmIdentityBrokerLink(String link, long expirationInMinutes) throws EmailException {
+ Map<String, Object> attributes = new HashMap<String, Object>();
+ attributes.put("user", new ProfileBean(user));
+ attributes.put("link", link);
+ attributes.put("linkExpiration", expirationInMinutes);
+
+ String realmName = realm.getName().substring(0, 1).toUpperCase() + realm.getName().substring(1);
+ attributes.put("realmName", realmName);
+
+ BrokeredIdentityContext brokerContext = (BrokeredIdentityContext) this.attributes.get(IDENTITY_PROVIDER_BROKER_CONTEXT);
+ String idpAlias = brokerContext.getIdpConfig().getAlias();
+ idpAlias = idpAlias.substring(0, 1).toUpperCase() + idpAlias.substring(1);
+
+ attributes.put("identityProviderContext", brokerContext);
+ attributes.put("identityProviderAlias", idpAlias);
+
+ List<Object> subjectAttrs = Arrays.<Object>asList(idpAlias);
+ send("identityProviderLinkSubject", subjectAttrs, "identity-provider-link.ftl", attributes);
+ }
+
+ @Override
public void sendExecuteActions(String link, long expirationInMinutes) throws EmailException {
Map<String, Object> attributes = new HashMap<String, Object>();
attributes.put("user", new ProfileBean(user));
@@ -111,6 +144,10 @@ public class FreeMarkerEmailProvider implements EmailProvider {
}
private void send(String subjectKey, String template, Map<String, Object> attributes) throws EmailException {
+ send(subjectKey, Collections.emptyList(), template, attributes);
+ }
+
+ private void send(String subjectKey, List<Object> subjectAttributes, String template, Map<String, Object> attributes) throws EmailException {
try {
ThemeProvider themeProvider = session.getProvider(ThemeProvider.class, "extending");
Theme theme = themeProvider.getTheme(realm.getEmailTheme(), Theme.Type.EMAIL);
@@ -118,7 +155,7 @@ public class FreeMarkerEmailProvider implements EmailProvider {
attributes.put("locale", locale);
Properties rb = theme.getMessages(locale);
attributes.put("msg", new MessageFormatterMethod(locale, rb));
- String subject = new MessageFormat(rb.getProperty(subjectKey,subjectKey),locale).format(new Object[0]);
+ String subject = new MessageFormat(rb.getProperty(subjectKey,subjectKey),locale).format(subjectAttributes.toArray());
String textTemplate = String.format("text/%s", template);
String textBody;
try {
diff --git a/forms/login-api/src/main/java/org/keycloak/login/LoginFormsPages.java b/forms/login-api/src/main/java/org/keycloak/login/LoginFormsPages.java
index e34b0ef..9a44db4 100644
--- a/forms/login-api/src/main/java/org/keycloak/login/LoginFormsPages.java
+++ b/forms/login-api/src/main/java/org/keycloak/login/LoginFormsPages.java
@@ -5,6 +5,8 @@ package org.keycloak.login;
*/
public enum LoginFormsPages {
- LOGIN, LOGIN_TOTP, LOGIN_CONFIG_TOTP, LOGIN_VERIFY_EMAIL, OAUTH_GRANT, LOGIN_RESET_PASSWORD, LOGIN_UPDATE_PASSWORD, REGISTER, INFO, ERROR, LOGIN_UPDATE_PROFILE, CODE;
+ LOGIN, LOGIN_TOTP, LOGIN_CONFIG_TOTP, LOGIN_VERIFY_EMAIL,
+ LOGIN_IDP_LINK_CONFIRM, LOGIN_IDP_LINK_EMAIL,
+ OAUTH_GRANT, LOGIN_RESET_PASSWORD, LOGIN_UPDATE_PASSWORD, REGISTER, INFO, ERROR, LOGIN_UPDATE_PROFILE, CODE;
}
diff --git a/forms/login-api/src/main/java/org/keycloak/login/LoginFormsProvider.java b/forms/login-api/src/main/java/org/keycloak/login/LoginFormsProvider.java
index fccfce1..eaeb4b4 100755
--- a/forms/login-api/src/main/java/org/keycloak/login/LoginFormsProvider.java
+++ b/forms/login-api/src/main/java/org/keycloak/login/LoginFormsProvider.java
@@ -23,6 +23,13 @@ import org.keycloak.provider.Provider;
*/
public interface LoginFormsProvider extends Provider {
+ String UPDATE_PROFILE_CONTEXT_ATTR = "updateProfileCtx";
+
+ String IDENTITY_PROVIDER_BROKER_CONTEXT = "identityProviderBrokerCtx";
+
+ String USERNAME_EDIT_DISABLED = "usernameEditDisabled";
+
+
/**
* Adds a script to the html header
*
@@ -44,6 +51,12 @@ public interface LoginFormsProvider extends Provider {
public Response createInfoPage();
+ public Response createUpdateProfilePage();
+
+ public Response createIdpLinkConfirmLinkPage();
+
+ public Response createIdpLinkEmailPage();
+
public Response createErrorPage();
public Response createOAuthGrant(ClientSessionModel clientSessionModel);
diff --git a/forms/login-freemarker/src/main/java/org/keycloak/login/freemarker/FreeMarkerLoginFormsProvider.java b/forms/login-freemarker/src/main/java/org/keycloak/login/freemarker/FreeMarkerLoginFormsProvider.java
index 6125d0b..ef8cc74 100755
--- a/forms/login-freemarker/src/main/java/org/keycloak/login/freemarker/FreeMarkerLoginFormsProvider.java
+++ b/forms/login-freemarker/src/main/java/org/keycloak/login/freemarker/FreeMarkerLoginFormsProvider.java
@@ -19,6 +19,9 @@ package org.keycloak.login.freemarker;
import org.jboss.logging.Logger;
import org.jboss.resteasy.specimpl.MultivaluedMapImpl;
import org.keycloak.OAuth2Constants;
+import org.keycloak.authentication.requiredactions.util.UpdateProfileContext;
+import org.keycloak.authentication.requiredactions.util.UserUpdateProfileContext;
+import org.keycloak.broker.provider.BrokeredIdentityContext;
import org.keycloak.email.EmailException;
import org.keycloak.email.EmailProvider;
import org.keycloak.freemarker.BrowserSecurityHeaderSetup;
@@ -48,6 +51,7 @@ import org.keycloak.login.freemarker.model.UrlBean;
import org.keycloak.models.ClientModel;
import org.keycloak.models.ClientSessionModel;
import org.keycloak.models.Constants;
+import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.ProtocolMapperModel;
import org.keycloak.models.RealmModel;
@@ -129,6 +133,9 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider {
page = LoginFormsPages.LOGIN_CONFIG_TOTP;
break;
case UPDATE_PROFILE:
+ UpdateProfileContext userBasedContext = new UserUpdateProfileContext(realm, user);
+ this.attributes.put(UPDATE_PROFILE_CONTEXT_ATTR, userBasedContext);
+
actionMessage = Messages.UPDATE_PROFILE;
page = LoginFormsPages.LOGIN_UPDATE_PROFILE;
break;
@@ -140,7 +147,7 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider {
try {
UriBuilder builder = Urls.loginActionEmailVerificationBuilder(uriInfo.getBaseUri());
builder.queryParam(OAuth2Constants.CODE, accessCode);
- builder.queryParam("key", clientSession.getNote(Constants.VERIFY_EMAIL_KEY));
+ builder.queryParam(Constants.KEY, clientSession.getNote(Constants.VERIFY_EMAIL_KEY));
String link = builder.build(realm.getName()).toString();
long expiration = TimeUnit.SECONDS.toMinutes(realm.getAccessCodeLifespanUserAction());
@@ -222,6 +229,8 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider {
}
}
attributes.put("message", wholeMessage);
+ } else {
+ attributes.put("message", null);
}
attributes.put("messagesPerField", messagesPerField);
@@ -237,7 +246,11 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider {
if (realm != null) {
attributes.put("realm", new RealmBean(realm));
- attributes.put("social", new IdentityProviderBean(realm, baseUri, uriInfo));
+
+ List<IdentityProviderModel> identityProviders = realm.getIdentityProviders();
+ identityProviders = LoginFormsUtil.filterIdentityProviders(identityProviders, session, realm, attributes, formData);
+ attributes.put("social", new IdentityProviderBean(realm, identityProviders, baseUri, uriInfo));
+
attributes.put("url", new UrlBean(realm, theme, baseUri, this.actionUri));
if (realm.isInternationalizationEnabled()) {
@@ -268,7 +281,17 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider {
attributes.put("totp", new TotpBean(realm, user, baseUri));
break;
case LOGIN_UPDATE_PROFILE:
- attributes.put("user", new ProfileBean(user, formData));
+ UpdateProfileContext userCtx = (UpdateProfileContext) attributes.get(LoginFormsProvider.UPDATE_PROFILE_CONTEXT_ATTR);
+ attributes.put("user", new ProfileBean(userCtx, formData));
+ break;
+ case LOGIN_IDP_LINK_CONFIRM:
+ case LOGIN_IDP_LINK_EMAIL:
+ BrokeredIdentityContext brokerContext = (BrokeredIdentityContext) this.attributes.get(IDENTITY_PROVIDER_BROKER_CONTEXT);
+ String idpAlias = brokerContext.getIdpConfig().getAlias();
+ idpAlias = idpAlias.substring(0, 1).toUpperCase() + idpAlias.substring(1);
+
+ attributes.put("brokerContext", brokerContext);
+ attributes.put("idpAlias", idpAlias);
break;
case REGISTER:
attributes.put("register", new RegisterBean(formData));
@@ -371,7 +394,11 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider {
if (realm != null) {
attributes.put("realm", new RealmBean(realm));
- attributes.put("social", new IdentityProviderBean(realm, baseUri, uriInfo));
+
+ List<IdentityProviderModel> identityProviders = realm.getIdentityProviders();
+ identityProviders = LoginFormsUtil.filterIdentityProviders(identityProviders, session, realm, attributes, formData);
+ attributes.put("social", new IdentityProviderBean(realm, identityProviders, baseUri, uriInfo));
+
attributes.put("url", new UrlBean(realm, theme, baseUri, this.actionUri));
attributes.put("requiredActionUrl", new RequiredActionUrlFormatterMethod(realm, baseUri));
@@ -424,6 +451,32 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider {
}
@Override
+ public Response createUpdateProfilePage() {
+ // Don't display initial message if we already have some errors
+ if (messageType != MessageType.ERROR) {
+ setMessage(MessageType.WARNING, Messages.UPDATE_PROFILE);
+ }
+
+ return createResponse(LoginFormsPages.LOGIN_UPDATE_PROFILE);
+ }
+
+
+ @Override
+ public Response createIdpLinkConfirmLinkPage() {
+ return createResponse(LoginFormsPages.LOGIN_IDP_LINK_CONFIRM);
+ }
+
+ @Override
+ public Response createIdpLinkEmailPage() {
+ BrokeredIdentityContext brokerContext = (BrokeredIdentityContext) this.attributes.get(IDENTITY_PROVIDER_BROKER_CONTEXT);
+ String idpAlias = brokerContext.getIdpConfig().getAlias();
+ idpAlias = idpAlias.substring(0, 1).toUpperCase() + idpAlias.substring(1);
+ setMessage(MessageType.WARNING, Messages.LINK_IDP, idpAlias);
+
+ return createResponse(LoginFormsPages.LOGIN_IDP_LINK_EMAIL);
+ }
+
+ @Override
public Response createErrorPage() {
if (status == null) {
status = Response.Status.INTERNAL_SERVER_ERROR;
diff --git a/forms/login-freemarker/src/main/java/org/keycloak/login/freemarker/LoginFormsUtil.java b/forms/login-freemarker/src/main/java/org/keycloak/login/freemarker/LoginFormsUtil.java
new file mode 100644
index 0000000..bbcf1c0
--- /dev/null
+++ b/forms/login-freemarker/src/main/java/org/keycloak/login/freemarker/LoginFormsUtil.java
@@ -0,0 +1,58 @@
+package org.keycloak.login.freemarker;
+
+import java.util.HashSet;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import javax.ws.rs.core.MultivaluedMap;
+
+import org.keycloak.login.LoginFormsProvider;
+import org.keycloak.models.FederatedIdentityModel;
+import org.keycloak.models.IdentityProviderModel;
+import org.keycloak.models.KeycloakSession;
+import org.keycloak.models.RealmModel;
+import org.keycloak.models.UserModel;
+
+/**
+ * Various util methods, so the logic is not hardcoded in freemarker beans
+ *
+ * @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
+ */
+public class LoginFormsUtil {
+
+ // Display just those identityProviders on login screen, which are already linked to "known" established user
+ public static List<IdentityProviderModel> filterIdentityProviders(List<IdentityProviderModel> providers, KeycloakSession session, RealmModel realm,
+ Map<String, Object> attributes, MultivaluedMap<String, String> formData) {
+
+ Boolean usernameEditDisabled = (Boolean) attributes.get(LoginFormsProvider.USERNAME_EDIT_DISABLED);
+ if (usernameEditDisabled != null && usernameEditDisabled) {
+ String username = formData.getFirst(UserModel.USERNAME);
+ if (username == null) {
+ throw new IllegalStateException("USERNAME_EDIT_DISABLED but username not known");
+ }
+
+ UserModel user = session.users().getUserByUsername(username, realm);
+ if (user == null || !user.isEnabled()) {
+ throw new IllegalStateException("User " + username + " not found or disabled");
+ }
+
+ Set<FederatedIdentityModel> fedLinks = session.users().getFederatedIdentities(user, realm);
+ Set<String> federatedIdentities = new HashSet<>();
+ for (FederatedIdentityModel fedLink : fedLinks) {
+ federatedIdentities.add(fedLink.getIdentityProvider());
+ }
+
+ List<IdentityProviderModel> result = new LinkedList<>();
+ for (IdentityProviderModel idp : providers) {
+ if (federatedIdentities.contains(idp.getAlias())) {
+ result.add(idp);
+ }
+ }
+ return result;
+ } else {
+ return providers;
+ }
+ }
+}
diff --git a/forms/login-freemarker/src/main/java/org/keycloak/login/freemarker/model/IdentityProviderBean.java b/forms/login-freemarker/src/main/java/org/keycloak/login/freemarker/model/IdentityProviderBean.java
index 23c6e69..4d4f1e3 100755
--- a/forms/login-freemarker/src/main/java/org/keycloak/login/freemarker/model/IdentityProviderBean.java
+++ b/forms/login-freemarker/src/main/java/org/keycloak/login/freemarker/model/IdentityProviderBean.java
@@ -44,9 +44,8 @@ public class IdentityProviderBean {
private List<IdentityProvider> providers;
private RealmModel realm;
- public IdentityProviderBean(RealmModel realm, URI baseURI, UriInfo uriInfo) {
+ public IdentityProviderBean(RealmModel realm, List<IdentityProviderModel> identityProviders, URI baseURI, UriInfo uriInfo) {
this.realm = realm;
- List<IdentityProviderModel> identityProviders = realm.getIdentityProviders();
if (!identityProviders.isEmpty()) {
Set<IdentityProvider> orderedSet = new TreeSet<>(IdentityProviderComparator.INSTANCE);
@@ -57,7 +56,7 @@ public class IdentityProviderBean {
}
if (!orderedSet.isEmpty()) {
- providers = new LinkedList<IdentityProvider>(orderedSet);
+ providers = new LinkedList<>(orderedSet);
displaySocial = true;
}
}
diff --git a/forms/login-freemarker/src/main/java/org/keycloak/login/freemarker/model/ProfileBean.java b/forms/login-freemarker/src/main/java/org/keycloak/login/freemarker/model/ProfileBean.java
index e730c14..b2d6b1d 100755
--- a/forms/login-freemarker/src/main/java/org/keycloak/login/freemarker/model/ProfileBean.java
+++ b/forms/login-freemarker/src/main/java/org/keycloak/login/freemarker/model/ProfileBean.java
@@ -28,7 +28,7 @@ import java.util.Map;
import javax.ws.rs.core.MultivaluedMap;
import org.jboss.logging.Logger;
-import org.keycloak.models.UserModel;
+import org.keycloak.authentication.requiredactions.util.UpdateProfileContext;
/**
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
@@ -38,12 +38,12 @@ public class ProfileBean {
private static final Logger logger = Logger.getLogger(ProfileBean.class);
- private UserModel user;
+ private UpdateProfileContext user;
private MultivaluedMap<String, String> formData;
private final Map<String, String> attributes = new HashMap<>();
- public ProfileBean(UserModel user, MultivaluedMap<String, String> formData) {
+ public ProfileBean(UpdateProfileContext user, MultivaluedMap<String, String> formData) {
this.user = user;
this.formData = formData;
@@ -70,6 +70,10 @@ public class ProfileBean {
}
+ public boolean isEditUsernameAllowed() {
+ return user.isEditUsernameAllowed();
+ }
+
public String getUsername() { return formData != null ? formData.getFirst("username") : user.getUsername(); }
public String getFirstName() {
diff --git a/forms/login-freemarker/src/main/java/org/keycloak/login/freemarker/model/UrlBean.java b/forms/login-freemarker/src/main/java/org/keycloak/login/freemarker/model/UrlBean.java
index 25ebc83..ff83c43 100755
--- a/forms/login-freemarker/src/main/java/org/keycloak/login/freemarker/model/UrlBean.java
+++ b/forms/login-freemarker/src/main/java/org/keycloak/login/freemarker/model/UrlBean.java
@@ -90,6 +90,10 @@ public class UrlBean {
return Urls.loginActionEmailVerification(baseURI, realm).toString();
}
+ public String getFirstBrokerLoginUrl() {
+ return Urls.firstBrokerLoginProcessor(baseURI, realm).toString();
+ }
+
public String getOauthAction() {
if (this.actionuri != null) {
return this.actionuri.getPath();
diff --git a/forms/login-freemarker/src/main/java/org/keycloak/login/freemarker/Templates.java b/forms/login-freemarker/src/main/java/org/keycloak/login/freemarker/Templates.java
index 785b186..e63f49f 100644
--- a/forms/login-freemarker/src/main/java/org/keycloak/login/freemarker/Templates.java
+++ b/forms/login-freemarker/src/main/java/org/keycloak/login/freemarker/Templates.java
@@ -17,6 +17,10 @@ public class Templates {
return "login-config-totp.ftl";
case LOGIN_VERIFY_EMAIL:
return "login-verify-email.ftl";
+ case LOGIN_IDP_LINK_CONFIRM:
+ return "login-idp-link-confirm.ftl";
+ case LOGIN_IDP_LINK_EMAIL:
+ return "login-idp-link-email.ftl";
case OAUTH_GRANT:
return "login-oauth-grant.ftl";
case LOGIN_RESET_PASSWORD:
diff --git a/model/api/src/main/java/org/keycloak/models/Constants.java b/model/api/src/main/java/org/keycloak/models/Constants.java
index 8977def..3ecc51c 100755
--- a/model/api/src/main/java/org/keycloak/models/Constants.java
+++ b/model/api/src/main/java/org/keycloak/models/Constants.java
@@ -23,5 +23,6 @@ public interface Constants {
// 30 days
int DEFAULT_OFFLINE_SESSION_IDLE_TIMEOUT = 2592000;
- public static final String VERIFY_EMAIL_KEY = "VERIFY_EMAIL_KEY";
+ String VERIFY_EMAIL_KEY = "VERIFY_EMAIL_KEY";
+ String KEY = "key";
}
diff --git a/model/api/src/main/java/org/keycloak/models/entities/IdentityProviderEntity.java b/model/api/src/main/java/org/keycloak/models/entities/IdentityProviderEntity.java
index c4cdd62..4ea6602 100755
--- a/model/api/src/main/java/org/keycloak/models/entities/IdentityProviderEntity.java
+++ b/model/api/src/main/java/org/keycloak/models/entities/IdentityProviderEntity.java
@@ -35,6 +35,7 @@ public class IdentityProviderEntity {
private boolean storeToken;
protected boolean addReadTokenRoleOnCreate;
private boolean authenticateByDefault;
+ private String firstBrokerLoginFlowId;
private Map<String, String> config = new HashMap<String, String>();
@@ -78,6 +79,14 @@ public class IdentityProviderEntity {
this.authenticateByDefault = authenticateByDefault;
}
+ public String getFirstBrokerLoginFlowId() {
+ return firstBrokerLoginFlowId;
+ }
+
+ public void setFirstBrokerLoginFlowId(String firstBrokerLoginFlowId) {
+ this.firstBrokerLoginFlowId = firstBrokerLoginFlowId;
+ }
+
public boolean isStoreToken() {
return this.storeToken;
}
diff --git a/model/api/src/main/java/org/keycloak/models/IdentityProviderModel.java b/model/api/src/main/java/org/keycloak/models/IdentityProviderModel.java
index fa3eb71..288b1f0 100755
--- a/model/api/src/main/java/org/keycloak/models/IdentityProviderModel.java
+++ b/model/api/src/main/java/org/keycloak/models/IdentityProviderModel.java
@@ -64,6 +64,8 @@ public class IdentityProviderModel implements Serializable {
*/
private boolean authenticateByDefault;
+ private String firstBrokerLoginFlowId;
+
/**
* <p>A map containing the configuration and properties for a specific identity provider instance and implementation. The items
* in the map are understood by the identity provider implementation.</p>
@@ -84,6 +86,7 @@ public class IdentityProviderModel implements Serializable {
this.storeToken = model.isStoreToken();
this.authenticateByDefault = model.isAuthenticateByDefault();
this.addReadTokenRoleOnCreate = model.addReadTokenRoleOnCreate;
+ this.firstBrokerLoginFlowId = model.getFirstBrokerLoginFlowId();
}
public String getInternalId() {
@@ -148,6 +151,14 @@ public class IdentityProviderModel implements Serializable {
this.authenticateByDefault = authenticateByDefault;
}
+ public String getFirstBrokerLoginFlowId() {
+ return firstBrokerLoginFlowId;
+ }
+
+ public void setFirstBrokerLoginFlowId(String firstBrokerLoginFlowId) {
+ this.firstBrokerLoginFlowId = firstBrokerLoginFlowId;
+ }
+
public Map<String, String> getConfig() {
return this.config;
}
diff --git a/model/api/src/main/java/org/keycloak/models/utils/DefaultAuthenticationFlows.java b/model/api/src/main/java/org/keycloak/models/utils/DefaultAuthenticationFlows.java
index 9992af3..a88e805 100755
--- a/model/api/src/main/java/org/keycloak/models/utils/DefaultAuthenticationFlows.java
+++ b/model/api/src/main/java/org/keycloak/models/utils/DefaultAuthenticationFlows.java
@@ -19,6 +19,7 @@ public class DefaultAuthenticationFlows {
public static final String LOGIN_FORMS_FLOW = "forms";
public static final String CLIENT_AUTHENTICATION_FLOW = "clients";
+ public static final String FIRST_BROKER_LOGIN_FLOW = "first broker login";
public static void addFlows(RealmModel realm) {
if (realm.getFlowByAlias(BROWSER_FLOW) == null) browserFlow(realm);
@@ -26,6 +27,7 @@ public class DefaultAuthenticationFlows {
if (realm.getFlowByAlias(REGISTRATION_FLOW) == null) registrationFlow(realm);
if (realm.getFlowByAlias(RESET_CREDENTIALS_FLOW) == null) resetCredentialsFlow(realm);
if (realm.getFlowByAlias(CLIENT_AUTHENTICATION_FLOW) == null) clientAuthFlow(realm);
+ if (realm.getFlowByAlias(FIRST_BROKER_LOGIN_FLOW) == null) firstBrokerLoginFlow(realm);
}
public static void migrateFlows(RealmModel realm) {
if (realm.getFlowByAlias(BROWSER_FLOW) == null) browserFlow(realm, true);
@@ -33,6 +35,7 @@ public class DefaultAuthenticationFlows {
if (realm.getFlowByAlias(REGISTRATION_FLOW) == null) registrationFlow(realm);
if (realm.getFlowByAlias(RESET_CREDENTIALS_FLOW) == null) resetCredentialsFlow(realm);
if (realm.getFlowByAlias(CLIENT_AUTHENTICATION_FLOW) == null) clientAuthFlow(realm);
+ if (realm.getFlowByAlias(FIRST_BROKER_LOGIN_FLOW) == null) firstBrokerLoginFlow(realm);
}
public static void registrationFlow(RealmModel realm) {
@@ -309,4 +312,98 @@ public class DefaultAuthenticationFlows {
execution.setAuthenticatorFlow(false);
realm.addAuthenticatorExecution(execution);
}
+
+ public static void firstBrokerLoginFlow(RealmModel realm) {
+ AuthenticationFlowModel firstBrokerLogin = new AuthenticationFlowModel();
+ firstBrokerLogin.setAlias(FIRST_BROKER_LOGIN_FLOW);
+ firstBrokerLogin.setDescription("Actions taken after first broker login with identity provider account, which is not yet linked to any Keycloak account");
+ firstBrokerLogin.setProviderId("basic-flow");
+ firstBrokerLogin.setTopLevel(true);
+ firstBrokerLogin.setBuiltIn(true);
+ firstBrokerLogin = realm.addAuthenticationFlow(firstBrokerLogin);
+ // realm.setClientAuthenticationFlow(clients);
+
+ AuthenticationExecutionModel execution = new AuthenticationExecutionModel();
+ execution.setParentFlow(firstBrokerLogin.getId());
+ execution.setRequirement(AuthenticationExecutionModel.Requirement.REQUIRED);
+ execution.setAuthenticator("idp-update-profile");
+ execution.setPriority(10);
+ execution.setAuthenticatorFlow(false);
+ realm.addAuthenticatorExecution(execution);
+
+ execution = new AuthenticationExecutionModel();
+ execution.setParentFlow(firstBrokerLogin.getId());
+ execution.setRequirement(AuthenticationExecutionModel.Requirement.ALTERNATIVE);
+ execution.setAuthenticator("idp-detect-duplications");
+ execution.setPriority(20);
+ execution.setAuthenticatorFlow(false);
+ realm.addAuthenticatorExecution(execution);
+
+ AuthenticationFlowModel linkExistingAccountFlow = new AuthenticationFlowModel();
+ linkExistingAccountFlow.setTopLevel(false);
+ linkExistingAccountFlow.setBuiltIn(true);
+ linkExistingAccountFlow.setAlias("Handle Existing Account");
+ linkExistingAccountFlow.setDescription("Handle what to do if there is existing account with same email/username like authenticated identity provider");
+ linkExistingAccountFlow.setProviderId("basic-flow");
+ linkExistingAccountFlow = realm.addAuthenticationFlow(linkExistingAccountFlow);
+ execution = new AuthenticationExecutionModel();
+ execution.setParentFlow(firstBrokerLogin.getId());
+ execution.setRequirement(AuthenticationExecutionModel.Requirement.ALTERNATIVE);
+ execution.setFlowId(linkExistingAccountFlow.getId());
+ execution.setPriority(30);
+ execution.setAuthenticatorFlow(true);
+ realm.addAuthenticatorExecution(execution);
+
+ execution = new AuthenticationExecutionModel();
+ execution.setParentFlow(linkExistingAccountFlow.getId());
+ execution.setRequirement(AuthenticationExecutionModel.Requirement.REQUIRED);
+ execution.setAuthenticator("idp-confirm-link");
+ execution.setPriority(10);
+ execution.setAuthenticatorFlow(false);
+ realm.addAuthenticatorExecution(execution);
+
+ execution = new AuthenticationExecutionModel();
+ execution.setParentFlow(linkExistingAccountFlow.getId());
+ execution.setRequirement(AuthenticationExecutionModel.Requirement.ALTERNATIVE);
+ execution.setAuthenticator("idp-email-verification");
+ execution.setPriority(20);
+ execution.setAuthenticatorFlow(false);
+ realm.addAuthenticatorExecution(execution);
+
+ AuthenticationFlowModel verifyByReauthenticationAccountFlow = new AuthenticationFlowModel();
+ verifyByReauthenticationAccountFlow.setTopLevel(false);
+ verifyByReauthenticationAccountFlow.setBuiltIn(true);
+ verifyByReauthenticationAccountFlow.setAlias("Verify Existing Account by Re-authentication");
+ verifyByReauthenticationAccountFlow.setDescription("Reauthentication of existing account");
+ verifyByReauthenticationAccountFlow.setProviderId("basic-flow");
+ verifyByReauthenticationAccountFlow = realm.addAuthenticationFlow(verifyByReauthenticationAccountFlow);
+ execution = new AuthenticationExecutionModel();
+ execution.setParentFlow(linkExistingAccountFlow.getId());
+ execution.setRequirement(AuthenticationExecutionModel.Requirement.ALTERNATIVE);
+ execution.setFlowId(verifyByReauthenticationAccountFlow.getId());
+ execution.setPriority(30);
+ execution.setAuthenticatorFlow(true);
+ realm.addAuthenticatorExecution(execution);
+
+ // password + otp
+ execution = new AuthenticationExecutionModel();
+ execution.setParentFlow(verifyByReauthenticationAccountFlow.getId());
+ execution.setRequirement(AuthenticationExecutionModel.Requirement.REQUIRED);
+ execution.setAuthenticator("idp-username-password-form");
+ execution.setPriority(10);
+ execution.setAuthenticatorFlow(false);
+ realm.addAuthenticatorExecution(execution);
+
+ execution = new AuthenticationExecutionModel();
+ execution.setParentFlow(verifyByReauthenticationAccountFlow.getId());
+ execution.setRequirement(AuthenticationExecutionModel.Requirement.OPTIONAL);
+ // TODO: read the requirement from browser authenticator
+// if (migrate && hasCredentialType(realm, RequiredCredentialModel.TOTP.getType())) {
+// execution.setRequirement(AuthenticationExecutionModel.Requirement.REQUIRED);
+// }
+ execution.setAuthenticator("auth-otp-form");
+ execution.setPriority(20);
+ execution.setAuthenticatorFlow(false);
+ realm.addAuthenticatorExecution(execution);
+ }
}
diff --git a/model/api/src/main/java/org/keycloak/models/utils/KeycloakModelUtils.java b/model/api/src/main/java/org/keycloak/models/utils/KeycloakModelUtils.java
index 4b630d8..5b6b0e1 100755
--- a/model/api/src/main/java/org/keycloak/models/utils/KeycloakModelUtils.java
+++ b/model/api/src/main/java/org/keycloak/models/utils/KeycloakModelUtils.java
@@ -3,6 +3,7 @@ package org.keycloak.models.utils;
import org.bouncycastle.openssl.PEMWriter;
import org.keycloak.models.ClientModel;
import org.keycloak.models.Constants;
+import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.models.KeycloakSessionTask;
@@ -198,9 +199,9 @@ public final class KeycloakModelUtils {
/**
* Deep search if given role is descendant of composite role
*
- * @param role role to check
+ * @param role role to check
* @param composite composite role
- * @param visited set of already visited roles (used for recursion)
+ * @param visited set of already visited roles (used for recursion)
* @return true if "role" is descendant of "composite"
*/
public static boolean searchFor(RoleModel role, RoleModel composite, Set<RoleModel> visited) {
@@ -218,14 +219,14 @@ public final class KeycloakModelUtils {
/**
* Try to find user by given username. If it fails, then fallback to find him by email
*
- * @param realm realm
+ * @param realm realm
* @param username username or email of user
* @return found user
*/
public static UserModel findUserByNameOrEmail(KeycloakSession session, RealmModel realm, String username) {
UserModel user = session.users().getUserByUsername(username, realm);
if (user == null && username.contains("@")) {
- user = session.users().getUserByEmail(username, realm);
+ user = session.users().getUserByEmail(username, realm);
}
return user;
}
@@ -265,7 +266,6 @@ public final class KeycloakModelUtils {
}
/**
- *
* @param roles
* @param targetRole
* @return true if targetRole is in roles (directly or indirectly via composite role)
@@ -284,8 +284,8 @@ public final class KeycloakModelUtils {
/**
* Ensure that displayName of myProvider (if not null) is unique and there is no other provider with same displayName in the list.
*
- * @param displayName to check for duplications
- * @param myProvider provider, which is excluded from the list (if present)
+ * @param displayName to check for duplications
+ * @param myProvider provider, which is excluded from the list (if present)
* @param federationProviders
* @throws ModelDuplicateException if there is other provider with same displayName
*/
diff --git a/model/api/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java b/model/api/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java
index 5fcb826..b975b56 100755
--- a/model/api/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java
+++ b/model/api/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java
@@ -202,7 +202,7 @@ public class ModelToRepresentation {
}
for (IdentityProviderModel provider : realm.getIdentityProviders()) {
- rep.addIdentityProvider(toRepresentation(provider));
+ rep.addIdentityProvider(toRepresentation(realm, provider));
}
for (IdentityProviderMapperModel mapper : realm.getIdentityProviderMappers()) {
@@ -381,7 +381,7 @@ public class ModelToRepresentation {
return rep;
}
- public static IdentityProviderRepresentation toRepresentation(IdentityProviderModel identityProviderModel) {
+ public static IdentityProviderRepresentation toRepresentation(RealmModel realm, IdentityProviderModel identityProviderModel) {
IdentityProviderRepresentation providerRep = new IdentityProviderRepresentation();
providerRep.setInternalId(identityProviderModel.getInternalId());
@@ -395,6 +395,15 @@ public class ModelToRepresentation {
providerRep.setConfig(identityProviderModel.getConfig());
providerRep.setAddReadTokenRoleOnCreate(identityProviderModel.isAddReadTokenRoleOnCreate());
+ String firstBrokerLoginFlowId = identityProviderModel.getFirstBrokerLoginFlowId();
+ if (firstBrokerLoginFlowId != null) {
+ AuthenticationFlowModel flow = realm.getAuthenticationFlowById(firstBrokerLoginFlowId);
+ if (flow == null) {
+ throw new ModelException("Couldn't find authentication flow with id " + firstBrokerLoginFlowId);
+ }
+ providerRep.setFirstBrokerLoginFlowAlias(flow.getAlias());
+ }
+
return providerRep;
}
diff --git a/model/api/src/main/java/org/keycloak/models/utils/RepresentationToModel.java b/model/api/src/main/java/org/keycloak/models/utils/RepresentationToModel.java
index 85b4ac0..110b376 100755
--- a/model/api/src/main/java/org/keycloak/models/utils/RepresentationToModel.java
+++ b/model/api/src/main/java/org/keycloak/models/utils/RepresentationToModel.java
@@ -164,6 +164,16 @@ public class RepresentationToModel {
if (rep.getOtpPolicyType() != null) newRealm.setOTPPolicy(toPolicy(rep));
else newRealm.setOTPPolicy(OTPPolicy.DEFAULT_POLICY);
+ importAuthenticationFlows(newRealm, rep);
+ if (rep.getRequiredActions() != null) {
+ for (RequiredActionProviderRepresentation action : rep.getRequiredActions()) {
+ RequiredActionProviderModel model = toModel(action);
+ newRealm.addRequiredActionProvider(model);
+ }
+ } else {
+ DefaultRequiredActions.addActions(newRealm);
+ }
+
importIdentityProviders(rep, newRealm);
importIdentityProviderMappers(rep, newRealm);
@@ -318,16 +328,6 @@ public class RepresentationToModel {
if(rep.getDefaultLocale() != null){
newRealm.setDefaultLocale(rep.getDefaultLocale());
}
-
- importAuthenticationFlows(newRealm, rep);
- if (rep.getRequiredActions() != null) {
- for (RequiredActionProviderRepresentation action : rep.getRequiredActions()) {
- RequiredActionProviderModel model = toModel(action);
- newRealm.addRequiredActionProvider(model);
- }
- } else {
- DefaultRequiredActions.addActions(newRealm);
- }
}
public static void importAuthenticationFlows(RealmModel newRealm, RealmRepresentation rep) {
@@ -1062,7 +1062,7 @@ public class RepresentationToModel {
private static void importIdentityProviders(RealmRepresentation rep, RealmModel newRealm) {
if (rep.getIdentityProviders() != null) {
for (IdentityProviderRepresentation representation : rep.getIdentityProviders()) {
- newRealm.addIdentityProvider(toModel(representation));
+ newRealm.addIdentityProvider(toModel(newRealm, representation));
}
}
}
@@ -1073,7 +1073,7 @@ public class RepresentationToModel {
}
}
}
- public static IdentityProviderModel toModel(IdentityProviderRepresentation representation) {
+ public static IdentityProviderModel toModel(RealmModel realm, IdentityProviderRepresentation representation) {
IdentityProviderModel identityProviderModel = new IdentityProviderModel();
identityProviderModel.setInternalId(representation.getInternalId());
@@ -1087,7 +1087,18 @@ public class RepresentationToModel {
identityProviderModel.setAddReadTokenRoleOnCreate(representation.isAddReadTokenRoleOnCreate());
identityProviderModel.setConfig(representation.getConfig());
- return identityProviderModel;
+ String flowAlias = representation.getFirstBrokerLoginFlowAlias();
+ if (flowAlias == null) {
+ flowAlias = DefaultAuthenticationFlows.FIRST_BROKER_LOGIN_FLOW;
+ }
+
+ AuthenticationFlowModel flowModel = realm.getFlowByAlias(flowAlias);
+ if (flowModel == null) {
+ throw new ModelException("No available authentication flow with alias: " + flowAlias);
+ }
+ identityProviderModel.setFirstBrokerLoginFlowId(flowModel.getId());
+
+ return identityProviderModel;
}
public static ProtocolMapperModel toModel(ProtocolMapperRepresentation rep) {
diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/entities/IdentityProviderEntity.java b/model/jpa/src/main/java/org/keycloak/models/jpa/entities/IdentityProviderEntity.java
index eeef707..5071c9b 100755
--- a/model/jpa/src/main/java/org/keycloak/models/jpa/entities/IdentityProviderEntity.java
+++ b/model/jpa/src/main/java/org/keycloak/models/jpa/entities/IdentityProviderEntity.java
@@ -11,6 +11,7 @@ import javax.persistence.ManyToOne;
import javax.persistence.MapKeyColumn;
import javax.persistence.NamedQueries;
import javax.persistence.NamedQuery;
+import javax.persistence.OneToOne;
import javax.persistence.Table;
import java.util.Map;
@@ -56,6 +57,9 @@ public class IdentityProviderEntity {
@Column(name="AUTHENTICATE_BY_DEFAULT")
private boolean authenticateByDefault;
+ @Column(name="FIRST_BROKER_LOGIN_FLOW_ID")
+ private String firstBrokerLoginFlowId;
+
@ElementCollection
@MapKeyColumn(name="NAME")
@Column(name="VALUE", columnDefinition = "TEXT")
@@ -126,6 +130,14 @@ public class IdentityProviderEntity {
this.authenticateByDefault = authenticateByDefault;
}
+ public String getFirstBrokerLoginFlowId() {
+ return firstBrokerLoginFlowId;
+ }
+
+ public void setFirstBrokerLoginFlowId(String firstBrokerLoginFlowId) {
+ this.firstBrokerLoginFlowId = firstBrokerLoginFlowId;
+ }
+
public Map<String, String> getConfig() {
return this.config;
}
diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/RealmAdapter.java b/model/jpa/src/main/java/org/keycloak/models/jpa/RealmAdapter.java
index 86f4490..1cd8b45 100755
--- a/model/jpa/src/main/java/org/keycloak/models/jpa/RealmAdapter.java
+++ b/model/jpa/src/main/java/org/keycloak/models/jpa/RealmAdapter.java
@@ -1219,6 +1219,7 @@ public class RealmAdapter implements RealmModel {
identityProviderModel.setUpdateProfileFirstLoginMode(entity.getUpdateProfileFirstLoginMode());
identityProviderModel.setTrustEmail(entity.isTrustEmail());
identityProviderModel.setAuthenticateByDefault(entity.isAuthenticateByDefault());
+ identityProviderModel.setFirstBrokerLoginFlowId(entity.getFirstBrokerLoginFlowId());
identityProviderModel.setStoreToken(entity.isStoreToken());
identityProviderModel.setAddReadTokenRoleOnCreate(entity.isAddReadTokenRoleOnCreate());
@@ -1252,6 +1253,7 @@ public class RealmAdapter implements RealmModel {
entity.setUpdateProfileFirstLoginMode(identityProvider.getUpdateProfileFirstLoginMode());
entity.setTrustEmail(identityProvider.isTrustEmail());
entity.setAuthenticateByDefault(identityProvider.isAuthenticateByDefault());
+ entity.setFirstBrokerLoginFlowId(identityProvider.getFirstBrokerLoginFlowId());
entity.setConfig(identityProvider.getConfig());
realm.addIdentityProvider(entity);
@@ -1279,6 +1281,7 @@ public class RealmAdapter implements RealmModel {
entity.setUpdateProfileFirstLoginMode(identityProvider.getUpdateProfileFirstLoginMode());
entity.setTrustEmail(identityProvider.isTrustEmail());
entity.setAuthenticateByDefault(identityProvider.isAuthenticateByDefault());
+ entity.setFirstBrokerLoginFlowId(identityProvider.getFirstBrokerLoginFlowId());
entity.setAddReadTokenRoleOnCreate(identityProvider.isAddReadTokenRoleOnCreate());
entity.setStoreToken(identityProvider.isStoreToken());
entity.setConfig(identityProvider.getConfig());
diff --git a/model/mongo/src/main/java/org/keycloak/models/mongo/keycloak/adapters/RealmAdapter.java b/model/mongo/src/main/java/org/keycloak/models/mongo/keycloak/adapters/RealmAdapter.java
index f21744d..479862c 100755
--- a/model/mongo/src/main/java/org/keycloak/models/mongo/keycloak/adapters/RealmAdapter.java
+++ b/model/mongo/src/main/java/org/keycloak/models/mongo/keycloak/adapters/RealmAdapter.java
@@ -826,6 +826,7 @@ public class RealmAdapter extends AbstractMongoAdapter<MongoRealmEntity> impleme
identityProviderModel.setUpdateProfileFirstLoginMode(entity.getUpdateProfileFirstLoginMode());
identityProviderModel.setTrustEmail(entity.isTrustEmail());
identityProviderModel.setAuthenticateByDefault(entity.isAuthenticateByDefault());
+ identityProviderModel.setFirstBrokerLoginFlowId(entity.getFirstBrokerLoginFlowId());
identityProviderModel.setStoreToken(entity.isStoreToken());
identityProviderModel.setAddReadTokenRoleOnCreate(entity.isAddReadTokenRoleOnCreate());
@@ -859,6 +860,7 @@ public class RealmAdapter extends AbstractMongoAdapter<MongoRealmEntity> impleme
entity.setAddReadTokenRoleOnCreate(identityProvider.isAddReadTokenRoleOnCreate());
entity.setStoreToken(identityProvider.isStoreToken());
entity.setAuthenticateByDefault(identityProvider.isAuthenticateByDefault());
+ entity.setFirstBrokerLoginFlowId(identityProvider.getFirstBrokerLoginFlowId());
entity.setConfig(identityProvider.getConfig());
realm.getIdentityProviders().add(entity);
@@ -885,6 +887,7 @@ public class RealmAdapter extends AbstractMongoAdapter<MongoRealmEntity> impleme
entity.setUpdateProfileFirstLoginMode(identityProvider.getUpdateProfileFirstLoginMode());
entity.setTrustEmail(identityProvider.isTrustEmail());
entity.setAuthenticateByDefault(identityProvider.isAuthenticateByDefault());
+ entity.setFirstBrokerLoginFlowId(identityProvider.getFirstBrokerLoginFlowId());
entity.setAddReadTokenRoleOnCreate(identityProvider.isAddReadTokenRoleOnCreate());
entity.setStoreToken(identityProvider.isStoreToken());
entity.setConfig(identityProvider.getConfig());
saml/saml-core/pom.xml 5(+0 -5)
diff --git a/saml/saml-core/pom.xml b/saml/saml-core/pom.xml
index 552f7be..5a1c01b 100755
--- a/saml/saml-core/pom.xml
+++ b/saml/saml-core/pom.xml
@@ -32,11 +32,6 @@
<groupId>org.apache.santuario</groupId>
<artifactId>xmlsec</artifactId>
</dependency>
- <dependency>
- <groupId>junit</groupId>
- <artifactId>junit</artifactId>
- <scope>test</scope>
- </dependency>
</dependencies>
<build>
<resources>
diff --git a/saml/saml-core/src/main/java/org/keycloak/saml/common/parsers/AbstractParser.java b/saml/saml-core/src/main/java/org/keycloak/saml/common/parsers/AbstractParser.java
index 3b5d545..dc25f17 100755
--- a/saml/saml-core/src/main/java/org/keycloak/saml/common/parsers/AbstractParser.java
+++ b/saml/saml-core/src/main/java/org/keycloak/saml/common/parsers/AbstractParser.java
@@ -76,6 +76,11 @@ public abstract class AbstractParser implements ParserNamespaceSupport {
* @throws {@link IllegalArgumentException} when the configStream is null
*/
public Object parse(InputStream configStream) throws ParsingException {
+ XMLEventReader xmlEventReader = createEventReader(configStream);
+ return parse(xmlEventReader);
+ }
+
+ public XMLEventReader createEventReader(InputStream configStream) throws ParsingException {
if (configStream == null)
throw logger.nullArgumentError("InputStream");
@@ -105,7 +110,7 @@ public abstract class AbstractParser implements ParserNamespaceSupport {
throw logger.parserException(e);
}
- return parse(xmlEventReader);
+ return xmlEventReader;
}
private ClassLoader getTCCL() {
diff --git a/saml/saml-core/src/main/java/org/keycloak/saml/processing/core/saml/v2/writers/SAMLAssertionWriter.java b/saml/saml-core/src/main/java/org/keycloak/saml/processing/core/saml/v2/writers/SAMLAssertionWriter.java
index af646e2..23a90d3 100755
--- a/saml/saml-core/src/main/java/org/keycloak/saml/processing/core/saml/v2/writers/SAMLAssertionWriter.java
+++ b/saml/saml-core/src/main/java/org/keycloak/saml/processing/core/saml/v2/writers/SAMLAssertionWriter.java
@@ -136,7 +136,7 @@ public class SAMLAssertionWriter extends BaseWriter {
if (statements != null) {
for (StatementAbstractType statement : statements) {
if (statement instanceof AuthnStatementType) {
- write((AuthnStatementType) statement);
+ write((AuthnStatementType) statement, false);
} else if (statement instanceof AttributeStatementType) {
write((AttributeStatementType) statement);
} else
@@ -188,8 +188,12 @@ public class SAMLAssertionWriter extends BaseWriter {
*
* @throws ProcessingException
*/
- public void write(AuthnStatementType authnStatement) throws ProcessingException {
+ public void write(AuthnStatementType authnStatement, boolean includeNamespace) throws ProcessingException {
StaxUtil.writeStartElement(writer, ASSERTION_PREFIX, JBossSAMLConstants.AUTHN_STATEMENT.get(), ASSERTION_NSURI.get());
+ if (includeNamespace) {
+ StaxUtil.writeNameSpace(writer, ASSERTION_PREFIX, ASSERTION_NSURI.get());
+ StaxUtil.writeDefaultNameSpace(writer, ASSERTION_NSURI.get());
+ }
XMLGregorianCalendar authnInstant = authnStatement.getAuthnInstant();
if (authnInstant != null) {
diff --git a/services/src/main/java/org/keycloak/authentication/AuthenticationFlowError.java b/services/src/main/java/org/keycloak/authentication/AuthenticationFlowError.java
index 8fd0faa..db3db37 100755
--- a/services/src/main/java/org/keycloak/authentication/AuthenticationFlowError.java
+++ b/services/src/main/java/org/keycloak/authentication/AuthenticationFlowError.java
@@ -22,5 +22,9 @@ public enum AuthenticationFlowError {
CLIENT_NOT_FOUND,
CLIENT_DISABLED,
CLIENT_CREDENTIALS_SETUP_REQUIRED,
- INVALID_CLIENT_CREDENTIALS
+ INVALID_CLIENT_CREDENTIALS,
+
+ IDENTITY_PROVIDER_NOT_FOUND,
+ IDENTITY_PROVIDER_DISABLED,
+ IDENTITY_PROVIDER_ERROR
}
diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/broker/AbstractIdpAuthenticator.java b/services/src/main/java/org/keycloak/authentication/authenticators/broker/AbstractIdpAuthenticator.java
new file mode 100644
index 0000000..b7c7663
--- /dev/null
+++ b/services/src/main/java/org/keycloak/authentication/authenticators/broker/AbstractIdpAuthenticator.java
@@ -0,0 +1,118 @@
+package org.keycloak.authentication.authenticators.broker;
+
+import javax.ws.rs.core.Response;
+
+import org.keycloak.authentication.AuthenticationFlowContext;
+import org.keycloak.authentication.AuthenticationFlowError;
+import org.keycloak.authentication.AuthenticationFlowException;
+import org.keycloak.authentication.Authenticator;
+import org.keycloak.authentication.authenticators.broker.util.ExistingUserInfo;
+import org.keycloak.authentication.authenticators.broker.util.SerializedBrokeredIdentityContext;
+import org.keycloak.broker.provider.BrokeredIdentityContext;
+import org.keycloak.events.Errors;
+import org.keycloak.models.ClientSessionModel;
+import org.keycloak.models.KeycloakSession;
+import org.keycloak.models.RealmModel;
+import org.keycloak.models.UserModel;
+import org.keycloak.services.messages.Messages;
+
+/**
+ * @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
+ */
+public abstract class AbstractIdpAuthenticator implements Authenticator {
+
+ // The clientSession note encapsulating all the BrokeredIdentityContext info. When this note is in clientSession, we know that firstBrokerLogin flow is in progress
+ public static final String BROKERED_CONTEXT_NOTE = "BROKERED_CONTEXT";
+
+ // The clientSession note with all the info about existing user
+ public static final String EXISTING_USER_INFO = "EXISTING_USER_INFO";
+
+ // The clientSession note flag to indicate that email provided by identityProvider was changed on updateProfile page
+ public static final String UPDATE_PROFILE_EMAIL_CHANGED = "UPDATE_PROFILE_EMAIL_CHANGED";
+
+ // The clientSession note flag to indicate if re-authentication after first broker login happened in different browser window. This can happen for example during email verification
+ public static final String IS_DIFFERENT_BROWSER = "IS_DIFFERENT_BROWSER";
+
+ // The clientSession note flag to indicate that updateProfile page will be always displayed even if "updateProfileOnFirstLogin" is off
+ public static final String ENFORCE_UPDATE_PROFILE = "ENFORCE_UPDATE_PROFILE";
+
+ // clientSession.note flag specifies if we imported new user to keycloak (true) or we just linked to an existing keycloak user (false)
+ public static final String BROKER_REGISTERED_NEW_USER = "BROKER_REGISTERED_NEW_USER";
+
+
+ @Override
+ public void authenticate(AuthenticationFlowContext context) {
+ ClientSessionModel clientSession = context.getClientSession();
+
+ SerializedBrokeredIdentityContext serializedCtx = SerializedBrokeredIdentityContext.readFromClientSession(clientSession);
+ if (serializedCtx == null) {
+ throw new AuthenticationFlowException("Not found serialized context in clientSession", AuthenticationFlowError.IDENTITY_PROVIDER_ERROR);
+ }
+ BrokeredIdentityContext brokerContext = serializedCtx.deserialize(context.getSession(), clientSession);
+
+ if (!brokerContext.getIdpConfig().isEnabled()) {
+ sendFailureChallenge(context, Errors.IDENTITY_PROVIDER_ERROR, Messages.IDENTITY_PROVIDER_UNEXPECTED_ERROR, AuthenticationFlowError.IDENTITY_PROVIDER_ERROR);
+ }
+
+ authenticateImpl(context, serializedCtx, brokerContext);
+ }
+
+ @Override
+ public void action(AuthenticationFlowContext context) {
+ ClientSessionModel clientSession = context.getClientSession();
+
+ SerializedBrokeredIdentityContext serializedCtx = SerializedBrokeredIdentityContext.readFromClientSession(clientSession);
+ if (serializedCtx == null) {
+ throw new AuthenticationFlowException("Not found serialized context in clientSession", AuthenticationFlowError.IDENTITY_PROVIDER_ERROR);
+ }
+ BrokeredIdentityContext brokerContext = serializedCtx.deserialize(context.getSession(), clientSession);
+
+ if (!brokerContext.getIdpConfig().isEnabled()) {
+ sendFailureChallenge(context, Errors.IDENTITY_PROVIDER_ERROR, Messages.IDENTITY_PROVIDER_UNEXPECTED_ERROR, AuthenticationFlowError.IDENTITY_PROVIDER_ERROR);
+ }
+
+ actionImpl(context, serializedCtx, brokerContext);
+ }
+
+ protected abstract void authenticateImpl(AuthenticationFlowContext context, SerializedBrokeredIdentityContext serializedCtx, BrokeredIdentityContext brokerContext);
+ protected abstract void actionImpl(AuthenticationFlowContext context, SerializedBrokeredIdentityContext serializedCtx, BrokeredIdentityContext brokerContext);
+
+ protected void sendFailureChallenge(AuthenticationFlowContext context, String eventError, String errorMessage, AuthenticationFlowError flowError) {
+ context.getEvent().user(context.getUser())
+ .error(eventError);
+ Response challengeResponse = context.form()
+ .setError(errorMessage)
+ .createErrorPage();
+ context.failureChallenge(flowError, challengeResponse);
+ }
+
+ @Override
+ public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) {
+ }
+
+ @Override
+ public void close() {
+
+ }
+
+ public static UserModel getExistingUser(KeycloakSession session, RealmModel realm, ClientSessionModel clientSession) {
+ String existingUserId = clientSession.getNote(EXISTING_USER_INFO);
+ if (existingUserId == null) {
+ throw new AuthenticationFlowException("Unexpected state. There is no existing duplicated user identified in ClientSession",
+ AuthenticationFlowError.INTERNAL_ERROR);
+ }
+
+ ExistingUserInfo duplication = ExistingUserInfo.deserialize(existingUserId);
+
+ UserModel existingUser = session.users().getUserById(duplication.getExistingUserId(), realm);
+ if (existingUser == null) {
+ throw new AuthenticationFlowException("User with ID '" + existingUserId + "' not found.", AuthenticationFlowError.INVALID_USER);
+ }
+
+ if (!existingUser.isEnabled()) {
+ throw new AuthenticationFlowException("User with ID '" + existingUserId + "', username '" + existingUser.getUsername() + "' disabled.", AuthenticationFlowError.USER_DISABLED);
+ }
+
+ return existingUser;
+ }
+}
diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/broker/IdpConfirmLinkAuthenticator.java b/services/src/main/java/org/keycloak/authentication/authenticators/broker/IdpConfirmLinkAuthenticator.java
new file mode 100644
index 0000000..6d6281d
--- /dev/null
+++ b/services/src/main/java/org/keycloak/authentication/authenticators/broker/IdpConfirmLinkAuthenticator.java
@@ -0,0 +1,73 @@
+package org.keycloak.authentication.authenticators.broker;
+
+import javax.ws.rs.core.MultivaluedMap;
+import javax.ws.rs.core.Response;
+
+import org.jboss.logging.Logger;
+import org.keycloak.authentication.AuthenticationFlowContext;
+import org.keycloak.authentication.AuthenticationFlowError;
+import org.keycloak.authentication.AuthenticationFlowException;
+import org.keycloak.authentication.authenticators.broker.util.ExistingUserInfo;
+import org.keycloak.authentication.authenticators.broker.util.SerializedBrokeredIdentityContext;
+import org.keycloak.broker.provider.BrokeredIdentityContext;
+import org.keycloak.login.LoginFormsProvider;
+import org.keycloak.models.ClientSessionModel;
+import org.keycloak.models.KeycloakSession;
+import org.keycloak.models.RealmModel;
+import org.keycloak.models.UserModel;
+import org.keycloak.services.messages.Messages;
+
+/**
+ * @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
+ */
+public class IdpConfirmLinkAuthenticator extends AbstractIdpAuthenticator {
+
+ protected static Logger logger = Logger.getLogger(IdpConfirmLinkAuthenticator.class);
+
+ @Override
+ protected void authenticateImpl(AuthenticationFlowContext context, SerializedBrokeredIdentityContext serializedCtx, BrokeredIdentityContext brokerContext) {
+ ClientSessionModel clientSession = context.getClientSession();
+
+ String existingUserInfo = clientSession.getNote(EXISTING_USER_INFO);
+ if (existingUserInfo == null) {
+ logger.warnf("No duplication detected.");
+ context.attempted();
+ return;
+ }
+
+ ExistingUserInfo duplicationInfo = ExistingUserInfo.deserialize(existingUserInfo);
+ Response challenge = context.form()
+ .setStatus(Response.Status.OK)
+ .setAttribute(LoginFormsProvider.IDENTITY_PROVIDER_BROKER_CONTEXT, brokerContext)
+ .setError(Messages.FEDERATED_IDENTITY_CONFIRM_LINK_MESSAGE, duplicationInfo.getDuplicateAttributeName(), duplicationInfo.getDuplicateAttributeValue())
+ .createIdpLinkConfirmLinkPage();
+ context.challenge(challenge);
+ }
+
+ @Override
+ protected void actionImpl(AuthenticationFlowContext context, SerializedBrokeredIdentityContext serializedCtx, BrokeredIdentityContext brokerContext) {
+ MultivaluedMap<String, String> formData = context.getHttpRequest().getDecodedFormParameters();
+
+ String action = formData.getFirst("submitAction");
+ if (action != null && action.equals("updateProfile")) {
+ context.getClientSession().setNote(ENFORCE_UPDATE_PROFILE, "true");
+ context.getClientSession().removeNote(EXISTING_USER_INFO);
+ context.resetFlow();
+ } else if (action != null && action.equals("linkAccount")) {
+ context.success();
+ } else {
+ throw new AuthenticationFlowException("Unknown action: " + action,
+ AuthenticationFlowError.INTERNAL_ERROR);
+ }
+ }
+
+ @Override
+ public boolean requiresUser() {
+ return false;
+ }
+
+ @Override
+ public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) {
+ return false;
+ }
+}
diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/broker/IdpConfirmLinkAuthenticatorFactory.java b/services/src/main/java/org/keycloak/authentication/authenticators/broker/IdpConfirmLinkAuthenticatorFactory.java
new file mode 100644
index 0000000..930a8bf
--- /dev/null
+++ b/services/src/main/java/org/keycloak/authentication/authenticators/broker/IdpConfirmLinkAuthenticatorFactory.java
@@ -0,0 +1,86 @@
+package org.keycloak.authentication.authenticators.broker;
+
+import java.util.List;
+
+import org.keycloak.Config;
+import org.keycloak.authentication.Authenticator;
+import org.keycloak.authentication.AuthenticatorFactory;
+import org.keycloak.models.AuthenticationExecutionModel;
+import org.keycloak.models.KeycloakSession;
+import org.keycloak.models.KeycloakSessionFactory;
+import org.keycloak.provider.ProviderConfigProperty;
+
+/**
+ * @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
+ */
+public class IdpConfirmLinkAuthenticatorFactory implements AuthenticatorFactory {
+
+ public static final String PROVIDER_ID = "idp-confirm-link";
+ static IdpConfirmLinkAuthenticator SINGLETON = new IdpConfirmLinkAuthenticator();
+
+ @Override
+ public Authenticator create(KeycloakSession session) {
+ return SINGLETON;
+ }
+
+ @Override
+ public void init(Config.Scope config) {
+
+ }
+
+ @Override
+ public void postInit(KeycloakSessionFactory factory) {
+
+ }
+
+ @Override
+ public void close() {
+
+ }
+
+ @Override
+ public String getId() {
+ return PROVIDER_ID;
+ }
+
+ @Override
+ public String getReferenceCategory() {
+ return "confirmLink";
+ }
+
+ @Override
+ public boolean isConfigurable() {
+ return false;
+ }
+
+ public static final AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = {
+ AuthenticationExecutionModel.Requirement.REQUIRED,
+ AuthenticationExecutionModel.Requirement.DISABLED};
+
+ @Override
+ public AuthenticationExecutionModel.Requirement[] getRequirementChoices() {
+ return REQUIREMENT_CHOICES;
+ }
+
+ @Override
+ public String getDisplayType() {
+ return "Confirm link existing account";
+ }
+
+ @Override
+ public String getHelpText() {
+ return "Show the form where user confirms if he wants to link identity provider with existing account or rather edit user profile data retrieved from identity provider to avoid conflict";
+ }
+
+ @Override
+ public List<ProviderConfigProperty> getConfigProperties() {
+ return null;
+ }
+
+ @Override
+ public boolean isUserSetupAllowed() {
+ return false;
+ }
+
+
+}
diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/broker/IdpDetectDuplicationsAuthenticator.java b/services/src/main/java/org/keycloak/authentication/authenticators/broker/IdpDetectDuplicationsAuthenticator.java
new file mode 100644
index 0000000..f060730
--- /dev/null
+++ b/services/src/main/java/org/keycloak/authentication/authenticators/broker/IdpDetectDuplicationsAuthenticator.java
@@ -0,0 +1,105 @@
+package org.keycloak.authentication.authenticators.broker;
+
+import java.util.List;
+import java.util.Map;
+
+import javax.ws.rs.core.Response;
+
+import org.jboss.logging.Logger;
+import org.keycloak.authentication.AuthenticationFlowContext;
+import org.keycloak.authentication.authenticators.broker.util.ExistingUserInfo;
+import org.keycloak.authentication.authenticators.broker.util.SerializedBrokeredIdentityContext;
+import org.keycloak.broker.provider.BrokeredIdentityContext;
+import org.keycloak.models.KeycloakSession;
+import org.keycloak.models.RealmModel;
+import org.keycloak.models.UserModel;
+import org.keycloak.services.messages.Messages;
+
+/**
+ * @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
+ */
+public class IdpDetectDuplicationsAuthenticator extends AbstractIdpAuthenticator {
+
+ protected static Logger logger = Logger.getLogger(IdpDetectDuplicationsAuthenticator.class);
+
+
+ @Override
+ protected void actionImpl(AuthenticationFlowContext context, SerializedBrokeredIdentityContext serializedCtx, BrokeredIdentityContext brokerContext) {
+ }
+
+ @Override
+ protected void authenticateImpl(AuthenticationFlowContext context, SerializedBrokeredIdentityContext serializedCtx, BrokeredIdentityContext brokerContext) {
+
+ KeycloakSession session = context.getSession();
+ RealmModel realm = context.getRealm();
+
+ if (context.getClientSession().getNote(EXISTING_USER_INFO) != null) {
+ context.attempted();
+ return;
+ }
+
+ ExistingUserInfo duplication = checkExistingUser(context, serializedCtx, brokerContext);
+
+ if (duplication == null) {
+ logger.debugf("No duplication detected. Creating account for user '%s' and linking with identity provider '%s' .",
+ brokerContext.getModelUsername(), brokerContext.getIdpConfig().getAlias());
+
+ UserModel federatedUser = session.users().addUser(realm, brokerContext.getModelUsername());
+ federatedUser.setEnabled(true);
+ federatedUser.setEmail(brokerContext.getEmail());
+ federatedUser.setFirstName(brokerContext.getFirstName());
+ federatedUser.setLastName(brokerContext.getLastName());
+
+ for (Map.Entry<String, List<String>> attr : serializedCtx.getAttributes().entrySet()) {
+ federatedUser.setAttribute(attr.getKey(), attr.getValue());
+ }
+
+ // TODO: Event
+
+ context.setUser(federatedUser);
+ context.getClientSession().setNote(BROKER_REGISTERED_NEW_USER, "true");
+ context.success();
+ } else {
+ logger.debugf("Duplication detected. There is already existing user with %s '%s' .",
+ duplication.getDuplicateAttributeName(), duplication.getDuplicateAttributeValue());
+
+ // Set duplicated user, so next authenticators can deal with it
+ context.getClientSession().setNote(EXISTING_USER_INFO, duplication.serialize());
+
+ Response challengeResponse = context.form()
+ .setError(Messages.FEDERATED_IDENTITY_EXISTS, duplication.getDuplicateAttributeName(), duplication.getDuplicateAttributeValue())
+ .createErrorPage();
+ context.challenge(challengeResponse);
+ }
+ }
+
+ // Could be overriden to detect duplication based on other criterias (firstName, lastName, ...)
+ protected ExistingUserInfo checkExistingUser(AuthenticationFlowContext context, SerializedBrokeredIdentityContext serializedCtx, BrokeredIdentityContext brokerContext) {
+
+ if (brokerContext.getEmail() != null) {
+ UserModel existingUser = context.getSession().users().getUserByEmail(brokerContext.getEmail(), context.getRealm());
+ if (existingUser != null) {
+ return new ExistingUserInfo(existingUser.getId(), UserModel.EMAIL, existingUser.getEmail());
+ }
+ }
+
+ UserModel existingUser = context.getSession().users().getUserByUsername(brokerContext.getModelUsername(), context.getRealm());
+ if (existingUser != null) {
+ return new ExistingUserInfo(existingUser.getId(), UserModel.USERNAME, existingUser.getUsername());
+ }
+
+ return null;
+ }
+
+
+ @Override
+ public boolean requiresUser() {
+ return false;
+ }
+
+ @Override
+ public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) {
+ return true;
+ }
+
+}
diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/broker/IdpDetectDuplicationsAuthenticatorFactory.java b/services/src/main/java/org/keycloak/authentication/authenticators/broker/IdpDetectDuplicationsAuthenticatorFactory.java
new file mode 100644
index 0000000..86a4dbd
--- /dev/null
+++ b/services/src/main/java/org/keycloak/authentication/authenticators/broker/IdpDetectDuplicationsAuthenticatorFactory.java
@@ -0,0 +1,85 @@
+package org.keycloak.authentication.authenticators.broker;
+
+import java.util.List;
+
+import org.keycloak.Config;
+import org.keycloak.authentication.Authenticator;
+import org.keycloak.authentication.AuthenticatorFactory;
+import org.keycloak.models.AuthenticationExecutionModel;
+import org.keycloak.models.KeycloakSession;
+import org.keycloak.models.KeycloakSessionFactory;
+import org.keycloak.provider.ProviderConfigProperty;
+
+/**
+ * @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
+ */
+public class IdpDetectDuplicationsAuthenticatorFactory implements AuthenticatorFactory {
+
+ public static final String PROVIDER_ID = "idp-detect-duplications";
+ static IdpDetectDuplicationsAuthenticator SINGLETON = new IdpDetectDuplicationsAuthenticator();
+
+ @Override
+ public Authenticator create(KeycloakSession session) {
+ return SINGLETON;
+ }
+
+ @Override
+ public void init(Config.Scope config) {
+
+ }
+
+ @Override
+ public void postInit(KeycloakSessionFactory factory) {
+
+ }
+
+ @Override
+ public void close() {
+
+ }
+
+ @Override
+ public String getId() {
+ return PROVIDER_ID;
+ }
+
+ @Override
+ public String getReferenceCategory() {
+ return "createUserIfUnique";
+ }
+
+ @Override
+ public boolean isConfigurable() {
+ return false;
+ }
+
+ public static final AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = {
+ AuthenticationExecutionModel.Requirement.ALTERNATIVE,
+ AuthenticationExecutionModel.Requirement.REQUIRED,
+ AuthenticationExecutionModel.Requirement.DISABLED};
+
+ @Override
+ public AuthenticationExecutionModel.Requirement[] getRequirementChoices() {
+ return REQUIREMENT_CHOICES;
+ }
+
+ @Override
+ public String getDisplayType() {
+ return "Create User If Unique";
+ }
+
+ @Override
+ public String getHelpText() {
+ return "Detect if there is existing Keycloak account with same email like identity provider. If no, create new user";
+ }
+
+ @Override
+ public List<ProviderConfigProperty> getConfigProperties() {
+ return null;
+ }
+
+ @Override
+ public boolean isUserSetupAllowed() {
+ return false;
+ }
+}
diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/broker/IdpEmailVerificationAuthenticator.java b/services/src/main/java/org/keycloak/authentication/authenticators/broker/IdpEmailVerificationAuthenticator.java
new file mode 100644
index 0000000..d6bf10f
--- /dev/null
+++ b/services/src/main/java/org/keycloak/authentication/authenticators/broker/IdpEmailVerificationAuthenticator.java
@@ -0,0 +1,135 @@
+package org.keycloak.authentication.authenticators.broker;
+
+import java.util.concurrent.TimeUnit;
+
+import javax.ws.rs.core.MultivaluedMap;
+import javax.ws.rs.core.Response;
+import javax.ws.rs.core.UriBuilder;
+
+import org.jboss.logging.Logger;
+import org.keycloak.authentication.AuthenticationFlowContext;
+import org.keycloak.authentication.AuthenticationFlowError;
+import org.keycloak.authentication.requiredactions.VerifyEmail;
+import org.keycloak.authentication.authenticators.broker.util.SerializedBrokeredIdentityContext;
+import org.keycloak.broker.provider.BrokeredIdentityContext;
+import org.keycloak.email.EmailException;
+import org.keycloak.email.EmailProvider;
+import org.keycloak.login.LoginFormsProvider;
+import org.keycloak.models.ClientSessionModel;
+import org.keycloak.models.Constants;
+import org.keycloak.models.KeycloakSession;
+import org.keycloak.models.RealmModel;
+import org.keycloak.models.UserModel;
+import org.keycloak.services.messages.Messages;
+import org.keycloak.services.resources.LoginActionsService;
+
+/**
+ * @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
+ */
+public class IdpEmailVerificationAuthenticator extends AbstractIdpAuthenticator {
+
+ protected static Logger logger = Logger.getLogger(IdpEmailVerificationAuthenticator.class);
+
+ @Override
+ protected void authenticateImpl(AuthenticationFlowContext context, SerializedBrokeredIdentityContext serializedCtx, BrokeredIdentityContext brokerContext) {
+ KeycloakSession session = context.getSession();
+ RealmModel realm = context.getRealm();
+ ClientSessionModel clientSession = context.getClientSession();
+
+ if (realm.getSmtpConfig().size() == 0) {
+ logger.warnf("Smtp is not configured for the realm. Ignoring email verification authenticator");
+ context.attempted();
+ return;
+ }
+
+ // Create action cookie to detect if email verification happened in same browser
+ LoginActionsService.createActionCookie(context.getRealm(), context.getUriInfo(), context.getConnection(), context.getClientSession().getId());
+
+ VerifyEmail.setupKey(clientSession);
+
+ UserModel existingUser = getExistingUser(session, realm, clientSession);
+
+ String link = UriBuilder.fromUri(context.getActionUrl())
+ .queryParam(Constants.KEY, clientSession.getNote(Constants.VERIFY_EMAIL_KEY))
+ .build().toString();
+ long expiration = TimeUnit.SECONDS.toMinutes(context.getRealm().getAccessCodeLifespanUserAction());
+ try {
+
+ context.getSession().getProvider(EmailProvider.class)
+ .setRealm(realm)
+ .setUser(existingUser)
+ .setAttribute(EmailProvider.IDENTITY_PROVIDER_BROKER_CONTEXT, brokerContext)
+ .sendConfirmIdentityBrokerLink(link, expiration);
+// event.clone().event(EventType.SEND_RESET_PASSWORD)
+// .user(user)
+// .detail(Details.USERNAME, username)
+// .detail(Details.EMAIL, user.getEmail()).detail(Details.CODE_ID, context.getClientSession().getId()).success();
+ } catch (EmailException e) {
+// event.clone().event(EventType.SEND_RESET_PASSWORD)
+// .detail(Details.USERNAME, username)
+// .user(user)
+// .error(Errors.EMAIL_SEND_FAILED);
+ logger.error("Failed to send email to confirm identity broker linking", e);
+ Response challenge = context.form()
+ .setError(Messages.EMAIL_SENT_ERROR)
+ .createErrorPage();
+ context.failure(AuthenticationFlowError.INTERNAL_ERROR, challenge);
+ return;
+ }
+
+ Response challenge = context.form()
+ .setStatus(Response.Status.OK)
+ .setAttribute(LoginFormsProvider.IDENTITY_PROVIDER_BROKER_CONTEXT, brokerContext)
+ .createIdpLinkEmailPage();
+ context.forceChallenge(challenge);
+ }
+
+ @Override
+ protected void actionImpl(AuthenticationFlowContext context, SerializedBrokeredIdentityContext serializedCtx, BrokeredIdentityContext brokerContext) {
+ MultivaluedMap<String, String> queryParams = context.getSession().getContext().getUri().getQueryParameters();
+ String key = queryParams.getFirst(Constants.KEY);
+ ClientSessionModel clientSession = context.getClientSession();
+ RealmModel realm = context.getRealm();
+ KeycloakSession session = context.getSession();
+
+ if (key != null) {
+ String keyFromSession = clientSession.getNote(Constants.VERIFY_EMAIL_KEY);
+ clientSession.removeNote(Constants.VERIFY_EMAIL_KEY);
+ if (key.equals(keyFromSession)) {
+ UserModel existingUser = getExistingUser(session, realm, clientSession);
+
+ logger.debugf("User '%s' confirmed that wants to link with identity provider '%s' . Identity provider username is '%s' ", existingUser.getUsername(),
+ brokerContext.getIdpConfig().getAlias(), brokerContext.getUsername());
+
+ String actionCookieValue = LoginActionsService.getActionCookie(session.getContext().getRequestHeaders(), realm, session.getContext().getUri(), context.getConnection());
+ if (actionCookieValue == null || !actionCookieValue.equals(clientSession.getId())) {
+ clientSession.setNote(IS_DIFFERENT_BROWSER, "true");
+ }
+
+ context.setUser(existingUser);
+ context.success();
+ } else {
+ logger.error("Key parameter don't match with the expected value from client session");
+ Response challengeResponse = context.form()
+ .setError(Messages.INVALID_ACCESS_CODE)
+ .createErrorPage();
+ context.failureChallenge(AuthenticationFlowError.IDENTITY_PROVIDER_ERROR, challengeResponse);
+ }
+ } else {
+ Response challengeResponse = context.form()
+ .setError(Messages.MISSING_PARAMETER, Constants.KEY)
+ .createErrorPage();
+ context.failureChallenge(AuthenticationFlowError.IDENTITY_PROVIDER_ERROR, challengeResponse);
+ }
+ }
+
+ @Override
+ public boolean requiresUser() {
+ return false;
+ }
+
+ @Override
+ public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) {
+ return false;
+ }
+}
diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/broker/IdpEmailVerificationAuthenticatorFactory.java b/services/src/main/java/org/keycloak/authentication/authenticators/broker/IdpEmailVerificationAuthenticatorFactory.java
new file mode 100644
index 0000000..e431966
--- /dev/null
+++ b/services/src/main/java/org/keycloak/authentication/authenticators/broker/IdpEmailVerificationAuthenticatorFactory.java
@@ -0,0 +1,85 @@
+package org.keycloak.authentication.authenticators.broker;
+
+import java.util.List;
+
+import org.keycloak.Config;
+import org.keycloak.authentication.Authenticator;
+import org.keycloak.authentication.AuthenticatorFactory;
+import org.keycloak.models.AuthenticationExecutionModel;
+import org.keycloak.models.KeycloakSession;
+import org.keycloak.models.KeycloakSessionFactory;
+import org.keycloak.provider.ProviderConfigProperty;
+
+/**
+ * @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
+ */
+public class IdpEmailVerificationAuthenticatorFactory implements AuthenticatorFactory {
+
+ public static final String PROVIDER_ID = "idp-email-verification";
+ static IdpEmailVerificationAuthenticator SINGLETON = new IdpEmailVerificationAuthenticator();
+
+ @Override
+ public Authenticator create(KeycloakSession session) {
+ return SINGLETON;
+ }
+
+ @Override
+ public void init(Config.Scope config) {
+
+ }
+
+ @Override
+ public void postInit(KeycloakSessionFactory factory) {
+
+ }
+
+ @Override
+ public void close() {
+
+ }
+
+ @Override
+ public String getId() {
+ return PROVIDER_ID;
+ }
+
+ @Override
+ public String getReferenceCategory() {
+ return "emailVerification";
+ }
+
+ @Override
+ public boolean isConfigurable() {
+ return false;
+ }
+
+ public static final AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = {
+ AuthenticationExecutionModel.Requirement.ALTERNATIVE,
+ AuthenticationExecutionModel.Requirement.REQUIRED,
+ AuthenticationExecutionModel.Requirement.DISABLED};
+
+ @Override
+ public AuthenticationExecutionModel.Requirement[] getRequirementChoices() {
+ return REQUIREMENT_CHOICES;
+ }
+
+ @Override
+ public String getDisplayType() {
+ return "Verify existing account by Email";
+ }
+
+ @Override
+ public String getHelpText() {
+ return "Email verification of existing Keycloak user, that wants to link his user account with identity provider";
+ }
+
+ @Override
+ public List<ProviderConfigProperty> getConfigProperties() {
+ return null;
+ }
+
+ @Override
+ public boolean isUserSetupAllowed() {
+ return false;
+ }
+}
diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/broker/IdpUpdateProfileAuthenticator.java b/services/src/main/java/org/keycloak/authentication/authenticators/broker/IdpUpdateProfileAuthenticator.java
new file mode 100644
index 0000000..35e10fd
--- /dev/null
+++ b/services/src/main/java/org/keycloak/authentication/authenticators/broker/IdpUpdateProfileAuthenticator.java
@@ -0,0 +1,118 @@
+package org.keycloak.authentication.authenticators.broker;
+
+import java.util.List;
+
+import javax.ws.rs.core.MultivaluedMap;
+import javax.ws.rs.core.Response;
+
+import org.jboss.logging.Logger;
+import org.keycloak.authentication.AuthenticationFlowContext;
+import org.keycloak.authentication.authenticators.broker.util.SerializedBrokeredIdentityContext;
+import org.keycloak.broker.provider.BrokeredIdentityContext;
+import org.keycloak.common.util.ObjectUtil;
+import org.keycloak.events.Details;
+import org.keycloak.events.EventBuilder;
+import org.keycloak.events.EventType;
+import org.keycloak.login.LoginFormsProvider;
+import org.keycloak.models.IdentityProviderModel;
+import org.keycloak.models.KeycloakSession;
+import org.keycloak.models.RealmModel;
+import org.keycloak.models.UserModel;
+import org.keycloak.models.utils.FormMessage;
+import org.keycloak.representations.idm.IdentityProviderRepresentation;
+import org.keycloak.services.resources.AttributeFormDataProcessor;
+import org.keycloak.services.validation.Validation;
+
+/**
+ * @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
+ */
+public class IdpUpdateProfileAuthenticator extends AbstractIdpAuthenticator {
+
+ protected static Logger logger = Logger.getLogger(IdpUpdateProfileAuthenticator.class);
+
+ @Override
+ public boolean requiresUser() {
+ return false;
+ }
+
+ @Override
+ protected void authenticateImpl(AuthenticationFlowContext context, SerializedBrokeredIdentityContext userCtx, BrokeredIdentityContext brokerContext) {
+ IdentityProviderModel idpConfig = brokerContext.getIdpConfig();
+
+ if (requiresUpdateProfilePage(context, userCtx, brokerContext)) {
+
+ logger.debugf("Identity provider '%s' requires update profile action for broker user '%s'.", idpConfig.getAlias(), userCtx.getUsername());
+
+ // No formData for first render. The profile is rendered from userCtx
+ Response challengeResponse = context.form()
+ .setAttribute(LoginFormsProvider.UPDATE_PROFILE_CONTEXT_ATTR, userCtx)
+ .setFormData(null)
+ .createUpdateProfilePage();
+ context.challenge(challengeResponse);
+ } else {
+ // Not required to update profile. Marked success
+ context.success();
+ }
+ }
+
+ protected boolean requiresUpdateProfilePage(AuthenticationFlowContext context, SerializedBrokeredIdentityContext userCtx, BrokeredIdentityContext brokerContext) {
+ String enforceUpdateProfile = context.getClientSession().getNote(ENFORCE_UPDATE_PROFILE);
+ if (Boolean.parseBoolean(enforceUpdateProfile)) {
+ return true;
+ }
+
+ IdentityProviderModel idpConfig = brokerContext.getIdpConfig();
+ RealmModel realm = context.getRealm();
+ return IdentityProviderRepresentation.UPFLM_ON.equals(idpConfig.getUpdateProfileFirstLoginMode())
+ || (IdentityProviderRepresentation.UPFLM_MISSING.equals(idpConfig.getUpdateProfileFirstLoginMode()) && !Validation.validateUserMandatoryFields(realm, userCtx));
+ }
+
+ @Override
+ protected void actionImpl(AuthenticationFlowContext context, SerializedBrokeredIdentityContext userCtx, BrokeredIdentityContext brokerContext) {
+ EventBuilder event = context.getEvent();
+ event.event(EventType.UPDATE_PROFILE);
+ MultivaluedMap<String, String> formData = context.getHttpRequest().getDecodedFormParameters();
+
+ RealmModel realm = context.getRealm();
+
+ List<FormMessage> errors = Validation.validateUpdateProfileForm(true, formData);
+ if (errors != null && !errors.isEmpty()) {
+ Response challenge = context.form()
+ .setErrors(errors)
+ .setAttribute(LoginFormsProvider.UPDATE_PROFILE_CONTEXT_ATTR, userCtx)
+ .setFormData(formData)
+ .createUpdateProfilePage();
+ context.challenge(challenge);
+ return;
+ }
+
+ userCtx.setUsername(formData.getFirst(UserModel.USERNAME));
+ userCtx.setFirstName(formData.getFirst(UserModel.FIRST_NAME));
+ userCtx.setLastName(formData.getFirst(UserModel.LAST_NAME));
+
+ String email = formData.getFirst(UserModel.EMAIL);
+ if (!ObjectUtil.isEqualOrBothNull(email, userCtx.getEmail())) {
+ if (logger.isTraceEnabled()) {
+ logger.tracef("Email updated on updateProfile page to '%s' ", email);
+ }
+
+ userCtx.setEmail(email);
+ context.getClientSession().setNote(UPDATE_PROFILE_EMAIL_CHANGED, "true");
+ }
+
+ AttributeFormDataProcessor.process(formData, realm, userCtx);
+
+ userCtx.saveToClientSession(context.getClientSession());
+
+ logger.debugf("Profile updated successfully after first authentication with identity provider '%s' for broker user '%s'.", brokerContext.getIdpConfig().getAlias(), userCtx.getUsername());
+
+ event.detail(Details.UPDATED_EMAIL, email);
+ context.success();
+ }
+
+ @Override
+ public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) {
+ return true;
+ }
+
+}
diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/broker/IdpUpdateProfileAuthenticatorFactory.java b/services/src/main/java/org/keycloak/authentication/authenticators/broker/IdpUpdateProfileAuthenticatorFactory.java
new file mode 100644
index 0000000..d947c60
--- /dev/null
+++ b/services/src/main/java/org/keycloak/authentication/authenticators/broker/IdpUpdateProfileAuthenticatorFactory.java
@@ -0,0 +1,84 @@
+package org.keycloak.authentication.authenticators.broker;
+
+import java.util.List;
+
+import org.keycloak.Config;
+import org.keycloak.authentication.Authenticator;
+import org.keycloak.authentication.AuthenticatorFactory;
+import org.keycloak.models.AuthenticationExecutionModel;
+import org.keycloak.models.KeycloakSession;
+import org.keycloak.models.KeycloakSessionFactory;
+import org.keycloak.provider.ProviderConfigProperty;
+
+/**
+ * @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
+ */
+public class IdpUpdateProfileAuthenticatorFactory implements AuthenticatorFactory {
+
+ public static final String PROVIDER_ID = "idp-update-profile";
+ static IdpUpdateProfileAuthenticator SINGLETON = new IdpUpdateProfileAuthenticator();
+
+ @Override
+ public Authenticator create(KeycloakSession session) {
+ return SINGLETON;
+ }
+
+ @Override
+ public void init(Config.Scope config) {
+
+ }
+
+ @Override
+ public void postInit(KeycloakSessionFactory factory) {
+
+ }
+
+ @Override
+ public void close() {
+
+ }
+
+ @Override
+ public String getId() {
+ return PROVIDER_ID;
+ }
+
+ @Override
+ public String getReferenceCategory() {
+ return "updateProfile";
+ }
+
+ @Override
+ public boolean isConfigurable() {
+ return false;
+ }
+
+ public static final AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = {
+ AuthenticationExecutionModel.Requirement.REQUIRED,
+ AuthenticationExecutionModel.Requirement.DISABLED};
+
+ @Override
+ public AuthenticationExecutionModel.Requirement[] getRequirementChoices() {
+ return REQUIREMENT_CHOICES;
+ }
+
+ @Override
+ public String getDisplayType() {
+ return "Update Profile";
+ }
+
+ @Override
+ public String getHelpText() {
+ return "Updates profile data retrieved from Identity Provider in the displayed form";
+ }
+
+ @Override
+ public List<ProviderConfigProperty> getConfigProperties() {
+ return null;
+ }
+
+ @Override
+ public boolean isUserSetupAllowed() {
+ return false;
+ }
+}
diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/broker/IdpUsernamePasswordForm.java b/services/src/main/java/org/keycloak/authentication/authenticators/broker/IdpUsernamePasswordForm.java
new file mode 100644
index 0000000..b293d1b
--- /dev/null
+++ b/services/src/main/java/org/keycloak/authentication/authenticators/broker/IdpUsernamePasswordForm.java
@@ -0,0 +1,55 @@
+package org.keycloak.authentication.authenticators.broker;
+
+import javax.ws.rs.core.MultivaluedMap;
+import javax.ws.rs.core.Response;
+
+import org.keycloak.authentication.AuthenticationFlowContext;
+import org.keycloak.authentication.AuthenticationFlowError;
+import org.keycloak.authentication.AuthenticationFlowException;
+import org.keycloak.authentication.authenticators.browser.UsernamePasswordForm;
+import org.keycloak.authentication.authenticators.broker.util.SerializedBrokeredIdentityContext;
+import org.keycloak.login.LoginFormsProvider;
+import org.keycloak.models.UserModel;
+import org.keycloak.services.managers.AuthenticationManager;
+import org.keycloak.services.messages.Messages;
+
+/**
+ * Same like classic username+password form, but username is "known" and user can't change it
+ *
+ * @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
+ */
+public class IdpUsernamePasswordForm extends UsernamePasswordForm {
+
+ @Override
+ protected Response challenge(AuthenticationFlowContext context, MultivaluedMap<String, String> formData) {
+ UserModel existingUser = AbstractIdpAuthenticator.getExistingUser(context.getSession(), context.getRealm(), context.getClientSession());
+
+ return setupForm(context, formData, existingUser)
+ .setStatus(Response.Status.OK)
+ .createLogin();
+ }
+
+ @Override
+ protected boolean validateForm(AuthenticationFlowContext context, MultivaluedMap<String, String> formData) {
+ UserModel existingUser = AbstractIdpAuthenticator.getExistingUser(context.getSession(), context.getRealm(), context.getClientSession());
+ context.setUser(existingUser);
+
+ // Restore formData for the case of error
+ setupForm(context, formData, existingUser);
+
+ return validatePassword(context, formData);
+ }
+
+ protected LoginFormsProvider setupForm(AuthenticationFlowContext context, MultivaluedMap<String, String> formData, UserModel existingUser) {
+ SerializedBrokeredIdentityContext serializedCtx = SerializedBrokeredIdentityContext.readFromClientSession(context.getClientSession());
+ if (serializedCtx == null) {
+ throw new AuthenticationFlowException("Not found serialized context in clientSession", AuthenticationFlowError.IDENTITY_PROVIDER_ERROR);
+ }
+
+ formData.add(AuthenticationManager.FORM_USERNAME, existingUser.getUsername());
+ return context.form()
+ .setFormData(formData)
+ .setAttribute(LoginFormsProvider.USERNAME_EDIT_DISABLED, true)
+ .setSuccess(Messages.FEDERATED_IDENTITY_CONFIRM_REAUTHENTICATE_MESSAGE, existingUser.getUsername(), serializedCtx.getIdentityProviderId());
+ }
+}
diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/broker/IdpUsernamePasswordFormFactory.java b/services/src/main/java/org/keycloak/authentication/authenticators/broker/IdpUsernamePasswordFormFactory.java
new file mode 100644
index 0000000..2adaead
--- /dev/null
+++ b/services/src/main/java/org/keycloak/authentication/authenticators/broker/IdpUsernamePasswordFormFactory.java
@@ -0,0 +1,35 @@
+package org.keycloak.authentication.authenticators.broker;
+
+import org.keycloak.authentication.Authenticator;
+import org.keycloak.authentication.authenticators.browser.UsernamePasswordForm;
+import org.keycloak.authentication.authenticators.browser.UsernamePasswordFormFactory;
+import org.keycloak.models.KeycloakSession;
+
+/**
+ * @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
+ */
+public class IdpUsernamePasswordFormFactory extends UsernamePasswordFormFactory {
+
+ public static final String PROVIDER_ID = "idp-username-password-form";
+ public static final UsernamePasswordForm IDP_SINGLETON = new IdpUsernamePasswordForm();
+
+ @Override
+ public Authenticator create(KeycloakSession session) {
+ return IDP_SINGLETON;
+ }
+
+ @Override
+ public String getId() {
+ return PROVIDER_ID;
+ }
+
+ @Override
+ public String getHelpText() {
+ return "Validates a password from login form. Username is already known from identity provider authentication";
+ }
+
+ @Override
+ public String getDisplayType() {
+ return "Username Password Form for identity provider reauthentication";
+ }
+}
diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/broker/util/ExistingUserInfo.java b/services/src/main/java/org/keycloak/authentication/authenticators/broker/util/ExistingUserInfo.java
new file mode 100644
index 0000000..2d6ea7f
--- /dev/null
+++ b/services/src/main/java/org/keycloak/authentication/authenticators/broker/util/ExistingUserInfo.java
@@ -0,0 +1,62 @@
+package org.keycloak.authentication.authenticators.broker.util;
+
+import java.io.IOException;
+
+import org.keycloak.util.JsonSerialization;
+
+/**
+ * @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
+ */
+public class ExistingUserInfo {
+ private String existingUserId;
+ private String duplicateAttributeName;
+ private String duplicateAttributeValue;
+
+ public ExistingUserInfo() {}
+
+ public ExistingUserInfo(String existingUserId, String duplicateAttributeName, String duplicateAttributeValue) {
+ this.existingUserId = existingUserId;
+ this.duplicateAttributeName = duplicateAttributeName;
+ this.duplicateAttributeValue = duplicateAttributeValue;
+ }
+
+ public String getExistingUserId() {
+ return existingUserId;
+ }
+
+ public void setExistingUserId(String existingUserId) {
+ this.existingUserId = existingUserId;
+ }
+
+ public String getDuplicateAttributeName() {
+ return duplicateAttributeName;
+ }
+
+ public void setDuplicateAttributeName(String duplicateAttributeName) {
+ this.duplicateAttributeName = duplicateAttributeName;
+ }
+
+ public String getDuplicateAttributeValue() {
+ return duplicateAttributeValue;
+ }
+
+ public void setDuplicateAttributeValue(String duplicateAttributeValue) {
+ this.duplicateAttributeValue = duplicateAttributeValue;
+ }
+
+ public String serialize() {
+ try {
+ return JsonSerialization.writeValueAsString(this);
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ public static ExistingUserInfo deserialize(String serialized) {
+ try {
+ return JsonSerialization.readValue(serialized, ExistingUserInfo.class);
+ } catch (IOException ioe) {
+ throw new RuntimeException(ioe);
+ }
+ }
+}
diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/broker/util/SerializedBrokeredIdentityContext.java b/services/src/main/java/org/keycloak/authentication/authenticators/broker/util/SerializedBrokeredIdentityContext.java
new file mode 100644
index 0000000..8f1b026
--- /dev/null
+++ b/services/src/main/java/org/keycloak/authentication/authenticators/broker/util/SerializedBrokeredIdentityContext.java
@@ -0,0 +1,327 @@
+package org.keycloak.authentication.authenticators.broker.util;
+
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.codehaus.jackson.annotate.JsonIgnore;
+import org.keycloak.authentication.authenticators.broker.AbstractIdpAuthenticator;
+import org.keycloak.authentication.requiredactions.util.UpdateProfileContext;
+import org.keycloak.broker.provider.BrokeredIdentityContext;
+import org.keycloak.broker.provider.IdentityProvider;
+import org.keycloak.broker.provider.IdentityProviderDataMarshaller;
+import org.keycloak.common.util.Base64Url;
+import org.keycloak.common.util.reflections.Reflections;
+import org.keycloak.models.ClientSessionModel;
+import org.keycloak.models.IdentityProviderModel;
+import org.keycloak.models.KeycloakSession;
+import org.keycloak.models.ModelException;
+import org.keycloak.models.RealmModel;
+import org.keycloak.services.resources.IdentityBrokerService;
+import org.keycloak.util.JsonSerialization;
+
+/**
+ * @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
+ */
+public class SerializedBrokeredIdentityContext implements UpdateProfileContext {
+
+ private String id;
+ private String brokerUsername;
+ private String modelUsername;
+ private String email;
+ private String firstName;
+ private String lastName;
+ private String brokerSessionId;
+ private String brokerUserId;
+ private String code;
+ private String token;
+
+ private String identityProviderId;
+ private Map<String, ContextDataEntry> contextData = new HashMap<>();
+
+ @JsonIgnore
+ @Override
+ public boolean isEditUsernameAllowed() {
+ return true;
+ }
+
+ public String getId() {
+ return id;
+ }
+
+ public void setId(String id) {
+ this.id = id;
+ }
+
+ @JsonIgnore
+ @Override
+ public String getUsername() {
+ return modelUsername;
+ }
+
+ @Override
+ public void setUsername(String username) {
+ this.modelUsername = username;
+ }
+
+ public String getModelUsername() {
+ return modelUsername;
+ }
+
+ public void setModelUsername(String modelUsername) {
+ this.modelUsername = modelUsername;
+ }
+
+ public String getBrokerUsername() {
+ return brokerUsername;
+ }
+
+ public void setBrokerUsername(String modelUsername) {
+ this.brokerUsername = modelUsername;
+ }
+
+ @Override
+ public String getEmail() {
+ return email;
+ }
+
+ @Override
+ public void setEmail(String email) {
+ this.email = email;
+ }
+
+ @Override
+ public String getFirstName() {
+ return firstName;
+ }
+
+ @Override
+ public void setFirstName(String firstName) {
+ this.firstName = firstName;
+ }
+
+ @Override
+ public String getLastName() {
+ return lastName;
+ }
+
+ @Override
+ public void setLastName(String lastName) {
+ this.lastName = lastName;
+ }
+
+ public String getBrokerSessionId() {
+ return brokerSessionId;
+ }
+
+ public void setBrokerSessionId(String brokerSessionId) {
+ this.brokerSessionId = brokerSessionId;
+ }
+
+ public String getBrokerUserId() {
+ return brokerUserId;
+ }
+
+ public void setBrokerUserId(String brokerUserId) {
+ this.brokerUserId = brokerUserId;
+ }
+
+ public String getCode() {
+ return code;
+ }
+
+ public void setCode(String code) {
+ this.code = code;
+ }
+
+ public String getToken() {
+ return token;
+ }
+
+ public void setToken(String token) {
+ this.token = token;
+ }
+
+ public String getIdentityProviderId() {
+ return identityProviderId;
+ }
+
+ public void setIdentityProviderId(String identityProviderId) {
+ this.identityProviderId = identityProviderId;
+ }
+
+ public Map<String, ContextDataEntry> getContextData() {
+ return contextData;
+ }
+
+ public void setContextData(Map<String, ContextDataEntry> contextData) {
+ this.contextData = contextData;
+ }
+
+ @Override
+ public Map<String, List<String>> getAttributes() {
+ Map<String, List<String>> result = new HashMap<>();
+
+ for (Map.Entry<String, ContextDataEntry> entry : this.contextData.entrySet()) {
+ if (entry.getKey().startsWith("user.attributes.")) {
+ ContextDataEntry ctxEntry = entry.getValue();
+ String asString = ctxEntry.getData();
+ try {
+ List<String> asList = JsonSerialization.readValue(asString, List.class);
+ result.put(entry.getKey().substring(16), asList);
+ } catch (IOException ioe) {
+ throw new RuntimeException(ioe);
+ }
+ }
+ }
+
+ return result;
+ }
+
+ @Override
+ public void setAttribute(String key, List<String> value) {
+ try {
+ String listStr = JsonSerialization.writeValueAsString(value);
+ ContextDataEntry ctxEntry = ContextDataEntry.create(List.class.getName(), listStr);
+ this.contextData.put("user.attributes." + key, ctxEntry);
+ } catch (IOException ioe) {
+ throw new RuntimeException(ioe);
+ }
+ }
+
+ @Override
+ public List<String> getAttribute(String key) {
+ ContextDataEntry ctxEntry = this.contextData.get("user.attributes." + key);
+ if (ctxEntry != null) {
+ try {
+ String asString = ctxEntry.getData();
+ List<String> asList = JsonSerialization.readValue(asString, List.class);
+ return asList;
+ } catch (IOException ioe) {
+ throw new RuntimeException(ioe);
+ }
+ } else {
+ return null;
+ }
+ }
+
+ public BrokeredIdentityContext deserialize(KeycloakSession session, ClientSessionModel clientSession) {
+ BrokeredIdentityContext ctx = new BrokeredIdentityContext(getId());
+
+ ctx.setUsername(getBrokerUsername());
+ ctx.setModelUsername(getModelUsername());
+ ctx.setEmail(getEmail());
+ ctx.setFirstName(getFirstName());
+ ctx.setLastName(getLastName());
+ ctx.setBrokerSessionId(getBrokerSessionId());
+ ctx.setBrokerUserId(getBrokerUserId());
+ ctx.setCode(getCode());
+ ctx.setToken(getToken());
+
+ RealmModel realm = clientSession.getRealm();
+ IdentityProviderModel idpConfig = realm.getIdentityProviderByAlias(getIdentityProviderId());
+ if (idpConfig == null) {
+ throw new ModelException("Can't find identity provider with ID " + getIdentityProviderId() + " in realm " + realm.getName());
+ }
+ IdentityProvider idp = IdentityBrokerService.getIdentityProvider(session, realm, idpConfig.getAlias());
+ ctx.setIdpConfig(idpConfig);
+ ctx.setIdp(idp);
+
+ IdentityProviderDataMarshaller serializer = idp.getMarshaller();
+
+ for (Map.Entry<String, ContextDataEntry> entry : getContextData().entrySet()) {
+ try {
+ ContextDataEntry value = entry.getValue();
+ Class<?> clazz = Reflections.classForName(value.getClazz(), this.getClass().getClassLoader());
+
+ Object deserialized = serializer.deserialize(value.getData(), clazz);
+
+ ctx.getContextData().put(entry.getKey(), deserialized);
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ ctx.setClientSession(clientSession);
+ return ctx;
+ }
+
+ public static SerializedBrokeredIdentityContext serialize(BrokeredIdentityContext context) {
+ SerializedBrokeredIdentityContext ctx = new SerializedBrokeredIdentityContext();
+ ctx.setId(context.getId());
+ ctx.setBrokerUsername(context.getUsername());
+ ctx.setModelUsername(context.getModelUsername());
+ ctx.setEmail(context.getEmail());
+ ctx.setFirstName(context.getFirstName());
+ ctx.setLastName(context.getLastName());
+ ctx.setBrokerSessionId(context.getBrokerSessionId());
+ ctx.setBrokerUserId(context.getBrokerUserId());
+ ctx.setCode(context.getCode());
+ ctx.setToken(context.getToken());
+ ctx.setIdentityProviderId(context.getIdpConfig().getAlias());
+
+ IdentityProviderDataMarshaller serializer = context.getIdp().getMarshaller();
+
+ for (Map.Entry<String, Object> entry : context.getContextData().entrySet()) {
+ Object value = entry.getValue();
+ String serializedValue = serializer.serialize(value);
+
+ ContextDataEntry ctxEntry = ContextDataEntry.create(value.getClass().getName(), serializedValue);
+ ctx.getContextData().put(entry.getKey(), ctxEntry);
+ }
+ return ctx;
+ }
+
+ // Save this context as note to clientSession
+ public void saveToClientSession(ClientSessionModel clientSession) {
+ try {
+ String asString = JsonSerialization.writeValueAsString(this);
+ clientSession.setNote(AbstractIdpAuthenticator.BROKERED_CONTEXT_NOTE, asString);
+ } catch (IOException ioe) {
+ throw new RuntimeException(ioe);
+ }
+ }
+
+ public static SerializedBrokeredIdentityContext readFromClientSession(ClientSessionModel clientSession) {
+ String asString = clientSession.getNote(AbstractIdpAuthenticator.BROKERED_CONTEXT_NOTE);
+ if (asString == null) {
+ return null;
+ } else {
+ try {
+ return JsonSerialization.readValue(asString, SerializedBrokeredIdentityContext.class);
+ } catch (IOException ioe) {
+ throw new RuntimeException(ioe);
+ }
+ }
+ }
+
+ public static class ContextDataEntry {
+
+ private String clazz;
+ private String data;
+
+ public String getClazz() {
+ return clazz;
+ }
+
+ public void setClazz(String clazz) {
+ this.clazz = clazz;
+ }
+
+ public String getData() {
+ return data;
+ }
+
+ public void setData(String data) {
+ this.data = data;
+ }
+
+ public static ContextDataEntry create(String clazz, String data) {
+ ContextDataEntry entry = new ContextDataEntry();
+ entry.setClazz(clazz);
+ entry.setData(data);
+ return entry;
+ }
+ }
+}
diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/resetcred/ResetCredentialChooseUser.java b/services/src/main/java/org/keycloak/authentication/authenticators/resetcred/ResetCredentialChooseUser.java
index a624495..a3538aa 100755
--- a/services/src/main/java/org/keycloak/authentication/authenticators/resetcred/ResetCredentialChooseUser.java
+++ b/services/src/main/java/org/keycloak/authentication/authenticators/resetcred/ResetCredentialChooseUser.java
@@ -1,10 +1,12 @@
package org.keycloak.authentication.authenticators.resetcred;
+import org.jboss.logging.Logger;
import org.keycloak.Config;
import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.authentication.AuthenticationFlowError;
import org.keycloak.authentication.Authenticator;
import org.keycloak.authentication.AuthenticatorFactory;
+import org.keycloak.authentication.authenticators.broker.AbstractIdpAuthenticator;
import org.keycloak.authentication.authenticators.browser.AbstractUsernameFormAuthenticator;
import org.keycloak.email.EmailException;
import org.keycloak.email.EmailProvider;
@@ -36,10 +38,22 @@ import java.util.concurrent.TimeUnit;
*/
public class ResetCredentialChooseUser implements Authenticator, AuthenticatorFactory {
+ protected static Logger logger = Logger.getLogger(ResetCredentialChooseUser.class);
+
public static final String PROVIDER_ID = "reset-credentials-choose-user";
@Override
public void authenticate(AuthenticationFlowContext context) {
+ String existingUserId = context.getClientSession().getNote(AbstractIdpAuthenticator.EXISTING_USER_INFO);
+ if (existingUserId != null) {
+ UserModel existingUser = AbstractIdpAuthenticator.getExistingUser(context.getSession(), context.getRealm(), context.getClientSession());
+
+ logger.debugf("Forget-password triggered when reauthenticating user after first broker login. Skipping reset-credential-choose-user screen and using user '%s' ", existingUser.getUsername());
+ context.setUser(existingUser);
+ context.success();
+ return;
+ }
+
Response challenge = context.form().createPasswordReset();
context.challenge(challenge);
}
diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/resetcred/ResetCredentialEmail.java b/services/src/main/java/org/keycloak/authentication/authenticators/resetcred/ResetCredentialEmail.java
index 8749360..678aac2 100755
--- a/services/src/main/java/org/keycloak/authentication/authenticators/resetcred/ResetCredentialEmail.java
+++ b/services/src/main/java/org/keycloak/authentication/authenticators/resetcred/ResetCredentialEmail.java
@@ -14,6 +14,7 @@ import org.keycloak.events.Errors;
import org.keycloak.events.EventBuilder;
import org.keycloak.events.EventType;
import org.keycloak.models.AuthenticationExecutionModel;
+import org.keycloak.models.Constants;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.models.RealmModel;
@@ -34,7 +35,7 @@ import java.util.concurrent.TimeUnit;
*/
public class ResetCredentialEmail implements Authenticator, AuthenticatorFactory {
public static final String RESET_CREDENTIAL_SECRET = "RESET_CREDENTIAL_SECRET";
- public static final String KEY = "key";
+
protected static Logger logger = Logger.getLogger(ResetCredentialEmail.class);
public static final String PROVIDER_ID = "reset-credential-email";
@@ -67,7 +68,7 @@ public class ResetCredentialEmail implements Authenticator, AuthenticatorFactory
// it can only be guessed once, and it must match watch is stored in the client session.
String secret = HmacOTP.generateSecret(10);
context.getClientSession().setNote(RESET_CREDENTIAL_SECRET, secret);
- String link = UriBuilder.fromUri(context.getActionUrl()).queryParam(KEY, secret).build().toString();
+ String link = UriBuilder.fromUri(context.getActionUrl()).queryParam(Constants.KEY, secret).build().toString();
long expiration = TimeUnit.SECONDS.toMinutes(context.getRealm().getAccessCodeLifespanUserAction());
try {
@@ -93,7 +94,7 @@ public class ResetCredentialEmail implements Authenticator, AuthenticatorFactory
@Override
public void action(AuthenticationFlowContext context) {
String secret = context.getClientSession().getNote(RESET_CREDENTIAL_SECRET);
- String key = context.getUriInfo().getQueryParameters().getFirst(KEY);
+ String key = context.getUriInfo().getQueryParameters().getFirst(Constants.KEY);
// Can only guess once! We remove the note so another guess can't happen
context.getClientSession().removeNote(RESET_CREDENTIAL_SECRET);
diff --git a/services/src/main/java/org/keycloak/authentication/requiredactions/UpdateProfile.java b/services/src/main/java/org/keycloak/authentication/requiredactions/UpdateProfile.java
index 9630f3b..107f7cc 100755
--- a/services/src/main/java/org/keycloak/authentication/requiredactions/UpdateProfile.java
+++ b/services/src/main/java/org/keycloak/authentication/requiredactions/UpdateProfile.java
@@ -52,7 +52,7 @@ public class UpdateProfile implements RequiredActionProvider, RequiredActionFact
RealmModel realm = context.getRealm();
- List<FormMessage> errors = Validation.validateUpdateProfileForm(realm, formData);
+ List<FormMessage> errors = Validation.validateUpdateProfileForm(realm.isEditUsernameAllowed(), formData);
if (errors != null && !errors.isEmpty()) {
Response challenge = context.form()
.setErrors(errors)
diff --git a/services/src/main/java/org/keycloak/authentication/requiredactions/util/UpdateProfileContext.java b/services/src/main/java/org/keycloak/authentication/requiredactions/util/UpdateProfileContext.java
new file mode 100644
index 0000000..2acef37
--- /dev/null
+++ b/services/src/main/java/org/keycloak/authentication/requiredactions/util/UpdateProfileContext.java
@@ -0,0 +1,38 @@
+package org.keycloak.authentication.requiredactions.util;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Abstraction, which allows to display updateProfile page in various contexts (Required action of already existing user, or first identity provider
+ * login when user doesn't yet exists in Keycloak DB)
+ *
+ * @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
+ */
+public interface UpdateProfileContext {
+
+ boolean isEditUsernameAllowed();
+
+ String getUsername();
+
+ void setUsername(String username);
+
+ String getEmail();
+
+ void setEmail(String email);
+
+ String getFirstName();
+
+ void setFirstName(String firstName);
+
+ String getLastName();
+
+ void setLastName(String lastName);
+
+ Map<String, List<String>> getAttributes();
+
+ void setAttribute(String key, List<String> value);
+
+ List<String> getAttribute(String key);
+
+}
diff --git a/services/src/main/java/org/keycloak/authentication/requiredactions/util/UserUpdateProfileContext.java b/services/src/main/java/org/keycloak/authentication/requiredactions/util/UserUpdateProfileContext.java
new file mode 100644
index 0000000..55d6dda
--- /dev/null
+++ b/services/src/main/java/org/keycloak/authentication/requiredactions/util/UserUpdateProfileContext.java
@@ -0,0 +1,81 @@
+package org.keycloak.authentication.requiredactions.util;
+
+import java.util.List;
+import java.util.Map;
+
+import org.keycloak.models.RealmModel;
+import org.keycloak.models.UserModel;
+
+/**
+ * @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
+ */
+public class UserUpdateProfileContext implements UpdateProfileContext {
+
+ private final RealmModel realm;
+ private final UserModel user;
+
+ public UserUpdateProfileContext(RealmModel realm, UserModel user) {
+ this.realm = realm;
+ this.user = user;
+ }
+
+ @Override
+ public boolean isEditUsernameAllowed() {
+ return realm.isEditUsernameAllowed();
+ }
+
+ @Override
+ public String getUsername() {
+ return user.getUsername();
+ }
+
+ @Override
+ public void setUsername(String username) {
+ user.setUsername(username);
+ }
+
+ @Override
+ public String getEmail() {
+ return user.getEmail();
+ }
+
+ @Override
+ public void setEmail(String email) {
+ user.setEmail(email);
+ }
+
+ @Override
+ public String getFirstName() {
+ return user.getFirstName();
+ }
+
+ @Override
+ public void setFirstName(String firstName) {
+ user.setFirstName(firstName);
+ }
+
+ @Override
+ public String getLastName() {
+ return user.getLastName();
+ }
+
+ @Override
+ public void setLastName(String lastName) {
+ user.setLastName(lastName);
+ }
+
+ @Override
+ public Map<String, List<String>> getAttributes() {
+ return user.getAttributes();
+ }
+
+ @Override
+ public void setAttribute(String key, List<String> value) {
+ user.setAttribute(key, value);
+ }
+
+ @Override
+ public List<String> getAttribute(String key) {
+ return user.getAttribute(key);
+ }
+}
diff --git a/services/src/main/java/org/keycloak/services/messages/Messages.java b/services/src/main/java/org/keycloak/services/messages/Messages.java
index d092d16..5f2eeb3 100755
--- a/services/src/main/java/org/keycloak/services/messages/Messages.java
+++ b/services/src/main/java/org/keycloak/services/messages/Messages.java
@@ -64,9 +64,13 @@ public class Messages {
public static final String EMAIL_EXISTS = "emailExistsMessage";
- public static final String FEDERATED_IDENTITY_EMAIL_EXISTS = "federatedIdentityEmailExistsMessage";
+ public static final String FEDERATED_IDENTITY_EXISTS = "federatedIdentityExistsMessage";
- public static final String FEDERATED_IDENTITY_USERNAME_EXISTS = "federatedIdentityUsernameExistsMessage";
+ public static final String FEDERATED_IDENTITY_CONFIRM_LINK_MESSAGE = "federatedIdentityConfirmLinkMessage";
+
+ public static final String FEDERATED_IDENTITY_CONFIRM_REAUTHENTICATE_MESSAGE = "federatedIdentityConfirmReauthenticateMessage";
+
+ public static final String IDENTITY_PROVIDER_DIFFERENT_USER_MESSAGE = "identityProviderDifferentUserMessage";
public static final String CONFIGURE_TOTP = "configureTotpMessage";
@@ -76,6 +80,8 @@ public class Messages {
public static final String VERIFY_EMAIL = "verifyEmailMessage";
+ public static final String LINK_IDP = "linkIdpMessage";
+
public static final String EMAIL_VERIFIED = "emailVerifiedMessage";
public static final String EMAIL_SENT = "emailSentMessage";
@@ -147,6 +153,8 @@ public class Messages {
public static final String IDENTITY_PROVIDER_NOT_FOUND = "identityProviderNotFoundMessage";
+ public static final String IDENTITY_PROVIDER_LINK_SUCCESS = "identityProviderLinkSuccess";
+
public static final String IDENTITY_PROVIDER_NOT_UNIQUE = "identityProviderNotUniqueMessage";
public static final String REALM_SUPPORTS_NO_CREDENTIALS = "realmSupportsNoCredentialsMessage";
diff --git a/services/src/main/java/org/keycloak/services/resources/AccountService.java b/services/src/main/java/org/keycloak/services/resources/AccountService.java
index dc5fa8d..71f849b 100755
--- a/services/src/main/java/org/keycloak/services/resources/AccountService.java
+++ b/services/src/main/java/org/keycloak/services/resources/AccountService.java
@@ -366,7 +366,7 @@ public class AccountService extends AbstractSecuredLocalService {
UserModel user = auth.getUser();
- List<FormMessage> errors = Validation.validateUpdateProfileForm(realm, formData);
+ List<FormMessage> errors = Validation.validateUpdateProfileForm(realm.isEditUsernameAllowed(), formData);
if (errors != null && !errors.isEmpty()) {
setReferrerOnPage();
return account.setErrors(errors).setProfileFormData(formData).createResponse(AccountPages.ACCOUNT);
diff --git a/services/src/main/java/org/keycloak/services/resources/admin/IdentityProviderResource.java b/services/src/main/java/org/keycloak/services/resources/admin/IdentityProviderResource.java
index bf17f4b..b49cf91 100755
--- a/services/src/main/java/org/keycloak/services/resources/admin/IdentityProviderResource.java
+++ b/services/src/main/java/org/keycloak/services/resources/admin/IdentityProviderResource.java
@@ -79,7 +79,7 @@ public class IdentityProviderResource {
@Produces(MediaType.APPLICATION_JSON)
public IdentityProviderRepresentation getIdentityProvider() {
this.auth.requireView();
- IdentityProviderRepresentation rep = ModelToRepresentation.toRepresentation(this.identityProviderModel);
+ IdentityProviderRepresentation rep = ModelToRepresentation.toRepresentation(realm, this.identityProviderModel);
return rep;
}
@@ -117,7 +117,7 @@ public class IdentityProviderResource {
String newProviderId = providerRep.getAlias();
String oldProviderId = getProviderIdByInternalId(this.realm, internalId);
- this.realm.updateIdentityProvider(RepresentationToModel.toModel(providerRep));
+ this.realm.updateIdentityProvider(RepresentationToModel.toModel(realm, providerRep));
if (oldProviderId != null && !oldProviderId.equals(newProviderId)) {
diff --git a/services/src/main/java/org/keycloak/services/resources/admin/IdentityProvidersResource.java b/services/src/main/java/org/keycloak/services/resources/admin/IdentityProvidersResource.java
index d3dc33a..bf452ff 100755
--- a/services/src/main/java/org/keycloak/services/resources/admin/IdentityProvidersResource.java
+++ b/services/src/main/java/org/keycloak/services/resources/admin/IdentityProvidersResource.java
@@ -145,7 +145,7 @@ public class IdentityProvidersResource {
List<IdentityProviderRepresentation> representations = new ArrayList<IdentityProviderRepresentation>();
for (IdentityProviderModel identityProviderModel : realm.getIdentityProviders()) {
- representations.add(ModelToRepresentation.toRepresentation(identityProviderModel));
+ representations.add(ModelToRepresentation.toRepresentation(realm, identityProviderModel));
}
return representations;
}
@@ -164,7 +164,7 @@ public class IdentityProvidersResource {
this.auth.requireManage();
try {
- IdentityProviderModel identityProvider = RepresentationToModel.toModel(representation);
+ IdentityProviderModel identityProvider = RepresentationToModel.toModel(realm, representation);
this.realm.addIdentityProvider(identityProvider);
adminEvent.operation(OperationType.CREATE).resourcePath(uriInfo, identityProvider.getInternalId())
diff --git a/services/src/main/java/org/keycloak/services/resources/AttributeFormDataProcessor.java b/services/src/main/java/org/keycloak/services/resources/AttributeFormDataProcessor.java
index c9ec4c3..9fb60ec 100755
--- a/services/src/main/java/org/keycloak/services/resources/AttributeFormDataProcessor.java
+++ b/services/src/main/java/org/keycloak/services/resources/AttributeFormDataProcessor.java
@@ -3,6 +3,8 @@ package org.keycloak.services.resources;
import java.util.ArrayList;
import java.util.List;
+import org.keycloak.authentication.requiredactions.util.UpdateProfileContext;
+import org.keycloak.authentication.requiredactions.util.UserUpdateProfileContext;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
@@ -21,6 +23,11 @@ public class AttributeFormDataProcessor {
* @param user
*/
public static void process(MultivaluedMap<String, String> formData, RealmModel realm, UserModel user) {
+ UpdateProfileContext userCtx = new UserUpdateProfileContext(realm, user);
+ process(formData, realm, userCtx);
+ }
+
+ public static void process(MultivaluedMap<String, String> formData, RealmModel realm, UpdateProfileContext user) {
for (String key : formData.keySet()) {
if (!key.startsWith("user.attributes.")) continue;
String attribute = key.substring("user.attributes.".length());
@@ -36,7 +43,6 @@ public class AttributeFormDataProcessor {
user.setAttribute(attribute, modelValue);
}
-
}
private static void addOrSetValue(List<String> list, int index, String value) {
diff --git a/services/src/main/java/org/keycloak/services/resources/IdentityBrokerService.java b/services/src/main/java/org/keycloak/services/resources/IdentityBrokerService.java
index a171bdb..fda37ef 100755
--- a/services/src/main/java/org/keycloak/services/resources/IdentityBrokerService.java
+++ b/services/src/main/java/org/keycloak/services/resources/IdentityBrokerService.java
@@ -20,6 +20,9 @@ package org.keycloak.services.resources;
import org.jboss.logging.Logger;
import org.jboss.resteasy.spi.HttpRequest;
import org.jboss.resteasy.spi.ResteasyProviderFactory;
+import org.keycloak.OAuth2Constants;
+import org.keycloak.authentication.authenticators.broker.AbstractIdpAuthenticator;
+import org.keycloak.authentication.authenticators.broker.util.SerializedBrokeredIdentityContext;
import org.keycloak.common.ClientConnection;
import org.keycloak.authentication.AuthenticationProcessor;
import org.keycloak.broker.provider.AuthenticationRequest;
@@ -28,10 +31,12 @@ import org.keycloak.broker.provider.IdentityBrokerException;
import org.keycloak.broker.provider.IdentityProvider;
import org.keycloak.broker.provider.IdentityProviderFactory;
import org.keycloak.broker.provider.IdentityProviderMapper;
+import org.keycloak.common.util.Time;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.events.EventBuilder;
import org.keycloak.events.EventType;
+import org.keycloak.login.LoginFormsProvider;
import org.keycloak.models.AuthenticationFlowModel;
import org.keycloak.models.ClientModel;
import org.keycloak.models.ClientSessionModel;
@@ -49,7 +54,6 @@ import org.keycloak.models.utils.FormMessage;
import org.keycloak.protocol.oidc.TokenManager;
import org.keycloak.provider.ProviderFactory;
import org.keycloak.representations.AccessToken;
-import org.keycloak.representations.idm.IdentityProviderRepresentation;
import org.keycloak.services.managers.AppAuthManager;
import org.keycloak.services.managers.AuthenticationManager.AuthResult;
import org.keycloak.services.managers.BruteForceProtector;
@@ -70,6 +74,7 @@ import javax.ws.rs.core.Response.Status;
import javax.ws.rs.core.UriBuilder;
import javax.ws.rs.core.UriInfo;
+import java.net.URI;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
@@ -79,7 +84,6 @@ import java.util.Set;
import static org.keycloak.models.AccountRoles.MANAGE_ACCOUNT;
import static org.keycloak.models.ClientSessionModel.Action.AUTHENTICATE;
import static org.keycloak.models.Constants.ACCOUNT_MANAGEMENT_CLIENT_ID;
-import static org.keycloak.models.UserModel.RequiredAction.UPDATE_PROFILE;
/**
* <p></p>
@@ -285,27 +289,133 @@ public class IdentityBrokerService implements IdentityProvider.AuthenticationCal
}
if (federatedUser == null) {
- try {
- federatedUser = createUser(context);
- if (IdentityProviderRepresentation.UPFLM_ON.equals(identityProviderConfig.getUpdateProfileFirstLoginMode())
- || (IdentityProviderRepresentation.UPFLM_MISSING.equals(identityProviderConfig.getUpdateProfileFirstLoginMode()) && !Validation.validateUserMandatoryFields(realmModel, federatedUser))) {
- if (isDebugEnabled()) {
- LOGGER.debugf("Identity provider requires update profile action.", federatedUser);
- }
- federatedUser.addRequiredAction(UPDATE_PROFILE);
- }
- if(identityProviderConfig.isTrustEmail() && !Validation.isBlank(federatedUser.getEmail())){
- federatedUser.setEmailVerified(true);
+ LOGGER.debugf("Federated user not found for provider '%s' and broker username '%s' . Redirecting to flow for firstBrokerLogin", providerId, context.getUsername());
+
+ String username = context.getModelUsername();
+ if (username == null) {
+ if (this.realmModel.isRegistrationEmailAsUsername() && !Validation.isBlank(context.getEmail())) {
+ username = context.getEmail();
+ } else if (context.getUsername() == null) {
+ username = context.getIdpConfig().getAlias() + "." + context.getId();
+ } else {
+ username = context.getIdpConfig().getAlias() + "." + context.getUsername();
}
- } catch (Exception e) {
- return redirectToLoginPage(e, clientCode);
}
+ username = username.trim();
+ context.setModelUsername(username);
+
+ clientSession.setTimestamp(Time.currentTime());
+
+ SerializedBrokeredIdentityContext ctx = SerializedBrokeredIdentityContext.serialize(context);
+ ctx.saveToClientSession(clientSession);
+
+ URI redirect = LoginActionsService.firstBrokerLoginProcessor(uriInfo)
+ .queryParam(OAuth2Constants.CODE, context.getCode())
+ .build(realmModel.getName());
+ return Response.status(302).location(redirect).build();
+
} else {
updateFederatedIdentity(context, federatedUser);
+
+ boolean firstBrokerLoginInProgress = (clientSession.getNote(AbstractIdpAuthenticator.BROKERED_CONTEXT_NOTE) != null);
+ if (firstBrokerLoginInProgress) {
+ LOGGER.debugf("Reauthenticated with broker '%s' when linking user '%s' with other broker", context.getIdpConfig().getAlias(), federatedUser.getUsername());
+
+ UserModel linkingUser = AbstractIdpAuthenticator.getExistingUser(session, realmModel, clientSession);
+ if (!linkingUser.getId().equals(federatedUser.getId())) {
+ return redirectToErrorPage(Messages.IDENTITY_PROVIDER_DIFFERENT_USER_MESSAGE, federatedUser.getUsername(), linkingUser.getUsername());
+ }
+
+ clientSession.setAuthenticatedUser(federatedUser);
+ return afterFirstBrokerLogin(context.getCode());
+ }
+
+ return finishBrokerAuthentication(context, federatedUser, clientSession, providerId);
}
+ }
+
+ // Callback from LoginActionsService after first login with broker was done and Keycloak account is successfully linked/created
+ @GET
+ @Path("/after-first-broker-login")
+ public Response afterFirstBrokerLogin(@QueryParam("code") String code) {
+ ClientSessionCode clientCode = parseClientSessionCode(code);
+ ClientSessionModel clientSession = clientCode.getClientSession();
+
+ try {
+ SerializedBrokeredIdentityContext serializedCtx = SerializedBrokeredIdentityContext.readFromClientSession(clientSession);
+ if (serializedCtx == null) {
+ throw new IdentityBrokerException("Not found serialized context in clientSession");
+ }
+ BrokeredIdentityContext context = serializedCtx.deserialize(session, clientSession);
+ String providerId = context.getIdpConfig().getAlias();
+ // firstBrokerLogin workflow finished. Removing note now
+ clientSession.removeNote(AbstractIdpAuthenticator.BROKERED_CONTEXT_NOTE);
+
+ UserModel federatedUser = clientSession.getAuthenticatedUser();
+ if (federatedUser == null) {
+ throw new IdentityBrokerException("Couldn't found authenticated federatedUser in clientSession");
+ }
+
+ if (context.getIdpConfig().isAddReadTokenRoleOnCreate()) {
+ RoleModel readTokenRole = realmModel.getClientByClientId(Constants.BROKER_SERVICE_CLIENT_ID).getRole(Constants.READ_TOKEN_ROLE);
+ federatedUser.grantRole(readTokenRole);
+ }
+
+ // Add federated identity link here
+ FederatedIdentityModel federatedIdentityModel = new FederatedIdentityModel(context.getIdpConfig().getAlias(), context.getId(),
+ context.getUsername(), context.getToken());
+ session.users().addFederatedIdentity(realmModel, federatedUser, federatedIdentityModel);
+
+ String isRegisteredNewUser = clientSession.getNote(AbstractIdpAuthenticator.BROKER_REGISTERED_NEW_USER);
+ if (Boolean.parseBoolean(isRegisteredNewUser)) {
+
+ LOGGER.debugf("Registered new user '%s' after first login with identity provider '%s'. Identity provider username is '%s' . ", federatedUser.getUsername(), providerId, context.getUsername());
+
+ context.getIdp().importNewUser(session, realmModel, federatedUser, context);
+ Set<IdentityProviderMapperModel> mappers = realmModel.getIdentityProviderMappersByAlias(providerId);
+ if (mappers != null) {
+ KeycloakSessionFactory sessionFactory = session.getKeycloakSessionFactory();
+ for (IdentityProviderMapperModel mapper : mappers) {
+ IdentityProviderMapper target = (IdentityProviderMapper)sessionFactory.getProviderFactory(IdentityProviderMapper.class, mapper.getIdentityProviderMapper());
+ target.importNewUser(session, realmModel, federatedUser, mapper, context);
+ }
+ }
+
+ if (context.getIdpConfig().isTrustEmail() && !Validation.isBlank(federatedUser.getEmail()) && !Boolean.parseBoolean(clientSession.getNote(AbstractIdpAuthenticator.UPDATE_PROFILE_EMAIL_CHANGED))) {
+ LOGGER.debugf("Email verified automatically after registration of user '%s' through Identity provider '%s' ", federatedUser.getUsername(), context.getIdpConfig().getAlias());
+ federatedUser.setEmailVerified(true);
+ }
+
+ this.event.clone().user(federatedUser).event(EventType.REGISTER)
+ .detail(Details.IDENTITY_PROVIDER, providerId)
+ .detail(Details.IDENTITY_PROVIDER_USERNAME, context.getUsername())
+ .removeDetail("auth_method")
+ .success();
+
+ } else {
+ LOGGER.debugf("Linked existing keycloak user '%s' with identity provider '%s' . Identity provider username is '%s' .", federatedUser.getUsername(), providerId, context.getUsername());
+
+ updateFederatedIdentity(context, federatedUser);
+ }
+
+ String isDifferentBrowser = clientSession.getNote(AbstractIdpAuthenticator.IS_DIFFERENT_BROWSER);
+ if (Boolean.parseBoolean(isDifferentBrowser)) {
+ session.sessions().removeClientSession(realmModel, clientSession);
+ return session.getProvider(LoginFormsProvider.class)
+ .setSuccess(Messages.IDENTITY_PROVIDER_LINK_SUCCESS, context.getIdpConfig().getAlias(), context.getUsername())
+ .createInfoPage();
+ } else {
+ return finishBrokerAuthentication(context, federatedUser, clientSession, providerId);
+ }
+ } catch (Exception e) {
+ // TODO?
+ return redirectToErrorPage(Messages.IDENTITY_PROVIDER_UNEXPECTED_ERROR, e);
+ }
+ }
+ private Response finishBrokerAuthentication(BrokeredIdentityContext context, UserModel federatedUser, ClientSessionModel clientSession, String providerId) {
UserSessionModel userSession = this.session.sessions()
.createUserSession(this.realmModel, federatedUser, federatedUser.getUsername(), this.clientConnection.getRemoteAddr(), "broker", false, context.getBrokerSessionId(), context.getBrokerUserId());
@@ -376,7 +486,7 @@ public class IdentityBrokerService implements IdentityProvider.AuthenticationCal
FederatedIdentityModel federatedIdentityModel = this.session.users().getFederatedIdentity(federatedUser, context.getIdpConfig().getAlias(), this.realmModel);
// Skip DB write if tokens are null or equal
- if (context.getIdpConfig().isStoreToken() && !ObjectUtil.isEqualOrNull(context.getToken(), federatedIdentityModel.getToken())) {
+ if (context.getIdpConfig().isStoreToken() && !ObjectUtil.isEqualOrBothNull(context.getToken(), federatedIdentityModel.getToken())) {
federatedIdentityModel.setToken(context.getToken());
this.session.users().updateFederatedIdentity(this.realmModel, federatedUser, federatedIdentityModel);
@@ -412,6 +522,7 @@ public class IdentityBrokerService implements IdentityProvider.AuthenticationCal
LOGGER.debugf("Got authorization code from client [%s].", client.getClientId());
this.event.client(client);
+ this.session.getContext().setClient(client);
if (clientSession.getUserSession() != null) {
this.event.session(clientSession.getUserSession());
@@ -534,100 +645,11 @@ public class IdentityBrokerService implements IdentityProvider.AuthenticationCal
}
private IdentityProviderModel getIdentityProviderConfig(String providerId) {
- for (IdentityProviderModel model : this.realmModel.getIdentityProviders()) {
- if (model.getAlias().equals(providerId)) {
- return model;
- }
- }
-
- throw new IdentityBrokerException("Configuration for identity provider [" + providerId + "] not found.");
- }
-
- private UserModel createUser(BrokeredIdentityContext context) {
- FederatedIdentityModel federatedIdentityModel = new FederatedIdentityModel(context.getIdpConfig().getAlias(), context.getId(),
- context.getUsername(), context.getToken());
- // Check if no user already exists with this username or email
- UserModel existingUser = null;
-
- if (context.getEmail() != null) {
- existingUser = this.session.users().getUserByEmail(context.getEmail(), this.realmModel);
+ IdentityProviderModel model = this.realmModel.getIdentityProviderByAlias(providerId);
+ if (model == null) {
+ throw new IdentityBrokerException("Configuration for identity provider [" + providerId + "] not found.");
}
-
- if (existingUser != null) {
- fireErrorEvent(Errors.FEDERATED_IDENTITY_EMAIL_EXISTS);
- throw new IdentityBrokerException(Messages.FEDERATED_IDENTITY_EMAIL_EXISTS);
- }
- String username = context.getModelUsername();
- if (username == null) {
- username = context.getUsername();
- if (this.realmModel.isRegistrationEmailAsUsername() && !Validation.isBlank(context.getEmail())) {
- username = context.getEmail();
- } else if (username == null) {
- username = context.getIdpConfig().getAlias() + "." + context.getId();
- } else {
- username = context.getIdpConfig().getAlias() + "." + context.getUsername();
- }
- }
- if (username == null) {
- LOGGER.warn("Unknown username");
- fireErrorEvent(Errors.FEDERATED_IDENTITY_USERNAME_EXISTS);
- throw new IdentityBrokerException(Messages.FEDERATED_IDENTITY_USERNAME_EXISTS);
-
- }
- username = username.trim();
-
- existingUser = this.session.users().getUserByUsername(username, this.realmModel);
-
- if (existingUser != null) {
- fireErrorEvent(Errors.FEDERATED_IDENTITY_USERNAME_EXISTS);
- throw new IdentityBrokerException(Messages.FEDERATED_IDENTITY_USERNAME_EXISTS);
- }
-
- if (isDebugEnabled()) {
- LOGGER.debugf("Creating account from identity [%s].", federatedIdentityModel);
- }
-
- UserModel federatedUser = this.session.users().addUser(this.realmModel, username);
-
- if (isDebugEnabled()) {
- LOGGER.debugf("Account [%s] created.", federatedUser);
- }
-
- federatedUser.setEnabled(true);
- federatedUser.setEmail(context.getEmail());
- federatedUser.setFirstName(context.getFirstName());
- federatedUser.setLastName(context.getLastName());
-
-
- if (context.getIdpConfig().isAddReadTokenRoleOnCreate()) {
- RoleModel readTokenRole = realmModel.getClientByClientId(Constants.BROKER_SERVICE_CLIENT_ID).getRole(Constants.READ_TOKEN_ROLE);
- federatedUser.grantRole(readTokenRole);
- }
-
- if (context.getIdpConfig().isStoreToken()) {
- federatedIdentityModel.setToken(context.getToken());
- }
-
- this.session.users().addFederatedIdentity(this.realmModel, federatedUser, federatedIdentityModel);
-
- context.getIdp().importNewUser(session, realmModel, federatedUser, context);
- Set<IdentityProviderMapperModel> mappers = realmModel.getIdentityProviderMappersByAlias(context.getIdpConfig().getAlias());
- if (mappers != null) {
- KeycloakSessionFactory sessionFactory = session.getKeycloakSessionFactory();
- for (IdentityProviderMapperModel mapper : mappers) {
- IdentityProviderMapper target = (IdentityProviderMapper)sessionFactory.getProviderFactory(IdentityProviderMapper.class, mapper.getIdentityProviderMapper());
- target.importNewUser(session, realmModel, federatedUser, mapper, context);
- }
- }
-
-
- this.event.clone().user(federatedUser).event(EventType.REGISTER)
- .detail(Details.IDENTITY_PROVIDER, federatedIdentityModel.getIdentityProvider())
- .detail(Details.IDENTITY_PROVIDER_USERNAME, context.getUsername())
- .removeDetail("auth_method")
- .success();
-
- return federatedUser;
+ return model;
}
private Response corsResponse(Response response, ClientModel clientModel) {
diff --git a/services/src/main/java/org/keycloak/services/resources/LoginActionsService.java b/services/src/main/java/org/keycloak/services/resources/LoginActionsService.java
index 20de78b..8f845c0 100755
--- a/services/src/main/java/org/keycloak/services/resources/LoginActionsService.java
+++ b/services/src/main/java/org/keycloak/services/resources/LoginActionsService.java
@@ -23,8 +23,10 @@ package org.keycloak.services.resources;
import org.jboss.logging.Logger;
import org.jboss.resteasy.spi.HttpRequest;
-import org.keycloak.authentication.AuthenticationFlowError;
+import org.keycloak.authentication.authenticators.broker.AbstractIdpAuthenticator;
import org.keycloak.authentication.requiredactions.VerifyEmail;
+import org.keycloak.authentication.authenticators.broker.util.SerializedBrokeredIdentityContext;
+import org.keycloak.broker.provider.BrokeredIdentityContext;
import org.keycloak.common.ClientConnection;
import org.keycloak.OAuth2Constants;
import org.keycloak.authentication.AuthenticationProcessor;
@@ -33,6 +35,7 @@ import org.keycloak.authentication.RequiredActionContextResult;
import org.keycloak.authentication.RequiredActionFactory;
import org.keycloak.authentication.RequiredActionProvider;
import org.keycloak.authentication.authenticators.browser.AbstractUsernameFormAuthenticator;
+import org.keycloak.common.util.Time;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.events.EventBuilder;
@@ -51,7 +54,6 @@ import org.keycloak.models.UserModel;
import org.keycloak.models.UserModel.RequiredAction;
import org.keycloak.models.UserSessionModel;
import org.keycloak.models.utils.FormMessage;
-import org.keycloak.models.utils.HmacOTP;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.protocol.LoginProtocol;
import org.keycloak.protocol.RestartLoginCookie;
@@ -92,6 +94,7 @@ public class LoginActionsService {
public static final String REGISTRATION_PATH = "registration";
public static final String RESET_CREDENTIALS_PATH = "reset-credentials";
public static final String REQUIRED_ACTION = "required-action";
+ public static final String FIRST_BROKER_LOGIN_PATH = "first-broker-login";
private RealmModel realm;
@@ -134,6 +137,10 @@ public class LoginActionsService {
return loginActionsBaseUrl(uriInfo).path(LoginActionsService.class, "processRegister");
}
+ public static UriBuilder firstBrokerLoginProcessor(UriInfo uriInfo) {
+ return loginActionsBaseUrl(uriInfo).path(LoginActionsService.class, "firstBrokerLoginGet");
+ }
+
public static UriBuilder loginActionsBaseUrl(UriBuilder baseUriBuilder) {
return baseUriBuilder.path(RealmsResource.class).path(RealmsResource.class, "getLoginActionsService");
}
@@ -208,7 +215,7 @@ public class LoginActionsService {
ClientSessionModel clientSession = RestartLoginCookie.restartSession(session, realm, code);
if (clientSession != null) {
event.clone().detail(Details.RESTART_AFTER_TIMEOUT, "true").error(Errors.EXPIRED_CODE);
- response = processFlow(null, clientSession, AUTHENTICATE_PATH, realm.getBrowserFlow(), Messages.LOGIN_TIMEOUT);
+ response = processFlow(null, clientSession, AUTHENTICATE_PATH, realm.getBrowserFlow(), Messages.LOGIN_TIMEOUT, new AuthenticationProcessor());
return false;
}
} catch (Exception e) {
@@ -267,11 +274,10 @@ public class LoginActionsService {
}
protected Response processAuthentication(String execution, ClientSessionModel clientSession, String errorMessage) {
- return processFlow(execution, clientSession, AUTHENTICATE_PATH, realm.getBrowserFlow(), errorMessage);
+ return processFlow(execution, clientSession, AUTHENTICATE_PATH, realm.getBrowserFlow(), errorMessage, new AuthenticationProcessor());
}
- protected Response processFlow(String execution, ClientSessionModel clientSession, String flowPath, AuthenticationFlowModel flow, String errorMessage) {
- AuthenticationProcessor processor = new AuthenticationProcessor();
+ protected Response processFlow(String execution, ClientSessionModel clientSession, String flowPath, AuthenticationFlowModel flow, String errorMessage, AuthenticationProcessor processor) {
processor.setClientSession(clientSession)
.setFlowPath(flowPath)
.setBrowserFlow(true)
@@ -384,12 +390,33 @@ public class LoginActionsService {
}
protected Response processResetCredentials(String execution, ClientSessionModel clientSession, String errorMessage) {
- return processFlow(execution, clientSession, RESET_CREDENTIALS_PATH, realm.getResetCredentialsFlow(), errorMessage);
+ AuthenticationProcessor authProcessor = new AuthenticationProcessor() {
+
+ @Override
+ protected Response authenticationComplete() {
+ boolean firstBrokerLoginInProgress = (clientSession.getNote(AbstractIdpAuthenticator.BROKERED_CONTEXT_NOTE) != null);
+ if (firstBrokerLoginInProgress) {
+
+ UserModel linkingUser = AbstractIdpAuthenticator.getExistingUser(session, realm, clientSession);
+ if (!linkingUser.getId().equals(clientSession.getAuthenticatedUser().getId())) {
+ return ErrorPage.error(session, Messages.IDENTITY_PROVIDER_DIFFERENT_USER_MESSAGE, clientSession.getAuthenticatedUser().getUsername(), linkingUser.getUsername());
+ }
+
+ logger.debugf("Forget-password flow finished when authenticated user '%s' after first broker login.", linkingUser.getUsername());
+
+ return redirectToAfterFirstBrokerLoginEndpoint(clientSession);
+ } else {
+ return super.authenticationComplete();
+ }
+ }
+ };
+
+ return processFlow(execution, clientSession, RESET_CREDENTIALS_PATH, realm.getResetCredentialsFlow(), errorMessage, authProcessor);
}
protected Response processRegistration(String execution, ClientSessionModel clientSession, String errorMessage) {
- return processFlow(execution, clientSession, REGISTRATION_PATH, realm.getRegistrationFlow(), errorMessage);
+ return processFlow(execution, clientSession, REGISTRATION_PATH, realm.getRegistrationFlow(), errorMessage, new AuthenticationProcessor());
}
@@ -450,6 +477,60 @@ public class LoginActionsService {
return processRegistration(execution, clientSession, null);
}
+ @Path(FIRST_BROKER_LOGIN_PATH)
+ @GET
+ public Response firstBrokerLoginGet(@QueryParam("code") String code,
+ @QueryParam("execution") String execution) {
+ return firstBrokerLogin(code, execution);
+ }
+
+ @Path(FIRST_BROKER_LOGIN_PATH)
+ @POST
+ public Response firstBrokerLoginPost(@QueryParam("code") String code,
+ @QueryParam("execution") String execution) {
+ return firstBrokerLogin(code, execution);
+ }
+
+ protected Response firstBrokerLogin(String code, String execution) {
+ event.event(EventType.IDENTITY_PROVIDER_FIRST_LOGIN);
+
+ Checks checks = new Checks();
+ if (!checks.verifyCode(code, ClientSessionModel.Action.AUTHENTICATE.name())) {
+ return checks.response;
+ }
+ event.detail(Details.CODE_ID, code);
+ ClientSessionCode clientSessionCode = checks.clientCode;
+ ClientSessionModel clientSession = clientSessionCode.getClientSession();
+
+ SerializedBrokeredIdentityContext serializedCtx = SerializedBrokeredIdentityContext.readFromClientSession(clientSession);
+ if (serializedCtx == null) {
+ throw new WebApplicationException(ErrorPage.error(session, "Not found serialized context in clientSession"));
+ }
+ BrokeredIdentityContext brokerContext = serializedCtx.deserialize(session, clientSession);
+ AuthenticationFlowModel firstBrokerLoginFlow = realm.getAuthenticationFlowById(brokerContext.getIdpConfig().getFirstBrokerLoginFlowId());
+
+ AuthenticationProcessor processor = new AuthenticationProcessor() {
+
+ @Override
+ protected Response authenticationComplete() {
+ return redirectToAfterFirstBrokerLoginEndpoint(clientSession);
+ }
+
+ };
+
+ return processFlow(execution, clientSession, FIRST_BROKER_LOGIN_PATH, firstBrokerLoginFlow, null, processor);
+ }
+
+ private Response redirectToAfterFirstBrokerLoginEndpoint(ClientSessionModel clientSession) {
+ ClientSessionCode accessCode = new ClientSessionCode(realm, clientSession);
+ clientSession.setTimestamp(Time.currentTime());
+
+ URI redirect = Urls.identityProviderAfterFirstBrokerLogin(uriInfo.getBaseUri(), realm.getName(), accessCode.getCode());
+ logger.debugf("Redirecting to '%s' ", redirect);
+
+ return Response.status(302).location(redirect).build();
+ }
+
/**
* OAuth grant page. You should not invoked this directly!
*
@@ -627,6 +708,10 @@ public class LoginActionsService {
}
private String getActionCookie() {
+ return getActionCookie(headers, realm, uriInfo, clientConnection);
+ }
+
+ public static String getActionCookie(HttpHeaders headers, RealmModel realm, UriInfo uriInfo, ClientConnection clientConnection) {
Cookie cookie = headers.getCookies().get(ACTION_COOKIE);
AuthenticationManager.expireCookie(realm, ACTION_COOKIE, AuthenticationManager.getRealmCookiePath(realm, uriInfo), realm.getSslRequired().isRequired(clientConnection), clientConnection);
return cookie != null ? cookie.getValue() : null;
diff --git a/services/src/main/java/org/keycloak/services/Urls.java b/services/src/main/java/org/keycloak/services/Urls.java
index cac365e..51c6182 100755
--- a/services/src/main/java/org/keycloak/services/Urls.java
+++ b/services/src/main/java/org/keycloak/services/Urls.java
@@ -32,6 +32,8 @@ import org.keycloak.services.resources.RealmsResource;
import org.keycloak.services.resources.ThemeResource;
import javax.ws.rs.core.UriBuilder;
+import javax.ws.rs.core.UriInfo;
+
import java.net.URI;
/**
@@ -94,6 +96,13 @@ public class Urls {
return identityProviderAuthnRequest(baseURI, providerId, realmName, null);
}
+ public static URI identityProviderAfterFirstBrokerLogin(URI baseUri, String realmName, String accessCode) {
+ return realmBase(baseUri).path(RealmsResource.class, "getBrokerService")
+ .path(IdentityBrokerService.class, "afterFirstBrokerLogin")
+ .replaceQueryParam(OAuth2Constants.CODE, accessCode)
+ .build(realmName);
+ }
+
public static URI accountTotpPage(URI baseUri, String realmId) {
return accountBase(baseUri).path(AccountService.class, "totpPage").build(realmId);
}
@@ -204,6 +213,11 @@ public class Urls {
return loginActionsBase(baseUri).path(LoginActionsService.class, "processConsent").build(realmId);
}
+ public static URI firstBrokerLoginProcessor(URI baseUri, String realmName) {
+ return loginActionsBase(baseUri).path(LoginActionsService.class, "firstBrokerLoginGet")
+ .build(realmName);
+ }
+
public static String localeCookiePath(URI baseUri, String realmName){
return realmBase(baseUri).path(realmName).build().getRawPath();
}
diff --git a/services/src/main/java/org/keycloak/services/validation/Validation.java b/services/src/main/java/org/keycloak/services/validation/Validation.java
index d3abc4d..0a3c9bb 100755
--- a/services/src/main/java/org/keycloak/services/validation/Validation.java
+++ b/services/src/main/java/org/keycloak/services/validation/Validation.java
@@ -1,8 +1,8 @@
package org.keycloak.services.validation;
+import org.keycloak.authentication.requiredactions.util.UpdateProfileContext;
import org.keycloak.models.PasswordPolicy;
import org.keycloak.models.RealmModel;
-import org.keycloak.models.UserModel;
import org.keycloak.models.utils.FormMessage;
import org.keycloak.representations.idm.CredentialRepresentation;
import org.keycloak.services.messages.Messages;
@@ -68,13 +68,13 @@ public class Validation {
}
public static List<FormMessage> validateUpdateProfileForm(MultivaluedMap<String, String> formData) {
- return validateUpdateProfileForm(null, formData);
+ return validateUpdateProfileForm(false, formData);
}
- public static List<FormMessage> validateUpdateProfileForm(RealmModel realm, MultivaluedMap<String, String> formData) {
+ public static List<FormMessage> validateUpdateProfileForm(boolean editUsernameAllowed, MultivaluedMap<String, String> formData) {
List<FormMessage> errors = new ArrayList<>();
- if (realm != null && realm.isEditUsernameAllowed() && isBlank(formData.getFirst(FIELD_USERNAME))) {
+ if (editUsernameAllowed && isBlank(formData.getFirst(FIELD_USERNAME))) {
addError(errors, FIELD_USERNAME, Messages.MISSING_USERNAME);
}
@@ -102,7 +102,7 @@ public class Validation {
* @param user to validate
* @return true if user object contains all mandatory values, false if some mandatory value is missing
*/
- public static boolean validateUserMandatoryFields(RealmModel realm, UserModel user){
+ public static boolean validateUserMandatoryFields(RealmModel realm, UpdateProfileContext user){
return!(isBlank(user.getFirstName()) || isBlank(user.getLastName()) || isBlank(user.getEmail()));
}
diff --git a/services/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory b/services/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory
index d6cada5..ec98dd2 100755
--- a/services/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory
+++ b/services/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory
@@ -9,3 +9,8 @@ org.keycloak.authentication.authenticators.resetcred.ResetCredentialChooseUser
org.keycloak.authentication.authenticators.resetcred.ResetCredentialEmail
org.keycloak.authentication.authenticators.resetcred.ResetOTP
org.keycloak.authentication.authenticators.resetcred.ResetPassword
+org.keycloak.authentication.authenticators.broker.IdpUpdateProfileAuthenticatorFactory
+org.keycloak.authentication.authenticators.broker.IdpDetectDuplicationsAuthenticatorFactory
+org.keycloak.authentication.authenticators.broker.IdpConfirmLinkAuthenticatorFactory
+org.keycloak.authentication.authenticators.broker.IdpEmailVerificationAuthenticatorFactory
+org.keycloak.authentication.authenticators.broker.IdpUsernamePasswordFormFactory
diff --git a/testsuite/integration/src/test/java/org/keycloak/testsuite/broker/AbstractIdentityProviderTest.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/broker/AbstractIdentityProviderTest.java
index d7084c1..f1d4e10 100755
--- a/testsuite/integration/src/test/java/org/keycloak/testsuite/broker/AbstractIdentityProviderTest.java
+++ b/testsuite/integration/src/test/java/org/keycloak/testsuite/broker/AbstractIdentityProviderTest.java
@@ -434,7 +434,8 @@ public abstract class AbstractIdentityProviderTest {
loginPage.findSocialButton(getProviderId());
}
- @Test
+ // TODO: Reenable and adjust to KEYCLOAK-1750 changed behaviour
+ // @Test
public void testUserAlreadyExistsWhenUpdatingProfile() {
this.driver.navigate().to("http://localhost:8081/test-app/");
@@ -469,7 +470,8 @@ public abstract class AbstractIdentityProviderTest {
assertNotNull(federatedUser);
}
- @Test
+ // TODO: Reenable and adjust to KEYCLOAK-1750 changed behaviour
+ // @Test
public void testUserAlreadyExistsWhenNotUpdatingProfile() {
IdentityProviderModel identityProviderModel = getIdentityProviderModel();
diff --git a/testsuite/integration/src/test/java/org/keycloak/testsuite/broker/ImportIdentityProviderTest.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/broker/ImportIdentityProviderTest.java
index 8914e7c..b5bc056 100755
--- a/testsuite/integration/src/test/java/org/keycloak/testsuite/broker/ImportIdentityProviderTest.java
+++ b/testsuite/integration/src/test/java/org/keycloak/testsuite/broker/ImportIdentityProviderTest.java
@@ -30,9 +30,9 @@ import org.keycloak.broker.oidc.OIDCIdentityProviderFactory;
import org.keycloak.broker.saml.SAMLIdentityProvider;
import org.keycloak.broker.saml.SAMLIdentityProviderConfig;
import org.keycloak.broker.saml.SAMLIdentityProviderFactory;
-import org.keycloak.models.ClientModel;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.RealmModel;
+import org.keycloak.models.utils.DefaultAuthenticationFlows;
import org.keycloak.representations.idm.IdentityProviderRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.social.facebook.FacebookIdentityProvider;
@@ -63,7 +63,7 @@ public class ImportIdentityProviderTest extends AbstractIdentityProviderModelTes
public void testInstallation() throws Exception {
RealmModel realm = installTestRealm();
- assertIdentityProviderConfig(realm.getIdentityProviders());
+ assertIdentityProviderConfig(realm, realm.getIdentityProviders());
assertTrue(realm.isIdentityFederationEnabled());
this.realmManager.removeRealm(realm);
@@ -85,6 +85,7 @@ public class ImportIdentityProviderTest extends AbstractIdentityProviderModelTes
identityProviderModel.setTrustEmail(true);
identityProviderModel.setStoreToken(true);
identityProviderModel.setAuthenticateByDefault(true);
+ identityProviderModel.setFirstBrokerLoginFlowId(realm.getBrowserFlow().getId());
realm.updateIdentityProvider(identityProviderModel);
@@ -100,6 +101,7 @@ public class ImportIdentityProviderTest extends AbstractIdentityProviderModelTes
assertTrue(identityProviderModel.isTrustEmail());
assertTrue(identityProviderModel.isStoreToken());
assertTrue(identityProviderModel.isAuthenticateByDefault());
+ assertEquals(identityProviderModel.getFirstBrokerLoginFlowId(), realm.getBrowserFlow().getId());
identityProviderModel.getConfig().remove("config-added");
identityProviderModel.setEnabled(true);
@@ -122,7 +124,7 @@ public class ImportIdentityProviderTest extends AbstractIdentityProviderModelTes
this.realmManager.removeRealm(realm);
}
- private void assertIdentityProviderConfig(List<IdentityProviderModel> identityProviders) {
+ private void assertIdentityProviderConfig(RealmModel realm, List<IdentityProviderModel> identityProviders) {
assertFalse(identityProviders.isEmpty());
Set<String> checkedProviders = new HashSet<String>(getExpectedProviders());
@@ -138,9 +140,9 @@ public class ImportIdentityProviderTest extends AbstractIdentityProviderModelTes
} else if (OIDCIdentityProviderFactory.PROVIDER_ID.equals(providerId)) {
assertOidcIdentityProviderConfig(identityProvider);
} else if (FacebookIdentityProviderFactory.PROVIDER_ID.equals(providerId)) {
- assertFacebookIdentityProviderConfig(identityProvider);
+ assertFacebookIdentityProviderConfig(realm, identityProvider);
} else if (GitHubIdentityProviderFactory.PROVIDER_ID.equals(providerId)) {
- assertGitHubIdentityProviderConfig(identityProvider);
+ assertGitHubIdentityProviderConfig(realm, identityProvider);
} else if (TwitterIdentityProviderFactory.PROVIDER_ID.equals(providerId)) {
assertTwitterIdentityProviderConfig(identityProvider);
} else if (LinkedInIdentityProviderFactory.PROVIDER_ID.equals(providerId)) {
@@ -213,7 +215,7 @@ public class ImportIdentityProviderTest extends AbstractIdentityProviderModelTes
assertEquals("clientSecret", config.getClientSecret());
}
- private void assertFacebookIdentityProviderConfig(IdentityProviderModel identityProvider) {
+ private void assertFacebookIdentityProviderConfig(RealmModel realm, IdentityProviderModel identityProvider) {
FacebookIdentityProvider facebookIdentityProvider = new FacebookIdentityProviderFactory().create(identityProvider);
OAuth2IdentityProviderConfig config = facebookIdentityProvider.getConfig();
@@ -226,12 +228,13 @@ public class ImportIdentityProviderTest extends AbstractIdentityProviderModelTes
assertEquals(false, config.isStoreToken());
assertEquals("clientId", config.getClientId());
assertEquals("clientSecret", config.getClientSecret());
+ assertEquals(realm.getBrowserFlow().getId(), identityProvider.getFirstBrokerLoginFlowId());
assertEquals(FacebookIdentityProvider.AUTH_URL, config.getAuthorizationUrl());
assertEquals(FacebookIdentityProvider.TOKEN_URL, config.getTokenUrl());
assertEquals(FacebookIdentityProvider.PROFILE_URL, config.getUserInfoUrl());
}
- private void assertGitHubIdentityProviderConfig(IdentityProviderModel identityProvider) {
+ private void assertGitHubIdentityProviderConfig(RealmModel realm, IdentityProviderModel identityProvider) {
GitHubIdentityProvider gitHubIdentityProvider = new GitHubIdentityProviderFactory().create(identityProvider);
OAuth2IdentityProviderConfig config = gitHubIdentityProvider.getConfig();
@@ -244,6 +247,7 @@ public class ImportIdentityProviderTest extends AbstractIdentityProviderModelTes
assertEquals(false, config.isStoreToken());
assertEquals("clientId", config.getClientId());
assertEquals("clientSecret", config.getClientSecret());
+ assertEquals(realm.getFlowByAlias(DefaultAuthenticationFlows.FIRST_BROKER_LOGIN_FLOW).getId(), identityProvider.getFirstBrokerLoginFlowId());
assertEquals(GitHubIdentityProvider.AUTH_URL, config.getAuthorizationUrl());
assertEquals(GitHubIdentityProvider.TOKEN_URL, config.getTokenUrl());
assertEquals(GitHubIdentityProvider.PROFILE_URL, config.getUserInfoUrl());
diff --git a/testsuite/integration/src/test/resources/broker-test/test-realm-with-broker.json b/testsuite/integration/src/test/resources/broker-test/test-realm-with-broker.json
index 410d373..a4f204d 100755
--- a/testsuite/integration/src/test/resources/broker-test/test-realm-with-broker.json
+++ b/testsuite/integration/src/test/resources/broker-test/test-realm-with-broker.json
@@ -30,6 +30,7 @@
"providerId" : "facebook",
"enabled": true,
"updateProfileFirstLogin" : "false",
+ "firstBrokerLoginFlowAlias" : "browser",
"config": {
"authorizationUrl": "authorizationUrl",
"tokenUrl": "tokenUrl",