phase-10.5: market screener ui enhancements
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* AuthService — authentication logic.
|
||||
*
|
||||
* JWT: hand-rolled HMAC-SHA256 (no external lib) using Node's built-in crypto.
|
||||
* Passwords: scrypt KDF with random salt (Node crypto, OWASP-recommended).
|
||||
*/
|
||||
|
||||
import { createHmac, randomBytes, scryptSync, timingSafeEqual, randomUUID } from 'crypto';
|
||||
import type { UserStore } from './UserStore.js';
|
||||
import type { AuthResponse, Role, TokenPayload, User } from './auth.model.js';
|
||||
|
||||
// ── JWT helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
function b64url(input: string | Buffer): string {
|
||||
const buf = typeof input === 'string' ? Buffer.from(input) : input;
|
||||
return buf.toString('base64url');
|
||||
}
|
||||
|
||||
function signJwt(payload: TokenPayload, secret: string, expiresInSec = 60 * 60 * 8): string {
|
||||
const header = b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const body = b64url(JSON.stringify({ ...payload, iat: now, exp: now + expiresInSec }));
|
||||
const sig = b64url(createHmac('sha256', secret).update(`${header}.${body}`).digest());
|
||||
return `${header}.${body}.${sig}`;
|
||||
}
|
||||
|
||||
export function verifyJwt(token: string, secret: string): TokenPayload {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) throw new Error('Invalid token format');
|
||||
const [header, body, sig] = parts;
|
||||
const expected = b64url(createHmac('sha256', secret).update(`${header}.${body}`).digest());
|
||||
if (sig !== expected) throw new Error('Invalid token signature');
|
||||
const payload: TokenPayload = JSON.parse(Buffer.from(body, 'base64url').toString());
|
||||
if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) throw new Error('Token expired');
|
||||
return payload;
|
||||
}
|
||||
|
||||
// ── Password helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
const SCRYPT_PARAMS = { N: 16384, r: 8, p: 1, keylen: 32 };
|
||||
|
||||
function hashPassword(plain: string): string {
|
||||
const salt = randomBytes(16).toString('hex');
|
||||
const hash = scryptSync(plain, salt, SCRYPT_PARAMS.keylen, {
|
||||
N: SCRYPT_PARAMS.N,
|
||||
r: SCRYPT_PARAMS.r,
|
||||
p: SCRYPT_PARAMS.p,
|
||||
}).toString('hex');
|
||||
return `${salt}:${hash}`;
|
||||
}
|
||||
|
||||
function verifyPassword(plain: string, stored: string): boolean {
|
||||
const [salt, hash] = stored.split(':');
|
||||
if (!salt || !hash) return false;
|
||||
const attempt = scryptSync(plain, salt, SCRYPT_PARAMS.keylen, {
|
||||
N: SCRYPT_PARAMS.N,
|
||||
r: SCRYPT_PARAMS.r,
|
||||
p: SCRYPT_PARAMS.p,
|
||||
});
|
||||
return timingSafeEqual(Buffer.from(hash, 'hex'), attempt);
|
||||
}
|
||||
|
||||
// ── AuthService ───────────────────────────────────────────────────────────────
|
||||
|
||||
export class AuthService {
|
||||
readonly #store: UserStore;
|
||||
readonly #secret: string;
|
||||
|
||||
constructor(store: UserStore, secret: string) {
|
||||
this.#store = store;
|
||||
this.#secret = secret;
|
||||
}
|
||||
|
||||
register(email: string, password: string, role: Role = 'viewer'): AuthResponse {
|
||||
const existing = this.#store.findByEmail(email);
|
||||
if (existing) throw Object.assign(new Error('Email already registered'), { statusCode: 409 });
|
||||
|
||||
const passwordHash = hashPassword(password);
|
||||
const user = this.#store.create(email, passwordHash, role);
|
||||
const token = this.#issueToken(user);
|
||||
return { token, user };
|
||||
}
|
||||
|
||||
login(email: string, password: string): AuthResponse {
|
||||
const row = this.#store.findByEmail(email);
|
||||
if (!row) throw Object.assign(new Error('Invalid credentials'), { statusCode: 401 });
|
||||
|
||||
const valid = verifyPassword(password, row.password_hash);
|
||||
if (!valid) throw Object.assign(new Error('Invalid credentials'), { statusCode: 401 });
|
||||
|
||||
this.#store.touchLogin(row.id);
|
||||
|
||||
const user: User = {
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
role: row.role,
|
||||
createdAt: row.created_at,
|
||||
lastLogin: row.last_login,
|
||||
};
|
||||
const token = this.#issueToken(user);
|
||||
return { token, user };
|
||||
}
|
||||
|
||||
verify(token: string): TokenPayload {
|
||||
return verifyJwt(token, this.#secret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a password reset token and print the reset link to the console.
|
||||
* Always returns success (no email enumeration).
|
||||
*/
|
||||
forgotPassword(email: string, appOrigin: string): void {
|
||||
this.#store.purgeExpiredTokens();
|
||||
const user = this.#store.findByEmail(email);
|
||||
if (!user) return; // silent — don't reveal whether email exists
|
||||
|
||||
const token = randomUUID().replace(/-/g, ''); // 32-char hex
|
||||
const expiresAt = new Date(Date.now() + 60 * 60 * 1000).toISOString(); // 1 hour
|
||||
this.#store.createResetToken(user.id, token, expiresAt);
|
||||
|
||||
const link = `${appOrigin}/auth/reset-password?token=${token}`;
|
||||
console.log('\n🔐 Password reset requested for:', email);
|
||||
console.log(' Link (expires in 1 hour):');
|
||||
console.log(` ${link}\n`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a reset token and update the user's password.
|
||||
*/
|
||||
resetPassword(token: string, newPassword: string): void {
|
||||
const row = this.#store.findResetToken(token);
|
||||
if (!row) throw Object.assign(new Error('Invalid or expired reset link'), { statusCode: 400 });
|
||||
if (row.used) throw Object.assign(new Error('Reset link already used'), { statusCode: 400 });
|
||||
if (new Date(row.expires_at) < new Date()) {
|
||||
throw Object.assign(new Error('Reset link has expired'), { statusCode: 400 });
|
||||
}
|
||||
|
||||
const passwordHash = hashPassword(newPassword);
|
||||
this.#store.updatePassword(row.user_id, passwordHash);
|
||||
this.#store.markTokenUsed(token);
|
||||
}
|
||||
|
||||
#issueToken(user: User): string {
|
||||
return signJwt({ sub: user.id, email: user.email, role: user.role }, this.#secret);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user