Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ref: migrate encrypt spcp verified content flow to own verified-content module #934

Merged
merged 22 commits into from
Feb 10, 2021
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
a0e1548
chore: add ts-essentials package
karrui Dec 22, 2020
a3a7308
ref: use ts-essential's UnreachableCaseError instead of homegrown fn
karrui Dec 22, 2020
022cbb9
feat: add better comments on generating verified content key-values
karrui Dec 22, 2020
ab50718
ref(verifiedContentUtil): move unshared fns to verified-content module
karrui Dec 22, 2020
b6f3841
feat(VerifiedContentSvc): add getVerifiedContent service fn
karrui Dec 22, 2020
30ef55d
feat(VerifiedContentSvc): add encryptVerifiedContent service fn
karrui Dec 22, 2020
41e55c3
feat(VerifiedContentFty): add factory to gate access to service fns
karrui Dec 22, 2020
824b967
feat(VerifiedContentMdw): add encryptVerifiedSpcpFields middleware fn
karrui Dec 22, 2020
0907a10
ref(AdminFormsRoutes): use new encryptVerifiedSpcpFields middleware
karrui Dec 22, 2020
d73fc20
test(EncryptSubsCtl): update tests to work with new middleware
karrui Dec 22, 2020
df2cb73
feat: remove now unused encryptedVerifiedFields fn
karrui Dec 22, 2020
c9663dc
feat(verifiedContentSharedUtil): remove misleading document comment
karrui Dec 22, 2020
f2ed44a
test: fix assertion due to using UnreachableCaseError
karrui Dec 22, 2020
c7242e3
feat(verifiedContentUtils): inline creation of verified content shapes
karrui Dec 28, 2020
eb849d1
ref: revert installation and usage of ts-essential
karrui Dec 28, 2020
b05a68d
fix(VerifiedContentSvc): remove usage of uninstalled ts-essentials
karrui Dec 28, 2020
b987fe2
Merge branch 'develop' into feat/verified-field-typeguard
karrui Dec 28, 2020
427c409
fix: use encryptVerifiedSpcpFields middleware in integration test
karrui Dec 28, 2020
b02f2c4
Merge branch 'develop' into feat/verified-field-typeguard
karrui Feb 9, 2021
fa14ea3
fix: remove errors from merging
karrui Feb 9, 2021
c309042
fix: lint errors
karrui Feb 9, 2021
2174ede
test: add tests for VerifiedContentService
karrui Feb 9, 2021
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@
"text-encoding": "^0.7.0",
"toastr": "^2.1.4",
"triple-beam": "^1.3.0",
"ts-essentials": "^7.0.1",
karrui marked this conversation as resolved.
Show resolved Hide resolved
"tweetnacl": "^1.0.1",
"twilio": "^3.54.1",
"ui-select": "^0.19.8",
Expand Down Expand Up @@ -259,4 +260,4 @@
"webpack-merge": "^4.1.3",
"worker-loader": "^2.0.0"
}
}
}
51 changes: 0 additions & 51 deletions src/app/controllers/spcp.server.controller.js

This file was deleted.

5 changes: 0 additions & 5 deletions src/app/factories/webhook-verified-content.factory.js
Original file line number Diff line number Diff line change
@@ -1,18 +1,13 @@
const webhook = require('../modules/webhook/webhook.controller')
const spcp = require('../controllers/spcp.server.controller')
const featureManager = require('../../config/feature-manager').default

const webhookVerifiedContentFactory = ({ isEnabled, props }) => {
if (isEnabled && props) {
return {
encryptedVerifiedFields: spcp.encryptedVerifiedFields(
props.signingSecretKey,
),
post: webhook.post,
}
} else {
return {
encryptedVerifiedFields: (req, res, next) => next(),
post: (req, res, next) => next(),
}
}
Expand Down
4 changes: 2 additions & 2 deletions src/app/modules/form/admin-form/admin-form.utils.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { StatusCodes } from 'http-status-codes'
import { err, ok, Result } from 'neverthrow'
import { UnreachableCaseError } from 'ts-essentials'

import { createLoggerWithLabel } from '../../../../config/logger'
import { IPopulatedForm, ResponseMode, Status } from '../../../../types'
import { assertUnreachable } from '../../../utils/assert-unreachable'
import {
ApplicationError,
DatabaseConflictError,
Expand Down Expand Up @@ -206,7 +206,7 @@ export const getAssertPermissionFn = (level: PermissionLevel): AssertFormFn => {
case PermissionLevel.Delete:
return assertHasDeletePermissions
default:
return assertUnreachable(level)
throw new UnreachableCaseError(level)
}
}

Expand Down
4 changes: 2 additions & 2 deletions src/app/modules/form/form.service.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import mongoose from 'mongoose'
import { err, errAsync, ok, okAsync, Result, ResultAsync } from 'neverthrow'
import { UnreachableCaseError } from 'ts-essentials'

import { createLoggerWithLabel } from '../../../config/logger'
import { IFormSchema, IPopulatedForm, Status } from '../../../types'
import getFormModel from '../../models/form.server.model'
import { assertUnreachable } from '../../utils/assert-unreachable'
import { getMongoErrorMessage } from '../../utils/handle-mongo-error'
import { ApplicationError, DatabaseError } from '../core/core.errors'

Expand Down Expand Up @@ -117,6 +117,6 @@ export const isFormPublic = (
case Status.Private:
return err(new PrivateFormError(form.inactiveMessage, form.title))
default:
return assertUnreachable(form.status)
throw new UnreachableCaseError(form.status)
}
}
18 changes: 17 additions & 1 deletion src/app/modules/form/form.utils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { pick } from 'lodash'
import { Merge } from 'type-fest'

import { IPopulatedForm } from 'src/types'
import {
IEncryptedFormSchema,
IFormSchema,
IPopulatedForm,
ResponseMode,
} from '../../../types'

// Kept in this file instead of form.types.ts so that this can be kept in sync
// with FORM_PUBLIC_FIELDS more easily.
Expand Down Expand Up @@ -55,3 +60,14 @@ export const removePrivateDetailsFromForm = (
admin: pick(form.admin, 'agency'),
}
}

/**
* Typeguard to check if given form is an encrypt mode form.
* @param form the form to check
* @returns true if form is encrypt mode form, false otherwise.
*/
export const isFormEncryptMode = (
form: IFormSchema | IPopulatedForm,
): form is IEncryptedFormSchema => {
return form.responseMode === ResponseMode.Encrypt
}
3 changes: 2 additions & 1 deletion src/app/modules/spcp/spcp.errors.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { AuthType } from '../../../types'
import { ApplicationError } from '../../modules/core/core.errors'

/**
* Error while creating redirect URL
*/
Expand Down Expand Up @@ -36,7 +37,7 @@ export class VerifyJwtError extends ApplicationError {
}
}

/*
/**
* Invalid OOB params passed to login endpoint.
*/
export class InvalidOOBParamsError extends ApplicationError {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ describe('submission.utils', () => {
const invalidResponseMode = 'something' as ResponseMode
// Act + Assert
expect(() => getModeFilter(invalidResponseMode)).toThrowError(
`This should never be reached in TypeScript: "${invalidResponseMode}"`,
`Unreachable case: ${invalidResponseMode}`,
)
})
})
Expand Down
5 changes: 3 additions & 2 deletions src/app/modules/submission/submission.utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { UnreachableCaseError } from 'ts-essentials'

import { BasicField, ResponseMode } from '../../../types'
import { assertUnreachable } from '../../utils/assert-unreachable'
import { FIELDS_TO_REJECT } from '../../utils/field-validation/config'

type ModeFilterParam = {
Expand All @@ -15,7 +16,7 @@ export const getModeFilter = (
case ResponseMode.Encrypt:
return encryptModeFilter
default:
return assertUnreachable(responseMode)
throw new UnreachableCaseError(responseMode)
}
}

Expand Down
19 changes: 19 additions & 0 deletions src/app/modules/verified-content/verified-content.errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { ApplicationError } from '../core/core.errors'

/**
* Verified content has the wrong shape
*/
export class MalformedVerifiedContentError extends ApplicationError {
constructor(message = 'Verified content is malformed') {
super(message)
}
}

/**
* Error to be returned when verified content fails to be encrypted.
*/
export class EncryptVerifiedContentError extends ApplicationError {
constructor(message = 'Failed to encrypt verified content') {
super(message)
}
}
66 changes: 66 additions & 0 deletions src/app/modules/verified-content/verified-content.factory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { err } from 'neverthrow'

import FeatureManager, {
FeatureNames,
RegisteredFeature,
} from '../../../config/feature-manager'
import { MissingFeatureError } from '../core/core.errors'

import {
EncryptVerifiedContentError,
MalformedVerifiedContentError,
} from './verified-content.errors'
import * as VerifiedContentService from './verified-content.service'
import {
CpVerifiedContent,
EncryptVerificationContentParams,
FactoryVerifiedContentResult,
GetVerifiedContentParams,
SpVerifiedContent,
} from './verified-content.types'

interface IVerifiedContentFactory {
getVerifiedContent: (
params: GetVerifiedContentParams,
) => FactoryVerifiedContentResult<
CpVerifiedContent | SpVerifiedContent,
MalformedVerifiedContentError
>
encryptVerifiedContent: (
params: EncryptVerificationContentParams,
) => FactoryVerifiedContentResult<string, EncryptVerifiedContentError>
}

const verifiedContentFeature = FeatureManager.get(
FeatureNames.WebhookVerifiedContent,
)

export const createVerifiedContentFactory = ({
isEnabled,
props,
}: RegisteredFeature<FeatureNames.WebhookVerifiedContent>): IVerifiedContentFactory => {
// Feature is enabled and valid.
if (isEnabled && props?.signingSecretKey) {
return {
getVerifiedContent: VerifiedContentService.getVerifiedContent,
// Pass in signing secret key from feature manager.
encryptVerifiedContent: ({ verifiedContent, formPublicKey }) =>
VerifiedContentService.encryptVerifiedContent({
verifiedContent,
formPublicKey,
signingSecretKey: props.signingSecretKey,
}),
}
}

return {
getVerifiedContent: () =>
err(new MissingFeatureError(FeatureNames.WebhookVerifiedContent)),
encryptVerifiedContent: () =>
err(new MissingFeatureError(FeatureNames.WebhookVerifiedContent)),
}
}

export const VerifiedContentFactory = createVerifiedContentFactory(
verifiedContentFeature,
)
69 changes: 69 additions & 0 deletions src/app/modules/verified-content/verified-content.middlewares.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { RequestHandler } from 'express'
import { StatusCodes } from 'http-status-codes'

import { createLoggerWithLabel } from '../../../config/logger'
import { AuthType, WithForm } from '../../../types'
import { createReqMeta } from '../../utils/request'
import { MissingFeatureError } from '../core/core.errors'
import { isFormEncryptMode } from '../form/form.utils'

import { VerifiedContentFactory } from './verified-content.factory'

const logger = createLoggerWithLabel(module)

export const encryptVerifiedSpcpFields: RequestHandler = (req, res, next) => {
const { form } = req as WithForm<typeof req>

// Early return if this is not a Singpass/Corppass submission.
if (form.authType === AuthType.NIL) {
return next()
}

const logMeta = {
action: 'encryptVerifiedSpcpFields',
formId: form._id,
...createReqMeta(req),
}

if (!isFormEncryptMode(form)) {
logger.error({
message: 'encryptVerifiedSpcpFields called on non-encrypt mode form',
meta: logMeta,
})
return res.status(StatusCodes.UNPROCESSABLE_ENTITY).send({
message:
'Unable to encrypt verified SPCP fields on non storage mode forms',
})
}

const encryptVerifiedContentResult = VerifiedContentFactory.getVerifiedContent(
{ type: form.authType, data: res.locals },
).andThen((verifiedContent) =>
VerifiedContentFactory.encryptVerifiedContent({
verifiedContent,
formPublicKey: form.publicKey,
}),
)

if (encryptVerifiedContentResult.isErr()) {
const { error } = encryptVerifiedContentResult
logger.error({
message: 'Unable to encrypt verified content',
meta: logMeta,
error,
})

// Passthrough if feature is not activated.
if (error instanceof MissingFeatureError) {
return next()
}

return res
.status(StatusCodes.BAD_REQUEST)
.json({ message: 'Invalid data was found. Please submit again.' })
}

// No errors, set local variable to the encrypted string.
res.locals.verified = encryptVerifiedContentResult.value
return next()
}
Loading