generated from nhcarrigan/template
feat: another security sweep
This commit is contained in:
@@ -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" });
|
||||
});
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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<void> {
|
||||
);
|
||||
|
||||
// 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<void> {
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user