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 const hostnameSchema = z
.string() .string()
.regex( .regex(
/^(?!-)[a-zA-Z0-9-]{1,63}(?<!-)(\.[a-zA-Z]{2,})*$/, /^(?:(?: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])$/
"Invalid hostname. Must be a valid hostname like 'localhost' or 'test.example.com'." )
); .or(z.literal("localhost"));
const environmentSchema = z.object({ const environmentSchema = z.object({
app: 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 { and, eq } from "drizzle-orm";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { canUserAccessResource } from "@server/lib/canUserAccessResource"; import { canUserAccessResource } from "@server/auth/canUserAccessResource";
export async function verifyAccessTokenAccess( export async function verifyAccessTokenAccess(
req: Request, req: Request,

View file

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

View file

@ -7,6 +7,7 @@ import { response } from "@server/lib/response";
import { validateSessionToken } from "@server/auth/sessions/app"; import { validateSessionToken } from "@server/auth/sessions/app";
import db from "@server/db"; import db from "@server/db";
import { import {
ResourceAccessToken,
resourceAccessToken, resourceAccessToken,
resourcePassword, resourcePassword,
resourcePincode, resourcePincode,
@ -17,9 +18,15 @@ import {
} from "@server/db/schema"; } from "@server/db/schema";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import config from "@server/lib/config"; 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 { Resource, roleResources, userResources } from "@server/db/schema";
import logger from "@server/logger"; import logger from "@server/logger";
import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
import { generateSessionToken } from "@server/auth";
const verifyResourceSessionSchema = z.object({ const verifyResourceSessionSchema = z.object({
sessions: z.record(z.string()).optional(), sessions: z.record(z.string()).optional(),
@ -28,6 +35,7 @@ const verifyResourceSessionSchema = z.object({
host: z.string(), host: z.string(),
path: z.string(), path: z.string(),
method: z.string(), method: z.string(),
accessToken: z.string().optional(),
tls: z.boolean() tls: z.boolean()
}); });
@ -59,7 +67,8 @@ export async function verifyResourceSession(
} }
try { try {
const { sessions, host, originalRequestURL } = parsedBody.data; const { sessions, host, originalRequestURL, accessToken: token } =
parsedBody.data;
const [result] = await db const [result] = await db
.select() .select()
@ -103,11 +112,41 @@ export async function verifyResourceSession(
const redirectUrl = `${config.getRawConfig().app.dashboard_url}/auth/resource/${encodeURIComponent(resource.resourceId)}?redirect=${encodeURIComponent(originalRequestURL)}`; 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) { if (!sessions) {
return notAllowed(res); 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 // check for unified login
if (sso && sessionToken) { 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"); logger.debug("No more auth to check, resource not allowed");
return notAllowed(res, redirectUrl); return notAllowed(res, redirectUrl);
} catch (e) { } catch (e) {
@ -209,11 +258,41 @@ function allowed(res: Response) {
return response<VerifyUserResponse>(res, data); 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( async function isUserAllowedToAccessResource(
user: User, user: User,
resource: Resource resource: Resource
): Promise<boolean> { ): Promise<boolean> {
if (config.getRawConfig().flags?.require_email_verification && !user.emailVerified) { if (
config.getRawConfig().flags?.require_email_verification &&
!user.emailVerified
) {
return false; return false;
} }

View file

@ -14,9 +14,7 @@ import {
} from "@server/auth/sessions/resource"; } from "@server/auth/sessions/resource";
import config from "@server/lib/config"; import config from "@server/lib/config";
import logger from "@server/logger"; import logger from "@server/logger";
import { verify } from "@node-rs/argon2"; import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
import { isWithinExpirationDate } from "oslo";
import { verifyPassword } from "@server/auth/password";
const authWithAccessTokenBodySchema = z const authWithAccessTokenBodySchema = z
.object({ .object({
@ -69,58 +67,38 @@ export async function authWithAccessToken(
const { accessToken, accessTokenId } = parsedBody.data; const { accessToken, accessTokenId } = parsedBody.data;
try { try {
const [result] = await db const [resource] = await db
.select() .select()
.from(resourceAccessToken) .from(resources)
.where( .where(eq(resources.resourceId, resourceId))
and(
eq(resourceAccessToken.resourceId, resourceId),
eq(resourceAccessToken.accessTokenId, accessTokenId)
)
)
.leftJoin(
resources,
eq(resources.resourceId, resourceAccessToken.resourceId)
)
.limit(1); .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) { if (!resource) {
return next( 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) { if (!valid) {
return next(
createHttpError(HttpCode.UNAUTHORIZED, "Invalid access token")
);
}
if (
tokenItem.expiresAt &&
!isWithinExpirationDate(new Date(tokenItem.expiresAt))
) {
return next( return next(
createHttpError( createHttpError(
HttpCode.UNAUTHORIZED, 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, config.getRawConfig().server.resource_session_cookie_name,
userSessionCookieName: userSessionCookieName:
config.getRawConfig().server.session_cookie_name, config.getRawConfig().server.session_cookie_name,
accessTokenQueryParam: "p_token"
}, },
}, },
}, },

View file

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

View file

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