Skip to content
Open
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
95 changes: 95 additions & 0 deletions src/login.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
const crypto = require('crypto');

const { createSessionToken } = require('./signup');

const DEFAULT_MAX_ATTEMPTS = 5;
const DEFAULT_WINDOW_MS = 15 * 60 * 1000;
const GENERIC_ERROR = 'Invalid email or password';

function verifyPassword(password, salt, expectedHash) {
if (typeof password !== 'string' || typeof salt !== 'string' || typeof expectedHash !== 'string') {
return false;
}
const actual = crypto.scryptSync(password, salt, 64);
const expected = Buffer.from(expectedHash, 'hex');
if (actual.length !== expected.length) return false;
return crypto.timingSafeEqual(actual, expected);
}

function createRateLimiter({ maxAttempts = DEFAULT_MAX_ATTEMPTS, windowMs = DEFAULT_WINDOW_MS } = {}) {
const attempts = new Map();

function prune(key, now) {
const list = attempts.get(key);
if (!list) return [];
const fresh = list.filter((t) => now - t < windowMs);
if (fresh.length) attempts.set(key, fresh);
else attempts.delete(key);
return fresh;
}

return {
check: (key, now = Date.now()) => {
const fresh = prune(key, now);
return fresh.length < maxAttempts;
},
recordFailure: (key, now = Date.now()) => {
const fresh = prune(key, now);
fresh.push(now);
attempts.set(key, fresh);
},
reset: (key) => {
attempts.delete(key);
},
};
}

function login(
{ email, password },
{ store, rateLimiter, now = () => new Date() } = {},
) {
if (!store) throw new Error('store is required');
if (!rateLimiter) throw new Error('rateLimiter is required');

const key = typeof email === 'string' ? email.toLowerCase() : '';

if (!rateLimiter.check(key, now().getTime())) {
const err = new Error('Too many login attempts. Please try again later.');
err.code = 'RATE_LIMITED';
throw err;
}

const fail = () => {
rateLimiter.recordFailure(key, now().getTime());
const err = new Error(GENERIC_ERROR);
err.code = 'INVALID_CREDENTIALS';
throw err;
};

if (typeof email !== 'string' || typeof password !== 'string') {
fail();
}

const user = store.findByEmail(email);
if (!user) {
fail();
}

if (!verifyPassword(password, user.passwordSalt, user.passwordHash)) {
fail();
}

rateLimiter.reset(key);

return {
user: { id: user.id, email: user.email, createdAt: user.createdAt },
sessionToken: createSessionToken(),
};
}

module.exports = {
login,
verifyPassword,
createRateLimiter,
GENERIC_ERROR,
};
107 changes: 107 additions & 0 deletions src/password-reset.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
const crypto = require('crypto');

const { hashPassword, validatePassword } = require('./signup');

const DEFAULT_TTL_MS = 60 * 60 * 1000;

function createResetTokenStore() {
const byToken = new Map();
return {
put: (token, userId, expiresAt) => byToken.set(token, { userId, expiresAt }),
get: (token) => byToken.get(token) || null,
delete: (token) => byToken.delete(token),
deleteForUser: (userId) => {
for (const [t, entry] of byToken.entries()) {
if (entry.userId === userId) byToken.delete(t);
}
},
};
}

function createSessionStore() {
const byToken = new Map();
return {
register: (token, userId) => byToken.set(token, userId),
isValid: (token) => byToken.has(token),
userFor: (token) => byToken.get(token) || null,
invalidateAllForUser: (userId) => {
for (const [t, uid] of byToken.entries()) {
if (uid === userId) byToken.delete(t);
}
},
size: () => byToken.size,
};
}

function requestPasswordReset(
{ email },
{ store, resetTokens, ttlMs = DEFAULT_TTL_MS, now = () => new Date() } = {},
) {
if (!store) throw new Error('store is required');
if (!resetTokens) throw new Error('resetTokens is required');

if (typeof email !== 'string') return null;

const user = store.findByEmail(email);
if (!user) return null;

resetTokens.deleteForUser(user.id);

const token = crypto.randomBytes(32).toString('hex');
const expiresAt = now().getTime() + ttlMs;
resetTokens.put(token, user.id, expiresAt);
return { token, expiresAt };
}

function resetPassword(
{ token, newPassword },
{ store, resetTokens, sessionStore, now = () => new Date() } = {},
) {
if (!store) throw new Error('store is required');
if (!resetTokens) throw new Error('resetTokens is required');
if (!sessionStore) throw new Error('sessionStore is required');

const invalid = () => {
const err = new Error('Invalid or expired reset token');
err.code = 'INVALID_TOKEN';
return err;
};

const entry = typeof token === 'string' ? resetTokens.get(token) : null;
if (!entry) throw invalid();

if (now().getTime() > entry.expiresAt) {
resetTokens.delete(token);
throw invalid();
}

const pwError = validatePassword(newPassword);
if (pwError) {
const err = new Error(pwError);
err.code = 'WEAK_PASSWORD';
throw err;
}

const user = store.findById(entry.userId);
if (!user) {
resetTokens.delete(token);
throw invalid();
}

const { salt, hash } = hashPassword(newPassword);
user.passwordSalt = salt;
user.passwordHash = hash;

resetTokens.delete(token);
sessionStore.invalidateAllForUser(user.id);

return { userId: user.id };
}

module.exports = {
requestPasswordReset,
resetPassword,
createResetTokenStore,
createSessionStore,
DEFAULT_TTL_MS,
};
91 changes: 91 additions & 0 deletions src/signup.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
const crypto = require('crypto');

const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

function validateEmail(email) {
if (typeof email !== 'string' || !EMAIL_RE.test(email)) {
return 'Invalid email format';
}
return null;
}

function validatePassword(password) {
if (typeof password !== 'string' || password.length < 8) {
return 'Password must be at least 8 characters';
}
if (!/[A-Z]/.test(password)) return 'Password must contain an uppercase letter';
if (!/[a-z]/.test(password)) return 'Password must contain a lowercase letter';
if (!/[0-9]/.test(password)) return 'Password must contain a number';
return null;
}

function hashPassword(password, salt) {
const s = salt || crypto.randomBytes(16).toString('hex');
const hash = crypto.scryptSync(password, s, 64).toString('hex');
return { salt: s, hash };
}

function createUserStore() {
const byEmail = new Map();
const byId = new Map();
return {
findByEmail: (email) => byEmail.get(email.toLowerCase()) || null,
findById: (id) => byId.get(id) || null,
insert: (user) => {
byEmail.set(user.email.toLowerCase(), user);
byId.set(user.id, user);
return user;
},
all: () => Array.from(byEmail.values()),
};
}

function createSessionToken() {
return crypto.randomBytes(32).toString('hex');
}

function signup({ email, password }, { store, now = () => new Date() } = {}) {
const emailError = validateEmail(email);
if (emailError) {
const err = new Error(emailError);
err.code = 'INVALID_EMAIL';
throw err;
}
const pwError = validatePassword(password);
if (pwError) {
const err = new Error(pwError);
err.code = 'WEAK_PASSWORD';
throw err;
}
if (!store) throw new Error('store is required');

if (store.findByEmail(email)) {
const err = new Error('An account with this email already exists');
err.code = 'EMAIL_TAKEN';
throw err;
}

const { salt, hash } = hashPassword(password);
const user = {
id: crypto.randomUUID(),
email: email.toLowerCase(),
passwordSalt: salt,
passwordHash: hash,
createdAt: now().toISOString(),
};
store.insert(user);

return {
user: { id: user.id, email: user.email, createdAt: user.createdAt },
sessionToken: createSessionToken(),
};
}

module.exports = {
signup,
validateEmail,
validatePassword,
hashPassword,
createUserStore,
createSessionToken,
};
Loading
Loading