218 lines
6.5 KiB
TypeScript
218 lines
6.5 KiB
TypeScript
import { Database } from "bun:sqlite";
|
|
|
|
const DB_PATH = "./musicroom.db";
|
|
const SESSION_EXPIRY_DAYS = 7;
|
|
|
|
const db = new Database(DB_PATH);
|
|
|
|
// Initialize tables
|
|
db.run(`
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
username TEXT UNIQUE NOT NULL,
|
|
password_hash TEXT NOT NULL,
|
|
is_admin INTEGER DEFAULT 0,
|
|
created_at INTEGER DEFAULT (unixepoch())
|
|
)
|
|
`);
|
|
|
|
db.run(`
|
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
token TEXT PRIMARY KEY,
|
|
user_id INTEGER NOT NULL,
|
|
expires_at INTEGER NOT NULL,
|
|
created_at INTEGER DEFAULT (unixepoch()),
|
|
user_agent TEXT,
|
|
ip_address TEXT,
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
)
|
|
`);
|
|
|
|
// Migration: add columns if they don't exist
|
|
try {
|
|
db.run(`ALTER TABLE sessions ADD COLUMN user_agent TEXT`);
|
|
} catch {}
|
|
try {
|
|
db.run(`ALTER TABLE sessions ADD COLUMN ip_address TEXT`);
|
|
} catch {}
|
|
|
|
db.run(`
|
|
CREATE TABLE IF NOT EXISTS permissions (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL,
|
|
resource_type TEXT NOT NULL,
|
|
resource_id TEXT,
|
|
permission TEXT NOT NULL,
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
UNIQUE(user_id, resource_type, resource_id, permission)
|
|
)
|
|
`);
|
|
|
|
// Types
|
|
export interface User {
|
|
id: number;
|
|
username: string;
|
|
password_hash: string;
|
|
is_admin: boolean;
|
|
created_at: number;
|
|
}
|
|
|
|
export interface Session {
|
|
token: string;
|
|
user_id: number;
|
|
expires_at: number;
|
|
created_at: number;
|
|
user_agent: string | null;
|
|
ip_address: string | null;
|
|
}
|
|
|
|
export interface Permission {
|
|
id: number;
|
|
user_id: number;
|
|
resource_type: string;
|
|
resource_id: string | null;
|
|
permission: string;
|
|
}
|
|
|
|
// User functions
|
|
export async function createUser(username: string, password: string): Promise<User> {
|
|
const password_hash = await Bun.password.hash(password);
|
|
|
|
// First user becomes admin
|
|
const userCount = db.query("SELECT COUNT(*) as count FROM users").get() as { count: number };
|
|
const is_admin = userCount.count === 0 ? 1 : 0;
|
|
|
|
const result = db.query(
|
|
"INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, ?) RETURNING *"
|
|
).get(username, password_hash, is_admin) as any;
|
|
|
|
return { ...result, is_admin: !!result.is_admin };
|
|
}
|
|
|
|
export function findUserById(id: number): User | null {
|
|
const result = db.query("SELECT * FROM users WHERE id = ?").get(id) as any;
|
|
if (!result) return null;
|
|
return { ...result, is_admin: !!result.is_admin };
|
|
}
|
|
|
|
export function findUserByUsername(username: string): User | null {
|
|
const result = db.query("SELECT * FROM users WHERE username = ?").get(username) as any;
|
|
if (!result) return null;
|
|
return { ...result, is_admin: !!result.is_admin };
|
|
}
|
|
|
|
export async function validatePassword(user: User, password: string): Promise<boolean> {
|
|
return Bun.password.verify(password, user.password_hash);
|
|
}
|
|
|
|
// Session functions
|
|
export function createSession(userId: number, userAgent?: string, ipAddress?: string): string {
|
|
const token = crypto.randomUUID();
|
|
const expires_at = Math.floor(Date.now() / 1000) + (SESSION_EXPIRY_DAYS * 24 * 60 * 60);
|
|
|
|
db.query("INSERT INTO sessions (token, user_id, expires_at, user_agent, ip_address) VALUES (?, ?, ?, ?, ?)")
|
|
.run(token, userId, expires_at, userAgent ?? null, ipAddress ?? null);
|
|
|
|
return token;
|
|
}
|
|
|
|
export function validateSession(token: string, currentUserAgent?: string, currentIpAddress?: string): User | null {
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const session = db.query(
|
|
"SELECT * FROM sessions WHERE token = ? AND expires_at > ?"
|
|
).get(token, now) as Session | null;
|
|
|
|
if (!session) return null;
|
|
|
|
// Invalidate if BOTH ip and user agent changed (potential session hijack)
|
|
if (currentUserAgent && currentIpAddress && session.user_agent && session.ip_address) {
|
|
const ipChanged = session.ip_address !== currentIpAddress;
|
|
const uaChanged = session.user_agent !== currentUserAgent;
|
|
if (ipChanged && uaChanged) {
|
|
console.log(`[AUTH] Session invalidated (ip+ua changed): session=${token} old_ip=${session.ip_address} new_ip=${currentIpAddress}`);
|
|
deleteSession(token);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Sliding expiration - extend on each use
|
|
const newExpiry = now + (SESSION_EXPIRY_DAYS * 24 * 60 * 60);
|
|
db.query("UPDATE sessions SET expires_at = ? WHERE token = ?").run(newExpiry, token);
|
|
|
|
return findUserById(session.user_id);
|
|
}
|
|
|
|
export function deleteSession(token: string): void {
|
|
db.query("DELETE FROM sessions WHERE token = ?").run(token);
|
|
}
|
|
|
|
export function deleteExpiredSessions(): void {
|
|
const now = Math.floor(Date.now() / 1000);
|
|
db.query("DELETE FROM sessions WHERE expires_at <= ?").run(now);
|
|
}
|
|
|
|
// Permission functions
|
|
export function hasPermission(
|
|
userId: number,
|
|
resourceType: string,
|
|
resourceId: string | null,
|
|
permission: string
|
|
): boolean {
|
|
const user = findUserById(userId);
|
|
if (!user) return false;
|
|
if (user.is_admin) return true;
|
|
|
|
const result = db.query(`
|
|
SELECT 1 FROM permissions
|
|
WHERE user_id = ?
|
|
AND resource_type = ?
|
|
AND (resource_id = ? OR resource_id IS NULL)
|
|
AND permission = ?
|
|
LIMIT 1
|
|
`).get(userId, resourceType, resourceId, permission);
|
|
|
|
return !!result;
|
|
}
|
|
|
|
export function grantPermission(
|
|
userId: number,
|
|
resourceType: string,
|
|
resourceId: string | null,
|
|
permission: string
|
|
): void {
|
|
db.query(`
|
|
INSERT OR IGNORE INTO permissions (user_id, resource_type, resource_id, permission)
|
|
VALUES (?, ?, ?, ?)
|
|
`).run(userId, resourceType, resourceId, permission);
|
|
}
|
|
|
|
export function revokePermission(
|
|
userId: number,
|
|
resourceType: string,
|
|
resourceId: string | null,
|
|
permission: string
|
|
): void {
|
|
db.query(`
|
|
DELETE FROM permissions
|
|
WHERE user_id = ? AND resource_type = ? AND resource_id IS ? AND permission = ?
|
|
`).run(userId, resourceType, resourceId, permission);
|
|
}
|
|
|
|
export function getUserPermissions(userId: number): Permission[] {
|
|
return db.query("SELECT * FROM permissions WHERE user_id = ?").all(userId) as Permission[];
|
|
}
|
|
|
|
export function getAllUsers(): Omit<User, 'password_hash'>[] {
|
|
const users = db.query("SELECT id, username, is_admin, created_at FROM users").all() as any[];
|
|
return users.map(u => ({ ...u, is_admin: !!u.is_admin }));
|
|
}
|
|
|
|
export function getUserSessions(userId: number): Omit<Session, 'token'>[] {
|
|
return db.query(
|
|
"SELECT user_id, expires_at, created_at, user_agent, ip_address FROM sessions WHERE user_id = ? AND expires_at > ?"
|
|
).all(userId, Math.floor(Date.now() / 1000)) as Omit<Session, 'token'>[];
|
|
}
|
|
|
|
// Cleanup expired sessions periodically
|
|
setInterval(() => deleteExpiredSessions(), 60 * 60 * 1000); // Every hour
|