Skip to content

Commit

Permalink
feat: generate otp on auth
Browse files Browse the repository at this point in the history
  • Loading branch information
okjodom committed Jan 16, 2025
1 parent 4b845e0 commit 8ee7df3
Show file tree
Hide file tree
Showing 13 changed files with 225 additions and 66 deletions.
14 changes: 7 additions & 7 deletions apps/api/src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,20 +43,20 @@ export class AuthController {
type: RegisterUserRequestDto,
})
register(@Body() req: RegisterUserRequestDto) {
return firstValueFrom(this.authService.registerUser(req)).then((user) => ({
user,
}));
return this.authService.registerUser(req)
}

@Post('verify')
@ApiOperation({ summary: 'Register user' })
@ApiBody({
type: VerifyUserRequestDto,
})
verify(@Body() req: VerifyUserRequestDto) {
return firstValueFrom(this.authService.verifyUser(req)).then((user) => ({
user,
}));
async verify(
@Body() req: VerifyUserRequestDto,
@Res({ passthrough: true }) res: Response,
) {
const auth = await this.authService.verifyUser(req);
return this.setAuthCookie(auth, res);
}

@Post('authenticate')
Expand Down
1 change: 1 addition & 0 deletions apps/auth/.dev.env
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
NODE_ENV='development'
AUTH_GRPC_URL='auth:4010'
SMS_GRPC_URL='sms:4060'
DATABASE_URL=mongodb://bs:password@mongodb:27017
JWT_SECRET='secret'
JWT_EXPIRATION='3600'
Expand Down
18 changes: 18 additions & 0 deletions apps/auth/src/auth.module.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import * as Joi from 'joi';
import { join } from 'path';
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ClientsModule, Transport } from '@nestjs/microservices';
import {
DatabaseModule,
LoggerModule,
SMS_SERVICE_NAME,
UsersDocument,
UsersSchema,
} from '@bitsacco/common';
Expand All @@ -20,6 +23,7 @@ import { AuthService } from './auth.service';
validationSchema: Joi.object({
NODE_ENV: Joi.string().required(),
AUTH_GRPC_URL: Joi.string().required(),
SMS_GRPC_URL: Joi.string().required(),
DATABASE_URL: Joi.string().required(),
JWT_SECRET: Joi.string().required(),
JWT_EXPIRATION: Joi.string().required(),
Expand All @@ -39,6 +43,20 @@ import { AuthService } from './auth.service';
}),
inject: [ConfigService],
}),
ClientsModule.registerAsync([
{
name: SMS_SERVICE_NAME,
useFactory: (configService: ConfigService) => ({
transport: Transport.GRPC,
options: {
package: 'sms',
protoPath: join(__dirname, '../../../proto/sms.proto'),
url: configService.getOrThrow<string>('SMS_GRPC_URL'),
},
}),
inject: [ConfigService],
},
]),
LoggerModule,
],
controllers: [AuthController],
Expand Down
64 changes: 47 additions & 17 deletions apps/auth/src/auth.service.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { Injectable, Logger } from '@nestjs/common';
import {
Injectable,
InternalServerErrorException,
Logger,
UnauthorizedException,
} from '@nestjs/common';
import {
AuthRequest,
AuthResponse,
AuthTokenPayload,
LoginUserRequestDto,
RegisterUserRequestDto,
User,
VerifyUserRequestDto,
User,
} from '@bitsacco/common';
import { UsersService } from './users';

Expand All @@ -23,33 +29,57 @@ export class AuthService {
this.logger.log('AuthService initialized');
}

async loginUser(req: LoginUserRequestDto) {
const user = await this.userService.validateUser(req);
return {
token: this.createAuthToken(user),
};
async loginUser(req: LoginUserRequestDto): Promise<AuthResponse> {
try {
const { user, authorized } = await this.userService.validateUser(req);
const token = authorized ? this.createAuthToken(user) : undefined;

return { user, token };
} catch (e) {
this.logger.error(e);
throw new UnauthorizedException('Invalid credentials');
}
}

async registerUser(req: RegisterUserRequestDto) {
return this.userService.registerUser(req);
async registerUser(req: RegisterUserRequestDto): Promise<AuthResponse> {
try {
const user = await this.userService.registerUser(req);

return { user };
} catch (e) {
this.logger.error(e);
throw new InternalServerErrorException('Register user failed');
}
}

async verifyUser(req: VerifyUserRequestDto) {
return this.userService.verifyUser(req);
async verifyUser(req: VerifyUserRequestDto): Promise<AuthResponse> {
try {
const { user, authorized } = await this.userService.verifyUser(req);
const token = authorized ? this.createAuthToken(user) : undefined;

return { user, token };
} catch (e) {
this.logger.error(e);
throw new UnauthorizedException('Invalid credentials');
}
}

async authenticate({ token }: AuthRequest) {
async authenticate({ token }: AuthRequest): Promise<AuthResponse> {
const { user, expires } = this.jwtService.verify<AuthTokenPayload>(token);

if (expires < new Date()) {
throw new Error('Token expired. Unauthenticated');
throw new UnauthorizedException('Token expired');
}

const u = await this.userService.findUser({
id: user.id,
});
try {
await this.userService.findUser({
id: user.id,
});
} catch {
throw new UnauthorizedException('Invalid user');
}

return this.createAuthToken(u);
return { user, token };
}

private createAuthToken(user: User) {
Expand Down
31 changes: 23 additions & 8 deletions apps/auth/src/users/users.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
import { ConfigService } from '@nestjs/config';
import { TestingModule } from '@nestjs/testing';
import { type ClientGrpc } from '@nestjs/microservices';
import { createTestingModuleWithValidation } from '@bitsacco/testing';
import { UsersService } from './users.service';
import { UsersRepository } from './users.repository';
import { ConfigService } from '@nestjs/config';
import { UsersService } from './users.service';
import { SmsServiceClient } from '@bitsacco/common';

describe('UsersService', () => {
let usersService: UsersService;
let mockUsersRepository: UsersRepository;
let mockCfg: {
get: jest.Mock;
getOrThrow: jest.Mock;
};
let serviceGenerator: ClientGrpc;
let mockSmsServiceClient: Partial<SmsServiceClient>;
let mockCfg: ConfigService;

beforeEach(async () => {
mockUsersRepository = {
Expand All @@ -21,14 +22,28 @@ describe('UsersService', () => {
findOneAndDelete: jest.fn(),
} as unknown as UsersRepository;

serviceGenerator = {
getService: jest.fn().mockReturnValue(mockSmsServiceClient),
getClientByServiceName: jest.fn().mockReturnValue(mockSmsServiceClient),
};

mockCfg = {
get: jest.fn(),
getOrThrow: jest.fn(),
};
} as unknown as ConfigService;

const module: TestingModule = await createTestingModuleWithValidation({
providers: [
UsersService,
{
provide: UsersService,
useFactory: () => {
return new UsersService(
mockCfg,
mockUsersRepository,
serviceGenerator,
);
},
},
{
provide: UsersRepository,
useValue: mockUsersRepository,
Expand Down
103 changes: 86 additions & 17 deletions apps/auth/src/users/users.service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as bcrypt from 'bcryptjs';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { Inject, Injectable, Logger } from '@nestjs/common';
import {
RegisterUserRequestDto,
FindUserDto,
Expand All @@ -8,78 +8,147 @@ import {
UsersDocument,
VerifyUserRequestDto,
LoginUserRequestDto,
generateOTP,
SMS_SERVICE_NAME,
SmsServiceClient,
} from '@bitsacco/common';
import { UsersRepository } from './users.repository';
import { ConfigService } from '@nestjs/config';
import { type ClientGrpc } from '@nestjs/microservices';

interface UserAuth {
user: User;
authorized: boolean;
}

@Injectable()
export class UsersService {
private readonly logger = new Logger(UsersService.name);
private readonly smsService: SmsServiceClient;

constructor(
private readonly configService: ConfigService,
private readonly users: UsersRepository,
) {}
@Inject(SMS_SERVICE_NAME) private readonly smsGrpc: ClientGrpc,
) {
this.logger.log('UsersService initialized');
this.smsService =
this.smsGrpc.getService<SmsServiceClient>(SMS_SERVICE_NAME);
}

async validateUser({ pin, phone, npub }: LoginUserRequestDto): Promise<User> {
async validateUser({
pin,
phone,
npub,
}: LoginUserRequestDto): Promise<UserAuth> {
const ud: UsersDocument = await this.queryUser({ phone, npub });

const pinIsValid = await bcrypt.compare(pin, ud.pinHash);
if (!pinIsValid) {
throw new UnauthorizedException('Credentials are not valid.');
throw new Error('Credentials are not valid.');
}

return toUser(ud);
return {
user: toUser(ud),
authorized: true,
};
}

async registerUser({ pin, phone, npub, roles }: RegisterUserRequestDto) {
async registerUser({
pin,
phone,
npub,
roles,
}: RegisterUserRequestDto): Promise<User> {
let salt = await bcrypt.genSalt(
this.configService.getOrThrow('SALT_ROUNDS'),
);
const pinHash = await bcrypt.hash(pin, salt);

const otp = generateOTP();
this.logger.log(`OTP-${otp}`);

const user = await this.users.create({
pinHash,
phone: {
otp,
phone: phone && {
number: phone,
verified: false,
},
nostr: {
nostr: npub && {
npub,
verified: false,
},
profile: undefined,
roles,
});

this.verifyUser({ phone, npub });
this.verifyUser({ otp, phone, npub });

return toUser(user);
}

async findUser(req: FindUserDto) {
async findUser(req: FindUserDto): Promise<User> {
const ud: UsersDocument = await this.queryUser(req);
return toUser(ud);
}

async verifyUser({ otp, phone, npub }: VerifyUserRequestDto) {
async verifyUser({
otp,
phone,
npub,
}: VerifyUserRequestDto): Promise<UserAuth> {
let ud: UsersDocument = await this.queryUser({ phone, npub });

if (!ud) {
throw new Error('User not found');
}

if (otp) {
// check otp against user otp
if (otp !== ud.otp) {
throw new Error('Invalid otp');
}

const newOtp = generateOTP();
this.logger.log(`OTP-${newOtp}`);

ud = await this.users.findOneAndUpdate({ _id: ud._id }, { otp: newOtp });

return {
user: toUser(ud),
authorized: true,
};
}

if (!otp) {
// generate new otp and update user document
// send otp notifications via sms/nostr
// send user otp for verification
if (phone) {
try {
this.smsService.sendSms({
message: ud.otp,
receiver: phone,
});
} catch (e) {
this.logger.error(e);
}
}

if (npub) {
// send otp via nostr
}

return {
user: toUser(ud),
authorized: false,
};
}

return toUser(ud);
}

private async queryUser({ id, phone, npub }: Queryuser) {
private async queryUser({
id,
phone,
npub,
}: Queryuser): Promise<UsersDocument> {
let ud: UsersDocument;

if (id) {
Expand Down
Loading

0 comments on commit 8ee7df3

Please sign in to comment.