diff --git a/backend/jest.config.js b/backend/jest.config.js index 1236e7297..b873d72d6 100644 --- a/backend/jest.config.js +++ b/backend/jest.config.js @@ -6,7 +6,7 @@ module.exports = { collectCoverageFrom: ['src/**/*.ts', '!**/node_modules/**', '!src/seeds/**', '!build/**'], coverageThreshold: { global: { - lines: 77, + lines: 76, }, }, setupFiles: ['/test/testSetup.ts'], diff --git a/backend/package.json b/backend/package.json index cb3681548..caa13974d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -11,15 +11,16 @@ "build": "ts-node ./esbuild.config.ts && mkdirp build/templates/ && ncp src/emails/templates build/templates && mkdirp locales/ && ncp src/locales locales", "clean": "tsc --build --clean", "dev": "cross-env TZ=UTC nodemon -w src --ext ts,pug,json,css -r tsconfig-paths/register src/index.ts", - "gmsusers": "cross-env TZ=UTC NODE_ENV=development ts-node -r tsconfig-paths/register src/apis/gms/ExportUsers.ts", - "humhubUserExport": "cross-env TZ=UTC NODE_ENV=development ts-node -r tsconfig-paths/register src/apis/humhub/ExportUsers.ts", + "test": "cross-env TZ=UTC NODE_ENV=development DB_DATABASE=gradido_test_backend jest --runInBand --forceExit --detectOpenHandles", + "seed": "cross-env TZ=UTC NODE_ENV=development ts-node -r tsconfig-paths/register src/seeds/index.ts", "klicktipp": "cross-env TZ=UTC NODE_ENV=development ts-node -r tsconfig-paths/register src/util/executeKlicktipp.ts", + "gmsusers": "cross-env TZ=UTC NODE_ENV=development ts-node -r tsconfig-paths/register src/apis/gms/ExportUsers.ts", + "humhubUserExport": "cross-env TZ=UTC NODE_ENV=development ts-node -r tsconfig-paths/register src/apis/humhub/ExportUsers.ts", "lint": "biome check --error-on-warnings .", "lint:fix": "biome check --error-on-warnings . --write", + "lint:fix:unsafe": "biome check --fix --unsafe", "locales": "scripts/sort.sh", - "seed": "cross-env TZ=UTC NODE_ENV=development ts-node -r tsconfig-paths/register src/seeds/index.ts", "start": "cross-env TZ=UTC NODE_ENV=production node build/index.js", - "test": "cross-env TZ=UTC NODE_ENV=development DB_DATABASE=gradido_test_backend jest --runInBand --forceExit --detectOpenHandles", "typecheck": "tsc --noEmit" }, "nodemonConfig": { diff --git a/backend/src/apis/humhub/syncUser.ts b/backend/src/apis/humhub/syncUser.ts index ba03df530..1e62871be 100644 --- a/backend/src/apis/humhub/syncUser.ts +++ b/backend/src/apis/humhub/syncUser.ts @@ -49,7 +49,11 @@ export async function syncUser( if (!isValid(postUser, user.id)) { return ExecutedHumhubAction.VALIDATION_ERROR } - const humhubUser = humhubUsers.get(postUser.account.username) + let humhubUser = humhubUsers.get(postUser.account.username) + if (!humhubUser) { + // fallback for legacy users + humhubUser = humhubUsers.get(user.gradidoID) + } const humHubClient = HumHubClient.getInstance() if (!humHubClient) { throw new LogError('Error creating humhub client') diff --git a/backend/src/auth/INALIENABLE_RIGHTS.ts b/backend/src/auth/INALIENABLE_RIGHTS.ts index c3c96b95e..19865608f 100644 --- a/backend/src/auth/INALIENABLE_RIGHTS.ts +++ b/backend/src/auth/INALIENABLE_RIGHTS.ts @@ -7,6 +7,7 @@ export const INALIENABLE_RIGHTS = [ RIGHTS.SEND_RESET_PASSWORD_EMAIL, RIGHTS.SET_PASSWORD, RIGHTS.QUERY_TRANSACTION_LINK, + RIGHTS.QUERY_REDEEM_JWT, RIGHTS.QUERY_OPT_IN, RIGHTS.CHECK_USERNAME, RIGHTS.PROJECT_BRANDING_BANNER, diff --git a/backend/src/auth/RIGHTS.ts b/backend/src/auth/RIGHTS.ts index 0ccb9695f..012a4e627 100644 --- a/backend/src/auth/RIGHTS.ts +++ b/backend/src/auth/RIGHTS.ts @@ -6,6 +6,7 @@ export enum RIGHTS { SEND_RESET_PASSWORD_EMAIL = 'SEND_RESET_PASSWORD_EMAIL', SET_PASSWORD = 'SET_PASSWORD', QUERY_TRANSACTION_LINK = 'QUERY_TRANSACTION_LINK', + QUERY_REDEEM_JWT = 'QUERY_REDEEM_JWT', QUERY_OPT_IN = 'QUERY_OPT_IN', CHECK_USERNAME = 'CHECK_USERNAME', PROJECT_BRANDING_BANNER = 'PROJECT_BRANDING_BANNER', @@ -24,6 +25,7 @@ export enum RIGHTS { CREATE_TRANSACTION_LINK = 'CREATE_TRANSACTION_LINK', DELETE_TRANSACTION_LINK = 'DELETE_TRANSACTION_LINK', REDEEM_TRANSACTION_LINK = 'REDEEM_TRANSACTION_LINK', + DISBURSE_TRANSACTION_LINK = 'DISBURSE_TRANSACTION_LINK', LIST_TRANSACTION_LINKS = 'LIST_TRANSACTION_LINKS', GDT_BALANCE = 'GDT_BALANCE', CREATE_CONTRIBUTION = 'CREATE_CONTRIBUTION', diff --git a/backend/src/auth/USER_RIGHTS.ts b/backend/src/auth/USER_RIGHTS.ts index 2d0a4d980..3f08d1160 100644 --- a/backend/src/auth/USER_RIGHTS.ts +++ b/backend/src/auth/USER_RIGHTS.ts @@ -15,6 +15,7 @@ export const USER_RIGHTS = [ RIGHTS.CREATE_TRANSACTION_LINK, RIGHTS.DELETE_TRANSACTION_LINK, RIGHTS.REDEEM_TRANSACTION_LINK, + RIGHTS.DISBURSE_TRANSACTION_LINK, RIGHTS.LIST_TRANSACTION_LINKS, RIGHTS.GDT_BALANCE, RIGHTS.CREATE_CONTRIBUTION, diff --git a/backend/src/auth/jwt/JWT.ts b/backend/src/auth/jwt/JWT.ts new file mode 100644 index 000000000..6f6581773 --- /dev/null +++ b/backend/src/auth/jwt/JWT.ts @@ -0,0 +1,70 @@ +import { createPrivateKey, sign } from 'node:crypto' + +import { JWTPayload, SignJWT, decodeJwt, jwtVerify } from 'jose' + +import { LogError } from '@/server/LogError' +import { backendLogger as logger } from '@/server/logger' + +import { JwtPayloadType } from './payloadtypes/JwtPayloadType' + +export const verify = async (token: string, signkey: string): Promise => { + if (!token) { + throw new LogError('401 Unauthorized') + } + logger.info('JWT.verify... token, signkey=', token, signkey) + + try { + /* + const { KeyObject } = await import('node:crypto') + const cryptoKey = await crypto.subtle.importKey('raw', signkey, { name: 'RS256' }, false, [ + 'sign', + ]) + const keyObject = KeyObject.from(cryptoKey) + logger.info('JWT.verify... keyObject=', keyObject) + logger.info('JWT.verify... keyObject.asymmetricKeyDetails=', keyObject.asymmetricKeyDetails) + logger.info('JWT.verify... keyObject.asymmetricKeyType=', keyObject.asymmetricKeyType) + logger.info('JWT.verify... keyObject.asymmetricKeySize=', keyObject.asymmetricKeySize) + */ + const secret = new TextEncoder().encode(signkey) + const { payload } = await jwtVerify(token, secret, { + issuer: 'urn:gradido:issuer', + audience: 'urn:gradido:audience', + }) + logger.info('JWT.verify after jwtVerify... payload=', payload) + return payload as JwtPayloadType + } catch (err) { + logger.error('JWT.verify after jwtVerify... error=', err) + return null + } +} + +export const encode = async (payload: JwtPayloadType, signkey: string): Promise => { + logger.info('JWT.encode... payload=', payload) + logger.info('JWT.encode... signkey=', signkey) + try { + const secret = new TextEncoder().encode(signkey) + const token = await new SignJWT({ payload, 'urn:gradido:claim': true }) + .setProtectedHeader({ + alg: 'HS256', + }) + .setIssuedAt() + .setIssuer('urn:gradido:issuer') + .setAudience('urn:gradido:audience') + .setExpirationTime(payload.expiration) + .sign(secret) + return token + } catch (e) { + logger.error('Failed to sign JWT:', e) + throw e + } +} + +export const verifyJwtType = async (token: string, signkey: string): Promise => { + const payload = await verify(token, signkey) + return payload ? payload.tokentype : 'unknown token type' +} + +export const decode = (token: string): JwtPayloadType => { + const { payload } = decodeJwt(token) + return payload as JwtPayloadType +} diff --git a/backend/src/auth/jwt/payloadtypes/DisburseJwtPayloadType.ts b/backend/src/auth/jwt/payloadtypes/DisburseJwtPayloadType.ts new file mode 100644 index 000000000..16a029d2d --- /dev/null +++ b/backend/src/auth/jwt/payloadtypes/DisburseJwtPayloadType.ts @@ -0,0 +1,48 @@ +// import { JWTPayload } from 'jose' +import { JwtPayloadType } from './JwtPayloadType' + +export class DisburseJwtPayloadType extends JwtPayloadType { + static DISBURSE_ACTIVATION_TYPE = 'disburse-activation' + + sendercommunityuuid: string + sendergradidoid: string + recipientcommunityuuid: string + recipientcommunityname: string + recipientgradidoid: string + recipientfirstname: string + code: string + amount: string + memo: string + validuntil: string + recipientalias: string + + constructor( + senderCommunityUuid: string, + senderGradidoId: string, + recipientCommunityUuid: string, + recipientCommunityName: string, + recipientGradidoId: string, + recipientFirstName: string, + code: string, + amount: string, + memo: string, + validUntil: string, + recipientAlias: string, + ) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + super() + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + this.tokentype = DisburseJwtPayloadType.DISBURSE_ACTIVATION_TYPE + this.sendercommunityuuid = senderCommunityUuid + this.sendergradidoid = senderGradidoId + this.recipientcommunityuuid = recipientCommunityUuid + this.recipientcommunityname = recipientCommunityName + this.recipientgradidoid = recipientGradidoId + this.recipientfirstname = recipientFirstName + this.code = code + this.amount = amount + this.memo = memo + this.validuntil = validUntil + this.recipientalias = recipientAlias + } +} diff --git a/backend/src/auth/jwt/payloadtypes/JwtPayloadType.ts b/backend/src/auth/jwt/payloadtypes/JwtPayloadType.ts new file mode 100644 index 000000000..48881ee32 --- /dev/null +++ b/backend/src/auth/jwt/payloadtypes/JwtPayloadType.ts @@ -0,0 +1,21 @@ +import { JWTPayload } from 'jose' + +import { CONFIG } from '@/config' + +export class JwtPayloadType implements JWTPayload { + iat?: number | undefined + exp?: number | undefined + nbf?: number | undefined + jti?: string | undefined + aud?: string | string[] | undefined + sub?: string | undefined + iss?: string | undefined; + [propName: string]: unknown + + tokentype: string + expiration: string // in minutes (format: 10m for ten minutes) + constructor() { + this.tokentype = 'unknown jwt type' + this.expiration = CONFIG.REDEEM_JWT_TOKEN_EXPIRATION || '10m' + } +} diff --git a/backend/src/auth/jwt/payloadtypes/RedeemJwtPayloadType.ts b/backend/src/auth/jwt/payloadtypes/RedeemJwtPayloadType.ts new file mode 100644 index 000000000..7c9af45e2 --- /dev/null +++ b/backend/src/auth/jwt/payloadtypes/RedeemJwtPayloadType.ts @@ -0,0 +1,36 @@ +// import { JWTPayload } from 'jose' +import { JwtPayloadType } from './JwtPayloadType' + +export class RedeemJwtPayloadType extends JwtPayloadType { + static REDEEM_ACTIVATION_TYPE = 'redeem-activation' + + sendercommunityuuid: string + sendergradidoid: string + sendername: string // alias or firstname + redeemcode: string + amount: string + memo: string + validuntil: string + + constructor( + senderCom: string, + senderUser: string, + sendername: string, + code: string, + amount: string, + memo: string, + validUntil: string, + ) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + super() + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + this.tokentype = RedeemJwtPayloadType.REDEEM_ACTIVATION_TYPE + this.sendercommunityuuid = senderCom + this.sendergradidoid = senderUser + this.sendername = sendername + this.redeemcode = code + this.amount = amount + this.memo = memo + this.validuntil = validUntil + } +} diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index c742b4b28..2eba8c8c1 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -25,6 +25,7 @@ const server = { PORT: process.env.PORT ?? 4000, JWT_SECRET: process.env.JWT_SECRET ?? 'secret123', JWT_EXPIRES_IN: process.env.JWT_EXPIRES_IN ?? '10m', + REDEEM_JWT_TOKEN_EXPIRATION: process.env.REDEEM_JWT_TOKEN_EXPIRATION ?? '10m', GRAPHIQL: process.env.GRAPHIQL === 'true' || false, GDT_ACTIVE: process.env.GDT_ACTIVE === 'true' || false, GDT_API_URL: process.env.GDT_API_URL ?? 'https://gdt.gradido.net', diff --git a/backend/src/config/schema.ts b/backend/src/config/schema.ts index 5c937ae5b..4bfe2a551 100644 --- a/backend/src/config/schema.ts +++ b/backend/src/config/schema.ts @@ -367,5 +367,20 @@ export const schema = Joi.object({ .required() .description('Time for JWT token to expire, auto logout'), + REDEEM_JWT_TOKEN_EXPIRATION: Joi.alternatives() + .try( + Joi.string() + .pattern(/^\d+[smhdw]$/) + .description( + 'Expiration time for x-community redeem JWT token, in format like "10m", "1h", "1d"', + ) + .default('10m'), + Joi.number() + .positive() + .description('Expiration time for x-community redeem JWT token in minutes'), + ) + .required() + .description('Time for x-community redeem JWT token to expire'), + WEBHOOK_ELOPAGE_SECRET: Joi.string().description("isn't really used any more").optional(), }) diff --git a/backend/src/federation/client/1_0/FederationClient.ts b/backend/src/federation/client/1_0/FederationClient.ts index ca7b07c9a..b83da8a8b 100644 --- a/backend/src/federation/client/1_0/FederationClient.ts +++ b/backend/src/federation/client/1_0/FederationClient.ts @@ -78,6 +78,7 @@ export class FederationClient { ) return data.getPublicCommunityInfo } catch (err) { + logger.warn(' err', err) const errorString = JSON.stringify(err) logger.warn('Federation: getPublicCommunityInfo failed for endpoint', { endpoint: this.endpoint, diff --git a/backend/src/graphql/model/RedeemJwtLink.ts b/backend/src/graphql/model/RedeemJwtLink.ts new file mode 100644 index 000000000..018a92bb0 --- /dev/null +++ b/backend/src/graphql/model/RedeemJwtLink.ts @@ -0,0 +1,55 @@ +import { Decimal } from 'decimal.js-light' +import { Field, ObjectType } from 'type-graphql' + +import { RedeemJwtPayloadType } from '@/auth/jwt/payloadtypes/RedeemJwtPayloadType' + +import { Community } from './Community' +import { User } from './User' + +@ObjectType() +export class RedeemJwtLink { + constructor( + redeemJwtPayload: RedeemJwtPayloadType, + senderCommunity: Community, + senderUser: User, + recipientCommunity: Community, + recipientUser?: User, + ) { + this.senderCommunity = senderCommunity + this.recipientCommunity = recipientCommunity + this.senderUser = senderUser + if (recipientUser !== undefined) { + this.recipientUser = recipientUser + } else { + this.recipientUser = null + } + this.amount = new Decimal(redeemJwtPayload.amount) + this.memo = redeemJwtPayload.memo + this.code = redeemJwtPayload.redeemcode + this.validUntil = new Date(redeemJwtPayload.validuntil) + } + + @Field(() => Community) + senderCommunity: Community + + @Field(() => User) + senderUser: User + + @Field(() => Community) + recipientCommunity: Community + + @Field(() => User, { nullable: true }) + recipientUser: User | null + + @Field(() => Decimal) + amount: Decimal + + @Field(() => String) + memo: string + + @Field(() => String) + code: string + + @Field(() => Date) + validUntil: Date +} diff --git a/backend/src/graphql/model/TransactionLink.ts b/backend/src/graphql/model/TransactionLink.ts index 6799dd859..8262b1264 100644 --- a/backend/src/graphql/model/TransactionLink.ts +++ b/backend/src/graphql/model/TransactionLink.ts @@ -1,33 +1,48 @@ -import { TransactionLink as dbTransactionLink } from 'database' +import { Community as DbCommunity, TransactionLink as DbTransactionLink } from 'database' import { Decimal } from 'decimal.js-light' import { Field, Int, ObjectType } from 'type-graphql' import { CONFIG } from '@/config' +import { Community } from './Community' import { User } from './User' @ObjectType() export class TransactionLink { - constructor(transactionLink: dbTransactionLink, user: User, redeemedBy: User | null = null) { - this.id = transactionLink.id - this.user = user - this.amount = transactionLink.amount - this.holdAvailableAmount = transactionLink.holdAvailableAmount - this.memo = transactionLink.memo - this.code = transactionLink.code - this.createdAt = transactionLink.createdAt - this.validUntil = transactionLink.validUntil - this.deletedAt = transactionLink.deletedAt - this.redeemedAt = transactionLink.redeemedAt - this.redeemedBy = redeemedBy - this.link = CONFIG.COMMUNITY_REDEEM_URL + this.code + constructor( + dbTransactionLink?: DbTransactionLink, + user?: User, + redeemedBy?: User, + dbCommunities?: DbCommunity[], + ) { + if (dbTransactionLink !== undefined) { + this.id = dbTransactionLink.id + this.amount = dbTransactionLink.amount + this.holdAvailableAmount = dbTransactionLink.holdAvailableAmount + this.memo = dbTransactionLink.memo + this.code = dbTransactionLink.code + this.link = CONFIG.COMMUNITY_REDEEM_URL + this.code + this.createdAt = dbTransactionLink.createdAt + this.validUntil = dbTransactionLink.validUntil + this.deletedAt = dbTransactionLink.deletedAt + this.redeemedAt = dbTransactionLink.redeemedAt + } + if (user !== undefined) { + this.senderUser = user + } + if (redeemedBy !== undefined) { + this.redeemedBy = redeemedBy + } + if (dbCommunities !== undefined) { + this.communities = dbCommunities.map((dbCom: DbCommunity) => new Community(dbCom)) + } } @Field(() => Int) id: number @Field(() => User) - user: User + senderUser: User @Field(() => Decimal) amount: Decimal @@ -58,6 +73,12 @@ export class TransactionLink { @Field(() => String) link: string + + @Field(() => String) + communityName: string + + @Field(() => [Community]) + communities: Community[] } @ObjectType() diff --git a/backend/src/graphql/model/User.ts b/backend/src/graphql/model/User.ts index ac4b0bb08..ffc6aae31 100644 --- a/backend/src/graphql/model/User.ts +++ b/backend/src/graphql/model/User.ts @@ -1,4 +1,4 @@ -import { User as dbUser } from 'database' +import { User as DbUser } from 'database' import { Field, Int, ObjectType } from 'type-graphql' import { Point } from 'typeorm' @@ -14,43 +14,43 @@ import { UserContact } from './UserContact' @ObjectType() export class User { - constructor(user: dbUser | null) { - if (user) { - this.id = user.id - this.foreign = user.foreign - this.communityUuid = user.communityUuid - if (user.community) { - this.communityName = user.community.name + constructor(dbUser: DbUser | null) { + if (dbUser) { + this.id = dbUser.id + this.foreign = dbUser.foreign + this.communityUuid = dbUser.communityUuid + if (dbUser.community) { + this.communityName = dbUser.community.name } - this.gradidoID = user.gradidoID - this.alias = user.alias + this.gradidoID = dbUser.gradidoID + this.alias = dbUser.alias - const publishNameLogic = new PublishNameLogic(user) - const publishNameType = user.humhubPublishName as PublishNameType + const publishNameLogic = new PublishNameLogic(dbUser) + const publishNameType = dbUser.humhubPublishName as PublishNameType this.publicName = publishNameLogic.getPublicName(publishNameType) this.userIdentifier = publishNameLogic.getUserIdentifier(publishNameType) - if (user.emailContact) { - this.emailChecked = user.emailContact.emailChecked - this.emailContact = new UserContact(user.emailContact) + if (dbUser.emailContact) { + this.emailChecked = dbUser.emailContact.emailChecked + this.emailContact = new UserContact(dbUser.emailContact) } - this.firstName = user.firstName - this.lastName = user.lastName - this.deletedAt = user.deletedAt - this.createdAt = user.createdAt - this.language = user.language - this.publisherId = user.publisherId - this.roles = user.userRoles?.map((userRole) => userRole.role) ?? [] + this.firstName = dbUser.firstName + this.lastName = dbUser.lastName + this.deletedAt = dbUser.deletedAt + this.createdAt = dbUser.createdAt + this.language = dbUser.language + this.publisherId = dbUser.publisherId + this.roles = dbUser.userRoles?.map((userRole) => userRole.role) ?? [] this.klickTipp = null this.hasElopage = null - this.hideAmountGDD = user.hideAmountGDD - this.hideAmountGDT = user.hideAmountGDT - this.humhubAllowed = user.humhubAllowed - this.gmsAllowed = user.gmsAllowed - this.gmsPublishName = user.gmsPublishName - this.humhubPublishName = user.humhubPublishName - this.gmsPublishLocation = user.gmsPublishLocation - this.userLocation = user.location ? Point2Location(user.location as Point) : null + this.hideAmountGDD = dbUser.hideAmountGDD + this.hideAmountGDT = dbUser.hideAmountGDT + this.humhubAllowed = dbUser.humhubAllowed + this.gmsAllowed = dbUser.gmsAllowed + this.gmsPublishName = dbUser.gmsPublishName + this.humhubPublishName = dbUser.humhubPublishName + this.gmsPublishLocation = dbUser.gmsPublishLocation + this.userLocation = dbUser.location ? Point2Location(dbUser.location as Point) : null } } diff --git a/backend/src/graphql/resolver/TransactionLinkResolver.ts b/backend/src/graphql/resolver/TransactionLinkResolver.ts index 110be8966..0f375b387 100644 --- a/backend/src/graphql/resolver/TransactionLinkResolver.ts +++ b/backend/src/graphql/resolver/TransactionLinkResolver.ts @@ -1,5 +1,19 @@ import { randomBytes } from 'crypto' +import { Paginated } from '@arg/Paginated' +import { TransactionLinkArgs } from '@arg/TransactionLinkArgs' +import { TransactionLinkFilters } from '@arg/TransactionLinkFilters' +import { ContributionCycleType } from '@enum/ContributionCycleType' +import { ContributionStatus } from '@enum/ContributionStatus' +import { ContributionType } from '@enum/ContributionType' +import { TransactionTypeId } from '@enum/TransactionTypeId' +import { Community } from '@model/Community' +import { ContributionLink } from '@model/ContributionLink' +import { Decay } from '@model/Decay' +import { RedeemJwtLink } from '@model/RedeemJwtLink' +import { TransactionLink, TransactionLinkResult } from '@model/TransactionLink' +import { User } from '@model/User' +import { QueryLinkResult } from '@union/QueryLinkResult' import { Contribution as DbContribution, ContributionLink as DbContributionLink, @@ -11,20 +25,9 @@ import { Decimal } from 'decimal.js-light' import { Arg, Args, Authorized, Ctx, Int, Mutation, Query, Resolver } from 'type-graphql' import { getConnection } from 'typeorm' -import { Paginated } from '@arg/Paginated' -import { TransactionLinkArgs } from '@arg/TransactionLinkArgs' -import { TransactionLinkFilters } from '@arg/TransactionLinkFilters' -import { ContributionCycleType } from '@enum/ContributionCycleType' -import { ContributionStatus } from '@enum/ContributionStatus' -import { ContributionType } from '@enum/ContributionType' -import { TransactionTypeId } from '@enum/TransactionTypeId' -import { ContributionLink } from '@model/ContributionLink' -import { Decay } from '@model/Decay' -import { TransactionLink, TransactionLinkResult } from '@model/TransactionLink' -import { User } from '@model/User' -import { QueryLinkResult } from '@union/QueryLinkResult' - import { RIGHTS } from '@/auth/RIGHTS' +import { decode, encode, verify } from '@/auth/jwt/JWT' +import { RedeemJwtPayloadType } from '@/auth/jwt/payloadtypes/RedeemJwtPayloadType' import { EVENT_CONTRIBUTION_LINK_REDEEM, EVENT_TRANSACTION_LINK_CREATE, @@ -40,7 +43,13 @@ import { calculateDecay } from '@/util/decay' import { fullName } from '@/util/utilities' import { calculateBalance } from '@/util/validate' +import { DisburseJwtPayloadType } from '@/auth/jwt/payloadtypes/DisburseJwtPayloadType' import { executeTransaction } from './TransactionResolver' +import { + getAuthenticatedCommunities, + getCommunityByUuid, + getHomeCommunity, +} from './util/communities' import { getUserCreation, validateContribution } from './util/creations' import { getLastTransaction } from './util/getLastTransaction' import { sendTransactionsToDltConnector } from './util/sendTransactionsToDltConnector' @@ -138,6 +147,7 @@ export class TransactionLinkResolver { @Authorized([RIGHTS.QUERY_TRANSACTION_LINK]) @Query(() => QueryLinkResult) async queryTransactionLink(@Arg('code') code: string): Promise { + logger.debug('TransactionLinkResolver.queryTransactionLink... code=', code) if (code.match(/^CL-/)) { const contributionLink = await DbContributionLink.findOneOrFail({ where: { code: code.replace('CL-', '') }, @@ -145,18 +155,36 @@ export class TransactionLinkResolver { }) return new ContributionLink(contributionLink) } else { - const transactionLink = await DbTransactionLink.findOneOrFail({ - where: { code }, - withDeleted: true, - }) - const user = await DbUser.findOneOrFail({ where: { id: transactionLink.userId } }) - let redeemedBy: User | null = null - if (transactionLink?.redeemedBy) { - redeemedBy = new User( - await DbUser.findOneOrFail({ where: { id: transactionLink.redeemedBy } }), - ) + let txLinkFound = false + let dbTransactionLink!: DbTransactionLink + try { + dbTransactionLink = await DbTransactionLink.findOneOrFail({ + where: { code }, + withDeleted: true, + }) + txLinkFound = true + } catch (_err) { + txLinkFound = false + } + // normal redeem code + if (txLinkFound) { + logger.debug( + 'TransactionLinkResolver.queryTransactionLink... normal redeem code found=', + txLinkFound, + ) + const user = await DbUser.findOneOrFail({ where: { id: dbTransactionLink.userId } }) + let redeemedBy + if (dbTransactionLink.redeemedBy) { + redeemedBy = new User( + await DbUser.findOneOrFail({ where: { id: dbTransactionLink.redeemedBy } }), + ) + } + const communities = await getAuthenticatedCommunities() + return new TransactionLink(dbTransactionLink, new User(user), redeemedBy, communities) + } else { + // redeem jwt-token + return await this.queryRedeemJwtLink(code) } - return new TransactionLink(transactionLink, new User(user), redeemedBy) } } @@ -169,7 +197,6 @@ export class TransactionLinkResolver { const clientTimezoneOffset = getClientTimezoneOffset(context) // const homeCom = await DbCommunity.findOneOrFail({ where: { foreign: false } }) const user = getUser(context) - if (code.match(/^CL-/)) { // acquire lock const releaseLock = await TRANSACTIONS_LOCK.acquire() @@ -366,6 +393,104 @@ export class TransactionLinkResolver { } } + @Authorized([RIGHTS.QUERY_REDEEM_JWT]) + @Mutation(() => String) + async createRedeemJwt( + @Arg('gradidoId') gradidoId: string, + @Arg('senderCommunityUuid') senderCommunityUuid: string, + @Arg('senderCommunityName') senderCommunityName: string, + @Arg('recipientCommunityUuid') recipientCommunityUuid: string, + @Arg('code') code: string, + @Arg('amount') amount: string, + @Arg('memo') memo: string, + @Arg('firstName', { nullable: true }) firstName?: string, + @Arg('alias', { nullable: true }) alias?: string, + @Arg('validUntil', { nullable: true }) validUntil?: string, + ): Promise { + logger.debug('TransactionLinkResolver.queryRedeemJwt... args=', { + gradidoId, + senderCommunityUuid, + senderCommunityName, + recipientCommunityUuid, + code, + amount, + memo, + firstName, + alias, + validUntil, + }) + + const redeemJwtPayloadType = new RedeemJwtPayloadType( + senderCommunityUuid, + gradidoId, + alias ?? firstName ?? '', + code, + amount, + memo, + validUntil ?? '', + ) + // TODO:encode/sign the jwt normally with the private key of the sender/home community, but interims with uuid + const homeCom = await getHomeCommunity() + if (!homeCom.communityUuid) { + throw new LogError('Home community UUID is not set') + } + const redeemJwt = await encode(redeemJwtPayloadType, homeCom.communityUuid) + // TODO: encrypt the payload with the public key of the target community + return redeemJwt + } + + @Authorized([RIGHTS.DISBURSE_TRANSACTION_LINK]) + @Mutation(() => Boolean) + async disburseTransactionLink( + @Ctx() _context: Context, + @Arg('senderCommunityUuid') senderCommunityUuid: string, + @Arg('senderGradidoId') senderGradidoId: string, + @Arg('recipientCommunityUuid') recipientCommunityUuid: string, + @Arg('recipientCommunityName') recipientCommunityName: string, + @Arg('recipientGradidoId') recipientGradidoId: string, + @Arg('recipientFirstName') recipientFirstName: string, + @Arg('code') code: string, + @Arg('amount') amount: string, + @Arg('memo') memo: string, + @Arg('validUntil', { nullable: true }) validUntil?: string, + @Arg('recipientAlias', { nullable: true }) recipientAlias?: string, + ): Promise { + logger.debug('TransactionLinkResolver.disburseTransactionLink... args=', { + senderGradidoId, + senderCommunityUuid, + recipientCommunityUuid, + recipientCommunityName, + recipientGradidoId, + recipientFirstName, + code, + amount, + memo, + validUntil, + recipientAlias, + }) + const disburseJwt = await this.createDisburseJwt( + senderCommunityUuid, + senderGradidoId, + recipientCommunityUuid, + recipientCommunityName, + recipientGradidoId, + recipientFirstName, + code, + amount, + memo, + validUntil ?? '', + recipientAlias ?? '', + ) + try { + logger.debug('TransactionLinkResolver.disburseTransactionLink... disburseJwt=', disburseJwt) + // now send the disburseJwt to the sender community to invoke a x-community-tx to disbures the redeemLink + // await sendDisburseJwtToSenderCommunity(context, disburseJwt) + } catch (e) { + throw new LogError('Disburse JWT was not sent successfully', e) + } + return true + } + @Authorized([RIGHTS.LIST_TRANSACTION_LINKS]) @Query(() => TransactionLinkResult) async listTransactionLinks( @@ -400,4 +525,163 @@ export class TransactionLinkResolver { } return transactionLinkList(paginated, filters, user) } + + async queryRedeemJwtLink(code: string): Promise { + logger.debug('TransactionLinkResolver.queryRedeemJwtLink... redeem jwt-token found') + // decode token first to get the senderCommunityUuid as input for verify token + const decodedPayload = decode(code) + logger.debug('TransactionLinkResolver.queryRedeemJwtLink... decodedPayload=', decodedPayload) + if ( + decodedPayload != null && + decodedPayload.tokentype === RedeemJwtPayloadType.REDEEM_ACTIVATION_TYPE + ) { + const redeemJwtPayload = new RedeemJwtPayloadType( + decodedPayload.sendercommunityuuid as string, + decodedPayload.sendergradidoid as string, + decodedPayload.sendername as string, + decodedPayload.redeemcode as string, + decodedPayload.amount as string, + decodedPayload.memo as string, + decodedPayload.validuntil as string, + ) + logger.debug( + 'TransactionLinkResolver.queryRedeemJwtLink... redeemJwtPayload=', + redeemJwtPayload, + ) + const senderCom = await getCommunityByUuid(redeemJwtPayload.sendercommunityuuid) + if (!senderCom) { + throw new LogError('Sender community not found:', redeemJwtPayload.sendercommunityuuid) + } + logger.debug('TransactionLinkResolver.queryRedeemJwtLink... senderCom=', senderCom) + if (!senderCom.communityUuid) { + throw new LogError('Sender community UUID is not set') + } + // now with the sender community UUID the jwt token can be verified + const verifiedJwtPayload = await verify(code, senderCom.communityUuid) + logger.debug( + 'TransactionLinkResolver.queryRedeemJwtLink... nach verify verifiedJwtPayload=', + verifiedJwtPayload, + ) + let verifiedRedeemJwtPayload: RedeemJwtPayloadType | null = null + if (verifiedJwtPayload !== null) { + if (verifiedJwtPayload.exp !== undefined) { + const expDate = new Date(verifiedJwtPayload.exp * 1000) + logger.debug( + 'TransactionLinkResolver.queryRedeemJwtLink... expDate, exp =', + expDate, + verifiedJwtPayload.exp, + ) + if (expDate < new Date()) { + throw new LogError('Redeem JWT-Token expired! jwtPayload.exp=', expDate) + } + } + if (verifiedJwtPayload.tokentype === RedeemJwtPayloadType.REDEEM_ACTIVATION_TYPE) { + logger.debug( + 'TransactionLinkResolver.queryRedeemJwtLink... verifiedJwtPayload.tokentype=', + verifiedJwtPayload.tokentype, + ) + verifiedRedeemJwtPayload = new RedeemJwtPayloadType( + verifiedJwtPayload.sendercommunityuuid as string, + verifiedJwtPayload.sendergradidoid as string, + verifiedJwtPayload.sendername as string, + verifiedJwtPayload.redeemcode as string, + verifiedJwtPayload.amount as string, + verifiedJwtPayload.memo as string, + verifiedJwtPayload.validuntil as string, + ) + logger.debug( + 'TransactionLinkResolver.queryRedeemJwtLink... nach verify verifiedRedeemJwtPayload=', + verifiedRedeemJwtPayload, + ) + } + } + if (verifiedRedeemJwtPayload === null) { + logger.debug( + 'TransactionLinkResolver.queryRedeemJwtLink... verifiedRedeemJwtPayload===null', + ) + verifiedRedeemJwtPayload = new RedeemJwtPayloadType( + decodedPayload.sendercommunityuuid as string, + decodedPayload.sendergradidoid as string, + decodedPayload.sendername as string, + decodedPayload.redeemcode as string, + decodedPayload.amount as string, + decodedPayload.memo as string, + decodedPayload.validuntil as string, + ) + } else { + // TODO: as long as the verification fails, fallback to simply decoded payload + verifiedRedeemJwtPayload = redeemJwtPayload + logger.debug( + 'TransactionLinkResolver.queryRedeemJwtLink... fallback to decode verifiedRedeemJwtPayload=', + verifiedRedeemJwtPayload, + ) + } + const homeCommunity = await getHomeCommunity() + const recipientCommunity = new Community(homeCommunity) + const senderCommunity = new Community(senderCom) + const senderUser = new User(null) + senderUser.gradidoID = verifiedRedeemJwtPayload.sendergradidoid + senderUser.firstName = verifiedRedeemJwtPayload.sendername + const redeemJwtLink = new RedeemJwtLink( + verifiedRedeemJwtPayload, + senderCommunity, + senderUser, + recipientCommunity, + ) + logger.debug('TransactionLinkResolver.queryRedeemJwtLink... redeemJwtLink=', redeemJwtLink) + return redeemJwtLink + } else { + throw new LogError( + 'Redeem with wrong type of JWT-Token or expired! decodedPayload=', + decodedPayload, + ) + } + } + + async createDisburseJwt( + senderCommunityUuid: string, + senderGradidoId: string, + recipientCommunityUuid: string, + recipientCommunityName: string, + recipientGradidoId: string, + recipientFirstName: string, + code: string, + amount: string, + memo: string, + validUntil: string, + recipientAlias: string, + ): Promise { + logger.debug('TransactionLinkResolver.createDisburseJwt... args=', { + senderCommunityUuid, + senderGradidoId, + recipientCommunityUuid, + recipientCommunityName, + recipientGradidoId, + recipientFirstName, + code, + amount, + memo, + validUntil, + recipientAlias, + }) + + const disburseJwtPayloadType = new DisburseJwtPayloadType( + senderCommunityUuid, + senderGradidoId, + recipientCommunityUuid, + recipientCommunityName, + recipientGradidoId, + recipientFirstName, + code, + amount, + memo, + validUntil, + recipientAlias, + ) + // TODO:encode/sign the jwt normally with the private key of the recipient community, but interims with uuid + const disburseJwt = await encode(disburseJwtPayloadType, recipientCommunityUuid) + logger.debug('TransactionLinkResolver.createDisburseJwt... disburseJwt=', disburseJwt) + // TODO: encrypt the payload with the public key of the target/sender community + return disburseJwt + } } diff --git a/backend/src/graphql/resolver/util/communities.ts b/backend/src/graphql/resolver/util/communities.ts index 1461f093b..31189bebc 100644 --- a/backend/src/graphql/resolver/util/communities.ts +++ b/backend/src/graphql/resolver/util/communities.ts @@ -1,5 +1,5 @@ import { Community as DbCommunity, FederatedCommunity as DbFederatedCommunity } from 'database' -import { FindOneOptions } from 'typeorm' +import { FindOneOptions, IsNull, Not } from 'typeorm' import { Paginated } from '@arg/Paginated' @@ -86,6 +86,16 @@ export async function getCommunityByUuid(communityUuid: string): Promise { + const dbCommunities: DbCommunity[] = await DbCommunity.find({ + where: { communityUuid: Not(IsNull()) }, //, authenticatedAt: Not(IsNull()) }, + order: { + name: 'ASC', + }, + }) + return dbCommunities +} + export async function getCommunityByIdentifier( communityIdentifier: string, ): Promise { diff --git a/backend/src/graphql/resolver/util/syncHumhub.ts b/backend/src/graphql/resolver/util/syncHumhub.ts index 134fbe962..b483af1ce 100644 --- a/backend/src/graphql/resolver/util/syncHumhub.ts +++ b/backend/src/graphql/resolver/util/syncHumhub.ts @@ -4,7 +4,9 @@ import { HumHubClient } from '@/apis/humhub/HumHubClient' import { GetUser } from '@/apis/humhub/model/GetUser' import { PostUser } from '@/apis/humhub/model/PostUser' import { ExecutedHumhubAction, syncUser } from '@/apis/humhub/syncUser' +import { PublishNameLogic } from '@/data/PublishName.logic' import { UpdateUserInfosArgs } from '@/graphql/arg/UpdateUserInfosArgs' +import { PublishNameType } from '@/graphql/enum/PublishNameType' import { backendLogger as logger } from '@/server/logger' /** @@ -44,7 +46,9 @@ export async function syncHumhub( } const humhubUsers = new Map() if (humhubUser) { - humhubUsers.set(humhubUser.account.username, humhubUser) + const publishNameLogic = new PublishNameLogic(user) + const username = publishNameLogic.getUserIdentifier(user.humhubPublishName as PublishNameType) + humhubUsers.set(username, humhubUser) } logger.debug('update user at humhub') const result = await syncUser(user, humhubUsers) diff --git a/backend/src/graphql/union/QueryLinkResult.ts b/backend/src/graphql/union/QueryLinkResult.ts index fdf1c7b17..e506efdc6 100644 --- a/backend/src/graphql/union/QueryLinkResult.ts +++ b/backend/src/graphql/union/QueryLinkResult.ts @@ -1,9 +1,22 @@ import { createUnionType } from 'type-graphql' import { ContributionLink } from '@model/ContributionLink' +import { RedeemJwtLink } from '@model/RedeemJwtLink' import { TransactionLink } from '@model/TransactionLink' export const QueryLinkResult = createUnionType({ name: 'QueryLinkResult', // the name of the GraphQL union - types: () => [TransactionLink, ContributionLink] as const, // function that returns tuple of object types classes + types: () => [TransactionLink, RedeemJwtLink, ContributionLink] as const, // function that returns tuple of object types classes + resolveType: (value: TransactionLink | RedeemJwtLink | ContributionLink) => { + if (value instanceof TransactionLink) { + return TransactionLink + } + if (value instanceof RedeemJwtLink) { + return RedeemJwtLink + } + if (value instanceof ContributionLink) { + return ContributionLink + } + return null + }, }) diff --git a/docu/Concepts/TechnicalRequirements/image/redeemlink_without_community-switch.png b/docu/Concepts/TechnicalRequirements/image/redeemlink_without_community-switch.png new file mode 100644 index 000000000..a929d5ff7 Binary files /dev/null and b/docu/Concepts/TechnicalRequirements/image/redeemlink_without_community-switch.png differ diff --git a/frontend/src/components/CommunitySwitch.vue b/frontend/src/components/CommunitySwitch.vue index cd50789dd..8775e8667 100644 --- a/frontend/src/components/CommunitySwitch.vue +++ b/frontend/src/components/CommunitySwitch.vue @@ -46,6 +46,7 @@ const validCommunityIdentifier = ref(false) const { onResult } = useQuery(selectCommunities) onResult(({ data }) => { + // console.log('CommunitySwitch.onResult...data=', data) if (data) { communities.value = data.communities setDefaultCommunity() @@ -55,22 +56,42 @@ onResult(({ data }) => { const communityIdentifier = computed(() => route.params.communityIdentifier) function updateCommunity(community) { + // console.log('CommunitySwitch.updateCommunity...community=', community) emit('update:model-value', community) } function setDefaultCommunity() { + // console.log( + // 'CommunitySwitch.setDefaultCommunity... communityIdentifier= communities=', + // communityIdentifier, + // communities, + // ) if (communityIdentifier.value && communities.value.length >= 1) { + // console.log( + // 'CommunitySwitch.setDefaultCommunity... communities.value.length=', + // communities.value.length, + // ) const foundCommunity = communities.value.find((community) => { + // console.log('CommunitySwitch.setDefaultCommunity... community=', community) if ( community.uuid === communityIdentifier.value || community.name === communityIdentifier.value ) { validCommunityIdentifier.value = true + // console.log( + // 'CommunitySwitch.setDefaultCommunity...true validCommunityIdentifier=', + // validCommunityIdentifier, + // ) return true } + // console.log( + // 'CommunitySwitch.setDefaultCommunity...false validCommunityIdentifier=', + // validCommunityIdentifier, + // ) return false }) if (foundCommunity) { + // console.log('CommunitySwitch.setDefaultCommunity...foundCommunity=', foundCommunity) updateCommunity(foundCommunity) return } @@ -79,10 +100,20 @@ function setDefaultCommunity() { if (validCommunityIdentifier.value && !communityIdentifier.value) { validCommunityIdentifier.value = false + // console.log( + // 'CommunitySwitch.setDefaultCommunity...validCommunityIdentifier=', + // validCommunityIdentifier, + // ) } if (props.modelValue?.uuid === '' && communities.value.length) { + // console.log( + // 'CommunitySwitch.setDefaultCommunity...props.modelValue= communities=', + // props.modelValue, + // communities.value.length, + // ) const foundCommunity = communities.value.find((community) => !community.foreign) + // console.log('CommunitySwitch.setDefaultCommunity...foundCommunity=', foundCommunity) if (foundCommunity) { updateCommunity(foundCommunity) } diff --git a/frontend/src/components/LinkInformations/RedeemCommunitySelection.vue b/frontend/src/components/LinkInformations/RedeemCommunitySelection.vue new file mode 100644 index 000000000..46e9eb81f --- /dev/null +++ b/frontend/src/components/LinkInformations/RedeemCommunitySelection.vue @@ -0,0 +1,182 @@ + + diff --git a/frontend/src/components/LinkInformations/RedeemInformation.vue b/frontend/src/components/LinkInformations/RedeemInformation.vue index 6793240e2..c89c7c3e9 100644 --- a/frontend/src/components/LinkInformations/RedeemInformation.vue +++ b/frontend/src/components/LinkInformations/RedeemInformation.vue @@ -1,16 +1,20 @@ @@ -20,10 +24,9 @@ import CONFIG from '@/config' export default { name: 'RedeemInformation', props: { - user: { type: Object, required: false }, - amount: { type: String, required: true }, - memo: { type: String, required: true, default: '' }, + linkData: { type: Object, required: true }, isContributionLink: { type: Boolean, default: false }, + isRedeemJwtLink: { type: Boolean, default: false }, }, data() { return { diff --git a/frontend/src/components/LinkInformations/RedeemSelectCommunity.vue b/frontend/src/components/LinkInformations/RedeemSelectCommunity.vue new file mode 100644 index 000000000..f93e25a04 --- /dev/null +++ b/frontend/src/components/LinkInformations/RedeemSelectCommunity.vue @@ -0,0 +1,59 @@ + + + diff --git a/frontend/src/components/LinkInformations/RedeemValid.vue b/frontend/src/components/LinkInformations/RedeemValid.vue index d97de8b5f..aaf52d313 100644 --- a/frontend/src/components/LinkInformations/RedeemValid.vue +++ b/frontend/src/components/LinkInformations/RedeemValid.vue @@ -1,6 +1,11 @@