init
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { AuthService } from './services/auth.service';
|
||||
import { AuthController } from './controllers/auth.controller';
|
||||
import { UtilsModule } from '../utils/utils.module';
|
||||
import { UserModule } from '../users/user.module';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { AdminModule } from '../admin/admin.module';
|
||||
import { TokensService } from './services/tokens.service';
|
||||
import { RestaurantsModule } from '../restaurants/restaurants.module';
|
||||
import { MikroOrmModule } from '@mikro-orm/nestjs';
|
||||
import { User } from '../users/entities/user.entity';
|
||||
import { AdminAuthGuard } from './guards/adminAuth.guard';
|
||||
import { RefreshToken } from './entities/refresh-token.entity';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
UtilsModule,
|
||||
forwardRef(() => UserModule),
|
||||
JwtModule.registerAsync({
|
||||
useFactory: (configService: ConfigService) => {
|
||||
const expiresIn = configService.getOrThrow<number>('JWT_EXPIRATION_TIME');
|
||||
return {
|
||||
global: true,
|
||||
secret: configService.getOrThrow<string>('JWT_SECRET'),
|
||||
signOptions: {
|
||||
// Use string format with time unit for explicit expiration (value should be in seconds)
|
||||
expiresIn: `${expiresIn}s`,
|
||||
},
|
||||
};
|
||||
},
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
forwardRef(() => AdminModule),
|
||||
forwardRef(() => RestaurantsModule),
|
||||
MikroOrmModule.forFeature([User, RefreshToken]),
|
||||
forwardRef(() => NotificationsModule),
|
||||
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, TokensService, AdminAuthGuard],
|
||||
exports: [AdminAuthGuard],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -0,0 +1,71 @@
|
||||
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()
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) { }
|
||||
|
||||
@Throttle({ default: { limit: 3, ttl: 180_000 } })
|
||||
@Post('public/auth/otp/request')
|
||||
@ApiOperation({ summary: 'Request OTP for login or signup' })
|
||||
@ApiBody({ type: RequestOtpDto, description: 'Mobile number to receive OTP' })
|
||||
otpRequest(@Body() dto: RequestOtpDto) {
|
||||
return this.authService.requestOtp(dto, false);
|
||||
}
|
||||
|
||||
@Post('public/auth/otp/verify')
|
||||
@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
|
||||
}
|
||||
|
||||
@RefreshTokenRateLimit()
|
||||
@ApiOperation({ summary: 'refresh the user access token / refresh token' })
|
||||
@Post('public/auth/refresh')
|
||||
refreshToken(@Body() refreshTokenDto: RefreshTokenDto) {
|
||||
return this.authService.refreshToken(refreshTokenDto.refreshToken);
|
||||
}
|
||||
|
||||
@Throttle({ default: { limit: 3, ttl: 180_000 } })
|
||||
@Post('admin/auth/otp/request')
|
||||
@ApiOperation({ summary: 'Request OTP for login or signup' })
|
||||
@ApiBody({ type: RequestOtpDto, description: 'Mobile number to receive OTP' })
|
||||
otpRequestAdmin(@Body() dto: RequestOtpDto) {
|
||||
return this.authService.requestOtp(dto, true);
|
||||
}
|
||||
|
||||
@Post('admin/auth/otp/verify')
|
||||
@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);
|
||||
}
|
||||
|
||||
@RefreshTokenRateLimit()
|
||||
@ApiOperation({ summary: 'refresh the admin access token / refresh token' })
|
||||
@Post('admin/auth/refresh')
|
||||
refreshTokenAdmin(@Body() refreshTokenDto: RefreshTokenDto) {
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { IsNotEmpty, IsString, IsMobilePhone } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class DirectLoginDto {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
@ApiProperty({ example: '09362532122', description: 'Mobile number' })
|
||||
@IsMobilePhone('fa-IR')
|
||||
phone: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
@ApiProperty({ example: 'zhivan', description: 'Restaurant slug' })
|
||||
slug: string;
|
||||
}
|
||||
Executable
+9
@@ -0,0 +1,9 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class RefreshTokenDto {
|
||||
@ApiProperty({ description: 'Refresh token' })
|
||||
@IsString({ message: 'Refresh token must be a string' })
|
||||
@IsNotEmpty({ message: 'Refresh token is required' })
|
||||
refreshToken: string;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { IsNotEmpty, IsString, IsMobilePhone } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class RequestOtpDto {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
@ApiProperty({ example: '09362532122', description: 'Mobile number' })
|
||||
@IsMobilePhone('fa-IR')
|
||||
phone: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
@ApiProperty({ example: 'zhivan', description: 'restaurant slug' })
|
||||
slug: string;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { IsNotEmpty, IsString, Length, IsMobilePhone } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class VerifyOtpDto {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
@ApiProperty({ example: '09362532122', description: 'Mobile number' })
|
||||
@IsMobilePhone('fa-IR')
|
||||
phone: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
@ApiProperty({ example: '', description: 'Otp' })
|
||||
@Length(5)
|
||||
otp: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
@ApiProperty({ example: 'zhivan', description: 'restaurant slug' })
|
||||
slug: string;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Entity, Enum, Index, Property } from '@mikro-orm/core';
|
||||
|
||||
import { BaseEntity } from '../../../common/entities/base.entity';
|
||||
|
||||
export enum RefreshTokenType {
|
||||
USER = 'user',
|
||||
ADMIN = 'admin',
|
||||
}
|
||||
|
||||
@Entity({ tableName: 'refreshtokens' })
|
||||
@Index({ properties: ['ownerId', 'restId', 'type'] })
|
||||
@Index({ properties: ['hashedToken'] })
|
||||
@Index({ properties: ['expiresAt'] })
|
||||
export class RefreshToken extends BaseEntity {
|
||||
@Property({ type: 'varchar', length: 255 })
|
||||
hashedToken!: string;
|
||||
|
||||
@Property()
|
||||
ownerId!: string;
|
||||
|
||||
@Property()
|
||||
restId!: string;
|
||||
|
||||
@Enum(() => RefreshTokenType)
|
||||
type!: RefreshTokenType;
|
||||
|
||||
@Property({ type: 'timestamptz' })
|
||||
expiresAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
Inject,
|
||||
Logger,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { Request } from 'express';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { IAdminTokenPayload } from '../interfaces/IToken-payload';
|
||||
import { PERMISSIONS_KEY } from '../../../common/decorators/permissions.decorator';
|
||||
import { PermissionsService } from 'src/modules/roles/providers/permissions.service';
|
||||
|
||||
export interface AdminAuthRequest extends Request {
|
||||
adminId: string;
|
||||
restId: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AdminAuthGuard implements CanActivate {
|
||||
private readonly logger = new Logger(AdminAuthGuard.name);
|
||||
|
||||
constructor(
|
||||
@Inject(JwtService)
|
||||
private readonly jwtService: JwtService,
|
||||
@Inject(ConfigService)
|
||||
private readonly configService: ConfigService,
|
||||
private readonly permissionsService: PermissionsService,
|
||||
private readonly reflector: Reflector,
|
||||
) { }
|
||||
|
||||
async canActivate(context: ExecutionContext) {
|
||||
const request = context.switchToHttp().getRequest<AdminAuthRequest>();
|
||||
|
||||
const token = this.extractTokenFromHeader(request);
|
||||
if (!token) {
|
||||
this.logger.warn('No token provided', {
|
||||
hasAuthHeader: !!request.headers.authorization,
|
||||
authHeader: request.headers.authorization ? 'present' : 'missing',
|
||||
headers: Object.keys(request.headers),
|
||||
});
|
||||
throw new UnauthorizedException('No token provided');
|
||||
}
|
||||
|
||||
try {
|
||||
// Get the JWT secret from config
|
||||
const secret = this.configService.getOrThrow<string>('JWT_SECRET');
|
||||
|
||||
// Verify token with the secret
|
||||
const payload = await this.jwtService.verifyAsync<IAdminTokenPayload>(token, {
|
||||
secret,
|
||||
});
|
||||
|
||||
if (!payload.adminId || !payload.restId) {
|
||||
this.logger.error('Invalid token payload structure', payload);
|
||||
throw new UnauthorizedException('Invalid token payload');
|
||||
}
|
||||
|
||||
request['adminId'] = payload.adminId;
|
||||
request['restId'] = payload.restId;
|
||||
|
||||
// check if the user has the required permissions
|
||||
const requiredPermissions =
|
||||
this.reflector.getAllAndOverride<string[]>(PERMISSIONS_KEY, [context.getHandler(), context.getClass()]) ?? [];
|
||||
|
||||
if (!requiredPermissions || requiredPermissions.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const adminPermission = await this.permissionsService.getAdminPermissions(payload.adminId, payload.restId);
|
||||
if (!adminPermission || !Array.isArray(adminPermission)) {
|
||||
this.logger.error('No permissions found', { adminId: payload.adminId, restId: payload.restId });
|
||||
throw new ForbiddenException('No permissions found');
|
||||
}
|
||||
|
||||
const hasPermission = requiredPermissions.every(p => adminPermission.includes(p));
|
||||
|
||||
if (!hasPermission) {
|
||||
this.logger.warn('Insufficient permissions', {
|
||||
adminId: payload.adminId,
|
||||
restId: payload.restId,
|
||||
required: requiredPermissions,
|
||||
has: adminPermission,
|
||||
});
|
||||
throw new ForbiddenException('You are not authorized to access this resource');
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (err instanceof ForbiddenException || err instanceof UnauthorizedException) {
|
||||
throw err;
|
||||
}
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
||||
const errorStack = err instanceof Error ? err.stack : undefined;
|
||||
this.logger.error('Token verification error in AdminAuthGuard', {
|
||||
error: errorMessage,
|
||||
stack: errorStack,
|
||||
});
|
||||
throw new UnauthorizedException('Invalid or expired token');
|
||||
}
|
||||
}
|
||||
|
||||
private extractTokenFromHeader(request: Request): string | undefined {
|
||||
const authHeader = request.headers.authorization || request.headers['authorization'];
|
||||
if (!authHeader) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const [type, token] = authHeader.split(' ');
|
||||
if (type?.toLowerCase() !== 'bearer' || !token) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return token;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException, Inject, Logger } from '@nestjs/common';
|
||||
import { Request } from 'express';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { ITokenPayload } from '../interfaces/IToken-payload';
|
||||
|
||||
export interface UserAuthRequest extends Request {
|
||||
userId: string;
|
||||
restId: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthGuard implements CanActivate {
|
||||
private readonly logger = new Logger(AuthGuard.name);
|
||||
|
||||
constructor(
|
||||
@Inject(JwtService)
|
||||
private readonly jwtService: JwtService,
|
||||
@Inject(ConfigService)
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext) {
|
||||
const request = context.switchToHttp().getRequest<UserAuthRequest>();
|
||||
|
||||
try {
|
||||
const secret = this.configService.getOrThrow<string>('JWT_SECRET');
|
||||
const token = this.extractTokenFromHeader(request);
|
||||
const slug = this.extractSlugFromHeader(request);
|
||||
if (!slug) {
|
||||
throw new UnauthorizedException('Slug is required');
|
||||
}
|
||||
if (!token) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await this.jwtService.verifyAsync<ITokenPayload>(token, {
|
||||
secret,
|
||||
});
|
||||
if (!payload.userId || !payload.restId) {
|
||||
this.logger.error('Invalid token payload structure', payload);
|
||||
throw new UnauthorizedException('Invalid token payload');
|
||||
}
|
||||
if (payload.slug !== slug) {
|
||||
throw new UnauthorizedException('Invalid slug');
|
||||
}
|
||||
request['userId'] = payload.userId;
|
||||
request['restId'] = payload.restId;
|
||||
} catch {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedException) {
|
||||
throw err;
|
||||
}
|
||||
this.logger.error('error in AuthGuard', err);
|
||||
throw new UnauthorizedException('Invalid or expired token');
|
||||
}
|
||||
}
|
||||
private extractTokenFromHeader(request: Request): string | undefined {
|
||||
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
||||
return type === 'Bearer' ? token : undefined;
|
||||
}
|
||||
private extractSlugFromHeader(request: Request): string | undefined {
|
||||
// Fastify normalizes headers to lowercase, so check both cases
|
||||
const slug = (request.headers['x-slug'] || request.headers['X-Slug']) as string;
|
||||
return slug;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { CanActivate, ExecutionContext, Injectable, Inject, Logger } from '@nestjs/common';
|
||||
import { Request } from 'express';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { ITokenPayload } from '../interfaces/IToken-payload';
|
||||
|
||||
export interface UserOptionalAuthRequest extends Request {
|
||||
userId?: string;
|
||||
restId?: string;
|
||||
slug?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class OptionalAuthGuard implements CanActivate {
|
||||
private readonly logger = new Logger(OptionalAuthGuard.name);
|
||||
|
||||
constructor(
|
||||
@Inject(JwtService)
|
||||
private readonly jwtService: JwtService,
|
||||
@Inject(ConfigService)
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<UserOptionalAuthRequest>();
|
||||
const token = this.extractTokenFromHeader(request);
|
||||
const slug = this.extractSlugFromHeader(request);
|
||||
|
||||
// If no token is provided, allow the request without authentication
|
||||
if (!slug) {
|
||||
console.log('No slug provided');
|
||||
this.logger.warn('No slug provided');
|
||||
return false;
|
||||
}
|
||||
if (!token) {
|
||||
request['slug'] = slug;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Try to verify the token if it exists
|
||||
try {
|
||||
const secret = this.configService.getOrThrow<string>('JWT_SECRET');
|
||||
const payload = await this.jwtService.verifyAsync<ITokenPayload>(token, {
|
||||
secret,
|
||||
});
|
||||
|
||||
// Validate payload structure
|
||||
if (!payload.userId || !payload.restId) {
|
||||
this.logger.warn('Invalid token payload structure', payload);
|
||||
// Allow request but don't set user info
|
||||
request['slug'] = slug;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Validate slug if provided
|
||||
if (slug && payload.slug !== slug) {
|
||||
this.logger.warn('Token slug does not match header slug', {
|
||||
tokenSlug: payload.slug,
|
||||
headerSlug: slug,
|
||||
});
|
||||
// Allow request but don't set user info
|
||||
request['slug'] = slug;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Token is valid, set user info
|
||||
request['userId'] = payload.userId;
|
||||
request['restId'] = payload.restId;
|
||||
request['slug'] = slug;
|
||||
return true;
|
||||
} catch (err) {
|
||||
// Token is invalid or expired, but allow the request anyway
|
||||
this.logger.debug('Token verification failed in OptionalAuthGuard', {
|
||||
error: err instanceof Error ? err.message : 'Unknown error',
|
||||
});
|
||||
// Set restId from slug
|
||||
|
||||
request['slug'] = slug;
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private extractTokenFromHeader(request: Request): string | undefined {
|
||||
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
||||
return type === 'Bearer' ? token : undefined;
|
||||
}
|
||||
|
||||
private extractSlugFromHeader(request: Request): string | undefined {
|
||||
// Fastify normalizes headers to lowercase, so check both cases
|
||||
const slug = (request.headers['x-slug'] || request.headers['X-Slug']) as string;
|
||||
return slug;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException, Inject, Logger } from '@nestjs/common';
|
||||
import { Request } from 'express';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
@Injectable()
|
||||
export class SuperAdminAuthGuard implements CanActivate {
|
||||
private readonly logger = new Logger(SuperAdminAuthGuard.name);
|
||||
|
||||
constructor(
|
||||
@Inject(ConfigService)
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
|
||||
try {
|
||||
const credentials = this.extractBasicAuthCredentials(request);
|
||||
if (!credentials) {
|
||||
throw new UnauthorizedException('Basic authentication required');
|
||||
}
|
||||
|
||||
const { username, password } = credentials;
|
||||
const expectedUsername = this.configService.get<string>('SUPER_ADMIN_USERNAME') ?? 'danak@dsc.com';
|
||||
const expectedPassword = this.configService.get<string>('SUPER_ADMIN_PASSWORD') ?? 'DsCdAnAk?@ABC';
|
||||
if (username !== expectedUsername || password !== expectedPassword) {
|
||||
this.logger.warn(`Invalid super admin credentials attempt for username: ${username}`);
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedException) {
|
||||
throw err;
|
||||
}
|
||||
this.logger.error('error in SuperAdminAuthGuard', err);
|
||||
throw new UnauthorizedException('Authentication failed');
|
||||
}
|
||||
}
|
||||
|
||||
private extractBasicAuthCredentials(request: Request): { username: string; password: string } | null {
|
||||
const authHeader = request.headers.authorization;
|
||||
if (!authHeader) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [type, encoded] = authHeader.split(' ');
|
||||
if (type?.toLowerCase() !== 'basic' || !encoded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = Buffer.from(encoded, 'base64').toString('utf-8');
|
||||
const [username, password] = decoded.split(':');
|
||||
|
||||
if (!username || !password) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { username, password };
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to decode Basic Auth credentials', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
export interface ITokenPayload {
|
||||
userId: string;
|
||||
restId: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
export interface IAdminTokenPayload {
|
||||
adminId: string;
|
||||
restId: string;
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { Injectable, BadRequestException } from '@nestjs/common';
|
||||
import { RequestOtpDto } from '../dto/request-otp.dto';
|
||||
import { CacheService } from '../../utils/cache.service';
|
||||
import { SmsService } from '../../notifications/services/sms.service';
|
||||
import { UserService } from '../../users/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 { AdminRepository } from 'src/modules/admin/repositories/admin.repository';
|
||||
import { AdminLoginTransformer } from '../transformers/admin-login.transformer';
|
||||
import { UserLoginTransformer } from '../transformers/user-login.transformer';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { normalizePhone } from '../../utils/phone.util';
|
||||
|
||||
@Injectable()
|
||||
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 adminOtpKey(restaurantSlug: string, phone: string) {
|
||||
return `otp-admin:${restaurantSlug}:${phone}`;
|
||||
}
|
||||
|
||||
async requestOtp(dto: RequestOtpDto, isAdmin: boolean) {
|
||||
const { phone, slug } = dto;
|
||||
const normalizedPhone = normalizePhone(phone);
|
||||
|
||||
if (isAdmin) {
|
||||
const admin = await this.adminRepository.findByPhoneAndRestaurantSlug(normalizedPhone, slug);
|
||||
if (!admin) {
|
||||
throw new BadRequestException(AuthMessage.ADMIN_NOT_FOUND);
|
||||
}
|
||||
}
|
||||
const code = this.generateOtpCode();
|
||||
const key = isAdmin ? this.adminOtpKey(slug, normalizedPhone) : this.userOtpKey(slug, normalizedPhone);
|
||||
await this.cacheService.set(key, code, this.OTP_EXPIRATION_TIME);
|
||||
|
||||
await this.smsService.sendotp(normalizedPhone, code);
|
||||
|
||||
return { code: null };
|
||||
}
|
||||
|
||||
async verifyOtp(phone: string, slug: string, code: string) {
|
||||
const normalizedPhone = normalizePhone(phone);
|
||||
const key = this.userOtpKey(slug, 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 rest = await this.restRepository.findOne({ slug });
|
||||
|
||||
if (!rest) {
|
||||
throw new BadRequestException(RestMessage.NOT_FOUND);
|
||||
}
|
||||
|
||||
const tokens = await this.tokensService.generateAccessAndRefreshToken(user.id, rest.id, false, slug);
|
||||
|
||||
const userResponse = UserLoginTransformer.transform(user, rest);
|
||||
|
||||
return { tokens, user: userResponse };
|
||||
}
|
||||
|
||||
async verifyOtpAdmin(phone: string, slug: string, code: string) {
|
||||
const normalizedPhone = normalizePhone(phone);
|
||||
const key = this.adminOtpKey(slug, 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 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 };
|
||||
}
|
||||
/**
|
||||
*
|
||||
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 };
|
||||
}
|
||||
|
||||
private generateOtpCode(): string {
|
||||
const code = randomInt(10000, 100000);
|
||||
return code.toString();
|
||||
}
|
||||
|
||||
refreshToken(oldRefreshToken: string) {
|
||||
return this.tokensService.refreshToken(oldRefreshToken);
|
||||
}
|
||||
}
|
||||
Executable
+145
@@ -0,0 +1,145 @@
|
||||
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 { AuthMessage } from '../../../common/enums/message.enum';
|
||||
import { RefreshToken, RefreshTokenType } 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,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly em: EntityManager,
|
||||
) {}
|
||||
|
||||
async generateAccessAndRefreshToken(
|
||||
ownerId: string,
|
||||
restaurantId: 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, restId: restaurantId }
|
||||
: { userId: ownerId, restId: restaurantId, slug };
|
||||
|
||||
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, restaurantId, refreshToken, type, em);
|
||||
|
||||
return {
|
||||
accessToken: { token: accessToken, expire: dayjs().add(accessExpire, 'seconds').valueOf() },
|
||||
refreshToken: { token: refreshToken, expire: dayjs().add(refreshExpire, 'day').valueOf() },
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
restId: string,
|
||||
refreshToken: string,
|
||||
type: RefreshTokenType,
|
||||
em?: EntityManager,
|
||||
) {
|
||||
const entityManager = em || this.em;
|
||||
const refreshExpire = this.configService.getOrThrow<number>('REFRESH_TOKEN_EXPIRE');
|
||||
const expiresAt = dayjs().add(refreshExpire, 'day').toDate();
|
||||
|
||||
const hashedToken = this.hashToken(refreshToken);
|
||||
|
||||
const token = entityManager.create(RefreshToken, {
|
||||
hashedToken,
|
||||
ownerId,
|
||||
restId,
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
async refreshToken(oldRefreshToken: string) {
|
||||
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 });
|
||||
|
||||
if (!token) {
|
||||
throw new UnauthorizedException(AuthMessage.INVALID_REFRESH_TOKEN);
|
||||
}
|
||||
|
||||
// Check expiration
|
||||
if (dayjs(token.expiresAt).isBefore(dayjs())) {
|
||||
em.remove(token);
|
||||
await em.flush();
|
||||
throw new UnauthorizedException(AuthMessage.REFRESH_TOKEN_EXPIRED);
|
||||
}
|
||||
|
||||
// Store token data before removal
|
||||
const ownerId = token.ownerId;
|
||||
const restId = token.restId;
|
||||
const isAdmin = token.type === RefreshTokenType.ADMIN;
|
||||
|
||||
// Verify restaurant still exists
|
||||
const restaurant = await em.findOne(Restaurant, { id: restId });
|
||||
if (!restaurant) {
|
||||
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
|
||||
em.remove(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');
|
||||
}
|
||||
|
||||
private hashToken(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { Admin } from '../../admin/entities/admin.entity';
|
||||
import type { Restaurant } from '../../restaurants/entities/restaurant.entity';
|
||||
|
||||
export interface AdminLoginResponse {
|
||||
id: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { User } from '../../users/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 {
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user