diff --git a/common/utils/array.ts b/common/utils/array.ts index 7a74ad574a..10c8f9f97c 100644 --- a/common/utils/array.ts +++ b/common/utils/array.ts @@ -9,3 +9,12 @@ export function isUndefined(val: T | undefined): val is T { export function isString(v: any): v is string { return typeof v === 'string'; } + +export function stringFromStringOrStringArray( + input: string | string[] +): string { + if (Array.isArray(input)) { + return input.join(''); + } + return input; +} diff --git a/identity/webapp/config.js b/identity/webapp/config.js index 6a7fdc9e6d..35c2e8148f 100644 --- a/identity/webapp/config.js +++ b/identity/webapp/config.js @@ -25,6 +25,7 @@ const getConfig = () => { domain: process.env.AUTH0_DOMAIN || 'build', clientID: process.env.AUTH0_CLIENT_ID || 'build', clientSecret: process.env.AUTH0_CLIENT_SECRET || 'build', + actionSecret: process.env.AUTH0_ACTION_SECRET || 'build', }, remoteApi: { diff --git a/identity/webapp/package.json b/identity/webapp/package.json index 17c1c506a4..f5a83fd36b 100644 --- a/identity/webapp/package.json +++ b/identity/webapp/package.json @@ -29,6 +29,7 @@ "@weco/common": "1.0.0", "axios": "^0.24.0", "dotenv": "^8.2.0", + "jsonwebtoken": "^8.5.1", "koa": "^2.13.0", "koa-json": "^2.0.2", "koa-logger": "^3.2.1", @@ -52,6 +53,7 @@ "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.6.3", "@types/jest": "^27.0.2", + "@types/jsonwebtoken": "^8.5.8", "@types/koa": "^2.11.0", "@types/koa-json": "^2.0.18", "@types/koa-logger": "^3.1.1", diff --git a/identity/webapp/pages/api/registration.ts b/identity/webapp/pages/api/registration.ts new file mode 100644 index 0000000000..696a14c295 --- /dev/null +++ b/identity/webapp/pages/api/registration.ts @@ -0,0 +1,55 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import { generateNewToken, decodeToken } from '../../src/utility/jwt-codec'; +import axios from 'axios'; +import getConfig from 'next/config'; + +const { serverRuntimeConfig: config } = getConfig(); + +export default (req: NextApiRequest, res: NextApiResponse): void => { + if (req.method === 'POST') { + const { state, firstName, lastName, termsAndConditions, sessionToken } = + req.body; + + if ( + !state || + !firstName || + !lastName || + !termsAndConditions || + !sessionToken + ) { + res.status(400).json({ + error: 'Missing required fields', + }); + return; + } + + try { + const decodedToken = decodeToken(sessionToken); + const formData = { firstName, lastName, termsAndConditions }; + + if (typeof decodedToken !== 'string') { + const newToken = generateNewToken(decodedToken, state, formData); + + axios + .post(`${config.auth0.domain}/continue`, { + state, + session_token: newToken, + }) + .then(() => { + res.redirect(302, `/account`); + }) + .catch(() => { + res.status(400).json({ + error: 'Registration failed', + }); + }); + } + } catch (error) { + res.status(400).json({ + error: error.message, + }); + } + } else { + res.redirect('/account'); + } +}; diff --git a/identity/webapp/pages/registration.tsx b/identity/webapp/pages/registration.tsx new file mode 100644 index 0000000000..80f6701d9d --- /dev/null +++ b/identity/webapp/pages/registration.tsx @@ -0,0 +1,246 @@ +import { FormEvent } from 'react'; +import { NextPage, GetServerSideProps } from 'next'; +import { withPageAuthRequiredSSR } from '../src/utility/auth0'; +import { useForm, Controller } from 'react-hook-form'; +import { ErrorMessage } from '@hookform/error-message'; +import { PageWrapper } from '../src/frontend/components/PageWrapper'; +import { font } from '@weco/common/utils/classnames'; +import { + Checkbox, + ExternalLink, + CheckboxLabel, + FullWidthButton, + FlexStartCheckbox, +} from '../src/frontend/Registration/Registration.style'; +import { Container, Wrapper } from '../src/frontend/components/Layout.style'; +import WellcomeTextInput, { + TextInputErrorMessage, +} from '@weco/common/views/components/TextInput/TextInput'; +import { usePageTitle } from '../src/frontend/hooks/usePageTitle'; +import Layout8 from '@weco/common/views/components/Layout8/Layout8'; +import Layout10 from '@weco/common/views/components/Layout10/Layout10'; +import Space from '@weco/common/views/components/styled/Space'; +import SpacingComponent from '@weco/common/views/components/SpacingComponent/SpacingComponent'; +import ButtonSolid, { + ButtonTypes, +} from '@weco/common/views/components/ButtonSolid/ButtonSolid'; +import { getServerData } from '@weco/common/server-data'; +import { AppErrorProps } from '@weco/common/views/pages/_app'; +import { removeUndefinedProps } from '@weco/common/utils/json'; +import { SimplifiedServerData } from '@weco/common/server-data/types'; +import { useUser } from '@weco/common/views/components/UserProvider/UserProvider'; +import { Claims } from '@auth0/nextjs-auth0'; +import { RegistrationInputs } from '../src/utility/jwt-codec'; +import { stringFromStringOrStringArray } from '@weco/common/utils/array'; +import { + Auth0UserProfile, + auth0UserProfileToUserInfo, +} from '@weco/common/model/user'; +import RegistrationInformation from '../src/frontend/Registration/RegistrationInformation'; + +type Props = { + serverData: SimplifiedServerData; + user?: Claims; + sessionToken: string; + auth0State: string; + redirectUri: string; +}; + +export const getServerSideProps: GetServerSideProps = + withPageAuthRequiredSSR({ + getServerSideProps: async context => { + const serverData = await getServerData(context); + const auth0State = stringFromStringOrStringArray(context.query.state); + const redirectUri = stringFromStringOrStringArray( + context.query.redirect_uri + ); + const sessionToken = stringFromStringOrStringArray( + context.query.session_token + ); + + if (!serverData.toggles.selfRegistration) { + return { + redirect: { + destination: '/', + permanent: false, + }, + }; + } + + return { + props: removeUndefinedProps({ + serverData, + sessionToken, + auth0State, + redirectUri, + }), + }; + }, + }); + +const RegistrationPage: NextPage = ({ + user: auth0UserClaims, + sessionToken, + auth0State, + redirectUri, +}) => { + const { control, trigger, handleSubmit, formState } = + useForm(); + + usePageTitle('Register for a library account'); + const { user: contextUser } = useUser(); + + // Use the user from the context provider as first preference, as it will + // change without a page reload being required + const user = + contextUser || + auth0UserProfileToUserInfo(auth0UserClaims as Auth0UserProfile); + + const updateActionData = (_, event: FormEvent) => { + (event.target as HTMLFormElement).submit(); // Use the action/method if client side validation passes + }; + + return ( + + + + + + + + + +
+ + + + + ( + trigger('firstName')} + showValidity={formState.isSubmitted} + errorMessage={formState.errors.firstName?.message} + /> + )} + /> + + + ( + trigger('lastName')} + showValidity={formState.isSubmitted} + errorMessage={formState.errors.lastName?.message} + /> + )} + /> + + + +

+ Collections research agreement +

+ ( + + ) => + onChange(e.currentTarget.checked) + } + checked={value} + text={ + + I will use personal data on living persons for + research purposes only. I will not use + personal data to support decisions about the + person who is the subject of the data, or in a + way that causes substantial damage or distress + to them. I have read and accept the + regulations detailed in the{' '} + + Library’s Terms & Conditions of Use + + .{' '} + + } + /> + + )} + /> + + ( + + {message} + + )} + /> + +
+ + + + + +
+
+
+
+
+
+
+
+ ); +}; + +export default RegistrationPage; diff --git a/identity/webapp/src/frontend/Registration/Registration.style.ts b/identity/webapp/src/frontend/Registration/Registration.style.ts index bdfdcb110c..da131feb29 100644 --- a/identity/webapp/src/frontend/Registration/Registration.style.ts +++ b/identity/webapp/src/frontend/Registration/Registration.style.ts @@ -43,7 +43,7 @@ export const HighlightMessage = styled(Space).attrs({ export const Checkbox = styled(CheckboxRadio).attrs({ type: 'checkbox' })``; -export const CheckboxLabel = styled.span` +export const CheckboxLabel = styled.div` margin-left: 0.333em; `; @@ -79,3 +79,21 @@ export const Cancel = styled.button.attrs({ cursor: pointer; } `; + +export const YellowBorder = styled(Space).attrs({ + h: { size: 's', properties: ['padding-left'] }, +})` + border-left: 10px solid ${props => props.theme.color('yellow')}; +`; + +export const FullWidthButton = styled.div` + * { + width: 100%; + } +`; + +export const FlexStartCheckbox = styled.div` + label { + align-items: flex-start; + } +`; diff --git a/identity/webapp/src/frontend/Registration/RegistrationInformation.tsx b/identity/webapp/src/frontend/Registration/RegistrationInformation.tsx new file mode 100644 index 0000000000..5e367faece --- /dev/null +++ b/identity/webapp/src/frontend/Registration/RegistrationInformation.tsx @@ -0,0 +1,58 @@ +import { FC } from 'react'; +import Divider from '@weco/common/views/components/Divider/Divider'; +import { YellowBorder } from './Registration.style'; +import { SectionHeading } from '../components/Layout.style'; +import { font } from '@weco/common/utils/classnames'; +import Space from '@weco/common/views/components/styled/Space'; +import { UserInfo } from '@weco/common/model/user'; + +type Props = { + user: UserInfo; +}; + +const RegistrationInformation: FC = ({ user }) => { + return ( + <> + Apply for a library membership +
+

With a library membership and online account, you’ll be able to:

+
    +
  • + Request up to 15 materials from our closed stores to view in the + library +
  • +
  • Access subscription databases and other online resources.
  • +
+

+ When you complete your registration online, you’ll need to email a + form of personal identification (ID) and proof of address to the + Library team in order to confirm your membership. Your membership will + be confirmed within 72 hours. +

+ + +

+ Note: You don’t need to apply for a membership if + you wish to view our digital collections or visit the library for + the day. +

+
+
+ +

Your details

+

+ Email address: {user.email} +

+ + + + + ); +}; + +export default RegistrationInformation; diff --git a/identity/webapp/src/utility/jwt-codec.ts b/identity/webapp/src/utility/jwt-codec.ts new file mode 100644 index 0000000000..47a0bb4798 --- /dev/null +++ b/identity/webapp/src/utility/jwt-codec.ts @@ -0,0 +1,65 @@ +import { JwtPayload, sign, verify } from 'jsonwebtoken'; +import getConfig from 'next/config'; + +const { serverRuntimeConfig: config } = getConfig(); + +// we need some jwt encoding to deal with passing data to an auth0 action +// https://auth0.com/docs/customize/actions/flows-and-triggers/login-flow/redirect-with-actions#pass-data-back-to-auth0 + +export type RegistrationInputs = { + firstName: string; + lastName: string; + termsAndConditions: boolean; +}; + +// we first need to decode the session token we receive on redirecting from the post-login flow action +// this token will come from request query when this handler is used in the context of the registration form i.e. req.query.session_token +export const decodeToken = (token: string): JwtPayload | string => { + try { + const decoded = verify(token, process.env.AUTH0_ACTION_SECRET); + return decoded; + } catch (e) { + throw new Error('Invalid session_token in decode'); + } +}; + +// we then need to add our registration form data to the token along with other details +// this token object includes iat, iss, sub, exp and ip (from auth0 incoming token) +// we must also include the state, which validates our ability to finish the action with /continue +// finally we must make sure to add aud (audience) as without this the token won't be accepted by auth0 +const jwtRequiredFields = [ + 'iat', + 'iss', + 'sub', + 'exp', + 'aud', + 'state', + 'https://wellcomecollection.org/terms_agreed', + 'https://wellcomecollection.org/first_name', + 'https://wellcomecollection.org/last_name', +] as const; +export type RegistrationJwtPayload = Pick< + JwtPayload, + typeof jwtRequiredFields[number] +>; + +export const generateNewToken = ( + dataFromAuth0: JwtPayload, + state: string, + formData: RegistrationInputs +): string => { + const payload: RegistrationJwtPayload = { + ...dataFromAuth0, + aud: 'https://wellcomecollection.org/account/registration', + state, + 'https://wellcomecollection.org/terms_agreed': formData.termsAndConditions, + 'https://wellcomecollection.org/first_name': formData.firstName, + 'https://wellcomecollection.org/last_name': formData.lastName, + }; + + const token = sign(payload, config.auth0.actionSecret, { + algorithm: 'HS256', + }); + + return token; +}; diff --git a/toggles/webapp/toggles.ts b/toggles/webapp/toggles.ts index d977ce955e..dfbd6976f6 100644 --- a/toggles/webapp/toggles.ts +++ b/toggles/webapp/toggles.ts @@ -40,6 +40,12 @@ const toggles = { defaultValue: false, description: 'A toolbar to help us navigate the secret depths of the API', }, + { + id: 'selfRegistration', + title: 'Self registration', + defaultValue: false, + description: 'Allow users to sign up for an account', + }, { id: 'inter', title: 'Inter font', diff --git a/yarn.lock b/yarn.lock index 0554299b73..ea2c4610b4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4245,6 +4245,13 @@ resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" integrity sha1-7ihweulOEdK4J7y+UnC86n8+ce4= +"@types/jsonwebtoken@^8.5.8": + version "8.5.8" + resolved "https://registry.yarnpkg.com/@types/jsonwebtoken/-/jsonwebtoken-8.5.8.tgz#01b39711eb844777b7af1d1f2b4cf22fda1c0c44" + integrity sha512-zm6xBQpFDIDM6o9r6HSgDeIcLy82TKWctCXEPbJJcXb5AKmi5BNNdLXneixK4lplX3PqIVcwLBCGE/kAGnlD4A== + dependencies: + "@types/node" "*" + "@types/keygrip@*": version "1.0.2" resolved "https://registry.yarnpkg.com/@types/keygrip/-/keygrip-1.0.2.tgz#513abfd256d7ad0bf1ee1873606317b33b1b2a72" @@ -6319,6 +6326,11 @@ bser@2.1.1: dependencies: node-int64 "^0.4.0" +buffer-equal-constant-time@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819" + integrity sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk= + buffer-from@^1.0.0: version "1.1.2" resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" @@ -7897,6 +7909,13 @@ duplexify@^3.4.2, duplexify@^3.6.0: readable-stream "^2.0.0" stream-shift "^1.0.0" +ecdsa-sig-formatter@1.0.11: + version "1.0.11" + resolved "https://registry.yarnpkg.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz#ae0f0fa2d85045ef14a817daa3ce9acd0489e5bf" + integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ== + dependencies: + safe-buffer "^5.0.1" + ee-first@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" @@ -11589,6 +11608,22 @@ jsonfile@^6.0.1: optionalDependencies: graceful-fs "^4.1.6" +jsonwebtoken@^8.5.1: + version "8.5.1" + resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-8.5.1.tgz#00e71e0b8df54c2121a1f26137df2280673bcc0d" + integrity sha512-XjwVfRS6jTMsqYs0EsuJ4LGxXV14zQybNd4L2r0UvbVnSF9Af8x7p5MzbJ90Ioz/9TI41/hTCvznF/loiSzn8w== + dependencies: + jws "^3.2.2" + lodash.includes "^4.3.0" + lodash.isboolean "^3.0.3" + lodash.isinteger "^4.0.4" + lodash.isnumber "^3.0.3" + lodash.isplainobject "^4.0.6" + lodash.isstring "^4.0.1" + lodash.once "^4.0.0" + ms "^2.1.1" + semver "^5.6.0" + "jsx-ast-utils@^2.4.1 || ^3.0.0": version "3.2.0" resolved "https://registry.yarnpkg.com/jsx-ast-utils/-/jsx-ast-utils-3.2.0.tgz#41108d2cec408c3453c1bbe8a4aae9e1e2bd8f82" @@ -11602,6 +11637,23 @@ junk@^3.1.0: resolved "https://registry.yarnpkg.com/junk/-/junk-3.1.0.tgz#31499098d902b7e98c5d9b9c80f43457a88abfa1" integrity sha512-pBxcB3LFc8QVgdggvZWyeys+hnrNWg4OcZIU/1X59k5jQdLBlCsYGRQaz234SqoRLTCgMH00fY0xRJH+F9METQ== +jwa@^1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/jwa/-/jwa-1.4.1.tgz#743c32985cb9e98655530d53641b66c8645b039a" + integrity sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA== + dependencies: + buffer-equal-constant-time "1.0.1" + ecdsa-sig-formatter "1.0.11" + safe-buffer "^5.0.1" + +jws@^3.2.2: + version "3.2.2" + resolved "https://registry.yarnpkg.com/jws/-/jws-3.2.2.tgz#001099f3639468c9414000e99995fa52fb478304" + integrity sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA== + dependencies: + jwa "^1.4.1" + safe-buffer "^5.0.1" + keygrip@~1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/keygrip/-/keygrip-1.1.0.tgz#871b1681d5e159c62a445b0c74b615e0917e7226" @@ -12006,11 +12058,41 @@ lodash.groupby@^4.6.0: resolved "https://registry.yarnpkg.com/lodash.groupby/-/lodash.groupby-4.6.0.tgz#0b08a1dcf68397c397855c3239783832df7403d1" integrity sha1-Cwih3PaDl8OXhVwyOXg4Mt90A9E= +lodash.includes@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f" + integrity sha1-YLuYqHy5I8aMoeUTJUgzFISfVT8= + +lodash.isboolean@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6" + integrity sha1-bC4XHbKiV82WgC/UOwGyDV9YcPY= + lodash.isequal@^4.5.0: version "4.5.0" resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0" integrity sha1-QVxEePK8wwEgwizhDtMib30+GOA= +lodash.isinteger@^4.0.4: + version "4.0.4" + resolved "https://registry.yarnpkg.com/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz#619c0af3d03f8b04c31f5882840b77b11cd68343" + integrity sha1-YZwK89A/iwTDH1iChAt3sRzWg0M= + +lodash.isnumber@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz#3ce76810c5928d03352301ac287317f11c0b1ffc" + integrity sha1-POdoEMWSjQM1IwGsKHMX8RwLH/w= + +lodash.isplainobject@^4.0.6: + version "4.0.6" + resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb" + integrity sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs= + +lodash.isstring@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz#d527dfb5456eca7cc9bb95d5daeaf88ba54a5451" + integrity sha1-1SfftUVuynzJu5XV2ur4i6VKVFE= + lodash.memoize@4.x: version "4.1.2" resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe" @@ -12021,6 +12103,11 @@ lodash.merge@^4.6.2: resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== +lodash.once@^4.0.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac" + integrity sha1-DdOXEhPHxW34gJd9UEyI+0cal6w= + lodash.sortby@^4.7.0: version "4.7.0" resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438"