Skip to content

Commit 4f4dcfe

Browse files
committed
Add different magic token intents for email address verification
- Use intent: register when testing magic link endpoints - Update magic link tests - Allow any email to make an account in testing mode
1 parent e85b236 commit 4f4dcfe

File tree

5 files changed

+137
-94
lines changed

5 files changed

+137
-94
lines changed

pydatalab/src/pydatalab/routes/v0_1/auth.py

Lines changed: 56 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,11 @@
1414

1515
import jwt
1616
from bson import ObjectId
17-
from flask import Blueprint, Response, g, jsonify, redirect, request
17+
from flask import Blueprint, g, jsonify, redirect, request
1818
from flask_dance.consumer import OAuth2ConsumerBlueprint, oauth_authorized
1919
from flask_login import current_user, login_user
2020
from flask_login.utils import LocalProxy
21-
from werkzeug.exceptions import BadRequest
21+
from werkzeug.exceptions import BadRequest, Forbidden
2222

2323
from pydatalab.config import CONFIG
2424
from pydatalab.errors import UserRegistrationForbidden
@@ -253,6 +253,9 @@ def _check_email_domain(email: str, allow_list: list[str] | None) -> bool:
253253
Whether the email address is allowed to register an account.
254254
255255
"""
256+
if CONFIG.TESTING:
257+
return True
258+
256259
domain = email.split("@")[-1]
257260
if isinstance(allow_list, list) and not allow_list:
258261
return False
@@ -420,48 +423,36 @@ def attach_identity_to_user(
420423
wrapped_login_user(get_by_id(str(user.immutable_id)))
421424

422425

423-
def _validate_magic_link_request(email: str, referrer: str) -> tuple[Response | None, int]:
426+
def _validate_magic_link_request(email: str, referrer: str) -> None:
424427
if not email:
425-
return jsonify({"status": "error", "detail": "No email provided."}), 400
428+
raise BadRequest("No email provided")
426429

427430
if not re.match(r"^\S+@\S+.\S+$", email):
428-
return jsonify({"status": "error", "detail": "Invalid email provided."}), 400
431+
raise BadRequest("Invalid email provided.")
429432

430433
if not referrer:
431-
LOGGER.warning("No referrer provided for magic link request")
432-
return (
433-
jsonify(
434-
{
435-
"status": "error",
436-
"detail": "Referrer address not provided, please contact the datalab administrator.",
437-
}
438-
),
439-
400,
440-
)
441-
442-
return None, 200
434+
raise BadRequest("Referrer address not provided, please contact the datalab administrator")
443435

444436

445-
def _generate_and_store_token(email: str, is_test: bool = False) -> str:
437+
def _generate_and_store_token(email: str, intent: str = "register") -> str:
446438
"""Generate a JWT for the user with a short expiration and store it in the session.
447439
448440
The session itself persists beyond the JWT expiration. The `exp` key is a standard
449441
part of JWT that PyJWT treats as an expiration time and will correctly encode the datetime.
450442
451443
Args:
452444
email: The user's email address to include in the token.
453-
is_test: If True, generates a token for testing purposes that may have different
454-
expiration or validation rules. Defaults to False.
445+
intent: The intent of the magic link, e.g., "register" "verify", or "login".
455446
456447
Returns:
457448
The generated JWT token string.
449+
458450
"""
459451
payload = {
460452
"exp": datetime.datetime.now(datetime.timezone.utc) + LINK_EXPIRATION,
461453
"email": email,
454+
"intent": intent,
462455
}
463-
if is_test:
464-
payload["is_test"] = True
465456

466457
token = jwt.encode(
467458
payload,
@@ -474,45 +465,49 @@ def _generate_and_store_token(email: str, is_test: bool = False) -> str:
474465
return token
475466

476467

477-
def _check_user_registration_allowed(email: str) -> tuple[Response | None, int]:
468+
def _check_user_registration_allowed(email: str) -> None:
478469
user = find_user_with_identity(email, IdentityType.EMAIL, verify=False)
479470

480471
if not user:
481472
allowed = _check_email_domain(email, CONFIG.EMAIL_DOMAIN_ALLOW_LIST)
482473
if not allowed:
483-
LOGGER.info("Did not allow %s to register an account", email)
484-
return (
485-
jsonify(
486-
{
487-
"status": "error",
488-
"detail": f"Email address {email} is not allowed to register an account. Please contact the administrator if you believe this is an error.",
489-
}
490-
),
491-
403,
474+
LOGGER.warning("Did not allow %s to register an account", email)
475+
raise Forbidden(
476+
f"Email address {email} is not allowed to register an account. Please contact the administrator if you believe this is an error."
492477
)
493478

494-
return None, 200
495479

480+
def _send_magic_link_email(
481+
email: str, token: str, referrer: str | None, purpose: str = "authorize"
482+
) -> None:
483+
if not referrer:
484+
referrer = "https://example.com"
496485

497-
def _send_magic_link_email(email: str, token: str, referrer: str) -> tuple[Response | None, int]:
498486
link = f"{referrer}?token={token}"
499487
instance_url = referrer.replace("https://", "")
500-
user = find_user_with_identity(email, IdentityType.EMAIL, verify=False)
501488

502-
if user is not None:
503-
subject = "Datalab Sign-in Magic Link"
504-
body = f"Click the link below to sign-in to the datalab instance at {instance_url}:\n\n{link}\n\nThis link is single-use and will expire in 1 hour."
489+
if purpose == "authorize":
490+
user = find_user_with_identity(email, IdentityType.EMAIL, verify=False)
491+
if user is not None:
492+
subject = "datalab Sign-in Magic Link"
493+
body = f"Click the link below to sign-in to the datalab instance at {instance_url}:\n\n{link}\n\nThis link is single-use and will expire in 1 hour."
494+
else:
495+
subject = "datalab Registration Magic Link"
496+
body = f"Click the link below to register for the datalab instance at {instance_url}:\n\n{link}\n\nThis link is single-use and will expire in 1 hour."
497+
498+
elif purpose == "verify":
499+
subject = "datalab Email Address Verification"
500+
body = f"Click the link below to verify your email address for the datalab instance at {instance_url}:\n\n{link}\n\nThis link is single-use and will expire in 1 hour."
501+
505502
else:
506-
subject = "Datalab Registration Magic Link"
507-
body = f"Click the link below to register for the datalab instance at {instance_url}:\n\n{link}\n\nThis link is single-use and will expire in 1 hour."
503+
LOGGER.critical("Unknown purpose %s for magic link email", purpose)
504+
raise RuntimeError("Unknown error occurred")
508505

509506
try:
510507
send_mail(email, subject, body)
511508
except Exception as exc:
512509
LOGGER.warning("Failed to send email to %s: %s", email, exc)
513-
return jsonify({"status": "error", "detail": "Email not sent successfully."}), 400
514-
515-
return None, 200
510+
raise RuntimeError("Email not sent successfully.")
516511

517512

518513
@EMAIL_BLUEPRINT.route("/magic-link", methods=["POST"])
@@ -526,21 +521,12 @@ def generate_and_share_magic_link():
526521
email = request_json.get("email")
527522
referrer = request_json.get("referrer")
528523

529-
error_response, status_code = _validate_magic_link_request(email, referrer)
530-
if error_response:
531-
return error_response, status_code
532-
533-
error_response, status_code = _check_user_registration_allowed(email)
534-
if error_response:
535-
return error_response, status_code
524+
_validate_magic_link_request(email, referrer)
525+
_check_user_registration_allowed(email)
526+
token = _generate_and_store_token(email, intent="register")
527+
_send_magic_link_email(email, token, referrer)
536528

537-
token = _generate_and_store_token(email)
538-
539-
error_response, status_code = _send_magic_link_email(email, token, referrer)
540-
if error_response:
541-
return error_response, status_code
542-
543-
return jsonify({"status": "success", "detail": "Email sent successfully."}), 200
529+
return jsonify({"status": "success", "message": "Email sent successfully."}), 200
544530

545531

546532
@EMAIL_BLUEPRINT.route("/email")
@@ -580,21 +566,22 @@ def email_logged_in():
580566
# If the email domain list is explicitly configured to None, this allows any
581567
# email address to make an active account, otherwise the email domain must match
582568
# the list of allowed domains and the admin must verify the user
583-
is_test = data.get("is_test", False)
584569

585-
if not is_test:
570+
if data.get("intent") == "register":
586571
allowed = _check_email_domain(email, CONFIG.EMAIL_DOMAIN_ALLOW_LIST)
587572
if not allowed:
588573
raise UserRegistrationForbidden
589574

590-
create_account = AccountStatus.UNVERIFIED
591-
if (
592-
CONFIG.EMAIL_DOMAIN_ALLOW_LIST is None
593-
or CONFIG.EMAIL_AUTO_ACTIVATE_ACCOUNTS
594-
or CONFIG.AUTO_ACTIVATE_ACCOUNTS
595-
or is_test
596-
):
597-
create_account = AccountStatus.ACTIVE
575+
create_account = AccountStatus.UNVERIFIED
576+
if (
577+
CONFIG.EMAIL_DOMAIN_ALLOW_LIST is None
578+
or CONFIG.EMAIL_AUTO_ACTIVATE_ACCOUNTS
579+
or CONFIG.AUTO_ACTIVATE_ACCOUNTS
580+
):
581+
create_account = AccountStatus.ACTIVE
582+
583+
else:
584+
create_account = False
598585

599586
find_create_or_modify_user(
600587
email,
@@ -751,10 +738,8 @@ def create_test_magic_link():
751738
email = request_json.get("email")
752739
referrer = request_json.get("referrer", "http://localhost:8080")
753740

754-
error_response, status_code = _validate_magic_link_request(email, referrer)
755-
if error_response:
756-
return error_response, status_code
741+
_validate_magic_link_request(email, referrer)
757742

758-
token = _generate_and_store_token(email, is_test=True)
743+
token = _generate_and_store_token(email, intent="register")
759744

760745
return jsonify({"status": "success", "token": token}), 200
Lines changed: 70 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
from bson import ObjectId
22
from flask import Blueprint, jsonify, request
33
from flask_login import current_user
4+
from werkzeug.exceptions import BadRequest, Forbidden, Unauthorized
45

56
from pydatalab.config import CONFIG
6-
from pydatalab.models.people import DisplayName, EmailStr
7+
from pydatalab.logger import LOGGER
8+
from pydatalab.models.people import AccountStatus, DisplayName, EmailStr
79
from pydatalab.mongo import flask_mongo
810
from pydatalab.permissions import active_users_or_get_only
11+
from pydatalab.routes.v0_1.auth import _generate_and_store_token, _send_magic_link_email
912

1013
USERS = Blueprint("users", __name__)
1114

@@ -29,51 +32,98 @@ def save_user(user_id):
2932
account_status = request_json.get("account_status", None)
3033

3134
if not current_user.is_authenticated and not CONFIG.TESTING:
32-
return (jsonify({"status": "error", "message": "No user authenticated."}), 401)
35+
raise Unauthorized("No user authenticated.")
3336

3437
if not CONFIG.TESTING and current_user.id != user_id and current_user.role != "admin":
35-
return (
36-
jsonify({"status": "error", "message": "User not allowed to edit this profile."}),
37-
403,
38-
)
38+
raise Forbidden("Current user not allowed to edit this profile.")
3939

4040
update = {}
4141

4242
try:
4343
if display_name:
4444
update["display_name"] = DisplayName(display_name)
4545

46+
except ValueError:
47+
raise BadRequest(f"Invalid display name {display_name!r} was passed")
48+
49+
try:
4650
if contact_email or contact_email in (None, ""):
4751
if contact_email in ("", None):
4852
update["contact_email"] = None
4953
else:
5054
update["contact_email"] = EmailStr(contact_email)
5155

52-
if account_status:
53-
update["account_status"] = account_status
56+
except ValueError:
57+
raise BadRequest(f"Invalid email address {contact_email!r} was passed")
58+
59+
trigger_email_verification = False
60+
if update.get("contact_email"):
61+
# Check if this email identity already exists for this user
62+
existing_email_identity = False
63+
if update.get("contact_email") is not None:
64+
existing_email_identity = flask_mongo.db.users.find_one(
65+
{
66+
"_id": ObjectId(user_id),
67+
"identities": {"$elemMatch": {"type": "email", "identifier": contact_email}},
68+
}
69+
)
70+
if not existing_email_identity:
71+
# If not, push it as a new unverified identity
72+
flask_mongo.db.users.update_one(
73+
{"_id": ObjectId(user_id)},
74+
{
75+
"$push": {
76+
"identities": {
77+
"identity_type": "email",
78+
"identifier": contact_email,
79+
"name": contact_email,
80+
"verified": False,
81+
}
82+
}
83+
},
84+
)
85+
trigger_email_verification = True
86+
87+
if existing_email_identity and not existing_email_identity.get("verified"):
88+
# If this did exist, but is not yet verified, also trigger an email
89+
trigger_email_verification = True
90+
91+
if trigger_email_verification:
92+
token = _generate_and_store_token(
93+
email=contact_email,
94+
intent="verify",
95+
)
96+
try:
97+
_send_magic_link_email(
98+
email=contact_email,
99+
token=token,
100+
referrer=CONFIG.APP_URL,
101+
purpose="verify",
102+
)
103+
except RuntimeError as e:
104+
trigger_email_verification = False
105+
LOGGER.critical(f"Unable to send verification email on this deployment: {e}")
54106

55-
except ValueError as e:
56-
return jsonify(
57-
{"status": "error", "message": f"Invalid display name or email was passed: {str(e)}"}
58-
), 400
107+
try:
108+
if account_status:
109+
update["account_status"] = AccountStatus(account_status)
110+
except ValueError:
111+
raise BadRequest(f"Invalid account status {account_status!r} was passed")
59112

60113
if not update:
61-
return jsonify({"status": "success", "message": "No update was performed."}), 200
114+
return jsonify({"status": "success", "message": "No update to perform."}), 200
62115

63116
update_result = flask_mongo.db.users.update_one({"_id": ObjectId(user_id)}, {"$set": update})
64117

65118
if update_result.matched_count != 1:
66-
return (jsonify({"status": "error", "message": "Unable to update user."}), 400)
119+
raise BadRequest("Unable to update user.")
67120

68-
if update_result.modified_count != 1:
121+
if trigger_email_verification:
69122
return (
70123
jsonify(
71-
{
72-
"status": "success",
73-
"message": "No update was performed",
74-
}
124+
{"message": f"Verification email sent to {contact_email}", "status": "success"}
75125
),
76126
200,
77127
)
78128

79-
return (jsonify({"status": "success"}), 200)
129+
return (jsonify({"message": "User updated successfully", "status": "success"}), 200)

pydatalab/tests/server/test_auth.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ def test_magic_link_account_creation(unauthenticated_client, app, database):
1919
"/login/magic-link",
2020
json={"email": "[email protected]", "referrer": "datalab.example.org"},
2121
)
22-
assert response.json["detail"] == "Email sent successfully."
22+
assert response.json["message"] == "Email sent successfully."
2323
assert response.status_code == 200
2424
assert len(outbox) == 1
2525

@@ -46,7 +46,7 @@ def test_magic_links_expected_failures(unauthenticated_client, app):
4646
)
4747
assert response.status_code == 400
4848
assert len(outbox) == 0
49-
assert response.json["detail"] == "Invalid email provided."
49+
assert response.json["message"] == "Invalid email provided."
5050

5151
response = unauthenticated_client.post(
5252
"/login/magic-link",

pydatalab/tests/server/test_users.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ def test_user_update(client, real_mongo_client, user_id, admin_user_id):
5656
assert resp.status_code == 200
5757
user = real_mongo_client.get_database().users.find_one({"_id": user_id})
5858
assert user["contact_email"] == "[email protected]"
59+
assert user["identities"][-1]["identifier"] == "[email protected]"
60+
assert not user["identities"][-1]["verified"]
5961

6062
# Test that display name -> None does not remove display name
6163
user_request = {"display_name": None}

0 commit comments

Comments
 (0)