Expire invite link (#2141)

Signed-off-by: Denis Bykhov <80476319+BykhovDenis@users.noreply.github.com>
This commit is contained in:
Denis Bykhov 2022-06-25 23:31:31 +06:00 committed by GitHub
parent d935e9b963
commit 7499ec85d2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 131 additions and 46 deletions

View File

@ -24,6 +24,10 @@ HR:
- Leaves schedule
Core:
- Invite link expire from 1 hour
## 0.6.28
Core:

View File

@ -24,6 +24,7 @@
"Copied": "Copied",
"Close": "Close",
"InviteDescription": "Share this link to invite other users",
"InviteNote": "Link is valid for 1 hour",
"WantAnotherWorkspace": "Want to create another workspace?",
"ChangeAccount": "Change account",
"NotSeeingWorkspace": "Not seeing your workspace?"

View File

@ -24,6 +24,7 @@
"Copied": "Скопировано",
"Close": "Закрыть",
"InviteDescription": "Поделитесь ссылкой чтобы пригласить других участников",
"InviteNote": "Ссылка действительна 1 час",
"WantAnotherWorkspace": "Хотите создать другое рабочее пространство?",
"ChangeAccount": "Сменить пользователя",
"NotSeeingWorkspace": "Не видите ваше рабочее пространство?"

View File

@ -1,6 +1,5 @@
<!--
// Copyright © 2020, 2021 Anticrm Platform Contributors.
// Copyright © 2021, 2022 Hardcore Engineering Inc.
// Copyright © 2022 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the 'License');
// you may not use this file except in compliance with the License. You may
@ -16,7 +15,7 @@
<script lang="ts">
import { Timestamp } from '@anticrm/core'
import { Button, getCurrentLocation, Label, locationToUrl, ticker } from '@anticrm/ui'
import { getWorkspaceHash } from '../utils'
import { getInviteLink } from '../utils'
import { createEventDispatcher } from 'svelte'
import login from '../plugin'
import InviteWorkspace from './icons/InviteWorkspace.svelte'
@ -24,13 +23,13 @@
const dispatch = createEventDispatcher()
async function getLink (): Promise<string> {
const hash = await getWorkspaceHash()
const inviteId = await getInviteLink()
const loc = getCurrentLocation()
loc.path[0] = login.component.LoginApp
loc.path[1] = 'join'
loc.path.length = 2
loc.query = {
workspace: hash
inviteId
}
loc.fragment = undefined
@ -59,6 +58,9 @@
<Label label={login.string.InviteDescription} />
<InviteWorkspace size="large" />
</div>
<div class="mt-2">
<Label label={login.string.InviteNote} />
</div>
{#await getLink() then link}
<div class="over-underline link" on:click={() => copy(link)}>{link}</div>
<div class="buttons flex">

View File

@ -1,6 +1,5 @@
<!--
// Copyright © 2020, 2021 Anticrm Platform Contributors.
// Copyright © 2021, 2022 Hardcore Engineering Inc.
// Copyright © 2022 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
@ -62,13 +61,13 @@
const [loginStatus, result] =
page === 'login'
? await join(object.username, object.password, location.query?.workspace ?? '')
? await join(object.username, object.password, location.query?.inviteId ?? '')
: await signUpJoin(
object.username,
object.password,
object.first,
object.last,
location.query?.workspace ?? ''
location.query?.inviteId ?? ''
)
status = loginStatus
@ -76,7 +75,7 @@
setMetadataLocalStorage(login.metadata.LoginToken, result.token)
setMetadataLocalStorage(login.metadata.LoginEndpoint, result.endpoint)
setMetadataLocalStorage(login.metadata.LoginEmail, result.email)
setMetadataLocalStorage(login.metadata.CurrentWorkspace, location.query?.workspace ?? '')
setMetadataLocalStorage(login.metadata.CurrentWorkspace, result.workspace)
navigate({ path: [workbench.component.WorkbenchApp] })
}
}

View File

@ -47,6 +47,7 @@ export default mergeIds(loginId, login, {
InviteDescription: '' as IntlString,
WantAnotherWorkspace: '' as IntlString,
NotSeeingWorkspace: '' as IntlString,
ChangeAccount: '' as IntlString
ChangeAccount: '' as IntlString,
InviteNote: '' as IntlString
}
})

View File

@ -1,5 +1,5 @@
//
// Copyright © 2020 Anticrm Platform Contributors.
// Copyright © 2022 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
@ -13,12 +13,15 @@
// limitations under the License.
//
import { Status, OK, unknownError, getMetadata, serialize, unknownStatus } from '@anticrm/platform'
import type { Request, Response } from '@anticrm/platform'
import { getMetadata, OK, serialize, Status, unknownError, unknownStatus } from '@anticrm/platform'
import login from '@anticrm/login'
import { fetchMetadataLocalStorage, getCurrentLocation, navigate } from '@anticrm/ui'
export interface WorkspaceLoginInfo extends LoginInfo {
workspace: string
}
export interface LoginInfo {
token: string
endpoint: string
@ -29,6 +32,8 @@ export interface Workspace {
workspace: string
}
const DEV_WORKSPACE = 'DEV WORKSPACE'
/**
* Perform a login operation to required workspace with user credentials.
*/
@ -168,7 +173,7 @@ export async function getWorkspaces (): Promise<Workspace[]> {
if (endpoint !== undefined) {
return [
{
workspace: 'DEV WORKSPACE'
workspace: DEV_WORKSPACE
}
]
}
@ -205,7 +210,7 @@ export async function getWorkspaces (): Promise<Workspace[]> {
}
}
export async function selectWorkspace (workspace: string): Promise<[Status, LoginInfo | undefined]> {
export async function selectWorkspace (workspace: string): Promise<[Status, WorkspaceLoginInfo | undefined]> {
const accountsUrl = getMetadata(login.metadata.AccountsUrl)
if (accountsUrl === undefined) {
@ -217,7 +222,7 @@ export async function selectWorkspace (workspace: string): Promise<[Status, Logi
if (overrideToken !== undefined) {
const endpoint = getMetadata(login.metadata.OverrideEndpoint)
if (endpoint !== undefined) {
return [OK, { token: overrideToken, endpoint, email }]
return [OK, { token: overrideToken, endpoint, email, workspace }]
}
}
@ -251,9 +256,13 @@ export async function selectWorkspace (workspace: string): Promise<[Status, Logi
}
}
export async function getWorkspaceHash (): Promise<string> {
export async function getInviteLink (): Promise<string> {
const accountsUrl = getMetadata(login.metadata.AccountsUrl)
const exp = 1000 * 60 * 60
const emailMask = ''
const limit = -1
if (accountsUrl === undefined) {
throw new Error('accounts url not specified')
}
@ -267,9 +276,9 @@ export async function getWorkspaceHash (): Promise<string> {
return ''
}
const request: Request<[]> = {
method: 'getWorkspaceHash',
params: []
const request: Request<[number, string, number]> = {
method: 'getInviteLink',
params: [exp, emailMask, limit]
}
const response = await fetch(accountsUrl, {
@ -287,8 +296,8 @@ export async function getWorkspaceHash (): Promise<string> {
export async function join (
email: string,
password: string,
workspaceHash: string
): Promise<[Status, LoginInfo | undefined]> {
inviteId: string
): Promise<[Status, WorkspaceLoginInfo | undefined]> {
const accountsUrl = getMetadata(login.metadata.AccountsUrl)
if (accountsUrl === undefined) {
@ -299,13 +308,13 @@ export async function join (
if (token !== undefined) {
const endpoint = getMetadata(login.metadata.OverrideEndpoint)
if (endpoint !== undefined) {
return [OK, { token, endpoint, email }]
return [OK, { token, endpoint, email, workspace: DEV_WORKSPACE }]
}
}
const request: Request<[string, string, string]> = {
method: 'join',
params: [email, password, workspaceHash]
params: [email, password, inviteId]
}
try {
@ -328,8 +337,8 @@ export async function signUpJoin (
password: string,
first: string,
last: string,
workspaceHash: string
): Promise<[Status, LoginInfo | undefined]> {
inviteId: string
): Promise<[Status, WorkspaceLoginInfo | undefined]> {
const accountsUrl = getMetadata(login.metadata.AccountsUrl)
if (accountsUrl === undefined) {
@ -340,13 +349,13 @@ export async function signUpJoin (
if (token !== undefined) {
const endpoint = getMetadata(login.metadata.OverrideEndpoint)
if (endpoint !== undefined) {
return [OK, { token, endpoint, email }]
return [OK, { token, endpoint, email, workspace: DEV_WORKSPACE }]
}
}
const request: Request<[string, string, string, string, string]> = {
method: 'signUpJoin',
params: [email, password, first, last, workspaceHash]
params: [email, password, first, last, inviteId]
}
try {

View File

@ -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 TRANSACTOR_URL=ws:/localhost:3333 ts-node src/index.ts",
"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/index.ts",
"lint": "eslint src",
"format": "prettier --write src && eslint --fix src"
},

View File

@ -1,6 +1,5 @@
//
// Copyright © 2020, 2021 Anticrm Platform Contributors.
// Copyright © 2021 Hardcore Engineering Inc.
// Copyright © 2022 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
@ -27,13 +26,14 @@ import platform, {
Status,
StatusCode
} from '@anticrm/platform'
import toolPlugin, { connect, initModel, upgradeModel, version } from '@anticrm/server-tool'
import { decodeToken, generateToken } from '@anticrm/server-token'
import toolPlugin, { connect, initModel, upgradeModel, version } from '@anticrm/server-tool'
import { pbkdf2Sync, randomBytes } from 'crypto'
import { Binary, Db, ObjectId } from 'mongodb'
const WORKSPACE_COLLECTION = 'workspace'
const ACCOUNT_COLLECTION = 'account'
const INVITE_COLLECTION = 'invite'
/**
* @public
@ -106,6 +106,24 @@ export interface LoginInfo {
endpoint: string
}
/**
* @public
*/
export interface WorkspaceLoginInfo extends LoginInfo {
workspace: string
}
/**
* @public
*/
export interface Invite {
_id: ObjectId
workspace: string
exp: number
emailMask: string
limit: number
}
/**
* @public
*/
@ -176,7 +194,7 @@ export async function login (db: Db, email: string, password: string): Promise<L
/**
* @public
*/
export async function selectWorkspace (db: Db, token: string, workspace: string): Promise<LoginInfo> {
export async function selectWorkspace (db: Db, token: string, workspace: string): Promise<WorkspaceLoginInfo> {
const { email } = decodeToken(token)
const accountInfo = await getAccount(db, email)
if (accountInfo === null) {
@ -192,7 +210,8 @@ export async function selectWorkspace (db: Db, token: string, workspace: string)
const result = {
endpoint: getEndpoint(),
email,
token: generateToken(email, workspace)
token: generateToken(email, workspace),
workspace
}
return result
}
@ -205,12 +224,46 @@ export async function selectWorkspace (db: Db, token: string, workspace: string)
/**
* @public
*/
export async function join (db: Db, email: string, password: string, workspaceHash: string): Promise<LoginInfo> {
const { workspace } = decodeToken(workspaceHash)
const token = (await login(db, email, password)).token
export async function getInvite (db: Db, inviteId: ObjectId): Promise<Invite | null> {
return await db.collection(INVITE_COLLECTION).findOne<Invite>({ _id: new ObjectId(inviteId) })
}
/**
* @public
*/
export async function checkInvite (invite: Invite | null, email: string): Promise<string> {
if (invite === null || invite.limit === 0) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
}
if (invite.exp < new Date().getTime()) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
}
if (!new RegExp(invite.emailMask).test(email)) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
}
return invite.workspace
}
/**
* @public
*/
export async function useInvite (db: Db, inviteId: ObjectId): Promise<void> {
await db.collection(INVITE_COLLECTION).updateOne({ _id: inviteId }, { $inc: { limit: -1 } })
}
/**
* @public
*/
export async function join (db: Db, email: string, password: string, inviteId: ObjectId): Promise<WorkspaceLoginInfo> {
const invite = await getInvite(db, inviteId)
const workspace = await checkInvite(invite, email)
await assignWorkspace(db, email, workspace)
return await selectWorkspace(db, token, workspace)
const token = (await login(db, email, password)).token
const result = await selectWorkspace(db, token, workspace)
await useInvite(db, inviteId)
return result
}
/**
@ -222,14 +275,17 @@ export async function signUpJoin (
password: string,
first: string,
last: string,
workspaceHash: string
): Promise<LoginInfo> {
inviteId: ObjectId
): Promise<WorkspaceLoginInfo> {
const invite = await getInvite(db, inviteId)
const workspace = await checkInvite(invite, email)
await createAccount(db, email, password, first, last)
const { workspace } = decodeToken(workspaceHash)
await assignWorkspace(db, email, workspace)
const token = (await login(db, email, password)).token
return await selectWorkspace(db, token, workspace)
const result = await selectWorkspace(db, token, workspace)
await useInvite(db, inviteId)
return result
}
/**
@ -335,13 +391,25 @@ export async function createUserWorkspace (db: Db, token: string, workspace: str
/**
* @public
*/
export async function getWorkspaceHash (db: Db, token: string): Promise<string> {
export async function getInviteLink (
db: Db,
token: string,
exp: number,
emailMask: string,
limit: number
): Promise<ObjectId> {
const { workspace } = decodeToken(token)
const wsPromise = await getWorkspace(db, workspace)
if (wsPromise === null) {
throw new PlatformError(new Status(Severity.ERROR, accountPlugin.status.WorkspaceNotFound, { workspace }))
}
return generateToken('', workspace)
const result = await db.collection(INVITE_COLLECTION).insertOne({
workspace,
exp: new Date().getTime() + exp,
emailMask,
limit
})
return result.insertedId
}
/**
@ -560,7 +628,7 @@ export const methods = {
signUpJoin: wrap(signUpJoin),
selectWorkspace: wrap(selectWorkspace),
getUserWorkspaces: wrap(getUserWorkspaces),
getWorkspaceHash: wrap(getWorkspaceHash),
getInviteLink: wrap(getInviteLink),
getAccountInfo: wrap(getAccountInfo),
createAccount: wrap(createAccount),
createWorkspace: wrap(createUserWorkspace),