mirror of
https://github.com/bretzel-app/crumbs.git
synced 2026-10-13 04:06:57 +02:00
feat(auth): session list and revocation
Track session metadata (createdAt, userAgent, ip, lastUsedAt). Add API for listing and revoking sessions. Settings page shows active sessions with "Revoke" per session and "Log out everywhere else" option. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
committed by
Fred Cabassut
co-authored by
Claude Opus 4.6
parent
34c95fae92
commit
e155251f27
+64
-2
@@ -65,13 +65,21 @@ export async function verifyPassword(
|
||||
return valid ? toUser(user) : null;
|
||||
}
|
||||
|
||||
export async function createSession(userId: number): Promise<string> {
|
||||
export async function createSession(
|
||||
userId: number,
|
||||
meta?: { userAgent?: string; ip?: string }
|
||||
): Promise<string> {
|
||||
const token = randomBytes(32).toString('hex');
|
||||
const now = new Date();
|
||||
db.insert(sessions)
|
||||
.values({
|
||||
id: token,
|
||||
userId,
|
||||
expiresAt: new Date(Date.now() + SESSION_DURATION_MS)
|
||||
expiresAt: new Date(Date.now() + SESSION_DURATION_MS),
|
||||
createdAt: now,
|
||||
userAgent: meta?.userAgent || null,
|
||||
ip: meta?.ip || null,
|
||||
lastUsedAt: now
|
||||
})
|
||||
.run();
|
||||
return token;
|
||||
@@ -108,6 +116,12 @@ export async function validateSession(
|
||||
return { valid: false };
|
||||
}
|
||||
|
||||
// Update lastUsedAt
|
||||
db.update(sessions)
|
||||
.set({ lastUsedAt: new Date() })
|
||||
.where(eq(sessions.id, token))
|
||||
.run();
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
user: {
|
||||
@@ -127,6 +141,54 @@ export async function cleanExpiredSessions(): Promise<void> {
|
||||
db.delete(sessions).where(lt(sessions.expiresAt, new Date())).run();
|
||||
}
|
||||
|
||||
export type SessionInfo = {
|
||||
id: string;
|
||||
createdAt: Date | null;
|
||||
userAgent: string | null;
|
||||
ip: string | null;
|
||||
lastUsedAt: Date | null;
|
||||
expiresAt: Date;
|
||||
isCurrent: boolean;
|
||||
};
|
||||
|
||||
export function listUserSessions(userId: number, currentToken: string): SessionInfo[] {
|
||||
return db
|
||||
.select()
|
||||
.from(sessions)
|
||||
.where(eq(sessions.userId, userId))
|
||||
.all()
|
||||
.map((s) => ({
|
||||
id: s.id,
|
||||
createdAt: s.createdAt,
|
||||
userAgent: s.userAgent,
|
||||
ip: s.ip,
|
||||
lastUsedAt: s.lastUsedAt,
|
||||
expiresAt: s.expiresAt,
|
||||
isCurrent: s.id === currentToken
|
||||
}));
|
||||
}
|
||||
|
||||
export function revokeSession(userId: number, sessionId: string): boolean {
|
||||
const session = db
|
||||
.select()
|
||||
.from(sessions)
|
||||
.where(and(eq(sessions.id, sessionId), eq(sessions.userId, userId)))
|
||||
.get();
|
||||
if (!session) return false;
|
||||
db.delete(sessions).where(eq(sessions.id, sessionId)).run();
|
||||
return true;
|
||||
}
|
||||
|
||||
export function revokeAllSessions(userId: number, exceptToken?: string): void {
|
||||
if (exceptToken) {
|
||||
sqlite
|
||||
.prepare('DELETE FROM sessions WHERE user_id = ? AND id != ?')
|
||||
.run(userId, exceptToken);
|
||||
} else {
|
||||
db.delete(sessions).where(eq(sessions.userId, userId)).run();
|
||||
}
|
||||
}
|
||||
|
||||
// --- User CRUD (admin operations) ---
|
||||
|
||||
export async function createUser(
|
||||
|
||||
@@ -36,7 +36,11 @@ sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
expires_at INTEGER NOT NULL
|
||||
expires_at INTEGER NOT NULL,
|
||||
created_at INTEGER,
|
||||
user_agent TEXT,
|
||||
ip TEXT,
|
||||
last_used_at INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notes (
|
||||
@@ -141,6 +145,19 @@ if (!hasEmail) {
|
||||
}
|
||||
}
|
||||
|
||||
// Migration: add session tracking columns
|
||||
const sessionColumns = sqlite
|
||||
.prepare("PRAGMA table_info('sessions')")
|
||||
.all() as { name: string }[];
|
||||
if (!sessionColumns.some((col) => col.name === 'created_at')) {
|
||||
sqlite.exec(`
|
||||
ALTER TABLE sessions ADD COLUMN created_at INTEGER;
|
||||
ALTER TABLE sessions ADD COLUMN user_agent TEXT;
|
||||
ALTER TABLE sessions ADD COLUMN ip TEXT;
|
||||
ALTER TABLE sessions ADD COLUMN last_used_at INTEGER;
|
||||
`);
|
||||
}
|
||||
|
||||
// Create indexes AFTER migration ensures columns exist
|
||||
sqlite.exec(`
|
||||
CREATE INDEX IF NOT EXISTS notes_user_id_idx ON notes(user_id);
|
||||
|
||||
@@ -16,7 +16,11 @@ export const sessions = sqliteTable('sessions', {
|
||||
userId: integer('user_id')
|
||||
.references(() => users.id)
|
||||
.notNull(),
|
||||
expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull()
|
||||
expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull(),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }),
|
||||
userAgent: text('user_agent'),
|
||||
ip: text('ip'),
|
||||
lastUsedAt: integer('last_used_at', { mode: 'timestamp' })
|
||||
});
|
||||
|
||||
export const notes = sqliteTable(
|
||||
|
||||
@@ -27,7 +27,11 @@ export function createTestDb() {
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
expires_at INTEGER NOT NULL
|
||||
expires_at INTEGER NOT NULL,
|
||||
created_at INTEGER,
|
||||
user_agent TEXT,
|
||||
ip TEXT,
|
||||
last_used_at INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE notes (
|
||||
|
||||
Reference in New Issue
Block a user