├── .flake8 ├── .gitattributes ├── .github ├── FUNDING.yml ├── copilot-instructions.md ├── pull_request_template.md └── workflows │ ├── publish.yml │ ├── stale.yml │ └── tests.yml ├── .gitignore ├── .junie └── guidelines.md ├── .pre-commit-config.yaml ├── .run ├── Migrate.run.xml ├── Run All Tests.run.xml ├── Run Example App.run.xml └── Serve Docs.run.xml ├── CHANGELOG.md ├── LICENSE ├── Makefile ├── README.md ├── django_google_sso ├── __init__.py ├── admin.py ├── apps.py ├── checks │ ├── __init__.py │ └── warnings.py ├── conf.py ├── hooks.py ├── main.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_alter_googlessouser_picture_url.py │ └── __init__.py ├── models.py ├── static │ └── django_google_sso │ │ └── google_button.css ├── templates │ └── google_sso │ │ ├── login.html │ │ └── login_sso.html ├── templatetags │ ├── __init__.py │ ├── show_form.py │ └── sso_tags.py ├── tests │ ├── __init__.py │ ├── conftest.py │ ├── test_conf.py │ ├── test_google_auth.py │ ├── test_models.py │ ├── test_user_helper.py │ └── test_views.py ├── urls.py ├── utils.py └── views.py ├── docs ├── admin.md ├── advanced.md ├── callback.md ├── credentials.md ├── customize.md ├── how.md ├── images │ ├── django-google-sso.png │ ├── django_login_with_google_custom.png │ ├── django_login_with_google_dark.png │ ├── django_login_with_google_light.png │ └── django_multiple_sso.png ├── index.md ├── model.md ├── multiple.md ├── quick_setup.md ├── settings.md ├── thanks.md ├── third_party_admins.md ├── troubleshooting.md ├── urls.md └── users.md ├── example_google_app ├── .env ├── .stela ├── README.md ├── __init__.py ├── asgi.py ├── backend.py ├── manage.py ├── settings.py ├── static │ └── django_google_sso │ │ ├── google_button_custom.css │ │ └── google_button_unfold.css ├── urls.py ├── views.py └── wsgi.py ├── mkdocs.yml ├── pyproject.toml └── pytest.ini /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 88 3 | extend-ignore = E203 4 | exclude = */site-packages/,.git 5 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [megalus] 4 | custom: ['https://www.buymeacoffee.com/megalus'] 5 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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@v4 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@v5 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@v9.10.0 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@v4 56 | with: 57 | fetch-depth: 0 58 | 59 | - name: Docs | Install MKDocs 60 | run: pip install mkdocs-material 61 | 62 | - name: Docs | Publish Docs 63 | run: mkdocs gh-deploy --force 64 | -------------------------------------------------------------------------------- /.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@v9 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 | only-labels: '' # Process only issues/PRs with these labels 26 | operations-per-run: 30 # Number of operations per run 27 | remove-stale-when-updated: true # Remove stale label when issue/PR is updated 28 | delete-branch: false # Delete the branch of closed PRs 29 | stale-issue-label: 'stale' # Label to mark stale issues 30 | stale-pr-label: 'stale' # Label to mark stale PRs 31 | -------------------------------------------------------------------------------- /.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@v4 13 | with: 14 | fetch-depth: 0 15 | - name: Setups Python 16 | uses: actions/setup-python@v5 17 | with: 18 | python-version: "3.12" 19 | - name: Setup Poetry 20 | uses: abatilo/actions-poetry@v3 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"] 31 | steps: 32 | - uses: actions/checkout@v4 33 | - name: Set up Python ${{ matrix.python-version }} 34 | uses: actions/setup-python@v5 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@v3 41 | - name: Install Project 42 | run: | 43 | poetry install 44 | poetry add django==${{ matrix.django-version }} 45 | - name: Run CI Tests 46 | run: | 47 | poetry run pytest 48 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 test`. 4 | 5 | Always use the latest version of `django`. 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 | Project python version is 3.11.10. 16 | 17 | This is a public python library hosted in PyPI. All configuration is inside `pyproject.toml` file. 18 | 19 | Use semantic versioning for commit messages. Create a one-line commit. Do not use "`". 20 | 21 | Use `feat:` if commit creates new code in both ./django_google_sso and unit tests. 22 | 23 | Use `fix:` if commit only changes the code inside ./django_google_sso. 24 | 25 | Use `chore:` if commit changes files outside ./django_google_sso. 26 | 27 | Use `ci:` if commit changes files only in pyproject.toml. 28 | 29 | Use `docs:` if commit changes files only in ./docs or the README. 30 | 31 | Use `refactor:` if commit changes files in ./django_google_sso but not in unit tests. 32 | 33 | Use `BREAKING CHANGE:` if commit changes the minimum version of Python or Django in pyproject.toml. 34 | 35 | Project versioning is done during GitHub actions `.github/publish.yml` workflow, using the [auto-changelog](https://github.com/KeNaCo/auto-changelog) library. 36 | 37 | Always update the README at the root of the project. 38 | 39 | README always contains [shields.io](https://shields.io/docs) badges for (when applicable): python versions, django versions, pypi version, license and build status. 40 | 41 | Prefer use mermaid diagrams on docs. 42 | 43 | Always use English on code and docs. 44 | 45 | The README always contains the minimal configuration for the library to work. 46 | 47 | 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. 48 | 49 | 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. 50 | 51 | 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 README.md and settings.py as a reference for how to use. 52 | 53 | Do not run terminal commands until Jetbrains fixes Junie support to WSL (JBAI-13074). 54 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v5.0.0 4 | hooks: 5 | - id: check-yaml 6 | - id: end-of-file-fixer 7 | - id: trailing-whitespace 8 | - id: check-ast 9 | - repo: https://github.com/Lucas-C/pre-commit-hooks-bandit 10 | rev: v1.0.6 11 | hooks: 12 | - id: python-bandit-vulnerability-check 13 | args: [ "-s", "B101,B105", "-r", "--exclude", "*/.venv/*", "." ] 14 | - repo: https://github.com/pycqa/isort 15 | rev: 5.13.2 16 | hooks: 17 | - id: isort 18 | name: isort (python) 19 | args: [ "--profile", "black", "--filter-files" ] 20 | - repo: https://github.com/psf/black 21 | rev: 24.10.0 22 | hooks: 23 | - id: black 24 | exclude: ^.*\b(migrations)\b.*$ 25 | - repo: https://github.com/Lucas-C/pre-commit-hooks-safety 26 | rev: v1.3.3 27 | hooks: 28 | - id: python-safety-dependencies-check 29 | # Ignoring vulnerabilities for local dev packages: localstack 30 | args: ["--ignore=42835,42836,42837"] 31 | - repo: https://github.com/asottile/yesqa 32 | rev: v1.5.0 33 | hooks: 34 | - id: yesqa 35 | - repo: https://github.com/PyCQA/flake8 36 | rev: 7.1.1 37 | hooks: 38 | - id: flake8 39 | args: ["--count", "--exclude", "*/migrations/*,.git,*/site-packages/*", "." ] 40 | - repo: https://github.com/myint/autoflake 41 | rev: v2.3.1 42 | hooks: 43 | - id: autoflake 44 | args: [ "--in-place", "--remove-all-unused-imports", "--remove-duplicate-keys" ] 45 | -------------------------------------------------------------------------------- /.run/Migrate.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 29 | 30 | -------------------------------------------------------------------------------- /.run/Run All Tests.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 24 | 25 | -------------------------------------------------------------------------------- /.run/Run Example App.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 32 | 33 | -------------------------------------------------------------------------------- /.run/Serve Docs.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 31 | 32 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | 4 | ## v8.0.0 (2024-10-09) 5 | 6 | ### Breaking 7 | 8 | * ci!: bump version 9 | 10 | BREAKING CHANGE commit ([`4cef07a`](https://github.com/megalus/django-google-sso/commit/4cef07acf3f659d166ac3bccfd92c0a7ce4404bb)) 11 | 12 | ### Documentation 13 | 14 | * docs: update docs. 15 | 16 | Closes #41 ([`6b5da42`](https://github.com/megalus/django-google-sso/commit/6b5da421d6b412c4b8abcd9741e43943ff30c6e2)) 17 | 18 | * docs: update docs ([`9393cbb`](https://github.com/megalus/django-google-sso/commit/9393cbb7cf955828c6b6252e9c0677f8316af749)) 19 | 20 | * docs: update docs ([`d1de7d5`](https://github.com/megalus/django-google-sso/commit/d1de7d5d3444b70a6bdd884a432ebc99327fd3a8)) 21 | 22 | ### Unknown 23 | 24 | * Add support to 3.13 (#47) 25 | 26 | * Allow "*" in GOOGLE_SSO_ALLOWABLE_DOMAINS 27 | 28 | * feat!: Add support to Python to 3.13 and remove 3.10 29 | 30 | This is a BREAKING CHANGE commit 31 | 32 | * chore: fix unit tests 33 | 34 | * chore: fix line breaks 35 | 36 | * chore: fix github actions 37 | 38 | --------- 39 | 40 | Co-authored-by: Pavel Mises ([`0e6b547`](https://github.com/megalus/django-google-sso/commit/0e6b5474b1decfe3fba4bf6e903f4f60f05ea80c)) 41 | 42 | 43 | ## v7.1.0 (2024-09-17) 44 | 45 | ### Features 46 | 47 | * feat: Add new option GOOGLE_SSO_SHOW_FAILED_LOGIN_MESSAGE 48 | 49 | Also failed attempts to create user in database will be logged on terminal ([`e53accb`](https://github.com/megalus/django-google-sso/commit/e53accba4b3bb9046bbeb68aa26f23d345091c38)) 50 | 51 | 52 | ## v7.0.0 (2024-08-29) 53 | 54 | ### Breaking 55 | 56 | * feat!: Remove support for Django 4.1 and add support to 5.1 57 | 58 | This is a BREAKING CHANGE commit. ([`6e7573a`](https://github.com/megalus/django-google-sso/commit/6e7573a0acee58e9bdcd13ac60c389435dc314ba)) 59 | 60 | ### Documentation 61 | 62 | * docs: small fixes ([`5219a90`](https://github.com/megalus/django-google-sso/commit/5219a90717f3700c93b116b4285260d9a8a0e5d4)) 63 | 64 | 65 | ## v6.5.0 (2024-08-29) 66 | 67 | ### Documentation 68 | 69 | * docs: update settings.md ([`d0f3073`](https://github.com/megalus/django-google-sso/commit/d0f3073dae2117ccd935f4083a3decf039a85db1)) 70 | 71 | ### Features 72 | 73 | * feat: add `GOOGLE_SSO_PRE_VALIDATE_CALLBACK` option. 74 | 75 | Fixes #43 ([`8163b93`](https://github.com/megalus/django-google-sso/commit/8163b93cf8950a255daaea764c87d1c98237eab4)) 76 | 77 | 78 | ## v6.4.0 (2024-08-13) 79 | 80 | ### Documentation 81 | 82 | * docs: update settings.md with current logo path ([`fa85eb8`](https://github.com/megalus/django-google-sso/commit/fa85eb8edd2f58959d291a06acf40326aa80a5e1)) 83 | 84 | * docs: update Django version numbers to match implementation ([`a732f9d`](https://github.com/megalus/django-google-sso/commit/a732f9dc97794699249d3ab944d6e36b8a633f75)) 85 | 86 | ### Features 87 | 88 | * feat: add new option GOOGLE_SSO_SAVE_BASIC_GOOGLE_INFO. 89 | 90 | When set to False, the GoogleSSOUser model will not be created or updated for the logged user. Default is True. 91 | 92 | Fixes #39 ([`6a5a4d4`](https://github.com/megalus/django-google-sso/commit/6a5a4d4d90ab953c25b52058a4466702e59e65a6)) 93 | 94 | ### Unknown 95 | 96 | * Merge pull request #40 from paulschreiber/main 97 | 98 | docs: update settings.md with current logo path ([`177782d`](https://github.com/megalus/django-google-sso/commit/177782d05bedf1ffd5ff53724f909d1e0b1c9258)) 99 | 100 | * Merge pull request #42 from paulschreiber/fix/docs 101 | 102 | docs: update Django version numbers to match implementation ([`27e13dc`](https://github.com/megalus/django-google-sso/commit/27e13dc9cc2ecf98afe3888117aabe051965a750)) 103 | 104 | 105 | ## v6.3.0 (2024-07-31) 106 | 107 | ### Features 108 | 109 | * feat: add option to add all created users as staff 110 | 111 | Also fix bugs when reading upper and lower case emails ([`1a7b3ab`](https://github.com/megalus/django-google-sso/commit/1a7b3abc787126679db2bcc005ed45aac16381a9)) 112 | 113 | 114 | ## v6.2.1 (2024-06-07) 115 | 116 | ### Fixes 117 | 118 | * fix: Add support to Django USERNAME_FIELD ([`b62f560`](https://github.com/megalus/django-google-sso/commit/b62f560255b4662c2816b9d8b5a3b6b12eb73762)) 119 | 120 | ### Unknown 121 | 122 | * Merge pull request #36 from megalus/develop 123 | 124 | develop ([`13984de`](https://github.com/megalus/django-google-sso/commit/13984dea3e550fe96e9de05ef6d501f18f46c4ae)) 125 | 126 | * Merge pull request #35 from AndrewGrossman/main 127 | 128 | Handle user model w/o username field ([`2663dcb`](https://github.com/megalus/django-google-sso/commit/2663dcb93252b78605cab7b0f0367a1d281049eb)) 129 | 130 | * Handle user model w/o username field 131 | 132 | Encountered this in a project based upon Django Cookie Cutter, where the 133 | username field is not present. This change avoids trying to pass 134 | "username" as a default to the user creation if it is not present, 135 | avoiding the error that would otherwise result. ([`c0cb6a5`](https://github.com/megalus/django-google-sso/commit/c0cb6a58d23165764beb86c10f374ec539b8d910)) 136 | 137 | 138 | ## v6.2.0 (2024-04-23) 139 | 140 | ### Features 141 | 142 | * feat: add more control on messaging 143 | 144 | Use the `GOOGLE_SSO_ENABLE_LOGS` to enable/disable logs. Logs now will show all info send to django messages. 145 | 146 | Use the `GOOGLE_SSO_ENABLE_MESSAGES` to enable/disable django messages. ([`c718030`](https://github.com/megalus/django-google-sso/commit/c7180305e05b6d146e6369e4635cebee9de17a57)) 147 | 148 | 149 | ## v6.1.1 (2024-04-09) 150 | 151 | ### Fixes 152 | 153 | * fix: add token in request before call pre-create callback ([`2bf6467`](https://github.com/megalus/django-google-sso/commit/2bf6467c948ded6fc98df1e0d07ec84d4a9ffa0c)) 154 | 155 | 156 | ## v6.1.0 (2024-04-09) 157 | 158 | ### Features 159 | 160 | * feat: Add support to custom attributes in User model before creation. 161 | 162 | Use the `GOOGLE_SSO_PRE_CREATE_CALLBACK` to define a custom function which can return a dictionary which will be used during user creation for the `defaults` value. ([`cc9ad6a`](https://github.com/megalus/django-google-sso/commit/cc9ad6a6dc5263f6c9efd139102bd9070962d97a)) 163 | 164 | 165 | ## v6.0.2 (2024-03-14) 166 | 167 | ### Continuous Integration 168 | 169 | * ci: add stale bot for github ([`e4f457e`](https://github.com/megalus/django-google-sso/commit/e4f457e1efcf129ddd7be70bff39572daab605e5)) 170 | 171 | ### Fixes 172 | 173 | * fix: error when field `locale` was not available on Google API response. 174 | 175 | If you need to define a default value for this field, please use the `GOOGLE_SSO_DEFAULT_LOCALE` option. 176 | 177 | Also make these fields optional: `given_name`, `given_name` and `picture` 178 | 179 | Resolves #31 ([`646d986`](https://github.com/megalus/django-google-sso/commit/646d986d609aba7acc4f0df0ea0f6136a4fe7747)) 180 | 181 | ### Unknown 182 | 183 | * Merge pull request #32 from megalus/develop 184 | 185 | Fix missing field locale in Google Response ([`05a3383`](https://github.com/megalus/django-google-sso/commit/05a33838bfe6f4e3febd83d9b75d54fc824e2d8b)) 186 | 187 | 188 | ## v6.0.1 (2024-03-12) 189 | 190 | ### Chores 191 | 192 | * chore: Refactor UserHelper ([`acdf98d`](https://github.com/megalus/django-google-sso/commit/acdf98dee95285afe4aa02c38d53379e47098666)) 193 | 194 | ### Fixes 195 | 196 | * fix: Bump version ([`59907fd`](https://github.com/megalus/django-google-sso/commit/59907fdb0234e73ffb498b7af5ebce3e36055c94)) 197 | 198 | ### Unknown 199 | 200 | * Merge pull request #30 from megalus/develop 201 | 202 | New Release ([`c95154d`](https://github.com/megalus/django-google-sso/commit/c95154d8b13bd5fb227f323ad7fa1565beea4148)) 203 | 204 | * Merge pull request #29 from Anexen/fix/empty-username 205 | 206 | Fix an issue with empty username when user creation disrupted ([`2830f8c`](https://github.com/megalus/django-google-sso/commit/2830f8c18a290a5487611a1710452ea73db56636)) 207 | 208 | * fix an issue with empty username when user creation failed ([`6f1121e`](https://github.com/megalus/django-google-sso/commit/6f1121e5164b5bd3557ded6c66a10a79ec8565e0)) 209 | 210 | 211 | ## v6.0.0 (2024-03-12) 212 | 213 | ### Breaking 214 | 215 | * feat!: Add basic support to custom login templates. 216 | 217 | Rework the login.html and login_sso.html to simplify login template customization. The use case is the [Django Unfold](https://github.com/unfoldadmin/django-unfold) package. This is a BREAKING CHANGE for the static and html files. 218 | 219 | Also: 220 | * Remove pytest-lazy-fixture to upgrade pytest to latest version ([`75d979f`](https://github.com/megalus/django-google-sso/commit/75d979f2999dc77665aeb6cec64bd2c9c8a7c16d)) 221 | 222 | ### Documentation 223 | 224 | * docs: Better Stela use. 225 | 226 | Also add missing tests in GitHub Actions ([`f34152f`](https://github.com/megalus/django-google-sso/commit/f34152ffde5a9eb1eb31a3983e1eba6e840838a0)) 227 | 228 | 229 | ## v5.0.0 (2023-12-20) 230 | 231 | ### Breaking 232 | 233 | * feat!: New version 234 | 235 | BREAKING CHANGE: 236 | * Remove Django 4.1 support 237 | * Add Django 5.0 support 238 | * Fix `SSO_USE_ALTERNATE_W003` bug 239 | * Fix several CSS issues with custom logo images 240 | * Update docs ([`dc3560c`](https://github.com/megalus/django-google-sso/commit/dc3560c12398037f289dc1e8b08d4c8d40b7577a)) 241 | 242 | 243 | ## v4.0.0 (2023-11-23) 244 | 245 | ### Breaking 246 | 247 | * feat!: v4.0 248 | 249 | BREAKING CHANGE: New changes: 250 | 251 | * Drop Python 3.9 support 252 | * Add Python 3.12 support 253 | * Add compatibility with multiple django-sso packages (ie. django-microsoft-sso) 254 | * Renamed option GOOGLE_SSO_SHOW_FORM_ON_ADMIN_PAGE to SSO_SHOW_FORM_ON_ADMIN_PAGE 255 | * Fix default google icon logo 404 error ([`7212d2c`](https://github.com/megalus/django-google-sso/commit/7212d2c30a25710a507ed26e5640284bd8e87486)) 256 | 257 | ### Unknown 258 | 259 | * Revert "ci: fix permission in actions" 260 | 261 | This reverts commit bad8be733943f4a2bc03f07f74736f9b824993e9. ([`22f87bc`](https://github.com/megalus/django-google-sso/commit/22f87bcfd56d4a2158da45ccef8c459e0e619a80)) 262 | 263 | 264 | ## v3.3.0 (2023-09-27) 265 | 266 | ### Continuous Integration 267 | 268 | * ci: fix permission in actions ([`bad8be7`](https://github.com/megalus/django-google-sso/commit/bad8be733943f4a2bc03f07f74736f9b824993e9)) 269 | 270 | * ci: add permissions ([`3189773`](https://github.com/megalus/django-google-sso/commit/31897733fe3bda39fe2f011d1d941d50d632d563)) 271 | 272 | ### Documentation 273 | 274 | * docs: update example in docs ([`0db95f8`](https://github.com/megalus/django-google-sso/commit/0db95f8388329fc7937b1e10299c74fb7ba2960a)) 275 | 276 | * docs: better docs ([`2b3e3cb`](https://github.com/megalus/django-google-sso/commit/2b3e3cb3e72a9e6f4ec2b3a03660439b8a83139d)) 277 | 278 | ### Features 279 | 280 | * feat: Add GOOGLE_SSO_SHOW_FORM_ON_ADMIN_PAGE option. 281 | 282 | This commit adds the missing logic and documentation for this new option. ([`efc33cd`](https://github.com/megalus/django-google-sso/commit/efc33cd418e3a89044687c40788d195abc4a38a8)) 283 | 284 | ### Unknown 285 | 286 | * Merge pull request #27 from megalus/develop 287 | 288 | v3.3 ([`2d79094`](https://github.com/megalus/django-google-sso/commit/2d79094c1472f72ecd9c8c86bdb1e6251970f972)) 289 | 290 | * Merge pull request #26 from jnoring/optionally-hide-login 291 | 292 | Optionally hide the login boxes on the admin page ([`76cde44`](https://github.com/megalus/django-google-sso/commit/76cde44d0cfaf8c10badc95063412d543a8bcff3)) 293 | 294 | * Optionally hide the login boxes on the admin page 295 | 296 | For my site, I want to manage access to google admin _only_ through 297 | SSO; I don't even want to expose the login boxes to anyone navigating 298 | to the admin site. Add an optional setting to hide the standard 299 | login (defaults to "show" for backwards compatibility) ([`734bf45`](https://github.com/megalus/django-google-sso/commit/734bf45d517c1f490ca8d357a2d5799f520921dd)) 300 | 301 | 302 | ## v3.2.0 (2023-09-19) 303 | 304 | ### Documentation 305 | 306 | * docs: update example code in admin.md ([`4c38551`](https://github.com/megalus/django-google-sso/commit/4c38551647b45ff55872929230ac8c8697bab137)) 307 | 308 | ### Features 309 | 310 | * feat: Add GOOGLE_SSO_ALWAYS_UPDATE_USER_DATA option 311 | 312 | * Add logic help use Google as Single Source of Truth (thanks @ckoppelman) 313 | 314 | The update introduces token handling for Google users within the session and implements Pre-Login Callback functionality within the example app's backend. This allows for execution of custom code post-user authentication, including updating user information using Google user token. This enhances customizability and user information update capabilities. The example code and documentation have been updated accordingly. 315 | 316 | Resolves #23 ([`a106fcf`](https://github.com/megalus/django-google-sso/commit/a106fcf166b1463ddf159112ffa0d43ee999868f)) 317 | 318 | ### Unknown 319 | 320 | * Merge pull request #25 from megalus/develop 321 | 322 | v 3.2 ([`ee64dc6`](https://github.com/megalus/django-google-sso/commit/ee64dc61ee83e034124fbcd346c05124fb8f634e)) 323 | 324 | * Merge pull request #24 from ckoppelman/always-update-user-data 325 | 326 | Always update user data ([`fdfcaa8`](https://github.com/megalus/django-google-sso/commit/fdfcaa8a26b74f4ed59d09ba8dbfbbfea4575495)) 327 | 328 | * Create setting to always update user data from Google on login. ([`6a80fe2`](https://github.com/megalus/django-google-sso/commit/6a80fe20b0a59f8da179acd61c5c420edeb44868)) 329 | 330 | * Exclude .venv and site-packages from pre-commit hooks to allow commits ([`84f2b7a`](https://github.com/megalus/django-google-sso/commit/84f2b7a24f62cbc571f9a088c4d05a9508dc1e86)) 331 | 332 | 333 | ## v3.1.0 (2023-08-16) 334 | 335 | ### Features 336 | 337 | * feat: Add option to save access token 338 | 339 | This commit introduces functionality to save the Google SSO access token to the user's session via a new configuration parameter (`GOOGLE_SSO_SAVE_ACCESS_TOKEN`), providing finer control over logout functionality. Default value for this parameter is `False`. ([`cd23c76`](https://github.com/megalus/django-google-sso/commit/cd23c76010a589e0bbe56e4bbab673668394e378)) 340 | 341 | * feat: Add new configuration parameters and fix bugs 342 | 343 | * Add configuration GOOGLE_SSO_LOGIN_FAILED_URL to finetune redirection after login failed. Thanks @simook 344 | * Add configuration GOOGLE_SSO_NEXT_URL to finetune redirection after login succeeds. Thanks @simook 345 | * Fix error when adding Google SSO inline to User Admin. Thanks @jnoring 346 | * Update Github Actions ([`3b26037`](https://github.com/megalus/django-google-sso/commit/3b26037c1ebe549d10b4a25533d5ed72ad4e4c99)) 347 | 348 | ### Unknown 349 | 350 | * Merge pull request #21 from megalus/develop 351 | 352 | Version 3.1 ([`0ab66ac`](https://github.com/megalus/django-google-sso/commit/0ab66acf2a43fdd1f2a40fd9300b602388a24bcd)) 353 | 354 | * Merge pull request #20 from simook/main 355 | 356 | Fixed a TypeError and added a configuration parameter. ([`8abd39f`](https://github.com/megalus/django-google-sso/commit/8abd39f1d5ff07026859ccf25cf664e0b05a7377)) 357 | 358 | * Added a configuration parameter that defines the URL used in the callback method. ([`f9d9f8b`](https://github.com/megalus/django-google-sso/commit/f9d9f8b3ec6cc94753057f0a75778c0f44d0d116)) 359 | 360 | * Fixed a TypeError when the next query param is absent. ([`b6fed53`](https://github.com/megalus/django-google-sso/commit/b6fed534db87765465cce81df7e299502b97cf1b)) 361 | 362 | * Merge pull request #19 from jnoring/patch-1 363 | 364 | Update admin.py ([`62c8b27`](https://github.com/megalus/django-google-sso/commit/62c8b27feccdd31691d5898884b63829be40c427)) 365 | 366 | * Update admin.py 367 | 368 | This needs to ultimately be a list or a tuple--not a set. ([`eb79550`](https://github.com/megalus/django-google-sso/commit/eb7955033fd43ce48d7cdcc7e3bed439e17cf8d3)) 369 | 370 | 371 | ## v3.0.0 (2023-04-19) 372 | 373 | ### Breaking 374 | 375 | * feat!: version 3.0 376 | 377 | BREAKING CHANGE: 378 | 379 | * Drop support to Django 3.2 380 | * Rework code to make compatible with future login projects 381 | * Add fully customizable button ([`24bfb2e`](https://github.com/megalus/django-google-sso/commit/24bfb2e5849f3a637de68193506c3a943fcdbba7)) 382 | 383 | ### Chores 384 | 385 | * chore: fix unit tests ([`f3d7d92`](https://github.com/megalus/django-google-sso/commit/f3d7d92bff9a67184a02b9b7b1bdbaff15389d08)) 386 | 387 | * chore: fix example images ([`617eb77`](https://github.com/megalus/django-google-sso/commit/617eb778e6b253d1d642fe50c9b4b1609706a428)) 388 | 389 | * chore: add missing image and fix pycharm scripts ([`2fe5c17`](https://github.com/megalus/django-google-sso/commit/2fe5c1776988a9a5956549edb37516c05dad06cc)) 390 | 391 | ### Documentation 392 | 393 | * docs: fix typo ([`08c782d`](https://github.com/megalus/django-google-sso/commit/08c782df65519afac67d4662f568b3b3841b26c2)) 394 | 395 | * docs: update docs ([`a2ef5b7`](https://github.com/megalus/django-google-sso/commit/a2ef5b7026a84e971b6a1b1bf3544e53c7bf60e5)) 396 | 397 | ### Unknown 398 | 399 | * Merge pull request #16 from megalus/develop 400 | 401 | Version 3.0 ([`cdc38ab`](https://github.com/megalus/django-google-sso/commit/cdc38ab07a99780bf08e1298c70e8ba12340fa40)) 402 | 403 | * Merge pull request #15 from jhhayashi/jhh/user-admin-monkeypatch 404 | 405 | Use the existing UserAdmin model if it's already registered ([`6aece66`](https://github.com/megalus/django-google-sso/commit/6aece668a184f964d42e737a5bb25d5dc261164f)) 406 | 407 | * Use the existing UserAdmin model if it's already registered ([`dc6bbfa`](https://github.com/megalus/django-google-sso/commit/dc6bbfa39cacbc5ad7baf99691a5f59d3a4e46e2)) 408 | 409 | 410 | ## v2.5.0 (2023-04-05) 411 | 412 | ### Chores 413 | 414 | * chore: add missing Optional ([`a80cd75`](https://github.com/megalus/django-google-sso/commit/a80cd75716375ffd713e7bd9ffaab9225df1535e)) 415 | 416 | ### Continuous Integration 417 | 418 | * ci: add .env ([`f2582a5`](https://github.com/megalus/django-google-sso/commit/f2582a58dc59f222397169a03e6d49aacfba51f6)) 419 | 420 | * ci: persist credentials ([`a8364ea`](https://github.com/megalus/django-google-sso/commit/a8364ea3676a59e795e0ba286eff2868fd8c15d3)) 421 | 422 | * ci: fix action version ([`46d3d6c`](https://github.com/megalus/django-google-sso/commit/46d3d6cae8091ea6423d75194b2dd98e489a1649)) 423 | 424 | * ci: move repository to megalus organization 425 | 426 | Resolves DGS-2 ([`dfeaa54`](https://github.com/megalus/django-google-sso/commit/dfeaa547fc1abd5f276cd37513a9ea874c12699c)) 427 | 428 | ### Documentation 429 | 430 | * docs: update Stela example ([`6ff6676`](https://github.com/megalus/django-google-sso/commit/6ff6676b04729ef8d2687ee7f54bd31e68cba3a0)) 431 | 432 | * docs: improve documentation ([`131f1b1`](https://github.com/megalus/django-google-sso/commit/131f1b10a1398cc17a8eec95feb571de3cf6a0c8)) 433 | 434 | ### Features 435 | 436 | * feat: Update to Django 4.2 437 | 438 | This is a BREAKING CHANGE: drop support to python 3.8 439 | 440 | * Update documentation 441 | * Remove Python 3.8 442 | 443 | Resolves DGS-4 ([`677a5da`](https://github.com/megalus/django-google-sso/commit/677a5da8c4d0815595e4aaa72c363f8feb409a93)) 444 | 445 | ### Unknown 446 | 447 | * Merge pull request #14 from megalus/feat/DGS-4 448 | 449 | Add Django 4.2 ([`96568a9`](https://github.com/megalus/django-google-sso/commit/96568a905a2ddc9936a7649a40fa91d8a086a7ed)) 450 | 451 | 452 | ## v2.4.1 (2023-02-25) 453 | 454 | ### Continuous Integration 455 | 456 | * ci: github action update ([`7504bbc`](https://github.com/megalus/django-google-sso/commit/7504bbcb18dea18e5b10486b3f103c9edc845dca)) 457 | 458 | ### Fixes 459 | 460 | * fix: UserManager error when GOOGLE_SSO_AUTO_CREATE_USERS is set to False ([`4451c6b`](https://github.com/megalus/django-google-sso/commit/4451c6bf228e29cba14b11fd6ee17d9f2089cefd)) 461 | 462 | * fix(docs/how.md): add missing S with GOOGLE_SSO_AUTO_CREATE_USERS ([`3e9b661`](https://github.com/megalus/django-google-sso/commit/3e9b661eaec4693541b92f85de65129f18bc3fe2)) 463 | 464 | ### Unknown 465 | 466 | * Merge pull request #13 from chrismaille/develop 467 | 468 | Fix GOOGLE_SSO_AUTO_CREATE_USERS issues ([`9d54638`](https://github.com/megalus/django-google-sso/commit/9d546380d408a3aef652a37afae6dbac6bbf1618)) 469 | 470 | * Merge pull request #11 from blueyed/doc-typo 471 | 472 | fix(docs/how.md): add missing S with GOOGLE_SSO_AUTO_CREATE_USERS ([`72f08b7`](https://github.com/megalus/django-google-sso/commit/72f08b75197ed4af0cdcde5026be113c1a0c005c)) 473 | 474 | 475 | ## v2.4.0 (2023-01-23) 476 | 477 | ### Features 478 | 479 | * feat: Add GOOGLE_SSO_PRE_LOGIN_CALLBACK feature 480 | 481 | Resolves #10 ([`44ade37`](https://github.com/megalus/django-google-sso/commit/44ade37ce4f65a530562da4edbdc4c5d122d9f85)) 482 | 483 | 484 | ## v2.3.1 (2023-01-18) 485 | 486 | ### Fixes 487 | 488 | * fix: small fixes 489 | 490 | * typo in environments variables (thanks @ciodaro) 491 | * update github action dependencies ([`1ec44cc`](https://github.com/megalus/django-google-sso/commit/1ec44cc5f6080e8de67a0548b3af647ba96cc262)) 492 | 493 | ### Unknown 494 | 495 | * Merge pull request #9 from ciodaro/fix/settings-var-name 496 | 497 | Fixing settings var name from GOGGLE to GOOGLE ([`285ed2b`](https://github.com/megalus/django-google-sso/commit/285ed2b6e18540c5b3fc1b1d464e7890b3fbdc1c)) 498 | 499 | * Fixing settings var name from GOGGLE to GOOGLE ([`7424d1b`](https://github.com/megalus/django-google-sso/commit/7424d1be12f91893ae5cff88bed5a174a2990f4c)) 500 | 501 | 502 | ## v2.3.0 (2022-10-28) 503 | 504 | ### Chores 505 | 506 | * chore: fix python version in actions ([`aa73aa4`](https://github.com/megalus/django-google-sso/commit/aa73aa4e4d5e7265fef695d402860300725cf1c2)) 507 | 508 | * chore: fix docs ([`9a8f841`](https://github.com/megalus/django-google-sso/commit/9a8f841d4f18dcbe4914961cf800eaca103e1e2a)) 509 | 510 | ### Continuous Integration 511 | 512 | * ci: fix poetry add command ([`606fe69`](https://github.com/megalus/django-google-sso/commit/606fe699355ae543bea39183d8d4f77f4d7d8534)) 513 | 514 | * ci: force min version for click ([`0438b1a`](https://github.com/megalus/django-google-sso/commit/0438b1aa59221ec8fcb4cc73b544cbc57a787475)) 515 | 516 | * ci: fix correct numbers ([`6e4ceaf`](https://github.com/megalus/django-google-sso/commit/6e4ceafb6e275bb1b7fc639753b45a668a655cf4)) 517 | 518 | * ci: update github actions versions ([`751fe5b`](https://github.com/megalus/django-google-sso/commit/751fe5b95cae2138ee347f45596739e3d97e2e71)) 519 | 520 | ### Features 521 | 522 | * feat: release 2.3.0 523 | 524 | ### New features 525 | 526 | * Add Python 3.11 support 527 | * Add Django 4.1 support 528 | 529 | ### New settings options: 530 | 531 | * GOOGLE_SSO_AUTHENTICATION_BACKEND: set up a custom authentication backend to log in the Google SSO users. Thanks @savionak. Resolves #4 532 | * GOOGLE_SSO_AUTO_CREATE_USERS: toggle auto-create users 533 | 534 | ### New documentation: 535 | 536 | Full documentation is now hosted on https://chrismaille.github.io/django-google-sso/ 537 | 538 | ### Fix bugs 539 | 540 | * Fix error when anonymous request session is not created. Resolves #3 541 | * Fix error when Google Picture URL is larger than 255 characters. We accept up to 2,000 characters now. Resolves #6 ([`8ef3b04`](https://github.com/megalus/django-google-sso/commit/8ef3b04e2c096338c4b92126ebbf4f6cfac0d208)) 542 | 543 | * feat: Add new settings option GOOGLE_SSO_AUTHENTICATION_BACKEND 544 | 545 | Use this option if you have multiple authentication backends to select one. ([`4212782`](https://github.com/megalus/django-google-sso/commit/4212782eae4c1400e1d9634b79df83f4a5d36f3d)) 546 | 547 | ### Unknown 548 | 549 | * Merge pull request #8 from chrismaille/develop 550 | 551 | New Release 2.3.0 ([`85571ba`](https://github.com/megalus/django-google-sso/commit/85571ba22fd90bcb10dd6ac43285696b430e5f8c)) 552 | 553 | * Merge pull request #7 from chrismaille/update_python_version 554 | 555 | New release ([`81612e5`](https://github.com/megalus/django-google-sso/commit/81612e559b59331211f39e1862637be238cd3358)) 556 | 557 | * Merge pull request #5 from savionak/multiple-backend-fix 558 | 559 | feat: Add new settings option GOOGLE_SSO_AUTHENTICATION_BACKEND ([`125012b`](https://github.com/megalus/django-google-sso/commit/125012b9bea7c3b774a34728f57b76afb18c90c2)) 560 | 561 | * Create FUNDING.yml ([`5b6f2f6`](https://github.com/megalus/django-google-sso/commit/5b6f2f6c6ae944a9361f0ec6314267f9101ed2d1)) 562 | 563 | 564 | ## v2.2.0 (2022-09-06) 565 | 566 | ### Features 567 | 568 | * feat: Make Sites Framework optional 569 | 570 | To define the callback netloc, you can simply use the GOOGLE_SSO_CALLBACK_DOMAIN settings. Please make not if you use both, the value on GOOGLE_SSO_CALLBACK_DOMAIN will prevail. ([`e5a3839`](https://github.com/megalus/django-google-sso/commit/e5a38395b68ca4614b67cc5868c5adfd2a504f82)) 571 | 572 | 573 | ## v2.1.0 (2022-09-02) 574 | 575 | ### Features 576 | 577 | * feat: Add new settings option GOOGLE_SSO_CALLBACK_DOMAIN 578 | 579 | Use this option if you can't or don't want to use Django Sites Framework to determine which domain will be used to generate Django SSO Callback URL. 580 | 581 | For example, if you set `GOOGLE_SSO_CALLBACK_DOMAIN="my-other-domain.com"`, you callback url will be `https://my-other-domain.com/google_sso/callback/` ([`4b49059`](https://github.com/megalus/django-google-sso/commit/4b490596a0e2efc47f3067628bb939d832da5ae5)) 582 | 583 | 584 | ## v2.0.0 (2022-02-23) 585 | 586 | ### Breaking 587 | 588 | * feat: Add django 4 support 589 | 590 | BREAKING CHANGE: update tests and example app ([`dcb5f9f`](https://github.com/megalus/django-google-sso/commit/dcb5f9ff2329e54f38985cfb2eb1c0edd06ebf5a)) 591 | 592 | ### Unknown 593 | 594 | * Merge pull request #1 from chrismaille/2.0 595 | 596 | Version 2.0 ([`11639a8`](https://github.com/megalus/django-google-sso/commit/11639a8a8766e7ecb6d1124389dc9a2a6fcc2694)) 597 | 598 | 599 | ## v1.0.2 (2022-02-23) 600 | 601 | ### Continuous Integration 602 | 603 | * ci: update actions logic ([`94d8947`](https://github.com/megalus/django-google-sso/commit/94d8947b7105059cc871ae52fe3fd5c1a8149b1b)) 604 | 605 | ### Fixes 606 | 607 | * fix: change license to MIT ([`750f979`](https://github.com/megalus/django-google-sso/commit/750f9791dcc7057359da08b69774515b63a3578d)) 608 | 609 | 610 | ## v1.0.1 (2021-11-23) 611 | 612 | ### Documentation 613 | 614 | * docs: Add `login_required` use example and add Django Classifiers ([`fccc7b6`](https://github.com/megalus/django-google-sso/commit/fccc7b62174a2898e93a0ad483ffe014884b538c)) 615 | 616 | * docs: Update README.md ([`c2e6c3b`](https://github.com/megalus/django-google-sso/commit/c2e6c3b17388f9ac7d5442f3d780cc2859071afd)) 617 | 618 | ### Fixes 619 | 620 | * fix: Update Django Classifiers ([`17664cb`](https://github.com/megalus/django-google-sso/commit/17664cb89430f2be730b859a3d5926acb708300c)) 621 | 622 | 623 | ## v1.0.0 (2021-11-22) 624 | 625 | ### Breaking 626 | 627 | * feat!: First Release 628 | 629 | BREAKING CHANGE: This is version 1.0. To find additional information please check README.md file. ([`54d979f`](https://github.com/megalus/django-google-sso/commit/54d979f06c76f6985483d642823f85c006776b19)) 630 | 631 | 632 | ## v0.2.1 (2021-11-20) 633 | 634 | ### Continuous Integration 635 | 636 | * ci: fix bad reverse url at import module level ([`50fc39f`](https://github.com/megalus/django-google-sso/commit/50fc39f2a7223b2100a8d381e08ecbf496d008d1)) 637 | 638 | * ci: add missing pytest lib ([`afed6d5`](https://github.com/megalus/django-google-sso/commit/afed6d526b6ee7cbbed01fe074d314726577f887)) 639 | 640 | * ci: update poetry version ([`7252ef6`](https://github.com/megalus/django-google-sso/commit/7252ef65c8b6947e704821732e9ca08c26ca631a)) 641 | 642 | * ci: fix test versions ([`22544f3`](https://github.com/megalus/django-google-sso/commit/22544f30dca4c7f382ab41ad53d531e801a99c41)) 643 | 644 | ### Features 645 | 646 | * feat: Add alpha version ([`98c78e5`](https://github.com/megalus/django-google-sso/commit/98c78e589016948f352c67849e36d937c455456e)) 647 | 648 | ### Fixes 649 | 650 | * fix: unit test ([`220920c`](https://github.com/megalus/django-google-sso/commit/220920cef5913bd24e78fe4da379b66b037078df)) 651 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | install: 2 | @poetry install 3 | @poetry run pre-commit install -f 4 | 5 | test: 6 | @poetry run pytest -v -x -p no:warnings --cov-report term-missing --cov=./django_google_sso 7 | 8 | ci: 9 | @poetry run pytest --cov=./django_google_sso 10 | 11 | format: 12 | @poetry run black . 13 | 14 | pre-commit: 15 | @poetry run pre-commit run --all 16 | 17 | ############################# 18 | # SONAR COMMANDS # 19 | ############################# 20 | bandit-report: 21 | bandit --exit-zero --ignore-nosec -s B101 -x **/tests/**,**/venv/** --format json --output ./bandit-report.json --recursive . 22 | 23 | dep-check-report: 24 | dependency-check -s . --exclude "**/__pycache__/**" -f HTML -f JSON 25 | 26 | flake8-report: 27 | flake8 --exit-zero --output-file=flake8-report.json . 28 | 29 | # Let test fail here, we will check the report. 30 | pytest-report: 31 | @rm -rf .coverage 32 | @pytest --cov=. --ignore=migrations --cov-report xml --junit-xml=pytest-report.xml || true 33 | 34 | sonar: bandit-report flake8-report pytest-report 35 | true 36 | -------------------------------------------------------------------------------- /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 50 | > - For Python 3.10, use version 4.x 51 | > - For Python 3.9, use version 3.x 52 | > - For Python 3.8, use version 2.x 53 | 54 | ### Configuration 55 | 56 | 1. Add to your `settings.py`: 57 | 58 | ```python 59 | # settings.py 60 | 61 | INSTALLED_APPS = [ 62 | # other django apps 63 | "django.contrib.messages", # Required for auth messages 64 | "django_google_sso", # Add django_google_sso 65 | ] 66 | 67 | # Google OAuth2 credentials 68 | GOOGLE_SSO_CLIENT_ID = "your client id here" 69 | GOOGLE_SSO_PROJECT_ID = "your project id here" 70 | GOOGLE_SSO_CLIENT_SECRET = "your client secret here" 71 | 72 | # Auto-create users from these domains 73 | GOOGLE_SSO_ALLOWABLE_DOMAINS = ["example.com"] 74 | ``` 75 | 76 | 2. Add the callback URL in [Google Console](https://console.cloud.google.com/apis/credentials) under "Authorized Redirect URIs": 77 | - For local development: `http://localhost:8000/google_sso/callback/` 78 | - For production: `https://your-domain.com/google_sso/callback/` 79 | 80 | 3. Add to your `urls.py`: 81 | 82 | ```python 83 | # urls.py 84 | 85 | from django.urls import include, path 86 | 87 | urlpatterns = [ 88 | # other urlpatterns... 89 | path( 90 | "google_sso/", include("django_google_sso.urls", namespace="django_google_sso") 91 | ), 92 | ] 93 | ``` 94 | 95 | 4. Run migrations: 96 | 97 | ```shell 98 | $ python manage.py migrate 99 | ``` 100 | 101 | That's it! Start Django and visit `http://localhost:8000/admin/login` to see the Google SSO button: 102 | 103 |

104 | 105 |

106 | 107 | ## Admin Skin Compatibility 108 | 109 | Django Google SSO works with popular Django Admin skins including: 110 | - Django Admin (default) 111 | - [Grappelli](https://github.com/sehmaschine/django-grappelli) 112 | - [Django Jazzmin](https://github.com/farridav/django-jazzmin) 113 | - [Django Admin Interface](https://github.com/fabiocaccamo/django-admin-interface) 114 | - [Django Jet Reboot](https://github.com/assem-ch/django-jet-reboot) 115 | - [Django Unfold](https://github.com/unfoldadmin/django-unfold) 116 | 117 | ## Documentation 118 | 119 | For detailed documentation, visit: 120 | - [Full Documentation](https://megalus.github.io/django-google-sso/) 121 | - [Quick Setup](https://megalus.github.io/django-google-sso/quick_setup/) 122 | - [Google Credentials Setup](https://megalus.github.io/django-google-sso/credentials/) 123 | - [User Management](https://megalus.github.io/django-google-sso/users/) 124 | - [Customization](https://megalus.github.io/django-google-sso/customize/) 125 | - [Troubleshooting](https://megalus.github.io/django-google-sso/troubleshooting/) 126 | 127 | ## License 128 | This project is licensed under the terms of the MIT license. 129 | -------------------------------------------------------------------------------- /django_google_sso/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "8.0.0" 2 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /django_google_sso/checks/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/megalus/django-google-sso/831a10c4361a9fa438355d54f1fbc7f417e48b54/django_google_sso/checks/__init__.py -------------------------------------------------------------------------------- /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_google_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 | -------------------------------------------------------------------------------- /django_google_sso/conf.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from loguru import logger 3 | 4 | GOOGLE_SSO_CLIENT_ID = getattr(settings, "GOOGLE_SSO_CLIENT_ID", None) 5 | 6 | GOOGLE_SSO_PROJECT_ID = getattr(settings, "GOOGLE_SSO_PROJECT_ID", None) 7 | GOOGLE_SSO_CLIENT_SECRET = getattr(settings, "GOOGLE_SSO_CLIENT_SECRET", None) 8 | GOOGLE_SSO_SCOPES = getattr( 9 | settings, 10 | "GOOGLE_SSO_SCOPES", 11 | [ 12 | "openid", 13 | "https://www.googleapis.com/auth/userinfo.email", 14 | "https://www.googleapis.com/auth/userinfo.profile", 15 | ], 16 | ) 17 | GOOGLE_SSO_TIMEOUT = getattr(settings, "GOOGLE_SSO_TIMEOUT", 10) 18 | 19 | GOOGLE_SSO_ALLOWABLE_DOMAINS = getattr(settings, "GOOGLE_SSO_ALLOWABLE_DOMAINS", []) 20 | GOOGLE_SSO_AUTO_CREATE_FIRST_SUPERUSER = getattr( 21 | settings, "GOOGLE_SSO_AUTO_CREATE_FIRST_SUPERUSER", False 22 | ) 23 | GOOGLE_SSO_SESSION_COOKIE_AGE = getattr(settings, "GOOGLE_SSO_SESSION_COOKIE_AGE", 3600) 24 | GOOGLE_SSO_ENABLED = getattr(settings, "GOOGLE_SSO_ENABLED", True) 25 | GOOGLE_SSO_SUPERUSER_LIST = getattr(settings, "GOOGLE_SSO_SUPERUSER_LIST", []) 26 | GOOGLE_SSO_STAFF_LIST = getattr(settings, "GOOGLE_SSO_STAFF_LIST", []) 27 | GOOGLE_SSO_CALLBACK_DOMAIN = getattr(settings, "GOOGLE_SSO_CALLBACK_DOMAIN", None) 28 | GOOGLE_SSO_LOGIN_FAILED_URL = getattr( 29 | settings, "GOOGLE_SSO_LOGIN_FAILED_URL", "admin:index" 30 | ) 31 | GOOGLE_SSO_NEXT_URL = getattr(settings, "GOOGLE_SSO_NEXT_URL", "admin:index") 32 | GOOGLE_SSO_AUTO_CREATE_USERS = getattr(settings, "GOOGLE_SSO_AUTO_CREATE_USERS", True) 33 | 34 | GOOGLE_SSO_AUTHENTICATION_BACKEND = getattr( 35 | settings, "GOOGLE_SSO_AUTHENTICATION_BACKEND", None 36 | ) 37 | GOOGLE_SSO_PRE_VALIDATE_CALLBACK = getattr( 38 | settings, 39 | "GOOGLE_SSO_PRE_VALIDATE_CALLBACK", 40 | "django_google_sso.hooks.pre_validate_user", 41 | ) 42 | GOOGLE_SSO_PRE_CREATE_CALLBACK = getattr( 43 | settings, 44 | "GOOGLE_SSO_PRE_CREATE_CALLBACK", 45 | "django_google_sso.hooks.pre_create_user", 46 | ) 47 | GOOGLE_SSO_PRE_LOGIN_CALLBACK = getattr( 48 | settings, 49 | "GOOGLE_SSO_PRE_LOGIN_CALLBACK", 50 | "django_google_sso.hooks.pre_login_user", 51 | ) 52 | 53 | GOOGLE_SSO_LOGO_URL = getattr( 54 | settings, 55 | "GOOGLE_SSO_LOGO_URL", 56 | "https://upload.wikimedia.org/wikipedia/commons/thumb/c/c1/" 57 | "Google_%22G%22_logo.svg/1280px-Google_%22G%22_logo.svg.png", 58 | ) 59 | 60 | GOOGLE_SSO_TEXT = getattr(settings, "GOOGLE_SSO_TEXT", "Sign in with Google") 61 | GOOGLE_SSO_SAVE_ACCESS_TOKEN = getattr(settings, "GOOGLE_SSO_SAVE_ACCESS_TOKEN", False) 62 | GOOGLE_SSO_ALWAYS_UPDATE_USER_DATA = getattr( 63 | settings, "GOOGLE_SSO_ALWAYS_UPDATE_USER_DATA", False 64 | ) 65 | GOOGLE_SSO_DEFAULT_LOCALE = getattr(settings, "GOOGLE_SSO_DEFAULT_LOCALE", "en") 66 | SSO_USE_ALTERNATE_W003 = getattr(settings, "SSO_USE_ALTERNATE_W003", False) 67 | 68 | if SSO_USE_ALTERNATE_W003: 69 | from django_google_sso.checks.warnings import register_sso_check # noqa 70 | 71 | GOOGLE_SSO_ENABLE_LOGS = getattr(settings, "GOOGLE_SSO_ENABLE_LOGS", True) 72 | GOOGLE_SSO_ENABLE_MESSAGES = getattr(settings, "GOOGLE_SSO_ENABLE_MESSAGES", True) 73 | GOOGLE_SSO_SAVE_BASIC_GOOGLE_INFO = getattr( 74 | settings, "GOOGLE_SSO_SAVE_BASIC_GOOGLE_INFO", True 75 | ) 76 | GOOGLE_SSO_SHOW_FAILED_LOGIN_MESSAGE = getattr( 77 | settings, "GOOGLE_SSO_SHOW_FAILED_LOGIN_MESSAGE", False 78 | ) 79 | 80 | if GOOGLE_SSO_ENABLE_LOGS: 81 | logger.enable("django_google_sso") 82 | else: 83 | logger.disable("django_google_sso") 84 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 AbstractUser 7 | from django.contrib.sites.shortcuts import get_current_site 8 | from django.db.models import Field, Model 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 conf.GOOGLE_SSO_SCOPES 27 | 28 | def get_client_config(self) -> Credentials: 29 | client_config = { 30 | "web": { 31 | "client_id": conf.GOOGLE_SSO_CLIENT_ID, 32 | "project_id": conf.GOOGLE_SSO_PROJECT_ID, 33 | "client_secret": conf.GOOGLE_SSO_CLIENT_SECRET, 34 | "auth_uri": "https://accounts.google.com/o/oauth2/auth", 35 | "auth_provider_x509_cert_url": ( 36 | "https://www.googleapis.com/oauth2/v1/certs" 37 | ), 38 | "token_uri": "https://oauth2.googleapis.com/token", 39 | "redirect_uris": [self.get_redirect_uri()], 40 | } 41 | } 42 | return client_config 43 | 44 | def get_netloc(self): 45 | if conf.GOOGLE_SSO_CALLBACK_DOMAIN: 46 | logger.debug("Find Netloc using GOOGLE_SSO_CALLBACK_DOMAIN") 47 | return conf.GOOGLE_SSO_CALLBACK_DOMAIN 48 | 49 | site = get_current_site(self.request) 50 | logger.debug("Find Netloc using Site domain") 51 | return site.domain 52 | 53 | def get_redirect_uri(self) -> str: 54 | if "HTTP_X_FORWARDED_PROTO" in self.request.META: 55 | scheme = self.request.META["HTTP_X_FORWARDED_PROTO"] 56 | else: 57 | scheme = self.request.scheme 58 | netloc = self.get_netloc() 59 | path = reverse("django_google_sso:oauth_callback") 60 | callback_uri = f"{scheme}://{netloc}{path}" 61 | logger.debug(f"Callback URI: {callback_uri}") 62 | return callback_uri 63 | 64 | @property 65 | def flow(self) -> Flow: 66 | if not self._flow: 67 | self._flow = Flow.from_client_config( 68 | self.get_client_config(), 69 | scopes=self.scopes, 70 | redirect_uri=self.get_redirect_uri(), 71 | ) 72 | return self._flow 73 | 74 | def get_user_info(self): 75 | session = self.flow.authorized_session() 76 | user_info = session.get("https://www.googleapis.com/oauth2/v2/userinfo").json() 77 | return user_info 78 | 79 | def get_user_token(self): 80 | return self.flow.credentials.token 81 | 82 | 83 | @dataclass 84 | class UserHelper: 85 | user_info: dict[Any, Any] 86 | request: Any 87 | user_changed: bool = False 88 | 89 | @property 90 | def user_email(self): 91 | return self.user_info["email"].lower() 92 | 93 | @property 94 | def user_model(self) -> AbstractUser | Model: 95 | return get_user_model() 96 | 97 | @property 98 | def username_field(self) -> Field: 99 | return self.user_model._meta.get_field(self.user_model.USERNAME_FIELD) 100 | 101 | @property 102 | def email_is_valid(self) -> bool: 103 | user_email_domain = self.user_email.split("@")[-1] 104 | if ( 105 | "*" in conf.GOOGLE_SSO_ALLOWABLE_DOMAINS 106 | or user_email_domain in conf.GOOGLE_SSO_ALLOWABLE_DOMAINS 107 | ): 108 | return True 109 | email_verified = self.user_info.get("email_verified", None) 110 | if email_verified is not None and not email_verified: 111 | logger.debug(f"Email {self.user_email} is not verified.") 112 | return email_verified if email_verified is not None else False 113 | 114 | def get_or_create_user(self, extra_users_args: dict | None = None): 115 | user_defaults = extra_users_args or {} 116 | if self.username_field.name not in user_defaults: 117 | user_defaults[self.username_field.name] = self.user_email 118 | if "email" not in user_defaults: 119 | user_defaults["email"] = self.user_email 120 | user, created = self.user_model.objects.get_or_create( 121 | email__iexact=self.user_email, defaults=user_defaults 122 | ) 123 | self.check_first_super_user(user) 124 | self.check_for_update(created, user) 125 | if self.user_changed: 126 | user.save() 127 | 128 | if conf.GOOGLE_SSO_SAVE_BASIC_GOOGLE_INFO: 129 | GoogleSSOUser.objects.update_or_create( 130 | user=user, 131 | defaults={ 132 | "google_id": self.user_info["id"], 133 | "picture_url": self.user_info.get("picture"), 134 | "locale": self.user_info.get("locale") 135 | or conf.GOOGLE_SSO_DEFAULT_LOCALE, 136 | }, 137 | ) 138 | return user 139 | 140 | def check_for_update(self, created, user): 141 | if created or conf.GOOGLE_SSO_ALWAYS_UPDATE_USER_DATA: 142 | self.check_for_permissions(user) 143 | user.first_name = self.user_info.get("given_name") 144 | user.last_name = self.user_info.get("family_name") 145 | if not getattr(user, self.username_field.name): 146 | setattr(user, self.username_field.name, self.user_email) 147 | user.set_unusable_password() 148 | self.user_changed = True 149 | 150 | def check_first_super_user(self, user): 151 | if conf.GOOGLE_SSO_AUTO_CREATE_FIRST_SUPERUSER: 152 | superuser_exists = self.user_model.objects.filter( 153 | is_superuser=True, email__icontains=f"@{self.user_email.split('@')[-1]}" 154 | ).exists() 155 | if not superuser_exists: 156 | message_text = _( 157 | f"GOOGLE_SSO_AUTO_CREATE_FIRST_SUPERUSER is True. " 158 | f"Adding SuperUser status to email: {self.user_email}" 159 | ) 160 | messages.add_message(self.request, messages.INFO, message_text) 161 | logger.warning(message_text) 162 | user.is_superuser = True 163 | user.is_staff = True 164 | self.user_changed = True 165 | 166 | def check_for_permissions(self, user): 167 | if ( 168 | user.email in conf.GOOGLE_SSO_STAFF_LIST 169 | or "*" in conf.GOOGLE_SSO_STAFF_LIST 170 | ): 171 | message_text = _( 172 | f"User email: {self.user_email} in GOOGLE_SSO_STAFF_LIST. " 173 | f"Added Staff Permission." 174 | ) 175 | messages.add_message(self.request, messages.INFO, message_text) 176 | logger.debug(message_text) 177 | user.is_staff = True 178 | if user.email in conf.GOOGLE_SSO_SUPERUSER_LIST: 179 | message_text = _( 180 | f"User email: {self.user_email} in GOOGLE_SSO_SUPERUSER_LIST. " 181 | f"Added SuperUser Permission." 182 | ) 183 | messages.add_message(self.request, messages.INFO, message_text) 184 | logger.debug(message_text) 185 | user.is_superuser = True 186 | user.is_staff = True 187 | 188 | def find_user(self): 189 | query = self.user_model.objects.filter(email__iexact=self.user_email) 190 | if query.exists(): 191 | return query.get() 192 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /django_google_sso/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/megalus/django-google-sso/831a10c4361a9fa438355d54f1fbc7f417e48b54/django_google_sso/migrations/__init__.py -------------------------------------------------------------------------------- /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 | return f"{self.user.email} ({self.google_id})" 27 | 28 | class Meta: 29 | db_table = "google_sso_user" 30 | verbose_name = _("Google SSO User") 31 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | {% define_sso_providers as sso_providers %} 10 | {% for provider in sso_providers %} 11 | 12 | {% endfor %} 13 | {% endblock %} 14 | 15 | {# Default Django Admin Block #} 16 | {% block content %} 17 | {% define_show_form as show_form %} 18 | {% if show_form %} 19 | {{ block.super }} 20 | {% endif %} 21 | {% include 'google_sso/login_sso.html' %} 22 | {% endblock %} 23 | 24 | {# Django Unfold Admin Block #} 25 | {% block base %} 26 | {{ block.super }} {# Process HTML login elements from Django Unfold #} 27 | {% include 'google_sso/login_sso.html' %} {# Add Google SSO HTML elements #} 28 | 41 | {% endblock %} 42 | -------------------------------------------------------------------------------- /django_google_sso/templates/google_sso/login_sso.html: -------------------------------------------------------------------------------- 1 | {% load sso_tags %} 2 | {% load show_form %} 3 | 4 | 5 | {% define_sso_providers as sso_providers %} 6 | 7 | 23 | 24 |
25 | 38 |
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 | ![](images/django_login_with_google_custom.png) 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 | ![](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 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 | ![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 | -------------------------------------------------------------------------------- /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/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 |
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/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. 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 "Got a "KeyError: 'NAME'" error after set SSO_USE_ALTERNATE_W003" 39 | If you get a `KeyError: 'NAME'` error, please set a `NAME` in `TEMPLATES` at `settings.py`: 40 | 41 | ```python 42 | # settings.py 43 | 44 | TEMPLATES = [ 45 | { 46 | "BACKEND": "django.template.backends.django.DjangoTemplates", 47 | "NAME" : "default", # <-- Add name here 48 | "DIRS": [BASE_DIR / "templates"], 49 | "APP_DIRS": True, 50 | "OPTIONS": { 51 | "context_processors": [ 52 | "django.template.context_processors.debug", 53 | "django.template.context_processors.request", 54 | "django.contrib.auth.context_processors.auth", 55 | "django.contrib.messages.context_processors.messages", 56 | ], 57 | }, 58 | }, 59 | ] 60 | ``` 61 | 62 | ??? question "Got this error when migrating: 'The model User is already registered with 'core.GoogleSSOUserAdmin'" 63 | This is because you're already define a custom User model and admin in your project. You need to [extended the 64 | existing user model](https://docs.djangoproject.com/en/5.1/topics/auth/customizing/#extending-the-existing-user-model) 65 | unregistering your current User Admin class and add manually the GoogleSSOInlineAdmin in your custom class. 66 | You can use the `get_current_user_and_admin` helper as explained [here](admin.md) (the recommended action), or 67 | alternately, you can add the `django-google-sso` at the end of your `INSTALLED_APPS` list. 68 | 69 | 70 | ### Example App 71 | 72 | To test this library please check the `Example App` provided [here](https://github.com/megalus/django-google-sso/tree/main/example_google_app). 73 | 74 | ### Not working? 75 | 76 | Don't panic. Get a towel and, please, open an [issue](https://github.com/megalus/django-google-sso/issues). 77 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /example_google_app/.stela: -------------------------------------------------------------------------------- 1 | [stela] 2 | environment_variable_name = STELA_ENV 3 | evaluate_data = True 4 | show_logs = False 5 | dotenv_overwrites_memory = False 6 | env_file = .env 7 | config_file_path = . 8 | -------------------------------------------------------------------------------- /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/secret` 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 | -------------------------------------------------------------------------------- /example_google_app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/megalus/django-google-sso/831a10c4361a9fa438355d54f1fbc7f417e48b54/example_google_app/__init__.py -------------------------------------------------------------------------------- /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/backend.py: -------------------------------------------------------------------------------- 1 | import arrow 2 | import httpx 3 | from django.contrib import messages 4 | from django.contrib.auth import logout 5 | from django.contrib.auth.backends import ModelBackend 6 | from loguru import logger 7 | 8 | 9 | class MyBackend(ModelBackend): 10 | """Simple test for custom authentication backend""" 11 | 12 | def authenticate(self, request, username=None, password=None, **kwargs): 13 | return super().authenticate(request, username, password, **kwargs) 14 | 15 | 16 | def pre_login_callback(user, request): 17 | """Callback function called before user is logged in.""" 18 | messages.info(request, f"Running Pre-Login callback for user: {user}.") 19 | 20 | # Example 1: Add SuperUser status to user 21 | if not user.is_superuser or not user.is_staff: 22 | logger.info(f"Adding SuperUser status to email: {user.email}") 23 | user.is_superuser = True 24 | user.is_staff = True 25 | 26 | # Example 2: Use Google Info as the unique source of truth 27 | token = request.session.get("google_sso_access_token") 28 | if token: 29 | headers = { 30 | "Authorization": f"Bearer {token}", 31 | } 32 | url = "https://www.googleapis.com/oauth2/v3/userinfo" 33 | 34 | # Use response to update user info 35 | # Please add the custom scope in settings.GOOGLE_SSO_SCOPES 36 | # to access this info 37 | response = httpx.get(url, headers=headers, timeout=10) 38 | if response.status_code == 200: 39 | user_data = response.json() 40 | logger.debug(f"Updating User Data with Google Info: {user_data}") 41 | 42 | url = "https://people.googleapis.com/v1/people/me?personFields=birthdays" 43 | response = httpx.get(url, headers=headers, timeout=10) 44 | people_data = response.json() 45 | logger.debug(f"Updating User Data with Google People Info: {people_data}") 46 | 47 | user.first_name = user_data["given_name"] 48 | user.last_name = user_data["family_name"] 49 | 50 | user.save() 51 | 52 | 53 | def is_user_valid(token): 54 | headers = { 55 | "Authorization": f"Bearer {token}", 56 | } 57 | url = "https://www.googleapis.com/oauth2/v3/userinfo" 58 | response = httpx.get(url, headers=headers, timeout=10) 59 | 60 | # Add any check here 61 | 62 | return response.status_code == 200 63 | 64 | 65 | class GoogleSLOMiddlewareExample: 66 | def __init__(self, get_response): 67 | self.get_response = get_response 68 | 69 | def __call__(self, request): 70 | token = request.session.get("google_sso_access_token") 71 | 72 | if token and not is_user_valid(token): 73 | logout(request) 74 | 75 | response = self.get_response(request) 76 | return response 77 | 78 | 79 | def pre_create_callback(google_info, request) -> dict: 80 | """Callback function called before user is created. 81 | 82 | return: dict content to be passed to User.objects.create() as `defaults` argument. 83 | If not informed, field `username` is always passed with user email as value. 84 | """ 85 | 86 | user_key = google_info.get("email").split("@")[0] 87 | user_id = google_info.get("id") 88 | 89 | return { 90 | "username": f"{user_key}_{user_id}", 91 | "date_joined": arrow.utcnow().shift(days=-1).datetime, 92 | } 93 | 94 | 95 | def pre_validate_callback(google_info, request) -> bool: 96 | """Callback function called before user is validated. 97 | 98 | Must return a boolean to indicate if user is valid to login. 99 | 100 | params: 101 | google_info: dict containing user info received from Google. 102 | request: HttpRequest object. 103 | """ 104 | messages.info( 105 | request, f"Running Pre-Validate callback for email: {google_info.get('email')}." 106 | ) 107 | return True 108 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | from pathlib import Path 14 | 15 | from stela import env 16 | 17 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 18 | 19 | BASE_DIR = Path(__file__).resolve().parent.parent 20 | 21 | 22 | # Quick-start development settings - unsuitable for production 23 | # See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/ 24 | 25 | # SECURITY WARNING: keep the secret key used in production secret! 26 | SECRET_KEY = "django-insecure" 27 | 28 | # SECURITY WARNING: don't run with debug turned on in production! 29 | DEBUG = True 30 | 31 | ALLOWED_HOSTS = [] 32 | 33 | 34 | # Application definition 35 | 36 | INSTALLED_APPS = [ 37 | # Uncomment for Grappelli 38 | # "grappelli", 39 | # Uncomment for Jazzmin 40 | # "jazzmin", 41 | # Uncomment for Admin Interface 42 | # "admin_interface", 43 | # "colorfield", 44 | # Uncomment for Jest 45 | # "jet.dashboard", 46 | # "jet", 47 | # Uncomment for Unfold 48 | # "unfold", 49 | "django.contrib.admin", 50 | "django.contrib.auth", 51 | "django.contrib.contenttypes", 52 | "django.contrib.sessions", 53 | "django.contrib.messages", # Need for Auth messages 54 | "django.contrib.staticfiles", 55 | "django.contrib.sites", # Optional: Add Sites framework 56 | "django_google_sso", # Add django_google_sso 57 | ] 58 | 59 | MIDDLEWARE = [ 60 | "django.middleware.security.SecurityMiddleware", 61 | "django.contrib.sessions.middleware.SessionMiddleware", 62 | "django.middleware.common.CommonMiddleware", 63 | "django.middleware.csrf.CsrfViewMiddleware", 64 | "django.contrib.auth.middleware.AuthenticationMiddleware", 65 | # Must be after Authentication 66 | "example_google_app.backend.GoogleSLOMiddlewareExample", 67 | "django.contrib.messages.middleware.MessageMiddleware", 68 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 69 | ] 70 | 71 | ROOT_URLCONF = "example_google_app.urls" 72 | 73 | TEMPLATES = [ 74 | { 75 | "BACKEND": "django.template.backends.django.DjangoTemplates", 76 | "DIRS": [], 77 | "APP_DIRS": True, 78 | "OPTIONS": { 79 | "context_processors": [ 80 | "django.template.context_processors.debug", 81 | "django.template.context_processors.request", 82 | "django.contrib.auth.context_processors.auth", 83 | "django.contrib.messages.context_processors.messages", 84 | "django.template.context_processors.static", 85 | ], 86 | }, 87 | }, 88 | ] 89 | 90 | WSGI_APPLICATION = "example_google_app.wsgi.application" 91 | 92 | 93 | # Database 94 | # https://docs.djangoproject.com/en/3.2/ref/settings/#databases 95 | 96 | DATABASES = { 97 | "default": { 98 | "ENGINE": "django.db.backends.sqlite3", 99 | "NAME": BASE_DIR / "db.sqlite3", 100 | } 101 | } 102 | 103 | 104 | # Password validation 105 | # https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators 106 | 107 | AUTH_PASSWORD_VALIDATORS = [ 108 | { 109 | "NAME": "django.contrib.auth.password_validation." 110 | "UserAttributeSimilarityValidator", 111 | }, 112 | { 113 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 114 | }, 115 | { 116 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 117 | }, 118 | { 119 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 120 | }, 121 | ] 122 | 123 | 124 | # Internationalization 125 | # https://docs.djangoproject.com/en/3.2/topics/i18n/ 126 | 127 | LANGUAGE_CODE = "en-us" 128 | 129 | TIME_ZONE = "UTC" 130 | 131 | USE_I18N = True 132 | 133 | USE_L10N = True 134 | 135 | USE_TZ = True 136 | 137 | if "jet" in INSTALLED_APPS: 138 | # Jet Theme 139 | JET_DEFAULT_THEME = "light-gray" 140 | 141 | JET_THEMES = [ 142 | { 143 | "theme": "default", # theme folder name 144 | "color": "#47bac1", # color of the theme's button in user menu 145 | "title": "Default", # theme title 146 | }, 147 | {"theme": "green", "color": "#44b78b", "title": "Green"}, 148 | {"theme": "light-green", "color": "#2faa60", "title": "Light Green"}, 149 | {"theme": "light-violet", "color": "#a464c4", "title": "Light Violet"}, 150 | {"theme": "light-blue", "color": "#5EADDE", "title": "Light Blue"}, 151 | {"theme": "light-gray", "color": "#222", "title": "Light Gray"}, 152 | ] 153 | 154 | 155 | # Static files (CSS, JavaScript, Images) 156 | # https://docs.djangoproject.com/en/3.2/howto/static-files/ 157 | 158 | STATIC_URL = "static/" 159 | STATICFILES_DIRS = [BASE_DIR / "example_google_app" / "static"] 160 | 161 | # Default primary key field type 162 | # https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field 163 | 164 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 165 | 166 | AUTHENTICATION_BACKENDS = ["backend.MyBackend"] 167 | 168 | # Uncomment GOOGLE_SSO_CALLBACK_DOMAIN to use Sites Framework site domain 169 | # Or comment both and use domain retrieved from accounts/login/ request 170 | SITE_ID = 1 171 | GOOGLE_SSO_CALLBACK_DOMAIN = env.GOOGLE_SSO_CALLBACK_DOMAIN 172 | 173 | GOOGLE_SSO_SESSION_COOKIE_AGE = 3600 # default value 174 | GOOGLE_SSO_CLIENT_ID = env.GOOGLE_SSO_CLIENT_ID 175 | GOOGLE_SSO_PROJECT_ID = env.GOOGLE_SSO_PROJECT_ID 176 | GOOGLE_SSO_CLIENT_SECRET = env.GOOGLE_SSO_CLIENT_SECRET 177 | 178 | GOOGLE_SSO_ALLOWABLE_DOMAINS = env.get_or_default("GOOGLE_SSO_ALLOWABLE_DOMAINS", []) 179 | GOOGLE_SSO_AUTO_CREATE_FIRST_SUPERUSER = ( 180 | False # Mark as True, to create superuser on first eligible user login 181 | ) 182 | GOOGLE_SSO_STAFF_LIST = env.get_or_default("GOOGLE_SSO_STAFF_LIST", []) 183 | GOOGLE_SSO_SUPERUSER_LIST = env.get_or_default("GOOGLE_SSO_SUPERUSER_LIST", []) 184 | 185 | GOOGLE_SSO_TIMEOUT = 10 # default value 186 | GOOGLE_SSO_SCOPES = [ 187 | "openid", 188 | "https://www.googleapis.com/auth/userinfo.email", 189 | "https://www.googleapis.com/auth/userinfo.profile", 190 | # "https://www.googleapis.com/auth/user.birthday.read", # additional scope 191 | ] 192 | 193 | # Optional: Add if you want to use custom authentication backend 194 | GOOGLE_SSO_AUTHENTICATION_BACKEND = "backend.MyBackend" 195 | 196 | # Optional: You can save access token to session 197 | GOOGLE_SSO_SAVE_ACCESS_TOKEN = True 198 | 199 | # Optional: Change default login text 200 | # GOOGLE_SSO_TEXT = "Login using Google Account" 201 | 202 | # Optional: Add pre-validate logic 203 | GOOGLE_SSO_PRE_VALIDATE_CALLBACK = "backend.pre_validate_callback" 204 | 205 | # Optional: Add pre-create logic 206 | GOOGLE_SSO_PRE_CREATE_CALLBACK = "backend.pre_create_callback" 207 | 208 | # Optional: Add pre-login logic 209 | GOOGLE_SSO_PRE_LOGIN_CALLBACK = "backend.pre_login_callback" 210 | 211 | # Uncomment to disable SSO login 212 | # GOOGLE_SSO_ENABLED = False # default: True 213 | 214 | # Uncomment to disable user auto-creation 215 | # GOOGLE_SSO_AUTO_CREATE_USERS = False # default: True 216 | 217 | # Uncomment to hide login form on admin page 218 | # GOOGLE_SSO_SHOW_FORM_ON_ADMIN_PAGE = False # default: True 219 | 220 | # Always update user data with Google Info 221 | GOOGLE_SSO_ALWAYS_UPDATE_USER_DATA = True 222 | 223 | # Uncomment to hide the login form on admin page 224 | SSO_SHOW_FORM_ON_ADMIN_PAGE = False # default: True 225 | 226 | # Optional: Disable Logs 227 | # GOOGLE_SSO_ENABLE_LOGS = False 228 | 229 | # Optional: Disable Django Messages 230 | # GOOGLE_SSO_ENABLE_MESSAGES = False 231 | 232 | # Optional: Start or Stop User auto-creation 233 | # GOOGLE_SSO_AUTO_CREATE_USERS = True 234 | 235 | # Optional: Show failed login attempt message on browser. 236 | # This message can be used in exploit attempts. 237 | # GOOGLE_SSO_SHOW_FAILED_LOGIN_MESSAGE = True 238 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 secret_page, single_logout_view 25 | 26 | urlpatterns = [ 27 | path("admin/", admin.site.urls), 28 | ] 29 | 30 | urlpatterns += [ 31 | path("secret/", secret_page), 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 | ] 40 | 41 | if "grappelli" in INSTALLED_APPS: 42 | urlpatterns += [path("grappelli/", include("grappelli.urls"))] 43 | 44 | if "jet" in INSTALLED_APPS: 45 | urlpatterns += [ 46 | path("jet/dashboard/", include("jet.dashboard.urls", "jet-dashboard")), 47 | path("jet/", include("jet.urls", "jet")), 48 | ] 49 | 50 | urlpatterns += [ 51 | path( 52 | "google_sso/", include("django_google_sso.urls", namespace="django_google_sso") 53 | ), 54 | ] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) 55 | -------------------------------------------------------------------------------- /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.urls import reverse 6 | 7 | 8 | @login_required 9 | def secret_page(request): 10 | logout_url = reverse("logout") 11 | body = f"

You're looking at the secret page.

Logout" 12 | return HttpResponse(body) 13 | 14 | 15 | def single_logout_view(request): 16 | token = request.session.get("google_sso_access_token") 17 | logout(request) 18 | 19 | # You can revoke the Access Token here 20 | if token: 21 | httpx.post( 22 | "https://oauth2.googleapis.com/revoke", params={"token": token}, timeout=10 23 | ) 24 | 25 | # And redirect user to Google logout page if you want 26 | redirect_url = reverse("admin:index") # Or 'https://accounts.google.com/logout' 27 | return HttpResponseRedirect(redirect_url) 28 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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.snippets 59 | - pymdownx.superfences 60 | - pymdownx.magiclink: 61 | repo_url_shorthand: true 62 | user: squidfunk 63 | repo: mkdocs-material 64 | - pymdownx.mark 65 | - pymdownx.smartsymbols 66 | - pymdownx.tabbed: 67 | alternate_style: true 68 | - pymdownx.tasklist: 69 | custom_checkbox: true 70 | - pymdownx.tilde 71 | 72 | nav: 73 | - Intro: index.md 74 | - quick_setup.md 75 | - credentials.md 76 | - callback.md 77 | - users.md 78 | - urls.md 79 | - model.md 80 | - admin.md 81 | - customize.md 82 | - third_party_admins.md 83 | - how.md 84 | - advanced.md 85 | - multiple.md 86 | - settings.md 87 | - troubleshooting.md 88 | - thanks.md 89 | -------------------------------------------------------------------------------- /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 = "8.0.0" 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 | "Intended Audience :: Developers", 57 | "Development Status :: 5 - Production/Stable", 58 | "Environment :: Plugins" 59 | ] 60 | 61 | [tool.poetry.dependencies] 62 | python = ">=3.11, <4.0" 63 | django = ">=4.2" 64 | loguru = "*" 65 | google-auth = "*" 66 | google-auth-httplib2 = "*" 67 | google-auth-oauthlib = "*" 68 | 69 | [tool.poetry.dev-dependencies] 70 | auto-changelog = "*" 71 | arrow = "*" 72 | black = {version = "*", allow-prereleases = true} 73 | Faker = "*" 74 | pre-commit = "*" 75 | pytest-coverage = "*" 76 | pytest-django = "*" 77 | pytest-mock = "*" 78 | twine = "*" 79 | python-dotenv = "*" 80 | mkdocs-material = "*" 81 | django-grappelli = "*" 82 | django-jazzmin = "*" 83 | django-admin-interface = "*" 84 | django-jet-reboot = "*" 85 | django-unfold = "*" 86 | click = ">8" 87 | bandit = "*" 88 | flake8 = "*" 89 | stela = "*" 90 | httpx = "*" 91 | 92 | [tool.stela] 93 | environment_variable_name = "STELA_ENV" 94 | evaluate_data = true 95 | show_logs = false 96 | dotenv_overwrites_memory = true 97 | env_file = ".env" 98 | config_file_path = "./example_google_app" 99 | 100 | [build-system] 101 | requires = ["poetry-core>=1.0.0"] 102 | build-backend = "poetry.core.masonry.api" 103 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | DJANGO_SETTINGS_MODULE = example_google_app.settings 3 | python_files = tests.py test_*.py *_tests.py 4 | addopts = --ignore=migration --ignore=.cache 5 | --------------------------------------------------------------------------------