allow access token in resource url

This commit is contained in:
Milo Schwartz 2025-01-11 19:47:07 -05:00
parent e32301ade4
commit f5fda5d8ea
No known key found for this signature in database
10 changed files with 226 additions and 55 deletions

View file

@ -0,0 +1,45 @@
import db from "@server/db";
import { and, eq } from "drizzle-orm";
import { roleResources, userResources } from "@server/db/schema";
export async function canUserAccessResource({
userId,
resourceId,
roleId
}: {
userId: string;
resourceId: number;
roleId: number;
}): Promise<boolean> {
const roleResourceAccess = await db
.select()
.from(roleResources)
.where(
and(
eq(roleResources.resourceId, resourceId),
eq(roleResources.roleId, roleId)
)
)
.limit(1);
if (roleResourceAccess.length > 0) {
return true;
}
const userResourceAccess = await db
.select()
.from(userResources)
.where(
and(
eq(userResources.userId, userId),
eq(userResources.resourceId, resourceId)
)
)
.limit(1);
if (userResourceAccess.length > 0) {
return true;
}
return false;
}

View file

@ -0,0 +1,67 @@
import db from "@server/db";
import {
Resource,
ResourceAccessToken,
resourceAccessToken,
} from "@server/db/schema";
import { and, eq } from "drizzle-orm";
import { isWithinExpirationDate } from "oslo";
import { verifyPassword } from "./password";
export async function verifyResourceAccessToken({
resource,
accessTokenId,
accessToken
}: {
resource: Resource;
accessTokenId: string;
accessToken: string;
}): Promise<{
valid: boolean;
error?: string;
tokenItem?: ResourceAccessToken;
}> {
const [result] = await db
.select()
.from(resourceAccessToken)
.where(
and(
eq(resourceAccessToken.resourceId, resource.resourceId),
eq(resourceAccessToken.accessTokenId, accessTokenId)
)
)
.limit(1);
const tokenItem = result;
if (!tokenItem) {
return {
valid: false,
error: "Access token does not exist for resource"
};
}
const validCode = await verifyPassword(accessToken, tokenItem.tokenHash);
if (!validCode) {
return {
valid: false,
error: "Invalid access token"
};
}
if (
tokenItem.expiresAt &&
!isWithinExpirationDate(new Date(tokenItem.expiresAt))
) {
return {
valid: false,
error: "Access token has expired"
};
}
return {
valid: true,
tokenItem
};
}

View file

@ -11,9 +11,9 @@ const portSchema = z.number().positive().gt(0).lte(65535);
const hostnameSchema = z
.string()
.regex(
/^(?!-)[a-zA-Z0-9-]{1,63}(?<!-)(\.[a-zA-Z]{2,})*$/,
"Invalid hostname. Must be a valid hostname like 'localhost' or 'test.example.com'."
);
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$|^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)+([A-Za-z]|[A-Za-z][A-Za-z0-9\-]*[A-Za-z0-9])$/
)
.or(z.literal("localhost"));
const environmentSchema = z.object({
app: z.object({

View file

@ -4,7 +4,7 @@ import { resourceAccessToken, resources, userOrgs } from "@server/db/schema";
import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { canUserAccessResource } from "@server/lib/canUserAccessResource";
import { canUserAccessResource } from "@server/auth/canUserAccessResource";
export async function verifyAccessTokenAccess(
req: Request,

View file

@ -4,7 +4,7 @@ import { resources, targets, userOrgs } from "@server/db/schema";
import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { canUserAccessResource } from "../lib/canUserAccessResource";
import { canUserAccessResource } from "../auth/canUserAccessResource";
export async function verifyTargetAccess(
req: Request,

View file

@ -7,6 +7,7 @@ import { response } from "@server/lib/response";
import { validateSessionToken } from "@server/auth/sessions/app";
import db from "@server/db";
import {
ResourceAccessToken,
resourceAccessToken,
resourcePassword,
resourcePincode,
@ -17,9 +18,15 @@ import {
} from "@server/db/schema";
import { and, eq } from "drizzle-orm";
import config from "@server/lib/config";
import { validateResourceSessionToken } from "@server/auth/sessions/resource";
import {
createResourceSession,
serializeResourceSessionCookie,
validateResourceSessionToken
} from "@server/auth/sessions/resource";
import { Resource, roleResources, userResources } from "@server/db/schema";
import logger from "@server/logger";
import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
import { generateSessionToken } from "@server/auth";
const verifyResourceSessionSchema = z.object({
sessions: z.record(z.string()).optional(),
@ -28,6 +35,7 @@ const verifyResourceSessionSchema = z.object({
host: z.string(),
path: z.string(),
method: z.string(),
accessToken: z.string().optional(),
tls: z.boolean()
});
@ -59,7 +67,8 @@ export async function verifyResourceSession(
}
try {
const { sessions, host, originalRequestURL } = parsedBody.data;
const { sessions, host, originalRequestURL, accessToken: token } =
parsedBody.data;
const [result] = await db
.select()
@ -103,11 +112,41 @@ export async function verifyResourceSession(
const redirectUrl = `${config.getRawConfig().app.dashboard_url}/auth/resource/${encodeURIComponent(resource.resourceId)}?redirect=${encodeURIComponent(originalRequestURL)}`;
// check for access token
let validAccessToken: ResourceAccessToken | undefined;
if (token) {
const [accessTokenId, accessToken] = token.split(".");
const { valid, error, tokenItem } = await verifyResourceAccessToken(
{
resource,
accessTokenId,
accessToken
}
);
if (error) {
logger.debug("Access token invalid: " + error);
}
if (valid && tokenItem) {
validAccessToken = tokenItem;
if (!sessions) {
return await createAccessTokenSession(
res,
resource,
tokenItem
);
}
}
}
if (!sessions) {
return notAllowed(res);
}
const sessionToken = sessions[config.getRawConfig().server.session_cookie_name];
const sessionToken =
sessions[config.getRawConfig().server.session_cookie_name];
// check for unified login
if (sso && sessionToken) {
@ -172,6 +211,16 @@ export async function verifyResourceSession(
}
}
// At this point we have checked all sessions, but since the access token is valid, we should allow access
// and create a new session.
if (validAccessToken) {
return await createAccessTokenSession(
res,
resource,
validAccessToken
);
}
logger.debug("No more auth to check, resource not allowed");
return notAllowed(res, redirectUrl);
} catch (e) {
@ -209,11 +258,41 @@ function allowed(res: Response) {
return response<VerifyUserResponse>(res, data);
}
async function createAccessTokenSession(
res: Response,
resource: Resource,
tokenItem: ResourceAccessToken
) {
const token = generateSessionToken();
await createResourceSession({
resourceId: resource.resourceId,
token,
accessTokenId: tokenItem.accessTokenId,
sessionLength: tokenItem.sessionLength,
expiresAt: tokenItem.expiresAt,
doNotExtend: tokenItem.expiresAt ? true : false
});
const cookieName = `${config.getRawConfig().server.resource_session_cookie_name}_${resource.resourceId}`;
const cookie = serializeResourceSessionCookie(cookieName, token);
res.appendHeader("Set-Cookie", cookie);
logger.debug("Access token is valid, creating new session")
return response<VerifyUserResponse>(res, {
data: { valid: true },
success: true,
error: false,
message: "Access allowed",
status: HttpCode.OK
});
}
async function isUserAllowedToAccessResource(
user: User,
resource: Resource
): Promise<boolean> {
if (config.getRawConfig().flags?.require_email_verification && !user.emailVerified) {
if (
config.getRawConfig().flags?.require_email_verification &&
!user.emailVerified
) {
return false;
}

View file

@ -14,9 +14,7 @@ import {
} from "@server/auth/sessions/resource";
import config from "@server/lib/config";
import logger from "@server/logger";
import { verify } from "@node-rs/argon2";
import { isWithinExpirationDate } from "oslo";
import { verifyPassword } from "@server/auth/password";
import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
const authWithAccessTokenBodySchema = z
.object({
@ -69,58 +67,38 @@ export async function authWithAccessToken(
const { accessToken, accessTokenId } = parsedBody.data;
try {
const [result] = await db
const [resource] = await db
.select()
.from(resourceAccessToken)
.where(
and(
eq(resourceAccessToken.resourceId, resourceId),
eq(resourceAccessToken.accessTokenId, accessTokenId)
)
)
.leftJoin(
resources,
eq(resources.resourceId, resourceAccessToken.resourceId)
)
.from(resources)
.where(eq(resources.resourceId, resourceId))
.limit(1);
const resource = result?.resources;
const tokenItem = result?.resourceAccessToken;
if (!tokenItem) {
return next(
createHttpError(
HttpCode.UNAUTHORIZED,
createHttpError(
HttpCode.BAD_REQUEST,
"Access token does not exist for resource"
)
)
);
}
if (!resource) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Resource does not exist")
createHttpError(HttpCode.NOT_FOUND, "Resource not found")
);
}
const validCode = await verifyPassword(accessToken, tokenItem.tokenHash);
const { valid, error, tokenItem } = await verifyResourceAccessToken({
resource,
accessTokenId,
accessToken
});
if (!validCode) {
return next(
createHttpError(HttpCode.UNAUTHORIZED, "Invalid access token")
);
}
if (
tokenItem.expiresAt &&
!isWithinExpirationDate(new Date(tokenItem.expiresAt))
) {
if (!valid) {
return next(
createHttpError(
HttpCode.UNAUTHORIZED,
"Access token has expired"
error || "Invalid access token"
)
);
}
if (!tokenItem || !resource) {
return next(
createHttpError(
HttpCode.UNAUTHORIZED,
"Access token does not exist for resource"
)
);
}

View file

@ -56,6 +56,7 @@ export async function traefikConfigProvider(
config.getRawConfig().server.resource_session_cookie_name,
userSessionCookieName:
config.getRawConfig().server.session_cookie_name,
accessTokenQueryParam: "p_token"
},
},
},

View file

@ -69,6 +69,8 @@ export async function setupServerAdmin() {
const userId = generateId(15);
await trx.update(users).set({ serverAdmin: false });
await db.insert(users).values({
userId: userId,
email: email,

View file

@ -45,11 +45,10 @@ export default async function ResourceAuthPage(props: {
const user = await getUser({ skipCheckVerifyEmail: true });
if (!authInfo) {
{
/* @ts-ignore */
} // TODO: fix this
// TODO: fix this
return (
<div className="w-full max-w-md">
{/* @ts-ignore */}
<ResourceNotFound />
</div>
);