From 8f8e8e6df4a4a5d8759c95c2a07e457050830ed6 Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Mon, 15 Nov 2021 14:36:57 +0100 Subject: [PATCH] feat: Send reset password recordId as query parameter This is a revert of a previous change but is now possible due to the use of JSON bodies. This does mean JavaScript is required in the HTML page, but that will be required for future changes anyway. --- .../handler/interaction/routes/reset-password.json | 2 +- .../email-password/handler/ForgotPasswordHandler.ts | 3 ++- .../email-password/handler/ResetPasswordHandler.ts | 4 +--- templates/identity/email-password/reset-password.html.ejs | 7 +++++++ test/integration/Identity.test.ts | 6 +++++- .../email-password/handler/ForgotPasswordHandler.test.ts | 2 +- .../email-password/handler/ResetPasswordHandler.test.ts | 8 ++++---- 7 files changed, 21 insertions(+), 11 deletions(-) diff --git a/config/identity/handler/interaction/routes/reset-password.json b/config/identity/handler/interaction/routes/reset-password.json index c4cdd7644..9a48ad540 100644 --- a/config/identity/handler/interaction/routes/reset-password.json +++ b/config/identity/handler/interaction/routes/reset-password.json @@ -6,7 +6,7 @@ "comment": "Handles the reset password page submission", "@id": "urn:solid-server:auth:password:ResetPasswordRoute", "@type": "BasicInteractionRoute", - "route": "^/resetpassword/[^/]*$", + "route": "^/resetpassword/$", "viewTemplates": { "BasicInteractionRoute:_viewTemplates_key": "text/html", "BasicInteractionRoute:_viewTemplates_value": "@css:templates/identity/email-password/reset-password.html.ejs" diff --git a/src/identity/interaction/email-password/handler/ForgotPasswordHandler.ts b/src/identity/interaction/email-password/handler/ForgotPasswordHandler.ts index 7e64f6c50..e536db2d9 100644 --- a/src/identity/interaction/email-password/handler/ForgotPasswordHandler.ts +++ b/src/identity/interaction/email-password/handler/ForgotPasswordHandler.ts @@ -68,7 +68,8 @@ export class ForgotPasswordHandler extends InteractionHandler { */ private async sendResetMail(recordId: string, email: string): Promise { this.logger.info(`Sending password reset to ${email}`); - const resetLink = joinUrl(this.baseUrl, this.idpPath, `resetpassword/${recordId}`); + // `joinUrl` strips trailing slash when query parameter gets added + const resetLink = `${joinUrl(this.baseUrl, this.idpPath, 'resetpassword/')}?rid=${recordId}`; const renderedEmail = await this.templateEngine.render({ resetLink }); await this.emailSender.handleSafe({ recipient: email, diff --git a/src/identity/interaction/email-password/handler/ResetPasswordHandler.ts b/src/identity/interaction/email-password/handler/ResetPasswordHandler.ts index b2150ca20..431de8ea1 100644 --- a/src/identity/interaction/email-password/handler/ResetPasswordHandler.ts +++ b/src/identity/interaction/email-password/handler/ResetPasswordHandler.ts @@ -21,10 +21,8 @@ export class ResetPasswordHandler extends InteractionHandler { } public async handle({ operation }: InteractionHandlerInput): Promise { - // Extract record ID from request URL - const recordId = /\/([^/]+)$/u.exec(operation.target.path)?.[1]; // Validate input data - const { password, confirmPassword } = await readJsonStream(operation.body.data); + const { password, confirmPassword, recordId } = await readJsonStream(operation.body.data); assert( typeof recordId === 'string' && recordId.length > 0, 'Invalid request. Open the link from your email again', diff --git a/templates/identity/email-password/reset-password.html.ejs b/templates/identity/email-password/reset-password.html.ejs index a5c605eb8..69e9bbfc6 100644 --- a/templates/identity/email-password/reset-password.html.ejs +++ b/templates/identity/email-password/reset-password.html.ejs @@ -15,7 +15,14 @@ +

+ + diff --git a/test/integration/Identity.test.ts b/test/integration/Identity.test.ts index 36468643c..0561562e8 100644 --- a/test/integration/Identity.test.ts +++ b/test/integration/Identity.test.ts @@ -206,8 +206,12 @@ describe('A Solid server with IDP', (): void => { // Reset password form has no action causing the current URL to be used expect(relative).toBeUndefined(); + // Extract recordId from URL since JS is used to add it + const recordId = /\?rid=([^/]+)$/u.exec(nextUrl)?.[1]; + expect(typeof recordId).toBe('string'); + // POST the new password to the same URL - const formData = stringify({ password: password2, confirmPassword: password2 }); + const formData = stringify({ password: password2, confirmPassword: password2, recordId }); res = await fetch(nextUrl, { method: 'POST', headers: { 'content-type': APPLICATION_X_WWW_FORM_URLENCODED }, diff --git a/test/unit/identity/interaction/email-password/handler/ForgotPasswordHandler.test.ts b/test/unit/identity/interaction/email-password/handler/ForgotPasswordHandler.test.ts index 4a7470571..f905e963c 100644 --- a/test/unit/identity/interaction/email-password/handler/ForgotPasswordHandler.test.ts +++ b/test/unit/identity/interaction/email-password/handler/ForgotPasswordHandler.test.ts @@ -64,7 +64,7 @@ describe('A ForgotPasswordHandler', (): void => { expect(emailSender.handleSafe).toHaveBeenLastCalledWith({ recipient: email, subject: 'Reset your password', - text: `To reset your password, go to this link: http://test.com/base/idp/resetpassword/${recordId}`, + text: `To reset your password, go to this link: http://test.com/base/idp/resetpassword/?rid=${recordId}`, html, }); }); diff --git a/test/unit/identity/interaction/email-password/handler/ResetPasswordHandler.test.ts b/test/unit/identity/interaction/email-password/handler/ResetPasswordHandler.test.ts index 37f4c2176..73d7c851e 100644 --- a/test/unit/identity/interaction/email-password/handler/ResetPasswordHandler.test.ts +++ b/test/unit/identity/interaction/email-password/handler/ResetPasswordHandler.test.ts @@ -27,25 +27,25 @@ describe('A ResetPasswordHandler', (): void => { const errorMessage = 'Invalid request. Open the link from your email again'; operation = createPostJsonOperation({}); await expect(handler.handle({ operation })).rejects.toThrow(errorMessage); - operation = createPostJsonOperation({}, ''); + operation = createPostJsonOperation({ recordId: 5 }); await expect(handler.handle({ operation })).rejects.toThrow(errorMessage); }); it('errors for invalid passwords.', async(): Promise => { const errorMessage = 'Your password and confirmation did not match.'; - operation = createPostJsonOperation({ password: 'password!', confirmPassword: 'otherPassword!' }, url); + operation = createPostJsonOperation({ password: 'password!', confirmPassword: 'otherPassword!', recordId }, url); await expect(handler.handle({ operation })).rejects.toThrow(errorMessage); }); it('errors for invalid emails.', async(): Promise => { const errorMessage = 'This reset password link is no longer valid.'; - operation = createPostJsonOperation({ password: 'password!', confirmPassword: 'password!' }, url); + operation = createPostJsonOperation({ password: 'password!', confirmPassword: 'password!', recordId }, url); (accountStore.getForgotPasswordRecord as jest.Mock).mockResolvedValueOnce(undefined); await expect(handler.handle({ operation })).rejects.toThrow(errorMessage); }); it('renders a message on success.', async(): Promise => { - operation = createPostJsonOperation({ password: 'password!', confirmPassword: 'password!' }, url); + operation = createPostJsonOperation({ password: 'password!', confirmPassword: 'password!', recordId }, url); await expect(handler.handle({ operation })).resolves.toEqual({ type: 'response' }); expect(accountStore.getForgotPasswordRecord).toHaveBeenCalledTimes(1); expect(accountStore.getForgotPasswordRecord).toHaveBeenLastCalledWith(recordId);