feat(auth): add post-Fayda password setup flow

This commit is contained in:
Abubeker Yasin
2026-06-26 14:20:32 +03:00
parent 84a14b7312
commit 9c664ccce6
8 changed files with 286 additions and 15 deletions

View File

@@ -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 }[]>(