Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 18 additions & 4 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,27 @@ datasource db {
}

model User {
id String @id @default(uuid())
email String @unique
id String @id @default(uuid())
email String @unique
passwordHash String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
wallets Wallet[]
intents EscrowIntent[]
resetTokens PasswordResetToken[]
}

model PasswordResetToken {
id String @id @default(uuid())
userId String
tokenHash String
expiresAt DateTime
usedAt DateTime?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)

@@index([tokenHash])
@@index([userId])
}

model Wallet {
Expand Down
38 changes: 38 additions & 0 deletions src/repositories/password-reset-token.repository.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
const prisma = require('../clients/prisma.client');

class PasswordResetTokenRepository {
constructor(dbClient = prisma) {
this.db = dbClient;
}

async create(data) {
return this.db.passwordResetToken.create({ data });
}

async findByTokenHash(tokenHash) {
return this.db.passwordResetToken.findFirst({
where: { tokenHash },
});
}

async markUsed(id) {
return this.db.passwordResetToken.update({
where: { id },
data: { usedAt: new Date() },
});
}

async deleteExpiredByUserId(userId) {
return this.db.passwordResetToken.deleteMany({
where: {
userId,
OR: [
{ expiresAt: { lt: new Date() } },
{ usedAt: { not: null } },
],
},
});
}
}

module.exports = { PasswordResetTokenRepository };
32 changes: 30 additions & 2 deletions src/routes/auth.routes.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
const express = require('express');
const router = express.Router();
const { validate } = require('../middleware/validate.middleware');
const { registerSchema, loginSchema, googleSchema } = require('../validation/schemas/auth.schema');
const { registerSchema, loginSchema, googleSchema, recoverSchema, resetPasswordSchema } = require('../validation/schemas/auth.schema');
const { createAuthService } = require('../services/auth.service');
const { UserRepository } = require('../repositories/user.repository');
const { PasswordResetTokenRepository } = require('../repositories/password-reset-token.repository');
const prisma = require('../clients/prisma.client');

// Initialize dependencies
const userRepository = new UserRepository(prisma);
const authService = createAuthService({ userRepository });
const passwordResetTokenRepository = new PasswordResetTokenRepository(prisma);
const authService = createAuthService({ userRepository, passwordResetTokenRepository });

router.post('/register', validate(registerSchema), async (req, res, next) => {
try {
Expand Down Expand Up @@ -49,4 +51,30 @@ router.post('/google', validate(googleSchema), async (req, res, next) => {
}
});

router.post('/recover', validate(recoverSchema), async (req, res, next) => {
try {
const result = await authService.requestPasswordReset(req.body);
res.status(200).json({
success: true,
message: 'If the email exists, a password recovery token has been issued',
data: result,
});
} catch (error) {
next(error);
}
});

router.post('/reset', validate(resetPasswordSchema), async (req, res, next) => {
try {
const result = await authService.resetPassword(req.body);
res.status(200).json({
success: true,
message: 'Password reset successful',
data: result,
});
} catch (error) {
next(error);
}
});

module.exports = router;
50 changes: 48 additions & 2 deletions src/services/auth.service.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
const crypto = require('crypto');
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const { OAuth2Client } = require('google-auth-library');
const AppError = require('../errors/AppError');
const { loadConfig } = require('../config/env.config');

const createAuthService = ({ userRepository }) => {
const createAuthService = ({ userRepository, passwordResetTokenRepository }) => {
const register = async ({ email, password }) => {
const existingUser = await userRepository.findByEmail(email);
if (existingUser) {
Expand Down Expand Up @@ -93,7 +94,52 @@ const createAuthService = ({ userRepository }) => {
}
};

return { register, login, googleSignIn };
const requestPasswordReset = async ({ email }) => {
const user = await userRepository.findByEmail(email);
if (user) {
const rawToken = crypto.randomBytes(32).toString('hex');
const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex');
const expiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 mins

if (passwordResetTokenRepository.deleteExpiredByUserId) {
await passwordResetTokenRepository.deleteExpiredByUserId(user.id);
}

await passwordResetTokenRepository.create({
userId: user.id,
tokenHash,
expiresAt,
});

// Log raw token for MVP testing (since no email service is hooked up)
console.log(`[PASSWORD RECOVERY] Reset token for ${email}: ${rawToken}`);
}

// Always return success to prevent email enumeration
return { success: true };
};

const resetPassword = async ({ token, newPassword }) => {
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
const resetRecord = await passwordResetTokenRepository.findByTokenHash(tokenHash);

if (!resetRecord || resetRecord.usedAt || resetRecord.expiresAt < new Date()) {
throw new AppError('Invalid or expired reset token', 400);
}

const salt = await bcrypt.genSalt(10);
const hashedPassword = await bcrypt.hash(newPassword, salt);

// Update password
await userRepository.update(resetRecord.userId, { password: hashedPassword });

// Mark token as used
await passwordResetTokenRepository.markUsed(resetRecord.id);

return { success: true };
};

return { register, login, googleSignIn, requestPasswordReset, resetPassword };
};

module.exports = { createAuthService };
21 changes: 21 additions & 0 deletions src/validation/schemas/auth.schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,29 @@ const googleSchema = z.object({
})
});

const recoverSchema = z.object({
body: z.object({
email: z.string().email('Invalid email address'),
})
});

const resetPasswordSchema = z.object({
body: z.object({
token: z.string().min(1, 'Reset token is required'),
newPassword: z
.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
.regex(/[0-9]/, 'Password must contain at least one number')
.regex(/[^A-Za-z0-9]/, 'Password must contain at least one special character'),
})
});

module.exports = {
registerSchema,
loginSchema,
googleSchema,
recoverSchema,
resetPasswordSchema,
};
77 changes: 77 additions & 0 deletions tests/repositories/password-reset-token.repository.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
jest.mock('../../src/clients/prisma.client', () => ({
passwordResetToken: {
create: jest.fn(),
findFirst: jest.fn(),
update: jest.fn(),
deleteMany: jest.fn(),
}
}));

const mockPrismaClient = require('../../src/clients/prisma.client');
const { PasswordResetTokenRepository } = require('../../src/repositories/password-reset-token.repository');

describe('PasswordResetTokenRepository', () => {
let repository;

beforeEach(() => {
jest.clearAllMocks();
repository = new PasswordResetTokenRepository(mockPrismaClient);
});

describe('create', () => {
it('creates a token record', async () => {
const data = { userId: '1', tokenHash: 'hash', expiresAt: new Date() };
mockPrismaClient.passwordResetToken.create.mockResolvedValue({ id: 't1', ...data });

const result = await repository.create(data);

expect(mockPrismaClient.passwordResetToken.create).toHaveBeenCalledWith({ data });
expect(result.id).toBe('t1');
});
});

describe('findByTokenHash', () => {
it('finds a token by its hash', async () => {
const record = { id: 't1', tokenHash: 'hash' };
mockPrismaClient.passwordResetToken.findFirst.mockResolvedValue(record);

const result = await repository.findByTokenHash('hash');

expect(mockPrismaClient.passwordResetToken.findFirst).toHaveBeenCalledWith({
where: { tokenHash: 'hash' },
});
expect(result).toEqual(record);
});
});

describe('markUsed', () => {
it('sets usedAt for a token', async () => {
mockPrismaClient.passwordResetToken.update.mockResolvedValue({ id: 't1', usedAt: new Date() });

await repository.markUsed('t1');

expect(mockPrismaClient.passwordResetToken.update).toHaveBeenCalledWith({
where: { id: 't1' },
data: { usedAt: expect.any(Date) },
});
});
});

describe('deleteExpiredByUserId', () => {
it('deletes expired and used tokens', async () => {
mockPrismaClient.passwordResetToken.deleteMany.mockResolvedValue({ count: 2 });

await repository.deleteExpiredByUserId('u1');

expect(mockPrismaClient.passwordResetToken.deleteMany).toHaveBeenCalledWith({
where: {
userId: 'u1',
OR: [
{ expiresAt: { lt: expect.any(Date) } },
{ usedAt: { not: null } },
],
},
});
});
});
});
61 changes: 61 additions & 0 deletions tests/routes/auth.routes.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ jest.mock('../../src/services/auth.service', () => ({
register: jest.fn(),
login: jest.fn(),
googleSignIn: jest.fn(),
requestPasswordReset: jest.fn(),
resetPassword: jest.fn(),
})
}));

Expand Down Expand Up @@ -104,4 +106,63 @@ describe('Auth Routes', () => {
expect(res.body.message).toContain('body.idToken');
});
});
describe('POST /api/auth/recover', () => {
it('returns 200 with success message on valid email payload', async () => {
authServiceMock.requestPasswordReset.mockResolvedValue({ success: true });

const res = await request(app)
.post('/api/auth/recover')
.send({ email: 'test@test.com' });

expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.message).toContain('password recovery token has been issued');
});

it('returns 400 for invalid email', async () => {
const res = await request(app)
.post('/api/auth/recover')
.send({ email: 'not-an-email' });

expect(res.status).toBe(400);
expect(res.body.success).toBe(false);
expect(res.body.message).toContain('body.email');
});
});

describe('POST /api/auth/reset', () => {
it('returns 200 on successful password reset', async () => {
authServiceMock.resetPassword.mockResolvedValue({ success: true });

const res = await request(app)
.post('/api/auth/reset')
.send({ token: 'valid-token', newPassword: 'Password1!' });

expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.message).toBe('Password reset successful');
});

it('returns 400 for weak newPassword', async () => {
const res = await request(app)
.post('/api/auth/reset')
.send({ token: 'valid-token', newPassword: 'weak' });

expect(res.status).toBe(400);
expect(res.body.success).toBe(false);
expect(res.body.message).toContain('Password must');
});

it('handles service AppError for invalid/expired tokens', async () => {
authServiceMock.resetPassword.mockRejectedValue(new AppError('Invalid or expired reset token', 400));

const res = await request(app)
.post('/api/auth/reset')
.send({ token: 'invalid-token', newPassword: 'Password1!' });

expect(res.status).toBe(400);
expect(res.body.success).toBe(false);
expect(res.body.message).toBe('Invalid or expired reset token');
});
});
});
Loading
Loading