Просмотр исходного кода

feat(u2f): deprecated u2f-api for registration flow - FE (#30351)

Richard Ma 3 лет назад
Родитель
Сommit
d881634416

+ 1 - 0
package.json

@@ -65,6 +65,7 @@
     "babel-plugin-add-react-displayname": "^0.0.5",
     "babel-plugin-transform-react-remove-prop-types": "^0.4.24",
     "buffer": "^6.0.3",
+    "cbor-web": "^8.1.0",
     "classnames": "2.3.1",
     "color": "^3.1.3",
     "compression-webpack-plugin": "7.0.0",

+ 75 - 37
static/app/components/u2f/u2finterface.tsx

@@ -1,11 +1,13 @@
 import * as React from 'react';
 import * as Sentry from '@sentry/react';
+import * as cbor from 'cbor-web';
 import u2f from 'u2f-api';
 
 import {base64urlToBuffer, bufferToBase64url} from 'sentry/components/u2f/webAuthnHelper';
 import {t, tct} from 'sentry/locale';
 import ConfigStore from 'sentry/stores/configStore';
-import {ChallengeData} from 'sentry/types';
+import {ChallengeData, Organization} from 'sentry/types';
+import withOrganization from 'sentry/utils/withOrganization';
 
 type TapParams = {
   response: string;
@@ -13,6 +15,7 @@ type TapParams = {
 };
 
 type Props = {
+  organization: Organization;
   challengeData: ChallengeData;
   isWebauthnSigninFFEnabled: boolean;
   flowMode: string;
@@ -58,14 +61,29 @@ class U2fInterface extends React.Component<Props, State> {
       return JSON.stringify(data);
     }
 
-    const authenticatorData = {
-      keyHandle: data.id,
-      clientData: bufferToBase64url(data.response.clientDataJSON),
-      signatureData: bufferToBase64url(data.response.signature),
-      authenticatorData: bufferToBase64url(data.response.authenticatorData),
-    };
+    if (this.props.flowMode === 'sign') {
+      const authenticatorData = {
+        keyHandle: data.id,
+        clientData: bufferToBase64url(data.response.clientDataJSON),
+        signatureData: bufferToBase64url(data.response.signature),
+        authenticatorData: bufferToBase64url(data.response.authenticatorData),
+      };
+      return JSON.stringify(authenticatorData);
+    }
+    if (this.props.flowMode === 'enroll') {
+      const authenticatorData = {
+        id: data.id,
+        rawId: bufferToBase64url(data.rawId),
+        response: {
+          attestationObject: bufferToBase64url(data.response.attestationObject),
+          clientDataJSON: bufferToBase64url(data.response.clientDataJSON),
+        },
+        type: bufferToBase64url(data.type),
+      };
+      return JSON.stringify(authenticatorData);
+    }
 
-    return JSON.stringify(authenticatorData);
+    throw new Error(`Unsupported flow mode '${this.props.flowMode}'`);
   }
 
   submitU2fResponse(promise) {
@@ -130,49 +148,69 @@ class U2fInterface extends React.Component<Props, State> {
       });
   }
 
-  webAuthnSignIn(authenticateRequests) {
-    const credentials: PublicKeyCredentialDescriptor[] = [];
-    // challenge and appId are the same for each device in authenticateRequests
-    const challenge = authenticateRequests[0].challenge;
-    const appId = authenticateRequests[0].appId;
-
-    authenticateRequests.forEach(device => {
-      credentials.push({
-        id: base64urlToBuffer(device.keyHandle),
-        type: 'public-key',
-        transports: ['usb', 'ble', 'nfc'],
-      });
-    });
-
-    const publicKeyCredentialRequestOptions: PublicKeyCredentialRequestOptions = {
-      challenge: base64urlToBuffer(challenge),
-      allowCredentials: credentials,
-      userVerification: 'discouraged',
-      extensions: {
-        appid: appId,
-      },
-    };
-
+  webAuthnSignIn(publicKeyCredentialRequestOptions) {
     const promise = navigator.credentials.get({
       publicKey: publicKeyCredentialRequestOptions,
     });
     this.submitU2fResponse(promise);
   }
 
+  webAuthnRegister(publicKey) {
+    const promise = navigator.credentials.create({
+      publicKey,
+    });
+    this.submitU2fResponse(promise);
+  }
+
   invokeU2fFlow() {
     let promise: Promise<u2f.SignResponse | u2f.RegisterResponse>;
-
     if (this.props.flowMode === 'sign') {
       if (this.props.isWebauthnSigninFFEnabled) {
-        this.webAuthnSignIn(this.props.challengeData.authenticateRequests);
+        const challengeArray = base64urlToBuffer(
+          this.props.challengeData.webAuthnAuthenticationData
+        );
+        const challenge = cbor.decodeFirst(challengeArray);
+        challenge
+          .then(data => {
+            this.webAuthnSignIn(data);
+          })
+          .catch(err => {
+            const failure = 'DEVICE_ERROR';
+            Sentry.captureException(err);
+            this.setState({
+              deviceFailure: failure,
+              hasBeenTapped: false,
+            });
+          });
       } else {
         promise = u2f.sign(this.props.challengeData.authenticateRequests);
         this.submitU2fResponse(promise);
       }
     } else if (this.props.flowMode === 'enroll') {
-      const {registerRequests, registeredKeys} = this.props.challengeData;
-      promise = u2f.register(registerRequests as any, registeredKeys as any);
-      this.submitU2fResponse(promise);
+      const {organization} = this.props;
+      if (organization.features.includes('webauthn-register')) {
+        const challengeArray = base64urlToBuffer(
+          this.props.challengeData.webAuthnRegisterData
+        );
+        const challenge = cbor.decodeFirst(challengeArray);
+        // challenge contains a PublicKeyCredentialRequestOptions object for webauthn registration
+        challenge
+          .then(data => {
+            this.webAuthnRegister(data.publicKey);
+          })
+          .catch(err => {
+            const failure = 'DEVICE_ERROR';
+            Sentry.captureException(err);
+            this.setState({
+              deviceFailure: failure,
+              hasBeenTapped: false,
+            });
+          });
+      } else {
+        const {registerRequests, registeredKeys} = this.props.challengeData;
+        promise = u2f.register(registerRequests as any, registeredKeys as any);
+        this.submitU2fResponse(promise);
+      }
     } else {
       throw new Error(`Unsupported flow mode '${this.props.flowMode}'`);
     }
@@ -312,4 +350,4 @@ class U2fInterface extends React.Component<Props, State> {
   }
 }
 
-export default U2fInterface;
+export default withOrganization(U2fInterface);

+ 1 - 1
static/app/components/u2f/u2fsign.tsx

@@ -14,7 +14,7 @@ const MESSAGES = {
   ),
 };
 
-type InterfaceProps = U2fInterface['props'];
+type InterfaceProps = React.ComponentProps<typeof U2fInterface>;
 
 type Props = Omit<InterfaceProps, 'silentIfUnsupported' | 'flowMode'> & {
   displayMode: 'signin' | 'enroll' | 'sudo';

+ 3 - 0
static/app/types/auth.tsx

@@ -82,6 +82,9 @@ export type ChallengeData = {
   authenticateRequests: u2f.SignRequest;
   registerRequests: u2f.RegisterRequest;
   registeredKeys: u2f.RegisteredKey[];
+  // for WebAuthn register
+  webAuthnRegisterData: string;
+  webAuthnAuthenticationData: string;
 };
 
 export type EnrolledAuthenticator = {

+ 8 - 0
tests/js/setup.ts

@@ -1,3 +1,7 @@
+/* eslint-env node */
+/* eslint import/no-nodejs-modules:0 */
+import {TextDecoder, TextEncoder} from 'util';
+
 import {InjectedRouter} from 'react-router';
 import {configure} from '@testing-library/react';
 import Adapter from '@wojtekmaj/enzyme-adapter-react-17';
@@ -13,6 +17,10 @@ import TestStubFixtures from '../fixtures/js-stubs/types';
 
 import {loadFixtures} from './sentry-test/loadFixtures';
 
+// needed by cbor-web for webauthn
+window.TextEncoder = TextEncoder;
+window.TextDecoder = TextDecoder as typeof window.TextDecoder;
+
 /**
  * XXX(epurkhiser): Gross hack to fix a bug in jsdom which makes testing of
  * framer-motion SVG components fail

+ 5 - 0
yarn.lock

@@ -5123,6 +5123,11 @@ case-sensitive-paths-webpack-plugin@^2.3.0:
   resolved "https://registry.yarnpkg.com/case-sensitive-paths-webpack-plugin/-/case-sensitive-paths-webpack-plugin-2.4.0.tgz#db64066c6422eed2e08cc14b986ca43796dbc6d4"
   integrity sha512-roIFONhcxog0JSSWbvVAh3OocukmSgpqOH6YpMkCvav/ySIV3JKg4Dc8vYtQjYi/UxpNE36r/9v+VqTQqgkYmw==
 
+cbor-web@^8.1.0:
+  version "8.1.0"
+  resolved "https://registry.yarnpkg.com/cbor-web/-/cbor-web-8.1.0.tgz#c1148e91ca6bfc0f5c07c1df164854596e2e33d6"
+  integrity sha512-2hWHHMVrfffgoEmsAUh8vCxHoLa1vgodtC73+C5cSarkJlwTapnqAzcHINlP6Ej0DXuP4OmmJ9LF+JaNM5Lj/g==
+
 ccount@^1.0.0:
   version "1.0.4"
   resolved "https://registry.yarnpkg.com/ccount/-/ccount-1.0.4.tgz#9cf2de494ca84060a2a8d2854edd6dfb0445f386"