forked from CGM_Public/pretix_original
Update webauthn requirement from ==0.4.* to ==2.0.* (#3880)
* Get rid of unmaintained dependency python-u2flib-server * Update webauthn requirement from ==0.4.* to ==2.0.* * Fix tests * Update src/pretix/control/views/auth.py Co-authored-by: Richard Schreiber <schreiber@rami.io> * Update src/pretix/control/views/auth.py Co-authored-by: Richard Schreiber <schreiber@rami.io> * Update src/pretix/control/views/user.py Co-authored-by: Richard Schreiber <schreiber@rami.io> * Update src/pretix/control/views/user.py Co-authored-by: Richard Schreiber <schreiber@rami.io> * Update src/pretix/control/views/user.py Co-authored-by: Richard Schreiber <schreiber@rami.io> --------- Co-authored-by: Richard Schreiber <schreiber@rami.io>
This commit is contained in:
co-authored by
Richard Schreiber
parent
7b5ce5e198
commit
57738f19bf
+1
-1
@@ -102,7 +102,7 @@ dependencies = [
|
|||||||
"tqdm==4.*",
|
"tqdm==4.*",
|
||||||
"vat_moss_forked==2020.3.20.0.11.0",
|
"vat_moss_forked==2020.3.20.0.11.0",
|
||||||
"vobject==0.9.*",
|
"vobject==0.9.*",
|
||||||
"webauthn==0.4.*",
|
"webauthn==2.0.*",
|
||||||
"zeep==4.2.*"
|
"zeep==4.2.*"
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -37,9 +37,7 @@ import json
|
|||||||
import operator
|
import operator
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from functools import reduce
|
from functools import reduce
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
import webauthn
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import (
|
from django.contrib.auth.models import (
|
||||||
AbstractBaseUser, BaseUserManager, PermissionsMixin,
|
AbstractBaseUser, BaseUserManager, PermissionsMixin,
|
||||||
@@ -53,11 +51,12 @@ from django.utils.timezone import now
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django_otp.models import Device
|
from django_otp.models import Device
|
||||||
from django_scopes import scopes_disabled
|
from django_scopes import scopes_disabled
|
||||||
|
from webauthn.helpers.structs import PublicKeyCredentialDescriptor
|
||||||
|
|
||||||
from pretix.base.i18n import language
|
from pretix.base.i18n import language
|
||||||
from pretix.helpers.urls import build_absolute_uri
|
from pretix.helpers.urls import build_absolute_uri
|
||||||
|
|
||||||
from ...helpers.u2f import pub_key_from_der, websafe_decode, websafe_encode
|
from ...helpers.u2f import pub_key_from_der, websafe_decode
|
||||||
from .base import LoggingMixin
|
from .base import LoggingMixin
|
||||||
|
|
||||||
|
|
||||||
@@ -606,7 +605,12 @@ class U2FDevice(Device):
|
|||||||
json_data = models.TextField()
|
json_data = models.TextField()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def webauthnuser(self):
|
def webauthndevice(self):
|
||||||
|
d = json.loads(self.json_data)
|
||||||
|
return PublicKeyCredentialDescriptor(websafe_decode(d['keyHandle']))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def webauthnpubkey(self):
|
||||||
d = json.loads(self.json_data)
|
d = json.loads(self.json_data)
|
||||||
# We manually need to convert the pubkey from DER format (used in our
|
# We manually need to convert the pubkey from DER format (used in our
|
||||||
# former U2F implementation) to the format required by webauthn. This
|
# former U2F implementation) to the format required by webauthn. This
|
||||||
@@ -618,16 +622,7 @@ class U2FDevice(Device):
|
|||||||
pub_key.public_numbers().x, pub_key.public_numbers().y
|
pub_key.public_numbers().x, pub_key.public_numbers().y
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return webauthn.WebAuthnUser(
|
return pub_key
|
||||||
d['keyHandle'],
|
|
||||||
self.user.email,
|
|
||||||
str(self.user),
|
|
||||||
settings.SITE_URL,
|
|
||||||
d['keyHandle'],
|
|
||||||
websafe_encode(pub_key),
|
|
||||||
1,
|
|
||||||
urlparse(settings.SITE_URL).netloc
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class WebAuthnDevice(Device):
|
class WebAuthnDevice(Device):
|
||||||
@@ -639,14 +634,9 @@ class WebAuthnDevice(Device):
|
|||||||
sign_count = models.IntegerField(default=0)
|
sign_count = models.IntegerField(default=0)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def webauthnuser(self):
|
def webauthndevice(self):
|
||||||
return webauthn.WebAuthnUser(
|
return PublicKeyCredentialDescriptor(websafe_decode(self.credential_id))
|
||||||
self.ukey,
|
|
||||||
self.user.email,
|
@property
|
||||||
str(self.user),
|
def webauthnpubkey(self):
|
||||||
settings.SITE_URL,
|
return websafe_decode(self.pub_key)
|
||||||
self.credential_id,
|
|
||||||
self.pub_key,
|
|
||||||
self.sign_count,
|
|
||||||
urlparse(settings.SITE_URL).netloc
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -32,11 +32,11 @@
|
|||||||
# Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is
|
# Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is
|
||||||
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
# License for the specific language governing permissions and limitations under the License.
|
# License for the specific language governing permissions and limitations under the License.
|
||||||
|
import base64
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote, urlparse
|
||||||
|
|
||||||
import webauthn
|
import webauthn
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@@ -54,6 +54,7 @@ from django.utils.http import url_has_allowed_host_and_scheme
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.views.generic import TemplateView
|
from django.views.generic import TemplateView
|
||||||
from django_otp import match_token
|
from django_otp import match_token
|
||||||
|
from webauthn.helpers import generate_challenge
|
||||||
|
|
||||||
from pretix.base.auth import get_auth_backends
|
from pretix.base.auth import get_auth_backends
|
||||||
from pretix.base.forms.auth import (
|
from pretix.base.forms.auth import (
|
||||||
@@ -62,7 +63,6 @@ from pretix.base.forms.auth import (
|
|||||||
from pretix.base.models import TeamInvite, U2FDevice, User, WebAuthnDevice
|
from pretix.base.models import TeamInvite, U2FDevice, User, WebAuthnDevice
|
||||||
from pretix.base.services.mail import SendMailException
|
from pretix.base.services.mail import SendMailException
|
||||||
from pretix.helpers.http import redirect_to_url
|
from pretix.helpers.http import redirect_to_url
|
||||||
from pretix.helpers.webauthn import generate_challenge
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -389,6 +389,10 @@ def get_u2f_appid(request):
|
|||||||
return settings.SITE_URL
|
return settings.SITE_URL
|
||||||
|
|
||||||
|
|
||||||
|
def get_webauthn_rp_id(request):
|
||||||
|
return urlparse(settings.SITE_URL).hostname
|
||||||
|
|
||||||
|
|
||||||
class Login2FAView(TemplateView):
|
class Login2FAView(TemplateView):
|
||||||
template_name = 'pretixcontrol/auth/login_2fa.html'
|
template_name = 'pretixcontrol/auth/login_2fa.html'
|
||||||
|
|
||||||
@@ -427,25 +431,41 @@ class Login2FAView(TemplateView):
|
|||||||
devices = U2FDevice.objects.filter(user=self.user)
|
devices = U2FDevice.objects.filter(user=self.user)
|
||||||
|
|
||||||
for d in devices:
|
for d in devices:
|
||||||
|
credential_current_sign_count = d.sign_count if isinstance(d, WebAuthnDevice) else 0
|
||||||
try:
|
try:
|
||||||
wu = d.webauthnuser
|
webauthn_assertion_response = webauthn.verify_authentication_response(
|
||||||
|
credential=resp,
|
||||||
if isinstance(d, U2FDevice):
|
expected_challenge=base64.b64decode(challenge),
|
||||||
# RP_ID needs to be appId for U2F devices, but we can't
|
expected_rp_id=get_webauthn_rp_id(self.request),
|
||||||
# set it that way in U2FDevice.webauthnuser, since that
|
expected_origin=settings.SITE_URL,
|
||||||
# breaks the frontend part.
|
credential_public_key=d.webauthnpubkey,
|
||||||
wu.rp_id = settings.SITE_URL
|
credential_current_sign_count=credential_current_sign_count,
|
||||||
|
|
||||||
webauthn_assertion_response = webauthn.WebAuthnAssertionResponse(
|
|
||||||
wu,
|
|
||||||
resp,
|
|
||||||
challenge,
|
|
||||||
settings.SITE_URL,
|
|
||||||
uv_required=False # User Verification
|
|
||||||
)
|
)
|
||||||
sign_count = webauthn_assertion_response.verify()
|
sign_count = webauthn_assertion_response.new_sign_count
|
||||||
|
if sign_count < credential_current_sign_count:
|
||||||
|
raise Exception("Possible replay attack, sign count not higher")
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception('U2F login failed')
|
if isinstance(d, U2FDevice):
|
||||||
|
# https://www.w3.org/TR/webauthn/#sctn-appid-extension says
|
||||||
|
# "When verifying the assertion, expect that the rpIdHash MAY be the hash of the AppID instead of the RP ID."
|
||||||
|
try:
|
||||||
|
webauthn_assertion_response = webauthn.verify_authentication_response(
|
||||||
|
credential=resp,
|
||||||
|
expected_challenge=base64.b64decode(challenge),
|
||||||
|
expected_rp_id=get_u2f_appid(self.request),
|
||||||
|
expected_origin=settings.SITE_URL,
|
||||||
|
credential_public_key=d.webauthnpubkey,
|
||||||
|
credential_current_sign_count=credential_current_sign_count,
|
||||||
|
)
|
||||||
|
if webauthn_assertion_response.new_sign_count < 1:
|
||||||
|
raise Exception("Possible replay attack, sign count set")
|
||||||
|
except Exception:
|
||||||
|
logger.exception('U2F login failed')
|
||||||
|
else:
|
||||||
|
valid = True
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
logger.exception('Webauthn login failed')
|
||||||
else:
|
else:
|
||||||
if isinstance(d, WebAuthnDevice):
|
if isinstance(d, WebAuthnDevice):
|
||||||
d.sign_count = sign_count
|
d.sign_count = sign_count
|
||||||
@@ -471,23 +491,24 @@ class Login2FAView(TemplateView):
|
|||||||
ctx = super().get_context_data()
|
ctx = super().get_context_data()
|
||||||
if 'webauthn_challenge' in self.request.session:
|
if 'webauthn_challenge' in self.request.session:
|
||||||
del self.request.session['webauthn_challenge']
|
del self.request.session['webauthn_challenge']
|
||||||
challenge = generate_challenge(32)
|
challenge = generate_challenge()
|
||||||
self.request.session['webauthn_challenge'] = challenge
|
self.request.session['webauthn_challenge'] = base64.b64encode(challenge).decode()
|
||||||
devices = [
|
devices = [
|
||||||
device.webauthnuser for device in WebAuthnDevice.objects.filter(confirmed=True, user=self.user)
|
device.webauthndevice for device in WebAuthnDevice.objects.filter(confirmed=True, user=self.user)
|
||||||
] + [
|
] + [
|
||||||
device.webauthnuser for device in U2FDevice.objects.filter(confirmed=True, user=self.user)
|
device.webauthndevice for device in U2FDevice.objects.filter(confirmed=True, user=self.user)
|
||||||
]
|
]
|
||||||
if devices:
|
if devices:
|
||||||
webauthn_assertion_options = webauthn.WebAuthnAssertionOptions(
|
auth_options = webauthn.generate_authentication_options(
|
||||||
devices,
|
rp_id=get_webauthn_rp_id(self.request),
|
||||||
challenge
|
challenge=challenge,
|
||||||
|
allow_credentials=devices,
|
||||||
)
|
)
|
||||||
ad = webauthn_assertion_options.assertion_dict
|
|
||||||
ad['extensions'] = {
|
# Backwards compatibility to U2F
|
||||||
'appid': get_u2f_appid(self.request)
|
j = json.loads(webauthn.options_to_json(auth_options))
|
||||||
}
|
j["extensions"] = {"appid": get_u2f_appid(self.request)}
|
||||||
ctx['jsondata'] = json.dumps(ad)
|
ctx['jsondata'] = json.dumps(j)
|
||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
|
|||||||
@@ -35,10 +35,9 @@
|
|||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import time
|
import time
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from urllib.parse import quote, urlparse
|
from urllib.parse import quote
|
||||||
|
|
||||||
import webauthn
|
import webauthn
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@@ -57,6 +56,7 @@ from django.views.generic import FormView, ListView, TemplateView, UpdateView
|
|||||||
from django_otp.plugins.otp_static.models import StaticDevice
|
from django_otp.plugins.otp_static.models import StaticDevice
|
||||||
from django_otp.plugins.otp_totp.models import TOTPDevice
|
from django_otp.plugins.otp_totp.models import TOTPDevice
|
||||||
from django_scopes import scopes_disabled
|
from django_scopes import scopes_disabled
|
||||||
|
from webauthn.helpers import generate_challenge, generate_user_handle
|
||||||
|
|
||||||
from pretix.base.auth import get_auth_backends
|
from pretix.base.auth import get_auth_backends
|
||||||
from pretix.base.forms.auth import ReauthForm
|
from pretix.base.forms.auth import ReauthForm
|
||||||
@@ -70,9 +70,9 @@ from pretix.control.forms.users import StaffSessionForm
|
|||||||
from pretix.control.permissions import (
|
from pretix.control.permissions import (
|
||||||
AdministratorPermissionRequiredMixin, StaffMemberRequiredMixin,
|
AdministratorPermissionRequiredMixin, StaffMemberRequiredMixin,
|
||||||
)
|
)
|
||||||
from pretix.control.views.auth import get_u2f_appid
|
from pretix.control.views.auth import get_u2f_appid, get_webauthn_rp_id
|
||||||
from pretix.helpers.http import redirect_to_url
|
from pretix.helpers.http import redirect_to_url
|
||||||
from pretix.helpers.webauthn import generate_challenge, generate_ukey
|
from pretix.helpers.u2f import websafe_encode
|
||||||
|
|
||||||
REAL_DEVICE_TYPES = (TOTPDevice, WebAuthnDevice, U2FDevice)
|
REAL_DEVICE_TYPES = (TOTPDevice, WebAuthnDevice, U2FDevice)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -105,25 +105,41 @@ class ReauthView(TemplateView):
|
|||||||
devices = U2FDevice.objects.filter(user=self.request.user)
|
devices = U2FDevice.objects.filter(user=self.request.user)
|
||||||
|
|
||||||
for d in devices:
|
for d in devices:
|
||||||
|
credential_current_sign_count = d.sign_count if isinstance(d, WebAuthnDevice) else 0
|
||||||
try:
|
try:
|
||||||
wu = d.webauthnuser
|
webauthn_assertion_response = webauthn.verify_authentication_response(
|
||||||
|
credential=resp,
|
||||||
if isinstance(d, U2FDevice):
|
expected_challenge=base64.b64decode(challenge),
|
||||||
# RP_ID needs to be appId for U2F devices, but we can't
|
expected_rp_id=get_webauthn_rp_id(self.request),
|
||||||
# set it that way in U2FDevice.webauthnuser, since that
|
expected_origin=settings.SITE_URL,
|
||||||
# breaks the frontend part.
|
credential_public_key=d.webauthnpubkey,
|
||||||
wu.rp_id = settings.SITE_URL
|
credential_current_sign_count=credential_current_sign_count,
|
||||||
|
|
||||||
webauthn_assertion_response = webauthn.WebAuthnAssertionResponse(
|
|
||||||
wu,
|
|
||||||
resp,
|
|
||||||
challenge,
|
|
||||||
settings.SITE_URL,
|
|
||||||
uv_required=False # User Verification
|
|
||||||
)
|
)
|
||||||
sign_count = webauthn_assertion_response.verify()
|
sign_count = webauthn_assertion_response.new_sign_count
|
||||||
|
if sign_count < credential_current_sign_count:
|
||||||
|
raise Exception("Possible replay attack, sign count not higher")
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception('U2F login failed')
|
if isinstance(d, U2FDevice):
|
||||||
|
# https://www.w3.org/TR/webauthn/#sctn-appid-extension says
|
||||||
|
# "When verifying the assertion, expect that the rpIdHash MAY be the hash of the AppID instead of the RP ID."
|
||||||
|
try:
|
||||||
|
webauthn_assertion_response = webauthn.verify_authentication_response(
|
||||||
|
credential=resp,
|
||||||
|
expected_challenge=base64.b64decode(challenge),
|
||||||
|
expected_rp_id=get_u2f_appid(self.request),
|
||||||
|
expected_origin=settings.SITE_URL,
|
||||||
|
credential_public_key=d.webauthnpubkey,
|
||||||
|
credential_current_sign_count=credential_current_sign_count,
|
||||||
|
)
|
||||||
|
if webauthn_assertion_response.new_sign_count < 1:
|
||||||
|
raise Exception("Possible replay attack, sign count set")
|
||||||
|
except Exception:
|
||||||
|
logger.exception('U2F login failed')
|
||||||
|
else:
|
||||||
|
valid = True
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
logger.exception('Webauthn login failed')
|
||||||
else:
|
else:
|
||||||
if isinstance(d, WebAuthnDevice):
|
if isinstance(d, WebAuthnDevice):
|
||||||
d.sign_count = sign_count
|
d.sign_count = sign_count
|
||||||
@@ -162,23 +178,24 @@ class ReauthView(TemplateView):
|
|||||||
ctx = super().get_context_data()
|
ctx = super().get_context_data()
|
||||||
if 'webauthn_challenge' in self.request.session:
|
if 'webauthn_challenge' in self.request.session:
|
||||||
del self.request.session['webauthn_challenge']
|
del self.request.session['webauthn_challenge']
|
||||||
challenge = generate_challenge(32)
|
challenge = generate_challenge()
|
||||||
self.request.session['webauthn_challenge'] = challenge
|
self.request.session['webauthn_challenge'] = base64.b64encode(challenge).decode()
|
||||||
devices = [
|
devices = [
|
||||||
device.webauthnuser for device in WebAuthnDevice.objects.filter(confirmed=True, user=self.request.user)
|
device.webauthndevice for device in WebAuthnDevice.objects.filter(confirmed=True, user=self.request.user)
|
||||||
] + [
|
] + [
|
||||||
device.webauthnuser for device in U2FDevice.objects.filter(confirmed=True, user=self.request.user)
|
device.webauthndevice for device in U2FDevice.objects.filter(confirmed=True, user=self.request.user)
|
||||||
]
|
]
|
||||||
if devices:
|
if devices:
|
||||||
webauthn_assertion_options = webauthn.WebAuthnAssertionOptions(
|
auth_options = webauthn.generate_authentication_options(
|
||||||
devices,
|
rp_id=get_webauthn_rp_id(self.request),
|
||||||
challenge
|
challenge=challenge,
|
||||||
|
allow_credentials=devices,
|
||||||
)
|
)
|
||||||
ad = webauthn_assertion_options.assertion_dict
|
|
||||||
ad['extensions'] = {
|
# Backwards compatibility to U2F
|
||||||
'appid': get_u2f_appid(self.request)
|
j = json.loads(webauthn.options_to_json(auth_options))
|
||||||
}
|
j["extensions"] = {"appid": get_u2f_appid(self.request)}
|
||||||
ctx['jsondata'] = json.dumps(ad)
|
ctx['jsondata'] = json.dumps(j)
|
||||||
ctx['form'] = self.form
|
ctx['form'] = self.form
|
||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
@@ -387,23 +404,26 @@ class User2FADeviceConfirmWebAuthnView(RecentAuthenticationRequiredMixin, Templa
|
|||||||
if 'webauthn_challenge' in self.request.session:
|
if 'webauthn_challenge' in self.request.session:
|
||||||
del self.request.session['webauthn_challenge']
|
del self.request.session['webauthn_challenge']
|
||||||
|
|
||||||
challenge = generate_challenge(32)
|
challenge = generate_challenge()
|
||||||
ukey = generate_ukey()
|
ukey = generate_user_handle()
|
||||||
|
|
||||||
self.request.session['webauthn_challenge'] = challenge
|
self.request.session['webauthn_challenge'] = base64.b64encode(challenge).decode()
|
||||||
self.request.session['webauthn_register_ukey'] = ukey
|
self.request.session['webauthn_register_ukey'] = base64.b64encode(ukey).decode()
|
||||||
|
|
||||||
make_credential_options = webauthn.WebAuthnMakeCredentialOptions(
|
devices = [
|
||||||
challenge,
|
device.webauthndevice for device in WebAuthnDevice.objects.filter(confirmed=True, user=self.request.user)
|
||||||
urlparse(settings.SITE_URL).netloc,
|
] + [
|
||||||
urlparse(settings.SITE_URL).netloc,
|
device.webauthndevice for device in U2FDevice.objects.filter(confirmed=True, user=self.request.user)
|
||||||
ukey,
|
]
|
||||||
self.request.user.email,
|
make_credential_options = webauthn.generate_registration_options(
|
||||||
str(self.request.user),
|
rp_id=get_webauthn_rp_id(self.request),
|
||||||
settings.SITE_URL,
|
rp_name=get_webauthn_rp_id(self.request),
|
||||||
attestation="none"
|
user_id=ukey,
|
||||||
|
user_name=self.request.user.email,
|
||||||
|
challenge=challenge,
|
||||||
|
exclude_credentials=devices,
|
||||||
)
|
)
|
||||||
ctx['jsondata'] = json.dumps(make_credential_options.registration_dict)
|
ctx['jsondata'] = webauthn.options_to_json(make_credential_options)
|
||||||
|
|
||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
@@ -412,30 +432,13 @@ class User2FADeviceConfirmWebAuthnView(RecentAuthenticationRequiredMixin, Templa
|
|||||||
challenge = self.request.session['webauthn_challenge']
|
challenge = self.request.session['webauthn_challenge']
|
||||||
ukey = self.request.session['webauthn_register_ukey']
|
ukey = self.request.session['webauthn_register_ukey']
|
||||||
resp = json.loads(self.request.POST.get("token"))
|
resp = json.loads(self.request.POST.get("token"))
|
||||||
trust_anchor_dir = os.path.normpath(os.path.join(
|
|
||||||
os.path.dirname(os.path.abspath(__file__)),
|
|
||||||
'../../static/webauthn_trusted_attestation_roots' # currently does not exist
|
|
||||||
))
|
|
||||||
# We currently do not check attestation certificates, since there's no real risk
|
|
||||||
# and we do not have any policies specifying what devices can be used. (Also, we
|
|
||||||
# didn't get it to work.)
|
|
||||||
# Read more: https://fidoalliance.org/fido-technotes-the-truth-about-attestation/
|
|
||||||
trusted_attestation_cert_required = False
|
|
||||||
self_attestation_permitted = True
|
|
||||||
none_attestation_permitted = True
|
|
||||||
|
|
||||||
webauthn_registration_response = webauthn.WebAuthnRegistrationResponse(
|
registration_verification = webauthn.verify_registration_response(
|
||||||
urlparse(settings.SITE_URL).netloc,
|
credential=resp,
|
||||||
settings.SITE_URL,
|
expected_challenge=base64.b64decode(challenge),
|
||||||
resp,
|
expected_rp_id=get_webauthn_rp_id(self.request),
|
||||||
challenge,
|
expected_origin=settings.SITE_URL,
|
||||||
trust_anchor_dir,
|
|
||||||
trusted_attestation_cert_required,
|
|
||||||
self_attestation_permitted,
|
|
||||||
none_attestation_permitted,
|
|
||||||
uv_required=False
|
|
||||||
)
|
)
|
||||||
webauthn_credential = webauthn_registration_response.verify()
|
|
||||||
|
|
||||||
# Check that the credentialId is not yet registered to any other user.
|
# Check that the credentialId is not yet registered to any other user.
|
||||||
# If registration is requested for a credential that is already registered
|
# If registration is requested for a credential that is already registered
|
||||||
@@ -443,7 +446,7 @@ class User2FADeviceConfirmWebAuthnView(RecentAuthenticationRequiredMixin, Templa
|
|||||||
# ceremony, or it MAY decide to accept the registration, e.g. while deleting
|
# ceremony, or it MAY decide to accept the registration, e.g. while deleting
|
||||||
# the older registration.
|
# the older registration.
|
||||||
credential_id_exists = WebAuthnDevice.objects.filter(
|
credential_id_exists = WebAuthnDevice.objects.filter(
|
||||||
credential_id=webauthn_credential.credential_id
|
credential_id=registration_verification.credential_id
|
||||||
).first()
|
).first()
|
||||||
if credential_id_exists:
|
if credential_id_exists:
|
||||||
messages.error(request, _('This security device is already registered.'))
|
messages.error(request, _('This security device is already registered.'))
|
||||||
@@ -451,14 +454,11 @@ class User2FADeviceConfirmWebAuthnView(RecentAuthenticationRequiredMixin, Templa
|
|||||||
'device': self.device.pk
|
'device': self.device.pk
|
||||||
}))
|
}))
|
||||||
|
|
||||||
webauthn_credential.credential_id = str(webauthn_credential.credential_id, "utf-8")
|
self.device.credential_id = websafe_encode(registration_verification.credential_id)
|
||||||
webauthn_credential.public_key = str(webauthn_credential.public_key, "utf-8")
|
self.device.ukey = websafe_encode(ukey)
|
||||||
|
self.device.pub_key = websafe_encode(registration_verification.credential_public_key)
|
||||||
self.device.credential_id = webauthn_credential.credential_id
|
self.device.sign_count = registration_verification.sign_count
|
||||||
self.device.ukey = ukey
|
self.device.rp_id = get_webauthn_rp_id(request)
|
||||||
self.device.pub_key = webauthn_credential.public_key
|
|
||||||
self.device.sign_count = webauthn_credential.sign_count
|
|
||||||
self.device.rp_id = urlparse(settings.SITE_URL).netloc
|
|
||||||
self.device.icon_url = settings.SITE_URL
|
self.device.icon_url = settings.SITE_URL
|
||||||
self.device.confirmed = True
|
self.device.confirmed = True
|
||||||
self.device.save()
|
self.device.save()
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
#
|
|
||||||
# This file is part of pretix (Community Edition).
|
|
||||||
#
|
|
||||||
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
|
||||||
# Copyright (C) 2020-2021 rami.io GmbH and contributors
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
|
||||||
# Public License as published by the Free Software Foundation in version 3 of the License.
|
|
||||||
#
|
|
||||||
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
|
||||||
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
|
||||||
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
|
||||||
# this file, see <https://pretix.eu/about/en/license>.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
|
||||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
|
||||||
# details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
|
||||||
# <https://www.gnu.org/licenses/>.
|
|
||||||
#
|
|
||||||
import random
|
|
||||||
import string
|
|
||||||
|
|
||||||
|
|
||||||
def generate_challenge(challenge_len):
|
|
||||||
return ''.join([
|
|
||||||
random.SystemRandom().choice(string.ascii_letters + string.digits)
|
|
||||||
for i in range(challenge_len)
|
|
||||||
])
|
|
||||||
|
|
||||||
|
|
||||||
def generate_ukey():
|
|
||||||
"""
|
|
||||||
Its value's id member is required, and contains an identifier
|
|
||||||
for the account, specified by the Relying Party. This is not meant
|
|
||||||
to be displayed to the user, but is used by the Relying Party to
|
|
||||||
control the number of credentials - an authenticator will never
|
|
||||||
contain more than one credential for a given Relying Party under
|
|
||||||
the same id.
|
|
||||||
A unique identifier for the entity. For a relying party entity,
|
|
||||||
sets the RP ID. For a user account entity, this will be an
|
|
||||||
arbitrary string specified by the relying party.
|
|
||||||
"""
|
|
||||||
return generate_challenge(20)
|
|
||||||
@@ -35,16 +35,23 @@ async function fetch_json(url, options) {
|
|||||||
* @param {Object} credentialCreateOptionsFromServer
|
* @param {Object} credentialCreateOptionsFromServer
|
||||||
*/
|
*/
|
||||||
const transformCredentialCreateOptions = function (credentialCreateOptionsFromServer) {
|
const transformCredentialCreateOptions = function (credentialCreateOptionsFromServer) {
|
||||||
let {challenge, user} = credentialCreateOptionsFromServer;
|
let {challenge, user, excludeCredentials} = credentialCreateOptionsFromServer;
|
||||||
user.id = Uint8Array.from(
|
user.id = user.id.replace(/\_/g, "/").replace(/\-/g, "+");
|
||||||
atob(credentialCreateOptionsFromServer.user.id), c => c.charCodeAt(0));
|
user.id = Uint8Array.from(atob(user.id), c => c.charCodeAt(0));
|
||||||
|
|
||||||
challenge = Uint8Array.from(
|
challenge = challenge.replace(/\_/g, "/").replace(/\-/g, "+");
|
||||||
atob(credentialCreateOptionsFromServer.challenge), c => c.charCodeAt(0));
|
challenge = Uint8Array.from(atob(challenge), c => c.charCodeAt(0));
|
||||||
|
|
||||||
|
excludeCredentials = excludeCredentials.map(credentialDescriptor => {
|
||||||
|
let {id} = credentialDescriptor;
|
||||||
|
id = id.replace(/\_/g, "/").replace(/\-/g, "+");
|
||||||
|
id = Uint8Array.from(atob(id), c => c.charCodeAt(0));
|
||||||
|
return Object.assign({}, credentialDescriptor, {id});
|
||||||
|
});
|
||||||
|
|
||||||
const transformedCredentialCreateOptions = Object.assign(
|
const transformedCredentialCreateOptions = Object.assign(
|
||||||
{}, credentialCreateOptionsFromServer,
|
{}, credentialCreateOptionsFromServer,
|
||||||
{challenge, user});
|
{challenge, user, excludeCredentials});
|
||||||
|
|
||||||
return transformedCredentialCreateOptions;
|
return transformedCredentialCreateOptions;
|
||||||
};
|
};
|
||||||
@@ -56,22 +63,25 @@ const transformCredentialCreateOptions = function (credentialCreateOptionsFromSe
|
|||||||
* @param {PublicKeyCredential} newAssertion
|
* @param {PublicKeyCredential} newAssertion
|
||||||
*/
|
*/
|
||||||
const transformNewAssertionForServer = (newAssertion) => {
|
const transformNewAssertionForServer = (newAssertion) => {
|
||||||
const attObj = new Uint8Array(
|
const attObj = new Uint8Array(newAssertion.response.attestationObject);
|
||||||
newAssertion.response.attestationObject);
|
const clientDataJSON = new Uint8Array(newAssertion.response.clientDataJSON);
|
||||||
const clientDataJSON = new Uint8Array(
|
const rawId = new Uint8Array(newAssertion.rawId);
|
||||||
newAssertion.response.clientDataJSON);
|
const transports = newAssertion.response.getTransports();
|
||||||
const rawId = new Uint8Array(
|
const authenticatorAttachment = newAssertion.authenticatorAttachment;
|
||||||
newAssertion.rawId);
|
|
||||||
|
|
||||||
const registrationClientExtensions = newAssertion.getClientExtensionResults();
|
const registrationClientExtensions = newAssertion.getClientExtensionResults();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: newAssertion.id,
|
id: newAssertion.id,
|
||||||
rawId: b64enc(rawId),
|
rawId: b64enc(rawId),
|
||||||
|
response: {
|
||||||
|
attestationObject: b64enc(attObj),
|
||||||
|
clientDataJSON: b64enc(clientDataJSON),
|
||||||
|
transports: transports,
|
||||||
|
},
|
||||||
type: newAssertion.type,
|
type: newAssertion.type,
|
||||||
attObj: b64enc(attObj),
|
clientExtensionResults: JSON.stringify(registrationClientExtensions),
|
||||||
clientData: b64enc(clientDataJSON),
|
authenticatorAttachment: authenticatorAttachment,
|
||||||
registrationClientExtensions: JSON.stringify(registrationClientExtensions)
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -79,8 +89,8 @@ const transformNewAssertionForServer = (newAssertion) => {
|
|||||||
const transformCredentialRequestOptions = (credentialRequestOptionsFromServer) => {
|
const transformCredentialRequestOptions = (credentialRequestOptionsFromServer) => {
|
||||||
let {challenge, allowCredentials} = credentialRequestOptionsFromServer;
|
let {challenge, allowCredentials} = credentialRequestOptionsFromServer;
|
||||||
|
|
||||||
challenge = Uint8Array.from(
|
challenge = challenge.replace(/\_/g, "/").replace(/\-/g, "+");
|
||||||
atob(challenge), c => c.charCodeAt(0));
|
challenge = Uint8Array.from(atob(challenge), c => c.charCodeAt(0));
|
||||||
|
|
||||||
allowCredentials = allowCredentials.map(credentialDescriptor => {
|
allowCredentials = allowCredentials.map(credentialDescriptor => {
|
||||||
let {id} = credentialDescriptor;
|
let {id} = credentialDescriptor;
|
||||||
@@ -106,16 +116,22 @@ const transformAssertionForServer = (newAssertion) => {
|
|||||||
const clientDataJSON = new Uint8Array(newAssertion.response.clientDataJSON);
|
const clientDataJSON = new Uint8Array(newAssertion.response.clientDataJSON);
|
||||||
const rawId = new Uint8Array(newAssertion.rawId);
|
const rawId = new Uint8Array(newAssertion.rawId);
|
||||||
const sig = new Uint8Array(newAssertion.response.signature);
|
const sig = new Uint8Array(newAssertion.response.signature);
|
||||||
|
const userHandle = new Uint8Array(newAssertion.response.userHandle);
|
||||||
const assertionClientExtensions = newAssertion.getClientExtensionResults();
|
const assertionClientExtensions = newAssertion.getClientExtensionResults();
|
||||||
|
const authenticatorAttachment = newAssertion.authenticatorAttachment;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: newAssertion.id,
|
id: newAssertion.id,
|
||||||
rawId: b64enc(rawId),
|
rawId: b64enc(rawId),
|
||||||
type: newAssertion.type,
|
type: newAssertion.type,
|
||||||
authData: b64RawEnc(authData),
|
response: {
|
||||||
clientData: b64RawEnc(clientDataJSON),
|
authenticatorData: b64RawEnc(authData),
|
||||||
signature: hexEncode(sig),
|
clientDataJSON: b64RawEnc(clientDataJSON),
|
||||||
assertionClientExtensions: JSON.stringify(assertionClientExtensions)
|
signature: b64RawEnc(sig),
|
||||||
|
userHandle: b64RawEnc(userHandle),
|
||||||
|
},
|
||||||
|
authenticatorAttachment: authenticatorAttachment,
|
||||||
|
clientExtensionResults: JSON.stringify(assertionClientExtensions)
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ from django.test import TestCase, override_settings
|
|||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
from django_otp.oath import TOTP
|
from django_otp.oath import TOTP
|
||||||
from django_otp.plugins.otp_totp.models import TOTPDevice
|
from django_otp.plugins.otp_totp.models import TOTPDevice
|
||||||
|
from webauthn.authentication.verify_authentication_response import (
|
||||||
|
VerifiedAuthentication,
|
||||||
|
)
|
||||||
|
|
||||||
from pretix.base.models import U2FDevice, User
|
from pretix.base.models import U2FDevice, User
|
||||||
from pretix.helpers import security
|
from pretix.helpers import security
|
||||||
@@ -382,7 +385,7 @@ class Login2FAFormTest(TestCase):
|
|||||||
raise Exception("Failed")
|
raise Exception("Failed")
|
||||||
|
|
||||||
m = self.monkeypatch
|
m = self.monkeypatch
|
||||||
m.setattr("webauthn.WebAuthnAssertionResponse.verify", fail)
|
m.setattr("webauthn.verify_authentication_response", fail)
|
||||||
U2FDevice.objects.create(
|
U2FDevice.objects.create(
|
||||||
user=self.user, name='test',
|
user=self.user, name='test',
|
||||||
json_data='{"appId": "https://local.pretix.eu", "keyHandle": '
|
json_data='{"appId": "https://local.pretix.eu", "keyHandle": '
|
||||||
@@ -403,7 +406,10 @@ class Login2FAFormTest(TestCase):
|
|||||||
|
|
||||||
def test_u2f_valid(self):
|
def test_u2f_valid(self):
|
||||||
m = self.monkeypatch
|
m = self.monkeypatch
|
||||||
m.setattr("webauthn.WebAuthnAssertionResponse.verify", lambda *args, **kwargs: 1)
|
m.setattr("webauthn.verify_authentication_response",
|
||||||
|
lambda *args, **kwargs: VerifiedAuthentication(
|
||||||
|
b'', 1, 'single_device', True,
|
||||||
|
))
|
||||||
|
|
||||||
U2FDevice.objects.create(
|
U2FDevice.objects.create(
|
||||||
user=self.user, name='test',
|
user=self.user, name='test',
|
||||||
|
|||||||
@@ -40,7 +40,9 @@ from django_otp.oath import TOTP
|
|||||||
from django_otp.plugins.otp_static.models import StaticDevice
|
from django_otp.plugins.otp_static.models import StaticDevice
|
||||||
from django_otp.plugins.otp_totp.models import TOTPDevice
|
from django_otp.plugins.otp_totp.models import TOTPDevice
|
||||||
from tests.base import SoupTest, extract_form_fields
|
from tests.base import SoupTest, extract_form_fields
|
||||||
from webauthn import WebAuthnCredential
|
from webauthn.registration.verify_registration_response import (
|
||||||
|
VerifiedRegistration,
|
||||||
|
)
|
||||||
|
|
||||||
from pretix.base.models import (
|
from pretix.base.models import (
|
||||||
Event, Organizer, U2FDevice, User, WebAuthnDevice,
|
Event, Organizer, U2FDevice, User, WebAuthnDevice,
|
||||||
@@ -356,9 +358,9 @@ class UserSettings2FATest(SoupTest):
|
|||||||
}, follow=True)
|
}, follow=True)
|
||||||
|
|
||||||
m = self.monkeypatch
|
m = self.monkeypatch
|
||||||
m.setattr("webauthn.WebAuthnRegistrationResponse.verify",
|
m.setattr("webauthn.verify_registration_response",
|
||||||
lambda *args, **kwargs: WebAuthnCredential(
|
lambda *args, **kwargs: VerifiedRegistration(
|
||||||
'', '', b'asd', b'foo', 1
|
b'', b'', 1, '', 'foo', 'public-key', True, b'', 'single_device', True
|
||||||
))
|
))
|
||||||
|
|
||||||
d = WebAuthnDevice.objects.first()
|
d = WebAuthnDevice.objects.first()
|
||||||
|
|||||||
Reference in New Issue
Block a user