feat: T27 AuthService DB session persistence - replace in-memory Map with SQLite sessions table
This commit is contained in:
@@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
import bcrypt from 'bcrypt';
|
import bcrypt from 'bcrypt';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import type { DatabaseInterface, DBUser } from '../database/DatabaseInterface.js';
|
import type { DatabaseInterface, DBUser, DBSession } from '../database/DatabaseInterface.js';
|
||||||
|
|
||||||
const SALT_ROUNDS = 10;
|
const SALT_ROUNDS = 10;
|
||||||
const SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
const SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||||
@@ -20,8 +20,6 @@ export interface AuthResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
private sessions = new Map<string, Session>();
|
|
||||||
|
|
||||||
constructor(private db: DatabaseInterface) {}
|
constructor(private db: DatabaseInterface) {}
|
||||||
|
|
||||||
async register(email: string, password: string, name: string, role: string = 'planer'): Promise<AuthResult> {
|
async register(email: string, password: string, name: string, role: string = 'planer'): Promise<AuthResult> {
|
||||||
@@ -64,27 +62,27 @@ export class AuthService {
|
|||||||
|
|
||||||
createSession(userId: string): Session {
|
createSession(userId: string): Session {
|
||||||
const token = randomUUID();
|
const token = randomUUID();
|
||||||
const session: Session = {
|
const expiresAt = Date.now() + SESSION_TTL_MS;
|
||||||
token,
|
this.db.createSession({ token, user_id: userId, expires_at: expiresAt });
|
||||||
userId,
|
return { token, userId, expiresAt };
|
||||||
expiresAt: Date.now() + SESSION_TTL_MS,
|
|
||||||
};
|
|
||||||
this.sessions.set(token, session);
|
|
||||||
return session;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
validateSession(token: string): Session | null {
|
validateSession(token: string): Session | null {
|
||||||
const session = this.sessions.get(token);
|
const dbSession = this.db.getSession(token);
|
||||||
if (!session) return null;
|
if (!dbSession) return null;
|
||||||
if (Date.now() > session.expiresAt) {
|
if (Date.now() > dbSession.expires_at) {
|
||||||
this.sessions.delete(token);
|
this.db.deleteSession(token);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return session;
|
return {
|
||||||
|
token: dbSession.token,
|
||||||
|
userId: dbSession.user_id,
|
||||||
|
expiresAt: dbSession.expires_at,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
destroySession(token: string): void {
|
destroySession(token: string): void {
|
||||||
this.sessions.delete(token);
|
this.db.deleteSession(token);
|
||||||
}
|
}
|
||||||
|
|
||||||
getUserFromSession(token: string): DBUser | null {
|
getUserFromSession(token: string): DBUser | null {
|
||||||
|
|||||||
@@ -72,6 +72,13 @@ export interface DBUser {
|
|||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DBSession {
|
||||||
|
token: string;
|
||||||
|
user_id: string;
|
||||||
|
expires_at: number;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DatabaseInterface {
|
export interface DatabaseInterface {
|
||||||
init(): Promise<void>;
|
init(): Promise<void>;
|
||||||
close(): void;
|
close(): void;
|
||||||
@@ -119,4 +126,10 @@ export interface DatabaseInterface {
|
|||||||
// Settings
|
// Settings
|
||||||
getSetting(key: string): DBSetting | null;
|
getSetting(key: string): DBSetting | null;
|
||||||
setSetting(key: string, value: string): void;
|
setSetting(key: string, value: string): void;
|
||||||
|
|
||||||
|
// Sessions
|
||||||
|
createSession(data: Partial<DBSession>): DBSession;
|
||||||
|
getSession(token: string): DBSession | null;
|
||||||
|
deleteSession(token: string): boolean;
|
||||||
|
deleteExpiredSessions(): void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ import Database from 'better-sqlite3';
|
|||||||
import { readFileSync } from 'fs';
|
import { readFileSync } from 'fs';
|
||||||
import { join, dirname } from 'path';
|
import { join, dirname } from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
import type {
|
import type {
|
||||||
DatabaseInterface, DBProject, DBDrawing, DBLayer,
|
DatabaseInterface, DBProject, DBDrawing, DBLayer,
|
||||||
DBElement, DBBlock, DBSetting, DBUser,
|
DBElement, DBBlock, DBSetting, DBUser, DBSession,
|
||||||
} from './DatabaseInterface.js';
|
} from './DatabaseInterface.js';
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
@@ -243,4 +244,25 @@ export class SqliteAdapter implements DatabaseInterface {
|
|||||||
"INSERT INTO settings (key, value, updated_at) VALUES (?, ?, datetime('now')) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')",
|
"INSERT INTO settings (key, value, updated_at) VALUES (?, ?, datetime('now')) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')",
|
||||||
).run(key, value);
|
).run(key, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Sessions ───────────────────────────────────────
|
||||||
|
createSession(data: Partial<DBSession>): DBSession {
|
||||||
|
const token = data.token ?? randomUUID();
|
||||||
|
this.db.prepare(
|
||||||
|
'INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)',
|
||||||
|
).run(token, data.user_id!, data.expires_at!);
|
||||||
|
return this.getSession(token)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
getSession(token: string): DBSession | null {
|
||||||
|
return (this.db.prepare('SELECT * FROM sessions WHERE token = ?').get(token) as DBSession) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteSession(token: string): boolean {
|
||||||
|
return this.db.prepare('DELETE FROM sessions WHERE token = ?').run(token).changes > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteExpiredSessions(): void {
|
||||||
|
this.db.prepare('DELETE FROM sessions WHERE expires_at < ?').run(Date.now());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,6 +83,15 @@ CREATE TABLE IF NOT EXISTS blocks (
|
|||||||
FOREIGN KEY (drawing_id) REFERENCES drawings(id) ON DELETE CASCADE
|
FOREIGN KEY (drawing_id) REFERENCES drawings(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- ─── Sessions ──────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
token TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
expires_at INTEGER NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
-- ─── Settings ───────────────────────────────────────
|
-- ─── Settings ───────────────────────────────────────
|
||||||
CREATE TABLE IF NOT EXISTS settings (
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
|
|||||||
Reference in New Issue
Block a user