From 3f755682cfb4267f94ecb312d7c9bcf7149b470b Mon Sep 17 00:00:00 2001 From: Denis Bykhov Date: Tue, 31 Jan 2023 15:10:44 +0600 Subject: [PATCH] Password recovery (#2568) Signed-off-by: Denis Bykhov --- .vscode/launch.json | 6 +- common/config/rush/pnpm-lock.yaml | 16 +++- dev/docker-compose.yaml | 2 + plugins/login-assets/lang/en.json | 7 +- plugins/login-assets/lang/ru.json | 7 +- .../src/components/CreateWorkspaceForm.svelte | 20 +++-- .../src/components/Form.svelte | 24 +++--- .../src/components/Join.svelte | 21 +++-- .../src/components/LoginApp.svelte | 6 ++ .../src/components/LoginForm.svelte | 42 ++++++---- .../src/components/PasswordRequest.svelte | 84 +++++++++++++++++++ .../src/components/PasswordRestore.svelte | 59 +++++++++++++ .../src/components/SignupForm.svelte | 20 +++-- plugins/login-resources/src/plugin.ts | 7 +- plugins/login-resources/src/utils.ts | 62 ++++++++++++++ pods/account/package.json | 2 +- pods/account/src/index.ts | 8 +- server/account/package.json | 6 +- server/account/src/index.ts | 79 ++++++++++++++++- 19 files changed, 415 insertions(+), 63 deletions(-) create mode 100644 plugins/login-resources/src/components/PasswordRequest.svelte create mode 100644 plugins/login-resources/src/components/PasswordRestore.svelte diff --git a/.vscode/launch.json b/.vscode/launch.json index fa0592871a..5b91c48101 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -48,10 +48,12 @@ "request": "launch", "args": ["src/__start.ts"], "env": { - "MONGO_URL": "mongodb://localhost:27018", + "MONGO_URL": "mongodb://localhost:27017", "SERVER_SECRET": "secret", "TRANSACTOR_URL": "ws:/localhost:3333", - "ACCOUNT_PORT": "3000" + "ACCOUNT_PORT": "3000", + "FRONT_URL": "http://localhost:8080", + "SES_URL": "http://localhost:8091" }, "runtimeArgs": ["--nolazy", "-r", "ts-node/register"], "sourceMaps": true, diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 07803b3bd2..b636abdb55 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -319,6 +319,7 @@ specifiers: mini-css-extract-plugin: ^2.2.0 minio: ^7.0.26 mongodb: ^4.11.0 + node-fetch: ^2.6.6 p-queue: ~7.3.0 pdfkit: ~0.13.0 postcss: ^8.4.20 @@ -686,6 +687,7 @@ dependencies: mini-css-extract-plugin: 2.6.1_webpack@5.75.0 minio: 7.0.32 mongodb: 4.11.0 + node-fetch: 2.6.7 p-queue: 7.3.0 pdfkit: 0.13.0 postcss: 8.4.20 @@ -3427,6 +3429,13 @@ packages: '@types/node': 16.11.68 dev: false + /@types/node-fetch/2.6.2: + resolution: {integrity: sha512-DHqhlq5jeESLy19TYhLakJ07kNumXWjcDdxXsLUMJZ6ue8VZJj4kLPQVE/2mdHh3xZziNF1xppu5lwmS53HR+A==} + dependencies: + '@types/node': 16.11.68 + form-data: 3.0.1 + dev: false + /@types/node/12.20.24: resolution: {integrity: sha512-yxDeaQIAJlMav7fH5AQqPH1u8YIuhYJXYBzxaQ4PifsU0GDO38MSdmEDeRlIxrKbC6NbEaaEHDanWb+y30U8SQ==} dev: false @@ -11462,13 +11471,14 @@ packages: dev: false file:projects/account.tgz: - resolution: {integrity: sha512-1O91BX+tpcoZjaOTfyy1CvFF8Bea7RUvkQShNBsMgMthz1S4C82Q5htsCJDYSdm7TC3qDORBJGG4VBVN/Pwcrw==, tarball: file:projects/account.tgz} + resolution: {integrity: sha512-aJKpRhl1z/VBWuJpIUi71qe6EicrFQjmyQ7wGSDBM/b1M6Sm9wKSTBOjjhknIHNwno2toNAEmyV0nkwZcit9hQ==, tarball: file:projects/account.tgz} name: '@rush-temp/account' version: 0.0.0 dependencies: '@rushstack/heft': 0.47.11 '@types/heft-jest': 1.0.3 '@types/minio': 7.0.14 + '@types/node-fetch': 2.6.2 '@types/ws': 8.5.3 '@typescript-eslint/eslint-plugin': 5.42.1_d506b9be61cb4ac2646ecbc6e0680464 '@typescript-eslint/parser': 5.42.1_eslint@8.27.0+typescript@4.8.4 @@ -11479,12 +11489,14 @@ packages: eslint-plugin-promise: 6.1.1_eslint@8.27.0 minio: 7.0.32 mongodb: 4.11.0 + node-fetch: 2.6.7 prettier: 2.7.1 typescript: 4.8.4 ws: 8.11.0 transitivePeerDependencies: - aws-crt - bufferutil + - encoding - supports-color - utf-8-validate dev: false @@ -13477,7 +13489,7 @@ packages: dev: false file:projects/model-recruit.tgz_typescript@4.8.4: - resolution: {integrity: sha512-d2S1FnV9nadIWl5JBjdxs7lZo109fWavsI6SVq/lc6enCzQWas5m/ZVu7dF9HvT9OaD+6HHnqx3pQ/oqTl3fSw==, tarball: file:projects/model-recruit.tgz} + resolution: {integrity: sha512-KqP/U4mkxWwAYH7pD0Z//Cv+/CXMxRrwdnlWRoJOr3nKGFshVb64dRAdkgVdRYNG2rcIlNeaFrBKuqW+6rdjBw==, tarball: file:projects/model-recruit.tgz} id: file:projects/model-recruit.tgz name: '@rush-temp/model-recruit' version: 0.0.0 diff --git a/dev/docker-compose.yaml b/dev/docker-compose.yaml index b2376604e2..f84714c09a 100644 --- a/dev/docker-compose.yaml +++ b/dev/docker-compose.yaml @@ -58,6 +58,8 @@ services: - MINIO_ENDPOINT=minio - MINIO_ACCESS_KEY=minioadmin - MINIO_SECRET_KEY=minioadmin + - FRONT_URL=http://localhost:8087 + - SES_URL=http://localhost:8091 restart: unless-stopped collaborator: image: hardcoreeng/collaborator diff --git a/plugins/login-assets/lang/en.json b/plugins/login-assets/lang/en.json index ab8fa4d11b..3445e2d1a4 100644 --- a/plugins/login-assets/lang/en.json +++ b/plugins/login-assets/lang/en.json @@ -29,6 +29,11 @@ "NotSeeingWorkspace": "Not seeing your workspace?", "WorkspaceNameRule": "The workspace name can contains lowercase letters, numbers, and symbols !@#%&^-", "LinkValidHours": "Link valid (hours):", - "GetLink": "Get invite link" + "GetLink": "Get invite link", + "ForgotPassword": "Forgot your password", + "KnowPassword": "Know your password?", + "Recover": "Recover", + "PasswordRecovery": "Password recovery", + "RecoveryLinkSent": "Password recovery link sent to email" } } \ No newline at end of file diff --git a/plugins/login-assets/lang/ru.json b/plugins/login-assets/lang/ru.json index 7d5a5f0ed1..10097ab928 100644 --- a/plugins/login-assets/lang/ru.json +++ b/plugins/login-assets/lang/ru.json @@ -29,6 +29,11 @@ "NotSeeingWorkspace": "Не видите ваше рабочее пространство?", "WorkspaceNameRule": "Название рабочего пространства должно состояить из строчных латинских букв, цифр и символов !@#%&^-", "LinkValidHours": "Ссылка действительна (часов):", - "GetLink": "Получить ссылку" + "GetLink": "Получить ссылку", + "ForgotPassword": "Забыли пароль?", + "KnowPassword": "Знаете пароль?", + "Recover": "Восстановить", + "PasswordRecovery": "Восстановление пароля", + "RecoveryLinkSent": "Ссылка для восстановления пароля отправлена на почту" } } \ No newline at end of file diff --git a/plugins/login-resources/src/components/CreateWorkspaceForm.svelte b/plugins/login-resources/src/components/CreateWorkspaceForm.svelte index b7beefbd9c..ad4a327842 100644 --- a/plugins/login-resources/src/components/CreateWorkspaceForm.svelte +++ b/plugins/login-resources/src/components/CreateWorkspaceForm.svelte @@ -73,12 +73,16 @@ {fields} {object} {action} - bottomCaption={login.string.HaveWorkspace} - bottomActionLabel={login.string.SelectWorkspace} - bottomActionFunc={() => { - const loc = getCurrentLocation() - loc.path[1] = 'selectWorkspace' - loc.path.length = 2 - navigate(loc) - }} + bottomActions={[ + { + caption: login.string.HaveWorkspace, + i18n: login.string.SelectWorkspace, + func: () => { + const loc = getCurrentLocation() + loc.path[1] = 'selectWorkspace' + loc.path.length = 2 + navigate(loc) + } + } + ]} /> diff --git a/plugins/login-resources/src/components/Form.svelte b/plugins/login-resources/src/components/Form.svelte index 37214309d6..0b0ab4ce67 100644 --- a/plugins/login-resources/src/components/Form.svelte +++ b/plugins/login-resources/src/components/Form.svelte @@ -38,15 +38,19 @@ func: () => Promise } + interface BottomAction { + i18n: IntlString + func: () => void + caption: IntlString + } + export let caption: IntlString export let status: Status export let fields: Field[] export let action: Action export let secondaryButtonLabel: IntlString | undefined = undefined export let secondaryButtonAction: (() => void) | undefined = undefined - export let bottomCaption: IntlString | undefined = undefined - export let bottomActionLabel: IntlString | undefined = undefined - export let bottomActionFunc: (() => void) | undefined = undefined + export let bottomActions: BottomAction[] = [] export let object: any async function validate () { @@ -152,15 +156,15 @@ {/if} - {#if bottomCaption || (bottomActionLabel && bottomActionFunc)} + {#if bottomActions.length}
{/if} diff --git a/plugins/login-resources/src/components/Join.svelte b/plugins/login-resources/src/components/Join.svelte index 174ae1345f..614554d6db 100644 --- a/plugins/login-resources/src/components/Join.svelte +++ b/plugins/login-resources/src/components/Join.svelte @@ -84,13 +84,24 @@ } } - $: bottomCaption = page === 'login' ? login.string.DoNotHaveAnAccount : login.string.HaveAccount - $: bottomActionLabel = page === 'login' ? login.string.SignUp : login.string.LogIn + $: bottom = page === 'login' ? [signUpAction] : [loginAction] $: secondaryButtonLabel = page === 'login' ? login.string.SignUp : undefined $: secondaryButtonAction = () => { page = 'signUp' } + const signUpAction = { + caption: login.string.DoNotHaveAnAccount, + i18n: login.string.SignUp, + func: () => (page = 'signUp') + } + + const loginAction = { + caption: login.string.HaveAccount, + i18n: login.string.LogIn, + func: () => (page = 'login') + } + onMount(() => { check() }) @@ -121,9 +132,5 @@ {action} {secondaryButtonLabel} {secondaryButtonAction} - {bottomCaption} - {bottomActionLabel} - bottomActionFunc={() => { - page = page === 'login' ? 'signUp' : 'login' - }} + bottomActions={bottom} /> diff --git a/plugins/login-resources/src/components/LoginApp.svelte b/plugins/login-resources/src/components/LoginApp.svelte index c423cc370f..070bf492b3 100644 --- a/plugins/login-resources/src/components/LoginApp.svelte +++ b/plugins/login-resources/src/components/LoginApp.svelte @@ -25,6 +25,8 @@ import { onDestroy } from 'svelte' import login from '../plugin' import { getMetadata } from '@hcengineering/platform' + import PasswordRequest from './PasswordRequest.svelte' + import PasswordRestore from './PasswordRestore.svelte' export let page: string = 'login' @@ -58,6 +60,10 @@ {:else if page === 'createWorkspace'} + {:else if page === 'password'} + + {:else if page === 'recovery'} + {:else if page === 'selectWorkspace'} {:else if page === 'join'} diff --git a/plugins/login-resources/src/components/LoginForm.svelte b/plugins/login-resources/src/components/LoginForm.svelte index ecd4186856..b327aee8f0 100644 --- a/plugins/login-resources/src/components/LoginForm.svelte +++ b/plugins/login-resources/src/components/LoginForm.svelte @@ -14,11 +14,11 @@ // limitations under the License. --> -
{ - const loc = getCurrentLocation() - loc.path[1] = 'signup' - loc.path.length = 2 - navigate(loc) - }} -/> + diff --git a/plugins/login-resources/src/components/PasswordRequest.svelte b/plugins/login-resources/src/components/PasswordRequest.svelte new file mode 100644 index 0000000000..4e69042a60 --- /dev/null +++ b/plugins/login-resources/src/components/PasswordRequest.svelte @@ -0,0 +1,84 @@ + + + + diff --git a/plugins/login-resources/src/components/PasswordRestore.svelte b/plugins/login-resources/src/components/PasswordRestore.svelte new file mode 100644 index 0000000000..096eae19b9 --- /dev/null +++ b/plugins/login-resources/src/components/PasswordRestore.svelte @@ -0,0 +1,59 @@ + + + + diff --git a/plugins/login-resources/src/components/SignupForm.svelte b/plugins/login-resources/src/components/SignupForm.svelte index b50c253221..8db670c764 100644 --- a/plugins/login-resources/src/components/SignupForm.svelte +++ b/plugins/login-resources/src/components/SignupForm.svelte @@ -67,12 +67,16 @@ {fields} {object} {action} - bottomCaption={login.string.HaveAccount} - bottomActionLabel={login.string.LogIn} - bottomActionFunc={() => { - const loc = getCurrentLocation() - loc.path[1] = 'login' - loc.path.length = 2 - navigate(loc) - }} + bottomActions={[ + { + caption: login.string.HaveAccount, + i18n: login.string.LogIn, + func: () => { + const loc = getCurrentLocation() + loc.path[1] = 'login' + loc.path.length = 2 + navigate(loc) + } + } + ]} /> diff --git a/plugins/login-resources/src/plugin.ts b/plugins/login-resources/src/plugin.ts index a0cfd7e409..b0777d39c7 100644 --- a/plugins/login-resources/src/plugin.ts +++ b/plugins/login-resources/src/plugin.ts @@ -50,6 +50,11 @@ export default mergeIds(loginId, login, { ChangeAccount: '' as IntlString, WorkspaceNameRule: '' as IntlString, LinkValidHours: '' as IntlString, - GetLink: '' as IntlString + GetLink: '' as IntlString, + ForgotPassword: '' as IntlString, + Recover: '' as IntlString, + KnowPassword: '' as IntlString, + PasswordRecovery: '' as IntlString, + RecoveryLinkSent: '' as IntlString } }) diff --git a/plugins/login-resources/src/utils.ts b/plugins/login-resources/src/utils.ts index a4876bda95..fbe45c54e3 100644 --- a/plugins/login-resources/src/utils.ts +++ b/plugins/login-resources/src/utils.ts @@ -566,3 +566,65 @@ export async function leaveWorkspace (email: string): Promise { body: serialize(request) }) } + +export async function requestPassword (email: string): Promise { + const accountsUrl = getMetadata(login.metadata.AccountsUrl) + + if (accountsUrl === undefined) { + throw new Error('accounts url not specified') + } + + const overrideToken = getMetadata(login.metadata.OverrideLoginToken) + if (overrideToken !== undefined) { + const endpoint = getMetadata(login.metadata.OverrideEndpoint) + if (endpoint !== undefined) { + return OK + } + } + const request: Request<[string]> = { + method: 'requestPassword', + params: [email] + } + + try { + const response = await fetch(accountsUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: serialize(request) + }) + const result: Response = await response.json() + return result.error ?? OK + } catch (err) { + return unknownError(err) + } +} + +export async function restorePassword (token: string, password: string): Promise<[Status, LoginInfo | undefined]> { + const accountsUrl = getMetadata(login.metadata.AccountsUrl) + + if (accountsUrl === undefined) { + throw new Error('accounts url not specified') + } + + const request: Request<[string]> = { + method: 'restorePassword', + params: [password] + } + + try { + const response = await fetch(accountsUrl, { + method: 'POST', + headers: { + Authorization: 'Bearer ' + token, + 'Content-Type': 'application/json' + }, + body: serialize(request) + }) + const result: Response = await response.json() + return [result.error ?? OK, result.result] + } catch (err) { + return [unknownError(err), undefined] + } +} diff --git a/pods/account/package.json b/pods/account/package.json index 00bdb4919b..e98b81cae8 100644 --- a/pods/account/package.json +++ b/pods/account/package.json @@ -13,7 +13,7 @@ "docker:build": "docker build -t hardcoreeng/account .", "docker:staging": "../../common/scripts/docker_tag.sh hardcoreeng/account staging", "docker:push": "../../common/scripts/docker_tag.sh hardcoreeng/account", - "run-local": "cross-env MONGO_URL=mongodb://localhost:27017 MINIO_ACCESS_KEY=minioadmin MINIO_SECRET_KEY=minioadmin MINIO_ENDPOINT=localhost SERVER_SECRET='secret' TRANSACTOR_URL=ws:/localhost:3333 ts-node src/__start.ts", + "run-local": "cross-env MONGO_URL=mongodb://localhost:27017 MINIO_ACCESS_KEY=minioadmi MINIO_SECRET_KEY=minioadmin MINIO_ENDPOINT=localhost SERVER_SECRET='secret' TRANSACTOR_URL=ws:/localhost:3333 ts-node src/__start.ts", "lint": "eslint src", "format": "prettier --write src && eslint --fix src" }, diff --git a/pods/account/src/index.ts b/pods/account/src/index.ts index c9310ddd22..a8da0f7151 100644 --- a/pods/account/src/index.ts +++ b/pods/account/src/index.ts @@ -14,7 +14,7 @@ // limitations under the License. // -import { AccountMethod, ACCOUNT_DB } from '@hcengineering/account' +import account, { AccountMethod, ACCOUNT_DB } from '@hcengineering/account' import platform, { Response, serialize, setMetadata, Severity, Status } from '@hcengineering/platform' import serverToken from '@hcengineering/server-token' import toolPlugin from '@hcengineering/server-tool' @@ -50,6 +50,12 @@ export function serveAccount (methods: Record, productId process.exit(1) } + const ses = process.env.SES_URL + const frontURL = process.env.FRONT_URL + + setMetadata(account.metadata.SES_URL, ses) + setMetadata(account.metadata.FrontURL, frontURL) + setMetadata(serverToken.metadata.Secret, serverSecret) setMetadata(toolPlugin.metadata.Endpoint, endpointUri) setMetadata(toolPlugin.metadata.Transactor, transactorUri) diff --git a/server/account/package.json b/server/account/package.json index ded420da24..a5dda22781 100644 --- a/server/account/package.json +++ b/server/account/package.json @@ -24,7 +24,8 @@ "prettier": "^2.7.1", "@rushstack/heft": "^0.47.9", "typescript": "^4.3.5", - "@types/ws": "^8.5.3" + "@types/ws": "^8.5.3", + "@types/node-fetch": "~2.6.2" }, "dependencies": { "mongodb": "^4.11.0", @@ -37,6 +38,7 @@ "@hcengineering/model": "^0.6.0", "@hcengineering/server-tool": "^0.6.0", "@hcengineering/server-token": "^0.6.0", - "@hcengineering/model-all": "^0.6.0" + "@hcengineering/model-all": "^0.6.0", + "node-fetch": "^2.6.6" } } diff --git a/server/account/src/index.ts b/server/account/src/index.ts index 468ae8980a..ea4e17a48a 100644 --- a/server/account/src/index.ts +++ b/server/account/src/index.ts @@ -34,6 +34,7 @@ import core, { import { MigrateOperation } from '@hcengineering/model' import platform, { getMetadata, + Metadata, PlatformError, Plugin, plugin, @@ -47,6 +48,7 @@ import { decodeToken, generateToken } from '@hcengineering/server-token' import toolPlugin, { connect, initModel, upgradeModel } from '@hcengineering/server-tool' import { pbkdf2Sync, randomBytes } from 'crypto' import { Binary, Db, Filter, ObjectId } from 'mongodb' +import fetch from 'node-fetch' const WORKSPACE_COLLECTION = 'workspace' const ACCOUNT_COLLECTION = 'account' @@ -73,6 +75,10 @@ const accountPlugin = plugin(accountId, { AccountAlreadyExists: '' as StatusCode<{ account: string }>, WorkspaceAlreadyExists: '' as StatusCode<{ workspace: string }>, ProductIdMismatch: '' as StatusCode<{ productId: string }> + }, + metadata: { + FrontURL: '' as Metadata, + SES_URL: '' as Metadata } }) @@ -637,7 +643,7 @@ export async function replacePassword (db: Db, productId: string, email: string, const account = await getAccount(db, email) if (account === null) { - throw new PlatformError(new Status(Severity.ERROR, accountPlugin.status.InvalidPassword, { account: email })) + throw new PlatformError(new Status(Severity.ERROR, accountPlugin.status.AccountNotFound, { account: email })) } const salt = randomBytes(32) const hash = hashWithSalt(password, salt) @@ -645,6 +651,73 @@ export async function replacePassword (db: Db, productId: string, email: string, await db.collection(ACCOUNT_COLLECTION).updateOne({ _id: account._id }, { $set: { salt, hash } }) } +/** + * @public + */ +export async function requestPassword (db: Db, productId: string, email: string): Promise { + const account = await getAccount(db, email) + + if (account === null) { + throw new PlatformError(new Status(Severity.ERROR, accountPlugin.status.AccountNotFound, { account: email })) + } + + const sesURL = getMetadata(accountPlugin.metadata.SES_URL) + if (sesURL === undefined || sesURL === '') { + throw new Error('Please provide email service url') + } + const front = getMetadata(accountPlugin.metadata.FrontURL) + if (front === undefined || front === '') { + throw new Error('Please provide front url') + } + + const token = generateToken('@restore', getWorkspaceId('', productId), { + restore: email + }) + + const link = `${front}/login/recovery?id=${token}` + + const text = `We received a request to reset the password for your account. To reset your password, please paste the following link in your web browser's address bar: ${link}. If you have not ordered a password recovery just ignore this letter.` + const html = `

We received a request to reset the password for your account. To reset your password, please click the link below: Reset password

+ If the Reset password link above does not work, paste the following link in your web browser's address bar: ${link} +

If you have not ordered a password recovery just ignore this letter.

` + const subject = 'Password recovery' + const to = account.email + await fetch(`${sesURL}/send`, { + method: 'post', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + text, + html, + subject, + to + }) + }) +} + +/** + * @public + */ +export async function restorePassword (db: Db, productId: string, token: string, password: string): Promise { + const decode = decodeToken(token) + const email = decode.extra?.restore + if (email === undefined) { + throw new PlatformError(new Status(Severity.ERROR, accountPlugin.status.AccountNotFound, { account: accountId })) + } + const account = await getAccount(db, email) + + if (account === null) { + throw new PlatformError(new Status(Severity.ERROR, accountPlugin.status.AccountNotFound, { account: accountId })) + } + const salt = randomBytes(32) + const hash = hashWithSalt(password, salt) + + await db.collection(ACCOUNT_COLLECTION).updateOne({ _id: account._id }, { $set: { salt, hash } }) + + return await login(db, productId, email, password) +} + /** * @public */ @@ -868,7 +941,9 @@ export function getMethods ( leaveWorkspace: wrap(leaveWorkspace), listWorkspaces: wrap(listWorkspaces), changeName: wrap(changeName), - changePassword: wrap(changePassword) + changePassword: wrap(changePassword), + requestPassword: wrap(requestPassword), + restorePassword: wrap(restorePassword) // updateAccount: wrap(updateAccount) } }