Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions app/backend/src/couchers/servicers/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,47 @@ def SignupFlow(
signup_guidelines_accepted_counter.inc()
flow.accepted_community_guidelines = GUIDELINES_VERSION
session.flush()
if request.HasField("change_email"):
if flow.email_verified:
context.abort_with_error_code(
grpc.StatusCode.FAILED_PRECONDITION,
"signup_flow_email_taken",
)
new_email = request.change_email.new_email
if not is_valid_email(new_email):
context.abort_with_error_code(
grpc.StatusCode.INVALID_ARGUMENT,
"invalid_email",
)
existing_user = session.execute(select(User).where(User.email == new_email)).scalar_one_or_none()
if existing_user:
if not existing_user.is_visible:
context.abort_with_error_code(
grpc.StatusCode.FAILED_PRECONDITION,
"signup_email_cannot_be_used",
)
context.abort_with_error_code(
grpc.StatusCode.FAILED_PRECONDITION,
"signup_flow_email_taken",
)
existing_signup = session.execute(
select(SignupFlow).where(
SignupFlow.email == new_email,
SignupFlow.flow_token != flow.flow_token,
)
).scalar_one_or_none()
if existing_signup:
context.abort_with_error_code(
grpc.StatusCode.FAILED_PRECONDITION,
"signup_flow_email_taken",
)
if flow.email != new_email:
flow.email = new_email
flow.email_token = None
flow.email_token_expiry = None
flow.email_sent = False
send_signup_email(context, session, flow)
session.flush()

# send verification email if needed
if not flow.email_sent or request.resend_verification_email:
Expand Down
232 changes: 231 additions & 1 deletion app/backend/src/tests/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -421,7 +421,7 @@ def test_basic_login(db):

def test_login_part_signed_up_verified_email(db):
"""
If you try to log in but didn't finish singing up, we send you a new email and ask you to finish signing up.
If you try to log in but didn't finish signing up, we send you a new email and ask you to finish signing up.
"""
with auth_api_session() as (auth_api, metadata_interceptor):
res = auth_api.SignupFlow(
Expand Down Expand Up @@ -925,6 +925,236 @@ def test_signup_resend_email(db, email_collector: EmailCollector):
assert res.HasField("auth_res")


def test_signup_change_email(db, email_collector: EmailCollector):
old_email = f"{random_hex(12)}@couchers.org.invalid"
new_email = f"{random_hex(12)}@couchers.org.invalid"

# Start a signup with the old email.
with auth_api_session() as (auth_api, metadata_interceptor):
res = auth_api.SignupFlow(
auth_pb2.SignupFlowReq(
basic=auth_pb2.SignupBasic(
name="testing",
email=old_email,
)
)
)

flow_token = res.flow_token
assert flow_token

# Get the original verification token.
with session_scope() as session:
flow = session.execute(select(SignupFlow).where(SignupFlow.flow_token == flow_token)).scalar_one()

old_email_token = flow.email_token
assert flow.email == old_email
assert old_email_token

# Consume the initial email so we can specifically check the new email below.
email_collector.pop_for_recipient(old_email, last=True)

# Change the signup email.
with auth_api_session() as (auth_api, metadata_interceptor):
res = auth_api.SignupFlow(
auth_pb2.SignupFlowReq(
flow_token=flow_token,
change_email=auth_pb2.ChangeSignupEmail(new_email=new_email),
)
)

assert res.flow_token == flow_token
assert res.need_verify_email

# The signup should now have the new email and a new verification token.
with session_scope() as session:
flow = session.execute(select(SignupFlow).where(SignupFlow.flow_token == flow_token)).scalar_one()

assert flow.email == new_email
assert flow.email != old_email

new_email_token = flow.email_token
assert new_email_token
assert new_email_token != old_email_token

# The old token should no longer be usable.
with auth_api_session() as (auth_api, metadata_interceptor):
with pytest.raises(grpc.RpcError) as e:
auth_api.SignupFlow(
auth_pb2.SignupFlowReq(
email_token=old_email_token,
)
)

assert e.value.code() == grpc.StatusCode.NOT_FOUND

# The new email should receive a verification link containing the new token.
email = email_collector.pop_for_recipient(new_email, last=True)

assert email.recipient == new_email
assert new_email_token in email.plain
assert new_email_token in email.html
assert old_email_token not in email.plain
assert old_email_token not in email.html


@pytest.mark.parametrize("invalid_email", ["bad email", "a@b", "a@b.", "@ab.cd", "a@b.c"])
def test_signup_change_new_invalid_email(db, invalid_email):
old_email = f"{random_hex(12)}@couchers.org.invalid"

# Create a signup with a valid email first.
with auth_api_session() as (auth_api, metadata_interceptor):
res = auth_api.SignupFlow(
auth_pb2.SignupFlowReq(
basic=auth_pb2.SignupBasic(
name="frodo",
email=old_email,
)
)
)

flow_token = res.flow_token
assert flow_token

# Try changing to an invalid email.
with auth_api_session() as (auth_api, metadata_interceptor):
with pytest.raises(grpc.RpcError) as e:
auth_api.SignupFlow(
auth_pb2.SignupFlowReq(
flow_token=flow_token,
change_email=auth_pb2.ChangeSignupEmail(
new_email=invalid_email,
),
)
)

assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
assert e.value.details() == "Invalid email."

# Make sure the original email wasn't changed.
with session_scope() as session:
flow = session.execute(select(SignupFlow).where(SignupFlow.flow_token == flow_token)).scalar_one()

assert flow.email == old_email


def test_signup_change_email_after_email_verified(db):
old_email = f"{random_hex(12)}@couchers.org.invalid"
new_email = f"{random_hex(12)}@couchers.org.invalid"

# Start a signup.
with auth_api_session() as (auth_api, metadata_interceptor):
res = auth_api.SignupFlow(
auth_pb2.SignupFlowReq(
basic=auth_pb2.SignupBasic(
name="testing",
email=old_email,
)
)
)

flow_token = res.flow_token
assert flow_token

# Get the verification token.
with session_scope() as session:
flow = session.execute(select(SignupFlow).where(SignupFlow.flow_token == flow_token)).scalar_one()

email_token = flow.email_token
assert email_token
assert not flow.email_verified

# Verify the original email.
with auth_api_session() as (auth_api, metadata_interceptor):
res = auth_api.SignupFlow(
auth_pb2.SignupFlowReq(
email_token=email_token,
)
)

# The email should now be verified.
with session_scope() as session:
flow = session.execute(select(SignupFlow).where(SignupFlow.flow_token == flow_token)).scalar_one()

assert flow.email_verified

# Try changing to an invalid email.
with auth_api_session() as (auth_api, metadata_interceptor):
with pytest.raises(grpc.RpcError) as e:
auth_api.SignupFlow(
auth_pb2.SignupFlowReq(
flow_token=flow_token,
change_email=auth_pb2.ChangeSignupEmail(
new_email=new_email,
),
)
)
assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION
assert e.value.details() == "That email address is already associated with an account. Please log in instead!"

# Make sure the signup email wasn't changed.
with session_scope() as session:
flow = session.execute(select(SignupFlow).where(SignupFlow.flow_token == flow_token)).scalar_one()

assert flow.email == old_email
assert flow.email_verified


def test_signup_change_email_same_email_resends_existing_token(db, email_collector: EmailCollector):
testing_email = f"{random_hex(12)}@couchers.org.invalid"

with auth_api_session() as (auth_api, metadata_interceptor):
res = auth_api.SignupFlow(
auth_pb2.SignupFlowReq(
basic=auth_pb2.SignupBasic(
name="testing",
email=testing_email,
)
)
)

flow_token = res.flow_token
assert flow_token

# Get the original email token.
with session_scope() as session:
flow = session.execute(select(SignupFlow).where(SignupFlow.flow_token == flow_token)).scalar_one()

original_email_token = flow.email_token
assert original_email_token
assert flow.email == testing_email

# Clear the original email so we can inspect the one sent by
# SignupFlowChangeEmail.
email_collector.pop_for_recipient(testing_email, last=True)

# Ask to change the signup email to the same email.
with auth_api_session() as (auth_api, metadata_interceptor):
res = auth_api.SignupFlow(
auth_pb2.SignupFlowReq(
flow_token=flow_token,
change_email=auth_pb2.ChangeSignupEmail(new_email=testing_email),
)
)

assert res.flow_token == flow_token
assert res.need_verify_email

# The email should have been resent.
email = email_collector.pop_for_recipient(testing_email, last=True)
assert email.recipient == testing_email
assert original_email_token in email.plain
assert original_email_token in email.html

# The signup flow should still have the same token.
with session_scope() as session:
flow = session.execute(select(SignupFlow).where(SignupFlow.flow_token == flow_token)).scalar_one()

assert flow.email == testing_email
assert flow.email_token == original_email_token
assert not flow.email_verified


def test_successful_authenticate(db):
user, _ = generate_user(hashed_password=hash_password("password"))

Expand Down
7 changes: 6 additions & 1 deletion app/proto/auth.proto
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,8 @@ message SignupFlowReq {
google.protobuf.BoolValue accept_community_guidelines = 6;

bool resend_verification_email = 7;

ChangeSignupEmail change_email = 9;
}

message SignupFlowRes {
Expand Down Expand Up @@ -177,6 +179,9 @@ message SignupMotivations {
repeated string motivations = 2;
}

message ChangeSignupEmail {
string new_email = 1;
}
message UsernameValidReq {
string username = 1;
}
Expand Down Expand Up @@ -283,4 +288,4 @@ message GetInviteCodeInfoRes {
string username = 2;
string avatar_url = 3;
string url = 4;
}
}
Loading