Skip to content

Commit

Permalink
feat: Add support to custom attributes in User model before creation.
Browse files Browse the repository at this point in the history
Use the `GOOGLE_SSO_PRE_CREATE_CALLBACK` to define a custom function which can return a dictionary which will be used during user creation for the `defaults` value.
  • Loading branch information
chrismaille committed Apr 9, 2024
1 parent 922d23b commit cc9ad6a
Show file tree
Hide file tree
Showing 11 changed files with 110 additions and 11 deletions.
6 changes: 5 additions & 1 deletion django_google_sso/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,11 @@
GOOGLE_SSO_AUTHENTICATION_BACKEND = getattr(
settings, "GOOGLE_SSO_AUTHENTICATION_BACKEND", None
)

GOOGLE_SSO_PRE_CREATE_CALLBACK = getattr(
settings,
"GOOGLE_SSO_PRE_CREATE_CALLBACK",
"django_google_sso.hooks.pre_create_user",
)
GOOGLE_SSO_PRE_LOGIN_CALLBACK = getattr(
settings,
"GOOGLE_SSO_PRE_LOGIN_CALLBACK",
Expand Down
14 changes: 14 additions & 0 deletions django_google_sso/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,17 @@ def pre_login_user(user: User, request: HttpRequest) -> None:
"""
Callback function called after user is created/retrieved but before logged in.
"""


def pre_create_user(google_user_info: dict, request: HttpRequest) -> dict | None:
"""
Callback function called before user is created.
params:
google_user_info: dict containing user info received from Google.
request: HttpRequest object.
return: dict content to be passed to User.objects.create() as `defaults` argument.
If not informed, field `username` is always the user email.
"""
return {}
10 changes: 7 additions & 3 deletions django_google_sso/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,10 +99,13 @@ def email_is_valid(self) -> bool:
logger.debug(f"Email {self.user_email} is not verified.")
return email_verified if email_verified is not None else False

def get_or_create_user(self):
def get_or_create_user(self, extra_users_args: dict | None = None):
user_model = get_user_model()
user_defaults = extra_users_args or {}
if "username" not in user_defaults:
user_defaults["username"] = self.user_email
user, created = user_model.objects.get_or_create(
email=self.user_email, defaults={"username": self.user_email}
email=self.user_email, defaults=user_defaults
)
self.check_first_super_user(user, user_model)
self.check_for_update(created, user)
Expand All @@ -125,7 +128,8 @@ def check_for_update(self, created, user):
self.check_for_permissions(user)
user.first_name = self.user_info.get("given_name")
user.last_name = self.user_info.get("family_name")
user.username = self.user_email
if not user.username:
user.username = self.user_email
user.set_unusable_password()
self.user_changed = True

Expand Down
1 change: 1 addition & 0 deletions django_google_sso/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ def callback_request_with_state(callback_request):
def client_with_session(client, settings, mocker, google_response):
settings.GOOGLE_SSO_ALLOWABLE_DOMAINS = ["example.com"]
settings.GOOGLE_SSO_PRE_LOGIN_CALLBACK = "django_google_sso.hooks.pre_login_user"
settings.GOOGLE_SSO_PRE_CREATE_CALLBACK = "django_google_sso.hooks.pre_create_user"
importlib.reload(conf)
session = client.session
session.update({"sso_state": "foo", "sso_next_url": SECRET_PATH})
Expand Down
11 changes: 9 additions & 2 deletions django_google_sso/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,8 @@ def callback(request: HttpRequest) -> HttpResponseRedirect:
return HttpResponseRedirect(login_failed_url)

# Get User Info from Google
user_helper = UserHelper(google.get_user_info(), request)
google_user_data = google.get_user_info()
user_helper = UserHelper(google_user_data, request)

# Check if User Info is valid to login
if not user_helper.email_is_valid:
Expand All @@ -93,9 +94,15 @@ def callback(request: HttpRequest) -> HttpResponseRedirect:
)
return HttpResponseRedirect(login_failed_url)

# Run Pre-Create Callback
module_path = ".".join(conf.GOOGLE_SSO_PRE_CREATE_CALLBACK.split(".")[:-1])
pre_login_fn = conf.GOOGLE_SSO_PRE_CREATE_CALLBACK.split(".")[-1]
module = importlib.import_module(module_path)
extra_users_args = getattr(module, pre_login_fn)(google_user_data, request)

# Get or Create User
if conf.GOOGLE_SSO_AUTO_CREATE_USERS:
user = user_helper.get_or_create_user()
user = user_helper.get_or_create_user(extra_users_args)
else:
user = user_helper.find_user()

Expand Down
16 changes: 12 additions & 4 deletions docs/multiple.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Using Multiple Social Logins

A special advanced case is when you need to login from multiple social providers. In this case, each provider will have its own
package which you need to install and configure:
A special advanced case is when you need to log in from multiple social providers. In this case, each provider will have its own
package which you need to install and configure. Currently, we support:

* [Django Google SSO](https://github.com/megalus/django-google-sso)
* [Django Microsoft SSO](https://github.com/megalus/django-microsoft-sso)
Expand Down Expand Up @@ -113,8 +113,16 @@ The login page will look like this:
SSO_SHOW_FORM_ON_ADMIN_PAGE = False
```

### The Django E003/W003 Warning
If you are using both **Django Google SSO** and **Django Microsoft SSO**, you will get the following warning:
## Avoiding duplicated Users
Both **Django GitHub SSO** and **Django Microsoft SSO** can create users without an email address, comparing the User `username`
field against the _Azure User Principal Name_ or _Github User Name_. This can cause duplicated users if you are using either package.

To avoid this, you can set the `MICROSOFT_SSO_UNIQUE_EMAIL` and `GITHUB_SSO_UNIQUE_EMAIL` settings to `True`,
making these packages compare User `email` against _Azure Mail_ field or _Github Primary Email_. Make sure your Azure Tenant
and GitHub Organization users have registered emails.

## The Django E003/W003 Warning
If you are using multiple **Django SSO** projects, you will get a warning like this:

```
WARNINGS:
Expand Down
1 change: 1 addition & 0 deletions docs/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
| `GOOGLE_SSO_LOGIN_FAILED_URL` | The named url path that the user will be redirected to if an authentication error is encountered. Default: `admin:index` |
| `GOOGLE_SSO_LOGO_URL` | The URL of the logo to be used on the login button. Default: `https://upload.wikimedia.org/wikipedia/commons/thumb/5/53/Google_%22G%22_Logo.svg/512px-Google_%22G%22_Logo.svg.png` |
| `GOOGLE_SSO_NEXT_URL` | The named url path that the user will be redirected if there is no next url after successful authentication. Default: `admin:index` |
| `GOOGLE_SSO_PRE_CREATE_CALLBACK` | Callable for processing pre-create logic. Default: `django_google_sso.hooks.pre_create_user` |
| `GOOGLE_SSO_PRE_LOGIN_CALLBACK` | Callable for processing pre-login logic. Default: `django_google_sso.hooks.pre_login_user` |
| `GOOGLE_SSO_PROJECT_ID` | The Google OAuth 2.0 Project ID. Default: `None` |
| `GOOGLE_SSO_SAVE_ACCESS_TOKEN` | Save the access token in the session. Default: `False` |
Expand Down
39 changes: 39 additions & 0 deletions docs/users.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,45 @@ GOOGLE_SSO_SUPERUSER_LIST = ["another-email@my-domain.com"]
GOOGLE_SSO_AUTO_CREATE_FIRST_SUPERUSER = True
```

## Fine-tuning users before creation

If you need to do some processing _before_ user is created, you can set the
`GOOGLE_SSO_PRE_CREATE_CALLBACK` setting to import a custom function that will be called before the user is created.
This function will receive two arguments: the `google_user_info` dict from Google User API and `request` objects.

!!! tip "You can add custom fields to the user model here"

The `pre_create_callback` function can return a dictionary with the fields and values that will be passed to
`User.objects.create()` as the `defaults` argument. This means you can add custom fields to the user model here or
change default values for some fields, like `username`.

If not defined, the field `username` is always the user email.

You can't change the fields: `first_name`, `last_name`, `email` and `password` using this callback. These fields are
always passed to `User.objects.create()` with the values from Google API and the password is always unusable.


```python
import arrow

def pre_create_callback(google_info, request) -> dict | None:
"""Callback function called before user is created.
return: dict content to be passed to
User.objects.create() as `defaults` argument.
If not informed, field `username` is always
the user email.
"""

user_key = google_info.get("email").split("@")[0]
user_id = google_info.get("id")

return {
"username": f"{user_key}_{user_id}",
"date_joined": arrow.utcnow().shift(days=-1).datetime,
}
```

## Fine-tuning users before login

If you need to do some processing _after_ user is created or retrieved,
Expand Down
19 changes: 18 additions & 1 deletion example_google_app/backend.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import arrow
import httpx
from django.contrib import messages
from django.contrib.auth import logout
Expand All @@ -13,7 +14,7 @@ def authenticate(self, request, username=None, password=None, **kwargs):


def pre_login_callback(user, request):
"""Callback function called after user is logged in."""
"""Callback function called before user is logged in."""
messages.info(request, f"Running Pre-Login callback for user: {user}.")

# Example 1: Add SuperUser status to user
Expand Down Expand Up @@ -73,3 +74,19 @@ def __call__(self, request):

response = self.get_response(request)
return response


def pre_create_callback(google_info, request) -> dict:
"""Callback function called before user is created.
return: dict content to be passed to User.objects.create() as `defaults` argument.
If not informed, field `username` is always passed with user email as value.
"""

user_key = google_info.get("email").split("@")[0]
user_id = google_info.get("id")

return {
"username": f"{user_key}_{user_id}",
"date_joined": arrow.utcnow().shift(days=-1).datetime,
}
3 changes: 3 additions & 0 deletions example_google_app/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,9 @@
# Optional: Change default login text
# GOOGLE_SSO_TEXT = "Login using Google Account"

# Optional: Add pre-create logic
GOOGLE_SSO_PRE_CREATE_CALLBACK = "backend.pre_create_callback"

# Optional: Add pre-login logic
GOOGLE_SSO_PRE_LOGIN_CALLBACK = "backend.pre_login_callback"

Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ google-auth-oauthlib = "*"

[tool.poetry.dev-dependencies]
auto-changelog = "*"
arrow = "*"
black = {version = "*", allow-prereleases = true}
pre-commit = "*"
pytest-coverage = "*"
Expand Down

0 comments on commit cc9ad6a

Please sign in to comment.