Phase1
Some checks failed
CI / Node — lint, typecheck, test, build (push) Failing after 9s
CI / Go — node-agent tests (push) Failing after 21s
CI / Go — edge-gateway build (push) Successful in 13s

This commit is contained in:
smueller
2026-06-26 11:31:54 +02:00
parent 4fe25e6ec3
commit 58961000eb
123 changed files with 4231 additions and 136 deletions

View File

@@ -12,7 +12,9 @@
"test": "node --test test/**/*.test.js"
},
"dependencies": {
"@fastify/cookie": "^11.0.2",
"@fastify/static": "^8.0.4",
"@hexahost/auth": "workspace:*",
"@hexahost/config": "workspace:*",
"@hexahost/contracts": "workspace:*",
"@hexahost/database": "workspace:*",
@@ -21,12 +23,15 @@
"@nestjs/platform-fastify": "^11.0.7",
"@nestjs/swagger": "^11.0.3",
"@nestjs/throttler": "^6.4.0",
"bullmq": "^5.34.8",
"fastify": "^5.2.1",
"ioredis": "^5.4.2",
"nestjs-pino": "^4.3.0",
"pino": "^9.6.0",
"pino-http": "^10.4.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
"rxjs": "^7.8.1",
"zod": "^3.24.1"
},
"devDependencies": {
"@hexahost/typescript-config": "workspace:*",

View File

@@ -3,6 +3,8 @@ import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { RequestIdMiddleware } from './common/middleware/request-id.middleware';
import { HealthModule } from './health/health.module';
import { PrismaModule } from './prisma/prisma.module';
import { AuthModule } from './auth/auth.module';
import { AppConfigModule } from './config/app-config.module';
import { APP_GUARD } from '@nestjs/core';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
@@ -32,7 +34,9 @@ import { LoggerModule } from 'nestjs-pino';
},
]),
PrismaModule,
AppConfigModule,
HealthModule,
AuthModule,
],
providers: [
{

View File

@@ -0,0 +1,31 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@hexahost/database';
import { PrismaService } from '../prisma/prisma.service';
export interface AuditEventInput {
action: string;
userId?: string;
entityType?: string;
entityId?: string;
metadata?: Record<string, unknown>;
ipAddress?: string;
}
@Injectable()
export class AuditService {
constructor(private readonly prisma: PrismaService) {}
async record(input: AuditEventInput): Promise<void> {
await this.prisma.auditEvent.create({
data: {
action: input.action,
userId: input.userId,
entityType: input.entityType,
entityId: input.entityId,
metadata: input.metadata as Prisma.InputJsonValue | undefined,
ipAddress: input.ipAddress,
},
});
}
}

View File

@@ -0,0 +1,203 @@
import {
BadRequestException,
Body,
Controller,
HttpCode,
HttpStatus,
Post,
Query,
Req,
Res,
UseGuards,
} from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { SkipThrottle, Throttle } from '@nestjs/throttler';
import type { FastifyReply, FastifyRequest } from 'fastify';
import {
forgotPasswordRequestSchema,
loginRequestSchema,
registerRequestSchema,
resetPasswordRequestSchema,
twoFactorConfirmRequestSchema,
twoFactorDisableRequestSchema,
twoFactorVerifyRequestSchema,
verifyEmailRequestSchema,
} from '@hexahost/contracts';
import { ZodValidationPipe } from '../common/pipes/zod-validation.pipe';
import { AuthService } from './auth.service';
import { CurrentUser, Public } from './decorators/current-user.decorator';
import { SessionGuard } from './guards/session.guard';
import { SessionService } from './session.service';
@ApiTags('auth')
@Controller('auth')
@SkipThrottle()
@Throttle({ default: { limit: 5, ttl: 60_000 } })
export class AuthController {
constructor(
private readonly authService: AuthService,
private readonly sessionService: SessionService,
) {}
@Public()
@Post('register')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Register a new account' })
@ApiResponse({ status: 201, description: 'Account created' })
register(
@Body(new ZodValidationPipe(registerRequestSchema)) body: unknown,
@Req() request: FastifyRequest,
) {
return this.authService.register(
body as Parameters<AuthService['register']>[0],
request.ip,
);
}
@Public()
@Post('login')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Login with email and password' })
login(
@Body(new ZodValidationPipe(loginRequestSchema)) body: unknown,
@Req() request: FastifyRequest,
@Res({ passthrough: true }) reply: FastifyReply,
) {
return this.authService.login(
body as Parameters<AuthService['login']>[0],
reply,
request.ip,
request.headers['user-agent'],
);
}
@Public()
@Post('logout')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Logout current session' })
logout(
@Req() request: FastifyRequest,
@Res({ passthrough: true }) reply: FastifyReply,
) {
const token = this.sessionService.readSessionToken(request);
return this.authService.logout(token, reply, request.ip);
}
@Public()
@Post('verify-email')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Verify email address' })
verifyEmail(
@Body(new ZodValidationPipe(verifyEmailRequestSchema)) body: unknown,
@Query('token') queryToken: string | undefined,
@Req() request: FastifyRequest,
) {
const payload = body as { token?: string };
const token = payload.token ?? queryToken;
if (!token) {
throw new BadRequestException('Verification token is required');
}
return this.authService.verifyEmail({ token }, request.ip);
}
@UseGuards(SessionGuard)
@Post('resend-verification')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Resend email verification' })
resendVerification(
@CurrentUser() user: { id: string },
@Req() request: FastifyRequest,
) {
return this.authService.resendVerification(user.id, request.ip);
}
@Public()
@Post('forgot-password')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Request password reset email' })
forgotPassword(
@Body(new ZodValidationPipe(forgotPasswordRequestSchema)) body: unknown,
@Req() request: FastifyRequest,
) {
return this.authService.forgotPassword(
body as Parameters<AuthService['forgotPassword']>[0],
request.ip,
);
}
@Public()
@Post('reset-password')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Reset password with token' })
resetPassword(
@Body(new ZodValidationPipe(resetPasswordRequestSchema)) body: unknown,
@Req() request: FastifyRequest,
) {
return this.authService.resetPassword(
body as Parameters<AuthService['resetPassword']>[0],
request.ip,
);
}
@UseGuards(SessionGuard)
@Post('2fa/setup')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Begin two-factor setup' })
setupTwoFactor(@CurrentUser() user: { id: string }) {
return this.authService.setupTwoFactor(user.id);
}
@UseGuards(SessionGuard)
@Post('2fa/confirm')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Confirm two-factor setup' })
confirmTwoFactor(
@CurrentUser() user: { id: string },
@Body(new ZodValidationPipe(twoFactorConfirmRequestSchema)) body: unknown,
@Req() request: FastifyRequest,
) {
return this.authService.confirmTwoFactor(
user.id,
body as Parameters<AuthService['confirmTwoFactor']>[1],
request.ip,
);
}
@Public()
@Post('2fa/verify')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Complete login with two-factor code' })
verifyTwoFactor(
@Body(new ZodValidationPipe(twoFactorVerifyRequestSchema)) body: unknown,
@Req() request: FastifyRequest,
@Res({ passthrough: true }) reply: FastifyReply,
) {
return this.authService.verifyTwoFactorLogin(
body as Parameters<AuthService['verifyTwoFactorLogin']>[0],
reply,
request.ip,
request.headers['user-agent'],
);
}
@UseGuards(SessionGuard)
@Post('2fa/disable')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Disable two-factor authentication' })
disableTwoFactor(
@CurrentUser() user: { id: string },
@Body(new ZodValidationPipe(twoFactorDisableRequestSchema)) body: unknown,
@Req() request: FastifyRequest,
) {
return this.authService.disableTwoFactor(
user.id,
body as Parameters<AuthService['disableTwoFactor']>[1],
request.ip,
);
}
}

View File

@@ -0,0 +1,52 @@
import { Module } from '@nestjs/common';
import { Queue } from 'bullmq';
import Redis from 'ioredis';
import { getConfig } from '@hexahost/config';
import { AppConfigModule } from '../config/app-config.module';
import { AuditService } from './audit.service';
import { AuthController } from './auth.controller';
import {
AuthService,
NOTIFICATIONS_QUEUE,
REDIS_CLIENT,
} from './auth.service';
import { SessionGuard } from './guards/session.guard';
import { MeController } from './me.controller';
import { SessionService } from './session.service';
@Module({
imports: [AppConfigModule],
controllers: [AuthController, MeController],
providers: [
AuthService,
SessionService,
AuditService,
SessionGuard,
{
provide: REDIS_CLIENT,
useFactory: () => {
const config = getConfig();
return new Redis(config.REDIS_URL, {
maxRetriesPerRequest: null,
});
},
},
{
provide: NOTIFICATIONS_QUEUE,
useFactory: () => {
const config = getConfig();
return new Queue('notifications', {
connection: {
url: config.REDIS_URL,
maxRetriesPerRequest: null,
},
});
},
},
],
exports: [AuthService, SessionService, SessionGuard],
})
export class AuthModule {}

View File

@@ -0,0 +1,711 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Inject,
Injectable,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { Queue } from 'bullmq';
import type { FastifyReply } from 'fastify';
import type Redis from 'ioredis';
import {
buildOtpAuthUrl,
generateRecoveryCodes,
generateSecureToken,
generateTotpSecret,
generateUsernameFromEmail,
hashPassword,
hashRecoveryCode,
hashToken,
validateUsername,
verifyPassword,
verifyRecoveryCode,
verifyTotpCode,
} from '@hexahost/auth';
import type {
AuthUser,
ForgotPasswordRequest,
LoginRequest,
MessageResponse,
RegisterRequest,
ResetPasswordRequest,
TwoFactorConfirmRequest,
TwoFactorDisableRequest,
TwoFactorVerifyRequest,
VerifyEmailRequest,
} 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 { SessionService } from './session.service';
const LOCKOUT_ATTEMPTS = 5;
const LOCKOUT_DURATION_MS = 15 * 60 * 1000;
const LOGIN_CHALLENGE_TTL_SECONDS = 300;
const EMAIL_VERIFICATION_TTL_MS = 24 * 60 * 60 * 1000;
const PASSWORD_RESET_TTL_MS = 60 * 60 * 1000;
export const REDIS_CLIENT = Symbol('REDIS_CLIENT');
export const NOTIFICATIONS_QUEUE = Symbol('NOTIFICATIONS_QUEUE');
interface LoginChallengePayload {
userId: string;
ipAddress?: string;
userAgent?: string;
}
interface NotificationEmailPayload {
type: string;
to: string;
subject: string;
template: string;
data: Record<string, unknown>;
}
@Injectable()
export class AuthService {
constructor(
private readonly prisma: PrismaService,
private readonly sessionService: SessionService,
private readonly auditService: AuditService,
@Inject(APP_CONFIG) private readonly config: AppConfig,
@Inject(REDIS_CLIENT) private readonly redis: Redis,
@Inject(NOTIFICATIONS_QUEUE) private readonly notificationsQueue: Queue,
) {}
async register(
input: RegisterRequest,
ipAddress?: string,
): Promise<MessageResponse> {
const email = input.email.trim().toLowerCase();
const existing = await this.prisma.user.findUnique({ where: { email } });
if (existing) {
throw new ConflictException('Email is already registered');
}
let username = input.username?.trim().toLowerCase();
if (username) {
const validation = validateUsername(username);
if (!validation.valid) {
throw new BadRequestException(validation.error);
}
} else {
username = generateUsernameFromEmail(email);
}
username = await this.ensureUniqueUsername(username);
const passwordHash = await hashPassword(input.password);
const user = await this.prisma.$transaction(async (tx) => {
const created = await tx.user.create({
data: {
email,
username,
displayName: input.displayName?.trim() ?? null,
},
});
await tx.userCredential.create({
data: {
userId: created.id,
passwordHash,
},
});
return created;
});
await this.createEmailVerificationToken(user.id, user.email);
await this.auditService.record({
action: 'auth.register',
userId: user.id,
entityType: 'user',
entityId: user.id,
ipAddress,
});
return { message: 'Registration successful. Please verify your email.' };
}
async login(
input: LoginRequest,
reply: FastifyReply,
ipAddress?: string,
userAgent?: string,
) {
const email = input.email.trim().toLowerCase();
const user = await this.prisma.user.findUnique({
where: { email },
include: {
credentials: { take: 1, orderBy: { createdAt: 'desc' } },
totpCredential: true,
},
});
if (!user || user.credentials.length === 0) {
await this.auditService.record({
action: 'auth.login_failed',
metadata: { email, reason: 'unknown_user' },
ipAddress,
});
throw new UnauthorizedException('Invalid email or password');
}
if (user.status !== 'ACTIVE') {
throw new ForbiddenException('Account is not active');
}
if (user.lockedUntil && user.lockedUntil > new Date()) {
throw new ForbiddenException('Account is temporarily locked');
}
const credential = user.credentials[0]!;
const passwordValid = await verifyPassword(input.password, credential.passwordHash);
if (!passwordValid) {
const failedAttempts = user.failedLoginAttempts + 1;
const lockedUntil =
failedAttempts >= LOCKOUT_ATTEMPTS
? new Date(Date.now() + LOCKOUT_DURATION_MS)
: null;
await this.prisma.user.update({
where: { id: user.id },
data: {
failedLoginAttempts: failedAttempts,
lockedUntil,
},
});
await this.auditService.record({
action: 'auth.login_failed',
userId: user.id,
metadata: {
reason: 'invalid_password',
failedAttempts,
locked: lockedUntil !== null,
},
ipAddress,
});
if (lockedUntil) {
throw new ForbiddenException(
'Account locked due to too many failed login attempts',
);
}
throw new UnauthorizedException('Invalid email or password');
}
await this.prisma.user.update({
where: { id: user.id },
data: {
failedLoginAttempts: 0,
lockedUntil: null,
},
});
if (user.totpCredential?.enabledAt) {
const challengeId = this.sessionService.createLoginChallengeId();
const payload: LoginChallengePayload = { userId: user.id, ipAddress, userAgent };
await this.redis.set(
this.challengeKey(challengeId),
JSON.stringify(payload),
'EX',
LOGIN_CHALLENGE_TTL_SECONDS,
);
return {
twoFactorRequired: true as const,
challengeId,
};
}
return this.completeLogin(user.id, reply, ipAddress, userAgent);
}
async verifyTwoFactorLogin(
input: TwoFactorVerifyRequest,
reply: FastifyReply,
ipAddress?: string,
userAgent?: string,
) {
const raw = await this.redis.get(this.challengeKey(input.challengeId));
if (!raw) {
throw new UnauthorizedException('Invalid or expired login challenge');
}
const challenge = JSON.parse(raw) as LoginChallengePayload;
const user = await this.prisma.user.findUnique({
where: { id: challenge.userId },
include: { totpCredential: { include: { recoveryCodes: true } } },
});
if (!user?.totpCredential?.enabledAt) {
throw new UnauthorizedException('Two-factor authentication is not enabled');
}
const code = input.code.replace(/\s+/gu, '');
let verified = verifyTotpCode(user.totpCredential.secret, code);
if (!verified) {
verified = await this.tryRecoveryCode(user.totpCredential.id, code);
}
if (!verified) {
throw new UnauthorizedException('Invalid two-factor code');
}
await this.redis.del(this.challengeKey(input.challengeId));
await this.auditService.record({
action: 'auth.2fa_verified',
userId: user.id,
ipAddress,
});
return this.completeLogin(
user.id,
reply,
ipAddress ?? challenge.ipAddress,
userAgent ?? challenge.userAgent,
);
}
async logout(
token: string | undefined,
reply: FastifyReply,
ipAddress?: string,
): Promise<MessageResponse> {
if (token) {
const userId = await this.sessionService.revokeByToken(token);
if (userId) {
await this.auditService.record({
action: 'auth.logout',
userId,
ipAddress,
});
}
}
this.sessionService.clearSessionCookie(reply);
return { message: 'Logged out' };
}
async verifyEmail(
input: VerifyEmailRequest,
ipAddress?: string,
): Promise<MessageResponse> {
const tokenHash = hashToken(input.token);
const record = await this.prisma.emailVerificationToken.findUnique({
where: { tokenHash },
include: { user: true },
});
if (!record || record.usedAt || record.expiresAt <= new Date()) {
throw new BadRequestException('Invalid or expired verification token');
}
await this.prisma.$transaction([
this.prisma.user.update({
where: { id: record.userId },
data: { emailVerifiedAt: new Date() },
}),
this.prisma.emailVerificationToken.update({
where: { id: record.id },
data: { usedAt: new Date() },
}),
]);
await this.auditService.record({
action: 'auth.email_verified',
userId: record.userId,
ipAddress,
});
return { message: 'Email verified successfully' };
}
async resendVerification(userId: string, ipAddress?: string): Promise<MessageResponse> {
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user) {
throw new NotFoundException('User not found');
}
if (user.emailVerifiedAt) {
return { message: 'Email is already verified' };
}
await this.createEmailVerificationToken(user.id, user.email);
await this.auditService.record({
action: 'auth.verification_resent',
userId,
ipAddress,
});
return { message: 'Verification email sent' };
}
async forgotPassword(
input: ForgotPasswordRequest,
ipAddress?: string,
): Promise<MessageResponse> {
const email = input.email.trim().toLowerCase();
const user = await this.prisma.user.findUnique({ where: { email } });
if (user) {
const token = generateSecureToken();
const tokenHash = hashToken(token);
await this.prisma.passwordResetToken.create({
data: {
userId: user.id,
tokenHash,
expiresAt: new Date(Date.now() + PASSWORD_RESET_TTL_MS),
},
});
await this.enqueueEmail({
type: 'password_reset',
to: user.email,
subject: 'Reset your HexaHost password',
template: 'password-reset',
data: {
resetUrl: `${this.config.APP_URL}/reset-password?token=${token}`,
},
});
await this.auditService.record({
action: 'auth.password_reset_requested',
userId: user.id,
ipAddress,
});
}
return {
message:
'If an account exists for this email, a password reset link has been sent.',
};
}
async resetPassword(
input: ResetPasswordRequest,
ipAddress?: string,
): Promise<MessageResponse> {
const tokenHash = hashToken(input.token);
const record = await this.prisma.passwordResetToken.findUnique({
where: { tokenHash },
});
if (!record || record.usedAt || record.expiresAt <= new Date()) {
throw new BadRequestException('Invalid or expired reset token');
}
const passwordHash = await hashPassword(input.newPassword);
await this.prisma.$transaction([
this.prisma.userCredential.updateMany({
where: { userId: record.userId },
data: { passwordHash },
}),
this.prisma.passwordResetToken.update({
where: { id: record.id },
data: { usedAt: new Date() },
}),
this.prisma.userSession.updateMany({
where: { userId: record.userId, revokedAt: null },
data: { revokedAt: new Date() },
}),
this.prisma.user.update({
where: { id: record.userId },
data: { failedLoginAttempts: 0, lockedUntil: null },
}),
]);
await this.auditService.record({
action: 'auth.password_reset_completed',
userId: record.userId,
ipAddress,
});
return { message: 'Password reset successfully' };
}
async setupTwoFactor(userId: string) {
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user) {
throw new NotFoundException('User not found');
}
const secret = generateTotpSecret();
await this.prisma.totpCredential.upsert({
where: { userId },
create: {
userId,
secret,
enabledAt: null,
},
update: {
secret,
enabledAt: null,
},
});
await this.prisma.recoveryCode.deleteMany({
where: {
totpCredential: { userId },
},
});
return {
secret,
qrUrl: buildOtpAuthUrl(this.config.APP_NAME, user.email, secret),
};
}
async confirmTwoFactor(
userId: string,
input: TwoFactorConfirmRequest,
ipAddress?: string,
) {
const credential = await this.prisma.totpCredential.findUnique({
where: { userId },
});
if (!credential) {
throw new BadRequestException('Two-factor setup has not been started');
}
if (!verifyTotpCode(credential.secret, input.code)) {
throw new BadRequestException('Invalid authenticator code');
}
const recoveryCodes = generateRecoveryCodes();
await this.prisma.$transaction(async (tx) => {
await tx.totpCredential.update({
where: { id: credential.id },
data: { enabledAt: new Date() },
});
await tx.recoveryCode.deleteMany({
where: { totpCredentialId: credential.id },
});
await tx.recoveryCode.createMany({
data: recoveryCodes.map((code) => ({
totpCredentialId: credential.id,
codeHash: hashRecoveryCode(code),
})),
});
});
await this.auditService.record({
action: 'auth.2fa_enabled',
userId,
ipAddress,
});
return {
message: 'Two-factor authentication enabled',
recoveryCodes,
};
}
async disableTwoFactor(
userId: string,
input: TwoFactorDisableRequest,
ipAddress?: string,
): Promise<MessageResponse> {
const user = await this.prisma.user.findUnique({
where: { id: userId },
include: {
credentials: { take: 1, orderBy: { createdAt: 'desc' } },
totpCredential: true,
},
});
if (!user?.totpCredential?.enabledAt) {
throw new BadRequestException('Two-factor authentication is not enabled');
}
const credential = user.credentials[0];
if (!credential || !(await verifyPassword(input.password, credential.passwordHash))) {
throw new UnauthorizedException('Invalid password');
}
const code = input.code.replace(/\s+/gu, '');
let verified = verifyTotpCode(user.totpCredential.secret, code);
if (!verified) {
verified = await this.tryRecoveryCode(user.totpCredential.id, code);
}
if (!verified) {
throw new UnauthorizedException('Invalid two-factor code');
}
await this.prisma.totpCredential.delete({
where: { id: user.totpCredential.id },
});
await this.auditService.record({
action: 'auth.2fa_disabled',
userId,
ipAddress,
});
return { message: 'Two-factor authentication disabled' };
}
toAuthUser(user: {
id: string;
email: string;
username: string;
displayName: string | null;
emailVerifiedAt: Date | null;
platformRoles?: Array<{ role: { name: string } }>;
}): AuthUser {
const authenticated = this.sessionService.toAuthenticatedUser({
...user,
platformRoles: user.platformRoles ?? [],
});
return {
id: authenticated.id,
email: authenticated.email,
username: authenticated.username,
displayName: authenticated.displayName,
emailVerified: authenticated.emailVerified,
roles: authenticated.roles,
};
}
private async completeLogin(
userId: string,
reply: FastifyReply,
ipAddress?: string,
userAgent?: string,
) {
const { token, expiresAt } = await this.sessionService.createSession(
userId,
ipAddress,
userAgent,
);
this.sessionService.setSessionCookie(reply, token, expiresAt);
const user = await this.prisma.user.findUnique({
where: { id: userId },
include: {
platformRoles: { include: { role: true } },
},
});
if (!user) {
throw new NotFoundException('User not found');
}
await this.auditService.record({
action: 'auth.login',
userId,
ipAddress,
});
return { user: this.toAuthUser(user) };
}
private async ensureUniqueUsername(baseUsername: string): Promise<string> {
let candidate = baseUsername;
let suffix = 0;
while (true) {
const existing = await this.prisma.user.findUnique({
where: { username: candidate },
select: { id: true },
});
if (!existing) {
return candidate;
}
suffix += 1;
const suffixText = String(suffix);
candidate = `${baseUsername.slice(0, Math.max(1, 32 - suffixText.length))}${suffixText}`;
}
}
private async createEmailVerificationToken(userId: string, email: string): Promise<void> {
const token = generateSecureToken();
const tokenHash = hashToken(token);
await this.prisma.emailVerificationToken.create({
data: {
userId,
tokenHash,
expiresAt: new Date(Date.now() + EMAIL_VERIFICATION_TTL_MS),
},
});
await this.enqueueEmail({
type: 'email_verification',
to: email,
subject: 'Verify your HexaHost email',
template: 'email-verification',
data: {
verifyUrl: `${this.config.APP_URL}/verify-email?token=${token}`,
},
});
}
private async enqueueEmail(payload: NotificationEmailPayload): Promise<void> {
await this.notificationsQueue.add('send-email', payload, {
removeOnComplete: true,
removeOnFail: 100,
});
}
private challengeKey(challengeId: string): string {
return `hgc:login-challenge:${challengeId}`;
}
private async tryRecoveryCode(
totpCredentialId: string,
code: string,
): Promise<boolean> {
const recoveryCodes = await this.prisma.recoveryCode.findMany({
where: {
totpCredentialId,
usedAt: null,
},
});
for (const recoveryCode of recoveryCodes) {
if (verifyRecoveryCode(code, recoveryCode.codeHash)) {
await this.prisma.recoveryCode.update({
where: { id: recoveryCode.id },
data: { usedAt: new Date() },
});
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,37 @@
import {
createParamDecorator,
ExecutionContext,
SetMetadata,
} from '@nestjs/common';
import type { FastifyRequest } from 'fastify';
import type { AuthenticatedUser, SessionContext } from '@hexahost/auth';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
export interface RequestWithSession extends FastifyRequest {
session?: SessionContext;
user?: AuthenticatedUser;
}
export const CurrentUser = createParamDecorator(
(_data: unknown, context: ExecutionContext): AuthenticatedUser => {
const request = context.switchToHttp().getRequest<RequestWithSession>();
if (!request.user) {
throw new Error('CurrentUser decorator requires SessionGuard');
}
return request.user;
},
);
export const CurrentSession = createParamDecorator(
(_data: unknown, context: ExecutionContext): SessionContext => {
const request = context.switchToHttp().getRequest<RequestWithSession>();
if (!request.session) {
throw new Error('CurrentSession decorator requires SessionGuard');
}
return request.session;
},
);

View File

@@ -0,0 +1,10 @@
export {
registerRequestSchema,
loginRequestSchema,
verifyEmailRequestSchema,
forgotPasswordRequestSchema,
resetPasswordRequestSchema,
twoFactorConfirmRequestSchema,
twoFactorVerifyRequestSchema,
twoFactorDisableRequestSchema,
} from '@hexahost/contracts';

View File

@@ -0,0 +1,48 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import type { FastifyRequest } from 'fastify';
import {
IS_PUBLIC_KEY,
type RequestWithSession,
} from '../decorators/current-user.decorator';
import { SessionService } from '../session.service';
@Injectable()
export class SessionGuard implements CanActivate {
constructor(
private readonly sessionService: SessionService,
private readonly reflector: Reflector,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
const request = context.switchToHttp().getRequest<RequestWithSession>();
const token = this.sessionService.readSessionToken(
request as FastifyRequest,
);
if (!token) {
throw new UnauthorizedException('Authentication required');
}
const session = await this.sessionService.validateSessionToken(token);
request.session = session;
request.user = session.user;
return true;
}
}

View File

@@ -0,0 +1,55 @@
import {
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
UseGuards,
} from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import {
CurrentSession,
CurrentUser,
} from './decorators/current-user.decorator';
import { SessionGuard } from './guards/session.guard';
import { SessionService } from './session.service';
@ApiTags('me')
@Controller('me')
@UseGuards(SessionGuard)
export class MeController {
constructor(private readonly sessionService: SessionService) {}
@Get()
@ApiOperation({ summary: 'Get current user profile' })
@ApiResponse({ status: 200, description: 'Current user' })
getMe(@CurrentUser() user: { id: string }) {
return { user };
}
@Get('sessions')
@ApiOperation({ summary: 'List active sessions' })
async listSessions(
@CurrentUser() user: { id: string },
@CurrentSession() session: { sessionId: string },
) {
const sessions = await this.sessionService.listSessions(
user.id,
session.sessionId,
);
return { sessions };
}
@Delete('sessions/:id')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Revoke a session' })
async revokeSession(
@CurrentUser() user: { id: string },
@Param('id', ParseUUIDPipe) sessionId: string,
): Promise<void> {
await this.sessionService.revokeSession(sessionId, user.id);
}
}

View File

@@ -0,0 +1,195 @@
import { randomUUID } from 'node:crypto';
import {
Inject,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import type { FastifyReply, FastifyRequest } from 'fastify';
import {
SESSION_COOKIE_NAME,
UserRole,
createSessionExpiry,
generateSecureToken,
hashToken,
type AuthenticatedUser,
type SessionContext,
} from '@hexahost/auth';
import type { AppConfig } from '@hexahost/config';
import { APP_CONFIG } from '../config/config.constants';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class SessionService {
constructor(
private readonly prisma: PrismaService,
@Inject(APP_CONFIG) private readonly config: AppConfig,
) {}
async createSession(
userId: string,
ipAddress?: string,
userAgent?: string,
): Promise<{ token: string; sessionId: string; expiresAt: Date }> {
const token = generateSecureToken();
const tokenHash = hashToken(token);
const expiresAt = createSessionExpiry();
const session = await this.prisma.userSession.create({
data: {
userId,
tokenHash,
expiresAt,
ipAddress,
userAgent,
},
});
return { token, sessionId: session.id, expiresAt };
}
setSessionCookie(reply: FastifyReply, token: string, expiresAt: Date): void {
const isProduction = this.config.APP_ENV === 'production';
void reply.setCookie(SESSION_COOKIE_NAME, token, {
httpOnly: true,
secure: isProduction,
sameSite: 'lax',
path: '/',
expires: expiresAt,
});
}
clearSessionCookie(reply: FastifyReply): void {
void reply.clearCookie(SESSION_COOKIE_NAME, {
path: '/',
});
}
readSessionToken(request: FastifyRequest): string | undefined {
const cookies = request.cookies as Record<string, string | undefined>;
const token = cookies[SESSION_COOKIE_NAME];
return token && token.length > 0 ? token : undefined;
}
async validateSessionToken(token: string): Promise<SessionContext> {
const tokenHash = hashToken(token);
const now = new Date();
const session = await this.prisma.userSession.findUnique({
where: { tokenHash },
include: {
user: {
include: {
platformRoles: {
include: {
role: true,
},
},
},
},
},
});
if (
!session ||
session.revokedAt ||
session.expiresAt <= now ||
session.user.status !== 'ACTIVE'
) {
throw new UnauthorizedException('Invalid or expired session');
}
return {
sessionId: session.id,
issuedAt: session.createdAt,
expiresAt: session.expiresAt,
user: this.toAuthenticatedUser(session.user),
};
}
async revokeSession(sessionId: string, userId: string): Promise<void> {
const session = await this.prisma.userSession.findFirst({
where: { id: sessionId, userId },
});
if (!session || session.revokedAt) {
return;
}
await this.prisma.userSession.update({
where: { id: sessionId },
data: { revokedAt: new Date() },
});
}
async revokeByToken(token: string): Promise<string | undefined> {
const tokenHash = hashToken(token);
const session = await this.prisma.userSession.findUnique({
where: { tokenHash },
});
if (!session || session.revokedAt) {
return undefined;
}
await this.prisma.userSession.update({
where: { id: session.id },
data: { revokedAt: new Date() },
});
return session.userId;
}
async listSessions(userId: string, currentSessionId: string) {
const sessions = await this.prisma.userSession.findMany({
where: {
userId,
revokedAt: null,
expiresAt: { gt: new Date() },
},
orderBy: { createdAt: 'desc' },
});
return sessions.map((session) => ({
id: session.id,
ipAddress: session.ipAddress,
userAgent: session.userAgent,
createdAt: session.createdAt.toISOString(),
expiresAt: session.expiresAt.toISOString(),
current: session.id === currentSessionId,
}));
}
createLoginChallengeId(): string {
return randomUUID();
}
toAuthenticatedUser(
user: {
id: string;
email: string;
username: string;
displayName: string | null;
emailVerifiedAt: Date | null;
platformRoles: Array<{ role: { name: string } }>;
},
): AuthenticatedUser {
const roles = user.platformRoles
.map((entry) => entry.role.name)
.filter((name): name is UserRole =>
Object.values(UserRole).includes(name as UserRole),
);
return {
id: user.id,
email: user.email,
username: user.username,
displayName: user.displayName,
emailVerified: user.emailVerifiedAt !== null,
roles: roles.length > 0 ? roles : [UserRole.USER],
};
}
}

View File

@@ -0,0 +1,22 @@
import {
BadRequestException,
type PipeTransform,
} from '@nestjs/common';
import type { ZodSchema } from 'zod';
export class ZodValidationPipe<T> implements PipeTransform {
constructor(private readonly schema: ZodSchema<T>) {}
transform(value: unknown): T {
const result = this.schema.safeParse(value);
if (!result.success) {
const messages = result.error.issues.map(
(issue) => `${issue.path.join('.') || 'body'}: ${issue.message}`,
);
throw new BadRequestException(messages);
}
return result.data;
}
}

View File

@@ -0,0 +1,16 @@
import { Global, Module } from '@nestjs/common';
import { getConfig } from '@hexahost/config';
import { APP_CONFIG } from './config.constants';
@Global()
@Module({
providers: [
{
provide: APP_CONFIG,
useFactory: () => getConfig(),
},
],
exports: [APP_CONFIG],
})
export class AppConfigModule {}

View File

@@ -0,0 +1,5 @@
import type { AppConfig } from '@hexahost/config';
export const APP_CONFIG = Symbol('APP_CONFIG');
export type { AppConfig };

View File

@@ -1,5 +1,6 @@
import { randomUUID } from 'node:crypto';
import cookie from '@fastify/cookie';
import { NestFactory } from '@nestjs/core';
import {
FastifyAdapter,
@@ -30,8 +31,16 @@ async function bootstrap(): Promise<void> {
app.useLogger(app.get(Logger));
app.setGlobalPrefix('api/v1');
app.enableCors({
origin: config.APP_URL,
credentials: true,
});
app.useGlobalFilters(new Rfc7807ExceptionFilter());
await app.register(cookie, {
secret: config.SESSION_SECRET,
});
const swaggerConfig = new DocumentBuilder()
.setTitle(config.APP_NAME)
.setDescription('HexaHost GameCloud REST API')

View File

@@ -0,0 +1,76 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
describe('api auth contract', () => {
it('returns ok for liveness-style health response', async () => {
const server = http.createServer((req, res) => {
if (req.url === '/api/v1/health/live') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(
JSON.stringify({
status: 'ok',
service: 'api',
timestamp: new Date().toISOString(),
}),
);
return;
}
res.writeHead(404);
res.end();
});
await new Promise((resolve) => server.listen(0, resolve));
const addr = server.address();
assert.ok(addr && typeof addr === 'object');
const port = addr.port;
const response = await fetch(`http://127.0.0.1:${port}/api/v1/health/live`);
assert.equal(response.status, 200);
const body = await response.json();
assert.equal(body.status, 'ok');
assert.equal(body.service, 'api');
await new Promise((resolve, reject) =>
server.close((err) => (err ? reject(err) : resolve())),
);
});
it('documents auth module routes in source', () => {
const authControllerPath = join(
process.cwd(),
'src',
'auth',
'auth.controller.ts',
);
const meControllerPath = join(process.cwd(), 'src', 'auth', 'me.controller.ts');
const authSource = readFileSync(authControllerPath, 'utf8');
const meSource = readFileSync(meControllerPath, 'utf8');
const combined = `${authSource}\n${meSource}`;
const expectedSnippets = [
"@Post('register')",
"@Post('login')",
"@Post('logout')",
"@Post('verify-email')",
"@Post('resend-verification')",
"@Post('forgot-password')",
"@Post('reset-password')",
"@Post('2fa/setup')",
"@Post('2fa/confirm')",
"@Post('2fa/verify')",
"@Post('2fa/disable')",
"@Get()",
"@Get('sessions')",
"@Delete('sessions/:id')",
"@Controller('auth')",
"@Controller('me')",
];
for (const snippet of expectedSnippets) {
assert.ok(combined.includes(snippet), `missing ${snippet}`);
}
});
});

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long