├── tests ├── __init__.py └── test_authentication.py ├── .flake8 ├── .coveragerc ├── requirements.txt ├── requirements-dev.txt ├── pytest.ini ├── .editorconfig ├── firebase_auth ├── __init__.py ├── settings.py ├── apps.py └── authentication.py ├── .gitignore ├── manage.py ├── LICENSE ├── README.md ├── .github └── workflows │ └── main.yml ├── setup.py └── test_settings.py /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length=100 3 | exclude=*migrations* -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = firebase_auth 3 | omit = */migrations/* setup.py 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Django==2.2.20 2 | djangorestframework==3.11.0 3 | firebase-admin==4.3.0 -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | pytest 3 | pytest-django 4 | pytest-mock 5 | pytest-cov -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | DJANGO_SETTINGS_MODULE = test_settings 3 | python_files = tests.py test_*.py *_tests.py -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | trim_trailing_whitespace = true 6 | insert_final_newline = true 7 | 8 | [*.py] 9 | indent_style = space 10 | indent_size = 4 -------------------------------------------------------------------------------- /firebase_auth/__init__.py: -------------------------------------------------------------------------------- 1 | __title__ = 'django-rest-firebase-auth' 2 | __version__ = '0.2.0' 3 | __author__ = 'Walison Filipe' 4 | __license__ = 'MIT' 5 | __copyright__ = 'Copyright 2020 Walison Filipe' 6 | 7 | # Version synonym 8 | VERSION = __version__ 9 | -------------------------------------------------------------------------------- /firebase_auth/settings.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from rest_framework.settings import APISettings 3 | 4 | 5 | FIREBASE_AUTH_SETTINGS = "FIREBASE_AUTH" 6 | 7 | USER_SETTINGS = getattr(settings, FIREBASE_AUTH_SETTINGS) 8 | 9 | DEFAULTS = {"SERVICE_ACCOUNT_KEY_FILE": "", "EMAIL_VERIFICATION": False} 10 | 11 | IMPORT_STRINGS = () 12 | 13 | firebase_auth_settings = APISettings(USER_SETTINGS, DEFAULTS, IMPORT_STRINGS) 14 | -------------------------------------------------------------------------------- /firebase_auth/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | from firebase_admin import credentials, initialize_app 4 | from firebase_auth.settings import firebase_auth_settings 5 | 6 | 7 | class FirebaseAuthConfig(AppConfig): 8 | name = "firebase_auth" 9 | 10 | def ready(self) -> None: 11 | initialize_app( 12 | credentials.Certificate(firebase_auth_settings.SERVICE_ACCOUNT_KEY_FILE) 13 | ) 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .env 3 | .venv 4 | *.sqlite3 5 | *pyc 6 | __pycache__ 7 | .vscode 8 | .coverage 9 | serviceAccountKey.json 10 | 11 | # Distribution / packaging 12 | .Python 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | wheels/ 25 | pip-wheel-metadata/ 26 | share/python-wheels/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | MANIFEST 31 | -------------------------------------------------------------------------------- /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 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'test_settings') 9 | try: 10 | from django.core.management import execute_from_command_line 11 | except ImportError as exc: 12 | raise ImportError( 13 | "Couldn't import Django. Are you sure it's installed and " 14 | "available on your PYTHONPATH environment variable? Did you " 15 | "forget to activate a virtual environment?" 16 | ) from exc 17 | execute_from_command_line(sys.argv) 18 | 19 | 20 | if __name__ == '__main__': 21 | main() 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Walison Filipe 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Django Rest Firebase Auth 2 | 3 | Use firebase authentication with your django rest framework project 4 | 5 | [![codecov](https://codecov.io/gh/walison17/django-rest-firebase-auth/branch/master/graph/badge.svg)](https://codecov.io/gh/walison17/django-rest-firebase-auth) 6 | 7 | ## Requirements 8 | 9 | - Python >= 3.7 10 | - Django >= 2.2 11 | - Django Rest Framework 12 | 13 | ## Installation 14 | 15 | ``` 16 | pip install django-rest-firebase-auth 17 | ``` 18 | 19 | On your project's `settings.py` add this to the `REST_FRAMEWORK` configuration 20 | 21 | ```python 22 | REST_FRAMEWORK = { 23 | ... 24 | "DEFAULT_AUTHENTICATION_CLASSES": [ 25 | "firebase_auth.authentication.FirebaseAuthentication" 26 | ] 27 | ... 28 | } 29 | ``` 30 | 31 | Get your admin credentials `.json` from the Firebase SDK and add them to your project 32 | 33 | ```python 34 | FIREBASE_AUTH = { 35 | "SERVICE_ACCOUNT_KEY_FILE": "path_to_your_credentials.json" 36 | } 37 | ``` 38 | 39 | The `django-rest-firebase-auth` comes with the following settings as default, which can be overridden in your project's `settings.py`. 40 | 41 | ```python 42 | FIREBASE_AUTH = { 43 | "SERVICE_ACCOUNT_KEY_FILE": "", 44 | 45 | # require that user has verified their email 46 | "EMAIL_VERIFICATION": False 47 | } 48 | ``` 49 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Python package 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | python-version: ['3.8', '3.9', '3.10', '3.11'] 15 | 16 | steps: 17 | - uses: actions/checkout@v3 18 | 19 | - name: Set up Python ${{ matrix.python-version }} 20 | uses: actions/setup-python@v4 21 | with: 22 | python-version: ${{ matrix.python-version }} 23 | 24 | - name: Install dependencies 25 | run: | 26 | python -m pip install --upgrade pip 27 | pip install flake8 pytest 28 | pip install -r requirements-dev.txt 29 | 30 | - name: Lint with flake8 31 | run: | 32 | # stop the build if there are Python syntax errors or undefined names 33 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 34 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 35 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 36 | 37 | - name: Generate coverage report 38 | run: | 39 | pip install pytest 40 | pip install pytest-cov 41 | pytest --cov=firebase_auth --cov-report=xml 42 | 43 | - name: Upload coverage to Codecov 44 | uses: codecov/codecov-action@v1 45 | with: 46 | token: ${{ secrets.CODECOV_TOKEN }} 47 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import shutil 4 | import sys 5 | 6 | from setuptools import setup 7 | 8 | 9 | def get_version(package): 10 | """ 11 | Return package version as listed in `__version__` in `init.py`. 12 | """ 13 | with open(os.path.join(package, "__init__.py"), "rb") as init_py: 14 | src = init_py.read().decode("utf-8") 15 | return re.search("__version__ = ['\"]([^'\"]+)['\"]", src).group(1) 16 | 17 | 18 | name = "django-rest-firebase-auth" 19 | version = get_version("firebase_auth") 20 | package = "firebase_auth" 21 | description = "Firebase authentication for Django REST framework" 22 | url = "https://github.com/walison17/django-rest-firebase-auth" 23 | author = "Walison Filipe" 24 | author_email = "walisonfilipe@hotmail.com" 25 | license = "MIT" 26 | install_requires = [ 27 | "firebase-admin>=2.0.0" 28 | ] 29 | 30 | 31 | def read(*paths): 32 | """ 33 | Build a file path from paths and return the contents. 34 | """ 35 | with open(os.path.join(*paths), "r") as f: 36 | return f.read() 37 | 38 | 39 | def get_packages(package): 40 | """ 41 | Return root package and all sub-packages. 42 | """ 43 | return [ 44 | dirpath 45 | for dirpath, dirnames, filenames in os.walk(package) 46 | if os.path.exists(os.path.join(dirpath, "__init__.py")) 47 | ] 48 | 49 | 50 | def get_package_data(package): 51 | """ 52 | Return all files under the root package, that are not in a 53 | package themselves. 54 | """ 55 | walk = [ 56 | (dirpath.replace(package + os.sep, "", 1), filenames) 57 | for dirpath, dirnames, filenames in os.walk(package) 58 | if not os.path.exists(os.path.join(dirpath, "__init__.py")) 59 | ] 60 | 61 | filepaths = [] 62 | for base, filenames in walk: 63 | filepaths.extend([os.path.join(base, filename) for filename in filenames]) 64 | return {package: filepaths} 65 | 66 | 67 | if sys.argv[-1] == "publish": 68 | if os.system("pip freeze | grep twine"): 69 | print("twine not installed.\nUse `pip install twine`.\nExiting.") 70 | sys.exit() 71 | 72 | os.system("python setup.py sdist bdist_wheel") 73 | os.system("twine upload dist/*") 74 | 75 | shutil.rmtree("dist") 76 | shutil.rmtree("build") 77 | shutil.rmtree("django_rest_firebase_auth.egg-info") 78 | 79 | print("You probably want to also tag the version now:") 80 | print(" git tag -a {0} -m 'version {0}'".format(version)) 81 | print(" git push --tags") 82 | 83 | sys.exit() 84 | 85 | 86 | setup( 87 | name=name, 88 | version=version, 89 | url=url, 90 | license=license, 91 | description=description, 92 | long_description_content_type="text/markdown", 93 | long_description=read("README.md"), 94 | author=author, 95 | author_email=author_email, 96 | packages=get_packages(package), 97 | package_data=get_package_data(package), 98 | install_requires=install_requires, 99 | classifiers=[ 100 | "Development Status :: 3 - Alpha", 101 | "Environment :: Web Environment", 102 | "Framework :: Django", 103 | "Framework :: Django :: 2.2", 104 | "Framework :: Django :: 3.0", 105 | "Intended Audience :: Developers", 106 | "License :: OSI Approved :: MIT License", 107 | "Operating System :: OS Independent", 108 | "Programming Language :: Python", 109 | "Programming Language :: Python :: 3.5", 110 | "Programming Language :: Python :: 3.6", 111 | "Programming Language :: Python :: 3.7", 112 | "Programming Language :: Python :: 3.8", 113 | "Topic :: Internet :: WWW/HTTP", 114 | ], 115 | ) 116 | -------------------------------------------------------------------------------- /test_settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for firebase_auth example project. 3 | 4 | Generated by 'django-admin startproject' using Django 3.0.6. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.0/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/3.0/ref/settings/ 11 | """ 12 | 13 | import os 14 | 15 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 16 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/3.0/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = "__Secr3t__" 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = True 27 | 28 | ALLOWED_HOSTS = [] 29 | 30 | 31 | # Application definition 32 | 33 | INSTALLED_APPS = [ 34 | "django.contrib.admin", 35 | "django.contrib.auth", 36 | "django.contrib.contenttypes", 37 | "django.contrib.sessions", 38 | "django.contrib.messages", 39 | "django.contrib.staticfiles", 40 | "rest_framework", 41 | "firebase_auth", 42 | ] 43 | 44 | MIDDLEWARE = [ 45 | "django.middleware.security.SecurityMiddleware", 46 | "django.contrib.sessions.middleware.SessionMiddleware", 47 | "django.middleware.common.CommonMiddleware", 48 | "django.middleware.csrf.CsrfViewMiddleware", 49 | "django.contrib.auth.middleware.AuthenticationMiddleware", 50 | "django.contrib.messages.middleware.MessageMiddleware", 51 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 52 | ] 53 | 54 | ROOT_URLCONF = "example.urls" 55 | 56 | TEMPLATES = [ 57 | { 58 | "BACKEND": "django.template.backends.django.DjangoTemplates", 59 | "DIRS": [], 60 | "APP_DIRS": True, 61 | "OPTIONS": { 62 | "context_processors": [ 63 | "django.template.context_processors.debug", 64 | "django.template.context_processors.request", 65 | "django.contrib.auth.context_processors.auth", 66 | "django.contrib.messages.context_processors.messages", 67 | ], 68 | }, 69 | }, 70 | ] 71 | 72 | WSGI_APPLICATION = "example.wsgi.application" 73 | 74 | 75 | # Database 76 | # https://docs.djangoproject.com/en/3.0/ref/settings/#databases 77 | 78 | DATABASES = { 79 | "default": { 80 | "ENGINE": "django.db.backends.sqlite3", 81 | "NAME": os.path.join(BASE_DIR, "db.sqlite3"), 82 | } 83 | } 84 | 85 | 86 | # Password validation 87 | # https://docs.djangoproject.com/en/3.0/ref/settings/#auth-password-validators 88 | 89 | AUTH_PASSWORD_VALIDATORS = [ 90 | { 91 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 92 | }, 93 | {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, 94 | {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, 95 | {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, 96 | ] 97 | 98 | 99 | # Internationalization 100 | # https://docs.djangoproject.com/en/3.0/topics/i18n/ 101 | 102 | LANGUAGE_CODE = "pt-br" 103 | 104 | TIME_ZONE = "America/Recife" 105 | 106 | USE_I18N = True 107 | 108 | USE_L10N = True 109 | 110 | USE_TZ = True 111 | 112 | 113 | # Static files (CSS, JavaScript, Images) 114 | # https://docs.djangoproject.com/en/3.0/howto/static-files/ 115 | 116 | STATIC_URL = "/static/" 117 | 118 | REST_FRAMEWORK = { 119 | "DEFAULT_AUTHENTICATION_CLASSES": [ 120 | "firebase_auth.authentication.FirebaseAuthentication", 121 | "rest_framework.authentication.SessionAuthentication", 122 | ], 123 | "DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"], 124 | } 125 | 126 | FIREBASE_AUTH = { 127 | "EMAIL_VERIFICATION": False, 128 | } 129 | -------------------------------------------------------------------------------- /firebase_auth/authentication.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from django.utils.translation import gettext as _ 3 | from rest_framework import exceptions 4 | from rest_framework.authentication import BaseAuthentication, get_authorization_header 5 | 6 | from firebase_admin import auth 7 | 8 | from firebase_auth.settings import firebase_auth_settings 9 | 10 | 11 | User = get_user_model() 12 | 13 | 14 | class BaseFirebaseAuthentication(BaseAuthentication): 15 | """ 16 | Base implementation of token based authentication using firebase. 17 | """ 18 | 19 | www_authenticate_realm = "api" 20 | auth_header_prefix = "Bearer" 21 | uid_field = User.USERNAME_FIELD 22 | 23 | def authenticate(self, request): 24 | """ 25 | Returns a two-tuple of `User` and decoded firebase payload if a valid signature 26 | has been supplied. Otherwise returns `None`. 27 | """ 28 | firebase_token = self.get_token(request) 29 | 30 | if not firebase_token: 31 | return None 32 | 33 | try: 34 | payload = auth.verify_id_token(firebase_token) 35 | except ValueError: 36 | msg = _("Invalid firebase ID token.") 37 | raise exceptions.AuthenticationFailed(msg) 38 | except ( 39 | auth.ExpiredIdTokenError, 40 | auth.InvalidIdTokenError, 41 | auth.RevokedIdTokenError, 42 | ): 43 | msg = _("Could not log in.") 44 | raise exceptions.AuthenticationFailed(msg) 45 | 46 | user = self.authenticate_credentials(payload) 47 | 48 | return (user, payload) 49 | 50 | def get_token(self, request): 51 | """ 52 | Returns the firebase ID token from request. 53 | """ 54 | auth = get_authorization_header(request).split() 55 | 56 | if not auth or auth[0].lower() != self.auth_header_prefix.lower().encode(): 57 | return None 58 | 59 | if len(auth) == 1: 60 | msg = _("Invalid Authorization header. No credentials provided.") 61 | raise exceptions.AuthenticationFailed(msg) 62 | elif len(auth) > 2: 63 | msg = _( 64 | "Invalid Authorization header. Token string should not contain spaces." 65 | ) 66 | raise exceptions.AuthenticationFailed(msg) 67 | 68 | return auth[1] 69 | 70 | def authenticate_credentials(self, payload): 71 | """ 72 | Returns an user that matches the payload's user uid and email. 73 | """ 74 | if payload["firebase"]["sign_in_provider"] == "anonymous": 75 | msg = _("Firebase anonymous sign-in is not supported.") 76 | raise exceptions.AuthenticationFailed(msg) 77 | 78 | if firebase_auth_settings.EMAIL_VERIFICATION: 79 | if not payload["email_verified"]: 80 | msg = _("User email not yet confirmed.") 81 | raise exceptions.AuthenticationFailed(msg) 82 | 83 | uid = payload["uid"] 84 | 85 | try: 86 | user = self.get_user(uid) 87 | except User.DoesNotExist: 88 | firebase_user = auth.get_user(uid) 89 | user = self.create_user_from_firebase(uid, firebase_user) 90 | 91 | return user 92 | 93 | def get_user(self, uid: str) -> User: 94 | """Returns the user with given uid""" 95 | raise NotImplementedError(".get_user() must be overriden.") 96 | 97 | def create_user_from_firebase( 98 | self, uid: str, firebase_user: auth.UserRecord 99 | ) -> User: 100 | """Creates a new user with firebase info""" 101 | raise NotImplementedError(".create_user_from_firebase() must be overriden.") 102 | 103 | def authenticate_header(self, request): 104 | return '{} realm="{}"'.format( 105 | self.auth_header_prefix, self.www_authenticate_realm 106 | ) 107 | 108 | 109 | class FirebaseAuthentication(BaseFirebaseAuthentication): 110 | """ 111 | Token based authentication using firebase. 112 | 113 | Clients should authenticate by passing a Firebase ID token in the 114 | Authorization header using Bearer scheme. 115 | """ 116 | 117 | def get_user(self, uid: str) -> User: 118 | return User.objects.get(**{self.uid_field: uid}) 119 | 120 | def create_user_from_firebase( 121 | self, uid: str, firebase_user: auth.UserRecord 122 | ) -> User: 123 | query = {self.uid_field: uid} 124 | user, _ = User.objects.get_or_create(**query, defaults={"email": firebase_user.email}) 125 | 126 | return user 127 | -------------------------------------------------------------------------------- /tests/test_authentication.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import operator 3 | 4 | from django.contrib.auth import get_user_model 5 | from django.utils.translation import gettext as _ 6 | from rest_framework import exceptions 7 | 8 | from firebase_admin import auth 9 | 10 | from firebase_auth.authentication import ( 11 | BaseFirebaseAuthentication, 12 | FirebaseAuthentication, 13 | ) 14 | from firebase_auth.settings import firebase_auth_settings 15 | 16 | 17 | User = get_user_model() 18 | 19 | 20 | @pytest.fixture 21 | def firebase_authentication(): 22 | return FirebaseAuthentication() 23 | 24 | 25 | @pytest.fixture 26 | def firebase_uid(): 27 | return "firebase_uid" 28 | 29 | 30 | @pytest.fixture 31 | def firebase_payload(firebase_uid): 32 | return { 33 | "iss": "https://securetoken.google.com/firebase-authentication-example", 34 | "aud": "firebase-authentication-example", 35 | "auth_time": 1590388218, 36 | "user_id": firebase_uid, 37 | "sub": firebase_uid, 38 | "iat": 1590388218, 39 | "exp": 1590391818, 40 | "email": "walisonfilipe@hotmail.com", 41 | "email_verified": True, 42 | "firebase": { 43 | "identities": {"email": ["walisonfilipe@hotmail.com"]}, 44 | "sign_in_provider": "password", 45 | }, 46 | "uid": firebase_uid, 47 | } 48 | 49 | 50 | @pytest.fixture 51 | def user(firebase_uid, db): 52 | return User.objects.create_user( 53 | email="walisonfilipe@hotmail.com", password="102030", username=firebase_uid 54 | ) 55 | 56 | 57 | @pytest.fixture 58 | def make_request(rf): 59 | def _make_request(token): 60 | headers = {"HTTP_AUTHORIZATION": token} 61 | return rf.post("/token", **headers) 62 | 63 | return _make_request 64 | 65 | 66 | @pytest.fixture 67 | def fake_request(make_request): 68 | return make_request("Bearer token") 69 | 70 | 71 | @pytest.mark.parametrize( 72 | "method,args,exc_msg", 73 | [ 74 | ("get_user", ("fake_uid",), ".get_user() must be overriden."), 75 | ( 76 | "create_user_from_firebase", 77 | ( 78 | "fake_uid", 79 | auth.UserRecord( 80 | { 81 | "localId": "fake_uid", 82 | "display_name": "walison17", 83 | "email": "walisonfilipe@hotmail.com", 84 | "email_verified": True, 85 | "disabled": False, 86 | } 87 | ), 88 | ), 89 | ".create_user_from_firebase() must be overriden.", 90 | ), 91 | ], 92 | ) 93 | def test_base_abstract_methods_must_be_overriden(method, args, exc_msg): 94 | base = BaseFirebaseAuthentication() 95 | 96 | with pytest.raises(NotImplementedError) as exc: 97 | mcaller = operator.methodcaller(method, *args) 98 | mcaller(base) 99 | 100 | assert str(exc.value) == exc_msg 101 | 102 | 103 | def test_default_uid_field(firebase_authentication): 104 | assert firebase_authentication.uid_field == User.USERNAME_FIELD 105 | 106 | 107 | @pytest.mark.parametrize( 108 | "token,extracted", [("Bearer token", b"token"), ("InvalidPrefix token", None)] 109 | ) 110 | def test_get_token(firebase_authentication, make_request, token, extracted): 111 | request = make_request(token) 112 | 113 | token = firebase_authentication.get_token(request) 114 | 115 | assert token == extracted 116 | 117 | 118 | @pytest.mark.parametrize( 119 | "token,exc_message", 120 | [ 121 | ("Bearer", _("Invalid Authorization header. No credentials provided.")), 122 | ( 123 | "Bearer token with spaces", 124 | _("Invalid Authorization header. Token string should not contain spaces."), 125 | ), 126 | ], 127 | ) 128 | def test_get_token_raises_exception( 129 | firebase_authentication, make_request, token, exc_message 130 | ): 131 | request = make_request(token) 132 | 133 | with pytest.raises(exceptions.AuthenticationFailed) as exc: 134 | firebase_authentication.get_token(request) 135 | 136 | assert exc.value.detail == exc_message 137 | 138 | 139 | def test_valid_authentication( 140 | firebase_authentication, firebase_payload, fake_request, user, mocker 141 | ): 142 | mocker.patch( 143 | "firebase_auth.authentication.auth.verify_id_token", 144 | return_value=firebase_payload, 145 | ) 146 | 147 | authenticated_user, payload = firebase_authentication.authenticate(fake_request) 148 | 149 | assert authenticated_user == user 150 | assert payload == firebase_payload 151 | 152 | 153 | def test_authenticate_with_invalid_token( 154 | firebase_authentication, fake_request, user, mocker 155 | ): 156 | mocker.patch( 157 | "firebase_auth.authentication.FirebaseAuthentication.get_token", 158 | return_value=None, 159 | ) 160 | 161 | result = firebase_authentication.authenticate(fake_request) 162 | 163 | assert result is None 164 | 165 | 166 | def test_authenticate_with_anonymous_method( 167 | firebase_authentication, firebase_uid, firebase_payload 168 | ): 169 | firebase_payload["firebase"]["sign_in_provider"] = "anonymous" 170 | 171 | with pytest.raises(exceptions.AuthenticationFailed): 172 | firebase_authentication.authenticate_credentials(firebase_payload) 173 | 174 | 175 | def test_authenticate_with_email_verification_disabled( 176 | firebase_authentication, firebase_uid, firebase_payload, user 177 | ): 178 | firebase_auth_settings.EMAIL_VERIFICATION = False 179 | 180 | firebase_payload["email_verified"] = False 181 | 182 | assert firebase_authentication.authenticate_credentials(firebase_payload) == user 183 | 184 | 185 | def test_authenticate_with_email_verification_enabled( 186 | firebase_authentication, firebase_uid, firebase_payload 187 | ): 188 | firebase_auth_settings.EMAIL_VERIFICATION = True 189 | 190 | firebase_payload["email_verified"] = False 191 | 192 | with pytest.raises(exceptions.AuthenticationFailed): 193 | firebase_authentication.authenticate_credentials(firebase_payload) 194 | 195 | 196 | @pytest.mark.django_db 197 | def test_create_new_user_with_firebase_payload( 198 | firebase_authentication, firebase_payload, firebase_uid, mocker 199 | ): 200 | user_data = { 201 | "localId": firebase_uid, 202 | "display_name": "", 203 | "email": "walisonfilipe@hotmail.com", 204 | "email_verified": True, 205 | "disabled": False, 206 | } 207 | 208 | mocker.patch( 209 | "firebase_auth.authentication.auth.get_user", 210 | return_value=auth.UserRecord(user_data), 211 | ) 212 | 213 | assert not User.objects.exists() 214 | 215 | new_user = firebase_authentication.authenticate_credentials(firebase_payload) 216 | 217 | assert getattr(new_user, User.USERNAME_FIELD) == firebase_uid 218 | assert new_user.email == user_data["email"] 219 | 220 | 221 | @pytest.mark.parametrize( 222 | "side_effect,exc_message", 223 | [ 224 | [ValueError(), _("Invalid firebase ID token.")], 225 | [ 226 | auth.ExpiredIdTokenError(message="expired id token", cause="expired"), 227 | _("Could not log in."), 228 | ], 229 | [auth.InvalidIdTokenError(message="invalid id token"), _("Could not log in.")], 230 | [auth.RevokedIdTokenError(message="revoked id token"), _("Could not log in.")], 231 | ], 232 | ) 233 | def test_authenticate_raises_exception( 234 | mocker, side_effect, exc_message, firebase_authentication, fake_request 235 | ): 236 | mocker.patch( 237 | "firebase_auth.authentication.auth.verify_id_token", side_effect=side_effect 238 | ) 239 | 240 | with pytest.raises(exceptions.AuthenticationFailed) as exc: 241 | firebase_authentication.authenticate(fake_request) 242 | 243 | assert exc.value.detail == exc_message 244 | 245 | 246 | @pytest.mark.parametrize( 247 | "auth_prefix,realm,result_header", 248 | [("Bearer", "api", 'Bearer realm="api"'), ("Token", "api", 'Token realm="api"')], 249 | ) 250 | def test_authenticate_header( 251 | firebase_authentication, fake_request, auth_prefix, realm, result_header 252 | ): 253 | firebase_authentication.auth_header_prefix = auth_prefix 254 | 255 | header = firebase_authentication.authenticate_header(fake_request) 256 | 257 | assert header == result_header 258 | 259 | 260 | @pytest.mark.parametrize( 261 | "key,value", [("SERVICE_ACCOUNT_KEY_FILE", ""), ("EMAIL_VERIFICATION", False)] 262 | ) 263 | def test_default_settings(settings, key, value): 264 | settings.FIREBASE_AUTH = {} 265 | 266 | firebase_auth_settings.reload() 267 | 268 | assert getattr(firebase_auth_settings, key) == value 269 | --------------------------------------------------------------------------------