From 70333249d953c0a800c6edf8897c01a2b19f9617 Mon Sep 17 00:00:00 2001 From: Andrew Duthie Date: Wed, 4 May 2022 08:39:17 -0400 Subject: [PATCH 01/29] Update Frontend documentation overview (#6298) * Clarify JS disabled expectations Identity proofing will require JavaScript enabled * Mention TTS standards, custom ESLint config for awareness, less tying specifically to Airbnb * Include TypeScript expectation in docs * Merge Yarn + Yarn workspaces comment Avoid mentioning package.json as source of truth, since packages are scattered throughout workspaces directories * Avoid abbreviations don't assume they're universally understood * Normalize subject, verb form and tense * Point Yarn links to classic documentation since we use classic Yarn * Add changelog [skip changelog] --- docs/frontend.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/frontend.md b/docs/frontend.md index 409ba62589d..48631b28c14 100644 --- a/docs/frontend.md +++ b/docs/frontend.md @@ -44,12 +44,12 @@ identify issues in your code as you write. ### At a Glance -- Site should work if JS is off (and have enhanced features if JS is on). -- Uses AirBnB's ESLint config, alongside [Prettier](https://prettier.io/). -- JS modules are installed & managed via `yarn` (see `package.json`). -- JS is transpiled, bundled, and minified via [Webpack](https://webpack.js.org/) and [Babel](https://babeljs.io/). -- Reusable code is organized using - [Yarn workspaces](https://classic.yarnpkg.com/en/docs/workspaces/). +- All new code is expected to be written using [TypeScript](https://www.typescriptlang.org/) (`.ts` or `.tsx` file extension) +- The site should be functional even when JavaScript is disabled, with a few specific exceptions (identity proofing) +- The code follows [TTS JavaScript standards](https://engineering.18f.gov/javascript/), using a [custom ESLint configuration](https://github.com/18F/identity-idp/tree/main/app/javascript/packages/eslint-plugin) +- Code styling is formatted automatically using [Prettier](https://prettier.io/) +- Packages are managed with [Yarn](https://classic.yarnpkg.com/), organized using [Yarn workspaces](https://classic.yarnpkg.com/en/docs/workspaces/) +- JavaScript is transpiled, bundled, and minified via [Webpack](https://webpack.js.org/) and [Babel](https://babeljs.io/) ### Prettier From 0ca3e50b291deedd9831f7af575b284a145c19dc Mon Sep 17 00:00:00 2001 From: Andrew Duthie Date: Wed, 4 May 2022 09:06:51 -0400 Subject: [PATCH 02/29] Fix 500 in analytics logging from missing constant (#6300) **Why**: So that the build passes, and so that we don't have 500 errors. Context: https://github.com/18F/identity-idp/pull/6288/files#r864759689 [skip changelog] --- .../doc_auth/register_step_from_analytics_submit_event.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/funnel/doc_auth/register_step_from_analytics_submit_event.rb b/app/services/funnel/doc_auth/register_step_from_analytics_submit_event.rb index c36d263de95..fcc88511e20 100644 --- a/app/services/funnel/doc_auth/register_step_from_analytics_submit_event.rb +++ b/app/services/funnel/doc_auth/register_step_from_analytics_submit_event.rb @@ -3,7 +3,7 @@ module DocAuth class RegisterStepFromAnalyticsSubmitEvent ANALYTICS_EVENT_TO_DOC_AUTH_LOG_TOKEN = { Analytics::IDV_GPO_ADDRESS_LETTER_REQUESTED => :usps_letter_sent, - Analytics::IDV_PHONE_CONFIRMATION_FORM => :verify_phone, + 'IdV: phone confirmation form' => :verify_phone, }.freeze def self.call(user_id, issuer, event, result) From 1b4b64917ceb8062bd1326733a3acfa614b3f4cd Mon Sep 17 00:00:00 2001 From: Andrew Duthie Date: Wed, 4 May 2022 11:51:34 -0400 Subject: [PATCH 03/29] Remove NewRelic frontend event logging (#6302) * Remove NewRelic frontend event logging **Why**: Because it's redundant with logging via FrontendLogController and presumably runs up our bill. changelog: Improvements, Analytics, Reduce redundant analytics logging * Simplify addPageAction signature **Why**: For improved usability, and for alignment with other event tracking methods * Fix type signature for addPageAction --- .../components/acuant-capture.jsx | 25 +-- .../components/capture-troubleshooting.jsx | 7 +- .../components/review-issues-step.tsx | 2 +- .../document-capture/components/warning.jsx | 7 +- .../document-capture/context/acuant.jsx | 17 +- .../document-capture/context/analytics.jsx | 9 +- .../with-background-encrypted-upload.jsx | 26 +-- app/javascript/packs/document-capture.jsx | 14 +- .../components/acuant-capture-spec.jsx | 201 ++++++++---------- .../components/capture-advice-spec.jsx | 16 +- .../capture-troubleshooting-spec.jsx | 10 +- .../components/review-issues-step-spec.jsx | 16 +- .../components/warning-spec.jsx | 13 +- .../document-capture/context/acuant-spec.jsx | 29 +-- .../with-background-encrypted-upload-spec.jsx | 42 ++-- 15 files changed, 166 insertions(+), 268 deletions(-) diff --git a/app/javascript/packages/document-capture/components/acuant-capture.jsx b/app/javascript/packages/document-capture/components/acuant-capture.jsx index 65a3ed57523..7e9aa5aaba5 100644 --- a/app/javascript/packages/document-capture/components/acuant-capture.jsx +++ b/app/javascript/packages/document-capture/components/acuant-capture.jsx @@ -342,10 +342,7 @@ function AcuantCapture( size: nextValue.size, }); - addPageAction({ - label: `IdV: ${name} image added`, - payload: analyticsPayload, - }); + addPageAction(`IdV: ${name} image added`, analyticsPayload); } onChangeAndResetError(nextValue, analyticsPayload); @@ -366,10 +363,7 @@ function AcuantCapture( return (fn) => (...args) => { if (!isSuppressingClickLogging.current) { - addPageAction({ - label: `IdV: ${name} image clicked`, - payload: { source, ...metadata }, - }); + addPageAction(`IdV: ${name} image clicked`, { source, ...metadata }); } return fn(...args); @@ -488,11 +482,7 @@ function AcuantCapture( size: getDecodedBase64ByteSize(nextCapture.image.data), }); - addPageAction({ - key: 'documentCapture.acuantWebSDKResult', - label: `IdV: ${name} image added`, - payload: analyticsPayload, - }); + addPageAction(`IdV: ${name} image added`, analyticsPayload); if (assessment === 'success') { onChangeAndResetError(data, analyticsPayload); @@ -538,12 +528,9 @@ function AcuantCapture( } setIsCapturingEnvironment(false); - addPageAction({ - label: 'IdV: Image capture failed', - payload: { - field: name, - error: getNormalizedAcuantCaptureFailureMessage(error, code), - }, + addPageAction('IdV: Image capture failed', { + field: name, + error: getNormalizedAcuantCaptureFailureMessage(error, code), }); }} > diff --git a/app/javascript/packages/document-capture/components/capture-troubleshooting.jsx b/app/javascript/packages/document-capture/components/capture-troubleshooting.jsx index a5c301a4879..3477e50bfba 100644 --- a/app/javascript/packages/document-capture/components/capture-troubleshooting.jsx +++ b/app/javascript/packages/document-capture/components/capture-troubleshooting.jsx @@ -28,16 +28,13 @@ function CaptureTroubleshooting({ children }) { const { isAssessedAsGlare, isAssessedAsBlurry } = lastAttemptMetadata; function onCaptureTipsShown() { - addPageAction({ - label: 'IdV: Capture troubleshooting shown', - payload: lastAttemptMetadata, - }); + addPageAction('IdV: Capture troubleshooting shown', lastAttemptMetadata); onPageTransition(); } function onCaptureTipsDismissed() { - addPageAction({ label: 'IdV: Capture troubleshooting dismissed' }); + addPageAction('IdV: Capture troubleshooting dismissed'); setDidShowTroubleshooting(true); } diff --git a/app/javascript/packages/document-capture/components/review-issues-step.tsx b/app/javascript/packages/document-capture/components/review-issues-step.tsx index ac7e83baddc..19f82eeda6d 100644 --- a/app/javascript/packages/document-capture/components/review-issues-step.tsx +++ b/app/javascript/packages/document-capture/components/review-issues-step.tsx @@ -77,7 +77,7 @@ function ReviewIssuesStep({ useDidUpdateEffect(onPageTransition, [hasDismissed]); function onWarningPageDismissed() { - addPageAction({ label: 'IdV: Capture troubleshooting dismissed' }); + addPageAction('IdV: Capture troubleshooting dismissed'); setHasDismissed(true); } diff --git a/app/javascript/packages/document-capture/components/warning.jsx b/app/javascript/packages/document-capture/components/warning.jsx index b8f7e63d250..c9c7cf02087 100644 --- a/app/javascript/packages/document-capture/components/warning.jsx +++ b/app/javascript/packages/document-capture/components/warning.jsx @@ -33,10 +33,7 @@ function Warning({ const { addPageAction } = useContext(AnalyticsContext); const { t } = useI18n(); useEffect(() => { - addPageAction({ - label: 'IdV: warning shown', - payload: { location, remaining_attempts: remainingAttempts }, - }); + addPageAction('IdV: warning shown', { location, remaining_attempts: remainingAttempts }); }, []); return ( @@ -56,7 +53,7 @@ function Warning({ type="button" className="usa-button usa-button--big usa-button--wide" onClick={() => { - addPageAction({ label: 'IdV: warning action triggered', payload: { location } }); + addPageAction('IdV: warning action triggered', { location }); actionOnClick(); }} > diff --git a/app/javascript/packages/document-capture/context/acuant.jsx b/app/javascript/packages/document-capture/context/acuant.jsx index 8a0a3f49e9d..02e2d50d6d8 100644 --- a/app/javascript/packages/document-capture/context/acuant.jsx +++ b/app/javascript/packages/document-capture/context/acuant.jsx @@ -205,9 +205,9 @@ function AcuantContextProvider({ window ).AcuantCamera; - addPageAction({ - label: 'IdV: Acuant SDK loaded', - payload: { success: true, isCameraSupported: nextIsCameraSupported }, + addPageAction('IdV: Acuant SDK loaded', { + success: true, + isCameraSupported: nextIsCameraSupported, }); setIsCameraSupported(nextIsCameraSupported); @@ -216,13 +216,10 @@ function AcuantContextProvider({ }); }, onFail(code, description) { - addPageAction({ - label: 'IdV: Acuant SDK loaded', - payload: { - success: false, - code, - description, - }, + addPageAction('IdV: Acuant SDK loaded', { + success: false, + code, + description, }); setIsError(true); diff --git a/app/javascript/packages/document-capture/context/analytics.jsx b/app/javascript/packages/document-capture/context/analytics.jsx index 909ce78f1df..93bb4bf3ea4 100644 --- a/app/javascript/packages/document-capture/context/analytics.jsx +++ b/app/javascript/packages/document-capture/context/analytics.jsx @@ -1,5 +1,6 @@ import { createContext } from 'react'; +/** @typedef {import('@18f/identity-analytics').trackEvent} TrackEvent */ /** @typedef {Record} Payload */ /** @@ -10,10 +11,6 @@ import { createContext } from 'react'; * @property {Payload=} payload Additional payload arguments to log with action. */ -/** - * @typedef {(action: PageAction)=>void} AddPageAction - */ - /** * @typedef {(error: Error)=>void} NoticeError */ @@ -21,13 +18,13 @@ import { createContext } from 'react'; /** * @typedef AnalyticsContext * - * @prop {AddPageAction} addPageAction Log an action with optional payload. + * @prop {TrackEvent} addPageAction Log an action with optional payload. * @prop {NoticeError} noticeError Log an error without affecting application behavior. */ const AnalyticsContext = createContext( /** @type {AnalyticsContext} */ ({ - addPageAction: () => {}, + addPageAction: () => Promise.resolve(), noticeError: () => {}, }), ); diff --git a/app/javascript/packages/document-capture/higher-order/with-background-encrypted-upload.jsx b/app/javascript/packages/document-capture/higher-order/with-background-encrypted-upload.jsx index d6f35a1855d..8fa6d73a8f3 100644 --- a/app/javascript/packages/document-capture/higher-order/with-background-encrypted-upload.jsx +++ b/app/javascript/packages/document-capture/higher-order/with-background-encrypted-upload.jsx @@ -102,24 +102,14 @@ const withBackgroundEncryptedUpload = (Component) => { value, ) .catch((error) => { - addPageAction({ - label: 'IdV: document capture async upload encryption', - payload: { - success: false, - }, - }); + addPageAction('IdV: document capture async upload encryption', { success: false }); noticeError(error); // Rethrow error to skip upload and proceed from next `catch` block. throw error; }) .then((encryptedValue) => { - addPageAction({ - label: 'IdV: document capture async upload encryption', - payload: { - success: true, - }, - }); + addPageAction('IdV: document capture async upload encryption', { success: true }); return window.fetch(url, { method: 'PUT', @@ -129,14 +119,10 @@ const withBackgroundEncryptedUpload = (Component) => { }) .then((response) => { const traceId = response.headers.get('X-Amzn-Trace-Id'); - addPageAction({ - key: 'documentCapture.asyncUpload', - label: 'IdV: document capture async upload submitted', - payload: { - success: response.ok, - trace_id: traceId, - status_code: response.status, - }, + addPageAction('IdV: document capture async upload submitted', { + success: response.ok, + trace_id: traceId, + status_code: response.status, }); if (!response.ok) { diff --git a/app/javascript/packs/document-capture.jsx b/app/javascript/packs/document-capture.jsx index eeafa061367..317377fb4ba 100644 --- a/app/javascript/packs/document-capture.jsx +++ b/app/javascript/packs/document-capture.jsx @@ -20,7 +20,6 @@ import { trackEvent } from '@18f/identity-analytics'; /** * @typedef NewRelicAgent * - * @prop {(name:string,attributes:object)=>void} addPageAction Log page action to New Relic. * @prop {(error:Error)=>void} noticeError Log an error without affecting application behavior. */ @@ -103,17 +102,10 @@ const device = { isMobile: isCameraCapableMobile(), }; -/** @type {import('@18f/identity-document-capture/context/analytics').AddPageAction} */ -function addPageAction(action) { +/** @type {import('@18f/identity-analytics').trackEvent} */ +function addPageAction(event, payload) { const { flowPath } = appRoot.dataset; - const payload = { ...action.payload, flow_path: flowPath }; - - const { newrelic } = /** @type {DocumentCaptureGlobal} */ (window); - if (action.key && newrelic) { - newrelic.addPageAction(action.key, payload); - } - - trackEvent(action.label, payload); + return trackEvent(event, { ...payload, flow_path: flowPath }); } /** @type {import('@18f/identity-document-capture/context/analytics').NoticeError} */ diff --git a/spec/javascripts/packages/document-capture/components/acuant-capture-spec.jsx b/spec/javascripts/packages/document-capture/components/acuant-capture-spec.jsx index 472deefec35..8d4bd58d420 100644 --- a/spec/javascripts/packages/document-capture/components/acuant-capture-spec.jsx +++ b/spec/javascripts/packages/document-capture/components/acuant-capture-spec.jsx @@ -278,9 +278,9 @@ describe('document-capture/components/acuant-capture', () => { await findByText('doc_auth.errors.camera.failed'); expect(window.AcuantCameraUI.end).to.have.been.calledOnce(); expect(container.querySelector('.full-screen')).to.be.null(); - expect(addPageAction).to.have.been.calledWith({ - label: 'IdV: Image capture failed', - payload: { field: 'test', error: 'Camera not supported' }, + expect(addPageAction).to.have.been.calledWith('IdV: Image capture failed', { + field: 'test', + error: 'Camera not supported', }); expect(document.activeElement).to.equal(button); }); @@ -313,9 +313,9 @@ describe('document-capture/components/acuant-capture', () => { await findByText('doc_auth.errors.upload_error errors.messages.try_again'); expect(window.AcuantCameraUI.end).to.have.been.calledOnce(); expect(container.querySelector('.full-screen')).to.be.null(); - expect(addPageAction).to.have.been.calledWith({ - label: 'IdV: Image capture failed', - payload: { field: 'test', error: 'iOS 15 GPU Highwater failure (SEQUENCE_BREAK_CODE)' }, + expect(addPageAction).to.have.been.calledWith('IdV: Image capture failed', { + field: 'test', + error: 'iOS 15 GPU Highwater failure (SEQUENCE_BREAK_CODE)', }); await waitFor(() => document.activeElement === button); @@ -355,9 +355,9 @@ describe('document-capture/components/acuant-capture', () => { expect(window.AcuantCameraUI.end).to.eventually.be.called(), ]); expect(container.querySelector('.full-screen')).to.be.null(); - expect(addPageAction).to.have.been.calledWith({ - label: 'IdV: Image capture failed', - payload: { field: 'test', error: 'User or system denied camera access' }, + expect(addPageAction).to.have.been.calledWith('IdV: Image capture failed', { + field: 'test', + error: 'User or system denied camera access', }); expect(document.activeElement).to.equal(button); }); @@ -573,27 +573,23 @@ describe('document-capture/components/acuant-capture', () => { fireEvent.click(button); const error = await findByText('doc_auth.errors.glare.failed_short'); - expect(addPageAction).to.have.been.calledWith({ - key: 'documentCapture.acuantWebSDKResult', - label: 'IdV: test image added', - payload: { - documentType: 'id', - mimeType: 'image/jpeg', - source: 'acuant', - dpi: 519, - moire: 99, - glare: 49, - height: 1104, - sharpnessScoreThreshold: sinon.match.number, - glareScoreThreshold: 50, - isAssessedAsBlurry: false, - isAssessedAsGlare: true, - assessment: 'glare', - sharpness: 100, - width: 1748, - attempt: sinon.match.number, - size: sinon.match.number, - }, + expect(addPageAction).to.have.been.calledWith('IdV: test image added', { + documentType: 'id', + mimeType: 'image/jpeg', + source: 'acuant', + dpi: 519, + moire: 99, + glare: 49, + height: 1104, + sharpnessScoreThreshold: sinon.match.number, + glareScoreThreshold: 50, + isAssessedAsBlurry: false, + isAssessedAsGlare: true, + assessment: 'glare', + sharpness: 100, + width: 1748, + attempt: sinon.match.number, + size: sinon.match.number, }); expect(error).to.be.ok(); @@ -631,27 +627,23 @@ describe('document-capture/components/acuant-capture', () => { fireEvent.click(button); const error = await findByText('doc_auth.errors.sharpness.failed_short'); - expect(addPageAction).to.have.been.calledWith({ - key: 'documentCapture.acuantWebSDKResult', - label: 'IdV: test image added', - payload: { - documentType: 'id', - mimeType: 'image/jpeg', - source: 'acuant', - dpi: 519, - moire: 99, - glare: 100, - height: 1104, - sharpnessScoreThreshold: 50, - glareScoreThreshold: sinon.match.number, - isAssessedAsBlurry: true, - isAssessedAsGlare: false, - assessment: 'blurry', - sharpness: 49, - width: 1748, - attempt: sinon.match.number, - size: sinon.match.number, - }, + expect(addPageAction).to.have.been.calledWith('IdV: test image added', { + documentType: 'id', + mimeType: 'image/jpeg', + source: 'acuant', + dpi: 519, + moire: 99, + glare: 100, + height: 1104, + sharpnessScoreThreshold: 50, + glareScoreThreshold: sinon.match.number, + isAssessedAsBlurry: true, + isAssessedAsGlare: false, + assessment: 'blurry', + sharpness: 49, + width: 1748, + attempt: sinon.match.number, + size: sinon.match.number, }); expect(error).to.be.ok(); @@ -742,27 +734,23 @@ describe('document-capture/components/acuant-capture', () => { fireEvent.click(button); await waitFor(() => !error.textContent); - expect(addPageAction).to.have.been.calledWith({ - key: 'documentCapture.acuantWebSDKResult', - label: 'IdV: test image added', - payload: { - documentType: 'id', - mimeType: 'image/jpeg', - source: 'acuant', - dpi: 519, - moire: 99, - glare: 100, - height: 1104, - sharpnessScoreThreshold: 50, - glareScoreThreshold: sinon.match.number, - isAssessedAsBlurry: true, - isAssessedAsGlare: false, - assessment: 'blurry', - sharpness: 49, - width: 1748, - attempt: sinon.match.number, - size: sinon.match.number, - }, + expect(addPageAction).to.have.been.calledWith('IdV: test image added', { + documentType: 'id', + mimeType: 'image/jpeg', + source: 'acuant', + dpi: 519, + moire: 99, + glare: 100, + height: 1104, + sharpnessScoreThreshold: 50, + glareScoreThreshold: sinon.match.number, + isAssessedAsBlurry: true, + isAssessedAsGlare: false, + assessment: 'blurry', + sharpness: 49, + width: 1748, + attempt: sinon.match.number, + size: sinon.match.number, }); }); @@ -1002,16 +990,13 @@ describe('document-capture/components/acuant-capture', () => { const input = getByLabelText('Image'); uploadFile(input, validUpload); - await expect(addPageAction).to.eventually.be.calledWith({ - label: 'IdV: test image added', - payload: { - height: sinon.match.number, - mimeType: 'image/jpeg', - source: 'upload', - width: sinon.match.number, - attempt: sinon.match.number, - size: sinon.match.number, - }, + await expect(addPageAction).to.eventually.be.calledWith('IdV: test image added', { + height: sinon.match.number, + mimeType: 'image/jpeg', + source: 'upload', + width: sinon.match.number, + attempt: sinon.match.number, + size: sinon.match.number, }); }); @@ -1045,26 +1030,17 @@ describe('document-capture/components/acuant-capture', () => { fireEvent.click(upload); expect(addPageAction).to.have.been.calledThrice(); - expect(addPageAction.getCall(0)).to.have.been.calledWith({ - label: 'IdV: test image clicked', - payload: { - source: 'placeholder', - isDrop: false, - }, + expect(addPageAction.getCall(0)).to.have.been.calledWith('IdV: test image clicked', { + source: 'placeholder', + isDrop: false, }); - expect(addPageAction.getCall(1)).to.have.been.calledWith({ - label: 'IdV: test image clicked', - payload: { - source: 'button', - isDrop: false, - }, + expect(addPageAction.getCall(1)).to.have.been.calledWith('IdV: test image clicked', { + source: 'button', + isDrop: false, }); - expect(addPageAction.getCall(2)).to.have.been.calledWith({ - label: 'IdV: test image clicked', - payload: { - source: 'upload', - isDrop: false, - }, + expect(addPageAction.getCall(2)).to.have.been.calledWith('IdV: test image clicked', { + source: 'upload', + isDrop: false, }); }); @@ -1081,12 +1057,9 @@ describe('document-capture/components/acuant-capture', () => { const input = getByLabelText('Image'); fireEvent.drop(input); - expect(addPageAction.getCall(0)).to.have.been.calledWith({ - label: 'IdV: test image clicked', - payload: { - source: 'placeholder', - isDrop: true, - }, + expect(addPageAction.getCall(0)).to.have.been.calledWith('IdV: test image clicked', { + source: 'placeholder', + isDrop: true, }); }); @@ -1103,16 +1076,16 @@ describe('document-capture/components/acuant-capture', () => { const input = getByLabelText('Image'); uploadFile(input, validUpload); - await expect(addPageAction).to.eventually.be.calledWith({ - label: 'IdV: test image added', - payload: sinon.match({ attempt: 1 }), - }); + await expect(addPageAction).to.eventually.be.calledWith( + 'IdV: test image added', + sinon.match({ attempt: 1 }), + ); uploadFile(input, validUpload); - await expect(addPageAction).to.eventually.be.calledWith({ - label: 'IdV: test image added', - payload: sinon.match({ attempt: 2 }), - }); + await expect(addPageAction).to.eventually.be.calledWith( + 'IdV: test image added', + sinon.match({ attempt: 2 }), + ); }); }); diff --git a/spec/javascripts/packages/document-capture/components/capture-advice-spec.jsx b/spec/javascripts/packages/document-capture/components/capture-advice-spec.jsx index f1f46400ef7..019ea912647 100644 --- a/spec/javascripts/packages/document-capture/components/capture-advice-spec.jsx +++ b/spec/javascripts/packages/document-capture/components/capture-advice-spec.jsx @@ -14,22 +14,16 @@ describe('document-capture/components/capture-advice', () => { , ); - expect(addPageAction).to.have.been.calledWith({ - label: 'IdV: warning shown', - payload: { - location: 'doc_auth_capture_advice', - remaining_attempts: undefined, - }, + expect(addPageAction).to.have.been.calledWith('IdV: warning shown', { + location: 'doc_auth_capture_advice', + remaining_attempts: undefined, }); const button = getByRole('button'); await userEvent.click(button); - expect(addPageAction).to.have.been.calledWith({ - label: 'IdV: warning action triggered', - payload: { - location: 'doc_auth_capture_advice', - }, + expect(addPageAction).to.have.been.calledWith('IdV: warning action triggered', { + location: 'doc_auth_capture_advice', }); }); }); diff --git a/spec/javascripts/packages/document-capture/components/capture-troubleshooting-spec.jsx b/spec/javascripts/packages/document-capture/components/capture-troubleshooting-spec.jsx index 08028bb9581..b608a59bf36 100644 --- a/spec/javascripts/packages/document-capture/components/capture-troubleshooting-spec.jsx +++ b/spec/javascripts/packages/document-capture/components/capture-troubleshooting-spec.jsx @@ -89,17 +89,15 @@ describe('document-capture/context/capture-troubleshooting', () => { ); expect(addPageAction).to.have.been.calledTwice(); - expect(addPageAction).to.have.been.calledWith({ - label: 'IdV: Capture troubleshooting shown', - payload: { isAssessedAsGlare: false, isAssessedAsBlurry: false }, + expect(addPageAction).to.have.been.calledWith('IdV: Capture troubleshooting shown', { + isAssessedAsGlare: false, + isAssessedAsBlurry: false, }); const tryAgainButton = getByRole('button', { name: 'idv.failure.button.warning' }); await userEvent.click(tryAgainButton); expect(addPageAction.callCount).to.equal(4); - expect(addPageAction).to.have.been.calledWith({ - label: 'IdV: Capture troubleshooting dismissed', - }); + expect(addPageAction).to.have.been.calledWith('IdV: Capture troubleshooting dismissed'); }); }); diff --git a/spec/javascripts/packages/document-capture/components/review-issues-step-spec.jsx b/spec/javascripts/packages/document-capture/components/review-issues-step-spec.jsx index dc419c3b5d3..d3792abc2e4 100644 --- a/spec/javascripts/packages/document-capture/components/review-issues-step-spec.jsx +++ b/spec/javascripts/packages/document-capture/components/review-issues-step-spec.jsx @@ -23,22 +23,16 @@ describe('document-capture/components/review-issues-step', () => { , ); - expect(addPageAction).to.have.been.calledWith({ - label: 'IdV: warning shown', - payload: { - location: 'doc_auth_review_issues', - remaining_attempts: 3, - }, + expect(addPageAction).to.have.been.calledWith('IdV: warning shown', { + location: 'doc_auth_review_issues', + remaining_attempts: 3, }); const button = getByRole('button'); await userEvent.click(button); - expect(addPageAction).to.have.been.calledWith({ - label: 'IdV: warning action triggered', - payload: { - location: 'doc_auth_review_issues', - }, + expect(addPageAction).to.have.been.calledWith('IdV: warning action triggered', { + location: 'doc_auth_review_issues', }); }); diff --git a/spec/javascripts/packages/document-capture/components/warning-spec.jsx b/spec/javascripts/packages/document-capture/components/warning-spec.jsx index 41f69178889..6726a4bcab9 100644 --- a/spec/javascripts/packages/document-capture/components/warning-spec.jsx +++ b/spec/javascripts/packages/document-capture/components/warning-spec.jsx @@ -30,9 +30,9 @@ describe('document-capture/components/warning', () => { , ); - expect(addPageAction).to.have.been.calledWith({ - label: 'IdV: warning shown', - payload: { location: 'example', remaining_attempts: undefined }, + expect(addPageAction).to.have.been.calledWith('IdV: warning shown', { + location: 'example', + remaining_attempts: undefined, }); const tryAgainButton = getByRole('button', { name: 'Try again' }); @@ -41,11 +41,8 @@ describe('document-capture/components/warning', () => { expect(getByRole('heading', { name: 'Oops!' })).to.exist(); expect(tryAgainButton).to.exist(); expect(actionOnClick).to.have.been.calledOnce(); - expect(addPageAction).to.have.been.calledWith({ - label: 'IdV: warning action triggered', - payload: { - location: 'example', - }, + expect(addPageAction).to.have.been.calledWith('IdV: warning action triggered', { + location: 'example', }); expect(getByText('Something went wrong')).to.exist(); expect(getByRole('heading', { name: 'Having trouble?' })).to.exist(); diff --git a/spec/javascripts/packages/document-capture/context/acuant-spec.jsx b/spec/javascripts/packages/document-capture/context/acuant-spec.jsx index 84867f1403f..68170181661 100644 --- a/spec/javascripts/packages/document-capture/context/acuant-spec.jsx +++ b/spec/javascripts/packages/document-capture/context/acuant-spec.jsx @@ -156,12 +156,9 @@ describe('document-capture/context/acuant', () => { }); it('logs', () => { - expect(addPageAction).to.have.been.calledWith({ - label: 'IdV: Acuant SDK loaded', - payload: { - success: true, - isCameraSupported: true, - }, + expect(addPageAction).to.have.been.calledWith('IdV: Acuant SDK loaded', { + success: true, + isCameraSupported: true, }); }); }); @@ -179,12 +176,9 @@ describe('document-capture/context/acuant', () => { }); it('logs', () => { - expect(addPageAction).to.have.been.calledWith({ - label: 'IdV: Acuant SDK loaded', - payload: { - success: true, - isCameraSupported: false, - }, + expect(addPageAction).to.have.been.calledWith('IdV: Acuant SDK loaded', { + success: true, + isCameraSupported: false, }); }); }); @@ -219,13 +213,10 @@ describe('document-capture/context/acuant', () => { }); it('logs', () => { - expect(addPageAction).to.have.been.calledWith({ - label: 'IdV: Acuant SDK loaded', - payload: { - success: false, - code: sinon.match.number, - description: sinon.match.string, - }, + expect(addPageAction).to.have.been.calledWith('IdV: Acuant SDK loaded', { + success: false, + code: sinon.match.number, + description: sinon.match.string, }); }); }); diff --git a/spec/javascripts/packages/document-capture/higher-order/with-background-encrypted-upload-spec.jsx b/spec/javascripts/packages/document-capture/higher-order/with-background-encrypted-upload-spec.jsx index e6a1b108fca..fbd3f0c2157 100644 --- a/spec/javascripts/packages/document-capture/higher-order/with-background-encrypted-upload-spec.jsx +++ b/spec/javascripts/packages/document-capture/higher-order/with-background-encrypted-upload-spec.jsx @@ -189,15 +189,14 @@ describe('document-capture/higher-order/with-background-encrypted-upload', () => await onChange.getCall(0).args[0].foo_image_url; expect(addPageAction).to.have.been.calledTwice(); - expect(addPageAction).to.have.been.calledWith({ - label: 'IdV: document capture async upload encryption', - payload: { success: true }, - }); - expect(addPageAction).to.have.been.calledWith({ - key: 'documentCapture.asyncUpload', - label: 'IdV: document capture async upload submitted', - payload: { success: true, trace_id: null, status_code: 200 }, - }); + expect(addPageAction).to.have.been.calledWith( + 'IdV: document capture async upload encryption', + { success: true }, + ); + expect(addPageAction).to.have.been.calledWith( + 'IdV: document capture async upload submitted', + { success: true, trace_id: null, status_code: 200 }, + ); }); }); @@ -248,10 +247,10 @@ describe('document-capture/higher-order/with-background-encrypted-upload', () => sinon.match.instanceOf(BackgroundEncryptedUploadError), { field: 'foo' }, ); - expect(addPageAction).to.have.been.calledWith({ - label: 'IdV: document capture async upload encryption', - payload: { success: false }, - }); + expect(addPageAction).to.have.been.calledWith( + 'IdV: document capture async upload encryption', + { success: false }, + ); expect(noticeError).to.have.been.calledWith(error); expect(window.fetch).not.to.have.been.called(); }); @@ -272,19 +271,18 @@ describe('document-capture/higher-order/with-background-encrypted-upload', () => await onChange.getCall(0).args[0].foo_image_url.catch(() => {}); expect(addPageAction).to.have.been.calledTwice(); - expect(addPageAction).to.have.been.calledWith({ - label: 'IdV: document capture async upload encryption', - payload: { success: true }, - }); - expect(addPageAction).to.have.been.calledWith({ - key: 'documentCapture.asyncUpload', - label: 'IdV: document capture async upload submitted', - payload: { + expect(addPageAction).to.have.been.calledWith( + 'IdV: document capture async upload encryption', + { success: true }, + ); + expect(addPageAction).to.have.been.calledWith( + 'IdV: document capture async upload submitted', + { success: false, trace_id: '1-67891233-abcdef012345678912345678', status_code: 403, }, - }); + ); }); }); }); From ab29c7db1f427adc1ef8c2e46448a1ad4176794b Mon Sep 17 00:00:00 2001 From: Andrew Duthie Date: Wed, 4 May 2022 12:37:22 -0400 Subject: [PATCH 04/29] Use stubbed profile for authorization_count_spec (#6255) * Use stubbed profile for authorization_count_spec **Why:** - For improved compatibility with JS-enabled proofing, where authorization counts rely on an "Agree and continue" redirect back to the SP. With the JavaScript browser, there is no server to redirect to, resulting in an error. - Improved performance, since proofing involves many steps - To limit the concern of the specs to authorization counts, not to the ability to successfully proof changelog: Internal, Automated Testing, Improve performance of automated tests * Only set PII for verified profile mocks * Require PII opt-in for profile stubs too many tests assume it won't be there (probably a problem worth resolving) * Add non-empty vendor for liveness check component As of #6262, we now check component as "blank?". In the real world, the value would be the vendor name, so add a placeholder value for tests. * Update authorization_count_spec.rb * Remove default PII shouldn't have been here - bad cherry-pick? * Avoid concat for user profile creation See: https://github.com/18F/identity-idp/pull/6255/files#r863108404 Co-Authored-By: Zach Margolis * Remove unnecessary user save Co-authored-by: Zach Margolis Co-authored-by: Zach Margolis Co-authored-by: Zach Margolis --- spec/factories/profiles.rb | 14 ++++ spec/factories/users.rb | 8 +++ .../reports/authorization_count_spec.rb | 66 +++++++++++-------- 3 files changed, 62 insertions(+), 26 deletions(-) diff --git a/spec/factories/profiles.rb b/spec/factories/profiles.rb index 1b06be0a036..2a21e321291 100644 --- a/spec/factories/profiles.rb +++ b/spec/factories/profiles.rb @@ -1,6 +1,7 @@ FactoryBot.define do factory :profile do association :user, factory: %i[user signed_up] + transient do pii { false } end @@ -18,6 +19,19 @@ deactivation_reason { :password_reset } end + trait :with_liveness do + proofing_components { { liveness_check: 'vendor' } } + end + + trait :with_pii do + pii do + DocAuth::Mock::ResultResponseBuilder::DEFAULT_PII_FROM_DOC.merge( + ssn: DocAuthHelper::GOOD_SSN, + phone: '+1 (555) 555-1234', + ) + end + end + after(:build) do |profile, evaluator| if evaluator.pii pii_attrs = Pii::Attributes.new_from_hash(evaluator.pii) diff --git a/spec/factories/users.rb b/spec/factories/users.rb index 0ae6fca1948..1bfa7e8a2cc 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -187,5 +187,13 @@ confirmation_token { 'token' } password { nil } end + + trait :proofed do + signed_up + + after :build do |user| + create(:profile, :active, :verified, :with_pii, user: user) + end + end end end diff --git a/spec/features/reports/authorization_count_spec.rb b/spec/features/reports/authorization_count_spec.rb index 08899e1c8da..e092f15f33a 100644 --- a/spec/features/reports/authorization_count_spec.rb +++ b/spec/features/reports/authorization_count_spec.rb @@ -38,8 +38,7 @@ def visit_idp_from_ial2_saml_sp(issuer:) include OidcAuthHelper include DocAuthHelper - let(:email) { 'test@test.com' } - let(:password) { RequestHelper::VALID_PASSWORD } + let(:user) { nil } let(:today) { Time.zone.today } let(:client_id_1) { 'urn:gov:gsa:openidconnect:sp:server' } let(:client_id_2) { 'urn:gov:gsa:openidconnect:sp:server_two' } @@ -47,15 +46,16 @@ def visit_idp_from_ial2_saml_sp(issuer:) let(:issuer_2) { 'https://rp3.serviceprovider.com/auth/saml/metadata' } context 'an IAL1 user with an active session' do + let(:user) { create(:user, :signed_up) } + before do - create_ial1_user_from_sp(email) - reset_monthly_auth_count_and_login + reset_monthly_auth_count_and_login(user) end context 'using oidc' do it 'does not count second IAL1 auth at same sp' do visit_idp_from_ial1_oidc_sp(client_id: client_id_1) - click_continue + click_agree_and_continue expect_ial1_count_only(client_id_1) visit_idp_from_ial1_oidc_sp(client_id: client_id_1) @@ -65,11 +65,14 @@ def visit_idp_from_ial2_saml_sp(issuer:) it 'counts step up from IAL1 to IAL2 after proofing' do visit_idp_from_ial1_oidc_sp(client_id: client_id_1) - click_continue + click_agree_and_continue expect_ial1_count_only(client_id_1) + create(:profile, :active, :verified, :with_pii, user: user) visit_idp_from_ial2_oidc_sp(client_id: client_id_1) - complete_proofing_steps + fill_in t('account.index.password'), with: user.password + click_submit_default + click_agree_and_continue expect_ial1_and_ial2_count(client_id_1) end @@ -79,10 +82,12 @@ def visit_idp_from_ial2_saml_sp(issuer:) expect_ial1_count_only(client_id_1) end - it 'proofs user and counts IAL2 auth when ial2 strict is requested' do + it 'counts IAL2 auth when ial2 strict is requested' do allow(IdentityConfig.store).to receive(:liveness_checking_enabled).and_return(true) + create(:profile, :active, :verified, :with_pii, :with_liveness, user: user) visit_idp_from_ial2_strict_oidc_sp(client_id: client_id_1) - reproof_for_ial2_strict + fill_in t('account.index.password'), with: user.password + click_submit_default click_agree_and_continue expect_ial2_count_only(client_id_1) end @@ -104,8 +109,11 @@ def visit_idp_from_ial2_saml_sp(issuer:) click_agree_and_continue expect_ial1_count_only(issuer_1) + create(:profile, :active, :verified, :with_pii, user: user) visit_idp_from_ial2_saml_sp(issuer: issuer_1) - complete_proofing_steps + fill_in t('account.index.password'), with: user.password + click_submit_default + click_agree_and_continue expect_ial1_and_ial2_count(issuer_1) end @@ -130,6 +138,7 @@ def visit_idp_from_ial2_saml_sp(issuer:) end it 'counts IAL2 auth when ial2 strict is requested' do + create(:profile, :active, :verified, :with_pii, user: user) visit_saml_authn_request_url( overrides: { issuer: issuer_1, @@ -144,12 +153,15 @@ def visit_idp_from_ial2_saml_sp(issuer:) }, }, ) + fill_in t('account.index.password'), with: user.password + click_submit_default click_agree_and_continue expect_ial2_count_only(issuer_1) end - it 'proofs the user and counts IAL2 auth when ial2 strict is requested' do + it 'counts IAL2 auth when ial2 strict is requested' do allow(IdentityConfig.store).to receive(:liveness_checking_enabled).and_return(true) + create(:profile, :active, :verified, :with_pii, :with_liveness, user: user) visit_saml_authn_request_url( overrides: { issuer: issuer_1, @@ -164,7 +176,8 @@ def visit_idp_from_ial2_saml_sp(issuer:) }, }, ) - reproof_for_ial2_strict + fill_in t('account.index.password'), with: user.password + click_submit_default click_agree_and_continue expect_ial2_count_only(issuer_1) end @@ -173,15 +186,16 @@ def visit_idp_from_ial2_saml_sp(issuer:) end context 'an IAL2 user with an active session' do + let(:user) { create(:user, :proofed) } + before do - create_ial2_user_from_sp(email) - reset_monthly_auth_count_and_login + reset_monthly_auth_count_and_login(user) end context 'using oidc' do it 'counts IAL1 auth at same sp' do visit_idp_from_ial2_oidc_sp(client_id: client_id_1) - click_continue + click_agree_and_continue expect_ial2_count_only(client_id_1) visit_idp_from_ial1_oidc_sp(client_id: client_id_1) @@ -191,7 +205,7 @@ def visit_idp_from_ial2_saml_sp(issuer:) it 'does not count second IAL2 auth at same sp' do visit_idp_from_ial2_oidc_sp(client_id: client_id_1) - click_continue + click_agree_and_continue expect_ial2_count_only(client_id_1) visit_idp_from_ial2_oidc_sp(client_id: client_id_1) @@ -211,7 +225,7 @@ def visit_idp_from_ial2_saml_sp(issuer:) it 'counts IAL2 auth at another sp' do visit_idp_from_ial2_oidc_sp(client_id: client_id_1) - click_continue + click_agree_and_continue expect_ial2_count_only(client_id_1) visit_idp_from_ial2_oidc_sp(client_id: client_id_2) @@ -221,7 +235,7 @@ def visit_idp_from_ial2_saml_sp(issuer:) it 'counts IAL1 auth at another sp' do visit_idp_from_ial1_oidc_sp(client_id: client_id_1) - click_continue + click_agree_and_continue expect_ial1_count_only(client_id_1) visit_idp_from_ial1_oidc_sp(client_id: client_id_2) @@ -231,14 +245,14 @@ def visit_idp_from_ial2_saml_sp(issuer:) it 'counts IAL2 auth when ial max is requested' do visit_idp_from_ial_max_oidc_sp(client_id: client_id_1) - click_continue + click_agree_and_continue expect_ial2_count_only(client_id_1) end - it 're-proofs and counts IAL2 auth when ial2 strict is requested' do + it 'counts IAL2 auth when ial2 strict is requested' do allow(IdentityConfig.store).to receive(:liveness_checking_enabled).and_return(true) + user.active_profile.update(proofing_components: { liveness_check: 'vendor' }) visit_idp_from_ial2_strict_oidc_sp(client_id: client_id_1) - reproof_for_ial2_strict click_agree_and_continue expect_ial2_count_only(client_id_1) end @@ -315,8 +329,9 @@ def visit_idp_from_ial2_saml_sp(issuer:) expect_ial2_count_only(issuer_1) end - it 're-proofs and counts IAL2 auth when ial2 strict is requested' do + it 'counts IAL2 auth when ial2 strict is requested' do allow(IdentityConfig.store).to receive(:liveness_checking_enabled).and_return(true) + user.active_profile.update(proofing_components: { liveness_check: 'vendor' }) visit_saml_authn_request_url( overrides: { issuer: issuer_1, @@ -331,7 +346,6 @@ def visit_idp_from_ial2_saml_sp(issuer:) }, }, ) - reproof_for_ial2_strict click_agree_and_continue expect_ial2_count_only(issuer_1) end @@ -377,10 +391,10 @@ def ial1_monthly_auth_count(client_id) Db::MonthlySpAuthCount::SpMonthTotalAuthCounts.call(today, client_id, 1) end - def reset_monthly_auth_count_and_login + def reset_monthly_auth_count_and_login(user) MonthlySpAuthCount.delete_all SpReturnLog.delete_all - visit api_saml_logout2022_url - fill_in_credentials_and_submit(email, RequestHelper::VALID_PASSWORD) + visit api_saml_logout2022_path + sign_in_live_with_2fa(user) end end From 751ce17c46dec9089c122de16c83f6739f17a981 Mon Sep 17 00:00:00 2001 From: Andrew Duthie Date: Wed, 4 May 2022 12:49:04 -0400 Subject: [PATCH 05/29] test-helpers: Add useSandbox test helper (#6301) * Move useSandbox to test-helpers package **Why**: For better organization, and so that it's accessible to package specs. * Add support for destructured sandbox clock * Special-case clock tick destructure proxy Since it's the most common method called, and to allow it to be stored as a reference and called later * Convert existing package spec sandboxes to use test helper Much more convenient * Create passthrough proxy for clock implementation * Add basic spec for clean-up behavior * Add changelog changelog: Internal, Automated Testing, Add test helper for JavaScript stubbing sandbox --- .../packages/countdown-element/index.spec.ts | 10 +---- .../spinner-button-element.spec.ts | 14 ++---- .../spinner-button/spinner-button.spec.tsx | 18 +++----- app/javascript/packages/test-helpers/index.ts | 1 + .../packages/test-helpers/package.json | 5 ++- .../packages/test-helpers/use-sandbox.spec.ts | 36 +++++++++++++++ .../packages/test-helpers/use-sandbox.ts | 45 +++++++++++++++++++ spec/javascripts/app/components/modal-spec.js | 2 +- spec/javascripts/app/webauthn_spec.js | 2 +- .../packages/analytics/index-spec.js | 2 +- .../document-capture-polling/index-spec.js | 2 +- .../components/document-capture-spec.jsx | 2 +- .../components/review-issues-step-spec.jsx | 2 +- .../components/selfie-capture-spec.jsx | 2 +- .../components/submission-complete-spec.jsx | 2 +- .../document-capture/context/upload-spec.jsx | 2 +- .../with-background-encrypted-upload-spec.jsx | 2 +- .../document-capture/services/upload-spec.js | 2 +- .../one-time-code-input/index-spec.js | 2 +- .../javascripts/packs/form-steps-wait-spec.js | 3 +- spec/javascripts/packs/webauthn-setup-spec.js | 3 +- .../javascripts/packs/webauthn-unhide-spec.js | 3 +- spec/javascripts/support/sinon.js | 32 ------------- 23 files changed, 111 insertions(+), 83 deletions(-) create mode 100644 app/javascript/packages/test-helpers/use-sandbox.spec.ts create mode 100644 app/javascript/packages/test-helpers/use-sandbox.ts diff --git a/app/javascript/packages/countdown-element/index.spec.ts b/app/javascript/packages/countdown-element/index.spec.ts index 5666ce65051..09dcfc0e748 100644 --- a/app/javascript/packages/countdown-element/index.spec.ts +++ b/app/javascript/packages/countdown-element/index.spec.ts @@ -1,6 +1,6 @@ import sinon from 'sinon'; import { i18n } from '@18f/identity-i18n'; -import { usePropertyValue } from '@18f/identity-test-helpers'; +import { usePropertyValue, useSandbox } from '@18f/identity-test-helpers'; import { CountdownElement } from './index'; const DEFAULT_DATASET = { @@ -10,7 +10,7 @@ const DEFAULT_DATASET = { }; describe('CountdownElement', () => { - let clock: sinon.SinonFakeTimers; + const { clock } = useSandbox({ useFakeTimers: true }); usePropertyValue(i18n, 'strings', { 'datetime.dotiw.seconds': { one: 'one second', other: '%{count} seconds' }, @@ -22,12 +22,6 @@ describe('CountdownElement', () => { if (!customElements.get('lg-countdown')) { customElements.define('lg-countdown', CountdownElement); } - - clock = sinon.useFakeTimers(); - }); - - after(() => { - clock.restore(); }); function createElement(dataset = {}) { diff --git a/app/javascript/packages/spinner-button/spinner-button-element.spec.ts b/app/javascript/packages/spinner-button/spinner-button-element.spec.ts index 1854bd03df5..c91fdff2d7f 100644 --- a/app/javascript/packages/spinner-button/spinner-button-element.spec.ts +++ b/app/javascript/packages/spinner-button/spinner-button-element.spec.ts @@ -1,12 +1,12 @@ -import sinon from 'sinon'; import baseUserEvent from '@testing-library/user-event'; import { getByRole, fireEvent, screen } from '@testing-library/dom'; +import { useSandbox } from '@18f/identity-test-helpers'; import './spinner-button-element'; import type { SpinnerButtonElement } from './spinner-button-element'; describe('SpinnerButtonElement', () => { - let clock: sinon.SinonFakeTimers; - const userEvent = baseUserEvent.setup({ advanceTimers: (ms: number) => clock.tick(ms) }); + const { clock } = useSandbox({ useFakeTimers: true }); + const userEvent = baseUserEvent.setup({ advanceTimers: clock.tick }); const longWaitDurationMs = 1000; @@ -45,14 +45,6 @@ describe('SpinnerButtonElement', () => { return document.body.firstElementChild as SpinnerButtonElement; } - beforeEach(() => { - clock = sinon.useFakeTimers(); - }); - - afterEach(() => { - clock.restore(); - }); - it('shows spinner on click', async () => { const wrapper = createWrapper(); const button = screen.getByRole('link', { name: 'Click Me' }); diff --git a/app/javascript/packages/spinner-button/spinner-button.spec.tsx b/app/javascript/packages/spinner-button/spinner-button.spec.tsx index 7eed3392d74..266414bf25f 100644 --- a/app/javascript/packages/spinner-button/spinner-button.spec.tsx +++ b/app/javascript/packages/spinner-button/spinner-button.spec.tsx @@ -1,21 +1,13 @@ -import sinon from 'sinon'; import baseUserEvent from '@testing-library/user-event'; import { render } from '@testing-library/react'; import { createRef } from 'react'; +import { useSandbox } from '@18f/identity-test-helpers'; import { SpinnerButtonElement } from './spinner-button-element'; import SpinnerButton from './spinner-button'; describe('SpinnerButton', () => { - const sandbox = sinon.createSandbox(); - const userEvent = baseUserEvent.setup({ advanceTimers: (ms: number) => sandbox.clock.tick(ms) }); - - beforeEach(() => { - sandbox.useFakeTimers(); - }); - - afterEach(() => { - sandbox.restore(); - }); + const { clock } = useSandbox({ useFakeTimers: true }); + const userEvent = baseUserEvent.setup({ advanceTimers: clock.tick }); it('renders a SpinnerButton', async () => { const { getByRole } = render(Spin!); @@ -50,7 +42,7 @@ describe('SpinnerButton', () => { expect(status.textContent).not.to.be.empty(); expect(status.classList.contains('usa-sr-only')).to.be.true(); - sandbox.clock.tick(1); + clock.tick(1); expect(status.textContent).not.to.be.empty(); expect(status.classList.contains('usa-sr-only')).to.be.false(); @@ -70,7 +62,7 @@ describe('SpinnerButton', () => { expect(spinner.classList.contains('spinner-button--spinner-active')).to.be.false(); spinner.toggleSpinner(true); - sandbox.clock.tick(1); + clock.tick(1); expect(status.classList.contains('usa-sr-only')).to.be.false(); }); diff --git a/app/javascript/packages/test-helpers/index.ts b/app/javascript/packages/test-helpers/index.ts index c835adac397..4467543c15b 100644 --- a/app/javascript/packages/test-helpers/index.ts +++ b/app/javascript/packages/test-helpers/index.ts @@ -1,2 +1,3 @@ export { default as useDefineProperty } from './use-define-property'; export { default as usePropertyValue } from './use-property-value'; +export { default as useSandbox } from './use-sandbox'; diff --git a/app/javascript/packages/test-helpers/package.json b/app/javascript/packages/test-helpers/package.json index 39a34107089..d34e1ae3b98 100644 --- a/app/javascript/packages/test-helpers/package.json +++ b/app/javascript/packages/test-helpers/package.json @@ -1,5 +1,8 @@ { "name": "@18f/identity-test-helpers", "private": true, - "version": "1.0.0" + "version": "1.0.0", + "peerDependencies": { + "sinon": "^9.2.2" + } } diff --git a/app/javascript/packages/test-helpers/use-sandbox.spec.ts b/app/javascript/packages/test-helpers/use-sandbox.spec.ts new file mode 100644 index 00000000000..39c71beea37 --- /dev/null +++ b/app/javascript/packages/test-helpers/use-sandbox.spec.ts @@ -0,0 +1,36 @@ +import useSandbox from './use-sandbox'; + +describe('useSandbox', () => { + const sandbox = useSandbox(); + + const object = { fn: () => 0 }; + + afterEach(() => { + expect(object.fn()).to.equal(0); + }); + + it('cleans up after itself', () => { + sandbox.stub(object, 'fn').callsFake(() => 1); + + expect(object.fn()).to.equal(1); + // See `afterEach` for clean-up assertions + }); + + context('with fake timers', () => { + const { clock } = useSandbox({ useFakeTimers: true }); + + expect(clock.tick).to.be.a('function'); + + it('supports invoking against a destructured clock', () => { + clock.tick(0); + }); + + it('advances the clock', () => { + const MAX_SAFE_32_BIT_INT = 2147483647; + return new Promise((resolve) => { + setTimeout(resolve, MAX_SAFE_32_BIT_INT); + clock.tick(MAX_SAFE_32_BIT_INT); + }); + }); + }); +}); diff --git a/app/javascript/packages/test-helpers/use-sandbox.ts b/app/javascript/packages/test-helpers/use-sandbox.ts new file mode 100644 index 00000000000..8899a8cac84 --- /dev/null +++ b/app/javascript/packages/test-helpers/use-sandbox.ts @@ -0,0 +1,45 @@ +import sinon from 'sinon'; +import type { SinonSandboxConfig, SinonFakeTimers } from 'sinon'; + +/** + * Returns an instance of a Sinon sandbox, and automatically restores all stubbed methods after each + * test case. + */ +function useSandbox(config?: Partial) { + const { useFakeTimers = false, ...remainingConfig } = config ?? {}; + const sandbox = sinon.createSandbox(remainingConfig); + + // To support destructuring the result of the sandbox while still waiting for `beforeEach` to + // initialize the fake timers, create a proxy to pass through to the underlying implementation. + const clockImpl = {}; + if (useFakeTimers) { + sandbox.clock = Object.fromEntries( + Object.entries(sinon.useFakeTimers()).map(([key, value]) => [ + key, + key === 'restore' ? value : (...args: any[]) => clockImpl[key](...args), + ]), + ) as SinonFakeTimers; + sandbox.clock.restore(); + } + + beforeEach(() => { + // useFakeTimers overrides global timer functions as soon as sandbox is created, thus leaking + // across tests. Instead, wait until tests start to initialize. + if (useFakeTimers) { + Object.assign(clockImpl, sandbox.useFakeTimers()); + } + }); + + afterEach(() => { + sandbox.reset(); + sandbox.restore(); + + if (useFakeTimers) { + sandbox.clock.restore(); + } + }); + + return sandbox; +} + +export default useSandbox; diff --git a/spec/javascripts/app/components/modal-spec.js b/spec/javascripts/app/components/modal-spec.js index 64783c3b429..e1a5104282e 100644 --- a/spec/javascripts/app/components/modal-spec.js +++ b/spec/javascripts/app/components/modal-spec.js @@ -1,6 +1,6 @@ import { waitFor } from '@testing-library/dom'; +import { useSandbox } from '@18f/identity-test-helpers'; import BaseModal from '../../../../app/javascript/app/components/modal'; -import { useSandbox } from '../../support/sinon'; describe('components/modal', () => { const sandbox = useSandbox(); diff --git a/spec/javascripts/app/webauthn_spec.js b/spec/javascripts/app/webauthn_spec.js index 090a621ad5f..1324995ff08 100644 --- a/spec/javascripts/app/webauthn_spec.js +++ b/spec/javascripts/app/webauthn_spec.js @@ -1,5 +1,5 @@ import { TextEncoder } from 'util'; -import { useSandbox } from '../support/sinon'; +import { useSandbox } from '@18f/identity-test-helpers'; import * as WebAuthn from '../../../app/javascript/app/webauthn'; describe('WebAuthn', () => { diff --git a/spec/javascripts/packages/analytics/index-spec.js b/spec/javascripts/packages/analytics/index-spec.js index 8649be1be87..ff7e9ead970 100644 --- a/spec/javascripts/packages/analytics/index-spec.js +++ b/spec/javascripts/packages/analytics/index-spec.js @@ -1,5 +1,5 @@ import { trackEvent } from '@18f/identity-analytics'; -import { useSandbox } from '../../support/sinon'; +import { useSandbox } from '@18f/identity-test-helpers'; describe('trackEvent', () => { const sandbox = useSandbox(); diff --git a/spec/javascripts/packages/document-capture-polling/index-spec.js b/spec/javascripts/packages/document-capture-polling/index-spec.js index 3a0a0a2c3d6..293e7b0f135 100644 --- a/spec/javascripts/packages/document-capture-polling/index-spec.js +++ b/spec/javascripts/packages/document-capture-polling/index-spec.js @@ -5,7 +5,7 @@ import { MAX_DOC_CAPTURE_POLL_ATTEMPTS, DOC_CAPTURE_POLL_INTERVAL, } from '@18f/identity-document-capture-polling'; -import { useSandbox } from '../../support/sinon'; +import { useSandbox } from '@18f/identity-test-helpers'; describe('DocumentCapturePolling', () => { const sandbox = useSandbox({ useFakeTimers: true }); diff --git a/spec/javascripts/packages/document-capture/components/document-capture-spec.jsx b/spec/javascripts/packages/document-capture/components/document-capture-spec.jsx index 01e838fafc3..30d56a94248 100644 --- a/spec/javascripts/packages/document-capture/components/document-capture-spec.jsx +++ b/spec/javascripts/packages/document-capture/components/document-capture-spec.jsx @@ -16,8 +16,8 @@ import DocumentCapture, { except, } from '@18f/identity-document-capture/components/document-capture'; import { expect } from 'chai'; +import { useSandbox } from '@18f/identity-test-helpers'; import { render, useAcuant, useDocumentCaptureForm } from '../../../support/document-capture'; -import { useSandbox } from '../../../support/sinon'; import { getFixture, getFixtureFile } from '../../../support/file'; describe('document-capture/components/document-capture', () => { diff --git a/spec/javascripts/packages/document-capture/components/review-issues-step-spec.jsx b/spec/javascripts/packages/document-capture/components/review-issues-step-spec.jsx index d3792abc2e4..51c24ffc59d 100644 --- a/spec/javascripts/packages/document-capture/components/review-issues-step-spec.jsx +++ b/spec/javascripts/packages/document-capture/components/review-issues-step-spec.jsx @@ -7,8 +7,8 @@ import { } from '@18f/identity-document-capture'; import ReviewIssuesStep from '@18f/identity-document-capture/components/review-issues-step'; import { toFormEntryError } from '@18f/identity-document-capture/services/upload'; +import { useSandbox } from '@18f/identity-test-helpers'; import { render } from '../../../support/document-capture'; -import { useSandbox } from '../../../support/sinon'; import { getFixtureFile } from '../../../support/file'; describe('document-capture/components/review-issues-step', () => { diff --git a/spec/javascripts/packages/document-capture/components/selfie-capture-spec.jsx b/spec/javascripts/packages/document-capture/components/selfie-capture-spec.jsx index da9409a20b9..032c0b2810d 100644 --- a/spec/javascripts/packages/document-capture/components/selfie-capture-spec.jsx +++ b/spec/javascripts/packages/document-capture/components/selfie-capture-spec.jsx @@ -4,8 +4,8 @@ import { cleanup } from '@testing-library/react'; import { I18nContext } from '@18f/identity-react-i18n'; import { I18n } from '@18f/identity-i18n'; import SelfieCapture from '@18f/identity-document-capture/components/selfie-capture'; +import { useSandbox } from '@18f/identity-test-helpers'; import { render } from '../../../support/document-capture'; -import { useSandbox } from '../../../support/sinon'; import { getFixtureFile } from '../../../support/file'; describe('document-capture/components/selfie-capture', () => { diff --git a/spec/javascripts/packages/document-capture/components/submission-complete-spec.jsx b/spec/javascripts/packages/document-capture/components/submission-complete-spec.jsx index 70a5a75db8a..fe4161b7c78 100644 --- a/spec/javascripts/packages/document-capture/components/submission-complete-spec.jsx +++ b/spec/javascripts/packages/document-capture/components/submission-complete-spec.jsx @@ -5,8 +5,8 @@ import SubmissionComplete, { RetrySubmissionError, } from '@18f/identity-document-capture/components/submission-complete'; import SuspenseErrorBoundary from '@18f/identity-document-capture/components/suspense-error-boundary'; +import { useSandbox } from '@18f/identity-test-helpers'; import { render, useDocumentCaptureForm } from '../../../support/document-capture'; -import { useSandbox } from '../../../support/sinon'; describe('document-capture/components/submission-complete-step', () => { const onSubmit = useDocumentCaptureForm(); diff --git a/spec/javascripts/packages/document-capture/context/upload-spec.jsx b/spec/javascripts/packages/document-capture/context/upload-spec.jsx index 75ccdfa12cd..5d2ba0bb948 100644 --- a/spec/javascripts/packages/document-capture/context/upload-spec.jsx +++ b/spec/javascripts/packages/document-capture/context/upload-spec.jsx @@ -4,7 +4,7 @@ import UploadContext, { Provider as UploadContextProvider, } from '@18f/identity-document-capture/context/upload'; import defaultUpload from '@18f/identity-document-capture/services/upload'; -import { useSandbox } from '../../../support/sinon'; +import { useSandbox } from '@18f/identity-test-helpers'; describe('document-capture/context/upload', () => { const sandbox = useSandbox(); diff --git a/spec/javascripts/packages/document-capture/higher-order/with-background-encrypted-upload-spec.jsx b/spec/javascripts/packages/document-capture/higher-order/with-background-encrypted-upload-spec.jsx index fbd3f0c2157..bf8cb91ce8b 100644 --- a/spec/javascripts/packages/document-capture/higher-order/with-background-encrypted-upload-spec.jsx +++ b/spec/javascripts/packages/document-capture/higher-order/with-background-encrypted-upload-spec.jsx @@ -6,7 +6,7 @@ import withBackgroundEncryptedUpload, { blobToArrayBuffer, encrypt, } from '@18f/identity-document-capture/higher-order/with-background-encrypted-upload'; -import { useSandbox } from '../../../support/sinon'; +import { useSandbox } from '@18f/identity-test-helpers'; import { render } from '../../../support/document-capture'; /** diff --git a/spec/javascripts/packages/document-capture/services/upload-spec.js b/spec/javascripts/packages/document-capture/services/upload-spec.js index 1a22f1114f1..2c3dd749523 100644 --- a/spec/javascripts/packages/document-capture/services/upload-spec.js +++ b/spec/javascripts/packages/document-capture/services/upload-spec.js @@ -4,7 +4,7 @@ import upload, { toFormData, toFormEntryError, } from '@18f/identity-document-capture/services/upload'; -import { useSandbox } from '../../../support/sinon'; +import { useSandbox } from '@18f/identity-test-helpers'; describe('document-capture/services/upload', () => { const sandbox = useSandbox(); diff --git a/spec/javascripts/packages/one-time-code-input/index-spec.js b/spec/javascripts/packages/one-time-code-input/index-spec.js index 14581fbc623..f7f15769fe2 100644 --- a/spec/javascripts/packages/one-time-code-input/index-spec.js +++ b/spec/javascripts/packages/one-time-code-input/index-spec.js @@ -2,7 +2,7 @@ import OneTimeCodeInput from '@18f/identity-one-time-code-input'; import { waitFor, screen } from '@testing-library/dom'; import userEvent from '@testing-library/user-event'; import { expect } from 'chai'; -import { useSandbox } from '../../support/sinon'; +import { useSandbox } from '@18f/identity-test-helpers'; describe('OneTimeCodeInput', () => { const sandbox = useSandbox(); diff --git a/spec/javascripts/packs/form-steps-wait-spec.js b/spec/javascripts/packs/form-steps-wait-spec.js index 0175a857274..f04ccaf297b 100644 --- a/spec/javascripts/packs/form-steps-wait-spec.js +++ b/spec/javascripts/packs/form-steps-wait-spec.js @@ -1,6 +1,5 @@ import { fireEvent, findByRole } from '@testing-library/dom'; -import { useDefineProperty } from '@18f/identity-test-helpers'; -import { useSandbox } from '../support/sinon'; +import { useDefineProperty, useSandbox } from '@18f/identity-test-helpers'; import { FormStepsWait, getDOMFromHTML, diff --git a/spec/javascripts/packs/webauthn-setup-spec.js b/spec/javascripts/packs/webauthn-setup-spec.js index 3ed12e62b7d..ef39f1bdc83 100644 --- a/spec/javascripts/packs/webauthn-setup-spec.js +++ b/spec/javascripts/packs/webauthn-setup-spec.js @@ -1,5 +1,4 @@ -import { useDefineProperty } from '@18f/identity-test-helpers'; -import { useSandbox } from '../support/sinon'; +import { useDefineProperty, useSandbox } from '@18f/identity-test-helpers'; import { reloadWithError } from '../../../app/javascript/packs/webauthn-setup'; describe('webauthn-setup', () => { diff --git a/spec/javascripts/packs/webauthn-unhide-spec.js b/spec/javascripts/packs/webauthn-unhide-spec.js index aec71ed128b..063c4005a1e 100644 --- a/spec/javascripts/packs/webauthn-unhide-spec.js +++ b/spec/javascripts/packs/webauthn-unhide-spec.js @@ -1,6 +1,5 @@ import { screen } from '@testing-library/dom'; -import { useDefineProperty } from '@18f/identity-test-helpers'; -import { useSandbox } from '../support/sinon'; +import { useDefineProperty, useSandbox } from '@18f/identity-test-helpers'; import { unhideWebauthn } from '../../../app/javascript/packs/webauthn-unhide'; describe('webauthn-unhide', () => { diff --git a/spec/javascripts/support/sinon.js b/spec/javascripts/support/sinon.js index 904aa30d17a..142103f6bb3 100644 --- a/spec/javascripts/support/sinon.js +++ b/spec/javascripts/support/sinon.js @@ -1,35 +1,3 @@ -import sinon from 'sinon'; - -/** - * Returns an instance of a Sinon sandbox, and automatically restores all stubbed methods after each - * test case. - * - * @param {sinon.SinonSandboxConfig=} config - */ -export function useSandbox(config) { - const { useFakeTimers = false, ...remainingConfig } = config ?? {}; - const sandbox = sinon.createSandbox(remainingConfig); - - beforeEach(() => { - // useFakeTimers overrides global timer functions as soon as sandbox is created, thus leaking - // across tests. Instead, wait until tests start to initialize. - if (useFakeTimers) { - sandbox.useFakeTimers(); - } - }); - - afterEach(() => { - sandbox.reset(); - sandbox.restore(); - - if (useFakeTimers) { - sandbox.clock.restore(); - } - }); - - return sandbox; -} - /** * Chai plugin which allows a combination of `calledWith` and `eventually` to expect an eventual * spy (stub) call. From fe2142bdbdb881a55a044bdd998f660b4fc747cb Mon Sep 17 00:00:00 2001 From: Mitchell Henke Date: Wed, 4 May 2022 12:42:29 -0500 Subject: [PATCH 06/29] fix db schema (#6304) [skip changelog] --- db/schema.rb | 5 ----- 1 file changed, 5 deletions(-) diff --git a/db/schema.rb b/db/schema.rb index afa061e2de7..cb721d721a2 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -577,11 +577,6 @@ t.string "reset_password_token", limit: 255 t.datetime "reset_password_sent_at" t.datetime "remember_created_at" - t.integer "sign_in_count", default: 0, null: false - t.datetime "current_sign_in_at" - t.datetime "last_sign_in_at" - t.string "current_sign_in_ip", limit: 255 - t.string "last_sign_in_ip", limit: 255 t.datetime "created_at" t.datetime "updated_at" t.string "confirmation_token", limit: 255 From 4d6a4f194302d6a14a7d115309c8e740dc79d4fc Mon Sep 17 00:00:00 2001 From: Alex Bradley Date: Wed, 4 May 2022 15:23:04 -0400 Subject: [PATCH 07/29] LG-5700 Fix replicated content for the "Select your authentication method" page (#6261) Separate Voice and SMS option text changelog: Improvements, Content, Separate phone and sms text labels * add option to not show sms voice if phone option is available * remove voice and sms options from options_presenter --- .../phone_selection_presenter.rb | 21 +++++++------------ .../selection_presenter.rb | 4 ---- .../sms_selection_presenter.rb | 11 ++++++++++ .../voice_selection_presenter.rb | 11 ++++++++++ .../locales/two_factor_authentication/en.yml | 2 -- .../locales/two_factor_authentication/es.yml | 2 -- .../locales/two_factor_authentication/fr.yml | 2 -- .../phone_selection_presenter_spec.rb | 8 ------- .../sms_selection_presenter_spec.rb | 10 +++++++++ .../voice_selection_presenter_spec.rb | 10 +++++++++ 10 files changed, 49 insertions(+), 32 deletions(-) diff --git a/app/presenters/two_factor_authentication/phone_selection_presenter.rb b/app/presenters/two_factor_authentication/phone_selection_presenter.rb index 064a8c83952..102540443e1 100644 --- a/app/presenters/two_factor_authentication/phone_selection_presenter.rb +++ b/app/presenters/two_factor_authentication/phone_selection_presenter.rb @@ -13,21 +13,14 @@ def type end def info - if configuration.present? - t( - 'two_factor_authentication.login_options.phone_info_html', - phone: configuration.masked_phone, - ) - else - voip_note = if IdentityConfig.store.voip_block - t('two_factor_authentication.two_factor_choice_options.phone_info_no_voip') - end - - safe_join( - [t('two_factor_authentication.two_factor_choice_options.phone_info'), *voip_note], - ' ', - ) + voip_note = if IdentityConfig.store.voip_block + t('two_factor_authentication.two_factor_choice_options.phone_info_no_voip') end + + safe_join( + [t('two_factor_authentication.two_factor_choice_options.phone_info'), *voip_note], + ' ', + ) end def security_level diff --git a/app/presenters/two_factor_authentication/selection_presenter.rb b/app/presenters/two_factor_authentication/selection_presenter.rb index fa21d2913fb..137f9ccf734 100644 --- a/app/presenters/two_factor_authentication/selection_presenter.rb +++ b/app/presenters/two_factor_authentication/selection_presenter.rb @@ -96,10 +96,6 @@ def login_info(type) t('two_factor_authentication.login_options.personal_key_info') when 'piv_cac' t('two_factor_authentication.login_options.piv_cac_info') - when 'sms' - t('two_factor_authentication.login_options.sms_info_html') - when 'voice' - t('two_factor_authentication.login_options.voice_info_html') when 'webauthn' t('two_factor_authentication.login_options.webauthn_info') when 'webauthn_platform' diff --git a/app/presenters/two_factor_authentication/sms_selection_presenter.rb b/app/presenters/two_factor_authentication/sms_selection_presenter.rb index 3b0c122c0f7..081efd9d847 100644 --- a/app/presenters/two_factor_authentication/sms_selection_presenter.rb +++ b/app/presenters/two_factor_authentication/sms_selection_presenter.rb @@ -4,6 +4,17 @@ def method :sms end + def info + if configuration.present? + t( + 'two_factor_authentication.login_options.sms_info_html', + phone: configuration.masked_phone, + ) + else + super + end + end + def disabled? VendorStatus.new.vendor_outage?(:sms) end diff --git a/app/presenters/two_factor_authentication/voice_selection_presenter.rb b/app/presenters/two_factor_authentication/voice_selection_presenter.rb index 6144f27f023..b2e4a061785 100644 --- a/app/presenters/two_factor_authentication/voice_selection_presenter.rb +++ b/app/presenters/two_factor_authentication/voice_selection_presenter.rb @@ -4,6 +4,17 @@ def method :voice end + def info + if configuration.present? + t( + 'two_factor_authentication.login_options.voice_info_html', + phone: configuration.masked_phone, + ) + else + super + end + end + def disabled? VendorStatus.new.vendor_outage?(:voice) end diff --git a/config/locales/two_factor_authentication/en.yml b/config/locales/two_factor_authentication/en.yml index f936b0ea7f6..1281a2f41d4 100644 --- a/config/locales/two_factor_authentication/en.yml +++ b/config/locales/two_factor_authentication/en.yml @@ -35,8 +35,6 @@ en: backup_code_info: Use a backup code from your list of backup codes to sign in. personal_key: Personal Key personal_key_info: Use the 16 character personal key you received at account creation. - phone_info_html: Get security code via text/SMS or phone call to - %{phone}. piv_cac: Government employee ID piv_cac_info: Use your PIV/CAC card instead of a security code. sms: Text message diff --git a/config/locales/two_factor_authentication/es.yml b/config/locales/two_factor_authentication/es.yml index f588ca274d5..1b732f24b5a 100644 --- a/config/locales/two_factor_authentication/es.yml +++ b/config/locales/two_factor_authentication/es.yml @@ -37,8 +37,6 @@ es: personal_key: Clave personal personal_key_info: Use la clave personal de 16 caracteres que usó en la creación de la cuenta. - phone_info_html: Obtenga su código de seguridad a través de mensajes de texto / - SMS o de una llamada telefónica a %{phone}. piv_cac: Empleados del Gobierno piv_cac_info: Use su tarjeta PIV / CAC para asegurar su cuenta. sms: Mensaje de texto / SMS diff --git a/config/locales/two_factor_authentication/fr.yml b/config/locales/two_factor_authentication/fr.yml index ae1ccda9495..0426c5618aa 100644 --- a/config/locales/two_factor_authentication/fr.yml +++ b/config/locales/two_factor_authentication/fr.yml @@ -39,8 +39,6 @@ fr: personal_key: Clé personnelle personal_key_info: Utilisez la clé personnelle de 16 caractères que vous avez utilisée lors de la création du compte. - phone_info_html: Obtenez votre code de sécurité par SMS ou Obtenez votre code de - sécurité par SMS à %{phone}. piv_cac: Employés du gouvernement piv_cac_info: Utilisez votre carte PIV / CAC pour sécuriser votre compte. sms: SMS diff --git a/spec/presenters/two_factor_authentication/phone_selection_presenter_spec.rb b/spec/presenters/two_factor_authentication/phone_selection_presenter_spec.rb index 490c2e8c4ab..b1232fd892e 100644 --- a/spec/presenters/two_factor_authentication/phone_selection_presenter_spec.rb +++ b/spec/presenters/two_factor_authentication/phone_selection_presenter_spec.rb @@ -4,14 +4,6 @@ let(:presenter) { described_class.new(phone) } describe '#info' do - context 'when a user has a phone configuration' do - let(:phone) { build(:phone_configuration, phone: '+1 888 867-5309') } - - it 'includes the masked the number' do - expect(presenter.info).to include('(***) ***-5309') - end - end - context 'when a user does not have a phone configuration (first time)' do let(:phone) { nil } diff --git a/spec/presenters/two_factor_authentication/sms_selection_presenter_spec.rb b/spec/presenters/two_factor_authentication/sms_selection_presenter_spec.rb index da605f2da91..e88accba671 100644 --- a/spec/presenters/two_factor_authentication/sms_selection_presenter_spec.rb +++ b/spec/presenters/two_factor_authentication/sms_selection_presenter_spec.rb @@ -27,6 +27,16 @@ end end + describe '#info' do + context 'when a user has a phone configuration' do + let(:phone) { build(:phone_configuration, phone: '+1 888 867-5309') } + + it 'includes the masked the number' do + expect(subject.info).to include('(***) ***-5309') + end + end + end + describe '#disabled?' do let(:phone) { build(:phone_configuration, phone: '+1 888 867-5309') } diff --git a/spec/presenters/two_factor_authentication/voice_selection_presenter_spec.rb b/spec/presenters/two_factor_authentication/voice_selection_presenter_spec.rb index 2d484d95635..da9b47cbcd4 100644 --- a/spec/presenters/two_factor_authentication/voice_selection_presenter_spec.rb +++ b/spec/presenters/two_factor_authentication/voice_selection_presenter_spec.rb @@ -27,6 +27,16 @@ end end + describe '#info' do + context 'when a user has a phone configuration' do + let(:phone) { build(:phone_configuration, phone: '+1 888 867-5309') } + + it 'includes the masked the number' do + expect(subject.info).to include('(***) ***-5309') + end + end + end + describe '#disabled?' do let(:phone) { build(:phone_configuration, phone: '+1 888 867-5309') } From 4369ca1727b2e24377bcb3e87c7f559952133517 Mon Sep 17 00:00:00 2001 From: Zach Margolis Date: Wed, 4 May 2022 14:56:51 -0700 Subject: [PATCH 08/29] Migrate analytics events, batch 14 (LG-5932) (#6308) * Migrate IDV_PHONE_CONFIRMATION_OTP_RATE_LIMIT_ATTEMPTS * Migrate IDV_PHONE_CONFIRMATION_OTP_RATE_LIMIT_LOCKED_OUT * Migrate IDV_PHONE_CONFIRMATION_OTP_RATE_LIMIT_SENDS * Migrate IDV_PHONE_CONFIRMATION_OTP_RESENT * Migrate IDV_PHONE_CONFIRMATION_OTP_SENT changelog: Internal, Documentation, Document additional analytics events --- .../concerns/idv/phone_otp_rate_limitable.rb | 6 +- .../idv/otp_delivery_method_controller.rb | 2 +- app/controllers/idv/resend_otp_controller.rb | 2 +- app/services/analytics.rb | 5 -- app/services/analytics_events.rb | 79 +++++++++++++++++++ .../otp_delivery_method_controller_spec.rb | 2 +- .../idv/resend_otp_controller_spec.rb | 4 +- 7 files changed, 87 insertions(+), 13 deletions(-) diff --git a/app/controllers/concerns/idv/phone_otp_rate_limitable.rb b/app/controllers/concerns/idv/phone_otp_rate_limitable.rb index 69965896c25..5fcbd7fbbc9 100644 --- a/app/controllers/concerns/idv/phone_otp_rate_limitable.rb +++ b/app/controllers/concerns/idv/phone_otp_rate_limitable.rb @@ -10,7 +10,7 @@ module PhoneOtpRateLimitable def handle_locked_out_user reset_attempt_count_if_user_no_longer_locked_out return unless decorated_user.locked_out? - analytics.track_event(Analytics::IDV_PHONE_CONFIRMATION_OTP_RATE_LIMIT_LOCKED_OUT) + analytics.idv_phone_confirmation_otp_rate_limit_locked_out handle_too_many_otp_attempts false end @@ -28,12 +28,12 @@ def reset_attempt_count_if_user_no_longer_locked_out end def handle_too_many_otp_sends - analytics.track_event(Analytics::IDV_PHONE_CONFIRMATION_OTP_RATE_LIMIT_SENDS) + analytics.idv_phone_confirmation_otp_rate_limit_sends handle_max_attempts('otp_requests') end def handle_too_many_otp_attempts - analytics.track_event(Analytics::IDV_PHONE_CONFIRMATION_OTP_RATE_LIMIT_ATTEMPTS) + analytics.idv_phone_confirmation_otp_rate_limit_attempts handle_max_attempts('otp_login_attempts') end diff --git a/app/controllers/idv/otp_delivery_method_controller.rb b/app/controllers/idv/otp_delivery_method_controller.rb index ec19f9dae32..d55123e566a 100644 --- a/app/controllers/idv/otp_delivery_method_controller.rb +++ b/app/controllers/idv/otp_delivery_method_controller.rb @@ -48,7 +48,7 @@ def render_new_with_error_message def send_phone_confirmation_otp_and_handle_result save_delivery_preference result = send_phone_confirmation_otp - analytics.track_event(Analytics::IDV_PHONE_CONFIRMATION_OTP_SENT, result.to_h) + analytics.idv_phone_confirmation_otp_sent(**result.to_h) if result.success? redirect_to idv_otp_verification_url else diff --git a/app/controllers/idv/resend_otp_controller.rb b/app/controllers/idv/resend_otp_controller.rb index f6e85139b54..917f12d3a2b 100644 --- a/app/controllers/idv/resend_otp_controller.rb +++ b/app/controllers/idv/resend_otp_controller.rb @@ -10,7 +10,7 @@ class ResendOtpController < ApplicationController def create result = send_phone_confirmation_otp - analytics.track_event(Analytics::IDV_PHONE_CONFIRMATION_OTP_RESENT, result.to_h) + analytics.idv_phone_confirmation_otp_resent(**result.to_h) if result.success? redirect_to idv_otp_verification_url else diff --git a/app/services/analytics.rb b/app/services/analytics.rb index 91e68dbd22b..c96855ace09 100644 --- a/app/services/analytics.rb +++ b/app/services/analytics.rb @@ -148,11 +148,6 @@ def session_started_at IDV_INTRO_VISIT = 'IdV: intro visited' IDV_JURISDICTION_VISIT = 'IdV: jurisdiction visited' IDV_JURISDICTION_FORM = 'IdV: jurisdiction form submitted' - IDV_PHONE_CONFIRMATION_OTP_RATE_LIMIT_ATTEMPTS = 'Idv: Phone OTP attempts rate limited' - IDV_PHONE_CONFIRMATION_OTP_RATE_LIMIT_LOCKED_OUT = 'Idv: Phone OTP rate limited user' - IDV_PHONE_CONFIRMATION_OTP_RATE_LIMIT_SENDS = 'Idv: Phone OTP sends rate limited' - IDV_PHONE_CONFIRMATION_OTP_RESENT = 'IdV: phone confirmation otp resent' - IDV_PHONE_CONFIRMATION_OTP_SENT = 'IdV: phone confirmation otp sent' IDV_PHONE_OTP_DELIVERY_SELECTION_VISIT = 'IdV: Phone OTP delivery Selection Visited' IDV_PHONE_USE_DIFFERENT = 'IdV: use different phone number' IDV_PHONE_RECORD_VISIT = 'IdV: phone of record visited' diff --git a/app/services/analytics_events.rb b/app/services/analytics_events.rb index a6ca71ee53b..e4c4b516f37 100644 --- a/app/services/analytics_events.rb +++ b/app/services/analytics_events.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # rubocop:disable Metrics/ModuleLength module AnalyticsEvents # @identity.idp.previous_event_name Account Reset @@ -509,6 +511,83 @@ def idv_phone_confirmation_form_submitted( ) end + # The user was rate limited for submitting too many OTPs during the IDV phone step + def idv_phone_confirmation_otp_rate_limit_attempts + track_event('Idv: Phone OTP attempts rate limited') + end + + # The user was locked out for hitting the phone OTP rate limit during IDV + def idv_phone_confirmation_otp_rate_limit_locked_out + track_event('Idv: Phone OTP rate limited user') + end + + # The user was rate limited for requesting too many OTPs during the IDV phone step + def idv_phone_confirmation_otp_rate_limit_sends + track_event('Idv: Phone OTP sends rate limited') + end + + # @param [Boolean] success + # @param [Hash] errors + # @param ["sms","voice"] otp_delivery_preference which chaennel the OTP was delivered by + # @param [String] country_code country code of phone number + # @param [String] area_code area code of phone number + # @param [Boolean] rate_limit_exceeded whether or not the rate limit was exceeded by this attempt + # @param [Hash] telephony_response response from Telephony gem + # The user resent an OTP during the IDV phone step + def idv_phone_confirmation_otp_resent( + success:, + errors:, + otp_delivery_preference:, + country_code:, + area_code:, + rate_limit_exceeded:, + telephony_response:, + **extra + ) + track_event( + 'IdV: phone confirmation otp resent', + success: success, + errors: errors, + otp_delivery_preference: otp_delivery_preference, + country_code: country_code, + area_code: area_code, + rate_limit_exceeded: rate_limit_exceeded, + telephony_response: telephony_response, + **extra, + ) + end + + # @param [Boolean] success + # @param [Hash] errors + # @param ["sms","voice"] otp_delivery_preference which chaennel the OTP was delivered by + # @param [String] country_code country code of phone number + # @param [String] area_code area code of phone number + # @param [Boolean] rate_limit_exceeded whether or not the rate limit was exceeded by this attempt + # @param [Hash] telephony_response response from Telephony gem + # The user requested an OTP to confirm their phone during the IDV phone step + def idv_phone_confirmation_otp_sent( + success:, + errors:, + otp_delivery_preference:, + country_code:, + area_code:, + rate_limit_exceeded:, + telephony_response:, + **extra + ) + track_event( + 'IdV: phone confirmation otp sent', + success: success, + errors: errors, + otp_delivery_preference: otp_delivery_preference, + country_code: country_code, + area_code: area_code, + rate_limit_exceeded: rate_limit_exceeded, + telephony_response: telephony_response, + **extra, + ) + end + # @param [Boolean] success # @param [Hash] errors # The vendor finished the process of confirming the users phone diff --git a/spec/controllers/idv/otp_delivery_method_controller_spec.rb b/spec/controllers/idv/otp_delivery_method_controller_spec.rb index d759f5c2d97..51253c2ed35 100644 --- a/spec/controllers/idv/otp_delivery_method_controller_spec.rb +++ b/spec/controllers/idv/otp_delivery_method_controller_spec.rb @@ -215,7 +215,7 @@ 'IdV: Phone OTP Delivery Selection Submitted', hash_including(success: true) ) expect(@analytics).to receive(:track_event).ordered.with( - Analytics::IDV_PHONE_CONFIRMATION_OTP_SENT, + 'IdV: phone confirmation otp sent', hash_including( success: false, telephony_response: telephony_response, diff --git a/spec/controllers/idv/resend_otp_controller_spec.rb b/spec/controllers/idv/resend_otp_controller_spec.rb index a7b78443955..ea6eada9999 100644 --- a/spec/controllers/idv/resend_otp_controller_spec.rb +++ b/spec/controllers/idv/resend_otp_controller_spec.rb @@ -63,7 +63,7 @@ } expect(@analytics).to have_received(:track_event).with( - Analytics::IDV_PHONE_CONFIRMATION_OTP_RESENT, + 'IdV: phone confirmation otp resent', expected_result, ) end @@ -95,7 +95,7 @@ it 'tracks an analytics events' do expect(@analytics).to receive(:track_event).ordered.with( - Analytics::IDV_PHONE_CONFIRMATION_OTP_RESENT, + 'IdV: phone confirmation otp resent', hash_including( success: false, telephony_response: telephony_response, From 8a5f85e93d7ccbb5667ce26257e78efd7c67fe2d Mon Sep 17 00:00:00 2001 From: Mitchell Henke Date: Wed, 4 May 2022 17:34:47 -0500 Subject: [PATCH 09/29] Fix 500 when resending GPO letter (#6309) * add failing spec * Request password if PII is unlocked when resending GPO letter changelog: Bug Fixes, Identity Verification, Request password to unlock PII if it is locked before resending GPO letter --- app/controllers/idv/gpo_controller.rb | 8 +++++++- spec/controllers/idv/gpo_controller_spec.rb | 12 ++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/app/controllers/idv/gpo_controller.rb b/app/controllers/idv/gpo_controller.rb index ef23b2b2cc5..eb20b2b5079 100644 --- a/app/controllers/idv/gpo_controller.rb +++ b/app/controllers/idv/gpo_controller.rb @@ -32,7 +32,9 @@ def create update_tracking idv_session.address_verification_mechanism = :gpo - if current_user.decorate.pending_profile_requires_verification? + if current_user.decorate.pending_profile_requires_verification? && pii_locked? + redirect_to capture_password_url + elsif current_user.decorate.pending_profile_requires_verification? resend_letter redirect_to idv_come_back_later_url else @@ -256,5 +258,9 @@ def missing delete_async ProofingSessionAsyncResult.missing end + + def pii_locked? + !Pii::Cacher.new(current_user, user_session).exists_in_session? + end end end diff --git a/spec/controllers/idv/gpo_controller_spec.rb b/spec/controllers/idv/gpo_controller_spec.rb index 85d9d714b34..74cfe38ce1e 100644 --- a/spec/controllers/idv/gpo_controller_spec.rb +++ b/spec/controllers/idv/gpo_controller_spec.rb @@ -123,6 +123,17 @@ allow(FeatureManagement).to receive(:reveal_gpo_code?).and_return(true) expect_resend_letter_to_send_letter_and_redirect(otp: true) end + + it 'redirects to capture password if pii is locked' do + pii_cacher = instance_double(Pii::Cacher) + allow(pii_cacher).to receive(:fetch).and_return(nil) + allow(pii_cacher).to receive(:exists_in_session?).and_return(false) + allow(Pii::Cacher).to receive(:new).and_return(pii_cacher) + + put :create + + expect(response).to redirect_to capture_password_path + end end end @@ -130,6 +141,7 @@ def expect_resend_letter_to_send_letter_and_redirect(otp:) pii = { first_name: 'Samuel', last_name: 'Sampson' } pii_cacher = instance_double(Pii::Cacher) allow(pii_cacher).to receive(:fetch).and_return(pii) + allow(pii_cacher).to receive(:exists_in_session?).and_return(true) allow(Pii::Cacher).to receive(:new).and_return(pii_cacher) service_provider = create(:service_provider, issuer: '123abc') From e38b3c0d2f5617d3a008c2849a5506553b2bcb7b Mon Sep 17 00:00:00 2001 From: Andrew Duthie Date: Thu, 5 May 2022 08:18:31 -0400 Subject: [PATCH 10/29] Implement client session secret store (#6183) * Implement client session secret store **Why**: As a demonstration of secure client-side storage decrypted with key provided by server per session. changelog: Upcoming Features, Identity Proofing, Add client-side encrypted storage * Collapse readStorage try blocks * Clarify AES cipher key/iv generation To avoid magic number and make it clearer what's happening https://github.com/18F/identity-idp/pull/6183#discussion_r847730632 * Simplify cipher assignment logic * Refactor SecretsContextProvider as observable initializer - Avoid waiting to render the app - Manage subscribers automatically via context value change * Rename encode as s2ab Consistency https://github.com/18F/identity-idp/pull/6183#discussion_r853450056 * iv per encrypt https://github.com/18F/identity-idp/pull/6183#discussion_r855263044 * Make setItem await-able * Reference crypto consistently from window object * Add SecretSessionStorage inline comment docs * Add SecretSessionStorage specs * Merge useSecretValue to context implementation * Remove demo value from SecretValues * Add docs for SecretsContext * Use flow values as secrets * Split VerifyFlow from index Avoid dependency cycle, make room for more index-exported * Destructure storeKey in same way as other data attributes * Use user_session instead of session Route now authenticated * Inline encryption cipher initialization to memoized session assignment See: https://github.com/18F/identity-idp/pull/6183/files#r865355166 Co-Authored-By: Zach Margolis Co-authored-by: Zach Margolis --- app/controllers/verify_controller.rb | 7 ++ .../secret-session-storage/index.spec.ts | 81 ++++++++++++++ .../packages/secret-session-storage/index.ts | 104 +++++++++++++++++ .../secret-session-storage/package.json | 5 + .../verify-flow/context/secrets-context.tsx | 69 ++++++++++++ app/javascript/packages/verify-flow/index.tsx | 105 +----------------- .../steps/personal-key-confirm/index.ts | 2 +- .../personal-key-confirm-step.tsx | 2 +- .../verify-flow/steps/personal-key/index.ts | 2 +- .../steps/personal-key/personal-key-step.tsx | 2 +- .../packages/verify-flow/verify-flow.tsx | 103 +++++++++++++++++ app/javascript/packs/verify-flow.tsx | 27 +++-- app/services/encryption/aes_cipher.rb | 7 +- spec/services/encryption/aes_cipher_spec.rb | 11 ++ 14 files changed, 409 insertions(+), 118 deletions(-) create mode 100644 app/javascript/packages/secret-session-storage/index.spec.ts create mode 100644 app/javascript/packages/secret-session-storage/index.ts create mode 100644 app/javascript/packages/secret-session-storage/package.json create mode 100644 app/javascript/packages/verify-flow/context/secrets-context.tsx create mode 100644 app/javascript/packages/verify-flow/verify-flow.tsx diff --git a/app/controllers/verify_controller.rb b/app/controllers/verify_controller.rb index 194be97f393..6bc019ecc27 100644 --- a/app/controllers/verify_controller.rb +++ b/app/controllers/verify_controller.rb @@ -15,15 +15,22 @@ def show private def app_data + user_session[:idv_api_store_key] ||= Base64.strict_encode64(random_encryption_key) + { base_path: idv_app_root_path, app_name: APP_NAME, completion_url: completion_url, initial_values: { 'personalKey' => personal_key }, enabled_step_names: IdentityConfig.store.idv_api_enabled_steps, + store_key: user_session[:idv_api_store_key], } end + def random_encryption_key + Encryption::AesCipher.encryption_cipher.random_key + end + def confirm_profile_has_been_created redirect_to account_url if idv_session.profile.blank? end diff --git a/app/javascript/packages/secret-session-storage/index.spec.ts b/app/javascript/packages/secret-session-storage/index.spec.ts new file mode 100644 index 00000000000..43093f0c664 --- /dev/null +++ b/app/javascript/packages/secret-session-storage/index.spec.ts @@ -0,0 +1,81 @@ +import sinon from 'sinon'; +import SecretSessionStorage from './index'; + +describe('SecretSessionStorage', () => { + const STORAGE_KEY = 'test'; + + const sandbox = sinon.createSandbox(); + + let key: CryptoKey; + before(async () => { + key = await window.crypto.subtle.generateKey( + { + name: 'AES-GCM', + length: 256, + }, + true, + ['encrypt', 'decrypt'], + ); + }); + + function createStorage() { + const storage = new SecretSessionStorage(STORAGE_KEY); + storage.key = key; + return storage; + } + + afterEach(() => { + sessionStorage.removeItem(STORAGE_KEY); + sandbox.restore(); + }); + + it('writes to session storage', async () => { + sandbox.spy(Storage.prototype, 'setItem'); + + const storage = createStorage(); + await storage.setItem('foo', 'bar'); + + expect(Storage.prototype.setItem).to.have.been.calledWith( + STORAGE_KEY, + sinon.match( + (value: string) => + /^\[".+?",".+?"\]$/.test(value) && !value.includes('foo') && !value.includes('bar'), + ), + ); + }); + + it('loads from previous written storage', async () => { + const storage1 = createStorage(); + await storage1.setItem('foo', 'bar'); + + const storage2 = createStorage(); + await storage2.load(); + + expect(storage2.getItem('foo')).to.equal('bar'); + }); + + it('returns undefined for value not yet loaded from storage', async () => { + const storage1 = createStorage(); + await storage1.setItem('foo', 'bar'); + + const storage2 = createStorage(); + + expect(storage2.getItem('foo')).to.be.undefined(); + }); + + it('returns undefined for value not in loaded storage', async () => { + const storage1 = createStorage(); + await storage1.setItem('foo', 'bar'); + + const storage2 = createStorage(); + await storage2.load(); + + expect(storage2.getItem('baz')).to.be.undefined(); + }); + + it('silently ignores invalid written storage', async () => { + sessionStorage.setItem(STORAGE_KEY, 'nonsense'); + const storage = createStorage(); + await storage.load(); + }); +}); diff --git a/app/javascript/packages/secret-session-storage/index.ts b/app/javascript/packages/secret-session-storage/index.ts new file mode 100644 index 00000000000..453f7a34ff7 --- /dev/null +++ b/app/javascript/packages/secret-session-storage/index.ts @@ -0,0 +1,104 @@ +/** + * Serializable JSON value. + */ +type JSONValue = string | number | boolean | null | JSONValue[] | { [key: string]: JSONValue }; + +/** + * Convert an ArrayBuffer to an equivalent string. + */ +export const ab2s = (buffer: Uint8Array) => String.fromCharCode.apply(null, new Uint8Array(buffer)); + +/** + * Convert a string to an equivalent ArrayBuffer. + */ +export const s2ab = (string: string) => Uint8Array.from(string, (c) => c.charCodeAt(0)); + +class SecretSessionStorage> { + /** + * Web storage key. + */ + storageKey: string; + + /** + * In-memory reflection of unencrypted web storage payload. + */ + storage: S = {} as S; + + /** + * Encryption key. + */ + key: CryptoKey; + + /** + * Constructs a new session store. + * + * @param storageKey Web storage key. + * @param key Encryption key. + */ + constructor(storageKey: string) { + this.storageKey = storageKey; + } + + /** + * Reads and decrypts storage object into in-memory reflection, if available. + */ + async load() { + const storage = await this.#readStorage(); + if (storage) { + this.storage = storage; + } + } + + /** + * Sets a value into storage. + * + * @param key Storage object key. + * @param value Storage object value. + */ + async setItem(key: keyof S, value: S[typeof key]) { + this.storage[key] = value; + await this.#writeStorage(); + } + + /** + * Gets a value from the in-memory storage. + * + * @param key Storage object key. + */ + getItem(key: keyof S) { + return this.storage[key]; + } + + /** + * Reads and decrypts storage object, if available. + */ + async #readStorage() { + try { + const storageData = sessionStorage.getItem(this.storageKey)!; + const [encryptedData, iv] = (JSON.parse(storageData) as [string, string]).map(s2ab); + const data = await window.crypto.subtle.decrypt( + { name: 'AES-GCM', iv }, + this.key, + encryptedData, + ); + + return JSON.parse(ab2s(data)); + } catch {} + } + + /** + * Encrypts and writes current in-memory reflection of storage object to web storage. + */ + async #writeStorage() { + const iv = window.crypto.getRandomValues(new Uint8Array(12)); + const encryptedData = await window.crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + this.key, + s2ab(JSON.stringify(this.storage)), + ); + + sessionStorage.setItem(this.storageKey, JSON.stringify([encryptedData, iv].map(ab2s))); + } +} + +export default SecretSessionStorage; diff --git a/app/javascript/packages/secret-session-storage/package.json b/app/javascript/packages/secret-session-storage/package.json new file mode 100644 index 00000000000..a57a3b1a085 --- /dev/null +++ b/app/javascript/packages/secret-session-storage/package.json @@ -0,0 +1,5 @@ +{ + "name": "@18f/identity-secret-session-storage", + "private": true, + "version": "1.0.0" +} diff --git a/app/javascript/packages/verify-flow/context/secrets-context.tsx b/app/javascript/packages/verify-flow/context/secrets-context.tsx new file mode 100644 index 00000000000..aeae06ca481 --- /dev/null +++ b/app/javascript/packages/verify-flow/context/secrets-context.tsx @@ -0,0 +1,69 @@ +import { createContext, useContext, useEffect, useCallback, useMemo, useState } from 'react'; +import type { ReactNode } from 'react'; +import SecretSessionStorage from '@18f/identity-secret-session-storage'; +import { useIfStillMounted } from '@18f/identity-react-hooks'; +import { VerifyFlowValues } from '../verify-flow'; + +type SecretValues = Partial; + +type SetItem = typeof SecretSessionStorage.prototype.setItem; + +interface SecretsContextProviderProps { + /** + * Encryption key. + */ + storeKey: Uint8Array; + + /** + * Context provider children. + */ + children?: ReactNode; +} + +/** + * Web storage key. + */ +const STORAGE_KEY = 'verify'; + +const SecretsContext = createContext({ + storage: new SecretSessionStorage(STORAGE_KEY), + setItem: (async () => {}) as SetItem, +}); + +export function SecretsContextProvider({ storeKey, children }: SecretsContextProviderProps) { + const ifStillMounted = useIfStillMounted(); + const storage = useMemo(() => new SecretSessionStorage(STORAGE_KEY), []); + const [value, setValue] = useState({ storage, setItem: storage.setItem }); + const onChange = useCallback(() => { + setValue({ + storage, + async setItem(...args) { + await storage.setItem(...args); + onChange(); + }, + }); + }, []); + + useEffect(() => { + crypto.subtle + .importKey('raw', storeKey, 'AES-GCM', true, ['encrypt', 'decrypt']) + .then((cryptoKey) => { + storage.key = cryptoKey; + storage.load().then(ifStillMounted(onChange)); + }); + }, []); + + return {children}; +} + +export function useSecretValue( + key: K, +): [SecretValues[K], (nextValue: SecretValues[K]) => void] { + const { storage, setItem } = useContext(SecretsContext); + + const setValue = (nextValue: SecretValues[K]) => setItem(key, nextValue); + + return [storage.getItem(key), setValue]; +} + +export default SecretsContext; diff --git a/app/javascript/packages/verify-flow/index.tsx b/app/javascript/packages/verify-flow/index.tsx index 6fce4686195..9cb8593ad02 100644 --- a/app/javascript/packages/verify-flow/index.tsx +++ b/app/javascript/packages/verify-flow/index.tsx @@ -1,103 +1,2 @@ -import { useEffect } from 'react'; -import { FormSteps } from '@18f/identity-form-steps'; -import { StepIndicator, StepIndicatorStep, StepStatus } from '@18f/identity-step-indicator'; -import { t } from '@18f/identity-i18n'; -import { Alert } from '@18f/identity-components'; -import { trackEvent } from '@18f/identity-analytics'; -import { STEPS } from './steps'; - -export interface VerifyFlowValues { - personalKey?: string; - - personalKeyConfirm?: string; -} - -interface VerifyFlowProps { - /** - * Initial values for the form, if applicable. - */ - initialValues?: Partial; - - /** - * Names of steps to be included in the flow. - */ - enabledStepNames?: string[]; - - /** - * The path to which the current step is appended to create the current step URL. - */ - basePath?: string; - - /** - * Application name, used in generating page titles for current step. - */ - appName: string; - - /** - * Callback invoked after completing the form. - */ - onComplete: () => void; -} - -/** - * Returns a step name normalized for event logging. - * - * @param stepName Original step name. - * - * @return Step name normalized for event logging. - */ -const getEventStepName = (stepName: string) => stepName.toLowerCase().replace(/[^a-z]/g, ' '); - -/** - * Logs step visited event. - */ -const logStepVisited = (stepName: string) => - trackEvent(`IdV: ${getEventStepName(stepName)} visited`); - -/** - * Logs step submitted event. - */ -const logStepSubmitted = (stepName: string) => - trackEvent(`IdV: ${getEventStepName(stepName)} submitted`); - -export function VerifyFlow({ - initialValues = {}, - enabledStepNames, - basePath, - appName, - onComplete, -}: VerifyFlowProps) { - useEffect(() => { - logStepVisited(STEPS[0].name); - }, []); - - let steps = STEPS; - if (enabledStepNames) { - steps = steps.filter(({ name }) => enabledStepNames.includes(name)); - } - - return ( - <> - - - - - - - - - {t('idv.messages.confirm')} - - - - ); -} +export { SecretsContextProvider } from './context/secrets-context'; +export { VerifyFlow } from './verify-flow'; diff --git a/app/javascript/packages/verify-flow/steps/personal-key-confirm/index.ts b/app/javascript/packages/verify-flow/steps/personal-key-confirm/index.ts index ae9018bdaab..6ae5e247e74 100644 --- a/app/javascript/packages/verify-flow/steps/personal-key-confirm/index.ts +++ b/app/javascript/packages/verify-flow/steps/personal-key-confirm/index.ts @@ -1,6 +1,6 @@ import { t } from '@18f/identity-i18n'; import type { FormStep } from '@18f/identity-form-steps'; -import type { VerifyFlowValues } from '../..'; +import type { VerifyFlowValues } from '../../verify-flow'; import form from './personal-key-confirm-step'; export default { diff --git a/app/javascript/packages/verify-flow/steps/personal-key-confirm/personal-key-confirm-step.tsx b/app/javascript/packages/verify-flow/steps/personal-key-confirm/personal-key-confirm-step.tsx index 1db11d16133..cebcd6b942d 100644 --- a/app/javascript/packages/verify-flow/steps/personal-key-confirm/personal-key-confirm-step.tsx +++ b/app/javascript/packages/verify-flow/steps/personal-key-confirm/personal-key-confirm-step.tsx @@ -7,7 +7,7 @@ import { getAssetPath } from '@18f/identity-assets'; import { trackEvent } from '@18f/identity-analytics'; import PersonalKeyStep from '../personal-key/personal-key-step'; import PersonalKeyInput from './personal-key-input'; -import type { VerifyFlowValues } from '../..'; +import type { VerifyFlowValues } from '../../verify-flow'; interface PersonalKeyConfirmStepProps extends FormStepComponentProps {} diff --git a/app/javascript/packages/verify-flow/steps/personal-key/index.ts b/app/javascript/packages/verify-flow/steps/personal-key/index.ts index 87e56dfb267..73534cda563 100644 --- a/app/javascript/packages/verify-flow/steps/personal-key/index.ts +++ b/app/javascript/packages/verify-flow/steps/personal-key/index.ts @@ -1,6 +1,6 @@ import { t } from '@18f/identity-i18n'; import type { FormStep } from '@18f/identity-form-steps'; -import type { VerifyFlowValues } from '../..'; +import type { VerifyFlowValues } from '../../verify-flow'; import form from './personal-key-step'; export default { diff --git a/app/javascript/packages/verify-flow/steps/personal-key/personal-key-step.tsx b/app/javascript/packages/verify-flow/steps/personal-key/personal-key-step.tsx index d9106c1c25b..567e50c5a74 100644 --- a/app/javascript/packages/verify-flow/steps/personal-key/personal-key-step.tsx +++ b/app/javascript/packages/verify-flow/steps/personal-key/personal-key-step.tsx @@ -7,7 +7,7 @@ import { FormStepsButton } from '@18f/identity-form-steps'; import type { FormStepComponentProps } from '@18f/identity-form-steps'; import { getAssetPath } from '@18f/identity-assets'; import { trackEvent } from '@18f/identity-analytics'; -import type { VerifyFlowValues } from '../..'; +import type { VerifyFlowValues } from '../../verify-flow'; import DownloadButton from './download-button'; interface PersonalKeyStepProps extends FormStepComponentProps {} diff --git a/app/javascript/packages/verify-flow/verify-flow.tsx b/app/javascript/packages/verify-flow/verify-flow.tsx new file mode 100644 index 00000000000..6fce4686195 --- /dev/null +++ b/app/javascript/packages/verify-flow/verify-flow.tsx @@ -0,0 +1,103 @@ +import { useEffect } from 'react'; +import { FormSteps } from '@18f/identity-form-steps'; +import { StepIndicator, StepIndicatorStep, StepStatus } from '@18f/identity-step-indicator'; +import { t } from '@18f/identity-i18n'; +import { Alert } from '@18f/identity-components'; +import { trackEvent } from '@18f/identity-analytics'; +import { STEPS } from './steps'; + +export interface VerifyFlowValues { + personalKey?: string; + + personalKeyConfirm?: string; +} + +interface VerifyFlowProps { + /** + * Initial values for the form, if applicable. + */ + initialValues?: Partial; + + /** + * Names of steps to be included in the flow. + */ + enabledStepNames?: string[]; + + /** + * The path to which the current step is appended to create the current step URL. + */ + basePath?: string; + + /** + * Application name, used in generating page titles for current step. + */ + appName: string; + + /** + * Callback invoked after completing the form. + */ + onComplete: () => void; +} + +/** + * Returns a step name normalized for event logging. + * + * @param stepName Original step name. + * + * @return Step name normalized for event logging. + */ +const getEventStepName = (stepName: string) => stepName.toLowerCase().replace(/[^a-z]/g, ' '); + +/** + * Logs step visited event. + */ +const logStepVisited = (stepName: string) => + trackEvent(`IdV: ${getEventStepName(stepName)} visited`); + +/** + * Logs step submitted event. + */ +const logStepSubmitted = (stepName: string) => + trackEvent(`IdV: ${getEventStepName(stepName)} submitted`); + +export function VerifyFlow({ + initialValues = {}, + enabledStepNames, + basePath, + appName, + onComplete, +}: VerifyFlowProps) { + useEffect(() => { + logStepVisited(STEPS[0].name); + }, []); + + let steps = STEPS; + if (enabledStepNames) { + steps = steps.filter(({ name }) => enabledStepNames.includes(name)); + } + + return ( + <> + + + + + + + + + {t('idv.messages.confirm')} + + + + ); +} diff --git a/app/javascript/packs/verify-flow.tsx b/app/javascript/packs/verify-flow.tsx index 62e768496d0..495aad0f70e 100644 --- a/app/javascript/packs/verify-flow.tsx +++ b/app/javascript/packs/verify-flow.tsx @@ -1,5 +1,6 @@ import { render } from 'react-dom'; -import { VerifyFlow } from '@18f/identity-verify-flow'; +import { VerifyFlow, SecretsContextProvider } from '@18f/identity-verify-flow'; +import { s2ab } from '@18f/identity-secret-session-storage'; interface AppRootValues { /** @@ -26,6 +27,11 @@ interface AppRootValues { * URL to which user should be redirected after completing the form. */ completionUrl: string; + + /** + * Base64-encoded encryption key for secret session store. + */ + storeKey: string; } interface AppRootElement extends HTMLElement { @@ -39,8 +45,9 @@ const { basePath, appName, completionUrl: completionURL, + storeKey: storeKeyBase64, } = appRoot.dataset; - +const storeKey = s2ab(atob(storeKeyBase64)); const initialValues = JSON.parse(initialValuesJSON); const enabledStepNames = JSON.parse(enabledStepNamesJSON) as string[]; @@ -49,12 +56,14 @@ function onComplete() { } render( - , + + + , appRoot, ); diff --git a/app/services/encryption/aes_cipher.rb b/app/services/encryption/aes_cipher.rb index 9022acdfcfa..c020e9d723b 100644 --- a/app/services/encryption/aes_cipher.rb +++ b/app/services/encryption/aes_cipher.rb @@ -3,8 +3,7 @@ class AesCipher include Encodable def encrypt(plaintext, cek) - self.cipher = OpenSSL::Cipher.new 'aes-256-gcm' - cipher.encrypt + self.cipher = self.class.encryption_cipher # The key length for the AES-256-GCM cipher is fixed at 128 bits, or 32 # characters. Starting with Ruby 2.4, an expection is thrown if you try to # set a key longer than 32 characters, which is what we have been doing @@ -20,6 +19,10 @@ def decrypt(payload, cek) decipher(payload) end + def self.encryption_cipher + OpenSSL::Cipher.new('aes-256-gcm').encrypt + end + private attr_accessor :cipher diff --git a/spec/services/encryption/aes_cipher_spec.rb b/spec/services/encryption/aes_cipher_spec.rb index 6290996fcfd..1b9ca5cd330 100644 --- a/spec/services/encryption/aes_cipher_spec.rb +++ b/spec/services/encryption/aes_cipher_spec.rb @@ -28,4 +28,15 @@ expect { subject.decrypt(ciphertext, cek) }.to raise_error Encryption::EncryptionError end end + + describe '.encryption_cipher' do + it 'returns an AES cipher for encryption operation' do + expect_any_instance_of(OpenSSL::Cipher).to receive(:encrypt).and_call_original + + cipher = subject.class.encryption_cipher + + expect(cipher).to be_kind_of(OpenSSL::Cipher) + expect(cipher.name).to eq 'id-aes256-GCM' + end + end end From e7501424b59f887aa12bd255f69de03502969fa0 Mon Sep 17 00:00:00 2001 From: Andrew Duthie Date: Thu, 5 May 2022 10:54:13 -0400 Subject: [PATCH 11/29] Components: Assign Alert role by type (#6312) * TypeScript-ify Alert component * Components: Assign Alert role by type **Why**: - Alignment to AlertComponent Rails ViewComponent implementation - To avoid assertively announcing alert text for non-urgent alerts changelog: Improvements, Accessibility, Use status role for non-urgent alert content --- app/javascript/packages/components/alert.jsx | 35 ------------ .../packages/components/alert.spec.tsx | 56 +++++++++++++++++++ app/javascript/packages/components/alert.tsx | 44 +++++++++++++++ .../packages/components/alert-spec.jsx | 41 -------------- 4 files changed, 100 insertions(+), 76 deletions(-) delete mode 100644 app/javascript/packages/components/alert.jsx create mode 100644 app/javascript/packages/components/alert.spec.tsx create mode 100644 app/javascript/packages/components/alert.tsx delete mode 100644 spec/javascripts/packages/components/alert-spec.jsx diff --git a/app/javascript/packages/components/alert.jsx b/app/javascript/packages/components/alert.jsx deleted file mode 100644 index 1d6279de599..00000000000 --- a/app/javascript/packages/components/alert.jsx +++ /dev/null @@ -1,35 +0,0 @@ -import { forwardRef } from 'react'; - -/** @typedef {import('react').ReactNode} ReactNode */ - -/** - * @typedef {"success"|"warning"|"error"|"info"|"other"} AlertType - */ - -/** - * @typedef AlertProps - * - * @prop {AlertType=} type Alert type. Defaults to "other". - * @prop {string=} className Optional additional class names to add to element. - * @prop {boolean=} isFocusable Optional, whether rendered element should be focusable, as in the - * case where focus should be shifted programmatically to a new alert. - * @prop {ReactNode} children Child elements. - */ - -/** - * @param {AlertProps} props Props object. - * @param {import('react').ForwardedRef} ref - */ -function Alert({ type = 'other', className, isFocusable, children }, ref) { - const classes = [`usa-alert usa-alert--${type}`, className].filter(Boolean).join(' '); - - return ( -
-
-

{children}

-
-
- ); -} - -export default forwardRef(Alert); diff --git a/app/javascript/packages/components/alert.spec.tsx b/app/javascript/packages/components/alert.spec.tsx new file mode 100644 index 00000000000..2d11f4f9698 --- /dev/null +++ b/app/javascript/packages/components/alert.spec.tsx @@ -0,0 +1,56 @@ +import { createRef } from 'react'; +import { render } from '@testing-library/react'; +import Alert from './alert'; +import type { AlertType } from './alert'; + +describe('Alert', () => { + describe('role', () => { + ( + [ + ['success', 'status'], + ['warning', 'status'], + ['error', 'alert'], + ['info', 'status'], + ['other', 'status'], + ] as [AlertType, 'alert' | 'status'][] + ).forEach(([type, role]) => { + context(`with ${type} type`, () => { + it(`should apply ${role} role`, () => { + const { getByRole } = render(); + + const alert = getByRole(role); + + expect(alert).to.be.ok(); + }); + }); + }); + }); + + it('accepts additional class names', () => { + const { getByRole } = render( + + Uh oh! + , + ); + + const alert = getByRole('status'); + + expect(alert.classList.contains('my-class')).to.be.true(); + }); + + it('is optionally focusable', () => { + const { getByRole } = render(); + + const alert = getByRole('status'); + alert.focus(); + + expect(document.activeElement).to.equal(alert); + }); + + it('forwards ref', () => { + const ref = createRef(); + const { container } = render(); + + expect(ref.current).to.equal(container.firstChild); + }); +}); diff --git a/app/javascript/packages/components/alert.tsx b/app/javascript/packages/components/alert.tsx new file mode 100644 index 00000000000..0ea1b2fd612 --- /dev/null +++ b/app/javascript/packages/components/alert.tsx @@ -0,0 +1,44 @@ +import { forwardRef } from 'react'; +import type { ReactNode, ForwardedRef } from 'react'; + +export type AlertType = 'success' | 'warning' | 'error' | 'info' | 'other'; + +interface AlertProps { + /** + * Alert type. Defaults to "other". + */ + type?: AlertType; + + /** + * Optional additional class names to add to element. + */ + className?: string; + + /** + * Optional, whether rendered element should be focusable, as in the case where focus should be shifted programmatically to a new alert. + */ + isFocusable?: boolean; + + /** + * Child elements. + */ + children?: ReactNode; +} + +function Alert( + { type = 'other', className, isFocusable, children }: AlertProps, + ref: ForwardedRef, +) { + const classes = [`usa-alert usa-alert--${type}`, className].filter(Boolean).join(' '); + const role = type === 'error' ? 'alert' : 'status'; + + return ( +
+
+

{children}

+
+
+ ); +} + +export default forwardRef(Alert); diff --git a/spec/javascripts/packages/components/alert-spec.jsx b/spec/javascripts/packages/components/alert-spec.jsx deleted file mode 100644 index cd9b5e852a9..00000000000 --- a/spec/javascripts/packages/components/alert-spec.jsx +++ /dev/null @@ -1,41 +0,0 @@ -import { createRef } from 'react'; -import { Alert } from '@18f/identity-components'; -import { render } from '../../support/document-capture'; - -describe('identity-components/alert', () => { - it('should apply alert role', () => { - const { getByRole } = render(Uh oh!); - - const alert = getByRole('alert'); - - expect(alert).to.be.ok(); - }); - - it('accepts additional class names', () => { - const { getByRole } = render( - - Uh oh! - , - ); - - const alert = getByRole('alert'); - - expect(alert.classList.contains('my-class')).to.be.true(); - }); - - it('is optionally focusable', () => { - const { getByRole } = render(); - - const alert = getByRole('alert'); - alert.focus(); - - expect(document.activeElement).to.equal(alert); - }); - - it('forwards ref', () => { - const ref = createRef(); - const { container } = render(); - - expect(ref.current).to.equal(container.firstChild); - }); -}); From 0723d6bd7c9130393e0987473f021e3943da20d5 Mon Sep 17 00:00:00 2001 From: Mitchell Henke Date: Thu, 5 May 2022 10:47:02 -0500 Subject: [PATCH 12/29] Revert dropping some sp_costs (#6313) [skip changelog] --- app/controllers/application_controller.rb | 10 +++ .../concerns/billable_event_trackable.rb | 1 + app/controllers/users/sessions_controller.rb | 1 + .../two_factor_authentication_controller.rb | 5 +- app/services/db/sp_cost/add_sp_cost.rb | 6 ++ app/services/identity_linker.rb | 1 + .../idv/send_phone_confirmation_otp.rb | 1 + spec/controllers/saml_idp_controller_spec.rb | 12 +++ spec/features/sp_cost_tracking_spec.rb | 80 +++++++++++++++++-- 9 files changed, 109 insertions(+), 8 deletions(-) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index f9c8c89bf71..85bb625cd77 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -428,6 +428,16 @@ def analytics_exception_info(exception) } end + def add_sp_cost(token) + Db::SpCost::AddSpCost.call( + current_sp, + sp_session_ial, + token, + transaction_id: nil, + user: current_user, + ) + end + def mobile? BrowserCache.parse(request.user_agent).mobile? end diff --git a/app/controllers/concerns/billable_event_trackable.rb b/app/controllers/concerns/billable_event_trackable.rb index 573bb965f16..1b6798c4ea1 100644 --- a/app/controllers/concerns/billable_event_trackable.rb +++ b/app/controllers/concerns/billable_event_trackable.rb @@ -6,6 +6,7 @@ def track_billing_events increment_sp_monthly_auths create_sp_return_log(billable: true) mark_current_session_billed + add_sp_cost(:authentication) end end diff --git a/app/controllers/users/sessions_controller.rb b/app/controllers/users/sessions_controller.rb index d409eac260a..03d582431f4 100644 --- a/app/controllers/users/sessions_controller.rb +++ b/app/controllers/users/sessions_controller.rb @@ -127,6 +127,7 @@ def process_locked_out_user def handle_valid_authentication sign_in(resource_name, resource) cache_active_profile(auth_params[:password]) + add_sp_cost(:digest) create_user_event(:sign_in_before_2fa) EmailAddress.update_last_sign_in_at_on_user_id_and_email( user_id: current_user.id, diff --git a/app/controllers/users/two_factor_authentication_controller.rb b/app/controllers/users/two_factor_authentication_controller.rb index 5da262882e3..678e60002d8 100644 --- a/app/controllers/users/two_factor_authentication_controller.rb +++ b/app/controllers/users/two_factor_authentication_controller.rb @@ -172,7 +172,7 @@ def handle_valid_otp_params(method, default = nil) end def handle_telephony_result(method:, default:) - track_events + track_events(method) if @telephony_result.success? redirect_to login_two_factor_url( otp_delivery_preference: method, @@ -189,8 +189,9 @@ def handle_telephony_result(method:, default:) end end - def track_events + def track_events(method) analytics.track_event(Analytics::TELEPHONY_OTP_SENT, @telephony_result.to_h) + add_sp_cost(method) if @telephony_result.success? end def exceeded_otp_send_limit? diff --git a/app/services/db/sp_cost/add_sp_cost.rb b/app/services/db/sp_cost/add_sp_cost.rb index 6c4ef015e18..f7f4fe606aa 100644 --- a/app/services/db/sp_cost/add_sp_cost.rb +++ b/app/services/db/sp_cost/add_sp_cost.rb @@ -9,9 +9,15 @@ class SpCostTypeError < StandardError; end acuant_back_image acuant_result acuant_selfie + authentication + digest lexis_nexis_resolution lexis_nexis_address gpo_letter + phone_otp + sms + user_added + voice ].freeze def self.call(service_provider, ial, token, transaction_id: nil, user: nil) diff --git a/app/services/identity_linker.rb b/app/services/identity_linker.rb index fa52e198fc4..254f0326ec8 100644 --- a/app/services/identity_linker.rb +++ b/app/services/identity_linker.rb @@ -45,6 +45,7 @@ def identity def find_or_create_identity_with_costing identity_record = identity_relation.first return identity_record if identity_record + Db::SpCost::AddSpCost.call(service_provider, @ial, :user_added) user.identities.create(service_provider: service_provider.issuer) end diff --git a/app/services/idv/send_phone_confirmation_otp.rb b/app/services/idv/send_phone_confirmation_otp.rb index abc9e8bc235..0a960e51f6a 100644 --- a/app/services/idv/send_phone_confirmation_otp.rb +++ b/app/services/idv/send_phone_confirmation_otp.rb @@ -73,6 +73,7 @@ def otp_sent_response def add_cost Db::ProofingCost::AddUserProofingCost.call(user.id, :phone_otp) + Db::SpCost::AddSpCost.call(idv_session.service_provider, 2, :phone_otp) end def extra_analytics_attributes diff --git a/spec/controllers/saml_idp_controller_spec.rb b/spec/controllers/saml_idp_controller_spec.rb index fe557c6cc4d..5ca2a1a6045 100644 --- a/spec/controllers/saml_idp_controller_spec.rb +++ b/spec/controllers/saml_idp_controller_spec.rb @@ -1753,6 +1753,8 @@ def stub_requested_attributes ial: 1) generate_saml_response(user) + + expect_sp_authentication_cost end end @@ -1787,6 +1789,8 @@ def stub_requested_attributes ial: 1) generate_saml_response(user) + + expect_sp_authentication_cost end end end @@ -1813,4 +1817,12 @@ def stub_requested_attributes expect(subject.external_saml_request?).to eq false end end + + def expect_sp_authentication_cost + sp_cost = SpCost.where( + issuer: 'http://localhost:3000', + cost_type: 'authentication', + ).first + expect(sp_cost).to be_present + end end diff --git a/spec/features/sp_cost_tracking_spec.rb b/spec/features/sp_cost_tracking_spec.rb index 410f5b4abc2..a239452995e 100644 --- a/spec/features/sp_cost_tracking_spec.rb +++ b/spec/features/sp_cost_tracking_spec.rb @@ -12,21 +12,32 @@ let(:email) { 'test@test.com' } let(:password) { Features::SessionHelper::VALID_PASSWORD } + it 'logs the correct costs for an ial1 user creation from sp with oidc' do + create_ial1_user_from_sp(email) + + expect_sp_cost_type(0, 1, 'sms') + expect_sp_cost_type(1, 1, 'user_added') + expect_sp_cost_type(2, 1, 'authentication') + end + it 'logs the correct costs for an ial2 user creation from sp with oidc' do create_ial2_user_from_sp(email) - expect_sp_cost_type(0, 2, 'acuant_front_image') - expect_sp_cost_type(1, 2, 'acuant_back_image') - expect_sp_cost_type(2, 2, 'acuant_result') + expect_sp_cost_type(0, 2, 'sms') + expect_sp_cost_type(1, 2, 'acuant_front_image') + expect_sp_cost_type(2, 2, 'acuant_back_image') + expect_sp_cost_type(3, 2, 'acuant_result') expect_sp_cost_type( - 3, 2, 'lexis_nexis_resolution', + 4, 2, 'lexis_nexis_resolution', transaction_id: Proofing::Mock::ResolutionMockClient::TRANSACTION_ID ) expect_sp_cost_type( - 4, 2, 'aamva', + 5, 2, 'aamva', transaction_id: Proofing::Mock::StateIdMockClient::TRANSACTION_ID ) - expect_sp_cost_type(5, 2, 'lexis_nexis_address') + expect_sp_cost_type(6, 2, 'lexis_nexis_address') + expect_sp_cost_type(7, 2, 'user_added') + expect_sp_cost_type(8, 2, 'authentication') end it 'logs the cost to the SP for reproofing' do @@ -65,6 +76,56 @@ end end + it 'logs the correct costs for an ial1 authentication' do + create_ial1_user_from_sp(email) + SpCost.delete_all + + # track costs without dealing with 'remember device' + Capybara.reset_session! + + visit_idp_from_sp_with_ial1(:oidc) + fill_in_credentials_and_submit(email, password) + fill_in_code_with_last_phone_otp + click_submit_default + + expect_sp_cost_type(0, 1, 'digest') + expect_sp_cost_type(1, 1, 'sms') + expect_sp_cost_type(2, 1, 'authentication') + end + + it 'logs the correct costs for an ial2 authentication' do + create_ial2_user_from_sp(email) + SpCost.delete_all + + # track costs without dealing with 'remember device' + Capybara.reset_session! + + visit_idp_from_sp_with_ial2(:oidc) + fill_in_credentials_and_submit(email, password) + fill_in_code_with_last_phone_otp + click_submit_default + + expect_sp_cost_type(0, 2, 'digest') + expect_sp_cost_type(1, 2, 'sms') + expect_sp_cost_type(2, 2, 'authentication') + end + + it 'logs the correct costs for a direct authentication' do + visit root_path + create_ial1_user_directly(email) + SpCost.delete_all + + # track costs without dealing with 'remember device' + Capybara.reset_session! + + visit root_path + fill_in_credentials_and_submit(email, password) + fill_in_code_with_last_phone_otp + click_submit_default + + expect_direct_cost_type(0, 'digest') + end + def expect_sp_cost_type(sp_cost_index, ial, token, transaction_id: nil) sp_cost = sp_costs(sp_cost_index) expect(sp_cost.ial).to eq(ial) @@ -74,6 +135,13 @@ def expect_sp_cost_type(sp_cost_index, ial, token, transaction_id: nil) expect(sp_cost.transaction_id).to(eq(transaction_id)) if transaction_id end + def expect_direct_cost_type(sp_cost_index, token) + sp_cost = sp_costs(sp_cost_index) + expect(sp_cost.issuer).to eq('') + expect(sp_cost.agency_id).to eq(0) + expect(sp_cost.cost_type).to eq(token) + end + def sp_costs(index) SpCost.order('id asc')[index] end From 7928b23f69a81dfd08aaeff655072e10811b7b05 Mon Sep 17 00:00:00 2001 From: Manish Shah <97545428+gsa-manish@users.noreply.github.com> Date: Thu, 5 May 2022 11:47:44 -0400 Subject: [PATCH 13/29] Documents analytics #11 (#6293) * LG-5929-document-analytics-11 * Patch: analytics events 11 (#6303) * Remove @identity.idp.event_name - As of #6294, having it will cause build breakage since it's an unknown tag * Remove blank lines * Add clearer comments for each event Co-authored-by: Zach Margolis --- app/controllers/idv/review_controller.rb | 2 +- .../idv/api_document_verification_form.rb | 5 +- app/forms/idv/api_image_upload_form.rb | 17 +-- app/jobs/document_proofing_job.rb | 5 +- app/services/analytics.rb | 5 - app/services/analytics_events.rb | 128 ++++++++++++++++++ ...register_step_from_analytics_view_event.rb | 2 +- .../actions/verify_document_status_action.rb | 5 +- app/services/idv/steps/doc_auth_base_step.rb | 3 +- .../idv/image_uploads_controller_spec.rb | 49 +++---- .../controllers/idv/review_controller_spec.rb | 5 +- spec/forms/idv/api_image_upload_form_spec.rb | 25 +--- spec/jobs/document_proofing_job_spec.rb | 4 +- .../verify_document_status_action_spec.rb | 2 +- 14 files changed, 181 insertions(+), 76 deletions(-) diff --git a/app/controllers/idv/review_controller.rb b/app/controllers/idv/review_controller.rb index 81c13175fd3..cfd6055e8cb 100644 --- a/app/controllers/idv/review_controller.rb +++ b/app/controllers/idv/review_controller.rb @@ -46,7 +46,7 @@ def create user_session[:need_personal_key_confirmation] = true redirect_to next_step analytics.track_event(Analytics::IDV_REVIEW_COMPLETE) - analytics.track_event(Analytics::IDV_FINAL, success: true) + analytics.idv_final(success: true) return unless FeatureManagement.reveal_gpo_code? session[:last_gpo_confirmation_code] = idv_session.gpo_otp diff --git a/app/forms/idv/api_document_verification_form.rb b/app/forms/idv/api_document_verification_form.rb index b2054c90767..1b1f57b8c1d 100644 --- a/app/forms/idv/api_document_verification_form.rb +++ b/app/forms/idv/api_document_verification_form.rb @@ -31,9 +31,8 @@ def submit }, ) - @analytics.track_event( - Analytics::IDV_DOC_AUTH_SUBMITTED_IMAGE_UPLOAD_FORM, - response.to_h, + @analytics.idv_doc_auth_submitted_image_upload_form( + **response.to_h, ) response diff --git a/app/forms/idv/api_image_upload_form.rb b/app/forms/idv/api_image_upload_form.rb index 0f2c3d3f48c..c9f1c79d3b0 100644 --- a/app/forms/idv/api_image_upload_form.rb +++ b/app/forms/idv/api_image_upload_form.rb @@ -57,10 +57,7 @@ def validate_form extra: extra_attributes, ) - track_event( - Analytics::IDV_DOC_AUTH_SUBMITTED_IMAGE_UPLOAD_FORM, - response.to_h, - ) + analytics.idv_doc_auth_submitted_image_upload_form(**response.to_h) response end @@ -88,10 +85,8 @@ def validate_pii_from_doc(client_response) response = Idv::DocPiiForm.new(client_response.pii_from_doc).submit response.extra.merge!(extra_attributes) - track_event( - Analytics::IDV_DOC_AUTH_SUBMITTED_PII_VALIDATION, - response.to_h, - ) + analytics.idv_doc_auth_submitted_pii_validation(**response.to_h) + store_pii(client_response) if client_response.success? && response.success? response @@ -219,12 +214,10 @@ def track_event(event, attributes = {}) def update_analytics(client_response) add_costs(client_response) update_funnel(client_response) - track_event( - Analytics::IDV_DOC_AUTH_SUBMITTED_IMAGE_UPLOAD_VENDOR, - client_response.to_h.merge( + analytics.idv_doc_auth_submitted_image_upload_vendor( + **client_response.to_h.merge( client_image_metrics: image_metadata, async: false, - flow_path: params[:flow_path], ), ) end diff --git a/app/jobs/document_proofing_job.rb b/app/jobs/document_proofing_job.rb index 4bdbc3d6091..0d7d68d5e96 100644 --- a/app/jobs/document_proofing_job.rb +++ b/app/jobs/document_proofing_job.rb @@ -72,9 +72,8 @@ def perform( throttle = Throttle.for(user: user, throttle_type: :idv_doc_auth) - analytics.track_event( - Analytics::IDV_DOC_AUTH_SUBMITTED_IMAGE_UPLOAD_VENDOR, - proofer_result.to_h.merge( + analytics.idv_doc_auth_submitted_image_upload_vendor( + **proofer_result.to_h.merge( state: proofer_result.pii_from_doc[:state], state_id_type: proofer_result.pii_from_doc[:state_id_type], async: true, diff --git a/app/services/analytics.rb b/app/services/analytics.rb index c96855ace09..fc65bba6b40 100644 --- a/app/services/analytics.rb +++ b/app/services/analytics.rb @@ -138,11 +138,6 @@ def session_started_at # rubocop:disable Layout/LineLength DOC_AUTH = 'Doc Auth' # visited or submitted is appended - IDV_DOC_AUTH_SUBMITTED_IMAGE_UPLOAD_FORM = 'IdV: doc auth image upload form submitted' - IDV_DOC_AUTH_SUBMITTED_IMAGE_UPLOAD_VENDOR = 'IdV: doc auth image upload vendor submitted' - IDV_DOC_AUTH_SUBMITTED_PII_VALIDATION = 'IdV: doc auth image upload vendor pii validation' - IDV_DOC_AUTH_WARNING_VISITED = 'IdV: doc auth warning visited' - IDV_FINAL = 'IdV: final resolution' IDV_FORGOT_PASSWORD = 'IdV: forgot password visited' IDV_FORGOT_PASSWORD_CONFIRMED = 'IdV: forgot password confirmed' IDV_INTRO_VISIT = 'IdV: intro visited' diff --git a/app/services/analytics_events.rb b/app/services/analytics_events.rb index e4c4b516f37..597b61b81c9 100644 --- a/app/services/analytics_events.rb +++ b/app/services/analytics_events.rb @@ -467,6 +467,134 @@ def idv_doc_auth_exception_visited(step_name:, remaining_attempts:, **extra) ) end + # @param [Boolean] success + # @param [Hash] errors + # @param [Integer] attempts + # @param [Integer] remaining_attempts + # @param [String] user_id + # @param [String] flow_path + # The document capture image uploaded was locally validated during the IDV process + def idv_doc_auth_submitted_image_upload_form( + success:, + errors:, + remaining_attempts:, flow_path:, attempts: nil, + user_id: nil, + **extra + ) + track_event( + 'IdV: doc auth image upload form submitted', + success: success, + errors: errors, + attempts: attempts, + remaining_attempts: remaining_attempts, + user_id: user_id, + flow_path: flow_path, + **extra, + ) + end + + # @param [Boolean] success + # @param [Hash] errors + # @param [String] exception + # @param [Boolean] billed + # @param [String] doc_auth_result + # @param [String] state + # @param [String] state_id_type + # @param [Boolean] async + # @param [Integer] attempts + # @param [Integer] remaining_attempts + # @param [Hash] client_image_metrics + # @param [String] flow_path + # The document capture image was uploaded to vendor during the IDV process + def idv_doc_auth_submitted_image_upload_vendor( + success:, + errors:, + exception:, + state:, + state_id_type:, + async:, attempts:, + remaining_attempts:, + client_image_metrics:, + flow_path:, + billed: nil, + doc_auth_result: nil, + **extra + ) + track_event( + 'IdV: doc auth image upload vendor submitted', + success: success, + errors: errors, + exception: exception, + billed: billed, + doc_auth_result: doc_auth_result, + state: state, + state_id_type: state_id_type, + async: async, + attempts: attempts, + remaining_attempts: remaining_attempts, + client_image_metrics: client_image_metrics, + flow_path: flow_path, + **extra, + ) + end + + # @param [Boolean] success + # @param [Hash] errors + # @param [String] user_id + # @param [Integer] remaining_attempts + # @param [Hash] pii_like_keypaths + # @param [String] flow_path + # The PII that came back from the document capture vendor was validated + def idv_doc_auth_submitted_pii_validation( + success:, + errors:, + remaining_attempts:, + pii_like_keypaths:, + flow_path:, + user_id: nil, + **extra + ) + track_event( + 'IdV: doc auth image upload vendor pii validation', + success: success, + errors: errors, + user_id: user_id, + remaining_attempts: remaining_attempts, + pii_like_keypaths: pii_like_keypaths, + flow_path: flow_path, + **extra, + ) + end + + # @param [String] step_name + # @param [Integer] remaining_attempts + # The user was sent to a warning page during the IDV flow + def idv_doc_auth_warning_visited( + step_name:, + remaining_attempts:, + **extra + ) + track_event( + 'IdV: doc auth warning visited', + step_name: step_name, + remaining_attempts: remaining_attempts, + **extra, + ) + end + + # @param [Boolean] success + # Tracks the last step of IDV, indicates the user successfully prooved + def idv_final( + success:, + **extra + ) + track_event( + 'IdV: final resolution', + success: success, + **extra, + ) + end + # User visited IDV personal key page def idv_personal_key_visited track_event('IdV: personal key visited') diff --git a/app/services/funnel/doc_auth/register_step_from_analytics_view_event.rb b/app/services/funnel/doc_auth/register_step_from_analytics_view_event.rb index 6fdb86017d3..f873533b01e 100644 --- a/app/services/funnel/doc_auth/register_step_from_analytics_view_event.rb +++ b/app/services/funnel/doc_auth/register_step_from_analytics_view_event.rb @@ -4,7 +4,7 @@ class RegisterStepFromAnalyticsViewEvent ANALYTICS_EVENT_TO_DOC_AUTH_LOG_TOKEN = { Analytics::IDV_PHONE_RECORD_VISIT => :verify_phone, Analytics::IDV_REVIEW_VISIT => :encrypt, - Analytics::IDV_FINAL => :verified, + 'IdV: final resolution' => :verified, Analytics::IDV_GPO_ADDRESS_VISITED => :usps_address, }.freeze diff --git a/app/services/idv/actions/verify_document_status_action.rb b/app/services/idv/actions/verify_document_status_action.rb index 2a4a02097e8..7bb3cb28de7 100644 --- a/app/services/idv/actions/verify_document_status_action.rb +++ b/app/services/idv/actions/verify_document_status_action.rb @@ -43,9 +43,8 @@ def process_async_state(current_async_state) def async_state_done(async_result) doc_pii_form_result = Idv::DocPiiForm.new(async_result.pii).submit - @flow.analytics.track_event( - Analytics::IDV_DOC_AUTH_SUBMITTED_PII_VALIDATION, - doc_pii_form_result.to_h.merge( + @flow.analytics.idv_doc_auth_submitted_pii_validation( + **doc_pii_form_result.to_h.merge( remaining_attempts: remaining_attempts, flow_path: flow_path, ), diff --git a/app/services/idv/steps/doc_auth_base_step.rb b/app/services/idv/steps/doc_auth_base_step.rb index 65aa437f4fe..693e18c088f 100644 --- a/app/services/idv/steps/doc_auth_base_step.rb +++ b/app/services/idv/steps/doc_auth_base_step.rb @@ -30,8 +30,7 @@ def idv_failure(result) ) redirect_to idv_session_errors_exception_url else - @flow.analytics.track_event( - Analytics::IDV_DOC_AUTH_WARNING_VISITED, + @flow.analytics.idv_doc_auth_warning_visited( step_name: self.class.name, remaining_attempts: throttle.remaining_count, ) diff --git a/spec/controllers/idv/image_uploads_controller_spec.rb b/spec/controllers/idv/image_uploads_controller_spec.rb index 46276eab9e1..94291a0cc5b 100644 --- a/spec/controllers/idv/image_uploads_controller_spec.rb +++ b/spec/controllers/idv/image_uploads_controller_spec.rb @@ -40,7 +40,7 @@ stub_analytics expect(@analytics).to receive(:track_event).with( - Analytics::IDV_DOC_AUTH_SUBMITTED_IMAGE_UPLOAD_FORM, + 'IdV: doc auth image upload form submitted,', success: false, errors: { front: ['Please fill in this field.'], @@ -53,10 +53,10 @@ remaining_attempts: IdentityConfig.store.doc_auth_max_attempts - 1, pii_like_keypaths: [[:pii]], flow_path: 'standard', - ) + ).exactly(0).times expect(@analytics).not_to receive(:track_event).with( - Analytics::IDV_DOC_AUTH_SUBMITTED_IMAGE_UPLOAD_VENDOR, + 'IdV: doc auth image upload vendor submitted', any_args, ) @@ -95,7 +95,7 @@ stub_analytics expect(@analytics).to receive(:track_event).with( - Analytics::IDV_DOC_AUTH_SUBMITTED_IMAGE_UPLOAD_FORM, + 'IdV: doc auth image upload form submitted', success: false, errors: { front: [I18n.t('doc_auth.errors.not_a_file')], @@ -111,7 +111,8 @@ ) expect(@analytics).not_to receive(:track_event).with( - Analytics::IDV_DOC_AUTH_SUBMITTED_IMAGE_UPLOAD_VENDOR, + 'IdV: doc auth image upload vendor submitted', + # Analytics::IDV_DOC_AUTH_SUBMITTED_IMAGE_UPLOAD_VENDOR, any_args, ) @@ -196,7 +197,7 @@ stub_analytics expect(@analytics).to receive(:track_event).with( - Analytics::IDV_DOC_AUTH_SUBMITTED_IMAGE_UPLOAD_FORM, + 'IdV: doc auth image upload form submitted', success: false, errors: { limit: [I18n.t('errors.doc_auth.throttled_heading')], @@ -212,7 +213,7 @@ ) expect(@analytics).not_to receive(:track_event).with( - Analytics::IDV_DOC_AUTH_SUBMITTED_IMAGE_UPLOAD_VENDOR, + 'IdV: doc auth image upload vendor submitted', any_args, ) @@ -235,7 +236,7 @@ stub_analytics expect(@analytics).to receive(:track_event).with( - Analytics::IDV_DOC_AUTH_SUBMITTED_IMAGE_UPLOAD_FORM, + 'IdV: doc auth image upload form submitted', success: true, errors: {}, user_id: user.uuid, @@ -246,7 +247,7 @@ ) expect(@analytics).to receive(:track_event).with( - Analytics::IDV_DOC_AUTH_SUBMITTED_IMAGE_UPLOAD_VENDOR, + 'IdV: doc auth image upload vendor submitted', success: true, errors: {}, async: false, @@ -267,7 +268,7 @@ ) expect(@analytics).to receive(:track_event).with( - Analytics::IDV_DOC_AUTH_SUBMITTED_PII_VALIDATION, + 'IdV: doc auth image upload vendor pii validation', success: true, errors: {}, user_id: user.uuid, @@ -314,7 +315,7 @@ stub_analytics expect(@analytics).to receive(:track_event).with( - Analytics::IDV_DOC_AUTH_SUBMITTED_IMAGE_UPLOAD_FORM, + 'IdV: doc auth image upload form submitted', success: true, errors: {}, user_id: user.uuid, @@ -325,7 +326,7 @@ ) expect(@analytics).to receive(:track_event).with( - Analytics::IDV_DOC_AUTH_SUBMITTED_IMAGE_UPLOAD_VENDOR, + 'IdV: doc auth image upload vendor submitted', success: true, errors: {}, async: false, @@ -346,7 +347,7 @@ ) expect(@analytics).to receive(:track_event).with( - Analytics::IDV_DOC_AUTH_SUBMITTED_PII_VALIDATION, + 'IdV: doc auth image upload vendor pii validation', success: false, errors: { pii: [I18n.t('doc_auth.errors.alerts.full_name_check')], @@ -372,7 +373,7 @@ stub_analytics expect(@analytics).to receive(:track_event).with( - Analytics::IDV_DOC_AUTH_SUBMITTED_IMAGE_UPLOAD_FORM, + 'IdV: doc auth image upload form submitted', success: true, errors: {}, user_id: user.uuid, @@ -383,7 +384,7 @@ ) expect(@analytics).to receive(:track_event).with( - Analytics::IDV_DOC_AUTH_SUBMITTED_IMAGE_UPLOAD_VENDOR, + 'IdV: doc auth image upload vendor submitted', success: true, errors: {}, async: false, @@ -404,7 +405,7 @@ ) expect(@analytics).to receive(:track_event).with( - Analytics::IDV_DOC_AUTH_SUBMITTED_PII_VALIDATION, + 'IdV: doc auth image upload vendor pii validation', success: false, errors: { pii: [I18n.t('doc_auth.errors.general.no_liveness')], @@ -430,7 +431,7 @@ stub_analytics expect(@analytics).to receive(:track_event).with( - Analytics::IDV_DOC_AUTH_SUBMITTED_IMAGE_UPLOAD_FORM, + 'IdV: doc auth image upload form submitted', success: true, errors: {}, user_id: user.uuid, @@ -441,7 +442,7 @@ ) expect(@analytics).to receive(:track_event).with( - Analytics::IDV_DOC_AUTH_SUBMITTED_IMAGE_UPLOAD_VENDOR, + 'IdV: doc auth image upload vendor submitted', success: true, errors: {}, async: false, @@ -462,7 +463,7 @@ ) expect(@analytics).to receive(:track_event).with( - Analytics::IDV_DOC_AUTH_SUBMITTED_PII_VALIDATION, + 'IdV: doc auth image upload vendor pii validation', success: false, errors: { pii: [I18n.t('doc_auth.errors.alerts.birth_date_checks')], @@ -512,7 +513,7 @@ stub_analytics expect(@analytics).to receive(:track_event).with( - Analytics::IDV_DOC_AUTH_SUBMITTED_IMAGE_UPLOAD_FORM, + 'IdV: doc auth image upload form submitted', success: true, errors: {}, user_id: user.uuid, @@ -523,13 +524,14 @@ ) expect(@analytics).to receive(:track_event).with( - Analytics::IDV_DOC_AUTH_SUBMITTED_IMAGE_UPLOAD_VENDOR, + 'IdV: doc auth image upload vendor submitted', success: false, errors: { front: [I18n.t('doc_auth.errors.general.multiple_front_id_failures')], }, user_id: user.uuid, attempts: 1, + billed: nil, remaining_attempts: IdentityConfig.store.doc_auth_max_attempts - 1, state: nil, state_id_type: nil, @@ -539,6 +541,7 @@ front: { glare: 99.99 }, back: { glare: 99.99 }, }, + doc_auth_result: nil, pii_like_keypaths: [[:pii]], flow_path: 'standard', ) @@ -571,7 +574,7 @@ stub_analytics expect(@analytics).to receive(:track_event).with( - Analytics::IDV_DOC_AUTH_SUBMITTED_IMAGE_UPLOAD_FORM, + 'IdV: doc auth image upload form submitted', success: true, errors: {}, user_id: user.uuid, @@ -582,7 +585,7 @@ ) expect(@analytics).to receive(:track_event).with( - Analytics::IDV_DOC_AUTH_SUBMITTED_IMAGE_UPLOAD_VENDOR, + 'IdV: doc auth image upload vendor submitted', success: false, errors: { general: [I18n.t('doc_auth.errors.alerts.barcode_content_check')], diff --git a/spec/controllers/idv/review_controller_spec.rb b/spec/controllers/idv/review_controller_spec.rb index 72b754f8f72..b8200ed3430 100644 --- a/spec/controllers/idv/review_controller_spec.rb +++ b/spec/controllers/idv/review_controller_spec.rb @@ -297,7 +297,10 @@ def show put :create, params: { user: { password: ControllerHelper::VALID_PASSWORD } } expect(@analytics).to have_received(:track_event).with(Analytics::IDV_REVIEW_COMPLETE) - expect(@analytics).to have_received(:track_event).with(Analytics::IDV_FINAL, success: true) + expect(@analytics).to have_received(:track_event).with( + 'IdV: final resolution', + success: true, + ) expect(response).to redirect_to idv_personal_key_path end diff --git a/spec/forms/idv/api_image_upload_form_spec.rb b/spec/forms/idv/api_image_upload_form_spec.rb index 35f9ac55e02..06462763fca 100644 --- a/spec/forms/idv/api_image_upload_form_spec.rb +++ b/spec/forms/idv/api_image_upload_form_spec.rb @@ -1,6 +1,8 @@ require 'rails_helper' RSpec.describe Idv::ApiImageUploadForm do + include AnalyticsEvents + subject(:form) do Idv::ApiImageUploadForm.new( ActionController::Parameters.new( @@ -103,21 +105,12 @@ form.submit expect(fake_analytics).to have_logged_event( - Analytics::IDV_DOC_AUTH_SUBMITTED_IMAGE_UPLOAD_VENDOR, + 'IdV: doc auth image upload form submitted', success: true, errors: {}, - exception: nil, - doc_auth_result: 'Passed', - billed: true, attempts: 1, - remaining_attempts: IdentityConfig.store.doc_auth_max_attempts - 1, - state: 'MT', - state_id_type: 'drivers_license', + remaining_attempts: 3, user_id: document_capture_session.user.uuid, - client_image_metrics: { - front: JSON.parse(front_image_metadata, symbolize_names: true), - back: JSON.parse(back_image_metadata, symbolize_names: true), - }, ) end end @@ -144,18 +137,12 @@ form.submit expect(fake_analytics).to have_logged_event( - Analytics::IDV_DOC_AUTH_SUBMITTED_IMAGE_UPLOAD_VENDOR, + 'IdV: doc auth image upload form submitted', success: true, errors: {}, - exception: nil, - doc_auth_result: 'Passed', - billed: true, attempts: 1, - remaining_attempts: IdentityConfig.store.doc_auth_max_attempts - 1, + remaining_attempts: 3, user_id: document_capture_session.user.uuid, - client_image_metrics: { - front: JSON.parse(front_image_metadata, symbolize_names: true), - }, ) end end diff --git a/spec/jobs/document_proofing_job_spec.rb b/spec/jobs/document_proofing_job_spec.rb index 62babf24ac8..2e79d0a528b 100644 --- a/spec/jobs/document_proofing_job_spec.rb +++ b/spec/jobs/document_proofing_job_spec.rb @@ -138,7 +138,7 @@ ) expect(job_analytics).to have_logged_event( - Analytics::IDV_DOC_AUTH_SUBMITTED_IMAGE_UPLOAD_VENDOR, + 'IdV: doc auth image upload vendor submitted', success: true, errors: {}, exception: nil, @@ -192,7 +192,7 @@ ) expect(job_analytics).to have_logged_event( - Analytics::IDV_DOC_AUTH_SUBMITTED_IMAGE_UPLOAD_VENDOR, + 'IdV: doc auth image upload vendor submitted', success: true, errors: {}, exception: nil, diff --git a/spec/services/idv/actions/verify_document_status_action_spec.rb b/spec/services/idv/actions/verify_document_status_action_spec.rb index 048655f6064..f053ac55539 100644 --- a/spec/services/idv/actions/verify_document_status_action_spec.rb +++ b/spec/services/idv/actions/verify_document_status_action_spec.rb @@ -42,7 +42,7 @@ subject.call expect(analytics).to have_logged_event( - Analytics::IDV_DOC_AUTH_SUBMITTED_PII_VALIDATION, + 'IdV: doc auth image upload vendor pii validation', success: true, errors: {}, ) From 34c33d173f88591933946ccee41976e2d894bc33 Mon Sep 17 00:00:00 2001 From: Jonathan Hooper Date: Thu, 5 May 2022 12:27:14 -0400 Subject: [PATCH 14/29] Remove the USPS option during IAL2 strict (#6277) There is a chance we won't be able to roll out a compliant IAL2 flow with the USPS letter flow in place. This commit adds the ability to remove the letter flow option from strict IAL2 if that does become the case. * changelog: Upcoming Features, Proofing, The ability to disable the option to proof with a letter during IAL2 strict was added --- .../concerns/verify_profile_concern.rb | 2 + app/controllers/idv/doc_auth_controller.rb | 2 + app/controllers/idv/gpo_controller.rb | 7 ++ .../idv/otp_delivery_method_controller.rb | 5 +- app/controllers/idv/phone_controller.rb | 5 +- .../idv/phone_errors_controller.rb | 11 +++ app/models/profile.rb | 2 +- app/views/idv/phone_errors/_warning.html.erb | 2 +- app/views/idv/phone_errors/failure.html.erb | 2 +- config/application.yml.default | 2 +- lib/identity_config.rb | 2 +- spec/features/idv/strict_ial2/upgrade_spec.rb | 2 +- .../usps_upload_disallowed_spec.rb | 76 +++++++++++++++++++ spec/models/profile_spec.rb | 4 +- .../idv/phone_errors/failure.html.erb_spec.rb | 1 + .../idv/phone_errors/jobfail.html.erb_spec.rb | 8 +- .../idv/phone_errors/timeout.html.erb_spec.rb | 8 +- .../idv/phone_errors/warning.html.erb_spec.rb | 8 +- 18 files changed, 124 insertions(+), 25 deletions(-) create mode 100644 spec/features/idv/strict_ial2/usps_upload_disallowed_spec.rb diff --git a/app/controllers/concerns/verify_profile_concern.rb b/app/controllers/concerns/verify_profile_concern.rb index d49791c025f..6345dd54f04 100644 --- a/app/controllers/concerns/verify_profile_concern.rb +++ b/app/controllers/concerns/verify_profile_concern.rb @@ -10,6 +10,8 @@ def account_or_verify_profile_url def profile_needs_verification? return false if current_user.blank? + return false if sp_session[:ial2_strict] && + !IdentityConfig.store.gpo_allowed_for_strict_ial2 current_user.decorate.pending_profile_requires_verification? || user_needs_to_reactivate_account? end diff --git a/app/controllers/idv/doc_auth_controller.rb b/app/controllers/idv/doc_auth_controller.rb index d22e70162d1..45a937fc904 100644 --- a/app/controllers/idv/doc_auth_controller.rb +++ b/app/controllers/idv/doc_auth_controller.rb @@ -31,6 +31,8 @@ def redirect_if_mail_bounced end def redirect_if_pending_profile + return if sp_session[:ial2_strict] && + !IdentityConfig.store.gpo_allowed_for_strict_ial2 redirect_to idv_gpo_verify_url if current_user.decorate.pending_profile_requires_verification? end diff --git a/app/controllers/idv/gpo_controller.rb b/app/controllers/idv/gpo_controller.rb index eb20b2b5079..c7df8e8ae70 100644 --- a/app/controllers/idv/gpo_controller.rb +++ b/app/controllers/idv/gpo_controller.rb @@ -6,6 +6,7 @@ class GpoController < ApplicationController before_action :confirm_idv_needed before_action :confirm_user_completed_idv_profile_step before_action :confirm_mail_not_spammed + before_action :confirm_gpo_allowed_if_strict_ial2 before_action :max_attempts_reached, only: [:update] def index @@ -65,6 +66,12 @@ def failure redirect_to idv_gpo_url unless performed? end + def confirm_gpo_allowed_if_strict_ial2 + return unless sp_session[:ial2_strict] + return if IdentityConfig.store.gpo_allowed_for_strict_ial2 + redirect_to idv_phone_url + end + def pii(address_pii) address_pii.dup.merge(non_address_pii) end diff --git a/app/controllers/idv/otp_delivery_method_controller.rb b/app/controllers/idv/otp_delivery_method_controller.rb index d55123e566a..108d87d1416 100644 --- a/app/controllers/idv/otp_delivery_method_controller.rb +++ b/app/controllers/idv/otp_delivery_method_controller.rb @@ -79,8 +79,11 @@ def otp_delivery_selection_form end def gpo_letter_available + return @gpo_letter_available if defined?(@gpo_letter_available) @gpo_letter_available ||= FeatureManagement.enable_gpo_verification? && - !Idv::GpoMail.new(current_user).mail_spammed? + !Idv::GpoMail.new(current_user).mail_spammed? && + !(sp_session[:ial2_strict] && + !IdentityConfig.store.gpo_allowed_for_strict_ial2) end end end diff --git a/app/controllers/idv/phone_controller.rb b/app/controllers/idv/phone_controller.rb index fdb6d6d5d83..af0e5131b00 100644 --- a/app/controllers/idv/phone_controller.rb +++ b/app/controllers/idv/phone_controller.rb @@ -140,8 +140,11 @@ def new_phone_added? end def gpo_letter_available + return @gpo_letter_available if defined?(@gpo_letter_available) @gpo_letter_available ||= FeatureManagement.enable_gpo_verification? && - !Idv::GpoMail.new(current_user).mail_spammed? + !Idv::GpoMail.new(current_user).mail_spammed? && + !(sp_session[:ial2_strict] && + !IdentityConfig.store.gpo_allowed_for_strict_ial2) end end end diff --git a/app/controllers/idv/phone_errors_controller.rb b/app/controllers/idv/phone_errors_controller.rb index cb2d87d0a73..636c5598ae0 100644 --- a/app/controllers/idv/phone_errors_controller.rb +++ b/app/controllers/idv/phone_errors_controller.rb @@ -4,6 +4,7 @@ class PhoneErrorsController < ApplicationController before_action :confirm_two_factor_authenticated before_action :confirm_idv_phone_step_needed + before_action :set_gpo_letter_available def warning @remaining_attempts = throttle.remaining_count @@ -45,5 +46,15 @@ def track_event(type:) analytics.idv_phone_error_visited(**attributes) end + + # rubocop:disable Naming/MemoizedInstanceVariableName + def set_gpo_letter_available + return @gpo_letter_available if defined?(@gpo_letter_available) + @gpo_letter_available ||= FeatureManagement.enable_gpo_verification? && + !Idv::GpoMail.new(current_user).mail_spammed? && + !(sp_session[:ial2_strict] && + !IdentityConfig.store.gpo_allowed_for_strict_ial2) + end + # rubocop:enable Naming/MemoizedInstanceVariableName end end diff --git a/app/models/profile.rb b/app/models/profile.rb index 111e6b262ef..8eddfeeed34 100644 --- a/app/models/profile.rb +++ b/app/models/profile.rb @@ -93,7 +93,7 @@ def includes_phone_check? def strict_ial2_proofed? return false unless active return false unless includes_liveness_check? - return true if IdentityConfig.store.usps_upload_allowed_for_strict_ial2 + return true if IdentityConfig.store.gpo_allowed_for_strict_ial2 includes_phone_check? end diff --git a/app/views/idv/phone_errors/_warning.html.erb b/app/views/idv/phone_errors/_warning.html.erb index ccb0822efc8..9a722d0ecef 100644 --- a/app/views/idv/phone_errors/_warning.html.erb +++ b/app/views/idv/phone_errors/_warning.html.erb @@ -19,7 +19,7 @@ locals: text: t('idv.troubleshooting.options.contact_support', app_name: APP_NAME), new_tab: true, }, - FeatureManagement.enable_gpo_verification? && { + @gpo_letter_available && { text: t('idv.troubleshooting.options.verify_by_mail'), url: idv_gpo_path, }, diff --git a/app/views/idv/phone_errors/failure.html.erb b/app/views/idv/phone_errors/failure.html.erb index bd75ad9db9c..adde1cf968d 100644 --- a/app/views/idv/phone_errors/failure.html.erb +++ b/app/views/idv/phone_errors/failure.html.erb @@ -3,7 +3,7 @@ title: t('titles.failure.phone_verification'), heading: t('idv.failure.phone.heading'), options: [ - FeatureManagement.enable_gpo_verification? && { + @gpo_letter_available && { text: t('idv.troubleshooting.options.verify_by_mail'), url: idv_gpo_path, }, diff --git a/config/application.yml.default b/config/application.yml.default index 7a9d3309f7c..910d50efc5b 100644 --- a/config/application.yml.default +++ b/config/application.yml.default @@ -245,7 +245,7 @@ usps_ipp_root_url: '' usps_ipp_request_timeout: 10 usps_ipp_sponsor_id: '' usps_ipp_username: '' -usps_upload_allowed_for_strict_ial2: true +gpo_allowed_for_strict_ial2: true voice_otp_pause_time: '0.5s' voice_otp_speech_rate: 'slow' voip_check: true diff --git a/lib/identity_config.rb b/lib/identity_config.rb index 9d2a086c74b..9a410d4695b 100644 --- a/lib/identity_config.rb +++ b/lib/identity_config.rb @@ -327,7 +327,7 @@ def self.build_store(config_map) config.add(:usps_ipp_username, type: :string) config.add(:usps_ipp_request_timeout, type: :integer) config.add(:usps_upload_enabled, type: :boolean) - config.add(:usps_upload_allowed_for_strict_ial2, type: :boolean) + config.add(:gpo_allowed_for_strict_ial2, type: :boolean) config.add(:usps_upload_sftp_directory, type: :string) config.add(:usps_upload_sftp_host, type: :string) config.add(:usps_upload_sftp_password, type: :string) diff --git a/spec/features/idv/strict_ial2/upgrade_spec.rb b/spec/features/idv/strict_ial2/upgrade_spec.rb index fff19b5612d..3848eb056a7 100644 --- a/spec/features/idv/strict_ial2/upgrade_spec.rb +++ b/spec/features/idv/strict_ial2/upgrade_spec.rb @@ -35,7 +35,7 @@ context 'strict IAL2 does not allow a phone check' do before do allow(IdentityConfig.store).to receive( - :usps_upload_allowed_for_strict_ial2, + :gpo_allowed_for_strict_ial2, ).and_return(false) end diff --git a/spec/features/idv/strict_ial2/usps_upload_disallowed_spec.rb b/spec/features/idv/strict_ial2/usps_upload_disallowed_spec.rb new file mode 100644 index 00000000000..ec9a5c7e1fb --- /dev/null +++ b/spec/features/idv/strict_ial2/usps_upload_disallowed_spec.rb @@ -0,0 +1,76 @@ +require 'rails_helper' + +feature 'Strict IAL2 with usps upload disallowed' do + include IdvHelper + include OidcAuthHelper + include IdvHelper + include IdvStepHelper + + before do + allow(IdentityConfig.store).to receive(:liveness_checking_enabled).and_return(true) + allow(IdentityConfig.store).to receive( + :gpo_allowed_for_strict_ial2, + ).and_return(false) + end + + it 'does not allow the user to select the letter flow during proofing' do + user = create(:user, :signed_up) + visit_idp_from_oidc_sp_with_ial2_strict + sign_in_user(user) + fill_in_code_with_last_phone_otp + click_submit_default + complete_idv_steps_before_phone_step + + # Link is not present on the phone page + expect(page).to_not have_content(t('idv.troubleshooting.options.verify_by_mail')) + + # Link is not present on the OTP delivery selection page + fill_out_phone_form_ok('7032231234') + click_idv_continue + expect(page).to_not have_content(t('idv.troubleshooting.options.verify_by_mail')) + + # Link is not visible on the OTP entry page + choose_idv_otp_delivery_method_sms + expect(page).to_not have_content(t('idv.troubleshooting.options.verify_by_mail')) + + # Link is not visible on error or warning page + visit idv_phone_errors_warning_path + expect(page).to_not have_content(t('idv.troubleshooting.options.verify_by_mail')) + visit idv_phone_errors_jobfail_path + expect(page).to_not have_content(t('idv.troubleshooting.options.verify_by_mail')) + visit idv_phone_errors_timeout_path + expect(page).to_not have_content(t('idv.troubleshooting.options.verify_by_mail')) + visit idv_phone_errors_failure_path + expect(page).to_not have_content(t('idv.troubleshooting.options.verify_by_mail')) + + # Visiting the GPO page redirects + visit idv_gpo_path + expect(current_path).to eq(idv_phone_path) + end + + it 'does not prompt a pending user for a mailed code' do + user = create( + :profile, + deactivation_reason: :verification_pending, + pii: { first_name: 'John', ssn: '111223333' }, + ).user + + visit_idp_from_oidc_sp_with_ial2_strict + sign_in_user(user) + fill_in_code_with_last_phone_otp + click_submit_default + + # Directed to the start of the proofing flow instead of GPO code verification + expect(current_path).to eq(idv_doc_auth_step_path(step: :welcome)) + + complete_all_doc_auth_steps + click_continue + fill_in 'Password', with: user.password + click_continue + click_acknowledge_personal_key + click_agree_and_continue + + expect(current_url).to start_with('http://localhost:7654/auth/result') + expect(user.active_profile.strict_ial2_proofed?).to be_truthy + end +end diff --git a/spec/models/profile_spec.rb b/spec/models/profile_spec.rb index edf6d5b5731..d6c3b6f82cd 100644 --- a/spec/models/profile_spec.rb +++ b/spec/models/profile_spec.rb @@ -102,7 +102,7 @@ context 'the letter flow is allowed for strict IAL2' do before do allow(IdentityConfig.store).to receive( - :usps_upload_allowed_for_strict_ial2, + :gpo_allowed_for_strict_ial2, ).and_return(true) end @@ -124,7 +124,7 @@ context 'the letter flow is not allowed for strict IAL2' do before do allow(IdentityConfig.store).to receive( - :usps_upload_allowed_for_strict_ial2, + :gpo_allowed_for_strict_ial2, ).and_return(false) end diff --git a/spec/views/idv/phone_errors/failure.html.erb_spec.rb b/spec/views/idv/phone_errors/failure.html.erb_spec.rb index e87091921dd..02ac43246e8 100644 --- a/spec/views/idv/phone_errors/failure.html.erb_spec.rb +++ b/spec/views/idv/phone_errors/failure.html.erb_spec.rb @@ -11,6 +11,7 @@ before do decorated_session = instance_double(ServiceProviderSessionDecorator, sp_name: sp_name) allow(view).to receive(:decorated_session).and_return(decorated_session) + assign(:gpo_letter_available, true) allow(IdentityConfig.store).to receive(:idv_attempt_window_in_hours).and_return(timeout_hours) @expires_at = Time.zone.now + timeout_hours.hours diff --git a/spec/views/idv/phone_errors/jobfail.html.erb_spec.rb b/spec/views/idv/phone_errors/jobfail.html.erb_spec.rb index 5a71334b921..92f1d5a77e9 100644 --- a/spec/views/idv/phone_errors/jobfail.html.erb_spec.rb +++ b/spec/views/idv/phone_errors/jobfail.html.erb_spec.rb @@ -2,14 +2,12 @@ describe 'idv/phone_errors/jobfail.html.erb' do let(:sp_name) { 'Example SP' } - let(:enable_gpo_verification) { false } + let(:gpo_letter_available) { false } before do - allow(FeatureManagement).to receive(:enable_gpo_verification?). - and_return(enable_gpo_verification) - decorated_session = instance_double(ServiceProviderSessionDecorator, sp_name: sp_name) allow(view).to receive(:decorated_session).and_return(decorated_session) + assign(:gpo_letter_available, gpo_letter_available) render end @@ -43,7 +41,7 @@ end context 'gpo verification enabled' do - let(:enable_gpo_verification) { true } + let(:gpo_letter_available) { true } it 'renders a list of troubleshooting options' do expect(rendered).to have_link( diff --git a/spec/views/idv/phone_errors/timeout.html.erb_spec.rb b/spec/views/idv/phone_errors/timeout.html.erb_spec.rb index 2f9f3135b9d..0c481b89724 100644 --- a/spec/views/idv/phone_errors/timeout.html.erb_spec.rb +++ b/spec/views/idv/phone_errors/timeout.html.erb_spec.rb @@ -2,14 +2,12 @@ describe 'idv/phone_errors/timeout.html.erb' do let(:sp_name) { 'Example SP' } - let(:enable_gpo_verification) { false } + let(:gpo_letter_available) { false } before do - allow(FeatureManagement).to receive(:enable_gpo_verification?). - and_return(enable_gpo_verification) - decorated_session = instance_double(ServiceProviderSessionDecorator, sp_name: sp_name) allow(view).to receive(:decorated_session).and_return(decorated_session) + assign(:gpo_letter_available, gpo_letter_available) render end @@ -43,7 +41,7 @@ end context 'gpo verification enabled' do - let(:enable_gpo_verification) { true } + let(:gpo_letter_available) { true } it 'renders a list of troubleshooting options' do expect(rendered).to have_link( diff --git a/spec/views/idv/phone_errors/warning.html.erb_spec.rb b/spec/views/idv/phone_errors/warning.html.erb_spec.rb index 10a83fefed9..71e20bc0a2e 100644 --- a/spec/views/idv/phone_errors/warning.html.erb_spec.rb +++ b/spec/views/idv/phone_errors/warning.html.erb_spec.rb @@ -3,14 +3,12 @@ describe 'idv/phone_errors/warning.html.erb' do let(:sp_name) { 'Example SP' } let(:remaining_attempts) { 5 } - let(:enable_gpo_verification) { false } + let(:gpo_letter_available) { false } before do - allow(FeatureManagement).to receive(:enable_gpo_verification?). - and_return(enable_gpo_verification) - decorated_session = instance_double(ServiceProviderSessionDecorator, sp_name: sp_name) allow(view).to receive(:decorated_session).and_return(decorated_session) + assign(:gpo_letter_available, gpo_letter_available) assign(:remaining_attempts, remaining_attempts) @@ -43,7 +41,7 @@ end context 'gpo verification enabled' do - let(:enable_gpo_verification) { true } + let(:gpo_letter_available) { true } it 'renders a list of troubleshooting options' do expect(rendered).to have_link( From 344b0fa847c3099027a21736689c70d66cadabb3 Mon Sep 17 00:00:00 2001 From: Manish Shah <97545428+gsa-manish@users.noreply.github.com> Date: Thu, 5 May 2022 12:49:11 -0400 Subject: [PATCH 15/29] document-analytics-11-updates (#6314) changelog: Analytics, Document authorization, updates --- app/forms/idv/api_image_upload_form.rb | 1 + app/services/analytics_events.rb | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/app/forms/idv/api_image_upload_form.rb b/app/forms/idv/api_image_upload_form.rb index c9f1c79d3b0..9a047c4ae86 100644 --- a/app/forms/idv/api_image_upload_form.rb +++ b/app/forms/idv/api_image_upload_form.rb @@ -218,6 +218,7 @@ def update_analytics(client_response) **client_response.to_h.merge( client_image_metrics: image_metadata, async: false, + flow_path: params[:flow_path], ), ) end diff --git a/app/services/analytics_events.rb b/app/services/analytics_events.rb index 597b61b81c9..e739bae42b6 100644 --- a/app/services/analytics_events.rb +++ b/app/services/analytics_events.rb @@ -473,11 +473,13 @@ def idv_doc_auth_exception_visited(step_name:, remaining_attempts:, **extra) # @param [Integer] remaining_attempts # @param [String] user_id # @param [String] flow_path - # The document capture image uploaded was locally validated during the IDV process + # The document capture image uploaded was locally validated during the IDV process def idv_doc_auth_submitted_image_upload_form( success:, errors:, - remaining_attempts:, flow_path:, attempts: nil, + remaining_attempts:, + flow_path:, + attempts: nil, user_id: nil, **extra ) From 3054a03a2aadbcca06fb58e24b9b789924f58021 Mon Sep 17 00:00:00 2001 From: Andrew Duthie Date: Thu, 5 May 2022 13:39:20 -0400 Subject: [PATCH 16/29] Translate labels for IdV app step indicator (#6310) * Translate labels for IdV app step indicator **Why**: So that labels are shown in the user's preferred language. changelog: Upcoming Features, Identity Verification, Add personal key step screen * Create type for step indicator steps See: https://github.com/18F/identity-idp/pull/6310/files#r866030157 Co-Authored-By: Zach Margolis Co-authored-by: Zach Margolis --- .../verify-flow-step-indicator.spec.tsx | 35 ++++++++ .../verify-flow-step-indicator.tsx | 80 +++++++++++++++++++ .../packages/verify-flow/verify-flow.tsx | 20 ++--- 3 files changed, 123 insertions(+), 12 deletions(-) create mode 100644 app/javascript/packages/verify-flow/verify-flow-step-indicator.spec.tsx create mode 100644 app/javascript/packages/verify-flow/verify-flow-step-indicator.tsx diff --git a/app/javascript/packages/verify-flow/verify-flow-step-indicator.spec.tsx b/app/javascript/packages/verify-flow/verify-flow-step-indicator.spec.tsx new file mode 100644 index 00000000000..7c2086a2257 --- /dev/null +++ b/app/javascript/packages/verify-flow/verify-flow-step-indicator.spec.tsx @@ -0,0 +1,35 @@ +import { render } from '@testing-library/react'; +import { StepStatus } from '@18f/identity-step-indicator'; +import VerifyFlowStepIndicator, { getStepStatus } from './verify-flow-step-indicator'; + +describe('getStepStatus', () => { + it('returns incomplete if step is after current step', () => { + const result = getStepStatus(1, 0); + + expect(result).to.equal(StepStatus.INCOMPLETE); + }); + + it('returns current if step is current step', () => { + const result = getStepStatus(1, 1); + + expect(result).to.equal(StepStatus.CURRENT); + }); + + it('returns complete if step is before current step', () => { + const result = getStepStatus(0, 1); + + expect(result).to.equal(StepStatus.COMPLETE); + }); +}); + +describe('VerifyFlowStepIndicator', () => { + it('renders step indicator for the current step', () => { + const { getByText } = render(); + + const current = getByText('step_indicator.flows.idv.secure_account'); + expect(current.closest('.step-indicator__step--current')).to.exist(); + + const previous = getByText('step_indicator.flows.idv.verify_phone_or_address'); + expect(previous.closest('.step-indicator__step--complete')).to.exist(); + }); +}); diff --git a/app/javascript/packages/verify-flow/verify-flow-step-indicator.tsx b/app/javascript/packages/verify-flow/verify-flow-step-indicator.tsx new file mode 100644 index 00000000000..8b134f51db5 --- /dev/null +++ b/app/javascript/packages/verify-flow/verify-flow-step-indicator.tsx @@ -0,0 +1,80 @@ +import { StepIndicator, StepIndicatorStep, StepStatus } from '@18f/identity-step-indicator'; +import { t } from '@18f/identity-i18n'; + +// i18n-tasks-use t('step_indicator.flows.idv.getting_started') +// i18n-tasks-use t('step_indicator.flows.idv.verify_id') +// i18n-tasks-use t('step_indicator.flows.idv.verify_info') +// i18n-tasks-use t('step_indicator.flows.idv.verify_phone_or_address') +// i18n-tasks-use t('step_indicator.flows.idv.secure_account') + +type VerifyFlowStepIndicatorStep = + | 'getting_started' + | 'verify_id' + | 'verify_info' + | 'verify_phone_or_address' + | 'secure_account'; + +/** + * Mapping of flow form steps to corresponding step indicator step. + */ +const FLOW_STEP_STEP_MAPPING: Record = { + personal_key: 'secure_account', + personal_key_confirm: 'secure_account', +}; + +/** + * Sequence of step indicator steps. + */ +const STEP_INDICATOR_STEPS: VerifyFlowStepIndicatorStep[] = [ + 'getting_started', + 'verify_id', + 'verify_info', + 'verify_phone_or_address', + 'secure_account', +]; + +interface VerifyFlowStepIndicatorProps { + /** + * Current step name. + */ + currentStep: string; +} + +/** + * Given an index of a step and the current step index, returns the status of the step relative to + * the current step. + * + * @param index Index of step against which to compare current step. + * @param currentStepIndex Index of current step. + * + * @return Step status. + */ +export function getStepStatus(index, currentStepIndex): StepStatus { + if (index === currentStepIndex) { + return StepStatus.CURRENT; + } + + if (index < currentStepIndex) { + return StepStatus.COMPLETE; + } + + return StepStatus.INCOMPLETE; +} + +function VerifyFlowStepIndicator({ currentStep }: VerifyFlowStepIndicatorProps) { + const currentStepIndex = STEP_INDICATOR_STEPS.indexOf(FLOW_STEP_STEP_MAPPING[currentStep]); + + return ( + + {STEP_INDICATOR_STEPS.map((step, index) => ( + + ))} + + ); +} + +export default VerifyFlowStepIndicator; diff --git a/app/javascript/packages/verify-flow/verify-flow.tsx b/app/javascript/packages/verify-flow/verify-flow.tsx index 6fce4686195..54623ebdde7 100644 --- a/app/javascript/packages/verify-flow/verify-flow.tsx +++ b/app/javascript/packages/verify-flow/verify-flow.tsx @@ -1,9 +1,9 @@ -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { FormSteps } from '@18f/identity-form-steps'; -import { StepIndicator, StepIndicatorStep, StepStatus } from '@18f/identity-step-indicator'; import { t } from '@18f/identity-i18n'; import { Alert } from '@18f/identity-components'; import { trackEvent } from '@18f/identity-analytics'; +import VerifyFlowStepIndicator from './verify-flow-step-indicator'; import { STEPS } from './steps'; export interface VerifyFlowValues { @@ -67,9 +67,11 @@ export function VerifyFlow({ appName, onComplete, }: VerifyFlowProps) { + const [currentStep, setCurrentStep] = useState(STEPS[0].name); + useEffect(() => { - logStepVisited(STEPS[0].name); - }, []); + logStepVisited(currentStep); + }, [currentStep]); let steps = STEPS; if (enabledStepNames) { @@ -78,13 +80,7 @@ export function VerifyFlow({ return ( <> - - - - - - - + {t('idv.messages.confirm')} @@ -95,7 +91,7 @@ export function VerifyFlow({ basePath={basePath} titleFormat={`%{step} - ${appName}`} onStepSubmit={logStepSubmitted} - onStepChange={logStepVisited} + onStepChange={setCurrentStep} onComplete={onComplete} /> From 9945346f86aadeb8345baee9cc8c6d893f6952cf Mon Sep 17 00:00:00 2001 From: Mitchell Henke Date: Thu, 5 May 2022 12:53:02 -0500 Subject: [PATCH 17/29] Remove references to deprecated and renamed session keys (#6286) changelog: Internal, Maintenance, Remove references to deprecated and renamed session keys --- app/services/reactivate_account_session.rb | 10 +++------- spec/services/reactivate_account_session_spec.rb | 3 +-- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/app/services/reactivate_account_session.rb b/app/services/reactivate_account_session.rb index ab68fe00faf..2729264d465 100644 --- a/app/services/reactivate_account_session.rb +++ b/app/services/reactivate_account_session.rb @@ -27,23 +27,21 @@ def suspend # Stores PII as a string in the session # @param [Pii::Attributes] def store_decrypted_pii(pii) - reactivate_account_session[:personal_key] = true reactivate_account_session[:validated_personal_key] = true pii_json = pii.to_json - reactivate_account_session[:pii] = pii_json Pii::Cacher.new(@user, session).save_decrypted_pii_json(pii_json) nil end def validated_personal_key? - reactivate_account_session[:personal_key] + reactivate_account_session[:validated_personal_key] end # Parses string into PII struct # @return [Pii::Attributes, nil] def decrypted_pii - json_str = reactivate_account_session[:pii] - Pii::Attributes.new_from_json(json_str) if json_str + return unless validated_personal_key? + Pii::Cacher.new(@user, session).fetch end private @@ -53,9 +51,7 @@ def decrypted_pii def generate_session { active: false, - personal_key: false, validated_personal_key: false, - pii: nil, x509: nil, } end diff --git a/spec/services/reactivate_account_session_spec.rb b/spec/services/reactivate_account_session_spec.rb index 3da4f3ee530..534711233d6 100644 --- a/spec/services/reactivate_account_session_spec.rb +++ b/spec/services/reactivate_account_session_spec.rb @@ -63,9 +63,8 @@ pii = Pii::Attributes.new(first_name: 'Test') @reactivate_account_session.store_decrypted_pii(pii) account_reactivation_obj = user_session[:reactivate_account] - expect(account_reactivation_obj[:personal_key]).to be(true) expect(account_reactivation_obj[:validated_personal_key]).to be(true) - expect(account_reactivation_obj[:pii]).to eq(pii.to_json) + expect(user_session[:decrypted_pii]).to eq(pii.to_json) end end From 6f051ab5990bfc92020136ed36f35ca2bad766a3 Mon Sep 17 00:00:00 2001 From: Doug Price Date: Thu, 5 May 2022 14:41:10 -0400 Subject: [PATCH 18/29] LG-6204/LG-6220: capture user pii in a signed JWT and pass to frontend (#6282) * LG-6204/LG-6220: capture user pii in a signed JWT and pass to frontend skip changelog * unpack the pii from the user token and make data available to the flow * rename 'UserBundleTokenizer#call' to 'UserBundleTokenizer#token' * cleanup [skip changelog] * update param name in CompleteController#create * parse just the payload of the jwt Co-authored-by: Andrew Duthie * don't include service provider in jwt until/unless we need it Co-authored-by: Andrew Duthie --- .../api/verify/complete_controller.rb | 4 +- app/controllers/verify_controller.rb | 12 +++++- .../packages/verify-flow/verify-flow.tsx | 18 +++++++++ app/javascript/packs/verify-flow.tsx | 14 +++++++ app/services/idv/user_bundle_tokenizer.rb | 40 +++++++++++++++++++ .../api/verify/complete_controller_spec.rb | 8 ++-- .../idv/user_bundle_tokenizer_spec.rb | 39 ++++++++++++++++++ 7 files changed, 128 insertions(+), 7 deletions(-) create mode 100644 app/services/idv/user_bundle_tokenizer.rb create mode 100644 spec/services/idv/user_bundle_tokenizer_spec.rb diff --git a/app/controllers/api/verify/complete_controller.rb b/app/controllers/api/verify/complete_controller.rb index 8e7e08b0b3e..0cfd610a172 100644 --- a/app/controllers/api/verify/complete_controller.rb +++ b/app/controllers/api/verify/complete_controller.rb @@ -4,7 +4,7 @@ class CompleteController < Api::BaseController def create result, personal_key = Api::ProfileCreationForm.new( password: verify_params[:password], - jwt: verify_params[:details], + jwt: verify_params[:user_bundle_token], user_session: user_session, service_provider: current_sp, ).submit @@ -23,7 +23,7 @@ def create private def verify_params - params.permit(:password, :details) + params.permit(:password, :user_bundle_token) end def add_proofing_component(user) diff --git a/app/controllers/verify_controller.rb b/app/controllers/verify_controller.rb index 6bc019ecc27..68cff80cc46 100644 --- a/app/controllers/verify_controller.rb +++ b/app/controllers/verify_controller.rb @@ -21,7 +21,10 @@ def app_data base_path: idv_app_root_path, app_name: APP_NAME, completion_url: completion_url, - initial_values: { 'personalKey' => personal_key }, + initial_values: { + 'personalKey' => personal_key, + 'userBundleToken' => user_bundle_token, + }, enabled_step_names: IdentityConfig.store.idv_api_enabled_steps, store_key: user_session[:idv_api_store_key], } @@ -51,4 +54,11 @@ def completion_url after_sign_in_path_for(current_user) end end + + def user_bundle_token + Idv::UserBundleTokenizer.new( + user: current_user, + idv_session: idv_session, + ).token + end end diff --git a/app/javascript/packages/verify-flow/verify-flow.tsx b/app/javascript/packages/verify-flow/verify-flow.tsx index 54623ebdde7..88a18800b0d 100644 --- a/app/javascript/packages/verify-flow/verify-flow.tsx +++ b/app/javascript/packages/verify-flow/verify-flow.tsx @@ -10,6 +10,24 @@ export interface VerifyFlowValues { personalKey?: string; personalKeyConfirm?: string; + + firstName?: string; + + lastName?: string; + + address1?: string; + + address2?: string; + + city?: string; + + state?: string; + + zipcode?: string; + + phone?: string; + + ssn?: string; } interface VerifyFlowProps { diff --git a/app/javascript/packs/verify-flow.tsx b/app/javascript/packs/verify-flow.tsx index 495aad0f70e..2abeccff602 100644 --- a/app/javascript/packs/verify-flow.tsx +++ b/app/javascript/packs/verify-flow.tsx @@ -32,6 +32,11 @@ interface AppRootValues { * Base64-encoded encryption key for secret session store. */ storeKey: string; + + /** + * Signed JWT containing user data. + */ + userBundleToken: string; } interface AppRootElement extends HTMLElement { @@ -51,6 +56,15 @@ const storeKey = s2ab(atob(storeKeyBase64)); const initialValues = JSON.parse(initialValuesJSON); const enabledStepNames = JSON.parse(enabledStepNamesJSON) as string[]; +const camelCase = (string: string) => + string.replace(/[^a-z]([a-z])/gi, (_match, nextLetter) => nextLetter.toUpperCase()); + +const jwtData = JSON.parse(atob(initialValues.userBundleToken.split('.')[1])); +const pii = Object.fromEntries( + Object.entries(jwtData.pii).map(([key, value]) => [camelCase(key), value]), +); +Object.assign(initialValues, pii); + function onComplete() { window.location.href = completionURL; } diff --git a/app/services/idv/user_bundle_tokenizer.rb b/app/services/idv/user_bundle_tokenizer.rb new file mode 100644 index 00000000000..c077232f137 --- /dev/null +++ b/app/services/idv/user_bundle_tokenizer.rb @@ -0,0 +1,40 @@ +module Idv + class UserBundleTokenizer + def initialize(user:, idv_session:) + @user = user + @idv_session = idv_session + end + + def token + JWT.encode( + { + # for now, load whatever pii is saved in the session + pii: idv_session.applicant, + metadata: metadata, + }, + private_key, + 'RS256', + sub: user.uuid, + ) + end + + private + + attr_reader :user, :idv_session + + def private_key + OpenSSL::PKey::RSA.new(Base64.strict_decode64(IdentityConfig.store.idv_private_key)) + end + + def metadata + # populate with anything from the session we'll need later on + data = {} + + data[:address_verification_mechanism] = idv_session.address_verification_mechanism + data[:user_phone_confirmation] = idv_session.user_phone_confirmation + data[:vendor_phone_confirmation] = idv_session.vendor_phone_confirmation + + data + end + end +end diff --git a/spec/controllers/api/verify/complete_controller_spec.rb b/spec/controllers/api/verify/complete_controller_spec.rb index e5dd15f0900..ddb66b0a3d5 100644 --- a/spec/controllers/api/verify/complete_controller_spec.rb +++ b/spec/controllers/api/verify/complete_controller_spec.rb @@ -46,7 +46,7 @@ def stub_idv_session describe '#create' do context 'when the user is not signed in and submits the password' do it 'does not create a profile or return a key' do - post :create, params: { password: 'iambatman', details: jwt } + post :create, params: { password: 'iambatman', user_bundle_token: jwt } expect(JSON.parse(response.body)['personal_key']).to be_nil expect(response.status).to eq 401 expect(JSON.parse(response.body)['error']).to eq 'user is not fully authenticated' @@ -59,13 +59,13 @@ def stub_idv_session end it 'creates a profile and returns a key' do - post :create, params: { password: 'iambatman', details: jwt } + post :create, params: { password: 'iambatman', user_bundle_token: jwt } expect(JSON.parse(response.body)['personal_key']).not_to be_nil expect(response.status).to eq 200 end it 'does not create a profile and return a key when it has the wrong password' do - post :create, params: { password: 'iamnotbatman', details: jwt } + post :create, params: { password: 'iamnotbatman', user_bundle_token: jwt } expect(JSON.parse(response.body)['personal_key']).to be_nil expect(response.status).to eq 400 end @@ -77,7 +77,7 @@ def stub_idv_session end it 'responds with not found' do - post :create, params: { password: 'iambatman', details: jwt }, as: :json + post :create, params: { password: 'iambatman', user_bundle_token: jwt }, as: :json expect(response.status).to eq 404 expect(JSON.parse(response.body)['error']). to eq "The page you were looking for doesn't exist" diff --git a/spec/services/idv/user_bundle_tokenizer_spec.rb b/spec/services/idv/user_bundle_tokenizer_spec.rb new file mode 100644 index 00000000000..58678741f99 --- /dev/null +++ b/spec/services/idv/user_bundle_tokenizer_spec.rb @@ -0,0 +1,39 @@ +require 'rails_helper' + +RSpec.describe Idv::UserBundleTokenizer do + let(:public_key) do + OpenSSL::PKey::RSA.new(Base64.strict_decode64(IdentityConfig.store.idv_public_key)) + end + let(:user) { create(:user) } + let(:sp) { create(:service_provider) } + let(:user_session) do + { + idv: { + applicant: { + 'first_name' => 'Ada', + 'last_name' => 'Lovelace', + 'ssn' => '900900900', + 'phone' => '+1 410-555-1212', + }, + address_verification_mechanism: 'phone', + user_phone_confirmation: true, + vendor_phone_confirmation: true, + }, + } + end + let(:idv_session) do + Idv::Session.new(user_session: user_session, current_user: user, service_provider: sp) + end + subject do + Idv::UserBundleTokenizer.new(user: user, idv_session: idv_session) + end + + context 'when initialized with data' do + it 'encodes a signed JWT' do + token = subject.token + decorator = Api::UserBundleDecorator.new(user_bundle: token, public_key: public_key) + + expect(decorator.pii).to eq user_session[:idv][:applicant] + end + end +end From 5281e36437a5b5f57b1effed7884221766b3e8c5 Mon Sep 17 00:00:00 2001 From: Andrew Duthie Date: Thu, 5 May 2022 16:32:03 -0400 Subject: [PATCH 19/29] Show IdV app alert message relevant for current step (#6311) * Show IdV app alert message relevant for current step **Why**: So that the personal key success alert message won't be shown for all steps, as we continue to expand the flow. changelog: Upcoming Features, Identity Verification, Add password confirmation step * Add specs * Extract getStepMessage Avoid clunky switch assignment See: https://github.com/18F/identity-idp/pull/6311#discussion_r866194479 --- app/javascript/packages/verify-flow/index.tsx | 2 +- .../verify-flow/verify-flow-alert.spec.tsx | 25 +++++++++++++ .../verify-flow/verify-flow-alert.tsx | 35 +++++++++++++++++++ .../{index.spec.tsx => verify-flow.spec.tsx} | 7 +++- .../packages/verify-flow/verify-flow.tsx | 14 ++++---- 5 files changed, 73 insertions(+), 10 deletions(-) create mode 100644 app/javascript/packages/verify-flow/verify-flow-alert.spec.tsx create mode 100644 app/javascript/packages/verify-flow/verify-flow-alert.tsx rename app/javascript/packages/verify-flow/{index.spec.tsx => verify-flow.spec.tsx} (89%) diff --git a/app/javascript/packages/verify-flow/index.tsx b/app/javascript/packages/verify-flow/index.tsx index 9cb8593ad02..d98e4e84e5d 100644 --- a/app/javascript/packages/verify-flow/index.tsx +++ b/app/javascript/packages/verify-flow/index.tsx @@ -1,2 +1,2 @@ export { SecretsContextProvider } from './context/secrets-context'; -export { VerifyFlow } from './verify-flow'; +export { default as VerifyFlow } from './verify-flow'; diff --git a/app/javascript/packages/verify-flow/verify-flow-alert.spec.tsx b/app/javascript/packages/verify-flow/verify-flow-alert.spec.tsx new file mode 100644 index 00000000000..2e29e25af29 --- /dev/null +++ b/app/javascript/packages/verify-flow/verify-flow-alert.spec.tsx @@ -0,0 +1,25 @@ +import { render } from '@testing-library/react'; +import VerifyFlowAlert from './verify-flow-alert'; + +describe('VerifyFlowAlert', () => { + context('step with a status message', () => { + [ + ['personal_key', 'idv.messages.confirm'], + ['personal_key_confirm', 'idv.messages.confirm'], + ].forEach(([step, expected]) => { + it('renders status message', () => { + const { getByRole } = render(); + + expect(getByRole('status').textContent).equal(expected); + }); + }); + }); + + context('step without a status message', () => { + it('renders nothing', () => { + const { container } = render(); + + expect(container.innerHTML).to.be.empty(); + }); + }); +}); diff --git a/app/javascript/packages/verify-flow/verify-flow-alert.tsx b/app/javascript/packages/verify-flow/verify-flow-alert.tsx new file mode 100644 index 00000000000..29a8f670d8f --- /dev/null +++ b/app/javascript/packages/verify-flow/verify-flow-alert.tsx @@ -0,0 +1,35 @@ +import { t } from '@18f/identity-i18n'; +import { Alert } from '@18f/identity-components'; + +interface VerifyFlowAlertProps { + /** + * Current step name. + */ + currentStep: string; +} + +/** + * Returns the status message to show for a given step, if applicable. + * + * @param stepName Step name. + */ +function getStepMessage(stepName: string): string | undefined { + if (stepName === 'personal_key' || stepName === 'personal_key_confirm') { + return t('idv.messages.confirm'); + } +} + +function VerifyFlowAlert({ currentStep }: VerifyFlowAlertProps) { + const message = getStepMessage(currentStep); + if (!message) { + return null; + } + + return ( + + {message} + + ); +} + +export default VerifyFlowAlert; diff --git a/app/javascript/packages/verify-flow/index.spec.tsx b/app/javascript/packages/verify-flow/verify-flow.spec.tsx similarity index 89% rename from app/javascript/packages/verify-flow/index.spec.tsx rename to app/javascript/packages/verify-flow/verify-flow.spec.tsx index c5252cdd08f..423112dadb1 100644 --- a/app/javascript/packages/verify-flow/index.spec.tsx +++ b/app/javascript/packages/verify-flow/verify-flow.spec.tsx @@ -2,7 +2,7 @@ import sinon from 'sinon'; import { render } from '@testing-library/react'; import * as analytics from '@18f/identity-analytics'; import userEvent from '@testing-library/user-event'; -import { VerifyFlow } from './index'; +import VerifyFlow from './verify-flow'; describe('VerifyFlow', () => { const sandbox = sinon.createSandbox(); @@ -23,7 +23,12 @@ describe('VerifyFlow', () => { , ); + // Personal key + expect(getByText('idv.messages.confirm')).to.be.ok(); await userEvent.click(getByText('forms.buttons.continue')); + + // Personal key confirm + expect(getByText('idv.messages.confirm')).to.be.ok(); await userEvent.type(getByLabelText('forms.personal_key.confirmation_label'), personalKey); await userEvent.keyboard('{Enter}'); diff --git a/app/javascript/packages/verify-flow/verify-flow.tsx b/app/javascript/packages/verify-flow/verify-flow.tsx index 88a18800b0d..d1598c49db1 100644 --- a/app/javascript/packages/verify-flow/verify-flow.tsx +++ b/app/javascript/packages/verify-flow/verify-flow.tsx @@ -1,10 +1,9 @@ import { useEffect, useState } from 'react'; import { FormSteps } from '@18f/identity-form-steps'; -import { t } from '@18f/identity-i18n'; -import { Alert } from '@18f/identity-components'; import { trackEvent } from '@18f/identity-analytics'; -import VerifyFlowStepIndicator from './verify-flow-step-indicator'; import { STEPS } from './steps'; +import VerifyFlowStepIndicator from './verify-flow-step-indicator'; +import VerifyFlowAlert from './verify-flow-alert'; export interface VerifyFlowValues { personalKey?: string; @@ -78,7 +77,7 @@ const logStepVisited = (stepName: string) => const logStepSubmitted = (stepName: string) => trackEvent(`IdV: ${getEventStepName(stepName)} submitted`); -export function VerifyFlow({ +function VerifyFlow({ initialValues = {}, enabledStepNames, basePath, @@ -86,7 +85,6 @@ export function VerifyFlow({ onComplete, }: VerifyFlowProps) { const [currentStep, setCurrentStep] = useState(STEPS[0].name); - useEffect(() => { logStepVisited(currentStep); }, [currentStep]); @@ -99,9 +97,7 @@ export function VerifyFlow({ return ( <> - - {t('idv.messages.confirm')} - + ); } + +export default VerifyFlow; From 4f08a78de3db66f3be9d3b3797f473b3b5205da7 Mon Sep 17 00:00:00 2001 From: Mitchell Henke Date: Fri, 6 May 2022 13:26:09 -0500 Subject: [PATCH 20/29] Use superclass to catch Pinpoint errors (#6320) changelog: Bug Fixes, Telephony, Improve error handling when receiving unexpected telephony API responses --- lib/telephony/pinpoint/sms_sender.rb | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/telephony/pinpoint/sms_sender.rb b/lib/telephony/pinpoint/sms_sender.rb index 6780a24dfd1..91e594f3558 100644 --- a/lib/telephony/pinpoint/sms_sender.rb +++ b/lib/telephony/pinpoint/sms_sender.rb @@ -57,8 +57,7 @@ def send(message:, to:, country_code:, otp: nil) channel: :sms, extra: response.extra, ) - rescue Aws::Pinpoint::Errors::InternalServerErrorException, - Aws::Pinpoint::Errors::TooManyRequestsException, + rescue Aws::Pinpoint::Errors::ServiceError, Seahorse::Client::NetworkingError => e finish = Time.zone.now response = handle_pinpoint_error(e) From c05f26a9be4c1a4fecf8acd5fa05a9d7e49e8ab1 Mon Sep 17 00:00:00 2001 From: Andrew Duthie Date: Fri, 6 May 2022 14:27:05 -0400 Subject: [PATCH 21/29] LG-6193: Enable personal key step in development environment (#6229) * Enable idv_api_enabled_steps in development **Why**: Since it's reasonably functional * Handle JavaScript context for click_acknowledge_personal_key * Revert click_acknowledge_personal_key general behavior Some tests expect it only to open the modal, not confirm. Update "'idv confirmation step'" shared examples instead * Update focus element name assertion Test instead by user-facing label * Enable JS for GPO disabled verify flow * Improve compatibility for new personal key verify step * Use JS driver for OIDC specs IDV scenario * Don't disambiguate button click labels Because it will highlight where we're using no-JS on this page, and we should update them all to use JS * Opt-in more specs visiting personal key to JS * Confirm personal key for JS-enabled specs Previously, most of these specs had JS disabled, so it was expected the user would continue to the next step immediately upon clicking personal key "Continue", since we didn't have the modal confirmation in no-JS contexts. But now we're requiring JS for this step, so modal will be shown, and user must enter and confirm their personal key. * Avoid referencing "_url" when visiting pages Since they use 'example.com' as host (should they be?) * Improve personal key helper logic to be generically acceptable don't rely on specific IDs or CSS classes, check for content instead * Improve reproof after lockout JS compatibility (1) don't rely on URL, since domain name is incorrect (2) don't complete redirect back to SP, since there is no server accepting requests on that port * Remove JS-specific SAML override let's see what breaks, cuz its presence is currently breaking some specs * Check hidden content for SSN on confirmation screen If run with JS enabled, the text is hidden by default, but can be toggled as visible. The unmasked SSN exists in content as hidden. * Try using capybara-webmock to mock external requests Since capybara JS drivers run requests in a real browser, redirected SP requests will 404 * Try skipping response_headers checking for JS-enabled specs * Slowly devolving to desparation * Limit ACS_URL override to JavaScript drivers Where page.server.host is reliably defined * Update Sp attribute redirect URL test for JS ACS_URL Since the user would actually be redirected in a real browser * Use Rack driver for OIDC confirmation via page.driver.post * Update sign_in.rb * Update sign_in.rb * Guard profile encryption for valid user Presumably we relied previously on PII being false in most cases. A handful of tests create a profile without a valid user attached to it, so now that we're assigning default PII for profiles, we should also only actually encrypt it if there's a valid user * Re-enable SAML handoff path assertion * Revert some now-hopefully-unnecessary URL -> path updates In 259c213 we're now reliably setting default_url_options so that the URL will be generated correctly and we don't have to test path * Require PII opt-in for profile stubs too many tests assume it won't be there (probably a problem worth resolving) * Force JS interactivity for non-interactable elements Selenium::WebDriver::Error::ElementNotInteractableError: element not interactable * Drop CSP check on JS requests Capybara::NotSupportedByDriverError: Capybara::Driver::Base#response_headers shouldn't redirect if invalid CSP target? * Add changelog changelog: Upcoming Features, Identity Verification, Add personal key step screen * Enable personal key steps everywhere but production So that they're enabled in test * Fix enabled steps referenced as strings, not symbols * Enable JS for proofing component feature spec * Update complete_proofing_steps to confirm personal key * Refactor specs for personal key enabled by default in test env * Opt-in review feature spec * Opt-in strict reproof specs * Update accessibility spec URL assertions * Check current path in array * Expand PII accordion before asserting content JS browser would not have visibility to content otherwise * Acknowledge personal key in new JS-enabled specs * Reuse common helper for cross-feature compat * Open personal key confirmation modal when JS enabled * Escape value of xml_doc in SAML test view Co-authored-by: Zach Margolis * Allow some delay for phone -> review When JS is enabled, previous step triggers spinner button, and there may be a brief delay before review step is shown. Avoid spec flakiness by allowing some wait for the review path to be shown. precedent: https://github.com/18F/identity-idp/blob/e7501424b59f887aa12bd255f69de03502969fa0/spec/features/idv/proofing_components_spec.rb#L25 * More JS * Revert to personal key enabled in development only So that tests run against the in-production version, but we re-run personal key pages with both side of the toggle * Use click_idv_continue for phone step progression Because it's a spinner button, we need to be able to wait for navigation to complete Co-authored-by: Zach Margolis --- Gemfile | 1 + Gemfile.lock | 16 +++ app/controllers/idv/review_controller.rb | 2 +- .../test/saml_test/decode_response.html.erb | 1 + config/application.yml.default | 1 + .../api/verify/complete_controller_spec.rb | 2 +- .../controllers/idv/review_controller_spec.rb | 4 +- spec/controllers/verify_controller_spec.rb | 2 +- spec/factories/profiles.rb | 9 ++ spec/features/accessibility/idv_pages_spec.rb | 6 +- .../idv/clearing_and_restarting_spec.rb | 4 +- spec/features/idv/gpo_disabled_spec.rb | 4 +- spec/features/idv/proofing_components_spec.rb | 7 +- .../idv/steps/confirmation_step_spec.rb | 31 ++++- spec/features/idv/steps/gpo_step_spec.rb | 6 +- spec/features/idv/steps/review_step_spec.rb | 6 +- spec/features/idv/strict_ial2/upgrade_spec.rb | 10 +- .../usps_upload_disallowed_spec.rb | 6 +- spec/features/idv/uak_password_spec.rb | 4 +- .../openid_connect/openid_connect_spec.rb | 34 +++--- spec/features/saml/ial2_sso_spec.rb | 6 +- spec/features/sp_cost_tracking_spec.rb | 10 +- .../users/regenerate_personal_key_spec.rb | 17 ++- spec/features/users/user_profile_spec.rb | 4 +- spec/lib/feature_management_spec.rb | 2 +- spec/rails_helper.rb | 3 + spec/support/capybara.rb | 2 + spec/support/features/doc_auth_helper.rb | 3 +- spec/support/features/idv_from_sp_helper.rb | 8 +- spec/support/features/idv_helper.rb | 8 +- spec/support/features/personal_key_helper.rb | 2 +- spec/support/features/session_helper.rb | 13 +- spec/support/features/webauthn_helper.rb | 16 ++- .../idv_examples/clearing_and_restarting.rb | 8 +- .../support/idv_examples/confirmation_step.rb | 8 +- spec/support/idv_examples/max_attempts.rb | 4 +- spec/support/idv_examples/sp_handoff.rb | 114 ++++++++++-------- .../idv_examples/sp_requested_attributes.rb | 16 ++- spec/support/saml_response_doc.rb | 6 +- .../shared_examples/account_creation.rb | 22 +--- spec/support/shared_examples/sign_in.rb | 43 ++++--- .../shared_examples_for_personal_keys.rb | 8 +- spec/support/sp_auth_helper.rb | 2 +- 43 files changed, 283 insertions(+), 198 deletions(-) diff --git a/Gemfile b/Gemfile index 02cfd82138a..d3ee6239b52 100644 --- a/Gemfile +++ b/Gemfile @@ -87,6 +87,7 @@ group :development, :test do gem 'aws-sdk-cloudwatchlogs', require: false gem 'brakeman', require: false gem 'bullet', '>= 6.0.2' + gem 'capybara-webmock', git: 'https://github.com/hashrocket/capybara-webmock.git', ref: '63d790a0' gem 'data_uri', require: false gem 'erb_lint', '~> 0.1.0', require: false gem 'i18n-tasks', '>= 0.9.31' diff --git a/Gemfile.lock b/Gemfile.lock index a6abfc25d43..09284cc0357 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -36,6 +36,19 @@ GIT pkcs11 uuid +GIT + remote: https://github.com/hashrocket/capybara-webmock.git + revision: 63d790a0b6c779b9700634bfc153e25ccdeb3688 + ref: 63d790a0 + specs: + capybara-webmock (0.6.0) + capybara (>= 2.4, < 4) + rack (>= 1.4) + rack-proxy (>= 0.6.0) + rexml (>= 3.2) + selenium-webdriver (>= 4.0) + webrick (>= 1.7) + GEM remote: https://rubygems.org/ specs: @@ -442,6 +455,8 @@ GEM rack-headers_filter (0.0.1) rack-mini-profiler (2.3.3) rack (>= 1.2.0) + rack-proxy (0.7.2) + rack rack-test (1.1.0) rack (>= 1.0, < 3) rack-timeout (0.6.0) @@ -700,6 +715,7 @@ DEPENDENCIES bullet (>= 6.0.2) bundler-audit capybara-selenium (>= 0.0.6) + capybara-webmock! connection_pool cssbundling-rails data_uri diff --git a/app/controllers/idv/review_controller.rb b/app/controllers/idv/review_controller.rb index cfd6055e8cb..8688e2ac340 100644 --- a/app/controllers/idv/review_controller.rb +++ b/app/controllers/idv/review_controller.rb @@ -123,7 +123,7 @@ def next_step def idv_api_personal_key_step_enabled? return false if idv_session.address_verification_mechanism == 'gpo' - IdentityConfig.store.idv_api_enabled_steps.include?(:personal_key) + IdentityConfig.store.idv_api_enabled_steps.include?('personal_key') end end end diff --git a/app/views/test/saml_test/decode_response.html.erb b/app/views/test/saml_test/decode_response.html.erb index 23272b31a57..ec426e239e2 100644 --- a/app/views/test/saml_test/decode_response.html.erb +++ b/app/views/test/saml_test/decode_response.html.erb @@ -26,6 +26,7 @@ <%= link_to 'Open in New Window', "data:text/xml;charset=utf-8;base64,#{xml_doc}", target: '_blank', rel: 'noopener noreferrer' %> + <%= hidden_field_tag '', xml_doc, id: 'SAMLResponse' %> diff --git a/config/application.yml.default b/config/application.yml.default index 910d50efc5b..e3b75262343 100644 --- a/config/application.yml.default +++ b/config/application.yml.default @@ -281,6 +281,7 @@ development: hmac_fingerprinter_key: a2c813d4dca919340866ba58063e4072adc459b767a74cf2666d5c1eef3861db26708e7437abde1755eb24f4034386b0fea1850a1cb7e56bff8fae3cc6ade96c hmac_fingerprinter_key_queue: '["11111111111111111111111111111111", "22222222222222222222222222222222"]' identity_pki_local_dev: true + idv_api_enabled_steps: '["personal_key","personal_key_confirm"]' liveness_checking_enabled: true logins_per_ip_limit: 5 logo_upload_enabled: true diff --git a/spec/controllers/api/verify/complete_controller_spec.rb b/spec/controllers/api/verify/complete_controller_spec.rb index ddb66b0a3d5..4d5cecbc6e8 100644 --- a/spec/controllers/api/verify/complete_controller_spec.rb +++ b/spec/controllers/api/verify/complete_controller_spec.rb @@ -31,7 +31,7 @@ def stub_idv_session let(:jwt) { JWT.encode({ pii: pii, metadata: {} }, key, 'RS256', sub: user.uuid) } before do - allow(IdentityConfig.store).to receive(:idv_api_enabled_steps).and_return([:personal_key]) + allow(IdentityConfig.store).to receive(:idv_api_enabled_steps).and_return(['personal_key']) end describe 'before_actions' do diff --git a/spec/controllers/idv/review_controller_spec.rb b/spec/controllers/idv/review_controller_spec.rb index b8200ed3430..8a15471b633 100644 --- a/spec/controllers/idv/review_controller_spec.rb +++ b/spec/controllers/idv/review_controller_spec.rb @@ -352,7 +352,7 @@ def show context 'with idv app personal key step enabled' do before do allow(IdentityConfig.store).to receive(:idv_api_enabled_steps). - and_return([:personal_key]) + and_return(['personal_key']) end it 'redirects to idv app personal key path' do @@ -380,7 +380,7 @@ def show context 'with idv api personal key step enabled' do before do allow(IdentityConfig.store).to receive(:idv_api_enabled_steps). - and_return([:personal_key]) + and_return(['personal_key']) end it 'redirects to personal key path' do diff --git a/spec/controllers/verify_controller_spec.rb b/spec/controllers/verify_controller_spec.rb index 573506c2454..7a46452d12b 100644 --- a/spec/controllers/verify_controller_spec.rb +++ b/spec/controllers/verify_controller_spec.rb @@ -37,7 +37,7 @@ context 'with step feature-enabled' do before do allow(IdentityConfig.store).to receive(:idv_api_enabled_steps). - and_return([:personal_key, :personal_key_confirm]) + and_return(['personal_key', 'personal_key_confirm']) end it 'renders view' do diff --git a/spec/factories/profiles.rb b/spec/factories/profiles.rb index 2a21e321291..4f0cdb1414d 100644 --- a/spec/factories/profiles.rb +++ b/spec/factories/profiles.rb @@ -32,6 +32,15 @@ end end + trait :with_pii do + pii do + DocAuth::Mock::ResultResponseBuilder::DEFAULT_PII_FROM_DOC.merge( + ssn: DocAuthHelper::GOOD_SSN, + phone: '+1 (555) 555-1234', + ) + end + end + after(:build) do |profile, evaluator| if evaluator.pii pii_attrs = Pii::Attributes.new_from_hash(evaluator.pii) diff --git a/spec/features/accessibility/idv_pages_spec.rb b/spec/features/accessibility/idv_pages_spec.rb index 7188ee54ca8..398b30c1225 100644 --- a/spec/features/accessibility/idv_pages_spec.rb +++ b/spec/features/accessibility/idv_pages_spec.rb @@ -57,7 +57,7 @@ fill_in t('idv.form.password'), with: Features::SessionHelper::VALID_PASSWORD click_continue - expect(current_path).to eq idv_personal_key_path + expect(current_path).to be_in([idv_personal_key_path, idv_app_root_path]) expect(page).to be_axe_clean.according_to :section508, :"best-practice", :wcag21aa expect(page).to label_required_fields expect(page).to be_uniquely_titled @@ -71,7 +71,7 @@ fill_in t('idv.form.password'), with: Features::SessionHelper::VALID_PASSWORD click_continue - expect(current_path).to eq idv_personal_key_path + expect(current_path).to be_in([idv_personal_key_path, idv_app_root_path]) expect(page).to be_axe_clean.according_to :section508, :"best-practice", :wcag21aa expect(page).to label_required_fields expect(page).to be_uniquely_titled @@ -85,7 +85,7 @@ fill_in t('idv.form.password'), with: Features::SessionHelper::VALID_PASSWORD click_continue - expect(current_path).to eq idv_personal_key_path + expect(current_path).to be_in([idv_personal_key_path, idv_app_root_path]) expect(page).to be_axe_clean.according_to :section508, :"best-practice", :wcag21aa expect(page).to label_required_fields expect(page).to be_uniquely_titled diff --git a/spec/features/idv/clearing_and_restarting_spec.rb b/spec/features/idv/clearing_and_restarting_spec.rb index 842c18bc649..05e6d9d5603 100644 --- a/spec/features/idv/clearing_and_restarting_spec.rb +++ b/spec/features/idv/clearing_and_restarting_spec.rb @@ -5,11 +5,11 @@ let(:user) { user_with_2fa } - context 'during GPO otp verification' do + context 'during GPO otp verification', js: true do before do start_idv_from_sp complete_idv_steps_with_gpo_before_confirmation_step(user) - click_acknowledge_personal_key + acknowledge_and_confirm_personal_key end context 'before signing out' do diff --git a/spec/features/idv/gpo_disabled_spec.rb b/spec/features/idv/gpo_disabled_spec.rb index da29d4440b9..9f37df1be7c 100644 --- a/spec/features/idv/gpo_disabled_spec.rb +++ b/spec/features/idv/gpo_disabled_spec.rb @@ -17,7 +17,7 @@ Rails.application.reload_routes! end - it 'allows verification without the option to confirm address with usps' do + it 'allows verification without the option to confirm address with usps', js: true do user = user_with_2fa start_idv_from_sp complete_idv_steps_before_phone_step(user) @@ -36,7 +36,7 @@ click_submit_default fill_in 'Password', with: user.password click_continue - click_acknowledge_personal_key + acknowledge_and_confirm_personal_key expect(page).to have_current_path(sign_up_completed_path) end diff --git a/spec/features/idv/proofing_components_spec.rb b/spec/features/idv/proofing_components_spec.rb index f39f0d8a1cb..dfd0a0e68f1 100644 --- a/spec/features/idv/proofing_components_spec.rb +++ b/spec/features/idv/proofing_components_spec.rb @@ -21,11 +21,10 @@ expect(current_path).to eq idv_doc_auth_step_path(step: :welcome) complete_all_doc_auth_steps - click_continue - expect(page).to have_current_path('/verify/review', wait: 5) + click_idv_continue fill_in 'Password', with: Features::SessionHelper::VALID_PASSWORD click_continue - click_acknowledge_personal_key + acknowledge_and_confirm_personal_key end context 'async proofing', js: true do @@ -51,7 +50,7 @@ end end - it 'clears the liveness enabled proofing component when a user re-proofs without liveness' do + it 'clears liveness enabled proofing component when user re-proofs without liveness', js: true do allow(IdentityConfig.store).to receive(:liveness_checking_enabled).and_return(true) user = user_with_2fa sign_in_and_2fa_user(user) diff --git a/spec/features/idv/steps/confirmation_step_spec.rb b/spec/features/idv/steps/confirmation_step_spec.rb index b69c5a6dd1b..165e98c8e6e 100644 --- a/spec/features/idv/steps/confirmation_step_spec.rb +++ b/spec/features/idv/steps/confirmation_step_spec.rb @@ -1,6 +1,6 @@ require 'rails_helper' -feature 'idv confirmation step' do +feature 'idv confirmation step', js: true do include IdvStepHelper it_behaves_like 'idv confirmation step' @@ -24,4 +24,33 @@ it_behaves_like 'personal key page' end + + context 'with idv app feature enabled' do + before do + allow(IdentityConfig.store).to receive(:idv_api_enabled_steps). + and_return(['personal_key', 'personal_key_confirm']) + end + + it_behaves_like 'idv confirmation step' + it_behaves_like 'idv confirmation step', :oidc + it_behaves_like 'idv confirmation step', :saml + + context 'personal key information and actions' do + before do + @user = sign_in_and_2fa_user + + visit idv_path + + complete_idv_steps_before_confirmation_step(@user) + end + + it 'allows the user to refresh and still displays the personal key' do + # Visit the current path is the same as refreshing + visit current_path + expect(page).to have_content(t('headings.personal_key')) + end + + it_behaves_like 'personal key page' + end + end end diff --git a/spec/features/idv/steps/gpo_step_spec.rb b/spec/features/idv/steps/gpo_step_spec.rb index c909468438d..4a360b63e1d 100644 --- a/spec/features/idv/steps/gpo_step_spec.rb +++ b/spec/features/idv/steps/gpo_step_spec.rb @@ -24,7 +24,7 @@ context 'the user has sent a letter but not verified an OTP' do let(:user) { user_with_2fa } - it 'allows the user to resend a letter and redirects to the come back later step' do + it 'allows the user to resend a letter and redirects to the come back later step', js: true do complete_idv_and_return_to_gpo_step expect { click_on t('idv.buttons.mail.resend') }. @@ -34,7 +34,7 @@ expect(page).to have_current_path(idv_come_back_later_path) end - it 'allows the user to return to gpo otp confirmation' do + it 'allows the user to return to gpo otp confirmation', js: true do complete_idv_and_return_to_gpo_step click_doc_auth_back_link @@ -49,7 +49,7 @@ def complete_idv_and_return_to_gpo_step click_on t('idv.buttons.mail.send') fill_in 'Password', with: user_password click_continue - click_acknowledge_personal_key + acknowledge_and_confirm_personal_key visit root_path click_on t('idv.buttons.cancel') first(:link, t('links.sign_out')).click diff --git a/spec/features/idv/steps/review_step_spec.rb b/spec/features/idv/steps/review_step_spec.rb index d1f92c3b281..375d961e77b 100644 --- a/spec/features/idv/steps/review_step_spec.rb +++ b/spec/features/idv/steps/review_step_spec.rb @@ -9,10 +9,12 @@ expect(current_path).to eq(root_path) end - it 'requires the user to enter the correct password to redirect to confirmation step' do + it 'requires the user to enter the correct password to redirect to confirmation step', js: true do start_idv_from_sp complete_idv_steps_before_review_step + click_on t('idv.messages.review.intro') + expect(page).to have_content('FAKEY') expect(page).to have_content('MCFAKERSON') expect(page).to have_content('1 FAKE RD') @@ -31,7 +33,7 @@ click_idv_continue expect(page).to have_content(t('headings.personal_key')) - expect(page).to have_current_path(idv_personal_key_path) + expect(current_path).to be_in([idv_personal_key_path, idv_app_root_path]) end context 'choosing to confirm address with phone' do diff --git a/spec/features/idv/strict_ial2/upgrade_spec.rb b/spec/features/idv/strict_ial2/upgrade_spec.rb index 3848eb056a7..e41bc45294d 100644 --- a/spec/features/idv/strict_ial2/upgrade_spec.rb +++ b/spec/features/idv/strict_ial2/upgrade_spec.rb @@ -1,6 +1,6 @@ require 'rails_helper' -feature 'Strict IAL2 upgrade' do +feature 'Strict IAL2 upgrade', js: true do include IdvHelper include OidcAuthHelper include SamlAuthHelper @@ -22,10 +22,10 @@ expect(page.current_path).to eq(idv_doc_auth_welcome_step) complete_all_doc_auth_steps - click_continue + click_idv_continue fill_in 'Password', with: user.password click_continue - click_acknowledge_personal_key + acknowledge_and_confirm_personal_key click_agree_and_continue expect(current_url).to start_with('http://localhost:7654/auth/result') @@ -54,10 +54,10 @@ expect(page.current_path).to eq(idv_doc_auth_welcome_step) complete_all_doc_auth_steps - click_continue + click_idv_continue fill_in 'Password', with: user.password click_continue - click_acknowledge_personal_key + acknowledge_and_confirm_personal_key click_agree_and_continue expect(current_url).to start_with('http://localhost:7654/auth/result') diff --git a/spec/features/idv/strict_ial2/usps_upload_disallowed_spec.rb b/spec/features/idv/strict_ial2/usps_upload_disallowed_spec.rb index ec9a5c7e1fb..74d0a47ef5d 100644 --- a/spec/features/idv/strict_ial2/usps_upload_disallowed_spec.rb +++ b/spec/features/idv/strict_ial2/usps_upload_disallowed_spec.rb @@ -1,6 +1,6 @@ require 'rails_helper' -feature 'Strict IAL2 with usps upload disallowed' do +feature 'Strict IAL2 with usps upload disallowed', js: true do include IdvHelper include OidcAuthHelper include IdvHelper @@ -64,10 +64,10 @@ expect(current_path).to eq(idv_doc_auth_step_path(step: :welcome)) complete_all_doc_auth_steps - click_continue + click_idv_continue fill_in 'Password', with: user.password click_continue - click_acknowledge_personal_key + acknowledge_and_confirm_personal_key click_agree_and_continue expect(current_url).to start_with('http://localhost:7654/auth/result') diff --git a/spec/features/idv/uak_password_spec.rb b/spec/features/idv/uak_password_spec.rb index f9ed09daebe..e32ee8f0067 100644 --- a/spec/features/idv/uak_password_spec.rb +++ b/spec/features/idv/uak_password_spec.rb @@ -3,7 +3,7 @@ feature 'A user with a UAK passwords attempts IdV' do include IdvStepHelper - it 'allows the user to continue to the SP' do + it 'allows the user to continue to the SP', js: true do user = user_with_2fa user.update!( encrypted_password_digest: Encryption::UakPasswordVerifier.digest(user.password), @@ -12,7 +12,7 @@ start_idv_from_sp(:oidc) complete_idv_steps_with_phone_before_confirmation_step(user) - click_acknowledge_personal_key + acknowledge_and_confirm_personal_key expect(page).to have_current_path(sign_up_completed_path) diff --git a/spec/features/openid_connect/openid_connect_spec.rb b/spec/features/openid_connect/openid_connect_spec.rb index 5ccfe10a81a..d5a15efd7cf 100644 --- a/spec/features/openid_connect/openid_connect_spec.rb +++ b/spec/features/openid_connect/openid_connect_spec.rb @@ -244,7 +244,7 @@ to include('Verified within value must be at least 30 days or older') end - it 'sends the user through idv again via verified_within param' do + it 'sends the user through idv again via verified_within param', js: true do client_id = 'urn:gov:gsa:openidconnect:sp:server' user = user_with_2fa _profile = create( @@ -270,7 +270,7 @@ fill_in t('idv.form.password'), with: Features::SessionHelper::VALID_PASSWORD click_continue - acknowledge_and_confirm_personal_key(js: false) + acknowledge_and_confirm_personal_key end, handoff_page_steps: proc do expect(page).to have_content(t('help_text.requested_attributes.verified_at')) @@ -281,14 +281,16 @@ access_token = token_response[:access_token] expect(access_token).to be_present - page.driver.get api_openid_connect_userinfo_path, - {}, - 'HTTP_AUTHORIZATION' => "Bearer #{access_token}" + Capybara.using_driver(:desktop_rack_test) do + page.driver.get api_openid_connect_userinfo_path, + {}, + 'HTTP_AUTHORIZATION' => "Bearer #{access_token}" - userinfo_response = JSON.parse(page.body).with_indifferent_access - expect(userinfo_response[:email]).to eq(user.email) - expect(userinfo_response[:verified_at]).to be > 60.days.ago.to_i - expect(userinfo_response[:verified_at]).to eq(user.active_profile.verified_at.to_i) + userinfo_response = JSON.parse(page.body).with_indifferent_access + expect(userinfo_response[:email]).to eq(user.email) + expect(userinfo_response[:verified_at]).to be > 60.days.ago.to_i + expect(userinfo_response[:verified_at]).to eq(user.active_profile.verified_at.to_i) + end end it 'prompts for consent if last consent time was over a year ago', driver: :mobile_rack_test do @@ -687,13 +689,15 @@ def sign_in_get_token_response( code = redirect_params[:code] expect(code).to be_present - page.driver.post api_openid_connect_token_path, - grant_type: 'authorization_code', - code: code, - code_verifier: code_verifier - expect(page.status_code).to eq(200) + Capybara.using_driver(:desktop_rack_test) do + page.driver.post api_openid_connect_token_path, + grant_type: 'authorization_code', + code: code, + code_verifier: code_verifier + expect(page.status_code).to eq(200) - JSON.parse(page.body).with_indifferent_access + JSON.parse(page.body).with_indifferent_access + end end def certs_response diff --git a/spec/features/saml/ial2_sso_spec.rb b/spec/features/saml/ial2_sso_spec.rb index 381b24ed922..49f4be230ed 100644 --- a/spec/features/saml/ial2_sso_spec.rb +++ b/spec/features/saml/ial2_sso_spec.rb @@ -29,7 +29,7 @@ def perform_id_verification_with_gpo_without_confirming_code(user) click_on t('idv.buttons.mail.send') fill_in t('idv.form.password'), with: user.password click_continue - click_acknowledge_personal_key + acknowledge_and_confirm_personal_key click_link t('idv.buttons.continue_plain') end @@ -48,7 +48,7 @@ def update_mailing_address click_on t('idv.buttons.mail.resend') fill_in t('idv.form.password'), with: user.password click_continue - click_acknowledge_personal_key + acknowledge_and_confirm_personal_key click_link t('idv.buttons.continue_plain') end @@ -90,7 +90,7 @@ def sign_out_user ) end - context 'having previously selected USPS verification' do + context 'having previously selected USPS verification', js: true do let(:phone_confirmed) { false } context 'provides an option to send another letter' do diff --git a/spec/features/sp_cost_tracking_spec.rb b/spec/features/sp_cost_tracking_spec.rb index a239452995e..dfd2cd79e12 100644 --- a/spec/features/sp_cost_tracking_spec.rb +++ b/spec/features/sp_cost_tracking_spec.rb @@ -20,7 +20,7 @@ expect_sp_cost_type(2, 1, 'authentication') end - it 'logs the correct costs for an ial2 user creation from sp with oidc' do + it 'logs the correct costs for an ial2 user creation from sp with oidc', js: true do create_ial2_user_from_sp(email) expect_sp_cost_type(0, 2, 'sms') @@ -40,7 +40,7 @@ expect_sp_cost_type(8, 2, 'authentication') end - it 'logs the cost to the SP for reproofing' do + it 'logs the cost to the SP for reproofing', js: true do create_ial2_user_from_sp(email) # track costs without dealing with 'remember device' @@ -54,10 +54,10 @@ fill_in_code_with_last_phone_otp click_submit_default complete_all_doc_auth_steps - click_continue + click_idv_continue fill_in 'Password', with: password click_continue - click_acknowledge_personal_key + acknowledge_and_confirm_personal_key click_agree_and_continue %w[ @@ -93,7 +93,7 @@ expect_sp_cost_type(2, 1, 'authentication') end - it 'logs the correct costs for an ial2 authentication' do + it 'logs the correct costs for an ial2 authentication', js: true do create_ial2_user_from_sp(email) SpCost.delete_all diff --git a/spec/features/users/regenerate_personal_key_spec.rb b/spec/features/users/regenerate_personal_key_spec.rb index c6fe1afd4be..95acd31d4d2 100644 --- a/spec/features/users/regenerate_personal_key_spec.rb +++ b/spec/features/users/regenerate_personal_key_spec.rb @@ -31,7 +31,7 @@ end context 'regenerating new code after canceling edit password action' do - scenario 'displays new code' do + scenario 'displays new code', js: true do sign_in_and_2fa_user(user) old_digest = user.encrypted_recovery_code_digest @@ -53,7 +53,7 @@ click_continue expect(page).to have_content(t('headings.personal_key')) - click_acknowledge_personal_key + acknowledge_and_confirm_personal_key expect(user.reload.encrypted_recovery_code_digest).to_not eq old_digest end @@ -140,8 +140,7 @@ def sign_up_and_view_personal_key end def expect_confirmation_modal_to_appear_with_first_code_field_in_focus - expect(page).not_to have_xpath("//div[@id='personal-key-confirm'][@class='display-none']") - expect(page.evaluate_script('document.activeElement.name')).to eq 'personal_key' + expect(page.find(':focus')).to eq page.find_field(t('forms.personal_key.confirmation_label')) end def click_back_button @@ -158,14 +157,14 @@ def expect_to_be_back_on_manage_personal_key_page_with_continue_button_in_focus end def submit_form_without_entering_the_code - click_on t('forms.buttons.continue'), class: 'personal-key-confirm' - expect(page).to have_selector('.validation-message') - expect(page).not_to have_selector('#personal-key-alert') + within('[role=dialog]') { click_continue } + expect(page).to have_content(t('simple_form.required.text')) + expect(page).not_to have_content(t('users.personal_key.confirmation_error')) end def submit_form_with_the_wrong_code fill_in 'personal_key', with: 'hellohellohello' - click_on t('forms.buttons.continue'), class: 'personal-key-confirm' + within('[role=dialog]') { click_continue } expect(page).to have_content(t('users.personal_key.confirmation_error')) - expect(page).not_to have_selector('.validation-message') + expect(page).not_to have_content(t('simple_form.required.text')) end diff --git a/spec/features/users/user_profile_spec.rb b/spec/features/users/user_profile_spec.rb index 1e598c120ed..4546f34cbd9 100644 --- a/spec/features/users/user_profile_spec.rb +++ b/spec/features/users/user_profile_spec.rb @@ -144,7 +144,7 @@ expect(page).to have_content(t('idv.messages.personal_key')) end - it 'allows the user reactivate their profile by reverifying' do + it 'allows the user reactivate their profile by reverifying', js: true do profile = create(:profile, :active, :verified, pii: { ssn: '1234', dob: '1920-01-01' }) user = profile.user @@ -158,7 +158,7 @@ click_idv_continue fill_in 'Password', with: user_password click_idv_continue - click_acknowledge_personal_key + acknowledge_and_confirm_personal_key expect(current_path).to eq(sign_up_completed_path) diff --git a/spec/lib/feature_management_spec.rb b/spec/lib/feature_management_spec.rb index d50616111ca..7a5db14502a 100644 --- a/spec/lib/feature_management_spec.rb +++ b/spec/lib/feature_management_spec.rb @@ -364,7 +364,7 @@ context 'with steps enabled' do it 'returns true' do - allow(IdentityConfig.store).to receive(:idv_api_enabled_steps).and_return([:example]) + allow(IdentityConfig.store).to receive(:idv_api_enabled_steps).and_return(['example']) expect(FeatureManagement.idv_api_enabled?).to eq(true) end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 75674b35cb6..b53892f5698 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -15,6 +15,7 @@ require 'factory_bot' require 'view_component/test_helpers' require 'capybara/rspec' +require 'capybara/webmock' # Checks for pending migrations before tests are run. # If you are not using ActiveRecord, you can remove this line. @@ -102,7 +103,9 @@ class Analytics config.around(:each, type: :feature) do |example| Bullet.enable = true + Capybara::Webmock.start example.run + Capybara::Webmock.stop Bullet.enable = false end diff --git a/spec/support/capybara.rb b/spec/support/capybara.rb index 7ac7956da5b..cd94be301f6 100644 --- a/spec/support/capybara.rb +++ b/spec/support/capybara.rb @@ -10,6 +10,7 @@ options.add_argument('--window-size=1200x700') options.add_argument('--no-sandbox') options.add_argument('--disable-dev-shm-usage') + options.add_argument("--proxy-server=127.0.0.1:#{Capybara::Webmock.port_number}") Capybara::Selenium::Driver.new app, browser: :chrome, @@ -31,6 +32,7 @@ options.add_argument('--window-size=414,736') options.add_argument("--user-agent='#{user_agent_string}'") options.add_argument('--use-fake-device-for-media-stream') + options.add_argument("--proxy-server=127.0.0.1:#{Capybara::Webmock.port_number}") Capybara::Selenium::Driver.new app, browser: :chrome, diff --git a/spec/support/features/doc_auth_helper.rb b/spec/support/features/doc_auth_helper.rb index 06336180a11..ea8bd6987df 100644 --- a/spec/support/features/doc_auth_helper.rb +++ b/spec/support/features/doc_auth_helper.rb @@ -152,9 +152,10 @@ def complete_all_doc_auth_steps(expect_accessible: false) def complete_proofing_steps complete_all_doc_auth_steps click_continue + expect(page).to have_current_path(idv_review_path, wait: 10) fill_in 'Password', with: RequestHelper::VALID_PASSWORD click_continue - click_acknowledge_personal_key + acknowledge_and_confirm_personal_key click_agree_and_continue end diff --git a/spec/support/features/idv_from_sp_helper.rb b/spec/support/features/idv_from_sp_helper.rb index 55f97d62f78..3ed8adebf96 100644 --- a/spec/support/features/idv_from_sp_helper.rb +++ b/spec/support/features/idv_from_sp_helper.rb @@ -11,19 +11,19 @@ def create_ial2_user_from_sp(email, **options) visit_idp_from_sp_with_ial2(:oidc, **options) register_user(email) complete_all_doc_auth_steps - click_continue + click_idv_continue fill_in 'Password', with: password click_continue - click_acknowledge_personal_key + acknowledge_and_confirm_personal_key click_agree_and_continue end def reproof_for_ial2_strict complete_all_doc_auth_steps - click_continue + click_idv_continue fill_in 'Password', with: password click_continue - click_acknowledge_personal_key + acknowledge_and_confirm_personal_key end def create_ial1_user_from_sp(email) diff --git a/spec/support/features/idv_helper.rb b/spec/support/features/idv_helper.rb index 3cb87426348..ad54e3d111c 100644 --- a/spec/support/features/idv_helper.rb +++ b/spec/support/features/idv_helper.rb @@ -61,9 +61,11 @@ def visit_idp_from_sp_with_ial2(sp, **extra) }, } if javascript_enabled? - idp_domain_name = "#{page.server.host}:#{page.server.port}" - saml_overrides[:idp_sso_target_url] = "http://#{idp_domain_name}/api/saml/auth" - saml_overrides[:idp_slo_target_url] = "http://#{idp_domain_name}/api/saml/logout" + service_provider = ServiceProvider.find_by(issuer: sp1_issuer) + acs_url = URI.parse(service_provider.acs_url) + acs_url.host = page.server.host + acs_url.port = page.server.port + service_provider.update(acs_url: acs_url.to_s) end visit_saml_authn_request_url(overrides: saml_overrides) elsif sp == :oidc diff --git a/spec/support/features/personal_key_helper.rb b/spec/support/features/personal_key_helper.rb index 3b2a651a25c..cc99ebf315d 100644 --- a/spec/support/features/personal_key_helper.rb +++ b/spec/support/features/personal_key_helper.rb @@ -28,6 +28,6 @@ def trigger_reset_password_and_click_email_link(email) end def scrape_personal_key - page.all(:css, '.separator-text__code').map(&:text).join('-') + page.all('.separator-text__code').map(&:text).join('-') end end diff --git a/spec/support/features/session_helper.rb b/spec/support/features/session_helper.rb index 3aad175cc8f..6d764b2530d 100644 --- a/spec/support/features/session_helper.rb +++ b/spec/support/features/session_helper.rb @@ -308,18 +308,15 @@ def sign_in_with_totp_enabled_user click_submit_default end - def acknowledge_and_confirm_personal_key(js: true) - button_text = t('forms.buttons.continue') + def acknowledge_and_confirm_personal_key + click_acknowledge_personal_key - click_on button_text, class: 'personal-key-continue' if js - - fill_in 'personal_key', with: scrape_personal_key - - find_all('.personal-key-confirm', text: button_text).first.click + page.find(':focus').fill_in with: scrape_personal_key + within('[role=dialog]') { click_continue } end def click_acknowledge_personal_key - click_on t('forms.buttons.continue'), class: 'personal-key-continue' + click_continue end def enter_personal_key(personal_key:, selector: 'input[type="text"]') diff --git a/spec/support/features/webauthn_helper.rb b/spec/support/features/webauthn_helper.rb index 65e75d6e591..efc78d7c6f0 100644 --- a/spec/support/features/webauthn_helper.rb +++ b/spec/support/features/webauthn_helper.rb @@ -1,4 +1,6 @@ module WebAuthnHelper + include JavascriptDriverHelper + def mock_webauthn_setup_challenge allow(WebAuthn::Credential).to receive(:options_for_create).and_return( instance_double( @@ -35,7 +37,12 @@ def mock_press_button_on_hardware_key_on_setup set_hidden_field('attestation_object', attestation_object) set_hidden_field('client_data_json', setup_client_data_json) - first('#submit-button', visible: false).click + button = first('#submit-button', visible: false) + if javascript_enabled? + button.execute_script('this.click()') + else + button.click + end end def mock_press_button_on_hardware_key_on_verification @@ -50,7 +57,12 @@ def mock_press_button_on_hardware_key_on_verification end def set_hidden_field(id, value) - first("input##{id}", visible: false).set(value) + input = first("input##{id}", visible: false) + if javascript_enabled? + input.execute_script("this.value = #{value.to_json}") + else + input.set(value) + end end def protocol diff --git a/spec/support/idv_examples/clearing_and_restarting.rb b/spec/support/idv_examples/clearing_and_restarting.rb index 205439a5464..73b6ca12087 100644 --- a/spec/support/idv_examples/clearing_and_restarting.rb +++ b/spec/support/idv_examples/clearing_and_restarting.rb @@ -1,5 +1,5 @@ shared_examples 'clearing and restarting idv' do - it 'allows the user to retry verification with phone' do + it 'allows the user to retry verification with phone', js: true do click_on t('idv.messages.clear_and_start_over') expect(user.reload.pending_profile?).to eq(false) @@ -8,13 +8,13 @@ click_idv_continue fill_in 'Password', with: user.password click_idv_continue - click_acknowledge_personal_key + acknowledge_and_confirm_personal_key expect(page).to have_current_path(sign_up_completed_path) expect(user.reload.decorate.identity_verified?).to eq(true) end - it 'allows the user to retry verification with gpo' do + it 'allows the user to retry verification with gpo', js: true do click_on t('idv.messages.clear_and_start_over') expect(user.reload.pending_profile?).to eq(false) @@ -28,7 +28,7 @@ end fill_in 'Password', with: user.password click_idv_continue - click_acknowledge_personal_key + acknowledge_and_confirm_personal_key gpo_confirmation = GpoConfirmation.order(created_at: :desc).first diff --git a/spec/support/idv_examples/confirmation_step.rb b/spec/support/idv_examples/confirmation_step.rb index 3286c807d80..81436859616 100644 --- a/spec/support/idv_examples/confirmation_step.rb +++ b/spec/support/idv_examples/confirmation_step.rb @@ -6,7 +6,7 @@ end it 'redirects to the come back later url then to the sp or account' do - click_acknowledge_personal_key + acknowledge_and_confirm_personal_key expect(page).to have_current_path(idv_come_back_later_path) click_on t('forms.buttons.continue') @@ -52,7 +52,7 @@ end it 'redirects to the completions page and then to the SP', if: sp.present? do - click_acknowledge_personal_key + acknowledge_and_confirm_personal_key expect(page).to have_current_path(sign_up_completed_path) @@ -61,12 +61,12 @@ if sp == :oidc expect(current_url).to start_with('http://localhost:7654/auth/result') else - expect(current_path).to eq(api_saml_auth2022_path) + expect(current_path).to eq(test_saml_decode_assertion_path) end end it 'redirects to the account page', if: sp.nil? do - click_acknowledge_personal_key + acknowledge_and_confirm_personal_key expect(page).to have_content(t('headings.account.verified_account')) expect(page).to have_current_path(account_path) diff --git a/spec/support/idv_examples/max_attempts.rb b/spec/support/idv_examples/max_attempts.rb index 80d9176fc79..d44a4783b40 100644 --- a/spec/support/idv_examples/max_attempts.rb +++ b/spec/support/idv_examples/max_attempts.rb @@ -32,7 +32,7 @@ expect_user_to_fail_at_phone_step end - scenario 'after 24 hours the user can retry and complete idv' do + scenario 'after 24 hours the user can retry and complete idv', js: true do visit account_path first(:link, t('links.sign_out')).click reattempt_interval = (IdentityConfig.store.idv_attempt_window_in_hours + 1).hours @@ -48,7 +48,7 @@ click_idv_continue fill_in 'Password', with: user.password click_idv_continue - click_acknowledge_personal_key + acknowledge_and_confirm_personal_key click_agree_and_continue expect(current_url).to start_with('http://localhost:7654/auth/result') diff --git a/spec/support/idv_examples/sp_handoff.rb b/spec/support/idv_examples/sp_handoff.rb index 77d2f5e51c3..932f23002c7 100644 --- a/spec/support/idv_examples/sp_handoff.rb +++ b/spec/support/idv_examples/sp_handoff.rb @@ -1,23 +1,24 @@ shared_examples 'sp handoff after identity verification' do |sp| include SamlAuthHelper include IdvHelper + include JavascriptDriverHelper let(:email) { 'test@test.com' } context 'sign up' do let(:user) { User.find_with_email(email) } - it 'requires idv and hands off correctly' do + it 'requires idv and hands off correctly', js: true do visit_idp_from_sp_with_ial2(sp) register_user(email) expect(current_path).to eq idv_doc_auth_step_path(step: :welcome) complete_all_doc_auth_steps - click_continue + click_idv_continue fill_in 'Password', with: Features::SessionHelper::VALID_PASSWORD click_continue - click_acknowledge_personal_key + acknowledge_and_confirm_personal_key expect(page).to have_content t( 'titles.sign_up.completion_ial2', @@ -36,7 +37,7 @@ context 'unverified user sign in' do let(:user) { user_with_2fa } - it 'requires idv and hands off successfully' do + it 'requires idv and hands off successfully', js: true do visit_idp_from_sp_with_ial2(sp) sign_in_user(user) fill_in_code_with_last_phone_otp @@ -45,10 +46,10 @@ expect(current_path).to eq idv_doc_auth_step_path(step: :welcome) complete_all_doc_auth_steps - click_continue + click_idv_continue fill_in 'Password', with: user.password click_continue - click_acknowledge_personal_key + acknowledge_and_confirm_personal_key expect(page).to have_content t( 'titles.sign_up.completion_ial2', @@ -64,16 +65,16 @@ end end - context 'verified user sign in' do + context 'verified user sign in', js: true do let(:user) { user_with_2fa } before do sign_in_and_2fa_user(user) complete_all_doc_auth_steps - click_continue + click_idv_continue fill_in 'Password', with: user.password click_continue - click_acknowledge_personal_key + acknowledge_and_confirm_personal_key first(:link, t('links.sign_out')).click end @@ -92,7 +93,7 @@ end end - context 'second time a user signs in to an SP' do + context 'second time a user signs in to an SP', js: true do let(:user) { user_with_2fa } before do @@ -103,10 +104,10 @@ fill_in_code_with_last_phone_otp click_submit_default complete_all_doc_auth_steps - click_continue + click_idv_continue fill_in 'Password', with: user.password click_continue - click_acknowledge_personal_key + acknowledge_and_confirm_personal_key click_agree_and_continue visit account_path first(:link, t('links.sign_out')).click @@ -128,6 +129,9 @@ end def expect_csp_headers_to_be_present + # Selenium driver does not support response header inspection, but we should be able to expect + # that the browser itself would respect CSP and refuse invalid form targets. + return if javascript_enabled? expect(page.response_headers['Content-Security-Policy']). to(include('form-action \'self\' http://localhost:7654')) end @@ -153,45 +157,47 @@ def expect_successful_oidc_handoff client_assertion = JWT.encode(jwt_payload, client_private_key, 'RS256') client_assertion_type = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer' - page.driver.post api_openid_connect_token_path, - grant_type: 'authorization_code', - code: code, - client_assertion_type: client_assertion_type, - client_assertion: client_assertion - - expect(page.status_code).to eq(200) - token_response = JSON.parse(page.body).with_indifferent_access - - id_token = token_response[:id_token] - expect(id_token).to be_present - - decoded_id_token, _headers = JWT.decode( - id_token, sp_public_key, true, algorithm: 'RS256' - ).map(&:with_indifferent_access) - - sub = decoded_id_token[:sub] - expect(sub).to be_present - expect(decoded_id_token[:nonce]).to eq(@nonce) - expect(decoded_id_token[:aud]).to eq(@client_id) - expect(decoded_id_token[:acr]).to eq(Saml::Idp::Constants::IAL2_AUTHN_CONTEXT_CLASSREF) - expect(decoded_id_token[:iss]).to eq(root_url) - expect(decoded_id_token[:email]).to eq(user.email) - expect(decoded_id_token[:given_name]).to eq('FAKEY') - expect(decoded_id_token[:social_security_number]).to eq(DocAuthHelper::GOOD_SSN) - - access_token = token_response[:access_token] - expect(access_token).to be_present - - page.driver.get api_openid_connect_userinfo_path, - {}, - 'HTTP_AUTHORIZATION' => "Bearer #{access_token}" - - userinfo_response = JSON.parse(page.body).with_indifferent_access - expect(userinfo_response[:sub]).to eq(sub) - expect(AgencyIdentity.where(user_id: user.id, agency_id: 2).first.uuid).to eq(sub) - expect(userinfo_response[:email]).to eq(user.email) - expect(userinfo_response[:given_name]).to eq('FAKEY') - expect(userinfo_response[:social_security_number]).to eq(DocAuthHelper::GOOD_SSN) + Capybara.using_driver(:desktop_rack_test) do + page.driver.post api_openid_connect_token_path, + grant_type: 'authorization_code', + code: code, + client_assertion_type: client_assertion_type, + client_assertion: client_assertion + + expect(page.status_code).to eq(200) + token_response = JSON.parse(page.body).with_indifferent_access + + id_token = token_response[:id_token] + expect(id_token).to be_present + + decoded_id_token, _headers = JWT.decode( + id_token, sp_public_key, true, algorithm: 'RS256' + ).map(&:with_indifferent_access) + + sub = decoded_id_token[:sub] + expect(sub).to be_present + expect(decoded_id_token[:nonce]).to eq(@nonce) + expect(decoded_id_token[:aud]).to eq(@client_id) + expect(decoded_id_token[:acr]).to eq(Saml::Idp::Constants::IAL2_AUTHN_CONTEXT_CLASSREF) + expect(decoded_id_token[:iss]).to eq(root_url) + expect(decoded_id_token[:email]).to eq(user.email) + expect(decoded_id_token[:given_name]).to eq('FAKEY') + expect(decoded_id_token[:social_security_number]).to eq(DocAuthHelper::GOOD_SSN) + + access_token = token_response[:access_token] + expect(access_token).to be_present + + page.driver.get api_openid_connect_userinfo_path, + {}, + 'HTTP_AUTHORIZATION' => "Bearer #{access_token}" + + userinfo_response = JSON.parse(page.body).with_indifferent_access + expect(userinfo_response[:sub]).to eq(sub) + expect(AgencyIdentity.where(user_id: user.id, agency_id: 2).first.uuid).to eq(sub) + expect(userinfo_response[:email]).to eq(user.email) + expect(userinfo_response[:given_name]).to eq('FAKEY') + expect(userinfo_response[:social_security_number]).to eq(DocAuthHelper::GOOD_SSN) + end end def expect_successful_saml_handoff @@ -199,7 +205,11 @@ def expect_successful_saml_handoff xmldoc = SamlResponseDoc.new('feature', 'response_assertion') expect(AgencyIdentity.where(user_id: user.id, agency_id: 2).first.uuid).to eq(xmldoc.uuid) - expect(current_url).to eq @saml_authn_request + if javascript_enabled? + expect(current_path).to eq test_saml_decode_assertion_path + else + expect(current_url).to eq @saml_authn_request + end expect(xmldoc.phone_number.children.children.to_s).to eq(Phonelib.parse(profile_phone).e164) end diff --git a/spec/support/idv_examples/sp_requested_attributes.rb b/spec/support/idv_examples/sp_requested_attributes.rb index 0f48478bfb4..cec18edee03 100644 --- a/spec/support/idv_examples/sp_requested_attributes.rb +++ b/spec/support/idv_examples/sp_requested_attributes.rb @@ -13,7 +13,7 @@ end context 'visiting an SP for the first time' do - it 'requires the user to verify the attributes submitted to the SP' do + it 'requires the user to verify the attributes submitted to the SP', js: true do visit_idp_from_sp_with_ial2(sp) sign_in_user(user) fill_in_code_with_last_phone_otp @@ -27,7 +27,7 @@ click_idv_continue fill_in 'Password', with: user.password click_continue - click_acknowledge_personal_key + acknowledge_and_confirm_personal_key expect(current_path).to eq(sign_up_completed_path) @@ -41,12 +41,12 @@ expect(page).to have_content t('help_text.requested_attributes.phone') expect(page).to have_content '+1 202-555-1212' expect(page).to have_content t('help_text.requested_attributes.social_security_number') - expect(page).to have_content good_ssn + expect(page).to have_css '.masked-text__text', text: good_ssn, visible: :hidden end end end - context 'visiting an SP the user has already signed into' do + context 'visiting an SP the user has already signed into', js: true do before do visit_idp_from_sp_with_ial2(sp) sign_in_user(user) @@ -59,7 +59,7 @@ click_idv_continue fill_in 'Password', with: user.password click_continue - click_acknowledge_personal_key + acknowledge_and_confirm_personal_key click_agree_and_continue visit account_path first(:link, t('links.sign_out')).click @@ -75,7 +75,11 @@ if sp == :oidc expect(current_url).to include('http://localhost:7654/auth/result') elsif sp == :saml - expect(current_url).to include(api_saml_auth2022_url) + if javascript_enabled? + expect(current_path).to eq(test_saml_decode_assertion_path) + else + expect(current_url).to include(api_saml_auth2022_url) + end end end end diff --git a/spec/support/saml_response_doc.rb b/spec/support/saml_response_doc.rb index 62401f40143..94600fb751c 100644 --- a/spec/support/saml_response_doc.rb +++ b/spec/support/saml_response_doc.rb @@ -17,11 +17,7 @@ def original_encrypted? end def xml_response - Base64.decode64( - Capybara.current_session.find( - "//input[@id='#{input_id}']", visible: false - ).value, - ) + Base64.decode64(Capybara.current_session.find("##{input_id}", visible: false).value) end def html_response diff --git a/spec/support/shared_examples/account_creation.rb b/spec/support/shared_examples/account_creation.rb index 909b3d0d1da..5f9385e0e9e 100644 --- a/spec/support/shared_examples/account_creation.rb +++ b/spec/support/shared_examples/account_creation.rb @@ -42,7 +42,7 @@ end shared_examples 'creating an IAL2 account using authenticator app for 2FA' do |sp| - it 'does not prompt for recovery code before IdV flow', email: true, idv_job: true do + it 'does not prompt for recovery code before IdV flow', email: true, idv_job: true, js: true do visit_idp_from_sp_with_ial2(sp) register_user_with_authenticator_app expect(page).to have_current_path(idv_doc_auth_step_path(step: :welcome)) @@ -54,15 +54,10 @@ click_submit_default fill_in 'Password', with: Features::SessionHelper::VALID_PASSWORD click_continue - click_acknowledge_personal_key - - if sp == :oidc - expect(page.response_headers['Content-Security-Policy']). - to(include('form-action \'self\' http://localhost:7654')) - end + acknowledge_and_confirm_personal_key click_agree_and_continue - expect(current_url).to eq @saml_authn_request if sp == :saml + expect(current_path).to eq test_saml_decode_assertion_path if sp == :saml if sp == :oidc redirect_uri = URI(current_url) @@ -94,7 +89,7 @@ end shared_examples 'creating an IAL2 account using webauthn for 2FA' do |sp| - it 'does not prompt for recovery code before IdV flow', email: true do + it 'does not prompt for recovery code before IdV flow', email: true, js: true do mock_webauthn_setup_challenge visit_idp_from_sp_with_ial2(sp) confirm_email_and_password('test@test.com') @@ -110,15 +105,10 @@ click_submit_default fill_in 'Password', with: Features::SessionHelper::VALID_PASSWORD click_continue - click_acknowledge_personal_key - - if sp == :oidc - expect(page.response_headers['Content-Security-Policy']). - to(include('form-action \'self\' http://localhost:7654')) - end + acknowledge_and_confirm_personal_key click_agree_and_continue - expect(current_url).to eq @saml_authn_request if sp == :saml + expect(current_path).to eq test_saml_decode_assertion_path if sp == :saml if sp == :oidc redirect_uri = URI(current_url) diff --git a/spec/support/shared_examples/sign_in.rb b/spec/support/shared_examples/sign_in.rb index 99792304e4d..a9747032779 100644 --- a/spec/support/shared_examples/sign_in.rb +++ b/spec/support/shared_examples/sign_in.rb @@ -60,7 +60,7 @@ end shared_examples 'signing in as IAL2 with personal key' do |sp| - it 'does not present personal key as an MFA option', :email do + it 'does not present personal key as an MFA option', :email, js: true do user = create_ial2_account_go_back_to_sp_and_sign_out(sp) Capybara.reset_sessions! @@ -75,19 +75,19 @@ end shared_examples 'signing in as IAL2 with piv/cac' do |sp| - it 'redirects to the SP after authenticating and getting the password', :email do + it 'redirects to the SP after authenticating and getting the password', :email, js: true do ial2_sign_in_with_piv_cac_goes_to_sp(sp) end if sp == :saml context 'no authn_context specified' do - it 'redirects to the SP after authenticating and getting the password', :email do + it 'redirects to the SP after authenticating and getting the password', :email, js: true do no_authn_context_sign_in_with_piv_cac_goes_to_sp(sp) end end end - it 'gets bad password error', :email do + it 'gets bad password error', :email, js: true do ial2_sign_in_with_piv_cac_gets_bad_password_error(sp) end end @@ -119,7 +119,7 @@ end shared_examples 'signing in as IAL2 with personal key after resetting password' do |sp| - xit 'redirects to SP after reactivating account', :email do + xit 'redirects to SP after reactivating account', :email, js: true do user = create_ial2_account_go_back_to_sp_and_sign_out(sp) visit_idp_from_sp_with_ial2(sp) trigger_reset_password_and_click_email_link(user.email) @@ -133,7 +133,7 @@ expect(current_path).to eq manage_personal_key_path new_personal_key = scrape_personal_key - click_acknowledge_personal_key + acknowledge_and_confirm_personal_key expect(current_path).to eq reactivate_account_path @@ -141,7 +141,7 @@ expect(current_path).to eq manage_personal_key_path - click_acknowledge_personal_key + acknowledge_and_confirm_personal_key expect(current_url).to eq @saml_authn_request if sp == :saml if sp == :oidc @@ -205,7 +205,7 @@ def user_with_broken_personal_key(protocol) end context "protocol: #{protocol}, ial: #{sp_ial}" do - it 'prompts the user to get a new personal key when signing in with email/password' do + it 'prompts the user to get a new personal key when signing in with email/password', js: true do user = user_with_broken_personal_key(protocol) case sp_ial @@ -220,14 +220,14 @@ def user_with_broken_personal_key(protocol) fill_in_credentials_and_submit(user.email, user.password) expect(page).to have_content(t('account.personal_key.needs_new')) - code = page.all('[data-personal-key]').map(&:text).join(' ') - click_acknowledge_personal_key + code = page.all('.separator-text__code').map(&:text).join(' ') + acknowledge_and_confirm_personal_key expect(user.reload.valid_personal_key?(code)).to eq(true) expect(user.active_profile.reload.recover_pii(code)).to be_present end - it 'prompts for password when signing in via PIV/CAC' do + it 'prompts for password when signing in via PIV/CAC', js: true do user = user_with_broken_personal_key(protocol) create(:piv_cac_configuration, user: user) @@ -243,8 +243,8 @@ def user_with_broken_personal_key(protocol) click_button t('forms.buttons.submit.default') expect(page).to have_content(t('account.personal_key.needs_new')) - code = page.all('[data-personal-key]').map(&:text).join(' ') - click_acknowledge_personal_key + code = page.all('.separator-text__code').map(&:text).join(' ') + acknowledge_and_confirm_personal_key expect(user.reload.valid_personal_key?(code)).to eq(true) expect(user.active_profile.reload.recover_pii(code)).to be_present @@ -310,15 +310,14 @@ def ial2_sign_in_with_piv_cac_goes_to_sp(sp) # capture password before redirecting to SP expect(current_url).to eq capture_password_url - if sp == :oidc - expect(page.response_headers['Content-Security-Policy']). - to(include('form-action \'self\' http://localhost:7654')) - end - fill_in_password_and_submit(user.password) if sp == :saml - expect(current_url).to eq @saml_authn_request + if javascript_enabled? + expect(current_path).to eq(test_saml_decode_assertion_path) + else + expect(current_url).to include(@saml_authn_request) + end elsif sp == :oidc redirect_uri = URI(current_url) @@ -347,7 +346,11 @@ def no_authn_context_sign_in_with_piv_cac_goes_to_sp(sp) fill_in_password_and_submit(user.password) - expect(current_url).to eq @saml_authn_request + if javascript_enabled? + expect(current_path).to eq(test_saml_decode_assertion_path) + else + expect(current_url).to eq @saml_authn_request + end end def ial2_sign_in_with_piv_cac_gets_bad_password_error(sp) diff --git a/spec/support/shared_examples_for_personal_keys.rb b/spec/support/shared_examples_for_personal_keys.rb index ca655eaedd2..67c32ad0010 100644 --- a/spec/support/shared_examples_for_personal_keys.rb +++ b/spec/support/shared_examples_for_personal_keys.rb @@ -1,7 +1,12 @@ shared_examples_for 'personal key page' do include PersonalKeyHelper + include JavascriptDriverHelper context 'informational text' do + before do + click_continue if javascript_enabled? + end + context 'modal content' do it 'displays the modal title' do expect(page).to have_content t('forms.personal_key.title') @@ -30,8 +35,7 @@ click_on t('components.clipboard_button.label') copied_text = page.evaluate_async_script('navigator.clipboard.readText().then(arguments[0])') - code = page.all('[data-personal-key]').map(&:text).join('-') - expect(copied_text).to eq(code) + expect(copied_text).to eq(scrape_personal_key) end it 'validates as case-insensitive, crockford-normalized, length-limited, dash-flexible' do diff --git a/spec/support/sp_auth_helper.rb b/spec/support/sp_auth_helper.rb index da65f25470c..c8eb326e9a9 100644 --- a/spec/support/sp_auth_helper.rb +++ b/spec/support/sp_auth_helper.rb @@ -22,7 +22,7 @@ def create_ial2_account_go_back_to_sp_and_sign_out(sp) click_idv_continue fill_in t('idv.form.password'), with: user.password click_continue - click_acknowledge_personal_key + acknowledge_and_confirm_personal_key expect(page).to have_current_path(sign_up_completed_path) click_agree_and_continue visit sign_out_url From 6d4fc07cba3dde26e57958f80680fb7b1c6f17ff Mon Sep 17 00:00:00 2001 From: Mitchell Henke Date: Fri, 6 May 2022 13:32:44 -0500 Subject: [PATCH 22/29] Fix 500 when trying to display invalid phone numbers (#6319) * add failing specs * Do not attempt to display phone if it is invalid changelog: Bug Fixes, Authentication, Fix 500 when phone ID is invalid --- .../otp_verification_controller.rb | 8 ++++++++ .../two_factor_authentication_controller.rb | 8 ++++++++ .../options_controller_spec.rb | 6 ++++++ .../otp_verification_controller_spec.rb | 9 +++++++++ ...two_factor_authentication_controller_spec.rb | 17 +++++++++++++++++ 5 files changed, 48 insertions(+) diff --git a/app/controllers/two_factor_authentication/otp_verification_controller.rb b/app/controllers/two_factor_authentication/otp_verification_controller.rb index e304a39c3a2..6187b7f9dc6 100644 --- a/app/controllers/two_factor_authentication/otp_verification_controller.rb +++ b/app/controllers/two_factor_authentication/otp_verification_controller.rb @@ -5,6 +5,7 @@ class OtpVerificationController < ApplicationController before_action :check_sp_required_mfa_bypass before_action :confirm_multiple_factors_enabled + before_action :redirect_if_blank_phone, only: [:show] before_action :confirm_voice_capability, only: [:show] def show @@ -35,6 +36,13 @@ def create private + def redirect_if_blank_phone + return if phone.present? + + flash[:error] = t('errors.messages.phone_required') + redirect_to new_user_session_path + end + def confirm_multiple_factors_enabled return if UserSessionContext.confirmation_context?(context) || phone_enabled? diff --git a/app/controllers/users/two_factor_authentication_controller.rb b/app/controllers/users/two_factor_authentication_controller.rb index 678e60002d8..5fb042de916 100644 --- a/app/controllers/users/two_factor_authentication_controller.rb +++ b/app/controllers/users/two_factor_authentication_controller.rb @@ -5,6 +5,7 @@ class TwoFactorAuthenticationController < ApplicationController before_action :check_remember_device_preference before_action :redirect_to_vendor_outage_if_phone_only, only: [:show] + before_action :redirect_if_blank_phone, only: [:send_code] def show service_provider_mfa_requirement_redirect || non_phone_redirect || phone_redirect || @@ -127,6 +128,13 @@ def redirect_to_otp_verification_with_error ) end + def redirect_if_blank_phone + return if phone_to_deliver_to.present? + + flash[:error] = t('errors.messages.phone_required') + redirect_to login_two_factor_options_path + end + def redirect_to_vendor_outage_if_phone_only return unless VendorStatus.new.all_phone_vendor_outage? && phone_enabled? && diff --git a/spec/controllers/two_factor_authentication/options_controller_spec.rb b/spec/controllers/two_factor_authentication/options_controller_spec.rb index e420ec09e01..b0a7c9813ff 100644 --- a/spec/controllers/two_factor_authentication/options_controller_spec.rb +++ b/spec/controllers/two_factor_authentication/options_controller_spec.rb @@ -64,6 +64,12 @@ expect(response).to redirect_to login_two_factor_webauthn_url(platform: true) end + it 'sets phone_id in session when selecting a phone option' do + post :create, params: { two_factor_options_form: { selection: 'sms_0' } } + + expect(controller.user_session[:phone_id]).to eq('0') + end + it 'rerenders the page with errors on failure' do post :create, params: { two_factor_options_form: { selection: 'foo' } } diff --git a/spec/controllers/two_factor_authentication/otp_verification_controller_spec.rb b/spec/controllers/two_factor_authentication/otp_verification_controller_spec.rb index fe7e0989e79..1fca83ddbcb 100644 --- a/spec/controllers/two_factor_authentication/otp_verification_controller_spec.rb +++ b/spec/controllers/two_factor_authentication/otp_verification_controller_spec.rb @@ -31,6 +31,15 @@ expect(assigns(:code_value)).to be_nil end end + + context 'when the user has an invalid phone number in the session' do + it 'redirects to homepage' do + controller.user_session[:phone_id] = 0 + + get :show, params: { otp_delivery_preference: 'sms' } + expect(response).to redirect_to new_user_session_path + end + end end it 'tracks the page visit and context' do diff --git a/spec/controllers/users/two_factor_authentication_controller_spec.rb b/spec/controllers/users/two_factor_authentication_controller_spec.rb index 0ae29f8161c..84e9a9b01c4 100644 --- a/spec/controllers/users/two_factor_authentication_controller_spec.rb +++ b/spec/controllers/users/two_factor_authentication_controller_spec.rb @@ -440,6 +440,23 @@ def index otp_make_default_number: nil }, } end + + context 'when selecting specific phone configuration' do + before do + user = create(:user, :signed_up) + sign_in_before_2fa(user) + end + end + + it 'redirects to two factor options path with invalid id' do + controller.user_session[:phone_id] = 0 + + get :send_code, params: { + otp_delivery_selection_form: { otp_delivery_preference: 'voice' }, + } + + expect(response).to redirect_to(login_two_factor_options_path) + end end context 'phone is not confirmed' do From 9ece88cccc43591e195f62ef2cc9bffdd82b62b5 Mon Sep 17 00:00:00 2001 From: Oren Kanner Date: Fri, 6 May 2022 14:37:51 -0400 Subject: [PATCH 23/29] Add the SAML remote logout endpoint to the metadata (#5709) **Why:** This needs to be configured as a separate item after upgrading the saml_idp gem. This commit also restricts remote logout requests to the POST HTTP method since that is the only binding we're supporting for that functionality (not HTTP-Redirect) changelog: Improvements, Authentication, Add SAML remote logout endpoint to metadata --- Gemfile | 2 +- Gemfile.lock | 8 ++-- app/services/saml_endpoint.rb | 6 ++- config/initializers/saml_idp.rb | 1 + config/routes.rb | 2 +- spec/controllers/saml_idp_controller_spec.rb | 12 +++--- spec/features/saml/multiple_endpoints_spec.rb | 19 +++++++-- spec/requests/saml_post_spec.rb | 18 --------- spec/requests/saml_requests_spec.rb | 40 +++++++++++++++++++ spec/services/saml_endpoint_spec.rb | 3 ++ 10 files changed, 76 insertions(+), 35 deletions(-) delete mode 100644 spec/requests/saml_post_spec.rb create mode 100644 spec/requests/saml_requests_spec.rb diff --git a/Gemfile b/Gemfile index d3ee6239b52..f88a95012bf 100644 --- a/Gemfile +++ b/Gemfile @@ -54,7 +54,7 @@ gem 'rqrcode' gem 'ruby-progressbar' gem 'ruby-saml' gem 'safe_target_blank', '>= 1.0.2' -gem 'saml_idp', github: '18F/saml_idp', tag: '0.16.0-18f' +gem 'saml_idp', github: '18F/saml_idp', tag: '0.17.0-18f' gem 'scrypt' gem 'simple_form', '>= 5.0.2' gem 'stringex', require: false diff --git a/Gemfile.lock b/Gemfile.lock index 09284cc0357..e7c0e56fc76 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -25,10 +25,10 @@ GIT GIT remote: https://github.com/18F/saml_idp.git - revision: f82bd9cd1682d1645abe98e1fe5e6261f53a8279 - tag: 0.16.0-18f + revision: f10c8ba1b4e10ba983a79b1d0fd39cadca95a728 + tag: 0.17.0-18f specs: - saml_idp (0.16.0.pre.18f) + saml_idp (0.17.0.pre.18f) activesupport builder faraday @@ -421,7 +421,7 @@ GEM pg_query (2.1.3) google-protobuf (>= 3.19.2) phonelib (0.6.54) - pkcs11 (0.3.3) + pkcs11 (0.3.4) premailer (1.15.0) addressable css_parser (>= 1.6.0) diff --git a/app/services/saml_endpoint.rb b/app/services/saml_endpoint.rb index 2e4c0062bf8..a1a9014828a 100644 --- a/app/services/saml_endpoint.rb +++ b/app/services/saml_endpoint.rb @@ -34,8 +34,10 @@ def x509_certificate def saml_metadata config = SamlIdp.config.dup - config.single_service_post_location = config.single_service_post_location + suffix - config.single_logout_service_post_location = config.single_logout_service_post_location + suffix + config.single_service_post_location += suffix + config.single_logout_service_post_location += suffix + config.remote_logout_service_post_location += suffix + SamlIdp::MetadataBuilder.new( config, x509_certificate, diff --git a/config/initializers/saml_idp.rb b/config/initializers/saml_idp.rb index eb23021e5d4..af52ccd80a9 100644 --- a/config/initializers/saml_idp.rb +++ b/config/initializers/saml_idp.rb @@ -19,6 +19,7 @@ config.attribute_service_location = "#{api_base}/saml/attributes" config.single_service_post_location = "#{api_base}/saml/auth" config.single_logout_service_post_location = "#{api_base}/saml/logout" + config.remote_logout_service_post_location = "#{api_base}/saml/remotelogout" # Name ID config.name_id.formats = diff --git a/config/routes.rb b/config/routes.rb index 47f926bc0bb..007180c9423 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -16,7 +16,7 @@ SamlEndpoint.suffixes.each do |suffix| get "/api/saml/metadata#{suffix}" => 'saml_idp#metadata', format: false match "/api/saml/logout#{suffix}" => 'saml_idp#logout', via: %i[get post delete] - match "/api/saml/remotelogout#{suffix}" => 'saml_idp#remotelogout', via: %i[get post delete] + post "/api/saml/remotelogout#{suffix}" => 'saml_idp#remotelogout' # JS-driven POST redirect route to preserve existing session post "/api/saml/auth#{suffix}" => 'saml_post#auth' # actual SAML handling POST route diff --git a/spec/controllers/saml_idp_controller_spec.rb b/spec/controllers/saml_idp_controller_spec.rb index 5ca2a1a6045..7e9919b25d2 100644 --- a/spec/controllers/saml_idp_controller_spec.rb +++ b/spec/controllers/saml_idp_controller_spec.rb @@ -111,7 +111,7 @@ result = { service_provider: nil, saml_request_valid: false } expect(@analytics).to receive(:track_event).with('Remote Logout initiated', result) - delete :remotelogout, params: { SAMLRequest: 'foo' } + post :remotelogout, params: { SAMLRequest: 'foo' } end let(:agency) { create(:agency) } @@ -240,7 +240,7 @@ ), ).to eq(true) - delete :remotelogout, params: payload.to_h.merge(Signature: Base64.encode64(signature)) + post :remotelogout, params: payload.to_h.merge(Signature: Base64.encode64(signature)) expect(response).to be_ok expect(session_accessor.load).to be_empty @@ -277,7 +277,7 @@ ), ).to eq(true) - delete :remotelogout, params: payload.to_h.merge(Signature: Base64.encode64(signature)) + post :remotelogout, params: payload.to_h.merge(Signature: Base64.encode64(signature)) expect(response).to be_bad_request end @@ -308,7 +308,7 @@ ), ).to eq(true) - delete :remotelogout, params: payload.to_h.merge(Signature: Base64.encode64(signature)) + post :remotelogout, params: payload.to_h.merge(Signature: Base64.encode64(signature)) expect(response).to be_bad_request end @@ -339,13 +339,13 @@ ), ).to eq(true) - delete :remotelogout, params: payload.to_h.merge(Signature: Base64.encode64(signature)) + post :remotelogout, params: payload.to_h.merge(Signature: Base64.encode64(signature)) expect(response).to be_bad_request end it 'rejects requests from a wrong cert' do - delete :remotelogout, params: UriService.params( + post :remotelogout, params: UriService.params( OneLogin::RubySaml::Logoutrequest.new.create(wrong_cert_settings), ) diff --git a/spec/features/saml/multiple_endpoints_spec.rb b/spec/features/saml/multiple_endpoints_spec.rb index 8bf33432a68..d501a33e69e 100644 --- a/spec/features/saml/multiple_endpoints_spec.rb +++ b/spec/features/saml/multiple_endpoints_spec.rb @@ -81,17 +81,30 @@ expect(cert_base64).to eq(Base64.strict_encode64(endpoint_cert.to_der)) end - it 'includes the correct auth url, and no SingleLogoutService urls' do + it 'includes the correct auth url' do visit endpoint_metadata_path document = REXML::Document.new(page.html) auth_node = REXML::XPath.first(document, '//SingleSignOnService') - logout_node = REXML::XPath.first(document, '//SingleLogoutService') expect(auth_node.attributes['Location']).to include( ['/api/saml/auth', endpoint_suffix].join(''), ) + end - expect(logout_node).to be_nil + it 'includes the front-channel logout url' do + visit endpoint_metadata_path + document = REXML::Document.new(page.html) + logout_nodes = REXML::XPath.match(document, '//SingleLogoutService') + expect(logout_nodes.count { |n| n['Location'].match?(%r{/api/saml/logout\d{4}}) }). + to eq(2) + end + + it 'includes the remote logout url' do + visit endpoint_metadata_path + document = REXML::Document.new(page.html) + logout_nodes = REXML::XPath.match(document, '//SingleLogoutService') + expect(logout_nodes.count { |n| n['Location'].match?(%r{/api/saml/remotelogout\d{4}}) }). + to eq(1) end end end diff --git a/spec/requests/saml_post_spec.rb b/spec/requests/saml_post_spec.rb deleted file mode 100644 index b39aac365dd..00000000000 --- a/spec/requests/saml_post_spec.rb +++ /dev/null @@ -1,18 +0,0 @@ -require 'rails_helper' - -RSpec.describe 'SAML POST handling', type: :request do - include SamlAuthHelper - - describe 'POST /api/saml/auth' do - let(:cookie_regex) { /\A(?\w+)=/ } - - it 'does not set a session cookie' do - post saml_settings.idp_sso_target_url - new_cookies = response.header['Set-Cookie'].split("\n").map do |c| - cookie_regex.match(c)[:cookie] - end - - expect(new_cookies).not_to include('_upaya_session') - end - end -end diff --git a/spec/requests/saml_requests_spec.rb b/spec/requests/saml_requests_spec.rb new file mode 100644 index 00000000000..766a00844a0 --- /dev/null +++ b/spec/requests/saml_requests_spec.rb @@ -0,0 +1,40 @@ +require 'rails_helper' + +RSpec.describe 'SAML requests', type: :request do + include SamlAuthHelper + + describe 'POST /api/saml/auth' do + let(:cookie_regex) { /\A(?\w+)=/ } + + it 'does not set a session cookie' do + post saml_settings.idp_sso_target_url + new_cookies = response.header['Set-Cookie'].split("\n").map do |c| + cookie_regex.match(c)[:cookie] + end + + expect(new_cookies).not_to include('_upaya_session') + end + end + + describe '/api/saml/remotelogout' do + let(:remote_slo_url) do + saml_settings.idp_slo_target_url.gsub('logout', 'remotelogout') + end + + it 'does not accept GET requests' do + get remote_slo_url + expect(response.status).to eq(404) + end + + it 'does not accept DELETE requests' do + delete remote_slo_url + expect(response.status).to eq(404) + end + + it 'accepts POST requests' do + post remote_slo_url + # fails (:bad_request) without SAMLRequest param but not 404 + expect(response.status).to eq(400) + end + end +end diff --git a/spec/services/saml_endpoint_spec.rb b/spec/services/saml_endpoint_spec.rb index 6866426cd20..0b05403957e 100644 --- a/spec/services/saml_endpoint_spec.rb +++ b/spec/services/saml_endpoint_spec.rb @@ -79,6 +79,9 @@ expect(result.configurator.single_logout_service_post_location).to match( %r{api/saml/logout2022\Z}, ) + expect(result.configurator.remote_logout_service_post_location).to match( + %r{api/saml/remotelogout2022\Z}, + ) end end end From 0f0f1174b3c737167ea37c763e75d8309cff44fd Mon Sep 17 00:00:00 2001 From: Sammy Date: Mon, 9 May 2022 09:13:14 -0400 Subject: [PATCH 24/29] Lg 6167 second mfa for sms (#6278) * changelog: feature, prevent phone from being the only mfa method when multi-mfa-option feature flag is enabled, LG-6167 * Create partial for mfa selection checkboxes with js validation and sass * update tests Co-authored-by: Zach Margolis Co-authored-by: Andrew Duthie --- .../components/_validated-checkbox.scss | 7 +++++ app/assets/stylesheets/components/all.scss | 1 + ..._factor_authentication_setup_controller.rb | 14 ++++----- app/forms/two_factor_options_form.rb | 13 +++++++- .../packs/mfa_selection_component.ts | 17 ++++++++++ .../phone_selection_presenter.rb | 11 ++----- .../selection_presenter.rb | 4 ++- .../_mfa_selection_component.html.erb | 31 +++++++++++++++++++ .../index.html.erb | 21 ++----------- config/locales/errors/en.yml | 1 + config/locales/errors/es.yml | 1 + config/locales/errors/fr.yml | 1 + .../locales/two_factor_authentication/en.yml | 4 ++- .../locales/two_factor_authentication/es.yml | 7 +++-- .../locales/two_factor_authentication/fr.yml | 6 ++-- db/schema.rb | 2 +- ...or_authentication_setup_controller_spec.rb | 18 ++++++++--- .../multiple_mfa_sign_up_spec.rb | 28 +++++++++++++++++ spec/forms/two_factor_options_form_spec.rb | 23 ++++++++++---- .../phone_selection_presenter_spec.rb | 8 ++--- .../_mfa_selection_component.html.erb_spec.rb | 31 +++++++++++++++++++ 21 files changed, 190 insertions(+), 59 deletions(-) create mode 100644 app/assets/stylesheets/components/_validated-checkbox.scss create mode 100644 app/javascript/packs/mfa_selection_component.ts create mode 100644 app/views/users/two_factor_authentication_setup/_mfa_selection_component.html.erb create mode 100644 spec/views/users/two_factor_authentication_setup/_mfa_selection_component.html.erb_spec.rb diff --git a/app/assets/stylesheets/components/_validated-checkbox.scss b/app/assets/stylesheets/components/_validated-checkbox.scss new file mode 100644 index 00000000000..7bb8efb9024 --- /dev/null +++ b/app/assets/stylesheets/components/_validated-checkbox.scss @@ -0,0 +1,7 @@ +.mfa-selection { + .usa-checkbox__input--tile:checked + + label.checkbox__invalid.usa-checkbox__label.usa-checkbox__label--illustrated { + border-color: color('secondary'); + border-width: 2px; + } +} diff --git a/app/assets/stylesheets/components/all.scss b/app/assets/stylesheets/components/all.scss index 1a0947e5b58..435850e3922 100644 --- a/app/assets/stylesheets/components/all.scss +++ b/app/assets/stylesheets/components/all.scss @@ -24,4 +24,5 @@ @import 'spinner-dots'; @import 'step-indicator'; @import 'troubleshooting-options'; +@import 'validated-checkbox'; @import 'i18n-dropdown'; diff --git a/app/controllers/users/two_factor_authentication_setup_controller.rb b/app/controllers/users/two_factor_authentication_setup_controller.rb index 9543960b3cd..3731705cdfc 100644 --- a/app/controllers/users/two_factor_authentication_setup_controller.rb +++ b/app/controllers/users/two_factor_authentication_setup_controller.rb @@ -6,7 +6,6 @@ class TwoFactorAuthenticationSetupController < ApplicationController before_action :authenticate_user before_action :confirm_user_authenticated_for_2fa_setup before_action :confirm_user_needs_2fa_setup - before_action :handle_empty_selection, only: :create def index @two_factor_options_form = TwoFactorOptionsForm.new(current_user) @@ -20,10 +19,16 @@ def create if result.success? process_valid_form + elsif result.errors[:selection].include? 'phone' + flash[:phone_error] = t('errors.two_factor_auth_setup.must_select_additional_option') + redirect_to two_factor_options_path(anchor: 'select_phone') else @presenter = two_factor_options_presenter render :index end + rescue ActionController::ParameterMissing + flash[:error] = t('errors.two_factor_auth_setup.must_select_option') + redirect_back(fallback_location: two_factor_options_path, allow_other_host: false) end private @@ -47,13 +52,6 @@ def process_valid_form redirect_to confirmation_path(user_session[:selected_mfa_options].first) end - def handle_empty_selection - return if params[:two_factor_options_form].present? - - flash[:error] = t('errors.two_factor_auth_setup.must_select_option') - redirect_back(fallback_location: two_factor_options_path, allow_other_host: false) - end - def confirm_user_needs_2fa_setup return unless mfa_policy.two_factor_enabled? return if params.has_key?(:multiple_mfa_setup) diff --git a/app/forms/two_factor_options_form.rb b/app/forms/two_factor_options_form.rb index e25258d8676..addd67d4ee6 100644 --- a/app/forms/two_factor_options_form.rb +++ b/app/forms/two_factor_options_form.rb @@ -7,6 +7,10 @@ class TwoFactorOptionsForm validates :selection, inclusion: { in: %w[phone sms voice auth_app piv_cac webauthn webauthn_platform backup_code] } + validates :selection, length: { minimum: 2, message: 'phone' }, if: [ + :multiple_mfa_options_enabled?, + :phone_selected?, + ] def initialize(user) self.user = user @@ -17,7 +21,6 @@ def submit(params) success = valid? update_otp_delivery_preference_for_user if success && user_needs_updating? - FormResponse.new(success: success, errors: errors, extra: extra_analytics_attributes) end @@ -42,4 +45,12 @@ def update_otp_delivery_preference_for_user selection.find { |element| %w[voice sms].include?(element) } } UpdateUser.new(user: user, attributes: user_attributes).call end + + def multiple_mfa_options_enabled? + IdentityConfig.store.select_multiple_mfa_options + end + + def phone_selected? + selection.include?('phone') || selection.include?('voice') || selection.include?('sms') + end end diff --git a/app/javascript/packs/mfa_selection_component.ts b/app/javascript/packs/mfa_selection_component.ts new file mode 100644 index 00000000000..3f25909de9d --- /dev/null +++ b/app/javascript/packs/mfa_selection_component.ts @@ -0,0 +1,17 @@ +function clearPhoneSelectionError() { + const error = document.getElementById('phone_error'); + const invalid = document.querySelector('label.checkbox__invalid'); + if (error) { + error.style.display = 'none'; + } + if (invalid) { + invalid.classList.remove('checkbox__invalid'); + } +} + +document.addEventListener('DOMContentLoaded', () => { + const checkboxes = document.getElementsByName('two_factor_options_form[selection][]'); + checkboxes.forEach((checkbox) => { + checkbox.onchange = clearPhoneSelectionError; + }); +}); diff --git a/app/presenters/two_factor_authentication/phone_selection_presenter.rb b/app/presenters/two_factor_authentication/phone_selection_presenter.rb index 102540443e1..dc5f67c9292 100644 --- a/app/presenters/two_factor_authentication/phone_selection_presenter.rb +++ b/app/presenters/two_factor_authentication/phone_selection_presenter.rb @@ -13,14 +13,9 @@ def type end def info - voip_note = if IdentityConfig.store.voip_block - t('two_factor_authentication.two_factor_choice_options.phone_info_no_voip') - end - - safe_join( - [t('two_factor_authentication.two_factor_choice_options.phone_info'), *voip_note], - ' ', - ) + IdentityConfig.store.select_multiple_mfa_options ? + t('two_factor_authentication.two_factor_choice_options.phone_info_html') : + t('two_factor_authentication.two_factor_choice_options.phone_info') end def security_level diff --git a/app/presenters/two_factor_authentication/selection_presenter.rb b/app/presenters/two_factor_authentication/selection_presenter.rb index 137f9ccf734..e03675b1445 100644 --- a/app/presenters/two_factor_authentication/selection_presenter.rb +++ b/app/presenters/two_factor_authentication/selection_presenter.rb @@ -112,7 +112,9 @@ def setup_info(type) when 'backup_code' t('two_factor_authentication.two_factor_choice_options.backup_code_info') when 'phone' - t('two_factor_authentication.two_factor_choice_options.phone_info') + IdentityConfig.store.select_multiple_mfa_options ? + t('two_factor_authentication.two_factor_choice_options.phone_info_html') : + t('two_factor_authentication.two_factor_choice_options.phone_info') when 'piv_cac' t('two_factor_authentication.two_factor_choice_options.piv_cac_info') when 'sms' diff --git a/app/views/users/two_factor_authentication_setup/_mfa_selection_component.html.erb b/app/views/users/two_factor_authentication_setup/_mfa_selection_component.html.erb new file mode 100644 index 00000000000..ebbb0934146 --- /dev/null +++ b/app/views/users/two_factor_authentication_setup/_mfa_selection_component.html.erb @@ -0,0 +1,31 @@ +
+ <%= check_box_tag( + 'two_factor_options_form[selection][]', + option.type, + option.type == 'phone' && flash[:phone_error].present?, + disabled: option.disabled?, + class: 'usa-checkbox__input usa-checkbox__input--tile', + id: "two_factor_options_form_selection_#{option.type}", + ) %> + <%= label_tag( + "two_factor_options_form_selection_#{option.type}", + class: [ + option.type == 'phone' && flash[:phone_error] && 'checkbox__invalid', + 'usa-checkbox__label', + 'usa-checkbox__label--illustrated', + ].select(&:present?).join(' '), + ) do %> + <%= image_tag(asset_url("mfa-options/#{option.type}.svg"), alt: "#{option.label} icon", class: 'usa-checkbox__image') %> +
<%= option.label %> + + <%= option.info %> + +
+ <% end %> + <% if option.type == "phone" && flash[:phone_error] %> + + <% end %> +
+<%= javascript_packs_tag_once('mfa_selection_component') %> \ No newline at end of file diff --git a/app/views/users/two_factor_authentication_setup/index.html.erb b/app/views/users/two_factor_authentication_setup/index.html.erb index b5c060a52be..5b418f66bb4 100644 --- a/app/views/users/two_factor_authentication_setup/index.html.erb +++ b/app/views/users/two_factor_authentication_setup/index.html.erb @@ -32,25 +32,8 @@ <% @presenter.options.each do |option| %>
" class="<%= option.html_class %>"> <% if IdentityConfig.store.select_multiple_mfa_options %> - <%= check_box_tag( - 'two_factor_options_form[selection][]', - option.type, - false, - disabled: option.disabled?, - class: 'usa-checkbox__input usa-checkbox__input--tile', - id: "two_factor_options_form_selection_#{option.type}", - ) %> - <%= label_tag( - "two_factor_options_form_selection_#{option.type}", - class: 'usa-checkbox__label usa-checkbox__label--illustrated', - ) do %> - <%= image_tag(asset_url("mfa-options/#{option.type}.svg"), alt: "#{option.label} icon", class: 'usa-checkbox__image') %> -
<%= option.label %> - - <%= option.info %> - -
- <% end %> + <%= render partial: 'mfa_selection_component', + locals: { form: f, option: option } %> <% else %> <%= radio_button_tag( 'two_factor_options_form[selection]', diff --git a/config/locales/errors/en.yml b/config/locales/errors/en.yml index 049bee7e87a..78b14ac0fcb 100644 --- a/config/locales/errors/en.yml +++ b/config/locales/errors/en.yml @@ -107,6 +107,7 @@ en: sign_in: bad_password_limit: You have exceeeded the maximum sign in attempts. two_factor_auth_setup: + must_select_additional_option: Select an additional authentication method. must_select_option: Select an authentication method. verify_personal_key: throttled: You tried too many times, please try again in %{timeout}. diff --git a/config/locales/errors/es.yml b/config/locales/errors/es.yml index 0674e94c139..0d6f56777f5 100644 --- a/config/locales/errors/es.yml +++ b/config/locales/errors/es.yml @@ -112,6 +112,7 @@ es: sign_in: bad_password_limit: Has superado el número máximo de intentos de inicio de sesión. two_factor_auth_setup: + must_select_additional_option: Seleccione un método de autenticación adicional. must_select_option: Seleccione un método de autenticación. verify_personal_key: throttled: Lo intentaste muchas veces, vuelve a intentarlo en %{timeout}. diff --git a/config/locales/errors/fr.yml b/config/locales/errors/fr.yml index a173700b69a..3384668a7f6 100644 --- a/config/locales/errors/fr.yml +++ b/config/locales/errors/fr.yml @@ -121,6 +121,7 @@ fr: sign_in: bad_password_limit: Vous avez dépassé le nombre maximal de tentatives de connexion. two_factor_auth_setup: + must_select_additional_option: Sélectionnez une méthode d’authentification supplémentaire. must_select_option: Sélectionnez une méthode d’authentification. verify_personal_key: throttled: Vous avez essayé plusieurs fois, essayez à nouveau dans %{timeout}. diff --git a/config/locales/two_factor_authentication/en.yml b/config/locales/two_factor_authentication/en.yml index 1281a2f41d4..d57527b7d9f 100644 --- a/config/locales/two_factor_authentication/en.yml +++ b/config/locales/two_factor_authentication/en.yml @@ -133,7 +133,9 @@ en: less_secure_label: Less secure more_secure_label: More Secure phone: Text or Voice Message - phone_info: Receive a secure code by (SMS) text or phone call to your device. + phone_info: Receive a secure code by (SMS) text or phone call. + phone_info_html: Receive a secure code by (SMS) text or phone call. You + need to select another method in addition to this one. phone_info_no_voip: Do not use web-based (VOIP) phone services or premium rate (toll) phone numbers. piv_cac: Government Employee ID diff --git a/config/locales/two_factor_authentication/es.yml b/config/locales/two_factor_authentication/es.yml index 1b732f24b5a..8568b851fe2 100644 --- a/config/locales/two_factor_authentication/es.yml +++ b/config/locales/two_factor_authentication/es.yml @@ -144,8 +144,11 @@ es: less_secure_label: Menos seguro more_secure_label: Más seguro phone: Mensaje de texto o de voz - phone_info: Reciba un código de seguridad a través de un mensaje de texto (SMS) - o una llamada telefónica a su dispositivo. + phone_info: Recibir un código seguro por medio de un mensaje de texto (SMS) o + una llamada telefónica. + phone_info_html: Recibir un código seguro por medio de un mensaje de texto (SMS) + o una llamada telefónica. Tienes que elegir otro método además + de este. phone_info_no_voip: Se prohíbe el uso de servicios telefónicos basados en la web (VOIP) o de números de teléfono de tarificación adicional (de pago). piv_cac: Identificación de empleado gubernamental diff --git a/config/locales/two_factor_authentication/fr.yml b/config/locales/two_factor_authentication/fr.yml index 0426c5618aa..0509626e2a5 100644 --- a/config/locales/two_factor_authentication/fr.yml +++ b/config/locales/two_factor_authentication/fr.yml @@ -148,8 +148,10 @@ fr: less_secure_label: Moins sécurisé more_secure_label: Plus sécurisé phone: Message texte ou vocal - phone_info: Recevez un code sécurisé par message texte ou appel téléphonique sur - votre appareil. + phone_info: Recevoir un code de sécurité par texto (SMS) ou appel téléphonique. + phone_info_html: Recevoir un code de sécurité par texto (SMS) ou appel + téléphonique. Vous devez sélectionner une autre méthode en plus + de celle-ci. phone_info_no_voip: N’utilisez pas de services téléphoniques basés sur le Web ( Voix sur IP ) ou de numéros de téléphone à tarif majoré ( péage ). piv_cac: Carte d’identification des employés du gouvernement diff --git a/db/schema.rb b/db/schema.rb index cb721d721a2..ac3ccd694cd 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -171,8 +171,8 @@ t.string "state" t.boolean "aamva" t.datetime "verify_submit_at" - t.datetime "verify_phone_submit_at" t.integer "verify_phone_submit_count", default: 0 + t.datetime "verify_phone_submit_at" t.datetime "document_capture_submit_at" t.index ["issuer"], name: "index_doc_auth_logs_on_issuer" t.index ["user_id"], name: "index_doc_auth_logs_on_user_id", unique: true diff --git a/spec/controllers/users/two_factor_authentication_setup_controller_spec.rb b/spec/controllers/users/two_factor_authentication_setup_controller_spec.rb index 96c11d000d8..e09791c8c39 100644 --- a/spec/controllers/users/two_factor_authentication_setup_controller_spec.rb +++ b/spec/controllers/users/two_factor_authentication_setup_controller_spec.rb @@ -81,7 +81,7 @@ stub_analytics result = { - selection: ['voice'], + selection: ['voice', 'auth_app'], success: true, errors: {}, } @@ -91,13 +91,14 @@ patch :create, params: { two_factor_options_form: { - selection: 'voice', + selection: ['voice', 'auth_app'], }, } end - context 'when the selection is phone' do - it 'redirects to phone setup page' do + context 'when the selection is only phone and multi mfa is enabled' do + before do + allow(IdentityConfig.store).to receive(:select_multiple_mfa_options).and_return(true) stub_sign_in_before_2fa patch :create, params: { @@ -105,8 +106,15 @@ selection: 'phone', }, } + end - expect(response).to redirect_to phone_setup_url + it 'the redirect to the form page with an anchor' do + expect(response).to redirect_to(two_factor_options_path(anchor: 'select_phone')) + end + it 'contains a flash message' do + expect(flash[:phone_error]).to eq( + t('errors.two_factor_auth_setup.must_select_additional_option'), + ) end end diff --git a/spec/features/two_factor_authentication/multiple_mfa_sign_up_spec.rb b/spec/features/two_factor_authentication/multiple_mfa_sign_up_spec.rb index 921d5f03681..82b1af21c26 100644 --- a/spec/features/two_factor_authentication/multiple_mfa_sign_up_spec.rb +++ b/spec/features/two_factor_authentication/multiple_mfa_sign_up_spec.rb @@ -76,6 +76,34 @@ end end + describe 'user attempts to submit with only the phone MFA method selected', js: true do + before do + sign_in_before_2fa + click_2fa_option('phone') + click_on t('forms.buttons.continue') + end + + scenario 'redirects to the two_factor path with an error and phone option selected' do + expect(page). + to have_content(t('errors.two_factor_auth_setup.must_select_additional_option')) + expect( + URI.parse(current_url).path + '#' + URI.parse(current_url).fragment, + ).to eq two_factor_options_path(anchor: 'select_phone') + end + + scenario 'clears the error when another mfa method is selected' do + click_2fa_option('backup_code') + expect(page). + to_not have_content(t('errors.two_factor_auth_setup.must_select_additional_option')) + end + + scenario 'clears the error when phone mfa method is unselected' do + click_2fa_option('phone') + expect(page). + to_not have_content(t('errors.two_factor_auth_setup.must_select_additional_option')) + end + end + def click_2fa_option(option) find("label[for='two_factor_options_form_selection_#{option}']").click end diff --git a/spec/forms/two_factor_options_form_spec.rb b/spec/forms/two_factor_options_form_spec.rb index bdc95b81d5c..b15ed8d6286 100644 --- a/spec/forms/two_factor_options_form_spec.rb +++ b/spec/forms/two_factor_options_form_spec.rb @@ -6,22 +6,33 @@ describe '#submit' do it 'is successful if the selection is valid' do - %w[voice sms auth_app piv_cac webauthn webauthn_platform].each do |selection| + %w[auth_app piv_cac webauthn webauthn_platform].each do |selection| result = subject.submit(selection: selection) expect(result.success?).to eq true end end + it 'is unsuccessful if the selection is invalid for multi mfa' do + allow(IdentityConfig.store).to receive(:select_multiple_mfa_options).and_return(true) + %w[phone sms voice !!!!].each do |selection| + result = subject.submit(selection: selection) + + expect(result.success?).to eq false + end + end + it 'is unsuccessful if the selection is invalid' do - result = subject.submit(selection: '!!!!') + %w[!!!!].each do |selection| + result = subject.submit(selection: selection) - expect(result.success?).to eq false - expect(result.errors).to include :selection + expect(result.success?).to eq false + expect(result.errors).to include :selection + end end context "when the selection is different from the user's otp_delivery_preference" do - it "updates the user's otp_delivery_preference" do + it "updates the user's otp_delivery_preference if they have an alternate method selected" do user_updater = instance_double(UpdateUser) allow(UpdateUser). to receive(:new). @@ -32,7 +43,7 @@ and_return(user_updater) expect(user_updater).to receive(:call) - subject.submit(selection: 'voice') + subject.submit(selection: ['voice', 'backup_code']) end end diff --git a/spec/presenters/two_factor_authentication/phone_selection_presenter_spec.rb b/spec/presenters/two_factor_authentication/phone_selection_presenter_spec.rb index b1232fd892e..a673c34bac2 100644 --- a/spec/presenters/two_factor_authentication/phone_selection_presenter_spec.rb +++ b/spec/presenters/two_factor_authentication/phone_selection_presenter_spec.rb @@ -6,6 +6,9 @@ describe '#info' do context 'when a user does not have a phone configuration (first time)' do let(:phone) { nil } + before do + allow(IdentityConfig.store).to receive(:select_multiple_mfa_options).and_return(false) + end it 'includes a note about choosing voice or sms' do expect(presenter.info). @@ -20,11 +23,6 @@ before do allow(IdentityConfig.store).to receive(:voip_block).and_return(true) end - - it 'tells people to not use voip numbers' do - expect(presenter.info). - to include(t('two_factor_authentication.two_factor_choice_options.phone_info_no_voip')) - end end end end diff --git a/spec/views/users/two_factor_authentication_setup/_mfa_selection_component.html.erb_spec.rb b/spec/views/users/two_factor_authentication_setup/_mfa_selection_component.html.erb_spec.rb new file mode 100644 index 00000000000..1de633efa3a --- /dev/null +++ b/spec/views/users/two_factor_authentication_setup/_mfa_selection_component.html.erb_spec.rb @@ -0,0 +1,31 @@ +require 'rails_helper' + +describe 'users/two_factor_authentication_setup/_mfa_selection_component.html.erb' do + include SimpleForm::ActionViewExtensions::FormHelper + include Devise::Test::ControllerHelpers + + let(:lookup_context) { ActionView::LookupContext.new(ActionController::Base.view_paths) } + let(:view_context) { ActionView::Base.new(lookup_context, {}, controller) } + let(:form_object) { User.new } + let(:presenter) { TwoFactorOptionsPresenter.new(user_agent: nil) } + let(:form_builder) do + SimpleForm::FormBuilder.new(form_object.model_name.param_key, form_object, view_context, {}) + end + + subject(:rendered) do + render partial: 'mfa_selection_component', locals: { + form: form_builder, + option: presenter.options[4], + } + end + + it 'renders an lg-validated-field tag' do + expect(rendered).to have_css('.mfa-selection') + end + + context 'before selecting options' do + it 'does not display any errors' do + expect(rendered).to_not have_css('.checkbox__invalid') + end + end +end From 95c7c317be5552c67cb7d8c8e020ca84401d6e59 Mon Sep 17 00:00:00 2001 From: Andrew Duthie Date: Mon, 9 May 2022 13:25:47 -0400 Subject: [PATCH 25/29] LG-6204: Populate initial values based on IdV app enabled steps (#6318) * LG-6204: Populate initial values based on IdV app enabled steps **Why**: Because initial values should be limited to whichever step is earliest in the enabled set of steps. changelog: Upcoming Features, Identity Verification, Add password confirmation step * Simplify verify route to specify step as parameter So that we can assert specific steps in specs via URL helpers, e.g. `idv_app_path(step: 'personal_key')` * Limit before_action based on first step * idv_app_root_path -> idv_app_path * Validate step only if present **Why**: So that root URL renders the app * Remove password_confirm from verify steps Because it's not yet implemented as of this branch * Guard possibly-undefined userBundleToken * Remove redundant const STEP_NAMES duplicates same info we should expect from enabled_step_names config * Redirect root path to first step So step name is always in the URL --- app/controllers/idv/review_controller.rb | 2 +- app/controllers/verify_controller.rb | 48 ++++++-- .../verify-flow/{index.tsx => index.ts} | 2 + .../packages/verify-flow/verify-flow.tsx | 2 + app/javascript/packs/verify-flow.tsx | 15 ++- config/routes.rb | 8 +- .../controllers/idv/review_controller_spec.rb | 2 +- spec/controllers/verify_controller_spec.rb | 111 ++++++++++++++---- spec/features/accessibility/idv_pages_spec.rb | 6 +- spec/features/idv/steps/review_step_spec.rb | 2 +- 10 files changed, 146 insertions(+), 52 deletions(-) rename app/javascript/packages/verify-flow/{index.tsx => index.ts} (68%) diff --git a/app/controllers/idv/review_controller.rb b/app/controllers/idv/review_controller.rb index 8688e2ac340..eb304dde471 100644 --- a/app/controllers/idv/review_controller.rb +++ b/app/controllers/idv/review_controller.rb @@ -115,7 +115,7 @@ def need_personal_key_confirmation? def next_step if idv_api_personal_key_step_enabled? - idv_app_root_url + idv_app_url else idv_personal_key_url end diff --git a/app/controllers/verify_controller.rb b/app/controllers/verify_controller.rb index 68cff80cc46..1f5e12e6632 100644 --- a/app/controllers/verify_controller.rb +++ b/app/controllers/verify_controller.rb @@ -2,11 +2,13 @@ class VerifyController < ApplicationController include RenderConditionConcern include IdvSession + check_or_render_not_found -> { FeatureManagement.idv_api_enabled? }, only: [:show] + + before_action :redirect_root_path_to_first_step + before_action :validate_step before_action :confirm_two_factor_authenticated before_action :confirm_idv_vendor_session_started - before_action :confirm_profile_has_been_created - - check_or_render_not_found -> { FeatureManagement.idv_api_enabled? }, only: [:show] + before_action :confirm_profile_has_been_created, if: :first_step_is_personal_key? def show @app_data = app_data @@ -14,22 +16,52 @@ def show private + def redirect_root_path_to_first_step + redirect_to idv_app_path(step: first_step) if params[:step].blank? + end + + def validate_step + render_not_found if !enabled_steps.include?(params[:step]) + end + def app_data user_session[:idv_api_store_key] ||= Base64.strict_encode64(random_encryption_key) { - base_path: idv_app_root_path, + base_path: idv_app_path, app_name: APP_NAME, completion_url: completion_url, - initial_values: { - 'personalKey' => personal_key, - 'userBundleToken' => user_bundle_token, - }, + initial_values: initial_values, enabled_step_names: IdentityConfig.store.idv_api_enabled_steps, store_key: user_session[:idv_api_store_key], } end + def initial_values + case first_step + when 'password_confirm' + { 'userBundleToken' => user_bundle_token } + when 'personal_key' + { 'personalKey' => personal_key } + end + end + + def first_step + enabled_steps.detect { |step| step_enabled?(step) } + end + + def first_step_is_personal_key? + first_step == 'personal_key' + end + + def enabled_steps + IdentityConfig.store.idv_api_enabled_steps + end + + def step_enabled?(step) + enabled_steps.include?(step) + end + def random_encryption_key Encryption::AesCipher.encryption_cipher.random_key end diff --git a/app/javascript/packages/verify-flow/index.tsx b/app/javascript/packages/verify-flow/index.ts similarity index 68% rename from app/javascript/packages/verify-flow/index.tsx rename to app/javascript/packages/verify-flow/index.ts index d98e4e84e5d..a412251b0cd 100644 --- a/app/javascript/packages/verify-flow/index.tsx +++ b/app/javascript/packages/verify-flow/index.ts @@ -1,2 +1,4 @@ export { SecretsContextProvider } from './context/secrets-context'; export { default as VerifyFlow } from './verify-flow'; + +export type { VerifyFlowValues } from './verify-flow'; diff --git a/app/javascript/packages/verify-flow/verify-flow.tsx b/app/javascript/packages/verify-flow/verify-flow.tsx index d1598c49db1..f4097973926 100644 --- a/app/javascript/packages/verify-flow/verify-flow.tsx +++ b/app/javascript/packages/verify-flow/verify-flow.tsx @@ -6,6 +6,8 @@ import VerifyFlowStepIndicator from './verify-flow-step-indicator'; import VerifyFlowAlert from './verify-flow-alert'; export interface VerifyFlowValues { + userBundleToken?: string; + personalKey?: string; personalKeyConfirm?: string; diff --git a/app/javascript/packs/verify-flow.tsx b/app/javascript/packs/verify-flow.tsx index 2abeccff602..99c8e9c4576 100644 --- a/app/javascript/packs/verify-flow.tsx +++ b/app/javascript/packs/verify-flow.tsx @@ -1,6 +1,7 @@ import { render } from 'react-dom'; import { VerifyFlow, SecretsContextProvider } from '@18f/identity-verify-flow'; import { s2ab } from '@18f/identity-secret-session-storage'; +import type { VerifyFlowValues } from '@18f/identity-verify-flow'; interface AppRootValues { /** @@ -53,17 +54,19 @@ const { storeKey: storeKeyBase64, } = appRoot.dataset; const storeKey = s2ab(atob(storeKeyBase64)); -const initialValues = JSON.parse(initialValuesJSON); +const initialValues: Partial = JSON.parse(initialValuesJSON); const enabledStepNames = JSON.parse(enabledStepNamesJSON) as string[]; const camelCase = (string: string) => string.replace(/[^a-z]([a-z])/gi, (_match, nextLetter) => nextLetter.toUpperCase()); -const jwtData = JSON.parse(atob(initialValues.userBundleToken.split('.')[1])); -const pii = Object.fromEntries( - Object.entries(jwtData.pii).map(([key, value]) => [camelCase(key), value]), -); -Object.assign(initialValues, pii); +if (initialValues.userBundleToken) { + const jwtData = JSON.parse(atob(initialValues.userBundleToken.split('.')[1])); + const pii = Object.fromEntries( + Object.entries(jwtData.pii).map(([key, value]) => [camelCase(key), value]), + ); + Object.assign(initialValues, pii); +} function onComplete() { window.location.href = completionURL; diff --git a/config/routes.rb b/config/routes.rb index 007180c9423..05652b03c88 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -324,13 +324,7 @@ post '/confirmations' => 'personal_key#update' end - scope '/verify/v2' do - get '/' => 'verify#show', as: :idv_app_root - %w[ - /personal_key - /personal_key_confirm - ].each { |step_path| get step_path => 'verify#show' } - end + get '/verify/v2(/:step)' => 'verify#show', as: :idv_app namespace :api do post '/verify/complete' => 'verify/complete#create' diff --git a/spec/controllers/idv/review_controller_spec.rb b/spec/controllers/idv/review_controller_spec.rb index 8a15471b633..cd866e877b7 100644 --- a/spec/controllers/idv/review_controller_spec.rb +++ b/spec/controllers/idv/review_controller_spec.rb @@ -358,7 +358,7 @@ def show it 'redirects to idv app personal key path' do put :create, params: { user: { password: ControllerHelper::VALID_PASSWORD } } - expect(response).to redirect_to idv_app_root_url + expect(response).to redirect_to idv_app_url end end end diff --git a/spec/controllers/verify_controller_spec.rb b/spec/controllers/verify_controller_spec.rb index 7a46452d12b..c785a50ce87 100644 --- a/spec/controllers/verify_controller_spec.rb +++ b/spec/controllers/verify_controller_spec.rb @@ -2,6 +2,7 @@ describe VerifyController do describe '#show' do + let(:idv_api_enabled_steps) { [] } let(:password) { 'sekrit phrase' } let(:user) { create(:user, :signed_up, password: password) } let(:applicant) do @@ -16,37 +17,106 @@ } end let(:profile) { subject.idv_session.profile } + let(:step) { '' } - subject(:response) { get :show } + subject(:response) { get :show, params: { step: step } } before do - stub_sign_in + allow(IdentityConfig.store).to receive(:idv_api_enabled_steps). + and_return(idv_api_enabled_steps) + stub_sign_in(user) stub_idv_session end - context 'with step feature-disabled' do - before do - allow(IdentityConfig.store).to receive(:idv_api_enabled_steps).and_return([]) - end + it 'renders 404' do + expect(response).to be_not_found + end + + context 'with idv api enabled' do + let(:idv_api_enabled_steps) { ['something'] } + + context 'invalid step' do + let(:step) { 'bad' } - it 'renders 404' do - expect(response).to be_not_found + it 'renders 404' do + expect(response).to be_not_found + end end - end - context 'with step feature-enabled' do - before do - allow(IdentityConfig.store).to receive(:idv_api_enabled_steps). - and_return(['personal_key', 'personal_key_confirm']) + context 'with personal key step enabled' do + let(:idv_api_enabled_steps) { ['personal_key', 'personal_key_confirm'] } + let(:step) { 'personal_key' } + + before do + profile_maker = Idv::ProfileMaker.new( + applicant: applicant, + user: user, + user_password: password, + ) + profile = profile_maker.save_profile + controller.idv_session.pii = profile_maker.pii_attributes + controller.idv_session.profile_id = profile.id + controller.idv_session.personal_key = profile.personal_key + end + + it 'renders view' do + expect(response).to render_template(:show) + end + + it 'sets app data' do + response + + expect(assigns[:app_data]).to include( + app_name: APP_NAME, + base_path: idv_app_path, + completion_url: idv_gpo_verify_url, + enabled_step_names: idv_api_enabled_steps, + initial_values: { 'personalKey' => kind_of(String) }, + store_key: kind_of(String), + ) + end + + context 'empty step' do + let(:step) { nil } + + it 'redirects to first step' do + expect(response).to redirect_to idv_app_path(step: 'personal_key') + end + end end - it 'renders view' do - expect(response).to render_template(:show) + context 'with password confirmation step enabled' do + let(:idv_api_enabled_steps) { ['password_confirm', 'personal_key', 'personal_key_confirm'] } + let(:step) { 'password_confirm' } + + it 'renders view' do + expect(response).to render_template(:show) + end + + it 'sets app data' do + response + + expect(assigns[:app_data]).to include( + app_name: APP_NAME, + base_path: idv_app_path, + completion_url: account_url, + enabled_step_names: idv_api_enabled_steps, + initial_values: { 'userBundleToken' => kind_of(String) }, + store_key: kind_of(String), + ) + end + + context 'empty step' do + let(:step) { nil } + + it 'redirects to first step' do + expect(response).to redirect_to idv_app_path(step: 'password_confirm') + end + end end end def stub_idv_session - stub_sign_in(user) idv_session = Idv::Session.new( user_session: controller.user_session, current_user: user, @@ -54,15 +124,6 @@ def stub_idv_session ) idv_session.applicant = applicant idv_session.resolution_successful = true - profile_maker = Idv::ProfileMaker.new( - applicant: applicant, - user: user, - user_password: password, - ) - profile = profile_maker.save_profile - idv_session.pii = profile_maker.pii_attributes - idv_session.profile_id = profile.id - idv_session.personal_key = profile.personal_key allow(controller).to receive(:idv_session).and_return(idv_session) end end diff --git a/spec/features/accessibility/idv_pages_spec.rb b/spec/features/accessibility/idv_pages_spec.rb index 398b30c1225..07892a34d88 100644 --- a/spec/features/accessibility/idv_pages_spec.rb +++ b/spec/features/accessibility/idv_pages_spec.rb @@ -57,7 +57,7 @@ fill_in t('idv.form.password'), with: Features::SessionHelper::VALID_PASSWORD click_continue - expect(current_path).to be_in([idv_personal_key_path, idv_app_root_path]) + expect(current_path).to be_in([idv_personal_key_path, idv_app_path]) expect(page).to be_axe_clean.according_to :section508, :"best-practice", :wcag21aa expect(page).to label_required_fields expect(page).to be_uniquely_titled @@ -71,7 +71,7 @@ fill_in t('idv.form.password'), with: Features::SessionHelper::VALID_PASSWORD click_continue - expect(current_path).to be_in([idv_personal_key_path, idv_app_root_path]) + expect(current_path).to be_in([idv_personal_key_path, idv_app_path]) expect(page).to be_axe_clean.according_to :section508, :"best-practice", :wcag21aa expect(page).to label_required_fields expect(page).to be_uniquely_titled @@ -85,7 +85,7 @@ fill_in t('idv.form.password'), with: Features::SessionHelper::VALID_PASSWORD click_continue - expect(current_path).to be_in([idv_personal_key_path, idv_app_root_path]) + expect(current_path).to be_in([idv_personal_key_path, idv_app_path]) expect(page).to be_axe_clean.according_to :section508, :"best-practice", :wcag21aa expect(page).to label_required_fields expect(page).to be_uniquely_titled diff --git a/spec/features/idv/steps/review_step_spec.rb b/spec/features/idv/steps/review_step_spec.rb index 375d961e77b..fb89d595d37 100644 --- a/spec/features/idv/steps/review_step_spec.rb +++ b/spec/features/idv/steps/review_step_spec.rb @@ -33,7 +33,7 @@ click_idv_continue expect(page).to have_content(t('headings.personal_key')) - expect(current_path).to be_in([idv_personal_key_path, idv_app_root_path]) + expect(current_path).to be_in([idv_personal_key_path, idv_app_path]) end context 'choosing to confirm address with phone' do From 2bd9626cf8ca8d45d688369eb71038deb9d9fc72 Mon Sep 17 00:00:00 2001 From: Mitchell Henke Date: Mon, 9 May 2022 13:22:52 -0500 Subject: [PATCH 26/29] Drop unused types for sp_costs (#6322) changelog: Internal, Optimization, Do not create sp_costs for unused cost types --- app/controllers/application_controller.rb | 10 --- .../concerns/billable_event_trackable.rb | 1 - app/controllers/users/sessions_controller.rb | 1 - .../two_factor_authentication_controller.rb | 5 +- app/services/db/sp_cost/add_sp_cost.rb | 6 -- app/services/identity_linker.rb | 1 - .../idv/send_phone_confirmation_otp.rb | 1 - spec/controllers/saml_idp_controller_spec.rb | 12 --- spec/features/sp_cost_tracking_spec.rb | 80 ++----------------- 9 files changed, 8 insertions(+), 109 deletions(-) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 85bb625cd77..f9c8c89bf71 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -428,16 +428,6 @@ def analytics_exception_info(exception) } end - def add_sp_cost(token) - Db::SpCost::AddSpCost.call( - current_sp, - sp_session_ial, - token, - transaction_id: nil, - user: current_user, - ) - end - def mobile? BrowserCache.parse(request.user_agent).mobile? end diff --git a/app/controllers/concerns/billable_event_trackable.rb b/app/controllers/concerns/billable_event_trackable.rb index 1b6798c4ea1..573bb965f16 100644 --- a/app/controllers/concerns/billable_event_trackable.rb +++ b/app/controllers/concerns/billable_event_trackable.rb @@ -6,7 +6,6 @@ def track_billing_events increment_sp_monthly_auths create_sp_return_log(billable: true) mark_current_session_billed - add_sp_cost(:authentication) end end diff --git a/app/controllers/users/sessions_controller.rb b/app/controllers/users/sessions_controller.rb index 03d582431f4..d409eac260a 100644 --- a/app/controllers/users/sessions_controller.rb +++ b/app/controllers/users/sessions_controller.rb @@ -127,7 +127,6 @@ def process_locked_out_user def handle_valid_authentication sign_in(resource_name, resource) cache_active_profile(auth_params[:password]) - add_sp_cost(:digest) create_user_event(:sign_in_before_2fa) EmailAddress.update_last_sign_in_at_on_user_id_and_email( user_id: current_user.id, diff --git a/app/controllers/users/two_factor_authentication_controller.rb b/app/controllers/users/two_factor_authentication_controller.rb index 5fb042de916..d888257ff86 100644 --- a/app/controllers/users/two_factor_authentication_controller.rb +++ b/app/controllers/users/two_factor_authentication_controller.rb @@ -180,7 +180,7 @@ def handle_valid_otp_params(method, default = nil) end def handle_telephony_result(method:, default:) - track_events(method) + track_events if @telephony_result.success? redirect_to login_two_factor_url( otp_delivery_preference: method, @@ -197,9 +197,8 @@ def handle_telephony_result(method:, default:) end end - def track_events(method) + def track_events analytics.track_event(Analytics::TELEPHONY_OTP_SENT, @telephony_result.to_h) - add_sp_cost(method) if @telephony_result.success? end def exceeded_otp_send_limit? diff --git a/app/services/db/sp_cost/add_sp_cost.rb b/app/services/db/sp_cost/add_sp_cost.rb index f7f4fe606aa..6c4ef015e18 100644 --- a/app/services/db/sp_cost/add_sp_cost.rb +++ b/app/services/db/sp_cost/add_sp_cost.rb @@ -9,15 +9,9 @@ class SpCostTypeError < StandardError; end acuant_back_image acuant_result acuant_selfie - authentication - digest lexis_nexis_resolution lexis_nexis_address gpo_letter - phone_otp - sms - user_added - voice ].freeze def self.call(service_provider, ial, token, transaction_id: nil, user: nil) diff --git a/app/services/identity_linker.rb b/app/services/identity_linker.rb index 254f0326ec8..fa52e198fc4 100644 --- a/app/services/identity_linker.rb +++ b/app/services/identity_linker.rb @@ -45,7 +45,6 @@ def identity def find_or_create_identity_with_costing identity_record = identity_relation.first return identity_record if identity_record - Db::SpCost::AddSpCost.call(service_provider, @ial, :user_added) user.identities.create(service_provider: service_provider.issuer) end diff --git a/app/services/idv/send_phone_confirmation_otp.rb b/app/services/idv/send_phone_confirmation_otp.rb index 0a960e51f6a..abc9e8bc235 100644 --- a/app/services/idv/send_phone_confirmation_otp.rb +++ b/app/services/idv/send_phone_confirmation_otp.rb @@ -73,7 +73,6 @@ def otp_sent_response def add_cost Db::ProofingCost::AddUserProofingCost.call(user.id, :phone_otp) - Db::SpCost::AddSpCost.call(idv_session.service_provider, 2, :phone_otp) end def extra_analytics_attributes diff --git a/spec/controllers/saml_idp_controller_spec.rb b/spec/controllers/saml_idp_controller_spec.rb index 7e9919b25d2..d622683e9a9 100644 --- a/spec/controllers/saml_idp_controller_spec.rb +++ b/spec/controllers/saml_idp_controller_spec.rb @@ -1753,8 +1753,6 @@ def stub_requested_attributes ial: 1) generate_saml_response(user) - - expect_sp_authentication_cost end end @@ -1789,8 +1787,6 @@ def stub_requested_attributes ial: 1) generate_saml_response(user) - - expect_sp_authentication_cost end end end @@ -1817,12 +1813,4 @@ def stub_requested_attributes expect(subject.external_saml_request?).to eq false end end - - def expect_sp_authentication_cost - sp_cost = SpCost.where( - issuer: 'http://localhost:3000', - cost_type: 'authentication', - ).first - expect(sp_cost).to be_present - end end diff --git a/spec/features/sp_cost_tracking_spec.rb b/spec/features/sp_cost_tracking_spec.rb index dfd2cd79e12..bb1ad8ae172 100644 --- a/spec/features/sp_cost_tracking_spec.rb +++ b/spec/features/sp_cost_tracking_spec.rb @@ -12,32 +12,21 @@ let(:email) { 'test@test.com' } let(:password) { Features::SessionHelper::VALID_PASSWORD } - it 'logs the correct costs for an ial1 user creation from sp with oidc' do - create_ial1_user_from_sp(email) - - expect_sp_cost_type(0, 1, 'sms') - expect_sp_cost_type(1, 1, 'user_added') - expect_sp_cost_type(2, 1, 'authentication') - end - it 'logs the correct costs for an ial2 user creation from sp with oidc', js: true do create_ial2_user_from_sp(email) - expect_sp_cost_type(0, 2, 'sms') - expect_sp_cost_type(1, 2, 'acuant_front_image') - expect_sp_cost_type(2, 2, 'acuant_back_image') - expect_sp_cost_type(3, 2, 'acuant_result') + expect_sp_cost_type(0, 2, 'acuant_front_image') + expect_sp_cost_type(1, 2, 'acuant_back_image') + expect_sp_cost_type(2, 2, 'acuant_result') expect_sp_cost_type( - 4, 2, 'lexis_nexis_resolution', + 3, 2, 'lexis_nexis_resolution', transaction_id: Proofing::Mock::ResolutionMockClient::TRANSACTION_ID ) expect_sp_cost_type( - 5, 2, 'aamva', + 4, 2, 'aamva', transaction_id: Proofing::Mock::StateIdMockClient::TRANSACTION_ID ) - expect_sp_cost_type(6, 2, 'lexis_nexis_address') - expect_sp_cost_type(7, 2, 'user_added') - expect_sp_cost_type(8, 2, 'authentication') + expect_sp_cost_type(5, 2, 'lexis_nexis_address') end it 'logs the cost to the SP for reproofing', js: true do @@ -76,56 +65,6 @@ end end - it 'logs the correct costs for an ial1 authentication' do - create_ial1_user_from_sp(email) - SpCost.delete_all - - # track costs without dealing with 'remember device' - Capybara.reset_session! - - visit_idp_from_sp_with_ial1(:oidc) - fill_in_credentials_and_submit(email, password) - fill_in_code_with_last_phone_otp - click_submit_default - - expect_sp_cost_type(0, 1, 'digest') - expect_sp_cost_type(1, 1, 'sms') - expect_sp_cost_type(2, 1, 'authentication') - end - - it 'logs the correct costs for an ial2 authentication', js: true do - create_ial2_user_from_sp(email) - SpCost.delete_all - - # track costs without dealing with 'remember device' - Capybara.reset_session! - - visit_idp_from_sp_with_ial2(:oidc) - fill_in_credentials_and_submit(email, password) - fill_in_code_with_last_phone_otp - click_submit_default - - expect_sp_cost_type(0, 2, 'digest') - expect_sp_cost_type(1, 2, 'sms') - expect_sp_cost_type(2, 2, 'authentication') - end - - it 'logs the correct costs for a direct authentication' do - visit root_path - create_ial1_user_directly(email) - SpCost.delete_all - - # track costs without dealing with 'remember device' - Capybara.reset_session! - - visit root_path - fill_in_credentials_and_submit(email, password) - fill_in_code_with_last_phone_otp - click_submit_default - - expect_direct_cost_type(0, 'digest') - end - def expect_sp_cost_type(sp_cost_index, ial, token, transaction_id: nil) sp_cost = sp_costs(sp_cost_index) expect(sp_cost.ial).to eq(ial) @@ -135,13 +74,6 @@ def expect_sp_cost_type(sp_cost_index, ial, token, transaction_id: nil) expect(sp_cost.transaction_id).to(eq(transaction_id)) if transaction_id end - def expect_direct_cost_type(sp_cost_index, token) - sp_cost = sp_costs(sp_cost_index) - expect(sp_cost.issuer).to eq('') - expect(sp_cost.agency_id).to eq(0) - expect(sp_cost.cost_type).to eq(token) - end - def sp_costs(index) SpCost.order('id asc')[index] end From fb8d3d4da0dd404bb5966a016a8c26610797dd30 Mon Sep 17 00:00:00 2001 From: Oren Kanner Date: Tue, 10 May 2022 00:35:20 -0400 Subject: [PATCH 27/29] Support IALMAX using the Comparison attribute in SAML (#5652) **Why:** Several partners have requested the ability to have users sign in at the maximum level of identity assurance they have obtained without sending multiple requests to determine whether a user has a verified credential or not. The SAML spec does support a `Comparison` attribute for the `` element that can be set to "exact" (the default), "minimum", "maximum", or "better". These determine what authentication context the response should meet relative to the requested AuthnContext in the SAML request. The specific implementation of how those are treated is left up to the responder (in this case, Login.gov). In this commit, we add the capability for Login.gov to send a user back with either an auth-only or verified credential to an SP configured to receive verified attributes when they request the IAL1 AuthnContext with a Comparison attribute set to "minimum". This does not change the behavior when the IAL2 AuthnContext is requested or when the SP is not configured to receive verified attributes. This also includes more comprehensive feature specs for both the overall behavior as well as billing records in the `sp_redirect_logs` table. changelog: Improvements, Authentication, Support IALMAX using the SAML Comparison attribute --- .../concerns/billable_event_trackable.rb | 6 +- .../concerns/saml_idp_auth_concern.rb | 1 + .../authorization_controller.rb | 4 + app/controllers/saml_idp_controller.rb | 18 +- app/forms/openid_connect_authorize_form.rb | 8 +- app/presenters/saml_request_presenter.rb | 22 ++- app/services/attribute_asserter.rb | 19 ++- app/services/ial_context.rb | 20 ++- spec/controllers/saml_idp_controller_spec.rb | 120 +++++++++++++ spec/features/ialmax/saml_sign_in_spec.rb | 157 ++++++++++++++++++ .../presenters/saml_request_presenter_spec.rb | 57 ++++++- spec/services/attribute_asserter_spec.rb | 75 +++++++++ spec/services/ial_context_spec.rb | 56 +++++-- spec/support/saml_auth_helper.rb | 13 ++ spec/support/shared_examples/sign_in.rb | 5 + 15 files changed, 541 insertions(+), 40 deletions(-) create mode 100644 spec/features/ialmax/saml_sign_in_spec.rb diff --git a/app/controllers/concerns/billable_event_trackable.rb b/app/controllers/concerns/billable_event_trackable.rb index 573bb965f16..0f08e90debf 100644 --- a/app/controllers/concerns/billable_event_trackable.rb +++ b/app/controllers/concerns/billable_event_trackable.rb @@ -20,14 +20,14 @@ def increment_sp_monthly_auths end def create_sp_return_log(billable:) - ial_context = IalContext.new( - ial: sp_session_ial, service_provider: current_sp, user: current_user, + user_ial_context = IalContext.new( + ial: ial_context.ial, service_provider: current_sp, user: current_user, ) Db::SpReturnLog.create_return( request_id: request_id, user_id: current_user.id, billable: billable, - ial: ial_context.bill_for_ial_1_or_2, + ial: user_ial_context.bill_for_ial_1_or_2, issuer: current_sp.issuer, requested_at: session[:session_started_at], ) diff --git a/app/controllers/concerns/saml_idp_auth_concern.rb b/app/controllers/concerns/saml_idp_auth_concern.rb index 10c7bcf0a9b..adf162a5af9 100644 --- a/app/controllers/concerns/saml_idp_auth_concern.rb +++ b/app/controllers/concerns/saml_idp_auth_concern.rb @@ -122,6 +122,7 @@ def ial_context @ial_context ||= IalContext.new( ial: requested_ial_authn_context, service_provider: saml_request_service_provider, + authn_context_comparison: saml_request.requested_authn_context_comparison, ) end diff --git a/app/controllers/openid_connect/authorization_controller.rb b/app/controllers/openid_connect/authorization_controller.rb index a6ab0006e53..adcc1f1836d 100644 --- a/app/controllers/openid_connect/authorization_controller.rb +++ b/app/controllers/openid_connect/authorization_controller.rb @@ -58,6 +58,10 @@ def link_identity_to_service_provider @authorize_form.link_identity_to_service_provider(current_user, session.id) end + def ial_context + @authorize_form.ial_context + end + def handle_successful_handoff track_events SpHandoffBounce::AddHandoffTimeToSession.call(sp_session) diff --git a/app/controllers/saml_idp_controller.rb b/app/controllers/saml_idp_controller.rb index 361fb05b158..d5c5cd090a5 100644 --- a/app/controllers/saml_idp_controller.rb +++ b/app/controllers/saml_idp_controller.rb @@ -100,10 +100,14 @@ def redirect_to_verification_url end def profile_or_identity_needs_verification_or_decryption? - return false unless ial_context.ial2_or_greater? + return false unless ial_context.ial2_or_greater? || ialmax_requested_with_ial2_user? profile_needs_verification? || identity_needs_verification? || identity_needs_decryption? end + def ialmax_requested_with_ial2_user? + ial_context.ialmax_requested? && identity_needs_decryption? + end + def identity_needs_decryption? UserDecorator.new(current_user).identity_verified? && !Pii::Cacher.new(current_user, user_session).exists_in_session? @@ -114,7 +118,7 @@ def capture_analytics endpoint: remap_auth_post_path(request.env['PATH_INFO']), idv: identity_needs_verification?, finish_profile: profile_needs_verification?, - requested_ial: saml_request&.requested_ial_authn_context || 'none', + requested_ial: requested_ial, ) analytics.track_event(Analytics::SAML_AUTH, analytics_payload) end @@ -123,11 +127,17 @@ def log_external_saml_auth_request return unless external_saml_request? analytics.saml_auth_request( - requested_ial: saml_request&.requested_ial_authn_context || 'none', + requested_ial: requested_ial, service_provider: saml_request&.issuer, ) end + def requested_ial + return 'ialmax' if ial_context.ialmax_requested? + + saml_request&.requested_ial_authn_context || 'none' + end + def handle_successful_handoff track_events delete_branded_experience @@ -152,7 +162,7 @@ def render_template_for(message, action_url, type) end def track_events - analytics.track_event(Analytics::SP_REDIRECT_INITIATED, ial: sp_session_ial) + analytics.track_event(Analytics::SP_REDIRECT_INITIATED, ial: ial_context.ial) track_billing_events end end diff --git a/app/forms/openid_connect_authorize_form.rb b/app/forms/openid_connect_authorize_form.rb index 27d789e3263..f4aa6076e90 100644 --- a/app/forms/openid_connect_authorize_form.rb +++ b/app/forms/openid_connect_authorize_form.rb @@ -95,6 +95,10 @@ def aal_values acr_values.filter { |acr| %r{/aal/}.match? acr } end + def ial_context + @ial_context ||= IalContext.new(ial: ial, service_provider: service_provider) + end + def_delegators :ial_context, :ial2_or_greater?, :ial2_requested?, @@ -104,10 +108,6 @@ def aal_values attr_reader :identity, :success - def ial_context - @ial_context ||= IalContext.new(ial: ial, service_provider: service_provider) - end - def check_for_unauthorized_scope(params) param_value = params[:scope] return false if ial2_or_greater? || param_value.blank? diff --git a/app/presenters/saml_request_presenter.rb b/app/presenters/saml_request_presenter.rb index 1535af95c85..ff6e2231c65 100644 --- a/app/presenters/saml_request_presenter.rb +++ b/app/presenters/saml_request_presenter.rb @@ -3,7 +3,6 @@ class SamlRequestPresenter email: :email, all_emails: :all_emails, first_name: :given_name, - middle_name: :name, last_name: :family_name, dob: :birthdate, ssn: :social_security_number, @@ -27,6 +26,7 @@ def requested_attributes bundle.map { |attr| ATTRIBUTE_TO_FRIENDLY_NAME_MAP[attr] }.compact.uniq else attrs = [:email] + attrs << :all_emails if bundle.include?(:all_emails) attrs << :verified_at if bundle.include?(:verified_at) attrs end @@ -37,7 +37,7 @@ def requested_attributes attr_reader :request, :service_provider def ial2_authn_context? - (Saml::Idp::Constants::IAL2_AUTHN_CONTEXTS & authn_context).present? + ial_context.ial2_requested? end def ial2_strict_authn_context? @@ -45,13 +45,29 @@ def ial2_strict_authn_context? end def ialmax_authn_context? - authn_context.include? Saml::Idp::Constants::IALMAX_AUTHN_CONTEXT_CLASSREF + ial_context.ialmax_requested? end def authn_context request.requested_authn_contexts end + def ial_context + @ial_context ||= IalContext.new( + ial: request.requested_ial_authn_context || default_ial_context, + service_provider: service_provider, + authn_context_comparison: request.requested_authn_context_comparison, + ) + end + + def default_ial_context + if service_provider&.ial + Saml::Idp::Constants::AUTHN_CONTEXT_IAL_TO_CLASSREF[service_provider.ial] + else + Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF + end + end + def bundle @bundle ||= ( authn_request_bundle || service_provider&.attribute_bundle || [] diff --git a/app/services/attribute_asserter.rb b/app/services/attribute_asserter.rb index b7cf33006ea..e4b4737a01f 100644 --- a/app/services/attribute_asserter.rb +++ b/app/services/attribute_asserter.rb @@ -52,7 +52,12 @@ def build :user_session def ial_context - @ial_context ||= IalContext.new(ial: authn_context, service_provider: service_provider) + @ial_context ||= IalContext.new( + ial: authn_context, + service_provider: service_provider, + user: user, + authn_context_comparison: authn_request&.requested_authn_context_comparison, + ) end def default_attrs @@ -120,11 +125,19 @@ def add_aal(attrs) end def add_ial(attrs) - context = authn_request.requested_ial_authn_context - context ||= Saml::Idp::Constants::AUTHN_CONTEXT_IAL_TO_CLASSREF[service_provider.ial] + requested_context = authn_request.requested_ial_authn_context + context = if ial_context.ialmax_requested? && ial_context.ial2_requested? + sp_ial # IAL2 since IALMAX only works for IAL2 SPs + else + requested_context.presence || sp_ial + end attrs[:ial] = { getter: ial_getter_function(context) } if context end + def sp_ial + Saml::Idp::Constants::AUTHN_CONTEXT_IAL_TO_CLASSREF[service_provider.ial] + end + def add_x509(attrs) attrs[:x509_subject] = { getter: ->(_principal) { x509_data.subject } } attrs[:x509_issuer] = { getter: ->(_principal) { x509_data.issuer } } diff --git a/app/services/ial_context.rb b/app/services/ial_context.rb index fd1b9dd11b5..e1af6e3d6d5 100644 --- a/app/services/ial_context.rb +++ b/app/services/ial_context.rb @@ -1,14 +1,15 @@ # Wraps up logic for querying the IAL level of an authorization request class IalContext - attr_reader :ial, :service_provider, :user + attr_reader :ial, :service_provider, :user, :authn_context_comparison # @param ial [String, Integer] IAL level as either an integer (see ::Idp::Constants::IAL2, etc) # or a string see Saml::Idp::Constants contexts # @param service_provider [ServiceProvider, nil] - def initialize(ial:, service_provider:, user: nil) - @ial = int_ial(ial) + def initialize(ial:, service_provider:, user: nil, authn_context_comparison: nil) + @authn_context_comparison = authn_context_comparison @service_provider = service_provider @user = user + @ial = int_ial(ial) end def ial2_service_provider? @@ -46,6 +47,19 @@ def ial2_strict_requested? private def int_ial(input) + return 0 if saml_ialmax?(input) + + convert_ial_to_int(input) + end + + def saml_ialmax?(input) + int_ial_from_request = convert_ial_to_int(input) + return false unless int_ial_from_request.present? + + service_provider&.ial == 2 && authn_context_comparison == 'minimum' && int_ial_from_request < 2 + end + + def convert_ial_to_int(input) Integer(input) rescue TypeError # input was nil nil diff --git a/spec/controllers/saml_idp_controller_spec.rb b/spec/controllers/saml_idp_controller_spec.rb index d622683e9a9..096413627e1 100644 --- a/spec/controllers/saml_idp_controller_spec.rb +++ b/spec/controllers/saml_idp_controller_spec.rb @@ -441,6 +441,15 @@ def name_id_version(format_urn) }, ) end + let(:ialmax_settings) do + saml_settings( + overrides: { + issuer: sp1_issuer, + authn_context: Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF, + authn_context_comparison: 'minimum', + }, + ) + end shared_examples 'a verified identity' do |authn_context, ial| let(:ial2_settings) do @@ -604,6 +613,117 @@ def name_id_version(format_urn) end end + context 'with IALMAX and the identity is already verified' do + let(:user) { create(:profile, :active, :verified).user } + let(:pii) do + Pii::Attributes.new_from_hash( + first_name: 'Some', + last_name: 'One', + ssn: '666666666', + zipcode: '12345', + ) + end + let(:this_authn_request) do + ialmax_authnrequest = saml_authn_request_url( + overrides: { + issuer: sp1_issuer, + authn_context: Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF, + authn_context_comparison: 'minimum', + }, + ) + raw_req = CGI.unescape ialmax_authnrequest.split('SAMLRequest').last + SamlIdp::Request.from_deflated_request(raw_req) + end + let(:asserter) do + AttributeAsserter.new( + user: user, + service_provider: ServiceProvider.find_by(issuer: sp1_issuer), + authn_request: this_authn_request, + name_id_format: Saml::Idp::Constants::NAME_ID_FORMAT_PERSISTENT, + decrypted_pii: pii, + user_session: {}, + ) + end + + before do + stub_sign_in(user) + IdentityLinker.new(user, ServiceProvider.find_by(issuer: sp1_issuer)).link_identity(ial: 2) + user.identities.last.update!( + verified_attributes: %w[email given_name family_name social_security_number address], + ) + allow(subject).to receive(:attribute_asserter) { asserter } + + controller.user_session[:decrypted_pii] = pii + end + + it 'calls AttributeAsserter#build' do + expect(asserter).to receive(:build).at_least(:once).and_call_original + + saml_get_auth(ialmax_settings) + end + + it 'sets identity ial to 0' do + saml_get_auth(ialmax_settings) + expect(user.identities.last.ial).to eq(0) + end + + it 'does not redirect the user to the IdV URL' do + saml_get_auth(ialmax_settings) + + expect(response).to_not be_redirect + end + + it 'contains verified attributes' do + saml_get_auth(ialmax_settings) + + expect(xmldoc.attribute_node_for('address1')).to be_nil + + %w[first_name last_name ssn zipcode].each do |attr| + node_value = xmldoc.attribute_value_for(attr) + expect(node_value).to eq(pii[attr]) + end + + expect(xmldoc.attribute_value_for('verified_at')).to eq( + user.active_profile.verified_at.iso8601, + ) + end + + it 'tracks IAL2 authentication events' do + stub_analytics + expect(@analytics).to receive(:track_event). + with('SAML Auth Request', + requested_ial: 'ialmax', + service_provider: sp1_issuer) + expect(@analytics).to receive(:track_event). + with(Analytics::SAML_AUTH, + success: true, + errors: {}, + nameid_format: Saml::Idp::Constants::NAME_ID_FORMAT_PERSISTENT, + authn_context: ['http://idmanagement.gov/ns/assurance/ial/1'], + authn_context_comparison: 'minimum', + requested_ial: 'ialmax', + service_provider: sp1_issuer, + endpoint: '/api/saml/auth2022', + idv: false, + finish_profile: false) + expect(@analytics).to receive(:track_event). + with(Analytics::SP_REDIRECT_INITIATED, + ial: 0) + + allow(controller).to receive(:identity_needs_verification?).and_return(false) + saml_get_auth(ialmax_settings) + end + + context 'profile is not in session' do + let(:pii) { nil } + + it 'redirects to password capture if profile is verified but not in session' do + saml_get_auth(ialmax_settings) + expect(response).to redirect_to capture_password_url + end + end + end + context 'authn_context is invalid' do it 'renders an error page' do stub_analytics diff --git a/spec/features/ialmax/saml_sign_in_spec.rb b/spec/features/ialmax/saml_sign_in_spec.rb new file mode 100644 index 00000000000..c5925ff6f8e --- /dev/null +++ b/spec/features/ialmax/saml_sign_in_spec.rb @@ -0,0 +1,157 @@ +require 'rails_helper' + +feature 'SAML IALMAX sign in' do + include SamlAuthHelper + + context 'with an ial2 SP' do + context 'with an ial1 user' do + scenario 'piv sign in' do + user = user_with_piv_cac + visit_idp_from_saml_sp_with_ialmax + signin_with_piv(user) + click_agree_and_continue + + xmldoc = SamlResponseDoc.new('feature', 'response_assertion') + expect(xmldoc.attribute_value_for(:ial)).to eq( + Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF, + ) + expect { xmldoc.attribute_value_for(:ssn) }.to raise_exception + + sp_return_logs = SpReturnLog.where(user_id: user.id) + expect(sp_return_logs.count).to eq(1) + expect(sp_return_logs.first.ial).to eq(1) + end + + scenario 'password sign in' do + user = create(:user, :signed_up) + visit_idp_from_saml_sp_with_ialmax + sign_in_live_with_2fa(user) + click_agree_and_continue + + xmldoc = SamlResponseDoc.new('feature', 'response_assertion') + expect(xmldoc.attribute_value_for(:ial)).to eq( + Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF, + ) + expect { xmldoc.attribute_value_for(:ssn) }.to raise_exception + + sp_return_logs = SpReturnLog.where(user_id: user.id) + expect(sp_return_logs.count).to eq(1) + expect(sp_return_logs.first.ial).to eq(1) + end + end + + context 'with an ial2 user' do + scenario 'piv sign in' do + pii = { phone: '+12025555555', ssn: '111111111' } + user = create(:profile, :active, :verified, pii: pii).user + user.piv_cac_configurations.create(x509_dn_uuid: 'helloworld', name: 'My PIV Card') + visit_idp_from_saml_sp_with_ialmax + signin_with_piv(user) + fill_in 'user[password]', with: user.password + click_submit_default + click_agree_and_continue + + xmldoc = SamlResponseDoc.new('feature', 'response_assertion') + expect(xmldoc.attribute_value_for(:ial)).to eq( + Saml::Idp::Constants::IAL2_AUTHN_CONTEXT_CLASSREF, + ) + expect { xmldoc.attribute_value_for(:ssn) }.not_to raise_exception + expect(xmldoc.attribute_value_for(:ssn)).to eq('111111111') + + sp_return_logs = SpReturnLog.where(user_id: user.id) + expect(sp_return_logs.count).to eq(1) + expect(sp_return_logs.first.ial).to eq(2) + end + + scenario 'password sign in' do + pii = { phone: '+12025555555', ssn: '111111111' } + user = create(:profile, :active, :verified, pii: pii).user + visit_idp_from_saml_sp_with_ialmax + sign_in_live_with_2fa(user) + click_agree_and_continue + + xmldoc = SamlResponseDoc.new('feature', 'response_assertion') + expect(xmldoc.attribute_value_for(:ial)).to eq( + Saml::Idp::Constants::IAL2_AUTHN_CONTEXT_CLASSREF, + ) + expect { xmldoc.attribute_value_for(:ssn) }.not_to raise_exception + expect(xmldoc.attribute_value_for(:ssn)).to eq('111111111') + + sp_return_logs = SpReturnLog.where(user_id: user.id) + expect(sp_return_logs.count).to eq(1) + expect(sp_return_logs.first.ial).to eq(2) + end + end + + context 'with an inactive profile user' do + scenario 'piv sign in' do + user = create(:profile, :active, :verified).user + user.profiles.first.update!( + active: false, + deactivation_reason: :verification_cancelled, + ) + user.piv_cac_configurations.create(x509_dn_uuid: 'helloworld', name: 'My PIV Card') + visit_idp_from_saml_sp_with_ialmax + signin_with_piv(user) + click_agree_and_continue + + xmldoc = SamlResponseDoc.new('feature', 'response_assertion') + expect(xmldoc.attribute_value_for(:ial)).to eq( + Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF, + ) + expect { xmldoc.attribute_value_for(:ssn) }.to raise_exception + + sp_return_logs = SpReturnLog.where(user_id: user.id) + expect(sp_return_logs.count).to eq(1) + expect(sp_return_logs.first.ial).to eq(1) + end + + scenario 'password sign in' do + user = create(:profile, :active, :verified).user + user.profiles.first.update!( + active: false, + deactivation_reason: :verification_cancelled, + ) + visit_idp_from_saml_sp_with_ialmax + sign_in_live_with_2fa(user) + click_agree_and_continue + + xmldoc = SamlResponseDoc.new('feature', 'response_assertion') + expect(xmldoc.attribute_value_for(:ial)).to eq( + Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF, + ) + expect { xmldoc.attribute_value_for(:ssn) }.to raise_exception + + sp_return_logs = SpReturnLog.where(user_id: user.id) + expect(sp_return_logs.count).to eq(1) + expect(sp_return_logs.first.ial).to eq(1) + end + end + end + + context 'with an ial1 SP' do + before do + ServiceProvider. + find_by(issuer: 'saml_sp_ial2'). + update!(ial: 1) + end + + scenario 'returns an ial1 responses even with an ial2 user' do + pii = { phone: '+12025555555', ssn: '111111111' } + user = create(:profile, :active, :verified, pii: pii).user + visit_idp_from_saml_sp_with_ialmax + sign_in_live_with_2fa(user) + click_agree_and_continue + + xmldoc = SamlResponseDoc.new('feature', 'response_assertion') + expect(xmldoc.attribute_value_for(:ial)).to eq( + Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF, + ) + expect { xmldoc.attribute_value_for(:ssn) }.to raise_exception + + sp_return_logs = SpReturnLog.where(user_id: user.id) + expect(sp_return_logs.count).to eq(1) + expect(sp_return_logs.first.ial).to eq(1) + end + end +end diff --git a/spec/presenters/saml_request_presenter_spec.rb b/spec/presenters/saml_request_presenter_spec.rb index 850ce56b1e4..58e8f858ce2 100644 --- a/spec/presenters/saml_request_presenter_spec.rb +++ b/spec/presenters/saml_request_presenter_spec.rb @@ -8,19 +8,68 @@ allow(FakeSamlRequest).to receive(:new).and_return(request) allow(request).to receive(:requested_authn_contexts). and_return([Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF]) + allow(request).to receive(:requested_authn_context_comparison).and_return('exact') + allow(request).to receive(:requested_ial_authn_context). + and_return(Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF) parser = instance_double(SamlRequestParser) allow(SamlRequestParser).to receive(:new).with(request).and_return(parser) allow(parser).to receive(:requested_attributes).and_return(nil) all_attributes = %w[ - email first_name middle_name last_name dob ssn verified_at + email all_emails first_name last_name dob ssn verified_at phone address1 address2 city state zipcode foo ] service_provider = ServiceProvider.new(attribute_bundle: all_attributes) presenter = SamlRequestPresenter.new(request: request, service_provider: service_provider) - expect(presenter.requested_attributes).to eq(%i[email verified_at]) + expect(presenter.requested_attributes).to eq(%i[email all_emails verified_at]) + end + end + + context 'with no requested context and IAL2 SP' do + it 'returns SP attribute_bundle' do + request = instance_double(FakeSamlRequest) + allow(FakeSamlRequest).to receive(:new).and_return(request) + allow(request).to receive(:requested_authn_contexts). + and_return([]) + allow(request).to receive(:requested_authn_context_comparison).and_return('exact') + allow(request).to receive(:requested_ial_authn_context). + and_return(nil) + + parser = instance_double(SamlRequestParser) + allow(SamlRequestParser).to receive(:new).with(request).and_return(parser) + allow(parser).to receive(:requested_attributes).and_return(nil) + + sp_attributes = %w[email first_name last_name ssn zipcode] + service_provider = ServiceProvider.new(attribute_bundle: sp_attributes, ial: 2) + presenter = SamlRequestPresenter.new(request: request, service_provider: service_provider) + + expect(presenter.requested_attributes).to eq( + %i[email given_name family_name social_security_number address], + ) + end + end + + context 'with no requested context and IAL1 SP' do + it 'returns permitted attribute_bundle' do + request = instance_double(FakeSamlRequest) + allow(FakeSamlRequest).to receive(:new).and_return(request) + allow(request).to receive(:requested_authn_contexts). + and_return([]) + allow(request).to receive(:requested_authn_context_comparison).and_return('exact') + allow(request).to receive(:requested_ial_authn_context). + and_return(nil) + + parser = instance_double(SamlRequestParser) + allow(SamlRequestParser).to receive(:new).with(request).and_return(parser) + allow(parser).to receive(:requested_attributes).and_return(nil) + + sp_attributes = %w[email first_name last_name ssn zipcode all_emails] + service_provider = ServiceProvider.new(attribute_bundle: sp_attributes, ial: 1) + presenter = SamlRequestPresenter.new(request: request, service_provider: service_provider) + + expect(presenter.requested_attributes).to eq(%i[email all_emails]) end end @@ -34,12 +83,12 @@ service_provider = ServiceProvider.new( attribute_bundle: %w[ - email first_name middle_name last_name dob foo ssn phone verified_at + email first_name last_name dob foo ssn phone verified_at ], ) presenter = SamlRequestPresenter.new(request: request, service_provider: service_provider) valid_attributes = %i[ - email given_name name family_name birthdate social_security_number phone verified_at + email given_name family_name birthdate social_security_number phone verified_at ] expect(presenter.requested_attributes).to eq(valid_attributes) diff --git a/spec/services/attribute_asserter_spec.rb b/spec/services/attribute_asserter_spec.rb index c7a38bf3576..680a2bfdf90 100644 --- a/spec/services/attribute_asserter_spec.rb +++ b/spec/services/attribute_asserter_spec.rb @@ -71,6 +71,18 @@ ) CGI.unescape ial1_aal3_authnrequest.split('SAMLRequest').last end + let(:raw_ialmax_authn_request) do + ialmax_authnrequest = saml_authn_request_url( + overrides: { + issuer: sp1_issuer, + authn_context: [ + Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF, + ], + authn_context_comparison: 'minimum', + }, + ) + CGI.unescape ialmax_authnrequest.split('SAMLRequest').last + end let(:sp1_authn_request) do SamlIdp::Request.from_deflated_request(raw_sp1_authn_request) end @@ -86,6 +98,9 @@ let(:ial1_aal3_authn_request) do SamlIdp::Request.from_deflated_request(raw_ial1_aal3_authn_request) end + let(:ialmax_authn_request) do + SamlIdp::Request.from_deflated_request(raw_ialmax_authn_request) + end let(:decrypted_pii) do Pii::Attributes.new_from_hash( first_name: 'Jåné', @@ -502,6 +517,66 @@ end end + context 'IALMAX' do + context 'service provider requests IALMAX with IAL1 user' do + let(:service_provider_ial) { 2 } + let(:subject) do + described_class.new( + user: user, + name_id_format: name_id_format, + service_provider: service_provider, + authn_request: ialmax_authn_request, + decrypted_pii: decrypted_pii, + user_session: user_session, + ) + end + + before do + user.profiles.delete_all + subject.build + end + + it 'includes ial' do + expect(user.asserted_attributes.keys).to include(:ial) + end + + it 'creates a getter function for ial attribute' do + expected_ial = Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF + expect(user.asserted_attributes[:ial][:getter].call(user)).to eq expected_ial + end + end + + context 'service provider requests IALMAX with IAL2 user' do + let(:service_provider_ial) { 2 } + let(:subject) do + described_class.new( + user: user, + name_id_format: name_id_format, + service_provider: service_provider, + authn_request: ialmax_authn_request, + decrypted_pii: decrypted_pii, + user_session: user_session, + ) + end + + before do + user.identities << identity + allow(service_provider.metadata).to receive(:[]).with(:attribute_bundle). + and_return(%w[email phone first_name]) + subject.build + end + + it 'includes ial' do + expect(user.asserted_attributes.keys).to include(:ial) + end + + it 'creates a getter function for ial attribute' do + expected_ial = Saml::Idp::Constants::IAL2_AUTHN_CONTEXT_CLASSREF + expect(user.asserted_attributes[:ial][:getter].call(user)).to eq expected_ial + end + end + end + shared_examples 'unverified user' do context 'custom bundle does not include email, phone' do before do diff --git a/spec/services/ial_context_spec.rb b/spec/services/ial_context_spec.rb index c67cb497eff..faed99b7635 100644 --- a/spec/services/ial_context_spec.rb +++ b/spec/services/ial_context_spec.rb @@ -10,8 +10,16 @@ ) end let(:user) { nil } - - subject(:ial_context) { IalContext.new(ial: ial, service_provider: service_provider, user: user) } + let(:authn_context_comparison) { nil } + + subject(:ial_context) do + IalContext.new( + ial: ial, + service_provider: service_provider, + user: user, + authn_context_comparison: authn_context_comparison, + ) + end describe '#ial' do context 'with an integer input' do @@ -135,8 +143,24 @@ it { expect(ial_context.ialmax_requested?).to eq(true) } end - context 'when ial 1 is requested' do + context 'when ial 1 is requested without Comparison=minimum and ial 2 SP' do let(:ial) { Idp::Constants::IAL1 } + let(:authn_context_comparison) { 'exact' } + let(:sp_ial) { 2 } + it { expect(ial_context.ialmax_requested?).to eq(false) } + end + + context 'when ial 1 is requested with Comparison=minimum and ial 2 SP' do + let(:ial) { Idp::Constants::IAL1 } + let(:authn_context_comparison) { 'minimum' } + let(:sp_ial) { 2 } + it { expect(ial_context.ialmax_requested?).to eq(true) } + end + + context 'when ial 1 is requested with Comparison=minimum and ial 1 SP' do + let(:ial) { Idp::Constants::IAL1 } + let(:authn_context_comparison) { 'minimum' } + let(:sp_ial) { 1 } it { expect(ial_context.ialmax_requested?).to eq(false) } end @@ -268,11 +292,23 @@ end describe '#ial2_requested?' do - context 'when ialmax is requested' do + context 'when ialmax is requested without a user' do + let(:ial) { Idp::Constants::IAL_MAX } + it { expect(ial_context.ial2_requested?).to eq(false) } + end + + context 'when ialmax is requested with a user with no profile' do let(:ial) { Idp::Constants::IAL_MAX } + let(:user) { create(:user, :signed_up) } it { expect(ial_context.ial2_requested?).to eq(false) } end + context 'when ialmax is requested with a user with a verified profile' do + let(:ial) { Idp::Constants::IAL_MAX } + let(:user) { create(:profile, :active, :verified).user } + it { expect(ial_context.ial2_requested?).to eq(true) } + end + context 'when ial 1 is requested' do let(:ial) { Idp::Constants::IAL1 } it { expect(ial_context.ial2_requested?).to eq(false) } @@ -293,18 +329,6 @@ let(:ial) { Idp::Constants::IAL2 } it { expect(ial_context.ial2_requested?).to eq(true) } end - - context 'when ial max and the user has proofed for ial2' do - let(:ial) { Idp::Constants::IAL_MAX } - let(:user) do - create( - :user, - :signed_up, - profiles: [build(:profile, :active, :verified, pii: { first_name: 'Jane' })], - ) - end - it { expect(ial_context.ial2_requested?).to eq(true) } - end end describe '#ial2_strict_requested?' do diff --git a/spec/support/saml_auth_helper.rb b/spec/support/saml_auth_helper.rb index 795ac74d448..914f1fd0434 100644 --- a/spec/support/saml_auth_helper.rb +++ b/spec/support/saml_auth_helper.rb @@ -310,6 +310,19 @@ def visit_idp_from_oidc_sp_with_hspd12_and_require_piv_cac ) end + def visit_idp_from_saml_sp_with_ialmax + visit_saml_authn_request_url( + overrides: { + issuer: 'saml_sp_ial2', + authn_context: [ + Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF, + "#{Saml::Idp::Constants::REQUESTED_ATTRIBUTES_CLASSREF}ssn", + ], + authn_context_comparison: 'minimum', + }, + ) + end + def visit_idp_from_oidc_sp_with_ialmax state = SecureRandom.hex client_id = 'urn:gov:gsa:openidconnect:sp:server' diff --git a/spec/support/shared_examples/sign_in.rb b/spec/support/shared_examples/sign_in.rb index a9747032779..a940785c583 100644 --- a/spec/support/shared_examples/sign_in.rb +++ b/spec/support/shared_examples/sign_in.rb @@ -346,6 +346,11 @@ def no_authn_context_sign_in_with_piv_cac_goes_to_sp(sp) fill_in_password_and_submit(user.password) + # needed because the SP default attribute bundle includes the zip_code + # attribute which wasn't originally requested, so consent is required + expect(page).to have_current_path(sign_up_completed_path) + click_agree_and_continue + if javascript_enabled? expect(current_path).to eq(test_saml_decode_assertion_path) else From 7eef1660d27bc29a483b4386b789995bc0814eb3 Mon Sep 17 00:00:00 2001 From: Jessica Dembe Date: Tue, 10 May 2022 11:53:02 -0400 Subject: [PATCH 28/29] Complete path for users to setup an additional method (#6276) changelog: Upcoming feature, multi-factor-authentication, complete sad path flow Co-authored-by: Zach Margolis --- .../users/mfa_selection_controller.rb | 49 ++++++ ..._factor_authentication_setup_controller.rb | 1 - .../_mfa_selection.html.erb} | 2 +- app/views/sign_up/completions/show.html.erb | 2 +- app/views/users/mfa_selection/index.html.erb | 33 ++++ .../index.html.erb | 4 +- config/routes.rb | 2 + .../users/mfa_selection_controller_spec.rb | 157 ++++++++++++++++++ ...or_authentication_setup_controller_spec.rb | 3 + .../mfa_cta_spec.rb | 9 +- .../_mfa_selection.html.erb_spec.rb} | 4 +- 11 files changed, 254 insertions(+), 12 deletions(-) create mode 100644 app/controllers/users/mfa_selection_controller.rb rename app/views/{users/two_factor_authentication_setup/_mfa_selection_component.html.erb => partials/multi_factor_authentication/_mfa_selection.html.erb} (95%) create mode 100644 app/views/users/mfa_selection/index.html.erb create mode 100644 spec/controllers/users/mfa_selection_controller_spec.rb rename spec/views/{users/two_factor_authentication_setup/_mfa_selection_component.html.erb_spec.rb => partials/multi_factor_authentication/_mfa_selection.html.erb_spec.rb} (86%) diff --git a/app/controllers/users/mfa_selection_controller.rb b/app/controllers/users/mfa_selection_controller.rb new file mode 100644 index 00000000000..1afe3d1758d --- /dev/null +++ b/app/controllers/users/mfa_selection_controller.rb @@ -0,0 +1,49 @@ +module Users + class MfaSelectionController < ApplicationController + include UserAuthenticator + include MfaSetupConcern + + def index + @two_factor_options_form = TwoFactorOptionsForm.new(current_user) + @presenter = two_factor_options_presenter + analytics.track_event(Analytics::USER_REGISTRATION_2FA_SETUP_VISIT) + end + + def update + result = submit_form + analytics.track_event(Analytics::USER_REGISTRATION_2FA_SETUP, result.to_h) + + if result.success? + process_valid_form + else + @presenter = two_factor_options_presenter + render :index + end + end + + private + + def submit_form + @two_factor_options_form = TwoFactorOptionsForm.new(current_user) + @two_factor_options_form.submit(two_factor_options_form_params) + end + + def two_factor_options_presenter + TwoFactorOptionsPresenter.new( + user_agent: request.user_agent, + user: current_user, + aal3_required: service_provider_mfa_policy.aal3_required?, + piv_cac_required: service_provider_mfa_policy.piv_cac_required?, + ) + end + + def process_valid_form + user_session[:selected_mfa_options] = @two_factor_options_form.selection + redirect_to confirmation_path(user_session[:selected_mfa_options].first) + end + + def two_factor_options_form_params + params.require(:two_factor_options_form).permit(:selection, selection: []) + end + end +end diff --git a/app/controllers/users/two_factor_authentication_setup_controller.rb b/app/controllers/users/two_factor_authentication_setup_controller.rb index 3731705cdfc..10f0ade0850 100644 --- a/app/controllers/users/two_factor_authentication_setup_controller.rb +++ b/app/controllers/users/two_factor_authentication_setup_controller.rb @@ -54,7 +54,6 @@ def process_valid_form def confirm_user_needs_2fa_setup return unless mfa_policy.two_factor_enabled? - return if params.has_key?(:multiple_mfa_setup) return if service_provider_mfa_policy.user_needs_sp_auth_method_setup? redirect_to after_mfa_setup_path end diff --git a/app/views/users/two_factor_authentication_setup/_mfa_selection_component.html.erb b/app/views/partials/multi_factor_authentication/_mfa_selection.html.erb similarity index 95% rename from app/views/users/two_factor_authentication_setup/_mfa_selection_component.html.erb rename to app/views/partials/multi_factor_authentication/_mfa_selection.html.erb index ebbb0934146..1e4909f7556 100644 --- a/app/views/users/two_factor_authentication_setup/_mfa_selection_component.html.erb +++ b/app/views/partials/multi_factor_authentication/_mfa_selection.html.erb @@ -28,4 +28,4 @@
<% end %> -<%= javascript_packs_tag_once('mfa_selection_component') %> \ No newline at end of file +<%= javascript_packs_tag_once('mfa_selection_component') %> diff --git a/app/views/sign_up/completions/show.html.erb b/app/views/sign_up/completions/show.html.erb index 81b1a968500..88117f51b57 100644 --- a/app/views/sign_up/completions/show.html.erb +++ b/app/views/sign_up/completions/show.html.erb @@ -28,7 +28,7 @@ <%= render(AlertComponent.new(type: :warning, class: 'margin-bottom-4')) do %> <%= link_to( t('mfa.second_method_warning.link'), - two_factor_options_url(multiple_mfa_setup: ''), + mfa_setup_path, ) %> <%= t('mfa.second_method_warning.text') %> <% end %> diff --git a/app/views/users/mfa_selection/index.html.erb b/app/views/users/mfa_selection/index.html.erb new file mode 100644 index 00000000000..639bfcd6f73 --- /dev/null +++ b/app/views/users/mfa_selection/index.html.erb @@ -0,0 +1,33 @@ +<%= title t('two_factor_authentication.two_factor_choice') %> + +<%= render PageHeadingComponent.new.with_content(t('two_factor_authentication.two_factor_choice')) %> + +<% if IdentityConfig.store.select_multiple_mfa_options %> + <%= render AlertComponent.new(type: :info, class: 'margin-bottom-4') do %> + <%= t('mfa.info') %> + <% end %> +<% end %> + +<%= validated_form_for @two_factor_options_form, + html: { autocomplete: 'off' }, + method: :patch, + url: mfa_setup_path do |f| %> +
+
+ <%= @presenter.intro %> + <% @presenter.options.each do |option| %> +
" class="<%= option.html_class %>"> + <%= render partial: 'partials/multi_factor_authentication/mfa_selection', + locals: { form: f, option: option } %> +
+ <% end %> +
+
+ + <%= f.button :submit, t('forms.buttons.continue'), class: 'usa-button--big usa-button--wide margin-bottom-1' %> +<% end %> + +<%= render 'shared/cancel', link: destroy_user_session_path %> + +<%= javascript_packs_tag_once('webauthn-unhide') %> + diff --git a/app/views/users/two_factor_authentication_setup/index.html.erb b/app/views/users/two_factor_authentication_setup/index.html.erb index 5b418f66bb4..9881592b8a8 100644 --- a/app/views/users/two_factor_authentication_setup/index.html.erb +++ b/app/views/users/two_factor_authentication_setup/index.html.erb @@ -14,8 +14,6 @@

<%= @presenter.intro %>

-<%# If it is decided that there will be an info banner on this page for MFA setup, the text will need Gengo translations %> - <% if IdentityConfig.store.select_multiple_mfa_options %> <%= render AlertComponent.new(type: :info, class: 'margin-bottom-4') do %> <%= t('mfa.info') %> @@ -32,7 +30,7 @@ <% @presenter.options.each do |option| %>
" class="<%= option.html_class %>"> <% if IdentityConfig.store.select_multiple_mfa_options %> - <%= render partial: 'mfa_selection_component', + <%= render partial: 'partials/multi_factor_authentication/mfa_selection', locals: { form: f, option: option } %> <% else %> <%= radio_button_tag( diff --git a/config/routes.rb b/config/routes.rb index 05652b03c88..fa3dabceb29 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -220,6 +220,8 @@ get '/otp/send' => 'users/two_factor_authentication#send_code' get '/two_factor_options' => 'users/two_factor_authentication_setup#index' patch '/two_factor_options' => 'users/two_factor_authentication_setup#create' + get '/mfa_setup' => 'users/mfa_selection#index' + patch '/mfa_setup' => 'users/mfa_selection#update' get '/phone_setup' => 'users/phone_setup#index' patch '/phone_setup' => 'users/phone_setup#create' get '/aal3_required' => 'users/aal3#show' diff --git a/spec/controllers/users/mfa_selection_controller_spec.rb b/spec/controllers/users/mfa_selection_controller_spec.rb new file mode 100644 index 00000000000..3cdeadece4f --- /dev/null +++ b/spec/controllers/users/mfa_selection_controller_spec.rb @@ -0,0 +1,157 @@ +require 'rails_helper' + +describe Users::MfaSelectionController do + let(:current_sp) { create(:service_provider) } + + describe '#index' do + before do + allow(IdentityConfig.store).to receive(:select_multiple_mfa_options).and_return(true) + user = build(:user, :signed_up) + stub_sign_in(user) + end + + context 'when the user is using one authenticator option' do + it 'shows the mfa setup screen' do + controller.user_session[:selected_mfa_options] = ['backup_code'] + + get :index + + expect(response).to render_template(:index) + end + end + end + + describe '#update' do + it 'submits the TwoFactorOptionsForm' do + user = build(:user) + stub_sign_in_before_2fa(user) + + voice_params = { + two_factor_options_form: { + selection: 'voice', + }, + } + params = ActionController::Parameters.new(voice_params) + response = FormResponse.new(success: true, errors: {}, extra: { selection: ['voice'] }) + + form = instance_double(TwoFactorOptionsForm) + allow(TwoFactorOptionsForm).to receive(:new).with(user).and_return(form) + expect(form).to receive(:submit). + with(params.require(:two_factor_options_form).permit(:selection)). + and_return(response) + expect(form).to receive(:selection).and_return(['voice']) + + patch :update, params: voice_params + end + + context 'when the selection is phone' do + it 'redirects to phone setup page' do + stub_sign_in_before_2fa + + patch :update, params: { + two_factor_options_form: { + selection: 'phone', + }, + } + + expect(response).to redirect_to phone_setup_url + end + end + + context 'when multi selection with phone first' do + it 'redirects properly' do + stub_sign_in_before_2fa + patch :update, params: { + two_factor_options_form: { + selection: ['phone', 'auth_app'], + }, + } + + expect(response).to redirect_to phone_setup_url + end + end + + context 'when multi selection with auth app first' do + it 'redirects properly' do + stub_sign_in_before_2fa + patch :update, params: { + two_factor_options_form: { + selection: ['auth_app', 'phone', 'webauthn'], + }, + } + + expect(response).to redirect_to authenticator_setup_url + end + end + + context 'when the selection is auth_app' do + it 'redirects to authentication app setup page' do + stub_sign_in_before_2fa + + patch :update, params: { + two_factor_options_form: { + selection: 'auth_app', + }, + } + + expect(response).to redirect_to authenticator_setup_url + end + end + + context 'when the selection is webauthn' do + it 'redirects to webauthn setup page' do + stub_sign_in_before_2fa + + patch :update, params: { + two_factor_options_form: { + selection: 'webauthn', + }, + } + + expect(response).to redirect_to webauthn_setup_url + end + end + + context 'when the selection is webauthn platform authenticator' do + it 'redirects to webauthn setup page with the platform param' do + stub_sign_in_before_2fa + + patch :update, params: { + two_factor_options_form: { + selection: 'webauthn_platform', + }, + } + + expect(response).to redirect_to webauthn_setup_url(platform: true) + end + end + + context 'when the selection is piv_cac' do + it 'redirects to piv/cac setup page' do + stub_sign_in_before_2fa + + patch :update, params: { + two_factor_options_form: { + selection: 'piv_cac', + }, + } + + expect(response).to redirect_to setup_piv_cac_url + end + end + + context 'when the selection is not valid' do + it 'renders index page' do + stub_sign_in_before_2fa + + patch :update, params: { + two_factor_options_form: { + selection: 'foo', + }, + } + + expect(response).to render_template(:index) + end + end + end +end diff --git a/spec/controllers/users/two_factor_authentication_setup_controller_spec.rb b/spec/controllers/users/two_factor_authentication_setup_controller_spec.rb index e09791c8c39..fb056c55394 100644 --- a/spec/controllers/users/two_factor_authentication_setup_controller_spec.rb +++ b/spec/controllers/users/two_factor_authentication_setup_controller_spec.rb @@ -57,6 +57,7 @@ it 'submits the TwoFactorOptionsForm' do user = build(:user) stub_sign_in_before_2fa(user) + stub_analytics voice_params = { two_factor_options_form: { @@ -74,6 +75,8 @@ expect(form).to receive(:selection).and_return(['voice']) patch :create, params: voice_params + + expect(@analytics).to have_logged_event(Analytics::USER_REGISTRATION_2FA_SETUP, response.to_h) end it 'tracks analytics event' do diff --git a/spec/features/multi_factor_authentication/mfa_cta_spec.rb b/spec/features/multi_factor_authentication/mfa_cta_spec.rb index 317bd6a32d2..9ae73ef5f21 100644 --- a/spec/features/multi_factor_authentication/mfa_cta_spec.rb +++ b/spec/features/multi_factor_authentication/mfa_cta_spec.rb @@ -56,11 +56,12 @@ it 'redirects user to select additional authentication methods' do visit_idp_from_sp_with_ial1(:oidc) sign_up_and_set_password - select_2fa_option('backup_code') + check t('two_factor_authentication.two_factor_choice_options.backup_code') click_continue - expect(page).to have_current_path(sign_up_completed_path) - click_on(t('mfa.second_method_warning.link')) - expect(page).to have_content(t('two_factor_authentication.two_factor_choice')) + + set_up_mfa_with_backup_codes + click_link(t('mfa.second_method_warning.link')) + expect(page).to have_current_path(mfa_setup_path) end end end diff --git a/spec/views/users/two_factor_authentication_setup/_mfa_selection_component.html.erb_spec.rb b/spec/views/partials/multi_factor_authentication/_mfa_selection.html.erb_spec.rb similarity index 86% rename from spec/views/users/two_factor_authentication_setup/_mfa_selection_component.html.erb_spec.rb rename to spec/views/partials/multi_factor_authentication/_mfa_selection.html.erb_spec.rb index 1de633efa3a..ea6d2e3f6b8 100644 --- a/spec/views/users/two_factor_authentication_setup/_mfa_selection_component.html.erb_spec.rb +++ b/spec/views/partials/multi_factor_authentication/_mfa_selection.html.erb_spec.rb @@ -1,6 +1,6 @@ require 'rails_helper' -describe 'users/two_factor_authentication_setup/_mfa_selection_component.html.erb' do +describe 'partials/multi_factor_authentication/_mfa_selection.html.erb' do include SimpleForm::ActionViewExtensions::FormHelper include Devise::Test::ControllerHelpers @@ -13,7 +13,7 @@ end subject(:rendered) do - render partial: 'mfa_selection_component', locals: { + render partial: 'mfa_selection', locals: { form: form_builder, option: presenter.options[4], } From 7719a7ff37fca2b5e25f2b975dbbfe0308776ba7 Mon Sep 17 00:00:00 2001 From: Oren Kanner Date: Wed, 11 May 2022 09:08:43 -0400 Subject: [PATCH 29/29] Add feature flag for including SLO in SAML metadata (#6330) **Why:** We require logout requests to be signed but not all SAML clients send signed logout requests by default. Turning this on caused certain SAML clients that weren't previously sending SLO requests to us to start sending SLO requests, so this allows us to ease into this. [skip changelog] --- app/services/saml_endpoint.rb | 9 +++-- config/application.yml.default | 1 + lib/identity_config.rb | 1 + spec/features/saml/multiple_endpoints_spec.rb | 35 +++++++++++++------ spec/services/saml_endpoint_spec.rb | 16 +++++++++ 5 files changed, 50 insertions(+), 12 deletions(-) diff --git a/app/services/saml_endpoint.rb b/app/services/saml_endpoint.rb index a1a9014828a..5dd5da315c7 100644 --- a/app/services/saml_endpoint.rb +++ b/app/services/saml_endpoint.rb @@ -35,8 +35,13 @@ def x509_certificate def saml_metadata config = SamlIdp.config.dup config.single_service_post_location += suffix - config.single_logout_service_post_location += suffix - config.remote_logout_service_post_location += suffix + if IdentityConfig.store.include_slo_in_saml_metadata + config.single_logout_service_post_location += suffix + config.remote_logout_service_post_location += suffix + else + config.single_logout_service_post_location = nil + config.remote_logout_service_post_location = nil + end SamlIdp::MetadataBuilder.new( config, diff --git a/config/application.yml.default b/config/application.yml.default index e3b75262343..a16558208ad 100644 --- a/config/application.yml.default +++ b/config/application.yml.default @@ -102,6 +102,7 @@ idv_private_key: 'LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlCT2dJQkFBSkJBS3 idv_send_link_attempt_window_in_minutes: 10 idv_send_link_max_attempts: 5 in_person_proofing_enabled: true +include_slo_in_saml_metadata: false liveness_checking_enabled: false logins_per_ip_track_only_mode: false # LexisNexis ##################################################### diff --git a/lib/identity_config.rb b/lib/identity_config.rb index 9a410d4695b..33001e01f9d 100644 --- a/lib/identity_config.rb +++ b/lib/identity_config.rb @@ -176,6 +176,7 @@ def self.build_store(config_map) config.add(:idv_send_link_attempt_window_in_minutes, type: :integer) config.add(:idv_send_link_max_attempts, type: :integer) config.add(:in_person_proofing_enabled, type: :boolean) + config.add(:include_slo_in_saml_metadata, type: :boolean) config.add(:lexisnexis_base_url, type: :string) config.add(:lexisnexis_request_mode, type: :string) config.add(:lexisnexis_account_id, type: :string) diff --git a/spec/features/saml/multiple_endpoints_spec.rb b/spec/features/saml/multiple_endpoints_spec.rb index d501a33e69e..8b9e2d62069 100644 --- a/spec/features/saml/multiple_endpoints_spec.rb +++ b/spec/features/saml/multiple_endpoints_spec.rb @@ -91,20 +91,35 @@ ) end - it 'includes the front-channel logout url' do - visit endpoint_metadata_path + it 'does not include logout urls if configured' do + allow(IdentityConfig.store).to receive(:include_slo_in_saml_metadata). + and_return(false) document = REXML::Document.new(page.html) logout_nodes = REXML::XPath.match(document, '//SingleLogoutService') - expect(logout_nodes.count { |n| n['Location'].match?(%r{/api/saml/logout\d{4}}) }). - to eq(2) + expect(logout_nodes.count).to be_zero end - it 'includes the remote logout url' do - visit endpoint_metadata_path - document = REXML::Document.new(page.html) - logout_nodes = REXML::XPath.match(document, '//SingleLogoutService') - expect(logout_nodes.count { |n| n['Location'].match?(%r{/api/saml/remotelogout\d{4}}) }). - to eq(1) + context 'when configured to include logout endpoints' do + before do + allow(IdentityConfig.store).to receive(:include_slo_in_saml_metadata). + and_return(true) + end + + it 'includes the front-channel logout url' do + visit endpoint_metadata_path + document = REXML::Document.new(page.html) + logout_nodes = REXML::XPath.match(document, '//SingleLogoutService') + expect(logout_nodes.count { |n| n['Location'].match?(%r{/api/saml/logout\d{4}}) }). + to eq(2) + end + + it 'includes the remote logout url' do + visit endpoint_metadata_path + document = REXML::Document.new(page.html) + logout_nodes = REXML::XPath.match(document, '//SingleLogoutService') + expect(logout_nodes.count { |n| n['Location'].match?(%r{/api/saml/remotelogout\d{4}}) }). + to eq(1) + end end end end diff --git a/spec/services/saml_endpoint_spec.rb b/spec/services/saml_endpoint_spec.rb index 0b05403957e..ca059b822ab 100644 --- a/spec/services/saml_endpoint_spec.rb +++ b/spec/services/saml_endpoint_spec.rb @@ -76,6 +76,22 @@ result = subject.saml_metadata expect(result.configurator.single_service_post_location).to match(%r{api/saml/auth2022\Z}) + end + + it 'does not include the SingLogoutService endpoints when configured' do + allow(IdentityConfig.store).to receive(:include_slo_in_saml_metadata). + and_return(false) + result = subject.saml_metadata + + expect(result.configurator.single_logout_service_post_location).to be_nil + expect(result.configurator.remote_logout_service_post_location).to be_nil + end + + it 'includes the SingLogoutService endpoints when configured' do + allow(IdentityConfig.store).to receive(:include_slo_in_saml_metadata). + and_return(true) + result = subject.saml_metadata + expect(result.configurator.single_logout_service_post_location).to match( %r{api/saml/logout2022\Z}, )