Skip to content

Commit

Permalink
New contact channels (#287)
Browse files Browse the repository at this point in the history
* removed contact channels from otp

* fixed types

* fixed bugs

* fixed bug

* fixed bugs

* updated user contact channel

* updated tests

* updated tests

* added unique key to otp and password auth

* removed contact channel from user object
  • Loading branch information
fomalhautb committed Oct 1, 2024
1 parent d0b3d6e commit 28c3f57
Show file tree
Hide file tree
Showing 16 changed files with 216 additions and 548 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/*
Warnings:
- You are about to drop the column `contactChannelId` on the `OtpAuthMethod` table. All the data in the column will be lost.
- You are about to drop the column `identifier` on the `PasswordAuthMethod` table. All the data in the column will be lost.
- A unique constraint covering the columns `[projectId,type,value,usedForAuth]` on the table `ContactChannel` will be added. If there are existing duplicate values, this will fail.
- You are about to drop the column `identifierType` on the `PasswordAuthMethod` table. All the data in the column will be lost.
- You are about to drop the column `identifierType` on the `PasswordAuthMethodConfig` table. All the data in the column will be lost.
*/
-- DropForeignKey
ALTER TABLE "OtpAuthMethod" DROP CONSTRAINT "OtpAuthMethod_projectId_projectUserId_contactChannelId_fkey";

-- DropIndex
DROP INDEX "OtpAuthMethod_projectId_contactChannelId_key";

-- DropIndex
DROP INDEX "PasswordAuthMethod_projectId_identifierType_identifier_key";

-- AlterTable
ALTER TABLE "ContactChannel" ADD COLUMN "usedForAuth" "BooleanTrue";

-- Set the usedForAuth value to "TRUE" if the contact channel is used in `OtpAuthMethod` or the value is the same as the `PasswordAuthMethod` of the same user
UPDATE "ContactChannel" cc
SET "usedForAuth" = 'TRUE'
WHERE EXISTS (
SELECT 1
FROM "OtpAuthMethod" oam
WHERE oam."projectId" = cc."projectId"
AND oam."projectUserId" = cc."projectUserId"
)
OR EXISTS (
SELECT 1
FROM "PasswordAuthMethod" pam
WHERE pam."projectId" = cc."projectId"
AND pam."projectUserId" = cc."projectUserId"
AND pam."identifier" = cc."value"
);


-- AlterTable
ALTER TABLE "OtpAuthMethod" DROP COLUMN "contactChannelId";

-- AlterTable
ALTER TABLE "PasswordAuthMethod" DROP COLUMN "identifier";

-- CreateIndex
CREATE UNIQUE INDEX "ContactChannel_projectId_type_value_usedForAuth_key" ON "ContactChannel"("projectId", "type", "value", "usedForAuth");

-- AlterTable
ALTER TABLE "PasswordAuthMethod" DROP COLUMN "identifierType";

-- AlterTable
ALTER TABLE "PasswordAuthMethodConfig" DROP COLUMN "identifierType";

-- DropEnum
DROP TYPE "PasswordAuthMethodIdentifierType";

-- CreateIndex
CREATE UNIQUE INDEX "OtpAuthMethod_projectId_projectUserId_key" ON "OtpAuthMethod"("projectId", "projectUserId");

-- CreateIndex
CREATE UNIQUE INDEX "PasswordAuthMethod_projectId_projectUserId_key" ON "PasswordAuthMethod"("projectId", "projectUserId");

48 changes: 19 additions & 29 deletions apps/backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -290,20 +290,22 @@ model ContactChannel {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
type ContactChannelType
isPrimary BooleanTrue?
isVerified Boolean
value String
type ContactChannelType
isPrimary BooleanTrue?
usedForAuth BooleanTrue?
isVerified Boolean
value String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
projectUser ProjectUser @relation(fields: [projectId, projectUserId], references: [projectId, projectUserId], onDelete: Cascade)
otpAuthMethod OtpAuthMethod[]
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
projectUser ProjectUser @relation(fields: [projectId, projectUserId], references: [projectId, projectUserId], onDelete: Cascade)
@@id([projectId, projectUserId, id])
// each user has at most one primary contact channel of each type
@@unique([projectId, projectUserId, type, isPrimary])
// value must be unique per user per type
@@unique([projectId, projectUserId, type, value])
// only one contact channel per project with the same value and type can be used for auth
@@unique([projectId, type, value, usedForAuth])
}

model ConnectedAccountConfig {
Expand Down Expand Up @@ -379,11 +381,6 @@ model OtpAuthMethodConfig {
@@id([projectConfigId, authMethodConfigId])
}

enum PasswordAuthMethodIdentifierType {
EMAIL
// USERNAME
}

model PasswordAuthMethodConfig {
projectConfigId String @db.Uuid
authMethodConfigId String @db.Uuid
Expand All @@ -393,8 +390,6 @@ model PasswordAuthMethodConfig {
authMethodConfig AuthMethodConfig @relation(fields: [projectConfigId, authMethodConfigId], references: [projectConfigId, id], onDelete: Cascade)
identifierType PasswordAuthMethodIdentifierType
@@id([projectConfigId, authMethodConfigId])
}

Expand Down Expand Up @@ -498,21 +493,19 @@ model AuthMethod {
}

model OtpAuthMethod {
projectId String
authMethodId String @db.Uuid
contactChannelId String @db.Uuid
projectUserId String @db.Uuid
projectId String
authMethodId String @db.Uuid
projectUserId String @db.Uuid
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
contactChannel ContactChannel @relation(fields: [projectId, projectUserId, contactChannelId], references: [projectId, projectUserId, id], onDelete: Cascade)
authMethod AuthMethod @relation(fields: [projectId, authMethodId], references: [projectId, id], onDelete: Cascade)
projectUser ProjectUser @relation(fields: [projectId, projectUserId], references: [projectId, projectUserId], onDelete: Cascade)
authMethod AuthMethod @relation(fields: [projectId, authMethodId], references: [projectId, id], onDelete: Cascade)
projectUser ProjectUser @relation(fields: [projectId, projectUserId], references: [projectId, projectUserId], onDelete: Cascade)
@@id([projectId, authMethodId])
// each contact channel can only be used once per project as an otp method
@@unique([projectId, contactChannelId])
// a user can only have one OTP auth method
@@unique([projectId, projectUserId])
}

model PasswordAuthMethod {
Expand All @@ -523,17 +516,14 @@ model PasswordAuthMethod {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
identifierType PasswordAuthMethodIdentifierType
// The identifier is the email or username, depending on the type.
identifier String
passwordHash String
passwordHash String
authMethod AuthMethod @relation(fields: [projectId, authMethodId], references: [projectId, id], onDelete: Cascade)
projectUser ProjectUser @relation(fields: [projectId, projectUserId], references: [projectId, projectUserId], onDelete: Cascade)
@@id([projectId, authMethodId])
// each identifier of each type can only occur once per project
@@unique([projectId, identifierType, identifier])
// a user can only have one password auth method
@@unique([projectId, projectUserId])
}

// This connects to projectUserOauthAccount, which might be shared between auth method and connected account.
Expand Down
4 changes: 1 addition & 3 deletions apps/backend/prisma/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,7 @@ async function seed() {
},
{
passwordConfig: {
create: {
identifierType: 'EMAIL',
}
create: {}
}
},
...(['github', 'spotify', 'google', 'microsoft'] as const).map((id) => ({
Expand Down
31 changes: 18 additions & 13 deletions apps/backend/src/app/api/v1/auth/otp/send-sign-in-code/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,33 +39,38 @@ export const POST = createSmartRouteHandler({
throw new StatusError(StatusError.Forbidden, "Magic link is not enabled for this project");
}

const authMethods = await prismaClient.otpAuthMethod.findMany({
const contactChannel = await prismaClient.contactChannel.findUnique({
where: {
projectId: project.id,
contactChannel: {
projectId_type_value_usedForAuth: {
projectId: project.id,
type: "EMAIL",
value: email,
},
usedForAuth: "TRUE",
}
},
include: {
projectUser: true,
contactChannel: true,
projectUser: {
include: {
authMethods: {
include: {
otpAuthMethod: true,
}
}
}
}
}
});

if (authMethods.length > 1) {
throw new StackAssertionError("Tried to send OTP sign in code but found multiple auth methods? The uniqueness on the DB schema should prevent this");
}
const authMethod = authMethods.length === 1 ? authMethods[0] : null;
const otpAuthMethod = contactChannel?.projectUser.authMethods.find((m) => m.otpAuthMethod)?.otpAuthMethod;

const isNewUser = !authMethod;
const isNewUser = !otpAuthMethod;
if (isNewUser && !project.config.sign_up_enabled) {
throw new KnownErrors.SignUpNotEnabled();
}

let user;

if (!authMethod) {
if (!otpAuthMethod) {
// TODO this should be in the same transaction as the read above
user = await usersCrudHandlers.adminCreate({
project,
Expand All @@ -79,7 +84,7 @@ export const POST = createSmartRouteHandler({
} else {
user = await usersCrudHandlers.adminRead({
project,
user_id: authMethod.projectUser.projectUserId,
user_id: contactChannel.projectUser.projectUserId,
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,41 +55,47 @@ export const signInVerificationCodeHandler = createVerificationCodeHandler({
};
},
async handler(project, { email }, data) {
const authMethods = await prismaClient.otpAuthMethod.findMany({
const contactChannel = await prismaClient.contactChannel.findUnique({
where: {
projectId: project.id,
contactChannel: {
projectId_type_value_usedForAuth: {
projectId: project.id,
type: "EMAIL",
value: email,
},
usedForAuth: "TRUE",
}
},
include: {
projectUser: true,
projectUser: {
include: {
authMethods: {
include: {
otpAuthMethod: true,
}
}
}
}
}
});

if (authMethods.length === 0) {
const otpAuthMethod = contactChannel?.projectUser.authMethods.find((m) => m.otpAuthMethod)?.otpAuthMethod;

if (!contactChannel || !otpAuthMethod) {
throw new StackAssertionError("Tried to use OTP sign in but auth method was not found?");
}
if (authMethods.length > 1) {
throw new StackAssertionError("Tried to use OTP sign in but found multiple auth methods? The uniqueness on the DB schema should prevent this");
}

const authMethod = authMethods[0];

if (authMethod.projectUser.requiresTotpMfa) {
if (contactChannel.projectUser.requiresTotpMfa) {
throw await createMfaRequiredError({
project,
isNewUser: data.is_new_user,
userId: authMethod.projectUserId,
userId: contactChannel.projectUser.projectUserId,
});
}

await prismaClient.contactChannel.update({
where: {
projectId_projectUserId_type_value: {
projectId: project.id,
projectUserId: authMethod.projectUserId,
projectUserId: contactChannel.projectUser.projectUserId,
type: "EMAIL",
value: email,
}
Expand All @@ -101,7 +107,7 @@ export const signInVerificationCodeHandler = createVerificationCodeHandler({

const { refreshToken, accessToken } = await createAuthTokens({
projectId: project.id,
projectUserId: authMethod.projectUserId,
projectUserId: contactChannel.projectUser.projectUserId,
});

return {
Expand Down
33 changes: 22 additions & 11 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 @@ -37,39 +37,50 @@ export const POST = createSmartRouteHandler({
throw new KnownErrors.PasswordAuthenticationNotEnabled();
}

const authMethod = await prismaClient.passwordAuthMethod.findUnique({
const contactChannel = await prismaClient.contactChannel.findUnique({
where: {
projectId_identifierType_identifier: {
projectId_type_value_usedForAuth: {
projectId: project.id,
identifierType: "EMAIL",
identifier: email,
type: "EMAIL",
value: email,
usedForAuth: "TRUE",
}
},
include: {
projectUser: true,
projectUser: {
include: {
authMethods: {
include: {
passwordAuthMethod: true,
}
}
}
}
}
});

const passwordAuthMethod = contactChannel?.projectUser.authMethods.find((m) => m.passwordAuthMethod)?.passwordAuthMethod;

// we compare the password even if the authMethod doesn't exist to prevent timing attacks
if (!await comparePassword(password, authMethod?.passwordHash || "")) {
if (!await comparePassword(password, passwordAuthMethod?.passwordHash || "")) {
throw new KnownErrors.EmailPasswordMismatch();
}

if (!authMethod) {
if (!contactChannel || !passwordAuthMethod) {
throw new StackAssertionError("This should never happen (the comparePassword call should've already caused this to fail)");
}

if (authMethod.projectUser.requiresTotpMfa) {
if (contactChannel.projectUser.requiresTotpMfa) {
throw await createMfaRequiredError({
project,
isNewUser: false,
userId: authMethod.projectUser.projectUserId,
userId: contactChannel.projectUser.projectUserId,
});
}

const { refreshToken, accessToken } = await createAuthTokens({
projectId: project.id,
projectUserId: authMethod.projectUser.projectUserId,
projectUserId: contactChannel.projectUser.projectUserId,
});

return {
Expand All @@ -78,7 +89,7 @@ export const POST = createSmartRouteHandler({
body: {
access_token: accessToken,
refresh_token: refreshToken,
user_id: authMethod.projectUser.projectUserId,
user_id: contactChannel.projectUser.projectUserId,
}
};
},
Expand Down
4 changes: 1 addition & 3 deletions apps/backend/src/app/api/v1/internal/projects/crud.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,9 +141,7 @@ export const internalProjectsCrudHandlers = createLazyProxy(() => createCrudHand
...(data.config?.credential_enabled ?? true) ? [{
enabled: true,
passwordConfig: {
create: {
identifierType: 'EMAIL',
}
create: {}
},
}] : [],
]
Expand Down
Loading

0 comments on commit 28c3f57

Please sign in to comment.