mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 23:28:11 +00:00
feat(auth): add post-Fayda password setup flow
This commit is contained in:
@@ -3,10 +3,10 @@ import { ApiTags, ApiOperation, ApiResponse, ApiBody, ApiBearerAuth } from '@nes
|
||||
import { Throttle, SkipThrottle } from '@nestjs/throttler';
|
||||
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
|
||||
import { PassengerAuthService } from './passenger-auth.service';
|
||||
import { RegisterDto, LoginDto } from './auth.dto';
|
||||
import { RegisterDto, LoginDto, FaydaRequestPasswordSetupDto, FaydaVerifyAndLoginDto } from './auth.dto';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
|
||||
@ApiTags('Auth')
|
||||
@ApiTags('Passenger Auth')
|
||||
@Controller('auth')
|
||||
@Throttle({ auth: { limit: 5, ttl: 60_000 } })
|
||||
export class AuthController {
|
||||
@@ -118,4 +118,24 @@ export class AuthController {
|
||||
resetPassword(@Param('id') id: string, @Body() body: { tempPassword: string }) {
|
||||
return this.passengerAuthService.resetUserPassword(id, body.tempPassword);
|
||||
}
|
||||
|
||||
@Post('fayda/request-password-setup')
|
||||
@IsPublic()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Send OTP to phone for Fayda-verified account password setup' })
|
||||
@ApiResponse({ status: 200, description: 'OTP sent to registered phone number' })
|
||||
@ApiBody({ type: FaydaRequestPasswordSetupDto })
|
||||
requestFaydaPasswordSetup(@Body() dto: FaydaRequestPasswordSetupDto, @Request() req: any) {
|
||||
return this.passengerAuthService.requestFaydaPasswordSetup(dto.phoneNumber, req);
|
||||
}
|
||||
|
||||
@Post('fayda/verify-and-login')
|
||||
@IsPublic()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Verify OTP and receive session token for Fayda-verified account' })
|
||||
@ApiResponse({ status: 200, description: 'Returns token + requiresPassword flag. Use token with POST /v1/auth/set-fayda-password.' })
|
||||
@ApiBody({ type: FaydaVerifyAndLoginDto })
|
||||
verifyFaydaAndLogin(@Body() dto: FaydaVerifyAndLoginDto) {
|
||||
return this.passengerAuthService.verifyFaydaAndLogin(dto.phoneNumber, dto.otp);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { IsEmail, IsString, MinLength, ValidateNested } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class NameDto {
|
||||
@ApiProperty({ example: 'ቀለሙ ቀጸላ' })
|
||||
@@ -49,3 +49,19 @@ export class LoginDto {
|
||||
@IsString()
|
||||
password: string;
|
||||
}
|
||||
|
||||
export class FaydaRequestPasswordSetupDto {
|
||||
@ApiProperty({ example: '+251911234567', description: 'Phone number of the Fayda-verified account' })
|
||||
@IsString()
|
||||
phoneNumber: string;
|
||||
}
|
||||
|
||||
export class FaydaVerifyAndLoginDto {
|
||||
@ApiProperty({ example: '+251911234567' })
|
||||
@IsString()
|
||||
phoneNumber: string;
|
||||
|
||||
@ApiProperty({ example: '123456', description: '6-digit OTP received via SMS' })
|
||||
@IsString()
|
||||
otp: string;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
Injectable,
|
||||
ConflictException,
|
||||
InternalServerErrorException,
|
||||
Logger,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { ModuleRef, ContextIdFactory } from '@nestjs/core';
|
||||
@@ -10,6 +11,7 @@ import { DataSource } from 'typeorm';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { AuthService as IamAuthService } from '@tria-plc/iamapi-common/module/auth/services/auth.service';
|
||||
import { EUserType } from '@tria-plc/api-common/utils/enums/user.enum';
|
||||
import { EOtpType } from '@tria-plc/iamapi-common/enums/otp.enum';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { RegisterDto, LoginDto } from './auth.dto';
|
||||
|
||||
@@ -19,10 +21,13 @@ type IamUserRow = {
|
||||
name: { en: string; am: string } | null;
|
||||
phone_number: string | null;
|
||||
metadata: Record<string, any> | null;
|
||||
verified_by: string | null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class PassengerAuthService {
|
||||
private readonly logger = new Logger(PassengerAuthService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
@InjectDataSource() private readonly dataSource: DataSource,
|
||||
@@ -165,7 +170,7 @@ export class PassengerAuthService {
|
||||
include: { loyalty: true, wallet: true },
|
||||
}),
|
||||
this.dataSource.query<IamUserRow[]>(
|
||||
`SELECT id, email, name, phone_number, metadata FROM iam.users WHERE id = $1 LIMIT 1`,
|
||||
`SELECT id, email, name, phone_number, metadata, verified_by FROM iam.users WHERE id = $1 LIMIT 1`,
|
||||
[iamUserId],
|
||||
),
|
||||
]);
|
||||
@@ -178,7 +183,7 @@ export class PassengerAuthService {
|
||||
email: iam?.email ?? null,
|
||||
phone: iam?.phone_number ?? null,
|
||||
fullName: iam?.name?.en ?? iam?.name?.am ?? null,
|
||||
faydaVerified: iam?.metadata?.faydaVerified ?? false,
|
||||
faydaVerified: iam?.verified_by === 'fayda',
|
||||
createdAt: passenger.createdAt,
|
||||
passenger: {
|
||||
id: passenger.id,
|
||||
@@ -396,6 +401,121 @@ export class PassengerAuthService {
|
||||
return { success: true, message: 'Password reset successfully' };
|
||||
}
|
||||
|
||||
async requestFaydaPasswordSetup(phoneNumber: string, req: any): Promise<{ sent: boolean }> {
|
||||
const phone = this.standardizePhone(phoneNumber);
|
||||
|
||||
const users = await this.dataSource.query<{ id: string; email: string }[]>(
|
||||
`SELECT id, email FROM iam.users WHERE phone_number = $1 AND verified_by = 'fayda' LIMIT 1`,
|
||||
[phone],
|
||||
);
|
||||
this.logger.log(`requestFaydaPasswordSetup: phone=${phone} found=${users.length > 0}`);
|
||||
// Return success regardless to avoid phone enumeration
|
||||
if (!users.length) return { sent: true };
|
||||
const u = users[0];
|
||||
|
||||
const iamAuthService = await this.resolveIamAuthService(req);
|
||||
await iamAuthService.generateVerificationCode({
|
||||
email: u.email,
|
||||
phoneNumber: phone,
|
||||
type: EOtpType.SET_PASSWORD,
|
||||
});
|
||||
|
||||
return { sent: true };
|
||||
}
|
||||
|
||||
async verifyFaydaAndLogin(
|
||||
phoneNumber: string,
|
||||
otp: string,
|
||||
): Promise<{ token: string; refreshToken: string; requiresPassword: boolean; iamUserId: string }> {
|
||||
const phone = this.standardizePhone(phoneNumber);
|
||||
|
||||
const users = await this.dataSource.query<{
|
||||
id: string;
|
||||
email: string;
|
||||
name: { en: string; am: string } | null;
|
||||
username: string;
|
||||
phone_number: string | null;
|
||||
has_set_password: boolean;
|
||||
}[]>(
|
||||
`SELECT id, email, name, username, phone_number, has_set_password
|
||||
FROM iam.users WHERE phone_number = $1 AND verified_by = 'fayda' LIMIT 1`,
|
||||
[phone],
|
||||
);
|
||||
if (!users.length) throw new UnauthorizedException('Invalid phone number or OTP');
|
||||
const u = users[0];
|
||||
|
||||
const verifications = await this.dataSource.query<{
|
||||
id: string; verification_code: string; attempt_count: number;
|
||||
}[]>(
|
||||
`SELECT id, verification_code, attempt_count FROM iam.user_verifications
|
||||
WHERE user_id = $1 AND otp_type = 'set-password' AND "isUsed" = false AND expires_at > NOW()
|
||||
ORDER BY created_at DESC LIMIT 1`,
|
||||
[u.id],
|
||||
);
|
||||
if (!verifications.length) throw new UnauthorizedException('Invalid phone number or OTP');
|
||||
const v = verifications[0];
|
||||
|
||||
if (v.attempt_count >= 5) {
|
||||
await this.dataSource.query(
|
||||
`UPDATE iam.user_verifications SET "isUsed" = true WHERE id = $1`, [v.id],
|
||||
);
|
||||
throw new UnauthorizedException('Too many attempts. Request a new code.');
|
||||
}
|
||||
|
||||
await this.dataSource.query(
|
||||
`UPDATE iam.user_verifications SET attempt_count = attempt_count + 1 WHERE id = $1`, [v.id],
|
||||
);
|
||||
|
||||
const { verifyPassword } = await import('@tria-plc/api-common/utils/argon');
|
||||
const valid = await verifyPassword(otp, v.verification_code);
|
||||
if (!valid) throw new UnauthorizedException('Invalid phone number or OTP');
|
||||
|
||||
await this.dataSource.query(
|
||||
`UPDATE iam.user_verifications SET "isUsed" = true WHERE id = $1`, [v.id],
|
||||
);
|
||||
|
||||
const userInfo = {
|
||||
id: u.id,
|
||||
email: u.email ?? '',
|
||||
name: u.name ?? { en: '', am: '' },
|
||||
userType: 'individual',
|
||||
status: 'accepted',
|
||||
hasSetPassword: u.has_set_password,
|
||||
isPhoneNumberVerified: false,
|
||||
hasFinishedRegistration: false,
|
||||
hasFinishedDMSOnboarding: false,
|
||||
username: u.username,
|
||||
phoneNumber: u.phone_number ?? '',
|
||||
roles: [],
|
||||
permissions: [],
|
||||
employee: [],
|
||||
};
|
||||
|
||||
const sessions = await this.dataSource.query<{ id: string }[]>(
|
||||
`INSERT INTO iam.sessions
|
||||
(id, email, device, "userInfo", expiry_time, refresh_count, status, user_id)
|
||||
VALUES (gen_random_uuid(), $1, 'fayda-otp-setup', $2::jsonb, NOW() + INTERVAL '1 day', 0, 'ACTIVE', $3)
|
||||
ON CONFLICT (user_id, device) DO UPDATE
|
||||
SET status = 'ACTIVE', "userInfo" = EXCLUDED."userInfo",
|
||||
expiry_time = NOW() + INTERVAL '1 day', updated_at = NOW()
|
||||
RETURNING id`,
|
||||
[u.email ?? '', JSON.stringify(userInfo), u.id],
|
||||
);
|
||||
|
||||
const { generateToken, generateRefreshToken } = await import('@tria-plc/api-common/utils/token');
|
||||
const token = generateToken({ id: sessions[0].id });
|
||||
const refreshToken = generateRefreshToken({ id: sessions[0].id });
|
||||
|
||||
return { token, refreshToken, requiresPassword: !u.has_set_password, iamUserId: u.id };
|
||||
}
|
||||
|
||||
private standardizePhone(phone: string): string {
|
||||
const digits = phone.replace(/\D/g, '');
|
||||
if (digits.startsWith('251')) return `+${digits}`;
|
||||
if (digits.startsWith('0')) return `+251${digits.slice(1)}`;
|
||||
return `+${digits}`;
|
||||
}
|
||||
|
||||
private async compensateIamSignup(email: string): Promise<void> {
|
||||
try {
|
||||
const rows = await this.dataSource.query<{ id: string }[]>(
|
||||
|
||||
Reference in New Issue
Block a user