diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 6f130baf5..54ef612aa 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -145,18 +145,18 @@ jobs: ssh -i ./ssh-key app@${{ vars.HOST_DOMAIN }} "\ cd dm3 && ls |grep -E 'dm3-.*tar' | xargs --no-run-if-empty -L 1 docker load -i; \ rm dm3-*.tar || true" - - name: Reset state of testing environment - run: | - if [ $environment_name == "testing" ]; then - ssh -i ./ssh-key root@${{ vars.HOST_DOMAIN }} "\ - cd ${{ vars.PERSISTENCE_DIRECTORY }}/db && rm -r * || true; - cd ${{ vars.PERSISTENCE_DIRECTORY }}/storage && rm -r * || true" - fi + # - name: Reset state of testing environment + # run: | + # if [ $environment_name == "testing" ]; then + # ssh -i ./ssh-key root@${{ vars.HOST_DOMAIN }} "\ + # cd ${{ vars.PERSISTENCE_DIRECTORY }}/db && rm -r * || true; + # cd ${{ vars.PERSISTENCE_DIRECTORY }}/storage && rm -r * || true" + # fi - name: Configure Firewall run: | ssh -i ./ssh-key root@${{ vars.HOST_DOMAIN }} "\ ufw allow from 172.18.0.1/16 proto tcp to ${{ vars.HOST_IP}} port 80; - ufw allow from 172.18.0.1/16 proto tcp to ${{ secrets.IP_ADDRESS }} port 443; + ufw allow from 172.18.0.1/16 proto tcp to ${{ vars.HOST_IP }} port 443; ufw enable" - name: Start docker on server run: | diff --git a/packages/backend/README.md b/packages/backend/README.md index 0a20dc02c..e7e80aab7 100644 --- a/packages/backend/README.md +++ b/packages/backend/README.md @@ -8,6 +8,15 @@ cd ../../ && yarn build ``` +#### Contributing + +Whenever the prisma schema is updated, run the following command to generate the types: + +1. `prisma-create-migrations`: this will add a new migration to the migrations folder, which will be committed to the repository. Our server environments do not generate the migrations on the fly, so we need to commit the migration to the repository. This requires a running database, so make sure to have the database running before running this command. +2. `prisma-generate`: this will generate the types for the prisma schema (and the client). This step is executed automatically when running on the server. + +Fogetting step #1 will result in the server not being able to start, as the types will be missing. + ### Usage yarn @@ -15,9 +24,3 @@ yarn ``` yarn start ``` - -npm - -``` -npm start -``` diff --git a/packages/backend/manual_data_migration/insertWithinDocker.sh b/packages/backend/manual_data_migration/insertWithinDocker.sh new file mode 100755 index 000000000..c99c7cf57 --- /dev/null +++ b/packages/backend/manual_data_migration/insertWithinDocker.sh @@ -0,0 +1,27 @@ +#!/bin/bash + +# Input file +input_file="dump.txt" + +DB_NAME="dm3" +DB_USER="prisma" + + +# Read the input file line by line +while IFS= read -r line +do + # Extract the ID (first part of the line) and timestamp (after "createdAt") + id=$(echo "$line" | cut -d ':' -f 2) + timestamp=$(echo "$line" | grep -oP '(?<="createdAt":)[0-9]+') + + # Convert the timestamp from milliseconds to seconds + timestamp_seconds=$(echo $timestamp | sed 's/...$//') + + # Insert the extracted values into the PostgreSQL table + psql -U $DB_USER -d $DB_NAME -c "INSERT INTO \"Account\" (id, \"createdAt\") \ + VALUES ('$id', to_timestamp($timestamp_seconds))\ + ON CONFLICT (id) \ + DO UPDATE SET \"createdAt\" = excluded.\"createdAt\";" + +done < "$input_file" + diff --git a/packages/backend/manual_data_migration/notes.md b/packages/backend/manual_data_migration/notes.md new file mode 100644 index 000000000..f7ab24c2c --- /dev/null +++ b/packages/backend/manual_data_migration/notes.md @@ -0,0 +1,37 @@ +Process: + +check data +`docker exec -it dm3-db-1 redis-cli --scan --pattern 'session*addr.dm3.eth'` + +`docker exec -it dm3-storage psql -U prisma -d dm3 -c 'SELECT * FROM "Account";'` + +go into the redis container +docker exec -it dm3-db-1 bash + +dump all relevant sessions +for key in `redis-cli --scan --pattern 'session*addr.dm3.eth'`; do echo $key: `redis-cli GET $key` >> dump.txt; echo $key; done + +copy the dump to the host +docker cp dm3-db-1:/data/dump.txt . + +copy the dump to the postgres container +docker cp dump.txt dm3-storage:/ + +paste script onto server +vi insertWithinDocker.sh +-> paste, close + +copy the script to the postgres container +docker cp insertWithinDocker.sh dm3-storage:/ + +go into the postgres container +docker exec -it dm3-storage bash + +make script executable +chmod a+x insertWithinDocker.sh + +run the script +./insertWithinDocker.sh + +check the data from outside the container +docker exec -it dm3-storage psql -U prisma -d dm3 -c 'SELECT \* FROM "Account";' diff --git a/packages/backend/migrations/20240719092732_add_account_created_at/migration.sql b/packages/backend/migrations/20240719092732_add_account_created_at/migration.sql new file mode 100644 index 000000000..6472c4b02 --- /dev/null +++ b/packages/backend/migrations/20240719092732_add_account_created_at/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Account" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; diff --git a/packages/backend/package.json b/packages/backend/package.json index 0289f08f0..745b7c9af 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -29,10 +29,11 @@ "scripts": { "docker:up": "docker compose up -d", "prisma-init": "prisma generate && prisma migrate deploy ", + "prisma-create-migrations": "prisma generate && prisma migrate dev", "start": "yarn prisma-init && node ./dist/index.js", "start-inspect": "node --inspect=0.0.0.0:9229 ./dist/index.js", "test": "yarn run before:tests && DATABASE_URL='postgresql://prisma:prisma@localhost:5433/tests?schema=public' yarn jest --coverage --runInBand --transformIgnorePatterns 'node_modules/(?!(dm3-lib-\\w*)/)'", - "build": "yarn tsc && cp ./config.yml ./dist/config.yml | true", + "build": "yarn prisma generate && yarn tsc && cp ./config.yml ./dist/config.yml | true", "build:schema": "sh ./schemas.sh", "createDeliveryServiceProfile": "node --no-warnings ./cli.js", "before:tests": "docker compose -f docker-compose.test.yml up -d && DATABASE_URL='postgresql://prisma:prisma@localhost:5433/tests?schema=public' yarn prisma-init" diff --git a/packages/backend/schema.prisma b/packages/backend/schema.prisma index 97c5b5473..e5a0c379e 100644 --- a/packages/backend/schema.prisma +++ b/packages/backend/schema.prisma @@ -34,6 +34,7 @@ model Conversation { model Account { id String @id + createdAt DateTime @default(now()) conversations Conversation[] EncryptedMessage EncryptedMessage[] } diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 085c3fd24..20fe2d05b 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -39,7 +39,7 @@ app.use(bodyParser.json()); }); app.use('/profile', Profile(db, web3Provider, serverSecret)); app.use('/storage', Storage(db, web3Provider, serverSecret)); - app.use('/auth', Auth(db, serverSecret)); + app.use('/auth', Auth(db, serverSecret, web3Provider)); app.use(logError); app.use(errorHandler); })(); diff --git a/packages/backend/src/persistence/session/getAccount.ts b/packages/backend/src/persistence/account/getAccount.ts similarity index 100% rename from packages/backend/src/persistence/session/getAccount.ts rename to packages/backend/src/persistence/account/getAccount.ts diff --git a/packages/backend/src/persistence/session/index.ts b/packages/backend/src/persistence/account/index.ts similarity index 100% rename from packages/backend/src/persistence/session/index.ts rename to packages/backend/src/persistence/account/index.ts diff --git a/packages/backend/src/persistence/account/setAccount.test.ts b/packages/backend/src/persistence/account/setAccount.test.ts new file mode 100644 index 000000000..f82ab6d57 --- /dev/null +++ b/packages/backend/src/persistence/account/setAccount.test.ts @@ -0,0 +1,33 @@ +import { UserProfile } from '@dm3-org/dm3-lib-profile'; +import { PrismaClient } from '@prisma/client'; +import { IBackendDatabase, getDatabase, getPrismaClient } from '../getDatabase'; + +const USER_NAME = '0x25A643B6e52864d0eD816F1E43c0CF49C83B8292.dm3.eth'; + +describe('Set Account', () => { + let prismaClient: PrismaClient; + let db: IBackendDatabase; + + beforeEach(async () => { + prismaClient = await getPrismaClient(); + db = await getDatabase(prismaClient); + }); + + it('Creates a new Account ', async () => { + const profile: UserProfile = { + publicEncryptionKey: '', + publicSigningKey: '', + deliveryServices: [], + }; + + const priorSetAccount = await db.getAccount(USER_NAME); + + //User has no account yet + expect(priorSetAccount).toBe(null); + await db.setAccount(USER_NAME); + + const afterSetAccount = await db.getAccount(USER_NAME); + //User has no account yet + expect(afterSetAccount?.id).toEqual(USER_NAME); + }); +}); diff --git a/packages/backend/src/persistence/session/setAccount.ts b/packages/backend/src/persistence/account/setAccount.ts similarity index 100% rename from packages/backend/src/persistence/session/setAccount.ts rename to packages/backend/src/persistence/account/setAccount.ts diff --git a/packages/backend/src/persistence/getDatabase.ts b/packages/backend/src/persistence/getDatabase.ts index be093f2ce..6c16b1db8 100644 --- a/packages/backend/src/persistence/getDatabase.ts +++ b/packages/backend/src/persistence/getDatabase.ts @@ -1,11 +1,9 @@ -import { Session as DSSession, spamFilter } from '@dm3-org/dm3-lib-delivery'; -import { IAccountDatabase } from '@dm3-org/dm3-lib-server-side'; -import { PrismaClient } from '@prisma/client'; +import { Account, PrismaClient } from '@prisma/client'; import { createClient } from 'redis'; -import Session from './session'; import Storage from './storage'; import { ConversationRecord } from './storage/postgres/dto/ConversationRecord'; import { MessageRecord } from './storage/postgres/dto/MessageRecord'; +import { IAccountDatabase } from '@dm3-org/dm3-lib-server-side'; export enum RedisPrefix { Conversation = 'conversation:', @@ -13,7 +11,6 @@ export enum RedisPrefix { Sync = 'sync:', // Account used to be called Session. The prefix still resolves to "session:" for now. Account = 'session:', - Session = 'session:', NotificationChannel = 'notificationChannel:', GlobalNotification = 'globalNotification:', Otp = 'otp:', @@ -54,16 +51,15 @@ export async function getPrismaClient() { } export async function getDatabase( - _redis?: Redis, _prisma?: PrismaClient, -): Promise { - const redis = _redis ?? (await getRedisClient()); +): Promise { const prisma = _prisma ?? (await getPrismaClient()); return { //Session - setAccount: Session.setAccount(redis), - getAccount: Session.getAccount(redis), + setAccount: Storage.setAccount(prisma), + getAccount: Storage.getAccount(prisma), + hasAccount: Storage.hasAccount(prisma), //Storage AddConversation addConversation: Storage.addConversation(prisma), getConversationList: Storage.getConversationList(prisma), @@ -86,14 +82,11 @@ export async function getDatabase( }; } -export interface IDatabase extends IAccountDatabase { - setAccount: (ensName: string, session: DSSession) => Promise; - getAccount: (ensName: string) => Promise< - | (DSSession & { - spamFilterRules: spamFilter.SpamFilterRules; - }) - | null - >; +export interface IBackendDatabase extends IAccountDatabase { + setAccount: (ensName: string) => Promise; + getAccount: (ensName: string) => Promise; + hasAccount: (ensName: string) => Promise; + addConversation: ( ensName: string, encryptedContactName: string, diff --git a/packages/backend/src/persistence/session/setAccount.test.ts b/packages/backend/src/persistence/session/setAccount.test.ts deleted file mode 100644 index 2e333d730..000000000 --- a/packages/backend/src/persistence/session/setAccount.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { Redis, IDatabase, getRedisClient, getDatabase } from '../getDatabase'; -import { UserProfile } from '@dm3-org/dm3-lib-profile'; -import { Session } from '@dm3-org/dm3-lib-delivery'; - -const USER_ADDRESS = '0x25A643B6e52864d0eD816F1E43c0CF49C83B8292'; - -describe('Set Session', () => { - let redisClient: Redis; - let db: IDatabase; - - beforeEach(async () => { - redisClient = await getRedisClient(); - db = await getDatabase(redisClient); - await redisClient.flushDb(); - }); - - afterEach(async () => { - await redisClient.flushDb(); - await redisClient.disconnect(); - }); - - it('Creates a new Session ', async () => { - const profile: UserProfile = { - publicEncryptionKey: '', - publicSigningKey: '', - deliveryServices: [], - }; - const session: Session = { - account: USER_ADDRESS, - signedUserProfile: { profile, signature: 'foo' }, - token: '', - createdAt: 0, - profileExtension: { - notSupportedMessageTypes: [], - }, - }; - - const priorSetSession = await db.getAccount(USER_ADDRESS); - //User has no session yet - expect(priorSetSession).toBe(null); - await db.setAccount(USER_ADDRESS, session); - - const afterSetSession = await db.getAccount(USER_ADDRESS); - //User has no session yet - expect(afterSetSession?.signedUserProfile).toEqual({ - profile, - signature: 'foo', - }); - }); - - it('Rejcts session with an invalid schema', async () => { - const invalidSession = {} as Session; - try { - await db.setAccount('foo', invalidSession); - fail(); - } catch (e) { - expect(e).toStrictEqual(Error('Invalid session')); - } - }); -}); diff --git a/packages/backend/src/persistence/storage/index.ts b/packages/backend/src/persistence/storage/index.ts index ad50305cc..dd7fb55f3 100644 --- a/packages/backend/src/persistence/storage/index.ts +++ b/packages/backend/src/persistence/storage/index.ts @@ -10,6 +10,9 @@ import { MessageRecord } from './postgres/dto/MessageRecord'; import { getHaltedMessages } from './postgres/haltedMessage/getHaltedMessages'; import { clearHaltedMessage } from './postgres/haltedMessage/clearHaltedMessage'; +import { getAccount } from './postgres/getAccount'; +import { setAccount } from './postgres/setAccount'; +import { hasAccount } from './postgres/hasAccount'; export default { addConversation, @@ -22,6 +25,9 @@ export default { toggleHideConversation, getHaltedMessages, clearHaltedMessage, + getAccount, + setAccount, + hasAccount, }; export type { MessageRecord }; diff --git a/packages/backend/src/persistence/storage/postgres/getAccount.ts b/packages/backend/src/persistence/storage/postgres/getAccount.ts new file mode 100644 index 000000000..d3499e82b --- /dev/null +++ b/packages/backend/src/persistence/storage/postgres/getAccount.ts @@ -0,0 +1,18 @@ +import { PrismaClient } from '@prisma/client'; + +export const getAccount = (db: PrismaClient) => async (ensName: string) => { + // Find the account + const account = await db.account.findFirst({ + where: { + id: ensName, + }, + }); + + // Return the account if it exists + if (account) { + return account; + } + + // If the account does not exist, return null + return null; +}; diff --git a/packages/backend/src/persistence/storage/postgres/hasAccount.ts b/packages/backend/src/persistence/storage/postgres/hasAccount.ts new file mode 100644 index 000000000..342c27694 --- /dev/null +++ b/packages/backend/src/persistence/storage/postgres/hasAccount.ts @@ -0,0 +1,15 @@ +import { PrismaClient } from '@prisma/client'; + +/// Check if an account exists for a given ENS name +/// @param db +/// @returns true if account exists, false otherwise +export const hasAccount = (db: PrismaClient) => async (ensName: string) => { + //Check if account exists + const account = await db.account.findFirst({ + where: { + id: ensName, + }, + }); + + return !!account; +}; diff --git a/packages/backend/src/persistence/storage/postgres/setAccount.ts b/packages/backend/src/persistence/storage/postgres/setAccount.ts new file mode 100644 index 000000000..3c4e7b659 --- /dev/null +++ b/packages/backend/src/persistence/storage/postgres/setAccount.ts @@ -0,0 +1,6 @@ +import { PrismaClient } from '@prisma/client'; +import { getOrCreateAccount } from './utils/getOrCreateAccount'; + +export const setAccount = (db: PrismaClient) => async (ensName: string) => { + return getOrCreateAccount(db, ensName); +}; diff --git a/packages/backend/src/profile/profile.test.ts b/packages/backend/src/profile/profile.test.ts new file mode 100644 index 000000000..673869ee0 --- /dev/null +++ b/packages/backend/src/profile/profile.test.ts @@ -0,0 +1,179 @@ +import { generateAuthJWT } from '@dm3-org/dm3-lib-server-side'; +import { + UserProfile, + getProfileCreationMessage, +} from '@dm3-org/dm3-lib-profile'; +import { stringify } from '@dm3-org/dm3-lib-shared'; +import { Account } from '@prisma/client'; +import bodyParser from 'body-parser'; +import { ethers } from 'ethers'; +import express from 'express'; +import http from 'http'; +import request from 'supertest'; +import { IBackendDatabase } from '../persistence/getDatabase'; +import profile from './profile'; +import storage from '../storage'; +import { mockUserProfile } from '@dm3-org/dm3-lib-test-helper'; + +// todo: create a web3 provider mock that returns a resolver and that thren returns a text when the respective functions +// are called +const web3ProviderMock: ethers.providers.JsonRpcProvider = + new ethers.providers.JsonRpcProvider(); + +const serverSecret = 'veryImportantSecretToGenerateAndValidateJSONWebTokens'; + +let token = generateAuthJWT('alice.eth', serverSecret); + +const setUpApp = async ( + app: express.Express, + db: IBackendDatabase, + web3Provider: ethers.providers.JsonRpcProvider, + serverSecret: string = 'my-secret', +) => { + app.use(bodyParser.json()); + const server = http.createServer(app); + app.use(profile(db, web3Provider, serverSecret)); +}; + +const createDbMock = async () => { + const accountMocked = { + id: 'alice.eth', + } as Account; + + const dbMock = { + getAccount: async (ensName: string) => Promise.resolve(accountMocked), + setAccount: async (id: string) => {}, + getIdEnsName: async (ensName: string) => ensName, + }; + + return dbMock as any; +}; + +describe('Profile', () => { + describe('getProfile', () => { + it('Returns 200 if user profile exists', async () => { + const app = express(); + + const db = await createDbMock(); + + const user = await mockUserProfile( + ethers.Wallet.createRandom(), + 'alice.eth', + ['ds1.eth', 'ds2.eth'], + ); + const expectedUserProfile = user.signedUserProfile; + const userAddress = user.wallet.address; + + const mockGetEnsResolver = (_: string) => + Promise.resolve({ + getText: (_: string) => + Promise.resolve( + 'data:application/json,' + + stringify(expectedUserProfile), + ), + }); + + const _web3Provider = { + getResolver: mockGetEnsResolver, + resolveName: async () => userAddress, + } as unknown as ethers.providers.StaticJsonRpcProvider; + + // I don't know why this function is needed in this test. + // Remove it after storage migration. + db.getUserStorage = () => {}; + app.use(storage(db, _web3Provider, serverSecret)); + setUpApp(app, db, _web3Provider); + + const response = await request(app) + .get('/alice.eth') + .set({ + authorization: 'Bearer ' + token, + }) + .send(); + const status = response.status; + + expect(status).toBe(200); + }); + }); + + describe('submitUserProfile', () => { + it('Returns 200 if user profile creation was successful', async () => { + const mnemonic = + 'announce room limb pattern dry unit scale effort smooth jazz weasel alcohol'; + + const wallet = ethers.Wallet.fromMnemonic(mnemonic); + + // this provider must return the address of the wallet when resolveName is called + const _web3ProviderMock = { + resolveName: async () => wallet.address, + }; + // the db must return null when getAccount is called + const _dbMock = { + getAccount: async (ensName: string) => Promise.resolve(null), + setAccount: async (_: string, __: any) => { + return (_: any, __: any, ___: any) => {}; + }, + getIdEnsName: async (ensName: string) => ensName, + }; + + const app = express(); + setUpApp(app, _dbMock as any, _web3ProviderMock as any); + + const userProfile: UserProfile = { + publicSigningKey: '2', + publicEncryptionKey: '1', + deliveryServices: [], + }; + + const createUserProfileMessage = getProfileCreationMessage( + stringify(userProfile), + wallet.address, + ); + const signature = await wallet.signMessage( + createUserProfileMessage, + ); + + const signedUserProfile = { + profile: userProfile, + signature, + }; + + const response = await request(app) + .post(`/${wallet.address}`) + .send(signedUserProfile); + + const status = response.status; + + expect(status).toBe(200); + }); + + it('Returns 400 if schema is invalid', async () => { + const app = express(); + setUpApp(app, await createDbMock(), web3ProviderMock); + + const userProfile: UserProfile = { + publicSigningKey: '2', + publicEncryptionKey: '1', + deliveryServices: [], + }; + + const mnemonic = + 'announce room limb pattern dry unit scale effort smooth jazz weasel alcohol'; + + const wallet = ethers.Wallet.fromMnemonic(mnemonic); + + const signature = await wallet.signMessage(stringify(userProfile)); + + const signedUserProfile = { + profile: userProfile, + signature: null, + }; + + const { status } = await request(app) + .post(`/1234`) + .send(signedUserProfile); + + expect(status).toBe(400); + }); + }); +}); diff --git a/packages/backend/src/profile/profile.ts b/packages/backend/src/profile/profile.ts index f06e3bdec..6b64090c4 100644 --- a/packages/backend/src/profile/profile.ts +++ b/packages/backend/src/profile/profile.ts @@ -1,13 +1,18 @@ -import { normalizeEnsName, schema } from '@dm3-org/dm3-lib-profile'; +import { generateAuthJWT } from '@dm3-org/dm3-lib-server-side'; +import { getUserProfile } from '@dm3-org/dm3-lib-profile'; + +import { + checkUserProfile, + normalizeEnsName, + schema, +} from '@dm3-org/dm3-lib-profile'; import { validateSchema } from '@dm3-org/dm3-lib-shared'; import { ethers } from 'ethers'; import express from 'express'; -import { IDatabase } from '../persistence/getDatabase'; -import { getUserProfile } from './getUserProfile'; -import { submitUserProfile } from './submitUserProfile'; +import { IBackendDatabase } from '../persistence/getDatabase'; export default ( - db: IDatabase, + db: IBackendDatabase, web3Provider: ethers.providers.JsonRpcProvider, serverSecret: string, ) => { @@ -17,7 +22,7 @@ export default ( try { const ensName = normalizeEnsName(req.params.ensName); - const profile = await getUserProfile(db.getAccount, ensName); + const profile = await getUserProfile(web3Provider, ensName); if (profile) { res.json(profile); } else { @@ -28,6 +33,11 @@ export default ( } }); + /** + * Creates a new profile for the given ensName + * @param ensName ens id of the account + * @param signedUserProfile signed user profile + */ router.post('/:ensName', async (req: express.Request, res, next) => { try { const schemaIsValid = validateSchema( @@ -48,21 +58,44 @@ export default ( process.env.DISABLE_SESSION_CHECK === 'true', }); - const data = await submitUserProfile( - web3Provider, - db.getAccount, - db.setAccount, - ensName, - req.body, - serverSecret, - ); + // check if profile and signature are valid + if ( + !(await checkUserProfile( + web3Provider, + req.body, // as SignedUserProfile, + normalizeEnsName(ensName), + )) + ) { + console.debug( + 'Not creating account for ' + + ensName + + ' - Signature invalid', + ); + throw Error('Signature invalid.'); + } + + // check if an account for this name already exists + if (await db.getAccount(ensName)) { + console.debug( + 'Not creating account for ' + + ensName + + ' - account exists already', + ); + throw Error('Account exists already'); + } + + // create account + const account = await db.setAccount(ensName); console.debug({ message: 'POST profile', ensName, - data, + account, }); - res.json(data); + // generate auth jwt + const token = generateAuthJWT(ensName, serverSecret); + + res.json(token); } catch (e) { console.warn({ message: 'POST profile', diff --git a/packages/backend/src/profile/submitUserProfile.ts b/packages/backend/src/profile/submitUserProfile.ts index e3fbadf44..4c0b7831c 100644 --- a/packages/backend/src/profile/submitUserProfile.ts +++ b/packages/backend/src/profile/submitUserProfile.ts @@ -1,17 +1,16 @@ -import { Session, generateAuthJWT } from '@dm3-org/dm3-lib-delivery'; import { SignedUserProfile, - normalizeEnsName, checkUserProfile, - getDefaultProfileExtension, + normalizeEnsName, } from '@dm3-org/dm3-lib-profile'; +import { generateAuthJWT } from '@dm3-org/dm3-lib-server-side'; import { logDebug } from '@dm3-org/dm3-lib-shared'; import { ethers } from 'ethers'; +import { IBackendDatabase } from '../persistence/getDatabase'; export async function submitUserProfile( provider: ethers.providers.JsonRpcProvider, - getAccount: (accountAddress: string) => Promise, - setAccount: (accountAddress: string, session: Session) => Promise, + db: IBackendDatabase, ensName: string, signedUserProfile: SignedUserProfile, serverSecret: string, @@ -22,19 +21,16 @@ export async function submitUserProfile( logDebug('submitUserProfile - Signature invalid'); throw Error('Signature invalid.'); } - if (await getAccount(account)) { + if (await db.hasAccount(account)) { logDebug('submitUserProfile - Profile exists already'); throw Error('Profile exists already'); } - const session: Session = { - account, - signedUserProfile, - token: generateAuthJWT(ensName, serverSecret), - createdAt: new Date().getTime(), - profileExtension: getDefaultProfileExtension(), - }; - logDebug({ text: 'submitUserProfile', session }); - await setAccount(account.toLocaleLowerCase(), session); + logDebug({ text: 'submitUserProfile', account }); + + await db.setAccount(account.toLocaleLowerCase()); + + // create auth jwt + const token = generateAuthJWT(account, serverSecret); - return session.token; + return token; } diff --git a/packages/backend/src/storage.test.ts b/packages/backend/src/storage.test.ts index 35f0d5495..f8beaf672 100644 --- a/packages/backend/src/storage.test.ts +++ b/packages/backend/src/storage.test.ts @@ -1,9 +1,4 @@ -import { - Session, - generateAuthJWT, - spamFilter, -} from '@dm3-org/dm3-lib-delivery'; -import { SignedUserProfile, schema } from '@dm3-org/dm3-lib-profile'; +import { generateAuthJWT } from '@dm3-org/dm3-lib-server-side'; import { sha256 } from '@dm3-org/dm3-lib-shared'; import { MockDeliveryServiceProfile, @@ -17,17 +12,10 @@ import bodyParser from 'body-parser'; import { ethers } from 'ethers'; import express from 'express'; import request from 'supertest'; -import { - IDatabase, - Redis, - getDatabase, - getRedisClient, -} from './persistence/getDatabase'; +import { IBackendDatabase, getDatabase } from './persistence/getDatabase'; import { MessageRecord } from './persistence/storage/postgres/dto/MessageRecord'; import storage from './storage'; -import fs from 'fs'; - const keysA = { encryptionKeyPair: { publicKey: 'eHmMq29FeiPKfNPkSctPuZGXvV0sKeO/KZkX2nXvMgw=', @@ -51,13 +39,10 @@ describe('Storage', () => { let sender: MockedUserProfile; let receiver: MockedUserProfile; let deliveryService: MockDeliveryServiceProfile; - let redisClient: Redis; beforeEach(async () => { prisma = new PrismaClient(); - redisClient = await getRedisClient(); - await redisClient.flushDb(); app = express(); app.use(bodyParser.json()); @@ -78,29 +63,12 @@ describe('Storage', () => { 'http://localhost:3000', ); - const db = await getDatabase(redisClient, prisma); - - const sessionMocked = { - challenge: '123', - token, - signedUserProfile: { - profile: { - publicSigningKey: keysA.signingKeyPair.publicKey, - }, - } as SignedUserProfile, - } as Session & { spamFilterRules: spamFilter.SpamFilterRules }; + const db = await getDatabase(prisma); const dbMocked = { - getAccount: async (ensName: string) => - Promise.resolve< - Session & { - spamFilterRules: spamFilter.SpamFilterRules; - } - >(sessionMocked), - setAccount: async (_: string, __: Session) => {}, - getIdEnsName: async (ensName: string) => ensName, + hasAccount: (ensName: string) => true, }; - const dbFinal: IDatabase = { ...db, ...dbMocked }; + const dbFinal: IBackendDatabase = { ...db, ...dbMocked }; //const web3ProviderBase = getWeb3Provider(process.env); @@ -121,8 +89,6 @@ describe('Storage', () => { await prisma.encryptedMessage.deleteMany(); await prisma.conversation.deleteMany(); await prisma.account.deleteMany(); - await redisClient.flushDb(); - await redisClient.disconnect(); }); describe('addConversation', () => { diff --git a/packages/backend/src/storage.ts b/packages/backend/src/storage.ts index 536d7dd51..736df4555 100644 --- a/packages/backend/src/storage.ts +++ b/packages/backend/src/storage.ts @@ -1,10 +1,10 @@ import { normalizeEnsName } from '@dm3-org/dm3-lib-profile'; -import { auth } from '@dm3-org/dm3-lib-server-side'; +import { authorize } from '@dm3-org/dm3-lib-server-side'; import { sha256, validateSchema } from '@dm3-org/dm3-lib-shared'; import cors from 'cors'; import { ethers } from 'ethers'; import express, { NextFunction, Request, Response } from 'express'; -import { IDatabase } from './persistence/getDatabase'; +import { IBackendDatabase } from './persistence/getDatabase'; import { MessageRecord } from './persistence/storage'; import { AddMessageBatchRequest } from './schema/storage/AddMessageBatchRequest'; import { AddMessageRequest } from './schema/storage/AddMesssageRequest'; @@ -15,7 +15,7 @@ const DEFAULT_CONVERSATION_PAGE_SIZE = 10; const DEFAULT_MESSAGE_PAGE_SIZE = 100; export default ( - db: IDatabase, + db: IBackendDatabase, web3Provider: ethers.providers.JsonRpcProvider, serverSecret: string, ) => { @@ -32,7 +32,15 @@ export default ( next: NextFunction, ensName: string, ) => { - auth(req, res, next, ensName, db, web3Provider, serverSecret); + authorize( + req, + res, + next, + ensName, + db.hasAccount, + web3Provider, + serverSecret, + ); }, ); diff --git a/packages/delivery-service/src/delivery.test.ts b/packages/delivery-service/src/delivery.test.ts index 0c876cd7e..6f78eaf46 100644 --- a/packages/delivery-service/src/delivery.test.ts +++ b/packages/delivery-service/src/delivery.test.ts @@ -1,4 +1,4 @@ -import { generateAuthJWT } from '@dm3-org/dm3-lib-delivery'; +import { generateAuthJWT } from '@dm3-org/dm3-lib-server-side'; import bodyParser from 'body-parser'; import express from 'express'; import request from 'supertest'; @@ -61,6 +61,7 @@ describe('Delivery', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), getMessages: () => Promise.resolve([]), getIdEnsName: async (ensName: string) => ensName, }; @@ -100,6 +101,7 @@ describe('Delivery', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), getIdEnsName: async (ensName: string) => ensName, }; const app = express(); @@ -145,6 +147,7 @@ describe('Delivery', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), getIdEnsName: async (ensName: string) => ensName, }; diff --git a/packages/delivery-service/src/delivery.ts b/packages/delivery-service/src/delivery.ts index 04bfbb99d..b81b408b3 100644 --- a/packages/delivery-service/src/delivery.ts +++ b/packages/delivery-service/src/delivery.ts @@ -4,7 +4,7 @@ import { schema, } from '@dm3-org/dm3-lib-delivery'; import { normalizeEnsName } from '@dm3-org/dm3-lib-profile'; -import { auth } from '@dm3-org/dm3-lib-server-side'; +import { authorize } from '@dm3-org/dm3-lib-server-side'; import { validateSchema } from '@dm3-org/dm3-lib-shared'; import cors from 'cors'; import { ethers } from 'ethers'; @@ -40,7 +40,15 @@ export default ( //TODO remove router.use(cors()); router.param('ensName', async (req, res, next, ensName: string) => { - auth(req, res, next, ensName, db, web3Provider, serverSecret); + authorize( + req, + res, + next, + ensName, + db.hasAccount, + web3Provider, + serverSecret, + ); }); //Returns all incoming messages for a specific contact name router.get( diff --git a/packages/delivery-service/src/index.ts b/packages/delivery-service/src/index.ts index 816587661..84a072f52 100644 --- a/packages/delivery-service/src/index.ts +++ b/packages/delivery-service/src/index.ts @@ -1,14 +1,12 @@ import { Session } from '@dm3-org/dm3-lib-delivery'; import { Auth, - Profile, errorHandler, getCachedWebProvider, getServerSecret, logError, logRequest, readKeysFromEnv, - socketAuth, } from '@dm3-org/dm3-lib-server-side'; import { NotificationChannelType, logInfo } from '@dm3-org/dm3-lib-shared'; import { Axios } from 'axios'; @@ -27,8 +25,10 @@ import Delivery from './delivery'; import { onConnection } from './messaging'; import Notifications from './notifications'; import { IDatabase, getDatabase } from './persistence/getDatabase'; +import { Profile } from './profile/profile'; import RpcProxy from './rpc/rpc-proxy'; import { WebSocketManager } from './ws/WebSocketManager'; +import { socketAuth } from './socketAuth'; const app = express(); app.use(express.json({ limit: '50mb' })); @@ -36,10 +36,10 @@ app.use(express.urlencoded({ limit: '50mb' })); const server = http.createServer(app); -//On the delivery-service side the address functions as an identifier for the account. +// On the delivery-service side the address functions as an identifier for the account. // The reason for that is that the DS should accept all messages directet to the address. Regardless of its ENS name. // To use as much shared code as possible from lib/server-side the address is resolved to the account before each database call. -//using this wrapper around the IDatabase +// using this wrapper around the IDatabase const getDbWithAddressResolvedGetAccount = ( db: IDatabase, web3Provider: ethers.providers.JsonRpcProvider, @@ -63,9 +63,29 @@ const getDbWithAddressResolvedGetAccount = ( }; }; + const hasAccountForEnsName = ( + web3Provider: ethers.providers.JsonRpcProvider, + hasAccount: (ensName: string) => Promise, + ) => { + return async (ensName: string) => { + const address = await web3Provider.resolveName(ensName); + console.debug( + 'getDbWithAddressResolvedHasAccount resolved address for ens name: ', + ensName, + address, + ); + if (!address) { + console.info('no address found for ens name: ', ensName); + return false; + } + return hasAccount(address); + }; + }; + return { ...db, getAccount: getAccountForEnsName(web3Provider, db.getAccount), + hasAccount: hasAccountForEnsName(web3Provider, db.hasAccount), }; }; //TODO remove @@ -133,7 +153,7 @@ global.logger = winston.createLogger({ //socketAuth //restAuth - app.use('/auth', Auth(db, serverSecret)); + app.use('/auth', Auth(db, serverSecret, web3Provider)); app.use('/profile', Profile(db, web3Provider, serverSecret)); app.use('/delivery', Delivery(web3Provider, db, serverSecret)); app.use( diff --git a/packages/delivery-service/src/notifications.test.ts b/packages/delivery-service/src/notifications.test.ts index 9e2a3bcd4..bb72ed3b9 100644 --- a/packages/delivery-service/src/notifications.test.ts +++ b/packages/delivery-service/src/notifications.test.ts @@ -1,12 +1,11 @@ -import { - DeliveryServiceProperties, - generateAuthJWT, -} from '@dm3-org/dm3-lib-delivery'; +import { DeliveryServiceProperties } from '@dm3-org/dm3-lib-delivery'; +import { generateAuthJWT } from '@dm3-org/dm3-lib-server-side'; import { NotificationChannelType } from '@dm3-org/dm3-lib-shared'; import bodyParser from 'body-parser'; import express from 'express'; import request from 'supertest'; import notifications from './notifications'; +import { hasAccount } from './persistence/account/hasAccount'; const serverSecret = 'secret'; @@ -37,122 +36,123 @@ describe('Notifications', () => { // db = await getDatabase(); // }); - describe('get NotificationChannels', () => { - it('Returns empty array as global notification is turned off', async () => { - const app = express(); - app.use(bodyParser.json()); - - const db = { - getAccount: async (ensName: string) => - Promise.resolve({ - challenge: '123', - token, - signedUserProfile: { - profile: { - publicSigningKey: - keysA.signingKeyPair.publicKey, - }, - }, - }), - setAccount: async (_: string, __: any) => { - return (_: any, __: any, ___: any) => {}; - }, - getUserStorage: async (addr: string) => { - return {}; - }, - getIdEnsName: async (ensName: string) => ensName, - getGlobalNotification: async (ensName: string) => - Promise.resolve({ isEnabled: false }), - getUsersNotificationChannels: async (ensName: string) => - Promise.resolve([]), - }; - - const web3Provider = { - resolveName: async () => - '0x71CB05EE1b1F506fF321Da3dac38f25c0c9ce6E1', - }; - - app.use( - notifications( - deliveryServiceProperties, - db as any, - web3Provider as any, - serverSecret, - ), - ); - - const token = generateAuthJWT('bob.eth', serverSecret); - - const { status, body } = await request(app) - .get(`/bob.eth`) - .set({ - authorization: `Bearer ${token}`, - }) - .send(); - - expect(status).toBe(200); - expect(body).toEqual({ - notificationChannels: [], - }); - }); - - it('Returns 200 with empty notification channels as global notification is turned on', async () => { - const app = express(); - app.use(bodyParser.json()); - const db = { - getAccount: async (ensName: string) => - Promise.resolve({ - challenge: '123', - token, - signedUserProfile: { - profile: { - publicSigningKey: - keysA.signingKeyPair.publicKey, - }, - }, - }), - setAccount: async (_: string, __: any) => { - return (_: any, __: any, ___: any) => {}; - }, - getUserStorage: async (addr: string) => { - return {}; - }, - getIdEnsName: async (ensName: string) => ensName, - getUsersNotificationChannels: async (ensName: string) => - Promise.resolve([]), - getGlobalNotification: async (ensName: string) => - Promise.resolve({ isEnabled: true }), - }; - - const web3Provider = { - resolveName: async () => - '0x71CB05EE1b1F506fF321Da3dac38f25c0c9ce6E1', - }; - - app.use( - notifications( - deliveryServiceProperties, - db as any, - web3Provider as any, - serverSecret, - ), - ); - - const token = generateAuthJWT('bob.eth', serverSecret); - - const { status, body } = await request(app) - .get(`/bob.eth`) - .set({ - authorization: `Bearer ${token}`, - }) - .send(); - - expect(status).toBe(200); - expect(body).toEqual({ - notificationChannels: [], - }); - }); - }); + // describe('get NotificationChannels', () => { + // it('Returns empty array as global notification is turned off', async () => { + // const app = express(); + // app.use(bodyParser.json()); + + // const db = { + // getAccount: async (ensName: string) => + // Promise.resolve({ + // challenge: '123', + // token, + // signedUserProfile: { + // profile: { + // publicSigningKey: + // keysA.signingKeyPair.publicKey, + // }, + // }, + // }), + // setAccount: async (_: string, __: any) => { + // return (_: any, __: any, ___: any) => {}; + // }, + // hasAccount: (_: string) => Promise.resolve(true), + // getUserStorage: async (addr: string) => { + // return {}; + // }, + // getIdEnsName: async (ensName: string) => ensName, + // getGlobalNotification: async (ensName: string) => + // Promise.resolve({ isEnabled: false }), + // getUsersNotificationChannels: async (ensName: string) => + // Promise.resolve([]), + // }; + + // const web3Provider = { + // resolveName: async () => + // '0x71CB05EE1b1F506fF321Da3dac38f25c0c9ce6E1', + // }; + + // app.use( + // notifications( + // deliveryServiceProperties, + // db as any, + // web3Provider as any, + // serverSecret, + // ), + // ); + + // const token = generateAuthJWT('bob.eth', serverSecret); + + // const { status, body } = await request(app) + // .get(`/bob.eth`) + // .set({ + // authorization: `Bearer ${token}`, + // }) + // .send(); + + // expect(status).toBe(200); + // expect(body).toEqual({ + // notificationChannels: [], + // }); + // }); + + // it('Returns 200 with empty notification channels as global notification is turned on', async () => { + // const app = express(); + // app.use(bodyParser.json()); + // const db = { + // getAccount: async (ensName: string) => + // Promise.resolve({ + // challenge: '123', + // token, + // signedUserProfile: { + // profile: { + // publicSigningKey: + // keysA.signingKeyPair.publicKey, + // }, + // }, + // }), + // setAccount: async (_: string, __: any) => { + // return (_: any, __: any, ___: any) => {}; + // }, + // getUserStorage: async (addr: string) => { + // return {}; + // }, + // getIdEnsName: async (ensName: string) => ensName, + // getUsersNotificationChannels: async (ensName: string) => + // Promise.resolve([]), + // getGlobalNotification: async (ensName: string) => + // Promise.resolve({ isEnabled: true }), + // }; + + // const web3Provider = { + // resolveName: async () => + // '0x71CB05EE1b1F506fF321Da3dac38f25c0c9ce6E1', + // }; + + // app.use( + // notifications( + // deliveryServiceProperties, + // db as any, + // web3Provider as any, + // serverSecret, + // ), + // ); + + // const token = generateAuthJWT('bob.eth', serverSecret); + + // const { status, body } = await request(app) + // .get(`/bob.eth`) + // .set({ + // authorization: `Bearer ${token}`, + // }) + // .send(); + + // expect(status).toBe(200); + // expect(body).toEqual({ + // notificationChannels: [], + // }); + // }); + // }); describe('Add Email as notification channel', () => { it('Returns 400 on setup email notifications as email ID is invalid', async () => { @@ -169,6 +169,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), setUserStorage: (_: string, __: string) => {}, getIdEnsName: async (ensName: string) => ensName, addUsersNotificationChannel: addUsersNotificationChannelMock, @@ -216,6 +217,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), setUserStorage: (_: string, __: string) => {}, getIdEnsName: async (ensName: string) => ensName, addUsersNotificationChannel: addUsersNotificationChannelMock, @@ -264,6 +266,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), setUserStorage: (_: string, __: string) => {}, getIdEnsName: async (ensName: string) => ensName, getGlobalNotification: async (ensName: string) => @@ -333,6 +336,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), setUserStorage: (_: string, __: string) => {}, getIdEnsName: async (ensName: string) => ensName, getGlobalNotification: async (ensName: string) => @@ -397,6 +401,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), setUserStorage: (_: string, __: string) => {}, getIdEnsName: async (ensName: string) => ensName, getGlobalNotification: async (ensName: string) => @@ -466,6 +471,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), getUserStorage: async (addr: string) => { return {}; }, @@ -520,6 +526,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), setUserStorage: (_: string, __: string) => {}, getIdEnsName: async (ensName: string) => ensName, setGlobalNotification: setGlobalNotificationMock, @@ -570,6 +577,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), setUserStorage: (_: string, __: string) => {}, getIdEnsName: async (ensName: string) => ensName, setGlobalNotification: setGlobalNotificationMock, @@ -619,6 +627,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), setUserStorage: (_: string, __: string) => {}, getIdEnsName: async (ensName: string) => ensName, setGlobalNotification: setGlobalNotificationMock, @@ -666,6 +675,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), setUserStorage: (_: string, __: string) => {}, getIdEnsName: async (ensName: string) => ensName, getGlobalNotification: async (ensName: string) => @@ -714,6 +724,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), setUserStorage: (_: string, __: string) => {}, getIdEnsName: async (ensName: string) => ensName, resendOtp: resendOtpMock, @@ -782,6 +793,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), setUserStorage: (_: string, __: string) => {}, getIdEnsName: async (ensName: string) => ensName, getGlobalNotification: async (ensName: string) => @@ -845,6 +857,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), setUserStorage: (_: string, __: string) => {}, getIdEnsName: async (ensName: string) => ensName, getGlobalNotification: async (ensName: string) => @@ -896,6 +909,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), setUserStorage: (_: string, __: string) => {}, getIdEnsName: async (ensName: string) => ensName, getGlobalNotification: async (ensName: string) => @@ -947,6 +961,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), setUserStorage: (_: string, __: string) => {}, getIdEnsName: async (ensName: string) => ensName, getGlobalNotification: async (ensName: string) => @@ -1034,6 +1049,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), setUserStorage: (_: string, __: string) => {}, getIdEnsName: async (ensName: string) => ensName, getGlobalNotification: async (ensName: string) => @@ -1088,6 +1104,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), setUserStorage: (_: string, __: string) => {}, getIdEnsName: async (ensName: string) => ensName, getGlobalNotification: async (ensName: string) => @@ -1136,6 +1153,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), setUserStorage: (_: string, __: string) => {}, getIdEnsName: async (ensName: string) => ensName, getGlobalNotification: async (ensName: string) => @@ -1185,6 +1203,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), setUserStorage: (_: string, __: string) => {}, getIdEnsName: async (ensName: string) => ensName, getGlobalNotification: async (ensName: string) => @@ -1234,6 +1253,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), setUserStorage: (_: string, __: string) => {}, getIdEnsName: async (ensName: string) => ensName, getGlobalNotification: async (ensName: string) => @@ -1283,6 +1303,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), setUserStorage: (_: string, __: string) => {}, getIdEnsName: async (ensName: string) => ensName, getGlobalNotification: async (ensName: string) => @@ -1350,6 +1371,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), setUserStorage: (_: string, __: string) => {}, getIdEnsName: async (ensName: string) => ensName, getGlobalNotification: async (ensName: string) => @@ -1426,6 +1448,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), setUserStorage: (_: string, __: string) => {}, getIdEnsName: async (ensName: string) => ensName, getGlobalNotification: async (ensName: string) => @@ -1487,6 +1510,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), setUserStorage: (_: string, __: string) => {}, getIdEnsName: async (ensName: string) => ensName, getGlobalNotification: async (ensName: string) => @@ -1534,6 +1558,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), setUserStorage: (_: string, __: string) => {}, getIdEnsName: async (ensName: string) => ensName, getGlobalNotification: async (ensName: string) => @@ -1595,6 +1620,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), setUserStorage: (_: string, __: string) => {}, getIdEnsName: async (ensName: string) => ensName, getGlobalNotification: async (ensName: string) => @@ -1656,6 +1682,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), setUserStorage: (_: string, __: string) => {}, getIdEnsName: async (ensName: string) => ensName, addUsersNotificationChannel: addUsersNotificationChannelMock, @@ -1711,6 +1738,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), setUserStorage: (_: string, __: string) => {}, getIdEnsName: async (ensName: string) => ensName, addUsersNotificationChannel: addUsersNotificationChannelMock, @@ -1765,6 +1793,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), setUserStorage: (_: string, __: string) => {}, getIdEnsName: async (ensName: string) => ensName, addUsersNotificationChannel: addUsersNotificationChannelMock, @@ -1822,6 +1851,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), setUserStorage: (_: string, __: string) => {}, getIdEnsName: async (ensName: string) => ensName, addUsersNotificationChannel: addUsersNotificationChannelMock, @@ -1879,6 +1909,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), setUserStorage: (_: string, __: string) => {}, getIdEnsName: async (ensName: string) => ensName, addUsersNotificationChannel: addUsersNotificationChannelMock, @@ -1926,6 +1957,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), setUserStorage: (_: string, __: string) => {}, getIdEnsName: async (ensName: string) => ensName, addUsersNotificationChannel: addUsersNotificationChannelMock, @@ -1974,6 +2006,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), setUserStorage: (_: string, __: string) => {}, getIdEnsName: async (ensName: string) => ensName, getGlobalNotification: async (ensName: string) => @@ -2041,6 +2074,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), setUserStorage: (_: string, __: string) => {}, getIdEnsName: async (ensName: string) => ensName, getGlobalNotification: async (ensName: string) => @@ -2105,6 +2139,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), setUserStorage: (_: string, __: string) => {}, getIdEnsName: async (ensName: string) => ensName, getGlobalNotification: async (ensName: string) => @@ -2166,6 +2201,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), setUserStorage: (_: string, __: string) => {}, getIdEnsName: async (ensName: string) => ensName, getGlobalNotification: async (ensName: string) => @@ -2213,6 +2249,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), setUserStorage: (_: string, __: string) => {}, getIdEnsName: async (ensName: string) => ensName, getGlobalNotification: async (ensName: string) => @@ -2282,6 +2319,7 @@ describe('Notifications', () => { setAccount: async (_: string, __: any) => { return (_: any, __: any, ___: any) => {}; }, + hasAccount: (_: string) => Promise.resolve(true), setUserStorage: (_: string, __: string) => {}, getIdEnsName: async (ensName: string) => ensName, getGlobalNotification: async (ensName: string) => diff --git a/packages/delivery-service/src/notifications.ts b/packages/delivery-service/src/notifications.ts index 21bb4aaa8..9a993cf01 100644 --- a/packages/delivery-service/src/notifications.ts +++ b/packages/delivery-service/src/notifications.ts @@ -2,7 +2,7 @@ import cors from 'cors'; import { normalizeEnsName } from '@dm3-org/dm3-lib-profile'; import express from 'express'; -import { auth } from '@dm3-org/dm3-lib-server-side'; +import { authorize } from '@dm3-org/dm3-lib-server-side'; import { ethers } from 'ethers'; import { validateNewNotificationChannelData, @@ -35,7 +35,15 @@ export default ( // Adding a route parameter middleware named 'ensName' router.param('ensName', (req, res, next, ensName: string) => { - auth(req, res, next, ensName, db, web3Provider, serverSecret); + authorize( + req, + res, + next, + ensName, + db.hasAccount, + web3Provider, + serverSecret, + ); }); // Defining a route to enable/disable global notifications diff --git a/packages/delivery-service/src/persistence/account/hasAccount.ts b/packages/delivery-service/src/persistence/account/hasAccount.ts new file mode 100644 index 000000000..dc073088e --- /dev/null +++ b/packages/delivery-service/src/persistence/account/hasAccount.ts @@ -0,0 +1,18 @@ +import { Redis, RedisPrefix } from '../getDatabase'; +import { ethers } from 'ethers'; + +export function hasAccount(redis: Redis) { + return async (address: string) => { + if (!ethers.utils.isAddress(address)) { + console.debug('hasAccount: Invalid address: ', address); + throw Error('hasAccount: Invalid address'); + } + + let account = await redis.get( + RedisPrefix.Account + ethers.utils.getAddress(address), + ); + + // return true if account exists, false otherwise + return !!account; + }; +} diff --git a/packages/delivery-service/src/persistence/account/index.ts b/packages/delivery-service/src/persistence/account/index.ts index 37cd75311..e13cb3151 100644 --- a/packages/delivery-service/src/persistence/account/index.ts +++ b/packages/delivery-service/src/persistence/account/index.ts @@ -1,4 +1,5 @@ import { setAccount } from './setAccount'; import { getAccount } from './getAccount'; import { getIdEnsName } from '../getIdEnsName'; -export default { setAccount, getAccount, getIdEnsName }; +import { hasAccount } from './hasAccount'; +export default { setAccount, getAccount, getIdEnsName, hasAccount }; diff --git a/packages/delivery-service/src/persistence/getDatabase.ts b/packages/delivery-service/src/persistence/getDatabase.ts index f313314e7..e9b36b144 100644 --- a/packages/delivery-service/src/persistence/getDatabase.ts +++ b/packages/delivery-service/src/persistence/getDatabase.ts @@ -69,6 +69,7 @@ export async function getDatabase( // Account setAccount: Account.setAccount(redis), getAccount: Account.getAccount(redis), + hasAccount: Account.hasAccount(redis), getIdEnsName: getIdEnsName(redis), syncAcknowledge: syncAcknowledge(redis), //Notification diff --git a/packages/lib/server-side/src/profile.test.ts b/packages/delivery-service/src/profile/profile.test.ts similarity index 98% rename from packages/lib/server-side/src/profile.test.ts rename to packages/delivery-service/src/profile/profile.test.ts index d9623355a..1b679d6ca 100644 --- a/packages/lib/server-side/src/profile.test.ts +++ b/packages/delivery-service/src/profile/profile.test.ts @@ -1,4 +1,5 @@ -import { Session, generateAuthJWT } from '@dm3-org/dm3-lib-delivery'; +import { Session } from '@dm3-org/dm3-lib-delivery'; +import { generateAuthJWT } from '@dm3-org/dm3-lib-server-side'; import { UserProfile, getProfileCreationMessage, @@ -9,7 +10,7 @@ import { ethers } from 'ethers'; import express from 'express'; import request from 'supertest'; import { Profile as profile } from './profile'; -import { IAccountDatabase } from '../dist'; +import { IDatabase } from '../persistence/getDatabase'; const serverSecret = 'veryImportantSecretToGenerateAndValidateJSONWebTokens'; @@ -17,7 +18,7 @@ const token = generateAuthJWT('alice.eth', serverSecret); const setUpApp = async ( app: express.Express, - db: IAccountDatabase, + db: IDatabase, web3Provider: ethers.providers.JsonRpcProvider, serverSecret: string = 'my-secret', ) => { diff --git a/packages/lib/server-side/src/profile.ts b/packages/delivery-service/src/profile/profile.ts similarity index 97% rename from packages/lib/server-side/src/profile.ts rename to packages/delivery-service/src/profile/profile.ts index 9e073bf19..870a86a48 100644 --- a/packages/lib/server-side/src/profile.ts +++ b/packages/delivery-service/src/profile/profile.ts @@ -3,10 +3,10 @@ import { normalizeEnsName, schema } from '@dm3-org/dm3-lib-profile'; import { validateSchema } from '@dm3-org/dm3-lib-shared'; import { ethers } from 'ethers'; import express from 'express'; -import { IAccountDatabase } from './iSessionDatabase'; +import { IDatabase } from '../persistence/getDatabase'; export const Profile = ( - db: IAccountDatabase, + db: IDatabase, web3Provider: ethers.providers.JsonRpcProvider, serverSecret: string, ) => { diff --git a/packages/delivery-service/src/socketAuth.ts b/packages/delivery-service/src/socketAuth.ts new file mode 100644 index 000000000..51d2cd8c5 --- /dev/null +++ b/packages/delivery-service/src/socketAuth.ts @@ -0,0 +1,55 @@ +/* eslint-disable no-console */ +import { checkToken } from '@dm3-org/dm3-lib-server-side'; +import { normalizeEnsName } from '@dm3-org/dm3-lib-profile'; +import { ethers } from 'ethers'; +import { Socket } from 'socket.io'; +import { ExtendedError } from 'socket.io/dist/namespace'; +import type { IDatabase } from './persistence/getDatabase'; + +export function socketAuth( + db: IDatabase, + web3Provider: ethers.providers.JsonRpcProvider, + serverSecret: string, +) { + return async ( + socket: Socket, + next: (err?: ExtendedError | undefined) => void, + ) => { + try { + const ensName = normalizeEnsName( + socket.handshake.auth.account.ensName, + ); + console.log('Start WS auth for ', ensName, socket.id); + + if ( + !(await checkToken( + web3Provider, + db.hasAccount, + ensName, + socket.handshake.auth.token as string, + serverSecret, + )) + ) { + console.log('check token has failed for WS '); + return next(new Error('check token has failed for WS')); + } + const session = await db.getAccount(ensName); + if (!session) { + throw Error('Could not get session'); + } + //we use session.account here as a key for setAccount here. + //We can do this because the address is used as account when the Session has been created. + //That saves a address lookup via ENS + await db.setAccount(session.account, { + ...session, + socketId: socket.id, + }); + } catch (e) { + console.log('socket auth error'); + console.log(e); + next(e as Error); + } + + next(); + }; +} diff --git a/packages/delivery-service/src/ws/WebSocketManager.test.ts b/packages/delivery-service/src/ws/WebSocketManager.test.ts index 4b53a2fcc..bf65316f8 100644 --- a/packages/delivery-service/src/ws/WebSocketManager.test.ts +++ b/packages/delivery-service/src/ws/WebSocketManager.test.ts @@ -2,7 +2,7 @@ import { io as Client } from 'socket.io-client'; import { Server as SocketIoServer } from 'socket.io'; import { createServer, Server as HttpServerType } from 'http'; import { AUTHORIZED, UNAUTHORIZED, WebSocketManager } from './WebSocketManager'; -import { generateAuthJWT } from '@dm3-org/dm3-lib-delivery'; +import { generateAuthJWT } from '@dm3-org/dm3-lib-server-side'; import { getMockDeliveryServiceProfile, MockDeliveryServiceProfile, @@ -139,6 +139,7 @@ describe('WebSocketManager', () => { token: 'token', createdAt: new Date().getTime(), }), + hasAccount: (_: string) => Promise.resolve(true), } as any; new WebSocketManager( @@ -184,6 +185,7 @@ describe('WebSocketManager', () => { token: 'old token that is not used anymore', createdAt: new Date().getTime(), }), + hasAccount: (_: string) => Promise.resolve(true), } as any; const wsManager = new WebSocketManager( @@ -232,6 +234,7 @@ describe('WebSocketManager', () => { token: 'old token that is not used anymore', createdAt: new Date().getTime(), }), + hasAccount: (_: string) => Promise.resolve(true), } as any; const wsManager = new WebSocketManager( @@ -305,6 +308,7 @@ describe('WebSocketManager', () => { token: 'old token that is not used anymore', createdAt: new Date().getTime(), }), + hasAccount: (_: string) => Promise.resolve(true), } as any; const wsManager = new WebSocketManager( @@ -374,6 +378,7 @@ describe('WebSocketManager', () => { }); } }, + hasAccount: (_: string) => Promise.resolve(true), } as any; const wsManager = new WebSocketManager( @@ -470,6 +475,7 @@ describe('WebSocketManager', () => { token: 'old token that is not used anymore', createdAt: new Date().getTime(), }), + hasAccount: (_: string) => Promise.resolve(true), } as any; const wsManager = new WebSocketManager( diff --git a/packages/delivery-service/src/ws/WebSocketManager.ts b/packages/delivery-service/src/ws/WebSocketManager.ts index d7ef1aac6..3b86d6638 100644 --- a/packages/delivery-service/src/ws/WebSocketManager.ts +++ b/packages/delivery-service/src/ws/WebSocketManager.ts @@ -1,4 +1,4 @@ -import { checkToken } from '@dm3-org/dm3-lib-delivery'; +import { checkToken } from '@dm3-org/dm3-lib-server-side'; import { normalizeEnsName } from '@dm3-org/dm3-lib-profile'; import { IWebSocketManager } from '@dm3-org/dm3-lib-shared'; import { ethers } from 'ethers'; @@ -60,7 +60,7 @@ export class WebSocketManager implements IWebSocketManager { //Use the already existing function checkToken to check whether the token matches the provided ensName const hasSession = await checkToken( this.web3Provider, - this.db.getAccount, + this.db.hasAccount, ensName, token, this.serverSecret, diff --git a/packages/lib/delivery/package.json b/packages/lib/delivery/package.json index 45ac02216..e7f884a69 100644 --- a/packages/lib/delivery/package.json +++ b/packages/lib/delivery/package.json @@ -15,6 +15,7 @@ "@dm3-org/dm3-lib-crypto": "workspace:^", "@dm3-org/dm3-lib-messaging": "workspace:^", "@dm3-org/dm3-lib-profile": "workspace:^", + "@dm3-org/dm3-lib-server-side": "workspace:^", "@dm3-org/dm3-lib-shared": "workspace:^", "@types/libsodium-wrappers": "^0.7.10", "ethers": "5.7.2", diff --git a/packages/lib/delivery/src/Session.test.ts b/packages/lib/delivery/src/Session.test.ts index e757ce9f8..562cfe4db 100644 --- a/packages/lib/delivery/src/Session.test.ts +++ b/packages/lib/delivery/src/Session.test.ts @@ -1,6 +1,5 @@ -import { checkToken, Session } from './Session'; +import { checkToken, generateAuthJWT } from '@dm3-org/dm3-lib-server-side'; import { sign, verify } from 'jsonwebtoken'; -import { generateAuthJWT } from './Keys'; const serverSecret = 'veryImportantSecretToGenerateAndValidateJSONWebTokens'; // create valid jwt @@ -9,18 +8,14 @@ const token = generateAuthJWT('alice.eth', serverSecret); describe('Session', () => { describe('checkToken with state', () => { it('Should return true if the jwt is valid', async () => { - const getAccount = (_: string) => - Promise.resolve({ - token: token, - createdAt: new Date().getTime(), - } as Session); + const hasAccount = (_: string) => Promise.resolve(true); const isValid = await checkToken( { resolveName: async () => '0x25A643B6e52864d0eD816F1E43c0CF49C83B8292', } as any, - getAccount, + hasAccount, 'alice.eth', token, serverSecret, @@ -30,14 +25,14 @@ describe('Session', () => { }); it('Should return false if no session exists for the account ', async () => { - const getAccount = (_: string) => Promise.resolve(null); + const hasAccount = (_: string) => Promise.resolve(false); const isValid = await checkToken( { resolveName: async () => '0x25A643B6e52864d0eD816F1E43c0CF49C83B8292', } as any, - getAccount, + hasAccount, 'alice.eth', token, serverSecret, @@ -48,15 +43,14 @@ describe('Session', () => { it('Should return false if the token is signed with a different secret ', async () => { const token = generateAuthJWT('alice.eth', 'attackersSecret'); - const getAccount = (_: string) => - Promise.resolve({ token: 'bar' } as Session); + const hasAccount = (_: string) => Promise.resolve(true); const isValid = await checkToken( { resolveName: async () => '0x25A643B6e52864d0eD816F1E43c0CF49C83B8292', } as any, - getAccount, + hasAccount, 'alice.eth', token, serverSecret, @@ -66,8 +60,7 @@ describe('Session', () => { }); it('Should return false if a session exists but the token is expired ', async () => { - const getAccount = (_: string) => - Promise.resolve({ token: 'foo', createdAt: 1 } as Session); + const hasAccount = (_: string) => Promise.resolve(true); const oneMinuteAgo = new Date().getTime() / 1000 - 60; // this token expired a minute ago @@ -86,7 +79,7 @@ describe('Session', () => { resolveName: async () => '0x25A643B6e52864d0eD816F1E43c0CF49C83B8292', } as any, - getAccount, + hasAccount, 'alice.eth', _token, serverSecret, @@ -96,8 +89,7 @@ describe('Session', () => { }); it('Should return false if token issuance date is in the future ', async () => { - const getAccount = (_: string) => - Promise.resolve({ token: 'foo', createdAt: 1 } as Session); + const hasAccount = (_: string) => Promise.resolve(true); const tokenBody = verify(token, serverSecret); if ( @@ -118,7 +110,7 @@ describe('Session', () => { resolveName: async () => '0x25A643B6e52864d0eD816F1E43c0CF49C83B8292', } as any, - getAccount, + hasAccount, 'alice.eth', token, serverSecret, @@ -142,7 +134,7 @@ describe('Session', () => { resolveName: async () => '0x25A643B6e52864d0eD816F1E43c0CF49C83B8292', } as any, - getAccount, + hasAccount, 'alice.eth', _token, serverSecret, @@ -153,8 +145,7 @@ describe('Session', () => { }); describe('checkToken is not missing information', () => { it('Should return false if iat is missing', async () => { - const getAccount = (_: string) => - Promise.resolve({ token: 'foo', createdAt: 1 } as Session); + const hasAccount = (_: string) => Promise.resolve(true); const tokenBody = verify(token, serverSecret); if ( @@ -175,7 +166,7 @@ describe('Session', () => { resolveName: async () => '0x25A643B6e52864d0eD816F1E43c0CF49C83B8292', } as any, - getAccount, + hasAccount, 'alice.eth', token, serverSecret, @@ -199,7 +190,7 @@ describe('Session', () => { resolveName: async () => '0x25A643B6e52864d0eD816F1E43c0CF49C83B8292', } as any, - getAccount, + hasAccount, 'alice.eth', _invalidToken, serverSecret, @@ -209,8 +200,7 @@ describe('Session', () => { }); it('Should return false if nbf is missing', async () => { - const getAccount = (_: string) => - Promise.resolve({ token: 'foo', createdAt: 1 } as Session); + const hasAccount = (_: string) => Promise.resolve(true); const tokenBody = verify(token, serverSecret); if ( @@ -231,7 +221,7 @@ describe('Session', () => { resolveName: async () => '0x25A643B6e52864d0eD816F1E43c0CF49C83B8292', } as any, - getAccount, + hasAccount, 'alice.eth', token, serverSecret, @@ -256,7 +246,7 @@ describe('Session', () => { resolveName: async () => '0x25A643B6e52864d0eD816F1E43c0CF49C83B8292', } as any, - getAccount, + hasAccount, 'alice.eth', _invalidToken, serverSecret, @@ -265,9 +255,8 @@ describe('Session', () => { expect(isValid).toBe(false); }); - it('Should return false if account is missing', async () => { - const getAccount = (_: string) => - Promise.resolve({ token: 'foo', createdAt: 1 } as Session); + it('Should return false if key "account" is missing', async () => { + const hasAccount = (_: string) => Promise.resolve(true); const tokenBody = verify(token, serverSecret); if ( @@ -288,7 +277,7 @@ describe('Session', () => { resolveName: async () => '0x25A643B6e52864d0eD816F1E43c0CF49C83B8292', } as any, - getAccount, + hasAccount, 'alice.eth', token, serverSecret, @@ -313,7 +302,7 @@ describe('Session', () => { resolveName: async () => '0x25A643B6e52864d0eD816F1E43c0CF49C83B8292', } as any, - getAccount, + hasAccount, 'alice.eth', _invalidToken, serverSecret, @@ -323,8 +312,7 @@ describe('Session', () => { }); it('Should return false if exp is missing', async () => { - const getAccount = (_: string) => - Promise.resolve({ token: 'foo', createdAt: 1 } as Session); + const hasAccount = (_: string) => Promise.resolve(true); const tokenBody = verify(token, serverSecret); if ( @@ -345,7 +333,7 @@ describe('Session', () => { resolveName: async () => '0x25A643B6e52864d0eD816F1E43c0CF49C83B8292', } as any, - getAccount, + hasAccount, 'alice.eth', token, serverSecret, @@ -370,7 +358,7 @@ describe('Session', () => { resolveName: async () => '0x25A643B6e52864d0eD816F1E43c0CF49C83B8292', } as any, - getAccount, + hasAccount, 'alice.eth', _invalidToken, serverSecret, @@ -379,10 +367,9 @@ describe('Session', () => { expect(isValid).toBe(false); }); }); - describe('checkToken does not contain unexpeted keys', () => { + describe('checkToken does not contain unexpected keys', () => { it('Should return false if challenge is present', async () => { - const getAccount = (_: string) => - Promise.resolve({ token: 'foo', createdAt: 1 } as Session); + const hasAccount = (_: string) => Promise.resolve(true); const tokenBody = verify(token, serverSecret); if ( @@ -403,7 +390,7 @@ describe('Session', () => { resolveName: async () => '0x25A643B6e52864d0eD816F1E43c0CF49C83B8292', } as any, - getAccount, + hasAccount, 'alice.eth', token, serverSecret, @@ -429,7 +416,7 @@ describe('Session', () => { resolveName: async () => '0x25A643B6e52864d0eD816F1E43c0CF49C83B8292', } as any, - getAccount, + hasAccount, 'alice.eth', _invalidToken, serverSecret, @@ -438,8 +425,7 @@ describe('Session', () => { expect(isValid).toBe(false); }); it('Should return false if some additional key is present', async () => { - const getAccount = (_: string) => - Promise.resolve({ token: 'foo', createdAt: 1 } as Session); + const hasAccount = (_: string) => Promise.resolve(true); const tokenBody = verify(token, serverSecret); if ( @@ -460,7 +446,7 @@ describe('Session', () => { resolveName: async () => '0x25A643B6e52864d0eD816F1E43c0CF49C83B8292', } as any, - getAccount, + hasAccount, 'alice.eth', token, serverSecret, @@ -486,7 +472,7 @@ describe('Session', () => { resolveName: async () => '0x25A643B6e52864d0eD816F1E43c0CF49C83B8292', } as any, - getAccount, + hasAccount, 'alice.eth', _invalidToken, serverSecret, diff --git a/packages/lib/delivery/src/Session.ts b/packages/lib/delivery/src/Session.ts index 3e68f82cd..c0b5fb63e 100644 --- a/packages/lib/delivery/src/Session.ts +++ b/packages/lib/delivery/src/Session.ts @@ -1,24 +1,9 @@ import { ProfileExtension, SignedUserProfile } from '@dm3-org/dm3-lib-profile'; -import { validateSchema } from '@dm3-org/dm3-lib-shared'; -import { ethers } from 'ethers'; -import { decode, verify } from 'jsonwebtoken'; import { SpamFilterRules } from './spam-filter'; //1Year const TTL = 31536000000; -const authJwtPayloadSchema = { - type: 'object', - properties: { - account: { type: 'string' }, - iat: { type: 'number' }, - exp: { type: 'number' }, - nbf: { type: 'number' }, - }, - required: ['account', 'iat', 'exp', 'nbf'], - additionalProperties: false, -}; - export interface Session { account: string; signedUserProfile: SignedUserProfile; @@ -31,50 +16,3 @@ export interface Session { //TODO use SpamFilterRules once spam-filer is ready spamFilterRules?: SpamFilterRules; } - -export async function checkToken( - provider: ethers.providers.JsonRpcProvider, - getAccount: (ensName: string) => Promise, - ensName: string, - token: string, - serverSecret: string, -): Promise { - const session = await getAccount(ensName.toLocaleLowerCase()); - if (!session) { - console.debug('there is no account for this ens name: ', ensName); - return false; - } - - // check jwt for validity - try { - // will throw if signature is invalid or exp is in the past - const jwtPayload = verify(token, serverSecret, { - algorithms: ['HS256'], - }); - - // check if payload is well formed - if ( - typeof jwtPayload === 'string' || - !validateSchema(authJwtPayloadSchema, jwtPayload) - ) { - console.debug('jwt malformed'); - return false; - } - - if (!jwtPayload.iat || jwtPayload.iat > Date.now() / 1000) { - console.debug('jwt invalid: iat missing or in the future'); - return false; - } - - if (jwtPayload.account !== ensName) { - console.debug('jwt invalid: account mismatch'); - return false; - } - } catch (error) { - console.debug(`jwt invalid: ${error}`); - return false; - } - - // the token is valid only if all checks passed - return true; -} diff --git a/packages/lib/delivery/src/UserProfile.ts b/packages/lib/delivery/src/UserProfile.ts index 9e1f78afb..6b4944c12 100644 --- a/packages/lib/delivery/src/UserProfile.ts +++ b/packages/lib/delivery/src/UserProfile.ts @@ -6,7 +6,7 @@ import { } from '@dm3-org/dm3-lib-profile'; import { logDebug } from '@dm3-org/dm3-lib-shared'; import { ethers } from 'ethers'; -import { generateAuthJWT } from './Keys'; +import { generateAuthJWT } from '@dm3-org/dm3-lib-server-side'; import { Session } from './Session'; export async function submitUserProfile( @@ -40,6 +40,7 @@ export async function submitUserProfile( return session.token; } +// todo: remove this function (profiles should be loaded from chain and possibly cached) export async function getUserProfile( getAccount: (accountAddress: string) => Promise, ensName: string, diff --git a/packages/lib/delivery/src/index.ts b/packages/lib/delivery/src/index.ts index 3477f82c7..fd5e683f3 100644 --- a/packages/lib/delivery/src/index.ts +++ b/packages/lib/delivery/src/index.ts @@ -1,8 +1,3 @@ -export { - createChallenge, - createNewSessionToken, - generateAuthJWT, -} from './Keys'; export { submitUserProfile, getUserProfile } from './UserProfile'; export { addPostmark, @@ -14,7 +9,6 @@ export { getConversationId } from './Messages'; export type {} from './PublicMessages'; export * as schema from './schema'; export * as spamFilter from './spam-filter/'; -export { checkToken } from './Session'; export type { Session } from './Session'; export type { DeliveryServiceProperties } from './Delivery'; export * from './notifications'; diff --git a/packages/lib/server-side/package.json b/packages/lib/server-side/package.json index 8895ccd53..4b360270b 100644 --- a/packages/lib/server-side/package.json +++ b/packages/lib/server-side/package.json @@ -12,7 +12,6 @@ } }, "dependencies": { - "@dm3-org/dm3-lib-delivery": "workspace:^", "@dm3-org/dm3-lib-profile": "workspace:^", "@dm3-org/dm3-lib-shared": "workspace:^", "express": "^4.18.1" diff --git a/packages/lib/delivery/src/Keys.test.ts b/packages/lib/server-side/src/Keys.test.ts similarity index 60% rename from packages/lib/delivery/src/Keys.test.ts rename to packages/lib/server-side/src/Keys.test.ts index f52d4331e..812416481 100644 --- a/packages/lib/delivery/src/Keys.test.ts +++ b/packages/lib/server-side/src/Keys.test.ts @@ -1,8 +1,10 @@ import { sign } from '@dm3-org/dm3-lib-crypto'; -import { createChallenge, createNewSessionToken } from './Keys'; -import * as spamFilter from './spam-filter/SpamFilterRules'; -import { Session } from './Session'; +import { stringify } from '@dm3-org/dm3-lib-shared'; +import { mockUserProfile } from '@dm3-org/dm3-lib-test-helper'; +import { ethers } from 'ethers'; import { JsonWebTokenError } from 'jsonwebtoken'; +import { IAccountDatabase } from './iAccountDatabase'; +import { createChallenge, createNewSessionToken } from './Keys'; const RANDO_ADDRESS = '0xDd36ae7F9a8E34FACf1e110c6e9d37D0dc917855'; const SENDER_ADDRESS = '0x71CB05EE1b1F506fF321Da3dac38f25c0c9ce6E1'; @@ -22,40 +24,74 @@ const keysA = { storageEncryptionNonce: 0, }; +const mockDbWithAccount: IAccountDatabase = { + hasAccount: async (ensName: string) => Promise.resolve(true), +}; + +const mockDbWithOUTAccount: IAccountDatabase = { + hasAccount: async (ensName: string) => Promise.resolve(false), +}; + describe('Keys', () => { - describe('CreateChallenge', () => { - it('Throws Exception if Session was not found', async () => { - const getAccount = () => Promise.resolve(null); - const setAccount = () => Promise.resolve(); + let user: any; + let expectedUserProfile: any; + let userAddress: string; + let mockWeb3Provider: ethers.providers.StaticJsonRpcProvider; + + beforeAll(async () => { + user = await mockUserProfile( + ethers.Wallet.createRandom(), + 'alice.eth', + ['ds1.eth', 'ds2.eth'], + ); + expectedUserProfile = user.signedUserProfile; + userAddress = user.wallet.address; + + const mockGetEnsResolver = (_: string) => + Promise.resolve({ + getText: (_: string) => + Promise.resolve( + 'data:application/json,' + + stringify(expectedUserProfile), + ), + }); + + mockWeb3Provider = { + getResolver: mockGetEnsResolver, + resolveName: async () => userAddress, + } as unknown as ethers.providers.StaticJsonRpcProvider; + }); + describe('CreateChallenge', () => { + it('Throws Exception if Account was not found', async () => { await expect(async () => { - await createChallenge(getAccount, RANDO_ADDRESS, SERVER_SECRET); - }).rejects.toEqual(Error('Session not found')); + await createChallenge( + mockDbWithOUTAccount, + RANDO_ADDRESS, + SERVER_SECRET, + ); + }).rejects.toEqual(Error("User account doesn't exist")); }); it('Ignores challenge field in database', async () => { - const getAccount = () => - Promise.resolve({ challenge: 'foo' } as Session); - const challenge = await createChallenge( - getAccount, + mockDbWithAccount, RANDO_ADDRESS, SERVER_SECRET, ); expect(challenge).not.toBe('foo'); }); - it('Creates a new challenge even if called multiple times', async () => { - const getAccount = () => Promise.resolve({} as Session); + it('Creates a new challenge even if called multiple times', async () => { const challenge1 = await createChallenge( - getAccount, + mockDbWithAccount, RANDO_ADDRESS, SERVER_SECRET, ); const challenge2 = await createChallenge( - getAccount, + mockDbWithAccount, RANDO_ADDRESS, SERVER_SECRET, ); @@ -65,100 +101,65 @@ describe('Keys', () => { expect(challenge1).not.toBe(challenge2); }); }); + describe('CreateNewSessionToken', () => { - it('Throws Exception if Session was not found', async () => { + it('Throws Exception if Account was not found', async () => { const getAccount = () => Promise.resolve(null); await expect(async () => { await createNewSessionToken( - getAccount, + mockDbWithOUTAccount, 'signature', 'challenge', RANDO_ADDRESS, SERVER_SECRET, + mockWeb3Provider, ); - }).rejects.toEqual(Error('Session not found')); + }).rejects.toEqual(Error("User account doesn't exist")); }); - it('Throws Exception if the challenge is not valid', async () => { - const getAccount = () => Promise.resolve({} as Session); + it('Throws Exception if the challenge is not valid', async () => { await expect(async () => { await createNewSessionToken( - getAccount, + mockDbWithAccount, 'signature', 'challenge', RANDO_ADDRESS, SERVER_SECRET, + mockWeb3Provider, ); }).rejects.toEqual(new JsonWebTokenError('jwt malformed')); }); it('Returns token if challenge was correct', async () => { - const sessionMocked = { - challenge: '123', - token: 'deprecated token that is not used anymore', - signedUserProfile: { - profile: { - publicSigningKey: keysA.signingKeyPair.publicKey, - }, - }, - } as Session & { - spamFilterRules: spamFilter.SpamFilterRules; - }; - - const getAccount = async (ensName: string) => - Promise.resolve< - Session & { - spamFilterRules: spamFilter.SpamFilterRules; - } - >(sessionMocked); - // create valid challenge jwt const challenge = await createChallenge( - getAccount, + mockDbWithAccount, SENDER_ADDRESS, SERVER_SECRET, ); const signature = await sign( - keysA.signingKeyPair.privateKey, + user.profileKeys.signingKeyPair.privateKey, challenge, ); const token = await createNewSessionToken( - getAccount, + mockDbWithAccount, signature, challenge, SENDER_ADDRESS, SERVER_SECRET, + mockWeb3Provider, ); expect(token).not.toBeUndefined(); }); it('Throws Exception if challange was solved wrong', async () => { - const sessionMocked = { - challenge: '123', - token: 'deprecated token that is not used anymore', - signedUserProfile: { - profile: { - publicSigningKey: keysA.signingKeyPair.publicKey, - }, - }, - } as Session & { - spamFilterRules: spamFilter.SpamFilterRules; - }; - - const getAccount = async (ensName: string) => - Promise.resolve< - Session & { - spamFilterRules: spamFilter.SpamFilterRules; - } - >(sessionMocked); - // create valid challenge jwt const challenge = await createChallenge( - getAccount, + mockDbWithAccount, SENDER_ADDRESS, SERVER_SECRET, ); @@ -167,11 +168,12 @@ describe('Keys', () => { await expect(async () => { await createNewSessionToken( - getAccount, + mockDbWithAccount, signature, challenge, SENDER_ADDRESS, SERVER_SECRET, + mockWeb3Provider, ); }).rejects.toEqual(TypeError('invalid signature length')); }); diff --git a/packages/lib/delivery/src/Keys.ts b/packages/lib/server-side/src/Keys.ts similarity index 68% rename from packages/lib/delivery/src/Keys.ts rename to packages/lib/server-side/src/Keys.ts index 61aa32461..8a6c16af2 100644 --- a/packages/lib/delivery/src/Keys.ts +++ b/packages/lib/server-side/src/Keys.ts @@ -1,9 +1,10 @@ -import { v4 as uuidv4 } from 'uuid'; -import { Session } from './Session'; import { checkSignature } from '@dm3-org/dm3-lib-crypto'; -import { normalizeEnsName } from '@dm3-org/dm3-lib-profile'; +import { getUserProfile, normalizeEnsName } from '@dm3-org/dm3-lib-profile'; import { validateSchema } from '@dm3-org/dm3-lib-shared'; +import { ethers } from 'ethers'; import { sign, verify } from 'jsonwebtoken'; +import { v4 as uuidv4 } from 'uuid'; +import { IAccountDatabase } from './iAccountDatabase'; const challengeJwtPayloadSchema = { type: 'object', @@ -19,15 +20,14 @@ const challengeJwtPayloadSchema = { }; export async function createChallenge( - getAccount: (accountAddress: string) => Promise, + db: IAccountDatabase, ensName: string, serverSecret: string, ) { - const account = normalizeEnsName(ensName); - const session = await getAccount(account); + const accountName = normalizeEnsName(ensName); - if (!session) { - throw Error('Session not found'); + if (!(await db.hasAccount(accountName))) { + throw Error("User account doesn't exist"); } // generates a jwt with a new, unique challenge @@ -65,17 +65,31 @@ export function generateAuthJWT( }); } +/** + * Creates a new session token for the given ensName, after checking: + * - if the account exists + * - if the challenge is valid + * - if the signature is valid + * - if the user profile exists + * @param hasAccount function that checks if the account exists + * @param signature signature of the challenge + * @param challenge challenge that was created before + * @param ensName ens id of the account + * @param serverSecret secret value that is used to sign the JWT + * @returns JWT + */ export async function createNewSessionToken( - getAccount: (ensName: string) => Promise, + db: IAccountDatabase, signature: string, challenge: string, ensName: string, serverSecret: string, + web3Provider: ethers.providers.JsonRpcProvider, ): Promise { - const session = await getAccount(ensName); + const accountName = normalizeEnsName(ensName); - if (!session) { - throw Error('Session not found'); + if (!(await db.hasAccount(accountName))) { + throw Error("User account doesn't exist"); } // check the challenge jwt the user provided. It must be a valid @@ -92,10 +106,15 @@ export async function createNewSessionToken( throw Error('Provided challenge is not valid'); } + const signedProfile = await getUserProfile(web3Provider, ensName); + + if (!signedProfile) { + throw Error("User profile doesn't exist"); + } + if ( - // todo: get public signing key from public profile !(await checkSignature( - session.signedUserProfile.profile.publicSigningKey, + signedProfile.profile.publicSigningKey, // we expect the whole jwt to be signed, not just the challenge-part of the payload. // This way, the client does not have to understand the jwt. challenge, diff --git a/packages/lib/server-side/src/auth.test.ts b/packages/lib/server-side/src/auth.test.ts index 636d314a4..b0e29db8e 100644 --- a/packages/lib/server-side/src/auth.test.ts +++ b/packages/lib/server-side/src/auth.test.ts @@ -1,51 +1,77 @@ -import { sign } from '@dm3-org/dm3-lib-crypto'; -import { - Session, - createChallenge, - spamFilter, -} from '@dm3-org/dm3-lib-delivery'; -import { SignedUserProfile } from '@dm3-org/dm3-lib-profile'; +import { stringify } from '@dm3-org/dm3-lib-shared'; +import { mockUserProfile } from '@dm3-org/dm3-lib-test-helper'; import bodyParser from 'body-parser'; import { ethers } from 'ethers'; import express from 'express'; import { verify } from 'jsonwebtoken'; import request from 'supertest'; import { Auth } from './auth'; -import { IAccountDatabase } from './iSessionDatabase'; +import { IAccountDatabase } from './iAccountDatabase'; +import { createChallenge } from './Keys'; +import { sign } from '@dm3-org/dm3-lib-crypto'; const serverSecret = 'testSecret'; +const mockDbWithAccount: IAccountDatabase = { + hasAccount: async (ensName: string) => Promise.resolve(true), +}; + +const mockDbWithOUTAccount: IAccountDatabase = { + hasAccount: async (ensName: string) => Promise.resolve(false), +}; + +const keysA = { + encryptionKeyPair: { + publicKey: 'eHmMq29FeiPKfNPkSctPuZGXvV0sKeO/KZkX2nXvMgw=', + privateKey: 'pMI77F2w3GK+omZCB4A61WDqISOOnWGXR2f/MTLbqbY=', + }, + signingKeyPair: { + publicKey: '+tkDQWZfv9ixBmObsf8tgTHTZajwAE9muTtFAUj2e9I=', + privateKey: + '+DpeBjCzICFoi743/466yJunsHR55Bhr3GnqcS4cuJX62QNBZl+/2LEGY5ux/y2BMdNlqPAAT2a5O0UBSPZ70g==', + }, + storageEncryptionKey: '+DpeBjCzICFoi743/466yJunsHR55Bhr3GnqcS4cuJU=', + storageEncryptionNonce: 0, +}; + describe('Auth', () => { - const getAccountMock = async (ensName: string) => - Promise.resolve({ challenge: '123' }); - const setAccountMock = async (_: string, __: any) => { - return (_: any, __: any, ___: any) => {}; - }; - - const keysA = { - encryptionKeyPair: { - publicKey: 'eHmMq29FeiPKfNPkSctPuZGXvV0sKeO/KZkX2nXvMgw=', - privateKey: 'pMI77F2w3GK+omZCB4A61WDqISOOnWGXR2f/MTLbqbY=', - }, - signingKeyPair: { - publicKey: '+tkDQWZfv9ixBmObsf8tgTHTZajwAE9muTtFAUj2e9I=', - privateKey: - '+DpeBjCzICFoi743/466yJunsHR55Bhr3GnqcS4cuJX62QNBZl+/2LEGY5ux/y2BMdNlqPAAT2a5O0UBSPZ70g==', - }, - storageEncryptionKey: '+DpeBjCzICFoi743/466yJunsHR55Bhr3GnqcS4cuJU=', - storageEncryptionNonce: 0, - }; + let user: any; + let expectedUserProfile: any; + let userAddress: string; + let mockWeb3Provider: ethers.providers.StaticJsonRpcProvider; + + beforeAll(async () => { + user = await mockUserProfile( + ethers.Wallet.createRandom(), + 'alice.eth', + ['ds1.eth', 'ds2.eth'], + ); + expectedUserProfile = user.signedUserProfile; + userAddress = user.wallet.address; + + const mockGetEnsResolver = (_: string) => + Promise.resolve({ + getText: (_: string) => + Promise.resolve( + 'data:application/json,' + + stringify(expectedUserProfile), + ), + }); + + mockWeb3Provider = { + getResolver: mockGetEnsResolver, + resolveName: async () => userAddress, + } as unknown as ethers.providers.StaticJsonRpcProvider; + }); describe('getChallenge', () => { describe('schema', () => { it('Returns 200 and a jwt if schema is valid', async () => { - const db = { - getAccount: getAccountMock, - } as IAccountDatabase; - const app = express(); app.use(bodyParser.json()); - app.use(Auth(db, serverSecret)); + app.use( + Auth(mockDbWithAccount, serverSecret, mockWeb3Provider), + ); const response = await request(app) .get( @@ -112,118 +138,101 @@ describe('Auth', () => { describe('createNewSessionToken', () => { describe('schema', () => { - it('Returns 400 if params is invalid', async () => { + it('Returns 400 if signature is invalid', async () => { const app = express(); - const db = { - getAccount: getAccountMock, - } as IAccountDatabase; app.use(bodyParser.json()); - app.use(Auth(db, serverSecret)); - - const mnemonic = - 'announce room limb pattern dry unit scale effort smooth jazz weasel alcohol'; - - const wallet = ethers.Wallet.fromMnemonic(mnemonic); - - const sessionMocked = { - challenge: '123', - token: 'deprecated token that is not used anymore', - signedUserProfile: {}, - } as Session & { - spamFilterRules: spamFilter.SpamFilterRules; - }; + app.use( + Auth(mockDbWithAccount, serverSecret, mockWeb3Provider), + ); // create the challenge jwt - const challengeJwt = createChallenge( - async (ensName: string) => - Promise.resolve< - Session & { - spamFilterRules: spamFilter.SpamFilterRules; - } - >(sessionMocked), - '1234', + const challengeJwt = await createChallenge( + mockDbWithAccount, + 'bob.eth', serverSecret, ); - const signature = await wallet.signMessage('123'); + // signing with this keyA, but the server checks signature against user.profileKeys.signingKeyPair.privateKey, + const signature = await sign( + keysA.signingKeyPair.privateKey, + challengeJwt, + ); const { status } = await request(app).post(`/1234`).send({ - signature: 123, + signature, challenge: challengeJwt, }); expect(status).toBe(400); }); - it('Returns 400 if body is invalid', async () => { + + it('Returns 400 if params is invalid', async () => { const app = express(); - const db = { - getAccount: getAccountMock, - } as IAccountDatabase; app.use(bodyParser.json()); - app.use(Auth(db, serverSecret)); + app.use( + Auth(mockDbWithAccount, serverSecret, mockWeb3Provider), + ); + + // create the challenge jwt + const challengeJwt = await createChallenge( + mockDbWithAccount, + 'bob.eth', + serverSecret, + ); - const mnemonic = - 'announce room limb pattern dry unit scale effort smooth jazz weasel alcohol'; + const signature = await sign( + user.profileKeys.signingKeyPair.privateKey, + challengeJwt, + ); - const wallet = ethers.Wallet.fromMnemonic(mnemonic); + const { status } = await request(app).post(`/1234`).send({ + // we do not provide a signature + challenge: challengeJwt, + }); - const foo = await wallet.signMessage('123'); + expect(status).toBe(400); - const { status } = await request(app) - .post(`/${wallet.address}`) + const { status: status2 } = await request(app) + .post(`/1234`) .send({ - foo, + signature, + // we do not provide a challenge }); + expect(status2).toBe(400); + }); + it('Returns 400 if body is invalid', async () => { + const app = express(); + + app.use(bodyParser.json()); + app.use( + Auth(mockDbWithAccount, serverSecret, mockWeb3Provider), + ); + + const { status } = await request(app).post(`/somename`).send({ + foo: 'some content', + }); expect(status).toBe(400); }); - it('Returns 200 if schema is valid', async () => { - const sessionMocked = { - challenge: 'deprecated challenge that is not used anymore', - token: 'deprecated token that is not used anymore', - signedUserProfile: { - profile: { - publicSigningKey: keysA.signingKeyPair.publicKey, - }, - } as SignedUserProfile, - } as Session & { - spamFilterRules: spamFilter.SpamFilterRules; - }; - - const getAccountMockLocal = async (ensName: string) => - Promise.resolve< - Session & { - spamFilterRules: spamFilter.SpamFilterRules; - } - >(sessionMocked); - // async (ensName: string) => ({ - // challenge: 'my-Challenge', - // signedUserProfile: { - // profile: { - // publicSigningKey: keysA.signingKeyPair.publicKey, - // }, - // }, - // }); - + it('Returns 200 if schema and content is valid', async () => { const app = express(); - const db = { - getAccount: getAccountMockLocal, - } as IAccountDatabase; app.use(bodyParser.json()); - app.use(Auth(db, serverSecret)); + app.use( + Auth(mockDbWithAccount, serverSecret, mockWeb3Provider), + ); // create the challenge jwt const challengeJwt = await createChallenge( - getAccountMockLocal, + mockDbWithAccount, '0x71CB05EE1b1F506fF321Da3dac38f25c0c9ce6E1', serverSecret, ); const signature = await sign( - keysA.signingKeyPair.privateKey, + user.profileKeys.signingKeyPair.privateKey, challengeJwt, ); diff --git a/packages/lib/server-side/src/auth.ts b/packages/lib/server-side/src/auth.ts index c38d3f137..0901ec384 100644 --- a/packages/lib/server-side/src/auth.ts +++ b/packages/lib/server-side/src/auth.ts @@ -1,14 +1,10 @@ -import { - Session, - createChallenge, - createNewSessionToken, -} from '@dm3-org/dm3-lib-delivery'; import { normalizeEnsName } from '@dm3-org/dm3-lib-profile'; import { validateSchema } from '@dm3-org/dm3-lib-shared'; import cors from 'cors'; -import express from 'express'; import { ethers } from 'ethers'; -import { IAccountDatabase } from './iSessionDatabase'; +import express from 'express'; +import { IAccountDatabase } from './iAccountDatabase'; +import { createChallenge, createNewSessionToken } from './Keys'; const getChallengeSchema = { type: 'object', @@ -38,7 +34,11 @@ const createNewSessionTokenBodySchema = { additionalProperties: false, }; -export const Auth = (db: IAccountDatabase, serverSecret: string) => { +export const Auth = ( + db: IAccountDatabase, + serverSecret: string, + web3Provider: ethers.providers.JsonRpcProvider, +) => { const router = express.Router(); //TODO remove @@ -58,7 +58,7 @@ export const Auth = (db: IAccountDatabase, serverSecret: string) => { } const challenge = await createChallenge( - db.getAccount, + db, idEnsName, serverSecret, ); @@ -89,11 +89,12 @@ export const Auth = (db: IAccountDatabase, serverSecret: string) => { } const jwt = await createNewSessionToken( - db.getAccount, + db, req.body.signature, req.body.challenge, idEnsName, serverSecret, + web3Provider, ); res.json(jwt); diff --git a/packages/lib/server-side/src/utils.test.ts b/packages/lib/server-side/src/authorize.test.ts similarity index 80% rename from packages/lib/server-side/src/utils.test.ts rename to packages/lib/server-side/src/authorize.test.ts index 52d923be1..e1e0f9d66 100644 --- a/packages/lib/server-side/src/utils.test.ts +++ b/packages/lib/server-side/src/authorize.test.ts @@ -3,7 +3,7 @@ import express, { NextFunction, Request, Response } from 'express'; import { sign, verify } from 'jsonwebtoken'; import request from 'supertest'; import winston from 'winston'; -import { auth } from './utils'; +import { authorize } from './authorize'; const serverSecret = 'testSecret'; winston.loggers.add('default', { @@ -18,20 +18,7 @@ describe('Utils', () => { notBefore: 0, }); - const getAccount = async (accountAddress: string) => - Promise.resolve({ - signedUserProfile: {}, - token: 'testToken', - createdAt: new Date().getTime(), - }); - const setAccount = async (_: string, __: any) => { - return (_: any, __: any, ___: any) => {}; - }; - - const db = { - getAccount, - setAccount, - }; + const hasAccount = (_: string) => Promise.resolve(true); const web3Provider = { resolveName: async () => @@ -50,12 +37,12 @@ describe('Utils', () => { next: NextFunction, ensName: string, ) => { - auth( + authorize( req, res, next, ensName, - db as any, + hasAccount, web3Provider as any, serverSecret, ); @@ -80,20 +67,7 @@ describe('Utils', () => { notBefore: 0, }); - const getAccount = async (accountAddress: string) => - Promise.resolve({ - signedUserProfile: {}, - token: 'testToken', - createdAt: new Date().getTime(), - }); - const setAccount = async (_: string, __: any) => { - return (_: any, __: any, ___: any) => {}; - }; - - const db = { - getAccount, - setAccount, - }; + const hasAccount = (_: string) => Promise.resolve(true); const web3Provider = { resolveName: async () => @@ -112,12 +86,12 @@ describe('Utils', () => { next: NextFunction, ensName: string, ) => { - auth( + authorize( req, res, next, ensName, - db as any, + hasAccount, web3Provider as any, serverSecret, ); @@ -140,13 +114,8 @@ describe('Utils', () => { const token = sign({ account: 'alice.eth' }, serverSecret, { expiresIn: '1h', }); - const db = { - getAccount: async (accountAddress: string) => - Promise.resolve(null), - setAccount: async (_: string, __: any) => { - return (_: any, __: any, ___: any) => {}; - }, - }; + + const hasAccount = (_: string) => Promise.resolve(false); const web3Provider = { resolveName: async () => @@ -165,12 +134,12 @@ describe('Utils', () => { next: NextFunction, ensName: string, ) => { - auth( + authorize( req, res, next, ensName, - db as any, + hasAccount, web3Provider as any, serverSecret, ); @@ -203,16 +172,7 @@ describe('Utils', () => { const token = sign({ account: 'some.other.name' }, serverSecret, { expiresIn: '1h', }); - const db = { - getAccount: async (accountAddress: string) => - Promise.resolve({ - signedUserProfile: {}, - token: 'foo', - }), - setAccount: async (_: string, __: any) => { - return (_: any, __: any, ___: any) => {}; - }, - }; + const hasAccount = (_: string) => Promise.resolve(true); const web3Provider = { resolveName: async () => @@ -231,12 +191,12 @@ describe('Utils', () => { next: NextFunction, ensName: string, ) => { - auth( + authorize( req, res, next, ensName, - db as any, + hasAccount, web3Provider as any, serverSecret, ); @@ -281,17 +241,7 @@ describe('Utils', () => { serverSecret, ); - const db = { - getAccount: async (accountAddress: string) => - Promise.resolve({ - signedUserProfile: {}, - token: 'foo', - createdAt: 1, - }), - setAccount: async (_: string, __: any) => { - return (_: any, __: any, ___: any) => {}; - }, - }; + const hasAccount = (_: string) => Promise.resolve(true); const web3Provider = { resolveName: async () => @@ -310,12 +260,12 @@ describe('Utils', () => { next: NextFunction, ensName: string, ) => { - auth( + authorize( req, res, next, ensName, - db as any, + hasAccount, web3Provider as any, serverSecret, ); @@ -380,17 +330,7 @@ describe('Utils', () => { }, serverSecret, ); - const db = { - getAccount: async (accountAddress: string) => - Promise.resolve({ - signedUserProfile: {}, - token: 'foo', - createdAt: 1, - }), - setAccount: async (_: string, __: any) => { - return (_: any, __: any, ___: any) => {}; - }, - }; + const hasAccount = (_: string) => Promise.resolve(true); const web3Provider = { resolveName: async () => @@ -409,12 +349,12 @@ describe('Utils', () => { next: NextFunction, ensName: string, ) => { - auth( + authorize( req, res, next, ensName, - db as any, + hasAccount, web3Provider as any, serverSecret, ); diff --git a/packages/lib/server-side/src/authorize.ts b/packages/lib/server-side/src/authorize.ts new file mode 100644 index 000000000..724459bf6 --- /dev/null +++ b/packages/lib/server-side/src/authorize.ts @@ -0,0 +1,96 @@ +import { normalizeEnsName } from '@dm3-org/dm3-lib-profile'; +import { validateSchema } from '@dm3-org/dm3-lib-shared'; +import ethers from 'ethers'; +import { NextFunction, Request, Response } from 'express'; +import { verify } from 'jsonwebtoken'; + +const authJwtPayloadSchema = { + type: 'object', + properties: { + account: { type: 'string' }, + iat: { type: 'number' }, + exp: { type: 'number' }, + nbf: { type: 'number' }, + }, + required: ['account', 'iat', 'exp', 'nbf'], + additionalProperties: false, +}; + +export async function authorize( + req: Request, + res: Response, + next: NextFunction, + ensName: string, + hasAccount: (ensName: string) => Promise, + web3Provider: ethers.providers.JsonRpcProvider, + serverSecret: string, +) { + const normalizedEnsName = normalizeEnsName(ensName); + const authHeader = req.headers['authorization']; + const token = authHeader && authHeader.split(' ')[1]; + if ( + token && + (await checkToken( + web3Provider, + hasAccount, + normalizedEnsName, + token, + serverSecret, + )) + ) { + next(); + } else { + console.warn('AUTH Token check failed for ', normalizedEnsName); + res.sendStatus(401); + } +} + +export async function checkToken( + provider: ethers.providers.JsonRpcProvider, + hasAccount: (ensName: string) => Promise, + ensName: string, + token: string, + serverSecret: string, +): Promise { + if (!(await hasAccount(ensName.toLocaleLowerCase()))) { + console.debug('there is no account for this ens name: ', ensName); + return false; + } + + console.debug('checkToken - ensName', ensName); + + // check jwt for validity + try { + // will throw if signature is invalid or exp is in the past + const jwtPayload = verify(token, serverSecret, { + algorithms: ['HS256'], + }); + + console.debug('checkToken - jwtPayload', jwtPayload); + + // check if payload is well formed + if ( + typeof jwtPayload === 'string' || + !validateSchema(authJwtPayloadSchema, jwtPayload) + ) { + console.debug('jwt malformed'); + return false; + } + + if (!jwtPayload.iat || jwtPayload.iat > Date.now() / 1000) { + console.debug('jwt invalid: iat missing or in the future'); + return false; + } + + if (jwtPayload.account !== ensName) { + console.debug('jwt invalid: account mismatch'); + return false; + } + } catch (error) { + console.debug(`jwt invalid: ${error}`); + return false; + } + + // the token is valid only if all checks passed + return true; +} diff --git a/packages/lib/server-side/src/iAccountDatabase.ts b/packages/lib/server-side/src/iAccountDatabase.ts new file mode 100644 index 000000000..0184e106a --- /dev/null +++ b/packages/lib/server-side/src/iAccountDatabase.ts @@ -0,0 +1,3 @@ +export interface IAccountDatabase { + hasAccount: (ensName: string) => Promise; +} diff --git a/packages/lib/server-side/src/iSessionDatabase.ts b/packages/lib/server-side/src/iSessionDatabase.ts deleted file mode 100644 index 723dc6591..000000000 --- a/packages/lib/server-side/src/iSessionDatabase.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { Session as DSSession, spamFilter } from '@dm3-org/dm3-lib-delivery'; - -export interface IAccountDatabase { - setAccount: (ensName: string, session: DSSession) => Promise; - getAccount: (ensName: string) => Promise; -} diff --git a/packages/lib/server-side/src/index.ts b/packages/lib/server-side/src/index.ts index bc605e2c5..2106f0f28 100644 --- a/packages/lib/server-side/src/index.ts +++ b/packages/lib/server-side/src/index.ts @@ -1,5 +1,6 @@ export { Auth } from './auth'; -export * from './utils'; +export * from './authorize'; export { getCachedWebProvider } from './web3Provider/getCachedWebProvider'; -export type { IAccountDatabase } from './iSessionDatabase'; -export { Profile } from './profile'; +export type { IAccountDatabase } from './iAccountDatabase'; +export * from './utils'; +export * from './Keys'; diff --git a/packages/lib/server-side/src/utils.ts b/packages/lib/server-side/src/utils.ts index 904de2ef3..5af754431 100644 --- a/packages/lib/server-side/src/utils.ts +++ b/packages/lib/server-side/src/utils.ts @@ -1,93 +1,8 @@ /* eslint-disable no-console */ -import { checkToken } from '@dm3-org/dm3-lib-delivery'; -import { - DeliveryServiceProfileKeys, - normalizeEnsName, -} from '@dm3-org/dm3-lib-profile'; +import { DeliveryServiceProfileKeys } from '@dm3-org/dm3-lib-profile'; import { ethers } from 'ethers'; import { NextFunction, Request, Response } from 'express'; -import { Socket } from 'socket.io'; -import { ExtendedError } from 'socket.io/dist/namespace'; import winston from 'winston'; -import type { IAccountDatabase } from './iSessionDatabase'; - -export async function auth( - req: Request, - res: Response, - next: NextFunction, - ensName: string, - db: IAccountDatabase, - web3Provider: ethers.providers.JsonRpcProvider, - serverSecret: string, -) { - const normalizedEnsName = normalizeEnsName(ensName); - const authHeader = req.headers['authorization']; - const token = authHeader && authHeader.split(' ')[1]; - //TODO resolve addr for ens name - if ( - token && - (await checkToken( - web3Provider, - db.getAccount, - normalizedEnsName, - token, - serverSecret, - )) - ) { - next(); - } else { - console.warn('AUTH Token check failed for ', normalizedEnsName); - res.sendStatus(401); - } -} - -export function socketAuth( - db: IAccountDatabase, - web3Provider: ethers.providers.JsonRpcProvider, - serverSecret: string, -) { - return async ( - socket: Socket, - next: (err?: ExtendedError | undefined) => void, - ) => { - try { - const ensName = normalizeEnsName( - socket.handshake.auth.account.ensName, - ); - console.log('Start WS auth for ', ensName, socket.id); - - if ( - !(await checkToken( - web3Provider, - db.getAccount, - ensName, - socket.handshake.auth.token as string, - serverSecret, - )) - ) { - console.log('check token has failed for WS '); - return next(new Error('check token has failed for WS')); - } - const session = await db.getAccount(ensName); - if (!session) { - throw Error('Could not get session'); - } - //we use session.account here as a key for setAccount here. - //We can do this because the address is used as account when the Session has been created. - //That saves a address lookup via ENS - await db.setAccount(session.account, { - ...session, - socketId: socket.id, - }); - } catch (e) { - console.log('socket auth error'); - console.log(e); - next(e as Error); - } - - next(); - }; -} export function logRequest(req: Request, res: Response, next: NextFunction) { winston.loggers.get('default').info({ diff --git a/yarn.lock b/yarn.lock index 87e02a356..dd42d23fa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2179,6 +2179,7 @@ __metadata: "@dm3-org/dm3-lib-crypto": "workspace:^" "@dm3-org/dm3-lib-messaging": "workspace:^" "@dm3-org/dm3-lib-profile": "workspace:^" + "@dm3-org/dm3-lib-server-side": "workspace:^" "@dm3-org/dm3-lib-shared": "workspace:^" "@types/data-urls": ^3.0.1 "@types/jest": ^28.1.1 @@ -2262,7 +2263,6 @@ __metadata: version: 0.0.0-use.local resolution: "@dm3-org/dm3-lib-server-side@workspace:packages/lib/server-side" dependencies: - "@dm3-org/dm3-lib-delivery": "workspace:^" "@dm3-org/dm3-lib-profile": "workspace:^" "@dm3-org/dm3-lib-shared": "workspace:^" express: ^4.18.1