Repository: getmoto/moto
Pull request #6853 (2023-09-26 15:09:29)

CognitoIDP: clean MFA settings capability and enable multiple MFA methods
Fix for issue #6852, with the following changes:

- add capacity to disable MFA settings and clear relaated properties
- add capacity to enable MFA with SMS and TOTP in parallel

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/getmoto/moto/pull/6853?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) Report
> Merging [#6853](https://app.codecov.io/gh/getmoto/moto/pull/6853?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) (3435f5b) into [master](https://app.codecov.io/gh/getmoto/moto/commit/178f2b8c03d7bf45ac520b3c03576e6a17494b5c?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) (178f2b8) will **increase** coverage by `0.00%`.
> The diff coverage is `100.00%`.

```diff
@@           Coverage Diff           @@
##           master    #6853   +/-   ##
=======================================
  Coverage   96.20%   96.21%           
=======================================
  Files         816      816           
  Lines       80118    80115    -3     
=======================================
+ Hits        77081    77085    +4     
+ Misses       3037     3030    -7     
```

| [Flag](https://app.codecov.io/gh/getmoto/moto/pull/6853/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) | Coverage Δ | |
|---|---|---|
| [servertests](https://app.codecov.io/gh/getmoto/moto/pull/6853/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) | `37.04% <6.25%> (+<0.01%)` | :arrow_up: |
| [unittests](https://app.codecov.io/gh/getmoto/moto/pull/6853/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) | `96.16% <100.00%> (+<0.01%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Files](https://app.codecov.io/gh/getmoto/moto/pull/6853?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) | Coverage Δ | |
|---|---|---|
| [moto/cognitoidp/models.py](https://app.codecov.io/gh/getmoto/moto/pull/6853?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto#diff-bW90by9jb2duaXRvaWRwL21vZGVscy5weQ==) | `96.21% <100.00%> (+0.68%)` | :arrow_up: |



Patch:
diff --git a/moto/cognitoidp/models.py b/moto/cognitoidp/models.py
index 2cccdec56..1e9c82ec8 100644
--- a/moto/cognitoidp/models.py
+++ b/moto/cognitoidp/models.py
@@ -833,7 +833,7 @@ class CognitoIdpUser(BaseModel):
         user_mfa_setting_list = []
         if self.software_token_mfa_enabled:
             user_mfa_setting_list.append("SOFTWARE_TOKEN_MFA")
-        elif self.sms_mfa_enabled:
+        if self.sms_mfa_enabled:
             user_mfa_setting_list.append("SMS_MFA")
         user_json = self._base_json()
         if extended:
@@ -1981,26 +1981,13 @@ class CognitoIdpBackend(BaseBackend):
         for user_pool in self.user_pools.values():
             if access_token in user_pool.access_tokens:
                 _, username = user_pool.access_tokens[access_token]
-                user = self.admin_get_user(user_pool.id, username)
 
-                if software_token_mfa_settings and software_token_mfa_settings.get(
-                    "Enabled"
-                ):
-                    if user.token_verified:
-                        user.software_token_mfa_enabled = True
-                    else:
-                        raise InvalidParameterException(
-                            "User has not verified software token mfa"
-                        )
-
-                    if software_token_mfa_settings.get("PreferredMfa"):
-                        user.preferred_mfa_setting = "SOFTWARE_TOKEN_MFA"
-                elif sms_mfa_settings and sms_mfa_settings["Enabled"]:
-                    user.sms_mfa_enabled = True
-
-                    if sms_mfa_settings.get("PreferredMfa"):
-                        user.preferred_mfa_setting = "SMS_MFA"
-                return None
+                return self.admin_set_user_mfa_preference(
+                    user_pool.id,
+                    username,
+                    software_token_mfa_settings,
+                    sms_mfa_settings,
+                )
 
         raise NotAuthorizedError(access_token)
 
@@ -2013,21 +2000,33 @@ class CognitoIdpBackend(BaseBackend):
     ) -> None:
         user = self.admin_get_user(user_pool_id, username)
 
-        if software_token_mfa_settings and software_token_mfa_settings.get("Enabled"):
-            if user.token_verified:
-                user.software_token_mfa_enabled = True
+        if software_token_mfa_settings:
+            if software_token_mfa_settings.get("Enabled"):
+                if user.token_verified:
+                    user.software_token_mfa_enabled = True
+                else:
+                    raise InvalidParameterException(
+                        "User has not verified software token mfa"
+                    )
             else:
-                raise InvalidParameterException(
-                    "User has not verified software token mfa"
-                )
+                user.software_token_mfa_enabled = False
 
             if software_token_mfa_settings.get("PreferredMfa"):
                 user.preferred_mfa_setting = "SOFTWARE_TOKEN_MFA"
-        elif sms_mfa_settings and sms_mfa_settings.get("Enabled"):
-            user.sms_mfa_enabled = True
+            elif user.preferred_mfa_setting != "SMS_MFA":
+                user.preferred_mfa_setting = ""
+
+        if sms_mfa_settings:
+            if sms_mfa_settings.get("Enabled"):
+                user.sms_mfa_enabled = True
+            else:
+                user.sms_mfa_enabled = False
 
             if sms_mfa_settings.get("PreferredMfa"):
                 user.preferred_mfa_setting = "SMS_MFA"
+            elif user.preferred_mfa_setting != "SOFTWARE_TOKEN_MFA":
+                user.preferred_mfa_setting = ""
+
         return None
 
     def _validate_password(self, user_pool_id: str, password: str) -> None:


Test patch:
diff --git a/tests/test_cognitoidp/test_cognitoidp.py b/tests/test_cognitoidp/test_cognitoidp.py
index fa22ea810..b79464285 100644
--- a/tests/test_cognitoidp/test_cognitoidp.py
+++ b/tests/test_cognitoidp/test_cognitoidp.py
@@ -4235,6 +4235,8 @@ def test_setting_mfa():
 
     for auth_flow in ["ADMIN_NO_SRP_AUTH", "ADMIN_USER_PASSWORD_AUTH"]:
         result = authentication_flow(conn, auth_flow)
+
+        # Set MFA method
         conn.associate_software_token(AccessToken=result["access_token"])
         conn.verify_software_token(
             AccessToken=result["access_token"], UserCode="123456"
@@ -4243,12 +4245,24 @@ def test_setting_mfa():
             AccessToken=result["access_token"],
             SoftwareTokenMfaSettings={"Enabled": True, "PreferredMfa": True},
         )
-        result = conn.admin_get_user(
+        user = conn.admin_get_user(
             UserPoolId=result["user_pool_id"], Username=result["username"]
         )
 
-        assert len(result["UserMFASettingList"]) == 1
-        assert result["PreferredMfaSetting"] == "SOFTWARE_TOKEN_MFA"
+        assert len(user["UserMFASettingList"]) == 1
+        assert user["PreferredMfaSetting"] == "SOFTWARE_TOKEN_MFA"
+
+        # Unset MFA method
+        conn.set_user_mfa_preference(
+            AccessToken=result["access_token"],
+            SoftwareTokenMfaSettings={"Enabled": False, "PreferredMfa": False},
+        )
+        user = conn.admin_get_user(
+            UserPoolId=result["user_pool_id"], Username=result["username"]
+        )
+
+        assert len(user["UserMFASettingList"]) == 0
+        assert user["PreferredMfaSetting"] == ""
 
 
 @mock_cognitoidp
@@ -4269,7 +4283,7 @@ def test_setting_mfa_when_token_not_verified():
 
 
 @mock_cognitoidp
-def test_admin_setting_mfa():
+def test_admin_setting_single_mfa():
     conn = boto3.client("cognito-idp", "us-west-2")
 
     user_pool_id = conn.create_user_pool(
@@ -4278,6 +4292,7 @@ def test_admin_setting_mfa():
     username = "test@example.com"
     conn.admin_create_user(UserPoolId=user_pool_id, Username=username)
 
+    # Set MFA SMS method
     conn.admin_set_user_mfa_preference(
         Username=username,
         UserPoolId=user_pool_id,
@@ -4287,6 +4302,50 @@ def test_admin_setting_mfa():
     assert len(result["UserMFASettingList"]) == 1
     assert result["PreferredMfaSetting"] == "SMS_MFA"
 
+    # Unset MFA SMS method
+    conn.admin_set_user_mfa_preference(
+        Username=username,
+        UserPoolId=user_pool_id,
+        SMSMfaSettings={"Enabled": False, "PreferredMfa": False},
+    )
+    result = conn.admin_get_user(UserPoolId=user_pool_id, Username=username)
+    assert len(result["UserMFASettingList"]) == 0
+    assert result["PreferredMfaSetting"] == ""
+
+
+@mock_cognitoidp
+def test_admin_setting_mfa_totp_and_sms():
+    conn = boto3.client("cognito-idp", "us-west-2")
+
+    result = authentication_flow(conn, "ADMIN_NO_SRP_AUTH")
+    access_token = result["access_token"]
+    user_pool_id = result["user_pool_id"]
+    username = result["username"]
+    conn.associate_software_token(AccessToken=access_token)
+    conn.verify_software_token(AccessToken=access_token, UserCode="123456")
+
+    # Set MFA TOTP and SMS methods
+    conn.admin_set_user_mfa_preference(
+        Username=username,
+        UserPoolId=user_pool_id,
+        SoftwareTokenMfaSettings={"Enabled": True, "PreferredMfa": True},
+        SMSMfaSettings={"Enabled": True, "PreferredMfa": False},
+    )
+    result = conn.admin_get_user(UserPoolId=user_pool_id, Username=username)
+    assert len(result["UserMFASettingList"]) == 2
+    assert result["PreferredMfaSetting"] == "SOFTWARE_TOKEN_MFA"
+
+    # Unset MFA TOTP and SMS methods
+    conn.admin_set_user_mfa_preference(
+        Username=username,
+        UserPoolId=user_pool_id,
+        SoftwareTokenMfaSettings={"Enabled": False, "PreferredMfa": False},
+        SMSMfaSettings={"Enabled": False, "PreferredMfa": False},
+    )
+    result = conn.admin_get_user(UserPoolId=user_pool_id, Username=username)
+    assert len(result["UserMFASettingList"]) == 0
+    assert result["PreferredMfaSetting"] == ""
+
 
 @mock_cognitoidp
 def test_admin_setting_mfa_when_token_not_verified():