feat: user session management (#2056)
* user session management * WIP * cleanup * license * cleanup * don't cache index * rename current device property * fix
This commit is contained in:
@@ -17,6 +17,7 @@ import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
import * as process from 'node:process';
|
||||
import { MigrationService } from '@docmost/db/services/migration.service';
|
||||
import { UserTokenRepo } from './repos/user-token/user-token.repo';
|
||||
import { UserSessionRepo } from '@docmost/db/repos/session/user-session.repo';
|
||||
import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
|
||||
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
||||
import { NotificationRepo } from '@docmost/db/repos/notification/notification.repo';
|
||||
@@ -76,6 +77,7 @@ import { normalizePostgresUrl } from '../common/helpers';
|
||||
CommentRepo,
|
||||
AttachmentRepo,
|
||||
UserTokenRepo,
|
||||
UserSessionRepo,
|
||||
BacklinkRepo,
|
||||
ShareRepo,
|
||||
NotificationRepo,
|
||||
@@ -95,6 +97,7 @@ import { normalizePostgresUrl } from '../common/helpers';
|
||||
CommentRepo,
|
||||
AttachmentRepo,
|
||||
UserTokenRepo,
|
||||
UserSessionRepo,
|
||||
BacklinkRepo,
|
||||
ShareRepo,
|
||||
NotificationRepo,
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.createTable('user_sessions')
|
||||
.addColumn('id', 'uuid', (col) =>
|
||||
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
||||
)
|
||||
.addColumn('user_id', 'uuid', (col) =>
|
||||
col.notNull().references('users.id').onDelete('cascade'),
|
||||
)
|
||||
.addColumn('workspace_id', 'uuid', (col) =>
|
||||
col.notNull().references('workspaces.id').onDelete('cascade'),
|
||||
)
|
||||
.addColumn('device_name', 'varchar')
|
||||
.addColumn('user_agent', 'text')
|
||||
.addColumn('ip_address', sql`inet`)
|
||||
.addColumn('geo_location', 'varchar')
|
||||
.addColumn('last_active_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addColumn('expires_at', 'timestamptz', (col) => col.notNull())
|
||||
.addColumn('metadata', 'jsonb')
|
||||
.addColumn('revoked_at', 'timestamptz')
|
||||
.addColumn('created_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.execute();
|
||||
|
||||
await sql`
|
||||
CREATE INDEX idx_user_sessions_active
|
||||
ON user_sessions (user_id, workspace_id, last_active_at DESC)
|
||||
WHERE revoked_at IS NULL
|
||||
`.execute(db);
|
||||
|
||||
await sql`
|
||||
CREATE INDEX idx_user_sessions_revoked
|
||||
ON user_sessions (expires_at)
|
||||
WHERE revoked_at IS NOT NULL
|
||||
`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await db.schema.dropTable('user_sessions').execute();
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import {
|
||||
InsertableUserSession,
|
||||
UserSession,
|
||||
} from '@docmost/db/types/entity.types';
|
||||
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
||||
import { dbOrTx } from '@docmost/db/utils';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { sql } from 'kysely';
|
||||
|
||||
@Injectable()
|
||||
export class UserSessionRepo {
|
||||
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
||||
|
||||
async insertSession(
|
||||
session: InsertableUserSession,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<UserSession> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
return db
|
||||
.insertInto('userSessions')
|
||||
.values(session)
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
async findActiveById(id: string): Promise<UserSession | undefined> {
|
||||
return this.db
|
||||
.selectFrom('userSessions')
|
||||
.selectAll()
|
||||
.where('id', '=', id)
|
||||
.where('expiresAt', '>', new Date())
|
||||
.where('revokedAt', 'is', null)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async findActiveByUser(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
): Promise<UserSession[]> {
|
||||
return this.db
|
||||
.selectFrom('userSessions')
|
||||
.selectAll()
|
||||
.where('userId', '=', userId)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.where('expiresAt', '>', new Date())
|
||||
.where('revokedAt', 'is', null)
|
||||
.orderBy('lastActiveAt', 'desc')
|
||||
.execute();
|
||||
}
|
||||
|
||||
async updateLastActiveAt(id: string): Promise<void> {
|
||||
await this.db
|
||||
.updateTable('userSessions')
|
||||
.set({ lastActiveAt: new Date() })
|
||||
.where('id', '=', id)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async revokeById(
|
||||
id: string,
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
await this.db
|
||||
.updateTable('userSessions')
|
||||
.set({ revokedAt: new Date() })
|
||||
.where('id', '=', id)
|
||||
.where('userId', '=', userId)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.where('revokedAt', 'is', null)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async revokeAllExceptCurrent(
|
||||
currentSessionId: string,
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
await this.db
|
||||
.updateTable('userSessions')
|
||||
.set({ revokedAt: new Date() })
|
||||
.where('userId', '=', userId)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.where('id', '!=', currentSessionId)
|
||||
.where('revokedAt', 'is', null)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async revokeByUserId(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
await db
|
||||
.updateTable('userSessions')
|
||||
.set({ revokedAt: new Date() })
|
||||
.where('userId', '=', userId)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.where('revokedAt', 'is', null)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async deleteByUserId(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
await this.db
|
||||
.deleteFrom('userSessions')
|
||||
.where('userId', '=', userId)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async deleteAllExceptCurrent(
|
||||
currentSessionId: string,
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
await this.db
|
||||
.deleteFrom('userSessions')
|
||||
.where('userId', '=', userId)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.where('id', '!=', currentSessionId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async deleteStale(retentionDays: number): Promise<void> {
|
||||
const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000);
|
||||
await this.db
|
||||
.deleteFrom('userSessions')
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb('revokedAt', '<', cutoff),
|
||||
eb('expiresAt', '<', cutoff),
|
||||
]),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async trimExcessSessions(maxPerUser: number): Promise<void> {
|
||||
const overflowed = await this.db
|
||||
.selectFrom('userSessions')
|
||||
.select(['userId', 'workspaceId'])
|
||||
.groupBy(['userId', 'workspaceId'])
|
||||
.having(sql`COUNT(*)`, '>', maxPerUser)
|
||||
.execute();
|
||||
|
||||
for (const { userId, workspaceId } of overflowed) {
|
||||
await sql`
|
||||
DELETE FROM user_sessions
|
||||
WHERE id IN (
|
||||
SELECT id FROM user_sessions
|
||||
WHERE user_id = ${userId} AND workspace_id = ${workspaceId}
|
||||
ORDER BY last_active_at DESC
|
||||
OFFSET ${maxPerUser}
|
||||
)
|
||||
`.execute(this.db);
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
@@ -429,6 +429,21 @@ export interface PagePermissions {
|
||||
updatedAt: Generated<Timestamp>;
|
||||
}
|
||||
|
||||
export interface UserSessions {
|
||||
id: Generated<string>;
|
||||
userId: string;
|
||||
workspaceId: string;
|
||||
deviceName: string | null;
|
||||
userAgent: string | null;
|
||||
ipAddress: string | null;
|
||||
geoLocation: string | null;
|
||||
metadata: Json | null;
|
||||
lastActiveAt: Generated<Timestamp>;
|
||||
expiresAt: Timestamp;
|
||||
revokedAt: Timestamp | null;
|
||||
createdAt: Generated<Timestamp>;
|
||||
}
|
||||
|
||||
export interface DB {
|
||||
apiKeys: ApiKeys;
|
||||
attachments: Attachments;
|
||||
@@ -451,6 +466,7 @@ export interface DB {
|
||||
spaces: Spaces;
|
||||
userMfa: UserMfa;
|
||||
users: Users;
|
||||
userSessions: UserSessions;
|
||||
userTokens: UserTokens;
|
||||
watchers: Watchers;
|
||||
workspaceInvitations: WorkspaceInvitations;
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
Shares,
|
||||
FileTasks,
|
||||
UserMfa as _UserMFA,
|
||||
UserSessions,
|
||||
ApiKeys,
|
||||
Watchers,
|
||||
Audit as _Audit,
|
||||
@@ -157,6 +158,11 @@ export type PagePermission = Selectable<_PagePermissions>;
|
||||
export type InsertablePagePermission = Insertable<_PagePermissions>;
|
||||
export type UpdatablePagePermission = Updateable<Omit<_PagePermissions, 'id'>>;
|
||||
|
||||
// User Session
|
||||
export type UserSession = Selectable<UserSessions>;
|
||||
export type InsertableUserSession = Insertable<UserSessions>;
|
||||
export type UpdatableUserSession = Updateable<Omit<UserSessions, 'id'>>;
|
||||
|
||||
// Audit
|
||||
export type Audit = Selectable<_Audit>;
|
||||
export type InsertableAudit = Insertable<_Audit>;
|
||||
|
||||
Reference in New Issue
Block a user