a1d2c6e6c2
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>
314 lines
11 KiB
TypeScript
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();
|
|
}
|
|
}
|