Add login and signup by code (#6097)

Signed-off-by: Kristina Fefelova <kristin.fefelova@gmail.com>
This commit is contained in:
Kristina
2024-07-30 14:52:10 +07:00
committed by Andrey Sobolev
parent 59bb49c186
commit 8a45a3fdde
53 changed files with 1721 additions and 236 deletions
+20 -9
View File
@@ -8,8 +8,9 @@ import account, {
UpgradeWorker,
accountId,
cleanInProgressWorkspaces,
getAllTransactors,
getMethods
getMethods,
cleanExpiredOtp,
getAllTransactors
} from '@hcengineering/account'
import accountEn from '@hcengineering/account/lang/en.json'
import accountRu from '@hcengineering/account/lang/ru.json'
@@ -88,6 +89,8 @@ export function serveAccount (
setMetadata(account.metadata.Transactors, transactorUri)
setMetadata(platform.metadata.locale, lang)
setMetadata(account.metadata.ProductName, productName)
setMetadata(account.metadata.OtpTimeToLiveSec, parseInt(process.env.OTP_TIME_TO_LIVE ?? '60'))
setMetadata(account.metadata.OtpRetryDelaySec, parseInt(process.env.OTP_RETRY_DELAY ?? '60'))
setMetadata(account.metadata.SES_URL, ses)
setMetadata(account.metadata.FrontURL, frontURL)
@@ -105,10 +108,17 @@ export function serveAccount (
let client: MongoClient | Promise<MongoClient> = MongoClient.connect(dbUri)
let worker: UpgradeWorker | undefined
const app = new Koa()
const router = new Router()
let worker: UpgradeWorker | undefined
app.use(
cors({
credentials: true
})
)
app.use(bodyParser())
void client.then(async (p: MongoClient) => {
const db = p.db(ACCOUNT_DB)
@@ -117,6 +127,13 @@ export function serveAccount (
// We need to clean workspace with creating === true, since server is restarted.
void cleanInProgressWorkspaces(db, productId)
setInterval(
() => {
void cleanExpiredOtp(db)
},
3 * 60 * 1000
)
const performUpgrade = (process.env.PERFORM_UPGRADE ?? 'true') === 'true'
if (performUpgrade) {
await measureCtx.with('upgrade-all-models', {}, async (ctx) => {
@@ -241,12 +258,6 @@ export function serveAccount (
ctx.body = result
})
app.use(
cors({
credentials: true
})
)
app.use(bodyParser())
app.use(router.routes()).use(router.allowedMethods())
const server = app.listen(ACCOUNT_PORT, () => {
+4 -1
View File
@@ -8,6 +8,9 @@
"RecoverySubject": "Password recovery",
"InviteText": "You were invited to {ws}. To join, please paste the following link into your browser's URL bar: {link}. The link is valid for {expHours} hours.",
"InviteHTML": "<p>You were invited to {ws}. To join, please click the link below: <a href={link}>Join</a></p><p>If the invite link above does not work, paste the following link into your browser's URL bar: {link}</p><p>The link is valid for {expHours} hours.</p>",
"InviteSubject": "Invitation to {ws}"
"InviteSubject": "Invitation to {ws}",
"OtpText": "Confirm your email address to access {app}!\n\nYour confirmation code is below - enter it in the window where you've started signing in for {app}.\n\n{code}\n\nIf you didn’t request this email, there’s nothing to worry about — you can safely ignore it.",
"OtpHTML": "<h3>Confirm your email address to access {app}!</h3><p>Your confirmation code is below - enter it in the window where you've started signing in for {app}.<p/><br/><b style=\"font-size:36px\">{code}</b><br/><br/><p>If you didn’t request this email, there’s nothing to worry about — you can safely ignore it.</p>",
"OtpSubject": "{app} confirmation code: {code}"
}
}
+4 -1
View File
@@ -8,6 +8,9 @@
"RecoverySubject": "Recuperación de Contraseña",
"InviteText": "Te invitaron a {ws}. Para unirte, pega el siguiente enlace en la barra de URL de tu navegador web: {link}. Enlace válido por {expHours} horas.",
"InviteHTML": "<p>Te invitaron a {ws}. Para unirte, haz clic en el enlace a continuación: <a href={link}>Unirse</a></p><p>Si el enlace de invitación anterior no funciona, pega el siguiente enlace en la barra de URL de tu navegador web: {link}</p><p>Enlace válido por {expHours} horas.</p>",
"InviteSubject": "Invitación a {ws}"
"InviteSubject": "Invitación a {ws}",
"OtpText": "¡Confirma tu dirección de correo electrónico para acceder a {app}!\n\nTu código de confirmación está a continuación: ingrésalo en la ventana donde iniciaste sesión en {app}.\n\n{code}\n\nSi no solicitaste este correo electrónico, no hay de qué preocuparse; puedes ignorarlo con seguridad.",
"OtpHTML": "<h3>¡Confirma tu dirección de correo electrónico para acceder a {app}!</h3><p>Tu código de confirmación está a continuación: ingrésalo en la ventana donde iniciaste sesión en {app}.<p/><br/><b style=\"font-size:36px\">{code}</b><br/><br/><p>Si no solicitaste este correo electrónico, no hay de qué preocuparse; puedes ignorarlo con seguridad.</p>",
"OtpSubject": "Código de confirmación de {app}: {code}"
}
}
+4 -1
View File
@@ -8,6 +8,9 @@
"RecoverySubject": "Réinitialisation du mot de passe",
"InviteText": "Vous avez été invité à {ws}. Pour rejoindre, veuillez coller le lien suivant dans la barre d'adresse de votre navigateur web : {link}. Le lien est valable pendant {expHours} heures.",
"InviteHTML": "<p>Vous avez été invité à {ws}. Pour rejoindre, veuillez cliquer sur le lien ci-dessous : <a href={link}>Rejoindre</a></p><p>Si le lien d'invitation ci-dessus ne fonctionne pas, collez le lien suivant dans la barre d'adresse de votre navigateur web : {link}</p><p>Le lien est valable pendant {expHours} heures.</p>",
"InviteSubject": "Invitation à {ws}"
"InviteSubject": "Invitation à {ws}",
"OtpText": "Confirmez votre adresse e-mail pour accéder à {app}!\n\nVotre code de confirmation est ci-dessous - entrez-le dans la fenêtre où vous avez commencé à vous connecter pour {app}.\n\n{code}\n\nSi vous n'avez pas demandé cet e-mail, ne vous inquiétez pas - vous pouvez l'ignorer en toute sécurité.",
"OtpHTML": "<h3>Confirmez votre adresse e-mail pour accéder à {app}!</h3><p>Votre code de confirmation est ci-dessous - entrez-le dans la fenêtre où vous avez commencé à vous connecter pour {app}.<p/><br/><b style=\"font-size:36px\">{code}</b><br/><br/><p>Si vous n'avez pas demandé cet e-mail, ne vous inquiétez pas - vous pouvez l'ignorer en toute sécurité.</p>",
"OtpSubject": "Code de confirmation {app} : {code}"
}
}
+4 -1
View File
@@ -8,6 +8,9 @@
"RecoverySubject": "Recuperação de Senha",
"InviteText": "Foi convidado para {ws}. Para se juntar, cole o seguinte link na barra de URL do seu navegador web: {link}. Link válido por {expHours} horas.",
"InviteHTML": "<p>Foi convidado para {ws}. Para se juntar, clique no link abaixo: <a href={link}>Juntar-se</a></p><p>Se o link de convite acima não funcionar, cole o seguinte link na barra de URL do seu navegador web: {link}</p><p>Link válido por {expHours} horas.</p>",
"InviteSubject": "Convite para {ws}"
"InviteSubject": "Convite para {ws}",
"OtpText": "Confirme o seu endereço de email para aceder a {app}!\n\nO seu código de confirmação está abaixo - introduza-o na janela onde começou a iniciar sessão para {app}.\n\n{code}\n\nSe não solicitou este email, não há motivo para preocupação - pode ignorá-lo com segurança.",
"OtpHTML": "<h3>Confirme o seu endereço de email para aceder a {app}!</h3><p>O seu código de confirmação está abaixo - introduza-o na janela onde começou a iniciar sessão para {app}.<p/><br/><b style=\"font-size:36px\">{code}</b><br/><br/><p>Se não solicitou este email, não há motivo para preocupação - pode ignorá-lo com segurança.</p>",
"OtpSubject": "Código de confirmação {app}: {code}"
}
}
+4 -1
View File
@@ -8,6 +8,9 @@
"RecoverySubject": "Восстановление пароля",
"InviteText": "Вы были приглашены в {ws}. Чтобы присоединиться, пожалуйста, скопируйте ссылку ниже в адресную строку вашего браузера: {link}. Ссылка действительна в течение {expHours} ч.",
"InviteHTML": "<p>Вы были приглашены в {ws}. Чтобы присоединиться, пожалуйста, скопируйте ссылку ниже в адресную строку вашего браузера: <a href={link}>Присоединиться</a></p><p>Если ссылка выше не работает, скопируйте следующую ссылку в адресную строку вашего браузера: {link}</p><p>Ссылка действительна в течение {expHours} ч.</p>",
"InviteSubjectRU": "Приглашение в {ws}"
"InviteSubjectRU": "Приглашение в {ws}",
"OtpText": "Подтвердите адрес электронной почты для доступа к {app}!\n\nВаш код подтверждения ниже - введите его в окне, где вы начали входить в {app}.\n\n{code}\n\nЕсли вы не запрашивали это письмо, не беспокойтесь - вы можете спокойно проигнорировать его.",
"OtpHTML": "<h3>Подтвердите адрес электронной почты для доступа к {app}!</h3><p>Ваш код подтверждения ниже - введите его в окне, где вы начали входить в {app}.<p/><br/><b style=\"font-size:36px\">{code}</b><br/><br/><p>Если вы не запрашивали это письмо, не беспокойтесь - вы можете спокойно проигнорировать его.</p>",
"OtpSubject": "Код подтверждения {app}: {code}"
}
}
+4 -1
View File
@@ -8,6 +8,9 @@
"RecoverySubject": "密码恢复",
"InviteText": "您被邀请加入 {ws}。要加入,请将以下链接粘贴到您的浏览器地址栏中:{link}。链接有效期为 {expHours} 小时。",
"InviteHTML": "<p>您被邀请加入 {ws}。要加入,请点击以下链接:<a href={link}>加入</a></p><p>如果上面的邀请链接不起作用,请将以下链接粘贴到您的浏览器地址栏中:{link}</p><p>链接有效期为 {expHours} 小时。</p>",
"InviteSubject": "邀请加入 {ws}"
"InviteSubject": "邀请加入 {ws}",
"OtpText": "确认您的电子邮件地址以访问 {app}!\n\n您的确认码如下 - 请在您开始登录 {app} 的窗口中输入。\n\n{code}\n\n如果您没有请求此邮件,不用担心 — 您可以放心地忽略它。",
"OtpHTML": "<h3>确认您的电子邮件地址以访问 {app}!</h3><p>您的确认码如下 - 请在您开始登录 {app} 的窗口中输入。<p/><br/><b style=\"font-size:36px\">{code}</b><br/><br/><p>如果您没有请求此邮件,不用担心 — 您可以放心地忽略它。</p>",
"OtpSubject": "{app} 确认码:{code}"
}
}
+3 -1
View File
@@ -36,7 +36,8 @@
"@types/node-fetch": "~2.6.2",
"jest": "^29.7.0",
"ts-jest": "^29.1.1",
"@types/jest": "^29.5.5"
"@types/jest": "^29.5.5",
"@types/otp-generator": "^4.0.2"
},
"dependencies": {
"mongodb": "^6.8.0",
@@ -45,6 +46,7 @@
"@hcengineering/contact": "^0.6.24",
"@hcengineering/client-resources": "^0.6.27",
"@hcengineering/client": "^0.6.18",
"otp-generator": "^4.0.1",
"ws": "^8.18.0",
"@hcengineering/model": "^0.6.11",
"@hcengineering/server-backup": "^0.6.0",
+208 -2
View File
@@ -45,6 +45,7 @@ import core, {
Version,
versionToString,
WorkspaceId,
Timestamp,
WorkspaceIdWithUrl,
type Branding
} from '@hcengineering/core'
@@ -62,6 +63,7 @@ import toolPlugin, {
import { pbkdf2Sync, randomBytes } from 'crypto'
import { Binary, Db, Filter, ObjectId, type MongoClient } from 'mongodb'
import fetch from 'node-fetch'
import otpGenerator from 'otp-generator'
import {
DummyFullTextAdapter,
Pipeline,
@@ -76,10 +78,12 @@ import {
registerServerPlugins,
registerStringLoaders
} from '@hcengineering/server-pipeline'
import { accountPlugin } from './plugin'
const WORKSPACE_COLLECTION = 'workspace'
const ACCOUNT_COLLECTION = 'account'
const OTP_COLLECTION = 'otp'
const INVITE_COLLECTION = 'invite'
/**
* @public
@@ -200,6 +204,18 @@ export interface Workspace extends BaseWorkspaceInfo {
transactor?: string // Transactor group name
}
export interface OtpRecord {
account: ObjectId
otp: string
expires: Timestamp
createdOn: Timestamp
}
export interface OtpInfo {
sent: boolean
retryOn: Timestamp
}
/**
* @public
*/
@@ -343,7 +359,36 @@ async function getAccountInfo (
return toAccountInfo(account)
}
async function getAccountInfoByToken (
async function sendOtpEmail (branding: Branding | null, otp: string, email: string): Promise<void> {
const sesURL = getMetadata(accountPlugin.metadata.SES_URL)
if (sesURL === undefined || sesURL === '') {
console.info('Please provide email service url to enable email otp.')
return
}
const lang = branding?.language
const app = branding?.title ?? getMetadata(accountPlugin.metadata.ProductName)
const text = await translate(accountPlugin.string.OtpText, { code: otp, app }, lang)
const html = await translate(accountPlugin.string.OtpHTML, { code: otp, app }, lang)
const subject = await translate(accountPlugin.string.OtpSubject, { code: otp, app }, lang)
const to = email
await fetch(concatLink(sesURL, '/send'), {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
text,
html,
subject,
to
})
})
}
export async function getAccountInfoByToken (
ctx: MeasureContext,
db: Db,
productId: string,
@@ -409,6 +454,117 @@ export async function login (
}
}
async function getNewOtp (db: Db): Promise<string> {
let otp = otpGenerator.generate(6, {
upperCaseAlphabets: false,
lowerCaseAlphabets: false,
specialChars: false
})
let exist = await db.collection<OtpRecord>(OTP_COLLECTION).findOne({ otp })
while (exist != null) {
otp = otpGenerator.generate(6, {
lowerCaseAlphabets: false
})
exist = await db.collection<OtpRecord>(OTP_COLLECTION).findOne({ otp })
}
return otp
}
export async function sendOtp (
ctx: MeasureContext,
db: Db,
productId: string,
branding: Branding | null,
_email: string
): Promise<OtpInfo> {
const email = cleanEmail(_email)
const account = await getAccount(db, email)
if (account == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, { account: email }))
}
const now = Date.now()
const otpData = (
await db
.collection<OtpRecord>(OTP_COLLECTION)
.find({ account: account._id })
.sort({ createdOn: -1 })
.limit(1)
.toArray()
)[0]
const retryDelay = getMetadata(accountPlugin.metadata.OtpRetryDelaySec) ?? 30
const isValid = otpData !== undefined && otpData.expires > now && otpData.createdOn + retryDelay * 1000 > now
if (isValid) {
return { sent: true, retryOn: otpData.createdOn + retryDelay * 1000 }
}
const secs = getMetadata(accountPlugin.metadata.OtpTimeToLiveSec) ?? 60
const timeToLive = secs * 1000
const expires = now + timeToLive
const otp = await getNewOtp(db)
await sendOtpEmail(branding, otp, email)
await db.collection<OtpRecord>(OTP_COLLECTION).insertOne({ account: account._id, otp, expires, createdOn: now })
return { sent: true, retryOn: now + retryDelay * 1000 }
}
async function isOtpValid (db: Db, account: Account, otp: string): Promise<boolean> {
const now = Date.now()
const otpData = (await db.collection<OtpRecord>(OTP_COLLECTION).findOne({ account: account._id, otp })) ?? undefined
return otpData !== undefined && otpData.expires > now
}
export async function validateOtp (
ctx: MeasureContext,
db: Db,
productId: string,
branding: Branding | null,
_email: string,
otp: string
): Promise<LoginInfo> {
const email = cleanEmail(_email)
const account = await getAccount(db, email)
if (account == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, { account: email }))
}
const isValid = await isOtpValid(db, account, otp)
if (!isValid) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.InvalidOtp, {}))
}
try {
const info = toAccountInfo(account)
if (account.confirmed !== true) {
await db.collection(ACCOUNT_COLLECTION).updateOne({ _id: account._id }, { $set: { confirmed: true } })
}
const result = {
endpoint: '',
email,
confirmed: true,
token: generateToken(email, getWorkspaceId('', productId), getExtra(info))
}
await db.collection<OtpRecord>(OTP_COLLECTION).deleteMany({ account: account._id })
ctx.info('otp login success', { email, productId })
return result
} catch (err: any) {
Analytics.handleError(err)
ctx.error('otp login failed', { email, productId, _email, err })
throw err
}
}
/**
* Will add extra props
*/
@@ -824,6 +980,25 @@ export async function createAccount (
return result
}
/**
* @public
*/
export async function signUpOtp (
ctx: MeasureContext,
db: Db,
productId: string,
branding: Branding | null,
_email: string
): Promise<OtpInfo> {
const email = cleanEmail(_email)
const first = email.split('@', 1)[0] ?? ''
const last = ''
await createAcc(ctx, db, productId, branding, email, null, first, last, false)
return await sendOtp(ctx, db, productId, branding, _email)
}
/**
* @public
*/
@@ -894,6 +1069,10 @@ export async function cleanInProgressWorkspaces (db: Db, productId: string): Pro
}
}
export async function cleanExpiredOtp (db: Db): Promise<void> {
await db.collection<OtpRecord>(OTP_COLLECTION).deleteMany({ expires: { $lte: Date.now() } })
}
/**
* @public
*/
@@ -2425,6 +2604,29 @@ export async function loginWithProvider (
}
}
/**
* @public
*/
export async function changeUsername (
ctx: MeasureContext,
db: Db,
productId: string,
branding: Branding | null,
token: string,
first: string,
last: string
): Promise<void> {
const { email } = decodeToken(token)
const account = await getAccount(db, email)
if (account == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, { account: email }))
}
await db.collection(ACCOUNT_COLLECTION).updateOne({ _id: account._id }, { $set: { first, last } })
ctx.info('change-username success', { email })
}
/**
* @public
*/
@@ -2436,6 +2638,9 @@ export function getMethods (
return {
login: wrap(login),
join: wrap(join),
sendOtp: wrap(sendOtp),
validateOtp: wrap(validateOtp),
signUpOtp: wrap(signUpOtp),
checkJoin: wrap(checkJoin),
signUpJoin: wrap(signUpJoin),
selectWorkspace: wrap(selectWorkspace),
@@ -2455,7 +2660,8 @@ export function getMethods (
sendInvite: wrap(sendInvite),
confirm: wrap(confirm),
getAccountInfoByToken: wrap(getAccountInfoByToken),
createMissingEmployee: wrap(createMissingEmployee)
createMissingEmployee: wrap(createMissingEmployee),
changeUsername: wrap(changeUsername)
// updateAccount: wrap(updateAccount)
}
}
+7 -2
View File
@@ -13,7 +13,9 @@ export const accountPlugin = plugin(accountId, {
FrontURL: '' as Metadata<string>,
SES_URL: '' as Metadata<string>,
ProductName: '' as Metadata<string>,
Transactors: '' as Metadata<string>
Transactors: '' as Metadata<string>,
OtpTimeToLiveSec: '' as Metadata<number>,
OtpRetryDelaySec: '' as Metadata<number>
},
string: {
ConfirmationText: '' as IntlString,
@@ -24,6 +26,9 @@ export const accountPlugin = plugin(accountId, {
RecoverySubject: '' as IntlString,
InviteText: '' as IntlString,
InviteHTML: '' as IntlString,
InviteSubject: '' as IntlString
InviteSubject: '' as IntlString,
OtpText: '' as IntlString,
OtpHTML: '' as IntlString,
OtpSubject: '' as IntlString
}
})