From 472e569c6034e543baedf767966c070776425c34 Mon Sep 17 00:00:00 2001 From: amend07 Date: Tue, 4 Jun 2024 12:22:24 +0300 Subject: [PATCH 1/3] Add keycloak as oauth2 provider --- plugins/user-authenticators/oauth2/pom.xml | 5 + .../keycloak/KeycloakOAuth2Provider.java | 98 +++++++++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/keycloak/KeycloakOAuth2Provider.java diff --git a/plugins/user-authenticators/oauth2/pom.xml b/plugins/user-authenticators/oauth2/pom.xml index 5a1e49874a89..72fa061685da 100644 --- a/plugins/user-authenticators/oauth2/pom.xml +++ b/plugins/user-authenticators/oauth2/pom.xml @@ -59,5 +59,10 @@ 1.20.0 compile + + org.keycloak + keycloak-admin-client + 24.0.0 + diff --git a/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/keycloak/KeycloakOAuth2Provider.java b/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/keycloak/KeycloakOAuth2Provider.java new file mode 100644 index 000000000000..ef86e8501198 --- /dev/null +++ b/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/keycloak/KeycloakOAuth2Provider.java @@ -0,0 +1,98 @@ +package org.apache.cloudstack.oauth2.keycloak; + +import com.cloud.exception.CloudAuthenticationException; +import com.cloud.utils.component.AdapterBase; +import com.cloud.utils.exception.CloudRuntimeException; +import org.apache.cloudstack.auth.UserOAuth2Authenticator; +import org.apache.cloudstack.oauth2.dao.OauthProviderDao; +import org.apache.cloudstack.oauth2.vo.OauthProviderVO; +import org.apache.commons.lang3.StringUtils; +import org.keycloak.OAuth2Constants; +import org.keycloak.admin.client.Keycloak; +import org.keycloak.admin.client.KeycloakBuilder; +import org.keycloak.representations.idm.UserRepresentation; +import org.keycloak.representations.AccessTokenResponse; + +import javax.inject.Inject; +import java.util.List; + +public class KeycloakOAuth2Provider extends AdapterBase implements UserOAuth2Authenticator { + + protected String accessToken = null; + protected String refreshToken = null; + + @Inject + OauthProviderDao _oauthProviderDao; + + @Override + public String getName() { + return "keycloak"; + } + + @Override + public String getDescription() { + return "Keycloak OAuth2 Provider Plugin"; + } + + @Override + public boolean verifyUser(String email, String secretCode) { + if (StringUtils.isAnyEmpty(email, secretCode)) { + throw new CloudAuthenticationException("Either email or secret code should not be null/empty"); + } + + OauthProviderVO providerVO = _oauthProviderDao.findByProvider(getName()); + if (providerVO == null) { + throw new CloudAuthenticationException("Keycloak provider is not registered, so user cannot be verified"); + } + + String verifiedEmail = verifyCodeAndFetchEmail(secretCode); + if (verifiedEmail == null || !email.equals(verifiedEmail)) { + throw new CloudRuntimeException("Unable to verify the email address with the provided secret"); + } + clearAccessAndRefreshTokens(); + + return true; + } + + @Override + public String verifyCodeAndFetchEmail(String secretCode) { + OauthProviderVO keycloakProvider = _oauthProviderDao.findByProvider(getName()); + String clientId = keycloakProvider.getClientId(); + String clientSecret = keycloakProvider.getSecretKey(); + String redirectUri = keycloakProvider.getRedirectUri(); + String authServerUrl = keycloakProvider.getAuthenticationUri(); + + Keycloak keycloak = KeycloakBuilder.builder() + .serverUrl(authServerUrl) + .realm(keycloakProvider.getProviderName()) + .clientId(clientId) + .clientSecret(clientSecret) + .grantType(OAuth2Constants.AUTHORIZATION_CODE) + .redirectUri(redirectUri) + .code(secretCode) + .build(); + + AccessTokenResponse tokenResponse = keycloak.tokenManager().getAccessToken(); + + accessToken = tokenResponse.getToken(); + refreshToken = tokenResponse.getRefreshToken(); + + List users = keycloak.realm(keycloakProvider.getProviderName()).users().search("", 0, 1); + if (users.isEmpty()) { + throw new CloudRuntimeException("No user found with the provided secret"); + } + + return users.get(0).getEmail(); + } + + protected void clearAccessAndRefreshTokens() { + accessToken = null; + refreshToken = null; + } + + @Override + public String getUserEmailAddress() throws CloudRuntimeException { + return null; + } +} + From 2f1b1b35e461fbdfa2bbe1faff6ac74aa80e4ac3 Mon Sep 17 00:00:00 2001 From: amend07 Date: Mon, 17 Jun 2024 12:52:00 +0300 Subject: [PATCH 2/3] Add apache license --- .../oauth2/keycloak/KeycloakOAuth2Provider.java | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/keycloak/KeycloakOAuth2Provider.java b/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/keycloak/KeycloakOAuth2Provider.java index ef86e8501198..0f02902b39e2 100644 --- a/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/keycloak/KeycloakOAuth2Provider.java +++ b/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/keycloak/KeycloakOAuth2Provider.java @@ -1,3 +1,19 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you 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.apache.cloudstack.oauth2.keycloak; import com.cloud.exception.CloudAuthenticationException; From 92aff1112f008584135668e2b10a5db112a1199f Mon Sep 17 00:00:00 2001 From: amend07 Date: Tue, 25 Jun 2024 08:57:50 +0300 Subject: [PATCH 3/3] Add Keycloak integration --- .../keycloak/KeycloakOAuth2Provider.java | 49 +++--- .../keycloak/KeycloakOAuth2ProviderTest.java | 166 ++++++++++++++++++ 2 files changed, 189 insertions(+), 26 deletions(-) create mode 100644 plugins/user-authenticators/oauth2/src/test/java/org/apache/cloudstack/oauth2/keycloak/KeycloakOAuth2ProviderTest.java diff --git a/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/keycloak/KeycloakOAuth2Provider.java b/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/keycloak/KeycloakOAuth2Provider.java index 0f02902b39e2..9a2f5f5e0113 100644 --- a/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/keycloak/KeycloakOAuth2Provider.java +++ b/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/keycloak/KeycloakOAuth2Provider.java @@ -1,19 +1,20 @@ // Licensed to the Apache Software Foundation (ASF) under one -// or more contributor license agreements. See the NOTICE file +// or more contributor license agreements. See the NOTICE file // distributed with this work for additional information -// regarding copyright ownership. The ASF licenses this file +// regarding copyright ownership. The ASF licenses this file // to you 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 +// with the License. You may obtain a copy of the License at // -// http://www.apache.org/licenses/LICENSE-2.0 +// 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 +// KIND, either express or implied. See the License for the // specific language governing permissions and limitations // under the License. + package org.apache.cloudstack.oauth2.keycloak; import com.cloud.exception.CloudAuthenticationException; @@ -25,12 +26,13 @@ import org.apache.commons.lang3.StringUtils; import org.keycloak.OAuth2Constants; import org.keycloak.admin.client.Keycloak; -import org.keycloak.admin.client.KeycloakBuilder; -import org.keycloak.representations.idm.UserRepresentation; +import org.keycloak.admin.client.resource.RealmResource; +import org.keycloak.admin.client.resource.UserResource; import org.keycloak.representations.AccessTokenResponse; import javax.inject.Inject; -import java.util.List; +import java.util.HashMap; +import java.util.Map; public class KeycloakOAuth2Provider extends AdapterBase implements UserOAuth2Authenticator { @@ -74,31 +76,27 @@ public boolean verifyUser(String email, String secretCode) { public String verifyCodeAndFetchEmail(String secretCode) { OauthProviderVO keycloakProvider = _oauthProviderDao.findByProvider(getName()); String clientId = keycloakProvider.getClientId(); - String clientSecret = keycloakProvider.getSecretKey(); - String redirectUri = keycloakProvider.getRedirectUri(); - String authServerUrl = keycloakProvider.getAuthenticationUri(); - - Keycloak keycloak = KeycloakBuilder.builder() - .serverUrl(authServerUrl) - .realm(keycloakProvider.getProviderName()) - .clientId(clientId) - .clientSecret(clientSecret) - .grantType(OAuth2Constants.AUTHORIZATION_CODE) - .redirectUri(redirectUri) - .code(secretCode) - .build(); + String secret = keycloakProvider.getSecretKey(); + String authServerUrl = keycloakProvider.getAuthServerUrl(); + String realm = keycloakProvider.getRealm(); + Keycloak keycloak = Keycloak.getInstance(authServerUrl, realm, clientId, secret, OAuth2Constants.CLIENT_CREDENTIALS); AccessTokenResponse tokenResponse = keycloak.tokenManager().getAccessToken(); accessToken = tokenResponse.getToken(); refreshToken = tokenResponse.getRefreshToken(); - List users = keycloak.realm(keycloakProvider.getProviderName()).users().search("", 0, 1); - if (users.isEmpty()) { - throw new CloudRuntimeException("No user found with the provided secret"); + RealmResource realmResource = keycloak.realm(realm); + UserResource userResource = realmResource.users().get(tokenResponse.getSubject()); + + Map attributes = new HashMap<>(); + try { + attributes = userResource.toRepresentation().getAttributes(); + } catch (Exception e) { + throw new CloudRuntimeException(String.format("Failed to fetch the email address with the provided secret: %s", e.getMessage())); } - return users.get(0).getEmail(); + return (String) attributes.get("email"); } protected void clearAccessAndRefreshTokens() { @@ -111,4 +109,3 @@ public String getUserEmailAddress() throws CloudRuntimeException { return null; } } - diff --git a/plugins/user-authenticators/oauth2/src/test/java/org/apache/cloudstack/oauth2/keycloak/KeycloakOAuth2ProviderTest.java b/plugins/user-authenticators/oauth2/src/test/java/org/apache/cloudstack/oauth2/keycloak/KeycloakOAuth2ProviderTest.java new file mode 100644 index 000000000000..3a8e4135caff --- /dev/null +++ b/plugins/user-authenticators/oauth2/src/test/java/org/apache/cloudstack/oauth2/keycloak/KeycloakOAuth2ProviderTest.java @@ -0,0 +1,166 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you 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.apache.cloudstack.oauth2.keycloak; + +import com.cloud.exception.CloudAuthenticationException; +import com.cloud.utils.exception.CloudRuntimeException; +import org.apache.cloudstack.oauth2.dao.OauthProviderDao; +import org.apache.cloudstack.oauth2.vo.OauthProviderVO; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.keycloak.admin.client.Keycloak; +import org.keycloak.admin.client.resource.RealmResource; +import org.keycloak.admin.client.resource.UserResource; +import org.keycloak.representations.AccessTokenResponse; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.mockito.Spy; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.Assert.*; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +public class KeycloakOAuth2ProviderTest { + + @Mock + private OauthProviderDao _oauthProviderDao; + + @Spy + @InjectMocks + private KeycloakOAuth2Provider _keycloakOAuth2Provider; + + private AutoCloseable closeable; + + @Before + public void setUp() { + closeable = MockitoAnnotations.openMocks(this); + } + + @After + public void tearDown() throws Exception { + closeable.close(); + } + + @Test(expected = CloudAuthenticationException.class) + public void testVerifyUserWithNullEmail() { + _keycloakOAuth2Provider.verifyUser(null, "secretCode"); + } + + @Test(expected = CloudAuthenticationException.class) + public void testVerifyUserWithNullSecretCode() { + _keycloakOAuth2Provider.verifyUser("email@example.com", null); + } + + @Test(expected = CloudAuthenticationException.class) + public void testVerifyUserWithUnregisteredProvider() { + when(_oauthProviderDao.findByProvider(anyString())).thenReturn(null); + _keycloakOAuth2Provider.verifyUser("email@example.com", "secretCode"); + } + + @Test(expected = CloudRuntimeException.class) + public void testVerifyUserWithInvalidSecretCode() { + OauthProviderVO providerVO = mock(OauthProviderVO.class); + when(_oauthProviderDao.findByProvider(anyString())).thenReturn(providerVO); + when(providerVO.getClientId()).thenReturn("testClientId"); + when(providerVO.getSecretKey()).thenReturn("testSecretKey"); + when(providerVO.getAuthServerUrl()).thenReturn("http://localhost:8080/auth"); + when(providerVO.getRealm()).thenReturn("testRealm"); + + Keycloak keycloak = mock(Keycloak.class); + when(Keycloak.getInstance(anyString(), anyString(), anyString(), anyString(), anyString())).thenReturn(keycloak); + + AccessTokenResponse tokenResponse = mock(AccessTokenResponse.class); + when(keycloak.tokenManager().getAccessToken()).thenReturn(tokenResponse); + + RealmResource realmResource = mock(RealmResource.class); + when(keycloak.realm(anyString())).thenReturn(realmResource); + + UserResource userResource = mock(UserResource.class); + when(realmResource.users().get(anyString())).thenReturn(userResource); + + Map attributes = new HashMap<>(); + attributes.put("email", null); + when(userResource.toRepresentation().getAttributes()).thenReturn(attributes); + + _keycloakOAuth2Provider.verifyUser("email@example.com", "secretCode"); + } + + @Test(expected = CloudRuntimeException.class) + public void testVerifyUserWithMismatchedEmail() { + OauthProviderVO providerVO = mock(OauthProviderVO.class); + when(_oauthProviderDao.findByProvider(anyString())).thenReturn(providerVO); + when(providerVO.getClientId()).thenReturn("testClientId"); + when(providerVO.getSecretKey()).thenReturn("testSecretKey"); + when(providerVO.getAuthServerUrl()).thenReturn("http://localhost:8080/auth"); + when(providerVO.getRealm()).thenReturn("testRealm"); + + Keycloak keycloak = mock(Keycloak.class); + when(Keycloak.getInstance(anyString(), anyString(), anyString(), anyString(), anyString())).thenReturn(keycloak); + + AccessTokenResponse tokenResponse = mock(AccessTokenResponse.class); + when(keycloak.tokenManager().getAccessToken()).thenReturn(tokenResponse); + + RealmResource realmResource = mock(RealmResource.class); + when(keycloak.realm(anyString())).thenReturn(realmResource); + + UserResource userResource = mock(UserResource.class); + when(realmResource.users().get(anyString())).thenReturn(userResource); + + Map attributes = new HashMap<>(); + attributes.put("email", "otheremail@example.com"); + when(userResource.toRepresentation().getAttributes()).thenReturn(attributes); + + _keycloakOAuth2Provider.verifyUser("email@example.com", "secretCode"); + } + + @Test + public void testVerifyUserEmail() { + OauthProviderVO providerVO = mock(OauthProviderVO.class); + when(_oauthProviderDao.findByProvider(anyString())).thenReturn(providerVO); + when(providerVO.getClientId()).thenReturn("testClientId"); + when(providerVO.getSecretKey()).thenReturn("testSecretKey"); + when(providerVO.getAuthServerUrl()).thenReturn("http://localhost:8080/auth"); + when(providerVO.getRealm()).thenReturn("testRealm"); + + Keycloak keycloak = mock(Keycloak.class); + when(Keycloak.getInstance(anyString(), anyString(), anyString(), anyString(), anyString())).thenReturn(keycloak); + + AccessTokenResponse tokenResponse = mock(AccessTokenResponse.class); + when(keycloak.tokenManager().getAccessToken()).thenReturn(tokenResponse); + + RealmResource realmResource = mock(RealmResource.class); + when(keycloak.realm(anyString())).thenReturn(realmResource); + + UserResource userResource = mock(UserResource.class); + when(realmResource.users().get(anyString())).thenReturn(userResource); + + Map attributes = new HashMap<>(); + attributes.put("email", "email@example.com"); + when(userResource.toRepresentation().getAttributes()).thenReturn(attributes); + + boolean result = _keycloakOAuth2Provider.verifyUser("email@example.com", "secretCode"); + + assertTrue(result); + assertNull(_keycloakOAuth2Provider.accessToken); + } +}