Add Discord OAuth login with whitelist for user 1207017997173137481

This commit is contained in:
2026-06-30 17:08:23 +02:00
parent db1e9a77d3
commit 2abdb02640
10 changed files with 178 additions and 6 deletions
+19 -3
View File
@@ -13,9 +13,13 @@ db.exec(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
email TEXT UNIQUE,
password_hash TEXT,
discord_id TEXT UNIQUE,
global_name TEXT,
avatar TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS characters (
@@ -100,4 +104,16 @@ db.exec(`
);
`);
// Migrations: add columns that may not exist yet
const migrations = [
"ALTER TABLE users ADD COLUMN email TEXT",
"ALTER TABLE users ADD COLUMN discord_id TEXT UNIQUE",
"ALTER TABLE users ADD COLUMN global_name TEXT",
"ALTER TABLE users ADD COLUMN avatar TEXT",
"ALTER TABLE users ADD COLUMN updated_at TEXT DEFAULT (datetime('now'))",
];
for (const sql of migrations) {
try { db.exec(sql); } catch {}
}
export default db;
+8
View File
@@ -1,11 +1,18 @@
import dotenv from 'dotenv';
import path from 'path';
import { fileURLToPath } from 'url';
import express from 'express';
import cors from 'cors';
import authRoutes from './routes/auth.js';
import discordRoutes from './routes/discord.js';
import characterRoutes from './routes/characters.js';
import lorebookRoutes from './routes/lorebooks.js';
import fragmentRoutes from './routes/fragments.js';
import aiRoutes from './routes/ai.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
dotenv.config({ path: path.resolve(__dirname, '../.env') });
const app = express();
const PORT = process.env.PORT || 3001;
@@ -13,6 +20,7 @@ app.use(cors());
app.use(express.json());
app.use('/api/auth', authRoutes);
app.use('/auth', discordRoutes);
app.use('/api/characters', characterRoutes);
app.use('/api/lorebooks', lorebookRoutes);
app.use('/api/fragments', fragmentRoutes);
+15 -2
View File
@@ -1,16 +1,17 @@
{
"name": "character-sandbox-server",
"name": "thw-server",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "character-sandbox-server",
"name": "thw-server",
"version": "1.0.0",
"dependencies": {
"bcrypt": "^5.1.1",
"better-sqlite3": "^12.11.1",
"cors": "^2.8.5",
"dotenv": "^17.4.2",
"express": "^4.18.2",
"jsonwebtoken": "^9.0.2",
"pg": "^8.11.3",
@@ -452,6 +453,18 @@
"node": ">=8"
}
},
"node_modules/dotenv": {
"version": "17.4.2",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
"integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+1
View File
@@ -12,6 +12,7 @@
"bcrypt": "^5.1.1",
"better-sqlite3": "^12.11.1",
"cors": "^2.8.5",
"dotenv": "^17.4.2",
"express": "^4.18.2",
"jsonwebtoken": "^9.0.2",
"pg": "^8.11.3",
+1 -1
View File
@@ -41,7 +41,7 @@ router.post('/login', (req, res) => {
});
router.get('/me', authenticateToken, (req, res) => {
const user = db.prepare('SELECT id, username, email, created_at FROM users WHERE id = ?').get(req.userId);
const user = db.prepare('SELECT id, username, email, discord_id, global_name, avatar, created_at FROM users WHERE id = ?').get(req.userId);
if (!user) return res.status(404).json({ error: 'User not found' });
res.json({ user });
});
+100
View File
@@ -0,0 +1,100 @@
import { Router } from 'express';
import { v4 as uuidv4 } from 'uuid';
import db from '../db.js';
import { generateToken } from '../middleware/auth.js';
const router = Router();
const DISCORD_API = 'https://discord.com/api';
const DISCORD_CLIENT_ID = process.env.DISCORD_CLIENT_ID;
const DISCORD_CLIENT_SECRET = process.env.DISCORD_CLIENT_SECRET;
const DISCORD_REDIRECT_URI = process.env.DISCORD_REDIRECT_URI;
const WHITELIST = new Set([
'1207017997173137481'
]);
function getRedirectOrigin() {
return process.env.FRONTEND_URL || 'http://localhost:3000';
}
router.get('/discord', (req, res) => {
const next = req.query.next || '/';
const safeNext = next.startsWith('/') && !next.startsWith('//') ? next : '/';
const params = new URLSearchParams({
client_id: DISCORD_CLIENT_ID,
redirect_uri: DISCORD_REDIRECT_URI,
response_type: 'code',
scope: 'identify',
state: safeNext,
});
res.redirect(`${DISCORD_API}/oauth2/authorize?${params.toString()}`);
});
router.get('/discord/callback', async (req, res) => {
const { code, state, error } = req.query;
if (error) {
return res.redirect(`${getRedirectOrigin()}/login?error=${encodeURIComponent(error)}`);
}
if (!code || !state) {
return res.redirect(`${getRedirectOrigin()}/login?error=missing_code_or_state`);
}
try {
const tokenRes = await fetch(`${DISCORD_API}/oauth2/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: DISCORD_CLIENT_ID,
client_secret: DISCORD_CLIENT_SECRET,
grant_type: 'authorization_code',
code,
redirect_uri: DISCORD_REDIRECT_URI,
}).toString(),
});
if (!tokenRes.ok) {
const text = await tokenRes.text();
throw new Error(`Discord token exchange failed: ${tokenRes.status} ${text}`);
}
const token = await tokenRes.json();
const userRes = await fetch(`${DISCORD_API}/users/@me`, {
headers: { Authorization: `Bearer ${token.access_token}` },
});
if (!userRes.ok) {
const text = await userRes.text();
throw new Error(`Discord user fetch failed: ${userRes.status} ${text}`);
}
const discordUser = await userRes.json();
if (!WHITELIST.has(discordUser.id)) {
return res.redirect(`${getRedirectOrigin()}/login?error=not_allowed`);
}
const existing = db.prepare('SELECT id FROM users WHERE discord_id = ?').get(discordUser.id);
let userId;
if (existing) {
userId = existing.id;
db.prepare(
"UPDATE users SET username = ?, global_name = ?, avatar = ?, updated_at = datetime('now') WHERE id = ?"
).run(discordUser.username, discordUser.global_name, discordUser.avatar, userId);
} else {
userId = uuidv4();
db.prepare(
'INSERT INTO users (id, username, global_name, avatar, discord_id) VALUES (?, ?, ?, ?, ?)'
).run(userId, discordUser.username, discordUser.global_name, discordUser.avatar, discordUser.id);
}
const jwt = generateToken(userId);
const redirectTo = state.startsWith('/') ? state : '/';
res.redirect(`${getRedirectOrigin()}${redirectTo}?token=${jwt}`);
} catch (e) {
const msg = e instanceof Error ? e.message : 'unknown error';
res.redirect(`${getRedirectOrigin()}/login?error=${encodeURIComponent(msg.slice(0, 200))}`);
}
});
export default router;