-
Notifications
You must be signed in to change notification settings - Fork 119
Add email normalization utility and integrate into login flow #5694
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,17 @@ | ||||||||||||||||||||||||||
| # Utility to normalize emails for account merging | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| def normalize_email(email: str) -> str: | ||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||
| Normalize email by removing any "+..." before the @, and lowercasing. | ||||||||||||||||||||||||||
| E.g. user+alias@example.com -> user@example.com | ||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||
| if not email: | ||||||||||||||||||||||||||
| return email | ||||||||||||||||||||||||||
| if "@" not in email: | ||||||||||||||||||||||||||
| return email | ||||||||||||||||||||||||||
|
Comment on lines
+8
to
+12
|
||||||||||||||||||||||||||
| """ | |
| if not email: | |
| return email | |
| if "@" not in email: | |
| return email | |
| Raises: | |
| ValueError: If the email does not contain an '@' character. | |
| """ | |
| if not email: | |
| return email | |
| if "@" not in email: | |
| raise ValueError(f"Invalid email format: '{email}' (missing '@')") |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -24,6 +24,7 @@ | |
|
|
||
| from . import apps, config, models | ||
| from .database import get_db | ||
| from .email_utils import normalize_email | ||
| from .emails import EmailCategory | ||
| from .login_info import ( | ||
| LoginInformation, | ||
|
|
@@ -822,12 +823,19 @@ def continue_oauth_flow( | |
| ) | ||
| # We now have a logged in user, so let's do our best to do something useful | ||
| provider_data = token_to_data(login_result) | ||
| # Do we have a provider's user noted with this ID already? | ||
|
|
||
| # Try to find an existing account by provider ID | ||
| account = account_model.by_provider_id(db, provider_data.id) | ||
| normalized_email = ( | ||
| normalize_email(provider_data.email) if provider_data.email else None | ||
| ) | ||
| merged_user = None | ||
| if normalized_email: | ||
| merged_user = models.FlathubUser.by_normalized_email(db, normalized_email) | ||
|
Comment on lines
+829
to
+834
|
||
|
|
||
| if account is None: | ||
| # We've never seen this provider's user before, if we're not already logged | ||
| # in then create a user | ||
| user = login.user | ||
| # If merging, use the merged user, else current login user, else new user | ||
| user = merged_user or login.user | ||
| if user is None: | ||
| user = models.FlathubUser(display_name=provider_data.name) | ||
| db.add(user) | ||
|
|
@@ -855,8 +863,7 @@ def continue_oauth_flow( | |
| # The provider's user has been seen before, if we're logged in already and | ||
| # things don't match then abort now | ||
| user = login.user | ||
| if user is not None: | ||
| # Eventually we might do user-merge here? | ||
| if user is not None and (merged_user is None or user.id != merged_user.id): | ||
|
||
| db.commit() | ||
| return JSONResponse( | ||
| {"status": "error", "error": "error-already-logged-in"}, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -23,6 +23,7 @@ | |
| desc, | ||
| false, | ||
| func, | ||
| literal, | ||
| or_, | ||
| text, | ||
| ) | ||
|
|
@@ -426,6 +427,49 @@ def by_permission(db, permission_name: str): | |
|
|
||
| return result | ||
|
|
||
| @staticmethod | ||
| def by_normalized_email(db, normalized_email: str) -> Optional["FlathubUser"]: | ||
| """ | ||
| Find a FlathubUser by normalized email across all connected account types. | ||
|
|
||
| Returns: | ||
| FlathubUser if a user is found, otherwise None. | ||
| """ | ||
| account_classes = [ | ||
| GithubAccount, | ||
| GitlabAccount, | ||
| GnomeAccount, | ||
| GoogleAccount, | ||
| KdeAccount, | ||
| ] | ||
| for acc_cls in account_classes: | ||
| # Normalize email: lowercase, remove anything after '+' in local part, keep domain | ||
| norm_expr = func.lower( | ||
| func.concat( | ||
| func.substring( | ||
| acc_cls.email, | ||
| 1, | ||
| func.nullif(func.position(literal("+"), acc_cls.email) - 1, -1), | ||
| ) | ||
| if func.position(literal("+"), acc_cls.email) > 0 | ||
| else func.substring( | ||
| acc_cls.email, 1, func.position(literal("@"), acc_cls.email) - 1 | ||
| ), | ||
| literal("@"), | ||
| func.substring( | ||
| acc_cls.email, func.position(literal("@"), acc_cls.email) + 1 | ||
| ), | ||
| ) | ||
| ) | ||
|
Comment on lines
+447
to
+463
|
||
| match = ( | ||
| db.session.query(acc_cls) | ||
| .filter(acc_cls.email.isnot(None), norm_expr == normalized_email) | ||
| .first() | ||
| ) | ||
| if match: | ||
| return match.user_entity | ||
| return None | ||
|
|
||
|
|
||
| class flathubuser_role(Base): | ||
| __tablename__ = "flathubuser_role" | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| from app.email_utils import normalize_email | ||
|
|
||
|
|
||
| def test_normalize_email_removes_plus(): | ||
| assert normalize_email("user+alias@example.com") == "user@example.com" | ||
| assert normalize_email("USER+foo@Example.com") == "user@example.com" | ||
|
|
||
|
|
||
| def test_normalize_email_no_plus(): | ||
| assert normalize_email("user@example.com") == "user@example.com" | ||
|
|
||
|
|
||
| def test_normalize_email_multiple_plus(): | ||
| assert normalize_email("user+foo+bar@example.com") == "user@example.com" | ||
|
|
||
|
|
||
| def test_normalize_email_empty(): | ||
| assert normalize_email("") == "" | ||
|
|
||
|
|
||
| def test_normalize_email_case_insensitive(): | ||
| assert normalize_email("User+Test@Example.COM") == "user@example.com" |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,31 @@ | ||||||
| from fastapi.testclient import TestClient | ||||||
|
|
||||||
| from app.main import app | ||||||
|
|
||||||
| client = TestClient(app) | ||||||
|
|
||||||
|
|
||||||
| def test_login_methods(): | ||||||
| response = client.get("/auth/login") | ||||||
| assert response.status_code == 200 | ||||||
| data = response.json() | ||||||
| assert any(m["method"] == "github" for m in data) | ||||||
| assert any(m["method"] == "gitlab" for m in data) | ||||||
| assert any(m["method"] == "gnome" for m in data) | ||||||
| assert any(m["method"] == "kde" for m in data) | ||||||
|
|
||||||
|
|
||||||
| def test_userinfo_not_logged_in(): | ||||||
| response = client.get("/auth/userinfo") | ||||||
| assert response.status_code == 204 | ||||||
|
|
||||||
|
|
||||||
| def test_deleteuser_not_logged_in(): | ||||||
| response = client.get("/auth/deleteuser") | ||||||
|
||||||
| response = client.get("/auth/deleteuser") | |
| response = client.delete("/auth/deleteuser") |
Uh oh!
There was an error while loading. Please reload this page.