diff --git a/scripts/create-user.mjs b/scripts/create-user.mjs new file mode 100644 index 0000000..5bd0e67 --- /dev/null +++ b/scripts/create-user.mjs @@ -0,0 +1,23 @@ +import Database from 'better-sqlite3'; +import bcrypt from 'bcryptjs'; +import { mkdirSync } from 'node:fs'; +import { dirname } from 'node:path'; + +const [, , username, password] = process.argv; +if (!username || !password) { + console.error('Uso: npm run create-user -- '); + process.exit(1); +} +const path = process.env.DB_PATH ?? 'data/insanitylab.db'; +mkdirSync(dirname(path), { recursive: true }); +const db = new Database(path); +db.exec(`CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL +);`); +db.prepare( + `INSERT INTO users (username, password_hash) VALUES (?, ?) + ON CONFLICT(username) DO UPDATE SET password_hash = excluded.password_hash` +).run(username, bcrypt.hashSync(password, 12)); +console.log(`Utente "${username}" creato/aggiornato in ${path}`); diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..1946702 --- /dev/null +++ b/src/lib/auth.ts @@ -0,0 +1,48 @@ +import type Database from 'better-sqlite3'; +import bcrypt from 'bcryptjs'; +import { randomBytes } from 'node:crypto'; + +export const SESSION_COOKIE = 'session'; +const SESSION_DAYS = 7; + +export function hashPassword(plain: string): string { + return bcrypt.hashSync(plain, 12); +} + +export function verifyPassword(plain: string, hash: string): boolean { + return bcrypt.compareSync(plain, hash); +} + +export function createUser(db: Database.Database, username: string, password: string): number { + const res = db.prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)') + .run(username, hashPassword(password)); + return Number(res.lastInsertRowid); +} + +export function login(db: Database.Database, username: string, password: string): string | null { + const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username) as + | { id: number; password_hash: string } | undefined; + if (!user || !verifyPassword(password, user.password_hash)) return null; + const token = randomBytes(32).toString('hex'); + db.prepare( + `INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, datetime('now', '+${SESSION_DAYS} days'))` + ).run(token, user.id); + return token; +} + +export function getSessionUser(db: Database.Database, token: string): { id: number; username: string } | null { + const row = db.prepare( + `SELECT s.token, s.expires_at, u.id, u.username + FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token = ?` + ).get(token) as { token: string; expires_at: string; id: number; username: string } | undefined; + if (!row) return null; + if (row.expires_at <= new Date().toISOString().slice(0, 19).replace('T', ' ')) { + db.prepare('DELETE FROM sessions WHERE token = ?').run(token); + return null; + } + return { id: row.id, username: row.username }; +} + +export function logout(db: Database.Database, token: string): void { + db.prepare('DELETE FROM sessions WHERE token = ?').run(token); +} diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..fa8dcfd --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,24 @@ +import { defineMiddleware } from 'astro:middleware'; +import { getDb } from './lib/db'; +import { getSessionUser, SESSION_COOKIE } from './lib/auth'; + +export const onRequest = defineMiddleware((context, next) => { + const { pathname } = context.url; + const isProtected = + (pathname.startsWith('/admin') && pathname !== '/admin/login') || + pathname.startsWith('/api/admin'); + if (!isProtected) return next(); + + const token = context.cookies.get(SESSION_COOKIE)?.value; + const user = token ? getSessionUser(getDb(), token) : null; + if (!user) { + if (pathname.startsWith('/api/')) { + return new Response(JSON.stringify({ error: 'Non autorizzato' }), { + status: 401, headers: { 'Content-Type': 'application/json' }, + }); + } + return context.redirect('/admin/login'); + } + context.locals.user = user; + return next(); +}); diff --git a/tests/auth.test.ts b/tests/auth.test.ts new file mode 100644 index 0000000..5eb2459 --- /dev/null +++ b/tests/auth.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import type Database from 'better-sqlite3'; +import { createDb } from '../src/lib/db'; +import { hashPassword, verifyPassword, createUser, login, getSessionUser, logout } from '../src/lib/auth'; + +let db: Database.Database; +beforeEach(() => { db = createDb(':memory:'); }); + +describe('auth', () => { + it('hash e verifica password', () => { + const h = hashPassword('segreta123'); + expect(h).not.toBe('segreta123'); + expect(verifyPassword('segreta123', h)).toBe(true); + expect(verifyPassword('sbagliata', h)).toBe(false); + }); + + it('login corretto crea sessione recuperabile', () => { + createUser(db, 'adriano', 'segreta123'); + const token = login(db, 'adriano', 'segreta123'); + expect(token).toBeTruthy(); + const user = getSessionUser(db, token!); + expect(user).toMatchObject({ username: 'adriano' }); + }); + + it('login errato ritorna null', () => { + createUser(db, 'adriano', 'segreta123'); + expect(login(db, 'adriano', 'sbagliata')).toBeNull(); + expect(login(db, 'inesistente', 'x')).toBeNull(); + }); + + it('sessione scaduta viene rifiutata ed eliminata', () => { + createUser(db, 'adriano', 'segreta123'); + const token = login(db, 'adriano', 'segreta123')!; + db.prepare("UPDATE sessions SET expires_at = datetime('now', '-1 day')").run(); + expect(getSessionUser(db, token)).toBeNull(); + expect(db.prepare('SELECT COUNT(*) AS n FROM sessions').get()).toMatchObject({ n: 0 }); + }); + + it('logout elimina la sessione', () => { + createUser(db, 'adriano', 'segreta123'); + const token = login(db, 'adriano', 'segreta123')!; + logout(db, token); + expect(getSessionUser(db, token)).toBeNull(); + }); +});