diff --git a/src/app.module.ts b/src/app.module.ts index 650a598..15c54f0 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -12,7 +12,6 @@ import { ApiModule } from './services/api/api.module'; import { AuthModule } from './services/auth/auth.module'; import { HiveChainModule } from './repositories/hive-chain/hive-chain.module'; import { HiveAccountModule } from './repositories/hive-account/hive-account.module'; -import { LinkedAccountModule } from './repositories/linked-accounts/linked-account.module'; import { SessionModule } from './repositories/session/session.module'; import { UploadModule } from './repositories/upload/upload.module'; import { UserAccountModule } from './repositories/userAccount/user-account.module'; @@ -64,7 +63,6 @@ const mongoUrl = process.env.CORE_MONGODB_URL || 'mongodb://mongo:27017'; IpfsModule, UploadingModule, HiveAccountModule, - LinkedAccountModule, SessionModule, UploadModule, EmailModule, diff --git a/src/repositories/hive-account/hive-account.module.ts b/src/repositories/hive-account/hive-account.module.ts index 098b102..ba8a5bd 100644 --- a/src/repositories/hive-account/hive-account.module.ts +++ b/src/repositories/hive-account/hive-account.module.ts @@ -1,17 +1,17 @@ import { Module } from '@nestjs/common'; import { MongooseModule } from '@nestjs/mongoose'; -import { HiveAccount, HiveAccountSchema } from './schemas/hive-account.schema'; -import { HiveAccountRepository } from './hive-account.repository'; +import { LegacyHiveAccountSchema } from './schemas/hive-account.schema'; +import { LegacyHiveAccountRepository } from './hive-account.repository'; @Module({ imports: [ MongooseModule.forFeature( - [{ name: HiveAccount.name, schema: HiveAccountSchema }], + [{ name: 'hiveaccounts', schema: LegacyHiveAccountSchema }], 'threespeak', ), ], controllers: [], - providers: [HiveAccountRepository], - exports: [HiveAccountRepository], + providers: [LegacyHiveAccountRepository], + exports: [LegacyHiveAccountRepository], }) export class HiveAccountModule {} diff --git a/src/repositories/hive-account/hive-account.repository.test.ts b/src/repositories/hive-account/hive-account.repository.test.ts new file mode 100644 index 0000000..b9d81b7 --- /dev/null +++ b/src/repositories/hive-account/hive-account.repository.test.ts @@ -0,0 +1,72 @@ +import 'dotenv/config' +import { Test } from '@nestjs/testing'; +import { MongooseModule } from '@nestjs/mongoose'; +import { UserModule } from '../../repositories/user/user.module'; +import { MongoMemoryServer } from 'mongodb-memory-server'; +import { INestApplication, Module } from '@nestjs/common'; +import { TestingModule } from '@nestjs/testing'; +import crypto from 'crypto'; +import { LegacyUserSchema } from '../user/schemas/user.schema'; +import { HiveAccountModule } from './hive-account.module'; +import { LegacyHiveAccountRepository } from './hive-account.repository'; +import { LegacyHiveAccountSchema } from './schemas/hive-account.schema'; +import { LegacyUserRepository } from '../user/user.repository'; + +describe('Legacy hive account', () => { + let app: INestApplication + let mongod: MongoMemoryServer; + let hiveAccountRepository: LegacyHiveAccountRepository; + let legacyUserRepository: LegacyUserRepository; + + beforeEach(async () => { + mongod = await MongoMemoryServer.create() + const uri: string = mongod.getUri() + + process.env.JWT_PRIVATE_KEY = crypto.randomBytes(64).toString('hex'); + process.env.DELEGATED_ACCOUNT = 'threespeak'; + process.env.ACCOUNT_CREATOR = 'threespeak'; + + @Module({ + imports: [ + MongooseModule.forRoot(uri, { + ssl: false, + authSource: 'threespeak', + readPreference: 'primary', + connectionName: 'threespeak', + dbName: 'threespeak', + autoIndex: true, + }), + MongooseModule.forFeature([{ name: 'hiveaccounts', schema: LegacyHiveAccountSchema }], 'threespeak'), + MongooseModule.forFeature([{ name: 'users', schema: LegacyUserSchema }], 'threespeak'), + HiveAccountModule, + UserModule + ], + controllers: [], + providers: [LegacyHiveAccountRepository, LegacyUserRepository] + }) + class TestModule {} + + let moduleRef: TestingModule; + + moduleRef = await Test.createTestingModule({ + imports: [TestModule], + }).compile() + hiveAccountRepository = moduleRef.get(LegacyHiveAccountRepository); + legacyUserRepository = moduleRef.get(LegacyUserRepository); + app = moduleRef.createNestApplication(); + await app.init() + }) + + afterEach(async () => { + await app.close(); + await mongod.stop(); + }); + + describe('User repository', () => { + it(`Successfully creates a new user`, async () => { + await legacyUserRepository.createNewSubUser({ sub: 'singleton/did/check', user_id: 'example' }) + const result = await legacyUserRepository.findOneBySub('singleton/did/check') + expect(result).toBeTruthy() + }); + }) +}); \ No newline at end of file diff --git a/src/repositories/hive-account/hive-account.repository.ts b/src/repositories/hive-account/hive-account.repository.ts index 0b0e790..0bb5ef1 100644 --- a/src/repositories/hive-account/hive-account.repository.ts +++ b/src/repositories/hive-account/hive-account.repository.ts @@ -1,32 +1,29 @@ import { Injectable, Logger } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; import { Model } from 'mongoose'; -import { HiveAccount } from './schemas/hive-account.schema'; -import { ObjectId } from 'mongodb'; +import { LegacyHiveAccount } from './schemas/hive-account.schema'; @Injectable() -export class HiveAccountRepository { - readonly #logger = new Logger(HiveAccountRepository.name); +export class LegacyHiveAccountRepository { + readonly #logger = new Logger(LegacyHiveAccountRepository.name); constructor( - @InjectModel(HiveAccount.name, 'threespeak') private hiveAccountModel: Model, + @InjectModel('hiveaccounts', 'threespeak') private hiveAccountModel: Model, ) {} - async findOneByOwnerIdAndHiveAccountName({ - user_id, - account, - }: { - user_id: string | ObjectId; - account: string; - }): Promise { - const acelaUser = await this.hiveAccountModel.findOne({ user_id, account }); + async findOneByOwnerIdAndHiveAccountName( + query: Pick, + ): Promise { + const acelaUser = await this.hiveAccountModel.findOne(query); this.#logger.log(acelaUser); return acelaUser; } - async findOneByOwnerId({ user_id }: { user_id: string | ObjectId }): Promise { - const acelaUser = await this.hiveAccountModel.findOne({ user_id }); + async findOneByOwnerId( + query: Pick, + ): Promise { + const acelaUser = await this.hiveAccountModel.findOne(query); this.#logger.log(acelaUser); return acelaUser; @@ -45,10 +42,11 @@ export class HiveAccountRepository { }); } - async insertCreated(username: string, created_by: string) { - return await this.hiveAccountModel.create({ - account: username, - user_id: created_by, - }); + async deleteOne(query: Pick) { + return this.hiveAccountModel.deleteOne(query); + } + + async insertCreated(query: Pick) { + return await this.hiveAccountModel.create(query); } } diff --git a/src/repositories/hive-account/schemas/hive-account.schema.ts b/src/repositories/hive-account/schemas/hive-account.schema.ts index 0460f43..359241e 100644 --- a/src/repositories/hive-account/schemas/hive-account.schema.ts +++ b/src/repositories/hive-account/schemas/hive-account.schema.ts @@ -1,10 +1,10 @@ import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; import { Document, HydratedDocument, Types } from 'mongoose'; -export type HiveAccountDocument = HydratedDocument; +export type HiveAccountDocument = HydratedDocument; @Schema() -export class HiveAccount extends Document { +export class LegacyHiveAccount extends Document { @Prop({ type: String, required: true }) account: string; @@ -12,4 +12,4 @@ export class HiveAccount extends Document { user_id: Types.ObjectId; } -export const HiveAccountSchema = SchemaFactory.createForClass(HiveAccount); +export const LegacyHiveAccountSchema = SchemaFactory.createForClass(LegacyHiveAccount); diff --git a/src/repositories/linked-accounts/linked-account.module.ts b/src/repositories/linked-accounts/linked-account.module.ts deleted file mode 100644 index 52464f3..0000000 --- a/src/repositories/linked-accounts/linked-account.module.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Module } from '@nestjs/common'; -import { MongooseModule } from '@nestjs/mongoose'; -import { LinkedAccountRepository } from './linked-account.repository'; -import { LinkedAccount, LinkedAccountSchema } from './schemas/linked-account.schema'; - -@Module({ - imports: [ - MongooseModule.forFeature( - [{ name: LinkedAccount.name, schema: LinkedAccountSchema }], - 'acela-core', - ), - ], - controllers: [], - providers: [LinkedAccountRepository], - exports: [LinkedAccountRepository], -}) -export class LinkedAccountModule {} diff --git a/src/repositories/linked-accounts/linked-account.repository.ts b/src/repositories/linked-accounts/linked-account.repository.ts deleted file mode 100644 index 2c80886..0000000 --- a/src/repositories/linked-accounts/linked-account.repository.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { Model } from 'mongoose'; -import { Injectable } from '@nestjs/common'; -import { InjectModel } from '@nestjs/mongoose'; -import { LinkedAccount } from './schemas/linked-account.schema'; -import { ObjectId } from 'mongodb'; - -@Injectable() -export class LinkedAccountRepository { - constructor( - @InjectModel(LinkedAccount.name, 'acela-core') - private readonly linkedAccountModel: Model, - ) {} - - async linkHiveAccount(user_id: string, account: string) { - return await this.linkedAccountModel.create({ - user_id, - account, - status: 'verified', - network: 'HIVE', - } satisfies LinkedAccount); - } - - async unlinkHiveAccount(user_id: string, account: string) { - return await this.linkedAccountModel.deleteOne({ - user_id, - account, - } satisfies Partial); - } - - async findOneByUserIdAndAccountName(query: { - account: LinkedAccount['account']; - user_id: LinkedAccount['user_id']; - }) { - return await this.linkedAccountModel.findOne({ - ...query, - status: 'verified', - } satisfies Partial); - } - - async verify(_id: ObjectId) { - return this.linkedAccountModel.updateOne( - { - _id, - }, - { - $set: { - status: 'verified', - }, - }, - ); - } - - async findAllByUserId(user_id: string) { - return this.linkedAccountModel - .find({ user_id, status: 'verified' }, { account: 1, _id: 0 }) - .lean() - .exec(); - } -} diff --git a/src/repositories/linked-accounts/schemas/linked-account.schema.ts b/src/repositories/linked-accounts/schemas/linked-account.schema.ts deleted file mode 100644 index 128bfec..0000000 --- a/src/repositories/linked-accounts/schemas/linked-account.schema.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; -import { HydratedDocument } from 'mongoose'; - -export type LinkedAccountDocument = HydratedDocument; - -@Schema() -export class LinkedAccount { - @Prop({ required: true, default: 'unverified', enum: ['verified', 'unverified'] }) - status: 'verified' | 'unverified'; - - @Prop({ required: true }) - account: string; - - @Prop({ required: true }) - user_id: string; - - @Prop({ required: true, enum: ['HIVE'] }) - network: 'HIVE'; -} - -export const LinkedAccountSchema = SchemaFactory.createForClass(LinkedAccount); diff --git a/src/repositories/session/session.repository.ts b/src/repositories/session/session.repository.ts index c0e37d9..5490561 100644 --- a/src/repositories/session/session.repository.ts +++ b/src/repositories/session/session.repository.ts @@ -8,10 +8,10 @@ export class SessionRepository { constructor(@InjectModel('auth_sessions', 'acela-core') private sessionModel: Model) {} async insertOne({ id, type, sub }: { id: string; type?: string; sub?: string }) { - return await this.sessionModel.create({ id, type, sub }); + return this.sessionModel.create({ id, type, sub }); } async findOneBySub(sub: string) { - return await this.sessionModel.findOne({ sub }); + return this.sessionModel.findOne({ sub }); } } diff --git a/src/repositories/upload/upload.repository.ts b/src/repositories/upload/upload.repository.ts index e1983b8..ff6bae8 100644 --- a/src/repositories/upload/upload.repository.ts +++ b/src/repositories/upload/upload.repository.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; import { FilterQuery, Model, QueryOptions, Types, UpdateQuery } from 'mongoose'; import { Upload } from './schemas/upload.schema'; +import { User } from '../../services/auth/auth.types'; @Injectable() export class UploadRepository { @@ -57,11 +58,7 @@ export class UploadRepository { id: string, cid: string, video_id: string, - user: { - sub: string; - username: string; - id?: string; - }, + user: User, ): Promise { return await this.uploadModel.create({ id, @@ -72,7 +69,7 @@ export class UploadRepository { ipfs_status: 'done', cid: cid, type: 'thumbnail', - created_by: user.sub, + created_by: user.user_id, }); } diff --git a/src/repositories/user/schemas/user.schema.ts b/src/repositories/user/schemas/user.schema.ts index 70a42a3..db82e10 100644 --- a/src/repositories/user/schemas/user.schema.ts +++ b/src/repositories/user/schemas/user.schema.ts @@ -1,29 +1,29 @@ import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; -import { Document, HydratedDocument, Types } from 'mongoose'; -import { HiveAccount } from '../../hive-account/schemas/hive-account.schema'; -import { v4 as uuid } from 'uuid'; +import { HydratedDocument, Types } from 'mongoose'; +import { LegacyHiveAccount } from '../../hive-account/schemas/hive-account.schema'; -export type UserDocument = HydratedDocument; +export type LegacyUserDocument = HydratedDocument; @Schema() -export class User extends Document { - @Prop({ type: String, unique: true, default: () => uuid() }) - user_id: string; +export class LegacyUser { + // same as userAccount.username + @Prop({ type: String, required: true }) + user_id!: string; - @Prop({ type: Boolean, required: true, default: false }) - banned: boolean; + @Prop({ type: String, unique: true }) + sub?: string; - @Prop({ type: String, required: true, unique: true }) - email: string; + @Prop({ type: Boolean, required: true, default: false }) + banned?: boolean; - @Prop({ type: Types.ObjectId, ref: HiveAccount.name }) // Assuming 'Identity' is another schema - last_identity: Types.ObjectId; + @Prop({ type: String }) + email?: string; - @Prop() - display_name: string; + @Prop({ type: Types.ObjectId, ref: LegacyHiveAccount.name }) + last_identity?: Types.ObjectId; @Prop() - self_deleted: boolean; + self_deleted?: boolean; } -export const UserSchema = SchemaFactory.createForClass(User); +export const LegacyUserSchema = SchemaFactory.createForClass(LegacyUser); diff --git a/src/repositories/user/user.module.ts b/src/repositories/user/user.module.ts index 254ac33..707738f 100644 --- a/src/repositories/user/user.module.ts +++ b/src/repositories/user/user.module.ts @@ -1,12 +1,12 @@ import { Module } from '@nestjs/common'; import { MongooseModule } from '@nestjs/mongoose'; -import { UserRepository } from './user.repository'; -import { User, UserSchema } from './schemas/user.schema'; +import { LegacyUserRepository } from './user.repository'; +import { LegacyUserSchema } from './schemas/user.schema'; @Module({ - imports: [MongooseModule.forFeature([{ name: User.name, schema: UserSchema }], 'threespeak')], + imports: [MongooseModule.forFeature([{ name: 'users', schema: LegacyUserSchema }], 'threespeak')], controllers: [], - providers: [UserRepository], - exports: [UserRepository], + providers: [LegacyUserRepository], + exports: [LegacyUserRepository], }) export class UserModule {} diff --git a/src/repositories/user/user.repository.test.ts b/src/repositories/user/user.repository.test.ts new file mode 100644 index 0000000..424cd28 --- /dev/null +++ b/src/repositories/user/user.repository.test.ts @@ -0,0 +1,93 @@ +import 'dotenv/config' +import { Test } from '@nestjs/testing'; +import { MongooseModule } from '@nestjs/mongoose'; +import { UserModule } from '../../repositories/user/user.module'; +import { MongoMemoryServer } from 'mongodb-memory-server'; +import { INestApplication, Module } from '@nestjs/common'; +import { TestingModule } from '@nestjs/testing'; +import crypto from 'crypto'; +import { LegacyUserRepository } from './user.repository'; +import { LegacyUserSchema } from './schemas/user.schema'; +import { HiveAccountModule } from '../hive-account/hive-account.module'; +import { LegacyHiveAccountRepository } from '../hive-account/hive-account.repository'; +import { LegacyHiveAccountSchema } from '../hive-account/schemas/hive-account.schema'; + +describe('User repository', () => { + let app: INestApplication + let mongod: MongoMemoryServer; + let userRepository: LegacyUserRepository; + let hiveAccount: LegacyHiveAccountRepository; + + + beforeEach(async () => { + mongod = await MongoMemoryServer.create() + const uri: string = mongod.getUri() + + process.env.JWT_PRIVATE_KEY = crypto.randomBytes(64).toString('hex'); + process.env.DELEGATED_ACCOUNT = 'threespeak'; + process.env.ACCOUNT_CREATOR = 'threespeak'; + + @Module({ + imports: [ + MongooseModule.forRoot(uri, { + ssl: false, + authSource: 'threespeak', + readPreference: 'primary', + connectionName: 'threespeak', + dbName: 'threespeak', + autoIndex: true, + }), + MongooseModule.forFeature([{ name: 'users', schema: LegacyUserSchema }], 'threespeak'), + MongooseModule.forFeature([{ name: 'hiveaccounts', schema: LegacyHiveAccountSchema }], 'threespeak'), + UserModule, + HiveAccountModule + ], + controllers: [], + providers: [LegacyUserRepository, LegacyHiveAccountRepository] + }) + class TestModule {} + + let moduleRef: TestingModule; + + moduleRef = await Test.createTestingModule({ + imports: [TestModule], + }).compile() + userRepository = moduleRef.get(LegacyUserRepository); + hiveAccount = moduleRef.get(LegacyHiveAccountRepository); + app = moduleRef.createNestApplication(); + await app.init() + }) + + afterEach(async () => { + await app.close(); + await mongod.stop(); + }); + it(`Successfully creates a new user`, async () => { + await userRepository.createNewSubUser({ sub: 'singleton/did/check', user_id: 'example' }) + const result = await userRepository.findOneBySub('singleton/did/check') + expect(result).toBeTruthy() + }); + + it('Successfully gets legacy linked hive accounts', async () => { + const user = await userRepository.createNewSubUser({ sub: 'singleton/sisy/hive', user_id: 'example' }); + if (!user?.user_id) throw new Error('No user id'); + + await hiveAccount.insertCreated({ account: 'sisygoboom', user_id: user._id }); + + await hiveAccount.insertCreated({ account: 'dave', user_id: user._id }); + + const result = await userRepository.getLegacyLinkedHiveAccounts(user.user_id); + + expect(result).toMatchObject( + { + banned: false, + linked_hiveaccounts: [ + "sisygoboom", + "dave", + ], + user_id: user.user_id, + _id: user._id + } + ); + }); +}); \ No newline at end of file diff --git a/src/repositories/user/user.repository.ts b/src/repositories/user/user.repository.ts index 3025fe8..5a170cd 100644 --- a/src/repositories/user/user.repository.ts +++ b/src/repositories/user/user.repository.ts @@ -1,13 +1,14 @@ import { Injectable, Logger } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; import { Model } from 'mongoose'; -import { User } from './schemas/user.schema'; +import { LegacyUser } from './schemas/user.schema'; +import { ObjectId } from 'mongodb'; @Injectable() -export class UserRepository { - readonly #logger = new Logger(UserRepository.name); +export class LegacyUserRepository { + readonly #logger = new Logger(LegacyUserRepository.name); - constructor(@InjectModel(User.name, 'threespeak') private userModel: Model) {} + constructor(@InjectModel('users', 'threespeak') private userModel: Model) {} // async findOneByUsername(username: string): Promise { // const query = { username }; @@ -17,7 +18,7 @@ export class UserRepository { // return acelaUser; // } - async findOneByEmail(email: string): Promise { + async findOneByEmail(email: string): Promise { const query = { email }; const acelaUser = await this.userModel.findOne(query); this.#logger.log(acelaUser); @@ -25,20 +26,100 @@ export class UserRepository { return acelaUser; } + async findOneBySub(sub: string) { + const query = { sub } satisfies Partial; + const authUser = await this.userModel.findOne(query); + + return authUser ? authUser.toObject() : null; + } + + async findOneByUserId(query: Pick) { + const authUser = await this.userModel.findOne(query); + + return authUser ? authUser.toObject() : null; + } + // async insertOne() { // this.userModel.create({}) // } - async verifyEmail(verifyCode: string) { - this.userModel.updateOne( - { - email_code: verifyCode, - }, - { - $set: { - email_status: 'verified', - }, - }, - ); + async createNewEmailUser(query: Pick) { + await this.userModel.create(query); + } + + async createNewSubUser(user: Pick) { + return await this.userModel.create(user); + } + + async getLegacyLinkedHiveAccounts(user_id: string): Promise<{ + banned: boolean; + linked_hiveaccounts: string[]; + user_id: string; + last_hiveaccount: string; + _id: ObjectId; + }> { + return ( + await this.userModel + .aggregate<{ + banned: boolean; + linked_hiveaccounts: string[]; + user_id: string; + last_hiveaccount: string; + _id: ObjectId; + }>([ + { + $match: { user_id }, + }, + { + $lookup: { + from: 'hiveaccounts', + localField: '_id', + foreignField: 'user_id', + as: 'linked_hiveaccounts', + }, + }, + { + $lookup: { + from: 'hiveaccounts', + localField: 'last_identity', + foreignField: '_id', + as: 'last_hiveaccount', + }, + }, + { + $unwind: { + path: '$linked_hiveaccounts', + preserveNullAndEmptyArrays: true, + }, + }, + { + $unwind: { + path: '$last_hiveaccount', + preserveNullAndEmptyArrays: true, + }, + }, + { + $group: { + _id: '$_id', + email: { $first: '$email' }, + user_id: { $first: '$user_id' }, + banned: { $first: '$banned' }, + linked_hiveaccounts: { $push: '$linked_hiveaccounts.account' }, + last_hiveaccount: { $first: '$last_hiveaccount.account' }, + }, + }, + { + $project: { + email: 1, + linked_hiveaccounts: 1, + user_id: 1, + last_hiveaccount: 1, + banned: 1, + _id: 1, + }, + }, + ]) + .exec() + )[0]; } } diff --git a/src/repositories/userAccount/schemas/user-account.schema.ts b/src/repositories/userAccount/schemas/user-account.schema.ts index b209290..ea2c140 100644 --- a/src/repositories/userAccount/schemas/user-account.schema.ts +++ b/src/repositories/userAccount/schemas/user-account.schema.ts @@ -2,54 +2,54 @@ import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; import * as mongoose from 'mongoose'; import { v4 as uuid } from 'uuid'; -export type UserAccountDocument = mongoose.Document & UserAccount; +export type LegacyUserDocument = mongoose.Document & LegacyUserAccount; @Schema() -export class UserAccount { +export class LegacyUserAccount { @Prop({ type: String, required: false }) - confirmationCode: string; + confirmationCode?: string; - @Prop({ type: Date, required: true, default: Date.now }) - createdAt: Date; + @Prop({ type: Date, required: true, default: () => new Date() }) + createdAt?: Date; - @Prop({ type: String, unique: true }) - email: string; + @Prop({ type: String }) + email?: string; @Prop({ type: Boolean, required: true, default: false }) - emailVerified: boolean; + emailVerified?: boolean; @Prop({ type: Boolean, required: true, default: true }) - enabled: boolean; + enabled?: boolean; @Prop({ type: String, default: null }) - hiveAccount: string; + hiveAccount: string | null; @Prop({ type: Boolean, default: false }) - keysRequested: boolean; + keysRequested?: boolean; @Prop({ type: Boolean, default: false }) - keysSent: boolean; + keysSent?: boolean; @Prop({ type: String, required: false }) - password: string; + password?: string; @Prop({ type: String, default: 'FFFFFFFFFFFF' }) - passwordResetCode: string; + passwordResetCode?: string; @Prop({ type: Boolean, default: false }) - passwordResetRequired: boolean; + passwordResetRequired?: boolean; - @Prop({ type: Date, required: true, default: Date.now }) - updatedAt: Date; + @Prop({ type: Date, required: true, default: () => new Date() }) + updatedAt?: Date; - @Prop({ type: String, default: () => uuid(), required: true }) + @Prop({ type: String, default: () => uuid(), required: true, unique: true }) username: string; @Prop({ type: ['UNCONFIRMED', 'CONFIRMED'], default: 'UNCONFIRMED', required: true }) - userStatus: string; + userStatus?: string; @Prop({ type: String }) - did: string; + sub: string; } -export const UserAccountSchema = SchemaFactory.createForClass(UserAccount); +export const LegacyUserAccountSchema = SchemaFactory.createForClass(LegacyUserAccount); diff --git a/src/repositories/userAccount/user-account.module.ts b/src/repositories/userAccount/user-account.module.ts index f3ce1bd..e0520c0 100644 --- a/src/repositories/userAccount/user-account.module.ts +++ b/src/repositories/userAccount/user-account.module.ts @@ -1,16 +1,16 @@ import { Module } from '@nestjs/common'; import { MongooseModule } from '@nestjs/mongoose'; -import { UserAccount, UserAccountSchema } from './schemas/user-account.schema'; -import { UserAccountRepository } from './user-account.repository'; +import { LegacyUserAccountSchema } from './schemas/user-account.schema'; +import { LegacyUserAccountRepository } from './user-account.repository'; @Module({ imports: [ MongooseModule.forFeature( - [{ name: UserAccount.name, schema: UserAccountSchema }], + [{ name: 'useraccounts', schema: LegacyUserAccountSchema }], '3speakAuth', ), ], - providers: [UserAccountRepository], - exports: [UserAccountRepository], + providers: [LegacyUserAccountRepository], + exports: [LegacyUserAccountRepository], }) export class UserAccountModule {} diff --git a/src/repositories/userAccount/user-account.repository.ts b/src/repositories/userAccount/user-account.repository.ts index 7edc819..4149693 100644 --- a/src/repositories/userAccount/user-account.repository.ts +++ b/src/repositories/userAccount/user-account.repository.ts @@ -1,52 +1,68 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; import { Model } from 'mongoose'; -import { UserAccount } from './schemas/user-account.schema'; -import { v4 as uuid } from 'uuid'; -import { CreateUserAccountDto } from './dto/user-account.dto'; +import { LegacyUserAccount } from './schemas/user-account.schema'; +import bcrypt from 'bcryptjs'; + +interface CreateUserParams { + email: string; + password: string; + username: string; +} @Injectable() -export class UserAccountRepository { - readonly #logger = new Logger(UserAccountRepository.name); +export class LegacyUserAccountRepository { + readonly #logger = new Logger(LegacyUserAccountRepository.name); constructor( - @InjectModel(UserAccount.name, '3speakAuth') private userAccountModel: Model, + @InjectModel('useraccounts', '3speakAuth') + private legacyUserAccountModel: Model, ) {} - async findOneByEmail(email: string): Promise { - const query = { email }; - const authUser = await this.userAccountModel.findOne(query); + async findOneByEmail(query: Pick): Promise { + const authUser = await this.legacyUserAccountModel.findOne(query); this.#logger.log(authUser); // TODO: delete - not suitable for prod return authUser; } - async findOneByDid(did: string) { - const query = { did }; - const authUser = await this.userAccountModel.findOne(query); - this.#logger.log(authUser); // TODO: delete - not suitable for prod - - return authUser; + async verifyEmail(query: Pick): Promise { + const result = await this.legacyUserAccountModel.updateOne(query, { + $set: { emailVerified: true }, + }); + return result.modifiedCount > 0; } - async createNewEmailAndPasswordUser( - email: string, - hashedPassword: string, - ): Promise { - return this.userAccountModel.create({ - email, - email_code: uuid(), - auth_methods: { - password: { - value: hashedPassword, - }, - }, - }); + async createNewEmailAndPasswordUser(query: CreateUserParams): Promise { + query.password = bcrypt.hashSync(query.password, bcrypt.genSaltSync(10)); + const { confirmationCode } = await this.legacyUserAccountModel.create(query); + + if (!confirmationCode) + throw new InternalServerErrorException( + 'Please alert the team email code was missing after email account creation', + ); + + return confirmationCode; } - async createNewDidUser(did: string): Promise { - return this.userAccountModel.create({ - did, - }); + async createOne({ + sub, + hiveAccount, + password, + email, + username, + }: { + sub: string; + hiveAccount: string | null; + password?: string; + email?: string; + username: string; + }) { + if ((email && !password) || (!email && password)) { + throw new Error('Both email and password must be provided or not provided at all.'); + } + + const userAccount = { sub, hiveAccount, password, email, username } satisfies LegacyUserAccount; + return (await this.legacyUserAccountModel.create(userAccount)).toObject(); } } diff --git a/src/repositories/video/video.repository.ts b/src/repositories/video/video.repository.ts index 17a2c98..544461b 100644 --- a/src/repositories/video/video.repository.ts +++ b/src/repositories/video/video.repository.ts @@ -78,7 +78,7 @@ export class VideoRepository { } async createNewHiveVideoPost({ - sub, + user_id, username, title, description, @@ -87,7 +87,7 @@ export class VideoRepository { language, beneficiaries, }: { - sub: string; + user_id: string; username: string; title: string; description: string; @@ -114,7 +114,7 @@ export class VideoRepository { publish_type: 'immediate', publish_date: null, }, - created_by: sub, + created_by: user_id, expires: moment().add('1', 'day').toDate(), upload_links: {}, network: 'hive', diff --git a/src/services/api/api.contoller.test.ts b/src/services/api/api.contoller.test.ts index 7eb6ff8..094a688 100644 --- a/src/services/api/api.contoller.test.ts +++ b/src/services/api/api.contoller.test.ts @@ -8,14 +8,12 @@ import { AuthGuard } from '@nestjs/passport'; import { ApiController } from './api.controller'; import { ApiModule } from './api.module'; import { AuthService } from '../auth/auth.service'; -import { HiveAccountRepository } from '../../repositories/hive-account/hive-account.repository'; -import { UserRepository } from '../../repositories/user/user.repository'; +import { LegacyHiveAccountRepository } from '../../repositories/hive-account/hive-account.repository'; +import { LegacyUserRepository } from '../../repositories/user/user.repository'; import { HiveChainRepository } from '../../repositories/hive-chain/hive-chain.repository'; -import { LinkedAccountRepository } from '../../repositories/linked-accounts/linked-account.repository'; import { EmailService } from '../email/email.service'; import { HiveAccountModule } from '../../repositories/hive-account/hive-account.module'; import { UserModule } from '../../repositories/user/user.module'; -import { LinkedAccountModule } from '../../repositories/linked-accounts/linked-account.module'; import { JwtModule } from '@nestjs/jwt'; import { AuthModule } from '../auth/auth.module'; import { MockAuthGuard, MockDidUserDetailsInterceptor, UserDetailsInterceptor } from './utils'; @@ -24,15 +22,14 @@ import { EmailModule } from '../email/email.module'; import * as crypto from 'crypto'; import { HiveModule } from '../hive/hive.module'; import { PrivateKey } from '@hiveio/dhive'; +import { UserAccountModule } from '../../repositories/userAccount/user-account.module'; +import { SessionModule } from '../../repositories/session/session.module'; describe('ApiController', () => { let app: INestApplication; let mongod: MongoMemoryServer; let authService: AuthService; - let hiveAccountRepository: HiveAccountRepository; - let userRepository: UserRepository; let hiveRepository: HiveChainRepository; - let linkedAccountsRepository: LinkedAccountRepository; let emailService: EmailService; beforeEach(async () => { @@ -66,10 +63,12 @@ describe('ApiController', () => { connectionName: '3speakAuth', dbName: '3speakAuth', }), + AuthModule, HiveModule, UserModule, - AuthModule, + UserAccountModule, HiveAccountModule, + SessionModule, HiveChainModule, EmailModule, ApiModule, @@ -77,10 +76,10 @@ describe('ApiController', () => { secretOrPrivateKey: process.env.JWT_PRIVATE_KEY, signOptions: { expiresIn: '30d' }, }), - LinkedAccountModule ], controllers: [ApiController], - providers: [], + providers: [AuthService], + exports: [AuthService] }) class TestModule {} @@ -94,10 +93,7 @@ describe('ApiController', () => { .compile(); authService = moduleRef.get(AuthService); - hiveAccountRepository = moduleRef.get(HiveAccountRepository); - userRepository = moduleRef.get(UserRepository); hiveRepository = moduleRef.get(HiveChainRepository); - linkedAccountsRepository = moduleRef.get(LinkedAccountRepository); emailService = moduleRef.get(EmailService); app = moduleRef.createNestApplication(); @@ -140,13 +136,17 @@ describe('ApiController', () => { it('should link a Hive account', async () => { const jwtToken = 'test_jwt_token'; + const user_id = 'test_user_id' + const user = await authService.createDidUser('did:key:z6MkjHhFz9hXYJKGrT5fShwJMzQpHGi63sS3wY3U1eH4n7i5#z6MkjHhFz9hXYJKGrT5fShwJMzQpHGi63sS3wY3U1eH4n7i5', user_id) + const hiveUsername = 'starkerz' + const privateKey = PrivateKey.fromSeed(crypto.randomBytes(32).toString("hex")); const publicKey = privateKey.createPublic(); const publicKeyString = publicKey.toString(); - const message = "singleton/bob/did is the owner of @starkerz"; + const message = `${user_id} is the owner of @${hiveUsername}`; const signature = privateKey.sign(crypto.createHash('sha256').update(message).digest()); - const body = { username: 'starkerz', proof: signature.toString() }; + const body = { username: hiveUsername, proof: signature.toString() }; process.env.TEST_PUBLIC_KEY = publicKeyString; @@ -159,10 +159,8 @@ describe('ApiController', () => { expect(response.body).toEqual({ __v: 0, _id: expect.any(String), - account: "starkerz", - network: "HIVE", - status: "verified", - user_id: "singleton/bob/did", + account: hiveUsername, + user_id: user._id.toString(), }); }); }); @@ -178,11 +176,10 @@ describe('ApiController', () => { .expect(200) .then(response => { expect(response.body).toEqual({ - id: "test_user_id", network: "did", - sub: "singleton/bob/did", + sub: "singleton/did:key:z6MkjHhFz9hXYJKGrT5fShwJMzQpHGi63sS3wY3U1eH4n7i5#z6MkjHhFz9hXYJKGrT5fShwJMzQpHGi63sS3wY3U1eH4n7i5/did", type: "singleton", - username: "test_user_id", + user_id: "test_user_id", }); }); }); @@ -193,8 +190,9 @@ describe('ApiController', () => { const jwtToken = 'test_jwt_token'; // Mock linking and verifying an account - const link = await linkedAccountsRepository.linkHiveAccount('singleton/bob/did', 'test-account'); - await linkedAccountsRepository.verify(link._id); + const user = await authService.createDidUser('did:key:z6MkjHhFz9hXYJKGrT5fShwJMzQpHGi63sS3wY3U1eH4n7i5#z6MkjHhFz9hXYJKGrT5fShwJMzQpHGi63sS3wY3U1eH4n7i5', 'test_user_id') + await authService.linkHiveAccount({ user_id: user._id, username: 'test-account' }) + await authService.linkHiveAccount({ user_id: user._id, username: 'joop' }) return request(app.getHttpServer()) .get('/v1/hive/linked-account/list') @@ -203,7 +201,7 @@ describe('ApiController', () => { .then(response => { expect(response.body).toEqual( { - accounts: ['test-account'], + accounts: ['test-account', 'joop'], }, ); }); diff --git a/src/services/api/api.controller.ts b/src/services/api/api.controller.ts index dd12b9e..5ec2231 100644 --- a/src/services/api/api.controller.ts +++ b/src/services/api/api.controller.ts @@ -5,10 +5,9 @@ import { Post, UseGuards, Body, - HttpException, - HttpStatus, UseInterceptors, Logger, + UnauthorizedException, } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { AuthService } from '../auth/auth.service'; @@ -20,13 +19,10 @@ import { ApiOkResponse, ApiOperation, } from '@nestjs/swagger'; -import { HiveAccountRepository } from '../../repositories/hive-account/hive-account.repository'; -import { UserRepository } from '../../repositories/user/user.repository'; +import { LegacyUserRepository } from '../../repositories/user/user.repository'; import { LinkAccountPostDto } from './dto/LinkAccountPost.dto'; import { VotePostResponseDto } from './dto/VotePostResponse.dto'; import { VotePostDto } from './dto/VotePost.dto'; -import { LinkedAccountRepository } from '../../repositories/linked-accounts/linked-account.repository'; -import { EmailService } from '../email/email.service'; import { parseAndValidateRequest } from '../auth/auth.utils'; import { HiveService } from '../hive/hive.service'; import { HiveChainRepository } from '../../repositories/hive-chain/hive-chain.repository'; @@ -37,13 +33,9 @@ export class ApiController { constructor( private readonly authService: AuthService, - private readonly hiveAccountRepository: HiveAccountRepository, - private readonly userRepository: UserRepository, + private readonly userRepository: LegacyUserRepository, private readonly hiveService: HiveService, private readonly hiveChainRepository: HiveChainRepository, - //private readonly delegatedAuthorityRepository: DelegatedAuthorityRepository, - private readonly linkedAccountsRepository: LinkedAccountRepository, - private readonly emailService: EmailService, ) {} @ApiHeader({ @@ -98,7 +90,6 @@ export class ApiController { }, ) { const { body, parent_author, parent_permlink, author } = reqBody; - // console.log(body) //TODO: Do validation of account ownership before doing operation return await this.hiveChainRepository.comment(author, body, { parent_author, parent_permlink }); @@ -158,11 +149,16 @@ export class ApiController { async linkAccount(@Body() data: LinkAccountPostDto, @Request() req: unknown) { const parsedRequest = parseAndValidateRequest(req, this.#logger); - return await this.hiveService.linkHiveAccount( - parsedRequest.user.sub, - data.username, - data.proof, - ); + const user = await this.authService.getUserByUserId({ user_id: parsedRequest.user.user_id }); + + if (!user) throw new UnauthorizedException('User not found'); + + return await this.hiveService.linkHiveAccount({ + proof: data.proof, + hiveUsername: data.username, + user_id: parsedRequest.user.user_id, + db_user_id: user._id, + }); } @ApiHeader({ @@ -189,17 +185,12 @@ export class ApiController { @Get('/hive/linked-account/list') async listLinkedAccounts(@Request() req: unknown) { const request = parseAndValidateRequest(req, this.#logger); - if (!request.user.sub) { - throw new HttpException( - { - reason: 'Logged in with a lite account, full account needed to check linked accounts.', - }, - HttpStatus.FORBIDDEN, - ); - } // TODO: before going live, check that current linked accounts will still show since user.sub is a proprietary new format - const accounts = await this.linkedAccountsRepository.findAllByUserId(request.user.sub); - return { accounts: accounts.map((account) => account.account) }; + const accounts = { + accounts: (await this.userRepository.getLegacyLinkedHiveAccounts(request.user.user_id)) + .linked_hiveaccounts, + }; + return accounts; } @ApiOperation({ @@ -216,6 +207,10 @@ export class ApiController { const parsedRequest = parseAndValidateRequest(req, this.#logger); const { author, permlink, weight, votingAccount } = data; + const user = await this.authService.getUserByUserId({ user_id: parsedRequest.user.user_id }); + + if (!user) throw new UnauthorizedException('User not found'); + return await this.hiveService.vote({ sub: parsedRequest.user.sub, votingAccount, @@ -223,6 +218,7 @@ export class ApiController { permlink, weight, network: parsedRequest.user.network, + user_id: user._id, }); } } diff --git a/src/services/api/api.module.ts b/src/services/api/api.module.ts index 953525b..2555d8a 100644 --- a/src/services/api/api.module.ts +++ b/src/services/api/api.module.ts @@ -6,7 +6,6 @@ import { UserModule } from '../../repositories/user/user.module'; import { HiveAccountModule } from '../../repositories/hive-account/hive-account.module'; import { HiveChainModule } from '../../repositories/hive-chain/hive-chain.module'; import { EmailModule } from '../email/email.module'; -import { LinkedAccountModule } from '../../repositories/linked-accounts/linked-account.module'; import { JwtModule } from '@nestjs/jwt'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { HiveModule } from '../hive/hive.module'; @@ -18,7 +17,6 @@ import { HiveModule } from '../hive/hive.module'; HiveAccountModule, HiveChainModule, HiveModule, - LinkedAccountModule, EmailModule, JwtModule.registerAsync({ imports: [ConfigModule], diff --git a/src/services/api/dto/LoginSingleton.dto.ts b/src/services/api/dto/LoginSingleton.dto.ts index c969be8..41fade3 100644 --- a/src/services/api/dto/LoginSingleton.dto.ts +++ b/src/services/api/dto/LoginSingleton.dto.ts @@ -55,15 +55,16 @@ export class LoginSingletonDidDto { @IsNotEmpty() @ApiProperty({ description: 'Did of the account', - default: 'test-did', + type: 'string', + example: + 'did:key:z6MkjHhFz9hXYJKGrT5fShwJMzQpHGi63sS3wY3U1eH4n7i5#z6MkjHhFz9hXYJKGrT5fShwJMzQpHGi63sS3wY3U1eH4n7i5', }) did: string; - @IsNotEmpty() @ApiProperty({ - description: - 'Issued at (timestamp) - milliseconds denominated timestamp representing when the token was issued', - default: Date.now(), + description: 'Expiry time', + type: 'number', + example: Date.now(), }) iat: number; } diff --git a/src/services/api/utils.test.ts b/src/services/api/utils.test.ts new file mode 100644 index 0000000..d2c3554 --- /dev/null +++ b/src/services/api/utils.test.ts @@ -0,0 +1,206 @@ + +import { Ed25519Provider } from 'key-did-provider-ed25519'; +import * as KeyResolver from 'key-did-resolver'; +import { DID } from 'dids'; +import { Response } from 'express'; +import { Test, TestingModule } from '@nestjs/testing'; +import { ExecutionContext, CallHandler } from '@nestjs/common'; +import { of } from 'rxjs'; +import { jest } from '@jest/globals'; +import { toString } from 'uint8arrays/to-string'; +import { decode } from 'codeco'; +import { DagJWS, uint8ArrayAsBase64pad, uint8ArrayAsBase64url } from '@didtools/codecs'; +import { AuthInterceptor } from './utils'; +import { WithAuthData } from '../auth/auth.interface'; + +export function encodeBase64(bytes: Uint8Array): string { + return uint8ArrayAsBase64pad.encode(bytes); +} + +export function encodeBase64Url(bytes: Uint8Array): string { + return uint8ArrayAsBase64url.encode(bytes); +} + +export function decodeBase64(s: string): Uint8Array { + return decode(uint8ArrayAsBase64pad, s); +} + +export function base64urlToJSON(s: string): Record { + const decoded = decode(uint8ArrayAsBase64url, s); + return JSON.parse(toString(decoded)) as Record; +} + +describe('AuthInterceptor', () => { + let authInterceptor: AuthInterceptor; + const seedBuf = new Uint8Array(32); + seedBuf.fill(27); + const key = new Ed25519Provider(seedBuf); + const did = new DID({ provider: key, resolver: KeyResolver.getResolver() }); + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AuthInterceptor, + { + provide: 'APP_INTERCEPTOR', + useClass: AuthInterceptor, + }, + ], + }).compile(); + + authInterceptor = module.get(AuthInterceptor); + }); + + const createExecutionContext = (req: any, res: Response, next: any): ExecutionContext => ({ + switchToHttp: () => ({ + getRequest: () => req, + getResponse: () => res, + }), + getHandler: jest.fn(), + getClass: jest.fn(), + getArgs: jest.fn(), + getArgByIndex: jest.fn(), + switchToRpc: jest.fn(), + switchToWs: jest.fn(), + getType: jest.fn(), + } as unknown as ExecutionContext); + + describe('intercept', () => { + it('should set request body to JWS data', async () => { + await did.authenticate(); + + const reqBody = { + some: 'data', + num: 53, + did: did.id, + iat: Date.now(), + } satisfies WithAuthData<{ some: string; num: number }>; + + const jws = await did.createJWS(reqBody); + + const req: { body: DagJWS } = { + body: jws, + }; + + const res = {} as Response; + const next = { + handle: jest.fn(() => of(null)), + } as unknown as CallHandler; + + const context = createExecutionContext(req, res, next); + + await authInterceptor.intercept(context, next); + expect(reqBody).toEqual(req.body); + expect(next.handle).toHaveBeenCalled(); + }); + + it('should return 401 if JWS is invalid', async () => { + await did.authenticate(); + + const reqBody = { + some: 'data', + num: 53, + did: did.id, + iat: Date.now(), + } satisfies WithAuthData<{ some: string; num: number }>; + + const jws = await did.createJWS(reqBody); + const invalidJWS: DagJWS = { + ...jws, + signatures: jws.signatures.map((s) => ({ + ...s, + signature: 'invalid', + })), + }; + + const req: { body: DagJWS } = { + body: invalidJWS, + }; + + const res = { + status: jest.fn(() => res), + send: jest.fn(() => res), + } as unknown as Response; + const next = { + handle: jest.fn(), + } as unknown as CallHandler; + + const context = createExecutionContext(req, res, next); + + try { + await authInterceptor.intercept(context, next); + } catch (error) { + expect(error.status).toBe(401); + expect(error.message).toBe('Invalid signature'); + } + }); + + it('should return 401 if DID is invalid', async () => { + await did.authenticate(); + + const reqBody = { + some: 'data', + num: 53, + did: 'did:key:invalid', + iat: Date.now(), + } satisfies WithAuthData<{ some: string; num: number }>; + + const jws = await did.createJWS(reqBody); + + const req: { body: DagJWS } = { + body: jws, + }; + + const res = { + status: jest.fn(() => res), + send: jest.fn(() => res), + } as unknown as Response; + const next = { + handle: jest.fn(), + } as unknown as CallHandler; + + const context = createExecutionContext(req, res, next); + + try { + await authInterceptor.intercept(context, next); + } catch (error) { + expect(error.status).toBe(401); + expect(error.message).toBe('Invalid DID'); + } + }); + + it('should return 401 if timestamp is invalid', async () => { + await did.authenticate(); + + const reqBody = { + some: 'data', + num: 53, + did: did.id, + iat: Date.now() - 1000 * 60 * 6, + } satisfies WithAuthData<{ some: string; num: number }>; + + const jws = await did.createJWS(reqBody); + + const req: { body: DagJWS } = { + body: jws, + }; + + const res = { + status: jest.fn(() => res), + send: jest.fn(() => res), + } as unknown as Response; + const next = { + handle: jest.fn(), + } as unknown as CallHandler; + + const context = createExecutionContext(req, res, next); + + try { + await authInterceptor.intercept(context, next); + } catch (error) { + expect(error.status).toBe(401); + expect(error.message).toBe('Invalid timestamp'); + } + }); + }); +}); diff --git a/src/services/api/utils.ts b/src/services/api/utils.ts index e019701..4dc0a94 100644 --- a/src/services/api/utils.ts +++ b/src/services/api/utils.ts @@ -4,10 +4,15 @@ import { ExecutionContext, Injectable, NestInterceptor, + UnauthorizedException, } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { Observable } from 'rxjs'; import { User } from '../auth/auth.types'; +import { DID, DagJWS, VerifyJWSResult } from 'dids'; +import { Request, Response } from 'express'; +import { authSchema } from '../auth/auth.interface'; +import * as KeyDidResolver from 'key-did-resolver'; @Injectable() export class UserDetailsInterceptor implements NestInterceptor { @@ -47,9 +52,8 @@ export class MockDidUserDetailsInterceptor { intercept(context: ExecutionContext, next: CallHandler): Observable { const request = context.switchToHttp().getRequest(); request.user = { - id: 'test_user_id', - sub: 'singleton/bob/did', - username: 'test_user_id', + user_id: 'test_user_id', + sub: 'singleton/did:key:z6MkjHhFz9hXYJKGrT5fShwJMzQpHGi63sS3wY3U1eH4n7i5#z6MkjHhFz9hXYJKGrT5fShwJMzQpHGi63sS3wY3U1eH4n7i5/did', network: 'did', type: 'singleton', } satisfies User; // Mock user @@ -62,12 +66,54 @@ export class MockHiveUserDetailsInterceptor { intercept(context: ExecutionContext, next: CallHandler): Observable { const request = context.switchToHttp().getRequest(); request.user = { - id: 'test_user_id', + user_id: 'test_user_id', sub: 'singleton/starkerz/hive', - username: 'starkerz', network: 'hive', type: 'singleton', } satisfies User; // Mock user return next.handle(); } } + +const VALID_TIMESTAMP_DIFF_MS = 1000 * 60 * 5; + +export function isValidTimestamp(timestamp: number): boolean { + const now = Date.now(); + const diff = Math.abs(now - timestamp); + return diff < VALID_TIMESTAMP_DIFF_MS; +} + +@Injectable() +export class AuthInterceptor implements NestInterceptor { + async intercept(context: ExecutionContext, next: CallHandler): Promise> { + const request = context.switchToHttp().getRequest>(); + const response = context.switchToHttp().getResponse(); + + let verificationResult: VerifyJWSResult; + const verifier = new DID({ resolver: KeyDidResolver.getResolver() }); + try { + verificationResult = await verifier.verifyJWS(request.body); + } catch (error) { + console.error('Invalid signature', error); + throw new UnauthorizedException('Invalid signature'); + } + + const authData = authSchema.parse(verificationResult.payload); + const { did, iat } = authData; + + if (did !== verificationResult.kid.split('#')[0]) { + console.error('Invalid DID:', did); + console.error('Expected DID:', verificationResult.kid); + throw new UnauthorizedException('Invalid DID'); + } + + if (!isValidTimestamp(iat)) { + console.error('Invalid timestamp:', iat); + throw new UnauthorizedException('Invalid timestamp'); + } + + request.body = verificationResult.payload as any; + + return next.handle(); + } +} diff --git a/src/services/auth/auth.controller.test.ts b/src/services/auth/auth.controller.test.ts index 3439695..9a58a57 100644 --- a/src/services/auth/auth.controller.test.ts +++ b/src/services/auth/auth.controller.test.ts @@ -123,17 +123,19 @@ describe('AuthController', () => { const jws = await did.createJWS(payload); + await authService.createDidUser(did.id, 'test_user_id') + return request(app.getHttpServer()) .post('/v1/auth/login/singleton/did') .send(jws) .set('Content-Type', 'application/json') .set('Accept', 'application/json') .expect(200) - .then(response => { + .then(async response => { expect(response.body).toHaveProperty('access_token'); expect(typeof response.body.access_token).toBe('string'); - expect(authService.didUserExists(did.id)).toBeTruthy(); - expect(authService.getSessionByDid(did.id)).toBeTruthy(); + expect(await authService.didUserExists(did.id)).toBeTruthy(); + expect(await authService.getSessionByDid(did.id)).toBeTruthy(); }); }); }); @@ -141,12 +143,14 @@ describe('AuthController', () => { describe('/POST /request_hive_account', () => { it('creates a Hive account successfully', async () => { - const hiveUsername = 'test_user_id' + const hiveUsername = 'jimbob' + + const user = await authService.createDidUser('did:key:z6MkjHhFz9hXYJKGrT5fShwJMzQpHGi63sS3wY3U1eH4n7i5#z6MkjHhFz9hXYJKGrT5fShwJMzQpHGi63sS3wY3U1eH4n7i5', 'test_user_id') // Make the request to the endpoint return request(app.getHttpServer()) .post('/v1/auth/request_hive_account') - .send({ username: hiveUsername}) + .send({ username: hiveUsername }) .set('Authorization', 'Bearer ') .expect(201) .then(async response => { @@ -157,14 +161,15 @@ describe('AuthController', () => { trx_num: 10, }); - expect(await hiveService.isHiveAccountLinked('singleton/bob/did', hiveUsername)).toBe(true) + expect(await hiveService.isHiveAccountLinked({ user_id: user._id, account: hiveUsername })).toBe(true) }); }); it('throws error when user has already created a Hive account', async () => { const username= 'yeet'; - await hiveService.requestHiveAccount('bob', 'singleton/bob/did'); + const user = await authService.createDidUser('did:key:z6MkjHhFz9hXYJKGrT5fShwJMzQpHGi63sS3wY3U1eH4n7i5#z6MkjHhFz9hXYJKGrT5fShwJMzQpHGi63sS3wY3U1eH4n7i5', 'test_user_id') + await hiveService.requestHiveAccount('bob', user.user_id); // Make the request to the endpoint return request(app.getHttpServer()) .post('/v1/auth/request_hive_account') @@ -173,10 +178,10 @@ describe('AuthController', () => { .expect(400) .then(async response => { expect(response.body).toEqual({ - reason: "You have already created the maximum of 1 free Hive account", + reason: "You have already linked a hive account, so cannot claim a free one.", }); - expect(await hiveService.isHiveAccountLinked('singleton/bob/did', 'bob')).toBe(true) - expect(await hiveService.isHiveAccountLinked('singleton/bob/did', username)).toBe(false) + expect(await hiveService.isHiveAccountLinked({ account: 'bob', user_id: user._id })).toBe(true) + expect(await hiveService.isHiveAccountLinked({ account: username, user_id: user._id })).toBe(false) }); }); }); diff --git a/src/services/auth/auth.controller.ts b/src/services/auth/auth.controller.ts index 01165ff..2f88988 100644 --- a/src/services/auth/auth.controller.ts +++ b/src/services/auth/auth.controller.ts @@ -28,23 +28,20 @@ import { ApiMovedPermanentlyResponse, } from '@nestjs/swagger'; import moment from 'moment'; -import { authenticator } from 'otplib'; -import { HiveClient } from '../../utils/hiveClient'; import { LoginDto } from '../api/dto/Login.dto'; import { LoginErrorResponseDto } from '../api/dto/LoginErrorResponse.dto'; import { LoginResponseDto } from '../api/dto/LoginResponse.dto'; -import { LoginSingletonHiveDto } from '../api/dto/LoginSingleton.dto'; +import { LoginSingletonDidDto, LoginSingletonHiveDto } from '../api/dto/LoginSingleton.dto'; import { AuthService } from './auth.service'; -import { HiveAccountRepository } from '../../repositories/hive-account/hive-account.repository'; -import { UserRepository } from '../../repositories/user/user.repository'; +import { LegacyHiveAccountRepository } from '../../repositories/hive-account/hive-account.repository'; +import { LegacyUserRepository } from '../../repositories/user/user.repository'; import { HiveChainRepository } from '../../repositories/hive-chain/hive-chain.repository'; import { EmailService } from '../email/email.service'; -import bcrypt from 'bcryptjs'; -import { WithAuthData } from './auth.interface'; import { parseAndValidateRequest } from './auth.utils'; import { RequestHiveAccountDto } from '../api/dto/RequestHiveAccount.dto'; import { HiveService } from '../hive/hive.service'; -import { UserDetailsInterceptor } from '../api/utils'; +import { AuthInterceptor, UserDetailsInterceptor } from '../api/utils'; +import { v4 as uuid } from 'uuid'; @Controller('/v1/auth') export class AuthController { @@ -52,8 +49,8 @@ export class AuthController { constructor( private readonly authService: AuthService, - private readonly hiveAccountRepository: HiveAccountRepository, - private readonly userRepository: UserRepository, + private readonly hiveAccountRepository: LegacyHiveAccountRepository, + private readonly userRepository: LegacyUserRepository, private readonly hiveRepository: HiveChainRepository, private readonly hiveService: HiveService, //private readonly delegatedAuthorityRepository: DelegatedAuthorityRepository, @@ -129,6 +126,8 @@ export class AuthController { ); } + await this.authService.getOrCreateUserByHiveUsername(body.proof_payload.account); + return await this.authService.authenticateUser('singleton', body.proof_payload.account, 'hive'); } @@ -145,11 +144,12 @@ export class AuthController { description: 'Internal Server Error - unrelated to request body', }) @HttpCode(200) + @UseInterceptors(AuthInterceptor) @Post('/login/singleton/did') - async loginSingletonReturn(@Body() body: WithAuthData) { + async loginSingletonReturn(@Body() body: LoginSingletonDidDto) { try { await this.authService.getOrCreateUserByDid(body.did); - return await this.authService.authenticateUser('singleton', body.did, 'did'); + return await this.authService.authenticateUserByDid(body.did); } catch (e) { this.#logger.error(e); throw new HttpException( @@ -186,7 +186,7 @@ export class AuthController { @UseGuards(AuthGuard('jwt')) @Post('/check') async checkAuth(@Request() req) { - console.log('user details check', req.user); + this.#logger.log('user details check', req.user); return { ok: true, }; @@ -240,52 +240,59 @@ export class AuthController { }) @Post('/lite/register-initial') async registerLite(@Body() body: { username: string; otp_code: string; secret: string }) { - const { username, otp_code } = body; - const output = await HiveClient.database.getAccounts([username]); - - if (output.length === 0) { - // const secret = authenticator.generateSecret(32) - - if ( - authenticator.verify({ - token: otp_code, - secret: body.secret, - }) - ) { - // const accountCreation = await createAccountWithAuthority( - // username, - // process.env.ACCOUNT_CREATOR - // ) - await this.hiveAccountRepository.createLite(username, body.secret); - - const jwt = this.authService.jwtSign({ - sub: this.authService.generateSub('lite', username, 'hive'), - username, - network: 'hive', - }); - - return { - // id: accountCreation.id, - access_token: jwt, - }; - } else { - throw new HttpException( - { - reason: 'Invalid OTP code', - errorType: 'INVALID_OTP', - }, - HttpStatus.BAD_REQUEST, - ); - } - } else { - throw new HttpException( - { - reason: 'Hive account with the requested name already exists', - errorType: 'HIVE_ACCOUNT_EXISTS', - }, - HttpStatus.BAD_REQUEST, - ); - } + throw new HttpException('Not Implemented', HttpStatus.NOT_IMPLEMENTED); + // const { username, otp_code } = body; + // const output = await HiveClient.database.getAccounts([username]); + + // if (output.length === 0) { + // // const secret = authenticator.generateSecret(32) + + // if ( + // authenticator.verify({ + // token: otp_code, + // secret: body.secret, + // }) + // ) { + // // const accountCreation = await createAccountWithAuthority( + // // username, + // // process.env.ACCOUNT_CREATOR + // // ) + // await this.hiveAccountRepository.createLite(username, body.secret); + + // const sub = this.authService.generateSub('lite', username, 'hive'); + + // const user_id = randomUUID(); + + // await this.userRepository.createNewSubUser({ sub, user_id }); + + // const jwt = this.authService.jwtSign({ + // sub, + // network: 'hive', + // user_id, + // }); + + // return { + // // id: accountCreation.id, + // access_token: jwt, + // }; + // } else { + // throw new HttpException( + // { + // reason: 'Invalid OTP code', + // errorType: 'INVALID_OTP', + // }, + // HttpStatus.BAD_REQUEST, + // ); + // } + // } else { + // throw new HttpException( + // { + // reason: 'Hive account with the requested name already exists', + // errorType: 'HIVE_ACCOUNT_EXISTS', + // }, + // HttpStatus.BAD_REQUEST, + // ); + // } } // @Post('/lite/register-initial') // async registerLiteFinish(@Body() body) { @@ -326,7 +333,6 @@ export class AuthController { @Post('/register') async register(@Request() req, @Body() body: { password: string; email: string }) { const { email, password } = body; - const hashedPassword = bcrypt.hashSync(password, bcrypt.genSaltSync(10)); const existingRecord = await this.userRepository.findOneByEmail(email); @@ -335,17 +341,16 @@ export class AuthController { { reason: 'Email Password account already created!' }, HttpStatus.BAD_REQUEST, ); - } else { - const { email_code } = await this.authService.createEmailAndPasswordUser( - email, - hashedPassword, - ); - - await this.emailService.sendRegistration(email, email_code); - return { - ok: true, - }; } + + const user_id = uuid(); + + const email_code = await this.authService.createEmailAndPasswordUser(email, password, user_id); + + await this.emailService.sendRegistration(email, email_code); + return { + ok: true, + }; // return this.authService.login(req.user); } @@ -364,7 +369,7 @@ export class AuthController { throw new BadRequestException('Verification code is required'); } - await this.userRepository.verifyEmail(verifyCode); + await this.authService.verifyEmail(verifyCode); return res.redirect('https://3speak.tv'); } @@ -407,7 +412,6 @@ export class AuthController { @Request() req, ): Promise { const parsedRequest = parseAndValidateRequest(req, this.#logger); - - return await this.hiveService.requestHiveAccount(body.username, parsedRequest.user.sub); + return await this.hiveService.requestHiveAccount(body.username, parsedRequest.user.user_id); } } diff --git a/src/services/auth/auth.middleware.spec.ts b/src/services/auth/auth.middleware.spec.ts deleted file mode 100644 index e573c23..0000000 --- a/src/services/auth/auth.middleware.spec.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { AuthData, WithAuthData } from "./auth.interface"; -import { AuthMiddleware } from "./auth.middleware"; -import { Ed25519Provider } from 'key-did-provider-ed25519' -import * as KeyResolver from 'key-did-resolver' -import { DID } from 'dids' -import { Response } from 'express' -import { jest } from '@jest/globals' -import { toString } from 'uint8arrays/to-string' -import { decode } from 'codeco' -import { type DagJWS, uint8ArrayAsBase64pad, uint8ArrayAsBase64url } from '@didtools/codecs' - -export function encodeBase64(bytes: Uint8Array): string { - return uint8ArrayAsBase64pad.encode(bytes) -} - -export function encodeBase64Url(bytes: Uint8Array): string { - return uint8ArrayAsBase64url.encode(bytes) -} - -export function decodeBase64(s: string): Uint8Array { - return decode(uint8ArrayAsBase64pad, s) -} - -export function base64urlToJSON(s: string): Record { - const decoded = decode(uint8ArrayAsBase64url, s) - return JSON.parse(toString(decoded)) as Record -} - - -describe('AuthMiddleware', () => { - let authMiddleware: AuthMiddleware - const seedBuf = new Uint8Array(32); - seedBuf.fill(27); - const key = new Ed25519Provider(seedBuf) - const did = new DID({ provider: key, resolver: KeyResolver.getResolver() }) - - beforeEach(() => { - authMiddleware = new AuthMiddleware(); - }); - - describe('use', () => { - it('should set request body to JWS data', async () => { - await did.authenticate() - - const reqBody = { - some: 'data', - num: 53, - did: did.id, - iat: Date.now() - } satisfies WithAuthData<{ - some: string; - num: number; - }> - - const jws = await did.createJWS(reqBody) - - const req: { body: DagJWS } = { - body: jws - } - - const res = jest.mocked({} as any) - const next = jest.fn() - - await authMiddleware.use(req as any, res, next) - expect(reqBody).toEqual(req.body) - expect(next).toHaveBeenCalled() - }); - - it('should return 401 if JWS is invalid', async () => { - await did.authenticate() - const reqBody = { - some: 'data', - num: 53, - did: did.id, - iat: Date.now() - } satisfies WithAuthData<{ - some: string; - num: number; - }> - const jws = await did.createJWS(reqBody) - const req: { body: DagJWS } = { - body: jws - } - const res = jest.mocked({ status: jest.fn(() => res), send: jest.fn(() => res) } as any) - const next = jest.fn() - const invalidJWS: DagJWS = { - ...jws, - signatures: jws.signatures.map(s => ({ - ...s, - signature: 'invalid' - })) - } - req.body = invalidJWS - await authMiddleware.use(req as any, res, next) - expect(res.status).toHaveBeenCalledWith(401) - expect(res.send).toHaveBeenCalledWith('Invalid signature') - expect(next).not.toHaveBeenCalled() - }); - - it('should return 401 if DID is invalid', async () => { - await did.authenticate() - const reqBody = { - some: 'data', - num: 53, - did: 'did:key:invalid', - iat: Date.now() - } satisfies WithAuthData<{ - some: string; - num: number; - }> - const jws = await did.createJWS(reqBody) - const req: { body: DagJWS } = { - body: jws - } - const res = jest.mocked({ status: jest.fn(() => res), send: jest.fn(() => res) } as any) - const next = jest.fn() - - await authMiddleware.use(req as any, res, next); - expect(res.status).toHaveBeenCalledWith(401) - expect(res.send).toHaveBeenCalledWith('Invalid DID') - expect(next).not.toHaveBeenCalled() - }); - - it('should return 401 if timestamp is invalid', async () => { - await did.authenticate() - const reqBody = { - some: 'data', - num: 53, - did: did.id, - iat: Date.now() - 1000 * 60 * 6 - } satisfies WithAuthData<{ - some: string; - num: number; - }> - const jws = await did.createJWS(reqBody) - const req: { body: DagJWS } = { - body: jws - } - const res = jest.mocked({ status: jest.fn(() => res), send: jest.fn(() => res) } as any) - const next = jest.fn() - await authMiddleware.use(req as any, res, next); - expect(res.status).toHaveBeenCalledWith(401) - expect(res.send).toHaveBeenCalledWith('Invalid timestamp') - expect(next).not.toHaveBeenCalled() - }) - }); -}); \ No newline at end of file diff --git a/src/services/auth/auth.middleware.ts b/src/services/auth/auth.middleware.ts deleted file mode 100644 index 9e9686e..0000000 --- a/src/services/auth/auth.middleware.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { Injectable, NestMiddleware } from '@nestjs/common'; -import { Request, Response, NextFunction } from 'express'; -import { authSchema } from './auth.interface'; -import * as KeyDidResolver from 'key-did-resolver'; -import { DID, DagJWS, VerifyJWSResult } from 'dids'; - -const VALID_TIMESTAMP_DIFF_MS = 1000 * 60 * 5; - -export function isValidTimestamp(timestamp: number): boolean { - const now = Date.now(); - const diff = Math.abs(now - timestamp); - return diff < VALID_TIMESTAMP_DIFF_MS; -} - -@Injectable() -export class AuthMiddleware implements NestMiddleware { - async use(req: Request, res: Response, next: NextFunction) { - let verificationResult: VerifyJWSResult; - const verifier = new DID({ resolver: KeyDidResolver.getResolver() }); - try { - verificationResult = await verifier.verifyJWS(req.body); - } catch { - console.error('Invalid signature'); - res.status(401).send('Invalid signature'); - return; - } - - const authData = authSchema.parse(verificationResult.payload); - const { did, iat } = authData; - - //new Date(proof_payload.ts) > moment().subtract('1', 'minute').toDate() - - if (did !== verificationResult.kid.split('#')[0]) { - console.error('Invalid DID:', did); - console.error('Expected DID:', verificationResult.kid); - res.status(401).send('Invalid DID'); - return; - } - - if (!isValidTimestamp(iat)) { - console.error('Invalid timestamp:', iat); - res.status(401).send('Invalid timestamp'); - return; - } - - req.body = verificationResult.payload as any; - - next(); - } -} diff --git a/src/services/auth/auth.module.ts b/src/services/auth/auth.module.ts index c735140..b7644fe 100644 --- a/src/services/auth/auth.module.ts +++ b/src/services/auth/auth.module.ts @@ -1,4 +1,4 @@ -import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; +import { Module } from '@nestjs/common'; import { AuthService } from './auth.service'; import { PassportModule } from '@nestjs/passport'; import { JwtModule } from '@nestjs/jwt'; @@ -8,7 +8,6 @@ import { UserAccountModule } from '../../repositories/userAccount/user-account.m import { SessionModule } from '../../repositories/session/session.module'; import { AuthController } from './auth.controller'; import { EmailModule } from '../email/email.module'; -import { AuthMiddleware } from './auth.middleware'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { HiveModule } from '../hive/hive.module'; import { HiveChainModule } from '../../repositories/hive-chain/hive-chain.module'; @@ -44,8 +43,4 @@ import { HiveAccountModule } from '../../repositories/hive-account/hive-account. controllers: [AuthController], exports: [AuthService], }) -export class AuthModule implements NestModule { - configure(consumer: MiddlewareConsumer) { - consumer.apply(AuthMiddleware).forRoutes('/v1/auth/login_singleton/did'); - } -} +export class AuthModule {} diff --git a/src/services/auth/auth.service.test.ts b/src/services/auth/auth.service.test.ts new file mode 100644 index 0000000..833fe78 --- /dev/null +++ b/src/services/auth/auth.service.test.ts @@ -0,0 +1,108 @@ +import 'dotenv/config' +import { UserAccountModule } from '../../repositories/userAccount/user-account.module'; +import { SessionModule } from '../../repositories/session/session.module'; +import { AuthController } from './auth.controller'; +import { AuthService } from './auth.service'; +import { Test } from '@nestjs/testing'; +import { JwtModule } from '@nestjs/jwt'; +import { MongooseModule } from '@nestjs/mongoose'; +import { ConfigModule } from '@nestjs/config'; +import { HiveChainModule } from '../../repositories/hive-chain/hive-chain.module'; +import { EmailModule } from '../email/email.module'; +import { AuthModule } from './auth.module'; +import { HiveAccountModule } from '../../repositories/hive-account/hive-account.module'; +import { UserModule } from '../../repositories/user/user.module'; +import { MongoMemoryServer } from 'mongodb-memory-server'; +import { INestApplication, Module } from '@nestjs/common'; +import { TestingModule } from '@nestjs/testing'; +import crypto from 'crypto'; +import { HiveModule } from '../hive/hive.module'; +import { LegacyUserRepository } from '../../repositories/user/user.repository'; + +describe('Auth Service', () => { + let app: INestApplication + let mongod: MongoMemoryServer; + let authService: AuthService; + let legacyUserRepository: LegacyUserRepository + + beforeEach(async () => { + mongod = await MongoMemoryServer.create() + const uri: string = mongod.getUri() + + process.env.JWT_PRIVATE_KEY = crypto.randomBytes(64).toString('hex'); + process.env.DELEGATED_ACCOUNT = 'threespeak'; + process.env.ACCOUNT_CREATOR = 'threespeak'; + + @Module({ + imports: [ + ConfigModule, + MongooseModule.forRoot(uri, { + ssl: false, + authSource: 'threespeak', + readPreference: 'primary', + connectionName: 'threespeak', + dbName: 'threespeak', + autoIndex: true, + }), + MongooseModule.forRoot(uri, { + ssl: false, + authSource: 'threespeak', + readPreference: 'primary', + connectionName: '3speakAuth', + dbName: '3speakAuth', + }), + MongooseModule.forRoot(uri, { + ssl: false, + authSource: 'threespeak', + readPreference: 'primary', + connectionName: 'acela-core', + dbName: 'acela-core', + }), + UserAccountModule, + SessionModule, + HiveAccountModule, + UserModule, + JwtModule.register({ + secretOrPrivateKey: process.env.JWT_PRIVATE_KEY, + signOptions: { expiresIn: '30d' }, + }), + HiveChainModule, + EmailModule, + AuthModule, + HiveModule + ], + controllers: [AuthController], + providers: [AuthService] + }) + class TestModule {} + + let moduleRef: TestingModule; + + moduleRef = await Test.createTestingModule({ + imports: [TestModule], + }).compile(); + authService = moduleRef.get(AuthService); + legacyUserRepository = moduleRef.get(LegacyUserRepository); + app = moduleRef.createNestApplication(); + await app.init() + }) + + afterEach(async () => { + await app.close(); + await mongod.stop(); + }); + + describe('Account Creation', () => { + it('creates a Did account successfully', async () => { + await authService.createDidUser('did:key:z6MkjHhFz9hXYJKGrT5fShwJMzQpHGi63sS3wY3U1eH4n7i5#z6MkjHhFz9hXYJKGrT5fShwJMzQpHGi63sS3wY3U1eH4n7i5', 'test_user_id') + const exists = await authService.didUserExists('did:key:z6MkjHhFz9hXYJKGrT5fShwJMzQpHGi63sS3wY3U1eH4n7i5#z6MkjHhFz9hXYJKGrT5fShwJMzQpHGi63sS3wY3U1eH4n7i5') + expect(exists).toBeTruthy() + }) + + it('Creates a hive account successfully', async () => { + await authService.createHiveUser({ user_id: '1337', hiveAccount: 'sisygoboom' }) + const exists = await legacyUserRepository.findOneBySub('singleton/sisygoboom/hive') + expect(exists).toBeTruthy() + }) + }) +}); \ No newline at end of file diff --git a/src/services/auth/auth.service.ts b/src/services/auth/auth.service.ts index 54e815c..984e32f 100644 --- a/src/services/auth/auth.service.ts +++ b/src/services/auth/auth.service.ts @@ -1,17 +1,24 @@ import 'dotenv/config'; -import { Injectable } from '@nestjs/common'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import bcrypt from 'bcryptjs'; -import { UserAccountRepository } from '../../repositories/userAccount/user-account.repository'; +import { LegacyUserAccountRepository } from '../../repositories/userAccount/user-account.repository'; import { v4 as uuid } from 'uuid'; import { SessionRepository } from '../../repositories/session/session.repository'; import { AccountType, Network, User } from './auth.types'; +import { LegacyUserRepository } from '../../repositories/user/user.repository'; +import { LegacyHiveAccountRepository } from '../../repositories/hive-account/hive-account.repository'; +import { ObjectId } from 'mongodb'; +import { DID } from 'dids'; +import * as KeyDidResolver from 'key-did-resolver'; @Injectable() export class AuthService { constructor( - private readonly userAccountRepository: UserAccountRepository, + private readonly legacyUserAccountRepository: LegacyUserAccountRepository, + private readonly legacyUserRepository: LegacyUserRepository, private readonly sessionRepository: SessionRepository, + private readonly legacyHiveAccountRepository: LegacyHiveAccountRepository, private readonly jwtService: JwtService, ) {} @@ -19,24 +26,42 @@ export class AuthService { return this.jwtService.sign(payload); } - async validateUser(email: string, pass: string): Promise { - const user = await this.userAccountRepository.findOneByEmail(email); - if (user && (await bcrypt.compare(user.password, pass))) { + async validateUser(email: string, pass: string) { + const user = await this.legacyUserAccountRepository.findOneByEmail({ email }); + if (!user || !user.password) { + throw new UnauthorizedException('Email or password was incorrect'); + } + if (await bcrypt.compare(user.password, pass)) { const { password, ...result } = user; return result; } - return null; + throw new UnauthorizedException('Email or password was incorrect'); } - async getOrCreateUserByDid(did: string) { - const user = await this.userAccountRepository.findOneByDid(did); - if (!user) { - return await this.createDidUser(did); + async getOrCreateUserByDid(did: string): Promise<{ sub?: string; user_id: string }> { + const user = await this.legacyUserRepository.findOneBySub(this.generateDidSub(did)); + if (user) { + return { user_id: user.user_id, sub: user.sub }; } + const user_id = uuid(); + const didUser = await this.createDidUser(did, user_id); + return { sub: didUser.sub, user_id }; + } + + async getOrCreateUserByHiveUsername( + username: string, + ): Promise<{ sub?: string; user_id: string }> { + const user = await this.legacyUserRepository.findOneBySub(this.generateHiveSub(username)); + if (user) { + return { user_id: user.user_id, sub: user.sub }; + } + const user_id = uuid(); + const didUser = await this.createHiveUser({ user_id, hiveAccount: username }); + return { sub: didUser.sub, user_id }; } async didUserExists(did: string): Promise { - return Boolean(await this.userAccountRepository.findOneByDid(did)); + return !!(await this.legacyUserRepository.findOneBySub(this.generateDidSub(did))); } async login(user: User) { @@ -45,27 +70,47 @@ export class AuthService { }; } + async getUserByUserId({ user_id }: { user_id: string }) { + return this.legacyUserRepository.findOneByUserId({ user_id }); + } + generateSub(accountType: AccountType, account: string, network: Network) { - return `${accountType}/${network}/${account}`; + return `${accountType}/${account}/${network}`; + } + + generateDidSub(did: string) { + return this.generateSub('singleton', did, 'did'); + } + + generateHiveSub(username: string) { + return this.generateSub('singleton', username, 'hive'); } async authenticateUser(type: AccountType, account: string, network: Network) { - const id = uuid(); + const sub = this.generateSub(type, account, network); + + const user = await this.legacyUserRepository.findOneBySub(sub); + + if (!user) throw new UnauthorizedException('Could not find requested user'); + const access_token = this.jwtSign({ - id: id, + user_id: user.user_id, type, - sub: this.generateSub(type, account, network), - username: account, + sub, network, }); - await this.createSession(type, id, account, network); + await this.createSession(type, user.user_id, account, network); return { access_token, }; } + async authenticateUserByDid(did: string) { + return this.authenticateUser('singleton', did, 'did'); + } + async createSession(type: AccountType, id: string, account: string, network: Network) { return await this.sessionRepository.insertOne({ id, @@ -74,15 +119,77 @@ export class AuthService { }); } - async getSessionByDid(id: string) { - return await this.sessionRepository.findOneBySub(`singleton/did/${id}`); + async getSessionByDid(did: string) { + return await this.sessionRepository.findOneBySub(this.generateDidSub(did)); } - async createEmailAndPasswordUser(email: string, hashedPassword: string) { - return await this.userAccountRepository.createNewEmailAndPasswordUser(email, hashedPassword); + async createEmailAndPasswordUser( + email: string, + password: string, + user_id: string, + ): Promise { + await this.legacyUserRepository.createNewEmailUser({ email, user_id }); + return await this.legacyUserAccountRepository.createNewEmailAndPasswordUser({ + email, + password, + username: user_id, + }); + } + + async createHiveUser({ user_id, hiveAccount }: { user_id: string; hiveAccount: string }) { + const sub = this.generateHiveSub(hiveAccount); + const account = await this.legacyUserRepository.createNewSubUser({ + sub, + user_id, + }); + await this.legacyUserAccountRepository.createOne({ + sub, + hiveAccount, + username: user_id, + }); + await this.linkHiveAccount({ + username: hiveAccount, + user_id: account._id, + }); + return account; + } + + async linkHiveAccount({ user_id, username }: { user_id: ObjectId; username: string }) { + await this.legacyHiveAccountRepository.insertCreated({ + account: username, + user_id, + }); + } + + async unlinkHiveAccount({ user_id, username }: { user_id: ObjectId; username: string }) { + await this.legacyHiveAccountRepository.deleteOne({ + account: username, + user_id, + }); + } + + async createDidUser(did: string, user_id: string) { + const verifier = new DID({ resolver: KeyDidResolver.getResolver() }); + try { + await verifier.resolve(did); + } catch (error) { + console.error('Invalid signature', error); + throw new UnauthorizedException('Invalid signature'); + } + const sub = this.generateDidSub(did); + const account = await this.legacyUserRepository.createNewSubUser({ + sub, + user_id, + }); + await this.legacyUserAccountRepository.createOne({ + sub, + hiveAccount: null, + username: user_id, + }); + return account; } - async createDidUser(did: string) { - return await this.userAccountRepository.createNewDidUser(did); + async verifyEmail(confirmationCode: string): Promise { + return this.legacyUserAccountRepository.verifyEmail({ confirmationCode }); } } diff --git a/src/services/auth/auth.strategy.ts b/src/services/auth/auth.strategy.ts index b61833d..bdd1e63 100644 --- a/src/services/auth/auth.strategy.ts +++ b/src/services/auth/auth.strategy.ts @@ -1,7 +1,7 @@ import { Strategy } from 'passport-jwt'; import { Strategy as StrategyLocal } from 'passport-local'; import { PassportStrategy } from '@nestjs/passport'; -import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { AuthService } from './auth.service'; import { ExtractJwt } from 'passport-jwt'; import 'dotenv/config'; @@ -13,12 +13,8 @@ export class LocalStrategy extends PassportStrategy(StrategyLocal) { super(); } - async validate(email: string, password: string): Promise { - const user = await this.authService.validateUser(email, password); - if (!user) { - throw new UnauthorizedException(); - } - return user; + async validate(email: string, password: string) { + return await this.authService.validateUser(email, password); } } @@ -35,7 +31,6 @@ export class JwtStrategy extends PassportStrategy(Strategy) { } async validate(payload: any) { - console.log(payload); return { id: payload.id, type: payload.type, diff --git a/src/services/auth/auth.types.ts b/src/services/auth/auth.types.ts index 3a56326..9273aef 100644 --- a/src/services/auth/auth.types.ts +++ b/src/services/auth/auth.types.ts @@ -8,10 +8,9 @@ export type AccountType = (typeof accountTypes)[number]; const userSchema = z.object({ sub: z.string(), - username: z.string(), network: z.enum(network), type: z.enum(accountTypes).optional(), - id: z.string().optional(), + user_id: z.string(), }); export const interceptedRequestSchema = z.object({ diff --git a/src/services/hive/hive.module.ts b/src/services/hive/hive.module.ts index 5ebd1d3..6dd4b27 100644 --- a/src/services/hive/hive.module.ts +++ b/src/services/hive/hive.module.ts @@ -2,10 +2,10 @@ import { Module } from '@nestjs/common'; import { HiveChainModule } from '../../repositories/hive-chain/hive-chain.module'; import { HiveService } from './hive.service'; import { HiveAccountModule } from '../../repositories/hive-account/hive-account.module'; -import { LinkedAccountModule } from '../../repositories/linked-accounts/linked-account.module'; +import { UserModule } from '../../repositories/user/user.module'; @Module({ - imports: [HiveAccountModule, HiveChainModule, LinkedAccountModule], + imports: [HiveAccountModule, HiveChainModule, UserModule], providers: [HiveService], exports: [HiveService], }) diff --git a/src/services/hive/hive.service.test.ts b/src/services/hive/hive.service.test.ts index df6fe43..9746955 100644 --- a/src/services/hive/hive.service.test.ts +++ b/src/services/hive/hive.service.test.ts @@ -9,13 +9,17 @@ import crypto from 'crypto'; import { HiveModule } from './hive.module'; import { HiveService } from './hive.service'; import { MongooseModule } from '@nestjs/mongoose'; -import { LinkedAccountModule } from '../../repositories/linked-accounts/linked-account.module'; +import { UserModule } from '../../repositories/user/user.module'; +import { LegacyUserRepository } from '../../repositories/user/user.repository'; +import { LegacyHiveAccountRepository } from '../../repositories/hive-account/hive-account.repository'; describe('AuthController', () => { let app: INestApplication let mongod: MongoMemoryServer; let hiveService: HiveService; + let legacyUserRepository: LegacyUserRepository; + let legacyHiveAccountRepository: LegacyHiveAccountRepository; beforeEach(async () => { mongod = await MongoMemoryServer.create() @@ -44,7 +48,8 @@ describe('AuthController', () => { }), HiveAccountModule, HiveChainModule, - LinkedAccountModule, + HiveAccountModule, + UserModule, HiveModule, ], controllers: [], @@ -58,6 +63,8 @@ describe('AuthController', () => { imports: [TestModule], }).compile(); hiveService = moduleRef.get(HiveService); + legacyUserRepository = moduleRef.get(LegacyUserRepository) + legacyHiveAccountRepository = moduleRef.get(LegacyHiveAccountRepository) app = moduleRef.createNestApplication(); app.useGlobalPipes(new ValidationPipe()); await app.init() @@ -71,7 +78,8 @@ describe('AuthController', () => { describe('Create hive account', () => { it('Creates an account on the happy path', async () => { const sub = 'sad'; - const response = await hiveService.requestHiveAccount('madeupusername77', sub) + const user = await legacyUserRepository.createNewSubUser({ sub, user_id: 'test_user_id' }) + const response = await hiveService.requestHiveAccount('madeupusername77', user!.user_id) expect(response).toEqual({ block_num: 1, @@ -83,15 +91,18 @@ describe('AuthController', () => { it('Fails when a user requests a second account', async () => { const sub = 'sad'; - await hiveService.requestHiveAccount('madeupusername21', sub) - await expect(hiveService.requestHiveAccount('madeupusername77', sub)).rejects.toThrow('Http Exception'); + const user = await legacyUserRepository.createNewSubUser({ sub, user_id: 'test_user_id' }) + await hiveService.requestHiveAccount('madeupusername21', user!.user_id) + await expect(hiveService.requestHiveAccount('madeupusername77', user!.user_id)).rejects.toThrow('Http Exception'); }) }) describe('Vote on a hive post', () => { it('Votes on a post when a hive user is logged in and the vote is authorised', async () => { const sub = 'singleton/sisygoboom/hive'; - const response = await hiveService.vote({ votingAccount: 'sisygoboom', sub, network: 'hive', author: 'ned', permlink: 'sa', weight: 10000 }) + const user = await legacyUserRepository.createNewSubUser({ sub, user_id: 'test_user_id' }) + await legacyHiveAccountRepository.insertCreated({ account: 'sisygoboom', user_id: user!._id }) + const response = await hiveService.vote({ votingAccount: 'sisygoboom', sub, user_id: user!._id, network: 'hive', author: 'ned', permlink: 'sa', weight: 10000 }) expect(response).toEqual({ block_num: 123456, @@ -103,29 +114,18 @@ describe('AuthController', () => { it('Fails when attempting to vote from a different hive account which has not been linked', async () => { const sub = 'singleton/username1/hive'; - - await expect(hiveService.vote({ votingAccount: 'username2', sub, network: 'hive', author: 'ned', permlink: 'sa', weight: 10000 })) + const user = await legacyUserRepository.createNewSubUser({ sub, user_id: 'test_user_id' }) + await legacyHiveAccountRepository.insertCreated({ account: 'username1', user_id: user!._id }) + await expect(hiveService.vote({ votingAccount: 'username2', sub, network: 'hive', author: 'ned', user_id: user!._id, permlink: 'sa', weight: 10000 })) .rejects .toThrow(UnauthorizedException); }); it('Votes on a post when a hive user is logged in and attepts to vote from a linked account', async () => { - const sub = 'singleton/username1/hive'; - await hiveService.insertCreated('username2', sub); - const response = await hiveService.vote({ votingAccount: 'username2', sub, network: 'hive', author: 'ned', permlink: 'sa', weight: 10000 }) - - expect(response).toEqual({ - block_num: 123456, - expired: false, - id: "mock_id", - trx_num: 789, - }) - }); - - it('Votes on a post when a did user is logged in and attepts to vote from a linked account', async () => { - const sub = 'singleton/username1/did'; - await hiveService.insertCreated('username2', sub); - const response = await hiveService.vote({ votingAccount: 'username2', sub, network: 'did', author: 'ned', permlink: 'sa', weight: 10000 }) + const sub = 'singleton/ned/hive' + const user = await legacyUserRepository.createNewSubUser({ sub, user_id: 'test_user_id' }); + await hiveService.insertCreated('username2', user!._id); + const response = await hiveService.vote({ votingAccount: 'username2', user_id: user!._id, sub, network: 'hive', author: 'ned', permlink: 'sa', weight: 10000 }) expect(response).toEqual({ block_num: 123456, @@ -137,10 +137,11 @@ describe('AuthController', () => { it('Throws an error when a vote weight is invalid', async () => { const sub = 'singleton/sisygoboom/hive'; - await expect(hiveService.vote({ votingAccount: 'sisygoboom', sub, network: 'hive', author: 'ned', permlink: 'sa', weight: 10001 })) + const user = await legacyUserRepository.createNewSubUser({ sub, user_id: 'test_user_id' }); + await expect(hiveService.vote({ votingAccount: 'sisygoboom', sub, network: 'hive', author: 'ned', user_id: user!._id, permlink: 'sa', weight: 10001 })) .rejects .toThrow(); - await expect(hiveService.vote({ votingAccount: 'sisygoboom', sub, network: 'hive', author: 'ned', permlink: 'sa', weight: -10001 })) + await expect(hiveService.vote({ votingAccount: 'sisygoboom', sub, network: 'hive', author: 'ned', user_id: user!._id, permlink: 'sa', weight: -10001 })) .rejects .toThrow(); }); diff --git a/src/services/hive/hive.service.ts b/src/services/hive/hive.service.ts index 2540642..8cc2299 100644 --- a/src/services/hive/hive.service.ts +++ b/src/services/hive/hive.service.ts @@ -9,31 +9,32 @@ import { } from '@nestjs/common'; import { HiveChainRepository } from '../../repositories/hive-chain/hive-chain.repository'; import 'dotenv/config'; -import { HiveAccountRepository } from '../../repositories/hive-account/hive-account.repository'; +import { LegacyHiveAccountRepository } from '../../repositories/hive-account/hive-account.repository'; import { Network } from '../auth/auth.types'; import { parseSub } from '../auth/auth.utils'; -import { LinkedAccountRepository } from '../../repositories/linked-accounts/linked-account.repository'; -import { LinkedAccount } from '../../repositories/linked-accounts/schemas/linked-account.schema'; +import { ObjectId } from 'mongodb'; +import { LegacyUserRepository } from '../../repositories/user/user.repository'; @Injectable() export class HiveService { readonly #hiveChainRepository: HiveChainRepository; - readonly #hiveAccountRepository: HiveAccountRepository; - readonly #linkedAccountsRepository: LinkedAccountRepository; + readonly #legacyHiveAccountRepository: LegacyHiveAccountRepository; + readonly #legacyUserRepository: LegacyUserRepository; readonly #logger: LoggerService = new Logger(HiveService.name); constructor( - hiveAccountRepository: HiveAccountRepository, + legacyHiveAccountRepository: LegacyHiveAccountRepository, hiveChainRepository: HiveChainRepository, - linkedAccountRepository: LinkedAccountRepository, + legacyUserRepository: LegacyUserRepository, ) { this.#hiveChainRepository = hiveChainRepository; - this.#hiveAccountRepository = hiveAccountRepository; - this.#linkedAccountsRepository = linkedAccountRepository; + this.#legacyHiveAccountRepository = legacyHiveAccountRepository; + this.#legacyUserRepository = legacyUserRepository; } async vote({ votingAccount, + user_id, sub, author, permlink, @@ -41,6 +42,7 @@ export class HiveService { network, }: { votingAccount: string; + user_id: ObjectId; sub: string; author: string; permlink: string; @@ -48,14 +50,16 @@ export class HiveService { network: Network; }) { // TODO: investigate how this could be reused on other methods that access accounts onchain - if (network === 'hive' && parseSub(sub).account === votingAccount) { + const parsedSub = parseSub(sub); + if (parsedSub.network === 'hive' && parsedSub.account === votingAccount) { return this.#hiveChainRepository.vote({ author, permlink, voter: votingAccount, weight }); } - const delegatedAuth = await this.#hiveAccountRepository.findOneByOwnerIdAndHiveAccountName({ - account: votingAccount, - user_id: sub, - }); + const delegatedAuth = + await this.#legacyHiveAccountRepository.findOneByOwnerIdAndHiveAccountName({ + account: votingAccount, + user_id, + }); if (!delegatedAuth) { throw new UnauthorizedException('You have not verified ownership of the target account'); @@ -64,26 +68,29 @@ export class HiveService { return this.#hiveChainRepository.vote({ author, permlink, voter: votingAccount, weight }); } - async requestHiveAccount(hiveUsername: string, sub: string) { - const existingDbAcocunt = await this.#hiveAccountRepository.findOneByOwnerId({ - user_id: sub, - }); + async requestHiveAccount(hiveUsername: string, user_id: string) { + const linkedAccounts = await this.#legacyUserRepository.getLegacyLinkedHiveAccounts(user_id); - if (existingDbAcocunt) { + if (!linkedAccounts) { + throw new NotFoundException("Account couldn't be found"); + } + + if (linkedAccounts.linked_hiveaccounts.length) { throw new HttpException( - { reason: 'You have already created the maximum of 1 free Hive account' }, + { reason: 'You have already linked a hive account, so cannot claim a free one.' }, HttpStatus.BAD_REQUEST, ); } - const accountCreation = await this.#createAccountWithAuthority(hiveUsername, sub); - - await this.insertCreated(hiveUsername, sub); + const accountCreation = await this.#createAccountWithAuthority( + hiveUsername, + linkedAccounts._id, + ); return accountCreation; } - async #createAccountWithAuthority(hiveUsername: string, sub: string) { + async #createAccountWithAuthority(hiveUsername: string, user_id: ObjectId) { if (!process.env.ACCOUNT_CREATOR) { throw new Error('Please set the ACCOUNT_CREATOR env var'); } @@ -92,22 +99,33 @@ export class HiveService { hiveUsername, process.env.ACCOUNT_CREATOR, ); - await this.#linkedAccountsRepository.linkHiveAccount(sub, hiveUsername); + await this.#legacyHiveAccountRepository.insertCreated({ account: hiveUsername, user_id }); return accountWithAuthority; } catch (ex) { throw new HttpException({ reason: `On chain error - ${ex.message}` }, HttpStatus.BAD_REQUEST); } } - async insertCreated(hiveUsername: string, sub: string) { - await this.#hiveAccountRepository.insertCreated(hiveUsername, sub); + async insertCreated(hiveUsername: string, user_id: ObjectId) { + await this.#legacyHiveAccountRepository.insertCreated({ account: hiveUsername, user_id }); } - async linkHiveAccount(sub: string, hiveUsername: string, proof: string): Promise { - const linkedAccount = await this.#linkedAccountsRepository.findOneByUserIdAndAccountName({ - user_id: sub, - account: hiveUsername, - }); + async linkHiveAccount({ + db_user_id, + user_id, + hiveUsername, + proof, + }: { + db_user_id: ObjectId; + user_id: string; + hiveUsername: string; + proof: string; + }) { + const linkedAccount = + await this.#legacyHiveAccountRepository.findOneByOwnerIdAndHiveAccountName({ + user_id: db_user_id, + account: hiveUsername, + }); if (linkedAccount) { throw new HttpException({ reason: 'Hive account already linked' }, HttpStatus.BAD_REQUEST); } @@ -115,41 +133,50 @@ export class HiveService { if (!hiveAccount) throw new NotFoundException(`Requested hive account (${hiveAccount}) could not be found.`); await this.#hiveChainRepository.verifyHiveMessage( - `${sub} is the owner of @${hiveUsername}`, + `${user_id} is the owner of @${hiveUsername}`, proof, hiveAccount, ); - return (await this.#linkedAccountsRepository.linkHiveAccount( - sub, - hiveUsername, - )) satisfies LinkedAccount; + return await this.#legacyHiveAccountRepository.insertCreated({ + account: hiveUsername, + user_id: db_user_id, + }); } - async isHiveAccountLinked(sub: string, accountName: string) { - return !!(await this.#linkedAccountsRepository.findOneByUserIdAndAccountName({ - user_id: sub, - account: accountName, + async isHiveAccountLinked({ user_id, account }: { user_id: ObjectId; account: string }) { + return !!(await this.#legacyHiveAccountRepository.findOneByOwnerIdAndHiveAccountName({ + user_id, + account, })); } - async subAuthorizedToUseHiveAccount({ + async authorizedToUseHiveAccount({ hiveAccount, + user_id, sub, }: { hiveAccount: string; - sub: string; + user_id: string; + sub?: string; }): Promise { - const user = parseSub(sub); - if (user.account !== hiveAccount || user.network !== 'hive') { - const hasLinkedAccount = await this.#linkedAccountsRepository.findOneByUserIdAndAccountName({ - user_id: sub, + const dbUser = await this.#legacyUserRepository.findOneByUserId({ user_id }); + if (!dbUser) throw new UnauthorizedException('User does not exist'); + if (sub) { + const user = parseSub(sub); + if (user.account === hiveAccount || user.network === 'hive') { + return; + } + } + const hasLinkedAccount = + await this.#legacyHiveAccountRepository.findOneByOwnerIdAndHiveAccountName({ + user_id: dbUser._id, account: hiveAccount, }); - if (!hasLinkedAccount) { - throw new UnauthorizedException( - 'you are not logged in or do not have a link to this hive account', - ); - } + if (hasLinkedAccount) { + return; } + throw new UnauthorizedException( + 'you are not logged in or do not have a link to this hive account', + ); } } diff --git a/src/services/uploader/uploading.controller.test.ts b/src/services/uploader/uploading.controller.test.ts index d7c45c2..774402b 100644 --- a/src/services/uploader/uploading.controller.test.ts +++ b/src/services/uploader/uploading.controller.test.ts @@ -22,13 +22,14 @@ import { JwtModule } from '@nestjs/jwt'; import crypto from 'crypto'; import { MockHiveUserDetailsInterceptor, UserDetailsInterceptor } from '../api/utils'; import { HiveModule } from '../hive/hive.module'; -import { LinkedAccountRepository } from '../../repositories/linked-accounts/linked-account.repository'; +import { AuthService } from '../auth/auth.service'; +import { AuthModule } from '../auth/auth.module'; describe('UploadingController', () => { let app: INestApplication; let mongod: MongoMemoryServer; let uploadingService: UploadingService; - let linkedAccountsRepository: LinkedAccountRepository; + let authService: AuthService; beforeEach(async () => { mongod = await MongoMemoryServer.create(); @@ -54,8 +55,16 @@ describe('UploadingController', () => { connectionName: 'acela-core', dbName: 'acela-core', }), + MongooseModule.forRoot(uri, { + ssl: false, + authSource: 'threespeak', + readPreference: 'primary', + connectionName: '3speakAuth', + dbName: '3speakAuth', + }), VideoModule, HiveChainModule, + AuthModule, HiveModule, UploadModule, IpfsModule, @@ -87,7 +96,7 @@ describe('UploadingController', () => { .compile(); uploadingService = moduleRef.get(UploadingService); - linkedAccountsRepository = moduleRef.get(LinkedAccountRepository); + authService = moduleRef.get(AuthService); app = moduleRef.createNestApplication(); app.useGlobalPipes(new ValidationPipe()); await app.init(); @@ -129,8 +138,9 @@ describe('UploadingController', () => { describe('createUpload', () => { it('should create an upload document', async () => { - await linkedAccountsRepository.linkHiveAccount('singleton/starkerz/hive', 'sisygoboom'); - const response = await uploadingService.createUpload({ sub: 'singleton/starkerz/hive', username: 'sisygoboom' }) + const didUser = await authService.createDidUser('did:key:z6MkjHhFz9hXYJKGrT5fShwJMzQpHGi63sS3wY3U1eH4n7i5#z6MkjHhFz9hXYJKGrT5fShwJMzQpHGi63sS3wY3U1eH4n7i5', 'test_id') + await authService.linkHiveAccount({ user_id: didUser._id, username: 'sisygoboom' }); + const response = await uploadingService.createUpload({ sub: 'singleton/starkerz/hive', username: 'sisygoboom', user_id: didUser.user_id }) expect(response).toEqual({ permlink: expect.any(String), upload_id: expect.any(String), @@ -150,32 +160,63 @@ describe('UploadingController', () => { describe('Start encode', () => { it('Should encode successfully when logged in with a hive account', async () => { const jwtToken = 'test_jwt_token'; - - const upload = await uploadingService.createUpload({ sub: 'singleton/starkerz/hive', username: 'starkerz' }); - - process.env.DELEGATED_ACCOUNT = 'threespeak' - + + const user = await authService.createDidUser( + 'did:key:z6MkjHhFz9hXYJKGrT5fShwJMzQpHGi63sS3wY3U1eH4n7i5#z6MkjHhFz9hXYJKGrT5fShwJMzQpHGi63sS3wY3U1eH4n7i5', + 'test_user_id' + ); + await authService.linkHiveAccount({ user_id: user._id, username: 'sisygoboom' }); + if (!user.sub) throw new Error('No sub on user'); + + const upload = await uploadingService.createUpload({ + sub: user.sub, + username: 'sisygoboom', + user_id: user.user_id + }); + + const updateUploadDto = { + video_id: upload.video_id, + permlink: upload.permlink, + title: 'Test Video Title', + body: 'This video is a test video. Here we can put a description', + tags: ['threespeak', 'acela-core'], + community: 'hive-181335', + beneficiaries: '[]', + language: 'en', + originalFilename: 'test-video.mp4', + filename: 'e1e7903087f9c39ac1645d69f5bb96cd', + size: 32330, + duration: 98, + }; + + await uploadingService.postUpdate(updateUploadDto); + + process.env.DELEGATED_ACCOUNT = 'threespeak'; + return request(app.getHttpServer()) .post('/v1/upload/start_encode') .set('Authorization', `Bearer ${jwtToken}`) .send({ upload_id: upload.upload_id, video_id: upload.video_id, - permlink: upload.permlink + permlink: upload.permlink, + username: 'sisygoboom' }) .expect(201) .then(response => { expect(response.body).toEqual({}); }); - }) + }); it('Should encode successfully when requesting use of a linked hive account', async () => { const jwtToken = 'test_jwt_token'; - await linkedAccountsRepository.linkHiveAccount('singleton/starkerz/hive', 'sisygoboom'); - await linkedAccountsRepository.linkHiveAccount('singleton/ned/hive', 'sisygoboom'); + const starkerzUser = await authService.createHiveUser({ hiveAccount: 'starkerz', user_id: 'test_user_id_other' }); + const nedUser = await authService.createHiveUser({ hiveAccount: 'ned', user_id: 'test_user_id' }); + await authService.linkHiveAccount({ user_id: starkerzUser._id, username: 'sisygoboom' }); + await authService.linkHiveAccount({ user_id: nedUser._id, username: 'sisygoboom' }); - const upload = await uploadingService.createUpload({ sub: 'singleton/ned/hive', username: 'sisygoboom' }); + const upload = await uploadingService.createUpload({ sub: 'singleton/ned/hive', username: 'sisygoboom', user_id: nedUser.user_id }); process.env.DELEGATED_ACCOUNT = 'threespeak' @@ -197,11 +238,12 @@ describe('UploadingController', () => { it('Should fail when requesting use of an unlinked hive account', async () => { const jwtToken = 'test_jwt_token'; - await linkedAccountsRepository.linkHiveAccount('singleton/starkerz/hive', 'sisygoboom') + const starkerzUser = await authService.createDidUser('did:key:z6MkjHhFz9hXYJKGrT5fShwJMzQpHGi63sS3wY3U1eH4n7i5#z6MkjHhFz9hXYJKGrT5fShwJMzQpHGi63sS3wY3U1eH4n7i5', 'test_user_id'); + await authService.linkHiveAccount({ user_id: starkerzUser._id, username: 'sisygoboom' }); - const upload = await uploadingService.createUpload({ sub: 'singleton/starkerz/hive', username: 'sisygoboom' }); + const upload = await uploadingService.createUpload({ sub: 'singleton/did:key:z6MkjHhFz9hXYJKGrT5fShwJMzQpHGi63sS3wY3U1eH4n7i5#z6MkjHhFz9hXYJKGrT5fShwJMzQpHGi63sS3wY3U1eH4n7i5/dids', username: 'sisygoboom', user_id: starkerzUser.user_id }); - await linkedAccountsRepository.unlinkHiveAccount('singleton/starkerz/hive', 'sisygoboom') + await authService.unlinkHiveAccount({ user_id: starkerzUser._id, username: 'sisygoboom' }) process.env.DELEGATED_ACCOUNT = 'threespeak' @@ -227,7 +269,9 @@ describe('UploadingController', () => { it('Should fail if the upload details are falsified', async () => { const jwtToken = 'test_jwt_token'; - await linkedAccountsRepository.linkHiveAccount('singleton/starkerz/hive', 'sisygoboom'); + const user = await authService.createDidUser('did:key:z6MkjHhFz9hXYJKGrT5fShwJMzQpHGi63sS3wY3U1eH4n7i5#z6MkjHhFz9hXYJKGrT5fShwJMzQpHGi63sS3wY3U1eH4n7i5', 'test_user_id'); + if (!user.sub) throw new Error('no sub') + await authService.linkHiveAccount({ user_id: user._id, username: 'sisygoboom' }); process.env.DELEGATED_ACCOUNT = 'threespeak' @@ -240,12 +284,12 @@ describe('UploadingController', () => { video_id: 'random', permlink: 'random' }) - .expect(400) + .expect(404) .then(response => { expect(response.body).toEqual({ - error: "Bad Request", + error: "Not Found", message: "No upload could be found matching that owner and permlink combination", - statusCode: 400, + statusCode: 404, }); }); }) @@ -253,7 +297,10 @@ describe('UploadingController', () => { it('Should fail if the account is not linked', async () => { const jwtToken = 'test_jwt_token'; - const upload = await uploadingService.createUpload({ sub: 'singleton/starkerz/hive', username: 'starkerz' }); + const user = await authService.createDidUser('did:key:z6MkjHhFz9hXYJKGrT5fShwJMzQpHGi63sS3wY3U1eH4n7i5#z6MkjHhFz9hXYJKGrT5fShwJMzQpHGi63sS3wY3U1eH4n7i5', 'test_user_id'); + if (!user.sub) throw new Error('no sub') + await authService.linkHiveAccount({ user_id: user._id, username: 'starkerz' }) + const upload = await uploadingService.createUpload({ sub: user.sub, username: 'starkerz', user_id: user.user_id }); process.env.DELEGATED_ACCOUNT = 'threespeak' diff --git a/src/services/uploader/uploading.controller.ts b/src/services/uploader/uploading.controller.ts index d1118ca..032b7db 100644 --- a/src/services/uploader/uploading.controller.ts +++ b/src/services/uploader/uploading.controller.ts @@ -29,9 +29,10 @@ import { StartEncodeDto } from './dto/start-encode.dto'; import { UploadingService } from './uploading.service'; import { HiveChainRepository } from '../../repositories/hive-chain/hive-chain.repository'; import { Upload } from './uploading.types'; -import { parseAndValidateRequest } from '../auth/auth.utils'; +import { parseAndValidateRequest, parseSub } from '../auth/auth.utils'; import { HiveService } from '../hive/hive.service'; import { CreateUploadDto } from './dto/create-upload.dto'; +import { AuthService } from '../auth/auth.service'; MulterModule.registerAsync({ useFactory: () => ({ @@ -47,6 +48,7 @@ export class UploadingController { private readonly uploadingService: UploadingService, private readonly hiveChainRepository: HiveChainRepository, private readonly hiveService: HiveService, + private readonly authService: AuthService, ) {} @ApiConsumes('multipart/form-data', 'application/json') @@ -87,14 +89,14 @@ export class UploadingController { @Body() body: CreateUploadDto, ) { const parsedRequest = parseAndValidateRequest(request, this.#logger); + const { account } = parseSub(parsedRequest.user.sub); const hiveUsername = - body.username || parsedRequest.user.network === 'hive' - ? parsedRequest.user.username - : undefined; + body.username || parsedRequest.user.network === 'hive' ? account : undefined; if (!hiveUsername) throw new BadRequestException('No username provided'); return this.uploadingService.createUpload({ sub: parsedRequest.user.sub, - username: body.username || parsedRequest.user.username, + username: hiveUsername, + user_id: parsedRequest.user.user_id, }); } @@ -103,28 +105,31 @@ export class UploadingController { @Post('start_encode') async startEncode(@Body() body: StartEncodeDto, @Request() req) { const request = parseAndValidateRequest(req, this.#logger); - const hiveUsername = - body.username || (request.user.network === 'hive' ? request.user.username : undefined); + const { account } = parseSub(request.user.sub); + const hiveUsername = body.username || (request.user.network === 'hive' ? account : undefined); if (!hiveUsername) { throw new BadRequestException( 'Must be signed in with a hive account or include a linked hive account in the request', ); } + + const user = await this.authService.getUserByUserId({ user_id: request.user.user_id }); + + if (!user) { + throw new UnauthorizedException('User not found'); + } + if ( - !(await this.hiveService.isHiveAccountLinked(request.user.sub, hiveUsername)) && - !(request.user.username === hiveUsername && request.user.network === 'hive') + !(await this.hiveService.isHiveAccountLinked({ + account: hiveUsername, + user_id: user._id, + })) && + !(account === hiveUsername && request.user.network === 'hive') ) { throw new UnauthorizedException('Your account is not linked to the requested hive account'); } - const accountDetails = await this.hiveChainRepository.getAccount(hiveUsername); - if (!accountDetails) throw new NotFoundException('Hive account could not be found'); - // Check 1: Do we have posting authority? - if (this.hiveChainRepository.verifyPostingAuth(accountDetails) === false) { - const reason = `Hive Account @${hiveUsername} has not granted posting authority to @threespeak`; - const errorType = 'MISSING_POSTING_AUTHORITY'; - throw new HttpException({ reason: reason, errorType: errorType }, HttpStatus.FORBIDDEN); - } - // Check 2: Is post title too big or too small? + + this.#logger.debug('Checking video title length', request, body); const videoTitleLength = await this.uploadingService.getVideoTitleLength( body.permlink, hiveUsername, @@ -141,7 +146,7 @@ export class UploadingController { HttpStatus.BAD_REQUEST, ); } - // Check 3: Is this post already published? + this.#logger.debug('Checking if post is already published', request, body); const postExists = await this.hiveChainRepository.hivePostExists({ author: hiveUsername, permlink: body.permlink, @@ -152,7 +157,7 @@ export class UploadingController { HttpStatus.BAD_REQUEST, ); } - // Check 4: Does user have enough RC? + this.#logger.debug('Checking if there are enough RCs', request, body); const hasEnoughRC = await this.hiveChainRepository.hasEnoughRC({ author: hiveUsername }); if (!hasEnoughRC) { throw new HttpException( @@ -160,7 +165,10 @@ export class UploadingController { HttpStatus.BAD_REQUEST, ); } - // All check went well? let's encode & publish + const accountDetails = await this.hiveChainRepository.getAccount(hiveUsername); + if (!accountDetails) throw new NotFoundException('Hive account could not be found'); + + this.#logger.debug('All checks passed, starting encode', request, body); return await this.uploadingService.startEncode( body.upload_id, body.video_id, diff --git a/src/services/uploader/uploading.module.ts b/src/services/uploader/uploading.module.ts index a9c77d2..73e1914 100644 --- a/src/services/uploader/uploading.module.ts +++ b/src/services/uploader/uploading.module.ts @@ -11,6 +11,7 @@ import { JwtModule } from '@nestjs/jwt'; import { UserDetailsInterceptor } from '../api/utils'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { HiveModule } from '../hive/hive.module'; +import { AuthModule } from '../auth/auth.module'; @Module({ imports: [ @@ -18,6 +19,7 @@ import { HiveModule } from '../hive/hive.module'; VideoModule, IpfsModule, HiveModule, + AuthModule, PublishingModule, HiveChainModule, JwtModule.registerAsync({ diff --git a/src/services/uploader/uploading.service.ts b/src/services/uploader/uploading.service.ts index a7e4520..62407f9 100644 --- a/src/services/uploader/uploading.service.ts +++ b/src/services/uploader/uploading.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable, NotFoundException } from '@nestjs/common'; import { VideoRepository } from '../../repositories/video/video.repository'; import { UploadRepository } from '../../repositories/upload/upload.repository'; import { PublishingService } from '../../services/publishing/publishing.service'; @@ -9,6 +9,7 @@ import ffmpeg from 'fluent-ffmpeg'; import { Upload } from './uploading.types'; import { v4 as uuid } from 'uuid'; import { HiveService } from '../hive/hive.service'; +import { User } from '../auth/auth.types'; @Injectable() export class UploadingService { @@ -20,11 +21,7 @@ export class UploadingService { private readonly hiveService: HiveService, ) {} - async uploadThumbnail( - file: any, - video_id: string, - user: { sub: string; username: string; id?: string }, - ) { + async uploadThumbnail(file: any, video_id: string, user: User) { const id = uuid(); const { cid }: { cid: string } = await this.ipfsService.addData( @@ -48,14 +45,23 @@ export class UploadingService { return cid; } - async createUpload({ sub, username }: { sub: string; username: string }) { - await this.hiveService.subAuthorizedToUseHiveAccount({ + async createUpload({ + sub, + username, + user_id, + }: { + sub: string; + username: string; + user_id: string; + }) { + await this.hiveService.authorizedToUseHiveAccount({ sub, hiveAccount: username, + user_id, }); const video = await this.videoRepository.createNewHiveVideoPost({ - sub, + user_id, username, title: ' ', description: ' ', @@ -70,7 +76,7 @@ export class UploadingService { const upload = await this.uploadRepository.insertOne({ video_id: video.video_id, expires: moment().add('1', 'day').toDate(), - created_by: sub, + created_by: user_id, ipfs_status: 'pending', type: 'video', immediatePublish: false, @@ -122,7 +128,7 @@ export class UploadingService { async getVideoTitleLength(permlink: string, owner: string): Promise { const publishData = await this.videoRepository.getVideoToPublish(owner, permlink); if (!publishData) { - throw new BadRequestException( + throw new NotFoundException( 'No upload could be found matching that owner and permlink combination', ); } diff --git a/src/services/video-process/video.process.service.ts b/src/services/video-process/video.process.service.ts index 0f50884..1903f28 100644 --- a/src/services/video-process/video.process.service.ts +++ b/src/services/video-process/video.process.service.ts @@ -35,7 +35,7 @@ export class VideoProcessService { `${this.#configService.get('ENCODER_API')}/api/v0/gateway/jobstatus/${upload.encode_id}`, ); - console.log(data); + this.#logger.log(data); if (data.job.status === 'complete') { await this.#uploadRepository.setJobToDone(upload._id, data.job.result.cid); } @@ -131,7 +131,7 @@ export class VideoProcessService { try { await this.initS3(); } catch (ex) { - console.log(ex); + this.#logger.fatal(ex); } } }