From ea72599bdeefd7dd67e7371c727bdccc8bc139bb Mon Sep 17 00:00:00 2001 From: forehalo Date: Wed, 12 Mar 2025 02:52:18 +0000 Subject: [PATCH] feat(server): ban account (#10761) close CLOUD-158 --- .../migration.sql | 2 + .../server/migrations/migration_lock.toml | 2 +- packages/backend/server/schema.prisma | 23 ++-- .../src/__tests__/models/session.spec.ts | 8 +- .../server/src/__tests__/models/user.spec.ts | 102 ++++++++++++++---- .../backend/server/src/core/auth/service.ts | 8 +- .../backend/server/src/core/auth/session.ts | 2 +- .../backend/server/src/core/user/resolver.ts | 24 ++++- .../backend/server/src/core/user/types.ts | 5 + .../backend/server/src/models/doc-user.ts | 8 ++ packages/backend/server/src/models/session.ts | 4 +- packages/backend/server/src/models/user.ts | 75 ++++++++----- .../server/src/models/workspace-user.ts | 8 ++ .../server/src/plugins/payment/service.ts | 6 +- packages/backend/server/src/schema.gql | 14 ++- 15 files changed, 215 insertions(+), 76 deletions(-) create mode 100644 packages/backend/server/migrations/20250311072215_disable_account/migration.sql diff --git a/packages/backend/server/migrations/20250311072215_disable_account/migration.sql b/packages/backend/server/migrations/20250311072215_disable_account/migration.sql new file mode 100644 index 000000000..2cf87fc50 --- /dev/null +++ b/packages/backend/server/migrations/20250311072215_disable_account/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "users" ADD COLUMN "disabled" BOOLEAN NOT NULL DEFAULT false; diff --git a/packages/backend/server/migrations/migration_lock.toml b/packages/backend/server/migrations/migration_lock.toml index 99e4f2009..fbffa92c2 100644 --- a/packages/backend/server/migrations/migration_lock.toml +++ b/packages/backend/server/migrations/migration_lock.toml @@ -1,3 +1,3 @@ # Please do not edit this file manually # It should be added in your version-control system (i.e. Git) -provider = "postgresql" +provider = "postgresql" \ No newline at end of file diff --git a/packages/backend/server/schema.prisma b/packages/backend/server/schema.prisma index f553cc82e..cee3923b1 100644 --- a/packages/backend/server/schema.prisma +++ b/packages/backend/server/schema.prisma @@ -21,6 +21,7 @@ model User { /// Indicate whether the user finished the signup progress. /// for example, the value will be false if user never registered and invited into a workspace by others. registered Boolean @default(true) + disabled Boolean @default(false) features UserFeature[] userStripeCustomer UserStripeCustomer? @@ -130,8 +131,8 @@ model WorkspaceDoc { mode Int @default(0) @db.SmallInt // Whether the doc is blocked blocked Boolean @default(false) - title String? @db.VarChar - summary String? @db.VarChar + title String? @db.VarChar + summary String? @db.VarChar workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) @@ -647,16 +648,16 @@ enum NotificationLevel { } model Notification { - id String @id @default(uuid()) @db.VarChar - userId String @map("user_id") @db.VarChar - createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) - updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) - level NotificationLevel - read Boolean @default(false) - type NotificationType - body Json @db.JsonB + id String @id @default(uuid()) @db.VarChar + userId String @map("user_id") @db.VarChar + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + level NotificationLevel + read Boolean @default(false) + type NotificationType + body Json @db.JsonB - user User @relation(name: "user_notifications", fields: [userId], references: [id], onDelete: Cascade) + user User @relation(name: "user_notifications", fields: [userId], references: [id], onDelete: Cascade) // for user notifications list, including read and unread, ordered by createdAt @@index([userId, createdAt, read]) diff --git a/packages/backend/server/src/__tests__/models/session.spec.ts b/packages/backend/server/src/__tests__/models/session.spec.ts index 83332aad1..66630b2f6 100644 --- a/packages/backend/server/src/__tests__/models/session.spec.ts +++ b/packages/backend/server/src/__tests__/models/session.spec.ts @@ -214,9 +214,9 @@ test('should delete userSession success by userId', async t => { data: {}, }); await t.context.session.createOrRefreshUserSession(user.id, session.id); - let count = await t.context.session.deleteUserSession(user.id); + let count = await t.context.session.deleteUserSessions(user.id); t.is(count, 1); - count = await t.context.session.deleteUserSession(user.id); + count = await t.context.session.deleteUserSessions(user.id); t.is(count, 0); }); @@ -228,7 +228,7 @@ test('should delete userSession success by userId and sessionId', async t => { data: {}, }); await t.context.session.createOrRefreshUserSession(user.id, session.id); - const count = await t.context.session.deleteUserSession(user.id, session.id); + const count = await t.context.session.deleteUserSessions(user.id, session.id); t.is(count, 1); }); @@ -240,7 +240,7 @@ test('should delete userSession fail when sessionId not match', async t => { data: {}, }); await t.context.session.createOrRefreshUserSession(user.id, session.id); - const count = await t.context.session.deleteUserSession( + const count = await t.context.session.deleteUserSessions( user.id, 'not-exists-session-id' ); diff --git a/packages/backend/server/src/__tests__/models/user.spec.ts b/packages/backend/server/src/__tests__/models/user.spec.ts index 3db2728a8..5b5cd5fc9 100644 --- a/packages/backend/server/src/__tests__/models/user.spec.ts +++ b/packages/backend/server/src/__tests__/models/user.spec.ts @@ -181,21 +181,6 @@ test('should get workspace user by id', async t => { t.is(workspaceUser!.email, user.email); }); -test('should get workspace user by email', async t => { - const user = await t.context.user.create({ - email: 'test@affine.pro', - }); - - const workspaceUser = await t.context.user.getWorkspaceUserByEmail( - user.email - ); - - t.not(workspaceUser, null); - t.is(workspaceUser!.id, user.id); - t.true(!('password' in workspaceUser!)); - t.is(workspaceUser!.email, user.email); -}); - test('should get user by email', async t => { const user = await t.context.user.create({ email: 'test@affine.pro', @@ -305,6 +290,7 @@ test('should paginate users', async t => { name: `test-paginate-${i}`, email: `test-paginate-${i}@affine.pro`, createdAt: new Date(now + i), + disabled: i % 2 === 0, }) ) ); @@ -317,14 +303,94 @@ test('should paginate users', async t => { ); }); -test('should check if user exists', async t => { +// #region disabled user +test('should not get disabled user by default', async t => { + const user = await t.context.user.create({ + email: 'test@affine.pro', + disabled: true, + }); + + const user2 = await t.context.user.get(user.id); + const user3 = await t.context.user.getPublicUser(user.id); + const user4 = await t.context.user.getPublicUserByEmail(user.email); + const userList1 = await t.context.user.getPublicUsers([user.id]); + const user5 = await t.context.user.getWorkspaceUser(user.id); + const userList2 = await t.context.user.getWorkspaceUsers([user.id]); + + t.is(user2, null); + t.is(user3, null); + t.is(user4, null); + t.is(user5, null); + t.is(userList1.length, 0); + t.is(userList2.length, 0); +}); + +test('should get disabled user `withDisabled`', async t => { + const user = await t.context.user.create({ + email: 'test@affine.pro', + disabled: true, + }); + + const user2 = await t.context.user.get(user.id, { withDisabled: true }); + const user3 = await t.context.user.getUserByEmail(user.email, { + withDisabled: true, + }); + + t.is(user2!.id, user.id); + t.is(user3!.id, user.id); +}); + +test('should not be able to update email to disabled user', async t => { + const user = await t.context.user.create({ + email: 'test@affine.pro', + disabled: false, + }); + const user2 = await t.context.user.create({ + email: 'test2@affine.pro', + disabled: true, + }); + + await t.throwsAsync( + t.context.user.update(user.id, { + email: user2.email, + }), + { + instanceOf: EmailAlreadyUsed, + } + ); +}); + +test('should ban user', async t => { const user = await t.context.user.create({ email: 'test@affine.pro', }); - t.true(await t.context.user.exists(user.id)); - t.false(await t.context.user.exists('non-existing-user')); + const event = t.context.module.get(EventBus); + const spy = Sinon.spy(); + event.on('user.deleted', spy); + + await t.context.user.ban(user.id); + + t.true(spy.calledOnce); + const user2 = await t.context.user.get(user.id); + t.is(user2, null); }); +test('should enable user', async t => { + const user = await t.context.user.create({ + email: 'test@affine.pro', + disabled: true, + }); + + const user2 = await t.context.user.enable(user.id); + + t.is(user2.disabled, false); + + const user3 = await t.context.user.get(user.id); + t.is(user3!.id, user.id); +}); + +// #endregion + // #region ConnectedAccount test('should create, get, update, delete connected account', async t => { diff --git a/packages/backend/server/src/core/auth/service.ts b/packages/backend/server/src/core/auth/service.ts index 1526e32ff..c6aff40c7 100644 --- a/packages/backend/server/src/core/auth/service.ts +++ b/packages/backend/server/src/core/auth/service.ts @@ -10,11 +10,11 @@ import type { CurrentUser } from './session'; export function sessionUser( user: Pick< User, - 'id' | 'email' | 'avatarUrl' | 'name' | 'emailVerifiedAt' + 'id' | 'email' | 'avatarUrl' | 'name' | 'emailVerifiedAt' | 'disabled' > & { password?: string | null } ): CurrentUser { // use pick to avoid unexpected fields - return assign(pick(user, 'id', 'email', 'avatarUrl', 'name'), { + return assign(pick(user, 'id', 'email', 'avatarUrl', 'name', 'disabled'), { hasPassword: user.password !== null, emailVerified: user.emailVerifiedAt !== null, }); @@ -105,7 +105,7 @@ export class AuthService implements OnApplicationBootstrap { if (!userId) { await this.models.session.deleteSession(sessionId); } else { - await this.models.session.deleteUserSession(userId, sessionId); + await this.models.session.deleteUserSessions(userId, sessionId); } } @@ -195,7 +195,7 @@ export class AuthService implements OnApplicationBootstrap { } async revokeUserSessions(userId: string) { - return await this.models.session.deleteUserSession(userId); + return await this.models.session.deleteUserSessions(userId); } getSessionOptionsFromRequest(req: Request) { diff --git a/packages/backend/server/src/core/auth/session.ts b/packages/backend/server/src/core/auth/session.ts index 9a42e5fca..bdb93d239 100644 --- a/packages/backend/server/src/core/auth/session.ts +++ b/packages/backend/server/src/core/auth/session.ts @@ -45,7 +45,7 @@ export const CurrentUser = createParamDecorator( ); export interface CurrentUser - extends Pick { + extends Pick { hasPassword: boolean | null; emailVerified: boolean; } diff --git a/packages/backend/server/src/core/user/resolver.ts b/packages/backend/server/src/core/user/resolver.ts index 21b1215bc..73b2fe885 100644 --- a/packages/backend/server/src/core/user/resolver.ts +++ b/packages/backend/server/src/core/user/resolver.ts @@ -202,7 +202,9 @@ export class UserManagementResolver { description: 'Get user by id', }) async getUser(@Args('id') id: string) { - const user = await this.models.user.get(id); + const user = await this.models.user.get(id, { + withDisabled: true, + }); if (!user) { return null; @@ -217,7 +219,9 @@ export class UserManagementResolver { nullable: true, }) async getUserByEmail(@Args('email') email: string) { - const user = await this.models.user.getUserByEmail(email); + const user = await this.models.user.getUserByEmail(email, { + withDisabled: true, + }); if (!user) { return null; @@ -256,7 +260,7 @@ export class UserManagementResolver { } @Mutation(() => UserType, { - description: 'Update a user', + description: 'Update an user', }) async updateUser( @Args('id') id: string, @@ -282,4 +286,18 @@ export class UserManagementResolver { }) ); } + + @Mutation(() => UserType, { + description: 'Ban an user', + }) + async banUser(@Args('id') id: string): Promise { + return sessionUser(await this.models.user.ban(id)); + } + + @Mutation(() => UserType, { + description: 'Reenable an banned user', + }) + async enableUser(@Args('id') id: string): Promise { + return sessionUser(await this.models.user.enable(id)); + } } diff --git a/packages/backend/server/src/core/user/types.ts b/packages/backend/server/src/core/user/types.ts index 941efd611..6affbf9c0 100644 --- a/packages/backend/server/src/core/user/types.ts +++ b/packages/backend/server/src/core/user/types.ts @@ -41,6 +41,11 @@ export class UserType implements CurrentUser { nullable: true, }) createdAt?: Date | null; + + @Field(() => Boolean, { + description: 'User is disabled', + }) + disabled!: boolean; } @ObjectType() diff --git a/packages/backend/server/src/models/doc-user.ts b/packages/backend/server/src/models/doc-user.ts index 911044409..8695160a2 100644 --- a/packages/backend/server/src/models/doc-user.ts +++ b/packages/backend/server/src/models/doc-user.ts @@ -144,6 +144,14 @@ export class DocUserModel extends BaseModel { }); } + async deleteByUserId(userId: string) { + await this.db.workspaceDocUserRole.deleteMany({ + where: { + userId, + }, + }); + } + async getOwner(workspaceId: string, docId: string) { return await this.db.workspaceDocUserRole.findFirst({ where: { diff --git a/packages/backend/server/src/models/session.ts b/packages/backend/server/src/models/session.ts index c60cae75c..ecef83b50 100644 --- a/packages/backend/server/src/models/session.ts +++ b/packages/backend/server/src/models/session.ts @@ -123,7 +123,7 @@ export class SessionModel extends BaseModel { }); } - async deleteUserSession(userId: string, sessionId?: string) { + async deleteUserSessions(userId: string, sessionId?: string) { const { count } = await this.db.userSession.deleteMany({ where: { userId, @@ -131,7 +131,7 @@ export class SessionModel extends BaseModel { }, }); this.logger.log( - `Deleted user session success by userId: ${userId} and sessionId: ${sessionId}` + `Deleted user sessions success by userId: ${userId} and sessionId: ${sessionId}` ); return count; } diff --git a/packages/backend/server/src/models/user.ts b/packages/backend/server/src/models/user.ts index b8e598e1e..a176fccc0 100644 --- a/packages/backend/server/src/models/user.ts +++ b/packages/backend/server/src/models/user.ts @@ -1,5 +1,7 @@ import { Injectable } from '@nestjs/common'; +import { Transactional } from '@nestjs-cls/transactional'; import { type ConnectedAccount, Prisma, type User } from '@prisma/client'; +import { omit } from 'lodash-es'; import { CryptoHelper, @@ -49,6 +51,10 @@ declare global { } } +interface UserFilter { + withDisabled?: boolean; +} + export type PublicUser = Pick; export type WorkspaceUser = Pick; export type { ConnectedAccount, User }; @@ -62,52 +68,49 @@ export class UserModel extends BaseModel { super(); } - async get(id: string) { + async get(id: string, filter: UserFilter = {}) { return this.db.user.findUnique({ - where: { id }, + where: { id, disabled: filter.withDisabled ? undefined : false }, }); } - async exists(id: string) { - const count = await this.db.user.count({ - where: { id }, - }); - return count > 0; - } - async getPublicUser(id: string): Promise { return this.db.user.findUnique({ select: publicUserSelect, - where: { id }, + where: { id, disabled: false }, }); } async getPublicUsers(ids: string[]): Promise { return this.db.user.findMany({ select: publicUserSelect, - where: { id: { in: ids } }, + where: { id: { in: ids }, disabled: false }, }); } async getWorkspaceUser(id: string): Promise { return this.db.user.findUnique({ select: workspaceUserSelect, - where: { id }, + where: { id, disabled: false }, }); } async getWorkspaceUsers(ids: string[]): Promise { return this.db.user.findMany({ select: workspaceUserSelect, - where: { id: { in: ids } }, + where: { id: { in: ids }, disabled: false }, }); } - async getUserByEmail(email: string): Promise { + async getUserByEmail( + email: string, + filter: UserFilter = {} + ): Promise { const rows = await this.db.$queryRaw` SELECT id, name, email, password, registered, email_verified as emailVerifiedAt, avatar_url as avatarUrl, registered, created_at as createdAt FROM "users" WHERE lower("email") = lower(${email}) + ${Prisma.raw(filter.withDisabled ? '' : 'AND disabled = false')} `; return rows[0] ?? null; @@ -141,23 +144,14 @@ export class UserModel extends BaseModel { SELECT id, name, avatar_url as avatarUrl FROM "users" WHERE lower("email") = lower(${email}) - `; - - return rows[0] ?? null; - } - - async getWorkspaceUserByEmail(email: string): Promise { - const rows = await this.db.$queryRaw` - SELECT id, name, email, avatar_url as avatarUrl - FROM "users" - WHERE lower("email") = lower(${email}) + AND disabled = false `; return rows[0] ?? null; } async create(data: CreateUserInput) { - let user = await this.getUserByEmail(data.email); + let user = await this.getUserByEmail(data.email, { withDisabled: true }); if (user) { throw new EmailAlreadyUsed(); @@ -183,13 +177,16 @@ export class UserModel extends BaseModel { return user; } + @Transactional() async update(id: string, data: UpdateUserInput) { if (data.password) { data.password = await this.crypto.encryptPassword(data.password); } if (data.email) { - const user = await this.getUserByEmail(data.email); + const user = await this.getUserByEmail(data.email, { + withDisabled: true, + }); if (user && user.id !== id) { throw new EmailAlreadyUsed(); } @@ -211,7 +208,7 @@ export class UserModel extends BaseModel { * When user created by others invitation, we will leave it as unregistered. */ async fulfill(email: string, data: Omit = {}) { - const user = await this.getUserByEmail(email); + const user = await this.getUserByEmail(email, { withDisabled: true }); if (!user) { return this.create({ @@ -258,6 +255,30 @@ export class UserModel extends BaseModel { return user; } + async ban(id: string) { + // ban an user barely share the same logic with delete an user, + // but keep the record with `disabled` flag + // we delete the account and create it again to trigger all cleanups + let user = await this.delete(id); + user = await this.db.user.create({ + data: { + ...omit(user, 'id'), + disabled: true, + }, + }); + + await this.event.emitAsync('user.postCreated', user); + + return user; + } + + async enable(id: string) { + return await this.db.user.update({ + where: { id }, + data: { disabled: false }, + }); + } + async pagination(skip: number = 0, take: number = 20, after?: Date) { return this.db.user.findMany({ where: { diff --git a/packages/backend/server/src/models/workspace-user.ts b/packages/backend/server/src/models/workspace-user.ts index 974e340b2..2970f8b56 100644 --- a/packages/backend/server/src/models/workspace-user.ts +++ b/packages/backend/server/src/models/workspace-user.ts @@ -196,6 +196,14 @@ export class WorkspaceUserModel extends BaseModel { }); } + async deleteByUserId(userId: string) { + await this.db.workspaceUserRole.deleteMany({ + where: { + userId, + }, + }); + } + async get(workspaceId: string, userId: string) { return await this.db.workspaceUserRole.findUnique({ where: { diff --git a/packages/backend/server/src/plugins/payment/service.ts b/packages/backend/server/src/plugins/payment/service.ts index 8492bf4b2..9587b2de7 100644 --- a/packages/backend/server/src/plugins/payment/service.ts +++ b/packages/backend/server/src/plugins/payment/service.ts @@ -574,7 +574,7 @@ export class SubscriptionService implements OnApplicationBootstrap { return null; } - const user = await this.models.user.getWorkspaceUserByEmail(customer.email); + const user = await this.models.user.getUserByEmail(customer.email); if (!user) { return { @@ -620,9 +620,7 @@ export class SubscriptionService implements OnApplicationBootstrap { return null; } - const user = await this.models.user.getWorkspaceUserByEmail( - invoice.customer_email - ); + const user = await this.models.user.getUserByEmail(invoice.customer_email); return { userId: user?.id, diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index 2b2353f99..4ce5d2426 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -618,6 +618,9 @@ type InviteUserType { """User email verified""" createdAt: DateTime @deprecated(reason: "useless") + """User is disabled""" + disabled: Boolean + """User email""" email: String @@ -767,6 +770,9 @@ type Mutation { addContextDoc(options: AddContextDocInput!): [CopilotContextListItem!]! addWorkspaceFeature(feature: FeatureType!, workspaceId: String!): Boolean! approveMember(userId: String!, workspaceId: String!): Boolean! + + """Ban an user""" + banUser(id: String!): UserType! cancelSubscription(idempotencyKey: String @deprecated(reason: "use header `Idempotency-Key`"), plan: SubscriptionPlan = Pro, workspaceId: String): SubscriptionType! changeEmail(email: String!, token: String!): UserType! changePassword(newPassword: String!, token: String!, userId: String): Boolean! @@ -810,6 +816,9 @@ type Mutation { deleteUser(id: String!): DeleteAccount! deleteWorkspace(id: String!): Boolean! + """Reenable an banned user""" + enableUser(id: String!): UserType! + """Create a chat session""" forkCopilotSession(options: ForkChatSessionInput!): String! generateLicenseKey(sessionId: String!): String! @@ -864,7 +873,7 @@ type Mutation { updateRuntimeConfigs(updates: JSONObject!): [ServerRuntimeConfigType!]! updateSubscriptionRecurring(idempotencyKey: String @deprecated(reason: "use header `Idempotency-Key`"), plan: SubscriptionPlan = Pro, recurring: SubscriptionRecurring!, workspaceId: String): SubscriptionType! - """Update a user""" + """Update an user""" updateUser(id: String!, input: ManageUserInput!): UserType! """update user enabled feature""" @@ -1359,6 +1368,9 @@ type UserType { """User email verified""" createdAt: DateTime @deprecated(reason: "useless") + """User is disabled""" + disabled: Boolean! + """User email""" email: String!