Add internal session foundation
This commit is contained in:
parent
a479d0ddd3
commit
6bf096f6c2
@ -0,0 +1,30 @@
|
|||||||
|
CREATE TYPE "AccountSessionStatus" AS ENUM ('ACTIVE', 'REVOKED', 'EXPIRED');
|
||||||
|
|
||||||
|
CREATE TABLE "account_sessions" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"user_id" UUID NOT NULL,
|
||||||
|
"refresh_token_hash" TEXT NOT NULL,
|
||||||
|
"status" "AccountSessionStatus" NOT NULL DEFAULT 'ACTIVE',
|
||||||
|
"access_token_version" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
"expires_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
"last_refreshed_at" TIMESTAMP(3),
|
||||||
|
"revoked_at" TIMESTAMP(3),
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "account_sessions_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "account_sessions_refresh_token_hash_key"
|
||||||
|
ON "account_sessions"("refresh_token_hash");
|
||||||
|
|
||||||
|
CREATE INDEX "account_sessions_user_id_idx"
|
||||||
|
ON "account_sessions"("user_id");
|
||||||
|
|
||||||
|
CREATE INDEX "account_sessions_status_expires_at_idx"
|
||||||
|
ON "account_sessions"("status", "expires_at");
|
||||||
|
|
||||||
|
ALTER TABLE "account_sessions"
|
||||||
|
ADD CONSTRAINT "account_sessions_user_id_fkey"
|
||||||
|
FOREIGN KEY ("user_id") REFERENCES "users"("id")
|
||||||
|
ON DELETE CASCADE
|
||||||
|
ON UPDATE CASCADE;
|
||||||
@ -33,6 +33,7 @@ model User {
|
|||||||
outgoingTransfers OwnershipTransfer[] @relation("OwnershipTransferFromUser")
|
outgoingTransfers OwnershipTransfer[] @relation("OwnershipTransferFromUser")
|
||||||
incomingTransfers OwnershipTransfer[] @relation("OwnershipTransferToUser")
|
incomingTransfers OwnershipTransfer[] @relation("OwnershipTransferToUser")
|
||||||
initiatedTransfers OwnershipTransfer[] @relation("OwnershipTransferRequestedByUser")
|
initiatedTransfers OwnershipTransfer[] @relation("OwnershipTransferRequestedByUser")
|
||||||
|
accountSessions AccountSession[]
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
@ -282,6 +283,24 @@ model OwnershipTransfer {
|
|||||||
@@map("ownership_transfers")
|
@@map("ownership_transfers")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model AccountSession {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
userId String @map("user_id") @db.Uuid
|
||||||
|
refreshTokenHash String @unique @map("refresh_token_hash")
|
||||||
|
status AccountSessionStatus @default(ACTIVE)
|
||||||
|
accessTokenVersion Int @default(1) @map("access_token_version")
|
||||||
|
expiresAt DateTime @map("expires_at")
|
||||||
|
lastRefreshedAt DateTime? @map("last_refreshed_at")
|
||||||
|
revokedAt DateTime? @map("revoked_at")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@index([status, expiresAt])
|
||||||
|
@@map("account_sessions")
|
||||||
|
}
|
||||||
|
|
||||||
enum UserAccountKind {
|
enum UserAccountKind {
|
||||||
LEGACY_DEFAULT
|
LEGACY_DEFAULT
|
||||||
GUEST
|
GUEST
|
||||||
@ -294,6 +313,12 @@ enum UserAccountStatus {
|
|||||||
DELETED
|
DELETED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum AccountSessionStatus {
|
||||||
|
ACTIVE
|
||||||
|
REVOKED
|
||||||
|
EXPIRED
|
||||||
|
}
|
||||||
|
|
||||||
enum DevicePlatform {
|
enum DevicePlatform {
|
||||||
MACOS
|
MACOS
|
||||||
IPHONE
|
IPHONE
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { DeviceAuthGuard } from './device-auth.guard';
|
|||||||
import { DeviceAuthService } from './device-auth.service';
|
import { DeviceAuthService } from './device-auth.service';
|
||||||
import { OptionalDeviceAuthGuard } from './optional-device-auth.guard';
|
import { OptionalDeviceAuthGuard } from './optional-device-auth.guard';
|
||||||
import { ProtectedDeviceAuthMiddleware } from './protected-device-auth.middleware';
|
import { ProtectedDeviceAuthMiddleware } from './protected-device-auth.middleware';
|
||||||
|
import { SessionService } from './session.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule, RequestContextModule],
|
imports: [PrismaModule, RequestContextModule],
|
||||||
@ -14,12 +15,14 @@ import { ProtectedDeviceAuthMiddleware } from './protected-device-auth.middlewar
|
|||||||
DeviceAuthGuard,
|
DeviceAuthGuard,
|
||||||
OptionalDeviceAuthGuard,
|
OptionalDeviceAuthGuard,
|
||||||
ProtectedDeviceAuthMiddleware,
|
ProtectedDeviceAuthMiddleware,
|
||||||
|
SessionService,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
DeviceAuthService,
|
DeviceAuthService,
|
||||||
DeviceAuthGuard,
|
DeviceAuthGuard,
|
||||||
OptionalDeviceAuthGuard,
|
OptionalDeviceAuthGuard,
|
||||||
ProtectedDeviceAuthMiddleware,
|
ProtectedDeviceAuthMiddleware,
|
||||||
|
SessionService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AuthModule implements NestModule {
|
export class AuthModule implements NestModule {
|
||||||
|
|||||||
389
backend/src/modules/auth/session.service.spec.ts
Normal file
389
backend/src/modules/auth/session.service.spec.ts
Normal file
@ -0,0 +1,389 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
AccountSessionStatus,
|
||||||
|
UserAccountStatus,
|
||||||
|
} from '@prisma/client';
|
||||||
|
import { SessionService } from './session.service';
|
||||||
|
|
||||||
|
describe('SessionService', () => {
|
||||||
|
const now = new Date('2026-06-27T18:00:00.000Z');
|
||||||
|
const future = new Date('2026-07-27T18:00:00.000Z');
|
||||||
|
const activeUserId = randomUUID();
|
||||||
|
const lockedUserId = randomUUID();
|
||||||
|
const deletedUserId = randomUUID();
|
||||||
|
|
||||||
|
const buildService = () => {
|
||||||
|
const users = new Map<string, any>([
|
||||||
|
[
|
||||||
|
activeUserId,
|
||||||
|
{
|
||||||
|
id: activeUserId,
|
||||||
|
accountStatus: UserAccountStatus.ACTIVE,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
lockedUserId,
|
||||||
|
{
|
||||||
|
id: lockedUserId,
|
||||||
|
accountStatus: UserAccountStatus.LOCKED,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
deletedUserId,
|
||||||
|
{
|
||||||
|
id: deletedUserId,
|
||||||
|
accountStatus: UserAccountStatus.DELETED,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
const sessions = new Map<string, any>();
|
||||||
|
|
||||||
|
const accountSession = {
|
||||||
|
create: jest.fn().mockImplementation(async ({ data }) => {
|
||||||
|
const session = {
|
||||||
|
id: randomUUID(),
|
||||||
|
userId: data.userId,
|
||||||
|
refreshTokenHash: data.refreshTokenHash,
|
||||||
|
status: data.status ?? AccountSessionStatus.ACTIVE,
|
||||||
|
accessTokenVersion: data.accessTokenVersion ?? 1,
|
||||||
|
expiresAt: data.expiresAt,
|
||||||
|
lastRefreshedAt: null,
|
||||||
|
revokedAt: null,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
sessions.set(session.id, session);
|
||||||
|
return { ...session };
|
||||||
|
}),
|
||||||
|
findUnique: jest.fn().mockImplementation(async ({ where, include }) => {
|
||||||
|
const session = sessions.get(where.id) ?? null;
|
||||||
|
if (!session) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (include?.user) {
|
||||||
|
return {
|
||||||
|
...session,
|
||||||
|
user: users.get(session.userId),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...session };
|
||||||
|
}),
|
||||||
|
update: jest.fn().mockImplementation(async ({ where, data }) => {
|
||||||
|
const session = sessions.get(where.id);
|
||||||
|
if (!session) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(session, data, { updatedAt: now });
|
||||||
|
return { ...session };
|
||||||
|
}),
|
||||||
|
updateMany: jest.fn().mockImplementation(async ({ where, data }) => {
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
for (const session of sessions.values()) {
|
||||||
|
const user = users.get(session.userId);
|
||||||
|
const matches =
|
||||||
|
(where.id === undefined || session.id === where.id) &&
|
||||||
|
(where.userId === undefined || session.userId === where.userId) &&
|
||||||
|
(where.refreshTokenHash === undefined ||
|
||||||
|
session.refreshTokenHash === where.refreshTokenHash) &&
|
||||||
|
(where.status === undefined || session.status === where.status) &&
|
||||||
|
(where.expiresAt?.gt === undefined ||
|
||||||
|
session.expiresAt.getTime() > where.expiresAt.gt.getTime()) &&
|
||||||
|
(where.user?.accountStatus === undefined ||
|
||||||
|
user?.accountStatus === where.user.accountStatus);
|
||||||
|
|
||||||
|
if (!matches) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(data)) {
|
||||||
|
if (
|
||||||
|
value &&
|
||||||
|
typeof value === 'object' &&
|
||||||
|
'increment' in value
|
||||||
|
) {
|
||||||
|
session[key] += (value as { increment: number }).increment;
|
||||||
|
} else {
|
||||||
|
session[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
session.updatedAt = now;
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { count };
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const user = {
|
||||||
|
findUnique: jest.fn().mockImplementation(async ({ where }) =>
|
||||||
|
users.get(where.id) ?? null,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
const prismaService = {
|
||||||
|
user,
|
||||||
|
accountSession,
|
||||||
|
} as any;
|
||||||
|
const service = new SessionService(prismaService);
|
||||||
|
|
||||||
|
const addSession = (overrides: Record<string, unknown> = {}) => {
|
||||||
|
const session = {
|
||||||
|
id: randomUUID(),
|
||||||
|
userId: activeUserId,
|
||||||
|
refreshTokenHash: `refresh-${randomUUID()}`,
|
||||||
|
status: AccountSessionStatus.ACTIVE,
|
||||||
|
accessTokenVersion: 1,
|
||||||
|
expiresAt: future,
|
||||||
|
lastRefreshedAt: null,
|
||||||
|
revokedAt: null,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
sessions.set(session.id, session);
|
||||||
|
return session;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
service,
|
||||||
|
prismaService,
|
||||||
|
users,
|
||||||
|
sessions,
|
||||||
|
addSession,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
it('creates a session for an active user', async () => {
|
||||||
|
const { service, sessions } = buildService();
|
||||||
|
|
||||||
|
const result = await service.createSession(
|
||||||
|
{
|
||||||
|
userId: activeUserId,
|
||||||
|
refreshTokenHash: 'initial-refresh-token-hash',
|
||||||
|
expiresAt: future,
|
||||||
|
},
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
userId: activeUserId,
|
||||||
|
refreshTokenHash: 'initial-refresh-token-hash',
|
||||||
|
status: AccountSessionStatus.ACTIVE,
|
||||||
|
accessTokenVersion: 1,
|
||||||
|
expiresAt: future,
|
||||||
|
});
|
||||||
|
expect(sessions.get(result.id)).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rotates the refresh token hash and access token version', async () => {
|
||||||
|
const { service, sessions, addSession } = buildService();
|
||||||
|
const session = addSession({
|
||||||
|
refreshTokenHash: 'old-refresh-token-hash',
|
||||||
|
accessTokenVersion: 4,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.refreshSession(
|
||||||
|
{
|
||||||
|
sessionId: session.id,
|
||||||
|
refreshTokenHash: 'old-refresh-token-hash',
|
||||||
|
nextRefreshTokenHash: 'new-refresh-token-hash',
|
||||||
|
},
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
refreshTokenHash: 'new-refresh-token-hash',
|
||||||
|
accessTokenVersion: 5,
|
||||||
|
lastRefreshedAt: now,
|
||||||
|
});
|
||||||
|
expect(sessions.get(session.id)?.refreshTokenHash).toBe(
|
||||||
|
'new-refresh-token-hash',
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
service.refreshSession(
|
||||||
|
{
|
||||||
|
sessionId: session.id,
|
||||||
|
refreshTokenHash: 'old-refresh-token-hash',
|
||||||
|
nextRefreshTokenHash: 'another-refresh-token-hash',
|
||||||
|
},
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
).rejects.toThrow(UnauthorizedException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('revokes a session and prevents validation', async () => {
|
||||||
|
const { service, addSession } = buildService();
|
||||||
|
const session = addSession();
|
||||||
|
|
||||||
|
const result = await service.revokeSession(session.id, now);
|
||||||
|
|
||||||
|
expect(result.status).toBe(AccountSessionStatus.REVOKED);
|
||||||
|
expect(result.revokedAt).toEqual(now);
|
||||||
|
await expect(
|
||||||
|
service.validateSession(
|
||||||
|
{
|
||||||
|
sessionId: session.id,
|
||||||
|
accessTokenVersion: 1,
|
||||||
|
},
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
).rejects.toThrow(UnauthorizedException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('revokes all active sessions for a user only', async () => {
|
||||||
|
const { service, addSession } = buildService();
|
||||||
|
const first = addSession();
|
||||||
|
const second = addSession();
|
||||||
|
const other = addSession({ userId: randomUUID() });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.revokeAllSessionsForUser(activeUserId, now),
|
||||||
|
).resolves.toBe(2);
|
||||||
|
expect(first.status).toBe(AccountSessionStatus.REVOKED);
|
||||||
|
expect(second.status).toBe(AccountSessionStatus.REVOKED);
|
||||||
|
expect(other.status).toBe(AccountSessionStatus.ACTIVE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects validation and refresh for an expired session', async () => {
|
||||||
|
const { service, addSession } = buildService();
|
||||||
|
const session = addSession({
|
||||||
|
expiresAt: new Date('2026-06-27T17:59:59.000Z'),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.validateSession(
|
||||||
|
{
|
||||||
|
sessionId: session.id,
|
||||||
|
accessTokenVersion: 1,
|
||||||
|
},
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
).rejects.toThrow(UnauthorizedException);
|
||||||
|
await expect(
|
||||||
|
service.refreshSession(
|
||||||
|
{
|
||||||
|
sessionId: session.id,
|
||||||
|
refreshTokenHash: session.refreshTokenHash,
|
||||||
|
nextRefreshTokenHash: 'rotated-expired-hash',
|
||||||
|
},
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
).rejects.toThrow(UnauthorizedException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects refresh for a revoked session', async () => {
|
||||||
|
const { service, addSession } = buildService();
|
||||||
|
const session = addSession({
|
||||||
|
status: AccountSessionStatus.REVOKED,
|
||||||
|
revokedAt: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.refreshSession(
|
||||||
|
{
|
||||||
|
sessionId: session.id,
|
||||||
|
refreshTokenHash: session.refreshTokenHash,
|
||||||
|
nextRefreshTokenHash: 'rotated-revoked-hash',
|
||||||
|
},
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
).rejects.toThrow(UnauthorizedException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects session creation and validation for a locked user', async () => {
|
||||||
|
const { service, addSession } = buildService();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.createSession(
|
||||||
|
{
|
||||||
|
userId: lockedUserId,
|
||||||
|
refreshTokenHash: 'locked-user-refresh-hash',
|
||||||
|
expiresAt: future,
|
||||||
|
},
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
).rejects.toThrow(new BadRequestException('Account is not active'));
|
||||||
|
|
||||||
|
const existingSession = addSession({ userId: lockedUserId });
|
||||||
|
await expect(
|
||||||
|
service.validateSession(
|
||||||
|
{
|
||||||
|
sessionId: existingSession.id,
|
||||||
|
accessTokenVersion: 1,
|
||||||
|
},
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
).rejects.toThrow(UnauthorizedException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects session creation and validation for a deleted user', async () => {
|
||||||
|
const { service, addSession } = buildService();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.createSession(
|
||||||
|
{
|
||||||
|
userId: deletedUserId,
|
||||||
|
refreshTokenHash: 'deleted-user-refresh-hash',
|
||||||
|
expiresAt: future,
|
||||||
|
},
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
).rejects.toThrow(new BadRequestException('Account is not active'));
|
||||||
|
|
||||||
|
const existingSession = addSession({ userId: deletedUserId });
|
||||||
|
await expect(
|
||||||
|
service.validateSession(
|
||||||
|
{
|
||||||
|
sessionId: existingSession.id,
|
||||||
|
accessTokenVersion: 1,
|
||||||
|
},
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
).rejects.toThrow(UnauthorizedException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expires a session explicitly', async () => {
|
||||||
|
const { service, addSession } = buildService();
|
||||||
|
const session = addSession();
|
||||||
|
|
||||||
|
const result = await service.expireSession(session.id, now);
|
||||||
|
|
||||||
|
expect(result.status).toBe(AccountSessionStatus.EXPIRED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates by session identity and version without selecting email', async () => {
|
||||||
|
const { service, prismaService, addSession } = buildService();
|
||||||
|
const session = addSession({ accessTokenVersion: 7 });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.validateSession(
|
||||||
|
{
|
||||||
|
sessionId: session.id,
|
||||||
|
accessTokenVersion: 7,
|
||||||
|
},
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
).resolves.toMatchObject({ id: session.id, userId: activeUserId });
|
||||||
|
|
||||||
|
expect(prismaService.accountSession.findUnique).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
id: session.id,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
accountStatus: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
296
backend/src/modules/auth/session.service.ts
Normal file
296
backend/src/modules/auth/session.service.ts
Normal file
@ -0,0 +1,296 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
AccountSession,
|
||||||
|
AccountSessionStatus,
|
||||||
|
UserAccountStatus,
|
||||||
|
} from '@prisma/client';
|
||||||
|
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||||
|
|
||||||
|
export interface CreateSessionInput {
|
||||||
|
userId: string;
|
||||||
|
refreshTokenHash: string;
|
||||||
|
expiresAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RefreshSessionInput {
|
||||||
|
sessionId: string;
|
||||||
|
refreshTokenHash: string;
|
||||||
|
nextRefreshTokenHash: string;
|
||||||
|
expiresAt?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ValidateSessionInput {
|
||||||
|
sessionId: string;
|
||||||
|
accessTokenVersion: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SessionService {
|
||||||
|
constructor(private readonly prismaService: PrismaService) {}
|
||||||
|
|
||||||
|
async createSession(
|
||||||
|
input: CreateSessionInput,
|
||||||
|
now = new Date(),
|
||||||
|
): Promise<AccountSession> {
|
||||||
|
const user = await this.prismaService.user.findUnique({
|
||||||
|
where: {
|
||||||
|
id: input.userId,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
accountStatus: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException('Account not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.accountStatus !== UserAccountStatus.ACTIVE) {
|
||||||
|
throw new BadRequestException('Account is not active');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.assertUsableRefreshTokenHash(input.refreshTokenHash);
|
||||||
|
this.assertFutureExpiration(input.expiresAt, now);
|
||||||
|
|
||||||
|
return this.prismaService.accountSession.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
refreshTokenHash: input.refreshTokenHash,
|
||||||
|
status: AccountSessionStatus.ACTIVE,
|
||||||
|
accessTokenVersion: 1,
|
||||||
|
expiresAt: input.expiresAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshSession(
|
||||||
|
input: RefreshSessionInput,
|
||||||
|
now = new Date(),
|
||||||
|
): Promise<AccountSession> {
|
||||||
|
this.assertUsableRefreshTokenHash(input.refreshTokenHash);
|
||||||
|
this.assertUsableRefreshTokenHash(input.nextRefreshTokenHash);
|
||||||
|
|
||||||
|
if (input.refreshTokenHash === input.nextRefreshTokenHash) {
|
||||||
|
throw new BadRequestException('Refresh token hash must be rotated');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.expiresAt) {
|
||||||
|
this.assertFutureExpiration(input.expiresAt, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await this.prismaService.accountSession.findUnique({
|
||||||
|
where: {
|
||||||
|
id: input.sessionId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
accountStatus: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.assertSessionCanBeUsed(
|
||||||
|
session,
|
||||||
|
input.refreshTokenHash,
|
||||||
|
now,
|
||||||
|
'refresh',
|
||||||
|
);
|
||||||
|
|
||||||
|
const rotation = await this.prismaService.accountSession.updateMany({
|
||||||
|
where: {
|
||||||
|
id: input.sessionId,
|
||||||
|
refreshTokenHash: input.refreshTokenHash,
|
||||||
|
status: AccountSessionStatus.ACTIVE,
|
||||||
|
expiresAt: {
|
||||||
|
gt: now,
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
accountStatus: UserAccountStatus.ACTIVE,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
refreshTokenHash: input.nextRefreshTokenHash,
|
||||||
|
accessTokenVersion: {
|
||||||
|
increment: 1,
|
||||||
|
},
|
||||||
|
lastRefreshedAt: now,
|
||||||
|
...(input.expiresAt ? { expiresAt: input.expiresAt } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (rotation.count !== 1) {
|
||||||
|
throw new UnauthorizedException('Session cannot be refreshed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshedSession =
|
||||||
|
await this.prismaService.accountSession.findUnique({
|
||||||
|
where: {
|
||||||
|
id: input.sessionId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!refreshedSession) {
|
||||||
|
throw new UnauthorizedException('Session cannot be refreshed');
|
||||||
|
}
|
||||||
|
|
||||||
|
return refreshedSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
async validateSession(
|
||||||
|
input: ValidateSessionInput,
|
||||||
|
now = new Date(),
|
||||||
|
): Promise<AccountSession> {
|
||||||
|
const session = await this.prismaService.accountSession.findUnique({
|
||||||
|
where: {
|
||||||
|
id: input.sessionId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
accountStatus: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.assertSessionCanBeUsed(session, undefined, now, 'validate');
|
||||||
|
|
||||||
|
if (session.accessTokenVersion !== input.accessTokenVersion) {
|
||||||
|
throw new UnauthorizedException('Session is invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user: _user, ...validatedSession } = session;
|
||||||
|
return validatedSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
async revokeSession(
|
||||||
|
sessionId: string,
|
||||||
|
now = new Date(),
|
||||||
|
): Promise<AccountSession> {
|
||||||
|
const session = await this.prismaService.accountSession.findUnique({
|
||||||
|
where: {
|
||||||
|
id: sessionId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
throw new NotFoundException('Session not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.status === AccountSessionStatus.REVOKED) {
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prismaService.accountSession.update({
|
||||||
|
where: {
|
||||||
|
id: sessionId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
status: AccountSessionStatus.REVOKED,
|
||||||
|
revokedAt: now,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async revokeAllSessionsForUser(
|
||||||
|
userId: string,
|
||||||
|
now = new Date(),
|
||||||
|
): Promise<number> {
|
||||||
|
const result = await this.prismaService.accountSession.updateMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
status: AccountSessionStatus.ACTIVE,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
status: AccountSessionStatus.REVOKED,
|
||||||
|
revokedAt: now,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.count;
|
||||||
|
}
|
||||||
|
|
||||||
|
async expireSession(
|
||||||
|
sessionId: string,
|
||||||
|
now = new Date(),
|
||||||
|
): Promise<AccountSession> {
|
||||||
|
const session = await this.prismaService.accountSession.findUnique({
|
||||||
|
where: {
|
||||||
|
id: sessionId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
throw new NotFoundException('Session not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.status === AccountSessionStatus.EXPIRED) {
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prismaService.accountSession.update({
|
||||||
|
where: {
|
||||||
|
id: sessionId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
status: AccountSessionStatus.EXPIRED,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertSessionCanBeUsed(
|
||||||
|
session:
|
||||||
|
| (AccountSession & {
|
||||||
|
user: {
|
||||||
|
accountStatus: UserAccountStatus;
|
||||||
|
};
|
||||||
|
})
|
||||||
|
| null,
|
||||||
|
refreshTokenHash: string | undefined,
|
||||||
|
now: Date,
|
||||||
|
operation: 'refresh' | 'validate',
|
||||||
|
): asserts session is AccountSession & {
|
||||||
|
user: {
|
||||||
|
accountStatus: UserAccountStatus;
|
||||||
|
};
|
||||||
|
} {
|
||||||
|
const message =
|
||||||
|
operation === 'refresh'
|
||||||
|
? 'Session cannot be refreshed'
|
||||||
|
: 'Session is invalid';
|
||||||
|
|
||||||
|
if (
|
||||||
|
!session ||
|
||||||
|
session.status !== AccountSessionStatus.ACTIVE ||
|
||||||
|
session.expiresAt.getTime() <= now.getTime() ||
|
||||||
|
session.user.accountStatus !== UserAccountStatus.ACTIVE ||
|
||||||
|
(refreshTokenHash !== undefined &&
|
||||||
|
session.refreshTokenHash !== refreshTokenHash)
|
||||||
|
) {
|
||||||
|
throw new UnauthorizedException(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertUsableRefreshTokenHash(refreshTokenHash: string): void {
|
||||||
|
if (!refreshTokenHash.trim()) {
|
||||||
|
throw new BadRequestException('Refresh token hash is required');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertFutureExpiration(expiresAt: Date, now: Date): void {
|
||||||
|
if (
|
||||||
|
Number.isNaN(expiresAt.getTime()) ||
|
||||||
|
expiresAt.getTime() <= now.getTime()
|
||||||
|
) {
|
||||||
|
throw new BadRequestException('Session expiration must be in the future');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user