Files
my_openplace/src/routes/auth.ts
T
2025-10-05 00:58:08 -07:00

261 lines
7.1 KiB
TypeScript

import { App } from "@tinyhttp/app";
import bcrypt from "bcryptjs";
import { JWT_SECRET } from "../config/auth.js";
import { prisma } from "../config/database.js";
import { authMiddleware } from "../middleware/auth.js";
import { captchaMiddleware } from "../middleware/captcha.js";
import { captchaConfig } from "../config/captcha.js";
import jwt from "jsonwebtoken";
import fs from "fs/promises";
import passport from "passport";
import { Strategy as GoogleStrategy } from "passport-google-oauth20";
const GOOGLE_CLIENT_ID = process.env["GOOGLE_CLIENT_ID"] || "";
const GOOGLE_CLIENT_SECRET = process.env["GOOGLE_CLIENT_SECRET"] || "";
const GOOGLE_CALLBACK_URL = process.env["GOOGLE_CALLBACK_URL"] || "http://localhost:3000/auth/google/callback";
const SESSION_COOKIE_NAME = "j";
const SESSION_MAX_AGE_SECONDS = 30 * 24 * 60 * 60;
const COOKIE_SAME_SITE = process.env["COOKIE_SAME_SITE"] || "Strict";
const COOKIE_DOMAIN = process.env["COOKIE_DOMAIN"] || "";
const SHOULD_USE_SECURE_COOKIES = (
process.env["COOKIE_SECURE"] ?? ""
).toLowerCase() !== "false" && (process.env["NODE_ENV"] ?? "production").toLowerCase() !== "development";
function buildSessionCookie(value: string, maxAgeSeconds: number, expiresAt: Date) {
const encodedValue = encodeURIComponent(value);
const attributes = [
`${SESSION_COOKIE_NAME}=${encodedValue}`,
"HttpOnly",
"Path=/",
`Max-Age=${maxAgeSeconds}`,
`SameSite=${COOKIE_SAME_SITE}`,
`Expires=${expiresAt.toUTCString()}`
];
if (COOKIE_DOMAIN) {
attributes.push(`Domain=${COOKIE_DOMAIN}`);
}
if (SHOULD_USE_SECURE_COOKIES) {
attributes.push("Secure");
}
return attributes.join("; ");
}
function setSessionCookie(res: any, token: string, expiresAt: Date) {
res.setHeader("Set-Cookie", buildSessionCookie(token, SESSION_MAX_AGE_SECONDS, expiresAt));
}
function clearSessionCookie(res: any) {
const expiresAt = new Date(0);
res.setHeader("Set-Cookie", buildSessionCookie("", 0, expiresAt));
}
// Configure Google OAuth Strategy
if (GOOGLE_CLIENT_ID && GOOGLE_CLIENT_SECRET) {
passport.use(new GoogleStrategy({
clientID: GOOGLE_CLIENT_ID,
clientSecret: GOOGLE_CLIENT_SECRET,
callbackURL: GOOGLE_CALLBACK_URL,
scope: ["profile", "email"]
}, async (_accessToken, _refreshToken, profile, done) => {
try {
let user = await prisma.user.findFirst({
where: { googleId: profile.id }
});
if (!user) {
const email = profile.emails?.[0]?.value;
const name = profile.displayName || profile.emails?.[0]?.value?.split("@")[0] || `user${Date.now()}`;
user = await prisma.user.create({
data: {
googleId: profile.id,
name,
email: email ?? null,
country: "US", // TODO: detect from profile or IP
droplets: 1000,
currentCharges: 20,
maxCharges: 20,
pixelsPainted: 0,
level: 1,
extraColorsBitmap: 0,
equippedFlag: 0,
chargesLastUpdatedAt: new Date()
}
});
}
return done(null, user);
} catch (error) {
return done(error as Error, undefined);
}
}));
}
export default function (app: App) {
// Initialize passport
app.use(passport.initialize() as any);
// Google OAuth routes
app.get("/auth/google",
captchaMiddleware(captchaConfig.login, "token"),
passport.authenticate("google", { session: false })
);
app.get("/auth/google/callback",
passport.authenticate("google", {
session: false,
failureRedirect: "/login"
}),
async (req: any, res: any) => {
try {
const user = req.user;
if (!user) {
return res.redirect("/login?error=auth_failed");
}
// Create session
const session = await prisma.session.create({
data: {
userId: user.id,
expiresAt: new Date(Date.now() + SESSION_MAX_AGE_SECONDS * 1000)
}
});
// Create JWT token
const token = jwt.sign(
{
userId: user.id,
sessionId: session.id,
iss: "FurryPlace",
exp: Math.floor(session.expiresAt.getTime() / 1000),
iat: Math.floor(Date.now() / 1000)
},
JWT_SECRET
);
setSessionCookie(res, token, session.expiresAt);
return res.redirect("/");
} catch (error) {
console.error("Google callback error:", error);
return res.redirect("/login?error=server_error");
}
}
);
app.get("/login", async (_req, res) => {
const loginHtml = await fs.readFile("./frontend/login.html", "utf8");
res.setHeader("Content-Type", "text/html");
return res.send(loginHtml);
});
app.post("/login", captchaMiddleware(captchaConfig.login), async (req: any, res: any) => {
try {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400)
.json({ error: "Username and password required" });
}
let user = await prisma.user.findFirst({
where: { name: username }
});
if (user) {
const passwordValid = await bcrypt.compare(password, user.passwordHash ?? "");
if (!passwordValid) {
return res.status(401)
.json({ error: "Invalid username or password" });
}
} else {
// For new user registration, check register captcha if enabled
if (captchaConfig.register.enabled && captchaConfig.register.provider !== "none") {
const registerCaptchaToken = req.body?.registerCaptchaToken || req.body?.captchaToken || "";
if (!registerCaptchaToken) {
return res.status(400)
.json({ error: "Registration requires captcha verification" });
}
const { verifyCaptcha, getRemoteAddress } = await import("../utils/captcha.js");
const remoteIp = getRemoteAddress(req);
const verified = await verifyCaptcha(registerCaptchaToken, captchaConfig.register, remoteIp);
if (!verified) {
return res.status(400)
.json({ error: "Registration captcha verification failed" });
}
}
const passwordHash = await bcrypt.hash(password, 10);
user = await prisma.user.create({
data: {
name: username,
passwordHash,
country: "US", // TODO
droplets: 1000,
currentCharges: 20,
maxCharges: 20,
pixelsPainted: 0,
level: 1,
extraColorsBitmap: 0,
equippedFlag: 0,
chargesLastUpdatedAt: new Date()
}
});
}
const session = await prisma.session.create({
data: {
userId: user.id,
expiresAt: new Date(Date.now() + SESSION_MAX_AGE_SECONDS * 1000)
}
});
const token = jwt.sign(
{
userId: user.id,
sessionId: session.id,
iss: "FurryPlace",
exp: Math.floor(session.expiresAt.getTime() / 1000),
iat: Math.floor(Date.now() / 1000)
},
JWT_SECRET!
);
setSessionCookie(res, token, session.expiresAt);
return res.json({ success: true });
} catch (error) {
console.error("Login error:", error);
return res.status(500)
.json({ error: "Internal Server Error" });
}
});
app.post("/auth/logout", authMiddleware, async (req: any, res: any) => {
try {
if (req.user?.sessionId) {
await prisma.session.delete({
where: { id: req.user.sessionId }
});
}
clearSessionCookie(res);
return res.json({ success: true });
} catch (error) {
console.error("Logout error:", error);
return res.status(500)
.json({ error: "Internal Server Error" });
}
});
}