From 835c53c5e7182e67c9b611a51770c1b4fdbffe79 Mon Sep 17 00:00:00 2001 From: okjodom Date: Wed, 15 Jan 2025 06:05:39 +0300 Subject: [PATCH] feat: progressive auth service --- apps/api/.dev.env | 1 + apps/api/src/api.module.ts | 25 +++++- apps/api/src/auth/auth.controller.spec.ts | 39 ++++++++ apps/api/src/auth/auth.controller.ts | 90 +++++++++++++++++++ apps/api/src/auth/auth.service.spec.ts | 34 +++++++ apps/api/src/auth/auth.service.ts | 37 ++++++++ apps/auth/.dev.env | 2 +- apps/auth/src/auth.controller.ts | 2 +- apps/auth/src/auth.module.ts | 2 +- apps/auth/src/auth.service.ts | 11 +-- apps/auth/src/users/users.service.ts | 39 +++----- libs/common/src/auth/index.ts | 1 - libs/common/src/database/users.schema.ts | 2 +- libs/common/src/dto/auth.dto.ts | 8 +- libs/common/src/dto/swap.dto.ts | 5 +- libs/common/src/index.ts | 1 - libs/common/src/types/auth.ts | 6 ++ libs/common/src/types/index.ts | 1 + libs/common/src/types/proto/auth.ts | 27 +++--- libs/common/src/utils/index.ts | 1 + .../src/{auth => utils}/jwt-auth.guard.ts | 25 +++--- proto/auth.proto | 13 +-- 22 files changed, 298 insertions(+), 74 deletions(-) create mode 100644 apps/api/src/auth/auth.controller.spec.ts create mode 100644 apps/api/src/auth/auth.controller.ts create mode 100644 apps/api/src/auth/auth.service.spec.ts create mode 100644 apps/api/src/auth/auth.service.ts delete mode 100644 libs/common/src/auth/index.ts create mode 100644 libs/common/src/types/auth.ts rename libs/common/src/{auth => utils}/jwt-auth.guard.ts (73%) diff --git a/apps/api/.dev.env b/apps/api/.dev.env index 8d9721d..0f22ba0 100644 --- a/apps/api/.dev.env +++ b/apps/api/.dev.env @@ -1,5 +1,6 @@ PORT=4000 NODE_ENV='development' +AUTH_GRPC_URL='auth:4010' SWAP_GRPC_URL='swap:4040' NOSTR_GRPC_URL='nostr:4050' SMS_GRPC_URL='sms:4060' diff --git a/apps/api/src/api.module.ts b/apps/api/src/api.module.ts index 041fd3f..c016146 100644 --- a/apps/api/src/api.module.ts +++ b/apps/api/src/api.module.ts @@ -1,9 +1,11 @@ -import { join } from 'path'; import * as Joi from 'joi'; +import { join } from 'path'; +import { JwtModule } from '@nestjs/jwt'; import { Module } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { ClientsModule, Transport } from '@nestjs/microservices'; import { + AUTH_SERVICE_NAME, EVENTS_SERVICE_BUS, LoggerModule, NOSTR_SERVICE_NAME, @@ -22,9 +24,12 @@ import { AdminController } from './admin/admin.controller'; import { AdminService } from './admin/admin.service'; import { SolowalletService } from './solowallet/solowallet.service'; import { SolowalletController } from './solowallet/solowallet.controller'; +import { AuthService } from './auth/auth.service'; +import { AuthController } from './auth/auth.controller'; @Module({ imports: [ + JwtModule, LoggerModule, ConfigModule.forRoot({ isGlobal: true, @@ -41,6 +46,18 @@ import { SolowalletController } from './solowallet/solowallet.controller'; }), }), ClientsModule.registerAsync([ + { + name: AUTH_SERVICE_NAME, + useFactory: (configService: ConfigService) => ({ + transport: Transport.GRPC, + options: { + package: 'auth', + protoPath: join(__dirname, '../../../proto/auth.proto'), + url: configService.getOrThrow('AUTH_GRPC_URL'), + }, + }), + inject: [ConfigService], + }, { name: SWAP_SERVICE_NAME, useFactory: (configService: ConfigService) => ({ @@ -115,20 +132,22 @@ import { SolowalletController } from './solowallet/solowallet.controller'; ]), ], controllers: [ - AdminController, + AuthController, SwapController, NostrController, SmsController, SharesController, SolowalletController, + AdminController, ], providers: [ + AuthService, SwapService, NostrService, SmsService, SharesService, - AdminService, SolowalletService, + AdminService, ], }) export class ApiModule {} diff --git a/apps/api/src/auth/auth.controller.spec.ts b/apps/api/src/auth/auth.controller.spec.ts new file mode 100644 index 0000000..c6c9dd0 --- /dev/null +++ b/apps/api/src/auth/auth.controller.spec.ts @@ -0,0 +1,39 @@ +import { JwtService } from '@nestjs/jwt'; +import { ConfigService } from '@nestjs/config'; +import { TestingModule } from '@nestjs/testing'; +import { createTestingModuleWithValidation } from '@bitsacco/testing'; + +import { AuthController } from './auth.controller'; +import { AuthService } from './auth.service'; + +describe('AuthController', () => { + let controller: AuthController; + let authService: AuthService; + + beforeEach(async () => { + const module: TestingModule = await createTestingModuleWithValidation({ + controllers: [AuthController], + providers: [ + ConfigService, + { + provide: AuthService, + useValue: { + loginUser: jest.fn(), + registerUser: jest.fn(), + verifyUser: jest.fn(), + authenticate: jest.fn(), + }, + }, + JwtService, + ], + }); + + controller = module.get(AuthController); + authService = module.get(AuthService); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + expect(authService).toBeDefined(); + }); +}); diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts new file mode 100644 index 0000000..9f05e82 --- /dev/null +++ b/apps/api/src/auth/auth.controller.ts @@ -0,0 +1,90 @@ +import { type Response } from 'express'; +import { firstValueFrom, Observable } from 'rxjs'; +import { Body, Controller, Logger, Post, Res } from '@nestjs/common'; +import { ApiBody, ApiOperation } from '@nestjs/swagger'; +import { JwtService } from '@nestjs/jwt'; +import { + AuthRequestDto, + AuthResponse, + AuthTokenPayload, + LoginUserRequestDto, + RegisterUserRequestDto, + VerifyUserRequestDto, +} from '@bitsacco/common'; +import { AuthService } from './auth.service'; + +@Controller('auth') +export class AuthController { + private readonly logger = new Logger(AuthController.name); + + constructor( + private readonly authService: AuthService, + private readonly jwtService: JwtService, + ) { + this.logger.log('AuthController initialized'); + } + + @Post('login') + @ApiOperation({ summary: 'Login user' }) + @ApiBody({ + type: LoginUserRequestDto, + }) + async login( + @Body() req: LoginUserRequestDto, + @Res({ passthrough: true }) res: Response, + ) { + const auth = this.authService.loginUser(req); + return this.setAuthCookie(auth, res); + } + + @Post('register') + @ApiOperation({ summary: 'Register user' }) + @ApiBody({ + type: RegisterUserRequestDto, + }) + register(@Body() req: RegisterUserRequestDto) { + return firstValueFrom(this.authService.registerUser(req)).then((user) => ({ + user, + })); + } + + @Post('verify') + @ApiOperation({ summary: 'Register user' }) + @ApiBody({ + type: VerifyUserRequestDto, + }) + verify(@Body() req: VerifyUserRequestDto) { + return firstValueFrom(this.authService.verifyUser(req)).then((user) => ({ + user, + })); + } + + @Post('authenticate') + @ApiOperation({ summary: 'Authenticate user' }) + @ApiBody({ + type: AuthRequestDto, + }) + async authenticate( + @Body() req: AuthRequestDto, + @Res({ passthrough: true }) res: Response, + ) { + const auth = this.authService.authenticate(req); + return this.setAuthCookie(auth, res); + } + + private async setAuthCookie(auth: Observable, res: Response) { + return firstValueFrom(auth).then(({ token }) => { + const { user, expires } = this.jwtService.decode(token); + + res.cookie('Authentication', token, { + httpOnly: true, + expires: new Date(expires), + }); + + return { + user, + token, + }; + }); + } +} diff --git a/apps/api/src/auth/auth.service.spec.ts b/apps/api/src/auth/auth.service.spec.ts new file mode 100644 index 0000000..e8a2136 --- /dev/null +++ b/apps/api/src/auth/auth.service.spec.ts @@ -0,0 +1,34 @@ +import { TestingModule } from '@nestjs/testing'; +import { AuthServiceClient } from '@bitsacco/common'; +import { createTestingModuleWithValidation } from '@bitsacco/testing'; +import { ClientGrpc } from '@nestjs/microservices'; +import { AuthService } from './auth.service'; + +describe('AuthService', () => { + let service: AuthService; + let serviceGenerator: ClientGrpc; + let mockAuthServiceClient: Partial; + + beforeEach(async () => { + serviceGenerator = { + getService: jest.fn().mockReturnValue(mockAuthServiceClient), + getClientByServiceName: jest.fn().mockReturnValue(mockAuthServiceClient), + }; + + const module: TestingModule = await createTestingModuleWithValidation({ + providers: [ + { + provide: AuthService, + useFactory: () => { + return new AuthService(serviceGenerator); + }, + }, + ], + }); + service = module.get(AuthService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts new file mode 100644 index 0000000..4abec66 --- /dev/null +++ b/apps/api/src/auth/auth.service.ts @@ -0,0 +1,37 @@ +import { + AUTH_SERVICE_NAME, + AuthRequest, + AuthServiceClient, + LoginUserRequest, + RegisterUserRequest, + VerifyUserRequest, +} from '@bitsacco/common'; +import { Inject, Injectable, OnModuleInit } from '@nestjs/common'; +import { type ClientGrpc } from '@nestjs/microservices'; + +@Injectable() +export class AuthService implements OnModuleInit { + private client: AuthServiceClient; + + constructor(@Inject(AUTH_SERVICE_NAME) private readonly grpc: ClientGrpc) {} + + onModuleInit() { + this.client = this.grpc.getService(AUTH_SERVICE_NAME); + } + + loginUser(req: LoginUserRequest) { + return this.client.loginUser(req); + } + + registerUser(req: RegisterUserRequest) { + return this.client.registerUser(req); + } + + verifyUser(req: VerifyUserRequest) { + return this.client.verifyUser(req); + } + + authenticate(req: AuthRequest) { + return this.client.authenticate(req); + } +} diff --git a/apps/auth/.dev.env b/apps/auth/.dev.env index 3b5aee2..051f3c3 100644 --- a/apps/auth/.dev.env +++ b/apps/auth/.dev.env @@ -3,4 +3,4 @@ AUTH_GRPC_URL='auth:4010' DATABASE_URL=mongodb://bs:password@mongodb:27017 JWT_SECRET='secret' JWT_EXPIRATION='3600' -PIN_SALT='BSPN' +SALT_ROUNDS='12' diff --git a/apps/auth/src/auth.controller.ts b/apps/auth/src/auth.controller.ts index 66b08f3..da00160 100644 --- a/apps/auth/src/auth.controller.ts +++ b/apps/auth/src/auth.controller.ts @@ -25,7 +25,7 @@ export class AuthController { } @GrpcMethod() - verifyuser(req: VerifyUserRequestDto) { + verifyUser(req: VerifyUserRequestDto) { return this.authService.verifyUser(req); } diff --git a/apps/auth/src/auth.module.ts b/apps/auth/src/auth.module.ts index 49c313f..44d2cc9 100644 --- a/apps/auth/src/auth.module.ts +++ b/apps/auth/src/auth.module.ts @@ -23,7 +23,7 @@ import { AuthService } from './auth.service'; DATABASE_URL: Joi.string().required(), JWT_SECRET: Joi.string().required(), JWT_EXPIRATION: Joi.string().required(), - PIN_SALT: Joi.string().required(), + SALT_ROUNDS: Joi.number().required(), }), }), DatabaseModule, diff --git a/apps/auth/src/auth.service.ts b/apps/auth/src/auth.service.ts index bacf861..4b50193 100644 --- a/apps/auth/src/auth.service.ts +++ b/apps/auth/src/auth.service.ts @@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config'; import { Injectable, Logger } from '@nestjs/common'; import { AuthRequest, + AuthTokenPayload, LoginUserRequestDto, RegisterUserRequestDto, User, @@ -10,11 +11,6 @@ import { } from '@bitsacco/common'; import { UsersService } from './users'; -interface AuthTokenPayload { - user: User; - expires: Date; -} - @Injectable() export class AuthService { private readonly logger = new Logger(AuthService.name); @@ -29,8 +25,9 @@ export class AuthService { async loginUser(req: LoginUserRequestDto) { const user = await this.userService.validateUser(req); - - return this.createAuthToken(user); + return { + token: this.createAuthToken(user), + }; } async registerUser(req: RegisterUserRequestDto) { diff --git a/apps/auth/src/users/users.service.ts b/apps/auth/src/users/users.service.ts index 13c86d7..5c4f18c 100644 --- a/apps/auth/src/users/users.service.ts +++ b/apps/auth/src/users/users.service.ts @@ -22,11 +22,7 @@ export class UsersService { async validateUser({ pin, phone, npub }: LoginUserRequestDto): Promise { const ud: UsersDocument = await this.queryUser({ phone, npub }); - const pinHash = await bcrypt.hash( - pin, - this.configService.getOrThrow('PIN_SALT'), - ); - const pinIsValid = await bcrypt.compare(pinHash, ud.pinHash); + const pinIsValid = await bcrypt.compare(pin, ud.pinHash); if (!pinIsValid) { throw new UnauthorizedException('Credentials are not valid.'); } @@ -35,11 +31,13 @@ export class UsersService { } async registerUser({ pin, phone, npub, roles }: RegisterUserRequestDto) { + let salt = await bcrypt.genSalt( + this.configService.getOrThrow('SALT_ROUNDS'), + ); + const pinHash = await bcrypt.hash(pin, salt); + const user = await this.users.create({ - pinHash: await bcrypt.hash( - pin, - this.configService.getOrThrow('PIN_SALT'), - ), + pinHash, phone: { number: phone, verified: false, @@ -69,12 +67,15 @@ export class UsersService { throw new Error('User not found'); } + if (otp) { + // check otp against user otp + } + if (!otp) { // generate new otp and update user document + // send otp notifications via sms/nostr } - // send otp notifications to sms/nostr - return toUser(ud); } @@ -85,23 +86,11 @@ export class UsersService { ud = await this.users.findOne({ _id: id }); } else if (phone) { ud = await this.users.findOne({ - $match: { - phone: { - $match: { - number: phone, - }, - }, - }, + 'phone.number': phone, }); } else if (npub) { ud = await this.users.findOne({ - $match: { - nostr: { - $match: { - npub, - }, - }, - }, + 'nostr.npub': npub, }); } else { throw new Error('Invalid user query'); diff --git a/libs/common/src/auth/index.ts b/libs/common/src/auth/index.ts deleted file mode 100644 index 3525e2d..0000000 --- a/libs/common/src/auth/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './jwt-auth.guard'; diff --git a/libs/common/src/database/users.schema.ts b/libs/common/src/database/users.schema.ts index f81e1af..ec8f05c 100644 --- a/libs/common/src/database/users.schema.ts +++ b/libs/common/src/database/users.schema.ts @@ -41,7 +41,7 @@ export class UsersDocument extends AbstractDocument { export const UsersSchema = SchemaFactory.createForClass(UsersDocument); UsersSchema.index({ 'phone.number': 1 }, { unique: true, sparse: true }); -UsersSchema.index({ 'nostr.pubkey': 1 }, { unique: true, sparse: true }); +UsersSchema.index({ 'nostr.npub': 1 }, { unique: true, sparse: true }); export function toUser(doc: UsersDocument): User { return { diff --git a/libs/common/src/dto/auth.dto.ts b/libs/common/src/dto/auth.dto.ts index aed7346..568d8e0 100644 --- a/libs/common/src/dto/auth.dto.ts +++ b/libs/common/src/dto/auth.dto.ts @@ -21,15 +21,14 @@ class AuthRequestBase { @IsNotEmpty() @IsString() @Validate(IsStringifiedNumberConstraint, [{ digits: 6, positive: true }]) - @ApiProperty({ example: '123456' }) + @ApiProperty({ example: '000000' }) pin: string; @IsOptional() @IsString() - @IsNotEmpty() @IsPhoneNumber() @ApiProperty({ - example: '254700000000', + example: '+254700000000', }) phone?: string; @@ -51,8 +50,7 @@ export class RegisterUserRequestDto implements RegisterUserRequest { @IsArray() - @IsEnum({ each: true, type: Role }) - @IsNotEmpty({ each: true }) + // @IsEnum({ each: true, type: Role }) @ApiProperty({ type: [Role], enum: Role, diff --git a/libs/common/src/dto/swap.dto.ts b/libs/common/src/dto/swap.dto.ts index 58a8ca9..28cf40f 100644 --- a/libs/common/src/dto/swap.dto.ts +++ b/libs/common/src/dto/swap.dto.ts @@ -9,6 +9,7 @@ import { IsNumber, Min, IsBoolean, + IsPhoneNumber, } from 'class-validator'; import { Type } from 'class-transformer'; import { ApiProperty } from '@nestjs/swagger'; @@ -49,9 +50,9 @@ export class QuoteDto { export class MobileMoneyDto implements MobileMoney { @IsString() - @Type(() => String) + @IsPhoneNumber() @ApiProperty({ - example: '254700000000', + example: '+254700000000', }) phone: string; } diff --git a/libs/common/src/index.ts b/libs/common/src/index.ts index 7528335..7ae22a2 100644 --- a/libs/common/src/index.ts +++ b/libs/common/src/index.ts @@ -1,4 +1,3 @@ -export * from './auth'; export * from './types'; export * from './logger'; export * from './utils'; diff --git a/libs/common/src/types/auth.ts b/libs/common/src/types/auth.ts new file mode 100644 index 0000000..1b15c0d --- /dev/null +++ b/libs/common/src/types/auth.ts @@ -0,0 +1,6 @@ +import { User } from './proto/auth'; + +export interface AuthTokenPayload { + user: User; + expires: Date; +} diff --git a/libs/common/src/types/index.ts b/libs/common/src/types/index.ts index 21928f6..9366e14 100644 --- a/libs/common/src/types/index.ts +++ b/libs/common/src/types/index.ts @@ -9,3 +9,4 @@ export * from './proto/solowallet'; export * from './api'; export * from './validator'; export * from './fedimint'; +export * from './auth'; diff --git a/libs/common/src/types/proto/auth.ts b/libs/common/src/types/proto/auth.ts index 02c4945..8e0d156 100644 --- a/libs/common/src/types/proto/auth.ts +++ b/libs/common/src/types/proto/auth.ts @@ -7,7 +7,6 @@ /* eslint-disable */ import { GrpcMethod, GrpcStreamMethod } from '@nestjs/microservices'; import { Observable } from 'rxjs'; -import { Empty } from './lib'; export enum Role { Member = 0, @@ -17,7 +16,7 @@ export enum Role { } export interface LoginUserRequest { - pin: string | undefined; + pin?: string | undefined; phone?: string | undefined; npub?: string | undefined; } @@ -39,6 +38,10 @@ export interface AuthRequest { token: string; } +export interface AuthResponse { + token: string; +} + export interface User { id: string; phone?: Phone | undefined; @@ -66,27 +69,31 @@ export interface Profile { } export interface AuthServiceClient { - loginUser(request: LoginUserRequest): Observable; + loginUser(request: LoginUserRequest): Observable; registerUser(request: RegisterUserRequest): Observable; - verifyuser(request: VerifyUserRequest): Observable; + verifyUser(request: VerifyUserRequest): Observable; - authenticate(request: AuthRequest): Observable; + authenticate(request: AuthRequest): Observable; } export interface AuthServiceController { - loginUser(request: LoginUserRequest): Promise | Observable | User; + loginUser( + request: LoginUserRequest, + ): Promise | Observable | AuthResponse; registerUser( request: RegisterUserRequest, ): Promise | Observable | User; - verifyuser( + verifyUser( request: VerifyUserRequest, - ): Promise | Observable | Empty; + ): Promise | Observable | User; - authenticate(request: AuthRequest): Promise | Observable | User; + authenticate( + request: AuthRequest, + ): Promise | Observable | AuthResponse; } export function AuthServiceControllerMethods() { @@ -94,7 +101,7 @@ export function AuthServiceControllerMethods() { const grpcMethods: string[] = [ 'loginUser', 'registerUser', - 'verifyuser', + 'verifyUser', 'authenticate', ]; for (const method of grpcMethods) { diff --git a/libs/common/src/utils/index.ts b/libs/common/src/utils/index.ts index e07a326..a5d94da 100644 --- a/libs/common/src/utils/index.ts +++ b/libs/common/src/utils/index.ts @@ -1,2 +1,3 @@ export * from './currency'; export { CustomStore } from './cache'; +export { JwtAuthGuard } from './jwt-auth.guard'; diff --git a/libs/common/src/auth/jwt-auth.guard.ts b/libs/common/src/utils/jwt-auth.guard.ts similarity index 73% rename from libs/common/src/auth/jwt-auth.guard.ts rename to libs/common/src/utils/jwt-auth.guard.ts index f6eb089..3e4e368 100644 --- a/libs/common/src/auth/jwt-auth.guard.ts +++ b/libs/common/src/utils/jwt-auth.guard.ts @@ -7,10 +7,16 @@ import { OnModuleInit, UnauthorizedException, } from '@nestjs/common'; -import { type ClientGrpc } from '@nestjs/microservices'; import { Reflector } from '@nestjs/core'; +import { JwtService } from '@nestjs/jwt'; +import { type ClientGrpc } from '@nestjs/microservices'; import { catchError, map, Observable, of, tap } from 'rxjs'; -import { AUTH_SERVICE_NAME, AuthServiceClient, Role } from '../types'; +import { + AUTH_SERVICE_NAME, + AuthServiceClient, + AuthTokenPayload, + Role, +} from '../types'; @Injectable() export class JwtAuthGuard implements CanActivate, OnModuleInit { @@ -18,13 +24,14 @@ export class JwtAuthGuard implements CanActivate, OnModuleInit { private authService: AuthServiceClient; constructor( - @Inject(AUTH_SERVICE_NAME) private readonly client: ClientGrpc, + @Inject(AUTH_SERVICE_NAME) private readonly grpc: ClientGrpc, private readonly reflector: Reflector, + private readonly jwtService: JwtService, ) {} onModuleInit() { this.authService = - this.client.getService(AUTH_SERVICE_NAME); + this.grpc.getService(AUTH_SERVICE_NAME); } canActivate( @@ -45,19 +52,17 @@ export class JwtAuthGuard implements CanActivate, OnModuleInit { token: jwt, }) .pipe( - tap((res) => { + tap(({ token }) => { + const { user } = this.jwtService.decode(token); if (roles) { for (const role of roles) { - if (!res.roles?.includes(role)) { + if (!user.roles?.includes(role)) { this.logger.error('The user does not have valid roles.'); throw new UnauthorizedException(); } } } - context.switchToHttp().getRequest().user = { - ...res, - _id: res.id, - }; + context.switchToHttp().getRequest().user = user; }), map(() => true), catchError((err) => { diff --git a/proto/auth.proto b/proto/auth.proto index 2cd29f4..cb8e4bb 100644 --- a/proto/auth.proto +++ b/proto/auth.proto @@ -1,14 +1,11 @@ syntax = "proto3"; - -import "lib.proto"; - package auth; service AuthService { - rpc LoginUser (LoginUserRequest) returns (User) {} + rpc LoginUser (LoginUserRequest) returns (AuthResponse) {} rpc RegisterUser (RegisterUserRequest) returns (User) {} - rpc Verifyuser (VerifyUserRequest) returns (lib.Empty) {} - rpc Authenticate (AuthRequest) returns (User) {} + rpc VerifyUser (VerifyUserRequest) returns (User) {} + rpc Authenticate (AuthRequest) returns (AuthResponse) {} } message LoginUserRequest { @@ -34,6 +31,10 @@ message AuthRequest { string token = 1; } +message AuthResponse { + string token = 1; +} + message User { string id = 1;