Inspector Gadget SPA
Where This Started
The original artifact for this submission is the Travlr Getaways MEAN stack application from CS 465 Full Stack Development I. That project established an Express REST API backed by MongoDB, an Angular single-page application (SPA) consuming it, and a Mongoose schema modeling the domain. Authentication used JWTs stored in localStorage, and the data model was built around travel listings and customer trips. If you would like to see the source code for the original artifact click here.
A New Domain, A Tighter Security Bar
The enhancement repurposes that foundation as the backend and dashboard for the network intrusion detection system. The Raspberry Pi running the IDS needs to POST alerts somewhere persistent and analysts need to query, filter, and sort those alerts in a browser. I chose the MEAN stack as the right tool for both jobs. However, the security requirements are slightly different from a travel booking app. An IDS dashboard that leaks its auth token or accepts unauthenticated writes is a liability.
Modeling the Alert Domain
The alert schema maps directly to what the Pi's Alert_System posts: source IP, source port, destination, destination port, category (the classifier's output label), and a reported timestamp. Every field is required because a record missing a source or category is useless for incident response, so Mongoose rejects it before it reaches the database. The category field carries an index because I projected the most common query pattern is filtering by attack type, and an unindexed scan over a growing alerts collection would degrade quickly.
const mongoose = require('mongoose');
// Define alert schema
const alertSchema = new mongoose.Schema({
source: {type: String, required: true},
source_port: {type: Number, required: true},
destination: {type: String, required: true},
destination_port: {type: Number, required: true},
category: {type: String, required: true, index: true},
reported: {type: Date, required: true},
});
const Alert = mongoose.model('alert', alertSchema);
module.exports = Alert;Querying With an Aggregation Pipeline
The alert listing endpoint needs to support filtering by category, IP, date range, pagination, and sort, all simultaneously and in any combination. A chain of Model.find() calls would require multiple round-trips and force the server to hold the full result in memory to paginate it. MongoDB's aggregation pipeline handles this in a single round-trip. A $match stage applies whatever filters are active, then a $facet stage runs two sub-pipelines in parallel. One that sorts and paginates the matching documents, and one that counts them. The total count and the current page come back in the same response without a second query. The Mongoose and MongoDB documentation made it straightforward to implement.
// GET /alerts - paginated, filtered, sorted list via $match + $facet
const alertsList = async(req, res) => {
try {
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 25;
const sort = req.query.sort || 'reported';
const order = req.query.order == 'asc' ? 1 : -1;
const category = req.query.category;
const search = req.query.search;
const from = req.query.from;
const to = req.query.to;
const pipeline = [];
const match = {};
if (category) match.category = category;
if (search) {
match.$or = [
{ source: {$regex: search, $options: 'i'}},
{ destination: { $regex: search, $options: 'i'}}
];
}
if (from || to) {
if (from) match.reported.$get = new Date(from);
if (to) match.reported.$lte = new Date(to);
}
if (Object.keys(match).length > 0) {
pipeline.push({$match: match});
}
// $facet returns both the page of results and the total count
// in a single round-trip to Atlas
pipeline.push({
$facet: {
metadata: [{$count: 'total'}],
alerts: [
{ $sort: { [sort]: order}},
{ $skip: (page-1) * limit },
{ $limit: limit }
]
}
});
const [result] = await Model.aggregate(pipeline);
const total = result.metadata[0]?.total || 0;
return res.status(200).json({
alerts: result.alerts,
total, page, limit,
pages: Math.ceil(total/limit)
});
} catch (err) {
return res.status(err.status || 400).json({message: err.message});
}
};Two Clients, Two Auth Paths
The /alerts resource has two very different clients: the Raspberry Pi, which writes alerts via POST, and the Angular dashboard, which reads them via GET. These clients have different auth requirements and different threat models. The Pi is not an interactive user. It holds a static API key set in an environment variable and sends it as a Bearer token on every request. The Angular user logs in with a username and password and receives a short-lived JWT. Sharing a single mechanism between them would either weaken user authentication (static keys don't expire) or unnecessarily complicate the Pi. The solution is two middleware functions on the same route, with authenticatePi guarding writes and authenticateJWT guarding reads.
// Same resource, two gatekeepers:
// dashboard reads via JWT cookie (authenticateJWT)
// Pi writes via bearer-secret header (authenticatePi)
router
.route('/alerts')
.get(authenticateJWT, alertsController.alertsList)
.post(authenticatePi, alertsController.alertsAddAlerts);
router.route('/heartbeat')
.get(authenticatePi, alertsController.heartBeat);authenticatePi extracts the Bearer token from the Authorization header and compares it to the API_KEY environment variable. There is no database lookup and no cryptography. It is a secret that never leaves the server.
// Pi auth: compare Authorization header bearer secret to env var
function authenticatePi(req, res, next) {
const authHeader = req.headers['authorization'];
if (authHeader == null) {
return res.status(401).json({message: 'auth Header Required but NOT PRESENT!'});
}
const secret = authHeader.split(' ')[1];
if (secret == null) {
return res.status(401).json({message: 'Null Bearer Secret'});
}
const verified = process.env.API_KEY == secret;
if (!verified) {
return res.status(401).json({message: 'Wrong Bearer Secret'});
}
next();
}authenticateJWT reads the token out of the httpOnly cookie, not the Authorization header or localStorage, and verifies its signature against JWT_SECRET. Reading from an httpOnly cookie means JavaScript running on the page can never access the token, which closes the Cross Site Scripting (XSS) theft vector.
// Dashboard auth: read JWT out of httpOnly cookie and verify signature
function authenticateJWT(req, res, next) {
const token = req.cookies.token;
if (!token) {
return res.sendStatus(401);
}
jwt.verify(token, process.env.JWT_SECRET, (err, verified) => {
if (err) {
return res.status(401).json('Token Validation Error!');
}
req.auth = verified;
next();
});
}User Auth and Password Storage
Password storage is handled by passport-local-mongoose, which adds a salted PBKDF2 hash to the user schema as a plugin. There are no hand-written salt or hash routines in this codebase. The plugin's register() method handles credential creation, and createStrategy() wires up Passport's local strategy against that storage automatically. Using a well-audited library for credential storage keeps the attack surface small and avoids the class of vulnerabilities associated with creating your own.
const mongoose = require('mongoose');
const jwt = require('jsonwebtoken');
const passportLocalMongoose = require('passport-local-mongoose').default
|| require('passport-local-mongoose');
const userSchema = new mongoose.Schema({});
// Plugin adds username, salted hash, register(), and Passport strategy
userSchema.plugin(passportLocalMongoose);
userSchema.methods.generateJWT = function() {
return jwt.sign(
{ _id: this._id, username: this.username },
process.env.JWT_SECRET,
{ expiresIn: '1h' });
};
const User = mongoose.model('users', userSchema);
module.exports = User;const passport = require('passport');
const User = require('../models/user');
// The passport-local-mongoose plugin provides this strategy
// - no hand-written salt/hash/verify methods required
passport.use(User.createStrategy());On successful login the JWT is written to a cookie with httpOnly: true, sameSite: 'strict', and a 1-hour max age. The sameSite: strict flag prevents the cookie from being sent on cross-site requests, mitigating CSRF attacks without a separate token. The response body carries only the username and expiry timestamp, which is enough for the Angular app to display session state without ever touching the token itself.
const login = async (req, res) => {
const result = validationResult(req);
if (!result.isEmpty()) {
return res.status(401).json({'message': result.message, 'type': result.type});
}
passport.authenticate('local', (err, user, info) => {
if (err) return res.status(404).json(err);
if (user) {
const expire = Math.floor(Date.now() / 1000) + 60 * 60;
const token = user.generateJWT();
// httpOnly: true is the XSS mitigation -
// JavaScript on the page cannot read this cookie
res.cookie('token', token, {
httpOnly: true,
secure: false,
sameSite: 'strict',
maxAge: 60*60*1000
});
return res.status(200).json({ username: user.username, expire: expire });
} else {
res.status(401).json(info);
}
})(req, res);
};Input validation runs before either controller sees the request. A shared factory function applies the same three rules to any named field: not empty, must be a string, and trim whitespace. Validation errors return a 401 before the request reaches Passport, so malformed or empty credentials never touch the auth logic.
// Validation rule factory - every auth field must be
// present, string, and trimmed before reaching the controller
const validateParam = (param) => body(param).notEmpty().isString().trim();
router.route('/register')
.post(
validateParam('username'),
validateParam('password'),
authController.register);
router.route('/login')
.post(
validateParam('username'),
validateParam('password'),
authController.login);The Angular Side
On the Angular side, the httpOnly cookie is invisible to TypeScript. The browser sends it automatically on every request, but no application code can read or modify it. The JWT interceptor makes this transparent by cloning every outgoing request and setting withCredentials: true, which instructs the browser to include cookies on cross-origin requests. No Authorization header is constructed, no token is retrieved from storage.
import { inject } from '@angular/core';
import { HttpRequest, HttpHandlerFn, HttpEvent } from '@angular/common/http';
import { HttpInterceptorFn } from '@angular/common/http';
import { Observable } from 'rxjs';
// Setting withCredentials instructs the browser to send cookies
// with every request - no Authorization header needed
export const jwtInterceptor: HttpInterceptorFn = (req, next) => {
const authReq = req.clone({
withCredentials: true
});
return next(authReq);
};The alert data service assembles query parameters into an HttpParams object and passes them to the /alerts endpoint. Every parameter is optional. If no category filter or date range is set, those keys are simply absent from the request. This matches the server's aggregation pipeline, which builds the $match stage only from parameters that are actually present.
// Service method that assembles every optional query parameter
// into an HttpParams object for the /alerts endpoint
getAlerts(params: {
page?: number,
limit?: number,
sort?: string,
order?: string,
category?: string,
search?: string,
from?: Date,
to?: Date
} = {}) : Observable<any> {
let httpParams = new HttpParams();
if (params.page) httpParams = httpParams.set('page', params.page);
if (params.limit) httpParams = httpParams.set('limit', params.limit);
if (params.sort) httpParams = httpParams.set('sort', params.sort);
if (params.order) httpParams = httpParams.set('order', params.order);
if (params.category) httpParams = httpParams.set('category', params.category);
if (params.search) httpParams = httpParams.set('search', params.search);
if (params.from) httpParams = httpParams.set('from', params.from.toISOString());
if (params.to) httpParams = httpParams.set('to', params.to.toISOString());
return this.http.get(this.url, { params: httpParams });
}The Alert Dashboard
The alert listing component owns all query state, including the current page, limit, sort column, sort order, category filter, and date range. Every user interaction updates the relevant field and calls loadAlerts(), which rebuilds the params object and fires a new request. The component never caches or transforms the server's response. It assigns res.alerts to this.alerts and lets Angular's change detection re-render the table.
export class AlertListing implements OnInit {
alerts: Alert[] = [];
total = 0;
page = 1;
limit = 25;
sort = 'reported';
order = 'desc';
category = '';
search = '';
from?: Date;
to?: Date;
displayedColumns = ['source', 'source_port', 'destination',
'destination_port', 'category', 'reported'];loadAlerts(): void {
this.alertData.getAlerts({
page: this.page,
limit: this.limit,
sort: this.sort,
order: this.order,
category: this.category || undefined,
search: this.search || undefined,
from: this.from || undefined,
to: this.to || undefined
}).subscribe({
next: (res) => {
this.alerts = res.alerts;
this.total = res.total;
},
error: (err) => console.log('Error: ' + err)
});
}onPageChange(event: PageEvent): void {
this.page = event.pageIndex + 1;
this.limit = event.pageSize;
this.loadAlerts();
}
onSortChange(event: Sort): void {
this.sort = event.active;
this.order = event.direction || 'desc';
this.page = 1;
this.loadAlerts();
}The table and paginator are Angular Material components. The mat-table binds to alerts as its data source and fires a matSortChange event whenever a column header is clicked. The paginator is server-side. It reports user interactions via the page event, and onPageChange updates component state and triggers a new request. The total record count comes from the server's $facet metadata, so the paginator displays the correct total without loading the entire collection.
<table mat-table [dataSource]="alerts" matSort (matSortChange)="onSortChange($event)">
<ng-container matColumnDef="source">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Source</th>
<td mat-cell *matCellDef="let alert">{{ alert.source }}</td>
</ng-container>
<ng-container matColumnDef="category">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Category</th>
<td mat-cell *matCellDef="let alert">{{ alert.category }}</td>
</ng-container>
<ng-container matColumnDef="reported">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Reported</th>
<td mat-cell *matCellDef="let alert">{{ alert.reported | date:'short' }}</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
</table><mat-paginator
[length]="total"
[pageSize]="limit"
[pageSizeOptions]="[10, 25, 50, 100]"
(page)="onPageChange($event)">
</mat-paginator>Conclusions
This enhancement demonstrates the ability to design and implement a full-stack solution to a real security problem. The aggregation pipeline, dual authentication paths, and httpOnly cookie strategy are all decisions made from first principles. They were not inherited from the original Travlr scaffold. Each one was added because the new problem required it. Repurposing an existing application is not the same as copying it. Most of the work was identifying which parts of the original design were compatible with the new requirements, which parts needed to change, and which needed to be discarded entirely.
The security decisions in particular reflect a mindset of thinking through failure modes before writing code. The httpOnly cookie is the right choice not because it is easier than localStorage, but because it removes an entire class of attack. The static API key for the Pi is the right choice not because it is simpler than OAuth, but because the Pi is not an interactive user and adding OAuth complexity would create more attack surface without adding meaningful protection. Security decisions made for the wrong reasons, like following a tutorial or matching a prior implementation, tend to be wrong. Decisions made from an explicit threat model tend to hold up. If you would like to preview the page and the signal flows my IDS system identified you can go here. Make an account (any old name and password) to see and filter the database.
References
MongoDB. (2024). Aggregation pipeline. https://www.mongodb.com/docs/manual/core/aggregation-pipeline/
OWASP. (2021). OWASP Top Ten. https://owasp.org/www-project-top-ten/
Saintedlama. (2024). passport-local-mongoose. GitHub. https://github.com/saintedlama/passport-local-mongoose
