') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); GitHub - rifadul/node-api-postgres · GitHub
Skip to content

Repository files navigation

node-api-postgres

A RESTful API built with Node.js, Express 5, and PostgreSQL. It provides user management with JWT-based authentication delivered through httpOnly cookies, CSRF protection, refresh-token rotation with SHA-256 hashing and reuse detection, password recovery, soft-delete + restore, request validation, audit logging, rate limiting, security headers, request logging, Swagger/OpenAPI docs, and a global error handler.

Tech Stack

  • Runtime: Node.js (ESM — "type": "module")
  • Framework: Express 5
  • Database: PostgreSQL via pg
  • Auth:jsonwebtoken (access + refresh JWTs in httpOnly cookies) + bcrypt
  • Cookies/CSRF:cookie-parser, csurf
  • Validation:joi & zod
  • Security/Infra:cors (origin allowlist + credentials), helmet, express-rate-limit
  • Logging:morgan (dev only)
  • API Docs:swagger-jsdoc, swagger-ui-express
  • Dev:nodemon

Project Structure

.
├── app.js # Express app entry point
├── config/
│ └── swagger.js # Swagger / OpenAPI spec
├── constants/
│ ├── allowedOrigins.js # CORS origin allowlist
│ ├── auditActions.js # Audit-log action enum
│ └── errorCodes.js # Centralized error codes
├── controllers/
│ ├── authController.js # Register, login, logout, password & token flows
│ └── userController.js # User CRUD + restore
├── db/
│ └── index.js # PostgreSQL pool
├── middleware/
│ ├── asyncHandler.js # Async wrapper
│ ├── authMiddleware.js # JWT auth from accessToken cookie
│ ├── authorizeSelf.js # Owner-only guard
│ ├── errorHandler.js # Global error handler
│ ├── validate.js # Schema validator
│ └── validateId.js
├── models/
│ ├── auditLogModel.js # Audit-log SQL queries
│ └── userModel.js # User SQL queries (incl. hashed refresh token)
├── routes/
│ ├── authRoutes.js
│ └── userRoutes.js
├── services/
│ ├── auth/
│ │ └── authService.js # Auth business logic
│ ├── auditLogService.js # Fire-and-forget audit logger
│ └── userService.js
├── utils/
│ ├── AppError.js # Custom error class
│ ├── cookies.js # Shared cookie options (access/refresh)
│ ├── formatZodError.js
│ ├── hash.js # SHA-256 token hashing
│ ├── jwt.js # Access/refresh token sign & verify
│ ├── resetToken.js # Reset-token generation
│ └── response.js # Unified success response
├── validators/
│ ├── auth.validator.js
│ └── user.validator.js
└── package.json

Getting Started

Prerequisites

  • Node.js 18+
  • PostgreSQL 13+
  • Yarn or npm

1. Clone & Install

git clone <your-repo-url>cd node-api-postgres
yarn install
# or: npm install

2. Environment Variables

Create a .env file in the project root:

# Single PostgreSQL connection string (SSL is enabled automatically when NODE_ENV=production)DATABASE_URL=postgres://user:password@localhost:5432/crud_apiPORT=3000# Separate secrets for access and refresh tokensJWT_ACCESS_SECRET=your_long_random_access_secretJWT_REFRESH_SECRET=your_long_random_refresh_secret# "development" enables morgan request logging; "production" marks cookies as Secure and enables DB SSLNODE_ENV=development

JWTs expire in 5 minutes (access) and 1 day (refresh). The cookies that carry them have maxAge of 15 minutes and 7 days respectively, so the JWT lifetime is the binding limit.

3. Database Schema

Create the database and tables:

CREATEDATABASEnode_api;
CREATETABLEusers (
id SERIALPRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(150) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
is_deleted BOOLEAN DEFAULT FALSE,
reset_token VARCHAR(255),
reset_token_expires TIMESTAMP,
refresh_token TEXT,
created_at TIMESTAMP DEFAULT NOW()
);
CREATETABLEaudit_logs (
id SERIALPRIMARY KEY,
actor_id INTEGERREFERENCES users(id) ON DELETESETNULL,
action VARCHAR(64) NOT NULL,
entity_type VARCHAR(64) NOT NULL,
entity_id INTEGER,
metadata JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMP DEFAULT NOW()
);

4. Run

yarn dev
# or: npm run dev

Server runs at http://localhost:3000. Interactive API docs are available at http://localhost:3000/api-docs.

Global Middleware

Every request flows through (in order):

  1. CORS — origin allowlist from constants/allowedOrigins.js; credentials: true so cookies are sent cross-origin.
  2. cookie-parser — parses accessToken, refreshToken, and the CSRF cookie.
  3. JSON / urlencoded body parsers — JSON limited to 10kb.
  4. CSRF (csurf) — applied to all non-GET/HEAD/OPTIONS requests, except GET /auth/csrf-token. Clients must send the token (default header X-CSRF-Token / csrf-token) on every state-changing call.
  5. Rate limit — 100 requests per 15 minutes per IP.
  6. helmet — sets a hardened set of HTTP security headers (CSP, HSTS-ready, frameguard, etc.). x-powered-by is also disabled at the app level.
  7. morgan — concise request logging, enabled only when NODE_ENV=development.

Protected routes additionally require the accessToken cookie (set automatically on login/refresh).

API Endpoints

Docs — /api-docs

Swagger UI is mounted at /api-docs and is generated from JSDoc annotations in the route files via config/swagger.js.

Auth — /auth

MethodPathAuthDescription
GET/csrf-tokenIssue a CSRF token (sets the CSRF cookie, returns the token)
POST/registerRegister a new user
POST/loginLog in, sets accessToken + refreshToken cookies
POST/refresh-tokenRotate tokens using the refreshToken cookie
POST/logoutJWTInvalidate the refresh token and clear cookies
POST/change-passwordJWTChange password (requires current password)
POST/forgot-passwordGenerate password reset token (logged to console)
POST/reset-passwordReset password using token

Users — /users

MethodPathAuthDescription
GET/JWTPaginated list (?page=&limit=)
GET/:idJWTGet user by id
PUT/:idJWT+SelfReplace user
PATCH/:idJWT+SelfPartial update
DELETE/:idJWT+SelfSoft delete
PATCH/:id/restoreJWT+SelfRestore a soft-deleted user

Self means the JWT subject (req.user.id) must match the route :id.

Example Requests

These examples use cookies.txt to persist the auth + CSRF cookies between calls.

Fetch a CSRF token (must be done before any POST/PUT/PATCH/DELETE)

curl http://localhost:3000/auth/csrf-token \
-c cookies.txt
# => { "csrfToken": "<token>" }

Register

curl -X POST http://localhost:3000/auth/register \
-H "Content-Type: application/json" \
-H "X-CSRF-Token: <token>" \
-b cookies.txt -c cookies.txt \
-d '{"name":"Siam","email":"siam@example.com","password":"Secret123!"}'

Login (sets accessToken + refreshToken cookies)

curl -X POST http://localhost:3000/auth/login \
-H "Content-Type: application/json" \
-H "X-CSRF-Token: <token>" \
-b cookies.txt -c cookies.txt \
-d '{"email":"siam@example.com","password":"Secret123!"}'

Refresh tokens (reads refreshToken cookie, rotates both cookies)

curl -X POST http://localhost:3000/auth/refresh-token \
-H "X-CSRF-Token: <token>" \
-b cookies.txt -c cookies.txt

Logout

curl -X POST http://localhost:3000/auth/logout \
-H "X-CSRF-Token: <token>" \
-b cookies.txt -c cookies.txt

List users (GET — no CSRF token required)

curl "http://localhost:3000/users?page=1&limit=10" \
-b cookies.txt

Response Format

All successful responses follow:

{
"success": true,
"message": "...",
"data": { },
"meta": { }
}

Errors are normalized through the global handler with AppError:

{
"success": false,
"message": "Invalid credentials",
"code": "UNAUTHORIZED"
}

Authentication Flow

  1. Get CSRF tokenGET /auth/csrf-token sets the CSRF cookie and returns the token. Send it on every state-changing request.
  2. Login — sets two httpOnly cookies: accessToken (15-min cookie / 5-min JWT) and refreshToken (7-day cookie / 1-day JWT). The refresh token is stored server-side as a SHA-256 hash.
  3. Authenticated requests — the browser sends the accessToken cookie automatically; middleware/authMiddleware.js verifies it and populates req.user.
  4. Refresh — when the access token expires, call POST /auth/refresh-token. The server hashes the incoming refresh token, looks it up, issues new tokens, and rotates both cookies.
  5. Token-reuse detection — if a refresh token is presented but no matching hash is found, all of that user's sessions are invalidated (clearRefreshToken) and a TOKEN_REUSE_DETECTED audit entry is written.
  6. Logout — clears the stored refresh token hash and both cookies.

Cookie flags (see utils/cookies.js): httpOnly, sameSite: 'strict', and secure when NODE_ENV=production.

CSRF Protection

csurf is mounted globally with cookie-stored tokens. Behavior:

  • GET, HEAD, and OPTIONS requests bypass the check.
  • GET /auth/csrf-token is exempt from the CSRF check itself but is the route that issues the token.
  • All other methods require the token from GET /auth/csrf-token, sent in the X-CSRF-Token (or csrf-token) header. The CSRF cookie must accompany the request.
  • Missing/invalid tokens return 403 EBADCSRFTOKEN from the global error handler.

API Documentation (Swagger)

Swagger UI is served at http://localhost:3000/api-docs. The OpenAPI 3.0 spec is generated by swagger-jsdoc from @swagger JSDoc blocks placed above route handlers in routes/, with the base definition in config/swagger.js.

All 14 endpoints (8 auth + 6 user) are documented with request bodies, path/query parameters, response schemas, and the relevant error cases (400 / 401 / 403 / 404 / 409). Reusable components defined in config/swagger.js:

  • Schemas:User, SuccessResponse, PaginatedUsersResponse, ErrorResponse
  • Security schemes:cookieAuth (accessToken cookie), apiKeyAuth (x-api-key header), csrfToken (x-csrf-token header)

Public auth endpoints (/register, /login, /forgot-password, /reset-password, /csrf-token) override the global security with security: [] so Swagger UI does not require credentials for them.

To document a new endpoint, add a @swagger JSDoc block above its handler — it will be picked up automatically on next start.

Audit Logging

Sensitive actions are recorded in the audit_logs table via a fire-and-forget service (services/auditLogService.js). Failures are logged but never break the request. Tracked actions are defined in constants/auditActions.js:

  • LOGIN
  • CREATE_USER, UPDATE_USER, DELETE_USER, RESTORE_USER
  • CHANGE_PASSWORD, RESET_PASSWORD
  • TOKEN_REUSE_DETECTED

Each entry stores the actor, action, entity type/id, and a JSON metadata blob.

Security Notes

  • Passwords are hashed with bcrypt (cost 10).
  • Access and refresh tokens use separate secrets (JWT_ACCESS_SECRET, JWT_REFRESH_SECRET).
  • Tokens travel in httpOnly + sameSite=strict cookies (Secure in production), so they are not reachable from JavaScript.
  • Refresh tokens are stored as a SHA-256 hash (utils/hash.js) and rotated on every refresh.
  • Refresh-token reuse triggers full session invalidation for that user and an audit-log entry.
  • Reset tokens are also stored as a SHA-256 hash; only the raw token leaves the server, and they expire after 15 minutes.
  • helmet sets hardened HTTP security headers; x-powered-by is disabled.
  • CORS uses an explicit allowlist (constants/allowedOrigins.js) with credentials: true.
  • forgot-password does not reveal whether an email exists.
  • .env is git-ignored — never commit secrets.

Scripts

ScriptDescription
yarn devStart dev server with nodemon
yarn startRun the server with node (production)

License

MIT

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages