diff --git a/.github/workflows/basic_checks.yml b/.github/workflows/basic_checks.yml index 59d62eb..5be491a 100644 --- a/.github/workflows/basic_checks.yml +++ b/.github/workflows/basic_checks.yml @@ -22,20 +22,14 @@ jobs: python setup.py install pip install -r requirements.txt pip install -r requirements_test.txt - - name: Run Migrations - run: | - cd example - python manage.py migrate - name: Run Tests run: | - cd example - coverage run manage.py test + coverage run runtests.py coverage report - name: Coverage Badge uses: tj-actions/coverage-badge-py@v2 with: - working-directory: example - output: ../coverage.svg + output: coverage.svg - name: Verify Changed files uses: tj-actions/verify-changed-files@v14 @@ -46,7 +40,6 @@ jobs: - name: Commit files if: steps.verify-changed-files.outputs.files_changed == 'true' run: | - #mv example/coverage.svg coverage.svg git config --local user.email "github-actions[bot]@users.noreply.github.com" git config --local user.name "github-actions[bot]" git add coverage.svg diff --git a/README.md b/README.md index e841201..7abbed2 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,7 @@ Tidelift will coordinate the fix and disclosure. * [pulse-mind](https://github.com/pulse-mind) * [smark-1](https://github.com/smark-1) * [rafaelurben](https://github.com/rafaelurben) - +* [christian-thieme](https://github.com/christian-thieme) diff --git a/coverage.svg b/coverage.svg index 6bfc8fa..3438732 100644 --- a/coverage.svg +++ b/coverage.svg @@ -15,7 +15,7 @@ coverage coverage - 99% - 99% + 97% + 97% diff --git a/example/test_app/test_settings.py b/example/test_app/test_settings.py deleted file mode 100644 index a5189b6..0000000 --- a/example/test_app/test_settings.py +++ /dev/null @@ -1,136 +0,0 @@ -""" -Django settings for example project. - -Generated by 'django-admin startproject' using Django 2.0. - -For more information on this file, see -https://docs.djangoproject.com/en/2.0/topics/settings/ - -For the full list of settings and their values, see -https://docs.djangoproject.com/en/2.0/ref/settings/ -""" - -import os -from django.conf.global_settings import PASSWORD_HASHERS as DEFAULT_PASSWORD_HASHERS - -import passkeys - -# Build paths inside the project like this: os.path.join(BASE_DIR, ...) -BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - - -# Quick-start development settings - unsuitable for production -# See https://docs.djangoproject.com/en/2.0/howto/deployment/checklist/ - -# SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = '#9)q!_i3@pr-^3oda(e^3$x!kq3b4f33#5l@+=+&vuz+p6gb3g' - -# SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True - -ALLOWED_HOSTS = ['*'] - - -# Application definition - -INSTALLED_APPS = [ - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - 'test_app', - 'passkeys', - 'sslserver' -] - -MIDDLEWARE = [ - 'django.middleware.security.SecurityMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', -] - -ROOT_URLCONF = 'test_app.urls' - -TEMPLATES = [ - { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [os.path.join(BASE_DIR ,'example','templates' )], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', - ], - }, - }, -] - -WSGI_APPLICATION = 'test_app.wsgi.application' - - -# Database -# https://docs.djangoproject.com/en/2.0/ref/settings/#databases - -DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': 'test_db', - } -} - - -# Password validation -# https://docs.djangoproject.com/en/2.0/ref/settings/#auth-password-validators - -AUTH_PASSWORD_VALIDATORS = [ - { - 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', - }, -] - - -# Internationalization -# https://docs.djangoproject.com/en/2.0/topics/i18n/ - -LANGUAGE_CODE = 'en-us' - -TIME_ZONE = 'UTC' - -USE_I18N = True - -USE_L10N = True - -USE_TZ = True - - -# Static files (CSS, JavaScript, Images) -# https://docs.djangoproject.com/en/2.0/howto/static-files/ - -STATIC_URL = '/static/' -#STATIC_ROOT=(os.path.join(BASE_DIR,'static')) -STATICFILES_DIRS=[os.path.join(BASE_DIR,'static')] -LOGIN_URL="/auth/login" - -AUTHENTICATION_BACKENDS = ['passkeys.backend.PasskeyModelBackend'] - -FIDO_SERVER_ID="testserver" # Server rp id for FIDO2, it the full domain of your project -FIDO_SERVER_NAME="TestApp" -KEY_ATTACHMENT = passkeys.Attachment.PLATFORM -CSRF_TRUSTED_ORIGINS = ['http://localhost:8000','https://localhost:8000'] diff --git a/example/test_app/tests/test_current_platform.py b/example/test_app/tests/test_current_platform.py deleted file mode 100644 index 5988f38..0000000 --- a/example/test_app/tests/test_current_platform.py +++ /dev/null @@ -1,32 +0,0 @@ -from django.test import TestCase, RequestFactory - -from passkeys.FIDO2 import get_current_platform - - -class TestCurrentPlatform(TestCase): - def setUp(self) -> None: - self.request_factory = RequestFactory() - # if not getattr(self, "assertEquals", None): - # self.assertEquals = self.assertEqual - - def check_platform(self,user_agent, platform): - request = self.request_factory.get('/', HTTP_USER_AGENT=user_agent) - self.assertEqual(get_current_platform(request), platform) - - def test_mac(self): - self.check_platform("Mozilla/5.0 (Macintosh; Intel Mac OS X 13_4) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Safari/605.1.15","Apple") - def test_ios(self): - self.check_platform("Mozilla/5.0 (Macintosh; Intel Mac OS X 13_4) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Safari/605.1.15","Apple") - def test_ipad(self): - self.check_platform("Mozilla/5.0 (iPad; CPU OS 16_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Mobile/15E148 Safari/604.1","Apple") - - def test_chrome_mac(self): - self.check_platform("Mozilla/5.0 (Macintosh; Intel Mac OS X 13_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36","Chrome on Apple") - - def test_chrome_windows(self): - self.check_platform("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36","Microsoft") - - def test_android(self): - self.check_platform("Mozilla/5.0 (Linux; Android 10) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.5735.130 Mobile Safari/537.36","Google") - self.check_platform("Mozilla/5.0 (Linux; Android 10; SM-A205U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.5735.130 Mobile Safari/537.36","Google") - self.check_platform("Mozilla/5.0 (Linux; Android 10; LM-Q720) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.5735.130 Mobile Safari/537.36","Google") diff --git a/example/test_app/tests/test_fido.py b/example/test_app/tests/test_fido.py deleted file mode 100644 index b929db4..0000000 --- a/example/test_app/tests/test_fido.py +++ /dev/null @@ -1,142 +0,0 @@ -import json -from base64 import urlsafe_b64encode -from importlib import import_module - -from django.http import HttpRequest -from django.test import RequestFactory,TransactionTestCase, Client -from django.urls import reverse - -from django.conf import settings -from test_app.soft_webauthn import SoftWebauthnDevice - -from passkeys.models import UserPasskey - - -def get_server_id(request): - return request.META["SERVER_NAME"] + "1" - -def get_server_name(request): - return "MySite" - -class test_fido(TransactionTestCase): - def setUp(self) -> None: - - from django.contrib.auth import get_user_model - self.user_model = get_user_model() - if self.user_model.objects.filter(username="test").count()==0: - self.user = self.user_model.objects.create_user(username="test",password="test") - else: - self.user = self.user_model.objects.get(username="test") - self.client = Client() - settings.SESSION_ENGINE = 'django.contrib.sessions.backends.file' - engine = import_module(settings.SESSION_ENGINE) - #settings.SESSION_FILE_PATH = "/" - store = engine.SessionStore() - store.save(must_create=True) - self.session = store - self.client.cookies["sessionid"] = store.session_key - - self.client.post("/auth/login", {"username": "test", "password": "test", 'passkeys': ''}) - self.factory = RequestFactory() - - - def test_key_reg(self): - self.client.post('auth/login',{"usernaame":"test","password":"test","passkeys":""}) - r = self.client.get(reverse('passkeys:reg_begin')) - self.assertEqual(r.status_code, 200) - j = json.loads(r.content) - j['publicKey']['challenge'] = j['publicKey']['challenge'].encode("ascii") - s = SoftWebauthnDevice() - res = s.create(j, "https://" + j["publicKey"]["rp"]["id"]) - res["key_name"]="testKey" - u = reverse('passkeys:reg_complete') - r = self.client.post(u, data=json.dumps(res),headers={"USER_AGENT":""}, HTTP_USER_AGENT="", content_type="application/json") - try: - j = json.loads(r.content) - except Exception: - raise AssertionError("Failed to get the required JSON after reg_completed") - self.assertTrue("status" in j) - - self.assertEqual(j["status"], "OK") - self.assertEqual(UserPasskey.objects.latest('id').name, "testKey") - return s - - - def test_auto_key_name(self): - r = self.client.get(reverse('passkeys:reg_begin')) - self.assertEqual(r.status_code, 200) - j = json.loads(r.content) - j['publicKey']['challenge'] = j['publicKey']['challenge'].encode("ascii") - s = SoftWebauthnDevice() - res = s.create(j, "https://" + j["publicKey"]["rp"]["id"]) - u = reverse('passkeys:reg_complete') - r = self.client.post(u, data=json.dumps(res), HTTP_USER_AGENT="Mozilla/5.0 (Macintosh; Intel Mac OS X 13_4) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Safari/605.1.15", content_type="application/json") - try: - j = json.loads(r.content) - except Exception: - raise AssertionError("Failed to get the required JSON after reg_completed") - self.assertTrue("status" in j) - self.assertEqual(j["status"], "OK") - self.assertEqual(UserPasskey.objects.latest('id').name,"Apple") - return s - - def test_error_when_no_session(self): - res = {} - res["key_name"] = "testKey" - u = reverse('passkeys:reg_complete') - r = self.client.post(u, data=json.dumps(res), headers={"USER_AGENT": ""}, HTTP_USER_AGENT="", - content_type="application/json") - try: - j = json.loads(r.content) - except Exception: - raise AssertionError("Failed to get the required JSON after reg_completed") - self.assertTrue("status" in j) - self.assertEqual(j["status"], "ERR") - self.assertEqual(j["message"], "FIDO Status can't be found, please try again") - - def test_passkey_login(self): - authenticator = self.test_key_reg() - self.client.get('/auth/logout') - r = self.client.get(reverse('passkeys:auth_begin')) - self.assertEqual(r.status_code, 200) - j = json.loads(r.content) - j['publicKey']['challenge'] = j['publicKey']['challenge'].encode("ascii") - - res = authenticator.get(j, "https://" + j["publicKey"]["rpId"]) - u = reverse('login') - self.client.post(u, {'passkeys': json.dumps(res), "username": "", "password": ""},headers={"USER_AGENT":""}, HTTP_USER_AGENT="") - self.assertTrue(self.client.session.get('_auth_user_id',False)) - self.assertTrue(self.client.session.get("passkey",{}).get("passkey",False)) - self.assertEqual(self.client.session.get("passkey",{}).get("name"),"testKey") - - def test_base_username(self): - authenticator = self.test_key_reg() - self.client.get('/auth/logout') - session = self.session - session["base_username"]= "test" - session.save(must_create=True) - self.client.cookies["sessionid"] = session.session_key - r = self.client.get(reverse('passkeys:auth_begin')) - self.assertEqual(r.status_code, 200) - j = json.loads(r.content) - self.assertEqual(j['publicKey']['allowCredentials'][0]['id'],urlsafe_b64encode(authenticator.credential_id).decode("utf8").strip('=')) - - def test_passkey_login_no_session(self): - pass - - - def test_server_id_callable(self): - from test_app.tests.test_fido import get_server_id - settings.FIDO_SERVER_ID = get_server_id - r = self.client.get(reverse('passkeys:auth_begin')) - self.assertEqual(r.status_code, 200) - j = json.loads(r.content) - self.assertEqual(j['publicKey']['rpId'],'testserver1') - - def test_server_name_callable(self): - from test_app.tests.test_fido import get_server_name - settings.FIDO_SERVER_NAME = get_server_name - r = self.client.get(reverse('passkeys:reg_begin')) - self.assertEqual(r.status_code, 200) - j = json.loads(r.content) - self.assertEqual(j['publicKey']['rp']["name"],'MySite') diff --git a/example/test_app/tests/test_passkeys.py b/example/test_app/tests/test_passkeys.py deleted file mode 100644 index 7ba3558..0000000 --- a/example/test_app/tests/test_passkeys.py +++ /dev/null @@ -1,40 +0,0 @@ -from django.test import RequestFactory,TransactionTestCase, Client - -class test_passkeys(TransactionTestCase): - def setUp(self) -> None: - from django.contrib.auth import get_user_model - - self.user_model = get_user_model() - self.user = self.user_model.objects.create_user(username="test",password="test") - self.client = Client() - self.factory = RequestFactory() - - def test_raiseException(self): - from django.contrib.auth import authenticate - try: - authenticate(request=None,username="test",password="test") - self.assertFalse(True) - except Exception as e: - self.assertEqual(str(e),"request is required for passkeys.backend.PasskeyModelBackend") - - def test_not_add_passkeys_field(self): - request = self.factory.post("/auth/login",{"username":"","password":""}) - from django.contrib.auth import authenticate - try: - user = authenticate(request=request,username="",password="") - self.assertFalse(True) - except Exception as e: - self.assertEqual(str(e),"Can't find 'passkeys' key in request.POST, did you add the hidden input?") - - def test_username_password_failed_login(self): - self.client.post("/auth/login",{"username":"test","password":"test123",'passkeys':''}) - self.assertFalse(self.client.session.get('_auth_user_id',False)) - - def test_username_password_login(self): - self.client.post("/auth/login",{"username":"test","password":"test",'passkeys':''}) - self.assertTrue(self.client.session.get('_auth_user_id',False)) - self.assertFalse(self.client.session.get('passkey', {}).get('passkey', False)) - - def test_no_data(self): - self.client.post("/auth/login",{"username":"","password":"",'passkeys':''}) - self.assertFalse(self.client.session.get('_auth_user_id',False)) diff --git a/example/test_app/tests/test_views.py b/example/test_app/tests/test_views.py deleted file mode 100644 index be4fab8..0000000 --- a/example/test_app/tests/test_views.py +++ /dev/null @@ -1,46 +0,0 @@ -from django.test import TransactionTestCase, Client -from django.urls import reverse - -from passkeys.models import UserPasskey -from .test_fido import test_fido - -class test_views(TransactionTestCase): - - def setUp(self) -> None: - from django.contrib.auth import get_user_model - self.user_model = get_user_model() - #self.user = self.user_model.objects.create_user(username="test", password="test") - self.client = Client() - #self.client.post("/auth/login", {"username": "test", "password": "test", 'passkeys': ''}) - test = test_fido() - test.setUp() - self.authenticator = test.test_key_reg() - self.client.post("/auth/login", {"username": "test", "password": "test", 'passkeys': ''}) - self.user = self.user_model.objects.get(username="test") - - def test_disabling_key(self): - key = UserPasskey.objects.filter(user=self.user).latest('id') - self.client.post(reverse('passkeys:toggle'),{"id":key.id}) - self.assertFalse(UserPasskey.objects.get(id=key.id).enabled) - - self.client.post(reverse('passkeys:toggle'),{"id":key.id}) - self.assertTrue(UserPasskey.objects.get(id=key.id).enabled) - - def test_deleting_key(self): - key = UserPasskey.objects.filter(user=self.user).latest('id') - self.client.post(reverse('passkeys:delKey'),{"id":key.id}) - self.assertEqual(UserPasskey.objects.filter(id=key.id).count(), 0) - - def test_wrong_ownership(self): - test = test_fido() - test.setUp() - authenticator = test.test_key_reg() - key = UserPasskey.objects.filter(user=self.user).latest('id') - self.user = self.user_model.objects.create_user(username="test2", password="test2") - self.client.post("/auth/login", {"username": "test2", "password": "test2", 'passkeys': ''}) - r = self.client.post(reverse('passkeys:delKey'),{"id":key.id}) - self.assertEqual(r.status_code, 403) - self.assertEqual(r.content,b"Error: You own this token so you can't delete it") - r = self.client.post(reverse('passkeys:toggle'),{"id":key.id}) - self.assertEqual(r.status_code, 403) - self.assertEqual(r.content, b"Error: You own this token so you can't toggle it") diff --git a/passkeys/FIDO2.py b/passkeys/FIDO2.py index 345796f..187abd2 100644 --- a/passkeys/FIDO2.py +++ b/passkeys/FIDO2.py @@ -27,7 +27,7 @@ def enable_json_mapping(): def getUserCredentials(user): User = get_user_model() username_field = User.USERNAME_FIELD - filter_args = {"user__"+username_field : user} + filter_args = {"user__" + username_field: user} return [AttestedCredentialData(websafe_decode(uk.token)) for uk in UserPasskey.objects.filter(**filter_args)] @@ -36,12 +36,12 @@ def getServer(request=None): if callable(settings.FIDO_SERVER_ID): fido_server_id = settings.FIDO_SERVER_ID(request) else: - fido_server_id = settings.FIDO_SERVER_ID + fido_server_id = settings.FIDO_SERVER_ID # pragma: no cover if callable(settings.FIDO_SERVER_NAME): fido_server_name = settings.FIDO_SERVER_NAME(request) else: - fido_server_name = settings.FIDO_SERVER_NAME + fido_server_name = settings.FIDO_SERVER_NAME # pragma: no cover rp = PublicKeyCredentialRpEntity(id=fido_server_id, name=fido_server_name) return Fido2Server(rp) @@ -57,22 +57,26 @@ def get_current_platform(request): return "Google" elif "Windows" in ua.os.family: return "Microsoft" - else: return "Key" + else: + return "Key" # pragma: no cover + @login_required def reg_begin(request): """Starts registering a new FIDO Device, called from API""" enable_json_mapping() server = getServer(request) - auth_attachment = getattr(settings,'KEY_ATTACHMENT', None) + auth_attachment = getattr(settings, 'KEY_ATTACHMENT', None) registration_data, state = server.register_begin({ - u'id': urlsafe_b64encode(request.user.username.encode("utf8")), + u'id': urlsafe_b64encode(request.user.username.encode("utf8")), u'name': request.user.get_username(), u'displayName': request.user.get_full_name() - }, getUserCredentials(request.user), authenticator_attachment = auth_attachment, resident_key_requirement=fido2.webauthn.ResidentKeyRequirement.PREFERRED) + }, getUserCredentials(request.user), authenticator_attachment=auth_attachment, + resident_key_requirement=fido2.webauthn.ResidentKeyRequirement.PREFERRED) request.session['fido2_state'] = state return JsonResponse(dict(registration_data)) - #return HttpResponse(cbor.encode(registration_data), content_type = 'application/octet-stream') + # return HttpResponse(cbor.encode(registration_data), content_type = 'application/octet-stream') + @login_required @csrf_exempt @@ -80,36 +84,38 @@ def reg_complete(request): """Completes the registeration, called by API""" try: if not "fido2_state" in request.session: - return JsonResponse({'status': 'ERR', "message": "FIDO Status can't be found, please try again"}, status=401) + return JsonResponse({'status': 'ERR', "message": "FIDO Status can't be found, please try again"}, + status=401) enable_json_mapping() data = json.loads(request.body) - name = data.pop("key_name",'') + name = data.pop("key_name", '') server = getServer(request) - auth_data = server.register_complete(request.session.pop("fido2_state"), response = data) + auth_data = server.register_complete(request.session.pop("fido2_state"), response=data) encoded = websafe_encode(auth_data.credential_data) platform = get_current_platform(request) if name == "": name = platform - uk = UserPasskey(user=request.user, token=encoded, name = name,platform=platform) + uk = UserPasskey(user=request.user, token=encoded, name=name, platform=platform) if data.get("id"): uk.credential_id = data.get('id') uk.save() return JsonResponse({'status': 'OK'}) - except Exception as exp: # pragma: no cover - print(traceback.format_exc()) # pragma: no cover - return JsonResponse({'status': 'ERR', "message": "Error on server, please try again later"}, status=500 ) # pragma: no cover + except Exception as exp: # pragma: no cover + print(traceback.format_exc()) # pragma: no cover + return JsonResponse({'status': 'ERR', "message": "Error on server, please try again later"}, + status=500) # pragma: no cover def auth_begin(request): enable_json_mapping() server = getServer(request) - credentials=[] + credentials = [] username = None if "base_username" in request.session: username = request.session["base_username"] if request.user.is_authenticated: - username = request.user.username + username = request.user.username # pragma: no cover if username: credentials = getUserCredentials(username) auth_data, state = server.authenticate_begin(credentials) @@ -124,7 +130,7 @@ def auth_complete(request): server = getServer(request) data = json.loads(request.POST["passkeys"]) key = None - #userHandle = data.get("response",{}).get('userHandle') + # userHandle = data.get("response",{}).get('userHandle') credential_id = data['id'] # # if userHandle: @@ -136,24 +142,27 @@ def auth_complete(request): # if keys.exists(): # credentials = [AttestedCredentialData(websafe_decode(keys[0].properties["device"]))] - keys = UserPasskey.objects.filter(credential_id = credential_id,enabled=1) + keys = UserPasskey.objects.filter(credential_id=credential_id, enabled=1) if keys.exists(): - credentials=[AttestedCredentialData(websafe_decode(keys[0].token))] + credentials = [AttestedCredentialData(websafe_decode(keys[0].token))] key = keys[0] try: cred = server.authenticate_complete( - request.session.pop('fido2_state'), credentials = credentials, response = data + request.session.pop('fido2_state'), credentials=credentials, response=data ) - except ValueError: # pragma: no cover - return None # pragma: no cover - except Exception as excep: # pragma: no cover - raise Exception(excep) # pragma: no cover + except ValueError: # pragma: no cover + return None # pragma: no cover + except Exception as excep: # pragma: no cover + raise Exception(excep) # pragma: no cover + if key: key.last_used = timezone.now() - request.session["passkey"] = {'passkey': True, 'name': key.name, "id":key.id, "platform": key.platform, - 'cross_platform': get_current_platform(request) != key.platform} + request.session["passkey"] = {'passkey': True, 'name': key.name, "id": key.id, "platform": key.platform, + 'cross_platform': get_current_platform(request) != key.platform} + request.session.save() key.save() return key.user - return None # pragma: no cover + + return None # pragma: no cover diff --git a/passkeys/backend.py b/passkeys/backend.py index c1eb6af..e0c7b8b 100644 --- a/passkeys/backend.py +++ b/passkeys/backend.py @@ -1,19 +1,26 @@ from django.contrib.auth.backends import ModelBackend from .FIDO2 import auth_complete +import logging + +LOGGER = logging.getLogger(__name__) + + class PasskeyModelBackend(ModelBackend): - def authenticate(self, request, username='',password='', **kwargs): + def authenticate(self, request, username='', password='', **kwargs): + if username != '' and password != '': + if request is not None: + request.session["passkey"] = {'passkey': False} - if request is None: - raise Exception('request is required for passkeys.backend.PasskeyModelBackend') + return super().authenticate(request, username=username, password=password, **kwargs) - if username!='' and password != '': - request.session["passkey"]={'passkey':False} - return super().authenticate(request,username=username,password=password, **kwargs) + if request is None: + LOGGER.error( + "Please pass the request parameter to the authenticate method for this authentication backend to work.") + return None passkeys = request.POST.get('passkeys') - if passkeys is None: - raise Exception("Can't find 'passkeys' key in request.POST, did you add the hidden input?") - if passkeys != '': - return auth_complete(request) - return None + if passkeys in (None, ''): + return None + + return auth_complete(request) diff --git a/passkeys/models.py b/passkeys/models.py index 34721d0..88a4f72 100644 --- a/passkeys/models.py +++ b/passkeys/models.py @@ -4,11 +4,11 @@ class UserPasskey(models.Model): user_model = get_user_model() - user = models.ForeignKey(user_model,on_delete=models.CASCADE) + user = models.ForeignKey(user_model, on_delete=models.CASCADE) name = models.CharField(max_length=255) - enabled= models.BooleanField(default=True) - platform = models.CharField(max_length=255,default='') + enabled = models.BooleanField(default=True) + platform = models.CharField(max_length=255, default='') added_on = models.DateTimeField(auto_now_add=True) - last_used = models.DateTimeField(null=True,default=None) + last_used = models.DateTimeField(null=True, default=None) credential_id = models.CharField(max_length=255, unique=True) - token = models.CharField(max_length=255, null=False) \ No newline at end of file + token = models.CharField(max_length=255, null=False) diff --git a/passkeys/tests/__init__.py b/passkeys/tests/__init__.py new file mode 100644 index 0000000..7bc027f --- /dev/null +++ b/passkeys/tests/__init__.py @@ -0,0 +1,4 @@ +from .platform import CurrentPlatformTestCase +from .fido import FidoTestCase +from .backend import PasskeyModelBackendTestCase +from .views import ViewsTestCase diff --git a/passkeys/tests/backend.py b/passkeys/tests/backend.py new file mode 100644 index 0000000..5baa2d8 --- /dev/null +++ b/passkeys/tests/backend.py @@ -0,0 +1,26 @@ +from django.contrib.auth import get_user_model +from django.test import TransactionTestCase + +from passkeys.backend import PasskeyModelBackend + + +class PasskeyModelBackendTestCase(TransactionTestCase): + + def setUp(self) -> None: + self.user_model = get_user_model() + self.user = self.user_model.objects.create_user(username="test", password="test") + + def test_username_password_failed(self): + backend = PasskeyModelBackend() + user = backend.authenticate(None, 'test', 'test123') + self.assertEqual(user, None) + + def test_username_password_success(self): + backend = PasskeyModelBackend() + user = backend.authenticate(None, 'test', 'test') + self.assertNotEqual(user, None) + + def test_username_password_success(self): + backend = PasskeyModelBackend() + user = backend.authenticate(None, 'test', 'test') + self.assertNotEqual(user, None) diff --git a/passkeys/tests/fido.py b/passkeys/tests/fido.py new file mode 100644 index 0000000..db7371b --- /dev/null +++ b/passkeys/tests/fido.py @@ -0,0 +1,169 @@ +import json +from base64 import urlsafe_b64encode + +from django.conf import settings +from django.contrib.auth import get_user_model +from django.test import RequestFactory, TransactionTestCase +from django.urls import reverse + +from passkeys.backend import PasskeyModelBackend +from passkeys.models import UserPasskey +from passkeys.tests.soft_webauthn import SoftWebauthnDevice + +USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 13_4) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Safari/605.1.15" + +PLATFORM = "Apple" + + +def get_server_id(request): + return "fido-server-id" + + +def get_server_name(request): + return "fido-server-name" + + +class FidoTestCase(TransactionTestCase): + def setUp(self) -> None: + settings.FIDO_SERVER_ID = get_server_id + settings.FIDO_SERVER_NAME = get_server_name + + self.user_model = get_user_model() + self.user_a = self.user_model.objects.create_user(username="a", password="a") + self.user_b = self.user_model.objects.create_user(username="b", password="b") + + def test_server_id_callable(self): + self.client.logout() + settings.FIDO_SERVER_ID = get_server_id + response = self.client.get(reverse('passkeys:auth_begin')) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()['publicKey']['rpId'], 'fido-server-id') + + def test_server_name_callable(self): + self.client.login(username="b", password="b") + response = self.client.get(reverse('passkeys:reg_begin')) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()['publicKey']['rp']["name"], 'fido-server-name') + self.client.logout() + + def __test_registration(self, key_name=None): + # make sure no keys exist + UserPasskey.objects.all().delete() + + # check anonymous access + response = self.client.get(reverse('passkeys:reg_begin')) + self.assertEqual(response.status_code, 302) + + response = self.client.get(reverse('passkeys:reg_complete')) + self.assertEqual(response.status_code, 302) + + # login + self.client.login(username="b", password="b") + + # start registration + response = self.client.get(reverse('passkeys:reg_begin')) + self.assertEqual(response.status_code, 200) + + data = json.loads(response.content) + data['publicKey']['challenge'] = data['publicKey']['challenge'].encode("ascii") + + # creqte authentication data + authenticator = SoftWebauthnDevice() + + request = authenticator.create(data, "https://" + data["publicKey"]["rp"]["id"]) + if key_name is not None: + request["key_name"] = key_name + + # complete registration + response = self.client.post( + reverse('passkeys:reg_complete'), + data=json.dumps(request), + headers={"USER_AGENT": USER_AGENT}, + HTTP_USER_AGENT=USER_AGENT, + content_type="application/json" + ) + + self.assertEqual(response.status_code, 200) + + key = UserPasskey.objects.latest('id') + self.assertNotEqual(key, None) + + if key_name is None: + self.assertEqual(key.name, key.platform) + else: + self.assertEqual(key.name, key_name) + + self.assertEqual(key.platform, PLATFORM) + self.assertEqual(key.user, self.user_b) + + return authenticator + + def test_registration(self): + self.__test_registration('test-key') + + def test_registration_auto_key_name(self): + self.__test_registration() + + def test_base_username(self): + authenticator = self.__test_registration() + + self.client.logout() + + session = self.client.session + session.update({"base_username": "b"}) + session.save() + + response = self.client.get(reverse('passkeys:auth_begin')) + self.assertEqual(response.status_code, 200) + + self.assertEqual(response.json()['publicKey']['allowCredentials'][0]['id'], + urlsafe_b64encode(authenticator.credential_id).decode("utf8").strip('=')) + + def test_error_when_no_session(self): + self.client.login(username='a', password='a') + res = {"key_name": "testKey"} + url = reverse('passkeys:reg_complete') + r = self.client.post(url, data=json.dumps(res), headers={"USER_AGENT": ""}, HTTP_USER_AGENT="", + content_type="application/json") + try: + j = json.loads(r.content) + except Exception: + raise AssertionError("Failed to get the required JSON after reg_completed") + + self.assertTrue("status" in j) + self.assertEqual(j["status"], "ERR") + self.assertEqual(j["message"], "FIDO Status can't be found, please try again") + + def test_passkey_login(self): + authenticator = self.__test_registration("TestKey") + self.client.logout() + + response = self.client.get(reverse('passkeys:auth_begin')) + self.assertEqual(response.status_code, 200) + result = response.json() + result['publicKey']['challenge'] = result['publicKey']['challenge'].encode("ascii") + + res = authenticator.get(result, "https://" + result["publicKey"]["rpId"]) + + # build a fake request for the authentication backend + factory = RequestFactory() + request = factory.post('/login/', + { + "username": "", + "password": "", + 'passkeys': json.dumps(res) + }, + headers={"USER_AGENT": USER_AGENT}) + + # set user agent for running tests in tox + request.META['HTTP_USER_AGENT'] = USER_AGENT + + # keeping client session is required + request.session = self.client.session + + backend = PasskeyModelBackend() + user = backend.authenticate(request, "", "") + + self.assertEqual(user, self.user_b) + self.assertTrue(self.client.session.get("passkey", {}).get("passkey", True)) + self.assertEqual(self.client.session.get("passkey", {}).get("name"), "TestKey") diff --git a/passkeys/tests/platform.py b/passkeys/tests/platform.py new file mode 100644 index 0000000..5e02e26 --- /dev/null +++ b/passkeys/tests/platform.py @@ -0,0 +1,49 @@ +from django.test import TestCase, RequestFactory + +from passkeys.FIDO2 import get_current_platform + + +class CurrentPlatformTestCase(TestCase): + + def setUp(self) -> None: + self.request_factory = RequestFactory() + + def check_platform(self, user_agent, platform): + request = self.request_factory.get('/', HTTP_USER_AGENT=user_agent) + self.assertEqual(get_current_platform(request), platform) + + def test_mac(self): + self.check_platform( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 13_4) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Safari/605.1.15", + "Apple") + + def test_ios(self): + self.check_platform( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 13_4) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Safari/605.1.15", + "Apple") + + def test_ipad(self): + self.check_platform( + "Mozilla/5.0 (iPad; CPU OS 16_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Mobile/15E148 Safari/604.1", + "Apple") + + def test_chrome_mac(self): + self.check_platform( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 13_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "Chrome on Apple") + + def test_chrome_windows(self): + self.check_platform( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "Microsoft") + + def test_android(self): + self.check_platform( + "Mozilla/5.0 (Linux; Android 10) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.5735.130 Mobile Safari/537.36", + "Google") + self.check_platform( + "Mozilla/5.0 (Linux; Android 10; SM-A205U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.5735.130 Mobile Safari/537.36", + "Google") + self.check_platform( + "Mozilla/5.0 (Linux; Android 10; LM-Q720) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.5735.130 Mobile Safari/537.36", + "Google") diff --git a/example/test_app/soft_webauthn.py b/passkeys/tests/soft_webauthn.py similarity index 98% rename from example/test_app/soft_webauthn.py rename to passkeys/tests/soft_webauthn.py index 3e36738..40cfc5b 100644 --- a/example/test_app/soft_webauthn.py +++ b/passkeys/tests/soft_webauthn.py @@ -43,7 +43,7 @@ def cred_init(self, rp_id, user_handle): def cred_as_attested(self): """return current credential as AttestedCredentialData""" - return AttestedCredentialData.create( + return AttestedCredentialData.create( #noqa self.aaguid, self.credential_id, ES256.from_cryptography_key(self.private_key.public_key())) diff --git a/passkeys/tests/views.py b/passkeys/tests/views.py new file mode 100644 index 0000000..4f8c633 --- /dev/null +++ b/passkeys/tests/views.py @@ -0,0 +1,101 @@ +from django.test import TransactionTestCase, Client +from django.urls import reverse +from django.contrib.auth import get_user_model + +from passkeys.models import UserPasskey + + +class ViewsTestCase(TransactionTestCase): + + def setUp(self) -> None: + self.user_model = get_user_model() + + self.user_a = self.user_model.objects.create_user(username="a", password="a") + self.user_b = self.user_model.objects.create_user(username="b", password="b") + + UserPasskey.objects.create(user=self.user_a, name='UserPasskey A', credential_id='userpasskey-a', enabled=True) + UserPasskey.objects.create(user=self.user_b, name='UserPasskey B', credential_id='userpasskey-b', enabled=True) + + def test_index(self): + self.client.logout() + + # anonymous access + response = self.client.post(reverse('passkeys:home')) + self.assertEqual(response.status_code, 302) + + # login + self.client.login(username="a", password="a") + + # retrieve key list, check if correct keys are listed + response = self.client.post(reverse('passkeys:home')) + self.assertEqual(response.status_code, 200) + + self.assertContains(response, 'UserPasskey A') + self.assertNotContains(response, 'UserPasskey B') + + def test_key_toggle(self): + self.client.logout() + + key = UserPasskey.objects.filter(user=self.user_a).latest('pk') + + # no anonymous access + response = self.client.post(reverse('passkeys:toggle'), {"id": key.id}) + self.assertEqual(response.status_code, 302) + + # login + self.client.login(username="a", password="a") + + # enable key + response = self.client.post(reverse('passkeys:toggle'), {"id": key.id}) + self.assertEqual(response.status_code, 200) + key.refresh_from_db() + self.assertFalse(key.enabled) + + # disable key + response = self.client.post(reverse('passkeys:toggle'), {"id": key.id}) + self.assertEqual(response.status_code, 200) + key.refresh_from_db() + self.assertTrue(UserPasskey.objects.get(id=key.id).enabled) + + # ownership error + key = UserPasskey.objects.filter(user=self.user_b).latest('pk') + response = self.client.post(reverse('passkeys:toggle'), {"id": key.id}) + self.assertEqual(response.status_code, 403) + key.refresh_from_db() + self.assertTrue(UserPasskey.objects.get(id=key.id).enabled) + + # invalid key id + response = self.client.post(reverse('passkeys:toggle'), {"id": 9999999}) + self.assertEqual(response.status_code, 403) + key.refresh_from_db() + self.assertTrue(UserPasskey.objects.get(id=key.id).enabled) + + response = self.client.post(reverse('passkeys:toggle'), {}) + self.assertEqual(response.status_code, 403) + def test_key_delete(self): + self.client.logout() + key = UserPasskey.objects.filter(user=self.user_a).latest('pk') + + # no anonymous access + response = self.client.post(reverse('passkeys:delKey'), {"id": key.id}) + self.assertEqual(response.status_code, 302) + + # login + self.client.login(username="a", password="a") + + # successful delete + self.client.post(reverse('passkeys:delKey'), {"id": key.id}) + self.assertEqual(UserPasskey.objects.filter(id=key.id).count(), 0) + + # ownership error + key = UserPasskey.objects.filter(user=self.user_b).latest('pk') + response = self.client.post(reverse('passkeys:delKey'), {"id": key.id}) + self.assertEqual(response.status_code, 403) + + # invalid key id + response = self.client.post(reverse('passkeys:delKey'), {"id": 9999999}) + self.assertEqual(response.status_code, 403) + + #Missing Parameter + response = self.client.post(reverse('passkeys:delKey'), {}) + self.assertEqual(response.status_code, 403) diff --git a/passkeys/views.py b/passkeys/views.py index fe63888..b0a229f 100644 --- a/passkeys/views.py +++ b/passkeys/views.py @@ -5,33 +5,41 @@ from .models import UserPasskey + @login_required -def index(request,enroll=False): # noqa - keys = UserPasskey.objects.filter(user=request.user) # pragma: no cover - return render(request,'passkeys/passkeys.html',{"keys":keys,"enroll":enroll}) # pragma: no cover +def index(request, enroll=False): # noqa + keys = UserPasskey.objects.filter(user=request.user) # pragma: no cover + return render(request, 'passkeys/passkeys.html', {"keys": keys, "enroll": enroll}) # pragma: no cover + @require_http_methods(["POST"]) @login_required def delKey(request): - id=request.POST.get("id") - if not id: + pk = request.POST.get("id") + if not pk: return HttpResponse("Error: You are missing a key", status=403) - key=UserPasskey.objects.get(id=id) - if key.user.pk == request.user.pk: - key.delete() - return HttpResponse("Deleted Successfully") - return HttpResponse("Error: You own this token so you can't delete it", status=403) + + key = UserPasskey.objects.filter(user=request.user, pk=pk).first() + + if key is None: + return HttpResponse("Error: You own this token so you can't delete it", status=403) + + key.delete() + return HttpResponse("Deleted Successfully") + @require_http_methods(["POST"]) @login_required def toggleKey(request): - id=request.POST.get("id") - if not id: + pk = request.POST.get("id") + if not pk: return HttpResponse("Error: You are missing a key", status=403) - q=UserPasskey.objects.filter(user=request.user, id=id) - if q.count()==1: - key=q[0] - key.enabled=not key.enabled - key.save() - return HttpResponse("OK") - return HttpResponse("Error: You own this token so you can't toggle it", status=403) + + key = UserPasskey.objects.filter(user=request.user, pk=pk).first() + + if key is None: + return HttpResponse("Error: You own this token so you can't toggle it", status=403) + + key.enabled = not key.enabled + key.save() + return HttpResponse("OK") diff --git a/runtests.py b/runtests.py new file mode 100644 index 0000000..965e347 --- /dev/null +++ b/runtests.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python3 +import os +import sys + +import django +from django.conf import settings +from django.test.utils import get_runner + +if __name__ == "__main__": + os.environ["DJANGO_SETTINGS_MODULE"] = "tests.settings" + django.setup() + TestRunner = get_runner(settings) + test_runner = TestRunner() + failures = test_runner.run_tests(["passkeys"]) + sys.exit(bool(failures)) diff --git a/example/test_app/tests/__init__.py b/tests/__init__.py similarity index 100% rename from example/test_app/tests/__init__.py rename to tests/__init__.py diff --git a/tests/settings.py b/tests/settings.py new file mode 100644 index 0000000..c6d4624 --- /dev/null +++ b/tests/settings.py @@ -0,0 +1,78 @@ +from pathlib import Path + +# Build paths inside the project like this: os.path.join(BASE_DIR, ...) +BASE_DIR = Path(__file__).resolve().parent + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = 'secret' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'passkeys', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'tests.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [BASE_DIR / 'templates'], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + } +} + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + +STATIC_URL = '/static/' + +AUTHENTICATION_BACKENDS = ['passkeys.backend.PasskeyModelBackend'] + +FIDO_SERVER_ID = "localhost" # Server rp id for FIDO2, it the full domain of your project + +FIDO_SERVER_NAME = "TestApp" + +KEY_ATTACHMENT = None # Set None to allow all authenticator attachment diff --git a/tests/templates/base.html b/tests/templates/base.html new file mode 100644 index 0000000..b8e230b --- /dev/null +++ b/tests/templates/base.html @@ -0,0 +1,3 @@ +{% block head %}{% endblock %} + +{% block content %}{% endblock %} \ No newline at end of file diff --git a/tests/urls.py b/tests/urls.py new file mode 100644 index 0000000..d7a6f8f --- /dev/null +++ b/tests/urls.py @@ -0,0 +1,5 @@ +from django.urls import path, include + +urlpatterns = [ + path("passkeys/", include("passkeys.urls")), +] diff --git a/tox.ini b/tox.ini index c9d2394..dce2e0d 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,5 @@ [tox] envlist= - #docs, py37-django{20,21,22,32}, py38-django{22,32,40,41,42}, py39-django{22,32,40,41,42}, @@ -10,8 +9,6 @@ envlist= [testenv] -changedir= - example/ deps = django22: django>=2.2,<2.3 django32: django>=3.2,<3.3 @@ -22,13 +19,11 @@ deps = django51: django>=5.1,<5.2 ua-parser user-agents - django-sslserver -rrequirements_test.txt -setenv = - DJANGO_SETTINGS_MODULE = test_app.test_settings allowlist_externals = coverage + commands = - coverage run manage.py test + coverage run runtests.py