From 81c1673694723862881fe2f4971b1520c82dec45 Mon Sep 17 00:00:00 2001 From: Sergio Riavzon <187537278+Sergo706@users.noreply.github.com> Date: Mon, 2 Feb 2026 15:53:39 +0200 Subject: [PATCH 1/3] Test trust visitor (#114) * feat: trust visitor on login to prevent redundant MFA - Add trustVisitor helper to update user's visitor_id on login - Integrate trustVisitor in loginController and OAuth handler - Add trustUserDeviceOnAuth config option (default: true) - Update findUserByProvider to return user id and visitor_id - Fixes issue where expired canary cookies triggered New Device MFA * feat: Add rate limiting to email MFA and password reset flows Add layered rate limiting to prevent email flooding attacks: - Global limit: 800 emails/day across all email flows (protects costs) - Per-user limit: 8 MFA emails/day per user - Per-IP limit: 5 MFA emails/day per IP Changes: - Add new emailMfaFlow limiter bundle with configurable thresholds - Add emailMfaLimiters schema to configuration - Update sendTempMfaLink to accept ip/res params for rate limiting - Update all MFA callers to handle rate_limited return value - Share globalEmailLimiter between MFA and password reset flows - Improve consumeOrReject/guard types for RateLimiterUnion support - Expand validate-config.cjs to validate all config fields * fixed ratelimiters configuration --- .../utils/limiters/protectedEndpoints/emailMfaFlow/email.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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, From 8a6003fc9be7cc0ad15bb77a754cc9c4e805522d Mon Sep 17 00:00:00 2001 From: Sergio Riavzon <187537278+Sergo706@users.noreply.github.com> Date: Tue, 17 Feb 2026 14:51:34 +0200 Subject: [PATCH 2/3] Test trust visitor (#115) * feat: trust visitor on login to prevent redundant MFA - Add trustVisitor helper to update user's visitor_id on login - Integrate trustVisitor in loginController and OAuth handler - Add trustUserDeviceOnAuth config option (default: true) - Update findUserByProvider to return user id and visitor_id - Fixes issue where expired canary cookies triggered New Device MFA * feat: Add rate limiting to email MFA and password reset flows Add layered rate limiting to prevent email flooding attacks: - Global limit: 800 emails/day across all email flows (protects costs) - Per-user limit: 8 MFA emails/day per user - Per-IP limit: 5 MFA emails/day per IP Changes: - Add new emailMfaFlow limiter bundle with configurable thresholds - Add emailMfaLimiters schema to configuration - Update sendTempMfaLink to accept ip/res params for rate limiting - Update all MFA callers to handle rate_limited return value - Share globalEmailLimiter between MFA and password reset flows - Improve consumeOrReject/guard types for RateLimiterUnion support - Expand validate-config.cjs to validate all config fields * fixed ratelimiters configuration * feat: add custom MFA flow with secure code verification Core (Controllers and Routes): - Added initCustomMfaFlow controller with IP restriction, layered rate limiting, and session anomaly detection - Added verifyCustomMfaController for code verification and token rotation - Created magicLinks.ts route file with custom MFA endpoints Middleware and Utilities: - Refactored verifyEmailMFA middleware with improved separation of concerns - Added verifyTempLink middleware for temporary link validation - Created secureRandomCode utility for cryptographically secure code generation - Added verifyMfaCode utility for timing-safe code verification - Added customMfaLinks utility for generating and managing MFA links - Added genericMfaFlowEmail utility for email template handling - Added CustomMfaSchema types for validation Infrastructure: - Added docker-compose.test.yml for containerized test environment - Created config.test.json for test configuration - Updated start.sh with improved startup logic - Updated decrypt.sh for secrets handling Testing: - Refactored test setup with proper database initialization - Updated vitest config for improved test isolation - Fixed all refreshTokens tests to use new setup patterns Docs: - Updated ARCHITECTURE.md with custom MFA flow documentation - Updated DEVELOPMENT.md with testing instructions * feat: implement secure custom MFA flow for email updates and token rotation Backend (Models and API): Added verifyMfaCode utility with atomic transaction support for custom actions Implemented updateEmailController with strict validation and session revocation Added revokeAllRefreshTokens logic to refreshTokens.ts Updated verifyJwt to integrate strangeThings anomaly detection Added generic sendEmailNotification helper and new email templates Configured routes for custom/mfa and update/email Added TypeScript types for Emails and UpdateEmail payloads Emails & Notifications: Created new EJS templates for OTP and Notifications Added systemEmailMap configuration for new email types * refactor: use unified email system for custom MFA links Backend (Models and API): Updated initCustomMfaFlow to extract and pass EmailMetaDataOTP (device, browser, location) Refactored customMfaLinks to use mfaEmail from systemEmailMap instead of legacy genericMfaFlowEmail Removed deprecated genericMfaFlowEmail.ts Simplified user lookup in customMfaLinks (removed unused name selection) * feat: Introduce `SKIP_CONFIG_UNLINK` environment variable to conditionally prevent config file deletion and standardize package dependency versions. * feat: provide a passive mode configuration for the bot detector when disabled and update its initialization logic, alongside adding a Zod dependency override. * chore: Update zod dependency, adjust bot detector whitelist, and disable bot detector in test configuration. * fix: resolve rate limiter key length issues and refine response handling Backend (Models and API): - Updated guard utility to hash rate limiter keys exceeding 255 characters (fixing MySQL VARCHAR limit) - Removed redundant success response from initCustomMfaFlow (handled by finally block) - Refactored zodSafeStringMaker to correctly chain schema checks and transforms * refactor: remove redundant rate limiter and content-type check from verifyMFA, make magic link thresholds configurable Middleware (verifyEmailMFA): - Removed duplicate guard(uniLimiter) call that double-consumed rate limit budget - Removed redundant req.is(application/json) check already handled by contentType middleware upstream - Removed unused imports for guard, getLimiters, and consecutiveForSlowDown cache Middleware (verifyTempLink): - Replaced hardcoded allowedPerSuccessfulGet and allowedPerSuccessfulPost constants with per-flow configurable values from getConfiguration - Each verification handler now reads its own thresholds: adaptiveMfa, linkPasswordVerification, customMfaFlowsAndEmailChanges - Increased default allowedPerSuccessfulPost from 1 to 3 to allow code retry on typo Config Schema (configSchema): - Added thresholds object to magic_links config with three scoped sub-objects - Used Zod prefault to guarantee defaults cascade even when consumer provides no config * refactor: replace `console.warn` with structured logger for content type validation warnings. * feat: add breached password detection via HIBP k-Anonymity API Security: - Added isPwned utility using HaveIBeenPwned Passwords API with k-Anonymity range search - SHA-1 prefix/suffix split ensures passwords never leave the server - LRU cache with 15min TTL prevents redundant API calls - Padding header enabled to resist traffic analysis Backend (Controllers and Middleware): - Signup controller blocks registration with breached passwords - Password reset middleware rejects breached passwords before DB connection - Login controller returns advisory breached field in response for client-side warnings Testing: - Added integration tests against real HIBP API - Covers known-breached passwords, random passwords, SHA-1 correctness, caching, and padding * refactor: introduce prefix-based caching to `isPwned` function to reduce external API calls. * fix: stabilize MFA verification and logout flows Backend (Models and API): - Fixed ERR_HTTP_HEADERS_SENT in logout controller by clearing cookies before response - Enhanced verifyMfaCode to return detailed user metadata on success - Updated verifyTempLink middleware with robust validation logic - Refined configSchema and system email templates for better error handling Testing: - Validated logout flow completes without header errors - Confirmed MFA verification returns expected metadata structure --- .dockerignore | 3 +- .github/workflows/copilot-setup-steps.yml | 61 +- .gitignore | 3 +- ARCHITECTURE.md | 32 + DEVELOPMENT.md | 15 +- decrypt.sh | 10 +- docker-compose.test.yml | 52 ++ package.json | 70 +-- src/jwtAuth/config/botDetectorConfig.ts | 106 +++- src/jwtAuth/controllers/initCustomMfaFlow.ts | 186 ++++++ src/jwtAuth/controllers/loginController.ts | 12 +- src/jwtAuth/controllers/logout.ts | 21 +- src/jwtAuth/controllers/rotateAccessToken.ts | 14 +- src/jwtAuth/controllers/rotateOnEveryUse.ts | 14 +- .../controllers/rotateRefreshTokens.ts | 14 +- src/jwtAuth/controllers/signUpController.ts | 13 +- .../controllers/updateEmailController.ts | 146 +++++ .../controllers/verifyCustomMfaController.ts | 43 ++ src/jwtAuth/emails/OTP/index.ejs | 569 ++++++++++++++++++ src/jwtAuth/emails/nottifications/index.ejs | 314 ++++++++++ src/jwtAuth/emails/system.ejs | 164 ----- src/jwtAuth/middleware/validateContentType.ts | 4 +- src/jwtAuth/middleware/verifyEmailMFA.ts | 218 +------ src/jwtAuth/middleware/verifyJwt.ts | 16 +- src/jwtAuth/middleware/verifyPasswordReset.ts | 35 +- src/jwtAuth/middleware/verifyTempLink.ts | 339 +++++++++-- src/jwtAuth/routes/TokenRotations.ts | 4 + src/jwtAuth/routes/allowBffAccessRoute.ts | 4 + src/jwtAuth/routes/magicLinks.ts | 81 ++- src/jwtAuth/types/CustomMfaSchema.ts | 46 ++ src/jwtAuth/types/Emails.ts | 84 +++ .../types/MfaAndPasswordResetSchema.ts | 22 + src/jwtAuth/types/UpdateEmail.ts | 75 +++ src/jwtAuth/types/configSchema.ts | 49 +- src/jwtAuth/types/userIds.d.ts | 2 +- src/jwtAuth/utils/changePassword.ts | 20 +- src/jwtAuth/utils/customMfaLinks.ts | 164 +++++ src/jwtAuth/utils/emailMFA.ts | 84 +-- src/jwtAuth/utils/isPasswordPwned.ts | 58 ++ src/jwtAuth/utils/limiters/utils/guard.ts | 25 +- src/jwtAuth/utils/magicLinksCache.ts | 12 +- src/jwtAuth/utils/secureRandomCode.ts | 99 +++ src/jwtAuth/utils/systemEmailMap.ts | 101 ++-- src/jwtAuth/utils/systemEmails.ts | 26 +- src/jwtAuth/utils/verifyMfaCode.ts | 294 +++++++++ src/jwtAuth/utils/zodSafeStringMaker.ts | 3 +- src/main.ts | 6 + src/refreshTokens.ts | 36 ++ src/service.ts | 26 +- src/tempLinks.ts | 16 +- start.sh | 77 ++- test/isPwned.spec.ts | 100 +++ .../consumeAndVerifyRefreshToke.test.ts | 45 +- test/refreshTokens-test/edge-cases.test.ts | 5 +- .../generateRefreshToken.test.ts | 35 +- .../revokeRefreshToken.test.ts | 44 +- .../rotateRefreshToken.test.ts | 65 +- test/refreshTokens-test/security.test.ts | 29 +- .../verifyRefreshToken.test.ts | 41 +- test/setup/setupTestDB.ts | 34 +- test/setup/test.setup.ts | 72 +-- test/setup/testConfig.ts | 56 +- test/setup/vitest.d.ts | 10 +- vitest.config.ts | 22 +- 64 files changed, 3549 insertions(+), 897 deletions(-) create mode 100644 docker-compose.test.yml create mode 100644 src/jwtAuth/controllers/initCustomMfaFlow.ts create mode 100644 src/jwtAuth/controllers/updateEmailController.ts create mode 100644 src/jwtAuth/controllers/verifyCustomMfaController.ts create mode 100644 src/jwtAuth/emails/OTP/index.ejs create mode 100644 src/jwtAuth/emails/nottifications/index.ejs delete mode 100644 src/jwtAuth/emails/system.ejs create mode 100644 src/jwtAuth/types/CustomMfaSchema.ts create mode 100644 src/jwtAuth/types/Emails.ts create mode 100644 src/jwtAuth/types/MfaAndPasswordResetSchema.ts create mode 100644 src/jwtAuth/types/UpdateEmail.ts create mode 100644 src/jwtAuth/utils/customMfaLinks.ts create mode 100644 src/jwtAuth/utils/isPasswordPwned.ts create mode 100644 src/jwtAuth/utils/secureRandomCode.ts create mode 100644 src/jwtAuth/utils/verifyMfaCode.ts create mode 100644 test/isPwned.spec.ts 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..39a2008 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.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" } } 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/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/**"], }, }); + From 485c57ca1a62370c146307d174725ebc9c96f61f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 17 Feb 2026 14:17:42 +0000 Subject: [PATCH 3/3] npm(deps-dev): bump @types/node from 25.1.0 to 25.2.3 Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 25.1.0 to 25.2.3. - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node) --- updated-dependencies: - dependency-name: "@types/node" dependency-version: 25.2.3 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 39a2008..d9c7060 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,7 @@ "@types/he": "1.2.3", "@types/cookie-parser": "1.4.9", "@types/express": "5.0.3", - "@types/node": "25.1.0", + "@types/node": "25.2.3", "@vitest/coverage-v8": "4.0.18", "@vitest/ui": "4.0.18", "dotenv": "17.2.2",