Merge branch 'master' into refactor-balance-resolver

This commit is contained in:
Moriz Wahl 2022-03-29 19:59:12 +02:00 committed by GitHub
commit a38a7889a3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 168 additions and 124 deletions

View File

@ -528,7 +528,7 @@ jobs:
report_name: Coverage Backend report_name: Coverage Backend
type: lcov type: lcov
result_path: ./backend/coverage/lcov.info result_path: ./backend/coverage/lcov.info
min_coverage: 54 min_coverage: 55
token: ${{ github.token }} token: ${{ github.token }}
########################################################################## ##########################################################################

View File

@ -1,4 +1,4 @@
CONFIG_VERSION=v1.2022-03-18 CONFIG_VERSION=v2.2022-03-24
# Server # Server
PORT=4000 PORT=4000
@ -41,8 +41,9 @@ EMAIL_PASSWORD=xxx
EMAIL_SMTP_URL=gmail.com EMAIL_SMTP_URL=gmail.com
EMAIL_SMTP_PORT=587 EMAIL_SMTP_PORT=587
EMAIL_LINK_VERIFICATION=http://localhost/checkEmail/{optin}{code} EMAIL_LINK_VERIFICATION=http://localhost/checkEmail/{optin}{code}
EMAIL_LINK_SETPASSWORD=http://localhost/reset/{optin} EMAIL_LINK_SETPASSWORD=http://localhost/reset/{code}
EMAIL_CODE_VALID_TIME=10 EMAIL_CODE_VALID_TIME=1440
EMAIL_CODE_REQUEST_TIME=10
# Webhook # Webhook
WEBHOOK_ELOPAGE_SECRET=secret WEBHOOK_ELOPAGE_SECRET=secret

View File

@ -14,7 +14,7 @@ const constants = {
DECAY_START_TIME: new Date('2021-05-13 17:46:31'), // GMT+0 DECAY_START_TIME: new Date('2021-05-13 17:46:31'), // GMT+0
CONFIG_VERSION: { CONFIG_VERSION: {
DEFAULT: 'DEFAULT', DEFAULT: 'DEFAULT',
EXPECTED: 'v1.2022-03-18', EXPECTED: 'v2.2022-03-24',
CURRENT: '', CURRENT: '',
}, },
} }
@ -70,8 +70,13 @@ const email = {
process.env.EMAIL_LINK_VERIFICATION || 'http://localhost/checkEmail/{optin}{code}', process.env.EMAIL_LINK_VERIFICATION || 'http://localhost/checkEmail/{optin}{code}',
EMAIL_LINK_SETPASSWORD: EMAIL_LINK_SETPASSWORD:
process.env.EMAIL_LINK_SETPASSWORD || 'http://localhost/reset-password/{optin}', process.env.EMAIL_LINK_SETPASSWORD || 'http://localhost/reset-password/{optin}',
// time in minutes a optin code is valid
EMAIL_CODE_VALID_TIME: process.env.EMAIL_CODE_VALID_TIME EMAIL_CODE_VALID_TIME: process.env.EMAIL_CODE_VALID_TIME
? parseInt(process.env.EMAIL_CODE_VALID_TIME) || 10 ? parseInt(process.env.EMAIL_CODE_VALID_TIME) || 1440
: 1440,
// time in minutes that must pass to request a new optin code
EMAIL_CODE_REQUEST_TIME: process.env.EMAIL_CODE_REQUEST_TIME
? parseInt(process.env.EMAIL_CODE_REQUEST_TIME) || 10
: 10, : 10,
} }

View File

@ -0,0 +1,11 @@
import { registerEnumType } from 'type-graphql'
export enum OptInType {
EMAIL_OPT_IN_REGISTER = 1,
EMAIL_OPT_IN_RESET_PASSWORD = 2,
}
registerEnumType(OptInType, {
name: 'OptInType', // this one is mandatory
description: 'Type of the email optin', // this one is optional
})

View File

@ -39,6 +39,8 @@ import Paginated from '@arg/Paginated'
import TransactionLinkFilters from '@arg/TransactionLinkFilters' import TransactionLinkFilters from '@arg/TransactionLinkFilters'
import { Order } from '@enum/Order' import { Order } from '@enum/Order'
import { communityUser } from '@/util/communityUser' import { communityUser } from '@/util/communityUser'
import { checkOptInCode, activationLink } from './UserResolver'
import { sendAccountActivationEmail } from '@/mailer/sendAccountActivationEmail'
// const EMAIL_OPT_IN_REGISTER = 1 // const EMAIL_OPT_IN_REGISTER = 1
// const EMAIL_OPT_UNKNOWN = 3 // elopage? // const EMAIL_OPT_UNKNOWN = 3 // elopage?
@ -375,6 +377,39 @@ export class AdminResolver {
return userTransactions.map((t) => new Transaction(t, new User(user), communityUser)) return userTransactions.map((t) => new Transaction(t, new User(user), communityUser))
} }
@Authorized([RIGHTS.SEND_ACTIVATION_EMAIL])
@Mutation(() => Boolean)
async sendActivationEmail(@Arg('email') email: string): Promise<boolean> {
email = email.trim().toLowerCase()
const user = await dbUser.findOneOrFail({ email: email })
// can be both types: REGISTER and RESET_PASSWORD
let optInCode = await LoginEmailOptIn.findOne({
where: { userId: user.id },
order: { updatedAt: 'DESC' },
})
optInCode = await checkOptInCode(optInCode, user.id)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const emailSent = await sendAccountActivationEmail({
link: activationLink(optInCode),
firstName: user.firstName,
lastName: user.lastName,
email,
})
/* uncomment this, when you need the activation link on the console
// In case EMails are disabled log the activation link for the user
if (!emailSent) {
// eslint-disable-next-line no-console
console.log(`Account confirmation link: ${activationLink}`)
}
*/
return true
}
@Authorized([RIGHTS.LIST_TRANSACTION_LINKS_ADMIN]) @Authorized([RIGHTS.LIST_TRANSACTION_LINKS_ADMIN])
@Query(() => TransactionLinkResult) @Query(() => TransactionLinkResult)
async listTransactionLinksAdmin( async listTransactionLinksAdmin(

View File

@ -11,6 +11,7 @@ import { LoginEmailOptIn } from '@entity/LoginEmailOptIn'
import { User } from '@entity/User' import { User } from '@entity/User'
import CONFIG from '@/config' import CONFIG from '@/config'
import { sendAccountActivationEmail } from '@/mailer/sendAccountActivationEmail' import { sendAccountActivationEmail } from '@/mailer/sendAccountActivationEmail'
import { printTimeDuration } from './UserResolver'
// import { klicktippSignIn } from '@/apis/KlicktippController' // import { klicktippSignIn } from '@/apis/KlicktippController'
@ -220,10 +221,6 @@ describe('UserResolver', () => {
expect(newUser[0].password).toEqual('3917921995996627700') expect(newUser[0].password).toEqual('3917921995996627700')
}) })
it('removes the optin', async () => {
await expect(LoginEmailOptIn.find()).resolves.toHaveLength(0)
})
/* /*
it('calls the klicktipp API', () => { it('calls the klicktipp API', () => {
expect(klicktippSignIn).toBeCalledWith( expect(klicktippSignIn).toBeCalledWith(
@ -415,3 +412,17 @@ describe('UserResolver', () => {
}) })
}) })
}) })
describe('printTimeDuration', () => {
it('works with 10 minutes', () => {
expect(printTimeDuration(10)).toBe('10 minutes')
})
it('works with 1440 minutes', () => {
expect(printTimeDuration(1440)).toBe('24 hours')
})
it('works with 1410 minutes', () => {
expect(printTimeDuration(1410)).toBe('23 hours and 30 minutes')
})
})

View File

@ -3,7 +3,7 @@
import fs from 'fs' import fs from 'fs'
import { Resolver, Query, Args, Arg, Authorized, Ctx, UseMiddleware, Mutation } from 'type-graphql' import { Resolver, Query, Args, Arg, Authorized, Ctx, UseMiddleware, Mutation } from 'type-graphql'
import { getConnection, getCustomRepository, QueryRunner } from '@dbTools/typeorm' import { getConnection, getCustomRepository } from '@dbTools/typeorm'
import CONFIG from '@/config' import CONFIG from '@/config'
import { User } from '@model/User' import { User } from '@model/User'
import { User as DbUser } from '@entity/User' import { User as DbUser } from '@entity/User'
@ -15,6 +15,7 @@ import UpdateUserInfosArgs from '@arg/UpdateUserInfosArgs'
import { klicktippNewsletterStateMiddleware } from '@/middleware/klicktippMiddleware' import { klicktippNewsletterStateMiddleware } from '@/middleware/klicktippMiddleware'
import { UserSettingRepository } from '@repository/UserSettingRepository' import { UserSettingRepository } from '@repository/UserSettingRepository'
import { Setting } from '@enum/Setting' import { Setting } from '@enum/Setting'
import { OptInType } from '@enum/OptInType'
import { LoginEmailOptIn } from '@entity/LoginEmailOptIn' import { LoginEmailOptIn } from '@entity/LoginEmailOptIn'
import { sendResetPasswordEmail } from '@/mailer/sendResetPasswordEmail' import { sendResetPasswordEmail } from '@/mailer/sendResetPasswordEmail'
import { sendAccountActivationEmail } from '@/mailer/sendAccountActivationEmail' import { sendAccountActivationEmail } from '@/mailer/sendAccountActivationEmail'
@ -24,9 +25,6 @@ import { ROLE_ADMIN } from '@/auth/ROLES'
import { hasElopageBuys } from '@/util/hasElopageBuys' import { hasElopageBuys } from '@/util/hasElopageBuys'
import { ServerUser } from '@entity/ServerUser' import { ServerUser } from '@entity/ServerUser'
const EMAIL_OPT_IN_RESET_PASSWORD = 2
const EMAIL_OPT_IN_REGISTER = 1
// eslint-disable-next-line @typescript-eslint/no-var-requires // eslint-disable-next-line @typescript-eslint/no-var-requires
const sodium = require('sodium-native') const sodium = require('sodium-native')
// eslint-disable-next-line @typescript-eslint/no-var-requires // eslint-disable-next-line @typescript-eslint/no-var-requires
@ -148,57 +146,47 @@ const SecretKeyCryptographyDecrypt = (encryptedMessage: Buffer, encryptionKey: B
return message return message
} }
const createEmailOptIn = async (
loginUserId: number, const newEmailOptIn = (userId: number): LoginEmailOptIn => {
queryRunner: QueryRunner, const emailOptIn = new LoginEmailOptIn()
): Promise<LoginEmailOptIn> => {
let emailOptIn = await LoginEmailOptIn.findOne({
userId: loginUserId,
emailOptInTypeId: EMAIL_OPT_IN_REGISTER,
})
if (emailOptIn) {
if (isOptInCodeValid(emailOptIn)) {
throw new Error(`email already sent less than $(CONFIG.EMAIL_CODE_VALID_TIME} minutes ago`)
}
emailOptIn.updatedAt = new Date()
emailOptIn.resendCount++
} else {
emailOptIn = new LoginEmailOptIn()
emailOptIn.verificationCode = random(64) emailOptIn.verificationCode = random(64)
emailOptIn.userId = loginUserId emailOptIn.userId = userId
emailOptIn.emailOptInTypeId = EMAIL_OPT_IN_REGISTER emailOptIn.emailOptInTypeId = OptInType.EMAIL_OPT_IN_REGISTER
}
await queryRunner.manager.save(emailOptIn).catch((error) => {
// eslint-disable-next-line no-console
console.log('Error while saving emailOptIn', error)
throw new Error('error saving email opt in')
})
return emailOptIn return emailOptIn
} }
const getOptInCode = async (loginUserId: number): Promise<LoginEmailOptIn> => { // needed by AdminResolver
let optInCode = await LoginEmailOptIn.findOne({ // checks if given code exists and can be resent
userId: loginUserId, // if optIn does not exits, it is created
emailOptInTypeId: EMAIL_OPT_IN_RESET_PASSWORD, export const checkOptInCode = async (
}) optInCode: LoginEmailOptIn | undefined,
userId: number,
// Check for `CONFIG.EMAIL_CODE_VALID_TIME` minute delay optInType: OptInType = OptInType.EMAIL_OPT_IN_REGISTER,
): Promise<LoginEmailOptIn> => {
if (optInCode) { if (optInCode) {
if (isOptInCodeValid(optInCode)) { if (!canResendOptIn(optInCode)) {
throw new Error(`email already sent less than $(CONFIG.EMAIL_CODE_VALID_TIME} minutes ago`) throw new Error(
`email already sent less than ${printTimeDuration(
CONFIG.EMAIL_CODE_REQUEST_TIME,
)} minutes ago`,
)
} }
optInCode.updatedAt = new Date() optInCode.updatedAt = new Date()
optInCode.resendCount++ optInCode.resendCount++
} else { } else {
optInCode = new LoginEmailOptIn() optInCode = newEmailOptIn(userId)
optInCode.verificationCode = random(64)
optInCode.userId = loginUserId
optInCode.emailOptInTypeId = EMAIL_OPT_IN_RESET_PASSWORD
} }
await LoginEmailOptIn.save(optInCode) optInCode.emailOptInTypeId = optInType
await LoginEmailOptIn.save(optInCode).catch(() => {
throw new Error('Unable to save optin code.')
})
return optInCode return optInCode
} }
export const activationLink = (optInCode: LoginEmailOptIn): string => {
return CONFIG.EMAIL_LINK_SETPASSWORD.replace(/{optin}/g, optInCode.verificationCode.toString())
}
@Resolver() @Resolver()
export class UserResolver { export class UserResolver {
@Authorized([RIGHTS.VERIFY_LOGIN]) @Authorized([RIGHTS.VERIFY_LOGIN])
@ -363,9 +351,12 @@ export class UserResolver {
throw new Error('error saving user') throw new Error('error saving user')
}) })
// Store EmailOptIn in DB const emailOptIn = newEmailOptIn(dbUser.id)
// TODO: this has duplicate code with sendResetPasswordEmail await queryRunner.manager.save(emailOptIn).catch((error) => {
const emailOptIn = await createEmailOptIn(dbUser.id, queryRunner) // eslint-disable-next-line no-console
console.log('Error while saving emailOptIn', error)
throw new Error('error saving email opt in')
})
const activationLink = CONFIG.EMAIL_LINK_VERIFICATION.replace( const activationLink = CONFIG.EMAIL_LINK_VERIFICATION.replace(
/{optin}/g, /{optin}/g,
@ -398,67 +389,22 @@ export class UserResolver {
return new User(dbUser) return new User(dbUser)
} }
// THis is used by the admin only - should we move it to the admin resolver?
@Authorized([RIGHTS.SEND_ACTIVATION_EMAIL])
@Mutation(() => Boolean)
async sendActivationEmail(@Arg('email') email: string): Promise<boolean> {
email = email.trim().toLowerCase()
const user = await DbUser.findOneOrFail({ email: email })
const queryRunner = getConnection().createQueryRunner()
await queryRunner.connect()
await queryRunner.startTransaction('READ UNCOMMITTED')
try {
const emailOptIn = await createEmailOptIn(user.id, queryRunner)
const activationLink = CONFIG.EMAIL_LINK_VERIFICATION.replace(
/{optin}/g,
emailOptIn.verificationCode.toString(),
)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const emailSent = await sendAccountActivationEmail({
link: activationLink,
firstName: user.firstName,
lastName: user.lastName,
email,
})
/* uncomment this, when you need the activation link on the console
// In case EMails are disabled log the activation link for the user
if (!emailSent) {
// eslint-disable-next-line no-console
console.log(`Account confirmation link: ${activationLink}`)
}
*/
await queryRunner.commitTransaction()
} catch (e) {
await queryRunner.rollbackTransaction()
throw e
} finally {
await queryRunner.release()
}
return true
}
@Authorized([RIGHTS.SEND_RESET_PASSWORD_EMAIL]) @Authorized([RIGHTS.SEND_RESET_PASSWORD_EMAIL])
@Query(() => Boolean) @Query(() => Boolean)
async sendResetPasswordEmail(@Arg('email') email: string): Promise<boolean> { async sendResetPasswordEmail(@Arg('email') email: string): Promise<boolean> {
// TODO: this has duplicate code with createUser
email = email.trim().toLowerCase() email = email.trim().toLowerCase()
const user = await DbUser.findOneOrFail({ email }) const user = await DbUser.findOneOrFail({ email })
const optInCode = await getOptInCode(user.id) // can be both types: REGISTER and RESET_PASSWORD
let optInCode = await LoginEmailOptIn.findOne({
userId: user.id,
})
const link = CONFIG.EMAIL_LINK_SETPASSWORD.replace( optInCode = await checkOptInCode(optInCode, user.id, OptInType.EMAIL_OPT_IN_RESET_PASSWORD)
/{optin}/g,
optInCode.verificationCode.toString(),
)
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
const emailSent = await sendResetPasswordEmail({ const emailSent = await sendResetPasswordEmail({
link, link: activationLink(optInCode),
firstName: user.firstName, firstName: user.firstName,
lastName: user.lastName, lastName: user.lastName,
email, email,
@ -494,8 +440,10 @@ export class UserResolver {
}) })
// Code is only valid for `CONFIG.EMAIL_CODE_VALID_TIME` minutes // Code is only valid for `CONFIG.EMAIL_CODE_VALID_TIME` minutes
if (!isOptInCodeValid(optInCode)) { if (!isOptInValid(optInCode)) {
throw new Error(`email already more than $(CONFIG.EMAIL_CODE_VALID_TIME} minutes ago`) throw new Error(
`email was sent more than ${printTimeDuration(CONFIG.EMAIL_CODE_VALID_TIME)} ago`,
)
} }
// load user // load user
@ -538,11 +486,6 @@ export class UserResolver {
throw new Error('error saving user: ' + error) throw new Error('error saving user: ' + error)
}) })
// Delete Code
await queryRunner.manager.remove(optInCode).catch((error) => {
throw new Error('error deleting code: ' + error)
})
await queryRunner.commitTransaction() await queryRunner.commitTransaction()
} catch (e) { } catch (e) {
await queryRunner.rollbackTransaction() await queryRunner.rollbackTransaction()
@ -553,7 +496,7 @@ export class UserResolver {
// Sign into Klicktipp // Sign into Klicktipp
// TODO do we always signUp the user? How to handle things with old users? // TODO do we always signUp the user? How to handle things with old users?
if (optInCode.emailOptInTypeId === EMAIL_OPT_IN_REGISTER) { if (optInCode.emailOptInTypeId === OptInType.EMAIL_OPT_IN_REGISTER) {
try { try {
await klicktippSignIn(user.email, user.language, user.firstName, user.lastName) await klicktippSignIn(user.email, user.language, user.firstName, user.lastName)
} catch { } catch {
@ -573,8 +516,10 @@ export class UserResolver {
async queryOptIn(@Arg('optIn') optIn: string): Promise<boolean> { async queryOptIn(@Arg('optIn') optIn: string): Promise<boolean> {
const optInCode = await LoginEmailOptIn.findOneOrFail({ verificationCode: optIn }) const optInCode = await LoginEmailOptIn.findOneOrFail({ verificationCode: optIn })
// Code is only valid for `CONFIG.EMAIL_CODE_VALID_TIME` minutes // Code is only valid for `CONFIG.EMAIL_CODE_VALID_TIME` minutes
if (!isOptInCodeValid(optInCode)) { if (!isOptInValid(optInCode)) {
throw new Error(`email was sent more than $(CONFIG.EMAIL_CODE_VALID_TIME} minutes ago`) throw new Error(
`email was sent more than ${printTimeDuration(CONFIG.EMAIL_CODE_VALID_TIME)} ago`,
)
} }
return true return true
} }
@ -680,7 +625,34 @@ export class UserResolver {
return hasElopageBuys(userEntity.email) return hasElopageBuys(userEntity.email)
} }
} }
function isOptInCodeValid(optInCode: LoginEmailOptIn) {
const timeElapsed = Date.now() - new Date(optInCode.updatedAt).getTime() const isTimeExpired = (optIn: LoginEmailOptIn, duration: number): boolean => {
return timeElapsed <= CONFIG.EMAIL_CODE_VALID_TIME * 60 * 1000 const timeElapsed = Date.now() - new Date(optIn.updatedAt).getTime()
// time is given in minutes
return timeElapsed <= duration * 60 * 1000
}
const isOptInValid = (optIn: LoginEmailOptIn): boolean => {
return isTimeExpired(optIn, CONFIG.EMAIL_CODE_VALID_TIME)
}
const canResendOptIn = (optIn: LoginEmailOptIn): boolean => {
return !isTimeExpired(optIn, CONFIG.EMAIL_CODE_REQUEST_TIME)
}
const getTimeDurationObject = (time: number): { hours?: number; minutes: number } => {
if (time > 60) {
return {
hours: Math.floor(time / 60),
minutes: time % 60,
}
}
return { minutes: time }
}
export const printTimeDuration = (duration: number): string => {
const time = getTimeDurationObject(duration)
const result = time.minutes > 0 ? `${time.minutes} minutes` : ''
if (time.hours) return `${time.hours} hours` + (result !== '' ? ` and ${result}` : '')
return result
} }

View File

@ -18,7 +18,7 @@ WEBHOOK_GITHUB_SECRET=secret
WEBHOOK_GITHUB_BRANCH=master WEBHOOK_GITHUB_BRANCH=master
# backend # backend
BACKEND_CONFIG_VERSION=v1.2022-03-18 BACKEND_CONFIG_VERSION=v2.2022-03-24
EMAIL=true EMAIL=true
EMAIL_USERNAME=peter@lustig.de EMAIL_USERNAME=peter@lustig.de

View File

@ -150,13 +150,18 @@ describe('ResetPassword', () => {
describe('server response with error code > 10min', () => { describe('server response with error code > 10min', () => {
beforeEach(async () => { beforeEach(async () => {
apolloMutationMock.mockRejectedValue({ message: '...Code is older than 10 minutes' }) jest.clearAllMocks()
apolloMutationMock.mockRejectedValue({
message: '...email was sent more than 23 hours and 10 minutes ago',
})
await wrapper.find('form').trigger('submit') await wrapper.find('form').trigger('submit')
await flushPromises() await flushPromises()
}) })
it('toasts an error message', () => { it('toasts an error message', () => {
expect(toastErrorSpy).toHaveBeenCalledWith('...Code is older than 10 minutes') expect(toastErrorSpy).toHaveBeenCalledWith(
'...email was sent more than 23 hours and 10 minutes ago',
)
}) })
it('router pushes to /forgot-password/resetPassword', () => { it('router pushes to /forgot-password/resetPassword', () => {

View File

@ -108,7 +108,11 @@ export default {
}) })
.catch((error) => { .catch((error) => {
this.toastError(error.message) this.toastError(error.message)
if (error.message.includes('Code is older than 10 minutes')) if (
error.message.match(
/email was sent more than ([0-9]+ hours)?( and )?([0-9]+ minutes)? ago/,
)
)
this.$router.push('/forgot-password/resetPassword') this.$router.push('/forgot-password/resetPassword')
}) })
}, },