Skip to content

Commit 700cf1f

Browse files
authored
feat(cognito-idp): make TOTP MFA work (#9785)
1 parent b78aa48 commit 700cf1f

8 files changed

Lines changed: 261 additions & 24 deletions

File tree

.github/workflows/tests_servermode.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ jobs:
2121
run: |
2222
pip install build
2323
python -m build
24-
docker run --rm -t --name motoserver -e TEST_SERVER_MODE=true -e MOTO_EC2_LOAD_DEFAULT_AMIS=false -e AWS_SECRET_ACCESS_KEY=server_secret -e AWS_ACCESS_KEY_ID=server_key -v `pwd`:/moto -p 5000:5000 -v /var/run/docker.sock:/var/run/docker.sock python:${{ matrix.python-version }}-slim /moto/scripts/ci_moto_server.sh &
24+
docker run --rm -t --name motoserver -e TEST_SERVER_MODE=true -e MOTO_EC2_LOAD_DEFAULT_AMIS=false -e MOTO_COGNITO_IDP_USER_POOL_ENABLE_TOTP=true -e AWS_SECRET_ACCESS_KEY=server_secret -e AWS_ACCESS_KEY_ID=server_key -v `pwd`:/moto -p 5000:5000 -v /var/run/docker.sock:/var/run/docker.sock python:${{ matrix.python-version }}-slim /moto/scripts/ci_moto_server.sh &
2525
python scripts/ci_wait_for_server.py
2626
- name: Get pip cache dir
2727
id: pip-cache

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: 56 additions & 10 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,
@@ -32,12 +35,16 @@
3235
from .utils import (
3336
PAGINATION_MODEL,
3437
check_secret_hash,
38+
cognito_totp,
3539
expand_attrs,
3640
flatten_attrs,
3741
generate_id,
3842
validate_username_format,
3943
)
4044

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

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

977988
def __init__(self, region_name: str, account_id: str):
@@ -1720,6 +1731,16 @@ def respond_to_auth_challenge(
17201731
):
17211732
raise NotAuthorizedError(secret_hash)
17221733

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

@@ -2173,7 +2194,7 @@ def initiate_auth(
21732194
def associate_software_token(
21742195
self, access_token: str, session: str
21752196
) -> dict[str, str]:
2176-
secret_code = "asdfasdfasdf"
2197+
secret_code = COGNITO_TOTP_MFA_SECRET
21772198
if session:
21782199
if session in self.sessions:
21792200
return {"SecretCode": secret_code, "Session": session}
@@ -2188,16 +2209,25 @@ def associate_software_token(
21882209

21892210
raise NotAuthorizedError(access_token)
21902211

2191-
def verify_software_token(self, access_token: str, session: str) -> dict[str, str]:
2192-
"""
2193-
The parameter UserCode has not yet been implemented
2194-
"""
2212+
def verify_software_token(
2213+
self, access_token: str, session: str, user_code: str, friendly_device_name: str
2214+
) -> dict[str, str]:
2215+
totp = cognito_totp(COGNITO_TOTP_MFA_SECRET)
21952216
if session:
21962217
if session not in self.sessions:
21972218
raise ResourceNotFoundError(session)
21982219

21992220
username, user_pool = self.sessions[session]
22002221
user = self.admin_get_user(user_pool.id, username)
2222+
2223+
if get_cognito_idp_user_pool_enable_totp():
2224+
try:
2225+
totp.verify(user_code.encode("utf-8"), int(time.time()))
2226+
except InvalidToken:
2227+
raise CodeMismatchException(
2228+
f"Code mismatch ({friendly_device_name})"
2229+
)
2230+
22012231
user.token_verified = True
22022232

22032233
session = str(random.uuid4())
@@ -2209,6 +2239,14 @@ def verify_software_token(self, access_token: str, session: str) -> dict[str, st
22092239
_, username = user_pool.access_tokens[access_token]
22102240
user = self.admin_get_user(user_pool.id, username)
22112241

2242+
if get_cognito_idp_user_pool_enable_totp():
2243+
try:
2244+
totp.verify(user_code.encode("utf-8"), int(time.time()))
2245+
except InvalidToken:
2246+
raise CodeMismatchException(
2247+
f"Code mismatch ({friendly_device_name})"
2248+
)
2249+
22122250
user.token_verified = True
22132251

22142252
session = str(random.uuid4())
@@ -2454,9 +2492,17 @@ def associate_software_token(
24542492
backend = self._find_backend_by_access_token_or_session(access_token, session)
24552493
return backend.associate_software_token(access_token, session)
24562494

2457-
def verify_software_token(self, access_token: str, session: str) -> dict[str, str]:
2495+
def verify_software_token(
2496+
self,
2497+
access_token: str,
2498+
session: str,
2499+
user_code: str,
2500+
friendly_device_name: str,
2501+
) -> dict[str, str]:
24582502
backend = self._find_backend_by_access_token_or_session(access_token, session)
2459-
return backend.verify_software_token(access_token, session)
2503+
return backend.verify_software_token(
2504+
access_token, session, user_code, friendly_device_name
2505+
)
24602506

24612507
def set_user_mfa_preference(
24622508
self,

moto/cognitoidp/responses.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -585,8 +585,10 @@ def associate_software_token(self) -> ActionResult:
585585
def verify_software_token(self) -> ActionResult:
586586
access_token = self._get_param("AccessToken")
587587
session = self._get_param("Session")
588+
user_code = self._get_param("UserCode")
589+
friendly_device_name = self._get_param("FriendlyDeviceName")
588590
result = self._get_region_agnostic_backend().verify_software_token(
589-
access_token, session
591+
access_token, session, user_code, friendly_device_name
590592
)
591593
return ActionResult(result)
592594

moto/cognitoidp/utils.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55
import string
66
from typing import Any, Optional
77

8+
from cryptography.hazmat.primitives.hashes import SHA1
9+
from cryptography.hazmat.primitives.twofactor.totp import TOTP
10+
811
from moto.moto_api._internal import mock_random as random
912

1013
FORMATS = {
@@ -120,3 +123,19 @@ def _generate_id_hash(args: Any) -> str:
120123
hasher.update(str(arg).encode())
121124

122125
return hasher.hexdigest()
126+
127+
128+
def cognito_totp(key: str) -> TOTP:
129+
key_padded = key
130+
# Pad the secret if required before converting it to bytes
131+
padding = len(key) % 8
132+
if padding != 0:
133+
key_padded += "=" * (8 - padding)
134+
# https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-settings-mfa-totp.html
135+
return TOTP(
136+
key=base64.b32decode(key_padded, casefold=True),
137+
length=6,
138+
algorithm=SHA1(),
139+
time_step=30,
140+
enforce_key_length=False,
141+
)

moto/settings.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,13 @@ 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("MOTO_COGNITO_IDP_USER_POOL_ENABLE_TOTP", "false").lower()
196+
== "true"
197+
)
198+
199+
193200
def enable_iso_regions() -> bool:
194201
return os.environ.get("MOTO_ENABLE_ISO_REGIONS", "false").lower() == "true"
195202

0 commit comments

Comments
 (0)