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);