KEYCLOAK-7429: Linked Accounts REST API

This commit is contained in:
Stan Silvert 2019-11-05 09:03:38 -05:00
parent d0386dab85
commit 041229f9ca
6 changed files with 617 additions and 1 deletions

View file

@ -0,0 +1,53 @@
/*
* Copyright 2018 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.representations.account;
import java.net.URI;
/**
*
* @author Stan Silvert
*/
public class AccountLinkUriRepresentation {
private URI accountLinkUri;
private String nonce;
private String hash;
public URI getAccountLinkUri() {
return accountLinkUri;
}
public void setAccountLinkUri(URI accountLinkUri) {
this.accountLinkUri = accountLinkUri;
}
public String getNonce() {
return nonce;
}
public void setNonce(String nonce) {
this.nonce = nonce;
}
public String getHash() {
return hash;
}
public void setHash(String hash) {
this.hash = hash;
}
}

View file

@ -0,0 +1,91 @@
/*
* Copyright 2018 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.representations.account;
import com.fasterxml.jackson.annotation.JsonIgnore;
/**
*
* @author Stan Silvert
*/
public class LinkedAccountRepresentation implements Comparable<LinkedAccountRepresentation> {
private boolean connected;
private String providerAlias;
private String providerName;
private String displayName;
private String linkedUsername;
@JsonIgnore
private String guiOrder;
public String getLinkedUsername() {
return linkedUsername;
}
public void setLinkedUsername(String userName) {
this.linkedUsername = userName;
}
public boolean isConnected() {
return connected;
}
public void setConnected(boolean connected) {
this.connected = connected;
}
public String getProviderAlias() {
return providerAlias;
}
public void setProviderAlias(String providerId) {
this.providerAlias = providerId;
}
public String getProviderName() {
return providerName;
}
public void setProviderName(String providerName) {
this.providerName = providerName;
}
public String getGuiOrder() {
return guiOrder;
}
public void setGuiOrder(String guiOrder) {
this.guiOrder = guiOrder;
}
public String getDisplayName() {
return displayName;
}
public void setDisplayName(String displayName) {
this.displayName = displayName;
}
@Override
public int compareTo(LinkedAccountRepresentation rep) {
if (this.getGuiOrder() == null) return -1;
if (rep.getGuiOrder() == null) return 1;
return rep.getGuiOrder().compareTo(this.getGuiOrder());
}
}

View file

@ -464,6 +464,11 @@ public class AccountRestService {
return consent;
}
@Path("/linked-accounts")
public LinkedAccountsResource linkedAccounts() {
return new LinkedAccountsResource(session, request, client, auth, event, user);
}
// TODO Logs
private static void checkAccountApiEnabled() {

View file

@ -0,0 +1,250 @@
/*
* Copyright 2018 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.services.resources.account;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.List;
import java.util.Set;
import java.util.SortedSet;
import java.util.TreeSet;
import java.util.UUID;
import javax.ws.rs.DELETE;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.UriBuilder;
import org.jboss.logging.Logger;
import org.jboss.resteasy.spi.HttpRequest;
import org.keycloak.common.util.Base64Url;
import org.keycloak.credential.CredentialModel;
import org.keycloak.events.Details;
import org.keycloak.events.EventBuilder;
import org.keycloak.events.EventType;
import org.keycloak.models.AccountRoles;
import org.keycloak.models.ClientModel;
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;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.representations.account.AccountLinkUriRepresentation;
import org.keycloak.representations.account.LinkedAccountRepresentation;
import org.keycloak.services.ErrorResponse;
import org.keycloak.services.Urls;
import org.keycloak.services.managers.Auth;
import org.keycloak.services.messages.Messages;
import org.keycloak.services.resources.Cors;
import org.keycloak.services.validation.Validation;
/**
* API for linking/unlinking social login accounts
*
* @author Stan Silvert
*/
public class LinkedAccountsResource {
private static final Logger logger = Logger.getLogger(LinkedAccountsResource.class);
private final KeycloakSession session;
private final HttpRequest request;
private final ClientModel client;
private final EventBuilder event;
private final UserModel user;
private final RealmModel realm;
private final Auth auth;
public LinkedAccountsResource(KeycloakSession session,
HttpRequest request,
ClientModel client,
Auth auth,
EventBuilder event,
UserModel user) {
this.session = session;
this.request = request;
this.client = client;
this.auth = auth;
this.event = event;
this.user = user;
realm = session.getContext().getRealm();
}
@GET
@Path("/")
@Produces(MediaType.APPLICATION_JSON)
public Response linkedAccounts() {
auth.requireOneOf(AccountRoles.MANAGE_ACCOUNT, AccountRoles.VIEW_PROFILE);
SortedSet<LinkedAccountRepresentation> linkedAccounts = getLinkedAccounts(this.session, this.realm, this.user);
return Cors.add(request, Response.ok(linkedAccounts)).auth().allowedOrigins(auth.getToken()).build();
}
public SortedSet<LinkedAccountRepresentation> getLinkedAccounts(KeycloakSession session, RealmModel realm, UserModel user) {
List<IdentityProviderModel> identityProviders = realm.getIdentityProviders();
SortedSet<LinkedAccountRepresentation> linkedAccounts = new TreeSet<>();
if (identityProviders == null || identityProviders.isEmpty()) return linkedAccounts;
Set<FederatedIdentityModel> identities = session.users().getFederatedIdentities(user, realm);
for (IdentityProviderModel provider : identityProviders) {
if (!provider.isEnabled()) {
continue;
}
String providerId = provider.getAlias();
FederatedIdentityModel identity = getIdentity(identities, providerId);
String displayName = KeycloakModelUtils.getIdentityProviderDisplayName(session, provider);
String guiOrder = provider.getConfig() != null ? provider.getConfig().get("guiOrder") : null;
LinkedAccountRepresentation rep = new LinkedAccountRepresentation();
rep.setConnected(identity != null);
rep.setProviderAlias(providerId);
rep.setDisplayName(displayName);
rep.setGuiOrder(guiOrder);
rep.setProviderName(provider.getAlias());
if (identity != null) {
rep.setLinkedUsername(identity.getUserName());
}
linkedAccounts.add(rep);
}
return linkedAccounts;
}
private FederatedIdentityModel getIdentity(Set<FederatedIdentityModel> identities, String providerId) {
for (FederatedIdentityModel link : identities) {
if (providerId.equals(link.getIdentityProvider())) {
return link;
}
}
return null;
}
@GET
@Path("/{providerId}")
@Produces(MediaType.APPLICATION_JSON)
@Deprecated
public Response buildLinkedAccountURI(@PathParam("providerId") String providerId,
@QueryParam("redirectUri") String redirectUri) {
auth.require(AccountRoles.MANAGE_ACCOUNT);
if (redirectUri == null) {
ErrorResponse.error(Messages.INVALID_REDIRECT_URI, Response.Status.BAD_REQUEST);
}
String errorMessage = checkCommonPreconditions(providerId);
if (errorMessage != null) {
return ErrorResponse.error(errorMessage, Response.Status.BAD_REQUEST);
}
try {
String nonce = UUID.randomUUID().toString();
MessageDigest md = MessageDigest.getInstance("SHA-256");
String input = nonce + auth.getSession().getId() + client.getClientId() + providerId;
byte[] check = md.digest(input.getBytes(StandardCharsets.UTF_8));
String hash = Base64Url.encode(check);
URI linkUri = Urls.identityProviderLinkRequest(this.session.getContext().getUri().getBaseUri(), providerId, realm.getName());
linkUri = UriBuilder.fromUri(linkUri)
.queryParam("nonce", nonce)
.queryParam("hash", hash)
.queryParam("client_id", client.getClientId())
.queryParam("redirect_uri", redirectUri)
.build();
AccountLinkUriRepresentation rep = new AccountLinkUriRepresentation();
rep.setAccountLinkUri(linkUri);
rep.setHash(hash);
rep.setNonce(nonce);
return Cors.add(request, Response.ok(rep)).auth().allowedOrigins(auth.getToken()).build();
} catch (Exception spe) {
spe.printStackTrace();
return ErrorResponse.error(Messages.FAILED_TO_PROCESS_RESPONSE, Response.Status.INTERNAL_SERVER_ERROR);
}
}
@DELETE
@Path("/{providerId}")
@Produces(MediaType.APPLICATION_JSON)
public Response removeLinkedAccount(@PathParam("providerId") String providerId) {
auth.require(AccountRoles.MANAGE_ACCOUNT);
String errorMessage = checkCommonPreconditions(providerId);
if (errorMessage != null) {
return ErrorResponse.error(errorMessage, Response.Status.BAD_REQUEST);
}
FederatedIdentityModel link = session.users().getFederatedIdentity(user, providerId, realm);
if (link == null) {
return ErrorResponse.error(Messages.FEDERATED_IDENTITY_NOT_ACTIVE, Response.Status.BAD_REQUEST);
}
// Removing last social provider is not possible if you don't have other possibility to authenticate
if (!(session.users().getFederatedIdentities(user, realm).size() > 1 || user.getFederationLink() != null || isPasswordSet())) {
return ErrorResponse.error(Messages.FEDERATED_IDENTITY_REMOVING_LAST_PROVIDER, Response.Status.BAD_REQUEST);
}
session.users().removeFederatedIdentity(realm, user, providerId);
logger.debugv("Social provider {0} removed successfully from user {1}", providerId, user.getUsername());
event.event(EventType.REMOVE_FEDERATED_IDENTITY).client(auth.getClient()).user(auth.getUser())
.detail(Details.USERNAME, auth.getUser().getUsername())
.detail(Details.IDENTITY_PROVIDER, link.getIdentityProvider())
.detail(Details.IDENTITY_PROVIDER_USERNAME, link.getUserName())
.success();
return Cors.add(request, Response.ok()).auth().allowedOrigins(auth.getToken()).build();
}
private String checkCommonPreconditions(String providerId) {
auth.require(AccountRoles.MANAGE_ACCOUNT);
if (Validation.isEmpty(providerId)) {
return Messages.MISSING_IDENTITY_PROVIDER;
}
if (!isValidProvider(providerId)) {
return Messages.IDENTITY_PROVIDER_NOT_FOUND;
}
if (!user.isEnabled()) {
return Messages.ACCOUNT_DISABLED;
}
return null;
}
private boolean isPasswordSet() {
return session.userCredentialManager().isConfiguredFor(realm, user, CredentialModel.PASSWORD);
}
private boolean isValidProvider(String providerId) {
for (IdentityProviderModel model : realm.getIdentityProviders()) {
if (model.getAlias().equals(providerId)) {
return true;
}
}
return false;
}
}

View file

@ -0,0 +1,199 @@
/*
* Copyright 2018 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.account;
import com.fasterxml.jackson.core.type.TypeReference;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.junit.After;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.keycloak.broker.provider.util.SimpleHttp;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.testsuite.AbstractTestRealmKeycloakTest;
import org.keycloak.testsuite.AssertEvents;
import org.keycloak.testsuite.util.TokenUtil;
import org.keycloak.testsuite.util.UserBuilder;
import java.io.IOException;
import java.net.URI;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.List;
import java.util.SortedSet;
import org.apache.http.NameValuePair;
import org.apache.http.client.utils.URLEncodedUtils;
import org.keycloak.representations.idm.FederatedIdentityRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.testsuite.util.IdentityProviderBuilder;
import static org.junit.Assert.*;
import org.junit.FixMethodOrder;
import org.junit.runners.MethodSorters;
import org.keycloak.representations.account.AccountLinkUriRepresentation;
import org.keycloak.representations.account.LinkedAccountRepresentation;
/**
* @author <a href="mailto:ssilvert@redhat.com">Stan Silvert</a>
*/
@FixMethodOrder(MethodSorters.NAME_ASCENDING)
public class LinkedAccountsRestServiceTest extends AbstractTestRealmKeycloakTest {
@Rule
public TokenUtil tokenUtil = new TokenUtil();
@Rule
public AssertEvents events = new AssertEvents(this);
private CloseableHttpClient client;
@Before
public void before() {
client = HttpClientBuilder.create().build();
}
@After
public void after() {
try {
client.close();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
@Override
public void configureTestRealm(RealmRepresentation testRealm) {
testRealm.getUsers().add(UserBuilder.create().username("no-account-access").password("password").build());
testRealm.getUsers().add(UserBuilder.create().username("view-account-access").role("account", "view-profile").password("password").build());
testRealm.addIdentityProvider(IdentityProviderBuilder.create()
.providerId("github")
.alias("github")
.setAttribute("guiOrder", "2")
.build());
testRealm.addIdentityProvider(IdentityProviderBuilder.create()
.providerId("saml")
.alias("mysaml")
.setAttribute("guiOrder", "0")
.build());
testRealm.addIdentityProvider(IdentityProviderBuilder.create()
.providerId("oidc")
.alias("myoidc")
.displayName("MyOIDC")
.setAttribute("guiOrder", "1")
.build());
addGitHubIdentity(testRealm);
}
private void addGitHubIdentity(RealmRepresentation testRealm) {
UserRepresentation acctMgtUser = findUser(testRealm, "test-user@localhost");
FederatedIdentityRepresentation fedIdp = new FederatedIdentityRepresentation();
fedIdp.setIdentityProvider("github");
fedIdp.setUserId("foo");
fedIdp.setUserName("foo");
ArrayList<FederatedIdentityRepresentation> fedIdps = new ArrayList<>();
fedIdps.add(fedIdp);
acctMgtUser.setFederatedIdentities(fedIdps);
}
private UserRepresentation findUser(RealmRepresentation testRealm, String userName) {
for (UserRepresentation user : testRealm.getUsers()) {
if (user.getUsername().equals(userName)) return user;
}
return null;
}
private String getAccountUrl(String resource) {
return suiteContext.getAuthServerInfo().getContextRoot().toString() + "/auth/realms/test/account" + (resource != null ? "/" + resource : "");
}
private SortedSet<LinkedAccountRepresentation> linkedAccountsRep() throws IOException {
return SimpleHttp.doGet(getAccountUrl("linked-accounts"), client).auth(tokenUtil.getToken()).asJson(new TypeReference<SortedSet<LinkedAccountRepresentation>>() {});
}
private LinkedAccountRepresentation findLinkedAccount(String providerAlias) throws IOException {
for (LinkedAccountRepresentation account : linkedAccountsRep()) {
if (account.getProviderAlias().equals(providerAlias)) return account;
}
return null;
}
@Test
public void testBuildLinkedAccountUri() throws IOException {
AccountLinkUriRepresentation rep = SimpleHttp.doGet(getAccountUrl("linked-accounts/github?redirectUri=phonyUri"), client)
.auth(tokenUtil.getToken())
.asJson(new TypeReference<AccountLinkUriRepresentation>() {});
URI brokerUri = rep.getAccountLinkUri();
assertTrue(brokerUri.getPath().endsWith("/auth/realms/test/broker/github/link"));
List<NameValuePair> queryParams = URLEncodedUtils.parse(brokerUri, Charset.defaultCharset());
assertEquals(4, queryParams.size());
for (NameValuePair nvp : queryParams) {
switch (nvp.getName()) {
case "nonce" : {
assertNotNull(nvp.getValue());
assertEquals(rep.getNonce(), nvp.getValue());
break;
}
case "hash" : {
assertNotNull(nvp.getValue());
assertEquals(rep.getHash(), nvp.getValue());
break;
}
case "client_id" : assertEquals("account", nvp.getValue()); break;
case "redirect_uri" : assertEquals("phonyUri", nvp.getValue());
}
}
}
@Test
public void testGetLinkedAccounts() throws IOException {
SortedSet<LinkedAccountRepresentation> details = linkedAccountsRep();
assertEquals(3, details.size());
int order = 0;
for (LinkedAccountRepresentation account : details) {
if (account.getProviderAlias().equals("github")) {
assertTrue(account.isConnected());
} else {
assertFalse(account.isConnected());
}
// test that accounts were sorted by guiOrder
if (order == 0) assertEquals("mysaml", account.getDisplayName());
if (order == 1) assertEquals("MyOIDC", account.getDisplayName());
if (order == 2) assertEquals("GitHub", account.getDisplayName());
order++;
}
}
@Test
public void testRemoveLinkedAccount() throws IOException {
assertTrue(findLinkedAccount("github").isConnected());
SimpleHttp.doDelete(getAccountUrl("linked-accounts/github"), client).auth(tokenUtil.getToken()).acceptJson().asResponse();
assertFalse(findLinkedAccount("github").isConnected());
}
}

View file

@ -1,5 +1,23 @@
<div class="page-header">
<h1 id="pageTitle">{{'linkedAccountsHtmlTitle' | translate}}</h1>
<h1>{{'linkedAccountsHtmlTitle' | translate}}</h1>
<hr/>
<h2>Buttons for manual testing. See JS Console for output.</h2>
<div class="card-pf-body row">
<button class="label label-primary" (click)="doGet()">List All</button>
</div>
<div class="card-pf-body row">
<button class="label label-primary" (click)="linkAccount('github')">Link Account to GitHub</button>
<button class="label label-primary" (click)="doRemove('github')">Remove GitHub</button>
</div>
<div class="card-pf-body row">
<button class="label label-primary" (click)="linkAccount('stackoverflow')">Link Account to Stack Overflow</button>
<button class="label label-primary" (click)="doRemove('stackoverflow')">Remove Stack Overflow</button>
</div>
<div class="card-pf-body row">
<button class="label label-primary" (click)="linkAccount('linkedin')">Link Account to LinkedIn</button>
<button class="label label-primary" (click)="doRemove('linkedin')">Remove LinkedIn</button>
</div>
<hr/>
</div>
<div class="col-sm-12 card-pf card-linked-account">
<div class="card-pf-body row">