Skip to content

Commit 12f041f

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 12f041f

File tree

6 files changed

+190
-18
lines changed

6 files changed

+190
-18
lines changed

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: 48 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,9 @@
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"
47+
4248

4349
class UserStatus(str, enum.Enum):
4450
FORCE_CHANGE_PASSWORD = "FORCE_CHANGE_PASSWORD"
@@ -970,8 +976,12 @@ class CognitoIdpBackend(BaseBackend):
970976
In some cases, you need to have reproducible IDs for the user pool.
971977
For example, a single initialization before the start of integration tests.
972978
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.
979+
This behavior can be enabled by passing the environment variable: `MOTO_COGNITO_IDP_USER_POOL_ID_STRATEGY=HASH`.
980+
Passing `MOTO_COGNITO_IDP_USER_POOL_CLIENT_ID_STRATEGY=HASH` enables the same logic for user pool clients.
981+
982+
Support for MFA TOTP can be enabled by setting `MOTO_COGNITO_IDP_USER_POOL_ENABLE_TOTP=true`.
983+
Moto will validate the TOTP MFA provided by the user when registering MFA or subsequently authenticating.
984+
At this time, Moto uses a single fixed secret across all users.
975985
"""
976986

977987
def __init__(self, region_name: str, account_id: str):
@@ -1720,6 +1730,16 @@ def respond_to_auth_challenge(
17201730
):
17211731
raise NotAuthorizedError(secret_hash)
17221732

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

@@ -2173,7 +2193,7 @@ def initiate_auth(
21732193
def associate_software_token(
21742194
self, access_token: str, session: str
21752195
) -> dict[str, str]:
2176-
secret_code = "asdfasdfasdf"
2196+
secret_code = COGNITO_TOTP_MFA_SECRET
21772197
if session:
21782198
if session in self.sessions:
21792199
return {"SecretCode": secret_code, "Session": session}
@@ -2189,20 +2209,23 @@ def associate_software_token(
21892209
raise NotAuthorizedError(access_token)
21902210

21912211
def verify_software_token(
2192-
self, access_token: str, session: str, user_code: str
2212+
self, access_token: str, session: str, user_code: str, friendly_device_name: str
21932213
) -> dict[str, str]:
2194-
"""
2195-
The parameter UserCode has not yet been implemented
2196-
"""
2197-
totp = cognito_totp("asdfasdfasdf")
2214+
totp = cognito_totp(COGNITO_TOTP_MFA_SECRET)
21982215
if session:
21992216
if session not in self.sessions:
22002217
raise ResourceNotFoundError(session)
22012218

22022219
username, user_pool = self.sessions[session]
22032220
user = self.admin_get_user(user_pool.id, username)
22042221

2205-
totp.verify(user_code.encode("utf-8"), int(time.time()))
2222+
if get_cognito_idp_user_pool_enable_totp():
2223+
try:
2224+
totp.verify(user_code.encode("utf-8"), int(time.time()))
2225+
except InvalidToken:
2226+
raise CodeMismatchException(
2227+
f"Code mismatch ({friendly_device_name})"
2228+
)
22062229

22072230
user.token_verified = True
22082231

@@ -2215,7 +2238,13 @@ def verify_software_token(
22152238
_, username = user_pool.access_tokens[access_token]
22162239
user = self.admin_get_user(user_pool.id, username)
22172240

2218-
totp.verify(user_code.encode("utf-8"), int(time.time()))
2241+
if get_cognito_idp_user_pool_enable_totp():
2242+
try:
2243+
totp.verify(user_code.encode("utf-8"), int(time.time()))
2244+
except InvalidToken:
2245+
raise CodeMismatchException(
2246+
f"Code mismatch ({friendly_device_name})"
2247+
)
22192248

22202249
user.token_verified = True
22212250

@@ -2463,10 +2492,16 @@ def associate_software_token(
24632492
return backend.associate_software_token(access_token, session)
24642493

24652494
def verify_software_token(
2466-
self, access_token: str, session: str, user_code: str
2495+
self,
2496+
access_token: str,
2497+
session: str,
2498+
user_code: str,
2499+
friendly_device_name: str,
24672500
) -> dict[str, str]:
24682501
backend = self._find_backend_by_access_token_or_session(access_token, session)
2469-
return backend.verify_software_token(access_token, session, user_code)
2502+
return backend.verify_software_token(
2503+
access_token, session, user_code, friendly_device_name
2504+
)
24702505

24712506
def set_user_mfa_preference(
24722507
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: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,15 @@ 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

192192

193+
def get_cognito_idp_user_pool_enable_totp() -> bool:
194+
return (
195+
os.environ.get(
196+
key="MOTO_COGNITO_IDP_USER_POOL_ENABLE_TOTP", default="false"
197+
).lower()
198+
== "true"
199+
)
200+
201+
193202
def enable_iso_regions() -> bool:
194203
return os.environ.get("MOTO_ENABLE_ISO_REGIONS", "false").lower() == "true"
195204

tests/test_cognitoidp/test_cognitoidp.py

Lines changed: 123 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3294,6 +3294,7 @@ def user_authentication_flow(
32943294

32953295

32963296
@cognitoidp_aws_verified(generate_secret=True, with_mfa="ON")
3297+
@mock.patch.dict(os.environ, {"MOTO_COGNITO_IDP_USER_POOL_ENABLE_TOTP": "true"})
32973298
@pytest.mark.aws_verified
32983299
def test_user_authentication_flow_mfa_on(user_pool=None, user_pool_client=None):
32993300
conn = boto3.client("cognito-idp", "us-west-2")
@@ -3397,6 +3398,7 @@ def test_user_authentication_flow_mfa_on(user_pool=None, user_pool_client=None):
33973398

33983399

33993400
@cognitoidp_aws_verified(generate_secret=True, with_mfa="OPTIONAL")
3401+
@mock.patch.dict(os.environ, {"MOTO_COGNITO_IDP_USER_POOL_ENABLE_TOTP": "true"})
34003402
@pytest.mark.aws_verified
34013403
def test_user_authentication_flow_mfa_optional(user_pool=None, user_pool_client=None):
34023404
conn = boto3.client("cognito-idp", "us-west-2")
@@ -5019,6 +5021,7 @@ def test_initiate_auth_USER_PASSWORD_AUTH_with_FORCE_CHANGE_PASSWORD_status():
50195021

50205022

50215023
@cognitoidp_aws_verified(explicit_auth_flows=["USER_PASSWORD_AUTH"], with_mfa="ON")
5024+
@mock.patch.dict(os.environ, {"MOTO_COGNITO_IDP_USER_POOL_ENABLE_TOTP": "true"})
50225025
@pytest.mark.aws_verified
50235026
def test_initiate_mfa_auth_USER_PASSWORD_AUTH_with_FORCE_CHANGE_PASSWORD_status(
50245027
user_pool=None, user_pool_client=None
@@ -5241,6 +5244,7 @@ def test_initiate_auth_with_invalid_secret_hash():
52415244

52425245

52435246
@mock_aws
5247+
@mock.patch.dict(os.environ, {"MOTO_COGNITO_IDP_USER_POOL_ENABLE_TOTP": "true"})
52445248
def test_setting_mfa():
52455249
conn = boto3.client("cognito-idp", "us-west-2")
52465250

@@ -5329,6 +5333,7 @@ def test_admin_setting_single_mfa():
53295333

53305334

53315335
@mock_aws
5336+
@mock.patch.dict(os.environ, {"MOTO_COGNITO_IDP_USER_POOL_ENABLE_TOTP": "true"})
53325337
def test_admin_setting_mfa_totp_and_sms():
53335338
conn = boto3.client("cognito-idp", "us-west-2")
53345339

@@ -5367,7 +5372,7 @@ def test_admin_setting_mfa_totp_and_sms():
53675372

53685373

53695374
@mock_aws
5370-
def test_admin_initiate_auth_when_token_totp_enabled():
5375+
def test_admin_initiate_auth_when_token_totp_masked():
53715376
conn = boto3.client("cognito-idp", "us-west-2")
53725377

53735378
result = authentication_flow(conn, "ADMIN_NO_SRP_AUTH")
@@ -5425,6 +5430,123 @@ def test_admin_initiate_auth_when_token_totp_enabled():
54255430
assert result["AuthenticationResult"]["TokenType"] == "Bearer"
54265431

54275432

5433+
@mock_aws
5434+
@mock.patch.dict(os.environ, {"MOTO_COGNITO_IDP_USER_POOL_ENABLE_TOTP": "true"})
5435+
def test_admin_initiate_auth_when_token_totp_enabled():
5436+
conn = boto3.client("cognito-idp", "us-west-2")
5437+
5438+
result = authentication_flow(conn, "ADMIN_NO_SRP_AUTH")
5439+
access_token = result["access_token"]
5440+
user_pool_id = result["user_pool_id"]
5441+
username = result["username"]
5442+
client_id = result["client_id"]
5443+
password = result["password"]
5444+
resp = conn.associate_software_token(AccessToken=access_token)
5445+
secret_code = resp["SecretCode"]
5446+
totp = pyotp.TOTP(secret_code)
5447+
user_code = totp.now()
5448+
conn.verify_software_token(AccessToken=access_token, UserCode=user_code)
5449+
5450+
# Set MFA TOTP and SMS methods
5451+
conn.admin_set_user_mfa_preference(
5452+
Username=username,
5453+
UserPoolId=user_pool_id,
5454+
SoftwareTokenMfaSettings={"Enabled": True, "PreferredMfa": True},
5455+
SMSMfaSettings={"Enabled": True, "PreferredMfa": False},
5456+
)
5457+
result = conn.admin_get_user(UserPoolId=user_pool_id, Username=username)
5458+
assert len(result["UserMFASettingList"]) == 2
5459+
assert result["PreferredMfaSetting"] == "SOFTWARE_TOKEN_MFA"
5460+
5461+
# Initiate auth with TOTP
5462+
result = conn.admin_initiate_auth(
5463+
UserPoolId=user_pool_id,
5464+
ClientId=client_id,
5465+
AuthFlow="ADMIN_NO_SRP_AUTH",
5466+
AuthParameters={
5467+
"USERNAME": username,
5468+
"PASSWORD": password,
5469+
},
5470+
)
5471+
5472+
assert result["ChallengeName"] == "SOFTWARE_TOKEN_MFA"
5473+
assert result["Session"] != ""
5474+
5475+
# Respond to challenge with TOTP
5476+
result = conn.admin_respond_to_auth_challenge(
5477+
UserPoolId=user_pool_id,
5478+
ClientId=client_id,
5479+
ChallengeName="SOFTWARE_TOKEN_MFA",
5480+
Session=result["Session"],
5481+
ChallengeResponses={
5482+
"SOFTWARE_TOKEN_MFA_CODE": totp.now(),
5483+
"USERNAME": username,
5484+
},
5485+
)
5486+
5487+
assert result["AuthenticationResult"]["IdToken"] != ""
5488+
assert result["AuthenticationResult"]["AccessToken"] != ""
5489+
assert result["AuthenticationResult"]["RefreshToken"] != ""
5490+
assert result["AuthenticationResult"]["TokenType"] == "Bearer"
5491+
5492+
5493+
@mock_aws
5494+
@mock.patch.dict(os.environ, {"MOTO_COGNITO_IDP_USER_POOL_ENABLE_TOTP": "true"})
5495+
def test_admin_initiate_auth_when_token_totp_enabled_invalid():
5496+
conn = boto3.client("cognito-idp", "us-west-2")
5497+
5498+
result = authentication_flow(conn, "ADMIN_NO_SRP_AUTH")
5499+
access_token = result["access_token"]
5500+
user_pool_id = result["user_pool_id"]
5501+
username = result["username"]
5502+
client_id = result["client_id"]
5503+
password = result["password"]
5504+
resp = conn.associate_software_token(AccessToken=access_token)
5505+
secret_code = resp["SecretCode"]
5506+
totp = pyotp.TOTP(secret_code)
5507+
user_code = totp.now()
5508+
conn.verify_software_token(AccessToken=access_token, UserCode=user_code)
5509+
5510+
# Set MFA TOTP and SMS methods
5511+
conn.admin_set_user_mfa_preference(
5512+
Username=username,
5513+
UserPoolId=user_pool_id,
5514+
SoftwareTokenMfaSettings={"Enabled": True, "PreferredMfa": True},
5515+
SMSMfaSettings={"Enabled": True, "PreferredMfa": False},
5516+
)
5517+
result = conn.admin_get_user(UserPoolId=user_pool_id, Username=username)
5518+
assert len(result["UserMFASettingList"]) == 2
5519+
assert result["PreferredMfaSetting"] == "SOFTWARE_TOKEN_MFA"
5520+
5521+
# Initiate auth with TOTP
5522+
result = conn.admin_initiate_auth(
5523+
UserPoolId=user_pool_id,
5524+
ClientId=client_id,
5525+
AuthFlow="ADMIN_NO_SRP_AUTH",
5526+
AuthParameters={
5527+
"USERNAME": username,
5528+
"PASSWORD": password,
5529+
},
5530+
)
5531+
5532+
assert result["ChallengeName"] == "SOFTWARE_TOKEN_MFA"
5533+
assert result["Session"] != ""
5534+
5535+
with pytest.raises(ClientError) as exc:
5536+
result = conn.admin_respond_to_auth_challenge(
5537+
UserPoolId=user_pool_id,
5538+
ClientId=client_id,
5539+
ChallengeName="SOFTWARE_TOKEN_MFA",
5540+
Session=result["Session"],
5541+
ChallengeResponses={
5542+
"SOFTWARE_TOKEN_MFA_CODE": "123456",
5543+
"USERNAME": username,
5544+
},
5545+
)
5546+
err = exc.value.response["Error"]
5547+
assert err["Code"] == "CodeMismatch"
5548+
5549+
54285550
@mock_aws
54295551
def test_admin_initiate_auth_when_sms_mfa_enabled():
54305552
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)