Skip to content

Commit

Permalink
Create users endpoint can now take a password hash
Browse files Browse the repository at this point in the history
  • Loading branch information
N2D4 committed Nov 20, 2024
1 parent 6a0ca75 commit 1f84ff5
Show file tree
Hide file tree
Showing 17 changed files with 211 additions and 57 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { VerificationCodeType } from "@prisma/client";
import { KnownErrors } from "@stackframe/stack-shared";
import { getPasswordError } from "@stackframe/stack-shared/dist/helpers/password";
import { UsersCrud } from "@stackframe/stack-shared/dist/interface/crud/users";
import { emailSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { emailSchema, passwordSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { usersCrudHandlers } from "../../../users/crud";

export const resetPasswordVerificationCodeHandler = createVerificationCodeHandler({
Expand All @@ -28,7 +28,7 @@ export const resetPasswordVerificationCodeHandler = createVerificationCodeHandle
email: emailSchema.defined(),
}),
requestBody: yupObject({
password: yupString().defined(),
password: passwordSchema.defined(),
}).defined(),
response: yupObject({
statusCode: yupNumber().oneOf([200]).defined(),
Expand Down
6 changes: 3 additions & 3 deletions apps/backend/src/app/api/v1/auth/password/set/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ import { prismaClient } from "@/prisma-client";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { KnownErrors } from "@stackframe/stack-shared";
import { getPasswordError } from "@stackframe/stack-shared/dist/helpers/password";
import { adaptSchema, clientOrHigherAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { adaptSchema, clientOrHigherAuthTypeSchema, passwordSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { StackAssertionError, StatusError } from "@stackframe/stack-shared/dist/utils/errors";
import { hashPassword } from "@stackframe/stack-shared/dist/utils/password";
import { hashPassword } from "@stackframe/stack-shared/dist/utils/hashes";

export const POST = createSmartRouteHandler({
metadata: {
Expand All @@ -19,7 +19,7 @@ export const POST = createSmartRouteHandler({
user: adaptSchema.defined(),
}).defined(),
body: yupObject({
password: yupString().defined(),
password: passwordSchema.defined(),
}).defined(),
headers: yupObject({}).defined(),
}),
Expand Down
6 changes: 3 additions & 3 deletions apps/backend/src/app/api/v1/auth/password/sign-in/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ import { createAuthTokens } from "@/lib/tokens";
import { prismaClient } from "@/prisma-client";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { KnownErrors } from "@stackframe/stack-shared";
import { adaptSchema, clientOrHigherAuthTypeSchema, emailSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { adaptSchema, clientOrHigherAuthTypeSchema, emailSchema, passwordSchema, 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 { comparePassword } from "@stackframe/stack-shared/dist/utils/hashes";
import { createMfaRequiredError } from "../../mfa/sign-in/verification-code-handler";

export const POST = createSmartRouteHandler({
Expand All @@ -21,7 +21,7 @@ export const POST = createSmartRouteHandler({
}).defined(),
body: yupObject({
email: emailSchema.defined(),
password: yupString().defined(),
password: passwordSchema.defined(),
}).defined(),
}),
response: yupObject({
Expand Down
4 changes: 2 additions & 2 deletions apps/backend/src/app/api/v1/auth/password/sign-up/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { prismaClient } from "@/prisma-client";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { KnownErrors } from "@stackframe/stack-shared";
import { getPasswordError } from "@stackframe/stack-shared/dist/helpers/password";
import { adaptSchema, clientOrHigherAuthTypeSchema, emailVerificationCallbackUrlSchema, signInEmailSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { adaptSchema, clientOrHigherAuthTypeSchema, emailVerificationCallbackUrlSchema, passwordSchema, signInEmailSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { captureError } from "@stackframe/stack-shared/dist/utils/errors";
import { contactChannelVerificationCodeHandler } from "../../../contact-channels/verify/verification-code-handler";
import { usersCrudHandlers } from "../../../users/crud";
Expand All @@ -23,7 +23,7 @@ export const POST = createSmartRouteHandler({
}).defined(),
body: yupObject({
email: signInEmailSchema.defined(),
password: yupString().defined(),
password: passwordSchema.defined(),
verification_callback_url: emailVerificationCallbackUrlSchema.defined(),
}).defined(),
}),
Expand Down
10 changes: 5 additions & 5 deletions apps/backend/src/app/api/v1/auth/password/update/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ import { prismaClient } from "@/prisma-client";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { KnownErrors } from "@stackframe/stack-shared";
import { getPasswordError } from "@stackframe/stack-shared/dist/helpers/password";
import { adaptSchema, clientOrHigherAuthTypeSchema, yupNumber, yupObject, yupString, yupTuple } from "@stackframe/stack-shared/dist/schema-fields";
import { StackAssertionError, StatusError } from "@stackframe/stack-shared/dist/utils/errors";
import { comparePassword, hashPassword } from "@stackframe/stack-shared/dist/utils/password";
import { adaptSchema, clientOrHigherAuthTypeSchema, passwordSchema, yupNumber, yupObject, yupString, yupTuple } from "@stackframe/stack-shared/dist/schema-fields";
import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors";
import { comparePassword, hashPassword } from "@stackframe/stack-shared/dist/utils/hashes";

export const POST = createSmartRouteHandler({
metadata: {
Expand All @@ -19,8 +19,8 @@ export const POST = createSmartRouteHandler({
user: adaptSchema.defined(),
}).defined(),
body: yupObject({
old_password: yupString().defined(),
new_password: yupString().defined(),
old_password: passwordSchema.defined(),
new_password: passwordSchema.defined(),
}).defined(),
headers: yupObject({
"x-stack-refresh-token": yupTuple([yupString().optional()]).optional(),
Expand Down
2 changes: 1 addition & 1 deletion apps/backend/src/app/api/v1/check-version/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export const POST = createSmartRouteHandler({
error: yupString().defined(),
severe: yupBoolean().defined(),
}),
),
).defined(),
}),
handler: async (req) => {
const err = (severe: boolean, msg: string) => ({
Expand Down
40 changes: 28 additions & 12 deletions apps/backend/src/app/api/v1/users/crud.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { PrismaTransaction } from "@/lib/types";
import { sendTeamMembershipDeletedWebhook, sendUserCreatedWebhook, sendUserDeletedWebhook, sendUserUpdatedWebhook } from "@/lib/webhooks";
import { prismaClient } from "@/prisma-client";
import { createCrudHandlers } from "@/route-handlers/crud-handler";
import { runAsynchronouslyAndWaitUntil } from "@/utils/vercel";
import { BooleanTrue, Prisma } from "@prisma/client";
import { KnownErrors } from "@stackframe/stack-shared";
import { currentUserCrud } from "@stackframe/stack-shared/dist/interface/crud/current-user";
Expand All @@ -11,11 +12,10 @@ import { userIdOrMeSchema, yupBoolean, yupNumber, yupObject, yupString } from "@
import { validateBase64Image } from "@stackframe/stack-shared/dist/utils/base64";
import { decodeBase64 } from "@stackframe/stack-shared/dist/utils/bytes";
import { StackAssertionError, StatusError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { hashPassword } from "@stackframe/stack-shared/dist/utils/password";
import { hashPassword } from "@stackframe/stack-shared/dist/utils/hashes";
import { createLazyProxy } from "@stackframe/stack-shared/dist/utils/proxies";
import { typedToLowercase } from "@stackframe/stack-shared/dist/utils/strings";
import { teamPrismaToCrud, teamsCrudHandlers } from "../teams/crud";
import { runAsynchronouslyAndWaitUntil } from "@/utils/vercel";

export const userFullInclude = {
projectUserOAuthAccounts: {
Expand Down Expand Up @@ -105,6 +105,23 @@ export const userPrismaToCrud = (
};
};

async function getPasswordHashFromData(data: {
password?: string | null,
password_hash?: string,
}) {
if (data.password !== undefined) {
if (data.password_hash !== undefined) {
throw new StatusError(400, "Cannot set both password and password_hash at the same time.");
}
if (data.password === null) {
return null;
}
return await hashPassword(data.password);
} else {
return data.password_hash;
}
}

async function checkAuthData(
tx: PrismaTransaction,
data: {
Expand All @@ -113,7 +130,6 @@ async function checkAuthData(
primaryEmail?: string | null,
primaryEmailVerified?: boolean,
primaryEmailAuthEnabled?: boolean,
passwordHash?: string | null,
}
) {
if (!data.primaryEmail && data.primaryEmailAuthEnabled) {
Expand Down Expand Up @@ -327,12 +343,12 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC
},
onCreate: async ({ auth, data }) => {
const result = await prismaClient.$transaction(async (tx) => {
const passwordHash = await getPasswordHashFromData(data);
await checkAuthData(tx, {
projectId: auth.project.id,
primaryEmail: data.primary_email,
primaryEmailVerified: data.primary_email_verified,
primaryEmailAuthEnabled: data.primary_email_auth_enabled,
passwordHash: data.password && await hashPassword(data.password),
});

const newUser = await tx.projectUser.create({
Expand Down Expand Up @@ -436,7 +452,7 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC
});
}

if (data.password) {
if (passwordHash) {
const passwordConfig = await getPasswordConfig(tx, auth.project.config.id);

if (!passwordConfig) {
Expand All @@ -451,7 +467,7 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC
authMethodConfigId: passwordConfig.authMethodConfigId,
passwordAuthMethod: {
create: {
passwordHash: await hashPassword(data.password),
passwordHash,
projectUserId: newUser.projectUserId,
}
}
Expand Down Expand Up @@ -521,6 +537,7 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC
return result;
},
onUpdate: async ({ auth, data, params }) => {
const passwordHash = await getPasswordHashFromData(data);
const result = await prismaClient.$transaction(async (tx) => {
await ensureUserExists(tx, { projectId: auth.project.id, userId: params.user_id });

Expand Down Expand Up @@ -585,7 +602,6 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC
primaryEmail: primaryEmailContactChannel?.value || data.primary_email,
primaryEmailVerified: primaryEmailContactChannel?.isVerified || data.primary_email_verified,
primaryEmailAuthEnabled: !!primaryEmailContactChannel?.usedForAuth || data.primary_email_auth_enabled,
passwordHash: passwordAuth ? passwordAuth.passwordHash : (data.password && await hashPassword(data.password)),
});

// if there is a new primary email
Expand Down Expand Up @@ -715,8 +731,8 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC
// - update the password auth method if it exists
// if the password is null
// - delete the password auth method if it exists
if (data.password !== undefined) {
if (data.password === null) {
if (passwordHash !== undefined) {
if (passwordHash === null) {
if (passwordAuth) {
await tx.authMethod.delete({
where: {
Expand All @@ -737,7 +753,7 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC
},
},
data: {
passwordHash: await hashPassword(data.password),
passwordHash,
},
});
} else {
Expand Down Expand Up @@ -768,7 +784,7 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC
authMethodConfigId: passwordConfig.authMethodConfigId,
passwordAuthMethod: {
create: {
passwordHash: await hashPassword(data.password),
passwordHash,
projectUserId: params.user_id,
}
}
Expand Down Expand Up @@ -798,7 +814,7 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC
});

// if user password changed, reset all refresh tokens
if (data.password !== undefined) {
if (passwordHash !== undefined) {
await prismaClient.projectUserRefreshToken.deleteMany({
where: {
projectId: auth.project.id,
Expand Down
4 changes: 2 additions & 2 deletions apps/dashboard/src/components/user-dialog.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useAdminApp } from "@/app/(main)/(protected)/projects/[projectId]/use-admin-app";
import { ServerUser } from "@stackframe/stack";
import { KnownErrors } from "@stackframe/stack-shared";
import { emailSchema, jsonStringOrEmptySchema } from "@stackframe/stack-shared/dist/schema-fields";
import { emailSchema, jsonStringOrEmptySchema, passwordSchema } from "@stackframe/stack-shared/dist/schema-fields";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, Typography, useToast } from "@stackframe/stack-ui";
import * as yup from "yup";
import { FormDialog } from "./form-dialog";
Expand Down Expand Up @@ -48,7 +48,7 @@ export function UserDialog(props: {
clientReadOnlyMetadata: jsonStringOrEmptySchema.default("null"),
serverMetadata: jsonStringOrEmptySchema.default("null"),
primaryEmailVerified: yup.boolean().optional(),
password: yup.string().optional(),
password: passwordSchema.optional(),
otpAuthEnabled: yup.boolean().test({
name: 'otp-verified',
message: "Primary email must be verified if OTP/magic link sign-in is enabled",
Expand Down
98 changes: 98 additions & 0 deletions apps/e2e/tests/backend/endpoints/api/v1/users.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -983,6 +983,104 @@ describe("with server access", () => {
`);
});

it("should be able to create a user with a password hash and sign in with it", async ({ expect }) => {
const password = "hello-world";
const response = await niceBackendFetch("/api/v1/users", {
accessType: "server",
method: "POST",
body: {
primary_email: backendContext.value.mailbox.emailAddress,
primary_email_auth_enabled: true,
password_hash: "$2a$13$TVyY/gpw9Db/w1fBeJkCgeNg2Rae2JfNqrPnSAKtj.ufAO5cVF13.",
},
});
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 201,
"body": {
"auth_with_email": true,
"client_metadata": null,
"client_read_only_metadata": null,
"display_name": null,
"has_password": true,
"id": "<stripped UUID>",
"last_active_at_millis": <stripped field 'last_active_at_millis'>,
"oauth_providers": [],
"otp_auth_enabled": false,
"passkey_auth_enabled": false,
"primary_email": "<stripped UUID>@stack-generated.example.com",
"primary_email_auth_enabled": true,
"primary_email_verified": false,
"profile_image_url": null,
"requires_totp_mfa": false,
"selected_team": null,
"selected_team_id": null,
"server_metadata": null,
"signed_up_at_millis": <stripped field 'signed_up_at_millis'>,
},
"headers": Headers { <some fields may have been hidden> },
}
`);
const signInResponse = await Auth.Password.signInWithEmail({ password });
expect(signInResponse.signInResponse).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": {
"access_token": <stripped field 'access_token'>,
"refresh_token": <stripped field 'refresh_token'>,
"user_id": "<stripped UUID>",
},
"headers": Headers { <some fields may have been hidden> },
}
`);
});

it("should not be able to create a user when both password and password hash are provided", async ({ expect }) => {
const response = await niceBackendFetch("/api/v1/users", {
accessType: "server",
method: "POST",
body: {
primary_email: backendContext.value.mailbox.emailAddress,
primary_email_auth_enabled: true,
password: "hello-world",
password_hash: "$2a$13$TVyY/gpw9Db/w1fBeJkCgeNg2Rae2JfNqrPnSAKtj.ufAO5cVF13.",
},
});
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": "Cannot set both password and password_hash at the same time.",
"headers": Headers { <some fields may have been hidden> },
}
`);
});

it("should not be able to create a user with a password hash that has too many rounds", async ({ expect }) => {
const response = await niceBackendFetch("/api/v1/users", {
accessType: "server",
method: "POST",
body: {
primary_email: backendContext.value.mailbox.emailAddress,
primary_email_auth_enabled: true,
password_hash: "$2a$17$VIhIOofSMqGdGlL4wzE//e.77dAQGqNtF/1dT7bqCrVtQuInWy2qi",
},
});
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": {
"code": "SCHEMA_ERROR",
"details": { "message": "Request validation failed on POST /api/v1/users:\\n - Invalid password hash" },
"error": "Request validation failed on POST /api/v1/users:\\n - Invalid password hash",
},
"headers": Headers {
"x-stack-known-error": "SCHEMA_ERROR",
<some fields may have been hidden>,
},
}
`);
});

it("should not be able to create a user without primary email but with email auth enabled", async ({ expect }) => {
const response = await niceBackendFetch("/api/v1/users", {
accessType: "server",
Expand Down
1 change: 1 addition & 0 deletions packages/stack-shared/src/interface/crud/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export const usersCrudServerUpdateSchema = fieldSchema.yupObject({
primary_email_auth_enabled: fieldSchema.primaryEmailAuthEnabledSchema.optional(),
passkey_auth_enabled: fieldSchema.userOtpAuthEnabledSchema.optional(),
password: fieldSchema.userPasswordMutationSchema.optional(),
password_hash: fieldSchema.userPasswordHashMutationSchema.optional(),
otp_auth_enabled: fieldSchema.userOtpAuthEnabledMutationSchema.optional(),
totp_secret_base64: fieldSchema.userTotpSecretMutationSchema.optional(),
selected_team_id: fieldSchema.selectedTeamIdSchema.nullable().optional(),
Expand Down
Loading

0 comments on commit 1f84ff5

Please sign in to comment.