Skip to content

Repository files navigation

ToDo-Backend

A server-rendered task management application built with Node.js, Express, MongoDB, and EJS, featuring cookie-based JWT authentication, bcrypt password hashing, and per-user data isolation for to-do items.

Node.js Express MongoDB EJS JWT bcrypt License

Note on the name: despite "Backend" in the repository name, this project is not a JSON REST API. It is a full server-rendered MVC web application — Express controllers respond with rendered EJS pages and redirects, not JSON payloads. That distinction is documented throughout this README.


Table of Contents


Project Overview

ToDo-Backend is a personal task manager where each visitor creates an account, logs in, and manages a private list of to-do items. The application follows a classic MVC layout: Mongoose models define the data shape, Express routes map URLs to controllers, controllers contain the business logic, and EJS views render the resulting HTML that is sent back to the browser. Authentication is stateless and cookie-based: a signed JWT is issued at login, stored in an httpOnly cookie, and verified on every request to a protected route by a custom middleware.

There is no single-page app or client-side framework here — pages are rendered server-side and the browser navigates via standard form submissions (POST) and links (GET). Small amounts of client-side JavaScript exist only for pre-submit form validation.

Key Features

  • User registration and login with hashed passwords (controller/authController.js).
  • Stateless authentication via JWT stored in an httpOnly cookie (no server-side session store).
  • Route protection — all /todos* routes are gated behind middleware/authMiddleware.js, which verifies the JWT before allowing access.
  • Per-user data isolation — every Todo query is scoped with { user: req.user.id }, so one user can never read, edit, toggle, or delete another user's tasks (controller/todoController.js).
  • Full CRUD on tasks: create, list, edit, update, toggle complete/incomplete, and delete.
  • Filtering/todos?filter=pending and /todos?filter=completed narrow the list via a Mongoose query built from req.query.filter.
  • Server-rendered views for login, registration, the task list, and task editing (views/*.ejs).
  • Client-side pre-submit validation on the registration form (email/password regex) and the add-task form (non-empty title), implemented as plain DOM scripts in public/js/.
  • Logout clears the auth cookie and redirects to /login.

Screenshots

No screenshots are currently committed to this repository. This section is a placeholder — recommended additions:

Screen Suggested filename
Login page docs/screenshots/login.png
Register page docs/screenshots/register.png
Task list (with filters) docs/screenshots/todos.png
Edit task page docs/screenshots/edit-todo.png

Tech Stack

Layer Technology Where it's used
Runtime Node.js (CommonJS) Entire project ("type": "commonjs" in package.json)
Web framework Express 5 server.js, routes/*.js
Database MongoDB Accessed via Mongoose
ODM Mongoose config/db.js, models/User.js, models/Todo.js
Templating EJS views/*.ejs, set as view engine in server.js
Authentication jsonwebtoken (JWT) controller/authController.js, middleware/authMiddleware.js
Password hashing bcrypt controller/authController.js
Cookies cookie-parser server.js (parses req.cookies.token)
Environment config dotenv server.js loads .env at startup
Static assets Express static middleware Serves public/ (CSS + client JS)
Dev tooling nodemon devDependencies, used for local auto-reload

Architecture Overview

flowchart TD
    A[Browser] -->|HTTP request + cookie| B[Express App - server.js]
    B --> C[cookie-parser]
    C --> D[Static file middleware - /public]
    D --> E[express.urlencoded body parser]
    E --> F{Router}
    F -->|"/login /signup /logout"| G[authRoutes.js]
    F -->|"/todos*"| H[todoRoutes.js]
    H --> I[authMiddleware.js]
    I -->|valid JWT| J[todoController.js]
    I -->|missing/invalid JWT| K[Redirect to /login]
    G --> L[authController.js]
    J --> M[(MongoDB via Mongoose)]
    L --> M
    J --> N[EJS Views]
    L --> N
    N --> A
Loading

Every request flows through Express's global middleware chain first (cookie parsing → static files → body parsing), then into route-specific handling. The /todos* routes add an additional authentication gate that the /login, /signup, and /logout routes do not need, since account creation and login must be reachable by unauthenticated visitors.

Folder Structure

ToDo-Backend/
├── config/
│   └── db.js                # Mongoose connection (reads MONGO_URI)
├── controller/
│   ├── authController.js    # register, login, logout logic
│   └── todoController.js    # CRUD + filtering logic for tasks
├── middleware/
│   └── authMiddleware.js    # verifies JWT cookie, sets req.user
├── models/
│   ├── User.js               # name, email, password (hash)
│   └── Todo.js                # user ref, title, deadline, priority, notes, completed
├── public/
│   ├── css/
│   │   └── style.css         # shared styling for all views
│   └── js/
│       ├── login.js          # empty — no client-side logic wired up
│       ├── register.js       # pre-submit validation for registration
│       └── todos.js          # pre-submit validation for adding a task
├── routes/
│   ├── authRoutes.js         # /login, /signup, /logout
│   └── todoRoutes.js         # /todos and its sub-routes, all behind auth
├── views/
│   ├── login.ejs
│   ├── register.ejs
│   ├── todos.ejs
│   └── editTodo.ejs
├── .gitattributes
├── .gitignore                 # ignores node_modules/ and .env
├── package.json
├── package-lock.json
└── server.js                  # app bootstrap, middleware wiring, DB connect + listen

Project Structure Explanation

  • config/ isolates the database connection from application logic, so server.js only has to call connectDb() without knowing how Mongoose is configured.
  • models/ contains the two Mongoose schemas. Todo stores a user field (ObjectId, ref: "User") which is the foreign key tying every task to its owner.
  • controller/ holds the business logic. It is named controller (singular) rather than controllers — the folder contains one file per resource (authController.js, todoController.js).
  • middleware/ currently contains a single custom middleware, authMiddleware.js, which is the enforcement point for authentication.
  • routes/ defines URL-to-controller mappings only; no logic lives here beyond wiring the auth middleware into the /todos* chain.
  • views/ contains the four EJS templates that are actually rendered by the controllers (login, register, todos, editTodo).
  • public/ is served statically by Express and holds CSS and the small client-side validation scripts. It is not a client-side application — no bundler, no framework, no build step.

Authentication Flow

Registration

flowchart TD
    A[User submits register form] --> B["Client-side regex validation (public/js/register.js)"]
    B -->|passes| C["POST /signup"]
    C --> D["authController.createUser"]
    D --> E["bcrypt.hash(password, 10)"]
    E --> F["User.create({ name, email, password: hash })"]
    F --> G["redirect to /login"]
Loading

Login

flowchart TD
    A["POST /login"] --> B["authController.loginValidate"]
    B --> C["User.findOne({ email })"]
    C -->|not found| D["render login.ejs with error"]
    C -->|found| E["bcrypt.compare(password, user.password)"]
    E -->|no match| D
    E -->|match| F["jwt.sign({ id, email }, JWT_SECRET, { expiresIn: '1d' })"]
    F --> G["res.cookie('token', jwt, { httpOnly: true, maxAge: 86400000 })"]
    G --> H["redirect to /todos"]
Loading

JWT Lifecycle

Step Detail
Issued On successful login, signed with process.env.JWT_SECRET, payload { id, email }
Expiry 1d (enforced by jsonwebtoken, backed by a matching cookie maxAge of 24 hours)
Storage httpOnly cookie named token — not accessible to client-side JavaScript, which mitigates XSS token theft
Verified On every request to a route wrapped with authMiddlewarejwt.verify(token, JWT_SECRET)
Cleared POST /logout calls res.clearCookie("token") and redirects to /login

Password Hashing Flow

flowchart LR
    A["Plaintext password from form"] --> B["bcrypt.hash(password, 10)"]
    B --> C["Salted hash stored in User.password"]
    C -.->|"login later"| D["bcrypt.compare(inputPassword, storedHash)"]
Loading

Hashing happens explicitly inside authController.createUser — there is no Mongoose pre("save") hook on the User model. This is a deliberate (if implicit) design choice visible in the code: the model stays a plain schema definition, and all password handling logic lives in the controller layer alongside the rest of the authentication flow.

Why bcrypt? Bcrypt is a purpose-built, adaptive password-hashing algorithm (as opposed to a general-purpose hash like SHA-256). It incorporates a per-password salt automatically and a configurable cost factor (10 rounds here), making brute-force and rainbow-table attacks significantly more expensive. Storing a bcrypt hash instead of the plaintext password means a database leak does not directly expose user credentials.

Why JWT in an httpOnly cookie instead of localStorage? Storing the token in an httpOnly cookie keeps it inaccessible to JavaScript running on the page, which closes off the most common vector for token theft via XSS. The cookie is also sent automatically by the browser on every request, which fits naturally with a server-rendered app that navigates via full page loads rather than an SPA issuing fetch/XHR calls with a manually attached Authorization header.

Why JWT instead of server-side sessions? A JWT lets the server verify a request's identity (jwt.verify) without querying a session store on every request — the user's identity is encoded and cryptographically signed inside the token itself. This keeps config/db.js and the data layer focused solely on application data (User, Todo) rather than also managing session state.

Middleware Pipeline

flowchart TD
    A[Incoming Request] --> B["cookie-parser"]
    B --> C["view engine set: ejs"]
    C --> D["express.static('public')"]
    D --> E["express.urlencoded({ extended: true })"]
    E --> F["todoRoutes / authRoutes"]
    F -->|"/todos*"| G["authMiddleware (custom)"]
    G --> H[Controller]
Loading

authMiddleware.js is the only custom middleware in the application. It exists to centralize the authentication check in one place rather than repeating a JWT-verification block at the top of every protected controller function. It is applied selectively — per-route, as a second argument in todoRoutes.js (e.g. router.get("/todos", auth, todoController.getTodo)) — rather than globally with app.use, because authRoutes.js (/login, /signup) must remain reachable by unauthenticated visitors.

function auth(req, res, next) {
    const token = req.cookies.token;
    if (!token) return res.redirect('/login');
    try {
        const decoded = jwt.verify(token, process.env.JWT_SECRET);
        req.user = decoded;
        next();
    } catch (err) {
        return res.redirect('/login');
    }
}

If the cookie is missing or the token fails verification (expired, tampered, wrong secret), the middleware redirects to /login rather than returning a JSON error — consistent with this being a server-rendered app, not an API.

Request Lifecycle

Example: a logged-in user requests their task list.

sequenceDiagram
    participant Browser
    participant Express as server.js
    participant Router as todoRoutes.js
    participant Auth as authMiddleware.js
    participant Ctrl as todoController.js
    participant DB as MongoDB

    Browser->>Express: GET /todos?filter=pending (Cookie: token=...)
    Express->>Express: cookie-parser, static, urlencoded
    Express->>Router: match "/todos"
    Router->>Auth: auth(req, res, next)
    Auth->>Auth: jwt.verify(token, JWT_SECRET)
    Auth->>Ctrl: next() — req.user = { id, email }
    Ctrl->>DB: Todo.find({ user: req.user.id, completed: false })
    DB-->>Ctrl: matching todos[]
    Ctrl->>Browser: res.render("todos", { todos })
Loading

Todo CRUD Flow

flowchart TD
    subgraph Create
        A1["POST /todos"] --> A2[auth middleware]
        A2 --> A3["todoController.createTodo"]
        A3 --> A4["Todo.create({ user: req.user.id, title, deadline, priority, notes })"]
        A4 --> A5["redirect /todos"]
    end

    subgraph Read
        B1["GET /todos?filter="] --> B2[auth middleware]
        B2 --> B3["todoController.getTodo"]
        B3 --> B4["Todo.find(query scoped to req.user.id)"]
        B4 --> B5["render todos.ejs"]
    end

    subgraph Update
        C1["GET /todos/edit/:id"] --> C2[auth middleware]
        C2 --> C3["todoController.editTodo"]
        C3 --> C4["Todo.findOne scoped to user"]
        C4 --> C5["render editTodo.ejs"]
        C6["POST /todos/edit/:id"] --> C7[auth middleware]
        C7 --> C8["todoController.updateTodo"]
        C8 --> C9["Todo.findOneAndUpdate scoped to user"]
        C9 --> C10["redirect /todos"]
    end

    subgraph Toggle
        D1["POST /todos/toggle/:id"] --> D2[auth middleware]
        D2 --> D3["todoController.toggleTodo"]
        D3 --> D4["flip todo.completed, save()"]
        D4 --> D5["redirect /todos"]
    end

    subgraph Delete
        E1["POST /todos/delete/:id"] --> E2[auth middleware]
        E2 --> E3["todoController.deleteTodo"]
        E3 --> E4["Todo.findOneAndDelete scoped to user"]
        E4 --> E5["redirect /todos"]
    end
Loading

Every read, update, toggle, and delete operation filters by { _id: req.params.id, user: req.user.id } (see controller/todoController.js). This is the application's core authorization rule: it guarantees a user can only ever act on their own tasks, even if they guess or manipulate another user's task ID in the URL.

Database Schema

erDiagram
    USER ||--o{ TODO : owns
    USER {
        ObjectId _id
        String name
        String email "unique, lowercase"
        String password "bcrypt hash"
        Date createdAt
        Date updatedAt
    }
    TODO {
        ObjectId _id
        ObjectId user FK "ref: User"
        String title
        Date deadline "nullable"
        String priority "low | medium | high"
        String notes
        Boolean completed "default false"
        Date createdAt
        Date updatedAt
    }
Loading

User (models/User.js)

Field Type Constraints
name String required, trimmed
email String required, unique, lowercase, trimmed
password String required (stores the bcrypt hash, not plaintext)
timestamps createdAt / updatedAt auto-managed by Mongoose

Todo (models/Todo.js)

Field Type Constraints
user ObjectId required, ref: "User" — the ownership link
title String required, trimmed
deadline Date optional, defaults to null
priority String enum ["low", "medium", "high"], defaults to "medium"
notes String optional, defaults to ""
completed Boolean defaults to false
timestamps createdAt / updatedAt auto-managed by Mongoose

Relationship: one User owns many Todo documents (one-to-many), enforced application-side via the user field on every query and write in todoController.js — there is no MongoDB-level cascade delete, so removing a user would not automatically remove their todos (see Known Limitations).

Installation

Prerequisites

  • Node.js (project uses CommonJS modules and Express 5)
  • A running MongoDB instance — local (mongod) or a hosted cluster (e.g. MongoDB Atlas)
  • npm (ships with Node.js)

Local Setup

# 1. Clone the repository
git clone https://github.com/snkhn007/ToDo-Backend.git
cd ToDo-Backend

# 2. Install dependencies
npm install

# 3. Create a .env file in the project root (see Environment Variables below)

# 4. Start the server
npm start

Environment Variables

The repository does not include a .env.example file. Based on what server.js, config/db.js, and authController.js/authMiddleware.js read from process.env, create a .env file in the project root with:

Variable Required Used in Purpose
MONGO_URI Yes config/db.js Mongoose connection string for your MongoDB instance
JWT_SECRET Yes authController.js, authMiddleware.js Secret used to sign and verify JWTs
PORT Yes server.js (app.listen(process.env.PORT, ...)) Port the Express server listens on

Note: server.js declares a local const PORT = 3000 but never actually uses it — app.listen reads process.env.PORT directly. If PORT is not set in .env, the app will not start on a predictable port. Set PORT explicitly (e.g. PORT=3000).

Example .env:

MONGO_URI=mongodb://localhost:27017/todo-backend
JWT_SECRET=replace-with-a-long-random-string
PORT=3000

Running the Project

npm start        # runs `node server.js`

nodemon is listed as a dev dependency but there is no dev script defined in package.json; to use it for auto-reload during development, run it directly:

npx nodemon server.js

Once running, visit http://localhost:<PORT>/signup to create an account.

API Routes

These routes render server-side HTML (EJS) or issue redirects — they do not return JSON. There is no separate JSON API surface in this codebase.

Authentication Routes (routes/authRoutes.js)

Method Path Auth required Handler Behavior
GET /login No inline route Renders login.ejs
GET /signup No inline route Renders register.ejs
POST /signup No authController.createUser Hashes password, creates User, redirects to /login
POST /login No authController.loginValidate Verifies credentials, issues JWT cookie, redirects to /todos
POST /logout No* authController.logout Clears the token cookie, redirects to /login

* /logout is not wrapped with authMiddleware, though it is only linked from the authenticated task list view.

Todo Routes (routes/todoRoutes.js)

All routes below are protected by authMiddleware (auth).

Method Path Handler Behavior
GET /todos todoController.getTodo Lists the current user's todos; supports ?filter=pending or ?filter=completed
POST /todos todoController.createTodo Creates a todo owned by req.user.id
POST /todos/delete/:id todoController.deleteTodo Deletes a todo, scoped to the current user
POST /todos/toggle/:id todoController.toggleTodo Flips completed on a todo, scoped to the current user
GET /todos/edit/:id todoController.editTodo Renders editTodo.ejs for a single todo, scoped to the current user
POST /todos/edit/:id todoController.updateTodo Updates title, deadline, priority, notes on a todo, scoped to the current user

Security Features

Feature Implemented Detail
Password hashing Yes bcrypt.hash(password, 10) before storage
Stateless auth token Yes JWT signed with JWT_SECRET, 1-day expiry
httpOnly cookie Yes Prevents client-side JS from reading the auth token
Per-user data scoping Yes Every Todo query includes user: req.user.id
Route-level access control Yes authMiddleware guards all /todos* routes
Secrets via environment variables Yes MONGO_URI and JWT_SECRET are never hard-coded; .env is git-ignored
secure / sameSite cookie flags No The auth cookie is set with only httpOnly and maxAge — no secure: true (HTTPS-only) or sameSite attribute
CSRF protection No No CSRF token middleware on state-changing POST forms
Rate limiting / brute-force protection No No throttling on /login or /signup
Server-side input validation Partial Enforced only via Mongoose schema constraints (required, enum, unique); no dedicated validation library

Validation

  • Registration (public/js/register.js): client-side only, regex-checks email format, password strength (min 8 chars, upper/lowercase, digit, special character), and password/confirmation match. This runs in the browser before the form submits — it can be bypassed by submitting the form directly, since authController.createUser performs no equivalent server-side check.
  • Add Task (public/js/todos.js): client-side only, blocks submission if the title field is empty.
  • Login (views/login.ejs): no client-side script is wired up (public/js/login.js is an empty file); all validation is the server-side User.findOne + bcrypt.compare check in authController.loginValidate.
  • Schema-level validation: Mongoose enforces required fields, the priority enum, and unique/lowercase on email at the database layer, independent of any client-side script.

Error Handling

Error handling is implemented locally, per controller function, using try/catch (for async handlers) or .catch() (for Promise-chain handlers):

  • Database or unexpected errors are logged with console.log(err) and answered with res.status(500).send(...).
  • Expected auth failures (wrong password, user not found) return res.status(400).render("login", { error: ... }), surfacing a message in the login form rather than a generic error page.
  • A missing todo on edit/toggle returns res.status(404).send("Todo not found").
  • authMiddleware treats any JWT failure (missing, expired, invalid) as "not authenticated" and redirects to /login rather than returning a 401 status.

There is no centralized Express error-handling middleware (app.use((err, req, res, next) => ...)) — every handler manages its own error responses inline.

Dependencies

Package Version (declared) Actually used? Purpose
express ^5.2.1 Yes Web framework, routing
mongoose ^9.9.1 Yes MongoDB ODM, schemas
ejs ^6.0.1 Yes Server-side view templating
jsonwebtoken ^9.0.3 Yes JWT signing/verification
bcrypt ^6.0.0 Yes Password hashing (authController.js)
cookie-parser ^1.4.7 Yes Reads req.cookies
dotenv ^17.4.2 Yes Loads .env into process.env
bcryptjs ^3.0.3 No Listed in package.json but never required anywhere in the codebase
cors ^2.8.6 No Listed in package.json but never required or applied via app.use
nodemon (dev) ^3.1.14 Available, not wired into a script No dev script in package.json; must be invoked manually

Development Decisions

  • MVC folder separation (models / controller / routes / views) keeps data shape, business logic, URL wiring, and presentation independently testable and easy to navigate — a common convention for Express applications of this size.
  • Server-rendered EJS instead of a client-side framework matches the scope of the app: form-driven CRUD on a single resource type doesn't require a SPA, and rendering HTML directly from req.user-scoped queries keeps authorization logic in one place (the controller) instead of duplicating it across an API layer and a frontend.
  • JWT in an httpOnly cookie was chosen over localStorage token storage specifically because this is a cookie-driven, full-page-navigation app (see JWT Lifecycle above for the full reasoning).
  • Route-level middleware application (auth passed per-route in todoRoutes.js) rather than global app.use(auth) keeps /login and /signup reachable without an authenticated session, while still enforcing the check everywhere it's needed.
  • Ownership scoping baked into every query ({ user: req.user.id }) rather than checked separately after fetching a record — this means an unauthorized document simply doesn't match the query at all, rather than being fetched and then rejected.

Project Highlights

  • Clean separation of concerns across five distinct layers (config, models, controllers, routes, views).
  • Authentication and authorization implemented from first principles (no third-party auth service), covering hashing, token issuance, cookie storage, and verification middleware.
  • Consistent per-user data isolation enforced at the query level across every CRUD operation.
  • Environment-based configuration (MONGO_URI, JWT_SECRET, PORT) with secrets excluded from version control via .gitignore.

Known Limitations

These are gaps and inconsistencies visible directly in the current codebase, documented here for transparency:

  • Edit form field mismatch: views/editTodo.ejs submits a field named description, and reads todo.description for the textarea's current value — but the Todo schema (models/Todo.js) has no description field, only notes. todoController.updateTodo reads req.body.notes (not description), so the "Notes" field on the edit page does not actually update a todo's stored notes.
  • Edit form is incomplete: editTodo.ejs only exposes title and a (mismatched) notes field — deadline and priority cannot be changed from the edit view, even though updateTodo accepts them.
  • No .env.example: environment variables required to run the app (MONGO_URI, JWT_SECRET, PORT) are not documented anywhere in the repository itself.
  • Unused dependencies: bcryptjs and cors are declared in package.json but never imported or used.
  • Dead code: const PORT = 3000 in server.js is declared but never referenced — the actual listening port comes entirely from process.env.PORT.
  • No automated tests: the test script in package.json is the default placeholder ("echo \"Error: no test specified\" && exit 1"); there is no test suite.
  • Client-side validation is bypassable: registration and add-task validation happen only in the browser; the server does not re-validate email format, password strength, or required fields beyond Mongoose's required constraint.
  • No CSRF protection on state-changing POST forms (create, update, delete, toggle, logout).
  • Cookie is not hardened for production: the auth cookie sets httpOnly and maxAge, but not secure (HTTPS-only) or sameSite.
  • No rate limiting on /login or /signup, leaving both open to brute-force attempts.
  • No cascade cleanup: deleting a User (not currently exposed via any route) would not remove their associated Todo documents.
  • No LICENSE file in the repository, despite "license": "ISC" being declared in package.json.

Future Improvements

  • Add a .env.example documenting all required environment variables.
  • Fix the notes/description field mismatch and extend editTodo.ejs to support editing deadline and priority.
  • Add server-side validation (e.g. via express-validator or manual checks) mirroring the existing client-side rules, so validation isn't solely enforced in the browser.
  • Add secure and sameSite attributes to the auth cookie for production deployments behind HTTPS.
  • Introduce CSRF protection on all state-changing form submissions.
  • Add rate limiting on authentication routes.
  • Add an automated test suite (unit tests for controllers/middleware, integration tests for routes).
  • Remove unused dependencies (bcryptjs, cors) or wire them in if CORS support becomes necessary.
  • Add a dev script in package.json ("dev": "nodemon server.js") since nodemon is already a dev dependency.
  • Add a LICENSE file matching the declared ISC license.

Contributing

This is currently a solo learning/portfolio project with a single contributor. If you'd like to suggest a change:

  1. Fork the repository.
  2. Create a feature branch (git checkout -b feature/your-feature).
  3. Commit your changes with a clear message.
  4. Open a pull request describing the change and why it's needed.

License

package.json declares this project under the ISC license. No LICENSE file is currently present in the repository — add one matching the declared license to make this legally explicit.

Author

Sana Sadaf Khan GitHub: @snkhn007

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages