add resource whitelist auth method

This commit is contained in:
Milo Schwartz 2024-12-16 22:40:42 -05:00
parent 998fab6d0a
commit 207a7b8a39
No known key found for this signature in database
20 changed files with 970 additions and 739 deletions

View file

@ -1,10 +1,10 @@
import { verify } from "@node-rs/argon2";
import { generateSessionToken } from "@server/auth";
import db from "@server/db";
import { orgs, resourceOtp, resourcePassword, resources } from "@server/db/schema";
import { orgs, resourcePassword, resources } from "@server/db/schema";
import HttpCode from "@server/types/HttpCode";
import response from "@server/utils/response";
import { and, eq } from "drizzle-orm";
import { eq } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
@ -13,14 +13,10 @@ import {
createResourceSession,
serializeResourceSessionCookie
} from "@server/auth/resource";
import logger from "@server/logger";
import config from "@server/config";
import { isValidOtp, sendResourceOtpEmail } from "@server/auth/resourceOtp";
export const authWithPasswordBodySchema = z.object({
password: z.string(),
email: z.string().email().optional(),
otp: z.string().optional()
password: z.string()
});
export const authWithPasswordParamsSchema = z.object({
@ -28,8 +24,6 @@ export const authWithPasswordParamsSchema = z.object({
});
export type AuthWithPasswordResponse = {
otpRequested?: boolean;
otpSent?: boolean;
session?: string;
};
@ -61,7 +55,7 @@ export async function authWithPassword(
}
const { resourceId } = parsedParams.data;
const { email, password, otp } = parsedBody.data;
const { password } = parsedBody.data;
try {
const [result] = await db
@ -119,69 +113,11 @@ export async function authWithPassword(
);
}
if (resource.otpEnabled) {
if (otp && email) {
const isValidCode = await isValidOtp(
email,
resource.resourceId,
otp
);
if (!isValidCode) {
return next(
createHttpError(HttpCode.UNAUTHORIZED, "Incorrect OTP")
);
}
await db
.delete(resourceOtp)
.where(
and(
eq(resourceOtp.email, email),
eq(resourceOtp.resourceId, resource.resourceId)
)
);
} else if (email) {
try {
await sendResourceOtpEmail(
email,
resource.resourceId,
resource.name,
org.name
);
return response<AuthWithPasswordResponse>(res, {
data: { otpSent: true },
success: true,
error: false,
message: "Sent one-time otp to email address",
status: HttpCode.ACCEPTED
});
} catch (e) {
logger.error(e);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to send one-time otp. Make sure the email address is correct and try again."
)
);
}
} else {
return response<AuthWithPasswordResponse>(res, {
data: { otpRequested: true },
success: true,
error: false,
message: "One-time otp required to complete authentication",
status: HttpCode.ACCEPTED
});
}
}
const token = generateSessionToken();
await createResourceSession({
resourceId,
token,
passwordId: definedPassword.passwordId,
usedOtp: otp !== undefined,
email
passwordId: definedPassword.passwordId
});
const cookieName = `${config.server.resource_session_cookie_name}_${resource.resourceId}`;
const cookie = serializeResourceSessionCookie(

View file

@ -5,7 +5,8 @@ import {
orgs,
resourceOtp,
resourcePincode,
resources
resources,
resourceWhitelist
} from "@server/db/schema";
import HttpCode from "@server/types/HttpCode";
import response from "@server/utils/response";
@ -24,9 +25,7 @@ import { AuthWithPasswordResponse } from "./authWithPassword";
import { isValidOtp, sendResourceOtpEmail } from "@server/auth/resourceOtp";
export const authWithPincodeBodySchema = z.object({
pincode: z.string(),
email: z.string().email().optional(),
otp: z.string().optional()
pincode: z.string()
});
export const authWithPincodeParamsSchema = z.object({
@ -34,8 +33,6 @@ export const authWithPincodeParamsSchema = z.object({
});
export type AuthWithPincodeResponse = {
otpRequested?: boolean;
otpSent?: boolean;
session?: string;
};
@ -67,7 +64,7 @@ export async function authWithPincode(
}
const { resourceId } = parsedParams.data;
const { email, pincode, otp } = parsedBody.data;
const { pincode } = parsedBody.data;
try {
const [result] = await db
@ -124,69 +121,11 @@ export async function authWithPincode(
);
}
if (resource.otpEnabled) {
if (otp && email) {
const isValidCode = await isValidOtp(
email,
resource.resourceId,
otp
);
if (!isValidCode) {
return next(
createHttpError(HttpCode.UNAUTHORIZED, "Incorrect OTP")
);
}
await db
.delete(resourceOtp)
.where(
and(
eq(resourceOtp.email, email),
eq(resourceOtp.resourceId, resource.resourceId)
)
);
} else if (email) {
try {
await sendResourceOtpEmail(
email,
resource.resourceId,
resource.name,
org.name
);
return response<AuthWithPasswordResponse>(res, {
data: { otpSent: true },
success: true,
error: false,
message: "Sent one-time otp to email address",
status: HttpCode.ACCEPTED
});
} catch (e) {
logger.error(e);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to send one-time otp. Make sure the email address is correct and try again."
)
);
}
} else {
return response<AuthWithPasswordResponse>(res, {
data: { otpRequested: true },
success: true,
error: false,
message: "One-time otp required to complete authentication",
status: HttpCode.ACCEPTED
});
}
}
const token = generateSessionToken();
await createResourceSession({
resourceId,
token,
pincodeId: definedPincode.pincodeId,
usedOtp: otp !== undefined,
email
pincodeId: definedPincode.pincodeId
});
const cookieName = `${config.server.resource_session_cookie_name}_${resource.resourceId}`;
const cookie = serializeResourceSessionCookie(

View file

@ -0,0 +1,200 @@
import { generateSessionToken } from "@server/auth";
import db from "@server/db";
import {
orgs,
resourceOtp,
resourcePassword,
resources,
resourceWhitelist
} from "@server/db/schema";
import HttpCode from "@server/types/HttpCode";
import response from "@server/utils/response";
import { eq, and } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
import {
createResourceSession,
serializeResourceSessionCookie
} from "@server/auth/resource";
import config from "@server/config";
import { isValidOtp, sendResourceOtpEmail } from "@server/auth/resourceOtp";
import logger from "@server/logger";
const authWithWhitelistBodySchema = z.object({
email: z.string().email(),
otp: z.string().optional()
});
const authWithWhitelistParamsSchema = z.object({
resourceId: z.string().transform(Number).pipe(z.number().int().positive())
});
export type AuthWithWhitelistResponse = {
otpSent?: boolean;
session?: string;
};
export async function authWithWhitelist(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
const parsedBody = authWithWhitelistBodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const parsedParams = authWithWhitelistParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { resourceId } = parsedParams.data;
const { email, otp } = parsedBody.data;
try {
const [result] = await db
.select()
.from(resourceWhitelist)
.where(
and(
eq(resourceWhitelist.resourceId, resourceId),
eq(resourceWhitelist.email, email)
)
)
.leftJoin(
resources,
eq(resources.resourceId, resourceWhitelist.resourceId)
)
.leftJoin(orgs, eq(orgs.orgId, resources.orgId))
.limit(1);
const resource = result?.resources;
const org = result?.orgs;
const whitelistedEmail = result?.resourceWhitelist;
if (!whitelistedEmail) {
return next(
createHttpError(
HttpCode.UNAUTHORIZED,
createHttpError(
HttpCode.BAD_REQUEST,
"Email is not whitelisted"
)
)
);
}
if (!org) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Resource does not exist")
);
}
if (!resource) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Resource does not exist")
);
}
if (otp && email) {
const isValidCode = await isValidOtp(
email,
resource.resourceId,
otp
);
if (!isValidCode) {
return next(
createHttpError(HttpCode.UNAUTHORIZED, "Incorrect OTP")
);
}
await db
.delete(resourceOtp)
.where(
and(
eq(resourceOtp.email, email),
eq(resourceOtp.resourceId, resource.resourceId)
)
);
} else if (email) {
try {
await sendResourceOtpEmail(
email,
resource.resourceId,
resource.name,
org.name
);
return response<AuthWithWhitelistResponse>(res, {
data: { otpSent: true },
success: true,
error: false,
message: "Sent one-time otp to email address",
status: HttpCode.ACCEPTED
});
} catch (e) {
logger.error(e);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to send one-time otp. Make sure the email address is correct and try again."
)
);
}
} else {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Email is required for whitelist authentication"
)
);
}
const token = generateSessionToken();
await createResourceSession({
resourceId,
token,
whitelistId: whitelistedEmail.whitelistId
});
const cookieName = `${config.server.resource_session_cookie_name}_${resource.resourceId}`;
const cookie = serializeResourceSessionCookie(
cookieName,
token,
resource.fullDomain
);
res.appendHeader("Set-Cookie", cookie);
return response<AuthWithWhitelistResponse>(res, {
data: {
session: token
},
success: true,
error: false,
message: "Authenticated with resource successfully",
status: HttpCode.OK
});
} catch (e) {
throw e;
logger.error(e);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to authenticate with resource"
)
);
}
}

View file

@ -24,6 +24,7 @@ export type GetResourceAuthInfoResponse = {
sso: boolean;
blockAccess: boolean;
url: string;
whitelist: boolean;
};
export async function getResourceAuthInfo(
@ -79,6 +80,7 @@ export async function getResourceAuthInfo(
sso: resource.sso,
blockAccess: resource.blockAccess,
url,
whitelist: resource.emailWhitelistEnabled
},
success: true,
error: false,

View file

@ -0,0 +1,64 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { resourceWhitelist, users } from "@server/db/schema"; // Assuming these are the correct tables
import { eq } from "drizzle-orm";
import response from "@server/utils/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
const getResourceWhitelistSchema = z.object({
resourceId: z.string().transform(Number).pipe(z.number().int().positive())
});
async function queryWhitelist(resourceId: number) {
return await db
.select({
email: resourceWhitelist.email
})
.from(resourceWhitelist)
.where(eq(resourceWhitelist.resourceId, resourceId));
}
export type GetResourceWhitelistResponse = {
whitelist: NonNullable<Awaited<ReturnType<typeof queryWhitelist>>>;
};
export async function getResourceWhitelist(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = getResourceWhitelistSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { resourceId } = parsedParams.data;
const whitelist = await queryWhitelist(resourceId);
return response<GetResourceWhitelistResponse>(res, {
data: {
whitelist
},
success: true,
error: false,
message: "Resource whitelist retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View file

@ -12,3 +12,6 @@ export * from "./authWithPassword";
export * from "./getResourceAuthInfo";
export * from "./setResourcePincode";
export * from "./authWithPincode";
export * from "./setResourceWhitelist";
export * from "./getResourceWhitelist";
export * from "./authWithWhitelist";

View file

@ -62,6 +62,7 @@ function queryResources(
passwordId: resourcePassword.passwordId,
pincodeId: resourcePincode.pincodeId,
sso: resources.sso,
whitelist: resources.emailWhitelistEnabled
})
.from(resources)
.leftJoin(sites, eq(resources.siteId, sites.siteId))
@ -91,6 +92,7 @@ function queryResources(
passwordId: resourcePassword.passwordId,
sso: resources.sso,
pincodeId: resourcePincode.pincodeId,
whitelist: resources.emailWhitelistEnabled
})
.from(resources)
.leftJoin(sites, eq(resources.siteId, sites.siteId))

View file

@ -0,0 +1,120 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { resources, resourceWhitelist } from "@server/db/schema";
import response from "@server/utils/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { and, eq } from "drizzle-orm";
const setResourceWhitelistBodySchema = z.object({
emails: z.array(z.string().email()).max(50)
});
const setResourceWhitelistParamsSchema = z.object({
resourceId: z.string().transform(Number).pipe(z.number().int().positive())
});
export async function setResourceWhitelist(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedBody = setResourceWhitelistBodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { emails } = parsedBody.data;
const parsedParams = setResourceWhitelistParamsSchema.safeParse(
req.params
);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { resourceId } = parsedParams.data;
const [resource] = await db
.select()
.from(resources)
.where(eq(resources.resourceId, resourceId));
if (!resource) {
return next(
createHttpError(HttpCode.NOT_FOUND, "Resource not found")
);
}
if (!resource.emailWhitelistEnabled) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Email whitelist is not enabled for this resource"
)
);
}
const whitelist = await db
.select()
.from(resourceWhitelist)
.where(eq(resourceWhitelist.resourceId, resourceId));
await db.transaction(async (trx) => {
// diff the emails
const existingEmails = whitelist.map((w) => w.email);
const emailsToAdd = emails.filter(
(e) => !existingEmails.includes(e)
);
const emailsToRemove = existingEmails.filter(
(e) => !emails.includes(e)
);
for (const email of emailsToAdd) {
await trx.insert(resourceWhitelist).values({
email,
resourceId
});
}
for (const email of emailsToRemove) {
await trx
.delete(resourceWhitelist)
.where(
and(
eq(resourceWhitelist.resourceId, resourceId),
eq(resourceWhitelist.email, email)
)
);
}
return response(res, {
data: {},
success: true,
error: false,
message: "Whitelist set for resource successfully",
status: HttpCode.CREATED
});
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View file

@ -21,6 +21,7 @@ const updateResourceBodySchema = z
ssl: z.boolean().optional(),
sso: z.boolean().optional(),
blockAccess: z.boolean().optional(),
emailWhitelistEnabled: z.boolean().optional(),
// siteId: z.number(),
})
.strict()