Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,6 @@ jobs:

- name: Build
run: pnpm build

- name: Test
run: pnpm test
2 changes: 2 additions & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,13 @@
"@nestjs/core": "^10.4.15",
"@nestjs/jwt": "^11.0.2",
"@nestjs/platform-express": "^10.4.15",
"@nestjs/throttler": "^6.4.0",
"@prisma/client": "^5.22.0",
"@sentry/node": "^10.53.1",
"@statify/db": "workspace:*",
"@statify/shared": "workspace:*",
"argon2": "^0.44.0",
"helmet": "^8.0.0",
"nestjs-pino": "^4.1.0",
"pino": "^9.5.0",
"pino-http": "^10.3.0",
Expand Down
5 changes: 5 additions & 0 deletions apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
import { LoggerModule } from './common/logger/logger.module';
import { RequestIdMiddleware } from './common/logger/request-id.middleware';
import { ConfigModule } from './config/config.module';
Expand All @@ -17,6 +19,8 @@ import { UserPlaylistsModule } from './modules/user-playlists/user-playlists.mod
imports: [
ConfigModule,
LoggerModule,
// Per-IP rate limiting (global default; stricter limits on auth routes).
ThrottlerModule.forRoot([{ ttl: 60_000, limit: 100 }]),
PrismaModule,
ItunesModule,
AuthModule,
Expand All @@ -28,6 +32,7 @@ import { UserPlaylistsModule } from './modules/user-playlists/user-playlists.mod
AnalyticsModule,
AdminModule,
],
providers: [{ provide: APP_GUARD, useClass: ThrottlerGuard }],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer): void {
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import 'reflect-metadata';
import { HEADERS } from '@statify/shared';
import { NestFactory } from '@nestjs/core';
import * as Sentry from '@sentry/node';
import helmet from 'helmet';
import { Logger } from 'nestjs-pino';
import { AppModule } from './app.module';
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';
Expand All @@ -21,6 +22,7 @@ async function bootstrap(): Promise<void> {

const app = await NestFactory.create(AppModule, { bufferLogs: true });

app.use(helmet());
app.useLogger(app.get(Logger));
app.useGlobalFilters(new AllExceptionsFilter());

Expand Down
4 changes: 4 additions & 0 deletions apps/api/src/modules/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
Res,
UseGuards,
} from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import {
AccountDeleteRequest,
AccountDeleteRequestSchema,
Expand Down Expand Up @@ -42,6 +43,7 @@ export class AuthController {
private readonly cookieService: AuthCookieService,
) {}

@Throttle({ default: { limit: 5, ttl: 60_000 } })
@Post('register')
async register(
@Body(new ZodValidationPipe(RegisterRequestSchema)) body: RegisterRequest,
Expand All @@ -55,6 +57,7 @@ export class AuthController {
return { user: session.user };
}

@Throttle({ default: { limit: 10, ttl: 60_000 } })
@Post('login')
async login(
@Body(new ZodValidationPipe(LoginRequestSchema)) body: LoginRequest,
Expand All @@ -68,6 +71,7 @@ export class AuthController {
return { user: session.user };
}

@Throttle({ default: { limit: 30, ttl: 60_000 } })
@Post('refresh')
@UseGuards(CsrfGuard)
async refresh(
Expand Down
41 changes: 32 additions & 9 deletions apps/api/src/modules/auth/auth.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { HttpStatus, Injectable } from '@nestjs/common';
import { Prisma, type User } from '@prisma/client';
import {
AppError,
ErrorCode,
Expand Down Expand Up @@ -33,11 +34,21 @@ export class AuthService {
});
}

const user = await this.repository.createUser({
email: input.email,
displayName: input.displayName,
passwordHash: await this.passwordService.hash(input.password),
});
// findUserByEmail excludes soft-deleted/banned rows, so a soft-deleted
// account can still own this email; catch the unique violation as a 409.
let user: User;
try {
user = await this.repository.createUser({
email: input.email,
displayName: input.displayName,
passwordHash: await this.passwordService.hash(input.password),
});
} catch (error) {
if (isUniqueEmailViolation(error)) {
throw emailTakenError();
}
throw error;
}

await this.auditLog.record({
actorUserId: user.id,
Expand Down Expand Up @@ -201,10 +212,18 @@ export class AuthService {
}
}

const updated = await this.repository.updateProfile(userId, {
displayName: input.displayName,
email: input.email,
});
let updated: User;
try {
updated = await this.repository.updateProfile(userId, {
displayName: input.displayName,
email: input.email,
});
} catch (error) {
if (isUniqueEmailViolation(error)) {
throw emailTakenError();
}
throw error;
}

await this.auditLog.record({
actorUserId: userId,
Expand Down Expand Up @@ -320,6 +339,10 @@ function isUsableRefreshToken(token: RefreshTokenWithUser | null): token is Refr
return token !== null && token.revokedAt === null && token.expiresAt > new Date();
}

function isUniqueEmailViolation(error: unknown): boolean {
return error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002';
}

function clientMetadata(context: AuthRequestContext): Record<string, string> {
const meta: Record<string, string> = {};
if (context.ipAddr !== undefined && context.ipAddr.length > 0) meta.ip = context.ipAddr;
Expand Down
41 changes: 34 additions & 7 deletions apps/api/src/modules/auth/guards/jwt-auth.guard.spec.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,37 @@
import { ErrorCode } from '@statify/shared';
import type { ExecutionContext } from '@nestjs/common';
import type { User } from '@prisma/client';
import type { Request } from 'express';
import { describe, expect, it, vi } from 'vitest';
import type { AuthRepository } from '../auth.repository';
import type { AuthTokenService } from '../auth-token.service';
import { JwtAuthGuard } from './jwt-auth.guard';

describe('JwtAuthGuard', () => {
it('attaches the authenticated user from a valid access token', async () => {
it('attaches the authenticated user from the current account record', async () => {
const tokenService = {
verifyAccessToken: vi.fn().mockResolvedValue({
sub: 1,
email: 'stale@example.com',
displayName: 'Stale Name',
role: 'user',
}),
} as unknown as AuthTokenService;
const repository = {
findUserById: vi.fn().mockResolvedValue({
id: 1,
email: 'user@example.com',
displayName: 'Admin User',
role: 'admin',
}),
} as unknown as AuthTokenService;
const guard = new JwtAuthGuard(tokenService);
} as User),
} as unknown as AuthRepository;
const guard = new JwtAuthGuard(tokenService, repository);
const request = {
headers: { cookie: 'sf_access=access-token' },
} as Request & { user?: unknown };

await expect(guard.canActivate(createContext(request))).resolves.toBe(true);
// Identity comes from the DB row, not the (possibly stale) token payload.
expect(request.user).toEqual({
id: 1,
email: 'user@example.com',
Expand All @@ -29,10 +40,26 @@ describe('JwtAuthGuard', () => {
});
});

it('rejects a token whose account no longer exists (banned or deleted)', async () => {
const tokenService = {
verifyAccessToken: vi.fn().mockResolvedValue({ sub: 1, role: 'user' }),
} as unknown as AuthTokenService;
const repository = {
findUserById: vi.fn().mockResolvedValue(null),
} as unknown as AuthRepository;
const guard = new JwtAuthGuard(tokenService, repository);
const request = { headers: { cookie: 'sf_access=access-token' } } as Request;

await expect(guard.canActivate(createContext(request))).rejects.toMatchObject({
code: ErrorCode.UNAUTHENTICATED,
});
});

it('rejects requests without an access token', async () => {
const guard = new JwtAuthGuard({
verifyAccessToken: vi.fn(),
} as unknown as AuthTokenService);
const guard = new JwtAuthGuard(
{ verifyAccessToken: vi.fn() } as unknown as AuthTokenService,
{ findUserById: vi.fn() } as unknown as AuthRepository,
);

await expect(
guard.canActivate(createContext({ headers: {} } as Request)),
Expand Down
30 changes: 20 additions & 10 deletions apps/api/src/modules/auth/guards/jwt-auth.guard.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
import { CanActivate, ExecutionContext, HttpStatus, Injectable } from '@nestjs/common';
import { AppError, COOKIE_NAMES, ErrorCode } from '@statify/shared';
import type { Request } from 'express';
import { toAuthUser } from '../auth.mapper';
import { AuthRepository } from '../auth.repository';
import { AuthTokenService } from '../auth-token.service';
import type { RequestWithUser } from '../auth.types';
import { getCookie } from '../cookie.utils';

@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(private readonly tokenService: AuthTokenService) {}
constructor(
private readonly tokenService: AuthTokenService,
private readonly repository: AuthRepository,
) {}

async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request & RequestWithUser>();
Expand All @@ -17,20 +22,25 @@ export class JwtAuthGuard implements CanActivate {
throw unauthenticatedError();
}

let userId: number;
try {
const payload = await this.tokenService.verifyAccessToken(accessToken);

request.user = {
id: payload.sub,
email: payload.email,
displayName: payload.displayName,
role: payload.role,
};

return true;
userId = payload.sub;
} catch {
throw unauthenticatedError();
}

// Re-check the account on every request so a banned, soft-deleted, or
// role-changed user cannot keep acting on a still-valid access token
// (findUserById filters out deletedAt/bannedAt rows).
const user = await this.repository.findUserById(userId);
if (user === null) {
throw unauthenticatedError();
}

request.user = toAuthUser(user);

return true;
}
}

Expand Down
25 changes: 25 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading