From e2db206e8c115e90b7af621374b7ffdc94bbd09d Mon Sep 17 00:00:00 2001 From: Zeeshan Akram <97m.zeeshan@gmail.com> Date: Mon, 18 Mar 2024 13:12:15 +0500 Subject: [PATCH 01/22] Add graphql schema changes --- schema/auth.graphql | 36 ++++++++++++++++++++++++------------ schema/membership.graphql | 10 +++++++++- 2 files changed, 33 insertions(+), 13 deletions(-) diff --git a/schema/auth.graphql b/schema/auth.graphql index b87d6c99a..cded40893 100644 --- a/schema/auth.graphql +++ b/schema/auth.graphql @@ -112,31 +112,20 @@ type Account @entity @schema(name: "admin") { "Gateway account's e-mail address" email: String! @unique - """ - Indicates whether the gateway account's e-mail has been confirmed or not. - """ - isEmailConfirmed: Boolean! - "Indicates whether the access to the gateway account is blocked" isBlocked: Boolean! "Time when the gateway account was registered" registeredAt: DateTime! - "On-chain membership associated with the gateway account" - membership: Membership! @unique - "Blockchain (joystream) account associated with the gateway account" - joystreamAccount: String! @unique + joystreamAccount: BlockchainAccount! @unique "runtime notifications" notifications: [Notification!]! @derivedFrom(field: "account") "notification preferences for the account" notificationPreferences: AccountNotificationPreferences! - - "ID of the channel which referred the user to the platform" - referrerChannelId: String } type AccountNotificationPreferences { @@ -214,3 +203,26 @@ type Token @entity @schema(name: "admin") { "The account the token was issued for" issuedFor: Account! } + +type EmailConfirmationToken @entity @schema(name: "admin") { + "The token itself (32-byte string, securely random)" + id: ID! + + "When was the token issued" + issuedAt: DateTime! + + "When does the token expire or when has it expired" + expiry: DateTime! + + # "The User the token was issued for" + # issuedFor: User! + + "The email the token was issued for" + email: String! + + # "Indicates whether the token has been confirmed or not" + # isConfirmed: Boolean! + + # "Time when the token was confirmed" + # confirmedAt: DateTime +} diff --git a/schema/membership.graphql b/schema/membership.graphql index 91d3d80ba..3e3bc0175 100644 --- a/schema/membership.graphql +++ b/schema/membership.graphql @@ -8,6 +8,14 @@ type AvatarUri @variant { avatarUri: String! } +type BlockchainAccount @entity { + "The blockchain account id/address" + id: ID! + + "Membership associated with the blockchain account (controllerAccount)" + memberships: [Membership!] @derivedFrom(field: "controllerAccount") +} + union Avatar = AvatarObject | AvatarUri type MemberMetadata @entity { @@ -41,7 +49,7 @@ type Membership @entity { metadata: MemberMetadata @derivedFrom(field: "member") "Member's controller account id" - controllerAccount: String! + controllerAccount: BlockchainAccount! "Auctions in which is this user whitelisted to participate" whitelistedInAuctions: [AuctionWhitelistedMember!] @derivedFrom(field: "member") From 9289462c867170e51738df87e4509829a910a25f Mon Sep 17 00:00:00 2001 From: Zeeshan Akram <97m.zeeshan@gmail.com> Date: Mon, 18 Mar 2024 13:14:29 +0500 Subject: [PATCH 02/22] regenerate DB migrations --- db/migrations/1710699134312-Data.js | 49 +++++++++++++++++++ ...962433-Views.js => 1710699134452-Views.js} | 4 +- 2 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 db/migrations/1710699134312-Data.js rename db/migrations/{1709641962433-Views.js => 1710699134452-Views.js} (91%) diff --git a/db/migrations/1710699134312-Data.js b/db/migrations/1710699134312-Data.js new file mode 100644 index 000000000..e6b1a772a --- /dev/null +++ b/db/migrations/1710699134312-Data.js @@ -0,0 +1,49 @@ +module.exports = class Data1710699134312 { + name = 'Data1710699134312' + + async up(db) { + await db.query(`ALTER TABLE "admin"."account" DROP CONSTRAINT "FK_601b93655bcbe73cb58d8c80cd3"`) + await db.query(`DROP INDEX "admin"."IDX_601b93655bcbe73cb58d8c80cd"`) + await db.query(`DROP INDEX "admin"."IDX_df4da05a7a80c1afd18b8f0990"`) + await db.query(`ALTER TABLE "membership" RENAME COLUMN "controller_account" TO "controller_account_id"`) + await db.query(`CREATE TABLE "blockchain_account" ("id" character varying NOT NULL, CONSTRAINT "PK_3d07d692a436bc34ef4093d9c60" PRIMARY KEY ("id"))`) + await db.query(`CREATE TABLE "admin"."email_confirmation_token" ("id" character varying NOT NULL, "issued_at" TIMESTAMP WITH TIME ZONE NOT NULL, "expiry" TIMESTAMP WITH TIME ZONE NOT NULL, "email" text NOT NULL, CONSTRAINT "PK_2fa8d5586af7e96201b84492131" PRIMARY KEY ("id"))`) + await db.query(`ALTER TABLE "admin"."account" DROP COLUMN "is_email_confirmed"`) + await db.query(`ALTER TABLE "admin"."account" DROP CONSTRAINT "Account_membership"`) + await db.query(`ALTER TABLE "admin"."account" DROP COLUMN "membership_id"`) + await db.query(`ALTER TABLE "admin"."account" DROP CONSTRAINT "Account_joystreamAccount"`) + await db.query(`ALTER TABLE "admin"."account" DROP COLUMN "joystream_account"`) + await db.query(`ALTER TABLE "admin"."account" DROP COLUMN "referrer_channel_id"`) + await db.query(`ALTER TABLE "admin"."account" ADD "joystream_account_id" character varying NOT NULL`) + await db.query(`ALTER TABLE "admin"."account" ADD CONSTRAINT "UQ_90debbc4217372d2464201c576a" UNIQUE ("joystream_account_id")`) + await db.query(`ALTER TABLE "membership" DROP COLUMN "controller_account_id"`) + await db.query(`ALTER TABLE "membership" ADD "controller_account_id" character varying`) + await db.query(`CREATE INDEX "IDX_58492b909a36e6a3e4dabd4674" ON "membership" ("controller_account_id") `) + await db.query(`CREATE INDEX "IDX_90debbc4217372d2464201c576" ON "admin"."account" ("joystream_account_id") `) + await db.query(`ALTER TABLE "membership" ADD CONSTRAINT "FK_58492b909a36e6a3e4dabd46743" FOREIGN KEY ("controller_account_id") REFERENCES "blockchain_account"("id") ON DELETE NO ACTION ON UPDATE NO ACTION DEFERRABLE INITIALLY DEFERRED`) + await db.query(`ALTER TABLE "admin"."account" ADD CONSTRAINT "FK_90debbc4217372d2464201c576a" FOREIGN KEY ("joystream_account_id") REFERENCES "blockchain_account"("id") ON DELETE NO ACTION ON UPDATE NO ACTION DEFERRABLE INITIALLY DEFERRED`) + } + + async down(db) { + await db.query(`ALTER TABLE "admin"."account" ADD CONSTRAINT "FK_601b93655bcbe73cb58d8c80cd3" FOREIGN KEY ("membership_id") REFERENCES "membership"("id") ON DELETE NO ACTION ON UPDATE NO ACTION DEFERRABLE INITIALLY DEFERRED`) + await db.query(`CREATE INDEX "IDX_601b93655bcbe73cb58d8c80cd" ON "admin"."account" ("membership_id") `) + await db.query(`CREATE INDEX "IDX_df4da05a7a80c1afd18b8f0990" ON "admin"."account" ("joystream_account") `) + await db.query(`ALTER TABLE "membership" RENAME COLUMN "controller_account_id" TO "controller_account"`) + await db.query(`DROP TABLE "blockchain_account"`) + await db.query(`DROP TABLE "admin"."email_confirmation_token"`) + await db.query(`ALTER TABLE "admin"."account" ADD "is_email_confirmed" boolean NOT NULL`) + await db.query(`ALTER TABLE "admin"."account" ADD CONSTRAINT "Account_membership" UNIQUE ("membership_id")`) + await db.query(`ALTER TABLE "admin"."account" ADD "membership_id" character varying NOT NULL`) + await db.query(`ALTER TABLE "admin"."account" ADD CONSTRAINT "Account_joystreamAccount" UNIQUE ("joystream_account")`) + await db.query(`ALTER TABLE "admin"."account" ADD "joystream_account" text NOT NULL`) + await db.query(`ALTER TABLE "admin"."account" ADD "referrer_channel_id" text`) + await db.query(`ALTER TABLE "admin"."account" DROP COLUMN "joystream_account_id"`) + await db.query(`ALTER TABLE "admin"."account" DROP CONSTRAINT "UQ_90debbc4217372d2464201c576a"`) + await db.query(`ALTER TABLE "membership" ADD "controller_account_id" text NOT NULL`) + await db.query(`ALTER TABLE "membership" DROP COLUMN "controller_account_id"`) + await db.query(`DROP INDEX "public"."IDX_58492b909a36e6a3e4dabd4674"`) + await db.query(`DROP INDEX "admin"."IDX_90debbc4217372d2464201c576"`) + await db.query(`ALTER TABLE "membership" DROP CONSTRAINT "FK_58492b909a36e6a3e4dabd46743"`) + await db.query(`ALTER TABLE "admin"."account" DROP CONSTRAINT "FK_90debbc4217372d2464201c576a"`) + } +} diff --git a/db/migrations/1709641962433-Views.js b/db/migrations/1710699134452-Views.js similarity index 91% rename from db/migrations/1709641962433-Views.js rename to db/migrations/1710699134452-Views.js index 247ab4dca..7243d2547 100644 --- a/db/migrations/1709641962433-Views.js +++ b/db/migrations/1710699134452-Views.js @@ -1,8 +1,8 @@ const { getViewDefinitions } = require('../viewDefinitions') -module.exports = class Views1709641962433 { - name = 'Views1709641962433' +module.exports = class Views1710699134452 { + name = 'Views1710699134452' async up(db) { const viewDefinitions = getViewDefinitions(db); From 1cd57ba28a5672d6fee2b26df997b4912096fd00 Mon Sep 17 00:00:00 2001 From: Zeeshan Akram <97m.zeeshan@gmail.com> Date: Mon, 18 Mar 2024 13:16:30 +0500 Subject: [PATCH 03/22] opdate openapi.yaml file --- docker.env | 1 + src/auth-server/openapi.yml | 53 +++++-------------------------------- 2 files changed, 7 insertions(+), 47 deletions(-) diff --git a/docker.env b/docker.env index 3b0da8318..caebf25d0 100644 --- a/docker.env +++ b/docker.env @@ -7,3 +7,4 @@ DB_HOST=orion_db PROCESSOR_HOST=orion_processor # Archive gateway host&port (can be overriden via local env) ARCHIVE_GATEWAY_URL=${CUSTOM_ARCHIVE_GATEWAY_URL:-http://orion_archive_gateway:8000/graphql} +# ARCHIVE_GATEWAY_URL=${CUSTOM_ARCHIVE_GATEWAY_URL:-https://archive.joystream.org/graphql} diff --git a/src/auth-server/openapi.yml b/src/auth-server/openapi.yml index 2e95976ac..3345da9ba 100644 --- a/src/auth-server/openapi.yml +++ b/src/auth-server/openapi.yml @@ -140,28 +140,15 @@ paths: $ref: '#/components/responses/GenericTooManyRequestsResponse' default: $ref: '#/components/responses/GenericInternalServerErrorResponse' - /confirm-email: - post: - operationId: confirmEmail - x-eov-operation-handler: confirmEmail - description: Confirm account's e-mail address provided during registration. - requestBody: - $ref: '#/components/requestBodies/ConfirmEmailRequestBody' - responses: - '200': - $ref: '#/components/responses/GenericOkResponse' - '400': - $ref: '#/components/responses/ConfirmEmailBadRequestResponse' - '429': - $ref: '#/components/responses/GenericTooManyRequestsResponse' - default: - $ref: '#/components/responses/GenericInternalServerErrorResponse' /request-email-confirmation-token: post: operationId: requestEmailConfirmationToken x-eov-operation-handler: requestEmailConfirmationToken - description: Request a token to be sent to account's e-mail address, + description: + Request a token to be sent to e-mail address (as the first step of signup process), which will allow confirming the ownership of the e-mail by the user. + security: + - cookieAuth: [] requestBody: $ref: '#/components/requestBodies/RequestTokenRequestBody' responses: @@ -169,8 +156,6 @@ paths: $ref: '#/components/responses/GenericOkResponse' '400': $ref: '#/components/responses/RequestTokenBadRequestResponse' - '404': - $ref: '#/components/responses/RequestEmailConfirmationAccountNotFoundResponse' '429': $ref: '#/components/responses/RequestTokenTooManyRequestsResponse' default: @@ -240,11 +225,6 @@ components: application/json: schema: $ref: '#/components/schemas/CreateAccountRequestData' - ConfirmEmailRequestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/ConfirmEmailRequestData' RequestTokenRequestBody: content: application/json: @@ -309,12 +289,6 @@ components: application/json: schema: $ref: '#/components/schemas/GenericOkResponseData' - ConfirmEmailBadRequestResponse: - description: Missing token or provided token is invalid / already used. - content: - application/json: - schema: - $ref: '#/components/schemas/GenericErrorResponseData' GenericUnauthorizedResponse: description: Access token (session id) is missing or invalid. content: @@ -339,12 +313,6 @@ components: application/json: schema: $ref: '#/components/schemas/GenericErrorResponseData' - RequestEmailConfirmationAccountNotFoundResponse: - description: Provided e-mail address is not associated with any account. - content: - application/json: - schema: - $ref: '#/components/schemas/GenericErrorResponseData' GetArtifactsResponse: description: Encryption artifacts found and provided in the response. content: @@ -461,18 +429,17 @@ components: - type: object required: - email - - memberId properties: action: type: string enum: - createAccount - memberId: - type: string email: type: string pattern: ^\S+@\S+\.\S+$ maxLength: 255 + emailConfirmationToken: + type: string encryptionArtifacts: $ref: '#/components/schemas/EncryptionArtifacts' ChangeAccountRequestData: @@ -495,14 +462,6 @@ components: type: string newArtifacts: $ref: '#/components/schemas/EncryptionArtifacts' - ConfirmEmailRequestData: - type: object - required: - - token - properties: - token: - type: string - description: Confirmation token recieved by the user via an e-mail. RequestTokenRequestData: type: object required: From 6324de81f6173fa25ab3b8f13521629930353b81 Mon Sep 17 00:00:00 2001 From: Zeeshan Akram <97m.zeeshan@gmail.com> Date: Mon, 18 Mar 2024 13:17:26 +0500 Subject: [PATCH 04/22] updated authHandler function --- src/auth-server/index.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/auth-server/index.ts b/src/auth-server/index.ts index 991a88ceb..83bb415d4 100644 --- a/src/auth-server/index.ts +++ b/src/auth-server/index.ts @@ -1,16 +1,16 @@ -import express from 'express' +import { createLogger } from '@subsquid/logger' +import cookieParser from 'cookie-parser' import cors from 'cors' +import express from 'express' import * as OpenApiValidator from 'express-openapi-validator' import { HttpError } from 'express-openapi-validator/dist/framework/types' +import fs from 'fs' +import YAML from 'js-yaml' import path from 'path' -import { AuthApiError } from './errors' -import { createLogger } from '@subsquid/logger' +import swaggerUi, { JsonObject } from 'swagger-ui-express' import { authenticate, getCorsOrigin } from '../utils/auth' -import cookieParser from 'cookie-parser' +import { AuthApiError } from './errors' import { applyRateLimits, globalRateLimit, rateLimitsPerRoute } from './rateLimits' -import swaggerUi, { JsonObject } from 'swagger-ui-express' -import YAML from 'js-yaml' -import fs from 'fs' export const logger = createLogger('auth-api') @@ -22,7 +22,7 @@ function authHandler(type: 'header' | 'cookie') { if (req.res) { req.res.locals.authContext = authContext } - return true + return !!authContext } } From 65f5e201973c4663bf3ce6814ac817d3ea8633d6 Mon Sep 17 00:00:00 2001 From: Zeeshan Akram <97m.zeeshan@gmail.com> Date: Mon, 18 Mar 2024 13:19:29 +0500 Subject: [PATCH 05/22] remove /confirm-email auth api endpoint --- src/auth-server/handlers/confirmEmail.ts | 47 ------------------------ src/auth-server/rateLimits.ts | 8 +--- 2 files changed, 1 insertion(+), 54 deletions(-) delete mode 100644 src/auth-server/handlers/confirmEmail.ts diff --git a/src/auth-server/handlers/confirmEmail.ts b/src/auth-server/handlers/confirmEmail.ts deleted file mode 100644 index 6d25dfeb8..000000000 --- a/src/auth-server/handlers/confirmEmail.ts +++ /dev/null @@ -1,47 +0,0 @@ -import express from 'express' -import { BadRequestError } from '../errors' -import { components } from '../generated/api-types' -import { globalEm } from '../../utils/globalEm' -import { Token } from '../../model' -import { MoreThan } from 'typeorm' - -type ReqParams = Record -type ResBody = - | components['schemas']['GenericOkResponseData'] - | components['schemas']['GenericErrorResponseData'] -type ReqBody = components['schemas']['ConfirmEmailRequestData'] - -export const confirmEmail: ( - req: express.Request, - res: express.Response, - next: express.NextFunction -) => Promise = async (req, res, next) => { - try { - const { token: tokenId } = req.body - const em = await globalEm - - await em.transaction(async (em) => { - const token = await em.getRepository(Token).findOne({ - where: { id: tokenId, expiry: MoreThan(new Date()) }, - relations: { issuedFor: true }, - }) - - if (!token) { - throw new BadRequestError('Token not found. Possibly expired or already used.') - } - - if (token.issuedFor.isEmailConfirmed) { - throw new BadRequestError('Email already confirmed') - } - - const account = token.issuedFor - account.isEmailConfirmed = true - token.expiry = new Date() - await em.save([account, token]) - }) - - res.status(200).json({ success: true }) - } catch (e) { - next(e) - } -} diff --git a/src/auth-server/rateLimits.ts b/src/auth-server/rateLimits.ts index 67bf818d1..e4f83c83f 100644 --- a/src/auth-server/rateLimits.ts +++ b/src/auth-server/rateLimits.ts @@ -1,6 +1,6 @@ +import { Express } from 'express' import rateLimit, { Options as RateLimitOptions, RateLimitRequestHandler } from 'express-rate-limit' import { paths } from './generated/api-types' -import { Express } from 'express' const defaultRateLimitOptions: Partial = { standardHeaders: true, @@ -69,12 +69,6 @@ export const rateLimitsPerRoute: RateLimitsPerRoute = { limit: 10, }, }, - '/confirm-email': { - post: { - windowMinutes: 5, - limit: 10, - }, - }, } const limiters: RateLimitRequestHandler[] = [] From 42f9b10089642edc3bf22d7d34f0e8d54845f634 Mon Sep 17 00:00:00 2001 From: Zeeshan Akram <97m.zeeshan@gmail.com> Date: Mon, 18 Mar 2024 13:20:17 +0500 Subject: [PATCH 06/22] [auth-api] update /change-account endpoint implementation --- src/auth-server/handlers/changeAccount.ts | 35 ++++++++++++----------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/src/auth-server/handlers/changeAccount.ts b/src/auth-server/handlers/changeAccount.ts index 22dccb26d..6e1d689f4 100644 --- a/src/auth-server/handlers/changeAccount.ts +++ b/src/auth-server/handlers/changeAccount.ts @@ -1,9 +1,8 @@ import express from 'express' -import { components } from '../generated/api-types' -import { UnauthorizedError, BadRequestError, ConflictError } from '../errors' -import { AuthContext } from '../../utils/auth' +import { Account, BlockchainAccount, EncryptionArtifacts, Session } from '../../model' import { globalEm } from '../../utils/globalEm' -import { Account, EncryptionArtifacts } from '../../model' +import { BadRequestError, ConflictError, UnauthorizedError } from '../errors' +import { components } from '../generated/api-types' import { verifyActionExecutionRequest } from '../utils' type ReqParams = Record @@ -11,7 +10,7 @@ type ResBody = | components['schemas']['GenericOkResponseData'] | components['schemas']['GenericErrorResponseData'] type ReqBody = components['schemas']['ChangeAccountRequestData'] -type ResLocals = { authContext: AuthContext } +type ResLocals = { authContext: Session } export const changeAccount: ( req: express.Request, @@ -19,14 +18,10 @@ export const changeAccount: ( next: express.NextFunction ) => Promise = async (req, res, next) => { try { - const { - body: { payload }, - } = req - const { - locals: { authContext }, - } = res + const { payload } = req.body + const { authContext } = res.locals - if (!authContext?.account) { + if (!authContext.account) { throw new UnauthorizedError() } @@ -41,7 +36,7 @@ export const changeAccount: ( await em.transaction(async (em) => { const existingGatewayAccount = await em.findOne(Account, { - where: { joystreamAccount: payload.joystreamAccountId }, + where: { joystreamAccountId: payload.joystreamAccountId }, }) if (existingGatewayAccount && existingGatewayAccount.id !== account.id) { @@ -50,15 +45,23 @@ export const changeAccount: ( ) } - // Update the assigned blockchain account - await em.update(Account, { id: account.id }, { joystreamAccount: payload.joystreamAccountId }) + // Create the given blockchain account if it doesn't exist + await em.upsert(BlockchainAccount, { id: payload.joystreamAccountId }, ['id']) + + await em.update( + Account, + { id: account.id }, + { joystreamAccountId: payload.joystreamAccountId } + ) + + console.log('after update') // Remove the old encryption artifacts await em.delete(EncryptionArtifacts, { accountId: account.id }) // Optionally save new encryption artifacts if (payload.newArtifacts) { - // We don't check if artifacts already exist by this id, becasue that opens up + // We don't check if artifacts already exist by this id, because that opens up // a brute-force attack vector. Instead, in this case the existing artifacts will // be overwritten. await em.save(EncryptionArtifacts, { From 6c8de7a845c38678fdea482f5d7fa6197ba26779 Mon Sep 17 00:00:00 2001 From: Zeeshan Akram <97m.zeeshan@gmail.com> Date: Mon, 18 Mar 2024 13:21:46 +0500 Subject: [PATCH 07/22] [auth-api] update create account endpoint --- src/auth-server/handlers/createAccount.ts | 71 ++++++++++--------- .../handlers/getSessionArtifacts.ts | 10 +-- src/auth-server/handlers/login.ts | 6 +- src/auth-server/handlers/logout.ts | 6 +- .../handlers/postSessionArtifacts.ts | 9 ++- 5 files changed, 51 insertions(+), 51 deletions(-) diff --git a/src/auth-server/handlers/createAccount.ts b/src/auth-server/handlers/createAccount.ts index 096927fa3..18b035158 100644 --- a/src/auth-server/handlers/createAccount.ts +++ b/src/auth-server/handlers/createAccount.ts @@ -1,6 +1,13 @@ import express from 'express' -import { Account, EncryptionArtifacts, Membership, NextEntityId } from '../../model' -import { AuthContext } from '../../utils/auth' +import { MoreThan } from 'typeorm' +import { + Account, + BlockchainAccount, + EmailConfirmationToken, + EncryptionArtifacts, + NextEntityId, + Session, +} from '../../model' import { globalEm } from '../../utils/globalEm' import { idStringFromNumber } from '../../utils/misc' import { defaultNotificationPreferences } from '../../utils/notification/helpers' @@ -13,7 +20,7 @@ type ResBody = | components['schemas']['GenericOkResponseData'] | components['schemas']['GenericErrorResponseData'] type ReqBody = components['schemas']['CreateAccountRequestData'] -type ResLocals = { authContext: AuthContext } +type ResLocals = { authContext: Session } export const createAccount: ( req: express.Request, @@ -22,15 +29,23 @@ export const createAccount: ( ) => Promise = async (req, res, next) => { try { const { - payload: { email, memberId, joystreamAccountId }, + payload: { email, emailConfirmationToken, joystreamAccountId }, } = req.body const { authContext } = res.locals const em = await globalEm - if (authContext?.account) { + if (authContext.account) { throw new BadRequestError('Already logged in to an account.') } + const token = await em.getRepository(EmailConfirmationToken).findOne({ + where: { id: emailConfirmationToken, email, expiry: MoreThan(new Date()) }, + }) + + if (!token) { + throw new NotFoundError('Token not found. Possibly expired or already used.') + } + await verifyActionExecutionRequest(em, req.body) await em.transaction(async (em) => { @@ -45,55 +60,43 @@ export const createAccount: ( )?.nextId.toString() || '1' ) - const existingByEmail = await em.getRepository(Account).findOneBy({ email }) - if (existingByEmail) { - throw new ConflictError('Account with the provided e-mail address already exists.') - } + // TODO: Don't reveal whether an account with the given email exists, to prevent email enumeration attacks + // ! Not needed, as the token is already checked for existence, and is not expired + // const existingByEmail = await em.getRepository(Account).findOneBy({ email }) + // if (existingByEmail) { + // throw new ConflictError('Account with the provided e-mail address already exists.') + // } - const existingByMemberId = await em - .getRepository(Account) - .findOneBy({ membershipId: memberId }) - if (existingByMemberId) { - throw new ConflictError('Account with the provided member id already exists.') - } + // Create the given blockchain account if it doesn't exist + await em.upsert(BlockchainAccount, { id: joystreamAccountId }, ['id']) const existingByJoystreamAccountId = await em .getRepository(Account) - .findOneBy({ joystreamAccount: joystreamAccountId }) + .findOneBy({ joystreamAccountId }) if (existingByJoystreamAccountId) { throw new ConflictError( 'Account with the provided joystream account address already exists.' ) } - const membership = await em.getRepository(Membership).findOneBy({ id: memberId }) - if (!membership) { - throw new NotFoundError(`Membership not found by id: ${memberId}`) - } - - if (membership.controllerAccount !== joystreamAccountId) { - throw new BadRequestError( - `Provided joystream account address doesn't match the controller account of the provided membership.` - ) - } - - const notificationPreferences = defaultNotificationPreferences() + // Create the account const account = new Account({ id: idStringFromNumber(nextAccountId), email, - isEmailConfirmed: false, registeredAt: new Date(), isBlocked: false, - userId: authContext?.user.id, - joystreamAccount: joystreamAccountId, - membershipId: memberId.toString(), - notificationPreferences, - referrerChannelId: null, + userId: authContext.user.id, + joystreamAccountId: joystreamAccountId, + notificationPreferences: defaultNotificationPreferences(), }) + // Mark the token as used + token.expiry = new Date() + await em.save([ account, new NextEntityId({ entityName: 'Account', nextId: nextAccountId + 1 }), + token, ]) if (req.body.payload.encryptionArtifacts) { diff --git a/src/auth-server/handlers/getSessionArtifacts.ts b/src/auth-server/handlers/getSessionArtifacts.ts index 2ba3fe832..729ce92c8 100644 --- a/src/auth-server/handlers/getSessionArtifacts.ts +++ b/src/auth-server/handlers/getSessionArtifacts.ts @@ -1,15 +1,15 @@ import express from 'express' -import { NotFoundError, UnauthorizedError } from '../errors' +import { Session, SessionEncryptionArtifacts } from '../../model' import { globalEm } from '../../utils/globalEm' -import { SessionEncryptionArtifacts } from '../../model' +import { NotFoundError, UnauthorizedError } from '../errors' import { components } from '../generated/api-types' -import { AuthContext } from '../../utils/auth' +// TODO: ensure that encryption artifacts for expired sessions are removed type ReqParams = Record type ResBody = | components['schemas']['SessionEncryptionArtifacts'] | components['schemas']['GenericErrorResponseData'] -type ResLocals = { authContext: AuthContext } +type ResLocals = { authContext: Session } export const getSessionArtifacts: ( req: express.Request, @@ -19,7 +19,7 @@ export const getSessionArtifacts: ( try { const em = await globalEm const { authContext: session } = res.locals - if (!session?.account) { + if (!session.account) { throw new UnauthorizedError('Cannot get session artifacts for anonymous session') } const artifacts = await em diff --git a/src/auth-server/handlers/login.ts b/src/auth-server/handlers/login.ts index 723f9c9fe..902448652 100644 --- a/src/auth-server/handlers/login.ts +++ b/src/auth-server/handlers/login.ts @@ -1,9 +1,9 @@ import express from 'express' import { Account } from '../../model' +import { getOrCreateSession, setSessionCookie } from '../../utils/auth' import { globalEm } from '../../utils/globalEm' import { UnauthorizedError } from '../errors' import { components } from '../generated/api-types' -import { getOrCreateSession, setSessionCookie } from '../../utils/auth' import { verifyActionExecutionRequest } from '../utils' type ReqParams = Record @@ -26,9 +26,7 @@ export const login: ( await verifyActionExecutionRequest(em, req.body) const [sessionData, account] = await em.transaction(async (em) => { - const account = await em - .getRepository(Account) - .findOneBy({ joystreamAccount: joystreamAccountId }) + const account = await em.getRepository(Account).findOneBy({ joystreamAccountId }) if (!account) { throw new UnauthorizedError('Invalid credentials') } diff --git a/src/auth-server/handlers/logout.ts b/src/auth-server/handlers/logout.ts index b7c42f41c..f19cc7ac5 100644 --- a/src/auth-server/handlers/logout.ts +++ b/src/auth-server/handlers/logout.ts @@ -1,14 +1,14 @@ import express from 'express' -import { AuthContext } from '../../utils/auth' +import { Session } from '../../model' import { globalEm } from '../../utils/globalEm' -import { components } from '../generated/api-types' import { BadRequestError } from '../errors' +import { components } from '../generated/api-types' type ReqParams = Record type ResBody = | components['schemas']['GenericOkResponseData'] | components['schemas']['GenericErrorResponseData'] -type ResLocals = { authContext: AuthContext } +type ResLocals = { authContext: Session } export const logout: ( req: express.Request, diff --git a/src/auth-server/handlers/postSessionArtifacts.ts b/src/auth-server/handlers/postSessionArtifacts.ts index dd9bd9d4f..b824249e7 100644 --- a/src/auth-server/handlers/postSessionArtifacts.ts +++ b/src/auth-server/handlers/postSessionArtifacts.ts @@ -1,17 +1,16 @@ import express from 'express' -import { AuthContext } from '../../utils/auth' -import { globalEm } from '../../utils/globalEm' -import { components } from '../generated/api-types' -import { SessionEncryptionArtifacts } from '../../model' +import { Session, SessionEncryptionArtifacts } from '../../model' import { uniqueId } from '../../utils/crypto' +import { globalEm } from '../../utils/globalEm' import { ConflictError, UnauthorizedError } from '../errors' +import { components } from '../generated/api-types' type ReqParams = Record type ReqBody = components['schemas']['SessionEncryptionArtifacts'] type ResBody = | components['schemas']['GenericOkResponseData'] | components['schemas']['GenericErrorResponseData'] -type ResLocals = { authContext: AuthContext } +type ResLocals = { authContext: Session } export const postSessionArtifacts: ( req: express.Request, From 8b441ecebec955c5ee52d397afff6b103fee95de Mon Sep 17 00:00:00 2001 From: Zeeshan Akram <97m.zeeshan@gmail.com> Date: Mon, 18 Mar 2024 13:22:25 +0500 Subject: [PATCH 08/22] [auth-api] update /request-email-confirmation-token endpoint --- .../handlers/requestEmailConfirmationToken.ts | 49 +++++++++---------- 1 file changed, 22 insertions(+), 27 deletions(-) diff --git a/src/auth-server/handlers/requestEmailConfirmationToken.ts b/src/auth-server/handlers/requestEmailConfirmationToken.ts index ee00e628e..f07d99d22 100644 --- a/src/auth-server/handlers/requestEmailConfirmationToken.ts +++ b/src/auth-server/handlers/requestEmailConfirmationToken.ts @@ -1,25 +1,31 @@ import express from 'express' -import { NotFoundError, TooManyRequestsError, BadRequestError } from '../errors' -import { components } from '../generated/api-types' -import { globalEm } from '../../utils/globalEm' -import { Account, Token, TokenType } from '../../model' import { MoreThan } from 'typeorm' +import { Account, EmailConfirmationToken, Session } from '../../model' import { ConfigVariable, config } from '../../utils/config' -import { sendWelcomeEmail } from '../utils' +import { globalEm } from '../../utils/globalEm' +import { BadRequestError, TooManyRequestsError } from '../errors' +import { components } from '../generated/api-types' +import { sendLoginOrChangePasswordEmail, sendWelcomeEmail } from '../utils' type ReqParams = Record type ResBody = | components['schemas']['GenericOkResponseData'] | components['schemas']['GenericErrorResponseData'] type ReqBody = components['schemas']['RequestTokenRequestData'] +type ResLocals = { authContext: Session } export const requestEmailConfirmationToken: ( req: express.Request, - res: express.Response, + res: express.Response, next: express.NextFunction ) => Promise = async (req, res, next) => { try { const { email } = req.body + const { authContext } = res.locals + + if (authContext.account) { + throw new BadRequestError('Already logged in to an account.') + } const em = await globalEm @@ -38,18 +44,9 @@ export const requestEmailConfirmationToken: ( lock: { mode: 'pessimistic_write' }, }) - if (!account) { - throw new NotFoundError('Account not found') - } - - if (account.isEmailConfirmed) { - throw new BadRequestError('Email already confirmed') - } - - const tokensInTimeframeCount = await em.getRepository(Token).count({ + const tokensInTimeframeCount = await em.getRepository(EmailConfirmationToken).count({ where: { - issuedForId: account.id, - type: TokenType.EMAIL_CONFIRMATION, + email, issuedAt: MoreThan( new Date(Date.now() - emailConfirmationTokenExpiryTimeHours * 3600 * 1000) ), @@ -60,17 +57,15 @@ export const requestEmailConfirmationToken: ( throw new TooManyRequestsError() } - // Deactivate all currently active email confirmation tokens for this account - await em.getRepository(Token).update( - { - issuedForId: account.id, - type: TokenType.EMAIL_CONFIRMATION, - expiry: MoreThan(new Date()), - }, - { expiry: new Date() } - ) + // Deactivate all currently active email confirmation tokens for this email + await em + .getRepository(EmailConfirmationToken) + .update({ email, expiry: MoreThan(new Date()) }, { expiry: new Date() }) - await sendWelcomeEmail(account, em) + // Send email with verification token if Gateway account does not exist. + // Otherwise, send email to asking user to login/change password. but + // the API response should be the same to avoid email enumeration attack. + account ? await sendLoginOrChangePasswordEmail(email, em) : await sendWelcomeEmail(email, em) }) res.status(200).json({ success: true }) From 4bd1731e2bdb9ce03d6735c353c44fa5a5869ab6 Mon Sep 17 00:00:00 2001 From: Zeeshan Akram <97m.zeeshan@gmail.com> Date: Mon, 18 Mar 2024 13:23:12 +0500 Subject: [PATCH 09/22] update mappings due to schema changes --- src/mappings/content/nft.ts | 14 ++++++++---- src/mappings/content/utils.ts | 20 ++++++++++++----- src/mappings/membership/index.ts | 18 ++++++++++++---- src/mappings/membership/utils.ts | 18 ++++++++++++++++ src/mappings/token/utils.ts | 23 +++++++++++++------- src/mappings/utils.ts | 37 +++++++++++++++++++++++++++++++- 6 files changed, 108 insertions(+), 22 deletions(-) create mode 100644 src/mappings/membership/utils.ts diff --git a/src/mappings/content/nft.ts b/src/mappings/content/nft.ts index dfeb5f579..8303922f7 100644 --- a/src/mappings/content/nft.ts +++ b/src/mappings/content/nft.ts @@ -34,7 +34,12 @@ import { EventHandlerContext } from '../../utils/events' import { criticalError } from '../../utils/misc' import { addNotification } from '../../utils/notification' import { EntityManagerOverlay } from '../../utils/overlay' -import { addNftActivity, addNftHistoryEntry, genericEventFields } from '../utils' +import { + addNftActivity, + addNftHistoryEntry, + genericEventFields, + getAccountForMember, +} from '../utils' import { addNewBidNotification, addRoyaltyPaymentNotification, @@ -617,9 +622,10 @@ export async function processNftBoughtEvent({ await maybeNotifyNftCreator(overlay, previousNftOwner, notificationData, event) if (previousNftOwner.isTypeOf === 'NftOwnerMember') { // case when previous owner is a member - const previousNftOwnerAccount = await overlay - .getRepository(Account) - .getOneByRelation('membershipId', previousNftOwner.member) + const previousNftOwnerAccount = await getAccountForMember( + overlay.getEm(), + previousNftOwner.member + ) await addNotification( overlay, previousNftOwnerAccount ? (previousNftOwnerAccount as Account) : null, diff --git a/src/mappings/content/utils.ts b/src/mappings/content/utils.ts index 1622116d2..f57783825 100644 --- a/src/mappings/content/utils.ts +++ b/src/mappings/content/utils.ts @@ -74,6 +74,7 @@ import { import { criticalError } from '../../utils/misc' import { addNotification } from '../../utils/notification' import { EntityManagerOverlay, Flat } from '../../utils/overlay' +import { getMemberControllerAccount } from '../membership/utils' import { addNftActivity, addNftHistoryEntry, genericEventFields, invalidMetadata } from '../utils' // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -466,10 +467,12 @@ export async function createBid( auction.auctionType.isTypeOf === 'AuctionTypeEnglish' ) { newBid.previousTopBidId = previousTopBidId - return { bid: newBid, auction, previousTopBid, auctionBids } } - return { bid: newBid, auction, auctionBids } + // Although there is no notion of a "previous top bid" in the OpenAuction type + // as all active bids are considered valid, but we still return it whether the + // auction type is "Open" or "English" for notification purposes. + return { bid: newBid, auction, previousTopBid, auctionBids } } export async function getChannelOwnerMemberByVideoId( @@ -654,7 +657,13 @@ export async function getFollowersAccountsForChannel( const limit = pLimit(10) // Limit to 10 concurrent promises const followersAccounts: (Account | null)[] = await Promise.all( followersUserIds.map((userId) => - limit(async () => await overlay.getEm().getRepository(Account).findOneBy({ userId })) + limit( + async () => + await overlay + .getEm() + .getRepository(Account) + .findOne({ where: { userId }, relations: { joystreamAccount: { memberships: true } } }) + ) ) ) @@ -676,10 +685,11 @@ export async function getAccountForMember( if (!memberId) { return null } + // accounts are created by orion_auth_api and updated by orion_graphql-server const memberAccount = await overlay .getRepository(Account) - .getOneByRelation('membershipId', memberId) + .getOneByRelation('joystreamAccountId', await getMemberControllerAccount(overlay, memberId)) return (memberAccount as Account) ?? null } @@ -754,7 +764,7 @@ export async function notifyChannelFollowers( await addNotification( overlay, followerAccount, - new MemberRecipient({ membership: followerAccount.membershipId }), + new MemberRecipient({ membership: followerAccount.joystreamAccount.memberships[0].id }), // TODO: handle multiple memberships (i.e. follower account address owns multiple memberships). Also, consider a scenario where a follower account has no memberships. notificationType, event ) diff --git a/src/mappings/membership/index.ts b/src/mappings/membership/index.ts index 63bcd1a47..7e052c17e 100644 --- a/src/mappings/membership/index.ts +++ b/src/mappings/membership/index.ts @@ -1,14 +1,15 @@ +import { MemberRemarked, MembershipMetadata } from '@joystream/metadata-protobuf' import { u8aToHex } from '@polkadot/util' import { + BlockchainAccount, Event, Membership, MetaprotocolTransactionResultFailed, MetaprotocolTransactionStatusEventData, } from '../../model' import { EventHandlerContext } from '../../utils/events' -import { MemberRemarked, MembershipMetadata } from '@joystream/metadata-protobuf' import { bytesToString, deserializeMetadata, genericEventFields, toAddress } from '../utils' -import { processMembershipMetadata, processMemberRemark } from './metadata' +import { processMemberRemark, processMembershipMetadata } from './metadata' export async function processNewMember({ overlay, @@ -24,10 +25,19 @@ export async function processNewMember({ const { controllerAccount, handle: handleBytes, metadata: metadataBytes } = params const metadata = deserializeMetadata(MembershipMetadata, metadataBytes) + const controllerAccountId = toAddress(controllerAccount) + + // Create blockchain account entity if it doesn't exist + const blockchainAccount = + (await overlay.getRepository(BlockchainAccount).getById(controllerAccountId)) || + overlay.getRepository(BlockchainAccount).new({ + id: controllerAccountId, + }) + const member = overlay.getRepository(Membership).new({ createdAt: new Date(block.timestamp), id: memberId.toString(), - controllerAccount: toAddress(controllerAccount), + controllerAccountId: blockchainAccount.id, totalChannelsCreated: 0, }) if (handleBytes) { @@ -47,7 +57,7 @@ export async function processMemberAccountsUpdatedEvent({ }: EventHandlerContext<'Members.MemberAccountsUpdated'>) { if (newControllerAccount) { const member = await overlay.getRepository(Membership).getByIdOrFail(memberId.toString()) - member.controllerAccount = toAddress(newControllerAccount) + member.controllerAccountId = toAddress(newControllerAccount) } } diff --git a/src/mappings/membership/utils.ts b/src/mappings/membership/utils.ts new file mode 100644 index 000000000..5f2656b94 --- /dev/null +++ b/src/mappings/membership/utils.ts @@ -0,0 +1,18 @@ +import { Membership } from '../../model' +import { criticalError } from '../../utils/misc' +import { EntityManagerOverlay } from '../../utils/overlay' + +export async function getMemberControllerAccount( + overlay: EntityManagerOverlay, + memberId: string +): Promise { + const membership = await overlay.getRepository(Membership).getByIdOrFail(memberId) + + if (!membership.controllerAccountId) { + // This should never happen, but only added for type safety as + // the foreign entity references are always set nullable by the + // subsquid codegen even if in the graphql schema they are not. + criticalError(`Membership ${membership.id} controller account not found.`) + } + return membership.controllerAccountId +} diff --git a/src/mappings/token/utils.ts b/src/mappings/token/utils.ts index a4b7d1447..7194f21e1 100644 --- a/src/mappings/token/utils.ts +++ b/src/mappings/token/utils.ts @@ -23,6 +23,7 @@ import { uniqueId } from '../../utils/crypto' import { criticalError } from '../../utils/misc' import { addNotification } from '../../utils/notification' import { EntityManagerOverlay, Flat } from '../../utils/overlay' +import { getAccountForMember } from '../utils' export const FALLBACK_TOKEN_SYMBOL = '??' @@ -319,25 +320,31 @@ export async function processTokenMetadata( } } +/** + * @returns [holderMemberId, Account][] + */ export async function getHolderAccountsForToken( overlay: EntityManagerOverlay, tokenId: string -): Promise { +): Promise<[string, Account][]> { const em = overlay.getEm() const holders = await em.getRepository(TokenAccount).findBy({ tokenId }) const holdersMemberIds = holders - .filter((follower) => follower?.memberId) + .filter((holder) => holder?.memberId) .map((follower) => follower.memberId as string) const limit = pLimit(10) // Limit to 10 concurrent promises - const holdersAccounts: (Account | null)[] = await Promise.all( + const holdersAccounts = await Promise.all( holdersMemberIds.map((membershipId) => - limit(async () => await em.getRepository(Account).findOneBy({ membershipId })) + limit(async () => { + const account = await getAccountForMember(em, membershipId) + return [membershipId, account] as [string, Account | null] + }) ) ) - return holdersAccounts.filter((account) => account) as Account[] + return holdersAccounts.filter(([_, account]) => account !== null) as [string, Account][] } export async function notifyTokenHolders( @@ -352,12 +359,12 @@ export async function notifyTokenHolders( const limit = pLimit(10) // Limit to 10 concurrent promises await Promise.all( - holdersAccounts.map((holdersAccount) => + holdersAccounts.map(([holderMemberId, account]) => limit(() => addNotification( em, - holdersAccount, - new MemberRecipient({ membership: holdersAccount.membershipId }), + account, + new MemberRecipient({ membership: holderMemberId }), notificationType, event, dispatchBlock diff --git a/src/mappings/utils.ts b/src/mappings/utils.ts index 49d1f8877..468c2c8a7 100644 --- a/src/mappings/utils.ts +++ b/src/mappings/utils.ts @@ -5,8 +5,16 @@ import { Bytes } from '@polkadot/types/primitive' import { u8aToHex } from '@polkadot/util' import { encodeAddress } from '@polkadot/util-crypto' import { SubstrateBlock } from '@subsquid/substrate-processor' +import { EntityManager } from 'typeorm' import { Logger } from '../logger' -import { Event, MetaprotocolTransactionResultFailed, NftActivity, NftHistoryEntry } from '../model' +import { + Account, + Event, + Membership, + MetaprotocolTransactionResultFailed, + NftActivity, + NftHistoryEntry, +} from '../model' import { CommentCountersManager } from '../utils/CommentsCountersManager' import { VideoRelevanceManager } from '../utils/VideoRelevanceManager' import { EntityManagerOverlay } from '../utils/overlay' @@ -100,6 +108,33 @@ export function addNftActivity( } } +export async function getAccountForMemberOrFail( + em: EntityManager, + memberOrMemberId: string | Membership +) { + const account = await getAccountForMember(em, memberOrMemberId) + + if (!account) { + throw new Error(`Account not found for member ${memberOrMemberId}`) + } + return account +} + +export async function getAccountForMember( + em: EntityManager, + memberOrMemberId: string | Membership +): Promise { + const member = + memberOrMemberId instanceof Membership + ? memberOrMemberId + : await em.getRepository(Membership).findOneByOrFail({ id: memberOrMemberId }) + + return em.getRepository(Account).findOne({ + where: { joystreamAccountId: member.controllerAccountId! }, + relations: { joystreamAccount: { memberships: true } }, + }) +} + export function toAddress(addressBytes: Uint8Array) { return encodeAddress(addressBytes, JOYSTREAM_SS58_PREFIX) } From 86c0ff0c7535b7611c59d2d820fe38c7ada25509 Mon Sep 17 00:00:00 2001 From: Zeeshan Akram <97m.zeeshan@gmail.com> Date: Mon, 18 Mar 2024 13:24:23 +0500 Subject: [PATCH 10/22] update custon resolvers due to schema changes --- .../resolvers/AdminResolver/index.ts | 7 +- .../resolvers/ChannelsResolver/index.ts | 96 +++++++++---------- .../resolvers/VideosResolver/index.ts | 5 +- src/server-extension/resolvers/middleware.ts | 2 +- 4 files changed, 51 insertions(+), 59 deletions(-) diff --git a/src/server-extension/resolvers/AdminResolver/index.ts b/src/server-extension/resolvers/AdminResolver/index.ts index b7ea62db1..ce1a00985 100644 --- a/src/server-extension/resolvers/AdminResolver/index.ts +++ b/src/server-extension/resolvers/AdminResolver/index.ts @@ -12,9 +12,8 @@ import 'reflect-metadata' import { Args, Ctx, Info, Int, Mutation, Query, Resolver, UseMiddleware } from 'type-graphql' import { EntityManager, In, Not, UpdateResult } from 'typeorm' import { parseVideoTitle } from '../../../mappings/content/utils' -import { videoRelevanceManager } from '../../../mappings/utils' +import { getAccountForMember, videoRelevanceManager } from '../../../mappings/utils' import { - Account, Channel, ChannelRecipient, CreatorToken, @@ -543,9 +542,7 @@ export const setFeaturedNftsInner = async (em: EntityManager, featuredNftsIds: s videoTitle: parseVideoTitle(featuredNft.video), } const channelOwnerAccount = featuredNft.video.channel.ownerMemberId - ? await em - .getRepository(Account) - .findOneBy({ membershipId: featuredNft.video.channel.ownerMemberId }) + ? await getAccountForMember(em, featuredNft.video.channel.ownerMemberId) : null await addNotification( em, diff --git a/src/server-extension/resolvers/ChannelsResolver/index.ts b/src/server-extension/resolvers/ChannelsResolver/index.ts index 06ab2a602..5f0e631d1 100644 --- a/src/server-extension/resolvers/ChannelsResolver/index.ts +++ b/src/server-extension/resolvers/ChannelsResolver/index.ts @@ -1,60 +1,60 @@ +import { parseAnyTree } from '@subsquid/openreader/lib/opencrud/tree' +import { ListQuery } from '@subsquid/openreader/lib/sql/query' +import { getResolveTree } from '@subsquid/openreader/lib/util/resolve-tree' +import { assertNotNull } from '@subsquid/substrate-processor' +import { GraphQLResolveInfo } from 'graphql' +import pLimit from 'p-limit' import 'reflect-metadata' -import { Args, Query, Mutation, Resolver, Info, Ctx, UseMiddleware } from 'type-graphql' +import { Args, Ctx, Info, Mutation, Query, Resolver, UseMiddleware } from 'type-graphql' import { EntityManager, In, IsNull } from 'typeorm' +import { FALLBACK_CHANNEL_TITLE } from '../../../mappings/content/utils' +import { getAccountForMember } from '../../../mappings/utils' +import { + Channel, + ChannelExcluded, + ChannelFollow, + ChannelRecipient, + ChannelSuspended, + ChannelSuspension, + ChannelVerification, + ChannelVerified, + Exclusion, + MemberRecipient, + Membership, + NewChannelFollower, + Report, + YppSuspended, + YppVerified, +} from '../../../model' +import { uniqueId } from '../../../utils/crypto' +import { addNotification } from '../../../utils/notification' +import { extendClause, withHiddenEntities } from '../../../utils/sql' +import { Context } from '../../check' +import { AccountOnly, OperatorOnly, UserOnly } from '../middleware' +import { model } from '../model' import { + ChannelFollowResult, ChannelNftCollector, ChannelNftCollectorsArgs, + ChannelNftCollectorsOrderByInput, + ChannelReportInfo, + ChannelUnfollowResult, + ExcludeChannelArgs, + ExcludeChannelResult, ExtendedChannel, ExtendedChannelsArgs, FollowChannelArgs, - UnfollowChannelArgs, MostRecentChannelsArgs, - ChannelReportInfo, ReportChannelArgs, - ChannelFollowResult, - ChannelUnfollowResult, - ChannelNftCollectorsOrderByInput, + SuspendChannelArgs, + SuspendChannelResult, TopSellingChannelsArgs, TopSellingChannelsResult, - ExcludeChannelArgs, - ExcludeChannelResult, + UnfollowChannelArgs, VerifyChannelArgs, VerifyChannelResult, - SuspendChannelResult, - SuspendChannelArgs, } from './types' -import { GraphQLResolveInfo } from 'graphql' -import { - Account, - Channel, - ChannelFollow, - Report, - Membership, - Exclusion, - NewChannelFollower, - ChannelExcluded, - ChannelVerified, - ChannelVerification, - ChannelSuspension, - ChannelSuspended, - YppVerified, - YppSuspended, - ChannelRecipient, - MemberRecipient, -} from '../../../model' -import { extendClause, withHiddenEntities } from '../../../utils/sql' import { buildExtendedChannelsQuery, buildTopSellingChannelsQuery } from './utils' -import { parseAnyTree } from '@subsquid/openreader/lib/opencrud/tree' -import { getResolveTree } from '@subsquid/openreader/lib/util/resolve-tree' -import { ListQuery } from '@subsquid/openreader/lib/sql/query' -import { model } from '../model' -import { Context } from '../../check' -import { uniqueId } from '../../../utils/crypto' -import { AccountOnly, OperatorOnly, UserOnly } from '../middleware' -import { addNotification } from '../../../utils/notification' -import { assertNotNull } from '@subsquid/substrate-processor' -import { FALLBACK_CHANNEL_TITLE } from '../../../mappings/content/utils' -import pLimit from 'p-limit' @Resolver() export class ChannelsResolver { @@ -224,7 +224,7 @@ export class ChannelsResolver { }) const ownerAccount = channel.ownerMemberId - ? await em.getRepository(Account).findOneBy({ membershipId: channel.ownerMemberId }) + ? await getAccountForMember(em, channel.ownerMemberId) : null if (ownerAccount) { if (!ctx.account) { @@ -233,7 +233,7 @@ export class ChannelsResolver { } const followerMembership = await em .getRepository(Membership) - .findOneByOrFail({ id: ctx.account.membershipId }) + .findOneByOrFail({ controllerAccountId: ctx.account.joystreamAccountId }) await addNotification( em, ownerAccount, @@ -376,9 +376,7 @@ export class ChannelsResolver { // in case account exist deposit notification const channelOwnerMemberId = channel.ownerMemberId if (channelOwnerMemberId) { - const account = await em.findOne(Account, { - where: { membershipId: channelOwnerMemberId }, - }) + const account = await getAccountForMember(em, channelOwnerMemberId) await addNotification( em, account, @@ -458,7 +456,7 @@ export const excludeChannelService = async ( // in case account exist deposit notification const channelOwnerMemberId = channel.ownerMemberId if (channelOwnerMemberId) { - const account = await em.findOne(Account, { where: { membershipId: channelOwnerMemberId } }) + const account = await getAccountForMember(em, channelOwnerMemberId) await addNotification( em, account, @@ -508,9 +506,7 @@ export const verifyChannelService = async (em: EntityManager, channelIds: string // in case account exist deposit notification const channelOwnerMemberId = channel.ownerMemberId if (channelOwnerMemberId) { - const account = await em.findOne(Account, { - where: { membershipId: channelOwnerMemberId }, - }) + const account = await getAccountForMember(em, channelOwnerMemberId) await addNotification( em, account, diff --git a/src/server-extension/resolvers/VideosResolver/index.ts b/src/server-extension/resolvers/VideosResolver/index.ts index 63b0a847d..47d537fb9 100644 --- a/src/server-extension/resolvers/VideosResolver/index.ts +++ b/src/server-extension/resolvers/VideosResolver/index.ts @@ -22,9 +22,8 @@ import 'reflect-metadata' import { Arg, Args, Ctx, Info, Mutation, Query, Resolver, UseMiddleware } from 'type-graphql' import { EntityManager, In, MoreThan } from 'typeorm' import { parseVideoTitle } from '../../../mappings/content/utils' -import { videoRelevanceManager } from '../../../mappings/utils' +import { getAccountForMember, videoRelevanceManager } from '../../../mappings/utils' import { - Account, ChannelRecipient, Exclusion, OperatorPermission, @@ -424,7 +423,7 @@ export const excludeVideoService = async ( // in case account exist deposit notification const channelOwnerMemberId = video.channel.ownerMemberId if (channelOwnerMemberId) { - const account = await em.findOne(Account, { where: { membershipId: channelOwnerMemberId } }) + const account = await getAccountForMember(em, channelOwnerMemberId) await addNotification( em, account, diff --git a/src/server-extension/resolvers/middleware.ts b/src/server-extension/resolvers/middleware.ts index b4dc09639..37efe8c85 100644 --- a/src/server-extension/resolvers/middleware.ts +++ b/src/server-extension/resolvers/middleware.ts @@ -7,7 +7,7 @@ export const OperatorOnly = ( ): MiddlewareFn => { return async ({ context }, next) => { // Ensure the user exists in the context - if (!context?.user) { + if (!context.user) { throw new Error('Unauthorized: User required') } From e2efa924d187fb45939082b3e0aeb5eb56786167 Mon Sep 17 00:00:00 2001 From: Zeeshan Akram <97m.zeeshan@gmail.com> Date: Mon, 18 Mar 2024 13:27:11 +0500 Subject: [PATCH 11/22] added createAccountMembership custom mutation --- src/server-extension/check.ts | 4 +- .../resolvers/AccountResolver/index.ts | 120 +++++++++++++++--- .../resolvers/AccountResolver/types.ts | 50 ++++++-- src/utils/auth.ts | 10 +- src/utils/config.ts | 4 + src/utils/misc.ts | 30 +++++ 6 files changed, 183 insertions(+), 35 deletions(-) diff --git a/src/server-extension/check.ts b/src/server-extension/check.ts index 3c16c11dd..ae219b91b 100644 --- a/src/server-extension/check.ts +++ b/src/server-extension/check.ts @@ -2,8 +2,10 @@ import { RequestCheckFunction } from '@subsquid/graphql-server/lib/check' import { TypeormOpenreaderContext } from '@subsquid/graphql-server/lib/typeorm' import { Context as OpenreaderContext } from '@subsquid/openreader/lib/context' import { UnauthorizedError } from 'type-graphql' -import { AuthContext, authenticate } from '../utils/auth' +import { Session } from '../model' +import { authenticate } from '../utils/auth' +type AuthContext = Session | null export type Context = OpenreaderContext & AuthContext const autogeneratedOperatorQueries = [ diff --git a/src/server-extension/resolvers/AccountResolver/index.ts b/src/server-extension/resolvers/AccountResolver/index.ts index 01796a410..feef93feb 100644 --- a/src/server-extension/resolvers/AccountResolver/index.ts +++ b/src/server-extension/resolvers/AccountResolver/index.ts @@ -1,13 +1,24 @@ +import assert from 'assert' +import axios from 'axios' +import { GraphQLResolveInfo } from 'graphql' import 'reflect-metadata' -import { Query, Resolver, UseMiddleware, Ctx, Info } from 'type-graphql' +import { Args, Ctx, Info, Mutation, Query, Resolver, UseMiddleware } from 'type-graphql' import { EntityManager } from 'typeorm' -import { AccountOnly } from '../middleware' -import { AccountData, FollowedChannel } from './types' -import { Context } from '../../check' -import { GraphQLResolveInfo } from 'graphql' -import assert from 'assert' +import { Account, ChannelFollow } from '../../../model' +import { getCurrentBlockHeight } from '../../../utils/blockHeight' +import { ConfigVariable, config } from '../../../utils/config' +import { pWaitFor } from '../../../utils/misc' import { withHiddenEntities } from '../../../utils/sql' -import { ChannelFollow } from '../../../model' +import { Context } from '../../check' +import { AccountOnly } from '../middleware' +import { + AccountData, + CreateAccountMembershipArgs, + CreateAccountMembershipResult, + FaucetRegisterMembershipParams, + FaucetRegisterMembershipResponse, + FollowedChannel, +} from './types' @Resolver() export class AccountResolver { @@ -20,33 +31,102 @@ export class AccountResolver { const account = ctx.account const em = await this.em() assert(account, 'Unexpected context: account is not set') - const { id, email, joystreamAccount, membershipId, isEmailConfirmed, notificationPreferences } = - account + const { id, email, joystreamAccount, notificationPreferences } = account let followedChannels: FollowedChannel[] = [] if ( info.fieldNodes[0].selectionSet?.selections.some( (s) => s.kind === 'Field' && s.name.value === 'followedChannels' ) ) { - followedChannels = await withHiddenEntities(em, async () => { - const followedChannels = await em - .getRepository(ChannelFollow) - .findBy({ userId: account.userId }) - return followedChannels.map(({ channelId, timestamp }) => ({ - channelId, - timestamp: timestamp.toISOString(), - })) - }) + followedChannels = await this.getFollowedChannels(id) } return { id, email, joystreamAccount, - membershipId, - isEmailConfirmed, followedChannels, preferences: notificationPreferences, } } + + @UseMiddleware(AccountOnly) + @Mutation(() => CreateAccountMembershipResult) + async createAccountMembership( + @Args() args: CreateAccountMembershipArgs, + @Ctx() ctx: Context + ): Promise { + const em = await this.em() + return em.transaction(async (em) => { + const account = await em.getRepository(Account).findOne({ + where: { id: ctx.account?.id }, + lock: { mode: 'pessimistic_write' }, + relations: { joystreamAccount: { memberships: true } }, + }) + + if (!account) { + throw new Error('Account not found') + } + + if (account.joystreamAccount.memberships.length > 0) { + throw new Error('Membership already exists') + } + + const { handle, avatar, about, name } = args + const address = account.joystreamAccountId + + const { memberId, block } = await this.createMemberWithFaucet({ + address, + handle, + avatar, + about, + name, + }) + + // ensure membership is processed + await pWaitFor( + async () => (await getCurrentBlockHeight(em)).lastProcessedBlock >= block, + 1000, // 1 second + 20000, // 20 seconds + 'Membership not processed' + ) + + return { accountId: account.id, memberId } + }) + } + + private async getFollowedChannels(accountId: string): Promise { + const em = await this.em() + return withHiddenEntities(em, async () => { + const followedChannels = await em.getRepository(ChannelFollow).findBy({ userId: accountId }) + return followedChannels.map(({ channelId, timestamp }) => ({ + channelId, + timestamp: timestamp.toISOString(), + })) + }) + } + + private async createMemberWithFaucet( + params: FaucetRegisterMembershipParams + ): Promise { + const em = await this.em() + const url = await config.get(ConfigVariable.FaucetUrl, em) + const captchaBypassKey = await config.get(ConfigVariable.FaucetCaptchaBypassKey, em) + + try { + const response = await axios.post(url, params, { + headers: { Authorization: `Bearer ${captchaBypassKey}` }, + }) + return response.data + } catch (error) { + if (axios.isAxiosError(error)) { + throw new Error( + `Failed to create membership through faucet for account address: ${ + params.address + }, error: ${error.response?.data?.error || error.cause || error.code}` + ) + } + throw error + } + } } diff --git a/src/server-extension/resolvers/AccountResolver/types.ts b/src/server-extension/resolvers/AccountResolver/types.ts index 9d3c0918a..8769a2ec0 100644 --- a/src/server-extension/resolvers/AccountResolver/types.ts +++ b/src/server-extension/resolvers/AccountResolver/types.ts @@ -1,4 +1,6 @@ -import { Field, ObjectType } from 'type-graphql' +import { IsUrl } from 'class-validator' +import { ArgsType, Field, ObjectType } from 'type-graphql' +import { BlockchainAccount } from '../../../model' import { AccountNotificationPreferencesOutput } from '../NotificationResolver/types' @ObjectType() @@ -19,13 +21,7 @@ export class AccountData { email!: string @Field(() => String, { nullable: false }) - joystreamAccount!: string - - @Field(() => Boolean, { nullable: false }) - isEmailConfirmed!: boolean - - @Field(() => String, { nullable: false }) - membershipId: string + joystreamAccount!: BlockchainAccount @Field(() => [FollowedChannel], { nullable: false }) followedChannels: FollowedChannel[] @@ -33,3 +29,41 @@ export class AccountData { @Field(() => AccountNotificationPreferencesOutput, { nullable: true }) preferences?: AccountNotificationPreferencesOutput } + +@ArgsType() +export class CreateAccountMembershipArgs { + @Field(() => String, { nullable: false, description: 'Membership Handle' }) + handle: string + + @Field(() => String, { description: 'Membership avatar URL' }) + @IsUrl({ require_tld: false }) + avatar: string + + @Field(() => String, { description: '`about` information to associate with new Membership' }) + about: string + + @Field(() => String, { description: 'Membership name' }) + name: string +} + +@ObjectType() +export class CreateAccountMembershipResult { + @Field(() => String, { nullable: false }) + accountId!: string + + @Field(() => Number, { nullable: false }) + memberId: number +} + +export type FaucetRegisterMembershipParams = { + address: string + handle: string + avatar: string + about: string + name: string +} + +export type FaucetRegisterMembershipResponse = { + memberId: number + block: number +} diff --git a/src/utils/auth.ts b/src/utils/auth.ts index 9484cf599..2c65c8b92 100644 --- a/src/utils/auth.ts +++ b/src/utils/auth.ts @@ -105,15 +105,13 @@ sessionCache.on('expired', (sessionId: string, cachedData: CachedSessionData) => }) }) -export type AuthContext = Session | null - -export async function getSessionIdFromHeader(req: Request): Promise { +export function getSessionIdFromHeader(req: Request): string | undefined { authLogger.trace(`Authorization header: ${JSON.stringify(req.headers.authorization, null, 2)}`) const [, sessionId] = req.headers.authorization?.match(/^Bearer ([A-Za-z0-9+/=]+)$/) || [] return sessionId } -export async function getSessionIdFromCookie(req: Request): Promise { +export function getSessionIdFromCookie(req: Request): string | undefined { authLogger.trace(`Cookies: ${JSON.stringify(req.cookies, null, 2)}`) return req.cookies ? req.cookies[SESSION_COOKIE_NAME] : undefined } @@ -121,10 +119,10 @@ export async function getSessionIdFromCookie(req: Request): Promise { +): Promise { const em = await globalEm const sessionId = - authType === 'cookie' ? await getSessionIdFromCookie(req) : await getSessionIdFromHeader(req) + authType === 'cookie' ? getSessionIdFromCookie(req) : getSessionIdFromHeader(req) if (sessionId) { authLogger.trace(`Authenticating... SessionId: ${sessionId}`) diff --git a/src/utils/config.ts b/src/utils/config.ts index e91ea67b7..5734d8241 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -24,6 +24,8 @@ export enum ConfigVariable { AppAssetStorage = 'APP_ASSET_STORAGE', AppNameAlt = 'APP_NAME_ALT', NotificationAssetRoot = 'NOTIFICATION_ASSET_ROOT', + FaucetUrl = 'FAUCET_URL', + FaucetCaptchaBypassKey = 'FAUCET_CAPTCHA_BYPASS_KEY', } const boolType = { @@ -69,6 +71,8 @@ export const configVariables = { [ConfigVariable.AppAssetStorage]: stringType, [ConfigVariable.AppNameAlt]: stringType, [ConfigVariable.NotificationAssetRoot]: stringType, + [ConfigVariable.FaucetUrl]: stringType, + [ConfigVariable.FaucetCaptchaBypassKey]: stringType, } as const type TypeOf = ReturnType<(typeof configVariables)[C]['deserialize']> diff --git a/src/utils/misc.ts b/src/utils/misc.ts index e7de05dc5..96083b55c 100644 --- a/src/utils/misc.ts +++ b/src/utils/misc.ts @@ -30,3 +30,33 @@ export function idStringFromNumber(idNum: number) { // Add leading zeros to simplify sorting return _.repeat('0', 8 - idStr.length) + idStr } + +export async function pWaitFor( + conditionFunction: () => Promise, + checkInterval = 100, + timeout = 5000, // Default timeout of 5000 milliseconds + errorMessage = 'Condition timed out' +): Promise { + return new Promise((resolve, reject) => { + let elapsedTime = 0 + + const interval = setInterval(() => { + conditionFunction() + .then((isConditionMet) => { + if (isConditionMet) { + clearInterval(interval) + resolve() + } else if (elapsedTime > timeout) { + clearInterval(interval) + reject(new Error(errorMessage)) + } else { + elapsedTime += checkInterval // Update the elapsed time + } + }) + .catch((error) => { + clearInterval(interval) + reject(error) + }) + }, checkInterval) + }) +} From b0daecbd4c6c4763c8b0f2fc8e41a8d3715a2c5a Mon Sep 17 00:00:00 2001 From: Zeeshan Akram <97m.zeeshan@gmail.com> Date: Mon, 18 Mar 2024 13:29:33 +0500 Subject: [PATCH 12/22] update auth-server, mail scheduler and notification tests --- src/auth-server/tests/changeAccount.ts | 30 +++++----- src/auth-server/tests/common.ts | 48 +++++++++++----- src/mail-scheduler/tests/testUtils.ts | 22 ++++---- src/tests/integration/notifications.test.ts | 61 ++++++++------------- src/tests/integration/seedData.test.ts | 7 +-- src/tests/integration/testUtils.ts | 16 ++++-- src/tests/migrations/migration.test.ts | 14 ++--- 7 files changed, 102 insertions(+), 96 deletions(-) diff --git a/src/auth-server/tests/changeAccount.ts b/src/auth-server/tests/changeAccount.ts index 3be983f9a..0124ec261 100644 --- a/src/auth-server/tests/changeAccount.ts +++ b/src/auth-server/tests/changeAccount.ts @@ -1,12 +1,15 @@ -import './config' -import request from 'supertest' -import { app } from '../index' -import { globalEm } from '../../utils/globalEm' -import assert from 'assert' -import { ConfigVariable, config } from '../../utils/config' -import { u8aToHex } from '@polkadot/util' import { KeyringPair } from '@polkadot/keyring/types' +import { u8aToHex } from '@polkadot/util' +import { cryptoWaitReady } from '@polkadot/util-crypto' +import assert from 'assert' +import request from 'supertest' import { EntityManager } from 'typeorm' +import { Account, EncryptionArtifacts as EncryptionArtifactsEntity } from '../../model' +import { SESSION_COOKIE_NAME } from '../../utils/auth' +import { ConfigVariable, config } from '../../utils/config' +import { globalEm } from '../../utils/globalEm' +import { components } from '../generated/api-types' +import { app } from '../index' import { EncryptionArtifacts, LoggedInAccountInfo, @@ -16,10 +19,7 @@ import { keyring, prepareEncryptionArtifacts, } from './common' -import { cryptoWaitReady } from '@polkadot/util-crypto' -import { components } from '../generated/api-types' -import { SESSION_COOKIE_NAME } from '../../utils/auth' -import { Account, EncryptionArtifacts as EncryptionArtifactsEntity } from '../../model' +import './config' type ChangeAccountArgs = { accountId: string @@ -94,7 +94,7 @@ describe('changeAccount', () => { accountId, }) assert( - account?.joystreamAccount === joystreamAccountId, + account?.joystreamAccountId === joystreamAccountId, 'Blockchain account unexpectedly changed' ) assert(encryptionArtifacts, 'Encryption artifacts unexpectedly deleted') @@ -226,7 +226,7 @@ describe('changeAccount', () => { .getRepository(EncryptionArtifactsEntity) .findOneBy({ accountId }) assert( - account?.joystreamAccount === joystreamAccountId, + account?.joystreamAccountId === joystreamAccountId, 'Blockchain account unexpectedly changed' ) assert(encryptionArtifacts, 'New encryption artifacts not saved') @@ -253,7 +253,7 @@ describe('changeAccount', () => { const encryptionArtifacts = await em .getRepository(EncryptionArtifactsEntity) .findOneBy({ accountId }) - assert(account?.joystreamAccount === alice.address, 'Blockchain account not changed') + assert(account?.joystreamAccountId === alice.address, 'Blockchain account not changed') assert(encryptionArtifacts, 'Encryption artifacts not saved') assert( (await decryptSeed(email, password, encryptionArtifacts)) === newSeed, @@ -275,7 +275,7 @@ describe('changeAccount', () => { const encryptionArtifacts = await em .getRepository(EncryptionArtifactsEntity) .findOneBy({ accountId }) - assert(account?.joystreamAccount === bob.address, 'Blockchain account not changed') + assert(account?.joystreamAccountId === bob.address, 'Blockchain account not changed') assert(!encryptionArtifacts, 'Encryption artifacts not deleted') }) diff --git a/src/auth-server/tests/common.ts b/src/auth-server/tests/common.ts index a5cbe236a..07ba0177a 100644 --- a/src/auth-server/tests/common.ts +++ b/src/auth-server/tests/common.ts @@ -1,19 +1,19 @@ -import './config' -import request from 'supertest' -import { app } from '../index' -import { globalEm } from '../../utils/globalEm' -import { Account, Membership } from '../../model' -import assert from 'assert' -import { components } from '../generated/api-types' +import { JOYSTREAM_ADDRESS_PREFIX } from '@joystream/types' import { Keyring } from '@polkadot/keyring' import { KeyringPair } from '@polkadot/keyring/types' -import { ConfigVariable, config } from '../../utils/config' import { u8aToHex } from '@polkadot/util' -import { JOYSTREAM_ADDRESS_PREFIX } from '@joystream/types' -import { uniqueId } from '../../utils/crypto' +import assert from 'assert' import { ScryptOptions, createCipheriv, createDecipheriv, randomBytes, scrypt } from 'crypto' +import request from 'supertest' +import { Account, Membership } from '../../model' import { SESSION_COOKIE_NAME } from '../../utils/auth' +import { ConfigVariable, config } from '../../utils/config' +import { uniqueId } from '../../utils/crypto' +import { globalEm } from '../../utils/globalEm' +import { components } from '../generated/api-types' +import { app } from '../index' import { SimpleRateLimit, resetAllLimits } from '../rateLimits' +import './config' export const keyring = new Keyring({ type: 'sr25519', ss58Format: JOYSTREAM_ADDRESS_PREFIX }) @@ -125,13 +125,20 @@ async function insertFakeMember(controllerAccount: string) { return em.getRepository(Membership).save({ createdAt: new Date(), id: uniqueId(), - controllerAccount, + controllerAccountId: controllerAccount, handle: uniqueId(), handleRaw: '0x' + Buffer.from(handle).toString('hex'), totalChannelsCreated: 0, }) } +async function getAccountByEmail(email: string) { + const em = await globalEm + return em + .getRepository(Account) + .findOne({ where: { email }, relations: { joystreamAccount: { memberships: true } } }) +} + export async function createAccount( email = `test.${uniqueId()}@example.com`, password = DEFAULT_PASSWORD, @@ -141,7 +148,7 @@ export async function createAccount( const keypair = keyring.addFromUri(`//${seed}`) const em = await globalEm - const membership = await insertFakeMember(keypair.address) + // const membership = await insertFakeMember(keypair.address) const anonSessionId = await anonymousAuth() const createAccountReqData = await signedAction< @@ -150,7 +157,6 @@ export async function createAccount( { action: 'createAccount', email, - memberId: membership.id, encryptionArtifacts: await prepareEncryptionArtifacts(seed, email, password), }, keypair @@ -161,8 +167,22 @@ export async function createAccount( .set('Cookie', `${SESSION_COOKIE_NAME}=${anonSessionId}`) .send(createAccountReqData) .expect(200) - const account = await em.getRepository(Account).findOneBy({ email }) + const account = await getAccountByEmail(email) assert(account, 'Account not found') + assert.equal(account.joystreamAccount?.memberships.length, 0) + + // Associate the account with a membership + const membership = await insertFakeMember(keypair.address) + + const updatedAccount = await getAccountByEmail(email) + + assert(updatedAccount, 'Account not found') + assert.equal(updatedAccount.joystreamAccount.memberships.length, 1) + assert.equal( + updatedAccount.joystreamAccount.memberships[0].controllerAccountId, + membership.controllerAccountId + ) + return { accountId: account.id, joystreamAccountId: keypair.address, email, password, seed } } diff --git a/src/mail-scheduler/tests/testUtils.ts b/src/mail-scheduler/tests/testUtils.ts index 21c292de7..187af83f3 100644 --- a/src/mail-scheduler/tests/testUtils.ts +++ b/src/mail-scheduler/tests/testUtils.ts @@ -1,21 +1,21 @@ import { - Membership, - User, - Notification, Account, - Unread, - MemberRecipient, - NotificationEmailDelivery, - GatewayConfig, + AuctionTypeOpen, AuctionWon, EmailDeliveryAttempt, - AuctionTypeOpen, + GatewayConfig, + MemberRecipient, + Membership, + Notification, + NotificationEmailDelivery, + Unread, + User, } from '../../model' -import { defaultNotificationPreferences } from '../../utils/notification' +import { uniqueId } from '../../utils/crypto' import { globalEm } from '../../utils/globalEm' import { idStringFromNumber } from '../../utils/misc' +import { defaultNotificationPreferences } from '../../utils/notification' import { RUNTIME_NOTIFICATION_ID_TAG } from '../../utils/notification/helpers' -import { uniqueId } from '../../utils/crypto' export const NUM_ENTITIES = 3 @@ -33,7 +33,7 @@ export async function populateDbWithSeedData() { totalChannelsCreated: 0, handle: `handle-${i}`, handleRaw: '0x' + Buffer.from(`handle-${i}`).toString('hex'), - controllerAccount: `j4${i}7rVcUCxi2crhhjRq46fNDRbVHTjJrz6bKxZwehEMQxZeSf`, + controllerAccountId: `j4${i}7rVcUCxi2crhhjRq46fNDRbVHTjJrz6bKxZwehEMQxZeSf`, channels: [], }) // create users diff --git a/src/tests/integration/notifications.test.ts b/src/tests/integration/notifications.test.ts index a5e4455aa..19eac4f79 100644 --- a/src/tests/integration/notifications.test.ts +++ b/src/tests/integration/notifications.test.ts @@ -1,14 +1,15 @@ -import { EntityManager } from 'typeorm' -import { IMemberRemarked, ReactVideo, MemberRemarked } from '@joystream/metadata-protobuf' +import { IMemberRemarked, MemberRemarked, ReactVideo } from '@joystream/metadata-protobuf' import { AnyMetadataClass } from '@joystream/metadata-protobuf/types' -import { defaultTestBlock, populateDbWithSeedData } from './testUtils' -import { globalEm } from '../../utils/globalEm' -import { - excludeChannelService, - verifyChannelService, -} from '../../server-extension/resolvers/ChannelsResolver' +import { Store } from '@subsquid/typeorm-store' +import { expect } from 'chai' +import { config as dontenvConfig } from 'dotenv' +import Long from 'long' +import path from 'path' +import { EntityManager } from 'typeorm' +import { auctionBidMadeInner } from '../../mappings/content/nft' +import { processMemberRemarkedEvent } from '../../mappings/membership' +import { backwardCompatibleMetaID, getAccountForMember } from '../../mappings/utils' import { - Account, Channel, ChannelExcluded, ChannelRecipient, @@ -26,21 +27,19 @@ import { Video, VideoLiked, } from '../../model' -import { expect } from 'chai' +import { setFeaturedNftsInner } from '../../server-extension/resolvers/AdminResolver' +import { + excludeChannelService, + verifyChannelService, +} from '../../server-extension/resolvers/ChannelsResolver' +import { excludeVideoService } from '../../server-extension/resolvers/VideosResolver' +import { globalEm } from '../../utils/globalEm' import { OFFCHAIN_NOTIFICATION_ID_TAG, RUNTIME_NOTIFICATION_ID_TAG, } from '../../utils/notification/helpers' -import { setFeaturedNftsInner } from '../../server-extension/resolvers/AdminResolver' -import { auctionBidMadeInner } from '../../mappings/content/nft' import { EntityManagerOverlay } from '../../utils/overlay' -import { Store } from '@subsquid/typeorm-store' -import { processMemberRemarkedEvent } from '../../mappings/membership' -import Long from 'long' -import { backwardCompatibleMetaID } from '../../mappings/utils' -import { config as dontenvConfig } from 'dotenv' -import path from 'path' -import { excludeVideoService } from '../../server-extension/resolvers/VideosResolver' +import { defaultTestBlock, populateDbWithSeedData } from './testUtils' dontenvConfig({ path: path.resolve(__dirname, './.env'), @@ -84,9 +83,7 @@ describe('notifications tests', () => { }) const channel = await em.getRepository(Channel).findOneByOrFail({ id: channelId }) const nextNotificationIdPost = await getNextNotificationId(em, false) - const account = await em - .getRepository(Account) - .findOneBy({ membershipId: channel!.ownerMemberId! }) + const account = await getAccountForMember(em, channel!.ownerMemberId!) expect(notification).not.to.be.null expect(channel).not.to.be.null expect(notification!.notificationType.isTypeOf).to.equal('ChannelVerified') @@ -132,9 +129,7 @@ describe('notifications tests', () => { }) const channel = await em.getRepository(Channel).findOneBy({ id: channelId }) const nextNotificationIdPost = await getNextNotificationId(em, false) - const account = await em - .getRepository(Account) - .findOneBy({ membershipId: channel!.ownerMemberId! }) + const account = await getAccountForMember(em, channel!.ownerMemberId!) expect(notification).not.to.be.null expect(channel).not.to.be.null expect(notification!.notificationType.isTypeOf).to.equal('ChannelExcluded') @@ -191,9 +186,7 @@ describe('notifications tests', () => { expect(video).not.to.be.null expect(video!.channel).not.to.be.null const nextNotificationIdPost = await getNextNotificationId(em, false) - const account = await em - .getRepository(Account) - .findOneBy({ membershipId: video!.channel.ownerMemberId! }) + const account = await getAccountForMember(em, video!.channel!.ownerMemberId!) expect(notification).not.to.be.null expect(notification!.notificationType.isTypeOf).to.equal('VideoExcluded') expect(notification!.status.isTypeOf).to.equal('Unread') @@ -240,9 +233,7 @@ describe('notifications tests', () => { const nft = await em .getRepository(OwnedNft) .findOneOrFail({ where: { id: nftId }, relations: { video: { channel: true } } }) - const account = await em - .getRepository(Account) - .findOneBy({ membershipId: nft!.video!.channel!.ownerMemberId! }) + const account = await getAccountForMember(em, nft!.video!.channel!.ownerMemberId!) const nextNotificationIdPost = await getNextNotificationId(em, false) expect(notification).not.to.be.null expect(notification!.notificationType.isTypeOf).to.equal('NftFeaturedOnMarketPlace') @@ -300,9 +291,7 @@ describe('notifications tests', () => { notification = (await overlay .getRepository(Notification) .getById(notificationId)) as Notification | null - const account = (await overlay - .getRepository(Account) - .getOneByRelationOrFail('membershipId', outbiddedMember)) as Account + const account = await getAccountForMember(em, outbiddedMember) expect(notification).not.to.be.null expect(notification!.notificationType.isTypeOf).to.equal('HigherBidPlaced') @@ -328,9 +317,7 @@ describe('notifications tests', () => { notification = (await overlay .getRepository(Notification) .getByIdOrFail(notificationId)) as Notification - const account = await overlay - .getRepository(Account) - .getOneByRelationOrFail('membershipId', channel!.ownerMemberId!) + const account = await getAccountForMember(em, channel!.ownerMemberId!) // complete the missing checks as above expect(notification).not.to.be.null diff --git a/src/tests/integration/seedData.test.ts b/src/tests/integration/seedData.test.ts index 536807c23..ee38bd163 100644 --- a/src/tests/integration/seedData.test.ts +++ b/src/tests/integration/seedData.test.ts @@ -1,8 +1,7 @@ import { expect } from 'chai' +import { EntityManager } from 'typeorm' import { Account, Auction, AuctionTypeEnglish, Channel, OwnedNft } from '../../model' import { globalEm } from '../../utils/globalEm' -import { EntityManager } from 'typeorm' -import _ from 'lodash' import { clearDb, populateDbWithSeedData } from './testUtils' describe('Database seed data tests', () => { @@ -19,9 +18,9 @@ describe('Database seed data tests', () => { it('check that seed membership data exists', async () => { const result = await em .getRepository(Account) - .findOne({ where: { id: '1' }, relations: { membership: true } }) + .findOne({ where: { id: '1' }, relations: { joystreamAccount: { memberships: true } } }) expect(result).to.not.be.null - expect(result?.membership.id).to.equal('1') + expect(result?.joystreamAccount.memberships[0].id).to.equal('1') }) describe('check that seed content entities exists', () => { diff --git a/src/tests/integration/testUtils.ts b/src/tests/integration/testUtils.ts index 799476ece..44d22e780 100644 --- a/src/tests/integration/testUtils.ts +++ b/src/tests/integration/testUtils.ts @@ -5,6 +5,7 @@ import { Auction, AuctionTypeEnglish, Bid, + BlockchainAccount, Channel, Membership, NftOwnerChannel, @@ -24,30 +25,35 @@ import { defaultNotificationPreferences } from '../../utils/notification' export async function populateDbWithSeedData() { const em = await globalEm for (let i = 0; i < 10; i++) { + const controllerAccountId = `controller-account-${i}` + const blockchainAccount = new BlockchainAccount({ + id: controllerAccountId, + }) + await em.save(blockchainAccount) const member = new Membership({ createdAt: new Date(), id: i.toString(), - controllerAccount: `controller-account-${i}`, + controllerAccountId, handle: `handle-${i}`, handleRaw: '0x' + Buffer.from(`handle-${i}`).toString('hex'), totalChannelsCreated: 0, }) + await em.save(member) const user = new User({ id: `user-${i}`, isRoot: false, }) + await em.save(user) const account = new Account({ id: i.toString(), email: `test-email-${i}@example.com`, - isEmailConfirmed: false, registeredAt: new Date(), isBlocked: false, userId: user.id, - joystreamAccount: `test-joystream-account-${i}`, - membershipId: member.id, + joystreamAccountId: controllerAccountId, notificationPreferences: defaultNotificationPreferences(), }) - await em.save([user, member, account]) + await em.save(account) } for (let i = 0; i < 10; i++) { const channel = new Channel({ diff --git a/src/tests/migrations/migration.test.ts b/src/tests/migrations/migration.test.ts index 4f11e9610..704f53fa8 100644 --- a/src/tests/migrations/migration.test.ts +++ b/src/tests/migrations/migration.test.ts @@ -1,17 +1,15 @@ +import { expect } from 'chai' import { EntityManager } from 'typeorm' -import { globalEm } from '../../utils/globalEm' +import { getAccountForMemberOrFail } from '../../mappings/utils' import { Account, Channel, NextEntityId, NotificationPreference } from '../../model' -import { expect } from 'chai' +import { globalEm } from '../../utils/globalEm' const arePrefsAllTrue = (account: Account) => Object.values(account.notificationPreferences).every((v: NotificationPreference) => { return v.inAppEnabled && v.emailEnabled }) const queryAccount = async (membershipId: string, em: EntityManager) => { - return await em.getRepository(Account).findOneOrFail({ - where: { membershipId }, - relations: { notifications: true }, - }) + return await getAccountForMemberOrFail(em, membershipId) } describe('Migration from 3.0.2 to 3.2.0', () => { @@ -36,10 +34,6 @@ describe('Migration from 3.0.2 to 3.2.0', () => { expect(aliceAccount.notifications).to.have.lengthOf(0) expect(bobAccount.notifications).to.have.lengthOf(0) }) - it('referrer channel id should be null', () => { - expect(aliceAccount.referrerChannelId).to.be.null - expect(bobAccount.referrerChannelId).to.be.null - }) }) describe('Channels are migrated properly', () => { const aliceChannelId = '1' From bb8f9891bb45a0ca1c34a184f3c5736d8b9720ac Mon Sep 17 00:00:00 2001 From: Zeeshan Akram <97m.zeeshan@gmail.com> Date: Mon, 18 Mar 2024 13:39:30 +0500 Subject: [PATCH 13/22] update auth-api docs --- .env | 7 + src/auth-server/docs/.openapi-generator/FILES | 1 - src/auth-server/docs/Apis/DefaultApi.md | 30 +- .../docs/Models/ConfirmEmailRequestData.md | 9 - src/auth-server/docs/README.md | 4 +- src/auth-server/generated/api-types.ts | 508 ++++++++---------- src/auth-server/utils.ts | 47 +- 7 files changed, 278 insertions(+), 328 deletions(-) delete mode 100644 src/auth-server/docs/Models/ConfirmEmailRequestData.md diff --git a/.env b/.env index 3cc110bab..c85fc0eeb 100644 --- a/.env +++ b/.env @@ -76,6 +76,13 @@ APP_ASSET_STORAGE=https://raw.githubusercontent.com/Joystream/atlas-notification APP_NAME_ALT=Gleev.xyz NOTIFICATION_ASSET_ROOT=https://raw.githubusercontent.com/Joystream/atlas-notification-assets/main/icons +# ===================================================================================== +# Faucet config +# ===================================================================================== + +FAUCET_URL=http://localhost:3002/register +FAUCET_CAPTCHA_BYPASS_KEY=faucet-captcha-bypass-key + # ===================================================================================== # Telemetry # ===================================================================================== diff --git a/src/auth-server/docs/.openapi-generator/FILES b/src/auth-server/docs/.openapi-generator/FILES index 33a91b3b2..9354fa743 100644 --- a/src/auth-server/docs/.openapi-generator/FILES +++ b/src/auth-server/docs/.openapi-generator/FILES @@ -6,7 +6,6 @@ Models/AnonymousUserAuthResponseData.md Models/AnonymousUserAuthResponseData_allOf.md Models/ChangeAccountRequestData.md Models/ChangeAccountRequestData_allOf.md -Models/ConfirmEmailRequestData.md Models/CreateAccountRequestData.md Models/CreateAccountRequestData_allOf.md Models/EncryptionArtifacts.md diff --git a/src/auth-server/docs/Apis/DefaultApi.md b/src/auth-server/docs/Apis/DefaultApi.md index 6a4fabc21..0e570bbe8 100644 --- a/src/auth-server/docs/Apis/DefaultApi.md +++ b/src/auth-server/docs/Apis/DefaultApi.md @@ -6,7 +6,6 @@ All URIs are relative to *http://localhost/api/v1* |------------- | ------------- | -------------| | [**anonymousAuth**](DefaultApi.md#anonymousAuth) | **POST** /anonymous-auth | | | [**changeAccount**](DefaultApi.md#changeAccount) | **POST** /change-account | | -| [**confirmEmail**](DefaultApi.md#confirmEmail) | **POST** /confirm-email | | | [**createAccount**](DefaultApi.md#createAccount) | **POST** /account | | | [**getArtifacts**](DefaultApi.md#getArtifacts) | **GET** /artifacts | | | [**getSessionArtifacts**](DefaultApi.md#getSessionArtifacts) | **GET** /session-artifacts | | @@ -70,33 +69,6 @@ No authorization required - **Content-Type**: application/json - **Accept**: application/json - -# **confirmEmail** -> GenericOkResponseData confirmEmail(ConfirmEmailRequestData) - - - - Confirm account's e-mail address provided during registration. - -### Parameters - -|Name | Type | Description | Notes | -|------------- | ------------- | ------------- | -------------| -| **ConfirmEmailRequestData** | [**ConfirmEmailRequestData**](../Models/ConfirmEmailRequestData.md)| | [optional] | - -### Return type - -[**GenericOkResponseData**](../Models/GenericOkResponseData.md) - -### Authorization - -No authorization required - -### HTTP request headers - -- **Content-Type**: application/json -- **Accept**: application/json - # **createAccount** > GenericOkResponseData createAccount(CreateAccountRequestData) @@ -260,7 +232,7 @@ This endpoint does not need any parameter. - Request a token to be sent to account's e-mail address, which will allow confirming the ownership of the e-mail by the user. + Request a token to be sent to e-mail address (as the first step of signup process), which will allow confirming the ownership of the e-mail by the user. ### Parameters diff --git a/src/auth-server/docs/Models/ConfirmEmailRequestData.md b/src/auth-server/docs/Models/ConfirmEmailRequestData.md deleted file mode 100644 index 3d3542cb7..000000000 --- a/src/auth-server/docs/Models/ConfirmEmailRequestData.md +++ /dev/null @@ -1,9 +0,0 @@ -# ConfirmEmailRequestData -## Properties - -| Name | Type | Description | Notes | -|------------ | ------------- | ------------- | -------------| -| **token** | **String** | Confirmation token recieved by the user via an e-mail. | [default to null] | - -[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) - diff --git a/src/auth-server/docs/README.md b/src/auth-server/docs/README.md index a7f237c26..fb27af937 100644 --- a/src/auth-server/docs/README.md +++ b/src/auth-server/docs/README.md @@ -9,14 +9,13 @@ All URIs are relative to *http://localhost/api/v1* |------------ | ------------- | ------------- | -------------| | *DefaultApi* | [**anonymousAuth**](Apis/DefaultApi.md#anonymousauth) | **POST** /anonymous-auth | Authenticate as an anonymous user, either using an existing user identifier or creating a new one. | *DefaultApi* | [**changeAccount**](Apis/DefaultApi.md#changeaccount) | **POST** /change-account | Change the blockchain (Joystream) account associated with the Gateway account. Delete the old account's encryption artifacts and optionally set new ones. | -*DefaultApi* | [**confirmEmail**](Apis/DefaultApi.md#confirmemail) | **POST** /confirm-email | Confirm account's e-mail address provided during registration. | *DefaultApi* | [**createAccount**](Apis/DefaultApi.md#createaccount) | **POST** /account | Create a new Gateway account. Requires anonymousAuth to be performed first. | *DefaultApi* | [**getArtifacts**](Apis/DefaultApi.md#getartifacts) | **GET** /artifacts | Get wallet seed encryption artifacts. | *DefaultApi* | [**getSessionArtifacts**](Apis/DefaultApi.md#getsessionartifacts) | **GET** /session-artifacts | Get wallet seed encryption artifacts for the current session. | *DefaultApi* | [**login**](Apis/DefaultApi.md#login) | **POST** /login | Login to user's account by providing a message signed by the associated blockchain account. | *DefaultApi* | [**logout**](Apis/DefaultApi.md#logout) | **POST** /logout | Terminate the current session. | *DefaultApi* | [**postSessionArtifacts**](Apis/DefaultApi.md#postsessionartifacts) | **POST** /session-artifacts | Save wallet seed encryption artifacts for the current session on the server. | -*DefaultApi* | [**requestEmailConfirmationToken**](Apis/DefaultApi.md#requestemailconfirmationtoken) | **POST** /request-email-confirmation-token | Request a token to be sent to account's e-mail address, which will allow confirming the ownership of the e-mail by the user. | +*DefaultApi* | [**requestEmailConfirmationToken**](Apis/DefaultApi.md#requestemailconfirmationtoken) | **POST** /request-email-confirmation-token | Request a token to be sent to e-mail address (as the first step of signup process), which will allow confirming the ownership of the e-mail by the user. | @@ -29,7 +28,6 @@ All URIs are relative to *http://localhost/api/v1* - [AnonymousUserAuthResponseData_allOf](./Models/AnonymousUserAuthResponseData_allOf.md) - [ChangeAccountRequestData](./Models/ChangeAccountRequestData.md) - [ChangeAccountRequestData_allOf](./Models/ChangeAccountRequestData_allOf.md) - - [ConfirmEmailRequestData](./Models/ConfirmEmailRequestData.md) - [CreateAccountRequestData](./Models/CreateAccountRequestData.md) - [CreateAccountRequestData_allOf](./Models/CreateAccountRequestData_allOf.md) - [EncryptionArtifacts](./Models/EncryptionArtifacts.md) diff --git a/src/auth-server/generated/api-types.ts b/src/auth-server/generated/api-types.ts index a5b821866..48d8b6217 100644 --- a/src/auth-server/generated/api-types.ts +++ b/src/auth-server/generated/api-types.ts @@ -3,408 +3,374 @@ * Do not make direct changes to the file. */ + export interface paths { - '/anonymous-auth': { + "/anonymous-auth": { /** @description Authenticate as an anonymous user, either using an existing user identifier or creating a new one. */ - post: operations['anonymousAuth'] - } - '/login': { + post: operations["anonymousAuth"]; + }; + "/login": { /** @description Login to user's account by providing a message signed by the associated blockchain account. */ - post: operations['login'] - } - '/artifacts': { + post: operations["login"]; + }; + "/artifacts": { /** @description Get wallet seed encryption artifacts. */ - get: operations['getArtifacts'] - } - '/session-artifacts': { + get: operations["getArtifacts"]; + }; + "/session-artifacts": { /** @description Get wallet seed encryption artifacts for the current session. */ - get: operations['getSessionArtifacts'] + get: operations["getSessionArtifacts"]; /** @description Save wallet seed encryption artifacts for the current session on the server. */ - post: operations['postSessionArtifacts'] - } - '/account': { + post: operations["postSessionArtifacts"]; + }; + "/account": { /** @description Create a new Gateway account. Requires anonymousAuth to be performed first. */ - post: operations['createAccount'] - } - '/confirm-email': { - /** @description Confirm account's e-mail address provided during registration. */ - post: operations['confirmEmail'] - } - '/request-email-confirmation-token': { - /** @description Request a token to be sent to account's e-mail address, which will allow confirming the ownership of the e-mail by the user. */ - post: operations['requestEmailConfirmationToken'] - } - '/change-account': { + post: operations["createAccount"]; + }; + "/request-email-confirmation-token": { + /** @description Request a token to be sent to e-mail address (as the first step of signup process), which will allow confirming the ownership of the e-mail by the user. */ + post: operations["requestEmailConfirmationToken"]; + }; + "/change-account": { /** @description Change the blockchain (Joystream) account associated with the Gateway account. Delete the old account's encryption artifacts and optionally set new ones. */ - post: operations['changeAccount'] - } - '/logout': { + post: operations["changeAccount"]; + }; + "/logout": { /** @description Terminate the current session. */ - post: operations['logout'] - } + post: operations["logout"]; + }; } -export type webhooks = Record +export type webhooks = Record; export interface components { schemas: { ActionExecutionPayload: { - joystreamAccountId: string - gatewayName: string - timestamp: number - action: string - } + joystreamAccountId: string; + gatewayName: string; + timestamp: number; + action: string; + }; ActionExecutionRequestData: { - signature: string - payload: components['schemas']['ActionExecutionPayload'] - } + signature: string; + payload: components["schemas"]["ActionExecutionPayload"]; + }; AnonymousUserAuthRequestData: { - userId?: string - } - AnonymousUserAuthResponseData: components['schemas']['GenericOkResponseData'] & { - userId: string - } - LoginRequestData: components['schemas']['ActionExecutionRequestData'] & { - payload?: components['schemas']['ActionExecutionPayload'] & { + userId?: string; + }; + AnonymousUserAuthResponseData: components["schemas"]["GenericOkResponseData"] & { + userId: string; + }; + LoginRequestData: components["schemas"]["ActionExecutionRequestData"] & ({ + payload?: components["schemas"]["ActionExecutionPayload"] & { /** @enum {string} */ - action?: 'login' - } - } + action?: "login"; + }; + }); LoginResponseData: { - accountId: string - } - CreateAccountRequestData: components['schemas']['ActionExecutionRequestData'] & { - payload?: components['schemas']['ActionExecutionPayload'] & { + accountId: string; + }; + CreateAccountRequestData: components["schemas"]["ActionExecutionRequestData"] & ({ + payload?: components["schemas"]["ActionExecutionPayload"] & { /** @enum {string} */ - action?: 'createAccount' - memberId: string - email: string - encryptionArtifacts?: components['schemas']['EncryptionArtifacts'] - } - } - ChangeAccountRequestData: components['schemas']['ActionExecutionRequestData'] & { - payload?: components['schemas']['ActionExecutionPayload'] & { + action?: "createAccount"; + email: string; + emailConfirmationToken?: string; + encryptionArtifacts?: components["schemas"]["EncryptionArtifacts"]; + }; + }); + ChangeAccountRequestData: components["schemas"]["ActionExecutionRequestData"] & ({ + payload?: components["schemas"]["ActionExecutionPayload"] & { /** @enum {string} */ - action?: 'changeAccount' - gatewayAccountId: string - newArtifacts?: components['schemas']['EncryptionArtifacts'] - } - } - ConfirmEmailRequestData: { - /** @description Confirmation token recieved by the user via an e-mail. */ - token: string - } + action?: "changeAccount"; + gatewayAccountId: string; + newArtifacts?: components["schemas"]["EncryptionArtifacts"]; + }; + }); RequestTokenRequestData: { /** @description User's e-mail address. */ - email: string - } + email: string; + }; GenericErrorResponseData: { - message?: string - errors?: string[] - } + message?: string; + errors?: string[]; + }; GenericOkResponseData: { - success: boolean - } + success: boolean; + }; EncryptionArtifacts: { - id: string - encryptedSeed: string - cipherIv: string - } + id: string; + encryptedSeed: string; + cipherIv: string; + }; SessionEncryptionArtifacts: { - cipherKey: string - cipherIv: string - } - } + cipherKey: string; + cipherIv: string; + }; + }; responses: { /** @description Ok */ AnonymousUserAuthOkResponse: { content: { - 'application/json': components['schemas']['AnonymousUserAuthResponseData'] - } - } + "application/json": components["schemas"]["AnonymousUserAuthResponseData"]; + }; + }; /** @description Invalid request data */ GenericBadRequestResponse: { content: { - 'application/json': components['schemas']['GenericErrorResponseData'] - } - } + "application/json": components["schemas"]["GenericErrorResponseData"]; + }; + }; /** @description Internal server error */ GenericInternalServerErrorResponse: { content: { - 'application/json': components['schemas']['GenericErrorResponseData'] - } - } + "application/json": components["schemas"]["GenericErrorResponseData"]; + }; + }; /** @description Anonymous user id not recognized */ UnauthorizedAnonymousUserResponse: { content: { - 'application/json': components['schemas']['GenericErrorResponseData'] - } - } + "application/json": components["schemas"]["GenericErrorResponseData"]; + }; + }; /** @description Logged in */ LoginOkResponse: { content: { - 'application/json': components['schemas']['LoginResponseData'] - } - } + "application/json": components["schemas"]["LoginResponseData"]; + }; + }; /** @description Account not found by provided address or the address cannot be used to login. */ LoginUnauthorizedResponse: { content: { - 'application/json': components['schemas']['GenericErrorResponseData'] - } - } + "application/json": components["schemas"]["GenericErrorResponseData"]; + }; + }; /** @description Cannot create user account with the provided credentials */ CreateAccountBadRequestResponse: { content: { - 'application/json': components['schemas']['GenericErrorResponseData'] - } - } + "application/json": components["schemas"]["GenericErrorResponseData"]; + }; + }; /** @description Ok */ GenericOkResponse: { content: { - 'application/json': components['schemas']['GenericOkResponseData'] - } - } - /** @description Missing token or provided token is invalid / already used. */ - ConfirmEmailBadRequestResponse: { - content: { - 'application/json': components['schemas']['GenericErrorResponseData'] - } - } + "application/json": components["schemas"]["GenericOkResponseData"]; + }; + }; /** @description Access token (session id) is missing or invalid. */ GenericUnauthorizedResponse: { content: { - 'application/json': components['schemas']['GenericErrorResponseData'] - } - } + "application/json": components["schemas"]["GenericErrorResponseData"]; + }; + }; /** @description Request is malformatted or provided e-mail address is not valid. */ RequestTokenBadRequestResponse: { content: { - 'application/json': components['schemas']['GenericErrorResponseData'] - } - } + "application/json": components["schemas"]["GenericErrorResponseData"]; + }; + }; /** @description Too many requests for a new token sent within a given timeframe. */ RequestTokenTooManyRequestsResponse: { content: { - 'application/json': components['schemas']['GenericErrorResponseData'] - } - } + "application/json": components["schemas"]["GenericErrorResponseData"]; + }; + }; /** @description Too many requests sent within a given timeframe. */ GenericTooManyRequestsResponse: { content: { - 'application/json': components['schemas']['GenericErrorResponseData'] - } - } - /** @description Provided e-mail address is not associated with any account. */ - RequestEmailConfirmationAccountNotFoundResponse: { - content: { - 'application/json': components['schemas']['GenericErrorResponseData'] - } - } + "application/json": components["schemas"]["GenericErrorResponseData"]; + }; + }; /** @description Encryption artifacts found and provided in the response. */ GetArtifactsResponse: { content: { - 'application/json': components['schemas']['EncryptionArtifacts'] - } - } + "application/json": components["schemas"]["EncryptionArtifacts"]; + }; + }; /** @description Encryption artifacts not found by the provided lookup key. */ GetArtifactsNotFoundResponse: { content: { - 'application/json': components['schemas']['GenericErrorResponseData'] - } - } + "application/json": components["schemas"]["GenericErrorResponseData"]; + }; + }; /** @description No artifacts associated with the current session found. */ GetSessionArtifactsNotFoundResponse: { content: { - 'application/json': components['schemas']['GenericErrorResponseData'] - } - } + "application/json": components["schemas"]["GenericErrorResponseData"]; + }; + }; /** @description Account with the provided e-mail address already exists. */ CreateAccountConflictResponse: { content: { - 'application/json': components['schemas']['GenericErrorResponseData'] - } - } + "application/json": components["schemas"]["GenericErrorResponseData"]; + }; + }; /** @description Session encryption artifacts for the current session already saved. */ PostSessionArtifactsConflictResponse: { content: { - 'application/json': components['schemas']['GenericErrorResponseData'] - } - } + "application/json": components["schemas"]["GenericErrorResponseData"]; + }; + }; /** @description Provided blockchain (Joystream) account is already assigned to another user account. */ ChangeAccountConflictResponse: { content: { - 'application/json': components['schemas']['GenericErrorResponseData'] - } - } + "application/json": components["schemas"]["GenericErrorResponseData"]; + }; + }; /** @description On-chain membership not found by the provided memberId. */ CreateAccountNotFoundResponse: { content: { - 'application/json': components['schemas']['GenericErrorResponseData'] - } - } - } - parameters: never + "application/json": components["schemas"]["GenericErrorResponseData"]; + }; + }; + }; + parameters: never; requestBodies: { AnonymousUserAuthRequestBody?: { content: { - 'application/json': components['schemas']['AnonymousUserAuthRequestData'] - } - } + "application/json": components["schemas"]["AnonymousUserAuthRequestData"]; + }; + }; LoginRequestBody?: { content: { - 'application/json': components['schemas']['LoginRequestData'] - } - } + "application/json": components["schemas"]["LoginRequestData"]; + }; + }; CreateAccountRequestBody?: { content: { - 'application/json': components['schemas']['CreateAccountRequestData'] - } - } - ConfirmEmailRequestBody?: { - content: { - 'application/json': components['schemas']['ConfirmEmailRequestData'] - } - } + "application/json": components["schemas"]["CreateAccountRequestData"]; + }; + }; RequestTokenRequestBody?: { content: { - 'application/json': components['schemas']['RequestTokenRequestData'] - } - } + "application/json": components["schemas"]["RequestTokenRequestData"]; + }; + }; PostSessionArtifactsRequestBody?: { content: { - 'application/json': components['schemas']['SessionEncryptionArtifacts'] - } - } + "application/json": components["schemas"]["SessionEncryptionArtifacts"]; + }; + }; ChangeAccountRequestBody?: { content: { - 'application/json': components['schemas']['ChangeAccountRequestData'] - } - } - } - headers: never - pathItems: never + "application/json": components["schemas"]["ChangeAccountRequestData"]; + }; + }; + }; + headers: never; + pathItems: never; } -export type $defs = Record +export type $defs = Record; -export type external = Record +export type external = Record; export interface operations { + /** @description Authenticate as an anonymous user, either using an existing user identifier or creating a new one. */ anonymousAuth: { - requestBody: components['requestBodies']['AnonymousUserAuthRequestBody'] + requestBody: components["requestBodies"]["AnonymousUserAuthRequestBody"]; responses: { - 200: components['responses']['AnonymousUserAuthOkResponse'] - 400: components['responses']['GenericBadRequestResponse'] - 401: components['responses']['UnauthorizedAnonymousUserResponse'] - 429: components['responses']['GenericTooManyRequestsResponse'] - default: components['responses']['GenericInternalServerErrorResponse'] - } - } + 200: components["responses"]["AnonymousUserAuthOkResponse"]; + 400: components["responses"]["GenericBadRequestResponse"]; + 401: components["responses"]["UnauthorizedAnonymousUserResponse"]; + 429: components["responses"]["GenericTooManyRequestsResponse"]; + default: components["responses"]["GenericInternalServerErrorResponse"]; + }; + }; /** @description Login to user's account by providing a message signed by the associated blockchain account. */ login: { - requestBody: components['requestBodies']['LoginRequestBody'] + requestBody: components["requestBodies"]["LoginRequestBody"]; responses: { - 200: components['responses']['LoginOkResponse'] - 400: components['responses']['GenericBadRequestResponse'] - 401: components['responses']['LoginUnauthorizedResponse'] - 429: components['responses']['GenericTooManyRequestsResponse'] - default: components['responses']['GenericInternalServerErrorResponse'] - } - } + 200: components["responses"]["LoginOkResponse"]; + 400: components["responses"]["GenericBadRequestResponse"]; + 401: components["responses"]["LoginUnauthorizedResponse"]; + 429: components["responses"]["GenericTooManyRequestsResponse"]; + default: components["responses"]["GenericInternalServerErrorResponse"]; + }; + }; /** @description Get wallet seed encryption artifacts. */ getArtifacts: { parameters: { query: { /** @description The lookup key derived from user's credentials. */ - id: string + id: string; /** @description The user's email address. */ - email: string - } - } + email: string; + }; + }; responses: { - 200: components['responses']['GetArtifactsResponse'] - 400: components['responses']['GenericBadRequestResponse'] - 404: components['responses']['GetArtifactsNotFoundResponse'] - 429: components['responses']['GenericTooManyRequestsResponse'] - default: components['responses']['GenericInternalServerErrorResponse'] - } - } + 200: components["responses"]["GetArtifactsResponse"]; + 400: components["responses"]["GenericBadRequestResponse"]; + 404: components["responses"]["GetArtifactsNotFoundResponse"]; + 429: components["responses"]["GenericTooManyRequestsResponse"]; + default: components["responses"]["GenericInternalServerErrorResponse"]; + }; + }; /** @description Get wallet seed encryption artifacts for the current session. */ getSessionArtifacts: { responses: { - 200: components['responses']['GetArtifactsResponse'] - 401: components['responses']['GenericUnauthorizedResponse'] - 404: components['responses']['GetSessionArtifactsNotFoundResponse'] - 429: components['responses']['GenericTooManyRequestsResponse'] - default: components['responses']['GenericInternalServerErrorResponse'] - } - } + 200: components["responses"]["GetArtifactsResponse"]; + 401: components["responses"]["GenericUnauthorizedResponse"]; + 404: components["responses"]["GetSessionArtifactsNotFoundResponse"]; + 429: components["responses"]["GenericTooManyRequestsResponse"]; + default: components["responses"]["GenericInternalServerErrorResponse"]; + }; + }; /** @description Save wallet seed encryption artifacts for the current session on the server. */ postSessionArtifacts: { - requestBody: components['requestBodies']['PostSessionArtifactsRequestBody'] + requestBody: components["requestBodies"]["PostSessionArtifactsRequestBody"]; responses: { - 200: components['responses']['GenericOkResponse'] - 400: components['responses']['GenericBadRequestResponse'] - 401: components['responses']['GenericUnauthorizedResponse'] - 409: components['responses']['PostSessionArtifactsConflictResponse'] - 429: components['responses']['GenericTooManyRequestsResponse'] - default: components['responses']['GenericInternalServerErrorResponse'] - } - } + 200: components["responses"]["GenericOkResponse"]; + 400: components["responses"]["GenericBadRequestResponse"]; + 401: components["responses"]["GenericUnauthorizedResponse"]; + 409: components["responses"]["PostSessionArtifactsConflictResponse"]; + 429: components["responses"]["GenericTooManyRequestsResponse"]; + default: components["responses"]["GenericInternalServerErrorResponse"]; + }; + }; /** @description Create a new Gateway account. Requires anonymousAuth to be performed first. */ createAccount: { - requestBody: components['requestBodies']['CreateAccountRequestBody'] - responses: { - 200: components['responses']['GenericOkResponse'] - 400: components['responses']['CreateAccountBadRequestResponse'] - 401: components['responses']['GenericUnauthorizedResponse'] - 404: components['responses']['CreateAccountNotFoundResponse'] - 409: components['responses']['CreateAccountConflictResponse'] - 429: components['responses']['GenericTooManyRequestsResponse'] - default: components['responses']['GenericInternalServerErrorResponse'] - } - } - /** @description Confirm account's e-mail address provided during registration. */ - confirmEmail: { - requestBody: components['requestBodies']['ConfirmEmailRequestBody'] + requestBody: components["requestBodies"]["CreateAccountRequestBody"]; responses: { - 200: components['responses']['GenericOkResponse'] - 400: components['responses']['ConfirmEmailBadRequestResponse'] - 429: components['responses']['GenericTooManyRequestsResponse'] - default: components['responses']['GenericInternalServerErrorResponse'] - } - } - /** @description Request a token to be sent to account's e-mail address, which will allow confirming the ownership of the e-mail by the user. */ + 200: components["responses"]["GenericOkResponse"]; + 400: components["responses"]["CreateAccountBadRequestResponse"]; + 401: components["responses"]["GenericUnauthorizedResponse"]; + 404: components["responses"]["CreateAccountNotFoundResponse"]; + 409: components["responses"]["CreateAccountConflictResponse"]; + 429: components["responses"]["GenericTooManyRequestsResponse"]; + default: components["responses"]["GenericInternalServerErrorResponse"]; + }; + }; + /** @description Request a token to be sent to e-mail address (as the first step of signup process), which will allow confirming the ownership of the e-mail by the user. */ requestEmailConfirmationToken: { - requestBody: components['requestBodies']['RequestTokenRequestBody'] + requestBody: components["requestBodies"]["RequestTokenRequestBody"]; responses: { - 200: components['responses']['GenericOkResponse'] - 400: components['responses']['RequestTokenBadRequestResponse'] - 404: components['responses']['RequestEmailConfirmationAccountNotFoundResponse'] - 429: components['responses']['RequestTokenTooManyRequestsResponse'] - default: components['responses']['GenericInternalServerErrorResponse'] - } - } + 200: components["responses"]["GenericOkResponse"]; + 400: components["responses"]["RequestTokenBadRequestResponse"]; + 429: components["responses"]["RequestTokenTooManyRequestsResponse"]; + default: components["responses"]["GenericInternalServerErrorResponse"]; + }; + }; /** @description Change the blockchain (Joystream) account associated with the Gateway account. Delete the old account's encryption artifacts and optionally set new ones. */ changeAccount: { - requestBody: components['requestBodies']['ChangeAccountRequestBody'] + requestBody: components["requestBodies"]["ChangeAccountRequestBody"]; responses: { - 200: components['responses']['GenericOkResponse'] - 400: components['responses']['GenericBadRequestResponse'] - 401: components['responses']['GenericUnauthorizedResponse'] - 409: components['responses']['ChangeAccountConflictResponse'] - 429: components['responses']['GenericTooManyRequestsResponse'] - default: components['responses']['GenericInternalServerErrorResponse'] - } - } + 200: components["responses"]["GenericOkResponse"]; + 400: components["responses"]["GenericBadRequestResponse"]; + 401: components["responses"]["GenericUnauthorizedResponse"]; + 409: components["responses"]["ChangeAccountConflictResponse"]; + 429: components["responses"]["GenericTooManyRequestsResponse"]; + default: components["responses"]["GenericInternalServerErrorResponse"]; + }; + }; /** @description Terminate the current session. */ logout: { responses: { - 200: components['responses']['GenericOkResponse'] - 401: components['responses']['GenericUnauthorizedResponse'] - 429: components['responses']['GenericTooManyRequestsResponse'] - default: components['responses']['GenericInternalServerErrorResponse'] - } - } + 200: components["responses"]["GenericOkResponse"]; + 401: components["responses"]["GenericUnauthorizedResponse"]; + 429: components["responses"]["GenericTooManyRequestsResponse"]; + default: components["responses"]["GenericInternalServerErrorResponse"]; + }; + }; } diff --git a/src/auth-server/utils.ts b/src/auth-server/utils.ts index 949027353..ff7f35724 100644 --- a/src/auth-server/utils.ts +++ b/src/auth-server/utils.ts @@ -1,20 +1,31 @@ -import { components } from './generated/api-types' -import { decodeAddress, cryptoWaitReady, signatureVerify } from '@polkadot/util-crypto' -import { BadRequestError } from './errors' -import { config, ConfigVariable } from '../utils/config' import { JOYSTREAM_ADDRESS_PREFIX } from '@joystream/types' -import { Account, Token, TokenType } from '../model' +import { + checkAddress, + cryptoWaitReady, + decodeAddress, + signatureVerify, +} from '@polkadot/util-crypto' import { EntityManager } from 'typeorm' +import { EmailConfirmationToken } from '../model' +import { ConfigVariable, config } from '../utils/config' import { uniqueId } from '../utils/crypto' -import { sgSendMail } from '../utils/mail' import { formatDate } from '../utils/date' +import { sgSendMail } from '../utils/mail' import { registerEmailContent } from './emails' +import { BadRequestError } from './errors' +import { components } from './generated/api-types' export async function verifyActionExecutionRequest( em: EntityManager, { payload, signature }: components['schemas']['ActionExecutionRequestData'] ): Promise { await cryptoWaitReady() + + const [isValid, error] = checkAddress(payload.joystreamAccountId, JOYSTREAM_ADDRESS_PREFIX) + if (!isValid) { + throw new BadRequestError(`Payload joystreamAccountId is invalid. Error: ${error}`) + } + const signatureVerifyResult = signatureVerify( JSON.stringify(payload), signature, @@ -45,26 +56,25 @@ export async function verifyActionExecutionRequest( } } -export async function issueEmailConfirmationToken( - account: Account, +async function issueEmailConfirmationToken( + email: string, em: EntityManager -): Promise { +): Promise { const issuedAt = new Date() const lifetimeMs = (await config.get(ConfigVariable.EmailConfirmationTokenExpiryTimeHours, em)) * 3_600_000 const expiry = new Date(issuedAt.getTime() + lifetimeMs) - const token = new Token({ + const token = new EmailConfirmationToken({ id: uniqueId(), - type: TokenType.EMAIL_CONFIRMATION, expiry, issuedAt, - issuedForId: account.id, + email, }) return em.save(token) } -export async function sendWelcomeEmail(account: Account, em: EntityManager): Promise { - const emailConfirmationToken = await issueEmailConfirmationToken(account, em) +export async function sendWelcomeEmail(email: string, em: EntityManager): Promise { + const emailConfirmationToken = await issueEmailConfirmationToken(email, em) const appName = await config.get(ConfigVariable.AppName, em) const confirmEmailRoute = await config.get(ConfigVariable.EmailConfirmationRoute, em) const emailConfirmationLink = confirmEmailRoute.replace('{token}', emailConfirmationToken.id) @@ -76,8 +86,15 @@ export async function sendWelcomeEmail(account: Account, em: EntityManager): Pro }) await sgSendMail({ from: await config.get(ConfigVariable.SendgridFromEmail, em), - to: account.email, + to: email, subject: `Welcome to ${appName}!`, content: emailContent, }) } + +export async function sendLoginOrChangePasswordEmail( + email: string, + em: EntityManager +): Promise { + // TODO: implement this +} From 1389dfc7ed9b85b23284da0885ad764d8ee25382 Mon Sep 17 00:00:00 2001 From: Zeeshan Akram <97m.zeeshan@gmail.com> Date: Thu, 16 May 2024 20:21:48 +0500 Subject: [PATCH 14/22] small refactor --- src/mappings/content/utils.ts | 17 ++++++++++------- src/utils/mail.ts | 4 +++- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/mappings/content/utils.ts b/src/mappings/content/utils.ts index b7a8948dc..f71e0659f 100644 --- a/src/mappings/content/utils.ts +++ b/src/mappings/content/utils.ts @@ -761,13 +761,16 @@ export async function notifyChannelFollowers( ) { const followersAccounts = await getFollowersAccountsForChannel(overlay, channelId) for (const followerAccount of followersAccounts) { - await addNotification( - overlay, - followerAccount, - new MemberRecipient({ membership: followerAccount.joystreamAccount.memberships[0].id }), // TODO: handle multiple memberships (i.e. follower account address owns multiple memberships). Also, consider a scenario where a follower account has no memberships. - notificationType, - event - ) + // TODO: handling multiple memberships (i.e. follower account address owns multiple memberships). Also, consider a scenario where a follower account has no memberships. + for (const membership of followerAccount.joystreamAccount.memberships) { + await addNotification( + overlay, + followerAccount, + new MemberRecipient({ membership: membership.id }), + notificationType, + event + ) + } } } diff --git a/src/utils/mail.ts b/src/utils/mail.ts index 6189692f2..60f1162e4 100644 --- a/src/utils/mail.ts +++ b/src/utils/mail.ts @@ -24,13 +24,15 @@ export async function sgSendMail({ from, to, subject, content }: SendMailArgs) { ) return } + const [clientResponse] = await sgMail.send({ from, to, subject, html: content, }) - // mailerLogger.info(`E-mail sent:\n${JSON.stringify({ from, to, subject, content }, null, 2)}`) + + mailerLogger.info(`E-mail sent:\n${JSON.stringify({ from, to, subject, content }, null, 2)}`) return clientResponse } From 3a93392f16295b11d84470e3d426318326ce4508 Mon Sep 17 00:00:00 2001 From: Zeeshan Akram <97m.zeeshan@gmail.com> Date: Thu, 16 May 2024 23:35:05 +0500 Subject: [PATCH 15/22] fix: auth-api unit tests --- src/auth-server/index.ts | 1 - src/auth-server/tests/common.ts | 9 +++ .../tests/requestEmailConfirmationToken.ts | 69 ++++++++----------- 3 files changed, 37 insertions(+), 42 deletions(-) diff --git a/src/auth-server/index.ts b/src/auth-server/index.ts index 83bb415d4..5b25fdcab 100644 --- a/src/auth-server/index.ts +++ b/src/auth-server/index.ts @@ -42,7 +42,6 @@ if (process.env.OPENAPI_PLAYGROUND === 'true' || process.env.OPENAPI_PLAYGROUND fs.readFileSync(path.join(__dirname, 'openapi.yml')).toString() ) as JsonObject logger.info('Running playground at /playground') - console.log('Spec', spec) app.use('/playground', swaggerUi.serve, swaggerUi.setup(spec)) } app.use( diff --git a/src/auth-server/tests/common.ts b/src/auth-server/tests/common.ts index 07ba0177a..1e84487a2 100644 --- a/src/auth-server/tests/common.ts +++ b/src/auth-server/tests/common.ts @@ -14,6 +14,7 @@ import { components } from '../generated/api-types' import { app } from '../index' import { SimpleRateLimit, resetAllLimits } from '../rateLimits' import './config' +import { findActiveToken } from './requestEmailConfirmationToken' export const keyring = new Keyring({ type: 'sr25519', ss58Format: JOYSTREAM_ADDRESS_PREFIX }) @@ -151,12 +152,18 @@ export async function createAccount( // const membership = await insertFakeMember(keypair.address) const anonSessionId = await anonymousAuth() + await requestEmailConfirmationToken(email, anonSessionId, 200) + + const token = await findActiveToken(em, email) + assert(token, 'Token not found') + const createAccountReqData = await signedAction< components['schemas']['CreateAccountRequestData'] >( { action: 'createAccount', email, + emailConfirmationToken: token.id, encryptionArtifacts: await prepareEncryptionArtifacts(seed, email, password), }, keypair @@ -196,11 +203,13 @@ export async function confirmEmail(token: string, expectedStatus: number): Promi export async function requestEmailConfirmationToken( email: string, + sessionId: string, expectedStatus: number ): Promise { await request(app) .post('/api/v1/request-email-confirmation-token') .set('Content-Type', 'application/json') + .set('Cookie', `${SESSION_COOKIE_NAME}=${sessionId}`) .send({ email }) .expect(expectedStatus) } diff --git a/src/auth-server/tests/requestEmailConfirmationToken.ts b/src/auth-server/tests/requestEmailConfirmationToken.ts index 300205b55..5342a0f43 100644 --- a/src/auth-server/tests/requestEmailConfirmationToken.ts +++ b/src/auth-server/tests/requestEmailConfirmationToken.ts @@ -1,65 +1,52 @@ -import './config' -import { globalEm } from '../../utils/globalEm' -import { EntityManager, MoreThan } from 'typeorm' -import { - AccountAccessData, - confirmEmail, - createAccount, - requestEmailConfirmationToken, -} from './common' -import { Token, TokenType } from '../../model' +import { cryptoWaitReady } from '@polkadot/util-crypto' import assert from 'assert' +import { EntityManager, MoreThan } from 'typeorm' +import { EmailConfirmationToken } from '../../model' import { ConfigVariable, config } from '../../utils/config' -import { cryptoWaitReady } from '@polkadot/util-crypto' +import { globalEm } from '../../utils/globalEm' +import { anonymousAuth, requestEmailConfirmationToken } from './common' +import './config' -async function findActiveToken(em: EntityManager, accountId: string): Promise { - return em.getRepository(Token).findOneBy({ - type: TokenType.EMAIL_CONFIRMATION, - issuedForId: accountId, +export async function findActiveToken( + em: EntityManager, + email: string +): Promise { + return em.getRepository(EmailConfirmationToken).findOneBy({ + email, expiry: MoreThan(new Date()), }) } -async function reloadToken(em: EntityManager, token: Token): Promise { - return em.getRepository(Token).findOneByOrFail({ id: token.id }) +async function reloadToken( + em: EntityManager, + token: EmailConfirmationToken +): Promise { + return em.getRepository(EmailConfirmationToken).findOneByOrFail({ id: token.id }) } describe('requestEmailConfirmationToken', () => { let em: EntityManager - let accountInfo: AccountAccessData + const email = `email@example.com` + let anonSessionId: string before(async () => { await cryptoWaitReady() em = await globalEm - accountInfo = await createAccount() - }) - - it('should fail if account does not exist', async () => { - await requestEmailConfirmationToken('non.existing.account@example.com', 404) + anonSessionId = await anonymousAuth() }) - it('should succeed if account exists', async () => { - await requestEmailConfirmationToken(accountInfo.email, 200) - const token = await findActiveToken(em, accountInfo.accountId) + it('Token should exist', async () => { + await requestEmailConfirmationToken(email, anonSessionId, 200) + const token = await findActiveToken(em, email) assert(token, 'Token not found') }) - it('should fail if account is already confirmed', async () => { - let oldToken = await findActiveToken(em, accountInfo.accountId) - assert(oldToken, 'Pre-existing token not found') - await confirmEmail(oldToken.id, 200) - oldToken = await reloadToken(em, oldToken) - assert(oldToken.expiry.getTime() <= Date.now(), 'Pre-existing token is not expired') - await requestEmailConfirmationToken(accountInfo.email, 400) - }) - it('should fail if rate limit is exceeded', async () => { - const { email, accountId } = await createAccount() const rateLimit = await config.get(ConfigVariable.EmailConfirmationTokenRateLimit, em) - let previousToken: Token | null = null - for (let i = 0; i < rateLimit; i++) { - await requestEmailConfirmationToken(email, 200) - const newToken = await findActiveToken(em, accountId) + let previousToken: EmailConfirmationToken | null = null + for (let i = 1; i < rateLimit; i++) { + await requestEmailConfirmationToken(email, anonSessionId, 200) + const newToken = await findActiveToken(em, email) assert(newToken, 'Token not found') if (previousToken) { previousToken = await reloadToken(em, previousToken) @@ -68,6 +55,6 @@ describe('requestEmailConfirmationToken', () => { } previousToken = newToken } - await requestEmailConfirmationToken(email, 429) + await requestEmailConfirmationToken(email, anonSessionId, 429) }) }) From 556ca205ed419c3f71ba4791154d6be097d8e7ea Mon Sep 17 00:00:00 2001 From: Zeeshan Akram <97m.zeeshan@gmail.com> Date: Fri, 17 May 2024 00:09:02 +0500 Subject: [PATCH 16/22] fix: lint errors --- .prettierignore | 2 ++ src/auth-server/handlers/requestEmailConfirmationToken.ts | 6 +++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.prettierignore b/.prettierignore index 2c664c4d3..b1bb5ec46 100644 --- a/.prettierignore +++ b/.prettierignore @@ -10,3 +10,5 @@ db/migrations/*-Views.js schema.graphql /scripts/orion-v1-migration/data /db/export +src/auth-server/generated +src/auth-server/emails/templates/preview diff --git a/src/auth-server/handlers/requestEmailConfirmationToken.ts b/src/auth-server/handlers/requestEmailConfirmationToken.ts index f07d99d22..d6a96add7 100644 --- a/src/auth-server/handlers/requestEmailConfirmationToken.ts +++ b/src/auth-server/handlers/requestEmailConfirmationToken.ts @@ -65,7 +65,11 @@ export const requestEmailConfirmationToken: ( // Send email with verification token if Gateway account does not exist. // Otherwise, send email to asking user to login/change password. but // the API response should be the same to avoid email enumeration attack. - account ? await sendLoginOrChangePasswordEmail(email, em) : await sendWelcomeEmail(email, em) + if (account) { + await sendLoginOrChangePasswordEmail(email, em) + } else { + await sendWelcomeEmail(email, em) + } }) res.status(200).json({ success: true }) From 2fba0a5fc922f47f1e8129cfdb380259e794c1bf Mon Sep 17 00:00:00 2001 From: Zeeshan Akram <97m.zeeshan@gmail.com> Date: Fri, 17 May 2024 00:13:00 +0500 Subject: [PATCH 17/22] move encryption_artifacts & session_encryption_artifacts to admin schema --- db/migrations/1708169663879-Data.js | 20 ++++++++++---------- schema/auth.graphql | 4 ++-- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/db/migrations/1708169663879-Data.js b/db/migrations/1708169663879-Data.js index 0057bff07..181114344 100644 --- a/db/migrations/1708169663879-Data.js +++ b/db/migrations/1708169663879-Data.js @@ -106,14 +106,14 @@ module.exports = class Data1708169663879 { await db.query(`CREATE INDEX "IDX_4c8f96ccf523e9a3faefd5bdd4" ON "admin"."account" ("email") `) await db.query(`CREATE INDEX "IDX_601b93655bcbe73cb58d8c80cd" ON "admin"."account" ("membership_id") `) await db.query(`CREATE INDEX "IDX_df4da05a7a80c1afd18b8f0990" ON "admin"."account" ("joystream_account") `) - await db.query(`CREATE TABLE "encryption_artifacts" ("id" character varying NOT NULL, "account_id" character varying NOT NULL, "cipher_iv" text NOT NULL, "encrypted_seed" text NOT NULL, CONSTRAINT "EncryptionArtifacts_account" UNIQUE ("account_id") DEFERRABLE INITIALLY DEFERRED, CONSTRAINT "REL_ec8f68a544aadc4fbdadefe4a0" UNIQUE ("account_id"), CONSTRAINT "PK_6441471581ba6d149ad75655bd0" PRIMARY KEY ("id"))`) - await db.query(`CREATE INDEX "IDX_ec8f68a544aadc4fbdadefe4a0" ON "encryption_artifacts" ("account_id") `) + await db.query(`CREATE TABLE "admin"."encryption_artifacts" ("id" character varying NOT NULL, "account_id" character varying NOT NULL, "cipher_iv" text NOT NULL, "encrypted_seed" text NOT NULL, CONSTRAINT "EncryptionArtifacts_account" UNIQUE ("account_id") DEFERRABLE INITIALLY DEFERRED, CONSTRAINT "REL_ec8f68a544aadc4fbdadefe4a0" UNIQUE ("account_id"), CONSTRAINT "PK_6441471581ba6d149ad75655bd0" PRIMARY KEY ("id"))`) + await db.query(`CREATE INDEX "IDX_ec8f68a544aadc4fbdadefe4a0" ON "admin"."encryption_artifacts" ("account_id") `) await db.query(`CREATE TABLE "admin"."session" ("id" character varying NOT NULL, "browser" text NOT NULL, "os" text NOT NULL, "device" text NOT NULL, "device_type" text, "user_id" character varying, "account_id" character varying, "ip" text NOT NULL, "started_at" TIMESTAMP WITH TIME ZONE NOT NULL, "expiry" TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT "PK_f55da76ac1c3ac420f444d2ff11" PRIMARY KEY ("id"))`) await db.query(`CREATE INDEX "IDX_30e98e8746699fb9af235410af" ON "admin"."session" ("user_id") `) await db.query(`CREATE INDEX "IDX_fae5a6b4a57f098e9af8520d49" ON "admin"."session" ("account_id") `) await db.query(`CREATE INDEX "IDX_213b5a19bfdbe0ab6e06b1dede" ON "admin"."session" ("ip") `) - await db.query(`CREATE TABLE "session_encryption_artifacts" ("id" character varying NOT NULL, "session_id" character varying NOT NULL, "cipher_iv" text NOT NULL, "cipher_key" text NOT NULL, CONSTRAINT "SessionEncryptionArtifacts_session" UNIQUE ("session_id") DEFERRABLE INITIALLY DEFERRED, CONSTRAINT "REL_3612880efd8926a17eba5ab0e1" UNIQUE ("session_id"), CONSTRAINT "PK_e328da2643599e265a848219885" PRIMARY KEY ("id"))`) - await db.query(`CREATE INDEX "IDX_3612880efd8926a17eba5ab0e1" ON "session_encryption_artifacts" ("session_id") `) + await db.query(`CREATE TABLE "admin"."session_encryption_artifacts" ("id" character varying NOT NULL, "session_id" character varying NOT NULL, "cipher_iv" text NOT NULL, "cipher_key" text NOT NULL, CONSTRAINT "SessionEncryptionArtifacts_session" UNIQUE ("session_id") DEFERRABLE INITIALLY DEFERRED, CONSTRAINT "REL_3612880efd8926a17eba5ab0e1" UNIQUE ("session_id"), CONSTRAINT "PK_e328da2643599e265a848219885" PRIMARY KEY ("id"))`) + await db.query(`CREATE INDEX "IDX_3612880efd8926a17eba5ab0e1" ON "admin"."session_encryption_artifacts" ("session_id") `) await db.query(`CREATE TABLE "admin"."token" ("id" character varying NOT NULL, "type" character varying(18) NOT NULL, "issued_at" TIMESTAMP WITH TIME ZONE NOT NULL, "expiry" TIMESTAMP WITH TIME ZONE NOT NULL, "issued_for_id" character varying, CONSTRAINT "PK_82fae97f905930df5d62a702fc9" PRIMARY KEY ("id"))`) await db.query(`CREATE INDEX "IDX_a6fe18c105f85a63d761ccb078" ON "admin"."token" ("issued_for_id") `) await db.query(`CREATE TABLE "admin"."nft_history_entry" ("id" character varying NOT NULL, "nft_id" character varying, "event_id" character varying, CONSTRAINT "PK_9018e80b335a965a54959c4c6e2" PRIMARY KEY ("id"))`) @@ -204,10 +204,10 @@ module.exports = class Data1708169663879 { await db.query(`ALTER TABLE "notification" ADD CONSTRAINT "FK_122be1f0696e0255acf95f9e336" FOREIGN KEY ("event_id") REFERENCES "admin"."event"("id") ON DELETE NO ACTION ON UPDATE NO ACTION DEFERRABLE INITIALLY DEFERRED`) await db.query(`ALTER TABLE "admin"."account" ADD CONSTRAINT "FK_efef1e5fdbe318a379c06678c51" FOREIGN KEY ("user_id") REFERENCES "admin"."user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION DEFERRABLE INITIALLY DEFERRED`) await db.query(`ALTER TABLE "admin"."account" ADD CONSTRAINT "FK_601b93655bcbe73cb58d8c80cd3" FOREIGN KEY ("membership_id") REFERENCES "membership"("id") ON DELETE NO ACTION ON UPDATE NO ACTION DEFERRABLE INITIALLY DEFERRED`) - await db.query(`ALTER TABLE "encryption_artifacts" ADD CONSTRAINT "FK_ec8f68a544aadc4fbdadefe4a0a" FOREIGN KEY ("account_id") REFERENCES "admin"."account"("id") ON DELETE NO ACTION ON UPDATE NO ACTION DEFERRABLE INITIALLY DEFERRED`) + await db.query(`ALTER TABLE "admin"."encryption_artifacts" ADD CONSTRAINT "FK_ec8f68a544aadc4fbdadefe4a0a" FOREIGN KEY ("account_id") REFERENCES "admin"."account"("id") ON DELETE NO ACTION ON UPDATE NO ACTION DEFERRABLE INITIALLY DEFERRED`) await db.query(`ALTER TABLE "admin"."session" ADD CONSTRAINT "FK_30e98e8746699fb9af235410aff" FOREIGN KEY ("user_id") REFERENCES "admin"."user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION DEFERRABLE INITIALLY DEFERRED`) await db.query(`ALTER TABLE "admin"."session" ADD CONSTRAINT "FK_fae5a6b4a57f098e9af8520d499" FOREIGN KEY ("account_id") REFERENCES "admin"."account"("id") ON DELETE NO ACTION ON UPDATE NO ACTION DEFERRABLE INITIALLY DEFERRED`) - await db.query(`ALTER TABLE "session_encryption_artifacts" ADD CONSTRAINT "FK_3612880efd8926a17eba5ab0e1a" FOREIGN KEY ("session_id") REFERENCES "admin"."session"("id") ON DELETE NO ACTION ON UPDATE NO ACTION DEFERRABLE INITIALLY DEFERRED`) + await db.query(`ALTER TABLE "admin"."session_encryption_artifacts" ADD CONSTRAINT "FK_3612880efd8926a17eba5ab0e1a" FOREIGN KEY ("session_id") REFERENCES "admin"."session"("id") ON DELETE NO ACTION ON UPDATE NO ACTION DEFERRABLE INITIALLY DEFERRED`) await db.query(`ALTER TABLE "admin"."token" ADD CONSTRAINT "FK_a6fe18c105f85a63d761ccb0780" FOREIGN KEY ("issued_for_id") REFERENCES "admin"."account"("id") ON DELETE NO ACTION ON UPDATE NO ACTION DEFERRABLE INITIALLY DEFERRED`) await db.query(`ALTER TABLE "admin"."nft_history_entry" ADD CONSTRAINT "FK_57f51d35ecab042478fe2e31c19" FOREIGN KEY ("nft_id") REFERENCES "admin"."owned_nft"("id") ON DELETE NO ACTION ON UPDATE NO ACTION DEFERRABLE INITIALLY DEFERRED`) await db.query(`ALTER TABLE "admin"."nft_history_entry" ADD CONSTRAINT "FK_d1a28b178f5d028d048d40ce208" FOREIGN KEY ("event_id") REFERENCES "admin"."event"("id") ON DELETE NO ACTION ON UPDATE NO ACTION DEFERRABLE INITIALLY DEFERRED`) @@ -331,13 +331,13 @@ module.exports = class Data1708169663879 { await db.query(`DROP INDEX "admin"."IDX_4c8f96ccf523e9a3faefd5bdd4"`) await db.query(`DROP INDEX "admin"."IDX_601b93655bcbe73cb58d8c80cd"`) await db.query(`DROP INDEX "admin"."IDX_df4da05a7a80c1afd18b8f0990"`) - await db.query(`DROP TABLE "encryption_artifacts"`) + await db.query(`DROP TABLE "admin"."encryption_artifacts"`) await db.query(`DROP INDEX "public"."IDX_ec8f68a544aadc4fbdadefe4a0"`) await db.query(`DROP TABLE "admin"."session"`) await db.query(`DROP INDEX "admin"."IDX_30e98e8746699fb9af235410af"`) await db.query(`DROP INDEX "admin"."IDX_fae5a6b4a57f098e9af8520d49"`) await db.query(`DROP INDEX "admin"."IDX_213b5a19bfdbe0ab6e06b1dede"`) - await db.query(`DROP TABLE "session_encryption_artifacts"`) + await db.query(`DROP TABLE "admin"."session_encryption_artifacts"`) await db.query(`DROP INDEX "public"."IDX_3612880efd8926a17eba5ab0e1"`) await db.query(`DROP TABLE "admin"."token"`) await db.query(`DROP INDEX "admin"."IDX_a6fe18c105f85a63d761ccb078"`) @@ -429,10 +429,10 @@ module.exports = class Data1708169663879 { await db.query(`ALTER TABLE "notification" DROP CONSTRAINT "FK_122be1f0696e0255acf95f9e336"`) await db.query(`ALTER TABLE "admin"."account" DROP CONSTRAINT "FK_efef1e5fdbe318a379c06678c51"`) await db.query(`ALTER TABLE "admin"."account" DROP CONSTRAINT "FK_601b93655bcbe73cb58d8c80cd3"`) - await db.query(`ALTER TABLE "encryption_artifacts" DROP CONSTRAINT "FK_ec8f68a544aadc4fbdadefe4a0a"`) + await db.query(`ALTER TABLE "admin"."encryption_artifacts" DROP CONSTRAINT "FK_ec8f68a544aadc4fbdadefe4a0a"`) await db.query(`ALTER TABLE "admin"."session" DROP CONSTRAINT "FK_30e98e8746699fb9af235410aff"`) await db.query(`ALTER TABLE "admin"."session" DROP CONSTRAINT "FK_fae5a6b4a57f098e9af8520d499"`) - await db.query(`ALTER TABLE "session_encryption_artifacts" DROP CONSTRAINT "FK_3612880efd8926a17eba5ab0e1a"`) + await db.query(`ALTER TABLE "admin"."session_encryption_artifacts" DROP CONSTRAINT "FK_3612880efd8926a17eba5ab0e1a"`) await db.query(`ALTER TABLE "admin"."token" DROP CONSTRAINT "FK_a6fe18c105f85a63d761ccb0780"`) await db.query(`ALTER TABLE "admin"."nft_history_entry" DROP CONSTRAINT "FK_57f51d35ecab042478fe2e31c19"`) await db.query(`ALTER TABLE "admin"."nft_history_entry" DROP CONSTRAINT "FK_d1a28b178f5d028d048d40ce208"`) diff --git a/schema/auth.graphql b/schema/auth.graphql index cded40893..4941994d6 100644 --- a/schema/auth.graphql +++ b/schema/auth.graphql @@ -41,7 +41,7 @@ type User @entity @schema(name: "admin") { nftFeaturingRequests: [NftFeaturingRequest!]! @derivedFrom(field: "user") } -type EncryptionArtifacts @entity { +type EncryptionArtifacts @entity @schema(name: "admin") { "ID / lookupKey" id: ID! @@ -55,7 +55,7 @@ type EncryptionArtifacts @entity { encryptedSeed: String! } -type SessionEncryptionArtifacts @entity { +type SessionEncryptionArtifacts @entity @schema(name: "admin") { "Unique identifier" id: ID! From d580268b258af83f3e1331515c02988601557f87 Mon Sep 17 00:00:00 2001 From: Zeeshan Akram <97m.zeeshan@gmail.com> Date: Fri, 17 May 2024 01:11:18 +0500 Subject: [PATCH 18/22] bump package version --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index aea4e4852..949ca4381 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "orion", - "version": "4.0.2", + "version": "5.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "orion", - "version": "4.0.2", + "version": "5.0.0", "hasInstallScript": true, "workspaces": [ "network-tests" diff --git a/package.json b/package.json index 0c4d76f52..12b912627 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orion", - "version": "4.0.2", + "version": "5.0.0", "engines": { "node": ">=16" }, From 1be0059c932e4f99da58054adea925d81ed79968 Mon Sep 17 00:00:00 2001 From: Zeeshan Akram <97m.zeeshan@gmail.com> Date: Fri, 17 May 2024 03:25:56 +0500 Subject: [PATCH 19/22] fix: Members.MemberAccountsUpdated mappings bug --- src/mappings/content/nft.ts | 13 +++---------- src/mappings/content/utils.ts | 16 +++++++++++++++- src/mappings/membership/index.ts | 9 ++++++++- src/mappings/membership/utils.ts | 18 ------------------ src/mappings/token/utils.ts | 4 ++-- 5 files changed, 28 insertions(+), 32 deletions(-) delete mode 100644 src/mappings/membership/utils.ts diff --git a/src/mappings/content/nft.ts b/src/mappings/content/nft.ts index 8303922f7..10e96972f 100644 --- a/src/mappings/content/nft.ts +++ b/src/mappings/content/nft.ts @@ -34,12 +34,7 @@ import { EventHandlerContext } from '../../utils/events' import { criticalError } from '../../utils/misc' import { addNotification } from '../../utils/notification' import { EntityManagerOverlay } from '../../utils/overlay' -import { - addNftActivity, - addNftHistoryEntry, - genericEventFields, - getAccountForMember, -} from '../utils' +import { addNftActivity, addNftHistoryEntry, genericEventFields } from '../utils' import { addNewBidNotification, addRoyaltyPaymentNotification, @@ -49,6 +44,7 @@ import { createBid, findTopBid, finishAuction, + getAccountForMember, getChannelTitleById, getCurrentAuctionFromVideo, getNftOwnerMemberId, @@ -622,10 +618,7 @@ export async function processNftBoughtEvent({ await maybeNotifyNftCreator(overlay, previousNftOwner, notificationData, event) if (previousNftOwner.isTypeOf === 'NftOwnerMember') { // case when previous owner is a member - const previousNftOwnerAccount = await getAccountForMember( - overlay.getEm(), - previousNftOwner.member - ) + const previousNftOwnerAccount = await getAccountForMember(overlay, previousNftOwner.member) await addNotification( overlay, previousNftOwnerAccount ? (previousNftOwnerAccount as Account) : null, diff --git a/src/mappings/content/utils.ts b/src/mappings/content/utils.ts index f71e0659f..9515e180b 100644 --- a/src/mappings/content/utils.ts +++ b/src/mappings/content/utils.ts @@ -74,7 +74,6 @@ import { import { criticalError } from '../../utils/misc' import { addNotification } from '../../utils/notification' import { EntityManagerOverlay, Flat } from '../../utils/overlay' -import { getMemberControllerAccount } from '../membership/utils' import { addNftActivity, addNftHistoryEntry, genericEventFields, invalidMetadata } from '../utils' // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -693,6 +692,21 @@ export async function getAccountForMember( return (memberAccount as Account) ?? null } +async function getMemberControllerAccount( + overlay: EntityManagerOverlay, + memberId: string +): Promise { + const membership = await overlay.getRepository(Membership).getByIdOrFail(memberId) + + if (!membership.controllerAccountId) { + // This should never happen, but only added for type safety as + // the foreign entity references are always set nullable by the + // subsquid codegen even if in the graphql schema they are not. + criticalError(`Membership ${membership.id} controller account not found.`) + } + return membership.controllerAccountId +} + export async function getAccountsForBidders( overlay: EntityManagerOverlay, auctionBids: Flat[] diff --git a/src/mappings/membership/index.ts b/src/mappings/membership/index.ts index 7e052c17e..399ba50a6 100644 --- a/src/mappings/membership/index.ts +++ b/src/mappings/membership/index.ts @@ -56,8 +56,15 @@ export async function processMemberAccountsUpdatedEvent({ }, }: EventHandlerContext<'Members.MemberAccountsUpdated'>) { if (newControllerAccount) { + // Create blockchain account entity if it doesn't exist + const controllerAccountId = toAddress(newControllerAccount) + const blockchainAccount = + (await overlay.getRepository(BlockchainAccount).getById(controllerAccountId)) || + overlay.getRepository(BlockchainAccount).new({ + id: controllerAccountId, + }) const member = await overlay.getRepository(Membership).getByIdOrFail(memberId.toString()) - member.controllerAccountId = toAddress(newControllerAccount) + member.controllerAccountId = blockchainAccount.id } } diff --git a/src/mappings/membership/utils.ts b/src/mappings/membership/utils.ts deleted file mode 100644 index 5f2656b94..000000000 --- a/src/mappings/membership/utils.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Membership } from '../../model' -import { criticalError } from '../../utils/misc' -import { EntityManagerOverlay } from '../../utils/overlay' - -export async function getMemberControllerAccount( - overlay: EntityManagerOverlay, - memberId: string -): Promise { - const membership = await overlay.getRepository(Membership).getByIdOrFail(memberId) - - if (!membership.controllerAccountId) { - // This should never happen, but only added for type safety as - // the foreign entity references are always set nullable by the - // subsquid codegen even if in the graphql schema they are not. - criticalError(`Membership ${membership.id} controller account not found.`) - } - return membership.controllerAccountId -} diff --git a/src/mappings/token/utils.ts b/src/mappings/token/utils.ts index 7194f21e1..0ca209589 100644 --- a/src/mappings/token/utils.ts +++ b/src/mappings/token/utils.ts @@ -23,7 +23,7 @@ import { uniqueId } from '../../utils/crypto' import { criticalError } from '../../utils/misc' import { addNotification } from '../../utils/notification' import { EntityManagerOverlay, Flat } from '../../utils/overlay' -import { getAccountForMember } from '../utils' +import { getAccountForMember } from '../content/utils' export const FALLBACK_TOKEN_SYMBOL = '??' @@ -338,7 +338,7 @@ export async function getHolderAccountsForToken( const holdersAccounts = await Promise.all( holdersMemberIds.map((membershipId) => limit(async () => { - const account = await getAccountForMember(em, membershipId) + const account = await getAccountForMember(overlay, membershipId) return [membershipId, account] as [string, Account | null] }) ) From 3bfb7fdf47e12bebc90dd27e0eb88814e0d83571 Mon Sep 17 00:00:00 2001 From: Zeeshan Akram <97m.zeeshan@gmail.com> Date: Fri, 17 May 2024 03:28:13 +0500 Subject: [PATCH 20/22] [offchainState] add v5.0.0 migrations --- src/utils/offchainState.ts | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/src/utils/offchainState.ts b/src/utils/offchainState.ts index 7360ed2c3..3439b305a 100644 --- a/src/utils/offchainState.ts +++ b/src/utils/offchainState.ts @@ -60,6 +60,7 @@ const exportedStateMap: ExportedStateMap = { EmailDeliveryAttempt: true, Token: true, NextEntityId: true, + BlockchainAccount: true, // this is a special case, because it's being created both in the mappings and the create/change account resolvers. Channel: ['isExcluded', 'videoViewsNum', 'followsNum', 'yppStatus', 'channelWeight'], Video: ['isExcluded', 'viewsNum'], Comment: ['isExcluded'], @@ -141,9 +142,7 @@ export function setCrtNotificationPreferences( function migrateExportDataToV400(data: ExportedData): ExportedData { data.Account?.values.forEach((account) => { // account will find himself with all CRT notification pref. enabled by default - account.notificationPreferences = setCrtNotificationPreferences( - account.notificationPreferences as AccountNotificationPreferences - ) + account.notificationPreferences = setCrtNotificationPreferences(account.notificationPreferences) }) data.Notification?.values.forEach((notification) => { @@ -158,11 +157,26 @@ function migrateExportDataToV400(data: ExportedData): ExportedData { return data } +// TODO: How to upgrade to v5.0.0? +// 1. Backup old db +// 2. Export state using old code build (i.e. `npm run offchain-state:export`) +// 3. Create copy of export.json file +// 4. Build new code (v5.0.0) +// 5. import state (i.e. `npm run offchain-state:import`) +function migrateExportDataToV500(data: ExportedData): ExportedData { + data.Account?.values.forEach((account) => { + account.joystreamAccountId = (account as any).joystreamAccount + }) + + return data +} + export class OffchainState { private logger = createLogger('offchainState') private _isImported = false private migrations: Migrations = { + '5.0.0': migrateExportDataToV500, '4.0.0': migrateExportDataToV400, '3.2.0': migrateExportDataToV320, '3.0.0': migrateExportDataToV300, From d8721937c8179e7d3e68fb390bd6d5bd4f6f147f Mon Sep 17 00:00:00 2001 From: Zeeshan Akram <97m.zeeshan@gmail.com> Date: Tue, 11 Jun 2024 18:18:01 +0500 Subject: [PATCH 21/22] rename joystreamAccount to joystreamAccountId in 'AccountData' graphql type --- src/auth-server/handlers/changeAccount.ts | 2 -- src/server-extension/resolvers/AccountResolver/index.ts | 8 +++++--- src/server-extension/resolvers/AccountResolver/types.ts | 3 +-- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/auth-server/handlers/changeAccount.ts b/src/auth-server/handlers/changeAccount.ts index 6e1d689f4..a13f9f66d 100644 --- a/src/auth-server/handlers/changeAccount.ts +++ b/src/auth-server/handlers/changeAccount.ts @@ -54,8 +54,6 @@ export const changeAccount: ( { joystreamAccountId: payload.joystreamAccountId } ) - console.log('after update') - // Remove the old encryption artifacts await em.delete(EncryptionArtifacts, { accountId: account.id }) diff --git a/src/server-extension/resolvers/AccountResolver/index.ts b/src/server-extension/resolvers/AccountResolver/index.ts index feef93feb..c286d5442 100644 --- a/src/server-extension/resolvers/AccountResolver/index.ts +++ b/src/server-extension/resolvers/AccountResolver/index.ts @@ -31,7 +31,7 @@ export class AccountResolver { const account = ctx.account const em = await this.em() assert(account, 'Unexpected context: account is not set') - const { id, email, joystreamAccount, notificationPreferences } = account + const { id, email, joystreamAccountId, notificationPreferences } = account let followedChannels: FollowedChannel[] = [] if ( info.fieldNodes[0].selectionSet?.selections.some( @@ -44,7 +44,7 @@ export class AccountResolver { return { id, email, - joystreamAccount, + joystreamAccountId, followedChannels, preferences: notificationPreferences, } @@ -123,7 +123,9 @@ export class AccountResolver { throw new Error( `Failed to create membership through faucet for account address: ${ params.address - }, error: ${error.response?.data?.error || error.cause || error.code}` + }, error: ${ + error.response?.data?.error || error.cause || error.code + }, faucet address: ${url}` ) } throw error diff --git a/src/server-extension/resolvers/AccountResolver/types.ts b/src/server-extension/resolvers/AccountResolver/types.ts index 8769a2ec0..ab5c767b7 100644 --- a/src/server-extension/resolvers/AccountResolver/types.ts +++ b/src/server-extension/resolvers/AccountResolver/types.ts @@ -1,6 +1,5 @@ import { IsUrl } from 'class-validator' import { ArgsType, Field, ObjectType } from 'type-graphql' -import { BlockchainAccount } from '../../../model' import { AccountNotificationPreferencesOutput } from '../NotificationResolver/types' @ObjectType() @@ -21,7 +20,7 @@ export class AccountData { email!: string @Field(() => String, { nullable: false }) - joystreamAccount!: BlockchainAccount + joystreamAccountId!: string @Field(() => [FollowedChannel], { nullable: false }) followedChannels: FollowedChannel[] From 728fd8ae6df7a9eaacb99a257f29e4d0ef1e8df5 Mon Sep 17 00:00:00 2001 From: Zeeshan Akram <97m.zeeshan@gmail.com> Date: Wed, 12 Jun 2024 16:22:47 +0500 Subject: [PATCH 22/22] add 'signupType' param to email confirmation link --- .env | 2 +- src/auth-server/docs/Apis/DefaultApi.md | 2 +- .../docs/Models/RequestTokenRequestData.md | 1 + src/auth-server/emails/index.ts | 3 +-- src/auth-server/generated/api-types.ts | 5 +++++ .../handlers/requestEmailConfirmationToken.ts | 7 ++++--- src/auth-server/openapi.yml | 8 ++++++++ src/auth-server/utils.ts | 13 +++++++++---- 8 files changed, 30 insertions(+), 11 deletions(-) diff --git a/.env b/.env index c85fc0eeb..a9743134b 100644 --- a/.env +++ b/.env @@ -47,7 +47,7 @@ MAX_CACHED_ENTITIES=1000 APP_PRIVATE_KEY=this-is-not-so-secret-change-it SESSION_EXPIRY_AFTER_INACTIVITY_MINUTES=60 SESSION_MAX_DURATION_HOURS=720 -EMAIL_CONFIRMATION_ROUTE=http://localhost:4074/api/v1/confirm-email?token={token} +EMAIL_CONFIRMATION_ROUTE="http://localhost:4074/api/v1/confirm-email?token={token}&expiry={expiry}&signupType={signupType}" EMAIL_CONFIRMATION_TOKEN_EXPIRY_TIME_HOURS=24 EMAIL_CONFIRMATION_TOKEN_RATE_LIMIT=5 ACCOUNT_OWNERSHIP_PROOF_EXPIRY_TIME_SECONDS=300 # 5 minutes diff --git a/src/auth-server/docs/Apis/DefaultApi.md b/src/auth-server/docs/Apis/DefaultApi.md index 0e570bbe8..8aee912c5 100644 --- a/src/auth-server/docs/Apis/DefaultApi.md +++ b/src/auth-server/docs/Apis/DefaultApi.md @@ -246,7 +246,7 @@ This endpoint does not need any parameter. ### Authorization -No authorization required +[cookieAuth](../README.md#cookieAuth) ### HTTP request headers diff --git a/src/auth-server/docs/Models/RequestTokenRequestData.md b/src/auth-server/docs/Models/RequestTokenRequestData.md index 8548ae512..0219eec2f 100644 --- a/src/auth-server/docs/Models/RequestTokenRequestData.md +++ b/src/auth-server/docs/Models/RequestTokenRequestData.md @@ -4,6 +4,7 @@ | Name | Type | Description | Notes | |------------ | ------------- | ------------- | -------------| | **email** | **String** | User's e-mail address. | [default to null] | +| **signupType** | **String** | Signup type. | [default to null] | [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/src/auth-server/emails/index.ts b/src/auth-server/emails/index.ts index 3fce25be5..10e6a5e54 100644 --- a/src/auth-server/emails/index.ts +++ b/src/auth-server/emails/index.ts @@ -1,5 +1,5 @@ -import { compile } from 'handlebars' import fs from 'fs' +import { compile } from 'handlebars' import path from 'path' import { NotificationData } from '../../utils/notification/notificationsData' @@ -14,7 +14,6 @@ function getEmailTemplateData(templatePath: string): (data: T) => string { // type aliases for template data type RegisterEmailTemplateData = { link: string - linkExpiryDate: string appName: string } diff --git a/src/auth-server/generated/api-types.ts b/src/auth-server/generated/api-types.ts index 48d8b6217..dd3904457 100644 --- a/src/auth-server/generated/api-types.ts +++ b/src/auth-server/generated/api-types.ts @@ -90,6 +90,11 @@ export interface components { RequestTokenRequestData: { /** @description User's e-mail address. */ email: string; + /** + * @description Signup type. + * @enum {string} + */ + signupType: "internal" | "external" | "ypp"; }; GenericErrorResponseData: { message?: string; diff --git a/src/auth-server/handlers/requestEmailConfirmationToken.ts b/src/auth-server/handlers/requestEmailConfirmationToken.ts index d6a96add7..d9e741ed8 100644 --- a/src/auth-server/handlers/requestEmailConfirmationToken.ts +++ b/src/auth-server/handlers/requestEmailConfirmationToken.ts @@ -20,7 +20,7 @@ export const requestEmailConfirmationToken: ( next: express.NextFunction ) => Promise = async (req, res, next) => { try { - const { email } = req.body + const { email, signupType } = req.body const { authContext } = res.locals if (authContext.account) { @@ -58,9 +58,10 @@ export const requestEmailConfirmationToken: ( } // Deactivate all currently active email confirmation tokens for this email + const currentTimestamp = new Date() await em .getRepository(EmailConfirmationToken) - .update({ email, expiry: MoreThan(new Date()) }, { expiry: new Date() }) + .update({ email, expiry: MoreThan(currentTimestamp) }, { expiry: currentTimestamp }) // Send email with verification token if Gateway account does not exist. // Otherwise, send email to asking user to login/change password. but @@ -68,7 +69,7 @@ export const requestEmailConfirmationToken: ( if (account) { await sendLoginOrChangePasswordEmail(email, em) } else { - await sendWelcomeEmail(email, em) + await sendWelcomeEmail(email, signupType, em) } }) diff --git a/src/auth-server/openapi.yml b/src/auth-server/openapi.yml index 3345da9ba..951d34c4f 100644 --- a/src/auth-server/openapi.yml +++ b/src/auth-server/openapi.yml @@ -466,10 +466,18 @@ components: type: object required: - email + - signupType properties: email: type: string description: User's e-mail address. + signupType: + type: string + enum: + - internal + - external + - ypp + description: Signup type. GenericErrorResponseData: type: object properties: diff --git a/src/auth-server/utils.ts b/src/auth-server/utils.ts index ff7f35724..b7a94868c 100644 --- a/src/auth-server/utils.ts +++ b/src/auth-server/utils.ts @@ -9,7 +9,6 @@ import { EntityManager } from 'typeorm' import { EmailConfirmationToken } from '../model' import { ConfigVariable, config } from '../utils/config' import { uniqueId } from '../utils/crypto' -import { formatDate } from '../utils/date' import { sgSendMail } from '../utils/mail' import { registerEmailContent } from './emails' import { BadRequestError } from './errors' @@ -73,15 +72,21 @@ async function issueEmailConfirmationToken( return em.save(token) } -export async function sendWelcomeEmail(email: string, em: EntityManager): Promise { +export async function sendWelcomeEmail( + email: string, + signupType: string, + em: EntityManager +): Promise { const emailConfirmationToken = await issueEmailConfirmationToken(email, em) const appName = await config.get(ConfigVariable.AppName, em) const confirmEmailRoute = await config.get(ConfigVariable.EmailConfirmationRoute, em) - const emailConfirmationLink = confirmEmailRoute.replace('{token}', emailConfirmationToken.id) + const emailConfirmationLink = confirmEmailRoute + .replace('{token}', emailConfirmationToken.id) + .replace('{expiry}', emailConfirmationToken.expiry.toISOString()) + .replace('{signupType}', signupType) const emailContent = registerEmailContent({ link: emailConfirmationLink, - linkExpiryDate: formatDate(emailConfirmationToken.expiry), appName, }) await sgSendMail({