Skip to content

Commit

Permalink
Handle multiple WebAuthn email factors (#7861)
Browse files Browse the repository at this point in the history
`email` is not exclusive for WebAuthn factors, so we need to detect the
case where you are trying to resend the verification email, require a
`credential_id` which is the exclusive identifier for each WebAuthn
credential stored in the browser for a given identity, and select on
that instead.
  • Loading branch information
scotttrinh authored Oct 15, 2024
1 parent 497d19c commit ef27a4d
Show file tree
Hide file tree
Showing 3 changed files with 192 additions and 17 deletions.
48 changes: 32 additions & 16 deletions edb/server/protocol/auth_ext/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,6 @@
webauthn,
magic_link,
webhook,
local,
)
from .data import EmailFactor

Expand Down Expand Up @@ -729,11 +728,21 @@ async def handle_resend_verification_email(
request_data = self._get_data_from_request(request)

_check_keyset(request_data, {"provider"})
local_client = local.Client(db=self.db)
provider_name = request_data["provider"]
local_client: email_password.Client | webauthn.Client
match provider_name:
case "builtin::local_emailpassword":
local_client = email_password.Client(db=self.db)
case "builtin::local_webauthn":
local_client = webauthn.Client(db=self.db)
case _:
raise errors.InvalidData(
f"Unsupported provider: {request_data['provider']}"
)

verify_url = request_data.get(
"verify_url", f"{self.base_path}/ui/verify"
)
identity_id: Optional[str] = None
email_factor: Optional[EmailFactor] = None
if "verification_token" in request_data:
(
Expand All @@ -748,7 +757,7 @@ async def handle_resend_verification_email(
email_factor = await local_client.get_email_factor_by_identity_id(
identity_id
)
elif "email" in request_data:
else:
maybe_challenge = request_data.get(
"challenge", request_data.get("code_challenge")
)
Expand All @@ -759,21 +768,28 @@ async def handle_resend_verification_email(
raise errors.InvalidData(
"Redirect URL does not match any allowed URLs.",
)
match local_client:
case webauthn.Client():
_check_keyset(request_data, {"credential_id"})
credential_id = base64.b64decode(
request_data["credential_id"]
)
email_factor = (
await local_client.get_email_factor_by_credential_id(
credential_id
)
)
case email_password.Client():
_check_keyset(request_data, {"email"})
email_factor = await local_client.get_email_factor_by_email(
request_data["email"]
)

email_factor = await local_client.get_email_factor_by_email(
request_data["email"]
)
identity_id = (
email_factor.identity.id if email_factor is not None else None
)
else:
raise errors.InvalidData("Missing 'verification_token' or 'email'")

if identity_id is None or email_factor is None:
if email_factor is None:
await auth_emails.send_fake_email(self.tenant)
else:
verification_token = self._make_verification_token(
identity_id=identity_id,
identity_id=email_factor.identity.id,
verify_url=verify_url,
maybe_challenge=maybe_challenge,
maybe_redirect_to=maybe_redirect_to,
Expand All @@ -782,7 +798,7 @@ async def handle_resend_verification_email(
webhook.EmailVerificationRequested(
event_id=str(uuid.uuid4()),
timestamp=datetime.datetime.now(datetime.timezone.utc),
identity_id=identity_id,
identity_id=email_factor.identity.id,
email_factor_id=email_factor.id,
verification_token=verification_token,
)
Expand Down
31 changes: 31 additions & 0 deletions edb/server/protocol/auth_ext/webauthn.py
Original file line number Diff line number Diff line change
Expand Up @@ -497,3 +497,34 @@ async def authenticate(
)

return factor.identity

async def get_email_factor_by_credential_id(
self,
credential_id: bytes,
) -> Optional[data.EmailFactor]:
result = await execute.parse_execute_json(
self.db,
"""
with
credential_id := <bytes>$credential_id,
select ext::auth::WebAuthnFactor {
id,
created_at,
modified_at,
email,
verified_at,
identity: {*},
} filter .credential_id = credential_id;""",
variables={
"credential_id": credential_id,
},
)
result_json = json.loads(result.decode())
if len(result_json) == 0:
return None
elif len(result_json) > 1:
# This should never happen given the exclusive constraint
raise errors.WebAuthnAuthenticationFailed(
"Multiple WebAuthn factors found for the same credential ID."
)
return data.EmailFactor(**result_json[0])
130 changes: 129 additions & 1 deletion tests/test_http_ext_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -3161,7 +3161,7 @@ async def test_http_auth_ext_local_password_authenticate_01(self):
auth_data_redirect_on_failure["redirect_on_failure"],
)

async def test_http_auth_ext_resend_verification_email(self):
async def test_http_auth_ext_local_emailpassword_resend_verification(self):
with self.http_con() as http_con:
# Register a new user
provider_config = await self.get_builtin_provider_config_by_name(
Expand Down Expand Up @@ -3298,6 +3298,134 @@ async def test_http_auth_ext_resend_verification_email(self):

self.assertEqual(status, 400)

async def test_http_auth_ext_local_webauthn_resend_verification(self):
with self.http_con() as http_con:
# Register a new user
provider_config = await self.get_builtin_provider_config_by_name(
"local_webauthn"
)
provider_name = provider_config.name
email = f"{uuid.uuid4()}@example.com"
credential_one = uuid.uuid4().bytes
credential_two = uuid.uuid4().bytes

await self.con.query_single(
"""
with
email := <str>$email,
user_handle := <bytes>$user_handle,
credential_one := <bytes>$credential_one,
public_key_one := <bytes>$public_key_one,
credential_two := <bytes>$credential_two,
public_key_two := <bytes>$public_key_two,
factor_one := (insert ext::auth::WebAuthnFactor {
email := email,
user_handle := user_handle,
credential_id := credential_one,
public_key := public_key_one,
identity := (insert ext::auth::LocalIdentity {
issuer := "local",
subject := "",
}),
}),
factor_two := (insert ext::auth::WebAuthnFactor {
email := email,
user_handle := user_handle,
credential_id := credential_two,
public_key := public_key_two,
identity := (insert ext::auth::LocalIdentity {
issuer := "local",
subject := "",
}),
}),
select true;
""",
email=email,
user_handle=uuid.uuid4().bytes,
credential_one=credential_one,
public_key_one=uuid.uuid4().bytes,
credential_two=credential_two,
public_key_two=uuid.uuid4().bytes,
)

# Resend verification email with credential_id
resend_data = {
"provider": provider_name,
"credential_id": base64.b64encode(credential_one).decode(),
}
resend_data_encoded = urllib.parse.urlencode(resend_data).encode()

_, _, status = self.http_con_request(
http_con,
None,
path="resend-verification-email",
method="POST",
body=resend_data_encoded,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)

self.assertEqual(status, 200)

file_name_hash = hashlib.sha256(
f"{SENDER}{email}".encode()
).hexdigest()
test_file = os.environ.get(
"EDGEDB_TEST_EMAIL_FILE",
f"/tmp/edb-test-email-{file_name_hash}.pickle",
)
with open(test_file, "rb") as f:
email_args = pickle.load(f)
self.assertEqual(email_args["sender"], SENDER)
self.assertEqual(email_args["recipients"], email)
html_msg = email_args["message"].get_payload(0).get_payload(1)
html_email = html_msg.get_payload(decode=True).decode("utf-8")
match = re.search(
r'<p style="word-break: break-all">([^<]+)', html_email
)
assert match is not None
verify_url = urllib.parse.urlparse(match.group(1))
search_params = urllib.parse.parse_qs(verify_url.query)
verification_token = search_params.get(
"verification_token", [None]
)[0]
assert verification_token is not None

# Resend verification email with the verification token
resend_data = {
"provider": provider_name,
"verification_token": verification_token,
}
resend_data_encoded = urllib.parse.urlencode(resend_data).encode()

_, _, status = self.http_con_request(
http_con,
None,
path="resend-verification-email",
method="POST",
body=resend_data_encoded,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)

self.assertEqual(status, 200)

# Resend verification email with email
resend_data = {
"provider": provider_name,
"email": email,
}
resend_data_encoded = urllib.parse.urlencode(resend_data).encode()

_, _, status = self.http_con_request(
http_con,
None,
path="resend-verification-email",
method="POST",
body=resend_data_encoded,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)

self.assertEqual(status, 400)

async def test_http_auth_ext_token_01(self):
base_url = self.mock_net_server.get_base_url().rstrip("/")
webhook_request = (
Expand Down

0 comments on commit ef27a4d

Please sign in to comment.