File size: 5,351 Bytes
5ef6e9d | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 | import { Router } from "express";
import bcrypt from "bcryptjs";
import jwt from "jsonwebtoken";
import { db, usersTable, configTable, creditTransactionsTable } from "@workspace/db";
import { eq, count } from "drizzle-orm";
const router = Router();
const JWT_SECRET = process.env.SESSION_SECRET || "dev-secret-change-me";
const COOKIE_NAME = "sf_auth";
const COOKIE_OPTS = {
httpOnly: true,
sameSite: "lax" as const,
secure: process.env.NODE_ENV === "production",
maxAge: 30 * 24 * 60 * 60 * 1000,
path: "/",
};
function signToken(userId: number, email: string) {
return jwt.sign({ userId, email }, JWT_SECRET, { expiresIn: "30d" });
}
async function isFirstUser(): Promise<boolean> {
const [row] = await db.select({ count: count() }).from(usersTable);
return Number(row.count) === 0;
}
router.post("/signup", async (req, res) => {
const { email, password, displayName } = req.body as {
email?: string;
password?: string;
displayName?: string;
};
if (!email || !password) {
return res.status(400).json({ error: "Email and password are required" });
}
if (password.length < 6) {
return res.status(400).json({ error: "Password must be at least 6 characters" });
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return res.status(400).json({ error: "Invalid email format" });
}
const existing = await db.select({ id: usersTable.id }).from(usersTable).where(eq(usersTable.email, email.toLowerCase())).limit(1);
if (existing.length > 0) {
return res.status(409).json({ error: "Email already registered" });
}
const firstUser = await isFirstUser();
const passwordHash = await bcrypt.hash(password, 12);
const [user] = await db.insert(usersTable).values({
email: email.toLowerCase(),
passwordHash,
displayName: displayName?.trim() || null,
isAdmin: firstUser,
}).returning({
id: usersTable.id,
email: usersTable.email,
displayName: usersTable.displayName,
isAdmin: usersTable.isAdmin,
});
// Grant signup credits from config
const signupCreditRow = await db.select({ value: configTable.value }).from(configTable).where(eq(configTable.key, "signup_credits")).limit(1);
const signupCredits = Number(signupCreditRow[0]?.value) || 0;
if (signupCredits > 0) {
await db.update(usersTable).set({ credits: signupCredits }).where(eq(usersTable.id, user.id));
await db.insert(creditTransactionsTable).values({ userId: user.id, amount: signupCredits, type: "signup", description: "新用戶註冊贈點" });
}
const token = signToken(user.id, user.email);
res.cookie(COOKIE_NAME, token, COOKIE_OPTS);
res.json({ id: user.id, email: user.email, displayName: user.displayName, isAdmin: user.isAdmin, credits: signupCredits });
});
router.post("/login", async (req, res) => {
const { email, password } = req.body as { email?: string; password?: string };
if (!email || !password) {
return res.status(400).json({ error: "Email and password are required" });
}
const [user] = await db.select().from(usersTable).where(eq(usersTable.email, email.toLowerCase())).limit(1);
if (!user) {
return res.status(401).json({ error: "Invalid email or password" });
}
const valid = await bcrypt.compare(password, user.passwordHash);
if (!valid) {
return res.status(401).json({ error: "Invalid email or password" });
}
const token = signToken(user.id, user.email);
res.cookie(COOKIE_NAME, token, COOKIE_OPTS);
res.json({ id: user.id, email: user.email, displayName: user.displayName, isAdmin: user.isAdmin });
});
router.post("/logout", (_req, res) => {
res.clearCookie(COOKIE_NAME, { path: "/" });
res.json({ success: true });
});
router.get("/me", async (req, res) => {
const token = req.cookies?.[COOKIE_NAME];
if (!token) return res.status(401).json({ error: "Not authenticated" });
try {
const payload = jwt.verify(token, JWT_SECRET) as { userId: number; email: string };
const [user] = await db
.select({ id: usersTable.id, email: usersTable.email, displayName: usersTable.displayName, isAdmin: usersTable.isAdmin })
.from(usersTable)
.where(eq(usersTable.id, payload.userId))
.limit(1);
if (!user) {
res.clearCookie(COOKIE_NAME, { path: "/" });
return res.status(401).json({ error: "User not found" });
}
res.json(user);
} catch {
res.clearCookie(COOKIE_NAME, { path: "/" });
res.status(401).json({ error: "Session expired" });
}
});
export function requireJwtAuth(req: any, res: any, next: any) {
const token = req.cookies?.[COOKIE_NAME];
if (!token) return res.status(401).json({ error: "Unauthorized" });
try {
const payload = jwt.verify(token, JWT_SECRET) as { userId: number; email: string };
req.jwtUserId = payload.userId;
req.jwtEmail = payload.email;
next();
} catch {
res.clearCookie(COOKIE_NAME, { path: "/" });
res.status(401).json({ error: "Session expired" });
}
}
export function optionalJwtAuth(req: any, _res: any, next: any) {
const token = req.cookies?.[COOKIE_NAME];
if (token) {
try {
const payload = jwt.verify(token, JWT_SECRET) as { userId: number; email: string };
req.jwtUserId = payload.userId;
req.jwtEmail = payload.email;
} catch {
// invalid token, continue as guest
}
}
next();
}
export default router;
|