Added significant verification to ForgotPassword, changed UserStatus dict to enum (#4469)
This commit is contained in:
parent
a4a8949166
commit
fee16cb388
@ -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 = {}
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
Loading…
x
Reference in New Issue
Block a user