Fix case-insensitive auth and harden for production

- Fix login/register to treat usernames case-insensitively (HeagBoKaT == heagbokat) using LOWER() in SQL with JS .toLowerCase() for Unicode safety
- Add helmet for HTTP security headers and express-rate-limit on auth endpoints (20 req / 15 min) to prevent brute-force
- Lock CORS to ALLOWED_ORIGIN env var; warn if JWT_SECRET is the default dev key
- Serve built client/dist from Express so a single PM2 process handles everything
- Add deploy.sh, README.md with full first-run and auto-restart instructions, and server/.env.example

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-25 23:29:06 +03:00
parent 68c17b16fe
commit e0247bc288
9 changed files with 231 additions and 16 deletions

3
server/.env.example Normal file
View File

@@ -0,0 +1,3 @@
PORT=3040
JWT_SECRET=заменитеа_случайную_строку_32_символа
ALLOWED_ORIGIN=http://cats.heagbokat-server.ru

View File

@@ -10,6 +10,8 @@
"cors": "^2.8.5",
"dotenv": "^16.4.0",
"express": "^4.21.0",
"express-rate-limit": "^8.5.2",
"helmet": "^8.2.0",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"sql.js": "^1.14.1"
@@ -18,6 +20,7 @@
"@types/bcryptjs": "^2.4.6",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/express-rate-limit": "^5.1.3",
"@types/jsonwebtoken": "^9.0.7",
"@types/multer": "^1.4.12",
"@types/node": "^22.0.0",
@@ -518,6 +521,16 @@
"@types/serve-static": "^1"
}
},
"node_modules/@types/express-rate-limit": {
"version": "5.1.3",
"resolved": "https://registry.npmjs.org/@types/express-rate-limit/-/express-rate-limit-5.1.3.tgz",
"integrity": "sha512-H+TYy3K53uPU2TqPGFYaiWc2xJV6+bIFkDd/Ma2/h67Pa6ARk9kWE0p/K9OH1Okm0et9Sfm66fmXoAxsH2PHXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/express": "*"
}
},
"node_modules/@types/express-serve-static-core": {
"version": "4.19.8",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz",
@@ -1031,6 +1044,24 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/express-rate-limit": {
"version": "8.5.2",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz",
"integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==",
"license": "MIT",
"dependencies": {
"ip-address": "^10.2.0"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/express-rate-limit"
},
"peerDependencies": {
"express": ">= 4.11"
}
},
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
@@ -1164,6 +1195,18 @@
"node": ">= 0.4"
}
},
"node_modules/helmet": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.2.0.tgz",
"integrity": "sha512-DRgTIUgnWcJ62KyarxxziuqYxKGnR6Rgg19BlbucN/dpmJbl1XOit6qvoOX0ZT+HhWe5OUVhU/a1zpGyc1xA0Q==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"funding": {
"url": "https://github.com/sponsors/EvanHahn"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -1202,6 +1245,15 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ip-address": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",

View File

@@ -11,6 +11,8 @@
"cors": "^2.8.5",
"dotenv": "^16.4.0",
"express": "^4.21.0",
"express-rate-limit": "^8.5.2",
"helmet": "^8.2.0",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"sql.js": "^1.14.1"
@@ -19,6 +21,7 @@
"@types/bcryptjs": "^2.4.6",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/express-rate-limit": "^5.1.3",
"@types/jsonwebtoken": "^9.0.7",
"@types/multer": "^1.4.12",
"@types/node": "^22.0.0",

View File

@@ -41,7 +41,8 @@ export async function initDb(): Promise<SqlJsDatabase> {
if (!admin) {
const hash = bcrypt.hashSync('admin123', 10);
execute('INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, 1)', ['admin', hash]);
console.log('Admin user created: admin / admin123');
console.warn('⚠️ Admin user created with default password: admin / admin123');
console.warn('⚠️ CHANGE THE ADMIN PASSWORD IMMEDIATELY!');
}
db.run(`

View File

@@ -1,6 +1,8 @@
import 'dotenv/config';
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import rateLimit from 'express-rate-limit';
import path from 'path';
import { fileURLToPath } from 'url';
import { initDb } from './db.js';
@@ -13,15 +15,37 @@ import userRoutes from './routes/users.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DEV_JWT_SECRET = 'catstagram-dev-secret-key-change-in-production';
if (!process.env.JWT_SECRET || process.env.JWT_SECRET === DEV_JWT_SECRET) {
console.warn('⚠️ WARNING: JWT_SECRET not set or using default dev key. Set a strong secret in server/.env for production!');
}
async function main() {
await initDb();
const app = express();
const PORT = parseInt(process.env.PORT || '3040');
app.use(cors());
app.use(helmet({
crossOriginEmbedderPolicy: false,
contentSecurityPolicy: false,
}));
const allowedOrigin = process.env.ALLOWED_ORIGIN || `http://localhost:${PORT}`;
app.use(cors({ origin: allowedOrigin, credentials: true }));
app.use(express.json());
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 20,
message: { error: 'Слишком много попыток, подождите 15 минут' },
standardHeaders: true,
legacyHeaders: false,
});
app.use('/api/auth/login', authLimiter);
app.use('/api/auth/register', authLimiter);
app.use('/uploads', express.static(path.join(__dirname, '..', 'uploads')));
app.use('/api/auth', authRoutes);
@@ -35,6 +59,13 @@ async function main() {
res.json({ status: 'ok' });
});
// Serve built React client
const clientDist = path.join(__dirname, '..', '..', 'client', 'dist');
app.use(express.static(clientDist));
app.get('*', (_req, res) => {
res.sendFile(path.join(clientDist, 'index.html'));
});
app.listen(PORT, () => {
console.log(`Котограм server running on http://localhost:${PORT}`);
});

View File

@@ -53,7 +53,7 @@ router.post('/register', (req: Request, res: Response) => {
return;
}
const existing = queryOne('SELECT id FROM users WHERE username = ?', [username]);
const existing = queryOne('SELECT id FROM users WHERE LOWER(username) = ?', [username.toLowerCase()]);
if (existing) {
res.status(409).json({ error: 'Имя пользователя уже занято' });
return;
@@ -62,7 +62,7 @@ router.post('/register', (req: Request, res: Response) => {
const password_hash = bcrypt.hashSync(password, 10);
execute('INSERT INTO users (username, password_hash) VALUES (?, ?)', [username, password_hash]);
const user = queryOne('SELECT id, username, points, is_admin, avatar_url FROM users WHERE username = ?', [username]);
const user = queryOne('SELECT id, username, points, is_admin, avatar_url FROM users WHERE LOWER(username) = ?', [username.toLowerCase()]);
const token = generateToken(user.id, user.username, !!user.is_admin);
res.status(201).json({ token, user: userToResponse(user) });
@@ -71,7 +71,7 @@ router.post('/register', (req: Request, res: Response) => {
router.post('/login', (req: Request, res: Response) => {
const { username, password } = req.body;
const user = queryOne('SELECT id, username, password_hash, points, is_admin, avatar_url FROM users WHERE username = ?', [username]);
const user = queryOne('SELECT id, username, password_hash, points, is_admin, avatar_url FROM users WHERE LOWER(username) = ?', [username.toLowerCase()]);
if (!user || !bcrypt.compareSync(password, user.password_hash)) {
res.status(401).json({ error: 'Неверное имя пользователя или пароль' });
return;