Commit c9394e72 authored by ThinhNC's avatar ThinhNC

feat(auth): add dedicated avatar endpoints, session pagination, and audit log idempotency

parent e125e0ff
...@@ -141,6 +141,7 @@ model User { ...@@ -141,6 +141,7 @@ model User {
asyncJobs AsyncJob[] asyncJobs AsyncJob[]
@@index([roleId]) @@index([roleId])
@@index([deletedAt, isActive])
@@map("users") @@map("users")
} }
...@@ -395,6 +396,8 @@ model RefreshToken { ...@@ -395,6 +396,8 @@ model RefreshToken {
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId]) @@index([userId])
@@index([userId, expiresAt])
@@index([expiresAt])
@@map("refresh_tokens") @@map("refresh_tokens")
} }
...@@ -422,6 +425,8 @@ model VerificationToken { ...@@ -422,6 +425,8 @@ model VerificationToken {
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId]) @@index([userId])
@@index([userId, expiresAt])
@@index([expiresAt])
@@map("verification_tokens") @@map("verification_tokens")
} }
...@@ -435,6 +440,8 @@ model PasswordResetToken { ...@@ -435,6 +440,8 @@ model PasswordResetToken {
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId]) @@index([userId])
@@index([userId, expiresAt])
@@index([expiresAt])
@@map("password_reset_tokens") @@map("password_reset_tokens")
} }
......
...@@ -7,7 +7,10 @@ const ENCRYPTION_ALGORITHM = 'aes-256-gcm'; ...@@ -7,7 +7,10 @@ const ENCRYPTION_ALGORITHM = 'aes-256-gcm';
* Lấy encryption key 32-byte từ APP_SECRET hoặc JWT accessSecret. * Lấy encryption key 32-byte từ APP_SECRET hoặc JWT accessSecret.
*/ */
function getEncryptionKey(): Buffer { function getEncryptionKey(): Buffer {
const secret = process.env.APP_SECRET || envConfig.jwt.accessSecret || 'finwise_default_secure_app_secret_key_32_bytes'; const secret = process.env.APP_SECRET || envConfig.jwt.accessSecret;
if (!secret) {
throw new Error('Encryption secret is not configured. Set APP_SECRET or JWT_ACCESS_SECRET environment variable.');
}
return crypto.createHash('sha256').update(secret).digest(); return crypto.createHash('sha256').update(secret).digest();
} }
......
import { Request, Response, NextFunction } from 'express'; import { Request, Response, NextFunction } from 'express';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { LoginDto, RegisterDto, UpdateProfileDto, UpdatePasswordDto, ForgotPasswordDto, ResetPasswordDto, ResendVerificationDto } from './auth.dto'; import { LoginDto, RegisterDto, UpdateProfileDto, UpdateAvatarDto, UpdatePasswordDto, ForgotPasswordDto, ResetPasswordDto, ResendVerificationDto, SessionQueryDto } from './auth.dto';
import { AppError } from '../../common/errors/app-error'; import { AppError } from '../../common/errors/app-error';
import { ERROR_CODE } from '../../common/errors/error-code'; import { ERROR_CODE } from '../../common/errors/error-code';
...@@ -153,6 +153,35 @@ export class AuthController { ...@@ -153,6 +153,35 @@ export class AuthController {
} }
}; };
updateAvatar = async (req: Request, res: Response, next: NextFunction) => {
try {
const body = req.body as UpdateAvatarDto;
const result = await this.service.updateAvatar(req.user.id, body);
res.json({
success: true,
message: 'Avatar updated successfully',
data: result,
});
} catch (error) {
next(error);
}
};
deleteAvatar = async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await this.service.updateAvatar(req.user.id, { avatarUrl: null });
res.json({
success: true,
message: 'Avatar deleted successfully',
data: result,
});
} catch (error) {
next(error);
}
};
updatePassword = async (req: Request, res: Response, next: NextFunction) => { updatePassword = async (req: Request, res: Response, next: NextFunction) => {
try { try {
const body = req.body as UpdatePasswordDto; const body = req.body as UpdatePasswordDto;
...@@ -216,10 +245,12 @@ export class AuthController { ...@@ -216,10 +245,12 @@ export class AuthController {
req.body?.refreshToken || req.body?.refreshToken ||
(req.headers['x-refresh-token'] as string | undefined) || (req.headers['x-refresh-token'] as string | undefined) ||
(req.query?.refreshToken as string | undefined); (req.query?.refreshToken as string | undefined);
const result = await this.service.getActiveSessions(req.user.id, currentToken); const query = req.query as unknown as SessionQueryDto;
const result = await this.service.getActiveSessions(req.user.id, currentToken, query);
res.json({ res.json({
success: true, success: true,
data: result, data: result.data,
meta: result.meta,
}); });
} catch (error) { } catch (error) {
next(error); next(error);
......
...@@ -43,10 +43,13 @@ export interface LoginResponseDto { ...@@ -43,10 +43,13 @@ export interface LoginResponseDto {
export interface UpdateProfileDto { export interface UpdateProfileDto {
fullName?: string; fullName?: string;
avatarUrl?: string | null; phoneNumber?: string;
}
export interface UpdateAvatarDto {
avatarUrl: string | null;
avatarPositionX?: number; avatarPositionX?: number;
avatarPositionY?: number; avatarPositionY?: number;
phoneNumber?: string;
} }
export interface UpdatePasswordDto { export interface UpdatePasswordDto {
...@@ -66,3 +69,27 @@ export interface ResetPasswordDto { ...@@ -66,3 +69,27 @@ export interface ResetPasswordDto {
export interface ResendVerificationDto { export interface ResendVerificationDto {
email: string; email: string;
} }
export interface SessionQueryDto {
page?: number;
limit?: number;
}
export interface SessionItemDto {
id: string;
deviceName: string;
ipAddress: string;
createdAt: Date;
isCurrent: boolean;
}
export interface SessionsResponseDto {
data: SessionItemDto[];
meta: {
total: number;
page: number;
limit: number;
totalPages: number;
};
}
...@@ -39,6 +39,22 @@ export class AuthRepository { ...@@ -39,6 +39,22 @@ export class AuthRepository {
}); });
} }
async rotateRefreshToken(
oldToken: string,
newRecord: {
userId: string;
token: string;
expiresAt: Date;
userAgent?: string;
ipAddress?: string;
}
) {
return prisma.$transaction(async (tx) => {
await tx.refreshToken.deleteMany({ where: { token: oldToken } });
return tx.refreshToken.create({ data: newRecord });
});
}
async findBySocial(provider: string, providerUserId: string) { async findBySocial(provider: string, providerUserId: string) {
const socialAccount = await prisma.userSocial.findUnique({ const socialAccount = await prisma.userSocial.findUnique({
where: { where: {
...@@ -142,10 +158,18 @@ export class AuthRepository { ...@@ -142,10 +158,18 @@ export class AuthRepository {
async updateProfile(userId: string, data: { async updateProfile(userId: string, data: {
fullName?: string; fullName?: string;
avatarUrl?: string | null; phoneNumber?: string | null;
}) {
return prisma.user.update({
where: { id: userId },
data,
});
}
async updateAvatar(userId: string, data: {
avatarUrl: string | null;
avatarPositionX?: number; avatarPositionX?: number;
avatarPositionY?: number; avatarPositionY?: number;
phoneNumber?: string;
}) { }) {
return prisma.user.update({ return prisma.user.update({
where: { id: userId }, where: { id: userId },
...@@ -231,12 +255,65 @@ export class AuthRepository { ...@@ -231,12 +255,65 @@ export class AuthRepository {
}); });
} }
async findSessionsByUserId(userId: string) { async cleanupExpiredSessions(userId: string) {
return prisma.refreshToken.findMany({ return prisma.refreshToken.deleteMany({
where: { userId }, where: {
userId,
expiresAt: { lte: new Date() },
},
});
}
async enforceSessionLimit(userId: string, maxSessions = 10) {
const activeSessions = await prisma.refreshToken.findMany({
where: {
userId,
expiresAt: { gt: new Date() },
},
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
select: { id: true },
});
if (activeSessions.length > maxSessions) {
const idsToDelete = activeSessions.slice(maxSessions).map((s) => s.id);
await prisma.refreshToken.deleteMany({
where: {
id: { in: idsToDelete },
userId,
},
}); });
} }
}
async findSessionsByUserId(userId: string, query: { page: number; limit: number }) {
const { page, limit } = query;
const skip = (page - 1) * limit;
const now = new Date();
const where = {
userId,
expiresAt: { gt: now },
};
const [sessions, total] = await prisma.$transaction([
prisma.refreshToken.findMany({
where,
orderBy: { createdAt: 'desc' },
skip,
take: limit,
}),
prisma.refreshToken.count({ where }),
]);
return {
data: sessions,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
async findSessionById(userId: string, sessionId: string) { async findSessionById(userId: string, sessionId: string) {
return prisma.refreshToken.findFirst({ return prisma.refreshToken.findFirst({
...@@ -261,3 +338,4 @@ export class AuthRepository { ...@@ -261,3 +338,4 @@ export class AuthRepository {
}); });
} }
} }
...@@ -2,7 +2,7 @@ import { Router } from 'express'; ...@@ -2,7 +2,7 @@ import { Router } from 'express';
import { AuthController } from './auth.controller'; import { AuthController } from './auth.controller';
import { authMiddleware } from '../../middlewares/auth.middleware'; import { authMiddleware } from '../../middlewares/auth.middleware';
import { validate } from '../../middlewares/validate.middleware'; import { validate } from '../../middlewares/validate.middleware';
import { loginSchema, refreshSchema, logoutSchema, registerSchema, verifyEmailSchema, updateProfileSchema, updatePasswordSchema, forgotPasswordSchema, resetPasswordSchema, resendVerificationSchema, sessionParamsSchema, revokeOtherSessionsSchema } from './auth.validation'; import { loginSchema, refreshSchema, logoutSchema, registerSchema, verifyEmailSchema, updateProfileSchema, updateAvatarSchema, updatePasswordSchema, forgotPasswordSchema, resetPasswordSchema, resendVerificationSchema, sessionParamsSchema, sessionQuerySchema, revokeOtherSessionsSchema } from './auth.validation';
const router = Router(); const router = Router();
const controller = new AuthController(); const controller = new AuthController();
...@@ -14,13 +14,15 @@ router.get('/me', authMiddleware, controller.me); ...@@ -14,13 +14,15 @@ router.get('/me', authMiddleware, controller.me);
router.post('/refresh', validate(refreshSchema), controller.refresh); router.post('/refresh', validate(refreshSchema), controller.refresh);
router.post('/logout', validate(logoutSchema), controller.logout); router.post('/logout', validate(logoutSchema), controller.logout);
router.put('/profile', authMiddleware, validate(updateProfileSchema), controller.updateProfile); router.put('/profile', authMiddleware, validate(updateProfileSchema), controller.updateProfile);
router.put('/avatar', authMiddleware, validate(updateAvatarSchema), controller.updateAvatar);
router.delete('/avatar', authMiddleware, controller.deleteAvatar);
router.put('/password', authMiddleware, validate(updatePasswordSchema), controller.updatePassword); router.put('/password', authMiddleware, validate(updatePasswordSchema), controller.updatePassword);
router.post('/forgot-password', validate(forgotPasswordSchema), controller.forgotPassword); router.post('/forgot-password', validate(forgotPasswordSchema), controller.forgotPassword);
router.post('/reset-password', validate(resetPasswordSchema), controller.resetPassword); router.post('/reset-password', validate(resetPasswordSchema), controller.resetPassword);
router.post('/resend-verification', validate(resendVerificationSchema), controller.resendVerification); router.post('/resend-verification', validate(resendVerificationSchema), controller.resendVerification);
// Session management // Session management
router.get('/sessions', authMiddleware, controller.getSessions); router.get('/sessions', authMiddleware, validate(sessionQuerySchema, 'query'), controller.getSessions);
router.delete('/sessions/:id', authMiddleware, validate(sessionParamsSchema, 'params'), controller.revokeSession); router.delete('/sessions/:id', authMiddleware, validate(sessionParamsSchema, 'params'), controller.revokeSession);
router.delete('/sessions', authMiddleware, validate(revokeOtherSessionsSchema), controller.revokeOtherSessions); router.delete('/sessions', authMiddleware, validate(revokeOtherSessionsSchema), controller.revokeOtherSessions);
......
This diff is collapsed.
...@@ -40,6 +40,16 @@ export const verifyEmailSchema = z.object({ ...@@ -40,6 +40,16 @@ export const verifyEmailSchema = z.object({
export const updateProfileSchema = z.object({ export const updateProfileSchema = z.object({
fullName: z.string().min(1, 'Full name cannot be empty').optional(), fullName: z.string().min(1, 'Full name cannot be empty').optional(),
phoneNumber: z
.union([
z.string().regex(/^[0-9]{10,11}$/, 'Invalid phone number format (must be 10-11 digits)'),
z.literal('').transform(() => null),
z.null(),
])
.optional(),
});
export const updateAvatarSchema = z.object({
avatarUrl: z avatarUrl: z
.union([ .union([
z z
...@@ -53,17 +63,9 @@ export const updateProfileSchema = z.object({ ...@@ -53,17 +63,9 @@ export const updateProfileSchema = z.object({
), ),
z.null(), z.null(),
z.literal('').transform(() => null), z.literal('').transform(() => null),
]) ]),
.optional(),
avatarPositionX: z.number().int().min(0).max(100).optional(), avatarPositionX: z.number().int().min(0).max(100).optional(),
avatarPositionY: z.number().int().min(0).max(100).optional(), avatarPositionY: z.number().int().min(0).max(100).optional(),
phoneNumber: z
.union([
z.string().regex(/^[0-9]{10,11}$/, 'Invalid phone number format (must be 10-11 digits)'),
z.literal('').transform(() => null),
z.null(),
])
.optional(),
}); });
export const updatePasswordSchema = z.object({ export const updatePasswordSchema = z.object({
...@@ -108,6 +110,12 @@ export const sessionParamsSchema = z.object({ ...@@ -108,6 +110,12 @@ export const sessionParamsSchema = z.object({
id: z.string().uuid('Invalid session id'), id: z.string().uuid('Invalid session id'),
}); });
export const sessionQuerySchema = z.object({
page: z.coerce.number().int().positive().optional().default(1),
limit: z.coerce.number().int().min(1).max(100).optional().default(10),
});
export const revokeOtherSessionsSchema = z.object({ export const revokeOtherSessionsSchema = z.object({
refreshToken: z.string().optional(), refreshToken: z.string().optional(),
}); });
...@@ -99,14 +99,99 @@ export class QueryCompiler { ...@@ -99,14 +99,99 @@ export class QueryCompiler {
const fromDatePrisma = businessDateToPrismaDate(from); const fromDatePrisma = businessDateToPrismaDate(from);
const toDatePrisma = businessDateToPrismaDate(to); const toDatePrisma = businessDateToPrismaDate(to);
// Branch 1: Handle TRANSFER querying on Prisma Transfer model
if (ast.transactionType === 'TRANSFER') {
const nextDayAfterTo = new Date(toDatePrisma.getTime() + 24 * 60 * 60 * 1000);
const transferWhere: Prisma.TransferWhereInput = {
userId,
transferredAt: {
gte: fromDatePrisma,
lt: nextDayAfterTo,
},
...(ast.walletIds && ast.walletIds.length > 0
? {
OR: [
{ sourceWalletId: { in: ast.walletIds } },
{ destinationWalletId: { in: ast.walletIds } },
],
}
: {}),
...(ast.amountFilter?.minAmount || ast.amountFilter?.maxAmount
? {
amount: {
...(ast.amountFilter.minAmount ? { gte: ast.amountFilter.minAmount } : {}),
...(ast.amountFilter.maxAmount ? { lte: ast.amountFilter.maxAmount } : {}),
},
}
: {}),
};
const agg = await prisma.transfer.aggregate({
where: transferWhere,
_sum: { amount: true },
_count: { id: true },
_avg: { amount: true },
_min: { amount: true },
_max: { amount: true },
});
const totalVal = agg._sum.amount ? agg._sum.amount.toNumber() : 0;
const count = agg._count.id || 0;
const avgVal = agg._avg.amount ? agg._avg.amount.toNumber() : 0;
const minVal = agg._min.amount ? agg._min.amount.toNumber() : null;
const maxVal = agg._max.amount ? agg._max.amount.toNumber() : null;
let items: QueryTransactionItemDto[] | undefined;
if (ast.aggregation === 'LIST' || count > 0) {
const records = await prisma.transfer.findMany({
where: transferWhere,
include: {
sourceWallet: { select: { name: true } },
destinationWallet: { select: { name: true } },
},
orderBy: { transferredAt: 'desc' },
take: ast.limit || 30,
});
items = records.map((r) => ({
id: r.id,
date: instantToBusinessDate(r.transferredAt),
amount: r.amount.toFixed(2),
type: 'EXPENSE' as TransactionType,
description: r.note || `Chuyển từ ${r.sourceWallet.name} sang ${r.destinationWallet.name}`,
categoryName: 'Chuyển tiền',
walletName: `${r.sourceWallet.name} -> ${r.destinationWallet.name}`,
}));
}
const entityLabel = ast.walletNames?.length ? `ví ${ast.walletNames.join(', ')}` : '';
const summary = count === 0
? `Không tìm thấy giao dịch chuyển khoản nào ${entityLabel ? `thuộc ${entityLabel} ` : ''}trong khoảng thời gian ${timeRangeDesc}.`
: `Tổng chuyển khoản ${entityLabel ? `thuộc ${entityLabel} ` : ''}trong ${timeRangeDesc}${totalVal.toLocaleString('vi-VN')} VND qua ${count} giao dịch (bình quân: ${Math.round(avgVal).toLocaleString('vi-VN')} VND/giao dịch).`;
return {
summary,
timeRangeDescription: timeRangeDesc,
aggregation: ast.aggregation,
totalValue: (agg._sum.amount ?? new Prisma.Decimal(0)).toFixed(2),
count,
average: (agg._avg.amount ?? new Prisma.Decimal(0)).toFixed(2),
minValue: agg._min.amount ? agg._min.amount.toFixed(2) : null,
maxValue: agg._max.amount ? agg._max.amount.toFixed(2) : null,
currency: 'VND',
items,
};
}
// Branch 2: Handle Standard INCOME / EXPENSE / ALL
const where: Prisma.TransactionWhereInput = { const where: Prisma.TransactionWhereInput = {
userId, userId,
date: { date: {
gte: fromDatePrisma, gte: fromDatePrisma,
lte: toDatePrisma, lte: toDatePrisma,
}, },
...(ast.transactionType !== 'ALL' ...(ast.transactionType === 'INCOME' || ast.transactionType === 'EXPENSE'
? { type: ast.transactionType as TransactionType } ? { type: ast.transactionType }
: {}), : {}),
...(ast.categoryIds && ast.categoryIds.length > 0 ...(ast.categoryIds && ast.categoryIds.length > 0
? { categoryId: { in: ast.categoryIds } } ? { categoryId: { in: ast.categoryIds } }
...@@ -215,8 +300,6 @@ export class QueryCompiler { ...@@ -215,8 +300,6 @@ export class QueryCompiler {
const typeLabel = const typeLabel =
ast.transactionType === 'INCOME' ast.transactionType === 'INCOME'
? 'thu nhập' ? 'thu nhập'
: ast.transactionType === 'TRANSFER'
? 'chuyển khoản'
: 'chi tiêu'; : 'chi tiêu';
const entityLabel = ast.categoryNames?.length const entityLabel = ast.categoryNames?.length
...@@ -244,11 +327,11 @@ export class QueryCompiler { ...@@ -244,11 +327,11 @@ export class QueryCompiler {
summary, summary,
timeRangeDescription: timeRangeDesc, timeRangeDescription: timeRangeDesc,
aggregation: ast.aggregation, aggregation: ast.aggregation,
totalValue: totalVal.toFixed(2), totalValue: (agg._sum.amount ?? new Prisma.Decimal(0)).toFixed(2),
count, count,
average: avgVal.toFixed(2), average: (agg._avg.amount ?? new Prisma.Decimal(0)).toFixed(2),
minValue: minVal !== null ? minVal.toFixed(2) : null, minValue: agg._min.amount ? agg._min.amount.toFixed(2) : null,
maxValue: maxVal !== null ? maxVal.toFixed(2) : null, maxValue: agg._max.amount ? agg._max.amount.toFixed(2) : null,
currency: resolvedCurrency, currency: resolvedCurrency,
groups, groups,
items, items,
......
...@@ -80,6 +80,18 @@ export class RbacService { ...@@ -80,6 +80,18 @@ export class RbacService {
) { ) {
const role = await this.findRoleById(id); const role = await this.findRoleById(id);
// Check if fields are unchanged
const isNameSame = data.name === undefined || data.name === role.name;
const isDescSame =
data.description === undefined ||
data.description === role.description ||
(!data.description && !role.description);
if (isNameSame && isDescSame) {
this.logger.info(`Role "${role.name}" (ID: ${id}) fields unchanged, skipping update and audit log`);
return role;
}
// Protected: cannot rename ADMIN or SUPER_ADMIN system roles // Protected: cannot rename ADMIN or SUPER_ADMIN system roles
if ( if (
role.isSystem && role.isSystem &&
...@@ -185,13 +197,27 @@ export class RbacService { ...@@ -185,13 +197,27 @@ export class RbacService {
} }
} }
const uniqueNewIds = Array.from(new Set(permissionIds));
const currentPermissionIds = (role.rolePermissions || []).map((rp) => rp.permissionId);
const currentIdSet = new Set(currentPermissionIds);
// If permissions are identical, skip database mutation and audit log
const isUnchanged =
uniqueNewIds.length === currentIdSet.size &&
uniqueNewIds.every((id) => currentIdSet.has(id));
if (isUnchanged) {
this.logger.info(`Permissions for role "${role.name}" (ID: ${roleId}) unchanged, skipping update and audit log`);
return role;
}
// Last Admin Protection: Check if removing critical permissions from the last admin role // Last Admin Protection: Check if removing critical permissions from the last admin role
const criticalPermissions = ['ROLE_PERMISSION_ASSIGN', 'ROLE_UPDATE', 'USER_UPDATE']; const criticalPermissions = ['ROLE_PERMISSION_ASSIGN', 'ROLE_UPDATE', 'USER_UPDATE'];
const currentPermissionNames = await this.repository.getPermissionNamesByRoleId(roleId); const currentPermissionNames = (role.rolePermissions || []).map((rp) => rp.permission.name);
const hasCriticalPerms = criticalPermissions.some((p) => currentPermissionNames.includes(p)); const hasCriticalPerms = criticalPermissions.some((p) => currentPermissionNames.includes(p));
if (hasCriticalPerms) { if (hasCriticalPerms) {
const newPermissions = await this.repository.findPermissionsByIds(permissionIds); const newPermissions = await this.repository.findPermissionsByIds(uniqueNewIds);
const newPermissionNames = newPermissions.map((p) => p.name); const newPermissionNames = newPermissions.map((p) => p.name);
const willRetainCritical = criticalPermissions.some((p) => newPermissionNames.includes(p)); const willRetainCritical = criticalPermissions.some((p) => newPermissionNames.includes(p));
...@@ -210,7 +236,7 @@ export class RbacService { ...@@ -210,7 +236,7 @@ export class RbacService {
} }
} }
const updatedRole = await this.repository.assignRolePermissions(roleId, permissionIds); const updatedRole = await this.repository.assignRolePermissions(roleId, uniqueNewIds);
// Invalidate caches // Invalidate caches
await this.invalidateRoleCache(roleId); await this.invalidateRoleCache(roleId);
...@@ -221,12 +247,12 @@ export class RbacService { ...@@ -221,12 +247,12 @@ export class RbacService {
targetType: 'ROLE', targetType: 'ROLE',
targetId: roleId, targetId: roleId,
previousState: { permissions: currentPermissionNames }, previousState: { permissions: currentPermissionNames },
newState: { permissionIds }, newState: { permissionIds: uniqueNewIds },
ipAddress: metadata?.ipAddress, ipAddress: metadata?.ipAddress,
userAgent: metadata?.userAgent, userAgent: metadata?.userAgent,
}); });
this.logger.info(`Assigned ${permissionIds.length} permissions to role "${role.name}" by actor ${actorId || 'system'}`); this.logger.info(`Assigned ${uniqueNewIds.length} permissions to role "${role.name}" by actor ${actorId || 'system'}`);
return updatedRole; return updatedRole;
} }
...@@ -242,6 +268,12 @@ export class RbacService { ...@@ -242,6 +268,12 @@ export class RbacService {
throw new AppError('Quyền hạn không tồn tại', 404, ERROR_CODE.PERMISSION_NOT_FOUND); throw new AppError('Quyền hạn không tồn tại', 404, ERROR_CODE.PERMISSION_NOT_FOUND);
} }
const currentPermissionIds = (role.rolePermissions || []).map((rp) => rp.permissionId);
if (!currentPermissionIds.includes(permissionId)) {
this.logger.info(`Role "${role.name}" (ID: ${roleId}) does not have permission "${permission.name}", skipping remove`);
return { success: true, message: `Vai trò "${role.name}" không có quyền "${permission.name}"` };
}
// Last Admin Protection check // Last Admin Protection check
if (['ROLE_PERMISSION_ASSIGN', 'ROLE_UPDATE', 'USER_UPDATE'].includes(permission.name)) { if (['ROLE_PERMISSION_ASSIGN', 'ROLE_UPDATE', 'USER_UPDATE'].includes(permission.name)) {
const otherRoles = await this.repository.findRolesWithPermissions([permission.name]); const otherRoles = await this.repository.findRolesWithPermissions([permission.name]);
......
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; import { PutObjectCommand, DeleteObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { AppError } from '../../common/errors/app-error'; import { AppError } from '../../common/errors/app-error';
import { ERROR_CODE } from '../../common/errors/error-code'; import { ERROR_CODE } from '../../common/errors/error-code';
import { envConfig } from '../../config/env.config'; import { envConfig } from '../../config/env.config';
import { LoggerService } from '../../common/services/logger.service';
import { import {
AvatarContentType, AvatarContentType,
CreatePresignedUploadDto, CreatePresignedUploadDto,
...@@ -17,6 +18,8 @@ const EXTENSION_BY_CONTENT_TYPE: Record<AvatarContentType, string> = { ...@@ -17,6 +18,8 @@ const EXTENSION_BY_CONTENT_TYPE: Record<AvatarContentType, string> = {
}; };
export class UploadService { export class UploadService {
private readonly logger = new LoggerService('UploadService');
async createPresignedUpload( async createPresignedUpload(
userId: string, userId: string,
data: CreatePresignedUploadDto, data: CreatePresignedUploadDto,
...@@ -69,4 +72,53 @@ export class UploadService { ...@@ -69,4 +72,53 @@ export class UploadService {
}, },
}; };
} }
async deleteFileByUrl(url: string): Promise<void> {
if (!url) return;
const config = envConfig.r2;
if (
!config.accountId
|| !config.bucketName
|| !config.accessKeyId
|| !config.secretAccessKey
) {
return;
}
try {
let objectKey = '';
if (config.publicBaseUrl && url.startsWith(config.publicBaseUrl)) {
objectKey = url.substring(config.publicBaseUrl.length).replace(/^\/+/, '');
} else {
const avatarIdx = url.indexOf('avatars/');
if (avatarIdx !== -1) {
objectKey = url.substring(avatarIdx);
}
}
if (!objectKey) return;
objectKey = decodeURIComponent(objectKey);
const client = new S3Client({
region: 'auto',
endpoint: `https://${config.accountId}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey,
},
});
const command = new DeleteObjectCommand({
Bucket: config.bucketName,
Key: objectKey,
});
await client.send(command);
this.logger.info(`Deleted old cloud storage file: ${objectKey}`);
} catch (error) {
this.logger.warn(`Failed to delete file from R2 (${url}): ${error instanceof Error ? error.message : String(error)}`);
}
}
} }
...@@ -13,6 +13,7 @@ export interface CreateUserDto { ...@@ -13,6 +13,7 @@ export interface CreateUserDto {
email: string; email: string;
password: string; password: string;
roleId: string; roleId: string;
fullName?: string;
} }
export interface UpdateUserDto { export interface UpdateUserDto {
......
...@@ -2,6 +2,23 @@ import { Prisma } from '@prisma/client'; ...@@ -2,6 +2,23 @@ import { Prisma } from '@prisma/client';
import { prisma } from '../../database/prisma.client'; import { prisma } from '../../database/prisma.client';
import { UserQueryDto } from './user.dto'; import { UserQueryDto } from './user.dto';
export const userSelect = {
id: true,
email: true,
fullName: true,
avatarUrl: true,
avatarPositionX: true,
avatarPositionY: true,
phoneNumber: true,
roleId: true,
isActive: true,
deletedAt: true,
deletedBy: true,
createdAt: true,
updatedAt: true,
role: true,
} satisfies Prisma.UserSelect;
export class UserRepository { export class UserRepository {
async findAll(query: UserQueryDto) { async findAll(query: UserQueryDto) {
const { const {
...@@ -28,7 +45,7 @@ export class UserRepository { ...@@ -28,7 +45,7 @@ export class UserRepository {
const [data, total] = await prisma.$transaction([ const [data, total] = await prisma.$transaction([
prisma.user.findMany({ prisma.user.findMany({
where, where,
include: { role: true }, select: userSelect,
orderBy: { [sortBy]: order }, orderBy: { [sortBy]: order },
skip, skip,
take: limit, take: limit,
...@@ -45,21 +62,21 @@ export class UserRepository { ...@@ -45,21 +62,21 @@ export class UserRepository {
findById(id: string) { findById(id: string) {
return prisma.user.findFirst({ return prisma.user.findFirst({
where: { id, deletedAt: null }, where: { id, deletedAt: null },
include: { role: true }, select: userSelect,
}); });
} }
findDeletedById(id: string) { findDeletedById(id: string) {
return prisma.user.findFirst({ return prisma.user.findFirst({
where: { id, deletedAt: { not: null } }, where: { id, deletedAt: { not: null } },
include: { role: true }, select: userSelect,
}); });
} }
findByEmail(email: string) { findByEmail(email: string) {
return prisma.user.findFirst({ return prisma.user.findFirst({
where: { email, deletedAt: null }, where: { email, deletedAt: null },
include: { role: true }, select: userSelect,
}); });
} }
...@@ -82,7 +99,7 @@ export class UserRepository { ...@@ -82,7 +99,7 @@ export class UserRepository {
roleId: data.roleId, roleId: data.roleId,
isActive: data.isActive, isActive: data.isActive,
}, },
include: { role: true }, select: userSelect,
}); });
} }
...@@ -90,7 +107,7 @@ export class UserRepository { ...@@ -90,7 +107,7 @@ export class UserRepository {
return prisma.user.update({ return prisma.user.update({
where: { id }, where: { id },
data, data,
include: { role: true }, select: userSelect,
}); });
} }
...@@ -103,6 +120,7 @@ export class UserRepository { ...@@ -103,6 +120,7 @@ export class UserRepository {
deletedBy: adminId, deletedBy: adminId,
isActive: false, isActive: false,
}, },
select: userSelect,
}), }),
prisma.refreshToken.deleteMany({ prisma.refreshToken.deleteMany({
where: { userId: id }, where: { userId: id },
...@@ -118,7 +136,7 @@ export class UserRepository { ...@@ -118,7 +136,7 @@ export class UserRepository {
deletedBy: null, deletedBy: null,
isActive: true, isActive: true,
}, },
include: { role: true }, select: userSelect,
}); });
} }
......
...@@ -36,6 +36,7 @@ export class UserService { ...@@ -36,6 +36,7 @@ export class UserService {
return this.repository.create({ return this.repository.create({
email: data.email, email: data.email,
passwordHash, passwordHash,
fullName: data.fullName,
roleId: data.roleId, roleId: data.roleId,
isActive: true, // Admin-created users are active by default isActive: true, // Admin-created users are active by default
}); });
...@@ -49,8 +50,10 @@ export class UserService { ...@@ -49,8 +50,10 @@ export class UserService {
) { ) {
const user = await this.findById(id); const user = await this.findById(id);
const roleChanged = Boolean(data.roleId && data.roleId !== user.roleId);
// If role is changing, delegate to rbacService for safety checks // If role is changing, delegate to rbacService for safety checks
if (data.roleId && data.roleId !== user.roleId) { if (roleChanged && data.roleId) {
await rbacService.updateUserRole(id, data.roleId, actorId, metadata); await rbacService.updateUserRole(id, data.roleId, actorId, metadata);
} }
...@@ -66,10 +69,17 @@ export class UserService { ...@@ -66,10 +69,17 @@ export class UserService {
} }
} }
const updated = await this.repository.update(id, { const updatePayload: { isActive?: boolean; roleId?: string } = {};
isActive: data.isActive, if (data.isActive !== undefined && data.isActive !== user.isActive) {
roleId: data.roleId, updatePayload.isActive = data.isActive;
}); }
if (data.roleId !== undefined && !roleChanged && data.roleId !== user.roleId) {
updatePayload.roleId = data.roleId;
}
const updated = Object.keys(updatePayload).length > 0
? await this.repository.update(id, updatePayload)
: await this.findById(id);
await rbacService.invalidateUserCache(id); await rbacService.invalidateUserCache(id);
......
...@@ -30,6 +30,7 @@ export const createUserSchema = z.object({ ...@@ -30,6 +30,7 @@ export const createUserSchema = z.object({
.regex(/[0-9]/, 'Password must contain at least one number') .regex(/[0-9]/, 'Password must contain at least one number')
.regex(/[^a-zA-Z0-9]/, 'Password must contain at least one special character'), .regex(/[^a-zA-Z0-9]/, 'Password must contain at least one special character'),
roleId: z.string().uuid('Invalid roleId format'), roleId: z.string().uuid('Invalid roleId format'),
fullName: z.string().trim().min(1).max(100).optional(),
}); });
export const updateUserSchema = z.object({ export const updateUserSchema = z.object({
......
...@@ -145,6 +145,105 @@ describe('Auth Integration Tests', () => { ...@@ -145,6 +145,105 @@ describe('Auth Integration Tests', () => {
expect(res.body.data).toHaveProperty('email', testUser.email); expect(res.body.data).toHaveProperty('email', testUser.email);
}); });
it('should update user profile successfully', async () => {
const res = await request(app)
.put('/api/v1/auth/profile')
.set('Authorization', `Bearer ${accessTokenHeader}`)
.send({
fullName: 'Updated Test User',
phoneNumber: '0987654321',
});
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.data.fullName).toBe('Updated Test User');
expect(res.body.data.phoneNumber).toBe('0987654321');
});
it('should handle unchanged profile update gracefully', async () => {
const res = await request(app)
.put('/api/v1/auth/profile')
.set('Authorization', `Bearer ${accessTokenHeader}`)
.send({
fullName: 'Updated Test User',
phoneNumber: '0987654321',
});
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.data.fullName).toBe('Updated Test User');
expect(res.body.data.phoneNumber).toBe('0987654321');
});
it('should update user avatar successfully', async () => {
const res = await request(app)
.put('/api/v1/auth/avatar')
.set('Authorization', `Bearer ${accessTokenHeader}`)
.send({
avatarUrl: 'https://example.com/avatar.png',
avatarPositionX: 60,
avatarPositionY: 40,
});
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.data.avatarUrl).toBe('https://example.com/avatar.png');
expect(res.body.data.avatarPositionX).toBe(60);
expect(res.body.data.avatarPositionY).toBe(40);
});
it('should handle unchanged avatar update gracefully', async () => {
const res = await request(app)
.put('/api/v1/auth/avatar')
.set('Authorization', `Bearer ${accessTokenHeader}`)
.send({
avatarUrl: 'https://example.com/avatar.png',
avatarPositionX: 60,
avatarPositionY: 40,
});
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.data.avatarUrl).toBe('https://example.com/avatar.png');
});
it('should delete user avatar successfully', async () => {
const res = await request(app)
.delete('/api/v1/auth/avatar')
.set('Authorization', `Bearer ${accessTokenHeader}`);
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.data.avatarUrl).toBeNull();
expect(res.body.data.avatarPositionX).toBe(50);
expect(res.body.data.avatarPositionY).toBe(50);
});
it('should get active sessions with pagination and meta', async () => {
const res = await request(app)
.get('/api/v1/auth/sessions?page=1&limit=5')
.set('Authorization', `Bearer ${accessTokenHeader}`);
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(Array.isArray(res.body.data)).toBe(true);
expect(res.body.meta).toBeDefined();
expect(res.body.meta.page).toBe(1);
expect(res.body.meta.limit).toBe(5);
expect(typeof res.body.meta.total).toBe('number');
expect(typeof res.body.meta.totalPages).toBe('number');
});
it('should return 422 for invalid pagination query parameters', async () => {
const res = await request(app)
.get('/api/v1/auth/sessions?page=-1&limit=200')
.set('Authorization', `Bearer ${accessTokenHeader}`);
expect(res.status).toBe(422);
expect(res.body.success).toBe(false);
expect(res.body.code).toBe('VALIDATION_ERROR');
});
it('should logout successfully and clear cookies', async () => { it('should logout successfully and clear cookies', async () => {
// Trích xuất refresh token từ DB để gửi kèm body nếu logout yêu cầu (hoặc qua cookie) // Trích xuất refresh token từ DB để gửi kèm body nếu logout yêu cầu (hoặc qua cookie)
const dbUser = await prisma.user.findUnique({ const dbUser = await prisma.user.findUnique({
......
...@@ -234,6 +234,37 @@ describe('Dynamic RBAC Integration Tests', () => { ...@@ -234,6 +234,37 @@ describe('Dynamic RBAC Integration Tests', () => {
expect(res.body.success).toBe(true); expect(res.body.success).toBe(true);
expect(res.body.data.rolePermissions.length).toBe(4); expect(res.body.data.rolePermissions.length).toBe(4);
}); });
it('should NOT create redundant audit log when assigning identical permissions', async () => {
// Get current audit log count for this role
const initialLogs = await prisma.auditLog.count({
where: { targetId: createdRoleId, action: 'ROLE_PERMISSIONS_ASSIGN' },
});
// Get current role permissions
const role = await prisma.role.findUnique({
where: { id: createdRoleId },
include: { rolePermissions: true },
});
const currentPermIds = role!.rolePermissions.map((rp) => rp.permissionId);
// Re-assign identical permission IDs
const res = await request(app)
.put(`/api/v1/roles/${createdRoleId}/permissions`)
.set('Authorization', `Bearer ${adminToken}`)
.send({
permissionIds: currentPermIds,
});
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
// Audit logs count should NOT increase
const newLogs = await prisma.auditLog.count({
where: { targetId: createdRoleId, action: 'ROLE_PERMISSIONS_ASSIGN' },
});
expect(newLogs).toBe(initialLogs);
});
}); });
describe('3. System Role Protection & Invariants', () => { describe('3. System Role Protection & Invariants', () => {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment