Merge pull request #2974 from specklesystems/fabians/pwdreset-ioc-2

chore(server): pwdreset IoC 2 - requestPasswordRecoveryFactory
This commit is contained in:
Alessandro Magionami 2024-09-12 15:20:23 +02:00 коммит произвёл GitHub
Родитель 61b357f413 809a748995
Коммит 8cd4691cf2
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: B5690EEEBB952194
5 изменённых файлов: 125 добавлений и 74 удалений

Просмотреть файл

@ -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)
}