diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7a987730e..902b71b11 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -399,7 +399,7 @@ jobs: report_name: Coverage Frontend type: lcov result_path: ./coverage/lcov.info - min_coverage: 85 + min_coverage: 86 token: ${{ github.token }} ############################################################################## @@ -491,7 +491,7 @@ jobs: report_name: Coverage Backend type: lcov result_path: ./backend/coverage/lcov.info - min_coverage: 39 + min_coverage: 38 token: ${{ github.token }} ############################################################################## diff --git a/backend/src/graphql/directive/isAuthorized.ts b/backend/src/graphql/directive/isAuthorized.ts index 77fe56ba1..d72f19456 100644 --- a/backend/src/graphql/directive/isAuthorized.ts +++ b/backend/src/graphql/directive/isAuthorized.ts @@ -13,15 +13,9 @@ const isAuthorized: AuthChecker = async ( ) => { if (context.token) { const decoded = decode(context.token) - if (decoded.sessionId && decoded.sessionId !== 0) { - const result = await apiGet( - `${CONFIG.LOGIN_API_URL}checkSessionState?session_id=${decoded.sessionId}`, - ) - context.sessionId = decoded.sessionId - context.pubKey = decoded.pubKey - context.setHeaders.push({ key: 'token', value: encode(decoded.sessionId, decoded.pubKey) }) - return result.success - } + context.pubKey = decoded.pubKey + context.setHeaders.push({ key: 'token', value: encode(decoded.pubKey) }) + return true } throw new Error('401 Unauthorized') } diff --git a/backend/src/graphql/model/UpdateUserInfosResponse.ts b/backend/src/graphql/model/UpdateUserInfosResponse.ts deleted file mode 100644 index 0e41f21cb..000000000 --- a/backend/src/graphql/model/UpdateUserInfosResponse.ts +++ /dev/null @@ -1,13 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ -import { ObjectType, Field } from 'type-graphql' - -@ObjectType() -export class UpdateUserInfosResponse { - constructor(json: any) { - this.validValues = json.valid_values - } - - @Field(() => Number) - validValues: number -} diff --git a/backend/src/graphql/resolver/CommunityResolver.test.ts b/backend/src/graphql/resolver/CommunityResolver.test.ts index 20a06c2b8..afc6decec 100644 --- a/backend/src/graphql/resolver/CommunityResolver.test.ts +++ b/backend/src/graphql/resolver/CommunityResolver.test.ts @@ -48,7 +48,7 @@ describe('CommunityResolver', () => { describe('getCommunityInfo', () => { it('returns the default values', async () => { - expect(query({ query: getCommunityInfoQuery })).resolves.toMatchObject({ + await expect(query({ query: getCommunityInfoQuery })).resolves.toMatchObject({ data: { getCommunityInfo: { name: 'Gradido Entwicklung', @@ -68,7 +68,7 @@ describe('CommunityResolver', () => { }) it('returns three communities', async () => { - expect(query({ query: communities })).resolves.toMatchObject({ + await expect(query({ query: communities })).resolves.toMatchObject({ data: { communities: [ { @@ -104,7 +104,7 @@ describe('CommunityResolver', () => { }) it('returns one community', async () => { - expect(query({ query: communities })).resolves.toMatchObject({ + await expect(query({ query: communities })).resolves.toMatchObject({ data: { communities: [ { diff --git a/backend/src/graphql/resolver/TransactionResolver.ts b/backend/src/graphql/resolver/TransactionResolver.ts index 755955a7f..968ce9d4c 100644 --- a/backend/src/graphql/resolver/TransactionResolver.ts +++ b/backend/src/graphql/resolver/TransactionResolver.ts @@ -33,6 +33,7 @@ import { calculateDecay, calculateDecayWithInterval } from '../../util/decay' import { TransactionTypeId } from '../enum/TransactionTypeId' import { TransactionType } from '../enum/TransactionType' import { hasUserAmount, isHexPublicKey } from '../../util/validate' +import { LoginUserRepository } from '../../typeorm/repository/LoginUser' /* # Test @@ -451,15 +452,15 @@ async function addUserTransaction( }) } -async function getPublicKey(email: string, sessionId: number): Promise { - const result = await apiPost(CONFIG.LOGIN_API_URL + 'getUserInfos', { - session_id: sessionId, - email, - ask: ['user.pubkeyhex'], - }) - if (result.success) { - return result.data.userData.pubkeyhex +async function getPublicKey(email: string): Promise { + const loginUserRepository = getCustomRepository(LoginUserRepository) + const loginUser = await loginUserRepository.findOne({ email: email }) + // User not found + if (!loginUser) { + return null } + + return loginUser.pubKey.toString('hex') } @Resolver() @@ -517,7 +518,7 @@ export class TransactionResolver { // validate recipient user // TODO: the detour over the public key is unnecessary - const recipiantPublicKey = await getPublicKey(email, context.sessionId) + const recipiantPublicKey = await getPublicKey(email) if (!recipiantPublicKey) { throw new Error('recipiant not known') } diff --git a/backend/src/graphql/resolver/UserResolver.ts b/backend/src/graphql/resolver/UserResolver.ts index 888a1aa00..5c4625938 100644 --- a/backend/src/graphql/resolver/UserResolver.ts +++ b/backend/src/graphql/resolver/UserResolver.ts @@ -7,7 +7,6 @@ import { getConnection, getCustomRepository } from 'typeorm' import CONFIG from '../../config' import { LoginViaVerificationCode } from '../model/LoginViaVerificationCode' import { SendPasswordResetEmailResponse } from '../model/SendPasswordResetEmailResponse' -import { UpdateUserInfosResponse } from '../model/UpdateUserInfosResponse' import { User } from '../model/User' import { User as DbUser } from '@entity/User' import encode from '../../jwt/encode' @@ -30,6 +29,7 @@ import { LoginElopageBuys } from '@entity/LoginElopageBuys' import { LoginUserBackup } from '@entity/LoginUserBackup' import { LoginEmailOptIn } from '@entity/LoginEmailOptIn' import { sendEMail } from '../../util/sendEMail' +import { LoginUserRepository } from '../../typeorm/repository/LoginUser' // eslint-disable-next-line @typescript-eslint/no-var-requires const sodium = require('sodium-native') @@ -174,7 +174,7 @@ const getEmailHash = (email: string): Buffer => { } const SecretKeyCryptographyEncrypt = (message: Buffer, encryptionKey: Buffer): Buffer => { - const encrypted = Buffer.alloc(sodium.crypto_secretbox_MACBYTES + message.length) + const encrypted = Buffer.alloc(message.length + sodium.crypto_secretbox_MACBYTES) const nonce = Buffer.alloc(sodium.crypto_secretbox_NONCEBYTES) nonce.fill(31) // static nonce @@ -182,6 +182,16 @@ const SecretKeyCryptographyEncrypt = (message: Buffer, encryptionKey: Buffer): B return encrypted } +const SecretKeyCryptographyDecrypt = (encryptedMessage: Buffer, encryptionKey: Buffer): Buffer => { + const message = Buffer.alloc(encryptedMessage.length - sodium.crypto_secretbox_MACBYTES) + const nonce = Buffer.alloc(sodium.crypto_secretbox_NONCEBYTES) + nonce.fill(31) // static nonce + + sodium.crypto_secretbox_open_easy(message, encryptedMessage, nonce, encryptionKey) + + return message +} + @Resolver() export class UserResolver { @Query(() => User) @@ -200,7 +210,7 @@ export class UserResolver { context.setHeaders.push({ key: 'token', - value: encode(result.data.session_id, result.data.user.public_hex), + value: encode(result.data.user.public_hex), }) const user = new User(result.data.user) // Hack: Database Field is not validated properly and not nullable @@ -230,10 +240,11 @@ export class UserResolver { // Save publisherId if Elopage is not yet registered if (!user.hasElopage && publisherId) { user.publisherId = publisherId - await this.updateUserInfos( - { publisherId }, - { sessionId: result.data.session_id, pubKey: result.data.user.public_hex }, - ) + + const loginUserRepository = getCustomRepository(LoginUserRepository) + const loginUser = await loginUserRepository.findOneOrFail({ email: userEntity.email }) + loginUser.publisherId = publisherId + loginUserRepository.save(loginUser) } const userSettingRepository = getCustomRepository(UserSettingRepository) @@ -446,7 +457,7 @@ export class UserResolver { } @Authorized() - @Mutation(() => UpdateUserInfosResponse) + @Mutation(() => Boolean) async updateUserInfos( @Args() { @@ -461,79 +472,97 @@ export class UserResolver { coinanimation, }: UpdateUserInfosArgs, @Ctx() context: any, - ): Promise { - const payload = { - session_id: context.sessionId, - update: { - 'User.first_name': firstName || undefined, - 'User.last_name': lastName || undefined, - 'User.description': description || undefined, - 'User.username': username || undefined, - 'User.language': language || undefined, - 'User.publisher_id': publisherId || undefined, - 'User.password': passwordNew || undefined, - 'User.password_old': password || undefined, - }, - } - let response: UpdateUserInfosResponse | undefined + ): Promise { const userRepository = getCustomRepository(UserRepository) + const userEntity = await userRepository.findByPubkeyHex(context.pubKey) + const loginUserRepository = getCustomRepository(LoginUserRepository) + const loginUser = await loginUserRepository.findOneOrFail({ email: userEntity.email }) - if ( - firstName || - lastName || - description || - username || - language || - publisherId || - passwordNew || - password - ) { - const result = await apiPost(CONFIG.LOGIN_API_URL + 'updateUserInfos', payload) - if (!result.success) throw new Error(result.data) - response = new UpdateUserInfosResponse(result.data) - - const userEntity = await userRepository.findByPubkeyHex(context.pubKey) - let userEntityChanged = false - if (firstName) { - userEntity.firstName = firstName - userEntityChanged = true - } - if (lastName) { - userEntity.lastName = lastName - userEntityChanged = true - } - if (username) { - userEntity.username = username - userEntityChanged = true - } - if (userEntityChanged) { - userRepository.save(userEntity).catch((error) => { - throw new Error(error) - }) - } + if (username) { + throw new Error('change username currently not supported!') + // TODO: this error was thrown on login_server whenever you tried to change the username + // to anything except "" which is an exception to the rules below. Those were defined + // aswell, even tho never used. + // ^[a-zA-Z][a-zA-Z0-9_-]*$ + // username must start with [a-z] or [A-Z] and than can contain also [0-9], - and _ + // username already used + // userEntity.username = username } - if (coinanimation !== undefined) { - // load user and balance - const userEntity = await userRepository.findByPubkeyHex(context.pubKey) + if (firstName) { + loginUser.firstName = firstName + userEntity.firstName = firstName + } - const userSettingRepository = getCustomRepository(UserSettingRepository) - userSettingRepository - .setOrUpdate(userEntity.id, Setting.COIN_ANIMATION, coinanimation.toString()) - .catch((error) => { - throw new Error(error) - }) + if (lastName) { + loginUser.lastName = lastName + userEntity.lastName = lastName + } - if (!response) { - response = new UpdateUserInfosResponse({ valid_values: 1 }) - } else { - response.validValues++ + if (description) { + loginUser.description = description + } + + if (language) { + if (!isLanguage(language)) { + throw new Error(`"${language}" isn't a valid language`) } + loginUser.language = language } - if (!response) { - throw new Error('no valid response') + + if (password && passwordNew) { + // TODO: This had some error cases defined - like missing private key. This is no longer checked. + const oldPasswordHash = SecretKeyCryptographyCreateKey(loginUser.email, password) + if (loginUser.password !== oldPasswordHash[0].readBigUInt64LE()) { + throw new Error(`Old password is invalid`) + } + + const privKey = SecretKeyCryptographyDecrypt(loginUser.privKey, oldPasswordHash[1]) + + const newPasswordHash = SecretKeyCryptographyCreateKey(loginUser.email, passwordNew) // return short and long hash + const encryptedPrivkey = SecretKeyCryptographyEncrypt(privKey, newPasswordHash[1]) + + // Save new password hash and newly encrypted private key + loginUser.password = newPasswordHash[0].readBigInt64LE() + loginUser.privKey = encryptedPrivkey } - return response + + // Save publisherId only if Elopage is not yet registered + if (publisherId && !(await this.hasElopage(context))) { + loginUser.publisherId = publisherId + } + + const queryRunner = getConnection().createQueryRunner() + await queryRunner.connect() + await queryRunner.startTransaction('READ UNCOMMITTED') + + try { + if (coinanimation) { + queryRunner.manager + .getCustomRepository(UserSettingRepository) + .setOrUpdate(userEntity.id, Setting.COIN_ANIMATION, coinanimation.toString()) + .catch((error) => { + throw new Error('error saving coinanimation: ' + error) + }) + } + + await queryRunner.manager.save(loginUser).catch((error) => { + throw new Error('error saving loginUser: ' + error) + }) + + await queryRunner.manager.save(userEntity).catch((error) => { + throw new Error('error saving user: ' + error) + }) + + await queryRunner.commitTransaction() + } catch (e) { + await queryRunner.rollbackTransaction() + throw e + } finally { + await queryRunner.release() + } + + return true } @Query(() => Boolean) diff --git a/backend/src/jwt/decode.ts b/backend/src/jwt/decode.ts index 34b3ed836..6f09276b0 100644 --- a/backend/src/jwt/decode.ts +++ b/backend/src/jwt/decode.ts @@ -2,27 +2,22 @@ import jwt, { JwtPayload } from 'jsonwebtoken' import CONFIG from '../config/' interface CustomJwtPayload extends JwtPayload { - sessionId: number pubKey: Buffer } type DecodedJwt = { token: string - sessionId: number pubKey: Buffer } export default (token: string): DecodedJwt => { if (!token) throw new Error('401 Unauthorized') - let sessionId = null let pubKey = null try { const decoded = jwt.verify(token, CONFIG.JWT_SECRET) - sessionId = decoded.sessionId pubKey = decoded.pubKey return { token, - sessionId, pubKey, } } catch (err) { diff --git a/backend/src/jwt/encode.ts b/backend/src/jwt/encode.ts index fde28b467..ef062ad3a 100644 --- a/backend/src/jwt/encode.ts +++ b/backend/src/jwt/encode.ts @@ -5,10 +5,9 @@ import jwt from 'jsonwebtoken' import CONFIG from '../config/' // Generate an Access Token -export default function encode(sessionId: number, pubKey: Buffer): string { - const token = jwt.sign({ sessionId, pubKey }, CONFIG.JWT_SECRET, { +export default function encode(pubKey: Buffer): string { + const token = jwt.sign({ pubKey }, CONFIG.JWT_SECRET, { expiresIn: CONFIG.JWT_EXPIRES_IN, - subject: sessionId.toString(), }) return token } diff --git a/backend/src/util/sendEMail.ts b/backend/src/util/sendEMail.ts index e34597419..4c239980d 100644 --- a/backend/src/util/sendEMail.ts +++ b/backend/src/util/sendEMail.ts @@ -2,7 +2,12 @@ import { createTransport } from 'nodemailer' import CONFIG from '../config' -export const sendEMail = async (emailDef: any): Promise => { +export const sendEMail = async (emailDef: { + from: string + to: string + subject: string + text: string +}): Promise => { if (!CONFIG.EMAIL) { // eslint-disable-next-line no-console console.log('Emails are disabled via config') diff --git a/frontend/src/graphql/mutations.js b/frontend/src/graphql/mutations.js index a20367aa8..d1d3d583c 100644 --- a/frontend/src/graphql/mutations.js +++ b/frontend/src/graphql/mutations.js @@ -38,9 +38,7 @@ export const updateUserInfos = gql` passwordNew: $passwordNew language: $locale coinanimation: $coinanimation - ) { - validValues - } + ) } ` diff --git a/frontend/src/plugins/globalComponents.test.js b/frontend/src/plugins/globalComponents.test.js new file mode 100644 index 000000000..294c5d616 --- /dev/null +++ b/frontend/src/plugins/globalComponents.test.js @@ -0,0 +1,29 @@ +import GlobalComponents from './globalComponents.js' +import Vue from 'vue' +import 'vee-validate' + +jest.mock('vue') +jest.mock('vee-validate', () => { + const originalModule = jest.requireActual('vee-validate') + return { + __esModule: true, + ...originalModule, + ValidationProvider: 'mocked validation provider', + ValidationObserver: 'mocked validation observer', + } +}) + +const vueComponentMock = jest.fn() +Vue.component = vueComponentMock + +describe('global Components', () => { + GlobalComponents.install(Vue) + + it('installs the validation provider', () => { + expect(vueComponentMock).toBeCalledWith('validation-provider', 'mocked validation provider') + }) + + it('installs the validation observer', () => { + expect(vueComponentMock).toBeCalledWith('validation-observer', 'mocked validation observer') + }) +}) diff --git a/frontend/src/views/Pages/SendOverview/GddSend/QrCode.spec.js b/frontend/src/views/Pages/SendOverview/GddSend/QrCode.spec.js new file mode 100644 index 000000000..66da5748d --- /dev/null +++ b/frontend/src/views/Pages/SendOverview/GddSend/QrCode.spec.js @@ -0,0 +1,70 @@ +import { mount } from '@vue/test-utils' +import QrCode from './QrCode' + +const localVue = global.localVue + +describe('QrCode', () => { + let wrapper + + const mocks = { + $t: jest.fn((t) => t), + } + + const stubs = { + QrcodeStream: true, + QrcodeCapture: true, + } + + const Wrapper = () => { + return mount(QrCode, { localVue, mocks, stubs }) + } + + describe('mount', () => { + beforeEach(() => { + wrapper = Wrapper() + }) + + it('renders the component', () => { + expect(wrapper.find('div.alert').exists()).toBeTruthy() + }) + + describe('scanning', () => { + beforeEach(async () => { + wrapper.find('a').trigger('click') + }) + + it('has a scanning stream', () => { + expect(wrapper.findComponent({ name: 'QrcodeStream' }).exists()).toBeTruthy() + }) + + describe('decode', () => { + beforeEach(async () => { + await wrapper + .findComponent({ name: 'QrcodeStream' }) + .vm.$emit('decode', '[{"email": "user@example.org", "amount": 10.0}]') + }) + + it('emits set transaction', () => { + expect(wrapper.emitted()['set-transaction']).toEqual([ + [ + { + email: 'user@example.org', + amount: 10, + }, + ], + ]) + }) + }) + + describe('detect', () => { + beforeEach(async () => { + await wrapper.find('div.row > *').vm.$emit('detect') + }) + + it('calls onDetect', () => { + expect(wrapper.vm.detect).toBeTruthy() + }) + }) + }) + }) +}) diff --git a/frontend/src/views/Pages/SendOverview/GddSend/QrCode.vue b/frontend/src/views/Pages/SendOverview/GddSend/QrCode.vue index edf027aef..0146621ed 100644 --- a/frontend/src/views/Pages/SendOverview/GddSend/QrCode.vue +++ b/frontend/src/views/Pages/SendOverview/GddSend/QrCode.vue @@ -44,6 +44,7 @@ export default { data() { return { scan: false, + detect: false, } }, methods: { @@ -55,6 +56,10 @@ export default { this.$emit('set-transaction', { email: arr[0].email, amount: arr[0].amount }) this.scan = false }, + async onDetect() { + // TODO: what is this for? I added the detect data to test that the method is called + this.detect = !this.detect + }, }, }