diff --git a/.env.example b/.env.example index 38cceb8..246443b 100644 --- a/.env.example +++ b/.env.example @@ -30,6 +30,33 @@ API_PORT=3001 # Прод: https://ваш-домен — несколько origin через запятую: https://a.ru,https://www.a.ru CORS_ORIGIN=http://localhost:5173 +# ─── Auth / sessions ───────────────────────────────────────── +# APP_BASE_URL is the only source for verify/reset email links. +APP_BASE_URL=http://localhost:5173 +SESSION_SECRET=replace_with_32plus_char_random_secret +# Production defaults to __Host-sid + Secure cookies. Local dev can stay insecure over http. +# SESSION_COOKIE_NAME=__Host-sid +# SESSION_COOKIE_SECURE=true +# SESSION_TTL_DAYS=30 + +# ─── Cloudflare Turnstile ──────────────────────────────────── +TURNSTILE_SECRET_KEY=replace_with_turnstile_secret +# Local tests/dev only, never production: +# TURNSTILE_BYPASS_TOKEN=mock-turnstile-token + +# ─── SMTP email ────────────────────────────────────────────── +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_SECURE=false +SMTP_USER=replace_with_smtp_user +SMTP_PASSWORD=replace_with_smtp_password +SMTP_FROM=Calendar Run + +# ─── Seed after auth ───────────────────────────────────────── +# Required once users exist, so seed never creates ownerless races or overwrites user edits. +# SEED_OWNER_USER_ID= +# SEED_OWNER_EMAIL= + # ─── Версия API (опционально) ───────────────────────────────── # Если в образе не удаётся прочитать package.json, подставьте вручную (видно в GET /health). # APP_VERSION=1.0.0 diff --git a/backend/migrations/004_auth_and_race_ownership.sql b/backend/migrations/004_auth_and_race_ownership.sql new file mode 100644 index 0000000..5c74bca --- /dev/null +++ b/backend/migrations/004_auth_and_race_ownership.sql @@ -0,0 +1,114 @@ +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email TEXT NOT NULL, + password_hash TEXT NOT NULL, + email_verified_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ +); + +CREATE UNIQUE INDEX IF NOT EXISTS users_email_normalized_key + ON users (LOWER(BTRIM(email))); + +CREATE TABLE IF NOT EXISTS sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + csrf_token_hash TEXT NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + revoked_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS sessions_user_id_idx ON sessions(user_id); +CREATE INDEX IF NOT EXISTS sessions_expires_at_idx ON sessions(expires_at); + +CREATE TABLE IF NOT EXISTS email_verification_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS email_verification_tokens_user_id_idx + ON email_verification_tokens(user_id); + +CREATE TABLE IF NOT EXISTS password_reset_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS password_reset_tokens_user_id_idx + ON password_reset_tokens(user_id); + +CREATE TABLE IF NOT EXISTS app_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +DO $$ +DECLARE + id_type TEXT; + pk_name TEXT; +BEGIN + SELECT data_type INTO id_type + FROM information_schema.columns + WHERE table_name = 'races' AND column_name = 'id'; + + IF id_type IS NOT NULL AND id_type <> 'uuid' THEN + SELECT conname INTO pk_name + FROM pg_constraint + WHERE conrelid = 'races'::regclass AND contype = 'p' + LIMIT 1; + + IF pk_name IS NOT NULL THEN + EXECUTE format('ALTER TABLE races DROP CONSTRAINT %I', pk_name); + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'races' AND column_name = 'slug' + ) THEN + ALTER TABLE races RENAME COLUMN id TO slug; + ELSE + ALTER TABLE races DROP COLUMN id; + END IF; + END IF; +END $$; + +ALTER TABLE races ADD COLUMN IF NOT EXISTS id UUID DEFAULT gen_random_uuid(); +UPDATE races SET id = gen_random_uuid() WHERE id IS NULL; +ALTER TABLE races ALTER COLUMN id SET NOT NULL; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conrelid = 'races'::regclass AND contype = 'p' + ) THEN + ALTER TABLE races ADD CONSTRAINT races_pkey PRIMARY KEY (id); + END IF; +END $$; + +ALTER TABLE races ADD COLUMN IF NOT EXISTS slug TEXT; +UPDATE races SET slug = id::text WHERE slug IS NULL OR BTRIM(slug) = ''; +ALTER TABLE races ALTER COLUMN slug SET NOT NULL; + +ALTER TABLE races ADD COLUMN IF NOT EXISTS owner_user_id UUID REFERENCES users(id) ON DELETE CASCADE; +ALTER TABLE races ADD COLUMN IF NOT EXISTS source TEXT NOT NULL DEFAULT 'user'; + +CREATE UNIQUE INDEX IF NOT EXISTS races_owner_slug_key + ON races(owner_user_id, slug) + WHERE owner_user_id IS NOT NULL; + +CREATE INDEX IF NOT EXISTS races_owner_user_id_idx ON races(owner_user_id); diff --git a/backend/package-lock.json b/backend/package-lock.json index d90c03b..fa231ce 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,23 +1,31 @@ { "name": "calendar-run-backend", - "version": "1.3.0", + "version": "1.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "calendar-run-backend", - "version": "1.3.0", + "version": "1.4.0", "dependencies": { + "argon2": "^0.44.0", + "cookie-parser": "^1.4.7", "cors": "^2.8.5", "csv-parse": "^5.6.0", "dotenv": "^16.4.7", "express": "^4.21.2", - "pg": "^8.13.1" + "express-rate-limit": "^8.4.1", + "helmet": "^8.1.0", + "nodemailer": "^8.0.7", + "pg": "^8.13.1", + "zod": "^4.3.6" }, "devDependencies": { + "@types/cookie-parser": "^1.4.10", "@types/cors": "^2.8.17", "@types/express": "^5.0.0", "@types/node": "^22.12.0", + "@types/nodemailer": "^8.0.0", "@types/pg": "^8.11.10", "@types/supertest": "^6.0.2", "cross-env": "^10.1.0", @@ -44,7 +52,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", "integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==", - "dev": true, "license": "MIT" }, "node_modules/@esbuild/aix-ppc64": { @@ -540,6 +547,15 @@ "@noble/hashes": "^1.1.5" } }, + "node_modules/@phc/format": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz", + "integrity": "sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/@tsconfig/node10": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", @@ -589,6 +605,16 @@ "@types/node": "*" } }, + "node_modules/@types/cookie-parser": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz", + "integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/express": "*" + } + }, "node_modules/@types/cookiejar": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", @@ -651,11 +677,20 @@ "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } }, + "node_modules/@types/nodemailer": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.0.tgz", + "integrity": "sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/pg": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", @@ -773,6 +808,22 @@ "dev": true, "license": "MIT" }, + "node_modules/argon2": { + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/argon2/-/argon2-0.44.0.tgz", + "integrity": "sha512-zHPGN3S55sihSQo0dBbK0A5qpi2R31z7HZDZnry3ifOyj8bZZnpZND2gpmhnRGO1V/d555RwBqIK5W4Mrmv3ig==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@phc/format": "^1.0.0", + "cross-env": "^10.0.0", + "node-addon-api": "^8.5.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">=16.17.0" + } + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -908,6 +959,25 @@ "node": ">= 0.6" } }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, "node_modules/cookie-signature": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", @@ -949,7 +1019,6 @@ "version": "10.1.0", "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", "integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==", - "dev": true, "license": "MIT", "dependencies": { "@epic-web/invariant": "^1.0.0", @@ -967,7 +1036,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -1233,6 +1301,24 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.4.1.tgz", + "integrity": "sha512-NGVYwQSAyEQgzxX1iCM978PP9AdO/hW93gMcF6ZwQCm+rFvLsBH6w4xcXWTcliS8La5EPRN3p9wzItqBwJrfNw==", + "license": "MIT", + "dependencies": { + "ip-address": "10.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/fast-safe-stringify": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", @@ -1437,6 +1523,15 @@ "node": ">= 0.4" } }, + "node_modules/helmet": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz", + "integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -1475,6 +1570,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "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", @@ -1488,7 +1592,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/make-error": { @@ -1582,6 +1685,35 @@ "node": ">= 0.6" } }, + "node_modules/node-addon-api": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz", + "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/nodemailer": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.7.tgz", + "integrity": "sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -1638,7 +1770,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -1655,7 +1786,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.12.0", "pg-pool": "^3.13.0", @@ -1922,7 +2052,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -1935,7 +2064,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -2207,7 +2335,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -2261,7 +2388,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -2298,6 +2424,15 @@ "engines": { "node": ">=6" } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/backend/package.json b/backend/package.json index 3859a4a..e94dcef 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "calendar-run-backend", - "version": "1.3.0", + "version": "1.4.0", "private": true, "scripts": { "build": "tsc", @@ -11,16 +11,24 @@ "test": "cross-env CALENDAR_RUN_MOCK_DB=1 tsx --test test/app.test.ts" }, "dependencies": { + "argon2": "^0.44.0", + "cookie-parser": "^1.4.7", "cors": "^2.8.5", "csv-parse": "^5.6.0", "dotenv": "^16.4.7", "express": "^4.21.2", - "pg": "^8.13.1" + "express-rate-limit": "^8.4.1", + "helmet": "^8.1.0", + "nodemailer": "^8.0.7", + "pg": "^8.13.1", + "zod": "^4.3.6" }, "devDependencies": { + "@types/cookie-parser": "^1.4.10", "@types/cors": "^2.8.17", "@types/express": "^5.0.0", "@types/node": "^22.12.0", + "@types/nodemailer": "^8.0.0", "@types/pg": "^8.11.10", "@types/supertest": "^6.0.2", "cross-env": "^10.1.0", diff --git a/backend/src/app.ts b/backend/src/app.ts index 484c433..c3e25af 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -1,6 +1,10 @@ import express, { Request, Response, NextFunction } from "express"; import cors from "cors"; +import cookieParser from "cookie-parser"; +import helmet from "helmet"; import { config } from "./config"; +import { loadAuth, requireCsrf } from "./authMiddleware"; +import authRouter from "./routes/auth"; import healthRouter from "./routes/health"; import racesRouter from "./routes/races"; @@ -8,11 +12,43 @@ export function createApp(): express.Express { const app = express(); app.use( - cors({ origin: config.corsOrigin, methods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"] }), + helmet({ + contentSecurityPolicy: + config.securityProfile === "production" + ? { + directives: { + defaultSrc: ["'self'"], + scriptSrc: ["'self'"], + styleSrc: ["'self'"], + imgSrc: ["'self'", "data:", "https:"], + connectSrc: ["'self'"], + objectSrc: ["'none'"], + frameAncestors: ["'none'"], + }, + } + : false, + hsts: + config.securityProfile === "production" + ? { maxAge: 31_536_000, includeSubDomains: true } + : false, + referrerPolicy: { policy: "strict-origin-when-cross-origin" }, + }), + ); + app.use( + cors({ + origin: config.corsOrigin, + credentials: true, + methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], + allowedHeaders: ["Content-Type", "X-CSRF-Token"], + }), ); app.use(express.json()); + app.use(cookieParser(config.session.secret)); + app.use(loadAuth); + app.use(requireCsrf); app.use("/api", healthRouter); + app.use("/api", authRouter); app.use("/api", racesRouter); app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => { diff --git a/backend/src/authMiddleware.ts b/backend/src/authMiddleware.ts new file mode 100644 index 0000000..f1a73c2 --- /dev/null +++ b/backend/src/authMiddleware.ts @@ -0,0 +1,83 @@ +import { NextFunction, Request, Response } from "express"; +import { config } from "./config"; +import { csrfMatches, getSession } from "./authService"; + +declare global { + namespace Express { + interface Request { + auth?: { + user: { + id: string; + email: string; + emailVerifiedAt: string | null; + }; + csrfTokenHash: string; + sessionToken: string; + }; + } + } +} + +const MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]); + +export function setSessionCookie(res: Response, sessionToken: string): void { + res.cookie(config.session.cookieName, sessionToken, { + httpOnly: true, + secure: config.session.secure, + sameSite: "lax", + path: "/", + maxAge: config.session.ttlDays * 24 * 60 * 60 * 1000, + }); +} + +export function clearSessionCookie(res: Response): void { + res.clearCookie(config.session.cookieName, { + httpOnly: true, + secure: config.session.secure, + sameSite: "lax", + path: "/", + }); +} + +export async function loadAuth(req: Request, _res: Response, next: NextFunction): Promise { + const token = req.cookies?.[config.session.cookieName]; + if (typeof token !== "string" || token.trim() === "") { + next(); + return; + } + try { + const session = await getSession(token); + if (session) { + req.auth = { ...session, sessionToken: token }; + } + } catch (error) { + next(error); + return; + } + next(); +} + +export function requireAuth(req: Request, res: Response, next: NextFunction): void { + if (!req.auth) { + res.status(401).json({ error: "unauthorized", details: ["Authentication required"] }); + return; + } + if (!req.auth.user.emailVerifiedAt) { + res.status(403).json({ error: "email_not_verified", details: ["Email verification required"] }); + return; + } + next(); +} + +export function requireCsrf(req: Request, res: Response, next: NextFunction): void { + if (!MUTATING_METHODS.has(req.method) || !req.auth) { + next(); + return; + } + const token = req.header("X-CSRF-Token"); + if (!token || !csrfMatches(req.auth.csrfTokenHash, token)) { + res.status(403).json({ error: "csrf_error", details: ["Invalid CSRF token"] }); + return; + } + next(); +} diff --git a/backend/src/authService.ts b/backend/src/authService.ts new file mode 100644 index 0000000..581a9e8 --- /dev/null +++ b/backend/src/authService.ts @@ -0,0 +1,348 @@ +import { PoolClient } from "pg"; +import { config } from "./config"; +import { pool } from "./db"; +import { sendMail } from "./mailer"; +import { + addDays, + addHours, + hashPassword, + normalizeEmail, + randomToken, + sha256Hex, + timingSafeEqualHex, + verifyPassword, +} from "./security"; +import { anonymizeEmail, securityLog } from "./securityLog"; + +export interface AuthUser { + id: string; + email: string; + emailVerifiedAt: string | null; +} + +interface UserRow { + id: string; + email: string; + password_hash: string; + email_verified_at: Date | string | null; +} + +interface SessionRow { + id: string; + user_id: string; + token_hash: string; + csrf_token_hash: string; + expires_at: Date | string; + email: string; + email_verified_at: Date | string | null; +} + +function toIso(value: Date | string | null): string | null { + if (!value) { + return null; + } + return value instanceof Date ? value.toISOString() : String(value); +} + +function userFromRow(row: UserRow): AuthUser { + return { + id: row.id, + email: row.email, + emailVerifiedAt: toIso(row.email_verified_at), + }; +} + +function appUrl(path: string, token: string): string { + const url = new URL(path, config.appBaseUrl); + url.searchParams.set("token", token); + return url.toString(); +} + +export async function findUserByEmail(email: string): Promise { + const normalized = normalizeEmail(email); + const { rows } = await pool.query( + "SELECT id, email, password_hash, email_verified_at FROM users WHERE LOWER(BTRIM(email)) = $1", + [normalized], + ); + return rows[0] ?? null; +} + +export async function createVerificationToken(client: PoolClient, userId: string): Promise { + await client.query( + "UPDATE email_verification_tokens SET used_at = NOW() WHERE user_id = $1 AND used_at IS NULL", + [userId], + ); + const token = randomToken(32); + await client.query( + `INSERT INTO email_verification_tokens (user_id, token_hash, expires_at) + VALUES ($1, $2, $3)`, + [userId, sha256Hex(token), addHours(new Date(), 24)], + ); + return token; +} + +export async function createResetToken(client: PoolClient, userId: string): Promise { + await client.query( + "UPDATE password_reset_tokens SET used_at = NOW() WHERE user_id = $1 AND used_at IS NULL", + [userId], + ); + const token = randomToken(32); + await client.query( + `INSERT INTO password_reset_tokens (user_id, token_hash, expires_at) + VALUES ($1, $2, $3)`, + [userId, sha256Hex(token), addHours(new Date(), 1)], + ); + return token; +} + +export async function sendVerificationEmail(email: string, token: string): Promise { + await sendMail( + email, + "Подтвердите email в Calendar Run", + `Откройте ссылку для подтверждения email: ${appUrl("/verify-email", token)}`, + ); +} + +export async function sendResetEmail(email: string, token: string): Promise { + await sendMail( + email, + "Сброс пароля Calendar Run", + `Откройте ссылку для сброса пароля: ${appUrl("/reset-password", token)}`, + ); +} + +export async function registerUser(email: string, password: string): Promise { + const normalized = normalizeEmail(email); + const passwordHash = await hashPassword(password); + const existing = await findUserByEmail(normalized); + if (existing) { + securityLog("register.existing", { emailHash: anonymizeEmail(normalized) }); + return; + } + const client = await pool.connect(); + try { + await client.query("BEGIN"); + const { rows } = await client.query( + `INSERT INTO users (email, password_hash) + VALUES ($1, $2) + RETURNING id, email, password_hash, email_verified_at`, + [normalized, passwordHash], + ); + const user = rows[0]; + if (user) { + const token = await createVerificationToken(client, user.id); + await client.query("COMMIT"); + await sendVerificationEmail(user.email, token); + securityLog("register.created", { emailHash: anonymizeEmail(normalized) }); + return; + } + await client.query("COMMIT"); + securityLog("register.existing", { emailHash: anonymizeEmail(normalized) }); + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } +} + +export async function createSession(userId: string): Promise<{ sessionToken: string; csrfToken: string; user: AuthUser }> { + const sessionToken = randomToken(32); + const csrfToken = randomToken(32); + const expiresAt = addDays(new Date(), config.session.ttlDays); + const { rows } = await pool.query( + `INSERT INTO sessions (user_id, token_hash, csrf_token_hash, expires_at) + VALUES ($1, $2, $3, $4) + RETURNING id, user_id, token_hash, csrf_token_hash, expires_at, + (SELECT email FROM users WHERE id = $1) AS email, + (SELECT email_verified_at FROM users WHERE id = $1) AS email_verified_at`, + [userId, sha256Hex(sessionToken), sha256Hex(csrfToken), expiresAt], + ); + const row = rows[0]; + securityLog("session.issued", { userId }); + return { + sessionToken, + csrfToken, + user: { + id: row.user_id, + email: row.email, + emailVerifiedAt: toIso(row.email_verified_at), + }, + }; +} + +export async function loginUser(email: string, password: string): Promise<{ ok: false } | { ok: true; sessionToken: string; csrfToken: string; user: AuthUser }> { + const normalized = normalizeEmail(email); + const user = await findUserByEmail(normalized); + const passwordOk = await verifyPassword(user?.password_hash ?? null, password); + if (!user || !passwordOk || !user.email_verified_at) { + securityLog("login.failed", { emailHash: anonymizeEmail(normalized) }); + return { ok: false }; + } + const session = await createSession(user.id); + securityLog("login.succeeded", { userId: user.id }); + return { ok: true, ...session }; +} + +export async function revokeSession(sessionToken: string): Promise { + await pool.query("UPDATE sessions SET revoked_at = NOW() WHERE token_hash = $1", [sha256Hex(sessionToken)]); + securityLog("session.revoked"); +} + +export async function rotateCsrf(sessionToken: string): Promise { + const csrfToken = randomToken(32); + const { rowCount } = await pool.query( + `UPDATE sessions + SET csrf_token_hash = $2, last_seen_at = NOW() + WHERE token_hash = $1 AND revoked_at IS NULL AND expires_at > NOW()`, + [sha256Hex(sessionToken), sha256Hex(csrfToken)], + ); + return rowCount && rowCount > 0 ? csrfToken : null; +} + +export async function getSession(sessionToken: string): Promise<{ user: AuthUser; csrfTokenHash: string } | null> { + const tokenHash = sha256Hex(sessionToken); + const { rows } = await pool.query( + `SELECT s.id, s.user_id, s.token_hash, s.csrf_token_hash, s.expires_at, u.email, u.email_verified_at + FROM sessions s + JOIN users u ON u.id = s.user_id + WHERE s.token_hash = $1 AND s.revoked_at IS NULL AND s.expires_at > NOW()`, + [tokenHash], + ); + const row = rows[0]; + if (!row) { + return null; + } + await pool.query("UPDATE sessions SET last_seen_at = NOW(), expires_at = $2 WHERE id = $1", [ + row.id, + addDays(new Date(), config.session.ttlDays), + ]); + return { + user: { + id: row.user_id, + email: row.email, + emailVerifiedAt: toIso(row.email_verified_at), + }, + csrfTokenHash: row.csrf_token_hash, + }; +} + +export function csrfMatches(hash: string, token: string): boolean { + return timingSafeEqualHex(hash, sha256Hex(token)); +} + +export async function verifyEmailToken(token: string): Promise { + const tokenHash = sha256Hex(token); + const client = await pool.connect(); + try { + await client.query("BEGIN"); + const { rows } = await client.query<{ id: string; user_id: string; token_hash: string }>( + `SELECT id, user_id, token_hash + FROM email_verification_tokens + WHERE used_at IS NULL AND expires_at > NOW()`, + ); + const row = rows.find((candidate) => timingSafeEqualHex(candidate.token_hash, tokenHash)); + if (!row) { + await client.query("ROLLBACK"); + return false; + } + await client.query("UPDATE email_verification_tokens SET used_at = NOW() WHERE user_id = $1 AND used_at IS NULL", [row.user_id]); + await client.query("UPDATE users SET email_verified_at = COALESCE(email_verified_at, NOW()), updated_at = NOW() WHERE id = $1", [row.user_id]); + await client.query("SELECT pg_advisory_xact_lock(706365)"); + const claimed = await client.query("SELECT value FROM app_settings WHERE key = 'orphan_races_claimed_by_user_id' FOR UPDATE"); + if (claimed.rows.length === 0) { + await client.query("UPDATE races SET owner_user_id = $1, updated_at = NOW() WHERE owner_user_id IS NULL", [row.user_id]); + await client.query( + `INSERT INTO app_settings (key, value) + VALUES ('orphan_races_claimed_by_user_id', $1)`, + [row.user_id], + ); + } + await client.query("COMMIT"); + securityLog("email.verified", { userId: row.user_id }); + return true; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } +} + +export async function requestPasswordReset(email: string): Promise { + const normalized = normalizeEmail(email); + const user = await findUserByEmail(normalized); + if (!user) { + securityLog("password_reset.requested_missing", { emailHash: anonymizeEmail(normalized) }); + return; + } + const client = await pool.connect(); + try { + await client.query("BEGIN"); + const token = await createResetToken(client, user.id); + await client.query("COMMIT"); + await sendResetEmail(user.email, token); + securityLog("password_reset.requested", { userId: user.id }); + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } +} + +export async function resetPassword(token: string, password: string): Promise { + const tokenHash = sha256Hex(token); + const passwordHash = await hashPassword(password); + const client = await pool.connect(); + try { + await client.query("BEGIN"); + const { rows } = await client.query<{ id: string; user_id: string; token_hash: string }>( + `SELECT id, user_id, token_hash + FROM password_reset_tokens + WHERE used_at IS NULL AND expires_at > NOW()`, + ); + const row = rows.find((candidate) => timingSafeEqualHex(candidate.token_hash, tokenHash)); + if (!row) { + await client.query("ROLLBACK"); + return false; + } + await client.query("UPDATE password_reset_tokens SET used_at = NOW() WHERE user_id = $1 AND used_at IS NULL", [row.user_id]); + await client.query("UPDATE users SET password_hash = $2, updated_at = NOW() WHERE id = $1", [row.user_id, passwordHash]); + await client.query("UPDATE sessions SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL", [row.user_id]); + await client.query("COMMIT"); + securityLog("password_reset.completed", { userId: row.user_id }); + return true; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } +} + +export async function resendVerification(email: string): Promise { + const normalized = normalizeEmail(email); + const user = await findUserByEmail(normalized); + if (!user || user.email_verified_at) { + return; + } + const client = await pool.connect(); + try { + await client.query("BEGIN"); + const token = await createVerificationToken(client, user.id); + await client.query("COMMIT"); + await sendVerificationEmail(user.email, token); + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } +} + +export async function cleanupExpiredAuthRows(): Promise { + await pool.query("DELETE FROM sessions WHERE expires_at <= NOW() OR revoked_at < NOW() - INTERVAL '30 days'"); + await pool.query("DELETE FROM email_verification_tokens WHERE expires_at <= NOW() OR used_at IS NOT NULL"); + await pool.query("DELETE FROM password_reset_tokens WHERE expires_at <= NOW() OR used_at IS NOT NULL"); +} diff --git a/backend/src/config.ts b/backend/src/config.ts index 5319951..61bbffe 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -11,6 +11,25 @@ function requireEnv(name: string): string { return value; } +function optionalEnv(name: string): string | null { + const value = process.env[name]?.trim(); + return value ? value : null; +} + +function requireEnvUnlessMock(name: string, fallback: string): string { + if (useMockDb) { + return process.env[name]?.trim() || fallback; + } + return requireEnv(name); +} + +function parseBoolean(value: string | undefined, fallback: boolean): boolean { + if (value == null || value.trim() === "") { + return fallback; + } + return value === "1" || value.toLowerCase() === "true"; +} + const useMockDb = process.env.CALENDAR_RUN_MOCK_DB === "1" || process.env.CALENDAR_RUN_MOCK_DB?.toLowerCase() === "true"; @@ -35,6 +54,28 @@ export const config = { apiPort: parseInt(process.env.PORT || process.env.API_PORT || "3001", 10), /** Одно значение или несколько через запятую (прод: https://домен) */ corsOrigin: parseCorsOrigins(), + appBaseUrl: process.env.APP_BASE_URL?.trim() || "http://localhost:5173", + session: { + secret: requireEnvUnlessMock("SESSION_SECRET", "mock-session-secret-change-me"), + cookieName: + process.env.SESSION_COOKIE_NAME?.trim() || + (process.env.NODE_ENV === "production" ? "__Host-sid" : "sid"), + secure: parseBoolean(process.env.SESSION_COOKIE_SECURE, process.env.NODE_ENV === "production"), + ttlDays: parseInt(process.env.SESSION_TTL_DAYS || "30", 10), + }, + smtp: { + host: optionalEnv("SMTP_HOST"), + port: parseInt(process.env.SMTP_PORT || "587", 10), + secure: parseBoolean(process.env.SMTP_SECURE, false), + user: optionalEnv("SMTP_USER"), + password: optionalEnv("SMTP_PASSWORD"), + from: process.env.SMTP_FROM?.trim() || "Calendar Run ", + }, + turnstile: { + secretKey: process.env.TURNSTILE_SECRET_KEY?.trim() || (useMockDb ? "mock-turnstile-secret" : ""), + bypassToken: process.env.TURNSTILE_BYPASS_TOKEN?.trim() || (useMockDb ? "mock-turnstile-token" : ""), + }, + securityProfile: process.env.SECURITY_PROFILE?.trim() || process.env.NODE_ENV || "development", }; function parseCorsOrigins(): string | string[] { diff --git a/backend/src/db.ts b/backend/src/db.ts index 53ba136..59e909d 100644 --- a/backend/src/db.ts +++ b/backend/src/db.ts @@ -1,4 +1,5 @@ import { Pool, PoolConfig, QueryResult, QueryResultRow } from "pg"; +import crypto from "crypto"; import { config } from "./config"; import type { RaceRow } from "./mappers/race"; @@ -19,6 +20,8 @@ function mockRowFromInsert(sql: string, params: unknown[]): RaceRow { if (!match) { return { id: String(params[0] ?? ""), + slug: String(params[0] ?? ""), + owner_user_id: null, race_date: "", title: "", distance_km: "0", @@ -42,7 +45,9 @@ function mockRowFromInsert(sql: string, params: unknown[]): RaceRow { row[col] = params[i]; }); return { - id: String(row.id ?? ""), + id: String(row.id ?? crypto.randomUUID()), + slug: String(row.slug ?? row.id ?? ""), + owner_user_id: row.owner_user_id != null ? String(row.owner_user_id) : null, race_date: String(row.race_date ?? ""), title: String(row.title ?? ""), distance_km: String(row.distance_km ?? "0"), @@ -72,6 +77,14 @@ function createMockPool(): Pool { }) as QueryResult; const store = new Map(); + const users = new Map(); + const sessions = new Map(); + const verificationTokens = new Map(); + const resetTokens = new Map(); + const appSettings = new Map(); + + const result = (rows: T[], command = "SELECT"): QueryResult => + ({ rows, rowCount: rows.length, command, oid: 0, fields: [] }) as QueryResult; const mockQuery = async ( text: string, @@ -80,8 +93,217 @@ function createMockPool(): Pool { const sql = text.replace(/\s+/g, " ").trim(); const p = params ?? []; + if (sql === "BEGIN" || sql === "COMMIT" || sql === "ROLLBACK" || sql.includes("pg_advisory_xact_lock")) { + return result([]); + } + + if (sql.includes("SELECT COUNT(*)::text AS count FROM users")) { + return result([{ count: String(users.size) } as unknown as T]); + } + + if (sql.includes("SELECT id FROM users WHERE LOWER(BTRIM(email))")) { + const email = String(p[0] ?? ""); + const user = Array.from(users.values()).find((item) => item.email.trim().toLowerCase() === email); + return user ? result([user as T]) : emptyResult(); + } + + if (sql.includes("SELECT id, email, password_hash, email_verified_at FROM users WHERE LOWER(BTRIM(email))")) { + const email = String(p[0] ?? ""); + const user = Array.from(users.values()).find((item) => item.email.trim().toLowerCase() === email); + return user ? result([user as T]) : emptyResult(); + } + + if (sql.includes("INSERT INTO users")) { + const id = crypto.randomUUID(); + const row = { + id, + email: String(p[0] ?? ""), + password_hash: String(p[1] ?? ""), + email_verified_at: null, + created_at: new Date(), + updated_at: null, + }; + users.set(id, row); + return result([row as unknown as T], "INSERT"); + } + + if (sql.includes("INSERT INTO email_verification_tokens")) { + const id = crypto.randomUUID(); + const row = { + id, + user_id: String(p[0] ?? ""), + token_hash: String(p[1] ?? ""), + expires_at: p[2] ?? new Date(), + used_at: null, + created_at: new Date(), + }; + verificationTokens.set(id, row); + return result([row as unknown as T], "INSERT"); + } + + if (sql.includes("UPDATE email_verification_tokens SET used_at = NOW() WHERE user_id")) { + const userId = String(p[0] ?? ""); + for (const row of verificationTokens.values()) { + if (row.user_id === userId && row.used_at == null) { + row.used_at = new Date(); + } + } + return result([], "UPDATE"); + } + + if (sql.includes("FROM email_verification_tokens") && sql.includes("WHERE used_at IS NULL")) { + const now = Date.now(); + return result( + Array.from(verificationTokens.values()).filter((row) => !row.used_at && new Date(row.expires_at).getTime() > now) as T[], + ); + } + + if (sql.includes("INSERT INTO password_reset_tokens")) { + const id = crypto.randomUUID(); + const row = { + id, + user_id: String(p[0] ?? ""), + token_hash: String(p[1] ?? ""), + expires_at: p[2] ?? new Date(), + used_at: null, + created_at: new Date(), + }; + resetTokens.set(id, row); + return result([row as unknown as T], "INSERT"); + } + + if (sql.includes("UPDATE password_reset_tokens SET used_at = NOW() WHERE user_id")) { + const userId = String(p[0] ?? ""); + for (const row of resetTokens.values()) { + if (row.user_id === userId && row.used_at == null) { + row.used_at = new Date(); + } + } + return result([], "UPDATE"); + } + + if (sql.includes("FROM password_reset_tokens") && sql.includes("WHERE used_at IS NULL")) { + const now = Date.now(); + return result( + Array.from(resetTokens.values()).filter((row) => !row.used_at && new Date(row.expires_at).getTime() > now) as T[], + ); + } + + if (sql.includes("UPDATE users SET email_verified_at")) { + const user = users.get(String(p[0] ?? "")); + if (user) { + user.email_verified_at = user.email_verified_at ?? new Date(); + user.updated_at = new Date(); + } + return result([], "UPDATE"); + } + + if (sql.includes("UPDATE users SET password_hash")) { + const user = users.get(String(p[0] ?? "")); + if (user) { + user.password_hash = String(p[1] ?? ""); + user.updated_at = new Date(); + } + return result([], "UPDATE"); + } + + if (sql.includes("INSERT INTO sessions")) { + const id = crypto.randomUUID(); + const user = users.get(String(p[0] ?? "")); + const row = { + id, + user_id: String(p[0] ?? ""), + token_hash: String(p[1] ?? ""), + csrf_token_hash: String(p[2] ?? ""), + expires_at: p[3] ?? new Date(), + email: user?.email ?? "", + email_verified_at: user?.email_verified_at ?? null, + revoked_at: null, + created_at: new Date(), + last_seen_at: new Date(), + }; + sessions.set(id, row); + return result([row as unknown as T], "INSERT"); + } + + if (sql.includes("FROM sessions s JOIN users u")) { + const tokenHash = String(p[0] ?? ""); + const now = Date.now(); + const row = Array.from(sessions.values()).find( + (item) => item.token_hash === tokenHash && !item.revoked_at && new Date(item.expires_at).getTime() > now, + ); + if (!row) { + return emptyResult(); + } + const user = users.get(row.user_id); + return result([{ ...row, email: user?.email ?? "", email_verified_at: user?.email_verified_at ?? null } as unknown as T]); + } + + if (sql.includes("UPDATE sessions SET csrf_token_hash")) { + const tokenHash = String(p[0] ?? ""); + const row = Array.from(sessions.values()).find((item) => item.token_hash === tokenHash && !item.revoked_at); + if (row) { + row.csrf_token_hash = String(p[1] ?? ""); + row.last_seen_at = new Date(); + } + return result(row ? ([{} as unknown as T]) : [], "UPDATE"); + } + + if (sql.includes("UPDATE sessions SET last_seen_at")) { + return result([], "UPDATE"); + } + + if (sql.includes("UPDATE sessions SET revoked_at = NOW() WHERE token_hash")) { + const tokenHash = String(p[0] ?? ""); + for (const row of sessions.values()) { + if (row.token_hash === tokenHash) { + row.revoked_at = new Date(); + } + } + return result([], "UPDATE"); + } + + if (sql.includes("UPDATE sessions SET revoked_at = NOW() WHERE user_id")) { + const userId = String(p[0] ?? ""); + for (const row of sessions.values()) { + if (row.user_id === userId && !row.revoked_at) { + row.revoked_at = new Date(); + } + } + return result([], "UPDATE"); + } + + if (sql.includes("SELECT value FROM app_settings")) { + const value = appSettings.get("orphan_races_claimed_by_user_id"); + return value ? result([{ value } as unknown as T]) : emptyResult(); + } + + if (sql.includes("INSERT INTO app_settings")) { + appSettings.set("orphan_races_claimed_by_user_id", String(p[0] ?? "")); + return result([], "INSERT"); + } + + if (sql.includes("UPDATE races SET owner_user_id")) { + const userId = String(p[0] ?? ""); + for (const race of store.values()) { + if (!race.owner_user_id) { + race.owner_user_id = userId; + race.updated_at = new Date(); + } + } + return result([], "UPDATE"); + } + if (sql.includes("INSERT INTO races") && sql.includes("RETURNING")) { const row = mockRowFromInsert(text, p); + const conflict = Array.from(store.values()).find( + (item) => item.owner_user_id && item.owner_user_id === row.owner_user_id && item.slug === row.slug, + ); + if (conflict) { + const err = new Error("duplicate key") as Error & { code?: string }; + err.code = "23505"; + throw err; + } store.set(row.id, row); return { rows: [row as unknown as T], @@ -94,7 +316,12 @@ function createMockPool(): Pool { if (sql.includes("DELETE FROM races")) { const id = String(p[0] ?? ""); - const existed = store.delete(id); + const ownerId = p[1] != null ? String(p[1]) : null; + const existing = store.get(id); + const existed = Boolean(existing && (!ownerId || existing.owner_user_id === ownerId)); + if (existed) { + store.delete(id); + } return { rows: [], rowCount: existed ? 1 : 0, @@ -105,9 +332,10 @@ function createMockPool(): Pool { } if (sql.includes("UPDATE races") && sql.includes("RETURNING")) { - const id = String(p[p.length - 1] ?? ""); + const id = String(p[p.length - 2] ?? p[p.length - 1] ?? ""); + const ownerId = p[p.length - 1] != null ? String(p[p.length - 1]) : null; const existing = store.get(id); - if (!existing) { + if (!existing || (ownerId && existing.owner_user_id !== ownerId)) { return emptyResult(); } const setMatch = sql.match(/UPDATE races SET (.+) WHERE id =/); @@ -135,14 +363,18 @@ function createMockPool(): Pool { if (sql.includes("SELECT * FROM races WHERE id =")) { const id = String(p[0] ?? ""); + const ownerId = p[1] != null ? String(p[1]) : null; const row = store.get(id); - return row + return row && (!ownerId || row.owner_user_id === ownerId) ? { rows: [row as unknown as T], rowCount: 1, command: "SELECT", oid: 0, fields: [] } as QueryResult : emptyResult(); } if (sql.includes("SELECT * FROM races")) { - const rows = Array.from(store.values()); + const ownerParam = p.find((value) => typeof value === "string" && /^[0-9a-f-]{36}$/i.test(value)); + const rows = ownerParam + ? Array.from(store.values()).filter((row) => row.owner_user_id === ownerParam) + : Array.from(store.values()); return { rows: rows as unknown as T[], rowCount: rows.length, command: "SELECT", oid: 0, fields: [] } as QueryResult; } @@ -152,9 +384,10 @@ function createMockPool(): Pool { const mockPool = { query: mockQuery, connect: async () => { - throw new Error( - "CALENDAR_RUN_MOCK_DB is enabled: migrate/seed require a real database; unset CALENDAR_RUN_MOCK_DB and configure DB_*.", - ); + return { + query: mockQuery, + release() {}, + }; }, end: async () => {}, on() { diff --git a/backend/src/mailer.ts b/backend/src/mailer.ts new file mode 100644 index 0000000..e6ea265 --- /dev/null +++ b/backend/src/mailer.ts @@ -0,0 +1,26 @@ +import nodemailer from "nodemailer"; +import { config } from "./config"; + +export async function sendMail(to: string, subject: string, text: string): Promise { + if (!config.smtp.host) { + console.info("[mail] SMTP is not configured; email content follows.", { to, subject, text }); + return; + } + + const transporter = nodemailer.createTransport({ + host: config.smtp.host, + port: config.smtp.port, + secure: config.smtp.secure, + auth: + config.smtp.user && config.smtp.password + ? { user: config.smtp.user, pass: config.smtp.password } + : undefined, + }); + + await transporter.sendMail({ + from: config.smtp.from, + to, + subject, + text, + }); +} diff --git a/backend/src/mappers/race.ts b/backend/src/mappers/race.ts index 18e6f85..068eb7b 100644 --- a/backend/src/mappers/race.ts +++ b/backend/src/mappers/race.ts @@ -4,6 +4,8 @@ */ export interface RaceRow { id: string; + slug: string; + owner_user_id: string | null; race_date: string | Date; title: string; distance_km: string; @@ -24,6 +26,7 @@ export interface RaceRow { /** API shape (camelCase). */ export interface RaceDto { id: string; + slug: string; date: string; title: string; distanceKm: number; @@ -61,6 +64,7 @@ function raceDateToApiValue(value: string | Date): string { export function rowToDto(row: RaceRow): RaceDto { return { id: row.id, + slug: row.slug, date: raceDateToApiValue(row.race_date), title: row.title, distanceKm: parseFloat(row.distance_km), @@ -81,6 +85,7 @@ export function rowToDto(row: RaceRow): RaceDto { /** Map incoming camelCase body fields to snake_case column names. */ const FIELD_MAP: Record = { + slug: "slug", date: "race_date", title: "title", distanceKm: "distance_km", diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts new file mode 100644 index 0000000..64bab73 --- /dev/null +++ b/backend/src/routes/auth.ts @@ -0,0 +1,197 @@ +import { Router, Request, Response } from "express"; +import rateLimit from "express-rate-limit"; +import { z } from "zod"; +import { + loginUser, + registerUser, + requestPasswordReset, + resendVerification, + resetPassword, + rotateCsrf, + revokeSession, + verifyEmailToken, +} from "../authService"; +import { clearSessionCookie, setSessionCookie } from "../authMiddleware"; +import { isValidPassword, normalizeEmail } from "../security"; +import { verifyTurnstileToken } from "../turnstile"; + +const router = Router(); + +const genericOk = { ok: true }; +const genericAuthError = { error: "invalid_credentials", details: ["Invalid email or password"] }; + +const registerLimiter = rateLimit({ windowMs: 60 * 60 * 1000, limit: 10, standardHeaders: true, legacyHeaders: false }); +const loginLimiter = rateLimit({ windowMs: 60 * 1000, limit: 20, standardHeaders: true, legacyHeaders: false }); +const loginEmailLimiter = rateLimit({ + windowMs: 60 * 1000, + limit: 5, + standardHeaders: true, + legacyHeaders: false, + keyGenerator: (req) => `login:${normalizeEmail(String(req.body?.email ?? ""))}`, +}); +const emailIpLimiter = rateLimit({ windowMs: 60 * 60 * 1000, limit: 10, standardHeaders: true, legacyHeaders: false }); +const emailAddressLimiter = rateLimit({ + windowMs: 60 * 60 * 1000, + limit: 3, + standardHeaders: true, + legacyHeaders: false, + keyGenerator: (req) => `email:${normalizeEmail(String(req.body?.email ?? ""))}`, +}); + +const registerSchema = z.object({ + email: z.string().email(), + password: z.string().refine(isValidPassword, "Password must be at least 15 characters"), + turnstileToken: z.string().min(1), +}); + +const loginSchema = z.object({ + email: z.string().email(), + password: z.string().min(1), +}); + +const tokenSchema = z.object({ + token: z.string().min(16), +}); + +const emailSchema = z.object({ + email: z.string().email(), +}); + +const resetSchema = z.object({ + token: z.string().min(16), + password: z.string().refine(isValidPassword, "Password must be at least 15 characters"), +}); + +function validationError(res: Response): void { + res.status(400).json({ error: "validation_error", details: ["Invalid request body"] }); +} + +router.post("/auth/register", registerLimiter, async (req: Request, res: Response, next) => { + const parsed = registerSchema.safeParse(req.body); + if (!parsed.success) { + validationError(res); + return; + } + try { + const captchaOk = await verifyTurnstileToken(parsed.data.turnstileToken, req.ip); + if (!captchaOk) { + res.status(400).json({ error: "captcha_failed", details: ["Captcha verification failed"] }); + return; + } + await registerUser(parsed.data.email, parsed.data.password); + res.status(202).json(genericOk); + } catch (error) { + next(error); + } +}); + +router.post("/auth/login", loginLimiter, loginEmailLimiter, async (req: Request, res: Response, next) => { + const parsed = loginSchema.safeParse(req.body); + if (!parsed.success) { + res.status(401).json(genericAuthError); + return; + } + try { + const result = await loginUser(parsed.data.email, parsed.data.password); + if (!result.ok) { + res.status(401).json(genericAuthError); + return; + } + setSessionCookie(res, result.sessionToken); + res.json({ user: result.user, csrfToken: result.csrfToken }); + } catch (error) { + next(error); + } +}); + +router.post("/auth/logout", async (req: Request, res: Response, next) => { + try { + if (req.auth) { + await revokeSession(req.auth.sessionToken); + } + clearSessionCookie(res); + res.status(204).end(); + } catch (error) { + next(error); + } +}); + +router.get("/auth/me", async (req: Request, res: Response, next) => { + if (!req.auth) { + res.status(401).json({ error: "unauthorized", details: ["Authentication required"] }); + return; + } + try { + const csrfToken = await rotateCsrf(req.auth.sessionToken); + res.json({ user: req.auth.user, csrfToken }); + } catch (error) { + next(error); + } +}); + +router.post("/auth/verify-email", emailIpLimiter, async (req: Request, res: Response, next) => { + const parsed = tokenSchema.safeParse(req.body); + if (!parsed.success) { + validationError(res); + return; + } + try { + const ok = await verifyEmailToken(parsed.data.token); + if (!ok) { + res.status(410).json({ error: "invalid_token", details: ["Token is invalid or expired"] }); + return; + } + res.json(genericOk); + } catch (error) { + next(error); + } +}); + +router.post("/auth/resend-verification", emailIpLimiter, emailAddressLimiter, async (req: Request, res: Response, next) => { + const parsed = emailSchema.safeParse(req.body); + if (!parsed.success) { + validationError(res); + return; + } + try { + await resendVerification(parsed.data.email); + res.status(202).json(genericOk); + } catch (error) { + next(error); + } +}); + +router.post("/auth/forgot-password", emailIpLimiter, emailAddressLimiter, async (req: Request, res: Response, next) => { + const parsed = emailSchema.safeParse(req.body); + if (!parsed.success) { + validationError(res); + return; + } + try { + await requestPasswordReset(parsed.data.email); + res.status(202).json(genericOk); + } catch (error) { + next(error); + } +}); + +router.post("/auth/reset-password", emailIpLimiter, async (req: Request, res: Response, next) => { + const parsed = resetSchema.safeParse(req.body); + if (!parsed.success) { + validationError(res); + return; + } + try { + const ok = await resetPassword(parsed.data.token, parsed.data.password); + if (!ok) { + res.status(410).json({ error: "invalid_token", details: ["Token is invalid or expired"] }); + return; + } + clearSessionCookie(res); + res.json(genericOk); + } catch (error) { + next(error); + } +}); + +export default router; diff --git a/backend/src/routes/races.ts b/backend/src/routes/races.ts index cf305a4..931d0c4 100644 --- a/backend/src/routes/races.ts +++ b/backend/src/routes/races.ts @@ -2,8 +2,10 @@ import { Router, Request, Response } from "express"; import { pool } from "../db"; import { rowToDto, bodyToColumns, RaceRow } from "../mappers/race"; import { extractRaceCoverImage } from "../raceCoverImage"; +import { requireAuth } from "../authMiddleware"; const router = Router(); +router.use(requireAuth); type ValidationErrorBody = { error: "validation_error"; @@ -69,6 +71,9 @@ router.get("/races", async (req: Request, res: Response) => { const params: unknown[] = []; let idx = 1; + conditions.push(`owner_user_id = $${idx++}`); + params.push(req.auth!.user.id); + if (yearResult.value != null) { conditions.push(`EXTRACT(YEAR FROM race_date) = $${idx++}`); params.push(yearResult.value); @@ -94,8 +99,8 @@ router.get("/races", async (req: Request, res: Response) => { router.get("/races/:id", async (req: Request, res: Response) => { try { const { rows } = await pool.query( - "SELECT * FROM races WHERE id = $1", - [req.params.id], + "SELECT * FROM races WHERE id = $1 AND owner_user_id = $2", + [req.params.id, req.auth!.user.id], ); if (rows.length === 0) { res.status(404).json({ error: "not_found", details: ["Race not found"] }); @@ -113,12 +118,14 @@ router.get("/races/:id", async (req: Request, res: Response) => { router.post("/races", async (req: Request, res: Response) => { const body = req.body; - if (!body.id || !body.date || !body.title || body.distanceKm == null) { - validationError(res, ["Fields id, date, title, distanceKm are required"]); + const slug = typeof body.slug === "string" && body.slug.trim() ? body.slug.trim() : body.id; + + if (!slug || !body.date || !body.title || body.distanceKm == null) { + validationError(res, ["Fields slug, date, title, distanceKm are required"]); return; } - const payload = { ...body }; + const payload = { ...body, slug }; const hasManualCover = typeof payload.coverImageUrl === "string" && payload.coverImageUrl.trim() !== ""; const hasOfficialUrl = typeof payload.officialUrl === "string" && payload.officialUrl.trim() !== ""; @@ -127,8 +134,12 @@ router.post("/races", async (req: Request, res: Response) => { } const { columns, values } = bodyToColumns(payload); - columns.unshift("id"); - values.unshift(body.id); + columns.unshift("owner_user_id"); + values.unshift(req.auth!.user.id); + if (!columns.includes("slug")) { + columns.push("slug"); + values.push(slug); + } const placeholders = values.map((_, i) => `$${i + 1}`).join(", "); const sql = `INSERT INTO races (${columns.join(", ")}) VALUES (${placeholders}) RETURNING *`; @@ -140,7 +151,7 @@ router.post("/races", async (req: Request, res: Response) => { if (err.code === "23505") { res.status(409).json({ error: "conflict", - details: ["Race with this id already exists"], + details: ["Race with this slug already exists"], }); return; } @@ -162,7 +173,8 @@ router.patch("/races/:id", async (req: Request, res: Response) => { const sets = columns.map((col, i) => `${col} = $${i + 1}`); sets.push(`updated_at = NOW()`); values.push(req.params.id); - const sql = `UPDATE races SET ${sets.join(", ")} WHERE id = $${values.length} RETURNING *`; + values.push(req.auth!.user.id); + const sql = `UPDATE races SET ${sets.join(", ")} WHERE id = $${values.length - 1} AND owner_user_id = $${values.length} RETURNING *`; try { const { rows } = await pool.query(sql, values); @@ -182,8 +194,8 @@ router.patch("/races/:id", async (req: Request, res: Response) => { router.delete("/races/:id", async (req: Request, res: Response) => { try { const { rowCount } = await pool.query( - "DELETE FROM races WHERE id = $1", - [req.params.id], + "DELETE FROM races WHERE id = $1 AND owner_user_id = $2", + [req.params.id, req.auth!.user.id], ); if (rowCount === 0) { res.status(404).json({ error: "not_found", details: ["Race not found"] }); diff --git a/backend/src/security.ts b/backend/src/security.ts new file mode 100644 index 0000000..a50271f --- /dev/null +++ b/backend/src/security.ts @@ -0,0 +1,63 @@ +import crypto from "crypto"; +import argon2 from "argon2"; + +export const PASSWORD_MIN_LENGTH = 15; +export const PASSWORD_MAX_LENGTH = 256; + +export const ARGON2_OPTIONS: argon2.Options & { raw?: false } = { + type: argon2.argon2id, + memoryCost: 65_536, + timeCost: 3, + parallelism: 1, + hashLength: 32, +}; + +const DUMMY_PASSWORD_HASH = + "$argon2id$v=19$m=65536,t=3,p=1$Jkdr1qT0c9cPK5v8m0tEMQ$SLnLmyorTDzBK74I1lrEF92E7S0c6DAm8iMG0YOyAIo"; + +export function normalizeEmail(value: string): string { + return value.trim().toLowerCase(); +} + +export function isValidPassword(value: string): boolean { + return value.length >= PASSWORD_MIN_LENGTH && value.length <= PASSWORD_MAX_LENGTH; +} + +export async function hashPassword(password: string): Promise { + return argon2.hash(password, ARGON2_OPTIONS); +} + +export async function verifyPassword(hash: string | null, password: string): Promise { + const candidate = hash ?? DUMMY_PASSWORD_HASH; + try { + const ok = await argon2.verify(candidate, password); + return hash ? ok : false; + } catch { + return false; + } +} + +export function randomToken(bytes = 32): string { + return crypto.randomBytes(bytes).toString("base64url"); +} + +export function sha256Hex(value: string): string { + return crypto.createHash("sha256").update(value).digest("hex"); +} + +export function timingSafeEqualHex(left: string, right: string): boolean { + const a = Buffer.from(left, "hex"); + const b = Buffer.from(right, "hex"); + if (a.length !== b.length) { + return false; + } + return crypto.timingSafeEqual(a, b); +} + +export function addDays(date: Date, days: number): Date { + return new Date(date.getTime() + days * 24 * 60 * 60 * 1000); +} + +export function addHours(date: Date, hours: number): Date { + return new Date(date.getTime() + hours * 60 * 60 * 1000); +} diff --git a/backend/src/securityLog.ts b/backend/src/securityLog.ts new file mode 100644 index 0000000..07c7b61 --- /dev/null +++ b/backend/src/securityLog.ts @@ -0,0 +1,9 @@ +import { sha256Hex } from "./security"; + +export function anonymizeEmail(email: string): string { + return sha256Hex(email).slice(0, 16); +} + +export function securityLog(event: string, details: Record = {}): void { + console.info("[security]", event, details); +} diff --git a/backend/src/seed.ts b/backend/src/seed.ts index 128fc38..1775b8c 100644 --- a/backend/src/seed.ts +++ b/backend/src/seed.ts @@ -24,6 +24,31 @@ function makeId(date: string, title: string): string { return `${date}-${slugify(title)}`; } +async function resolveSeedOwnerUserId(client: { query: typeof pool.query }): Promise { + const explicit = process.env.SEED_OWNER_USER_ID?.trim(); + if (explicit) { + return explicit; + } + const email = process.env.SEED_OWNER_EMAIL?.trim().toLowerCase(); + if (email) { + const { rows } = await client.query<{ id: string }>( + "SELECT id FROM users WHERE LOWER(BTRIM(email)) = $1", + [email], + ); + if (rows[0]) { + return rows[0].id; + } + throw new Error("SEED_OWNER_EMAIL does not match an existing user"); + } + + const { rows } = await client.query<{ count: string }>("SELECT COUNT(*)::text AS count FROM users"); + const usersCount = Number(rows[0]?.count ?? "0"); + if (usersCount > 0) { + throw new Error("Refusing to seed without SEED_OWNER_USER_ID or SEED_OWNER_EMAIL after users exist"); + } + return null; +} + const CSV_NAME = "races_2026_calendar.csv"; function resolveCsvPath(): string | null { @@ -56,23 +81,28 @@ async function seed() { const client = await pool.connect(); try { + const ownerUserId = await resolveSeedOwnerUserId(client); for (const row of records) { - const id = makeId(row.date, row.event); + const slug = makeId(row.date, row.event); const distanceKm = parseFloat(row.distance_km); - await client.query( - `INSERT INTO races (id, race_date, title, distance_km, status) - VALUES ($1, $2, $3, $4, $5) - ON CONFLICT (id) DO UPDATE SET - race_date = EXCLUDED.race_date, - title = EXCLUDED.title, - distance_km = EXCLUDED.distance_km, - status = EXCLUDED.status, - updated_at = NOW()`, - [id, row.date, row.event, distanceKm, "planned"], - ); + if (ownerUserId) { + await client.query( + `INSERT INTO races (slug, owner_user_id, race_date, title, distance_km, status, source) + VALUES ($1, $2, $3, $4, $5, $6, 'seed') + ON CONFLICT DO NOTHING`, + [slug, ownerUserId, row.date, row.event, distanceKm, "planned"], + ); + } else { + await client.query( + `INSERT INTO races (slug, race_date, title, distance_km, status, source) + VALUES ($1, $2, $3, $4, $5, 'seed') + ON CONFLICT DO NOTHING`, + [slug, row.date, row.event, distanceKm, "planned"], + ); + } - console.log(`[seed] Upserted: ${id}`); + console.log(`[seed] Inserted if missing: ${slug}`); } console.log("[seed] Done."); diff --git a/backend/src/turnstile.ts b/backend/src/turnstile.ts new file mode 100644 index 0000000..f547b06 --- /dev/null +++ b/backend/src/turnstile.ts @@ -0,0 +1,33 @@ +import { config } from "./config"; + +export async function verifyTurnstileToken(token: string, remoteIp?: string): Promise { + if (config.turnstile.bypassToken && token === config.turnstile.bypassToken) { + return true; + } + if (!config.turnstile.secretKey) { + return false; + } + + const body = new URLSearchParams(); + body.set("secret", config.turnstile.secretKey); + body.set("response", token); + if (remoteIp) { + body.set("remoteip", remoteIp); + } + + try { + const response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body, + signal: AbortSignal.timeout(5_000), + }); + if (!response.ok) { + return false; + } + const payload = (await response.json()) as { success?: boolean }; + return payload.success === true; + } catch { + return false; + } +} diff --git a/backend/test/app.test.ts b/backend/test/app.test.ts index e99ea01..241e07a 100644 --- a/backend/test/app.test.ts +++ b/backend/test/app.test.ts @@ -2,9 +2,46 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import request from "supertest"; import { createApp } from "../src/app"; +import { createResetToken, resetPassword } from "../src/authService"; +import { pool } from "../src/db"; import { extractRaceCoverImageFromHtml } from "../src/raceCoverImage"; +import { hashPassword, normalizeEmail } from "../src/security"; const app = createApp(); +let userCounter = 0; + +async function authAgent() { + userCounter += 1; + const email = normalizeEmail(`runner${userCounter}@example.com`); + const password = "correct horse battery staple"; + const passwordHash = await hashPassword(password); + const inserted = await pool.query<{ id: string }>( + `INSERT INTO users (email, password_hash) + VALUES ($1, $2) + RETURNING id`, + [email, passwordHash], + ); + await pool.query("UPDATE users SET email_verified_at = COALESCE(email_verified_at, NOW()) WHERE id = $1", [ + inserted.rows[0].id, + ]); + const agent = request.agent(app); + const login = await agent.post("/api/auth/login").send({ email, password }).expect(200); + return { agent, csrfToken: login.body.csrfToken as string }; +} + +async function createVerifiedUser(email: string, password: string) { + const passwordHash = await hashPassword(password); + const inserted = await pool.query<{ id: string }>( + `INSERT INTO users (email, password_hash) + VALUES ($1, $2) + RETURNING id`, + [normalizeEmail(email), passwordHash], + ); + await pool.query("UPDATE users SET email_verified_at = COALESCE(email_verified_at, NOW()) WHERE id = $1", [ + inserted.rows[0].id, + ]); + return inserted.rows[0].id; +} test("GET /api/health returns ok", async () => { const res = await request(app).get("/api/health").expect(200); @@ -26,27 +63,81 @@ test("GET /api/ready succeeds with mock database", async () => { }); test("GET /api/races rejects invalid year", async () => { - const res = await request(app).get("/api/races?year=bad").expect(400); + const { agent } = await authAgent(); + const res = await agent.get("/api/races?year=bad").expect(400); assert.equal(res.body.error, "validation_error"); assert.ok(Array.isArray(res.body.details)); }); test("GET /api/races rejects month out of range", async () => { - const res = await request(app).get("/api/races?month=13").expect(400); + const { agent } = await authAgent(); + const res = await agent.get("/api/races?month=13").expect(400); assert.equal(res.body.error, "validation_error"); }); test("GET /api/races accepts year and month", async () => { - const res = await request(app).get("/api/races?year=2026&month=5").expect(200); + const { agent } = await authAgent(); + const res = await agent.get("/api/races?year=2026&month=5").expect(200); assert.ok(Array.isArray(res.body)); }); test("GET /api/races/:id returns not_found", async () => { - const res = await request(app).get("/api/races/does-not-exist").expect(404); + const { agent } = await authAgent(); + const res = await agent.get("/api/races/does-not-exist").expect(404); assert.equal(res.body.error, "not_found"); assert.ok(Array.isArray(res.body.details)); }); +test("GET /api/races requires authentication", async () => { + const res = await request(app).get("/api/races").expect(401); + assert.equal(res.body.error, "unauthorized"); +}); + +test("login uses generic response for missing user and wrong password", async () => { + const password = "correct horse battery staple"; + await createVerifiedUser("generic@example.com", password); + + const wrongPassword = await request(app) + .post("/api/auth/login") + .send({ email: "generic@example.com", password: "wrong password" }) + .expect(401); + const missingUser = await request(app) + .post("/api/auth/login") + .send({ email: "missing@example.com", password }) + .expect(401); + + assert.deepEqual(missingUser.body, wrongPassword.body); +}); + +test("GET /api/races/:id returns not_found for another user's race", async () => { + const first = await authAgent(); + const created = await first.agent + .post("/api/races") + .set("X-CSRF-Token", first.csrfToken) + .send({ + slug: "2026-07-01-private-race", + date: "2026-07-01", + title: "Private Race", + distanceKm: 10, + }) + .expect(201); + + const second = await authAgent(); + const res = await second.agent.get(`/api/races/${created.body.id}`).expect(404); + assert.equal(res.body.error, "not_found"); +}); + +test("new password reset token invalidates previous token", async () => { + const userId = await createVerifiedUser("reset@example.com", "correct horse battery staple"); + const client = await pool.connect(); + const first = await createResetToken(client, userId); + const second = await createResetToken(client, userId); + client.release(); + + assert.equal(await resetPassword(first, "new correct horse battery staple"), false); + assert.equal(await resetPassword(second, "new correct horse battery staple"), true); +}); + test("extractRaceCoverImageFromHtml prefers runc.run intro image", () => { const html = ` @@ -81,11 +172,13 @@ test("extractRaceCoverImageFromHtml reads Open Graph and Twitter images", () => }); test("POST /api/races stores manual coverImageUrl", async () => { + const { agent, csrfToken } = await authAgent(); const coverImageUrl = "https://example.com/manual.jpg"; - const res = await request(app) + const res = await agent .post("/api/races") + .set("X-CSRF-Token", csrfToken) .send({ - id: "2026-06-01-manual-cover", + slug: "2026-06-01-manual-cover", date: "2026-06-01", title: "Manual Cover", distanceKm: 10, @@ -106,10 +199,12 @@ test("POST /api/races auto extracts coverImageUrl from officialUrl", async () => }); try { - const res = await request(app) + const { agent, csrfToken } = await authAgent(); + const res = await agent .post("/api/races") + .set("X-CSRF-Token", csrfToken) .send({ - id: "2026-06-02-auto-cover", + slug: "2026-06-02-auto-cover", date: "2026-06-02", title: "Auto Cover", distanceKm: 21.1, @@ -130,10 +225,12 @@ test("POST /api/races succeeds when cover extraction fails", async () => { }; try { - const res = await request(app) + const { agent, csrfToken } = await authAgent(); + const res = await agent .post("/api/races") + .set("X-CSRF-Token", csrfToken) .send({ - id: "2026-06-03-cover-fail", + slug: "2026-06-03-cover-fail", date: "2026-06-03", title: "Cover Fail", distanceKm: 5, @@ -148,11 +245,12 @@ test("POST /api/races succeeds when cover extraction fails", async () => { }); test("PATCH /api/races/:id updates coverImageUrl explicitly", async () => { - const id = "2026-06-04-patch-cover"; - await request(app) + const { agent, csrfToken } = await authAgent(); + const created = await agent .post("/api/races") + .set("X-CSRF-Token", csrfToken) .send({ - id, + slug: "2026-06-04-patch-cover", date: "2026-06-04", title: "Patch Cover", distanceKm: 10, @@ -160,8 +258,9 @@ test("PATCH /api/races/:id updates coverImageUrl explicitly", async () => { .expect(201); const coverImageUrl = "https://example.com/patched.jpg"; - const res = await request(app) - .patch(`/api/races/${id}`) + const res = await agent + .patch(`/api/races/${created.body.id}`) + .set("X-CSRF-Token", csrfToken) .send({ coverImageUrl }) .expect(200); diff --git a/docs/auth-rollout.md b/docs/auth-rollout.md new file mode 100644 index 0000000..c279fac --- /dev/null +++ b/docs/auth-rollout.md @@ -0,0 +1,13 @@ +# Auth rollout + +Before applying auth and ownership migrations in production: + +1. Run `pg_dump` for the target database and keep the dump until verification is complete. +2. Record the rollback command for restoring that dump if migration or legacy race claiming fails. +3. Record the current race count with `SELECT count(*) FROM races;`. +4. Run migrations. +5. After the first email-verified user claims legacy races, verify: + - `SELECT count(*) FROM races;` matches the pre-migration count. + - `SELECT count(*) FROM races WHERE owner_user_id IS NULL;` is `0` before planning the follow-up `NOT NULL` migration. + +Seed/import after auth requires `SEED_OWNER_USER_ID` or `SEED_OWNER_EMAIL` once users exist. It inserts missing seed races for that owner and does not overwrite user-edited races. diff --git a/frontend/.env.example b/frontend/.env.example index 9fdf8ec..5aef37b 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,2 +1,4 @@ -# Для локального npm run dev дополнительных VITE-переменных не требуется. +# Для production-регистрации укажите публичный site key Cloudflare Turnstile. +# Без значения локально используется dev bypass token, если он разрешён бэкендом. +# VITE_TURNSTILE_SITE_KEY= # Полный список переменных окружения — в корневом .env.example репозитория. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3ad31a8..f33f133 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "calendar-run-frontend", - "version": "0.6.1", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "calendar-run-frontend", - "version": "0.6.1", + "version": "0.7.0", "dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/frontend/package.json b/frontend/package.json index 172d4b8..2a726cd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "calendar-run-frontend", "private": true, - "version": "0.6.1", + "version": "0.7.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts new file mode 100644 index 0000000..32d74c8 --- /dev/null +++ b/frontend/src/api/auth.ts @@ -0,0 +1,69 @@ +import { requestJson, setCsrfToken } from "./http"; +import type { AuthUser } from "./types"; + +interface AuthResponse { + user: AuthUser; + csrfToken: string | null; +} + +function applyAuthResponse(response: AuthResponse): AuthResponse { + setCsrfToken(response.csrfToken); + return response; +} + +export async function getCurrentUser(): Promise { + return applyAuthResponse(await requestJson("/auth/me")); +} + +export async function register(payload: { + email: string; + password: string; + turnstileToken: string; +}): Promise { + await requestJson("/auth/register", { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export async function login(payload: { email: string; password: string }): Promise { + return applyAuthResponse( + await requestJson("/auth/login", { + method: "POST", + body: JSON.stringify(payload), + }), + ); +} + +export async function logout(): Promise { + await requestJson("/auth/logout", { method: "POST" }); + setCsrfToken(null); +} + +export async function verifyEmail(token: string): Promise { + await requestJson("/auth/verify-email", { + method: "POST", + body: JSON.stringify({ token }), + }); +} + +export async function resendVerification(email: string): Promise { + await requestJson("/auth/resend-verification", { + method: "POST", + body: JSON.stringify({ email }), + }); +} + +export async function forgotPassword(email: string): Promise { + await requestJson("/auth/forgot-password", { + method: "POST", + body: JSON.stringify({ email }), + }); +} + +export async function resetPassword(token: string, password: string): Promise { + await requestJson("/auth/reset-password", { + method: "POST", + body: JSON.stringify({ token, password }), + }); +} diff --git a/frontend/src/api/errors.ts b/frontend/src/api/errors.ts index a89a9eb..bac287f 100644 --- a/frontend/src/api/errors.ts +++ b/frontend/src/api/errors.ts @@ -3,6 +3,12 @@ export type ApiErrorCode = | "not_found" | "database_unavailable" | "conflict" + | "unauthorized" + | "email_not_verified" + | "csrf_error" + | "captcha_failed" + | "invalid_credentials" + | "invalid_token" | "network_error" | "unknown_error"; @@ -36,6 +42,12 @@ function normalizeApiCode(value: string | undefined): ApiErrorCode { value === "not_found" || value === "database_unavailable" || value === "conflict" || + value === "unauthorized" || + value === "email_not_verified" || + value === "csrf_error" || + value === "captcha_failed" || + value === "invalid_credentials" || + value === "invalid_token" || value === "unknown_error" ) { return value; @@ -98,6 +110,18 @@ export function getApiErrorMessage(code: ApiErrorCode): string { return "Сервис временно недоступен. Попробуйте позже."; case "conflict": return "Запись с таким идентификатором уже существует."; + case "unauthorized": + return "Нужно войти в аккаунт."; + case "email_not_verified": + return "Подтвердите email, чтобы продолжить."; + case "csrf_error": + return "Сессия устарела. Обновите страницу и попробуйте снова."; + case "captcha_failed": + return "Проверка капчи не пройдена."; + case "invalid_credentials": + return "Неверный email или пароль."; + case "invalid_token": + return "Ссылка недействительна или устарела."; case "network_error": return "Не удалось связаться с сервером."; case "unknown_error": diff --git a/frontend/src/api/http.ts b/frontend/src/api/http.ts index 35bbe22..c7d006c 100644 --- a/frontend/src/api/http.ts +++ b/frontend/src/api/http.ts @@ -1,6 +1,11 @@ import { ApiError, isStructuredApiErrorPayload, toApiError } from "./errors"; const API_ROOT = "/api"; +let csrfToken: string | null = null; + +export function setCsrfToken(token: string | null): void { + csrfToken = token; +} function buildUrl(path: string): string { const normalizedPath = path.startsWith("/") ? path : `/${path}`; @@ -61,9 +66,13 @@ export async function requestJson(path: string, init?: RequestInit): Promise< if (method !== "GET" && method !== "HEAD") { defaultHeaders["Content-Type"] = "application/json"; } + if (csrfToken && ["POST", "PUT", "PATCH", "DELETE"].includes(method)) { + defaultHeaders["X-CSRF-Token"] = csrfToken; + } const response = await fetch(buildUrl(path), { ...init, + credentials: "include", headers: { ...defaultHeaders, ...(init?.headers ?? {}), diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 1cf165f..d1dd26b 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -1,5 +1,15 @@ -export type { CreateRacePayload, Race, RacesQuery, RaceStatus, UpdateRacePayload } from "./types"; +export type { AuthUser, CreateRacePayload, Race, RacesQuery, RaceStatus, UpdateRacePayload } from "./types"; export { ApiError, getApiErrorMessage } from "./errors"; export type { BackendMetaResponse, HealthResponse } from "./health"; export { getBackendMeta, getHealth } from "./health"; export { getRaceById, getRaces, createRace, updateRace, deleteRace } from "./races"; +export { + forgotPassword, + getCurrentUser, + login, + logout, + register, + resendVerification, + resetPassword, + verifyEmail, +} from "./auth"; diff --git a/frontend/src/api/races.ts b/frontend/src/api/races.ts index 6d2b770..1486146 100644 --- a/frontend/src/api/races.ts +++ b/frontend/src/api/races.ts @@ -19,6 +19,7 @@ function normalizeRace(input: unknown): Race { const isValid = isString(race?.id) && + isString(race?.slug) && isString(race?.date) && isString(race?.title) && typeof race?.distanceKm === "number" && @@ -48,6 +49,7 @@ function normalizeRace(input: unknown): Race { return { id: race.id, + slug: race.slug, date: race.date, title: race.title, distanceKm: race.distanceKm, diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 571bcb5..79ba65d 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -2,6 +2,7 @@ export type RaceStatus = "planned" | "registered" | "completed"; export interface Race { id: string; + slug: string; date: string; title: string; distanceKm: number; @@ -25,7 +26,7 @@ export interface RacesQuery { } export interface CreateRacePayload { - id: string; + slug: string; date: string; title: string; distanceKm: number; @@ -42,3 +43,9 @@ export interface CreateRacePayload { } export type UpdateRacePayload = Partial>; + +export interface AuthUser { + id: string; + email: string; + emailVerifiedAt: string | null; +} diff --git a/frontend/src/app/auth/AuthContext.tsx b/frontend/src/app/auth/AuthContext.tsx new file mode 100644 index 0000000..f1cbb3f --- /dev/null +++ b/frontend/src/app/auth/AuthContext.tsx @@ -0,0 +1,68 @@ +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"; +import type { AuthUser } from "../../api"; +import { ApiError, getCurrentUser, login as loginRequest, logout as logoutRequest } from "../../api"; + +interface AuthContextValue { + user: AuthUser | null; + isLoading: boolean; + login(email: string, password: string): Promise; + logout(): Promise; + refresh(): Promise; +} + +const AuthContext = createContext(null); + +export function AuthProvider(props: { children: React.ReactNode }): JSX.Element { + const [user, setUser] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + const refresh = useCallback(async () => { + try { + const response = await getCurrentUser(); + setUser(response.user); + } catch (error) { + if (error instanceof ApiError && error.status === 401) { + setUser(null); + return; + } + setUser(null); + } + }, []); + + useEffect(() => { + let mounted = true; + void refresh().finally(() => { + if (mounted) { + setIsLoading(false); + } + }); + return () => { + mounted = false; + }; + }, [refresh]); + + const login = useCallback(async (email: string, password: string) => { + const response = await loginRequest({ email, password }); + setUser(response.user); + }, []); + + const logout = useCallback(async () => { + await logoutRequest(); + setUser(null); + }, []); + + const value = useMemo( + () => ({ user, isLoading, login, logout, refresh }), + [user, isLoading, login, logout, refresh], + ); + + return {props.children}; +} + +export function useAuth(): AuthContextValue { + const context = useContext(AuthContext); + if (!context) { + throw new Error("useAuth must be used inside AuthProvider"); + } + return context; +} diff --git a/frontend/src/app/auth/RequireAuth.tsx b/frontend/src/app/auth/RequireAuth.tsx new file mode 100644 index 0000000..7086007 --- /dev/null +++ b/frontend/src/app/auth/RequireAuth.tsx @@ -0,0 +1,26 @@ +import { Navigate, Outlet, useLocation } from "react-router-dom"; +import { useAuth } from "./AuthContext"; + +export function RequireAuth(): JSX.Element { + const { user, isLoading } = useAuth(); + const location = useLocation(); + + if (isLoading) { + return ( +
+

Загрузка

+

Проверяем сессию...

+
+ ); + } + + if (!user) { + return ; + } + + if (!user.emailVerifiedAt) { + return ; + } + + return ; +} diff --git a/frontend/src/app/layouts/AppLayout.tsx b/frontend/src/app/layouts/AppLayout.tsx index 6c3c13a..d99ba4c 100644 --- a/frontend/src/app/layouts/AppLayout.tsx +++ b/frontend/src/app/layouts/AppLayout.tsx @@ -1,7 +1,10 @@ import { Link, NavLink, Outlet } from "react-router-dom"; +import { useAuth } from "../auth/AuthContext"; import { AppShellFooter } from "./AppShellFooter"; export function AppLayout(): JSX.Element { + const { user, logout } = useAuth(); + return (
@@ -34,6 +37,20 @@ export function AppLayout(): JSX.Element { > + Добавить + {user ? ( + + ) : ( + + isActive ? "app-shell__link app-shell__link--active" : "app-shell__link" + } + > + Войти + + )}
diff --git a/frontend/src/app/router.tsx b/frontend/src/app/router.tsx index 307e461..1b6a6a7 100644 --- a/frontend/src/app/router.tsx +++ b/frontend/src/app/router.tsx @@ -5,18 +5,30 @@ import { RacesPage } from "../pages/RacesPage"; import { RaceDetailsPage } from "../pages/RaceDetailsPage"; import { RaceFormPage } from "../pages/RaceFormPage"; import { RaceDayPage } from "../pages/RaceDayPage"; +import { ForgotPasswordPage, LoginPage, RegisterPage, ResetPasswordPage, VerifyEmailPage } from "../pages/AuthPages"; +import { RequireAuth } from "./auth/RequireAuth"; export const appRouter = createBrowserRouter([ { path: "/", element: , children: [ - { index: true, element: }, - { path: "races", element: }, - { path: "races/new", element: }, - { path: "races/day/:ymd", element: }, - { path: "races/:raceId", element: }, - { path: "races/:raceId/edit", element: }, + { path: "login", element: }, + { path: "register", element: }, + { path: "verify-email", element: }, + { path: "forgot-password", element: }, + { path: "reset-password", element: }, + { + element: , + children: [ + { index: true, element: }, + { path: "races", element: }, + { path: "races/new", element: }, + { path: "races/day/:ymd", element: }, + { path: "races/:raceId", element: }, + { path: "races/:raceId/edit", element: }, + ], + }, ], }, ]); diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 0ec46c4..cad3354 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,12 +1,15 @@ import React from "react"; import ReactDOM from "react-dom/client"; import { RouterProvider } from "react-router-dom"; +import { AuthProvider } from "./app/auth/AuthContext"; import { appRouter } from "./app/router"; import "./styles/tokens.css"; import "./styles/global.css"; ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - + + + ); diff --git a/frontend/src/pages/AuthPages.tsx b/frontend/src/pages/AuthPages.tsx new file mode 100644 index 0000000..fba900c --- /dev/null +++ b/frontend/src/pages/AuthPages.tsx @@ -0,0 +1,253 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { Link, Navigate, useLocation, useNavigate, useSearchParams } from "react-router-dom"; +import { + ApiError, + forgotPassword, + register, + resendVerification, + resetPassword, + verifyEmail, +} from "../api"; +import { useAuth } from "../app/auth/AuthContext"; + +function errorMessage(error: unknown, fallback: string): string { + if (error instanceof ApiError) { + return error.details.length > 0 ? error.details.join("; ") : error.message; + } + return fallback; +} + +function TurnstileField(props: { onToken(token: string): void }): JSX.Element { + const ref = useRef(null); + const siteKey = import.meta.env.VITE_TURNSTILE_SITE_KEY; + const { onToken } = props; + + useEffect(() => { + if (!siteKey || !ref.current) { + onToken("mock-turnstile-token"); + return; + } + + const scriptId = "turnstile-script"; + if (!document.getElementById(scriptId)) { + const script = document.createElement("script"); + script.id = scriptId; + script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"; + script.async = true; + script.defer = true; + document.head.appendChild(script); + } + + let widgetId: string | null = null; + const timer = window.setInterval(() => { + if (window.turnstile && ref.current && !widgetId) { + widgetId = window.turnstile.render(ref.current, { + sitekey: siteKey, + callback: onToken, + "expired-callback": () => onToken(""), + }); + window.clearInterval(timer); + } + }, 100); + + return () => { + window.clearInterval(timer); + if (widgetId && window.turnstile) { + window.turnstile.remove(widgetId); + } + }; + }, [onToken, siteKey]); + + return
; +} + +export function LoginPage(): JSX.Element { + const { user, login } = useAuth(); + const navigate = useNavigate(); + const location = useLocation(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + if (user?.emailVerifiedAt) { + return ; + } + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + setIsSubmitting(true); + setError(null); + try { + await login(email, password); + const from = (location.state as { from?: Location } | null)?.from?.pathname ?? "/"; + navigate(from, { replace: true }); + } catch (err) { + setError(errorMessage(err, "Не удалось войти.")); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+

Вход

+
+ + + {error ?

{error}

: null} + +
+

+ Регистрация + Забыли пароль? +

+
+ ); +} + +export function RegisterPage(): JSX.Element { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [turnstileToken, setTurnstileToken] = useState(""); + const [message, setMessage] = useState(null); + const [error, setError] = useState(null); + + const handleToken = useCallback((token: string) => setTurnstileToken(token), []); + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + setError(null); + setMessage(null); + try { + await register({ email, password, turnstileToken }); + setMessage("Проверьте почту: мы отправили ссылку для подтверждения email."); + } catch (err) { + setError(errorMessage(err, "Не удалось зарегистрироваться.")); + } + }; + + return ( +
+

Регистрация

+
+ + + + {message ?

{message}

: null} + {error ?

{error}

: null} + + +

+ Уже есть аккаунт +

+
+ ); +} + +export function VerifyEmailPage(): JSX.Element { + const { user, refresh } = useAuth(); + const [params] = useSearchParams(); + const [email, setEmail] = useState(user?.email ?? ""); + const [message, setMessage] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + const token = params.get("token"); + if (!token) { + return; + } + void verifyEmail(token) + .then(async () => { + await refresh(); + setMessage("Email подтверждён. Теперь можно пользоваться календарём."); + }) + .catch((err) => setError(errorMessage(err, "Ссылка недействительна."))); + }, [params, refresh]); + + const resend = async () => { + setError(null); + await resendVerification(email); + setMessage("Если email зарегистрирован и ещё не подтверждён, письмо отправлено."); + }; + + return ( +
+

Подтверждение email

+

Для доступа к календарю подтвердите email по ссылке из письма.

+
{ e.preventDefault(); void resend(); }}> + + {message ?

{message}

: null} + {error ?

{error}

: null} + +
+
+ ); +} + +export function ForgotPasswordPage(): JSX.Element { + const [email, setEmail] = useState(""); + const [message, setMessage] = useState(null); + + return ( +
+

Сброс пароля

+
{ e.preventDefault(); await forgotPassword(email); setMessage("Если email зарегистрирован, ссылка отправлена."); }}> + + {message ?

{message}

: null} + +
+
+ ); +} + +export function ResetPasswordPage(): JSX.Element { + const [params] = useSearchParams(); + const [password, setPassword] = useState(""); + const [message, setMessage] = useState(null); + const [error, setError] = useState(null); + + return ( +
+

Новый пароль

+
{ + e.preventDefault(); + const token = params.get("token") ?? ""; + try { + await resetPassword(token, password); + setMessage("Пароль обновлён. Теперь войдите заново."); + } catch (err) { + setError(errorMessage(err, "Не удалось обновить пароль.")); + } + }}> + + {message ?

{message}

: null} + {error ?

{error}

: null} + +
+
+ ); +} diff --git a/frontend/src/pages/RaceFormPage.tsx b/frontend/src/pages/RaceFormPage.tsx index 5df1c33..d30002d 100644 --- a/frontend/src/pages/RaceFormPage.tsx +++ b/frontend/src/pages/RaceFormPage.tsx @@ -224,9 +224,9 @@ export function RaceFormPage(): JSX.Element { await updateRace(raceId, payload); navigate(`/races/${raceId}`); } else { - const id = generateId(form.date.trim(), form.title.trim()); + const slug = generateId(form.date.trim(), form.title.trim()); const payload: CreateRacePayload = { - id, + slug, date: form.date.trim(), title: form.title.trim(), distanceKm: parseFloat(form.distanceKm), diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 6d25522..8efaffb 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -63,6 +63,13 @@ a { color: var(--color-text-muted); } +.app-shell__link--button { + border: 0; + background: transparent; + font: inherit; + cursor: pointer; +} + .app-shell__link:hover, .app-shell__link:focus-visible { color: var(--color-text); @@ -122,6 +129,54 @@ a { color: var(--color-error); } +.page--auth { + max-width: 32rem; + margin: 0 auto; +} + +.auth-form { + margin-top: var(--space-5); + display: grid; + gap: var(--space-4); +} + +.auth-form__field { + display: grid; + gap: var(--space-2); +} + +.auth-form__label { + color: var(--color-text-muted); + font-size: var(--font-size-caption); + font-weight: 600; +} + +.auth-form__input { + width: 100%; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + padding: var(--space-3); + font: inherit; + color: var(--color-text); + background: var(--color-surface); +} + +.auth-form__input:focus { + border-color: var(--color-accent); + outline: 2px solid color-mix(in srgb, var(--color-accent) 22%, transparent); +} + +.auth-form__captcha { + min-height: 4rem; +} + +.auth-form__links { + margin: var(--space-5) 0 0; + display: flex; + flex-wrap: wrap; + gap: var(--space-4); +} + .dashboard-grid { margin-top: var(--space-6); display: grid; @@ -1358,7 +1413,8 @@ body { .dashboard-hero--with-image { background: linear-gradient(90deg, rgba(7, 25, 39, 0.94) 0%, rgba(7, 25, 39, 0.68) 48%, rgba(7, 25, 39, 0.2) 100%), - var(--dashboard-hero-image) center / cover; + var(--dashboard-hero-image) center / cover, + url("/images/runner-hero.jpg") center / cover; } .dashboard-hero__content, diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts index 11f02fe..fe8c248 100644 --- a/frontend/src/vite-env.d.ts +++ b/frontend/src/vite-env.d.ts @@ -1 +1,12 @@ /// + +interface ImportMetaEnv { + readonly VITE_TURNSTILE_SITE_KEY?: string; +} + +interface Window { + turnstile?: { + render(container: HTMLElement, options: { sitekey: string; callback(token: string): void; "expired-callback"(): void }): string; + remove(widgetId: string): void; + }; +}