mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-23 13:29:02 +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)
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user