261 lines
7.1 KiB
TypeScript
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" });
|
|
}
|
|
});
|
|
}
|