diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index c031860..a722689 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -163,22 +163,23 @@ enum MangaStatus { } model User { - id String @id @default(auto()) @map("_id") @db.ObjectId - discordId String @unique - username String - email String @unique - avatar String? - isAdmin Boolean @default(false) - isBanned Boolean @default(false) - inDiscord Boolean @default(false) - isVip Boolean @default(false) - isMod Boolean @default(false) - isStaff Boolean @default(false) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - comments Comment[] - suggestions Suggestion[] - likes Like[] + id String @id @default(auto()) @map("_id") @db.ObjectId + discordId String @unique + username String + email String @unique + avatar String? + isAdmin Boolean @default(false) + isBanned Boolean @default(false) + inDiscord Boolean @default(false) + isVip Boolean @default(false) + isMod Boolean @default(false) + isStaff Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + comments Comment[] + suggestions Suggestion[] + likes Like[] + refreshTokens RefreshToken[] } model Comment { @@ -289,3 +290,15 @@ model Like { @@unique([userId, entityType, entityId]) } + +model RefreshToken { + id String @id @default(auto()) @map("_id") @db.ObjectId + token String @unique + userId String @db.ObjectId + user User @relation(fields: [userId], references: [id]) + expiresAt DateTime + createdAt DateTime @default(now()) + + @@index([userId]) + @@index([expiresAt]) +} diff --git a/api/src/app/routes/auth/index.ts b/api/src/app/routes/auth/index.ts index ed11000..d666cf8 100644 --- a/api/src/app/routes/auth/index.ts +++ b/api/src/app/routes/auth/index.ts @@ -79,8 +79,9 @@ const authRoutes: FastifyPluginAsync = async (app) => { // Create or update user in database const user = await authService.createOrUpdateUserFromDiscord(userData, inDiscord, isVip, isMod, isStaff); - // Generate JWT - const jwt = await authService.generateToken(user); + // Generate access token and refresh token + const accessToken = await authService.generateToken(user); + const refreshToken = await authService.generateRefreshToken(user.id); // Log successful login await AuditService.log({ @@ -91,13 +92,21 @@ const authRoutes: FastifyPluginAsync = async (app) => { success: true, }, request); - // Set signed cookie and redirect to frontend + // Set signed cookies and redirect to frontend reply - .setCookie("auth-token", jwt, { + .setCookie("auth-token", accessToken, { path: "/", httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax", + maxAge: 15 * 60, // 15 minutes + signed: true, + }) + .setCookie("refresh-token", refreshToken, { + path: "/api/auth", + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", maxAge: 7 * 24 * 60 * 60, // 7 days signed: true, }) @@ -143,11 +152,78 @@ const authRoutes: FastifyPluginAsync = async (app) => { } ); + /** + * Refresh access token using refresh token. + */ + app.post("/refresh", async (request, reply) => { + const signedRefreshToken = request.cookies["refresh-token"]; + if (!signedRefreshToken) { + return reply.code(401).send({ error: "No refresh token provided" }); + } + + const unsignedResult = request.unsignCookie(signedRefreshToken); + if (!unsignedResult.valid || !unsignedResult.value) { + return reply.code(401).send({ error: "Invalid refresh token" }); + } + + const refreshToken = unsignedResult.value; + const user = await authService.validateRefreshToken(refreshToken); + + if (!user) { + reply.clearCookie("refresh-token", { path: "/api/auth", signed: true }); + return reply.code(401).send({ error: "Refresh token expired or invalid" }); + } + + if (user.isBanned) { + await authService.revokeAllUserTokens(user.id); + reply + .clearCookie("auth-token", { path: "/", signed: true }) + .clearCookie("refresh-token", { path: "/api/auth", signed: true }); + return reply.code(403).send({ error: "Account is banned" }); + } + + // Rotate refresh token for security + const newRefreshToken = await authService.rotateRefreshToken(refreshToken, user.id); + if (!newRefreshToken) { + return reply.code(401).send({ error: "Failed to rotate refresh token" }); + } + + const newAccessToken = await authService.generateToken(user); + + reply + .setCookie("auth-token", newAccessToken, { + path: "/", + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + maxAge: 15 * 60, // 15 minutes + signed: true, + }) + .setCookie("refresh-token", newRefreshToken, { + path: "/api/auth", + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + maxAge: 7 * 24 * 60 * 60, // 7 days + signed: true, + }) + .send({ user, accessToken: newAccessToken }); + }); + /** * Logout. */ app.post("/logout", async (request, reply) => { - // Try to get user ID from JWT if available + // Revoke refresh token if present + const signedRefreshToken = request.cookies["refresh-token"]; + if (signedRefreshToken) { + const unsignedResult = request.unsignCookie(signedRefreshToken); + if (unsignedResult.valid && unsignedResult.value) { + await authService.revokeRefreshToken(unsignedResult.value); + } + } + + // Try to get user ID from JWT for audit logging try { await request.jwtVerify(); const user = request.user as { id?: string; username?: string }; @@ -169,6 +245,10 @@ const authRoutes: FastifyPluginAsync = async (app) => { path: "/", signed: true, }) + .clearCookie("refresh-token", { + path: "/api/auth", + signed: true, + }) .send({ message: "Logged out successfully" }); }); diff --git a/api/src/app/routes/likes/index.ts b/api/src/app/routes/likes/index.ts index 2cd5c2a..b65bcfc 100644 --- a/api/src/app/routes/likes/index.ts +++ b/api/src/app/routes/likes/index.ts @@ -136,6 +136,11 @@ const likesRoutes: FastifyPluginAsync = async (app) => { return { error: "Items array is required" }; } + if (items.length > 100) { + reply.code(400); + return { error: "Maximum 100 items allowed per request" }; + } + const validTypes = ['book', 'game', 'show', 'manga', 'music', 'art']; const results = await Promise.all( items.map(async (item) => { diff --git a/api/src/app/routes/suggestions/index.ts b/api/src/app/routes/suggestions/index.ts index 9984b58..35d04cc 100644 --- a/api/src/app/routes/suggestions/index.ts +++ b/api/src/app/routes/suggestions/index.ts @@ -7,6 +7,7 @@ import type { SuggestionEntity, CreateSuggestionDto, DeclineSuggestionDto, + AcceptWithEditsDto, } from "@library/shared-types"; import { adminGuard } from "../../middleware/admin-guard"; import { bannedGuard } from "../../middleware/banned-guard"; @@ -132,7 +133,7 @@ export default async function (app: FastifyInstance): Promise { ); // Accept a suggestion with edits (admin only) - app.put<{ Params: { id: string }; Body: any }>( + app.put<{ Params: { id: string }; Body: AcceptWithEditsDto }>( "/:id/accept-with-edits", { preHandler: [app.authenticate, adminGuard, app.csrfProtection], @@ -192,4 +193,36 @@ export default async function (app: FastifyInstance): Promise { } } ); + + // Delete a suggestion (owner or admin only, only if unreviewed) + app.delete<{ Params: { id: string } }>( + "/:id", + { + preHandler: [app.authenticate, app.csrfProtection], + }, + async (request, reply) => { + const { id } = request.params; + const userId = request.user.id; + const isAdmin = request.user.isAdmin; + + try { + const suggestion = await SuggestionService.deleteSuggestion(id, userId, isAdmin); + + await AuditService.logFromRequest(request, { + action: AuditAction.ENTRY_DELETE, + category: isAdmin ? AuditCategory.ADMIN : AuditCategory.CONTENT, + resourceType: "Suggestion", + resourceId: suggestion.id, + details: `Deleted ${suggestion.entityType} suggestion: ${suggestion.title}`, + success: true, + }); + + reply.send({ success: true }); + } catch (error) { + return reply.badRequest( + error instanceof Error ? error.message : "Failed to delete suggestion" + ); + } + } + ); } diff --git a/api/src/app/services/auth.service.ts b/api/src/app/services/auth.service.ts index a05dae4..010473d 100644 --- a/api/src/app/services/auth.service.ts +++ b/api/src/app/services/auth.service.ts @@ -1,6 +1,10 @@ import { FastifyInstance } from "fastify"; import { JwtPayload, User } from "@library/shared-types"; import { prisma } from "../lib/prisma"; +import { randomBytes } from "crypto"; + +const ACCESS_TOKEN_EXPIRY = "15m"; +const REFRESH_TOKEN_EXPIRY_DAYS = 7; export class AuthService { private prisma = prisma; @@ -8,7 +12,7 @@ export class AuthService { constructor(private readonly app: FastifyInstance) {} /** - * Generate JWT token for user. + * Generate short-lived access token for user. */ async generateToken(user: User): Promise { const payload: JwtPayload = { @@ -19,10 +23,125 @@ export class AuthService { }; return this.app.jwt.sign(payload, { - expiresIn: "7d", + expiresIn: ACCESS_TOKEN_EXPIRY, }); } + /** + * Generate a secure refresh token and store it in the database. + */ + async generateRefreshToken(userId: string): Promise { + const token = randomBytes(64).toString("hex"); + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + REFRESH_TOKEN_EXPIRY_DAYS); + + await this.prisma.refreshToken.create({ + data: { + token, + userId, + expiresAt, + }, + }); + + return token; + } + + /** + * Validate and consume a refresh token, returning the user if valid. + */ + async validateRefreshToken(token: string): Promise { + const refreshToken = await this.prisma.refreshToken.findUnique({ + where: { token }, + include: { user: true }, + }); + + if (!refreshToken) { + return null; + } + + if (refreshToken.expiresAt < new Date()) { + await this.prisma.refreshToken.delete({ where: { id: refreshToken.id } }); + return null; + } + + const dbUser = refreshToken.user; + return { + id: dbUser.id, + discordId: dbUser.discordId, + username: dbUser.username, + email: dbUser.email, + avatar: dbUser.avatar || undefined, + isAdmin: dbUser.isAdmin, + isBanned: dbUser.isBanned, + inDiscord: dbUser.inDiscord, + isVip: dbUser.isVip, + isMod: dbUser.isMod, + isStaff: dbUser.isStaff, + }; + } + + /** + * Rotate a refresh token (invalidate old, create new with same expiry). + * Preserves original expiry so users must re-auth with Discord every 7 days. + */ + async rotateRefreshToken(oldToken: string, userId: string): Promise { + const existingToken = await this.prisma.refreshToken.findUnique({ + where: { token: oldToken }, + }); + + if (!existingToken) { + return null; + } + + const originalExpiry = existingToken.expiresAt; + + await this.prisma.refreshToken.delete({ + where: { id: existingToken.id }, + }); + + const newToken = randomBytes(64).toString("hex"); + + await this.prisma.refreshToken.create({ + data: { + token: newToken, + userId, + expiresAt: originalExpiry, + }, + }); + + return newToken; + } + + /** + * Revoke a specific refresh token. + */ + async revokeRefreshToken(token: string): Promise { + await this.prisma.refreshToken.deleteMany({ + where: { token }, + }); + } + + /** + * Revoke all refresh tokens for a user (logout from all devices). + */ + async revokeAllUserTokens(userId: string): Promise { + await this.prisma.refreshToken.deleteMany({ + where: { userId }, + }); + } + + /** + * Clean up expired refresh tokens (can be called periodically). + */ + async cleanupExpiredTokens(): Promise { + const result = await this.prisma.refreshToken.deleteMany({ + where: { + expiresAt: { lt: new Date() }, + }, + }); + return result.count; + } + /** * Verify JWT token. */ diff --git a/api/src/app/services/suggestion.service.ts b/api/src/app/services/suggestion.service.ts index da4d677..41b8457 100644 --- a/api/src/app/services/suggestion.service.ts +++ b/api/src/app/services/suggestion.service.ts @@ -4,6 +4,7 @@ import type { SuggestionStatus, SuggestionEntity, CreateSuggestionDto, + AcceptWithEditsDto, } from "@library/shared-types"; import { GameStatus, @@ -340,7 +341,7 @@ export const SuggestionService = { return mapSuggestion(updatedSuggestion); }, - async acceptSuggestionWithEdits(id: string, editedData: any): Promise { + async acceptSuggestionWithEdits(id: string, editedData: AcceptWithEditsDto): Promise { const suggestion = await prisma.suggestion.findUnique({ where: { id }, include: { user: true }, @@ -453,4 +454,29 @@ export const SuggestionService = { return mapSuggestion(updatedSuggestion); }, + + async deleteSuggestion(id: string, userId: string, isAdmin: boolean): Promise { + const suggestion = await prisma.suggestion.findUnique({ + where: { id }, + include: { user: true }, + }); + + if (!suggestion) { + throw new Error("Suggestion not found"); + } + + if (!isAdmin && suggestion.userId !== userId) { + throw new Error("You can only delete your own suggestions"); + } + + if (suggestion.status !== "UNREVIEWED") { + throw new Error("Cannot delete a suggestion that has already been reviewed"); + } + + await prisma.suggestion.delete({ + where: { id }, + }); + + return mapSuggestion(suggestion); + }, }; diff --git a/api/src/main.ts b/api/src/main.ts index 1332901..b54fdb7 100644 --- a/api/src/main.ts +++ b/api/src/main.ts @@ -2,11 +2,12 @@ import Fastify from 'fastify'; import { app } from './app/app'; const host = process.env.HOST ?? 'localhost'; -const port = process.env.PORT ? Number(process.env.PORT) : 3000; +const port = process.env.PORT ? Number(process.env.PORT) : 12321; // Instantiate Fastify with some config const server = Fastify({ logger: true, + bodyLimit: 1048576, // 1MB max body size }); // Register your application as a normal plugin. diff --git a/apps/frontend/src/app/interceptors/auth.interceptor.ts b/apps/frontend/src/app/interceptors/auth.interceptor.ts index 9de49b9..769e1af 100644 --- a/apps/frontend/src/app/interceptors/auth.interceptor.ts +++ b/apps/frontend/src/app/interceptors/auth.interceptor.ts @@ -4,36 +4,78 @@ * @author Naomi Carrigan */ -import { Injectable } from '@angular/core'; +import { Injectable, inject } from '@angular/core'; import { HttpRequest, HttpHandler, HttpEvent, HttpInterceptor, - HttpErrorResponse + HttpErrorResponse, + HttpClient } from '@angular/common/http'; -import { Observable, catchError, throwError } from 'rxjs'; +import { Observable, catchError, throwError, switchMap, BehaviorSubject, filter, take } from 'rxjs'; import { Router } from '@angular/router'; +import { environment } from '../../environments/environment'; @Injectable() export class AuthInterceptor implements HttpInterceptor { - constructor(private router: Router) {} + private isRefreshing = false; + private refreshTokenSubject = new BehaviorSubject(null); + + constructor( + private router: Router, + private http: HttpClient + ) {} intercept(request: HttpRequest, next: HttpHandler): Observable> { // Clone the request to add withCredentials - // This ensures cookies are sent with every request const authReq = request.clone({ withCredentials: true }); return next.handle(authReq).pipe( catchError((error: HttpErrorResponse) => { - if (error.status === 401) { - // Unauthorized - redirect to home - this.router.navigate(['/']); + if (error.status === 401 && !request.url.includes('/auth/refresh') && !request.url.includes('/auth/logout')) { + return this.handle401Error(authReq, next); } return throwError(() => error); }) ); } + + private handle401Error(request: HttpRequest, next: HttpHandler): Observable> { + if (!this.isRefreshing) { + this.isRefreshing = true; + this.refreshTokenSubject.next(null); + + return this.http.post( + `${environment.apiUrl}/auth/refresh`, + {}, + { withCredentials: true } + ).pipe( + switchMap(() => { + this.isRefreshing = false; + this.refreshTokenSubject.next(true); + return next.handle(request); + }), + catchError((err) => { + this.isRefreshing = false; + this.refreshTokenSubject.next(false); + this.router.navigate(['/']); + return throwError(() => err); + }) + ); + } + + return this.refreshTokenSubject.pipe( + filter(result => result !== null), + take(1), + switchMap(success => { + if (success) { + return next.handle(request); + } + return throwError(() => new Error('Token refresh failed')); + }) + ); + } } \ No newline at end of file diff --git a/apps/frontend/src/app/services/auth.service.ts b/apps/frontend/src/app/services/auth.service.ts index 2ae06e6..1a9189c 100644 --- a/apps/frontend/src/app/services/auth.service.ts +++ b/apps/frontend/src/app/services/auth.service.ts @@ -6,10 +6,11 @@ import { Injectable, signal } from '@angular/core'; import { Router } from '@angular/router'; -import { Observable, tap } from 'rxjs'; +import { Observable, tap, catchError, switchMap, throwError, of } from 'rxjs'; import { ApiService } from './api.service'; import { AuthResponse, User } from '@library/shared-types'; import { environment } from '../../environments/environment'; +import { HttpClient } from '@angular/common/http'; @Injectable({ providedIn: 'root' @@ -17,10 +18,12 @@ import { environment } from '../../environments/environment'; export class AuthService { private currentUser = signal(null); public readonly user = this.currentUser.asReadonly(); + private refreshing = false; constructor( private api: ApiService, - private router: Router + private router: Router, + private http: HttpClient ) {} login(): void { @@ -32,6 +35,44 @@ export class AuthService { return this.api.get('/auth/me').pipe( tap(response => { this.currentUser.set(response.user); + }), + catchError(error => { + if (error.status === 401) { + return this.refreshToken().pipe( + switchMap(() => this.api.get('/auth/me')), + tap(response => { + this.currentUser.set(response.user); + }), + catchError(() => { + this.currentUser.set(null); + return throwError(() => error); + }) + ); + } + return throwError(() => error); + }) + ); + } + + refreshToken(): Observable { + if (this.refreshing) { + return of({ user: this.currentUser()!, accessToken: '' }); + } + + this.refreshing = true; + return this.http.post( + `${environment.apiUrl}/auth/refresh`, + {}, + { withCredentials: true } + ).pipe( + tap(response => { + this.currentUser.set(response.user); + this.refreshing = false; + }), + catchError(error => { + this.refreshing = false; + this.currentUser.set(null); + return throwError(() => error); }) ); } @@ -46,6 +87,10 @@ export class AuthService { ); } + clearUser(): void { + this.currentUser.set(null); + } + isAuthenticated(): boolean { return this.user() !== null; } diff --git a/shared-types/src/lib/suggestion.types.ts b/shared-types/src/lib/suggestion.types.ts index b56b2d8..3f410bb 100644 --- a/shared-types/src/lib/suggestion.types.ts +++ b/shared-types/src/lib/suggestion.types.ts @@ -109,3 +109,19 @@ export type CreateSuggestionDto = export interface DeclineSuggestionDto { reason?: string; } + +export interface AcceptWithEditsDto { + title?: string; + platform?: string; + author?: string; + artist?: string; + isbn?: string; + type?: string; + notes?: string; + description?: string; + coverImage?: string; + coverArt?: string; + imageUrl?: string; + tags?: string[]; + links?: Array<{ label: string; url: string }>; +}