From 9329b76e9b040aebd145b7222f72f8702d5cbf73 Mon Sep 17 00:00:00 2001 From: ZOMBIIIIIII <120676065+Metaton241@users.noreply.github.com> Date: Mon, 20 Apr 2026 17:39:38 +0300 Subject: [PATCH] chore: initial deploy bundle --- .env.example | 79 +++-------- README.md | 40 ++++-- apps/api/package.json | 5 +- apps/api/src/app.ts | 23 ++-- apps/api/src/config/env.ts | 78 ++--------- apps/api/src/db/knexfile.ts | 23 ---- .../api/src/db/migrations/001_create_users.ts | 28 ---- .../src/db/migrations/002_create_wallets.ts | 20 --- .../src/db/migrations/003_create_sessions.ts | 26 ---- apps/api/src/index.ts | 43 +++--- apps/api/src/middleware/csrf.ts | 36 +++++ apps/api/src/middleware/validate.ts | 17 --- apps/api/src/services/csrf.service.ts | 130 ++++++++++++++++++ apps/api/src/services/key-rotation.service.ts | 70 ++++++++++ db/schema.sql | 63 +++++++++ pnpm-lock.yaml | 8 -- 16 files changed, 386 insertions(+), 303 deletions(-) delete mode 100644 apps/api/src/db/knexfile.ts delete mode 100644 apps/api/src/db/migrations/001_create_users.ts delete mode 100644 apps/api/src/db/migrations/002_create_wallets.ts delete mode 100644 apps/api/src/db/migrations/003_create_sessions.ts create mode 100644 apps/api/src/middleware/csrf.ts delete mode 100644 apps/api/src/middleware/validate.ts create mode 100644 apps/api/src/services/csrf.service.ts create mode 100644 apps/api/src/services/key-rotation.service.ts create mode 100644 db/schema.sql diff --git a/.env.example b/.env.example index 4ac732a..bfe8b4d 100644 --- a/.env.example +++ b/.env.example @@ -1,20 +1,4 @@ -# PostgreSQL -# Для локального dev: DB_HOST=localhost -# Для Docker Compose: DB_HOST переопределяется на 'postgres' в docker-compose.yml -DB_HOST=localhost -DB_PORT=5432 -DB_USER=postgres -DB_PASSWORD=postgres -DB_NAME=cryptowallet_v2 - -# Database Pool -DATABASE_POOL_SIZE=10 -DATABASE_MAX_OVERFLOW=20 -DATABASE_POOL_TIMEOUT=30 -DATABASE_POOL_RECYCLE=3600 -DATABASE_ECHO=false - -# Vault (AppRole auth) +# ── Vault (AppRole) ──────────────────────────────────────────────── VAULT_ADDR= VAULT_ROLE_ID= VAULT_SECRET_ID= @@ -22,55 +6,28 @@ VAULT_MOUNT_POINT=dev-secrets VAULT_SECRET_PATH=database VAULT_JWT_KID_PATH=jwt/kid VAULT_JWT_KIDS_PREFIX=jwt/kids +VAULT_CSRF_PATH=csrf -# CSRF -CSRF_COOKIE_SECURE=false -CSRF_COOKIE_HTTPONLY=true -CSRF_COOKIE_SAMESITE=Lax -CSRF_COOKIE_PATH=/ -CSRF_COOKIE_DOMAIN= - -# JWT +# ── JWT ──────────────────────────────────────────────────────────── JWT_ALGORITHM=RS256 -JWT_ACCESS_TTL_SECONDS=900 -JWT_REFRESH_TTL_SECONDS=2592000 JWT_ISSUER=auth-service -JWT_AUDIENCE=wallet-service +JWT_AUDIENCE=elcsa -# Docs -DOCS_USERNAME=admin -DOCS_PASSWORD=admin - -# Redis -REDIS_HOST=keydb -REDIS_PORT=6379 -REDIS_PASSWORD=keydb -REDIS_DB=0 - -# RabbitMQ -RABBIT_EMAIL_CODE_QUEUE=email.verification_code -RABBIT_PUBLISH_PERSIST=true -RABBIT_CONNECT_TIMEOUT=5 - -# Logging -LOG_LEVEL=INFO -LOG_FORMAT=JSON - -# CORS -CORS_ORIGINS=http://localhost:3000,http://localhost:8000 -CORS_ALLOW_CREDENTIALS=true - -# Rate Limiting -RATE_LIMIT_REQUESTS=60 -RATE_LIMIT_WINDOW=60 - -# Server +# ── Server ───────────────────────────────────────────────────────── API_PORT=3001 -FRONTEND_URL=http://localhost:3000 +CORS_ORIGINS=http://localhost:3000 +LOG_LEVEL=INFO + +# ── External API keys (fallback, обычно приходят из Vault) ───────── RELAY_API_KEY= - -# TRON TRON_API_KEY= - -# Jupiter (Solana DEX aggregator) JUPITER_API_KEY= +JUPITER_REFERRAL_ACCOUNT= +JUPITER_FEE_BPS=70 + +# ── DB fallback (используется если Vault недоступен при старте) ──── +DB_HOST= +DB_PORT=5432 +DB_USER= +DB_PASSWORD= +DB_NAME= diff --git a/README.md b/README.md index 972a3b7..2b52b22 100644 --- a/README.md +++ b/README.md @@ -7,10 +7,12 @@ ``` deployserver/ ├── Dockerfile # Multi-stage production build -├── docker-compose.yml # PostgreSQL + API +├── docker-compose.yml # Только API (БД внешняя, из Vault) ├── .env.example # Шаблон переменных окружения ├── .dockerignore ├── start.sh # Автоматический deploy скрипт +├── db/ +│ └── schema.sql # DDL таблиц (users, wallets, sessions) — идемпотентный ├── apps/api/ # Исходник API │ ├── src/ │ ├── package.json @@ -27,11 +29,12 @@ deployserver/ - Ubuntu 20.04+ / Debian 11+ / любой Linux с Docker 24+ - Docker Compose plugin (`docker compose` команда) - Исходящий HTTPS на Vault (`corp.vault.elcsa.ru:443`) +- Сетевой доступ к PostgreSQL (адрес приходит из Vault) ## Быстрый старт ```bash -# 1. Скопировать папку на сервер (или git clone и cd deployserver) +# 1. Скопировать папку на сервер scp -r deployserver user@server:/opt/cryptowallet ssh user@server cd /opt/cryptowallet @@ -45,11 +48,15 @@ newgrp docker cp .env.example .env nano .env # заполнить VAULT_ROLE_ID, VAULT_SECRET_ID -# 4. Запустить +# 4. Применить схему БД (один раз, на пустой БД) +sudo apt install -y postgresql-client +PGPASSWORD=<пароль_из_Vault> psql -h -U -d -f db/schema.sql + +# 5. Запустить chmod +x start.sh ./start.sh -# 5. Открыть порт наружу +# 6. Открыть порт наружу sudo ufw allow 22/tcp sudo ufw allow 3001/tcp sudo ufw enable @@ -71,29 +78,32 @@ Swagger UI: `http://:3001/api/docs` | Порт | Назначение | Открыть наружу? | |------|-----------|-----------------| | 3001 | API HTTP | ✅ да (`ufw allow 3001`) | -| 5432 | PostgreSQL | ❌ нет (только docker network) | | 443 (out) | Vault | исходящий, обычно открыт | +| 5432 (out) | PostgreSQL | исходящий к внешнему адресу БД | ## Управление ```bash docker compose logs -f api # смотреть логи -docker compose restart api # рестарт +docker compose restart api # рестарт (например после смены .env) docker compose down # остановить -docker compose down -v # + удалить БД (ОСТОРОЖНО) docker compose ps # статус -docker compose exec postgres psql -U postgres cryptowallet_v2 # подключиться к БД +docker compose up -d --build # пересобрать и запустить (после обновления кода) ``` ## Обновление ```bash -# Скопировать новую версию deployserver/ +# Скопировать новую версию deployserver/ (или git pull) docker compose build --pull api docker compose up -d ``` -Миграции применятся автоматически при старте API. +Схема БД не применяется автоматически — если добавились новые таблицы/колонки, выполни `schema.sql` вручную (он идемпотентный, безопасно запускать повторно). + +## Ротация ключей + +JWT public keys и CSRF secret читаются из Vault при старте и **каждый час** обновляются автоматически (см. `key-rotation.service.ts`). При ошибках Vault сервис продолжает работать со старыми ключами — в логах будет `ERROR: Failed to refresh ...`. ## Безопасность Dockerfile @@ -110,9 +120,12 @@ docker compose up -d - Проверь VAULT_ROLE_ID / VAULT_SECRET_ID в .env - Проверь доступ: `curl -v https://corp.vault.elcsa.ru/v1/sys/health` -**API рестартуется в цикле** -- `docker compose logs api` — смотри ошибку -- Скорее всего БД не поднялась: `docker compose logs postgres` +**`password authentication failed for user "postgres_user"`** +- Креды в `.env` не совпадают с тем что в Vault (или с реальной БД) +- Решение: либо подставь пароль из Vault в `.env`, либо оставь пустыми — Vault перекроет при логине + +**Таблицы не существуют (relation does not exist)** +- Не применён `db/schema.sql` — см. шаг 4 в Quick Start **Port 3001 занят** - `sudo lsof -i :3001` @@ -120,7 +133,6 @@ docker compose up -d **Нет места на диске** - `docker system prune -a` — удалит старые образы -- `docker compose logs --tail=0 --no-log-prefix > /dev/null` — логи ротейтятся автоматически ## Автозапуск при reboot diff --git a/apps/api/package.json b/apps/api/package.json index 9677106..dac6031 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -6,8 +6,6 @@ "dev": "ts-node-dev --respawn --transpile-only src/index.ts", "build": "tsc", "start": "node dist/index.js", - "migrate": "node --require ts-node/register node_modules/knex/bin/cli.js migrate:latest --knexfile src/db/knexfile.ts", - "migrate:rollback": "node --require ts-node/register node_modules/knex/bin/cli.js migrate:rollback --knexfile src/db/knexfile.ts", "typecheck": "tsc --noEmit", "lint": "eslint src/ --ext .ts" }, @@ -22,8 +20,7 @@ "knex": "^3.1.0", "pg": "^8.13.0", "swagger-ui-express": "^5.0.1", - "ulidx": "^2.4.1", - "zod": "^3.23.0" + "ulidx": "^2.4.1" }, "devDependencies": { "@types/cookie-parser": "^1.4.7", diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 6362bcd..b45578a 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -7,6 +7,7 @@ import { env } from './config/env'; import { swaggerSpec } from './config/swagger'; import { traceMiddleware } from './middleware/trace'; import { authMiddleware } from './middleware/auth'; +import { csrfMiddleware } from './middleware/csrf'; import { errorHandler } from './middleware/error-handler'; import walletRoutes from './routes/wallet.routes'; import relayProxyRoutes from './routes/relay-proxy.routes'; @@ -19,12 +20,12 @@ import bscSwapProxyRoutes from './routes/bsc-swap-proxy.routes'; const app = express(); app.use(helmet()); -app.use(cors({ origin: env.frontendUrl, credentials: true })); +app.use(cors({ origin: env.cors.origins, credentials: env.cors.allowCredentials })); app.use(express.json()); app.use(cookieParser()); app.use(traceMiddleware); -// ── PUBLIC endpoints (no auth) ──────────────────────────────────────────────── +// ── PUBLIC endpoints ───────────────────────────────────────────────────────── app.get('/api/health', (_req, res) => { res.json({ success: true, data: { status: 'ok' } }); }); @@ -34,14 +35,16 @@ app.get('/api/docs/swagger.json', (_req, res) => { res.json(swaggerSpec); }); -// ── PROTECTED endpoints (JWT required) ──────────────────────────────────────── -app.use('/api/wallets', authMiddleware, walletRoutes); -app.use('/api/relay', authMiddleware, relayProxyRoutes); -app.use('/api/tron', authMiddleware, tronProxyRoutes); -app.use('/api/sol/swap', authMiddleware, solSwapProxyRoutes); -app.use('/api/tron/swap', authMiddleware, tronSwapProxyRoutes); -app.use('/api/btc', authMiddleware, btcProxyRoutes); -app.use('/api/bsc/swap', authMiddleware, bscSwapProxyRoutes); +// ── PROTECTED endpoints (JWT + CSRF for mutating methods) ──────────────────── +const protect = [authMiddleware, csrfMiddleware]; + +app.use('/api/wallets', ...protect, walletRoutes); +app.use('/api/relay', ...protect, relayProxyRoutes); +app.use('/api/tron', ...protect, tronProxyRoutes); +app.use('/api/sol/swap', ...protect, solSwapProxyRoutes); +app.use('/api/tron/swap', ...protect, tronSwapProxyRoutes); +app.use('/api/btc', ...protect, btcProxyRoutes); +app.use('/api/bsc/swap', ...protect, bscSwapProxyRoutes); app.use(errorHandler); diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts index 54fba82..7ab06c6 100644 --- a/apps/api/src/config/env.ts +++ b/apps/api/src/config/env.ts @@ -9,23 +9,16 @@ const p = process.env; export let env = { db: { - host: p.DB_HOST || 'localhost', + host: p.DB_HOST || '', port: parseInt(p.DB_PORT || '5432'), - user: p.DB_USER || 'postgres', - password: p.DB_PASSWORD || 'postgres', - name: p.DB_NAME || 'cryptowallet_v2', - poolSize: parseInt(p.DATABASE_POOL_SIZE || '10'), - maxOverflow: parseInt(p.DATABASE_MAX_OVERFLOW || '20'), - poolTimeout: parseInt(p.DATABASE_POOL_TIMEOUT || '30'), - poolRecycle: parseInt(p.DATABASE_POOL_RECYCLE || '3600'), - echo: p.DATABASE_ECHO === 'true', + user: p.DB_USER || '', + password: p.DB_PASSWORD || '', + name: p.DB_NAME || '', }, jwt: { algorithm: p.JWT_ALGORITHM || 'RS256', issuer: p.JWT_ISSUER || 'auth-service', - audience: p.JWT_AUDIENCE || 'bitforce', - accessTtl: parseInt(p.JWT_ACCESS_TTL_SECONDS || '900'), - refreshTtl: parseInt(p.JWT_REFRESH_TTL_SECONDS || '2592000'), + audience: p.JWT_AUDIENCE || 'elcsa', }, vault: { addr: p.VAULT_ADDR || '', @@ -35,43 +28,13 @@ export let env = { secretPath: p.VAULT_SECRET_PATH || 'database', jwtKidPath: p.VAULT_JWT_KID_PATH || 'jwt/kid', jwtKidsPrefix: p.VAULT_JWT_KIDS_PREFIX || 'jwt/kids', - }, - csrf: { - cookieSecure: p.CSRF_COOKIE_SECURE === 'true', - cookieHttpOnly: p.CSRF_COOKIE_HTTPONLY !== 'false', - cookieSameSite: p.CSRF_COOKIE_SAMESITE || 'Lax', - cookiePath: p.CSRF_COOKIE_PATH || '/', - cookieDomain: p.CSRF_COOKIE_DOMAIN || '', - }, - docs: { - username: p.DOCS_USERNAME || 'admin', - password: p.DOCS_PASSWORD || 'admin', - }, - redis: { - host: p.REDIS_HOST || 'keydb', - port: parseInt(p.REDIS_PORT || '6379'), - password: p.REDIS_PASSWORD || 'keydb', - db: parseInt(p.REDIS_DB || '0'), - }, - rabbit: { - emailCodeQueue: p.RABBIT_EMAIL_CODE_QUEUE || 'email.verification_code', - publishPersist: p.RABBIT_PUBLISH_PERSIST !== 'false', - connectTimeout: parseInt(p.RABBIT_CONNECT_TIMEOUT || '5'), - }, - log: { - level: p.LOG_LEVEL || 'INFO', - format: p.LOG_FORMAT || 'JSON', + csrfPath: p.VAULT_CSRF_PATH || 'csrf', }, cors: { origins: (p.CORS_ORIGINS || 'http://localhost:3000').split(','), allowCredentials: p.CORS_ALLOW_CREDENTIALS !== 'false', }, - rateLimit: { - requests: parseInt(p.RATE_LIMIT_REQUESTS || '60'), - window: parseInt(p.RATE_LIMIT_WINDOW || '60'), - }, port: parseInt(p.API_PORT || '3001'), - frontendUrl: p.FRONTEND_URL || 'http://localhost:3000', relayApiKey: p.RELAY_API_KEY || null, tronApiKey: p.TRON_API_KEY || null, jupiterApiKey: p.JUPITER_API_KEY || null, @@ -116,41 +79,26 @@ export async function initEnv(): Promise { return v ? parseInt(v) : fallback; }; + // Vault stores DB secrets in lowercase (host, user, password, name, port). + // Accept uppercase DB_* as fallback for compatibility. env = { ...env, db: { - host: s('DB_HOST') || env.db.host, - port: si('DB_PORT', env.db.port), - user: s('DB_USER') || env.db.user, - password: s('DB_PASSWORD') || env.db.password, - name: s('DB_NAME') || env.db.name, - poolSize: si('DATABASE_POOL_SIZE', env.db.poolSize), - maxOverflow: si('DATABASE_MAX_OVERFLOW', env.db.maxOverflow), - poolTimeout: si('DATABASE_POOL_TIMEOUT', env.db.poolTimeout), - poolRecycle: si('DATABASE_POOL_RECYCLE', env.db.poolRecycle), - echo: secrets['DATABASE_ECHO'] === 'true', + host: s('host') || s('DB_HOST') || env.db.host, + port: si('port', si('DB_PORT', env.db.port)), + user: s('user') || s('DB_USER') || env.db.user, + password: s('password') || s('DB_PASSWORD') || env.db.password, + name: s('name') || s('DB_NAME') || env.db.name, }, jwt: { ...env.jwt, issuer: s('JWT_ISSUER') || env.jwt.issuer, audience: s('JWT_AUDIENCE') || env.jwt.audience, - accessTtl: si('JWT_ACCESS_TTL_SECONDS', env.jwt.accessTtl), - refreshTtl: si('JWT_REFRESH_TTL_SECONDS', env.jwt.refreshTtl), - }, - redis: { - host: s('REDIS_HOST') || env.redis.host, - port: si('REDIS_PORT', env.redis.port), - password: s('REDIS_PASSWORD') || env.redis.password, - db: si('REDIS_DB', env.redis.db), }, cors: { origins: s('CORS_ORIGINS') ? s('CORS_ORIGINS')!.split(',') : env.cors.origins, allowCredentials: secrets['CORS_ALLOW_CREDENTIALS'] !== 'false', }, - rateLimit: { - requests: si('RATE_LIMIT_REQUESTS', env.rateLimit.requests), - window: si('RATE_LIMIT_WINDOW', env.rateLimit.window), - }, relayApiKey: s('RELAY_API_KEY') || env.relayApiKey, tronApiKey: s('TRON_API_KEY') || env.tronApiKey, jupiterApiKey: s('JUPITER_API_KEY') || env.jupiterApiKey, diff --git a/apps/api/src/db/knexfile.ts b/apps/api/src/db/knexfile.ts deleted file mode 100644 index 03d53be..0000000 --- a/apps/api/src/db/knexfile.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { Knex } from 'knex'; -import path from 'path'; -import dotenv from 'dotenv'; - -// Load .env from repo root when running migrations directly -dotenv.config({ path: path.resolve(__dirname, '../../../../.env') }); - -const config: Knex.Config = { - client: 'pg', - connection: { - host: process.env.DB_HOST || 'localhost', - port: parseInt(process.env.DB_PORT || '5432'), - user: process.env.DB_USER || 'postgres', - password: process.env.DB_PASSWORD || 'postgres', - database: process.env.DB_NAME || 'cryptowallet_v2', - }, - migrations: { - directory: path.resolve(__dirname, 'migrations'), - extension: __filename.endsWith('.js') ? 'js' : 'ts', - }, -}; - -export default config; diff --git a/apps/api/src/db/migrations/001_create_users.ts b/apps/api/src/db/migrations/001_create_users.ts deleted file mode 100644 index 6d2c69b..0000000 --- a/apps/api/src/db/migrations/001_create_users.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { Knex } from 'knex'; - -export async function up(knex: Knex): Promise { - await knex.schema.createTable('users', (t) => { - t.string('id', 26).primary(); - t.string('email', 255).notNullable().unique(); - t.string('password_hash', 255).notNullable(); - t.string('last_name', 128).nullable(); - t.string('first_name', 128).nullable(); - t.string('middle_name', 128).nullable(); - t.date('birth_date').nullable(); - t.string('crypto_wallet', 255).nullable(); - t.string('phone', 16).nullable(); - t.string('bik', 9).nullable(); - t.string('account_number', 20).nullable(); - t.string('card_number', 19).nullable(); - t.string('inn', 12).nullable(); - t.boolean('kyc_verified').notNullable().defaultTo(false); - t.timestamp('kyc_verified_at', { useTz: true }).nullable(); - t.boolean('is_deleted').notNullable().defaultTo(false); - t.timestamp('created_at', { useTz: true }).notNullable().defaultTo(knex.fn.now()); - t.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(knex.fn.now()); - }); -} - -export async function down(knex: Knex): Promise { - await knex.schema.dropTableIfExists('users'); -} diff --git a/apps/api/src/db/migrations/002_create_wallets.ts b/apps/api/src/db/migrations/002_create_wallets.ts deleted file mode 100644 index bb8fd95..0000000 --- a/apps/api/src/db/migrations/002_create_wallets.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { Knex } from 'knex'; - -export async function up(knex: Knex): Promise { - await knex.schema.createTable('wallets', (t) => { - t.string('id', 26).primary(); - t.string('user_id', 26).notNullable().references('id').inTable('users').onDelete('CASCADE'); - t.string('chain', 10).notNullable(); - t.string('address', 256).notNullable(); - t.string('derivation_path', 64).notNullable(); - t.timestamp('created_at', { useTz: true }).notNullable().defaultTo(knex.fn.now()); - t.unique(['user_id', 'chain']); - }); - - await knex.schema.raw('CREATE INDEX idx_wallets_user_id ON wallets(user_id)'); - await knex.schema.raw('CREATE INDEX idx_wallets_address ON wallets(address)'); -} - -export async function down(knex: Knex): Promise { - await knex.schema.dropTableIfExists('wallets'); -} diff --git a/apps/api/src/db/migrations/003_create_sessions.ts b/apps/api/src/db/migrations/003_create_sessions.ts deleted file mode 100644 index 86929e9..0000000 --- a/apps/api/src/db/migrations/003_create_sessions.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { Knex } from 'knex'; - -export async function up(knex: Knex): Promise { - await knex.schema.createTable('sessions', (t) => { - t.string('id', 26).primary(); - t.string('sid', 26).notNullable().unique(); - t.string('user_id', 26).notNullable().references('id').inTable('users').onDelete('CASCADE'); - t.string('device_id', 26).nullable(); - t.string('user_agent', 500).nullable(); - t.string('first_ip', 64).nullable(); - t.string('last_ip', 64).nullable(); - t.timestamp('last_seen_at', { useTz: true }).nullable(); - t.timestamp('revoked_at', { useTz: true }).nullable(); - t.string('refresh_jti_hash', 255).nullable(); - t.timestamp('refresh_expires_at', { useTz: true }).nullable(); - t.timestamp('created_at', { useTz: true }).notNullable().defaultTo(knex.fn.now()); - t.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(knex.fn.now()); - }); - - await knex.schema.raw('CREATE INDEX idx_sessions_user_id ON sessions(user_id)'); - await knex.schema.raw('CREATE INDEX idx_sessions_sid ON sessions(sid)'); -} - -export async function down(knex: Knex): Promise { - await knex.schema.dropTableIfExists('sessions'); -} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index c2d4285..d86ed2d 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,40 +1,29 @@ -import knex from 'knex'; -import knexConfig from './db/knexfile'; import app from './app'; -import { env, initEnv, getVaultToken } from './config/env'; -import { loadJwtKeysFromVault } from './services/jwt.service'; +import { env, initEnv } from './config/env'; +import { refreshAllKeys, startKeyRotation, stopKeyRotation } from './services/key-rotation.service'; import { logger } from './lib/logger'; async function main() { logger.info(`Wallet service instance started with id ${logger.instanceId}`); await initEnv(); + await refreshAllKeys(); + startKeyRotation(); - // Load JWT public keys from Vault if available - const vaultToken = getVaultToken(); - if (vaultToken && env.vault.addr) { - await loadJwtKeysFromVault( - env.vault.addr, - vaultToken, - env.vault.mount, - env.vault.jwtKidPath, - env.vault.jwtKidsPrefix, - ); - } else { - logger.warn('JWT keys not loaded: Vault not available'); - } - - const db = knex(knexConfig); - - logger.info('Running migrations...'); - await db.migrate.latest(); - logger.info('Migrations complete'); - - await db.destroy(); - - app.listen(env.port, () => { + const server = app.listen(env.port, () => { logger.info(`Server running on port ${env.port}`); }); + + const shutdown = (signal: string) => { + logger.info(`${signal} received, shutting down gracefully`); + stopKeyRotation(); + server.close(() => process.exit(0)); + // Force exit if shutdown takes too long + setTimeout(() => process.exit(1), 10_000).unref(); + }; + + process.on('SIGTERM', () => shutdown('SIGTERM')); + process.on('SIGINT', () => shutdown('SIGINT')); } main().catch((err) => { diff --git a/apps/api/src/middleware/csrf.ts b/apps/api/src/middleware/csrf.ts new file mode 100644 index 0000000..aead5db --- /dev/null +++ b/apps/api/src/middleware/csrf.ts @@ -0,0 +1,36 @@ +import { Request, Response, NextFunction } from 'express'; +import { verifyCsrfToken, isCsrfConfigured } from '../services/csrf.service'; +import { logger } from '../lib/logger'; + +const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); + +export function csrfMiddleware(req: Request, res: Response, next: NextFunction): void { + if (SAFE_METHODS.has(req.method)) { + next(); + return; + } + + // If CSRF is not configured (Vault down при старте) — пропускаем, чтобы не блокировать сервис. + // В логах будет warning — легко заметить. + if (!isCsrfConfigured()) { + logger.warn('CSRF check skipped: secret not loaded'); + next(); + return; + } + + const token = req.cookies?.csrf_token || req.headers['x-csrf-token']; + + if (!token || typeof token !== 'string') { + res.status(403).json({ success: false, error: 'CSRF token missing' }); + return; + } + + const result = verifyCsrfToken(token); + if (!result.valid) { + logger.warn(`CSRF validation failed: ${result.reason}`); + res.status(403).json({ success: false, error: 'Invalid CSRF token' }); + return; + } + + next(); +} diff --git a/apps/api/src/middleware/validate.ts b/apps/api/src/middleware/validate.ts deleted file mode 100644 index e42bc76..0000000 --- a/apps/api/src/middleware/validate.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Request, Response, NextFunction } from 'express'; -import { ZodSchema } from 'zod'; - -export function validate(schema: ZodSchema) { - return (req: Request, res: Response, next: NextFunction): void => { - const result = schema.safeParse(req.body); - if (!result.success) { - res.status(400).json({ - success: false, - error: result.error.errors.map((e) => e.message).join(', '), - }); - return; - } - req.body = result.data; - next(); - }; -} diff --git a/apps/api/src/services/csrf.service.ts b/apps/api/src/services/csrf.service.ts new file mode 100644 index 0000000..3f6e878 --- /dev/null +++ b/apps/api/src/services/csrf.service.ts @@ -0,0 +1,130 @@ +import crypto from 'crypto'; +import { logger } from '../lib/logger'; + +/** + * CSRF token validation compatible with Python's `itsdangerous` + * `URLSafeTimedSerializer` (which Flask-WTF uses). + * + * Token format: .. + * + * Default algorithm (itsdangerous ≥ 2.0): + * - digest: SHA-512 (HMAC) + * - salt: "itsdangerous.Signer" (or app-specific, e.g. "csrf-token") + * - derived_key = HMAC(secret, salt + "signer").digest() + * - signature = HMAC(derived_key, payload + "." + timestamp).digest() + * + * Timestamp: seconds since 2011-01-01 (itsdangerous epoch), base64url-encoded big-endian. + */ + +const ITSDANGEROUS_EPOCH = 1293840000; // 2011-01-01 UTC in unix time + +let csrfSecret: string | null = null; +let csrfSalt = 'itsdangerous.Signer'; +let csrfDigest: 'sha1' | 'sha256' | 'sha512' = 'sha512'; +let csrfMaxAgeSec = 60 * 60 * 24 * 7; // 7 days + +export async function loadCsrfSecret( + addr: string, + token: string, + mount: string, + path: string, +): Promise { + const { fetchVaultKV2 } = await import('../config/vault'); + + const secrets = await fetchVaultKV2(addr, token, mount, path); + if (!secrets) { + logger.warn('Failed to load CSRF secret from Vault'); + return; + } + + const secret = secrets.secret_key || secrets.SECRET_KEY || secrets.key || secrets.csrf_secret; + if (!secret) { + logger.warn('CSRF secret not found in Vault payload (expected key: secret_key)'); + return; + } + + csrfSecret = secret; + if (secrets.salt) csrfSalt = secrets.salt; + if (secrets.digest === 'sha1' || secrets.digest === 'sha256' || secrets.digest === 'sha512') { + csrfDigest = secrets.digest as 'sha1' | 'sha256' | 'sha512'; + } + if (secrets.max_age_sec) { + const n = parseInt(secrets.max_age_sec); + if (!Number.isNaN(n) && n > 0) csrfMaxAgeSec = n; + } + + logger.info(`CSRF secret loaded (digest=${csrfDigest}, salt="${csrfSalt}", max_age=${csrfMaxAgeSec}s)`); +} + +export function isCsrfConfigured(): boolean { + return csrfSecret !== null; +} + +function b64urlDecode(s: string): Buffer { + // itsdangerous strips padding + const pad = s.length % 4 === 0 ? 0 : 4 - (s.length % 4); + const padded = s + '='.repeat(pad); + return Buffer.from(padded.replace(/-/g, '+').replace(/_/g, '/'), 'base64'); +} + +function deriveKey(secret: string, salt: string, digest: string): Buffer { + // itsdangerous `Signer.derive_key`: HMAC(secret, salt + "signer") + return crypto.createHmac(digest, secret).update(salt + 'signer').digest(); +} + +function decodeTimestamp(encoded: string): number { + const raw = b64urlDecode(encoded); + let ts = 0; + for (const b of raw) ts = (ts << 8) | b; + return ts + ITSDANGEROUS_EPOCH; +} + +export interface CsrfVerifyResult { + valid: boolean; + reason?: string; +} + +export function verifyCsrfToken(token: string): CsrfVerifyResult { + if (!csrfSecret) return { valid: false, reason: 'CSRF secret not loaded' }; + if (!token || typeof token !== 'string') return { valid: false, reason: 'Empty token' }; + + const lastDot = token.lastIndexOf('.'); + if (lastDot < 0) return { valid: false, reason: 'Malformed token (no signature)' }; + + const payloadTs = token.slice(0, lastDot); // "." + const sigStr = token.slice(lastDot + 1); + + const prevDot = payloadTs.lastIndexOf('.'); + if (prevDot < 0) return { valid: false, reason: 'Malformed token (no timestamp)' }; + + const tsStr = payloadTs.slice(prevDot + 1); + + const derived = deriveKey(csrfSecret, csrfSalt, csrfDigest); + const expectedSig = crypto.createHmac(csrfDigest, derived).update(payloadTs).digest(); + + let actualSig: Buffer; + try { + actualSig = b64urlDecode(sigStr); + } catch { + return { valid: false, reason: 'Invalid signature encoding' }; + } + + if (expectedSig.length !== actualSig.length) { + return { valid: false, reason: 'Signature length mismatch' }; + } + if (!crypto.timingSafeEqual(expectedSig, actualSig)) { + return { valid: false, reason: 'Signature mismatch' }; + } + + // Timestamp check + try { + const issuedAt = decodeTimestamp(tsStr); + const now = Math.floor(Date.now() / 1000); + if (issuedAt > now + 60) return { valid: false, reason: 'Token from the future' }; + if (now - issuedAt > csrfMaxAgeSec) return { valid: false, reason: 'Token expired' }; + } catch { + return { valid: false, reason: 'Invalid timestamp' }; + } + + return { valid: true }; +} diff --git a/apps/api/src/services/key-rotation.service.ts b/apps/api/src/services/key-rotation.service.ts new file mode 100644 index 0000000..ae86cb9 --- /dev/null +++ b/apps/api/src/services/key-rotation.service.ts @@ -0,0 +1,70 @@ +import { env, getVaultToken } from '../config/env'; +import { vaultAppRoleLogin } from '../config/vault'; +import { loadJwtKeysFromVault } from './jwt.service'; +import { loadCsrfSecret } from './csrf.service'; +import { logger } from '../lib/logger'; + +const DEFAULT_INTERVAL_MS = 60 * 60 * 1000; // 1 hour + +let timer: NodeJS.Timeout | null = null; +let currentVaultToken: string | null = null; + +/** + * Refresh JWT public keys (active + previous) and CSRF secret from Vault. + * Errors are logged but do NOT throw — старые значения остаются в памяти, + * сервис продолжает работать до следующего успешного refresh. + */ +export async function refreshAllKeys(): Promise { + const { addr, roleId, secretId, mount, jwtKidPath, jwtKidsPrefix, csrfPath } = env.vault; + + if (!addr || !roleId || !secretId) { + logger.warn('Vault not configured, skipping key refresh'); + return; + } + + // Use token from initEnv first call; re-login only if we don't have one yet. + let token = currentVaultToken || getVaultToken(); + if (!token) { + const fresh = await vaultAppRoleLogin(addr, roleId, secretId); + if (!fresh) { + logger.error('Key refresh: Vault AppRole login failed'); + return; + } + token = fresh; + currentVaultToken = fresh; + } + + try { + await loadJwtKeysFromVault(addr, token, mount, jwtKidPath, jwtKidsPrefix); + } catch (err: any) { + logger.error(`Failed to refresh JWT keys: ${err.message}`); + } + + try { + await loadCsrfSecret(addr, token, mount, csrfPath); + } catch (err: any) { + logger.error(`Failed to refresh CSRF secret: ${err.message}`); + } +} + +export function startKeyRotation(intervalMs: number = DEFAULT_INTERVAL_MS): void { + if (timer) return; + timer = setInterval(() => { + logger.info('Refreshing keys from Vault...'); + void refreshAllKeys().catch((err) => + logger.error(`Key rotation tick failed: ${err?.message || err}`) + ); + // On token expiry Vault will return 403 — we need to re-login. + // Reset cached token so refreshAllKeys re-logs in on next call. + currentVaultToken = null; + }, intervalMs); + logger.info(`Key rotation scheduled (every ${intervalMs}ms)`); +} + +export function stopKeyRotation(): void { + if (timer) { + clearInterval(timer); + timer = null; + logger.info('Key rotation stopped'); + } +} diff --git a/db/schema.sql b/db/schema.sql new file mode 100644 index 0000000..d0a40f5 --- /dev/null +++ b/db/schema.sql @@ -0,0 +1,63 @@ +-- ============================================================================ +-- CryptoWallet API — Database Schema +-- Idempotent: safe to run multiple times (CREATE IF NOT EXISTS). +-- +-- Применение: +-- psql "postgresql://user:pass@host:5432/db" -f deployserver/db/schema.sql +-- ============================================================================ + +-- ── users ─────────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS users ( + id VARCHAR(26) PRIMARY KEY, + email VARCHAR(255) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + last_name VARCHAR(128), + first_name VARCHAR(128), + middle_name VARCHAR(128), + birth_date DATE, + crypto_wallet VARCHAR(255), + phone VARCHAR(16), + bik VARCHAR(9), + account_number VARCHAR(20), + card_number VARCHAR(19), + inn VARCHAR(12), + kyc_verified BOOLEAN NOT NULL DEFAULT FALSE, + kyc_verified_at TIMESTAMPTZ, + is_deleted BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- ── wallets ───────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS wallets ( + id VARCHAR(26) PRIMARY KEY, + user_id VARCHAR(26) NOT NULL REFERENCES users(id) ON DELETE CASCADE, + chain VARCHAR(10) NOT NULL, + address VARCHAR(256) NOT NULL, + derivation_path VARCHAR(64) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT wallets_user_id_chain_unique UNIQUE (user_id, chain) +); + +CREATE INDEX IF NOT EXISTS idx_wallets_user_id ON wallets(user_id); +CREATE INDEX IF NOT EXISTS idx_wallets_address ON wallets(address); + +-- ── sessions ──────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS sessions ( + id VARCHAR(26) PRIMARY KEY, + sid VARCHAR(26) NOT NULL UNIQUE, + user_id VARCHAR(26) NOT NULL REFERENCES users(id) ON DELETE CASCADE, + device_id VARCHAR(26), + user_agent VARCHAR(500), + first_ip VARCHAR(64), + last_ip VARCHAR(64), + last_seen_at TIMESTAMPTZ, + revoked_at TIMESTAMPTZ, + refresh_jti_hash VARCHAR(255), + refresh_expires_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id); +CREATE INDEX IF NOT EXISTS idx_sessions_sid ON sessions(sid); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1be52ab..26419c8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,9 +50,6 @@ importers: ulidx: specifier: ^2.4.1 version: 2.4.1 - zod: - specifier: ^3.23.0 - version: 3.25.76 devDependencies: '@types/cookie-parser': specifier: ^1.4.7 @@ -1521,9 +1518,6 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} - zod@3.25.76: - resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} - snapshots: '@cspotcode/source-map-support@0.8.1': @@ -3256,5 +3250,3 @@ snapshots: yn@3.1.1: {} yocto-queue@0.1.0: {} - - zod@3.25.76: {}