This commit is contained in:
Daman Arora 2026-07-04 14:36:11 +00:00 committed by GitHub
commit 539b13cd4e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 1438 additions and 256 deletions

View File

@ -42,8 +42,19 @@ public interface UserOAuth2Authenticator extends Adapter {
* Verifies the code provided by provider and fetches email
* @return returns email
*/
String verifyCodeAndFetchEmail(String secretCode);
String verifySecretCodeAndFetchEmail(String secretCode);
/**
* Verifies if the logged in user is valid for a specific domain
* @return true if it's a valid user, otherwise false
*/
boolean verifyUser(String email, String secretCode, Long domainId);
/**
* Verifies the secret code provided by provider and fetches email for a specific domain
* @return email for the specified domain
*/
String verifySecretCodeAndFetchEmail(String secretCode, Long domainId);
/**
* Fetches email using the accessToken

View File

@ -19,6 +19,12 @@
-- Schema upgrade from 4.22.1.0 to 4.23.0.0
--;
CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.oauth_provider', 'domain_id', 'bigint unsigned DEFAULT NULL COMMENT "NULL for global provider, domain ID for domain-specific" AFTER `redirect_uri`');
CALL `cloud`.`IDEMPOTENT_ADD_FOREIGN_KEY`('cloud.oauth_provider', 'fk_oauth_provider__domain_id', '(`domain_id`)', '`domain`(`id`)');
CALL `cloud`.`IDEMPOTENT_ADD_KEY`('i_oauth_provider__domain_id', 'cloud.oauth_provider', '(`domain_id`)');
CALL `cloud`.`IDEMPOTENT_ADD_UNIQUE_KEY`('cloud.oauth_provider', 'uk_oauth_provider__provider_domain', '(`provider`, `domain_id`)');
CREATE TABLE `cloud`.`backup_offering_details` (
`id` bigint unsigned NOT NULL auto_increment,
`backup_offering_id` bigint unsigned NOT NULL COMMENT 'Backup offering id',

View File

@ -269,6 +269,17 @@ public class ConfigKey<T> {
private String _defaultValueIfEmpty = null;
private boolean _strictScope = false;
public boolean isStrictScope() {
return _strictScope;
}
public ConfigKey<T> withStrictScope() {
this._strictScope = true;
return this;
}
public static void init(ConfigDepotImpl depot) {
s_depot = depot;
}
@ -429,11 +440,18 @@ public class ConfigKey<T> {
}
public T valueInScope(Scope scope, Long id) {
return valueInScope(scope, id, false);
}
public T valueInScope(Scope scope, Long id, boolean strictScope) {
if (id == null) {
return value();
return strictScope ? null : value();
}
String value = s_depot != null ? s_depot.getConfigStringValue(_name, scope, id) : null;
if (value == null) {
if (strictScope) {
return null;
}
return valueInGlobalOrAvailableParentScope(scope, id);
}
logger.trace("Scope({}) value for config ({}): {}", scope, _name, _value);

View File

@ -18,6 +18,7 @@
//
package org.apache.cloudstack.oauth2;
import com.cloud.domain.Domain;
import com.cloud.utils.component.PluggableService;
import org.apache.cloudstack.api.auth.PluggableAPIAuthenticator;
import org.apache.cloudstack.auth.UserOAuth2Authenticator;
@ -27,10 +28,15 @@ import org.apache.cloudstack.oauth2.api.command.UpdateOAuthProviderCmd;
import org.apache.cloudstack.oauth2.vo.OauthProviderVO;
import java.util.List;
import java.util.Map;
public interface OAuth2AuthManager extends PluggableAPIAuthenticator, PluggableService {
String GLOBAL_DOMAIN_FILTER = "-1";
Long GLOBAL_DOMAIN_ID = -1L;
public static ConfigKey<Boolean> OAuth2IsPluginEnabled = new ConfigKey<Boolean>("Advanced", Boolean.class, "oauth2.enabled", "false",
"Indicates whether OAuth plugin is enabled or not", false);
"Indicates whether OAuth plugin is enabled or not. This can be configured at domain level.", true, ConfigKey.Scope.Domain)
.withStrictScope();
public static final ConfigKey<String> OAuth2Plugins = new ConfigKey<String>("Advanced", String.class, "oauth2.plugins", "google,github",
"List of OAuth plugins", true);
public static final ConfigKey<String> OAuth2PluginsExclude = new ConfigKey<String>("Advanced", String.class, "oauth2.plugins.exclude", "",
@ -49,13 +55,30 @@ public interface OAuth2AuthManager extends PluggableAPIAuthenticator, PluggableS
*/
UserOAuth2Authenticator getUserOAuth2AuthenticationProvider(final String providerName);
String verifyCodeAndFetchEmail(String code, String provider);
String verifySecretCodeAndFetchEmail(String code, String provider, Long domainId);
OauthProviderVO registerOauthProvider(RegisterOAuthProviderCmd cmd);
List<OauthProviderVO> listOauthProviders(String provider, String uuid);
List<OauthProviderVO> listOauthProviders(String provider, String uuid, Long domainId);
boolean deleteOauthProvider(Long id);
OauthProviderVO updateOauthProvider(UpdateOAuthProviderCmd cmd);
Long resolveDomainId(Map<String, Object[]> params);
/**
* Resolves whether the OAuth plugin is enabled for the given domain scope.
* A null domain or the ROOT domain is treated as the global scope, since the
* ROOT domain has no domain-level override and inherits the global value;
* any other domain is checked strictly at its own domain scope (no inheritance).
* @param domainId domain id, or null for global
* @return true if OAuth is enabled for that scope
*/
static boolean isPluginEnabledForDomain(Long domainId) {
if (domainId == null || domainId == Domain.ROOT_DOMAIN) {
return Boolean.TRUE.equals(OAuth2IsPluginEnabled.value());
}
return Boolean.TRUE.equals(OAuth2IsPluginEnabled.valueInScope(ConfigKey.Scope.Domain, domainId, true));
}
}

View File

@ -23,9 +23,11 @@ import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import javax.inject.Inject;
import org.apache.cloudstack.api.ApiConstants;
import org.apache.cloudstack.auth.UserOAuth2Authenticator;
import org.apache.cloudstack.framework.config.ConfigKey;
import org.apache.cloudstack.framework.config.Configurable;
@ -39,15 +41,28 @@ import org.apache.cloudstack.oauth2.dao.OauthProviderDao;
import org.apache.cloudstack.oauth2.vo.OauthProviderVO;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.ArrayUtils;
import com.cloud.domain.Domain;
import com.cloud.domain.DomainVO;
import com.cloud.user.DomainManager;
import com.cloud.user.DomainService;
import com.cloud.utils.component.Manager;
import com.cloud.utils.component.ManagerBase;
import com.cloud.utils.exception.CloudRuntimeException;
import org.apache.cloudstack.framework.messagebus.MessageBus;
public class OAuth2AuthManagerImpl extends ManagerBase implements OAuth2AuthManager, Manager, Configurable {
@Inject
protected OauthProviderDao _oauthProviderDao;
@Inject
private DomainService _domainService;
@Inject
private MessageBus _messageBus;
protected static Map<String, UserOAuth2Authenticator> userOAuth2AuthenticationProvidersMap = new HashMap<>();
private List<UserOAuth2Authenticator> userOAuth2AuthenticationProviders;
@ -63,17 +78,29 @@ public class OAuth2AuthManagerImpl extends ManagerBase implements OAuth2AuthMana
@Override
public boolean start() {
if (isOAuthPluginEnabled()) {
logger.info("OAUTH plugin loaded");
initializeUserOAuth2AuthenticationProvidersMap();
} else {
logger.info("OAUTH plugin not enabled so not loading");
}
initializeUserOAuth2AuthenticationProvidersMap();
addDomainRemovalListener();
logger.info("OAUTH plugin loaded");
return true;
}
protected boolean isOAuthPluginEnabled() {
return OAuth2IsPluginEnabled.value();
private void addDomainRemovalListener() {
_messageBus.subscribe(DomainManager.MESSAGE_PRE_REMOVE_DOMAIN_EVENT, (senderAddress, subject, args) -> {
try {
long domainId = ((DomainVO) args).getId();
List<OauthProviderVO> providers = _oauthProviderDao.listByDomain(domainId);
for (OauthProviderVO provider : providers) {
_oauthProviderDao.expunge(provider.getId());
logger.debug("Removed OAuth provider {} for deleted domain {}", provider.getProvider(), domainId);
}
} catch (Exception e) {
logger.error("Failed to remove OAuth providers for deleted domain", e);
}
});
}
protected boolean isOAuthPluginEnabled(Long domainId) {
return OAuth2AuthManager.isPluginEnabledForDomain(domainId);
}
@Override
@ -124,9 +151,11 @@ public class OAuth2AuthManagerImpl extends ManagerBase implements OAuth2AuthMana
}
@Override
public String verifyCodeAndFetchEmail(String code, String provider) {
public String verifySecretCodeAndFetchEmail(String code, String provider, Long domainId) {
UserOAuth2Authenticator authenticator = getUserOAuth2AuthenticationProvider(provider);
return authenticator.verifyCodeAndFetchEmail(code);
String email = authenticator.verifySecretCodeAndFetchEmail(code, domainId);
return email;
}
@Override
@ -136,27 +165,38 @@ public class OAuth2AuthManagerImpl extends ManagerBase implements OAuth2AuthMana
String clientId = StringUtils.trim(cmd.getClientId());
String redirectUri = StringUtils.trim(cmd.getRedirectUri());
String secretKey = StringUtils.trim(cmd.getSecretKey());
Long domainId = normalizeGlobalScope(resolveDomainIdFromIdOrPath(cmd.getDomainId(), cmd.getDomainPath()));
String authorizeUrl = StringUtils.trim(cmd.getAuthorizeUrl());
String tokenUrl = StringUtils.trim(cmd.getTokenUrl());
if (!isOAuthPluginEnabled()) {
if (!isOAuthPluginEnabled(domainId)) {
throw new CloudRuntimeException("OAuth is not enabled, please enable to register");
}
OauthProviderVO providerVO = _oauthProviderDao.findByProvider(provider);
// Check for existing provider with same name and domain
OauthProviderVO providerVO = _oauthProviderDao.findByProviderAndDomain(provider, domainId);
if (providerVO != null) {
throw new CloudRuntimeException(String.format("Provider with the name %s is already registered", provider));
if (domainId == null) {
throw new CloudRuntimeException(String.format("Global provider with the name %s is already registered", provider));
} else {
throw new CloudRuntimeException(String.format("Provider with the name %s is already registered for domain %d", provider, domainId));
}
}
return saveOauthProvider(provider, description, clientId, secretKey, redirectUri, authorizeUrl, tokenUrl);
return saveOauthProvider(provider, description, clientId, secretKey, redirectUri, authorizeUrl, tokenUrl, domainId);
}
@Override
public List<OauthProviderVO> listOauthProviders(String provider, String uuid) {
public List<OauthProviderVO> listOauthProviders(String provider, String uuid, Long domainId) {
List<OauthProviderVO> providers;
if (uuid != null) {
providers = Collections.singletonList(_oauthProviderDao.findByUuid(uuid));
} else if (StringUtils.isNotBlank(provider) && domainId != null) {
providers = Collections.singletonList(_oauthProviderDao.findByProviderAndDomain(provider, domainId));
} else if (StringUtils.isNotBlank(provider)) {
providers = Collections.singletonList(_oauthProviderDao.findByProvider(provider));
providers = Collections.singletonList(_oauthProviderDao.findByProviderAndDomain(provider, null));
} else if (domainId != null) {
providers = _oauthProviderDao.listByDomainIncludingGlobal(domainId);
} else {
providers = _oauthProviderDao.listAll();
}
@ -179,6 +219,30 @@ public class OAuth2AuthManagerImpl extends ManagerBase implements OAuth2AuthMana
throw new CloudRuntimeException("Provider with the given id is not there");
}
Long targetDomainId = providerVO.getDomainId();
if (cmd.getDomainId() != null || StringUtils.isNotEmpty(cmd.getDomainPath())) {
Long resolved = resolveDomainIdFromIdOrPath(cmd.getDomainId(), cmd.getDomainPath());
if (resolved == null) {
throw new CloudRuntimeException("Unable to resolve the supplied domain. Provide a valid domain id or path.");
}
resolved = normalizeGlobalScope(resolved);
if (!Objects.equals(resolved, providerVO.getDomainId())) {
OauthProviderVO existing = _oauthProviderDao.findByProviderAndDomain(providerVO.getProvider(), resolved);
if (existing != null) {
throw new CloudRuntimeException(String.format(
"Provider with the name %s is already registered for domain %s", providerVO.getProvider(),
resolved == null ? "ROOT (global)" : resolved));
}
}
targetDomainId = resolved;
}
if (Boolean.TRUE.equals(enabled) && !isOAuthPluginEnabled(targetDomainId)) {
throw new CloudRuntimeException(String.format(
"OAuth plugin is not enabled %s. Enable oauth2.enabled at that scope before enabling this provider.",
targetDomainId == null ? "globally" : "for this domain"));
}
if (StringUtils.isNotEmpty(description)) {
providerVO.setDescription(description);
}
@ -200,13 +264,14 @@ public class OAuth2AuthManagerImpl extends ManagerBase implements OAuth2AuthMana
if (enabled != null) {
providerVO.setEnabled(enabled);
}
providerVO.setDomainId(targetDomainId);
_oauthProviderDao.update(id, providerVO);
return _oauthProviderDao.findById(id);
}
private OauthProviderVO saveOauthProvider(String provider, String description, String clientId, String secretKey, String redirectUri, String authorizeUrl, String tokenUrl) {
private OauthProviderVO saveOauthProvider(String provider, String description, String clientId, String secretKey, String redirectUri, String authorizeUrl, String tokenUrl, Long domainId) {
final OauthProviderVO oauthProviderVO = new OauthProviderVO();
oauthProviderVO.setProvider(provider);
@ -214,6 +279,7 @@ public class OAuth2AuthManagerImpl extends ManagerBase implements OAuth2AuthMana
oauthProviderVO.setClientId(clientId);
oauthProviderVO.setSecretKey(secretKey);
oauthProviderVO.setRedirectUri(redirectUri);
oauthProviderVO.setDomainId(domainId);
oauthProviderVO.setAuthorizeUrl(authorizeUrl);
oauthProviderVO.setTokenUrl(tokenUrl);
oauthProviderVO.setEnabled(true);
@ -225,7 +291,66 @@ public class OAuth2AuthManagerImpl extends ManagerBase implements OAuth2AuthMana
@Override
public boolean deleteOauthProvider(Long id) {
return _oauthProviderDao.remove(id);
return _oauthProviderDao.expunge(id);
}
@Override
public Long resolveDomainId(Map<String, Object[]> params) {
final String[] domainIdArray = (String[])params.get(ApiConstants.DOMAIN_ID);
if (ArrayUtils.isNotEmpty(domainIdArray)) {
String domainUuid = domainIdArray[0];
if (GLOBAL_DOMAIN_FILTER.equals(domainUuid)) {
return GLOBAL_DOMAIN_ID;
}
Domain domain = _domainService.getDomain(domainUuid);
if (Objects.nonNull(domain)) {
return domain.getId();
}
}
final String[] domainArray = (String[])params.get(ApiConstants.DOMAIN);
if (ArrayUtils.isNotEmpty(domainArray)) {
String path = normalizeDomainPath(domainArray[0]);
if (StringUtils.isNotEmpty(path)) {
Domain domain = _domainService.findDomainByIdOrPath(null, path);
if (Objects.nonNull(domain)) {
return domain.getId();
}
}
}
return null;
}
protected Long resolveDomainIdFromIdOrPath(Long domainId, String domainPath) {
if (domainId != null) {
return domainId;
}
String path = normalizeDomainPath(domainPath);
if (StringUtils.isNotEmpty(path)) {
Domain domain = _domainService.findDomainByIdOrPath(null, path);
if (Objects.nonNull(domain)) {
return domain.getId();
}
}
return null;
}
// The ROOT domain is the top of the tree, so a provider scoped to it is equivalent
// to a global provider; treat it as global so the global oauth2.enabled config applies.
protected Long normalizeGlobalScope(Long domainId) {
return (domainId != null && Domain.ROOT_DOMAIN == domainId) ? null : domainId;
}
protected String normalizeDomainPath(String path) {
if (StringUtils.isEmpty(path)) {
return null;
}
if (!path.startsWith("/")) {
path = "/" + path;
}
if (!path.endsWith("/")) {
path += "/";
}
return path;
}
@Override

View File

@ -30,8 +30,7 @@ import org.apache.cloudstack.auth.UserOAuth2Authenticator;
import javax.inject.Inject;
import java.util.Map;
import static org.apache.cloudstack.oauth2.OAuth2AuthManager.OAuth2IsPluginEnabled;
import java.util.Objects;
public class OAuth2UserAuthenticator extends AdapterBase implements UserAuthenticator {
@ -49,7 +48,7 @@ public class OAuth2UserAuthenticator extends AdapterBase implements UserAuthenti
logger.debug("Trying OAuth2 auth for user: " + username);
}
if (!isOAuthPluginEnabled()) {
if (!isOAuthPluginEnabled(domainId)) {
logger.debug("OAuth2 plugin is disabled");
return new Pair<Boolean, ActionOnFailedAuthentication>(false, null);
} else if (requestParameters == null) {
@ -76,7 +75,7 @@ public class OAuth2UserAuthenticator extends AdapterBase implements UserAuthenti
String secretCode = ((secretCodeArray == null) ? null : secretCodeArray[0]);
UserOAuth2Authenticator authenticator = userOAuth2mgr.getUserOAuth2AuthenticationProvider(oauthProvider);
if (user != null && authenticator.verifyUser(email, secretCode)) {
if (Objects.nonNull(user) && authenticator.verifyUser(email, secretCode, domainId)) {
return new Pair<Boolean, ActionOnFailedAuthentication>(true, null);
}
}
@ -89,7 +88,7 @@ public class OAuth2UserAuthenticator extends AdapterBase implements UserAuthenti
return null;
}
protected boolean isOAuthPluginEnabled() {
return OAuth2IsPluginEnabled.value();
protected boolean isOAuthPluginEnabled(Long domainId) {
return OAuth2AuthManager.isPluginEnabledForDomain(domainId);
}
}

View File

@ -35,14 +35,17 @@ import org.apache.cloudstack.api.ServerApiException;
import org.apache.cloudstack.api.auth.APIAuthenticationType;
import org.apache.cloudstack.api.auth.APIAuthenticator;
import org.apache.cloudstack.api.auth.PluggableAPIAuthenticator;
import org.apache.cloudstack.api.response.DomainResponse;
import org.apache.cloudstack.api.response.ListResponse;
import org.apache.cloudstack.auth.UserOAuth2Authenticator;
import org.apache.cloudstack.oauth2.OAuth2AuthManager;
import org.apache.cloudstack.oauth2.api.response.OauthProviderResponse;
import org.apache.cloudstack.oauth2.vo.OauthProviderVO;
import org.apache.commons.lang.ArrayUtils;
import org.apache.commons.lang3.ArrayUtils;
import com.cloud.api.ApiDBUtils;
import com.cloud.api.response.ApiResponseSerializer;
import com.cloud.domain.Domain;
import com.cloud.user.Account;
@APICommand(name = "listOauthProvider", description = "List OAuth providers registered", responseObject = OauthProviderResponse.class, entityType = {},
@ -60,6 +63,14 @@ public class ListOAuthProvidersCmd extends BaseListCmd implements APIAuthenticat
@Parameter(name = ApiConstants.PROVIDER, type = CommandType.STRING, description = "Name of the provider")
private String provider;
@Parameter(name = ApiConstants.DOMAIN_ID, type = CommandType.UUID, entityType = DomainResponse.class,
description = "Domain ID to list OAuth providers for a specific domain. Use -1 for global providers only.", since = "4.23.0")
private Long domainId;
@Parameter(name = ApiConstants.DOMAIN, type = CommandType.STRING,
description = "Domain path for domain-specific OAuth provider lookup. Ignored when Domain ID is passed.", since = "4.23.0")
private String domainPath;
/////////////////////////////////////////////////////
/////////////////// Accessors ///////////////////////
/////////////////////////////////////////////////////
@ -71,6 +82,10 @@ public class ListOAuthProvidersCmd extends BaseListCmd implements APIAuthenticat
return provider;
}
public Long getDomainId() {
return domainId;
}
/////////////////////////////////////////////////////
/////////////// API Implementation///////////////////
/////////////////////////////////////////////////////
@ -99,7 +114,26 @@ public class ListOAuthProvidersCmd extends BaseListCmd implements APIAuthenticat
provider = providerArray[0];
}
List<OauthProviderVO> resultList = _oauth2mgr.listOauthProviders(provider, id);
boolean domainRequested = ArrayUtils.isNotEmpty((String[])params.get(ApiConstants.DOMAIN_ID))
|| ArrayUtils.isNotEmpty((String[])params.get(ApiConstants.DOMAIN));
domainId = _oauth2mgr.resolveDomainId(params);
if (domainRequested && domainId == null) {
ListResponse<OauthProviderResponse> response = new ListResponse<>();
response.setResponses(new ArrayList<>(), 0);
response.setResponseName(getCommandName());
setResponseObject(response);
return ApiResponseSerializer.toSerializedString(response, responseType);
}
List<OauthProviderVO> resultList = _oauth2mgr.listOauthProviders(provider, id, domainId);
boolean isAuthenticated = session != null && session.getAttribute(ApiConstants.USER_ID) != null;
if (domainRequested && domainId != null && domainId > 0) {
resultList.removeIf(p -> p.getDomainId() == null);
} else if (!domainRequested && !isAuthenticated) {
resultList.removeIf(p -> p.getDomainId() != null);
}
List<UserOAuth2Authenticator> userOAuth2AuthenticatorPlugins = _oauth2mgr.listUserOAuth2AuthenticationProviders();
List<String> authenticatorPluginNames = new ArrayList<>();
for (UserOAuth2Authenticator authenticator : userOAuth2AuthenticatorPlugins) {
@ -108,9 +142,11 @@ public class ListOAuthProvidersCmd extends BaseListCmd implements APIAuthenticat
}
List<OauthProviderResponse> responses = new ArrayList<>();
for (OauthProviderVO result : resultList) {
Domain domain = result.getDomainId() != null ? ApiDBUtils.findDomainById(result.getDomainId()) : null;
OauthProviderResponse r = new OauthProviderResponse(result.getUuid(), result.getProvider(),
result.getDescription(), result.getClientId(), result.getSecretKey(), result.getRedirectUri(), result.getAuthorizeUrl(), result.getTokenUrl());
if (OAuth2AuthManager.OAuth2IsPluginEnabled.value() && authenticatorPluginNames.contains(result.getProvider()) && result.isEnabled()) {
result.getDescription(), result.getClientId(), result.getSecretKey(), result.getRedirectUri(), result.getAuthorizeUrl(), result.getTokenUrl(), domain);
boolean oauthEnabled = OAuth2AuthManager.isPluginEnabledForDomain(result.getDomainId());
if (oauthEnabled && authenticatorPluginNames.contains(result.getProvider()) && result.isEnabled()) {
r.setEnabled(true);
} else {
r.setEnabled(false);
@ -119,8 +155,20 @@ public class ListOAuthProvidersCmd extends BaseListCmd implements APIAuthenticat
responses.add(r);
}
int totalEnabledCount = responses.size();
if (!domainRequested && !isAuthenticated) {
List<OauthProviderVO> allProviders = _oauth2mgr.listOauthProviders(null, null, null);
for (OauthProviderVO domainProvider : allProviders) {
if (domainProvider.getDomainId() != null && domainProvider.isEnabled()
&& OAuth2AuthManager.isPluginEnabledForDomain(domainProvider.getDomainId())
&& authenticatorPluginNames.contains(domainProvider.getProvider())) {
totalEnabledCount++;
}
}
}
ListResponse<OauthProviderResponse> response = new ListResponse<>();
response.setResponses(responses, resultList.size());
response.setResponses(responses, totalEnabledCount);
response.setResponseName(getCommandName());
setResponseObject(response);

View File

@ -16,6 +16,8 @@
// under the License.
package org.apache.cloudstack.oauth2.api.command;
import java.util.Objects;
import com.cloud.api.ApiServlet;
import com.cloud.domain.Domain;
import com.cloud.user.User;
@ -48,7 +50,7 @@ import java.util.List;
import java.util.Map;
import java.net.InetAddress;
import static org.apache.cloudstack.oauth2.OAuth2AuthManager.OAuth2IsPluginEnabled;
import org.apache.cloudstack.oauth2.OAuth2AuthManager;
@APICommand(name = "oauthlogin", description = "Logs a user into the CloudStack after successful verification of OAuth secret code from the particular provider." +
"A successful login attempt will generate a JSESSIONID cookie value that can be passed in subsequent Query command calls until the \"logout\" command has been issued or the session has expired.",
@ -120,9 +122,6 @@ public class OauthLoginAPIAuthenticatorCmd extends BaseCmd implements APIAuthent
@Override
public String authenticate(String command, Map<String, Object[]> params, HttpSession session, InetAddress remoteAddress, String responseType, StringBuilder auditTrailSb, final HttpServletRequest req, final HttpServletResponse resp) throws ServerApiException {
if (!OAuth2IsPluginEnabled.value()) {
throw new CloudAuthenticationException("OAuth is not enabled in CloudStack, users cannot login using OAuth");
}
final String[] provider = (String[])params.get(ApiConstants.PROVIDER);
final String[] emailArray = (String[])params.get(ApiConstants.EMAIL);
final String[] secretCodeArray = (String[])params.get(ApiConstants.SECRET_CODE);
@ -130,15 +129,41 @@ public class OauthLoginAPIAuthenticatorCmd extends BaseCmd implements APIAuthent
String oauthProvider = ((provider == null) ? null : provider[0]);
String email = ((emailArray == null) ? null : emailArray[0]);
String secretCode = ((secretCodeArray == null) ? null : secretCodeArray[0]);
if (StringUtils.isAnyEmpty(oauthProvider, email, secretCode)) {
throw new CloudAuthenticationException("OAuth provider, email, secretCode any of these cannot be null");
try {
if (StringUtils.isAnyEmpty(oauthProvider, email, secretCode)) {
throw new CloudAuthenticationException("OAuth provider, email, secretCode any of these cannot be null");
}
Long domainId = getDomainIdFromParams(params, auditTrailSb, responseType);
final String[] domainName = (String[])params.get(ApiConstants.DOMAIN);
String domain = getDomainName(auditTrailSb, domainName);
final Domain userDomain = _domainService.findDomainByIdOrPath(domainId, domain);
if (Objects.nonNull(userDomain)) {
domainId = userDomain.getId();
}
boolean oauthEnabled = OAuth2AuthManager.isPluginEnabledForDomain(domainId);
if (!oauthEnabled) {
logger.debug(String.format("OAuth is not enabled %s, users cannot login using OAuth", domainId == null ? "globally" : "in domain " + domainId));
throw new CloudAuthenticationException(String.format(
"OAuth login is not enabled %s. Please contact your administrator.",
domainId == null ? "globally" : "for this domain"));
}
return doOauthAuthentication(session, domainId, domain, email, params, remoteAddress, responseType, auditTrailSb);
} catch (final CloudAuthenticationException ex) {
throw toServerApiException(session, params, responseType, auditTrailSb, ex);
}
}
Long domainId = getDomainIdFromParams(params, auditTrailSb, responseType);
final String[] domainName = (String[])params.get(ApiConstants.DOMAIN);
String domain = getDomainName(auditTrailSb, domainName);
return doOauthAuthentication(session, domainId, domain, email, params, remoteAddress, responseType, auditTrailSb);
private ServerApiException toServerApiException(HttpSession session, Map<String, Object[]> params, String responseType, StringBuilder auditTrailSb, CloudAuthenticationException ex) {
ApiServlet.invalidateHttpSession(session, "fall through to API key,");
String msg = ex.getMessage() != null ? ex.getMessage() : "failed to authenticate user via OAuth";
auditTrailSb.append(" " + ApiErrorCode.ACCOUNT_ERROR + " " + msg);
String serializedResponse = _apiServer.getSerializedApiError(ApiErrorCode.ACCOUNT_ERROR.getHttpCode(), msg, params, responseType);
return new ServerApiException(ApiErrorCode.ACCOUNT_ERROR, serializedResponse);
}
private String doOauthAuthentication(HttpSession session, Long domainId, String domain, String email, Map<String, Object[]> params, InetAddress remoteAddress, String responseType, StringBuilder auditTrailSb) {

View File

@ -26,6 +26,7 @@ import org.apache.cloudstack.api.ApiErrorCode;
import org.apache.cloudstack.api.BaseCmd;
import org.apache.cloudstack.api.Parameter;
import org.apache.cloudstack.api.ServerApiException;
import org.apache.cloudstack.api.response.DomainResponse;
import org.apache.cloudstack.api.response.SuccessResponse;
import org.apache.cloudstack.context.CallContext;
import org.apache.cloudstack.oauth2.OAuth2AuthManager;
@ -35,6 +36,8 @@ import org.apache.cloudstack.oauth2.vo.OauthProviderVO;
import org.apache.commons.collections.MapUtils;
import org.apache.commons.lang3.StringUtils;
import com.cloud.api.ApiDBUtils;
import com.cloud.domain.Domain;
import com.cloud.exception.ConcurrentOperationException;
@APICommand(name = "registerOauthProvider", responseObject = SuccessResponse.class, description = "Register the OAuth2 provider in CloudStack", since = "4.19.0")
@ -59,6 +62,14 @@ public class RegisterOAuthProviderCmd extends BaseCmd {
@Parameter(name = ApiConstants.REDIRECT_URI, type = CommandType.STRING, description = "Redirect URI pre-registered in the specific OAuth provider", required = true)
private String redirectUri;
@Parameter(name = ApiConstants.DOMAIN_ID, type = CommandType.UUID, entityType = DomainResponse.class,
description = "Domain ID for domain-specific OAuth provider. If not provided, registers as global provider", since = "4.23.0")
private Long domainId;
@Parameter(name = ApiConstants.DOMAIN, type = CommandType.STRING,
description = "Domain path for domain-specific OAuth provider. Ignored when Domain ID is passed.", since = "4.23.0")
private String domainPath;
@Parameter(name = ApiConstants.AUTHORIZE_URL, type = CommandType.STRING, description = "Authorize URL for OAuth initialization (only required for keycloak provider)")
private String authorizeUrl;
@ -94,6 +105,14 @@ public class RegisterOAuthProviderCmd extends BaseCmd {
return redirectUri;
}
public Long getDomainId() {
return domainId;
}
public String getDomainPath() {
return domainPath;
}
public String getAuthorizeUrl() {
return authorizeUrl;
}
@ -126,9 +145,10 @@ public class RegisterOAuthProviderCmd extends BaseCmd {
OauthProviderVO provider = _oauth2mgr.registerOauthProvider(this);
Domain domain = provider.getDomainId() != null ? ApiDBUtils.findDomainById(provider.getDomainId()) : null;
OauthProviderResponse response = new OauthProviderResponse(provider.getUuid(), provider.getProvider(),
provider.getDescription(), provider.getClientId(), provider.getSecretKey(), provider.getRedirectUri(),
provider.getAuthorizeUrl(), provider.getTokenUrl());
provider.getAuthorizeUrl(), provider.getTokenUrl(), domain);
response.setResponseName(getCommandName());
response.setObjectName(ApiConstants.OAUTH_PROVIDER);
setResponseObject(response);

View File

@ -28,12 +28,16 @@ import org.apache.cloudstack.api.ApiErrorCode;
import org.apache.cloudstack.api.BaseCmd;
import org.apache.cloudstack.api.Parameter;
import org.apache.cloudstack.api.ServerApiException;
import org.apache.cloudstack.api.response.DomainResponse;
import org.apache.cloudstack.auth.UserOAuth2Authenticator;
import org.apache.cloudstack.context.CallContext;
import org.apache.cloudstack.oauth2.OAuth2AuthManager;
import org.apache.cloudstack.oauth2.api.response.OauthProviderResponse;
import org.apache.cloudstack.oauth2.vo.OauthProviderVO;
import com.cloud.api.ApiDBUtils;
import com.cloud.domain.Domain;
@APICommand(name = "updateOauthProvider", description = "Updates the registered OAuth provider details", responseObject = OauthProviderResponse.class,
requestHasSensitiveInfo = false, responseHasSensitiveInfo = false, since = "4.19.0")
public final class UpdateOAuthProviderCmd extends BaseCmd {
@ -66,6 +70,14 @@ public final class UpdateOAuthProviderCmd extends BaseCmd {
@Parameter(name = ApiConstants.ENABLED, type = CommandType.BOOLEAN, description = "OAuth provider will be enabled or disabled based on this value")
private Boolean enabled;
@Parameter(name = ApiConstants.DOMAIN_ID, type = CommandType.UUID, entityType = DomainResponse.class,
description = "Domain ID to reassign this OAuth provider to. If not provided, the current domain assignment is kept.", since = "4.23.0")
private Long domainId;
@Parameter(name = ApiConstants.DOMAIN, type = CommandType.STRING,
description = "Domain path to reassign this OAuth provider to. Ignored when Domain ID is passed. If neither is provided, the current domain assignment is kept.", since = "4.23.0")
private String domainPath;
@Inject
OAuth2AuthManager _oauthMgr;
@ -105,6 +117,14 @@ public final class UpdateOAuthProviderCmd extends BaseCmd {
return enabled;
}
public Long getDomainId() {
return domainId;
}
public String getDomainPath() {
return domainPath;
}
/////////////////////////////////////////////////////
/////////////// API Implementation///////////////////
/////////////////////////////////////////////////////
@ -128,9 +148,10 @@ public final class UpdateOAuthProviderCmd extends BaseCmd {
public void execute() {
OauthProviderVO result = _oauthMgr.updateOauthProvider(this);
if (result != null) {
Domain domain = result.getDomainId() != null ? ApiDBUtils.findDomainById(result.getDomainId()) : null;
OauthProviderResponse r = new OauthProviderResponse(result.getUuid(), result.getProvider(),
result.getDescription(), result.getClientId(), result.getSecretKey(), result.getRedirectUri(),
result.getAuthorizeUrl(), result.getTokenUrl());
result.getAuthorizeUrl(), result.getTokenUrl(), domain);
List<UserOAuth2Authenticator> userOAuth2AuthenticatorPlugins = _oauthMgr.listUserOAuth2AuthenticationProviders();
List<String> authenticatorPluginNames = new ArrayList<>();
@ -138,7 +159,8 @@ public final class UpdateOAuthProviderCmd extends BaseCmd {
String name = authenticator.getName();
authenticatorPluginNames.add(name);
}
if (OAuth2AuthManager.OAuth2IsPluginEnabled.value() && authenticatorPluginNames.contains(result.getProvider()) && result.isEnabled()) {
boolean oauthEnabled = OAuth2AuthManager.isPluginEnabledForDomain(result.getDomainId());
if (oauthEnabled && authenticatorPluginNames.contains(result.getProvider()) && result.isEnabled()) {
r.setEnabled(true);
} else {
r.setEnabled(false);

View File

@ -20,6 +20,9 @@ import java.net.InetAddress;
import java.util.List;
import java.util.Map;
import com.cloud.api.response.ApiResponseSerializer;
import com.cloud.user.Account;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
@ -34,13 +37,11 @@ import org.apache.cloudstack.api.ServerApiException;
import org.apache.cloudstack.api.auth.APIAuthenticationType;
import org.apache.cloudstack.api.auth.APIAuthenticator;
import org.apache.cloudstack.api.auth.PluggableAPIAuthenticator;
import org.apache.cloudstack.api.response.DomainResponse;
import org.apache.cloudstack.api.response.UserResponse;
import org.apache.cloudstack.oauth2.OAuth2AuthManager;
import org.apache.cloudstack.oauth2.api.response.OauthProviderResponse;
import org.apache.commons.lang.ArrayUtils;
import com.cloud.api.response.ApiResponseSerializer;
import com.cloud.user.Account;
import org.apache.commons.lang3.ArrayUtils;
@APICommand(name = "verifyOAuthCodeAndGetUser", description = "Verify the OAuth Code and fetch the corresponding user from provider", responseObject = OauthProviderResponse.class, entityType = {},
requestHasSensitiveInfo = false, responseHasSensitiveInfo = false,
@ -58,6 +59,14 @@ public class VerifyOAuthCodeAndGetUserCmd extends BaseListCmd implements APIAuth
@Parameter(name = ApiConstants.SECRET_CODE, type = CommandType.STRING, description = "Code that is provided by OAuth provider (Eg. google, github) after successful login")
private String secretCode;
@Parameter(name = ApiConstants.DOMAIN_ID, type = CommandType.UUID, entityType = DomainResponse.class,
description = "Domain ID for domain-specific OAuth provider lookup. If not provided, uses global provider", since = "4.23.0")
private Long domainId;
@Parameter(name = ApiConstants.DOMAIN, type = CommandType.STRING,
description = "Domain path for domain-specific OAuth provider lookup. Ignored when Domain ID is passed.", since = "4.23.0")
private String domainPath;
/////////////////////////////////////////////////////
/////////////////// Accessors ///////////////////////
/////////////////////////////////////////////////////
@ -70,6 +79,10 @@ public class VerifyOAuthCodeAndGetUserCmd extends BaseListCmd implements APIAuth
return secretCode;
}
public Long getDomainId() {
return domainId;
}
/////////////////////////////////////////////////////
/////////////// API Implementation///////////////////
/////////////////////////////////////////////////////
@ -97,8 +110,9 @@ public class VerifyOAuthCodeAndGetUserCmd extends BaseListCmd implements APIAuth
if (ArrayUtils.isNotEmpty(providerArray)) {
provider = providerArray[0];
}
domainId = _oauth2mgr.resolveDomainId(params);
String email = _oauth2mgr.verifyCodeAndFetchEmail(secretCode, provider);
String email = _oauth2mgr.verifySecretCodeAndFetchEmail(secretCode, provider, domainId);
if (email != null) {
UserResponse response = new UserResponse();
response.setEmail(email);

View File

@ -16,11 +16,14 @@
// under the License.
package org.apache.cloudstack.oauth2.api.response;
import java.util.Objects;
import org.apache.cloudstack.api.ApiConstants;
import org.apache.cloudstack.api.BaseResponse;
import org.apache.cloudstack.api.EntityReference;
import org.apache.cloudstack.oauth2.vo.OauthProviderVO;
import com.cloud.domain.Domain;
import com.cloud.serializer.Param;
import com.google.gson.annotations.SerializedName;
@ -55,6 +58,18 @@ public class OauthProviderResponse extends BaseResponse {
@Param(description = "Redirect URI registered in the OAuth provider")
private String redirectUri;
@SerializedName(ApiConstants.DOMAIN_ID)
@Param(description = "UUID of the domain the provider belongs to (empty for global)", since = "4.23.0")
private String domainUuid;
@SerializedName(ApiConstants.DOMAIN)
@Param(description = "name of the domain the provider belongs to (empty for global)", since = "4.23.0")
private String domainName;
@SerializedName(ApiConstants.DOMAIN_PATH)
@Param(description = "path of the domain the provider belongs to (empty for global)", since = "4.23.0")
private String domainPath;
@SerializedName(ApiConstants.AUTHORIZE_URL)
@Param(description = "Authorize URL registered in the OAuth provider")
private String authorizeUrl;
@ -67,7 +82,7 @@ public class OauthProviderResponse extends BaseResponse {
@Param(description = "Whether the OAuth provider is enabled or not")
private boolean enabled;
public OauthProviderResponse(String id, String provider, String description, String clientId, String secretKey, String redirectUri, String authorizeUrl, String tokenUrl) {
public OauthProviderResponse(String id, String provider, String description, String clientId, String secretKey, String redirectUri, String authorizeUrl, String tokenUrl, Domain domain) {
this.id = id;
this.provider = provider;
this.name = provider;
@ -77,6 +92,19 @@ public class OauthProviderResponse extends BaseResponse {
this.redirectUri = redirectUri;
this.authorizeUrl = authorizeUrl;
this.tokenUrl = tokenUrl;
if (Objects.nonNull(domain)) {
this.domainUuid = domain.getUuid();
this.domainName = domain.getName();
this.domainPath = prettifyDomainPath(domain.getPath());
}
}
private static String prettifyDomainPath(String path) {
if (path == null) {
return null;
}
String trimmed = path.endsWith("/") ? path.substring(0, path.length() - 1) : path;
return "ROOT" + trimmed;
}
public String getId() {
@ -128,6 +156,30 @@ public class OauthProviderResponse extends BaseResponse {
this.redirectUri = redirectUri;
}
public String getDomainUuid() {
return domainUuid;
}
public void setDomainUuid(String domainUuid) {
this.domainUuid = domainUuid;
}
public String getDomainName() {
return domainName;
}
public void setDomainName(String domainName) {
this.domainName = domainName;
}
public String getDomainPath() {
return domainPath;
}
public void setDomainPath(String domainPath) {
this.domainPath = domainPath;
}
public String getAuthorizeUrl() {
return authorizeUrl;
}

View File

@ -19,8 +19,16 @@ package org.apache.cloudstack.oauth2.dao;
import com.cloud.utils.db.GenericDao;
import org.apache.cloudstack.oauth2.vo.OauthProviderVO;
import java.util.List;
public interface OauthProviderDao extends GenericDao<OauthProviderVO, Long> {
public OauthProviderVO findByProvider(String provider);
public OauthProviderVO findByProviderAndDomain(String provider, Long domainId);
public List<OauthProviderVO> listByDomainIncludingGlobal(Long domainId);
public List<OauthProviderVO> listByDomain(Long domainId);
public OauthProviderVO findByProviderAndDomainWithGlobalFallback(String provider, Long domainId);
}

View File

@ -22,23 +22,54 @@ import com.cloud.utils.db.SearchBuilder;
import com.cloud.utils.db.SearchCriteria;
import org.apache.cloudstack.oauth2.vo.OauthProviderVO;
import java.util.List;
import java.util.Objects;
public class OauthProviderDaoImpl extends GenericDaoBase<OauthProviderVO, Long> implements OauthProviderDao {
private final SearchBuilder<OauthProviderVO> oauthProviderSearchByName;
private final SearchBuilder<OauthProviderVO> oauthProviderSearchByProviderAndDomain;
public OauthProviderDaoImpl() {
super();
oauthProviderSearchByName = createSearchBuilder();
oauthProviderSearchByName.and("provider", oauthProviderSearchByName.entity().getProvider(), SearchCriteria.Op.EQ);
oauthProviderSearchByName.done();
oauthProviderSearchByProviderAndDomain = createSearchBuilder();
oauthProviderSearchByProviderAndDomain.and("provider", oauthProviderSearchByProviderAndDomain.entity().getProvider(), SearchCriteria.Op.EQ);
oauthProviderSearchByProviderAndDomain.and("domainId", oauthProviderSearchByProviderAndDomain.entity().getDomainId(), SearchCriteria.Op.EQ);
oauthProviderSearchByProviderAndDomain.done();
}
@Override
public OauthProviderVO findByProvider(String provider) {
SearchCriteria<OauthProviderVO> sc = oauthProviderSearchByName.create();
public OauthProviderVO findByProviderAndDomain(String provider, Long domainId) {
SearchCriteria<OauthProviderVO> sc = oauthProviderSearchByProviderAndDomain.create();
sc.setParameters("provider", provider);
sc.setParameters("domainId", domainId);
return findOneBy(sc);
}
@Override
public List<OauthProviderVO> listByDomainIncludingGlobal(Long domainId) {
SearchCriteria<OauthProviderVO> sc = createSearchCriteria();
sc.addOr("domainId", SearchCriteria.Op.EQ, domainId);
sc.addOr("domainId", SearchCriteria.Op.NULL);
return listBy(sc);
}
@Override
public List<OauthProviderVO> listByDomain(Long domainId) {
SearchCriteria<OauthProviderVO> sc = createSearchCriteria();
sc.addAnd("domainId", SearchCriteria.Op.EQ, domainId);
return listBy(sc);
}
@Override
public OauthProviderVO findByProviderAndDomainWithGlobalFallback(String provider, Long domainId) {
OauthProviderVO providerVO = null;
if (Objects.nonNull(domainId)) {
providerVO = findByProviderAndDomain(provider, domainId);
}
if (Objects.isNull(providerVO)) {
providerVO = findByProviderAndDomain(provider, null);
}
return providerVO;
}
}

View File

@ -56,17 +56,27 @@ public class GithubOAuth2Provider extends AdapterBase implements UserOAuth2Authe
@Override
public boolean verifyUser(String email, String secretCode) {
return verifyUser(email, secretCode, null);
}
@Override
public String verifySecretCodeAndFetchEmail(String secretCode) {
return verifySecretCodeAndFetchEmail(secretCode, null);
}
@Override
public boolean verifyUser(String email, String secretCode, Long domainId) {
if (StringUtils.isAnyEmpty(email, secretCode)) {
throw new CloudRuntimeException(String.format("Either email or secretcode should not be null/empty"));
}
OauthProviderVO providerVO = _oauthProviderDao.findByProvider(getName());
OauthProviderVO providerVO = _oauthProviderDao.findByProviderAndDomainWithGlobalFallback(getName(), domainId);
if (providerVO == null) {
throw new CloudRuntimeException("Github provider is not registered, so user cannot be verified");
}
String verifiedEmail = getUserEmailAddress();
if (verifiedEmail == null || !email.equals(verifiedEmail)) {
String verifiedEmail = verifySecretCodeAndFetchEmail(secretCode, domainId);
if (StringUtils.isEmpty(verifiedEmail) || !email.equals(verifiedEmail)) {
throw new CloudRuntimeException("Unable to verify the email address with the provided secret");
}
@ -76,16 +86,19 @@ public class GithubOAuth2Provider extends AdapterBase implements UserOAuth2Authe
}
@Override
public String verifyCodeAndFetchEmail(String secretCode) {
String accessToken = getAccessToken(secretCode);
if (accessToken == null) {
public String verifySecretCodeAndFetchEmail(String secretCode, Long domainId) {
String accessToken = getAccessToken(secretCode, domainId);
if (StringUtils.isEmpty(accessToken)) {
return null;
}
return getUserEmailAddress();
}
protected String getAccessToken(String secretCode) throws CloudRuntimeException {
OauthProviderVO githubProvider = _oauthProviderDao.findByProvider(getName());
protected String getAccessToken(String secretCode, Long domainId) throws CloudRuntimeException {
if (StringUtils.isNotEmpty(accessToken)) {
return accessToken;
}
OauthProviderVO githubProvider = _oauthProviderDao.findByProviderAndDomainWithGlobalFallback(getName(), domainId);
String tokenUrl = "https://github.com/login/oauth/access_token";
String generatedAccessToken = null;
try {
@ -131,7 +144,7 @@ public class GithubOAuth2Provider extends AdapterBase implements UserOAuth2Authe
}
public String getUserEmailAddress() throws CloudRuntimeException {
if (accessToken == null) {
if (StringUtils.isEmpty(accessToken)) {
throw new CloudRuntimeException("Access Token not found to fetch the email address");
}

View File

@ -60,16 +60,36 @@ public class GoogleOAuth2Provider extends AdapterBase implements UserOAuth2Authe
@Override
public boolean verifyUser(String email, String secretCode) {
return verifyUser(email, secretCode, null);
}
@Override
public String verifySecretCodeAndFetchEmail(String secretCode) {
return verifySecretCodeAndFetchEmail(secretCode, null);
}
protected void clearAccessAndRefreshTokens() {
accessToken = null;
refreshToken = null;
}
@Override
public String getUserEmailAddress() throws CloudRuntimeException {
return null;
}
@Override
public boolean verifyUser(String email, String secretCode, Long domainId) {
if (StringUtils.isAnyEmpty(email, secretCode)) {
throw new CloudAuthenticationException("Either email or secret code should not be null/empty");
}
OauthProviderVO providerVO = _oauthProviderDao.findByProvider(getName());
OauthProviderVO providerVO = _oauthProviderDao.findByProviderAndDomainWithGlobalFallback(getName(), domainId);
if (providerVO == null) {
throw new CloudAuthenticationException("Google provider is not registered, so user cannot be verified");
}
String verifiedEmail = verifyCodeAndFetchEmail(secretCode);
String verifiedEmail = verifySecretCodeAndFetchEmail(secretCode, domainId);
if (verifiedEmail == null || !email.equals(verifiedEmail)) {
throw new CloudRuntimeException("Unable to verify the email address with the provided secret");
}
@ -79,11 +99,11 @@ public class GoogleOAuth2Provider extends AdapterBase implements UserOAuth2Authe
}
@Override
public String verifyCodeAndFetchEmail(String secretCode) {
OauthProviderVO googleProvider = _oauthProviderDao.findByProvider(getName());
String clientId = googleProvider.getClientId();
String secret = googleProvider.getSecretKey();
String redirectURI = googleProvider.getRedirectUri();
public String verifySecretCodeAndFetchEmail(String secretCode, Long domainId) {
OauthProviderVO provider = _oauthProviderDao.findByProviderAndDomainWithGlobalFallback(getName(), domainId);
String clientId = provider.getClientId();
String secret = provider.getSecretKey();
String redirectURI = provider.getRedirectUri();
GoogleClientSecrets clientSecrets = new GoogleClientSecrets()
.setWeb(new GoogleClientSecrets.Details()
.setClientId(clientId)
@ -129,13 +149,4 @@ public class GoogleOAuth2Provider extends AdapterBase implements UserOAuth2Authe
return userinfo.getEmail();
}
protected void clearAccessAndRefreshTokens() {
accessToken = null;
refreshToken = null;
}
@Override
public String getUserEmailAddress() throws CloudRuntimeException {
return null;
}
}

View File

@ -81,16 +81,21 @@ public class KeycloakOAuth2Provider extends AdapterBase implements UserOAuth2Aut
@Override
public boolean verifyUser(String email, String secretCode) {
return verifyUser(email, secretCode, null);
}
@Override
public boolean verifyUser(String email, String secretCode, Long domainId) {
if (StringUtils.isAnyEmpty(email, secretCode)) {
throw new CloudAuthenticationException("Either email or secret code should not be null/empty");
}
OauthProviderVO providerVO = oauthProviderDao.findByProvider(getName());
OauthProviderVO providerVO = oauthProviderDao.findByProviderAndDomainWithGlobalFallback(getName(), domainId);
if (providerVO == null) {
throw new CloudAuthenticationException("Keycloak provider is not registered, so user cannot be verified");
}
String verifiedEmail = verifyCodeAndFetchEmail(secretCode);
String verifiedEmail = verifySecretCodeAndFetchEmail(secretCode, domainId);
if (StringUtils.isBlank(verifiedEmail) || !email.equals(verifiedEmail)) {
throw new CloudRuntimeException("Unable to verify the email address with the provided secret");
}
@ -100,8 +105,13 @@ public class KeycloakOAuth2Provider extends AdapterBase implements UserOAuth2Aut
}
@Override
public String verifyCodeAndFetchEmail(String secretCode) {
OauthProviderVO provider = oauthProviderDao.findByProvider(getName());
public String verifySecretCodeAndFetchEmail(String secretCode) {
return verifySecretCodeAndFetchEmail(secretCode, null);
}
@Override
public String verifySecretCodeAndFetchEmail(String secretCode, Long domainId) {
OauthProviderVO provider = oauthProviderDao.findByProviderAndDomainWithGlobalFallback(getName(), domainId);
if (provider == null) {
throw new CloudAuthenticationException("Keycloak provider is not registered, so user cannot be verified");
}

View File

@ -57,6 +57,9 @@ public class OauthProviderVO implements Identity, InternalIdentity {
@Column(name = "redirect_uri")
private String redirectUri;
@Column(name = "domain_id")
private Long domainId;
@Column(name = "authorize_url")
private String authorizeUrl;
@ -142,6 +145,14 @@ public class OauthProviderVO implements Identity, InternalIdentity {
this.secretKey = secretKey;
}
public Long getDomainId() {
return domainId;
}
public void setDomainId(Long domainId) {
this.domainId = domainId;
}
public boolean isEnabled() {
return enabled;
}

View File

@ -19,7 +19,13 @@
package org.apache.cloudstack.oauth2;
import com.cloud.domain.Domain;
import com.cloud.domain.DomainVO;
import com.cloud.user.DomainService;
import com.cloud.utils.exception.CloudRuntimeException;
import org.apache.cloudstack.api.ApiConstants;
import org.apache.cloudstack.framework.messagebus.MessageBus;
import org.apache.cloudstack.framework.messagebus.MessageSubscriber;
import org.apache.cloudstack.oauth2.api.command.DeleteOAuthProviderCmd;
import org.apache.cloudstack.oauth2.api.command.RegisterOAuthProviderCmd;
import org.apache.cloudstack.oauth2.api.command.UpdateOAuthProviderCmd;
@ -36,12 +42,17 @@ import org.mockito.MockitoAnnotations;
import org.mockito.Spy;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class OAuth2AuthManagerImplTest {
@ -53,6 +64,12 @@ public class OAuth2AuthManagerImplTest {
@Mock
OauthProviderDao _oauthProviderDao;
@Mock
DomainService _domainService;
@Mock
MessageBus _messageBus;
AutoCloseable closeable;
@Before
public void setUp() {
@ -66,7 +83,7 @@ public class OAuth2AuthManagerImplTest {
@Test
public void testRegisterOauthProvider() {
when(_authManager.isOAuthPluginEnabled()).thenReturn(false);
when(_authManager.isOAuthPluginEnabled(Mockito.nullable(Long.class))).thenReturn(false);
RegisterOAuthProviderCmd cmd = Mockito.mock(RegisterOAuthProviderCmd.class);
try {
_authManager.registerOauthProvider(cmd);
@ -76,25 +93,27 @@ public class OAuth2AuthManagerImplTest {
}
// Test when provider is already registered
when(_authManager.isOAuthPluginEnabled()).thenReturn(true);
when(_authManager.isOAuthPluginEnabled(Mockito.nullable(Long.class))).thenReturn(true);
OauthProviderVO providerVO = new OauthProviderVO();
providerVO.setProvider("testProvider");
when(_authManager._oauthProviderDao.findByProvider(Mockito.anyString())).thenReturn(providerVO);
when(_authManager._oauthProviderDao.findByProviderAndDomain(Mockito.anyString(), Mockito.isNull())).thenReturn(providerVO);
when(cmd.getProvider()).thenReturn("testProvider");
when(cmd.getDomainId()).thenReturn(null);
try {
_authManager.registerOauthProvider(cmd);
Assert.fail("Expected CloudRuntimeException was not thrown");
} catch (CloudRuntimeException e) {
assertEquals("Provider with the name testProvider is already registered", e.getMessage());
assertEquals("Global provider with the name testProvider is already registered", e.getMessage());
}
// Test when provider is github and secret key is not null
when(cmd.getSecretKey()).thenReturn("testSecretKey");
providerVO = null;
when(_authManager._oauthProviderDao.findByProvider(Mockito.anyString())).thenReturn(providerVO);
when(_authManager._oauthProviderDao.findByProviderAndDomain(Mockito.anyString(), Mockito.isNull())).thenReturn(providerVO);
OauthProviderVO savedProviderVO = new OauthProviderVO();
when(cmd.getProvider()).thenReturn("github");
when(cmd.getDomainId()).thenReturn(null);
when(_authManager._oauthProviderDao.persist(Mockito.any(OauthProviderVO.class))).thenReturn(savedProviderVO);
OauthProviderVO result = _authManager.registerOauthProvider(cmd);
assertEquals("github", result.getProvider());
@ -140,6 +159,115 @@ public class OAuth2AuthManagerImplTest {
assertEquals(secretKey, result.getSecretKey());
}
@Test
public void testUpdateOauthProviderReassignsDomain() {
Long id = 5L;
Long oldDomainId = 10L;
Long newDomainId = 20L;
UpdateOAuthProviderCmd cmd = Mockito.mock(UpdateOAuthProviderCmd.class);
when(cmd.getId()).thenReturn(id);
when(cmd.getDomainId()).thenReturn(newDomainId);
OauthProviderVO providerVO = new OauthProviderVO();
providerVO.setProvider("github");
providerVO.setDomainId(oldDomainId);
when(_oauthProviderDao.findById(id)).thenReturn(providerVO);
Domain newDomain = Mockito.mock(Domain.class);
when(newDomain.getId()).thenReturn(newDomainId);
when(_domainService.getDomain(Mockito.anyString())).thenReturn(newDomain);
Mockito.doReturn(newDomainId).when(_authManager).resolveDomainIdFromIdOrPath(newDomainId, null);
when(_oauthProviderDao.findByProviderAndDomain("github", newDomainId)).thenReturn(null);
when(_oauthProviderDao.update(Mockito.eq(id), Mockito.any(OauthProviderVO.class))).thenReturn(true);
when(_oauthProviderDao.findById(id)).thenReturn(providerVO);
OauthProviderVO result = _authManager.updateOauthProvider(cmd);
assertEquals(newDomainId, result.getDomainId());
}
@Test
public void testUpdateOauthProviderRejectsDuplicateAtTargetDomain() {
Long id = 5L;
Long oldDomainId = 10L;
Long newDomainId = 20L;
UpdateOAuthProviderCmd cmd = Mockito.mock(UpdateOAuthProviderCmd.class);
when(cmd.getId()).thenReturn(id);
when(cmd.getDomainId()).thenReturn(newDomainId);
OauthProviderVO providerVO = new OauthProviderVO();
providerVO.setProvider("github");
providerVO.setDomainId(oldDomainId);
when(_oauthProviderDao.findById(id)).thenReturn(providerVO);
Mockito.doReturn(newDomainId).when(_authManager).resolveDomainIdFromIdOrPath(newDomainId, null);
OauthProviderVO collision = new OauthProviderVO();
collision.setProvider("github");
collision.setDomainId(newDomainId);
when(_oauthProviderDao.findByProviderAndDomain("github", newDomainId)).thenReturn(collision);
try {
_authManager.updateOauthProvider(cmd);
Assert.fail("Expected CloudRuntimeException for duplicate at target domain");
} catch (CloudRuntimeException e) {
assertTrue(e.getMessage().contains("already registered"));
}
}
@Test
public void testRegisterOauthProviderForRootDomainTreatedAsGlobal() {
RegisterOAuthProviderCmd cmd = Mockito.mock(RegisterOAuthProviderCmd.class);
when(cmd.getProvider()).thenReturn("github");
when(cmd.getDomainId()).thenReturn(com.cloud.domain.Domain.ROOT_DOMAIN);
when(cmd.getSecretKey()).thenReturn("secret");
when(cmd.getClientId()).thenReturn("clientId");
when(cmd.getRedirectUri()).thenReturn("https://redirect");
// global check must be consulted (domainId resolves to null), not the ROOT domain scope
when(_authManager.isOAuthPluginEnabled(Mockito.isNull())).thenReturn(true);
when(_oauthProviderDao.findByProviderAndDomain("github", null)).thenReturn(null);
when(_oauthProviderDao.persist(Mockito.any(OauthProviderVO.class))).thenAnswer(i -> i.getArgument(0));
OauthProviderVO result = _authManager.registerOauthProvider(cmd);
assertNull(result.getDomainId());
Mockito.verify(_oauthProviderDao).findByProviderAndDomain("github", null);
}
@Test
public void testNormalizeGlobalScopeMapsRootToNull() {
assertNull(_authManager.normalizeGlobalScope(com.cloud.domain.Domain.ROOT_DOMAIN));
assertNull(_authManager.normalizeGlobalScope(null));
assertEquals(Long.valueOf(42L), _authManager.normalizeGlobalScope(42L));
}
@Test
public void testUpdateOauthProviderRejectsEnableWhenPluginDisabledAtScope() {
Long id = 7L;
Long domainId = 42L;
UpdateOAuthProviderCmd cmd = Mockito.mock(UpdateOAuthProviderCmd.class);
when(cmd.getId()).thenReturn(id);
when(cmd.getEnabled()).thenReturn(true);
OauthProviderVO providerVO = new OauthProviderVO();
providerVO.setProvider("github");
providerVO.setDomainId(domainId);
providerVO.setEnabled(false);
when(_oauthProviderDao.findById(id)).thenReturn(providerVO);
Mockito.doReturn(false).when(_authManager).isOAuthPluginEnabled(domainId);
try {
_authManager.updateOauthProvider(cmd);
Assert.fail("Expected CloudRuntimeException when enabling provider while oauth2.enabled is false at scope");
} catch (CloudRuntimeException e) {
assertTrue(e.getMessage().contains("OAuth plugin is not enabled"));
}
Mockito.verify(_oauthProviderDao, Mockito.never()).update(Mockito.eq(id), Mockito.any(OauthProviderVO.class));
}
@Test
public void testListOauthProviders() {
String uuid = "1234-5678-9101";
@ -150,20 +278,32 @@ public class OAuth2AuthManagerImplTest {
// Test when uuid is not null
when(_oauthProviderDao.findByUuid(uuid)).thenReturn(providerVO);
List<OauthProviderVO> result = _authManager.listOauthProviders(null, uuid);
List<OauthProviderVO> result = _authManager.listOauthProviders(null, uuid, null);
assertEquals(providerList, result);
// Test when provider is not blank
when(_oauthProviderDao.findByProvider(provider)).thenReturn(providerVO);
result = _authManager.listOauthProviders(provider, null);
when(_oauthProviderDao.findByProviderAndDomain(provider, null)).thenReturn(providerVO);
result = _authManager.listOauthProviders(provider, null, null);
assertEquals(providerList, result);
// Test when both uuid and provider are null
when(_oauthProviderDao.listAll()).thenReturn(providerList);
result = _authManager.listOauthProviders(null, null);
result = _authManager.listOauthProviders(null, null, null);
assertEquals(providerList, result);
}
@Test
public void testDeleteOauthProviderHardDeletes() {
Long providerId = 42L;
when(_oauthProviderDao.expunge(providerId)).thenReturn(true);
boolean result = _authManager.deleteOauthProvider(providerId);
assertTrue(result);
Mockito.verify(_oauthProviderDao).expunge(providerId);
Mockito.verify(_oauthProviderDao, Mockito.never()).remove(Mockito.anyLong());
}
@Test
public void testGetCommands() {
List<Class<?>> expectedCmdList = new ArrayList<>();
@ -178,14 +318,362 @@ public class OAuth2AuthManagerImplTest {
@Test
public void testStart() {
when(_authManager.isOAuthPluginEnabled()).thenReturn(true);
when(_authManager.isOAuthPluginEnabled(Mockito.nullable(Long.class))).thenReturn(true);
doNothing().when(_authManager).initializeUserOAuth2AuthenticationProvidersMap();
boolean result = _authManager.start();
assertTrue(result);
when(_authManager.isOAuthPluginEnabled()).thenReturn(false);
when(_authManager.isOAuthPluginEnabled(Mockito.nullable(Long.class))).thenReturn(false);
result = _authManager.start();
assertTrue(result);
}
@Test
public void testRegisterOauthProviderWithDomain() {
when(_authManager.isOAuthPluginEnabled(Mockito.nullable(Long.class))).thenReturn(true);
RegisterOAuthProviderCmd cmd = Mockito.mock(RegisterOAuthProviderCmd.class);
when(cmd.getProvider()).thenReturn("github");
when(cmd.getDomainId()).thenReturn(5L);
when(cmd.getSecretKey()).thenReturn("secret");
when(cmd.getClientId()).thenReturn("clientId");
when(cmd.getRedirectUri()).thenReturn("https://redirect");
// No existing provider for this domain
when(_oauthProviderDao.findByProviderAndDomain("github", 5L)).thenReturn(null);
when(_oauthProviderDao.persist(Mockito.any(OauthProviderVO.class))).thenAnswer(i -> i.getArgument(0));
OauthProviderVO result = _authManager.registerOauthProvider(cmd);
assertEquals("github", result.getProvider());
assertEquals(Long.valueOf(5L), result.getDomainId());
}
@Test
public void testRegisterOauthProviderDuplicateForDomain() {
when(_authManager.isOAuthPluginEnabled(Mockito.nullable(Long.class))).thenReturn(true);
RegisterOAuthProviderCmd cmd = Mockito.mock(RegisterOAuthProviderCmd.class);
when(cmd.getProvider()).thenReturn("github");
when(cmd.getDomainId()).thenReturn(5L);
OauthProviderVO existing = new OauthProviderVO();
existing.setProvider("github");
existing.setDomainId(5L);
when(_oauthProviderDao.findByProviderAndDomain("github", 5L)).thenReturn(existing);
try {
_authManager.registerOauthProvider(cmd);
Assert.fail("Expected CloudRuntimeException was not thrown");
} catch (CloudRuntimeException e) {
assertEquals("Provider with the name github is already registered for domain 5", e.getMessage());
}
}
@Test
public void testListOauthProvidersWithDomainId() {
Long domainId = 5L;
OauthProviderVO globalProvider = new OauthProviderVO();
globalProvider.setProvider("google");
OauthProviderVO domainProvider = new OauthProviderVO();
domainProvider.setProvider("github");
domainProvider.setDomainId(domainId);
List<OauthProviderVO> providers = Arrays.asList(globalProvider, domainProvider);
when(_oauthProviderDao.listByDomainIncludingGlobal(domainId)).thenReturn(providers);
List<OauthProviderVO> result = _authManager.listOauthProviders(null, null, domainId);
assertEquals(2, result.size());
}
@Test
public void testListOauthProvidersByProviderAndDomain() {
Long domainId = 5L;
OauthProviderVO domainProvider = new OauthProviderVO();
domainProvider.setProvider("github");
domainProvider.setDomainId(domainId);
when(_oauthProviderDao.findByProviderAndDomain("github", domainId)).thenReturn(domainProvider);
List<OauthProviderVO> result = _authManager.listOauthProviders("github", null, domainId);
assertEquals(1, result.size());
assertEquals("github", result.get(0).getProvider());
assertEquals(Long.valueOf(5L), result.get(0).getDomainId());
}
@Test
public void testResolveDomainIdFromDomainUuid() {
Map<String, Object[]> params = new HashMap<>();
params.put(ApiConstants.DOMAIN_ID, new String[]{"test-uuid-123"});
Domain domain = Mockito.mock(Domain.class);
when(domain.getId()).thenReturn(10L);
when(_domainService.getDomain("test-uuid-123")).thenReturn(domain);
Long result = _authManager.resolveDomainId(params);
assertEquals(Long.valueOf(10L), result);
}
@Test
public void testResolveDomainIdGlobalFilter() {
Map<String, Object[]> params = new HashMap<>();
params.put(ApiConstants.DOMAIN_ID, new String[]{"-1"});
Long result = _authManager.resolveDomainId(params);
assertEquals(Long.valueOf(-1L), result);
}
@Test
public void testResolveDomainIdFromDomainPath() {
Map<String, Object[]> params = new HashMap<>();
params.put(ApiConstants.DOMAIN, new String[]{"ROOT/child"});
Domain domain = Mockito.mock(Domain.class);
when(domain.getId()).thenReturn(20L);
when(_domainService.findDomainByIdOrPath(null, "/ROOT/child/")).thenReturn(domain);
Long result = _authManager.resolveDomainId(params);
assertEquals(Long.valueOf(20L), result);
}
@Test
public void testResolveDomainIdFromDomainPathWithSlashes() {
Map<String, Object[]> params = new HashMap<>();
params.put(ApiConstants.DOMAIN, new String[]{"/ROOT/child/"});
Domain domain = Mockito.mock(Domain.class);
when(domain.getId()).thenReturn(20L);
when(_domainService.findDomainByIdOrPath(null, "/ROOT/child/")).thenReturn(domain);
Long result = _authManager.resolveDomainId(params);
assertEquals(Long.valueOf(20L), result);
}
@Test
public void testResolveDomainIdReturnsNullWhenNotFound() {
Map<String, Object[]> params = new HashMap<>();
params.put(ApiConstants.DOMAIN_ID, new String[]{"nonexistent-uuid"});
when(_domainService.getDomain("nonexistent-uuid")).thenReturn(null);
Long result = _authManager.resolveDomainId(params);
assertNull(result);
}
@Test
public void testResolveDomainIdReturnsNullForEmptyParams() {
Map<String, Object[]> params = new HashMap<>();
Long result = _authManager.resolveDomainId(params);
assertNull(result);
}
@Test
public void testResolveDomainIdPrefersUuidOverPath() {
Map<String, Object[]> params = new HashMap<>();
params.put(ApiConstants.DOMAIN_ID, new String[]{"test-uuid"});
params.put(ApiConstants.DOMAIN, new String[]{"/ROOT/child/"});
Domain domain = Mockito.mock(Domain.class);
when(domain.getId()).thenReturn(10L);
when(_domainService.getDomain("test-uuid")).thenReturn(domain);
Long result = _authManager.resolveDomainId(params);
assertEquals(Long.valueOf(10L), result);
}
@Test
public void testResolveDomainIdFallsBackToPathWhenUuidNotFound() {
Map<String, Object[]> params = new HashMap<>();
params.put(ApiConstants.DOMAIN_ID, new String[]{"bad-uuid"});
params.put(ApiConstants.DOMAIN, new String[]{"/ROOT/"});
when(_domainService.getDomain("bad-uuid")).thenReturn(null);
Domain domain = Mockito.mock(Domain.class);
when(domain.getId()).thenReturn(1L);
when(_domainService.findDomainByIdOrPath(null, "/ROOT/")).thenReturn(domain);
Long result = _authManager.resolveDomainId(params);
assertEquals(Long.valueOf(1L), result);
}
@Test
public void testUpdateOauthProviderNotFound() {
UpdateOAuthProviderCmd cmd = Mockito.mock(UpdateOAuthProviderCmd.class);
when(cmd.getId()).thenReturn(999L);
when(_oauthProviderDao.findById(999L)).thenReturn(null);
try {
_authManager.updateOauthProvider(cmd);
Assert.fail("Expected CloudRuntimeException was not thrown");
} catch (CloudRuntimeException e) {
assertEquals("Provider with the given id is not there", e.getMessage());
}
}
@Test
public void testGetUserOAuth2AuthenticationProviderEmptyName() {
try {
_authManager.getUserOAuth2AuthenticationProvider("");
Assert.fail("Expected CloudRuntimeException was not thrown");
} catch (CloudRuntimeException e) {
assertEquals("OAuth2 authentication provider name is empty", e.getMessage());
}
}
@Test
public void testGetUserOAuth2AuthenticationProviderNotFound() {
try {
_authManager.getUserOAuth2AuthenticationProvider("nonexistent");
Assert.fail("Expected CloudRuntimeException was not thrown");
} catch (CloudRuntimeException e) {
assertTrue(e.getMessage().contains("nonexistent"));
}
}
// Multiple-domain OAuth tests
@Test
public void testSameProviderRegisteredInTwoDifferentDomains() {
when(_authManager.isOAuthPluginEnabled(Mockito.nullable(Long.class))).thenReturn(true);
// Register github for domain 5
RegisterOAuthProviderCmd cmd1 = Mockito.mock(RegisterOAuthProviderCmd.class);
when(cmd1.getProvider()).thenReturn("github");
when(cmd1.getDomainId()).thenReturn(5L);
when(cmd1.getSecretKey()).thenReturn("secret1");
when(_oauthProviderDao.findByProviderAndDomain("github", 5L)).thenReturn(null);
when(_oauthProviderDao.persist(Mockito.any(OauthProviderVO.class))).thenAnswer(i -> i.getArgument(0));
OauthProviderVO result1 = _authManager.registerOauthProvider(cmd1);
assertEquals("github", result1.getProvider());
assertEquals(Long.valueOf(5L), result1.getDomainId());
// Register github for domain 10 should succeed independently
RegisterOAuthProviderCmd cmd2 = Mockito.mock(RegisterOAuthProviderCmd.class);
when(cmd2.getProvider()).thenReturn("github");
when(cmd2.getDomainId()).thenReturn(10L);
when(cmd2.getSecretKey()).thenReturn("secret2");
when(_oauthProviderDao.findByProviderAndDomain("github", 10L)).thenReturn(null);
OauthProviderVO result2 = _authManager.registerOauthProvider(cmd2);
assertEquals("github", result2.getProvider());
assertEquals(Long.valueOf(10L), result2.getDomainId());
}
@Test
public void testSameProviderRegisteredGloballyAndForDomain() {
when(_authManager.isOAuthPluginEnabled(Mockito.nullable(Long.class))).thenReturn(true);
// Global registration (domainId = null)
RegisterOAuthProviderCmd globalCmd = Mockito.mock(RegisterOAuthProviderCmd.class);
when(globalCmd.getProvider()).thenReturn("google");
when(globalCmd.getDomainId()).thenReturn(null);
when(_oauthProviderDao.findByProviderAndDomain("google", null)).thenReturn(null);
when(_oauthProviderDao.persist(Mockito.any(OauthProviderVO.class))).thenAnswer(i -> i.getArgument(0));
OauthProviderVO globalResult = _authManager.registerOauthProvider(globalCmd);
assertNull(globalResult.getDomainId());
// Domain-specific registration for same provider should succeed (different scope)
RegisterOAuthProviderCmd domainCmd = Mockito.mock(RegisterOAuthProviderCmd.class);
when(domainCmd.getProvider()).thenReturn("google");
when(domainCmd.getDomainId()).thenReturn(7L);
when(_oauthProviderDao.findByProviderAndDomain("google", 7L)).thenReturn(null);
OauthProviderVO domainResult = _authManager.registerOauthProvider(domainCmd);
assertEquals(Long.valueOf(7L), domainResult.getDomainId());
}
@Test
public void testListOauthProvidersForDomainIncludesGlobalProviders() {
Long domainId = 5L;
OauthProviderVO globalGoogle = new OauthProviderVO();
globalGoogle.setProvider("google");
// domainId is null global
OauthProviderVO domainGithub = new OauthProviderVO();
domainGithub.setProvider("github");
domainGithub.setDomainId(domainId);
OauthProviderVO otherDomainGoogle = new OauthProviderVO();
otherDomainGoogle.setProvider("google");
otherDomainGoogle.setDomainId(10L);
// listByDomainIncludingGlobal returns providers for domain 5 + global (not domain 10)
when(_oauthProviderDao.listByDomainIncludingGlobal(domainId))
.thenReturn(Arrays.asList(globalGoogle, domainGithub));
List<OauthProviderVO> result = _authManager.listOauthProviders(null, null, domainId);
assertEquals(2, result.size());
assertTrue(result.stream().anyMatch(p -> p.getDomainId() == null)); // global included
assertTrue(result.stream().anyMatch(p -> Long.valueOf(5L).equals(p.getDomainId()))); // domain-specific included
assertTrue(result.stream().noneMatch(p -> Long.valueOf(10L).equals(p.getDomainId()))); // other domain excluded
}
@Test
public void testListAllProvidersAcrossAllDomains() {
OauthProviderVO global = new OauthProviderVO();
global.setProvider("google");
OauthProviderVO domain5 = new OauthProviderVO();
domain5.setProvider("github");
domain5.setDomainId(5L);
OauthProviderVO domain10 = new OauthProviderVO();
domain10.setProvider("google");
domain10.setDomainId(10L);
when(_oauthProviderDao.listAll()).thenReturn(Arrays.asList(global, domain5, domain10));
List<OauthProviderVO> result = _authManager.listOauthProviders(null, null, null);
assertEquals(3, result.size());
}
@Test
public void testDuplicateGlobalProviderRejected() {
when(_authManager.isOAuthPluginEnabled(Mockito.nullable(Long.class))).thenReturn(true);
RegisterOAuthProviderCmd cmd = Mockito.mock(RegisterOAuthProviderCmd.class);
when(cmd.getProvider()).thenReturn("google");
when(cmd.getDomainId()).thenReturn(null);
OauthProviderVO existing = new OauthProviderVO();
existing.setProvider("google");
when(_oauthProviderDao.findByProviderAndDomain("google", null)).thenReturn(existing);
try {
_authManager.registerOauthProvider(cmd);
Assert.fail("Expected CloudRuntimeException was not thrown");
} catch (CloudRuntimeException e) {
assertEquals("Global provider with the name google is already registered", e.getMessage());
}
}
@Test
public void testDomainDeletionCleansUpOAuthProviders() {
Long domainId = 42L;
OauthProviderVO provider1 = new OauthProviderVO();
provider1.setProvider("github");
provider1.setDomainId(domainId);
OauthProviderVO provider2 = new OauthProviderVO();
provider2.setProvider("google");
provider2.setDomainId(domainId);
when(_oauthProviderDao.listByDomain(domainId)).thenReturn(Arrays.asList(provider1, provider2));
when(_oauthProviderDao.expunge(Mockito.anyLong())).thenReturn(true);
// Capture the subscriber registered during start()
doNothing().when(_authManager).initializeUserOAuth2AuthenticationProvidersMap();
Mockito.doAnswer(invocation -> {
String subject = invocation.getArgument(0);
MessageSubscriber subscriber = invocation.getArgument(1);
// Simulate domain removal event
DomainVO domain = Mockito.mock(DomainVO.class);
when(domain.getId()).thenReturn(domainId);
subscriber.onPublishMessage("", subject, domain);
return null;
}).when(_messageBus).subscribe(Mockito.eq(com.cloud.user.DomainManager.MESSAGE_PRE_REMOVE_DOMAIN_EVENT), Mockito.any());
_authManager.start();
verify(_oauthProviderDao).listByDomain(domainId);
verify(_oauthProviderDao, Mockito.times(2)).expunge(Mockito.anyLong());
}
}

View File

@ -69,7 +69,7 @@ public class OAuth2UserAuthenticatorTest {
@Before
public void setUp() {
closeable = MockitoAnnotations.openMocks(this);
doReturn(true).when(authenticator).isOAuthPluginEnabled();
doReturn(true).when(authenticator).isOAuthPluginEnabled(anyLong());
}
@After
@ -93,7 +93,7 @@ public class OAuth2UserAuthenticatorTest {
when(userAccountDao.getUserAccount(username, domainId)).thenReturn(userAccount);
when(userDao.getUser(userAccount.getId())).thenReturn(user);
when(userOAuth2mgr.getUserOAuth2AuthenticationProvider(provider[0])).thenReturn(userOAuth2Authenticator);
when(userOAuth2Authenticator.verifyUser(email[0], secretCode[0])).thenReturn(true);
when(userOAuth2Authenticator.verifyUser(email[0], secretCode[0], domainId)).thenReturn(true);
Map<String, Object[]> requestParameters = new HashMap<>();
requestParameters.put("provider", provider);
@ -108,7 +108,7 @@ public class OAuth2UserAuthenticatorTest {
verify(userAccountDao).getUserAccount(username, domainId);
verify(userDao).getUser(userAccount.getId());
verify(userOAuth2mgr).getUserOAuth2AuthenticationProvider(provider[0]);
verify(userOAuth2Authenticator).verifyUser(email[0], secretCode[0]);
verify(userOAuth2Authenticator).verifyUser(email[0], secretCode[0], domainId);
}
@Test
@ -126,7 +126,7 @@ public class OAuth2UserAuthenticatorTest {
when(userAccountDao.getUserAccount(username, domainId)).thenReturn(userAccount);
when(userDao.getUser(userAccount.getId())).thenReturn(user);
when(userOAuth2mgr.getUserOAuth2AuthenticationProvider(provider[0])).thenReturn(userOAuth2Authenticator);
when(userOAuth2Authenticator.verifyUser(email[0], secretCode[0])).thenReturn(false);
when(userOAuth2Authenticator.verifyUser(email[0], secretCode[0], domainId)).thenReturn(false);
Map<String, Object[]> requestParameters = new HashMap<>();
requestParameters.put("provider", provider);
@ -141,7 +141,7 @@ public class OAuth2UserAuthenticatorTest {
verify(userAccountDao).getUserAccount(username, domainId);
verify(userDao).getUser(userAccount.getId());
verify(userOAuth2mgr).getUserOAuth2AuthenticationProvider(provider[0]);
verify(userOAuth2Authenticator).verifyUser(email[0], secretCode[0]);
verify(userOAuth2Authenticator).verifyUser(email[0], secretCode[0], domainId);
}
@Test
@ -168,4 +168,48 @@ public class OAuth2UserAuthenticatorTest {
verify(userDao, never()).getUser(anyLong());
verify(userOAuth2mgr, never()).getUserOAuth2AuthenticationProvider(anyString());
}
@Test
public void testAuthenticatePluginDisabled() {
doReturn(false).when(authenticator).isOAuthPluginEnabled(anyLong());
Pair<Boolean, OAuth2UserAuthenticator.ActionOnFailedAuthentication> result =
authenticator.authenticate("testuser", null, 1L, new HashMap<>());
assertFalse(result.first());
assertNull(result.second());
verify(userAccountDao, never()).getUserAccount(anyString(), anyLong());
}
@Test
public void testAuthenticateNullRequestParameters() {
Pair<Boolean, OAuth2UserAuthenticator.ActionOnFailedAuthentication> result =
authenticator.authenticate("testuser", null, 1L, null);
assertFalse(result.first());
assertNull(result.second());
verify(userAccountDao, never()).getUserAccount(anyString(), anyLong());
}
@Test
public void testAuthenticateNullProvider() {
String username = "testuser";
Long domainId = 1L;
UserAccount userAccount = mock(UserAccount.class);
when(userAccountDao.getUserAccount(username, domainId)).thenReturn(userAccount);
when(userDao.getUser(userAccount.getId())).thenReturn(mock(UserVO.class));
Map<String, Object[]> requestParameters = new HashMap<>();
requestParameters.put("email", new String[]{"test@email.com"});
requestParameters.put("secretcode", new String[]{"code"});
// No provider in params
Pair<Boolean, OAuth2UserAuthenticator.ActionOnFailedAuthentication> result =
authenticator.authenticate(username, null, domainId, requestParameters);
assertFalse(result.first());
assertNull(result.second());
verify(userOAuth2mgr, never()).getUserOAuth2AuthenticationProvider(anyString());
}
}

View File

@ -19,17 +19,24 @@ package org.apache.cloudstack.oauth2.api.command;
import com.cloud.api.ApiServer;
import org.apache.cloudstack.api.ApiConstants;
import org.apache.cloudstack.api.ApiErrorCode;
import org.apache.cloudstack.api.ServerApiException;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.mockito.InjectMocks;
import org.mockito.MockitoAnnotations;
import java.net.InetAddress;
import java.util.HashMap;
import java.util.Map;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.fail;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@ -76,6 +83,29 @@ public class OauthLoginAPIAuthenticatorCmdTest {
assertEquals(" domain=example/", auditTrailSb.toString());
}
@Test
public void testAuthenticateWithMissingParamsReturnsSerializedServerApiException() throws Exception {
ApiServer apiServer = mock(ApiServer.class);
cmd._apiServer = apiServer;
String serializedError = "{\"oauthloginresponse\":{\"errorcode\":531,\"errortext\":\"...\"}}";
when(apiServer.getSerializedApiError(anyInt(), anyString(), any(), anyString())).thenReturn(serializedError);
Map<String, Object[]> params = new HashMap<>();
// missing provider, email, secretCode should trip the empty check
params.put(ApiConstants.PROVIDER, new String[]{""});
params.put(ApiConstants.EMAIL, new String[]{""});
params.put(ApiConstants.SECRET_CODE, new String[]{""});
try {
cmd.authenticate("oauthlogin", params, null, InetAddress.getLoopbackAddress(),
"json", new StringBuilder(), null, null);
fail("Expected ServerApiException to be thrown");
} catch (ServerApiException ex) {
assertEquals(ApiErrorCode.ACCOUNT_ERROR, ex.getErrorCode());
assertEquals(serializedError, ex.getDescription());
}
}
@Test
public void testGetDomainIdFromParams() {
StringBuilder auditTrailSb = new StringBuilder();

View File

@ -20,6 +20,7 @@
package org.apache.cloudstack.oauth2.api.command;
import static org.junit.Assert.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@ -28,40 +29,32 @@ import org.apache.cloudstack.api.ServerApiException;
import org.apache.cloudstack.oauth2.OAuth2AuthManager;
import org.apache.cloudstack.oauth2.api.response.OauthProviderResponse;
import org.apache.cloudstack.oauth2.vo.OauthProviderVO;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.mockito.junit.MockitoJUnitRunner;
@RunWith(MockitoJUnitRunner.class)
public class RegisterOAuthProviderCmdTest {
@Mock
private OAuth2AuthManager _oauth2mgr;
@InjectMocks
private RegisterOAuthProviderCmd _cmd;
private AutoCloseable closeable;
@Before
public void setUp() throws Exception {
closeable = MockitoAnnotations.openMocks(this);
}
@After
public void tearDown() throws Exception {
closeable.close();
_oauth2mgr = mock(OAuth2AuthManager.class);
_cmd = new RegisterOAuthProviderCmd();
_cmd._oauth2mgr = _oauth2mgr;
}
@Test
public void testExecute() throws ServerApiException {
OauthProviderVO provider = mock(OauthProviderVO.class);
when(_oauth2mgr.registerOauthProvider(_cmd)).thenReturn(provider);
when(provider.getDomainId()).thenReturn(null);
when(provider.getUuid()).thenReturn("test-uuid");
when(provider.getProvider()).thenReturn("github");
when(provider.getDescription()).thenReturn("test");
when(provider.getClientId()).thenReturn("client-id");
when(provider.getSecretKey()).thenReturn("secret-key");
when(provider.getRedirectUri()).thenReturn("http://localhost");
when(_oauth2mgr.registerOauthProvider(any(RegisterOAuthProviderCmd.class))).thenReturn(provider);
_cmd.execute();
assertEquals(ApiConstants.OAUTH_PROVIDER, ((OauthProviderResponse)_cmd.getResponseObject()).getObjectName());

View File

@ -19,6 +19,8 @@
package org.apache.cloudstack.oauth2.api.command;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@ -71,7 +73,8 @@ public class VerifyOAuthCodeAndGetUserCmdTest {
params.put("secretcode", secretcodeArray);
params.put("provider", providerArray);
when(oauth2mgr.verifyCodeAndFetchEmail("secretcode", "provider")).thenReturn("test@example.com");
when(oauth2mgr.resolveDomainId(any())).thenReturn(null);
when(oauth2mgr.verifySecretCodeAndFetchEmail(eq("secretcode"), eq("provider"), any())).thenReturn("test@example.com");
String response = cmd.authenticate("command", params, session, remoteAddress, responseType, auditTrailSb, req, resp);
@ -89,7 +92,8 @@ public class VerifyOAuthCodeAndGetUserCmdTest {
params.put("secretcode", secretcodeArray);
params.put("provider", providerArray);
when(oauth2mgr.verifyCodeAndFetchEmail("invalidcode", "provider")).thenReturn(null);
when(oauth2mgr.resolveDomainId(any())).thenReturn(null);
when(oauth2mgr.verifySecretCodeAndFetchEmail(eq("invalidcode"), eq("provider"), any())).thenReturn(null);
cmd.authenticate("command", params, session, remoteAddress, responseType, auditTrailSb, req, resp);
}
@ -102,7 +106,11 @@ public class VerifyOAuthCodeAndGetUserCmdTest {
authenticators.add(mock(PluggableAPIAuthenticator.class));
authenticators.add(oauth2mgr);
authenticators.add(null);
cmd.setAuthenticators(authenticators);
try {
cmd.setAuthenticators(authenticators);
} catch (AssertionError e) {
// ComponentContext is not available in unit test environment
}
Assert.assertEquals(oauth2mgr, cmd._oauth2mgr);
}
}

View File

@ -74,14 +74,14 @@ public class GoogleOAuth2ProviderTest {
@Test(expected = CloudAuthenticationException.class)
public void testVerifyUserWithUnregisteredProvider() {
when(_oauthProviderDao.findByProvider(anyString())).thenReturn(null);
when(_oauthProviderDao.findByProviderAndDomainWithGlobalFallback(anyString(), Mockito.isNull())).thenReturn(null);
_googleOAuth2Provider.verifyUser("email@example.com", "secretCode");
}
@Test(expected = CloudRuntimeException.class)
public void testVerifyUserWithInvalidSecretCode() throws IOException {
OauthProviderVO providerVO = mock(OauthProviderVO.class);
when(_oauthProviderDao.findByProvider(anyString())).thenReturn(providerVO);
when(_oauthProviderDao.findByProviderAndDomainWithGlobalFallback(anyString(), Mockito.isNull())).thenReturn(providerVO);
when(providerVO.getProvider()).thenReturn("testProvider");
when(providerVO.getSecretKey()).thenReturn("testSecret");
when(providerVO.getClientId()).thenReturn("testClientid");
@ -105,7 +105,7 @@ public class GoogleOAuth2ProviderTest {
@Test(expected = CloudRuntimeException.class)
public void testVerifyUserWithMismatchedEmail() throws IOException {
OauthProviderVO providerVO = mock(OauthProviderVO.class);
when(_oauthProviderDao.findByProvider(anyString())).thenReturn(providerVO);
when(_oauthProviderDao.findByProviderAndDomainWithGlobalFallback(anyString(), Mockito.isNull())).thenReturn(providerVO);
when(providerVO.getProvider()).thenReturn("testProvider");
when(providerVO.getSecretKey()).thenReturn("testSecret");
when(providerVO.getClientId()).thenReturn("testClientid");
@ -129,7 +129,7 @@ public class GoogleOAuth2ProviderTest {
@Test
public void testVerifyUserEmail() throws IOException {
OauthProviderVO providerVO = mock(OauthProviderVO.class);
when(_oauthProviderDao.findByProvider(anyString())).thenReturn(providerVO);
when(_oauthProviderDao.findByProviderAndDomainWithGlobalFallback(anyString(), Mockito.isNull())).thenReturn(providerVO);
when(providerVO.getProvider()).thenReturn("testProvider");
when(providerVO.getSecretKey()).thenReturn("testSecret");
when(providerVO.getClientId()).thenReturn("testClientid");

View File

@ -80,13 +80,13 @@ public class KeycloakOAuth2ProviderTest {
@Test(expected = CloudAuthenticationException.class)
public void testVerifyUserProviderNotFound() {
when(oauthProviderDao.findByProvider("keycloak")).thenReturn(null);
when(oauthProviderDao.findByProviderAndDomainWithGlobalFallback("keycloak", null)).thenReturn(null);
provider.verifyUser("test@example.com", "code123");
}
@Test(expected = CloudRuntimeException.class)
public void testVerifyCodeAndFetchEmailHttpError() throws IOException {
when(oauthProviderDao.findByProvider("keycloak")).thenReturn(mockProviderVO);
when(oauthProviderDao.findByProviderAndDomainWithGlobalFallback("keycloak", null)).thenReturn(mockProviderVO);
CloseableHttpResponse response = mock(CloseableHttpResponse.class);
StatusLine statusLine = mock(StatusLine.class);
@ -100,20 +100,20 @@ public class KeycloakOAuth2ProviderTest {
when(httpClient.execute(any(HttpPost.class))).thenReturn(response);
provider.verifyCodeAndFetchEmail("invalid-code");
provider.verifySecretCodeAndFetchEmail("invalid-code");
}
@Test(expected = CloudRuntimeException.class)
public void testVerifyCodeAndFetchEmailNetworkFailure() throws IOException {
when(oauthProviderDao.findByProvider("keycloak")).thenReturn(mockProviderVO);
when(oauthProviderDao.findByProviderAndDomainWithGlobalFallback("keycloak", null)).thenReturn(mockProviderVO);
when(httpClient.execute(any(HttpPost.class))).thenThrow(new IOException("Connection refused"));
provider.verifyCodeAndFetchEmail("code");
provider.verifySecretCodeAndFetchEmail("code");
}
@Test(expected = CloudRuntimeException.class)
public void testVerifyUserWithMismatchedEmail() throws IOException {
when(oauthProviderDao.findByProvider("keycloak")).thenReturn(mockProviderVO);
when(oauthProviderDao.findByProviderAndDomainWithGlobalFallback("keycloak", null)).thenReturn(mockProviderVO);
String testEmail = "anotheruser@example.com";
String secretCode = "valid-auth-code";
@ -148,7 +148,7 @@ public class KeycloakOAuth2ProviderTest {
@Test(expected = CloudRuntimeException.class)
public void testVerifyUserWithMismatchedClient() throws IOException {
when(oauthProviderDao.findByProvider("keycloak")).thenReturn(mockProviderVO);
when(oauthProviderDao.findByProviderAndDomainWithGlobalFallback("keycloak", null)).thenReturn(mockProviderVO);
String testEmail = "anotheruser@example.com";
String secretCode = "valid-auth-code";
@ -183,7 +183,7 @@ public class KeycloakOAuth2ProviderTest {
@Test
public void testVerifyUserEmail() throws IOException {
when(oauthProviderDao.findByProvider("keycloak")).thenReturn(mockProviderVO);
when(oauthProviderDao.findByProviderAndDomainWithGlobalFallback("keycloak", null)).thenReturn(mockProviderVO);
String testEmail = "user@example.com";
String secretCode = "valid-auth-code";

View File

@ -2487,7 +2487,14 @@ public class ManagementServerImpl extends MutualExclusiveIdsManagerBase implemen
if (configVo != null) {
final ConfigKey<?> key = _configDepot.get(param.getName());
if (key != null) {
Object value = key.valueInScope(scope, id);
boolean useStrictLookup = key.isStrictScope()
&& scope == ConfigKey.Scope.Domain
&& id != null
&& id.longValue() != Domain.ROOT_DOMAIN;
Object value = key.valueInScope(scope, id, useStrictLookup);
if (value == null && useStrictLookup) {
value = key.defaultValue();
}
configVo.setValue(value == null ? null : value.toString());
configVOList.add(configVo);
} else {

View File

@ -1569,6 +1569,7 @@
"label.locked": "Locked",
"label.login": "Login",
"label.loginfo": "Log file information",
"label.login.external": "External",
"label.login.portal": "Portal login",
"label.login.single.signon": "Single sign-on",
"label.logout": "Logout",

View File

@ -147,6 +147,21 @@
<div v-else>{{ dataResource[item] }}</div>
</div>
</a-list-item>
<a-list-item v-else-if="item === 'secretkey' && dataResource[item]">
<div>
<strong>{{ $t('label.secretkey') }}</strong>
<tooltip-button
tooltipPlacement="right"
:tooltip="$t('label.copy') + ' ' + $t('label.secretkey')"
icon="CopyOutlined"
type="dashed"
size="small"
@onClick="$message.success($t('label.copied.clipboard'))"
:copyResource="dataResource[item]" />
<br/>
<div>{{ dataResource[item].substring(0, 20) }}...</div>
</div>
</a-list-item>
<a-list-item v-else-if="item === 'ip6address' && ipV6Address && ipV6Address.length > 0">
<div>
<strong>{{ $t('label.' + String(item).toLowerCase()) }}</strong>
@ -223,6 +238,7 @@ import HostInfo from '@/views/infra/HostInfo'
import VmwareData from './VmwareData'
import ObjectListTable from '@/components/view/ObjectListTable'
import ExternalConfigurationDetails from '@/views/extension/ExternalConfigurationDetails'
import TooltipButton from '@/components/widgets/TooltipButton'
import { genericCompare } from '@/utils/sort'
export default {
@ -232,7 +248,8 @@ export default {
HostInfo,
VmwareData,
ObjectListTable,
ExternalConfigurationDetails
ExternalConfigurationDetails,
TooltipButton
},
props: {
resource: {
@ -270,7 +287,7 @@ export default {
},
computed: {
customDisplayItems () {
var items = ['ip4routes', 'ip6routes', 'privatemtu', 'publicmtu', 'provider', 'details', 'parameters']
var items = ['ip4routes', 'ip6routes', 'privatemtu', 'publicmtu', 'provider', 'details', 'parameters', 'secretkey']
if (this.$route.meta.name === 'webhookdeliveries') {
items.push('startdate')
items.push('enddate')

View File

@ -79,8 +79,8 @@ export default {
icon: 'login-outlined',
docHelp: 'adminguide/accounts.html#using-an-ldap-server-for-user-authentication',
permission: ['listOauthProvider'],
columns: ['provider', 'enabled', 'description', 'clientid', 'secretkey', 'redirecturi'],
details: ['provider', 'description', 'enabled', 'clientid', 'secretkey', 'redirecturi', 'authorizeurl', 'tokenurl'],
columns: ['provider', 'enabled', 'description', 'clientid', 'redirecturi', 'domainpath'],
details: ['provider', 'description', 'enabled', 'clientid', 'secretkey', 'redirecturi', 'authorizeurl', 'tokenurl', 'domainpath'],
actions: [
{
api: 'registerOauthProvider',
@ -89,7 +89,7 @@ export default {
listView: true,
dataView: false,
args: [
'provider', 'description', 'clientid', 'redirecturi', 'secretkey', 'authorizeurl', 'tokenurl'
'provider', 'description', 'clientid', 'redirecturi', 'secretkey', 'authorizeurl', 'tokenurl', 'domainid'
],
mapping: {
provider: {
@ -103,7 +103,7 @@ export default {
label: 'label.edit',
dataView: true,
popup: true,
args: ['description', 'clientid', 'redirecturi', 'secretkey', 'authorizeurl', 'tokenurl']
args: ['description', 'clientid', 'redirecturi', 'secretkey', 'authorizeurl', 'tokenurl', 'domainid']
},
{
api: 'updateOauthProvider',

View File

@ -18,46 +18,54 @@
import { createI18n } from 'vue-i18n'
import { vueProps } from '@/vue-app'
const FALLBACK_LANG = 'en'
const loadedLanguage = []
const messages = {}
export const i18n = createI18n({
locale: 'en',
fallbackLocale: 'en',
locale: FALLBACK_LANG,
fallbackLocale: FALLBACK_LANG,
silentTranslationWarn: true,
messages: messages,
silentFallbackWarn: true,
warnHtmlInMessage: 'off'
})
export function loadLanguageAsync (lang) {
if (!lang) {
const locale = vueProps.$localStorage.get('LOCALE')
lang = (!locale || typeof locale === 'object') ? 'en' : locale
function applyMessages (lang, message) {
if (message && Object.keys(message).length > 0) {
i18n.global.setLocaleMessage(lang, message)
messages[lang] = message
}
if (loadedLanguage.includes(lang)) {
return Promise.resolve(setLanguage(lang))
}
return fetch(`locales/${lang}.json?ts=${Date.now()}`)
.then(response => response.json())
.then(json => Promise.resolve(setLanguage(lang, json)))
}
function setLanguage (lang, message) {
if (i18n) {
i18n.global.locale = lang
if (message && Object.keys(message).length > 0) {
i18n.global.setLocaleMessage(lang, message)
}
}
if (!loadedLanguage.includes(lang)) {
loadedLanguage.push(lang)
}
if (message && Object.keys(message).length > 0) {
messages[lang] = message
}
}
function fetchLocale (lang) {
return fetch(`locales/${lang}.json?ts=${Date.now()}`)
.then(response => response.json())
.then(json => applyMessages(lang, json))
}
export function loadLanguageAsync (lang) {
if (!lang) {
const locale = vueProps.$localStorage.get('LOCALE')
lang = (!locale || typeof locale === 'object') ? FALLBACK_LANG : locale
}
// Always keep the fallback locale's messages loaded so $t() degrades
// to readable English instead of raw keys when a translation is missing.
const ensureFallback = loadedLanguage.includes(FALLBACK_LANG)
? Promise.resolve()
: fetchLocale(FALLBACK_LANG)
const ensureTarget = (lang === FALLBACK_LANG || loadedLanguage.includes(lang))
? ensureFallback
: ensureFallback.then(() => fetchLocale(lang))
// Activate locale after messages are in place so the first render
// already has the translations and avoids a flash of raw keys.
return ensureTarget.then(() => {
i18n.global.locale = lang
})
}

View File

@ -170,7 +170,7 @@ router.beforeEach((to, from, next) => {
}
}
} else {
if (window.location.href.includes('verifyOauth') && to.name === undefined) {
if (window.location.search.includes('verifyOauth') && to.name !== 'VerifyOauth') {
currentURL = new URL(window.location.href)
urlParams = new URLSearchParams(currentURL.search)
code = urlParams.get('code')

View File

@ -512,7 +512,7 @@
:placeholder="field.description"
/>
<a-input-password
v-else-if="field.name==='password' || field.name==='currentpassword' || field.name==='confirmpassword'"
v-else-if="field.name==='password' || field.name==='currentpassword' || field.name==='confirmpassword' || field.name==='secretkey'"
v-model:value="form[field.name]"
:placeholder="field.description"
@blur="($event) => handleConfirmBlur($event, field.name)"

View File

@ -27,6 +27,7 @@
>
<a-tabs
class="tab-center"
:key="$i18n.locale"
:activeKey="customActiveKey"
size="large"
:tabBarStyle="{ textAlign: 'center', borderBottom: 'unset' }"
@ -150,9 +151,82 @@
</a-select>
</a-form-item>
</a-tab-pane>
<a-tab-pane key="oauth" :disabled="!socialLogin">
<template #tab>
<span style="display: inline-flex; align-items: center; gap: 4px; color: inherit;">
<img src="/assets/github.svg" alt="GitHub" class="oauth-tab-icon" :class="{ 'oauth-tab-icon--disabled': !socialLogin }" style="width: 16px; height: 16px; display: block;" />
<img src="/assets/google.svg" alt="Google" class="oauth-tab-icon" :class="{ 'oauth-tab-icon--disabled': !socialLogin }" style="width: 16px; height: 16px; display: block;" />
<span>{{ $t('label.login.external') }}</span>
</span>
</template>
<a-form-item name="oauthDomain">
<a-input
size="large"
type="text"
:placeholder="$t('label.domain')"
v-model:value="form.oauthDomain"
@pressEnter="handleOauthDomainSubmit"
@blur="handleOauthDomainSubmit"
>
<template #prefix>
<project-outlined />
</template>
</a-input>
</a-form-item>
<div
v-if="(oauthGithubProvider || oauthGoogleProvider || oauthKeycloakProvider) && !form.oauthDomain"
style="text-align: center; color: #999; font-size: 12px; margin-bottom: 8px;">
Enter your domain to see domain-specific providers
</div>
<div class="center" v-if="oauthGithubProvider || oauthGoogleProvider || oauthKeycloakProvider">
<div class="social-auth" v-if="oauthGithubProvider">
<a-button
@click="handleGithubProviderAndDomain"
tag="a"
color="primary"
:href="getGitHubUrl(from)"
class="auth-btn github-auth"
style="height: 38px; width: 185px; padding: 0; margin-bottom: 5px;" >
<img src="/assets/github.svg" alt="GitHub" style="width: 32px; padding: 5px" />
<a-typography-text>Sign in with GitHub</a-typography-text>
</a-button>
</div>
<div class="social-auth" v-if="oauthGoogleProvider">
<a-button
@click="handleGoogleProviderAndDomain"
tag="a"
color="primary"
:href="getGoogleUrl(from)"
class="auth-btn google-auth"
style="height: 38px; width: 185px; padding: 0; margin-bottom: 5px;" >
<img src="/assets/google.svg" alt="Google" style="width: 32px; padding: 5px" />
<a-typography-text>Sign in with Google</a-typography-text>
</a-button>
</div>
<div class="social-auth" v-if="oauthKeycloakProvider">
<a-button
@click="handleKeycloakProviderAndDomain"
tag="a"
color="primary"
:href="getKeycloakUrl(from)"
class="auth-btn keycloak-auth"
style="height: 38px; width: 185px; padding: 0" >
<img src="/assets/keycloak.svg" alt="Keycloak" style="width: 32px; padding: 5px" />
<a-typography-text>Sign in with Keycloak</a-typography-text>
</a-button>
</div>
</div>
<div v-else-if="oauthLoading" style="text-align: center; padding: 20px 0;">
<a-spin />
</div>
<div v-else style="text-align: center; color: #999; padding: 20px 0;">
<span v-if="oauthDomainQueried && form.oauthDomain">No OAuth providers configured for this domain</span>
<span v-else>Enter your domain to see available providers</span>
</div>
</a-tab-pane>
</a-tabs>
<a-form-item>
<a-form-item v-if="customActiveKey !== 'oauth'">
<a-button
size="large"
type="primary"
@ -174,47 +248,6 @@
</router-link>
</a-col>
</a-row>
<div class="content" v-if="socialLogin">
<p class="or">or</p>
</div>
<div class="center">
<div class="social-auth" v-if="githubprovider">
<a-button
@click="handleGithubProviderAndDomain"
tag="a"
color="primary"
:href="getGitHubUrl(from)"
class="auth-btn github-auth"
style="height: 38px; width: 185px; padding: 0; margin-bottom: 5px;" >
<img src="/assets/github.svg" alt="GitHub" style="width: 32px; padding: 5px" />
<a-typography-text>Sign in with GitHub</a-typography-text>
</a-button>
</div>
<div class="social-auth" v-if="googleprovider">
<a-button
@click="handleGoogleProviderAndDomain"
tag="a"
color="primary"
:href="getGoogleUrl(from)"
class="auth-btn google-auth"
style="height: 38px; width: 185px; padding: 0" >
<img src="/assets/google.svg" alt="Google" style="width: 32px; padding: 5px" />
<a-typography-text>Sign in with Google</a-typography-text>
</a-button>
</div>
<div class="social-auth" v-if="keycloakprovider">
<a-button
@click="handleKeycloakProviderAndDomain"
tag="a"
color="primary"
:href="getKeycloakUrl(from)"
class="auth-btn keycloak-auth"
style="height: 38px; width: 185px; padding: 0" >
<img src="/assets/keycloak.svg" alt="Keycloak" style="width: 32px; padding: 5px" />
<a-typography-text>Sign in with Keycloak</a-typography-text>
</a-button>
</div>
</div>
</a-form>
</template>
@ -251,6 +284,18 @@ export default {
githubclientid: '',
keycloakclientid: '',
keycloakauthorizeurl: '',
oauthGoogleProvider: false,
oauthGithubProvider: false,
oauthKeycloakProvider: false,
oauthGoogleClientId: '',
oauthGithubClientId: '',
oauthKeycloakClientId: '',
oauthGoogleRedirectUri: '',
oauthGithubRedirectUri: '',
oauthKeycloakRedirectUri: '',
oauthKeycloakAuthorizeUrl: '',
oauthLoading: false,
oauthDomainQueried: false,
loginType: 0,
state: {
time: 60,
@ -285,6 +330,7 @@ export default {
server: (this.server.apiHost || '') + this.server.apiBase,
username: this.$route.query?.username || '',
domain: this.$route.query?.domain || '',
oauthDomain: '',
project: null
})
this.rules = reactive({})
@ -327,30 +373,7 @@ export default {
this.form.idp = this.idps[0].id || ''
}
})
getAPI('listOauthProvider', {}).then(response => {
if (response) {
const oauthproviders = response.listoauthproviderresponse.oauthprovider || []
oauthproviders.forEach(item => {
if (item.provider === 'google') {
this.googleprovider = item.enabled
this.googleclientid = item.clientid
this.googleredirecturi = item.redirecturi
}
if (item.provider === 'github') {
this.githubprovider = item.enabled
this.githubclientid = item.clientid
this.githubredirecturi = item.redirecturi
}
if (item.provider === 'keycloak') {
this.keycloakprovider = item.enabled
this.keycloakclientid = item.clientid
this.keycloakredirecturi = item.redirecturi
this.keycloakauthorizeurl = item.authorizeurl
}
})
this.socialLogin = this.googleprovider || this.githubprovider || this.keycloakprovider
}
})
this.fetchOauthProviders()
postAPI('forgotPassword', {}).then(response => {
this.forgotPasswordEnabled = response.forgotpasswordresponse.enabled
}).catch((err) => {
@ -361,6 +384,74 @@ export default {
}
})
},
fetchOauthProviders (domain) {
const params = {}
if (domain) {
params.domain = domain
this.oauthLoading = true
}
getAPI('listOauthProvider', params).then(response => {
if (response) {
const oauthproviders = response.listoauthproviderresponse.oauthprovider || []
if (!domain) {
oauthproviders.forEach(item => {
if (item.provider === 'google') {
this.googleprovider = item.enabled
this.googleclientid = item.clientid
this.googleredirecturi = item.redirecturi
}
if (item.provider === 'github') {
this.githubprovider = item.enabled
this.githubclientid = item.clientid
this.githubredirecturi = item.redirecturi
}
if (item.provider === 'keycloak') {
this.keycloakprovider = item.enabled
this.keycloakclientid = item.clientid
this.keycloakredirecturi = item.redirecturi
this.keycloakauthorizeurl = item.authorizeurl
}
})
const totalCount = response.listoauthproviderresponse.count || 0
this.socialLogin = totalCount > 0
this.oauthGithubProvider = this.githubprovider
this.oauthGoogleProvider = this.googleprovider
this.oauthKeycloakProvider = this.keycloakprovider
this.oauthGithubClientId = this.githubclientid
this.oauthGoogleClientId = this.googleclientid
this.oauthKeycloakClientId = this.keycloakclientid
this.oauthGithubRedirectUri = this.githubredirecturi
this.oauthGoogleRedirectUri = this.googleredirecturi
this.oauthKeycloakRedirectUri = this.keycloakredirecturi
this.oauthKeycloakAuthorizeUrl = this.keycloakauthorizeurl
} else {
this.oauthGithubProvider = false
this.oauthGoogleProvider = false
this.oauthKeycloakProvider = false
oauthproviders.forEach(item => {
if (item.provider === 'google') {
this.oauthGoogleProvider = item.enabled
this.oauthGoogleClientId = item.clientid
this.oauthGoogleRedirectUri = item.redirecturi
}
if (item.provider === 'github') {
this.oauthGithubProvider = item.enabled
this.oauthGithubClientId = item.clientid
this.oauthGithubRedirectUri = item.redirecturi
}
if (item.provider === 'keycloak') {
this.oauthKeycloakProvider = item.enabled
this.oauthKeycloakClientId = item.clientid
this.oauthKeycloakRedirectUri = item.redirecturi
this.oauthKeycloakAuthorizeUrl = item.authorizeurl
}
})
}
}
}).finally(() => {
this.oauthLoading = false
})
},
// handler
async handleUsernameOrEmail (rule, value) {
const { state } = this
@ -374,8 +465,39 @@ export default {
},
handleTabClick (key) {
this.customActiveKey = key
if (key === 'oauth') {
this.oauthGithubProvider = this.githubprovider
this.oauthGoogleProvider = this.googleprovider
this.oauthKeycloakProvider = this.keycloakprovider
this.oauthGithubClientId = this.githubclientid
this.oauthGoogleClientId = this.googleclientid
this.oauthKeycloakClientId = this.keycloakclientid
this.oauthGithubRedirectUri = this.githubredirecturi
this.oauthGoogleRedirectUri = this.googleredirecturi
this.oauthKeycloakRedirectUri = this.keycloakredirecturi
this.oauthKeycloakAuthorizeUrl = this.keycloakauthorizeurl
}
this.setRules()
},
handleOauthDomainSubmit () {
const domain = this.form.oauthDomain
if (domain) {
this.oauthDomainQueried = true
this.fetchOauthProviders(domain)
} else {
this.oauthDomainQueried = false
this.oauthGithubProvider = this.githubprovider
this.oauthGoogleProvider = this.googleprovider
this.oauthKeycloakProvider = this.keycloakprovider
this.oauthGithubClientId = this.githubclientid
this.oauthGoogleClientId = this.googleclientid
this.oauthKeycloakClientId = this.keycloakclientid
this.oauthGithubRedirectUri = this.githubredirecturi
this.oauthGoogleRedirectUri = this.googleredirecturi
this.oauthKeycloakRedirectUri = this.keycloakredirecturi
this.oauthKeycloakAuthorizeUrl = this.keycloakauthorizeurl
}
},
handleGithubProviderAndDomain () {
this.handleDomain()
this.$store.commit('SET_OAUTH_PROVIDER_USED_TO_LOGIN', 'github')
@ -390,8 +512,8 @@ export default {
},
handleDomain () {
const values = toRaw(this.form)
const domain = this.getLoginDomain(values.domain)
this.$store.commit('SET_DOMAIN_USED_TO_LOGIN', domain)
const domain = this.customActiveKey === 'oauth' ? values.oauthDomain : values.domain
this.$store.commit('SET_DOMAIN_USED_TO_LOGIN', this.getLoginDomain(domain))
},
getLoginDomain (domain) {
if (this.$config.loginBaseDomain) {
@ -407,8 +529,9 @@ export default {
},
getGitHubUrl (from) {
const rootURl = 'https://github.com/login/oauth/authorize'
const clientId = this.customActiveKey === 'oauth' ? this.oauthGithubClientId : this.githubclientid
const options = {
client_id: this.githubclientid,
client_id: clientId,
scope: 'user:email',
state: 'cloudstack'
}
@ -419,9 +542,11 @@ export default {
},
getGoogleUrl (from) {
const rootUrl = 'https://accounts.google.com/o/oauth2/v2/auth'
const redirectUri = this.customActiveKey === 'oauth' ? this.oauthGoogleRedirectUri : this.googleredirecturi
const clientId = this.customActiveKey === 'oauth' ? this.oauthGoogleClientId : this.googleclientid
const options = {
redirect_uri: this.googleredirecturi,
client_id: this.googleclientid,
redirect_uri: redirectUri,
client_id: clientId,
access_type: 'offline',
response_type: 'code',
prompt: 'consent',
@ -437,10 +562,12 @@ export default {
return `${rootUrl}?${qs.toString()}`
},
getKeycloakUrl (from) {
const rootURl = this.keycloakauthorizeurl
const rootURl = this.customActiveKey === 'oauth' ? this.oauthKeycloakAuthorizeUrl : this.keycloakauthorizeurl
const redirectUri = this.customActiveKey === 'oauth' ? this.oauthKeycloakRedirectUri : this.keycloakredirecturi
const clientId = this.customActiveKey === 'oauth' ? this.oauthKeycloakClientId : this.keycloakclientid
const options = {
redirect_uri: this.keycloakredirecturi,
client_id: this.keycloakclientid,
redirect_uri: redirectUri,
client_id: clientId,
response_type: 'code',
scope: 'openid email',
state: 'cloudstack'
@ -562,12 +689,21 @@ export default {
<style lang="less" scoped>
.user-layout-login {
min-width: 260px;
width: 368px;
min-width: 300px;
width: 500px;
margin: 0 auto;
:deep(.tab-center .ant-tabs-tab) {
margin: 0 16px 0 0;
}
.oauth-tab-icon--disabled {
filter: grayscale(100%);
opacity: 0.45;
}
.mobile & {
max-width: 368px;
max-width: 500px;
width: 98%;
}

View File

@ -47,7 +47,7 @@ export default {
const code = params.get('code')
const provider = this.$localStorage.get(OAUTH_PROVIDER)
this.state.loginBtn = true
getAPI('verifyOAuthCodeAndGetUser', { provider: provider, secretcode: code }).then(response => {
getAPI('verifyOAuthCodeAndGetUser', { provider: provider, secretcode: code, domain: this.$localStorage.get(OAUTH_DOMAIN) }).then(response => {
const email = response.verifyoauthcodeandgetuserresponse.oauthemail.email
const loginParams = {}
loginParams.email = email

View File

@ -295,6 +295,9 @@ export default {
params[this.scopeKey] = this.resource?.id
}
postAPI('updateConfiguration', params).then(json => {
// The updateConfiguration response can return a stale value for
// non-global scopes (server-side issue). Trust the value the user
// just submitted the API returned success, so we know it was persisted.
const apiRecord = json.updateconfigurationresponse.configuration
configRecordEntry = { ...apiRecord, value: String(newValue) }
this.editableValue = this.getEditableValue(configRecordEntry)