diff --git a/backend/package.json b/backend/package.json index 50f26351d..bb4ab3e51 100644 --- a/backend/package.json +++ b/backend/package.json @@ -19,6 +19,7 @@ "dependencies": { "@types/jest": "^27.0.2", "@types/lodash.clonedeep": "^4.5.6", + "@types/uuid": "^8.3.4", "apollo-server-express": "^2.25.2", "apollo-server-testing": "^2.25.2", "axios": "^0.21.1", diff --git a/backend/src/graphql/enum/UserContactType.ts b/backend/src/graphql/enum/UserContactType.ts new file mode 100644 index 000000000..93c83830c --- /dev/null +++ b/backend/src/graphql/enum/UserContactType.ts @@ -0,0 +1,11 @@ +import { registerEnumType } from 'type-graphql' + +export enum UserContactType { + USER_CONTACT_EMAIL = 'EMAIL', + USER_CONTACT_PHONE = 'PHONE', +} + +registerEnumType(UserContactType, { + name: 'UserContactType', // this one is mandatory + description: 'Type of the user contact', // this one is optional +}) diff --git a/backend/src/graphql/model/User.ts b/backend/src/graphql/model/User.ts index 0642be630..3ea4e2c05 100644 --- a/backend/src/graphql/model/User.ts +++ b/backend/src/graphql/model/User.ts @@ -8,12 +8,12 @@ import { FULL_CREATION_AVAILABLE } from '../resolver/const/const' export class User { constructor(user: dbUser, creation: Decimal[] = FULL_CREATION_AVAILABLE) { this.id = user.id - this.email = user.email + this.email = user.emailContact.email this.firstName = user.firstName this.lastName = user.lastName this.deletedAt = user.deletedAt this.createdAt = user.createdAt - this.emailChecked = user.emailChecked + this.emailChecked = user.emailContact.emailChecked this.language = user.language this.publisherId = user.publisherId this.isAdmin = user.isAdmin diff --git a/backend/src/graphql/resolver/UserResolver.ts b/backend/src/graphql/resolver/UserResolver.ts index f61414e42..687cad68b 100644 --- a/backend/src/graphql/resolver/UserResolver.ts +++ b/backend/src/graphql/resolver/UserResolver.ts @@ -1,12 +1,12 @@ import fs from 'fs' import { backendLogger as logger } from '@/server/logger' - import { Context, getUser } from '@/server/context' import { Resolver, Query, Args, Arg, Authorized, Ctx, UseMiddleware, Mutation } from 'type-graphql' import { getConnection } from '@dbTools/typeorm' import CONFIG from '@/config' import { User } from '@model/User' import { User as DbUser } from '@entity/User' +import { UserContact as DbUserContact } from '@entity/UserContact' import { communityDbUser } from '@/util/communityUser' import { TransactionLink as dbTransactionLink } from '@entity/TransactionLink' import { ContributionLink as dbContributionLink } from '@entity/ContributionLink' @@ -32,6 +32,7 @@ import { EventSendConfirmationEmail, } from '@/event/Event' import { getUserCreation } from './util/creations' +import { UserContactType } from '../enum/UserContactType' // eslint-disable-next-line @typescript-eslint/no-var-requires const sodium = require('sodium-native') @@ -172,6 +173,19 @@ const SecretKeyCryptographyDecrypt = (encryptedMessage: Buffer, encryptionKey: B return message } +const newEmailContact = (email: string, userId: number): DbUserContact => { + logger.trace(`newEmailContact...`) + const emailContact = new DbUserContact() + emailContact.email = email + emailContact.userId = userId + emailContact.type = UserContactType.USER_CONTACT_EMAIL + emailContact.emailChecked = false + emailContact.emailOptInTypeId = OptInType.EMAIL_OPT_IN_REGISTER + emailContact.emailVerificationCode = random(64) + logger.debug(`newEmailContact...successful: ${emailContact}`) + return emailContact +} + const newEmailOptIn = (userId: number): LoginEmailOptIn => { logger.trace('newEmailOptIn...') const emailOptIn = new LoginEmailOptIn() @@ -182,6 +196,7 @@ const newEmailOptIn = (userId: number): LoginEmailOptIn => { return emailOptIn } +/* // needed by AdminResolver // checks if given code exists and can be resent // if optIn does not exits, it is created @@ -218,6 +233,36 @@ export const checkOptInCode = async ( logger.debug(`checkOptInCode...successful: ${optInCode} for userid=${userId}`) return optInCode } +*/ +export const checkEmailVerificationCode = async ( + emailContact: DbUserContact, + optInType: OptInType = OptInType.EMAIL_OPT_IN_REGISTER +): Promise => { + logger.info(`checkEmailVerificationCode... ${emailContact}`) + if (emailContact.updatedAt) { + if (!canEmailResend(emailContact.updatedAt)) { + logger.error(`email already sent less than ${printTimeDuration(CONFIG.EMAIL_CODE_REQUEST_TIME)} minutes ago`) + throw new Error( + `email already sent less than ${printTimeDuration( + CONFIG.EMAIL_CODE_REQUEST_TIME, + )} minutes ago`, + ) + } + emailContact.updatedAt = new Date() + emailContact.emailResendCount++ + } else { + logger.trace('create new EmailVerificationCode for userId=' + emailContact.userId) + emailContact.emailChecked = false + emailContact.emailVerificationCode = random(64) + } + emailContact.emailOptInTypeId = optInType + await DbUserContact.save(emailContact).catch(() => { + logger.error('Unable to save email verification code= ' + emailContact) + throw new Error('Unable to save email verification code.') + }) + logger.debug(`checkEmailVerificationCode...successful: ${emailContact}`) + return emailContact +} export const activationLink = (optInCode: LoginEmailOptIn): string => { logger.debug(`activationLink(${LoginEmailOptIn})...`) @@ -251,15 +296,31 @@ export class UserResolver { ): Promise { logger.info(`login with ${email}, ***, ${publisherId} ...`) email = email.trim().toLowerCase() + const dbUser = await findUserByEmail(email) + /* + const dbUserContact = await DbUserContact.findOneOrFail({ email }, { withDeleted: true }).catch( + () => { + logger.error(`UserContact with email=${email} does not exists`) + throw new Error('No user with this credentials') + }, + ) + const userId = dbUserContact.userId + const dbUser = await DbUser.findOneOrFail(userId).catch(() => { + logger.error(`User with emeilContact=${email} connected per userId=${userId} does not exist`) + throw new Error('No user with this credentials') + }) + */ + /* const dbUser = await DbUser.findOneOrFail({ email }, { withDeleted: true }).catch(() => { logger.error(`User with email=${email} does not exists`) throw new Error('No user with this credentials') }) + */ if (dbUser.deletedAt) { logger.error('The User was permanently deleted in database.') throw new Error('This user was permanently deleted. Contact support for questions.') } - if (!dbUser.emailChecked) { + if (!dbUser.emailContact.emailChecked) { logger.error('The Users email is not validate yet.') throw new Error('User email not validated') } @@ -339,11 +400,13 @@ export class UserResolver { // Validate email unique email = email.trim().toLowerCase() - // TODO we cannot use repository.count(), since it does not allow to specify if you want to include the soft deletes - const userFound = await DbUser.findOne({ email }, { withDeleted: true }) - logger.info(`DbUser.findOne(email=${email}) = ${userFound}`) + const foundUser = await findUserByEmail(email) - if (userFound) { + // TODO we cannot use repository.count(), since it does not allow to specify if you want to include the soft deletes + // const userFound = await DbUser.findOne({ email }, { withDeleted: true }) + logger.info(`DbUser.findOne(email=${email}) = ${foundUser}`) + + if (foundUser) { logger.info('User already exists with this email=' + email) // TODO: this is unsecure, but the current implementation of the login server. This way it can be queried if the user with given EMail is existent. @@ -382,8 +445,11 @@ export class UserResolver { const eventRegister = new EventRegister() const eventRedeemRegister = new EventRedeemRegister() const eventSendConfirmEmail = new EventSendConfirmationEmail() + // const dbEmailContact = new DbUserContact() + // dbEmailContact.email = email + const dbUser = new DbUser() - dbUser.email = email + // dbUser.emailContact = dbEmailContact dbUser.firstName = firstName dbUser.lastName = lastName // dbUser.emailHash = emailHash @@ -426,16 +492,29 @@ export class UserResolver { logger.error('Error while saving dbUser', error) throw new Error('error saving user') }) + const emailContact = newEmailContact(email, dbUser.id) + await queryRunner.manager.save(emailContact).catch((error) => { + logger.error('Error while saving emailContact', error) + throw new Error('error saving email user contact') + }) + dbUser.emailContact = emailContact + await queryRunner.manager.save(dbUser).catch((error) => { + logger.error('Error while updating dbUser', error) + throw new Error('error updating user') + }) + + /* const emailOptIn = newEmailOptIn(dbUser.id) await queryRunner.manager.save(emailOptIn).catch((error) => { logger.error('Error while saving emailOptIn', error) throw new Error('error saving email opt in') }) + */ const activationLink = CONFIG.EMAIL_LINK_VERIFICATION.replace( /{optin}/g, - emailOptIn.verificationCode.toString(), + emailContact.emailVerificationCode.toString(), ).replace(/{code}/g, redeemCode ? '/' + redeemCode : '') // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -482,16 +561,19 @@ export class UserResolver { async forgotPassword(@Arg('email') email: string): Promise { logger.info(`forgotPassword(${email})...`) email = email.trim().toLowerCase() - const user = await DbUser.findOne({ email }) + const user = await findUserByEmail(email) + // const user = await DbUser.findOne({ email }) if (!user) { logger.warn(`no user found with ${email}`) return true } // can be both types: REGISTER and RESET_PASSWORD - let optInCode = await LoginEmailOptIn.findOne({ - userId: user.id, - }) + // let optInCode = await LoginEmailOptIn.findOne({ + // userId: user.id, + // }) + let optInCode = user.emailContact.emailVerificationCode + optInCode = await checkEmailVerificationCode(user.emailContact, OptInType.EMAIL_OPT_IN_RESET_PASSWORD) optInCode = await checkOptInCode(optInCode, user.id, OptInType.EMAIL_OPT_IN_RESET_PASSWORD) logger.info(`optInCode for ${email}=${optInCode}`) @@ -727,25 +809,55 @@ export class UserResolver { logger.info('missing context.user for EloPage-check') return false } - const elopageBuys = hasElopageBuys(userEntity.email) + const elopageBuys = hasElopageBuys(userEntity.emailContact.email) logger.debug(`has ElopageBuys = ${elopageBuys}`) return elopageBuys } } +async function findUserByEmail(email: string): Promise { + const dbUserContact = await DbUserContact.findOneOrFail(email, { withDeleted: true }).catch( + () => { + logger.error(`UserContact with email=${email} does not exists`) + throw new Error('No user with this credentials') + }, + ) + const userId = dbUserContact.userId + const dbUser = await DbUser.findOneOrFail(userId).catch(() => { + logger.error(`User with emeilContact=${email} connected per userId=${userId} does not exist`) + throw new Error('No user with this credentials') + }) + return dbUser +} + +/* const isTimeExpired = (optIn: LoginEmailOptIn, duration: number): boolean => { const timeElapsed = Date.now() - new Date(optIn.updatedAt).getTime() // time is given in minutes return timeElapsed <= duration * 60 * 1000 } - +*/ +const isTimeExpired = (updatedAt: Date, duration: number): boolean => { + const timeElapsed = Date.now() - new Date(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 isEmailVerificationCodeValid = (updatedAt: Date): boolean => { + return isTimeExpired(updatedAt, CONFIG.EMAIL_CODE_VALID_TIME) +} +/* const canResendOptIn = (optIn: LoginEmailOptIn): boolean => { return !isTimeExpired(optIn, CONFIG.EMAIL_CODE_REQUEST_TIME) } +*/ +const canEmailResend = (updatedAt: Date): boolean => { + return !isTimeExpired(updatedAt, CONFIG.EMAIL_CODE_REQUEST_TIME) +} const getTimeDurationObject = (time: number): { hours?: number; minutes: number } => { if (time > 60) { @@ -763,3 +875,4 @@ export const printTimeDuration = (duration: number): string => { if (time.hours) return `${time.hours} hours` + (result !== '' ? ` and ${result}` : '') return result } + diff --git a/backend/src/util/communityUser.ts b/backend/src/util/communityUser.ts index c90e786c6..65dee6728 100644 --- a/backend/src/util/communityUser.ts +++ b/backend/src/util/communityUser.ts @@ -2,13 +2,14 @@ import { SaveOptions, RemoveOptions } from '@dbTools/typeorm' import { User as dbUser } from '@entity/User' +// import { UserContact as EmailContact } from '@entity/UserContact' import { User } from '@model/User' const communityDbUser: dbUser = { id: -1, gradidoID: '11111111-2222-3333-4444-55555555', alias: '', - email: 'support@gradido.net', + // email: 'support@gradido.net', firstName: 'Gradido', lastName: 'Akademie', pubKey: Buffer.from(''), @@ -17,7 +18,7 @@ const communityDbUser: dbUser = { password: BigInt(0), // emailHash: Buffer.from(''), createdAt: new Date(), - emailChecked: false, + // emailChecked: false, language: '', isAdmin: null, publisherId: 0, diff --git a/backend/yarn.lock b/backend/yarn.lock index 53a53cb9b..731404d6d 100644 --- a/backend/yarn.lock +++ b/backend/yarn.lock @@ -1000,6 +1000,11 @@ resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.1.tgz#20f18294f797f2209b5f65c8e3b5c8e8261d127c" integrity sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw== +"@types/uuid@^8.3.4": + version "8.3.4" + resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.3.4.tgz#bd86a43617df0594787d38b735f55c805becf1bc" + integrity sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw== + "@types/validator@^13.1.3": version "13.6.3" resolved "https://registry.yarnpkg.com/@types/validator/-/validator-13.6.3.tgz#31ca2e997bf13a0fffca30a25747d5b9f7dbb7de" diff --git a/database/entity/0045-adapt_users_table_for_gradidoid/User.ts b/database/entity/0045-adapt_users_table_for_gradidoid/User.ts index 1e7e9d8d8..69a085a87 100644 --- a/database/entity/0045-adapt_users_table_for_gradidoid/User.ts +++ b/database/entity/0045-adapt_users_table_for_gradidoid/User.ts @@ -6,8 +6,10 @@ import { DeleteDateColumn, OneToMany, JoinColumn, + OneToOne, } from 'typeorm' import { Contribution } from '../Contribution' +import { UserContact } from '../UserContact' @Entity('users', { engine: 'InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' }) export class User extends BaseEntity { @@ -37,11 +39,18 @@ export class User extends BaseEntity { @Column({ name: 'privkey', type: 'binary', length: 80, default: null, nullable: true }) privKey: Buffer + /* @Column({ length: 255, unique: true, nullable: false, collation: 'utf8mb4_unicode_ci' }) email: string + */ + @OneToOne(() => UserContact, { primary: true, cascade: true }) + @JoinColumn({ name: 'email_id' }) + emailContact: UserContact + /* @Column({ name: 'email_id', type: 'int', unsigned: true, nullable: true, default: null }) emailId?: number | null + */ @Column({ name: 'first_name', @@ -69,9 +78,10 @@ export class User extends BaseEntity { @Column({ name: 'created', default: () => 'CURRENT_TIMESTAMP', nullable: false }) createdAt: Date - + /* @Column({ name: 'email_checked', type: 'bool', nullable: false, default: false }) emailChecked: boolean + */ @Column({ length: 4, default: 'de', collation: 'utf8mb4_unicode_ci', nullable: false }) language: string @@ -106,4 +116,8 @@ export class User extends BaseEntity { @OneToMany(() => Contribution, (contribution) => contribution.user) @JoinColumn({ name: 'user_id' }) contributions?: Contribution[] + + @OneToMany(() => UserContact, (usercontact) => usercontact.userId) + @JoinColumn({ name: 'user_id' }) + usercontacts?: UserContact[] } diff --git a/database/entity/0045-adapt_users_table_for_gradidoid/UserContact.ts b/database/entity/0045-adapt_users_table_for_gradidoid/UserContact.ts index fee0afeda..7c2dff3db 100644 --- a/database/entity/0045-adapt_users_table_for_gradidoid/UserContact.ts +++ b/database/entity/0045-adapt_users_table_for_gradidoid/UserContact.ts @@ -20,8 +20,17 @@ export class UserContact extends BaseEntity { @Column({ length: 255, unique: true, nullable: false, collation: 'utf8mb4_unicode_ci' }) email: string - @Column({ name: 'email_hash', type: 'binary', length: 32, default: null, nullable: true }) - emailHash: Buffer + @Column({ name: 'email_verification_code', type: 'bigint', unsigned: true, unique: true }) + emailVerificationCode: BigInt + + @Column({ name: 'email_opt_in_type_id' }) + emailOptInTypeId: number + + @Column({ name: 'email_resend_count' }) + emailResendCount: number + + // @Column({ name: 'email_hash', type: 'binary', length: 32, default: null, nullable: true }) + // emailHash: Buffer @Column({ name: 'email_checked', type: 'bool', nullable: false, default: false }) emailChecked: boolean