KEYCLOAK-14023 Instagram User Endpoint change

Co-authored-by: Jean-Baptiste PIN <jibet.pin@gmail.com>
This commit is contained in:
vmuzikar 2020-04-28 09:33:52 +02:00 committed by Bruno Oliveira da Silva
parent 1db1deb066
commit 7087c081f0
11 changed files with 331 additions and 21 deletions

View file

@ -34,6 +34,7 @@ import java.util.Map;
public class BrokeredIdentityContext { public class BrokeredIdentityContext {
private String id; private String id;
private String legacyId;
private String username; private String username;
private String modelUsername; private String modelUsername;
private String email; private String email;
@ -64,6 +65,19 @@ public class BrokeredIdentityContext {
this.id = id; this.id = id;
} }
/**
* ID from older API version. For API migrations.
*
* @return legacy ID
*/
public String getLegacyId() {
return legacyId;
}
public void setLegacyId(String legacyId) {
this.legacyId = legacyId;
}
/** /**
* Username in remote idp * Username in remote idp
* *

View file

@ -38,6 +38,13 @@ public class FederatedIdentityModel {
this.token = token; this.token = token;
} }
public FederatedIdentityModel(FederatedIdentityModel originalIdentity, String userId) {
identityProvider = originalIdentity.getIdentityProvider();
this.userId = userId;
userName = originalIdentity.getUserName();
token = originalIdentity.getToken();
}
public String getUserId() { public String getUserId() {
return userId; return userId;
} }

View file

@ -546,6 +546,13 @@ public class IdentityBrokerService implements IdentityProvider.AuthenticationCal
.detail(Details.IDENTITY_PROVIDER_USERNAME, context.getUsername()); .detail(Details.IDENTITY_PROVIDER_USERNAME, context.getUsername());
UserModel federatedUser = this.session.users().getUserByFederatedIdentity(federatedIdentityModel, this.realmModel); UserModel federatedUser = this.session.users().getUserByFederatedIdentity(federatedIdentityModel, this.realmModel);
boolean shouldMigrateId = false;
// try to find the user using legacy ID
if (federatedUser == null && context.getLegacyId() != null) {
federatedIdentityModel = new FederatedIdentityModel(federatedIdentityModel, context.getLegacyId());
federatedUser = this.session.users().getUserByFederatedIdentity(federatedIdentityModel, this.realmModel);
shouldMigrateId = true;
}
// Check if federatedUser is already authenticated (this means linking social into existing federatedUser account) // Check if federatedUser is already authenticated (this means linking social into existing federatedUser account)
UserSessionModel userSession = new AuthenticationSessionManager(session).getUserSession(authenticationSession); UserSessionModel userSession = new AuthenticationSessionManager(session).getUserSession(authenticationSession);
@ -608,6 +615,9 @@ public class IdentityBrokerService implements IdentityProvider.AuthenticationCal
} }
updateFederatedIdentity(context, federatedUser); updateFederatedIdentity(context, federatedUser);
if (shouldMigrateId) {
migrateFederatedIdentityId(context, federatedUser);
}
authenticationSession.setAuthenticatedUser(federatedUser); authenticationSession.setAuthenticatedUser(federatedUser);
return finishOrRedirectToPostBrokerLogin(authenticationSession, context, false, parsedCode.clientSessionCode); return finishOrRedirectToPostBrokerLogin(authenticationSession, context, false, parsedCode.clientSessionCode);
@ -1006,6 +1016,16 @@ public class IdentityBrokerService implements IdentityProvider.AuthenticationCal
} }
private void migrateFederatedIdentityId(BrokeredIdentityContext context, UserModel federatedUser) {
FederatedIdentityModel identityModel = this.session.users().getFederatedIdentity(federatedUser, context.getIdpConfig().getAlias(), this.realmModel);
FederatedIdentityModel migratedIdentityModel = new FederatedIdentityModel(identityModel, context.getId());
// since ID is a partial key we need to recreate the identity
session.users().removeFederatedIdentity(realmModel, federatedUser, identityModel.getIdentityProvider());
session.users().addFederatedIdentity(realmModel, federatedUser, migratedIdentityModel);
logger.debugf("Federated user ID was migrated from %s to %s", identityModel.getUserId(), migratedIdentityModel.getUserId());
}
private void updateToken(BrokeredIdentityContext context, UserModel federatedUser, FederatedIdentityModel federatedIdentityModel) { private void updateToken(BrokeredIdentityContext context, UserModel federatedUser, FederatedIdentityModel federatedIdentityModel) {
if (context.getIdpConfig().isStoreToken() && !ObjectUtil.isEqualOrBothNull(context.getToken(), federatedIdentityModel.getToken())) { if (context.getIdpConfig().isStoreToken() && !ObjectUtil.isEqualOrBothNull(context.getToken(), federatedIdentityModel.getToken())) {
federatedIdentityModel.setToken(context.getToken()); federatedIdentityModel.setToken(context.getToken());

View file

@ -27,6 +27,8 @@ import org.keycloak.broker.provider.IdentityBrokerException;
import org.keycloak.broker.social.SocialIdentityProvider; import org.keycloak.broker.social.SocialIdentityProvider;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import java.io.IOException;
/** /**
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a> * @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
*/ */
@ -34,9 +36,11 @@ public class InstagramIdentityProvider extends AbstractOAuth2IdentityProvider im
public static final String AUTH_URL = "https://api.instagram.com/oauth/authorize"; public static final String AUTH_URL = "https://api.instagram.com/oauth/authorize";
public static final String TOKEN_URL = "https://api.instagram.com/oauth/access_token"; public static final String TOKEN_URL = "https://api.instagram.com/oauth/access_token";
public static final String PROFILE_URL = "https://api.instagram.com/v1/users/self"; public static final String PROFILE_URL = "https://graph.instagram.com/me";
public static final String DEFAULT_SCOPE = "basic"; public static final String PROFILE_FIELDS = "id,username";
public static final String DEFAULT_SCOPE = "user_profile";
public static final String LEGACY_ID_FIELD = "ig_id";
public InstagramIdentityProvider(KeycloakSession session, OAuth2IdentityProviderConfig config) { public InstagramIdentityProvider(KeycloakSession session, OAuth2IdentityProviderConfig config) {
super(session, config); super(session, config);
config.setAuthorizationUrl(AUTH_URL); config.setAuthorizationUrl(AUTH_URL);
@ -46,25 +50,29 @@ public class InstagramIdentityProvider extends AbstractOAuth2IdentityProvider im
protected BrokeredIdentityContext doGetFederatedIdentity(String accessToken) { protected BrokeredIdentityContext doGetFederatedIdentity(String accessToken) {
try { try {
JsonNode raw = SimpleHttp.doGet(PROFILE_URL,session).param("access_token", accessToken).asJson(); // try to get the profile incl. legacy Instagram ID to allow existing users to log in
JsonNode profile = fetchUserProfile(accessToken, true);
JsonNode profile = raw.get("data"); // ig_id field will get deprecated in the future and eventually might stop working (returning error)
if (!profile.has("id")) {
logger.debugf("Could not fetch user profile from instagram. Trying without %s.", LEGACY_ID_FIELD);
profile = fetchUserProfile(accessToken, false);
}
logger.debug(profile.toString()); logger.debug(profile.toString());
String id = getJsonProperty(profile, "id"); // it's not documented whether the new ID system can or cannot have conflicts with the legacy system, therefore
// we're using a custom prefix just to be sure
String id = "graph_" + getJsonProperty(profile, "id");
String username = getJsonProperty(profile, "username");
String legacyId = getJsonProperty(profile, LEGACY_ID_FIELD);
BrokeredIdentityContext user = new BrokeredIdentityContext(id); BrokeredIdentityContext user = new BrokeredIdentityContext(id);
String username = getJsonProperty(profile, "username");
user.setUsername(username); user.setUsername(username);
String full_name = getJsonProperty(profile, "full_name");
user.setName(full_name);
user.setIdpConfig(getConfig()); user.setIdpConfig(getConfig());
user.setIdp(this); user.setIdp(this);
if (legacyId != null && !legacyId.isEmpty()) {
user.setLegacyId(legacyId);
}
AbstractJsonUserAttributeMapper.storeUserProfileForMapper(user, profile, getConfig().getAlias()); AbstractJsonUserAttributeMapper.storeUserProfileForMapper(user, profile, getConfig().getAlias());
@ -74,6 +82,18 @@ public class InstagramIdentityProvider extends AbstractOAuth2IdentityProvider im
} }
} }
protected JsonNode fetchUserProfile(String accessToken, boolean includeIgId) throws IOException {
String fields = PROFILE_FIELDS;
if (includeIgId) {
fields += "," + LEGACY_ID_FIELD;
}
return SimpleHttp.doGet(PROFILE_URL,session)
.param("access_token", accessToken)
.param("fields", fields)
.asJson();
}
@Override @Override
protected String getDefaultScopes() { protected String getDefaultScopes() {
return DEFAULT_SCOPE; return DEFAULT_SCOPE;

View file

@ -0,0 +1,41 @@
/*
* Copyright 2020 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.testsuite.broker.oidc;
import org.keycloak.broker.oidc.KeycloakOIDCIdentityProvider;
import org.keycloak.broker.oidc.OIDCIdentityProviderConfig;
import org.keycloak.broker.provider.BrokeredIdentityContext;
import org.keycloak.models.KeycloakSession;
/**
* @author Vaclav Muzikar <vmuzikar@redhat.com>
*/
public class LegacyIdIdentityProvider extends KeycloakOIDCIdentityProvider {
public static final String LEGACY_ID = "3.14159265359";
public LegacyIdIdentityProvider(KeycloakSession session, OIDCIdentityProviderConfig config) {
super(session, config);
}
@Override
public BrokeredIdentityContext getFederatedIdentity(String response) {
BrokeredIdentityContext user = super.getFederatedIdentity(response);
user.setLegacyId(LEGACY_ID);
return user;
}
}

View file

@ -0,0 +1,47 @@
/*
* Copyright 2020 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.testsuite.broker.oidc;
import org.keycloak.broker.oidc.KeycloakOIDCIdentityProvider;
import org.keycloak.broker.oidc.OIDCIdentityProviderConfig;
import org.keycloak.broker.oidc.OIDCIdentityProviderFactory;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.KeycloakSession;
/**
* @author Vaclav Muzikar <vmuzikar@redhat.com>
*/
public class LegacyIdIdentityProviderFactory extends OIDCIdentityProviderFactory {
public static final String PROVIDER_ID = "legacy-id-idp";
@Override
public String getName() {
return PROVIDER_ID;
}
@Override
public KeycloakOIDCIdentityProvider create(KeycloakSession session, IdentityProviderModel model) {
return new LegacyIdIdentityProvider(session, new OIDCIdentityProviderConfig(model));
}
@Override
public String getId() {
return PROVIDER_ID;
}
}

View file

@ -0,0 +1,18 @@
#
# Copyright 2020 Red Hat, Inc. and/or its affiliates
# and other contributors as indicated by the @author tags.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
org.keycloak.testsuite.broker.oidc.LegacyIdIdentityProviderFactory

View file

@ -18,9 +18,12 @@
package org.keycloak.testsuite.pages.social; package org.keycloak.testsuite.pages.social;
import org.openqa.selenium.Keys; import org.openqa.selenium.Keys;
import org.openqa.selenium.NoSuchElementException;
import org.openqa.selenium.WebElement; import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.FindBy; import org.openqa.selenium.support.FindBy;
import static org.keycloak.testsuite.util.WaitUtils.pause;
/** /**
* @author Vaclav Muzikar <vmuzikar@redhat.com> * @author Vaclav Muzikar <vmuzikar@redhat.com>
*/ */
@ -31,11 +34,42 @@ public class InstagramLoginPage extends AbstractSocialLoginPage {
@FindBy(name = "password") @FindBy(name = "password")
private WebElement passwordInput; private WebElement passwordInput;
@FindBy(xpath = "//button[text()='Save Info']")
private WebElement saveInfoBtn;
@FindBy(xpath = "//button[text()='Authorize']")
private WebElement authorizeBtn;
@FindBy(xpath = "//button[text()='Continue']")
private WebElement continueBtn;
@Override @Override
public void login(String user, String password) { public void login(String user, String password) {
usernameInput.clear(); try {
usernameInput.sendKeys(user); usernameInput.clear();
passwordInput.sendKeys(password); usernameInput.sendKeys(user);
passwordInput.sendKeys(Keys.RETURN); passwordInput.sendKeys(password);
passwordInput.sendKeys(Keys.RETURN);
pause(2000); // wait for the login screen a bit
try {
saveInfoBtn.click();
}
catch (NoSuchElementException e) {
log.info("'Save Info' button not found, ignoring");
pause(2000); // wait for the login screen a bit
}
}
catch (NoSuchElementException e) {
log.info("Instagram is already logged in, just confirmation is expected");
}
try {
continueBtn.click();
}
catch (NoSuchElementException e) {
log.info("'Continue' button not found, trying 'Authorize'...");
authorizeBtn.click();
}
} }
} }

View file

@ -316,10 +316,14 @@ public abstract class AbstractBaseBrokerTest extends AbstractKeycloakTest {
protected void assertLoggedInAccountManagement() { protected void assertLoggedInAccountManagement() {
assertLoggedInAccountManagement(bc.getUserLogin(), bc.getUserEmail());
}
protected void assertLoggedInAccountManagement(String username, String email) {
waitForAccountManagementTitle(); waitForAccountManagementTitle();
Assert.assertTrue(accountUpdateProfilePage.isCurrent()); Assert.assertTrue(accountUpdateProfilePage.isCurrent());
Assert.assertEquals(accountUpdateProfilePage.getUsername(), bc.getUserLogin()); Assert.assertEquals(accountUpdateProfilePage.getUsername(), username);
Assert.assertEquals(accountUpdateProfilePage.getEmail(), bc.getUserEmail()); Assert.assertEquals(accountUpdateProfilePage.getEmail(), email);
} }
protected void waitForAccountManagementTitle() { protected void waitForAccountManagementTitle() {

View file

@ -0,0 +1,105 @@
/*
* Copyright 2020 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.testsuite.broker;
import org.junit.Test;
import org.keycloak.admin.client.resource.RealmResource;
import org.keycloak.admin.client.resource.UserResource;
import org.keycloak.models.IdentityProviderSyncMode;
import org.keycloak.representations.idm.FederatedIdentityRepresentation;
import org.keycloak.representations.idm.IdentityProviderRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.testsuite.broker.oidc.LegacyIdIdentityProviderFactory;
import org.keycloak.testsuite.util.FederatedIdentityBuilder;
import org.keycloak.testsuite.util.UserBuilder;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.keycloak.testsuite.admin.ApiUtil.createUserWithAdminClient;
import static org.keycloak.testsuite.broker.BrokerTestConstants.IDP_OIDC_ALIAS;
import static org.keycloak.testsuite.broker.BrokerTestTools.getConsumerRoot;
import static org.keycloak.testsuite.broker.BrokerTestTools.getProviderRoot;
import static org.keycloak.testsuite.broker.oidc.LegacyIdIdentityProvider.LEGACY_ID;
/**
* @author Vaclav Muzikar <vmuzikar@redhat.com>
*/
public class BrokerWithLegacyIdTest extends AbstractInitializedBaseBrokerTest {
private static final UserRepresentation consumerUser = UserBuilder.create()
.username("anakin")
.firstName("Darth")
.lastName("Vader")
.email("anakin@skywalker.tatooine")
.password("Come to the Dark Side. We have cookies")
.build();
private UserResource consumerUserResource;
@Override
protected BrokerConfiguration getBrokerConfiguration() {
return new KcOidcBrokerConfiguration() {
@Override
public IdentityProviderRepresentation setUpIdentityProvider(IdentityProviderSyncMode syncMode) {
IdentityProviderRepresentation idp = super.setUpIdentityProvider(syncMode);
idp.setProviderId(LegacyIdIdentityProviderFactory.PROVIDER_ID);
return idp;
}
};
}
@Override
public void beforeBrokerTest() {
super.beforeBrokerTest();
RealmResource consumerRealm = realmsResouce().realm(bc.consumerRealmName());
String consumerUserId = createUserWithAdminClient(consumerRealm, consumerUser);
FederatedIdentityRepresentation identity = FederatedIdentityBuilder.create()
.userId(LEGACY_ID)
.userName(bc.getUserLogin())
.identityProvider(IDP_OIDC_ALIAS)
.build();
consumerUserResource = consumerRealm.users().get(consumerUserId);
consumerUserResource.addFederatedIdentity(IDP_OIDC_ALIAS, identity);
}
@Test
public void loginWithLegacyId() {
assertEquals(LEGACY_ID, getFederatedIdentity().getUserId());
// login as existing user with legacy ID (from e.g. a deprecated API)
logInAsUserInIDP();
// id should be migrated to new one
assertEquals(userId, getFederatedIdentity().getUserId());
assertLoggedInAccountManagement(consumerUser.getUsername(), consumerUser.getEmail());
logoutFromRealm(getProviderRoot(), bc.providerRealmName());
logoutFromRealm(getConsumerRoot(), bc.consumerRealmName());
// try to login again to double check the new ID works
logInAsUserInIDP();
assertEquals(userId, getFederatedIdentity().getUserId());
assertLoggedInAccountManagement(consumerUser.getUsername(), consumerUser.getEmail());
}
private FederatedIdentityRepresentation getFederatedIdentity() {
List<FederatedIdentityRepresentation> identities = consumerUserResource.getFederatedIdentity();
assertEquals(1, identities.size());
return identities.get(0);
}
}

View file

@ -333,7 +333,7 @@ public class SocialLoginTest extends AbstractKeycloakTest {
public void instagramLogin() throws InterruptedException { public void instagramLogin() throws InterruptedException {
setTestProvider(INSTAGRAM); setTestProvider(INSTAGRAM);
performLogin(); performLogin();
assertUpdateProfile(false, false, true); assertUpdateProfile(true, true, true);
assertAccount(); assertAccount();
} }