Phase D
This commit is contained in:
@@ -23,14 +23,16 @@ import {
|
|||||||
twoFactorDisableRequestSchema,
|
twoFactorDisableRequestSchema,
|
||||||
twoFactorVerifyRequestSchema,
|
twoFactorVerifyRequestSchema,
|
||||||
verifyEmailRequestSchema,
|
verifyEmailRequestSchema,
|
||||||
|
ssoConsumeRequestSchema,
|
||||||
} from '@hexahost/contracts';
|
} from '@hexahost/contracts';
|
||||||
|
|
||||||
import { ZodValidationPipe } from '../common/pipes/zod-validation.pipe';
|
import { ZodValidationPipe } from '../common/pipes/zod-validation.pipe';
|
||||||
|
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { CurrentUser, Public } from './decorators/current-user.decorator';
|
import { Public, CurrentUser } from './decorators/current-user.decorator';
|
||||||
import { SessionGuard } from './guards/session.guard';
|
import { SessionGuard } from './guards/session.guard';
|
||||||
import { SessionService } from './session.service';
|
import { SessionService } from './session.service';
|
||||||
|
import { SsoService } from './sso.service';
|
||||||
|
|
||||||
@ApiTags('auth')
|
@ApiTags('auth')
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
@@ -40,6 +42,7 @@ export class AuthController {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly authService: AuthService,
|
private readonly authService: AuthService,
|
||||||
private readonly sessionService: SessionService,
|
private readonly sessionService: SessionService,
|
||||||
|
private readonly ssoService: SsoService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@@ -105,6 +108,24 @@ export class AuthController {
|
|||||||
return this.authService.verifyEmail({ token }, request.ip);
|
return this.authService.verifyEmail({ token }, request.ip);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Post('sso/consume')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({ summary: 'Redeem a one-time WHMCS SSO ticket' })
|
||||||
|
consumeSso(
|
||||||
|
@Body(new ZodValidationPipe(ssoConsumeRequestSchema)) body: unknown,
|
||||||
|
@Req() request: FastifyRequest,
|
||||||
|
@Res({ passthrough: true }) reply: FastifyReply,
|
||||||
|
) {
|
||||||
|
const payload = body as { ticket: string };
|
||||||
|
return this.ssoService.consumeTicket(
|
||||||
|
payload.ticket,
|
||||||
|
reply,
|
||||||
|
request.ip,
|
||||||
|
request.headers['user-agent'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@UseGuards(SessionGuard)
|
@UseGuards(SessionGuard)
|
||||||
@Post('resend-verification')
|
@Post('resend-verification')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
import { SessionGuard } from './guards/session.guard';
|
import { SessionGuard } from './guards/session.guard';
|
||||||
import { MeController } from './me.controller';
|
import { MeController } from './me.controller';
|
||||||
import { SessionService } from './session.service';
|
import { SessionService } from './session.service';
|
||||||
|
import { SsoService } from './sso.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AppConfigModule, BillingModule],
|
imports: [AppConfigModule, BillingModule],
|
||||||
@@ -24,6 +25,7 @@ import { SessionService } from './session.service';
|
|||||||
providers: [
|
providers: [
|
||||||
AuthService,
|
AuthService,
|
||||||
SessionService,
|
SessionService,
|
||||||
|
SsoService,
|
||||||
AuditService,
|
AuditService,
|
||||||
SessionGuard,
|
SessionGuard,
|
||||||
{
|
{
|
||||||
@@ -48,6 +50,6 @@ import { SessionService } from './session.service';
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
exports: [AuthService, SessionService, SessionGuard, AuditService],
|
exports: [AuthService, SessionService, SessionGuard, AuditService, SsoService],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
|||||||
@@ -26,8 +26,14 @@ export class MeController {
|
|||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: 'Get current user profile' })
|
@ApiOperation({ summary: 'Get current user profile' })
|
||||||
@ApiResponse({ status: 200, description: 'Current user' })
|
@ApiResponse({ status: 200, description: 'Current user' })
|
||||||
getMe(@CurrentUser() user: { id: string }) {
|
getMe(
|
||||||
return { user };
|
@CurrentUser() user: { id: string },
|
||||||
|
@CurrentSession() session: { impersonation?: { isImpersonation: boolean; impersonatorLabel?: string } },
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
impersonation: session.impersonation,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('sessions')
|
@Get('sessions')
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
type SessionContext,
|
type SessionContext,
|
||||||
} from '@hexahost/auth';
|
} from '@hexahost/auth';
|
||||||
import type { AppConfig } from '@hexahost/config';
|
import type { AppConfig } from '@hexahost/config';
|
||||||
|
import { Prisma } from '@hexahost/database';
|
||||||
|
|
||||||
import { APP_CONFIG } from '../config/config.constants';
|
import { APP_CONFIG } from '../config/config.constants';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
@@ -32,6 +33,10 @@ export class SessionService {
|
|||||||
userId: string,
|
userId: string,
|
||||||
ipAddress?: string,
|
ipAddress?: string,
|
||||||
userAgent?: string,
|
userAgent?: string,
|
||||||
|
options?: {
|
||||||
|
isImpersonation?: boolean;
|
||||||
|
impersonationMetadata?: Record<string, unknown>;
|
||||||
|
},
|
||||||
): Promise<{ token: string; sessionId: string; expiresAt: Date }> {
|
): Promise<{ token: string; sessionId: string; expiresAt: Date }> {
|
||||||
const token = generateSecureToken();
|
const token = generateSecureToken();
|
||||||
const tokenHash = hashToken(token);
|
const tokenHash = hashToken(token);
|
||||||
@@ -44,6 +49,8 @@ export class SessionService {
|
|||||||
expiresAt,
|
expiresAt,
|
||||||
ipAddress,
|
ipAddress,
|
||||||
userAgent,
|
userAgent,
|
||||||
|
isImpersonation: options?.isImpersonation ?? false,
|
||||||
|
impersonationMetadata: options?.impersonationMetadata as Prisma.InputJsonValue | undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -107,6 +114,53 @@ export class SessionService {
|
|||||||
issuedAt: session.createdAt,
|
issuedAt: session.createdAt,
|
||||||
expiresAt: session.expiresAt,
|
expiresAt: session.expiresAt,
|
||||||
user: this.toAuthenticatedUser(session.user),
|
user: this.toAuthenticatedUser(session.user),
|
||||||
|
impersonation: session.isImpersonation
|
||||||
|
? {
|
||||||
|
isImpersonation: true,
|
||||||
|
source:
|
||||||
|
typeof session.impersonationMetadata === 'object' &&
|
||||||
|
session.impersonationMetadata !== null &&
|
||||||
|
'source' in session.impersonationMetadata
|
||||||
|
? String((session.impersonationMetadata as { source?: string }).source)
|
||||||
|
: undefined,
|
||||||
|
externalServiceId:
|
||||||
|
typeof session.impersonationMetadata === 'object' &&
|
||||||
|
session.impersonationMetadata !== null &&
|
||||||
|
'externalServiceId' in session.impersonationMetadata
|
||||||
|
? String(
|
||||||
|
(session.impersonationMetadata as { externalServiceId?: string })
|
||||||
|
.externalServiceId ?? '',
|
||||||
|
) || null
|
||||||
|
: null,
|
||||||
|
externalClientId:
|
||||||
|
typeof session.impersonationMetadata === 'object' &&
|
||||||
|
session.impersonationMetadata !== null &&
|
||||||
|
'externalClientId' in session.impersonationMetadata
|
||||||
|
? String(
|
||||||
|
(session.impersonationMetadata as { externalClientId?: string })
|
||||||
|
.externalClientId ?? '',
|
||||||
|
) || null
|
||||||
|
: null,
|
||||||
|
externalUserId:
|
||||||
|
typeof session.impersonationMetadata === 'object' &&
|
||||||
|
session.impersonationMetadata !== null &&
|
||||||
|
'externalUserId' in session.impersonationMetadata
|
||||||
|
? String(
|
||||||
|
(session.impersonationMetadata as { externalUserId?: string })
|
||||||
|
.externalUserId ?? '',
|
||||||
|
) || null
|
||||||
|
: null,
|
||||||
|
impersonatorLabel:
|
||||||
|
typeof session.impersonationMetadata === 'object' &&
|
||||||
|
session.impersonationMetadata !== null &&
|
||||||
|
'impersonatorLabel' in session.impersonationMetadata
|
||||||
|
? String(
|
||||||
|
(session.impersonationMetadata as { impersonatorLabel?: string })
|
||||||
|
.impersonatorLabel ?? '',
|
||||||
|
) || undefined
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,4 +246,15 @@ export class SessionService {
|
|||||||
roles: roles.length > 0 ? roles : [UserRole.USER],
|
roles: roles.length > 0 ? roles : [UserRole.USER],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toAuthUserResponse(user: {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
username: string;
|
||||||
|
displayName: string | null;
|
||||||
|
emailVerifiedAt: Date | null;
|
||||||
|
platformRoles: Array<{ role: { name: string } }>;
|
||||||
|
}) {
|
||||||
|
return this.toAuthenticatedUser(user);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
65
apps/api/src/auth/sso-target.util.ts
Normal file
65
apps/api/src/auth/sso-target.util.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
|
||||||
|
const UUID_PATTERN =
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
|
const ALLOWED_EXACT_PATHS = new Set(['/dashboard', '/servers']);
|
||||||
|
|
||||||
|
const ALLOWED_SERVER_SUBPAGES = new Set([
|
||||||
|
'console',
|
||||||
|
'files',
|
||||||
|
'properties',
|
||||||
|
'players',
|
||||||
|
'worlds',
|
||||||
|
'backups',
|
||||||
|
'addons',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function resolveSsoTargetPath(
|
||||||
|
requestedPath: string | undefined,
|
||||||
|
serverId: string,
|
||||||
|
): string {
|
||||||
|
const defaultPath = `/servers/${serverId}`;
|
||||||
|
|
||||||
|
if (!requestedPath || requestedPath.trim() === '') {
|
||||||
|
return defaultPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = requestedPath.trim();
|
||||||
|
|
||||||
|
if (
|
||||||
|
trimmed.includes('://') ||
|
||||||
|
trimmed.startsWith('//') ||
|
||||||
|
trimmed.includes('\\') ||
|
||||||
|
trimmed.includes('\0')
|
||||||
|
) {
|
||||||
|
throw new BadRequestException('Target path must be a relative application path');
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
|
||||||
|
|
||||||
|
if (ALLOWED_EXACT_PATHS.has(path)) {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!path.startsWith(`/servers/${serverId}`)) {
|
||||||
|
throw new BadRequestException('Target path must reference the linked server');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === `/servers/${serverId}`) {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
const suffix = path.slice(`/servers/${serverId}`.length);
|
||||||
|
const subpage = suffix.startsWith('/') ? suffix.slice(1) : suffix;
|
||||||
|
|
||||||
|
if (subpage && ALLOWED_SERVER_SUBPAGES.has(subpage)) {
|
||||||
|
return `/servers/${serverId}/${subpage}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new BadRequestException('Target path is not allowlisted');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isUuid(value: string): boolean {
|
||||||
|
return UUID_PATTERN.test(value);
|
||||||
|
}
|
||||||
255
apps/api/src/auth/sso.service.ts
Normal file
255
apps/api/src/auth/sso.service.ts
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ForbiddenException,
|
||||||
|
Inject,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import type { FastifyReply } from 'fastify';
|
||||||
|
|
||||||
|
import { generateSecureToken, hashToken } from '@hexahost/auth';
|
||||||
|
import type {
|
||||||
|
SsoConsumeResponse,
|
||||||
|
WhmcsCreateSsoRequest,
|
||||||
|
WhmcsCreateSsoResponse,
|
||||||
|
} from '@hexahost/contracts';
|
||||||
|
import type { AppConfig } from '@hexahost/config';
|
||||||
|
|
||||||
|
import { APP_CONFIG } from '../config/config.constants';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
import { AuditService } from './audit.service';
|
||||||
|
import { resolveSsoTargetPath } from './sso-target.util';
|
||||||
|
import { SessionService } from './session.service';
|
||||||
|
|
||||||
|
const SSO_TICKET_TTL_MS = 60_000;
|
||||||
|
|
||||||
|
type InstallationRef = {
|
||||||
|
id: string;
|
||||||
|
integrationId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SsoService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly sessionService: SessionService,
|
||||||
|
private readonly auditService: AuditService,
|
||||||
|
@Inject(APP_CONFIG) private readonly config: AppConfig,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async createWhmcsServiceSso(
|
||||||
|
installation: InstallationRef,
|
||||||
|
externalServiceId: string,
|
||||||
|
input: WhmcsCreateSsoRequest,
|
||||||
|
idempotencyKey: string,
|
||||||
|
): Promise<WhmcsCreateSsoResponse> {
|
||||||
|
const existingOp = await this.prisma.integrationOperation.findUnique({
|
||||||
|
where: { idempotencyKey },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingOp?.result) {
|
||||||
|
return existingOp.result as WhmcsCreateSsoResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
const serviceLink = await this.prisma.whmcsServiceLink.findUnique({
|
||||||
|
where: {
|
||||||
|
installationId_externalServiceId: {
|
||||||
|
installationId: installation.id,
|
||||||
|
externalServiceId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
server: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!serviceLink) {
|
||||||
|
throw new NotFoundException(`Unknown WHMCS service ${externalServiceId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (serviceLink.status === 'SUSPENDED' || serviceLink.status === 'TERMINATED') {
|
||||||
|
throw new ForbiddenException('Service is not eligible for SSO');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (serviceLink.server.billingSuspendedAt) {
|
||||||
|
throw new ForbiddenException('Server is billing suspended');
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientLink = await this.prisma.whmcsClientLink.findUnique({
|
||||||
|
where: {
|
||||||
|
installationId_externalClientId: {
|
||||||
|
installationId: installation.id,
|
||||||
|
externalClientId: input.externalClientId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!clientLink || clientLink.userId !== serviceLink.server.userId) {
|
||||||
|
throw new ForbiddenException('WHMCS client is not linked to this service');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAdminSso = input.kind === 'admin';
|
||||||
|
const targetPath = resolveSsoTargetPath(
|
||||||
|
input.targetPath,
|
||||||
|
serviceLink.serverId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const plainToken = generateSecureToken();
|
||||||
|
const tokenHash = hashToken(plainToken);
|
||||||
|
const expiresAt = new Date(Date.now() + SSO_TICKET_TTL_MS);
|
||||||
|
const audience = this.config.APP_URL.replace(/\/$/, '');
|
||||||
|
|
||||||
|
const ticket = await this.prisma.ssoTicket.create({
|
||||||
|
data: {
|
||||||
|
installationId: installation.id,
|
||||||
|
tokenHash,
|
||||||
|
kind: isAdminSso ? 'ADMIN' : 'SERVICE',
|
||||||
|
userId: serviceLink.server.userId,
|
||||||
|
serverId: serviceLink.serverId,
|
||||||
|
externalServiceId,
|
||||||
|
externalClientId: input.externalClientId,
|
||||||
|
externalUserId: input.externalUserId,
|
||||||
|
targetPath,
|
||||||
|
issuer: installation.integrationId,
|
||||||
|
audience,
|
||||||
|
nonce: randomUUID(),
|
||||||
|
expiresAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const redirectUrl = `${audience}/auth/sso?ticket=${encodeURIComponent(plainToken)}`;
|
||||||
|
|
||||||
|
const response: WhmcsCreateSsoResponse = {
|
||||||
|
ticketId: ticket.id,
|
||||||
|
redirectUrl,
|
||||||
|
expiresAt: expiresAt.toISOString(),
|
||||||
|
targetPath,
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.prisma.integrationOperation.create({
|
||||||
|
data: {
|
||||||
|
installationId: installation.id,
|
||||||
|
idempotencyKey,
|
||||||
|
operation: isAdminSso ? 'sso:admin' : 'sso:service',
|
||||||
|
externalRef: externalServiceId,
|
||||||
|
status: 'COMPLETED',
|
||||||
|
result: response,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.auditService.record({
|
||||||
|
action: isAdminSso ? 'whmcs.sso.admin_created' : 'whmcs.sso.service_created',
|
||||||
|
userId: serviceLink.server.userId,
|
||||||
|
entityType: 'game_server',
|
||||||
|
entityId: serviceLink.serverId,
|
||||||
|
metadata: {
|
||||||
|
externalServiceId,
|
||||||
|
externalClientId: input.externalClientId,
|
||||||
|
externalUserId: input.externalUserId,
|
||||||
|
ticketId: ticket.id,
|
||||||
|
targetPath,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
async consumeTicket(
|
||||||
|
ticket: string,
|
||||||
|
reply: FastifyReply,
|
||||||
|
ipAddress?: string,
|
||||||
|
userAgent?: string,
|
||||||
|
): Promise<SsoConsumeResponse> {
|
||||||
|
const tokenHash = hashToken(ticket);
|
||||||
|
|
||||||
|
const redeemed = await this.prisma.$transaction(async (tx) => {
|
||||||
|
const record = await tx.ssoTicket.findUnique({
|
||||||
|
where: { tokenHash },
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
include: {
|
||||||
|
platformRoles: { include: { role: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!record || record.usedAt || record.expiresAt <= new Date()) {
|
||||||
|
throw new UnauthorizedException('Invalid or expired SSO ticket');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (record.audience !== this.config.APP_URL.replace(/\/$/, '')) {
|
||||||
|
throw new UnauthorizedException('SSO ticket audience mismatch');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await tx.ssoTicket.updateMany({
|
||||||
|
where: {
|
||||||
|
id: record.id,
|
||||||
|
usedAt: null,
|
||||||
|
expiresAt: { gt: new Date() },
|
||||||
|
},
|
||||||
|
data: { usedAt: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (updated.count !== 1) {
|
||||||
|
throw new UnauthorizedException('SSO ticket already used');
|
||||||
|
}
|
||||||
|
|
||||||
|
return record;
|
||||||
|
});
|
||||||
|
|
||||||
|
const isImpersonation = redeemed.kind === 'ADMIN';
|
||||||
|
const impersonationMetadata = isImpersonation
|
||||||
|
? {
|
||||||
|
source: 'whmcs',
|
||||||
|
externalServiceId: redeemed.externalServiceId,
|
||||||
|
externalClientId: redeemed.externalClientId,
|
||||||
|
externalUserId: redeemed.externalUserId,
|
||||||
|
impersonatorLabel: `WHMCS admin user ${redeemed.externalUserId ?? 'unknown'}`,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const { token, expiresAt } = await this.sessionService.createSession(
|
||||||
|
redeemed.userId,
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
{
|
||||||
|
isImpersonation,
|
||||||
|
impersonationMetadata,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
this.sessionService.setSessionCookie(reply, token, expiresAt);
|
||||||
|
|
||||||
|
await this.auditService.record({
|
||||||
|
action: isImpersonation ? 'whmcs.sso.admin_consumed' : 'whmcs.sso.service_consumed',
|
||||||
|
userId: redeemed.userId,
|
||||||
|
entityType: redeemed.serverId ? 'game_server' : 'user',
|
||||||
|
entityId: redeemed.serverId ?? redeemed.userId,
|
||||||
|
ipAddress,
|
||||||
|
metadata: {
|
||||||
|
ticketId: redeemed.id,
|
||||||
|
externalServiceId: redeemed.externalServiceId,
|
||||||
|
externalUserId: redeemed.externalUserId,
|
||||||
|
targetPath: redeemed.targetPath,
|
||||||
|
isImpersonation,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
redirectPath: redeemed.targetPath,
|
||||||
|
user: this.sessionService.toAuthUserResponse(redeemed.user),
|
||||||
|
impersonation: isImpersonation
|
||||||
|
? {
|
||||||
|
isImpersonation: true,
|
||||||
|
label:
|
||||||
|
impersonationMetadata?.impersonatorLabel ??
|
||||||
|
'Support impersonation session',
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,12 +13,15 @@ import { SkipThrottle } from '@nestjs/throttler';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
whmcsChangePackageRequestSchema,
|
whmcsChangePackageRequestSchema,
|
||||||
|
whmcsCreateSsoRequestSchema,
|
||||||
whmcsProvisionServiceRequestSchema,
|
whmcsProvisionServiceRequestSchema,
|
||||||
whmcsSuspendRequestSchema,
|
whmcsSuspendRequestSchema,
|
||||||
whmcsUpsertClientRequestSchema,
|
whmcsUpsertClientRequestSchema,
|
||||||
} from '@hexahost/contracts';
|
} from '@hexahost/contracts';
|
||||||
import { INTEGRATION_HEADERS } from '@hexahost/integration-auth';
|
import { INTEGRATION_HEADERS } from '@hexahost/integration-auth';
|
||||||
|
|
||||||
|
import { SsoService } from '../../auth/sso.service';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
WhmcsIntegrationGuard,
|
WhmcsIntegrationGuard,
|
||||||
type WhmcsAuthenticatedRequest,
|
type WhmcsAuthenticatedRequest,
|
||||||
@@ -29,7 +32,10 @@ import { WhmcsIntegrationService } from './whmcs-integration.service';
|
|||||||
@UseGuards(WhmcsIntegrationGuard)
|
@UseGuards(WhmcsIntegrationGuard)
|
||||||
@SkipThrottle()
|
@SkipThrottle()
|
||||||
export class WhmcsIntegrationController {
|
export class WhmcsIntegrationController {
|
||||||
constructor(private readonly whmcs: WhmcsIntegrationService) {}
|
constructor(
|
||||||
|
private readonly whmcs: WhmcsIntegrationService,
|
||||||
|
private readonly sso: SsoService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get('health')
|
@Get('health')
|
||||||
health(@Req() request: WhmcsAuthenticatedRequest) {
|
health(@Req() request: WhmcsAuthenticatedRequest) {
|
||||||
@@ -127,6 +133,21 @@ export class WhmcsIntegrationController {
|
|||||||
headerValue(request, INTEGRATION_HEADERS.idempotencyKey)!,
|
headerValue(request, INTEGRATION_HEADERS.idempotencyKey)!,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('services/:externalServiceId/sso')
|
||||||
|
createServiceSso(
|
||||||
|
@Req() request: WhmcsAuthenticatedRequest,
|
||||||
|
@Param('externalServiceId') externalServiceId: string,
|
||||||
|
@Body() body: unknown,
|
||||||
|
) {
|
||||||
|
const parsed = whmcsCreateSsoRequestSchema.parse(body);
|
||||||
|
return this.sso.createWhmcsServiceSso(
|
||||||
|
request.whmcsInstallation!,
|
||||||
|
externalServiceId,
|
||||||
|
parsed,
|
||||||
|
headerValue(request, INTEGRATION_HEADERS.idempotencyKey)!,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function headerValue(request: WhmcsAuthenticatedRequest, header: string): string | undefined {
|
function headerValue(request: WhmcsAuthenticatedRequest, header: string): string | undefined {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import Redis from 'ioredis';
|
|||||||
import { getConfig } from '@hexahost/config';
|
import { getConfig } from '@hexahost/config';
|
||||||
|
|
||||||
import { BillingModule } from '../../billing/billing.module';
|
import { BillingModule } from '../../billing/billing.module';
|
||||||
|
import { AuthModule } from '../../auth/auth.module';
|
||||||
import { ServersModule } from '../../servers/servers.module';
|
import { ServersModule } from '../../servers/servers.module';
|
||||||
|
|
||||||
import { WhmcsIntegrationController } from './whmcs-integration.controller';
|
import { WhmcsIntegrationController } from './whmcs-integration.controller';
|
||||||
@@ -14,7 +15,7 @@ import { WhmcsIntegrationService } from './whmcs-integration.service';
|
|||||||
export { INTEGRATIONS_REDIS } from './whmcs-integration.constants';
|
export { INTEGRATIONS_REDIS } from './whmcs-integration.constants';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [ServersModule, BillingModule],
|
imports: [ServersModule, BillingModule, AuthModule],
|
||||||
controllers: [WhmcsIntegrationController],
|
controllers: [WhmcsIntegrationController],
|
||||||
providers: [
|
providers: [
|
||||||
WhmcsIntegrationService,
|
WhmcsIntegrationService,
|
||||||
|
|||||||
42
apps/api/test/whmcs-phase-d.test.js
Normal file
42
apps/api/test/whmcs-phase-d.test.js
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
describe('whmcs phase d sso contract', () => {
|
||||||
|
it('documents SSO create and consume routes', () => {
|
||||||
|
const whmcsSource = readFileSync(
|
||||||
|
join(process.cwd(), 'src', 'integrations/whmcs/whmcs-integration.controller.ts'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
assert.ok(whmcsSource.includes("'services/:externalServiceId/sso'"));
|
||||||
|
|
||||||
|
const authSource = readFileSync(
|
||||||
|
join(process.cwd(), 'src', 'auth/auth.controller.ts'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
assert.ok(authSource.includes("'sso/consume'"));
|
||||||
|
|
||||||
|
const ssoServiceSource = readFileSync(
|
||||||
|
join(process.cwd(), 'src', 'auth/sso.service.ts'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
assert.ok(ssoServiceSource.includes('createWhmcsServiceSso'));
|
||||||
|
assert.ok(ssoServiceSource.includes('consumeTicket'));
|
||||||
|
assert.ok(ssoServiceSource.includes('isImpersonation'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('documents WHMCS PHP SSO hooks', () => {
|
||||||
|
const phpSource = readFileSync(
|
||||||
|
join(
|
||||||
|
process.cwd(),
|
||||||
|
'..',
|
||||||
|
'..',
|
||||||
|
'integrations/whmcs/modules/servers/hexagamecloud/hexagamecloud.php',
|
||||||
|
),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
assert.ok(phpSource.includes('ServiceSingleSignOn'));
|
||||||
|
assert.ok(phpSource.includes('AdminSingleSignOn'));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -86,6 +86,12 @@
|
|||||||
"securityTitle": "Sicherheit",
|
"securityTitle": "Sicherheit",
|
||||||
"securitySubtitle": "Authentifizierung und Sicherheitseinstellungen Ihres Kontos verwalten.",
|
"securitySubtitle": "Authentifizierung und Sicherheitseinstellungen Ihres Kontos verwalten.",
|
||||||
"backToLogin": "Zurück zur Anmeldung",
|
"backToLogin": "Zurück zur Anmeldung",
|
||||||
|
"ssoTitle": "Anmeldung wird vorbereitet",
|
||||||
|
"ssoLoading": "Sicherer Anmeldelink wird geprüft…",
|
||||||
|
"ssoError": "Anmeldung fehlgeschlagen. Der Link ist abgelaufen oder wurde bereits verwendet.",
|
||||||
|
"ssoMissingTicket": "Es wurde kein Anmeldeticket übergeben.",
|
||||||
|
"impersonationBanner": "Support-Sitzung aktiv: {label}",
|
||||||
|
"impersonationDefaultLabel": "WHMCS-Admin-Impersonation",
|
||||||
"sessionNone": "Sitzung: keine",
|
"sessionNone": "Sitzung: keine",
|
||||||
"errors": {
|
"errors": {
|
||||||
"generic": "Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut."
|
"generic": "Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut."
|
||||||
|
|||||||
@@ -86,6 +86,12 @@
|
|||||||
"securityTitle": "Security",
|
"securityTitle": "Security",
|
||||||
"securitySubtitle": "Manage authentication and security settings for your account.",
|
"securitySubtitle": "Manage authentication and security settings for your account.",
|
||||||
"backToLogin": "Back to sign in",
|
"backToLogin": "Back to sign in",
|
||||||
|
"ssoTitle": "Signing you in",
|
||||||
|
"ssoLoading": "Validating your secure sign-in link…",
|
||||||
|
"ssoError": "Sign-in failed. The link may be expired or already used.",
|
||||||
|
"ssoMissingTicket": "No sign-in ticket was provided.",
|
||||||
|
"impersonationBanner": "Support session active: {label}",
|
||||||
|
"impersonationDefaultLabel": "WHMCS admin impersonation",
|
||||||
"sessionNone": "session: none",
|
"sessionNone": "session: none",
|
||||||
"errors": {
|
"errors": {
|
||||||
"generic": "Something went wrong. Please try again."
|
"generic": "Something went wrong. Please try again."
|
||||||
|
|||||||
20
apps/web/src/app/[locale]/auth/sso/page.tsx
Normal file
20
apps/web/src/app/[locale]/auth/sso/page.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { Suspense } from "react";
|
||||||
|
import { setRequestLocale } from "next-intl/server";
|
||||||
|
import { SsoContent } from "@/components/auth/sso-content";
|
||||||
|
|
||||||
|
interface SsoPageProps {
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function SsoPage({ params }: SsoPageProps) {
|
||||||
|
const { locale } = await params;
|
||||||
|
setRequestLocale(locale);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex min-h-[calc(100vh-8rem)] max-w-6xl items-center justify-center px-4 py-12 sm:px-6">
|
||||||
|
<Suspense>
|
||||||
|
<SsoContent />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
apps/web/src/components/auth/impersonation-banner.tsx
Normal file
25
apps/web/src/components/auth/impersonation-banner.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useAuth } from "@/components/providers/auth-provider";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
export function ImpersonationBanner() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const t = useTranslations("auth");
|
||||||
|
|
||||||
|
const impersonation = user?.impersonation;
|
||||||
|
if (!impersonation?.isImpersonation) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="border-b border-amber-300 bg-amber-50 px-4 py-2 text-center text-sm text-amber-950 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-100"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
{t("impersonationBanner", {
|
||||||
|
label: impersonation.label ?? t("impersonationDefaultLabel"),
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
101
apps/web/src/components/auth/sso-content.tsx
Normal file
101
apps/web/src/components/auth/sso-content.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
|
||||||
|
import { Link, useRouter } from "@/i18n/navigation";
|
||||||
|
import { ApiError, consumeSsoTicket } from "@/lib/api/auth";
|
||||||
|
import { useAuth } from "@/components/providers/auth-provider";
|
||||||
|
|
||||||
|
type SsoState = "idle" | "loading" | "success" | "error";
|
||||||
|
|
||||||
|
export function SsoContent() {
|
||||||
|
const t = useTranslations("auth");
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const { refresh } = useAuth();
|
||||||
|
const ticket = searchParams.get("ticket");
|
||||||
|
const [state, setState] = useState<SsoState>(ticket ? "loading" : "idle");
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ticket) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function redeem() {
|
||||||
|
try {
|
||||||
|
const result = await consumeSsoTicket(ticket!);
|
||||||
|
await refresh();
|
||||||
|
if (!cancelled) {
|
||||||
|
setState("success");
|
||||||
|
router.replace(result.redirectPath);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setState("error");
|
||||||
|
if (error instanceof ApiError) {
|
||||||
|
setErrorMessage(error.detail ?? error.title);
|
||||||
|
} else {
|
||||||
|
setErrorMessage(t("errors.generic"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void redeem();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [ticket, refresh, router, t]);
|
||||||
|
|
||||||
|
if (!ticket) {
|
||||||
|
return (
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t("ssoTitle")}</CardTitle>
|
||||||
|
<CardDescription>{t("ssoMissingTicket")}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Link href="/login" className="text-sm font-medium text-sky-600 hover:underline dark:text-sky-400">
|
||||||
|
{t("backToLogin")}
|
||||||
|
</Link>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === "loading" || state === "idle" || state === "success") {
|
||||||
|
return (
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t("ssoTitle")}</CardTitle>
|
||||||
|
<CardDescription>{t("ssoLoading")}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t("ssoTitle")}</CardTitle>
|
||||||
|
<CardDescription>{t("ssoError")}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{errorMessage ? (
|
||||||
|
<p className="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-900 dark:border-red-900 dark:bg-red-950 dark:text-red-100" role="alert">
|
||||||
|
{errorMessage}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<Link href="/login" className="text-sm font-medium text-sky-600 hover:underline dark:text-sky-400">
|
||||||
|
{t("backToLogin")}
|
||||||
|
</Link>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import { ImpersonationBanner } from "@/components/auth/impersonation-banner";
|
||||||
import { Footer } from "./footer";
|
import { Footer } from "./footer";
|
||||||
import { Header } from "./header";
|
import { Header } from "./header";
|
||||||
|
|
||||||
@@ -10,6 +11,7 @@ export function SiteLayout({ children }: SiteLayoutProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-50">
|
<div className="flex min-h-screen flex-col bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-50">
|
||||||
<Header />
|
<Header />
|
||||||
|
<ImpersonationBanner />
|
||||||
<main className="flex-1">{children}</main>
|
<main className="flex-1">{children}</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -48,8 +48,23 @@ export interface CurrentUser {
|
|||||||
username: string;
|
username: string;
|
||||||
displayName?: string | null;
|
displayName?: string | null;
|
||||||
emailVerifiedAt?: string | null;
|
emailVerifiedAt?: string | null;
|
||||||
|
emailVerified?: boolean;
|
||||||
roles?: string[];
|
roles?: string[];
|
||||||
twoFactorEnabled?: boolean;
|
twoFactorEnabled?: boolean;
|
||||||
|
impersonation?: {
|
||||||
|
isImpersonation: boolean;
|
||||||
|
label?: string;
|
||||||
|
impersonatorLabel?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MeResponse {
|
||||||
|
user: CurrentUser;
|
||||||
|
impersonation?: {
|
||||||
|
isImpersonation: boolean;
|
||||||
|
impersonatorLabel?: string;
|
||||||
|
label?: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TwoFactorSetupResponse {
|
export interface TwoFactorSetupResponse {
|
||||||
@@ -149,8 +164,30 @@ export async function submitLogout(): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCurrentUser(): Promise<CurrentUser> {
|
export async function getCurrentUser(): Promise<CurrentUser | null> {
|
||||||
return apiFetch<CurrentUser>("/me");
|
try {
|
||||||
|
const response = await apiFetch<MeResponse>("/me");
|
||||||
|
return {
|
||||||
|
...response.user,
|
||||||
|
impersonation: response.impersonation
|
||||||
|
? {
|
||||||
|
isImpersonation: response.impersonation.isImpersonation,
|
||||||
|
label:
|
||||||
|
response.impersonation.label ??
|
||||||
|
response.impersonation.impersonatorLabel,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function consumeSsoTicket(ticket: string): Promise<{ redirectPath: string }> {
|
||||||
|
return apiFetch<{ redirectPath: string }>("/auth/sso/consume", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ ticket }),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function verifyEmail(token: string): Promise<void> {
|
export async function verifyEmail(token: string): Promise<void> {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -2,7 +2,42 @@
|
|||||||
|
|
||||||
Last updated: 2026-06-26
|
Last updated: 2026-06-26
|
||||||
|
|
||||||
## Current phase: Phase 9 — WHMCS integration and production hardening ✅
|
## Current phase: WHMCS Phase D — SSO ✅
|
||||||
|
|
||||||
|
### WHMCS Phase D completed
|
||||||
|
|
||||||
|
| Area | Status | Notes |
|
||||||
|
|------|--------|-------|
|
||||||
|
| `SsoTicket` model | Done | Hashed token, 60s TTL, single use |
|
||||||
|
| Impersonation sessions | Done | `UserSession.isImpersonation` + audit |
|
||||||
|
| API create SSO | Done | `POST .../services/:id/sso` |
|
||||||
|
| API consume SSO | Done | `POST /auth/sso/consume` → session cookie |
|
||||||
|
| Path allowlist | Done | Server panel routes only |
|
||||||
|
| Web SSO page | Done | `/auth/sso?ticket=` + impersonation banner |
|
||||||
|
| WHMCS PHP SSO | Done | `ServiceSingleSignOn`, `AdminSingleSignOn` |
|
||||||
|
| Docs | Done | `docs/integrations/whmcs/sso.md` |
|
||||||
|
|
||||||
|
### Abnahmekriterium Phase D
|
||||||
|
|
||||||
|
- [x] WHMCS „Server verwalten“ erzeugt einmaliges SSO-Ticket
|
||||||
|
- [x] Ticket-Einlösung setzt Session-Cookie und leitet auf Server-Panel weiter
|
||||||
|
- [x] Admin-SSO markiert Impersonation-Session sichtbar im Panel
|
||||||
|
- [x] Gesperrte/beendete Services erhalten kein SSO-Ticket
|
||||||
|
- [ ] Manual E2E: WHMCS client area → panel → server detail
|
||||||
|
|
||||||
|
### Local SSO test
|
||||||
|
|
||||||
|
1. `pnpm db:migrate && pnpm db:seed` (includes `local-dev-whmcs`)
|
||||||
|
2. Provision a service via WHMCS API (`PUT .../clients/1`, `POST .../services`)
|
||||||
|
3. `POST /api/v1/integrations/whmcs/services/{externalServiceId}/sso` (signed)
|
||||||
|
4. Open `{APP_URL}/auth/sso?ticket={token}` → redirects to `/servers/{id}`
|
||||||
|
5. Admin SSO: same flow with `kind=admin` → impersonation banner visible
|
||||||
|
|
||||||
|
See [docs/integrations/whmcs/sso.md](integrations/whmcs/sso.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 9 — WHMCS integration and production hardening ✅
|
||||||
|
|
||||||
### Phase 9 completed
|
### Phase 9 completed
|
||||||
|
|
||||||
@@ -156,4 +191,4 @@ Server entry fields:
|
|||||||
|
|
||||||
### Next phase: Post-MVP hardening
|
### Next phase: Post-MVP hardening
|
||||||
|
|
||||||
- Stripe webhooks, SSO (WHMCS Phase D), reconciliation dashboard, penetration test
|
- WHMCS Phase E (reconciliation), Phase F (usage billing), Stripe webhooks, penetration test
|
||||||
|
|||||||
51
docs/integrations/whmcs/sso.md
Normal file
51
docs/integrations/whmcs/sso.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# WHMCS single sign-on (Phase D)
|
||||||
|
|
||||||
|
HexaHost GameCloud uses one-time SSO tickets to bridge WHMCS and the customer panel without long-lived tokens in URLs.
|
||||||
|
|
||||||
|
## Flow
|
||||||
|
|
||||||
|
1. WHMCS calls `ServiceSingleSignOn` or `AdminSingleSignOn` in the server module.
|
||||||
|
2. The module requests `POST /api/v1/integrations/whmcs/services/:externalServiceId/sso`.
|
||||||
|
3. GameCloud stores a hashed ticket (60s TTL, single use) and returns `redirectUrl`.
|
||||||
|
4. WHMCS redirects the browser to `{APP_URL}/auth/sso?ticket=...`.
|
||||||
|
5. The web app calls `POST /api/v1/integrations/whmcs/auth/sso/consume` — actually `/auth/sso/consume`.
|
||||||
|
6. GameCloud sets a normal HTTP-only session cookie and returns an allowlisted `redirectPath`.
|
||||||
|
|
||||||
|
## Ticket properties
|
||||||
|
|
||||||
|
Stored server-side (hashed):
|
||||||
|
|
||||||
|
- GameCloud user and server IDs
|
||||||
|
- WHMCS installation, client, user, and service IDs
|
||||||
|
- Target path (allowlisted)
|
||||||
|
- Issuer (`integrationId`) and audience (`APP_URL`)
|
||||||
|
- Nonce (replay protection at ticket layer)
|
||||||
|
|
||||||
|
## Admin SSO / impersonation
|
||||||
|
|
||||||
|
`kind=admin` creates an impersonation session:
|
||||||
|
|
||||||
|
- `UserSession.isImpersonation = true`
|
||||||
|
- Metadata includes WHMCS admin user reference
|
||||||
|
- Full audit events: `whmcs.sso.admin_created`, `whmcs.sso.admin_consumed`
|
||||||
|
- Panel shows an amber support banner
|
||||||
|
|
||||||
|
## Allowlisted redirect paths
|
||||||
|
|
||||||
|
- `/dashboard`
|
||||||
|
- `/servers`
|
||||||
|
- `/servers/{serverId}`
|
||||||
|
- `/servers/{serverId}/{console|files|properties|players|worlds|backups|addons}`
|
||||||
|
|
||||||
|
Arbitrary external URLs are rejected.
|
||||||
|
|
||||||
|
## WHMCS configuration
|
||||||
|
|
||||||
|
Server module hostname must match `APP_URL` origin used for ticket audience validation.
|
||||||
|
|
||||||
|
## Security notes
|
||||||
|
|
||||||
|
- Tickets expire after **60 seconds**
|
||||||
|
- Each ticket is **single-use**
|
||||||
|
- Only the WHMCS integration API (HMAC) can mint tickets
|
||||||
|
- Suspended or terminated services cannot obtain SSO tickets
|
||||||
@@ -54,6 +54,8 @@ Handles global configuration:
|
|||||||
|
|
||||||
**Phase 9 (WHMCS Phase A–B + production DNS)** — implemented.
|
**Phase 9 (WHMCS Phase A–B + production DNS)** — implemented.
|
||||||
|
|
||||||
|
**Phase D (SSO)** — implemented. See [docs/integrations/whmcs/sso.md](../../docs/integrations/whmcs/sso.md).
|
||||||
|
|
||||||
- Server module: `integrations/whmcs/modules/servers/hexagamecloud/`
|
- Server module: `integrations/whmcs/modules/servers/hexagamecloud/`
|
||||||
- Addon module: planned for global mappings and reconciliation UI
|
- Addon module: planned for global mappings and reconciliation UI
|
||||||
|
|
||||||
|
|||||||
@@ -110,6 +110,16 @@ function hexagamecloud_ChangePackage(array $params): string
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hexagamecloud_ServiceSingleSignOn(array $params)
|
||||||
|
{
|
||||||
|
return hexagamecloud_requestSso($params, 'service');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexagamecloud_AdminSingleSignOn(array $params)
|
||||||
|
{
|
||||||
|
return hexagamecloud_requestSso($params, 'admin');
|
||||||
|
}
|
||||||
|
|
||||||
function hexagamecloud_TestConnection(array $params): array
|
function hexagamecloud_TestConnection(array $params): array
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
@@ -142,3 +152,31 @@ function hexagamecloud_lifecycleAction(array $params, string $action, array $bod
|
|||||||
return $exception->getMessage();
|
return $exception->getMessage();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hexagamecloud_requestSso(array $params, string $kind)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$client = new HexaGameCloudApiClient($params);
|
||||||
|
$externalServiceId = (string) $params['serviceid'];
|
||||||
|
$externalClientId = (string) ($params['clientsdetails']['userid'] ?? $params['userid'] ?? '');
|
||||||
|
$externalUserId = (string) ($params['clientsdetails']['userid'] ?? $params['adminid'] ?? $externalClientId);
|
||||||
|
|
||||||
|
$response = $client->createServiceSso($externalServiceId, [
|
||||||
|
'externalClientId' => $externalClientId,
|
||||||
|
'externalUserId' => $externalUserId,
|
||||||
|
'role' => $kind === 'admin' ? 'admin' : 'owner',
|
||||||
|
'kind' => $kind,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'redirectTo' => $response['redirectUrl'],
|
||||||
|
];
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
logModuleCall('hexagamecloud', 'sso:' . $kind, $params, $exception->getMessage());
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'error' => $exception->getMessage(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -85,6 +85,16 @@ final class HexaGameCloudApiClient
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function createServiceSso(string $externalServiceId, array $payload): array
|
||||||
|
{
|
||||||
|
return $this->request(
|
||||||
|
'POST',
|
||||||
|
'/api/v1/integrations/whmcs/services/' . rawurlencode($externalServiceId) . '/sso',
|
||||||
|
$payload,
|
||||||
|
'service:sso:' . $externalServiceId . ':' . ($payload['kind'] ?? 'service')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private function request(string $method, string $path, array $body = [], ?string $idempotencySuffix = null): array
|
private function request(string $method, string $path, array $body = [], ?string $idempotencySuffix = null): array
|
||||||
{
|
{
|
||||||
$bodyJson = $body === [] ? '' : json_encode($body, JSON_THROW_ON_ERROR);
|
$bodyJson = $body === [] ? '' : json_encode($body, JSON_THROW_ON_ERROR);
|
||||||
|
|||||||
@@ -15,11 +15,21 @@ export interface AuthenticatedUser {
|
|||||||
roles: UserRole[];
|
roles: UserRole[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SessionImpersonationContext {
|
||||||
|
isImpersonation: boolean;
|
||||||
|
source?: string;
|
||||||
|
externalServiceId?: string | null;
|
||||||
|
externalClientId?: string | null;
|
||||||
|
externalUserId?: string | null;
|
||||||
|
impersonatorLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SessionContext {
|
export interface SessionContext {
|
||||||
user: AuthenticatedUser;
|
user: AuthenticatedUser;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
issuedAt: Date;
|
issuedAt: Date;
|
||||||
expiresAt: Date;
|
expiresAt: Date;
|
||||||
|
impersonation?: SessionImpersonationContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthTokenPayload {
|
export interface AuthTokenPayload {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -228,6 +228,10 @@ export {
|
|||||||
whmcsServiceResponseSchema,
|
whmcsServiceResponseSchema,
|
||||||
whmcsChangePackageRequestSchema,
|
whmcsChangePackageRequestSchema,
|
||||||
whmcsSuspendRequestSchema,
|
whmcsSuspendRequestSchema,
|
||||||
|
whmcsCreateSsoRequestSchema,
|
||||||
|
whmcsCreateSsoResponseSchema,
|
||||||
|
ssoConsumeRequestSchema,
|
||||||
|
ssoConsumeResponseSchema,
|
||||||
type WhmcsHealthResponse,
|
type WhmcsHealthResponse,
|
||||||
type WhmcsPlanCatalogResponse,
|
type WhmcsPlanCatalogResponse,
|
||||||
type WhmcsProvisionServiceRequest,
|
type WhmcsProvisionServiceRequest,
|
||||||
@@ -235,4 +239,8 @@ export {
|
|||||||
type WhmcsUpsertClientRequest,
|
type WhmcsUpsertClientRequest,
|
||||||
type WhmcsUpsertClientResponse,
|
type WhmcsUpsertClientResponse,
|
||||||
type WhmcsChangePackageRequest,
|
type WhmcsChangePackageRequest,
|
||||||
|
type WhmcsCreateSsoRequest,
|
||||||
|
type WhmcsCreateSsoResponse,
|
||||||
|
type SsoConsumeRequest,
|
||||||
|
type SsoConsumeResponse,
|
||||||
} from './whmcs';
|
} from './whmcs';
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { authUserSchema } from './auth';
|
||||||
import { serverResponseSchema } from './servers';
|
import { serverResponseSchema } from './servers';
|
||||||
|
|
||||||
export const whmcsHealthResponseSchema = z.object({
|
export const whmcsHealthResponseSchema = z.object({
|
||||||
@@ -61,6 +62,40 @@ export const whmcsSuspendRequestSchema = z.object({
|
|||||||
reason: z.string().max(256).optional(),
|
reason: z.string().max(256).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const whmcsSsoRoleSchema = z.enum(['owner', 'admin']);
|
||||||
|
|
||||||
|
export const whmcsSsoKindSchema = z.enum(['service', 'admin']);
|
||||||
|
|
||||||
|
export const whmcsCreateSsoRequestSchema = z.object({
|
||||||
|
externalClientId: z.string().min(1).max(64),
|
||||||
|
externalUserId: z.string().min(1).max(64),
|
||||||
|
role: whmcsSsoRoleSchema.default('owner'),
|
||||||
|
kind: whmcsSsoKindSchema.default('service'),
|
||||||
|
targetPath: z.string().max(256).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const whmcsCreateSsoResponseSchema = z.object({
|
||||||
|
ticketId: z.string().uuid(),
|
||||||
|
redirectUrl: z.string().url(),
|
||||||
|
expiresAt: z.string().datetime(),
|
||||||
|
targetPath: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ssoConsumeRequestSchema = z.object({
|
||||||
|
ticket: z.string().min(32).max(256),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ssoImpersonationSchema = z.object({
|
||||||
|
isImpersonation: z.boolean(),
|
||||||
|
label: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ssoConsumeResponseSchema = z.object({
|
||||||
|
redirectPath: z.string(),
|
||||||
|
user: authUserSchema,
|
||||||
|
impersonation: ssoImpersonationSchema.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
export type WhmcsHealthResponse = z.infer<typeof whmcsHealthResponseSchema>;
|
export type WhmcsHealthResponse = z.infer<typeof whmcsHealthResponseSchema>;
|
||||||
export type WhmcsPlanCatalogResponse = z.infer<typeof whmcsPlanCatalogResponseSchema>;
|
export type WhmcsPlanCatalogResponse = z.infer<typeof whmcsPlanCatalogResponseSchema>;
|
||||||
export type WhmcsUpsertClientRequest = z.infer<typeof whmcsUpsertClientRequestSchema>;
|
export type WhmcsUpsertClientRequest = z.infer<typeof whmcsUpsertClientRequestSchema>;
|
||||||
@@ -69,3 +104,7 @@ export type WhmcsProvisionServiceRequest = z.infer<typeof whmcsProvisionServiceR
|
|||||||
export type WhmcsServiceResponse = z.infer<typeof whmcsServiceResponseSchema>;
|
export type WhmcsServiceResponse = z.infer<typeof whmcsServiceResponseSchema>;
|
||||||
export type WhmcsChangePackageRequest = z.infer<typeof whmcsChangePackageRequestSchema>;
|
export type WhmcsChangePackageRequest = z.infer<typeof whmcsChangePackageRequestSchema>;
|
||||||
export type WhmcsSuspendRequest = z.infer<typeof whmcsSuspendRequestSchema>;
|
export type WhmcsSuspendRequest = z.infer<typeof whmcsSuspendRequestSchema>;
|
||||||
|
export type WhmcsCreateSsoRequest = z.infer<typeof whmcsCreateSsoRequestSchema>;
|
||||||
|
export type WhmcsCreateSsoResponse = z.infer<typeof whmcsCreateSsoResponseSchema>;
|
||||||
|
export type SsoConsumeRequest = z.infer<typeof ssoConsumeRequestSchema>;
|
||||||
|
export type SsoConsumeResponse = z.infer<typeof ssoConsumeResponseSchema>;
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,43 @@
|
|||||||
|
-- WHMCS Phase D: SSO tickets and impersonation sessions
|
||||||
|
|
||||||
|
CREATE TYPE "SsoTicketKind" AS ENUM ('SERVICE', 'ADMIN');
|
||||||
|
|
||||||
|
ALTER TABLE "user_sessions"
|
||||||
|
ADD COLUMN IF NOT EXISTS "isImpersonation" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
ADD COLUMN IF NOT EXISTS "impersonationMetadata" JSONB;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "sso_tickets" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"installationId" TEXT NOT NULL,
|
||||||
|
"tokenHash" TEXT NOT NULL,
|
||||||
|
"kind" "SsoTicketKind" NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"serverId" TEXT,
|
||||||
|
"externalServiceId" TEXT,
|
||||||
|
"externalClientId" TEXT,
|
||||||
|
"externalUserId" TEXT,
|
||||||
|
"targetPath" TEXT NOT NULL,
|
||||||
|
"issuer" TEXT NOT NULL,
|
||||||
|
"audience" TEXT NOT NULL,
|
||||||
|
"nonce" TEXT NOT NULL,
|
||||||
|
"expiresAt" TIMESTAMPTZ(3) NOT NULL,
|
||||||
|
"usedAt" TIMESTAMPTZ(3),
|
||||||
|
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "sso_tickets_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "sso_tickets_tokenHash_key" ON "sso_tickets"("tokenHash");
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "sso_tickets_nonce_key" ON "sso_tickets"("nonce");
|
||||||
|
CREATE INDEX IF NOT EXISTS "sso_tickets_expiresAt_idx" ON "sso_tickets"("expiresAt");
|
||||||
|
CREATE INDEX IF NOT EXISTS "sso_tickets_installationId_createdAt_idx"
|
||||||
|
ON "sso_tickets"("installationId", "createdAt");
|
||||||
|
|
||||||
|
ALTER TABLE "sso_tickets"
|
||||||
|
ADD CONSTRAINT "sso_tickets_installationId_fkey"
|
||||||
|
FOREIGN KEY ("installationId") REFERENCES "whmcs_installations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "sso_tickets"
|
||||||
|
ADD CONSTRAINT "sso_tickets_userId_fkey"
|
||||||
|
FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "sso_tickets"
|
||||||
|
ADD CONSTRAINT "sso_tickets_serverId_fkey"
|
||||||
|
FOREIGN KEY ("serverId") REFERENCES "game_servers"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -147,6 +147,11 @@ enum IntegrationOperationStatus {
|
|||||||
FAILED
|
FAILED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum SsoTicketKind {
|
||||||
|
SERVICE
|
||||||
|
ADMIN
|
||||||
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
email String @unique
|
email String @unique
|
||||||
@@ -169,6 +174,7 @@ model User {
|
|||||||
totpCredential TotpCredential?
|
totpCredential TotpCredential?
|
||||||
creditWallet CreditWallet?
|
creditWallet CreditWallet?
|
||||||
whmcsClientLink WhmcsClientLink?
|
whmcsClientLink WhmcsClientLink?
|
||||||
|
ssoTickets SsoTicket[]
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
@@ -194,6 +200,8 @@ model UserSession {
|
|||||||
revokedAt DateTime? @db.Timestamptz(3)
|
revokedAt DateTime? @db.Timestamptz(3)
|
||||||
ipAddress String?
|
ipAddress String?
|
||||||
userAgent String?
|
userAgent String?
|
||||||
|
isImpersonation Boolean @default(false)
|
||||||
|
impersonationMetadata Json?
|
||||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||||
updatedAt DateTime @updatedAt @db.Timestamptz(3)
|
updatedAt DateTime @updatedAt @db.Timestamptz(3)
|
||||||
|
|
||||||
@@ -332,6 +340,7 @@ model GameServer {
|
|||||||
usageRecords UsageRecord[]
|
usageRecords UsageRecord[]
|
||||||
dnsRecords ServerDnsRecord[]
|
dnsRecords ServerDnsRecord[]
|
||||||
whmcsServiceLink WhmcsServiceLink?
|
whmcsServiceLink WhmcsServiceLink?
|
||||||
|
ssoTickets SsoTicket[]
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([nodeId])
|
@@index([nodeId])
|
||||||
@@ -714,6 +723,7 @@ model WhmcsInstallation {
|
|||||||
clientLinks WhmcsClientLink[]
|
clientLinks WhmcsClientLink[]
|
||||||
serviceLinks WhmcsServiceLink[]
|
serviceLinks WhmcsServiceLink[]
|
||||||
operations IntegrationOperation[]
|
operations IntegrationOperation[]
|
||||||
|
ssoTickets SsoTicket[]
|
||||||
|
|
||||||
@@map("whmcs_installations")
|
@@map("whmcs_installations")
|
||||||
}
|
}
|
||||||
@@ -770,3 +780,30 @@ model IntegrationOperation {
|
|||||||
@@index([installationId, createdAt])
|
@@index([installationId, createdAt])
|
||||||
@@map("integration_operations")
|
@@map("integration_operations")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model SsoTicket {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
installationId String
|
||||||
|
tokenHash String @unique
|
||||||
|
kind SsoTicketKind
|
||||||
|
userId String
|
||||||
|
serverId String?
|
||||||
|
externalServiceId String?
|
||||||
|
externalClientId String?
|
||||||
|
externalUserId String?
|
||||||
|
targetPath String
|
||||||
|
issuer String
|
||||||
|
audience String
|
||||||
|
nonce String @unique
|
||||||
|
expiresAt DateTime @db.Timestamptz(3)
|
||||||
|
usedAt DateTime? @db.Timestamptz(3)
|
||||||
|
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||||
|
|
||||||
|
installation WhmcsInstallation @relation(fields: [installationId], references: [id], onDelete: Cascade)
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
server GameServer? @relation(fields: [serverId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@index([expiresAt])
|
||||||
|
@@index([installationId, createdAt])
|
||||||
|
@@map("sso_tickets")
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export type {
|
|||||||
WhmcsClientLink,
|
WhmcsClientLink,
|
||||||
WhmcsServiceLink,
|
WhmcsServiceLink,
|
||||||
IntegrationOperation,
|
IntegrationOperation,
|
||||||
|
SsoTicket,
|
||||||
Plan,
|
Plan,
|
||||||
FeatureFlag,
|
FeatureFlag,
|
||||||
SystemSetting,
|
SystemSetting,
|
||||||
@@ -35,7 +36,7 @@ export type {
|
|||||||
JobRecord,
|
JobRecord,
|
||||||
} from '@prisma/client';
|
} from '@prisma/client';
|
||||||
|
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient, Prisma } from '@prisma/client';
|
||||||
|
|
||||||
const globalForPrisma = globalThis as unknown as {
|
const globalForPrisma = globalThis as unknown as {
|
||||||
prisma: PrismaClient | undefined;
|
prisma: PrismaClient | undefined;
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user