From 592f77270aef724d02a2b05bd89a6fc60913a35d Mon Sep 17 00:00:00 2001 From: Christopher Graney-Ward Date: Fri, 12 Jul 2024 20:18:25 +0100 Subject: [PATCH 1/6] Add data validation Dto to endpoint --- src/services/auth/auth.controller.ts | 34 +++++++++++++--------- src/services/auth/dto/EmailRegister.dto.ts | 20 +++++++++++++ 2 files changed, 41 insertions(+), 13 deletions(-) create mode 100644 src/services/auth/dto/EmailRegister.dto.ts diff --git a/src/services/auth/auth.controller.ts b/src/services/auth/auth.controller.ts index 859b71e..07345ef 100644 --- a/src/services/auth/auth.controller.ts +++ b/src/services/auth/auth.controller.ts @@ -45,6 +45,7 @@ import { HiveService } from '../hive/hive.service'; import { AuthInterceptor, UserDetailsInterceptor } from '../api/utils'; import { randomUUID } from 'crypto'; import { v4 as uuid } from 'uuid'; +import { EmailRegisterDto } from './dto/EmailRegister.dto'; @Controller('/v1/auth') export class AuthController { @@ -308,32 +309,40 @@ export class AuthController { summary: 'Registers an account using email/password login', }) @ApiBody({ + type: EmailRegisterDto, + }) + @ApiOkResponse({ schema: { properties: { - password: { - type: 'string', - default: '!SUPER-SECRET_PASSWORD!', - }, - email: { - type: 'string', - default: 'test@invalid.example.org', + ok: { + type: 'boolean', + default: true, }, }, }, }) - @ApiOkResponse({ + @ApiBadRequestResponse({ + description: 'Invalid options or email already registered', schema: { + type: 'object', properties: { - ok: { - type: 'boolean', - default: true, + reason: { + type: 'string', + default: 'Invalid email or password', + }, + errorType: { + type: 'string', + default: 'VALIDATION_ERROR', }, }, }, }) + @ApiInternalServerErrorResponse({ + description: 'Internal Server Error - unrelated to request body', + }) // @UseGuards(AuthGuard('local')) @Post('/register') - async register(@Request() req, @Body() body: { password: string; email: string }) { + async register(@Body() body: EmailRegisterDto) { const { email, password } = body; const existingRecord = await this.userRepository.findOneByEmail(email); @@ -353,7 +362,6 @@ export class AuthController { return { ok: true, }; - // return this.authService.login(req.user); } @ApiParam({ diff --git a/src/services/auth/dto/EmailRegister.dto.ts b/src/services/auth/dto/EmailRegister.dto.ts new file mode 100644 index 0000000..948a59d --- /dev/null +++ b/src/services/auth/dto/EmailRegister.dto.ts @@ -0,0 +1,20 @@ +import { IsEmail, IsNotEmpty, IsStrongPassword, Matches } from 'class-validator'; + +export class EmailRegisterDto { + @IsEmail() + @Matches(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/, { + message: 'Email must be a valid email address', + }) + @IsNotEmpty() + email: string; + + @IsStrongPassword({ + minLength: 7, + minLowercase: 1, + minUppercase: 1, + minNumbers: 1, + minSymbols: 1, + }) + @IsNotEmpty() + password: string; +} From 7ae0bd6be31ea4b3fb62e9b291e77d7995542c78 Mon Sep 17 00:00:00 2001 From: Christopher Graney-Ward Date: Fri, 12 Jul 2024 20:19:26 +0100 Subject: [PATCH 2/6] Create tests --- src/services/auth/auth.controller.test.ts | 99 +++++++++++++++++-- .../uploader/uploading.controller.test.ts | 2 - 2 files changed, 89 insertions(+), 12 deletions(-) diff --git a/src/services/auth/auth.controller.test.ts b/src/services/auth/auth.controller.test.ts index 7c8c549..1132dcb 100644 --- a/src/services/auth/auth.controller.test.ts +++ b/src/services/auth/auth.controller.test.ts @@ -1,4 +1,3 @@ -import 'dotenv/config' import { UserAccountModule } from '../../repositories/userAccount/user-account.module'; import { SessionModule } from '../../repositories/session/session.module'; import { AuthController } from './auth.controller'; @@ -19,12 +18,15 @@ import { Ed25519Provider } from 'key-did-provider-ed25519'; import { INestApplication, Module, ValidationPipe } from '@nestjs/common'; import * as KeyResolver from 'key-did-resolver'; import { TestingModule } from '@nestjs/testing'; -import crypto from 'crypto'; +import crypto, { randomUUID } from 'crypto'; import { PrivateKey } from '@hiveio/dhive'; import { AuthGuard } from '@nestjs/passport'; import { MockAuthGuard, MockDidUserDetailsInterceptor, UserDetailsInterceptor } from '../api/utils'; import { HiveService } from '../hive/hive.service'; import { HiveModule } from '../hive/hive.module'; +import { LegacyUserRepository } from '../../repositories/user/user.repository'; +import { EmailService } from '../email/email.service'; +import { LegacyUserAccountRepository } from '../../repositories/userAccount/user-account.repository'; describe('AuthController', () => { let app: INestApplication @@ -35,11 +37,13 @@ describe('AuthController', () => { let mongod: MongoMemoryServer; let authService: AuthService; let hiveService: HiveService; - + let userRepository: LegacyUserRepository; + let emailService: EmailService; + let userAccountRepository: LegacyUserAccountRepository; beforeEach(async () => { - mongod = await MongoMemoryServer.create() - const uri: string = mongod.getUri() + mongod = await MongoMemoryServer.create(); + const uri: string = mongod.getUri(); process.env.JWT_PRIVATE_KEY = crypto.randomBytes(64).toString('hex'); process.env.DELEGATED_ACCOUNT = 'threespeak'; @@ -88,21 +92,24 @@ describe('AuthController', () => { }) class TestModule {} - let moduleRef: TestingModule; - - moduleRef = await Test.createTestingModule({ + let moduleRef = await Test.createTestingModule({ imports: [TestModule], }).overrideGuard(AuthGuard('jwt')) .useClass(MockAuthGuard) .overrideInterceptor(UserDetailsInterceptor) .useClass(MockDidUserDetailsInterceptor) .compile(); + authService = moduleRef.get(AuthService); hiveService = moduleRef.get(HiveService); + userRepository = moduleRef.get(LegacyUserRepository); + userAccountRepository = moduleRef.get(LegacyUserAccountRepository); + emailService = moduleRef.get(EmailService); + app = moduleRef.createNestApplication(); app.useGlobalPipes(new ValidationPipe()); - await app.init() - }) + await app.init(); + }); afterEach(async () => { await app.close(); @@ -183,7 +190,78 @@ describe('AuthController', () => { expect(await hiveService.isHiveAccountLinked({ account: 'bob', user_id: user._id })).toBe(true) expect(await hiveService.isHiveAccountLinked({ account: username, user_id: user._id })).toBe(false) }); + }); + }); + + describe('/POST register', () => { + it('registers a new user successfully with valid email', async () => { + const email = 'test@invalid.example.org'; + const password = '!SUPER-SECRET_password!7'; + + const response = await request(app.getHttpServer()) + .post('/v1/auth/register') + .send({ email, password }) + .expect(201); + + expect(response.body).toEqual({ ok: true }); + + const user = await userRepository.findOneByEmail(email); + expect(user).toBeDefined(); + }); + + it('throws error when email is already registered', async () => { + const email = 'test@invalid.example.org'; + const password = '!SUPER-SECRET_password!7'; + + // Create a user with the same email + await authService.createEmailAndPasswordUser(email, password, randomUUID()); + + return request(app.getHttpServer()) + .post('/v1/auth/register') + .send({ email, password }) + .expect(400) + .then(response => { + expect(response.body).toEqual({ reason: 'Email Password account already created!' }); + }); }); + + it('throws error when email is invalid', async () => { + const email = 'tesnvalid.example.org'; + const password = '!SUPER-SECRET_password!7'; + + return request(app.getHttpServer()) + .post('/v1/auth/register') + .send({ email, password }) + .expect(400) + .then(response => { + expect(response.body).toEqual({ + error: "Bad Request", + message: [ + "Email must be a valid email address", + "email must be an email", + ], + statusCode: 400 + }); + }); + }); + + it('throws error when password is not strong enough', async () => { + const email = 'test@invalid.example.org'; + const password = '!SUPER-SECRET_password!'; + + return request(app.getHttpServer()) + .post('/v1/auth/register') + .send({ email, password }) + .expect(400) + .then(response => { + expect(response.body).toEqual({ + error: "Bad Request", + message: [ + "password is not strong enough", + ], + statusCode: 400 + }); + }); }); @@ -308,6 +386,7 @@ describe('AuthController', () => { statusCode: 401, }) }) + }) }) }) }); \ No newline at end of file diff --git a/src/services/uploader/uploading.controller.test.ts b/src/services/uploader/uploading.controller.test.ts index 5ff212a..e619758 100644 --- a/src/services/uploader/uploading.controller.test.ts +++ b/src/services/uploader/uploading.controller.test.ts @@ -9,8 +9,6 @@ import { UploadingModule } from './uploading.module'; import request from 'supertest'; import { MongoMemoryServer } from 'mongodb-memory-server'; import { INestApplication, Module, ValidationPipe } from '@nestjs/common'; -import * as fs from 'fs'; -import * as path from 'path'; import { AuthGuard } from '@nestjs/passport'; import { UploadModule } from '../../repositories/upload/upload.module'; import { VideoModule } from '../../repositories/video/video.module'; From 5f5695b01f13db05df0536144bc24011ddaa418d Mon Sep 17 00:00:00 2001 From: Christopher Graney-Ward Date: Fri, 12 Jul 2024 20:20:43 +0100 Subject: [PATCH 3/6] Enforce email uniqueness and move validation to the repository level --- src/repositories/user/schemas/user.schema.ts | 4 +-- .../schemas/user-account.schema.ts | 4 +-- src/services/auth/auth.controller.ts | 10 ------ src/services/auth/auth.service.ts | 36 +++++++++++++++---- 4 files changed, 33 insertions(+), 21 deletions(-) diff --git a/src/repositories/user/schemas/user.schema.ts b/src/repositories/user/schemas/user.schema.ts index db82e10..934084c 100644 --- a/src/repositories/user/schemas/user.schema.ts +++ b/src/repositories/user/schemas/user.schema.ts @@ -10,13 +10,13 @@ export class LegacyUser { @Prop({ type: String, required: true }) user_id!: string; - @Prop({ type: String, unique: true }) + @Prop({ type: String, unique: true, sparse: true }) sub?: string; @Prop({ type: Boolean, required: true, default: false }) banned?: boolean; - @Prop({ type: String }) + @Prop({ type: String, unique: true, sparse: true }) email?: string; @Prop({ type: Types.ObjectId, ref: LegacyHiveAccount.name }) diff --git a/src/repositories/userAccount/schemas/user-account.schema.ts b/src/repositories/userAccount/schemas/user-account.schema.ts index cead168..4f6d2c4 100644 --- a/src/repositories/userAccount/schemas/user-account.schema.ts +++ b/src/repositories/userAccount/schemas/user-account.schema.ts @@ -6,13 +6,13 @@ export type LegacyUserDocument = mongoose.Document & LegacyUserAccount; @Schema() export class LegacyUserAccount { - @Prop({ type: String, required: false }) + @Prop({ type: String, default: () => uuid() }) confirmationCode?: string; @Prop({ type: Date, required: true, default: new Date() }) createdAt?: Date; - @Prop({ type: String }) + @Prop({ type: String, unique: true, sparse: true }) email?: string; @Prop({ type: Boolean, required: true, default: false }) diff --git a/src/services/auth/auth.controller.ts b/src/services/auth/auth.controller.ts index 07345ef..ce5c7c0 100644 --- a/src/services/auth/auth.controller.ts +++ b/src/services/auth/auth.controller.ts @@ -344,16 +344,6 @@ export class AuthController { @Post('/register') async register(@Body() body: EmailRegisterDto) { const { email, password } = body; - - const existingRecord = await this.userRepository.findOneByEmail(email); - - if (existingRecord) { - throw new HttpException( - { reason: 'Email Password account already created!' }, - HttpStatus.BAD_REQUEST, - ); - } - const user_id = uuid(); const email_code = await this.authService.createEmailAndPasswordUser(email, password, user_id); diff --git a/src/services/auth/auth.service.ts b/src/services/auth/auth.service.ts index b868201..00ba8ff 100644 --- a/src/services/auth/auth.service.ts +++ b/src/services/auth/auth.service.ts @@ -1,5 +1,11 @@ import 'dotenv/config'; -import { Injectable, InternalServerErrorException, UnauthorizedException } from '@nestjs/common'; +import { + HttpException, + HttpStatus, + Injectable, + InternalServerErrorException, + UnauthorizedException, +} from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import bcrypt from 'bcryptjs'; import { LegacyUserAccountRepository } from '../../repositories/userAccount/user-account.repository'; @@ -135,12 +141,28 @@ export class AuthService { password: string, user_id: string, ): Promise { - await this.legacyUserRepository.createNewEmailUser({ email, user_id }); - return await this.legacyUserAccountRepository.createNewEmailAndPasswordUser({ - email, - password, - username: user_id, - }); + try { + await this.legacyUserRepository.createNewEmailUser({ email, user_id }); + return await this.legacyUserAccountRepository.createNewEmailAndPasswordUser({ + email, + password, + username: user_id, + }); + } catch (e) { + if (e.code == 11000) { + // Duplicate key error + throw new HttpException( + { reason: 'Email Password account already created!' }, + HttpStatus.BAD_REQUEST, + ); + } else { + console.log(e.code); + throw new HttpException( + { reason: 'Internal Server Error' }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } } async createHiveUser({ user_id, hiveAccount }: { user_id: string; hiveAccount: string }) { From 6cd08fec50cf084eef87d956413c4bffb9fa403b Mon Sep 17 00:00:00 2001 From: Christopher Graney-Ward Date: Fri, 12 Jul 2024 22:29:43 +0100 Subject: [PATCH 4/6] Refactoring validation allowing sub to be completely optional --- src/services/api/api.controller.ts | 13 ++-- src/services/auth/auth.controller.test.ts | 2 +- src/services/auth/auth.controller.ts | 5 +- src/services/auth/auth.types.ts | 2 +- src/services/hive/hive.service.test.ts | 63 ++++++++++++++----- src/services/hive/hive.service.ts | 45 +++++++------ .../uploader/uploading.controller.test.ts | 4 +- src/services/uploader/uploading.controller.ts | 26 ++------ src/services/uploader/uploading.service.ts | 2 +- 9 files changed, 87 insertions(+), 75 deletions(-) diff --git a/src/services/api/api.controller.ts b/src/services/api/api.controller.ts index e6760ed..911db96 100644 --- a/src/services/api/api.controller.ts +++ b/src/services/api/api.controller.ts @@ -253,20 +253,17 @@ export class ApiController { @Post(`/hive/vote`) async votePost(@Body() data: VotePostDto, @Request() req: any) { 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'); + const votingAccount = await this.hiveService.parseHiveUsername( + parsedRequest, + data.votingAccount, + ); + const { author, permlink, weight } = data; return await this.hiveService.vote({ - sub: parsedRequest.user.sub, votingAccount, author, permlink, weight, - network: parsedRequest.user.network, - user_id: user._id, }); } } diff --git a/src/services/auth/auth.controller.test.ts b/src/services/auth/auth.controller.test.ts index 1132dcb..53ffd7f 100644 --- a/src/services/auth/auth.controller.test.ts +++ b/src/services/auth/auth.controller.test.ts @@ -203,7 +203,7 @@ describe('AuthController', () => { .send({ email, password }) .expect(201); - expect(response.body).toEqual({ ok: true }); + expect(response.body).toEqual({ access_token: expect.any(String) }); const user = await userRepository.findOneByEmail(email); expect(user).toBeDefined(); diff --git a/src/services/auth/auth.controller.ts b/src/services/auth/auth.controller.ts index ce5c7c0..c9d563c 100644 --- a/src/services/auth/auth.controller.ts +++ b/src/services/auth/auth.controller.ts @@ -349,9 +349,8 @@ export class AuthController { const email_code = await this.authService.createEmailAndPasswordUser(email, password, user_id); await this.emailService.sendRegistration(email, email_code); - return { - ok: true, - }; + + return await this.authService.login({ network: 'email', user_id }); } @ApiParam({ diff --git a/src/services/auth/auth.types.ts b/src/services/auth/auth.types.ts index 9273aef..82aaa13 100644 --- a/src/services/auth/auth.types.ts +++ b/src/services/auth/auth.types.ts @@ -7,7 +7,7 @@ export const accountTypes = ['singleton', 'lite'] as const; export type AccountType = (typeof accountTypes)[number]; const userSchema = z.object({ - sub: z.string(), + sub: z.string().optional(), network: z.enum(network), type: z.enum(accountTypes).optional(), user_id: z.string(), diff --git a/src/services/hive/hive.service.test.ts b/src/services/hive/hive.service.test.ts index 9746955..2143a7c 100644 --- a/src/services/hive/hive.service.test.ts +++ b/src/services/hive/hive.service.test.ts @@ -3,7 +3,7 @@ import { Test } from '@nestjs/testing'; import { HiveChainModule } from '../../repositories/hive-chain/hive-chain.module'; import { HiveAccountModule } from '../../repositories/hive-account/hive-account.module'; import { MongoMemoryServer } from 'mongodb-memory-server'; -import { INestApplication, Module, UnauthorizedException, ValidationPipe } from '@nestjs/common'; +import { BadRequestException, INestApplication, Module, UnauthorizedException, ValidationPipe } from '@nestjs/common'; import { TestingModule } from '@nestjs/testing'; import crypto from 'crypto'; import { HiveModule } from './hive.module'; @@ -12,6 +12,7 @@ import { MongooseModule } from '@nestjs/mongoose'; import { UserModule } from '../../repositories/user/user.module'; import { LegacyUserRepository } from '../../repositories/user/user.repository'; import { LegacyHiveAccountRepository } from '../../repositories/hive-account/hive-account.repository'; +import { UserRequest } from '../auth/auth.types'; describe('AuthController', () => { let app: INestApplication @@ -97,12 +98,55 @@ describe('AuthController', () => { }) }) + describe('parseHiveUsername', () => { + it('returns the username if hive user is logged in and account is linked', async () => { + const sub = 'singleton/sisygoboom/hive'; + const user = await legacyUserRepository.createNewSubUser({ sub, user_id: 'test_user_id' }); + await legacyHiveAccountRepository.insertCreated({ account: 'sisygoboom', user_id: user._id }); + + const parsedRequest: UserRequest = { user: { user_id: 'test_user_id', sub, network: 'did' } }; + const response = await hiveService.parseHiveUsername(parsedRequest, 'sisygoboom'); + + expect(response).toBe('sisygoboom'); + }); + + it('throws UnauthorizedException if hive account is not linked', async () => { + const sub = 'singleton/username1/hive'; + const user = await legacyUserRepository.createNewSubUser({ sub, user_id: 'test_user_id' }); + await legacyHiveAccountRepository.insertCreated({ account: 'username1', user_id: user._id }); + + const parsedRequest: UserRequest = { user: { user_id: 'test_user_id', sub, network: 'did' } }; + + await expect(hiveService.parseHiveUsername(parsedRequest, 'username2')).rejects.toThrow(UnauthorizedException); + }); + + it('returns the username if hive user is logged in and attempts to use a linked account', async () => { + const sub = 'singleton/ned/hive'; + const user = await legacyUserRepository.createNewSubUser({ sub, user_id: 'test_user_id' }); + await legacyHiveAccountRepository.insertCreated({ account: 'username1', user_id: user._id }); + + const parsedRequest: UserRequest = { user: { user_id: 'test_user_id', sub, network: 'hive' } }; + const response = await hiveService.parseHiveUsername(parsedRequest, 'username1'); + + expect(response).toBe('username1'); + }); + + it('throws BadRequestException if no hive account in request or logged in', async () => { + const sub = null; + const user = await legacyUserRepository.createNewSubUser({ user_id: 'test_user_id' }); + + const parsedRequest: UserRequest = { user: { user_id: 'test_user_id', network: 'did' } }; + + await expect(hiveService.parseHiveUsername(parsedRequest)).rejects.toThrow(BadRequestException); + }); + }); + 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 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 }) + const response = await hiveService.vote({ votingAccount: 'sisygoboom', author: 'ned', permlink: 'sa', weight: 10000 }) expect(response).toEqual({ block_num: 123456, @@ -112,20 +156,11 @@ describe('AuthController', () => { }) }); - it('Fails when attempting to vote from a different hive account which has not been linked', async () => { - const sub = 'singleton/username1/hive'; - 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/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 }) + const response = await hiveService.vote({ votingAccount: 'username2', author: 'ned', permlink: 'sa', weight: 10000 }) expect(response).toEqual({ block_num: 123456, @@ -138,10 +173,10 @@ describe('AuthController', () => { it('Throws an error when a vote weight is invalid', async () => { const sub = 'singleton/sisygoboom/hive'; 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 })) + await expect(hiveService.vote({ votingAccount: 'sisygoboom', author: 'ned', permlink: 'sa', weight: 10001 })) .rejects .toThrow(); - await expect(hiveService.vote({ votingAccount: 'sisygoboom', sub, network: 'hive', author: 'ned', user_id: user!._id, permlink: 'sa', weight: -10001 })) + await expect(hiveService.vote({ votingAccount: 'sisygoboom', author: 'ned', permlink: 'sa', weight: -10001 })) .rejects .toThrow(); }); diff --git a/src/services/hive/hive.service.ts b/src/services/hive/hive.service.ts index 8cc2299..17bfa09 100644 --- a/src/services/hive/hive.service.ts +++ b/src/services/hive/hive.service.ts @@ -1,4 +1,5 @@ import { + BadRequestException, HttpException, HttpStatus, Injectable, @@ -10,7 +11,7 @@ import { import { HiveChainRepository } from '../../repositories/hive-chain/hive-chain.repository'; import 'dotenv/config'; import { LegacyHiveAccountRepository } from '../../repositories/hive-account/hive-account.repository'; -import { Network } from '../auth/auth.types'; +import { UserRequest } from '../auth/auth.types'; import { parseSub } from '../auth/auth.utils'; import { ObjectId } from 'mongodb'; import { LegacyUserRepository } from '../../repositories/user/user.repository'; @@ -34,37 +35,15 @@ export class HiveService { async vote({ votingAccount, - user_id, - sub, author, permlink, weight, - network, }: { votingAccount: string; - user_id: ObjectId; - sub: string; author: string; permlink: string; weight: number; - network: Network; }) { - // TODO: investigate how this could be reused on other methods that access accounts onchain - const parsedSub = parseSub(sub); - if (parsedSub.network === 'hive' && parsedSub.account === votingAccount) { - return this.#hiveChainRepository.vote({ author, permlink, voter: votingAccount, weight }); - } - - const delegatedAuth = - await this.#legacyHiveAccountRepository.findOneByOwnerIdAndHiveAccountName({ - account: votingAccount, - user_id, - }); - - if (!delegatedAuth) { - throw new UnauthorizedException('You have not verified ownership of the target account'); - } - return this.#hiveChainRepository.vote({ author, permlink, voter: votingAccount, weight }); } @@ -179,4 +158,24 @@ export class HiveService { 'you are not logged in or do not have a link to this hive account', ); } + + async parseHiveUsername(parsedRequest: UserRequest, username?: string): Promise { + if (username) { + const dbUser = await this.#legacyUserRepository.findOneByUserId({ + user_id: parsedRequest.user.user_id, + }); + if (!dbUser) throw new UnauthorizedException('Account does not exist'); + + if (!(await this.isHiveAccountLinked({ account: username, user_id: dbUser._id }))) { + throw new UnauthorizedException('Hive account is not linked'); + } + return username; + } + if (parsedRequest.user.sub && parsedRequest.user.network === 'hive') { + return parseSub(parsedRequest.user.sub).account; + } + throw new BadRequestException( + 'You must either be logged in with a hive account or include a linked account in the request body.', + ); + } } diff --git a/src/services/uploader/uploading.controller.test.ts b/src/services/uploader/uploading.controller.test.ts index e619758..0b74a82 100644 --- a/src/services/uploader/uploading.controller.test.ts +++ b/src/services/uploader/uploading.controller.test.ts @@ -232,7 +232,7 @@ describe('UploadingController', () => { .then(response => { expect(response.body).toEqual({ error: "Unauthorized", - message: "Your account is not linked to the requested hive account", + message: "Hive account is not linked", statusCode: 401, }); }); @@ -287,7 +287,7 @@ describe('UploadingController', () => { .then(response => { expect(response.body).toEqual({ error: "Unauthorized", - message: "Your account is not linked to the requested hive account", + message: "Hive account is not linked", statusCode: 401, }); }); diff --git a/src/services/uploader/uploading.controller.ts b/src/services/uploader/uploading.controller.ts index 178f650..110ac29 100644 --- a/src/services/uploader/uploading.controller.ts +++ b/src/services/uploader/uploading.controller.ts @@ -16,7 +16,6 @@ import { Headers, Logger, UnauthorizedException, - BadRequestException, NotFoundException, } from '@nestjs/common'; import { FileInterceptor, MulterModule } from '@nestjs/platform-express'; @@ -29,7 +28,7 @@ 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, parseSub } from '../auth/auth.utils'; +import { parseAndValidateRequest } from '../auth/auth.utils'; import { HiveService } from '../hive/hive.service'; import { CreateUploadDto } from './dto/create-upload.dto'; import { AuthService } from '../auth/auth.service'; @@ -89,10 +88,8 @@ 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' ? account : undefined; - if (!hiveUsername) throw new BadRequestException('No username provided'); + const hiveUsername = await this.hiveService.parseHiveUsername(parsedRequest, body.username); + return this.uploadingService.createUpload({ sub: parsedRequest.user.sub, username: hiveUsername, @@ -105,13 +102,7 @@ export class UploadingController { @Post('start_encode') async startEncode(@Body() body: StartEncodeDto, @Request() req) { const request = parseAndValidateRequest(req, this.#logger); - 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 hiveUsername = await this.hiveService.parseHiveUsername(request, body.username); const user = await this.authService.getUserByUserId({ user_id: request.user.user_id }); @@ -119,15 +110,6 @@ export class UploadingController { throw new UnauthorizedException('User not found'); } - if ( - !(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'); diff --git a/src/services/uploader/uploading.service.ts b/src/services/uploader/uploading.service.ts index fd3bb0b..f94382b 100644 --- a/src/services/uploader/uploading.service.ts +++ b/src/services/uploader/uploading.service.ts @@ -50,7 +50,7 @@ export class UploadingService { username, user_id, }: { - sub: string; + sub?: string; username: string; user_id: string; }) { From da63f8ee604780c7deb59c670e62fa4c642561b7 Mon Sep 17 00:00:00 2001 From: Christopher Graney-Ward Date: Sat, 13 Jul 2024 01:32:54 +0100 Subject: [PATCH 5/6] Refactor registration from controller to service --- src/services/auth/auth.controller.test.ts | 2 +- src/services/auth/auth.controller.ts | 9 +-------- src/services/auth/auth.service.ts | 14 +++++++++++++- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/src/services/auth/auth.controller.test.ts b/src/services/auth/auth.controller.test.ts index 53ffd7f..47c6bf5 100644 --- a/src/services/auth/auth.controller.test.ts +++ b/src/services/auth/auth.controller.test.ts @@ -214,7 +214,7 @@ describe('AuthController', () => { const password = '!SUPER-SECRET_password!7'; // Create a user with the same email - await authService.createEmailAndPasswordUser(email, password, randomUUID()); + await authService.registerEmailAndPasswordUser(email, password); return request(app.getHttpServer()) .post('/v1/auth/register') diff --git a/src/services/auth/auth.controller.ts b/src/services/auth/auth.controller.ts index c9d563c..5505e90 100644 --- a/src/services/auth/auth.controller.ts +++ b/src/services/auth/auth.controller.ts @@ -44,7 +44,6 @@ import { RequestHiveAccountDto } from '../api/dto/RequestHiveAccount.dto'; import { HiveService } from '../hive/hive.service'; import { AuthInterceptor, UserDetailsInterceptor } from '../api/utils'; import { randomUUID } from 'crypto'; -import { v4 as uuid } from 'uuid'; import { EmailRegisterDto } from './dto/EmailRegister.dto'; @Controller('/v1/auth') @@ -340,17 +339,11 @@ export class AuthController { @ApiInternalServerErrorResponse({ description: 'Internal Server Error - unrelated to request body', }) - // @UseGuards(AuthGuard('local')) @Post('/register') async register(@Body() body: EmailRegisterDto) { const { email, password } = body; - const user_id = uuid(); - const email_code = await this.authService.createEmailAndPasswordUser(email, password, user_id); - - await this.emailService.sendRegistration(email, email_code); - - return await this.authService.login({ network: 'email', user_id }); + return await this.authService.registerEmailAndPasswordUser(email, password); } @ApiParam({ diff --git a/src/services/auth/auth.service.ts b/src/services/auth/auth.service.ts index 00ba8ff..04ed9f3 100644 --- a/src/services/auth/auth.service.ts +++ b/src/services/auth/auth.service.ts @@ -16,6 +16,7 @@ import { LegacyUserRepository } from '../../repositories/user/user.repository'; import { LegacyHiveAccountRepository } from '../../repositories/hive-account/hive-account.repository'; import { DID } from 'dids'; import { ObjectId } from 'mongodb'; +import { EmailService } from '../email/email.service'; @Injectable() export class AuthService { @@ -25,6 +26,7 @@ export class AuthService { private readonly sessionRepository: SessionRepository, private readonly legacyHiveAccountRepository: LegacyHiveAccountRepository, private readonly jwtService: JwtService, + private readonly emailService: EmailService, ) {} jwtSign(payload: User) { @@ -136,7 +138,7 @@ export class AuthService { return await this.sessionRepository.findOneBySub(this.generateDidSub(did)); } - async createEmailAndPasswordUser( + async #createEmailAndPasswordUser( email: string, password: string, user_id: string, @@ -165,6 +167,16 @@ export class AuthService { } } + async registerEmailAndPasswordUser(email: string, password: string) { + const user_id = uuid(); + + const email_code = await this.#createEmailAndPasswordUser(email, password, user_id); + + await this.emailService.sendRegistration(email, email_code); + + return { access_token: this.jwtSign({ network: 'email', user_id }) }; + } + async createHiveUser({ user_id, hiveAccount }: { user_id: string; hiveAccount: string }) { const sub = this.generateHiveSub(hiveAccount); const account = await this.legacyUserRepository.createNewSubUser({ From 065acd25e5b060d37b76ba8a0c684fd83565e153 Mon Sep 17 00:00:00 2001 From: Christopher Graney-Ward Date: Wed, 31 Jul 2024 21:08:22 +0100 Subject: [PATCH 6/6] Fix: accounts should not be usable until they're verified --- .../userAccount/user-account.repository.ts | 6 ++++-- src/services/auth/auth.controller.ts | 2 ++ src/services/auth/auth.service.ts | 14 +++++++------- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/repositories/userAccount/user-account.repository.ts b/src/repositories/userAccount/user-account.repository.ts index 4149693..2f63244 100644 --- a/src/repositories/userAccount/user-account.repository.ts +++ b/src/repositories/userAccount/user-account.repository.ts @@ -19,8 +19,10 @@ export class LegacyUserAccountRepository { private legacyUserAccountModel: Model, ) {} - async findOneByEmail(query: Pick): Promise { - const authUser = await this.legacyUserAccountModel.findOne(query); + async findOneVerifiedByEmail( + query: Pick, + ): Promise { + const authUser = await this.legacyUserAccountModel.findOne({ ...query, emailVerified: true }); this.#logger.log(authUser); // TODO: delete - not suitable for prod return authUser; diff --git a/src/services/auth/auth.controller.ts b/src/services/auth/auth.controller.ts index 5505e90..ba408f7 100644 --- a/src/services/auth/auth.controller.ts +++ b/src/services/auth/auth.controller.ts @@ -366,6 +366,8 @@ export class AuthController { return res.redirect('https://3speak.tv'); } + // todo: send new verification code + @ApiHeader({ name: 'Authorization', description: 'JWT Authorization', diff --git a/src/services/auth/auth.service.ts b/src/services/auth/auth.service.ts index 04ed9f3..f90497f 100644 --- a/src/services/auth/auth.service.ts +++ b/src/services/auth/auth.service.ts @@ -34,9 +34,11 @@ export class AuthService { } async validateUser(email: string, pass: string) { - const user = await this.legacyUserAccountRepository.findOneByEmail({ email }); + const user = await this.legacyUserAccountRepository.findOneVerifiedByEmail({ email }); if (!user || !user.password) { - throw new UnauthorizedException('Email or password was incorrect'); + throw new UnauthorizedException( + 'Email or password was incorrect or email has not been verified', + ); } if (!user.password) { throw new InternalServerErrorException('Email does not have associated password'); @@ -45,7 +47,9 @@ export class AuthService { const { password, ...result } = user; return result; } - throw new UnauthorizedException('Email or password was incorrect'); + throw new UnauthorizedException( + 'Email or password was incorrect or email has not been verified', + ); } async getOrCreateUserByDid(did: string): Promise<{ sub?: string; user_id: string }> { @@ -80,10 +84,6 @@ export class AuthService { }; } - async getUserAccountBySub(sub: string) { - return this.legacyUserAccountRepository.findOneByEmail; - } - async getUserByUserId({ user_id }: { user_id: string }) { return this.legacyUserRepository.findOneByUserId({ user_id }); }