From fb3a0e7b8f7350296f676cc570ea31caa6e04021 Mon Sep 17 00:00:00 2001 From: liuyi Date: Tue, 12 Mar 2024 10:00:09 +0000 Subject: [PATCH] refactor(server): auth (#5895) Remove `next-auth` and implement our own Authorization/Authentication system from scratch. ## Server - [x] tokens - [x] function - [x] encryption - [x] AuthController - [x] /api/auth/sign-in - [x] /api/auth/sign-out - [x] /api/auth/session - [x] /api/auth/session (WE SUPPORT MULTI-ACCOUNT!) - [x] OAuthPlugin - [x] OAuthController - [x] /oauth/login - [x] /oauth/callback - [x] Providers - [x] Google - [x] GitHub ## Client - [x] useSession - [x] cloudSignIn - [x] cloudSignOut ## NOTE: Tests will be adding in the future --- .eslintrc.js | 22 - .github/workflows/build-test.yml | 16 +- package.json | 1 - .../20240228065558_new_auth/migration.sql | 70 +++ packages/backend/server/package.json | 4 +- packages/backend/server/schema.prisma | 81 ++- packages/backend/server/scripts/init-db.ts | 37 -- packages/backend/server/src/app.module.ts | 20 +- .../backend/server/src/config/affine.env.ts | 10 +- .../backend/server/src/config/affine.self.ts | 1 + packages/backend/server/src/config/affine.ts | 24 + .../server/src/core/auth/controller.ts | 212 +++++++ .../server/src/core/auth/current-user.ts | 55 ++ .../backend/server/src/core/auth/guard.ts | 167 ++---- .../backend/server/src/core/auth/index.ts | 19 +- .../server/src/core/auth/next-auth-options.ts | 286 ---------- .../src/core/auth/next-auth.controller.ts | 411 ------------- .../backend/server/src/core/auth/resolver.ts | 280 +++++---- .../backend/server/src/core/auth/service.ts | 538 ++++++++++-------- .../backend/server/src/core/auth/token.ts | 84 +++ .../server/src/core/auth/utils/index.ts | 3 - .../backend/server/src/core/auth/utils/jwt.ts | 76 --- .../server/src/core/auth/utils/send-mail.ts | 38 -- packages/backend/server/src/core/config.ts | 9 +- .../server/src/core/features/service.ts | 5 +- .../backend/server/src/core/quota/schema.ts | 2 +- .../backend/server/src/core/quota/types.ts | 2 +- .../src/core/sync/events/events.gateway.ts | 9 +- .../src/core/{users => user}/controller.ts | 0 .../server/src/core/{users => user}/index.ts | 10 +- .../src/core/{users => user}/management.ts | 22 +- .../src/core/{users => user}/resolver.ts | 95 ++-- .../backend/server/src/core/user/service.ts | 112 ++++ .../server/src/core/{users => user}/types.ts | 40 +- .../backend/server/src/core/users/users.ts | 32 -- .../server/src/core/utils/validators.ts | 55 ++ .../server/src/core/workspaces/controller.ts | 11 +- .../server/src/core/workspaces/index.ts | 5 +- .../server/src/core/workspaces/management.ts | 14 +- .../src/core/workspaces/resolvers/blob.ts | 16 +- .../src/core/workspaces/resolvers/history.ts | 6 +- .../src/core/workspaces/resolvers/page.ts | 12 +- .../core/workspaces/resolvers/workspace.ts | 36 +- .../server/src/core/workspaces/types.ts | 2 +- .../1605053000403-self-host-admin.ts | 21 +- .../server/src/fundamentals/config/def.ts | 99 ++-- .../server/src/fundamentals/config/default.ts | 33 +- .../helpers/__tests__/crypto.spec.ts | 105 ++++ .../helpers/__tests__/url.spec.ts | 72 +++ .../server/src/fundamentals/helpers/crypto.ts | 115 ++++ .../server/src/fundamentals/helpers/index.ts | 13 + .../server/src/fundamentals/helpers/url.ts | 54 ++ .../backend/server/src/fundamentals/index.ts | 2 +- .../src/fundamentals/mailer/mail.service.ts | 36 +- .../fundamentals/nestjs/optional-module.ts | 2 +- .../server/src/fundamentals/session/index.ts | 44 -- .../server/src/fundamentals/utils/request.ts | 89 ++- packages/backend/server/src/global.d.ts | 2 +- packages/backend/server/src/plugins/config.ts | 4 +- .../server/src/plugins/gcloud/index.ts | 5 +- packages/backend/server/src/plugins/index.ts | 18 +- .../server/src/plugins/oauth/controller.ts | 230 ++++++++ .../backend/server/src/plugins/oauth/index.ts | 25 + .../server/src/plugins/oauth/providers/def.ts | 21 + .../src/plugins/oauth/providers/github.ts | 113 ++++ .../src/plugins/oauth/providers/google.ts | 121 ++++ .../src/plugins/oauth/providers/index.ts | 4 + .../server/src/plugins/oauth/register.ts | 58 ++ .../server/src/plugins/oauth/resolver.ts | 17 + .../server/src/plugins/oauth/service.ts | 39 ++ .../backend/server/src/plugins/oauth/types.ts | 15 + .../server/src/plugins/payment/index.ts | 5 +- .../server/src/plugins/payment/resolver.ts | 17 +- .../server/src/plugins/payment/service.ts | 7 +- .../backend/server/src/plugins/redis/index.ts | 6 +- .../backend/server/src/plugins/registry.ts | 22 + .../server/src/plugins/storage/index.ts | 8 +- packages/backend/server/src/schema.gql | 45 +- packages/backend/server/tests/app.e2e.ts | 179 +----- packages/backend/server/tests/auth.e2e.ts | 8 +- packages/backend/server/tests/auth.spec.ts | 172 ------ packages/backend/server/tests/feature.spec.ts | 7 +- packages/backend/server/tests/mailer.e2e.ts | 10 +- packages/backend/server/tests/session.spec.ts | 40 -- packages/backend/server/tests/utils/user.ts | 33 +- packages/backend/server/tests/utils/utils.ts | 15 +- .../auth-components/email-verified-email.tsx | 22 + .../auth-components/onboarding-page.tsx | 2 +- .../src/components/auth-components/type.ts | 1 + .../not-found-page/not-found-page.tsx | 9 +- packages/frontend/core/.webpack/config.ts | 1 + packages/frontend/core/package.json | 1 - .../frontend/core/src/atoms/cloud-user.ts | 4 - .../core/src/components/affine/auth/index.tsx | 6 +- .../core/src/components/affine/auth/oauth.tsx | 66 +++ .../src/components/affine/auth/send-email.tsx | 26 +- .../affine/auth/sign-in-with-password.tsx | 10 +- .../src/components/affine/auth/sign-in.tsx | 26 +- .../src/components/affine/auth/use-auth.ts | 93 ++- .../src/components/affine/awareness/index.tsx | 10 +- .../setting-modal/account-setting/index.tsx | 43 +- .../setting-modal/setting-sidebar/index.tsx | 7 +- .../share-header-right-item/user-avatar.tsx | 6 +- .../core/src/components/pure/footer/index.tsx | 2 +- .../user-with-workspace-list/index.tsx | 11 +- .../workspace-list/index.tsx | 6 +- .../hooks/affine/use-current-login-status.ts | 8 +- .../core/src/hooks/affine/use-current-user.ts | 124 ++-- .../affine/use-delete-collection-info.ts | 9 +- .../src/hooks/affine/use-server-config.ts | 17 +- packages/frontend/core/src/pages/404.tsx | 19 +- packages/frontend/core/src/pages/auth.tsx | 32 +- .../core/src/pages/desktop-signin.tsx | 31 +- .../core/src/providers/modal-provider.tsx | 4 +- .../core/src/providers/session-provider.tsx | 32 +- .../frontend/core/src/utils/cloud-utils.tsx | 99 ++-- .../frontend/electron/src/main/deep-link.ts | 15 +- .../graphql/src/graphql/change-email.gql | 6 +- .../graphql/src/graphql/change-password.gql | 3 - .../graphql/src/graphql/early-access-list.gql | 1 - .../graphql/src/graphql/get-current-user.gql | 1 - .../src/graphql/get-oauth-providers.gql | 5 + .../frontend/graphql/src/graphql/index.ts | 102 ++-- .../graphql/src/graphql/send-change-email.gql | 4 +- .../graphql/send-change-password-email.gql | 4 +- .../src/graphql/send-set-password-email.gql | 4 +- .../graphql/src/graphql/send-verify-email.gql | 3 + .../frontend/graphql/src/graphql/sign-in.gql | 7 - .../frontend/graphql/src/graphql/sign-up.gql | 7 - .../src/graphql/update-user-profile.gql | 6 + .../graphql/src/graphql/verify-email.gql | 3 + packages/frontend/graphql/src/schema.ts | 143 ++--- packages/frontend/i18n/src/resources/en.json | 11 +- packages/frontend/i18n/src/resources/fr.json | 8 +- packages/frontend/i18n/src/resources/ko.json | 8 +- .../frontend/i18n/src/resources/pt-BR.json | 6 +- packages/frontend/i18n/src/resources/ru.json | 4 +- .../frontend/i18n/src/resources/zh-Hans.json | 9 +- .../frontend/i18n/src/resources/zh-Hant.json | 7 +- packages/frontend/native/index.js | 73 ++- packages/frontend/templates/templates.gen.ts | 12 +- packages/frontend/workspace-impl/package.json | 1 - .../frontend/workspace-impl/src/cloud/list.ts | 13 +- tests/affine-cloud/e2e/login.spec.ts | 1 + tests/kit/utils/cloud.ts | 6 +- tests/storybook/.storybook/preview.tsx | 50 -- tests/storybook/package.json | 1 - yarn.lock | 126 +--- 148 files changed, 3407 insertions(+), 2851 deletions(-) create mode 100644 packages/backend/server/migrations/20240228065558_new_auth/migration.sql delete mode 100644 packages/backend/server/scripts/init-db.ts create mode 100644 packages/backend/server/src/core/auth/controller.ts create mode 100644 packages/backend/server/src/core/auth/current-user.ts delete mode 100644 packages/backend/server/src/core/auth/next-auth-options.ts delete mode 100644 packages/backend/server/src/core/auth/next-auth.controller.ts create mode 100644 packages/backend/server/src/core/auth/token.ts delete mode 100644 packages/backend/server/src/core/auth/utils/index.ts delete mode 100644 packages/backend/server/src/core/auth/utils/jwt.ts delete mode 100644 packages/backend/server/src/core/auth/utils/send-mail.ts rename packages/backend/server/src/core/{users => user}/controller.ts (100%) rename packages/backend/server/src/core/{users => user}/index.ts (69%) rename packages/backend/server/src/core/{users => user}/management.ts (80%) rename packages/backend/server/src/core/{users => user}/resolver.ts (74%) create mode 100644 packages/backend/server/src/core/user/service.ts rename packages/backend/server/src/core/{users => user}/types.ts (73%) delete mode 100644 packages/backend/server/src/core/users/users.ts create mode 100644 packages/backend/server/src/core/utils/validators.ts create mode 100644 packages/backend/server/src/fundamentals/helpers/__tests__/crypto.spec.ts create mode 100644 packages/backend/server/src/fundamentals/helpers/__tests__/url.spec.ts create mode 100644 packages/backend/server/src/fundamentals/helpers/crypto.ts create mode 100644 packages/backend/server/src/fundamentals/helpers/index.ts create mode 100644 packages/backend/server/src/fundamentals/helpers/url.ts delete mode 100644 packages/backend/server/src/fundamentals/session/index.ts create mode 100644 packages/backend/server/src/plugins/oauth/controller.ts create mode 100644 packages/backend/server/src/plugins/oauth/index.ts create mode 100644 packages/backend/server/src/plugins/oauth/providers/def.ts create mode 100644 packages/backend/server/src/plugins/oauth/providers/github.ts create mode 100644 packages/backend/server/src/plugins/oauth/providers/google.ts create mode 100644 packages/backend/server/src/plugins/oauth/providers/index.ts create mode 100644 packages/backend/server/src/plugins/oauth/register.ts create mode 100644 packages/backend/server/src/plugins/oauth/resolver.ts create mode 100644 packages/backend/server/src/plugins/oauth/service.ts create mode 100644 packages/backend/server/src/plugins/oauth/types.ts create mode 100644 packages/backend/server/src/plugins/registry.ts delete mode 100644 packages/backend/server/tests/auth.spec.ts delete mode 100644 packages/backend/server/tests/session.spec.ts create mode 100644 packages/frontend/component/src/components/auth-components/email-verified-email.tsx delete mode 100644 packages/frontend/core/src/atoms/cloud-user.ts create mode 100644 packages/frontend/core/src/components/affine/auth/oauth.tsx create mode 100644 packages/frontend/graphql/src/graphql/get-oauth-providers.gql create mode 100644 packages/frontend/graphql/src/graphql/send-verify-email.gql delete mode 100644 packages/frontend/graphql/src/graphql/sign-in.gql delete mode 100644 packages/frontend/graphql/src/graphql/sign-up.gql create mode 100644 packages/frontend/graphql/src/graphql/update-user-profile.gql create mode 100644 packages/frontend/graphql/src/graphql/verify-email.gql diff --git a/.eslintrc.js b/.eslintrc.js index 0584f1209c..fbfe60b9d3 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -31,17 +31,6 @@ const createPattern = packageName => [ message: 'Use `useNavigateHelper` instead', importNames: ['useNavigate'], }, - { - group: ['next-auth/react'], - message: "Import hooks from 'use-current-user.tsx'", - // useSession is type unsafe - importNames: ['useSession'], - }, - { - group: ['next-auth/react'], - message: "Import hooks from 'cloud-utils.ts'", - importNames: ['signIn', 'signOut'], - }, { group: ['yjs'], message: 'Do not use this API because it has a bug', @@ -179,17 +168,6 @@ const config = { message: 'Use `useNavigateHelper` instead', importNames: ['useNavigate'], }, - { - group: ['next-auth/react'], - message: "Import hooks from 'use-current-user.tsx'", - // useSession is type unsafe - importNames: ['useSession'], - }, - { - group: ['next-auth/react'], - message: "Import hooks from 'cloud-utils.ts'", - importNames: ['signIn', 'signOut'], - }, { group: ['yjs'], message: 'Do not use this API because it has a bug', diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index dc66c88f61..6de163706b 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -336,17 +336,11 @@ jobs: env: PGPASSWORD: affine - - name: Generate prisma client + - name: Run init-db script run: | yarn workspace @affine/server exec prisma generate yarn workspace @affine/server exec prisma db push - env: - DATABASE_URL: postgresql://affine:affine@localhost:5432/affine - - - name: Run init-db script - run: | yarn workspace @affine/server data-migration run - yarn workspace @affine/server exec node --loader ts-node/esm/transpile-only ./scripts/init-db.ts env: DATABASE_URL: postgresql://affine:affine@localhost:5432/affine @@ -435,17 +429,11 @@ jobs: env: PGPASSWORD: affine - - name: Generate prisma client + - name: Run init-db script run: | yarn workspace @affine/server exec prisma generate yarn workspace @affine/server exec prisma db push - env: - DATABASE_URL: postgresql://affine:affine@localhost:5432/affine - - - name: Run init-db script - run: | yarn workspace @affine/server data-migration run - yarn workspace @affine/server exec node --loader ts-node/esm/transpile-only ./scripts/init-db.ts - name: ${{ matrix.tests.name }} run: | diff --git a/package.json b/package.json index 05ff1882ee..ef7d1ec4e5 100644 --- a/package.json +++ b/package.json @@ -167,7 +167,6 @@ "unbox-primitive": "npm:@nolyfill/unbox-primitive@latest", "which-boxed-primitive": "npm:@nolyfill/which-boxed-primitive@latest", "which-typed-array": "npm:@nolyfill/which-typed-array@latest", - "next-auth@^4.24.5": "patch:next-auth@npm%3A4.24.5#~/.yarn/patches/next-auth-npm-4.24.5-8428e11927.patch", "@reforged/maker-appimage/@electron-forge/maker-base": "7.3.0", "macos-alias": "npm:@napi-rs/macos-alias@latest", "fs-xattr": "npm:@napi-rs/xattr@latest", diff --git a/packages/backend/server/migrations/20240228065558_new_auth/migration.sql b/packages/backend/server/migrations/20240228065558_new_auth/migration.sql new file mode 100644 index 0000000000..303cd422f6 --- /dev/null +++ b/packages/backend/server/migrations/20240228065558_new_auth/migration.sql @@ -0,0 +1,70 @@ +-- DropForeignKey +ALTER TABLE "accounts" DROP CONSTRAINT "accounts_user_id_fkey"; + +-- DropForeignKey +ALTER TABLE "sessions" DROP CONSTRAINT "sessions_user_id_fkey"; + +-- CreateTable +CREATE TABLE "user_connected_accounts" ( + "id" VARCHAR(36) NOT NULL, + "user_id" VARCHAR(36) NOT NULL, + "provider" VARCHAR NOT NULL, + "provider_account_id" VARCHAR NOT NULL, + "scope" TEXT, + "access_token" TEXT, + "refresh_token" TEXT, + "expires_at" TIMESTAMPTZ(6), + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(6) NOT NULL, + + CONSTRAINT "user_connected_accounts_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "multiple_users_sessions" ( + "id" VARCHAR(36) NOT NULL, + "expires_at" TIMESTAMPTZ(6), + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "multiple_users_sessions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "user_sessions" ( + "id" VARCHAR(36) NOT NULL, + "session_id" VARCHAR(36) NOT NULL, + "user_id" VARCHAR(36) NOT NULL, + "expires_at" TIMESTAMPTZ(6), + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "user_sessions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "verification_tokens" ( + "token" VARCHAR(36) NOT NULL, + "type" SMALLINT NOT NULL, + "credential" TEXT, + "expiresAt" TIMESTAMPTZ(6) NOT NULL +); + +-- CreateIndex +CREATE INDEX "user_connected_accounts_user_id_idx" ON "user_connected_accounts"("user_id"); + +-- CreateIndex +CREATE INDEX "user_connected_accounts_provider_account_id_idx" ON "user_connected_accounts"("provider_account_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "user_sessions_session_id_user_id_key" ON "user_sessions"("session_id", "user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "verification_tokens_type_token_key" ON "verification_tokens"("type", "token"); + +-- AddForeignKey +ALTER TABLE "user_connected_accounts" ADD CONSTRAINT "user_connected_accounts_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "user_sessions" ADD CONSTRAINT "user_sessions_session_id_fkey" FOREIGN KEY ("session_id") REFERENCES "multiple_users_sessions"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "user_sessions" ADD CONSTRAINT "user_sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/backend/server/package.json b/packages/backend/server/package.json index 68eb0ae2fb..285f2cc695 100644 --- a/packages/backend/server/package.json +++ b/packages/backend/server/package.json @@ -74,7 +74,6 @@ "nanoid": "^5.0.6", "nest-commander": "^3.12.5", "nestjs-throttler-storage-redis": "^0.4.1", - "next-auth": "^4.24.5", "nodemailer": "^6.9.10", "on-headers": "^1.0.2", "parse-duration": "^1.1.0", @@ -143,7 +142,8 @@ "MAILER_USER": "noreply@toeverything.info", "MAILER_PASSWORD": "affine", "MAILER_SENDER": "noreply@toeverything.info", - "FEATURES_EARLY_ACCESS_PREVIEW": "false" + "FEATURES_EARLY_ACCESS_PREVIEW": "false", + "DEPLOYMENT_TYPE": "affine" } }, "nodemonConfig": { diff --git a/packages/backend/server/schema.prisma b/packages/backend/server/schema.prisma index ffc5be0b57..df67e2dfc6 100644 --- a/packages/backend/server/schema.prisma +++ b/packages/backend/server/schema.prisma @@ -10,28 +10,80 @@ datasource db { } model User { - id String @id @default(uuid()) @db.VarChar - name String - email String @unique - emailVerified DateTime? @map("email_verified") - // image field is for the next-auth - avatarUrl String? @map("avatar_url") @db.VarChar - createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + id String @id @default(uuid()) @db.VarChar + name String + email String @unique + emailVerifiedAt DateTime? @map("email_verified") + avatarUrl String? @map("avatar_url") @db.VarChar + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) /// Not available if user signed up through OAuth providers - password String? @db.VarChar + password String? @db.VarChar - accounts Account[] - sessions Session[] features UserFeatures[] customer UserStripeCustomer? subscription UserSubscription? invoices UserInvoice[] workspacePermissions WorkspaceUserPermission[] pagePermissions WorkspacePageUserPermission[] + connectedAccounts ConnectedAccount[] + sessions UserSession[] @@map("users") } +model ConnectedAccount { + id String @id @default(uuid()) @db.VarChar(36) + userId String @map("user_id") @db.VarChar(36) + provider String @db.VarChar + providerAccountId String @map("provider_account_id") @db.VarChar + scope String? @db.Text + accessToken String? @map("access_token") @db.Text + refreshToken String? @map("refresh_token") @db.Text + expiresAt DateTime? @map("expires_at") @db.Timestamptz(6) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@index([providerAccountId]) + @@map("user_connected_accounts") +} + +model Session { + id String @id @default(uuid()) @db.VarChar(36) + expiresAt DateTime? @map("expires_at") @db.Timestamptz(6) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + + userSessions UserSession[] + + @@map("multiple_users_sessions") +} + +model UserSession { + id String @id @default(uuid()) @db.VarChar(36) + sessionId String @map("session_id") @db.VarChar(36) + userId String @map("user_id") @db.VarChar(36) + expiresAt DateTime? @map("expires_at") @db.Timestamptz(6) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + + session Session @relation(fields: [sessionId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([sessionId, userId]) + @@map("user_sessions") +} + +model VerificationToken { + token String @db.VarChar(36) + type Int @db.SmallInt + credential String? @db.Text + expiresAt DateTime @db.Timestamptz(6) + + @@unique([type, token]) + @@map("verification_tokens") +} + model Workspace { id String @id @default(uuid()) @db.VarChar public Boolean @@ -186,7 +238,7 @@ model Features { @@map("features") } -model Account { +model DeprecatedNextAuthAccount { id String @id @default(cuid()) userId String @map("user_id") type String @@ -200,23 +252,20 @@ model Account { id_token String? @db.Text session_state String? - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - @@unique([provider, providerAccountId]) @@map("accounts") } -model Session { +model DeprecatedNextAuthSession { id String @id @default(cuid()) sessionToken String @unique @map("session_token") userId String @map("user_id") expires DateTime - user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@map("sessions") } -model VerificationToken { +model DeprecatedNextAuthVerificationToken { identifier String token String @unique expires DateTime diff --git a/packages/backend/server/scripts/init-db.ts b/packages/backend/server/scripts/init-db.ts deleted file mode 100644 index 8e850ea716..0000000000 --- a/packages/backend/server/scripts/init-db.ts +++ /dev/null @@ -1,37 +0,0 @@ -import userA from '@affine-test/fixtures/userA.json' assert { type: 'json' }; -import { hash } from '@node-rs/argon2'; -import { PrismaClient } from '@prisma/client'; -const prisma = new PrismaClient(); - -async function main() { - await prisma.user.create({ - data: { - ...userA, - password: await hash(userA.password), - features: { - create: { - reason: 'created by api sign up', - activated: true, - feature: { - connect: { - feature_version: { - feature: 'free_plan_v1', - version: 1, - }, - }, - }, - }, - }, - }, - }); -} - -main() - .then(async () => { - await prisma.$disconnect(); - }) - .catch(async e => { - console.error(e); - await prisma.$disconnect(); - process.exit(1); - }); diff --git a/packages/backend/server/src/app.module.ts b/packages/backend/server/src/app.module.ts index dd61261051..f5f44aaeb0 100644 --- a/packages/backend/server/src/app.module.ts +++ b/packages/backend/server/src/app.module.ts @@ -1,20 +1,20 @@ import { join } from 'node:path'; import { Logger, Module } from '@nestjs/common'; -import { APP_INTERCEPTOR } from '@nestjs/core'; +import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core'; import { ScheduleModule } from '@nestjs/schedule'; import { ServeStaticModule } from '@nestjs/serve-static'; import { get } from 'lodash-es'; import { AppController } from './app.controller'; -import { AuthModule } from './core/auth'; +import { AuthGuard, AuthModule } from './core/auth'; import { ADD_ENABLED_FEATURES, ServerConfigModule } from './core/config'; import { DocModule } from './core/doc'; import { FeatureModule } from './core/features'; import { QuotaModule } from './core/quota'; import { StorageModule } from './core/storage'; import { SyncModule } from './core/sync'; -import { UsersModule } from './core/users'; +import { UserModule } from './core/user'; import { WorkspaceModule } from './core/workspaces'; import { getOptionalModuleMetadata } from './fundamentals'; import { CacheInterceptor, CacheModule } from './fundamentals/cache'; @@ -25,14 +25,14 @@ import { } from './fundamentals/config'; import { EventModule } from './fundamentals/event'; import { GqlModule } from './fundamentals/graphql'; +import { HelpersModule } from './fundamentals/helpers'; import { MailModule } from './fundamentals/mailer'; import { MetricsModule } from './fundamentals/metrics'; import { PrismaModule } from './fundamentals/prisma'; -import { SessionModule } from './fundamentals/session'; import { StorageProviderModule } from './fundamentals/storage'; import { RateLimiterModule } from './fundamentals/throttler'; import { WebSocketModule } from './fundamentals/websocket'; -import { pluginsMap } from './plugins'; +import { REGISTERED_PLUGINS } from './plugins'; export const FunctionalityModules = [ ConfigModule.forRoot(), @@ -42,9 +42,9 @@ export const FunctionalityModules = [ PrismaModule, MetricsModule, RateLimiterModule, - SessionModule, MailModule, StorageProviderModule, + HelpersModule, ]; export class AppModuleBuilder { @@ -109,6 +109,10 @@ export class AppModuleBuilder { provide: APP_INTERCEPTOR, useClass: CacheInterceptor, }, + { + provide: APP_GUARD, + useClass: AuthGuard, + }, ], imports: this.modules, controllers: this.config.isSelfhosted ? [] : [AppController], @@ -141,7 +145,7 @@ function buildAppModule() { WebSocketModule, GqlModule, StorageModule, - UsersModule, + UserModule, WorkspaceModule, FeatureModule, QuotaModule @@ -157,7 +161,7 @@ function buildAppModule() { // plugin modules AFFiNE.plugins.enabled.forEach(name => { - const plugin = pluginsMap.get(name as AvailablePlugins); + const plugin = REGISTERED_PLUGINS.get(name as AvailablePlugins); if (!plugin) { throw new Error(`Unknown plugin ${name}`); } diff --git a/packages/backend/server/src/config/affine.env.ts b/packages/backend/server/src/config/affine.env.ts index 8b4aadc04d..e0e3f0799b 100644 --- a/packages/backend/server/src/config/affine.env.ts +++ b/packages/backend/server/src/config/affine.env.ts @@ -7,12 +7,10 @@ AFFiNE.ENV_MAP = { DATABASE_URL: 'db.url', ENABLE_CAPTCHA: ['auth.captcha.enable', 'boolean'], CAPTCHA_TURNSTILE_SECRET: ['auth.captcha.turnstile.secret', 'string'], - OAUTH_GOOGLE_ENABLED: ['auth.oauthProviders.google.enabled', 'boolean'], - OAUTH_GOOGLE_CLIENT_ID: 'auth.oauthProviders.google.clientId', - OAUTH_GOOGLE_CLIENT_SECRET: 'auth.oauthProviders.google.clientSecret', - OAUTH_GITHUB_ENABLED: ['auth.oauthProviders.github.enabled', 'boolean'], - OAUTH_GITHUB_CLIENT_ID: 'auth.oauthProviders.github.clientId', - OAUTH_GITHUB_CLIENT_SECRET: 'auth.oauthProviders.github.clientSecret', + OAUTH_GOOGLE_CLIENT_ID: 'plugins.oauth.providers.google.clientId', + OAUTH_GOOGLE_CLIENT_SECRET: 'plugins.oauth.providers.google.clientSecret', + OAUTH_GITHUB_CLIENT_ID: 'plugins.oauth.providers.github.clientId', + OAUTH_GITHUB_CLIENT_SECRET: 'plugins.oauth.providers.github.clientSecret', MAILER_HOST: 'mailer.host', MAILER_PORT: ['mailer.port', 'int'], MAILER_USER: 'mailer.auth.user', diff --git a/packages/backend/server/src/config/affine.self.ts b/packages/backend/server/src/config/affine.self.ts index 96cf69c842..157ee2360c 100644 --- a/packages/backend/server/src/config/affine.self.ts +++ b/packages/backend/server/src/config/affine.self.ts @@ -40,6 +40,7 @@ if (env.R2_OBJECT_STORAGE_ACCOUNT_ID) { AFFiNE.plugins.use('redis'); AFFiNE.plugins.use('payment'); +AFFiNE.plugins.use('oauth'); if (AFFiNE.deploy) { AFFiNE.mailer = { diff --git a/packages/backend/server/src/config/affine.ts b/packages/backend/server/src/config/affine.ts index d56a486529..84b82036f7 100644 --- a/packages/backend/server/src/config/affine.ts +++ b/packages/backend/server/src/config/affine.ts @@ -115,3 +115,27 @@ AFFiNE.plugins.use('payment', { // /* Update the provider of storages */ // AFFiNE.storage.storages.blob.provider = 'r2'; // AFFiNE.storage.storages.avatar.provider = 'r2'; +// +// /* OAuth Plugin */ +// AFFiNE.plugins.use('oauth', { +// providers: { +// github: { +// clientId: '', +// clientSecret: '', +// // See https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps +// args: { +// scope: 'user', +// }, +// }, +// google: { +// clientId: '', +// clientSecret: '', +// args: { +// // See https://developers.google.com/identity/protocols/oauth2 +// scope: 'openid email profile', +// promot: 'select_account', +// access_type: 'offline', +// }, +// }, +// }, +// }); diff --git a/packages/backend/server/src/core/auth/controller.ts b/packages/backend/server/src/core/auth/controller.ts new file mode 100644 index 0000000000..ccad6b1008 --- /dev/null +++ b/packages/backend/server/src/core/auth/controller.ts @@ -0,0 +1,212 @@ +import { randomUUID } from 'node:crypto'; + +import { + BadRequestException, + Body, + Controller, + Get, + Header, + Post, + Query, + Req, + Res, +} from '@nestjs/common'; +import type { Request, Response } from 'express'; + +import { + Config, + PaymentRequiredException, + URLHelper, +} from '../../fundamentals'; +import { UserService } from '../user'; +import { validators } from '../utils/validators'; +import { CurrentUser } from './current-user'; +import { Public } from './guard'; +import { AuthService, parseAuthUserSeqNum } from './service'; +import { TokenService, TokenType } from './token'; + +class SignInCredential { + email!: string; + password?: string; +} + +@Controller('/api/auth') +export class AuthController { + constructor( + private readonly config: Config, + private readonly url: URLHelper, + private readonly auth: AuthService, + private readonly user: UserService, + private readonly token: TokenService + ) {} + + @Public() + @Post('/sign-in') + @Header('content-type', 'application/json') + async signIn( + @Req() req: Request, + @Res() res: Response, + @Body() credential: SignInCredential, + @Query('redirect_uri') redirectUri = this.url.home + ) { + validators.assertValidEmail(credential.email); + const canSignIn = await this.auth.canSignIn(credential.email); + if (!canSignIn) { + throw new PaymentRequiredException( + `You don't have early access permission\nVisit https://community.affine.pro/c/insider-general/ for more information` + ); + } + + if (credential.password) { + validators.assertValidPassword(credential.password); + const user = await this.auth.signIn( + credential.email, + credential.password + ); + + await this.auth.setCookie(req, res, user); + res.send(user); + } else { + // send email magic link + const user = await this.user.findUserByEmail(credential.email); + const result = await this.sendSignInEmail( + { email: credential.email, signUp: !user }, + redirectUri + ); + + if (result.rejected.length) { + throw new Error('Failed to send sign-in email.'); + } + + res.send({ + email: credential.email, + }); + } + } + + async sendSignInEmail( + { email, signUp }: { email: string; signUp: boolean }, + redirectUri: string + ) { + const token = await this.token.createToken(TokenType.SignIn, email); + + const magicLink = this.url.link('/api/auth/magic-link', { + token, + email, + redirect_uri: redirectUri, + }); + + const result = await this.auth.sendSignInEmail(email, magicLink, signUp); + + return result; + } + + @Get('/sign-out') + async signOut( + @Req() req: Request, + @Res() res: Response, + @Query('redirect_uri') redirectUri?: string + ) { + const session = await this.auth.signOut( + req.cookies[AuthService.sessionCookieName], + parseAuthUserSeqNum(req.headers[AuthService.authUserSeqHeaderName]) + ); + + if (session) { + res.cookie(AuthService.sessionCookieName, session.id, { + expires: session.expiresAt ?? void 0, // expiredAt is `string | null` + ...this.auth.cookieOptions, + }); + } else { + res.clearCookie(AuthService.sessionCookieName); + } + + if (redirectUri) { + return this.url.safeRedirect(res, redirectUri); + } else { + return res.send(null); + } + } + + @Public() + @Get('/magic-link') + async magicLinkSignIn( + @Req() req: Request, + @Res() res: Response, + @Query('token') token?: string, + @Query('email') email?: string, + @Query('redirect_uri') redirectUri = this.url.home + ) { + if (!token || !email) { + throw new BadRequestException('Invalid Sign-in mail Token'); + } + + email = decodeURIComponent(email); + validators.assertValidEmail(email); + + const valid = await this.token.verifyToken(TokenType.SignIn, token, { + credential: email, + }); + + if (!valid) { + throw new BadRequestException('Invalid Sign-in mail Token'); + } + + const user = await this.user.findOrCreateUser(email, { + emailVerifiedAt: new Date(), + }); + + await this.auth.setCookie(req, res, user); + + return this.url.safeRedirect(res, redirectUri); + } + + @Get('/authorize') + async authorize( + @CurrentUser() user: CurrentUser, + @Query('redirect_uri') redirect_uri?: string + ) { + const session = await this.auth.createUserSession( + user, + undefined, + this.config.auth.accessToken.ttl + ); + + this.url.link(redirect_uri ?? '/open-app/redirect', { + token: session.sessionId, + }); + } + + @Public() + @Get('/session') + async currentSessionUser(@CurrentUser() user?: CurrentUser) { + return { + user, + }; + } + + @Public() + @Get('/sessions') + async currentSessionUsers(@Req() req: Request) { + const token = req.cookies[AuthService.sessionCookieName]; + if (!token) { + return { + users: [], + }; + } + + return { + users: await this.auth.getUserList(token), + }; + } + + @Public() + @Get('/challenge') + async challenge() { + // TODO: impl in following PR + return { + challenge: randomUUID(), + resource: randomUUID(), + }; + } +} diff --git a/packages/backend/server/src/core/auth/current-user.ts b/packages/backend/server/src/core/auth/current-user.ts new file mode 100644 index 0000000000..1aaa5b59cd --- /dev/null +++ b/packages/backend/server/src/core/auth/current-user.ts @@ -0,0 +1,55 @@ +import type { ExecutionContext } from '@nestjs/common'; +import { createParamDecorator } from '@nestjs/common'; +import { User } from '@prisma/client'; + +import { getRequestResponseFromContext } from '../../fundamentals'; + +function getUserFromContext(context: ExecutionContext) { + return getRequestResponseFromContext(context).req.user; +} + +/** + * Used to fetch current user from the request context. + * + * > The user may be undefined if authorization token or session cookie is not provided. + * + * @example + * + * ```typescript + * // Graphql Query + * \@Query(() => UserType) + * user(@CurrentUser() user: CurrentUser) { + * return user; + * } + * ``` + * + * ```typescript + * // HTTP Controller + * \@Get('/user') + * user(@CurrentUser() user: CurrentUser) { + * return user; + * } + * ``` + * + * ```typescript + * // for public apis + * \@Public() + * \@Get('/session') + * session(@currentUser() user?: CurrentUser) { + * return user + * } + * ``` + */ +// interface and variable don't conflict +// eslint-disable-next-line no-redeclare +export const CurrentUser = createParamDecorator( + (_: unknown, context: ExecutionContext) => { + return getUserFromContext(context); + } +); + +export interface CurrentUser + extends Omit { + hasPassword: boolean | null; + emailVerified: boolean; +} diff --git a/packages/backend/server/src/core/auth/guard.ts b/packages/backend/server/src/core/auth/guard.ts index 7983f3adef..d1ff3ab89d 100644 --- a/packages/backend/server/src/core/auth/guard.ts +++ b/packages/backend/server/src/core/auth/guard.ts @@ -1,67 +1,74 @@ -import type { CanActivate, ExecutionContext } from '@nestjs/common'; +import type { + CanActivate, + ExecutionContext, + OnModuleInit, +} from '@nestjs/common'; import { - createParamDecorator, - Inject, Injectable, SetMetadata, UnauthorizedException, UseGuards, } from '@nestjs/common'; -import { Reflector } from '@nestjs/core'; -import { PrismaClient } from '@prisma/client'; -import type { NextAuthOptions } from 'next-auth'; -import { AuthHandler } from 'next-auth/core'; +import { ModuleRef, Reflector } from '@nestjs/core'; -import { getRequestResponseFromContext } from '../../fundamentals'; -import { NextAuthOptionsProvide } from './next-auth-options'; -import { AuthService } from './service'; +import { Config, getRequestResponseFromContext } from '../../fundamentals'; +import { AuthService, parseAuthUserSeqNum } from './service'; -export function getUserFromContext(context: ExecutionContext) { - return getRequestResponseFromContext(context).req.user; +function extractTokenFromHeader(authorization: string) { + if (!/^Bearer\s/i.test(authorization)) { + return; + } + + return authorization.substring(7); } -/** - * Used to fetch current user from the request context. - * - * > The user may be undefined if authorization token is not provided. - * - * @example - * - * ```typescript - * // Graphql Query - * \@Query(() => UserType) - * user(@CurrentUser() user?: User) { - * return user; - * } - * ``` - * - * ```typescript - * // HTTP Controller - * \@Get('/user) - * user(@CurrentUser() user?: User) { - * return user; - * } - * ``` - */ -export const CurrentUser = createParamDecorator( - (_: unknown, context: ExecutionContext) => { - return getUserFromContext(context); - } -); - @Injectable() -class AuthGuard implements CanActivate { +export class AuthGuard implements CanActivate, OnModuleInit { + private auth!: AuthService; + constructor( - @Inject(NextAuthOptionsProvide) - private readonly nextAuthOptions: NextAuthOptions, - private readonly auth: AuthService, - private readonly prisma: PrismaClient, + private readonly config: Config, + private readonly ref: ModuleRef, private readonly reflector: Reflector ) {} + onModuleInit() { + this.auth = this.ref.get(AuthService, { strict: false }); + } + async canActivate(context: ExecutionContext) { - const { req, res } = getRequestResponseFromContext(context); - const token = req.headers.authorization; + const { req } = getRequestResponseFromContext(context); + + // check cookie + let sessionToken: string | undefined = + req.cookies[AuthService.sessionCookieName]; + + // backward compatibility for client older then 0.12 + // TODO: remove + if (!sessionToken) { + sessionToken = + req.cookies[ + this.config.https + ? '__Secure-next-auth.session-token' + : 'next-auth.session-token' + ]; + } + + if (!sessionToken && req.headers.authorization) { + sessionToken = extractTokenFromHeader(req.headers.authorization); + } + + if (sessionToken) { + const userSeq = parseAuthUserSeqNum( + req.headers[AuthService.authUserSeqHeaderName] + ); + + const user = await this.auth.getUser(sessionToken, userSeq); + + if (user) { + req.user = user; + } + } // api is public const isPublic = this.reflector.get( @@ -69,63 +76,15 @@ class AuthGuard implements CanActivate { context.getHandler() ); - // FIXME(@forehalo): @Publicable() is duplicated with @CurrentUser() user?: User - // ^ optional - // we can prefetch user session in each request even before this `Guard` - // api can be public, but if user is logged in, we can get user info - const isPublicable = this.reflector.get( - 'isPublicable', - context.getHandler() - ); - if (isPublic) { return true; - } else if (!token) { - if (!req.cookies) { - return isPublicable; - } - - const session = await AuthHandler({ - req: { - cookies: req.cookies, - action: 'session', - method: 'GET', - headers: req.headers, - }, - options: this.nextAuthOptions, - }); - - const { body = {}, cookies, status = 200 } = session; - if (!body && !isPublicable) { - throw new UnauthorizedException('You are not signed in.'); - } - - // @ts-expect-error body is user here - req.user = body.user; - if (cookies && res) { - for (const cookie of cookies) { - res.cookie(cookie.name, cookie.value, cookie.options); - } - } - - return Boolean( - status === 200 && - typeof body !== 'string' && - // ignore body if api is publicable - (Object.keys(body).length || isPublicable) - ); - } else { - const [type, jwt] = token.split(' ') ?? []; - - if (type === 'Bearer') { - const claims = await this.auth.verify(jwt); - req.user = await this.prisma.user.findUnique({ - where: { id: claims.id }, - }); - return !!req.user; - } } - return false; + + if (!req.user) { + throw new UnauthorizedException('You are not signed in.'); + } + + return true; } } @@ -140,7 +99,7 @@ class AuthGuard implements CanActivate { * ```typescript * \@Auth() * \@Query(() => UserType) - * user(@CurrentUser() user: User) { + * user(@CurrentUser() user: CurrentUser) { * return user; * } * ``` @@ -151,5 +110,3 @@ export const Auth = () => { // api is public accessible export const Public = () => SetMetadata('isPublic', true); -// api is public accessible, but if user is logged in, we can get user info -export const Publicable = () => SetMetadata('isPublicable', true); diff --git a/packages/backend/server/src/core/auth/index.ts b/packages/backend/server/src/core/auth/index.ts index b5b72ab31e..b557ba65cc 100644 --- a/packages/backend/server/src/core/auth/index.ts +++ b/packages/backend/server/src/core/auth/index.ts @@ -1,18 +1,21 @@ -import { Global, Module } from '@nestjs/common'; +import { Module } from '@nestjs/common'; -import { NextAuthController } from './next-auth.controller'; -import { NextAuthOptionsProvider } from './next-auth-options'; +import { FeatureModule } from '../features'; +import { UserModule } from '../user'; +import { AuthController } from './controller'; import { AuthResolver } from './resolver'; import { AuthService } from './service'; +import { TokenService } from './token'; -@Global() @Module({ - providers: [AuthService, AuthResolver, NextAuthOptionsProvider], - exports: [AuthService, NextAuthOptionsProvider], - controllers: [NextAuthController], + imports: [FeatureModule, UserModule], + providers: [AuthService, AuthResolver, TokenService], + exports: [AuthService], + controllers: [AuthController], }) export class AuthModule {} export * from './guard'; -export { TokenType } from './resolver'; +export { ClientTokenType } from './resolver'; export { AuthService }; +export * from './current-user'; diff --git a/packages/backend/server/src/core/auth/next-auth-options.ts b/packages/backend/server/src/core/auth/next-auth-options.ts deleted file mode 100644 index 72b368b025..0000000000 --- a/packages/backend/server/src/core/auth/next-auth-options.ts +++ /dev/null @@ -1,286 +0,0 @@ -import { PrismaAdapter } from '@auth/prisma-adapter'; -import { FactoryProvider, Logger } from '@nestjs/common'; -import { verify } from '@node-rs/argon2'; -import { PrismaClient } from '@prisma/client'; -import { assign, omit } from 'lodash-es'; -import { NextAuthOptions } from 'next-auth'; -import Credentials from 'next-auth/providers/credentials'; -import Email from 'next-auth/providers/email'; -import Github from 'next-auth/providers/github'; -import Google from 'next-auth/providers/google'; - -import { Config, MailService, SessionService } from '../../fundamentals'; -import { FeatureType } from '../features'; -import { Quota_FreePlanV1_1 } from '../quota'; -import { - decode, - encode, - sendVerificationRequest, - SendVerificationRequestParams, -} from './utils'; - -export const NextAuthOptionsProvide = Symbol('NextAuthOptions'); - -const TrustedProviders = ['google']; - -export const NextAuthOptionsProvider: FactoryProvider = { - provide: NextAuthOptionsProvide, - useFactory( - config: Config, - prisma: PrismaClient, - mailer: MailService, - session: SessionService - ) { - const logger = new Logger('NextAuth'); - const prismaAdapter = PrismaAdapter(prisma); - // createUser exists in the adapter - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const createUser = prismaAdapter.createUser!.bind(prismaAdapter); - prismaAdapter.createUser = async data => { - const userData = { - name: data.name, - email: data.email, - avatarUrl: '', - emailVerified: data.emailVerified, - features: { - create: { - reason: 'created by email sign up', - activated: true, - feature: { - connect: { - feature_version: Quota_FreePlanV1_1, - }, - }, - }, - }, - }; - if (data.email && !data.name) { - userData.name = data.email.split('@')[0]; - } - if (data.image) { - userData.avatarUrl = data.image; - } - // @ts-expect-error third part library type mismatch - return createUser(userData); - }; - // linkAccount exists in the adapter - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const linkAccount = prismaAdapter.linkAccount!.bind(prismaAdapter); - prismaAdapter.linkAccount = async account => { - // google account must be a verified email - if (TrustedProviders.includes(account.provider)) { - await prisma.user.update({ - where: { - id: account.userId, - }, - data: { - emailVerified: new Date(), - }, - }); - } - return linkAccount(account) as Promise; - }; - // getUser exists in the adapter - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const getUser = prismaAdapter.getUser!.bind(prismaAdapter)!; - prismaAdapter.getUser = async id => { - const result = await getUser(id); - if (result) { - // @ts-expect-error Third part library type mismatch - result.image = result.avatarUrl; - // @ts-expect-error Third part library type mismatch - result.hasPassword = Boolean(result.password); - } - return result; - }; - - prismaAdapter.createVerificationToken = async data => { - await session.set( - `${data.identifier}:${data.token}`, - Date.now() + session.sessionTtl - ); - return data; - }; - - prismaAdapter.useVerificationToken = async ({ identifier, token }) => { - const expires = await session.get(`${identifier}:${token}`); - if (expires) { - return { identifier, token, expires: new Date(expires) }; - } else { - return null; - } - }; - - const nextAuthOptions: NextAuthOptions = { - providers: [], - // @ts-expect-error Third part library type mismatch - adapter: prismaAdapter, - debug: !config.node.prod, - logger: { - debug(code, metadata) { - logger.debug(`${code}: ${JSON.stringify(metadata)}`); - }, - error(code, metadata) { - if (metadata instanceof Error) { - // @ts-expect-error assign code to error - metadata.code = code; - logger.error(metadata); - } else if (metadata.error instanceof Error) { - assign(metadata.error, omit(metadata, 'error'), { code }); - logger.error(metadata.error); - } - }, - warn(code) { - logger.warn(code); - }, - }, - }; - - nextAuthOptions.providers.push( - // @ts-expect-error esm interop issue - Credentials.default({ - name: 'Password', - credentials: { - email: { - label: 'Email', - type: 'text', - placeholder: 'torvalds@osdl.org', - }, - password: { label: 'Password', type: 'password' }, - }, - async authorize( - credentials: - | Record<'email' | 'password' | 'hashedPassword', string> - | undefined - ) { - if (!credentials) { - return null; - } - const { password, hashedPassword } = credentials; - if (!password || !hashedPassword) { - return null; - } - if (!(await verify(hashedPassword, password))) { - return null; - } - return credentials; - }, - }) - ); - - if (config.mailer && mailer) { - nextAuthOptions.providers.push( - // @ts-expect-error esm interop issue - Email.default({ - sendVerificationRequest: (params: SendVerificationRequestParams) => - sendVerificationRequest(config, logger, mailer, session, params), - }) - ); - } - - if (config.auth.oauthProviders.github) { - nextAuthOptions.providers.push( - // @ts-expect-error esm interop issue - Github.default({ - clientId: config.auth.oauthProviders.github.clientId, - clientSecret: config.auth.oauthProviders.github.clientSecret, - allowDangerousEmailAccountLinking: true, - }) - ); - } - - if (config.auth.oauthProviders.google?.enabled) { - nextAuthOptions.providers.push( - // @ts-expect-error esm interop issue - Google.default({ - clientId: config.auth.oauthProviders.google.clientId, - clientSecret: config.auth.oauthProviders.google.clientSecret, - checks: 'nonce', - allowDangerousEmailAccountLinking: true, - authorization: { - params: { scope: 'openid email profile', prompt: 'select_account' }, - }, - }) - ); - } - - if (nextAuthOptions.providers.length > 1) { - // not only credentials provider - nextAuthOptions.session = { strategy: 'database' }; - } - - nextAuthOptions.jwt = { - encode: async ({ token, maxAge }) => - encode(config, prisma, token, maxAge), - decode: async ({ token }) => decode(config, token), - }; - nextAuthOptions.secret ??= config.auth.nextAuthSecret; - - nextAuthOptions.callbacks = { - session: async ({ session, user, token }) => { - if (session.user) { - if (user) { - // @ts-expect-error Third part library type mismatch - session.user.id = user.id; - // @ts-expect-error Third part library type mismatch - session.user.image = user.image ?? user.avatarUrl; - // @ts-expect-error Third part library type mismatch - session.user.emailVerified = user.emailVerified; - // @ts-expect-error Third part library type mismatch - session.user.hasPassword = Boolean(user.password); - } else { - // technically the sub should be the same as id - // @ts-expect-error Third part library type mismatch - session.user.id = token.sub; - // @ts-expect-error Third part library type mismatch - session.user.emailVerified = token.emailVerified; - // @ts-expect-error Third part library type mismatch - session.user.hasPassword = token.hasPassword; - } - if (token && token.picture) { - session.user.image = token.picture; - } - } - return session; - }, - signIn: async ({ profile, user }) => { - if (!config.featureFlags.earlyAccessPreview) { - return true; - } - const email = profile?.email ?? user.email; - if (email) { - // FIXME: cannot inject FeatureManagementService here - // it will cause prisma.account to be undefined - // then prismaAdapter.getUserByAccount will throw error - if (email.endsWith('@toeverything.info')) return true; - return prisma.userFeatures - .count({ - where: { - user: { - email: { - equals: email, - mode: 'insensitive', - }, - }, - feature: { - feature: FeatureType.EarlyAccess, - }, - activated: true, - }, - }) - .then(count => count > 0); - } - return false; - }, - redirect({ url }) { - return url; - }, - }; - - nextAuthOptions.pages = { - newUser: '/auth/onboarding', - }; - return nextAuthOptions; - }, - inject: [Config, PrismaClient, MailService, SessionService], -}; diff --git a/packages/backend/server/src/core/auth/next-auth.controller.ts b/packages/backend/server/src/core/auth/next-auth.controller.ts deleted file mode 100644 index 22ef1dca5f..0000000000 --- a/packages/backend/server/src/core/auth/next-auth.controller.ts +++ /dev/null @@ -1,411 +0,0 @@ -import { URLSearchParams } from 'node:url'; - -import { - All, - BadRequestException, - Controller, - Get, - Inject, - Logger, - Next, - NotFoundException, - Query, - Req, - Res, - UseGuards, -} from '@nestjs/common'; -import { hash, verify } from '@node-rs/argon2'; -import { PrismaClient, type User } from '@prisma/client'; -import type { NextFunction, Request, Response } from 'express'; -import { pick } from 'lodash-es'; -import { nanoid } from 'nanoid'; -import type { AuthAction, CookieOption, NextAuthOptions } from 'next-auth'; -import { AuthHandler } from 'next-auth/core'; - -import { - AuthThrottlerGuard, - Config, - metrics, - SessionService, - Throttle, -} from '../../fundamentals'; -import { NextAuthOptionsProvide } from './next-auth-options'; -import { AuthService } from './service'; - -const BASE_URL = '/api/auth/'; - -const DEFAULT_SESSION_EXPIRE_DATE = 2592000 * 1000; // 30 days - -@Controller(BASE_URL) -export class NextAuthController { - private readonly callbackSession; - - private readonly logger = new Logger('NextAuthController'); - - constructor( - readonly config: Config, - readonly prisma: PrismaClient, - private readonly authService: AuthService, - @Inject(NextAuthOptionsProvide) - private readonly nextAuthOptions: NextAuthOptions, - private readonly session: SessionService - ) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - this.callbackSession = nextAuthOptions.callbacks!.session; - } - - @UseGuards(AuthThrottlerGuard) - @Throttle({ - default: { - limit: 60, - ttl: 60, - }, - }) - @Get('/challenge') - async getChallenge(@Res() res: Response) { - const challenge = nanoid(); - const resource = nanoid(); - await this.session.set(challenge, resource, 5 * 60 * 1000); - res.json({ challenge, resource }); - } - - @UseGuards(AuthThrottlerGuard) - @Throttle({ - default: { - limit: 60, - ttl: 60, - }, - }) - @All('*') - async auth( - @Req() req: Request, - @Res() res: Response, - @Query() query: Record, - @Next() next: NextFunction - ) { - if (req.path === '/api/auth/signin' && req.method === 'GET') { - const query = req.query - ? // @ts-expect-error req.query is satisfy with the Record - `?${new URLSearchParams(req.query).toString()}` - : ''; - res.redirect(`/signin${query}`); - return; - } - const [action, providerId] = req.url // start with request url - .slice(BASE_URL.length) // make relative to baseUrl - .replace(/\?.*/, '') // remove query part, use only path part - .split('/') as [AuthAction, string]; // as array of strings; - - metrics.auth.counter('call_counter').add(1, { action, providerId }); - - const credentialsSignIn = - req.method === 'POST' && providerId === 'credentials'; - let userId: string | undefined; - if (credentialsSignIn) { - const { email } = req.body; - if (email) { - const user = await this.prisma.user.findFirst({ - where: { - email: { - equals: email, - mode: 'insensitive', - }, - }, - }); - if (!user) { - req.statusCode = 401; - req.statusMessage = 'User not found'; - req.body = null; - throw new NotFoundException(`User not found`); - } else { - userId = user.id; - req.body = { - ...req.body, - name: user.name, - email: user.email, - image: user.avatarUrl, - hashedPassword: user.password, - }; - } - } - } - const options = this.nextAuthOptions; - if (req.method === 'POST' && action === 'session') { - if (typeof req.body !== 'object' || typeof req.body.data !== 'object') { - metrics.auth - .counter('call_fails_counter') - .add(1, { reason: 'invalid_session_data' }); - throw new BadRequestException(`Invalid new session data`); - } - const user = await this.updateSession(req, req.body.data); - // callbacks.session existed - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - options.callbacks!.session = ({ session }) => { - return { - user: { - ...pick(user, 'id', 'name', 'email'), - image: user.avatarUrl, - hasPassword: !!user.password, - }, - expires: session.expires, - }; - }; - } else { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - options.callbacks!.session = this.callbackSession; - } - - if ( - this.config.auth.captcha.enable && - req.method === 'POST' && - action === 'signin' && - // TODO: add credentials support in frontend - ['email'].includes(providerId) - ) { - const isVerified = await this.verifyChallenge(req, res); - if (!isVerified) return; - } - - const { status, headers, body, redirect, cookies } = await AuthHandler({ - req: { - body: req.body, - query: query, - method: req.method, - action, - providerId, - error: query.error ?? providerId, - cookies: req.cookies, - }, - options, - }); - - if (headers) { - for (const { key, value } of headers) { - res.setHeader(key, value); - } - } - if (cookies) { - for (const cookie of cookies) { - res.cookie(cookie.name, cookie.value, cookie.options); - } - } - - let nextAuthTokenCookie: (CookieOption & { value: string }) | undefined; - const secureCookiePrefix = '__Secure-'; - const sessionCookieName = `next-auth.session-token`; - // next-auth credentials login only support JWT strategy - // https://next-auth.js.org/configuration/providers/credentials - // let's store the session token in the database - if ( - credentialsSignIn && - (nextAuthTokenCookie = cookies?.find( - ({ name }) => - name === sessionCookieName || - name === `${secureCookiePrefix}${sessionCookieName}` - )) - ) { - const cookieExpires = new Date(); - cookieExpires.setTime( - cookieExpires.getTime() + DEFAULT_SESSION_EXPIRE_DATE - ); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - await this.nextAuthOptions.adapter!.createSession!({ - sessionToken: nextAuthTokenCookie.value, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - userId: userId!, - expires: cookieExpires, - }); - } - - if (redirect?.endsWith('api/auth/error?error=AccessDenied')) { - this.logger.log(`Early access redirect headers: ${req.headers}`); - metrics.auth - .counter('call_fails_counter') - .add(1, { reason: 'no_early_access_permission' }); - - if ( - !req.headers?.referer || - checkUrlOrigin(req.headers.referer, 'https://accounts.google.com') - ) { - res.redirect('https://community.affine.pro/c/insider-general/'); - } else { - res.status(403); - res.json({ - url: 'https://community.affine.pro/c/insider-general/', - error: `You don't have early access permission`, - }); - } - return; - } - - if (status) { - res.status(status); - } - - if (redirect) { - if (providerId === 'credentials') { - res.send(JSON.stringify({ ok: true, url: redirect })); - } else if ( - action === 'callback' || - action === 'error' || - (providerId !== 'credentials' && - // login in the next-auth page, /api/auth/signin, auto redirect. - // otherwise, return the json value to allow frontend to handle the redirect. - req.headers?.referer?.includes?.('/api/auth/signin')) - ) { - res.redirect(redirect); - } else { - res.json({ url: redirect }); - } - } else if (typeof body === 'string') { - res.send(body); - } else if (body && typeof body === 'object') { - res.json(body); - } else { - next(); - } - } - - private async updateSession( - req: Request, - newSession: Partial> & { oldPassword?: string } - ): Promise { - const { name, email, password, oldPassword } = newSession; - if (!name && !email && !password) { - throw new BadRequestException(`Invalid new session data`); - } - if (password) { - const user = await this.verifyUserFromRequest(req); - const { password: userPassword } = user; - if (!oldPassword) { - if (userPassword) { - throw new BadRequestException( - `Old password is required to update password` - ); - } - } else { - if (!userPassword) { - throw new BadRequestException(`No existed password`); - } - if (await verify(userPassword, oldPassword)) { - await this.prisma.user.update({ - where: { - id: user.id, - }, - data: { - ...pick(newSession, 'email', 'name'), - password: await hash(password), - }, - }); - } - } - return user; - } else { - const user = await this.verifyUserFromRequest(req); - return this.prisma.user.update({ - where: { - id: user.id, - }, - data: pick(newSession, 'name', 'email'), - }); - } - } - - private async verifyChallenge(req: Request, res: Response): Promise { - const challenge = req.query?.challenge; - if (typeof challenge === 'string' && challenge) { - const resource = await this.session.get(challenge); - - if (!resource) { - this.rejectResponse(res, 'Invalid Challenge'); - return false; - } - - const isChallengeVerified = - await this.authService.verifyChallengeResponse( - req.query?.token, - resource - ); - - this.logger.debug( - `Challenge: ${challenge}, Resource: ${resource}, Response: ${req.query?.token}, isChallengeVerified: ${isChallengeVerified}` - ); - - if (!isChallengeVerified) { - this.rejectResponse(res, 'Invalid Challenge Response'); - return false; - } - } else { - const isTokenVerified = await this.authService.verifyCaptchaToken( - req.query?.token, - req.headers['CF-Connecting-IP'] as string - ); - - if (!isTokenVerified) { - this.rejectResponse(res, 'Invalid Captcha Response'); - return false; - } - } - return true; - } - - private async verifyUserFromRequest(req: Request): Promise { - const token = req.headers.authorization; - if (!token) { - const session = await AuthHandler({ - req: { - cookies: req.cookies, - action: 'session', - method: 'GET', - headers: req.headers, - }, - options: this.nextAuthOptions, - }); - - const { body } = session; - // @ts-expect-error check if body.user exists - if (body && body.user && body.user.id) { - const user = await this.prisma.user.findUnique({ - where: { - // @ts-expect-error body.user.id exists - id: body.user.id, - }, - }); - if (user) { - return user; - } - } - } else { - const [type, jwt] = token.split(' ') ?? []; - - if (type === 'Bearer') { - const claims = await this.authService.verify(jwt); - const user = await this.prisma.user.findUnique({ - where: { id: claims.id }, - }); - if (user) { - return user; - } - } - } - throw new BadRequestException(`User not found`); - } - - rejectResponse(res: Response, error: string, status = 400) { - res.status(status); - res.json({ - url: `${this.config.baseUrl}/api/auth/error?${new URLSearchParams({ - error, - }).toString()}`, - error, - }); - } -} - -const checkUrlOrigin = (url: string, origin: string) => { - try { - return new URL(url).origin === origin; - } catch (e) { - return false; - } -}; diff --git a/packages/backend/server/src/core/auth/resolver.ts b/packages/backend/server/src/core/auth/resolver.ts index 8ab9ef089d..edd2d2168d 100644 --- a/packages/backend/server/src/core/auth/resolver.ts +++ b/packages/backend/server/src/core/auth/resolver.ts @@ -10,24 +10,22 @@ import { Mutation, ObjectType, Parent, + Query, ResolveField, Resolver, } from '@nestjs/graphql'; -import type { Request } from 'express'; -import { nanoid } from 'nanoid'; +import type { Request, Response } from 'express'; -import { - CloudThrottlerGuard, - Config, - SessionService, - Throttle, -} from '../../fundamentals'; -import { UserType } from '../users'; -import { Auth, CurrentUser } from './guard'; +import { CloudThrottlerGuard, Config, Throttle } from '../../fundamentals'; +import { UserType } from '../user/types'; +import { validators } from '../utils/validators'; +import { CurrentUser } from './current-user'; +import { Public } from './guard'; import { AuthService } from './service'; +import { TokenService, TokenType } from './token'; -@ObjectType() -export class TokenType { +@ObjectType('tokenType') +export class ClientTokenType { @Field() token!: string; @@ -50,46 +48,57 @@ export class AuthResolver { constructor( private readonly config: Config, private readonly auth: AuthService, - private readonly session: SessionService + private readonly token: TokenService ) {} + @Throttle({ + default: { + limit: 10, + ttl: 60, + }, + }) + @Public() + @Query(() => UserType, { + name: 'currentUser', + description: 'Get current user', + nullable: true, + }) + currentUser(@CurrentUser() user?: CurrentUser): UserType | undefined { + return user; + } + @Throttle({ default: { limit: 20, ttl: 60, }, }) - @ResolveField(() => TokenType) - async token( - @Context() ctx: { req: Request }, - @CurrentUser() currentUser: UserType, + @ResolveField(() => ClientTokenType, { + name: 'token', + deprecationReason: 'use [/api/auth/authorize]', + }) + async clientToken( + @CurrentUser() currentUser: CurrentUser, @Parent() user: UserType - ) { + ): Promise { if (user.id !== currentUser.id) { - throw new BadRequestException('Invalid user'); + throw new ForbiddenException('Invalid user'); } - let sessionToken: string | undefined; - - // only return session if the request is from the same origin & path == /open-app - if ( - ctx.req.headers.referer && - ctx.req.headers.host && - new URL(ctx.req.headers.referer).pathname.startsWith('/open-app') && - ctx.req.headers.host === new URL(this.config.origin).host - ) { - const cookiePrefix = this.config.node.prod ? '__Secure-' : ''; - const sessionCookieName = `${cookiePrefix}next-auth.session-token`; - sessionToken = ctx.req.cookies?.[sessionCookieName]; - } + const session = await this.auth.createUserSession( + user, + undefined, + this.config.auth.accessToken.ttl + ); return { - sessionToken, - token: this.auth.sign(user), - refresh: this.auth.refresh(user), + sessionToken: session.sessionId, + token: session.sessionId, + refresh: '', }; } + @Public() @Throttle({ default: { limit: 10, @@ -98,16 +107,19 @@ export class AuthResolver { }) @Mutation(() => UserType) async signUp( - @Context() ctx: { req: Request }, + @Context() ctx: { req: Request; res: Response }, @Args('name') name: string, @Args('email') email: string, @Args('password') password: string ) { + validators.assertValidCredential({ email, password }); const user = await this.auth.signUp(name, email, password); + await this.auth.setCookie(ctx.req, ctx.res, user); ctx.req.user = user; return user; } + @Public() @Throttle({ default: { limit: 10, @@ -116,11 +128,13 @@ export class AuthResolver { }) @Mutation(() => UserType) async signIn( - @Context() ctx: { req: Request }, + @Context() ctx: { req: Request; res: Response }, @Args('email') email: string, @Args('password') password: string ) { + validators.assertValidCredential({ email, password }); const user = await this.auth.signIn(email, password); + await this.auth.setCookie(ctx.req, ctx.res, user); ctx.req.user = user; return user; } @@ -132,28 +146,26 @@ export class AuthResolver { }, }) @Mutation(() => UserType) - @Auth() async changePassword( - @CurrentUser() user: UserType, + @CurrentUser() user: CurrentUser, @Args('token') token: string, @Args('newPassword') newPassword: string ) { - const id = await this.session.get(token); - if (!user.emailVerified) { - throw new ForbiddenException('Please verify the email first'); - } - if ( - !id || - (id !== user.id && - // change password after sign in with email link - // we only create user account after user sign in with email link - id !== user.email) - ) { + validators.assertValidPassword(newPassword); + // NOTE: Set & Change password are using the same token type. + const valid = await this.token.verifyToken( + TokenType.ChangePassword, + token, + { + credential: user.id, + } + ); + + if (!valid) { throw new ForbiddenException('Invalid token'); } await this.auth.changePassword(user.email, newPassword); - await this.session.delete(token); return user; } @@ -165,25 +177,24 @@ export class AuthResolver { }, }) @Mutation(() => UserType) - @Auth() async changeEmail( - @CurrentUser() user: UserType, - @Args('token') token: string + @CurrentUser() user: CurrentUser, + @Args('token') token: string, + @Args('email') email: string ) { - const key = await this.session.get(token); - if (!key) { + validators.assertValidEmail(email); + // @see [sendChangeEmail] + const valid = await this.token.verifyToken(TokenType.VerifyEmail, token, { + credential: user.id, + }); + + if (!valid) { throw new ForbiddenException('Invalid token'); } - // email has set token in `sendVerifyChangeEmail` - const [id, email] = key.split(','); - if (!id || id !== user.id || !email) { - throw new ForbiddenException('Invalid token'); - } - - await this.auth.changeEmail(id, email); - await this.session.delete(token); + email = decodeURIComponent(email); + await this.auth.changeEmail(user.id, email); await this.auth.sendNotificationChangeEmail(email); return user; @@ -196,19 +207,29 @@ export class AuthResolver { }, }) @Mutation(() => Boolean) - @Auth() async sendChangePasswordEmail( - @CurrentUser() user: UserType, - @Args('email') email: string, - @Args('callbackUrl') callbackUrl: string + @CurrentUser() user: CurrentUser, + @Args('callbackUrl') callbackUrl: string, + // @deprecated + @Args('email', { nullable: true }) _email?: string ) { - const token = nanoid(); - await this.session.set(token, user.id); + if (!user.emailVerified) { + throw new ForbiddenException('Please verify your email first.'); + } + + const token = await this.token.createToken( + TokenType.ChangePassword, + user.id + ); const url = new URL(callbackUrl, this.config.baseUrl); url.searchParams.set('token', token); - const res = await this.auth.sendChangePasswordEmail(email, url.toString()); + const res = await this.auth.sendChangePasswordEmail( + user.email, + url.toString() + ); + return !res.rejected.length; } @@ -219,19 +240,27 @@ export class AuthResolver { }, }) @Mutation(() => Boolean) - @Auth() async sendSetPasswordEmail( - @CurrentUser() user: UserType, - @Args('email') email: string, - @Args('callbackUrl') callbackUrl: string + @CurrentUser() user: CurrentUser, + @Args('callbackUrl') callbackUrl: string, + @Args('email', { nullable: true }) _email?: string ) { - const token = nanoid(); - await this.session.set(token, user.id); + if (!user.emailVerified) { + throw new ForbiddenException('Please verify your email first.'); + } + + const token = await this.token.createToken( + TokenType.ChangePassword, + user.id + ); const url = new URL(callbackUrl, this.config.baseUrl); url.searchParams.set('token', token); - const res = await this.auth.sendSetPasswordEmail(email, url.toString()); + const res = await this.auth.sendSetPasswordEmail( + user.email, + url.toString() + ); return !res.rejected.length; } @@ -249,19 +278,22 @@ export class AuthResolver { }, }) @Mutation(() => Boolean) - @Auth() async sendChangeEmail( - @CurrentUser() user: UserType, - @Args('email') email: string, - @Args('callbackUrl') callbackUrl: string + @CurrentUser() user: CurrentUser, + @Args('callbackUrl') callbackUrl: string, + // @deprecated + @Args('email', { nullable: true }) _email?: string ) { - const token = nanoid(); - await this.session.set(token, user.id); + if (!user.emailVerified) { + throw new ForbiddenException('Please verify your email first.'); + } + + const token = await this.token.createToken(TokenType.ChangeEmail, user.id); const url = new URL(callbackUrl, this.config.baseUrl); url.searchParams.set('token', token); - const res = await this.auth.sendChangeEmail(email, url.toString()); + const res = await this.auth.sendChangeEmail(user.email, url.toString()); return !res.rejected.length; } @@ -272,34 +304,92 @@ export class AuthResolver { }, }) @Mutation(() => Boolean) - @Auth() async sendVerifyChangeEmail( - @CurrentUser() user: UserType, + @CurrentUser() user: CurrentUser, @Args('token') token: string, @Args('email') email: string, @Args('callbackUrl') callbackUrl: string ) { - const id = await this.session.get(token); - if (!id || id !== user.id) { + validators.assertValidEmail(email); + const valid = await this.token.verifyToken(TokenType.ChangeEmail, token, { + credential: user.id, + }); + + if (!valid) { throw new ForbiddenException('Invalid token'); } const hasRegistered = await this.auth.getUserByEmail(email); if (hasRegistered) { - throw new BadRequestException(`Invalid user email`); + if (hasRegistered.id !== user.id) { + throw new BadRequestException(`The email provided has been taken.`); + } else { + throw new BadRequestException( + `The email provided is the same as the current email.` + ); + } } - const withEmailToken = nanoid(); - await this.session.set(withEmailToken, `${user.id},${email}`); + const verifyEmailToken = await this.token.createToken( + TokenType.VerifyEmail, + user.id + ); const url = new URL(callbackUrl, this.config.baseUrl); - url.searchParams.set('token', withEmailToken); + url.searchParams.set('token', verifyEmailToken); + url.searchParams.set('email', email); const res = await this.auth.sendVerifyChangeEmail(email, url.toString()); - await this.session.delete(token); - return !res.rejected.length; } + + @Throttle({ + default: { + limit: 5, + ttl: 60, + }, + }) + @Mutation(() => Boolean) + async sendVerifyEmail( + @CurrentUser() user: CurrentUser, + @Args('callbackUrl') callbackUrl: string + ) { + const token = await this.token.createToken(TokenType.VerifyEmail, user.id); + + const url = new URL(callbackUrl, this.config.baseUrl); + url.searchParams.set('token', token); + + const res = await this.auth.sendVerifyEmail(user.email, url.toString()); + return !res.rejected.length; + } + + @Throttle({ + default: { + limit: 5, + ttl: 60, + }, + }) + @Mutation(() => Boolean) + async verifyEmail( + @CurrentUser() user: CurrentUser, + @Args('token') token: string + ) { + if (!token) { + throw new BadRequestException('Invalid token'); + } + + const valid = await this.token.verifyToken(TokenType.VerifyEmail, token, { + credential: user.id, + }); + + if (!valid) { + throw new ForbiddenException('Invalid token'); + } + + const { emailVerifiedAt } = await this.auth.setEmailVerified(user.id); + + return emailVerifiedAt !== null; + } } diff --git a/packages/backend/server/src/core/auth/service.ts b/packages/backend/server/src/core/auth/service.ts index 41535175e2..e568937b56 100644 --- a/packages/backend/server/src/core/auth/service.ts +++ b/packages/backend/server/src/core/auth/service.ts @@ -1,299 +1,327 @@ -import { randomUUID } from 'node:crypto'; - import { BadRequestException, Injectable, - InternalServerErrorException, - UnauthorizedException, + NotAcceptableException, + NotFoundException, + OnApplicationBootstrap, } from '@nestjs/common'; -import { hash, verify } from '@node-rs/argon2'; -import { Algorithm, sign, verify as jwtVerify } from '@node-rs/jsonwebtoken'; import { PrismaClient, type User } from '@prisma/client'; -import { nanoid } from 'nanoid'; +import type { CookieOptions, Request, Response } from 'express'; +import { assign, omit } from 'lodash-es'; import { Config, + CryptoHelper, MailService, - verifyChallengeResponse, + SessionCache, } from '../../fundamentals'; -import { Quota_FreePlanV1_1 } from '../quota'; +import { FeatureManagementService } from '../features/management'; +import { UserService } from '../user/service'; +import type { CurrentUser } from './current-user'; -export type UserClaim = Pick< - User, - 'id' | 'name' | 'email' | 'emailVerified' | 'createdAt' | 'avatarUrl' -> & { - hasPassword?: boolean; -}; +export function parseAuthUserSeqNum(value: any) { + switch (typeof value) { + case 'number': { + return value; + } + case 'string': { + value = Number.parseInt(value); + return Number.isNaN(value) ? 0 : value; + } -export const getUtcTimestamp = () => Math.floor(Date.now() / 1000); + default: { + return 0; + } + } +} + +export function sessionUser( + user: Omit & { password?: string | null } +): CurrentUser { + return assign(omit(user, 'password', 'emailVerifiedAt', 'createdAt'), { + hasPassword: user.password !== null, + emailVerified: user.emailVerifiedAt !== null, + }); +} @Injectable() -export class AuthService { +export class AuthService implements OnApplicationBootstrap { + readonly cookieOptions: CookieOptions = { + sameSite: 'lax', + httpOnly: true, + path: '/', + domain: this.config.host, + secure: this.config.https, + }; + static readonly sessionCookieName = 'sid'; + static readonly authUserSeqHeaderName = 'x-auth-user'; + constructor( private readonly config: Config, - private readonly prisma: PrismaClient, - private readonly mailer: MailService + private readonly db: PrismaClient, + private readonly mailer: MailService, + private readonly feature: FeatureManagementService, + private readonly user: UserService, + private readonly crypto: CryptoHelper, + private readonly cache: SessionCache ) {} - sign(user: UserClaim) { - const now = getUtcTimestamp(); - return sign( - { - data: { - id: user.id, - name: user.name, - email: user.email, - emailVerified: user.emailVerified?.toISOString(), - image: user.avatarUrl, - hasPassword: Boolean(user.hasPassword), - createdAt: user.createdAt.toISOString(), - }, - iat: now, - exp: now + this.config.auth.accessTokenExpiresIn, - iss: this.config.serverId, - sub: user.id, - aud: 'https://affine.pro', - jti: randomUUID({ - disableEntropyCache: true, - }), - }, - this.config.auth.privateKey, - { - algorithm: Algorithm.ES256, - } - ); - } - - refresh(user: UserClaim) { - const now = getUtcTimestamp(); - return sign( - { - data: { - id: user.id, - name: user.name, - email: user.email, - emailVerified: user.emailVerified?.toISOString(), - image: user.avatarUrl, - hasPassword: Boolean(user.hasPassword), - createdAt: user.createdAt.toISOString(), - }, - exp: now + this.config.auth.refreshTokenExpiresIn, - iat: now, - iss: this.config.serverId, - sub: user.id, - aud: 'https://affine.pro', - jti: randomUUID({ - disableEntropyCache: true, - }), - }, - this.config.auth.privateKey, - { - algorithm: Algorithm.ES256, - } - ); - } - - async verify(token: string) { - try { - const data = ( - await jwtVerify(token, this.config.auth.publicKey, { - algorithms: [Algorithm.ES256], - iss: [this.config.serverId], - leeway: this.config.auth.leeway, - requiredSpecClaims: ['exp', 'iat', 'iss', 'sub'], - aud: ['https://affine.pro'], - }) - ).data as UserClaim; - - return { - ...data, - emailVerified: data.emailVerified ? new Date(data.emailVerified) : null, - createdAt: new Date(data.createdAt), - }; - } catch (e) { - throw new UnauthorizedException('Invalid token'); + async onApplicationBootstrap() { + if (this.config.node.dev) { + await this.signUp('Dev User', 'dev@affine.pro', 'dev').catch(() => { + // ignore + }); } } - async verifyCaptchaToken(token: any, ip: string) { - if (typeof token !== 'string' || !token) return false; - - const formData = new FormData(); - formData.append('secret', this.config.auth.captcha.turnstile.secret); - formData.append('response', token); - formData.append('remoteip', ip); - // prevent replay attack - formData.append('idempotency_key', nanoid()); - - const url = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'; - const result = await fetch(url, { - body: formData, - method: 'POST', - }); - const outcome = await result.json(); - - return ( - !!outcome.success && - // skip hostname check in dev mode - (this.config.node.dev || outcome.hostname === this.config.host) - ); + canSignIn(email: string) { + return this.feature.canEarlyAccess(email); } - async verifyChallengeResponse(response: any, resource: string) { - return verifyChallengeResponse( - response, - this.config.auth.captcha.challenge.bits, - resource - ); + async signUp( + name: string, + email: string, + password: string + ): Promise { + const user = await this.getUserByEmail(email); + + if (user) { + throw new BadRequestException('Email was taken'); + } + + const hashedPassword = await this.crypto.encryptPassword(password); + + return this.user + .createUser({ + name, + email, + password: hashedPassword, + }) + .then(sessionUser); } - async signIn(email: string, password: string): Promise { - const user = await this.prisma.user.findFirst({ - where: { - email: { - equals: email, - mode: 'insensitive', - }, - }, - }); + async signIn(email: string, password: string) { + const user = await this.user.findUserWithHashedPasswordByEmail(email); if (!user) { - throw new BadRequestException('Invalid email'); + throw new NotFoundException('User Not Found'); } if (!user.password) { - throw new BadRequestException('User has no password'); + throw new NotAcceptableException( + 'User Password is not set. Should login throw email link.' + ); } - let equal = false; - try { - equal = await verify(user.password, password); - } catch (e) { - console.error(e); - throw new InternalServerErrorException(e, 'Verify password failed'); + + const passwordMatches = await this.crypto.verifyPassword( + password, + user.password + ); + + if (!passwordMatches) { + throw new NotAcceptableException('Incorrect Password'); } - if (!equal) { - throw new UnauthorizedException('Invalid password'); + + return sessionUser(user); + } + + async getUserWithCache(token: string, seq = 0) { + const cacheKey = `session:${token}:${seq}`; + let user = await this.cache.get(cacheKey); + if (user) { + return user; + } + + user = await this.getUser(token, seq); + + if (user) { + await this.cache.set(cacheKey, user); } return user; } - async signUp(name: string, email: string, password: string): Promise { - const user = await this.prisma.user.findFirst({ - where: { - email: { - equals: email, - mode: 'insensitive', - }, - }, - }); + async getUser(token: string, seq = 0): Promise { + const session = await this.getSession(token); - if (user) { - throw new BadRequestException('Email already exists'); + // no such session + if (!session) { + return null; } - const hashedPassword = await hash(password); + const userSession = session.userSessions.at(seq); - return this.prisma.user.create({ - data: { - name, - email, - password: hashedPassword, - // TODO(@forehalo): handle in event system - features: { - create: { - reason: 'created by api sign up', - activated: true, - feature: { - connect: { - feature_version: Quota_FreePlanV1_1, - }, - }, - }, - }, - }, - }); - } - - async createAnonymousUser(email: string): Promise { - const user = await this.prisma.user.findFirst({ - where: { - email: { - equals: email, - mode: 'insensitive', - }, - }, - }); - - if (user) { - throw new BadRequestException('Email already exists'); + // no such user session + if (!userSession) { + return null; } - return this.prisma.user.create({ - data: { - name: 'Unnamed', - email, - features: { - create: { - reason: 'created by invite sign up', - activated: true, - feature: { - connect: { - feature_version: Quota_FreePlanV1_1, - }, - }, - }, - }, - }, - }); - } + // user session expired + if (userSession.expiresAt && userSession.expiresAt <= new Date()) { + return null; + } - async getUserByEmail(email: string): Promise { - return this.prisma.user.findFirst({ - where: { - email: { - equals: email, - mode: 'insensitive', - }, - }, + const user = await this.db.user.findUnique({ + where: { id: userSession.userId }, }); - } - async isUserHasPassword(email: string): Promise { - const user = await this.prisma.user.findFirst({ - where: { - email: { - equals: email, - mode: 'insensitive', - }, - }, - }); if (!user) { - throw new BadRequestException('Invalid email'); + return null; } - return Boolean(user.password); + + return sessionUser(user); + } + + async getUserList(token: string) { + const session = await this.getSession(token); + + if (!session || !session.userSessions.length) { + return []; + } + + const users = await this.db.user.findMany({ + where: { + id: { + in: session.userSessions.map(({ userId }) => userId), + }, + }, + }); + + // TODO(@forehalo): need to separate expired session, same for [getUser] + // Session + // | { user: LimitedUser { email, avatarUrl }, expired: true } + // | { user: User, expired: false } + return users.map(sessionUser); + } + + async signOut(token: string, seq = 0) { + const session = await this.getSession(token); + + if (session) { + // overflow the logged in user + if (session.userSessions.length <= seq) { + return session; + } + + await this.db.userSession.deleteMany({ + where: { id: session.userSessions[seq].id }, + }); + + // no more user session active, delete the whole session + if (session.userSessions.length === 1) { + await this.db.session.delete({ where: { id: session.id } }); + return null; + } + + return session; + } + + return null; + } + + async getSession(token: string) { + return this.db.$transaction(async tx => { + const session = await tx.session.findUnique({ + where: { + id: token, + }, + include: { + userSessions: { + orderBy: { + createdAt: 'asc', + }, + }, + }, + }); + + if (!session) { + return null; + } + + if (session.expiresAt && session.expiresAt <= new Date()) { + await tx.session.delete({ + where: { + id: session.id, + }, + }); + + return null; + } + + return session; + }); + } + + async createUserSession( + user: { id: string }, + existingSession?: string, + ttl = this.config.auth.session.ttl + ) { + const session = existingSession + ? await this.getSession(existingSession) + : null; + + const expiresAt = new Date(Date.now() + ttl * 1000); + if (session) { + return this.db.userSession.upsert({ + where: { + sessionId_userId: { + sessionId: session.id, + userId: user.id, + }, + }, + update: { + expiresAt, + }, + create: { + sessionId: session.id, + userId: user.id, + expiresAt, + }, + }); + } else { + return this.db.userSession.create({ + data: { + expiresAt, + session: { + create: {}, + }, + user: { + connect: { + id: user.id, + }, + }, + }, + }); + } + } + + async setCookie(req: Request, res: Response, user: { id: string }) { + const session = await this.createUserSession( + user, + req.cookies[AuthService.sessionCookieName] + ); + + res.cookie(AuthService.sessionCookieName, session.sessionId, { + expires: session.expiresAt ?? void 0, + ...this.cookieOptions, + }); + } + + async getUserByEmail(email: string) { + return this.user.findUserByEmail(email); } async changePassword(email: string, newPassword: string): Promise { - const user = await this.prisma.user.findFirst({ - where: { - email: { - equals: email, - mode: 'insensitive', - }, - emailVerified: { - not: null, - }, - }, - }); + const user = await this.getUserByEmail(email); if (!user) { throw new BadRequestException('Invalid email'); } - const hashedPassword = await hash(newPassword); + const hashedPassword = await this.crypto.encryptPassword(newPassword); - return this.prisma.user.update({ + return this.db.user.update({ where: { id: user.id, }, @@ -304,7 +332,7 @@ export class AuthService { } async changeEmail(id: string, newEmail: string): Promise { - const user = await this.prisma.user.findUnique({ + const user = await this.db.user.findUnique({ where: { id, }, @@ -314,12 +342,27 @@ export class AuthService { throw new BadRequestException('Invalid email'); } - return this.prisma.user.update({ + return this.db.user.update({ where: { id, }, data: { email: newEmail, + emailVerifiedAt: new Date(), + }, + }); + } + + async setEmailVerified(id: string) { + return await this.db.user.update({ + where: { + id, + }, + data: { + emailVerifiedAt: new Date(), + }, + select: { + emailVerifiedAt: true, }, }); } @@ -336,7 +379,20 @@ export class AuthService { async sendVerifyChangeEmail(email: string, callbackUrl: string) { return this.mailer.sendVerifyChangeEmail(email, callbackUrl); } + async sendVerifyEmail(email: string, callbackUrl: string) { + return this.mailer.sendVerifyEmail(email, callbackUrl); + } async sendNotificationChangeEmail(email: string) { return this.mailer.sendNotificationChangeEmail(email); } + + async sendSignInEmail(email: string, link: string, signUp: boolean) { + return signUp + ? await this.mailer.sendSignUpMail(link.toString(), { + to: email, + }) + : await this.mailer.sendSignInMail(link.toString(), { + to: email, + }); + } } diff --git a/packages/backend/server/src/core/auth/token.ts b/packages/backend/server/src/core/auth/token.ts new file mode 100644 index 0000000000..b154b7cbd6 --- /dev/null +++ b/packages/backend/server/src/core/auth/token.ts @@ -0,0 +1,84 @@ +import { randomUUID } from 'node:crypto'; + +import { Injectable } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; + +import { CryptoHelper } from '../../fundamentals/helpers'; + +export enum TokenType { + SignIn, + VerifyEmail, + ChangeEmail, + ChangePassword, + Challenge, +} + +@Injectable() +export class TokenService { + constructor( + private readonly db: PrismaClient, + private readonly crypto: CryptoHelper + ) {} + + async createToken( + type: TokenType, + credential?: string, + ttlInSec: number = 30 * 60 + ) { + const plaintextToken = randomUUID(); + + const { token } = await this.db.verificationToken.create({ + data: { + type, + token: plaintextToken, + credential, + expiresAt: new Date(Date.now() + ttlInSec * 1000), + }, + }); + + return this.crypto.encrypt(token); + } + + async verifyToken( + type: TokenType, + token: string, + { + credential, + keep, + }: { + credential?: string; + keep?: boolean; + } = {} + ) { + token = this.crypto.decrypt(token); + const record = await this.db.verificationToken.findUnique({ + where: { + type_token: { + token, + type, + }, + }, + }); + + if (!record) { + return null; + } + + const expired = record.expiresAt <= new Date(); + const valid = + !expired && (!record.credential || record.credential === credential); + + if ((expired || valid) && !keep) { + await this.db.verificationToken.delete({ + where: { + type_token: { + token, + type, + }, + }, + }); + } + + return valid ? record : null; + } +} diff --git a/packages/backend/server/src/core/auth/utils/index.ts b/packages/backend/server/src/core/auth/utils/index.ts deleted file mode 100644 index c00310ed8f..0000000000 --- a/packages/backend/server/src/core/auth/utils/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { jwtDecode as decode, jwtEncode as encode } from './jwt'; -export { sendVerificationRequest } from './send-mail'; -export type { SendVerificationRequestParams } from 'next-auth/providers/email'; diff --git a/packages/backend/server/src/core/auth/utils/jwt.ts b/packages/backend/server/src/core/auth/utils/jwt.ts deleted file mode 100644 index 92eac1f55c..0000000000 --- a/packages/backend/server/src/core/auth/utils/jwt.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { randomUUID } from 'node:crypto'; - -import { BadRequestException } from '@nestjs/common'; -import { Algorithm, sign, verify as jwtVerify } from '@node-rs/jsonwebtoken'; -import { PrismaClient } from '@prisma/client'; -import { JWT } from 'next-auth/jwt'; - -import { Config } from '../../../fundamentals'; -import { getUtcTimestamp, UserClaim } from '../service'; - -export const jwtEncode = async ( - config: Config, - prisma: PrismaClient, - token: JWT | undefined, - maxAge: number | undefined -) => { - if (!token?.email) { - throw new BadRequestException('Missing email in jwt token'); - } - const user = await prisma.user.findFirstOrThrow({ - where: { - email: token.email, - }, - }); - const now = getUtcTimestamp(); - return sign( - { - data: { - id: user.id, - name: user.name, - email: user.email, - emailVerified: user.emailVerified?.toISOString(), - picture: user.avatarUrl, - createdAt: user.createdAt.toISOString(), - hasPassword: Boolean(user.password), - }, - iat: now, - exp: now + (maxAge ?? config.auth.accessTokenExpiresIn), - iss: config.serverId, - sub: user.id, - aud: 'https://affine.pro', - jti: randomUUID({ - disableEntropyCache: true, - }), - }, - config.auth.privateKey, - { - algorithm: Algorithm.ES256, - } - ); -}; - -export const jwtDecode = async (config: Config, token: string | undefined) => { - if (!token) { - return null; - } - const { name, email, emailVerified, id, picture, hasPassword } = ( - await jwtVerify(token, config.auth.publicKey, { - algorithms: [Algorithm.ES256], - iss: [config.serverId], - leeway: config.auth.leeway, - requiredSpecClaims: ['exp', 'iat', 'iss', 'sub'], - }) - ).data as Omit & { - picture: string | undefined; - }; - return { - name, - email, - emailVerified, - picture, - sub: id, - id, - hasPassword, - }; -}; diff --git a/packages/backend/server/src/core/auth/utils/send-mail.ts b/packages/backend/server/src/core/auth/utils/send-mail.ts deleted file mode 100644 index fdc463d90c..0000000000 --- a/packages/backend/server/src/core/auth/utils/send-mail.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { Logger } from '@nestjs/common'; -import { nanoid } from 'nanoid'; -import type { SendVerificationRequestParams } from 'next-auth/providers/email'; - -import { Config, MailService, SessionService } from '../../../fundamentals'; - -export async function sendVerificationRequest( - config: Config, - logger: Logger, - mailer: MailService, - session: SessionService, - params: SendVerificationRequestParams -) { - const { identifier, url } = params; - const urlWithToken = new URL(url); - const callbackUrl = urlWithToken.searchParams.get('callbackUrl') || ''; - if (!callbackUrl) { - throw new Error('callbackUrl is not set'); - } else { - const newCallbackUrl = new URL(callbackUrl, config.origin); - - const token = nanoid(); - await session.set(token, identifier); - newCallbackUrl.searchParams.set('token', token); - - urlWithToken.searchParams.set('callbackUrl', newCallbackUrl.toString()); - } - - const result = await mailer.sendSignInEmail(urlWithToken.toString(), { - to: identifier, - }); - logger.log(`send verification email success: ${result.accepted.join(', ')}`); - - const failed = result.rejected.concat(result.pending).filter(Boolean); - if (failed.length) { - throw new Error(`Email (${failed.join(', ')}) could not be sent`); - } -} diff --git a/packages/backend/server/src/core/config.ts b/packages/backend/server/src/core/config.ts index 6940a77aca..828027df56 100644 --- a/packages/backend/server/src/core/config.ts +++ b/packages/backend/server/src/core/config.ts @@ -2,9 +2,11 @@ import { Module } from '@nestjs/common'; import { Field, ObjectType, Query, registerEnumType } from '@nestjs/graphql'; import { DeploymentType } from '../fundamentals'; +import { Public } from './auth'; export enum ServerFeature { Payment = 'payment', + OAuth = 'oauth', } registerEnumType(ServerFeature, { @@ -15,9 +17,9 @@ registerEnumType(DeploymentType, { name: 'ServerDeploymentType', }); -const ENABLED_FEATURES: ServerFeature[] = []; +const ENABLED_FEATURES: Set = new Set(); export function ADD_ENABLED_FEATURES(feature: ServerFeature) { - ENABLED_FEATURES.push(feature); + ENABLED_FEATURES.add(feature); } @ObjectType() @@ -48,6 +50,7 @@ export class ServerConfigType { } export class ServerConfigResolver { + @Public() @Query(() => ServerConfigType, { description: 'server config', }) @@ -61,7 +64,7 @@ export class ServerConfigResolver { // the old flavors contains `selfhosted` but it actually not flavor but deployment type // this field should be removed after frontend feature flags implemented flavor: AFFiNE.type, - features: ENABLED_FEATURES, + features: Array.from(ENABLED_FEATURES), }; } } diff --git a/packages/backend/server/src/core/features/service.ts b/packages/backend/server/src/core/features/service.ts index 2183b27c5b..d90581be74 100644 --- a/packages/backend/server/src/core/features/service.ts +++ b/packages/backend/server/src/core/features/service.ts @@ -1,7 +1,6 @@ import { Injectable } from '@nestjs/common'; import { PrismaClient } from '@prisma/client'; -import { UserType } from '../users/types'; import { WorkspaceType } from '../workspaces/types'; import { FeatureConfigType, getFeature } from './feature'; import { FeatureKind, FeatureType } from './types'; @@ -158,7 +157,7 @@ export class FeatureService { return configs.filter(feature => !!feature.feature); } - async listFeatureUsers(feature: FeatureType): Promise { + async listFeatureUsers(feature: FeatureType) { return this.prisma.userFeatures .findMany({ where: { @@ -175,7 +174,7 @@ export class FeatureService { name: true, avatarUrl: true, email: true, - emailVerified: true, + emailVerifiedAt: true, createdAt: true, }, }, diff --git a/packages/backend/server/src/core/quota/schema.ts b/packages/backend/server/src/core/quota/schema.ts index 8b122d6aed..5c607f8a21 100644 --- a/packages/backend/server/src/core/quota/schema.ts +++ b/packages/backend/server/src/core/quota/schema.ts @@ -1,4 +1,4 @@ -import { FeatureKind } from '../features'; +import { FeatureKind } from '../features/types'; import { OneDay, OneGB, OneMB } from './constant'; import { Quota, QuotaType } from './types'; diff --git a/packages/backend/server/src/core/quota/types.ts b/packages/backend/server/src/core/quota/types.ts index 888bedb446..8bc5854066 100644 --- a/packages/backend/server/src/core/quota/types.ts +++ b/packages/backend/server/src/core/quota/types.ts @@ -2,7 +2,7 @@ import { Field, ObjectType } from '@nestjs/graphql'; import { SafeIntResolver } from 'graphql-scalars'; import { z } from 'zod'; -import { commonFeatureSchema, FeatureKind } from '../features'; +import { commonFeatureSchema, FeatureKind } from '../features/types'; import { ByteUnit, OneDay, OneKB } from './constant'; /// ======== quota define ======== diff --git a/packages/backend/server/src/core/sync/events/events.gateway.ts b/packages/backend/server/src/core/sync/events/events.gateway.ts index 3bac482421..ca7941faeb 100644 --- a/packages/backend/server/src/core/sync/events/events.gateway.ts +++ b/packages/backend/server/src/core/sync/events/events.gateway.ts @@ -14,7 +14,6 @@ import { encodeStateAsUpdate, encodeStateVector } from 'yjs'; import { CallTimer, metrics } from '../../../fundamentals'; import { Auth, CurrentUser } from '../../auth'; import { DocManager } from '../../doc'; -import { UserType } from '../../users'; import { DocID } from '../../utils/doc'; import { PermissionService } from '../../workspaces/permission'; import { Permission } from '../../workspaces/types'; @@ -53,6 +52,7 @@ export const GatewayErrorWrapper = (): MethodDecorator => { if (result instanceof Promise) { return result.catch(e => { metrics.socketio.counter('unhandled_errors').add(1); + new Logger('EventsGateway').error(e, e.stack); return { error: new InternalError(e), }; @@ -139,7 +139,7 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect { @Auth() @SubscribeMessage('client-handshake-sync') async handleClientHandshakeSync( - @CurrentUser() user: UserType, + @CurrentUser() user: CurrentUser, @MessageBody('workspaceId') workspaceId: string, @MessageBody('version') version: string | undefined, @ConnectedSocket() client: Socket @@ -172,7 +172,7 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect { @Auth() @SubscribeMessage('client-handshake-awareness') async handleClientHandshakeAwareness( - @CurrentUser() user: UserType, + @CurrentUser() user: CurrentUser, @MessageBody('workspaceId') workspaceId: string, @MessageBody('version') version: string | undefined, @ConnectedSocket() client: Socket @@ -290,7 +290,7 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect { @SubscribeMessage('doc-load-v2') async loadDocV2( @ConnectedSocket() client: Socket, - @CurrentUser() user: UserType, + @CurrentUser() user: CurrentUser, @MessageBody() { workspaceId, @@ -339,6 +339,7 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect { }; } + @Auth() @SubscribeMessage('awareness-init') async handleInitAwareness( @MessageBody() workspaceId: string, diff --git a/packages/backend/server/src/core/users/controller.ts b/packages/backend/server/src/core/user/controller.ts similarity index 100% rename from packages/backend/server/src/core/users/controller.ts rename to packages/backend/server/src/core/user/controller.ts diff --git a/packages/backend/server/src/core/users/index.ts b/packages/backend/server/src/core/user/index.ts similarity index 69% rename from packages/backend/server/src/core/users/index.ts rename to packages/backend/server/src/core/user/index.ts index f25792b026..def37f3ba1 100644 --- a/packages/backend/server/src/core/users/index.ts +++ b/packages/backend/server/src/core/user/index.ts @@ -6,15 +6,15 @@ import { StorageModule } from '../storage'; import { UserAvatarController } from './controller'; import { UserManagementResolver } from './management'; import { UserResolver } from './resolver'; -import { UsersService } from './users'; +import { UserService } from './service'; @Module({ imports: [StorageModule, FeatureModule, QuotaModule], - providers: [UserResolver, UserManagementResolver, UsersService], + providers: [UserResolver, UserManagementResolver, UserService], controllers: [UserAvatarController], - exports: [UsersService], + exports: [UserService], }) -export class UsersModule {} +export class UserModule {} +export { UserService } from './service'; export { UserType } from './types'; -export { UsersService } from './users'; diff --git a/packages/backend/server/src/core/users/management.ts b/packages/backend/server/src/core/user/management.ts similarity index 80% rename from packages/backend/server/src/core/users/management.ts rename to packages/backend/server/src/core/user/management.ts index 08e3bd4def..8cc158b6e3 100644 --- a/packages/backend/server/src/core/users/management.ts +++ b/packages/backend/server/src/core/user/management.ts @@ -6,23 +6,21 @@ import { import { Args, Context, Int, Mutation, Query, Resolver } from '@nestjs/graphql'; import { CloudThrottlerGuard, Throttle } from '../../fundamentals'; -import { Auth, CurrentUser } from '../auth/guard'; -import { AuthService } from '../auth/service'; +import { CurrentUser } from '../auth/current-user'; +import { sessionUser } from '../auth/service'; import { FeatureManagementService } from '../features'; +import { UserService } from './service'; import { UserType } from './types'; -import { UsersService } from './users'; /** * User resolver * All op rate limit: 10 req/m */ @UseGuards(CloudThrottlerGuard) -@Auth() @Resolver(() => UserType) export class UserManagementResolver { constructor( - private readonly auth: AuthService, - private readonly users: UsersService, + private readonly users: UserService, private readonly feature: FeatureManagementService ) {} @@ -34,7 +32,7 @@ export class UserManagementResolver { }) @Mutation(() => Int) async addToEarlyAccess( - @CurrentUser() currentUser: UserType, + @CurrentUser() currentUser: CurrentUser, @Args('email') email: string ): Promise { if (!this.feature.isStaff(currentUser.email)) { @@ -44,7 +42,7 @@ export class UserManagementResolver { if (user) { return this.feature.addEarlyAccess(user.id); } else { - const user = await this.auth.createAnonymousUser(email); + const user = await this.users.createAnonymousUser(email); return this.feature.addEarlyAccess(user.id); } } @@ -57,7 +55,7 @@ export class UserManagementResolver { }) @Mutation(() => Int) async removeEarlyAccess( - @CurrentUser() currentUser: UserType, + @CurrentUser() currentUser: CurrentUser, @Args('email') email: string ): Promise { if (!this.feature.isStaff(currentUser.email)) { @@ -79,13 +77,15 @@ export class UserManagementResolver { @Query(() => [UserType]) async earlyAccessUsers( @Context() ctx: { isAdminQuery: boolean }, - @CurrentUser() user: UserType + @CurrentUser() user: CurrentUser ): Promise { if (!this.feature.isStaff(user.email)) { throw new ForbiddenException('You are not allowed to do this'); } // allow query other user's subscription ctx.isAdminQuery = true; - return this.feature.listEarlyAccess(); + return this.feature.listEarlyAccess().then(users => { + return users.map(sessionUser); + }); } } diff --git a/packages/backend/server/src/core/users/resolver.ts b/packages/backend/server/src/core/user/resolver.ts similarity index 74% rename from packages/backend/server/src/core/users/resolver.ts rename to packages/backend/server/src/core/user/resolver.ts index e76079d797..1b4f878eeb 100644 --- a/packages/backend/server/src/core/users/resolver.ts +++ b/packages/backend/server/src/core/user/resolver.ts @@ -9,6 +9,7 @@ import { } from '@nestjs/graphql'; import { PrismaClient, type User } from '@prisma/client'; import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs'; +import { isNil, omitBy } from 'lodash-es'; import { CloudThrottlerGuard, @@ -17,68 +18,38 @@ import { PaymentRequiredException, Throttle, } from '../../fundamentals'; -import { Auth, CurrentUser, Public, Publicable } from '../auth/guard'; +import { CurrentUser } from '../auth/current-user'; +import { Public } from '../auth/guard'; +import { sessionUser } from '../auth/service'; import { FeatureManagementService } from '../features'; import { QuotaService } from '../quota'; import { AvatarStorage } from '../storage'; +import { UserService } from './service'; import { DeleteAccount, RemoveAvatar, + UpdateUserInput, UserOrLimitedUser, UserQuotaType, UserType, } from './types'; -import { UsersService } from './users'; /** * User resolver * All op rate limit: 10 req/m */ @UseGuards(CloudThrottlerGuard) -@Auth() @Resolver(() => UserType) export class UserResolver { constructor( private readonly prisma: PrismaClient, private readonly storage: AvatarStorage, - private readonly users: UsersService, + private readonly users: UserService, private readonly feature: FeatureManagementService, private readonly quota: QuotaService, private readonly event: EventEmitter ) {} - @Throttle({ - default: { - limit: 10, - ttl: 60, - }, - }) - @Publicable() - @Query(() => UserType, { - name: 'currentUser', - description: 'Get current user', - nullable: true, - }) - async currentUser(@CurrentUser() user?: UserType) { - if (!user) { - return null; - } - - const storedUser = await this.users.findUserById(user.id); - if (!storedUser) { - throw new BadRequestException(`User ${user.id} not found in db`); - } - return { - id: storedUser.id, - name: storedUser.name, - email: storedUser.email, - emailVerified: storedUser.emailVerified, - avatarUrl: storedUser.avatarUrl, - createdAt: storedUser.createdAt, - hasPassword: !!storedUser.password, - }; - } - @Throttle({ default: { limit: 10, @@ -92,9 +63,9 @@ export class UserResolver { }) @Public() async user( - @CurrentUser() currentUser?: UserType, + @CurrentUser() currentUser?: CurrentUser, @Args('email') email?: string - ) { + ): Promise { if (!email || !(await this.feature.canEarlyAccess(email))) { throw new PaymentRequiredException( `You don't have early access permission\nVisit https://community.affine.pro/c/insider-general/ for more information` @@ -102,16 +73,19 @@ export class UserResolver { } // TODO: need to limit a user can only get another user witch is in the same workspace - const user = await this.users.findUserByEmail(email); - if (currentUser) return user; + const user = await this.users.findUserWithHashedPasswordByEmail(email); // return empty response when user not exists if (!user) return null; + if (currentUser) { + return sessionUser(user); + } + // only return limited info when not logged in return { - email: user?.email, - hasPassword: !!user?.password, + email: user.email, + hasPassword: !!user.password, }; } @@ -128,7 +102,7 @@ export class UserResolver { name: 'invoiceCount', description: 'Get user invoice count', }) - async invoiceCount(@CurrentUser() user: UserType) { + async invoiceCount(@CurrentUser() user: CurrentUser) { return this.prisma.userInvoice.count({ where: { userId: user.id }, }); @@ -145,7 +119,7 @@ export class UserResolver { description: 'Upload user avatar', }) async uploadAvatar( - @CurrentUser() user: UserType, + @CurrentUser() user: CurrentUser, @Args({ name: 'avatar', type: () => GraphQLUpload }) avatar: FileUpload ) { @@ -169,6 +143,33 @@ export class UserResolver { }); } + @Throttle({ + default: { + limit: 10, + ttl: 60, + }, + }) + @Mutation(() => UserType, { + name: 'updateProfile', + }) + async updateUserProfile( + @CurrentUser() user: CurrentUser, + @Args('input', { type: () => UpdateUserInput }) input: UpdateUserInput + ): Promise { + input = omitBy(input, isNil); + + if (Object.keys(input).length === 0) { + return user; + } + + return sessionUser( + await this.prisma.user.update({ + where: { id: user.id }, + data: input, + }) + ); + } + @Throttle({ default: { limit: 10, @@ -179,7 +180,7 @@ export class UserResolver { name: 'removeAvatar', description: 'Remove user avatar', }) - async removeAvatar(@CurrentUser() user: UserType) { + async removeAvatar(@CurrentUser() user: CurrentUser) { if (!user) { throw new BadRequestException(`User not found`); } @@ -197,7 +198,9 @@ export class UserResolver { }, }) @Mutation(() => DeleteAccount) - async deleteAccount(@CurrentUser() user: UserType): Promise { + async deleteAccount( + @CurrentUser() user: CurrentUser + ): Promise { const deletedUser = await this.users.deleteUser(user.id); this.event.emit('user.deleted', deletedUser); return { success: true }; diff --git a/packages/backend/server/src/core/user/service.ts b/packages/backend/server/src/core/user/service.ts new file mode 100644 index 0000000000..aaf91ef79f --- /dev/null +++ b/packages/backend/server/src/core/user/service.ts @@ -0,0 +1,112 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { Prisma, PrismaClient } from '@prisma/client'; + +import { Quota_FreePlanV1_1 } from '../quota/schema'; + +@Injectable() +export class UserService { + defaultUserSelect = { + id: true, + name: true, + email: true, + emailVerifiedAt: true, + avatarUrl: true, + } satisfies Prisma.UserSelect; + + constructor(private readonly prisma: PrismaClient) {} + + get userCreatingData(): Partial { + return { + name: 'Unnamed', + features: { + create: { + reason: 'created by invite sign up', + activated: true, + feature: { + connect: { + feature_version: Quota_FreePlanV1_1, + }, + }, + }, + }, + }; + } + + async createUser(data: Prisma.UserCreateInput) { + return this.prisma.user.create({ + data: { + ...this.userCreatingData, + ...data, + }, + }); + } + + async createAnonymousUser( + email: string, + data?: Partial + ) { + const user = await this.findUserByEmail(email); + + if (user) { + throw new BadRequestException('Email already exists'); + } + + return this.createUser({ + email, + name: 'Unnamed', + ...data, + }); + } + + async findUserById(id: string) { + return this.prisma.user + .findUnique({ + where: { id }, + select: this.defaultUserSelect, + }) + .catch(() => { + return null; + }); + } + + async findUserByEmail(email: string) { + return this.prisma.user.findFirst({ + where: { + email: { + equals: email, + mode: 'insensitive', + }, + }, + select: this.defaultUserSelect, + }); + } + + /** + * supposed to be used only for `Credential SignIn` + */ + async findUserWithHashedPasswordByEmail(email: string) { + return this.prisma.user.findFirst({ + where: { + email: { + equals: email, + mode: 'insensitive', + }, + }, + }); + } + + async findOrCreateUser( + email: string, + data?: Partial + ) { + const user = await this.findUserByEmail(email); + if (user) { + return user; + } + return this.createAnonymousUser(email, data); + } + + async deleteUser(id: string) { + return this.prisma.user.delete({ where: { id } }); + } +} diff --git a/packages/backend/server/src/core/users/types.ts b/packages/backend/server/src/core/user/types.ts similarity index 73% rename from packages/backend/server/src/core/users/types.ts rename to packages/backend/server/src/core/user/types.ts index eb41b31618..2f297627ea 100644 --- a/packages/backend/server/src/core/users/types.ts +++ b/packages/backend/server/src/core/user/types.ts @@ -1,7 +1,15 @@ -import { createUnionType, Field, ID, ObjectType } from '@nestjs/graphql'; +import { + createUnionType, + Field, + ID, + InputType, + ObjectType, +} from '@nestjs/graphql'; import type { User } from '@prisma/client'; import { SafeIntResolver } from 'graphql-scalars'; +import { CurrentUser } from '../auth/current-user'; + @ObjectType('UserQuotaHumanReadable') export class UserQuotaHumanReadableType { @Field({ name: 'name' }) @@ -42,7 +50,7 @@ export class UserQuotaType { } @ObjectType() -export class UserType implements Partial { +export class UserType implements CurrentUser { @Field(() => ID) id!: string; @@ -53,19 +61,25 @@ export class UserType implements Partial { email!: string; @Field(() => String, { description: 'User avatar url', nullable: true }) - avatarUrl: string | null = null; + avatarUrl!: string | null; - @Field(() => Date, { description: 'User email verified', nullable: true }) - emailVerified: Date | null = null; - - @Field({ description: 'User created date', nullable: true }) - createdAt!: Date; + @Field(() => Boolean, { + description: 'User email verified', + }) + emailVerified!: boolean; @Field(() => Boolean, { description: 'User password has been set', nullable: true, }) - hasPassword?: boolean; + hasPassword!: boolean | null; + + @Field(() => Date, { + deprecationReason: 'useless', + description: 'User email verified', + nullable: true, + }) + createdAt?: Date | null; } @ObjectType() @@ -77,7 +91,7 @@ export class LimitedUserType implements Partial { description: 'User password has been set', nullable: true, }) - hasPassword?: boolean; + hasPassword!: boolean | null; } export const UserOrLimitedUser = createUnionType({ @@ -101,3 +115,9 @@ export class RemoveAvatar { @Field() success!: boolean; } + +@InputType() +export class UpdateUserInput implements Partial { + @Field({ description: 'User name', nullable: true }) + name?: string; +} diff --git a/packages/backend/server/src/core/users/users.ts b/packages/backend/server/src/core/users/users.ts deleted file mode 100644 index 06c8aa0b62..0000000000 --- a/packages/backend/server/src/core/users/users.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { PrismaClient } from '@prisma/client'; - -@Injectable() -export class UsersService { - constructor(private readonly prisma: PrismaClient) {} - - async findUserByEmail(email: string) { - return this.prisma.user.findFirst({ - where: { - email: { - equals: email, - mode: 'insensitive', - }, - }, - }); - } - - async findUserById(id: string) { - return this.prisma.user - .findUnique({ - where: { id }, - }) - .catch(() => { - return null; - }); - } - - async deleteUser(id: string) { - return this.prisma.user.delete({ where: { id } }); - } -} diff --git a/packages/backend/server/src/core/utils/validators.ts b/packages/backend/server/src/core/utils/validators.ts new file mode 100644 index 0000000000..547ae8a1e3 --- /dev/null +++ b/packages/backend/server/src/core/utils/validators.ts @@ -0,0 +1,55 @@ +import { BadRequestException } from '@nestjs/common'; +import z from 'zod'; + +function getAuthCredentialValidator() { + const email = z.string().email({ message: 'Invalid email address' }); + let password = z.string(); + + const minPasswordLength = AFFiNE.node.prod ? 8 : 1; + password = password + .min(minPasswordLength, { + message: `Password must be ${minPasswordLength} or more charactors long`, + }) + .max(20, { message: 'Password must be 20 or fewer charactors long' }); + + return z + .object({ + email, + password, + }) + .required(); +} + +function assertValid(z: z.ZodType, value: unknown) { + const result = z.safeParse(value); + + if (!result.success) { + const firstIssue = result.error.issues.at(0); + if (firstIssue) { + throw new BadRequestException(firstIssue.message); + } else { + throw new BadRequestException('Invalid credential'); + } + } +} + +export function assertValidEmail(email: string) { + assertValid(getAuthCredentialValidator().shape.email, email); +} + +export function assertValidPassword(password: string) { + assertValid(getAuthCredentialValidator().shape.password, password); +} + +export function assertValidCredential(credential: { + email: string; + password: string; +}) { + assertValid(getAuthCredentialValidator(), credential); +} + +export const validators = { + assertValidEmail, + assertValidPassword, + assertValidCredential, +}; diff --git a/packages/backend/server/src/core/workspaces/controller.ts b/packages/backend/server/src/core/workspaces/controller.ts index 006518acd1..14e9b952b4 100644 --- a/packages/backend/server/src/core/workspaces/controller.ts +++ b/packages/backend/server/src/core/workspaces/controller.ts @@ -11,10 +11,9 @@ import { PrismaClient } from '@prisma/client'; import type { Response } from 'express'; import { CallTimer } from '../../fundamentals'; -import { Auth, CurrentUser, Publicable } from '../auth'; +import { CurrentUser, Public } from '../auth'; import { DocHistoryManager, DocManager } from '../doc'; import { WorkspaceBlobStorage } from '../storage'; -import { UserType } from '../users'; import { DocID } from '../utils/doc'; import { PermissionService, PublicPageMode } from './permission'; import { Permission } from './types'; @@ -63,11 +62,10 @@ export class WorkspacesController { // get doc binary @Get('/:id/docs/:guid') - @Auth() - @Publicable() + @Public() @CallTimer('controllers', 'workspace_get_doc') async doc( - @CurrentUser() user: UserType | undefined, + @CurrentUser() user: CurrentUser | undefined, @Param('id') ws: string, @Param('guid') guid: string, @Res() res: Response @@ -112,10 +110,9 @@ export class WorkspacesController { } @Get('/:id/docs/:guid/histories/:timestamp') - @Auth() @CallTimer('controllers', 'workspace_get_history') async history( - @CurrentUser() user: UserType, + @CurrentUser() user: CurrentUser, @Param('id') ws: string, @Param('guid') guid: string, @Param('timestamp') timestamp: string, diff --git a/packages/backend/server/src/core/workspaces/index.ts b/packages/backend/server/src/core/workspaces/index.ts index 36a822e7b3..95c7b4bdb4 100644 --- a/packages/backend/server/src/core/workspaces/index.ts +++ b/packages/backend/server/src/core/workspaces/index.ts @@ -4,7 +4,7 @@ import { DocModule } from '../doc'; import { FeatureModule } from '../features'; import { QuotaModule } from '../quota'; import { StorageModule } from '../storage'; -import { UsersService } from '../users'; +import { UserModule } from '../user'; import { WorkspacesController } from './controller'; import { WorkspaceManagementResolver } from './management'; import { PermissionService } from './permission'; @@ -16,13 +16,12 @@ import { } from './resolvers'; @Module({ - imports: [DocModule, FeatureModule, QuotaModule, StorageModule], + imports: [DocModule, FeatureModule, QuotaModule, StorageModule, UserModule], controllers: [WorkspacesController], providers: [ WorkspaceResolver, WorkspaceManagementResolver, PermissionService, - UsersService, PagePermissionResolver, DocHistoryResolver, WorkspaceBlobResolver, diff --git a/packages/backend/server/src/core/workspaces/management.ts b/packages/backend/server/src/core/workspaces/management.ts index f4e2752290..c8625c4d43 100644 --- a/packages/backend/server/src/core/workspaces/management.ts +++ b/packages/backend/server/src/core/workspaces/management.ts @@ -10,14 +10,12 @@ import { } from '@nestjs/graphql'; import { CloudThrottlerGuard, Throttle } from '../../fundamentals'; -import { Auth, CurrentUser } from '../auth'; +import { CurrentUser } from '../auth'; import { FeatureManagementService, FeatureType } from '../features'; -import { UserType } from '../users'; import { PermissionService } from './permission'; import { WorkspaceType } from './types'; @UseGuards(CloudThrottlerGuard) -@Auth() @Resolver(() => WorkspaceType) export class WorkspaceManagementResolver { constructor( @@ -33,7 +31,7 @@ export class WorkspaceManagementResolver { }) @Mutation(() => Int) async addWorkspaceFeature( - @CurrentUser() currentUser: UserType, + @CurrentUser() currentUser: CurrentUser, @Args('workspaceId') workspaceId: string, @Args('feature', { type: () => FeatureType }) feature: FeatureType ): Promise { @@ -52,7 +50,7 @@ export class WorkspaceManagementResolver { }) @Mutation(() => Int) async removeWorkspaceFeature( - @CurrentUser() currentUser: UserType, + @CurrentUser() currentUser: CurrentUser, @Args('workspaceId') workspaceId: string, @Args('feature', { type: () => FeatureType }) feature: FeatureType ): Promise { @@ -71,7 +69,7 @@ export class WorkspaceManagementResolver { }) @Query(() => [WorkspaceType]) async listWorkspaceFeatures( - @CurrentUser() user: UserType, + @CurrentUser() user: CurrentUser, @Args('feature', { type: () => FeatureType }) feature: FeatureType ): Promise { if (!this.feature.isStaff(user.email)) { @@ -83,7 +81,7 @@ export class WorkspaceManagementResolver { @Mutation(() => Boolean) async setWorkspaceExperimentalFeature( - @CurrentUser() user: UserType, + @CurrentUser() user: CurrentUser, @Args('workspaceId') workspaceId: string, @Args('feature', { type: () => FeatureType }) feature: FeatureType, @Args('enable') enable: boolean @@ -117,7 +115,7 @@ export class WorkspaceManagementResolver { complexity: 2, }) async availableFeatures( - @CurrentUser() user: UserType + @CurrentUser() user: CurrentUser ): Promise { const isEarlyAccessUser = await this.feature.isEarlyAccessUser(user.email); if (isEarlyAccessUser) { diff --git a/packages/backend/server/src/core/workspaces/resolvers/blob.ts b/packages/backend/server/src/core/workspaces/resolvers/blob.ts index 5d859d0c42..847ee1c909 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/blob.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/blob.ts @@ -22,16 +22,14 @@ import { MakeCache, PreventCache, } from '../../../fundamentals'; -import { Auth, CurrentUser } from '../../auth'; +import { CurrentUser } from '../../auth'; import { FeatureManagementService, FeatureType } from '../../features'; import { QuotaManagementService } from '../../quota'; import { WorkspaceBlobStorage } from '../../storage'; -import { UserType } from '../../users'; import { PermissionService } from '../permission'; import { Permission, WorkspaceBlobSizes, WorkspaceType } from '../types'; @UseGuards(CloudThrottlerGuard) -@Auth() @Resolver(() => WorkspaceType) export class WorkspaceBlobResolver { logger = new Logger(WorkspaceBlobResolver.name); @@ -47,7 +45,7 @@ export class WorkspaceBlobResolver { complexity: 2, }) async blobs( - @CurrentUser() user: UserType, + @CurrentUser() user: CurrentUser, @Parent() workspace: WorkspaceType ) { await this.permissions.checkWorkspace(workspace.id, user.id); @@ -74,7 +72,7 @@ export class WorkspaceBlobResolver { }) @MakeCache(['blobs'], ['workspaceId']) async listBlobs( - @CurrentUser() user: UserType, + @CurrentUser() user: CurrentUser, @Args('workspaceId') workspaceId: string ) { await this.permissions.checkWorkspace(workspaceId, user.id); @@ -90,7 +88,7 @@ export class WorkspaceBlobResolver { @Query(() => WorkspaceBlobSizes, { deprecationReason: 'use `user.storageUsage` instead', }) - async collectAllBlobSizes(@CurrentUser() user: UserType) { + async collectAllBlobSizes(@CurrentUser() user: CurrentUser) { const size = await this.quota.getUserUsage(user.id); return { size }; } @@ -102,7 +100,7 @@ export class WorkspaceBlobResolver { deprecationReason: 'no more needed', }) async checkBlobSize( - @CurrentUser() user: UserType, + @CurrentUser() user: CurrentUser, @Args('workspaceId') workspaceId: string, @Args('size', { type: () => SafeIntResolver }) blobSize: number ) { @@ -121,7 +119,7 @@ export class WorkspaceBlobResolver { @Mutation(() => String) @PreventCache(['blobs'], ['workspaceId']) async setBlob( - @CurrentUser() user: UserType, + @CurrentUser() user: CurrentUser, @Args('workspaceId') workspaceId: string, @Args({ name: 'blob', type: () => GraphQLUpload }) blob: FileUpload @@ -199,7 +197,7 @@ export class WorkspaceBlobResolver { @Mutation(() => Boolean) @PreventCache(['blobs'], ['workspaceId']) async deleteBlob( - @CurrentUser() user: UserType, + @CurrentUser() user: CurrentUser, @Args('workspaceId') workspaceId: string, @Args('hash') name: string ) { diff --git a/packages/backend/server/src/core/workspaces/resolvers/history.ts b/packages/backend/server/src/core/workspaces/resolvers/history.ts index 3b8475f3a4..deef0851a4 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/history.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/history.ts @@ -13,9 +13,8 @@ import { import type { SnapshotHistory } from '@prisma/client'; import { CloudThrottlerGuard } from '../../../fundamentals'; -import { Auth, CurrentUser } from '../../auth'; +import { CurrentUser } from '../../auth'; import { DocHistoryManager } from '../../doc'; -import { UserType } from '../../users'; import { DocID } from '../../utils/doc'; import { PermissionService } from '../permission'; import { Permission, WorkspaceType } from '../types'; @@ -68,10 +67,9 @@ export class DocHistoryResolver { ); } - @Auth() @Mutation(() => Date) async recoverDoc( - @CurrentUser() user: UserType, + @CurrentUser() user: CurrentUser, @Args('workspaceId') workspaceId: string, @Args('guid') guid: string, @Args({ name: 'timestamp', type: () => GraphQLISODateTime }) timestamp: Date diff --git a/packages/backend/server/src/core/workspaces/resolvers/page.ts b/packages/backend/server/src/core/workspaces/resolvers/page.ts index 8364a351b5..773165d4fa 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/page.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/page.ts @@ -15,8 +15,7 @@ import { } from '@prisma/client'; import { CloudThrottlerGuard } from '../../../fundamentals'; -import { Auth, CurrentUser } from '../../auth'; -import { UserType } from '../../users'; +import { CurrentUser } from '../../auth'; import { DocID } from '../../utils/doc'; import { PermissionService, PublicPageMode } from '../permission'; import { Permission, WorkspaceType } from '../types'; @@ -42,7 +41,6 @@ class WorkspacePage implements Partial { } @UseGuards(CloudThrottlerGuard) -@Auth() @Resolver(() => WorkspaceType) export class PagePermissionResolver { constructor( @@ -90,7 +88,7 @@ export class PagePermissionResolver { deprecationReason: 'renamed to publicPage', }) async deprecatedSharePage( - @CurrentUser() user: UserType, + @CurrentUser() user: CurrentUser, @Args('workspaceId') workspaceId: string, @Args('pageId') pageId: string ) { @@ -100,7 +98,7 @@ export class PagePermissionResolver { @Mutation(() => WorkspacePage) async publishPage( - @CurrentUser() user: UserType, + @CurrentUser() user: CurrentUser, @Args('workspaceId') workspaceId: string, @Args('pageId') pageId: string, @Args({ @@ -134,7 +132,7 @@ export class PagePermissionResolver { deprecationReason: 'use revokePublicPage', }) async deprecatedRevokePage( - @CurrentUser() user: UserType, + @CurrentUser() user: CurrentUser, @Args('workspaceId') workspaceId: string, @Args('pageId') pageId: string ) { @@ -144,7 +142,7 @@ export class PagePermissionResolver { @Mutation(() => WorkspacePage) async revokePublicPage( - @CurrentUser() user: UserType, + @CurrentUser() user: CurrentUser, @Args('workspaceId') workspaceId: string, @Args('pageId') pageId: string ) { diff --git a/packages/backend/server/src/core/workspaces/resolvers/workspace.ts b/packages/backend/server/src/core/workspaces/resolvers/workspace.ts index 87fe5bd46d..206a7598da 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/workspace.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/workspace.ts @@ -15,7 +15,7 @@ import { ResolveField, Resolver, } from '@nestjs/graphql'; -import { PrismaClient, type User } from '@prisma/client'; +import { PrismaClient } from '@prisma/client'; import { getStreamAsBuffer } from 'get-stream'; import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs'; import { applyUpdate, Doc } from 'yjs'; @@ -27,11 +27,10 @@ import { MailService, Throttle, } from '../../../fundamentals'; -import { Auth, CurrentUser, Public } from '../../auth'; -import { AuthService } from '../../auth/service'; +import { CurrentUser, Public } from '../../auth'; import { QuotaManagementService, QuotaQueryType } from '../../quota'; import { WorkspaceBlobStorage } from '../../storage'; -import { UsersService, UserType } from '../../users'; +import { UserService, UserType } from '../../user'; import { PermissionService } from '../permission'; import { InvitationType, @@ -48,18 +47,16 @@ import { defaultWorkspaceAvatar } from '../utils'; * Other rate limit: 120 req/m */ @UseGuards(CloudThrottlerGuard) -@Auth() @Resolver(() => WorkspaceType) export class WorkspaceResolver { private readonly logger = new Logger(WorkspaceResolver.name); constructor( - private readonly auth: AuthService, private readonly mailer: MailService, private readonly prisma: PrismaClient, private readonly permissions: PermissionService, private readonly quota: QuotaManagementService, - private readonly users: UsersService, + private readonly users: UserService, private readonly event: EventEmitter, private readonly blobStorage: WorkspaceBlobStorage ) {} @@ -69,7 +66,7 @@ export class WorkspaceResolver { complexity: 2, }) async permission( - @CurrentUser() user: UserType, + @CurrentUser() user: CurrentUser, @Parent() workspace: WorkspaceType ) { // may applied in workspaces query @@ -160,7 +157,7 @@ export class WorkspaceResolver { complexity: 2, }) async isOwner( - @CurrentUser() user: UserType, + @CurrentUser() user: CurrentUser, @Args('workspaceId') workspaceId: string ) { const data = await this.permissions.tryGetWorkspaceOwner(workspaceId); @@ -172,7 +169,7 @@ export class WorkspaceResolver { description: 'Get all accessible workspaces for current user', complexity: 2, }) - async workspaces(@CurrentUser() user: User) { + async workspaces(@CurrentUser() user: CurrentUser) { const data = await this.prisma.workspaceUserPermission.findMany({ where: { userId: user.id, @@ -216,7 +213,7 @@ export class WorkspaceResolver { @Query(() => WorkspaceType, { description: 'Get workspace by id', }) - async workspace(@CurrentUser() user: UserType, @Args('id') id: string) { + async workspace(@CurrentUser() user: CurrentUser, @Args('id') id: string) { await this.permissions.checkWorkspace(id, user.id); const workspace = await this.prisma.workspace.findUnique({ where: { id } }); @@ -231,7 +228,7 @@ export class WorkspaceResolver { description: 'Create a new workspace', }) async createWorkspace( - @CurrentUser() user: UserType, + @CurrentUser() user: CurrentUser, // we no longer support init workspace with a preload file // use sync system to uploading them once created @Args({ name: 'init', type: () => GraphQLUpload, nullable: true }) @@ -289,7 +286,7 @@ export class WorkspaceResolver { description: 'Update workspace', }) async updateWorkspace( - @CurrentUser() user: UserType, + @CurrentUser() user: CurrentUser, @Args({ name: 'input', type: () => UpdateWorkspaceInput }) { id, ...updates }: UpdateWorkspaceInput ) { @@ -304,7 +301,10 @@ export class WorkspaceResolver { } @Mutation(() => Boolean) - async deleteWorkspace(@CurrentUser() user: UserType, @Args('id') id: string) { + async deleteWorkspace( + @CurrentUser() user: CurrentUser, + @Args('id') id: string + ) { await this.permissions.checkWorkspace(id, user.id, Permission.Owner); await this.prisma.workspace.delete({ @@ -320,7 +320,7 @@ export class WorkspaceResolver { @Mutation(() => String) async invite( - @CurrentUser() user: UserType, + @CurrentUser() user: CurrentUser, @Args('workspaceId') workspaceId: string, @Args('email') email: string, @Args('permission', { type: () => Permission }) permission: Permission, @@ -358,7 +358,7 @@ export class WorkspaceResolver { // only invite if the user is not already in the workspace if (originRecord) return originRecord.id; } else { - target = await this.auth.createAnonymousUser(email); + target = await this.users.createAnonymousUser(email); } const inviteId = await this.permissions.grant( @@ -470,7 +470,7 @@ export class WorkspaceResolver { @Mutation(() => Boolean) async revoke( - @CurrentUser() user: UserType, + @CurrentUser() user: CurrentUser, @Args('workspaceId') workspaceId: string, @Args('userId') userId: string ) { @@ -514,7 +514,7 @@ export class WorkspaceResolver { @Mutation(() => Boolean) async leaveWorkspace( - @CurrentUser() user: UserType, + @CurrentUser() user: CurrentUser, @Args('workspaceId') workspaceId: string, @Args('workspaceName') workspaceName: string, @Args('sendLeaveMail', { nullable: true }) sendLeaveMail: boolean diff --git a/packages/backend/server/src/core/workspaces/types.ts b/packages/backend/server/src/core/workspaces/types.ts index e959a1d1f4..f85a5a6f22 100644 --- a/packages/backend/server/src/core/workspaces/types.ts +++ b/packages/backend/server/src/core/workspaces/types.ts @@ -11,7 +11,7 @@ import { import type { Workspace } from '@prisma/client'; import { SafeIntResolver } from 'graphql-scalars'; -import { UserType } from '../users/types'; +import { UserType } from '../user/types'; export enum Permission { Read = 0, diff --git a/packages/backend/server/src/data/migrations/1605053000403-self-host-admin.ts b/packages/backend/server/src/data/migrations/1605053000403-self-host-admin.ts index c0bd3493fd..0d13b0724d 100644 --- a/packages/backend/server/src/data/migrations/1605053000403-self-host-admin.ts +++ b/packages/backend/server/src/data/migrations/1605053000403-self-host-admin.ts @@ -1,13 +1,15 @@ import { ModuleRef } from '@nestjs/core'; -import { hash } from '@node-rs/argon2'; import { PrismaClient } from '@prisma/client'; -import { Config } from '../../fundamentals'; +import { UserService } from '../../core/user'; +import { Config, CryptoHelper } from '../../fundamentals'; export class SelfHostAdmin1605053000403 { // do the migration - static async up(db: PrismaClient, ref: ModuleRef) { + static async up(_db: PrismaClient, ref: ModuleRef) { const config = ref.get(Config, { strict: false }); + const crypto = ref.get(CryptoHelper, { strict: false }); + const user = ref.get(UserService, { strict: false }); if (config.isSelfhosted) { if ( !process.env.AFFINE_ADMIN_EMAIL || @@ -17,13 +19,12 @@ export class SelfHostAdmin1605053000403 { 'You have to set AFFINE_ADMIN_EMAIL and AFFINE_ADMIN_PASSWORD environment variables to generate the initial user for self-hosted AFFiNE Server.' ); } - await db.user.create({ - data: { - name: 'AFFINE First User', - email: process.env.AFFINE_ADMIN_EMAIL, - emailVerified: new Date(), - password: await hash(process.env.AFFINE_ADMIN_PASSWORD), - }, + await user.findOrCreateUser(process.env.AFFINE_ADMIN_EMAIL, { + name: 'AFFINE First User', + emailVerifiedAt: new Date(), + password: await crypto.encryptPassword( + process.env.AFFINE_ADMIN_PASSWORD + ), }); } } diff --git a/packages/backend/server/src/fundamentals/config/def.ts b/packages/backend/server/src/fundamentals/config/def.ts index 2dc04cad3e..a81d53be75 100644 --- a/packages/backend/server/src/fundamentals/config/def.ts +++ b/packages/backend/server/src/fundamentals/config/def.ts @@ -87,6 +87,22 @@ export interface AFFiNEConfig { sync: boolean; }; + /** + * Application secrets for authentication and data encryption + */ + secrets: { + /** + * Application public key + * + */ + publicKey: string; + /** + * Application private key + * + */ + privateKey: string; + }; + /** * Deployment environment */ @@ -204,67 +220,32 @@ export interface AFFiNEConfig { * authentication config */ auth: { + session: { + /** + * Application auth expiration time in seconds + * + * @default 15 days + */ + ttl: number; + }; + /** - * Application access token expiration time + * Application access token config */ - readonly accessTokenExpiresIn: number; - /** - * Application refresh token expiration time - */ - readonly refreshTokenExpiresIn: number; - /** - * Add some leeway (in seconds) to the exp and nbf validation to account for clock skew. - * Defaults to 60 if omitted. - */ - readonly leeway: number; - /** - * Application public key - * - */ - readonly publicKey: string; - /** - * Application private key - * - */ - readonly privateKey: string; - /** - * whether allow user to signup with email directly - */ - enableSignup: boolean; - /** - * whether allow user to signup by oauth providers - */ - enableOauth: boolean; - /** - * NEXTAUTH_SECRET - */ - nextAuthSecret: string; - /** - * all available oauth providers - */ - oauthProviders: Partial< - Record< - ExternalAccount, - { - enabled: boolean; - clientId: string; - clientSecret: string; - /** - * uri to start oauth flow - */ - authorizationUri?: string; - /** - * uri to authenticate `access_token` when user is redirected back from oauth provider with `code` - */ - accessTokenUri?: string; - /** - * uri to get user info with authenticated `access_token` - */ - userInfoUri?: string; - args?: Record; - } - > - >; + accessToken: { + /** + * Application access token expiration time in seconds + * + * @default 7 days + */ + ttl: number; + /** + * Application refresh token expiration time in seconds + * + * @default 30 days + */ + refreshTokenTtl: number; + }; captcha: { /** * whether to enable captcha diff --git a/packages/backend/server/src/fundamentals/config/default.ts b/packages/backend/server/src/fundamentals/config/default.ts index 9c884a136f..f41bc16e7c 100644 --- a/packages/backend/server/src/fundamentals/config/default.ts +++ b/packages/backend/server/src/fundamentals/config/default.ts @@ -3,7 +3,6 @@ import { createPrivateKey, createPublicKey } from 'node:crypto'; import { merge } from 'lodash-es'; -import parse from 'parse-duration'; import pkg from '../../../package.json' assert { type: 'json' }; import { @@ -23,7 +22,9 @@ AwEHoUQDQgAEF3U/0wIeJ3jRKXeFKqQyBKlr9F7xaAUScRrAuSP33rajm3cdfihI 3JvMxVNsS2lE8PSGQrvDrJZaDo0L+Lq9Gg== -----END EC PRIVATE KEY-----`; -const jwtKeyPair = (function () { +const ONE_DAY_IN_SEC = 60 * 60 * 24; + +const keyPair = (function () { const AUTH_PRIVATE_KEY = process.env.AUTH_PRIVATE_KEY ?? examplePrivateKey; const privateKey = createPrivateKey({ key: Buffer.from(AUTH_PRIVATE_KEY), @@ -114,6 +115,10 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => { get deploy() { return !this.node.dev && !this.node.test; }, + secrets: { + privateKey: keyPair.privateKey, + publicKey: keyPair.publicKey, + }, featureFlags: { earlyAccessPreview: false, syncClientVersionCheck: false, @@ -145,11 +150,13 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => { playground: true, }, auth: { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - accessTokenExpiresIn: parse('1h')! / 1000, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - refreshTokenExpiresIn: parse('7d')! / 1000, - leeway: 60, + session: { + ttl: 15 * ONE_DAY_IN_SEC, + }, + accessToken: { + ttl: 7 * ONE_DAY_IN_SEC, + refreshTokenTtl: 30 * ONE_DAY_IN_SEC, + }, captcha: { enable: false, turnstile: { @@ -159,14 +166,6 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => { bits: 20, }, }, - privateKey: jwtKeyPair.privateKey, - publicKey: jwtKeyPair.publicKey, - enableSignup: true, - enableOauth: false, - get nextAuthSecret() { - return this.privateKey; - }, - oauthProviders: {}, }, storage: getDefaultAFFiNEStorageConfig(), rateLimiter: { @@ -188,10 +187,10 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => { enabled: false, }, plugins: { - enabled: [], + enabled: new Set(), use(plugin, config) { this[plugin] = merge(this[plugin], config || {}); - this.enabled.push(plugin); + this.enabled.add(plugin); }, }, } satisfies AFFiNEConfig; diff --git a/packages/backend/server/src/fundamentals/helpers/__tests__/crypto.spec.ts b/packages/backend/server/src/fundamentals/helpers/__tests__/crypto.spec.ts new file mode 100644 index 0000000000..5ab3448ad2 --- /dev/null +++ b/packages/backend/server/src/fundamentals/helpers/__tests__/crypto.spec.ts @@ -0,0 +1,105 @@ +import { createPrivateKey, createPublicKey } from 'node:crypto'; + +import { Test } from '@nestjs/testing'; +import ava, { TestFn } from 'ava'; +import Sinon from 'sinon'; + +import { ConfigModule } from '../../config'; +import { CryptoHelper } from '../crypto'; + +const test = ava as TestFn<{ + crypto: CryptoHelper; +}>; + +const key = `-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIEtyAJLIULkphVhqXqxk4Nr8Ggty3XLwUJWBxzAWCWTMoAoGCCqGSM49 +AwEHoUQDQgAEF3U/0wIeJ3jRKXeFKqQyBKlr9F7xaAUScRrAuSP33rajm3cdfihI +3JvMxVNsS2lE8PSGQrvDrJZaDo0L+Lq9Gg== +-----END EC PRIVATE KEY-----`; +const privateKey = createPrivateKey({ + key, + format: 'pem', + type: 'sec1', +}) + .export({ + type: 'pkcs8', + format: 'pem', + }) + .toString('utf8'); + +const publicKey = createPublicKey({ + key, + format: 'pem', + type: 'spki', +}) + .export({ + format: 'pem', + type: 'spki', + }) + .toString('utf8'); + +test.beforeEach(async t => { + const module = await Test.createTestingModule({ + imports: [ + ConfigModule.forRoot({ + secrets: { + publicKey, + privateKey, + }, + }), + ], + providers: [CryptoHelper], + }).compile(); + + t.context.crypto = module.get(CryptoHelper); +}); + +test('should be able to sign and verify', t => { + const data = 'hello world'; + const signature = t.context.crypto.sign(data); + t.true(t.context.crypto.verify(data, signature)); + t.false(t.context.crypto.verify(data, 'fake-signature')); +}); + +test('should be able to encrypt and decrypt', t => { + const data = 'top secret'; + const stub = Sinon.stub(t.context.crypto, 'randomBytes').returns( + Buffer.alloc(12, 0) + ); + + const encrypted = t.context.crypto.encrypt(data); + const decrypted = t.context.crypto.decrypt(encrypted); + + // we are using a stub to make sure the iv is always 0, + // the encrypted result will always be the same + t.is(encrypted, 'AAAAAAAAAAAAAAAAWUDlJRhzP+SZ3avvmLcgnou+q4E11w=='); + t.is(decrypted, data); + + stub.restore(); +}); + +test('should be able to get random bytes', t => { + const bytes = t.context.crypto.randomBytes(); + t.is(bytes.length, 12); + const bytes2 = t.context.crypto.randomBytes(); + + t.notDeepEqual(bytes, bytes2); +}); + +test('should be able to digest', t => { + const data = 'hello world'; + const hash = t.context.crypto.sha256(data).toString('base64'); + t.is(hash, 'uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek='); +}); + +test('should be able to safe compare', t => { + t.true(t.context.crypto.compare('abc', 'abc')); + t.false(t.context.crypto.compare('abc', 'def')); +}); + +test('should be able to hash and verify password', async t => { + const password = 'mySecurePassword'; + const hash = await t.context.crypto.encryptPassword(password); + t.true(await t.context.crypto.verifyPassword(password, hash)); + t.false(await t.context.crypto.verifyPassword('wrong-password', hash)); +}); diff --git a/packages/backend/server/src/fundamentals/helpers/__tests__/url.spec.ts b/packages/backend/server/src/fundamentals/helpers/__tests__/url.spec.ts new file mode 100644 index 0000000000..7a8e8cb40c --- /dev/null +++ b/packages/backend/server/src/fundamentals/helpers/__tests__/url.spec.ts @@ -0,0 +1,72 @@ +import { Test } from '@nestjs/testing'; +import ava, { TestFn } from 'ava'; +import Sinon from 'sinon'; + +import { ConfigModule } from '../../config'; +import { URLHelper } from '../url'; + +const test = ava as TestFn<{ + url: URLHelper; +}>; + +test.beforeEach(async t => { + const module = await Test.createTestingModule({ + imports: [ + ConfigModule.forRoot({ + host: 'app.affine.local', + port: 3010, + https: true, + }), + ], + providers: [URLHelper], + }).compile(); + + t.context.url = module.get(URLHelper); +}); + +test('can get home page', t => { + t.is(t.context.url.home, 'https://app.affine.local'); +}); + +test('can stringify query', t => { + t.is(t.context.url.stringify({ a: 1, b: 2 }), 'a=1&b=2'); + t.is(t.context.url.stringify({ a: 1, b: '/path' }), 'a=1&b=%2Fpath'); +}); + +test('can create link', t => { + t.is(t.context.url.link('/path'), 'https://app.affine.local/path'); + t.is( + t.context.url.link('/path', { a: 1, b: 2 }), + 'https://app.affine.local/path?a=1&b=2' + ); + t.is( + t.context.url.link('/path', { a: 1, b: '/path' }), + 'https://app.affine.local/path?a=1&b=%2Fpath' + ); +}); + +test('can safe redirect', t => { + const res = { + redirect: (to: string) => to, + } as any; + + const spy = Sinon.spy(res, 'redirect'); + function allow(to: string) { + t.context.url.safeRedirect(res, to); + t.true(spy.calledOnceWith(to)); + spy.resetHistory(); + } + + function deny(to: string) { + t.context.url.safeRedirect(res, to); + t.true(spy.calledOnceWith(t.context.url.home)); + spy.resetHistory(); + } + + [ + 'https://app.affine.local', + 'https://app.affine.local/path', + 'https://app.affine.local/path?query=1', + ].forEach(allow); + ['https://other.domain.com', 'a://invalid.uri'].forEach(deny); +}); diff --git a/packages/backend/server/src/fundamentals/helpers/crypto.ts b/packages/backend/server/src/fundamentals/helpers/crypto.ts new file mode 100644 index 0000000000..fdd868cf80 --- /dev/null +++ b/packages/backend/server/src/fundamentals/helpers/crypto.ts @@ -0,0 +1,115 @@ +import { + createCipheriv, + createDecipheriv, + createHash, + createSign, + createVerify, + randomBytes, + timingSafeEqual, +} from 'node:crypto'; + +import { Injectable } from '@nestjs/common'; +import { + hash as hashPassword, + verify as verifyPassword, +} from '@node-rs/argon2'; + +import { Config } from '../config'; + +const NONCE_LENGTH = 12; +const AUTH_TAG_LENGTH = 12; + +@Injectable() +export class CryptoHelper { + keyPair: { + publicKey: Buffer; + privateKey: Buffer; + sha256: { + publicKey: Buffer; + privateKey: Buffer; + }; + }; + + constructor(config: Config) { + this.keyPair = { + publicKey: Buffer.from(config.secrets.publicKey, 'utf8'), + privateKey: Buffer.from(config.secrets.privateKey, 'utf8'), + sha256: { + publicKey: this.sha256(config.secrets.publicKey), + privateKey: this.sha256(config.secrets.privateKey), + }, + }; + } + + sign(data: string) { + const sign = createSign('rsa-sha256'); + sign.update(data, 'utf-8'); + sign.end(); + return sign.sign(this.keyPair.privateKey, 'base64'); + } + + verify(data: string, signature: string) { + const verify = createVerify('rsa-sha256'); + verify.update(data, 'utf-8'); + verify.end(); + return verify.verify(this.keyPair.privateKey, signature, 'base64'); + } + + encrypt(data: string) { + const iv = this.randomBytes(); + const cipher = createCipheriv( + 'aes-256-gcm', + this.keyPair.sha256.privateKey, + iv, + { + authTagLength: AUTH_TAG_LENGTH, + } + ); + const encrypted = Buffer.concat([ + cipher.update(data, 'utf-8'), + cipher.final(), + ]); + const authTag = cipher.getAuthTag(); + return Buffer.concat([iv, authTag, encrypted]).toString('base64'); + } + + decrypt(encrypted: string) { + const buf = Buffer.from(encrypted, 'base64'); + const iv = buf.subarray(0, NONCE_LENGTH); + const authTag = buf.subarray(NONCE_LENGTH, NONCE_LENGTH + AUTH_TAG_LENGTH); + const encryptedToken = buf.subarray(NONCE_LENGTH + AUTH_TAG_LENGTH); + const decipher = createDecipheriv( + 'aes-256-gcm', + this.keyPair.sha256.privateKey, + iv, + { authTagLength: AUTH_TAG_LENGTH } + ); + decipher.setAuthTag(authTag); + const decrepted = decipher.update(encryptedToken, void 0, 'utf8'); + return decrepted + decipher.final('utf8'); + } + + encryptPassword(password: string) { + return hashPassword(password); + } + + verifyPassword(password: string, hash: string) { + return verifyPassword(hash, password); + } + + compare(lhs: string, rhs: string) { + if (lhs.length !== rhs.length) { + return false; + } + + return timingSafeEqual(Buffer.from(lhs), Buffer.from(rhs)); + } + + randomBytes(length = NONCE_LENGTH) { + return randomBytes(length); + } + + sha256(data: string) { + return createHash('sha256').update(data).digest(); + } +} diff --git a/packages/backend/server/src/fundamentals/helpers/index.ts b/packages/backend/server/src/fundamentals/helpers/index.ts new file mode 100644 index 0000000000..1d03b06af4 --- /dev/null +++ b/packages/backend/server/src/fundamentals/helpers/index.ts @@ -0,0 +1,13 @@ +import { Global, Module } from '@nestjs/common'; + +import { CryptoHelper } from './crypto'; +import { URLHelper } from './url'; + +@Global() +@Module({ + providers: [URLHelper, CryptoHelper], + exports: [URLHelper, CryptoHelper], +}) +export class HelpersModule {} + +export { CryptoHelper, URLHelper }; diff --git a/packages/backend/server/src/fundamentals/helpers/url.ts b/packages/backend/server/src/fundamentals/helpers/url.ts new file mode 100644 index 0000000000..ee2a666a6c --- /dev/null +++ b/packages/backend/server/src/fundamentals/helpers/url.ts @@ -0,0 +1,54 @@ +import { Injectable } from '@nestjs/common'; +import { type Response } from 'express'; + +import { Config } from '../config'; + +@Injectable() +export class URLHelper { + redirectAllowHosts: string[]; + + constructor(private readonly config: Config) { + this.redirectAllowHosts = [this.config.baseUrl]; + } + + get home() { + return this.config.baseUrl; + } + + stringify(query: Record) { + return new URLSearchParams(query).toString(); + } + + link(path: string, query: Record = {}) { + const url = new URL( + this.config.baseUrl + (path.startsWith('/') ? path : '/' + path) + ); + + for (const key in query) { + url.searchParams.set(key, query[key]); + } + + return url.toString(); + } + + safeRedirect(res: Response, to: string) { + try { + const finalTo = new URL(decodeURIComponent(to), this.config.baseUrl); + + for (const host of this.redirectAllowHosts) { + const hostURL = new URL(host); + if ( + hostURL.origin === finalTo.origin && + finalTo.pathname.startsWith(hostURL.pathname) + ) { + return res.redirect(finalTo.toString().replace(/\/$/, '')); + } + } + } catch { + // just ignore invalid url + } + + // redirect to home if the url is invalid + return res.redirect(this.home); + } +} diff --git a/packages/backend/server/src/fundamentals/index.ts b/packages/backend/server/src/fundamentals/index.ts index af674c34ff..02c5515e16 100644 --- a/packages/backend/server/src/fundamentals/index.ts +++ b/packages/backend/server/src/fundamentals/index.ts @@ -14,6 +14,7 @@ export { } from './config'; export * from './error'; export { EventEmitter, type EventPayload, OnEvent } from './event'; +export { CryptoHelper, URLHelper } from './helpers'; export { MailService } from './mailer'; export { CallCounter, CallTimer, metrics } from './metrics'; export { @@ -21,7 +22,6 @@ export { GlobalExceptionFilter, OptionalModule, } from './nestjs'; -export { SessionService } from './session'; export * from './storage'; export { type StorageProvider, StorageProviderFactory } from './storage'; export { AuthThrottlerGuard, CloudThrottlerGuard, Throttle } from './throttler'; diff --git a/packages/backend/server/src/fundamentals/mailer/mail.service.ts b/packages/backend/server/src/fundamentals/mailer/mail.service.ts index aab0620a4e..7bab15dc55 100644 --- a/packages/backend/server/src/fundamentals/mailer/mail.service.ts +++ b/packages/backend/server/src/fundamentals/mailer/mail.service.ts @@ -1,12 +1,14 @@ import { Inject, Injectable, Optional } from '@nestjs/common'; import { Config } from '../config'; +import { URLHelper } from '../helpers'; import { MAILER_SERVICE, type MailerService, type Options } from './mailer'; import { emailTemplate } from './template'; @Injectable() export class MailService { constructor( private readonly config: Config, + private readonly url: URLHelper, @Optional() @Inject(MAILER_SERVICE) private readonly mailer?: MailerService ) {} @@ -41,7 +43,7 @@ export class MailService { } ) { // TODO: use callback url when need support desktop app - const buttonUrl = `${this.config.origin}/invite/${inviteId}`; + const buttonUrl = this.url.link(`/invite/${inviteId}`); const workspaceAvatar = invitationInfo.workspace.avatar; const content = `

${ @@ -92,7 +94,23 @@ export class MailService { }); } - async sendSignInEmail(url: string, options: Options) { + async sendSignUpMail(url: string, options: Options) { + const html = emailTemplate({ + title: 'Create AFFiNE Account', + content: + 'Click the button below to complete your account creation and sign in. This magic link will expire in 30 minutes.', + buttonContent: ' Create account and sign in', + buttonUrl: url, + }); + + return this.sendMail({ + html, + subject: 'Your AFFiNE account is waiting for you!', + ...options, + }); + } + + async sendSignInMail(url: string, options: Options) { const html = emailTemplate({ title: 'Sign in to AFFiNE', content: @@ -164,6 +182,20 @@ export class MailService { html, }); } + async sendVerifyEmail(to: string, url: string) { + const html = emailTemplate({ + title: 'Verify your email address', + content: + 'You recently requested to verify the email address associated with your AFFiNE account. To complete this process, please click on the verification link below. This magic link will expire in 30 minutes.', + buttonContent: 'Verify your email address', + buttonUrl: url, + }); + return this.sendMail({ + to, + subject: `Verify your email for AFFiNE`, + html, + }); + } async sendNotificationChangeEmail(to: string) { const html = emailTemplate({ title: 'Email change successful', diff --git a/packages/backend/server/src/fundamentals/nestjs/optional-module.ts b/packages/backend/server/src/fundamentals/nestjs/optional-module.ts index e7003485b7..b53d2408f6 100644 --- a/packages/backend/server/src/fundamentals/nestjs/optional-module.ts +++ b/packages/backend/server/src/fundamentals/nestjs/optional-module.ts @@ -9,7 +9,7 @@ import { omit } from 'lodash-es'; import { Config, ConfigPaths } from '../config'; -interface OptionalModuleMetadata extends ModuleMetadata { +export interface OptionalModuleMetadata extends ModuleMetadata { /** * Only install module if given config paths are defined in AFFiNE config. */ diff --git a/packages/backend/server/src/fundamentals/session/index.ts b/packages/backend/server/src/fundamentals/session/index.ts deleted file mode 100644 index 3ee1759310..0000000000 --- a/packages/backend/server/src/fundamentals/session/index.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { Global, Injectable, Module } from '@nestjs/common'; - -import { SessionCache } from '../cache'; - -@Injectable() -export class SessionService { - private readonly prefix = 'session:'; - public readonly sessionTtl = 30 * 60 * 1000; // 30 min - - constructor(private readonly cache: SessionCache) {} - - /** - * get session - * @param key session key - * @returns - */ - async get(key: string) { - return this.cache.get(this.prefix + key); - } - - /** - * set session - * @param key session key - * @param value session value - * @param sessionTtl session ttl (ms), default 30 min - * @returns return true if success - */ - async set(key: string, value?: any, sessionTtl = this.sessionTtl) { - return this.cache.set(this.prefix + key, value, { - ttl: sessionTtl, - }); - } - - async delete(key: string) { - return this.cache.delete(this.prefix + key); - } -} - -@Global() -@Module({ - providers: [SessionService], - exports: [SessionService], -}) -export class SessionModule {} diff --git a/packages/backend/server/src/fundamentals/utils/request.ts b/packages/backend/server/src/fundamentals/utils/request.ts index 7ee27745ce..21f4ed520f 100644 --- a/packages/backend/server/src/fundamentals/utils/request.ts +++ b/packages/backend/server/src/fundamentals/utils/request.ts @@ -1,53 +1,8 @@ import type { ArgumentsHost, ExecutionContext } from '@nestjs/common'; import type { GqlContextType } from '@nestjs/graphql'; -import { GqlArgumentsHost, GqlExecutionContext } from '@nestjs/graphql'; +import { GqlArgumentsHost } from '@nestjs/graphql'; import type { Request, Response } from 'express'; - -export function getRequestResponseFromContext(context: ExecutionContext) { - switch (context.getType()) { - case 'graphql': { - const gqlContext = GqlExecutionContext.create(context).getContext<{ - req: Request; - }>(); - return { - req: gqlContext.req, - res: gqlContext.req.res, - }; - } - case 'http': { - const http = context.switchToHttp(); - return { - req: http.getRequest(), - res: http.getResponse(), - }; - } - case 'ws': { - const ws = context.switchToWs(); - const req = ws.getClient().handshake; - - const cookies = req?.headers?.cookie; - // patch cookies to match auth guard logic - if (typeof cookies === 'string') { - req.cookies = cookies - .split(';') - .map(v => v.split('=')) - .reduce( - (acc, v) => { - acc[decodeURIComponent(v[0].trim())] = decodeURIComponent( - v[1].trim() - ); - return acc; - }, - {} as Record - ); - } - - return { req }; - } - default: - throw new Error('Unknown context type for getting request and response'); - } -} +import type { Socket } from 'socket.io'; export function getRequestResponseFromHost(host: ArgumentsHost) { switch (host.getType()) { @@ -67,11 +22,47 @@ export function getRequestResponseFromHost(host: ArgumentsHost) { res: http.getResponse(), }; } - default: - throw new Error('Unknown host type for getting request and response'); + case 'ws': { + const ws = host.switchToWs(); + const req = ws.getClient().client.conn.request as Request; + + const cookieStr = req?.headers?.cookie; + // patch cookies to match auth guard logic + if (typeof cookieStr === 'string') { + req.cookies = cookieStr.split(';').reduce( + (cookies, cookie) => { + const [key, val] = cookie.split('='); + + if (key) { + cookies[decodeURIComponent(key.trim())] = val + ? decodeURIComponent(val.trim()) + : val; + } + + return cookies; + }, + {} as Record + ); + } + + return { req }; + } + case 'rpc': { + const rpc = host.switchToRpc(); + const { req } = rpc.getContext<{ req: Request }>(); + + return { + req, + res: req.res, + }; + } } } export function getRequestFromHost(host: ArgumentsHost) { return getRequestResponseFromHost(host).req; } + +export function getRequestResponseFromContext(ctx: ExecutionContext) { + return getRequestResponseFromHost(ctx); +} diff --git a/packages/backend/server/src/global.d.ts b/packages/backend/server/src/global.d.ts index f7674001d3..ebb0fae5f1 100644 --- a/packages/backend/server/src/global.d.ts +++ b/packages/backend/server/src/global.d.ts @@ -1,6 +1,6 @@ declare namespace Express { interface Request { - user?: import('@prisma/client').User | null; + user?: import('./core/auth/current-user').CurrentUser; } } diff --git a/packages/backend/server/src/plugins/config.ts b/packages/backend/server/src/plugins/config.ts index 2e150c971c..eea08c491f 100644 --- a/packages/backend/server/src/plugins/config.ts +++ b/packages/backend/server/src/plugins/config.ts @@ -1,4 +1,5 @@ import { GCloudConfig } from './gcloud/config'; +import { OAuthConfig } from './oauth'; import { PaymentConfig } from './payment'; import { RedisOptions } from './redis'; import { R2StorageConfig, S3StorageConfig } from './storage'; @@ -10,13 +11,14 @@ declare module '../fundamentals/config' { readonly gcloud: GCloudConfig; readonly 'cloudflare-r2': R2StorageConfig; readonly 'aws-s3': S3StorageConfig; + readonly oauth: OAuthConfig; } export type AvailablePlugins = keyof PluginsConfig; interface AFFiNEConfig { readonly plugins: { - enabled: AvailablePlugins[]; + enabled: Set; use( plugin: Plugin, config?: DeepPartial diff --git a/packages/backend/server/src/plugins/gcloud/index.ts b/packages/backend/server/src/plugins/gcloud/index.ts index ed8b238c16..16a5a4494e 100644 --- a/packages/backend/server/src/plugins/gcloud/index.ts +++ b/packages/backend/server/src/plugins/gcloud/index.ts @@ -1,10 +1,11 @@ import { Global } from '@nestjs/common'; -import { OptionalModule } from '../../fundamentals'; +import { Plugin } from '../registry'; import { GCloudMetrics } from './metrics'; @Global() -@OptionalModule({ +@Plugin({ + name: 'gcloud', imports: [GCloudMetrics], }) export class GCloudModule {} diff --git a/packages/backend/server/src/plugins/index.ts b/packages/backend/server/src/plugins/index.ts index 9780e7322a..42ea147ad3 100644 --- a/packages/backend/server/src/plugins/index.ts +++ b/packages/backend/server/src/plugins/index.ts @@ -1,13 +1,7 @@ -import type { AvailablePlugins } from '../fundamentals/config'; -import { GCloudModule } from './gcloud'; -import { PaymentModule } from './payment'; -import { RedisModule } from './redis'; -import { AwsS3Module, CloudflareR2Module } from './storage'; +import './gcloud'; +import './oauth'; +import './payment'; +import './redis'; +import './storage'; -export const pluginsMap = new Map([ - ['payment', PaymentModule], - ['redis', RedisModule], - ['gcloud', GCloudModule], - ['cloudflare-r2', CloudflareR2Module], - ['aws-s3', AwsS3Module], -]); +export { REGISTERED_PLUGINS } from './registry'; diff --git a/packages/backend/server/src/plugins/oauth/controller.ts b/packages/backend/server/src/plugins/oauth/controller.ts new file mode 100644 index 0000000000..92f968e892 --- /dev/null +++ b/packages/backend/server/src/plugins/oauth/controller.ts @@ -0,0 +1,230 @@ +import { + BadRequestException, + Controller, + Get, + Query, + Req, + Res, +} from '@nestjs/common'; +import { ConnectedAccount, PrismaClient } from '@prisma/client'; +import type { Request, Response } from 'express'; + +import { AuthService, Public } from '../../core/auth'; +import { UserService } from '../../core/user'; +import { URLHelper } from '../../fundamentals'; +import { OAuthAccount, Tokens } from './providers/def'; +import { OAuthProviderFactory } from './register'; +import { OAuthService } from './service'; +import { OAuthProviderName } from './types'; + +@Controller('/oauth') +export class OAuthController { + constructor( + private readonly auth: AuthService, + private readonly oauth: OAuthService, + private readonly user: UserService, + private readonly providerFactory: OAuthProviderFactory, + private readonly url: URLHelper, + private readonly db: PrismaClient + ) {} + + @Public() + @Get('/login') + async login( + @Res() res: Response, + @Query('provider') unknownProviderName: string, + @Query('redirect_uri') redirectUri?: string + ) { + // @ts-expect-error safe + const providerName = OAuthProviderName[unknownProviderName]; + const provider = this.providerFactory.get(providerName); + + if (!provider) { + throw new BadRequestException('Invalid provider'); + } + + const state = await this.oauth.saveOAuthState({ + redirectUri: redirectUri ?? this.url.home, + provider: providerName, + }); + + return res.redirect(provider.getAuthUrl(state)); + } + + @Public() + @Get('/callback') + async callback( + @Req() req: Request, + @Res() res: Response, + @Query('code') code?: string, + @Query('state') stateStr?: string + ) { + if (!code) { + throw new BadRequestException('Missing query parameter `code`'); + } + + if (!stateStr) { + throw new BadRequestException('Invalid callback state parameter'); + } + + const state = await this.oauth.getOAuthState(stateStr); + + if (!state) { + throw new BadRequestException('OAuth state expired, please try again.'); + } + + if (!state.provider) { + throw new BadRequestException( + 'Missing callback state parameter `provider`' + ); + } + + const provider = this.providerFactory.get(state.provider); + + if (!provider) { + throw new BadRequestException('Invalid provider'); + } + + const tokens = await provider.getToken(code); + const externAccount = await provider.getUser(tokens.accessToken); + const user = req.user; + + try { + if (!user) { + // if user not found, login + const user = await this.loginFromOauth( + state.provider, + externAccount, + tokens + ); + const session = await this.auth.createUserSession( + user, + req.cookies[AuthService.sessionCookieName] + ); + res.cookie(AuthService.sessionCookieName, session.sessionId, { + expires: session.expiresAt ?? void 0, // expiredAt is `string | null` + ...this.auth.cookieOptions, + }); + } else { + // if user is found, connect the account to this user + await this.connectAccountFromOauth( + user, + state.provider, + externAccount, + tokens + ); + } + } catch (e: any) { + return res.redirect( + this.url.link('/signIn', { + redirect_uri: state.redirectUri, + error: e.message, + }) + ); + } + + this.url.safeRedirect(res, state.redirectUri); + } + + private async loginFromOauth( + provider: OAuthProviderName, + externalAccount: OAuthAccount, + tokens: Tokens + ) { + const connectedUser = await this.db.connectedAccount.findFirst({ + where: { + provider, + providerAccountId: externalAccount.id, + }, + include: { + user: true, + }, + }); + + if (connectedUser) { + // already connected + await this.updateConnectedAccount(connectedUser, tokens); + + return connectedUser.user; + } + + let user = await this.user.findUserByEmail(externalAccount.email); + + if (user) { + // we can't directly connect the external account with given email in sign in scenario for safety concern. + // let user manually connect in account sessions instead. + throw new BadRequestException( + 'The account with provided email is not register in the same way.' + ); + } else { + user = await this.createUserWithConnectedAccount( + provider, + externalAccount, + tokens + ); + } + + return user; + } + + updateConnectedAccount(connectedUser: ConnectedAccount, tokens: Tokens) { + return this.db.connectedAccount.update({ + where: { + id: connectedUser.id, + }, + data: tokens, + }); + } + + async createUserWithConnectedAccount( + provider: OAuthProviderName, + externalAccount: OAuthAccount, + tokens: Tokens + ) { + return this.user.createUser({ + email: externalAccount.email, + name: 'Unnamed', + avatarUrl: externalAccount.avatarUrl, + emailVerifiedAt: new Date(), + connectedAccounts: { + create: { + provider, + providerAccountId: externalAccount.id, + ...tokens, + }, + }, + }); + } + + private async connectAccountFromOauth( + user: { id: string }, + provider: OAuthProviderName, + externalAccount: OAuthAccount, + tokens: Tokens + ) { + const connectedUser = await this.db.connectedAccount.findFirst({ + where: { + provider, + providerAccountId: externalAccount.id, + }, + }); + + if (connectedUser) { + if (connectedUser.id !== user.id) { + throw new BadRequestException( + 'The third-party account has already been connected to another user.' + ); + } + } else { + await this.db.connectedAccount.create({ + data: { + userId: user.id, + provider, + providerAccountId: externalAccount.id, + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + }, + }); + } + } +} diff --git a/packages/backend/server/src/plugins/oauth/index.ts b/packages/backend/server/src/plugins/oauth/index.ts new file mode 100644 index 0000000000..0b14d1d984 --- /dev/null +++ b/packages/backend/server/src/plugins/oauth/index.ts @@ -0,0 +1,25 @@ +import { AuthModule } from '../../core/auth'; +import { ServerFeature } from '../../core/config'; +import { UserModule } from '../../core/user'; +import { Plugin } from '../registry'; +import { OAuthController } from './controller'; +import { OAuthProviders } from './providers'; +import { OAuthProviderFactory } from './register'; +import { OAuthResolver } from './resolver'; +import { OAuthService } from './service'; + +@Plugin({ + name: 'oauth', + imports: [AuthModule, UserModule], + providers: [ + OAuthProviderFactory, + OAuthService, + OAuthResolver, + ...OAuthProviders, + ], + controllers: [OAuthController], + contributesTo: ServerFeature.OAuth, + if: config => !!config.plugins.oauth, +}) +export class OAuthModule {} +export type { OAuthConfig } from './types'; diff --git a/packages/backend/server/src/plugins/oauth/providers/def.ts b/packages/backend/server/src/plugins/oauth/providers/def.ts new file mode 100644 index 0000000000..7e7913cdaf --- /dev/null +++ b/packages/backend/server/src/plugins/oauth/providers/def.ts @@ -0,0 +1,21 @@ +import { OAuthProviderName } from '../types'; + +export interface OAuthAccount { + id: string; + email: string; + avatarUrl?: string; +} + +export interface Tokens { + accessToken: string; + scope?: string; + refreshToken?: string; + expiresAt?: Date; +} + +export abstract class OAuthProvider { + abstract provider: OAuthProviderName; + abstract getAuthUrl(state?: string): string; + abstract getToken(code: string): Promise; + abstract getUser(token: string): Promise; +} diff --git a/packages/backend/server/src/plugins/oauth/providers/github.ts b/packages/backend/server/src/plugins/oauth/providers/github.ts new file mode 100644 index 0000000000..50227539a7 --- /dev/null +++ b/packages/backend/server/src/plugins/oauth/providers/github.ts @@ -0,0 +1,113 @@ +import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; + +import { Config, URLHelper } from '../../../fundamentals'; +import { AutoRegisteredOAuthProvider } from '../register'; +import { OAuthProviderName } from '../types'; + +interface AuthTokenResponse { + access_token: string; + scope: string; + token_type: string; +} + +export interface UserInfo { + login: string; + email: string; + avatar_url: string; + name: string; +} + +@Injectable() +export class GithubOAuthProvider extends AutoRegisteredOAuthProvider { + provider = OAuthProviderName.GitHub; + + constructor( + protected readonly AFFiNEConfig: Config, + private readonly url: URLHelper + ) { + super(); + } + + getAuthUrl(state: string) { + return `https://github.com/login/oauth/authorize?${this.url.stringify({ + client_id: this.config.clientId, + redirect_uri: this.url.link('/oauth/callback'), + scope: 'user', + ...this.config.args, + state, + })}`; + } + + async getToken(code: string) { + try { + const response = await fetch( + 'https://github.com/login/oauth/access_token', + { + method: 'POST', + body: this.url.stringify({ + code, + client_id: this.config.clientId, + client_secret: this.config.clientSecret, + redirect_uri: this.url.link('/oauth/callback'), + }), + headers: { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + } + ); + + if (response.ok) { + const ghToken = (await response.json()) as AuthTokenResponse; + + return { + accessToken: ghToken.access_token, + scope: ghToken.scope, + }; + } else { + throw new Error( + `Server responded with non-success code ${ + response.status + }, ${JSON.stringify(await response.json())}` + ); + } + } catch (e) { + throw new HttpException( + `Failed to get access_token, err: ${(e as Error).message}`, + HttpStatus.BAD_REQUEST + ); + } + } + + async getUser(token: string) { + try { + const response = await fetch('https://api.github.com/user', { + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (response.ok) { + const user = (await response.json()) as UserInfo; + + return { + id: user.login, + avatarUrl: user.avatar_url, + email: user.email, + }; + } else { + throw new Error( + `Server responded with non-success code ${ + response.status + } ${await response.text()}` + ); + } + } catch (e) { + throw new HttpException( + `Failed to get user information, err: ${(e as Error).stack}`, + HttpStatus.BAD_REQUEST + ); + } + } +} diff --git a/packages/backend/server/src/plugins/oauth/providers/google.ts b/packages/backend/server/src/plugins/oauth/providers/google.ts new file mode 100644 index 0000000000..fb22bd36f0 --- /dev/null +++ b/packages/backend/server/src/plugins/oauth/providers/google.ts @@ -0,0 +1,121 @@ +import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; + +import { Config, URLHelper } from '../../../fundamentals'; +import { AutoRegisteredOAuthProvider } from '../register'; +import { OAuthProviderName } from '../types'; + +interface GoogleOAuthTokenResponse { + access_token: string; + expires_in: number; + refresh_token: string; + scope: string; + token_type: string; +} + +export interface UserInfo { + id: string; + email: string; + picture: string; + name: string; +} + +@Injectable() +export class GoogleOAuthProvider extends AutoRegisteredOAuthProvider { + override provider = OAuthProviderName.Google; + + constructor( + protected readonly AFFiNEConfig: Config, + private readonly url: URLHelper + ) { + super(); + } + + getAuthUrl(state: string) { + return `https://accounts.google.com/o/oauth2/v2/auth?${this.url.stringify({ + client_id: this.config.clientId, + redirect_uri: this.url.link('/oauth/callback'), + response_type: 'code', + scope: 'openid email profile', + promot: 'select_account', + access_type: 'offline', + ...this.config.args, + state, + })}`; + } + + async getToken(code: string) { + try { + const response = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + body: this.url.stringify({ + code, + client_id: this.config.clientId, + client_secret: this.config.clientSecret, + redirect_uri: this.url.link('/oauth/callback'), + grant_type: 'authorization_code', + }), + headers: { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }); + + if (response.ok) { + const ghToken = (await response.json()) as GoogleOAuthTokenResponse; + + return { + accessToken: ghToken.access_token, + refreshToken: ghToken.refresh_token, + expiresAt: new Date(Date.now() + ghToken.expires_in * 1000), + scope: ghToken.scope, + }; + } else { + throw new Error( + `Server responded with non-success code ${ + response.status + }, ${JSON.stringify(await response.json())}` + ); + } + } catch (e) { + throw new HttpException( + `Failed to get access_token, err: ${(e as Error).message}`, + HttpStatus.BAD_REQUEST + ); + } + } + + async getUser(token: string) { + try { + const response = await fetch( + 'https://www.googleapis.com/oauth2/v2/userinfo', + { + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + }, + } + ); + + if (response.ok) { + const user = (await response.json()) as UserInfo; + + return { + id: user.id, + avatarUrl: user.picture, + email: user.email, + }; + } else { + throw new Error( + `Server responded with non-success code ${ + response.status + } ${await response.text()}` + ); + } + } catch (e) { + throw new HttpException( + `Failed to get user information, err: ${(e as Error).stack}`, + HttpStatus.BAD_REQUEST + ); + } + } +} diff --git a/packages/backend/server/src/plugins/oauth/providers/index.ts b/packages/backend/server/src/plugins/oauth/providers/index.ts new file mode 100644 index 0000000000..7af95d12d8 --- /dev/null +++ b/packages/backend/server/src/plugins/oauth/providers/index.ts @@ -0,0 +1,4 @@ +import { GithubOAuthProvider } from './github'; +import { GoogleOAuthProvider } from './google'; + +export const OAuthProviders = [GoogleOAuthProvider, GithubOAuthProvider]; diff --git a/packages/backend/server/src/plugins/oauth/register.ts b/packages/backend/server/src/plugins/oauth/register.ts new file mode 100644 index 0000000000..d6c53c57d2 --- /dev/null +++ b/packages/backend/server/src/plugins/oauth/register.ts @@ -0,0 +1,58 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; + +import { Config } from '../../fundamentals'; +import { OAuthProvider } from './providers/def'; +import { OAuthProviderName } from './types'; + +const PROVIDERS: Map = new Map(); + +export function registerOAuthProvider( + name: OAuthProviderName, + provider: OAuthProvider +) { + PROVIDERS.set(name, provider); +} + +@Injectable() +export class OAuthProviderFactory { + get providers() { + return PROVIDERS.keys(); + } + + get(name: OAuthProviderName): OAuthProvider | undefined { + return PROVIDERS.get(name); + } +} + +export abstract class AutoRegisteredOAuthProvider + extends OAuthProvider + implements OnModuleInit +{ + protected abstract AFFiNEConfig: Config; + + get optionalConfig() { + return this.AFFiNEConfig.plugins.oauth?.providers?.[this.provider]; + } + + get config() { + const config = this.optionalConfig; + + if (!config) { + throw new Error( + `OAuthProvider Config should not be used before registered` + ); + } + + return config; + } + + onModuleInit() { + const config = this.optionalConfig; + if (config && config.clientId && config.clientSecret) { + registerOAuthProvider(this.provider, this); + new Logger(`OAuthProvider:${this.provider}`).log( + 'OAuth provider registered.' + ); + } + } +} diff --git a/packages/backend/server/src/plugins/oauth/resolver.ts b/packages/backend/server/src/plugins/oauth/resolver.ts new file mode 100644 index 0000000000..467cc90360 --- /dev/null +++ b/packages/backend/server/src/plugins/oauth/resolver.ts @@ -0,0 +1,17 @@ +import { registerEnumType, ResolveField, Resolver } from '@nestjs/graphql'; + +import { ServerConfigType } from '../../core/config'; +import { OAuthProviderFactory } from './register'; +import { OAuthProviderName } from './types'; + +registerEnumType(OAuthProviderName, { name: 'OAuthProviderType' }); + +@Resolver(() => ServerConfigType) +export class OAuthResolver { + constructor(private readonly factory: OAuthProviderFactory) {} + + @ResolveField(() => [OAuthProviderName]) + oauthProviders() { + return this.factory.providers; + } +} diff --git a/packages/backend/server/src/plugins/oauth/service.ts b/packages/backend/server/src/plugins/oauth/service.ts new file mode 100644 index 0000000000..d05dc623df --- /dev/null +++ b/packages/backend/server/src/plugins/oauth/service.ts @@ -0,0 +1,39 @@ +import { randomUUID } from 'node:crypto'; + +import { Injectable } from '@nestjs/common'; + +import { SessionCache } from '../../fundamentals'; +import { OAuthProviderFactory } from './register'; +import { OAuthProviderName } from './types'; + +const OAUTH_STATE_KEY = 'OAUTH_STATE'; + +interface OAuthState { + redirectUri: string; + provider: OAuthProviderName; +} + +@Injectable() +export class OAuthService { + constructor( + private readonly providerFactory: OAuthProviderFactory, + private readonly cache: SessionCache + ) {} + + async saveOAuthState(state: OAuthState) { + const token = randomUUID(); + await this.cache.set(`${OAUTH_STATE_KEY}:${token}`, state, { + ttl: 3600 * 3 * 1000 /* 3 hours */, + }); + + return token; + } + + async getOAuthState(token: string) { + return this.cache.get(`${OAUTH_STATE_KEY}:${token}`); + } + + availableOAuthProviders() { + return this.providerFactory.providers; + } +} diff --git a/packages/backend/server/src/plugins/oauth/types.ts b/packages/backend/server/src/plugins/oauth/types.ts new file mode 100644 index 0000000000..6d66a264c3 --- /dev/null +++ b/packages/backend/server/src/plugins/oauth/types.ts @@ -0,0 +1,15 @@ +export interface OAuthProviderConfig { + clientId: string; + clientSecret: string; + args?: Record; +} + +export enum OAuthProviderName { + Google = 'google', + GitHub = 'github', +} + +export interface OAuthConfig { + enabled: boolean; + providers: Partial<{ [key in OAuthProviderName]: OAuthProviderConfig }>; +} diff --git a/packages/backend/server/src/plugins/payment/index.ts b/packages/backend/server/src/plugins/payment/index.ts index 9a4e33578f..975582a879 100644 --- a/packages/backend/server/src/plugins/payment/index.ts +++ b/packages/backend/server/src/plugins/payment/index.ts @@ -1,13 +1,14 @@ import { ServerFeature } from '../../core/config'; import { FeatureModule } from '../../core/features'; -import { OptionalModule } from '../../fundamentals'; +import { Plugin } from '../registry'; import { SubscriptionResolver, UserSubscriptionResolver } from './resolver'; import { ScheduleManager } from './schedule'; import { SubscriptionService } from './service'; import { StripeProvider } from './stripe'; import { StripeWebhook } from './webhook'; -@OptionalModule({ +@Plugin({ + name: 'payment', imports: [FeatureModule], providers: [ ScheduleManager, diff --git a/packages/backend/server/src/plugins/payment/resolver.ts b/packages/backend/server/src/plugins/payment/resolver.ts index e171b7a998..e3f5462d99 100644 --- a/packages/backend/server/src/plugins/payment/resolver.ts +++ b/packages/backend/server/src/plugins/payment/resolver.ts @@ -21,8 +21,8 @@ import type { User, UserInvoice, UserSubscription } from '@prisma/client'; import { PrismaClient } from '@prisma/client'; import { groupBy } from 'lodash-es'; -import { Auth, CurrentUser, Public } from '../../core/auth'; -import { UserType } from '../../core/users'; +import { CurrentUser, Public } from '../../core/auth'; +import { UserType } from '../../core/user'; import { Config } from '../../fundamentals'; import { decodeLookupKey, SubscriptionService } from './service'; import { @@ -155,7 +155,6 @@ class CreateCheckoutSessionInput { idempotencyKey!: string; } -@Auth() @Resolver(() => UserSubscriptionType) export class SubscriptionResolver { constructor( @@ -217,7 +216,7 @@ export class SubscriptionResolver { description: 'Create a subscription checkout link of stripe', }) async checkout( - @CurrentUser() user: User, + @CurrentUser() user: CurrentUser, @Args({ name: 'recurring', type: () => SubscriptionRecurring }) recurring: SubscriptionRecurring, @Args('idempotencyKey') idempotencyKey: string @@ -241,7 +240,7 @@ export class SubscriptionResolver { description: 'Create a subscription checkout link of stripe', }) async createCheckoutSession( - @CurrentUser() user: User, + @CurrentUser() user: CurrentUser, @Args({ name: 'input', type: () => CreateCheckoutSessionInput }) input: CreateCheckoutSessionInput ) { @@ -265,13 +264,13 @@ export class SubscriptionResolver { @Mutation(() => String, { description: 'Create a stripe customer portal to manage payment methods', }) - async createCustomerPortal(@CurrentUser() user: User) { + async createCustomerPortal(@CurrentUser() user: CurrentUser) { return this.service.createCustomerPortal(user.id); } @Mutation(() => UserSubscriptionType) async cancelSubscription( - @CurrentUser() user: User, + @CurrentUser() user: CurrentUser, @Args('idempotencyKey') idempotencyKey: string ) { return this.service.cancelSubscription(idempotencyKey, user.id); @@ -279,7 +278,7 @@ export class SubscriptionResolver { @Mutation(() => UserSubscriptionType) async resumeSubscription( - @CurrentUser() user: User, + @CurrentUser() user: CurrentUser, @Args('idempotencyKey') idempotencyKey: string ) { return this.service.resumeCanceledSubscription(idempotencyKey, user.id); @@ -287,7 +286,7 @@ export class SubscriptionResolver { @Mutation(() => UserSubscriptionType) async updateSubscriptionRecurring( - @CurrentUser() user: User, + @CurrentUser() user: CurrentUser, @Args({ name: 'recurring', type: () => SubscriptionRecurring }) recurring: SubscriptionRecurring, @Args('idempotencyKey') idempotencyKey: string diff --git a/packages/backend/server/src/plugins/payment/service.ts b/packages/backend/server/src/plugins/payment/service.ts index 30a1ffb3f8..a23fe51c8d 100644 --- a/packages/backend/server/src/plugins/payment/service.ts +++ b/packages/backend/server/src/plugins/payment/service.ts @@ -10,6 +10,7 @@ import type { import { PrismaClient } from '@prisma/client'; import Stripe from 'stripe'; +import { CurrentUser } from '../../core/auth'; import { FeatureManagementService } from '../../core/features'; import { EventEmitter } from '../../fundamentals'; import { ScheduleManager } from './schedule'; @@ -75,7 +76,7 @@ export class SubscriptionService { redirectUrl, idempotencyKey, }: { - user: User; + user: CurrentUser; recurring: SubscriptionRecurring; plan: SubscriptionPlan; promotionCode?: string | null; @@ -549,7 +550,7 @@ export class SubscriptionService { private async getOrCreateCustomer( idempotencyKey: string, - user: User + user: CurrentUser ): Promise { const customer = await this.db.userStripeCustomer.findUnique({ where: { @@ -649,7 +650,7 @@ export class SubscriptionService { } private async getAvailableCoupon( - user: User, + user: CurrentUser, couponType: CouponType ): Promise { const earlyAccess = await this.features.isEarlyAccessUser(user.email); diff --git a/packages/backend/server/src/plugins/redis/index.ts b/packages/backend/server/src/plugins/redis/index.ts index 46b44fe7fd..58d82c4642 100644 --- a/packages/backend/server/src/plugins/redis/index.ts +++ b/packages/backend/server/src/plugins/redis/index.ts @@ -2,9 +2,10 @@ import { Global, Provider, Type } from '@nestjs/common'; import { Redis, type RedisOptions } from 'ioredis'; import { ThrottlerStorageRedisService } from 'nestjs-throttler-storage-redis'; -import { Cache, OptionalModule, SessionCache } from '../../fundamentals'; +import { Cache, SessionCache } from '../../fundamentals'; import { ThrottlerStorage } from '../../fundamentals/throttler'; import { SocketIoAdapterImpl } from '../../fundamentals/websocket'; +import { Plugin } from '../registry'; import { RedisCache } from './cache'; import { CacheRedis, @@ -47,7 +48,8 @@ const socketIoRedisAdapterProvider: Provider = { }; @Global() -@OptionalModule({ +@Plugin({ + name: 'redis', providers: [CacheRedis, SessionRedis, ThrottlerRedis, SocketIoRedis], overrides: [ cacheProvider, diff --git a/packages/backend/server/src/plugins/registry.ts b/packages/backend/server/src/plugins/registry.ts new file mode 100644 index 0000000000..2eea3728af --- /dev/null +++ b/packages/backend/server/src/plugins/registry.ts @@ -0,0 +1,22 @@ +import { omit } from 'lodash-es'; + +import { AvailablePlugins } from '../fundamentals/config'; +import { OptionalModule, OptionalModuleMetadata } from '../fundamentals/nestjs'; + +export const REGISTERED_PLUGINS = new Map(); + +function register(plugin: AvailablePlugins, module: AFFiNEModule) { + REGISTERED_PLUGINS.set(plugin, module); +} + +interface PluginModuleMetadata extends OptionalModuleMetadata { + name: AvailablePlugins; +} + +export const Plugin = (options: PluginModuleMetadata) => { + return (target: any) => { + register(options.name, target); + + return OptionalModule(omit(options, 'name'))(target); + }; +}; diff --git a/packages/backend/server/src/plugins/storage/index.ts b/packages/backend/server/src/plugins/storage/index.ts index 914b68a5b0..7128f79126 100644 --- a/packages/backend/server/src/plugins/storage/index.ts +++ b/packages/backend/server/src/plugins/storage/index.ts @@ -1,5 +1,5 @@ -import { OptionalModule } from '../../fundamentals'; import { registerStorageProvider } from '../../fundamentals/storage'; +import { Plugin } from '../registry'; import { R2StorageProvider } from './providers/r2'; import { S3StorageProvider } from './providers/s3'; @@ -18,7 +18,8 @@ registerStorageProvider('aws-s3', (config, bucket) => { return new S3StorageProvider(config.plugins['aws-s3'], bucket); }); -@OptionalModule({ +@Plugin({ + name: 'cloudflare-r2', requires: [ 'plugins.cloudflare-r2.accountId', 'plugins.cloudflare-r2.credentials.accessKeyId', @@ -28,7 +29,8 @@ registerStorageProvider('aws-s3', (config, bucket) => { }) export class CloudflareR2Module {} -@OptionalModule({ +@Plugin({ + name: 'aws-s3', requires: [ 'plugins.aws-s3.credentials.accessKeyId', 'plugins.aws-s3.credentials.secretAccessKey', diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index 14a1d27036..cfb163f47f 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -67,14 +67,14 @@ type InviteUserType { """User avatar url""" avatarUrl: String - """User created date""" - createdAt: DateTime + """User email verified""" + createdAt: DateTime @deprecated(reason: "useless") """User email""" email: String """User email verified""" - emailVerified: DateTime + emailVerified: Boolean """User password has been set""" hasPassword: Boolean @@ -111,7 +111,7 @@ type Mutation { addToEarlyAccess(email: String!): Int! addWorkspaceFeature(feature: FeatureType!, workspaceId: String!): Int! cancelSubscription(idempotencyKey: String!): UserSubscription! - changeEmail(token: String!): UserType! + changeEmail(email: String!, token: String!): UserType! changePassword(newPassword: String!, token: String!): UserType! """Create a subscription checkout link of stripe""" @@ -141,15 +141,17 @@ type Mutation { revoke(userId: String!, workspaceId: String!): Boolean! revokePage(pageId: String!, workspaceId: String!): Boolean! @deprecated(reason: "use revokePublicPage") revokePublicPage(pageId: String!, workspaceId: String!): WorkspacePage! - sendChangeEmail(callbackUrl: String!, email: String!): Boolean! - sendChangePasswordEmail(callbackUrl: String!, email: String!): Boolean! - sendSetPasswordEmail(callbackUrl: String!, email: String!): Boolean! + sendChangeEmail(callbackUrl: String!, email: String): Boolean! + sendChangePasswordEmail(callbackUrl: String!, email: String): Boolean! + sendSetPasswordEmail(callbackUrl: String!, email: String): Boolean! sendVerifyChangeEmail(callbackUrl: String!, email: String!, token: String!): Boolean! + sendVerifyEmail(callbackUrl: String!): Boolean! setBlob(blob: Upload!, workspaceId: String!): String! setWorkspaceExperimentalFeature(enable: Boolean!, feature: FeatureType!, workspaceId: String!): Boolean! sharePage(pageId: String!, workspaceId: String!): Boolean! @deprecated(reason: "renamed to publicPage") signIn(email: String!, password: String!): UserType! signUp(email: String!, name: String!, password: String!): UserType! + updateProfile(input: UpdateUserInput!): UserType! updateSubscriptionRecurring(idempotencyKey: String!, recurring: SubscriptionRecurring!): UserSubscription! """Update workspace""" @@ -157,6 +159,12 @@ type Mutation { """Upload user avatar""" uploadAvatar(avatar: Upload!): UserType! + verifyEmail(token: String!): Boolean! +} + +enum OAuthProviderType { + GitHub + Google } """User permission in workspace""" @@ -239,6 +247,7 @@ type ServerConfigType { """server identical name could be shown as badge on user interface""" name: String! + oauthProviders: [OAuthProviderType!]! """server type""" type: ServerDeploymentType! @@ -253,6 +262,7 @@ enum ServerDeploymentType { } enum ServerFeature { + OAuth Payment } @@ -288,10 +298,9 @@ enum SubscriptionStatus { Unpaid } -type TokenType { - refresh: String! - sessionToken: String - token: String! +input UpdateUserInput { + """User name""" + name: String } input UpdateWorkspaceInput { @@ -356,14 +365,14 @@ type UserType { """User avatar url""" avatarUrl: String - """User created date""" - createdAt: DateTime + """User email verified""" + createdAt: DateTime @deprecated(reason: "useless") """User email""" email: String! """User email verified""" - emailVerified: DateTime + emailVerified: Boolean! """User password has been set""" hasPassword: Boolean @@ -377,7 +386,7 @@ type UserType { name: String! quota: UserQuota subscription: UserSubscription - token: TokenType! + token: tokenType! @deprecated(reason: "use [/api/auth/authorize]") } type WorkspaceBlobSizes { @@ -432,4 +441,10 @@ type WorkspaceType { """Shared pages of workspace""" sharedPages: [String!]! @deprecated(reason: "use WorkspaceType.publicPages") +} + +type tokenType { + refresh: String! + sessionToken: String + token: String! } \ No newline at end of file diff --git a/packages/backend/server/tests/app.e2e.ts b/packages/backend/server/tests/app.e2e.ts index 6fd112927c..111d661324 100644 --- a/packages/backend/server/tests/app.e2e.ts +++ b/packages/backend/server/tests/app.e2e.ts @@ -1,16 +1,8 @@ -import { ok } from 'node:assert'; -import { randomUUID } from 'node:crypto'; - -import { Transformer } from '@napi-rs/image'; import type { INestApplication } from '@nestjs/common'; -import { hashSync } from '@node-rs/argon2'; -import { PrismaClient, type User } from '@prisma/client'; import ava, { type TestFn } from 'ava'; -import type { Express } from 'express'; import request from 'supertest'; import { AppModule } from '../src/app.module'; -import { FeatureManagementService } from '../src/core/features'; import { createTestingApp } from './utils'; const gql = '/graphql'; @@ -19,43 +11,9 @@ const test = ava as TestFn<{ app: INestApplication; }>; -class FakePrisma { - fakeUser: User = { - id: randomUUID(), - name: 'Alex Yang', - avatarUrl: '', - email: 'alex.yang@example.org', - password: hashSync('123456'), - emailVerified: new Date(), - createdAt: new Date(), - }; - get user() { - // eslint-disable-next-line @typescript-eslint/no-this-alias - const prisma = this; - return { - async findFirst() { - return prisma.fakeUser; - }, - async findUnique() { - return this.findFirst(); - }, - async update() { - return this.findFirst(); - }, - }; - } -} - test.beforeEach(async t => { const { app } = await createTestingApp({ imports: [AppModule], - tapModule(builder) { - builder - .overrideProvider(PrismaClient) - .useClass(FakePrisma) - .overrideProvider(FeatureManagementService) - .useValue({ canEarlyAccess: () => true }); - }, }); t.context.app = app; @@ -66,7 +24,6 @@ test.afterEach.always(async t => { }); test('should init app', async t => { - t.is(typeof t.context.app, 'object'); await request(t.context.app.getHttpServer()) .post(gql) .send({ @@ -78,130 +35,22 @@ test('should init app', async t => { }) .expect(400); - const { token } = await createToken(t.context.app); - - await request(t.context.app.getHttpServer()) + const response = await request(t.context.app.getHttpServer()) .post(gql) - .auth(token, { type: 'bearer' }) .send({ - query: ` - query { - __typename - } - `, - }) - .expect(200) - .expect(res => { - t.is(res.body.data.__typename, 'Query'); - }); -}); - -test('should find default user', async t => { - const { token } = await createToken(t.context.app); - await request(t.context.app.getHttpServer()) - .post(gql) - .auth(token, { type: 'bearer' }) - .send({ - query: ` - query { - user(email: "alex.yang@example.org") { - ... on UserType { - email - } - ... on LimitedUserType { - email - } + query: `query { + serverConfig { + name + version + type + features } - } - `, + }`, }) - .expect(200) - .expect(res => { - t.is(res.body.data.user.email, 'alex.yang@example.org'); - }); + .expect(200); + + const config = response.body.data.serverConfig; + + t.is(config.type, 'Affine'); + t.true(Array.isArray(config.features)); }); - -test('should be able to upload avatar and remove it', async t => { - const { token, id } = await createToken(t.context.app); - const png = await Transformer.fromRgbaPixels( - Buffer.alloc(400 * 400 * 4).fill(255), - 400, - 400 - ).png(); - - await request(t.context.app.getHttpServer()) - .post(gql) - .auth(token, { type: 'bearer' }) - .field( - 'operations', - JSON.stringify({ - name: 'uploadAvatar', - query: `mutation uploadAvatar($avatar: Upload!) { - uploadAvatar(avatar: $avatar) { - id - name - avatarUrl - email - } - } - `, - variables: { id, avatar: null }, - }) - ) - .field('map', JSON.stringify({ '0': ['variables.avatar'] })) - .attach('0', png, 'avatar.png') - .expect(200) - .expect(res => { - t.is(res.body.data.uploadAvatar.id, id); - }); - - await request(t.context.app.getHttpServer()) - .post(gql) - .auth(token, { type: 'bearer' }) - .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) - .send({ - query: ` - mutation removeAvatar { - removeAvatar { - success - } - } - `, - }) - .expect(200) - .expect(res => { - t.is(res.body.data.removeAvatar.success, true); - }); -}); - -async function createToken(app: INestApplication): Promise<{ - id: string; - token: string; -}> { - let token; - let id; - await request(app.getHttpServer()) - .post(gql) - .send({ - query: ` - mutation { - signIn(email: "alex.yang@example.org", password: "123456") { - id - token { - token - } - } - } - `, - }) - .expect(200) - .expect(res => { - id = res.body.data.signIn.id; - ok( - typeof res.body.data.signIn.token.token === 'string', - 'res.body.data.signIn.token.token is not a string' - ); - token = res.body.data.signIn.token.token; - }); - return { token: token!, id: id! }; -} diff --git a/packages/backend/server/tests/auth.e2e.ts b/packages/backend/server/tests/auth.e2e.ts index 4300928b6a..a6b4db7c29 100644 --- a/packages/backend/server/tests/auth.e2e.ts +++ b/packages/backend/server/tests/auth.e2e.ts @@ -39,7 +39,7 @@ test('change email', async t => { if (mail.hasConfigured()) { const u1Email = 'u1@affine.pro'; const u2Email = 'u2@affine.pro'; - const tokenRegex = /token=3D([^"&\s]+)/; + const tokenRegex = /token=3D([^"&]+)/; const u1 = await signUp(app, 'u1', u1Email, '1'); @@ -57,7 +57,7 @@ test('change email', async t => { const changeTokenMatch = changeEmailContent.Content.Body.match(tokenRegex); const changeEmailToken = changeTokenMatch - ? decodeURIComponent(changeTokenMatch[1].replace(/=3D/g, '=')) + ? decodeURIComponent(changeTokenMatch[1].replace(/=\r\n/, '')) : null; t.not( @@ -85,7 +85,7 @@ test('change email', async t => { const verifyTokenMatch = verifyEmailContent.Content.Body.match(tokenRegex); const verifyEmailToken = verifyTokenMatch - ? decodeURIComponent(verifyTokenMatch[1].replace(/=3D/g, '=')) + ? decodeURIComponent(verifyTokenMatch[1].replace(/=\r\n/, '')) : null; t.not( @@ -94,7 +94,7 @@ test('change email', async t => { 'fail to get verify change email token from email content' ); - await changeEmail(app, u1.token.token, verifyEmailToken as string); + await changeEmail(app, u1.token.token, verifyEmailToken as string, u2Email); const afterNotificationMailCount = await getCurrentMailMessageCount(); diff --git a/packages/backend/server/tests/auth.spec.ts b/packages/backend/server/tests/auth.spec.ts deleted file mode 100644 index f5719ba5d2..0000000000 --- a/packages/backend/server/tests/auth.spec.ts +++ /dev/null @@ -1,172 +0,0 @@ -/// -import { TestingModule } from '@nestjs/testing'; -import test from 'ava'; - -import { AuthResolver } from '../src/core/auth/resolver'; -import { AuthService } from '../src/core/auth/service'; -import { ConfigModule } from '../src/fundamentals/config'; -import { - mintChallengeResponse, - verifyChallengeResponse, -} from '../src/fundamentals/storage'; -import { createTestingModule } from './utils'; - -let authService: AuthService; -let authResolver: AuthResolver; -let module: TestingModule; - -test.beforeEach(async () => { - module = await createTestingModule({ - imports: [ - ConfigModule.forRoot({ - auth: { - accessTokenExpiresIn: 1, - refreshTokenExpiresIn: 1, - leeway: 1, - }, - host: 'example.org', - https: true, - }), - ], - }); - - authService = module.get(AuthService); - authResolver = module.get(AuthResolver); -}); - -test.afterEach.always(async () => { - await module.close(); -}); - -test('should be able to register and signIn', async t => { - await authService.signUp('Alex Yang', 'alexyang@example.org', '123456'); - await authService.signIn('alexyang@example.org', '123456'); - t.pass(); -}); - -test('should be able to verify', async t => { - await authService.signUp('Alex Yang', 'alexyang@example.org', '123456'); - await authService.signIn('alexyang@example.org', '123456'); - const date = new Date(); - - const user = { - id: '1', - name: 'Alex Yang', - email: 'alexyang@example.org', - emailVerified: date, - createdAt: date, - avatarUrl: '', - }; - { - const token = await authService.sign(user); - const claim = await authService.verify(token); - t.is(claim.id, '1'); - t.is(claim.name, 'Alex Yang'); - t.is(claim.email, 'alexyang@example.org'); - t.is(claim.emailVerified?.toISOString(), date.toISOString()); - t.is(claim.createdAt.toISOString(), date.toISOString()); - } - { - const token = await authService.refresh(user); - const claim = await authService.verify(token); - t.is(claim.id, '1'); - t.is(claim.name, 'Alex Yang'); - t.is(claim.email, 'alexyang@example.org'); - t.is(claim.emailVerified?.toISOString(), date.toISOString()); - t.is(claim.createdAt.toISOString(), date.toISOString()); - } -}); - -test('should not be able to return token if user is invalid', async t => { - const date = new Date(); - const user = { - id: '1', - name: 'Alex Yang', - email: 'alexyang@example.org', - emailVerified: date, - createdAt: date, - avatarUrl: '', - }; - const anotherUser = { - id: '2', - name: 'Alex Yang 2', - email: 'alexyang@example.org', - emailVerified: date, - createdAt: date, - avatarUrl: '', - }; - await t.throwsAsync( - authResolver.token( - { - req: { - headers: { - referer: 'https://example.org', - host: 'example.org', - }, - } as any, - }, - user, - anotherUser - ), - { - message: 'Invalid user', - } - ); -}); - -test('should not return sessionToken if request headers is invalid', async t => { - const date = new Date(); - const user = { - id: '1', - name: 'Alex Yang', - email: 'alexyang@example.org', - emailVerified: date, - createdAt: date, - avatarUrl: '', - }; - const result = await authResolver.token( - { - req: { - headers: {}, - } as any, - }, - user, - user - ); - t.is(result.sessionToken, undefined); -}); - -test('should return valid sessionToken if request headers valid', async t => { - const date = new Date(); - const user = { - id: '1', - name: 'Alex Yang', - email: 'alexyang@example.org', - emailVerified: date, - createdAt: date, - avatarUrl: '', - }; - const result = await authResolver.token( - { - req: { - headers: { - referer: 'https://example.org/open-app/test', - host: 'example.org', - }, - cookies: { - 'next-auth.session-token': '123456', - }, - } as any, - }, - user, - user - ); - t.is(result.sessionToken, '123456'); -}); - -test('verify challenge', async t => { - const resource = 'xp8D3rcXV9bMhWrb6abxl'; - const response = await mintChallengeResponse(resource, 20); - const success = await verifyChallengeResponse(response, 20, resource); - t.true(success); -}); diff --git a/packages/backend/server/tests/feature.spec.ts b/packages/backend/server/tests/feature.spec.ts index 60be45e8bb..fc59bed01f 100644 --- a/packages/backend/server/tests/feature.spec.ts +++ b/packages/backend/server/tests/feature.spec.ts @@ -11,7 +11,7 @@ import { FeatureService, FeatureType, } from '../src/core/features'; -import { UserType } from '../src/core/users/types'; +import { UserType } from '../src/core/user/types'; import { WorkspaceResolver } from '../src/core/workspaces/resolvers'; import { Permission } from '../src/core/workspaces/types'; import { ConfigModule } from '../src/fundamentals/config'; @@ -54,11 +54,6 @@ test.beforeEach(async t => { const { app } = await createTestingApp({ imports: [ ConfigModule.forRoot({ - auth: { - accessTokenExpiresIn: 1, - refreshTokenExpiresIn: 1, - leeway: 1, - }, host: 'example.org', https: true, featureFlags: { diff --git a/packages/backend/server/tests/mailer.e2e.ts b/packages/backend/server/tests/mailer.e2e.ts index 8197c8befa..ecbf0c0e85 100644 --- a/packages/backend/server/tests/mailer.e2e.ts +++ b/packages/backend/server/tests/mailer.e2e.ts @@ -21,15 +21,7 @@ const test = ava as TestFn<{ test.beforeEach(async t => { t.context.module = await createTestingModule({ - imports: [ - ConfigModule.forRoot({ - auth: { - accessTokenExpiresIn: 1, - refreshTokenExpiresIn: 1, - leeway: 1, - }, - }), - ], + imports: [ConfigModule.forRoot({})], }); t.context.auth = t.context.module.get(AuthService); }); diff --git a/packages/backend/server/tests/session.spec.ts b/packages/backend/server/tests/session.spec.ts deleted file mode 100644 index 7e668317b5..0000000000 --- a/packages/backend/server/tests/session.spec.ts +++ /dev/null @@ -1,40 +0,0 @@ -/// - -import { TestingModule } from '@nestjs/testing'; -import ava, { type TestFn } from 'ava'; - -import { CacheModule } from '../src/fundamentals/cache'; -import { SessionModule, SessionService } from '../src/fundamentals/session'; -import { createTestingModule } from './utils'; - -const test = ava as TestFn<{ - session: SessionService; - module: TestingModule; -}>; - -test.beforeEach(async t => { - const module = await createTestingModule({ - imports: [CacheModule, SessionModule], - }); - const session = module.get(SessionService); - t.context.module = module; - t.context.session = session; -}); - -test.afterEach.always(async t => { - await t.context.module.close(); -}); - -test('should be able to set session', async t => { - const { session } = t.context; - await session.set('test', 'value'); - t.is(await session.get('test'), 'value'); -}); - -test('should be expired by ttl', async t => { - const { session } = t.context; - await session.set('test', 'value', 100); - t.is(await session.get('test'), 'value'); - await new Promise(resolve => setTimeout(resolve, 500)); - t.is(await session.get('test'), undefined); -}); diff --git a/packages/backend/server/tests/utils/user.ts b/packages/backend/server/tests/utils/user.ts index 3ead722ffd..f57dce3e39 100644 --- a/packages/backend/server/tests/utils/user.ts +++ b/packages/backend/server/tests/utils/user.ts @@ -1,16 +1,18 @@ import type { INestApplication } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; import request from 'supertest'; -import type { TokenType } from '../../src/core/auth'; -import type { UserType } from '../../src/core/users'; +import type { ClientTokenType } from '../../src/core/auth'; +import type { UserType } from '../../src/core/user'; import { gql } from './common'; export async function signUp( app: INestApplication, name: string, email: string, - password: string -): Promise { + password: string, + autoVerifyEmail = true +): Promise { const res = await request(app.getHttpServer()) .post(gql) .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) @@ -24,9 +26,23 @@ export async function signUp( `, }) .expect(200); + + if (autoVerifyEmail) { + await setEmailVerified(app, email); + } + return res.body.data.signUp; } +async function setEmailVerified(app: INestApplication, email: string) { + await app.get(PrismaClient).user.update({ + where: { email }, + data: { + emailVerifiedAt: new Date(), + }, + }); +} + export async function currentUser(app: INestApplication, token: string) { const res = await request(app.getHttpServer()) .post(gql) @@ -36,7 +52,7 @@ export async function currentUser(app: INestApplication, token: string) { query: ` query { currentUser { - id, name, email, emailVerified, avatarUrl, createdAt, hasPassword, + id, name, email, emailVerified, avatarUrl, hasPassword, token { token } } } @@ -94,8 +110,9 @@ export async function sendVerifyChangeEmail( export async function changeEmail( app: INestApplication, userToken: string, - token: string -): Promise { + token: string, + email: string +): Promise { const res = await request(app.getHttpServer()) .post(gql) .auth(userToken, { type: 'bearer' }) @@ -103,7 +120,7 @@ export async function changeEmail( .send({ query: ` mutation { - changeEmail(token: "${token}") { + changeEmail(token: "${token}", email: "${email}") { id name avatarUrl diff --git a/packages/backend/server/tests/utils/utils.ts b/packages/backend/server/tests/utils/utils.ts index 00cec6b3bd..8d3c32bf55 100644 --- a/packages/backend/server/tests/utils/utils.ts +++ b/packages/backend/server/tests/utils/utils.ts @@ -1,11 +1,13 @@ import { INestApplication, ModuleMetadata } from '@nestjs/common'; +import { APP_GUARD } from '@nestjs/core'; import { Query, Resolver } from '@nestjs/graphql'; import { Test, TestingModuleBuilder } from '@nestjs/testing'; import { PrismaClient } from '@prisma/client'; +import cookieParser from 'cookie-parser'; import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs'; import { AppModule, FunctionalityModules } from '../../src/app.module'; -import { AuthModule } from '../../src/core/auth'; +import { AuthGuard, AuthModule } from '../../src/core/auth'; import { UserFeaturesInit1698652531198 } from '../../src/data/migrations/1698652531198-user-features-init'; import { GqlModule } from '../../src/fundamentals/graphql'; @@ -78,7 +80,14 @@ export async function createTestingModule( const builder = Test.createTestingModule({ imports, - providers: [MockResolver, ...(moduleDef.providers ?? [])], + providers: [ + { + provide: APP_GUARD, + useClass: AuthGuard, + }, + MockResolver, + ...(moduleDef.providers ?? []), + ], controllers: moduleDef.controllers, }); @@ -113,6 +122,8 @@ export async function createTestingApp(moduleDef: TestingModuleMeatdata = {}) { }) ); + app.use(cookieParser()); + if (moduleDef.tapApp) { moduleDef.tapApp(app); } diff --git a/packages/frontend/component/src/components/auth-components/email-verified-email.tsx b/packages/frontend/component/src/components/auth-components/email-verified-email.tsx new file mode 100644 index 0000000000..bd49495333 --- /dev/null +++ b/packages/frontend/component/src/components/auth-components/email-verified-email.tsx @@ -0,0 +1,22 @@ +import { useAFFiNEI18N } from '@affine/i18n/hooks'; +import type { FC } from 'react'; + +import { Button } from '../../ui/button'; +import { AuthPageContainer } from './auth-page-container'; + +export const ConfirmChangeEmail: FC<{ + onOpenAffine: () => void; +}> = ({ onOpenAffine }) => { + const t = useAFFiNEI18N(); + + return ( + + + + ); +}; diff --git a/packages/frontend/component/src/components/auth-components/onboarding-page.tsx b/packages/frontend/component/src/components/auth-components/onboarding-page.tsx index 96fe6a8c29..05504f6a7c 100644 --- a/packages/frontend/component/src/components/auth-components/onboarding-page.tsx +++ b/packages/frontend/component/src/components/auth-components/onboarding-page.tsx @@ -37,7 +37,7 @@ function getCallbackUrl(location: Location) { try { const url = location.state?.callbackURL || - new URLSearchParams(location.search).get('callbackUrl'); + new URLSearchParams(location.search).get('redirect_uri'); if (typeof url === 'string' && url) { if (!url.startsWith('http:') && !url.startsWith('https:')) { return url; diff --git a/packages/frontend/component/src/components/auth-components/type.ts b/packages/frontend/component/src/components/auth-components/type.ts index d297b4bb4b..819bc607c0 100644 --- a/packages/frontend/component/src/components/auth-components/type.ts +++ b/packages/frontend/component/src/components/auth-components/type.ts @@ -3,4 +3,5 @@ export interface User { name: string; email: string; image?: string | null; + avatarUrl: string | null; } diff --git a/packages/frontend/component/src/components/not-found-page/not-found-page.tsx b/packages/frontend/component/src/components/not-found-page/not-found-page.tsx index aad290e815..6506b64be9 100644 --- a/packages/frontend/component/src/components/not-found-page/not-found-page.tsx +++ b/packages/frontend/component/src/components/not-found-page/not-found-page.tsx @@ -4,6 +4,7 @@ import { SignOutIcon } from '@blocksuite/icons'; import { Avatar } from '../../ui/avatar'; import { Button, IconButton } from '../../ui/button'; import { Tooltip } from '../../ui/tooltip'; +import type { User } from '../auth-components'; import { NotFoundPattern } from './not-found-pattern'; import { largeButtonEffect, @@ -12,11 +13,7 @@ import { } from './styles.css'; export interface NotFoundPageProps { - user: { - name: string; - email: string; - avatar: string; - } | null; + user?: User | null; onBack: () => void; onSignOut: () => void; } @@ -47,7 +44,7 @@ export const NotFoundPage = ({ {user ? (

- + {user.email} diff --git a/packages/frontend/core/.webpack/config.ts b/packages/frontend/core/.webpack/config.ts index af384252a6..4432eb69b5 100644 --- a/packages/frontend/core/.webpack/config.ts +++ b/packages/frontend/core/.webpack/config.ts @@ -384,6 +384,7 @@ export const createConfiguration: ( { context: '/api', target: 'http://localhost:3010' }, { context: '/socket.io', target: 'http://localhost:3010', ws: true }, { context: '/graphql', target: 'http://localhost:3010' }, + { context: '/oauth', target: 'http://localhost:3010' }, ], } as DevServerConfiguration, } satisfies webpack.Configuration; diff --git a/packages/frontend/core/package.json b/packages/frontend/core/package.json index 9ab8811791..9281dfd823 100644 --- a/packages/frontend/core/package.json +++ b/packages/frontend/core/package.json @@ -78,7 +78,6 @@ "lottie-web": "^5.12.2", "mini-css-extract-plugin": "^2.8.0", "nanoid": "^5.0.6", - "next-auth": "^4.24.5", "next-themes": "^0.2.1", "postcss-loader": "^8.1.0", "react": "18.2.0", diff --git a/packages/frontend/core/src/atoms/cloud-user.ts b/packages/frontend/core/src/atoms/cloud-user.ts deleted file mode 100644 index 2b43c0cf25..0000000000 --- a/packages/frontend/core/src/atoms/cloud-user.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { atom } from 'jotai'; -import type { SessionContextValue } from 'next-auth/react'; - -export const sessionAtom = atom | null>(null); diff --git a/packages/frontend/core/src/components/affine/auth/index.tsx b/packages/frontend/core/src/components/affine/auth/index.tsx index 7a6660e285..fe86068d03 100644 --- a/packages/frontend/core/src/components/affine/auth/index.tsx +++ b/packages/frontend/core/src/components/affine/auth/index.tsx @@ -24,7 +24,7 @@ export type AuthProps = { setAuthEmail: (state: AuthProps['email']) => void; setEmailType: (state: AuthProps['emailType']) => void; email: string; - emailType: 'setPassword' | 'changePassword' | 'changeEmail'; + emailType: 'setPassword' | 'changePassword' | 'changeEmail' | 'verifyEmail'; onSignedIn?: () => void; }; @@ -59,8 +59,10 @@ export const AuthModal: FC = ({ emailType, }) => { const onSignedIn = useCallback(() => { + setAuthState('signIn'); + setAuthEmail(''); setOpen(false); - }, [setOpen]); + }, [setAuthState, setAuthEmail, setOpen]); return ( diff --git a/packages/frontend/core/src/components/affine/auth/oauth.tsx b/packages/frontend/core/src/components/affine/auth/oauth.tsx new file mode 100644 index 0000000000..2747383a9d --- /dev/null +++ b/packages/frontend/core/src/components/affine/auth/oauth.tsx @@ -0,0 +1,66 @@ +import { Button } from '@affine/component/ui/button'; +import { + useOAuthProviders, + useServerFeatures, +} from '@affine/core/hooks/affine/use-server-config'; +import { OAuthProviderType } from '@affine/graphql'; +import { GithubIcon, GoogleDuotoneIcon } from '@blocksuite/icons'; +import { type ReactElement, useCallback } from 'react'; + +import { useAuth } from './use-auth'; + +const OAuthProviderMap: Record< + OAuthProviderType, + { + icon: ReactElement; + } +> = { + [OAuthProviderType.Google]: { + icon: , + }, + + [OAuthProviderType.GitHub]: { + icon: , + }, +}; + +export function OAuth() { + const { oauth } = useServerFeatures(); + + if (!oauth) { + return null; + } + + return ; +} + +function OAuthProviders() { + const providers = useOAuthProviders(); + + return providers.map(provider => ( + + )); +} + +function OAuthProvider({ provider }: { provider: OAuthProviderType }) { + const { icon } = OAuthProviderMap[provider]; + const { oauthSignIn } = useAuth(); + + const onClick = useCallback(() => { + oauthSignIn(provider); + }, [provider, oauthSignIn]); + + return ( + + ); +} diff --git a/packages/frontend/core/src/components/affine/auth/send-email.tsx b/packages/frontend/core/src/components/affine/auth/send-email.tsx index 91b3a6330b..8e767c22dc 100644 --- a/packages/frontend/core/src/components/affine/auth/send-email.tsx +++ b/packages/frontend/core/src/components/affine/auth/send-email.tsx @@ -12,6 +12,7 @@ import { sendChangeEmailMutation, sendChangePasswordEmailMutation, sendSetPasswordEmailMutation, + sendVerifyEmailMutation, } from '@affine/graphql'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { useSetAtom } from 'jotai/react'; @@ -29,7 +30,9 @@ const useEmailTitle = (emailType: AuthPanelProps['emailType']) => { case 'changePassword': return t['com.affine.auth.reset.password'](); case 'changeEmail': - return t['com.affine.settings.email.action'](); + return t['com.affine.settings.email.action.change'](); + case 'verifyEmail': + return t['com.affine.settings.email.action.verify'](); } }; const useContent = (emailType: AuthPanelProps['emailType'], email: string) => { @@ -41,7 +44,8 @@ const useContent = (emailType: AuthPanelProps['emailType'], email: string) => { case 'changePassword': return t['com.affine.auth.reset.password.message'](); case 'changeEmail': - return t['com.affine.auth.change.email.message']({ + case 'verifyEmail': + return t['com.affine.auth.verify.email.message']({ email, }); } @@ -56,7 +60,8 @@ const useNotificationHint = (emailType: AuthPanelProps['emailType']) => { case 'changePassword': return t['com.affine.auth.sent.change.password.hint'](); case 'changeEmail': - return t['com.affine.auth.sent.change.email.hint'](); + case 'verifyEmail': + return t['com.affine.auth.sent.verify.email.hint'](); } }; const useButtonContent = (emailType: AuthPanelProps['emailType']) => { @@ -68,7 +73,8 @@ const useButtonContent = (emailType: AuthPanelProps['emailType']) => { case 'changePassword': return t['com.affine.auth.send.reset.password.link'](); case 'changeEmail': - return t['com.affine.auth.send.change.email.link'](); + case 'verifyEmail': + return t['com.affine.auth.send.verify.email.hint'](); } }; @@ -87,12 +93,17 @@ const useSendEmail = (emailType: AuthPanelProps['emailType']) => { useMutation({ mutation: sendChangeEmailMutation, }); + const { trigger: sendVerifyEmail, isMutating: isVerifyEmailMutation } = + useMutation({ + mutation: sendVerifyEmailMutation, + }); return { loading: isChangePasswordMutating || isSetPasswordMutating || - isChangeEmailMutating, + isChangeEmailMutating || + isVerifyEmailMutation, sendEmail: useCallback( (email: string) => { let trigger: (args: { @@ -113,6 +124,10 @@ const useSendEmail = (emailType: AuthPanelProps['emailType']) => { trigger = sendChangeEmail; callbackUrl = 'changeEmail'; break; + case 'verifyEmail': + trigger = sendVerifyEmail; + callbackUrl = 'verify-email'; + break; } // TODO: add error handler return trigger({ @@ -127,6 +142,7 @@ const useSendEmail = (emailType: AuthPanelProps['emailType']) => { sendChangeEmail, sendChangePasswordEmail, sendSetPasswordEmail, + sendVerifyEmail, ] ), }; diff --git a/packages/frontend/core/src/components/affine/auth/sign-in-with-password.tsx b/packages/frontend/core/src/components/affine/auth/sign-in-with-password.tsx index bb649bd9d8..e5e6ff8515 100644 --- a/packages/frontend/core/src/components/affine/auth/sign-in-with-password.tsx +++ b/packages/frontend/core/src/components/affine/auth/sign-in-with-password.tsx @@ -5,10 +5,9 @@ import { ModalHeader, } from '@affine/component/auth-components'; import { Button } from '@affine/component/ui/button'; +import { useSession } from '@affine/core/hooks/affine/use-current-user'; import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; -// eslint-disable-next-line @typescript-eslint/no-restricted-imports -import { useSession } from 'next-auth/react'; import type { FC } from 'react'; import { useCallback, useState } from 'react'; @@ -25,7 +24,7 @@ export const SignInWithPassword: FC = ({ onSignedIn, }) => { const t = useAFFiNEI18N(); - const { update } = useSession(); + const { reload } = useSession(); const [password, setPassword] = useState(''); const [passwordError, setPasswordError] = useState(false); @@ -39,7 +38,6 @@ export const SignInWithPassword: FC = ({ const onSignIn = useAsyncCallback(async () => { const res = await signInCloud('credentials', { - redirect: false, email, password, }).catch(console.error); @@ -48,9 +46,9 @@ export const SignInWithPassword: FC = ({ return setPasswordError(true); } - await update(); + await reload(); onSignedIn?.(); - }, [email, password, onSignedIn, update]); + }, [email, password, onSignedIn, reload]); const sendMagicLink = useAsyncCallback(async () => { if (allowSendEmail && verifyToken && !sendingEmail) { diff --git a/packages/frontend/core/src/components/affine/auth/sign-in.tsx b/packages/frontend/core/src/components/affine/auth/sign-in.tsx index b53bcecc81..0d1a7a6a2f 100644 --- a/packages/frontend/core/src/components/affine/auth/sign-in.tsx +++ b/packages/frontend/core/src/components/affine/auth/sign-in.tsx @@ -12,7 +12,7 @@ import { } from '@affine/graphql'; import { Trans } from '@affine/i18n'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import { ArrowDownBigIcon, GoogleDuotoneIcon } from '@blocksuite/icons'; +import { ArrowDownBigIcon } from '@blocksuite/icons'; import { type FC, useState } from 'react'; import { useCallback } from 'react'; @@ -20,6 +20,7 @@ import { useCurrentLoginStatus } from '../../../hooks/affine/use-current-login-s import { useMutation } from '../../../hooks/use-mutation'; import { emailRegex } from '../../../utils/email-regex'; import type { AuthPanelProps } from './index'; +import { OAuth } from './oauth'; import * as style from './style.css'; import { INTERNAL_BETA_URL, useAuth } from './use-auth'; import { Captcha, useCaptcha } from './use-captcha'; @@ -46,7 +47,6 @@ export const SignIn: FC = ({ allowSendEmail, signIn, signUp, - signInWithGoogle, } = useAuth(); const { trigger: verifyUser, isMutating } = useMutation({ @@ -59,6 +59,10 @@ export const SignIn: FC = ({ } const onContinue = useAsyncCallback(async () => { + if (!allowSendEmail) { + return; + } + if (!validateEmail(email)) { setIsValidEmail(false); return; @@ -99,13 +103,14 @@ export const SignIn: FC = ({ const res = await signUp(email, verifyToken, challenge); if (res?.status === 403 && res?.url === INTERNAL_BETA_URL) { return setAuthState('noAccess'); - } else if (!res || res.status >= 400 || res.error) { + } else if (!res || res.status >= 400) { return; } setAuthState('afterSignUpSendEmail'); } } }, [ + allowSendEmail, subscriptionData, challenge, email, @@ -124,20 +129,7 @@ export const SignIn: FC = ({ subTitle={t['com.affine.brand.affineCloud']()} /> - +
void ) { - if (res?.error) { + if (!res?.ok) { pushNotification({ title: 'Send email error', message: 'Please back to home and try again', @@ -64,8 +64,13 @@ export const useAuth = () => { const [authStore, setAuthStore] = useAtom(authStoreAtom); const startResendCountDown = useSetAtom(countDownAtom); - const signIn = useCallback( - async (email: string, verifyToken: string, challenge?: string) => { + const sendEmailMagicLink = useCallback( + async ( + signUp: boolean, + email: string, + verifyToken: string, + challenge?: string + ) => { setAuthStore(prev => { return { ...prev, @@ -76,18 +81,19 @@ export const useAuth = () => { const res = await signInCloud( 'email', { - email: email, - callbackUrl: subscriptionData - ? subscriptionData.getRedirectUrl(false) - : '/auth/signIn', - redirect: false, + email, }, - challenge - ? { - challenge, - token: verifyToken, - } - : { token: verifyToken } + { + ...(challenge + ? { + challenge, + token: verifyToken, + } + : { token: verifyToken }), + callbackUrl: subscriptionData + ? subscriptionData.getRedirectUrl(signUp) + : '/auth/signIn', + } ).catch(console.error); handleSendEmailError(res, pushNotification); @@ -107,47 +113,24 @@ export const useAuth = () => { const signUp = useCallback( async (email: string, verifyToken: string, challenge?: string) => { - setAuthStore(prev => { - return { - ...prev, - isMutating: true, - }; - }); - - const res = await signInCloud( - 'email', - { - email: email, - callbackUrl: subscriptionData - ? subscriptionData.getRedirectUrl(true) - : '/auth/signUp', - redirect: false, - }, - challenge - ? { - challenge, - token: verifyToken, - } - : { token: verifyToken } - ).catch(console.error); - - handleSendEmailError(res, pushNotification); - - setAuthStore({ - isMutating: false, - allowSendEmail: false, - resendCountDown: COUNT_DOWN_TIME, - }); - - startResendCountDown(); - - return res; + return sendEmailMagicLink(true, email, verifyToken, challenge).catch( + console.error + ); }, - [pushNotification, setAuthStore, startResendCountDown, subscriptionData] + [sendEmailMagicLink] ); - const signInWithGoogle = useCallback(() => { - signInCloud('google').catch(console.error); + const signIn = useCallback( + async (email: string, verifyToken: string, challenge?: string) => { + return sendEmailMagicLink(false, email, verifyToken, challenge).catch( + console.error + ); + }, + [sendEmailMagicLink] + ); + + const oauthSignIn = useCallback((provider: OAuthProviderType) => { + signInCloud(provider).catch(console.error); }, []); const resetCountDown = useCallback(() => { @@ -165,6 +148,6 @@ export const useAuth = () => { isMutating: authStore.isMutating, signUp, signIn, - signInWithGoogle, + oauthSignIn, }; }; diff --git a/packages/frontend/core/src/components/affine/awareness/index.tsx b/packages/frontend/core/src/components/affine/awareness/index.tsx index f736203343..ac46150ca8 100644 --- a/packages/frontend/core/src/components/affine/awareness/index.tsx +++ b/packages/frontend/core/src/components/affine/awareness/index.tsx @@ -3,21 +3,21 @@ import { useLiveData } from '@toeverything/infra/livedata'; import { Suspense, useEffect } from 'react'; import { useCurrentLoginStatus } from '../../../hooks/affine/use-current-login-status'; -import { useCurrentUser } from '../../../hooks/affine/use-current-user'; +import { useSession } from '../../../hooks/affine/use-current-user'; import { CurrentWorkspaceService } from '../../../modules/workspace/current-workspace'; const SyncAwarenessInnerLoggedIn = () => { - const currentUser = useCurrentUser(); + const { user } = useSession(); const currentWorkspace = useLiveData( useService(CurrentWorkspaceService).currentWorkspace ); useEffect(() => { - if (currentUser && currentWorkspace) { + if (user && currentWorkspace) { currentWorkspace.blockSuiteWorkspace.awarenessStore.awareness.setLocalStateField( 'user', { - name: currentUser.name, + name: user.name, // todo: add avatar? } ); @@ -30,7 +30,7 @@ const SyncAwarenessInnerLoggedIn = () => { }; } return; - }, [currentUser, currentWorkspace]); + }, [user, currentWorkspace]); return null; }; diff --git a/packages/frontend/core/src/components/affine/setting-modal/account-setting/index.tsx b/packages/frontend/core/src/components/affine/setting-modal/account-setting/index.tsx index 287042d2af..b6a23a2aaa 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/account-setting/index.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/account-setting/index.tsx @@ -13,6 +13,7 @@ import { allBlobSizesQuery, removeAvatarMutation, SubscriptionPlan, + updateUserProfileMutation, uploadAvatarMutation, } from '@affine/graphql'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; @@ -58,11 +59,10 @@ export const UserAvatar = () => { async (file: File) => { try { const reducedFile = await validateAndReduceImage(file); - await avatarTrigger({ + const data = await avatarTrigger({ avatar: reducedFile, // Pass the reducedFile directly to the avatarTrigger }); - // XXX: This is a hack to force the user to update, since next-auth can not only use update function without params - await user.update({ name: user.name }); + user.update({ avatarUrl: data.uploadAvatar.avatarUrl }); pushNotification({ title: 'Update user avatar success', type: 'success', @@ -82,8 +82,7 @@ export const UserAvatar = () => { async (e: MouseEvent) => { e.stopPropagation(); await removeAvatarTrigger(); - // XXX: This is a hack to force the user to update, since next-auth can not only use update function without params - user.update({ name: user.name }).catch(console.error); + user.update({ avatarUrl: null }); }, [removeAvatarTrigger, user] ); @@ -97,9 +96,9 @@ export const UserAvatar = () => { } - onRemove={user.image ? handleRemoveUserAvatar : undefined} + onRemove={user.avatarUrl ? handleRemoveUserAvatar : undefined} avatarTooltipOptions={{ content: t['Click to replace photo']() }} removeTooltipOptions={{ content: t['Remove photo']() }} data-testid="user-setting-avatar" @@ -115,14 +114,30 @@ export const AvatarAndName = () => { const t = useAFFiNEI18N(); const user = useCurrentUser(); const [input, setInput] = useState(user.name); + const pushNotification = useSetAtom(pushNotificationAtom); + const { trigger: updateProfile } = useMutation({ + mutation: updateUserProfileMutation, + }); const allowUpdate = !!input && input !== user.name; - const handleUpdateUserName = useCallback(() => { + const handleUpdateUserName = useAsyncCallback(async () => { if (!allowUpdate) { return; } - user.update({ name: input }).catch(console.error); - }, [allowUpdate, input, user]); + + try { + const data = await updateProfile({ + input: { name: input }, + }); + user.update({ name: data.updateProfile.name }); + } catch (e) { + pushNotification({ + title: 'Failed to update user name.', + message: String(e), + type: 'error', + }); + } + }, [allowUpdate, input, user, updateProfile, pushNotification]); return ( { openModal: true, state: 'sendEmail', email: user.email, - emailType: 'changeEmail', + emailType: user.emailVerified ? 'changeEmail' : 'verifyEmail', }); - }, [setAuthModal, user.email]); + }, [setAuthModal, user.email, user.emailVerified]); const onPasswordButtonClick = useCallback(() => { setAuthModal({ @@ -249,7 +264,9 @@ export const AccountSetting: FC = () => { - +
diff --git a/packages/frontend/core/src/components/cloud/share-header-right-item/user-avatar.tsx b/packages/frontend/core/src/components/cloud/share-header-right-item/user-avatar.tsx index b9ad390902..0b4b313b53 100644 --- a/packages/frontend/core/src/components/cloud/share-header-right-item/user-avatar.tsx +++ b/packages/frontend/core/src/components/cloud/share-header-right-item/user-avatar.tsx @@ -26,7 +26,7 @@ const UserInfo = () => { @@ -51,7 +51,7 @@ export const PublishPageUserAvatar = () => { const location = useLocation(); const handleSignOut = useAsyncCallback(async () => { - await signOutCloud({ callbackUrl: location.pathname }); + await signOutCloud(location.pathname); }, [location.pathname]); const menuItem = useMemo(() => { @@ -84,7 +84,7 @@ export const PublishPageUserAvatar = () => { }} >
- +
); diff --git a/packages/frontend/core/src/components/pure/footer/index.tsx b/packages/frontend/core/src/components/pure/footer/index.tsx index a8a0af8d0f..68ef7dcc72 100644 --- a/packages/frontend/core/src/components/pure/footer/index.tsx +++ b/packages/frontend/core/src/components/pure/footer/index.tsx @@ -25,7 +25,7 @@ const SignInButton = () => { { - signInCloud().catch(console.error); + signInCloud('email').catch(console.error); }, [])} >
diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/index.tsx b/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/index.tsx index 289dc641a8..216335fce4 100644 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/index.tsx +++ b/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/index.tsx @@ -1,5 +1,6 @@ import { Divider } from '@affine/component/ui/divider'; import { MenuItem } from '@affine/component/ui/menu'; +import { useSession } from '@affine/core/hooks/affine/use-current-user'; import { Unreachable } from '@affine/env/constant'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { Logo1Icon } from '@blocksuite/icons'; @@ -7,9 +8,7 @@ import { WorkspaceManager } from '@toeverything/infra'; import { useService } from '@toeverything/infra/di'; import { useLiveData } from '@toeverything/infra/livedata'; import { useSetAtom } from 'jotai'; -// eslint-disable-next-line @typescript-eslint/no-restricted-imports -import { useSession } from 'next-auth/react'; -import { useCallback, useEffect, useMemo } from 'react'; +import { useCallback, useEffect } from 'react'; import { authAtom, @@ -68,9 +67,9 @@ export const UserWithWorkspaceList = ({ }: { onEventEnd?: () => void; }) => { - const { data: session, status } = useSession(); + const { user, status } = useSession(); - const isAuthenticated = useMemo(() => status === 'authenticated', [status]); + const isAuthenticated = status === 'authenticated'; const setOpenCreateWorkspaceModal = useSetAtom(openCreateWorkspaceModalAtom); const setDisableCloudOpen = useSetAtom(openDisableCloudAlertModalAtom); @@ -124,7 +123,7 @@ export const UserWithWorkspaceList = ({
{isAuthenticated ? ( ) : ( diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/workspace-list/index.tsx b/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/workspace-list/index.tsx index 88b721533b..23b3a90f12 100644 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/workspace-list/index.tsx +++ b/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/workspace-list/index.tsx @@ -1,6 +1,7 @@ import { ScrollableContainer } from '@affine/component'; import { Divider } from '@affine/component/ui/divider'; import { WorkspaceList } from '@affine/component/workspace-list'; +import { useSession } from '@affine/core/hooks/affine/use-current-user'; import { useWorkspaceAvatar, useWorkspaceName, @@ -12,8 +13,6 @@ import { WorkspaceManager, type WorkspaceMetadata } from '@toeverything/infra'; import { useService } from '@toeverything/infra/di'; import { useLiveData } from '@toeverything/infra/livedata'; import { useSetAtom } from 'jotai'; -// eslint-disable-next-line @typescript-eslint/no-restricted-imports -import { useSession } from 'next-auth/react'; import { useCallback, useMemo } from 'react'; import { @@ -119,10 +118,9 @@ export const AFFiNEWorkspaceList = ({ const setOpenSettingModalAtom = useSetAtom(openSettingModalAtom); - // TODO: AFFiNE Cloud support const { status } = useSession(); - const isAuthenticated = useMemo(() => status === 'authenticated', [status]); + const isAuthenticated = status === 'authenticated'; const cloudWorkspaces = useMemo( () => diff --git a/packages/frontend/core/src/hooks/affine/use-current-login-status.ts b/packages/frontend/core/src/hooks/affine/use-current-login-status.ts index 4bd5ca2e0e..14ff11afa7 100644 --- a/packages/frontend/core/src/hooks/affine/use-current-login-status.ts +++ b/packages/frontend/core/src/hooks/affine/use-current-login-status.ts @@ -1,10 +1,6 @@ -// eslint-disable-next-line @typescript-eslint/no-restricted-imports -import { useSession } from 'next-auth/react'; +import { useSession } from './use-current-user'; -export function useCurrentLoginStatus(): - | 'authenticated' - | 'unauthenticated' - | 'loading' { +export function useCurrentLoginStatus() { const session = useSession(); return session.status; } diff --git a/packages/frontend/core/src/hooks/affine/use-current-user.ts b/packages/frontend/core/src/hooks/affine/use-current-user.ts index 35cbe9bbc3..d4329d9ba6 100644 --- a/packages/frontend/core/src/hooks/affine/use-current-user.ts +++ b/packages/frontend/core/src/hooks/affine/use-current-user.ts @@ -1,42 +1,83 @@ -import { type User } from '@affine/component/auth-components'; -import type { DefaultSession, Session } from 'next-auth'; -// eslint-disable-next-line @typescript-eslint/no-restricted-imports -import { getSession, useSession } from 'next-auth/react'; -import { useEffect, useMemo, useReducer } from 'react'; +import { DebugLogger } from '@affine/debug'; +import { getBaseUrl } from '@affine/graphql'; +import { useMemo, useReducer } from 'react'; +import useSWR from 'swr'; import { SessionFetchErrorRightAfterLoginOrSignUp } from '../../unexpected-application-state/errors'; +import { useAsyncCallback } from '../affine-async-hooks'; -export type CheckedUser = User & { +const logger = new DebugLogger('auth'); + +interface User { + id: string; + email: string; + name: string; hasPassword: boolean; - update: ReturnType['update']; + avatarUrl: string | null; + emailVerified: string | null; +} + +export interface Session { + user?: User | null; + status: 'authenticated' | 'unauthenticated' | 'loading'; + reload: () => Promise; +} + +export type CheckedUser = Session['user'] & { + update: (changes?: Partial) => void; }; -declare module 'next-auth' { - interface Session { - user: { - name: string; - email: string; - id: string; - hasPassword: boolean; - } & Omit, 'name' | 'email'>; +export async function getSession( + url: string = getBaseUrl() + '/api/auth/session' +) { + try { + const res = await fetch(url); + + if (res.ok) { + return (await res.json()) as { user?: User | null }; + } + + logger.error('Failed to fetch session', res.statusText); + return { user: null }; + } catch (e) { + logger.error('Failed to fetch session', e); + return { user: null }; } } +export function useSession(): Session { + const { data, mutate, isLoading } = useSWR('session', () => getSession()); + + return { + user: data?.user, + status: isLoading + ? 'loading' + : data?.user + ? 'authenticated' + : 'unauthenticated', + reload: async () => { + return mutate().then(e => { + console.error(e); + }); + }, + }; +} + type UpdateSessionAction = | { type: 'update'; - payload: Session; + payload?: Partial; } | { type: 'fetchError'; payload: null; }; -function updateSessionReducer(prevState: Session, action: UpdateSessionAction) { +function updateSessionReducer(prevState: User, action: UpdateSessionAction) { const { type, payload } = action; switch (type) { case 'update': - return payload; + return { ...prevState, ...payload }; case 'fetchError': return prevState; } @@ -49,11 +90,11 @@ function updateSessionReducer(prevState: Session, action: UpdateSessionAction) { * If network error or API response error, it will use the cached value. */ export function useCurrentUser(): CheckedUser { - const { data, update } = useSession(); + const session = useSession(); - const [session, dispatcher] = useReducer( + const [user, dispatcher] = useReducer( updateSessionReducer, - data, + session.user, firstSession => { if (!firstSession) { // barely possible. @@ -64,10 +105,10 @@ export function useCurrentUser(): CheckedUser { () => { getSession() .then(session => { - if (session) { + if (session.user) { dispatcher({ type: 'update', - payload: session, + payload: session.user, }); } }) @@ -77,35 +118,30 @@ export function useCurrentUser(): CheckedUser { } ); } + return firstSession; } ); - useEffect(() => { - if (data) { + const update = useAsyncCallback( + async (changes?: Partial) => { dispatcher({ type: 'update', - payload: data, + payload: changes, }); - } else { - dispatcher({ - type: 'fetchError', - payload: null, - }); - } - }, [data, update]); - const user = session.user; + await session.reload(); + }, + [dispatcher, session] + ); - return useMemo(() => { - return { - id: user.id, - name: user.name, - email: user.email, - image: user.image, - hasPassword: user?.hasPassword ?? false, + return useMemo( + () => ({ + ...user, update, - }; - // spread the user object to make sure the hook will not be re-rendered when user ref changed but the properties not. - }, [user.id, user.name, user.email, user.image, user.hasPassword, update]); + }), + // only list the things will change as deps + // eslint-disable-next-line react-hooks/exhaustive-deps + [user.id, user.avatarUrl, user.name, update] + ); } diff --git a/packages/frontend/core/src/hooks/affine/use-delete-collection-info.ts b/packages/frontend/core/src/hooks/affine/use-delete-collection-info.ts index 2a99a4a3cf..e1b3b6764d 100644 --- a/packages/frontend/core/src/hooks/affine/use-delete-collection-info.ts +++ b/packages/frontend/core/src/hooks/affine/use-delete-collection-info.ts @@ -1,11 +1,12 @@ -// eslint-disable-next-line @typescript-eslint/no-restricted-imports -import { useSession } from 'next-auth/react'; import { useMemo } from 'react'; +import { useSession } from './use-current-user'; + export const useDeleteCollectionInfo = () => { - const user = useSession().data?.user; + const { user } = useSession(); + return useMemo( - () => (user ? { userName: user.name ?? '', userId: user.id } : null), + () => (user ? { userName: user.name, userId: user.id } : null), [user] ); }; diff --git a/packages/frontend/core/src/hooks/affine/use-server-config.ts b/packages/frontend/core/src/hooks/affine/use-server-config.ts index ef0878d1de..f6cf86146e 100644 --- a/packages/frontend/core/src/hooks/affine/use-server-config.ts +++ b/packages/frontend/core/src/hooks/affine/use-server-config.ts @@ -1,5 +1,5 @@ import type { ServerFeature } from '@affine/graphql'; -import { serverConfigQuery } from '@affine/graphql'; +import { oauthProvidersQuery, serverConfigQuery } from '@affine/graphql'; import type { BareFetcher, Middleware } from 'swr'; import { useQueryImmutable } from '../use-query'; @@ -44,6 +44,21 @@ export const useServerFeatures = (): ServerFeatureRecord => { }, {} as ServerFeatureRecord); }; +export const useOAuthProviders = () => { + const { data, error } = useQueryImmutable( + { query: oauthProvidersQuery }, + { + use: [errorHandler], + } + ); + + if (error || !data) { + return []; + } + + return data.serverConfig.oauthProviders; +}; + export const useServerBaseUrl = () => { const config = useServerConfig(); diff --git a/packages/frontend/core/src/pages/404.tsx b/packages/frontend/core/src/pages/404.tsx index 280a33da82..8d112e5c92 100644 --- a/packages/frontend/core/src/pages/404.tsx +++ b/packages/frontend/core/src/pages/404.tsx @@ -1,7 +1,6 @@ import { NotFoundPage } from '@affine/component/not-found-page'; +import { useSession } from '@affine/core/hooks/affine/use-current-user'; import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks'; -// eslint-disable-next-line @typescript-eslint/no-restricted-imports -import { useSession } from 'next-auth/react'; import type { ReactElement } from 'react'; import { useCallback, useState } from 'react'; @@ -10,7 +9,7 @@ import { RouteLogic, useNavigateHelper } from '../hooks/use-navigate-helper'; import { signOutCloud } from '../utils/cloud-utils'; export const PageNotFound = (): ReactElement => { - const { data: session } = useSession(); + const { user } = useSession(); const { jumpToIndex } = useNavigateHelper(); const [open, setOpen] = useState(false); @@ -25,22 +24,12 @@ export const PageNotFound = (): ReactElement => { const onConfirmSignOut = useAsyncCallback(async () => { setOpen(false); - await signOutCloud({ - callbackUrl: '/signIn', - }); + await signOutCloud('/signIn'); }, [setOpen]); return ( <> diff --git a/packages/frontend/core/src/pages/auth.tsx b/packages/frontend/core/src/pages/auth.tsx index c32fe72536..6f27042856 100644 --- a/packages/frontend/core/src/pages/auth.tsx +++ b/packages/frontend/core/src/pages/auth.tsx @@ -12,6 +12,7 @@ import { changeEmailMutation, changePasswordMutation, sendVerifyChangeEmailMutation, + verifyEmailMutation, } from '@affine/graphql'; import { fetcher } from '@affine/graphql'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; @@ -42,6 +43,7 @@ const authTypeSchema = z.enum([ 'changeEmail', 'confirm-change-email', 'subscription-redirect', + 'verify-email', ]); export const AuthPage = (): ReactElement | null => { @@ -73,12 +75,10 @@ export const AuthPage = (): ReactElement | null => { // FIXME: There is not notification if (res?.sendVerifyChangeEmail) { pushNotification({ - title: t['com.affine.auth.sent.change.email.hint'](), + title: t['com.affine.auth.sent.verify.email.hint'](), type: 'success', }); - } - - if (!res?.sendVerifyChangeEmail) { + } else { pushNotification({ title: t['com.affine.auth.sent.change.email.fail'](), type: 'error', @@ -156,6 +156,9 @@ export const AuthPage = (): ReactElement | null => { case 'subscription-redirect': { return ; } + case 'verify-email': { + return ; + } } return null; }; @@ -171,20 +174,37 @@ export const loader: LoaderFunction = async args => { if (args.params.authType === 'confirm-change-email') { const url = new URL(args.request.url); const searchParams = url.searchParams; - const token = searchParams.get('token'); + const token = searchParams.get('token') ?? ''; + const email = decodeURIComponent(searchParams.get('email') ?? ''); const res = await fetcher({ query: changeEmailMutation, variables: { - token: token || '', + token: token, + email: email, }, }).catch(console.error); // TODO: Add error handling if (!res?.changeEmail) { return redirect('/expired'); } + } else if (args.params.authType === 'verify-email') { + const url = new URL(args.request.url); + const searchParams = url.searchParams; + const token = searchParams.get('token') ?? ''; + const res = await fetcher({ + query: verifyEmailMutation, + variables: { + token: token, + }, + }).catch(console.error); + + if (!res?.verifyEmail) { + return redirect('/expired'); + } } return null; }; + export const Component = () => { const loginStatus = useCurrentLoginStatus(); const { jumpToExpired } = useNavigateHelper(); diff --git a/packages/frontend/core/src/pages/desktop-signin.tsx b/packages/frontend/core/src/pages/desktop-signin.tsx index f258fc16f2..04d45b3cb0 100644 --- a/packages/frontend/core/src/pages/desktop-signin.tsx +++ b/packages/frontend/core/src/pages/desktop-signin.tsx @@ -1,34 +1,43 @@ -import { getSession } from 'next-auth/react'; +import { OAuthProviderType } from '@affine/graphql'; import { type LoaderFunction } from 'react-router-dom'; import { z } from 'zod'; +import { getSession } from '../hooks/affine/use-current-user'; import { signInCloud, signOutCloud } from '../utils/cloud-utils'; -const supportedProvider = z.enum(['google']); +const supportedProvider = z.enum([ + 'google', + ...Object.values(OAuthProviderType), +]); export const loader: LoaderFunction = async ({ request }) => { const url = new URL(request.url); const searchParams = url.searchParams; const provider = searchParams.get('provider'); - const callback_url = searchParams.get('callback_url'); - if (!callback_url) { + const redirectUri = + searchParams.get('redirect_uri') ?? + /* backward compatibility */ searchParams.get('callback_url'); + + if (!redirectUri) { return null; } const session = await getSession(); - if (session) { + if (session.user) { // already signed in, need to sign out first - await signOutCloud({ - callbackUrl: request.url, // retry - }); + await signOutCloud(request.url); } const maybeProvider = supportedProvider.safeParse(provider); if (maybeProvider.success) { - const provider = maybeProvider.data; - await signInCloud(provider, { - callbackUrl: callback_url, + let provider = maybeProvider.data; + // BACKWARD COMPATIBILITY + if (provider === 'google') { + provider = OAuthProviderType.Google; + } + await signInCloud(provider, undefined, { + redirectUri, }); } return null; diff --git a/packages/frontend/core/src/providers/modal-provider.tsx b/packages/frontend/core/src/providers/modal-provider.tsx index a1922eae86..41ed373658 100644 --- a/packages/frontend/core/src/providers/modal-provider.tsx +++ b/packages/frontend/core/src/providers/modal-provider.tsx @@ -216,9 +216,7 @@ export const SignOutConfirmModal = () => { const onConfirm = useAsyncCallback(async () => { setOpen(false); - await signOutCloud({ - redirect: false, - }); + await signOutCloud(); // if current workspace is affine cloud, switch to local workspace if (currentWorkspace?.flavour === WorkspaceFlavour.AFFINE_CLOUD) { diff --git a/packages/frontend/core/src/providers/session-provider.tsx b/packages/frontend/core/src/providers/session-provider.tsx index 9decb80477..4993b40568 100644 --- a/packages/frontend/core/src/providers/session-provider.tsx +++ b/packages/frontend/core/src/providers/session-provider.tsx @@ -1,11 +1,10 @@ import { pushNotificationAtom } from '@affine/component/notification-center'; +import { useSession } from '@affine/core/hooks/affine/use-current-user'; import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks'; import { affine } from '@affine/electron-api'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { CLOUD_WORKSPACE_CHANGED_BROADCAST_CHANNEL_KEY } from '@affine/workspace-impl'; -import { useAtom, useSetAtom } from 'jotai'; -// eslint-disable-next-line @typescript-eslint/no-restricted-imports -import { SessionProvider, useSession } from 'next-auth/react'; +import { useSetAtom } from 'jotai'; import { type PropsWithChildren, startTransition, @@ -13,13 +12,11 @@ import { useRef, } from 'react'; -import { sessionAtom } from '../atoms/cloud-user'; import { useOnceSignedInEvents } from '../atoms/event'; -const SessionDefence = (props: PropsWithChildren) => { +export const CloudSessionProvider = (props: PropsWithChildren) => { const session = useSession(); const prevSession = useRef>(); - const [sessionInAtom, setSession] = useAtom(sessionAtom); const pushNotification = useSetAtom(pushNotificationAtom); const onceSignedInEvents = useOnceSignedInEvents(); const t = useAFFiNEI18N(); @@ -32,10 +29,6 @@ const SessionDefence = (props: PropsWithChildren) => { }, [onceSignedInEvents]); useEffect(() => { - if (sessionInAtom !== session && session.status === 'authenticated') { - setSession(session); - } - if (prevSession.current !== session && session.status !== 'loading') { // unauthenticated -> authenticated if ( @@ -55,22 +48,7 @@ const SessionDefence = (props: PropsWithChildren) => { } prevSession.current = session; } - }, [ - session, - sessionInAtom, - prevSession, - setSession, - pushNotification, - refreshAfterSignedInEvents, - t, - ]); + }, [session, prevSession, pushNotification, refreshAfterSignedInEvents, t]); + return props.children; }; - -export const CloudSessionProvider = ({ children }: PropsWithChildren) => { - return ( - - {children} - - ); -}; diff --git a/packages/frontend/core/src/utils/cloud-utils.tsx b/packages/frontend/core/src/utils/cloud-utils.tsx index 4d27e54515..b70951a8d1 100644 --- a/packages/frontend/core/src/utils/cloud-utils.tsx +++ b/packages/frontend/core/src/utils/cloud-utils.tsx @@ -1,12 +1,12 @@ import { generateRandUTF16Chars, + getBaseUrl, + OAuthProviderType, SPAN_ID_BYTES, TRACE_ID_BYTES, traceReporter, } from '@affine/graphql'; import { CLOUD_WORKSPACE_CHANGED_BROADCAST_CHANNEL_KEY } from '@affine/workspace-impl'; -// eslint-disable-next-line @typescript-eslint/no-restricted-imports -import { signIn, signOut } from 'next-auth/react'; type TraceParams = { startTime: string; @@ -43,62 +43,95 @@ function onRejectHandleTrace( return Promise.reject(res); } -export const signInCloud: typeof signIn = async (provider, ...rest) => { +type Providers = 'credentials' | 'email' | OAuthProviderType; + +export const signInCloud = async ( + provider: Providers, + credentials?: { email: string; password?: string }, + searchParams: Record = {} +): Promise => { const traceParams = genTraceParams(); - if (environment.isDesktop) { - if (provider === 'google') { + + if (provider === 'credentials' || provider === 'email') { + if (!credentials) { + throw new Error('Invalid Credentials'); + } + + return signIn(credentials, searchParams) + .then(res => onResolveHandleTrace(res, traceParams)) + .catch(err => onRejectHandleTrace(err, traceParams)); + } else if (OAuthProviderType[provider]) { + if (environment.isDesktop) { open( `${ runtimeConfig.serverUrlPrefix - }/desktop-signin?provider=google&callback_url=${buildCallbackUrl( + }/desktop-signin?provider=${provider}&redirect_uri=${buildRedirectUri( '/open-app/signin-redirect' )}`, '_target' ); - return; } else { - const [options, ...tail] = rest; - const callbackUrl = - runtimeConfig.serverUrlPrefix + - (provider === 'email' - ? '/open-app/signin-redirect' - : location.pathname); - return signIn( - provider, - { - ...options, - callbackUrl: buildCallbackUrl(callbackUrl), - }, - ...tail - ) - .then(res => onResolveHandleTrace(res, traceParams)) - .catch(err => onRejectHandleTrace(err, traceParams)); + location.href = `${ + runtimeConfig.serverUrlPrefix + }/oauth/login?provider=${provider}&redirect_uri=${encodeURIComponent( + searchParams.redirectUri ?? location.pathname + )}`; } + + return; } else { - return signIn(provider, ...rest) - .then(res => onResolveHandleTrace(res, traceParams)) - .catch(err => onRejectHandleTrace(err, traceParams)); + throw new Error('Invalid Provider'); } }; -export const signOutCloud: typeof signOut = async options => { +async function signIn( + credential: { email: string; password?: string }, + searchParams: Record = {} +) { + const url = new URL(getBaseUrl() + '/api/auth/sign-in'); + + for (const key in searchParams) { + url.searchParams.set(key, searchParams[key]); + } + + const redirectUri = + runtimeConfig.serverUrlPrefix + + (environment.isDesktop + ? buildRedirectUri('/open-app/signin-redirect') + : location.pathname); + + url.searchParams.set('redirect_uri', redirectUri); + + return fetch(url.toString(), { + method: 'POST', + body: JSON.stringify(credential), + headers: { + 'content-type': 'application/json', + }, + }); +} + +export const signOutCloud = async (redirectUri?: string) => { const traceParams = genTraceParams(); - return signOut({ - callbackUrl: '/', - ...options, - }) + return fetch(getBaseUrl() + '/api/auth/sign-out') .then(result => { - if (result) { + if (result.ok) { new BroadcastChannel( CLOUD_WORKSPACE_CHANGED_BROADCAST_CHANNEL_KEY ).postMessage(1); + + if (redirectUri && location.href !== redirectUri) { + setTimeout(() => { + location.href = redirectUri; + }, 0); + } } return onResolveHandleTrace(result, traceParams); }) .catch(err => onRejectHandleTrace(err, traceParams)); }; -export function buildCallbackUrl(callbackUrl: string) { +export function buildRedirectUri(callbackUrl: string) { const params: string[][] = []; if (environment.isDesktop && window.appInfo.schema) { params.push(['schema', window.appInfo.schema]); diff --git a/packages/frontend/electron/src/main/deep-link.ts b/packages/frontend/electron/src/main/deep-link.ts index a098bfb861..b61c9cd54f 100644 --- a/packages/frontend/electron/src/main/deep-link.ts +++ b/packages/frontend/electron/src/main/deep-link.ts @@ -8,7 +8,6 @@ import { logger } from './logger'; import { getMainWindow, handleOpenUrlInHiddenWindow, - removeCookie, setCookie, } from './main-window'; @@ -82,28 +81,16 @@ async function handleOauthJwt(url: string) { return; } - const isSecure = CLOUD_BASE_URL.startsWith('https://'); - // set token to cookie await setCookie({ url: CLOUD_BASE_URL, httpOnly: true, value: token, secure: true, - name: isSecure - ? '__Secure-next-auth.session-token' - : 'next-auth.session-token', + name: 'sid', expirationDate: Math.floor(Date.now() / 1000 + 3600 * 24 * 7), }); - // force reset next-auth.callback-url - // there could be incorrect callback-url in cookie that will cause auth failure - // so we need to reset it to empty to mitigate this issue - await removeCookie( - CLOUD_BASE_URL, - isSecure ? '__Secure-next-auth.callback-url' : 'next-auth.callback-url' - ); - let hiddenWindow: BrowserWindow | null = null; ipcMain.once('affine:login', () => { diff --git a/packages/frontend/graphql/src/graphql/change-email.gql b/packages/frontend/graphql/src/graphql/change-email.gql index 77746962ce..6ebd72e91f 100644 --- a/packages/frontend/graphql/src/graphql/change-email.gql +++ b/packages/frontend/graphql/src/graphql/change-email.gql @@ -1,8 +1,6 @@ -mutation changeEmail($token: String!) { - changeEmail(token: $token) { +mutation changeEmail($token: String!, $email: String!) { + changeEmail(token: $token, email: $email) { id - name - avatarUrl email } } diff --git a/packages/frontend/graphql/src/graphql/change-password.gql b/packages/frontend/graphql/src/graphql/change-password.gql index e1f86b3ffd..fb60b1a952 100644 --- a/packages/frontend/graphql/src/graphql/change-password.gql +++ b/packages/frontend/graphql/src/graphql/change-password.gql @@ -1,8 +1,5 @@ mutation changePassword($token: String!, $newPassword: String!) { changePassword(token: $token, newPassword: $newPassword) { id - name - avatarUrl - email } } diff --git a/packages/frontend/graphql/src/graphql/early-access-list.gql b/packages/frontend/graphql/src/graphql/early-access-list.gql index 13c92f22a6..1b87d67fa6 100644 --- a/packages/frontend/graphql/src/graphql/early-access-list.gql +++ b/packages/frontend/graphql/src/graphql/early-access-list.gql @@ -5,7 +5,6 @@ query earlyAccessUsers { email avatarUrl emailVerified - createdAt subscription { plan recurring diff --git a/packages/frontend/graphql/src/graphql/get-current-user.gql b/packages/frontend/graphql/src/graphql/get-current-user.gql index 272f527e04..8c9d837921 100644 --- a/packages/frontend/graphql/src/graphql/get-current-user.gql +++ b/packages/frontend/graphql/src/graphql/get-current-user.gql @@ -5,7 +5,6 @@ query getCurrentUser { email emailVerified avatarUrl - createdAt token { sessionToken } diff --git a/packages/frontend/graphql/src/graphql/get-oauth-providers.gql b/packages/frontend/graphql/src/graphql/get-oauth-providers.gql new file mode 100644 index 0000000000..afbdcc6a8e --- /dev/null +++ b/packages/frontend/graphql/src/graphql/get-oauth-providers.gql @@ -0,0 +1,5 @@ +query oauthProviders { + serverConfig { + oauthProviders + } +} diff --git a/packages/frontend/graphql/src/graphql/index.ts b/packages/frontend/graphql/src/graphql/index.ts index 272ce02ab4..d28ef61e64 100644 --- a/packages/frontend/graphql/src/graphql/index.ts +++ b/packages/frontend/graphql/src/graphql/index.ts @@ -101,11 +101,9 @@ export const changeEmailMutation = { definitionName: 'changeEmail', containsFile: false, query: ` -mutation changeEmail($token: String!) { - changeEmail(token: $token) { +mutation changeEmail($token: String!, $email: String!) { + changeEmail(token: $token, email: $email) { id - name - avatarUrl email } }`, @@ -120,9 +118,6 @@ export const changePasswordMutation = { mutation changePassword($token: String!, $newPassword: String!) { changePassword(token: $token, newPassword: $newPassword) { id - name - avatarUrl - email } }`, }; @@ -212,7 +207,6 @@ query earlyAccessUsers { email avatarUrl emailVerified - createdAt subscription { plan recurring @@ -248,7 +242,6 @@ query getCurrentUser { email emailVerified avatarUrl - createdAt token { sessionToken } @@ -324,6 +317,19 @@ query getMembersByWorkspaceId($workspaceId: String!, $skip: Int!, $take: Int!) { }`, }; +export const oauthProvidersQuery = { + id: 'oauthProvidersQuery' as const, + operationName: 'oauthProviders', + definitionName: 'serverConfig', + containsFile: false, + query: ` +query oauthProviders { + serverConfig { + oauthProviders + } +}`, +}; + export const getPublicWorkspaceQuery = { id: 'getPublicWorkspaceQuery' as const, operationName: 'getPublicWorkspace', @@ -627,8 +633,8 @@ export const sendChangeEmailMutation = { definitionName: 'sendChangeEmail', containsFile: false, query: ` -mutation sendChangeEmail($email: String!, $callbackUrl: String!) { - sendChangeEmail(email: $email, callbackUrl: $callbackUrl) +mutation sendChangeEmail($callbackUrl: String!) { + sendChangeEmail(callbackUrl: $callbackUrl) }`, }; @@ -638,8 +644,8 @@ export const sendChangePasswordEmailMutation = { definitionName: 'sendChangePasswordEmail', containsFile: false, query: ` -mutation sendChangePasswordEmail($email: String!, $callbackUrl: String!) { - sendChangePasswordEmail(email: $email, callbackUrl: $callbackUrl) +mutation sendChangePasswordEmail($callbackUrl: String!) { + sendChangePasswordEmail(callbackUrl: $callbackUrl) }`, }; @@ -649,8 +655,8 @@ export const sendSetPasswordEmailMutation = { definitionName: 'sendSetPasswordEmail', containsFile: false, query: ` -mutation sendSetPasswordEmail($email: String!, $callbackUrl: String!) { - sendSetPasswordEmail(email: $email, callbackUrl: $callbackUrl) +mutation sendSetPasswordEmail($callbackUrl: String!) { + sendSetPasswordEmail(callbackUrl: $callbackUrl) }`, }; @@ -665,6 +671,17 @@ mutation sendVerifyChangeEmail($token: String!, $email: String!, $callbackUrl: S }`, }; +export const sendVerifyEmailMutation = { + id: 'sendVerifyEmailMutation' as const, + operationName: 'sendVerifyEmail', + definitionName: 'sendVerifyEmail', + containsFile: false, + query: ` +mutation sendVerifyEmail($callbackUrl: String!) { + sendVerifyEmail(callbackUrl: $callbackUrl) +}`, +}; + export const serverConfigQuery = { id: 'serverConfigQuery' as const, operationName: 'serverConfig', @@ -695,36 +712,6 @@ mutation setWorkspacePublicById($id: ID!, $public: Boolean!) { }`, }; -export const signInMutation = { - id: 'signInMutation' as const, - operationName: 'signIn', - definitionName: 'signIn', - containsFile: false, - query: ` -mutation signIn($email: String!, $password: String!) { - signIn(email: $email, password: $password) { - token { - token - } - } -}`, -}; - -export const signUpMutation = { - id: 'signUpMutation' as const, - operationName: 'signUp', - definitionName: 'signUp', - containsFile: false, - query: ` -mutation signUp($name: String!, $email: String!, $password: String!) { - signUp(name: $name, email: $email, password: $password) { - token { - token - } - } -}`, -}; - export const subscriptionQuery = { id: 'subscriptionQuery' as const, operationName: 'subscription', @@ -766,6 +753,20 @@ mutation updateSubscription($recurring: SubscriptionRecurring!, $idempotencyKey: }`, }; +export const updateUserProfileMutation = { + id: 'updateUserProfileMutation' as const, + operationName: 'updateUserProfile', + definitionName: 'updateProfile', + containsFile: false, + query: ` +mutation updateUserProfile($input: UpdateUserInput!) { + updateProfile(input: $input) { + id + name + } +}`, +}; + export const uploadAvatarMutation = { id: 'uploadAvatarMutation' as const, operationName: 'uploadAvatar', @@ -782,6 +783,17 @@ mutation uploadAvatar($avatar: Upload!) { }`, }; +export const verifyEmailMutation = { + id: 'verifyEmailMutation' as const, + operationName: 'verifyEmail', + definitionName: 'verifyEmail', + containsFile: false, + query: ` +mutation verifyEmail($token: String!) { + verifyEmail(token: $token) +}`, +}; + export const enabledFeaturesQuery = { id: 'enabledFeaturesQuery' as const, operationName: 'enabledFeatures', diff --git a/packages/frontend/graphql/src/graphql/send-change-email.gql b/packages/frontend/graphql/src/graphql/send-change-email.gql index b9421d15b5..0300a20427 100644 --- a/packages/frontend/graphql/src/graphql/send-change-email.gql +++ b/packages/frontend/graphql/src/graphql/send-change-email.gql @@ -1,3 +1,3 @@ -mutation sendChangeEmail($email: String!, $callbackUrl: String!) { - sendChangeEmail(email: $email, callbackUrl: $callbackUrl) +mutation sendChangeEmail($callbackUrl: String!) { + sendChangeEmail(callbackUrl: $callbackUrl) } diff --git a/packages/frontend/graphql/src/graphql/send-change-password-email.gql b/packages/frontend/graphql/src/graphql/send-change-password-email.gql index ed99bab15b..3b40efba25 100644 --- a/packages/frontend/graphql/src/graphql/send-change-password-email.gql +++ b/packages/frontend/graphql/src/graphql/send-change-password-email.gql @@ -1,3 +1,3 @@ -mutation sendChangePasswordEmail($email: String!, $callbackUrl: String!) { - sendChangePasswordEmail(email: $email, callbackUrl: $callbackUrl) +mutation sendChangePasswordEmail($callbackUrl: String!) { + sendChangePasswordEmail(callbackUrl: $callbackUrl) } diff --git a/packages/frontend/graphql/src/graphql/send-set-password-email.gql b/packages/frontend/graphql/src/graphql/send-set-password-email.gql index 8caaebd989..e8da4bbefb 100644 --- a/packages/frontend/graphql/src/graphql/send-set-password-email.gql +++ b/packages/frontend/graphql/src/graphql/send-set-password-email.gql @@ -1,3 +1,3 @@ -mutation sendSetPasswordEmail($email: String!, $callbackUrl: String!) { - sendSetPasswordEmail(email: $email, callbackUrl: $callbackUrl) +mutation sendSetPasswordEmail($callbackUrl: String!) { + sendSetPasswordEmail(callbackUrl: $callbackUrl) } diff --git a/packages/frontend/graphql/src/graphql/send-verify-email.gql b/packages/frontend/graphql/src/graphql/send-verify-email.gql new file mode 100644 index 0000000000..300f005916 --- /dev/null +++ b/packages/frontend/graphql/src/graphql/send-verify-email.gql @@ -0,0 +1,3 @@ +mutation sendVerifyEmail($callbackUrl: String!) { + sendVerifyEmail(callbackUrl: $callbackUrl) +} diff --git a/packages/frontend/graphql/src/graphql/sign-in.gql b/packages/frontend/graphql/src/graphql/sign-in.gql deleted file mode 100644 index b43e7dcbee..0000000000 --- a/packages/frontend/graphql/src/graphql/sign-in.gql +++ /dev/null @@ -1,7 +0,0 @@ -mutation signIn($email: String!, $password: String!) { - signIn(email: $email, password: $password) { - token { - token - } - } -} diff --git a/packages/frontend/graphql/src/graphql/sign-up.gql b/packages/frontend/graphql/src/graphql/sign-up.gql deleted file mode 100644 index a93df61981..0000000000 --- a/packages/frontend/graphql/src/graphql/sign-up.gql +++ /dev/null @@ -1,7 +0,0 @@ -mutation signUp($name: String!, $email: String!, $password: String!) { - signUp(name: $name, email: $email, password: $password) { - token { - token - } - } -} diff --git a/packages/frontend/graphql/src/graphql/update-user-profile.gql b/packages/frontend/graphql/src/graphql/update-user-profile.gql new file mode 100644 index 0000000000..edb8d4fcb7 --- /dev/null +++ b/packages/frontend/graphql/src/graphql/update-user-profile.gql @@ -0,0 +1,6 @@ +mutation updateUserProfile($input: UpdateUserInput!) { + updateProfile(input: $input) { + id + name + } +} diff --git a/packages/frontend/graphql/src/graphql/verify-email.gql b/packages/frontend/graphql/src/graphql/verify-email.gql new file mode 100644 index 0000000000..493a21e5b5 --- /dev/null +++ b/packages/frontend/graphql/src/graphql/verify-email.gql @@ -0,0 +1,3 @@ +mutation verifyEmail($token: String!) { + verifyEmail(token: $token) +} diff --git a/packages/frontend/graphql/src/schema.ts b/packages/frontend/graphql/src/schema.ts index b92793f97f..c43b7c1ef1 100644 --- a/packages/frontend/graphql/src/schema.ts +++ b/packages/frontend/graphql/src/schema.ts @@ -57,6 +57,11 @@ export enum InvoiceStatus { Void = 'Void', } +export enum OAuthProviderType { + GitHub = 'GitHub', + Google = 'Google', +} + /** User permission in workspace */ export enum Permission { Admin = 'Admin', @@ -77,6 +82,7 @@ export enum ServerDeploymentType { } export enum ServerFeature { + OAuth = 'OAuth', Payment = 'Payment', } @@ -104,6 +110,11 @@ export enum SubscriptionStatus { Unpaid = 'Unpaid', } +export interface UpdateUserInput { + /** User name */ + name: InputMaybe; +} + export interface UpdateWorkspaceInput { id: Scalars['ID']['input']; /** is Public workspace */ @@ -176,17 +187,12 @@ export type CancelSubscriptionMutation = { export type ChangeEmailMutationVariables = Exact<{ token: Scalars['String']['input']; + email: Scalars['String']['input']; }>; export type ChangeEmailMutation = { __typename?: 'Mutation'; - changeEmail: { - __typename?: 'UserType'; - id: string; - name: string; - avatarUrl: string | null; - email: string; - }; + changeEmail: { __typename?: 'UserType'; id: string; email: string }; }; export type ChangePasswordMutationVariables = Exact<{ @@ -196,13 +202,7 @@ export type ChangePasswordMutationVariables = Exact<{ export type ChangePasswordMutation = { __typename?: 'Mutation'; - changePassword: { - __typename?: 'UserType'; - id: string; - name: string; - avatarUrl: string | null; - email: string; - }; + changePassword: { __typename?: 'UserType'; id: string }; }; export type CreateCheckoutSessionMutationVariables = Exact<{ @@ -270,8 +270,7 @@ export type EarlyAccessUsersQuery = { name: string; email: string; avatarUrl: string | null; - emailVerified: string | null; - createdAt: string | null; + emailVerified: boolean; subscription: { __typename?: 'UserSubscription'; plan: SubscriptionPlan; @@ -301,10 +300,9 @@ export type GetCurrentUserQuery = { id: string; name: string; email: string; - emailVerified: string | null; + emailVerified: boolean; avatarUrl: string | null; - createdAt: string | null; - token: { __typename?: 'TokenType'; sessionToken: string | null }; + token: { __typename?: 'tokenType'; sessionToken: string | null }; } | null; }; @@ -365,11 +363,21 @@ export type GetMembersByWorkspaceIdQuery = { permission: Permission; inviteId: string; accepted: boolean; - emailVerified: string | null; + emailVerified: boolean | null; }>; }; }; +export type OauthProvidersQueryVariables = Exact<{ [key: string]: never }>; + +export type OauthProvidersQuery = { + __typename?: 'Query'; + serverConfig: { + __typename?: 'ServerConfigType'; + oauthProviders: Array; + }; +}; + export type GetPublicWorkspaceQueryVariables = Exact<{ id: Scalars['String']['input']; }>; @@ -386,18 +394,14 @@ export type GetUserQueryVariables = Exact<{ export type GetUserQuery = { __typename?: 'Query'; user: - | { - __typename: 'LimitedUserType'; - email: string; - hasPassword: boolean | null; - } + | { __typename: 'LimitedUserType'; email: string; hasPassword: boolean } | { __typename: 'UserType'; id: string; name: string; avatarUrl: string | null; email: string; - hasPassword: boolean | null; + hasPassword: boolean; } | null; }; @@ -628,7 +632,6 @@ export type RevokePublicPageMutation = { }; export type SendChangeEmailMutationVariables = Exact<{ - email: Scalars['String']['input']; callbackUrl: Scalars['String']['input']; }>; @@ -638,7 +641,6 @@ export type SendChangeEmailMutation = { }; export type SendChangePasswordEmailMutationVariables = Exact<{ - email: Scalars['String']['input']; callbackUrl: Scalars['String']['input']; }>; @@ -648,7 +650,6 @@ export type SendChangePasswordEmailMutation = { }; export type SendSetPasswordEmailMutationVariables = Exact<{ - email: Scalars['String']['input']; callbackUrl: Scalars['String']['input']; }>; @@ -668,6 +669,15 @@ export type SendVerifyChangeEmailMutation = { sendVerifyChangeEmail: boolean; }; +export type SendVerifyEmailMutationVariables = Exact<{ + callbackUrl: Scalars['String']['input']; +}>; + +export type SendVerifyEmailMutation = { + __typename?: 'Mutation'; + sendVerifyEmail: boolean; +}; + export type ServerConfigQueryVariables = Exact<{ [key: string]: never }>; export type ServerConfigQuery = { @@ -692,33 +702,6 @@ export type SetWorkspacePublicByIdMutation = { updateWorkspace: { __typename?: 'WorkspaceType'; id: string }; }; -export type SignInMutationVariables = Exact<{ - email: Scalars['String']['input']; - password: Scalars['String']['input']; -}>; - -export type SignInMutation = { - __typename?: 'Mutation'; - signIn: { - __typename?: 'UserType'; - token: { __typename?: 'TokenType'; token: string }; - }; -}; - -export type SignUpMutationVariables = Exact<{ - name: Scalars['String']['input']; - email: Scalars['String']['input']; - password: Scalars['String']['input']; -}>; - -export type SignUpMutation = { - __typename?: 'Mutation'; - signUp: { - __typename?: 'UserType'; - token: { __typename?: 'TokenType'; token: string }; - }; -}; - export type SubscriptionQueryVariables = Exact<{ [key: string]: never }>; export type SubscriptionQuery = { @@ -755,6 +738,15 @@ export type UpdateSubscriptionMutation = { }; }; +export type UpdateUserProfileMutationVariables = Exact<{ + input: UpdateUserInput; +}>; + +export type UpdateUserProfileMutation = { + __typename?: 'Mutation'; + updateProfile: { __typename?: 'UserType'; id: string; name: string }; +}; + export type UploadAvatarMutationVariables = Exact<{ avatar: Scalars['Upload']['input']; }>; @@ -770,6 +762,15 @@ export type UploadAvatarMutation = { }; }; +export type VerifyEmailMutationVariables = Exact<{ + token: Scalars['String']['input']; +}>; + +export type VerifyEmailMutation = { + __typename?: 'Mutation'; + verifyEmail: boolean; +}; + export type EnabledFeaturesQueryVariables = Exact<{ id: Scalars['String']['input']; }>; @@ -938,6 +939,11 @@ export type Queries = variables: GetMembersByWorkspaceIdQueryVariables; response: GetMembersByWorkspaceIdQuery; } + | { + name: 'oauthProvidersQuery'; + variables: OauthProvidersQueryVariables; + response: OauthProvidersQuery; + } | { name: 'getPublicWorkspaceQuery'; variables: GetPublicWorkspaceQueryVariables; @@ -1145,31 +1151,36 @@ export type Mutations = variables: SendVerifyChangeEmailMutationVariables; response: SendVerifyChangeEmailMutation; } + | { + name: 'sendVerifyEmailMutation'; + variables: SendVerifyEmailMutationVariables; + response: SendVerifyEmailMutation; + } | { name: 'setWorkspacePublicByIdMutation'; variables: SetWorkspacePublicByIdMutationVariables; response: SetWorkspacePublicByIdMutation; } - | { - name: 'signInMutation'; - variables: SignInMutationVariables; - response: SignInMutation; - } - | { - name: 'signUpMutation'; - variables: SignUpMutationVariables; - response: SignUpMutation; - } | { name: 'updateSubscriptionMutation'; variables: UpdateSubscriptionMutationVariables; response: UpdateSubscriptionMutation; } + | { + name: 'updateUserProfileMutation'; + variables: UpdateUserProfileMutationVariables; + response: UpdateUserProfileMutation; + } | { name: 'uploadAvatarMutation'; variables: UploadAvatarMutationVariables; response: UploadAvatarMutation; } + | { + name: 'verifyEmailMutation'; + variables: VerifyEmailMutationVariables; + response: VerifyEmailMutation; + } | { name: 'setWorkspaceExperimentalFeatureMutation'; variables: SetWorkspaceExperimentalFeatureMutationVariables; diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index afdab4139f..af2b9c366d 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -406,10 +406,12 @@ "com.affine.appearanceSettings.windowFrame.description": "Customise appearance of Windows Client.", "com.affine.appearanceSettings.windowFrame.frameless": "Frameless", "com.affine.appearanceSettings.windowFrame.title": "Window frame style", - "com.affine.auth.change.email.message": "Your current email is {{email}}. We’ll send a temporary verification link to this email.", + "com.affine.auth.verify.email.message": "Your current email is {{email}}. We’ll send a temporary verification link to this email.", "com.affine.auth.change.email.page.subtitle": "Please enter your new email address below. We will send a verification link to this email address to complete the process.", "com.affine.auth.change.email.page.success.subtitle": "Congratulations! You have successfully updated the email address associated with your AFFiNE Cloud account.", "com.affine.auth.change.email.page.success.title": "Email address updated!", + "com.affine.auth.verify.email.page.success.title": "Email address verified!", + "com.affine.auth.verify.email.page.success.subtitle": "Congratulations! You have successfully verified the email address associated with your AFFiNE Cloud account.", "com.affine.auth.change.email.page.title": "Change email address", "com.affine.auth.create.count": "Create Account", "com.affine.auth.desktop.signing.in": "Signing in...", @@ -430,11 +432,11 @@ "com.affine.auth.reset.password.message": "You will receive an email with a link to reset your password. Please check your inbox.", "com.affine.auth.reset.password.page.success": "Password reset successful", "com.affine.auth.reset.password.page.title": "Reset your AFFiNE Cloud password", - "com.affine.auth.send.change.email.link": "Send verification link", + "com.affine.auth.send.verify.email.hint": "Send verification link", "com.affine.auth.send.reset.password.link": "Send reset link", "com.affine.auth.send.set.password.link": "Send set link", "com.affine.auth.sent": "Sent", - "com.affine.auth.sent.change.email.hint": "Verification link has been sent.", + "com.affine.auth.sent.verify.email.hint": "Verification link has been sent.", "com.affine.auth.sent.change.email.fail": "The verification link failed to be sent, please try again later.", "com.affine.auth.sent.change.password.hint": "Reset password link has been sent.", "com.affine.auth.sent.reset.password.success.message": "Your password has upgraded! You can sign in AFFiNE Cloud with new password!", @@ -951,7 +953,8 @@ "com.affine.settings.auto-check-description": "If enabled, it will automatically check for new versions at regular intervals.", "com.affine.settings.auto-download-description": " If enabled, new versions will be automatically downloaded to the current device.", "com.affine.settings.email": "Email", - "com.affine.settings.email.action": "Change Email", + "com.affine.settings.email.action.change": "Change Email", + "com.affine.settings.email.action.verify": "Verify Email", "com.affine.settings.member-tooltip": "Enable AFFiNE Cloud to collaborate with others", "com.affine.settings.noise-style": "Noise background on the sidebar", "com.affine.settings.noise-style-description": "Use background noise effect on the sidebar.", diff --git a/packages/frontend/i18n/src/resources/fr.json b/packages/frontend/i18n/src/resources/fr.json index 9079833774..468fd8bea1 100644 --- a/packages/frontend/i18n/src/resources/fr.json +++ b/packages/frontend/i18n/src/resources/fr.json @@ -406,7 +406,7 @@ "com.affine.appearanceSettings.windowFrame.description": "Personnalisez l'apparence de l'application Windows", "com.affine.appearanceSettings.windowFrame.frameless": "Sans Bords", "com.affine.appearanceSettings.windowFrame.title": "Style de fenêtre", - "com.affine.auth.change.email.message": "Votre email actuel est {{email}}. Nous enverrons un lien de vérification temporaire à cette addresse.", + "com.affine.auth.verify.email.message": "Votre email actuel est {{email}}. Nous enverrons un lien de vérification temporaire à cette addresse.", "com.affine.auth.change.email.page.subtitle": "Rentrez votre nouvelle adresse mail en dessous. Nous enverrons un lien de vérification à cette adresse mail pour compléter le processus", "com.affine.auth.change.email.page.success.subtitle": "Félicitation ! Vous avez réussi à mettre à jour votre adresse mail associé avec votre compte AFFiNE cloud ", "com.affine.auth.change.email.page.success.title": "Adresse mail mise à jour !", @@ -430,11 +430,11 @@ "com.affine.auth.reset.password.message": "Vous allez recevoir un mail avec un lien pour réinitialiser votre mot de passe. Merci de vérifier votre boite de réception", "com.affine.auth.reset.password.page.success": "Mot de passe réinitialisé avec succès", "com.affine.auth.reset.password.page.title": "Réinitialiser votre mot de passe AFFiNE Cloud", - "com.affine.auth.send.change.email.link": "Envoyer un lien de vérification", + "com.affine.auth.send.verify.email.hint": "Envoyer un lien de vérification", "com.affine.auth.send.reset.password.link": "Envoyer un lien de réinitialisation", "com.affine.auth.send.set.password.link": "Envoyer un lien pour définir votre mot de passe", "com.affine.auth.sent": "Envoyé", - "com.affine.auth.sent.change.email.hint": "Le lien de vérification a été envoyé", + "com.affine.auth.sent.verify.email.hint": "Le lien de vérification a été envoyé", "com.affine.auth.sent.change.password.hint": "Le lien de réinitialisation de mot de passe a été envoyé", "com.affine.auth.sent.reset.password.success.message": "Votre mot de passe a été changé ! Vous pouvez à nouveau vous connecter à AFFiNE Cloud avec votre nouveau mot de passe ! ", "com.affine.auth.sent.set.password.hint": "Le lien pour définir votre mot de passe à été envoyé", @@ -788,7 +788,7 @@ "com.affine.settings.auto-check-description": "Si activé, l'option cherchera automatiquement pour les nouvelles versions à intervalles réguliers", "com.affine.settings.auto-download-description": "Si activé, les nouvelles versions seront automatiquement téléchargées sur l'appareil actuel", "com.affine.settings.email": "Email", - "com.affine.settings.email.action": "Changer l'Email", + "com.affine.settings.email.action.change": "Changer l'Email", "com.affine.settings.member-tooltip": "Activer AFFiNE Cloud pour collaborer avec d'autres personnes", "com.affine.settings.noise-style": "Bruit d'arrière-plan de la barre latérale", "com.affine.settings.noise-style-description": "Utiliser l'effet de bruit d'arrière-plan sur la barre latérale", diff --git a/packages/frontend/i18n/src/resources/ko.json b/packages/frontend/i18n/src/resources/ko.json index 4cd2805a12..02bb7cddb9 100644 --- a/packages/frontend/i18n/src/resources/ko.json +++ b/packages/frontend/i18n/src/resources/ko.json @@ -406,7 +406,7 @@ "com.affine.appearanceSettings.windowFrame.description": "Windows 클라이언트의 모양을 사용자 정의합니다.", "com.affine.appearanceSettings.windowFrame.frameless": "프레임 없이", "com.affine.appearanceSettings.windowFrame.title": "윈도우 프레임 스타일", - "com.affine.auth.change.email.message": "현재 이메일은 {{email}}입니다. 이 이메일 주소로 임시 인증 링크를 보내 드리겠습니다.", + "com.affine.auth.verify.email.message": "현재 이메일은 {{email}}입니다. 이 이메일 주소로 임시 인증 링크를 보내 드리겠습니다.", "com.affine.auth.change.email.page.subtitle": "아래에 새 이메일 주소를 입력하세요. 절차를 완료하기 위해 이 이메일 주소로 인증 링크를 보내드립니다.", "com.affine.auth.change.email.page.success.subtitle": "축하합니다! AFFiNE Cloud 계정과 연결된 이메일 주소를 성공적으로 업데이트했습니다.", "com.affine.auth.change.email.page.success.title": "이메일 주소를 업데이트했습니다!", @@ -430,11 +430,11 @@ "com.affine.auth.reset.password.message": "비밀번호를 재설정할 수 있는 링크가 포함된 이메일을 받게 됩니다. 받은 편지함을 확인해 주세요.", "com.affine.auth.reset.password.page.success": "비밀번호 재설정 성공", "com.affine.auth.reset.password.page.title": "AFFiNE Cloud 비밀번호 재설정", - "com.affine.auth.send.change.email.link": "인증 링크 전송", + "com.affine.auth.send.verify.email.hint": "인증 링크 전송", "com.affine.auth.send.reset.password.link": "재설정 링크 전송", "com.affine.auth.send.set.password.link": "설정 링크 전송", "com.affine.auth.sent": "보냄", - "com.affine.auth.sent.change.email.hint": "인증 링크를 보냈습니다.", + "com.affine.auth.sent.verify.email.hint": "인증 링크를 보냈습니다.", "com.affine.auth.sent.change.password.hint": "비밀번호 재설정 링크를 보냈습니다.", "com.affine.auth.sent.reset.password.success.message": "비밀번호가 업그레이드했습니다! 새 비밀번호로 AFFiNE Cloud에 로그인할 수 있습니다!", "com.affine.auth.sent.set.password.hint": "비밀번호 설정 링크를 보냈습니다.", @@ -908,7 +908,7 @@ "com.affine.settings.auto-check-description": "이 기능을 활성화하면, 정기적으로 새 버전을 자동으로 확인합니다.", "com.affine.settings.auto-download-description": "이 기능을 활성화하면, 새 버전이 현재 디바이스에 자동으로 다운로드됩니다.", "com.affine.settings.email": "이메일", - "com.affine.settings.email.action": "이메일 변경", + "com.affine.settings.email.action.change": "이메일 변경", "com.affine.settings.member-tooltip": "다른 사람들과 협업할 수 있는 AFFiNE Cloud 활성화", "com.affine.settings.noise-style": "Noise background on the sidebar", "com.affine.settings.noise-style-description": "Use background noise effect on the sidebar.", diff --git a/packages/frontend/i18n/src/resources/pt-BR.json b/packages/frontend/i18n/src/resources/pt-BR.json index ce6e358b6c..8c41041054 100644 --- a/packages/frontend/i18n/src/resources/pt-BR.json +++ b/packages/frontend/i18n/src/resources/pt-BR.json @@ -337,11 +337,11 @@ "com.affine.auth.reset.password": "Redefinir Senha", "com.affine.auth.reset.password.message": "Você receberá um email com um link para redefinir sua senha. Por favor verifique sua caixa de entrada.", "com.affine.auth.reset.password.page.title": "Redefina sua senha da AFFiNE Cloud", - "com.affine.auth.send.change.email.link": "Envie um link de verificação", + "com.affine.auth.send.verify.email.hint": "Envie um link de verificação", "com.affine.auth.send.reset.password.link": "Enviar link de redefinição", "com.affine.auth.send.set.password.link": "Enviar link de definição", "com.affine.auth.sent": "Enviado", - "com.affine.auth.sent.change.email.hint": "Link de verificação foi enviado.", + "com.affine.auth.sent.verify.email.hint": "Link de verificação foi enviado.", "com.affine.auth.sent.change.password.hint": "Link de redefinição de senha foi enviado.", "com.affine.auth.set.email.save": "Salvar Email", "com.affine.auth.set.password.page.title": "Defina sua senha para AFFiNE Cloud", @@ -422,7 +422,7 @@ "com.affine.settings.auto-check-description": "Se ativado, ele verificará automaticamente novas versões em intervalos regulares.", "com.affine.settings.auto-download-description": "Se ativado, novas versões serão baixadas automaticamente para o dispositivo atual.", "com.affine.settings.email": "Email", - "com.affine.settings.email.action": "Mudar Email", + "com.affine.settings.email.action.change": "Mudar Email", "com.affine.settings.password": "Senha", "com.affine.settings.password.action.change": "Mudar senha", "com.affine.settings.profile": "Meu Perfil", diff --git a/packages/frontend/i18n/src/resources/ru.json b/packages/frontend/i18n/src/resources/ru.json index 167332b6cc..1776d7e444 100644 --- a/packages/frontend/i18n/src/resources/ru.json +++ b/packages/frontend/i18n/src/resources/ru.json @@ -318,10 +318,10 @@ "com.affine.auth.reset.password": "Восстановить пароль", "com.affine.auth.reset.password.message": "Вы получите письмо со ссылкой для восстановления пароля. Пожалуйста, проверьте свой почтовый ящик.", "com.affine.auth.reset.password.page.title": "Восстановить пароль AFFiNE Cloud", - "com.affine.auth.send.change.email.link": "Отправить ссылку для подтверждения", + "com.affine.auth.send.verify.email.hint": "Отправить ссылку для подтверждения", "com.affine.auth.send.reset.password.link": "Отправить ссылку для восстановления", "com.affine.auth.sent": "Отправлено", - "com.affine.auth.sent.change.email.hint": "Ссылка для подтверждения отправлена.", + "com.affine.auth.sent.verify.email.hint": "Ссылка для подтверждения отправлена.", "com.affine.auth.sent.change.password.hint": "Ссылка для восстановления пароля отправлена.", "com.affine.auth.sent.set.password.hint": "Ссылка для установки пароля отправлена.", "com.affine.auth.set.email.save": "Сохранить электронную почту", diff --git a/packages/frontend/i18n/src/resources/zh-Hans.json b/packages/frontend/i18n/src/resources/zh-Hans.json index 212f712dfd..b2e1f21a68 100644 --- a/packages/frontend/i18n/src/resources/zh-Hans.json +++ b/packages/frontend/i18n/src/resources/zh-Hans.json @@ -394,7 +394,7 @@ "com.affine.appearanceSettings.windowFrame.description": "自定义 Windows 客户端外观。", "com.affine.appearanceSettings.windowFrame.frameless": "无边框", "com.affine.appearanceSettings.windowFrame.title": "视窗样式", - "com.affine.auth.change.email.message": "您当前的邮箱是 {{email}}。我们将向此邮箱发送一个临时的验证链接。", + "com.affine.auth.verify.email.message": "您当前的邮箱是 {{email}}。我们将向此邮箱发送一个临时的验证链接。", "com.affine.auth.change.email.page.subtitle": "请在下方输入您的新电子邮件地址。我们将把验证链接发送至该电子邮件地址以完成此过程。", "com.affine.auth.change.email.page.success.subtitle": "恭喜!您已更新了与 AFFiNE Cloud 账户关联的电子邮件地址。", "com.affine.auth.change.email.page.success.title": "邮箱地址已更新!", @@ -418,11 +418,11 @@ "com.affine.auth.reset.password.message": "您将收到一封电子邮件,以便重置密码。请在收件箱中查收。", "com.affine.auth.reset.password.page.success": "密码重置成功", "com.affine.auth.reset.password.page.title": "重置您的 AFFiNE Cloud 密码", - "com.affine.auth.send.change.email.link": "发送验证链接", + "com.affine.auth.send.verify.email.hint": "发送验证链接", "com.affine.auth.send.reset.password.link": "发送重置链接", "com.affine.auth.send.set.password.link": "发送设置链接", "com.affine.auth.sent": "已发送", - "com.affine.auth.sent.change.email.hint": "验证链接已发送", + "com.affine.auth.sent.verify.email.hint": "验证链接已发送", "com.affine.auth.sent.change.password.hint": "重置密码链接已发送。", "com.affine.auth.sent.reset.password.success.message": "您的密码已更新!您可以使用新密码登录 AFFiNE Cloud!", "com.affine.auth.sent.set.password.hint": "设置密码链接已发送。", @@ -821,7 +821,8 @@ "com.affine.settings.auto-check-description": "如果启用,它将定期自动检查新版本。", "com.affine.settings.auto-download-description": "如果启用,新版本将自动下载到当前设备。", "com.affine.settings.email": "电子邮件", - "com.affine.settings.email.action": "更改邮箱", + "com.affine.settings.email.action.change": "更改邮箱", + "com.affine.settings.email.action.verify": "验证邮箱", "com.affine.settings.member-tooltip": "启用 AFFiNE Cloud 以与他人协作", "com.affine.settings.noise-style": "侧边栏的噪点背景", "com.affine.settings.noise-style-description": "在侧边栏使用噪点背景效果。", diff --git a/packages/frontend/i18n/src/resources/zh-Hant.json b/packages/frontend/i18n/src/resources/zh-Hant.json index 4e7a941f8c..45e570445a 100644 --- a/packages/frontend/i18n/src/resources/zh-Hant.json +++ b/packages/frontend/i18n/src/resources/zh-Hant.json @@ -336,11 +336,11 @@ "com.affine.auth.reset.password": "重設密碼", "com.affine.auth.reset.password.message": "您將收到一封電子郵件,其中包含重設密碼的連結。請檢查您的收件箱。", "com.affine.auth.reset.password.page.title": "重設您的 AFFiNE Cloud 密碼", - "com.affine.auth.send.change.email.link": "發送驗證連結", + "com.affine.auth.send.verify.email.hint": "發送驗證連結", "com.affine.auth.send.reset.password.link": "發送重設連結", "com.affine.auth.send.set.password.link": "發送設定連結", "com.affine.auth.sent": "已發送", - "com.affine.auth.sent.change.email.hint": "驗證連結已發送。", + "com.affine.auth.sent.verify.email.hint": "驗證連結已發送。", "com.affine.auth.sent.change.password.hint": "重設密碼連結已發送。", "com.affine.auth.sent.set.password.hint": "設定密碼連結已發送。", "com.affine.auth.set.email.save": "保存電子郵件地址", @@ -438,7 +438,8 @@ "com.affine.settings.auto-check-description": "若啟用,將定期自動檢測新版本。", "com.affine.settings.auto-download-description": "若啟用,將自動下載新版本。", "com.affine.settings.email": "電子郵件地址", - "com.affine.settings.email.action": "更改電子郵件地址", + "com.affine.settings.email.action.change": "更改電子郵件地址", + "com.affine.settings.email.action.verify": "验证電子郵件地址", "com.affine.settings.member-tooltip": "啟用 AFFiNE Cloud 以與他人協作", "com.affine.settings.noise-style": "側欄背景雜訊效果", "com.affine.settings.noise-style-description": "在側欄背景使用雜訊效果。", diff --git a/packages/frontend/native/index.js b/packages/frontend/native/index.js index 33cc759db6..2aae7d108d 100644 --- a/packages/frontend/native/index.js +++ b/packages/frontend/native/index.js @@ -88,7 +88,7 @@ switch (platform) { } break default: - throw new Error(`Unsupported architecture on Android ${arch}`) + loadError = new Error(`Unsupported architecture on Android ${arch}`) } break case 'win32': @@ -136,7 +136,7 @@ switch (platform) { } break default: - throw new Error(`Unsupported architecture on Windows: ${arch}`) + loadError = new Error(`Unsupported architecture on Windows: ${arch}`) } break case 'darwin': @@ -177,22 +177,37 @@ switch (platform) { } break default: - throw new Error(`Unsupported architecture on macOS: ${arch}`) + loadError = new Error(`Unsupported architecture on macOS: ${arch}`) } break case 'freebsd': - if (arch !== 'x64') { - throw new Error(`Unsupported architecture on FreeBSD: ${arch}`) - } - localFileExisted = existsSync(join(__dirname, 'affine.freebsd-x64.node')) - try { - if (localFileExisted) { - nativeBinding = require('./affine.freebsd-x64.node') - } else { - nativeBinding = require('@affine/native-freebsd-x64') - } - } catch (e) { - loadError = e + switch (arch) { + case 'x64': + localFileExisted = existsSync(join(__dirname, 'affine.freebsd-x64.node')) + try { + if (localFileExisted) { + nativeBinding = require('./affine.freebsd-x64.node') + } else { + nativeBinding = require('@affine/native-freebsd-x64') + } + } catch (e) { + loadError = e + } + break + case 'arm64': + localFileExisted = existsSync(join(__dirname, 'affine.freebsd-arm64.node')) + try { + if (localFileExisted) { + nativeBinding = require('./affine.freebsd-arm64.node') + } else { + nativeBinding = require('@affine/native-freebsd-arm64') + } + } catch (e) { + loadError = e + } + break + default: + loadError = new Error(`Unsupported architecture on FreeBSD: ${arch}`) } break case 'linux': @@ -298,25 +313,43 @@ switch (platform) { } } break + case 's390x': + localFileExisted = existsSync( + join(__dirname, 'affine.linux-s390x-gnu.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./affine.linux-s390x-gnu.node') + } else { + nativeBinding = require('@affine/native-linux-s390x-gnu') + } + } catch (e) { + loadError = e + } + break default: - throw new Error(`Unsupported architecture on Linux: ${arch}`) + loadError = new Error(`Unsupported architecture on Linux: ${arch}`) } break default: - throw new Error(`Unsupported OS: ${platform}, architecture: ${arch}`) + loadError = new Error(`Unsupported OS: ${platform}, architecture: ${arch}`) } if (!nativeBinding || process.env.NAPI_RS_FORCE_WASI) { try { nativeBinding = require('./affine.wasi.cjs') - } catch { - // ignore + } catch (err) { + if (process.env.NAPI_RS_FORCE_WASI) { + console.error(err) + } } if (!nativeBinding) { try { nativeBinding = require('@affine/native-wasm32-wasi') } catch (err) { - console.error(err) + if (process.env.NAPI_RS_FORCE_WASI) { + console.error(err) + } } } } diff --git a/packages/frontend/templates/templates.gen.ts b/packages/frontend/templates/templates.gen.ts index d95d5c0eed..6858f0b2b3 100644 --- a/packages/frontend/templates/templates.gen.ts +++ b/packages/frontend/templates/templates.gen.ts @@ -1,11 +1,11 @@ /* eslint-disable simple-import-sort/imports */ // Auto generated, do not edit manually -import json_0 from './onboarding/W-d9_llZ6rE-qoTiHKTk4.snapshot.json'; -import json_1 from './onboarding/info.json'; -import json_2 from './onboarding/blob.json'; +import json_0 from './onboarding/info.json'; +import json_1 from './onboarding/blob.json'; +import json_2 from './onboarding/W-d9_llZ6rE-qoTiHKTk4.snapshot.json'; export const onboarding = { - 'W-d9_llZ6rE-qoTiHKTk4.snapshot.json': json_0, - 'info.json': json_1, - 'blob.json': json_2 + 'info.json': json_0, + 'blob.json': json_1, + 'W-d9_llZ6rE-qoTiHKTk4.snapshot.json': json_2 } \ No newline at end of file diff --git a/packages/frontend/workspace-impl/package.json b/packages/frontend/workspace-impl/package.json index f735db316b..4f6db36f6c 100644 --- a/packages/frontend/workspace-impl/package.json +++ b/packages/frontend/workspace-impl/package.json @@ -21,7 +21,6 @@ "is-svg": "^5.0.0", "lodash-es": "^4.17.21", "nanoid": "^5.0.6", - "next-auth": "^4.24.5", "socket.io-client": "^4.7.4", "y-protocols": "^1.0.6", "yjs": "^13.6.12" diff --git a/packages/frontend/workspace-impl/src/cloud/list.ts b/packages/frontend/workspace-impl/src/cloud/list.ts index 38965fa2b3..6c54e9bdf2 100644 --- a/packages/frontend/workspace-impl/src/cloud/list.ts +++ b/packages/frontend/workspace-impl/src/cloud/list.ts @@ -2,6 +2,7 @@ import { WorkspaceFlavour } from '@affine/env/workspace'; import { createWorkspaceMutation, deleteWorkspaceMutation, + findGraphQLError, getWorkspacesQuery, } from '@affine/graphql'; import { fetcher } from '@affine/graphql'; @@ -16,7 +17,6 @@ import { import { globalBlockSuiteSchema } from '@toeverything/infra'; import { difference } from 'lodash-es'; import { nanoid } from 'nanoid'; -import { getSession } from 'next-auth/react'; import { applyUpdate, encodeStateAsUpdate } from 'yjs'; import { IndexedDBBlobStorage } from '../local/blob-indexeddb'; @@ -27,13 +27,11 @@ import { CLOUD_WORKSPACE_CHANGED_BROADCAST_CHANNEL_KEY } from './consts'; import { AffineStaticSyncStorage } from './sync'; async function getCloudWorkspaceList() { - const session = await getSession(); - if (!session) { - return []; - } try { const { workspaces } = await fetcher({ query: getWorkspacesQuery, + }).catch(() => { + return { workspaces: [] }; }); const ids = workspaces.map(({ id }) => id); return ids.map(id => ({ @@ -41,10 +39,13 @@ async function getCloudWorkspaceList() { flavour: WorkspaceFlavour.AFFINE_CLOUD, })); } catch (err) { - if (err instanceof Array && err[0]?.message === 'Forbidden resource') { + console.log(err); + const e = findGraphQLError(err, e => e.extensions.code === 401); + if (e) { // user not logged in return []; } + throw err; } } diff --git a/tests/affine-cloud/e2e/login.spec.ts b/tests/affine-cloud/e2e/login.spec.ts index 6bfca65460..6ede51db77 100644 --- a/tests/affine-cloud/e2e/login.spec.ts +++ b/tests/affine-cloud/e2e/login.spec.ts @@ -73,6 +73,7 @@ test.describe('login first', () => { await page.getByTestId('workspace-modal-account-option').click(); await page.getByTestId('workspace-modal-sign-out-option').click(); await page.getByTestId('confirm-sign-out-button').click(); + await page.reload(); await clickSideBarCurrentWorkspaceBanner(page); const signInButton = page.getByTestId('cloud-signin-button'); await expect(signInButton).toBeVisible(); diff --git a/tests/kit/utils/cloud.ts b/tests/kit/utils/cloud.ts index 7a54d1e91c..b73296a188 100644 --- a/tests/kit/utils/cloud.ts +++ b/tests/kit/utils/cloud.ts @@ -33,9 +33,7 @@ export async function getLatestMailMessage() { export async function getLoginCookie( context: BrowserContext ): Promise { - return (await context.cookies()).find( - c => c.name === 'next-auth.session-token' - ); + return (await context.cookies()).find(c => c.name === 'sid'); } const cloudUserSchema = z.object({ @@ -106,7 +104,7 @@ export async function createRandomUser(): Promise<{ await client.user.create({ data: { ...user, - emailVerified: new Date(), + emailVerifiedAt: new Date(), password: await hash(user.password), features: { create: { diff --git a/tests/storybook/.storybook/preview.tsx b/tests/storybook/.storybook/preview.tsx index 9c5b0ade89..8d863c68dc 100644 --- a/tests/storybook/.storybook/preview.tsx +++ b/tests/storybook/.storybook/preview.tsx @@ -1,10 +1,6 @@ import '@affine/component/theme/global.css'; import '@affine/component/theme/theme.css'; import { createI18n } from '@affine/i18n'; -import MockSessionContext, { - mockAuthStates, - // @ts-ignore -} from '@tomfreudenberg/next-auth-mock'; import { ThemeProvider, useTheme } from 'next-themes'; import { useDarkMode } from 'storybook-dark-mode'; import { AffineContext } from '@affine/component/context'; @@ -40,51 +36,6 @@ export const parameters = { }, }; -const SB_PARAMETER_KEY = 'nextAuthMock'; -export const mockAuthPreviewToolbarItem = ({ - name = 'mockAuthState', - description = 'Set authentication state', - defaultValue = null, - icon = 'user', - items = mockAuthStates, -} = {}) => { - return { - mockAuthState: { - name, - description, - defaultValue, - toolbar: { - icon, - items: Object.keys(items).map(e => ({ - value: e, - title: items[e].title, - })), - }, - }, - }; -}; - -export const withMockAuth: Decorator = (Story, context) => { - // Set a session value for mocking - const session = (() => { - // Allow overwrite of session value by parameter in story - const paramValue = context?.parameters[SB_PARAMETER_KEY]; - if (typeof paramValue?.session === 'string') { - return mockAuthStates[paramValue.session]?.session; - } else { - return paramValue?.session - ? paramValue.session - : mockAuthStates[context.globals.mockAuthState]?.session; - } - })(); - - return ( - - - - ); -}; - const i18n = createI18n(); const withI18n: Decorator = (Story, context) => { const locale = context.globals.locale; @@ -198,7 +149,6 @@ const withPlatformSelectionDecorator: Decorator = (Story, context) => { const decorators = [ withContextDecorator, withI18n, - withMockAuth, withPlatformSelectionDecorator, ]; diff --git a/tests/storybook/package.json b/tests/storybook/package.json index 984bb28bab..b26a1f0f82 100644 --- a/tests/storybook/package.json +++ b/tests/storybook/package.json @@ -40,7 +40,6 @@ "@storybook/react": "^7.6.17", "@storybook/react-vite": "^7.6.17", "@storybook/test-runner": "^0.16.0", - "@tomfreudenberg/next-auth-mock": "^0.5.6", "@vanilla-extract/esbuild-plugin": "^2.3.5", "@vitejs/plugin-react": "^4.2.1", "chromatic": "^11.0.0", diff --git a/yarn.lock b/yarn.lock index 2f9d5ef3bf..5334f74a27 100644 --- a/yarn.lock +++ b/yarn.lock @@ -374,7 +374,6 @@ __metadata: mime-types: "npm:^2.1.35" mini-css-extract-plugin: "npm:^2.8.0" nanoid: "npm:^5.0.6" - next-auth: "npm:^4.24.5" next-themes: "npm:^0.2.1" postcss-loader: "npm:^8.1.0" raw-loader: "npm:^4.0.2" @@ -696,7 +695,6 @@ __metadata: nanoid: "npm:^5.0.6" nest-commander: "npm:^3.12.5" nestjs-throttler-storage-redis: "npm:^0.4.1" - next-auth: "npm:^4.24.5" nodemailer: "npm:^6.9.10" nodemon: "npm:^3.1.0" on-headers: "npm:^1.0.2" @@ -761,7 +759,6 @@ __metadata: "@storybook/react-vite": "npm:^7.6.17" "@storybook/test-runner": "npm:^0.16.0" "@storybook/testing-library": "npm:^0.2.2" - "@tomfreudenberg/next-auth-mock": "npm:^0.5.6" "@vanilla-extract/esbuild-plugin": "npm:^2.3.5" "@vitejs/plugin-react": "npm:^4.2.1" chromatic: "npm:^11.0.0" @@ -820,7 +817,6 @@ __metadata: is-svg: "npm:^5.0.0" lodash-es: "npm:^4.17.21" nanoid: "npm:^5.0.6" - next-auth: "npm:^4.24.5" socket.io-client: "npm:^4.7.4" vitest: "npm:1.3.1" ws: "npm:^8.16.0" @@ -3358,7 +3354,7 @@ __metadata: languageName: node linkType: hard -"@babel/runtime@npm:^7.0.0, @babel/runtime@npm:^7.10.2, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.13.10, @babel/runtime@npm:^7.16.7, @babel/runtime@npm:^7.17.8, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.20.13, @babel/runtime@npm:^7.20.6, @babel/runtime@npm:^7.21.0, @babel/runtime@npm:^7.22.6, @babel/runtime@npm:^7.23.2, @babel/runtime@npm:^7.23.9, @babel/runtime@npm:^7.7.2, @babel/runtime@npm:^7.7.6, @babel/runtime@npm:^7.8.4, @babel/runtime@npm:^7.9.2": +"@babel/runtime@npm:^7.0.0, @babel/runtime@npm:^7.10.2, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.13.10, @babel/runtime@npm:^7.16.7, @babel/runtime@npm:^7.17.8, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.20.6, @babel/runtime@npm:^7.21.0, @babel/runtime@npm:^7.22.6, @babel/runtime@npm:^7.23.2, @babel/runtime@npm:^7.23.9, @babel/runtime@npm:^7.7.2, @babel/runtime@npm:^7.7.6, @babel/runtime@npm:^7.8.4, @babel/runtime@npm:^7.9.2": version: 7.23.9 resolution: "@babel/runtime@npm:7.23.9" dependencies: @@ -9346,7 +9342,7 @@ __metadata: languageName: node linkType: hard -"@panva/hkdf@npm:^1.0.2, @panva/hkdf@npm:^1.1.1": +"@panva/hkdf@npm:^1.1.1": version: 1.1.1 resolution: "@panva/hkdf@npm:1.1.1" checksum: 10/f0dd12903751d8792420353f809ed3c7de860cf506399759fff5f59f7acfef8a77e2b64012898cee7e5b047708fa0bd91dff5ef55a502bf8ea11aad9842160da @@ -13334,16 +13330,6 @@ __metadata: languageName: node linkType: hard -"@tomfreudenberg/next-auth-mock@npm:^0.5.6": - version: 0.5.6 - resolution: "@tomfreudenberg/next-auth-mock@npm:0.5.6" - peerDependencies: - next-auth: ^4.12.3 - react: ^18 - checksum: 10/50396706be6f3e806d130df3945dce4233504782f0f16fd6d255d54ef21ae713b9eedf3a93155de29f92f59d3592bd540a60a15edfffa4b6306c7a3c786aaae2 - languageName: node - linkType: hard - "@tootallnate/once@npm:2": version: 2.0.0 resolution: "@tootallnate/once@npm:2.0.0" @@ -24702,13 +24688,6 @@ __metadata: languageName: node linkType: hard -"jose@npm:^4.11.4, jose@npm:^4.15.1": - version: 4.15.4 - resolution: "jose@npm:4.15.4" - checksum: 10/20fa941597150dffc7af3f41d994500cc3e71cd650b755243dbd80d91cf26c1053f95b78af588f05cfc4371e492a67c5c7a48f689b8605145a8fe28b484d725b - languageName: node - linkType: hard - "jose@npm:^5.0.0, jose@npm:^5.1.3": version: 5.2.2 resolution: "jose@npm:5.2.2" @@ -27654,56 +27633,6 @@ __metadata: languageName: node linkType: hard -"next-auth@npm:4.24.5": - version: 4.24.5 - resolution: "next-auth@npm:4.24.5" - dependencies: - "@babel/runtime": "npm:^7.20.13" - "@panva/hkdf": "npm:^1.0.2" - cookie: "npm:^0.5.0" - jose: "npm:^4.11.4" - oauth: "npm:^0.9.15" - openid-client: "npm:^5.4.0" - preact: "npm:^10.6.3" - preact-render-to-string: "npm:^5.1.19" - uuid: "npm:^8.3.2" - peerDependencies: - next: ^12.2.5 || ^13 || ^14 - nodemailer: ^6.6.5 - react: ^17.0.2 || ^18 - react-dom: ^17.0.2 || ^18 - peerDependenciesMeta: - nodemailer: - optional: true - checksum: 10/c9256deaa7a77741be2c8829c290c43c63fd8fa86ace3196910d3fa4389c101d6a610f3c5f4b55000e766a51dd89eafc9b5cd876e373884db3bf90122fdfa6a1 - languageName: node - linkType: hard - -"next-auth@patch:next-auth@npm%3A4.24.5#~/.yarn/patches/next-auth-npm-4.24.5-8428e11927.patch": - version: 4.24.5 - resolution: "next-auth@patch:next-auth@npm%3A4.24.5#~/.yarn/patches/next-auth-npm-4.24.5-8428e11927.patch::version=4.24.5&hash=9af7e1" - dependencies: - "@babel/runtime": "npm:^7.20.13" - "@panva/hkdf": "npm:^1.0.2" - cookie: "npm:^0.5.0" - jose: "npm:^4.11.4" - oauth: "npm:^0.9.15" - openid-client: "npm:^5.4.0" - preact: "npm:^10.6.3" - preact-render-to-string: "npm:^5.1.19" - uuid: "npm:^8.3.2" - peerDependencies: - next: ^12.2.5 || ^13 || ^14 - nodemailer: ^6.6.5 - react: ^17.0.2 || ^18 - react-dom: ^17.0.2 || ^18 - peerDependenciesMeta: - nodemailer: - optional: true - checksum: 10/15f251a6e31c79459bce7a2d638c6069c34b5e92effdae8d7b2c366bbe2d1e1916da6ed5bc7995c1926dd35442552deb33959ee4bd45bbab0347455c13448d4b - languageName: node - linkType: hard - "next-themes@npm:^0.2.1": version: 0.2.1 resolution: "next-themes@npm:0.2.1" @@ -28257,13 +28186,6 @@ __metadata: languageName: node linkType: hard -"oauth@npm:^0.9.15": - version: 0.9.15 - resolution: "oauth@npm:0.9.15" - checksum: 10/6b0b10be19a461da417a37ea2821a773ef74dd667563291e1e83b2024b88e6571b0323a0a6887f2390fbaf28cc6ce5bfe0484fc22162b975305b1e19b76f5597 - languageName: node - linkType: hard - "object-assign@npm:^4, object-assign@npm:^4.1.0, object-assign@npm:^4.1.1": version: 4.1.1 resolution: "object-assign@npm:4.1.1" @@ -28278,13 +28200,6 @@ __metadata: languageName: node linkType: hard -"object-hash@npm:^2.2.0": - version: 2.2.0 - resolution: "object-hash@npm:2.2.0" - checksum: 10/dee06b6271bf5769ae5f1a7386fdd52c1f18aae9fcb0b8d4bb1232f2d743d06cb5b662be42378b60a1c11829f96f3f86834a16bbaa57a085763295fff8b93e27 - languageName: node - linkType: hard - "object-is@npm:@nolyfill/object-is@latest": version: 1.0.24 resolution: "@nolyfill/object-is@npm:1.0.24" @@ -28362,13 +28277,6 @@ __metadata: languageName: node linkType: hard -"oidc-token-hash@npm:^5.0.3": - version: 5.0.3 - resolution: "oidc-token-hash@npm:5.0.3" - checksum: 10/35fa19aea9ff2c509029ec569d74b778c8a215b92bd5e6e9bc4ebbd7ab035f44304ff02430a6397c3fb7c1d15ebfa467807ca0bcd31d06ba610b47798287d303 - languageName: node - linkType: hard - "on-finished@npm:2.4.1": version: 2.4.1 resolution: "on-finished@npm:2.4.1" @@ -28454,18 +28362,6 @@ __metadata: languageName: node linkType: hard -"openid-client@npm:^5.4.0": - version: 5.6.1 - resolution: "openid-client@npm:5.6.1" - dependencies: - jose: "npm:^4.15.1" - lru-cache: "npm:^6.0.0" - object-hash: "npm:^2.2.0" - oidc-token-hash: "npm:^5.0.3" - checksum: 10/8f2485438048def1bab680a634fd4ebb85bfb0d6a12d6490ef7a0f8189688db1920fff831ed23e70f59bc15d51ba6a33fca1313f0fba28b162c61e81c7e0649c - languageName: node - linkType: hard - "optionator@npm:^0.9.3": version: 0.9.3 resolution: "optionator@npm:0.9.3" @@ -29710,17 +29606,6 @@ __metadata: languageName: node linkType: hard -"preact-render-to-string@npm:^5.1.19": - version: 5.2.6 - resolution: "preact-render-to-string@npm:5.2.6" - dependencies: - pretty-format: "npm:^3.8.0" - peerDependencies: - preact: ">=10" - checksum: 10/356519f7640d1c49e11b4837b41a83b307f3f237f93de153b9dde833a701e3ce5cf1d45cb18e37a3ec9c568555e2f5373c128d8b5f6ef79de7658f3c400d3e70 - languageName: node - linkType: hard - "preact@npm:10.11.3": version: 10.11.3 resolution: "preact@npm:10.11.3" @@ -29728,13 +29613,6 @@ __metadata: languageName: node linkType: hard -"preact@npm:^10.6.3": - version: 10.19.2 - resolution: "preact@npm:10.19.2" - checksum: 10/1519050e79f0dec61aa85daa5dcba4a5294e89fb09ab53d5e1a215ef8526dd5ccdbe82a02842cc4875fa3ea076eee9697a7421c32ffcc6159007d27b13a60a8f - languageName: node - linkType: hard - "prelude-ls@npm:^1.2.1": version: 1.2.1 resolution: "prelude-ls@npm:1.2.1"