generated from nhcarrigan/template
feat: auth
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { FastifyReply, FastifyRequest } from "fastify";
|
||||
|
||||
/**
|
||||
* Middleware to check if the authenticated user is an admin.
|
||||
* Must be used after app.authenticate.
|
||||
*/
|
||||
export async function adminGuard(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply
|
||||
): Promise<void> {
|
||||
const user = request.user as any;
|
||||
if (!user || !user.isAdmin) {
|
||||
return reply.code(403).send({ error: "Forbidden: Admin access required" });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { FastifyPluginAsync, FastifyRequest } from "fastify";
|
||||
import fastifyPlugin from "fastify-plugin";
|
||||
import fastifyJwt from "@fastify/jwt";
|
||||
import fastifyCookie from "@fastify/cookie";
|
||||
import fastifyOauth2 from "@fastify/oauth2";
|
||||
|
||||
declare module "fastify" {
|
||||
interface FastifyInstance {
|
||||
authenticate: (request: FastifyRequest) => Promise<void>;
|
||||
oauth2Discord: any;
|
||||
}
|
||||
interface FastifyRequest {
|
||||
user?: any;
|
||||
}
|
||||
}
|
||||
|
||||
const authPlugin: FastifyPluginAsync = async (app) => {
|
||||
// Register JWT plugin
|
||||
app.register(fastifyJwt, {
|
||||
secret: process.env.JWT_SECRET || "your-secret-key",
|
||||
cookie: {
|
||||
cookieName: "auth-token",
|
||||
signed: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Register cookie plugin
|
||||
app.register(fastifyCookie);
|
||||
|
||||
// Register Discord OAuth2
|
||||
app.register(fastifyOauth2, {
|
||||
name: "oauth2Discord",
|
||||
credentials: {
|
||||
client: {
|
||||
id: process.env.DISCORD_CLIENT_ID || "",
|
||||
secret: process.env.DISCORD_CLIENT_SECRET || "",
|
||||
},
|
||||
auth: fastifyOauth2.DISCORD_CONFIGURATION,
|
||||
},
|
||||
startRedirectPath: "/api/auth/login",
|
||||
callbackUri: `${process.env.API_URL || "http://localhost:3000"}/api/auth/callback`,
|
||||
});
|
||||
|
||||
// Authentication decorator
|
||||
app.decorate("authenticate", async (request: FastifyRequest) => {
|
||||
try {
|
||||
await request.jwtVerify();
|
||||
} catch (err) {
|
||||
throw app.httpErrors.unauthorized("Invalid token");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export default fastifyPlugin(authPlugin);
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import fp from 'fastify-plugin';
|
||||
import sensible from '@fastify/sensible';
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { FastifyPluginAsync } from "fastify";
|
||||
import { AuthService } from "../services/auth.service";
|
||||
import { AuthResponse } from "@library/shared-types";
|
||||
|
||||
const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
const authService = new AuthService(app);
|
||||
|
||||
/**
|
||||
* Initiate Discord OAuth login.
|
||||
*/
|
||||
app.get("/login", async (request, reply) => {
|
||||
const authUrl = app.oauth2Discord.generateAuthorizationUri({
|
||||
scope: ["identify", "email"],
|
||||
});
|
||||
|
||||
return reply.redirect(authUrl);
|
||||
});
|
||||
|
||||
/**
|
||||
* Discord OAuth callback.
|
||||
*/
|
||||
app.get("/callback", async (request, reply) => {
|
||||
try {
|
||||
const token = await app.oauth2Discord.getAccessTokenFromAuthorizationCodeFlow(
|
||||
request
|
||||
);
|
||||
|
||||
// Get user data from Discord
|
||||
const userData = await app.oauth2Discord.userinfo(token.access_token);
|
||||
|
||||
// Create or update user in database
|
||||
const user = await authService.createOrUpdateUserFromDiscord(userData);
|
||||
|
||||
// Generate JWT
|
||||
const jwt = await authService.generateToken(user);
|
||||
|
||||
// Set cookie and redirect to frontend
|
||||
reply
|
||||
.setCookie("auth-token", jwt, {
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
maxAge: 7 * 24 * 60 * 60, // 7 days
|
||||
})
|
||||
.redirect(process.env.FRONTEND_URL || "http://localhost:4200");
|
||||
} catch (error) {
|
||||
app.log.error(error);
|
||||
reply
|
||||
.code(401)
|
||||
.send({ error: "Authentication failed" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get current user.
|
||||
*/
|
||||
app.get<{ Reply: AuthResponse | { error: string } }>(
|
||||
"/me",
|
||||
{
|
||||
preValidation: [app.authenticate],
|
||||
},
|
||||
async (request) => {
|
||||
const user = request.user as any;
|
||||
const token = await authService.generateToken(user);
|
||||
|
||||
return {
|
||||
user,
|
||||
accessToken: token,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Logout.
|
||||
*/
|
||||
app.post("/logout", async (request, reply) => {
|
||||
reply
|
||||
.clearCookie("auth-token", {
|
||||
path: "/",
|
||||
})
|
||||
.send({ message: "Logged out successfully" });
|
||||
});
|
||||
};
|
||||
|
||||
export default authRoutes;
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { FastifyPluginAsync } from "fastify";
|
||||
import { PrismaClient } from "../../../generated/prisma";
|
||||
import { Game, GameStatus } from "@library/shared-types";
|
||||
import { adminGuard } from "../../middleware/admin-guard";
|
||||
|
||||
const gamesRoutes: FastifyPluginAsync = async (app) => {
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Get all games (public route)
|
||||
app.get<{ Reply: Game[] }>("/", async () => {
|
||||
const games = await prisma.game.findMany({
|
||||
orderBy: { updatedAt: "desc" },
|
||||
});
|
||||
return games.map((game) => ({
|
||||
...game,
|
||||
status: game.status.toLowerCase() as GameStatus,
|
||||
}));
|
||||
});
|
||||
|
||||
// Get single game (public route)
|
||||
app.get<{ Params: { id: string }; Reply: Game | null }>(
|
||||
"/:id",
|
||||
async (request) => {
|
||||
const { id } = request.params;
|
||||
const game = await prisma.game.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
if (!game) return null;
|
||||
return {
|
||||
...game,
|
||||
status: game.status.toLowerCase() as GameStatus,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Create game (protected admin route)
|
||||
app.post<{ Body: Omit<Game, "id" | "createdAt" | "updatedAt">; Reply: Game }>(
|
||||
"/",
|
||||
{
|
||||
preValidation: [app.authenticate, adminGuard],
|
||||
},
|
||||
async (request, reply) => {
|
||||
|
||||
const game = await prisma.game.create({
|
||||
data: {
|
||||
...request.body,
|
||||
status: request.body.status.toUpperCase() as any,
|
||||
},
|
||||
});
|
||||
return {
|
||||
...game,
|
||||
status: game.status.toLowerCase() as GameStatus,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Update game (protected admin route)
|
||||
app.put<{
|
||||
Params: { id: string };
|
||||
Body: Partial<Omit<Game, "id" | "createdAt" | "updatedAt">>;
|
||||
Reply: Game | null;
|
||||
}>(
|
||||
"/:id",
|
||||
{
|
||||
preValidation: [app.authenticate, adminGuard],
|
||||
},
|
||||
async (request, reply) => {
|
||||
|
||||
const { id } = request.params;
|
||||
const updateData = { ...request.body };
|
||||
if (updateData.status) {
|
||||
updateData.status = updateData.status.toUpperCase() as any;
|
||||
}
|
||||
|
||||
const game = await prisma.game.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
});
|
||||
return {
|
||||
...game,
|
||||
status: game.status.toLowerCase() as GameStatus,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Delete game (protected admin route)
|
||||
app.delete<{ Params: { id: string }; Reply: { success: boolean } }>(
|
||||
"/:id",
|
||||
{
|
||||
preValidation: [app.authenticate, adminGuard],
|
||||
},
|
||||
async (request, reply) => {
|
||||
|
||||
const { id } = request.params;
|
||||
await prisma.game.delete({
|
||||
where: { id },
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default gamesRoutes;
|
||||
@@ -0,0 +1,78 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { JwtPayload, User } from "@library/shared-types";
|
||||
import { PrismaClient } from "../../generated/prisma";
|
||||
|
||||
export class AuthService {
|
||||
private prisma: PrismaClient;
|
||||
|
||||
constructor(private readonly app: FastifyInstance) {
|
||||
this.prisma = new PrismaClient();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate JWT token for user.
|
||||
*/
|
||||
async generateToken(user: User): Promise<string> {
|
||||
const payload: JwtPayload = {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
isAdmin: user.isAdmin,
|
||||
};
|
||||
|
||||
return this.app.jwt.sign(payload, {
|
||||
expiresIn: "7d",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify JWT token.
|
||||
*/
|
||||
async verifyToken(token: string): Promise<JwtPayload> {
|
||||
return this.app.jwt.verify(token) as JwtPayload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update user from Discord OAuth data.
|
||||
*/
|
||||
async createOrUpdateUserFromDiscord(discordData: DiscordUser): Promise<User> {
|
||||
const avatarUrl = discordData.avatar
|
||||
? `https://cdn.discordapp.com/avatars/${discordData.id}/${discordData.avatar}.png`
|
||||
: undefined;
|
||||
|
||||
// Upsert user in database
|
||||
const dbUser = await this.prisma.user.upsert({
|
||||
where: {
|
||||
discordId: discordData.id,
|
||||
},
|
||||
create: {
|
||||
discordId: discordData.id,
|
||||
username: discordData.username,
|
||||
email: discordData.email,
|
||||
avatar: avatarUrl,
|
||||
isAdmin: discordData.id === process.env.ADMIN_DISCORD_ID,
|
||||
},
|
||||
update: {
|
||||
username: discordData.username,
|
||||
email: discordData.email,
|
||||
avatar: avatarUrl,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: dbUser.id,
|
||||
discordId: dbUser.discordId,
|
||||
username: dbUser.username,
|
||||
email: dbUser.email,
|
||||
avatarUrl: dbUser.avatar || undefined,
|
||||
isAdmin: dbUser.isAdmin,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface DiscordUser {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
avatar?: string;
|
||||
}
|
||||
Reference in New Issue
Block a user