Added significant verification to ForgotPassword, changed UserStatus dict to enum (#4469)

This commit is contained in:
Łukasz 2021-10-24 16:26:57 +02:00 committed by GitHub
parent a4a8949166
commit fee16cb388
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 211 additions and 30 deletions

View File

@ -4,6 +4,7 @@ import json
import os import os
import time import time
import uuid import uuid
import enum
from boto3 import Session from boto3 import Session
from jose import jws from jose import jws
from collections import OrderedDict from collections import OrderedDict
@ -28,12 +29,12 @@ from .utils import (
) )
from moto.utilities.paginator import paginate from moto.utilities.paginator import paginate
UserStatus = {
"FORCE_CHANGE_PASSWORD": "FORCE_CHANGE_PASSWORD", class UserStatus(str, enum.Enum):
"CONFIRMED": "CONFIRMED", FORCE_CHANGE_PASSWORD = "FORCE_CHANGE_PASSWORD"
"UNCONFIRMED": "UNCONFIRMED", CONFIRMED = "CONFIRMED"
"RESET_REQUIRED": "RESET_REQUIRED", UNCONFIRMED = "UNCONFIRMED"
} RESET_REQUIRED = "RESET_REQUIRED"
class CognitoIdpUserPool(BaseModel): class CognitoIdpUserPool(BaseModel):
@ -67,6 +68,19 @@ class CognitoIdpUserPool(BaseModel):
) as f: ) as f:
self.json_web_key = json.loads(f.read()) self.json_web_key = json.loads(f.read())
def _account_recovery_setting(self):
# AccountRecoverySetting is not present in DescribeUserPool response if the pool was created without
# specifying it, ForgotPassword works on default settings nonetheless
return self.extended_config.get(
"AccountRecoverySetting",
{
"RecoveryMechanisms": [
{"Priority": 1, "Name": "verified_phone_number"},
{"Priority": 2, "Name": "verified_email"},
]
},
)
def _base_json(self): def _base_json(self):
return { return {
"Id": self.id, "Id": self.id,
@ -626,9 +640,9 @@ class CognitoIdpBackend(BaseBackend):
user = self.admin_get_user(user_pool_id, username) user = self.admin_get_user(user_pool_id, username)
if not user.enabled: if not user.enabled:
raise NotAuthorizedError("User is disabled") raise NotAuthorizedError("User is disabled")
if user.status == UserStatus["RESET_REQUIRED"]: if user.status is UserStatus.RESET_REQUIRED:
return return
if user.status != UserStatus["CONFIRMED"]: if user.status is not UserStatus.CONFIRMED:
raise NotAuthorizedError( raise NotAuthorizedError(
"User password cannot be reset in the current state." "User password cannot be reset in the current state."
) )
@ -639,7 +653,7 @@ class CognitoIdpBackend(BaseBackend):
raise InvalidParameterException( raise InvalidParameterException(
"Cannot reset password for the user as there is no registered/verified email or phone_number" "Cannot reset password for the user as there is no registered/verified email or phone_number"
) )
user.status = UserStatus["RESET_REQUIRED"] user.status = UserStatus.RESET_REQUIRED
# User # User
def admin_create_user( def admin_create_user(
@ -700,7 +714,7 @@ class CognitoIdpBackend(BaseBackend):
user_pool_id, user_pool_id,
username, username,
temporary_password, temporary_password,
UserStatus["FORCE_CHANGE_PASSWORD"], UserStatus.FORCE_CHANGE_PASSWORD,
attributes, attributes,
) )
@ -737,7 +751,7 @@ class CognitoIdpBackend(BaseBackend):
if ( if (
not user not user
or not user.enabled or not user.enabled
or user.status != UserStatus["CONFIRMED"] or user.status is not UserStatus.CONFIRMED
): ):
raise NotAuthorizedError("username") raise NotAuthorizedError("username")
return user return user
@ -809,8 +823,8 @@ class CognitoIdpBackend(BaseBackend):
raise NotAuthorizedError(username) raise NotAuthorizedError(username)
if user.status in [ if user.status in [
UserStatus["FORCE_CHANGE_PASSWORD"], UserStatus.FORCE_CHANGE_PASSWORD,
UserStatus["RESET_REQUIRED"], UserStatus.RESET_REQUIRED,
]: ]:
session = str(uuid.uuid4()) session = str(uuid.uuid4())
self.sessions[session] = user_pool self.sessions[session] = user_pool
@ -862,7 +876,7 @@ class CognitoIdpBackend(BaseBackend):
raise UserNotFoundError(username) raise UserNotFoundError(username)
user.password = new_password user.password = new_password
user.status = UserStatus["CONFIRMED"] user.status = UserStatus.CONFIRMED
del self.sessions[session] del self.sessions[session]
return self._log_user_in(user_pool, client, username) return self._log_user_in(user_pool, client, username)
@ -933,6 +947,45 @@ class CognitoIdpBackend(BaseBackend):
else: else:
raise ResourceNotFoundError(client_id) raise ResourceNotFoundError(client_id)
def forgot_password(self, client_id, username):
"""The ForgotPassword operation is partially broken in AWS. If the input is 100% correct it works fine.
Otherwise you get semi-random garbage and HTTP 200 OK, for example:
- recovery for username which is not registered in any cognito pool
- recovery for username belonging to a different user pool than the client id is registered to
- phone-based recovery for a user without phone_number / phone_number_verified attributes
- same as above, but email / email_verified
"""
for user_pool in self.user_pools.values():
if client_id in user_pool.clients:
recovery_settings = user_pool._account_recovery_setting()
user = user_pool._get_user(username)
break
else:
raise ResourceNotFoundError("Username/client id combination not found.")
code_delivery_details = {
"Destination": username + "@h***.com"
if not user
else user.attribute_lookup.get("email", username + "@h***.com"),
"DeliveryMedium": "EMAIL",
"AttributeName": "email",
}
selected_recovery = min(
recovery_settings["RecoveryMechanisms"],
key=lambda recovery_mechanism: recovery_mechanism["Priority"],
)
if selected_recovery["Name"] == "admin_only":
raise NotAuthorizedError("Contact administrator to reset password.")
if selected_recovery["Name"] == "verified_phone_number":
code_delivery_details = {
"Destination": "+*******9934"
if not user
else user.attribute_lookup.get("phone_number", "+*******9934"),
"DeliveryMedium": "SMS",
"AttributeName": "phone_number",
}
return {"CodeDeliveryDetails": code_delivery_details}
def change_password(self, access_token, previous_password, proposed_password): def change_password(self, access_token, previous_password, proposed_password):
for user_pool in self.user_pools.values(): for user_pool in self.user_pools.values():
if access_token in user_pool.access_tokens: if access_token in user_pool.access_tokens:
@ -946,10 +999,10 @@ class CognitoIdpBackend(BaseBackend):
user.password = proposed_password user.password = proposed_password
if user.status in [ if user.status in [
UserStatus["FORCE_CHANGE_PASSWORD"], UserStatus.FORCE_CHANGE_PASSWORD,
UserStatus["RESET_REQUIRED"], UserStatus.RESET_REQUIRED,
]: ]:
user.status = UserStatus["CONFIRMED"] user.status = UserStatus.CONFIRMED
break break
else: else:
@ -1050,7 +1103,7 @@ class CognitoIdpBackend(BaseBackend):
username=username, username=username,
password=password, password=password,
attributes=attributes, attributes=attributes,
status=UserStatus["UNCONFIRMED"], status=UserStatus.UNCONFIRMED,
) )
user_pool.users[user.username] = user user_pool.users[user.username] = user
return user return user
@ -1067,7 +1120,7 @@ class CognitoIdpBackend(BaseBackend):
if not user: if not user:
raise UserNotFoundError(username) raise UserNotFoundError(username)
user.status = UserStatus["CONFIRMED"] user.status = UserStatus.CONFIRMED
return "" return ""
def initiate_auth(self, client_id, auth_flow, auth_parameters): def initiate_auth(self, client_id, auth_flow, auth_parameters):
@ -1096,7 +1149,7 @@ class CognitoIdpBackend(BaseBackend):
if not user: if not user:
raise UserNotFoundError(username) raise UserNotFoundError(username)
if user.status == UserStatus["UNCONFIRMED"]: if user.status is UserStatus.UNCONFIRMED:
raise UserNotConfirmedException("User is not confirmed.") raise UserNotConfirmedException("User is not confirmed.")
session = str(uuid.uuid4()) session = str(uuid.uuid4())
@ -1125,7 +1178,7 @@ class CognitoIdpBackend(BaseBackend):
if user.password != password: if user.password != password:
raise NotAuthorizedError("Incorrect username or password.") raise NotAuthorizedError("Incorrect username or password.")
if user.status == UserStatus["UNCONFIRMED"]: if user.status is UserStatus.UNCONFIRMED:
raise UserNotConfirmedException("User is not confirmed.") raise UserNotConfirmedException("User is not confirmed.")
session = str(uuid.uuid4()) session = str(uuid.uuid4())
@ -1236,9 +1289,9 @@ class CognitoIdpBackend(BaseBackend):
user = self.admin_get_user(user_pool_id, username) user = self.admin_get_user(user_pool_id, username)
user.password = password user.password = password
if permanent: if permanent:
user.status = UserStatus["CONFIRMED"] user.status = UserStatus.CONFIRMED
else: else:
user.status = UserStatus["FORCE_CHANGE_PASSWORD"] user.status = UserStatus.FORCE_CHANGE_PASSWORD
cognitoidp_backends = {} cognitoidp_backends = {}

View File

@ -432,9 +432,11 @@ class CognitoIdpResponse(BaseResponse):
return json.dumps(auth_result) return json.dumps(auth_result)
def forgot_password(self): def forgot_password(self):
return json.dumps( client_id = self._get_param("ClientId")
{"CodeDeliveryDetails": {"DeliveryMedium": "EMAIL", "Destination": "..."}} username = self._get_param("Username")
) region = find_region_by_value("client_id", client_id)
response = cognitoidp_backends[region].forgot_password(client_id, username)
return json.dumps(response)
# This endpoint receives no authorization header, so if moto-server is listening # This endpoint receives no authorization header, so if moto-server is listening
# on localhost (doesn't get a region in the host header), it doesn't know what # on localhost (doesn't get a region in the host header), it doesn't know what

View File

@ -207,12 +207,21 @@ def test_describe_user_pool():
name = str(uuid.uuid4()) name = str(uuid.uuid4())
value = str(uuid.uuid4()) value = str(uuid.uuid4())
user_pool_details = conn.create_user_pool( user_pool_details = conn.create_user_pool(
PoolName=name, LambdaConfig={"PreSignUp": value} PoolName=name,
LambdaConfig={"PreSignUp": value},
AccountRecoverySetting={
"RecoveryMechanisms": [{"Name": "verified_email", "Priority": 1}]
},
) )
result = conn.describe_user_pool(UserPoolId=user_pool_details["UserPool"]["Id"]) result = conn.describe_user_pool(UserPoolId=user_pool_details["UserPool"]["Id"])
result["UserPool"]["Name"].should.equal(name) result["UserPool"]["Name"].should.equal(name)
result["UserPool"]["LambdaConfig"]["PreSignUp"].should.equal(value) result["UserPool"]["LambdaConfig"]["PreSignUp"].should.equal(value)
result["UserPool"]["AccountRecoverySetting"]["RecoveryMechanisms"][0][
"Name"
].should.equal("verified_email")
result["UserPool"]["AccountRecoverySetting"]["RecoveryMechanisms"][0][
"Priority"
].should.equal(1)
@mock_cognitoidp @mock_cognitoidp
@ -2153,9 +2162,126 @@ def test_change_password__using_custom_user_agent_header():
@mock_cognitoidp @mock_cognitoidp
def test_forgot_password(): def test_forgot_password():
conn = boto3.client("cognito-idp", "us-west-2") conn = boto3.client("cognito-idp", "us-west-2")
user_pool_id = conn.create_user_pool(PoolName=str(uuid.uuid4()))["UserPool"]["Id"]
client_id = conn.create_user_pool_client(
UserPoolId=user_pool_id, ClientName=str(uuid.uuid4())
)["UserPoolClient"]["ClientId"]
result = conn.forgot_password(ClientId=client_id, Username=str(uuid.uuid4()))
result = conn.forgot_password(ClientId=create_id(), Username=str(uuid.uuid4())) result["CodeDeliveryDetails"]["Destination"].should.not_be.none
result["CodeDeliveryDetails"].should_not.be.none result["CodeDeliveryDetails"]["DeliveryMedium"].should.equal("SMS")
result["CodeDeliveryDetails"]["AttributeName"].should.equal("phone_number")
@mock_cognitoidp
def test_forgot_password_nonexistent_client_id():
conn = boto3.client("cognito-idp", "us-west-2")
with pytest.raises(ClientError) as ex:
conn.forgot_password(ClientId=create_id(), Username=str(uuid.uuid4()))
err = ex.value.response["Error"]
err["Code"].should.equal("ResourceNotFoundException")
err["Message"].should.equal("Username/client id combination not found.")
@mock_cognitoidp
def test_forgot_password_admin_only_recovery():
conn = boto3.client("cognito-idp", "us-west-2")
user_pool_id = conn.create_user_pool(
PoolName=str(uuid.uuid4()),
AccountRecoverySetting={
"RecoveryMechanisms": [{"Name": "admin_only", "Priority": 1}]
},
)["UserPool"]["Id"]
client_id = conn.create_user_pool_client(
UserPoolId=user_pool_id, ClientName=str(uuid.uuid4())
)["UserPoolClient"]["ClientId"]
with pytest.raises(ClientError) as ex:
conn.forgot_password(ClientId=client_id, Username=str(uuid.uuid4()))
err = ex.value.response["Error"]
err["Code"].should.equal("NotAuthorizedException")
err["Message"].should.equal("Contact administrator to reset password.")
@mock_cognitoidp
def test_forgot_password_user_with_all_recovery_attributes():
conn = boto3.client("cognito-idp", "us-west-2")
user_pool_id = conn.create_user_pool(
PoolName=str(uuid.uuid4()),
AccountRecoverySetting={
"RecoveryMechanisms": [{"Name": "verified_email", "Priority": 1}]
},
)["UserPool"]["Id"]
client_id = conn.create_user_pool_client(
UserPoolId=user_pool_id, ClientName=str(uuid.uuid4())
)["UserPoolClient"]["ClientId"]
username = str(uuid.uuid4())
conn.admin_create_user(
UserPoolId=user_pool_id,
Username=username,
UserAttributes=[
{"Name": "email", "Value": "test@moto.com"},
{"Name": "phone_number", "Value": "555555555"},
],
)
result = conn.forgot_password(ClientId=client_id, Username=username)
result["CodeDeliveryDetails"]["Destination"].should.equal("test@moto.com")
result["CodeDeliveryDetails"]["DeliveryMedium"].should.equal("EMAIL")
result["CodeDeliveryDetails"]["AttributeName"].should.equal("email")
conn.update_user_pool(
UserPoolId=user_pool_id,
AccountRecoverySetting={
"RecoveryMechanisms": [{"Name": "verified_phone_number", "Priority": 1}]
},
)
result = conn.forgot_password(ClientId=client_id, Username=username)
result["CodeDeliveryDetails"]["Destination"].should.equal("555555555")
result["CodeDeliveryDetails"]["DeliveryMedium"].should.equal("SMS")
result["CodeDeliveryDetails"]["AttributeName"].should.equal("phone_number")
@mock_cognitoidp
def test_forgot_password_nonexistent_user_or_user_without_attributes():
conn = boto3.client("cognito-idp", "us-west-2")
user_pool_id = conn.create_user_pool(
PoolName=str(uuid.uuid4()),
AccountRecoverySetting={
"RecoveryMechanisms": [{"Name": "verified_email", "Priority": 1}]
},
)["UserPool"]["Id"]
client_id = conn.create_user_pool_client(
UserPoolId=user_pool_id, ClientName=str(uuid.uuid4())
)["UserPoolClient"]["ClientId"]
user_without_attributes = str(uuid.uuid4())
nonexistent_user = str(uuid.uuid4())
conn.admin_create_user(UserPoolId=user_pool_id, Username=user_without_attributes)
for user in user_without_attributes, nonexistent_user:
result = conn.forgot_password(ClientId=client_id, Username=user)
result["CodeDeliveryDetails"]["Destination"].should.equal(user + "@h***.com")
result["CodeDeliveryDetails"]["DeliveryMedium"].should.equal("EMAIL")
result["CodeDeliveryDetails"]["AttributeName"].should.equal("email")
conn.update_user_pool(
UserPoolId=user_pool_id,
AccountRecoverySetting={
"RecoveryMechanisms": [{"Name": "verified_phone_number", "Priority": 1}]
},
)
for user in user_without_attributes, nonexistent_user:
result = conn.forgot_password(ClientId=client_id, Username=user)
result["CodeDeliveryDetails"]["Destination"].should.equal("+*******9934")
result["CodeDeliveryDetails"]["DeliveryMedium"].should.equal("SMS")
result["CodeDeliveryDetails"]["AttributeName"].should.equal("phone_number")
@mock_cognitoidp @mock_cognitoidp