good forst commit
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeJid(jid: string): string {
|
||||
return jid.trim();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user