diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 91399595f80..fadf33f2043 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,6 +75,7 @@ jobs: smoke/test_list_ids_parameter smoke/test_loadbalance smoke/test_login + smoke/test_2fa smoke/test_metrics_api smoke/test_migration smoke/test_multipleips_per_nic diff --git a/api/src/main/java/com/cloud/exception/CloudTwoFactorAuthenticationException.java b/api/src/main/java/com/cloud/exception/CloudTwoFactorAuthenticationException.java new file mode 100644 index 00000000000..b77d60856dc --- /dev/null +++ b/api/src/main/java/com/cloud/exception/CloudTwoFactorAuthenticationException.java @@ -0,0 +1,32 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.exception; + +import com.cloud.utils.SerialVersionUID; +import com.cloud.utils.exception.CloudRuntimeException; + +public class CloudTwoFactorAuthenticationException extends CloudRuntimeException { + private static final long serialVersionUID = SerialVersionUID.CloudTwoFactorAuthenticationException; + + public CloudTwoFactorAuthenticationException(String message) { + super(message); + } + + public CloudTwoFactorAuthenticationException(String message, Throwable th) { + super(message, th); + } +} diff --git a/api/src/main/java/com/cloud/user/AccountService.java b/api/src/main/java/com/cloud/user/AccountService.java index 863801ec826..77a5b442e86 100644 --- a/api/src/main/java/com/cloud/user/AccountService.java +++ b/api/src/main/java/com/cloud/user/AccountService.java @@ -16,6 +16,7 @@ // under the License. package com.cloud.user; +import java.util.List; import java.util.Map; import org.apache.cloudstack.acl.ControlledEntity; @@ -33,6 +34,7 @@ import com.cloud.network.vpc.VpcOffering; import com.cloud.offering.DiskOffering; import com.cloud.offering.NetworkOffering; import com.cloud.offering.ServiceOffering; +import org.apache.cloudstack.auth.UserTwoFactorAuthenticator; public interface AccountService { @@ -124,4 +126,18 @@ public interface AccountService { public Map getKeys(GetUserKeysCmd cmd); public Map getKeys(Long userId); + + /** + * Lists user two-factor authentication provider plugins + * @return list of providers + */ + List listUserTwoFactorAuthenticationProviders(); + + /** + * Finds user two factor authenticator provider by domain ID + * @param domainId domain id + * @return backup provider + */ + UserTwoFactorAuthenticator getUserTwoFactorAuthenticationProvider(final Long domainId); + } diff --git a/api/src/main/java/com/cloud/user/User.java b/api/src/main/java/com/cloud/user/User.java index c3ac66c6979..c50ed3f28af 100644 --- a/api/src/main/java/com/cloud/user/User.java +++ b/api/src/main/java/com/cloud/user/User.java @@ -90,4 +90,8 @@ public interface User extends OwnedBy, InternalIdentity { public String getExternalEntity(); public void setExternalEntity(String entity); + + public boolean isUser2faEnabled(); + + public String getKeyFor2fa(); } diff --git a/api/src/main/java/com/cloud/user/UserAccount.java b/api/src/main/java/com/cloud/user/UserAccount.java index 0449514cc19..e6b07fb371e 100644 --- a/api/src/main/java/com/cloud/user/UserAccount.java +++ b/api/src/main/java/com/cloud/user/UserAccount.java @@ -17,6 +17,7 @@ package com.cloud.user; import java.util.Date; +import java.util.Map; import org.apache.cloudstack.api.InternalIdentity; @@ -67,4 +68,21 @@ public interface UserAccount extends InternalIdentity { public String getExternalEntity(); public void setExternalEntity(String entity); + + public boolean isUser2faEnabled(); + + public void setUser2faEnabled(boolean user2faEnabled); + + public String getKeyFor2fa(); + + public void setKeyFor2fa(String keyFor2fa); + + public String getUser2faProvider(); + + public void setUser2faProvider(String user2faProvider); + + public Map getDetails(); + + public void setDetails(Map details); + } diff --git a/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java b/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java index ec5799ceb91..2b77b9b0b0d 100644 --- a/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java +++ b/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java @@ -239,6 +239,10 @@ public class ApiConstants { public static final String IP_ADDRESSES = "ipaddresses"; public static final String IP6_ADDRESS = "ip6address"; public static final String IP_ADDRESS_ID = "ipaddressid"; + public static final String IS_2FA_ENABLED = "is2faenabled"; + public static final String IS_2FA_VERIFIED = "is2faverified"; + + public static final String IS_2FA_MANDATED = "is2famandated"; public static final String IS_ASYNC = "isasync"; public static final String IP_AVAILABLE = "ipavailable"; public static final String IP_LIMIT = "iplimit"; @@ -1003,6 +1007,11 @@ public class ApiConstants { public static final String ADMINS_ONLY = "adminsonly"; public static final String ANNOTATION_FILTER = "annotationfilter"; + public static final String CODE_FOR_2FA = "codefor2fa"; + public static final String PROVIDER_FOR_2FA = "providerfor2fa"; + public static final String ISSUER_FOR_2FA = "issuerfor2fa"; + public static final String MANDATE_2FA = "mandate2fa"; + public static final String SECRET_CODE = "secretcode"; public static final String LOGIN = "login"; public static final String LOGOUT = "logout"; public static final String LIST_IDPS = "listIdps"; @@ -1010,6 +1019,7 @@ public class ApiConstants { public static final String PUBLIC_MTU = "publicmtu"; public static final String PRIVATE_MTU = "privatemtu"; public static final String MTU = "mtu"; + public static final String LIST_APIS = "listApis"; /** * This enum specifies IO Drivers, each option controls specific policies on I/O. diff --git a/api/src/main/java/org/apache/cloudstack/api/ApiErrorCode.java b/api/src/main/java/org/apache/cloudstack/api/ApiErrorCode.java index 278405873e2..d4fdeddc9a9 100644 --- a/api/src/main/java/org/apache/cloudstack/api/ApiErrorCode.java +++ b/api/src/main/java/org/apache/cloudstack/api/ApiErrorCode.java @@ -23,6 +23,7 @@ package org.apache.cloudstack.api; public enum ApiErrorCode { UNAUTHORIZED(401), + UNAUTHORIZED2FA(511), METHOD_NOT_ALLOWED(405), MALFORMED_PARAMETER_ERROR(430), PARAM_ERROR(431), diff --git a/api/src/main/java/org/apache/cloudstack/api/auth/APIAuthenticationType.java b/api/src/main/java/org/apache/cloudstack/api/auth/APIAuthenticationType.java index fac969dbfcc..5ba9d182daa 100644 --- a/api/src/main/java/org/apache/cloudstack/api/auth/APIAuthenticationType.java +++ b/api/src/main/java/org/apache/cloudstack/api/auth/APIAuthenticationType.java @@ -17,5 +17,5 @@ package org.apache.cloudstack.api.auth; public enum APIAuthenticationType { - LOGIN_API, LOGOUT_API, READONLY_API + LOGIN_API, LOGOUT_API, READONLY_API, LOGIN_2FA_API } diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/config/ListCfgsByCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/config/ListCfgsByCmd.java index 457aeabe2f8..80abe5d3e8f 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/admin/config/ListCfgsByCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/config/ListCfgsByCmd.java @@ -42,9 +42,11 @@ import com.cloud.exception.InvalidParameterValueException; import com.cloud.utils.Pair; import com.cloud.utils.exception.CloudRuntimeException; -@APICommand(name = "listConfigurations", description = "Lists all configurations.", responseObject = ConfigurationResponse.class, +@APICommand(name = ListCfgsByCmd.APINAME, description = "Lists all configurations.", responseObject = ConfigurationResponse.class, requestHasSensitiveInfo = false, responseHasSensitiveInfo = false) public class ListCfgsByCmd extends BaseListCmd { + + public static final String APINAME = "listConfigurations"; public static final Logger s_logger = Logger.getLogger(ListCfgsByCmd.class.getName()); diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/user/UpdateUserCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/user/UpdateUserCmd.java index 5cfb5c4a649..cb9f6e189f0 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/admin/user/UpdateUserCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/user/UpdateUserCmd.java @@ -79,6 +79,10 @@ public class UpdateUserCmd extends BaseCmd { @Parameter(name = ApiConstants.USERNAME, type = CommandType.STRING, description = "Unique username") private String username; + @Parameter(name = ApiConstants.MANDATE_2FA, type = CommandType.BOOLEAN, description = "Provide true to mandate the user to use two factor authentication has to be enabled." + + "This parameter is only used to mandate 2FA, not to disable 2FA", since = "4.18.0.0") + private Boolean mandate2FA; + @Inject private RegionService _regionService; @@ -126,6 +130,10 @@ public class UpdateUserCmd extends BaseCmd { return username; } + public Boolean getMandate2FA() { + return mandate2FA; + } + ///////////////////////////////////////////////////// /////////////// API Implementation/////////////////// ///////////////////////////////////////////////////// diff --git a/api/src/main/java/org/apache/cloudstack/api/response/LoginCmdResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/LoginCmdResponse.java index d2d122efb66..84c79d32321 100644 --- a/api/src/main/java/org/apache/cloudstack/api/response/LoginCmdResponse.java +++ b/api/src/main/java/org/apache/cloudstack/api/response/LoginCmdResponse.java @@ -70,6 +70,22 @@ public class LoginCmdResponse extends AuthenticationCmdResponse { @Param(description = "Session key that can be passed in subsequent Query command calls", isSensitive = true) private String sessionKey; + @SerializedName(value = ApiConstants.IS_2FA_ENABLED) + @Param(description = "Is two factor authentication enabled", since = "4.18.0.0") + private String is2FAenabled; + + @SerializedName(value = ApiConstants.IS_2FA_VERIFIED) + @Param(description = "Is two factor authentication verified", since = "4.18.0.0") + private String is2FAverified; + + @SerializedName(value = ApiConstants.PROVIDER_FOR_2FA) + @Param(description = "Two factor authentication provider", since = "4.18.0.0") + private String providerFor2FA; + + @SerializedName(value = ApiConstants.ISSUER_FOR_2FA) + @Param(description = "Two factor authentication issuer", since = "4.18.0.0") + private String issuerFor2FA; + public String getUsername() { return username; } @@ -163,4 +179,36 @@ public class LoginCmdResponse extends AuthenticationCmdResponse { public void setSessionKey(String sessionKey) { this.sessionKey = sessionKey; } + + public String is2FAenabled() { + return is2FAenabled; + } + + public void set2FAenabled(String is2FAenabled) { + this.is2FAenabled = is2FAenabled; + } + + public String is2FAverfied() { + return is2FAverified; + } + + public void set2FAverfied(String is2FAverified) { + this.is2FAverified = is2FAverified; + } + + public String getProviderFor2FA() { + return providerFor2FA; + } + + public void setProviderFor2FA(String providerFor2FA) { + this.providerFor2FA = providerFor2FA; + } + + public String getIssuerFor2FA() { + return issuerFor2FA; + } + + public void setIssuerFor2FA(String issuerFor2FA) { + this.issuerFor2FA = issuerFor2FA; + } } diff --git a/api/src/main/java/org/apache/cloudstack/api/response/UserResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/UserResponse.java index 1c81027f0a8..1a17f3b8698 100644 --- a/api/src/main/java/org/apache/cloudstack/api/response/UserResponse.java +++ b/api/src/main/java/org/apache/cloudstack/api/response/UserResponse.java @@ -120,6 +120,14 @@ public class UserResponse extends BaseResponse implements SetResourceIconRespons @Param(description = "Base64 string representation of the resource icon", since = "4.16.0.0") ResourceIconResponse icon; + @SerializedName(ApiConstants.IS_2FA_ENABLED) + @Param(description = "true if user has two factor authentication enabled", since = "4.18.0.0") + private Boolean is2FAenabled; + + @SerializedName(ApiConstants.IS_2FA_MANDATED) + @Param(description = "true if user has two factor authentication is mandated", since = "4.18.0.0") + private Boolean is2FAmandated; + @Override public String getObjectId() { return this.getId(); @@ -285,4 +293,20 @@ public class UserResponse extends BaseResponse implements SetResourceIconRespons public void setResourceIconResponse(ResourceIconResponse icon) { this.icon = icon; } + + public Boolean is2FAenabled() { + return is2FAenabled; + } + + public void set2FAenabled(Boolean is2FAenabled) { + this.is2FAenabled = is2FAenabled; + } + + public Boolean getIs2FAmandated() { + return is2FAmandated; + } + + public void set2FAmandated(Boolean is2FAmandated) { + this.is2FAmandated = is2FAmandated; + } } diff --git a/api/src/main/java/org/apache/cloudstack/api/response/UserTwoFactorAuthenticationSetupResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/UserTwoFactorAuthenticationSetupResponse.java new file mode 100644 index 00000000000..35beefde403 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/response/UserTwoFactorAuthenticationSetupResponse.java @@ -0,0 +1,63 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.apache.cloudstack.api.response; + +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseResponse; + +import com.cloud.serializer.Param; +import com.google.gson.annotations.SerializedName; + +public class UserTwoFactorAuthenticationSetupResponse extends BaseResponse { + @SerializedName("id") + @Param(description = "the user ID") + private String id; + + @SerializedName("username") + @Param(description = "the user name") + private String username; + + @SerializedName("accountid") + @Param(description = "the account ID of the user") + private String accountId; + + @SerializedName(ApiConstants.SECRET_CODE) + @Param(description = "secret code that needs to be registered with authenticator") + private String secretCode; + + public void setId(String id) { + this.id = id; + } + + public void setUsername(String username) { + this.username = username; + } + + public void setAccountId(String accountId) { + this.accountId = accountId; + } + + public void setSecretCode(String secretCode) { + this.secretCode = secretCode; + } + + public String getSecretCode() { + return secretCode; + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/response/UserTwoFactorAuthenticatorProviderResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/UserTwoFactorAuthenticatorProviderResponse.java new file mode 100644 index 00000000000..4101dc375ed --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/response/UserTwoFactorAuthenticatorProviderResponse.java @@ -0,0 +1,52 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.api.response; + +import com.cloud.serializer.Param; +import com.google.gson.annotations.SerializedName; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseResponse; +import org.apache.cloudstack.api.EntityReference; +import org.apache.cloudstack.auth.UserTwoFactorAuthenticator; + +@EntityReference(UserTwoFactorAuthenticator.class) +public class UserTwoFactorAuthenticatorProviderResponse extends BaseResponse { + + @SerializedName(ApiConstants.NAME) + @Param(description = "the user two factor authenticator provider name") + private String name; + + @SerializedName(ApiConstants.DESCRIPTION) + @Param(description = "the description of the user two factor authenticator provider") + private String description; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } +} diff --git a/api/src/main/java/org/apache/cloudstack/auth/UserTwoFactorAuthenticator.java b/api/src/main/java/org/apache/cloudstack/auth/UserTwoFactorAuthenticator.java new file mode 100644 index 00000000000..d5ca9bc57e7 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/auth/UserTwoFactorAuthenticator.java @@ -0,0 +1,45 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.auth; + +import com.cloud.exception.CloudTwoFactorAuthenticationException; +import com.cloud.user.UserAccount; +import com.cloud.utils.component.Adapter; + +public interface UserTwoFactorAuthenticator extends Adapter { + + /** + * Returns the unique name of the provider + * @return returns provider name + */ + String getName(); + + /** + * Returns the description about the user 2FA provider plugin + * @return returns description + */ + String getDescription(); + + /** + * Verifies the 2FA code provided by user + * @return returns description + */ + void check2FA(String code, UserAccount userAccount) throws CloudTwoFactorAuthenticationException; + + String setup2FAKey(UserAccount userAccount); + +} diff --git a/client/pom.xml b/client/pom.xml index b416bbff7b6..7a70bbceb9d 100644 --- a/client/pom.xml +++ b/client/pom.xml @@ -181,6 +181,16 @@ cloud-plugin-user-authenticator-sha256salted ${project.version} + + org.apache.cloudstack + cloud-plugin-user-two-factor-authenticator-totp + ${project.version} + + + org.apache.cloudstack + cloud-plugin-user-two-factor-authenticator-staticpin + ${project.version} + org.apache.cloudstack cloud-plugin-metrics diff --git a/core/src/main/resources/META-INF/cloudstack/api/spring-core-lifecycle-api-context-inheritable.xml b/core/src/main/resources/META-INF/cloudstack/api/spring-core-lifecycle-api-context-inheritable.xml index 655b7fe6572..91a35f18a89 100644 --- a/core/src/main/resources/META-INF/cloudstack/api/spring-core-lifecycle-api-context-inheritable.xml +++ b/core/src/main/resources/META-INF/cloudstack/api/spring-core-lifecycle-api-context-inheritable.xml @@ -32,7 +32,13 @@ + value="org.apache.cloudstack.auth.UserAuthenticator" /> + + + + + @@ -64,7 +70,7 @@ - + diff --git a/core/src/main/resources/META-INF/cloudstack/core/spring-core-registry-core-context.xml b/core/src/main/resources/META-INF/cloudstack/core/spring-core-registry-core-context.xml index fa386bb1541..2522fefa2bd 100644 --- a/core/src/main/resources/META-INF/cloudstack/core/spring-core-registry-core-context.xml +++ b/core/src/main/resources/META-INF/cloudstack/core/spring-core-registry-core-context.xml @@ -36,6 +36,13 @@ + + + + + + diff --git a/engine/schema/src/main/java/com/cloud/user/UserAccountVO.java b/engine/schema/src/main/java/com/cloud/user/UserAccountVO.java index dfebb3c346c..c18ca53f7ab 100644 --- a/engine/schema/src/main/java/com/cloud/user/UserAccountVO.java +++ b/engine/schema/src/main/java/com/cloud/user/UserAccountVO.java @@ -17,6 +17,7 @@ package com.cloud.user; import java.util.Date; +import java.util.Map; import javax.persistence.Column; import javax.persistence.Entity; @@ -28,6 +29,7 @@ import javax.persistence.Id; import javax.persistence.PrimaryKeyJoinColumn; import javax.persistence.SecondaryTable; import javax.persistence.Table; +import javax.persistence.Transient; import org.apache.cloudstack.api.InternalIdentity; @@ -109,6 +111,21 @@ public class UserAccountVO implements UserAccount, InternalIdentity { @Column(name = "external_entity", length = 65535) private String externalEntity = null; + @Column(name = "is_user_2fa_enabled") + private boolean user2faEnabled = false; + + @Column(name = "user_2fa_provider") + private String user2faProvider; + + @Column(name = "key_for_2fa") + private String keyFor2fa; + + @Transient + Map details; + + public enum Setup2FAstatus { + ENABLED, VERIFIED + } public UserAccountVO() { } @@ -311,4 +328,44 @@ public class UserAccountVO implements UserAccount, InternalIdentity { public void setExternalEntity(String externalEntity) { this.externalEntity = externalEntity; } + + @Override + public boolean isUser2faEnabled() { + return user2faEnabled; + } + + @Override + public void setUser2faEnabled(boolean user2faEnabled) { + this.user2faEnabled = user2faEnabled; + } + + @Override + public String getKeyFor2fa() { + return keyFor2fa; + } + + @Override + public void setKeyFor2fa(String keyFor2fa) { + this.keyFor2fa = keyFor2fa; + } + + @Override + public String getUser2faProvider() { + return user2faProvider; + } + + @Override + public void setUser2faProvider(String user2faProvider) { + this.user2faProvider = user2faProvider; + } + + @Override + public Map getDetails() { + return details; + } + + @Override + public void setDetails(Map details) { + this.details = details; + } } diff --git a/engine/schema/src/main/java/com/cloud/user/UserVO.java b/engine/schema/src/main/java/com/cloud/user/UserVO.java index 94e61ff14a8..69970bf2d2c 100644 --- a/engine/schema/src/main/java/com/cloud/user/UserVO.java +++ b/engine/schema/src/main/java/com/cloud/user/UserVO.java @@ -106,6 +106,15 @@ public class UserVO implements User, Identity, InternalIdentity { @Column(name = "external_entity", length = 65535) private String externalEntity; + @Column(name = "is_user_2fa_enabled") + private boolean user2faEnabled; + + @Column(name = "user_2fa_provider") + private String user2faProvider; + + @Column(name = "key_for_2fa") + private String keyFor2fa; + public UserVO() { this.uuid = UUID.randomUUID().toString(); } @@ -316,4 +325,29 @@ public class UserVO implements User, Identity, InternalIdentity { public void setExternalEntity(String externalEntity) { this.externalEntity = externalEntity; } + + public boolean isUser2faEnabled() { + return user2faEnabled; + } + + public void setUser2faEnabled(boolean user2faEnabled) { + this.user2faEnabled = user2faEnabled; + } + + public String getKeyFor2fa() { + return keyFor2fa; + } + + public void setKeyFor2fa(String keyFor2fa) { + this.keyFor2fa = keyFor2fa; + } + + public String getUser2faProvider() { + return user2faProvider; + } + + public void setUser2faProvider(String user2faProvider) { + this.user2faProvider = user2faProvider; + } + } diff --git a/engine/schema/src/main/java/org/apache/cloudstack/resourcedetail/UserDetailVO.java b/engine/schema/src/main/java/org/apache/cloudstack/resourcedetail/UserDetailVO.java index 79115a2a1f3..b327e131148 100644 --- a/engine/schema/src/main/java/org/apache/cloudstack/resourcedetail/UserDetailVO.java +++ b/engine/schema/src/main/java/org/apache/cloudstack/resourcedetail/UserDetailVO.java @@ -45,6 +45,8 @@ public class UserDetailVO implements ResourceDetail { @Column(name = "display") private boolean display = true; + public static final String Setup2FADetail = "2FASetupStatus"; + public UserDetailVO() { } @@ -69,6 +71,10 @@ public class UserDetailVO implements ResourceDetail { return value; } + public void setValue(String value) { + this.value = value; + } + @Override public long getResourceId() { return resourceId; diff --git a/engine/schema/src/main/resources/META-INF/db/schema-41720to41800.sql b/engine/schema/src/main/resources/META-INF/db/schema-41720to41800.sql index ce3da1be014..a558d943146 100644 --- a/engine/schema/src/main/resources/META-INF/db/schema-41720to41800.sql +++ b/engine/schema/src/main/resources/META-INF/db/schema-41720to41800.sql @@ -1520,3 +1520,53 @@ INSERT INTO `cloud`.`role_permissions` (`uuid`, `role_id`, `rule`, `permission`) -- Increases the precision of the column `quota_used` from 15 to 20, keeping the scale of 8. ALTER TABLE `cloud_usage`.`quota_usage` MODIFY COLUMN quota_used decimal(20,8) unsigned NOT NULL; + +ALTER TABLE `cloud`.`user` ADD COLUMN `is_user_2fa_enabled` tinyint NOT NULL DEFAULT 0; +ALTER TABLE `cloud`.`user` ADD COLUMN `key_for_2fa` varchar(255) default NULL; +ALTER TABLE `cloud`.`user` ADD COLUMN `user_2fa_provider` varchar(255) default NULL; + +DROP VIEW IF EXISTS `cloud`.`user_view`; +CREATE VIEW `cloud`.`user_view` AS + select + user.id, + user.uuid, + user.username, + user.password, + user.firstname, + user.lastname, + user.email, + user.state, + user.api_key, + user.secret_key, + user.created, + user.removed, + user.timezone, + user.registration_token, + user.is_registered, + user.incorrect_login_attempts, + user.source, + user.default, + account.id account_id, + account.uuid account_uuid, + account.account_name account_name, + account.type account_type, + account.role_id account_role_id, + domain.id domain_id, + domain.uuid domain_uuid, + domain.name domain_name, + domain.path domain_path, + async_job.id job_id, + async_job.uuid job_uuid, + async_job.job_status job_status, + async_job.account_id job_account_id, + user.is_user_2fa_enabled is_user_2fa_enabled + from + `cloud`.`user` + inner join + `cloud`.`account` ON user.account_id = account.id + inner join + `cloud`.`domain` ON account.domain_id = domain.id + left join + `cloud`.`async_job` ON async_job.instance_id = user.id + and async_job.instance_type = 'User' + and async_job.job_status = 0; \ No newline at end of file diff --git a/plugins/network-elements/juniper-contrail/src/test/java/org/apache/cloudstack/network/contrail/management/IntegrationTestConfiguration.java b/plugins/network-elements/juniper-contrail/src/test/java/org/apache/cloudstack/network/contrail/management/IntegrationTestConfiguration.java index d80d812916c..61f83008389 100644 --- a/plugins/network-elements/juniper-contrail/src/test/java/org/apache/cloudstack/network/contrail/management/IntegrationTestConfiguration.java +++ b/plugins/network-elements/juniper-contrail/src/test/java/org/apache/cloudstack/network/contrail/management/IntegrationTestConfiguration.java @@ -45,6 +45,7 @@ import org.apache.cloudstack.affinity.dao.AffinityGroupDao; import org.apache.cloudstack.affinity.dao.AffinityGroupDaoImpl; import org.apache.cloudstack.affinity.dao.AffinityGroupDomainMapDaoImpl; import org.apache.cloudstack.affinity.dao.AffinityGroupVMMapDaoImpl; +import org.apache.cloudstack.auth.UserAuthenticator; import org.apache.cloudstack.context.CallContext; import org.apache.cloudstack.engine.datacenter.entity.api.db.dao.DcDetailsDaoImpl; import org.apache.cloudstack.engine.orchestration.NetworkOrchestrator; @@ -241,7 +242,6 @@ import com.cloud.server.ManagementServer; import com.cloud.server.ResourceMetaDataService; import com.cloud.server.StatsCollector; import com.cloud.server.TaggedResourceService; -import com.cloud.server.auth.UserAuthenticator; import com.cloud.service.dao.ServiceOfferingDaoImpl; import com.cloud.service.dao.ServiceOfferingDetailsDaoImpl; import com.cloud.storage.DataStoreProviderApiService; diff --git a/plugins/network-elements/juniper-contrail/src/test/java/org/apache/cloudstack/network/contrail/management/MockAccountManager.java b/plugins/network-elements/juniper-contrail/src/test/java/org/apache/cloudstack/network/contrail/management/MockAccountManager.java index f56689a4827..bf0b94a71ac 100644 --- a/plugins/network-elements/juniper-contrail/src/test/java/org/apache/cloudstack/network/contrail/management/MockAccountManager.java +++ b/plugins/network-elements/juniper-contrail/src/test/java/org/apache/cloudstack/network/contrail/management/MockAccountManager.java @@ -24,9 +24,12 @@ import java.net.InetAddress; import javax.inject.Inject; import javax.naming.ConfigurationException; +import com.cloud.api.auth.SetupUserTwoFactorAuthenticationCmd; import org.apache.cloudstack.api.command.admin.account.CreateAccountCmd; import org.apache.cloudstack.api.command.admin.user.GetUserKeysCmd; import org.apache.cloudstack.api.command.admin.user.MoveUserCmd; +import org.apache.cloudstack.api.response.UserTwoFactorAuthenticationSetupResponse; +import org.apache.cloudstack.auth.UserTwoFactorAuthenticator; import org.apache.cloudstack.framework.config.ConfigKey; import org.apache.log4j.Logger; @@ -326,6 +329,20 @@ public class MockAccountManager extends ManagerBase implements AccountManager { return false; } + @Override + public UserTwoFactorAuthenticator getUserTwoFactorAuthenticator(Long domainId, Long userAccountId) { + return null; + } + + @Override + public void verifyUsingTwoFactorAuthenticationCode(String code, Long domainId, Long userAccountId) { + } + + @Override + public UserTwoFactorAuthenticationSetupResponse setupUserTwoFactorAuthentication(SetupUserTwoFactorAuthenticationCmd cmd) { + return null; + } + @Override public boolean deleteUserAccount(long arg0) { // TODO Auto-generated method stub @@ -465,6 +482,16 @@ public class MockAccountManager extends ManagerBase implements AccountManager { return null; } + @Override + public List listUserTwoFactorAuthenticationProviders() { + return null; + } + + @Override + public UserTwoFactorAuthenticator getUserTwoFactorAuthenticationProvider(Long domainId) { + return null; + } + @Override public void checkAccess(User user, ControlledEntity entity) throws PermissionDeniedException { diff --git a/plugins/pom.xml b/plugins/pom.xml index 2ad3e5ba3c8..be9a62dac84 100755 --- a/plugins/pom.xml +++ b/plugins/pom.xml @@ -138,7 +138,11 @@ user-authenticators/plain-text user-authenticators/saml2 user-authenticators/sha256salted - + + user-two-factor-authenticators/totp + user-two-factor-authenticators/static-pin + + org.apache.cloudstack @@ -160,6 +164,16 @@ cloud-framework-config ${project.version} + + de.taimos + totp + 1.0 + + + com.google.zxing + javase + 3.2.1 + org.apache.cloudstack cloud-api diff --git a/plugins/user-authenticators/ldap/src/main/java/org/apache/cloudstack/ldap/LdapAuthenticator.java b/plugins/user-authenticators/ldap/src/main/java/org/apache/cloudstack/ldap/LdapAuthenticator.java index fc6c32f7213..41ef9573bb2 100644 --- a/plugins/user-authenticators/ldap/src/main/java/org/apache/cloudstack/ldap/LdapAuthenticator.java +++ b/plugins/user-authenticators/ldap/src/main/java/org/apache/cloudstack/ldap/LdapAuthenticator.java @@ -24,10 +24,10 @@ import java.util.UUID; import javax.inject.Inject; import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.auth.UserAuthenticator; import org.apache.commons.lang3.StringUtils; import org.apache.log4j.Logger; -import com.cloud.server.auth.UserAuthenticator; import com.cloud.user.Account; import com.cloud.user.AccountManager; import com.cloud.user.User; diff --git a/plugins/user-authenticators/ldap/src/test/groovy/org/apache/cloudstack/ldap/LdapAuthenticatorSpec.groovy b/plugins/user-authenticators/ldap/src/test/groovy/org/apache/cloudstack/ldap/LdapAuthenticatorSpec.groovy index 37a0aba8adb..8fa7f3ee2e8 100644 --- a/plugins/user-authenticators/ldap/src/test/groovy/org/apache/cloudstack/ldap/LdapAuthenticatorSpec.groovy +++ b/plugins/user-authenticators/ldap/src/test/groovy/org/apache/cloudstack/ldap/LdapAuthenticatorSpec.groovy @@ -16,7 +16,6 @@ // under the License. package groovy.org.apache.cloudstack.ldap -import com.cloud.server.auth.UserAuthenticator import com.cloud.user.Account import com.cloud.user.AccountManager import com.cloud.user.User @@ -24,6 +23,7 @@ import com.cloud.user.UserAccount import com.cloud.user.UserAccountVO import com.cloud.user.dao.UserAccountDao import com.cloud.utils.Pair +import org.apache.cloudstack.auth.UserAuthenticator import org.apache.cloudstack.ldap.LdapAuthenticator import org.apache.cloudstack.ldap.LdapManager import org.apache.cloudstack.ldap.LdapTrustMapVO diff --git a/plugins/user-authenticators/ldap/src/test/java/org/apache/cloudstack/ldap/LdapAuthenticatorTest.java b/plugins/user-authenticators/ldap/src/test/java/org/apache/cloudstack/ldap/LdapAuthenticatorTest.java index 6852d5e2f3a..ed68a9358e5 100644 --- a/plugins/user-authenticators/ldap/src/test/java/org/apache/cloudstack/ldap/LdapAuthenticatorTest.java +++ b/plugins/user-authenticators/ldap/src/test/java/org/apache/cloudstack/ldap/LdapAuthenticatorTest.java @@ -17,7 +17,6 @@ package org.apache.cloudstack.ldap; -import com.cloud.server.auth.UserAuthenticator; import com.cloud.user.AccountManager; import com.cloud.user.AccountVO; import com.cloud.user.Account; @@ -26,6 +25,7 @@ import com.cloud.user.UserAccount; import com.cloud.user.UserAccountVO; import com.cloud.user.dao.UserAccountDao; import com.cloud.utils.Pair; +import org.apache.cloudstack.auth.UserAuthenticator; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; diff --git a/plugins/user-authenticators/md5/src/main/java/com/cloud/server/auth/MD5UserAuthenticator.java b/plugins/user-authenticators/md5/src/main/java/org/apache/cloudstack/auth/MD5UserAuthenticator.java similarity index 88% rename from plugins/user-authenticators/md5/src/main/java/com/cloud/server/auth/MD5UserAuthenticator.java rename to plugins/user-authenticators/md5/src/main/java/org/apache/cloudstack/auth/MD5UserAuthenticator.java index 8398c6c3fc5..3f3898f6464 100644 --- a/plugins/user-authenticators/md5/src/main/java/com/cloud/server/auth/MD5UserAuthenticator.java +++ b/plugins/user-authenticators/md5/src/main/java/org/apache/cloudstack/auth/MD5UserAuthenticator.java @@ -13,7 +13,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package com.cloud.server.auth; +package org.apache.cloudstack.auth; import java.math.BigInteger; import java.security.MessageDigest; @@ -50,20 +50,20 @@ public class MD5UserAuthenticator extends AdapterBase implements UserAuthenticat if (StringUtils.isAnyEmpty(username, password)) { s_logger.debug("Username or Password cannot be empty"); - return new Pair(false, null); + return new Pair<>(false, null); } UserAccount user = _userAccountDao.getUserAccount(username, domainId); if (user == null) { s_logger.debug("Unable to find user with " + username + " in domain " + domainId); - return new Pair(false, null); + return new Pair<>(false, null); } if (!user.getPassword().equals(encode(password))) { s_logger.debug("Password does not match"); - return new Pair(false, ActionOnFailedAuthentication.INCREMENT_INCORRECT_LOGIN_ATTEMPT_COUNT); + return new Pair<>(false, ActionOnFailedAuthentication.INCREMENT_INCORRECT_LOGIN_ATTEMPT_COUNT); } - return new Pair(true, null); + return new Pair<>(true, null); } @Override diff --git a/plugins/user-authenticators/md5/src/main/resources/META-INF/cloudstack/md5/spring-md5-context.xml b/plugins/user-authenticators/md5/src/main/resources/META-INF/cloudstack/md5/spring-md5-context.xml index 782b1132718..132f1481bb6 100644 --- a/plugins/user-authenticators/md5/src/main/resources/META-INF/cloudstack/md5/spring-md5-context.xml +++ b/plugins/user-authenticators/md5/src/main/resources/META-INF/cloudstack/md5/spring-md5-context.xml @@ -27,7 +27,7 @@ http://www.springframework.org/schema/context/spring-context.xsd" > - + diff --git a/plugins/user-authenticators/md5/src/test/java/com/cloud/server/auth/MD5UserAuthenticatorTest.java b/plugins/user-authenticators/md5/src/test/java/org/apache/cloudstack/auth/MD5UserAuthenticatorTest.java similarity index 87% rename from plugins/user-authenticators/md5/src/test/java/com/cloud/server/auth/MD5UserAuthenticatorTest.java rename to plugins/user-authenticators/md5/src/test/java/org/apache/cloudstack/auth/MD5UserAuthenticatorTest.java index a0189e19720..78af8e532f0 100644 --- a/plugins/user-authenticators/md5/src/test/java/com/cloud/server/auth/MD5UserAuthenticatorTest.java +++ b/plugins/user-authenticators/md5/src/test/java/org/apache/cloudstack/auth/MD5UserAuthenticatorTest.java @@ -17,7 +17,7 @@ * under the License. */ -package com.cloud.server.auth; +package org.apache.cloudstack.auth; import java.lang.reflect.Field; @@ -28,7 +28,6 @@ import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.runners.MockitoJUnitRunner; -import com.cloud.server.auth.UserAuthenticator.ActionOnFailedAuthentication; import com.cloud.user.UserAccountVO; import com.cloud.user.dao.UserAccountDao; import com.cloud.utils.Pair; @@ -53,7 +52,7 @@ public class MD5UserAuthenticatorTest { UserAccountVO account = new UserAccountVO(); account.setPassword("5f4dcc3b5aa765d61d8327deb882cf99"); Mockito.when(dao.getUserAccount(Mockito.anyString(), Mockito.anyLong())).thenReturn(account); - Pair pair = authenticator.authenticate("admin", "password", 1l, null); + Pair pair = authenticator.authenticate("admin", "password", 1l, null); Assert.assertTrue(pair.first()); } @@ -66,7 +65,7 @@ public class MD5UserAuthenticatorTest { UserAccountVO account = new UserAccountVO(); account.setPassword("surprise"); Mockito.when(dao.getUserAccount(Mockito.anyString(), Mockito.anyLong())).thenReturn(account); - Pair pair = authenticator.authenticate("admin", "password", 1l, null); + Pair pair = authenticator.authenticate("admin", "password", 1l, null); Assert.assertFalse(pair.first()); } @@ -77,7 +76,7 @@ public class MD5UserAuthenticatorTest { daoField.setAccessible(true); daoField.set(authenticator, dao); Mockito.when(dao.getUserAccount(Mockito.anyString(), Mockito.anyLong())).thenReturn(null); - Pair pair = authenticator.authenticate("admin", "password", 1l, null); + Pair pair = authenticator.authenticate("admin", "password", 1l, null); Assert.assertFalse(pair.first()); } } diff --git a/plugins/user-authenticators/pbkdf2/src/main/java/org/apache/cloudstack/server/auth/PBKDF2UserAuthenticator.java b/plugins/user-authenticators/pbkdf2/src/main/java/org/apache/cloudstack/server/auth/PBKDF2UserAuthenticator.java index 733bde734fc..3c2521f3430 100644 --- a/plugins/user-authenticators/pbkdf2/src/main/java/org/apache/cloudstack/server/auth/PBKDF2UserAuthenticator.java +++ b/plugins/user-authenticators/pbkdf2/src/main/java/org/apache/cloudstack/server/auth/PBKDF2UserAuthenticator.java @@ -25,6 +25,7 @@ import java.util.Map; import javax.inject.Inject; +import org.apache.cloudstack.auth.UserAuthenticator; import org.apache.commons.lang3.StringUtils; import org.apache.log4j.Logger; import org.bouncycastle.crypto.PBEParametersGenerator; @@ -32,7 +33,6 @@ import org.bouncycastle.crypto.generators.PKCS5S2ParametersGenerator; import org.bouncycastle.crypto.params.KeyParameter; import org.bouncycastle.util.encoders.Base64; -import com.cloud.server.auth.UserAuthenticator; import com.cloud.user.UserAccount; import com.cloud.user.dao.UserAccountDao; import com.cloud.utils.ConstantTimeComparator; diff --git a/plugins/user-authenticators/pbkdf2/src/test/java/org/apache/cloudstack/server/auth/PBKD2UserAuthenticatorTest.java b/plugins/user-authenticators/pbkdf2/src/test/java/org/apache/cloudstack/server/auth/PBKD2UserAuthenticatorTest.java index f4014167cff..3440f26c905 100644 --- a/plugins/user-authenticators/pbkdf2/src/test/java/org/apache/cloudstack/server/auth/PBKD2UserAuthenticatorTest.java +++ b/plugins/user-authenticators/pbkdf2/src/test/java/org/apache/cloudstack/server/auth/PBKD2UserAuthenticatorTest.java @@ -15,10 +15,10 @@ package org.apache.cloudstack.server.auth; -import com.cloud.server.auth.UserAuthenticator; import com.cloud.user.UserAccountVO; import com.cloud.user.dao.UserAccountDao; import com.cloud.utils.Pair; +import org.apache.cloudstack.auth.UserAuthenticator; import org.junit.Assert; import org.junit.Test; import org.junit.runner.RunWith; diff --git a/plugins/user-authenticators/plain-text/src/main/java/com/cloud/server/auth/PlainTextUserAuthenticator.java b/plugins/user-authenticators/plain-text/src/main/java/org/apache/cloudstack/auth/PlainTextUserAuthenticator.java similarity index 84% rename from plugins/user-authenticators/plain-text/src/main/java/com/cloud/server/auth/PlainTextUserAuthenticator.java rename to plugins/user-authenticators/plain-text/src/main/java/org/apache/cloudstack/auth/PlainTextUserAuthenticator.java index 3740d702ca1..f38e88b76db 100644 --- a/plugins/user-authenticators/plain-text/src/main/java/com/cloud/server/auth/PlainTextUserAuthenticator.java +++ b/plugins/user-authenticators/plain-text/src/main/java/org/apache/cloudstack/auth/PlainTextUserAuthenticator.java @@ -13,7 +13,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package com.cloud.server.auth; +package org.apache.cloudstack.auth; import java.util.Map; @@ -41,20 +41,20 @@ public class PlainTextUserAuthenticator extends AdapterBase implements UserAuthe if (StringUtils.isAnyEmpty(username, password)) { s_logger.debug("Username or Password cannot be empty"); - return new Pair(false, null); + return new Pair<>(false, null); } UserAccount user = _userAccountDao.getUserAccount(username, domainId); if (user == null) { s_logger.debug("Unable to find user with " + username + " in domain " + domainId); - return new Pair(false, null); + return new Pair<>(false, null); } if (!user.getPassword().equals(password)) { s_logger.debug("Password does not match"); - return new Pair(false, ActionOnFailedAuthentication.INCREMENT_INCORRECT_LOGIN_ATTEMPT_COUNT); + return new Pair<>(false, ActionOnFailedAuthentication.INCREMENT_INCORRECT_LOGIN_ATTEMPT_COUNT); } - return new Pair(true, null); + return new Pair<>(true, null); } @Override diff --git a/plugins/user-authenticators/plain-text/src/main/resources/META-INF/cloudstack/plaintext/spring-plaintext-context.xml b/plugins/user-authenticators/plain-text/src/main/resources/META-INF/cloudstack/plaintext/spring-plaintext-context.xml index 674bfc72b04..fccff88c4cb 100644 --- a/plugins/user-authenticators/plain-text/src/main/resources/META-INF/cloudstack/plaintext/spring-plaintext-context.xml +++ b/plugins/user-authenticators/plain-text/src/main/resources/META-INF/cloudstack/plaintext/spring-plaintext-context.xml @@ -27,7 +27,7 @@ http://www.springframework.org/schema/context/spring-context.xsd" > - + diff --git a/plugins/user-authenticators/saml2/src/main/java/org/apache/cloudstack/saml/SAML2UserAuthenticator.java b/plugins/user-authenticators/saml2/src/main/java/org/apache/cloudstack/saml/SAML2UserAuthenticator.java index a1a8e7a0a73..0a33bc111d5 100644 --- a/plugins/user-authenticators/saml2/src/main/java/org/apache/cloudstack/saml/SAML2UserAuthenticator.java +++ b/plugins/user-authenticators/saml2/src/main/java/org/apache/cloudstack/saml/SAML2UserAuthenticator.java @@ -18,10 +18,10 @@ import java.util.Map; import javax.inject.Inject; +import org.apache.cloudstack.auth.UserAuthenticator; import org.apache.cxf.common.util.StringUtils; import org.apache.log4j.Logger; -import com.cloud.server.auth.UserAuthenticator; import com.cloud.user.User; import com.cloud.user.UserAccount; import com.cloud.user.dao.UserAccountDao; diff --git a/plugins/user-authenticators/saml2/src/main/java/org/apache/cloudstack/saml/SAMLUtils.java b/plugins/user-authenticators/saml2/src/main/java/org/apache/cloudstack/saml/SAMLUtils.java index b5934a8ba30..f10bc891368 100644 --- a/plugins/user-authenticators/saml2/src/main/java/org/apache/cloudstack/saml/SAMLUtils.java +++ b/plugins/user-authenticators/saml2/src/main/java/org/apache/cloudstack/saml/SAMLUtils.java @@ -60,6 +60,7 @@ import org.apache.cloudstack.api.ApiConstants; import org.apache.cloudstack.api.response.LoginCmdResponse; import org.apache.cloudstack.utils.security.CertUtils; import org.apache.cloudstack.utils.security.ParserUtils; +import org.apache.commons.lang3.StringUtils; import org.apache.log4j.Logger; import org.bouncycastle.operator.OperatorCreationException; import org.joda.time.DateTime; @@ -284,6 +285,12 @@ public class SAMLUtils { resp.addCookie(new Cookie("role", URLEncoder.encode(loginResponse.getType(), HttpUtils.UTF_8))); resp.addCookie(new Cookie("username", URLEncoder.encode(loginResponse.getUsername(), HttpUtils.UTF_8))); resp.addCookie(new Cookie("account", URLEncoder.encode(loginResponse.getAccount(), HttpUtils.UTF_8))); + resp.addCookie(new Cookie("isSAML", URLEncoder.encode("true", HttpUtils.UTF_8))); + resp.addCookie(new Cookie("twoFaEnabled", URLEncoder.encode(loginResponse.is2FAenabled(), HttpUtils.UTF_8))); + String providerFor2FA = loginResponse.getProviderFor2FA(); + if (StringUtils.isNotEmpty(providerFor2FA)) { + resp.addCookie(new Cookie("twoFaProvider", URLEncoder.encode(loginResponse.getProviderFor2FA(), HttpUtils.UTF_8))); + } String timezone = loginResponse.getTimeZone(); if (timezone != null) { resp.addCookie(new Cookie("timezone", URLEncoder.encode(timezone, HttpUtils.UTF_8))); diff --git a/plugins/user-authenticators/saml2/src/test/java/org/apache/cloudstack/SAML2UserAuthenticatorTest.java b/plugins/user-authenticators/saml2/src/test/java/org/apache/cloudstack/SAML2UserAuthenticatorTest.java index c0f61d729df..5acc9ad28ce 100644 --- a/plugins/user-authenticators/saml2/src/test/java/org/apache/cloudstack/SAML2UserAuthenticatorTest.java +++ b/plugins/user-authenticators/saml2/src/test/java/org/apache/cloudstack/SAML2UserAuthenticatorTest.java @@ -23,6 +23,7 @@ import java.lang.reflect.Field; import java.util.HashMap; import java.util.Map; +import org.apache.cloudstack.auth.UserAuthenticator.ActionOnFailedAuthentication; import org.apache.cloudstack.saml.SAML2UserAuthenticator; import org.apache.cloudstack.saml.SAMLPluginConstants; import org.junit.Assert; @@ -32,7 +33,6 @@ import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.junit.MockitoJUnitRunner; -import com.cloud.server.auth.UserAuthenticator.ActionOnFailedAuthentication; import com.cloud.user.UserAccountVO; import com.cloud.user.UserVO; import com.cloud.user.dao.UserAccountDao; diff --git a/plugins/user-authenticators/saml2/src/test/java/org/apache/cloudstack/api/command/ListAndSwitchSAMLAccountCmdTest.java b/plugins/user-authenticators/saml2/src/test/java/org/apache/cloudstack/api/command/ListAndSwitchSAMLAccountCmdTest.java index 7b91e119dbb..b4d230e3cd6 100644 --- a/plugins/user-authenticators/saml2/src/test/java/org/apache/cloudstack/api/command/ListAndSwitchSAMLAccountCmdTest.java +++ b/plugins/user-authenticators/saml2/src/test/java/org/apache/cloudstack/api/command/ListAndSwitchSAMLAccountCmdTest.java @@ -187,6 +187,7 @@ public class ListAndSwitchSAMLAccountCmdTest extends TestCase { loginCmdResponse.setFirstName("firstName"); loginCmdResponse.setLastName("lastName"); loginCmdResponse.setSessionKey("newSessionKeyString"); + loginCmdResponse.set2FAenabled("false"); Mockito.when(apiServer.loginUser(nullable(HttpSession.class), nullable(String.class), nullable(String.class), nullable(Long.class), nullable(String.class), nullable(InetAddress.class), nullable(Map.class))).thenReturn(loginCmdResponse); Mockito.doNothing().when(resp).sendRedirect(nullable(String.class)); diff --git a/plugins/user-authenticators/sha256salted/src/main/java/com/cloud/server/auth/SHA256SaltedUserAuthenticator.java b/plugins/user-authenticators/sha256salted/src/main/java/org/apache/cloudstack/auth/SHA256SaltedUserAuthenticator.java similarity index 87% rename from plugins/user-authenticators/sha256salted/src/main/java/com/cloud/server/auth/SHA256SaltedUserAuthenticator.java rename to plugins/user-authenticators/sha256salted/src/main/java/org/apache/cloudstack/auth/SHA256SaltedUserAuthenticator.java index 0b87bd445fd..c6bdbe672fa 100644 --- a/plugins/user-authenticators/sha256salted/src/main/java/com/cloud/server/auth/SHA256SaltedUserAuthenticator.java +++ b/plugins/user-authenticators/sha256salted/src/main/java/org/apache/cloudstack/auth/SHA256SaltedUserAuthenticator.java @@ -14,7 +14,7 @@ // KIND, either express or implied. See the License for the // specific language governing permissions and limitations // under the License. -package com.cloud.server.auth; +package org.apache.cloudstack.auth; import java.io.UnsupportedEncodingException; import java.security.MessageDigest; @@ -53,7 +53,7 @@ public class SHA256SaltedUserAuthenticator extends AdapterBase implements UserAu if (StringUtils.isAnyEmpty(username, password)) { s_logger.debug("Username or Password cannot be empty"); - return new Pair(false, null); + return new Pair<>(false, null); } boolean realUser = true; @@ -63,10 +63,10 @@ public class SHA256SaltedUserAuthenticator extends AdapterBase implements UserAu realUser = false; } /* Fake Data */ - String realPassword = new String(s_defaultPassword); - byte[] salt = new String(s_defaultSalt).getBytes(); + String realPassword = s_defaultPassword; + byte[] salt = s_defaultSalt.getBytes(); if (realUser) { - String storedPassword[] = user.getPassword().split(":"); + String[] storedPassword = user.getPassword().split(":"); if (storedPassword.length != 2) { s_logger.warn("The stored password for " + username + " isn't in the right format for this authenticator"); realUser = false; @@ -83,10 +83,8 @@ public class SHA256SaltedUserAuthenticator extends AdapterBase implements UserAu if (!result && realUser) { action = ActionOnFailedAuthentication.INCREMENT_INCORRECT_LOGIN_ATTEMPT_COUNT; } - return new Pair(result, action); - } catch (NoSuchAlgorithmException e) { - throw new CloudRuntimeException("Unable to hash password", e); - } catch (UnsupportedEncodingException e) { + return new Pair<>(result, action); + } catch (NoSuchAlgorithmException | UnsupportedEncodingException e) { throw new CloudRuntimeException("Unable to hash password", e); } } @@ -101,7 +99,7 @@ public class SHA256SaltedUserAuthenticator extends AdapterBase implements UserAu try { randomGen = SecureRandom.getInstance("SHA1PRNG"); - byte salt[] = new byte[s_saltlen]; + byte[] salt = new byte[s_saltlen]; randomGen.nextBytes(salt); String saltString = new String(Base64.encode(salt)); @@ -109,9 +107,7 @@ public class SHA256SaltedUserAuthenticator extends AdapterBase implements UserAu // 3. concatenate the two and return return saltString + ":" + hashString; - } catch (NoSuchAlgorithmException e) { - throw new CloudRuntimeException("Unable to hash password", e); - } catch (UnsupportedEncodingException e) { + } catch (NoSuchAlgorithmException | UnsupportedEncodingException e) { throw new CloudRuntimeException("Unable to hash password", e); } } diff --git a/plugins/user-authenticators/sha256salted/src/main/resources/META-INF/cloudstack/sha256salted/spring-sha256salted-context.xml b/plugins/user-authenticators/sha256salted/src/main/resources/META-INF/cloudstack/sha256salted/spring-sha256salted-context.xml index 53b1a40f0d3..3e29fd9ddba 100644 --- a/plugins/user-authenticators/sha256salted/src/main/resources/META-INF/cloudstack/sha256salted/spring-sha256salted-context.xml +++ b/plugins/user-authenticators/sha256salted/src/main/resources/META-INF/cloudstack/sha256salted/spring-sha256salted-context.xml @@ -27,7 +27,7 @@ http://www.springframework.org/schema/context/spring-context.xsd" > - + diff --git a/plugins/user-authenticators/sha256salted/src/test/java/com/cloud/server/auth/test/AuthenticatorTest.java b/plugins/user-authenticators/sha256salted/src/test/java/org/apache/cloudstack/auth/test/AuthenticatorTest.java similarity index 96% rename from plugins/user-authenticators/sha256salted/src/test/java/com/cloud/server/auth/test/AuthenticatorTest.java rename to plugins/user-authenticators/sha256salted/src/test/java/org/apache/cloudstack/auth/test/AuthenticatorTest.java index f770b74cd9e..7a3af9d19d4 100644 --- a/plugins/user-authenticators/sha256salted/src/test/java/com/cloud/server/auth/test/AuthenticatorTest.java +++ b/plugins/user-authenticators/sha256salted/src/test/java/org/apache/cloudstack/auth/test/AuthenticatorTest.java @@ -15,7 +15,7 @@ // specific language governing permissions and limitations // under the License. -package com.cloud.server.auth.test; +package org.apache.cloudstack.auth.test; import static org.junit.Assert.assertEquals; import static org.junit.Assert.fail; @@ -29,6 +29,7 @@ import java.util.Map; import javax.naming.ConfigurationException; +import org.apache.cloudstack.auth.SHA256SaltedUserAuthenticator; import org.bouncycastle.util.encoders.Base64; import org.junit.Before; import org.junit.Test; @@ -37,7 +38,6 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.runners.MockitoJUnitRunner; -import com.cloud.server.auth.SHA256SaltedUserAuthenticator; import com.cloud.user.UserAccount; import com.cloud.user.dao.UserAccountDao; @@ -80,7 +80,7 @@ public class AuthenticatorTest { String encodedPassword = authenticator.encode("password"); String storedPassword[] = encodedPassword.split(":"); - assertEquals("hash must consist of two components", storedPassword.length, 2); + assertEquals("hash must consist of two components", 2, storedPassword.length); byte salt[] = Base64.decode(storedPassword[0]); String hashedPassword = authenticator.encode("password", salt); diff --git a/plugins/user-two-factor-authenticators/static-pin/pom.xml b/plugins/user-two-factor-authenticators/static-pin/pom.xml new file mode 100644 index 00000000000..1d0118a5851 --- /dev/null +++ b/plugins/user-two-factor-authenticators/static-pin/pom.xml @@ -0,0 +1,30 @@ + + + 4.0.0 + cloud-plugin-user-two-factor-authenticator-staticpin + Apache CloudStack Plugin - User Two Factor Authenticator Static Pin + + org.apache.cloudstack + cloudstack-plugins + 4.18.0.0-SNAPSHOT + ../../pom.xml + + \ No newline at end of file diff --git a/plugins/user-two-factor-authenticators/static-pin/src/main/java/org/apache/cloudstack/auth/StaticPinUserTwoFactorAuthenticator.java b/plugins/user-two-factor-authenticators/static-pin/src/main/java/org/apache/cloudstack/auth/StaticPinUserTwoFactorAuthenticator.java new file mode 100644 index 00000000000..dd1b1580c35 --- /dev/null +++ b/plugins/user-two-factor-authenticators/static-pin/src/main/java/org/apache/cloudstack/auth/StaticPinUserTwoFactorAuthenticator.java @@ -0,0 +1,75 @@ +// Licensed to the Apache Software Foundation (ASF) under one or more +// contributor license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright ownership. +// The ASF licenses this file to You under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package org.apache.cloudstack.auth; + +import javax.inject.Inject; + +import com.cloud.exception.CloudTwoFactorAuthenticationException; +import com.cloud.user.UserAccount; +import com.cloud.utils.exception.CloudRuntimeException; +import org.apache.commons.lang3.StringUtils; +import org.apache.log4j.Logger; + +import com.cloud.user.dao.UserAccountDao; +import com.cloud.utils.component.AdapterBase; + +import java.security.SecureRandom; + +public class StaticPinUserTwoFactorAuthenticator extends AdapterBase implements UserTwoFactorAuthenticator { + public static final Logger s_logger = Logger.getLogger(StaticPinUserTwoFactorAuthenticator.class); + + @Inject + private UserAccountDao _userAccountDao; + + @Override + public String getName() { + return "staticpin"; + } + + @Override + public String getDescription() { + return "Static Pin user two factor authentication provider Plugin"; + } + + @Override + public void check2FA(String code, UserAccount userAccount) throws CloudTwoFactorAuthenticationException { + String expectedCode = getStaticPin(userAccount); + if (expectedCode.equals(code)) { + s_logger.info("2FA matches user's input"); + return; + } + throw new CloudTwoFactorAuthenticationException("two-factor authentication code provided is invalid"); + } + + private String getStaticPin(UserAccount userAccount) { + return userAccount.getKeyFor2fa(); + } + + @Override + public String setup2FAKey(UserAccount userAccount) { + if (StringUtils.isNotEmpty(userAccount.getKeyFor2fa())) { + throw new CloudRuntimeException(String.format("2FA key is already setup for the user account %s", userAccount.getAccountName())); + } + long timeSeed = System.currentTimeMillis(); + SecureRandom rng = new SecureRandom(); + rng.setSeed(timeSeed); + int number = rng.nextInt(999999); + String key = String.format("%06d", number); + userAccount.setKeyFor2fa(key); + + return key; + } +} diff --git a/plugins/user-two-factor-authenticators/static-pin/src/main/resources/META-INF/cloudstack/staticpin/module.properties b/plugins/user-two-factor-authenticators/static-pin/src/main/resources/META-INF/cloudstack/staticpin/module.properties new file mode 100644 index 00000000000..14deddd5bfc --- /dev/null +++ b/plugins/user-two-factor-authenticators/static-pin/src/main/resources/META-INF/cloudstack/staticpin/module.properties @@ -0,0 +1,18 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +name=staticpin +parent=api \ No newline at end of file diff --git a/plugins/user-two-factor-authenticators/static-pin/src/main/resources/META-INF/cloudstack/staticpin/spring-staticpin-context.xml b/plugins/user-two-factor-authenticators/static-pin/src/main/resources/META-INF/cloudstack/staticpin/spring-staticpin-context.xml new file mode 100644 index 00000000000..ac27ba527db --- /dev/null +++ b/plugins/user-two-factor-authenticators/static-pin/src/main/resources/META-INF/cloudstack/staticpin/spring-staticpin-context.xml @@ -0,0 +1,35 @@ + + + + + + + + + diff --git a/plugins/user-two-factor-authenticators/totp/pom.xml b/plugins/user-two-factor-authenticators/totp/pom.xml new file mode 100644 index 00000000000..f10f1c0928e --- /dev/null +++ b/plugins/user-two-factor-authenticators/totp/pom.xml @@ -0,0 +1,30 @@ + + + 4.0.0 + cloud-plugin-user-two-factor-authenticator-totp + Apache CloudStack Plugin - User Two Factor Authenticator TOTP + + org.apache.cloudstack + cloudstack-plugins + 4.18.0.0-SNAPSHOT + ../../pom.xml + + \ No newline at end of file diff --git a/plugins/user-two-factor-authenticators/totp/src/main/java/org/apache/cloudstack/auth/TotpUserTwoFactorAuthenticator.java b/plugins/user-two-factor-authenticators/totp/src/main/java/org/apache/cloudstack/auth/TotpUserTwoFactorAuthenticator.java new file mode 100644 index 00000000000..bb6939ad14f --- /dev/null +++ b/plugins/user-two-factor-authenticators/totp/src/main/java/org/apache/cloudstack/auth/TotpUserTwoFactorAuthenticator.java @@ -0,0 +1,86 @@ +// Licensed to the Apache Software Foundation (ASF) under one or more +// contributor license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright ownership. +// The ASF licenses this file to You under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package org.apache.cloudstack.auth; + + +import javax.inject.Inject; + +import com.cloud.exception.CloudTwoFactorAuthenticationException; +import com.cloud.utils.exception.CloudRuntimeException; +import de.taimos.totp.TOTP; + +import com.cloud.user.UserAccount; +import org.apache.commons.codec.binary.Base32; +import org.apache.commons.codec.binary.Hex; +import org.apache.commons.lang3.StringUtils; +import org.apache.log4j.Logger; + +import com.cloud.user.dao.UserAccountDao; +import com.cloud.utils.component.AdapterBase; + +import java.security.SecureRandom; + +public class TotpUserTwoFactorAuthenticator extends AdapterBase implements UserTwoFactorAuthenticator { + public static final Logger s_logger = Logger.getLogger(TotpUserTwoFactorAuthenticator.class); + + @Inject + private UserAccountDao _userAccountDao; + + @Override + public String getName() { + return "totp"; + } + + @Override + public String getDescription() { + return "TOTP user two factor authentication provider Plugin"; + } + + @Override + public void check2FA(String code, UserAccount userAccount) throws CloudTwoFactorAuthenticationException { + String expectedCode = get2FACode(get2FAKey(userAccount)); + if (expectedCode.equals(code)) { + s_logger.info("2FA matches user's input"); + return; + } + throw new CloudTwoFactorAuthenticationException("two-factor authentication code provided is invalid"); + } + + private String get2FAKey(UserAccount userAccount) { + return userAccount.getKeyFor2fa(); + } + + private String get2FACode(String secretKey) { + Base32 base32 = new Base32(); + byte[] bytes = base32.decode(secretKey); + String hexKey = Hex.encodeHexString(bytes); + return TOTP.getOTP(hexKey); + } + + @Override + public String setup2FAKey(UserAccount userAccount) { + if (StringUtils.isNotEmpty(userAccount.getKeyFor2fa())) { + throw new CloudRuntimeException(String.format("2FA key is already setup for the user account %s", userAccount.getAccountName())); + } + SecureRandom random = new SecureRandom(); + byte[] bytes = new byte[20]; + random.nextBytes(bytes); + Base32 base32 = new Base32(); + String key = base32.encodeToString(bytes); + userAccount.setKeyFor2fa(key); + return key; + } +} diff --git a/plugins/user-two-factor-authenticators/totp/src/main/resources/META-INF/cloudstack/totp/module.properties b/plugins/user-two-factor-authenticators/totp/src/main/resources/META-INF/cloudstack/totp/module.properties new file mode 100644 index 00000000000..1b735ac1242 --- /dev/null +++ b/plugins/user-two-factor-authenticators/totp/src/main/resources/META-INF/cloudstack/totp/module.properties @@ -0,0 +1,18 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +name=totp +parent=api \ No newline at end of file diff --git a/plugins/user-two-factor-authenticators/totp/src/main/resources/META-INF/cloudstack/totp/spring-google-context.xml b/plugins/user-two-factor-authenticators/totp/src/main/resources/META-INF/cloudstack/totp/spring-google-context.xml new file mode 100644 index 00000000000..84a0b0cfcb7 --- /dev/null +++ b/plugins/user-two-factor-authenticators/totp/src/main/resources/META-INF/cloudstack/totp/spring-google-context.xml @@ -0,0 +1,35 @@ + + + + + + + + + diff --git a/server/src/main/java/com/cloud/api/ApiServer.java b/server/src/main/java/com/cloud/api/ApiServer.java index 136cd624325..db79141b437 100644 --- a/server/src/main/java/com/cloud/api/ApiServer.java +++ b/server/src/main/java/com/cloud/api/ApiServer.java @@ -160,6 +160,7 @@ import com.cloud.projects.dao.ProjectDao; import com.cloud.storage.VolumeApiService; import com.cloud.user.Account; import com.cloud.user.AccountManager; +import com.cloud.user.AccountManagerImpl; import com.cloud.user.DomainManager; import com.cloud.user.User; import com.cloud.user.UserAccount; @@ -1069,6 +1070,18 @@ public class ApiServer extends ManagerBase implements HttpRequestHandler, ApiSer if (ApiConstants.SESSIONKEY.equalsIgnoreCase(attrName)) { response.setSessionKey(attrObj.toString()); } + if (ApiConstants.IS_2FA_ENABLED.equalsIgnoreCase(attrName)) { + response.set2FAenabled(attrObj.toString()); + } + if (ApiConstants.IS_2FA_VERIFIED.equalsIgnoreCase(attrName)) { + response.set2FAverfied(attrObj.toString()); + } + if (ApiConstants.PROVIDER_FOR_2FA.equalsIgnoreCase(attrName)) { + response.setProviderFor2FA(attrObj.toString()); + } + if (ApiConstants.ISSUER_FOR_2FA.equalsIgnoreCase(attrName)) { + response.setIssuerFor2FA(attrObj.toString()); + } } } response.setResponseName("loginresponse"); @@ -1132,6 +1145,20 @@ public class ApiServer extends ManagerBase implements HttpRequestHandler, ApiSer session.setAttribute("timezoneoffset", Float.valueOf(offsetInHrs).toString()); } + boolean is2faEnabled = false; + if (userAcct.isUser2faEnabled() || (Boolean.TRUE.equals(AccountManagerImpl.enableUserTwoFactorAuthentication.valueIn(userAcct.getDomainId())) && Boolean.TRUE.equals(AccountManagerImpl.mandateUserTwoFactorAuthentication.valueIn(userAcct.getDomainId())))) { + is2faEnabled = true; + } + String issuerFor2FA = AccountManagerImpl.userTwoFactorAuthenticationIssuer.valueIn(userAcct.getDomainId()); + session.setAttribute(ApiConstants.IS_2FA_ENABLED, Boolean.toString(is2faEnabled)); + if (!is2faEnabled) { + session.setAttribute(ApiConstants.IS_2FA_VERIFIED, true); + } else { + session.setAttribute(ApiConstants.IS_2FA_VERIFIED, false); + } + session.setAttribute(ApiConstants.PROVIDER_FOR_2FA, userAcct.getUser2faProvider()); + session.setAttribute(ApiConstants.ISSUER_FOR_2FA, issuerFor2FA); + // (bug 5483) generate a session key that the user must submit on every request to prevent CSRF, add that // to the login response so that session-based authenticators know to send the key back final SecureRandom sesssionKeyRandom = new SecureRandom(); diff --git a/server/src/main/java/com/cloud/api/ApiServlet.java b/server/src/main/java/com/cloud/api/ApiServlet.java index f93f2dfacd6..626678649d7 100644 --- a/server/src/main/java/com/cloud/api/ApiServlet.java +++ b/server/src/main/java/com/cloud/api/ApiServlet.java @@ -36,6 +36,7 @@ import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.ApiErrorCode; import org.apache.cloudstack.api.ApiServerService; import org.apache.cloudstack.api.BaseCmd; import org.apache.cloudstack.api.ServerApiException; @@ -51,11 +52,16 @@ import org.jetbrains.annotations.Nullable; import org.springframework.stereotype.Component; import org.springframework.web.context.support.SpringBeanAutowiringSupport; +import com.cloud.api.auth.ListUserTwoFactorAuthenticatorProvidersCmd; +import com.cloud.api.auth.SetupUserTwoFactorAuthenticationCmd; +import com.cloud.api.auth.ValidateUserTwoFactorAuthenticationCodeCmd; import com.cloud.projects.Project; import com.cloud.projects.dao.ProjectDao; import com.cloud.user.Account; +import com.cloud.user.AccountManagerImpl; import com.cloud.user.AccountService; import com.cloud.user.User; +import com.cloud.user.UserAccount; import com.cloud.utils.HttpUtils; import com.cloud.utils.StringUtils; @@ -219,7 +225,7 @@ public class ApiServlet extends HttpServlet { logName)); } - if (command != null) { + if (command != null && !command.equals(ValidateUserTwoFactorAuthenticationCodeCmd.APINAME)) { APIAuthenticator apiAuthenticator = authManager.getAPIAuthenticator(command); if (apiAuthenticator != null) { @@ -293,17 +299,27 @@ public class ApiServlet extends HttpServlet { // Initialize an empty context and we will update it after we have verified the request below, // we no longer rely on web-session here, verifyRequest will populate user/account information // if a API key exists - Long userId = null; if (isNew && s_logger.isTraceEnabled()) { s_logger.trace(String.format("new session: %s", session)); } + + if (!isNew && (command.equalsIgnoreCase(ValidateUserTwoFactorAuthenticationCodeCmd.APINAME) || (!skip2FAcheckForAPIs(command) && !skip2FAcheckForUser(session)))) { + s_logger.debug("Verifying two factor authentication"); + boolean success = verify2FA(session, command, auditTrailSb, params, remoteAddress, responseType, req, resp); + if (!success) { + s_logger.debug("Verification of two factor authentication failed"); + return; + } + } + + Long userId = null; if (!isNew) { userId = (Long)session.getAttribute("userid"); final String account = (String) session.getAttribute("account"); final Object accountObj = session.getAttribute("accountobj"); if (account != null) { - if (invalidateHttpSesseionIfNeeded(req, resp, auditTrailSb, responseType, params, session, account)) return; + if (invalidateHttpSessionIfNeeded(req, resp, auditTrailSb, responseType, params, session, account)) return; } else { if (s_logger.isDebugEnabled()) { s_logger.debug("no account, this request will be validated through apikey(%s)/signature"); @@ -360,6 +376,100 @@ public class ApiServlet extends HttpServlet { } } + private boolean checkIfAuthenticatorIsOf2FA(String command) { + boolean verify2FA = false; + APIAuthenticator apiAuthenticator = authManager.getAPIAuthenticator(command); + if (apiAuthenticator != null && apiAuthenticator.getAPIType().equals(APIAuthenticationType.LOGIN_2FA_API)) { + verify2FA = true; + } else { + verify2FA = false; + } + return verify2FA; + } + + protected boolean skip2FAcheckForAPIs(String command) { + boolean skip2FAcheck = false; + + if (command.equalsIgnoreCase(ApiConstants.LIST_IDPS) + || command.equalsIgnoreCase(ApiConstants.LIST_APIS) + || command.equalsIgnoreCase(ListUserTwoFactorAuthenticatorProvidersCmd.APINAME) + || command.equalsIgnoreCase(SetupUserTwoFactorAuthenticationCmd.APINAME)) { + skip2FAcheck = true; + } + return skip2FAcheck; + } + + protected boolean skip2FAcheckForUser(HttpSession session) { + boolean skip2FAcheck = false; + Long userId = (Long) session.getAttribute("userid"); + boolean is2FAverified = (boolean) session.getAttribute(ApiConstants.IS_2FA_VERIFIED); + if (is2FAverified) { + s_logger.debug(String.format("Two factor authentication is already verified for the user %d, so skipping", userId)); + skip2FAcheck = true; + } else { + UserAccount userAccount = accountMgr.getUserAccountById(userId); + boolean is2FAenabled = userAccount.isUser2faEnabled(); + if (is2FAenabled) { + skip2FAcheck = false; + } else { + Long domainId = userAccount.getDomainId(); + boolean is2FAmandated = Boolean.TRUE.equals(AccountManagerImpl.enableUserTwoFactorAuthentication.valueIn(domainId)) && Boolean.TRUE.equals(AccountManagerImpl.mandateUserTwoFactorAuthentication.valueIn(domainId)); + if (is2FAmandated) { + skip2FAcheck = false; + } else { + skip2FAcheck = true; + } + } + } + return skip2FAcheck; + } + + protected boolean verify2FA(HttpSession session, String command, StringBuilder auditTrailSb, Map params, + InetAddress remoteAddress, String responseType, HttpServletRequest req, HttpServletResponse resp) { + boolean verify2FA = false; + if (command.equals(ValidateUserTwoFactorAuthenticationCodeCmd.APINAME)) { + APIAuthenticator apiAuthenticator = authManager.getAPIAuthenticator(command); + if (apiAuthenticator != null) { + String responseString = apiAuthenticator.authenticate(command, params, session, remoteAddress, responseType, auditTrailSb, req, resp); + session.setAttribute(ApiConstants.IS_2FA_VERIFIED, true); + HttpUtils.writeHttpResponse(resp, responseString, HttpServletResponse.SC_OK, responseType, ApiServer.JSONcontentType.value()); + verify2FA = true; + } else { + s_logger.error("Cannot find API authenticator while verifying 2FA"); + auditTrailSb.append(" Cannot find API authenticator while verifying 2FA"); + verify2FA = false; + } + } else { + // invalidate the session + Long userId = (Long) session.getAttribute("userid"); + UserAccount userAccount = accountMgr.getUserAccountById(userId); + boolean is2FAenabled = userAccount.isUser2faEnabled(); + String keyFor2fa = userAccount.getKeyFor2fa(); + String providerFor2fa = userAccount.getUser2faProvider(); + String errorMsg; + if (is2FAenabled) { + if (org.apache.commons.lang3.StringUtils.isEmpty(keyFor2fa) || org.apache.commons.lang3.StringUtils.isEmpty(providerFor2fa)) { + errorMsg = "Two factor authentication is mandated by admin, user needs to setup 2FA using setupUserTwoFactorAuthentication API and" + + " then verify 2FA using validateUserTwoFactorAuthenticationCode API before calling other APIs. Existing session is invalidated."; + } else { + errorMsg = "Two factor authentication 2FA is enabled but not verified, please verify 2FA using validateUserTwoFactorAuthenticationCode API before calling other APIs. Existing session is invalidated."; + } + } else { + // when (is2FAmandated) is true + errorMsg = "Two factor authentication is mandated by admin, user needs to setup 2FA using setupUserTwoFactorAuthentication API and" + + " then verify 2FA using validateUserTwoFactorAuthenticationCode API before calling other APIs. Existing session is invalidated."; + } + s_logger.error(errorMsg); + + invalidateHttpSession(session, String.format("Unable to process the API request for %s from %s due to %s", userId, remoteAddress.getHostAddress(), errorMsg)); + auditTrailSb.append(" " + ApiErrorCode.UNAUTHORIZED2FA + " " + errorMsg); + final String serializedResponse = apiServer.getSerializedApiError(ApiErrorCode.UNAUTHORIZED2FA.getHttpCode(), "Unable to process the API request due to :" + errorMsg, params, responseType); + HttpUtils.writeHttpResponse(resp, serializedResponse, ApiErrorCode.UNAUTHORIZED2FA.getHttpCode(), responseType, ApiServer.JSONcontentType.value()); + verify2FA = false; + } + + return verify2FA; + } protected void setClientAddressForConsoleEndpointAccess(String command, Map params, HttpServletRequest req) throws UnknownHostException { if (org.apache.commons.lang3.StringUtils.isNotBlank(command) && command.equalsIgnoreCase(BaseCmd.getCommandNameByClass(CreateConsoleEndpointCmd.class))) { @@ -400,7 +510,7 @@ public class ApiServlet extends HttpServlet { return true; } - private boolean invalidateHttpSesseionIfNeeded(HttpServletRequest req, HttpServletResponse resp, StringBuilder auditTrailSb, String responseType, Map params, HttpSession session, String account) { + private boolean invalidateHttpSessionIfNeeded(HttpServletRequest req, HttpServletResponse resp, StringBuilder auditTrailSb, String responseType, Map params, HttpSession session, String account) { if (!HttpUtils.validateSessionKey(session, params, req.getCookies(), ApiConstants.SESSIONKEY)) { String msg = String.format("invalidating session %s for account %s", session.getId(), account); invalidateHttpSession(session, msg); diff --git a/server/src/main/java/com/cloud/api/auth/APIAuthenticationManagerImpl.java b/server/src/main/java/com/cloud/api/auth/APIAuthenticationManagerImpl.java index c304a10d917..6ec9ff9c1ce 100644 --- a/server/src/main/java/com/cloud/api/auth/APIAuthenticationManagerImpl.java +++ b/server/src/main/java/com/cloud/api/auth/APIAuthenticationManagerImpl.java @@ -77,6 +77,12 @@ public class APIAuthenticationManagerImpl extends ManagerBase implements APIAuth List> cmdList = new ArrayList>(); cmdList.add(DefaultLoginAPIAuthenticatorCmd.class); cmdList.add(DefaultLogoutAPIAuthenticatorCmd.class); + + cmdList.add(ListUserTwoFactorAuthenticatorProvidersCmd.class); + cmdList.add(ValidateUserTwoFactorAuthenticationCodeCmd.class); + cmdList.add(SetupUserTwoFactorAuthenticationCmd.class); + + for (PluggableAPIAuthenticator apiAuthenticator: _apiAuthenticators) { List> commands = apiAuthenticator.getAuthCommands(); if (commands != null) { diff --git a/server/src/main/java/com/cloud/api/auth/ListUserTwoFactorAuthenticatorProvidersCmd.java b/server/src/main/java/com/cloud/api/auth/ListUserTwoFactorAuthenticatorProvidersCmd.java new file mode 100644 index 00000000000..73e408843c6 --- /dev/null +++ b/server/src/main/java/com/cloud/api/auth/ListUserTwoFactorAuthenticatorProvidersCmd.java @@ -0,0 +1,98 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.api.auth; + +import com.cloud.user.Account; +import com.cloud.user.AccountManager; +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.response.UserTwoFactorAuthenticatorProviderResponse; +import org.apache.cloudstack.api.response.ListResponse; +import org.apache.cloudstack.auth.UserTwoFactorAuthenticator; + +import javax.inject.Inject; +import java.util.ArrayList; +import java.util.List; + +@APICommand(name = ListUserTwoFactorAuthenticatorProvidersCmd.APINAME, + description = "Lists user two factor authenticator providers", + authorized = {RoleType.Admin, RoleType.DomainAdmin, RoleType.ResourceAdmin, RoleType.User}, + responseObject = UserTwoFactorAuthenticatorProviderResponse.class, since = "4.18.0") +public class ListUserTwoFactorAuthenticatorProvidersCmd extends BaseCmd { + + public static final String APINAME = "listUserTwoFactorAuthenticatorProviders"; + + @Inject + private AccountManager accountManager; + + ///////////////////////////////////////////////////// + //////////////// API parameters ///////////////////// + ///////////////////////////////////////////////////// + + @Parameter(name = ApiConstants.NAME, type = BaseCmd.CommandType.STRING, description = "List user two factor authenticator provider by name") + private String name; + + ///////////////////////////////////////////////////// + /////////////////// Accessors /////////////////////// + ///////////////////////////////////////////////////// + + public String getName() { + return name; + } + + @Override + public String getCommandName() { + return APINAME.toLowerCase() + BaseCmd.RESPONSE_SUFFIX; + } + + @Override + public long getEntityOwnerId() { + return Account.ACCOUNT_ID_SYSTEM; + } + + ///////////////////////////////////////////////////// + /////////////// API Implementation/////////////////// + ///////////////////////////////////////////////////// + + private void setupResponse(final List providers) { + final ListResponse response = new ListResponse<>(); + final List responses = new ArrayList<>(); + for (final UserTwoFactorAuthenticator provider : providers) { + if (provider == null || (getName() != null && !provider.getName().equals(getName()))) { + continue; + } + final UserTwoFactorAuthenticatorProviderResponse userTwoFactorAuthenticatorProviderResponse = new UserTwoFactorAuthenticatorProviderResponse(); + userTwoFactorAuthenticatorProviderResponse.setName(provider.getName()); + userTwoFactorAuthenticatorProviderResponse.setDescription(provider.getDescription()); + userTwoFactorAuthenticatorProviderResponse.setObjectName("providers"); + responses.add(userTwoFactorAuthenticatorProviderResponse); + } + response.setResponses(responses); + response.setResponseName(getCommandName()); + setResponseObject(response); + } + + @Override + public void execute() { + List providers = accountManager.listUserTwoFactorAuthenticationProviders(); + setupResponse(providers); + } + +} \ No newline at end of file diff --git a/server/src/main/java/com/cloud/api/auth/SetupUserTwoFactorAuthenticationCmd.java b/server/src/main/java/com/cloud/api/auth/SetupUserTwoFactorAuthenticationCmd.java new file mode 100644 index 00000000000..32a8f49f2c4 --- /dev/null +++ b/server/src/main/java/com/cloud/api/auth/SetupUserTwoFactorAuthenticationCmd.java @@ -0,0 +1,96 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.api.auth; + +import com.cloud.user.AccountManager; +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.ApiCommandResourceType; +import org.apache.cloudstack.api.BaseCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.UserResponse; +import org.apache.cloudstack.api.response.UserTwoFactorAuthenticationSetupResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.log4j.Logger; + +import javax.inject.Inject; + +@APICommand(name = SetupUserTwoFactorAuthenticationCmd.APINAME, description = "Setup the 2FA for the user.", authorized = {RoleType.Admin, RoleType.DomainAdmin, RoleType.ResourceAdmin, RoleType.User}, requestHasSensitiveInfo = false, + responseObject = UserTwoFactorAuthenticationSetupResponse.class, entityType = {}, since = "4.18.0") +public class SetupUserTwoFactorAuthenticationCmd extends BaseCmd { + + public static final String APINAME = "setupUserTwoFactorAuthentication"; + public static final Logger s_logger = Logger.getLogger(SetupUserTwoFactorAuthenticationCmd.class.getName()); + + @Inject + private AccountManager accountManager; + + ///////////////////////////////////////////////////// + //////////////// API parameters ///////////////////// + ///////////////////////////////////////////////////// + + @Parameter(name = ApiConstants.PROVIDER, type = CommandType.STRING, description = "two factor authentication code") + private String provider; + + @Parameter(name = ApiConstants.ENABLE, type = CommandType.BOOLEAN, description = "Enabled by default, provide false to disable 2FA") + private Boolean enable = true; + + @Parameter(name = ApiConstants.USER_ID, type = CommandType.UUID, entityType = UserResponse.class, description = "optional: the id of the user for which 2FA has to be disabled") + private Long userId; + + ///////////////////////////////////////////////////// + /////////////////// Accessors /////////////////////// + ///////////////////////////////////////////////////// + + public String getProvider() { + return provider; + } + + public Boolean getEnable() { + return enable; + } + + public Long getUserId() { + return userId; + } + + @Override + public void execute() throws ServerApiException { + UserTwoFactorAuthenticationSetupResponse response = accountManager.setupUserTwoFactorAuthentication(this); + response.setObjectName("setup2fa"); + response.setResponseName(getCommandName()); + setResponseObject(response); + } + + @Override + public String getCommandName() { + return APINAME.toLowerCase() + BaseCmd.RESPONSE_SUFFIX; + } + + @Override + public long getEntityOwnerId() { + return CallContext.current().getCallingAccount().getId(); + } + + @Override + public ApiCommandResourceType getApiResourceType() { + return ApiCommandResourceType.User; + } + +} diff --git a/server/src/main/java/com/cloud/api/auth/ValidateUserTwoFactorAuthenticationCodeCmd.java b/server/src/main/java/com/cloud/api/auth/ValidateUserTwoFactorAuthenticationCodeCmd.java new file mode 100644 index 00000000000..df9f8bfdab8 --- /dev/null +++ b/server/src/main/java/com/cloud/api/auth/ValidateUserTwoFactorAuthenticationCodeCmd.java @@ -0,0 +1,145 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.api.auth; + +import com.cloud.api.ApiServlet; +import com.cloud.api.response.ApiResponseSerializer; +import com.cloud.exception.CloudTwoFactorAuthenticationException; +import com.cloud.user.AccountManager; +import com.cloud.user.UserAccount; +import com.cloud.user.UserAccountVO; +import com.cloud.utils.exception.CSExceptionErrorCode; +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.ApiErrorCode; +import org.apache.cloudstack.api.ApiServerService; +import org.apache.cloudstack.api.BaseCmd; +import org.apache.cloudstack.api.Parameter; +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.SuccessResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.resourcedetail.UserDetailVO; +import org.apache.commons.lang3.StringUtils; +import org.apache.log4j.Logger; + +import javax.inject.Inject; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; +import java.net.InetAddress; +import java.util.List; +import java.util.Map; + +@APICommand(name = ValidateUserTwoFactorAuthenticationCodeCmd.APINAME, description = "Checks the 2FA code for the user.", requestHasSensitiveInfo = false, + authorized = {RoleType.Admin, RoleType.DomainAdmin, RoleType.ResourceAdmin, RoleType.User}, + responseObject = SuccessResponse.class, entityType = {}, since = "4.18.0") +public class ValidateUserTwoFactorAuthenticationCodeCmd extends BaseCmd implements APIAuthenticator { + + public static final String APINAME = "validateUserTwoFactorAuthenticationCode"; + public static final Logger s_logger = Logger.getLogger(ValidateUserTwoFactorAuthenticationCodeCmd.class.getName()); + + @Inject + private AccountManager accountManager; + + @Inject + ApiServerService _apiServer; + + ///////////////////////////////////////////////////// + //////////////// API parameters ///////////////////// + ///////////////////////////////////////////////////// + + @Parameter(name = ApiConstants.CODE_FOR_2FA, type = CommandType.STRING, description = "two factor authentication code", required = true) + private String codeFor2fa; + + ///////////////////////////////////////////////////// + /////////////////// Accessors /////////////////////// + ///////////////////////////////////////////////////// + + public String getCodeFor2fa() { + return codeFor2fa; + } + + @Override + public void execute() throws ServerApiException { + throw new ServerApiException(ApiErrorCode.METHOD_NOT_ALLOWED, "This is an authentication api, cannot be used directly"); + } + + @Override + public String getCommandName() { + return APINAME.toLowerCase() + BaseCmd.RESPONSE_SUFFIX; + } + + @Override + public long getEntityOwnerId() { + return CallContext.current().getCallingAccount().getId(); + } + + @Override + public String authenticate(String command, Map params, HttpSession session, InetAddress remoteAddress, String responseType, StringBuilder auditTrailSb, HttpServletRequest req, HttpServletResponse resp) throws ServerApiException { + String codeFor2FA = null; + if (params.containsKey(ApiConstants.CODE_FOR_2FA)) { + codeFor2FA = ((String[])params.get(ApiConstants.CODE_FOR_2FA))[0]; + } + if (StringUtils.isEmpty(codeFor2FA)) { + throw new ServerApiException(ApiErrorCode.ACCOUNT_ERROR, "Code for two factor authentication is required"); + } + + final long currentUserId = (Long) session.getAttribute("userid"); + final UserAccount currentUserAccount = _accountService.getUserAccountById(currentUserId); + boolean setupPhase = false; + Map userDetails = currentUserAccount.getDetails(); + if (userDetails.containsKey(UserDetailVO.Setup2FADetail) && userDetails.get(UserDetailVO.Setup2FADetail).equals(UserAccountVO.Setup2FAstatus.ENABLED.name())) { + setupPhase = true; + } + + String serializedResponse = null; + try { + accountManager.verifyUsingTwoFactorAuthenticationCode(codeFor2FA, currentUserAccount.getDomainId(), currentUserId); + SuccessResponse response = new SuccessResponse(getCommandName()); + setResponseObject(response); + return ApiResponseSerializer.toSerializedString(response, responseType); + } catch (final CloudTwoFactorAuthenticationException ex) { + if (!setupPhase) { + ApiServlet.invalidateHttpSession(session, "fall through to API key,"); + } + String msg = String.format("%s", ex.getMessage() != null ? + ex.getMessage() : + "failed to authenticate user, check if two factor authentication code is correct"); + auditTrailSb.append(" " + ApiErrorCode.UNAUTHORIZED2FA + " " + msg); + serializedResponse = _apiServer.getSerializedApiError(ApiErrorCode.UNAUTHORIZED2FA.getHttpCode(), msg, params, responseType); + if (s_logger.isTraceEnabled()) { + s_logger.trace(msg); + } + } + ServerApiException exception = new ServerApiException(ApiErrorCode.UNAUTHORIZED2FA, serializedResponse); + exception.setCSErrorCode(CSExceptionErrorCode.getCSErrCode(CloudTwoFactorAuthenticationException.class.getName())); + throw exception; + } + + @Override + public APIAuthenticationType getAPIType() { + return APIAuthenticationType.LOGIN_2FA_API; + } + + @Override + public void setAuthenticators(List authenticators) { + } +} diff --git a/server/src/main/java/com/cloud/api/query/dao/UserAccountJoinDaoImpl.java b/server/src/main/java/com/cloud/api/query/dao/UserAccountJoinDaoImpl.java index 8d06bcd0a26..4633c52ee59 100644 --- a/server/src/main/java/com/cloud/api/query/dao/UserAccountJoinDaoImpl.java +++ b/server/src/main/java/com/cloud/api/query/dao/UserAccountJoinDaoImpl.java @@ -19,6 +19,7 @@ package com.cloud.api.query.dao; import java.util.List; +import com.cloud.user.AccountManagerImpl; import org.apache.log4j.Logger; import org.springframework.stereotype.Component; @@ -72,6 +73,10 @@ public class UserAccountJoinDaoImpl extends GenericDaoBase _availableIdsMap; private List _userAuthenticators; + private List _userTwoFactorAuthenticators; private List _userPasswordEncoders; protected boolean _executeInSequence; @@ -1030,6 +1032,14 @@ public class ManagementServerImpl extends ManagerBase implements ManagementServe _userAuthenticators = authenticators; } + public List getUserTwoFactorAuthenticators() { + return _userTwoFactorAuthenticators; + } + + public void setUserTwoFactorAuthenticators(final List userTwoFactorAuthenticators) { + _userTwoFactorAuthenticators = userTwoFactorAuthenticators; + } + public List getUserPasswordEncoders() { return _userPasswordEncoders; } diff --git a/server/src/main/java/com/cloud/user/AccountManager.java b/server/src/main/java/com/cloud/user/AccountManager.java index a4913feba81..c95047a6c42 100644 --- a/server/src/main/java/com/cloud/user/AccountManager.java +++ b/server/src/main/java/com/cloud/user/AccountManager.java @@ -20,11 +20,14 @@ import java.net.InetAddress; import java.util.List; import java.util.Map; +import com.cloud.api.auth.SetupUserTwoFactorAuthenticationCmd; import org.apache.cloudstack.acl.ControlledEntity; import org.apache.cloudstack.api.command.admin.account.UpdateAccountCmd; import org.apache.cloudstack.api.command.admin.user.DeleteUserCmd; import org.apache.cloudstack.api.command.admin.user.MoveUserCmd; import org.apache.cloudstack.api.command.admin.user.UpdateUserCmd; +import org.apache.cloudstack.api.response.UserTwoFactorAuthenticationSetupResponse; +import org.apache.cloudstack.auth.UserTwoFactorAuthenticator; import org.apache.cloudstack.framework.config.ConfigKey; import org.apache.cloudstack.framework.config.Configurable; @@ -188,4 +191,10 @@ public interface AccountManager extends AccountService, Configurable { "This parameter allows the users to enable or disable of showing secret key as a part of response for various APIs. By default it is set to false.", true); boolean moveUser(long id, Long domainId, Account newAccount); + + UserTwoFactorAuthenticator getUserTwoFactorAuthenticator(final Long domainId, final Long userAccountId); + + void verifyUsingTwoFactorAuthenticationCode(String code, Long domainId, Long userAccountId); + UserTwoFactorAuthenticationSetupResponse setupUserTwoFactorAuthentication(SetupUserTwoFactorAuthenticationCmd cmd); + } diff --git a/server/src/main/java/com/cloud/user/AccountManagerImpl.java b/server/src/main/java/com/cloud/user/AccountManagerImpl.java index 2a93908180d..bc7dc08772b 100644 --- a/server/src/main/java/com/cloud/user/AccountManagerImpl.java +++ b/server/src/main/java/com/cloud/user/AccountManagerImpl.java @@ -40,9 +40,6 @@ import javax.crypto.spec.SecretKeySpec; import javax.inject.Inject; import javax.naming.ConfigurationException; -import com.cloud.network.security.SecurityGroupService; -import com.cloud.network.security.SecurityGroupVO; -import com.cloud.utils.component.PluggableService; import org.apache.cloudstack.acl.APIChecker; import org.apache.cloudstack.acl.ControlledEntity; import org.apache.cloudstack.acl.QuerySelector; @@ -62,6 +59,10 @@ import org.apache.cloudstack.api.command.admin.user.GetUserKeysCmd; import org.apache.cloudstack.api.command.admin.user.MoveUserCmd; import org.apache.cloudstack.api.command.admin.user.RegisterCmd; import org.apache.cloudstack.api.command.admin.user.UpdateUserCmd; +import org.apache.cloudstack.api.response.UserTwoFactorAuthenticationSetupResponse; +import org.apache.cloudstack.auth.UserAuthenticator; +import org.apache.cloudstack.auth.UserAuthenticator.ActionOnFailedAuthentication; +import org.apache.cloudstack.auth.UserTwoFactorAuthenticator; import org.apache.cloudstack.config.ApiServiceConfiguration; import org.apache.cloudstack.context.CallContext; import org.apache.cloudstack.engine.orchestration.service.NetworkOrchestrationService; @@ -71,6 +72,8 @@ import org.apache.cloudstack.framework.messagebus.MessageBus; import org.apache.cloudstack.framework.messagebus.PublishScope; import org.apache.cloudstack.managed.context.ManagedContextRunnable; import org.apache.cloudstack.region.gslb.GlobalLoadBalancerRuleDao; +import org.apache.cloudstack.resourcedetail.UserDetailVO; +import org.apache.cloudstack.resourcedetail.dao.UserDetailsDao; import org.apache.cloudstack.utils.baremetal.BaremetalUtils; import org.apache.commons.codec.binary.Base64; import org.apache.commons.collections.CollectionUtils; @@ -80,6 +83,7 @@ import org.apache.log4j.Logger; import org.jetbrains.annotations.NotNull; import com.cloud.api.ApiDBUtils; +import com.cloud.api.auth.SetupUserTwoFactorAuthenticationCmd; import com.cloud.api.query.vo.ControlledViewEntity; import com.cloud.configuration.Config; import com.cloud.configuration.ConfigurationManager; @@ -103,6 +107,7 @@ import com.cloud.event.ActionEvents; import com.cloud.event.EventTypes; import com.cloud.exception.AgentUnavailableException; import com.cloud.exception.CloudAuthenticationException; +import com.cloud.exception.CloudTwoFactorAuthenticationException; import com.cloud.exception.ConcurrentOperationException; import com.cloud.exception.InvalidParameterValueException; import com.cloud.exception.OperationTimedoutException; @@ -112,6 +117,8 @@ import com.cloud.network.IpAddress; import com.cloud.network.IpAddressManager; import com.cloud.network.Network; import com.cloud.network.NetworkModel; +import com.cloud.network.security.SecurityGroupService; +import com.cloud.network.security.SecurityGroupVO; import com.cloud.network.VpnUserVO; import com.cloud.network.as.AutoScaleManager; import com.cloud.network.dao.AccountGuestVlanMapDao; @@ -142,8 +149,6 @@ import com.cloud.projects.ProjectVO; import com.cloud.projects.dao.ProjectAccountDao; import com.cloud.projects.dao.ProjectDao; import com.cloud.region.ha.GlobalLoadBalancingRulesService; -import com.cloud.server.auth.UserAuthenticator; -import com.cloud.server.auth.UserAuthenticator.ActionOnFailedAuthentication; import com.cloud.storage.VMTemplateVO; import com.cloud.storage.VolumeApiService; import com.cloud.storage.VolumeVO; @@ -163,6 +168,7 @@ import com.cloud.utils.Pair; import com.cloud.utils.Ternary; import com.cloud.utils.component.Manager; import com.cloud.utils.component.ManagerBase; +import com.cloud.utils.component.PluggableService; import com.cloud.utils.concurrency.NamedThreadFactory; import com.cloud.utils.db.DB; import com.cloud.utils.db.GlobalLock; @@ -203,6 +209,8 @@ public class AccountManagerImpl extends ManagerBase implements AccountManager, M @Inject private UserDao _userDao; @Inject + private UserDetailsDao _userDetailsDao; + @Inject private InstanceGroupDao _vmGroupDao; @Inject private UserAccountDao _userAccountDao; @@ -293,7 +301,11 @@ public class AccountManagerImpl extends ManagerBase implements AccountManager, M @Inject private GlobalLoadBalancingRulesService _gslbService; + @Inject + public AccountService _accountService; + private List _userAuthenticators; + private List _userTwoFactorAuthenticators; protected List _userPasswordEncoders; protected List services; private List apiAccessCheckers; @@ -317,6 +329,39 @@ public class AccountManagerImpl extends ManagerBase implements AccountManager, M private int _cleanupInterval; private List apiNameList; + protected static Map userTwoFactorAuthenticationProvidersMap = new HashMap<>(); + + private List userTwoFactorAuthenticationProviders; + + public static ConfigKey enableUserTwoFactorAuthentication = new ConfigKey<>("Advanced", + Boolean.class, + "enable.user.2fa", + "false", + "Determines whether two factor authentication is enabled or not. This can also be configured at domain level.", + true, + ConfigKey.Scope.Domain); + + public static ConfigKey mandateUserTwoFactorAuthentication = new ConfigKey<>("Advanced", + Boolean.class, + "mandate.user.2fa", + "false", + "Determines whether to make the two factor authentication mandatory or not. This setting is applicable only when enable.user.2fa is true. This can also be configured at domain level.", + true, + ConfigKey.Scope.Domain); + + public static final ConfigKey userTwoFactorAuthenticationIssuer = new ConfigKey<>("Advanced", + String.class, + "user.2fa.issuer", + "CloudStack", + "Name of the issuer of two factor authentication", + true, + ConfigKey.Scope.Domain); + + static final ConfigKey userTwoFactorAuthenticationDefaultProvider = new ConfigKey<>("Advanced", String.class, + "user.2fa.default.provider", + "totp", + "The default user two factor authentication provider plugin. Eg. totp, staticpin", true, ConfigKey.Scope.Domain); + protected AccountManagerImpl() { super(); } @@ -329,6 +374,14 @@ public class AccountManagerImpl extends ManagerBase implements AccountManager, M _userAuthenticators = authenticators; } + public List getUserTwoFactorAuthenticators() { + return _userTwoFactorAuthenticators; + } + + public void setUserTwoFactorAuthenticators(List twoFactorAuthenticators) { + _userTwoFactorAuthenticators = twoFactorAuthenticators; + } + public List getUserPasswordEncoders() { return _userPasswordEncoders; } @@ -402,6 +455,9 @@ public class AccountManagerImpl extends ManagerBase implements AccountManager, M @Override public boolean start() { + + initializeUserTwoFactorAuthenticationProvidersMap(); + if (apiNameList == null) { long startTime = System.nanoTime(); apiNameList = new ArrayList(); @@ -1362,6 +1418,10 @@ public class AccountManagerImpl extends ManagerBase implements AccountManager, M if (StringUtils.isNotBlank(timezone)) { user.setTimezone(timezone); } + Boolean mandate2FA = updateUserCmd.getMandate2FA(); + if (mandate2FA != null && mandate2FA) { + user.setUser2faEnabled(true); + } _userDao.update(user.getId(), user); return _userAccountDao.findById(user.getId()); } @@ -2378,6 +2438,7 @@ public class AccountManagerImpl extends ManagerBase implements AccountManager, M if (userUUID == null) { userUUID = UUID.randomUUID().toString(); } + UserVO user = _userDao.persist(new UserVO(accountId, userName, encodedPassword, firstName, lastName, email, timezone, userUUID, source)); CallContext.current().putContextParameter(User.class, user.getUuid()); return user; @@ -2650,6 +2711,27 @@ public class AccountManagerImpl extends ManagerBase implements AccountManager, M return keys; } + @Override + public List listUserTwoFactorAuthenticationProviders() { + return userTwoFactorAuthenticationProviders; + } + + @Override + public UserTwoFactorAuthenticator getUserTwoFactorAuthenticationProvider(Long domainId) { + final String name = userTwoFactorAuthenticationDefaultProvider.valueIn(domainId); + return getUserTwoFactorAuthenticationProvider(name); + } + + public UserTwoFactorAuthenticator getUserTwoFactorAuthenticationProvider(final String name) { + if (StringUtils.isEmpty(name)) { + throw new CloudRuntimeException("Two factor authentication provider name is empty"); + } + if (!userTwoFactorAuthenticationProvidersMap.containsKey(name.toLowerCase())) { + throw new CloudRuntimeException(String.format("Failed to find two factor authentication provider by the name: %s.", name)); + } + return userTwoFactorAuthenticationProvidersMap.get(name.toLowerCase()); + } + @Override @DB @ActionEvent(eventType = EventTypes.EVENT_REGISTER_FOR_SECRET_API_KEY, eventDescription = "register for the developer API keys") @@ -3031,7 +3113,11 @@ public class AccountManagerImpl extends ManagerBase implements AccountManager, M @Override public UserAccount getUserAccountById(Long userId) { - return _userAccountDao.findById(userId); + UserAccount userAccount = _userAccountDao.findById(userId); + Map details = _userDetailsDao.listDetailsKeyPairs(userId); + userAccount.setDetails(details); + + return userAccount; } @Override @@ -3114,6 +3200,171 @@ public class AccountManagerImpl extends ManagerBase implements AccountManager, M @Override public ConfigKey[] getConfigKeys() { - return new ConfigKey[] {UseSecretKeyInResponse}; + return new ConfigKey[] {UseSecretKeyInResponse, enableUserTwoFactorAuthentication, + userTwoFactorAuthenticationDefaultProvider, mandateUserTwoFactorAuthentication, userTwoFactorAuthenticationIssuer}; } + + public List getUserTwoFactorAuthenticationProviders() { + return userTwoFactorAuthenticationProviders; + } + + public void setUserTwoFactorAuthenticationProviders(final List userTwoFactorAuthenticationProviders) { + this.userTwoFactorAuthenticationProviders = userTwoFactorAuthenticationProviders; + } + + protected void initializeUserTwoFactorAuthenticationProvidersMap() { + if (userTwoFactorAuthenticationProviders != null) { + for (final UserTwoFactorAuthenticator userTwoFactorAuthenticator : userTwoFactorAuthenticationProviders) { + userTwoFactorAuthenticationProvidersMap.put(userTwoFactorAuthenticator.getName().toLowerCase(), userTwoFactorAuthenticator); + } + } + } + + @Override + public void verifyUsingTwoFactorAuthenticationCode(final String code, final Long domainId, final Long userAccountId) { + + Account caller = CallContext.current().getCallingAccount(); + Account owner = _accountService.getActiveAccountById(caller.getId()); + + checkAccess(caller, null, true, owner); + + UserAccount userAccount = _accountService.getUserAccountById(userAccountId); + if (!userAccount.isUser2faEnabled()) { + throw new CloudRuntimeException(String.format("Two factor authentication is not enabled on the user: %s", userAccount.getUsername())); + } + if (StringUtils.isBlank(userAccount.getUser2faProvider()) || StringUtils.isBlank(userAccount.getKeyFor2fa())) { + throw new CloudRuntimeException(String.format("Two factor authentication is not setup for the user: %s, please setup 2FA before verifying", userAccount.getUsername())); + } + + UserTwoFactorAuthenticator userTwoFactorAuthenticator = getUserTwoFactorAuthenticator(domainId, userAccountId); + try { + userTwoFactorAuthenticator.check2FA(code, userAccount); + UserDetailVO userDetailVO = _userDetailsDao.findDetail(userAccountId, UserDetailVO.Setup2FADetail); + if (userDetailVO != null) { + userDetailVO.setValue(UserAccountVO.Setup2FAstatus.VERIFIED.name()); + _userDetailsDao.update(userDetailVO.getId(), userDetailVO); + } + } catch (CloudTwoFactorAuthenticationException e) { + UserDetailVO userDetailVO = _userDetailsDao.findDetail(userAccountId, "2FAsetupComplete"); + if (userDetailVO != null && userDetailVO.getValue().equals(UserAccountVO.Setup2FAstatus.ENABLED.name())) { + disableTwoFactorAuthentication(userAccountId, caller, owner); + } + throw e; + } + } + + @Override + public UserTwoFactorAuthenticator getUserTwoFactorAuthenticator(Long domainId, Long userAccountId) { + if (userAccountId != null) { + UserAccount userAccount = _accountService.getUserAccountById(userAccountId); + String user2FAProvider = userAccount.getUser2faProvider(); + if (user2FAProvider != null) { + return getUserTwoFactorAuthenticator(user2FAProvider); + } + } + final String name = userTwoFactorAuthenticationDefaultProvider.valueIn(domainId); + return getUserTwoFactorAuthenticator(name); + } + + @Override + public UserTwoFactorAuthenticationSetupResponse setupUserTwoFactorAuthentication(SetupUserTwoFactorAuthenticationCmd cmd) { + String providerName = cmd.getProvider(); + + Account caller = CallContext.current().getCallingAccount(); + Account owner = _accountService.getActiveAccountById(caller.getId()); + + if (Boolean.TRUE.equals(cmd.getEnable())) { + checkAccess(caller, null, true, owner); + Long userId = CallContext.current().getCallingUserId(); + + return enableTwoFactorAuthentication(userId, providerName); + } + + // Admin can disable 2FA of the users + Long userId = cmd.getUserId(); + return disableTwoFactorAuthentication(userId, caller, owner); + } + + protected UserTwoFactorAuthenticationSetupResponse enableTwoFactorAuthentication(Long userId, String providerName) { + UserAccountVO userAccount = _userAccountDao.findById(userId); + UserVO userVO = _userDao.findById(userId); + Long domainId = userAccount.getDomainId(); + if (Boolean.FALSE.equals(enableUserTwoFactorAuthentication.valueIn(domainId)) && Boolean.FALSE.equals(mandateUserTwoFactorAuthentication.valueIn(domainId))) { + throw new CloudRuntimeException("2FA is not enabled for this domain or at global level"); + } + + if (StringUtils.isEmpty(providerName)) { + providerName = userTwoFactorAuthenticationDefaultProvider.valueIn(domainId); + s_logger.debug(String.format("Provider name is not given to setup 2FA, so using the default 2FA provider %s", providerName)); + } + + UserTwoFactorAuthenticator provider = getUserTwoFactorAuthenticationProvider(providerName); + String code = provider.setup2FAKey(userAccount); + UserVO user = _userDao.createForUpdate(); + user.setKeyFor2fa(code); + user.setUser2faProvider(provider.getName()); + user.setUser2faEnabled(true); + _userDao.update(userId, user); + + // 2FA setup will be complete only upon successful verification with 2FA code + UserDetailVO setup2FAstatus = new UserDetailVO(userId, UserDetailVO.Setup2FADetail, UserAccountVO.Setup2FAstatus.ENABLED.name()); + _userDetailsDao.persist(setup2FAstatus); + + UserTwoFactorAuthenticationSetupResponse response = new UserTwoFactorAuthenticationSetupResponse(); + response.setId(userVO.getUuid()); + response.setUsername(userAccount.getUsername()); + response.setSecretCode(code); + + return response; + } + + protected UserTwoFactorAuthenticationSetupResponse disableTwoFactorAuthentication(Long userId, Account caller, Account owner) { + UserVO userVO = null; + if (userId != null) { + userVO = validateUser(userId, caller.getDomainId()); + owner = _accountService.getActiveAccountById(userVO.getAccountId()); + } else { + userId = CallContext.current().getCallingUserId(); + userVO = _userDao.findById(userId); + } + checkAccess(caller, null, true, owner); + + UserVO user = _userDao.createForUpdate(); + user.setKeyFor2fa(null); + user.setUser2faProvider(null); + user.setUser2faEnabled(false); + _userDao.update(userVO.getId(), user); + _userDetailsDao.removeDetail(userId, UserDetailVO.Setup2FADetail); + + UserTwoFactorAuthenticationSetupResponse response = new UserTwoFactorAuthenticationSetupResponse(); + response.setId(userVO.getUuid()); + response.setUsername(userVO.getUsername()); + + return response; + } + + private UserVO validateUser(Long userId, Long domainId) { + UserVO user = null; + if (userId != null) { + user = _userDao.findById(userId); + if (user == null) { + throw new InvalidParameterValueException("Invalid user ID provided"); + } + if (_accountDao.findById(user.getAccountId()).getDomainId() != domainId) { + throw new InvalidParameterValueException("User doesn't belong to the specified account or domain"); + } + } + return user; + } + + public UserTwoFactorAuthenticator getUserTwoFactorAuthenticator(final String name) { + if (StringUtils.isEmpty(name)) { + throw new CloudRuntimeException("UserTwoFactorAuthenticator name provided is empty"); + } + if (!userTwoFactorAuthenticationProvidersMap.containsKey(name.toLowerCase())) { + throw new CloudRuntimeException(String.format("Failed to find UserTwoFactorAuthenticator by the name: %s.", name)); + } + return userTwoFactorAuthenticationProvidersMap.get(name.toLowerCase()); + } + } diff --git a/server/src/main/java/com/cloud/server/auth/UserAuthenticator.java b/server/src/main/java/org/apache/cloudstack/auth/UserAuthenticator.java similarity index 97% rename from server/src/main/java/com/cloud/server/auth/UserAuthenticator.java rename to server/src/main/java/org/apache/cloudstack/auth/UserAuthenticator.java index 895c3c06a61..36d591c3675 100644 --- a/server/src/main/java/com/cloud/server/auth/UserAuthenticator.java +++ b/server/src/main/java/org/apache/cloudstack/auth/UserAuthenticator.java @@ -14,7 +14,7 @@ // KIND, either express or implied. See the License for the // specific language governing permissions and limitations // under the License. -package com.cloud.server.auth; +package org.apache.cloudstack.auth; import java.util.Map; diff --git a/server/src/main/resources/META-INF/cloudstack/core/spring-server-core-managers-context.xml b/server/src/main/resources/META-INF/cloudstack/core/spring-server-core-managers-context.xml index 5e75388547c..a9db15979c9 100644 --- a/server/src/main/resources/META-INF/cloudstack/core/spring-server-core-managers-context.xml +++ b/server/src/main/resources/META-INF/cloudstack/core/spring-server-core-managers-context.xml @@ -44,12 +44,15 @@ + + @@ -58,6 +61,8 @@ + diff --git a/server/src/test/async-job-component.xml b/server/src/test/async-job-component.xml index d1b0ca600d7..3597041f904 100644 --- a/server/src/test/async-job-component.xml +++ b/server/src/test/async-job-component.xml @@ -125,8 +125,8 @@ - - + + diff --git a/server/src/test/java/com/cloud/api/ApiServletTest.java b/server/src/test/java/com/cloud/api/ApiServletTest.java index fa467e877f0..ce1f009d3e8 100644 --- a/server/src/test/java/com/cloud/api/ApiServletTest.java +++ b/server/src/test/java/com/cloud/api/ApiServletTest.java @@ -37,6 +37,8 @@ import org.apache.cloudstack.api.ApiConstants; import org.apache.cloudstack.api.auth.APIAuthenticationManager; import org.apache.cloudstack.api.auth.APIAuthenticationType; import org.apache.cloudstack.api.auth.APIAuthenticator; +import org.apache.cloudstack.api.command.admin.config.ListCfgsByCmd; +import org.apache.cloudstack.framework.config.ConfigKey; import org.junit.After; import org.junit.Assert; import org.junit.Before; @@ -45,10 +47,17 @@ import org.junit.runner.RunWith; import org.mockito.Mock; import org.mockito.Mockito; +import com.cloud.api.auth.ListUserTwoFactorAuthenticatorProvidersCmd; +import com.cloud.api.auth.SetupUserTwoFactorAuthenticationCmd; +import com.cloud.api.auth.ValidateUserTwoFactorAuthenticationCodeCmd; import com.cloud.server.ManagementServer; import com.cloud.user.Account; +import com.cloud.user.AccountManagerImpl; import com.cloud.user.AccountService; import com.cloud.user.User; +import com.cloud.user.UserAccount; +import com.cloud.utils.HttpUtils; +import com.cloud.vm.UserVmManager; import org.mockito.junit.MockitoJUnitRunner; @RunWith(MockitoJUnitRunner.class) @@ -84,6 +93,12 @@ public class ApiServletTest { @Mock ManagementServer managementServer; + @Mock + UserAccount userAccount; + + @Mock + AccountService accountMgr; + StringWriter responseWriter; ApiServlet servlet; @@ -115,6 +130,7 @@ public class ApiServletTest { Field apiServerField = ApiServlet.class.getDeclaredField("apiServer"); apiServerField.setAccessible(true); apiServerField.set(servlet, apiServer); + } /** @@ -273,4 +289,136 @@ public class ApiServletTest { Assert.assertEquals(InetAddress.getByName("127.0.0.1"), ApiServlet.getClientAddress(request)); } + @Test + public void testSkip2FAcheckForAPIs() { + String command = "listZones"; + Map params = new HashMap(); + boolean result = servlet.skip2FAcheckForAPIs(command); + Assert.assertEquals(false, result); + + command = ListCfgsByCmd.APINAME; + params.put(ApiConstants.NAME, new String[] { UserVmManager.AllowUserExpungeRecoverVm.key() }); + result = servlet.skip2FAcheckForAPIs(command); + Assert.assertEquals(false, result); + } + + @Test + public void testDoNotSkip2FAcheckForAPIs() { + String[] commands = new String[] {ApiConstants.LIST_IDPS, ApiConstants.LIST_APIS, + ListUserTwoFactorAuthenticatorProvidersCmd.APINAME, SetupUserTwoFactorAuthenticationCmd.APINAME}; + Map params = new HashMap(); + for (String cmd: commands) { + boolean result = servlet.skip2FAcheckForAPIs(cmd); + Assert.assertEquals(true, result); + } + } + + @Test + public void testSkip2FAcheckForUserWhenAlreadyVerified() { + Mockito.when(session.getAttribute("userid")).thenReturn(1L); + Mockito.when(session.getAttribute(ApiConstants.IS_2FA_VERIFIED)).thenReturn(true); + + boolean result = servlet.skip2FAcheckForUser(session); + Assert.assertEquals(true, result); + } + + @Test + public void testDoNotSkip2FAcheckForUserWhen2FAEnabled() { + servlet.accountMgr = accountMgr; + HttpSession cuurentSession = Mockito.mock(HttpSession.class); + Mockito.when(cuurentSession.getAttribute("userid")).thenReturn(1L); + Mockito.when(cuurentSession.getAttribute(ApiConstants.IS_2FA_VERIFIED)).thenReturn(false); + Mockito.when(accountMgr.getUserAccountById(1L)).thenReturn(userAccount); + Mockito.when(userAccount.isUser2faEnabled()).thenReturn(true); + + boolean result = servlet.skip2FAcheckForUser(cuurentSession); + Assert.assertEquals(false, result); + } + + @Test + public void testDoNotSkip2FAcheckForUserWhen2FAMandated() { + servlet.accountMgr = accountMgr; + HttpSession cuurentSession = Mockito.mock(HttpSession.class); + Mockito.when(cuurentSession.getAttribute("userid")).thenReturn(1L); + Mockito.when(cuurentSession.getAttribute(ApiConstants.IS_2FA_VERIFIED)).thenReturn(false); + + Mockito.when(accountMgr.getUserAccountById(1L)).thenReturn(userAccount); + Mockito.when(userAccount.getDomainId()).thenReturn(1L); + Mockito.when(userAccount.isUser2faEnabled()).thenReturn(false); + + ConfigKey mandateUserTwoFactorAuthentication = Mockito.mock(ConfigKey.class); + AccountManagerImpl.mandateUserTwoFactorAuthentication = mandateUserTwoFactorAuthentication; + Mockito.when(mandateUserTwoFactorAuthentication.valueIn(1L)).thenReturn(false); + + boolean result = servlet.skip2FAcheckForUser(cuurentSession); + Assert.assertEquals(true, result); + } + + @Test + public void testSkip2FAcheckForUserWhen2FAisNotEnabledAndNotMandated() { + servlet.accountMgr = accountMgr; + HttpSession cuurentSession = Mockito.mock(HttpSession.class); + Mockito.when(cuurentSession.getAttribute("userid")).thenReturn(1L); + Mockito.when(cuurentSession.getAttribute(ApiConstants.IS_2FA_VERIFIED)).thenReturn(false); + + Mockito.when(accountMgr.getUserAccountById(1L)).thenReturn(userAccount); + Mockito.when(userAccount.getDomainId()).thenReturn(1L); + Mockito.when(userAccount.isUser2faEnabled()).thenReturn(false); + + ConfigKey enableUserTwoFactorAuthentication = Mockito.mock(ConfigKey.class); + AccountManagerImpl.enableUserTwoFactorAuthentication = enableUserTwoFactorAuthentication; + Mockito.when(enableUserTwoFactorAuthentication.valueIn(1L)).thenReturn(true); + + ConfigKey mandateUserTwoFactorAuthentication = Mockito.mock(ConfigKey.class); + AccountManagerImpl.mandateUserTwoFactorAuthentication = mandateUserTwoFactorAuthentication; + Mockito.when(mandateUserTwoFactorAuthentication.valueIn(1L)).thenReturn(true); + + boolean result = servlet.skip2FAcheckForUser(cuurentSession); + Assert.assertEquals(false, result); + } + + @Test + public void testVerify2FA() throws UnknownHostException { + String command = ValidateUserTwoFactorAuthenticationCodeCmd.APINAME; + Mockito.lenient().when(authenticator.authenticate(Mockito.anyString(), Mockito.anyMap(), Mockito.isA(HttpSession.class), + Mockito.same(InetAddress.getByName("127.0.0.1")), Mockito.anyString(), Mockito.isA(StringBuilder.class), Mockito.isA(HttpServletRequest.class), Mockito.isA(HttpServletResponse.class))).thenReturn("{\"Success\":{}"); + + StringBuilder auditTrailSb = new StringBuilder(); + Map params = new HashMap(); + String responseType = HttpUtils.RESPONSE_TYPE_XML; + boolean result = servlet.verify2FA(session, command, auditTrailSb, params, InetAddress.getByName("192.168.1.1"), + responseType, request, response); + + Assert.assertEquals(true, result); + } + + @Test + public void testVerify2FAWhenAuthenticatorNotFound() throws UnknownHostException { + String command = ValidateUserTwoFactorAuthenticationCodeCmd.APINAME; + Mockito.when(authManager.getAPIAuthenticator(command)).thenReturn(null); + StringBuilder auditTrailSb = new StringBuilder(); + Map params = new HashMap(); + String responseType = HttpUtils.RESPONSE_TYPE_XML; + boolean result = servlet.verify2FA(session, command, auditTrailSb, params, InetAddress.getByName("192.168.1.1"), + responseType, request, response); + + Assert.assertEquals(false, result); + } + + @Test + public void testVerify2FAWhenExpectedCommandIsNotCalled() throws UnknownHostException { + servlet.accountMgr = accountMgr; + String command = "listZones"; + Mockito.when(session.getAttribute("userid")).thenReturn(1L); + Mockito.when(accountMgr.getUserAccountById(1L)).thenReturn(userAccount); + Mockito.when(userAccount.isUser2faEnabled()).thenReturn(true); + + StringBuilder auditTrailSb = new StringBuilder(); + Map params = new HashMap(); + String responseType = HttpUtils.RESPONSE_TYPE_XML; + boolean result = servlet.verify2FA(session, command, auditTrailSb, params, InetAddress.getByName("192.168.1.1"), + responseType, request, response); + + Assert.assertEquals(false, result); + } } diff --git a/server/src/test/java/com/cloud/user/AccountManagerImplTest.java b/server/src/test/java/com/cloud/user/AccountManagerImplTest.java index 2484ead3df4..71127137a9c 100644 --- a/server/src/test/java/com/cloud/user/AccountManagerImplTest.java +++ b/server/src/test/java/com/cloud/user/AccountManagerImplTest.java @@ -21,11 +21,18 @@ import java.net.UnknownHostException; import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.Map; +import java.util.HashMap; import org.apache.cloudstack.acl.SecurityChecker.AccessType; import org.apache.cloudstack.api.command.admin.user.GetUserKeysCmd; import org.apache.cloudstack.api.command.admin.user.UpdateUserCmd; +import org.apache.cloudstack.api.response.UserTwoFactorAuthenticationSetupResponse; +import org.apache.cloudstack.auth.UserAuthenticator; +import org.apache.cloudstack.auth.UserAuthenticator.ActionOnFailedAuthentication; +import org.apache.cloudstack.auth.UserTwoFactorAuthenticator; import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.framework.config.ConfigKey; import org.junit.Assert; import org.junit.Before; import org.junit.Test; @@ -34,8 +41,10 @@ import org.mockito.InOrder; import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.junit.MockitoJUnitRunner; +import static org.mockito.ArgumentMatchers.nullable; import com.cloud.acl.DomainChecker; +import com.cloud.api.auth.SetupUserTwoFactorAuthenticationCmd; import com.cloud.domain.Domain; import com.cloud.domain.DomainVO; import com.cloud.exception.ConcurrentOperationException; @@ -44,8 +53,6 @@ import com.cloud.exception.PermissionDeniedException; import com.cloud.exception.ResourceUnavailableException; import com.cloud.projects.Project; import com.cloud.projects.ProjectAccountVO; -import com.cloud.server.auth.UserAuthenticator; -import com.cloud.server.auth.UserAuthenticator.ActionOnFailedAuthentication; import com.cloud.user.Account.State; import com.cloud.utils.Pair; import com.cloud.utils.exception.CloudRuntimeException; @@ -92,6 +99,14 @@ public class AccountManagerImplTest extends AccountManagetImplTestBase { @Mock PasswordPolicyImpl passwordPolicyMock; + @Mock + ConfigKey enableUserTwoFactorAuthenticationMock; + + @Before + public void setUp() throws Exception { + enableUserTwoFactorAuthenticationMock = Mockito.mock(ConfigKey.class); + accountManagerImpl.enableUserTwoFactorAuthentication = enableUserTwoFactorAuthenticationMock; + } @Before public void beforeTest() { @@ -211,7 +226,7 @@ public class AccountManagerImplTest extends AccountManagetImplTestBase { CallContext.register(callingUser, callingAccount); // Calling account is user account i.e normal account Mockito.when(_listkeyscmd.getID()).thenReturn(1L); Mockito.when(accountManagerImpl.getActiveUser(1L)).thenReturn(userVoMock); - Mockito.when(accountManagerImpl.getUserAccountById(1L)).thenReturn(userAccountVO); + Mockito.when(userAccountDaoMock.findById(1L)).thenReturn(userAccountVO); Mockito.when(userAccountVO.getAccountId()).thenReturn(1L); Mockito.lenient().when(accountManagerImpl.getAccount(Mockito.anyLong())).thenReturn(accountMock); // Queried account - admin account @@ -778,4 +793,185 @@ public class AccountManagerImplTest extends AccountManagetImplTestBase { accountManagerImpl.updateLoginAttemptsWhenIncorrectLoginAttemptsEnabled(userAccountVO, true, allowedAttempts); Mockito.verify(accountManagerImpl).updateLoginAttempts(Mockito.eq(accountId), Mockito.eq(allowedAttempts), Mockito.eq(true)); } + + @Test(expected = CloudRuntimeException.class) + public void testEnableUserTwoFactorAuthenticationWhenDomainlevelSettingisDisabled() { + Long userId = 1L; + + UserAccountVO userAccount = Mockito.mock(UserAccountVO.class); + UserVO userVO = Mockito.mock(UserVO.class); + + Mockito.when(userAccountDaoMock.findById(userId)).thenReturn(userAccount); + Mockito.when(userDaoMock.findById(userId)).thenReturn(userVO); + Mockito.when(userAccount.getDomainId()).thenReturn(1L); + + ConfigKey enableUserTwoFactorAuthentication = Mockito.mock(ConfigKey.class); + AccountManagerImpl.enableUserTwoFactorAuthentication = enableUserTwoFactorAuthentication; + + Mockito.when(enableUserTwoFactorAuthentication.valueIn(1L)).thenReturn(false); + + accountManagerImpl.enableTwoFactorAuthentication(userId, "totp"); + } + + @Test + public void testEnableUserTwoFactorAuthenticationWhenProviderNameIsNullExpectedDefaultProviderTOTP() { + Long userId = 1L; + + UserAccountVO userAccount = Mockito.mock(UserAccountVO.class); + UserVO userVO = Mockito.mock(UserVO.class); + + Mockito.when(userAccountDaoMock.findById(userId)).thenReturn(userAccount); + Mockito.when(userDaoMock.findById(userId)).thenReturn(userVO); + Mockito.when(userAccount.getDomainId()).thenReturn(1L); + + ConfigKey enableUserTwoFactorAuthentication = Mockito.mock(ConfigKey.class); + AccountManagerImpl.enableUserTwoFactorAuthentication = enableUserTwoFactorAuthentication; + Mockito.when(enableUserTwoFactorAuthentication.valueIn(1L)).thenReturn(true); + + UserTwoFactorAuthenticator totpProvider = Mockito.mock(UserTwoFactorAuthenticator.class); + Map userTwoFactorAuthenticationProvidersMap = Mockito.mock(HashMap.class); + Mockito.when(userTwoFactorAuthenticationProvidersMap.containsKey("totp")).thenReturn( true); + Mockito.when(userTwoFactorAuthenticationProvidersMap.get("totp")).thenReturn(totpProvider); + AccountManagerImpl.userTwoFactorAuthenticationProvidersMap = userTwoFactorAuthenticationProvidersMap; + Mockito.when(totpProvider.setup2FAKey(userAccount)).thenReturn("EUJEAEDVOURFZTE6OGWVTJZMI54QGMIL"); + Mockito.when(userDaoMock.createForUpdate()).thenReturn(userVoMock); + Mockito.when(userDaoMock.update(userId, userVoMock)).thenReturn(true); + + UserTwoFactorAuthenticationSetupResponse response = accountManagerImpl.enableTwoFactorAuthentication(userId, null); + + Assert.assertEquals("EUJEAEDVOURFZTE6OGWVTJZMI54QGMIL", response.getSecretCode()); + } + + @Test + public void testEnableUserTwoFactorAuthentication() { + Long userId = 1L; + + UserAccountVO userAccount = Mockito.mock(UserAccountVO.class); + UserVO userVO = Mockito.mock(UserVO.class); + + Mockito.when(userAccountDaoMock.findById(userId)).thenReturn(userAccount); + Mockito.when(userDaoMock.findById(userId)).thenReturn(userVO); + Mockito.when(userAccount.getDomainId()).thenReturn(1L); + + ConfigKey enableUserTwoFactorAuthentication = Mockito.mock(ConfigKey.class); + AccountManagerImpl.enableUserTwoFactorAuthentication = enableUserTwoFactorAuthentication; + Mockito.when(enableUserTwoFactorAuthentication.valueIn(1L)).thenReturn(true); + + UserTwoFactorAuthenticator totpProvider = Mockito.mock(UserTwoFactorAuthenticator.class); + Map userTwoFactorAuthenticationProvidersMap = Mockito.mock(HashMap.class); + Mockito.when(userTwoFactorAuthenticationProvidersMap.containsKey("totp")).thenReturn( true); + Mockito.when(userTwoFactorAuthenticationProvidersMap.get("totp")).thenReturn(totpProvider); + AccountManagerImpl.userTwoFactorAuthenticationProvidersMap = userTwoFactorAuthenticationProvidersMap; + Mockito.when(totpProvider.setup2FAKey(userAccount)).thenReturn("EUJEAEDVOURFZTE6OGWVTJZMI54QGMIL"); + Mockito.when(userDaoMock.createForUpdate()).thenReturn(userVoMock); + Mockito.when(userDaoMock.update(userId, userVoMock)).thenReturn(true); + + UserTwoFactorAuthenticationSetupResponse response = accountManagerImpl.enableTwoFactorAuthentication(userId, "totp"); + + Assert.assertEquals("EUJEAEDVOURFZTE6OGWVTJZMI54QGMIL", response.getSecretCode()); + } + + @Test + public void testDisableUserTwoFactorAuthentication() { + Long userId = 1L; + + UserVO userVO = Mockito.mock(UserVO.class); + Account caller = Mockito.mock(Account.class); + + AccountVO accountMock = Mockito.mock(AccountVO.class); + Mockito.doNothing().when(accountManagerImpl).checkAccess(nullable(Account.class), Mockito.isNull(), nullable(Boolean.class), nullable(Account.class)); + + Mockito.when(caller.getDomainId()).thenReturn(1L); + Mockito.when(userDaoMock.findById(userId)).thenReturn(userVO); + Mockito.when(userVO.getAccountId()).thenReturn(1L); + Mockito.when(_accountDao.findById(1L)).thenReturn(accountMock); + Mockito.when(accountMock.getDomainId()).thenReturn(1L); + Mockito.when(_accountService.getActiveAccountById(1L)).thenReturn(caller); + + userVoMock.setKeyFor2fa("EUJEAEDVOURFZTE6OGWVTJZMI54QGMIL"); + userVoMock.setUser2faProvider("totp"); + userVoMock.setUser2faEnabled(true); + + Mockito.when(userDaoMock.createForUpdate()).thenReturn(userVoMock); + + UserTwoFactorAuthenticationSetupResponse response = accountManagerImpl.disableTwoFactorAuthentication(userId, caller, caller); + + Assert.assertNull(response.getSecretCode()); + Assert.assertNull(userVoMock.getKeyFor2fa()); + Assert.assertNull(userVoMock.getUser2faProvider()); + } + + @Test(expected = CloudRuntimeException.class) + public void testVerify2FAcodeWhen2FAisNotEnabled() { + AccountVO accountMock = Mockito.mock(AccountVO.class); + Account caller = CallContext.current().getCallingAccount(); + Mockito.when(caller.getId()).thenReturn(1L); + Mockito.lenient().when(_accountService.getActiveAccountById(1L)).thenReturn(accountMock); + Mockito.when(_accountService.getUserAccountById(1L)).thenReturn(userAccountVO); + Mockito.when(userAccountVO.isUser2faEnabled()).thenReturn(false); + + accountManagerImpl.verifyUsingTwoFactorAuthenticationCode("352352", 1L, 1L); + } + + @Test(expected = CloudRuntimeException.class) + public void testVerify2FAcodeWhen2FAisNotSetup() { + AccountVO accountMock = Mockito.mock(AccountVO.class); + Account caller = CallContext.current().getCallingAccount(); + Mockito.when(caller.getId()).thenReturn(1L); + Mockito.lenient().when(_accountService.getActiveAccountById(1L)).thenReturn(accountMock); + Mockito.when(_accountService.getUserAccountById(1L)).thenReturn(userAccountVO); + Mockito.when(userAccountVO.isUser2faEnabled()).thenReturn(true); + Mockito.when(userAccountVO.getUser2faProvider()).thenReturn(null); + + accountManagerImpl.verifyUsingTwoFactorAuthenticationCode("352352", 1L, 1L); + } + + @Test + public void testVerify2FAcode() { + AccountVO accountMock = Mockito.mock(AccountVO.class); + Account caller = CallContext.current().getCallingAccount(); + Mockito.when(caller.getId()).thenReturn(1L); + Mockito.lenient().when(_accountService.getActiveAccountById(1L)).thenReturn(accountMock); + Mockito.when(_accountService.getUserAccountById(1L)).thenReturn(userAccountVO); + Mockito.when(userAccountVO.isUser2faEnabled()).thenReturn(true); + Mockito.when(userAccountVO.getUser2faProvider()).thenReturn("staticpin"); + Mockito.when(userAccountVO.getKeyFor2fa()).thenReturn("352352"); + + UserTwoFactorAuthenticator staticpinProvider = Mockito.mock(UserTwoFactorAuthenticator.class); + Map userTwoFactorAuthenticationProvidersMap = Mockito.mock(HashMap.class); + Mockito.when(userTwoFactorAuthenticationProvidersMap.containsKey("staticpin")).thenReturn( true); + Mockito.when(userTwoFactorAuthenticationProvidersMap.get("staticpin")).thenReturn(staticpinProvider); + AccountManagerImpl.userTwoFactorAuthenticationProvidersMap = userTwoFactorAuthenticationProvidersMap; + + accountManagerImpl.verifyUsingTwoFactorAuthenticationCode("352352", 1L, 1L); + } + + @Test + public void testEnable2FAcode() { + SetupUserTwoFactorAuthenticationCmd cmd = Mockito.mock(SetupUserTwoFactorAuthenticationCmd.class); + Mockito.when(cmd.getProvider()).thenReturn("staticpin"); + + AccountVO accountMock = Mockito.mock(AccountVO.class); + Mockito.when(callingAccount.getId()).thenReturn(1L); + Mockito.when(callingUser.getId()).thenReturn(1L); + CallContext.register(callingUser, callingAccount); // Calling account is user account i.e normal account + Mockito.lenient().when(_accountService.getActiveAccountById(1L)).thenReturn(accountMock); + Mockito.when(userAccountDaoMock.findById(1L)).thenReturn(userAccountVO); + Mockito.when(userDaoMock.findById(1L)).thenReturn(userVoMock); + Mockito.when(userAccountVO.getDomainId()).thenReturn(1L); + Mockito.when(enableUserTwoFactorAuthenticationMock.valueIn(1L)).thenReturn(true); + Mockito.when(cmd.getEnable()).thenReturn(true); + + UserTwoFactorAuthenticator staticpinProvider = Mockito.mock(UserTwoFactorAuthenticator.class); + Map userTwoFactorAuthenticationProvidersMap = Mockito.mock(HashMap.class); + Mockito.when(userTwoFactorAuthenticationProvidersMap.containsKey("staticpin")).thenReturn( true); + Mockito.when(userTwoFactorAuthenticationProvidersMap.get("staticpin")).thenReturn(staticpinProvider); + Mockito.when(staticpinProvider.setup2FAKey(userAccountVO)).thenReturn("345543"); + Mockito.when(userDaoMock.createForUpdate()).thenReturn(userVoMock); + AccountManagerImpl.userTwoFactorAuthenticationProvidersMap = userTwoFactorAuthenticationProvidersMap; + + UserTwoFactorAuthenticationSetupResponse response = accountManagerImpl.setupUserTwoFactorAuthentication(cmd); + + Assert.assertEquals("345543", response.getSecretCode()); + } } diff --git a/server/src/test/java/com/cloud/user/AccountManagetImplTestBase.java b/server/src/test/java/com/cloud/user/AccountManagetImplTestBase.java index 556e0ced0ae..7648ec155bc 100644 --- a/server/src/test/java/com/cloud/user/AccountManagetImplTestBase.java +++ b/server/src/test/java/com/cloud/user/AccountManagetImplTestBase.java @@ -23,12 +23,14 @@ import java.util.Map; import org.apache.cloudstack.acl.SecurityChecker; import org.apache.cloudstack.affinity.dao.AffinityGroupDao; +import org.apache.cloudstack.auth.UserAuthenticator; import org.apache.cloudstack.context.CallContext; import org.apache.cloudstack.engine.orchestration.service.NetworkOrchestrationService; import org.apache.cloudstack.engine.service.api.OrchestrationService; import org.apache.cloudstack.framework.config.dao.ConfigurationDao; import org.apache.cloudstack.framework.messagebus.MessageBus; import org.apache.cloudstack.region.gslb.GlobalLoadBalancerRuleDao; +import org.apache.cloudstack.resourcedetail.dao.UserDetailsDao; import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -60,7 +62,6 @@ import com.cloud.network.vpn.Site2SiteVpnManager; import com.cloud.projects.ProjectManager; import com.cloud.projects.dao.ProjectAccountDao; import com.cloud.projects.dao.ProjectDao; -import com.cloud.server.auth.UserAuthenticator; import com.cloud.service.dao.ServiceOfferingDao; import com.cloud.storage.VolumeApiService; import com.cloud.storage.dao.SnapshotDao; @@ -92,6 +93,8 @@ public class AccountManagetImplTestBase { @Mock UserDao userDaoMock; @Mock + UserDetailsDao userDetailsDaoMock; + @Mock InstanceGroupDao _vmGroupDao; @Mock UserAccountDao userAccountDaoMock; @@ -196,6 +199,8 @@ public class AccountManagetImplTestBase { AccountManagerImpl accountManagerImpl; @Mock UsageEventDao _usageEventDao; + @Mock + AccountService _accountService; @Before public void setup() { diff --git a/server/src/test/java/com/cloud/user/MockAccountManagerImpl.java b/server/src/test/java/com/cloud/user/MockAccountManagerImpl.java index 8e2dc7dc54d..b8a1af82819 100644 --- a/server/src/test/java/com/cloud/user/MockAccountManagerImpl.java +++ b/server/src/test/java/com/cloud/user/MockAccountManagerImpl.java @@ -22,9 +22,12 @@ import java.net.InetAddress; import javax.naming.ConfigurationException; +import com.cloud.api.auth.SetupUserTwoFactorAuthenticationCmd; import org.apache.cloudstack.api.command.admin.account.CreateAccountCmd; import org.apache.cloudstack.api.command.admin.user.GetUserKeysCmd; import org.apache.cloudstack.api.command.admin.user.MoveUserCmd; +import org.apache.cloudstack.api.response.UserTwoFactorAuthenticationSetupResponse; +import org.apache.cloudstack.auth.UserTwoFactorAuthenticator; import org.apache.cloudstack.framework.config.ConfigKey; import org.springframework.stereotype.Component; @@ -137,6 +140,21 @@ public class MockAccountManagerImpl extends ManagerBase implements Manager, Acco return false; } + @Override + public UserTwoFactorAuthenticator getUserTwoFactorAuthenticator(Long domainId, Long userAccountId) { + return null; + } + + @Override + public void verifyUsingTwoFactorAuthenticationCode(String code, Long domainId, Long userAccountId) { + + } + + @Override + public UserTwoFactorAuthenticationSetupResponse setupUserTwoFactorAuthentication(SetupUserTwoFactorAuthenticationCmd cmd) { + return null; + } + @Override public boolean isAdmin(Long accountId) { // TODO Auto-generated method stub @@ -434,6 +452,16 @@ public class MockAccountManagerImpl extends ManagerBase implements Manager, Acco return null; } + @Override + public List listUserTwoFactorAuthenticationProviders() { + return null; + } + + @Override + public UserTwoFactorAuthenticator getUserTwoFactorAuthenticationProvider(Long domainId) { + return null; + } + @Override public void checkAccess(User user, ControlledEntity entity) throws PermissionDeniedException { diff --git a/test/integration/smoke/test_2fa.py b/test/integration/smoke/test_2fa.py new file mode 100644 index 00000000000..108c206a2cc --- /dev/null +++ b/test/integration/smoke/test_2fa.py @@ -0,0 +1,294 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +""" P1 tests for Account +""" +# Import Local Modules +from marvin.cloudstackTestCase import cloudstackTestCase +from marvin.lib.utils import (random_gen, + cleanup_resources, + validateList) +from marvin.cloudstackAPI import * +from marvin.lib.base import (Domain, + Account, + ServiceOffering, + VirtualMachine, + Network, + User, + Template, + Role) +from marvin.lib.common import (get_domain, + get_zone, + get_test_template, + list_accounts, + list_virtual_machines, + list_service_offering, + list_templates, + list_users, + wait_for_cleanup) +from nose.plugins.attrib import attr +from marvin.cloudstackException import CloudstackAPIException +from marvin.codes import PASS +import time + +from pyVmomi.VmomiSupport import GetVersionFromVersionUri + +class Services: + + """Test Account Services + """ + + def __init__(self): + self.services = { + "domain": { + "name": "Domain", + }, + "account": { + "email": "test@test.com", + "firstname": "Test", + "lastname": "User", + "username": "test", + # Random characters are appended for unique + # username + "password": "fr3sca", + }, + "role": { + "name": "User Role", + "type": "User", + "description": "User Role created by Marvin test" + }, + "user": { + "email": "user@test.com", + "firstname": "User", + "lastname": "User", + "username": "User", + # Random characters are appended for unique + # username + "password": "fr3sca", + }, + "service_offering": { + "name": "Tiny Instance", + "displaytext": "Tiny Instance", + "cpunumber": 1, + "cpuspeed": 100, + # in MHz + "memory": 128, + # In MBs + }, + "virtual_machine": { + "displayname": "Test VM", + "username": "root", + "password": "password", + "ssh_port": 22, + "hypervisor": 'XenServer', + # Hypervisor type should be same as + # hypervisor type of cluster + "privateport": 22, + "publicport": 22, + "protocol": 'TCP', + }, + "template": { + "displaytext": "Public Template", + "name": "Public template", + "ostype": 'CentOS 5.6 (64-bit)', + "url": "", + "hypervisor": '', + "format": '', + "isfeatured": True, + "ispublic": True, + "isextractable": True, + "templatefilter": "self" + }, + "ostype": 'CentOS 5.6 (64-bit)', + "sleep": 60, + "timeout": 10, + } + +class TestUserLogin(cloudstackTestCase): + + @classmethod + def setUpClass(cls): + cls.testClient = super(TestUserLogin, cls).getClsTestClient() + cls.api_client = cls.testClient.getApiClient() + + cls.services = Services().services + cls.domain = get_domain(cls.api_client) + cls.zone = get_zone(cls.api_client, cls.testClient.getZoneForTests()) + cls.services['mode'] = cls.zone.networktype + cls._cleanup = [] + return + + @classmethod + def tearDownClass(cls): + super(TestUserLogin,cls).tearDownClass() + + def setUp(self): + self.apiclient = self.testClient.getApiClient() + self.dbclient = self.testClient.getDbConnection() + + self.debug("Enabling 2FA in global setting") + updateConfigurationCmd = updateConfiguration.updateConfigurationCmd() + updateConfigurationCmd.name = "enable.user.2fa" + updateConfigurationCmd.value = "true" + updateConfigurationResponse = self.apiclient.updateConfiguration( + updateConfigurationCmd) + + self.cleanup = [] + return + + def tearDown(self): + self.debug("Disable 2FA in global setting") + updateConfigurationCmd = updateConfiguration.updateConfigurationCmd() + updateConfigurationCmd.name = "enable.user.2fa" + updateConfigurationCmd.value = "false" + updateConfigurationResponse = self.apiclient.updateConfiguration( + updateConfigurationCmd) + + super(TestUserLogin,self).tearDown() + + @attr(tags=["login", "accounts", "simulator", "advanced", + "advancedns", "basic", "eip", "sg"]) + def test_2FA_enabled(self): + """Test if Login API does not return UUID's + """ + + # Steps for test scenario + # 1. create a user account + # 2. login to the user account with given credentials (loginCmd) + # 3. verify login response + + # Setup Global settings + + self.debug("Mandate 2FA in global setting") + updateConfigurationCmd = updateConfiguration.updateConfigurationCmd() + updateConfigurationCmd.name = "mandate.user.2fa" + updateConfigurationCmd.value = "true" + updateConfigurationResponse = self.apiclient.updateConfiguration( + updateConfigurationCmd) + + self.debug("Creating an user account..") + self.account = Account.create( + self.apiclient, + self.services["account"], + domainid=self.domain.id + ) + self.cleanup.append(self.account) + + self.debug("Logging into the cloudstack with login API") + response = User.login( + self.apiclient, + username=self.account.name, + password=self.services["account"]["password"] + ) + + self.debug("Login API response: %s" % response) + + self.assertEqual( + response.is2faenabled, + "true", + "2FA enabled for user" + ) + + self.debug("Remove mandating 2FA in global setting") + updateConfigurationCmd = updateConfiguration.updateConfigurationCmd() + updateConfigurationCmd.name = "mandate.user.2fa" + updateConfigurationCmd.value = "false" + updateConfigurationResponse = self.apiclient.updateConfiguration( + updateConfigurationCmd) + + return + + @attr(tags=["login", "accounts", "simulator", "advanced", + "advancedns", "basic", "eip", "sg"]) + def test_2FA_setup(self): + """Test if Login API does not return UUID's + """ + + # Steps for test scenario + # 1. create a user account + # 2. login to the user account with given credentials (loginCmd) + # 3. verify login response for 2fa + # 4. setup 2fa for the user + # 5. verify the code in the setup 2fa response + # 6. test disable 2FA + + # Setup Global settings + + self.debug("Mandate 2FA in global setting") + updateConfigurationCmd = updateConfiguration.updateConfigurationCmd() + updateConfigurationCmd.name = "mandate.user.2fa" + updateConfigurationCmd.value = "true" + updateConfigurationResponse = self.apiclient.updateConfiguration( + updateConfigurationCmd) + + self.debug("Creating an user account..") + self.account = Account.create( + self.apiclient, + self.services["account"], + domainid=self.domain.id + ) + self.cleanup.append(self.account) + + self.debug("Logging into the cloudstack with login API") + response = User.login( + self.apiclient, + username=self.account.name, + password=self.services["account"]["password"] + ) + + self.debug("Login API response: %s" % response) + + self.assertEqual( + response.is2faenabled, + "true", + "2FA enabled for user" + ) + + self.user = self.account.user[0] + self.user_apiclient = self.testClient.getUserApiClient( + self.user.username, self.domain.id + ) + + setup2faCmd = setupUserTwoFactorAuthentication.setupUserTwoFactorAuthenticationCmd() + setup2faCmd.provider = "staticpin" + setup2faResponse = self.user_apiclient.setupUserTwoFactorAuthentication( + setup2faCmd) + + self.assertNotEqual( + setup2faResponse.secretcode, + None, + "2FA enabled for user" + ) + + disable2faCmd = setupUserTwoFactorAuthentication.setupUserTwoFactorAuthenticationCmd() + disable2faCmd.enable = "false" + disable2faResponse = self.user_apiclient.setupUserTwoFactorAuthentication( + disable2faCmd) + + self.assertEqual( + disable2faResponse.secretcode, + None, + "2FA disabled for user" + ) + + self.debug("Remove mandating 2FA in global setting") + updateConfigurationCmd = updateConfiguration.updateConfigurationCmd() + updateConfigurationCmd.name = "mandate.user.2fa" + updateConfigurationCmd.value = "false" + updateConfigurationResponse = self.apiclient.updateConfiguration( + updateConfigurationCmd) + + return diff --git a/ui/package-lock.json b/ui/package-lock.json index fbfb4ca8020..97cebc39d28 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -2840,6 +2840,11 @@ "source-map": "^0.6.1" } }, + "@types/uuid": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-8.3.4.tgz", + "integrity": "sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==" + }, "@types/webpack": { "version": "4.41.32", "resolved": "https://registry.npmjs.org/@types/webpack/-/webpack-4.41.32.tgz", @@ -6973,6 +6978,16 @@ "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", "integrity": "sha1-wKHS86cJLgN3S/qD8UwPxXkKhmc=" }, + "chart.js": { + "version": "3.9.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-3.9.1.tgz", + "integrity": "sha512-Ro2JbLmvg83gXF5F4sniaQ+lTbSv18E+TIf2cOeiH1Iqd2PGFOtem+DUufMZsCJwFE7ywPOpfXFBwRTGq7dh6w==" + }, + "chartjs-adapter-moment": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/chartjs-adapter-moment/-/chartjs-adapter-moment-1.0.0.tgz", + "integrity": "sha512-PqlerEvQcc5hZLQ/NQWgBxgVQ4TRdvkW3c/t+SUEQSj78ia3hgLkf2VZ2yGJtltNbEEFyYGm+cA6XXevodYvWA==" + }, "check-types": { "version": "8.0.3", "resolved": "https://registry.npmjs.org/check-types/-/check-types-8.0.3.tgz", @@ -18424,6 +18439,11 @@ "integrity": "sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc=", "dev": true }, + "qrious": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/qrious/-/qrious-4.0.2.tgz", + "integrity": "sha512-xWPJIrK1zu5Ypn898fBp8RHkT/9ibquV2Kv24S/JY9VYEhMBMKur1gHVsOiNUh7PHP9uCgejjpZUHUIXXKoU/g==" + }, "qs": { "version": "6.5.3", "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.3.tgz", @@ -22073,6 +22093,19 @@ "@vue/shared": "3.2.31" } }, + "vue-chartjs": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-4.1.2.tgz", + "integrity": "sha512-QSggYjeFv/L4jFSBQpX8NzrAvX0B+Ha6nDgxkTG8tEXxYOOTwKI4phRLe+B4f+REnkmg7hgPY24R0cixZJyXBg==" + }, + "vue-clipboard2": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/vue-clipboard2/-/vue-clipboard2-0.3.3.tgz", + "integrity": "sha512-aNWXIL2DKgJyY/1OOeITwAQz1fHaCIGvUFHf9h8UcoQBG5a74MkdhS/xqoYe7DNZdQmZRL+TAdIbtUs9OyVjbw==", + "requires": { + "clipboard": "^2.0.0" + } + }, "vue-codemod": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/vue-codemod/-/vue-codemod-0.0.5.tgz", @@ -22345,6 +22378,21 @@ "loader-utils": "^2.0.0" } }, + "vue-qrious": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vue-qrious/-/vue-qrious-3.1.0.tgz", + "integrity": "sha512-qC5jw94b/VbUHFxYfumwqhSXKBJNEmaimhpwEmudqOiORMd5yPCFn/mPInnP5nWobvhvcV+S+U3Ger6w2dLyfQ==", + "requires": { + "tslib": "^2.4.0" + }, + "dependencies": { + "tslib": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", + "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==" + } + } + }, "vue-router": { "version": "4.0.14", "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.0.14.tgz", @@ -22416,6 +22464,22 @@ "is-plain-object": "3.0.1" } }, + "vue-uuid": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/vue-uuid/-/vue-uuid-3.0.0.tgz", + "integrity": "sha512-+5DP857xVmTHYd00dMC1c1gVg/nxG6+K4Lepojv9ckHt8w0fDpGc5gQCCttS9D+AkSkTJgb0cekidKjTWu5OQQ==", + "requires": { + "@types/uuid": "^8.3.4", + "uuid": "^8.3.2" + }, + "dependencies": { + "uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" + } + } + }, "vue-web-storage": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/vue-web-storage/-/vue-web-storage-6.1.0.tgz", diff --git a/ui/package.json b/ui/package.json index 09186f544ea..55a9c96b6c3 100644 --- a/ui/package.json +++ b/ui/package.json @@ -53,12 +53,14 @@ "moment": "^2.26.0", "npm-check-updates": "^6.0.1", "nprogress": "^0.2.0", + "qrious": "^4.0.2", "vue": "^3.2.31", "vue-chartjs": "^4.0.7", "vue-clipboard2": "^0.3.1", "vue-cropper": "^1.0.2", "vue-i18n": "^9.1.6", "vue-loader": "^16.2.0", + "vue-qrious": "^3.1.0", "vue-router": "^4.0.14", "vue-uuid": "^3.0.0", "vue-web-storage": "^6.1.0", diff --git a/ui/public/locales/en.json b/ui/public/locales/en.json index 4969f283900..1b2c7fb1245 100644 --- a/ui/public/locales/en.json +++ b/ui/public/locales/en.json @@ -109,6 +109,8 @@ "label.action.edit.instance": "Edit instance", "label.action.edit.iso": "Edit ISO", "label.action.edit.zone": "Edit zone", +"label.action.enable.two.factor.authentication": "Enabled Two factor authentication", +"label.action.verify.two.factor.authentication": "Verified Two factor authentication", "label.action.enable.account": "Enable account", "label.action.enable.cluster": "Enable cluster", "label.action.enable.maintenance.mode": "Enable maintenance mode", @@ -142,6 +144,7 @@ "label.action.reboot.systemvm": "Reboot system VM", "label.action.recover.volume": "Recover volume", "label.action.recurring.snapshot": "Recurring snapshots", +"label.action.disable.2FA.user.auth": "Disable User Two Factor Authentication", "label.action.register.iso": "Register ISO", "label.action.register.template": "Register template from URL", "label.action.release.ip": "Release IP", @@ -160,6 +163,7 @@ "label.action.router.health.checks": "Get health checks result", "label.action.run.diagnostics": "Run diagnostics", "label.action.secure.host": "Provision host security keys", +"label.action.setup.2FA.user.auth": "Setup User Two Factor Authentication", "label.action.start.instance": "Start instance", "label.action.start.router": "Start router", "label.action.start.systemvm": "Start system VM", @@ -458,6 +462,7 @@ "label.configuration": "Configuration", "label.configure": "Configure", "label.configure.health.monitor": "Configure Health Monitor", +"label.configure.app": "Configure the App", "label.configure.ldap": "Configure LDAP", "label.configure.ovs": "Configure Ovs", "label.configure.sticky.policy": "Configure sticky policy", @@ -776,6 +781,8 @@ "label.endipv6": "IPv6 end IP", "label.endpoint": "Endpoint", "label.endport": "End port", +"label.enter.code": "Enter 2FA code to verify", +"label.enter.static.pin": "Enter static PIN to verify", "label.enter.token": "Enter token", "label.entityid": "Entity", "label.entitytype": "Entity Type", @@ -1014,6 +1021,7 @@ "label.iqn": "Target IQN", "label.is.in.progress": "is in progress", "label.is.shared": "Is shared", +"label.is2faenabled": "Is 2FA enabled", "label.isadvanced": "Show advanced settings", "label.iscsi": "iSCSI", "label.iscustomized": "Custom disk size", @@ -1703,6 +1711,7 @@ "label.select.ps": "Select primary storage", "label.select.tier": "Select tier", "label.select.zones": "Select zones", +"label.select.2fa.provider": "Select the provider", "label.self": "Mine", "label.selfexecutable": "Self", "label.semanticversion": "Semantic version", @@ -1942,6 +1951,10 @@ "label.transportzoneuuid": "Transport zone UUID", "label.try.again": "Try again", "label.tuesday": "Tuesday", +"label.two.factor.authentication.secret.key": "Your Two factor authentication secret key", +"label.two.factor.authentication.static.pin": "Your Two factor authentication static PIN", +"label.two.factor.authentication": "Two Factor Authentication", +"label.2FA": "2FA", "label.tungsten.fabric": "Tungsten Fabric", "label.tungsten.fabric.provider": "Tungsten Fabric Provider", "label.tungsten.fabric.routing": "Tungsten Fabric Routing", @@ -2037,6 +2050,7 @@ "label.vcenterpassword": "vCenter password", "label.vcenterusername": "vCenter username", "label.vcsdeviceid": "ID", +"label.verify": "Verify", "label.version": "Version", "label.versions": "Versions", "label.vgpu": "VGPU", @@ -2188,6 +2202,8 @@ "message.action.destroy.instance.with.backups": "Please confirm that you want to destroy the instance. There may be backups associated with the instance which will not be deleted.", "message.action.destroy.systemvm": "Please confirm that you want to destroy the System VM.", "message.action.destroy.volume": "Please confirm that you want to destroy the volume.", +"message.action.disable.2FA.user.auth": "Please confirm that you want to disable user two factor authentication.", +"message.action.about.mandate.and.disable.2FA.user.auth": "Two factor authentication is mandated for the user, if this is disabled now user will need to setup two factor authentication again during next login.

Please confirm that you want to disable.", "message.action.disable.cluster": "Please confirm that you want to disable this cluster.", "message.action.disable.physical.network": "Please confirm that you want to disable this physical network.", "message.action.disable.pod": "Please confirm that you want to disable this pod.", @@ -2498,6 +2514,7 @@ "message.error.add.tungsten.routing.policy": "Adding Tungsten-Fabric Routing Policy failed", "message.error.agent.password": "Please enter agent password.", "message.error.agent.username": "Please enter agent username.", +"message.error.authentication.code": "Please enter authentication code.", "message.error.apply.network.policy": "Applying Network Policy failed", "message.error.apply.tungsten.tag": "Applying Tag failed", "message.error.binaries.iso.url": "Please enter binaries ISO URL.", @@ -2516,6 +2533,8 @@ "message.error.delete.tungsten.tag": "Removing Tag failed", "message.error.description": "Please enter description.", "message.error.discovering.feature": "Exception caught while discovering features.", +"message.error.setup.2fa": "2FA setup failed while verifying the code, please retry.", +"message.error.verifying.2fa": "Unable to verify 2FA, please retry.", "message.error.display.text": "Please enter display text.", "message.error.duration.less.than.interval": "The duration in Autoscale policy cannot be less than interval", "message.error.enable.saml": "Unable to find users IDs to enable SAML single sign on, kindly enable it manually.", @@ -2951,6 +2970,21 @@ "message.update.autoscale.vm.profile.failed": "Failed to update autoscale vm profile", "message.update.condition.failed": "Failed to update condition", "message.update.condition.processing": "Updating condition...", +"message.two.factor.authorization.failed": "Unable to verify 2FA with provided code, please retry.", +"message.two.fa.auth": "Open the two-factor authentication app on your mobile device to view your authentication code.", +"message.two.fa.auth.register.account": "Open the two-factor authentication application and scan the QR code add the user account.", +"message.two.fa.static.pin.part1": "If you can't scan the QR code, ", +"message.two.fa.static.pin.part2": "Click here to view the secret code", +"message.two.fa.auth.staticpin": "
You have configured 2FA for security verification.
Enter the static PIN generated during the 2FA setup to verify.", +"message.two.fa.auth.totp": "
You have configured 2FA for security verification.
Open the TOTP authenticator application on your device and enter the authentication code.", +"message.two.fa.register.account": "1. Open the TOTP authenticator application on your device.
2. Scan the below QR code to add the user.
3. If you cannot scan the QR code, enter the setup key manually.
4. Verification of the 2FA code is mandatory to complete the 2FA setup.", +"message.two.fa.staticpin": "1. Use the generated static PIN as 2FA code for two factor authentication.
2. Save this static PIN / 2FA code and do not share it. This code will be used for subsequent logins.
3. Verification of the 2FA code is mandatory to complete the 2FA setup.", +"message.two.fa.register.account.login.page": "1. Open the TOTP authenticator application on your device.
2. Scan the below QR code to add the user.
3. If you cannot scan the QR code, enter the setup key manually.
4. Verify the 2FA code to continue to login.", +"message.two.fa.staticpin.login.page": "1. Use the generated static PIN as 2FA code for two factor authentication.
2. Save this static PIN / 2FA code and do not share it. This code will be used for subsequent logins.
3. Verify the 2FA code to continue to login.", +"message.two.fa.login.page": "Two factor authentication (2FA) is enabled on your account, you need to select a 2FA provider and setup. 2FA is an extra layer of security to your account. Once setup is done, on every login you will be prompted to enter 2FA code.
", +"message.two.fa.setup.page": "Two factor authentication (2FA) is an extra layer of security to your account.
Once setup is done, on every login you will be prompted to enter the 2FA code.
", +"message.two.fa.view.setup.key": "Click here to view the setup key", +"message.two.fa.view.static.pin": "Click here to view the static PIN", "message.update.ipaddress.processing": "Updating IP Address...", "message.update.resource.count": "Please confirm that you want to update resource counts for this account.", "message.update.resource.count.domain": "Please confirm that you want to update resource counts for this domain.", diff --git a/ui/src/components/page/GlobalLayout.vue b/ui/src/components/page/GlobalLayout.vue index 571a68e828e..3f8779d6062 100644 --- a/ui/src/components/page/GlobalLayout.vue +++ b/ui/src/components/page/GlobalLayout.vue @@ -17,9 +17,7 @@