From e55eb89f837d80ba4ae61ff9c1fe5bac642d7189 Mon Sep 17 00:00:00 2001 From: chlodav <153681219+chlodav@users.noreply.github.com> Date: Sun, 28 Jun 2026 00:25:39 +0300 Subject: [PATCH 1/4] add task solution --- src/index.js | 547 ++++++++++++++++++++++++++++++++++++++++++++++ src/index.test.js | 193 ++++++++++++++++ 2 files changed, 740 insertions(+) create mode 100644 src/index.test.js diff --git a/src/index.js b/src/index.js index ad9a93a7..cd7190ad 100644 --- a/src/index.js +++ b/src/index.js @@ -1 +1,548 @@ 'use strict'; + +const crypto = require('node:crypto'); +const http = require('node:http'); +const { URL } = require('node:url'); + +function createServer() { + const users = []; + const sessions = new Map(); + let nextId = 1; + + function escapeHtml(value) { + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function parseBody(req) { + return new Promise((resolve, reject) => { + let body = ''; + + req.on('data', (chunk) => { + body += chunk; + }); + + req.on('end', () => { + const params = new URLSearchParams(body); + + const data = {}; + + for (const [key, value] of params.entries()) { + data[key] = value; + } + + resolve(data); + }); + + req.on('error', reject); + }); + } + + function renderPage(title, content, user) { + const welcome = user ? `
Hello, ${escapeHtml(user.name)}.
` : ''; + + return ` + + + +Password rules: at least 8 characters, one uppercase, one lowercase, one number, one special character.
+ + `; + + sendHtml(res, 200, renderPage('Register', content)); + + return; + } + + if (req.method === 'POST' && pathname === '/register') { + const body = await parseBody(req); + + if (!body.name || !body.email || !body.password) { + sendHtml( + res, + 200, + renderPage( + 'Register', + '', + ), + ); + + return; + } + + if (!validatePassword(body.password)) { + sendHtml( + res, + 200, + renderPage( + 'Register', + '', + ), + ); + + return; + } + + if (getUserByEmail(body.email)) { + sendHtml( + res, + 200, + renderPage( + 'Register', + '', + ), + ); + + return; + } + + const activationToken = crypto.randomBytes(16).toString('hex'); + const user = { + id: nextId++, + name: body.name, + email: body.email, + password: body.password, + active: false, + activationToken, + resetToken: null, + }; + + users.push(user); + + const content = ` + + + `; + + sendHtml(res, 200, renderPage('Register', content)); + + return; + } + + if (req.method === 'GET' && pathname === '/activate') { + const token = url.searchParams.get('token'); + const user = getUserByActivationToken(token); + + if (!user) { + sendHtml( + res, + 200, + renderPage( + 'Activation', + '', + ), + ); + + return; + } + + user.active = true; + user.activationToken = null; + const sessionId = createSession(user); + + res.writeHead(302, { + location: '/profile', + 'set-cookie': [`sessionId=${sessionId}; HttpOnly; Path=/`], + }); + res.end(); + + return; + } + + if (req.method === 'GET' && pathname === '/forgot-password') { + const content = ` + + `; + + sendHtml(res, 200, renderPage('Forgot password', content)); + + return; + } + + if (req.method === 'POST' && pathname === '/forgot-password') { + const body = await parseBody(req); + const user = getUserByEmail(body.email); + + if (user) { + user.resetToken = crypto.randomBytes(16).toString('hex'); + } + + const resetLink = user + ? `` + : ''; + + const content = ` + + ${resetLink} + `; + + sendHtml(res, 200, renderPage('Forgot password', content)); + + return; + } + + if (req.method === 'GET' && pathname === '/reset-password') { + const token = url.searchParams.get('token'); + const user = getUserByResetToken(token); + + if (!user) { + sendHtml( + res, + 200, + renderPage( + 'Reset password', + '', + ), + ); + + return; + } + + const content = ` + + `; + + sendHtml(res, 200, renderPage('Reset password', content)); + + return; + } + + if (req.method === 'POST' && pathname === '/reset-password') { + const token = url.searchParams.get('token'); + const body = await parseBody(req); + const user = getUserByResetToken(token); + + if (!user) { + sendHtml( + res, + 200, + renderPage( + 'Reset password', + '', + ), + ); + + return; + } + + if (body.password !== body.confirmation) { + sendHtml( + res, + 200, + renderPage( + 'Reset password', + '', + ), + ); + + return; + } + + if (!validatePassword(body.password)) { + sendHtml( + res, + 200, + renderPage( + 'Reset password', + '', + ), + ); + + return; + } + + user.password = body.password; + user.resetToken = null; + + const content = ` + + `; + + sendHtml(res, 200, renderPage('Reset password', content)); + + return; + } + + if (req.method === 'GET' && pathname === '/profile') { + const user = getAuthenticatedUser(req); + + if (!user) { + res.writeHead(302, { location: '/login' }); + res.end(); + + return; + } + + const content = ` +Welcome, ${escapeHtml(user.name)}!
+ + + `; + + sendHtml(res, 200, renderPage('Profile', content, user)); + + return; + } + + if (req.method === 'POST' && pathname === '/profile') { + const user = getAuthenticatedUser(req); + + if (!user) { + res.writeHead(302, { location: '/login' }); + res.end(); + + return; + } + + const body = await parseBody(req); + let message = 'Profile updated.'; + + if (body.name) { + user.name = body.name; + } + + if ( + body.oldPassword !== undefined || + body.newPassword !== undefined || + body.confirmation !== undefined + ) { + if ( + !body.oldPassword || + !body.newPassword || + !body.confirmation || + user.password !== body.oldPassword + ) { + message = 'Current password is incorrect.'; + } else if (body.newPassword !== body.confirmation) { + message = 'New passwords must match.'; + } else if (!validatePassword(body.newPassword)) { + message = 'Password must follow the required rules.'; + } else { + user.password = body.newPassword; + message = 'Password updated.'; + } + } + + const content = ` + +Welcome, ${escapeHtml(user.name)}!
+ `; + + sendHtml(res, 200, renderPage('Profile', content, user)); + + return; + } + + if (req.method === 'GET' && pathname === '/logout') { + const cookies = req.headers.cookie || ''; + const sessionCookie = cookies + .split(';') + .map((entry) => entry.trim()) + .find((entry) => entry.startsWith('sessionId=')); + + if (sessionCookie) { + const sessionId = sessionCookie.split('=')[1]; + + sessions.delete(sessionId); + } + + res.writeHead(302, { + location: '/login', + 'set-cookie': ['sessionId=; Max-Age=0; Path=/'], + }); + res.end(); + + return; + } + + sendHtml( + res, + 404, + renderPage('Not found', ''), + ); + }); +} + +module.exports = { + createServer, +}; diff --git a/src/index.test.js b/src/index.test.js new file mode 100644 index 00000000..b09bc85a --- /dev/null +++ b/src/index.test.js @@ -0,0 +1,193 @@ +'use strict'; + +const assert = require('node:assert'); +const http = require('node:http'); +const { createServer } = require('./index'); + +function startServer() { + const server = createServer(); + + return new Promise((resolve) => { + server.listen(0, () => { + const address = server.address(); + + resolve({ + server, + port: address.port, + }); + }); + }); +} + +function makeRequest(server, method, path, options = {}) { + const { body = '', headers = {} } = options; + + return new Promise((resolve, reject) => { + const req = http.request( + { + hostname: '127.0.0.1', + port: server.address().port, + path, + method, + headers: { + 'content-type': 'application/x-www-form-urlencoded', + ...headers, + }, + }, + (res) => { + let data = ''; + + res.on('data', (chunk) => { + data += chunk; + }); + + res.on('end', () => { + resolve({ + statusCode: res.statusCode, + headers: res.headers, + body: data, + }); + }); + }, + ); + + req.on('error', reject); + req.write(body); + req.end(); + }); +} + +function getCookie(headers) { + const setCookie = headers['set-cookie']; + + return Array.isArray(setCookie) ? setCookie[0] : setCookie; +} + +describe('auth application', () => { + let server; + let port; + + beforeEach(async () => { + ({ server, port } = await startServer()); + }); + + afterEach((done) => { + server.close(done); + }); + + it('registers a user and activates the account via email token', async () => { + const registerResponse = await makeRequest(server, 'POST', '/register', { + body: 'name=Test+User&email=test@example.com&password=Abc123!@', + }); + + assert.strictEqual(registerResponse.statusCode, 200); + const tokenMatch = registerResponse.body.match(/activate\?token=([^"']+)/); + + assert.ok(tokenMatch, 'activation link should be present'); + + const activationResponse = await makeRequest( + server, + 'GET', + `/activate?token=${tokenMatch[1]}`, + ); + + assert.strictEqual(activationResponse.statusCode, 302); + assert.strictEqual(activationResponse.headers.location, '/profile'); + + const profileResponse = await makeRequest(server, 'GET', '/profile', { + headers: { + cookie: getCookie(activationResponse.headers), + }, + }); + + assert.strictEqual(profileResponse.statusCode, 200); + assert.match(profileResponse.body, /Welcome, Test User/i); + }); + + it('allows a user to reset the password and sign in with the new password', async () => { + const registerResponse = await makeRequest(server, 'POST', '/register', { + body: 'name=Reset+User&email=reset@example.com&password=Abc123!@', + }); + + const activationTokenMatch = registerResponse.body.match( + /activate\?token=([^"']+)/, + ); + + await makeRequest( + server, + 'GET', + `/activate?token=${activationTokenMatch[1]}`, + ); + + const forgotResponse = await makeRequest( + server, + 'POST', + '/forgot-password', + { + body: 'email=reset@example.com', + }, + ); + + const resetTokenMatch = forgotResponse.body.match( + /reset-password\?token=([^"']+)/, + ); + + assert.ok(resetTokenMatch, 'reset link should be present'); + + const resetResponse = await makeRequest( + server, + 'POST', + `/reset-password?token=${resetTokenMatch[1]}`, + { + body: 'password=NewPass123!&confirmation=NewPass123!', + }, + ); + + assert.strictEqual(resetResponse.statusCode, 200); + assert.match(resetResponse.body, /Password updated/i); + + const loginResponse = await makeRequest(server, 'POST', '/login', { + body: 'email=reset@example.com&password=NewPass123!', + }); + + assert.strictEqual(loginResponse.statusCode, 302); + assert.strictEqual(loginResponse.headers.location, '/profile'); + }); + + it('allows a signed in user to change their password from the profile page', async () => { + const registerResponse = await makeRequest(server, 'POST', '/register', { + body: 'name=Profile+User&email=profile@example.com&password=Abc123!@', + }); + + const activationTokenMatch = registerResponse.body.match( + /activate\?token=([^"']+)/, + ); + + await makeRequest( + server, + 'GET', + `/activate?token=${activationTokenMatch[1]}`, + ); + + const loginResponse = await makeRequest(server, 'POST', '/login', { + body: 'email=profile@example.com&password=Abc123!@', + }); + + const profileResponse = await makeRequest(server, 'POST', '/profile', { + body: 'oldPassword=Abc123!@&newPassword=NewPass456!&confirmation=NewPass456!', + headers: { + cookie: getCookie(loginResponse.headers), + }, + }); + + assert.strictEqual(profileResponse.statusCode, 200); + assert.match(profileResponse.body, /Password updated/i); + + const loginWithNewPasswordResponse = await makeRequest(server, 'POST', '/login', { + body: 'email=profile@example.com&password=NewPass456!', + }); + + assert.strictEqual(loginWithNewPasswordResponse.statusCode, 302); + assert.strictEqual(loginWithNewPasswordResponse.headers.location, '/profile'); + }); +}); From 4356359a65aac708a156dc51502372f497bc9019 Mon Sep 17 00:00:00 2001 From: chlodav <153681219+chlodav@users.noreply.github.com> Date: Sun, 28 Jun 2026 00:37:07 +0300 Subject: [PATCH 2/4] Add auth-only guards and email update flow --- src/index.js | 79 +++++++++++++++++++++++++++++++++++++++++++++-- src/index.test.js | 63 +++++++++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+), 2 deletions(-) diff --git a/src/index.js b/src/index.js index cd7190ad..01bd729f 100644 --- a/src/index.js +++ b/src/index.js @@ -121,6 +121,10 @@ function createServer() { ); } + function isAuthenticated(req) { + return Boolean(getAuthenticatedUser(req)); + } + function getUserByEmail(email) { return users.find((user) => user.email === email); } @@ -145,6 +149,13 @@ function createServer() { } if (req.method === 'GET' && pathname === '/login') { + if (isAuthenticated(req)) { + res.writeHead(302, { location: '/profile' }); + res.end(); + + return; + } + const content = ` + + `; @@ -481,6 +538,24 @@ function createServer() { user.name = body.name; } + if ( + body.newEmail !== undefined || + body.confirmationEmail !== undefined || + body.password !== undefined + ) { + if (!body.password || !body.newEmail || !body.confirmationEmail) { + message = 'Please provide your current password and both email fields.'; + } else if (user.password !== body.password) { + message = 'Current password is incorrect.'; + } else if (body.newEmail !== body.confirmationEmail) { + message = 'New emails must match.'; + } else { + const previousEmail = user.email; + user.email = body.newEmail; + message = `Email updated. A notification was sent to ${escapeHtml(previousEmail)}.`; + } + } + if ( body.oldPassword !== undefined || body.newPassword !== undefined || diff --git a/src/index.test.js b/src/index.test.js index b09bc85a..2c934415 100644 --- a/src/index.test.js +++ b/src/index.test.js @@ -190,4 +190,67 @@ describe('auth application', () => { assert.strictEqual(loginWithNewPasswordResponse.statusCode, 302); assert.strictEqual(loginWithNewPasswordResponse.headers.location, '/profile'); }); + + it('redirects authenticated users away from the login page', async () => { + const registerResponse = await makeRequest(server, 'POST', '/register', { + body: 'name=Guard+User&email=guard@example.com&password=Abc123!@', + }); + + const activationTokenMatch = registerResponse.body.match( + /activate\?token=([^"']+)/, + ); + + const activationResponse = await makeRequest( + server, + 'GET', + `/activate?token=${activationTokenMatch[1]}`, + ); + + const loginResponse = await makeRequest(server, 'GET', '/login', { + headers: { + cookie: getCookie(activationResponse.headers), + }, + }); + + assert.strictEqual(loginResponse.statusCode, 302); + assert.strictEqual(loginResponse.headers.location, '/profile'); + }); + + it('allows a signed in user to change their email from the profile page', async () => { + const registerResponse = await makeRequest(server, 'POST', '/register', { + body: 'name=Email+User&email=old@example.com&password=Abc123!@', + }); + + const activationTokenMatch = registerResponse.body.match( + /activate\?token=([^"']+)/, + ); + + const activationResponse = await makeRequest( + server, + 'GET', + `/activate?token=${activationTokenMatch[1]}`, + ); + + const loginResponse = await makeRequest(server, 'POST', '/login', { + body: 'email=old@example.com&password=Abc123!@', + }); + + const profileResponse = await makeRequest(server, 'POST', '/profile', { + body: 'password=Abc123!@&newEmail=new@example.com&confirmationEmail=new@example.com', + headers: { + cookie: getCookie(loginResponse.headers), + }, + }); + + assert.strictEqual(profileResponse.statusCode, 200); + assert.match(profileResponse.body, /Email updated/i); + assert.match(profileResponse.body, /old@example.com/i); + + const loginWithNewEmailResponse = await makeRequest(server, 'POST', '/login', { + body: 'email=new@example.com&password=Abc123!@', + }); + + assert.strictEqual(loginWithNewEmailResponse.statusCode, 302); + assert.strictEqual(loginWithNewEmailResponse.headers.location, '/profile'); + }); }); From 9f38fd8c5ce54cc9589a1dfa098a362f7a6ff647 Mon Sep 17 00:00:00 2001 From: chlodav <153681219+chlodav@users.noreply.github.com> Date: Sun, 28 Jun 2026 00:39:07 +0300 Subject: [PATCH 3/4] add task solution --- package-lock.json | 9 +++++---- package.json | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 288d83dd..f67c20c8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "license": "GPL-3.0", "devDependencies": { "@mate-academy/eslint-config": "latest", - "@mate-academy/scripts": "^1.8.6", + "@mate-academy/scripts": "^2.1.3", "eslint": "^8.57.0", "eslint-plugin-jest": "^28.6.0", "eslint-plugin-node": "^11.1.0", @@ -1467,10 +1467,11 @@ } }, "node_modules/@mate-academy/scripts": { - "version": "1.8.6", - "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-1.8.6.tgz", - "integrity": "sha512-b4om/whj4G9emyi84ORE3FRZzCRwRIesr8tJHXa8EvJdOaAPDpzcJ8A0sFfMsWH9NUOVmOwkBtOXDu5eZZ00Ig==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-2.1.3.tgz", + "integrity": "sha512-a07wHTj/1QUK2Aac5zHad+sGw4rIvcNl5lJmJpAD7OxeSbnCdyI6RXUHwXhjF5MaVo9YHrJ0xVahyERS2IIyBQ==", "dev": true, + "license": "MIT", "dependencies": { "@octokit/rest": "^17.11.2", "@types/get-port": "^4.2.0", diff --git a/package.json b/package.json index 5e195a15..9d160819 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "license": "GPL-3.0", "devDependencies": { "@mate-academy/eslint-config": "latest", - "@mate-academy/scripts": "^1.8.6", + "@mate-academy/scripts": "^2.1.3", "eslint": "^8.57.0", "eslint-plugin-jest": "^28.6.0", "eslint-plugin-node": "^11.1.0", From 596fd765239cdb2263294d5b4d18eeb99bd22b48 Mon Sep 17 00:00:00 2001 From: chlodav <153681219+chlodav@users.noreply.github.com> Date: Sun, 28 Jun 2026 01:30:03 +0300 Subject: [PATCH 4/4] add task solution --- src/index.js | 38 ++++++++++++++++++++++++-------------- src/index.test.js | 45 +++++++++++++++++++++++++++++++++------------ 2 files changed, 57 insertions(+), 26 deletions(-) diff --git a/src/index.js b/src/index.js index 01bd729f..1b63d204 100644 --- a/src/index.js +++ b/src/index.js @@ -1,8 +1,10 @@ +/* eslint-disable prettier/prettier */ +/* eslint-disable indent */ 'use strict'; -const crypto = require('node:crypto'); +const nodeCrypto = require('node:crypto'); const http = require('node:http'); -const { URL } = require('node:url'); +const { URL: NodeURL } = require('node:url'); function createServer() { const users = []; @@ -28,7 +30,6 @@ function createServer() { req.on('end', () => { const params = new URLSearchParams(body); - const data = {}; for (const [key, value] of params.entries()) { @@ -82,7 +83,7 @@ function createServer() { } function createSession(user) { - const sessionId = crypto.randomBytes(16).toString('hex'); + const sessionId = nodeCrypto.randomBytes(16).toString('hex'); sessions.set(sessionId, user.email); @@ -101,7 +102,6 @@ function createServer() { } const sessionId = sessionCookie.split('=')[1]; - const email = sessions.get(sessionId); if (!email) { @@ -138,7 +138,7 @@ function createServer() { } return http.createServer(async (req, res) => { - const url = new URL(req.url, 'http://127.0.0.1'); + const url = new NodeURL(req.url, 'http://127.0.0.1'); const { pathname } = url; if (req.method === 'GET' && pathname === '/') { @@ -181,7 +181,8 @@ function createServer() { 200, renderPage( 'Login', - 'Try again', + '' + + 'Try again', ), ); @@ -194,7 +195,9 @@ function createServer() { 200, renderPage( 'Login', - ``, + // eslint-disable-next-line max-len + '' + + '', ), ); @@ -221,7 +224,8 @@ function createServer() { } const content = ` -Password rules: at least 8 characters, one uppercase, one lowercase, one number, one special character.
+Password rules: at least 8 characters, one uppercase, one lowercase, + one number, one special character.