auth
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { Module, forwardRef } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { AdminService } from './providers/admin.service';
|
import { AdminService } from './providers/admin.service';
|
||||||
import { MikroOrmModule } from '@mikro-orm/nestjs';
|
import { MikroOrmModule } from '@mikro-orm/nestjs';
|
||||||
import { Admin } from './entities/admin.entity';
|
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 { RolePermission } from '../roles/entities/rolePermission.entity';
|
||||||
import { AdminController } from './controllers/admin.controller';
|
import { AdminController } from './controllers/admin.controller';
|
||||||
import { UtilsModule } from '../util/utils.module';
|
import { UtilsModule } from '../util/utils.module';
|
||||||
import { RestaurantsModule } from '../restaurants/restaurants.module';
|
|
||||||
import { AdminRole } from './entities/adminRole.entity';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
providers: [AdminService, AdminRepository],
|
providers: [AdminService, AdminRepository],
|
||||||
controllers: [AdminController],
|
controllers: [AdminController],
|
||||||
imports: [
|
imports: [
|
||||||
MikroOrmModule.forFeature([Admin, Role, Permission, RolePermission, AdminRole]),
|
MikroOrmModule.forFeature([Admin, Role, Permission, RolePermission]),
|
||||||
JwtModule,
|
JwtModule,
|
||||||
UtilsModule,
|
UtilsModule,
|
||||||
forwardRef(() => RestaurantsModule),
|
|
||||||
],
|
],
|
||||||
exports: [AdminService, AdminRepository],
|
exports: [AdminService, AdminRepository],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { UpdateAdminDto } from '../dto/update-admin.dto';
|
|||||||
import { normalizePhone } from '../../util/phone.util';
|
import { normalizePhone } from '../../util/phone.util';
|
||||||
import { CreateAdminDto } from '../dto/create-admin.dto';
|
import { CreateAdminDto } from '../dto/create-admin.dto';
|
||||||
import { AdminRepository } from '../repositories/admin.repository';
|
import { AdminRepository } from '../repositories/admin.repository';
|
||||||
import { RoleRepository } from '../repositories/role.repository';
|
import { RoleRepository } from 'src/modules/roles/respository/role.repository';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AdminService {
|
export class AdminService {
|
||||||
@@ -60,7 +60,7 @@ export class AdminService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async findAll(): Promise<Admin[]> {
|
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> {
|
async update(adminId: string, dto: UpdateAdminDto): Promise<Admin> {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export class AdminRepository extends EntityRepository<Admin> {
|
|||||||
const admins = await this.em.find(
|
const admins = await this.em.find(
|
||||||
Admin,
|
Admin,
|
||||||
{ role: { permissions: { name: permission } } },
|
{ role: { permissions: { name: permission } } },
|
||||||
{ populate: ['role', 'roles.permissions'] },
|
{ populate: ['role', 'role.permissions'] },
|
||||||
);
|
);
|
||||||
return admins;
|
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 { Controller, Post, Body, UseGuards } from '@nestjs/common';
|
||||||
import { AuthService } from '../services/auth.service';
|
import { AuthService } from '../services/auth.service';
|
||||||
import { RequestOtpDto } from '../dto/request-otp.dto';
|
import { RequestOtpDto } from '../dto/request-otp.dto';
|
||||||
import { DirectLoginDto } from '../dto/direct-login.dto';
|
|
||||||
import { ApiTags, ApiOperation, ApiBody } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBody } from '@nestjs/swagger';
|
||||||
import { VerifyOtpDto } from '../dto/verify-otp.dto';
|
import { VerifyOtpDto } from '../dto/verify-otp.dto';
|
||||||
import { Throttle } from '@nestjs/throttler';
|
import { Throttle } from '@nestjs/throttler';
|
||||||
import { RefreshTokenRateLimit } from 'src/common/decorators/rate-limit.decorator';
|
import { RefreshTokenRateLimit } from 'src/common/decorators/rate-limit.decorator';
|
||||||
import { RefreshTokenDto } from '../dto/refresh-token.dto';
|
import { RefreshTokenDto } from '../dto/refresh-token.dto';
|
||||||
import { SuperAdminAuthGuard } from '../guards/superAdminAuth.guard';
|
|
||||||
|
|
||||||
@ApiTags('auth')
|
@ApiTags('auth')
|
||||||
@Controller()
|
@Controller()
|
||||||
@@ -26,7 +25,7 @@ export class AuthController {
|
|||||||
@ApiOperation({ summary: 'Verify OTP code' })
|
@ApiOperation({ summary: 'Verify OTP code' })
|
||||||
@ApiBody({ type: VerifyOtpDto, description: 'Mobile number and OTP code' })
|
@ApiBody({ type: VerifyOtpDto, description: 'Mobile number and OTP code' })
|
||||||
otpVerify(@Body() dto: VerifyOtpDto) {
|
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()
|
@RefreshTokenRateLimit()
|
||||||
@@ -48,7 +47,7 @@ export class AuthController {
|
|||||||
@ApiOperation({ summary: 'Verify OTP code' })
|
@ApiOperation({ summary: 'Verify OTP code' })
|
||||||
@ApiBody({ type: VerifyOtpDto, description: 'Mobile number and OTP code' })
|
@ApiBody({ type: VerifyOtpDto, description: 'Mobile number and OTP code' })
|
||||||
otpVerifyAdmin(@Body() dto: VerifyOtpDto) {
|
otpVerifyAdmin(@Body() dto: VerifyOtpDto) {
|
||||||
return this.authService.verifyOtpAdmin(dto.phone, dto.slug, dto.otp);
|
return this.authService.verifyOtpAdmin(dto.phone, dto.otp);
|
||||||
}
|
}
|
||||||
|
|
||||||
@RefreshTokenRateLimit()
|
@RefreshTokenRateLimit()
|
||||||
@@ -58,14 +57,5 @@ export class AuthController {
|
|||||||
return this.authService.refreshToken(refreshTokenDto.refreshToken);
|
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 { Entity, Enum, Index, Property } from '@mikro-orm/core';
|
||||||
|
import { RefreshTokenType } from '../interfaces/IToken-payload';
|
||||||
import { BaseEntity } from '../../../common/entities/base.entity';
|
|
||||||
|
|
||||||
export enum RefreshTokenType {
|
|
||||||
USER = 'user',
|
|
||||||
ADMIN = 'admin',
|
|
||||||
}
|
|
||||||
|
|
||||||
@Entity({ tableName: 'refreshtokens' })
|
@Entity({ tableName: 'refreshtokens' })
|
||||||
@Index({ properties: ['ownerId', '', 'type'] })
|
|
||||||
@Index({ properties: ['hashedToken'] })
|
@Index({ properties: ['hashedToken'] })
|
||||||
@Index({ properties: ['expiresAt'] })
|
export class RefreshToken {
|
||||||
export class RefreshToken extends BaseEntity {
|
|
||||||
@Property({ type: 'varchar', length: 255 })
|
@Property({ type: 'varchar', length: 255 })
|
||||||
hashedToken!: string;
|
hashedToken!: string;
|
||||||
|
|
||||||
@Property()
|
@Property()
|
||||||
ownerId!: string;
|
ownerId!: string;
|
||||||
|
|
||||||
@Property()
|
@Enum(() => RefreshTokenType)
|
||||||
!: string;
|
|
||||||
|
|
||||||
@Enum(() => RefreshTokenType)
|
|
||||||
type!: RefreshTokenType;
|
type!: RefreshTokenType;
|
||||||
|
|
||||||
@Property({ type: 'timestamptz' })
|
@Property({ type: 'timestamptz' })
|
||||||
expiresAt!: Date;
|
expiresAt!: Date;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
export interface ITokenPayload {
|
export interface ITokenPayload {
|
||||||
userId: string;
|
userId: string;
|
||||||
: string;
|
|
||||||
slug: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IAdminTokenPayload {
|
export interface IAdminTokenPayload {
|
||||||
adminId: string;
|
adminId: string;
|
||||||
: string;
|
}
|
||||||
|
|
||||||
|
export enum RefreshTokenType {
|
||||||
|
USER = 'user',
|
||||||
|
ADMIN = 'admin',
|
||||||
}
|
}
|
||||||
@@ -5,8 +5,7 @@ import { SmsService } from '../../notification/services/sms.service';
|
|||||||
import { UserService } from '../../user/providers/user.service';
|
import { UserService } from '../../user/providers/user.service';
|
||||||
import { randomInt } from 'crypto';
|
import { randomInt } from 'crypto';
|
||||||
import { TokensService } from './tokens.service';
|
import { TokensService } from './tokens.service';
|
||||||
import { RestRepository } from 'src/modules/restaurants/repositories/rest.repository';
|
import { AuthMessage } from 'src/common/enums/message.enum';
|
||||||
import { AuthMessage, RestMessage } from 'src/common/enums/message.enum';
|
|
||||||
import { AdminRepository } from 'src/modules/admin/repositories/admin.repository';
|
import { AdminRepository } from 'src/modules/admin/repositories/admin.repository';
|
||||||
import { AdminLoginTransformer } from '../transformers/admin-login.transformer';
|
import { AdminLoginTransformer } from '../transformers/admin-login.transformer';
|
||||||
import { UserLoginTransformer } from '../transformers/user-login.transformer';
|
import { UserLoginTransformer } from '../transformers/user-login.transformer';
|
||||||
@@ -17,31 +16,32 @@ import { normalizePhone } from '../../util/phone.util';
|
|||||||
export class AuthService {
|
export class AuthService {
|
||||||
readonly ADMIN_PERMISSIONS_KEY = 'admin-perms';
|
readonly ADMIN_PERMISSIONS_KEY = 'admin-perms';
|
||||||
private OTP_EXPIRATION_TIME: number;
|
private OTP_EXPIRATION_TIME: number;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly cacheService: CacheService,
|
private readonly cacheService: CacheService,
|
||||||
private readonly smsService: SmsService,
|
private readonly smsService: SmsService,
|
||||||
private readonly userService: UserService,
|
private readonly userService: UserService,
|
||||||
private readonly tokensService: TokensService,
|
private readonly tokensService: TokensService,
|
||||||
private readonly restRepository: RestRepository,
|
|
||||||
private readonly adminRepository: AdminRepository,
|
private readonly adminRepository: AdminRepository,
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
) {
|
) {
|
||||||
this.OTP_EXPIRATION_TIME = this.configService.get<number>('OTP_EXPIRATION_TIME') ?? 240;
|
this.OTP_EXPIRATION_TIME = this.configService.get<number>('OTP_EXPIRATION_TIME') ?? 240;
|
||||||
}
|
}
|
||||||
|
|
||||||
private userOtpKey(restaurantSlug: string, phone: string) {
|
private userOtpKey(phone: string) {
|
||||||
return `otp:${restaurantSlug}:${phone}`;
|
return `otp:${phone}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private adminOtpKey(restaurantSlug: string, phone: string) {
|
private adminOtpKey(phone: string) {
|
||||||
return `otp-admin:${restaurantSlug}:${phone}`;
|
return `otp-admin:${phone}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async requestOtp(dto: RequestOtpDto, isAdmin: boolean) {
|
async requestOtp(dto: RequestOtpDto, isAdmin: boolean) {
|
||||||
const { phone, slug } = dto;
|
const { phone } = dto;
|
||||||
const normalizedPhone = normalizePhone(phone);
|
const normalizedPhone = normalizePhone(phone);
|
||||||
const code = this.generateOtpCode();
|
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.cacheService.set(key, code, this.OTP_EXPIRATION_TIME);
|
||||||
|
|
||||||
await this.smsService.sendotp(normalizedPhone, code);
|
await this.smsService.sendotp(normalizedPhone, code);
|
||||||
@@ -49,33 +49,34 @@ export class AuthService {
|
|||||||
return { code: null };
|
return { code: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
async verifyOtp(phone: string, slug: string, code: string) {
|
async verifyOtp(phone: string, code: string) {
|
||||||
const normalizedPhone = normalizePhone(phone);
|
const normalizedPhone = normalizePhone(phone);
|
||||||
const key = this.userOtpKey(slug, normalizedPhone);
|
const key = this.userOtpKey(normalizedPhone);
|
||||||
const cachedCode = await this.cacheService.get(key);
|
const cachedCode = await this.cacheService.get(key);
|
||||||
if (!cachedCode) throw new BadRequestException('OTP expired or not found');
|
if (!cachedCode) throw new BadRequestException('OTP expired or not found');
|
||||||
if (cachedCode !== code) throw new BadRequestException('Invalid OTP');
|
if (cachedCode !== code) throw new BadRequestException('Invalid OTP');
|
||||||
|
|
||||||
await this.cacheService.del(key);
|
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) {
|
const tokens = await this.tokensService.generateAccessAndRefreshToken(user.id, false);
|
||||||
throw new BadRequestException(RestMessage.NOT_FOUND);
|
|
||||||
}
|
|
||||||
|
|
||||||
const tokens = await this.tokensService.generateAccessAndRefreshToken(user.id, rest.id, false, slug);
|
const userResponse = UserLoginTransformer.transform(user);
|
||||||
|
|
||||||
const userResponse = UserLoginTransformer.transform(user, rest);
|
|
||||||
|
|
||||||
return { tokens, user: userResponse };
|
return { tokens, user: userResponse };
|
||||||
}
|
}
|
||||||
|
|
||||||
async verifyOtpAdmin(phone: string, slug: string, code: string) {
|
async verifyOtpAdmin(phone: string, code: string) {
|
||||||
const normalizedPhone = normalizePhone(phone);
|
const normalizedPhone = normalizePhone(phone);
|
||||||
const key = this.adminOtpKey(slug, normalizedPhone);
|
const key = this.adminOtpKey(normalizedPhone);
|
||||||
const cachedCode = await this.cacheService.get(key);
|
const cachedCode = await this.cacheService.get(key);
|
||||||
|
|
||||||
if (!cachedCode) throw new BadRequestException('OTP expired or not found');
|
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');
|
if (cachedCode !== code) throw new BadRequestException('Invalid OTP');
|
||||||
await this.cacheService.del(key);
|
await this.cacheService.del(key);
|
||||||
|
|
||||||
const admin = await this.adminRepository.findByPhoneAndRestaurantSlug(normalizedPhone, slug);
|
const admin = await this.adminRepository.findOne({ phone: normalizedPhone });
|
||||||
|
|
||||||
if (!admin) {
|
if (!admin) {
|
||||||
throw new BadRequestException(AuthMessage.ADMIN_NOT_FOUND);
|
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) {
|
const adminResponse = await AdminLoginTransformer.transform(admin);
|
||||||
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);
|
|
||||||
|
|
||||||
return { tokens, admin: adminResponse };
|
return { tokens, admin: adminResponse };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import { createHash, randomBytes } from 'node:crypto';
|
import { createHash, randomBytes } from 'node:crypto';
|
||||||
import { EntityManager } from '@mikro-orm/postgresql';
|
import { EntityManager } from '@mikro-orm/postgresql';
|
||||||
import { LockMode } from '@mikro-orm/core';
|
|
||||||
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
import { RefreshTokenType } from '../interfaces/IToken-payload';
|
||||||
import { AuthMessage } from '../../../common/enums/message.enum';
|
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 { IAdminTokenPayload, ITokenPayload } from '../interfaces/IToken-payload';
|
||||||
import { Restaurant } from 'src/modules/restaurants/entities/restaurant.entity';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TokensService {
|
export class TokensService {
|
||||||
|
|
||||||
private readonly logger = new Logger(TokensService.name);
|
private readonly logger = new Logger(TokensService.name);
|
||||||
constructor(
|
constructor(
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
@@ -22,24 +22,22 @@ export class TokensService {
|
|||||||
|
|
||||||
async generateAccessAndRefreshToken(
|
async generateAccessAndRefreshToken(
|
||||||
ownerId: string,
|
ownerId: string,
|
||||||
: string,
|
|
||||||
isAdmin: boolean,
|
isAdmin: boolean,
|
||||||
slug: string,
|
|
||||||
em?: EntityManager,
|
em?: EntityManager,
|
||||||
) {
|
) {
|
||||||
const refreshExpire = this.configService.getOrThrow<number>('REFRESH_TOKEN_EXPIRE');
|
const refreshExpire = this.configService.getOrThrow<number>('REFRESH_TOKEN_EXPIRE');
|
||||||
const accessExpire = this.configService.getOrThrow<number>('JWT_EXPIRATION_TIME');
|
const accessExpire = this.configService.getOrThrow<number>('JWT_EXPIRATION_TIME');
|
||||||
|
|
||||||
const payload: ITokenPayload | IAdminTokenPayload = isAdmin
|
const payload: ITokenPayload | IAdminTokenPayload = isAdmin
|
||||||
? { adminId: ownerId, : }
|
? { adminId: ownerId, }
|
||||||
: { userId: ownerId, : , slug };
|
: { userId: ownerId };
|
||||||
|
|
||||||
const accessToken = await this.generateAccessToken(payload, accessExpire);
|
const accessToken = await this.generateAccessToken(payload, accessExpire);
|
||||||
const refreshToken = this.generateRefreshToken();
|
const refreshToken = this.generateRefreshToken();
|
||||||
const type = isAdmin ? RefreshTokenType.ADMIN : RefreshTokenType.USER;
|
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
|
// 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 {
|
return {
|
||||||
accessToken: { token: accessToken, expire: dayjs().add(accessExpire, 'seconds').valueOf() },
|
accessToken: { token: accessToken, expire: dayjs().add(accessExpire, 'seconds').valueOf() },
|
||||||
@@ -48,15 +46,11 @@ export class TokensService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private generateAccessToken(payload: ITokenPayload | IAdminTokenPayload, expiresIn: number) {
|
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` });
|
return this.jwtService.signAsync(payload, { expiresIn: `${expiresIn}s` });
|
||||||
}
|
}
|
||||||
|
|
||||||
async storeRefreshToken(
|
async storeRefreshToken(
|
||||||
ownerId: string,
|
ownerId: string,
|
||||||
: string,
|
|
||||||
refreshToken: string,
|
refreshToken: string,
|
||||||
type: RefreshTokenType,
|
type: RefreshTokenType,
|
||||||
em?: EntityManager,
|
em?: EntityManager,
|
||||||
@@ -70,76 +64,69 @@ export class TokensService {
|
|||||||
const token = entityManager.create(RefreshToken, {
|
const token = entityManager.create(RefreshToken, {
|
||||||
hashedToken,
|
hashedToken,
|
||||||
ownerId,
|
ownerId,
|
||||||
,
|
|
||||||
type,
|
type,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
if(em) {
|
if (em) {
|
||||||
// Within transaction, just persist (flush will be called by transaction)
|
// Within transaction, just persist (flush will be called by transaction)
|
||||||
entityManager.persist(token);
|
entityManager.persist(token);
|
||||||
} else {
|
} else {
|
||||||
// Outside transaction, persist and flush immediately
|
// Outside transaction, persist and flush immediately
|
||||||
await entityManager.persistAndFlush(token);
|
await entityManager.persistAndFlush(token);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async refreshToken(oldRefreshToken: string) {
|
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 => {
|
||||||
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 });
|
|
||||||
|
|
||||||
if (!token) {
|
const token = await em.findOne(RefreshToken, { hashedToken });
|
||||||
throw new UnauthorizedException(AuthMessage.INVALID_REFRESH_TOKEN);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check expiration
|
if (!token) {
|
||||||
if (dayjs(token.expiresAt).isBefore(dayjs())) {
|
throw new UnauthorizedException(AuthMessage.INVALID_REFRESH_TOKEN);
|
||||||
em.remove(token);
|
}
|
||||||
await em.flush();
|
|
||||||
throw new UnauthorizedException(AuthMessage.REFRESH_TOKEN_EXPIRED);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store token data before removal
|
// Check expiration
|
||||||
const ownerId = token.ownerId;
|
if (dayjs(token.expiresAt).isBefore(dayjs())) {
|
||||||
const = token.;
|
em.remove(token);
|
||||||
const isAdmin = token.type === RefreshTokenType.ADMIN;
|
await em.flush();
|
||||||
|
throw new UnauthorizedException(AuthMessage.REFRESH_TOKEN_EXPIRED);
|
||||||
|
}
|
||||||
|
|
||||||
// Verify restaurant still exists
|
// Store token data before removal
|
||||||
const restaurant = await em.findOne(Restaurant, { id: });
|
const ownerId = token.ownerId;
|
||||||
if (!restaurant) {
|
const isAdmin = token.type === RefreshTokenType.ADMIN;
|
||||||
throw new UnauthorizedException(AuthMessage.INVALID_REFRESH_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, restaurant.id, isAdmin, restaurant.slug, em);
|
|
||||||
|
|
||||||
// Remove old token only after new token is created
|
try {
|
||||||
em.remove(token);
|
// 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
|
// Remove old token only after new token is created
|
||||||
await em.flush();
|
em.remove(token);
|
||||||
|
|
||||||
return tokens;
|
// Persist changes atomically
|
||||||
} catch (error) {
|
await em.flush();
|
||||||
this.logger.error('Failed to generate new tokens after refresh', error);
|
|
||||||
// Transaction will rollback automatically, preserving the old token
|
return tokens;
|
||||||
throw new UnauthorizedException('Failed to refresh token');
|
} 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() {
|
private generateRefreshToken() {
|
||||||
return randomBytes(32).toString('hex');
|
return randomBytes(32).toString('hex');
|
||||||
}
|
}
|
||||||
|
|
||||||
private hashToken(token: string): string {
|
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 { Admin } from '../../admin/entities/admin.entity';
|
||||||
import type { Restaurant } from '../../restaurants/entities/restaurant.entity';
|
|
||||||
|
|
||||||
export interface AdminLoginResponse {
|
export interface AdminLoginResponse {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -8,86 +8,20 @@ export interface AdminLoginResponse {
|
|||||||
phone: string;
|
phone: string;
|
||||||
role: string;
|
role: string;
|
||||||
permissions: string[];
|
permissions: string[];
|
||||||
restaurant?: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
slug: string;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AdminLoginTransformer {
|
export class AdminLoginTransformer {
|
||||||
static async transform(admin: Admin, restaurant?: Restaurant): Promise<AdminLoginResponse> {
|
static async transform(admin: Admin): 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: admin.id,
|
id: admin.id,
|
||||||
firstName: admin.firstName,
|
firstName: admin.firstName,
|
||||||
lastName: admin.lastName,
|
lastName: admin.lastName,
|
||||||
phone: admin.phone,
|
phone: admin.phone,
|
||||||
role: role.name,
|
role: admin.role.name,
|
||||||
permissions,
|
permissions: admin.role.permissions.map(p => p.name),
|
||||||
restaurant: restaurant
|
|
||||||
? {
|
|
||||||
id: restaurant.id,
|
|
||||||
name: restaurant.name,
|
|
||||||
slug: restaurant.slug,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +1,21 @@
|
|||||||
import type { User } from '../../user/entities/user.entity';
|
import type { User } from '../../user/entities/user.entity';
|
||||||
import type { Restaurant } from '../../restaurants/entities/restaurant.entity';
|
|
||||||
|
|
||||||
export interface UserLoginResponse {
|
export interface UserLoginResponse {
|
||||||
id: string;
|
id: string;
|
||||||
firstName: string;
|
firstName: string;
|
||||||
lastName?: string;
|
lastName?: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
|
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
restaurant: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
slug: string;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UserLoginTransformer {
|
export class UserLoginTransformer {
|
||||||
static transform(user: User, restaurant: Restaurant): UserLoginResponse {
|
static transform(user: User): UserLoginResponse {
|
||||||
return {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
firstName: user.firstName,
|
firstName: user.firstName,
|
||||||
lastName: user.lastName,
|
lastName: user.lastName,
|
||||||
phone: user.phone,
|
phone: user.phone,
|
||||||
isActive: user.isActive,
|
isActive: user.isActive,
|
||||||
restaurant: {
|
|
||||||
id: restaurant.id,
|
|
||||||
name: restaurant.name,
|
|
||||||
slug: restaurant.slug,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
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 { User } from '../entities/user.entity';
|
||||||
import { UserAddress } from '../entities/user-address.entity';
|
import { UserAddress } from '../entities/user-address.entity';
|
||||||
import { EntityManager } from '@mikro-orm/postgresql';
|
import { EntityManager } from '@mikro-orm/postgresql';
|
||||||
@@ -17,7 +17,7 @@ export class UserService {
|
|||||||
private readonly em: EntityManager,
|
private readonly em: EntityManager,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
async create(dto: CreateUserDto): Promise<User> {
|
async getOrCreate(dto: CreateUserDto): Promise<User> {
|
||||||
const normalizedPhone = normalizePhone(dto.phone)
|
const normalizedPhone = normalizePhone(dto.phone)
|
||||||
|
|
||||||
const currentUser = await this.userRepository.findOne({ phone: normalizedPhone });
|
const currentUser = await this.userRepository.findOne({ phone: normalizedPhone });
|
||||||
|
|||||||
Reference in New Issue
Block a user