Greenlens/server/lib/auth.js

108 lines
4.0 KiB
JavaScript

const crypto = require('crypto');
const { get, run } = require('./sqlite');
const JWT_SECRET = process.env.JWT_SECRET || 'greenlens-dev-secret-change-in-prod';
const TOKEN_EXPIRY_SECONDS = 365 * 24 * 3600; // 1 year
// ─── Minimal JWT (HS256, no external deps) ─────────────────────────────────
const b64url = (input) => {
const str = typeof input === 'string' ? input : JSON.stringify(input);
return Buffer.from(str).toString('base64url');
};
const b64urlDecode = (str) => Buffer.from(str, 'base64url').toString();
const signJwt = (payload) => {
const header = b64url({ alg: 'HS256', typ: 'JWT' });
const body = b64url(payload);
const sig = crypto.createHmac('sha256', JWT_SECRET).update(`${header}.${body}`).digest('base64url');
return `${header}.${body}.${sig}`;
};
const verifyJwt = (token) => {
if (!token || typeof token !== 'string') return null;
const parts = token.split('.');
if (parts.length !== 3) return null;
const [header, body, sig] = parts;
const expected = crypto.createHmac('sha256', JWT_SECRET).update(`${header}.${body}`).digest('base64url');
if (sig !== expected) return null;
try {
const payload = JSON.parse(b64urlDecode(body));
if (payload.exp && Math.floor(Date.now() / 1000) > payload.exp) return null;
return payload;
} catch {
return null;
}
};
const issueToken = (userId, email, name) =>
signJwt({
sub: userId,
email,
name,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + TOKEN_EXPIRY_SECONDS,
});
// ─── Password hashing ──────────────────────────────────────────────────────
const hashPassword = (password) =>
crypto.createHmac('sha256', JWT_SECRET).update(password).digest('hex');
// ─── Schema ────────────────────────────────────────────────────────────────
const ensureAuthSchema = async (db) => {
await run(
db,
`CREATE TABLE IF NOT EXISTS auth_users (
id TEXT PRIMARY KEY,
email TEXT NOT NULL UNIQUE COLLATE NOCASE,
name TEXT NOT NULL DEFAULT '',
password_hash TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)`,
);
};
// ─── Operations ───────────────────────────────────────────────────────────
const signUp = async (db, email, name, password) => {
const normalizedEmail = email.trim().toLowerCase();
const existing = await get(db, 'SELECT id FROM auth_users WHERE email = ?', [normalizedEmail]);
if (existing) {
const err = new Error('Email already in use.');
err.code = 'EMAIL_TAKEN';
err.status = 409;
throw err;
}
const id = `usr_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
await run(db, 'INSERT INTO auth_users (id, email, name, password_hash) VALUES (?, ?, ?, ?)', [
id,
normalizedEmail,
name.trim(),
hashPassword(password),
]);
return { id, email: normalizedEmail, name: name.trim() };
};
const login = async (db, email, password) => {
const normalizedEmail = email.trim().toLowerCase();
const user = await get(db, 'SELECT id, email, name, password_hash FROM auth_users WHERE email = ?', [normalizedEmail]);
if (!user) {
const err = new Error('No account found for this email.');
err.code = 'USER_NOT_FOUND';
err.status = 401;
throw err;
}
if (user.password_hash !== hashPassword(password)) {
const err = new Error('Wrong password.');
err.code = 'WRONG_PASSWORD';
err.status = 401;
throw err;
}
return { id: user.id, email: user.email, name: user.name };
};
module.exports = { ensureAuthSchema, signUp, login, issueToken, verifyJwt };