mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-23 21:38:44 +08:00
chore: drop old client support (#14369)
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import test from 'ava';
|
||||
|
||||
import { createModule } from '../../__tests__/create-module';
|
||||
@@ -23,8 +24,16 @@ test('should create access token', async t => {
|
||||
t.is(token.userId, user.id);
|
||||
t.is(token.name, 'test');
|
||||
t.truthy(token.token);
|
||||
t.true(token.token.startsWith('ut_'));
|
||||
t.truthy(token.createdAt);
|
||||
t.is(token.expiresAt, null);
|
||||
|
||||
const row = await module.get(PrismaClient).accessToken.findUnique({
|
||||
where: { id: token.id },
|
||||
});
|
||||
t.truthy(row);
|
||||
t.regex(row!.token, /^[0-9a-f]{64}$/);
|
||||
t.not(row!.token, token.token);
|
||||
});
|
||||
|
||||
test('should create access token with expiration', async t => {
|
||||
@@ -50,6 +59,22 @@ test('should list access tokens without token value', async t => {
|
||||
t.is(listed[0].token, undefined);
|
||||
});
|
||||
|
||||
test('should not reveal access token value after creation', async t => {
|
||||
const user = await module.create(Mockers.User);
|
||||
|
||||
const token = await models.accessToken.create({
|
||||
userId: user.id,
|
||||
name: 'test',
|
||||
});
|
||||
|
||||
const listed = await models.accessToken.list(user.id, true);
|
||||
const found = listed.find(item => item.id === token.id);
|
||||
|
||||
t.truthy(found);
|
||||
t.is(found!.token, '[REDACTED]');
|
||||
t.not(found!.token, token.token);
|
||||
});
|
||||
|
||||
test('should be able to revoke access token', async t => {
|
||||
const user = await module.create(Mockers.User);
|
||||
const token = await module.create(Mockers.AccessToken, { userId: user.id });
|
||||
@@ -62,7 +87,10 @@ test('should be able to revoke access token', async t => {
|
||||
|
||||
test('should be able to get access token by token value', async t => {
|
||||
const user = await module.create(Mockers.User);
|
||||
const token = await module.create(Mockers.AccessToken, { userId: user.id });
|
||||
const token = await models.accessToken.create({
|
||||
userId: user.id,
|
||||
name: 'test',
|
||||
});
|
||||
|
||||
const found = await models.accessToken.getByToken(token.token);
|
||||
t.is(found?.id, token.id);
|
||||
@@ -72,8 +100,9 @@ test('should be able to get access token by token value', async t => {
|
||||
|
||||
test('should not get expired access token', async t => {
|
||||
const user = await module.create(Mockers.User);
|
||||
const token = await module.create(Mockers.AccessToken, {
|
||||
const token = await models.accessToken.create({
|
||||
userId: user.id,
|
||||
name: 'test',
|
||||
expiresAt: Due.before('1s'),
|
||||
});
|
||||
|
||||
|
||||
@@ -3,43 +3,53 @@ import { Injectable } from '@nestjs/common';
|
||||
import { CryptoHelper } from '../base';
|
||||
import { BaseModel } from './base';
|
||||
|
||||
const REDACTED_TOKEN = '[REDACTED]';
|
||||
|
||||
export interface CreateAccessTokenInput {
|
||||
userId: string;
|
||||
name: string;
|
||||
expiresAt?: Date | null;
|
||||
}
|
||||
|
||||
type UserAccessToken = {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: Date;
|
||||
expiresAt: Date | null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AccessTokenModel extends BaseModel {
|
||||
constructor(private readonly crypto: CryptoHelper) {
|
||||
super();
|
||||
}
|
||||
|
||||
async list(userId: string, revealed?: false): Promise<UserAccessToken[]>;
|
||||
async list(
|
||||
userId: string,
|
||||
revealed: true
|
||||
): Promise<(UserAccessToken & { token: string })[]>;
|
||||
async list(userId: string, revealed: boolean = false) {
|
||||
return await this.db.accessToken.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
createdAt: true,
|
||||
expiresAt: true,
|
||||
token: revealed,
|
||||
},
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
const tokens = await this.db.accessToken.findMany({
|
||||
select: { id: true, name: true, createdAt: true, expiresAt: true },
|
||||
where: { userId },
|
||||
});
|
||||
|
||||
if (!revealed) return tokens;
|
||||
|
||||
return tokens.map(row => ({ ...row, token: REDACTED_TOKEN }));
|
||||
}
|
||||
|
||||
async create(input: CreateAccessTokenInput) {
|
||||
let token = 'ut_' + this.crypto.randomBytes(40).toString('hex');
|
||||
token = token.substring(0, 40);
|
||||
const token = `ut_${this.crypto.randomBytes(32).toString('base64url')}`;
|
||||
const tokenHash = this.crypto.sha256(token).toString('hex');
|
||||
|
||||
return await this.db.accessToken.create({
|
||||
data: {
|
||||
token,
|
||||
...input,
|
||||
},
|
||||
const created = await this.db.accessToken.create({
|
||||
data: { token: tokenHash, ...input },
|
||||
});
|
||||
|
||||
// NOTE: we only return the plaintext token once, at creation time.
|
||||
return { ...created, token };
|
||||
}
|
||||
|
||||
async revoke(id: string, userId: string) {
|
||||
@@ -52,20 +62,27 @@ export class AccessTokenModel extends BaseModel {
|
||||
}
|
||||
|
||||
async getByToken(token: string) {
|
||||
return await this.db.accessToken.findUnique({
|
||||
where: {
|
||||
token,
|
||||
OR: [
|
||||
{
|
||||
expiresAt: null,
|
||||
},
|
||||
{
|
||||
expiresAt: {
|
||||
gt: new Date(),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
const tokenHash = this.crypto.sha256(token).toString('hex');
|
||||
|
||||
const condition = [{ expiresAt: null }, { expiresAt: { gt: new Date() } }];
|
||||
const found = await this.db.accessToken.findUnique({
|
||||
where: { token: tokenHash, OR: condition },
|
||||
});
|
||||
|
||||
if (found) return found;
|
||||
|
||||
// Compatibility: lazy-migrate old plaintext tokens in DB.
|
||||
const legacy = await this.db.accessToken.findUnique({
|
||||
where: { token, OR: condition },
|
||||
});
|
||||
|
||||
if (!legacy) return null;
|
||||
|
||||
await this.db.accessToken.update({
|
||||
where: { id: legacy.id },
|
||||
data: { token: tokenHash },
|
||||
});
|
||||
|
||||
return { ...legacy, token: tokenHash };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,7 +131,7 @@ export class DocModel extends BaseModel {
|
||||
},
|
||||
});
|
||||
if (count > 0) {
|
||||
this.logger.log(
|
||||
this.logger.verbose(
|
||||
`Deleted ${count} updates for workspace ${workspaceId} doc ${docId}`
|
||||
);
|
||||
}
|
||||
@@ -159,7 +159,7 @@ export class DocModel extends BaseModel {
|
||||
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
const result: { updatedAt: Date }[] = await this.db.$queryRaw`
|
||||
INSERT INTO "snapshots" ("workspace_id", "guid", "blob", "size", "created_at", "updated_at", "created_by", "updated_by")
|
||||
VALUES (${spaceId}, ${docId}, ${blob}, ${size}, DEFAULT, ${updatedAt}, ${editorId}, ${editorId})
|
||||
VALUES (${spaceId}, ${docId}, ${blob}, ${size}, ${updatedAt}, ${updatedAt}, ${editorId}, ${editorId})
|
||||
ON CONFLICT ("workspace_id", "guid")
|
||||
DO UPDATE SET "blob" = ${blob}, "size" = ${size}, "updated_at" = ${updatedAt}, "updated_by" = ${editorId}
|
||||
WHERE "snapshots"."workspace_id" = ${spaceId} AND "snapshots"."guid" = ${docId} AND "snapshots"."updated_at" <= ${updatedAt}
|
||||
|
||||
@@ -24,6 +24,7 @@ import { DocModel } from './doc';
|
||||
import { DocUserModel } from './doc-user';
|
||||
import { FeatureModel } from './feature';
|
||||
import { HistoryModel } from './history';
|
||||
import { MagicLinkOtpModel } from './magic-link-otp';
|
||||
import { NotificationModel } from './notification';
|
||||
import { MODELS_SYMBOL } from './provider';
|
||||
import { SessionModel } from './session';
|
||||
@@ -41,6 +42,7 @@ const MODELS = {
|
||||
user: UserModel,
|
||||
session: SessionModel,
|
||||
verificationToken: VerificationTokenModel,
|
||||
magicLinkOtp: MagicLinkOtpModel,
|
||||
feature: FeatureModel,
|
||||
workspace: WorkspaceModel,
|
||||
userFeature: UserFeatureModel,
|
||||
@@ -133,6 +135,7 @@ export * from './doc';
|
||||
export * from './doc-user';
|
||||
export * from './feature';
|
||||
export * from './history';
|
||||
export * from './magic-link-otp';
|
||||
export * from './notification';
|
||||
export * from './session';
|
||||
export * from './user';
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Transactional } from '@nestjs-cls/transactional';
|
||||
|
||||
import { CryptoHelper } from '../base';
|
||||
import { BaseModel } from './base';
|
||||
|
||||
const MAX_OTP_ATTEMPTS = 10;
|
||||
const OTP_TTL_IN_SEC = 30 * 60;
|
||||
|
||||
export type ConsumeMagicLinkOtpResult =
|
||||
| { ok: true; token: string }
|
||||
| { ok: false; reason: 'not_found' | 'expired' | 'invalid_otp' | 'locked' }
|
||||
| { ok: false; reason: 'nonce_mismatch' };
|
||||
|
||||
@Injectable()
|
||||
export class MagicLinkOtpModel extends BaseModel {
|
||||
constructor(private readonly crypto: CryptoHelper) {
|
||||
super();
|
||||
}
|
||||
|
||||
private hash(otp: string) {
|
||||
return this.crypto.sha256(otp).toString('hex');
|
||||
}
|
||||
|
||||
async upsert(
|
||||
email: string,
|
||||
otp: string,
|
||||
token: string,
|
||||
clientNonce?: string
|
||||
) {
|
||||
const otpHash = this.hash(otp);
|
||||
const expiresAt = new Date(Date.now() + OTP_TTL_IN_SEC * 1000);
|
||||
|
||||
await this.db.magicLinkOtp.upsert({
|
||||
where: { email },
|
||||
create: { email, otpHash, token, clientNonce, expiresAt, attempts: 0 },
|
||||
update: { otpHash, token, clientNonce, expiresAt, attempts: 0 },
|
||||
});
|
||||
}
|
||||
|
||||
@Transactional()
|
||||
async consume(
|
||||
email: string,
|
||||
otp: string,
|
||||
clientNonce?: string
|
||||
): Promise<ConsumeMagicLinkOtpResult> {
|
||||
const now = new Date();
|
||||
const otpHash = this.hash(otp);
|
||||
|
||||
const record = await this.db.magicLinkOtp.findUnique({ where: { email } });
|
||||
if (!record) {
|
||||
return { ok: false, reason: 'not_found' };
|
||||
}
|
||||
|
||||
if (record.expiresAt <= now) {
|
||||
await this.db.magicLinkOtp.delete({ where: { email } });
|
||||
return { ok: false, reason: 'expired' };
|
||||
}
|
||||
|
||||
if (record.clientNonce && record.clientNonce !== clientNonce) {
|
||||
return { ok: false, reason: 'nonce_mismatch' };
|
||||
}
|
||||
|
||||
if (record.attempts >= MAX_OTP_ATTEMPTS) {
|
||||
await this.db.magicLinkOtp.delete({ where: { email } });
|
||||
return { ok: false, reason: 'locked' };
|
||||
}
|
||||
|
||||
const matches = this.crypto.compare(record.otpHash, otpHash);
|
||||
if (!matches) {
|
||||
const attempts = record.attempts + 1;
|
||||
if (attempts >= MAX_OTP_ATTEMPTS) {
|
||||
await this.db.magicLinkOtp.delete({ where: { email } });
|
||||
return { ok: false, reason: 'locked' };
|
||||
}
|
||||
await this.db.magicLinkOtp.update({
|
||||
where: { email },
|
||||
data: { attempts },
|
||||
});
|
||||
return { ok: false, reason: 'invalid_otp' };
|
||||
}
|
||||
|
||||
await this.db.magicLinkOtp.delete({ where: { email } });
|
||||
return { ok: true, token: record.token };
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ export enum TokenType {
|
||||
ChangeEmail,
|
||||
ChangePassword,
|
||||
Challenge,
|
||||
OpenAppSignIn,
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
|
||||
@@ -302,6 +302,29 @@ export class WorkspaceUserModel extends BaseModel {
|
||||
});
|
||||
}
|
||||
|
||||
async hasSharedWorkspace(userId: string, otherUserId: string) {
|
||||
if (userId === otherUserId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const shared = await this.db.workspaceUserRole.findFirst({
|
||||
select: { id: true },
|
||||
where: {
|
||||
userId,
|
||||
status: WorkspaceMemberStatus.Accepted,
|
||||
workspace: {
|
||||
permissions: {
|
||||
some: {
|
||||
userId: otherUserId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return !!shared;
|
||||
}
|
||||
|
||||
async paginate(workspaceId: string, pagination: PaginationInput) {
|
||||
return await Promise.all([
|
||||
this.db.workspaceUserRole.findMany({
|
||||
|
||||
Reference in New Issue
Block a user