diff --git a/src/util/entities/Session.ts b/src/util/entities/Session.ts index 8ebe759..1f0e521 100644 --- a/src/util/entities/Session.ts +++ b/src/util/entities/Session.ts @@ -17,19 +17,25 @@ */ import { User } from "./User"; -import { BaseClass } from "./BaseClass"; -import { Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm"; +import { BaseClass, BaseClassWithoutId } from "./BaseClass"; +import { ClientSession, Column, CreateDateColumn, Entity, Index, JoinColumn, ManyToOne, PrimaryColumn, RelationId } from "typeorm"; import { ClientStatus, Status } from "../interfaces/Status"; import { Activity } from "../interfaces/Activity"; +import crypto from "crypto"; +import { randomString, randomUpperString } from "@spacebar/api*"; //TODO we need to remove all sessions on server start because if the server crashes without closing websockets it won't delete them @Entity({ name: "sessions", }) -export class Session extends BaseClass { - @Column({ nullable: true }) +export class Session extends BaseClassWithoutId { + @PrimaryColumn({ nullable: false }) + session_id: string = randomUpperString(); + + @Column() @RelationId((session: Session) => session.user) + @Index({}) user_id: string; @JoinColumn({ name: "user_id" }) @@ -38,10 +44,6 @@ }) user: User; - //TODO check, should be 32 char long hex string - @Column({ nullable: false, select: false }) - session_id: string; - @Column({ type: "simple-json", default: "[]" }) activities: Activity[]; @@ -50,16 +52,45 @@ client: string; os: string; version: number; + location: string; }; @Column({ type: "simple-json" }) client_status: ClientStatus; - @Column({ nullable: false, type: "varchar" }) + @Column({ nullable: false, type: String }) status: Status; //TODO enum + + @Column({ default: false }) + is_admin_session: boolean; + + @CreateDateColumn({ type: Date }) + created_at: Date; + + @Column({ default: 0, type: Date }) + last_seen: Date; + + @Column({ default: "127.0.0.1", type: String }) + last_seen_ip: string; + + @Column({ nullable: true, type: String }) + last_seen_location?: string; + getPublicStatus() { return this.status === "invisible" ? "offline" : this.status; } + + getDiscordDeviceInfo() { + return { + id_hash: crypto.createHash("sha256").update(this.session_id).digest("hex"), + approx_last_used_time: this.last_seen.toISOString(), + client_info: { + os: this.client_info.os, + client: this.client_info.client, + location: this.last_seen_location + }, + }; + } } export const PrivateSessionProjection: (keyof Session)[] = ["user_id", "session_id", "activities", "client_info", "status"]; diff --git a/src/util/migration/postgres/1765863159930-ExtendedSessionInfo.ts b/src/util/migration/postgres/1765863159930-ExtendedSessionInfo.ts new file mode 100644 index 0000000..0ed0998 --- /dev/null +++ b/src/util/migration/postgres/1765863159930-ExtendedSessionInfo.ts @@ -0,0 +1,38 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class ExtendedSessionInfo1765863159930 implements MigrationInterface { + name = 'ExtendedSessionInfo1765863159930' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "sessions" DROP CONSTRAINT "PK_3238ef96f18b355b671619111bc"`); + await queryRunner.query(`ALTER TABLE "sessions" DROP COLUMN "id"`); + await queryRunner.query(`ALTER TABLE "sessions" ADD "is_admin_session" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`ALTER TABLE "sessions" ADD "created_at" TIMESTAMP NOT NULL DEFAULT now()`); + await queryRunner.query(`ALTER TABLE "sessions" ADD "last_seen" TIMESTAMP NOT NULL DEFAULT '1970-01-01 00:00:00'`); + await queryRunner.query(`ALTER TABLE "sessions" ADD "last_seen_ip" character varying NOT NULL DEFAULT '127.0.0.1'`); + await queryRunner.query(`ALTER TABLE "sessions" ADD "last_seen_location" character varying`); + await queryRunner.query(`ALTER TABLE "webhooks" ALTER COLUMN "source_channel_id" DROP DEFAULT`); + await queryRunner.query(`ALTER TABLE "sessions" DROP CONSTRAINT "FK_085d540d9f418cfbdc7bd55bb19"`); + await queryRunner.query(`ALTER TABLE "sessions" ADD CONSTRAINT "PK_9340188c93349808f10d1db74a8" PRIMARY KEY ("session_id")`); + await queryRunner.query(`ALTER TABLE "sessions" ALTER COLUMN "user_id" SET NOT NULL`); + await queryRunner.query(`CREATE INDEX "IDX_085d540d9f418cfbdc7bd55bb1" ON "sessions" ("user_id") `); + await queryRunner.query(`ALTER TABLE "sessions" ADD CONSTRAINT "FK_085d540d9f418cfbdc7bd55bb19" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "sessions" DROP CONSTRAINT "FK_085d540d9f418cfbdc7bd55bb19"`); + await queryRunner.query(`ALTER TABLE "webhooks" DROP CONSTRAINT "FK_4495b7032a33c6b8b605d030398"`); + await queryRunner.query(`DROP INDEX "public"."IDX_085d540d9f418cfbdc7bd55bb1"`); + await queryRunner.query(`ALTER TABLE "sessions" ALTER COLUMN "user_id" DROP NOT NULL`); + await queryRunner.query(`ALTER TABLE "sessions" DROP CONSTRAINT "PK_9340188c93349808f10d1db74a8"`); + await queryRunner.query(`ALTER TABLE "sessions" ADD CONSTRAINT "FK_085d540d9f418cfbdc7bd55bb19" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "sessions" DROP COLUMN "last_seen_location"`); + await queryRunner.query(`ALTER TABLE "sessions" DROP COLUMN "last_seen_ip"`); + await queryRunner.query(`ALTER TABLE "sessions" DROP COLUMN "last_seen"`); + await queryRunner.query(`ALTER TABLE "sessions" DROP COLUMN "created_at"`); + await queryRunner.query(`ALTER TABLE "sessions" DROP COLUMN "is_admin_session"`); + await queryRunner.query(`ALTER TABLE "sessions" ADD "id" character varying NOT NULL`); + await queryRunner.query(`ALTER TABLE "sessions" ADD CONSTRAINT "PK_3238ef96f18b355b671619111bc" PRIMARY KEY ("id")`); + } + +} diff --git a/src/util/util/Token.ts b/src/util/util/Token.ts index bca5839..0ab9636 100644 --- a/src/util/util/Token.ts +++ b/src/util/util/Token.ts @@ -18,13 +18,14 @@ import jwt, { VerifyOptions } from "jsonwebtoken"; import { Config } from "./Config"; -import { InstanceBan, User } from "../entities"; +import { InstanceBan, Session, User } from "../entities"; import crypto from "node:crypto"; import fs from "fs/promises"; import { existsSync } from "fs"; // TODO: dont use deprecated APIs lol import { FindManyOptions, FindOptions, FindOptionsRelationByString, FindOptionsSelect, FindOptionsSelectByString, FindOptionsWhere } from "typeorm"; import * as console from "node:console"; +import { randomUpperString } from "@spacebar/api*"; /// Change history: /// 1 - Initial version with HS256 @@ -34,7 +35,7 @@ export type UserTokenData = { user: User; - // Filled in by checkToken + session?: Session; tokenVersion: number; decoded: { id: string; @@ -136,13 +137,25 @@ } else return reject("Invalid token algorithm"); }); -export async function generateToken(id: string) { +export async function generateToken(id: string, isAdminSession: boolean = false) { const iat = Math.floor(Date.now() / 1000); const keyPair = await loadOrGenerateKeypair(); + let newSession; + do { + newSession = Session.create({ + session_id: randomUpperString(10), // readable at a glance + user_id: id, + is_admin_session: isAdminSession, + }); + } while (await Session.findOne({ where: { session_id: newSession.session_id } })); + + await newSession.save(); + return new Promise((res, rej) => { + const payload = { id, iat, kid: keyPair.fingerprint, ver: CurrentKeyFormatVersion, did: newSession.session_id } as UserTokenData["decoded"]; jwt.sign( - { id, iat, kid: keyPair.fingerprint, ver: CurrentKeyFormatVersion } as UserTokenData["decoded"], + payload, keyPair.privateKey, { algorithm: "ES512",