From 1b550e7e48c3e2a846f4d562019f20fd4387576c Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Fri, 9 Aug 2024 22:31:39 -0700 Subject: [PATCH] TOTP 2FA endpoints --- .vscode/settings.json | 1 + apps/backend/package.json | 1 + .../migration.sql | 16 + apps/backend/prisma/schema.prisma | 6 +- .../src/app/api/v1/auth/mfa/sign-in/route.tsx | 3 + .../mfa/sign-in/verification-code-handler.tsx | 89 +++++ .../oauth/callback/[provider_id]/route.tsx | 94 +++-- .../otp/sign-in/verification-code-handler.tsx | 21 + .../reset/verification-code-handler.tsx | 5 +- .../api/v1/auth/password/sign-in/route.tsx | 9 + .../api/v1/auth/password/sign-up/route.tsx | 9 + .../verify/verification-code-handler.tsx | 3 + .../accept/verification-code-handler.tsx | 3 + apps/backend/src/app/api/v1/users/crud.tsx | 5 + .../verification-code-handler.tsx | 66 ++-- apps/dashboard/prisma/schema.prisma | 8 +- apps/e2e/package.json | 6 +- apps/e2e/tests/backend/backend-helpers.ts | 23 ++ .../endpoints/api/v1/auth/mfa/sign-in.test.ts | 140 +++++++ .../api/v1/auth/oauth/callback.test.ts | 30 ++ .../endpoints/api/v1/auth/oauth/token.test.ts | 1 + .../endpoints/api/v1/auth/otp/sign-in.test.ts | 34 ++ .../api/v1/auth/password/sign-in.test.ts | 29 ++ .../endpoints/api/v1/team-memberships.test.ts | 3 + .../backend/endpoints/api/v1/users.test.ts | 60 ++- apps/e2e/tests/snapshot-serializer.ts | 1 + .../src/interface/crud/current-user.ts | 2 + .../stack-shared/src/interface/crud/users.ts | 5 + packages/stack-shared/src/known-errors.tsx | 26 ++ packages/stack-shared/src/schema-fields.ts | 5 + packages/stack-shared/src/utils/bytes.tsx | 46 +++ packages/stack/package.json | 1 + pnpm-lock.yaml | 366 ++++++++++++++++++ 33 files changed, 1050 insertions(+), 67 deletions(-) create mode 100644 apps/backend/prisma/migrations/20240810052738_multi_factor_authentication/migration.sql create mode 100644 apps/backend/src/app/api/v1/auth/mfa/sign-in/route.tsx create mode 100644 apps/backend/src/app/api/v1/auth/mfa/sign-in/verification-code-handler.tsx create mode 100644 apps/e2e/tests/backend/endpoints/api/v1/auth/mfa/sign-in.test.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index e84dda767..013d3bac3 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -33,6 +33,7 @@ "reqs", "stackframe", "Svix", + "totp", "typecheck", "typehack", "Uncapitalize", diff --git a/apps/backend/package.json b/apps/backend/package.json index c1da609bf..85d051e3e 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -37,6 +37,7 @@ "next": "^14.1", "nodemailer": "^6.9.10", "openid-client": "^5.6.4", + "oslo": "^1.2.1", "pg": "^8.11.3", "posthog-js": "^1.149.1", "react": "^18.2", diff --git a/apps/backend/prisma/migrations/20240810052738_multi_factor_authentication/migration.sql b/apps/backend/prisma/migrations/20240810052738_multi_factor_authentication/migration.sql new file mode 100644 index 000000000..c757c66ae --- /dev/null +++ b/apps/backend/prisma/migrations/20240810052738_multi_factor_authentication/migration.sql @@ -0,0 +1,16 @@ +/* + Warnings: + + - You are about to drop the column `email` on the `VerificationCode` table. All the data in the column will be lost. + +*/ +-- AlterEnum +ALTER TYPE "VerificationCodeType" ADD VALUE 'MFA_ATTEMPT'; + +-- AlterTable +ALTER TABLE "ProjectUser" ADD COLUMN "requiresTotpMfa" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "totpSecret" BYTEA; + +-- AlterTable +ALTER TABLE "VerificationCode" DROP COLUMN "email", +ADD COLUMN "method" JSONB NOT NULL DEFAULT 'null'; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index d0445e654..e64c6a4d7 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -239,6 +239,9 @@ model ProjectUser { passwordHash String? authWithEmail Boolean + requiresTotpMfa Boolean @default(false) + totpSecret Bytes? + serverMetadata Json? clientMetadata Json? @@ -359,7 +362,7 @@ model VerificationCode { usedAt DateTime? redirectUrl String? - email String + method Json @default("null") data Json @@ -372,6 +375,7 @@ enum VerificationCodeType { PASSWORD_RESET CONTACT_CHANNEL_VERIFICATION TEAM_INVITATION + MFA_ATTEMPT } // @deprecated diff --git a/apps/backend/src/app/api/v1/auth/mfa/sign-in/route.tsx b/apps/backend/src/app/api/v1/auth/mfa/sign-in/route.tsx new file mode 100644 index 000000000..69e9d8ca6 --- /dev/null +++ b/apps/backend/src/app/api/v1/auth/mfa/sign-in/route.tsx @@ -0,0 +1,3 @@ +import { mfaVerificationCodeHandler } from "./verification-code-handler"; + +export const POST = mfaVerificationCodeHandler.postHandler; diff --git a/apps/backend/src/app/api/v1/auth/mfa/sign-in/verification-code-handler.tsx b/apps/backend/src/app/api/v1/auth/mfa/sign-in/verification-code-handler.tsx new file mode 100644 index 000000000..b1e1b92f2 --- /dev/null +++ b/apps/backend/src/app/api/v1/auth/mfa/sign-in/verification-code-handler.tsx @@ -0,0 +1,89 @@ +import { yupObject, yupString, yupNumber, yupBoolean } from "@stackframe/stack-shared/dist/schema-fields"; +import { prismaClient } from "@/prisma-client"; +import { createAuthTokens } from "@/lib/tokens"; +import { createVerificationCodeHandler } from "@/route-handlers/verification-code-handler"; +import { signInResponseSchema } from "@stackframe/stack-shared/dist/schema-fields"; +import { VerificationCodeType } from "@prisma/client"; +import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; +import { TOTPController } from "oslo/otp"; +import { KnownErrors } from "@stackframe/stack-shared"; +import { ProjectsCrud } from "@stackframe/stack-shared/dist/interface/crud/projects"; + +export const mfaVerificationCodeHandler = createVerificationCodeHandler({ + metadata: { + post: { + summary: "MFA sign in", + description: "Complete multi-factor authorization to sign in, with a TOTP and an MFA attempt code", + tags: ["OTP"], + }, + check: { + summary: "Verify MFA", + description: "Check if the MFA attempt is valid without using it", + tags: ["OTP"], + } + }, + type: VerificationCodeType.ONE_TIME_PASSWORD, + data: yupObject({ + user_id: yupString().required(), + is_new_user: yupBoolean().required(), + }), + method: yupObject({}), + requestBody: yupObject({ + type: yupString().oneOf(["totp"]).required(), + totp: yupString().required(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).required(), + bodyType: yupString().oneOf(["json"]).required(), + body: signInResponseSchema.required(), + }), + async validate(project, method, data, body) { + const user = await prismaClient.projectUser.findUniqueOrThrow({ + where: { + projectId_projectUserId: { + projectId: project.id, + projectUserId: data.user_id, + }, + }, + }); + const totpSecret = user.totpSecret; + if (!totpSecret) { + throw new StackAssertionError("User does not have a TOTP secret", { user }); + } + const isTotpValid = await new TOTPController().verify(body.totp, totpSecret); + if (!isTotpValid) { + throw new KnownErrors.InvalidTotpCode(); + } + }, + async handler(project, {}, data, body) { + const { refreshToken, accessToken } = await createAuthTokens({ + projectId: project.id, + projectUserId: data.user_id, + }); + + return { + statusCode: 200, + bodyType: "json", + body: { + refresh_token: refreshToken, + access_token: accessToken, + is_new_user: data.is_new_user, + user_id: data.user_id, + }, + }; + }, +}); + +export async function createMfaRequiredError(options: { project: ProjectsCrud["Admin"]["Read"], isNewUser: boolean, userId: string }) { + const attemptCode = await mfaVerificationCodeHandler.createCode({ + expiresInMs: 1000 * 60 * 5, + project: options.project, + data: { + user_id: options.userId, + is_new_user: options.isNewUser, + }, + method: {}, + callbackUrl: undefined, + }); + return new KnownErrors.MultiFactorAuthenticationRequired(attemptCode.code); +} diff --git a/apps/backend/src/app/api/v1/auth/oauth/callback/[provider_id]/route.tsx b/apps/backend/src/app/api/v1/auth/oauth/callback/[provider_id]/route.tsx index 17d0e5907..f97a975cb 100644 --- a/apps/backend/src/app/api/v1/auth/oauth/callback/[provider_id]/route.tsx +++ b/apps/backend/src/app/api/v1/auth/oauth/callback/[provider_id]/route.tsx @@ -14,6 +14,7 @@ import { extractScopes } from "@stackframe/stack-shared/dist/utils/strings"; import { cookies } from "next/headers"; import { redirect } from "next/navigation"; import { oauthResponseToSmartResponse } from "../../oauth-helpers"; +import { createMfaRequiredError } from "../../../mfa/sign-in/verification-code-handler"; const redirectOrThrowError = (error: KnownError, project: ProjectsCrud["Admin"]["Read"], errorRedirectUrl?: string) => { if (!errorRedirectUrl || !validateRedirectUrl(errorRedirectUrl, project.config.domains, project.config.allow_localhost)) { @@ -231,46 +232,73 @@ export const GET = createSmartRouteHandler({ newUser: false, afterCallbackRedirectUrl, }; - } + } else { - // ========================== sign in user ========================== + // ========================== sign in user ========================== + + if (oldAccount) { + await storeTokens(); + + const projectUser = await prismaClient.projectUser.findUniqueOrThrow({ + where: { + projectId_projectUserId: { + projectId: outerInfo.projectId, + projectUserId: oldAccount.projectUserId, + }, + }, + }); + + if (projectUser.requiresTotpMfa) { + throw await createMfaRequiredError({ + project, + userId: projectUser.projectUserId, + isNewUser: false, + }); + } + + return { + id: oldAccount.projectUserId, + newUser: false, + afterCallbackRedirectUrl, + }; + } + + // ========================== sign up user ========================== + + if (!project.config.sign_up_enabled) { + throw new KnownErrors.SignUpNotEnabled(); + } + const newAccount = await usersCrudHandlers.adminCreate({ + project, + data: { + display_name: userInfo.displayName, + profile_image_url: userInfo.profileImageUrl || undefined, + primary_email: userInfo.email, + primary_email_verified: false, // TODO: check if email is verified with the provider + primary_email_auth_enabled: false, + oauth_providers: [{ + id: provider.id, + account_id: userInfo.accountId, + email: userInfo.email, + }], + }, + }); + + if (newAccount.requires_totp_mfa) { + throw await createMfaRequiredError({ + project, + userId: newAccount.id, + isNewUser: true, + }); + } - if (oldAccount) { await storeTokens(); - return { - id: oldAccount.projectUserId, - newUser: false, + id: newAccount.id, + newUser: true, afterCallbackRedirectUrl, }; } - - // ========================== sign up user ========================== - - if (!project.config.sign_up_enabled) { - throw new KnownErrors.SignUpNotEnabled(); - } - const newAccount = await usersCrudHandlers.adminCreate({ - project, - data: { - display_name: userInfo.displayName, - profile_image_url: userInfo.profileImageUrl || undefined, - primary_email: userInfo.email, - primary_email_verified: false, // TODO: check if email is verified with the provider - primary_email_auth_enabled: false, - oauth_providers: [{ - id: provider.id, - account_id: userInfo.accountId, - email: userInfo.email, - }], - }, - }); - await storeTokens(); - return { - id: newAccount.id, - newUser: true, - afterCallbackRedirectUrl, - }; } } } diff --git a/apps/backend/src/app/api/v1/auth/otp/sign-in/verification-code-handler.tsx b/apps/backend/src/app/api/v1/auth/otp/sign-in/verification-code-handler.tsx index 0181b0107..0eca0f06c 100644 --- a/apps/backend/src/app/api/v1/auth/otp/sign-in/verification-code-handler.tsx +++ b/apps/backend/src/app/api/v1/auth/otp/sign-in/verification-code-handler.tsx @@ -7,6 +7,8 @@ import { signInResponseSchema } from "@stackframe/stack-shared/dist/schema-field import { VerificationCodeType } from "@prisma/client"; import { UsersCrud } from "@stackframe/stack-shared/dist/interface/crud/users"; import { sendEmailFromTemplate } from "@/lib/emails"; +import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; +import { createMfaRequiredError } from "../../mfa/sign-in/verification-code-handler"; export const signInVerificationCodeHandler = createVerificationCodeHandler({ metadata: { @@ -26,6 +28,9 @@ export const signInVerificationCodeHandler = createVerificationCodeHandler({ user_id: yupString().required(), is_new_user: yupBoolean().required(), }), + method: yupObject({ + email: yupString().email().required(), + }), response: yupObject({ statusCode: yupNumber().oneOf([200]).required(), bodyType: yupString().oneOf(["json"]).required(), @@ -45,6 +50,22 @@ export const signInVerificationCodeHandler = createVerificationCodeHandler({ }); }, async handler(project, { email }, data) { + const projectUserBefore = await prismaClient.projectUser.findUniqueOrThrow({ + where: { + projectId_projectUserId: { + projectId: project.id, + projectUserId: data.user_id, + }, + }, + }); + if (projectUserBefore.requiresTotpMfa) { + throw await createMfaRequiredError({ + project, + isNewUser: data.is_new_user, + userId: projectUserBefore.projectUserId, + }); + } + const projectUser = await prismaClient.projectUser.update({ where: { projectId_projectUserId: { diff --git a/apps/backend/src/app/api/v1/auth/password/reset/verification-code-handler.tsx b/apps/backend/src/app/api/v1/auth/password/reset/verification-code-handler.tsx index 21f867837..d149cd06b 100644 --- a/apps/backend/src/app/api/v1/auth/password/reset/verification-code-handler.tsx +++ b/apps/backend/src/app/api/v1/auth/password/reset/verification-code-handler.tsx @@ -1,8 +1,6 @@ import { yupObject, yupString, yupNumber, yupBoolean } from "@stackframe/stack-shared/dist/schema-fields"; import { prismaClient } from "@/prisma-client"; -import { createAuthTokens } from "@/lib/tokens"; import { createVerificationCodeHandler } from "@/route-handlers/verification-code-handler"; -import { signInResponseSchema } from "@stackframe/stack-shared/dist/schema-fields"; import { VerificationCodeType } from "@prisma/client"; import { UsersCrud } from "@stackframe/stack-shared/dist/interface/crud/users"; import { sendEmailFromTemplate } from "@/lib/emails"; @@ -27,6 +25,9 @@ export const resetPasswordVerificationCodeHandler = createVerificationCodeHandle data: yupObject({ user_id: yupString().required(), }), + method: yupObject({ + email: yupString().email().required(), + }), requestBody: yupObject({ password: yupString().required(), }).required(), diff --git a/apps/backend/src/app/api/v1/auth/password/sign-in/route.tsx b/apps/backend/src/app/api/v1/auth/password/sign-in/route.tsx index 51d6ad4e1..ade7454c1 100644 --- a/apps/backend/src/app/api/v1/auth/password/sign-in/route.tsx +++ b/apps/backend/src/app/api/v1/auth/password/sign-in/route.tsx @@ -5,6 +5,7 @@ import { KnownErrors } from "@stackframe/stack-shared"; import { adaptSchema, clientOrHigherAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; import { comparePassword } from "@stackframe/stack-shared/dist/utils/password"; +import { createMfaRequiredError } from "../../mfa/sign-in/verification-code-handler"; export const POST = createSmartRouteHandler({ metadata: { @@ -56,6 +57,14 @@ export const POST = createSmartRouteHandler({ throw new StackAssertionError("This should never happen (the comparePassword call should've already caused this to fail)"); } + if (user.requiresTotpMfa) { + throw await createMfaRequiredError({ + project, + isNewUser: false, + userId: user.projectUserId, + }); + } + const { refreshToken, accessToken } = await createAuthTokens({ projectId: project.id, projectUserId: user.projectUserId, diff --git a/apps/backend/src/app/api/v1/auth/password/sign-up/route.tsx b/apps/backend/src/app/api/v1/auth/password/sign-up/route.tsx index 16c5868b9..0c022dc5b 100644 --- a/apps/backend/src/app/api/v1/auth/password/sign-up/route.tsx +++ b/apps/backend/src/app/api/v1/auth/password/sign-up/route.tsx @@ -8,6 +8,7 @@ import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; import { KnownErrors } from "@stackframe/stack-shared"; import { usersCrudHandlers } from "../../../users/crud"; import { contactChannelVerificationCodeHandler } from "../../../contact-channels/verify/verification-code-handler"; +import { createMfaRequiredError } from "../../mfa/sign-in/verification-code-handler"; export const POST = createSmartRouteHandler({ metadata: { @@ -73,6 +74,14 @@ export const POST = createSmartRouteHandler({ user: createdUser, }); + if (createdUser.requires_totp_mfa) { + throw await createMfaRequiredError({ + project, + isNewUser: true, + userId: createdUser.id, + }); + } + const { refreshToken, accessToken } = await createAuthTokens({ projectId: project.id, projectUserId: createdUser.id, diff --git a/apps/backend/src/app/api/v1/contact-channels/verify/verification-code-handler.tsx b/apps/backend/src/app/api/v1/contact-channels/verify/verification-code-handler.tsx index 923f6279a..14463cd32 100644 --- a/apps/backend/src/app/api/v1/contact-channels/verify/verification-code-handler.tsx +++ b/apps/backend/src/app/api/v1/contact-channels/verify/verification-code-handler.tsx @@ -23,6 +23,9 @@ export const contactChannelVerificationCodeHandler = createVerificationCodeHandl data: yupObject({ user_id: yupString().required(), }).required(), + method: yupObject({ + email: yupString().email().required(), + }), response: yupObject({ statusCode: yupNumber().oneOf([200]).required(), bodyType: yupString().oneOf(["success"]).required(), diff --git a/apps/backend/src/app/api/v1/team-invitations/accept/verification-code-handler.tsx b/apps/backend/src/app/api/v1/team-invitations/accept/verification-code-handler.tsx index 923a0287c..335eaf476 100644 --- a/apps/backend/src/app/api/v1/team-invitations/accept/verification-code-handler.tsx +++ b/apps/backend/src/app/api/v1/team-invitations/accept/verification-code-handler.tsx @@ -26,6 +26,9 @@ export const teamInvitationCodeHandler = createVerificationCodeHandler({ data: yupObject({ team_id: yupString().required(), }).required(), + method: yupObject({ + email: yupString().email().required(), + }), response: yupObject({ statusCode: yupNumber().oneOf([200]).required(), bodyType: yupString().oneOf(["json"]).required(), diff --git a/apps/backend/src/app/api/v1/users/crud.tsx b/apps/backend/src/app/api/v1/users/crud.tsx index a5fed2eb1..b29030d24 100644 --- a/apps/backend/src/app/api/v1/users/crud.tsx +++ b/apps/backend/src/app/api/v1/users/crud.tsx @@ -12,6 +12,7 @@ import { createLazyProxy } from "@stackframe/stack-shared/dist/utils/proxies"; import { teamPrismaToCrud } from "../teams/crud"; import { sendUserCreatedWebhook, sendUserDeletedWebhook, sendUserUpdatedWebhook } from "@/lib/webhooks"; import { getPasswordError } from "@stackframe/stack-shared/dist/helpers/password"; +import { decodeBase64, encodeBase64 } from "@stackframe/stack-shared/dist/utils/bytes"; const fullInclude = { projectUserOAuthAccounts: { @@ -83,6 +84,7 @@ const prismaToCrud = (prisma: Prisma.ProjectUserGetPayload<{ include: typeof ful server_metadata: prisma.serverMetadata, has_password: !!prisma.passwordHash, auth_with_email: prisma.authWithEmail, + requires_totp_mfa: prisma.requiresTotpMfa, oauth_providers: prisma.projectUserOAuthAccounts.map((a) => ({ id: a.oauthProviderConfigId, account_id: a.providerAccountId, @@ -183,6 +185,7 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC })) } } : undefined, + totpSecret: data.totp_secret_base64 == null ? data.totp_secret_base64 : Buffer.from(decodeBase64(data.totp_secret_base64)), }, include: fullInclude, }); @@ -251,6 +254,8 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC authWithEmail: data.primary_email_auth_enabled, passwordHash: data.password == null ? data.password : await hashPassword(data.password), profileImageUrl: data.profile_image_url, + requiresTotpMfa: data.totp_secret_base64 === undefined ? undefined : (data.totp_secret_base64 !== null), + totpSecret: data.totp_secret_base64 == null ? data.totp_secret_base64 : Buffer.from(decodeBase64(data.totp_secret_base64)), }, include: fullInclude, }); diff --git a/apps/backend/src/route-handlers/verification-code-handler.tsx b/apps/backend/src/route-handlers/verification-code-handler.tsx index 1bda01d73..083d15eea 100644 --- a/apps/backend/src/route-handlers/verification-code-handler.tsx +++ b/apps/backend/src/route-handlers/verification-code-handler.tsx @@ -13,11 +13,7 @@ import { DeepPartial } from "@stackframe/stack-shared/dist/utils/objects"; import { ProjectsCrud } from "@stackframe/stack-shared/dist/interface/crud/projects"; import { UsersCrud } from "@stackframe/stack-shared/dist/interface/crud/users"; -type Method = { - email: string, -}; - -type CreateCodeOptions = { +type CreateCodeOptions = { project: ProjectsCrud["Admin"]["Read"], method: Method, expiresInMs?: number, @@ -25,15 +21,15 @@ type CreateCodeOptions = callbackUrl: CallbackUrl, }; -type CodeObject = { +type CodeObject = { code: string, - link: URL, + link: CallbackUrl extends string | URL ? URL : undefined, expiresAt: Date, }; -type VerificationCodeHandler = { - createCode(options: CreateCodeOptions): Promise, - sendCode(options: CreateCodeOptions, sendOptions: SendCodeExtraOptions): Promise, +type VerificationCodeHandler = { + createCode(options: CreateCodeOptions): Promise>, + sendCode(options: CreateCodeOptions, sendOptions: SendCodeExtraOptions): Promise, postHandler: SmartRouteHandler, checkHandler: SmartRouteHandler, detailsHandler: HasDetails extends true ? SmartRouteHandler : undefined, @@ -49,6 +45,7 @@ export function createVerificationCodeHandler< DetailsResponse extends SmartResponse | undefined, UserRequired extends boolean, SendCodeExtraOptions extends {}, + Method extends {}, >(options: { metadata?: { post?: SmartRouteHandlerOverloadMetadata, @@ -57,15 +54,23 @@ export function createVerificationCodeHandler< }, type: VerificationCodeType, data: yup.Schema, + method: yup.Schema, requestBody?: yup.ObjectSchema, userRequired?: UserRequired, detailsResponse?: yup.Schema, response: yup.Schema, - send( - codeObject: CodeObject, - createOptions: CreateCodeOptions, + send?( + codeObject: CodeObject, + createOptions: CreateCodeOptions, sendOptions: SendCodeExtraOptions, ): Promise, + validate?( + project: ProjectsCrud["Admin"]["Read"], + method: Method, + data: Data, + body: RequestBody, + user: UserRequired extends true ? UsersCrud["Admin"]["Read"] : undefined + ): Promise, handler( project: ProjectsCrud["Admin"]["Read"], method: Method, @@ -80,7 +85,7 @@ export function createVerificationCodeHandler< body: RequestBody, user: UserRequired extends true ? UsersCrud["Admin"]["Read"] : undefined ) => Promise) : undefined, -}): VerificationCodeHandler { +}): VerificationCodeHandler { const createHandler = (type: 'post' | 'check' | 'details') => createSmartRouteHandler({ metadata: options.metadata?.[type], request: yupObject({ @@ -119,10 +124,17 @@ export function createVerificationCodeHandler< if (verificationCode.expiresAt < new Date()) throw new KnownErrors.VerificationCodeExpired(); if (verificationCode.usedAt) throw new KnownErrors.VerificationCodeAlreadyUsed(); + const validatedMethod = await options.method.validate(verificationCode.method, { + strict: true, + }); const validatedData = await options.data.validate(verificationCode.data, { strict: true, }); + if (options.validate) { + await options.validate(auth.project, validatedMethod, validatedData, requestBody as any, auth.user as any); + } + switch (type) { case 'post': { await prismaClient.verificationCode.update({ @@ -137,7 +149,7 @@ export function createVerificationCodeHandler< }, }); - return await options.handler(auth.project, { email: verificationCode.email }, validatedData as any, requestBody as any, auth.user as any); + return await options.handler(auth.project, validatedMethod, validatedData, requestBody as any, auth.user as any); } case 'check': { return { @@ -149,7 +161,7 @@ export function createVerificationCodeHandler< }; } case 'details': { - return await options.details?.(auth.project, { email: verificationCode.email }, validatedData as any, requestBody as any, auth.user as any) as any; + return await options.details?.(auth.project, validatedMethod, validatedData, requestBody as any, auth.user as any) as any; } } }, @@ -157,15 +169,11 @@ export function createVerificationCodeHandler< return { async createCode({ project, method, data, callbackUrl, expiresInMs }) { - if (!method.email) { - throw new StackAssertionError("No method specified"); - } - const validatedData = await options.data.validate(data, { strict: true, }); - if (!validateRedirectUrl( + if (callbackUrl !== undefined && !validateRedirectUrl( callbackUrl, project.config.domains, project.config.allow_localhost, @@ -178,15 +186,18 @@ export function createVerificationCodeHandler< projectId: project.id, type: options.type, code: generateSecureRandomString(), - redirectUrl: callbackUrl.toString(), + redirectUrl: callbackUrl?.toString(), expiresAt: new Date(Date.now() + (expiresInMs ?? 1000 * 60 * 60 * 24 * 7)), // default: expire after 7 days data: validatedData as any, - email: method.email, + method: method, } }); - const link = new URL(callbackUrl); - link.searchParams.set('code', verificationCodePrisma.code); + let link; + if (callbackUrl !== undefined) { + link = new URL(callbackUrl); + link.searchParams.set('code', verificationCodePrisma.code); + } return { code: verificationCodePrisma.code, @@ -195,10 +206,13 @@ export function createVerificationCodeHandler< }, async sendCode(createOptions, sendOptions) { const codeObj = await this.createCode(createOptions); + if (!options.send) { + throw new StackAssertionError("Cannot use sendCode on this verification code handler because it doesn't have a send function"); + } await options.send(codeObj, createOptions, sendOptions); }, postHandler: createHandler('post'), checkHandler: createHandler('check'), detailsHandler: (options.detailsResponse ? createHandler('details') : undefined) as any, }; -} \ No newline at end of file +} diff --git a/apps/dashboard/prisma/schema.prisma b/apps/dashboard/prisma/schema.prisma index d0445e654..df1b841f0 100644 --- a/apps/dashboard/prisma/schema.prisma +++ b/apps/dashboard/prisma/schema.prisma @@ -239,6 +239,9 @@ model ProjectUser { passwordHash String? authWithEmail Boolean + requiresTotpMfa Boolean @default(false) + totpSecret Bytes? + serverMetadata Json? clientMetadata Json? @@ -359,7 +362,9 @@ model VerificationCode { usedAt DateTime? redirectUrl String? - email String + // @deprecated in favor of method (TODO next-release; this is no longer used) + email String @default("") + method Json data Json @@ -372,6 +377,7 @@ enum VerificationCodeType { PASSWORD_RESET CONTACT_CHANNEL_VERIFICATION TEAM_INVITATION + MFA_ATTEMPT } // @deprecated diff --git a/apps/e2e/package.json b/apps/e2e/package.json index b19aa200f..63cd05b7d 100644 --- a/apps/e2e/package.json +++ b/apps/e2e/package.json @@ -11,8 +11,8 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@stackframe/stack-shared": "workspace:*", "dotenv": "^16.4.5", - "@stackframe/stack-shared": "workspace:*" - }, - "devDependencies": {} + "oslo": "^1.2.1" + } } diff --git a/apps/e2e/tests/backend/backend-helpers.ts b/apps/e2e/tests/backend/backend-helpers.ts index 0595f53bd..a717d3dcd 100644 --- a/apps/e2e/tests/backend/backend-helpers.ts +++ b/apps/e2e/tests/backend/backend-helpers.ts @@ -1,4 +1,5 @@ import { InternalProjectsCrud } from "@stackframe/stack-shared/dist/interface/crud/projects"; +import { encodeBase64 } from "@stackframe/stack-shared/dist/utils/bytes"; import { generateSecureRandomString } from "@stackframe/stack-shared/dist/utils/crypto"; import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import { filterUndefined } from "@stackframe/stack-shared/dist/utils/objects"; @@ -494,6 +495,28 @@ export namespace Auth { }; } } + + export namespace Mfa { + export async function setupTotpMfa() { + const totpSecretBytes = crypto.getRandomValues(new Uint8Array(20)); + const totpSecretBase64 = encodeBase64(totpSecretBytes); + const response = await niceBackendFetch("/api/v1/users/me", { + accessType: "client", + method: "PATCH", + body: { + totp_secret_base64: totpSecretBase64, + }, + }); + expect(response).toMatchObject({ + status: 200, + }); + + return { + setupTotpMfaResponse: response, + totpSecret: totpSecretBytes, + }; + } + } } export namespace ContactChannels { diff --git a/apps/e2e/tests/backend/endpoints/api/v1/auth/mfa/sign-in.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/auth/mfa/sign-in.test.ts new file mode 100644 index 000000000..8f467ad3b --- /dev/null +++ b/apps/e2e/tests/backend/endpoints/api/v1/auth/mfa/sign-in.test.ts @@ -0,0 +1,140 @@ +import { TOTPController } from "oslo/otp"; +import { it } from "../../../../../../helpers"; +import { Auth, backendContext, niceBackendFetch } from "../../../../../backend-helpers"; + +it("should sign in users with MFA enabled", async ({ expect }) => { + const passwordRes = await Auth.Password.signUpWithEmail(); + const { totpSecret } = await Auth.Mfa.setupTotpMfa(); + await Auth.signOut(); + const signInRes = await niceBackendFetch("/api/v1/auth/password/sign-in", { + method: "POST", + accessType: "client", + body: { + email: backendContext.value.mailbox.emailAddress, + password: passwordRes.password, + }, + }); + expect(signInRes).toMatchInlineSnapshot(` + NiceResponse { + "status": 400, + "body": { + "code": "MULTI_FACTOR_AUTHENTICATION_REQUIRED", + "details": { "attempt_code": }, + "error": "Multi-factor authentication is required for this user.", + }, + "headers": Headers { + "x-stack-known-error": "MULTI_FACTOR_AUTHENTICATION_REQUIRED", +