108 lines
4.0 KiB
JavaScript
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 };
|