diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0739729b5..e9762b4bb 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -528,7 +528,7 @@ jobs: report_name: Coverage Backend type: lcov result_path: ./backend/coverage/lcov.info - min_coverage: 53 + min_coverage: 54 token: ${{ github.token }} ########################################################################## diff --git a/backend/src/graphql/directive/isAuthorized.ts b/backend/src/graphql/directive/isAuthorized.ts index aa407c95f..159a1614c 100644 --- a/backend/src/graphql/directive/isAuthorized.ts +++ b/backend/src/graphql/directive/isAuthorized.ts @@ -13,35 +13,33 @@ import { ServerUser } from '@entity/ServerUser' const isAuthorized: AuthChecker = async ({ context }, rights) => { context.role = ROLE_UNAUTHORIZED // unauthorized user + // is rights an inalienable right? + if ((rights).reduce((acc, right) => acc && INALIENABLE_RIGHTS.includes(right), true)) + return true + // Do we have a token? - if (context.token) { - // Decode the token - const decoded = decode(context.token) - if (!decoded) { - // Are all rights requested public? - const isInalienable = (rights).reduce( - (acc, right) => acc && INALIENABLE_RIGHTS.includes(right), - true, - ) - if (isInalienable) { - // If public dont throw and permit access - return true - } else { - // Throw on a protected route - throw new Error('403.13 - Client certificate revoked') - } - } - // Set context pubKey - context.pubKey = Buffer.from(decoded.pubKey).toString('hex') - // set new header token - // TODO - load from database dynamically & admin - maybe encode this in the token to prevent many database requests - // TODO this implementation is bullshit - two database queries cause our user identifiers are not aligned and vary between email, id and pubKey - const userRepository = await getCustomRepository(UserRepository) + if (!context.token) { + throw new Error('401 Unauthorized') + } + + // Decode the token + const decoded = decode(context.token) + if (!decoded) { + throw new Error('403.13 - Client certificate revoked') + } + // Set context pubKey + context.pubKey = Buffer.from(decoded.pubKey).toString('hex') + + // TODO - load from database dynamically & admin - maybe encode this in the token to prevent many database requests + // TODO this implementation is bullshit - two database queries cause our user identifiers are not aligned and vary between email, id and pubKey + const userRepository = await getCustomRepository(UserRepository) + try { const user = await userRepository.findByPubkeyHex(context.pubKey) const countServerUsers = await ServerUser.count({ email: user.email }) context.role = countServerUsers > 0 ? ROLE_ADMIN : ROLE_USER - - context.setHeaders.push({ key: 'token', value: encode(decoded.pubKey) }) + } catch { + // in case the database query fails (user deleted) + throw new Error('401 Unauthorized') } // check for correct rights @@ -50,6 +48,8 @@ const isAuthorized: AuthChecker = async ({ context }, rights) => { throw new Error('401 Unauthorized') } + // set new header token + context.setHeaders.push({ key: 'token', value: encode(decoded.pubKey) }) return true } diff --git a/backend/src/graphql/resolver/UserResolver.test.ts b/backend/src/graphql/resolver/UserResolver.test.ts index fd0936b9a..947636aa4 100644 --- a/backend/src/graphql/resolver/UserResolver.test.ts +++ b/backend/src/graphql/resolver/UserResolver.test.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ -import { testEnvironment, createUser, headerPushMock, cleanDB } from '@test/helpers' +import { testEnvironment, createUser, headerPushMock, cleanDB, resetToken } from '@test/helpers' import { createUserMutation, setPasswordMutation } from '@test/graphql' import gql from 'graphql-tag' import { GraphQLError } from 'graphql' @@ -31,6 +31,24 @@ jest.mock('@/apis/KlicktippController', () => { let mutate: any, query: any, con: any +const loginQuery = gql` + query ($email: String!, $password: String!, $publisherId: Int) { + login(email: $email, password: $password, publisherId: $publisherId) { + email + firstName + lastName + language + coinanimation + klickTipp { + newsletterState + } + hasElopage + publisherId + isAdmin + } + } +` + beforeAll(async () => { const testEnv = await testEnvironment() mutate = testEnv.mutate @@ -284,24 +302,6 @@ describe('UserResolver', () => { }) describe('login', () => { - const loginQuery = gql` - query ($email: String!, $password: String!, $publisherId: Int) { - login(email: $email, password: $password, publisherId: $publisherId) { - email - firstName - lastName - language - coinanimation - klickTipp { - newsletterState - } - hasElopage - publisherId - isAdmin - } - } - ` - const variables = { email: 'peter@lustig.de', password: 'Aa12345_', @@ -328,7 +328,7 @@ describe('UserResolver', () => { }) }) - describe('user is in database', () => { + describe('user is in database and correct login data', () => { beforeAll(async () => { await createUser(mutate, { email: 'peter@lustig.de', @@ -370,5 +370,81 @@ describe('UserResolver', () => { expect(headerPushMock).toBeCalledWith({ key: 'token', value: expect.any(String) }) }) }) + + describe('user is in database and wrong password', () => { + beforeAll(async () => { + await createUser(mutate, { + email: 'peter@lustig.de', + firstName: 'Peter', + lastName: 'Lustig', + language: 'de', + publisherId: 1234, + }) + }) + + afterAll(async () => { + await cleanDB() + }) + + it('returns an error', () => { + expect( + query({ query: loginQuery, variables: { ...variables, password: 'wrong' } }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('No user with this credentials')], + }), + ) + }) + }) + }) + + describe('logout', () => { + const logoutQuery = gql` + query { + logout + } + ` + + describe('unauthenticated', () => { + it('throws an error', async () => { + resetToken() + await expect(query({ query: logoutQuery })).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('authenticated', () => { + const variables = { + email: 'peter@lustig.de', + password: 'Aa12345_', + } + + beforeAll(async () => { + await createUser(mutate, { + email: 'peter@lustig.de', + firstName: 'Peter', + lastName: 'Lustig', + language: 'de', + publisherId: 1234, + }) + await query({ query: loginQuery, variables }) + }) + + afterAll(async () => { + await cleanDB() + }) + + it('returns true', async () => { + await expect(query({ query: logoutQuery })).resolves.toEqual( + expect.objectContaining({ + data: { logout: 'true' }, + errors: undefined, + }), + ) + }) + }) }) }) diff --git a/backend/src/graphql/resolver/UserResolver.ts b/backend/src/graphql/resolver/UserResolver.ts index 4d1454e86..9896ddc97 100644 --- a/backend/src/graphql/resolver/UserResolver.ts +++ b/backend/src/graphql/resolver/UserResolver.ts @@ -373,6 +373,8 @@ export class UserResolver { /{code}/g, emailOptIn.verificationCode.toString(), ) + + // eslint-disable-next-line @typescript-eslint/no-unused-vars const emailSent = await sendAccountActivationEmail({ link: activationLink, firstName, @@ -380,11 +382,13 @@ export class UserResolver { 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() @@ -414,6 +418,7 @@ export class UserResolver { emailOptIn.verificationCode.toString(), ) + // eslint-disable-next-line @typescript-eslint/no-unused-vars const emailSent = await sendAccountActivationEmail({ link: activationLink, firstName: user.firstName, @@ -421,11 +426,13 @@ export class UserResolver { 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() @@ -450,6 +457,7 @@ export class UserResolver { optInCode.verificationCode.toString(), ) + // eslint-disable-next-line @typescript-eslint/no-unused-vars const emailSent = await sendResetPasswordEmail({ link, firstName: user.firstName, @@ -457,11 +465,13 @@ export class UserResolver { 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(`Reset password link: ${link}`) } + */ return true } @@ -551,7 +561,9 @@ export class UserResolver { } catch { // 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') + */ } } diff --git a/backend/test/helpers.ts b/backend/test/helpers.ts index 1048b16b7..edb4eb3e4 100644 --- a/backend/test/helpers.ts +++ b/backend/test/helpers.ts @@ -3,18 +3,18 @@ import { createTestClient } from 'apollo-server-testing' import createServer from '../src/server/createServer' -import { resetDB, initialize } from '@dbTools/helpers' +import { initialize } from '@dbTools/helpers' import { createUserMutation, setPasswordMutation } from './graphql' import { LoginEmailOptIn } from '@entity/LoginEmailOptIn' import { User } from '@entity/User' import { entities } from '@entity/index' -let token = '' - -export const headerPushMock = jest.fn((t) => (token = t.value)) +export const headerPushMock = jest.fn((t) => { + context.token = t.value +}) const context = { - token, + token: '', setHeaders: { push: headerPushMock, forEach: jest.fn(), @@ -35,12 +35,11 @@ export const testEnvironment = async () => { const mutate = testClient.mutate const query = testClient.query await initialize() - await resetDB() return { mutate, query, con } } export const resetEntity = async (entity: any) => { - const items = await entity.find() + const items = await entity.find({ withDeleted: true }) if (items.length > 0) { const ids = items.map((i: any) => i.id) await entity.delete(ids) @@ -48,13 +47,18 @@ export const resetEntity = async (entity: any) => { } export const createUser = async (mutate: any, user: any) => { + // resetToken() await mutate({ mutation: createUserMutation, variables: user }) const dbUser = await User.findOne({ where: { email: user.email } }) if (!dbUser) throw new Error('Ups, no user found') - const optin = await LoginEmailOptIn.findOne(dbUser.id) + const optin = await LoginEmailOptIn.findOne({ where: { userId: dbUser.id } }) if (!optin) throw new Error('Ups, no optin found') await mutate({ mutation: setPasswordMutation, variables: { password: 'Aa12345_', code: optin.verificationCode }, }) } + +export const resetToken = () => { + context.token = '' +}