This commit is contained in:
2026-01-14 00:05:13 +03:30
parent d046dc21ac
commit 1075dbc44f
12 changed files with 106 additions and 296 deletions
+3 -5
View File
@@ -1,4 +1,4 @@
import { Module, forwardRef } from '@nestjs/common';
import { Module } from '@nestjs/common';
import { AdminService } from './providers/admin.service';
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { Admin } from './entities/admin.entity';
@@ -9,17 +9,15 @@ import { Permission } from '../roles/entities/permission.entity';
import { RolePermission } from '../roles/entities/rolePermission.entity';
import { AdminController } from './controllers/admin.controller';
import { UtilsModule } from '../util/utils.module';
import { RestaurantsModule } from '../restaurants/restaurants.module';
import { AdminRole } from './entities/adminRole.entity';
@Module({
providers: [AdminService, AdminRepository],
controllers: [AdminController],
imports: [
MikroOrmModule.forFeature([Admin, Role, Permission, RolePermission, AdminRole]),
MikroOrmModule.forFeature([Admin, Role, Permission, RolePermission]),
JwtModule,
UtilsModule,
forwardRef(() => RestaurantsModule),
],
exports: [AdminService, AdminRepository],
})
+2 -2
View File
@@ -6,7 +6,7 @@ import { UpdateAdminDto } from '../dto/update-admin.dto';
import { normalizePhone } from '../../util/phone.util';
import { CreateAdminDto } from '../dto/create-admin.dto';
import { AdminRepository } from '../repositories/admin.repository';
import { RoleRepository } from '../repositories/role.repository';
import { RoleRepository } from 'src/modules/roles/respository/role.repository';
@Injectable()
export class AdminService {
@@ -60,7 +60,7 @@ export class AdminService {
}
async findAll(): Promise<Admin[]> {
return this.adminRepository.find({}, { populate: ['role', 'roles.role'] });
return this.adminRepository.find({}, { populate: ['role'] });
}
async update(adminId: string, dto: UpdateAdminDto): Promise<Admin> {
@@ -14,7 +14,7 @@ export class AdminRepository extends EntityRepository<Admin> {
const admins = await this.em.find(
Admin,
{ role: { permissions: { name: permission } } },
{ populate: ['role', 'roles.permissions'] },
{ populate: ['role', 'role.permissions'] },
);
return admins;
}
@@ -1,50 +0,0 @@
import type { Admin } from '../entities/admin.entity';
export interface AdminDetailResponse {
id: string;
firstName?: string;
lastName?: string;
phone: string;
role: {
id: string;
name: string;
};
permissions: string[];
restaurant?: {
id: string;
name: string;
slug: string;
};
}
export class AdminDetailTransformer {
static transform(admin: Admin): AdminDetailResponse | null {
if (!admin) {
return null;
}
// Extract role information
const role = admin.roles.getItems().find(r => r.role)?.role;
if (!role) {
return null;
}
return {
id: admin.id,
firstName: admin.firstName,
lastName: admin.lastName,
phone: admin.phone,
role: {
id: role.id,
name: role.name,
},
permissions: role.permissions.getItems().map(p => p.name) || [],
restaurant: {
id: role.restaurant?.id || '',
name: role.restaurant?.name || '',
slug: role.restaurant?.slug || '',
},
};
}
}
// Extract permissions - ensure collection is loaded if needed
@@ -1,13 +1,12 @@
import { Controller, Post, Body, UseGuards } from '@nestjs/common';
import { AuthService } from '../services/auth.service';
import { RequestOtpDto } from '../dto/request-otp.dto';
import { DirectLoginDto } from '../dto/direct-login.dto';
import { ApiTags, ApiOperation, ApiBody } from '@nestjs/swagger';
import { VerifyOtpDto } from '../dto/verify-otp.dto';
import { Throttle } from '@nestjs/throttler';
import { RefreshTokenRateLimit } from 'src/common/decorators/rate-limit.decorator';
import { RefreshTokenDto } from '../dto/refresh-token.dto';
import { SuperAdminAuthGuard } from '../guards/superAdminAuth.guard';
@ApiTags('auth')
@Controller()
@@ -26,7 +25,7 @@ export class AuthController {
@ApiOperation({ summary: 'Verify OTP code' })
@ApiBody({ type: VerifyOtpDto, description: 'Mobile number and OTP code' })
otpVerify(@Body() dto: VerifyOtpDto) {
return this.authService.verifyOtp(dto.phone, dto.slug, dto.otp); // assuming dto has `otp` property
return this.authService.verifyOtp(dto.phone, dto.otp);
}
@RefreshTokenRateLimit()
@@ -48,7 +47,7 @@ export class AuthController {
@ApiOperation({ summary: 'Verify OTP code' })
@ApiBody({ type: VerifyOtpDto, description: 'Mobile number and OTP code' })
otpVerifyAdmin(@Body() dto: VerifyOtpDto) {
return this.authService.verifyOtpAdmin(dto.phone, dto.slug, dto.otp);
return this.authService.verifyOtpAdmin(dto.phone, dto.otp);
}
@RefreshTokenRateLimit()
@@ -58,14 +57,5 @@ export class AuthController {
return this.authService.refreshToken(refreshTokenDto.refreshToken);
}
//super admin routes
@UseGuards(SuperAdminAuthGuard)
@Post('super-admin/auth/direct-login')
@ApiOperation({ summary: 'Direct login for DSC - returns login credentials' })
@ApiBody({ type: DirectLoginDto, description: 'Phone number and restaurant slug for direct login' })
directloginForDsc(@Body() dto: DirectLoginDto) {
return this.authService.loginAdminForDsc(dto.phone, dto.slug);
}
}
@@ -1,29 +1,19 @@
import { Entity, Enum, Index, Property } from '@mikro-orm/core';
import { BaseEntity } from '../../../common/entities/base.entity';
export enum RefreshTokenType {
USER = 'user',
ADMIN = 'admin',
}
import { RefreshTokenType } from '../interfaces/IToken-payload';
@Entity({ tableName: 'refreshtokens' })
@Index({ properties: ['ownerId', '', 'type'] })
@Index({ properties: ['hashedToken'] })
@Index({ properties: ['expiresAt'] })
export class RefreshToken extends BaseEntity {
export class RefreshToken {
@Property({ type: 'varchar', length: 255 })
hashedToken!: string;
@Property()
ownerId!: string;
@Property()
!: string;
@Enum(() => RefreshTokenType)
@Enum(() => RefreshTokenType)
type!: RefreshTokenType;
@Property({ type: 'timestamptz' })
expiresAt!: Date;
@Property({ type: 'timestamptz' })
expiresAt!: Date;
}
@@ -1,10 +1,12 @@
export interface ITokenPayload {
userId: string;
: string;
slug: string;
}
export interface IAdminTokenPayload {
adminId: string;
: string;
}
export enum RefreshTokenType {
USER = 'user',
ADMIN = 'admin',
}
+25 -54
View File
@@ -5,8 +5,7 @@ import { SmsService } from '../../notification/services/sms.service';
import { UserService } from '../../user/providers/user.service';
import { randomInt } from 'crypto';
import { TokensService } from './tokens.service';
import { RestRepository } from 'src/modules/restaurants/repositories/rest.repository';
import { AuthMessage, RestMessage } from 'src/common/enums/message.enum';
import { AuthMessage } from 'src/common/enums/message.enum';
import { AdminRepository } from 'src/modules/admin/repositories/admin.repository';
import { AdminLoginTransformer } from '../transformers/admin-login.transformer';
import { UserLoginTransformer } from '../transformers/user-login.transformer';
@@ -17,31 +16,32 @@ import { normalizePhone } from '../../util/phone.util';
export class AuthService {
readonly ADMIN_PERMISSIONS_KEY = 'admin-perms';
private OTP_EXPIRATION_TIME: number;
constructor(
private readonly cacheService: CacheService,
private readonly smsService: SmsService,
private readonly userService: UserService,
private readonly tokensService: TokensService,
private readonly restRepository: RestRepository,
private readonly adminRepository: AdminRepository,
private readonly configService: ConfigService,
) {
this.OTP_EXPIRATION_TIME = this.configService.get<number>('OTP_EXPIRATION_TIME') ?? 240;
}
private userOtpKey(restaurantSlug: string, phone: string) {
return `otp:${restaurantSlug}:${phone}`;
private userOtpKey(phone: string) {
return `otp:${phone}`;
}
private adminOtpKey(restaurantSlug: string, phone: string) {
return `otp-admin:${restaurantSlug}:${phone}`;
private adminOtpKey(phone: string) {
return `otp-admin:${phone}`;
}
async requestOtp(dto: RequestOtpDto, isAdmin: boolean) {
const { phone, slug } = dto;
const { phone } = dto;
const normalizedPhone = normalizePhone(phone);
const code = this.generateOtpCode();
const key = isAdmin ? this.adminOtpKey(slug, normalizedPhone) : this.userOtpKey(slug, normalizedPhone);
const key = isAdmin ? this.adminOtpKey(normalizedPhone) : this.userOtpKey(normalizedPhone);
await this.cacheService.set(key, code, this.OTP_EXPIRATION_TIME);
await this.smsService.sendotp(normalizedPhone, code);
@@ -49,33 +49,34 @@ export class AuthService {
return { code: null };
}
async verifyOtp(phone: string, slug: string, code: string) {
async verifyOtp(phone: string, code: string) {
const normalizedPhone = normalizePhone(phone);
const key = this.userOtpKey(slug, normalizedPhone);
const key = this.userOtpKey(normalizedPhone);
const cachedCode = await this.cacheService.get(key);
if (!cachedCode) throw new BadRequestException('OTP expired or not found');
if (cachedCode !== code) throw new BadRequestException('Invalid OTP');
await this.cacheService.del(key);
const user = await this.userService.findOrCreateByPhone(normalizedPhone);
const user = await this.userService.getOrCreate({
firstName: '[کاربر]',
lastName: '',
phone: normalizedPhone,
gender: true,
maxCredit: 0
});
const rest = await this.restRepository.findOne({ slug });
if (!rest) {
throw new BadRequestException(RestMessage.NOT_FOUND);
}
const tokens = await this.tokensService.generateAccessAndRefreshToken(user.id, false);
const tokens = await this.tokensService.generateAccessAndRefreshToken(user.id, rest.id, false, slug);
const userResponse = UserLoginTransformer.transform(user, rest);
const userResponse = UserLoginTransformer.transform(user);
return { tokens, user: userResponse };
}
async verifyOtpAdmin(phone: string, slug: string, code: string) {
async verifyOtpAdmin(phone: string, code: string) {
const normalizedPhone = normalizePhone(phone);
const key = this.adminOtpKey(slug, normalizedPhone);
const key = this.adminOtpKey(normalizedPhone);
const cachedCode = await this.cacheService.get(key);
if (!cachedCode) throw new BadRequestException('OTP expired or not found');
@@ -83,45 +84,15 @@ export class AuthService {
if (cachedCode !== code) throw new BadRequestException('Invalid OTP');
await this.cacheService.del(key);
const admin = await this.adminRepository.findByPhoneAndRestaurantSlug(normalizedPhone, slug);
const admin = await this.adminRepository.findOne({ phone: normalizedPhone });
if (!admin) {
throw new BadRequestException(AuthMessage.ADMIN_NOT_FOUND);
}
const rest = await this.restRepository.findOne({ slug });
const tokens = await this.tokensService.generateAccessAndRefreshToken(admin.id, true);
if (!rest) {
throw new BadRequestException(RestMessage.NOT_FOUND);
}
const tokens = await this.tokensService.generateAccessAndRefreshToken(admin.id, rest.id, true, slug);
const adminResponse = await AdminLoginTransformer.transform(admin, rest);
return { tokens, admin: adminResponse };
}
/**
*
to use for login directly from DSC
*/
async loginAdminForDsc(phone: string, slug: string) {
const normalizedPhone = normalizePhone(phone);
const admin = await this.adminRepository.findByPhoneAndRestaurantSlug(normalizedPhone, slug);
if (!admin) {
throw new BadRequestException(AuthMessage.ADMIN_NOT_FOUND);
}
const rest = await this.restRepository.findOne({ slug });
if (!rest) {
throw new BadRequestException(RestMessage.NOT_FOUND);
}
const tokens = await this.tokensService.generateAccessAndRefreshToken(admin.id, rest.id, true, slug);
const adminResponse = await AdminLoginTransformer.transform(admin, rest);
const adminResponse = await AdminLoginTransformer.transform(admin);
return { tokens, admin: adminResponse };
}
+52 -65
View File
@@ -1,18 +1,18 @@
import { createHash, randomBytes } from 'node:crypto';
import { EntityManager } from '@mikro-orm/postgresql';
import { LockMode } from '@mikro-orm/core';
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import dayjs from 'dayjs';
import { RefreshTokenType } from '../interfaces/IToken-payload';
import { AuthMessage } from '../../../common/enums/message.enum';
import { RefreshToken, RefreshTokenType } from '../entities/refresh-token.entity';
import { RefreshToken } from '../entities/refresh-token.entity';
import { IAdminTokenPayload, ITokenPayload } from '../interfaces/IToken-payload';
import { Restaurant } from 'src/modules/restaurants/entities/restaurant.entity';
@Injectable()
export class TokensService {
private readonly logger = new Logger(TokensService.name);
constructor(
private readonly configService: ConfigService,
@@ -22,24 +22,22 @@ export class TokensService {
async generateAccessAndRefreshToken(
ownerId: string,
: string,
isAdmin: boolean,
slug: string,
em?: EntityManager,
) {
const refreshExpire = this.configService.getOrThrow<number>('REFRESH_TOKEN_EXPIRE');
const accessExpire = this.configService.getOrThrow<number>('JWT_EXPIRATION_TIME');
const payload: ITokenPayload | IAdminTokenPayload = isAdmin
? { adminId: ownerId, : }
: { userId: ownerId, : , slug };
? { adminId: ownerId, }
: { userId: ownerId };
const accessToken = await this.generateAccessToken(payload, accessExpire);
const refreshToken = this.generateRefreshToken();
const type = isAdmin ? RefreshTokenType.ADMIN : RefreshTokenType.USER;
// Only pass em if it's a transaction manager (not this.em), otherwise pass undefined to trigger flush
await this.storeRefreshToken(ownerId, , refreshToken, type, em);
await this.storeRefreshToken(ownerId, refreshToken, type, em);
return {
accessToken: { token: accessToken, expire: dayjs().add(accessExpire, 'seconds').valueOf() },
@@ -48,15 +46,11 @@ export class TokensService {
}
private generateAccessToken(payload: ITokenPayload | IAdminTokenPayload, expiresIn: number) {
// Ensure expiresIn is passed as a string with time unit for reliability
// JWT library accepts: number (seconds) or string with unit (e.g., "3600s", "1h")
// Using string format is more explicit and prevents unit confusion
return this.jwtService.signAsync(payload, { expiresIn: `${expiresIn}s` });
}
async storeRefreshToken(
ownerId: string,
: string,
refreshToken: string,
type: RefreshTokenType,
em?: EntityManager,
@@ -70,76 +64,69 @@ export class TokensService {
const token = entityManager.create(RefreshToken, {
hashedToken,
ownerId,
,
type,
expiresAt,
});
if(em) {
// Within transaction, just persist (flush will be called by transaction)
entityManager.persist(token);
} else {
// Outside transaction, persist and flush immediately
await entityManager.persistAndFlush(token);
}
if (em) {
// Within transaction, just persist (flush will be called by transaction)
entityManager.persist(token);
} else {
// Outside transaction, persist and flush immediately
await entityManager.persistAndFlush(token);
}
}
async refreshToken(oldRefreshToken: string) {
const hashedToken = this.hashToken(oldRefreshToken);
const hashedToken = this.hashToken(oldRefreshToken);
// Use transaction to ensure atomicity and prevent race conditions
return this.em.transactional(async em => {
// Lock the token row to prevent concurrent refresh attempts
// Using pessimistic write lock (FOR UPDATE) to prevent race conditions
const token = await em.findOne(RefreshToken, { hashedToken }, { lockMode: LockMode.PESSIMISTIC_WRITE });
return this.em.transactional(async em => {
if (!token) {
throw new UnauthorizedException(AuthMessage.INVALID_REFRESH_TOKEN);
}
const token = await em.findOne(RefreshToken, { hashedToken });
// Check expiration
if (dayjs(token.expiresAt).isBefore(dayjs())) {
em.remove(token);
await em.flush();
throw new UnauthorizedException(AuthMessage.REFRESH_TOKEN_EXPIRED);
}
if (!token) {
throw new UnauthorizedException(AuthMessage.INVALID_REFRESH_TOKEN);
}
// Store token data before removal
const ownerId = token.ownerId;
const = token.;
const isAdmin = token.type === RefreshTokenType.ADMIN;
// Check expiration
if (dayjs(token.expiresAt).isBefore(dayjs())) {
em.remove(token);
await em.flush();
throw new UnauthorizedException(AuthMessage.REFRESH_TOKEN_EXPIRED);
}
// Verify restaurant still exists
const restaurant = await em.findOne(Restaurant, { id: });
if (!restaurant) {
throw new UnauthorizedException(AuthMessage.INVALID_REFRESH_TOKEN);
}
// Store token data before removal
const ownerId = token.ownerId;
const isAdmin = token.type === RefreshTokenType.ADMIN;
try {
// Generate new tokens first (before removing old token)
// Pass the transaction EntityManager to ensure operations are within the transaction
const tokens = await this.generateAccessAndRefreshToken(ownerId, restaurant.id, isAdmin, restaurant.slug, em);
// Remove old token only after new token is created
em.remove(token);
try {
// Generate new tokens first (before removing old token)
// Pass the transaction EntityManager to ensure operations are within the transaction
const tokens = await this.generateAccessAndRefreshToken(ownerId, isAdmin, em);
// Persist changes atomically
await em.flush();
// Remove old token only after new token is created
em.remove(token);
return tokens;
} catch (error) {
this.logger.error('Failed to generate new tokens after refresh', error);
// Transaction will rollback automatically, preserving the old token
throw new UnauthorizedException('Failed to refresh token');
}
});
}
// Persist changes atomically
await em.flush();
return tokens;
} catch (error) {
this.logger.error('Failed to generate new tokens after refresh', error);
// Transaction will rollback automatically, preserving the old token
throw new UnauthorizedException('Failed to refresh token');
}
});
}
private generateRefreshToken() {
return randomBytes(32).toString('hex');
}
return randomBytes(32).toString('hex');
}
private hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
return createHash('sha256').update(token).digest('hex');
}
}
@@ -1,5 +1,5 @@
import type { Admin } from '../../admin/entities/admin.entity';
import type { Restaurant } from '../../restaurants/entities/restaurant.entity';
export interface AdminLoginResponse {
id: string;
@@ -8,86 +8,20 @@ export interface AdminLoginResponse {
phone: string;
role: string;
permissions: string[];
restaurant?: {
id: string;
name: string;
slug: string;
};
}
export class AdminLoginTransformer {
static async transform(admin: Admin, restaurant?: Restaurant): Promise<AdminLoginResponse> {
// Find the AdminRole that matches the restaurant (or get the first one)
let adminRole = admin.roles.getItems().find(r => (restaurant ? r.restaurant?.id === restaurant.id : true));
// If no match found, get the first one
if (!adminRole && admin.roles.getItems().length > 0) {
adminRole = admin.roles.getItems()[0];
}
if (!adminRole) {
return {
id: admin.id,
firstName: admin.firstName,
lastName: admin.lastName,
phone: admin.phone,
role: '',
permissions: [],
restaurant: restaurant
? {
id: restaurant.id,
name: restaurant.name,
slug: restaurant.slug,
}
: undefined,
};
}
// Get the role from AdminRole
const role = adminRole.role;
if (!role) {
return {
id: admin.id,
firstName: admin.firstName,
lastName: admin.lastName,
phone: admin.phone,
role: '',
permissions: [],
restaurant: restaurant
? {
id: restaurant.id,
name: restaurant.name,
slug: restaurant.slug,
}
: undefined,
};
}
// Extract permissions - ensure collection is loaded if needed
let permissions: string[] = [];
if (role.permissions) {
if (role.permissions.isInitialized()) {
permissions = role.permissions.getItems().map(p => p.name);
} else {
await role.permissions.loadItems();
permissions = role.permissions.getItems().map(p => p.name);
}
}
static async transform(admin: Admin): Promise<AdminLoginResponse> {
return {
id: admin.id,
firstName: admin.firstName,
lastName: admin.lastName,
phone: admin.phone,
role: role.name,
permissions,
restaurant: restaurant
? {
id: restaurant.id,
name: restaurant.name,
slug: restaurant.slug,
}
: undefined,
role: admin.role.name,
permissions: admin.role.permissions.map(p => p.name),
};
}
}
@@ -1,33 +1,21 @@
import type { User } from '../../user/entities/user.entity';
import type { Restaurant } from '../../restaurants/entities/restaurant.entity';
export interface UserLoginResponse {
id: string;
firstName: string;
lastName?: string;
phone: string;
isActive?: boolean;
restaurant: {
id: string;
name: string;
slug: string;
};
}
export class UserLoginTransformer {
static transform(user: User, restaurant: Restaurant): UserLoginResponse {
static transform(user: User): UserLoginResponse {
return {
id: user.id,
firstName: user.firstName,
lastName: user.lastName,
phone: user.phone,
isActive: user.isActive,
restaurant: {
id: restaurant.id,
name: restaurant.name,
slug: restaurant.slug,
},
};
}
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { RequiredEntityData } from '@mikro-orm/core';
import { RequiredEntityData } from '@mikro-orm/core';
import { User } from '../entities/user.entity';
import { UserAddress } from '../entities/user-address.entity';
import { EntityManager } from '@mikro-orm/postgresql';
@@ -17,7 +17,7 @@ export class UserService {
private readonly em: EntityManager,
) { }
async create(dto: CreateUserDto): Promise<User> {
async getOrCreate(dto: CreateUserDto): Promise<User> {
const normalizedPhone = normalizePhone(dto.phone)
const currentUser = await this.userRepository.findOne({ phone: normalizedPhone });