generated from nhcarrigan/template
feat: another security sweep
This commit is contained in:
@@ -179,6 +179,7 @@ model User {
|
|||||||
comments Comment[]
|
comments Comment[]
|
||||||
suggestions Suggestion[]
|
suggestions Suggestion[]
|
||||||
likes Like[]
|
likes Like[]
|
||||||
|
refreshTokens RefreshToken[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Comment {
|
model Comment {
|
||||||
@@ -289,3 +290,15 @@ model Like {
|
|||||||
|
|
||||||
@@unique([userId, entityType, entityId])
|
@@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])
|
||||||
|
}
|
||||||
|
|||||||
@@ -79,8 +79,9 @@ const authRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
// Create or update user in database
|
// Create or update user in database
|
||||||
const user = await authService.createOrUpdateUserFromDiscord(userData, inDiscord, isVip, isMod, isStaff);
|
const user = await authService.createOrUpdateUserFromDiscord(userData, inDiscord, isVip, isMod, isStaff);
|
||||||
|
|
||||||
// Generate JWT
|
// Generate access token and refresh token
|
||||||
const jwt = await authService.generateToken(user);
|
const accessToken = await authService.generateToken(user);
|
||||||
|
const refreshToken = await authService.generateRefreshToken(user.id);
|
||||||
|
|
||||||
// Log successful login
|
// Log successful login
|
||||||
await AuditService.log({
|
await AuditService.log({
|
||||||
@@ -91,13 +92,21 @@ const authRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
success: true,
|
success: true,
|
||||||
}, request);
|
}, request);
|
||||||
|
|
||||||
// Set signed cookie and redirect to frontend
|
// Set signed cookies and redirect to frontend
|
||||||
reply
|
reply
|
||||||
.setCookie("auth-token", jwt, {
|
.setCookie("auth-token", accessToken, {
|
||||||
path: "/",
|
path: "/",
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: process.env.NODE_ENV === "production",
|
secure: process.env.NODE_ENV === "production",
|
||||||
sameSite: "lax",
|
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
|
maxAge: 7 * 24 * 60 * 60, // 7 days
|
||||||
signed: true,
|
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.
|
* Logout.
|
||||||
*/
|
*/
|
||||||
app.post("/logout", async (request, reply) => {
|
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 {
|
try {
|
||||||
await request.jwtVerify();
|
await request.jwtVerify();
|
||||||
const user = request.user as { id?: string; username?: string };
|
const user = request.user as { id?: string; username?: string };
|
||||||
@@ -169,6 +245,10 @@ const authRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
path: "/",
|
path: "/",
|
||||||
signed: true,
|
signed: true,
|
||||||
})
|
})
|
||||||
|
.clearCookie("refresh-token", {
|
||||||
|
path: "/api/auth",
|
||||||
|
signed: true,
|
||||||
|
})
|
||||||
.send({ message: "Logged out successfully" });
|
.send({ message: "Logged out successfully" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -136,6 +136,11 @@ const likesRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
return { error: "Items array is required" };
|
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 validTypes = ['book', 'game', 'show', 'manga', 'music', 'art'];
|
||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
items.map(async (item) => {
|
items.map(async (item) => {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
SuggestionEntity,
|
SuggestionEntity,
|
||||||
CreateSuggestionDto,
|
CreateSuggestionDto,
|
||||||
DeclineSuggestionDto,
|
DeclineSuggestionDto,
|
||||||
|
AcceptWithEditsDto,
|
||||||
} from "@library/shared-types";
|
} from "@library/shared-types";
|
||||||
import { adminGuard } from "../../middleware/admin-guard";
|
import { adminGuard } from "../../middleware/admin-guard";
|
||||||
import { bannedGuard } from "../../middleware/banned-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)
|
// 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",
|
"/:id/accept-with-edits",
|
||||||
{
|
{
|
||||||
preHandler: [app.authenticate, adminGuard, app.csrfProtection],
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { FastifyInstance } from "fastify";
|
import { FastifyInstance } from "fastify";
|
||||||
import { JwtPayload, User } from "@library/shared-types";
|
import { JwtPayload, User } from "@library/shared-types";
|
||||||
import { prisma } from "../lib/prisma";
|
import { prisma } from "../lib/prisma";
|
||||||
|
import { randomBytes } from "crypto";
|
||||||
|
|
||||||
|
const ACCESS_TOKEN_EXPIRY = "15m";
|
||||||
|
const REFRESH_TOKEN_EXPIRY_DAYS = 7;
|
||||||
|
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
private prisma = prisma;
|
private prisma = prisma;
|
||||||
@@ -8,7 +12,7 @@ export class AuthService {
|
|||||||
constructor(private readonly app: FastifyInstance) {}
|
constructor(private readonly app: FastifyInstance) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate JWT token for user.
|
* Generate short-lived access token for user.
|
||||||
*/
|
*/
|
||||||
async generateToken(user: User): Promise<string> {
|
async generateToken(user: User): Promise<string> {
|
||||||
const payload: JwtPayload = {
|
const payload: JwtPayload = {
|
||||||
@@ -19,10 +23,125 @@ export class AuthService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return this.app.jwt.sign(payload, {
|
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<string> {
|
||||||
|
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<User | null> {
|
||||||
|
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<string | null> {
|
||||||
|
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<void> {
|
||||||
|
await this.prisma.refreshToken.deleteMany({
|
||||||
|
where: { token },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke all refresh tokens for a user (logout from all devices).
|
||||||
|
*/
|
||||||
|
async revokeAllUserTokens(userId: string): Promise<void> {
|
||||||
|
await this.prisma.refreshToken.deleteMany({
|
||||||
|
where: { userId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up expired refresh tokens (can be called periodically).
|
||||||
|
*/
|
||||||
|
async cleanupExpiredTokens(): Promise<number> {
|
||||||
|
const result = await this.prisma.refreshToken.deleteMany({
|
||||||
|
where: {
|
||||||
|
expiresAt: { lt: new Date() },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return result.count;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verify JWT token.
|
* Verify JWT token.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
SuggestionStatus,
|
SuggestionStatus,
|
||||||
SuggestionEntity,
|
SuggestionEntity,
|
||||||
CreateSuggestionDto,
|
CreateSuggestionDto,
|
||||||
|
AcceptWithEditsDto,
|
||||||
} from "@library/shared-types";
|
} from "@library/shared-types";
|
||||||
import {
|
import {
|
||||||
GameStatus,
|
GameStatus,
|
||||||
@@ -340,7 +341,7 @@ export const SuggestionService = {
|
|||||||
return mapSuggestion(updatedSuggestion);
|
return mapSuggestion(updatedSuggestion);
|
||||||
},
|
},
|
||||||
|
|
||||||
async acceptSuggestionWithEdits(id: string, editedData: any): Promise<Suggestion> {
|
async acceptSuggestionWithEdits(id: string, editedData: AcceptWithEditsDto): Promise<Suggestion> {
|
||||||
const suggestion = await prisma.suggestion.findUnique({
|
const suggestion = await prisma.suggestion.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: { user: true },
|
include: { user: true },
|
||||||
@@ -453,4 +454,29 @@ export const SuggestionService = {
|
|||||||
|
|
||||||
return mapSuggestion(updatedSuggestion);
|
return mapSuggestion(updatedSuggestion);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async deleteSuggestion(id: string, userId: string, isAdmin: boolean): Promise<Suggestion> {
|
||||||
|
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);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+2
-1
@@ -2,11 +2,12 @@ import Fastify from 'fastify';
|
|||||||
import { app } from './app/app';
|
import { app } from './app/app';
|
||||||
|
|
||||||
const host = process.env.HOST ?? 'localhost';
|
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
|
// Instantiate Fastify with some config
|
||||||
const server = Fastify({
|
const server = Fastify({
|
||||||
logger: true,
|
logger: true,
|
||||||
|
bodyLimit: 1048576, // 1MB max body size
|
||||||
});
|
});
|
||||||
|
|
||||||
// Register your application as a normal plugin.
|
// Register your application as a normal plugin.
|
||||||
|
|||||||
@@ -4,36 +4,78 @@
|
|||||||
* @author Naomi Carrigan
|
* @author Naomi Carrigan
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Injectable } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import {
|
import {
|
||||||
HttpRequest,
|
HttpRequest,
|
||||||
HttpHandler,
|
HttpHandler,
|
||||||
HttpEvent,
|
HttpEvent,
|
||||||
HttpInterceptor,
|
HttpInterceptor,
|
||||||
HttpErrorResponse
|
HttpErrorResponse,
|
||||||
|
HttpClient
|
||||||
} from '@angular/common/http';
|
} 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 { Router } from '@angular/router';
|
||||||
|
import { environment } from '../../environments/environment';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthInterceptor implements HttpInterceptor {
|
export class AuthInterceptor implements HttpInterceptor {
|
||||||
constructor(private router: Router) {}
|
private isRefreshing = false;
|
||||||
|
private refreshTokenSubject = new BehaviorSubject<boolean | null>(null);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private router: Router,
|
||||||
|
private http: HttpClient
|
||||||
|
) {}
|
||||||
|
|
||||||
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
|
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
|
||||||
// Clone the request to add withCredentials
|
// Clone the request to add withCredentials
|
||||||
// This ensures cookies are sent with every request
|
|
||||||
const authReq = request.clone({
|
const authReq = request.clone({
|
||||||
withCredentials: true
|
withCredentials: true
|
||||||
});
|
});
|
||||||
|
|
||||||
return next.handle(authReq).pipe(
|
return next.handle(authReq).pipe(
|
||||||
catchError((error: HttpErrorResponse) => {
|
catchError((error: HttpErrorResponse) => {
|
||||||
if (error.status === 401) {
|
if (error.status === 401 && !request.url.includes('/auth/refresh') && !request.url.includes('/auth/logout')) {
|
||||||
// Unauthorized - redirect to home
|
return this.handle401Error(authReq, next);
|
||||||
this.router.navigate(['/']);
|
|
||||||
}
|
}
|
||||||
return throwError(() => error);
|
return throwError(() => error);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private handle401Error(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
|
||||||
|
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'));
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -6,10 +6,11 @@
|
|||||||
|
|
||||||
import { Injectable, signal } from '@angular/core';
|
import { Injectable, signal } from '@angular/core';
|
||||||
import { Router } from '@angular/router';
|
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 { ApiService } from './api.service';
|
||||||
import { AuthResponse, User } from '@library/shared-types';
|
import { AuthResponse, User } from '@library/shared-types';
|
||||||
import { environment } from '../../environments/environment';
|
import { environment } from '../../environments/environment';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
@@ -17,10 +18,12 @@ import { environment } from '../../environments/environment';
|
|||||||
export class AuthService {
|
export class AuthService {
|
||||||
private currentUser = signal<User | null>(null);
|
private currentUser = signal<User | null>(null);
|
||||||
public readonly user = this.currentUser.asReadonly();
|
public readonly user = this.currentUser.asReadonly();
|
||||||
|
private refreshing = false;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private api: ApiService,
|
private api: ApiService,
|
||||||
private router: Router
|
private router: Router,
|
||||||
|
private http: HttpClient
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
login(): void {
|
login(): void {
|
||||||
@@ -32,6 +35,44 @@ export class AuthService {
|
|||||||
return this.api.get<AuthResponse>('/auth/me').pipe(
|
return this.api.get<AuthResponse>('/auth/me').pipe(
|
||||||
tap(response => {
|
tap(response => {
|
||||||
this.currentUser.set(response.user);
|
this.currentUser.set(response.user);
|
||||||
|
}),
|
||||||
|
catchError(error => {
|
||||||
|
if (error.status === 401) {
|
||||||
|
return this.refreshToken().pipe(
|
||||||
|
switchMap(() => this.api.get<AuthResponse>('/auth/me')),
|
||||||
|
tap(response => {
|
||||||
|
this.currentUser.set(response.user);
|
||||||
|
}),
|
||||||
|
catchError(() => {
|
||||||
|
this.currentUser.set(null);
|
||||||
|
return throwError(() => error);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return throwError(() => error);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshToken(): Observable<AuthResponse> {
|
||||||
|
if (this.refreshing) {
|
||||||
|
return of({ user: this.currentUser()!, accessToken: '' });
|
||||||
|
}
|
||||||
|
|
||||||
|
this.refreshing = true;
|
||||||
|
return this.http.post<AuthResponse>(
|
||||||
|
`${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 {
|
isAuthenticated(): boolean {
|
||||||
return this.user() !== null;
|
return this.user() !== null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,3 +109,19 @@ export type CreateSuggestionDto =
|
|||||||
export interface DeclineSuggestionDto {
|
export interface DeclineSuggestionDto {
|
||||||
reason?: string;
|
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 }>;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user