mirror of
https://github.com/fosrl/pangolin.git
synced 2025-07-23 12:15:36 +02:00
allow access token in resource url
This commit is contained in:
parent
e32301ade4
commit
f5fda5d8ea
10 changed files with 226 additions and 55 deletions
45
server/auth/canUserAccessResource.ts
Normal file
45
server/auth/canUserAccessResource.ts
Normal 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;
|
||||||
|
}
|
67
server/auth/verifyResourceAccessToken.ts
Normal file
67
server/auth/verifyResourceAccessToken.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
|
@ -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({
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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"
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue