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
40 changes: 29 additions & 11 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -32,17 +32,20 @@ enum UserStatus {
}

model User {
id String @id @default(cuid())
email String @unique
username String? @unique
passwordHash String?
walletAddress String? @unique
role Role @default(DONOR)
status UserStatus @default(PENDING_VERIFICATION)
emailVerified Boolean @default(false)
lastLogin DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(cuid())
email String @unique
username String? @unique
passwordHash String?
walletAddress String? @unique
role Role @default(DONOR)
status UserStatus @default(PENDING_VERIFICATION)
emailVerified Boolean @default(false)
verificationToken String? @unique
verificationExpiry DateTime?
failedVerifyAttempts Int @default(0)
lastLogin DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

// Relations
sessions Session[]
Expand All @@ -66,6 +69,21 @@ model User {
@@index([status])
}

model VerificationLog {
id String @id @default(cuid())
userId String
action String // 'SENT' | 'VERIFIED' | 'EXPIRED' | 'RESENT' | 'FAILED'
tokenHash String?
ipAddress String?
userAgent String?
createdAt DateTime @default(now())

user User @relation(fields: [userId], references: [id], onDelete: Cascade)

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

model Session {
id String @id @default(cuid())
userId String
Expand Down
13 changes: 13 additions & 0 deletions src/config/__mocks__/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,19 @@ const prismaMock = {
findUnique: jest.fn(),
findMany: jest.fn(),
count: jest.fn(),
create: jest.fn(),
update: jest.fn(),
},
verificationLog: {
create: jest.fn(),
findMany: jest.fn(),
},
session: {
create: jest.fn(),
findUnique: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
deleteMany: jest.fn(),
},
auditLog: {
create: jest.fn().mockResolvedValue({}),
Expand Down
107 changes: 83 additions & 24 deletions src/controllers/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,68 @@ import logger from '../config/logger';
export class AuthController {
static async register(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const result = await AuthService.register(req.body);

const result = await AuthService.register(req.body, {
ipAddress: req.ip,
userAgent: req.headers['user-agent'],
});

res.status(201).json({
success: true,
data: result,
message: 'User registered successfully',
data: { userId: result.userId },
message: result.message,
});
} catch (error) {
next(error);
}
}

static async verifyEmail(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { token } = req.query;

if (!token || typeof token !== 'string') {
throw new AppError('Verification token is required', 400);
}

await AuthService.verifyEmail(token, {
ipAddress: req.ip,
userAgent: req.headers['user-agent'],
});

res.status(200).json({
success: true,
message: 'Email verified. You can now log in.',
});
} catch (error) {
if (error instanceof AppError && error.statusCode === 400) {
res.status(400).json({
success: false,
code: 'VERIFICATION_FAILED',
message: error.message,
resendUrl: '/api/v1/auth/resend-verification',
});
return;
}
next(error);
}
}

static async resendVerification(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { email } = req.body;

if (!email) {
throw new AppError('Email is required', 400);
}

const result = await AuthService.resendVerificationEmail(email, {
ipAddress: req.ip,
userAgent: req.headers['user-agent'],
});

res.status(200).json({
success: true,
...result,
});
} catch (error) {
next(error);
Expand All @@ -22,13 +78,25 @@ export class AuthController {
static async login(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const result = await AuthService.login(req.body);

res.status(200).json({
success: true,
data: result,
message: 'Login successful',
});
} catch (error) {
if (error instanceof AppError && error.statusCode === 403 && !req.body._suppressVerifyHint) {
const message = error.message;
if (message.includes('verify your email')) {
res.status(403).json({
success: false,
code: 'EMAIL_NOT_VERIFIED',
message,
resendUrl: '/api/v1/auth/resend-verification',
});
return;
}
}
next(error);
}
}
Expand All @@ -37,7 +105,7 @@ export class AuthController {
try {
const { walletAddress, signature, message } = req.body;
const result = await AuthService.walletAuth(walletAddress, signature, message);

res.status(200).json({
success: true,
data: result,
Expand All @@ -51,13 +119,13 @@ export class AuthController {
static async refreshToken(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { refreshToken } = req.body;

if (!refreshToken) {
throw new AppError('Refresh token is required', 400);
}

const tokens = await AuthService.refreshToken(refreshToken);

res.status(200).json({
success: true,
data: tokens,
Expand All @@ -71,17 +139,14 @@ export class AuthController {
static async logout(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
const token = req.headers.authorization?.substring(7);

if (!req.user || !token) {
throw new AppError('Authentication required', 401);
}

await AuthService.logout(req.user.id, token);

res.status(200).json({
success: true,
message: 'Logout successful',
});

res.status(200).json({ success: true, message: 'Logout successful' });
} catch (error) {
next(error);
}
Expand All @@ -94,11 +159,8 @@ export class AuthController {
}

await AuthService.logoutAll(req.user.id);

res.status(200).json({
success: true,
message: 'Logged out from all devices',
});

res.status(200).json({ success: true, message: 'Logged out from all devices' });
} catch (error) {
next(error);
}
Expand All @@ -111,11 +173,8 @@ export class AuthController {
}

const user = await AuthService.getUserById(req.user.id);

res.status(200).json({
success: true,
data: user,
});

res.status(200).json({ success: true, data: user });
} catch (error) {
next(error);
}
Expand Down
37 changes: 30 additions & 7 deletions src/middleware/auth.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Request, Response, NextFunction } from 'express';
import { JWTUtils } from '../utils/jwt';
import { AuthRequest } from '../types';
import prisma from '../config/database';
import logger from '../config/logger';
import prisma from '../config/database';

Expand All @@ -11,12 +12,9 @@ export const authenticate = async (
): Promise<void> => {
try {
const authHeader = req.headers.authorization;

if (!authHeader || !authHeader.startsWith('Bearer ')) {
res.status(401).json({
success: false,
error: 'No token provided',
});
res.status(401).json({ success: false, error: 'No token provided' });
return;
}

Expand All @@ -32,11 +30,36 @@ export const authenticate = async (
next();
} catch (error) {
logger.error('Authentication error:', error);
res.status(401).json({
res.status(401).json({ success: false, error: 'Invalid or expired token' });
}
};

export const requireVerified = async (
req: AuthRequest,
res: Response,
next: NextFunction
): Promise<void> => {
if (!req.user) {
res.status(401).json({ success: false, error: 'Authentication required' });
return;
}

const user = await prisma.user.findUnique({
where: { id: req.user.id },
select: { emailVerified: true },
});

if (!user?.emailVerified) {
res.status(403).json({
success: false,
error: 'Invalid or expired token',
code: 'EMAIL_NOT_VERIFIED',
message: 'Please verify your email before accessing this feature.',
resendUrl: '/api/v1/auth/resend-verification',
});
return;
}

next();
};

export const authorize = (...roles: string[]) => {
Expand Down
Loading