mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-22 20:46:38 +08:00
feat(server): support refresh token (#15218)
This commit is contained in:
@@ -0,0 +1,328 @@
|
||||
import { timingSafeEqual } from 'node:crypto';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Transactional } from '@nestjs-cls/transactional';
|
||||
|
||||
import { BaseModel } from './base';
|
||||
|
||||
export interface CreateAuthSessionInput {
|
||||
userSessionId: string;
|
||||
installationId: string;
|
||||
platform: string;
|
||||
deviceName?: string;
|
||||
appVersion?: string;
|
||||
idleExpiresAt: Date;
|
||||
absoluteExpiresAt: Date;
|
||||
refreshToken: {
|
||||
id: string;
|
||||
secretHash: string;
|
||||
expiresAt: Date;
|
||||
};
|
||||
}
|
||||
|
||||
export interface RotateAuthRefreshTokenInput {
|
||||
id: string;
|
||||
secretHash: string;
|
||||
now: Date;
|
||||
idleExpiresAt: Date;
|
||||
graceMs: number;
|
||||
appVersion?: string;
|
||||
next: {
|
||||
id: string;
|
||||
secretHash: string;
|
||||
expiresAt: Date;
|
||||
};
|
||||
}
|
||||
|
||||
export type RotateAuthRefreshTokenResult =
|
||||
| {
|
||||
status: 'rotated' | 'grace';
|
||||
authSessionId: string;
|
||||
userSessionId: string;
|
||||
platform: string;
|
||||
}
|
||||
| { status: 'reused'; authSessionId: string; platform: string }
|
||||
| { status: 'invalid' }
|
||||
| { status: 'expired' }
|
||||
| { status: 'revoked' }
|
||||
| { status: 'temporarily_unavailable' };
|
||||
|
||||
@Injectable()
|
||||
export class AuthSessionModel extends BaseModel {
|
||||
async hasUserInstallation(userSessionId: string, installationId: string) {
|
||||
const userSession = await this.db.userSession.findUnique({
|
||||
where: { id: userSessionId },
|
||||
select: { userId: true },
|
||||
});
|
||||
if (!userSession) return false;
|
||||
await this.db
|
||||
.$queryRaw`SELECT id FROM users WHERE id = ${userSession.userId} FOR UPDATE`;
|
||||
return (
|
||||
(await this.db.authSession.count({
|
||||
where: {
|
||||
installationId,
|
||||
userSession: { userId: userSession.userId },
|
||||
},
|
||||
})) > 0
|
||||
);
|
||||
}
|
||||
|
||||
@Transactional()
|
||||
async create(input: CreateAuthSessionInput) {
|
||||
const session = await this.db.authSession.create({
|
||||
data: {
|
||||
userSessionId: input.userSessionId,
|
||||
installationId: input.installationId,
|
||||
platform: input.platform,
|
||||
deviceName: input.deviceName,
|
||||
appVersion: input.appVersion,
|
||||
idleExpiresAt: input.idleExpiresAt,
|
||||
absoluteExpiresAt: input.absoluteExpiresAt,
|
||||
refreshTokens: {
|
||||
create: {
|
||||
id: input.refreshToken.id,
|
||||
generation: 0,
|
||||
secretHash: input.refreshToken.secretHash,
|
||||
expiresAt: input.refreshToken.expiresAt,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await this.db.userSession.update({
|
||||
where: { id: input.userSessionId },
|
||||
data: {
|
||||
expiresAt: input.idleExpiresAt,
|
||||
refreshClientVersion: input.appVersion,
|
||||
},
|
||||
});
|
||||
return session;
|
||||
}
|
||||
|
||||
async get(id: string) {
|
||||
return await this.db.authSession.findUnique({
|
||||
where: { id },
|
||||
include: { userSession: true },
|
||||
});
|
||||
}
|
||||
|
||||
async list(userId: string) {
|
||||
const now = new Date();
|
||||
return await this.db.authSession.findMany({
|
||||
where: {
|
||||
userSession: {
|
||||
userId,
|
||||
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
|
||||
},
|
||||
revokedAt: null,
|
||||
idleExpiresAt: { gt: now },
|
||||
absoluteExpiresAt: { gt: now },
|
||||
},
|
||||
orderBy: { lastSeenAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async revokeUserSessions(userId: string, reason: string) {
|
||||
return await this.db.authSession.updateMany({
|
||||
where: {
|
||||
userSession: { userId },
|
||||
revokedAt: null,
|
||||
},
|
||||
data: {
|
||||
revokedAt: new Date(),
|
||||
revokeReason: reason,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async revoke(id: string, reason: string, userId?: string) {
|
||||
const now = new Date();
|
||||
const where = {
|
||||
id,
|
||||
revokedAt: null,
|
||||
...(userId ? { userSession: { userId } } : {}),
|
||||
};
|
||||
const { count } = await this.db.authSession.updateMany({
|
||||
where,
|
||||
data: { revokedAt: now, revokeReason: reason },
|
||||
});
|
||||
if (count) {
|
||||
await this.db.authRefreshToken.updateMany({
|
||||
where: { authSessionId: id, revokedAt: null },
|
||||
data: { revokedAt: now },
|
||||
});
|
||||
}
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
async findByRefreshToken(id: string, secretHash: string) {
|
||||
const token = await this.db.authRefreshToken.findUnique({
|
||||
where: { id },
|
||||
select: { secretHash: true, authSessionId: true },
|
||||
});
|
||||
return token && this.secretHashesEqual(token.secretHash, secretHash)
|
||||
? token.authSessionId
|
||||
: undefined;
|
||||
}
|
||||
|
||||
@Transactional()
|
||||
async rotate(
|
||||
input: RotateAuthRefreshTokenInput
|
||||
): Promise<RotateAuthRefreshTokenResult> {
|
||||
await this.db.$queryRaw`
|
||||
SELECT id
|
||||
FROM auth_refresh_tokens
|
||||
WHERE id = ${input.id}
|
||||
FOR UPDATE
|
||||
`;
|
||||
|
||||
let token = await this.db.authRefreshToken.findUnique({
|
||||
where: { id: input.id },
|
||||
include: {
|
||||
authSession: {
|
||||
include: { userSession: { include: { user: true } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!token || !this.secretHashesEqual(token.secretHash, input.secretHash)) {
|
||||
return { status: 'invalid' };
|
||||
}
|
||||
|
||||
const session = token.authSession;
|
||||
if (token.revokedAt || session.revokedAt) return { status: 'revoked' };
|
||||
if (session.userSession.user.disabled) {
|
||||
await this.revoke(session.id, 'user_disabled');
|
||||
return { status: 'revoked' };
|
||||
}
|
||||
if (
|
||||
token.expiresAt <= input.now ||
|
||||
session.idleExpiresAt <= input.now ||
|
||||
session.absoluteExpiresAt <= input.now ||
|
||||
(session.userSession.expiresAt &&
|
||||
session.userSession.expiresAt <= input.now)
|
||||
) {
|
||||
return { status: 'expired' };
|
||||
}
|
||||
|
||||
if (token.usedAt) {
|
||||
const replacementId = token.replacedById;
|
||||
if (
|
||||
token.graceUsedAt ||
|
||||
!replacementId ||
|
||||
input.now.getTime() - token.usedAt.getTime() > input.graceMs
|
||||
) {
|
||||
await this.revoke(session.id, 'refresh_token_reused');
|
||||
return {
|
||||
status: 'reused',
|
||||
authSessionId: session.id,
|
||||
platform: session.platform,
|
||||
};
|
||||
}
|
||||
|
||||
const claimed = await this.db.authRefreshToken.updateMany({
|
||||
where: {
|
||||
id: token.id,
|
||||
graceUsedAt: null,
|
||||
replacedById: input.next.id,
|
||||
replacedBy: { secretHash: input.next.secretHash },
|
||||
},
|
||||
data: { graceUsedAt: input.now },
|
||||
});
|
||||
if (!claimed.count) {
|
||||
return { status: 'temporarily_unavailable' };
|
||||
}
|
||||
return {
|
||||
status: 'grace',
|
||||
authSessionId: session.id,
|
||||
userSessionId: session.userSessionId,
|
||||
platform: session.platform,
|
||||
};
|
||||
}
|
||||
|
||||
await this.db.authRefreshToken.create({
|
||||
data: {
|
||||
id: input.next.id,
|
||||
authSessionId: session.id,
|
||||
generation: token.generation + 1,
|
||||
secretHash: input.next.secretHash,
|
||||
expiresAt: input.next.expiresAt,
|
||||
},
|
||||
});
|
||||
await this.db.authRefreshToken.update({
|
||||
where: { id: token.id },
|
||||
data: { usedAt: input.now, replacedById: input.next.id },
|
||||
});
|
||||
await this.db.authSession.update({
|
||||
where: { id: session.id },
|
||||
data: {
|
||||
lastSeenAt: input.now,
|
||||
idleExpiresAt:
|
||||
input.idleExpiresAt < session.absoluteExpiresAt
|
||||
? input.idleExpiresAt
|
||||
: session.absoluteExpiresAt,
|
||||
appVersion: input.appVersion,
|
||||
},
|
||||
});
|
||||
await this.db.userSession.update({
|
||||
where: { id: session.userSessionId },
|
||||
data: {
|
||||
expiresAt:
|
||||
input.idleExpiresAt < session.absoluteExpiresAt
|
||||
? input.idleExpiresAt
|
||||
: session.absoluteExpiresAt,
|
||||
refreshClientVersion: input.appVersion,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
status: 'rotated',
|
||||
authSessionId: session.id,
|
||||
userSessionId: session.userSessionId,
|
||||
platform: session.platform,
|
||||
};
|
||||
}
|
||||
|
||||
async deleteExpiredRefreshTokens(before: Date, limit: number) {
|
||||
const rows = await this.db.authRefreshToken.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ expiresAt: { lt: before } },
|
||||
{ authSession: { revokedAt: { lt: before } } },
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
take: limit,
|
||||
});
|
||||
if (!rows.length) return 0;
|
||||
const { count } = await this.db.authRefreshToken.deleteMany({
|
||||
where: { id: { in: rows.map(row => row.id) } },
|
||||
});
|
||||
return count;
|
||||
}
|
||||
|
||||
async deleteExpiredSessions(before: Date, limit: number) {
|
||||
const rows = await this.db.authSession.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ absoluteExpiresAt: { lt: before } },
|
||||
{ revokedAt: { lt: before } },
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
take: limit,
|
||||
});
|
||||
if (!rows.length) return 0;
|
||||
const { count } = await this.db.authSession.deleteMany({
|
||||
where: { id: { in: rows.map(row => row.id) } },
|
||||
});
|
||||
return count;
|
||||
}
|
||||
|
||||
private secretHashesEqual(left: string, right: string) {
|
||||
const leftBuffer = Buffer.from(left, 'hex');
|
||||
const rightBuffer = Buffer.from(right, 'hex');
|
||||
return (
|
||||
leftBuffer.length === rightBuffer.length &&
|
||||
timingSafeEqual(leftBuffer, rightBuffer)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Transactional } from '@nestjs-cls/transactional';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
import { BaseModel } from './base';
|
||||
|
||||
@Injectable()
|
||||
export class AppConfigModel extends BaseModel {
|
||||
async load() {
|
||||
return this.db.appConfig.findMany();
|
||||
async load(excludedKeys: string[] = []) {
|
||||
return this.db.appConfig.findMany({
|
||||
where: excludedKeys.length ? { id: { notIn: excludedKeys } } : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@Transactional()
|
||||
@@ -21,4 +24,44 @@ export class AppConfigModel extends BaseModel {
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async get(key: string) {
|
||||
return await this.db.appConfig.findUnique({ where: { id: key } });
|
||||
}
|
||||
|
||||
async createIfAbsent(key: string, value: Prisma.InputJsonValue) {
|
||||
try {
|
||||
return await this.db.appConfig.create({
|
||||
data: { id: key, value, lastUpdatedBy: null },
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
!(error instanceof Prisma.PrismaClientKnownRequestError) ||
|
||||
error.code !== 'P2002'
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
return await this.db.appConfig.findUniqueOrThrow({ where: { id: key } });
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional()
|
||||
async mutate(
|
||||
key: string,
|
||||
actorId: string,
|
||||
mutator: (value: Prisma.JsonValue) => Prisma.InputJsonValue
|
||||
) {
|
||||
await this.db
|
||||
.$queryRaw`SELECT id FROM app_configs WHERE id = ${key} FOR UPDATE`;
|
||||
const current = await this.db.appConfig.findUniqueOrThrow({
|
||||
where: { id: key },
|
||||
});
|
||||
return await this.db.appConfig.update({
|
||||
where: { id: key },
|
||||
data: {
|
||||
value: mutator(current.value),
|
||||
lastUpdatedBy: actorId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ModuleRef } from '@nestjs/core';
|
||||
|
||||
import { ApplyType } from '../base';
|
||||
import { AccessTokenModel } from './access-token';
|
||||
import { AuthSessionModel } from './auth-session';
|
||||
import { BlobModel } from './blob';
|
||||
import { CalendarAccountModel } from './calendar-account';
|
||||
import { CalendarEventModel } from './calendar-event';
|
||||
@@ -59,6 +60,7 @@ const MODELS = {
|
||||
verificationToken: VerificationTokenModel,
|
||||
magicLinkOtp: MagicLinkOtpModel,
|
||||
mailDelivery: MailDeliveryModel,
|
||||
authSession: AuthSessionModel,
|
||||
feature: FeatureModel,
|
||||
workspace: WorkspaceModel,
|
||||
userFeature: UserFeatureModel,
|
||||
@@ -147,6 +149,7 @@ const ModelsSymbolProvider: ExistingProvider = {
|
||||
})
|
||||
export class ModelsModule {}
|
||||
|
||||
export * from './auth-session';
|
||||
export * from './blob';
|
||||
export * from './calendar-account';
|
||||
export * from './calendar-event';
|
||||
|
||||
@@ -35,6 +35,7 @@ type UpdateConnectedAccountInput = Omit<
|
||||
|
||||
declare global {
|
||||
interface Events {
|
||||
'user.preDelete': { id: string };
|
||||
'user.created': User;
|
||||
'user.updated': User;
|
||||
'user.deleted': User & {
|
||||
@@ -60,6 +61,16 @@ export type { ConnectedAccount, User };
|
||||
|
||||
@Injectable()
|
||||
export class UserModel extends BaseModel {
|
||||
async lockForAuthIssuance(id: string) {
|
||||
const users = await this.db.$queryRaw<Array<Pick<User, 'id' | 'disabled'>>>`
|
||||
SELECT id, disabled
|
||||
FROM users
|
||||
WHERE id = ${id}
|
||||
FOR UPDATE
|
||||
`;
|
||||
return users[0];
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly crypto: CryptoHelper,
|
||||
private readonly event: EventBus
|
||||
@@ -284,6 +295,8 @@ export class UserModel extends BaseModel {
|
||||
}
|
||||
}
|
||||
|
||||
await this.event.emitAsync('user.preDelete', { id });
|
||||
|
||||
await this.db.workspaceInvitation.deleteMany({
|
||||
where: { inviteeUserId: id },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user