Merge branch 'dev' of https://github.com/Tria-plc/edr-platform into feature/group-booking

This commit is contained in:
Roba Boru
2026-08-27 22:55:42 +03:00
212 changed files with 10522 additions and 1589 deletions

View File

@@ -21,6 +21,7 @@ import {
ApiBearerAuth,
} from "@nestjs/swagger";
import { IsPublic } from "@tria-plc/api-common/modules/auth/decorators/public.decorator";
import { Throttle, ThrottlerGuard } from "@nestjs/throttler";
import { PassengerAuthService } from "./passenger-auth.service";
import {
RegisterDto,
@@ -28,12 +29,19 @@ import {
ResendRegistrationCodeDto,
FaydaRequestPasswordSetupDto,
FaydaVerifyAndLoginDto,
IdentifierLookupDto,
PasswordSetupRequestDto,
PasswordSetupCompleteDto,
} from "./auth.dto";
import { JwtGuard } from "../../common/jwt.guard";
@ApiTags("Passenger Auth")
@Controller("auth")
// @Throttle({ auth: { limit: 5, ttl: 60_000 } })
// Scoped to this controller rather than registered as a global APP_GUARD: the staged sign-in
// exposes an account-existence lookup, and rate limiting is the mitigation for it. Applying the
// guard app-wide would change the behaviour of every other module at the same time.
@UseGuards(ThrottlerGuard)
@Throttle({ auth: { limit: 20, ttl: 60_000 } })
export class AuthController {
constructor(private passengerAuthService: PassengerAuthService) {}
@@ -189,6 +197,63 @@ export class AuthController {
return this.passengerAuthService.resetUserPassword(id, body.tempPassword);
}
@Post("identifier/lookup")
@IsPublic()
@HttpCode(HttpStatus.OK)
// Tighter than the rest of the controller: this is the endpoint that answers "does this
// account exist", so it is the one worth making expensive to sweep. Still roomy enough
// that a passenger correcting a typo two or three times is unaffected.
@Throttle({ auth: { limit: 10, ttl: 60_000 } })
@ApiOperation({
summary: "Step 1 of sign-in — decide what to ask the user for next",
description:
"Takes a phone number or an email and reports whether the account exists and whether it " +
"already has a password. PASSWORD → ask for the password. NEEDS_PASSWORD_SETUP → send a " +
"code and let them set one. NOT_FOUND → sign them up.",
})
@ApiResponse({
status: 200,
description: "{ status, method?, maskedPhone? } — never returns email or user id",
})
@ApiBody({ type: IdentifierLookupDto })
lookupIdentifier(@Body() dto: IdentifierLookupDto) {
return this.passengerAuthService.lookupIdentifier(dto.identifier);
}
@Post("password-setup/request")
@IsPublic()
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: "Send the SMS code that lets an account with no password set one",
description:
"Covers Fayda-created accounts and abandoned registrations alike. Always returns " +
"{ sent: true } regardless of whether the account exists.",
})
@ApiResponse({ status: 200, description: "{ sent: true }" })
@ApiBody({ type: PasswordSetupRequestDto })
requestPasswordSetup(@Body() dto: PasswordSetupRequestDto, @Request() req: any) {
return this.passengerAuthService.requestPasswordSetup(dto.identifier, req);
}
@Post("password-setup/complete")
@IsPublic()
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: "Redeem the code, set the password, and sign in",
description:
"Accepts both set-password codes (from password-setup/request) and verify-phone-number " +
"codes (from POST /auth/register), so one screen finishes both branches.",
})
@ApiResponse({
status: 200,
description: "Same shape as POST /auth/login — token, refreshToken and user.",
})
@ApiResponse({ status: 401, description: "Invalid or expired code" })
@ApiBody({ type: PasswordSetupCompleteDto })
completePasswordSetup(@Body() dto: PasswordSetupCompleteDto) {
return this.passengerAuthService.completePasswordSetup(dto);
}
@Post("fayda/request-password-setup")
@IsPublic()
@HttpCode(HttpStatus.OK)

View File

@@ -1,4 +1,11 @@
import { IsEmail, IsNotEmpty, IsString, ValidateNested } from 'class-validator';
import {
IsEmail,
IsNotEmpty,
IsString,
IsStrongPassword,
Length,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty } from '@nestjs/swagger';
@@ -75,3 +82,55 @@ export class FaydaVerifyAndLoginDto {
@IsString()
otp: string;
}
/**
* Step 1 of the staged sign-in. One field: the passenger types either their phone number
* or their email and the server decides which of the three branches follows.
*/
export class IdentifierLookupDto {
@ApiProperty({
example: '+251912345678',
description: 'Phone number or email address — the server detects which',
})
@IsString()
@IsNotEmpty()
identifier: string;
}
/** Step 2a: ask for the SMS code that lets an account with no password set one. */
export class PasswordSetupRequestDto {
@ApiProperty({ example: '+251912345678', description: 'Phone number or email address' })
@IsString()
@IsNotEmpty()
identifier: string;
}
/** Step 2b: redeem the code, set the password, and receive a session in one call. */
export class PasswordSetupCompleteDto {
@ApiProperty({ example: '+251912345678', description: 'Phone number or email address' })
@IsString()
@IsNotEmpty()
identifier: string;
@ApiProperty({ example: '123456', description: '6-digit code received via SMS' })
@IsString()
@Length(4, 10)
otp: string;
// The credential is written directly against iam.user_credentials rather than through
// the IAM's own set-password route, so the IAM's @IsStrongPassword rule has to be
// restated here or weak passwords would slip in unvalidated.
@ApiProperty({ example: 'Str0ng!Pass', format: 'password' })
@IsStrongPassword({
minLength: 8,
minLowercase: 1,
minUppercase: 1,
minNumbers: 1,
minSymbols: 1,
})
newPassword: string;
@ApiProperty({ example: 'Str0ng!Pass', format: 'password' })
@IsString()
confirmPassword: string;
}

View File

@@ -1,4 +1,5 @@
import {
BadRequestException,
Injectable,
ConflictException,
InternalServerErrorException,
@@ -13,7 +14,22 @@ import { AuthService as IamAuthService } from '@tria-plc/iamapi-common/module/au
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';
import { RegisterDto, LoginDto, PasswordSetupCompleteDto } from './auth.dto';
import { maskPhone, resolveIdentifier } from '../../common/utils/phone.utils';
/**
* The `iam.users` columns every sign-in branch needs. Kept separate from `IamUserRow`
* (which is profile-shaped) because the auth branches key off credential state, not metadata.
*/
type IamAuthRow = {
id: string;
email: string | null;
name: { en: string; am: string } | null;
username: string;
phone_number: string | null;
has_set_password: boolean;
verified_by: string | null;
};
type IamUserRow = {
id: string;
@@ -170,9 +186,19 @@ export class PassengerAuthService {
async login(dto: LoginDto, req: any) {
const iamAuthService = await this.resolveIamAuthService(req);
// `dto.email` may hold an email OR a phone number, in any of the shapes a passenger might
// type. Resolve it to the exact string the IAM stores before handing it over: the IAM
// matches the identifier literally, so someone entering `0912…` for a number stored as
// `+2519…` would be told their credentials are invalid despite a correct password.
const known = await this.findUserByIdentifier(dto.email);
const loginIdentifier = known?.email ?? known?.phone_number ?? dto.email;
let iamResult: { token: string; refreshToken: string } | { mfaRequired: boolean };
try {
iamResult = await iamAuthService.login({ email: dto.email, password: dto.password });
iamResult = await iamAuthService.login({
email: loginIdentifier,
password: dto.password,
});
} catch {
this.eventEmitter.emit('auth.login.failed', { email: dto.email });
throw new UnauthorizedException('Invalid credentials');
@@ -184,14 +210,16 @@ export class PassengerAuthService {
const { token, refreshToken } = iamResult as { token: string; refreshToken: string };
// `dto.email` may hold an email OR a phone number (passengers without an email log in
// with their phone). Match on either so the post-auth lookup works regardless of which
// identifier was used.
const iamRows = await this.dataSource.query<IamUserRow[]>(
`SELECT id, email, name, phone_number, metadata FROM iam.users WHERE email = $1 OR phone_number = $1 LIMIT 1`,
[dto.email],
);
const iamUser = iamRows[0];
// `known` is the same row the identifier resolved to; only fall back to a fresh lookup if
// the resolve missed but the IAM authenticated anyway.
let iamUser: { id: string; email: string | null } | null = known;
if (!iamUser) {
const iamRows = await this.dataSource.query<IamUserRow[]>(
`SELECT id, email, name, phone_number, metadata FROM iam.users WHERE email = $1 OR phone_number = $1 LIMIT 1`,
[loginIdentifier],
);
iamUser = iamRows[0] ?? null;
}
if (!iamUser) {
throw new InternalServerErrorException('IAM user not found after successful authentication');
}
@@ -496,19 +524,26 @@ export class PassengerAuthService {
}
async resetUserPassword(id: string, tempPassword: string) {
await this.writeActiveCredential(id, tempPassword);
return { success: true, message: 'Password reset successfully' };
}
/**
* Replaces the user's active credential. The IAM keeps credential history and relies on
* exactly one row per user having `is_active = true`, so the old row is deactivated in the
* same call rather than deleted.
*/
private async writeActiveCredential(userId: string, password: string): Promise<void> {
const { hashPassword } = await import('@tria-plc/api-common/utils/argon');
const passwordHash = await hashPassword(tempPassword);
// Deactivate existing credentials first (IAM keeps history, only one active at a time)
const passwordHash = await hashPassword(password);
await this.dataSource.query(
`UPDATE iam.user_credentials SET is_active = false WHERE user_id = $1 AND is_active = true`,
[id],
[userId],
);
// Insert new active credential
await this.dataSource.query(
`INSERT INTO iam.user_credentials (user_id, password, is_active) VALUES ($1, $2, true)`,
[id, passwordHash],
[userId, passwordHash],
);
return { success: true, message: 'Password reset successfully' };
}
async requestFaydaPasswordSetup(phoneNumber: string, req: any): Promise<{ sent: boolean }> {
@@ -554,15 +589,45 @@ export class PassengerAuthService {
if (!users.length) throw new UnauthorizedException('Invalid phone number or OTP');
const u = users[0];
await this.consumeSetupOtp(u.id, otp, 'Invalid phone number or OTP');
const { token, refreshToken } = await this.mintSession(
{ ...u, verified_by: 'fayda' },
'fayda-otp-setup',
);
return { token, refreshToken, requiresPassword: !u.has_set_password, iamUserId: u.id };
}
/**
* Verifies and burns a one-time code from `iam.user_verifications`.
*
* Both password-setup entry points land here: `set-password` codes come from
* `password-setup/request`, `verify-phone-number` codes from `POST /auth/register`. Accepting
* both is what lets a single screen finish the "existing account with no password" branch and
* the "brand new signup" branch.
*
* Codes are argon2-hashed at rest, so this is a verify rather than an equality check. The
* attempt counter is incremented *before* the comparison so a crash mid-verify still costs an
* attempt, and the code is burned on the 6th try.
*/
private async consumeSetupOtp(
userId: string,
otp: string,
failureMessage = 'Invalid or expired code',
): Promise<void> {
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()
WHERE user_id = $1
AND otp_type IN ('set-password', 'verify-phone-number')
AND "isUsed" = false
AND expires_at > NOW()
ORDER BY created_at DESC LIMIT 1`,
[u.id],
[userId],
);
if (!verifications.length) throw new UnauthorizedException('Invalid phone number or OTP');
if (!verifications.length) throw new UnauthorizedException(failureMessage);
const v = verifications[0];
if (v.attempt_count >= 5) {
@@ -578,12 +643,24 @@ export class PassengerAuthService {
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');
if (!valid) throw new UnauthorizedException(failureMessage);
await this.dataSource.query(
`UPDATE iam.user_verifications SET "isUsed" = true WHERE id = $1`, [v.id],
);
}
/**
* Inserts (or refreshes) an `iam.sessions` row and mints the token pair for it. The JWT payload
* is only the session id — `JwtGuard` resolves everything else from the table.
*
* `device` participates in a unique constraint on `(user_id, device)`, so each flow passes its
* own value and none of them clobbers a session another flow established.
*/
private async mintSession(
u: IamAuthRow,
device: string,
): Promise<{ token: string; refreshToken: string }> {
const userInfo = {
id: u.id,
email: u.email ?? '',
@@ -604,19 +681,183 @@ export class PassengerAuthService {
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)
VALUES (gen_random_uuid(), $1, $2, $3::jsonb, NOW() + INTERVAL '1 day', 0, 'ACTIVE', $4)
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],
[u.email ?? '', device, 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: generateToken({ id: sessions[0].id }),
refreshToken: generateRefreshToken({ id: sessions[0].id }),
};
}
return { token, refreshToken, requiresPassword: !u.has_set_password, iamUserId: u.id };
/**
* Resolves the single sign-in identifier field to an `iam.users` row.
*
* A phone number reaches us in three interchangeable shapes (`+2519…`, `2519…`, `09…`)
* depending on whether the account was created by IAM signup, a guest booking or Fayda, so
* matching on one canonical form silently misses. `normalizePhoneVariants` produces every
* shape and the query matches any of them.
*
* `ORDER BY has_set_password DESC` makes a fully-registered account win over a leftover
* pending row that shares the same phone — otherwise a passenger with an abandoned signup
* would be pushed into password setup for an account they already finished.
*/
private async findUserByIdentifier(identifier: string): Promise<IamAuthRow | null> {
const resolved = resolveIdentifier(identifier);
if (!resolved.email && resolved.phoneVariants.length === 0) return null;
const rows = await this.dataSource.query<IamAuthRow[]>(
`SELECT id, email, name, username, phone_number, has_set_password, verified_by
FROM iam.users
WHERE ($1::text IS NOT NULL AND lower(email) = $1)
OR phone_number = ANY($2::text[])
ORDER BY has_set_password DESC
LIMIT 1`,
[resolved.email, resolved.phoneVariants],
);
return rows[0] ?? null;
}
/**
* Step 1 of the staged sign-in: decide which of the three branches the portal should render.
*
* This deliberately reports whether an account exists — the whole point of the flow is that the
* passenger stops guessing — so it is a user-enumeration oracle by design. `POST
* /v1/auth/forgot-password` already leaks the same fact by throwing `user_not_found`; the
* mitigation here is the throttle on this controller, not secrecy. Nothing identifying is
* returned: no email, no user id, and the phone only ever masked.
*/
async lookupIdentifier(identifier: string): Promise<{
status: 'PASSWORD' | 'NEEDS_PASSWORD_SETUP' | 'NOT_FOUND';
method?: 'fayda' | 'pending';
maskedPhone?: string;
}> {
const user = await this.findUserByIdentifier(identifier);
if (!user) return { status: 'NOT_FOUND' };
if (user.has_set_password) return { status: 'PASSWORD' };
return {
status: 'NEEDS_PASSWORD_SETUP',
method: user.verified_by === 'fayda' ? 'fayda' : 'pending',
maskedPhone: user.phone_number ? maskPhone(user.phone_number) : undefined,
};
}
/**
* Sends the SMS code that lets an account with no password set one. Covers both Fayda-created
* accounts and abandoned registrations — the distinction only changes the copy the portal
* shows, not what happens here.
*
* Always resolves `{ sent: true }`. Returning a real result would make this a cheaper
* enumeration oracle than `lookupIdentifier`, which at least sits behind the same throttle.
*/
async requestPasswordSetup(identifier: string, req: any): Promise<{ sent: boolean }> {
const user = await this.findUserByIdentifier(identifier);
if (!user || user.has_set_password) return { sent: true };
if (!user.phone_number) {
// OTP delivery is SMS + in-app only; there is no email channel. Every account-creation
// path requires a phone, so this should be unreachable — log it rather than fail silently.
this.logger.warn(
`requestPasswordSetup: user ${user.id} has no phone number — no channel to send a code on`,
);
return { sent: true };
}
const iamAuthService = await this.resolveIamAuthService(req);
try {
await iamAuthService.generateVerificationCode({
// Both fields must match the stored row exactly: the IAM looks the user up with
// `where: { phoneNumber, email }`, which is AND, not OR. Passing the values we just
// read back guarantees the match — including a null email, which TypeORM renders as
// `IS NULL` and which coercing to '' would break.
email: user.email as string,
phoneNumber: user.phone_number,
type: EOtpType.SET_PASSWORD,
});
} catch (err) {
this.logger.error(
`[PassengerAuthService] password setup code failed for user ${user.id}`,
(err as Error).message,
);
}
return { sent: true };
}
/**
* Redeems the code, writes the password, and returns a session — the passenger lands signed in
* rather than being bounced back to the login form.
*
* Returns the same shape as `login()` so the portal can store the result through one code path.
*/
async completePasswordSetup(dto: PasswordSetupCompleteDto): Promise<{
token: string;
refreshToken: string;
user: { id: string; iamUserId: string; email: string | null; passengerId: string };
}> {
if (dto.newPassword !== dto.confirmPassword) {
throw new BadRequestException('Passwords do not match');
}
const user = await this.findUserByIdentifier(dto.identifier);
// Same message whether the account is missing or the code is wrong: the branch was already
// disclosed by `lookupIdentifier`, but there is no reason to re-confirm it on every attempt.
if (!user) throw new UnauthorizedException('Invalid or expired code');
if (user.has_set_password) {
throw new BadRequestException(
'This account already has a password. Sign in with it instead.',
);
}
await this.consumeSetupOtp(user.id, dto.otp);
await this.writeActiveCredential(user.id, dto.newPassword);
// Redeeming the code proves ownership of the phone, which is what promotes a Fayda-created
// `submitted` row or a pending signup to a usable account.
await this.dataSource.query(
`UPDATE iam.users
SET has_set_password = true,
status = 'accepted',
is_active = true,
is_phone_number_verified = true,
updated_at = NOW()
WHERE id = $1`,
[user.id],
);
const { token, refreshToken } = await this.mintSession(
{ ...user, has_set_password: true },
'password-setup',
);
let passenger = await this.prisma.passenger.findUnique({
where: { iamUserId: user.id },
select: { id: true },
});
if (!passenger) {
const result = await this.provisionPassengerSatellite({
iamUserId: user.id,
auditAction: 'USER_AUTO_PROVISIONED',
});
passenger = { id: result.passengerId };
}
return {
token,
refreshToken,
user: {
id: user.id,
iamUserId: user.id,
email: user.email,
passengerId: passenger.id,
},
};
}
private standardizePhone(phone: string): string {

View File

@@ -13,6 +13,7 @@ import { CurrencyService } from '../currency/currency.service';
import { FareEngineService } from '../fare-engine/fare-engine.service';
import { PaymentsService } from '../payments/payments.service';
import { MAX_PAYMENT_HOURS } from '../../common/utils/payment-deadline.utils';
import { normalizePhoneVariants } from '../../common/utils/phone.utils';
import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
import { JourneyDirection } from '../seats/seats.dto';
import { resolveCurrencyFromNationality } from '../fare-engine/fare-engine.dto';
@@ -46,39 +47,6 @@ function resolvePackageRoundTripTotal(
return adultCount * adultFareMinor + paidChildren * adultFareMinor;
}
/**
* Returns all plausible normalised variants of a raw phone string so that the
* DB query matches regardless of how the number was stored (local 09… vs international +251…).
* Returns an empty array when the input is clearly invalid (< 7 digits).
*/
function normalizePhoneVariants(raw: string): string[] {
// Strip whitespace, dashes, dots, parentheses — keep digits and a leading +
const stripped = raw.replace(/[^\d+]/g, '');
const digits = stripped.replace(/^\+/, '');
if (digits.length < 7) return [];
const variants = new Set<string>([stripped]);
if (stripped.startsWith('+251') && digits.length === 12) {
// +251 9XXXXXXXX → 251 9XXXXXXXX (no +) → 09XXXXXXXX
variants.add(digits); // 251XXXXXXXXX
variants.add('0' + digits.slice(3)); // 09XXXXXXXXX
} else if (stripped.startsWith('251') && digits.length === 12) {
// 251 9XXXXXXXX → +251 9XXXXXXXX → 09XXXXXXXX
variants.add('+' + stripped); // +251XXXXXXXXX
variants.add('0' + digits.slice(3)); // 09XXXXXXXXX
} else if (stripped.startsWith('0') && digits.length === 10) {
// 09XXXXXXXX → +251 9XXXXXXXX → 251 9XXXXXXXX (no +)
variants.add('+251' + digits.slice(1)); // +251XXXXXXXXX
variants.add('251' + digits.slice(1)); // 251XXXXXXXXX
} else if (!stripped.startsWith('+') && digits.length >= 9) {
// bare international digits without +
variants.add('+' + digits);
}
return [...variants];
}
function calculateAge(dateOfBirth: Date): number {
const today = new Date();
let age = today.getFullYear() - dateOfBirth.getFullYear();