diff --git a/backend/src/event/EVENT_ADMIN_USER_DELETE.ts b/backend/src/event/EVENT_ADMIN_USER_DELETE.ts new file mode 100644 index 000000000..bfd5be740 --- /dev/null +++ b/backend/src/event/EVENT_ADMIN_USER_DELETE.ts @@ -0,0 +1,6 @@ +import { User as DbUser } from '@entity/User' +import { Event as DbEvent } from '@entity/Event' +import { Event, EventType } from './Event' + +export const EVENT_ADMIN_USER_DELETE = async (user: DbUser, moderator: DbUser): Promise => + Event(EventType.ADMIN_USER_DELETE, user, moderator).save() diff --git a/backend/src/event/EVENT_ADMIN_USER_ROLE_SET.ts b/backend/src/event/EVENT_ADMIN_USER_ROLE_SET.ts new file mode 100644 index 000000000..3be825ad4 --- /dev/null +++ b/backend/src/event/EVENT_ADMIN_USER_ROLE_SET.ts @@ -0,0 +1,8 @@ +import { User as DbUser } from '@entity/User' +import { Event as DbEvent } from '@entity/Event' +import { Event, EventType } from './Event' + +export const EVENT_ADMIN_USER_ROLE_SET = async ( + user: DbUser, + moderator: DbUser, +): Promise => Event(EventType.ADMIN_USER_ROLE_SET, user, moderator).save() diff --git a/backend/src/event/EVENT_ADMIN_USER_UNDELETE.ts b/backend/src/event/EVENT_ADMIN_USER_UNDELETE.ts new file mode 100644 index 000000000..eb861dbf1 --- /dev/null +++ b/backend/src/event/EVENT_ADMIN_USER_UNDELETE.ts @@ -0,0 +1,8 @@ +import { User as DbUser } from '@entity/User' +import { Event as DbEvent } from '@entity/Event' +import { Event, EventType } from './Event' + +export const EVENT_ADMIN_USER_UNDELETE = async ( + user: DbUser, + moderator: DbUser, +): Promise => Event(EventType.ADMIN_USER_UNDELETE, user, moderator).save() diff --git a/backend/src/event/EVENT_EMAIL_FORGOT_PASSWORD.ts b/backend/src/event/EVENT_EMAIL_FORGOT_PASSWORD.ts new file mode 100644 index 000000000..f7e328369 --- /dev/null +++ b/backend/src/event/EVENT_EMAIL_FORGOT_PASSWORD.ts @@ -0,0 +1,6 @@ +import { User as DbUser } from '@entity/User' +import { Event as DbEvent } from '@entity/Event' +import { Event, EventType } from './Event' + +export const EVENT_EMAIL_FORGOT_PASSWORD = async (user: DbUser): Promise => + Event(EventType.EMAIL_FORGOT_PASSWORD, user, { id: 0 } as DbUser).save() diff --git a/backend/src/event/EVENT_LOGOUT.ts b/backend/src/event/EVENT_LOGOUT.ts new file mode 100644 index 000000000..1e423359c --- /dev/null +++ b/backend/src/event/EVENT_LOGOUT.ts @@ -0,0 +1,6 @@ +import { User as DbUser } from '@entity/User' +import { Event as DbEvent } from '@entity/Event' +import { Event, EventType } from './Event' + +export const EVENT_LOGOUT = async (user: DbUser): Promise => + Event(EventType.LOGOUT, user, user).save() diff --git a/backend/src/event/EVENT_USER_INFO_UPDATE.ts b/backend/src/event/EVENT_USER_INFO_UPDATE.ts new file mode 100644 index 000000000..681ecd473 --- /dev/null +++ b/backend/src/event/EVENT_USER_INFO_UPDATE.ts @@ -0,0 +1,6 @@ +import { User as DbUser } from '@entity/User' +import { Event as DbEvent } from '@entity/Event' +import { Event, EventType } from './Event' + +export const EVENT_USER_INFO_UPDATE = async (user: DbUser): Promise => + Event(EventType.USER_INFO_UPDATE, user, user).save() diff --git a/backend/src/event/Event.ts b/backend/src/event/Event.ts index cdb05748c..901bc33ff 100644 --- a/backend/src/event/Event.ts +++ b/backend/src/event/Event.ts @@ -47,12 +47,17 @@ export { EVENT_ADMIN_CONTRIBUTION_LINK_DELETE } from './EVENT_ADMIN_CONTRIBUTION export { EVENT_ADMIN_CONTRIBUTION_LINK_UPDATE } from './EVENT_ADMIN_CONTRIBUTION_LINK_UPDATE' export { EVENT_ADMIN_CONTRIBUTION_MESSAGE_CREATE } from './EVENT_ADMIN_CONTRIBUTION_MESSAGE_CREATE' export { EVENT_ADMIN_SEND_CONFIRMATION_EMAIL } from './EVENT_ADMIN_SEND_CONFIRMATION_EMAIL' +export { EVENT_ADMIN_USER_DELETE } from './EVENT_ADMIN_USER_DELETE' +export { EVENT_ADMIN_USER_UNDELETE } from './EVENT_ADMIN_USER_UNDELETE' +export { EVENT_ADMIN_USER_ROLE_SET } from './EVENT_ADMIN_USER_ROLE_SET' export { EVENT_CONTRIBUTION_CREATE } from './EVENT_CONTRIBUTION_CREATE' export { EVENT_CONTRIBUTION_DELETE } from './EVENT_CONTRIBUTION_DELETE' export { EVENT_CONTRIBUTION_UPDATE } from './EVENT_CONTRIBUTION_UPDATE' export { EVENT_CONTRIBUTION_MESSAGE_CREATE } from './EVENT_CONTRIBUTION_MESSAGE_CREATE' export { EVENT_CONTRIBUTION_LINK_REDEEM } from './EVENT_CONTRIBUTION_LINK_REDEEM' +export { EVENT_EMAIL_FORGOT_PASSWORD } from './EVENT_EMAIL_FORGOT_PASSWORD' export { EVENT_LOGIN } from './EVENT_LOGIN' +export { EVENT_LOGOUT } from './EVENT_LOGOUT' export { EVENT_REGISTER } from './EVENT_REGISTER' export { EVENT_SEND_ACCOUNT_MULTIREGISTRATION_EMAIL } from './EVENT_SEND_ACCOUNT_MULTIREGISTRATION_EMAIL' export { EVENT_SEND_CONFIRMATION_EMAIL } from './EVENT_SEND_CONFIRMATION_EMAIL' @@ -61,3 +66,4 @@ export { EVENT_TRANSACTION_RECEIVE } from './EVENT_TRANSACTION_RECEIVE' export { EVENT_TRANSACTION_LINK_CREATE } from './EVENT_TRANSACTION_LINK_CREATE' export { EVENT_TRANSACTION_LINK_DELETE } from './EVENT_TRANSACTION_LINK_DELETE' export { EVENT_TRANSACTION_LINK_REDEEM } from './EVENT_TRANSACTION_LINK_REDEEM' +export { EVENT_USER_INFO_UPDATE } from './EVENT_USER_INFO_UPDATE' diff --git a/backend/src/event/EventType.ts b/backend/src/event/EventType.ts index 47056f05e..58d03c84b 100644 --- a/backend/src/event/EventType.ts +++ b/backend/src/event/EventType.ts @@ -11,12 +11,17 @@ export enum EventType { ADMIN_CONTRIBUTION_LINK_UPDATE = 'ADMIN_CONTRIBUTION_LINK_UPDATE', ADMIN_CONTRIBUTION_MESSAGE_CREATE = 'ADMIN_CONTRIBUTION_MESSAGE_CREATE', ADMIN_SEND_CONFIRMATION_EMAIL = 'ADMIN_SEND_CONFIRMATION_EMAIL', + ADMIN_USER_DELETE = 'ADMIN_USER_DELETE', + ADMIN_USER_UNDELETE = 'ADMIN_USER_UNDELETE', + ADMIN_USER_ROLE_SET = 'ADMIN_USER_ROLE_SET', CONTRIBUTION_CREATE = 'CONTRIBUTION_CREATE', CONTRIBUTION_DELETE = 'CONTRIBUTION_DELETE', CONTRIBUTION_UPDATE = 'CONTRIBUTION_UPDATE', CONTRIBUTION_MESSAGE_CREATE = 'CONTRIBUTION_MESSAGE_CREATE', CONTRIBUTION_LINK_REDEEM = 'CONTRIBUTION_LINK_REDEEM', + EMAIL_FORGOT_PASSWORD = 'EMAIL_FORGOT_PASSWORD', LOGIN = 'LOGIN', + LOGOUT = 'LOGOUT', REGISTER = 'REGISTER', REDEEM_REGISTER = 'REDEEM_REGISTER', SEND_ACCOUNT_MULTIREGISTRATION_EMAIL = 'SEND_ACCOUNT_MULTIREGISTRATION_EMAIL', @@ -26,6 +31,7 @@ export enum EventType { TRANSACTION_LINK_CREATE = 'TRANSACTION_LINK_CREATE', TRANSACTION_LINK_DELETE = 'TRANSACTION_LINK_DELETE', TRANSACTION_LINK_REDEEM = 'TRANSACTION_LINK_REDEEM', + USER_INFO_UPDATE = 'USER_INFO_UPDATE', // VISIT_GRADIDO = 'VISIT_GRADIDO', // VERIFY_REDEEM = 'VERIFY_REDEEM', // INACTIVE_ACCOUNT = 'INACTIVE_ACCOUNT', diff --git a/backend/src/graphql/resolver/UserResolver.test.ts b/backend/src/graphql/resolver/UserResolver.test.ts index 11b0169ef..6dd659532 100644 --- a/backend/src/graphql/resolver/UserResolver.test.ts +++ b/backend/src/graphql/resolver/UserResolver.test.ts @@ -868,6 +868,20 @@ describe('UserResolver', () => { }), ) }) + + it('stores the LOGOUT event in the database', async () => { + const userConatct = await UserContact.findOneOrFail( + { email: 'bibi@bloxberg.de' }, + { relations: ['user'] }, + ) + await expect(DbEvent.find()).resolves.toContainEqual( + expect.objectContaining({ + type: EventType.LOGOUT, + affectedUserId: userConatct.user.id, + actingUserId: userConatct.user.id, + }), + ) + }) }) }) @@ -1023,6 +1037,20 @@ describe('UserResolver', () => { }), }) }) + + it('stores the EMAIL_FORGOT_PASSWORD event in the database', async () => { + const userConatct = await UserContact.findOneOrFail( + { email: 'bibi@bloxberg.de' }, + { relations: ['user'] }, + ) + await expect(DbEvent.find()).resolves.toContainEqual( + expect.objectContaining({ + type: EventType.EMAIL_FORGOT_PASSWORD, + affectedUserId: userConatct.user.id, + actingUserId: 0, + }), + ) + }) }) describe('request reset password again', () => { @@ -1147,6 +1175,20 @@ describe('UserResolver', () => { }), ) }) + + it('stores the USER_INFO_UPDATE event in the database', async () => { + const userConatct = await UserContact.findOneOrFail( + { email: 'bibi@bloxberg.de' }, + { relations: ['user'] }, + ) + await expect(DbEvent.find()).resolves.toContainEqual( + expect.objectContaining({ + type: EventType.USER_INFO_UPDATE, + affectedUserId: userConatct.user.id, + actingUserId: userConatct.user.id, + }), + ) + }) }) describe('language is not valid', () => { @@ -1517,6 +1559,24 @@ describe('UserResolver', () => { ) expect(new Date(result.data.setUserRole)).toEqual(expect.any(Date)) }) + + it('stores the ADMIN_USER_ROLE_SET event in the database', async () => { + const userConatct = await UserContact.findOneOrFail( + { email: 'bibi@bloxberg.de' }, + { relations: ['user'] }, + ) + const adminConatct = await UserContact.findOneOrFail( + { email: 'peter@lustig.de' }, + { relations: ['user'] }, + ) + await expect(DbEvent.find()).resolves.toContainEqual( + expect.objectContaining({ + type: EventType.ADMIN_USER_ROLE_SET, + affectedUserId: userConatct.user.id, + actingUserId: adminConatct.user.id, + }), + ) + }) }) describe('to usual user', () => { @@ -1702,6 +1762,24 @@ describe('UserResolver', () => { expect(new Date(result.data.deleteUser)).toEqual(expect.any(Date)) }) + it('stores the ADMIN_USER_DELETE event in the database', async () => { + const userConatct = await UserContact.findOneOrFail( + { email: 'bibi@bloxberg.de' }, + { relations: ['user'], withDeleted: true }, + ) + const adminConatct = await UserContact.findOneOrFail( + { email: 'peter@lustig.de' }, + { relations: ['user'] }, + ) + await expect(DbEvent.find()).resolves.toContainEqual( + expect.objectContaining({ + type: EventType.ADMIN_USER_DELETE, + affectedUserId: userConatct.user.id, + actingUserId: adminConatct.user.id, + }), + ) + }) + describe('delete deleted user', () => { it('throws an error', async () => { jest.clearAllMocks() @@ -1977,6 +2055,24 @@ describe('UserResolver', () => { }), ) }) + + it('stores the ADMIN_USER_UNDELETE event in the database', async () => { + const userConatct = await UserContact.findOneOrFail( + { email: 'bibi@bloxberg.de' }, + { relations: ['user'] }, + ) + const adminConatct = await UserContact.findOneOrFail( + { email: 'peter@lustig.de' }, + { relations: ['user'] }, + ) + await expect(DbEvent.find()).resolves.toContainEqual( + expect.objectContaining({ + type: EventType.ADMIN_USER_UNDELETE, + affectedUserId: userConatct.user.id, + actingUserId: adminConatct.user.id, + }), + ) + }) }) }) }) diff --git a/backend/src/graphql/resolver/UserResolver.ts b/backend/src/graphql/resolver/UserResolver.ts index 46c93fcc3..f792a4166 100644 --- a/backend/src/graphql/resolver/UserResolver.ts +++ b/backend/src/graphql/resolver/UserResolver.ts @@ -61,6 +61,12 @@ import { EVENT_REGISTER, EVENT_ACTIVATE_ACCOUNT, EVENT_ADMIN_SEND_CONFIRMATION_EMAIL, + EVENT_LOGOUT, + EVENT_EMAIL_FORGOT_PASSWORD, + EVENT_USER_INFO_UPDATE, + EVENT_ADMIN_USER_ROLE_SET, + EVENT_ADMIN_USER_DELETE, + EVENT_ADMIN_USER_UNDELETE, } from '@/event/Event' import { getUserCreations } from './util/creations' import { isValidPassword } from '@/password/EncryptorUtils' @@ -189,15 +195,9 @@ export class UserResolver { @Authorized([RIGHTS.LOGOUT]) @Mutation(() => Boolean) - logout(): boolean { - // TODO: Event still missing here!! - // TODO: We dont need this anymore, but might need this in the future in oder to invalidate a valid JWT-Token. - // Furthermore this hook can be useful for tracking user behaviour (did he logout or not? Warn him if he didn't on next login) - // The functionality is fully client side - the client just needs to delete his token with the current implementation. - // we could try to force this by sending `token: null` or `token: ''` with this call. But since it bares no real security - // we should just return true for now. - logger.info('Logout...') - // remove user.pubKey from logger-context to ensure a correct filter on log-messages belonging to the same user + async logout(@Ctx() context: Context): Promise { + await EVENT_LOGOUT(getUser(context)) + // remove user from logger context logger.addContext('user', 'unknown') return true } @@ -411,6 +411,7 @@ export class UserResolver { ) } logger.info(`forgotPassword(${email}) successful...`) + await EVENT_EMAIL_FORGOT_PASSWORD(user) return true } @@ -473,8 +474,6 @@ export class UserResolver { await queryRunner.commitTransaction() logger.info('User and UserContact data written successfully...') - - await EVENT_ACTIVATE_ACCOUNT(user) } catch (e) { await queryRunner.rollbackTransaction() throw new LogError('Error on writing User and User Contact data', e) @@ -492,13 +491,9 @@ export class UserResolver { ) } catch (e) { logger.error('Error subscribing to klicktipp', e) - // TODO is this a problem? - // eslint-disable-next-line no-console - /* uncomment this, when you need the activation link on the console - console.log('Could not subscribe to klicktipp') - */ } } + await EVENT_ACTIVATE_ACCOUNT(user) return true } @@ -535,21 +530,21 @@ export class UserResolver { @Ctx() context: Context, ): Promise { logger.info(`updateUserInfos(${firstName}, ${lastName}, ${language}, ***, ***)...`) - const userEntity = getUser(context) + const user = getUser(context) if (firstName) { - userEntity.firstName = firstName + user.firstName = firstName } if (lastName) { - userEntity.lastName = lastName + user.lastName = lastName } if (language) { if (!isLanguage(language)) { throw new LogError('Given language is not a valid language', language) } - userEntity.language = language + user.language = language i18n.setLocale(language) } @@ -561,22 +556,22 @@ export class UserResolver { ) } - if (!verifyPassword(userEntity, password)) { + if (!verifyPassword(user, password)) { throw new LogError(`Old password is invalid`) } // Save new password hash and newly encrypted private key - userEntity.passwordEncryptionType = PasswordEncryptionType.GRADIDO_ID - userEntity.password = encryptPassword(userEntity, passwordNew) + user.passwordEncryptionType = PasswordEncryptionType.GRADIDO_ID + user.password = encryptPassword(user, passwordNew) } // Save hideAmountGDD value if (hideAmountGDD !== undefined) { - userEntity.hideAmountGDD = hideAmountGDD + user.hideAmountGDD = hideAmountGDD } // Save hideAmountGDT value if (hideAmountGDT !== undefined) { - userEntity.hideAmountGDT = hideAmountGDT + user.hideAmountGDT = hideAmountGDT } const queryRunner = getConnection().createQueryRunner() @@ -584,7 +579,7 @@ export class UserResolver { await queryRunner.startTransaction('REPEATABLE READ') try { - await queryRunner.manager.save(userEntity).catch((error) => { + await queryRunner.manager.save(user).catch((error) => { throw new LogError('Error saving user', error) }) @@ -597,6 +592,8 @@ export class UserResolver { await queryRunner.release() } logger.info('updateUserInfos() successfully finished...') + await EVENT_USER_INFO_UPDATE(user) + return true } @@ -722,8 +719,8 @@ export class UserResolver { throw new LogError('Could not find user with given ID', userId) } // administrator user changes own role? - const moderatorUser = getUser(context) - if (moderatorUser.id === userId) { + const moderator = getUser(context) + if (moderator.id === userId) { throw new LogError('Administrator can not change his own role') } // change isAdmin @@ -744,6 +741,7 @@ export class UserResolver { break } await user.save() + await EVENT_ADMIN_USER_ROLE_SET(user, moderator) const newUser = await DbUser.findOne({ id: userId }) return newUser ? newUser.isAdmin : null } @@ -760,19 +758,23 @@ export class UserResolver { throw new LogError('Could not find user with given ID', userId) } // moderator user disabled own account? - const moderatorUser = getUser(context) - if (moderatorUser.id === userId) { + const moderator = getUser(context) + if (moderator.id === userId) { throw new LogError('Moderator can not delete his own account') } // soft-delete user await user.softRemove() + await EVENT_ADMIN_USER_DELETE(user, moderator) const newUser = await DbUser.findOne({ id: userId }, { withDeleted: true }) return newUser ? newUser.deletedAt : null } @Authorized([RIGHTS.UNDELETE_USER]) @Mutation(() => Date, { nullable: true }) - async unDeleteUser(@Arg('userId', () => Int) userId: number): Promise { + async unDeleteUser( + @Arg('userId', () => Int) userId: number, + @Ctx() context: Context, + ): Promise { const user = await DbUser.findOne({ id: userId }, { withDeleted: true }) if (!user) { throw new LogError('Could not find user with given ID', userId) @@ -781,6 +783,7 @@ export class UserResolver { throw new LogError('User is not deleted') } await user.recover() + await EVENT_ADMIN_USER_UNDELETE(user, getUser(context)) return null } @@ -814,9 +817,8 @@ export class UserResolver { // In case EMails are disabled log the activation link for the user if (!emailSent) { logger.info(`Account confirmation link: ${activationLink}`) - } else { - await EVENT_ADMIN_SEND_CONFIRMATION_EMAIL(user, getUser(context)) } + await EVENT_ADMIN_SEND_CONFIRMATION_EMAIL(user, getUser(context)) return true }