├── example_google_app ├── __init__.py ├── .stela ├── .env ├── asgi.py ├── wsgi.py ├── manage.py ├── README.md ├── static │ └── django_google_sso │ │ ├── google_button_custom.css │ │ └── google_button_unfold.css ├── views.py ├── templates │ ├── secret_page.html │ └── login.html ├── urls.py ├── backend.py └── settings.py ├── .gitattributes ├── django_google_sso ├── checks │ ├── __init__.py │ └── warnings.py ├── tests │ ├── __init__.py │ ├── test_conf.py │ ├── test_models.py │ ├── test_tags.py │ ├── test_sites.py │ ├── test_google_auth.py │ ├── test_callables.py │ ├── test_views.py │ └── test_user_helper.py ├── migrations │ ├── __init__.py │ ├── 0002_alter_googlessouser_picture_url.py │ └── 0001_initial.py ├── templatetags │ ├── __init__.py │ ├── show_form.py │ └── sso_tags.py ├── __init__.py ├── urls.py ├── apps.py ├── helpers.py ├── models.py ├── hooks.py ├── utils.py ├── static │ └── django_google_sso │ │ └── google_button.css ├── templates │ └── google_sso │ │ ├── login_sso.html │ │ └── login.html ├── admin.py ├── views.py ├── conf.py └── main.py ├── .flake8 ├── docs ├── images │ ├── django-google-sso.png │ ├── django_multiple_sso.png │ ├── django_login_with_google_dark.png │ ├── django_login_with_google_light.png │ └── django_login_with_google_custom.png ├── thanks.md ├── urls.md ├── index.md ├── admin.md ├── customize.md ├── callback.md ├── advanced.md ├── quick_setup.md ├── credentials.md ├── pages.md ├── third_party_admins.md ├── sites.md ├── model.md ├── troubleshooting.md ├── users.md ├── how.md ├── multiple.md └── settings.md ├── .github ├── FUNDING.yml ├── pull_request_template.md ├── workflows │ ├── stale.yml │ ├── tests.yml │ └── publish.yml └── copilot-instructions.md ├── pytest.ini ├── Makefile ├── LICENSE ├── .run ├── Migrate.run.xml ├── Run All Tests.run.xml ├── Run Example App.run.xml └── Serve Docs.run.xml ├── .pre-commit-config.yaml ├── pyproject.toml ├── mkdocs.yml ├── .junie └── guidelines.md ├── .gitignore └── README.md /example_google_app/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /django_google_sso/checks/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_google_sso/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_google_sso/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_google_sso/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_google_sso/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "9.0.2" 2 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 88 3 | extend-ignore = E203 4 | exclude = */site-packages/,.git 5 | -------------------------------------------------------------------------------- /docs/images/django-google-sso.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/megalus/django-google-sso/HEAD/docs/images/django-google-sso.png -------------------------------------------------------------------------------- /docs/images/django_multiple_sso.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/megalus/django-google-sso/HEAD/docs/images/django_multiple_sso.png -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [megalus] 4 | custom: ['https://www.buymeacoffee.com/megalus'] 5 | -------------------------------------------------------------------------------- /docs/images/django_login_with_google_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/megalus/django-google-sso/HEAD/docs/images/django_login_with_google_dark.png -------------------------------------------------------------------------------- /docs/images/django_login_with_google_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/megalus/django-google-sso/HEAD/docs/images/django_login_with_google_light.png -------------------------------------------------------------------------------- /docs/images/django_login_with_google_custom.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/megalus/django-google-sso/HEAD/docs/images/django_login_with_google_custom.png -------------------------------------------------------------------------------- /example_google_app/.stela: -------------------------------------------------------------------------------- 1 | [stela] 2 | environment_variable_name = STELA_ENV 3 | evaluate_data = True 4 | show_logs = False 5 | env_file = .env 6 | config_file_path = . 7 | -------------------------------------------------------------------------------- /django_google_sso/tests/test_conf.py: -------------------------------------------------------------------------------- 1 | from django_google_sso import conf 2 | 3 | 4 | def test_conf_from_settings(settings): 5 | # Arrange 6 | settings.GOOGLE_SSO_ENABLED = False 7 | 8 | # Assert 9 | assert conf.GOOGLE_SSO_ENABLED is False 10 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ### Contains 2 | - [ ] Breaking Changes 3 | - [ ] New/Update documentation 4 | - [ ] CI/CD modifications 5 | 6 | ### Changes 7 | * Add '...' 8 | * Remove '...' 9 | * Refactor '...' 10 | * Update '...' 11 | 12 | ### Resolves 13 | Resolves '...' 14 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | DJANGO_SETTINGS_MODULE = example_google_app.settings 3 | python_files = tests.py test_*.py *_tests.py 4 | addopts = -p no:warnings --ignore=migration --ignore=.cache --cov=django_google_sso --cov-report=term-missing --cov-fail-under=80 5 | asyncio_mode = auto 6 | asyncio_default_fixture_loop_scope = function 7 | -------------------------------------------------------------------------------- /example_google_app/.env: -------------------------------------------------------------------------------- 1 | # Add here your settings and fake secrets. You can commit this file. 2 | GOOGLE_SSO_ALLOWABLE_DOMAINS=["gmail.com"] 3 | GOOGLE_SSO_CLIENT_ID=999999999999-xxxxxxxxx.apps.googleusercontent.com 4 | GOOGLE_SSO_CLIENT_SECRET=xxxxxx 5 | GOOGLE_SSO_PROJECT_ID=999999999999 6 | GOOGLE_SSO_CALLBACK_DOMAIN=localhost:8000 7 | -------------------------------------------------------------------------------- /django_google_sso/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from django_google_sso import conf, views 4 | 5 | app_name = "django_google_sso" 6 | 7 | urlpatterns = [] 8 | 9 | if conf.GOOGLE_SSO_ENABLED: 10 | urlpatterns += [ 11 | path("login/", views.start_login, name="oauth_start_login"), 12 | path("callback/", views.callback, name="oauth_callback"), 13 | ] 14 | -------------------------------------------------------------------------------- /django_google_sso/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | 5 | class DjangoGoogleSsoConfig(AppConfig): 6 | default_auto_field = "django.db.models.BigAutoField" 7 | name = "django_google_sso" 8 | verbose_name = _("Google SSO User") 9 | 10 | def ready(self): 11 | import django_google_sso.templatetags # noqa 12 | -------------------------------------------------------------------------------- /example_google_app/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for example_google_app project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.2/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example_google_app.settings") 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /example_google_app/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for example_google_app project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.2/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example_google_app.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /django_google_sso/migrations/0002_alter_googlessouser_picture_url.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2 on 2022-10-27 11:40 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("django_google_sso", "0001_initial"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="googlessouser", 14 | name="picture_url", 15 | field=models.URLField(max_length=2000), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /example_google_app/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example_google_app.settings") 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == "__main__": 22 | main() 23 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | update: 2 | @poetry update && poetry run pre-commit autoupdate 3 | 4 | install: 5 | @poetry install 6 | @poetry run pre-commit install -f 7 | 8 | lint: 9 | @poetry run pre-commit run --all 10 | 11 | tests: 12 | @STELA_ENV=test poetry run pytest -v -x -p no:warnings --cov-report term-missing --cov=. 13 | 14 | test: 15 | @if [ "$(filter-out $@,$(MAKECMDGOALS))" = "" ]; then \ 16 | echo "Usage: make test . Example: make test megalus/tests.py::test_health_check"; \ 17 | exit 1; \ 18 | fi 19 | @echo "${BLUE}Running test: $(filter-out $@,$(MAKECMDGOALS))...${NC}" 20 | @STELA_ENV=test poetry run pytest -v -x -p no:warnings --cov-report term-missing --cov=. $(filter-out $@,$(MAKECMDGOALS)) 21 | @echo "${GREEN}Test completed.${NC}" 22 | 23 | # Prevent make from treating the argument as a target 24 | %: 25 | @: 26 | -------------------------------------------------------------------------------- /django_google_sso/templatetags/show_form.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | 3 | register = template.Library() 4 | 5 | 6 | @register.simple_tag(takes_context=True) 7 | def define_show_form(context) -> bool: 8 | from django_google_sso import conf 9 | 10 | request = context.get("request") 11 | 12 | # Because this tag can be called multiple times in a single request, 13 | # we cache the result in the request object. 14 | # This occurs when multiple django-*-sso providers are installed 15 | if request is not None and hasattr(request, "_sso_show_form_cache"): 16 | return request._sso_show_form_cache 17 | 18 | value = conf.SSO_SHOW_FORM_ON_ADMIN_PAGE 19 | if callable(value): 20 | value = value(request) 21 | 22 | if request is not None: 23 | request._sso_show_form_cache = value 24 | 25 | return value 26 | -------------------------------------------------------------------------------- /example_google_app/README.md: -------------------------------------------------------------------------------- 1 | ## Django Example App 2 | 3 | ## Start the Project 4 | 5 | Please create a `.env.local` file with the following information: 6 | 7 | ```dotenv 8 | GOOGLE_SSO_CLIENT_ID= 9 | GOOGLE_SSO_CLIENT_SECRET= 10 | GOOGLE_SSO_PROJECT_ID= 11 | ``` 12 | 13 | Then run the following commands: 14 | 15 | ```shell 16 | poetry install 17 | poetry run python manage.py migrate 18 | poetry run python manage.py runserver 19 | ``` 20 | 21 | Open browser in `http://localhost:8000/admin` 22 | 23 | ## Django Admin skins 24 | 25 | Please uncomment on `settings.py` the correct app for the skin you want to test, in `INSTALLED_APPS`. 26 | 27 | For `django-unfold` please rename the following css files: 28 | 29 | * `example_google_app/static/django_google_sso/google_button_unfold.css` to `static/django_google_sso/google_button.css` 30 | -------------------------------------------------------------------------------- /django_google_sso/helpers.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpRequest 2 | from django.urls import reverse 3 | 4 | from django_google_sso import conf 5 | 6 | 7 | def is_admin_path(request: HttpRequest) -> bool: 8 | """Check if the request path is for the admin interface. 9 | 10 | This function checks if the current request path starts with the admin route 11 | defined in the settings. It also checks the 'next' parameter in the GET request 12 | and the 'sso_next_url' in the session to determine if the next destination is 13 | the admin interface. 14 | 15 | """ 16 | admin_route = conf.SSO_ADMIN_ROUTE 17 | if callable(admin_route): 18 | admin_route = admin_route(request) 19 | return ( 20 | request.path.startswith(reverse(admin_route)) 21 | or request.GET.get("next", "").startswith(reverse(admin_route)) 22 | or request.session.get("sso_next_url", "").startswith(reverse(admin_route)) 23 | ) 24 | 25 | 26 | def is_page_path(request: HttpRequest) -> bool: 27 | return not is_admin_path(request) 28 | -------------------------------------------------------------------------------- /django_google_sso/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from django.db import models 3 | from django.utils.safestring import mark_safe 4 | from django.utils.translation import gettext_lazy as _ 5 | 6 | User = get_user_model() 7 | 8 | 9 | class GoogleSSOUser(models.Model): 10 | user = models.OneToOneField(User, on_delete=models.CASCADE) 11 | google_id = models.CharField(max_length=255) 12 | picture_url = models.URLField(max_length=2000) 13 | locale = models.CharField(max_length=5) 14 | 15 | @property 16 | def picture(self): 17 | if self.picture_url: 18 | return mark_safe( 19 | ''.format( 20 | self.picture_url 21 | ) # nosec 22 | ) 23 | return None 24 | 25 | def __str__(self): 26 | user_email = getattr(self.user, User.get_email_field_name()) 27 | return f"{user_email} ({self.google_id})" 28 | 29 | class Meta: 30 | db_table = "google_sso_user" 31 | verbose_name = _("Google SSO User") 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012-2022 Scott Chacon and others 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /example_google_app/static/django_google_sso/google_button_custom.css: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Please rename this file to google_button.css to override works. 4 | 5 | 6 | login-btn 7 | --------------------------------- 8 | | -------------- | 9 | | | btn-logo | btn-label | 10 | | -------------- | 11 | ---------------------------------- 12 | */ 13 | 14 | /* Goggle Login Button */ 15 | .google-login-btn { 16 | background-color: darkred; 17 | border-radius: 3px; 18 | padding: 2px; 19 | margin-bottom: 10px; 20 | width: 100%; 21 | } 22 | 23 | /* Google Login Button Hover */ 24 | .google-login-btn:hover { 25 | background-color: #611818; 26 | } 27 | 28 | /* Google Login Button Remove Decoration */ 29 | .google-login-btn a { 30 | text-decoration: none; 31 | } 32 | 33 | /* Google Login Button Logo Area */ 34 | .google-btn-logo { 35 | display: flex; 36 | justify-content: center; 37 | align-content: center; 38 | padding: 4px; 39 | } 40 | 41 | 42 | /* Google Login Button Label Area */ 43 | .google-btn-label { 44 | color: #ffffff; 45 | margin-top: -1px; 46 | width: 100%; 47 | text-align: center; 48 | padding: 0 10px; 49 | } 50 | -------------------------------------------------------------------------------- /django_google_sso/hooks.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpRequest 2 | 3 | from django_google_sso.models import User 4 | 5 | 6 | def pre_login_user(user: User, request: HttpRequest) -> None: 7 | """ 8 | Callback function called after user is created/retrieved but before logged in. 9 | """ 10 | 11 | 12 | def pre_create_user(google_user_info: dict, request: HttpRequest) -> dict | None: 13 | """ 14 | Callback function called before user is created. 15 | 16 | params: 17 | google_user_info: dict containing user info received from Google. 18 | request: HttpRequest object. 19 | 20 | return: dict content to be passed to User.objects.create() 21 | as `defaults` argument. 22 | If not informed, username field (default: `username`) 23 | is always the user email. 24 | """ 25 | return {} 26 | 27 | 28 | def pre_validate_user(google_user_info: dict, request: HttpRequest) -> bool: 29 | """ 30 | Callback function called before user is validated. 31 | 32 | Must return a boolean to indicate if user is valid to login. 33 | 34 | params: 35 | google_user_info: dict containing user info received from Google. 36 | request: HttpRequest object. 37 | """ 38 | return True 39 | -------------------------------------------------------------------------------- /docs/thanks.md: -------------------------------------------------------------------------------- 1 | # Thank you 2 | 3 | Thank you for using this project. And for all the appreciation, patience and support. 4 | 5 | I really hope this project can make your life a little easier. 6 | 7 | Please feel free to check our other projects: 8 | 9 | * [stela](https://github.com/megalus/stela): Easily manage project settings and secrets in any python project. 10 | * [django-google-sso](https://github.com/megalus/django-google-sso): A Django app to enable Single Sign-On with Google Accounts. 11 | * [django-microsoft-sso](https://github.com/megalus/django-microsoft-sso): A Django app to enable Single Sign-On with Microsoft 365 Accounts. 12 | * [django-github-sso](https://github.com/megalus/django-github-sso): A Django app to enable Single Sign-On with GitHub Accounts. 13 | 14 | ## Donating 15 | 16 | If you like to finance this project, please consider donating: 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /docs/urls.md: -------------------------------------------------------------------------------- 1 | # Setup Django URLs 2 | 3 | The base configuration for Django URLs is the same we have described as before: 4 | ```python 5 | # urls.py 6 | 7 | from django.urls import include, path 8 | 9 | urlpatterns = [ 10 | # other urlpatterns... 11 | path( 12 | "google_sso/", include( 13 | "django_google_sso.urls", 14 | namespace="django_google_sso" 15 | ) 16 | ), 17 | ] 18 | ``` 19 | You can change the initial Path - `google_sso/` - to whatever you want - just remember to change it in the Google Console as well. 20 | 21 | ## Overriding the Login view or Path 22 | 23 | If you need to override the login view, or just the path, please add on the new view/class the **Django SSO Admin** login template: 24 | 25 | ```python 26 | from django.contrib.auth.views import LoginView 27 | from django.urls import path 28 | 29 | urlpatterns = [ 30 | # other urlpatterns... 31 | path( 32 | "accounts/login/", 33 | LoginView.as_view( 34 | # The modified form with Google button 35 | template_name="google_sso/login.html" 36 | ), 37 | ), 38 | ] 39 | ``` 40 | 41 | or you can use a complete custom class: 42 | 43 | ```python 44 | from django.contrib.auth.views import LoginView 45 | 46 | 47 | class MyLoginView(LoginView): 48 | template_name = "google_sso/login.html" 49 | ``` 50 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | ![](images/django-google-sso.png) 2 | 3 | # Welcome to Django Google SSO 4 | 5 | ## Motivation 6 | 7 | This library aims to simplify the process of authenticating users with Google in Django Admin pages, 8 | inspired by libraries like [django_microsoft_auth](https://github.com/AngellusMortis/django_microsoft_auth) 9 | and [django-admin-sso](https://github.com/matthiask/django-admin-sso/) 10 | 11 | ## Why another library? 12 | 13 | * This library aims for _simplicity_ and ease of use. [django-allauth](https://github.com/pennersr/django-allauth) is 14 | _de facto_ solution for Authentication in Django, but add lots of boilerplate, specially the html templates. 15 | **Django-Google-SSO** just add a fully customizable "Login with Google" button in the default login page. 16 | 17 | === "Light Mode" 18 | ![](images/django_login_with_google_light.png) 19 | 20 | === "Dark Mode" 21 | ![](images/django_login_with_google_dark.png) 22 | 23 | * [django-admin-sso](https://github.com/matthiask/django-admin-sso/) is a good solution, but it uses a deprecated 24 | google `auth2client` version. 25 | 26 | --- 27 | 28 | ## Install 29 | 30 | ```shell 31 | pip install django-google-sso 32 | ``` 33 | 34 | !!! info "Currently this project supports:" 35 | * Python 3.11, 3.12 and 3.13 36 | * Django 4.2, 5.0, 5.1 and 5.2 37 | 38 | Older python/django versions are not supported. 39 | -------------------------------------------------------------------------------- /example_google_app/views.py: -------------------------------------------------------------------------------- 1 | import httpx 2 | from django.contrib.auth import logout 3 | from django.contrib.auth.decorators import login_required 4 | from django.http import HttpResponse, HttpResponseRedirect 5 | from django.shortcuts import render 6 | from django.urls import reverse 7 | 8 | 9 | @login_required 10 | def secret_page(request) -> HttpResponse: 11 | logout_url = reverse("logout") 12 | return render( 13 | request, 14 | "secret_page.html", 15 | {"logout_url": logout_url}, 16 | ) 17 | 18 | 19 | def single_logout_view(request): 20 | token = request.session.get("google_sso_access_token") 21 | logout(request) 22 | 23 | # You can revoke the Access Token here 24 | if token: 25 | httpx.post( 26 | "https://oauth2.googleapis.com/revoke", params={"token": token}, timeout=10 27 | ) 28 | 29 | # And redirect the user to your login page or 30 | # Google logout page if you want (like 'https://accounts.google.com/logout') 31 | redirect_url = ( 32 | reverse("admin:index") 33 | if request.path.startswith(reverse("admin:index")) 34 | else reverse("index") 35 | ) 36 | return HttpResponseRedirect(redirect_url) 37 | 38 | 39 | def index(request) -> HttpResponse: 40 | if request.user.is_authenticated: 41 | return HttpResponseRedirect(reverse("secret")) 42 | return render(request, "login.html", {}) 43 | -------------------------------------------------------------------------------- /django_google_sso/tests/test_models.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from django_google_sso.main import UserHelper 4 | 5 | pytestmark = pytest.mark.django_db(transaction=True) 6 | 7 | 8 | def test_google_sso_model(google_response, callback_request, settings): 9 | # Act 10 | helper = UserHelper(google_response, callback_request) 11 | user = helper.get_or_create_user() 12 | 13 | # Assert 14 | assert user.googlessouser.google_id == google_response["id"] 15 | assert user.googlessouser.picture_url == google_response["picture"] 16 | assert user.googlessouser.locale == google_response["locale"] 17 | 18 | 19 | def test_very_long_picture_url(google_response, callback_request, settings): 20 | # Arrange 21 | google_response["picture"] += "a" * 1900 22 | 23 | # Act 24 | helper = UserHelper(google_response, callback_request) 25 | user = helper.get_or_create_user() 26 | 27 | # Assert 28 | assert len(user.googlessouser.picture_url) == len(google_response["picture"]) 29 | 30 | 31 | def test_user_with_custom_field_names( 32 | custom_user_model, google_response, callback_request 33 | ): 34 | # Arrange 35 | from django_google_sso.main import UserHelper 36 | 37 | # Act 38 | helper = UserHelper(google_response, callback_request) 39 | user = helper.get_or_create_user() 40 | 41 | # Assert 42 | assert user.user_name == "foo@example.com" 43 | assert user.mail == "foo@example.com" 44 | -------------------------------------------------------------------------------- /example_google_app/templates/secret_page.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 | 4 | 5 | 6 | 7 | Secret Page 8 | 10 | 11 | 12 |
13 | {% if messages %} 14 | {% for message in messages %} 15 | 19 | {% endfor %} 20 | {% endif %} 21 |
22 |
23 |

You're looking at the secret page.

24 |
25 | {% csrf_token %} 26 | 27 |
28 |
29 | 30 | 31 | -------------------------------------------------------------------------------- /.run/Migrate.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 29 | 30 | -------------------------------------------------------------------------------- /django_google_sso/utils.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import Any, Callable, Coroutine 3 | 4 | from asgiref.sync import sync_to_async 5 | from django.contrib import messages 6 | from loguru import logger 7 | 8 | from django_google_sso import conf 9 | from django_google_sso.templatetags.show_form import define_show_form 10 | from django_google_sso.templatetags.sso_tags import define_sso_providers 11 | 12 | 13 | def send_message(request, message, level: str = "error"): 14 | getattr(logger, level.lower())(message) 15 | enable_messages = conf.GOOGLE_SSO_ENABLE_MESSAGES 16 | if callable(enable_messages): 17 | enable_messages = enable_messages(request) 18 | if enable_messages: 19 | messages.add_message(request, getattr(messages, level.upper()), message) 20 | 21 | 22 | def show_credential(credential): 23 | credential = str(credential) 24 | return f"{credential[:5]}...{credential[-5:]}" 25 | 26 | 27 | def async_( 28 | func: Callable, 29 | ) -> Callable[..., Any] | Callable[[Any, Any], Coroutine[Any, Any, Any]]: 30 | """Returns a coroutine function.""" 31 | return func if asyncio.iscoroutinefunction(func) else sync_to_async(func) 32 | 33 | 34 | async def adefine_sso_providers(request): 35 | context = {"request": request} 36 | return await async_(define_sso_providers)(context) 37 | 38 | 39 | async def adefine_show_form(request): 40 | context = {"request": request} 41 | return await async_(define_show_form)(context) 42 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: 'Close stale issues and PRs' 2 | 3 | on: 4 | schedule: 5 | - cron: '30 1 * * *' # Runs once a day at 01:30 6 | 7 | permissions: 8 | issues: write 9 | pull-requests: write 10 | 11 | jobs: 12 | stale: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/stale@v10 16 | with: 17 | days-before-stale: 30 # Mark as stale after 30 days 18 | days-before-close: 7 # Close 7 days after being marked as stale 19 | stale-issue-message: 'This issue has been marked as stale due to lack of activity. It will be closed in 7 days if no further activity occurs.' 20 | stale-pr-message: 'This pull request has been marked as stale due to lack of activity. It will be closed in 7 days if no further activity occurs.' 21 | close-issue-message: "This issue has been closed due to lack of activity. Feel free to reopen it if you believe it's still relevant." 22 | close-pr-message: "This pull request has been closed due to lack of activity. Feel free to reopen it if you believe it's still relevant." 23 | exempt-issue-labels: 'never-stale' # Issues with this label will not be marked as stale 24 | exempt-pr-labels: 'never-stale' # PRs with this label will not be marked as stale 25 | delete-branch: false # Delete the branch of closed PRs 26 | stale-issue-label: 'stale' # Label to mark stale issues 27 | stale-pr-label: 'stale' # Label to mark stale PRs 28 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | on: 3 | pull_request: 4 | branches: [ main ] 5 | jobs: 6 | lint: 7 | runs-on: ubuntu-latest 8 | name: Run Pre-Commit 9 | 10 | steps: 11 | - name: Checkout Branch 12 | uses: actions/checkout@v5 13 | with: 14 | fetch-depth: 0 15 | - name: Setups Python 16 | uses: actions/setup-python@v6 17 | with: 18 | python-version: "3.12" 19 | - name: Setup Poetry 20 | uses: abatilo/actions-poetry@v4 21 | - name: Run Pre-Commit 22 | uses: pre-commit/action@v3.0.1 23 | test: 24 | runs-on: ubuntu-latest 25 | needs: [lint] 26 | strategy: 27 | matrix: 28 | os: [ubuntu-latest, macos-latest, windows-latest] 29 | python-version: ["3.11", "3.12", "3.13"] 30 | django-version: ["4.2", "5.0", "5.1", "5.2"] 31 | steps: 32 | - uses: actions/checkout@v5 33 | - name: Set up Python ${{ matrix.python-version }} 34 | uses: actions/setup-python@v6 35 | with: 36 | python-version: ${{ matrix.python-version }} 37 | - name: Update tools 38 | run: python -m pip install --upgrade pip setuptools wheel 39 | - name: Setup Poetry 40 | uses: abatilo/actions-poetry@v4 41 | - name: Install Project 42 | run: | 43 | poetry install 44 | poetry add django==${{ matrix.django-version }} 45 | - name: Run CI Tests 46 | run: | 47 | make tests 48 | -------------------------------------------------------------------------------- /django_google_sso/static/django_google_sso/google_button.css: -------------------------------------------------------------------------------- 1 | /* 2 | login-btn 3 | --------------------------------- 4 | | -------------- | 5 | | | btn-logo | btn-label | 6 | | -------------- | 7 | ---------------------------------- 8 | */ 9 | 10 | /* Login Button Area */ 11 | .login-btn-area { 12 | display: flex; 13 | flex-direction: column; 14 | justify-content: center; 15 | align-items: center; 16 | width: 295px; 17 | } 18 | 19 | /* Goggle Login Button */ 20 | .google-login-btn { 21 | background-color: #3f86ed; 22 | border-radius: 1px; 23 | padding: 2px; 24 | margin-bottom: 10px; 25 | width: 100%; 26 | height: 28px; 27 | display: flex; 28 | } 29 | 30 | /* Google Login Button Hover */ 31 | .google-login-btn:hover { 32 | background-color: #254f89; 33 | } 34 | 35 | /* Google Login Button Remove Decoration */ 36 | .google-login-btn a { 37 | text-decoration: none; 38 | width: 100%; 39 | } 40 | 41 | /* Google Login Button Logo Area */ 42 | .google-btn-logo { 43 | display: flex; 44 | justify-content: center; 45 | align-content: center; 46 | background-color: white; 47 | height: 28px; 48 | width: 28px; 49 | } 50 | 51 | .google-btn-logo img { 52 | height: 28px; 53 | width: 28px; 54 | } 55 | 56 | /* Google Login Button Label Area */ 57 | .google-btn-label { 58 | color: #ffffff; 59 | margin-top: -1px; 60 | width: 100%; 61 | text-align: center; 62 | } 63 | -------------------------------------------------------------------------------- /.run/Run All Tests.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 26 | 27 | -------------------------------------------------------------------------------- /django_google_sso/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.9 on 2021-11-19 22:04 2 | 3 | import django.db.models.deletion 4 | from django.conf import settings 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | initial = True 10 | 11 | dependencies = [ 12 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name="GoogleSSOUser", 18 | fields=[ 19 | ( 20 | "id", 21 | models.BigAutoField( 22 | auto_created=True, 23 | primary_key=True, 24 | serialize=False, 25 | verbose_name="ID", 26 | ), 27 | ), 28 | ("google_id", models.CharField(max_length=255)), 29 | ("picture_url", models.URLField(max_length=255)), 30 | ("locale", models.CharField(max_length=5)), 31 | ( 32 | "user", 33 | models.OneToOneField( 34 | on_delete=django.db.models.deletion.CASCADE, 35 | to=settings.AUTH_USER_MODEL, 36 | ), 37 | ), 38 | ], 39 | options={ 40 | "verbose_name": "Google SSO User", 41 | "db_table": "google_sso_user", 42 | }, 43 | ), 44 | ] 45 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v6.0.0 4 | hooks: 5 | - id: end-of-file-fixer 6 | - id: trailing-whitespace 7 | - id: check-ast 8 | - repo: https://github.com/pre-commit/pre-commit-hooks 9 | rev: v6.0.0 10 | hooks: 11 | - id: check-yaml 12 | args: [--unsafe] 13 | - repo: https://github.com/Lucas-C/pre-commit-hooks-bandit 14 | rev: v1.0.6 15 | hooks: 16 | - id: python-bandit-vulnerability-check 17 | args: [ "-s", "B101,B105", "-r", "--exclude", "*/.venv/*", "." ] 18 | - repo: https://github.com/pycqa/isort 19 | rev: 6.0.1 20 | hooks: 21 | - id: isort 22 | name: isort (python) 23 | args: [ "--profile", "black", "--filter-files" ] 24 | - repo: https://github.com/psf/black 25 | rev: 25.1.0 26 | hooks: 27 | - id: black 28 | exclude: ^.*\b(migrations)\b.*$ 29 | - repo: https://github.com/asottile/yesqa 30 | rev: v1.5.0 31 | hooks: 32 | - id: yesqa 33 | - repo: https://github.com/PyCQA/flake8 34 | rev: 7.3.0 35 | hooks: 36 | - id: flake8 37 | args: ["--count", "--exclude", "*/migrations/*,.git,*/site-packages/*", "." ] 38 | - repo: https://github.com/myint/autoflake 39 | rev: v2.3.1 40 | hooks: 41 | - id: autoflake 42 | args: [ "--in-place", "--remove-all-unused-imports", "--remove-duplicate-keys" ] 43 | - repo: https://github.com/rtts/djhtml 44 | rev: '3.0.9' 45 | hooks: 46 | - id: djhtml 47 | -------------------------------------------------------------------------------- /docs/admin.md: -------------------------------------------------------------------------------- 1 | # Using Django Admin 2 | 3 | **Django Google SSO** integrates with Django Admin, adding an Inline Model Admin to the User model. This way, you can 4 | access the Google SSO data for each user. 5 | 6 | ## Using Custom User model 7 | 8 | If you are using a custom user model, you may need to add the `GoogleSSOInlineAdmin` inline model admin to your custom 9 | user model admin, like this: 10 | 11 | ```python 12 | # admin.py 13 | 14 | from django.contrib import admin 15 | from django.contrib.auth.admin import UserAdmin 16 | from django_google_sso.admin import ( 17 | GoogleSSOInlineAdmin, get_current_user_and_admin 18 | ) 19 | 20 | CurrentUserModel, last_admin, LastUserAdmin = get_current_user_and_admin() 21 | 22 | if admin.site.is_registered(CurrentUserModel): 23 | admin.site.unregister(CurrentUserModel) 24 | 25 | 26 | @admin.register(CurrentUserModel) 27 | class CustomUserAdmin(LastUserAdmin): 28 | inlines = ( 29 | tuple(set(list(last_admin.inlines) + [GoogleSSOInlineAdmin])) 30 | if last_admin 31 | else (GoogleSSOInlineAdmin,) 32 | ) 33 | ``` 34 | 35 | The `get_current_user_and_admin` helper function will return: 36 | 37 | * the current registered **UserModel** in Django Admin (default: `django.contrib.auth.models.User`) 38 | * the current registered **UserAdmin** in Django (default: `django.contrib.auth.admin.UserAdmin`) 39 | * the **instance** of the current registered UserAdmin in Django (default: `None`) 40 | 41 | 42 | Use this objects to maintain previous inlines and register your custom user model in Django Admin. 43 | -------------------------------------------------------------------------------- /.run/Run Example App.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 32 | 33 | -------------------------------------------------------------------------------- /example_google_app/static/django_google_sso/google_button_unfold.css: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Please rename this file to google_button.css to override works. 4 | This CSS is compatible with Django Unfold CSS 5 | 6 | login-btn 7 | --------------------------------- 8 | | -------------- | 9 | | | btn-logo | btn-label | 10 | | -------------- | 11 | ---------------------------------- 12 | */ 13 | 14 | /* Login Button Area */ 15 | .login-btn-area { 16 | display: flex; 17 | flex-direction: column; 18 | justify-content: center; 19 | align-items: center; 20 | width: 382px; 21 | } 22 | 23 | /* Goggle Login Button */ 24 | .google-login-btn { 25 | display: flex; 26 | flex-direction: column; 27 | justify-content: center; 28 | background-color: #9233e7; 29 | border-radius: 6px; 30 | padding: 2px; 31 | margin-bottom: 20px; 32 | width: 100%; 33 | height: 38px; 34 | font-family: 'Inter', sans-serif; 35 | font-size: 14px; 36 | font-weight: 600; 37 | } 38 | 39 | /* Google Login Button Hover */ 40 | .google-login-btn:hover { 41 | background-color: #254f89; 42 | } 43 | 44 | /* Google Login Button Remove Decoration */ 45 | .google-login-btn a { 46 | text-decoration: none; 47 | } 48 | 49 | /* Google Login Button Logo Area */ 50 | .google-btn-logo { 51 | display: flex; 52 | justify-content: center; 53 | align-content: center; 54 | padding: 4px; 55 | } 56 | 57 | 58 | /* Google Login Button Label Area */ 59 | .google-btn-label { 60 | color: #ffffff; 61 | margin-top: -1px; 62 | width: 100%; 63 | text-align: center; 64 | padding: 0 10px; 65 | } 66 | -------------------------------------------------------------------------------- /.run/Serve Docs.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 31 | 32 | -------------------------------------------------------------------------------- /django_google_sso/tests/test_tags.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from django_google_sso.templatetags.sso_tags import define_sso_providers 4 | from django_google_sso.utils import adefine_sso_providers 5 | 6 | pytestmark = pytest.mark.django_db 7 | 8 | 9 | def test_tags(client_with_session, settings, callback_request): 10 | 11 | # Arrange 12 | def custom_view(request): 13 | from django.shortcuts import render 14 | 15 | sso_providers = define_sso_providers({"request": request}) 16 | sso_providers[0]["text"] = "SignWith2" 17 | 18 | context = { 19 | "sso_providers": sso_providers, 20 | } 21 | 22 | return render(request, "login.html", context) 23 | 24 | settings.GOOGLE_SSO_ENABLED = True 25 | settings.GOOGLE_SSO_PAGES_ENABLED = True 26 | 27 | # Act 28 | response = custom_view(callback_request) 29 | response_text = ( 30 | response.text if hasattr(response, "text") else response.content.decode() 31 | ) 32 | 33 | # Assert 34 | assert "SignWith2" in response_text 35 | 36 | 37 | async def test_async_view(aclient_with_session, callback_request): 38 | 39 | # Arrange 40 | async def custom_view(request): 41 | from django.shortcuts import render 42 | 43 | sso_providers = await adefine_sso_providers(request) 44 | sso_providers[0]["text"] = "SignWith2" 45 | 46 | context = {"sso_providers": sso_providers} 47 | 48 | return render(request, "login.html", context) 49 | 50 | # Act 51 | response = await custom_view(callback_request) 52 | response_text = ( 53 | response.text if hasattr(response, "text") else response.content.decode() 54 | ) 55 | 56 | # Assert 57 | assert "SignWith2" in response_text 58 | -------------------------------------------------------------------------------- /docs/customize.md: -------------------------------------------------------------------------------- 1 | # Customizing the Login Page 2 | Below, you can find some tips on how to customize the login page. 3 | 4 | ## Hiding the Login Form 5 | 6 | If you want to show only the Google Login button, you can hide the login form using 7 | the `SSO_SHOW_FORM_ON_ADMIN_PAGE` setting. 8 | 9 | ```python 10 | # settings.py 11 | 12 | SSO_SHOW_FORM_ON_ADMIN_PAGE = False 13 | ``` 14 | 15 | ## Customizing the Login button 16 | 17 | Customizing the Login button is very simple. For the logo and text change is straightforward, just inform the new 18 | values. For 19 | the style, you can override the css file. 20 | 21 | ### The button logo 22 | 23 | To change the logo, use the `GOOGLE_SSO_BUTTON_LOGO` setting. 24 | 25 | ```python 26 | # settings.py 27 | GOOGLE_SSO_LOGO_URL = "https://example.com/logo.png" 28 | ``` 29 | 30 | ### The button text 31 | 32 | To change the text, use the `GOOGLE_SSO_BUTTON_TEXT` setting. 33 | 34 | ```python 35 | # settings.py 36 | 37 | GOOGLE_SSO_TEXT = "New login message" 38 | ``` 39 | 40 | ### The button style 41 | 42 | The login button css style is located at 43 | `static/django_google_sso/google_button.css`. You can override this file as per Django 44 | [static files documentation](https://docs.djangoproject.com/en/4.2/howto/static-files/). 45 | 46 | #### An example 47 | 48 | ```python 49 | # settings.py 50 | 51 | GOOGLE_SSO_TEXT = "Login using Google Account" 52 | ``` 53 | 54 | ```css 55 | /* static/django_google_sso/google_button.css */ 56 | 57 | /* other css... */ 58 | 59 | .google-login-btn { 60 | background-color: red; 61 | border-radius: 3px; 62 | padding: 2px; 63 | margin-bottom: 10px; 64 | width: 100%; 65 | } 66 | ``` 67 | 68 | The result: 69 | 70 | ![](images/django_login_with_google_custom.png) 71 | -------------------------------------------------------------------------------- /example_google_app/templates/login.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 | 4 | 5 | 6 | 7 | Page Login 8 | 9 | 11 | 20 | 21 | 22 |
23 | {% if messages %} 24 | {% for message in messages %} 25 | 29 | {% endfor %} 30 | {% endif %} 31 |
32 |
33 |
34 |
Django Google SSO
35 | {% include 'google_sso/login_sso.html' %} 36 | Go to Admin 37 |
38 |
39 | 40 | 41 | -------------------------------------------------------------------------------- /example_google_app/urls.py: -------------------------------------------------------------------------------- 1 | """example_google_app URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/3.2/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | 17 | from django.conf import settings 18 | from django.conf.urls.static import static 19 | from django.contrib import admin 20 | from django.contrib.auth.views import LoginView 21 | from django.urls import include, path 22 | 23 | from example_google_app.settings import INSTALLED_APPS 24 | from example_google_app.views import index, secret_page, single_logout_view 25 | 26 | urlpatterns = [ 27 | path("admin/", admin.site.urls), 28 | ] 29 | 30 | urlpatterns += [ 31 | path("secret/", secret_page, name="secret"), 32 | path( 33 | "accounts/login/", 34 | LoginView.as_view( 35 | template_name="google_sso/login.html" 36 | ), # The modified form with google button 37 | ), 38 | path("accounts/logout/", single_logout_view, name="logout"), 39 | path("", index, name="index"), 40 | ] 41 | 42 | if "grappelli" in INSTALLED_APPS: 43 | urlpatterns += [path("grappelli/", include("grappelli.urls"))] 44 | 45 | if "jet" in INSTALLED_APPS: 46 | urlpatterns += [ 47 | path("jet/dashboard/", include("jet.dashboard.urls", "jet-dashboard")), 48 | path("jet/", include("jet.urls", "jet")), 49 | ] 50 | 51 | urlpatterns += [ 52 | path( 53 | "google_sso/", include("django_google_sso.urls", namespace="django_google_sso") 54 | ), 55 | ] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) 56 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: publish 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | permissions: 7 | contents: write 8 | id-token: write 9 | jobs: 10 | release: 11 | if: github.event_name == 'push' && github.ref == 'refs/heads/main' && !contains(github.event.head_commit.message, 'chore(release):') 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Setup | Checkout Project 15 | uses: actions/checkout@v5 16 | with: 17 | fetch-depth: 0 18 | ref: ${{ github.sha }} 19 | 20 | - name: Setup | Force Branch 21 | run: | 22 | git checkout -B ${{ github.ref_name }} ${{ github.sha }} 23 | 24 | - name: Setup | Install Python 3.12 25 | uses: actions/setup-python@v6 26 | with: 27 | python-version: "3.12" 28 | 29 | - name: Release | Python Semantic Release 30 | id: release 31 | uses: python-semantic-release/python-semantic-release@v9.10.0 32 | with: 33 | build: true 34 | commit: true 35 | push: true 36 | github_token: ${{ secrets.GITHUB_TOKEN }} 37 | 38 | - name: Publish | Build and Publish to PyPI using Poetry 39 | if: steps.release.outputs.released == 'true' 40 | uses: JRubics/poetry-publish@v2.0 41 | with: 42 | pypi_token: ${{ secrets.POETRY_PYPI_TOKEN_PYPI }} 43 | 44 | - name: Release | Upload to GitHub Release Assets 45 | uses: python-semantic-release/publish-action@v10.3.1 46 | if: steps.release.outputs.released == 'true' 47 | with: 48 | github_token: ${{ secrets.GITHUB_TOKEN }} 49 | tag: ${{ steps.release.outputs.tag }} 50 | 51 | docs: 52 | runs-on: ubuntu-latest 53 | steps: 54 | - name: Setup | Checkout Project 55 | uses: actions/checkout@v5 56 | with: 57 | fetch-depth: 0 58 | 59 | - name: Docs | Install MKDocs 60 | run: pip install mkdocs-material mkdocs-mermaid2-plugin 61 | 62 | - name: Docs | Publish Docs 63 | run: mkdocs gh-deploy --force 64 | -------------------------------------------------------------------------------- /django_google_sso/templates/google_sso/login_sso.html: -------------------------------------------------------------------------------- 1 | {% load sso_tags %} 2 | {% load show_form %} 3 | 4 | 20 | 21 | 27 | {% with sso_providers|default:None as sso_providers %} 28 | {% if sso_providers is None %} 29 | {% define_sso_providers as sso_providers %} 30 | {% endif %} 31 | 46 | {% endwith %} 47 | 48 | 55 | -------------------------------------------------------------------------------- /.github/copilot-instructions.md: -------------------------------------------------------------------------------- 1 | # Coding Guidelines 2 | ## Introduction 3 | These are VS Code coding guidelines. For additional input please check the local file located at `.junie/guidelines.md`. 4 | 5 | ## Indentation 6 | - Use four spaces for indentation. Do not use tabs. 7 | 8 | ## Localization 9 | - Always use English in code, texts, tests, commits, docs and comments. 10 | - If non english code is needed, put in a separate file and use `gettext` for translation. 11 | 12 | ## Code Style 13 | - Follow PEP 8 guidelines for Python code style. 14 | - Use `pre-commit run --all` for code formatting. 15 | - Unless otherwise specified in pyproject.toml, line length is 92 characters. 16 | 17 | ## Types 18 | - Always use type hints in the code. 19 | - Always use TypeDicts for dictionaries. 20 | - Always use dataclasses for objects. 21 | - Always use `Enum` for fixed values. For single fixed value prefer `Literal`. 22 | - Always use `|` for optional values. 23 | 24 | ## Comments 25 | - Use comments to explain complex code. 26 | - Use docstrings for functions and classes with more than seven lines of code. 27 | - Use Google style for docstrings. 28 | 29 | ## Strings 30 | - Use `f-strings` for string formatting. 31 | - Use triple quotes for multi-line strings. 32 | - Use double quotes for strings. 33 | 34 | ## Style 35 | - Use `black` for code formatting, via `pre-commit run -all` command. 36 | 37 | ## Testing 38 | - Use `pytest` for testing. 39 | - Use `pytest.mark.parametrize` to avoid code duplication. 40 | 41 | ## Commits 42 | - Use semantic versioning for commit messages. Create a one-line commit. 43 | - Use `feat:` if commit creates new code in both ./django_google_sso and unit tests. 44 | - Use `fix:` if commit only changes the code inside ./django_google_sso. 45 | - Use `chore:` if commit changes files outside ./django_google_sso. 46 | - Use `ci:` if commit changes files only in pyproject.toml. 47 | - Use `docs:` if commit changes files only in ./docs or the README. 48 | - Use `refactor:` if commit changes files in ./django_google_sso but not in unit tests. 49 | - Use `BREAKING CHANGE:` if commit changes the minimum version of Python in pyproject.toml. 50 | -------------------------------------------------------------------------------- /django_google_sso/admin.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Type 2 | 3 | from django.contrib import admin 4 | from django.contrib.auth import get_user_model 5 | from django.contrib.auth.admin import UserAdmin 6 | from django.contrib.auth.models import AbstractUser 7 | 8 | from django_google_sso import conf 9 | from django_google_sso.models import GoogleSSOUser 10 | 11 | if conf.GOOGLE_SSO_ENABLED: 12 | admin.site.login_template = "google_sso/login.html" 13 | 14 | 15 | def get_current_user_and_admin() -> ( 16 | tuple[AbstractUser, Optional[UserAdmin], Type[UserAdmin]] 17 | ): 18 | """Get the current user model and last admin class. 19 | 20 | For user model, we use the get_user_model() function. 21 | For the last admin class registered, we use the 22 | admin.site._registry.get(user_model) function or default `UserAdmin` 23 | 24 | """ 25 | 26 | user_model = get_user_model() 27 | existing_user_admin = admin.site._registry.get(user_model) 28 | user_admin_model = ( 29 | UserAdmin if existing_user_admin is None else existing_user_admin.__class__ 30 | ) 31 | return user_model, existing_user_admin, user_admin_model 32 | 33 | 34 | CurrentUserModel, last_admin, LastUserAdmin = get_current_user_and_admin() 35 | 36 | 37 | if admin.site.is_registered(CurrentUserModel): 38 | admin.site.unregister(CurrentUserModel) 39 | 40 | 41 | class GoogleSSOInlineAdmin(admin.StackedInline): 42 | model = GoogleSSOUser 43 | readonly_fields = ("google_id",) 44 | extra = 0 45 | 46 | def has_add_permission(self, request, obj): 47 | return False 48 | 49 | 50 | @admin.register(GoogleSSOUser) 51 | class GoogleSSOAdmin(admin.ModelAdmin): 52 | list_display = ("user", "google_id") 53 | readonly_fields = ("google_id", "picture") 54 | 55 | def has_add_permission(self, request): 56 | return False 57 | 58 | 59 | @admin.register(CurrentUserModel) 60 | class GoogleSSOUserAdmin(LastUserAdmin): 61 | model = CurrentUserModel 62 | inlines = ( 63 | tuple(set(list(last_admin.inlines) + [GoogleSSOInlineAdmin])) 64 | if last_admin 65 | else (GoogleSSOInlineAdmin,) 66 | ) 67 | -------------------------------------------------------------------------------- /django_google_sso/templates/google_sso/login.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/login.html" %} 2 | {% load static %} 3 | {% load sso_tags %} 4 | {% load show_form %} 5 | {% load i18n %} 6 | 7 | {% block extrastyle %} 8 | {{ block.super }} 9 | {% with sso_providers|default:None as sso_providers %} 10 | {% if sso_providers is None %} 11 | {% define_sso_providers as sso_providers %} 12 | {% endif %} 13 | {% for provider in sso_providers %} 14 | 15 | {% endfor %} 16 | {% endwith %} 17 | {% endblock %} 18 | 19 | {# Default Django Admin Block #} 20 | {% block content %} 21 | 27 | {% with show_admin_form|default:None as show_form %} 28 | {% if show_form is None %} 29 | {% define_show_form as show_form %} 30 | {% endif %} 31 | {% if show_form %} 32 | {{ block.super }} 33 | {% endif %} 34 | {% include 'google_sso/login_sso.html' %} 35 | {% endwith %} 36 | {% endblock %} 37 | 38 | {# Django Unfold Admin Block #} 39 | {% block base %} 40 | 45 | {{ block.super }} {# Process HTML login elements from Django Unfold #} 46 | 63 | {% endblock %} 64 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | target-version = ['py312'] 3 | include = '\.pyi?$' 4 | exclude = ''' 5 | ( 6 | /( 7 | \.eggs # exclude a few common directories in the 8 | | \.git # root of the project 9 | | \.hg 10 | | \.mypy_cache 11 | | \.tox 12 | | \.venv 13 | | \venv 14 | | \.aws-sam 15 | | _build 16 | | buck-out 17 | | build 18 | | dist 19 | | node_modules 20 | )/ 21 | ) 22 | ''' 23 | 24 | [tool.isort] 25 | profile = "black" 26 | multi_line_output = 3 27 | include_trailing_comma = true 28 | force_grid_wrap = 0 29 | use_parentheses = true 30 | ensure_newline_before_comments = true 31 | 32 | [tool.semantic_release] 33 | version_variables = [ 34 | "django_google_sso/__init__.py:__version__", 35 | "pyproject.toml:version" 36 | ] 37 | branch = "main" 38 | upload_to_pypi = true 39 | upload_to_release = true 40 | build_command = "python -m pip install -U twine poetry && poetry build" 41 | 42 | [tool.poetry] 43 | name = "django-google-sso" 44 | version = "9.0.2" 45 | description = "Easily add Google Authentication to your Django Projects" 46 | authors = ["Chris Maillefaud "] 47 | readme = "README.md" 48 | repository = "https://github.com/megalus/django-google-sso" 49 | keywords = ["google", "django", "sso"] 50 | license = "MIT" 51 | classifiers = [ 52 | "Framework :: Django", 53 | "Framework :: Django :: 4.2", 54 | "Framework :: Django :: 5.0", 55 | "Framework :: Django :: 5.1", 56 | "Framework :: Django :: 5.2", 57 | "Intended Audience :: Developers", 58 | "Development Status :: 5 - Production/Stable", 59 | "Environment :: Plugins" 60 | ] 61 | 62 | [tool.poetry.dependencies] 63 | python = ">=3.11, <4.0" 64 | django = ">=4.2" 65 | loguru = "*" 66 | google-auth = "*" 67 | google-auth-httplib2 = "*" 68 | google-auth-oauthlib = "*" 69 | 70 | [tool.poetry.group.dev.dependencies] 71 | auto-changelog = "*" 72 | arrow = "*" 73 | black = {version = "*", allow-prereleases = true} 74 | Faker = "*" 75 | pre-commit = "*" 76 | pytest-coverage = "*" 77 | pytest-django = "*" 78 | pytest-mock = "*" 79 | pytest-asyncio = "*" 80 | twine = "*" 81 | python-dotenv = "*" 82 | mkdocs-material = "*" 83 | mkdocs-mermaid2-plugin = "*" 84 | django-grappelli = "*" 85 | django-jazzmin = "*" 86 | django-admin-interface = "*" 87 | django-jet-reboot = "*" 88 | django-unfold = "*" 89 | click = ">8" 90 | bandit = "*" 91 | flake8 = "*" 92 | stela = "*" 93 | httpx = "*" 94 | 95 | [tool.stela] 96 | environment_variable_name = "STELA_ENV" 97 | evaluate_data = true 98 | show_logs = false 99 | env_file = ".env" 100 | config_file_path = "./example_google_app" 101 | 102 | [build-system] 103 | requires = ["poetry-core>=1.0.0"] 104 | build-backend = "poetry.core.masonry.api" 105 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://squidfunk.github.io/mkdocs-material/schema.json 2 | site_name: Django Google SSO 3 | site_description: Easily add Google SSO login to Django Admin 4 | repo_url: https://github.com/megalus/django-google-sso 5 | repo_name: megalus/django-google-sso 6 | 7 | theme: 8 | name: material 9 | icon: 10 | logo: material/login 11 | repo: fontawesome/brands/github 12 | palette: 13 | # Palette toggle for light mode 14 | - scheme: default 15 | media: "(prefers-color-scheme: light)" 16 | primary: teal 17 | toggle: 18 | icon: material/brightness-7 19 | name: Switch to dark mode 20 | # Palette toggle for dark mode 21 | - scheme: slate 22 | media: "(prefers-color-scheme: dark)" 23 | primary: teal 24 | toggle: 25 | icon: material/brightness-4 26 | name: Switch to light mode 27 | 28 | extra: 29 | consent: 30 | title: Cookie consent 31 | description: >- 32 | We use cookies to recognize your repeated visits and preferences, as well 33 | as to measure the effectiveness of our documentation and whether users 34 | find what they're searching for. With your consent, you're helping us to 35 | make our documentation better. 36 | 37 | markdown_extensions: 38 | - abbr 39 | - admonition 40 | - attr_list 41 | - def_list 42 | - footnotes 43 | - md_in_html 44 | - toc: 45 | permalink: true 46 | - pymdownx.arithmatex: 47 | generic: true 48 | - pymdownx.betterem: 49 | smart_enable: all 50 | - pymdownx.caret 51 | - pymdownx.details 52 | - pymdownx.highlight: 53 | anchor_linenums: true 54 | line_spans: __span 55 | pygments_lang_class: true 56 | - pymdownx.inlinehilite 57 | - pymdownx.keys 58 | - pymdownx.magiclink: 59 | repo_url_shorthand: true 60 | user: squidfunk 61 | repo: mkdocs-material 62 | - pymdownx.mark 63 | - pymdownx.smartsymbols 64 | - pymdownx.tabbed: 65 | alternate_style: true 66 | - pymdownx.tasklist: 67 | custom_checkbox: true 68 | - pymdownx.tilde 69 | - pymdownx.snippets 70 | - pymdownx.superfences: 71 | # make exceptions to highlighting of code: 72 | custom_fences: 73 | - name: mermaid 74 | class: mermaid 75 | format: !!python/name:mermaid2.fence_mermaid_custom 76 | - pymdownx.snippets: 77 | base_path: '.' 78 | check_paths: true 79 | 80 | nav: 81 | - Intro: index.md 82 | - quick_setup.md 83 | - credentials.md 84 | - callback.md 85 | - users.md 86 | - urls.md 87 | - model.md 88 | - admin.md 89 | - customize.md 90 | - third_party_admins.md 91 | - how.md 92 | - advanced.md 93 | - sites.md 94 | - pages.md 95 | - multiple.md 96 | - settings.md 97 | - troubleshooting.md 98 | - thanks.md 99 | 100 | plugins: 101 | - search 102 | - mermaid2 103 | -------------------------------------------------------------------------------- /.junie/guidelines.md: -------------------------------------------------------------------------------- 1 | Project runs on a virtualenv inside WSL. Python interpreter can be found using command `poetry env info`. 2 | 3 | Test runner is `pytest`. To run can use command `make tests` to run all or `make test ::`, but you need docker to runs these commands. Without docker, activate the virtualenv and run `pytest -v` directly. 4 | 5 | Always check code for the `django` versions defined in pyproject.toml. 6 | 7 | Always use type hints in the code. Always use TypeDicts for dictionaries. 8 | 9 | Always use dataclasses for objects. 10 | 11 | Always add docstrings in functions with more than seven lines of code. Use Google style. 12 | 13 | Linter packages are managed by [pre-commit library](https://github.com/pre-commit/pre-commit). Use `make lint` to check for linter and format errors. 14 | 15 | The default python version for this project is 3.12.11. 16 | 17 | When creating Javascript always use JQuery. 18 | 19 | This is a public python library hosted in PyPI. All configuration is inside `pyproject.toml` file. 20 | 21 | Use semantic versioning for commit messages. Create a one-line commit. Do not use "`". 22 | 23 | Use `feat:` if commit creates new code in both ./django_google_sso and unit tests. 24 | 25 | Use `fix:` if commit only changes the code inside ./django_google_sso. 26 | 27 | Use `chore:` if commit changes files outside ./django_google_sso. 28 | 29 | Use `ci:` if commit changes files only in pyproject.toml. 30 | 31 | Use `docs:` if commit changes files only in ./docs or the README. 32 | 33 | Use `refactor:` if commit changes files in ./django_google_sso but not in unit tests. 34 | 35 | Use `BREAKING CHANGE:` if commit changes the minimum version of Python or Django in pyproject.toml. 36 | 37 | Project versioning is done during GitHub actions `.github/publish.yml` workflow, using the [auto-changelog](https://github.com/KeNaCo/auto-changelog) library. 38 | 39 | Always update the README at the root of the project. 40 | 41 | README always contains [shields.io](https://shields.io/docs) badges for (when applicable): python versions, django versions, pypi version, license and build status. 42 | 43 | Prefer use mermaid diagrams on docs. 44 | 45 | Always use English on code, comments, docstrings and documentation. 46 | 47 | The README always contains the minimal configuration for the library to work. 48 | 49 | Always write the README for developers with no or low experience with Django, Google APIs and OAuth2, but be pragmatic and short. The README should be a quick start guide for developers to use the library. 50 | 51 | The ./docs folder contains detailed instructions of how to use the library, including examples and diagrams. Reading order for the markdown files is located in mkdocs.yml at `nav` key. On these docs you can be very didactic. 52 | 53 | The folder `example_google_app` contains a minimal Django app using the library. It can be used as a reference for the documentation. Use their own README.md and settings.py as a reference for how to use. 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Python template 2 | # Byte-compiled / optimized / DLL files 3 | __pycache__/ 4 | *.py[cod] 5 | *$py.class 6 | 7 | # C extensions 8 | *.so 9 | 10 | # Distribution / packaging 11 | .Python 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | cover/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | db.sqlite3-journal 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | 75 | # PyBuilder 76 | .pybuilder/ 77 | target/ 78 | 79 | # Jupyter Notebook 80 | .ipynb_checkpoints 81 | 82 | # IPython 83 | profile_default/ 84 | ipython_config.py 85 | 86 | # pyenv 87 | # For a library or package, you might want to ignore these files since the code is 88 | # intended to run in multiple environments; otherwise, check them in: 89 | # .python-version 90 | 91 | # pipenv 92 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 93 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 94 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 95 | # install all needed dependencies. 96 | #Pipfile.lock 97 | 98 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 99 | __pypackages__/ 100 | 101 | # Celery stuff 102 | celerybeat-schedule 103 | celerybeat.pid 104 | 105 | # SageMath parsed files 106 | *.sage.py 107 | 108 | # Environments 109 | !.env 110 | .venv 111 | env/ 112 | venv/ 113 | ENV/ 114 | env.bak/ 115 | venv.bak/ 116 | 117 | # Spyder project settings 118 | .spyderproject 119 | .spyproject 120 | 121 | # Rope project settings 122 | .ropeproject 123 | 124 | # mkdocs documentation 125 | /site 126 | 127 | # mypy 128 | .mypy_cache/ 129 | .dmypy.json 130 | dmypy.json 131 | 132 | # Pyre type checker 133 | .pyre/ 134 | 135 | # pytype static type analyzer 136 | .pytype/ 137 | 138 | # Cython debug symbols 139 | cython_debug/ 140 | 141 | poetry.lock 142 | *.sqlite* 143 | 144 | .idea/ 145 | 146 | # Stela 147 | *.stela.back 148 | .env.local 149 | .env.*.local 150 | 151 | /.python-version 152 | -------------------------------------------------------------------------------- /django_google_sso/tests/test_sites.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.contrib.auth import get_user_model 3 | 4 | pytestmark = pytest.mark.django_db 5 | 6 | User = get_user_model() 7 | 8 | 9 | @pytest.mark.parametrize( 10 | "client_fixture, should_create_user, login_failed_url, " 11 | "expected_status, expected_redirect", 12 | [ 13 | # Site A: auto-creates users, login succeeds 14 | ("client_with_site_a", True, None, 200, None), 15 | # Site B: doesn't auto-create users, redirects to login failed URL 16 | ("client_with_site_b", False, "admin:index", None, "/admin/"), 17 | ], 18 | ids=["site_a_auto_create", "site_b_no_auto_create"], 19 | ) 20 | def test_site_auto_create_user( 21 | request, 22 | callback_url, 23 | mock_get_sso_value, 24 | client_fixture, 25 | should_create_user, 26 | login_failed_url, 27 | expected_status, 28 | expected_redirect, 29 | ): 30 | """Test user auto-creation behavior based on site settings.""" 31 | 32 | # Arrange 33 | User.objects.filter(email="foo@example.com").delete() 34 | client = request.getfixturevalue(client_fixture) 35 | 36 | if login_failed_url: 37 | # Configure the mock to return custom login_failed_url if provided 38 | mock_get_sso_value.__defaults__ = ( 39 | login_failed_url, 40 | ["example.com", "site.com", "other-site.com"], 41 | ) 42 | 43 | # Act 44 | response = client.get(callback_url, follow=True) 45 | 46 | # Assert 47 | if should_create_user: 48 | assert User.objects.filter( 49 | email="foo@example.com" 50 | ).exists(), f"User should be auto-created on {client_fixture}" 51 | assert client.session.get_expiry_age() > 0, "Session should have an expiry age" 52 | assert response.status_code == expected_status, "Login should be successful" 53 | else: 54 | assert not User.objects.filter( 55 | email="foo@example.com" 56 | ).exists(), f"User should not be auto-created on {client_fixture}" 57 | assert response.redirect_chain[-1][0].startswith( 58 | expected_redirect 59 | ), "Should redirect to login failed URL" 60 | 61 | 62 | def test_existing_user_site_b_session_age( 63 | client_with_site_b, callback_url, mock_get_sso_value 64 | ): 65 | """Test that existing users on site B get a 24-hour session.""" 66 | 67 | # Arrange 68 | user = User.objects.create_user( 69 | username="foo@example.com", 70 | email="foo@example.com", 71 | password="password", # nosec B106 72 | is_active=True, 73 | ) 74 | 75 | # Act 76 | response = client_with_site_b.get(callback_url, follow=True) 77 | 78 | # Assert 79 | assert response.status_code == 200, "Login should be successful for existing user" 80 | assert ( 81 | client_with_site_b.session.get_expiry_age() > 0 82 | ), "Session should have an expiry age" 83 | 84 | # Clean up 85 | user.delete() 86 | -------------------------------------------------------------------------------- /docs/callback.md: -------------------------------------------------------------------------------- 1 | # Get your Callback URI 2 | 3 | The callback URL is the URL where Google will redirect the user after the authentication process. This URL must be 4 | registered in your Google project. 5 | 6 | --- 7 | 8 | ## The Callback URI 9 | The callback URI is composed of `{scheme}://{netloc}/{path}/`, where the _netloc_ is the domain name of your Django 10 | project, and the _path_ is `/google_sso/callback/`. For example, if your Django project is hosted on 11 | `https://myproject.com`, then the callback URL will be `https://myproject.com/google_sso/callback/`. 12 | 13 | So, let's break each part of this URI: 14 | 15 | ### The scheme 16 | The scheme is the protocol used to access the URL. It can be `http` or `https`. **Django-Google-SSO** will select the 17 | same scheme used by the URL which shows to you the login page. 18 | 19 | For example, if you're running locally, like `http://localhost:8000/accounts/login`, then the callback URL scheme 20 | will be `http://`. 21 | 22 | ??? question "How about a Reverse-Proxy?" 23 | If you're running Django behind a reverse-proxy, please make sure you're passing the correct 24 | `X-Forwarded-Proto` header to the login request URL. 25 | 26 | ### The NetLoc 27 | The NetLoc is the domain of your Django project. It can be a dns name, or an IP address, including the Port, if 28 | needed. Some examples are: `example.com`, `localhost:8000`, `api.my-domain.com`, and so on. To find the correct netloc, 29 | **Django-Google-SSO** will check, in that order: 30 | 31 | - If settings contain the variable `GOOGLE_SSO_CALLBACK_DOMAIN`, it will use this value. 32 | - If Sites Framework is active, it will use the domain field for the current site. 33 | - The netloc found in the URL which shows you the login page. 34 | 35 | ### The Path 36 | The path is the path to the callback view. It will be always `//callback/`. 37 | 38 | Remember when you add this to the `urls.py`? 39 | 40 | ```python 41 | from django.urls import include, path 42 | 43 | urlpatterns = [ 44 | # other urlpatterns... 45 | path( 46 | "google_sso/", include( 47 | "django_google_sso.urls", 48 | namespace="django_google_sso" 49 | ) 50 | ), 51 | ] 52 | ``` 53 | 54 | The path starts with the `google_sso/` part. If you change this to `sso/` for example, your callback URL will change to 55 | `https://myproject.com/sso/callback/`. 56 | 57 | --- 58 | 59 | ## Registering the URI 60 | 61 | To register the callback URL, in your [Google project](https://console.cloud.google.com/apis/credentials), add the callback URL in the 62 | _**Authorized redirect URIs**_ field, clicking on button `Add URI`. Then add your full URL and click on `Save`. 63 | 64 | !!! tip "Do not forget the trailing slash" 65 | Many errors on this step are caused by forgetting the trailing slash: 66 | 67 | * Good: `http://localhost:8000/google_sso/callback/` 68 | * Bad: `http://localhost:8000/google_sso/callback` 69 | 70 | --- 71 | 72 | In the next step, we will configure **Django-Google-SSO** to auto create the Users. 73 | -------------------------------------------------------------------------------- /django_google_sso/checks/warnings.py: -------------------------------------------------------------------------------- 1 | from django.core.checks import Tags, register 2 | from django.core.checks.messages import Warning 3 | 4 | TEMPLATE_TAG_NAMES = ["show_form", "sso_tags"] 5 | 6 | 7 | @register(Tags.templates) 8 | def register_sso_check(app_configs, **kwargs): 9 | """Check for E003/W003 template warnings. 10 | 11 | This is a copy of the original check_for_template_tags_with_the_same_name 12 | but filtering out the TEMPLATE_TAG_NAMES from this library. 13 | 14 | Django will raise this warning if you're installed more than one SSO provider, 15 | like django_microsoft_sso and django_github_sso. 16 | 17 | To silence any E003/W003 warning, you can add the following to your settings.py: 18 | SILENCED_SYSTEM_CHECKS = ["templates.W003"] # or templates.E003 for Django<=5.1 19 | 20 | And to run an alternate version of this check, 21 | you can add the following to your settings.py: 22 | SSO_USE_ALTERNATE_W003 = True 23 | 24 | You need to silence the original templates.W003 check for this to work. 25 | New warnings will use the id `sso.W003` 26 | 27 | """ 28 | try: # Django <=5.0 error was templates.E003 29 | from django.core.checks.templates import ( 30 | check_for_template_tags_with_the_same_name, 31 | ) 32 | 33 | errors = check_for_template_tags_with_the_same_name(app_configs, **kwargs) 34 | errors = [ 35 | Warning(msg=error.msg, hint=error.hint, obj=error.obj, id="sso.E003") 36 | for error in errors 37 | if not any(name in error.msg for name in TEMPLATE_TAG_NAMES) 38 | ] 39 | return errors 40 | except ImportError: # Django >=5.1 error is now templates.W003 41 | from django.apps import apps 42 | from django.conf import settings 43 | from django.template.backends.django import DjangoTemplates 44 | 45 | errors = [] 46 | if app_configs is None: 47 | app_configs = apps.get_app_configs() 48 | 49 | errors = [] 50 | for config in app_configs: 51 | for engine in settings.TEMPLATES: 52 | if ( 53 | engine["BACKEND"] 54 | == "django.template.backends.django.DjangoTemplates" 55 | ): 56 | engine_params = engine.copy() 57 | engine_params.pop("BACKEND") 58 | django_engine = DjangoTemplates(engine_params) 59 | template_tag_errors = ( 60 | django_engine._check_for_template_tags_with_the_same_name() 61 | ) 62 | for error in template_tag_errors: 63 | if not any(name in error.msg for name in TEMPLATE_TAG_NAMES): 64 | errors.append( 65 | Warning( 66 | msg=error.msg, 67 | hint=error.hint, 68 | obj=error.obj, 69 | id="sso.W003", 70 | ) 71 | ) 72 | return errors 73 | -------------------------------------------------------------------------------- /docs/advanced.md: -------------------------------------------------------------------------------- 1 | # Advanced Use 2 | 3 | On this section, you will learn how to use **Django Google SSO** in more advanced scenarios. This section assumes you 4 | have a good understanding for Django advanced techniques, like custom User models, custom authentication backends, and 5 | so on. 6 | 7 | ## Using Custom Authentication Backend 8 | 9 | If the users need to log in using a custom authentication backend, you can use the `GOOGLE_SSO_AUTHENTICATION_BACKEND` 10 | setting: 11 | 12 | ```python 13 | # settings.py 14 | 15 | GOOGLE_SSO_AUTHENTICATION_BACKEND = "myapp.authentication.MyCustomAuthenticationBackend" 16 | ``` 17 | 18 | ## Using Google as Single Source of Truth 19 | 20 | If you want to use Google as the single source of truth for your users, you can simply set the 21 | `GOOGLE_SSO_ALWAYS_UPDATE_USER_DATA`. This will enforce the basic user data (first name, last name, email and picture) to be 22 | updated at every login. 23 | 24 | ```python 25 | # settings.py 26 | 27 | GOOGLE_SSO_ALWAYS_UPDATE_USER_DATA = True # Always update user data on login 28 | ``` 29 | 30 | ## Adding additional data to User model though scopes 31 | 32 | If you need more advanced logic, you can use the `GOOGLE_SSO_PRE_LOGIN_CALLBACK` setting to import custom data from Google 33 | (considering you have configured the right scopes and possibly a Custom User model to store these fields). 34 | 35 | For example, you can use the following code to update the user's 36 | name, email and birthdate at every login: 37 | 38 | ```python 39 | # settings.py 40 | 41 | GOOGLE_SSO_SAVE_ACCESS_TOKEN = True # You will need this token 42 | GOOGLE_SSO_PRE_LOGIN_CALLBACK = "hooks.pre_login_user" 43 | GOOGLE_SSO_SCOPES = [ 44 | "openid", 45 | "https://www.googleapis.com/auth/userinfo.email", 46 | "https://www.googleapis.com/auth/userinfo.profile", 47 | "https://www.googleapis.com/auth/user.birthday.read", # <- This is a custom scope 48 | ] 49 | ``` 50 | 51 | ```python 52 | # myapp/hooks.py 53 | import datetime 54 | import httpx 55 | from loguru import logger 56 | 57 | 58 | def pre_login_user(user, request): 59 | token = request.session.get("google_sso_access_token") 60 | if token: 61 | headers = { 62 | "Authorization": f"Bearer {token}", 63 | } 64 | 65 | # Request Google User Info 66 | url = "https://www.googleapis.com/oauth2/v3/userinfo" 67 | response = httpx.get(url, headers=headers) 68 | user_data = response.json() 69 | logger.debug(f"Updating User Data with Google User Info: {user_data}") 70 | 71 | # Request Google People Info for the additional scopes 72 | url = f"https://people.googleapis.com/v1/people/me?personFields=birthdays" 73 | response = httpx.get(url, headers=headers) 74 | people_data = response.json() 75 | logger.debug(f"Updating User Data with Google People Info: {people_data}") 76 | birthdate = datetime.date(**people_data["birthdays"][0]['date']) 77 | 78 | user.first_name = user_data["given_name"] 79 | user.last_name = user_data["family_name"] 80 | user.email = user_data["email"] 81 | user.birthdate = birthdate # You need a Custom User model to store this field 82 | user.save() 83 | ``` 84 | -------------------------------------------------------------------------------- /docs/quick_setup.md: -------------------------------------------------------------------------------- 1 | # Quick Setup 2 | 3 | ## Setup Django Settings 4 | 5 | To add this package in your Django Project, please modify the `INSTALLED_APPS` in your `settings.py`: 6 | 7 | ```python 8 | # settings.py 9 | 10 | INSTALLED_APPS = [ 11 | # other django apps 12 | "django.contrib.messages", # Need for Auth messages 13 | "django_google_sso", # Add django_google_sso 14 | ] 15 | ``` 16 | 17 | ## Setup Google Credentials 18 | 19 | Now, add your [Google Project Web App API Credentials](https://console.cloud.google.com/apis/credentials) in your `settings.py`: 20 | 21 | ```python 22 | # settings.py 23 | 24 | GOOGLE_SSO_CLIENT_ID = "your Web App Client Id here" 25 | GOOGLE_SSO_CLIENT_SECRET = "your Web App Client Secret here" 26 | GOOGLE_SSO_PROJECT_ID = "your Google Project Id here" 27 | ``` 28 | 29 | ## Setup Callback URI 30 | 31 | In [Google Console](https://console.cloud.google.com/apis/credentials) at _Api -> Credentials -> Oauth2 Client_, 32 | add the following _Authorized Redirect URI_: `https://your-domain.com/google_sso/callback/` replacing `your-domain.com` with your 33 | real domain (and Port). For example, if you're running locally, you can use `http://localhost:8000/google_sso/callback/`. 34 | 35 | !!! tip "Do not forget the trailing slash!" 36 | 37 | ## Setup Auto-Create Users 38 | 39 | The next option is to set up the auto-create users from Django Google SSO. Only emails with the allowed domains will be 40 | created automatically. If the email is not in the allowed domains, the user will be redirected to the login page. 41 | 42 | ```python 43 | # settings.py 44 | 45 | GOOGLE_SSO_ALLOWABLE_DOMAINS = ["your-domain.com"] 46 | ``` 47 | 48 | ## Setup Django URLs 49 | 50 | And in your `urls.py` please add the **Django-Google-SSO** views: 51 | 52 | ```python 53 | # urls.py 54 | 55 | from django.urls import include, path 56 | 57 | urlpatterns = [ 58 | # other urlpatterns... 59 | path( 60 | "google_sso/", include( 61 | "django_google_sso.urls", 62 | namespace="django_google_sso" 63 | ) 64 | ), 65 | ] 66 | ``` 67 | 68 | ## Run Django migrations 69 | 70 | Finally, run migrations 71 | 72 | ```shell 73 | $ python manage.py migrate 74 | ``` 75 | 76 | --- 77 | 78 | And, that's it: **Django Google SSO** is ready for use. When you open the admin page, you will see the "Login with Google" button: 79 | 80 | === "Light Mode" 81 | ![](images/django_login_with_google_light.png) 82 | 83 | === "Dark Mode" 84 | ![](images/django_login_with_google_dark.png) 85 | 86 | ??? question "How about Django Admin skins, like Grappelli?" 87 | **Django Google SSO** will works with any Django Admin skin which calls the original Django login template, like 88 | [Grappelli](https://github.com/sehmaschine/django-grappelli), [Django Jazzmin](https://github.com/farridav/django-jazzmin), 89 | [Django Admin Interface](https://github.com/fabiocaccamo/django-admin-interface) and [Django Jet Reboot](https://github.com/assem-ch/django-jet-reboot). 90 | 91 | If the skin uses his own login template, you will need create your own `admin/login.html` template to add both HTML from custom login.html from the custom package and from this library. 92 | 93 | --- 94 | 95 | For the next pages, let's see each one of these steps with more details. 96 | -------------------------------------------------------------------------------- /docs/credentials.md: -------------------------------------------------------------------------------- 1 | # Adding Google Credentials 2 | 3 | To make the SSO work, we need to set up, in your Django project, the Google credentials needed to perform the 4 | authentication. 5 | 6 | --- 7 | 8 | ## Getting Google Credentials 9 | 10 | In your [Google Console](https://console.cloud.google.com/apis/credentials) navigate to _Api -> Credentials_ to access 11 | the credentials for your all Google Cloud Projects. 12 | 13 | !!! tip "Your first Google Cloud Project" 14 | If you don't have a Google Cloud Project, you can create one by clicking on the _**Create**_ button. 15 | 16 | Then, you can select one of existing Web App Oauth 2.0 Client Ids in your Google project, or create a new one. 17 | 18 | ??? question "Do I need to create a new Oauth 2.0 Client Web App?" 19 | Normally you will have one credential per environment in your Django project. For example, if you have 20 | a _development_, _staging_ and _production_ environments, then you will have three credentials, one for each one. 21 | This mitigates the risk of exposing all your data in case of a security breach. 22 | 23 | If you decide to create a new one, please check https://developers.google.com/identity/protocols/oauth2/ for additional info. 24 | 25 | When you open your Web App Client Id, please get the following information: 26 | 27 | * The **Client ID**. This is something like `XXXX.apps.googleusercontent.com` and will be the `GOOGLE_SSO_CLIENT_ID` in 28 | your Django project. 29 | * The **Client Secret Key**. This is a long string and will be the `GOOGLE_SSO_CLIENT_SECRET` in your Django project. 30 | * The **Project ID**. This is the Project ID, you can get click on the Project Name, and will be 31 | the `GOOGLE_SSO_PROJECT_ID` in your Django project. 32 | 33 | After that, add them in your `settings.py` file: 34 | 35 | ```python 36 | # settings.py 37 | GOOGLE_SSO_ALLOWABLE_DOMAINS = ["your domain here"] 38 | GOOGLE_SSO_CLIENT_ID = "your client id here" 39 | GOOGLE_SSO_CLIENT_SECRET = "your client secret here" 40 | GOOGLE_SSO_PROJECT_ID = "your project id here" 41 | ``` 42 | 43 | Don't commit this info in your repository. 44 | This permits you to have different credentials for each environment and mitigates security breaches. 45 | That's why we recommend you to use environment variables to store this info. 46 | To read this data, we recommend you to install and use a [Twelve-factor compatible](https://www.12factor.net/) library 47 | in your project. 48 | 49 | For example, you can use our [sister project Stela](https://github.com/megalus/stela) to load the environment 50 | variables from a `.env.local` file, like this: 51 | 52 | ```ini 53 | # .env.local 54 | GOOGLE_SSO_ALLOWABLE_DOMAINS=["your domain here"] 55 | GOOGLE_SSO_CLIENT_ID="your client id here" 56 | GOOGLE_SSO_CLIENT_SECRET="your client secret here" 57 | GOOGLE_SSO_PROJECT_ID="your project id here" 58 | ``` 59 | 60 | ```python 61 | # Django settings.py 62 | from stela import env 63 | 64 | GOOGLE_SSO_ALLOWABLE_DOMAINS = env.GOOGLE_SSO_ALLOWABLE_DOMAINS 65 | GOOGLE_SSO_CLIENT_ID = env.GOOGLE_SSO_CLIENT_ID 66 | GOOGLE_SSO_CLIENT_SECRET = env.GOOGLE_SSO_CLIENT_SECRET 67 | GOOGLE_SSO_PROJECT_ID = env.GOOGLE_SSO_PROJECT_ID 68 | ``` 69 | 70 | But in fact, you can use any library you want, like 71 | [django-environ](https://pypi.org/project/django-environ/), [django-constance](https://github.com/jazzband/django-constance), 72 | [python-dotenv](https://pypi.org/project/python-dotenv/), etc... 73 | 74 | --- 75 | 76 | In the next step, we need to configure the authorized callback URI for your Django project. 77 | -------------------------------------------------------------------------------- /django_google_sso/tests/test_google_auth.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.contrib.sites.models import Site 3 | 4 | from django_google_sso import conf 5 | from django_google_sso.main import GoogleAuth 6 | 7 | pytestmark = pytest.mark.django_db 8 | 9 | 10 | def test_scopes(callback_request): 11 | # Arrange 12 | google = GoogleAuth(callback_request) 13 | 14 | # Assert 15 | assert google.scopes == conf.GOOGLE_SSO_SCOPES 16 | 17 | 18 | def test_get_client_config(monkeypatch, callback_request): 19 | # Arrange 20 | monkeypatch.setattr(conf, "GOOGLE_SSO_CLIENT_ID", "client_id") 21 | monkeypatch.setattr(conf, "GOOGLE_SSO_PROJECT_ID", "project_id") 22 | monkeypatch.setattr(conf, "GOOGLE_SSO_CLIENT_SECRET", "redirect_uri") 23 | monkeypatch.setattr(conf, "GOOGLE_SSO_CALLBACK_DOMAIN", "localhost:8000") 24 | 25 | # Act 26 | google = GoogleAuth(callback_request) 27 | 28 | # Assert 29 | assert google.get_client_config() == { 30 | "web": { 31 | "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", 32 | "auth_uri": "https://accounts.google.com/o/oauth2/auth", 33 | "client_id": "client_id", 34 | "client_secret": "redirect_uri", 35 | "project_id": "project_id", 36 | "redirect_uris": ["http://localhost:8000/google_sso/callback/"], 37 | "token_uri": "https://oauth2.googleapis.com/token", 38 | } 39 | } 40 | 41 | 42 | def test_get_redirect_uri_from_http(callback_request, monkeypatch): 43 | # Arrange 44 | expected_scheme = "http" 45 | monkeypatch.setattr(conf, "GOOGLE_SSO_CALLBACK_DOMAIN", None) 46 | current_site_domain = Site.objects.get_current().domain 47 | 48 | # Act 49 | google = GoogleAuth(callback_request) 50 | 51 | # Assert 52 | assert ( 53 | google.get_redirect_uri() 54 | == f"{expected_scheme}://{current_site_domain}/google_sso/callback/" 55 | ) 56 | 57 | 58 | def test_get_redirect_uri_from_reverse_proxy( 59 | callback_request_from_reverse_proxy, monkeypatch 60 | ): 61 | # Arrange 62 | expected_scheme = "https" 63 | monkeypatch.setattr(conf, "GOOGLE_SSO_CALLBACK_DOMAIN", None) 64 | current_site_domain = Site.objects.get_current().domain 65 | 66 | # Act 67 | google = GoogleAuth(callback_request_from_reverse_proxy) 68 | 69 | # Assert 70 | assert ( 71 | google.get_redirect_uri() 72 | == f"{expected_scheme}://{current_site_domain}/google_sso/callback/" 73 | ) 74 | 75 | 76 | def test_redirect_uri_with_custom_domain( 77 | callback_request_from_reverse_proxy, monkeypatch 78 | ): 79 | # Arrange 80 | monkeypatch.setattr(conf, "GOOGLE_SSO_CALLBACK_DOMAIN", "my-other-domain.com") 81 | 82 | # Act 83 | google = GoogleAuth(callback_request_from_reverse_proxy) 84 | 85 | # Assert 86 | assert ( 87 | google.get_redirect_uri() == "https://my-other-domain.com/google_sso/callback/" 88 | ) 89 | 90 | 91 | def test_get_redirect_uri_from_multiple_reverse_proxies(rf, query_string, monkeypatch): 92 | # Arrange 93 | expected_scheme = "https" 94 | monkeypatch.setattr(conf, "GOOGLE_SSO_CALLBACK_DOMAIN", None) 95 | current_site_domain = Site.objects.get_current().domain 96 | request = rf.get( 97 | f"/google_sso/callback/?{query_string}", HTTP_X_FORWARDED_PROTO="https, https" 98 | ) 99 | 100 | # Act 101 | google = GoogleAuth(request) 102 | 103 | # Assert 104 | assert ( 105 | google.get_redirect_uri() 106 | == f"{expected_scheme}://{current_site_domain}/google_sso/callback/" 107 | ) 108 | -------------------------------------------------------------------------------- /django_google_sso/tests/test_callables.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from django_google_sso.models import User 4 | 5 | pytestmark = pytest.mark.django_db(transaction=True) 6 | 7 | 8 | @pytest.mark.parametrize( 9 | "value, expected", 10 | [ 11 | (lambda req: "dynamic_value", "dynamic_value"), 12 | ("static_value", "static_value"), 13 | ], 14 | ) 15 | def test_value_from_conf(client_with_session, settings, value, expected): 16 | # Arrange 17 | settings.GOOGLE_SSO_TEXT = value 18 | 19 | # Act 20 | response = client_with_session.get("/") 21 | response_text = ( 22 | response.text if hasattr(response, "text") else response.content.decode() 23 | ) 24 | 25 | # Assert 26 | assert expected in response_text 27 | 28 | 29 | @pytest.mark.parametrize( 30 | "value, will_raise", 31 | [ 32 | (True, False), 33 | (lambda req: True, True), 34 | ], 35 | ) 36 | def test_accept_callable(settings, value, will_raise): 37 | # Arrange 38 | from django_google_sso import conf 39 | 40 | settings.GOOGLE_SSO_ENABLED = value 41 | 42 | # Act / Assert 43 | if will_raise: 44 | with pytest.raises(TypeError): 45 | assert conf.GOOGLE_SSO_ENABLED is True 46 | else: 47 | assert conf.GOOGLE_SSO_ENABLED is True 48 | 49 | 50 | def test_is_admin_path(client_with_session, settings): 51 | # Arrange 52 | from django_google_sso.helpers import is_admin_path 53 | 54 | settings.GOOGLE_SSO_ADMIN_ENABLED = is_admin_path 55 | settings.GOOGLE_SSO_PAGES_ENABLED = False 56 | 57 | # Act 58 | response_admin = client_with_session.get("/admin/login", follow=True) 59 | response_admin_text = ( 60 | response_admin.text 61 | if hasattr(response_admin, "text") 62 | else response_admin.content.decode() 63 | ) 64 | response_page = client_with_session.get("/", follow=True) 65 | response_page_text = ( 66 | response_page.text 67 | if hasattr(response_page, "text") 68 | else response_page.content.decode() 69 | ) 70 | 71 | # Assert 72 | assert "Sign in with Google" in response_admin_text 73 | assert "Sign in with Google" not in response_page_text 74 | 75 | 76 | def test_is_page_path(client_with_session, settings): 77 | # Arrange 78 | from django_google_sso.helpers import is_page_path 79 | 80 | settings.GOOGLE_SSO_ADMIN_ENABLED = False 81 | settings.GOOGLE_SSO_PAGES_ENABLED = is_page_path 82 | 83 | # Act 84 | response_admin = client_with_session.get("/admin/login", follow=True) 85 | response_admin_text = ( 86 | response_admin.text 87 | if hasattr(response_admin, "text") 88 | else response_admin.content.decode() 89 | ) 90 | response_page = client_with_session.get("/", follow=True) 91 | response_page_text = ( 92 | response_page.text 93 | if hasattr(response_page, "text") 94 | else response_page.content.decode() 95 | ) 96 | 97 | # Assert 98 | assert "Sign in with Google" not in response_admin_text 99 | assert "Sign in with Google" in response_page_text 100 | 101 | 102 | def test_pages_login_not_allowed( 103 | client_with_session, settings, google_response, callback_url 104 | ): 105 | # Arrange 106 | settings.GOOGLE_SSO_ENABLED = True 107 | settings.GOOGLE_SSO_PAGES_ENABLED = False 108 | 109 | # Act 110 | response = client_with_session.get(callback_url) 111 | 112 | # Assert 113 | assert response.status_code == 302 114 | assert User.objects.count() == 0 115 | assert response.url == "/" 116 | assert response.wsgi_request.user.is_authenticated is False 117 | -------------------------------------------------------------------------------- /docs/pages.md: -------------------------------------------------------------------------------- 1 | # Using Django Google SSO outside Django Admin 2 | 3 | Django-Google-SSO aims for simplicity, that's why the primary focus is on the Admin login logic. But this package can be used 4 | outside Django Admin, in a custom login page. To do so, you can follow the steps below. 5 | 6 | !!! warning "This is the Tip of the Iceberg" 7 | In real-life projects, user customer login involves more than just a login button. You need to implement many 8 | features like OTP, Captcha, "Recover Password", "Remember Me", "Login with Passkey" etc. This documentation shows a simple implementation 9 | to demonstrate how to use Django Google SSO outside Django Admin, but for more complex UX requisites, please check 10 | full solutions like [django-allauth](https://django-allauth.readthedocs.io/en/latest/index.html), or incremental 11 | solutions like [django-otp](https://github.com/django-otp/django-otp), [django-recaptcha](https://github.com/django-recaptcha/django-recaptcha), 12 | [django-passkeys](https://github.com/mkalioby/django-passkeys), etc. 13 | 14 | 15 | ### Add Django Google SSO templates to your login page 16 | 17 | Inside your login template, just add these two lines: 18 | 19 | * `{% include 'google_sso/login_sso.html' %}` inside `` 20 | * `{% static 'django_google_sso/google_button.css' %}` inside `` 21 | 22 | ### Login template example 23 | ```html 24 | --8<-- "example_google_app/templates/login.html" 25 | ``` 26 | 27 | The `include` command will add the login button to your template for all django-sso installed in the project. 28 | 29 | ## Define per-request parameters 30 | 31 | In the case you need different behavior for the login to Admin and login to Django pages, you can define this using callables on the Django Google SSO settings. For example: 32 | 33 | 34 | | Setting | Login to Admin | Login to Pages | 35 | |---------------------------------|-------------------|----------------| 36 | | `GOOGLE_SSO_ALLOWABLE_DOMAINS` | `["example.com"]` | `["*"]` | 37 | | `GOOGLE_SSO_LOGIN_FAILED_URL` | `"admin:login"` | `"index"` | 38 | | `GOOGLE_SSO_NEXT_URL` | `"admin:index"` | `"secret"` | 39 | | `GOOGLE_SSO_SESSION_COOKIE_AGE` | `3600` | `86400` | 40 | | `GOOGLE_SSO_STAFF_LIST` | `[...]` | `[]` | 41 | | `GOOGLE_SSO_SUPERUSER_LIST` | `[...]` | `[]` | 42 | 43 | !!! tip "You can config almost all settings per request" 44 | You can config different Google credentials, Scopes, Default Locale, etc. Please check the 45 | [Settings](settings.md) and [Sites](sites.md) docs for more details. 46 | 47 | ### Settings logic example 48 | ```python 49 | --8<-- "example_google_app/settings.py:sso_config" 50 | ``` 51 | 52 | ### Toggle Google SSO between Admin and Page logins 53 | 54 | Finally, if you want to toggle between Admin and Page login, you can enable/disable Google SSO using the 55 | `GOOGLE_SSO_PAGES_ENABLED` and `GOOGLE_SSO_ADMIN_ENABLED`. 56 | For example, if you want to enable Google SSO only for Page login: 57 | 58 | ```python 59 | # settings.py 60 | 61 | # Enable or disable globally 62 | GOOGLE_SSO_ENABLED = True 63 | 64 | # Enable or Disable per request 65 | # Always configure both Admin and Pages settings 66 | GOOGLE_SSO_ADMIN_ENABLED = False 67 | GOOGLE_SSO_PAGES_ENABLED = True 68 | ``` 69 | !!! question "How Package knows if the request is for Admin or Page login?" 70 | The package uses the `is_admin_path` and `is_page_path` helpers to check if the `request.path` 71 | starts with the admin path. To find the admin path, the package uses the `SSO_ADMIN_ROUTE` 72 | setting (default: `admin:index`). 73 | 74 | ```python 75 | # settings.py 76 | from django_google_sso.helpers import is_admin_path, is_page_path 77 | 78 | SSO_ADMIN_ROUTE = "admin:index" # Default admin route 79 | 80 | GOOGLE_SSO_ENABLED = True 81 | GOOGLE_SSO_ADMIN_ENABLED = is_admin_path # Same as True 82 | GOOGLE_SSO_PAGES_ENABLED = is_page_path # Same as True 83 | ``` 84 | -------------------------------------------------------------------------------- /docs/third_party_admins.md: -------------------------------------------------------------------------------- 1 | # Using Third Party Django Admins 2 | 3 | Django has a great ecosystem, and many third-party apps are available to completely replace the default UI for Django Admin. We are trying to make Django Google SSO compatible as much as possible with these third-party apps. We can divide these apps broadly into two categories: apps which use the original Django Admin login template and apps with custom login templates. 4 | 5 | ??? question "How can I know if the third app has a custom login template?" 6 | Check if the app code contains the `templates/admin/login.html` file. If the file exists, the app has a custom login template. 7 | 8 | ## Apps with use original Django Admin login template 9 | For these apps, Django Google SSO will work out of the box. You don't need to do anything special to make it work. 10 | 11 | Some examples: 12 | 13 | - [Django Admin Interface](https://github.com/fabiocaccamo/django-admin-interface) 14 | - [Django Grappelli](https://github.com/sehmaschine/django-grappelli) 15 | - [Django Jazzmin](https://github.com/farridav/django-jazzmin) 16 | - [Django Jet Reboot](https://github.com/assem-ch/django-jet-reboot) 17 | 18 | ## Apps with custom login template 19 | For these apps, you will need to create your own `admin/login.html` template to add both HTML from the custom login.html from the custom package and from this library, using this basic guideline: 20 | 21 | ### Create a custom `templates/admin/login.html` template 22 | Suppose the `templates/admin/login.html` from the 3rd party app is using this structure: 23 | 24 | ```django 25 | {% extends "third_app/base.html" %} 26 | 27 | {% block my_form %} 28 |
29 | {% csrf_token %} 30 | {{ form.as_p }} 31 | 32 | {% endblock %} 33 | ``` 34 | 35 | Please add on your project the `templates/admin/login.html` template: 36 | 37 | ```django 38 | {% extends "admin/login.html" %} 39 | 40 | {% block my_form %} {# Use the name of the block from the third-party app #} 41 | {{ block.super }} {# this will include the 3rd party app login.html content #} 42 | {% include "google_sso/login_sso.html" %} {# this will include the Google SSO login button #} 43 | {% endblock %} 44 | ``` 45 | 46 | Now, let's add support to the `SSO_SHOW_FORM_ON_ADMIN_PAGE` option. To do this, update the code to include our `show_form` tag: 47 | 48 | ```django 49 | {% extends "admin/login.html" %} 50 | {% load show_form %} 51 | 52 | {% block my_form %} {# Use the name of the block from the third-party app #} 53 | {% define_show_form as show_form %} 54 | {% if show_form %} 55 | {{ block.super }} {# this will include the 3rd party app login.html content #} 56 | {% endif %} 57 | {% include "google_sso/login_sso.html" %} {# this will include the Google SSO login button #} 58 | {% endblock %} 59 | ``` 60 | 61 | !!! tip "This is a basic example." 62 | 63 | In real cases, you will need to understand how to find the correct elements to hide, and/or how to correct positioning the SSO buttons on the 64 | 3rd party app layout. Use the real life example from `django-unfold` described below. 65 | 66 | Also, make sure you understand how Django works with [Template inheritance](https://docs.djangoproject.com/en/5.0/ref/templates/language/#template-inheritance) 67 | and [How to override templates](https://docs.djangoproject.com/en/5.0/howto/overriding-templates/). 68 | 69 | ### Current Custom Login Apps support 70 | 71 | To this date, Django Google SSO provides support out of the box for these apps with custom login templates: 72 | 73 | - [Django Unfold](https://github.com/unfoldadmin/django-unfold) 74 | 75 | For the Django Unfold this is the code used on our login template: 76 | 77 | ```django 78 | --8<-- "django_google_sso/templates/google_sso/login.html" 79 | ``` 80 | 81 | And this is the CSS you can use to customize your login button (you will need to create your custom `static/django_google_sso/google_button.css/` to work): 82 | 83 | ```css 84 | --8<-- "example_google_app/static/django_google_sso/google_button_unfold.css" 85 | ``` 86 | -------------------------------------------------------------------------------- /docs/sites.md: -------------------------------------------------------------------------------- 1 | # Using Django Sites Framework 2 | 3 | Django Google SSO supports the [Django Sites Framework](https://docs.djangoproject.com/en/stable/ref/contrib/sites/), allowing you to have different SSO configurations for different sites in your Django project. 4 | 5 | ## How It Works 6 | 7 | Most configuration settings in Django Google SSO can now accept either a direct value or a callable function that receives the current request and returns the appropriate value for the current site. 8 | 9 | This means you can dynamically determine configuration values based on the current site being accessed, enabling scenarios like: 10 | 11 | - Different Google OAuth credentials per site 12 | - Different user creation policies per site 13 | - Different session timeouts per site 14 | 15 | ## Setup 16 | 17 | 1. First, ensure the Django Sites Framework is properly configured in your project: 18 | 19 | ```python 20 | # settings.py 21 | INSTALLED_APPS = [ 22 | # ... 23 | 'django.contrib.sites', 24 | 'django_google_sso', 25 | # ... 26 | ] 27 | 28 | SITE_ID = 1 # Default site ID 29 | ``` 30 | 31 | 2. Create your sites in the Django admin or via migrations. 32 | 33 | 3. Configure Django Google SSO settings as callables that return different values based on the current site: 34 | 35 | ```python 36 | # settings.py 37 | from django.contrib.sites.shortcuts import get_current_site 38 | 39 | def get_client_id(request): 40 | """Return different client ID based on the current site.""" 41 | site = get_current_site(request) 42 | 43 | # Map site domains to client IDs 44 | client_ids = { 45 | 'example.com': 'client-id-for-example-com', 46 | 'other-site.com': 'client-id-for-other-site', 47 | } 48 | 49 | return client_ids.get(site.domain, 'default-client-id') 50 | 51 | # Configure settings as callables 52 | GOOGLE_SSO_CLIENT_ID = get_client_id 53 | ``` 54 | 55 | ## Example: Complete Site-Specific Configuration 56 | 57 | Here's a more comprehensive example showing how to configure multiple settings per site: 58 | 59 | ```python 60 | # settings.py 61 | from django.contrib.sites.shortcuts import get_current_site 62 | 63 | def get_site_config(request, config_key): 64 | """Get site-specific configuration.""" 65 | site = get_current_site(request) 66 | 67 | # Define configurations for each site 68 | site_configs = { 69 | 'example.com': { 70 | 'client_id': 'client-id-for-example-com', 71 | 'client_secret': 'secret-for-example-com', 72 | 'project_id': 'project-id-for-example-com', 73 | 'auto_create_users': True, 74 | 'session_cookie_age': 3600, # 1 hour 75 | 'allowable_domains': ['example.com', 'example.org'], 76 | }, 77 | 'other-site.com': { 78 | 'client_id': 'client-id-for-other-site', 79 | 'client_secret': 'secret-for-other-site', 80 | 'project_id': 'project-id-for-other-site', 81 | 'auto_create_users': False, 82 | 'session_cookie_age': 86400, # 24 hours 83 | 'allowable_domains': ['other-site.com'], 84 | } 85 | } 86 | 87 | # Get config for current site, or use defaults 88 | site_config = site_configs.get(site.domain, {}) 89 | return site_config.get(config_key, None) 90 | 91 | # Configure settings as callables 92 | GOOGLE_SSO_CLIENT_ID = lambda request: get_site_config(request, 'client_id') 93 | GOOGLE_SSO_CLIENT_SECRET = lambda request: get_site_config(request, 'client_secret') 94 | GOOGLE_SSO_PROJECT_ID = lambda request: get_site_config(request, 'project_id') 95 | GOOGLE_SSO_AUTO_CREATE_USERS = lambda request: get_site_config(request, 'auto_create_users') 96 | GOOGLE_SSO_SESSION_COOKIE_AGE = lambda request: get_site_config(request, 'session_cookie_age') 97 | GOOGLE_SSO_ALLOWABLE_DOMAINS = lambda request: get_site_config(request, 'allowable_domains') 98 | ``` 99 | !!! warning "Unsupported Settings for Callables" 100 | **All** settings support callable configuration, **except** the following: 101 | 102 | - `GOOGLE_SSO_ENABLED` 103 | - `GOOGLE_SSO_ENABLE_LOGS` 104 | - `SSO_USE_ALTERNATE_W003` 105 | -------------------------------------------------------------------------------- /example_google_app/backend.py: -------------------------------------------------------------------------------- 1 | import arrow 2 | import httpx 3 | from asgiref.sync import iscoroutinefunction, sync_to_async 4 | from django.contrib import messages 5 | from django.contrib.auth import logout 6 | from django.contrib.auth.backends import ModelBackend 7 | from django.utils.decorators import sync_and_async_middleware 8 | from loguru import logger 9 | 10 | try: 11 | from django.contrib.auth import alogout 12 | except ImportError: # Django < 5.0 13 | alogout = sync_to_async(logout) 14 | 15 | 16 | class MyBackend(ModelBackend): 17 | """Simple test for custom authentication backend""" 18 | 19 | 20 | def pre_login_callback(user, request): 21 | """Callback function called before user is logged in.""" 22 | messages.info(request, f"Running Pre-Login callback for user: {user}.") 23 | 24 | # Example 1: Add SuperUser status to user 25 | if not user.is_superuser or not user.is_staff: 26 | logger.info(f"Adding SuperUser status to email: {user.email}") 27 | user.is_superuser = True 28 | user.is_staff = True 29 | 30 | # Example 2: Use Google Info as the unique source of truth 31 | token = request.session.get("google_sso_access_token") 32 | if token: 33 | headers = { 34 | "Authorization": f"Bearer {token}", 35 | } 36 | url = "https://www.googleapis.com/oauth2/v3/userinfo" 37 | 38 | # Use response to update user info 39 | # Please add the custom scope in settings.GOOGLE_SSO_SCOPES 40 | # to access this info 41 | response = httpx.get(url, headers=headers, timeout=10) 42 | if response.status_code == 200: 43 | user_data = response.json() 44 | logger.debug(f"Updating User Data with Google Info: {user_data}") 45 | 46 | url = "https://people.googleapis.com/v1/people/me?personFields=birthdays" 47 | response = httpx.get(url, headers=headers, timeout=10) 48 | people_data = response.json() 49 | logger.debug(f"Updating User Data with Google People Info: {people_data}") 50 | 51 | user.first_name = user_data["given_name"] 52 | user.last_name = user_data["family_name"] 53 | 54 | user.save() 55 | 56 | 57 | def is_user_valid(token): 58 | headers = { 59 | "Authorization": f"Bearer {token}", 60 | } 61 | url = "https://www.googleapis.com/oauth2/v3/userinfo" 62 | response = httpx.get(url, headers=headers, timeout=10) 63 | 64 | # Add any check here 65 | 66 | return response.status_code == 200 67 | 68 | 69 | @sync_and_async_middleware 70 | def google_slo_middleware_example(get_response): 71 | 72 | if iscoroutinefunction(get_response): 73 | 74 | async def middleware(request): 75 | token = await sync_to_async(request.session.get)("google_sso_access_token") 76 | if token and not await sync_to_async(is_user_valid)(token): 77 | await alogout(request) 78 | response = await get_response(request) 79 | return response 80 | 81 | else: 82 | 83 | def middleware(request): 84 | token = request.session.get("google_sso_access_token") 85 | if token and not is_user_valid(token): 86 | logout(request) 87 | response = get_response(request) 88 | return response 89 | 90 | return middleware 91 | 92 | 93 | def pre_create_callback(google_info, request) -> dict: 94 | """Callback function called before user is created. 95 | 96 | return: dict content to be passed to User.objects.create() as `defaults` argument. 97 | If not informed, field `username` is always passed with user email as value. 98 | """ 99 | 100 | user_key = google_info.get("email").split("@")[0] 101 | user_id = google_info.get("id") 102 | 103 | return { 104 | "username": f"{user_key}_{user_id}", 105 | "date_joined": arrow.utcnow().shift(days=-1).datetime, 106 | } 107 | 108 | 109 | def pre_validate_callback(google_info, request) -> bool: 110 | """Callback function called before user is validated. 111 | 112 | Must return a boolean to indicate if user is valid to login. 113 | 114 | params: 115 | google_info: dict containing user info received from Google. 116 | request: HttpRequest object. 117 | """ 118 | messages.info( 119 | request, f"Running Pre-Validate callback for email: {google_info.get('email')}." 120 | ) 121 | return True 122 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Django Google SSO 3 |

4 |

5 | Easily integrate Google Authentication into your Django projects 6 |

7 | 8 |

9 | 10 | PyPI 11 | 12 | Build 13 | 14 | 15 | PyPI - Python Version 16 | 17 | 18 | PyPI - Django Version 19 | 20 | 21 | License 22 | 23 |

24 | 25 | ## Welcome to Django Google SSO 26 | 27 | This library simplifies the process of authenticating users with Google in Django projects. It adds a customizable "Login with Google" button to your Django Admin login page with minimal configuration. 28 | 29 | ### Why use Django Google SSO? 30 | 31 | - **Simplicity**: Adds Google authentication with minimal setup and no template modifications 32 | - **Admin Integration**: Seamlessly integrates with the Django Admin interface 33 | - **Customizable**: Works with popular Django Admin skins like Grappelli, Jazzmin, and more 34 | - **Modern**: Uses the latest Google authentication libraries 35 | - **Secure**: Follows OAuth 2.0 best practices for authentication 36 | 37 | --- 38 | 39 | ## Quick Start 40 | 41 | ### Installation 42 | 43 | ```shell 44 | $ pip install django-google-sso 45 | ``` 46 | 47 | > **Compatibility** 48 | > - Python 3.11, 3.12, 3.13 49 | > - Django 4.2, 5.0, 5.1, 5.2 50 | > 51 | > Older python/django versions are not supported. 52 | 53 | ### Configuration 54 | 55 | 1. Add to your `settings.py`: 56 | 57 | ```python 58 | # settings.py 59 | 60 | INSTALLED_APPS = [ 61 | # other django apps 62 | "django.contrib.messages", # Required for auth messages 63 | "django_google_sso", # Add django_google_sso 64 | ] 65 | 66 | # Google OAuth2 credentials 67 | GOOGLE_SSO_CLIENT_ID = "your client id here" 68 | GOOGLE_SSO_PROJECT_ID = "your project id here" 69 | GOOGLE_SSO_CLIENT_SECRET = "your client secret here" 70 | 71 | # Auto-create users from these domains 72 | GOOGLE_SSO_ALLOWABLE_DOMAINS = ["example.com"] 73 | ``` 74 | 75 | 2. Add the callback URL in [Google Console](https://console.cloud.google.com/apis/credentials) under "Authorized Redirect URIs": 76 | - For local development: `http://localhost:8000/google_sso/callback/` 77 | - For production: `https://your-domain.com/google_sso/callback/` 78 | 79 | 3. Add to your `urls.py`: 80 | 81 | ```python 82 | # urls.py 83 | 84 | from django.urls import include, path 85 | 86 | urlpatterns = [ 87 | # other urlpatterns... 88 | path( 89 | "google_sso/", include("django_google_sso.urls", namespace="django_google_sso") 90 | ), 91 | ] 92 | ``` 93 | 94 | 4. Run migrations: 95 | 96 | ```shell 97 | $ python manage.py migrate 98 | ``` 99 | 100 | That's it! Start Django and visit `http://localhost:8000/admin/login` to see the Google SSO button: 101 | 102 |

103 | 104 |

105 | 106 | ## Example project 107 | 108 | A minimal Django project using this library is included in this repository under `example_google_app/`. 109 | - Read the step-by-step instructions in example_google_app/README.md 110 | - Use it as a reference to configure your own project settings and URLs 111 | 112 | ## Documentation 113 | 114 | For detailed documentation, visit: 115 | - [Full Documentation](https://megalus.github.io/django-google-sso/) 116 | - [Quick Setup](https://megalus.github.io/django-google-sso/quick_setup/) 117 | - [Google Credentials Setup](https://megalus.github.io/django-google-sso/credentials/) 118 | - [User Management](https://megalus.github.io/django-google-sso/users/) 119 | - [Customization](https://megalus.github.io/django-google-sso/customize/) 120 | - [Troubleshooting](https://megalus.github.io/django-google-sso/troubleshooting/) 121 | 122 | ## License 123 | This project is licensed under the terms of the MIT license. 124 | -------------------------------------------------------------------------------- /docs/model.md: -------------------------------------------------------------------------------- 1 | # Getting Google info 2 | 3 | ## The User model 4 | 5 | **Django Google SSO** saves in the database the following information from Google, using current `User` model: 6 | 7 | * `email`: The email address of the user. 8 | * `first_name`: The first name of the user. 9 | * `last_name`: The last name of the user. 10 | * `username`: The email address of the user. 11 | * `password`: An unusable password, generated using `get_unusable_password()` from Django. 12 | 13 | Getting data on code is straightforward: 14 | 15 | ```python 16 | from django.contrib.auth.decorators import login_required 17 | from django.http import JsonResponse, HttpRequest 18 | 19 | @login_required 20 | def retrieve_user_data(request: HttpRequest) -> JsonResponse: 21 | user = request.user 22 | return JsonResponse({ 23 | "email": user.email, 24 | "first_name": user.first_name, 25 | "last_name": user.last_name, 26 | "username": user.username, 27 | }) 28 | ``` 29 | 30 | ## The GoogleSSOUser model 31 | 32 | Also, on the `GoogleSSOUser` model, it saves the following information: 33 | 34 | * `picture_url`: The URL of the user's profile picture. 35 | * `google_id`: The Google ID of the user. 36 | * `locale`: The preferred locale of the user. 37 | 38 | This is a one-to-one relationship with the `User` model, so you can access this data using the `googlessouser` reverse 39 | relation attribute: 40 | 41 | ```python 42 | from django.contrib.auth.decorators import login_required 43 | from django.http import JsonResponse, HttpRequest 44 | 45 | @login_required 46 | def retrieve_user_data(request: HttpRequest) -> JsonResponse: 47 | user = request.user 48 | return JsonResponse({ 49 | "email": user.email, 50 | "first_name": user.first_name, 51 | "last_name": user.last_name, 52 | "username": user.username, 53 | "picture": user.googlessouser.picture_url, 54 | "google_id": user.googlessouser.google_id, 55 | "locale": user.googlessouser.locale, 56 | }) 57 | ``` 58 | 59 | You can also import the model directly, like this: 60 | 61 | ```python 62 | from django_google_sso.models import GoogleSSOUser 63 | 64 | google_info = GoogleSSOUser.objects.get(user=user) 65 | ``` 66 | 67 | !!! tip "You can disable this model" 68 | If you don't want to save this basic data in the database, you can disable the `GoogleSSOUser` model by setting the 69 | `GOOGLE_SSO_SAVE_BASIC_GOOGLE_INFO` configuration to `False` in your `settings.py` file. 70 | 71 | ## About Google Scopes 72 | 73 | To retrieve this data **Django Google SSO** uses the following scopes for [Google OAuth 2.0](https://developers.google.com/identity/protocols/oauth2): 74 | 75 | ```python 76 | GOOGLE_SSO_SCOPES = [ # Google default scope 77 | "openid", 78 | "https://www.googleapis.com/auth/userinfo.email", 79 | "https://www.googleapis.com/auth/userinfo.profile", 80 | ] 81 | ``` 82 | 83 | You can change this scopes overriding the `GOOGLE_SSO_SCOPES` setting in your `settings.py` file. But if you ask the user 84 | to authorize more scopes, this plugin will not save this additional data in the database. You will need to implement 85 | your own logic to save this data, calling Google again. You can see a example [here](./advanced.md). 86 | 87 | !!! info "The main goal here is simplicity" 88 | The main goal of this plugin is to be simple to use as possible. But it is important to ask the user **_once_** for the scopes. 89 | That's why this plugin permits you to change the scopes, but will not save the additional data from it. 90 | 91 | ## The Access Token 92 | To make login possible, **Django Google SSO** needs to get an access token from Google. This token is used to retrieve 93 | User info to get or create the user in the database. If you need this access token, you can get it inside the User Request 94 | Session, like this: 95 | 96 | ```python 97 | from django.contrib.auth.decorators import login_required 98 | from django.http import JsonResponse, HttpRequest 99 | 100 | @login_required 101 | def retrieve_user_data(request: HttpRequest) -> JsonResponse: 102 | user = request.user 103 | return JsonResponse({ 104 | "email": user.email, 105 | "first_name": user.first_name, 106 | "last_name": user.last_name, 107 | "username": user.username, 108 | "picture": user.googlessouser.picture_url, 109 | "google_id": user.googlessouser.google_id, 110 | "locale": user.googlessouser.locale, 111 | "access_token": request.session["google_sso_access_token"], 112 | }) 113 | ``` 114 | 115 | Saving the Access Token in User Session is disabled, by default, to avoid security issues. If you need to enable it, 116 | you can set the configuration `GOOGLE_SSO_SAVE_ACCESS_TOKEN` to `True` in your `settings.py` file. Please make sure you 117 | understand how to [secure your cookies](https://docs.djangoproject.com/en/4.2/ref/settings/#session-cookie-secure) 118 | before enabling this option. 119 | -------------------------------------------------------------------------------- /django_google_sso/templatetags/sso_tags.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import re 3 | from typing import Callable 4 | 5 | from django import template 6 | from django.conf import settings 7 | from django.http import HttpRequest 8 | from django.templatetags.static import static 9 | from django.urls import reverse 10 | from loguru import logger 11 | 12 | from django_google_sso.helpers import is_admin_path, is_page_path 13 | 14 | register = template.Library() 15 | 16 | 17 | @register.simple_tag(takes_context=True) 18 | def define_sso_providers(context): 19 | provider_pattern = re.compile(r"^django_(.+)_sso$") 20 | providers = [] 21 | for app in settings.INSTALLED_APPS: 22 | match = re.search(provider_pattern, app) 23 | if match: 24 | providers.append(match.group(1)) 25 | 26 | sso_providers = [] 27 | request = context.get("request") 28 | 29 | # Because this tag can be called multiple times in a single request, 30 | # we cache the result in the request object. 31 | # This occurs when multiple django-*-sso providers are installed 32 | if request is not None and hasattr(request, "_sso_providers_cache"): 33 | return request._sso_providers_cache 34 | 35 | for provider in providers: 36 | package_name = f"django_{provider}_sso" 37 | try: 38 | package = importlib.import_module(package_name) 39 | conf = getattr(package, "conf") 40 | sso_enabled_conf = f"{provider.upper()}_SSO_ENABLED" 41 | sso_enabled: bool = getattr(conf, sso_enabled_conf) 42 | sso_pages_enabled_conf = f"{provider.upper()}_SSO_PAGES_ENABLED" 43 | sso_pages_enabled: bool | Callable[[HttpRequest], bool] | None = getattr( 44 | conf, sso_pages_enabled_conf, None 45 | ) 46 | sso_admin_enabled_conf = f"{provider.upper()}_SSO_ADMIN_ENABLED" 47 | sso_admin_enabled: bool | Callable[[HttpRequest], bool] | None = getattr( 48 | conf, sso_admin_enabled_conf, None 49 | ) 50 | 51 | provider_name = provider.title() 52 | if not sso_enabled: 53 | logger.debug( 54 | f"{provider_name} SSO is Disabled from config: {sso_enabled_conf}" 55 | ) 56 | continue 57 | 58 | can_add = True 59 | 60 | # Check for admin and pages only if they are defined (not None) 61 | if request and ( 62 | sso_admin_enabled is not None or sso_pages_enabled is not None 63 | ): 64 | # If callable, call it with the request 65 | if callable(sso_admin_enabled): 66 | sso_admin_enabled = sso_admin_enabled(request) 67 | # If is True, check if is admin path 68 | elif sso_admin_enabled is True: 69 | sso_admin_enabled = is_admin_path(request) 70 | else: 71 | sso_admin_enabled = False 72 | 73 | if callable(sso_pages_enabled): 74 | sso_pages_enabled = sso_pages_enabled(request) 75 | elif sso_pages_enabled is True: 76 | sso_pages_enabled = is_page_path(request) 77 | else: 78 | sso_pages_enabled = False 79 | 80 | if is_admin_path(request): 81 | can_add = sso_admin_enabled 82 | log_text = ( 83 | f"{provider_name} SSO is " 84 | f"{'Enabled' if can_add else 'Disabled'} " 85 | f"for ADMIN, from config: " 86 | f"{sso_admin_enabled_conf}=" 87 | f"{sso_admin_enabled} and path: {request.path}" 88 | ) 89 | else: 90 | can_add = sso_pages_enabled 91 | log_text = ( 92 | f"{provider_name} SSO is " 93 | f"{'Enabled' if can_add else 'Disabled'} for " 94 | f"PAGES, from config: " 95 | f"{sso_pages_enabled_conf}=" 96 | f"{sso_pages_enabled} and path: {request.path}" 97 | ) 98 | logger.debug(log_text) 99 | 100 | if can_add: 101 | logo_conf = f"{provider.upper()}_SSO_LOGO_URL" 102 | text_conf = f"{provider.upper()}_SSO_TEXT" 103 | logo_conf = getattr(conf, logo_conf) 104 | if callable(logo_conf): 105 | logo_conf = logo_conf(request) 106 | text_conf = getattr(conf, text_conf) 107 | if callable(text_conf): 108 | text_conf = text_conf(request) 109 | sso_providers.append( 110 | { 111 | "name": provider, 112 | "logo_url": logo_conf, 113 | "text": text_conf, 114 | "login_url": reverse( 115 | f"django_{provider}_sso:oauth_start_login" 116 | ), 117 | "css_url": static( 118 | f"django_{provider}_sso/{provider}_button.css" 119 | ), 120 | } 121 | ) 122 | except Exception as e: 123 | logger.error(f"Error importing {package_name}: {e}") 124 | 125 | if request is not None: 126 | setattr(request, "_sso_providers_cache", sso_providers) 127 | 128 | return sso_providers 129 | -------------------------------------------------------------------------------- /docs/troubleshooting.md: -------------------------------------------------------------------------------- 1 | # Troubleshooting Guide 2 | 3 | ### Common questions: 4 | 5 | ??? question "Admin Message: _**State Mismatched. Time expired?**_" 6 | This error occurs when the user is redirected to the Google login page and then returns to the Django login page but 7 | original state are not found or session was expired. Please check if the browser has the anonymous session created by Django. This error 8 | can occur if you use `127.0.0.1` instead of `localhost` for your local tests. 9 | 10 | ??? question "Google show the message: _**The Solicitation from App XXX is Invalid.**_" 11 | Make sure you have added the correct Callback URI on Google Console. Please remember the trailing slash for this URI. 12 | 13 | ??? question "My custom css is not working" 14 | Make sure you have added the correct static files path on your `settings.py` file. Please check the 15 | [Django documentation](https://docs.djangoproject.com/en/4.2/howto/static-files/) for more details. Make sure your 16 | path is `static/django_google_sso/google_button.css`. You can also need to setup the `STATICFILES_DIRS` setting in 17 | your project. Check the Example app below for more details. 18 | 19 | ??? question "How can I log out Django user if I log out from Google first?" 20 | If you log out from Google, the Django user will not be logged out automatically - his user session is valid up to 21 | 1 hour, or the time defined, in seconds, in `GOOGLE_SSO_SESSION_COOKIE_AGE`. You can use the `GOOGLE_SSO_SAVE_ACCESS_TOKEN` 22 | to save the access token generated during user login, and use it to check if the user status in Google (inside a 23 | Middleware, for example). Please check the [Example App](https://github.com/megalus/django-google-sso/tree/main/example_google_app) 24 | for more details. 25 | 26 | ??? question "My callback URL is http://example.com/google_sso/callback/ but my project is running at http://localhost:8000" 27 | This error occurs because your Project is using the Django Sites Framework and the current site is not configured correctly. 28 | Please make sure that the current site is configured for your needs or, alternatively, use the `GOOGLE_SSO_CALLBACK_DOMAIN` setting. 29 | 30 | ??? question "There's too much information on logs and messages from this app." 31 | You can disable the logs using the `GOOGLE_SSO_ENABLE_LOGS` setting and the messages using the `GOOGLE_SSO_ENABLE_MESSAGES` setting. 32 | 33 | ??? question "System goes looping to admin after login." 34 | This is because the user data was received from Google, but the user was not created in the database or is not active. 35 | To see these errors please check the logs or enable the option `GOOGLE_SSO_SHOW_FAILED_LOGIN_MESSAGE` to see failed 36 | login messages on browser. Please, make note these messages can be used on exploit attacks. 37 | 38 | ??? question "When I config a custom Authentication Backend using GOOGLE_SSO_AUTHENTICATION_BACKEND, the lib stops to login, without errors or logs." 39 | This is because the value of `GOOGLE_SSO_AUTHENTICATION_BACKEND` is not a valid authentication backend import path. 40 | Please check the value of this setting and make sure it is a valid import path to a Django authentication backend. 41 | 42 | ??? question "When using one package for Admin and another for Pages, the user can enter in Admin, even if I configure the Pages SSO to not give any admin rights" 43 | Please check if the user is not already a staff or superuser in the database, especially if you're using the 44 | `MICROSOFT_SSO_UNIQUE_EMAIL` and `GITHUB_SSO_UNIQUE_EMAIL` options. If the user is already a staff or superuser, 45 | he will be able to enter in Admin, even if the SSO package for Pages does not give him any admin rights. 46 | 47 | ??? question "Got a "KeyError: 'NAME'" error after set SSO_USE_ALTERNATE_W003" 48 | If you get a `KeyError: 'NAME'` error, please set a `NAME` in `TEMPLATES` at `settings.py`: 49 | 50 | ```python 51 | # settings.py 52 | 53 | TEMPLATES = [ 54 | { 55 | "BACKEND": "django.template.backends.django.DjangoTemplates", 56 | "NAME" : "default", # <-- Add name here 57 | "DIRS": [BASE_DIR / "templates"], 58 | "APP_DIRS": True, 59 | "OPTIONS": { 60 | "context_processors": [ 61 | "django.template.context_processors.debug", 62 | "django.template.context_processors.request", 63 | "django.contrib.auth.context_processors.auth", 64 | "django.contrib.messages.context_processors.messages", 65 | ], 66 | }, 67 | }, 68 | ] 69 | ``` 70 | 71 | ??? question "Got this error when migrating: 'The model User is already registered with 'core.GoogleSSOUserAdmin'" 72 | This is because you're already define a custom User model and admin in your project. You need to [extended the 73 | existing user model](https://docs.djangoproject.com/en/5.1/topics/auth/customizing/#extending-the-existing-user-model) 74 | unregistering your current User Admin class and add manually the GoogleSSOInlineAdmin in your custom class. 75 | You can use the `get_current_user_and_admin` helper as explained [here](admin.md) (the recommended action), or 76 | alternately, you can add the `django-google-sso` at the end of your `INSTALLED_APPS` list. 77 | 78 | 79 | ### Example App 80 | 81 | To test this library please check the `Example App` provided [here](https://github.com/megalus/django-google-sso/tree/main/example_google_app). 82 | 83 | ### Not working? 84 | 85 | Don't panic. Get a towel and, please, open an [issue](https://github.com/megalus/django-google-sso/issues). 86 | -------------------------------------------------------------------------------- /docs/users.md: -------------------------------------------------------------------------------- 1 | # Auto Creating Users 2 | 3 | **Django Google SSO** can automatically create users from Google SSO authentication. To enable this feature, you need to 4 | set the `GOOGLE_SSO_ALLOWABLE_DOMAINS` setting in your `settings.py`, with a list of domains that will be allowed to create. 5 | For example, if any user with a gmail account can sign in, you can set: 6 | 7 | ```python 8 | # settings.py 9 | GOOGLE_SSO_ALLOWABLE_DOMAINS = ["gmail.com"] 10 | ``` 11 | 12 | To allow everyone to register, you can use "*" as the value (but beware the security implications): 13 | 14 | ```python 15 | # Use "*" to add all users 16 | GOOGLE_SSO_ALLOWABLE_DOMAINS = ["*"] 17 | ``` 18 | 19 | ## Disabling the auto-create users 20 | 21 | You can disable the auto-create users feature by setting the `GOOGLE_SSO_AUTO_CREATE_USERS` setting to `False`: 22 | 23 | ```python 24 | GOOGLE_SSO_AUTO_CREATE_USERS = False 25 | ``` 26 | 27 | You can also disable the plugin completely: 28 | 29 | ```python 30 | GOOGLE_SSO_ENABLED = False 31 | ``` 32 | 33 | ## Giving Permissions to Auto-Created Users 34 | 35 | If you are using the auto-create users feature, you can give permissions to the users that are created automatically. To do 36 | this you can set the following options in your `settings.py`: 37 | 38 | ```python 39 | # List of emails that will be created as staff 40 | GOOGLE_SSO_STAFF_LIST = ["my-email@my-domain.com"] 41 | 42 | # List of emails that will be created as superuser 43 | GOOGLE_SSO_SUPERUSER_LIST = ["another-email@my-domain.com"] 44 | 45 | # If True, the first user that logs in will be created as superuser 46 | # if no superuser exists in the database at all 47 | GOOGLE_SSO_AUTO_CREATE_FIRST_SUPERUSER = True 48 | ``` 49 | 50 | For staff user creation _only_, you can add all users using "*" as the value: 51 | 52 | ```python 53 | # Use "*" to add all users as staff 54 | GOOGLE_SSO_STAFF_LIST = ["*"] 55 | ``` 56 | 57 | ## Fine-tuning validation before user validation 58 | 59 | If you need to do some custom validation _before_ user email is validated, you can set the 60 | `GOOGLE_SSO_PRE_VALIDATE_CALLBACK` setting to import a custom function that will be called before the user is created. 61 | This function will receive two arguments: the `google_user_info` dict from Google User API and `request` objects. 62 | 63 | ```python 64 | # myapp/hooks.py 65 | def pre_validate_user(google_info, request): 66 | # Check some info from google_info and/or request 67 | return True # The user can be created 68 | ``` 69 | 70 | Please note, even if this function returns `True`, the user can be denied if their email is not valid. 71 | 72 | ## Fine-tuning user info before user creation 73 | 74 | If you need to do some processing _before_ user is created, you can set the 75 | `GOOGLE_SSO_PRE_CREATE_CALLBACK` setting to import a custom function that will be called before the user is created. 76 | This function will receive two arguments: the `google_user_info` dict from Google User API and `request` objects. 77 | 78 | !!! tip "You can add custom fields to the user model here" 79 | 80 | The `pre_create_callback` function can return a dictionary with the fields and values that will be passed to 81 | `User.objects.create()` as the `defaults` argument. This means you can add custom fields to the user model here or 82 | change default values for some fields, like `username`. 83 | 84 | If not defined, the field `username` is always the user email. 85 | 86 | You can't change the fields: `first_name`, `last_name`, `email` and `password` using this callback. These fields are 87 | always passed to `User.objects.create()` with the values from Google API and the password is always unusable. 88 | 89 | 90 | ```python 91 | import arrow 92 | 93 | def pre_create_callback(google_info, request) -> dict | None: 94 | """Callback function called before user is created. 95 | 96 | return: dict content to be passed to 97 | User.objects.create() as `defaults` argument. 98 | If not informed, field `username` is always 99 | the user email. 100 | """ 101 | 102 | user_key = google_info.get("email").split("@")[0] 103 | user_id = google_info.get("id") 104 | 105 | return { 106 | "username": f"{user_key}_{user_id}", 107 | "date_joined": arrow.utcnow().shift(days=-1).datetime, 108 | } 109 | ``` 110 | 111 | ## Fine-tuning users before login 112 | 113 | If you need to do some processing _after_ user is created or retrieved, 114 | but _before_ the user is logged in, you can set the 115 | `GOOGLE_SSO_PRE_LOGIN_CALLBACK` setting to import a custom function that will be called before the user is logged in. 116 | This function will receive two arguments: the `user` and `request` objects. 117 | 118 | ```python 119 | # myapp/hooks.py 120 | def pre_login_user(user, request): 121 | # Do something with the user 122 | pass 123 | 124 | # settings.py 125 | GOOGLE_SSO_PRE_LOGIN_CALLBACK = "myapp.hooks.pre_login_user" 126 | ``` 127 | 128 | Please remember this function will be invoked only if user exists, and if it is active. 129 | In other words, if the user is eligible for login. 130 | 131 | !!! tip "You can add your hooks to customize all steps:" 132 | * `GOOGLE_SSO_PRE_VALIDATE_CALLBACK`: Run before the user is validated. 133 | * `GOOGLE_SSO_PRE_CREATE_CALLBACK`: Run before the user is created. 134 | * `GOOGLE_SSO_PRE_LOGIN_CALLBACK`: Run before the user is logged in. 135 | 136 | 137 | !!! warning "Be careful with these options" 138 | The idea here is to make your life easier, especially when testing. But if you are not careful, you can give 139 | permissions to users that you don't want, or even worse, you can give permissions to users that you don't know. 140 | So, please, be careful with these options. 141 | 142 | --- 143 | 144 | For the last step, we will look at the Django URLs. 145 | -------------------------------------------------------------------------------- /docs/how.md: -------------------------------------------------------------------------------- 1 | # How Django Google SSO works? 2 | 3 | ## Current Flow 4 | 5 | 1. First, the user is redirected to the Django login page. If settings `GOOGLE_SSO_ENABLED` is True, the 6 | "Login with Google" button will be added to a default form. 7 | 8 | 2. On click, **Django-Google-SSO** will add, in a anonymous request session, the `sso_next_url` and Google Flow `sso_state`. 9 | This data will expire in 10 minutes (defined in `GOOGLE_SSO_TIMEOUT`). Then user will be redirected to Google login page. 10 | 11 | !!! info "Using Request Anonymous session" 12 | If you make any actions which change or destroy this session, like restart django, clear cookies or change 13 | browsers, ou move between `localhost` and `127.0.0.1`, the login will fail, and you can see the message 14 | "State Mismatched. Time expired?" in the next time you log in again. Also remember the anonymous session 15 | lasts for 10 minutes, defined in`GOOGLE_SSO_TIMEOUT`. 16 | 17 | 3. On callback, **Django-Google-SSO** will check `code` and `state` received. If they are valid, 18 | Google's UserInfo will be retrieved. If the user is already registered in Django, the user 19 | will be logged in. 20 | 21 | 4. Otherwise, the user will be created and logged in, if his email domain, 22 | matches one of the `GOOGLE_SSO_ALLOWABLE_DOMAINS`. You can disable the auto-creation setting `GOOGLE_SSO_AUTO_CREATE_USERS` 23 | to False. 24 | 25 | 5. On creation only, this user can be set to the`staff` or `superuser` status, if his email are in `GOOGLE_SSO_STAFF_LIST` or 26 | `GOOGLE_SSO_SUPERUSER_LIST` respectively. Please note if you add an email to one of these lists, the email domain 27 | must be added to `GOOGLE_SSO_ALLOWABLE_DOMAINS`too. 28 | 29 | 6. This authenticated session will expire in 1 hour, or the time defined, in seconds, in `GOOGLE_SSO_SESSION_COOKIE_AGE`. 30 | 31 | 7. If login fails, you will be redirected to route defined in `GOOGLE_SSO_LOGIN_FAILED_URL` (default: `admin:index`) 32 | which will use Django Messaging system to show the error message. 33 | 34 | 8. If login succeeds, the user will be redirected to the `next_path` saved in the anonymous session, or to the route 35 | defined in `GOOGLE_SSO_NEXT_URL` (default: `admin:index`) as a fallback. 36 | 37 | ## The `define_sso_providers` template tag 38 | **Django-Google-SSO** uses this tag to define which buttons to show on the login page. This is because the same tag is 39 | used in other libraries, like [django-microsoft-sso](https://github.com/megalus/django-microsoft-sso) and 40 | [django-github-sso](https://github.com/megalus/django-github-sso). This tag checks the `*_SSO_ENABLED`, `*_SSO_ADMIN_ENABLED` 41 | and `*_SSO_PAGES_ENABLED` settings to return a list of enabled SSO providers for the current request. 42 | 43 | if you need to customize this, you can pass in the request context the `sso_providers` variable with a list of providers to show, like this: 44 | 45 | ```python 46 | # views.py 47 | from django.shortcuts import render 48 | 49 | def my_view(request): 50 | ... 51 | sso_providers = [ 52 | { 53 | "name": "Google", 54 | "logo_url": "...", # URL for the button logo 55 | "text": "...", # Text for the button 56 | "login_url": "...", # URL to redirect to start the login flow 57 | "css_url": "...", # URL for the button CSS 58 | } 59 | ] 60 | return render(request, "my_login_template.html", {"sso_providers": sso_providers}) 61 | ``` 62 | 63 | Also, if you're using async views, you can run the original template tags, like this: 64 | 65 | ```python 66 | # views.py 67 | from django.shortcuts import render 68 | from django_google_sso.utils import adefine_sso_providers, adefine_show_form 69 | 70 | async def my_async_view(request): 71 | ... 72 | context = { 73 | "show_admin_form": await adefine_show_form(request), 74 | "sso_providers": await adefine_sso_providers(request) 75 | } 76 | return render(request, "my_login_template.html", context) 77 | ``` 78 | 79 | !!! tip "The same is valid for define_show_form tag" 80 | You can pass in the request context the `show_admin_form` variable with a boolean value to show or hide 81 | the default login form. 82 | 83 | ## About the Google consent screen and the authorization prompt 84 | 85 | The setting `GOOGLE_SSO_AUTHORIZATION_PROMPT` controls the `prompt` parameter sent to Google's OpenID Connect authorization URL. It changes what Google shows to the user during authentication/consent: 86 | 87 | - `"consent"` (default): Always shows the consent screen, even if the user previously granted access to the requested scopes. 88 | - `"select_account"`: Always shows the account chooser so the user can switch Google accounts before continuing. 89 | - `"none"`: Never shows any screen. If the user is not already signed in to Google or has not granted consent yet, Google will return an error instead of showing screens. 90 | - `None` (or `""`): Only show the relevant screens when they are needed. If the user is only logged in to one google account and that account has already consented, both the account and consent screens are bypassed. If consent hasn't been given, or the user is signed in to multiple google accounts, the relevant screens are shown. This is the default google prompt behavior. 91 | 92 | Notes when testing locally: 93 | - If you have already granted consent to the default scopes (`openid`, `userinfo.email`, `userinfo.profile`) for your app, Google may only show the account selection step. This can make it seem like the experience is always the same. 94 | - To see the full consent screen again with `consent`, you can revoke the app permissions from your Google Account (Google Account -> Security -> Third-party access), or change the Scopes to include a new permission. 95 | - Using `select_account` typically results in the “Choose an account” screen, which matches what you are observing locally. 96 | 97 | Example configuration in your Django settings: 98 | 99 | ```python 100 | # Valid values: "none", "consent", "select_account" and None 101 | GOOGLE_SSO_AUTHORIZATION_PROMPT = None # default is "consent" 102 | ``` 103 | 104 | For more details about `prompt`, see Google's documentation: https://developers.google.com/identity/openid-connect/openid-connect#prompt 105 | -------------------------------------------------------------------------------- /django_google_sso/tests/test_views.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.contrib.auth.models import User 3 | from django.contrib.messages import get_messages 4 | from django.urls import reverse 5 | 6 | from django_google_sso import helpers 7 | from django_google_sso.main import GoogleAuth 8 | from django_google_sso.tests.conftest import SECRET_PATH 9 | 10 | ROUTE_NAME = "django_google_sso:oauth_callback" 11 | 12 | 13 | pytestmark = pytest.mark.django_db(transaction=True) 14 | 15 | 16 | def test_start_login(client, mocker): 17 | # Arrange 18 | flow_mock = mocker.patch.object(GoogleAuth, "flow") 19 | flow_mock.authorization_url.return_value = ("https://foo/bar", "foo") 20 | 21 | # Act 22 | url = reverse("django_google_sso:oauth_start_login") + "?next=/secret/" 23 | response = client.get(url) 24 | 25 | # Assert 26 | assert response.status_code == 302 27 | assert client.session["sso_next_url"] == SECRET_PATH 28 | assert client.session["sso_state"] == "foo" 29 | 30 | 31 | def test_start_login_none_next_param(client, mocker): 32 | # Arrange 33 | flow_mock = mocker.patch.object(GoogleAuth, "flow") 34 | flow_mock.authorization_url.return_value = ("https://foo/bar", "foo") 35 | helper_mock = mocker.patch.object(helpers, "is_admin_path") 36 | helper_mock.return_value = {"next_url": "secret"} 37 | 38 | # Act 39 | url = reverse("django_google_sso:oauth_start_login") 40 | response = client.get(url) 41 | 42 | # Assert 43 | assert response.status_code == 302 44 | assert client.session["sso_next_url"] == reverse("secret") 45 | assert client.session["sso_state"] == "foo" 46 | 47 | 48 | @pytest.mark.parametrize( 49 | "test_parameter", 50 | [ 51 | "bad-domain.com/secret/", 52 | "www.bad-domain.com/secret/", 53 | "//bad-domain.com/secret/", 54 | "http://bad-domain.com/secret/", 55 | "https://malicious.example.com/secret/", 56 | ], 57 | ) 58 | def test_exploit_redirect(client, mocker, test_parameter): 59 | # Arrange 60 | flow_mock = mocker.patch.object(GoogleAuth, "flow") 61 | flow_mock.authorization_url.return_value = ("https://foo/bar", "foo") 62 | 63 | # Act 64 | url = reverse("django_google_sso:oauth_start_login") + f"?next={test_parameter}" 65 | response = client.get(url) 66 | 67 | # Assert 68 | assert response.status_code == 302 69 | assert client.session["sso_next_url"] == SECRET_PATH 70 | assert client.session["sso_state"] == "foo" 71 | 72 | 73 | def test_google_sso_disabled(settings, client): 74 | # Arrange 75 | settings.GOOGLE_SSO_ENABLED = False 76 | 77 | # Act 78 | response = client.get(reverse(ROUTE_NAME)) 79 | 80 | # Assert 81 | assert response.status_code == 302 82 | assert User.objects.count() == 0 83 | assert "Google SSO not enabled." in [ 84 | m.message for m in get_messages(response.wsgi_request) 85 | ] 86 | 87 | 88 | def test_missing_code(client): 89 | # Act 90 | response = client.get(reverse(ROUTE_NAME)) 91 | 92 | # Assert 93 | assert response.status_code == 302 94 | assert User.objects.count() == 0 95 | assert "Authorization Code not received from SSO." in [ 96 | m.message for m in get_messages(response.wsgi_request) 97 | ] 98 | 99 | 100 | @pytest.mark.parametrize("querystring", ["?code=1234", "?code=1234&state=bad_dog"]) 101 | def test_bad_state(client, querystring): 102 | # Arrange 103 | session = client.session 104 | session.update({"sso_state": "good_dog"}) 105 | session.save() 106 | 107 | # Act 108 | url = reverse(ROUTE_NAME) + querystring 109 | response = client.get(url) 110 | 111 | # Assert 112 | assert response.status_code == 302 113 | assert User.objects.count() == 0 114 | assert "State Mismatch. Time expired?" in [ 115 | m.message for m in get_messages(response.wsgi_request) 116 | ] 117 | 118 | 119 | def test_invalid_email(client_with_session, settings, callback_url): 120 | # Arrange 121 | settings.GOOGLE_SSO_ALLOWABLE_DOMAINS = ["foobar.com"] 122 | 123 | # Act 124 | response = client_with_session.get(callback_url) 125 | 126 | # Assert 127 | assert response.status_code == 302 128 | assert User.objects.count() == 0 129 | assert ( 130 | "Email address not allowed: foo@example.com. Please contact your administrator." 131 | in [m.message for m in get_messages(response.wsgi_request)] 132 | ) 133 | 134 | 135 | def test_inactive_user(client_with_session, callback_url, google_response): 136 | # Arrange 137 | User.objects.create( 138 | username=google_response["email"], 139 | email=google_response["email"], 140 | is_active=False, 141 | ) 142 | 143 | # Act 144 | response = client_with_session.get(callback_url) 145 | 146 | # Assert 147 | assert response.status_code == 302 148 | assert User.objects.count() == 1 149 | assert User.objects.get(email=google_response["email"]).is_active is False 150 | 151 | 152 | def test_new_user_login(client_with_session, callback_url): 153 | # Arrange 154 | 155 | # Act 156 | response = client_with_session.get(callback_url) 157 | 158 | # Assert 159 | assert response.status_code == 302 160 | assert User.objects.count() == 1 161 | assert response.url == SECRET_PATH 162 | assert response.wsgi_request.user.is_authenticated is True 163 | 164 | 165 | def test_existing_user_login( 166 | client_with_session, settings, google_response, callback_url 167 | ): 168 | # Arrange 169 | existing_user = User.objects.create( 170 | username=google_response["email"], 171 | email=google_response["email"], 172 | is_active=True, 173 | ) 174 | 175 | settings.GOOGLE_SSO_AUTO_CREATE_USERS = False 176 | 177 | # Act 178 | response = client_with_session.get(callback_url) 179 | 180 | # Assert 181 | assert response.status_code == 302 182 | assert User.objects.count() == 1 183 | assert response.url == SECRET_PATH 184 | assert response.wsgi_request.user.is_authenticated is True 185 | assert response.wsgi_request.user.email == existing_user.email 186 | 187 | 188 | def test_missing_user_login( 189 | client_with_session, settings, google_response, callback_url 190 | ): 191 | # Arrange 192 | settings.GOOGLE_SSO_AUTO_CREATE_USERS = False 193 | 194 | # Act 195 | response = client_with_session.get(callback_url) 196 | 197 | # Assert 198 | assert response.status_code == 302 199 | assert User.objects.count() == 0 200 | assert response.url == "/" 201 | assert response.wsgi_request.user.is_authenticated is False 202 | -------------------------------------------------------------------------------- /django_google_sso/tests/test_user_helper.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | from copy import deepcopy 3 | 4 | import pytest 5 | from django.contrib.auth.models import User 6 | 7 | from django_google_sso import conf 8 | from django_google_sso.main import UserHelper 9 | 10 | pytestmark = pytest.mark.django_db 11 | 12 | 13 | def test_user_email(google_response, callback_request): 14 | # Act 15 | helper = UserHelper(google_response, callback_request) 16 | 17 | # Assert 18 | assert helper.user_info_email == "foo@example.com" 19 | 20 | 21 | @pytest.mark.parametrize( 22 | "allowable_domains, expected_result", [(["example.com"], True), ([], False)] 23 | ) 24 | def test_email_is_valid( 25 | google_response, callback_request, allowable_domains, expected_result, settings 26 | ): 27 | # Arrange 28 | settings.GOOGLE_SSO_ALLOWABLE_DOMAINS = allowable_domains 29 | importlib.reload(conf) 30 | 31 | # Act 32 | helper = UserHelper(google_response, callback_request) 33 | 34 | # Assert 35 | assert helper.email_is_valid == expected_result 36 | 37 | 38 | @pytest.mark.parametrize("auto_create_super_user", [True, False]) 39 | def test_get_or_create_user( 40 | auto_create_super_user, google_response, callback_request, settings 41 | ): 42 | # Arrange 43 | settings.GOOGLE_SSO_AUTO_CREATE_FIRST_SUPERUSER = auto_create_super_user 44 | importlib.reload(conf) 45 | 46 | # Act 47 | helper = UserHelper(google_response, callback_request) 48 | user = helper.get_or_create_user() 49 | 50 | # Assert 51 | assert user.first_name == google_response["given_name"] 52 | assert user.last_name == google_response["family_name"] 53 | assert user.username == google_response["email"] 54 | assert user.email == google_response["email"] 55 | assert user.is_active is True 56 | assert user.is_staff == auto_create_super_user 57 | assert user.is_superuser == auto_create_super_user 58 | 59 | 60 | @pytest.mark.parametrize( 61 | "always_update_user_data, expected_is_equal", [(True, True), (False, False)] 62 | ) 63 | def test_update_existing_user_record( 64 | always_update_user_data, 65 | google_response, 66 | google_response_update, 67 | callback_request, 68 | expected_is_equal, 69 | settings, 70 | ): 71 | # Arrange 72 | settings.GOOGLE_SSO_ALWAYS_UPDATE_USER_DATA = always_update_user_data 73 | importlib.reload(conf) 74 | helper = UserHelper(google_response, callback_request) 75 | helper.get_or_create_user() 76 | 77 | # Act 78 | helper = UserHelper(google_response_update, callback_request) 79 | user = helper.get_or_create_user() 80 | 81 | # Assert 82 | assert ( 83 | user.first_name == google_response_update["given_name"] 84 | ) == expected_is_equal 85 | assert ( 86 | user.last_name == google_response_update["family_name"] 87 | ) == expected_is_equal 88 | assert user.username == google_response_update["email"] 89 | assert user.email == google_response_update["email"] 90 | 91 | 92 | def test_add_all_users_to_staff_list( 93 | faker, google_response, callback_request, settings 94 | ): 95 | # Arrange 96 | settings.GOOGLE_SSO_STAFF_LIST = ["*"] 97 | settings.GOOGLE_SSO_AUTO_CREATE_FIRST_SUPERUSER = False 98 | importlib.reload(conf) 99 | 100 | emails = [ 101 | faker.email(), 102 | faker.email(), 103 | faker.email(), 104 | ] 105 | 106 | # Act 107 | for email in emails: 108 | response = deepcopy(google_response) 109 | response["email"] = email 110 | helper = UserHelper(response, callback_request) 111 | helper.get_or_create_user() 112 | helper.find_user() 113 | 114 | # Assert 115 | assert User.objects.filter(is_staff=True).count() == 3 116 | 117 | 118 | def test_create_staff_from_list(google_response, callback_request, settings): 119 | # Arrange 120 | settings.GOOGLE_SSO_AUTO_CREATE_FIRST_SUPERUSER = False 121 | settings.GOOGLE_SSO_STAFF_LIST = [google_response["email"]] 122 | importlib.reload(conf) 123 | 124 | # Act 125 | helper = UserHelper(google_response, callback_request) 126 | user = helper.get_or_create_user() 127 | 128 | # Assert 129 | assert user.is_active is True 130 | assert user.is_staff is True 131 | assert user.is_superuser is False 132 | 133 | 134 | def test_create_super_user_from_list(google_response, callback_request, settings): 135 | # Arrange 136 | settings.GOOGLE_SSO_AUTO_CREATE_FIRST_SUPERUSER = False 137 | settings.GOOGLE_SSO_SUPERUSER_LIST = [google_response["email"]] 138 | importlib.reload(conf) 139 | 140 | # Act 141 | helper = UserHelper(google_response, callback_request) 142 | user = helper.get_or_create_user() 143 | 144 | # Assert 145 | assert user.is_active is True 146 | assert user.is_staff is True 147 | assert user.is_superuser is True 148 | 149 | 150 | def test_different_null_values(google_response, callback_request, monkeypatch): 151 | # Arrange 152 | monkeypatch.setattr(conf, "GOOGLE_SSO_DEFAULT_LOCALE", "pt_BR") 153 | google_response_no_key = deepcopy(google_response) 154 | del google_response_no_key["locale"] 155 | google_response_key_none = deepcopy(google_response) 156 | google_response_key_none["locale"] = None 157 | 158 | # Act 159 | no_key_helper = UserHelper(google_response_no_key, callback_request) 160 | no_key_helper.get_or_create_user() 161 | user_one = no_key_helper.find_user() 162 | 163 | none_key_helper = UserHelper(google_response_key_none, callback_request) 164 | none_key_helper.get_or_create_user() 165 | user_two = none_key_helper.find_user() 166 | 167 | # Assert 168 | assert user_one.googlessouser.locale == "pt_BR" 169 | assert user_two.googlessouser.locale == "pt_BR" 170 | 171 | 172 | def test_duplicated_emails(google_response, callback_request): 173 | # Arrange 174 | User.objects.create( 175 | email=google_response["email"].upper(), 176 | username=google_response["email"].upper(), 177 | first_name=google_response["given_name"], 178 | last_name=google_response["family_name"], 179 | ) 180 | 181 | lowercase_email_response = deepcopy(google_response) 182 | lowercase_email_response["email"] = lowercase_email_response["email"].lower() 183 | uppercase_email_response = deepcopy(google_response) 184 | uppercase_email_response["email"] = uppercase_email_response["email"].upper() 185 | 186 | # Act 187 | user_one_helper = UserHelper(uppercase_email_response, callback_request) 188 | user_one_helper.get_or_create_user() 189 | user_one = user_one_helper.find_user() 190 | 191 | user_two_helper = UserHelper(lowercase_email_response, callback_request) 192 | user_two_helper.get_or_create_user() 193 | user_two = user_two_helper.find_user() 194 | 195 | # Assert 196 | assert user_one.id == user_two.id 197 | assert user_one.email == user_two.email 198 | assert User.objects.count() == 1 199 | -------------------------------------------------------------------------------- /django_google_sso/views.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | from urllib.parse import urlparse 3 | 4 | from django.contrib.auth import login 5 | from django.http import HttpRequest, HttpResponseRedirect 6 | from django.urls import reverse 7 | from django.utils.translation import gettext_lazy as _ 8 | from django.views.decorators.http import require_http_methods 9 | from loguru import logger 10 | 11 | from django_google_sso.main import GoogleAuth, UserHelper 12 | from django_google_sso.utils import send_message, show_credential 13 | 14 | 15 | @require_http_methods(["GET"]) 16 | def start_login(request: HttpRequest) -> HttpResponseRedirect: 17 | google = GoogleAuth(request) 18 | 19 | # Get the next url 20 | next_param = request.GET.get(key="next") 21 | if next_param: 22 | clean_param = ( 23 | next_param 24 | if next_param.startswith("http") or next_param.startswith("/") 25 | else f"//{next_param}" 26 | ) 27 | else: 28 | next_url = google.get_sso_value("next_url") 29 | clean_param = reverse(next_url) 30 | next_path = urlparse(clean_param).path 31 | 32 | # Get Google Auth URL 33 | prompt = google.get_sso_value("authorization_prompt") 34 | auth_url, state = google.flow.authorization_url(prompt=prompt) 35 | 36 | # Save data on Session 37 | timeout = google.get_sso_value("timeout") 38 | if not request.session.session_key: 39 | request.session.create() 40 | request.session.set_expiry(timeout * 60) 41 | request.session["sso_state"] = state 42 | request.session["sso_next_url"] = next_path 43 | request.session.save() 44 | 45 | # Redirect User 46 | return HttpResponseRedirect(auth_url) 47 | 48 | 49 | @require_http_methods(["GET"]) 50 | def callback(request: HttpRequest) -> HttpResponseRedirect: 51 | google = GoogleAuth(request) 52 | login_failed_url = reverse(google.get_sso_value("login_failed_url")) 53 | code = request.GET.get("code") 54 | state = request.GET.get("state") 55 | 56 | next_url_from_session = request.session.get("sso_next_url") 57 | next_url_from_conf = reverse(google.get_sso_value("next_url")) 58 | next_url = next_url_from_session if next_url_from_session else next_url_from_conf 59 | 60 | # Check if Google SSO is enabled 61 | enabled, message = google.check_enabled(next_url) 62 | if not enabled: 63 | send_message(request, _(message)) 64 | return HttpResponseRedirect(login_failed_url) 65 | 66 | # First, check for authorization code 67 | if not code: 68 | send_message(request, _("Authorization Code not received from SSO.")) 69 | return HttpResponseRedirect(login_failed_url) 70 | 71 | # Then, check state. 72 | request_state = request.session.get("sso_state") 73 | 74 | if not request_state or state != request_state: 75 | send_message(request, _("State Mismatch. Time expired?")) 76 | return HttpResponseRedirect(login_failed_url) 77 | 78 | # Get Access Token from Google 79 | try: 80 | google.flow.fetch_token(code=code) 81 | except Exception as error: 82 | send_message(request, _(f"Error while fetching token from SSO: {error}.")) 83 | logger.debug( 84 | f"GOOGLE_SSO_CLIENT_ID: " 85 | f"{show_credential(google.get_sso_value('client_id'))}" 86 | ) 87 | logger.debug( 88 | f"GOOGLE_SSO_PROJECT_ID: " 89 | f"{show_credential(google.get_sso_value('project_id'))}" 90 | ) 91 | logger.debug( 92 | f"GOOGLE_SSO_CLIENT_SECRET: " 93 | f"{show_credential(google.get_sso_value('client_secret'))}" 94 | ) 95 | return HttpResponseRedirect(login_failed_url) 96 | 97 | # Get User Info from Google 98 | google_user_data = google.get_user_info() 99 | user_helper = UserHelper(google_user_data, request) 100 | 101 | # Run Pre-Validate Callback 102 | pre_validate_callback = google.get_sso_value("pre_validate_callback") 103 | module_path = ".".join(pre_validate_callback.split(".")[:-1]) 104 | pre_validate_fn = pre_validate_callback.split(".")[-1] 105 | module = importlib.import_module(module_path) 106 | user_is_valid = getattr(module, pre_validate_fn)(google_user_data, request) 107 | 108 | # Check if User Info is valid to login 109 | if not user_helper.email_is_valid or not user_is_valid: 110 | send_message( 111 | request, 112 | _( 113 | f"Email address not allowed: {user_helper.user_info_email}. " 114 | f"Please contact your administrator." 115 | ), 116 | ) 117 | return HttpResponseRedirect(login_failed_url) 118 | 119 | # Save Token in Session 120 | save_access_token = google.get_sso_value("save_access_token") 121 | if save_access_token: 122 | access_token = google.get_user_token() 123 | request.session["google_sso_access_token"] = access_token 124 | 125 | # Run Pre-Create Callback 126 | pre_create_callback = google.get_sso_value("pre_create_callback") 127 | module_path = ".".join(pre_create_callback.split(".")[:-1]) 128 | pre_login_fn = pre_create_callback.split(".")[-1] 129 | module = importlib.import_module(module_path) 130 | extra_users_args = getattr(module, pre_login_fn)(google_user_data, request) 131 | 132 | # Get or Create User 133 | auto_create_users = google.get_sso_value("auto_create_users") 134 | if auto_create_users: 135 | user = user_helper.get_or_create_user(extra_users_args) 136 | else: 137 | user = user_helper.find_user() 138 | 139 | if not user or not user.is_active: 140 | failed_login_message = f"User not found - Email: '{google_user_data['email']}'" 141 | if not user and not auto_create_users: 142 | failed_login_message += ". Auto-Create is disabled." 143 | 144 | if user and not user.is_active: 145 | failed_login_message = f"User is not active: '{google_user_data['email']}'" 146 | 147 | show_failed_login_message = google.get_sso_value("show_failed_login_message") 148 | if show_failed_login_message: 149 | send_message(request, _(failed_login_message), level="warning") 150 | else: 151 | logger.warning(failed_login_message) 152 | 153 | return HttpResponseRedirect(login_failed_url) 154 | 155 | request.session.save() 156 | 157 | # Run Pre-Login Callback 158 | pre_login_callback = google.get_sso_value("pre_login_callback") 159 | module_path = ".".join(pre_login_callback.split(".")[:-1]) 160 | pre_login_fn = pre_login_callback.split(".")[-1] 161 | module = importlib.import_module(module_path) 162 | getattr(module, pre_login_fn)(user, request) 163 | 164 | # Get Authentication Backend 165 | # If exists, let's make a sanity check on it 166 | # Because Django does not raise errors if backend is wrong 167 | authentication_backend = google.get_sso_value("authentication_backend") 168 | if authentication_backend: 169 | module_path = ".".join(authentication_backend.split(".")[:-1]) 170 | backend_auth_class = authentication_backend.split(".")[-1] 171 | try: 172 | module = importlib.import_module(module_path) 173 | getattr(module, backend_auth_class) 174 | except (ImportError, AttributeError) as error: 175 | raise ImportError( 176 | f"Authentication Backend invalid: {authentication_backend}" 177 | ) from error 178 | 179 | # Login User 180 | cookie_age = google.get_sso_value("session_cookie_age") 181 | login(request, user, authentication_backend) 182 | request.session.set_expiry(cookie_age) 183 | 184 | return HttpResponseRedirect(next_url) 185 | -------------------------------------------------------------------------------- /docs/multiple.md: -------------------------------------------------------------------------------- 1 | # Using Multiple Social Logins 2 | 3 | A special advanced case is when you need to log in from multiple social providers. In this case, each provider will have its own 4 | package which you need to install and configure. Currently, we support: 5 | 6 | * [Django Google SSO](https://github.com/megalus/django-google-sso) 7 | * [Django Microsoft SSO](https://github.com/megalus/django-microsoft-sso) 8 | * [Django GitHub SSO](https://github.com/megalus/django-github-sso) 9 | 10 | ## Install the Packages 11 | Install the packages you need: 12 | 13 | ```bash 14 | pip install django-google-sso django-microsoft-sso django-github-sso 15 | 16 | # Optionally install Stela to handle .env files 17 | pip install stela 18 | ``` 19 | 20 | ## Add Package to Django Project 21 | To add this package in your Django Project, please modify the `INSTALLED_APPS` in your `settings.py`: 22 | 23 | ```python 24 | # settings.py 25 | 26 | INSTALLED_APPS = [ 27 | # other django apps 28 | "django.contrib.messages", # Need for Auth messages 29 | "django_github_sso", # Will show as first button in login page 30 | "django_google_sso", 31 | "django_microsoft_sso", 32 | ] 33 | ``` 34 | 35 | !!! tip "Order matters" 36 | The first package on list will be the first button in the login page. 37 | 38 | ## Add secrets to env file 39 | 40 | ```bash 41 | # .env.local 42 | GOOGLE_SSO_CLIENT_ID=999999999999-xxxxxxxxx.apps.googleusercontent.com 43 | GOOGLE_SSO_CLIENT_SECRET=xxxxxx 44 | GOOGLE_SSO_PROJECT_ID=999999999999 45 | 46 | MICROSOFT_SSO_APPLICATION_ID=FOO 47 | MICROSOFT_SSO_CLIENT_SECRET=BAZ 48 | 49 | GITHUB_SSO_CLIENT_ID=BAR 50 | GITHUB_SSO_CLIENT_SECRET=FOOBAR 51 | ``` 52 | 53 | ### Setup Django URLs 54 | Add the URLs of each provider to your `urls.py` file: 55 | 56 | ```python 57 | from django.urls import include, path 58 | 59 | 60 | urlpatterns += [ 61 | path( 62 | "github_sso/", 63 | include("django_google_sso.urls", namespace="django_github_sso"), 64 | ), 65 | path( 66 | "google_sso/", 67 | include("django_github_sso.urls", namespace="django_google_sso"), 68 | ), 69 | path( 70 | "microsoft_sso/", 71 | include("django_microsoft_sso.urls", namespace="django_microsoft_sso"), 72 | ), 73 | ] 74 | ``` 75 | 76 | ### Setup Django Settings 77 | Add the settings of each provider to your `settings.py` file: 78 | 79 | ```python 80 | # settings.py 81 | from stela import env 82 | 83 | # Django Microsoft SSO 84 | MICROSOFT_SSO_ENABLED = True 85 | MICROSOFT_SSO_APPLICATION_ID = env.MICROSOFT_SSO_APPLICATION_ID 86 | MICROSOFT_SSO_CLIENT_SECRET = env.MICROSOFT_SSO_CLIENT_SECRET 87 | MICROSOFT_SSO_ALLOWABLE_DOMAINS = ["contoso.com"] 88 | 89 | # Django Google SSO 90 | GOOGLE_SSO_ENABLED = True 91 | GOOGLE_SSO_CLIENT_ID = env.GOOGLE_SSO_CLIENT_ID 92 | GOOGLE_SSO_PROJECT_ID = env.GOOGLE_SSO_PROJECT_ID 93 | GOOGLE_SSO_CLIENT_SECRET = env.GOOGLE_SSO_CLIENT_SECRET 94 | GOOGLE_SSO_ALLOWABLE_DOMAINS = ["contoso.net"] 95 | 96 | # Django GitHub SSO 97 | GITHUB_SSO_ENABLED = True 98 | GITHUB_SSO_CLIENT_ID = env.GITHUB_SSO_CLIENT_ID 99 | GITHUB_SSO_CLIENT_SECRET = env.GITHUB_SSO_CLIENT_SECRET 100 | GITHUB_SSO_ALLOWABLE_ORGANIZATIONS = ["contoso"] 101 | ``` 102 | 103 | The login page will look like this: 104 | 105 | ![Django Login Page with Google and Microsoft SSO](images/django_multiple_sso.png) 106 | 107 | !!! tip "You can hide the login form" 108 | If you want to show only the SSO buttons, you can hide the login form using the `SSO_SHOW_FORM_ON_ADMIN_PAGE` setting. 109 | 110 | ```python 111 | # settings.py 112 | 113 | SSO_SHOW_FORM_ON_ADMIN_PAGE = False 114 | ``` 115 | 116 | ## Avoiding duplicated Users 117 | Both **Django GitHub SSO** and **Django Microsoft SSO** can create users without an email address, comparing the User `username` 118 | field against the _Azure User Principal Name_ or _Github User Name_. This can cause duplicated users if you are using either package. 119 | 120 | To avoid this, you can set the `MICROSOFT_SSO_UNIQUE_EMAIL` and `GITHUB_SSO_UNIQUE_EMAIL` settings to `True`, 121 | making these packages compare User `email` against _Azure Mail_ field or _Github Primary Email_. Make sure your Azure Tenant 122 | and GitHub Organization users have registered emails. 123 | 124 | ## The Django E003/W003 Warning 125 | If you are using multiple **Django SSO** projects, you will get a warning like this: 126 | 127 | ``` 128 | WARNINGS: 129 | ?: (templates.E003) 'show_form' is used for multiple template tag modules: 'django_google_sso.templatetags.show_form', 'django_microsoft_sso.templatetags.show_form' 130 | ?: (templates.E003) 'sso_tags' is used for multiple template tag modules: 'django_google_sso.templatetags.sso_tags', 'django_microsoft_sso.templatetags.sso_tags' 131 | ``` 132 | 133 | This is because both packages use the same template tags. To silence this warning, you can set the `SILENCED_SYSTEM_CHECKS` as per Django documentation: 134 | 135 | ```python 136 | # settings.py 137 | SILENCED_SYSTEM_CHECKS = ["templates.W003"] # Or "templates.E003" for Django <=5.0 138 | ``` 139 | 140 | But if you need to check the templates, you can use the `SSO_USE_ALTERNATE_W003` setting to use an alternate template tag. This alternate check will 141 | run the original check, but will not raise the warning for the Django SSO packages. To use this alternate check, you need to set both the Django Silence Check and `SSO_USE_ALTERNATE_W003`: 142 | 143 | ```python 144 | # settings.py 145 | 146 | SILENCED_SYSTEM_CHECKS = ["templates.W003"] # Will silence the original check 147 | SSO_USE_ALTERNATE_W003 = True # Will run alternate check 148 | ``` 149 | 150 | !!! warning "The tags will be executed only once, per request, for the **last** installed package" 151 | To avoid multiple executions for the `define_sso_providers` and `define_show_form` tags, these code will be executed once and the result will be cached on the request object. 152 | Due to django template loading mechanism, the tag's code from the **last** installed package will be the one executed. This means if you have 153 | multiple packages installed, only the last one will be executed. To avoid this, you can use the `sso_providers` and `show_admin_form` context variables 154 | to pass the values you want to show in the template. 155 | 156 | ```python 157 | # views.py 158 | from django.shortcuts import render 159 | from django_google_sso.template_tags import define_sso_providers, define_show_form 160 | 161 | def my_login_view(request): 162 | ... 163 | sso_providers = define_sso_providers({"context": request}) 164 | show_admin_form = define_show_form({"context": request}) 165 | 166 | return render( 167 | request, 168 | "my_login_template.html", 169 | {"sso_providers": sso_providers, "show_admin_form": show_admin_form}, 170 | ) 171 | ``` 172 | 173 | ## Split Providers between Admin and Page Logins 174 | 175 | If you want to use different providers for Admin and Page logins, you may need to enable/disable providers per request. For example, suppose if you want to use 176 | all Django SSOs for Page login but only **Django Google SSO** for the Admin, you can add the respective 177 | `*_SSO_PAGES_ENABLED` and `*_SSO_ADMIN_ENABLED`, like this: 178 | 179 | ```python 180 | # settings.py 181 | 182 | # Enable or Disable globally (both Admin and Pages): 183 | GOOGLE_SSO_ENABLED = True 184 | MICROSOFT_SSO_ENABLED = True 185 | GITHUB_SSO_ENABLED = True 186 | 187 | # Enable or disable per request path: 188 | MICROSOFT_SSO_ADMIN_ENABLED = False 189 | MICROSOFT_SSO_PAGES_ENABLED = True 190 | GITHUB_SSO_ADMIN_ENABLED = False 191 | GITHUB_SSO_PAGES_ENABLED = True 192 | ``` 193 | !!! warning "You need to be explicit on these settings" 194 | If you set `GOOGLE_SSO_ADMIN_ENABLED = False` and do not set `GOOGLE_SSO_PAGES_ENABLED`, the default value for `GOOGLE_SSO_PAGES_ENABLED` is also `False`. 195 | This means Google SSO will be disabled for both Admin and Page logins. You need to be explicit on these settings. 196 | -------------------------------------------------------------------------------- /docs/settings.md: -------------------------------------------------------------------------------- 1 | # All Django Settings options 2 | 3 | | Setting | Description | 4 | |------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| 5 | | `GOOGLE_SSO_ADMIN_ENABLED` | Enable SSO only when allowed on Admin pages. Default: `None` | 6 | | `GOOGLE_SSO_ALLOWABLE_DOMAINS` | List of domains that will be allowed to create users. Default: `[]` | 7 | | `GOOGLE_SSO_ALWAYS_UPDATE_USER_DATA` | If true, update default user info from Google data at every login. This will also make their password unusable. Otherwise, all of this happens only on create. Default: `False` | 8 | | `GOOGLE_SSO_AUTHENTICATION_BACKEND` | The authentication backend to use. Default: `None` | 9 | | `GOOGLE_SSO_AUTHORIZATION_PROMPT` | The "prompt" value to pass to the Google authorization URL (see ). Default: `consent` | 10 | | `GOOGLE_SSO_AUTO_CREATE_FIRST_SUPERUSER` | If True, the first user that logs in will be created as superuser if no superuser exists in the database at all. Default: `False` | 11 | | `GOOGLE_SSO_AUTO_CREATE_USERS` | Enable or disable the auto-create users feature. Default: `True` | 12 | | `GOOGLE_SSO_CALLBACK_DOMAIN` | The netloc to be used on Callback URI. Default: `None` | 13 | | `GOOGLE_SSO_CLIENT_ID` | The Google OAuth 2.0 Web Application Client ID. Default: `None` | 14 | | `GOOGLE_SSO_CLIENT_SECRET` | The Google OAuth 2.0 Web Application Client Secret. Default: `None` | 15 | | `GOOGLE_SSO_DEFAULT_LOCALE` | Default code for Google locale. Default: `en` | 16 | | `GOOGLE_SSO_ENABLE_LOGS` | Show Logs from the library. Default: `True` | 17 | | `GOOGLE_SSO_ENABLE_MESSAGES` | Show Messages using Django Messages Framework. Default: `True` | 18 | | `GOOGLE_SSO_ENABLED` | Enable or disable the plugin. Default: `True` | 19 | | `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` | 20 | | `GOOGLE_SSO_LOGO_URL` | The URL of the logo to be used on the login button. Default: `https://upload.wikimedia.org/wikipedia/commons/thumb/c/c1/Google_%22G%22_logo.svg/1280px-Google_%22G%22_logo.svg.png` | 21 | | `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` | 22 | | `GOOGLE_SSO_PAGES_ENABLED` | Enable SSO button injection on non-admin pages. Default: `None` | 23 | | `GOOGLE_SSO_PRE_CREATE_CALLBACK` | Callable for processing pre-create logic. Default: `django_google_sso.hooks.pre_create_user` | 24 | | `GOOGLE_SSO_PRE_LOGIN_CALLBACK` | Callable for processing pre-login logic. Default: `django_google_sso.hooks.pre_login_user` | 25 | | `GOOGLE_SSO_PRE_VALIDATE_CALLBACK` | Callable for processing pre-validate logic. Default: `django_google_sso.hooks.pre_validate_user` | 26 | | `GOOGLE_SSO_PROJECT_ID` | The Google OAuth 2.0 Project ID. Default: `None` | 27 | | `GOOGLE_SSO_SAVE_ACCESS_TOKEN` | Save the access token in the session. Default: `False` | 28 | | `GOOGLE_SSO_SAVE_BASIC_GOOGLE_INFO` | Save basic Google info in the database. Default: `True` | 29 | | `GOOGLE_SSO_SCOPES` | The Google OAuth 2.0 Scopes. Default: `["openid", "https://www.googleapis.com/auth/userinfo.email", "https://www.googleapis.com/auth/userinfo.profile"]` | 30 | | `GOOGLE_SSO_SESSION_COOKIE_AGE` | The age of the session cookie in seconds. Default: `3600` | 31 | | `GOOGLE_SSO_SHOW_FAILED_LOGIN_MESSAGE` | Show a message on browser when the user creation fails on database. Default: `False` | 32 | | `GOOGLE_SSO_STAFF_LIST` | List of emails that will be created as staff. Default: `[]` | 33 | | `GOOGLE_SSO_SUPERUSER_LIST` | List of emails that will be created as superuser. Default: `[]` | 34 | | `GOOGLE_SSO_TEXT` | The text to be used on the login button. Default: `Sign in with Google` | 35 | | `GOOGLE_SSO_TIMEOUT` | The timeout for the Google SSO authentication returns info, in minutes. Default: `10` | 36 | | `SSO_ADMIN_ROUTE` | The admin index page route. Default: `admin:index` | 37 | | `SSO_SHOW_FORM_ON_ADMIN_PAGE` | Show the form on the admin page. Default: `True` | 38 | | `SSO_USE_ALTERNATE_W003` | Use alternate W003 warning. You need to silence original templates.W003 warning. Default: `False` | 39 | -------------------------------------------------------------------------------- /django_google_sso/conf.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Callable, List 2 | 3 | from django.conf import settings 4 | from django.http import HttpRequest 5 | from loguru import logger 6 | 7 | 8 | class GoogleSSOSettings: 9 | """ 10 | Settings class for Django Google SSO. 11 | 12 | This class implements the PEP 562 approach to avoid accessing Django settings 13 | at import time, which can cause issues if the module is imported before 14 | Django has fully initialized its settings. 15 | """ 16 | 17 | def _get_setting( 18 | self, name: str, default: Any = None, accept_callable: bool = True 19 | ) -> Any: 20 | """Get a setting from Django settings.""" 21 | value = getattr(settings, name, default) 22 | if not accept_callable and callable(value): 23 | raise TypeError(f"The setting {name} cannot be a callable.") 24 | return value 25 | 26 | # Configurations without callable 27 | @property 28 | def GOOGLE_SSO_ENABLED(self) -> bool: 29 | return self._get_setting("GOOGLE_SSO_ENABLED", True, accept_callable=False) 30 | 31 | @property 32 | def GOOGLE_SSO_ENABLE_LOGS(self) -> bool: 33 | value = self._get_setting("GOOGLE_SSO_ENABLE_LOGS", True, accept_callable=False) 34 | if value: 35 | logger.enable("django_google_sso") 36 | else: 37 | logger.disable("django_google_sso") 38 | return value 39 | 40 | @property 41 | def SSO_USE_ALTERNATE_W003(self) -> bool: 42 | return self._get_setting("SSO_USE_ALTERNATE_W003", False, accept_callable=False) 43 | 44 | # Configurations with optional callable 45 | 46 | @property 47 | def GOOGLE_SSO_LOGO_URL(self) -> str | Callable[[HttpRequest], str]: 48 | return self._get_setting( 49 | "GOOGLE_SSO_LOGO_URL", 50 | "https://upload.wikimedia.org/wikipedia/commons/thumb/c/c1/" 51 | "Google_%22G%22_logo.svg/1280px-Google_%22G%22_logo.svg.png", 52 | ) 53 | 54 | @property 55 | def GOOGLE_SSO_TEXT(self) -> bool | Callable[[HttpRequest], str] | None: 56 | return self._get_setting("GOOGLE_SSO_TEXT", "Sign in with Google") 57 | 58 | @property 59 | def GOOGLE_SSO_ADMIN_ENABLED(self) -> bool | Callable[[HttpRequest], str] | None: 60 | return self._get_setting("GOOGLE_SSO_ADMIN_ENABLED", None) 61 | 62 | @property 63 | def GOOGLE_SSO_PAGES_ENABLED(self) -> bool | Callable[[HttpRequest], str] | None: 64 | return self._get_setting("GOOGLE_SSO_PAGES_ENABLED", None) 65 | 66 | @property 67 | def GOOGLE_SSO_CLIENT_ID(self) -> str | Callable[[HttpRequest], str] | None: 68 | return self._get_setting("GOOGLE_SSO_CLIENT_ID", None) 69 | 70 | @property 71 | def GOOGLE_SSO_PROJECT_ID(self) -> str | Callable[[HttpRequest], str] | None: 72 | return self._get_setting("GOOGLE_SSO_PROJECT_ID", None) 73 | 74 | @property 75 | def GOOGLE_SSO_CLIENT_SECRET(self) -> str | Callable[[HttpRequest], str] | None: 76 | return self._get_setting("GOOGLE_SSO_CLIENT_SECRET", None) 77 | 78 | @property 79 | def GOOGLE_SSO_SCOPES(self) -> List[str] | Callable[[HttpRequest], List[str]]: 80 | return self._get_setting( 81 | "GOOGLE_SSO_SCOPES", 82 | [ 83 | "openid", 84 | "https://www.googleapis.com/auth/userinfo.email", 85 | "https://www.googleapis.com/auth/userinfo.profile", 86 | ], 87 | ) 88 | 89 | @property 90 | def GOOGLE_SSO_TIMEOUT(self) -> int | Callable[[HttpRequest], int]: 91 | return self._get_setting("GOOGLE_SSO_TIMEOUT", 10) 92 | 93 | @property 94 | def GOOGLE_SSO_ALLOWABLE_DOMAINS( 95 | self, 96 | ) -> List[str] | Callable[[HttpRequest], List[str]]: 97 | return self._get_setting("GOOGLE_SSO_ALLOWABLE_DOMAINS", []) 98 | 99 | @property 100 | def GOOGLE_SSO_AUTO_CREATE_FIRST_SUPERUSER( 101 | self, 102 | ) -> bool | Callable[[HttpRequest], bool]: 103 | return self._get_setting("GOOGLE_SSO_AUTO_CREATE_FIRST_SUPERUSER", False) 104 | 105 | @property 106 | def GOOGLE_SSO_SESSION_COOKIE_AGE(self) -> int | Callable[[HttpRequest], int]: 107 | return self._get_setting("GOOGLE_SSO_SESSION_COOKIE_AGE", 3600) 108 | 109 | @property 110 | def GOOGLE_SSO_SUPERUSER_LIST( 111 | self, 112 | ) -> List[str] | Callable[[HttpRequest], List[str]]: 113 | return self._get_setting("GOOGLE_SSO_SUPERUSER_LIST", []) 114 | 115 | @property 116 | def GOOGLE_SSO_STAFF_LIST(self) -> List[str] | Callable[[HttpRequest], List[str]]: 117 | return self._get_setting("GOOGLE_SSO_STAFF_LIST", []) 118 | 119 | @property 120 | def GOOGLE_SSO_CALLBACK_DOMAIN(self) -> str | Callable[[HttpRequest], str] | None: 121 | return self._get_setting("GOOGLE_SSO_CALLBACK_DOMAIN", None) 122 | 123 | @property 124 | def GOOGLE_SSO_LOGIN_FAILED_URL(self) -> str | Callable[[HttpRequest], str]: 125 | return self._get_setting("GOOGLE_SSO_LOGIN_FAILED_URL", "admin:index") 126 | 127 | @property 128 | def GOOGLE_SSO_NEXT_URL(self) -> str | Callable[[HttpRequest], str]: 129 | return self._get_setting("GOOGLE_SSO_NEXT_URL", "admin:index") 130 | 131 | @property 132 | def GOOGLE_SSO_AUTO_CREATE_USERS(self) -> bool | Callable[[HttpRequest], bool]: 133 | return self._get_setting("GOOGLE_SSO_AUTO_CREATE_USERS", True) 134 | 135 | @property 136 | def GOOGLE_SSO_AUTHENTICATION_BACKEND( 137 | self, 138 | ) -> str | Callable[[HttpRequest], str] | None: 139 | return self._get_setting("GOOGLE_SSO_AUTHENTICATION_BACKEND", None) 140 | 141 | @property 142 | def GOOGLE_SSO_PRE_VALIDATE_CALLBACK(self) -> str | Callable[[HttpRequest], str]: 143 | return self._get_setting( 144 | "GOOGLE_SSO_PRE_VALIDATE_CALLBACK", 145 | "django_google_sso.hooks.pre_validate_user", 146 | ) 147 | 148 | @property 149 | def GOOGLE_SSO_PRE_CREATE_CALLBACK(self) -> str | Callable[[HttpRequest], str]: 150 | return self._get_setting( 151 | "GOOGLE_SSO_PRE_CREATE_CALLBACK", 152 | "django_google_sso.hooks.pre_create_user", 153 | ) 154 | 155 | @property 156 | def GOOGLE_SSO_PRE_LOGIN_CALLBACK(self) -> str | Callable[[HttpRequest], str]: 157 | return self._get_setting( 158 | "GOOGLE_SSO_PRE_LOGIN_CALLBACK", 159 | "django_google_sso.hooks.pre_login_user", 160 | ) 161 | 162 | @property 163 | def GOOGLE_SSO_SAVE_ACCESS_TOKEN(self) -> bool | Callable[[HttpRequest], bool]: 164 | return self._get_setting("GOOGLE_SSO_SAVE_ACCESS_TOKEN", False) 165 | 166 | @property 167 | def GOOGLE_SSO_ALWAYS_UPDATE_USER_DATA( 168 | self, 169 | ) -> bool | Callable[[HttpRequest], bool]: 170 | return self._get_setting("GOOGLE_SSO_ALWAYS_UPDATE_USER_DATA", False) 171 | 172 | @property 173 | def GOOGLE_SSO_DEFAULT_LOCALE(self) -> str | Callable[[HttpRequest], str]: 174 | return self._get_setting("GOOGLE_SSO_DEFAULT_LOCALE", "en") 175 | 176 | @property 177 | def GOOGLE_SSO_ENABLE_MESSAGES(self) -> bool | Callable[[HttpRequest], bool]: 178 | return self._get_setting("GOOGLE_SSO_ENABLE_MESSAGES", True) 179 | 180 | @property 181 | def GOOGLE_SSO_SAVE_BASIC_GOOGLE_INFO(self) -> bool | Callable[[HttpRequest], bool]: 182 | return self._get_setting("GOOGLE_SSO_SAVE_BASIC_GOOGLE_INFO", True) 183 | 184 | @property 185 | def GOOGLE_SSO_SHOW_FAILED_LOGIN_MESSAGE( 186 | self, 187 | ) -> bool | Callable[[HttpRequest], bool]: 188 | return self._get_setting("GOOGLE_SSO_SHOW_FAILED_LOGIN_MESSAGE", False) 189 | 190 | @property 191 | def GOOGLE_SSO_AUTHORIZATION_PROMPT( 192 | self, 193 | ) -> str | None | Callable[[HttpRequest], str]: 194 | return self._get_setting("GOOGLE_SSO_AUTHORIZATION_PROMPT", "consent") 195 | 196 | @property 197 | def SSO_ADMIN_ROUTE( 198 | self, 199 | ) -> str | Callable[[HttpRequest], str]: 200 | return self._get_setting("SSO_ADMIN_ROUTE", "admin:index") 201 | 202 | @property 203 | def SSO_SHOW_FORM_ON_ADMIN_PAGE( 204 | self, 205 | ) -> bool | Callable[[HttpRequest], bool]: 206 | return self._get_setting("SSO_SHOW_FORM_ON_ADMIN_PAGE", True) 207 | 208 | 209 | # Create a single instance of the settings class 210 | _google_sso_settings = GoogleSSOSettings() 211 | 212 | 213 | def __getattr__(name: str) -> Any: 214 | """ 215 | Implement PEP 562 __getattr__ to lazily load settings. 216 | 217 | This function is called when an attribute is not found in the module's 218 | global namespace. It delegates to the _google_sso_settings instance. 219 | """ 220 | return getattr(_google_sso_settings, name) 221 | 222 | 223 | if _google_sso_settings.SSO_USE_ALTERNATE_W003: 224 | from django_google_sso.checks.warnings import register_sso_check # noqa 225 | 226 | if _google_sso_settings.GOOGLE_SSO_ENABLE_LOGS: 227 | logger.enable("django_google_sso") 228 | else: 229 | logger.disable("django_google_sso") 230 | -------------------------------------------------------------------------------- /example_google_app/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for example_google_app project. 3 | 4 | Generated by 'django-admin startproject' using Django 3.2.9. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.2/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/3.2/ref/settings/ 11 | """ 12 | 13 | # flake8: noqa: E731 14 | 15 | from pathlib import Path 16 | 17 | from loguru import logger 18 | from stela import env 19 | 20 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 21 | 22 | BASE_DIR = Path(__file__).resolve().parent.parent 23 | 24 | 25 | # Quick-start development settings - unsuitable for production 26 | # See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/ 27 | 28 | # SECURITY WARNING: keep the secret key used in production secret! 29 | SECRET_KEY = "django-insecure" 30 | 31 | # SECURITY WARNING: don't run with debug turned on in production! 32 | DEBUG = True 33 | 34 | ALLOWED_HOSTS = [] 35 | 36 | 37 | # Application definition 38 | 39 | INSTALLED_APPS = [ 40 | # Uncomment for Grappelli 41 | # "grappelli", 42 | # Uncomment for Jazzmin 43 | # "jazzmin", 44 | # Uncomment for Admin Interface 45 | # "admin_interface", 46 | # "colorfield", 47 | # Uncomment for Jest 48 | # "jet.dashboard", 49 | # "jet", 50 | # Uncomment for Unfold 51 | # "unfold", 52 | "django.contrib.admin", 53 | "django.contrib.auth", 54 | "django.contrib.contenttypes", 55 | "django.contrib.sessions", 56 | "django.contrib.messages", # Need for Auth messages 57 | "django.contrib.staticfiles", 58 | "django.contrib.sites", # Optional: Add Sites framework 59 | "django_google_sso", # Add django_google_sso 60 | ] 61 | 62 | MIDDLEWARE = [ 63 | "django.middleware.security.SecurityMiddleware", 64 | "django.contrib.sessions.middleware.SessionMiddleware", 65 | "django.middleware.common.CommonMiddleware", 66 | "django.middleware.csrf.CsrfViewMiddleware", 67 | "django.contrib.auth.middleware.AuthenticationMiddleware", 68 | # Must be after Authentication 69 | "example_google_app.backend.google_slo_middleware_example", 70 | "django.contrib.messages.middleware.MessageMiddleware", 71 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 72 | ] 73 | 74 | ROOT_URLCONF = "example_google_app.urls" 75 | 76 | TEMPLATES = [ 77 | { 78 | "BACKEND": "django.template.backends.django.DjangoTemplates", 79 | "DIRS": [ 80 | BASE_DIR / "example_google_app" / "templates", 81 | ], 82 | "APP_DIRS": True, 83 | "OPTIONS": { 84 | "context_processors": [ 85 | "django.template.context_processors.debug", 86 | "django.template.context_processors.request", 87 | "django.contrib.auth.context_processors.auth", 88 | "django.contrib.messages.context_processors.messages", 89 | "django.template.context_processors.static", 90 | ], 91 | }, 92 | }, 93 | ] 94 | 95 | WSGI_APPLICATION = "example_google_app.wsgi.application" 96 | 97 | 98 | # Database 99 | # https://docs.djangoproject.com/en/3.2/ref/settings/#databases 100 | 101 | DATABASES = { 102 | "default": { 103 | "ENGINE": "django.db.backends.sqlite3", 104 | "NAME": BASE_DIR / "db.sqlite3", 105 | } 106 | } 107 | 108 | 109 | # Password validation 110 | # https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators 111 | 112 | AUTH_PASSWORD_VALIDATORS = [ 113 | { 114 | "NAME": "django.contrib.auth.password_validation." 115 | "UserAttributeSimilarityValidator", 116 | }, 117 | { 118 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 119 | }, 120 | { 121 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 122 | }, 123 | { 124 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 125 | }, 126 | ] 127 | 128 | 129 | # Internationalization 130 | # https://docs.djangoproject.com/en/3.2/topics/i18n/ 131 | 132 | LANGUAGE_CODE = "en-us" 133 | 134 | TIME_ZONE = "UTC" 135 | 136 | USE_I18N = True 137 | 138 | USE_L10N = True 139 | 140 | USE_TZ = True 141 | 142 | if "jet" in INSTALLED_APPS: 143 | # Jet Theme 144 | JET_DEFAULT_THEME = "light-gray" 145 | 146 | JET_THEMES = [ 147 | { 148 | "theme": "default", # theme folder name 149 | "color": "#47bac1", # color of the theme's button in user menu 150 | "title": "Default", # theme title 151 | }, 152 | {"theme": "green", "color": "#44b78b", "title": "Green"}, 153 | {"theme": "light-green", "color": "#2faa60", "title": "Light Green"}, 154 | {"theme": "light-violet", "color": "#a464c4", "title": "Light Violet"}, 155 | {"theme": "light-blue", "color": "#5EADDE", "title": "Light Blue"}, 156 | {"theme": "light-gray", "color": "#222", "title": "Light Gray"}, 157 | ] 158 | 159 | 160 | # Static files (CSS, JavaScript, Images) 161 | # https://docs.djangoproject.com/en/3.2/howto/static-files/ 162 | 163 | STATIC_URL = "static/" 164 | STATICFILES_DIRS = [BASE_DIR / "example_google_app" / "static"] 165 | 166 | # Default primary key field type 167 | # https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field 168 | 169 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 170 | 171 | AUTHENTICATION_BACKENDS = [ 172 | "django.contrib.auth.backends.ModelBackend", 173 | "example_google_app.backend.MyBackend", 174 | ] 175 | 176 | SITE_ID = 1 177 | 178 | # Uncomment GOOGLE_SSO_CALLBACK_DOMAIN to use Sites Framework site domain 179 | # Or comment both and use domain retrieved from accounts/login/ request 180 | GOOGLE_SSO_CALLBACK_DOMAIN = env.GOOGLE_SSO_CALLBACK_DOMAIN 181 | 182 | # **************************** 183 | # * * 184 | # * Callable Examples * 185 | # * * 186 | # **************************** 187 | 188 | 189 | def get_client_id(request): 190 | """ 191 | Example of callable to return client ID based on request. 192 | This can be used to dynamically set the client ID based on the request. 193 | """ 194 | return env.GOOGLE_SSO_CLIENT_ID 195 | 196 | 197 | def get_project_id(request): 198 | return env.GOOGLE_SSO_PROJECT_ID 199 | 200 | 201 | GOOGLE_SSO_CLIENT_ID = get_client_id # dynamic 202 | GOOGLE_SSO_PROJECT_ID = get_project_id # dynamic 203 | GOOGLE_SSO_CLIENT_SECRET = env.GOOGLE_SSO_CLIENT_SECRET # static 204 | 205 | # *********************************** 206 | # * * 207 | # * Page and Admin Login Examples * 208 | # * * 209 | # *********************************** 210 | 211 | # --8<-- [start:sso_config] 212 | # settings.py 213 | from django_google_sso.helpers import is_admin_path 214 | 215 | 216 | def get_sso_config(request): 217 | config = { 218 | "admin": { 219 | "allowable_domains": env.get_or_default("GOOGLE_SSO_ALLOWABLE_DOMAINS", []), 220 | "login_failed_url": "admin:login", 221 | "next_url": "admin:index", 222 | "session_cookie_age": 3600, # 1 hour - default 223 | "staff_list": env.get_or_default("GOOGLE_SSO_STAFF_LIST", []), 224 | "superuser_list": env.get_or_default("GOOGLE_SSO_SUPERUSER_LIST", []), 225 | "auto_create_first_superuser": True, # Create superuser on first eligible user login 226 | }, 227 | "pages": { 228 | "allowable_domains": ["*"], # Allow all domains 229 | "login_failed_url": "index", 230 | "next_url": "secret", 231 | "session_cookie_age": 86400, # 24 hours 232 | "staff_list": [], 233 | "superuser_list": [], 234 | "auto_create_first_superuser": False, 235 | }, 236 | } 237 | if is_admin_path(request): 238 | logger.debug("Returning Admin SSO configuration") 239 | return config["admin"] 240 | else: 241 | logger.debug("Returning Pages SSO configuration") 242 | return config["pages"] 243 | 244 | 245 | # Configure settings as callables 246 | GOOGLE_SSO_ALLOWABLE_DOMAINS = lambda request: get_sso_config(request)[ 247 | "allowable_domains" 248 | ] 249 | GOOGLE_SSO_LOGIN_FAILED_URL = lambda request: get_sso_config(request)[ 250 | "login_failed_url" 251 | ] 252 | GOOGLE_SSO_NEXT_URL = lambda request: get_sso_config(request)["next_url"] 253 | GOOGLE_SSO_SESSION_COOKIE_AGE = lambda request: get_sso_config(request)[ 254 | "session_cookie_age" 255 | ] 256 | GOOGLE_SSO_STAFF_LIST = lambda request: get_sso_config(request)["staff_list"] 257 | GOOGLE_SSO_SUPERUSER_LIST = lambda request: get_sso_config(request)["superuser_list"] 258 | GOOGLE_SSO_AUTO_CREATE_FIRST_SUPERUSER = lambda request: get_sso_config(request)[ 259 | "auto_create_first_superuser" 260 | ] 261 | # --8<-- [end:sso_config] 262 | 263 | 264 | # *********************************** 265 | # * * 266 | # * Other Config Examples * 267 | # * * 268 | # *********************************** 269 | 270 | GOOGLE_SSO_TIMEOUT = 10 # default value 271 | GOOGLE_SSO_SCOPES = [ 272 | "openid", 273 | "https://www.googleapis.com/auth/userinfo.email", 274 | "https://www.googleapis.com/auth/userinfo.profile", 275 | # "https://www.googleapis.com/auth/user.birthday.read", # additional scope 276 | ] 277 | 278 | # Optional: Add if you want to use custom authentication backend 279 | GOOGLE_SSO_AUTHENTICATION_BACKEND = "example_google_app.backend.MyBackend" 280 | 281 | # Optional: You can save access token to session 282 | GOOGLE_SSO_SAVE_ACCESS_TOKEN = True 283 | 284 | # Optional: Change default login text 285 | # GOOGLE_SSO_TEXT = "Login using Google Account" 286 | 287 | # Optional: Add pre-validate logic 288 | GOOGLE_SSO_PRE_VALIDATE_CALLBACK = "example_google_app.backend.pre_validate_callback" 289 | 290 | # Optional: Add pre-create logic 291 | GOOGLE_SSO_PRE_CREATE_CALLBACK = "example_google_app.backend.pre_create_callback" 292 | 293 | # Optional: Add pre-login logic 294 | GOOGLE_SSO_PRE_LOGIN_CALLBACK = "example_google_app.backend.pre_login_callback" 295 | 296 | # Uncomment to disable SSO login globally 297 | # GOOGLE_SSO_ENABLED = False # default: True 298 | 299 | # You can also define login per request.path 300 | # GOOGLE_SSO_PAGES_ENABLED = True # default: None 301 | # GOOGLE_SSO_ADMIN_ENABLED = False # default: None 302 | 303 | # Uncomment to disable user auto-creation 304 | # GOOGLE_SSO_AUTO_CREATE_USERS = False # default: True 305 | 306 | # Always update user data with Google Info 307 | GOOGLE_SSO_ALWAYS_UPDATE_USER_DATA = True 308 | 309 | # Uncomment to hide the login form on admin page 310 | SSO_SHOW_FORM_ON_ADMIN_PAGE = False # default: True 311 | 312 | # Optional: Disable Logs 313 | # GOOGLE_SSO_ENABLE_LOGS = False 314 | 315 | # Optional: Disable Django Messages 316 | # GOOGLE_SSO_ENABLE_MESSAGES = False 317 | 318 | # Optional: Start or Stop User auto-creation 319 | # GOOGLE_SSO_AUTO_CREATE_USERS = True 320 | 321 | # Optional: Show failed login attempt message on browser. 322 | # This message can be used in exploit attempts. 323 | # GOOGLE_SSO_SHOW_FAILED_LOGIN_MESSAGE = True 324 | 325 | # Optional: Change the "prompt" value to pass to the Google authorization URL 326 | # Valid options are: "none", "consent", "select_account" and None 327 | # https://developers.google.com/identity/protocols/oauth2/openid-connect#prompt 328 | # GOOGLE_SSO_AUTHORIZATION_PROMPT = "select_account" # default: "consent" 329 | -------------------------------------------------------------------------------- /django_google_sso/main.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Any, Optional 3 | 4 | from django.contrib import messages 5 | from django.contrib.auth import get_user_model 6 | from django.contrib.auth.models import User 7 | from django.contrib.sites.shortcuts import get_current_site 8 | from django.db.models import Field 9 | from django.urls import reverse 10 | from django.utils.translation import gettext_lazy as _ 11 | from google.oauth2.credentials import Credentials 12 | from google_auth_oauthlib.flow import Flow 13 | from loguru import logger 14 | 15 | from django_google_sso import conf 16 | from django_google_sso.models import GoogleSSOUser 17 | 18 | 19 | @dataclass 20 | class GoogleAuth: 21 | request: Any 22 | _flow: Optional[Flow] = None 23 | 24 | @property 25 | def scopes(self) -> list[str]: 26 | return self.get_sso_value("scopes") 27 | 28 | def get_sso_value(self, key: str) -> Any: 29 | """Get SSO value from request or settings. 30 | 31 | Both configurations are valid: 32 | GOOGLE_SSO_CLIENT_ID = "your-client-id" # string value 33 | GOOGLE_SSO_CLIENT_ID = get_client_id # callable function 34 | 35 | When the value is a callable, 36 | it will be called with the request as an argument: 37 | 38 | def get_client_id(request): 39 | client_ids = { 40 | "example.com": "your-client-id", 41 | "other.com": "your-other-client-id", 42 | } 43 | return client_ids.get(request.site.domain, None) 44 | 45 | GOOGLE_SSO_CLIENT_ID = get_client_id 46 | 47 | :param key: The key to retrieve from the settings. 48 | :return: The value associated with the key. 49 | :raises ValueError: If the key is not found in the settings. 50 | """ 51 | google_sso_conf = f"GOOGLE_SSO_{key.upper()}" 52 | if hasattr(conf, google_sso_conf): 53 | value = getattr(conf, google_sso_conf) 54 | if callable(value): 55 | logger.debug( 56 | f"Value from conf {google_sso_conf} is a callable. Calling it." 57 | ) 58 | return value(self.request) 59 | return value 60 | raise ValueError( 61 | f"SSO Configuration '{google_sso_conf}' not found in settings." 62 | ) 63 | 64 | def get_client_config(self) -> Credentials: 65 | client_config = { 66 | "web": { 67 | "client_id": self.get_sso_value("client_id"), 68 | "project_id": self.get_sso_value("project_id"), 69 | "client_secret": self.get_sso_value("client_secret"), 70 | "auth_uri": "https://accounts.google.com/o/oauth2/auth", 71 | "auth_provider_x509_cert_url": ( 72 | "https://www.googleapis.com/oauth2/v1/certs" 73 | ), 74 | "token_uri": "https://oauth2.googleapis.com/token", 75 | "redirect_uris": [self.get_redirect_uri()], 76 | } 77 | } 78 | return client_config 79 | 80 | def get_netloc(self): 81 | callback_domain = self.get_sso_value("callback_domain") 82 | if callback_domain: 83 | logger.debug("Find Netloc using GOOGLE_SSO_CALLBACK_DOMAIN") 84 | return callback_domain 85 | 86 | site = get_current_site(self.request) 87 | logger.debug("Find Netloc using Site domain") 88 | return site.domain 89 | 90 | def get_redirect_uri(self) -> str: 91 | if "HTTP_X_FORWARDED_PROTO" in self.request.META: 92 | raw_scheme = self.request.META["HTTP_X_FORWARDED_PROTO"] 93 | # Some reverse proxies may send a comma-separated list like "https,https". 94 | # Always use the first value and strip whitespace to build a valid URI. 95 | scheme = ( 96 | raw_scheme.split(",")[0].strip() if raw_scheme else self.request.scheme 97 | ) 98 | else: 99 | scheme = self.request.scheme 100 | netloc = self.get_netloc() 101 | path = reverse("django_google_sso:oauth_callback") 102 | callback_uri = f"{scheme}://{netloc}{path}" 103 | logger.debug(f"Callback URI: {callback_uri}") 104 | return callback_uri 105 | 106 | @property 107 | def flow(self) -> Flow: 108 | if not self._flow: 109 | self._flow = Flow.from_client_config( 110 | self.get_client_config(), 111 | scopes=self.scopes, 112 | redirect_uri=self.get_redirect_uri(), 113 | ) 114 | return self._flow 115 | 116 | def get_user_info(self): 117 | session = self.flow.authorized_session() 118 | user_info = session.get("https://www.googleapis.com/oauth2/v2/userinfo").json() 119 | return user_info 120 | 121 | def get_user_token(self): 122 | return self.flow.credentials.token 123 | 124 | def check_enabled(self, next_url: str) -> tuple[bool, str]: 125 | response = True, "" 126 | if not conf.GOOGLE_SSO_ENABLED: 127 | response = False, "Google SSO not enabled." 128 | else: 129 | admin_route = conf.SSO_ADMIN_ROUTE 130 | if callable(admin_route): 131 | admin_route = admin_route(self.request) 132 | 133 | admin_enabled = self.get_sso_value("admin_enabled") 134 | if admin_enabled is False and next_url.startswith(reverse(admin_route)): 135 | response = False, "Google SSO not enabled for Admin." 136 | 137 | pages_enabled = self.get_sso_value("pages_enabled") 138 | if pages_enabled is False and not next_url.startswith(reverse(admin_route)): 139 | response = False, "Google SSO not enabled for Pages." 140 | 141 | if response[1]: 142 | logger.debug(f"SSO Enable Check failed: {response[1]}") 143 | 144 | return response 145 | 146 | 147 | @dataclass 148 | class UserHelper: 149 | user_info: dict[Any, Any] 150 | request: Any 151 | user_changed: bool = False 152 | 153 | @property 154 | def user_info_email(self): 155 | return self.user_info["email"].lower() 156 | 157 | @property 158 | def user_model(self) -> type[User]: 159 | return get_user_model() 160 | 161 | @property 162 | def username_field(self) -> Field: 163 | return self.user_model._meta.get_field(self.user_model.USERNAME_FIELD) 164 | 165 | @property 166 | def email_field_name(self) -> str: 167 | return self.user_model.get_email_field_name() 168 | 169 | @property 170 | def email_is_valid(self) -> bool: 171 | google = GoogleAuth(self.request) 172 | user_email_domain = self.user_info_email.split("@")[-1] 173 | allowable_domains = google.get_sso_value("allowable_domains") 174 | if "*" in allowable_domains or user_email_domain in allowable_domains: 175 | return True 176 | email_verified = self.user_info.get("email_verified", None) 177 | if email_verified is not None and not email_verified: 178 | logger.debug(f"Email {self.user_info_email} is not verified.") 179 | return email_verified if email_verified is not None else False 180 | 181 | def get_or_create_user(self, extra_users_args: dict | None = None): 182 | user_defaults = extra_users_args or {} 183 | if self.username_field.name not in user_defaults: 184 | user_defaults[self.username_field.name] = self.user_info_email 185 | if self.email_field_name not in user_defaults: 186 | user_defaults[self.email_field_name] = self.user_info_email 187 | user, created = self.user_model.objects.get_or_create( 188 | **{f"{self.email_field_name}__iexact": self.user_info_email}, 189 | defaults=user_defaults, 190 | ) 191 | self.check_first_super_user(user) 192 | self.check_for_update(created, user) 193 | if self.user_changed: 194 | user.save() 195 | 196 | google = GoogleAuth(self.request) 197 | save_basic_info = google.get_sso_value("save_basic_google_info") 198 | if save_basic_info: 199 | default_locale = google.get_sso_value("default_locale") 200 | GoogleSSOUser.objects.update_or_create( 201 | user=user, 202 | defaults={ 203 | "google_id": self.user_info["id"], 204 | "picture_url": self.user_info.get("picture"), 205 | "locale": self.user_info.get("locale") or default_locale, 206 | }, 207 | ) 208 | return user 209 | 210 | def check_for_update(self, created, user): 211 | google = GoogleAuth(self.request) 212 | always_update = google.get_sso_value("always_update_user_data") 213 | if created or always_update: 214 | self.check_for_permissions(user) 215 | user.first_name = self.user_info.get("given_name") 216 | user.last_name = self.user_info.get("family_name") 217 | if not getattr(user, self.username_field.name): 218 | setattr(user, self.username_field.name, self.user_info_email) 219 | user.set_unusable_password() 220 | self.user_changed = True 221 | 222 | def check_first_super_user(self, user): 223 | google = GoogleAuth(self.request) 224 | auto_create = google.get_sso_value("auto_create_first_superuser") 225 | if auto_create: 226 | superuser_exists = self.user_model.objects.filter( 227 | is_superuser=True, 228 | **{ 229 | f"{self.email_field_name}__icontains": ( 230 | f"@{self.user_info_email.split('@')[-1]}" 231 | ) 232 | }, 233 | ).exists() 234 | if not superuser_exists: 235 | message_text = _( 236 | f"GOOGLE_SSO_AUTO_CREATE_FIRST_SUPERUSER is True. " 237 | f"Adding SuperUser status to email: {self.user_info_email}" 238 | ) 239 | messages.add_message(self.request, messages.INFO, message_text) 240 | logger.warning(message_text) 241 | user.is_superuser = True 242 | user.is_staff = True 243 | self.user_changed = True 244 | 245 | def check_for_permissions(self, user): 246 | user_email = getattr(user, self.email_field_name) 247 | google = GoogleAuth(self.request) 248 | staff_list = google.get_sso_value("staff_list") 249 | if user_email in staff_list or "*" in staff_list: 250 | message_text = _( 251 | f"User email: {user_email} in GOOGLE_SSO_STAFF_LIST. " 252 | f"Added Staff Permission." 253 | ) 254 | messages.add_message(self.request, messages.INFO, message_text) 255 | logger.debug(message_text) 256 | user.is_staff = True 257 | superuser_list = google.get_sso_value("superuser_list") 258 | if user_email in superuser_list: 259 | message_text = _( 260 | f"User email: {user_email} in GOOGLE_SSO_SUPERUSER_LIST. " 261 | f"Added SuperUser Permission." 262 | ) 263 | messages.add_message(self.request, messages.INFO, message_text) 264 | logger.debug(message_text) 265 | user.is_superuser = True 266 | user.is_staff = True 267 | 268 | def find_user(self): 269 | query = self.user_model.objects.filter( 270 | **{f"{self.email_field_name}__iexact": self.user_info_email} 271 | ) 272 | return query.get() if query.exists() else None 273 | --------------------------------------------------------------------------------