refactor auth to work cross domain and with http resources closes #100

This commit is contained in:
Milo Schwartz 2025-01-26 14:42:02 -05:00
parent 6050a0a7d7
commit 9f1f2910e4
No known key found for this signature in database
27 changed files with 688 additions and 201 deletions

View file

@ -10,9 +10,9 @@ server:
next_port: 3002
internal_hostname: "pangolin"
secure_cookies: true
session_cookie_name: "p_session"
resource_session_cookie_name: "p_resource_session"
session_cookie_name: "p_session_token"
resource_access_token_param: "p_token"
resource_session_request_param: "p_session_request"
traefik:
cert_resolver: "letsencrypt"

View file

@ -10,9 +10,9 @@ server:
next_port: 3002
internal_hostname: "pangolin"
secure_cookies: true
session_cookie_name: "p_session"
resource_session_cookie_name: "p_resource_session"
session_cookie_name: "p_session_token"
resource_access_token_param: "p_token"
resource_session_request_param: "p_session_request"
cors:
origins: ["https://{{.DashboardDomain}}"]
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"]

View file

@ -64,6 +64,7 @@
"moment": "2.30.1",
"next": "15.1.3",
"next-themes": "0.4.4",
"node-cache": "5.1.2",
"node-fetch": "3.3.2",
"nodemailer": "6.9.16",
"oslo": "1.2.1",

View file

@ -3,7 +3,13 @@ import {
encodeHexLowerCase
} from "@oslojs/encoding";
import { sha256 } from "@oslojs/crypto/sha2";
import { Session, sessions, User, users } from "@server/db/schema";
import {
resourceSessions,
Session,
sessions,
User,
users
} from "@server/db/schema";
import db from "@server/db";
import { eq } from "drizzle-orm";
import config from "@server/lib/config";
@ -13,9 +19,14 @@ import logger from "@server/logger";
export const SESSION_COOKIE_NAME =
config.getRawConfig().server.session_cookie_name;
export const SESSION_COOKIE_EXPIRES = 1000 * 60 * 60 * 24 * 30;
export const SESSION_COOKIE_EXPIRES =
1000 *
60 *
60 *
config.getRawConfig().server.dashboard_session_length_hours;
export const SECURE_COOKIES = config.getRawConfig().server.secure_cookies;
export const COOKIE_DOMAIN = "." + config.getBaseDomain();
export const COOKIE_DOMAIN =
"." + new URL(config.getRawConfig().app.dashboard_url).hostname;
export function generateSessionToken(): string {
const bytes = new Uint8Array(20);
@ -65,12 +76,21 @@ export async function validateSessionToken(
session.expiresAt = new Date(
Date.now() + SESSION_COOKIE_EXPIRES
).getTime();
await db
.update(sessions)
.set({
expiresAt: session.expiresAt
})
.where(eq(sessions.sessionId, session.sessionId));
await db.transaction(async (trx) => {
await trx
.update(sessions)
.set({
expiresAt: session.expiresAt
})
.where(eq(sessions.sessionId, session.sessionId));
await trx
.update(resourceSessions)
.set({
expiresAt: session.expiresAt
})
.where(eq(resourceSessions.userSessionId, session.sessionId));
});
}
return { session, user };
}
@ -90,9 +110,9 @@ export function serializeSessionCookie(
if (isSecure) {
logger.debug("Setting cookie for secure origin");
if (SECURE_COOKIES) {
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES / 1000}; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
} else {
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Domain=${COOKIE_DOMAIN}`;
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES / 1000}; Path=/; Domain=${COOKIE_DOMAIN}`;
}
} else {
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Lax; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/;`;

View file

@ -6,19 +6,20 @@ import { eq, and } from "drizzle-orm";
import config from "@server/lib/config";
export const SESSION_COOKIE_NAME =
config.getRawConfig().server.resource_session_cookie_name;
export const SESSION_COOKIE_EXPIRES = 1000 * 60 * 60 * 24 * 30;
config.getRawConfig().server.session_cookie_name;
export const SESSION_COOKIE_EXPIRES =
1000 * 60 * 60 * config.getRawConfig().server.resource_session_length_hours;
export const SECURE_COOKIES = config.getRawConfig().server.secure_cookies;
export const COOKIE_DOMAIN = "." + config.getBaseDomain();
export async function createResourceSession(opts: {
token: string;
resourceId: number;
passwordId?: number;
pincodeId?: number;
whitelistId?: number;
accessTokenId?: string;
usedOtp?: boolean;
isRequestToken?: boolean;
passwordId?: number | null;
pincodeId?: number | null;
userSessionId?: string | null;
whitelistId?: number | null;
accessTokenId?: string | null;
doNotExtend?: boolean;
expiresAt?: number | null;
sessionLength?: number | null;
@ -27,7 +28,8 @@ export async function createResourceSession(opts: {
!opts.passwordId &&
!opts.pincodeId &&
!opts.whitelistId &&
!opts.accessTokenId
!opts.accessTokenId &&
!opts.userSessionId
) {
throw new Error("Auth method must be provided");
}
@ -47,7 +49,9 @@ export async function createResourceSession(opts: {
pincodeId: opts.pincodeId || null,
whitelistId: opts.whitelistId || null,
doNotExtend: opts.doNotExtend || false,
accessTokenId: opts.accessTokenId || null
accessTokenId: opts.accessTokenId || null,
isRequestToken: opts.isRequestToken || false,
userSessionId: opts.userSessionId || null
};
await db.insert(resourceSessions).values(session);
@ -162,22 +166,25 @@ export async function invalidateAllSessions(
export function serializeResourceSessionCookie(
cookieName: string,
token: string
domain: string,
token: string,
isHttp: boolean = false
): string {
if (SECURE_COOKIES) {
return `${cookieName}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
if (SECURE_COOKIES && !isHttp) {
return `${cookieName}_s=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES / 1000}; Path=/; Secure; Domain=${"." + domain}`;
} else {
return `${cookieName}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Domain=${COOKIE_DOMAIN}`;
return `${cookieName}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES / 1000}; Path=/; Domain=${"." + domain}`;
}
}
export function createBlankResourceSessionTokenCookie(
cookieName: string
cookieName: string,
domain: string
): string {
if (SECURE_COOKIES) {
return `${cookieName}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
return `${cookieName}_s=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Secure; Domain=${"." + domain}`;
} else {
return `${cookieName}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Domain=${COOKIE_DOMAIN}`;
return `${cookieName}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Domain=${"." + domain}`;
}
}

View file

@ -313,6 +313,10 @@ export const resourceSessions = sqliteTable("resourceSessions", {
doNotExtend: integer("doNotExtend", { mode: "boolean" })
.notNull()
.default(false),
isRequestToken: integer("isRequestToken", { mode: "boolean" }),
userSessionId: text("userSessionId").references(() => sessions.sessionId, {
onDelete: "cascade"
}),
passwordId: integer("passwordId").references(
() => resourcePassword.passwordId,
{

View file

@ -2,7 +2,7 @@ import { runSetupFunctions } from "./setup";
import { createApiServer } from "./apiServer";
import { createNextServer } from "./nextServer";
import { createInternalServer } from "./internalServer";
import { User, UserOrg } from "./db/schema";
import { Session, User, UserOrg } from "./db/schema";
async function startServers() {
await runSetupFunctions();
@ -24,6 +24,7 @@ declare global {
namespace Express {
interface Request {
user?: User;
session?: Session;
userOrg?: UserOrg;
userOrgRoleId?: number;
userOrgId?: string;

View file

@ -61,8 +61,20 @@ const configSchema = z.object({
internal_hostname: z.string().transform((url) => url.toLowerCase()),
secure_cookies: z.boolean(),
session_cookie_name: z.string(),
resource_session_cookie_name: z.string(),
resource_access_token_param: z.string(),
resource_session_request_param: z.string(),
dashboard_session_length_hours: z
.number()
.positive()
.gt(0)
.optional()
.default(720),
resource_session_length_hours: z
.number()
.positive()
.gt(0)
.optional()
.default(720),
cors: z
.object({
origins: z.array(z.string()).optional(),
@ -241,8 +253,6 @@ export class Config {
: "false";
process.env.SESSION_COOKIE_NAME =
parsedConfig.data.server.session_cookie_name;
process.env.RESOURCE_SESSION_COOKIE_NAME =
parsedConfig.data.server.resource_session_cookie_name;
process.env.EMAIL_ENABLED = parsedConfig.data.email ? "true" : "false";
process.env.DISABLE_SIGNUP_WITHOUT_INVITE = parsedConfig.data.flags
?.disable_signup_without_invite
@ -254,6 +264,8 @@ export class Config {
: "false";
process.env.RESOURCE_ACCESS_TOKEN_PARAM =
parsedConfig.data.server.resource_access_token_param;
process.env.RESOURCE_SESSION_REQUEST_PARAM =
parsedConfig.data.server.resource_session_request_param;
this.rawConfig = parsedConfig.data;
}

View file

@ -5,18 +5,17 @@ import response from "@server/lib/response";
import logger from "@server/logger";
import {
createBlankSessionTokenCookie,
invalidateSession,
SESSION_COOKIE_NAME
invalidateSession
} from "@server/auth/sessions/app";
import { verifySession } from "@server/auth/sessions/verifySession";
export async function logout(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
const sessionId = req.cookies[SESSION_COOKIE_NAME];
if (!sessionId) {
const { user, session } = await verifySession(req);
if (!user || !session) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
@ -26,7 +25,7 @@ export async function logout(
}
try {
await invalidateSession(sessionId);
await invalidateSession(session.sessionId);
const isSecure = req.protocol === "https";
res.setHeader("Set-Cookie", createBlankSessionTokenCookie(isSecure));

View file

@ -0,0 +1,175 @@
import HttpCode from "@server/types/HttpCode";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
import logger from "@server/logger";
import { resourceAccessToken, resources, sessions } from "@server/db/schema";
import db from "@server/db";
import { eq } from "drizzle-orm";
import {
createResourceSession,
serializeResourceSessionCookie,
validateResourceSessionToken
} from "@server/auth/sessions/resource";
import { generateSessionToken } from "@server/auth";
import { SESSION_COOKIE_EXPIRES } from "@server/auth/sessions/app";
import { SESSION_COOKIE_EXPIRES as RESOURCE_SESSION_COOKIE_EXPIRES } from "@server/auth/sessions/resource";
import config from "@server/lib/config";
import { response } from "@server/lib";
const exchangeSessionBodySchema = z.object({
requestToken: z.string(),
host: z.string()
});
export type ExchangeSessionBodySchema = z.infer<
typeof exchangeSessionBodySchema
>;
export type ExchangeSessionResponse = {
valid: boolean;
cookie?: string;
};
export async function exchangeSession(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
logger.debug("Exchange session: Badger sent", req.body);
const parsedBody = exchangeSessionBodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
try {
const { requestToken, host } = parsedBody.data;
const [resource] = await db
.select()
.from(resources)
.where(eq(resources.fullDomain, host))
.limit(1);
if (!resource) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource with host ${host} not found`
)
);
}
const { resourceSession: requestSession } =
await validateResourceSessionToken(
requestToken,
resource.resourceId
);
if (!requestSession) {
return next(
createHttpError(HttpCode.UNAUTHORIZED, "Invalid request token")
);
}
if (!requestSession.isRequestToken) {
return next(
createHttpError(HttpCode.UNAUTHORIZED, "Invalid request token")
);
}
await db.delete(sessions).where(eq(sessions.sessionId, requestToken));
const token = generateSessionToken();
if (requestSession.userSessionId) {
const [res] = await db
.select()
.from(sessions)
.where(eq(sessions.sessionId, requestSession.userSessionId))
.limit(1);
if (res) {
await createResourceSession({
token,
resourceId: resource.resourceId,
isRequestToken: false,
userSessionId: requestSession.userSessionId,
doNotExtend: false,
expiresAt: res.expiresAt,
sessionLength: SESSION_COOKIE_EXPIRES
});
}
} else if (requestSession.accessTokenId) {
const [res] = await db
.select()
.from(resourceAccessToken)
.where(
eq(
resourceAccessToken.accessTokenId,
requestSession.accessTokenId
)
)
.limit(1);
if (res) {
await createResourceSession({
token,
resourceId: resource.resourceId,
isRequestToken: false,
accessTokenId: requestSession.accessTokenId,
doNotExtend: true,
expiresAt: res.expiresAt,
sessionLength: res.sessionLength
});
}
} else {
await createResourceSession({
token,
resourceId: resource.resourceId,
isRequestToken: false,
passwordId: requestSession.passwordId,
pincodeId: requestSession.pincodeId,
userSessionId: requestSession.userSessionId,
whitelistId: requestSession.whitelistId,
accessTokenId: requestSession.accessTokenId,
doNotExtend: false,
expiresAt: new Date(
Date.now() + SESSION_COOKIE_EXPIRES
).getTime(),
sessionLength: RESOURCE_SESSION_COOKIE_EXPIRES
});
}
const cookieName = `${config.getRawConfig().server.session_cookie_name}`;
const cookie = serializeResourceSessionCookie(
cookieName,
resource.fullDomain,
token,
!resource.ssl
);
logger.debug(JSON.stringify("Exchange cookie: " + cookie));
return response<ExchangeSessionResponse>(res, {
data: { valid: true, cookie },
success: true,
error: false,
message: "Session exchanged successfully",
status: HttpCode.OK
});
} catch (e) {
console.error(e);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to exchange session"
)
);
}
}

View file

@ -1 +1,2 @@
export * from "./verifySession";
export * from "./exchangeSession";

View file

@ -4,17 +4,17 @@ import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
import { response } from "@server/lib/response";
import { validateSessionToken } from "@server/auth/sessions/app";
import db from "@server/db";
import {
ResourceAccessToken,
resourceAccessToken,
ResourcePassword,
resourcePassword,
ResourcePincode,
resourcePincode,
resources,
resourceWhitelist,
User,
userOrgs
sessions,
userOrgs,
users
} from "@server/db/schema";
import { and, eq } from "drizzle-orm";
import config from "@server/lib/config";
@ -27,6 +27,12 @@ import { Resource, roleResources, userResources } from "@server/db/schema";
import logger from "@server/logger";
import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
import { generateSessionToken } from "@server/auth";
import NodeCache from "node-cache";
// We'll see if this speeds anything up
const cache = new NodeCache({
stdTTL: 5 // seconds
});
const verifyResourceSessionSchema = z.object({
sessions: z.record(z.string()).optional(),
@ -53,7 +59,7 @@ export async function verifyResourceSession(
res: Response,
next: NextFunction
): Promise<any> {
logger.debug("Badger sent", req.body); // remove when done testing
logger.debug("Verify session: Badger sent", req.body); // remove when done testing
const parsedBody = verifyResourceSessionSchema.safeParse(req.body);
@ -67,26 +73,52 @@ export async function verifyResourceSession(
}
try {
const { sessions, host, originalRequestURL, accessToken: token } =
parsedBody.data;
const {
sessions,
host,
originalRequestURL,
accessToken: token
} = parsedBody.data;
const [result] = await db
.select()
.from(resources)
.leftJoin(
resourcePincode,
eq(resourcePincode.resourceId, resources.resourceId)
)
.leftJoin(
resourcePassword,
eq(resourcePassword.resourceId, resources.resourceId)
)
.where(eq(resources.fullDomain, host))
.limit(1);
const resourceCacheKey = `resource:${host}`;
let resourceData:
| {
resource: Resource | null;
pincode: ResourcePincode | null;
password: ResourcePassword | null;
}
| undefined = cache.get(resourceCacheKey);
const resource = result?.resources;
const pincode = result?.resourcePincode;
const password = result?.resourcePassword;
if (!resourceData) {
const [result] = await db
.select()
.from(resources)
.leftJoin(
resourcePincode,
eq(resourcePincode.resourceId, resources.resourceId)
)
.leftJoin(
resourcePassword,
eq(resourcePassword.resourceId, resources.resourceId)
)
.where(eq(resources.fullDomain, host))
.limit(1);
if (!result) {
logger.debug("Resource not found", host);
return notAllowed(res);
}
resourceData = {
resource: result.resources,
pincode: result.resourcePincode,
password: result.resourcePassword
};
cache.set(resourceCacheKey, resourceData);
}
const { resource, pincode, password } = resourceData;
if (!resource) {
logger.debug("Resource not found", host);
@ -145,37 +177,31 @@ export async function verifyResourceSession(
return notAllowed(res);
}
const sessionToken =
sessions[config.getRawConfig().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,
resource
);
if (isAllowed) {
logger.debug(
"Resource allowed because user session is valid"
);
return allowed(res);
}
}
}
const resourceSessionToken =
sessions[
`${config.getRawConfig().server.resource_session_cookie_name}_${resource.resourceId}`
`${config.getRawConfig().server.session_cookie_name}${resource.ssl ? "_s" : ""}`
];
if (resourceSessionToken) {
const { resourceSession } = await validateResourceSessionToken(
resourceSessionToken,
resource.resourceId
);
const sessionCacheKey = `session:${resourceSessionToken}`;
let resourceSession: any = cache.get(sessionCacheKey);
if (!resourceSession) {
const result = await validateResourceSessionToken(
resourceSessionToken,
resource.resourceId
);
resourceSession = result?.resourceSession;
cache.set(sessionCacheKey, resourceSession);
}
if (resourceSession?.isRequestToken) {
logger.debug(
"Resource not allowed because session is a temporary request token"
);
return notAllowed(res);
}
if (resourceSession) {
if (pincode && resourceSession.pincodeId) {
@ -208,6 +234,29 @@ export async function verifyResourceSession(
);
return allowed(res);
}
if (resourceSession.userSessionId && sso) {
const userAccessCacheKey = `userAccess:${resourceSession.userSessionId}:${resource.resourceId}`;
let isAllowed: boolean | undefined =
cache.get(userAccessCacheKey);
if (isAllowed === undefined) {
isAllowed = await isUserAllowedToAccessResource(
resourceSession.userSessionId,
resource
);
cache.set(userAccessCacheKey, isAllowed);
}
if (isAllowed) {
logger.debug(
"Resource allowed because user session is valid"
);
return allowed(res);
}
}
}
}
@ -272,10 +321,15 @@ async function createAccessTokenSession(
expiresAt: tokenItem.expiresAt,
doNotExtend: tokenItem.expiresAt ? true : false
});
const cookieName = `${config.getRawConfig().server.resource_session_cookie_name}_${resource.resourceId}`;
const cookie = serializeResourceSessionCookie(cookieName, token);
const cookieName = `${config.getRawConfig().server.session_cookie_name}`;
const cookie = serializeResourceSessionCookie(
cookieName,
resource.fullDomain,
token,
!resource.ssl
);
res.appendHeader("Set-Cookie", cookie);
logger.debug("Access token is valid, creating new session")
logger.debug("Access token is valid, creating new session");
return response<VerifyUserResponse>(res, {
data: { valid: true },
success: true,
@ -286,9 +340,22 @@ async function createAccessTokenSession(
}
async function isUserAllowedToAccessResource(
user: User,
userSessionId: string,
resource: Resource
): Promise<boolean> {
const [res] = await db
.select()
.from(sessions)
.leftJoin(users, eq(users.userId, sessions.userId))
.where(eq(sessions.sessionId, userSessionId));
const user = res.user;
const session = res.session;
if (!user || !session) {
return false;
}
if (
config.getRawConfig().flags?.require_email_verification &&
!user.emailVerified

View file

@ -3,7 +3,9 @@ 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 * as resource from "@server/routers/resource";
import HttpCode from "@server/types/HttpCode";
import { verifyResourceAccess, verifySessionUserMiddleware } from "@server/middlewares";
// Root routes
const internalRouter = Router();
@ -15,7 +17,14 @@ internalRouter.get("/", (_, res) => {
internalRouter.get("/traefik-config", traefik.traefikConfigProvider);
internalRouter.get(
"/resource-session/:resourceId/:token",
auth.checkResourceSession,
auth.checkResourceSession
);
internalRouter.post(
`/resource/:resourceId/get-exchange-token`,
verifySessionUserMiddleware,
verifyResourceAccess,
resource.getExchangeToken
);
// Gerbil routes
@ -30,5 +39,6 @@ const badgerRouter = Router();
internalRouter.use("/badger", badgerRouter);
badgerRouter.post("/verify-session", badger.verifyResourceSession);
badgerRouter.post("/exchange-session", badger.exchangeSession);
export default internalRouter;

View file

@ -1,18 +1,16 @@
import { generateSessionToken } from "@server/auth/sessions/app";
import db from "@server/db";
import { resourceAccessToken, resources } from "@server/db/schema";
import { resources } from "@server/db/schema";
import HttpCode from "@server/types/HttpCode";
import response from "@server/lib/response";
import { eq, and } from "drizzle-orm";
import { eq } 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/sessions/resource";
import config from "@server/lib/config";
import logger from "@server/logger";
import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
@ -108,13 +106,11 @@ export async function authWithAccessToken(
resourceId,
token,
accessTokenId: tokenItem.accessTokenId,
sessionLength: tokenItem.sessionLength,
expiresAt: tokenItem.expiresAt,
doNotExtend: tokenItem.expiresAt ? true : false
isRequestToken: true,
expiresAt: Date.now() + 1000 * 30, // 30 seconds
sessionLength: 1000 * 30,
doNotExtend: true
});
const cookieName = `${config.getRawConfig().server.resource_session_cookie_name}_${resource.resourceId}`;
const cookie = serializeResourceSessionCookie(cookieName, token);
res.appendHeader("Set-Cookie", cookie);
return response<AuthWithAccessTokenResponse>(res, {
data: {

View file

@ -11,9 +11,7 @@ import { z } from "zod";
import { fromError } from "zod-validation-error";
import {
createResourceSession,
serializeResourceSessionCookie
} from "@server/auth/sessions/resource";
import config from "@server/lib/config";
import logger from "@server/logger";
import { verifyPassword } from "@server/auth/password";
@ -120,11 +118,12 @@ export async function authWithPassword(
await createResourceSession({
resourceId,
token,
passwordId: definedPassword.passwordId
passwordId: definedPassword.passwordId,
isRequestToken: true,
expiresAt: Date.now() + 1000 * 30, // 30 seconds
sessionLength: 1000 * 30,
doNotExtend: true
});
const cookieName = `${config.getRawConfig().server.resource_session_cookie_name}_${resource.resourceId}`;
const cookie = serializeResourceSessionCookie(cookieName, token);
res.appendHeader("Set-Cookie", cookie);
return response<AuthWithPasswordResponse>(res, {
data: {

View file

@ -1,28 +1,21 @@
import { verify } from "@node-rs/argon2";
import { generateSessionToken } from "@server/auth/sessions/app";
import db from "@server/db";
import {
orgs,
resourceOtp,
resourcePincode,
resources,
resourceWhitelist
} from "@server/db/schema";
import HttpCode from "@server/types/HttpCode";
import response from "@server/lib/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";
import { fromError } from "zod-validation-error";
import {
createResourceSession,
serializeResourceSessionCookie
} from "@server/auth/sessions/resource";
import logger from "@server/logger";
import config from "@server/lib/config";
import { AuthWithPasswordResponse } from "./authWithPassword";
import { isValidOtp, sendResourceOtpEmail } from "@server/auth/resourceOtp";
import { verifyPassword } from "@server/auth/password";
export const authWithPincodeBodySchema = z
@ -128,11 +121,12 @@ export async function authWithPincode(
await createResourceSession({
resourceId,
token,
pincodeId: definedPincode.pincodeId
pincodeId: definedPincode.pincodeId,
isRequestToken: true,
expiresAt: Date.now() + 1000 * 30, // 30 seconds
sessionLength: 1000 * 30,
doNotExtend: true
});
const cookieName = `${config.getRawConfig().server.resource_session_cookie_name}_${resource.resourceId}`;
const cookie = serializeResourceSessionCookie(cookieName, token);
res.appendHeader("Set-Cookie", cookie);
return response<AuthWithPincodeResponse>(res, {
data: {

View file

@ -3,7 +3,6 @@ import db from "@server/db";
import {
orgs,
resourceOtp,
resourcePassword,
resources,
resourceWhitelist
} from "@server/db/schema";
@ -16,9 +15,7 @@ import { z } from "zod";
import { fromError } from "zod-validation-error";
import {
createResourceSession,
serializeResourceSessionCookie
} from "@server/auth/sessions/resource";
import config from "@server/lib/config";
import { isValidOtp, sendResourceOtpEmail } from "@server/auth/resourceOtp";
import logger from "@server/logger";
@ -178,11 +175,12 @@ export async function authWithWhitelist(
await createResourceSession({
resourceId,
token,
whitelistId: whitelistedEmail.whitelistId
whitelistId: whitelistedEmail.whitelistId,
isRequestToken: true,
expiresAt: Date.now() + 1000 * 30, // 30 seconds
sessionLength: 1000 * 30,
doNotExtend: true
});
const cookieName = `${config.getRawConfig().server.resource_session_cookie_name}_${resource.resourceId}`;
const cookie = serializeResourceSessionCookie(cookieName, token);
res.appendHeader("Set-Cookie", cookie);
return response<AuthWithWhitelistResponse>(res, {
data: {

View file

@ -0,0 +1,109 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { resources } from "@server/db/schema";
import { eq } from "drizzle-orm";
import { createResourceSession } from "@server/auth/sessions/resource";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import { fromError } from "zod-validation-error";
import logger from "@server/logger";
import { generateSessionToken } from "@server/auth/sessions/app";
import config from "@server/lib/config";
import {
encodeHexLowerCase
} from "@oslojs/encoding";
import { sha256 } from "@oslojs/crypto/sha2";
import { response } from "@server/lib";
const getExchangeTokenParams = z
.object({
resourceId: z
.string()
.transform(Number)
.pipe(z.number().int().positive())
})
.strict();
export type GetExchangeTokenResponse = {
requestToken: string;
};
export async function getExchangeToken(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = getExchangeTokenParams.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))
.limit(1);
if (resource.length === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource with ID ${resourceId} not found`
)
);
}
const ssoSession =
req.cookies[config.getRawConfig().server.session_cookie_name];
if (!ssoSession) {
logger.debug(ssoSession);
return next(
createHttpError(
HttpCode.UNAUTHORIZED,
"Missing SSO session cookie"
)
);
}
const sessionId = encodeHexLowerCase(
sha256(new TextEncoder().encode(ssoSession))
);
const token = generateSessionToken();
await createResourceSession({
resourceId,
token,
userSessionId: sessionId,
isRequestToken: true,
expiresAt: Date.now() + 1000 * 30, // 30 seconds
sessionLength: 1000 * 30,
doNotExtend: true
});
logger.debug("Request token created successfully");
return response<GetExchangeTokenResponse>(res, {
data: {
requestToken: token
},
success: true,
error: false,
message: "Request token created successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View file

@ -16,3 +16,4 @@ export * from "./setResourceWhitelist";
export * from "./getResourceWhitelist";
export * from "./authWithWhitelist";
export * from "./authWithAccessToken";
export * from "./getExchangeToken";

View file

@ -111,6 +111,10 @@ export async function updateResource(
);
}
if (resource[0].resources.ssl !== updatedResource[0].ssl) {
// invalidate all sessions?
}
return response(res, {
data: updatedResource[0],
success: true,

View file

@ -39,7 +39,7 @@ export async function traefikConfigProvider(
}
const badgerMiddlewareName = "badger";
const redirectMiddlewareName = "redirect-to-https";
const redirectHttpsMiddlewareName = "redirect-to-https";
const http: any = {
routers: {},
@ -52,19 +52,18 @@ export async function traefikConfigProvider(
"/api/v1",
`http://${config.getRawConfig().server.internal_hostname}:${config.getRawConfig().server.internal_port}`,
).href,
resourceSessionCookieName:
config.getRawConfig().server.resource_session_cookie_name,
userSessionCookieName:
config.getRawConfig().server.session_cookie_name,
accessTokenQueryParam: config.getRawConfig().server.resource_access_token_param,
resourceSessionRequestParam: config.getRawConfig().server.resource_session_request_param
},
},
},
[redirectMiddlewareName]: {
[redirectHttpsMiddlewareName]: {
redirectScheme: {
scheme: "https"
},
},
}
},
};
for (const item of all) {
@ -120,10 +119,9 @@ export async function traefikConfigProvider(
};
if (resource.ssl) {
// this is a redirect router; all it does is redirect to the https version if tls is enabled
http.routers![routerName + "-redirect"] = {
entryPoints: [config.getRawConfig().traefik.http_entrypoint],
middlewares: [redirectMiddlewareName],
middlewares: [redirectHttpsMiddlewareName],
service: serviceName,
rule: `Host(\`${fullDomain}\`)`,
};

View file

@ -7,7 +7,13 @@ import {
userInvites,
users
} from "@server/db/schema";
import { APP_PATH, configFilePath1, configFilePath2 } from "@server/lib/consts";
import { sql } from "drizzle-orm";
import fs from "fs";
import yaml from "js-yaml";
import path from "path";
import { z } from "zod";
import { fromZodError } from "zod-validation-error";
export default async function migration() {
console.log("Running setup script 1.0.0-beta.9...");
@ -32,5 +38,96 @@ export default async function migration() {
console.error(error);
}
try {
// Determine which config file exists
const filePaths = [configFilePath1, configFilePath2];
let filePath = "";
for (const path of filePaths) {
if (fs.existsSync(path)) {
filePath = path;
break;
}
}
if (!filePath) {
throw new Error(
`No config file found (expected config.yml or config.yaml).`
);
}
// Read and parse the YAML file
let rawConfig: any;
const fileContents = fs.readFileSync(filePath, "utf8");
rawConfig = yaml.load(fileContents);
rawConfig.server.resource_session_request_param = "p_session_request";
rawConfig.server.session_cookie_name = "p_session_token"; // rename to prevent conflicts
delete rawConfig.server.resource_session_cookie_name;
// Write the updated YAML back to the file
const updatedYaml = yaml.dump(rawConfig);
fs.writeFileSync(filePath, updatedYaml, "utf8");
} catch (e) {
console.log(
`Failed to add resource_session_request_param to config. Please add it manually. https://docs.fossorial.io/Pangolin/Configuration/config`
);
throw e;
}
try {
const traefikPath = path.join(
APP_PATH,
"traefik",
"traefik_config.yml"
);
const schema = z.object({
experimental: z.object({
plugins: z.object({
badger: z.object({
moduleName: z.string(),
version: z.string()
})
})
})
});
const traefikFileContents = fs.readFileSync(traefikPath, "utf8");
const traefikConfig = yaml.load(traefikFileContents) as any;
const parsedConfig = schema.safeParse(traefikConfig);
if (!parsedConfig.success) {
throw new Error(fromZodError(parsedConfig.error).toString());
}
traefikConfig.experimental.plugins.badger.version = "v1.0.0-beta.3";
const updatedTraefikYaml = yaml.dump(traefikConfig);
fs.writeFileSync(traefikPath, updatedTraefikYaml, "utf8");
console.log(
"Updated the version of Badger in your Traefik configuration to v1.0.0-beta.3."
);
} catch (e) {
console.log(
"We were unable to update the version of Badger in your Traefik configuration. Please update it manually."
);
console.error(e);
}
try {
await db.transaction(async (trx) => {
trx.run(sql`ALTER TABLE 'resourceSessions' ADD 'isRequestToken' integer;`);
trx.run(sql`ALTER TABLE 'resourceSessions' ADD 'userSessionId' text REFERENCES session(id);`);
});
} catch (e) {
console.log(
"We were unable to add columns to the resourceSessions table."
);
throw e;
}
console.log("Done.");
}

View file

@ -5,14 +5,12 @@ import { Button } from "@app/components/ui/button";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle
} from "@app/components/ui/card";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { AuthWithAccessTokenResponse } from "@server/routers/resource";
import { AxiosResponse } from "axios";
import { Loader2 } from "lucide-react";
import Link from "next/link";
import { useEffect, useState } from "react";
@ -32,7 +30,17 @@ export default function AccessToken({
const [loading, setLoading] = useState(true);
const [isValid, setIsValid] = useState(false);
const api = createApiClient(useEnvContext());
const { env } = useEnvContext();
const api = createApiClient({ env });
function appendRequestToken(url: string, token: string) {
const fullUrl = new URL(url);
fullUrl.searchParams.append(
env.server.resourceSessionRequestParam,
token
);
return fullUrl.toString();
}
useEffect(() => {
if (!accessTokenId || !accessToken) {
@ -51,7 +59,10 @@ export default function AccessToken({
if (res.data.data.session) {
setIsValid(true);
window.location.href = redirectUrl;
window.location.href = appendRequestToken(
redirectUrl,
res.data.data.session
);
}
} catch (e) {
console.error("Error checking access token", e);

View file

@ -1,6 +1,6 @@
"use client";
import { useEffect, useState, useSyncExternalStore } from "react";
import { useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import * as z from "zod";
@ -8,7 +8,6 @@ import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle
} from "@/components/ui/card";
@ -30,9 +29,6 @@ import {
Key,
User,
Send,
ArrowLeft,
ArrowRight,
Lock,
AtSign
} from "lucide-react";
import {
@ -47,10 +43,8 @@ import { AxiosResponse } from "axios";
import LoginForm from "@app/components/LoginForm";
import {
AuthWithPasswordResponse,
AuthWithAccessTokenResponse,
AuthWithWhitelistResponse
} from "@server/routers/resource";
import { redirect } from "next/dist/server/api-utils";
import ResourceAccessDenied from "./ResourceAccessDenied";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
@ -118,7 +112,9 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
const [otpState, setOtpState] = useState<"idle" | "otp_sent">("idle");
const api = createApiClient(useEnvContext());
const { env } = useEnvContext();
const api = createApiClient({ env });
function getDefaultSelectedMethod() {
if (props.methods.sso) {
@ -169,6 +165,15 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
}
});
function appendRequestToken(url: string, token: string) {
const fullUrl = new URL(url);
fullUrl.searchParams.append(
env.server.resourceSessionRequestParam,
token
);
return fullUrl.toString();
}
const onWhitelistSubmit = (values: any) => {
setLoadingLogin(true);
api.post<AxiosResponse<AuthWithWhitelistResponse>>(
@ -190,7 +195,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
const session = res.data.data.session;
if (session) {
window.location.href = props.redirect;
window.location.href = appendRequestToken(props.redirect, session);
}
})
.catch((e) => {
@ -212,7 +217,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
setPincodeError(null);
const session = res.data.data.session;
if (session) {
window.location.href = props.redirect;
window.location.href = appendRequestToken(props.redirect, session);
}
})
.catch((e) => {
@ -237,7 +242,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
setPasswordError(null);
const session = res.data.data.session;
if (session) {
window.location.href = props.redirect;
window.location.href = appendRequestToken(props.redirect, session);
}
})
.catch((e) => {
@ -619,16 +624,6 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
</Tabs>
</CardContent>
</Card>
{/* {activeTab === "sso" && (
<div className="flex justify-center mt-4">
<p className="text-sm text-muted-foreground">
Don't have an account?{" "}
<a href="#" className="underline">
Sign up
</a>
</p>
</div>
)} */}
</div>
) : (
<ResourceAccessDenied />

View file

@ -1,7 +1,6 @@
import {
AuthWithAccessTokenResponse,
GetResourceAuthInfoResponse,
GetResourceResponse
GetExchangeTokenResponse
} from "@server/routers/resource";
import ResourceAuthPortal from "./ResourceAuthPortal";
import { internal, priv } from "@app/lib/api";
@ -12,9 +11,6 @@ import { verifySession } from "@app/lib/auth/verifySession";
import { redirect } from "next/navigation";
import ResourceNotFound from "./ResourceNotFound";
import ResourceAccessDenied from "./ResourceAccessDenied";
import { cookies } from "next/headers";
import { CheckResourceSessionResponse } from "@server/routers/auth";
import AccessTokenInvalid from "./AccessToken";
import AccessToken from "./AccessToken";
import { pullEnv } from "@app/lib/pullEnv";
@ -48,7 +44,7 @@ export default async function ResourceAuthPage(props: {
// TODO: fix this
return (
<div className="w-full max-w-md">
{/* @ts-ignore */}
{/* @ts-ignore */}
<ResourceNotFound />
</div>
);
@ -83,49 +79,41 @@ export default async function ResourceAuthPage(props: {
);
}
const allCookies = await cookies();
const cookieName =
env.server.resourceSessionCookieName + `_${params.resourceId}`;
const sessionId = allCookies.get(cookieName)?.value ?? null;
if (sessionId) {
let doRedirect = false;
try {
const res = await priv.get<
AxiosResponse<CheckResourceSessionResponse>
>(`/resource-session/${params.resourceId}/${sessionId}`);
if (res && res.data.data.valid) {
doRedirect = true;
}
} catch (e) {}
if (doRedirect) {
redirect(redirectUrl);
}
}
if (!hasAuth) {
// no authentication so always go straight to the resource
redirect(redirectUrl);
}
// convert the dashboard token into a resource session token
let userIsUnauthorized = false;
if (user && authInfo.sso) {
let doRedirect = false;
let redirectToUrl: string | undefined;
try {
const res = await internal.get<AxiosResponse<GetResourceResponse>>(
`/resource/${params.resourceId}`,
const res = await priv.post<
AxiosResponse<GetExchangeTokenResponse>
>(
`/resource/${params.resourceId}/get-exchange-token`,
{},
await authCookieHeader()
);
doRedirect = true;
if (res.data.data.requestToken) {
const paramName = env.server.resourceSessionRequestParam;
// append the param with the token to the redirect url
const fullUrl = new URL(redirectUrl);
fullUrl.searchParams.append(
paramName,
res.data.data.requestToken
);
redirectToUrl = fullUrl.toString();
}
} catch (e) {
userIsUnauthorized = true;
}
if (doRedirect) {
redirect(redirectUrl);
if (redirectToUrl) {
redirect(redirectToUrl);
}
}

View file

@ -6,8 +6,8 @@ export function pullEnv(): Env {
nextPort: process.env.NEXT_PORT as string,
externalPort: process.env.SERVER_EXTERNAL_PORT as string,
sessionCookieName: process.env.SESSION_COOKIE_NAME as string,
resourceSessionCookieName: process.env.RESOURCE_SESSION_COOKIE_NAME as string,
resourceAccessTokenParam: process.env.RESOURCE_ACCESS_TOKEN_PARAM as string
resourceAccessTokenParam: process.env.RESOURCE_ACCESS_TOKEN_PARAM as string,
resourceSessionRequestParam: process.env.RESOURCE_SESSION_REQUEST_PARAM as string
},
app: {
environment: process.env.ENVIRONMENT as string,

View file

@ -7,8 +7,8 @@ export type Env = {
externalPort: string;
nextPort: string;
sessionCookieName: string;
resourceSessionCookieName: string;
resourceAccessTokenParam: string;
resourceSessionRequestParam: string;
},
email: {
emailEnabled: boolean;