147 lines
5.7 KiB
TypeScript
147 lines
5.7 KiB
TypeScript
/**
|
|
* 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);
|
|
}
|
|
}
|