Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions docs/pr-auth-session/auth-session-hardening.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# Auth Session Hardening

## Detection

The previous backend accepted any syntactically valid login request. A user could
submit any email address and any non-empty password to `POST /login` and still be
redirected to `/dashboard`. The dashboard route was also directly accessible by
requesting `GET /dashboard` without a session. That meant the form submission
contract existed, but the backend did not yet verify identity before allowing the
protected task board page to load.

## External guidance used

- OWASP Authentication Cheat Sheet: authentication should verify supplied
authenticators, store passwords securely, and compare password hashes using
safe comparison functions.
- OWASP Session Management Cheat Sheet: authenticated state should be carried by
a hard-to-predict session identifier, and cookies should use protections such
as `HttpOnly` and `SameSite`.

References:

- https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
- https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html

## Implementation summary

- Added an in-memory auth store for the prototype signup/login flow.
- Normalised email addresses before account lookup.
- Hashed passwords with Node's built-in `crypto.scrypt` before storage.
- Compared password hashes using `crypto.timingSafeEqual`.
- Reserved email addresses during signup so parallel duplicate requests cannot
both succeed.
- Ran unknown-account login attempts through a dummy password hash, keeping the
failure path closer to the wrong-password path.
- Issued a signed `taskify_session` cookie after signup/login. The cookie stores
only non-sensitive user identity fields, so dashboard access does not depend
on a process-local Map lookup after the token has been signed.
- Set session cookies with `HttpOnly`, `SameSite=Lax`, `Path=/`, and `Max-Age`.
- Avoided a public production fallback secret. Development and test use a local
fallback, while other environments generate a private per-process secret if
`SESSION_SECRET` has not been configured. A stable `SESSION_SECRET` should be
configured for deployed environments.
- Protected `GET /dashboard`; unauthenticated or tampered-session requests now
redirect to `/signup`.
- Added `POST /logout` to clear the session cookie and revoke the current
session identifier for the running process.
- Added a small `/favicon.ico` 204 response to remove browser 404 noise during
runtime checks.

## Validation

Automated tests cover:

- signup redirects and sets a protected session cookie;
- login succeeds only after a matching signup;
- unknown email and wrong password return `401`;
- duplicate signup returns `409`;
- invalid email and short passwords return `400`;
- dashboard blocks unauthenticated and tampered-session requests;
- malformed cookie encoding is treated as unauthenticated rather than causing a
500 response;
- duplicate parallel signups cannot both succeed;
- dashboard still accepts a signed session if the process-local user store is
empty;
- logout clears the browser cookie and rejects reuse of the old copied cookie in
the current process;
- dashboard renders normally with a valid session;
- favicon requests no longer produce a browser 404.

Runtime smoke checks were also run against a local server with curl and Chrome
DevTools MCP. The signup -> dashboard flow loaded successfully, unauthenticated
dashboard requests redirected to `/signup`, wrong-password login returned `401`,
logout cleared the session, and the browser network panel showed no local 404s.

## Known scope limit

The store is intentionally in-memory because this coursework app does not yet
have a configured production database. It is suitable for proving the backend
authentication contract and session protection, but a permanent database-backed
user model would be needed before treating accounts as durable production data.
94 changes: 90 additions & 4 deletions src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,17 @@ const express = require("express");
const path = require("path");
require("dotenv").config();
require("../src/db/conn");
const {
createUser,
getUserById,
verifyCredentials,
} = require("./services/auth-store");
const {
clearSessionCookie,
createSessionCookie,
getSession,
revokeSession,
} = require("./middleware/session");
const views_path = path.join(__dirname, "../views");
const static_path = path.join(__dirname, "../static");
const app = express();
Expand All @@ -20,6 +31,26 @@ function collectMissingFields(body, fields) {
return fields.filter((field) => !isNonEmpty(body[field]));
}

function asyncRoute(handler) {
return (req, res, next) => {
Promise.resolve(handler(req, res, next)).catch(next);
};
}

function isStrongEnoughPassword(value) {
return typeof value === "string" && value.length >= 8;
}

function getAuthenticatedUser(req) {
const session = getSession(req);

if (!session) {
return null;
}

return getUserById(session.userId) || session.user || null;
}

app.use("/static", express.static(static_path));
app.use(express.json());
app.use(urlencoded({ extended: false }));
Expand All @@ -36,31 +67,53 @@ app.get("/signup", (req, res) => {
res.status(200).render("signup.ejs");
});

app.get("/favicon.ico", (req, res) => {
res.status(204).end();
});

app.get("/privacy", (req, res) => {
res.status(200).render("privacy.ejs");
});

app.post("/signup", (req, res) => {
app.post("/signup", asyncRoute(async (req, res) => {
const missingFields = collectMissingFields(req.body, [
"SignUpUsername",
"SignUpEmail",
"SignUpPassword",
]);
const passwordIsStrongEnough = isStrongEnoughPassword(req.body.SignUpPassword);

if (missingFields.length > 0 || !isValidEmail(req.body.SignUpEmail)) {
if (missingFields.length > 0 || !isValidEmail(req.body.SignUpEmail) || !passwordIsStrongEnough) {
return res.status(400).json({
success: false,
errors: {
missingFields,
email: isValidEmail(req.body.SignUpEmail) ? undefined : "Enter a valid email address.",
password: passwordIsStrongEnough ? undefined : "Password must be at least 8 characters.",
},
});
}

const result = await createUser({
username: req.body.SignUpUsername,
email: req.body.SignUpEmail,
password: req.body.SignUpPassword,
});

if (!result.ok) {
return res.status(409).json({
success: false,
errors: {
email: "An account already exists for this email address.",
},
});
}

res.setHeader("Set-Cookie", createSessionCookie(result.user));
return res.redirect(303, "/dashboard");
});
}));

app.post("/login", (req, res) => {
app.post("/login", asyncRoute(async (req, res) => {
const missingFields = collectMissingFields(req.body, [
"LoginEmail",
"LoginPassword",
Expand All @@ -76,14 +129,47 @@ app.post("/login", (req, res) => {
});
}

const user = await verifyCredentials(req.body.LoginEmail, req.body.LoginPassword);

if (!user) {
return res.status(401).json({
success: false,
errors: {
credentials: "Email or password is incorrect.",
},
});
}

res.setHeader("Set-Cookie", createSessionCookie(user));
return res.redirect(303, "/dashboard");
}));

app.post("/logout", (req, res) => {
revokeSession(req);
res.setHeader("Set-Cookie", clearSessionCookie());
return res.redirect(303, "/signup");
Comment on lines +147 to +150
});

// In Future this dashboard will be rendered after authentication of users
app.get("/dashboard", (req, res) => {
const user = getAuthenticatedUser(req);

if (!user) {
return res.redirect(303, "/signup");
}

res.locals.currentUser = user;
res.status(200).render("dashboard/dashboard.ejs");
});

app.use((err, req, res, next) => {
console.error(err);
return res.status(500).json({
success: false,
error: "Internal server error.",
});
});



if (require.main === module) {
Expand Down
150 changes: 150 additions & 0 deletions src/middleware/session.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
const crypto = require("crypto");

const SESSION_COOKIE = "taskify_session";
const SESSION_MAX_AGE_SECONDS = 60 * 60 * 8;
const SESSION_SECRET = resolveSessionSecret();
const revokedSessionIds = new Set();

function resolveSessionSecret() {
if (process.env.SESSION_SECRET) {
return process.env.SESSION_SECRET;
}

if (!process.env.NODE_ENV || process.env.NODE_ENV === "development" || process.env.NODE_ENV === "test") {
return "taskify-development-session-secret";
}

console.warn("SESSION_SECRET is not set; using a generated per-process secret for this runtime.");
return crypto.randomBytes(32).toString("hex");
}

function parseCookies(cookieHeader) {
return String(cookieHeader || "")
.split(";")
.map((part) => part.trim())
.filter(Boolean)
.reduce((cookies, part) => {
const separatorIndex = part.indexOf("=");

if (separatorIndex === -1) {
return cookies;
}

const name = part.slice(0, separatorIndex);
const value = part.slice(separatorIndex + 1);

try {
cookies[name] = decodeURIComponent(value);
} catch (error) {
return cookies;
}

return cookies;
}, {});
}

function sign(value) {
return crypto.createHmac("sha256", SESSION_SECRET).update(value).digest("base64url");
}

function safeEqual(left, right) {
const leftBuffer = Buffer.from(String(left));
const rightBuffer = Buffer.from(String(right));

if (leftBuffer.length !== rightBuffer.length) {
return false;
}

return crypto.timingSafeEqual(leftBuffer, rightBuffer);
}

function createSessionToken(user) {
const now = Math.floor(Date.now() / 1000);
const sessionId = crypto.randomUUID();
const payload = Buffer.from(
JSON.stringify({
sid: sessionId,
userId: user.id,
user: {
id: user.id,
username: user.username,
email: user.email,
},
iat: now,
exp: now + SESSION_MAX_AGE_SECONDS,
})
).toString("base64url");

return `${payload}.${sign(payload)}`;
}

function verifySessionToken(token) {
const [payload, signature] = String(token || "").split(".");

if (!payload || !signature || !safeEqual(sign(payload), signature)) {
return null;
}

try {
const session = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
const now = Math.floor(Date.now() / 1000);

if (
!session.sid ||
!session.userId ||
typeof session.exp !== "number" ||
session.exp <= now ||
revokedSessionIds.has(session.sid)
) {
return null;
}

return session;
} catch (error) {
return null;
}
}

function getSession(req) {
const cookies = parseCookies(req.headers.cookie);
return verifySessionToken(cookies[SESSION_COOKIE]);
}

function revokeSession(req) {
const cookies = parseCookies(req.headers.cookie);
const session = verifySessionToken(cookies[SESSION_COOKIE]);

if (session) {
revokedSessionIds.add(session.sid);
}
}

function createSessionCookie(user) {
const attributes = [
`${SESSION_COOKIE}=${encodeURIComponent(createSessionToken(user))}`,
"HttpOnly",
"SameSite=Lax",
"Path=/",
`Max-Age=${SESSION_MAX_AGE_SECONDS}`,
];

if (process.env.NODE_ENV === "production") {
attributes.push("Secure");
}

return attributes.join("; ");
}

function clearSessionCookie() {
return `${SESSION_COOKIE}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0`;
Comment on lines +138 to +139
}

module.exports = {
SESSION_COOKIE,
clearSessionCookie,
createSessionCookie,
getSession,
parseCookies,
revokeSession,
verifySessionToken,
};
Loading
Loading