39 |
40 |
47 |
--------------------------------------------------------------------------------
/django_google_sso/templatetags/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/megalus/django-google-sso/831a10c4361a9fa438355d54f1fbc7f417e48b54/django_google_sso/templatetags/__init__.py
--------------------------------------------------------------------------------
/django_google_sso/templatetags/show_form.py:
--------------------------------------------------------------------------------
1 | from django import template
2 |
3 | register = template.Library()
4 |
5 |
6 | @register.simple_tag
7 | def define_show_form() -> bool:
8 | from django.conf import settings
9 |
10 | return getattr(settings, "SSO_SHOW_FORM_ON_ADMIN_PAGE", True)
11 |
--------------------------------------------------------------------------------
/django_google_sso/templatetags/sso_tags.py:
--------------------------------------------------------------------------------
1 | import importlib
2 | import re
3 |
4 | from django import template
5 | from django.conf import settings
6 | from django.templatetags.static import static
7 | from django.urls import reverse
8 | from django.utils.translation import gettext
9 | from loguru import logger
10 |
11 | register = template.Library()
12 |
13 |
14 | @register.simple_tag
15 | def define_sso_providers():
16 | provider_pattern = re.compile(r"^django_(.+)_sso$")
17 | providers = []
18 | for app in settings.INSTALLED_APPS:
19 | match = re.search(provider_pattern, app)
20 | if match:
21 | providers.append(match.group(1))
22 |
23 | sso_providers = []
24 | for provider in providers:
25 | package_name = f"django_{provider}_sso"
26 | try:
27 | package = importlib.import_module(package_name)
28 | conf = getattr(package, "conf")
29 | if getattr(conf, f"{provider.upper()}_SSO_ENABLED"):
30 | sso_providers.append(
31 | {
32 | "name": provider,
33 | "logo_url": getattr(conf, f"{provider.upper()}_SSO_LOGO_URL"),
34 | "text": gettext(getattr(conf, f"{provider.upper()}_SSO_TEXT")),
35 | "login_url": reverse(
36 | f"django_{provider}_sso:oauth_start_login"
37 | ),
38 | "css_url": static(
39 | f"django_{provider}_sso/{provider}_button.css"
40 | ),
41 | }
42 | )
43 | except Exception as e:
44 | logger.error(f"Error importing {package_name}: {e}")
45 | return sso_providers
46 |
--------------------------------------------------------------------------------
/django_google_sso/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/megalus/django-google-sso/831a10c4361a9fa438355d54f1fbc7f417e48b54/django_google_sso/tests/__init__.py
--------------------------------------------------------------------------------
/django_google_sso/tests/conftest.py:
--------------------------------------------------------------------------------
1 | import importlib
2 | from copy import deepcopy
3 | from urllib.parse import quote, urlencode
4 |
5 | import pytest
6 | from django.contrib.messages.storage.fallback import FallbackStorage
7 | from django.contrib.sessions.middleware import SessionMiddleware
8 | from django.urls import reverse
9 |
10 | from django_google_sso import conf
11 | from django_google_sso.main import GoogleAuth
12 |
13 | SECRET_PATH = "/secret/"
14 |
15 |
16 | @pytest.fixture
17 | def query_string():
18 | return urlencode(
19 | {
20 | "code": "12345",
21 | "state": "foo",
22 | "scope": " ".join(conf.GOOGLE_SSO_SCOPES),
23 | "hd": "example.com",
24 | "prompt": "consent",
25 | },
26 | quote_via=quote,
27 | )
28 |
29 |
30 | @pytest.fixture
31 | def google_response():
32 | return {
33 | "id": "12345",
34 | "email": "foo@example.com",
35 | "verified_email": True,
36 | "name": "Bruce Wayne",
37 | "given_name": "Bruce",
38 | "family_name": "Wayne",
39 | "picture": "https://lh3.googleusercontent.com/a-/12345",
40 | "locale": "en-US",
41 | "hd": "example.com",
42 | }
43 |
44 |
45 | @pytest.fixture
46 | def google_response_update():
47 | return {
48 | "id": "12345",
49 | "email": "foo@example.com",
50 | "verified_email": True,
51 | "name": "Clark Kent",
52 | "given_name": "Clark",
53 | "family_name": "Kent",
54 | "picture": "https://lh3.googleusercontent.com/a-/12345",
55 | "locale": "en-US",
56 | "hd": "example.com",
57 | }
58 |
59 |
60 | @pytest.fixture
61 | def callback_request(rf, query_string):
62 | request = rf.get(f"/google_sso/callback/?{query_string}")
63 | middleware = SessionMiddleware(get_response=lambda req: None)
64 | middleware.process_request(request)
65 | request.session.save()
66 | messages = FallbackStorage(request)
67 | setattr(request, "_messages", messages)
68 | return request
69 |
70 |
71 | @pytest.fixture
72 | def callback_request_from_reverse_proxy(rf, query_string):
73 | request = rf.get(
74 | f"/google_sso/callback/?{query_string}", HTTP_X_FORWARDED_PROTO="https"
75 | )
76 | middleware = SessionMiddleware(get_response=lambda req: None)
77 | middleware.process_request(request)
78 | request.session.save()
79 | messages = FallbackStorage(request)
80 | setattr(request, "_messages", messages)
81 | return request
82 |
83 |
84 | @pytest.fixture
85 | def callback_request_with_state(callback_request):
86 | request = deepcopy(callback_request)
87 | request.session["sso_state"] = "foo"
88 | request.session["sso_next_url"] = "/secret/"
89 | return request
90 |
91 |
92 | @pytest.fixture
93 | def client_with_session(client, settings, mocker, google_response):
94 | settings.GOOGLE_SSO_ALLOWABLE_DOMAINS = ["example.com"]
95 | settings.GOOGLE_SSO_PRE_LOGIN_CALLBACK = "django_google_sso.hooks.pre_login_user"
96 | settings.GOOGLE_SSO_PRE_CREATE_CALLBACK = "django_google_sso.hooks.pre_create_user"
97 | settings.GOOGLE_SSO_PRE_VALIDATE_CALLBACK = (
98 | "django_google_sso.hooks.pre_validate_user"
99 | )
100 | importlib.reload(conf)
101 | session = client.session
102 | session.update({"sso_state": "foo", "sso_next_url": SECRET_PATH})
103 | session.save()
104 | mocker.patch.object(GoogleAuth, "flow")
105 | mocker.patch.object(GoogleAuth, "get_user_info", return_value=google_response)
106 | mocker.patch.object(GoogleAuth, "get_user_token", return_value="12345")
107 | yield client
108 |
109 |
110 | @pytest.fixture
111 | def callback_url(query_string):
112 | return f"{reverse('django_google_sso:oauth_callback')}?{query_string}"
113 |
--------------------------------------------------------------------------------
/django_google_sso/tests/test_conf.py:
--------------------------------------------------------------------------------
1 | import importlib
2 |
3 |
4 | def test_conf_from_settings(settings):
5 | # Arrange
6 | settings.GOOGLE_SSO_ENABLED = False
7 |
8 | # Act
9 | from django_google_sso import conf
10 |
11 | importlib.reload(conf)
12 |
13 | # Assert
14 | assert conf.GOOGLE_SSO_ENABLED is False
15 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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
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 |
--------------------------------------------------------------------------------
/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_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/tests/test_views.py:
--------------------------------------------------------------------------------
1 | import importlib
2 |
3 | import pytest
4 | from django.contrib.auth.models import User
5 | from django.contrib.messages import get_messages
6 | from django.urls import reverse
7 |
8 | from django_google_sso import conf
9 | from django_google_sso.main import GoogleAuth
10 | from django_google_sso.tests.conftest import SECRET_PATH
11 |
12 | ROUTE_NAME = "django_google_sso:oauth_callback"
13 |
14 |
15 | pytestmark = pytest.mark.django_db(transaction=True)
16 |
17 |
18 | def test_start_login(client, mocker):
19 | # Arrange
20 | flow_mock = mocker.patch.object(GoogleAuth, "flow")
21 | flow_mock.authorization_url.return_value = ("https://foo/bar", "foo")
22 |
23 | # Act
24 | url = reverse("django_google_sso:oauth_start_login") + "?next=/secret/"
25 | response = client.get(url)
26 |
27 | # Assert
28 | assert response.status_code == 302
29 | assert client.session["sso_next_url"] == SECRET_PATH
30 | assert client.session["sso_state"] == "foo"
31 |
32 |
33 | def test_start_login_none_next_param(client, mocker):
34 | # Arrange
35 | flow_mock = mocker.patch.object(GoogleAuth, "flow")
36 | flow_mock.authorization_url.return_value = ("https://foo/bar", "foo")
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(conf.GOOGLE_SSO_NEXT_URL)
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 | from django_google_sso import conf
76 |
77 | settings.GOOGLE_SSO_ENABLED = False
78 | importlib.reload(conf)
79 |
80 | # Act
81 | response = client.get(reverse(ROUTE_NAME))
82 |
83 | # Assert
84 | assert response.status_code == 302
85 | assert User.objects.count() == 0
86 | assert "Google SSO not enabled." in [
87 | m.message for m in get_messages(response.wsgi_request)
88 | ]
89 |
90 |
91 | def test_missing_code(client):
92 | # Arrange
93 | importlib.reload(conf)
94 |
95 | # Act
96 | response = client.get(reverse(ROUTE_NAME))
97 |
98 | # Assert
99 | assert response.status_code == 302
100 | assert User.objects.count() == 0
101 | assert "Authorization Code not received from SSO." in [
102 | m.message for m in get_messages(response.wsgi_request)
103 | ]
104 |
105 |
106 | @pytest.mark.parametrize("querystring", ["?code=1234", "?code=1234&state=bad_dog"])
107 | def test_bad_state(client, querystring):
108 | # Arrange
109 | importlib.reload(conf)
110 | session = client.session
111 | session.update({"sso_state": "good_dog"})
112 | session.save()
113 |
114 | # Act
115 | url = reverse(ROUTE_NAME) + querystring
116 | response = client.get(url)
117 |
118 | # Assert
119 | assert response.status_code == 302
120 | assert User.objects.count() == 0
121 | assert "State Mismatch. Time expired?" in [
122 | m.message for m in get_messages(response.wsgi_request)
123 | ]
124 |
125 |
126 | def test_invalid_email(client_with_session, settings, callback_url):
127 | # Arrange
128 | from django_google_sso import conf
129 |
130 | settings.GOOGLE_SSO_ALLOWABLE_DOMAINS = ["foobar.com"]
131 | importlib.reload(conf)
132 |
133 | # Act
134 | response = client_with_session.get(callback_url)
135 |
136 | # Assert
137 | assert response.status_code == 302
138 | assert User.objects.count() == 0
139 | assert (
140 | "Email address not allowed: foo@example.com. Please contact your administrator."
141 | in [m.message for m in get_messages(response.wsgi_request)]
142 | )
143 |
144 |
145 | def test_inactive_user(client_with_session, callback_url, google_response):
146 | # Arrange
147 | User.objects.create(
148 | username=google_response["email"],
149 | email=google_response["email"],
150 | is_active=False,
151 | )
152 |
153 | # Act
154 | response = client_with_session.get(callback_url)
155 |
156 | # Assert
157 | assert response.status_code == 302
158 | assert User.objects.count() == 1
159 | assert User.objects.get(email=google_response["email"]).is_active is False
160 |
161 |
162 | def test_new_user_login(client_with_session, callback_url):
163 | # Arrange
164 |
165 | # Act
166 | response = client_with_session.get(callback_url)
167 |
168 | # Assert
169 | assert response.status_code == 302
170 | assert User.objects.count() == 1
171 | assert response.url == SECRET_PATH
172 | assert response.wsgi_request.user.is_authenticated is True
173 |
174 |
175 | def test_existing_user_login(
176 | client_with_session, settings, google_response, callback_url
177 | ):
178 | # Arrange
179 | from django_google_sso import conf
180 |
181 | existing_user = User.objects.create(
182 | username=google_response["email"],
183 | email=google_response["email"],
184 | is_active=True,
185 | )
186 |
187 | settings.GOOGLE_SSO_AUTO_CREATE_USERS = False
188 | importlib.reload(conf)
189 |
190 | # Act
191 | response = client_with_session.get(callback_url)
192 |
193 | # Assert
194 | assert response.status_code == 302
195 | assert User.objects.count() == 1
196 | assert response.url == SECRET_PATH
197 | assert response.wsgi_request.user.is_authenticated is True
198 | assert response.wsgi_request.user.email == existing_user.email
199 |
200 |
201 | def test_missing_user_login(
202 | client_with_session, settings, google_response, callback_url
203 | ):
204 | # Arrange
205 | from django_google_sso import conf
206 |
207 | settings.GOOGLE_SSO_AUTO_CREATE_USERS = False
208 | importlib.reload(conf)
209 |
210 | # Act
211 | response = client_with_session.get(callback_url)
212 |
213 | # Assert
214 | assert response.status_code == 302
215 | assert User.objects.count() == 0
216 | assert response.url == "/admin/"
217 | assert response.wsgi_request.user.is_authenticated is False
218 |
--------------------------------------------------------------------------------
/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/utils.py:
--------------------------------------------------------------------------------
1 | from django.contrib import messages
2 | from loguru import logger
3 |
4 | from django_google_sso import conf
5 |
6 |
7 | def send_message(request, message, level: str = "error"):
8 | getattr(logger, level.lower())(message)
9 | if conf.GOOGLE_SSO_ENABLE_MESSAGES:
10 | messages.add_message(request, getattr(messages, level.upper()), message)
11 |
12 |
13 | def show_credential(credential):
14 | credential = str(credential)
15 | return f"{credential[:5]}...{credential[-5:]}"
16 |
--------------------------------------------------------------------------------
/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 import conf
12 | from django_google_sso.main import GoogleAuth, UserHelper
13 | from django_google_sso.utils import send_message, show_credential
14 |
15 |
16 | @require_http_methods(["GET"])
17 | def start_login(request: HttpRequest) -> HttpResponseRedirect:
18 | # Get the next url
19 | next_param = request.GET.get(key="next")
20 | if next_param:
21 | clean_param = (
22 | next_param
23 | if next_param.startswith("http") or next_param.startswith("/")
24 | else f"//{next_param}"
25 | )
26 | else:
27 | clean_param = reverse(conf.GOOGLE_SSO_NEXT_URL)
28 | next_path = urlparse(clean_param).path
29 |
30 | # Get Google Auth URL
31 | google = GoogleAuth(request)
32 | auth_url, state = google.flow.authorization_url(prompt="consent")
33 |
34 | # Save data on Session
35 | if not request.session.session_key:
36 | request.session.create()
37 | request.session.set_expiry(conf.GOOGLE_SSO_TIMEOUT * 60)
38 | request.session["sso_state"] = state
39 | request.session["sso_next_url"] = next_path
40 | request.session.save()
41 |
42 | # Redirect User
43 | return HttpResponseRedirect(auth_url)
44 |
45 |
46 | @require_http_methods(["GET"])
47 | def callback(request: HttpRequest) -> HttpResponseRedirect:
48 | login_failed_url = reverse(conf.GOOGLE_SSO_LOGIN_FAILED_URL)
49 | google = GoogleAuth(request)
50 | code = request.GET.get("code")
51 | state = request.GET.get("state")
52 |
53 | # Check if Google SSO is enabled
54 | if not conf.GOOGLE_SSO_ENABLED:
55 | send_message(request, _("Google SSO not enabled."))
56 | return HttpResponseRedirect(login_failed_url)
57 |
58 | # First, check for authorization code
59 | if not code:
60 | send_message(request, _("Authorization Code not received from SSO."))
61 | return HttpResponseRedirect(login_failed_url)
62 |
63 | # Then, check state.
64 | request_state = request.session.get("sso_state")
65 | next_url = request.session.get("sso_next_url")
66 |
67 | if not request_state or state != request_state:
68 | send_message(request, _("State Mismatch. Time expired?"))
69 | return HttpResponseRedirect(login_failed_url)
70 |
71 | # Get Access Token from Google
72 | try:
73 | google.flow.fetch_token(code=code)
74 | except Exception as error:
75 | send_message(request, _(f"Error while fetching token from SSO: {error}."))
76 | logger.debug(
77 | f"GOOGLE_SSO_CLIENT_ID: {show_credential(conf.GOOGLE_SSO_CLIENT_ID)}"
78 | )
79 | logger.debug(
80 | f"GOOGLE_SSO_PROJECT_ID: {show_credential(conf.GOOGLE_SSO_PROJECT_ID)}"
81 | )
82 | logger.debug(
83 | f"GOOGLE_SSO_CLIENT_SECRET: "
84 | f"{show_credential(conf.GOOGLE_SSO_CLIENT_SECRET)}"
85 | )
86 | return HttpResponseRedirect(login_failed_url)
87 |
88 | # Get User Info from Google
89 | google_user_data = google.get_user_info()
90 | user_helper = UserHelper(google_user_data, request)
91 |
92 | # Run Pre-Validate Callback
93 | module_path = ".".join(conf.GOOGLE_SSO_PRE_VALIDATE_CALLBACK.split(".")[:-1])
94 | pre_validate_fn = conf.GOOGLE_SSO_PRE_VALIDATE_CALLBACK.split(".")[-1]
95 | module = importlib.import_module(module_path)
96 | user_is_valid = getattr(module, pre_validate_fn)(google_user_data, request)
97 |
98 | # Check if User Info is valid to login
99 | if not user_helper.email_is_valid or not user_is_valid:
100 | send_message(
101 | request,
102 | _(
103 | f"Email address not allowed: {user_helper.user_email}. "
104 | f"Please contact your administrator."
105 | ),
106 | )
107 | return HttpResponseRedirect(login_failed_url)
108 |
109 | # Save Token in Session
110 | if conf.GOOGLE_SSO_SAVE_ACCESS_TOKEN:
111 | access_token = google.get_user_token()
112 | request.session["google_sso_access_token"] = access_token
113 |
114 | # Run Pre-Create Callback
115 | module_path = ".".join(conf.GOOGLE_SSO_PRE_CREATE_CALLBACK.split(".")[:-1])
116 | pre_login_fn = conf.GOOGLE_SSO_PRE_CREATE_CALLBACK.split(".")[-1]
117 | module = importlib.import_module(module_path)
118 | extra_users_args = getattr(module, pre_login_fn)(google_user_data, request)
119 |
120 | # Get or Create User
121 | if conf.GOOGLE_SSO_AUTO_CREATE_USERS:
122 | user = user_helper.get_or_create_user(extra_users_args)
123 | else:
124 | user = user_helper.find_user()
125 |
126 | if not user or not user.is_active:
127 | failed_login_message = f"User not found - Email: '{google_user_data['email']}'"
128 | if not user and not conf.GOOGLE_SSO_AUTO_CREATE_USERS:
129 | failed_login_message += ". Auto-Create is disabled."
130 |
131 | if user and not user.is_active:
132 | failed_login_message = f"User is not active: '{google_user_data['email']}'"
133 |
134 | if conf.GOOGLE_SSO_SHOW_FAILED_LOGIN_MESSAGE:
135 | send_message(request, _(failed_login_message), level="warning")
136 | else:
137 | logger.warning(failed_login_message)
138 |
139 | return HttpResponseRedirect(login_failed_url)
140 |
141 | request.session.save()
142 |
143 | # Run Pre-Login Callback
144 | module_path = ".".join(conf.GOOGLE_SSO_PRE_LOGIN_CALLBACK.split(".")[:-1])
145 | pre_login_fn = conf.GOOGLE_SSO_PRE_LOGIN_CALLBACK.split(".")[-1]
146 | module = importlib.import_module(module_path)
147 | getattr(module, pre_login_fn)(user, request)
148 |
149 | # Login User
150 | login(request, user, conf.GOOGLE_SSO_AUTHENTICATION_BACKEND)
151 | request.session.set_expiry(conf.GOOGLE_SSO_SESSION_COOKIE_AGE)
152 |
153 | return HttpResponseRedirect(next_url or reverse(conf.GOOGLE_SSO_NEXT_URL))
154 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 | 
71 |
--------------------------------------------------------------------------------
/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. 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, the login will fail, and you can see the message "State Mismatched. Time expired?" in the next time
14 | you log in again.
15 |
16 | 3. On callback, **Django-Google-SSO** will check `code` and `state` received. If they are valid,
17 | Google's UserInfo will be retrieved. If the user is already registered in Django, the user
18 | will be logged in.
19 |
20 | 4. Otherwise, the user will be created and logged in, if his email domain,
21 | matches one of the `GOOGLE_SSO_ALLOWABLE_DOMAINS`. You can disable the auto-creation setting `GOOGLE_SSO_AUTO_CREATE_USERS`
22 | to False.
23 |
24 | 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
25 | `GOOGLE_SSO_SUPERUSER_LIST` respectively. Please note if you add an email to one of these lists, the email domain
26 | must be added to `GOOGLE_SSO_ALLOWABLE_DOMAINS`too.
27 |
28 | 6. This authenticated session will expire in 1 hour, or the time defined, in seconds, in `GOOGLE_SSO_SESSION_COOKIE_AGE`.
29 |
30 | 7. If login fails, you will be redirected to route defined in `GOOGLE_SSO_LOGIN_FAILED_URL` (default: `admin:index`)
31 | which will use Django Messaging system to show the error message.
32 |
33 | 8. If login succeeds, the user will be redirected to the `next_path` saved in the anonymous session, or to the route
34 | defined in `GOOGLE_SSO_NEXT_URL` (default: `admin:index`) as a fallback.
35 |
--------------------------------------------------------------------------------
/docs/images/django-google-sso.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/megalus/django-google-sso/831a10c4361a9fa438355d54f1fbc7f417e48b54/docs/images/django-google-sso.png
--------------------------------------------------------------------------------
/docs/images/django_login_with_google_custom.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/megalus/django-google-sso/831a10c4361a9fa438355d54f1fbc7f417e48b54/docs/images/django_login_with_google_custom.png
--------------------------------------------------------------------------------
/docs/images/django_login_with_google_dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/megalus/django-google-sso/831a10c4361a9fa438355d54f1fbc7f417e48b54/docs/images/django_login_with_google_dark.png
--------------------------------------------------------------------------------
/docs/images/django_login_with_google_light.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/megalus/django-google-sso/831a10c4361a9fa438355d54f1fbc7f417e48b54/docs/images/django_login_with_google_light.png
--------------------------------------------------------------------------------
/docs/images/django_multiple_sso.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/megalus/django-google-sso/831a10c4361a9fa438355d54f1fbc7f417e48b54/docs/images/django_multiple_sso.png
--------------------------------------------------------------------------------
/docs/index.md:
--------------------------------------------------------------------------------
1 | 
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 | 
19 |
20 | === "Dark Mode"
21 | 
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 and 5.1
37 |
38 | For python 3.8 please use version 2.x
39 | For python 3.9 please use version 3.x
40 | For python 3.10 please use version 4.x
41 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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
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_github_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 | 
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 |
--------------------------------------------------------------------------------
/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 | 
82 |
83 | === "Dark Mode"
84 | 
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/settings.md:
--------------------------------------------------------------------------------
1 | # All Django Settings options
2 |
3 | | Setting | Description |
4 | |------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
5 | | `GOOGLE_SSO_ALLOWABLE_DOMAINS` | List of domains that will be allowed to create users. Default: `[]` |
6 | | `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` |
7 | | `GOOGLE_SSO_AUTHENTICATION_BACKEND` | The authentication backend to use. Default: `None` |
8 | | `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` |
9 | | `GOOGLE_SSO_AUTO_CREATE_USERS` | Enable or disable the auto-create users feature. Default: `True` |
10 | | `GOOGLE_SSO_CALLBACK_DOMAIN` | The netloc to be used on Callback URI. Default: `None` |
11 | | `GOOGLE_SSO_CLIENT_ID` | The Google OAuth 2.0 Web Application Client ID. Default: `None` |
12 | | `GOOGLE_SSO_CLIENT_SECRET` | The Google OAuth 2.0 Web Application Client Secret. Default: `None` |
13 | | `GOOGLE_SSO_DEFAULT_LOCALE` | Default code for Google locale. Default: `en` |
14 | | `GOOGLE_SSO_ENABLE_LOGS` | Show Logs from the library. Default: `True` |
15 | | `GOOGLE_SSO_ENABLE_MESSAGES` | Show Messages using Django Messages Framework. Default: `True` |
16 | | `GOOGLE_SSO_ENABLED` | Enable or disable the plugin. Default: `True` |
17 | | `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` |
18 | | `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` |
19 | | `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` |
20 | | `GOOGLE_SSO_PRE_CREATE_CALLBACK` | Callable for processing pre-create logic. Default: `django_google_sso.hooks.pre_create_user` |
21 | | `GOOGLE_SSO_PRE_LOGIN_CALLBACK` | Callable for processing pre-login logic. Default: `django_google_sso.hooks.pre_login_user` |
22 | | `GOOGLE_SSO_PRE_VALIDATE_CALLBACK` | Callable for processing pre-validate logic. Default: `django_google_sso.hooks.pre_validate_user` |
23 | | `GOOGLE_SSO_PROJECT_ID` | The Google OAuth 2.0 Project ID. Default: `None` |
24 | | `GOOGLE_SSO_SAVE_ACCESS_TOKEN` | Save the access token in the session. Default: `False` |
25 | | `GOOGLE_SSO_SAVE_BASIC_GOOGLE_INFO` | Save basic Google info in the database. Default: `True` |
26 | | `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"]` |
27 | | `GOOGLE_SSO_SESSION_COOKIE_AGE` | The age of the session cookie in seconds. Default: `3600` |
28 | | `GOOGLE_SSO_SHOW_FAILED_LOGIN_MESSAGE` | Show a message on browser when the user creation fails on database. Default: `False` |
29 | | `GOOGLE_SSO_STAFF_LIST` | List of emails that will be created as staff. Default: `[]` |
30 | | `GOOGLE_SSO_SUPERUSER_LIST` | List of emails that will be created as superuser. Default: `[]` |
31 | | `GOOGLE_SSO_TEXT` | The text to be used on the login button. Default: `Sign in with Google` |
32 | | `GOOGLE_SSO_TIMEOUT` | The timeout in seconds for the Google SSO authentication returns info, in minutes. Default: `10` |
33 | | `SSO_SHOW_FORM_ON_ADMIN_PAGE` | Show the form on the admin page. Default: `True` |
34 | | `SSO_USE_ALTERNATE_W003` | Use alternate W003 warning. You need to silence original templates.W003 warning. Default: `False` |
35 |
--------------------------------------------------------------------------------
/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/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 |