This commit is contained in:
Owen Schwartz
2024-12-01 19:46:01 -05:00
57 changed files with 957 additions and 485 deletions

View File

@@ -0,0 +1,65 @@
import { Request, Response, NextFunction } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
import HttpCode from "@server/types/HttpCode";
import { response } from "@server/utils";
import { validateSessionToken } from "@server/auth";
import { validateResourceSessionToken } from "@server/auth/resource";
export const params = z.object({
token: z.string(),
resourceId: z.string().transform(Number).pipe(z.number().int().positive()),
});
export type CheckResourceSessionParams = z.infer<typeof params>;
export type CheckResourceSessionResponse = {
valid: boolean;
};
export async function checkResourceSession(
req: Request,
res: Response,
next: NextFunction,
): Promise<any> {
const parsedParams = params.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString(),
),
);
}
const { token, resourceId } = parsedParams.data;
try {
const { resourceSession } = await validateResourceSessionToken(
token,
resourceId,
);
let valid = false;
if (resourceSession) {
valid = true;
}
return response<CheckResourceSessionResponse>(res, {
data: { valid },
success: true,
error: false,
message: "Checked validity",
status: HttpCode.OK,
});
} catch (e) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to reset password",
),
);
}
}

View File

@@ -9,3 +9,4 @@ export * from "./requestEmailVerificationCode";
export * from "./changePassword";
export * from "./requestPasswordReset";
export * from "./resetPassword";
export * from "./checkResourceSession";

View File

@@ -139,7 +139,7 @@ export async function login(
success: true,
error: false,
message: "Email verification code sent",
status: HttpCode.ACCEPTED,
status: HttpCode.OK,
});
}

View File

@@ -13,11 +13,18 @@ export async function sendEmailVerificationCode(
): Promise<void> {
const code = await generateEmailVerificationCode(userId, email);
await sendEmail(VerifyEmail({ username: email, verificationCode: code }), {
to: email,
from: config.email?.no_reply,
subject: "Verify your email address",
});
await sendEmail(
VerifyEmail({
username: email,
verificationCode: code,
verifyLink: `${config.app.base_url}/auth/verify-email`,
}),
{
to: email,
from: config.email?.no_reply,
subject: "Verify your email address",
},
);
}
async function generateEmailVerificationCode(

View File

@@ -10,6 +10,7 @@ import {
resourcePassword,
resourcePincode,
resources,
User,
userOrgs,
} from "@server/db/schema";
import { and, eq } from "drizzle-orm";
@@ -19,10 +20,7 @@ import { Resource, roleResources, userResources } from "@server/db/schema";
import logger from "@server/logger";
const verifyResourceSessionSchema = z.object({
sessions: z.object({
session: z.string().nullable(),
resource_session: z.string().nullable(),
}),
sessions: z.record(z.string()).optional(),
originalRequestURL: z.string().url(),
scheme: z.string(),
host: z.string(),
@@ -98,13 +96,18 @@ export async function verifyResourceSession(
const redirectUrl = `${config.app.base_url}/auth/resource/${encodeURIComponent(resource.resourceId)}?redirect=${encodeURIComponent(originalRequestURL)}`;
if (sso && sessions.session) {
const { session, user } = await validateSessionToken(
sessions.session,
);
if (!sessions) {
return notAllowed(res);
}
const sessionToken = sessions[config.server.session_cookie_name];
// check for unified login
if (sso && sessionToken) {
const { session, user } = await validateSessionToken(sessionToken);
if (session && user) {
const isAllowed = await isUserAllowedToAccessResource(
user.userId,
user,
resource,
);
@@ -117,11 +120,17 @@ export async function verifyResourceSession(
}
}
if (password && sessions.resource_session) {
const resourceSessionToken =
sessions[
`${config.server.resource_session_cookie_name}_${resource.resourceId}`
];
if ((pincode || password) && resourceSessionToken) {
const { resourceSession } = await validateResourceSessionToken(
sessions.resource_session,
resourceSessionToken,
resource.resourceId,
);
if (resourceSession) {
if (
pincode &&
@@ -165,7 +174,7 @@ function notAllowed(res: Response, redirectUrl?: string) {
error: false,
message: "Access denied",
status: HttpCode.OK,
}
};
logger.debug(JSON.stringify(data));
return response<VerifyUserResponse>(res, data);
}
@@ -177,21 +186,25 @@ function allowed(res: Response) {
error: false,
message: "Access allowed",
status: HttpCode.OK,
}
};
logger.debug(JSON.stringify(data));
return response<VerifyUserResponse>(res, data);
}
async function isUserAllowedToAccessResource(
userId: string,
user: User,
resource: Resource,
) {
): Promise<boolean> {
if (config.flags?.require_email_verification && !user.emailVerified) {
return false;
}
const userOrgRole = await db
.select()
.from(userOrgs)
.where(
and(
eq(userOrgs.userId, userId),
eq(userOrgs.userId, user.userId),
eq(userOrgs.orgId, resource.orgId),
),
)
@@ -221,7 +234,7 @@ async function isUserAllowedToAccessResource(
.from(userResources)
.where(
and(
eq(userResources.userId, userId),
eq(userResources.userId, user.userId),
eq(userResources.resourceId, resource.resourceId),
),
)

View File

@@ -2,6 +2,7 @@ import { Router } from "express";
import * as gerbil from "@server/routers/gerbil";
import * as badger from "@server/routers/badger";
import * as traefik from "@server/routers/traefik";
import * as auth from "@server/routers/auth";
import HttpCode from "@server/types/HttpCode";
// Root routes
@@ -12,6 +13,10 @@ internalRouter.get("/", (_, res) => {
});
internalRouter.get("/traefik-config", traefik.traefikConfigProvider);
internalRouter.get(
"/resource-session/:resourceId/:token",
auth.checkResourceSession,
);
// Gerbil routes
const gerbilRouter = Router();

View File

@@ -14,6 +14,7 @@ import {
serializeResourceSessionCookie,
} from "@server/auth/resource";
import logger from "@server/logger";
import config from "@server/config";
export const authWithPasswordBodySchema = z.object({
password: z.string(),
@@ -131,15 +132,15 @@ export async function authWithPassword(
token,
passwordId: definedPassword.passwordId,
});
// const secureCookie = resource.ssl;
// const cookie = serializeResourceSessionCookie(
// token,
// resource.fullDomain,
// secureCookie,
// );
// res.appendHeader("Set-Cookie", cookie);
const cookieName = `${config.server.resource_session_cookie_name}_${resource.resourceId}`;
const cookie = serializeResourceSessionCookie(
cookieName,
token,
resource.fullDomain,
);
res.appendHeader("Set-Cookie", cookie);
// logger.debug(cookie); // remove after testing
logger.debug(cookie); // remove after testing
return response<AuthWithPasswordResponse>(res, {
data: {

View File

@@ -14,6 +14,7 @@ import {
serializeResourceSessionCookie,
} from "@server/auth/resource";
import logger from "@server/logger";
import config from "@server/config";
export const authWithPincodeBodySchema = z.object({
pincode: z.string(),
@@ -127,15 +128,15 @@ export async function authWithPincode(
token,
pincodeId: definedPincode.pincodeId,
});
// const secureCookie = resource.ssl;
// const cookie = serializeResourceSessionCookie(
// token,
// resource.fullDomain,
// secureCookie,
// );
// res.appendHeader("Set-Cookie", cookie);
const cookieName = `${config.server.resource_session_cookie_name}_${resource.resourceId}`;
const cookie = serializeResourceSessionCookie(
cookieName,
token,
resource.fullDomain,
);
res.appendHeader("Set-Cookie", cookie);
// logger.debug(cookie); // remove after testing
logger.debug(cookie); // remove after testing
return response<AuthWithPincodeResponse>(res, {
data: {

View File

@@ -94,6 +94,7 @@ export async function createResource(
orgId,
name,
subdomain,
ssl: true,
})
.returning();

View File

@@ -6,6 +6,8 @@ import {
sites,
userResources,
roleResources,
resourcePassword,
resourcePincode,
} from "@server/db/schema";
import response from "@server/utils/response";
import HttpCode from "@server/types/HttpCode";
@@ -46,39 +48,65 @@ const listResourcesSchema = z.object({
function queryResources(
accessibleResourceIds: number[],
siteId?: number,
orgId?: string
orgId?: string,
) {
if (siteId) {
return db
.select({
resourceId: resources.resourceId,
name: resources.name,
subdomain: resources.subdomain,
fullDomain: resources.fullDomain,
ssl: resources.ssl,
siteName: sites.name,
siteId: sites.niceId,
passwordId: resourcePassword.passwordId,
pincodeId: resourcePincode.pincodeId,
sso: resources.sso,
})
.from(resources)
.leftJoin(sites, eq(resources.siteId, sites.siteId))
.leftJoin(
resourcePassword,
eq(resourcePassword.resourceId, resources.resourceId),
)
.leftJoin(
resourcePincode,
eq(resourcePincode.resourceId, resources.resourceId),
)
.where(
and(
inArray(resources.resourceId, accessibleResourceIds),
eq(resources.siteId, siteId)
)
eq(resources.siteId, siteId),
),
);
} else if (orgId) {
return db
.select({
resourceId: resources.resourceId,
name: resources.name,
subdomain: resources.subdomain,
ssl: resources.ssl,
fullDomain: resources.fullDomain,
siteName: sites.name,
siteId: sites.niceId,
passwordId: resourcePassword.passwordId,
sso: resources.sso,
pincodeId: resourcePincode.pincodeId,
})
.from(resources)
.leftJoin(sites, eq(resources.siteId, sites.siteId))
.leftJoin(
resourcePassword,
eq(resourcePassword.resourceId, resources.resourceId),
)
.leftJoin(
resourcePincode,
eq(resourcePincode.resourceId, resources.resourceId),
)
.where(
and(
inArray(resources.resourceId, accessibleResourceIds),
eq(resources.orgId, orgId)
)
eq(resources.orgId, orgId),
),
);
}
}
@@ -91,7 +119,7 @@ export type ListResourcesResponse = {
export async function listResources(
req: Request,
res: Response,
next: NextFunction
next: NextFunction,
): Promise<any> {
try {
const parsedQuery = listResourcesSchema.safeParse(req.query);
@@ -99,8 +127,8 @@ export async function listResources(
return next(
createHttpError(
HttpCode.BAD_REQUEST,
parsedQuery.error.errors.map((e) => e.message).join(", ")
)
parsedQuery.error.errors.map((e) => e.message).join(", "),
),
);
}
const { limit, offset } = parsedQuery.data;
@@ -110,8 +138,8 @@ export async function listResources(
return next(
createHttpError(
HttpCode.BAD_REQUEST,
parsedParams.error.errors.map((e) => e.message).join(", ")
)
parsedParams.error.errors.map((e) => e.message).join(", "),
),
);
}
const { siteId, orgId } = parsedParams.data;
@@ -120,8 +148,8 @@ export async function listResources(
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have access to this organization"
)
"User does not have access to this organization",
),
);
}
@@ -132,17 +160,17 @@ export async function listResources(
.from(userResources)
.fullJoin(
roleResources,
eq(userResources.resourceId, roleResources.resourceId)
eq(userResources.resourceId, roleResources.resourceId),
)
.where(
or(
eq(userResources.userId, req.user!.userId),
eq(roleResources.roleId, req.userOrgRoleId!)
)
eq(roleResources.roleId, req.userOrgRoleId!),
),
);
const accessibleResourceIds = accessibleResources.map(
(resource) => resource.resourceId
(resource) => resource.resourceId,
);
let countQuery: any = db
@@ -173,7 +201,10 @@ export async function listResources(
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"An error occurred",
),
);
}
}

View File

@@ -38,14 +38,15 @@ function querySites(orgId: string, accessibleSiteIds: number[]) {
megabytesIn: sites.megabytesIn,
megabytesOut: sites.megabytesOut,
orgName: orgs.name,
type: sites.type,
})
.from(sites)
.leftJoin(orgs, eq(sites.orgId, orgs.orgId))
.where(
and(
inArray(sites.siteId, accessibleSiteIds),
eq(sites.orgId, orgId)
)
eq(sites.orgId, orgId),
),
);
}
@@ -57,7 +58,7 @@ export type ListSitesResponse = {
export async function listSites(
req: Request,
res: Response,
next: NextFunction
next: NextFunction,
): Promise<any> {
try {
const parsedQuery = listSitesSchema.safeParse(req.query);
@@ -65,8 +66,8 @@ export async function listSites(
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedQuery.error)
)
fromError(parsedQuery.error),
),
);
}
const { limit, offset } = parsedQuery.data;
@@ -76,8 +77,8 @@ export async function listSites(
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error)
)
fromError(parsedParams.error),
),
);
}
const { orgId } = parsedParams.data;
@@ -86,8 +87,8 @@ export async function listSites(
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have access to this organization"
)
"User does not have access to this organization",
),
);
}
@@ -100,8 +101,8 @@ export async function listSites(
.where(
or(
eq(userSites.userId, req.user!.userId),
eq(roleSites.roleId, req.userOrgRoleId!)
)
eq(roleSites.roleId, req.userOrgRoleId!),
),
);
const accessibleSiteIds = accessibleSites.map((site) => site.siteId);
@@ -113,8 +114,8 @@ export async function listSites(
.where(
and(
inArray(sites.siteId, accessibleSiteIds),
eq(sites.orgId, orgId)
)
eq(sites.orgId, orgId),
),
);
const sitesList = await baseQuery.limit(limit).offset(offset);
@@ -137,7 +138,10 @@ export async function listSites(
});
} catch (error) {
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"An error occurred",
),
);
}
}

View File

@@ -22,6 +22,10 @@ export async function traefikConfigProvider(
schema.orgs,
eq(schema.resources.orgId, schema.orgs.orgId),
)
.innerJoin(
schema.sites,
eq(schema.sites.siteId, schema.resources.siteId),
)
.where(
and(
eq(schema.targets.enabled, true),
@@ -51,11 +55,9 @@ export async function traefikConfigProvider(
`http://${config.server.internal_hostname}:${config.server.internal_port}`,
).href,
resourceSessionCookieName:
config.badger.resource_session_cookie_name,
config.server.resource_session_cookie_name,
userSessionCookieName:
config.server.session_cookie_name,
sessionQueryParameter:
config.badger.session_query_parameter,
},
},
},
@@ -70,6 +72,7 @@ export async function traefikConfigProvider(
for (const item of all) {
const target = item.targets;
const resource = item.resources;
const site = item.sites;
const org = item.orgs;
const routerName = `${target.targetId}-router`;
@@ -112,7 +115,7 @@ export async function traefikConfigProvider(
? config.traefik.https_entrypoint
: config.traefik.http_entrypoint,
],
middlewares: resource.ssl ? [badgerMiddlewareName] : [],
middlewares: [badgerMiddlewareName],
service: serviceName,
rule: `Host(\`${fullDomain}\`)`,
...(resource.ssl ? { tls } : {}),
@@ -128,15 +131,28 @@ export async function traefikConfigProvider(
};
}
http.services![serviceName] = {
loadBalancer: {
servers: [
{
url: `${target.method}://${target.ip}:${target.port}`,
},
],
},
};
if (site.type === "newt") {
const ip = site.subnet.split("/")[0];
http.services![serviceName] = {
loadBalancer: {
servers: [
{
url: `${target.method}://${ip}:${target.internalPort}`,
},
],
},
};
} else if (site.type === "wireguard") {
http.services![serviceName] = {
loadBalancer: {
servers: [
{
url: `${target.method}://${target.ip}:${target.port}`,
},
],
},
};
}
}
return res.status(HttpCode.OK).json({ http });

View File

@@ -86,7 +86,6 @@ export async function inviteUser(
inviteTracker[email].timestamps.push(currentTime);
logger.debug("here0")
const org = await db
.select()
.from(orgs)
@@ -98,7 +97,6 @@ export async function inviteUser(
);
}
logger.debug("here1")
const existingUser = await db
.select()
.from(users)
@@ -114,7 +112,6 @@ export async function inviteUser(
);
}
logger.debug("here2")
const inviteId = generateRandomString(
10,
alphabet("a-z", "A-Z", "0-9"),
@@ -124,7 +121,6 @@ export async function inviteUser(
const tokenHash = await hashPassword(token);
logger.debug("here3")
// delete any existing invites for this email
await db
.delete(userInvites)
@@ -133,7 +129,6 @@ export async function inviteUser(
)
.execute();
logger.debug("here4")
await db.insert(userInvites).values({
inviteId,
orgId,
@@ -145,23 +140,21 @@ export async function inviteUser(
const inviteLink = `${config.app.base_url}/invite?token=${inviteId}-${token}`;
logger.debug("here5")
// await sendEmail(
// SendInviteLink({
// email,
// inviteLink,
// expiresInDays: (validHours / 24).toString(),
// orgName: org[0].name || orgId,
// inviterName: req.user?.email,
// }),
// {
// to: email,
// from: config.email?.no_reply,
// subject: "You're invited to join a Fossorial organization",
// },
// );
await sendEmail(
SendInviteLink({
email,
inviteLink,
expiresInDays: (validHours / 24).toString(),
orgName: org[0].name || orgId,
inviterName: req.user?.email,
}),
{
to: email,
from: config.email?.no_reply,
subject: "You're invited to join a Fossorial organization",
},
);
logger.debug("here6")
return response<InviteUserResponse>(res, {
data: {
inviteLink,