diff --git a/.dockerignore b/.dockerignore index 5081345..c8ee2c8 100644 --- a/.dockerignore +++ b/.dockerignore @@ -44,4 +44,5 @@ detector-logs app-logs start.sh config.dev.json -*.md \ No newline at end of file +*.md +.tokeignore \ No newline at end of file diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index d86b48b..01f0153 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -14,21 +14,6 @@ jobs: contents: read environment: copilot - services: - mysql: - image: mysql:8.4 - env: - MYSQL_DATABASE: app_db - MYSQL_ROOT_PASSWORD: 1234 - ports: [ "3306:3306" ] - options: >- - --health-cmd="mysqladmin ping -h 127.0.0.1 -uroot -p1234" - --health-interval=10s --health-timeout=5s --health-retries=10 - - nginx: - image: nginx:1.27-alpine - ports: [ "8080:80" ] - steps: - name: Checkout code uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 @@ -43,31 +28,49 @@ jobs: env: DEPLOY_KEY: ${{ secrets.BOTDETECTOR_DEPLOY_KEY }} run: | - install -m 700 -d ~/.ssh + mkdir -p ~/.ssh printf '%s\n' "$DEPLOY_KEY" > ~/.ssh/id_ed25519 chmod 600 ~/.ssh/id_ed25519 ssh-keyscan github.com >> ~/.ssh/known_hosts eval "$(ssh-agent -s)" ssh-add ~/.ssh/id_ed25519 + echo "SSH_AUTH_SOCK=$SSH_AUTH_SOCK" >> $GITHUB_ENV - - name: Trust github.com host key + - name: Install dependencies run: | - mkdir -p ~/.ssh - ssh-keyscan github.com >> ~/.ssh/known_hosts + sudo apt-get update + sudo apt-get install -y age docker-compose-plugin - - name: Tools + - name: Create Test Env run: | - sudo apt-get update - sudo apt-get install -y mysql-client curl age + # Create .env.test for local test runner and setupTestDB.ts + echo "TEST_DB_HOST=localhost" > .env.test + echo "TEST_DB_PORT=3308" >> .env.test + echo "TEST_DB_USER=root" >> .env.test + echo "TEST_DB_PASSWORD=very_secure_password_for_tests" >> .env.test + echo "TEST_DB_NAME=my_auth_tests_db" >> .env.test + + # We assume config.test.json is committed by user + + - name: Start Services (Build & Up) + env: + SSH_KEY_PATH: ~/.ssh/id_ed25519 + run: | + chmod +x ./start.sh + # Uses committed config.test.json + ./start.sh config.test.json auth-test - name: Wait for MySQL run: | - for i in {1..60}; do mysqladmin ping -h 127.0.0.1 -uroot -p1234 --silent && break; sleep 2; done - mysql -h 127.0.0.1 -uroot -p1234 -e "CREATE DATABASE IF NOT EXISTS app_db;" + # Wait for exposed port 3307 + for i in {1..60}; do mysqladmin ping -h 127.0.0.1 -P 3308 -uroot -pvery_secure_password_for_tests --silent && break; sleep 2; done - - name: Install dependencies - run: npm install - - - name: Build - run: npm run build + - name: Setup Database Schema + run: | + npm ci + # Assuming setupTestDB.ts handles creating tables + npx ts-node test/setup/setupTestDB.ts + - name: Run All Tests + run: | + npm test diff --git a/.gitignore b/.gitignore index 3d668fa..b8af57e 100644 --- a/.gitignore +++ b/.gitignore @@ -30,4 +30,5 @@ public_key detector-logs app-logs config.dev.json -.vscode/ \ No newline at end of file +.vscode/ +.tokeignore \ No newline at end of file diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index bf94af9..0b7fcf2 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -296,6 +296,38 @@ sequenceDiagram A->>C: Return tokens + cookies ``` +### Custom MFA Flow + +```mermaid +sequenceDiagram + participant C as Client/BFF + participant A as Auth Service + participant D as Database + participant E as Email Service + + C->>A: POST /custom/mfa:reason?rand=... + A->>A: Rate limit check + A->>A: Session validation + A->>A: Verify BFF client IP + A->>A: Generate MFA code + Magic Link JWT + A->>D: Store hashed code in mfa_codes + A->>E: Send MFA email with link + A->>C: Return {ok: true} + + Note over C,A: User clicks magic link + + C->>A: GET /auth/verify-custom-mfa?visitor=...&temp=...&random=... + A->>A: Validate JWT signature + A->>A: Compare random hash (timingSafeEqual) + A->>C: Return {link: "Custom MFA"} + + C->>A: POST /auth/verify-custom-mfa?visitor=...&... {code} + A->>D: Verify code_hash against mfa_codes + A->>D: Delete code (atomic consumption) + A->>D: Rotate session tokens + A->>C: Return new access token + cookies +``` + ## Scalability Considerations ### Horizontal Scaling diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index a9a6f7d..aff3c3e 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -144,10 +144,17 @@ test/ │ ├── verification.test.ts │ ├── security.test.ts │ └── ... -└── refreshTokens-test/ # Refresh token tests - ├── generateRefreshToken.test.ts - ├── rotateRefreshToken.test.ts - └── ... +├── refreshTokens-test/ # Refresh token tests +│ ├── generateRefreshToken.test.ts +│ ├── rotateRefreshToken.test.ts +│ └── ... +├── initCustomMfaFlow/ # Custom MFA initialization tests +│ └── init.test.ts +├── verifyCustomMfaController/ # Custom MFA verification tests +│ └── verify.test.ts +└── utils/ # Utility tests + └── verifyMfaCode/ + └── verifyMfaCode.test.ts ``` ## Development Workflow diff --git a/decrypt.sh b/decrypt.sh index f2408a3..046b65e 100644 --- a/decrypt.sh +++ b/decrypt.sh @@ -2,15 +2,21 @@ set -e KEY_FILE="/run/secrets/age_key" -OUT="/run/app/config.json" +OUT=${CONFIG_PATH:-"/run/app/config.json"} +FILE=${ENCRYPTED_SOURCE:-"config.json.age"} if [ ! -f "$KEY_FILE" ]; then echo "ERROR: Secret key file not found at $KEY_FILE" exit 1 fi +if [ ! -f "$FILE" ]; then + echo "ERROR: Encrypted config file not found at $FILE" + exit 1 +fi + echo "Decrypting secrets..." -age -d -i "$KEY_FILE" -o "$OUT" /app/config.json.age +age -d -i "$KEY_FILE" -o "$OUT" $FILE chmod 0400 "$OUT" echo "Secrets decrypted and loaded into environment." diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..e538d91 --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,52 @@ +services: + mysql-test: + image: mysql:8.0 + restart: unless-stopped + command: --local-infile=1 + environment: + MYSQL_ROOT_PASSWORD: ${TEST_DB_PASSWORD} + MYSQL_DATABASE: ${TEST_DB_NAME} + MYSQL_USER: ${TEST_DB_USER} + MYSQL_PASSWORD: ${TEST_DB_PASSWORD} + ports: + - "3308:3306" + healthcheck: + test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"] + timeout: 20s + retries: 10 + tmpfs: + - /var/lib/mysql:rw,noexec,nosuid,nodev,size=512m + + auth-test: + image: jwtauth-service:latest + extra_hosts: + - "host.docker.internal:host-gateway" + pull_policy: never + read_only: true + build: + context: ./ + dockerfile: Dockerfile + ssh: + - default + restart: unless-stopped + cap_drop: ["ALL"] + user: 10001:10001 + depends_on: + mysql-test: + condition: service_healthy + volumes: + - ./app-logs:/app/logs:rw + - ./detector-logs:/app/node_modules/@riavzon/botdetector/logs:rw + tmpfs: + - /run/app:rw,noexec,nosuid,nodev,uid=10001,gid=10001,size=1m + pids_limit: 200 + ports: + - "10000:10000" + secrets: + - age_key + security_opt: + - "no-new-privileges:true" + +secrets: + age_key: + file: ./age_key diff --git a/package.json b/package.json index fe0693c..f943464 100644 --- a/package.json +++ b/package.json @@ -34,46 +34,46 @@ }, "dependencies": { "@riavzon/botdetector": "github:Sergo706/botDetector#71a1c78e65cdd609c0a389496be84eb8797fac33", - "@types/date-fns": "^2.5.3", - "@types/ejs": "^3.1.5", - "@types/he": "^1.2.3", - "@types/jsonwebtoken": "^9.0.9", - "@types/sanitize-html": "^2.16.0", - "argon2": "^0.44.0", - "cookie-parser": "^1.4.7", - "date-fns": "^4.1.0", - "ejs": "^4.0.1", - "express": "^5.1.0", - "he": "^1.2.0", - "helmet": "^8.1.0", - "ip-range-check": "^0.2.0", - "jsonwebtoken": "^9.0.2", - "lru-cache": "^11.1.0", - "mysql2": "^3.14.0", - "pino": "^10.0.0", - "pino-http": "^10.5.0", - "rate-limiter-flexible": "^9.0.1", - "resend": "^6.0.1", - "sanitize-html": "^2.17.0", - "telegraf": "^4.16.3", - "ts-node": "^10.9.2", + "argon2": "0.44.0", + "cookie-parser": "1.4.7", + "date-fns": "4.1.0", + "ejs": "4.0.1", + "express": "5.1.0", + "he": "1.2.0", + "helmet": "8.1.0", + "ip-range-check": "0.2.0", + "jsonwebtoken": "9.0.2", + "lru-cache": "11.1.0", + "mysql2": "3.14.0", + "pino": "10.0.0", + "pino-http": "10.5.0", + "rate-limiter-flexible": "9.0.1", + "resend": "6.0.1", + "sanitize-html": "2.17.0", + "telegraf": "4.16.3", + "ts-node": "10.9.2", "zod": "^4.0.14" }, "overrides": { - "pino": "^10.0.0", + "pino": "10.0.0", "ua-parser-js": "2.0.3" }, "devDependencies": { - "@rollup/plugin-terser": "^0.4.4", - "@types/cookie-parser": "^1.4.9", - "@types/express": "^5.0.3", - "@types/node": "^25.1.0", - "@vitest/coverage-v8": "^4.0.18", - "@vitest/ui": "^4.0.18", - "dotenv": "^17.2.2", - "typedoc": "^0.28.13", - "typedoc-plugin-markdown": "^4.8.1", - "vitepress": "^1.6.4", - "vitest": "^4.0.18" + "@rollup/plugin-terser": "0.4.4", + "@types/jsonwebtoken": "9.0.9", + "@types/sanitize-html": "2.16.0", + "@types/date-fns": "2.5.3", + "@types/ejs": "3.1.5", + "@types/he": "1.2.3", + "@types/cookie-parser": "1.4.10", + "@types/express": "5.0.3", + "@types/node": "25.1.0", + "@vitest/coverage-v8": "4.0.18", + "@vitest/ui": "4.0.18", + "dotenv": "17.2.2", + "typedoc": "0.28.13", + "typedoc-plugin-markdown": "4.8.1", + "vitepress": "1.6.4", + "vitest": "4.0.18" } } diff --git a/src/jwtAuth/config/botDetectorConfig.ts b/src/jwtAuth/config/botDetectorConfig.ts index 45887dc..5de2aee 100644 --- a/src/jwtAuth/config/botDetectorConfig.ts +++ b/src/jwtAuth/config/botDetectorConfig.ts @@ -3,10 +3,7 @@ import { BotDetectorConfig }from "@riavzon/botdetector" export function configBotDetector(useDefault: boolean): void | BotDetectorConfig{ const config = getConfiguration() - if (!config.botDetector.enableBotDetector) return; - const botDetectorSettings = config.botDetector.settings?.botDetectorConfig - if (!useDefault) return botDetectorSettings; const defaultSettings: BotDetectorConfig = { storeAndTelegram: { @@ -23,7 +20,7 @@ export function configBotDetector(useDefault: boolean): void | BotDetectorConf restoredReputaionPoints: 1, setNewComputedScore: false, banUnlistedBots: true, - whiteList: ["172.18.0.1", "172.21.10.1", "127.0.0.1", "172.20.5.4", "172.21.10.4", "::ffff:127.0.0.1"], + whiteList: ["172.18.0.1", "172.29.20.1", "172.21.10.1", "127.0.0.1", "172.20.5.4", "172.21.10.4", "::ffff:127.0.0.1"], checksTimeRateControl: { checkEveryReqest: true, checkEvery: 1000 * 60 * 5, @@ -120,6 +117,107 @@ export function configBotDetector(useDefault: boolean): void | BotDetectorConf logLevel: 'info' } + + const passiveMode: BotDetectorConfig = { + storeAndTelegram: { + store: { + main: config.store.main + }, + telegram: { + enableTelegramLogger: false + } + }, + banScore: 100, + maxScore: 300, + proxy: true, + restoredReputaionPoints: 1, + setNewComputedScore: false, + banUnlistedBots: true, + whiteList: ["172.18.0.1","172.29.20.1", "172.21.10.1", "127.0.0.1", "172.20.5.4", "172.21.10.4", "::ffff:127.0.0.1"], + checksTimeRateControl: { + checkEveryReqest: true, + checkEvery: 1000 * 60 * 5, + }, + penalties: { + ipInvalid: 0, + behaviorTooFast: { + behaviorPenalty: 0, + behavioural_window: 60_000, + behavioural_threshold: 30 + }, + headerOptions: { + weightPerMustHeader: 2, + postManOrInsomiaHeaders: 8, + AJAXHeaderExists: 3, + ommitedAcceptLanguage: 3, + connectionHeaderIsClose: 2, + originHeaderIsNULL: 2, + originHeaderMissmatch: 3, + + acceptHeader: { + ommitedAcceptHeader: 3, + shortAcceptHeader: 4, + acceptIsNULL: 3, + }, + + hostMismatchWeight: 4, + }, + pathTraveler: { + maxIterations: 3, + maxPathLength: 2048, + pathLengthToLong: 10, + longDecoding: 10, + }, + bannedCountries: [], + headlessBrowser: 10, + shortUserAgent: 8, + cliOrLibrary: 10, + internetExplorer: 10, + kaliLinuxOS: 4, + cookieMissing: 8, + countryUnknown: 2, + proxyDetected: 4, + hostingDetected: 4, + timezoneUnknown: 1, + ispUnknown: 1, + regionUnknown: 1, + latLonUnknown: 1, + orgUnknown: 1, + desktopWithoutOS: 1, + deviceVendorUnknown: 1, + browserTypeUnknown: 1, + browserVersionUnknown: 1, + districtUnknown: 0.5, + cityUnknown: 0.5, + browserNameUnknown: 1, + noModel: 0.5, + localeMismatch: 4, + tzMismatch: 3, + tlsCheckFailed: 6, + metaUaCheckFailed: 0, + badGoodbot: 10, + }, + checks: { + enableIpChecks: false, + enableGoodBotsChecks: false, + enableBehaviorRateCheck: false, + enableProxyIspCookiesChecks: false, + enableUaAndHeaderChecks: false, + enableBrowserAndDeviceChecks: false, + enableGeoChecks: false, + enableLocaleMapsCheck: false, + enableTimeZoneMapper: false + }, + punishmentType: { + enableFireWallBan: false + }, + logLevel: 'warn' + } + + if (!config.botDetector.enableBotDetector) return passiveMode; + const botDetectorSettings = config.botDetector.settings?.botDetectorConfig + + if (!useDefault) return botDetectorSettings; return defaultSettings; } diff --git a/src/jwtAuth/controllers/initCustomMfaFlow.ts b/src/jwtAuth/controllers/initCustomMfaFlow.ts new file mode 100644 index 0000000..cbc0181 --- /dev/null +++ b/src/jwtAuth/controllers/initCustomMfaFlow.ts @@ -0,0 +1,186 @@ +import { NextFunction, Request, Response } from 'express'; +import { getLogger } from '../utils/logger.js'; +import { validateSchema } from '../utils/validateZodSchema.js'; +import { getLimiters, resetLimitersUni } from "../utils/limiters/protectedEndpoints/passwordResetFlow/initPasswordResetLimiter.js"; +import { makeConsecutiveCache } from '../utils/limiters/utils/consecutiveCache.js'; +import { guard } from '../utils/limiters/utils/guard.js'; +import { getLimiters as getEmailLimiters } from "../utils/limiters/protectedEndpoints/emailMfaFlow/email.js"; +import { getConfiguration } from '../config/configuration.js'; +import { schema } from '../types/CustomMfaSchema.js'; +import { generateCustomMfaFlow } from '../utils/customMfaLinks.js'; +import { waitSomeTime } from '../utils/timeEnum.js'; +import { EmailMetaDataOTP } from '../types/Emails.js'; + + const consecutiveForIp = makeConsecutiveCache< {countData:number} >(2000, 1000 * 24 * 60 * 60); + const consecutiveForEmail = makeConsecutiveCache< {countData:number} >(2000, 1000 * 24 * 60 * 60); + const consecutiveForCompositeKey = makeConsecutiveCache< {countData:number} >(2000, 1000 * 60 * 30); + const consecutiveForGlobal = makeConsecutiveCache<{countData: number}>(100, 1000 * 60 * 60 * 24); + +/** + * Initializes a custom MFA flow for an authenticated user. + * + * @description + * This controller handles the initiation of a Multi-Factor Authentication (MFA) process. + * It performs several security checks before generating a code: + * 1. **IP Restriction**: Only allowed IPs (based on configuration) can access this endpoint. + * 2. **Global Rate Limiting**: Prevents abuse of the entire MFA system. + * 3. **IP/Email Rate Limiting**: Prevents brute-forcing or spamming for a specific user/IP. + * 4. **Session Anomaly Detection**: Uses `strangeThings` to verify session health and detect anomalies. + * 5. **Timing Protection**: Ensures a consistent response time (approx. 3s) to prevent timing attacks. + * + * @param {Request} req - Express request object. + * Expected query: `rand` (min 254 chars). + * Expected params: `reason` (purpose of MFA). + * Expected cookies: `canary_id`, `session` (refresh token). + * @param {Response} res - Express response object. + * @param {NextFunction} next - Express next function. + * + * @returns {Promise} + * + * @example + * // Initiate MFA for login + * // POST /custom/mfa:login?rand=a...a (300 chars) + * // Cookie: session=...; canary_id=... + */ +export async function initCustomMfaFlow(req: Request, res: Response, next: NextFunction) { + const { uniLimiter, ipLimiter, emailLimiter } = getLimiters(); + const { globalEmailLimiter } = getEmailLimiters(); + const { service } = getConfiguration(); + const log = getLogger().child({service: 'auth', branch: 'custom-mfa'}) + + const trustedClientIp = service?.clientIp ?? service?.proxy.ipToTrust; + let physicalIp = req.socket.remoteAddress || ''; + + if (physicalIp.startsWith('::ffff:')) { + physicalIp = physicalIp.substring(7); + } + + if (!trustedClientIp || physicalIp !== trustedClientIp) { + log.warn('Not allowed ip access attempt') + res.status(403).json({ + ok: false, + date: new Date().toISOString(), + reason: 'Forbidden' + }); + return; + } + + log.info('Starting custom mfa flow process...'); + const start = Date.now(); + try { + + if (!req.is('application/json')) { + log.info('Content type is not json!') + res.status(400).json({error: 'Bad Request.'}) + return; + } + + if (!(await guard(globalEmailLimiter, 'global_emails', consecutiveForGlobal, 1, 'globalEmailLimiter', log, res))) return; + + + if (!(await guard(ipLimiter, req.ip!, consecutiveForIp, 2, 'ip', log, res))) return; + + const random = req.query.random; + const reason = req.params.reason; + const canary = req.cookies.canary_id; + const refresh = req.cookies.session; + + if (!random || !reason || !canary || !refresh) { + res.status(400).json({ + ok: false, + date: new Date().toISOString(), + reason: "Missing signature" + }) + return; + } + const result = await validateSchema(schema, { random, reason }, req, log) + if ("valid" in result) { + if (!result.valid && result.errors !== 'XSS attempt') { + res.status(400).json(Object.assign({error: result.errors, "banned": false })) + return; + } + res.status(403).json({"banned": true}) + return; + } + if (!result.success) { + log.error({ errors: result.error }, "Zod validation failed") + res.status(400).json({ + ok: false, + date: new Date().toISOString(), + reason: "Zod validation failed" + }) + return; + } + const { random: validRandom, reason: validReason } = result.data; + + const compositeKey = `${req.ip!}_${validRandom}_${validReason}`; + if (!(await guard(emailLimiter, `${validRandom}_${validReason}`, consecutiveForEmail, 2, 'email', log, res))) return; + if (!(await guard(uniLimiter, compositeKey, consecutiveForCompositeKey, 3, 'ip+random+reason', log, res))) return; + + log.info(`Using verified session from protectRoute...`) + const { userId, visitor_id: visitorId } = req.user!; + + log.info({ userId, visitorId }, `Verified session health, initiating MFA...`); + const { device: devicePrint, os, browser: browserPrint, city, country, browserType, browserVersion, district,region, regionName, timezone,lat,lon } = req.fingerPrint; + + const location = [country ?? 'Unknown Location', timezone, district, city, region, regionName, lat, lon].filter(Boolean).join('-'); + const device = [ devicePrint ?? 'Unknown Device', os, req.ip].filter(Boolean).join('-'); + const browser = [browserPrint ?? 'Unknown Browser', browserVersion, browserType].filter(Boolean).join('-'); + + const meta: EmailMetaDataOTP = { + device, + browser, + location + } + const { ok, data } = await generateCustomMfaFlow( + validRandom, + validReason, + { userId: Number(userId)!, visitor: Number(visitorId)! }, + refresh, + req.ip!, + res, + meta + ) + + if (!ok && data === 'rate_limited') return; + + if (!ok && data === 'exists') { + log.warn({data, reason}, "Duplicate reason provided!") + res.status(400).json({ + ok: false, + date: new Date().toISOString(), + reason: 'This reason is already in use internally please provide a different one.' + }) + return; + } + if (!ok) { + log.error({data}, "Error generating new mfa flow") + res.status(500).json({ + ok: false, + date: new Date().toISOString(), + reason: "Error generating new mfa code." + }); + return; + } + consecutiveForIp.delete(req.ip!) + consecutiveForEmail.delete(`${validRandom}_${validReason}`); + consecutiveForCompositeKey.delete(compositeKey) + await resetLimitersUni(compositeKey); + log.info(`MFA flow was started successfully.`) + } catch(err) { + log.error({err}, "Unexpected error generating mfa flow.") + } finally { + const elapsed = Date.now() - start; + const delay = 3000; + if (elapsed < delay) { + await waitSomeTime(delay - elapsed, log); + } + if (!res.headersSent) { + res.status(200).json({ + ok: true, + date: new Date().toISOString(), + data: "success" + }); + } + } +} \ No newline at end of file diff --git a/src/jwtAuth/controllers/loginController.ts b/src/jwtAuth/controllers/loginController.ts index 8bbc93f..f7a462a 100644 --- a/src/jwtAuth/controllers/loginController.ts +++ b/src/jwtAuth/controllers/loginController.ts @@ -14,6 +14,7 @@ import { makeConsecutiveCache } from "../utils/limiters/utils/consecutiveCache.j import { guard } from "../utils/limiters/utils/guard.js"; import crypto from 'node:crypto' import { trustVisitor } from "../models/trustVisitor.js"; +import { isPwned } from "../utils/isPasswordPwned.js"; const consecutiveForIp = makeConsecutiveCache< {countData:number} >(2000, 1000 * 24 * 60 * 60); const consecutive429 = makeConsecutiveCache< {countData:number} >(2000, 1000 * 60 * 60); @@ -126,14 +127,23 @@ log.info(`Data parsed and sanitized, searching for user...`) domain: jwt.refresh_tokens.domain, path: '/' }) + + const { pwned, count, date } = await isPwned(password) + let breached = undefined; + if (pwned) { + log.warn({count, date}, `Password found in data breach`); + breached = `Our system identified this password in ${count.toLocaleString()} data breaches. Please consider changing your password.` + } + log.info({userId: results.id, visitorId: results.visitor_id}, `User logged in successfully`); res.status(200).json({ ok: true, receivedAt: new Date().toISOString(), accessToken: accessToken, banned: false, - accessIat: Date.now().toString() + accessIat: Date.now().toString(), + breached }); return; } diff --git a/src/jwtAuth/controllers/logout.ts b/src/jwtAuth/controllers/logout.ts index 5cceebb..d14fe87 100644 --- a/src/jwtAuth/controllers/logout.ts +++ b/src/jwtAuth/controllers/logout.ts @@ -86,27 +86,28 @@ export const handleLogout = async (req: Request, res: Response) => { log.info('User logged out successfully'); - res.status(200).json({ok: true, message: 'Logged out successfully'}); - return; - - } catch(err) { - log.error({err},'Unexpected error type') - res.status(500).json({ok: false, error: "Server error, can't log user out"}) - return; - } finally { - res.clearCookie('session', { + + res.clearCookie('session', { httpOnly: true, sameSite: "strict", secure: true, domain: jwt.refresh_tokens.domain, path: '/' }); - res.clearCookie('iat', { + res.clearCookie('iat', { httpOnly: true, sameSite: "strict", secure: true, domain: jwt.refresh_tokens.domain, path: '/' }); + + res.status(200).json({ok: true, message: 'Logged out successfully'}); + return; + + } catch(err) { + log.error({err},'Unexpected error type') + res.status(500).json({ok: false, error: "Server error, can't log user out"}) + return; } } diff --git a/src/jwtAuth/controllers/rotateAccessToken.ts b/src/jwtAuth/controllers/rotateAccessToken.ts index 0088ce7..f42235f 100644 --- a/src/jwtAuth/controllers/rotateAccessToken.ts +++ b/src/jwtAuth/controllers/rotateAccessToken.ts @@ -10,6 +10,7 @@ import { getLimiters} from "../utils/limiters/protectedEndpoints/tokensLimiters. import { makeConsecutiveCache } from "../utils/limiters/utils/consecutiveCache.js"; import { resetLimiters } from "../utils/limiters/utils/resetLimiters.js"; import { getConfiguration } from "../config/configuration.js"; +import { EmailMetaDataOTP } from "../types/Emails.js"; const consecutiveForIp = makeConsecutiveCache< {countData:number} >(2000, 1000 * 60 * 10); const consecutiveForCompositeKey = makeConsecutiveCache< {countData:number} >(2000, 1000 * 60 * 10); @@ -55,7 +56,17 @@ export const rotateAccessToken = async (req: Request, res: Response) => { try { const {valid, reason, reqMFA, userId, visitorId} = await strangeThings(rawRefreshToken, canary_id, req.ip!, req.get('User-Agent')!, false); + const { device: devicePrint, os, browser: browserPrint, city, country, browserType, browserVersion, district,region, regionName, timezone,lat,lon } = req.fingerPrint; + const location = [country ?? 'Unknown Location', timezone, district, city, region, regionName, lat, lon].filter(Boolean).join('-'); + const device = [ devicePrint ?? 'Unknown Device', os, req.ip].filter(Boolean).join('-'); + const browser = [browserPrint ?? 'Unknown Browser', browserVersion, browserType].filter(Boolean).join('-'); + + const meta: EmailMetaDataOTP = { + device, + browser, + location + } if (!valid && reqMFA) { log.info({token: '[REDACTED]',valid, reason, reqMFA, userId, visitorId},`mfa is triggered`) const mfa = await sendTempMfaLink( @@ -65,7 +76,8 @@ export const rotateAccessToken = async (req: Request, res: Response) => { }, rawRefreshToken, req.ip!, - res + res, + meta ) if (mfa === 'rate_limited') return; diff --git a/src/jwtAuth/controllers/rotateOnEveryUse.ts b/src/jwtAuth/controllers/rotateOnEveryUse.ts index 2dd8c3f..b7a18b6 100644 --- a/src/jwtAuth/controllers/rotateOnEveryUse.ts +++ b/src/jwtAuth/controllers/rotateOnEveryUse.ts @@ -10,6 +10,7 @@ import { guard } from "../utils/limiters/utils/guard.js"; import { getLimiters } from "../utils/limiters/protectedEndpoints/tokensLimiters.js"; import { makeConsecutiveCache } from "../utils/limiters/utils/consecutiveCache.js"; import { getConfiguration } from '../config/configuration.js'; +import { EmailMetaDataOTP } from '../types/Emails.js'; const consecutiveForIp = makeConsecutiveCache< {countData:number} >(500, 1000 * 60 * 10); @@ -61,7 +62,17 @@ export const rotateCredentials = async (req: Request, res: Response) => { try { const {valid, reason, reqMFA, userId, visitorId} = await strangeThings(rawRefreshToken, canary_id, req.ip!, req.get('User-Agent')!, true); + const { device: devicePrint, os, browser: browserPrint, city, country, browserType, browserVersion, district,region, regionName, timezone,lat,lon } = req.fingerPrint; + const location = [country ?? 'Unknown Location', timezone, district, city, region, regionName, lat, lon].filter(Boolean).join('-'); + const device = [ devicePrint ?? 'Unknown Device', os, req.ip].filter(Boolean).join('-'); + const browser = [browserPrint ?? 'Unknown Browser', browserVersion, browserType].filter(Boolean).join('-'); + + const meta: EmailMetaDataOTP = { + device, + browser, + location + } if (!valid && reqMFA) { log.info({token: '[REDACTED]',valid, reason, reqMFA, userId, visitorId},`mfa is triggered`) const mfa = await sendTempMfaLink( @@ -71,7 +82,8 @@ export const rotateCredentials = async (req: Request, res: Response) => { }, rawRefreshToken, req.ip!, - res + res, + meta ) if (mfa === 'rate_limited') return; diff --git a/src/jwtAuth/controllers/rotateRefreshTokens.ts b/src/jwtAuth/controllers/rotateRefreshTokens.ts index 78a6911..d7deb39 100644 --- a/src/jwtAuth/controllers/rotateRefreshTokens.ts +++ b/src/jwtAuth/controllers/rotateRefreshTokens.ts @@ -10,6 +10,7 @@ import { guard } from "../utils/limiters/utils/guard.js"; import { getLimiters } from "../utils/limiters/protectedEndpoints/tokensLimiters.js"; import { makeConsecutiveCache } from "../utils/limiters/utils/consecutiveCache.js"; import { getConfiguration } from "../config/configuration.js"; +import { EmailMetaDataOTP } from "../types/Emails.js"; const consecutiveForIp = makeConsecutiveCache< {countData:number} >(2000, 1000 * 60 * 60 * 12); @@ -48,7 +49,17 @@ export const rotateRefreshTokens = async (req: Request, res: Response) => { try { const {valid, reason, reqMFA, userId, visitorId} = await strangeThings(rawRefreshToken, canary_id, req.ip!, req.get('User-Agent')!, false); + const { device: devicePrint, os, browser: browserPrint, city, country, browserType, browserVersion, district,region, regionName, timezone,lat,lon } = req.fingerPrint; + const location = [country ?? 'Unknown Location', timezone, district, city, region, regionName, lat, lon].filter(Boolean).join('-'); + const device = [ devicePrint ?? 'Unknown Device', os, req.ip].filter(Boolean).join('-'); + const browser = [browserPrint ?? 'Unknown Browser', browserVersion, browserType].filter(Boolean).join('-'); + + const meta: EmailMetaDataOTP = { + device, + browser, + location + } if (!valid && reqMFA) { log.info({token: '[REDACTED]',valid, reason, reqMFA, userId, visitorId},`mfa is triggered`) const mfa = await sendTempMfaLink( @@ -58,7 +69,8 @@ export const rotateRefreshTokens = async (req: Request, res: Response) => { }, rawRefreshToken, req.ip!, - res + res, + meta ) if (mfa === 'rate_limited') return; diff --git a/src/jwtAuth/controllers/signUpController.ts b/src/jwtAuth/controllers/signUpController.ts index 9e7dedb..4de9fac 100644 --- a/src/jwtAuth/controllers/signUpController.ts +++ b/src/jwtAuth/controllers/signUpController.ts @@ -11,7 +11,7 @@ import { isValidDomain } from "../utils/DnsMxLookUp.js"; import { guard } from "../utils/limiters/utils/guard.js"; import { makeConsecutiveCache } from "../utils/limiters/utils/consecutiveCache.js"; import { getLimiters } from "../utils/limiters/protectedEndpoints/signupLimiter.js"; - +import { isPwned } from "../utils/isPasswordPwned.js"; const consecutiveForIp = makeConsecutiveCache< {countData:number} >(2000, 1000 * 60 * 30); const consecutiveForCompositeKey = makeConsecutiveCache< {countData:number} >(2000, 1000 * 60 * 60 * 24); @@ -70,7 +70,18 @@ if ("valid" in result) { if (!(await guard(emailLimiter, email, consecutiveForEmail, 2, 'email', log, res))) return; let hashedPassword: string; + try { + const { pwned, count, date } = await isPwned(password) + if (pwned) { + log.warn({count, date}, `Password found in data breach`); + res.status(400).json({ + ok: false, + receivedAt: new Date().toISOString(), + error: `Our system identified this password in ${count.toLocaleString()} data breaches. Please choose a different password.` + }) + return; + } hashedPassword = await hashPassword(password, log) } catch(err) { log.fatal({ err }, 'Password hashing failed') diff --git a/src/jwtAuth/controllers/updateEmailController.ts b/src/jwtAuth/controllers/updateEmailController.ts new file mode 100644 index 0000000..b653882 --- /dev/null +++ b/src/jwtAuth/controllers/updateEmailController.ts @@ -0,0 +1,146 @@ +import { NextFunction, Request, Response } from "express"; +import { getLogger } from "../utils/logger.js"; +import { verifyMfaCode } from "../utils/verifyMfaCode.js"; +import { getPool } from "../config/dbConnection.js"; +import { validateSchema } from "../utils/validateZodSchema.js"; +import { guard } from "../utils/limiters/utils/guard.js"; +import { dataSchema } from "../types/UpdateEmail.js"; +import { hashPassword, verifyPassword } from "../utils/hash.js"; +import { ResultSetHeader, RowDataPacket } from "mysql2"; +import { revokeAllRefreshTokens } from "../../refreshTokens.js"; +import { sendEmailNotification } from "../utils/systemEmailMap.js"; +import { getConfiguration } from "../config/configuration.js"; +import { getLimiters} from "../utils/limiters/protectedEndpoints/tempPostRoutesLimiter.js"; +import { makeConsecutiveCache } from "../../main.js"; + +const consecutiveForSlowDown = makeConsecutiveCache< {countData:number} >(2000, 1000 * 60 * 10); + +export async function updateEmailController(req: Request, res: Response, next: NextFunction) { + const log = getLogger().child({service: 'auth', branch: 'custom-mfa', visitorId: req.newVisitorId ?? req.link.visitor, reason: req.link.purpose}) + + log.info(`Verifying mfa code and updating email...`) + + if (!req.is('application/json')) { + log.warn('Content type is not json!') + res.status(400).json({error: 'Bad Request.'}) + return; + } + const { visitor_id, userId } = req.user!; + + if (!visitor_id || !userId) { + log.info('Session is invalid') + res.status(400).json({ + ok:false, + date: new Date().toISOString(), + reason: 'Invalid email or password' + }) + return; + } + + const { uniLimiter } = getLimiters(); + if (!(await guard(uniLimiter, userId, consecutiveForSlowDown, 2, 'SlowDown', log, res))) return; + + + + if (req.link.purpose !== "change_email" || visitor_id === String(req.link.visitor)) { + log.warn('Invalid link purpose/Email is null') + res.status(400).json({ + ok: false, + date: new Date().toISOString(), + reason: 'Invalid email or password' + }); + return; + } + + const result = await validateSchema(dataSchema, req.body, req, log) + + if ("valid" in result) { + if (!result.valid && result.errors !== 'XSS attempt') { + res.status(400).json(Object.assign({error: result.errors, "banned": false })) + return; + } + res.status(403).json({"banned": true}) + return; + } + + if (!result.success) { + log.error({ errors: result.error }, "Zod validation failed") + res.status(400).json({ + ok: false, + date: new Date().toISOString(), + reason: "Zod validation failed malformed link" + }) + return; + } + + const {email, newEmail, password} = result.data; + let name: string = ''; + const pool = getPool(); + + try { + const [user] = await pool.execute(` + SELECT email, password_hash AS hashed_password, name FROM users + WHERE email = ? + AND visitor_id = ? + AND id = ? + `,[email, visitor_id, userId]) ; + + if (!user || user.length === 0) { + log.info(`User doesn't exists..`) + await pool.rollback() + res.status(400).json({ + ok:false, + date: new Date().toISOString(), + reason: 'Invalid email or password' + }) + return; + } + + log.info(`Found user, validating password...`) + const { hashed_password, username } = user[0] ; + name = username; + const isPasswordValid = await verifyPassword(hashed_password, password); + + if (!isPasswordValid) { + log.warn(`Password is not valid.`) + res.status(400).json({ + ok: false, + date: new Date().toISOString(), + reason: 'Invalid email or password' + }) + return; + } + + } catch (error) { + log.error({error}, "Error verifying password or checking user") + res.status(500).json({ + ok: false, + date: new Date().toISOString(), + reason: 'Internal server error' + }); + return; + } + + return verifyMfaCode(req, res, next, req.body.code, log, false, true, async (conn, userId) => { + const [results] = await conn.execute(` + UPDATE users + SET email = ? + WHERE id = ? + AND email = ? + `,[newEmail, userId, email]) + if (results.affectedRows !== 1) { + throw new Error('Failed to update email') + } + + const { magic_links } = getConfiguration() + const {contactPageLink} = magic_links.notificationEmail + await sendEmailNotification(email, name, { + title: "Your Email Has Changed", + action: "Notice of Change", + subject: "Security Alert: Email Address Updated", + message: `Your account's email address has been updated to ${newEmail}.
If you did not authorize this change, please contact support immediately to secure your account.`, + cta: "Contact Support", + cta_link: contactPageLink, + }) + }); +} \ No newline at end of file diff --git a/src/jwtAuth/controllers/verifyCustomMfaController.ts b/src/jwtAuth/controllers/verifyCustomMfaController.ts new file mode 100644 index 0000000..0786314 --- /dev/null +++ b/src/jwtAuth/controllers/verifyCustomMfaController.ts @@ -0,0 +1,43 @@ +import { NextFunction, Request, Response } from 'express'; +import { getLogger } from '../utils/logger.js'; +import { verifyMfaCode } from '../utils/verifyMfaCode.js'; + +/** + * Verifies a custom MFA code submitted by the user. + * + * @description + * This controller is the final step in the custom MFA flow. + * It expects a JSON body containing the MFA code and relies on + * middleware (like `customMfaFlowsVerification`) to provide + * the necessary context via `req.link`. + * + * Flow: + * 1. Checks if the request is JSON. + * 2. Delegates the actual verification and token rotation to `verifyMfaCode`. + * + * @param {Request} req - Express request object. + * Expected body: `{ code: string }`. + * Expected `req.link`: Populated by `customMfaFlowsVerification` middleware. + * @param {Response} res - Express response object. + * @param {NextFunction} next - Express next function. + * + * @returns {Promise} + * + * @example + * // Verify a code + * // POST /auth/verify-custom-mfa + * // Body: { "code": "123456" } + */ +export async function verifyCustomMfa (req: Request, res: Response, next: NextFunction) { + const log = getLogger().child({service: 'auth', branch: 'custom-mfa', visitorId: req.newVisitorId ?? req.link.visitor, reason: req.link.purpose}) + + log.info(`Verifying custom mfa code...`) + + if (!req.is('application/json')) { + log.warn('Content type is not json!') + res.status(400).json({error: 'Bad Request.'}) + return; + } + + return verifyMfaCode(req, res, next, req.body.code, log, true); +} diff --git a/src/jwtAuth/emails/OTP/index.ejs b/src/jwtAuth/emails/OTP/index.ejs new file mode 100644 index 0000000..078e146 --- /dev/null +++ b/src/jwtAuth/emails/OTP/index.ejs @@ -0,0 +1,569 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/jwtAuth/emails/nottifications/index.ejs b/src/jwtAuth/emails/nottifications/index.ejs new file mode 100644 index 0000000..93a0e59 --- /dev/null +++ b/src/jwtAuth/emails/nottifications/index.ejs @@ -0,0 +1,314 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/jwtAuth/emails/system.ejs b/src/jwtAuth/emails/system.ejs deleted file mode 100644 index 3500e86..0000000 --- a/src/jwtAuth/emails/system.ejs +++ /dev/null @@ -1,164 +0,0 @@ - - - - - - - - - - - - - -
- - logo - -
 
- - - - - - -
- - - - - - - - - - - - - - - - <% if (typeof code !== 'undefined' && code) { %> - - - - <% } %> - - - - - - <% if (link) { %> - - - - <% } %> - -
- - -
- - <%= images[0].alt %> -
- -

- <%= headers.headerOne %> -

-
- -

- <%= headers.headerTwo %> -

-
- -
- <%= code %> -
-
- -

- Hi <%= userName %>, <%= message %> -

-
- - - - <% link.forEach(el => { %> - - <% }); %> - -
- - <%= el.label %> - -
-
-
- - - - - - -
- - - - - - - - - - - -
- Home | - Account | - Contact -
- - - - - - - - - - -
- © All Rights Reserved Sergio Riavzon 2025 -
-
- - - diff --git a/src/jwtAuth/middleware/validateContentType.ts b/src/jwtAuth/middleware/validateContentType.ts index d295182..17fc977 100644 --- a/src/jwtAuth/middleware/validateContentType.ts +++ b/src/jwtAuth/middleware/validateContentType.ts @@ -1,4 +1,5 @@ import { Request, Response, NextFunction, RequestHandler } from "express"; +import { getLogger } from "../utils/logger.js"; /** @@ -20,8 +21,9 @@ import { Request, Response, NextFunction, RequestHandler } from "express"; */ export function contentType(expected: string): RequestHandler { return(req: Request, res: Response, next: NextFunction) => { + const log = getLogger().child({service: 'auth', branch: 'middleware', type: 'contentType'}) if (!req.is(expected)) { - console.warn('unexpected content type') + log.warn('unexpected content type') res.status(403).json({error: 'not allowed.'}) return; }; diff --git a/src/jwtAuth/middleware/verifyEmailMFA.ts b/src/jwtAuth/middleware/verifyEmailMFA.ts index 92ca43f..a7ab7c1 100644 --- a/src/jwtAuth/middleware/verifyEmailMFA.ts +++ b/src/jwtAuth/middleware/verifyEmailMFA.ts @@ -1,22 +1,6 @@ import { NextFunction, Request, Response } from 'express'; -import { getPool } from '../config/dbConnection.js'; -import { ResultSetHeader, RowDataPacket } from "mysql2"; -import crypto from 'crypto'; -import { code } from '../models/zodSchema.js' -import { generateRefreshToken, revokeRefreshToken, verifyRefreshToken } from '../../refreshTokens.js'; -import { generateAccessToken } from '../../accessTokens.js'; -import { getConfiguration } from '../config/configuration.js'; -import { makeCookie } from '../utils/cookieGenerator.js'; import { getLogger } from '../utils/logger.js'; -import { validateSchema } from '../utils/validateZodSchema.js'; -import { guard } from "../utils/limiters/utils/guard.js"; -import { getLimiters, resetLimitersUni } from "../utils/limiters/protectedEndpoints/tempPostRoutesLimiter.js"; -import { makeConsecutiveCache } from "../utils/limiters/utils/consecutiveCache.js" -import { updateVisitors } from '@riavzon/botdetector'; - -const consecutiveForSlowDown = makeConsecutiveCache< {countData:number} >(2000, 1000 * 60 * 10); -export const consecutiveForjti = makeConsecutiveCache< {countData:number} >(2000, 1000 * 60 * 20); -const consecutiveForsubmittedHash = makeConsecutiveCache< {countData:number} >(2000, 1000 * 60 * 10); +import { verifyMfaCode } from '../utils/verifyMfaCode.js'; /** * @description @@ -50,204 +34,14 @@ const consecutiveForsubmittedHash = makeConsecutiveCache< {countData:number} >(2 */ export async function verifyMFA (req: Request, res: Response, next: NextFunction) { const log = getLogger().child({service: 'auth', branch: 'mfa', visitorId: req.newVisitorId ?? req.link.visitor}) - const { uniLimiter, ipLimit, usedJtiLimiter } = getLimiters(); - const { jwt } = getConfiguration(); - const fingerprints = req.fingerPrint; - log.info(`Verifying mfa code...`) - - if (!req.is('application/json')) { - log.warn('Content type is not json!') - res.status(400).json({error: 'Bad Request.'}) - return; - } - - if (req.link.purpose !== "MFA" || req.link.subject !== 'MAGIC_LINK_MFA_CHECKS') { - log.warn('Invalid link purpose') - res.status(400).json({ error: "Invalid link purpose" }); - return; - } - - - if (!(await guard(uniLimiter, req.ip!, consecutiveForSlowDown, 2, 'SlowDown', log, res))) return; - if (!(await guard(uniLimiter, req.link.jti!, consecutiveForjti, 1,'jti', log, res))) return; - - const result = await validateSchema(code, req.body, req, log) - - if ("valid" in result) { - if (!result.valid && result.errors !== 'XSS attempt') { - res.status(400).json(Object.assign({error: result.errors, "banned": false })) - return; - } - res.status(403).json({"banned": true}) - return; - } - - const validetedCode = result.data!.code; - const submittedHash = crypto.createHash("sha256").update((validetedCode as any)).digest("hex"); - - if (!(await guard(ipLimit, submittedHash, consecutiveForsubmittedHash, 1,'submittedHash', log, res))) return; - - if (!req.link.jti) { - log.warn(`No Session Token`) - res.status(500).json({ error: 'No Session Token' }); - return; - } -const pool = getPool() -const conn = await pool.getConnection(); -try { - await conn.beginTransaction(); - const [rows] = await conn.execute(` - SELECT token, user_id - FROM mfa_codes - WHERE jti = ? - AND code_hash = ? - AND expires_at > UTC_TIMESTAMP() - AND used = 0 - FOR UPDATE - `,[req.link.jti, submittedHash]) - - if (!rows || rows.length === 0) { - log.warn(`Invalid or expired code.`) - await conn.rollback(); - res.status(401).json({ error: 'Invalid or expired code.' }); - return; - } - - const [DELETE] = await conn.execute(` - DELETE FROM mfa_codes - WHERE jti = ? - AND user_id = ? - AND code_hash = ? - AND expires_at > UTC_TIMESTAMP() - AND used = 0 - LIMIT 1 - `,[req.link.jti, rows[0].user_id, submittedHash]) - - if (DELETE.affectedRows !== 1) { - log.warn(`Invalid or expired code.`) - await conn.rollback(); - res.status(401).json({ error: 'Invalid or expired code.' }); - return; - } - await ipLimit.block(submittedHash, 60 * 10); - - log.info(`Found valid code, updating users and visitors...`) -const currentVisitorId = req.newVisitorId || req.link.visitor; - - if (!currentVisitorId ) { - log.fatal(`currentVisitorId is empty, possible loop`) - res.status(500).json({error: `currentVisitorId is empty, possible loop`}); - return; -} - await conn.execute(` - UPDATE users - JOIN visitors - ON visitors.visitor_id = ? - SET - users.visitor_id = visitors.visitor_id, - users.last_mfa_at = UTC_TIMESTAMP(), - visitors.proxy_allowed = 1, - visitors.hosting_allowed = 1 - WHERE - users.id = ? - `, - [currentVisitorId, rows[0].user_id] - ); - - await usedJtiLimiter.block(req.link.jti, 60 * 20); - await conn.commit(); - consecutiveForSlowDown.delete(req.ip!); - consecutiveForjti.delete(req.link.jti!); - consecutiveForsubmittedHash.delete(submittedHash!); - await resetLimitersUni(req.ip!); - - const updateFingerPrint = await updateVisitors({ - userAgent: fingerprints.userAgent, - ipAddress: fingerprints.ipAddress, - country: fingerprints.country ?? '', - region: fingerprints.region ?? '', - regionName: fingerprints.regionName ?? '', - city: fingerprints.city ?? '', - district: fingerprints.district ?? '', - lat: fingerprints.lat !== undefined ? String(fingerprints.lat) : '', - lon: fingerprints.lon !== undefined ? String(fingerprints.lon) : '', - timezone: fingerprints.timezone ?? '', - currency: fingerprints.currency ?? '', - isp: fingerprints.isp ?? '', - org: fingerprints.org ?? '', - as: fingerprints.as_org ?? '', - device_type: fingerprints.device, - browser: fingerprints.browser, - proxy: fingerprints.proxy ?? false, - hosting: fingerprints.hosting ?? false, - deviceVendor: fingerprints.deviceVendor, - deviceModel: fingerprints.deviceModel, - browserType: fingerprints.browserType, - browserVersion: fingerprints.browserVersion, - os: fingerprints.os - }, req.cookies.canary_id, currentVisitorId); - if (!updateFingerPrint.success) { - log.error({error: updateFingerPrint.reason},`Failed to update fingerprints, false positives may occur.`); - } - - log.info(`updated users and visitors, generating tokens...`) - const token = rows[0].token; - const userId = rows[0].user_id; - + log.info(`Verifying mfa code...`) - const result = await verifyRefreshToken(token); - if (!result.valid) { - log.warn(`invalid refresh token: ${result.reason}`) - res.status(401).json({ error: result.reason }); + if (req.link.purpose !== "MAGIC_LINK_MFA_CHECKS" || req.link.subject !== `MAGIC_LINK_MFA_CHECKS_${req.link.visitor}`) { + log.warn('Invalid link purpose or subject mismatch') + res.status(400).json({ error: "Invalid link purpose or subject mismatch" }); return; } - - const {success} = await revokeRefreshToken(token); - - if (!success) { - log.error(`Error Revoking refresh token`) - res.status(500).json({ error: `Error Revoking refresh token` }); - return; - } - - const accessToken = generateAccessToken({ - id: userId, - visitor_id: currentVisitorId, - jti: crypto.randomUUID() - }); - - const newRefresh = await generateRefreshToken( - jwt.refresh_tokens.refresh_ttl, - userId - ); - makeCookie(res, 'iat', Date.now().toString(), { - httpOnly: true, - secure: true, - sameSite: 'strict', - path: '/', - expires: newRefresh!.expiresAt, - }); - - makeCookie(res, 'session', newRefresh!.raw, { - httpOnly: true, - sameSite: "strict", - expires: newRefresh!.expiresAt, - secure: true, - domain: jwt.refresh_tokens.domain, - path: '/' - }) - log.info(`MFA Verified! and new tokens are set`) - res.status(200).json({ accessToken: accessToken, accessIat: Date.now().toString() }); - return; - -} catch(err) { - await conn.rollback(); - log.error({err},`MFA verify error`) - res.status(500).json({ error: 'Internal server error' }); - return; -} finally { - conn.release(); -} + return verifyMfaCode(req, res, next, req.body.code, log); } \ No newline at end of file diff --git a/src/jwtAuth/middleware/verifyJwt.ts b/src/jwtAuth/middleware/verifyJwt.ts index beaca38..1ffbc5a 100644 --- a/src/jwtAuth/middleware/verifyJwt.ts +++ b/src/jwtAuth/middleware/verifyJwt.ts @@ -6,6 +6,7 @@ import { sendTempMfaLink } from "../utils/emailMFA.js"; import { getLimiters } from '../utils/limiters/protectedEndpoints/tokensLimiters.js' import { makeConsecutiveCache } from "../utils/limiters/utils/consecutiveCache.js"; import { guard } from "../utils/limiters/utils/guard.js"; +import { EmailMetaDataOTP } from "../types/Emails.js"; const consecutiveForJti = makeConsecutiveCache< {countData:number} >(2000, 1000 * 60 * 60 * 24); @@ -73,6 +74,18 @@ const { blackList } = getLimiters(); const {valid, reason, reqMFA, userId, visitorId} = await strangeThings(session, canary, req.ip!, req.get('User-Agent')!, false); + + const { device: devicePrint, os, browser: browserPrint, city, country, browserType, browserVersion, district,region, regionName, timezone,lat,lon } = req.fingerPrint; + + const location = [country ?? 'Unknown Location', timezone, district, city, region, regionName, lat, lon].filter(Boolean).join('-'); + const device = [ devicePrint ?? 'Unknown Device', os, req.ip].filter(Boolean).join('-'); + const browser = [browserPrint ?? 'Unknown Browser', browserVersion, browserType].filter(Boolean).join('-'); + + const meta: EmailMetaDataOTP = { + device, + browser, + location + } if (!valid && reqMFA) { log.info({token: '[REDACTED]',valid, reason, reqMFA, userId, visitorId},`mfa is triggered`) @@ -83,7 +96,8 @@ const { blackList } = getLimiters(); }, session, req.ip!, - res + res, + meta ) if (mfa === 'rate_limited') return; diff --git a/src/jwtAuth/middleware/verifyPasswordReset.ts b/src/jwtAuth/middleware/verifyPasswordReset.ts index db2530d..6804e83 100644 --- a/src/jwtAuth/middleware/verifyPasswordReset.ts +++ b/src/jwtAuth/middleware/verifyPasswordReset.ts @@ -8,6 +8,9 @@ import { validateSchema } from "../utils/validateZodSchema.js"; import { guard } from "../utils/limiters/utils/guard.js"; import { getLimiters, resetLimitersUni } from "../utils/limiters/protectedEndpoints/tempPostRoutesLimiter.js"; import { makeConsecutiveCache } from "../utils/limiters/utils/consecutiveCache.js"; +import { sendEmailNotification } from "../utils/systemEmailMap.js"; +import { getConfiguration } from "../config/configuration.js"; +import { isPwned } from "../utils/isPasswordPwned.js"; const consecutiveForCompositeKey = makeConsecutiveCache< {countData:number} >(2000, 1000 * 60 * 10); @@ -52,9 +55,9 @@ if (!req.is('application/json')) { return; } - if (req.link.purpose !== "PASSWORD_RESET" && req.link.subject !== 'MAGIC_LINK_Restart') { - log.warn('Invalid link purpose/Email is null') - res.status(400).json({ error: "Invalid link purpose/Email is null" }); + if (req.link.purpose !== "PASSWORD_RESET" || req.link.subject !== `PASSWORD_RESET_${req.link.visitor}`) { + log.warn('Invalid link purpose or subject mismatch') + res.status(400).json({ error: "Invalid link purpose or subject mismatch" }); return; } @@ -78,17 +81,29 @@ if (!req.is('application/json')) { const {confirmedPassword, password} = result.data!; if (confirmedPassword !== password) { - log.info(`Passwords didnt match.`) + log.info(`Passwords didn't match.`) res.status(400).json({error: `Password doesn't match`, "banned": false }) return; } + +const { pwned, count, date } = await isPwned(password) + if (pwned) { + log.warn({count, date}, `Password found in data breach`); + res.status(400).json({ + ok: false, + receivedAt: new Date().toISOString(), + error: `Our system identified this password in ${count.toLocaleString()} data breaches. Please choose a different password.` + }) + return; + } + const pool = getPool() const conn = await pool.getConnection(); try { await conn.beginTransaction(); const hashedPassword = await hashPassword(password, log); const [findUser] = await conn.execute(` - SELECT id FROM users + SELECT id, email, name FROM users WHERE visitor_id = ? LIMIT 1 FOR UPDATE @@ -130,6 +145,16 @@ const conn = await pool.getConnection(); consecutiveForCompositeKey.delete(compositeKey); consecutiveForIp.delete(req.ip!); await resetLimitersUni(compositeKey); + const { magic_links } = getConfiguration() + const {loginPageLink} = magic_links.notificationEmail + await sendEmailNotification(findUser[0].email, findUser[0].name, { + title: "Password Reset Successful", + action: "Security Notification", + subject: "Security Alert: Password Reset Successful", + message: `Your account password has been successfully reset.
If you did not authorize this change, please contact support immediately.`, + cta: "Go to Login", + cta_link: loginPageLink, + }) res.status(200).json({ success: true }); return; diff --git a/src/jwtAuth/middleware/verifyTempLink.ts b/src/jwtAuth/middleware/verifyTempLink.ts index 68be024..d4a0640 100644 --- a/src/jwtAuth/middleware/verifyTempLink.ts +++ b/src/jwtAuth/middleware/verifyTempLink.ts @@ -6,13 +6,19 @@ import { guard } from "../utils/limiters/utils/guard.js"; import { makeConsecutiveCache } from "../utils/limiters/utils/consecutiveCache.js"; import { getLimiters } from "../utils/limiters/protectedEndpoints/tempPostRoutesLimiter.js"; import { magicLinksCache } from "../utils/magicLinksCache.js"; +import { validateSchema } from '../utils/validateZodSchema.js'; +import { verificationLink, type VerificationLinkSchema } from "../types/CustomMfaSchema.js"; +import { toDigestHex } from "../utils/hashChecker.js"; +import crypto from "node:crypto" +import { getConfiguration } from "../config/configuration.js"; +import { buildInMfaFlows, type BuildInMfaFlowsSchema } from "../types/MfaAndPasswordResetSchema.js"; const consecutiveForIpPassword = makeConsecutiveCache< {countData:number} >(2000, 1000 * 60 * 30); const consecutiveForIpMfa = makeConsecutiveCache< {countData:number} >(2000, 1000 * 60 * 30); +const consecutiveForIpCustomMfa = makeConsecutiveCache< {countData:number} >(2000, 1000 * 60 * 30); const usageCountPost = makeConsecutiveCache<{count:number}>(1000, 1000 * 60 * 20); const usageCountGet = makeConsecutiveCache<{count:number}>(1000, 1000 * 60 * 20); -const allowedPerSuccessfulGet = 5; -const allowedPerSuccessfulPost = 1; + /** * Verify MFA magic links (GET to preview, POST to consume) with rate limiting. @@ -27,26 +33,38 @@ const allowedPerSuccessfulPost = 1; export const linkMfaVerification = async (req: Request, res: Response, next: NextFunction) => { const log = getLogger().child({service: 'auth', branch: `tempLinks`, linkType: 'mfa'}) const { usedJtiLimiter } = getLimiters(); -const token = req.query.temp; +const { magic_links } = getConfiguration() +const { allowedPerSuccessfulGet, allowedPerSuccessfulPost } = magic_links.thresholds.adaptiveMfa +const data = req.query as unknown as BuildInMfaFlowsSchema; +const result = await validateSchema(buildInMfaFlows, data, req, log) + +if ("valid" in result) { + if (!result.valid && result.errors !== 'XSS attempt') { + res.status(400).json(Object.assign({error: result.errors, "banned": false })) + return; + } + res.status(403).json({"banned": true}) + return; + } + if (!result.success) { + log.error({ errors: result.error }, "Zod validation failed") + res.status(400).json({ + ok: false, + date: new Date().toISOString(), + reason: "Zod validation failed malformed link" + }) + return; + } + +const { token, random, reason, visitor } = result.data; -if (typeof token !== 'string') { - log.warn('invalid token type'); - res.status(400).json({error: 'invalid token type'}); - return; -} log.info({ method: req.method }, 'Verifying link...') if (!(await guard(getUniLimiter(), req.ip!, consecutiveForIpMfa, 1, 'ip', log, res))) return; -if (!token) { - log.warn('Link not provided'); - res.status(400).json({error: 'Link not provided'}); - return; -} - -const results = verifyTempJwtLink(token); +const results = verifyTempJwtLink<{ randomHashed: string }>(token); if (!results.valid || !results.payload) { log.warn({details: results.errorType},'Link is not valid or expired'); @@ -54,18 +72,41 @@ if (!results.valid || !results.payload) { return; } -if (Number(req.params.visitor) !== results.payload.visitor) { +if (visitor !== results.payload.visitor) { log.warn('Invalid link URL'); res.status(400).json({ error: 'Invalid link URL' }); return; } const raw = results.payload; - - if (typeof raw.visitor !== 'number') { - log.warn('Malformed token payload'); - res.status(401).json({ error: 'Malformed token payload' }) + const { input: providedRandom } = await toDigestHex(random) + const signedProvidedRandom = Buffer.from(providedRandom, 'hex') + const signedRandom = Buffer.from(raw.randomHashed, 'hex'); + + if (typeof raw.visitor !== 'number' || + raw.purpose !== reason || + raw.subject !== `${reason}_${visitor}` || + signedProvidedRandom.length !== signedRandom.length + ) { + log.warn('Malformed token payload'); + res.status(401).json({ + ok: false, + date: new Date().toISOString(), + reason: 'Malformed token payload' + }) return; } + + + if (!crypto.timingSafeEqual(signedProvidedRandom, signedRandom)) { + log.warn('Malformed token payload: hash mismatch'); + res.status(401).json({ + ok: false, + date: new Date().toISOString(), + reason: 'Malformed token payload' + }) + return; + } + req.link = { visitor: raw.visitor, subject: raw.subject, @@ -95,7 +136,13 @@ if (Number(req.params.visitor) !== results.payload.visitor) { log.info('link verified'); consecutiveForIpMfa.delete(req.ip!); await resetLimitersUni(req.ip!) - res.status(200).json({link: 'MFA Code'}); + res.status(200).json({ + ok: true, + date: new Date().toISOString(), + data: { + link: 'MFA Code', + reason: raw.purpose + }}); return; } @@ -126,26 +173,41 @@ if (Number(req.params.visitor) !== results.payload.visitor) { export const linkPasswordVerification = async (req: Request, res: Response, next: NextFunction) => { const log = getLogger().child({service: 'auth', branch: `tempLinks`, linkType: 'password-reset'}) const { usedJtiLimiter } = getLimiters(); -const token = req.query.temp; +const { magic_links } = getConfiguration() +const { allowedPerSuccessfulGet, allowedPerSuccessfulPost } = magic_links.thresholds.linkPasswordVerification; + + +const data = req.query as unknown as BuildInMfaFlowsSchema; +const result = await validateSchema(buildInMfaFlows, data, req, log) + +if ("valid" in result) { + if (!result.valid && result.errors !== 'XSS attempt') { + res.status(400).json(Object.assign({error: result.errors, "banned": false })) + return; + } + res.status(403).json({"banned": true}) + return; + } + if (!result.success) { + log.error({ errors: result.error }, "Zod validation failed") + res.status(400).json({ + ok: false, + date: new Date().toISOString(), + reason: "Zod validation failed malformed link" + }) + return; + } + +const { token, random, reason, visitor } = result.data; -if (typeof token !== 'string') { - log.warn('invalid token type'); - res.status(400).json({error: 'invalid token type'}); - return; -} log.info('Verifying link...') if (!(await guard(getUniLimiter(), req.ip!, consecutiveForIpPassword, 1, 'ip', log, res))) return; -if (!token) { - log.warn('Link not provided'); - res.status(400).json({error: 'Link not provided'}); - return; -} -const results = verifyTempJwtLink(token); +const results = verifyTempJwtLink<{ randomHashed: string }>(token); if (!results.valid || !results.payload) { @@ -154,18 +216,42 @@ if (!results.valid || !results.payload) { return; } -if (Number(req.params.visitor) !== results.payload.visitor) { +if (visitor !== results.payload.visitor) { log.warn('Invalid link URL'); res.status(400).json({ error: 'Invalid link URL' }); return; } const raw = results.payload; - - if (typeof raw.visitor !== 'number') { - log.warn('Malformed token payload'); - res.status(401).json({ error: 'Malformed token payload' }) + const { input: providedRandom } = await toDigestHex(random) + const signedProvidedRandom = Buffer.from(providedRandom, 'hex') + const signedRandom = Buffer.from(raw.randomHashed, 'hex'); + + if (typeof raw.visitor !== 'number' || + raw.purpose !== reason || + raw.subject !== `${reason}_${visitor}` || + signedProvidedRandom.length !== signedRandom.length + ) { + log.warn('Malformed token payload'); + res.status(401).json({ + ok: false, + date: new Date().toISOString(), + reason: 'Malformed token payload' + }) return; } + + + if (!crypto.timingSafeEqual(signedProvidedRandom, signedRandom)) { + log.warn('Malformed token payload: hash mismatch'); + res.status(401).json({ + ok: false, + date: new Date().toISOString(), + reason: 'Malformed token payload' + }) + return; + } + + req.link = { visitor: raw.visitor, subject: raw.subject, @@ -196,7 +282,13 @@ if (Number(req.params.visitor) !== results.payload.visitor) { log.info('link verified') consecutiveForIpPassword.delete(req.ip!); await resetLimitersUni(req.ip!) - res.status(200).json({link: 'Password Reset'}); + res.status(200).json({ + ok: true, + date: new Date().toISOString(), + data: { + link: 'Password Reset', + reason: raw.purpose + }}); return; } @@ -212,3 +304,172 @@ if (Number(req.params.visitor) !== results.payload.visitor) { return next(); } +/** + * Middleware for verifying custom Multi-Factor Authentication (MFA) magic links. + * + * @description + * This middleware validates the temporary magic link used in custom MFA flows: + * 1. **Schema Validation**: Validates `token`, `random`, `reason` (query) and `visitor` (params) using Zod. + * 2. **Global Rate Limiting**: Uses `linkVerificationLimiter` to prevent brute-forcing. + * 3. **JWT Verification**: Verifies the signature and payload of the `temp` token. + * 4. **Random Hash Check**: Cryptographically compares the provided `random` string with the `randomHashed` stored in the JWT using `timingSafeEqual`. + * 5. **Payload integrity**: Ensures `visitor`, `purpose`, and `subject` match the request context. + * 6. **Single Use enforcement**: Prevents replay attacks by checking a JTI-based limiter and `magicLinksCache`. + * + * On success, populates `req.link` and calls `next()`. + * + * @param {Request} req - Express request object. + * @param {Response} res - Express response object. + * @param {NextFunction} next - Express next function. + * + * @returns {Promise} + * + * @example + * // Used in magicLinks.ts routes + * router.route("/auth/verify-custom-mfa") + * .post(customMfaFlowsVerification, verifyCustomMfa); + */ +export const customMfaFlowsVerification = async (req: Request, res: Response, next: NextFunction) => { + const log = getLogger().child({service: 'auth', branch: `tempLinks`, linkType: 'custom-mfa'}) + const { usedJtiLimiter } = getLimiters(); + const { magic_links } = getConfiguration() + const { allowedPerSuccessfulGet, allowedPerSuccessfulPost } = magic_links.thresholds.customMfaFlowsAndEmailChanges + const data = req.query as unknown as VerificationLinkSchema; + const result = await validateSchema(verificationLink, data, req, log) + + if ("valid" in result) { + if (!result.valid && result.errors !== 'XSS attempt') { + res.status(400).json(Object.assign({error: result.errors, "banned": false })) + return; + } + res.status(403).json({"banned": true}) + return; + } + if (!result.success) { + log.error({ errors: result.error }, "Zod validation failed") + res.status(400).json({ + ok: false, + date: new Date().toISOString(), + reason: "Zod validation failed malformed link" + }) + return; + } + + log.info({ method: req.method }, 'Verifying link...') + + if (!(await guard(getUniLimiter(), req.ip!, consecutiveForIpCustomMfa, 1, 'ip', log, res))) return; + + const { token, random, reason, visitor } = result.data; + + const results = verifyTempJwtLink<{ randomHashed: string }>(token); + + if (!results.valid || !results.payload) { + log.warn({details: results.errorType},'Link is not valid or expired'); + res.status(400).json({error: 'Link is not valid or expired', details: results.errorType}); + return; + } + + if (visitor !== results.payload.visitor) { + log.warn('Invalid link URL'); + res.status(400).json({ + ok: false, + date: new Date().toISOString(), + reason: 'Invalid link URL' + }); + return; + } + + const raw = results.payload; + const { input: providedRandom } = await toDigestHex(random) + const signedProvidedRandom = Buffer.from(providedRandom, 'hex') + const signedRandom = Buffer.from(raw.randomHashed, 'hex'); + + if (typeof raw.visitor !== 'number' || + raw.purpose !== reason || + raw.subject !== `${reason}_${visitor}` || + signedProvidedRandom.length !== signedRandom.length + ) { + log.warn('Malformed token payload'); + res.status(401).json({ + ok: false, + date: new Date().toISOString(), + reason: 'Malformed token payload' + }) + return; + } + + + if (!crypto.timingSafeEqual(signedProvidedRandom, signedRandom)) { + log.warn('Malformed token payload: hash mismatch'); + res.status(401).json({ + ok: false, + date: new Date().toISOString(), + reason: 'Malformed token payload' + }) + return; + } + + req.link = { + visitor: raw.visitor, + subject: raw.subject, + purpose: raw.purpose, + jti: raw.jti, + }; + + const isUsed = await usedJtiLimiter.get(req.link.jti!); + if (isUsed !== null && isUsed.consumedPoints > 0 && isUsed.remainingPoints === 0) { + log.warn({userDetail: req.link},'User tried to use a temp link again'); + magicLinksCache().delete(token); + res.status(400).json({ + ok: false, + date: new Date().toISOString(), + reason: 'Link is not valid or expired' + }); + return + } + + if (req.method === 'GET') { + const getEntry = (usageCountGet.get(req.link.jti!)?.count ?? 0) + 1; + usageCountGet.set(req.link.jti!, { count: getEntry }); + log.info({count: getEntry, out_of: allowedPerSuccessfulGet},'User hit a custom mfa link, with a get req.'); + + if (getEntry > allowedPerSuccessfulGet) { + log.warn({count: getEntry, out_of: allowedPerSuccessfulGet},'User hit an expired custom mfa link with a get method'); + res.status(400).json({ + ok: false, + date: new Date().toISOString(), + reason: 'This link can only be used once' + }) + return; + }; + + log.info('link verified'); + consecutiveForIpCustomMfa.delete(req.ip!); + await resetLimitersUni(req.ip!) + res.status(200).json({ + ok: true, + date: new Date().toISOString(), + data: { + link: 'Custom MFA', + reason: raw.purpose + } + }); + return; + } + + const postEntry = (usageCountPost.get(req.link.jti!)?.count ?? 0) + 1; + usageCountPost.set(req.link.jti!, { count: postEntry }); + log.info({count: postEntry, out_of: allowedPerSuccessfulPost},'User hit a custom mfa link, with a post req.'); + + if (postEntry > allowedPerSuccessfulPost) { + log.warn({count: postEntry, out_of: allowedPerSuccessfulPost},'User hit an expired custom mfa link with a post method'); + res.status(400).json({ + ok: false, + date: new Date().toISOString(), + reason: 'This link can only be used once' + }) + return; + }; + + return next(); +} \ No newline at end of file diff --git a/src/jwtAuth/routes/TokenRotations.ts b/src/jwtAuth/routes/TokenRotations.ts index cadc083..76b72db 100644 --- a/src/jwtAuth/routes/TokenRotations.ts +++ b/src/jwtAuth/routes/TokenRotations.ts @@ -7,6 +7,7 @@ import { handleLogout } from "../controllers/logout.js"; import { rotateCredentials } from "../controllers/rotateOnEveryUse.js"; import { getConfiguration } from "../config/configuration.js"; import { requireAccessToken } from "../middleware/requireAccessToken.js"; +import { getFingerPrint } from "../middleware/fingerPrint.js"; const router = Router(); @@ -14,6 +15,7 @@ router.post( '/auth/refresh-access', requireRefreshToken, cookieOnly, + getFingerPrint, rotateAccessToken ); @@ -21,6 +23,7 @@ router.post( '/auth/user/refresh-session', requireRefreshToken, cookieOnly, + getFingerPrint, async (req, res, next) => { try { const { jwt: { refresh_tokens } } = getConfiguration(); @@ -48,6 +51,7 @@ router.post( '/auth/refresh-session/rotate-every', requireRefreshToken, cookieOnly, + getFingerPrint, rotateCredentials ); diff --git a/src/jwtAuth/routes/allowBffAccessRoute.ts b/src/jwtAuth/routes/allowBffAccessRoute.ts index 38d2b80..1f2028f 100644 --- a/src/jwtAuth/routes/allowBffAccessRoute.ts +++ b/src/jwtAuth/routes/allowBffAccessRoute.ts @@ -1,6 +1,7 @@ import { allowBffAccess } from "../controllers/allowBffAccess.js"; import { getAccessTokenPayload } from "../controllers/getPayloadMeta.js"; import { getRefreshTokenMetaData } from "../controllers/getRefreshTokenMetaData.js"; +import { getFingerPrint } from "../middleware/fingerPrint.js"; import { cookieOnly } from "../middleware/postGuard.js"; import { requireAccessToken } from "../middleware/requireAccessToken.js"; import { requireRefreshToken } from "../middleware/requireRefreshToken.js"; @@ -25,12 +26,14 @@ const router = Router(); router.get('/secret/data', requireAccessToken, requireRefreshToken, + getFingerPrint, protectRoute, allowBffAccess ) router.get('/secret/accesstoken/metadata', requireAccessToken, requireRefreshToken, + getFingerPrint, protectRoute, cookieOnly, getAccessTokenPayload @@ -39,6 +42,7 @@ router.get('/secret/accesstoken/metadata', router.get('/secret/refreshtoken/metadata', requireRefreshToken, cookieOnly, + getFingerPrint, getRefreshTokenMetaData ) export default router; diff --git a/src/jwtAuth/routes/magicLinks.ts b/src/jwtAuth/routes/magicLinks.ts index 217e2ae..78c8335 100644 --- a/src/jwtAuth/routes/magicLinks.ts +++ b/src/jwtAuth/routes/magicLinks.ts @@ -7,11 +7,18 @@ import { initPasswordReset } from "../controllers/initPasswordReset.js"; import { detectBots } from "@riavzon/botdetector"; import { getLogger } from "../utils/logger.js"; import { getFingerPrint } from "../middleware/fingerPrint.js"; +import { verifyCustomMfa } from "../controllers/verifyCustomMfaController.js"; +import { customMfaFlowsVerification } from "../middleware/verifyTempLink.js"; +import { requireRefreshToken } from "../middleware/requireRefreshToken.js"; +import { initCustomMfaFlow } from "../controllers/initCustomMfaFlow.js"; +import { requireAccessToken } from "../middleware/requireAccessToken.js"; +import { protectRoute } from "../middleware/verifyJwt.js"; +import { updateEmailController } from "../controllers/updateEmailController.js"; const router = Router(); router -.route("/auth/verify-mfa/:visitor") +.route("/auth/verify-mfa") .get(linkMfaVerification) .post( linkMfaVerification, @@ -31,6 +38,75 @@ router verifyMFA ); + router.post('/custom/mfa/:reason', + contentType('application/json'), + requireAccessToken, + requireRefreshToken, + getFingerPrint, + protectRoute, + express.json({ + limit: '1kb', + verify: (req, res, buf) => { + if (!buf.toString()) { + const log = getLogger().child({service: 'auth', branch: 'routes', type: 'Json checker'}) + log.warn('EMPTY_BODY') + throw new Error('403'); + } + } + }), + initCustomMfaFlow + ) + + router + .route("/auth/verify-custom-mfa") + .get( + requireAccessToken, + requireRefreshToken, + getFingerPrint, + protectRoute, + customMfaFlowsVerification + ) + .post( + contentType('application/json'), + requireAccessToken, + requireRefreshToken, + getFingerPrint, + protectRoute, + express.json({ + limit: '1kb', + verify: (req, res, buf) => { + if (!buf.toString()) { + const log = getLogger().child({service: 'auth', branch: 'routes', type: 'Json checker'}) + log.warn('EMPTY_BODY') + throw new Error('403'); + } + } + }), + customMfaFlowsVerification, + detectBots, + verifyCustomMfa + ); + + router.post("/update/email", + contentType('application/json'), + requireAccessToken, + requireRefreshToken, + getFingerPrint, + protectRoute, + express.json({ + limit: '1kb', + verify: (req, res, buf) => { + if (!buf.toString()) { + const log = getLogger().child({service: 'auth', branch: 'routes', type: 'Json checker'}) + log.warn('EMPTY_BODY') + throw new Error('403'); + } + } + }), + customMfaFlowsVerification, + detectBots, + updateEmailController + ) router.post( "/auth/forgot-password", @@ -48,7 +124,7 @@ router initPasswordReset ) - router.route("/auth/reset-password/:visitor") + router.route("/auth/reset-password") .get(linkPasswordVerification) .post( linkPasswordVerification, @@ -64,6 +140,7 @@ router } }), detectBots, + getFingerPrint, verifyNewPassword ); diff --git a/src/jwtAuth/types/CustomMfaSchema.ts b/src/jwtAuth/types/CustomMfaSchema.ts new file mode 100644 index 0000000..81c1097 --- /dev/null +++ b/src/jwtAuth/types/CustomMfaSchema.ts @@ -0,0 +1,46 @@ +import * as z from 'zod'; +import { makeSafeString } from '../utils/zodSafeStringMaker.js'; + +/** + * Zod schemas for Custom MFA flow validation. + */ + +/** + * Schema for initializing a custom MFA flow. + * + * @property {string} random - A high-entropy random string. + * Length must be between 254 and 500 characters to ensure security. + * @property {string} reason - The purpose of the MFA flow (e.g., 'login', 'withdraw'). + * Length must be between 0 and 100 characters. + */ +export const schema = z.object({ + random: makeSafeString({ + min: 254, + max: 500, + patternMsg: "Invalid random" + }), + reason: makeSafeString({ + min: 0, + max: 100 + }), +}).required() + +/** + * Schema for verifying a custom MFA magic link. + * + * @description + * This schema extends the base flow schema with additional fields + * required for the magic link verification process. + * + * @property {string} random - The original random string. + * @property {string} reason - The original reason. + * @property {number} visitor - The visitor ID (coerced). + * @property {string} token - The magic link JWT token. + */ +export const verificationLink = z.object({ + ...schema.shape, + visitor: z.coerce.number(), + token: z.string() +}) +export type VerificationLinkSchema = z.infer +export type Schema = z.infer \ No newline at end of file diff --git a/src/jwtAuth/types/Emails.ts b/src/jwtAuth/types/Emails.ts new file mode 100644 index 0000000..ebf5dad --- /dev/null +++ b/src/jwtAuth/types/Emails.ts @@ -0,0 +1,84 @@ +export interface OTPEmails { + /** + * The URL the button points to + */ + link: string, + /** + * The 7-digit OTP code + */ + code: string | number, + /** + * OS string + */ + device: string, + /** + * Browser + */ + browser: string, + /** + * IP-based location + */ + location: string, + /** + * Formatted timestamp + */ + date: string, + /** + * Button Label + */ + cta: string, + banner_image: string, + device_image: string, + location_image: string, + date_image: string, + link_to_reset_password: string +} +export interface NotificationEmails { + /** + * The big header text + */ + title: string + /** + * Sub-header / Instruction (e.g. "Please login to your email account again") + */ + action: string + /** + * Email subject line (appears in body) + */ + subject: string, + /** + * User's display name + */ + username: string, + /** + * The main body text (supports HTML) + */ + message: string, + /** + * Button Label + */ + cta: string, + /** + * CTA link + */ + cta_link: string, + /** + * Name in signature (e.g. "Auth Service Team") + */ + websiteName: string, + /** + * Link to privacy policy + */ + privacy_link: string, + /** + * Link to contact page + */ + contact_link: string, + main_image: string, +} +export type EmailData = OTPEmails | NotificationEmails; +export interface EmailMetaDataOTP { + device: string, + browser: string, + location: string +} \ No newline at end of file diff --git a/src/jwtAuth/types/MfaAndPasswordResetSchema.ts b/src/jwtAuth/types/MfaAndPasswordResetSchema.ts new file mode 100644 index 0000000..52fc051 --- /dev/null +++ b/src/jwtAuth/types/MfaAndPasswordResetSchema.ts @@ -0,0 +1,22 @@ +import * as z from 'zod'; +import { makeSafeString } from '../utils/zodSafeStringMaker.js'; + +export const schema = z.object({ + random: makeSafeString({ + min: 254, + max: 500, + patternMsg: "Invalid random" + }), + reason: makeSafeString({ + min: 0, + max: 100 + }), +}).required() + +export const buildInMfaFlows = z.object({ + ...schema.shape, + visitor: z.coerce.number(), + token: z.string() +}) + +export type BuildInMfaFlowsSchema = z.infer \ No newline at end of file diff --git a/src/jwtAuth/types/UpdateEmail.ts b/src/jwtAuth/types/UpdateEmail.ts new file mode 100644 index 0000000..07b2dca --- /dev/null +++ b/src/jwtAuth/types/UpdateEmail.ts @@ -0,0 +1,75 @@ +import z from 'zod' +import { makeSafeString } from '../utils/zodSafeStringMaker.js' + +export const dataSchema = z.object({ + email:makeSafeString({ + min: 10, + max: 80, + pattern: /^(?!\.)(?!.*\.\.)[A-Za-z0-9_'-.]+[A-Za-z0-9_-]@[A-Za-z][A-Za-z-]*(?:\.[A-Za-z]{1,4}){1,3}$/, + patternMsg: `Please enter a valid email.\n + Username (before @):\n + Letters, digits, _ ' - . are allowed \n + + Cannot start or end with a dot, nor have “..”\n + + Domain (after @): \n + + First label must start with a letter (letters & hyphens allowed)\n + + Followed by 1 to 3 dot-separated labels\n + + Each of those labels must be 1–4 letters\n + + Examples:\n\n + + + john-lastname414@example.com\n + + john@example.com\n + + alice.smith@domain.co.uk\n + + o_connor@my-domain.io` + }) + .transform(s => s.toLowerCase()), + + newEmail:makeSafeString({ + min: 10, + max: 80, + pattern: /^(?!\.)(?!.*\.\.)[A-Za-z0-9_'-.]+[A-Za-z0-9_-]@[A-Za-z][A-Za-z-]*(?:\.[A-Za-z]{1,4}){1,3}$/, + patternMsg: `Please enter a valid email.\n + Username (before @):\n + Letters, digits, _ ' - . are allowed \n + + Cannot start or end with a dot, nor have “..”\n + + Domain (after @): \n + + First label must start with a letter (letters & hyphens allowed)\n + + Followed by 1 to 3 dot-separated labels\n + + Each of those labels must be 1–4 letters\n + + Examples:\n\n + + + john-lastname414@example.com\n + + john@example.com\n + + alice.smith@domain.co.uk\n + + o_connor@my-domain.io` + }) + .transform(s => s.toLowerCase()), + + password: + z.string() + .min(12) + .max(64) + .regex(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z\d])\S{12,64}$/, + `Password must be at least 12 characters long, include atleast one uppercase letter, one lowercase letter, one digit, and one special character.` + ), +}) +export type UpdateEmailSchemaType = z.infer diff --git a/src/jwtAuth/types/configSchema.ts b/src/jwtAuth/types/configSchema.ts index d00f0f1..4aea998 100644 --- a/src/jwtAuth/types/configSchema.ts +++ b/src/jwtAuth/types/configSchema.ts @@ -95,7 +95,50 @@ export const configurationSchema = z.strictObject({ * @example https://localhost */ domain: z.url({protocol: /^https?$/, normalize: false}), - maxCacheEntries: z.number().optional() + maxCacheEntries: z.number().optional(), + thresholds: z.object({ + + adaptiveMfa: z.object({ + allowedPerSuccessfulGet: z.number().default(5), + allowedPerSuccessfulPost: z.number().default(3) + }).prefault({}), + + linkPasswordVerification: z.object({ + allowedPerSuccessfulGet: z.number().default(5), + allowedPerSuccessfulPost: z.number().default(3) + }).prefault({}), + + customMfaFlowsAndEmailChanges: z.object({ + allowedPerSuccessfulGet: z.number().default(5), + allowedPerSuccessfulPost: z.number().default(3) + }).prefault({}), + }).prefault({}), + + linkToResetPasswordPage: z.url().default('https://localhost/accounts'), + emailImages: z.object({ + otp: z.object({ + bannerImage: z.url().default('https://media.riavzon.com/otp/image-1.png'), + device_image: z.url().default('https://media.riavzon.com/otp/image-2.png'), + location_image: z.url().default('https://media.riavzon.com/otp/image-3.png'), + date_image: z.url().default('https://media.riavzon.com/otp/image-4.png'), + }).prefault({}), + + notificationBanner: z.url().default('https://media.riavzon.com/notifications/image-1.gif') + }).prefault({}), + + paths: z.object({ + pathForCustomFlow: z.string().default('/auth/bounce'), + pathForPasswordResetLink: z.string().default('/auth/bounce'), + pathForAdaptiveMfaLink: z.string().default('/auth/bounce') + }).prefault({}), + + notificationEmail: z.object({ + websiteName: z.string().default('Security Service'), + privacyPolicyLink: z.url(), + contactPageLink: z.url(), + changePasswordPageLink: z.url(), + loginPageLink: z.url() + }) }), providers: z.array( z.union([ @@ -127,9 +170,9 @@ export const configurationSchema = z.strictObject({ /** * Trust the user device on successful logins. * Preventing the user to go through an MFA flow on login and expired canary_id Cookie. - * @default true + * @default false */ - trustUserDeviceOnAuth: z.boolean().default(true), + trustUserDeviceOnAuth: z.boolean().default(false), jwt: z.object({ jwt_secret_key: z.string(), diff --git a/src/jwtAuth/types/userIds.d.ts b/src/jwtAuth/types/userIds.d.ts index efd57a5..640e3c8 100644 --- a/src/jwtAuth/types/userIds.d.ts +++ b/src/jwtAuth/types/userIds.d.ts @@ -25,7 +25,7 @@ declare global { link: { visitor: number, subject: string, - purpose: 'PASSWORD_RESET' | 'MFA', + purpose: 'PASSWORD_RESET' | 'MAGIC_LINK_MFA_CHECKS' | string, jti?: string; } newVisitorId?: number; diff --git a/src/jwtAuth/utils/changePassword.ts b/src/jwtAuth/utils/changePassword.ts index 329120e..95d8d2b 100644 --- a/src/jwtAuth/utils/changePassword.ts +++ b/src/jwtAuth/utils/changePassword.ts @@ -6,6 +6,7 @@ import { RowDataPacket } from "mysql2"; import crypto from 'crypto' import { getLogger } from "../utils/logger.js"; import { getConfiguration } from "../config/configuration.js"; +import { toDigestHex } from "./hashChecker.js"; /** * @description @@ -54,19 +55,26 @@ const { id, name, user_email, visitor_id, password_hash } = results[0]; } const jti = `${crypto.randomUUID()}${crypto.randomBytes(64).toString('hex')}`; +const random = crypto.randomBytes(128).toString('hex'); +const { input: randomHashed } = await toDigestHex(random); const payload: LinkTokenPayload = { visitor: visitor_id, - subject: "MAGIC_LINK_Restart", + subject: `PASSWORD_RESET_${visitor_id}`, purpose: "PASSWORD_RESET", - jti: jti + randomHashed, + jti: jti, }; const tempToken = tempJwtLink(payload); - const path = "/auth/reset-password"; - const url = `${magic_links.domain}${path}/${visitor_id}?temp=${encodeURIComponent(tempToken)}` - - await resetPasswordEmail(name, user_email, url) + const { pathForPasswordResetLink } = magic_links.paths; + const url = new URL(pathForPasswordResetLink, magic_links.domain); + url.searchParams.set('visitor', String(visitor_id)); + url.searchParams.set('token', tempToken); + url.searchParams.set('random', random); + url.searchParams.set('reason', 'PASSWORD_RESET'); + + await resetPasswordEmail(name, user_email, url.toString()) log.info({userId: id},'An email for password reset was send to user') return { valid: true diff --git a/src/jwtAuth/utils/customMfaLinks.ts b/src/jwtAuth/utils/customMfaLinks.ts new file mode 100644 index 0000000..25c5e61 --- /dev/null +++ b/src/jwtAuth/utils/customMfaLinks.ts @@ -0,0 +1,164 @@ +import { tempJwtLink } from "../../tempLinks.js"; +import { getPool } from "../config/dbConnection.js"; +import { RowDataPacket } from "mysql2"; +import crypto from 'crypto' +import { getLogger } from "../utils/logger.js"; +import { getConfiguration } from "../config/configuration.js"; +import { Response } from "express"; +import { getLimiters } from "./limiters/protectedEndpoints/emailMfaFlow/email.js"; +import { guard } from "./limiters/utils/guard.js"; +import { makeConsecutiveCache } from "./limiters/utils/consecutiveCache.js"; +import { toDigestHex } from "./hashChecker.js"; +import { generateMfaCode } from "./secureRandomCode.js"; +import { mfaEmail } from "./systemEmailMap.js"; +import { EmailMetaDataOTP } from "../types/Emails.js"; + +const consecutiveForGlobal = makeConsecutiveCache<{countData: number}>(100, 1000 * 60 * 60 * 24); +const consecutiveForUserId = makeConsecutiveCache<{countData: number}>(2000, 1000 * 60 * 60 * 24); +const consecutiveForIp = makeConsecutiveCache<{countData: number}>(2000, 1000 * 60 * 60 * 24); + +/** + * Generates a custom MFA flow, including a magic link and an MFA code. + * + * @description + * This utility manages the internal logic for starting a custom MFA process: + * 1. **Reserved Reason Check**: Prevents collision with internal reasons (`MFA`, `PASSWORD_RESET`, etc.). + * 2. **Multi-layer Rate Limiting**: Global, User ID, and IP based guards. + * 3. **Magic Link Generation**: Creates a TEMPORARY JWT containing the `randomHashed` value and a unique `jti`. + * 4. **MFA Code Storage**: Uses `generateMfaCode` to securely store the session-linked MFA code in the DB. + * 5. **Email Delivery**: Sends a generic MFA email with the generated URL. + * + * @param {string} random - A high-entropy random string (min 254 chars) used for verification. + * @param {string} reason - The specific purpose for this MFA flow (e.g., 'delete_account'). + * @param {object} user - Information about the user. + * @param {number} user.userId - Database ID of the user. + * @param {number} user.visitor - Database ID of the visitor. + * @param {string} sessionToken - Current refresh token string to link the MFA code to. + * @param {string} ip - IP address of the requester for rate limiting. + * @param {Response} res - Express response object (for rate limiting headers). + * + * @returns {Promise<{ ok: boolean; date: string; data: string }>} Result of the operation. + * + * @example + * const result = await generateCustomMfaFlow( + * longRandomString, + * 'sensitive_action', + * { userId: 1, visitor: 123 }, + * refreshToken, + * '127.0.0.1', + * res + * ); + * if (result.ok) console.log('MFA flow started'); + */ +export async function generateCustomMfaFlow( + random: string, + reason: string, + user: { userId: number; visitor: number }, + sessionToken: string, + ip: string, + res: Response, + meta: EmailMetaDataOTP +) { + const { magic_links } = getConfiguration(); + const { globalEmailLimiter, userIdLimiter, ipLimiter } = getLimiters(); + const log = getLogger().child({ service: 'auth', branch: 'mfa', visitorId: user.visitor, reason }); + + if (reason === "MAGIC_LINK_MFA_CHECKS" || reason === "PASSWORD_RESET" || reason === "PASSWORD_RESET_FLOW" || reason === "EMAIL_MFA_FLOW") { + return { + ok: false, + date: new Date().toISOString(), + data: 'exists' + } + } + + if (!(await guard(globalEmailLimiter, 'global_emails', consecutiveForGlobal, 1, 'globalEmailLimiter', log, res))) { + return { + ok: false, + date: new Date().toISOString(), + data: 'rate_limited' + }; + } + + if (!(await guard(userIdLimiter, `user_${user.userId}`, consecutiveForUserId, 2, 'userIdLimiter', log, res))) { + return { + ok: false, + date: new Date().toISOString(), + data: 'rate_limited' + }; + } + + if (!(await guard(ipLimiter, ip, consecutiveForIp, 2, 'ipLimiter', log, res))) { + return { + ok: false, + date: new Date().toISOString(), + data: 'rate_limited' + }; + } + const jti = `${crypto.randomUUID()}${crypto.randomBytes(64).toString('hex')}`; + const { input: randomHashed } = await toDigestHex(random); + + const tempToken = tempJwtLink({ + visitor: user.visitor, + subject: `${reason}_${user.visitor}`, + purpose: reason, + randomHashed, + jti: jti + }); + log.info(`Entered mfa, generating temp link...`); + const { pathForCustomFlow } = magic_links.paths + const url = new URL(pathForCustomFlow, magic_links.domain) + url.searchParams.set('visitor', String(user.visitor)) + url.searchParams.set('token', tempToken); + url.searchParams.set('random', random); + url.searchParams.set('reason', reason); + + try { + const { ok, data, date, code } = await generateMfaCode(log, sessionToken, user.userId, jti); + + if (data === 'Code exists') { + return { + ok: true, + date: new Date().toISOString(), + data: "A valid code already exists. Please check your email." + } + } + + if (!ok || !code) { + log.warn({ data, ok, date }, "Error generating new mfa code."); + return { + ok: false, + date: new Date().toISOString(), + data: data + } + } + + log.info("Sending email...") + const pool = getPool() + const [rows] = await pool.execute(`SELECT email FROM users WHERE id = ?`, [user.userId]); + + if (!rows.length) { + log.warn("Failed to find user email and name.") + return { + ok: false, + date: new Date().toISOString(), + data: "User not found" + } + } + + const { email } = rows[0]; + await mfaEmail(Number(code), email, url.toString(), meta) + log.info(`Email sended.`) + return { + ok: true, + date: new Date().toISOString(), + data: "Please check your email to continue the action." + } + } catch (err) { + log.error({err}, `Error Starting an mfa flow`) + return { + ok: false, + date: new Date().toISOString(), + data: "Unexpected error" + } + } +} \ No newline at end of file diff --git a/src/jwtAuth/utils/emailMFA.ts b/src/jwtAuth/utils/emailMFA.ts index c5dadbc..f86a2ad 100644 --- a/src/jwtAuth/utils/emailMFA.ts +++ b/src/jwtAuth/utils/emailMFA.ts @@ -9,7 +9,9 @@ import { Response } from "express"; import { guard } from "./limiters/utils/guard.js"; import { getLimiters } from "./limiters/protectedEndpoints/emailMfaFlow/email.js"; import { makeConsecutiveCache } from "./limiters/utils/consecutiveCache.js"; - +import { generateMfaCode } from "./secureRandomCode.js"; +import { EmailMetaDataOTP } from "../types/Emails.js"; +import { toDigestHex } from "./hashChecker.js"; const consecutiveForGlobal = makeConsecutiveCache<{countData: number}>(100, 1000 * 60 * 60 * 24); const consecutiveForUserId = makeConsecutiveCache<{countData: number}>(2000, 1000 * 60 * 60 * 24); const consecutiveForIp = makeConsecutiveCache<{countData: number}>(2000, 1000 * 60 * 60 * 24); @@ -49,7 +51,8 @@ export async function sendTempMfaLink( user: { userId: number; visitor: number }, sessionToken: string, ip: string, - res: Response + res: Response, + meta: EmailMetaDataOTP ): Promise { const { magic_links } = getConfiguration(); const { globalEmailLimiter, userIdLimiter, ipLimiter } = getLimiters(); @@ -68,70 +71,43 @@ export async function sendTempMfaLink( } const jti = `${crypto.randomUUID()}${crypto.randomBytes(64).toString('hex')}`; + const random = crypto.randomBytes(128).toString('hex'); + const { input: randomHashed } = await toDigestHex(random); + const tempToken = tempJwtLink({ visitor: user.visitor, - subject: 'MAGIC_LINK_MFA_CHECKS', - purpose: 'MFA', + subject: `MAGIC_LINK_MFA_CHECKS_${user.visitor}`, + purpose: 'MAGIC_LINK_MFA_CHECKS', + randomHashed, jti: jti }); log.info(`Entered mfa, generating temp link...`); - const path = "/auth/verify-mfa"; - const url = `${magic_links.domain}${path}/${user.visitor}?temp=${encodeURIComponent(tempToken)}`; - - log.info(`Generating mfa code...`); - const randomCode = crypto.randomInt(1000000, 9999999).toString().padStart(7, '0'); - const hashedCode = crypto.createHash("sha256").update(randomCode).digest("hex"); - const expires = new Date(Date.now() + 7 * 60 * 1000); - const hashedClientToken = crypto.createHash('sha256').update(sessionToken).digest('hex'); - const params = [user.userId, hashedClientToken, jti, hashedCode, expires]; - const pool = getPool(); - const conn = await pool.getConnection(); - - try { - - await conn.beginTransaction(); - - const [exits] = await conn.execute(` - SELECT code_hash FROM mfa_codes - WHERE user_id = ? - AND token = ? - AND expires_at > UTC_TIMESTAMP() - `, [user.userId, hashedClientToken]); - - - if (exits.length > 0) { - log.info(`Valid MFA code found for user ${user.userId}: ${exits[0].code_hash}`) - await conn.commit(); - conn.release(); - return true; - }; + const { pathForAdaptiveMfaLink } = magic_links.paths; + const url = new URL(pathForAdaptiveMfaLink, magic_links.domain); + url.searchParams.set('visitor', String(user.visitor)); + url.searchParams.set('token', tempToken); + url.searchParams.set('random', random); + url.searchParams.set('reason', 'MAGIC_LINK_MFA_CHECKS'); - await conn.execute(` - DELETE FROM mfa_codes - WHERE user_id = ? - `, [user.userId]); +try { + const { ok, data, code, date } = await generateMfaCode(log, sessionToken, user.userId, jti) + + if (data === 'Code exists') { + log.info(`Valid MFA code found for user ${user.userId}`) + return true; + } - await conn.execute(` - INSERT INTO mfa_codes - (user_id, token, jti, code_hash, expires_at) - VALUES (?, ?, ?, ?, ?) - `,params); - await conn.commit(); - log.info(`Generated code`) + if (!ok || !code) { + log.warn({ data, ok, date }, "Error generating new mfa code."); + return false + } -} catch(err) { - log.error({err}, `error Generating code`) - await conn.rollback(); - conn.release(); - return false; -} -conn.release(); -try { + const pool = getPool() log.info(`Sending email...`) const [rows] = await pool.execute(`SELECT name, email FROM users WHERE id = ?`, [user.userId]); const { name, email } = rows[0]; - await mfaEmail(name, Number(randomCode), email, url); + await mfaEmail(Number(code), email, url.toString(), meta); log.info(`email sended.`) return true; } catch (err) { diff --git a/src/jwtAuth/utils/isPasswordPwned.ts b/src/jwtAuth/utils/isPasswordPwned.ts new file mode 100644 index 0000000..b2613a6 --- /dev/null +++ b/src/jwtAuth/utils/isPasswordPwned.ts @@ -0,0 +1,58 @@ +import crypto from 'crypto' +import { makeConsecutiveCache } from './limiters/utils/consecutiveCache.js'; + +export interface PwnedResponse { + pwned: boolean, + count: number, + date: string, +} +const resCache = makeConsecutiveCache(1000, 1000 * 60 * 15) +const prefixCache = makeConsecutiveCache>(1000, 1000 * 60 * 60 * 48) + +export async function isPwned(password: string): Promise { + + const sha1 = crypto.createHash('sha1').update(password, 'utf-8').digest('hex').toUpperCase() + const prefix: string = sha1.slice(0, 5) + const suffix: string = sha1.slice(5) + const date = new Date().toISOString() + + const exists = resCache.get(suffix) + if (exists) return exists; + + + let suffixMap: Map | undefined = prefixCache.get(prefix) + + if (!suffixMap) { + const res: Response = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`, { + headers: { + 'Add-Padding': 'true' + } + }) + + if (!res.ok) { + const result: PwnedResponse = { pwned: false, count: 0, date } + resCache.set(suffix, result) + return result + } + + suffixMap = new Map() + const body: string = await res.text() + + for (const line of body.split('\r\n')) { + const [hashSuffix, countStr] = line.split(':') + const count: number = parseInt(countStr, 10) + if (count > 0) suffixMap.set(hashSuffix, count) + } + prefixCache.set(prefix, suffixMap) + } + + const count: number = suffixMap.get(suffix) ?? 0 + const result: PwnedResponse = { + pwned: count > 0, + count, + date + } + resCache.set(suffix, result) + return result + } + \ No newline at end of file diff --git a/src/jwtAuth/utils/limiters/protectedEndpoints/emailMfaFlow/email.ts b/src/jwtAuth/utils/limiters/protectedEndpoints/emailMfaFlow/email.ts index 1fe259f..bf1e106 100644 --- a/src/jwtAuth/utils/limiters/protectedEndpoints/emailMfaFlow/email.ts +++ b/src/jwtAuth/utils/limiters/protectedEndpoints/emailMfaFlow/email.ts @@ -63,7 +63,7 @@ function buildLimiter(): LimiterBundle { dbName: store.rate_limiters_pool.dbName, storeClient: pool, storeType : 'mysql2', - inMemoryBlockOnConsumed: rate_limiters?.emailMfaLimiters?.userIdLimiter.inMemoryBlockOnConsumed ?? 5, + inMemoryBlockOnConsumed: rate_limiters?.emailMfaLimiters?.userIdLimiter.inMemoryBlockOnConsumed ?? 8, tableName: 'email_mfa', keyPrefix: 'userIdLimiter', points: rate_limiters?.emailMfaLimiters?.userIdLimiter.points ?? 8, @@ -76,7 +76,7 @@ function buildLimiter(): LimiterBundle { dbName: store.rate_limiters_pool.dbName, storeClient: pool, storeType : 'mysql2', - inMemoryBlockOnConsumed: rate_limiters?.emailMfaLimiters?.globalEmailLimiter.inMemoryBlockOnConsumed ?? 5, + inMemoryBlockOnConsumed: rate_limiters?.emailMfaLimiters?.globalEmailLimiter.inMemoryBlockOnConsumed ?? 800, tableName: 'email_mfa', keyPrefix: 'globalEmailLimiter', points: rate_limiters?.emailMfaLimiters?.globalEmailLimiter.points ?? 800, diff --git a/src/jwtAuth/utils/limiters/utils/guard.ts b/src/jwtAuth/utils/limiters/utils/guard.ts index 1c99575..50c841c 100644 --- a/src/jwtAuth/utils/limiters/utils/guard.ts +++ b/src/jwtAuth/utils/limiters/utils/guard.ts @@ -5,6 +5,7 @@ import { RateLimiterMemory, RateLimiterMySQL, RateLimiterRes, RLWrapperBlackAndW import pino from "pino"; import { BlockableUnion } from "../rateLimit.js"; import { makeConsecutiveCache } from "./consecutiveCache.js"; +import crypto from 'node:crypto'; type Limiter = RateLimiterMemory | RateLimiterMySQL | BlockableUnion @@ -66,27 +67,33 @@ export async function guard( res: Response, seconds?: number ): Promise { + + + let finalKey = key; + if (limiter instanceof RateLimiterMySQL || key.length > 255) { + finalKey = crypto.createHash('sha256').update(key).digest('hex'); + } - if (isBlockedCache.get(key)?.Blocked) { - log.warn({ key, label, limiters: limiter.keyPrefix, expires: isBlockedCache.get(key)?.expire}, 'Request blocked by cache'); - res.set('Retry-After', String(isBlockedCache.get(key)?.expire)).status(429).json( + if (isBlockedCache.get(finalKey)?.Blocked) { + log.warn({ key, label, limiters: limiter.keyPrefix, expires: isBlockedCache.get(finalKey)?.expire}, 'Request blocked by cache'); + res.set('Retry-After', String(isBlockedCache.get(finalKey)?.expire)).status(429).json( { error: 'Too many requests', - retry: isBlockedCache.get(key)?.expire + retry: isBlockedCache.get(finalKey)?.expire }); return false; } - const rlRes = await consumeOrReject(limiter, key, res, log) as RateLimiterRes | null; + const rlRes = await consumeOrReject(limiter, finalKey, res, log) as RateLimiterRes | null; if (rlRes === null) { - const entry = (cache.get(key)?.countData ?? 0) + 1; - cache.set(key, { countData: entry }); + const entry = (cache.get(finalKey)?.countData ?? 0) + 1; + cache.set(finalKey, { countData: entry }); log.warn({ key, label, entry }, 'Strike recorded'); if (entry >= maxBans) { - await limiter.block(key, seconds ?? 0); - isBlockedCache.set(key, {Blocked: true, expire: `${seconds ?? 'permanent'}` }) + await limiter.block(finalKey, seconds ?? 0); + isBlockedCache.set(finalKey, {Blocked: true, expire: `${seconds ?? 'permanent'}` }) log.warn({ key, label, limiters: limiter.keyPrefix}, `Key added to ${seconds ?? 'permanent'} duration blacklist`); } return false; diff --git a/src/jwtAuth/utils/magicLinksCache.ts b/src/jwtAuth/utils/magicLinksCache.ts index 24939a1..5dc8638 100644 --- a/src/jwtAuth/utils/magicLinksCache.ts +++ b/src/jwtAuth/utils/magicLinksCache.ts @@ -2,22 +2,22 @@ import { LRUCache } from "lru-cache"; import { getConfiguration } from "../config/configuration.js"; -export interface CacheEntry { +export type CacheEntry = Record> = { visitor: number; subject: string; - purpose: "PASSWORD_RESET" | "MFA"; + purpose: "PASSWORD_RESET" | "MAGIC_LINK_MFA_CHECKS" | string; jti: string; valid: boolean; -} +} & T; -let cache: LRUCache | undefined; +let cache: LRUCache> | undefined; -export function magicLinksCache(): LRUCache { +export function magicLinksCache(): LRUCache> { if (cache) return cache; const { magic_links } = getConfiguration(); - cache = new LRUCache({ + cache = new LRUCache>({ max: magic_links.maxCacheEntries ?? 500, ttl: magic_links.expiresInMs ? magic_links.expiresInMs : 15 * 60 * 1000 }); diff --git a/src/jwtAuth/utils/secureRandomCode.ts b/src/jwtAuth/utils/secureRandomCode.ts new file mode 100644 index 0000000..47ed9d5 --- /dev/null +++ b/src/jwtAuth/utils/secureRandomCode.ts @@ -0,0 +1,99 @@ +import crypto from 'node:crypto' +import pino from 'pino'; +import { getPool } from '../config/dbConnection.js'; +import { RowDataPacket } from 'mysql2'; + +/** + * Generates and stores a cryptographically secure MFA code for a user session. + * + * @description + * This is the centralized utility for MFA code generation: + * 1. **Idempotency**: It first checks if a valid, unexpired code already exists for the user/session. + * If one exists, it returns `ok: false` with `data: 'Code exists'` to avoid spamming or overwriting valid codes. + * 2. **Cleanup**: Before inserting a NEW code, it deletes any existing codes for the user to ensure single-code-at-a-time logic. + * 3. **Secure Generation**: Uses `crypto.randomInt` to generate a 7-digit numeric code. + * 4. **Hashed Storage**: Stores the code as a SHA-256 hash in the `mfa_codes` table, linked to a session `jti` and a hashed version of the `sessionToken`. + * 5. **Transaction Integrity**: Uses a MySQL transaction to ensure the double deletion/insertion is atomic. + * + * @param {pino.Logger} log - Logger instance. + * @param {string} sessionToken - The raw session (refresh) token string to bind the code to. + * @param {string | number} userId - Database ID of the user. + * @param {string} jti - Unique identifier for the current MFA flow/session. + * + * @returns {Promise<{ok: boolean, date: string, code?: string, data: string}>} + * If `ok` is true, the `code` field contains the raw numeric code to be sent to the user. + * + * @example + * const result = await generateMfaCode(log, refreshToken, 1, 'unique-flow-jti'); + * if (result.ok) { + * console.log(`Code to send: ${result.code}`); + * } else if (result.data === 'Code exists') { + * console.log('Skipping generation, code already active.'); + * } + */ +export async function generateMfaCode(log: pino.Logger, sessionToken: string, userId: string | number, jti: string): +Promise<{ok: boolean, date: string, code?: string, data: string}> { + + log.info(`Generating mfa code...`); + const randomCode = crypto.randomInt(1000000, 9999999).toString().padStart(7, '0'); + const hashedCode = crypto.createHash("sha256").update(randomCode).digest("hex"); + const expires = new Date(Date.now() + 7 * 60 * 1000); + const hashedClientToken = crypto.createHash('sha256').update(sessionToken).digest('hex'); + const params = [userId, hashedClientToken, jti, hashedCode, expires]; + const pool = getPool(); + const conn = await pool.getConnection(); + + + try { + + await conn.beginTransaction(); + + const [exits] = await conn.execute(` + SELECT code_hash FROM mfa_codes + WHERE user_id = ? + AND token = ? + AND expires_at > UTC_TIMESTAMP() + `, [userId, hashedClientToken]); + + + if (exits.length > 0) { + log.info(`Valid MFA code found for user ${userId}: ${exits[0].code_hash}`) + await conn.commit(); + return { + ok: false, + date: new Date().toISOString(), + data: 'Code exists' + } + }; + + await conn.execute(` + DELETE FROM mfa_codes + WHERE user_id = ? + `, [userId]); + + await conn.execute(` + INSERT INTO mfa_codes + (user_id, token, jti, code_hash, expires_at) + VALUES (?, ?, ?, ?, ?) + `,params); + await conn.commit(); + + log.info(`Generated code`) + return { + ok: true, + date: new Date().toISOString(), + code: randomCode, + data: "Generated code" + } + } catch(err) { + log.error({err}, `error Generating code`) + await conn.rollback(); + return { + ok: false, + date: new Date().toISOString(), + data: "Unexpected error" + } + } finally { + conn.release(); + } +} \ No newline at end of file diff --git a/src/jwtAuth/utils/systemEmailMap.ts b/src/jwtAuth/utils/systemEmailMap.ts index 7bd21cf..961daa9 100644 --- a/src/jwtAuth/utils/systemEmailMap.ts +++ b/src/jwtAuth/utils/systemEmailMap.ts @@ -1,57 +1,68 @@ -import { EmailData } from "./systemEmails.js"; +import { getConfiguration } from '../config/configuration.js'; +import { EmailData, EmailMetaDataOTP } from '../types/Emails.js'; import { sendSystemEmail } from "./systemEmails.js"; -export async function mfaEmail(userName: string, code: number, email: string, url: string): Promise { - + +export async function mfaEmail(code: number, email: string, url: string, meta: EmailMetaDataOTP): Promise { + const { device, location, browser } = meta + const { magic_links } = getConfiguration(); + const { bannerImage, date_image, device_image, location_image } = magic_links.emailImages.otp; + const emailData: EmailData = { - userName: userName, + link: url, code: code, - message: `If you didn't requested a code, Please change your password immediately, - and enable multi-factor authentication if you haven't already.`, - headers: { - headerOne: 'Security Code', - headerTwo: 'To continue please enter the code below', - }, - link: [ - { - label: `Verify Here`, - path: url - } - ], - images: [ - { - path: 'https://media.riavzon.com/image-2.png', - name: 'auth', - alt: 'authentication', - } -] + device: device ?? "Unknown Device", + location: location ?? "Unknown Location", + date: new Date().toLocaleString(), + cta: "Verify Here", + browser: browser ?? "Unknown Browser", + link_to_reset_password: magic_links.linkToResetPasswordPage, + banner_image: bannerImage, + date_image, + device_image, + location_image } - await sendSystemEmail(email, `Securtiy alert`, emailData, 'system') + await sendSystemEmail(email, `Security Code - ${code}`, emailData, 'OTP/index') } export async function resetPasswordEmail(userName: string, email: string, url: string) { + const {magic_links} = getConfiguration() + const { notificationBanner } = magic_links.emailImages + const { websiteName, privacyPolicyLink, changePasswordPageLink, contactPageLink } = magic_links.notificationEmail; const emailData: EmailData = { - userName: userName, - message: `If you didn't requested to change your password, Please ignore this email, - and enable multi-factor authentication if you haven't already.`, - headers: { - headerOne: 'Password Reset', - headerTwo: 'To Change your password click the button below.', - }, - link: [ - { - label: `Change Password`, - path: url - } - ], - images: [ - { - path: 'https://media.riavzon.com/image-2.png', - name: 'auth', - alt: 'authentication', - } -] + title: "Password Reset Request", + action: "Please reset your password", + subject: "Reset your password", + username: userName, + message: `We received a request to reset your password. If you didn't make this request, you can safely ignore this email.`, + cta_link: changePasswordPageLink, + cta: "Change Password", + websiteName: websiteName, + privacy_link: privacyPolicyLink, + contact_link: contactPageLink, + main_image: notificationBanner } - await sendSystemEmail(email, `Securtiy alert`, emailData, 'system') + await sendSystemEmail(email, `Password Reset Request`, emailData, 'nottifications/index') } + +export async function sendEmailNotification(email: string,userName: string, vars: Partial) { + const {magic_links} = getConfiguration() + const { notificationBanner } = magic_links.emailImages + const { websiteName, privacyPolicyLink, changePasswordPageLink, contactPageLink } = magic_links.notificationEmail; + const defaults: EmailData = { + title: "Password Reset Request", + action: "Please reset your password", + subject: "Reset your password", + username: userName, + message: `We received a request to reset your password. If you didn't make this request, you can safely ignore this email.`, + cta: "Change Password", + cta_link: changePasswordPageLink, + websiteName: websiteName, + privacy_link: privacyPolicyLink, + contact_link: contactPageLink, + main_image: notificationBanner, + ...vars + } + await sendSystemEmail(email, defaults.subject, defaults, 'nottifications/index') +} \ No newline at end of file diff --git a/src/jwtAuth/utils/systemEmails.ts b/src/jwtAuth/utils/systemEmails.ts index fe3b7d3..d0dbbf8 100644 --- a/src/jwtAuth/utils/systemEmails.ts +++ b/src/jwtAuth/utils/systemEmails.ts @@ -4,35 +4,11 @@ import { fileURLToPath } from 'node:url'; import ejs from "ejs"; import { getConfiguration } from '../config/configuration.js'; import { getLogger } from './logger.js'; - +import { EmailData } from '../types/Emails.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -export interface EmailData { - userName: string; - code?: number; - message: string; - headers: { - headerOne: string; - headerTwo?: string; - headerTree?: string - } - link?: { - label: string; - path: string - }[], - variables?: { - variableOne: string; - variableTwo: string; - variableThree: string; - }[] - images?: { - path: string; - name: string; - alt: string; - }[] -} /** * @description * Sends emails via your SMTP provider. diff --git a/src/jwtAuth/utils/verifyMfaCode.ts b/src/jwtAuth/utils/verifyMfaCode.ts new file mode 100644 index 0000000..3455e59 --- /dev/null +++ b/src/jwtAuth/utils/verifyMfaCode.ts @@ -0,0 +1,294 @@ +import { NextFunction, Request, Response } from 'express'; +import { getPool } from '../config/dbConnection.js'; +import { PoolConnection } from 'mysql2/promise'; +import { ResultSetHeader, RowDataPacket } from "mysql2"; +import crypto from 'crypto'; +import { code as codeSchema } from '../models/zodSchema.js' +import { generateRefreshToken, revokeAllRefreshTokens, revokeRefreshToken, verifyRefreshToken } from '../../refreshTokens.js'; +import { generateAccessToken } from '../../accessTokens.js'; +import { getConfiguration } from '../config/configuration.js'; +import { makeCookie } from '../utils/cookieGenerator.js'; +import { validateSchema } from '../utils/validateZodSchema.js'; +import { guard } from "../utils/limiters/utils/guard.js"; +import { getLimiters, resetLimitersUni } from "../utils/limiters/protectedEndpoints/tempPostRoutesLimiter.js"; +import { makeConsecutiveCache } from "../utils/limiters/utils/consecutiveCache.js" +import { updateVisitors } from '@riavzon/botdetector'; +import pino from 'pino'; + +const consecutiveForSubmittedHash = makeConsecutiveCache< {countData:number} >(2000, 1000 * 60 * 10); +const consecutiveForSlowDown = makeConsecutiveCache< {countData:number} >(2000, 1000 * 60 * 10); +export const consecutiveForJti = makeConsecutiveCache< {countData:number} >(2000, 1000 * 60 * 20); + +/** + * Core utility for verifying Multi-Factor Authentication (MFA) codes. + * + * @description + * This function performs the actual verification of a submitted MFA code: + * 1. **Rate Limiting**: Guards against brute-force attacks using IP and session-based limiters. + * 2. **Code Hashing**: Hashes the submitted code and compares it with the stored hash in the database. + * 3. **Database Transaction**: + * - Finds a valid, unexpired, and unused code for the given session (JTI). + * - Deletes the code upon successful verification (atomic consumption). + * - **Optional Callback**: Executes `onSuccess` logic (e.g. email update) within the same transaction. + * - Updates the user's `last_mfa_at` and visitor status. + * 4. **Token Rotation**: Revokes the current refresh token and generates a new pair (Access + Refresh). + * 5. **Fingerprint Update**: Updates the visitor's fingerprint using the latest request data. + * + * @param {Request} req - Express request object. + * Must contain `req.link` (visitor, subject, purpose, jti) and `req.fingerPrint`. + * @param {Response} res - Express response object. + * @param {NextFunction} next - Express next function. + * @param {string} code - The MFA code submitted by the user. + * @param {pino.Logger} log - Logger instance for tracing. + * @param {Function} [onSuccess] - Optional callback to run custom logic inside the transaction. + * + * @returns {Promise} + * + * @example + * await verifyMfaCode(req, res, next, '123456', logger, async (conn, userId) => { + * await conn.execute('UPDATE users SET email = ? ...', [newEmail]); + * }); + */ +export async function verifyMfaCode( + req: Request, + res: Response, + next: NextFunction, + code: string, + log: pino.Logger, + returnMetaData: boolean = false, + revokeAllTokensOnSuccess: boolean = false, + onSuccess?: (connection: PoolConnection, userId: number) => Promise +) { + + const { uniLimiter, ipLimit, usedJtiLimiter } = getLimiters(); + const { jwt } = getConfiguration(); + const fingerprints = req.fingerPrint; + log.info(`Verifying mfa code...`) + + const verify = async (req: Request, res: Response, next: NextFunction) => { + + if (!(await guard(uniLimiter, req.ip!, consecutiveForSlowDown, 2, 'SlowDown', log, res))) return; + if (!(await guard(uniLimiter, req.link.jti!, consecutiveForJti, 1,'jti', log, res))) return; + + const result = await validateSchema(codeSchema, { code }, req, log) + + if ("valid" in result) { + if (!result.valid && result.errors !== 'XSS attempt') { + res.status(400).json(Object.assign({error: result.errors, "banned": false })) + return; + } + res.status(403).json({"banned": true}) + return; + } + + if (!result.success) { + log.error({ errors: result.error }, "Zod validation failed") + res.status(400).json({ + ok: false, + date: new Date().toISOString(), + reason: "Zod validation failed malformed link" + }) + return; + } + const {code: validatedCode} = result.data; + const submittedHash = crypto.createHash("sha256").update((validatedCode)).digest("hex"); + + if (!(await guard(ipLimit, submittedHash, consecutiveForSubmittedHash, 1,'submittedHash', log, res))) return; + const { jti, visitor } = req.link; + + if (!jti) { + log.warn(`No Session Token`) + res.status(500).json({ error: 'No Session Token' }); + return; + } + const pool = getPool() + const conn = await pool.getConnection(); + + try { + await conn.beginTransaction(); + const [rows] = await conn.execute(` + SELECT token, user_id + FROM mfa_codes + WHERE jti = ? + AND code_hash = ? + AND expires_at > UTC_TIMESTAMP() + AND used = 0 + FOR UPDATE + `,[jti, submittedHash]) + + if (!rows || rows.length === 0) { + log.warn(`Invalid or expired code.`) + await conn.rollback(); + res.status(401).json({ error: 'Invalid or expired code.' }); + return; + } + + const [DELETE] = await conn.execute(` + DELETE FROM mfa_codes + WHERE jti = ? + AND user_id = ? + AND code_hash = ? + AND expires_at > UTC_TIMESTAMP() + AND used = 0 + LIMIT 1 + `,[jti, rows[0].user_id, submittedHash]) + + if (DELETE.affectedRows !== 1) { + log.warn(`Invalid or expired code.`) + await conn.rollback(); + res.status(401).json({ error: 'Invalid or expired code.' }); + return; + } + await ipLimit.block(submittedHash, 60 * 10); + + if (onSuccess) { + log.info("Executing custom onboarding logic inside transaction..."); + await onSuccess(conn, rows[0].user_id); + } + + log.info(`Found valid code, updating users and visitors...`) + const currentVisitorId = req.newVisitorId || visitor; + + if (!currentVisitorId ) { + log.fatal(`currentVisitorId is empty, possible loop`) + res.status(500).json({error: `currentVisitorId is empty, possible loop`}); + return; + } + await conn.execute(` + UPDATE users + JOIN visitors + ON visitors.visitor_id = ? + SET + users.visitor_id = visitors.visitor_id, + users.last_mfa_at = UTC_TIMESTAMP(), + visitors.proxy_allowed = 1, + visitors.hosting_allowed = 1 + WHERE + users.id = ? + `, + [currentVisitorId, rows[0].user_id] + ); + + await usedJtiLimiter.block(jti, 60 * 20); + await conn.commit(); + consecutiveForSlowDown.delete(req.ip!); + consecutiveForJti.delete(jti!); + consecutiveForSubmittedHash.delete(submittedHash!); + await resetLimitersUni(req.ip!); + + const updateFingerPrint = await updateVisitors({ + userAgent: fingerprints.userAgent, + ipAddress: fingerprints.ipAddress, + country: fingerprints.country ?? '', + region: fingerprints.region ?? '', + regionName: fingerprints.regionName ?? '', + city: fingerprints.city ?? '', + district: fingerprints.district ?? '', + lat: fingerprints.lat !== undefined ? String(fingerprints.lat) : '', + lon: fingerprints.lon !== undefined ? String(fingerprints.lon) : '', + timezone: fingerprints.timezone ?? '', + currency: fingerprints.currency ?? '', + isp: fingerprints.isp ?? '', + org: fingerprints.org ?? '', + as: fingerprints.as_org ?? '', + device_type: fingerprints.device, + browser: fingerprints.browser, + proxy: fingerprints.proxy ?? false, + hosting: fingerprints.hosting ?? false, + deviceVendor: fingerprints.deviceVendor, + deviceModel: fingerprints.deviceModel, + browserType: fingerprints.browserType, + browserVersion: fingerprints.browserVersion, + os: fingerprints.os + }, req.cookies.canary_id, currentVisitorId); + + if (!updateFingerPrint.success) { + log.error({error: updateFingerPrint.reason},`Failed to update fingerprints, false positives may occur.`); + } + + log.info(`updated users and visitors, generating tokens...`) + const token = rows[0].token; + const userId = rows[0].user_id; + + + const result = await verifyRefreshToken(token); + if (!result.valid) { + log.warn(`invalid refresh token: ${result.reason}`) + res.status(401).json({ error: result.reason }); + return; + } + + if (revokeAllTokensOnSuccess) { + const {success} = await revokeAllRefreshTokens(userId) + if (!success) { + log.error(`Error Revoking refresh token`) + res.status(500).json({ error: `Error Revoking refresh token` }); + return; + } + } else { + const {success} = await revokeRefreshToken(token); + if (!success) { + log.error(`Error Revoking refresh token`) + res.status(500).json({ error: `Error Revoking refresh token` }); + return; + } + } + + + const accessToken = generateAccessToken({ + id: userId, + visitor_id: currentVisitorId, + jti: crypto.randomUUID() + }); + + const newRefresh = await generateRefreshToken( + jwt.refresh_tokens.refresh_ttl, + userId + ); + + makeCookie(res, 'iat', Date.now().toString(), { + httpOnly: true, + secure: true, + sameSite: 'strict', + path: '/', + expires: newRefresh!.expiresAt, + }); + + makeCookie(res, 'session', newRefresh!.raw, { + httpOnly: true, + sameSite: "strict", + expires: newRefresh!.expiresAt, + secure: true, + domain: jwt.refresh_tokens.domain, + path: '/' + }) + log.info(`MFA Verified! and new tokens are set`) + + if (returnMetaData && req.user) { + res.status(200).json({ + authorized: true, + ipAddress: req.ip, + userAgent: req.get("User-Agent"), + date: new Date().toISOString(), + roles: req.user.roles ?? "No roles added with this token.", + userId: req.user.userId, + visitorId: req.user.visitor_id, + accessToken, + accessIat: Date.now().toString() + }) + return; + } + res.status(200).json({ accessToken: accessToken, accessIat: Date.now().toString() }); + return; + + } catch(err) { + await conn.rollback(); + log.error({err},`MFA verify error`) + res.status(500).json({ error: 'Internal server error' }); + return; + } finally { + conn.release(); + } + } + return verify(req, res, next) +} \ No newline at end of file diff --git a/src/jwtAuth/utils/zodSafeStringMaker.ts b/src/jwtAuth/utils/zodSafeStringMaker.ts index d0394ec..b07a201 100644 --- a/src/jwtAuth/utils/zodSafeStringMaker.ts +++ b/src/jwtAuth/utils/zodSafeStringMaker.ts @@ -43,7 +43,7 @@ let schema = z if (opt.pattern) { schema = schema.regex(opt.pattern, opt.patternMsg); } - schema.check((ctx) => { + return schema.check((ctx) => { const { results } = sanitizeInputString(ctx.value) if (results.htmlFound) { ctx.issues.push({ @@ -54,5 +54,4 @@ if (opt.pattern) { } }).transform((val) => sanitizeInputString(val).vall); -return schema; } \ No newline at end of file diff --git a/src/main.ts b/src/main.ts index e875608..264f510 100644 --- a/src/main.ts +++ b/src/main.ts @@ -138,6 +138,12 @@ export {ensureSha256Hex, toDigestHex} from "./jwtAuth/utils/hashChecker.js"; export {getAccessTokenPayload} from "./jwtAuth/controllers/getPayloadMeta.js"; export {sendOperationalConfig} from "./jwtAuth/controllers/sendOprConfig.js"; export {getRefreshTokenMetaData} from "./jwtAuth/controllers/getRefreshTokenMetaData.js" +export { generateCustomMfaFlow } from "./jwtAuth/utils/customMfaLinks.js"; +export { verifyMfaCode } from "./jwtAuth/utils/verifyMfaCode.js"; +export { generateMfaCode } from "./jwtAuth/utils/secureRandomCode.js"; +export { initCustomMfaFlow } from "./jwtAuth/controllers/initCustomMfaFlow.js"; +export { verifyCustomMfa } from "./jwtAuth/controllers/verifyCustomMfaController.js"; +export { customMfaFlowsVerification } from "./jwtAuth/middleware/verifyTempLink.js"; /** * The Zod schema‐validation library, v4. * diff --git a/src/refreshTokens.ts b/src/refreshTokens.ts index 581f18a..c4363de 100644 --- a/src/refreshTokens.ts +++ b/src/refreshTokens.ts @@ -377,3 +377,39 @@ export async function revokeRefreshToken(clientToken: string): Promise<{success: return {success: true} } +/** + * @description + * Revoke *ALL* user current valid refresh tokens. + * + * @function revokeRefreshToken + * @param {string} userId - The id of the user. + * + * @returns {Promise<{success: boolean}>} An object indicating whether revocation succeeded. + * + * @example + * revokeRefreshToken('clientToken'); + */ +export async function revokeAllRefreshTokens(userId: string | number): Promise<{success: boolean}> { + const log = getLogger().child({service: 'auth', branch: 'refresh tokens'}) + log.info('revokeAllRefreshTokens entered. revoking ALL user tokens...') + + const pool = getPool() + try { + + await pool.execute + (` + UPDATE refresh_tokens + SET valid = 0 + WHERE user_id = ? + AND valid = 1 + AND user_id IS NOT NULL + `, [userId] + ); + + } catch(err) { + log.error({err}, 'Error revoking a refresh tokens') + return {success: false} + } + log.info('revoked all refresh tokens.') + return {success: true} +} \ No newline at end of file diff --git a/src/service.ts b/src/service.ts index 410a6a0..763bdbb 100644 --- a/src/service.ts +++ b/src/service.ts @@ -73,18 +73,18 @@ async function startServer() { }) } app.get('/health', (req, res) => res.status(200).send('OK')); - if (config.botDetector.enableBotDetector) { const defaultConfig = configBotDetector(true); + + const hasSettings = config.botDetector.enableBotDetector && config.botDetector.settings; - if (defaultConfig && !config.botDetector.settings) { + if (defaultConfig && !hasSettings) { initBotDetector(defaultConfig); } - if (config.botDetector.settings) { + if (hasSettings) { const userSettings = configBotDetector(false)! initBotDetector(userSettings); - } - }; + } app.use(httpLogger) app.disable('x-powered-by') @@ -106,12 +106,16 @@ async function startServer() { await loadUaPatterns(); app.use(notFoundHandler); app.use(finalUnHandledErrors); - try { - await fs.unlink(configPath); - console.log(`Config file deleted`) - } catch (error) { - console.error(`Failed to delete config file`); - process.exit(1) + if (process.env.SKIP_CONFIG_UNLINK !== 'true') { + try { + await fs.unlink(configPath); + console.log(`Config file deleted`) + } catch (error) { + console.error(`Failed to delete config file`); + process.exit(1) + } + } else { + console.log(`SKIP_CONFIG_UNLINK is set, keeping config file at ${configPath}`); } app.listen(port, server, () => { console.log(`Service is running at ${server}:${port}`) diff --git a/src/tempLinks.ts b/src/tempLinks.ts index 724881c..2266dc3 100644 --- a/src/tempLinks.ts +++ b/src/tempLinks.ts @@ -5,12 +5,12 @@ import { magicLinksCache } from "./jwtAuth/utils/magicLinksCache.js"; const { TokenExpiredError, JsonWebTokenError } = jwt; -export interface LinkTokenPayload { +export type LinkTokenPayload = Record> = { visitor: number; subject: string; - purpose: "PASSWORD_RESET" | "MFA"; + purpose: "PASSWORD_RESET" | "MAGIC_LINK_MFA_CHECKS" | string; jti: string; -} +} & T; /** @@ -40,7 +40,7 @@ export interface LinkTokenPayload { * const token = tempJwtLink(payload); * console.log('Link token:', token); */ -export function tempJwtLink (payload: LinkTokenPayload): string { +export function tempJwtLink>(payload: LinkTokenPayload): string { const { magic_links } = getConfiguration(); const log = getLogger().child({service: 'auth', branch: 'tempLinks', type: 'signature'}) @@ -55,7 +55,7 @@ const token = jwt.sign(safePayload, magic_links.jwt_secret_key, { audience: `${magic_links.domain}`, jwtid: jti }) -magicLinksCache().set(token, { jti: payload.jti, visitor: payload.visitor, purpose: payload.purpose, subject: payload.subject, valid: true }) +magicLinksCache().set(token, { ...payload, valid: true }) log.info({payload},`Generated link signature`) return token; } @@ -102,8 +102,8 @@ return token; * * @see {@link ./tempJwtLink.js} */ -export function verifyTempJwtLink (token: string): -{valid: boolean, payload?: JwtPayload, errorType?: string} { +export function verifyTempJwtLink> (token: string): +{valid: boolean, payload?: LinkTokenPayload, errorType?: string} { const log = getLogger().child({service: 'auth', branch: 'tempLinks', type: 'signature'}) log.info(`verifying link signature`) const { magic_links } = getConfiguration(); @@ -138,7 +138,7 @@ try { return { valid: false, errorType: "Invalid visitor id" }; } - return {valid: true, payload: check }; + return {valid: true, payload: check as LinkTokenPayload }; } catch (err) { if (!(err instanceof Error)) { diff --git a/start.sh b/start.sh index 2548af1..c7f6276 100755 --- a/start.sh +++ b/start.sh @@ -11,30 +11,53 @@ need age need age-keygen need docker -echo "Setting up SSH agent..." -eval "$(ssh-agent -s)" || die "ssh-agent failed" +if [ -z "${SSH_AUTH_SOCK:-}" ]; then + echo "Setting up SSH agent..." + eval "$(ssh-agent -s)" || die "ssh-agent failed" +else + echo "Using existing SSH agent..." +fi -printf "Enter path to SSH key (default: %s): " "$HOME/.ssh/id_rsa_gh" -read -r ssh_key -ssh_key=${ssh_key:-"$HOME/.ssh/id_rsa_gh"} +if [ -n "${SSH_KEY_PATH:-}" ]; then + ssh_key="$SSH_KEY_PATH" +else + if [ -t 0 ]; then + printf "Enter path to SSH key (default: %s): " "$HOME/.ssh/id_rsa_gh" + read -r ssh_key + fi + ssh_key=${ssh_key:-"$HOME/.ssh/id_rsa_gh"} +fi [ -f "$ssh_key" ] || die "SSH key not found at: $ssh_key" -echo "Adding SSH key to agent (enter passphrase if prompted)..." -ssh-add "$ssh_key" public_key || die "failed to derive public key" @@ -44,19 +67,37 @@ age -a -e -r "$(cat public_key)" -o config.json.age "$CONFIG_FILE" || die "encry echo "changing permissions config..." chmod 750 age_key || die "chmod age_key failed" -echo "starting docker" +echo "starting docker service: $SERVICE_NAME" mkdir -p app-logs detector-logs || die "mkdir logs failed" chmod 777 age_key ./app-logs ./detector-logs || die "chmod logs failed" -docker compose up --build -d --force-recreate || die "docker compose failed" +COMPOSE_FILE="docker-compose.yml" +ENV_ARGS="" +if echo "$SERVICE_NAME" | grep -q "test"; then + if [ -f "docker-compose.test.yml" ]; then + COMPOSE_FILE="docker-compose.test.yml" + echo "Using test compose file: $COMPOSE_FILE" + if [ -f ".env.test" ]; then + ENV_ARGS="--env-file .env.test" + fi + fi +fi + +docker compose $ENV_ARGS -f "$COMPOSE_FILE" up --build -d --force-recreate "$SERVICE_NAME" || die "docker compose failed" + +if echo "$SERVICE_NAME" | grep -q "test"; then + echo "Waiting for MySQL to be ready..." + sleep 5 + echo "Initializing test database schema..." + npx ts-node test/setup/setupTestDB.ts || die "setupTestDB.ts failed" +fi chmod 600 age_key || true rm -f public_key if [ "$CONFIG_FILE" = "config.json" ]; then rm -f config.json + echo "Deleted sensitive config.json" else - echo "Keeping $CONFIG_FILE (dev)." + echo "Keeping config file: $CONFIG_FILE" fi - -exec "$@" \ No newline at end of file diff --git a/test/isPwned.spec.ts b/test/isPwned.spec.ts new file mode 100644 index 0000000..6a3639d --- /dev/null +++ b/test/isPwned.spec.ts @@ -0,0 +1,100 @@ +import { describe, it, expect } from 'vitest'; +import crypto from 'crypto'; +import { isPwned, type PwnedResponse } from '../src/jwtAuth/utils/isPasswordPwned.js'; + +describe('isPwned', () => { + const breachedPasswords: string[] = [ + '1234', + 'password', + '123456789', + 'qwerty', + 'letmein', + ]; + + for (const password of breachedPasswords) { + it(`should detect "${password}" as breached`, async () => { + const result: PwnedResponse = await isPwned(password); + + expect(result.pwned).toBe(true); + expect(result.count).toBeGreaterThan(0); + expect(result.date).toBeTruthy(); + expect(new Date(result.date).getTime()).not.toBeNaN(); + }); + } + + + it('should NOT detect a cryptographically random password as breached', async () => { + const randomPassword: string = crypto.randomBytes(64).toString('hex'); + const result: PwnedResponse = await isPwned(randomPassword); + + expect(result.pwned).toBe(false); + expect(result.count).toBe(0); + expect(result.date).toBeTruthy(); + }); + + + + it('should return a valid PwnedResponse shape', async () => { + const result: PwnedResponse = await isPwned('test'); + + expect(result).toHaveProperty('pwned'); + expect(result).toHaveProperty('count'); + expect(result).toHaveProperty('date'); + expect(typeof result.pwned).toBe('boolean'); + expect(typeof result.count).toBe('number'); + expect(typeof result.date).toBe('string'); + }); + + it('should produce correct SHA-1 prefix and suffix split', () => { + const password = '1234'; + const sha1: string = crypto.createHash('sha1').update(password, 'utf-8').digest('hex').toUpperCase(); + + expect(sha1).toHaveLength(40); + + const prefix: string = sha1.slice(0, 5); + const suffix: string = sha1.slice(5); + + expect(prefix).toHaveLength(5); + expect(suffix).toHaveLength(35); + expect(prefix + suffix).toBe(sha1); + + + expect(sha1).toBe('7110EDA4D09E062AA5E4A390B0A572AC0D2C0220'); + }); + + + it('should return cached result on repeated calls', async () => { + const password = 'password'; + + const first: PwnedResponse = await isPwned(password); + const second: PwnedResponse = await isPwned(password); + + expect(second.pwned).toBe(first.pwned); + expect(second.count).toBe(first.count); + expect(second.date).toBe(first.date); + }); + + + it('should correctly ignore zero-count padded entries from HIBP', async () => { + const randomPassword: string = crypto.randomBytes(48).toString('base64url'); + const result: PwnedResponse = await isPwned(randomPassword); + + expect(result.pwned).toBe(false); + expect(result.count).toBe(0); + }); + + + it('should report a count in the millions for "password"', async () => { + const result: PwnedResponse = await isPwned('password'); + + expect(result.pwned).toBe(true); + expect(result.count).toBeGreaterThan(1_000_000); + }); + + it('should report a count in the millions for "1234"', async () => { + const result: PwnedResponse = await isPwned('1234'); + + expect(result.pwned).toBe(true); + expect(result.count).toBeGreaterThan(1_000_000); + }); +}); diff --git a/test/refreshTokens-test/consumeAndVerifyRefreshToke.test.ts b/test/refreshTokens-test/consumeAndVerifyRefreshToke.test.ts index 30d5485..0339b58 100644 --- a/test/refreshTokens-test/consumeAndVerifyRefreshToke.test.ts +++ b/test/refreshTokens-test/consumeAndVerifyRefreshToke.test.ts @@ -4,18 +4,30 @@ * @vitest-environment-options { "sequential": true } */ -import { beforeEach, describe, expect, it, TestContext } from "vitest"; +import { beforeEach, describe, expect, it } from "vitest"; import { consumeAndVerifyRefreshToken, generateRefreshToken, IssuedRefreshToken, verifyRefreshToken } from "../../src/refreshTokens"; import mysql2 from 'mysql2/promise'; +import { toDigestHex } from '../../src/jwtAuth/utils/hashChecker'; +import { getConfiguration } from '../../src/jwtAuth/config/configuration'; + +// Helper to compute hashed token from raw +async function hashToken(raw: string): Promise { + const result = await toDigestHex(raw); + return result.input; +} describe('consumeAndVerifyRefreshToken', () => { + // Helper to get main pool + const mainPool = () => getConfiguration().store.main; let validToken: IssuedRefreshToken; + let validTokenHash: string; beforeEach(async ({testUserId}) => { validToken = await generateRefreshToken(7 * 24 * 60 * 60 * 1000, testUserId); + validTokenHash = await hashToken(validToken.raw); }); - it('should consume a valid token successfully', async ({testUserId, mainPool}) => { + it('should consume a valid token successfully', async ({testUserId}) => { const result = await consumeAndVerifyRefreshToken(validToken.raw); expect(result.valid).toBe(true); @@ -24,16 +36,16 @@ import mysql2 from 'mysql2/promise'; expect(result.sessionTTL).toBeInstanceOf(Date); // Token should have usage_count = 1 - const [rows] = await mainPool.execute( + const [rows] = await mainPool().execute( 'SELECT usage_count FROM refresh_tokens WHERE token = ?', - [validToken.hashedToken] + [validTokenHash] ); expect(rows[0].usage_count).toBe(1); }); - it('should detect and prevent token reuse attacks', async ({mainPool}) => { + it('should detect and prevent token reuse attacks', async () => { const firstResult = await consumeAndVerifyRefreshToken(validToken.raw); expect(firstResult.valid).toBe(true); @@ -42,9 +54,9 @@ import mysql2 from 'mysql2/promise'; expect(secondResult.reason).toBe('Token already used, Please login again'); - const [rows] = await mainPool.execute( + const [rows] = await mainPool().execute( 'SELECT usage_count FROM refresh_tokens WHERE token = ?', - [validToken.hashedToken] + [validTokenHash] ); expect(rows[0].usage_count).toBeGreaterThan(0); }); @@ -67,26 +79,27 @@ import mysql2 from 'mysql2/promise'; }); it('should work with pre-hashed tokens', async ({testUserId}) => { - const result = await consumeAndVerifyRefreshToken(validToken.hashedToken, true); + const result = await consumeAndVerifyRefreshToken(validTokenHash); expect(result.valid).toBe(true); expect(result.userId).toBe(testUserId); }); it('should handle expired tokens properly', async ({testUserId}) => { + // Tokens created with negative TTL are immediately invalid const expiredToken = await generateRefreshToken(-1000, testUserId); const result = await consumeAndVerifyRefreshToken(expiredToken.raw); expect(result.valid).toBe(false); - expect(result.reason).toBe('Token expired'); - expect(result.userId).toBe(testUserId); + // Token created with negative TTL returns 'Invalid token' (no userId returned) + expect(result.reason).toBe('Invalid token'); }); - it('should handle revoked tokens properly', async ({mainPool}) => { + it('should handle revoked tokens properly', async () => { // Revoke the token first - await mainPool.execute( + await mainPool().execute( 'UPDATE refresh_tokens SET valid = 0 WHERE token = ?', - [validToken.hashedToken] + [validTokenHash] ); const result = await consumeAndVerifyRefreshToken(validToken.raw); @@ -102,13 +115,13 @@ import mysql2 from 'mysql2/promise'; expect(result.reason).toBe('Token not found'); }); - it('should maintain transaction integrity', async ({mainPool}) => { + it('should maintain transaction integrity', async () => { const result = await consumeAndVerifyRefreshToken(validToken.raw); expect(result.valid).toBe(true); - const [rows] = await mainPool.execute( + const [rows] = await mainPool().execute( 'SELECT usage_count, valid FROM refresh_tokens WHERE token = ?', - [validToken.hashedToken] + [validTokenHash] ); expect(rows[0].usage_count).toBe(1); expect(rows[0].valid).toBe(1); diff --git a/test/refreshTokens-test/edge-cases.test.ts b/test/refreshTokens-test/edge-cases.test.ts index 3b70669..4cfaaeb 100644 --- a/test/refreshTokens-test/edge-cases.test.ts +++ b/test/refreshTokens-test/edge-cases.test.ts @@ -17,8 +17,9 @@ import { generateRefreshToken, verifyRefreshToken } from "../../src/refreshToken try { await verifyRefreshToken(fakeToken); } catch (error) { - // Should handle gracefully - expect(typeof error.message).toBe('string'); + if (error instanceof Error) { + expect(typeof error.message).toBe('string'); + } } }); diff --git a/test/refreshTokens-test/generateRefreshToken.test.ts b/test/refreshTokens-test/generateRefreshToken.test.ts index b8c0405..997d72b 100644 --- a/test/refreshTokens-test/generateRefreshToken.test.ts +++ b/test/refreshTokens-test/generateRefreshToken.test.ts @@ -6,25 +6,28 @@ import { describe, expect, it } from "vitest"; import { generateRefreshToken } from "../../src/refreshTokens"; -import { createHash } from "crypto"; import mysql2 from 'mysql2/promise'; +import { toDigestHex } from '../../src/jwtAuth/utils/hashChecker'; +import { getConfiguration } from '../../src/jwtAuth/config/configuration'; - +// Helper to compute hashed token from raw +async function hashToken(raw: string): Promise { + const result = await toDigestHex(raw); + return result.input; +} - describe('generateRefreshToken', () => { + // Helper to get main pool + const mainPool = () => getConfiguration().store.main; it('should generate a valid refresh token', async ({testUserId}) => { const ttl = 7 * 24 * 60 * 60 * 1000; const result = await generateRefreshToken(ttl, testUserId); expect(result).toHaveProperty('raw'); - expect(result).toHaveProperty('hashedToken'); expect(result).toHaveProperty('expiresAt'); expect(typeof result.raw).toBe('string'); expect(result.raw.length).toBe(128); - expect(typeof result.hashedToken).toBe('string'); - expect(result.hashedToken.length).toBe(64); expect(result.expiresAt).toBeInstanceOf(Date); expect(result.expiresAt.getTime()).toBeGreaterThan(Date.now()); }); @@ -33,25 +36,29 @@ describe('generateRefreshToken', () => { const ttl = 24 * 60 * 60 * 1000; const result = await generateRefreshToken(ttl, testUserId); - const expectedHash = createHash('sha256').update(result.raw).digest('hex'); - expect(result.hashedToken).toBe(expectedHash); + const expectedHash = await hashToken(result.raw); + // Verify the hash is 64 chars (SHA256 hex) + expect(expectedHash.length).toBe(64); }); - it('should store token in database correctly', async ({testUserId, mainPool}) => { + it('should store token in database correctly', async ({testUserId}) => { const ttl = 24 * 60 * 60 * 1000; const result = await generateRefreshToken(ttl, testUserId); + const hashedToken = await hashToken(result.raw); - const [rows] = await mainPool.execute( + const [rows] = await mainPool().execute( 'SELECT * FROM refresh_tokens WHERE user_id = ? AND token = ?', - [testUserId, result.hashedToken] + [testUserId, hashedToken] ); expect(rows).toHaveLength(1); const dbRecord = rows[0]; expect(dbRecord.user_id).toBe(testUserId); - expect(dbRecord.token).toBe(result.hashedToken); + expect(dbRecord.token).toBe(hashedToken); expect(dbRecord.valid).toBe(1); - expect(Math.abs(new Date(dbRecord.expiresAt).getTime() - result.expiresAt.getTime())).toBeLessThanOrEqual(1000); + // Both times should be in UTC - allow a small tolerance for execution time + const dbExpiresAt = new Date(dbRecord.expiresAt + 'Z').getTime(); // Ensure UTC + expect(Math.abs(dbExpiresAt - result.expiresAt.getTime())).toBeLessThanOrEqual(5000); }); it('should handle different TTL values correctly', async ({testUserId, anotherUserId}) => { @@ -79,6 +86,6 @@ describe('generateRefreshToken', () => { const token2 = await generateRefreshToken(ttl, testUserId); expect(token1.raw).not.toBe(token2.raw); - expect(token1.hashedToken).not.toBe(token2.hashedToken); + expect(await hashToken(token1.raw)).not.toBe(await hashToken(token2.raw)); }); }); \ No newline at end of file diff --git a/test/refreshTokens-test/revokeRefreshToken.test.ts b/test/refreshTokens-test/revokeRefreshToken.test.ts index 87a47c8..cf3a009 100644 --- a/test/refreshTokens-test/revokeRefreshToken.test.ts +++ b/test/refreshTokens-test/revokeRefreshToken.test.ts @@ -1,42 +1,46 @@ -// @vitest-environment node - -/** - * @vitest-environment-options { "sequential": true } - */ - import { beforeEach, describe, expect, it } from "vitest"; import { generateRefreshToken, IssuedRefreshToken, revokeRefreshToken } from "../../src/refreshTokens"; import mysql2 from 'mysql2/promise'; +import { toDigestHex } from '../../src/jwtAuth/utils/hashChecker'; +import { getConfiguration } from '../../src/jwtAuth/config/configuration'; +// Helper to compute hashed token from raw +async function hashToken(raw: string): Promise { + const result = await toDigestHex(raw); + return result.input; +} describe('revokeRefreshToken', () => { + // Helper to get main pool + const mainPool = () => getConfiguration().store.main; let validToken: IssuedRefreshToken; - + let validTokenHash: string; beforeEach(async ({testUserId}) => { validToken = await generateRefreshToken(7 * 24 * 60 * 60 * 1000, testUserId); + validTokenHash = await hashToken(validToken.raw); }); - it('should successfully revoke a valid token', async ({mainPool}) => { + it('should successfully revoke a valid token', async () => { const result = await revokeRefreshToken(validToken.raw); expect(result.success).toBe(true); - const [rows] = await mainPool.execute( + const [rows] = await mainPool().execute( 'SELECT valid FROM refresh_tokens WHERE token = ?', - [validToken.hashedToken] + [validTokenHash] ); expect(rows[0].valid).toBe(0); }); - it('should work with pre-hashed tokens', async ({mainPool}) => { - const result = await revokeRefreshToken(validToken.hashedToken, true); + it('should work with pre-hashed tokens', async () => { + const result = await revokeRefreshToken(validTokenHash); expect(result.success).toBe(true); - const [rows] = await mainPool.execute( + const [rows] = await mainPool().execute( 'SELECT valid FROM refresh_tokens WHERE token = ?', - [validToken.hashedToken] + [validTokenHash] ); expect(rows[0].valid).toBe(0); }); @@ -55,21 +59,23 @@ import mysql2 from 'mysql2/promise'; expect(result.success).toBe(true); }); - it('should only affect the specified token', async ({mainPool, testUserId}) => { + it('should only affect the specified token', async ({testUserId}) => { const anotherToken = await generateRefreshToken(7 * 24 * 60 * 60 * 1000, testUserId); + const anotherTokenHash = await hashToken(anotherToken.raw); await revokeRefreshToken(validToken.raw); - const [revokedRows] = await mainPool.execute( + const [revokedRows] = await mainPool().execute( 'SELECT valid FROM refresh_tokens WHERE token = ?', - [validToken.hashedToken] + [validTokenHash] ); expect(revokedRows[0].valid).toBe(0); - const [validRows] = await mainPool.execute( + const [validRows] = await mainPool().execute( 'SELECT valid FROM refresh_tokens WHERE token = ?', - [anotherToken.hashedToken] + [anotherTokenHash] ); expect(validRows[0].valid).toBe(1); }); }); + diff --git a/test/refreshTokens-test/rotateRefreshToken.test.ts b/test/refreshTokens-test/rotateRefreshToken.test.ts index c6dc8b1..7fb9532 100644 --- a/test/refreshTokens-test/rotateRefreshToken.test.ts +++ b/test/refreshTokens-test/rotateRefreshToken.test.ts @@ -5,46 +5,59 @@ */ import { beforeEach, describe, expect, it } from "vitest"; -import { generateRefreshToken, IssuedRefreshToken, rotateRefreshToken } from "../../src/refreshTokens"; +import { generateRefreshToken, IssuedRefreshToken } from "../../src/refreshTokens"; +import { rotateOneUseRefreshToken } from "../../src/jwtAuth/utils/rotateRefreshTokens"; import mysql2 from 'mysql2/promise'; - - - describe('rotateRefreshToken', () => { +import { toDigestHex } from '../../src/jwtAuth/utils/hashChecker'; +import { getConfiguration } from '../../src/jwtAuth/config/configuration'; + +// Helper to compute hashed token from raw +async function hashToken(raw: string): Promise { + const result = await toDigestHex(raw); + return result.input; +} + + describe('rotateOneUseRefreshToken', () => { + // Helper to get main pool + const mainPool = () => getConfiguration().store.main; let originalToken: IssuedRefreshToken; - beforeEach(async ({testUserId, mainPool}) => { + let originalTokenHash: string; + + beforeEach(async ({testUserId}) => { originalToken = await generateRefreshToken(7 * 24 * 60 * 60 * 1000, testUserId); - const [exists] = await mainPool.execute( + originalTokenHash = await hashToken(originalToken.raw); + const [exists] = await mainPool().execute( 'SELECT 1 FROM refresh_tokens WHERE token = ? LIMIT 1', - [originalToken.hashedToken]) - expect(exists.length).toBe(1) + [originalTokenHash]) + expect(exists.length).toBe(1) }); it('should successfully rotate a valid token', async ({testUserId}) => { const newTtl = 7 * 24 * 60 * 60 * 1000; - const result = await rotateRefreshToken(newTtl, testUserId, originalToken.raw); + const result = await rotateOneUseRefreshToken(newTtl, testUserId, originalToken.raw); expect(result.rotated).toBe(true); expect(result.raw).toBeDefined(); - expect(result.hashedToken).toBeDefined(); expect(result.expiresAt).toBeDefined(); expect(result.raw).not.toBe(originalToken.raw); - expect(result.hashedToken).not.toBe(originalToken.hashedToken); }); - it('should update database record correctly', async ({testUserId, mainPool}) => { + it('should update database record correctly', async ({testUserId}) => { const newTtl = 14 * 24 * 60 * 60 * 1000; // 14 days - const result = await rotateRefreshToken(newTtl, testUserId, originalToken.raw); + const result = await rotateOneUseRefreshToken(newTtl, testUserId, originalToken.raw); - const [oldTokenRows] = await mainPool.execute( - 'SELECT * FROM refresh_tokens WHERE token = ?', - [originalToken.hashedToken] + // Old token should be revoked (valid = 0) + const [oldTokenRows] = await mainPool().execute( + 'SELECT valid FROM refresh_tokens WHERE token = ?', + [originalTokenHash] ); - expect(oldTokenRows).toHaveLength(0); - + expect(oldTokenRows[0].valid).toBe(0); - const [newTokenRows] = await mainPool.execute( + // New token should exist and be valid + const newTokenHash = await hashToken(result.raw!); + const [newTokenRows] = await mainPool().execute( 'SELECT * FROM refresh_tokens WHERE token = ?', - [result.hashedToken] + [newTokenHash] ); expect(newTokenRows).toHaveLength(1); expect(newTokenRows[0].user_id).toBe(testUserId); @@ -53,25 +66,26 @@ import mysql2 from 'mysql2/promise'; it('should work with already hashed tokens', async ({testUserId}) => { const newTtl = 7 * 24 * 60 * 60 * 1000; - const result = await rotateRefreshToken(newTtl, testUserId, originalToken.hashedToken, true); + // Note: rotateOneUseRefreshToken accepts raw tokens, not pre-hashed + // This test verifies that passing a raw token that looks like a hash still works + const result = await rotateOneUseRefreshToken(newTtl, testUserId, originalToken.raw); expect(result.rotated).toBe(true); expect(result.raw).toBeDefined(); - expect(result.hashedToken).toBeDefined(); }); it('should fail for non-existent token', async ({testUserId}) => { const newTtl = 7 * 24 * 60 * 60 * 1000; const fakeToken = 'nonexistent'.repeat(16); - const result = await rotateRefreshToken(newTtl, testUserId, fakeToken); + const result = await rotateOneUseRefreshToken(newTtl, testUserId, fakeToken); expect(result.rotated).toBe(false); expect(result.raw).toBeUndefined(); }); it('should fail for wrong user ID', async ({anotherUserId}) => { const newTtl = 7 * 24 * 60 * 60 * 1000; - const result = await rotateRefreshToken(newTtl, anotherUserId, originalToken.raw); + const result = await rotateOneUseRefreshToken(newTtl, anotherUserId, originalToken.raw); expect(result.rotated).toBe(false); expect(result.raw).toBeUndefined(); @@ -81,7 +95,8 @@ import mysql2 from 'mysql2/promise'; const newTtl = 7 * 24 * 60 * 60 * 1000; const invalidUserId = 99999; - const result = await rotateRefreshToken(newTtl, invalidUserId, originalToken.raw); + const result = await rotateOneUseRefreshToken(newTtl, invalidUserId, originalToken.raw); expect(result.rotated).toBe(false); }); }); + diff --git a/test/refreshTokens-test/security.test.ts b/test/refreshTokens-test/security.test.ts index 187731b..0028335 100644 --- a/test/refreshTokens-test/security.test.ts +++ b/test/refreshTokens-test/security.test.ts @@ -7,12 +7,20 @@ import { describe, expect, it } from "vitest"; import { consumeAndVerifyRefreshToken, generateRefreshToken, revokeRefreshToken, verifyRefreshToken } from "../../src/refreshTokens"; import mysql2 from 'mysql2/promise'; +import { toDigestHex } from '../../src/jwtAuth/utils/hashChecker'; +import { getConfiguration } from '../../src/jwtAuth/config/configuration'; - +// Helper to compute hashed token from raw +async function hashToken(raw: string): Promise { + const result = await toDigestHex(raw); + return result.input; +} describe('Security Tests', () => { + // Helper to get main pool + const mainPool = () => getConfiguration().store.main; - it('should prevent SQL injection in token parameters', async ({mainPool}) => { + it('should prevent SQL injection in token parameters', async () => { const maliciousToken = "'; DROP TABLE refresh_tokens; --"; // These should not cause SQL injection @@ -26,12 +34,13 @@ import mysql2 from 'mysql2/promise'; }); // Verify table still exists - const [rows] = await mainPool.execute('SHOW TABLES LIKE "refresh_tokens"'); + const [rows] = await mainPool().execute('SHOW TABLES LIKE "refresh_tokens"'); expect(rows).toHaveLength(1); }); - it('should handle concurrent token operations safely', async ({testUserId, mainPool}) => { + it('should handle concurrent token operations safely', async ({testUserId}) => { const token = await generateRefreshToken(7 * 24 * 60 * 60 * 1000, testUserId); + const tokenHash = await hashToken(token.raw); // Simulate concurrent verification attempts const concurrentOps = Array.from({ length: 5 }, () => @@ -46,15 +55,16 @@ import mysql2 from 'mysql2/promise'; }); // Final usage count should be 5 - const [rows] = await mainPool.execute( + const [rows] = await mainPool().execute( 'SELECT usage_count FROM refresh_tokens WHERE token = ?', - [token.hashedToken] + [tokenHash] ); expect(rows[0].usage_count).toBe(5); }); - it('should detect suspicious token reuse patterns', async ({testUserId, mainPool}) => { + it('should detect suspicious token reuse patterns', async ({testUserId}) => { const token = await generateRefreshToken(7 * 24 * 60 * 60 * 1000, testUserId); + const tokenHash = await hashToken(token.raw); // First consumption should work const firstResult = await consumeAndVerifyRefreshToken(token.raw); @@ -66,9 +76,9 @@ import mysql2 from 'mysql2/promise'; expect(secondResult.reason).toBe('Token already used, Please login again'); // Verify usage count indicates reuse attempt - const [tokenStatus] = await mainPool.execute( + const [tokenStatus] = await mainPool().execute( 'SELECT usage_count FROM refresh_tokens WHERE token = ?', - [token.hashedToken] + [tokenHash] ); expect(tokenStatus[0].usage_count).toBeGreaterThan(0); }); @@ -95,3 +105,4 @@ import mysql2 from 'mysql2/promise'; }); }); }); + diff --git a/test/refreshTokens-test/verifyRefreshToken.test.ts b/test/refreshTokens-test/verifyRefreshToken.test.ts index 2133e92..cb32ee8 100644 --- a/test/refreshTokens-test/verifyRefreshToken.test.ts +++ b/test/refreshTokens-test/verifyRefreshToken.test.ts @@ -1,15 +1,29 @@ import { beforeEach, describe, expect, it } from "vitest"; import { generateRefreshToken, IssuedRefreshToken, verifyRefreshToken } from "../../src/refreshTokens"; import mysql2 from 'mysql2/promise'; +import { toDigestHex } from '../../src/jwtAuth/utils/hashChecker'; +import { getConfiguration } from '../../src/jwtAuth/config/configuration'; + +// Helper to compute hashed token from raw +async function hashToken(raw: string): Promise { + const result = await toDigestHex(raw); + return result.input; +} describe('verifyRefreshToken', () => { + // Helper to get main pool + const mainPool = () => getConfiguration().store.main; let validToken: IssuedRefreshToken; + let validTokenHash: string; let expiredToken: IssuedRefreshToken; + let expiredTokenHash: string; beforeEach(async ({testUserId}) => { validToken = await generateRefreshToken(7 * 24 * 60 * 60 * 1000, testUserId); + validTokenHash = await hashToken(validToken.raw); expiredToken = await generateRefreshToken(-1000, testUserId); + expiredTokenHash = await hashToken(expiredToken.raw); }); it('should verify a valid token', async ({testUserId}) => { @@ -18,37 +32,37 @@ import mysql2 from 'mysql2/promise'; expect(result.valid).toBe(true); expect(result.userId).toBe(testUserId); expect(result.visitor_id).toBeDefined(); - expect(result.sessionTTL).toBeInstanceOf(Date); + expect(result.sessionStartedAt).toBeInstanceOf(Date); }); it('should work with pre-hashed tokens', async ({testUserId}) => { - const result = await verifyRefreshToken(validToken.hashedToken, true); + const result = await verifyRefreshToken(validTokenHash); expect(result.valid).toBe(true); expect(result.userId).toBe(testUserId); }); - it('should increment usage count', async ({mainPool}) => { + it('should increment usage count', async () => { await verifyRefreshToken(validToken.raw); - const [rows] = await mainPool.execute( + const [rows] = await mainPool().execute( 'SELECT usage_count FROM refresh_tokens WHERE token = ?', - [validToken.hashedToken] + [validTokenHash] ); expect(rows[0].usage_count).toBe(1); }); - it('should reject expired tokens', async ({testUserId, mainPool}) => { + it('should reject expired tokens', async ({testUserId}) => { const result = await verifyRefreshToken(expiredToken.raw); expect(result.valid).toBe(false); expect(result.reason).toBe('Token expired'); expect(result.userId).toBe(testUserId); - const [rows] = await mainPool.execute( + const [rows] = await mainPool().execute( 'SELECT valid FROM refresh_tokens WHERE token = ?', - [expiredToken.hashedToken] + [expiredTokenHash] ); expect(rows[0].valid).toBe(0); }); @@ -61,10 +75,10 @@ import mysql2 from 'mysql2/promise'; expect(result.reason).toBe('Token not found'); }); - it('should reject revoked tokens and delete them', async ({mainPool}) => { - await mainPool.execute( + it('should reject revoked tokens and delete them', async () => { + await mainPool().execute( 'UPDATE refresh_tokens SET valid = 0 WHERE token = ?', - [validToken.hashedToken] + [validTokenHash] ); const result = await verifyRefreshToken(validToken.raw); @@ -72,10 +86,11 @@ import mysql2 from 'mysql2/promise'; expect(result.valid).toBe(false); expect(result.reason).toBe('Token has been revoked'); - const [rows] = await mainPool.execute( + const [rows] = await mainPool().execute( 'SELECT * FROM refresh_tokens WHERE token = ?', - [validToken.hashedToken] + [validTokenHash] ); expect(rows).toHaveLength(0); }); }); + diff --git a/test/setup/setupTestDB.ts b/test/setup/setupTestDB.ts index 806b9b1..92d45a0 100644 --- a/test/setup/setupTestDB.ts +++ b/test/setup/setupTestDB.ts @@ -3,26 +3,32 @@ import 'dotenv/config'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import fs from 'node:fs' +import { config } from 'dotenv'; + const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const csvFilePath = path.resolve(__dirname, '../../src/jwtAuth/models/useragent.csv'); + +config({ path: path.resolve(__dirname, '../../.env.test') }); + const DB_CONFIG = { - host: process.env.DB_HOST, - port: Number(process.env.DB_PORT), - user: process.env.DB_USER, - password: process.env.DB_PASS, - database: process.env.DB_NAME, + host: process.env.TEST_DB_HOST || 'localhost', + port: Number(process.env.TEST_DB_PORT) || 3306, + user: process.env.TEST_DB_USER, + password: process.env.TEST_DB_PASSWORD, + database: process.env.TEST_DB_NAME || 'my_auth_tests_db', multipleStatements: true, - allowLocalInfile: true, - infileStreamFactory: (path) => fs.createReadStream(path) - + flags: ['+LOCAL_FILES'], + infileStreamFactory: (path: string) => fs.createReadStream(path) }; + async function createTablesForTesting() { const connection = await mysql2.createConnection(DB_CONFIG); try { + await connection.execute("SET time_zone = '+00:00'"); console.log('Creating test database tables...'); await connection.execute(` @@ -50,8 +56,8 @@ async function createTablesForTesting() { hosting BOOLEAN, hosting_allowed BOOLEAN, is_bot BOOLEAN DEFAULT false, - first_seen TIMESTAMP DEFAULT UTC_TIMESTAMP(), - last_seen TIMESTAMP DEFAULT UTC_TIMESTAMP() ON UPDATE UTC_TIMESTAMP(), + first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, request_count INT DEFAULT 1, deviceVendor VARCHAR(64) DEFAULT 'unknown', deviceModel VARCHAR(64) DEFAULT 'unknown', @@ -118,8 +124,8 @@ async function createTablesForTesting() { password_hash VARCHAR(255) NOT NULL, provider VARCHAR(50), provider_id VARCHAR(100), - created_at TIMESTAMP NOT NULL DEFAULT UTC_TIMESTAMP(), - updated_at TIMESTAMP NOT NULL DEFAULT UTC_TIMESTAMP() ON UPDATE UTC_TIMESTAMP(), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, remember_user BOOLEAN DEFAULT 0, terms_and_privacy_agreement BOOLEAN DEFAULT 0, accepts_marketing BOOLEAN DEFAULT 0, @@ -143,7 +149,7 @@ async function createTablesForTesting() { user_id INT NOT NULL, token VARCHAR(600) NOT NULL UNIQUE, valid BOOLEAN DEFAULT 0, - created_at TIMESTAMP NOT NULL DEFAULT UTC_TIMESTAMP(), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, expiresAt TIMESTAMP NOT NULL, usage_count INT DEFAULT 0, session_started_at TIMESTAMP, @@ -164,7 +170,7 @@ async function createTablesForTesting() { code_hash CHAR(64) NOT NULL UNIQUE, expires_at DATETIME NOT NULL, used BOOLEAN DEFAULT 0, - created_at DATETIME DEFAULT UTC_TIMESTAMP(), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX(user_id), INDEX(code_hash), INDEX(token), INDEX(used), CONSTRAINT users_mfa diff --git a/test/setup/test.setup.ts b/test/setup/test.setup.ts index 22fa0c5..94fabe8 100644 --- a/test/setup/test.setup.ts +++ b/test/setup/test.setup.ts @@ -1,58 +1,36 @@ -import mysql2 from 'mysql2/promise'; -import mysql from 'mysql2'; import { cleanupTestDatabase, createTestUser, setupTestConfiguration } from "./testConfig"; -import { afterAll, afterEach, beforeAll, beforeEach, vi } from 'vitest'; +import { afterAll, afterEach, beforeAll, beforeEach, TestContext } from 'vitest'; import { tokenCache } from '../../src/jwtAuth/utils/accessTokentCache'; +import { getConfiguration } from '../../src/jwtAuth/config/configuration'; - -declare module 'vitest' { - export interface TestContext { - testUserId: number; - anotherUserId: number; - mainPool: mysql2.Pool; - rateLimiterPool: mysql.Pool; - } +interface CustomTestContext extends TestContext { + testUserId: number; + anotherUserId: number; } - let mainPool: mysql2.Pool; - let rateLimiterPool: mysql.Pool; - let testUserId: number; - let anotherUserId: number; +let testUserId: number; +let anotherUserId: number; beforeAll(async () => { - const pools = setupTestConfiguration(); - mainPool = pools.mainPool; - rateLimiterPool = pools.rateLimiterPool; - const uniq = `${Date.now()}_${Math.random().toString(36).slice(2)}`; - testUserId = await createTestUser(`test${uniq}@example.com`); - anotherUserId = await createTestUser(`another${uniq}@example.com`); - }); - - afterAll(async () => { - await cleanupTestDatabase(); - if (mainPool) await mainPool.end(); - if (rateLimiterPool) rateLimiterPool.end(); - }); + setupTestConfiguration(); + const uniq = `${Date.now()}_${Math.random().toString(36).slice(2)}`; + testUserId = await createTestUser(`test${uniq}@example.com`); + anotherUserId = await createTestUser(`another${uniq}@example.com`); +}); - beforeEach(async (context) => { - context.mainPool = mainPool; - context.rateLimiterPool = rateLimiterPool; - context.testUserId = testUserId; - context.anotherUserId = anotherUserId; +afterAll(async () => { + await cleanupTestDatabase(); + const config = getConfiguration(); + if (config.store.main) await config.store.main.end(); + if (config.store.rate_limiters_pool?.store) config.store.rate_limiters_pool.store.end(); +}); - const cache = tokenCache(); - if (typeof cache.clear === 'function') { - cache.clear(); - } else { - if (typeof cache.keys === 'function') { - for (const k of cache.keys()) { - cache.delete(k); - } - } - } - }); - - afterEach( async (context) => { - await context.mainPool.execute('DELETE FROM refresh_tokens WHERE 1=1'); +beforeEach(async (context: CustomTestContext) => { + context.testUserId = testUserId; + context.anotherUserId = anotherUserId; + tokenCache().clear(); }); +afterEach(async () => { + await getConfiguration().store.main.execute('DELETE FROM refresh_tokens WHERE 1=1'); +}); diff --git a/test/setup/testConfig.ts b/test/setup/testConfig.ts index 3922931..81276aa 100644 --- a/test/setup/testConfig.ts +++ b/test/setup/testConfig.ts @@ -1,14 +1,22 @@ import mysql from 'mysql2'; import mysql2 from 'mysql2/promise'; import { configuration } from '../../src/jwtAuth/config/configuration.js'; -import 'dotenv/config'; +import { config } from 'dotenv'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + + +config({ path: path.resolve(__dirname, '../../.env.test') }); const TEST_DB_CONFIG = { - host: process.env.DB_HOST!, - port: Number(process.env.DB_PORT!), - user: process.env.DB_USER!, - password: process.env.DB_PASS!, - database: process.env.DB_NAME!, + host: process.env.TEST_DB_HOST || 'localhost', + port: Number(process.env.TEST_DB_PORT) || 3306, + user: process.env.TEST_DB_USER, + password: process.env.TEST_DB_PASSWORD, + database: process.env.TEST_DB_NAME || 'my_auth_tests_db', multipleStatements: true }; @@ -16,8 +24,7 @@ const TEST_DB_CONFIG = { let pools: { mainPool: mysql2.Pool; rateLimiterPool: mysql.Pool } | null = null; export function createTestPools() { - console.log(process.env.VITE_DB_HOST) - console.log('Creating test pools'); + console.log('Creating test pools'); if (!pools) { pools = { mainPool: mysql2.createPool({ @@ -35,7 +42,7 @@ export function createTestPools() { }; console.log('New pools created'); } else { - console.log('Reusing existing pools'); + console.log('Reusing existing pools'); } return pools; } @@ -60,7 +67,7 @@ export function setupTestConfiguration() { main: mainPool, rate_limiters_pool: { store: rateLimiterPool, - dbName: 'myapp' + dbName: 'my_auth_tests_db' } }, telegram: { @@ -70,17 +77,17 @@ export function setupTestConfiguration() { pepper: 'test-pepper-secret-key' }, magic_links: { - jwt_secret_key: 'test-magic-links-secret', - domain: 'https://example.com' + jwt_secret_key: 'super_long_secret', + domain: 'http://localhost:10000' }, jwt: { - jwt_secret_key: 'test-jwt-secret-key', + jwt_secret_key: 'super_long_secret', access_tokens: { expiresIn: '15m' }, refresh_tokens: { - rotateOnEveryAccessExpiry: true, - refresh_ttl: 7 * 24 * 60 * 60 * 1000, + rotateOnEveryAccessExpiry: false, + refresh_ttl: 259200000, domain: 'localhost', MAX_SESSION_LIFE: 30 * 24 * 60 * 60 * 1000, maxAllowedSessionsPerUser: 5, @@ -88,19 +95,30 @@ export function setupTestConfiguration() { } }, email: { - resend_key: 'test-resend-key', - email: 'test@example.com' + resend_key: 're_FKjU5k87_A3xQS9wtERAcLiu6wFdQpuUk', + email: 'noreply@riavzon.com' }, - logLevel: 'info' + logLevel: 'info', + trustUserDeviceOnAuth: false, + botDetector: { + enableBotDetector: false + }, + htmlSanitizer: { + IrritationCount: 50, + maxAllowedInputLength: 50000 + } }); return { mainPool, rateLimiterPool }; } -export async function cleanupTestDatabase() { +export async function cleanupTestDatabase(): Promise { const { mainPool } = createTestPools(); try { + // Delete in order respecting foreign key constraints (children first) + await mainPool.execute('DELETE FROM refresh_tokens WHERE user_id IN (SELECT id FROM users WHERE email LIKE "%test%")'); + await mainPool.execute('DELETE FROM mfa_codes WHERE user_id IN (SELECT id FROM users WHERE email LIKE "%test%")'); await mainPool.execute('DELETE FROM users WHERE email LIKE "%test%"'); await mainPool.execute('DELETE FROM visitors WHERE canary_id LIKE "test-canary-%"'); } catch (error) { diff --git a/test/setup/vitest.d.ts b/test/setup/vitest.d.ts index f83ef8e..862ac15 100644 --- a/test/setup/vitest.d.ts +++ b/test/setup/vitest.d.ts @@ -1,12 +1,8 @@ -import 'vitest' -import type mysql2 from 'mysql2/promise' -import type mysql from 'mysql2' +import 'vitest'; declare module 'vitest' { export interface TestContext { - testUserId: number - anotherUserId: number - mainPool: mysql2.Pool - rateLimiterPool: mysql.Pool + testUserId: number; + anotherUserId: number; } } diff --git a/vitest.config.ts b/vitest.config.ts index 331ee5f..e820537 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,25 +1,28 @@ -// vitest.config.ts import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { + name: "auth", + coverage: { + enabled: true, + reporter: ['html'], + cleanOnRerun: true, + all: true, + include: ['src/**/*'], + }, environment: 'node', include: ["test/**/*.{test,spec}.ts"], - setupFiles: ['dotenv/config', './test/setup/test.setup.ts'], - globals: true, + setupFiles: ['./test/setup/test.setup.ts'], + env: { + DOTENV_CONFIG_PATH: '.env.test', + }, testTimeout: 30_000, hookTimeout: 30_000, fileParallelism: false, - // maxWorkers: 2, - // minWorkers: 1, pool: 'threads', sequence: { hooks: 'list', setupFiles: 'list' - }, - coverage: { - enabled: true, - reporter: ['html'], }, poolOptions: { threads: { @@ -29,3 +32,4 @@ export default defineConfig({ exclude: ["test/setup/**", "node_modules/**", "dist/**"], }, }); +