Files
tower/apps/api/src/modules/onboarding/onboarding.service.ts
T
maaz519 a1d2c6e6c2 feat: member phone login — POST /public/auth/member-login + /my/login page
Returning members can now sign in with just their phone number.
The bot DMs them a 6-digit OTP; on verify a 30-day session cookie is set.
First-time users are directed to their invite link from the login page.

- Make OtpChallenge.groupId optional (migration) for re-login challenges
- Add memberLogin / memberVerify service methods
- Add POST /public/auth/member-login and /member-verify controller endpoints
- Add /api/my/login BFF route (sets tower_member_token cookie)
- Add /my/login page (phone → OTP two-step form)
- /my/* now redirects to /my/login instead of /onboard on no session

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 16:37:19 +05:30

314 lines
11 KiB
TypeScript

import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { createHash, randomBytes } from 'crypto';
import { PrismaService } from '../../prisma/prisma.service';
import { AuditService } from '../audit/audit.service';
import { AuditAction } from '../audit/audit.types';
import { ConsentScope, OnboardingTokenPayload, PublicOnboardInfo, RequestOtpResponse, VerifyOtpResponse } from '@tower/types';
import { ConsentStatus } from '@prisma/client';
const POLICY_VERSION = 'v1';
const OTP_TTL_MIN = 5;
const DEFAULT_SCOPES: ConsentScope[] = ['INGEST', 'DISPLAY'];
const DEFAULT_RETENTION_DAYS = 90;
function hashPhone(phone: string, pepper: string): string {
const normalized = phone.replace(/[^\d+]/g, '');
return createHash('sha256').update(`${pepper}:${normalized}`).digest('hex');
}
@Injectable()
export class OnboardingService {
private readonly logger = new Logger(OnboardingService.name);
private readonly policyVersion = POLICY_VERSION;
constructor(
private readonly prisma: PrismaService,
private readonly jwt: JwtService,
private readonly config: ConfigService,
private readonly audit: AuditService,
) {}
decodeOnboardingToken(token: string): OnboardingTokenPayload {
// Phase 2B: token is base64url({groupId, jid, tenantId}) — no signature.
// The OTP step (sent via DM to the jid) is the real authentication.
try {
const json = Buffer.from(token, 'base64url').toString('utf8');
const parsed = JSON.parse(json) as OnboardingTokenPayload;
if (!parsed.groupId || !parsed.jid || !parsed.tenantId) {
throw new Error('Missing required fields');
}
return parsed;
} catch {
throw new UnauthorizedException('Invalid onboarding link');
}
}
async getOnboardInfo(token: string): Promise<PublicOnboardInfo> {
const payload = this.decodeOnboardingToken(token);
const group = await this.prisma.group.findUnique({ where: { id: payload.groupId } });
if (!group) throw new NotFoundException('Group not found');
if (!group.tenantId) {
throw new ConflictException('Group is not yet claimed by a tenant');
}
const tenant = await this.prisma.tenant.findUnique({ where: { id: payload.tenantId } });
if (!tenant) throw new NotFoundException('Tenant not found');
return {
groupName: group.name,
tenantName: tenant.name,
policyVersion: this.policyVersion,
defaultScopes: DEFAULT_SCOPES,
defaultRetentionDays: DEFAULT_RETENTION_DAYS,
};
}
async requestOtp(token: string, phone: string): Promise<RequestOtpResponse> {
const payload = this.decodeOnboardingToken(token);
if (payload.jid !== this.normalizeJid(payload.jid)) {
// sanity
}
if (!phone || phone.length < 6) {
throw new BadRequestException('Invalid phone number');
}
const group = await this.prisma.group.findUnique({ where: { id: payload.groupId } });
if (!group || !group.tenantId) {
throw new ConflictException('Group is not claimable');
}
const pepper = this.config.get<string>('JWT_SECRET') ?? '';
const phoneHash = hashPhone(phone, pepper);
const code = String(Math.floor(100000 + Math.random() * 900000));
const expiresAt = new Date(Date.now() + OTP_TTL_MIN * 60 * 1000);
const challengeId = randomBytes(16).toString('hex');
const challenge = await this.prisma.otpChallenge.create({
data: {
id: challengeId,
tenantId: payload.tenantId,
jid: payload.jid,
phoneHash,
code,
scopes: DEFAULT_SCOPES,
retentionDays: DEFAULT_RETENTION_DAYS,
policyVersion: this.policyVersion,
groupId: payload.groupId,
expiresAt,
},
});
await this.audit.log({
tenantId: payload.tenantId,
action: AuditAction.OTP_REQUESTED,
resourceType: 'OtpChallenge',
resourceId: challenge.id,
payload: { jid: payload.jid },
});
return {
ok: true,
challengeId,
expiresInSeconds: OTP_TTL_MIN * 60,
};
}
async verifyOtp(
token: string,
challengeId: string,
phone: string,
code: string,
scopes: ConsentScope[],
retentionDays?: number,
): Promise<VerifyOtpResponse> {
const payload = this.decodeOnboardingToken(token);
const pepper = this.config.get<string>('JWT_SECRET') ?? '';
const phoneHash = hashPhone(phone, pepper);
const challenge = await this.prisma.otpChallenge.findUnique({ where: { id: challengeId } });
if (!challenge) throw new NotFoundException('Challenge not found');
if (challenge.consumedAt) throw new UnauthorizedException('Challenge already used');
if (challenge.expiresAt < new Date()) throw new UnauthorizedException('Challenge expired');
if (challenge.code !== code) throw new UnauthorizedException('Invalid code');
if (challenge.phoneHash !== phoneHash) throw new UnauthorizedException('Phone mismatch');
const effectiveScopes = scopes.length > 0 ? scopes : DEFAULT_SCOPES;
const effectiveRetention = retentionDays ?? DEFAULT_RETENTION_DAYS;
const user = await this.prisma.towerUser.upsert({
where: { tenantId_phoneHash: { tenantId: payload.tenantId, phoneHash } },
update: { jid: payload.jid, displayName: payload.jid },
create: {
tenantId: payload.tenantId,
phoneHash,
jid: payload.jid,
displayName: payload.jid,
},
});
const existing = await this.prisma.consentRecord.findFirst({
where: { tenantId: payload.tenantId, groupId: payload.groupId, userId: user.id },
});
let consent;
if (existing) {
consent = await this.prisma.consentRecord.update({
where: { id: existing.id },
data: {
scopes: effectiveScopes,
retentionDays: effectiveRetention,
policyVersion: this.policyVersion,
status: ConsentStatus.GRANTED,
revokedAt: null,
effectiveAt: new Date(),
},
});
} else {
consent = await this.prisma.consentRecord.create({
data: {
tenantId: payload.tenantId,
groupId: payload.groupId,
userId: user.id,
scopes: effectiveScopes,
retentionDays: effectiveRetention,
policyVersion: this.policyVersion,
status: ConsentStatus.GRANTED,
proofEventId: 'pending',
},
});
}
await this.prisma.consentRecord.update({
where: { id: consent.id },
data: { proofEventId: consent.id },
});
await this.prisma.otpChallenge.update({
where: { id: challengeId },
data: { consumedAt: new Date() },
});
await this.audit.log({
tenantId: payload.tenantId,
action: AuditAction.MEMBER_ONBOARDED,
resourceType: 'TowerUser',
resourceId: user.id,
payload: {
jid: payload.jid,
groupId: payload.groupId,
consentId: consent.id,
scopes: effectiveScopes,
},
});
const memberToken = await this.jwt.signAsync({
kind: 'member',
sub: user.id,
tenantId: user.tenantId,
jid: user.jid,
phoneHash: user.phoneHash,
} as const);
return {
memberToken,
user: {
id: user.id,
tenantId: user.tenantId,
jid: user.jid,
displayName: user.displayName,
},
consent: {
scopes: consent.scopes as ConsentScope[],
retentionDays: consent.retentionDays,
policyVersion: consent.policyVersion,
},
};
}
async memberLogin(phone: string): Promise<{ challengeId: string; expiresInSeconds: number }> {
const pepper = this.config.get<string>('JWT_SECRET') ?? '';
const phoneHash = hashPhone(phone, pepper);
const user = await this.prisma.towerUser.findFirst({
where: { phoneHash },
select: { id: true, tenantId: true, jid: true },
});
if (!user) throw new NotFoundException('No portal account found for this number. Use your original invite link to register first.');
const code = String(Math.floor(100000 + Math.random() * 900000));
const expiresAt = new Date(Date.now() + OTP_TTL_MIN * 60 * 1000);
const challengeId = randomBytes(16).toString('hex');
await this.prisma.otpChallenge.create({
data: {
id: challengeId,
tenantId: user.tenantId,
jid: user.jid,
phoneHash,
code,
scopes: DEFAULT_SCOPES,
retentionDays: DEFAULT_RETENTION_DAYS,
policyVersion: this.policyVersion,
expiresAt,
},
});
await this.audit.log({
tenantId: user.tenantId,
action: AuditAction.OTP_REQUESTED,
resourceType: 'TowerUser',
resourceId: user.id,
payload: { jid: user.jid, source: 'member-login' },
});
return { challengeId, expiresInSeconds: OTP_TTL_MIN * 60 };
}
async memberVerify(challengeId: string, phone: string, code: string): Promise<{
memberToken: string;
user: { id: string; tenantId: string; jid: string; displayName: string | null };
}> {
const pepper = this.config.get<string>('JWT_SECRET') ?? '';
const phoneHash = hashPhone(phone, pepper);
const challenge = await this.prisma.otpChallenge.findUnique({ where: { id: challengeId } });
if (!challenge) throw new NotFoundException('Challenge not found');
if (challenge.consumedAt) throw new UnauthorizedException('Challenge already used');
if (challenge.expiresAt < new Date()) throw new UnauthorizedException('Code expired');
if (challenge.code !== code) throw new UnauthorizedException('Invalid code');
if (challenge.phoneHash !== phoneHash) throw new UnauthorizedException('Phone mismatch');
await this.prisma.otpChallenge.update({
where: { id: challengeId },
data: { consumedAt: new Date() },
});
const user = await this.prisma.towerUser.findFirst({
where: { phoneHash, tenantId: challenge.tenantId },
select: { id: true, tenantId: true, jid: true, displayName: true, phoneHash: true },
});
if (!user) throw new NotFoundException('User not found');
await this.audit.log({
tenantId: user.tenantId,
action: AuditAction.OTP_VERIFIED,
resourceType: 'TowerUser',
resourceId: user.id,
payload: { jid: user.jid, source: 'member-login' },
});
const memberToken = await this.jwt.signAsync({
kind: 'member',
sub: user.id,
tenantId: user.tenantId,
jid: user.jid,
phoneHash: user.phoneHash,
} as const);
return { memberToken, user: { id: user.id, tenantId: user.tenantId, jid: user.jid, displayName: user.displayName } };
}
private normalizeJid(jid: string): string {
return jid.trim();
}
}