Phase1
This commit is contained in:
@@ -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:*",
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
31
apps/api/src/auth/audit.service.ts
Normal file
31
apps/api/src/auth/audit.service.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
203
apps/api/src/auth/auth.controller.ts
Normal file
203
apps/api/src/auth/auth.controller.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
52
apps/api/src/auth/auth.module.ts
Normal file
52
apps/api/src/auth/auth.module.ts
Normal 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 {}
|
||||
711
apps/api/src/auth/auth.service.ts
Normal file
711
apps/api/src/auth/auth.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
37
apps/api/src/auth/decorators/current-user.decorator.ts
Normal file
37
apps/api/src/auth/decorators/current-user.decorator.ts
Normal 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;
|
||||
},
|
||||
);
|
||||
10
apps/api/src/auth/dto/index.ts
Normal file
10
apps/api/src/auth/dto/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export {
|
||||
registerRequestSchema,
|
||||
loginRequestSchema,
|
||||
verifyEmailRequestSchema,
|
||||
forgotPasswordRequestSchema,
|
||||
resetPasswordRequestSchema,
|
||||
twoFactorConfirmRequestSchema,
|
||||
twoFactorVerifyRequestSchema,
|
||||
twoFactorDisableRequestSchema,
|
||||
} from '@hexahost/contracts';
|
||||
48
apps/api/src/auth/guards/session.guard.ts
Normal file
48
apps/api/src/auth/guards/session.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
55
apps/api/src/auth/me.controller.ts
Normal file
55
apps/api/src/auth/me.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
195
apps/api/src/auth/session.service.ts
Normal file
195
apps/api/src/auth/session.service.ts
Normal 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],
|
||||
};
|
||||
}
|
||||
}
|
||||
22
apps/api/src/common/pipes/zod-validation.pipe.ts
Normal file
22
apps/api/src/common/pipes/zod-validation.pipe.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
16
apps/api/src/config/app-config.module.ts
Normal file
16
apps/api/src/config/app-config.module.ts
Normal 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 {}
|
||||
5
apps/api/src/config/config.constants.ts
Normal file
5
apps/api/src/config/config.constants.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { AppConfig } from '@hexahost/config';
|
||||
|
||||
export const APP_CONFIG = Symbol('APP_CONFIG');
|
||||
|
||||
export type { AppConfig };
|
||||
@@ -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')
|
||||
|
||||
76
apps/api/test/auth.test.js
Normal file
76
apps/api/test/auth.test.js
Normal 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
Reference in New Issue
Block a user