Skip to content

Commit 04b6fc3

Browse files
committed
feat(cognito-idp): make TOTP MFA work
* Put working TOTP MFA behind a feature flag - specifically `MOTO_COGNITO_IDP_USER_POOL_ENABLE_TOTP` * Support the FriendlyDeviceName field, although only for error reporting to end-user
1 parent 16859cf commit 04b6fc3

6 files changed

Lines changed: 168 additions & 19 deletions

File tree

moto/cognitoidp/exceptions.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,8 @@ def __init__(self) -> None:
5959
error_type="InvalidPasswordException",
6060
message="The provided password does not confirm to the configured password policy",
6161
)
62+
63+
64+
class CodeMismatchException(JsonRESTError):
65+
def __init__(self, message: str):
66+
super().__init__(error_type="CodeMismatchException", message=message)

moto/cognitoidp/models.py

Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22
import re
33
import time
44
from collections import OrderedDict
5-
from typing import Any, Optional
5+
from typing import Any, Final, Optional
66

7+
from cryptography.hazmat.primitives.twofactor import InvalidToken
78
from joserfc import jwk, jwt
89

910
from moto.core.base_backend import BackendDict, BaseBackend
@@ -15,10 +16,12 @@
1516

1617
from ..settings import (
1718
get_cognito_idp_user_pool_client_id_strategy,
19+
get_cognito_idp_user_pool_enable_totp,
1820
get_cognito_idp_user_pool_id_strategy,
1921
)
2022
from .exceptions import (
2123
AliasExistsException,
24+
CodeMismatchException,
2225
ExpiredCodeException,
2326
GroupExistsException,
2427
InvalidParameterException,
@@ -39,6 +42,8 @@
3942
validate_username_format,
4043
)
4144

45+
# FIXME: Should be per user and stored in the user's profile
46+
COGNITO_TOTP_MFA_SECRET: Final[str] = "asdfasdfasdf"
4247

4348
class UserStatus(str, enum.Enum):
4449
FORCE_CHANGE_PASSWORD = "FORCE_CHANGE_PASSWORD"
@@ -970,8 +975,12 @@ class CognitoIdpBackend(BaseBackend):
970975
In some cases, you need to have reproducible IDs for the user pool.
971976
For example, a single initialization before the start of integration tests.
972977
973-
This behavior can be enabled by passing the environment variable: MOTO_COGNITO_IDP_USER_POOL_ID_STRATEGY=HASH.
974-
Passing MOTO_COGNITO_IDP_USER_POOL_CLIENT_ID_STRATEGY=HASH enables the same logic for user pool clients.
978+
This behavior can be enabled by passing the environment variable: `MOTO_COGNITO_IDP_USER_POOL_ID_STRATEGY=HASH`.
979+
Passing `MOTO_COGNITO_IDP_USER_POOL_CLIENT_ID_STRATEGY=HASH` enables the same logic for user pool clients.
980+
981+
Support for MFA TOTP can be enabled by setting `MOTO_COGNITO_IDP_USER_POOL_ENABLE_TOTP=true`.
982+
Moto will validate the TOTP MFA provided by the user when registering MFA or subsequently authenticating.
983+
At this time, Moto uses a single fixed secret across all users.
975984
"""
976985

977986
def __init__(self, region_name: str, account_id: str):
@@ -1720,6 +1729,13 @@ def respond_to_auth_challenge(
17201729
):
17211730
raise NotAuthorizedError(secret_hash)
17221731

1732+
if challenge_name == "SOFTWARE_TOKEN_MFA" and get_cognito_idp_user_pool_enable_totp():
1733+
totp = cognito_totp(COGNITO_TOTP_MFA_SECRET)
1734+
try:
1735+
totp.verify(mfa_code.encode("utf-8"), int(time.time()))
1736+
except InvalidToken:
1737+
raise CodeMismatchException("MFA Code Mismatch")
1738+
17231739
del self.sessions[session]
17241740
return self._log_user_in(user_pool, client, username)
17251741

@@ -2173,7 +2189,7 @@ def initiate_auth(
21732189
def associate_software_token(
21742190
self, access_token: str, session: str
21752191
) -> dict[str, str]:
2176-
secret_code = "asdfasdfasdf"
2192+
secret_code = COGNITO_TOTP_MFA_SECRET
21772193
if session:
21782194
if session in self.sessions:
21792195
return {"SecretCode": secret_code, "Session": session}
@@ -2189,20 +2205,21 @@ def associate_software_token(
21892205
raise NotAuthorizedError(access_token)
21902206

21912207
def verify_software_token(
2192-
self, access_token: str, session: str, user_code: str
2208+
self, access_token: str, session: str, user_code: str, friendly_device_name: str
21932209
) -> dict[str, str]:
2194-
"""
2195-
The parameter UserCode has not yet been implemented
2196-
"""
2197-
totp = cognito_totp("asdfasdfasdf")
2210+
totp = cognito_totp(COGNITO_TOTP_MFA_SECRET)
21982211
if session:
21992212
if session not in self.sessions:
22002213
raise ResourceNotFoundError(session)
22012214

22022215
username, user_pool = self.sessions[session]
22032216
user = self.admin_get_user(user_pool.id, username)
22042217

2205-
totp.verify(user_code.encode("utf-8"), int(time.time()))
2218+
if get_cognito_idp_user_pool_enable_totp():
2219+
try:
2220+
totp.verify(user_code.encode("utf-8"), int(time.time()))
2221+
except InvalidToken:
2222+
raise CodeMismatchException(f"Code mismatch ({friendly_device_name})")
22062223

22072224
user.token_verified = True
22082225

@@ -2215,7 +2232,11 @@ def verify_software_token(
22152232
_, username = user_pool.access_tokens[access_token]
22162233
user = self.admin_get_user(user_pool.id, username)
22172234

2218-
totp.verify(user_code.encode("utf-8"), int(time.time()))
2235+
if get_cognito_idp_user_pool_enable_totp():
2236+
try:
2237+
totp.verify(user_code.encode("utf-8"), int(time.time()))
2238+
except InvalidToken:
2239+
raise CodeMismatchException(f"Code mismatch ({friendly_device_name})")
22192240

22202241
user.token_verified = True
22212242

@@ -2463,10 +2484,10 @@ def associate_software_token(
24632484
return backend.associate_software_token(access_token, session)
24642485

24652486
def verify_software_token(
2466-
self, access_token: str, session: str, user_code: str
2487+
self, access_token: str, session: str, user_code: str, friendly_device_name: str,
24672488
) -> dict[str, str]:
24682489
backend = self._find_backend_by_access_token_or_session(access_token, session)
2469-
return backend.verify_software_token(access_token, session, user_code)
2490+
return backend.verify_software_token(access_token, session, user_code, friendly_device_name)
24702491

24712492
def set_user_mfa_preference(
24722493
self,

moto/cognitoidp/responses.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -586,8 +586,9 @@ def verify_software_token(self) -> ActionResult:
586586
access_token = self._get_param("AccessToken")
587587
session = self._get_param("Session")
588588
user_code = self._get_param("UserCode")
589+
friendly_device_name = self._get_param("FriendlyDeviceName")
589590
result = self._get_region_agnostic_backend().verify_software_token(
590-
access_token, session, user_code
591+
access_token, session, user_code, friendly_device_name
591592
)
592593
return ActionResult(result)
593594

moto/settings.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,8 @@ def get_cognito_idp_user_pool_id_strategy() -> Optional[str]:
189189
def get_cognito_idp_user_pool_client_id_strategy() -> Optional[str]:
190190
return os.environ.get("MOTO_COGNITO_IDP_USER_POOL_CLIENT_ID_STRATEGY")
191191

192+
def get_cognito_idp_user_pool_enable_totp() -> bool:
193+
return os.environ.get(key='MOTO_COGNITO_IDP_USER_POOL_ENABLE_TOTP', default="false").lower() == "true"
192194

193195
def enable_iso_regions() -> bool:
194196
return os.environ.get("MOTO_ENABLE_ISO_REGIONS", "false").lower() == "true"

tests/test_cognitoidp/test_cognitoidp.py

Lines changed: 122 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3134,7 +3134,6 @@ def test_authentication_flow_invalid_flow():
31343134
== "1 validation error detected: Value 'NO_SUCH_FLOW' at 'authFlow' failed to satisfy constraint: Member must satisfy enum value set: ['ADMIN_NO_SRP_AUTH', 'ADMIN_USER_PASSWORD_AUTH', 'USER_SRP_AUTH', 'REFRESH_TOKEN_AUTH', 'REFRESH_TOKEN', 'CUSTOM_AUTH', 'USER_PASSWORD_AUTH']"
31353135
)
31363136

3137-
31383137
@mock_aws
31393138
def test_authentication_flow_invalid_user_flow():
31403139
"""Pass a user authFlow to admin_initiate_auth"""
@@ -3294,6 +3293,7 @@ def user_authentication_flow(
32943293

32953294

32963295
@cognitoidp_aws_verified(generate_secret=True, with_mfa="ON")
3296+
@mock.patch.dict(os.environ, {"MOTO_COGNITO_IDP_USER_POOL_ENABLE_TOTP": "true"})
32973297
@pytest.mark.aws_verified
32983298
def test_user_authentication_flow_mfa_on(user_pool=None, user_pool_client=None):
32993299
conn = boto3.client("cognito-idp", "us-west-2")
@@ -3397,6 +3397,7 @@ def test_user_authentication_flow_mfa_on(user_pool=None, user_pool_client=None):
33973397

33983398

33993399
@cognitoidp_aws_verified(generate_secret=True, with_mfa="OPTIONAL")
3400+
@mock.patch.dict(os.environ, {"MOTO_COGNITO_IDP_USER_POOL_ENABLE_TOTP": "true"})
34003401
@pytest.mark.aws_verified
34013402
def test_user_authentication_flow_mfa_optional(user_pool=None, user_pool_client=None):
34023403
conn = boto3.client("cognito-idp", "us-west-2")
@@ -5019,6 +5020,7 @@ def test_initiate_auth_USER_PASSWORD_AUTH_with_FORCE_CHANGE_PASSWORD_status():
50195020

50205021

50215022
@cognitoidp_aws_verified(explicit_auth_flows=["USER_PASSWORD_AUTH"], with_mfa="ON")
5023+
@mock.patch.dict(os.environ, {"MOTO_COGNITO_IDP_USER_POOL_ENABLE_TOTP": "true"})
50225024
@pytest.mark.aws_verified
50235025
def test_initiate_mfa_auth_USER_PASSWORD_AUTH_with_FORCE_CHANGE_PASSWORD_status(
50245026
user_pool=None, user_pool_client=None
@@ -5241,6 +5243,7 @@ def test_initiate_auth_with_invalid_secret_hash():
52415243

52425244

52435245
@mock_aws
5246+
@mock.patch.dict(os.environ, {"MOTO_COGNITO_IDP_USER_POOL_ENABLE_TOTP": "true"})
52445247
def test_setting_mfa():
52455248
conn = boto3.client("cognito-idp", "us-west-2")
52465249

@@ -5329,6 +5332,7 @@ def test_admin_setting_single_mfa():
53295332

53305333

53315334
@mock_aws
5335+
@mock.patch.dict(os.environ, {"MOTO_COGNITO_IDP_USER_POOL_ENABLE_TOTP": "true"})
53325336
def test_admin_setting_mfa_totp_and_sms():
53335337
conn = boto3.client("cognito-idp", "us-west-2")
53345338

@@ -5365,8 +5369,67 @@ def test_admin_setting_mfa_totp_and_sms():
53655369
assert len(result["UserMFASettingList"]) == 0
53665370
assert result["PreferredMfaSetting"] == ""
53675371

5372+
@mock_aws
5373+
def test_admin_initiate_auth_when_token_totp_masked():
5374+
conn = boto3.client("cognito-idp", "us-west-2")
5375+
5376+
result = authentication_flow(conn, "ADMIN_NO_SRP_AUTH")
5377+
access_token = result["access_token"]
5378+
user_pool_id = result["user_pool_id"]
5379+
username = result["username"]
5380+
client_id = result["client_id"]
5381+
password = result["password"]
5382+
resp = conn.associate_software_token(AccessToken=access_token)
5383+
secret_code = resp["SecretCode"]
5384+
totp = pyotp.TOTP(secret_code)
5385+
user_code = totp.now()
5386+
conn.verify_software_token(AccessToken=access_token, UserCode=user_code)
5387+
5388+
# Set MFA TOTP and SMS methods
5389+
conn.admin_set_user_mfa_preference(
5390+
Username=username,
5391+
UserPoolId=user_pool_id,
5392+
SoftwareTokenMfaSettings={"Enabled": True, "PreferredMfa": True},
5393+
SMSMfaSettings={"Enabled": True, "PreferredMfa": False},
5394+
)
5395+
result = conn.admin_get_user(UserPoolId=user_pool_id, Username=username)
5396+
assert len(result["UserMFASettingList"]) == 2
5397+
assert result["PreferredMfaSetting"] == "SOFTWARE_TOKEN_MFA"
5398+
5399+
# Initiate auth with TOTP
5400+
result = conn.admin_initiate_auth(
5401+
UserPoolId=user_pool_id,
5402+
ClientId=client_id,
5403+
AuthFlow="ADMIN_NO_SRP_AUTH",
5404+
AuthParameters={
5405+
"USERNAME": username,
5406+
"PASSWORD": password,
5407+
},
5408+
)
5409+
5410+
assert result["ChallengeName"] == "SOFTWARE_TOKEN_MFA"
5411+
assert result["Session"] != ""
5412+
5413+
# Respond to challenge with TOTP
5414+
result = conn.admin_respond_to_auth_challenge(
5415+
UserPoolId=user_pool_id,
5416+
ClientId=client_id,
5417+
ChallengeName="SOFTWARE_TOKEN_MFA",
5418+
Session=result["Session"],
5419+
ChallengeResponses={
5420+
"SOFTWARE_TOKEN_MFA_CODE": "123456",
5421+
"USERNAME": username,
5422+
},
5423+
)
5424+
5425+
assert result["AuthenticationResult"]["IdToken"] != ""
5426+
assert result["AuthenticationResult"]["AccessToken"] != ""
5427+
assert result["AuthenticationResult"]["RefreshToken"] != ""
5428+
assert result["AuthenticationResult"]["TokenType"] == "Bearer"
5429+
53685430

53695431
@mock_aws
5432+
@mock.patch.dict(os.environ, {"MOTO_COGNITO_IDP_USER_POOL_ENABLE_TOTP": "true"})
53705433
def test_admin_initiate_auth_when_token_totp_enabled():
53715434
conn = boto3.client("cognito-idp", "us-west-2")
53725435

@@ -5414,7 +5477,7 @@ def test_admin_initiate_auth_when_token_totp_enabled():
54145477
ChallengeName="SOFTWARE_TOKEN_MFA",
54155478
Session=result["Session"],
54165479
ChallengeResponses={
5417-
"SOFTWARE_TOKEN_MFA_CODE": "123456",
5480+
"SOFTWARE_TOKEN_MFA_CODE": totp.now(),
54185481
"USERNAME": username,
54195482
},
54205483
)
@@ -5425,6 +5488,63 @@ def test_admin_initiate_auth_when_token_totp_enabled():
54255488
assert result["AuthenticationResult"]["TokenType"] == "Bearer"
54265489

54275490

5491+
@mock_aws
5492+
@mock.patch.dict(os.environ, {"MOTO_COGNITO_IDP_USER_POOL_ENABLE_TOTP": "true"})
5493+
def test_admin_initiate_auth_when_token_totp_enabled_invalid():
5494+
conn = boto3.client("cognito-idp", "us-west-2")
5495+
5496+
result = authentication_flow(conn, "ADMIN_NO_SRP_AUTH")
5497+
access_token = result["access_token"]
5498+
user_pool_id = result["user_pool_id"]
5499+
username = result["username"]
5500+
client_id = result["client_id"]
5501+
password = result["password"]
5502+
resp = conn.associate_software_token(AccessToken=access_token)
5503+
secret_code = resp["SecretCode"]
5504+
totp = pyotp.TOTP(secret_code)
5505+
user_code = totp.now()
5506+
conn.verify_software_token(AccessToken=access_token, UserCode=user_code)
5507+
5508+
# Set MFA TOTP and SMS methods
5509+
conn.admin_set_user_mfa_preference(
5510+
Username=username,
5511+
UserPoolId=user_pool_id,
5512+
SoftwareTokenMfaSettings={"Enabled": True, "PreferredMfa": True},
5513+
SMSMfaSettings={"Enabled": True, "PreferredMfa": False},
5514+
)
5515+
result = conn.admin_get_user(UserPoolId=user_pool_id, Username=username)
5516+
assert len(result["UserMFASettingList"]) == 2
5517+
assert result["PreferredMfaSetting"] == "SOFTWARE_TOKEN_MFA"
5518+
5519+
# Initiate auth with TOTP
5520+
result = conn.admin_initiate_auth(
5521+
UserPoolId=user_pool_id,
5522+
ClientId=client_id,
5523+
AuthFlow="ADMIN_NO_SRP_AUTH",
5524+
AuthParameters={
5525+
"USERNAME": username,
5526+
"PASSWORD": password,
5527+
},
5528+
)
5529+
5530+
assert result["ChallengeName"] == "SOFTWARE_TOKEN_MFA"
5531+
assert result["Session"] != ""
5532+
5533+
with pytest.raises(ClientError) as exc:
5534+
result = conn.admin_respond_to_auth_challenge(
5535+
UserPoolId=user_pool_id,
5536+
ClientId=client_id,
5537+
ChallengeName="SOFTWARE_TOKEN_MFA",
5538+
Session=result["Session"],
5539+
ChallengeResponses={
5540+
"SOFTWARE_TOKEN_MFA_CODE": "123456",
5541+
"USERNAME": username,
5542+
},
5543+
)
5544+
err = exc.value.response["Error"]
5545+
assert err["Code"] == "CodeMismatch"
5546+
5547+
54285548
@mock_aws
54295549
def test_admin_initiate_auth_when_sms_mfa_enabled():
54305550
conn = boto3.client("cognito-idp", "us-west-2")

tests/test_cognitoidp/test_cognitoidp_utils.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@
22

33
import pyotp
44

5+
from moto.cognitoidp.models import COGNITO_TOTP_MFA_SECRET
56
from moto.cognitoidp.utils import cognito_totp
67

78

89
def test_cognito_totp():
9-
key = "asdfasdfasdf"
10-
client_totp = pyotp.TOTP(s=key)
11-
internal_totp = cognito_totp(key)
10+
client_totp = pyotp.TOTP(s=COGNITO_TOTP_MFA_SECRET)
11+
internal_totp = cognito_totp(COGNITO_TOTP_MFA_SECRET)
1212

1313
client_code = client_totp.now()
1414
internal_code = internal_totp.generate(int(time.time())).decode("utf-8")

0 commit comments

Comments
 (0)