mirror of https://github.com/Yubico/python-fido2
Add to/from_json methods to WebAuthn dataclasses
This commit is contained in:
parent
4c6f7b68a2
commit
e983d4bc7d
|
@ -33,14 +33,16 @@ See the file README.adoc in this directory for details.
|
|||
|
||||
Navigate to https://localhost:5000 in a supported web browser.
|
||||
"""
|
||||
from fido2.webauthn import PublicKeyCredentialRpEntity, PublicKeyCredentialUserEntity
|
||||
from fido2.webauthn import (
|
||||
PublicKeyCredentialRpEntity,
|
||||
PublicKeyCredentialUserEntity,
|
||||
RegistrationResponse,
|
||||
AuthenticationResponse,
|
||||
)
|
||||
from fido2.server import Fido2Server
|
||||
from flask import Flask, session, request, redirect, abort, jsonify
|
||||
|
||||
import os
|
||||
import fido2.features
|
||||
|
||||
fido2.features.webauthn_json_mapping.enabled = True
|
||||
|
||||
|
||||
app = Flask(__name__, static_url_path="")
|
||||
|
@ -78,12 +80,12 @@ def register_begin():
|
|||
print(options)
|
||||
print("\n\n\n\n")
|
||||
|
||||
return jsonify(dict(options))
|
||||
return jsonify(options.to_json())
|
||||
|
||||
|
||||
@app.route("/api/register/complete", methods=["POST"])
|
||||
def register_complete():
|
||||
response = request.json
|
||||
response = RegistrationResponse.from_json(request.json)
|
||||
print("RegistrationResponse:", response)
|
||||
auth_data = server.register_complete(session["state"], response)
|
||||
|
||||
|
@ -100,7 +102,7 @@ def authenticate_begin():
|
|||
options, state = server.authenticate_begin(credentials)
|
||||
session["state"] = state
|
||||
|
||||
return jsonify(dict(options))
|
||||
return jsonify(options.to_json())
|
||||
|
||||
|
||||
@app.route("/api/authenticate/complete", methods=["POST"])
|
||||
|
@ -108,7 +110,7 @@ def authenticate_complete():
|
|||
if not credentials:
|
||||
abort(404)
|
||||
|
||||
response = request.json
|
||||
response = AuthenticationResponse.from_json(request.json)
|
||||
print("AuthenticationResponse:", response)
|
||||
server.authenticate_complete(
|
||||
session.pop("state"),
|
||||
|
|
|
@ -144,6 +144,7 @@ class Fido2Server:
|
|||
verify_attestation: Optional[VerifyAttestation] = None,
|
||||
):
|
||||
self.rp = PublicKeyCredentialRpEntity.from_dict(rp)
|
||||
assert self.rp.id is not None # nosec
|
||||
self._verify = verify_origin or _verify_origin_for_rp(self.rp.id)
|
||||
self.timeout = None
|
||||
self.attestation = AttestationConveyancePreference(attestation)
|
||||
|
|
|
@ -44,9 +44,11 @@ from typing import (
|
|||
Sequence,
|
||||
Mapping,
|
||||
Any,
|
||||
Type,
|
||||
TypeVar,
|
||||
Hashable,
|
||||
get_type_hints,
|
||||
overload,
|
||||
)
|
||||
import struct
|
||||
import warnings
|
||||
|
@ -207,14 +209,19 @@ def _parse_value(t, value):
|
|||
return t.from_dict(value)
|
||||
|
||||
# Convert to enum values, other wrappers
|
||||
return t(value)
|
||||
try:
|
||||
return t(value)
|
||||
except Exception:
|
||||
print("EXCEPTION", t, value)
|
||||
raise
|
||||
|
||||
|
||||
_T = TypeVar("_T", bound=Hashable)
|
||||
_T2 = TypeVar("_T2", bound="_DataClassMapping")
|
||||
|
||||
|
||||
class _DataClassMapping(Mapping[_T, Any]):
|
||||
# TODO: This requires Python 3.9, and fixes the tpye errors we now ignore
|
||||
# TODO: This requires Python 3.9, and fixes the type errors we now ignore
|
||||
# __dataclass_fields__: ClassVar[Dict[str, Field[Any]]]
|
||||
|
||||
def __post_init__(self):
|
||||
|
@ -233,7 +240,7 @@ class _DataClassMapping(Mapping[_T, Any]):
|
|||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def _get_field_key(cls, field: Field) -> _T:
|
||||
def _get_field_key(cls: Type[_T2], field: Field) -> _T:
|
||||
raise NotImplementedError()
|
||||
|
||||
def __getitem__(self, key):
|
||||
|
@ -262,8 +269,18 @@ class _DataClassMapping(Mapping[_T, Any]):
|
|||
def __len__(self):
|
||||
return len(list(iter(self)))
|
||||
|
||||
@overload
|
||||
@classmethod
|
||||
def from_dict(cls, data: Optional[Mapping[_T, Any]]):
|
||||
def from_dict(cls: Type[_T2], data: None) -> None:
|
||||
pass
|
||||
|
||||
@overload
|
||||
@classmethod
|
||||
def from_dict(cls: Type[_T2], data: Mapping[_T, Any]) -> _T2:
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls: Type[_T2], data: Optional[Mapping[_T, Any]]) -> Optional[_T2]:
|
||||
if data is None:
|
||||
return None
|
||||
if isinstance(data, cls):
|
||||
|
|
|
@ -38,8 +38,19 @@ from .utils import (
|
|||
)
|
||||
from .features import webauthn_json_mapping
|
||||
from enum import Enum, EnumMeta, unique, IntFlag
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Mapping, Optional, Sequence, Tuple, Union, cast
|
||||
from dataclasses import dataclass, field, fields
|
||||
from typing import (
|
||||
Any,
|
||||
Mapping,
|
||||
Optional,
|
||||
Sequence,
|
||||
Tuple,
|
||||
Union,
|
||||
Type,
|
||||
TypeVar,
|
||||
cast,
|
||||
get_type_hints,
|
||||
)
|
||||
import struct
|
||||
import json
|
||||
|
||||
|
@ -457,8 +468,81 @@ class PublicKeyCredentialType(_StringEnum):
|
|||
PUBLIC_KEY = "public-key"
|
||||
|
||||
|
||||
_T2 = TypeVar("_T2", bound="_JsonDataObject")
|
||||
|
||||
|
||||
def _get_basetype(t):
|
||||
if Optional[t] == t: # Optional, get the type
|
||||
t = t.__args__[0]
|
||||
|
||||
# Handle list of values
|
||||
if issubclass(getattr(t, "__origin__", object), Sequence):
|
||||
t = t.__args__[0]
|
||||
|
||||
return t
|
||||
|
||||
|
||||
def _dump_json(value):
|
||||
if isinstance(value, _JsonDataObject):
|
||||
return value.to_json()
|
||||
if isinstance(value, bytes):
|
||||
return websafe_encode(value)
|
||||
if isinstance(value, list):
|
||||
return [_dump_json(x) for x in value]
|
||||
return value
|
||||
|
||||
|
||||
def _load_json(hint, value):
|
||||
t = _get_basetype(hint)
|
||||
if isinstance(value, str):
|
||||
if issubclass(t, bytes):
|
||||
value = websafe_decode(value)
|
||||
return t(value)
|
||||
|
||||
# Handle lists
|
||||
if isinstance(value, Sequence):
|
||||
return [_load_json(t, v) for v in value]
|
||||
|
||||
# Check for subclass of _JsonDataObject
|
||||
try:
|
||||
is_json = issubclass(t, _JsonDataObject)
|
||||
except TypeError:
|
||||
is_json = False
|
||||
|
||||
if is_json:
|
||||
# Recursively call from_json for nested _JsonDataObject
|
||||
return t.from_json(value)
|
||||
|
||||
return value
|
||||
|
||||
|
||||
class _JsonDataObject(_CamelCaseDataObject):
|
||||
def to_json(self) -> Mapping[str, Any]:
|
||||
"""Returns a dict of the object which can be serialized to JSON."""
|
||||
data = {}
|
||||
for f in fields(self): # type: ignore
|
||||
key = self._get_field_key(f)
|
||||
value = getattr(self, f.name)
|
||||
if value is not None:
|
||||
data[key] = _dump_json(value)
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
def from_json(cls: Type[_T2], data: Mapping[str, Any]) -> _T2:
|
||||
"""Instantiates an object from a JSON-compatible dict representation."""
|
||||
hints = get_type_hints(cls)
|
||||
resp = {}
|
||||
for f in fields(cls): # type: ignore
|
||||
key = cls._get_field_key(f)
|
||||
if key in data:
|
||||
value = data[key]
|
||||
hint = hints.get(f.name)
|
||||
resp[key] = _load_json(hint, value)
|
||||
return cls.from_dict(resp)
|
||||
|
||||
|
||||
@dataclass(eq=False, frozen=True)
|
||||
class PublicKeyCredentialRpEntity(_CamelCaseDataObject):
|
||||
class PublicKeyCredentialRpEntity(_JsonDataObject):
|
||||
name: str
|
||||
id: Optional[str] = None
|
||||
|
||||
|
@ -469,14 +553,14 @@ class PublicKeyCredentialRpEntity(_CamelCaseDataObject):
|
|||
|
||||
|
||||
@dataclass(eq=False, frozen=True)
|
||||
class PublicKeyCredentialUserEntity(_CamelCaseDataObject):
|
||||
class PublicKeyCredentialUserEntity(_JsonDataObject):
|
||||
name: str
|
||||
id: bytes = field(metadata=_b64_metadata)
|
||||
display_name: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass(eq=False, frozen=True)
|
||||
class PublicKeyCredentialParameters(_CamelCaseDataObject):
|
||||
class PublicKeyCredentialParameters(_JsonDataObject):
|
||||
type: PublicKeyCredentialType
|
||||
alg: int
|
||||
|
||||
|
@ -489,7 +573,7 @@ class PublicKeyCredentialParameters(_CamelCaseDataObject):
|
|||
|
||||
|
||||
@dataclass(eq=False, frozen=True)
|
||||
class PublicKeyCredentialDescriptor(_CamelCaseDataObject):
|
||||
class PublicKeyCredentialDescriptor(_JsonDataObject):
|
||||
type: PublicKeyCredentialType
|
||||
id: bytes = field(metadata=_b64_metadata)
|
||||
transports: Optional[Sequence[AuthenticatorTransport]] = None
|
||||
|
@ -503,7 +587,7 @@ class PublicKeyCredentialDescriptor(_CamelCaseDataObject):
|
|||
|
||||
|
||||
@dataclass(eq=False, frozen=True)
|
||||
class AuthenticatorSelectionCriteria(_CamelCaseDataObject):
|
||||
class AuthenticatorSelectionCriteria(_JsonDataObject):
|
||||
authenticator_attachment: Optional[AuthenticatorAttachment] = None
|
||||
resident_key: Optional[ResidentKeyRequirement] = None
|
||||
user_verification: Optional[UserVerificationRequirement] = None
|
||||
|
@ -530,7 +614,7 @@ class AuthenticatorSelectionCriteria(_CamelCaseDataObject):
|
|||
|
||||
|
||||
@dataclass(eq=False, frozen=True)
|
||||
class PublicKeyCredentialCreationOptions(_CamelCaseDataObject):
|
||||
class PublicKeyCredentialCreationOptions(_JsonDataObject):
|
||||
rp: PublicKeyCredentialRpEntity
|
||||
user: PublicKeyCredentialUserEntity
|
||||
challenge: bytes = field(metadata=_b64_metadata)
|
||||
|
@ -548,7 +632,7 @@ class PublicKeyCredentialCreationOptions(_CamelCaseDataObject):
|
|||
|
||||
|
||||
@dataclass(eq=False, frozen=True)
|
||||
class PublicKeyCredentialRequestOptions(_CamelCaseDataObject):
|
||||
class PublicKeyCredentialRequestOptions(_JsonDataObject):
|
||||
challenge: bytes = field(metadata=_b64_metadata)
|
||||
timeout: Optional[int] = None
|
||||
rp_id: Optional[str] = None
|
||||
|
@ -561,7 +645,7 @@ class PublicKeyCredentialRequestOptions(_CamelCaseDataObject):
|
|||
|
||||
|
||||
@dataclass(eq=False, frozen=True)
|
||||
class AuthenticatorAttestationResponse(_CamelCaseDataObject):
|
||||
class AuthenticatorAttestationResponse(_JsonDataObject):
|
||||
client_data: CollectedClientData = field(
|
||||
metadata=dict(
|
||||
_b64_metadata,
|
||||
|
@ -578,7 +662,7 @@ class AuthenticatorAttestationResponse(_CamelCaseDataObject):
|
|||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Optional[Mapping[str, Any]]):
|
||||
if data is not None and not webauthn_json_mapping.enabled:
|
||||
if data is not None and "clientData" in data:
|
||||
value = dict(data)
|
||||
value["clientDataJSON"] = value.pop("clientData", None)
|
||||
data = value
|
||||
|
@ -586,7 +670,7 @@ class AuthenticatorAttestationResponse(_CamelCaseDataObject):
|
|||
|
||||
|
||||
@dataclass(eq=False, frozen=True)
|
||||
class AuthenticatorAssertionResponse(_CamelCaseDataObject):
|
||||
class AuthenticatorAssertionResponse(_JsonDataObject):
|
||||
client_data: CollectedClientData = field(
|
||||
metadata=dict(
|
||||
_b64_metadata,
|
||||
|
@ -606,7 +690,7 @@ class AuthenticatorAssertionResponse(_CamelCaseDataObject):
|
|||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Optional[Mapping[str, Any]]):
|
||||
if data is not None and not webauthn_json_mapping.enabled:
|
||||
if data is not None and "clientData" in data:
|
||||
value = dict(data)
|
||||
value["clientDataJSON"] = value.pop("clientData", None)
|
||||
data = value
|
||||
|
@ -614,36 +698,28 @@ class AuthenticatorAssertionResponse(_CamelCaseDataObject):
|
|||
|
||||
|
||||
@dataclass(eq=False, frozen=True)
|
||||
class RegistrationResponse(_CamelCaseDataObject):
|
||||
class RegistrationResponse(_JsonDataObject):
|
||||
id: bytes = field(metadata=_b64_metadata)
|
||||
response: AuthenticatorAttestationResponse
|
||||
authenticator_attachment: Optional[AuthenticatorAttachment] = None
|
||||
client_extension_results: Optional[Mapping] = None
|
||||
type: Optional[PublicKeyCredentialType] = None
|
||||
|
||||
def __post_init__(self):
|
||||
webauthn_json_mapping.require()
|
||||
super().__post_init__()
|
||||
|
||||
|
||||
@dataclass(eq=False, frozen=True)
|
||||
class AuthenticationResponse(_CamelCaseDataObject):
|
||||
class AuthenticationResponse(_JsonDataObject):
|
||||
id: bytes = field(metadata=_b64_metadata)
|
||||
response: AuthenticatorAssertionResponse
|
||||
authenticator_attachment: Optional[AuthenticatorAttachment] = None
|
||||
client_extension_results: Optional[Mapping] = None
|
||||
type: Optional[PublicKeyCredentialType] = None
|
||||
|
||||
def __post_init__(self):
|
||||
webauthn_json_mapping.require()
|
||||
super().__post_init__()
|
||||
|
||||
|
||||
@dataclass(eq=False, frozen=True)
|
||||
class CredentialCreationOptions(_CamelCaseDataObject):
|
||||
class CredentialCreationOptions(_JsonDataObject):
|
||||
public_key: PublicKeyCredentialCreationOptions
|
||||
|
||||
|
||||
@dataclass(eq=False, frozen=True)
|
||||
class CredentialRequestOptions(_CamelCaseDataObject):
|
||||
class CredentialRequestOptions(_JsonDataObject):
|
||||
public_key: PublicKeyCredentialRequestOptions
|
||||
|
|
|
@ -1,3 +0,0 @@
|
|||
import fido2.features
|
||||
|
||||
fido2.features.webauthn_json_mapping.enabled = True
|
|
@ -30,7 +30,7 @@
|
|||
import unittest
|
||||
from unittest import mock
|
||||
from fido2 import cbor
|
||||
from fido2.utils import sha256, websafe_encode
|
||||
from fido2.utils import sha256
|
||||
from fido2.hid import CAPABILITY
|
||||
from fido2.ctap import CtapError
|
||||
from fido2.ctap1 import RegistrationData
|
||||
|
@ -51,7 +51,7 @@ REG_DATA = RegistrationData(
|
|||
)
|
||||
|
||||
rp = {"id": "example.com", "name": "Example RP"}
|
||||
user = {"id": websafe_encode(b"user_id"), "name": "A. User"}
|
||||
user = {"id": b"user_id", "name": "A. User"}
|
||||
challenge = b"Y2hhbGxlbmdl"
|
||||
_INFO_NO_PIN = bytes.fromhex(
|
||||
"a60182665532465f5632684649444f5f325f3002826375766d6b686d61632d7365637265740350f8a011f38c0a4d15800617111f9edc7d04a462726bf5627570f564706c6174f469636c69656e7450696ef4051904b0068101" # noqa E501
|
||||
|
|
|
@ -8,7 +8,6 @@ from fido2.webauthn import (
|
|||
AttestedCredentialData,
|
||||
AuthenticatorData,
|
||||
)
|
||||
from fido2.utils import websafe_encode
|
||||
|
||||
from .test_ctap2 import _ATT_CRED_DATA, _CRED_ID
|
||||
from .utils import U2FDevice
|
||||
|
@ -96,7 +95,7 @@ class TestFido2Server(unittest.TestCase):
|
|||
challenge = b"1234567890123456"
|
||||
request, state = server.register_begin(USER, challenge=challenge)
|
||||
|
||||
self.assertEqual(request["publicKey"]["challenge"], websafe_encode(challenge))
|
||||
self.assertEqual(request["publicKey"]["challenge"], challenge)
|
||||
|
||||
def test_register_begin_custom_challenge_too_short(self):
|
||||
rp = PublicKeyCredentialRpEntity("Example", "example.com")
|
||||
|
|
|
@ -37,7 +37,6 @@ from fido2.webauthn import (
|
|||
PublicKeyCredentialCreationOptions,
|
||||
PublicKeyCredentialRequestOptions,
|
||||
)
|
||||
from fido2.utils import websafe_encode
|
||||
|
||||
import unittest
|
||||
import json
|
||||
|
@ -187,7 +186,7 @@ class TestWebAuthnDataTypes(unittest.TestCase):
|
|||
self.assertEqual(
|
||||
o,
|
||||
{
|
||||
"id": websafe_encode(b"user"),
|
||||
"id": b"user",
|
||||
"name": "Example",
|
||||
"displayName": "Display",
|
||||
},
|
||||
|
@ -219,9 +218,7 @@ class TestWebAuthnDataTypes(unittest.TestCase):
|
|||
|
||||
def test_descriptor(self):
|
||||
o = PublicKeyCredentialDescriptor("public-key", b"credential_id")
|
||||
self.assertEqual(
|
||||
o, {"type": "public-key", "id": websafe_encode(b"credential_id")}
|
||||
)
|
||||
self.assertEqual(o, {"type": "public-key", "id": b"credential_id"})
|
||||
self.assertEqual(o.type, "public-key")
|
||||
self.assertEqual(o.id, b"credential_id")
|
||||
self.assertIsNone(o.transports)
|
||||
|
@ -233,7 +230,7 @@ class TestWebAuthnDataTypes(unittest.TestCase):
|
|||
o,
|
||||
{
|
||||
"type": "public-key",
|
||||
"id": websafe_encode(b"credential_id"),
|
||||
"id": b"credential_id",
|
||||
"transports": ["usb", "nfc"],
|
||||
},
|
||||
)
|
||||
|
@ -257,7 +254,7 @@ class TestWebAuthnDataTypes(unittest.TestCase):
|
|||
b"request_challenge",
|
||||
[{"type": "public-key", "alg": -7}],
|
||||
10000,
|
||||
[{"type": "public-key", "id": websafe_encode(b"credential_id")}],
|
||||
[{"type": "public-key", "id": b"credential_id"}],
|
||||
{
|
||||
"authenticatorAttachment": "platform",
|
||||
"residentKey": "required",
|
||||
|
@ -266,18 +263,21 @@ class TestWebAuthnDataTypes(unittest.TestCase):
|
|||
"direct",
|
||||
)
|
||||
self.assertEqual(o.rp, {"id": "example.com", "name": "Example"})
|
||||
self.assertEqual(o.user, {"id": websafe_encode(b"user_id"), "name": "A. User"})
|
||||
self.assertEqual(o.user, {"id": b"user_id", "name": "A. User"})
|
||||
self.assertIsNone(o.extensions)
|
||||
|
||||
js = json.dumps(dict(o))
|
||||
o2 = PublicKeyCredentialCreationOptions.from_dict(json.loads(js))
|
||||
o2 = PublicKeyCredentialCreationOptions.from_dict(dict(o))
|
||||
self.assertEqual(o, o2)
|
||||
|
||||
js = json.dumps(o.to_json())
|
||||
o2 = PublicKeyCredentialCreationOptions.from_json(json.loads(js))
|
||||
self.assertEqual(o, o2)
|
||||
|
||||
o = PublicKeyCredentialCreationOptions.from_dict(
|
||||
{
|
||||
"rp": {"id": "example.com", "name": "Example"},
|
||||
"user": {"id": websafe_encode(b"user_id"), "name": "A. User"},
|
||||
"challenge": websafe_encode(b"request_challenge"),
|
||||
"user": {"id": b"user_id", "name": "A. User"},
|
||||
"challenge": b"request_challenge",
|
||||
"pubKeyCredParams": [{"type": "public-key", "alg": -7}],
|
||||
}
|
||||
)
|
||||
|
@ -290,15 +290,18 @@ class TestWebAuthnDataTypes(unittest.TestCase):
|
|||
self.assertIsNone(
|
||||
PublicKeyCredentialCreationOptions(
|
||||
{"id": "example.com", "name": "Example"},
|
||||
{"id": websafe_encode(b"user_id"), "name": "A. User"},
|
||||
{"id": b"user_id", "name": "A. User"},
|
||||
b"request_challenge",
|
||||
[{"type": "public-key", "alg": -7}],
|
||||
attestation="invalid",
|
||||
).attestation
|
||||
)
|
||||
|
||||
js = json.dumps(dict(o))
|
||||
o2 = PublicKeyCredentialCreationOptions.from_dict(json.loads(js))
|
||||
o2 = PublicKeyCredentialCreationOptions.from_dict(dict(o))
|
||||
self.assertEqual(o, o2)
|
||||
|
||||
js = json.dumps(o.to_json())
|
||||
o2 = PublicKeyCredentialCreationOptions.from_json(json.loads(js))
|
||||
|
||||
self.assertEqual(o, o2)
|
||||
|
||||
|
@ -315,8 +318,11 @@ class TestWebAuthnDataTypes(unittest.TestCase):
|
|||
self.assertEqual(o.timeout, 10000)
|
||||
self.assertIsNone(o.extensions)
|
||||
|
||||
js = json.dumps(dict(o))
|
||||
o2 = PublicKeyCredentialRequestOptions.from_dict(json.loads(js))
|
||||
o2 = PublicKeyCredentialRequestOptions.from_dict(dict(o))
|
||||
self.assertEqual(o, o2)
|
||||
|
||||
js = json.dumps(o.to_json())
|
||||
o2 = PublicKeyCredentialRequestOptions.from_json(json.loads(js))
|
||||
self.assertEqual(o, o2)
|
||||
|
||||
o = PublicKeyCredentialRequestOptions(b"request_challenge")
|
||||
|
|
Loading…
Reference in New Issue