Files
youwei-business-school/backend/auth-api.js
2026-03-30 18:30:09 +08:00

288 lines
9.1 KiB
JavaScript

import dotenv from 'dotenv';
import express from 'express';
import cors from 'cors';
import mysql from 'mysql2/promise';
import bcrypt from 'bcryptjs';
import argon2 from 'argon2';
import jwt from 'jsonwebtoken';
dotenv.config();
const app = express();
const port = 3010;
app.use(
cors({
origin(origin, callback) {
if (!origin) {
callback(null, true);
return;
}
callback(null, true);
}
})
);
app.use(express.json());
const pool = mysql.createPool({
host: process.env.DB_HOST || 'nw.sgcode.cn',
port: Number(process.env.DB_PORT || 21434),
user: process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD || 'root',
database: process.env.DB_NAME || 'opencoze',
waitForConnections: true,
connectionLimit: 10
});
async function ensureUsersTable() {
await pool.query(`
CREATE TABLE IF NOT EXISTS \`user\` (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
name VARCHAR(128) NOT NULL DEFAULT '',
unique_name VARCHAR(128) NOT NULL UNIQUE,
email VARCHAR(128) NOT NULL UNIQUE,
password VARCHAR(128) NOT NULL DEFAULT '',
description VARCHAR(512) NOT NULL DEFAULT '',
icon_uri VARCHAR(512) NOT NULL DEFAULT '',
user_verified TINYINT(1) NOT NULL DEFAULT 0,
locale VARCHAR(128) NOT NULL DEFAULT '',
session_key VARCHAR(256) NOT NULL DEFAULT '',
created_at BIGINT UNSIGNED NOT NULL DEFAULT 0,
updated_at BIGINT UNSIGNED NOT NULL DEFAULT 0,
deleted_at BIGINT UNSIGNED NULL DEFAULT NULL,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
}
app.get('/api/health', async (_req, res) => {
try {
await pool.query('SELECT 1');
res.json({ ok: true });
} catch (error) {
console.error('Health check failed:', error);
res.status(500).json({ ok: false, message: 'Database connection failed' });
}
});
app.post('/api/register', async (req, res) => {
let connection;
try {
const { email, password, confirmPassword } = req.body ?? {};
if (!email || !password || !confirmPassword) {
res.status(400).json({ message: 'Email, password, and confirm password are required' });
return;
}
if (password !== confirmPassword) {
res.status(400).json({ message: 'Passwords do not match' });
return;
}
const trimmedEmail = String(email).trim().toLowerCase();
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(trimmedEmail)) {
res.status(400).json({ message: 'Please enter a valid email address' });
return;
}
if (String(password).length < 6) {
res.status(400).json({ message: 'Password must be at least 6 characters' });
return;
}
const [existingRows] = await pool.query('SELECT id FROM `user` WHERE email = ? LIMIT 1', [trimmedEmail]);
if (existingRows.length > 0) {
res.status(409).json({ message: 'Email is already registered' });
return;
}
// New users are stored with Argon2id hashes.
const passwordHash = await argon2.hash(String(password), { type: argon2.argon2id });
const uniqueName = trimmedEmail;
const displayName = trimmedEmail.split('@')[0];
const now = Date.now();
const defaultIconUri = 'default_icon/user_default_icon.png';
const personalSpaceName = 'Personal Space';
const personalSpaceDescription = 'This is your personal space';
connection = await pool.getConnection();
await connection.beginTransaction();
await connection.query(
'INSERT INTO `user` (name, unique_name, email, password, description, icon_uri, user_verified, locale, session_key, created_at, updated_at, deleted_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[displayName, uniqueName, trimmedEmail, passwordHash, '', defaultIconUri, 0, 'zh-CN', '', now, now, null]
);
const [createdUserRows] = await connection.query('SELECT CAST(id AS CHAR) AS id FROM `user` WHERE email = ? LIMIT 1', [
trimmedEmail
]);
const userId = createdUserRows[0]?.id;
if (!userId) {
throw new Error('Failed to resolve created user ID');
}
await connection.query(
'INSERT INTO `space` (owner_id, name, description, icon_uri, creator_id, created_at, updated_at, deleted_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[userId, personalSpaceName, personalSpaceDescription, defaultIconUri, userId, now, now, null]
);
const [createdSpaceRows] = await connection.query(
'SELECT CAST(id AS CHAR) AS id FROM `space` WHERE owner_id = ? ORDER BY created_at DESC, id DESC LIMIT 1',
[userId]
);
const spaceId = createdSpaceRows[0]?.id;
if (!spaceId) {
throw new Error('Failed to resolve created space ID');
}
await connection.query(
'INSERT INTO `space_user` (space_id, user_id, role_type, created_at, updated_at) VALUES (?, ?, ?, ?, ?)',
[spaceId, userId, 1, now, now]
);
await connection.commit();
res.status(201).json({ message: 'Registered successfully' });
} catch (error) {
if (connection) {
await connection.rollback();
}
console.error('Register failed:', error);
res.status(500).json({ message: 'Register failed' });
} finally {
if (connection) {
connection.release();
}
}
});
app.post('/api/login', async (req, res) => {
try {
const { email, password } = req.body ?? {};
if (!email || !password) {
res.status(400).json({ message: 'Email and password are required' });
return;
}
const trimmedEmail = String(email).trim().toLowerCase();
const [rows] = await pool.query('SELECT id, email, password FROM `user` WHERE email = ? LIMIT 1', [trimmedEmail]);
const user = rows[0];
if (!user) {
res.status(401).json({ message: 'Invalid email or password' });
return;
}
const rawPassword = String(password);
const storedPassword = String(user.password || '');
let isPasswordValid = false;
if (storedPassword.startsWith('$argon2')) {
isPasswordValid = await argon2.verify(storedPassword, rawPassword);
} else if (storedPassword.startsWith('$2')) {
isPasswordValid = await bcrypt.compare(rawPassword, storedPassword);
}
if (!isPasswordValid) {
res.status(401).json({ message: 'Invalid email or password' });
return;
}
res.json({
message: 'Login successful',
user: {
id: user.id,
email: user.email
}
});
} catch (error) {
console.error('Login failed:', error);
res.status(500).json({ message: 'Login failed' });
}
});
app.get('/api/coze/space-url', async (req, res) => {
try {
const rawUser = String(req.query.user || req.query.email || '').trim().toLowerCase();
if (!rawUser) {
res.status(400).json({ message: 'user is required' });
return;
}
const [userRows] = await pool.query(
'SELECT CAST(id AS CHAR) AS id, email, unique_name, name FROM `user` WHERE email = ? OR unique_name = ? OR LOWER(name) = ? LIMIT 1',
[rawUser, rawUser, rawUser]
);
const user = userRows[0];
if (!user) {
res.status(404).json({ message: 'User not found' });
return;
}
const [spaceRows] = await pool.query(
'SELECT CAST(s.id AS CHAR) AS id FROM `space` s INNER JOIN `space_user` su ON su.space_id = s.id WHERE su.user_id = ? AND (s.deleted_at IS NULL OR s.deleted_at = 0) ORDER BY su.role_type ASC, su.id DESC LIMIT 1',
[user.id]
);
const space = spaceRows[0];
if (!space) {
res.status(404).json({ message: 'No space found for user' });
return;
}
const url = `http://nw.sgcode.cn:18888/space/${space.id}/develop`;
res.json({ url, spaceId: space.id, user: user.email || user.unique_name || user.name });
} catch (error) {
console.error('Resolve Coze space URL failed:', error);
res.status(500).json({ message: 'Failed to resolve Coze space URL' });
}
});
// Coze SSO: issue short-lived JWT and redirect to Coze SSO exchange endpoint
app.get('/api/coze/sso-login', (req, res) => {
try {
const email = String(req.query.email || req.query.user || '').trim().toLowerCase();
if (!email) {
res.status(400).json({ message: 'email is required' });
return;
}
const secret = process.env.SSO_SHARED_SECRET;
const cozeHost = process.env.COZE_HOST || 'http://nw.sgcode.cn:18888';
if (!secret) {
res.status(500).json({ message: 'SSO is not configured on server (missing SSO_SHARED_SECRET)' });
return;
}
const token = jwt.sign(
{ email },
secret,
{ algorithm: 'HS256', expiresIn: 300 } // 5 minutes
);
const nextPath = encodeURIComponent('/space');
const redirectUrl = `${cozeHost.replace(/\/+$/, '')}/api/passport/web/sso/exchange/?token=${encodeURIComponent(
token
)}&next=${nextPath}`;
res.redirect(302, redirectUrl);
} catch (error) {
console.error('Coze SSO login failed:', error);
res.status(500).json({ message: 'Coze SSO login failed' });
}
});
ensureUsersTable()
.then(() => {
app.listen(port, () => {
console.log(`Auth API running at http://localhost:${port}`);
});
})
.catch((error) => {
console.error('Failed to initialize database:', error);
process.exit(1);
});