Merge pull request #2974 from specklesystems/fabians/pwdreset-ioc-2
chore(server): pwdreset IoC 2 - requestPasswordRecoveryFactory
This commit is contained in:
Коммит
8cd4691cf2
|
@ -0,0 +1,10 @@
|
|||
import { PasswordResetTokenRecord } from '@/modules/pwdreset/repositories'
|
||||
import { Optional } from '@speckle/shared'
|
||||
|
||||
export type EmailOrTokenId = { email?: string; tokenId?: string }
|
||||
|
||||
export type GetPendingToken = (
|
||||
identity: EmailOrTokenId
|
||||
) => Promise<Optional<PasswordResetTokenRecord>>
|
||||
|
||||
export type CreateToken = (email: string) => Promise<PasswordResetTokenRecord>
|
|
@ -4,7 +4,11 @@ import { StringChain } from 'lodash'
|
|||
import dayjs from 'dayjs'
|
||||
import { InvalidArgumentError } from '@/modules/shared/errors'
|
||||
import { Knex } from 'knex'
|
||||
import { db } from '@/db/knex'
|
||||
import {
|
||||
CreateToken,
|
||||
EmailOrTokenId,
|
||||
GetPendingToken
|
||||
} from '@/modules/pwdreset/domain/operations'
|
||||
|
||||
export type PasswordResetTokenRecord = {
|
||||
id: string
|
||||
|
@ -12,8 +16,6 @@ export type PasswordResetTokenRecord = {
|
|||
createdAt: StringChain
|
||||
}
|
||||
|
||||
export type EmailOrTokenId = { email?: string; tokenId?: string }
|
||||
|
||||
const tables = {
|
||||
pwdresetTokens: (db: Knex) => db<PasswordResetTokenRecord>(PasswordResetTokens.name)
|
||||
}
|
||||
|
@ -38,15 +40,17 @@ const baseQueryFactory = (deps: { db: Knex }) => (identity: EmailOrTokenId) => {
|
|||
/**
|
||||
* Attempt to find a valid & pending password reset token that was created in the last hour
|
||||
*/
|
||||
export async function getPendingToken(identity: EmailOrTokenId) {
|
||||
const anHourAgo = dayjs().subtract(1, 'hour')
|
||||
export const getPendingTokenFactory =
|
||||
(deps: { db: Knex }): GetPendingToken =>
|
||||
async (identity: EmailOrTokenId) => {
|
||||
const anHourAgo = dayjs().subtract(1, 'hour')
|
||||
|
||||
const record = await baseQueryFactory({ db })(identity)
|
||||
.andWhere(PasswordResetTokens.col.createdAt, '>', anHourAgo.toISOString())
|
||||
.first()
|
||||
const record = await baseQueryFactory(deps)(identity)
|
||||
.andWhere(PasswordResetTokens.col.createdAt, '>', anHourAgo.toISOString())
|
||||
.first()
|
||||
|
||||
return record
|
||||
}
|
||||
return record
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all tokens that fit the specified identity
|
||||
|
@ -60,18 +64,22 @@ export const deleteTokensFactory =
|
|||
/**
|
||||
* Delete old tokens and create new one
|
||||
*/
|
||||
export const createTokenFactory = (deps: { db: Knex }) => async (email: string) => {
|
||||
if (!email) throw new InvalidArgumentError('E-mail address is empty')
|
||||
export const createTokenFactory =
|
||||
(deps: { db: Knex }): CreateToken =>
|
||||
async (email: string) => {
|
||||
if (!email) throw new InvalidArgumentError('E-mail address is empty')
|
||||
|
||||
await deleteTokensFactory(deps)({ email })
|
||||
await deleteTokensFactory(deps)({ email })
|
||||
|
||||
const data: PasswordResetTokenRecord[] = await tables.pwdresetTokens(deps.db).insert(
|
||||
{
|
||||
id: crs({ length: 10 }),
|
||||
email
|
||||
},
|
||||
'*'
|
||||
)
|
||||
const data: PasswordResetTokenRecord[] = await tables
|
||||
.pwdresetTokens(deps.db)
|
||||
.insert(
|
||||
{
|
||||
id: crs({ length: 10 }),
|
||||
email
|
||||
},
|
||||
'*'
|
||||
)
|
||||
|
||||
return data[0]
|
||||
}
|
||||
return data[0]
|
||||
}
|
||||
|
|
|
@ -1,5 +1,14 @@
|
|||
import { db } from '@/db/knex'
|
||||
import { getUserByEmail } from '@/modules/core/repositories/users'
|
||||
import { getServerInfo } from '@/modules/core/services/generic'
|
||||
import { renderEmail } from '@/modules/emails/services/emailRendering'
|
||||
import { sendEmail } from '@/modules/emails/services/sending'
|
||||
import {
|
||||
createTokenFactory,
|
||||
getPendingTokenFactory
|
||||
} from '@/modules/pwdreset/repositories'
|
||||
import { finalizePasswordReset } from '@/modules/pwdreset/services/finalize'
|
||||
import { requestPasswordRecovery } from '@/modules/pwdreset/services/request'
|
||||
import { requestPasswordRecoveryFactory } from '@/modules/pwdreset/services/request'
|
||||
import { ensureError } from '@/modules/shared/helpers/errorHelper'
|
||||
import { Express } from 'express'
|
||||
|
||||
|
@ -7,6 +16,15 @@ export default function (app: Express) {
|
|||
// sends a password recovery email.
|
||||
app.post('/auth/pwdreset/request', async (req, res) => {
|
||||
try {
|
||||
const requestPasswordRecovery = requestPasswordRecoveryFactory({
|
||||
getUserByEmail,
|
||||
getPendingToken: getPendingTokenFactory({ db }),
|
||||
createToken: createTokenFactory({ db }),
|
||||
getServerInfo,
|
||||
renderEmail,
|
||||
sendEmail
|
||||
})
|
||||
|
||||
const email = req.body.email
|
||||
await requestPasswordRecovery(email)
|
||||
|
||||
|
|
|
@ -3,13 +3,16 @@ import { deleteExistingAuthTokens } from '@/modules/auth/repositories'
|
|||
import { getUserByEmail } from '@/modules/core/repositories/users'
|
||||
import { updateUserPassword } from '@/modules/core/services/users'
|
||||
import { PasswordRecoveryFinalizationError } from '@/modules/pwdreset/errors'
|
||||
import { deleteTokensFactory, getPendingToken } from '@/modules/pwdreset/repositories'
|
||||
import {
|
||||
deleteTokensFactory,
|
||||
getPendingTokenFactory
|
||||
} from '@/modules/pwdreset/repositories'
|
||||
|
||||
async function initializeState(tokenId: string, password: string) {
|
||||
if (!tokenId && !password)
|
||||
throw new PasswordRecoveryFinalizationError('Both the token & password must be set')
|
||||
|
||||
const token = await getPendingToken({ tokenId })
|
||||
const token = await getPendingTokenFactory({ db })({ tokenId })
|
||||
if (!token)
|
||||
throw new PasswordRecoveryFinalizationError(
|
||||
'Invalid reset token, it may be expired'
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import { db } from '@/db/knex'
|
||||
import { getPasswordResetFinalizationRoute } from '@/modules/core/helpers/routeHelper'
|
||||
import { getUserByEmail } from '@/modules/core/repositories/users'
|
||||
import { getServerInfo } from '@/modules/core/services/generic'
|
||||
|
@ -7,49 +6,55 @@ import {
|
|||
renderEmail
|
||||
} from '@/modules/emails/services/emailRendering'
|
||||
import { sendEmail } from '@/modules/emails/services/sending'
|
||||
import { CreateToken, GetPendingToken } from '@/modules/pwdreset/domain/operations'
|
||||
import { InvalidPasswordRecoveryRequestError } from '@/modules/pwdreset/errors'
|
||||
import {
|
||||
createTokenFactory,
|
||||
getPendingToken,
|
||||
PasswordResetTokenRecord
|
||||
} from '@/modules/pwdreset/repositories'
|
||||
import { PasswordResetTokenRecord } from '@/modules/pwdreset/repositories'
|
||||
import { getFrontendOrigin } from '@/modules/shared/helpers/envHelper'
|
||||
|
||||
const EMAIL_SUBJECT = 'Speckle Account Password Reset'
|
||||
|
||||
type InitializeNewTokenDeps = {
|
||||
getUserByEmail: typeof getUserByEmail
|
||||
getPendingToken: GetPendingToken
|
||||
createToken: CreateToken
|
||||
getServerInfo: typeof getServerInfo
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize and validate password reset request
|
||||
*/
|
||||
async function initializeNewToken(email: string) {
|
||||
if (!email) throw new InvalidPasswordRecoveryRequestError('E-mail address is empty')
|
||||
const initializeNewTokenFactory =
|
||||
(deps: InitializeNewTokenDeps) => async (email: string) => {
|
||||
if (!email) throw new InvalidPasswordRecoveryRequestError('E-mail address is empty')
|
||||
|
||||
const [user, tokenAlreadyExists] = await Promise.all([
|
||||
getUserByEmail(email),
|
||||
getPendingToken({ email }).then((t) => !!t)
|
||||
])
|
||||
const [user, tokenAlreadyExists] = await Promise.all([
|
||||
deps.getUserByEmail(email),
|
||||
deps.getPendingToken({ email }).then((t) => !!t)
|
||||
])
|
||||
|
||||
if (!user) {
|
||||
throw new InvalidPasswordRecoveryRequestError(
|
||||
'No user with that e-mail address found'
|
||||
)
|
||||
if (!user) {
|
||||
throw new InvalidPasswordRecoveryRequestError(
|
||||
'No user with that e-mail address found'
|
||||
)
|
||||
}
|
||||
|
||||
if (tokenAlreadyExists) {
|
||||
throw new InvalidPasswordRecoveryRequestError(
|
||||
'Password reset already requested. Please try again in 1h, or check your email for the instructions we have sent.'
|
||||
)
|
||||
}
|
||||
|
||||
const [serverInfo, newToken] = await Promise.all([
|
||||
deps.getServerInfo(),
|
||||
deps.createToken(email)
|
||||
])
|
||||
|
||||
return { user, serverInfo, email, newToken }
|
||||
}
|
||||
|
||||
if (tokenAlreadyExists) {
|
||||
throw new InvalidPasswordRecoveryRequestError(
|
||||
'Password reset already requested. Please try again in 1h, or check your email for the instructions we have sent.'
|
||||
)
|
||||
}
|
||||
|
||||
const createToken = createTokenFactory({ db })
|
||||
const [serverInfo, newToken] = await Promise.all([
|
||||
getServerInfo(),
|
||||
createToken(email)
|
||||
])
|
||||
|
||||
return { user, serverInfo, email, newToken }
|
||||
}
|
||||
|
||||
type PasswordRecoveryRequestState = Awaited<ReturnType<typeof initializeNewToken>>
|
||||
type PasswordRecoveryRequestState = Awaited<
|
||||
ReturnType<ReturnType<typeof initializeNewTokenFactory>>
|
||||
>
|
||||
|
||||
function buildResetLink(token: PasswordResetTokenRecord) {
|
||||
return new URL(
|
||||
|
@ -94,25 +99,32 @@ function buildEmailTemplateParams(
|
|||
}
|
||||
}
|
||||
|
||||
async function sendResetEmail(state: PasswordRecoveryRequestState) {
|
||||
const emailTemplateParams = buildEmailTemplateParams(state)
|
||||
const { html, text } = await renderEmail(
|
||||
emailTemplateParams,
|
||||
state.serverInfo,
|
||||
state.user
|
||||
)
|
||||
await sendEmail({
|
||||
to: state.email,
|
||||
subject: EMAIL_SUBJECT,
|
||||
text,
|
||||
html
|
||||
})
|
||||
type SendResetEmailDeps = {
|
||||
renderEmail: typeof renderEmail
|
||||
sendEmail: typeof sendEmail
|
||||
}
|
||||
|
||||
const sendResetEmailFactory =
|
||||
(deps: SendResetEmailDeps) => async (state: PasswordRecoveryRequestState) => {
|
||||
const emailTemplateParams = buildEmailTemplateParams(state)
|
||||
const { html, text } = await deps.renderEmail(
|
||||
emailTemplateParams,
|
||||
state.serverInfo,
|
||||
state.user
|
||||
)
|
||||
await deps.sendEmail({
|
||||
to: state.email,
|
||||
subject: EMAIL_SUBJECT,
|
||||
text,
|
||||
html
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Request a new password recovery and send out the e-mail if the request is valid
|
||||
*/
|
||||
export async function requestPasswordRecovery(email: string) {
|
||||
const state = await initializeNewToken(email)
|
||||
await sendResetEmail(state)
|
||||
}
|
||||
export const requestPasswordRecoveryFactory =
|
||||
(deps: InitializeNewTokenDeps & SendResetEmailDeps) => async (email: string) => {
|
||||
const state = await initializeNewTokenFactory(deps)(email)
|
||||
await sendResetEmailFactory(deps)(state)
|
||||
}
|
||||
|
|
Загрузка…
Ссылка в новой задаче