mirror of
https://github.com/fosrl/pangolin.git
synced 2025-07-18 09:45:10 +02:00
access token endpoints and other backend support
This commit is contained in:
parent
283fb3990c
commit
72dc02ff2e
22 changed files with 905 additions and 107 deletions
|
@ -49,7 +49,14 @@ export enum ActionsEnum {
|
||||||
// addUserAction = "addUserAction",
|
// addUserAction = "addUserAction",
|
||||||
// removeUserAction = "removeUserAction",
|
// removeUserAction = "removeUserAction",
|
||||||
// removeUserSite = "removeUserSite",
|
// removeUserSite = "removeUserSite",
|
||||||
getOrgUser = "getOrgUser"
|
getOrgUser = "getOrgUser",
|
||||||
|
"setResourcePassword" = "setResourcePassword",
|
||||||
|
"setResourcePincode" = "setResourcePincode",
|
||||||
|
"setResourceWhitelist" = "setResourceWhitelist",
|
||||||
|
"getResourceWhitelist" = "getResourceWhitelist",
|
||||||
|
"generateAccessToken" = "generateAccessToken",
|
||||||
|
"deleteAcessToken" = "deleteAcessToken",
|
||||||
|
"listAccessTokens" = "listAccessTokens"
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkUserActionPermission(
|
export async function checkUserActionPermission(
|
||||||
|
|
|
@ -1,6 +1,10 @@
|
||||||
import { encodeHexLowerCase } from "@oslojs/encoding";
|
import { encodeHexLowerCase } from "@oslojs/encoding";
|
||||||
import { sha256 } from "@oslojs/crypto/sha2";
|
import { sha256 } from "@oslojs/crypto/sha2";
|
||||||
import { resourceSessions, ResourceSession } from "@server/db/schema";
|
import {
|
||||||
|
resourceSessions,
|
||||||
|
ResourceSession,
|
||||||
|
resources
|
||||||
|
} from "@server/db/schema";
|
||||||
import db from "@server/db";
|
import db from "@server/db";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import config from "@server/config";
|
import config from "@server/config";
|
||||||
|
@ -17,12 +21,19 @@ export async function createResourceSession(opts: {
|
||||||
passwordId?: number;
|
passwordId?: number;
|
||||||
pincodeId?: number;
|
pincodeId?: number;
|
||||||
whitelistId?: number;
|
whitelistId?: number;
|
||||||
|
accessTokenId?: string;
|
||||||
usedOtp?: boolean;
|
usedOtp?: boolean;
|
||||||
|
doNotExtend?: boolean;
|
||||||
|
expiresAt?: number | null;
|
||||||
|
sessionLength: number;
|
||||||
}): Promise<ResourceSession> {
|
}): Promise<ResourceSession> {
|
||||||
if (!opts.passwordId && !opts.pincodeId && !opts.whitelistId) {
|
if (
|
||||||
throw new Error(
|
!opts.passwordId &&
|
||||||
"At least one of passwordId or pincodeId must be provided"
|
!opts.pincodeId &&
|
||||||
);
|
!opts.whitelistId &&
|
||||||
|
!opts.accessTokenId
|
||||||
|
) {
|
||||||
|
throw new Error("Auth method must be provided");
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionId = encodeHexLowerCase(
|
const sessionId = encodeHexLowerCase(
|
||||||
|
@ -31,11 +42,16 @@ export async function createResourceSession(opts: {
|
||||||
|
|
||||||
const session: ResourceSession = {
|
const session: ResourceSession = {
|
||||||
sessionId: sessionId,
|
sessionId: sessionId,
|
||||||
expiresAt: new Date(Date.now() + SESSION_COOKIE_EXPIRES).getTime(),
|
expiresAt:
|
||||||
|
opts.expiresAt ||
|
||||||
|
new Date(Date.now() + SESSION_COOKIE_EXPIRES).getTime(),
|
||||||
|
sessionLength: opts.sessionLength || SESSION_COOKIE_EXPIRES,
|
||||||
resourceId: opts.resourceId,
|
resourceId: opts.resourceId,
|
||||||
passwordId: opts.passwordId || null,
|
passwordId: opts.passwordId || null,
|
||||||
pincodeId: opts.pincodeId || null,
|
pincodeId: opts.pincodeId || null,
|
||||||
whitelistId: opts.whitelistId || null
|
whitelistId: opts.whitelistId || null,
|
||||||
|
doNotExtend: opts.doNotExtend || false,
|
||||||
|
accessTokenId: opts.accessTokenId || null
|
||||||
};
|
};
|
||||||
|
|
||||||
await db.insert(resourceSessions).values(session);
|
await db.insert(resourceSessions).values(session);
|
||||||
|
@ -66,9 +82,18 @@ export async function validateResourceSessionToken(
|
||||||
|
|
||||||
const resourceSession = result[0];
|
const resourceSession = result[0];
|
||||||
|
|
||||||
if (Date.now() >= resourceSession.expiresAt - SESSION_COOKIE_EXPIRES / 2) {
|
if (Date.now() >= resourceSession.expiresAt) {
|
||||||
|
await db
|
||||||
|
.delete(resourceSessions)
|
||||||
|
.where(eq(resourceSessions.sessionId, resourceSessions.sessionId));
|
||||||
|
return { resourceSession: null };
|
||||||
|
} else if (
|
||||||
|
!resourceSession.doNotExtend &&
|
||||||
|
Date.now() >=
|
||||||
|
resourceSession.expiresAt - resourceSession.sessionLength / 2
|
||||||
|
) {
|
||||||
resourceSession.expiresAt = new Date(
|
resourceSession.expiresAt = new Date(
|
||||||
Date.now() + SESSION_COOKIE_EXPIRES
|
Date.now() + resourceSession.sessionLength
|
||||||
).getTime();
|
).getTime();
|
||||||
await db
|
await db
|
||||||
.update(resourceSessions)
|
.update(resourceSessions)
|
||||||
|
@ -138,8 +163,7 @@ export async function invalidateAllSessions(
|
||||||
|
|
||||||
export function serializeResourceSessionCookie(
|
export function serializeResourceSessionCookie(
|
||||||
cookieName: string,
|
cookieName: string,
|
||||||
token: string,
|
token: string
|
||||||
fqdn: string
|
|
||||||
): string {
|
): string {
|
||||||
if (SECURE_COOKIES) {
|
if (SECURE_COOKIES) {
|
||||||
return `${cookieName}=${token}; HttpOnly; SameSite=Lax; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
|
return `${cookieName}=${token}; HttpOnly; SameSite=Lax; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
|
||||||
|
@ -149,8 +173,7 @@ export function serializeResourceSessionCookie(
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createBlankResourceSessionTokenCookie(
|
export function createBlankResourceSessionTokenCookie(
|
||||||
cookieName: string,
|
cookieName: string
|
||||||
fqdn: string
|
|
||||||
): string {
|
): string {
|
||||||
if (SECURE_COOKIES) {
|
if (SECURE_COOKIES) {
|
||||||
return `${cookieName}=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
|
return `${cookieName}=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
|
||||||
|
|
|
@ -277,12 +277,32 @@ export const resourcePassword = sqliteTable("resourcePassword", {
|
||||||
passwordHash: text("passwordHash").notNull()
|
passwordHash: text("passwordHash").notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const resourceAccessToken = sqliteTable("resourceAccessToken", {
|
||||||
|
accessTokenId: text("accessTokenId").primaryKey(),
|
||||||
|
orgId: text("orgId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||||
|
resourceId: integer("resourceId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => resources.resourceId, { onDelete: "cascade" }),
|
||||||
|
tokenHash: text("tokenHash").notNull(),
|
||||||
|
sessionLength: integer("sessionLength").notNull(),
|
||||||
|
expiresAt: integer("expiresAt"),
|
||||||
|
title: text("title").notNull(),
|
||||||
|
description: text("description"),
|
||||||
|
createdAt: integer("createdAt").notNull()
|
||||||
|
});
|
||||||
|
|
||||||
export const resourceSessions = sqliteTable("resourceSessions", {
|
export const resourceSessions = sqliteTable("resourceSessions", {
|
||||||
sessionId: text("id").primaryKey(),
|
sessionId: text("id").primaryKey(),
|
||||||
resourceId: integer("resourceId")
|
resourceId: integer("resourceId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => resources.resourceId, { onDelete: "cascade" }),
|
.references(() => resources.resourceId, { onDelete: "cascade" }),
|
||||||
expiresAt: integer("expiresAt").notNull(),
|
expiresAt: integer("expiresAt").notNull(),
|
||||||
|
sessionLength: integer("sessionLength").notNull(),
|
||||||
|
doNotExtend: integer("doNotExtend", { mode: "boolean" })
|
||||||
|
.notNull()
|
||||||
|
.default(false),
|
||||||
passwordId: integer("passwordId").references(
|
passwordId: integer("passwordId").references(
|
||||||
() => resourcePassword.passwordId,
|
() => resourcePassword.passwordId,
|
||||||
{
|
{
|
||||||
|
@ -300,6 +320,12 @@ export const resourceSessions = sqliteTable("resourceSessions", {
|
||||||
{
|
{
|
||||||
onDelete: "cascade"
|
onDelete: "cascade"
|
||||||
}
|
}
|
||||||
|
),
|
||||||
|
accessTokenId: text("accessTokenId").references(
|
||||||
|
() => resourceAccessToken.accessTokenId,
|
||||||
|
{
|
||||||
|
onDelete: "cascade"
|
||||||
|
}
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -4,29 +4,34 @@ import cors from "cors";
|
||||||
import cookieParser from "cookie-parser";
|
import cookieParser from "cookie-parser";
|
||||||
import config from "@server/config";
|
import config from "@server/config";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { errorHandlerMiddleware, notFoundMiddleware } from "@server/middlewares";
|
import {
|
||||||
|
errorHandlerMiddleware,
|
||||||
|
notFoundMiddleware
|
||||||
|
} from "@server/middlewares";
|
||||||
import internal from "@server/routers/internal";
|
import internal from "@server/routers/internal";
|
||||||
|
|
||||||
const internalPort = config.server.internal_port;
|
const internalPort = config.server.internal_port;
|
||||||
|
|
||||||
export function createInternalServer() {
|
export function createInternalServer() {
|
||||||
const internalServer = express();
|
const internalServer = express();
|
||||||
|
|
||||||
internalServer.use(helmet());
|
internalServer.use(helmet());
|
||||||
internalServer.use(cors());
|
internalServer.use(cors());
|
||||||
internalServer.use(cookieParser());
|
internalServer.use(cookieParser());
|
||||||
internalServer.use(express.json());
|
internalServer.use(express.json());
|
||||||
|
|
||||||
const prefix = `/api/v1`;
|
const prefix = `/api/v1`;
|
||||||
internalServer.use(prefix, internal);
|
internalServer.use(prefix, internal);
|
||||||
|
|
||||||
internalServer.use(notFoundMiddleware);
|
internalServer.use(notFoundMiddleware);
|
||||||
internalServer.use(errorHandlerMiddleware);
|
internalServer.use(errorHandlerMiddleware);
|
||||||
|
|
||||||
internalServer.listen(internalPort, (err?: any) => {
|
internalServer.listen(internalPort, (err?: any) => {
|
||||||
if (err) throw err;
|
if (err) throw err;
|
||||||
logger.info(`Internal server is running on http://localhost:${internalPort}`);
|
logger.info(
|
||||||
});
|
`Internal server is running on http://localhost:${internalPort}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
return internalServer;
|
return internalServer;
|
||||||
}
|
}
|
|
@ -16,6 +16,7 @@ const hformat = winston.format.printf(
|
||||||
const transports: any = [
|
const transports: any = [
|
||||||
new winston.transports.Console({
|
new winston.transports.Console({
|
||||||
format: winston.format.combine(
|
format: winston.format.combine(
|
||||||
|
winston.format.errors({ stack: true }),
|
||||||
winston.format.colorize(),
|
winston.format.colorize(),
|
||||||
winston.format.splat(),
|
winston.format.splat(),
|
||||||
winston.format.timestamp(),
|
winston.format.timestamp(),
|
||||||
|
|
45
server/middlewares/helpers/canUserAccessResource.ts
Normal file
45
server/middlewares/helpers/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;
|
||||||
|
}
|
|
@ -13,3 +13,4 @@ export * from "./verifyUserAccess";
|
||||||
export * from "./verifyAdmin";
|
export * from "./verifyAdmin";
|
||||||
export * from "./verifySetResourceUsers";
|
export * from "./verifySetResourceUsers";
|
||||||
export * from "./verifyUserInRole";
|
export * from "./verifyUserInRole";
|
||||||
|
export * from "./verifyAccessTokenAccess";
|
||||||
|
|
123
server/middlewares/verifyAccessTokenAccess.ts
Normal file
123
server/middlewares/verifyAccessTokenAccess.ts
Normal file
|
@ -0,0 +1,123 @@
|
||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { db } from "@server/db";
|
||||||
|
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 "./helpers/canUserAccessResource";
|
||||||
|
|
||||||
|
export async function verifyAccessTokenAccess(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
) {
|
||||||
|
const userId = req.user!.userId;
|
||||||
|
const accessTokenId = req.params.accessTokenId;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [accessToken] = await db
|
||||||
|
.select()
|
||||||
|
.from(resourceAccessToken)
|
||||||
|
.where(eq(resourceAccessToken.accessTokenId, accessTokenId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!accessToken) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Access token with ID ${accessTokenId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resourceId = accessToken.resourceId;
|
||||||
|
|
||||||
|
if (!resourceId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
`Access token with ID ${accessTokenId} does not have a resource ID`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
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`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resource[0].orgId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
`Resource with ID ${resourceId} does not have an organization ID`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!req.userOrg) {
|
||||||
|
const res = await db
|
||||||
|
.select()
|
||||||
|
.from(userOrgs)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userOrgs.userId, userId),
|
||||||
|
eq(userOrgs.orgId, resource[0].orgId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
req.userOrg = res[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!req.userOrg) {
|
||||||
|
next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.FORBIDDEN,
|
||||||
|
"User does not have access to this organization"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
req.userOrgRoleId = req.userOrg.roleId;
|
||||||
|
req.userOrgId = resource[0].orgId!;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resourceAllowed = await canUserAccessResource({
|
||||||
|
userId,
|
||||||
|
resourceId,
|
||||||
|
roleId: req.userOrgRoleId!
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resourceAllowed) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.FORBIDDEN,
|
||||||
|
"User does not have access to this resource"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
} catch (e) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Error verifying organization access"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -4,6 +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 "./helpers/canUserAccessResource";
|
||||||
|
|
||||||
export async function verifyTargetAccess(
|
export async function verifyTargetAccess(
|
||||||
req: Request,
|
req: Request,
|
||||||
|
@ -99,8 +100,24 @@ export async function verifyTargetAccess(
|
||||||
} else {
|
} else {
|
||||||
req.userOrgRoleId = req.userOrg.roleId;
|
req.userOrgRoleId = req.userOrg.roleId;
|
||||||
req.userOrgId = resource[0].orgId!;
|
req.userOrgId = resource[0].orgId!;
|
||||||
next();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resourceAllowed = await canUserAccessResource({
|
||||||
|
userId,
|
||||||
|
resourceId,
|
||||||
|
roleId: req.userOrgRoleId!
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resourceAllowed) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.FORBIDDEN,
|
||||||
|
"User does not have access to this resource"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
|
|
67
server/routers/accessToken/deleteAccessToken.ts
Normal file
67
server/routers/accessToken/deleteAccessToken.ts
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import response from "@server/utils/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { resourceAccessToken } from "@server/db/schema";
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import db from "@server/db";
|
||||||
|
|
||||||
|
const deleteAccessTokenParamsSchema = z.object({
|
||||||
|
accessTokenId: z.string()
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function deleteAccessToken(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedParams = deleteAccessTokenParamsSchema.safeParse(
|
||||||
|
req.params
|
||||||
|
);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { accessTokenId } = parsedParams.data;
|
||||||
|
|
||||||
|
const [accessToken] = await db
|
||||||
|
.select()
|
||||||
|
.from(resourceAccessToken)
|
||||||
|
.where(and(eq(resourceAccessToken.accessTokenId, accessTokenId)));
|
||||||
|
|
||||||
|
if (!accessToken) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
"Resource access token not found"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(resourceAccessToken)
|
||||||
|
.where(and(eq(resourceAccessToken.accessTokenId, accessTokenId)));
|
||||||
|
|
||||||
|
return response(res, {
|
||||||
|
data: null,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Resource access token deleted successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
120
server/routers/accessToken/generateAccessToken.ts
Normal file
120
server/routers/accessToken/generateAccessToken.ts
Normal file
|
@ -0,0 +1,120 @@
|
||||||
|
import { hash } from "@node-rs/argon2";
|
||||||
|
import {
|
||||||
|
generateId,
|
||||||
|
generateIdFromEntropySize,
|
||||||
|
SESSION_COOKIE_EXPIRES
|
||||||
|
} from "@server/auth";
|
||||||
|
import db from "@server/db";
|
||||||
|
import { resourceAccessToken, resources } from "@server/db/schema";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import response from "@server/utils/response";
|
||||||
|
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 logger from "@server/logger";
|
||||||
|
import { createDate, TimeSpan } from "oslo";
|
||||||
|
|
||||||
|
export const generateAccessTokenBodySchema = z.object({
|
||||||
|
validForSeconds: z.number().int().positive().optional(), // seconds
|
||||||
|
title: z.string().optional(),
|
||||||
|
description: z.string().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const generateAccssTokenParamsSchema = z.object({
|
||||||
|
resourceId: z.string().transform(Number).pipe(z.number().int().positive())
|
||||||
|
});
|
||||||
|
|
||||||
|
export type GenerateAccessTokenResponse = {
|
||||||
|
token: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function generateAccessToken(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
const parsedBody = generateAccessTokenBodySchema.safeParse(req.body);
|
||||||
|
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedBody.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedParams = generateAccssTokenParamsSchema.safeParse(req.params);
|
||||||
|
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { resourceId } = parsedParams.data;
|
||||||
|
const { validForSeconds, title, description } = parsedBody.data;
|
||||||
|
|
||||||
|
const [resource] = await db
|
||||||
|
.select()
|
||||||
|
.from(resources)
|
||||||
|
.where(eq(resources.resourceId, resourceId));
|
||||||
|
|
||||||
|
if (!resource) {
|
||||||
|
return next(createHttpError(HttpCode.NOT_FOUND, "Resource not found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sessionLength = validForSeconds
|
||||||
|
? validForSeconds * 1000
|
||||||
|
: SESSION_COOKIE_EXPIRES;
|
||||||
|
const expiresAt = validForSeconds
|
||||||
|
? createDate(new TimeSpan(validForSeconds, "s")).getTime()
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const token = generateIdFromEntropySize(25);
|
||||||
|
|
||||||
|
const tokenHash = await hash(token, {
|
||||||
|
memoryCost: 19456,
|
||||||
|
timeCost: 2,
|
||||||
|
outputLen: 32,
|
||||||
|
parallelism: 1
|
||||||
|
});
|
||||||
|
|
||||||
|
const id = generateId(15);
|
||||||
|
await db.insert(resourceAccessToken).values({
|
||||||
|
accessTokenId: id,
|
||||||
|
orgId: resource.orgId,
|
||||||
|
resourceId,
|
||||||
|
tokenHash,
|
||||||
|
expiresAt: expiresAt || null,
|
||||||
|
sessionLength: sessionLength,
|
||||||
|
title: title || `${resource.name} Token ${new Date().getTime()}`,
|
||||||
|
description: description || null,
|
||||||
|
createdAt: new Date().getTime()
|
||||||
|
});
|
||||||
|
|
||||||
|
return response<GenerateAccessTokenResponse>(res, {
|
||||||
|
data: {
|
||||||
|
token: `${id}.${token}`
|
||||||
|
},
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Resource access token generated successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(e);
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Failed to authenticate with resource"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
3
server/routers/accessToken/index.ts
Normal file
3
server/routers/accessToken/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
export * from "./generateAccessToken";
|
||||||
|
export * from "./listAccessTokens";
|
||||||
|
export * from "./deleteAccessToken";
|
183
server/routers/accessToken/listAccessTokens.ts
Normal file
183
server/routers/accessToken/listAccessTokens.ts
Normal file
|
@ -0,0 +1,183 @@
|
||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@server/db";
|
||||||
|
import {
|
||||||
|
resources,
|
||||||
|
userResources,
|
||||||
|
roleResources,
|
||||||
|
resourceAccessToken
|
||||||
|
} from "@server/db/schema";
|
||||||
|
import response from "@server/utils/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import { sql, eq, or, inArray, and, count } from "drizzle-orm";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import stoi from "@server/utils/stoi";
|
||||||
|
|
||||||
|
const listAccessTokensParamsSchema = z
|
||||||
|
.object({
|
||||||
|
resourceId: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform(stoi)
|
||||||
|
.pipe(z.number().int().positive().optional()),
|
||||||
|
orgId: z.string().optional()
|
||||||
|
})
|
||||||
|
.refine((data) => !!data.resourceId !== !!data.orgId, {
|
||||||
|
message: "Either resourceId or orgId must be provided, but not both"
|
||||||
|
});
|
||||||
|
|
||||||
|
const listAccessTokensSchema = z.object({
|
||||||
|
limit: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.default("1000")
|
||||||
|
.transform(Number)
|
||||||
|
.pipe(z.number().int().nonnegative()),
|
||||||
|
|
||||||
|
offset: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.default("0")
|
||||||
|
.transform(Number)
|
||||||
|
.pipe(z.number().int().nonnegative())
|
||||||
|
});
|
||||||
|
|
||||||
|
function queryAccessTokens(
|
||||||
|
accessibleResourceIds: number[],
|
||||||
|
orgId?: string,
|
||||||
|
resourceId?: number
|
||||||
|
) {
|
||||||
|
const cols = {
|
||||||
|
accessTokenId: resourceAccessToken.accessTokenId,
|
||||||
|
orgId: resourceAccessToken.orgId,
|
||||||
|
resourceId: resourceAccessToken.resourceId,
|
||||||
|
sessionLength: resourceAccessToken.sessionLength,
|
||||||
|
expiresAt: resourceAccessToken.expiresAt,
|
||||||
|
title: resourceAccessToken.title,
|
||||||
|
description: resourceAccessToken.description,
|
||||||
|
createdAt: resourceAccessToken.createdAt
|
||||||
|
};
|
||||||
|
|
||||||
|
if (orgId) {
|
||||||
|
return db
|
||||||
|
.select(cols)
|
||||||
|
.from(resourceAccessToken)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(resourceAccessToken.resourceId, accessibleResourceIds),
|
||||||
|
eq(resourceAccessToken.orgId, orgId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else if (resourceId) {
|
||||||
|
return db
|
||||||
|
.select(cols)
|
||||||
|
.from(resourceAccessToken)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(resources.resourceId, accessibleResourceIds),
|
||||||
|
eq(resources.resourceId, resourceId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ListAccessTokensResponse = {
|
||||||
|
accessTokens: NonNullable<Awaited<ReturnType<typeof queryAccessTokens>>>;
|
||||||
|
pagination: { total: number; limit: number; offset: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function listAccessTokens(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedQuery = listAccessTokensSchema.safeParse(req.query);
|
||||||
|
if (!parsedQuery.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
parsedQuery.error.errors.map((e) => e.message).join(", ")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { limit, offset } = parsedQuery.data;
|
||||||
|
|
||||||
|
const parsedParams = listAccessTokensParamsSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
parsedParams.error.errors.map((e) => e.message).join(", ")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { orgId, resourceId } = parsedParams.data;
|
||||||
|
|
||||||
|
if (orgId && orgId !== req.userOrgId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.FORBIDDEN,
|
||||||
|
"User does not have access to this organization"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessibleResources = await db
|
||||||
|
.select({
|
||||||
|
resourceId: sql<number>`COALESCE(${userResources.resourceId}, ${roleResources.resourceId})`
|
||||||
|
})
|
||||||
|
.from(userResources)
|
||||||
|
.fullJoin(
|
||||||
|
roleResources,
|
||||||
|
eq(userResources.resourceId, roleResources.resourceId)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
or(
|
||||||
|
eq(userResources.userId, req.user!.userId),
|
||||||
|
eq(roleResources.roleId, req.userOrgRoleId!)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const accessibleResourceIds = accessibleResources.map(
|
||||||
|
(resource) => resource.resourceId
|
||||||
|
);
|
||||||
|
|
||||||
|
let countQuery: any = db
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(resources)
|
||||||
|
.where(inArray(resources.resourceId, accessibleResourceIds));
|
||||||
|
|
||||||
|
const baseQuery = queryAccessTokens(
|
||||||
|
accessibleResourceIds,
|
||||||
|
orgId,
|
||||||
|
resourceId
|
||||||
|
);
|
||||||
|
|
||||||
|
const list = await baseQuery!.limit(limit).offset(offset);
|
||||||
|
const totalCountResult = await countQuery;
|
||||||
|
const totalCount = totalCountResult[0].count;
|
||||||
|
|
||||||
|
return response<ListAccessTokensResponse>(res, {
|
||||||
|
data: {
|
||||||
|
accessTokens: list,
|
||||||
|
pagination: {
|
||||||
|
total: totalCount,
|
||||||
|
limit,
|
||||||
|
offset
|
||||||
|
}
|
||||||
|
},
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Access tokens retrieved successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -4,7 +4,6 @@ import { z } from "zod";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import { response } from "@server/utils";
|
import { response } from "@server/utils";
|
||||||
import { validateSessionToken } from "@server/auth";
|
|
||||||
import { validateResourceSessionToken } from "@server/auth/resource";
|
import { validateResourceSessionToken } from "@server/auth/resource";
|
||||||
|
|
||||||
export const params = z.object({
|
export const params = z.object({
|
||||||
|
|
|
@ -7,9 +7,11 @@ import { response } from "@server/utils/response";
|
||||||
import { validateSessionToken } from "@server/auth";
|
import { validateSessionToken } from "@server/auth";
|
||||||
import db from "@server/db";
|
import db from "@server/db";
|
||||||
import {
|
import {
|
||||||
|
resourceAccessToken,
|
||||||
resourcePassword,
|
resourcePassword,
|
||||||
resourcePincode,
|
resourcePincode,
|
||||||
resources,
|
resources,
|
||||||
|
resourceWhitelist,
|
||||||
User,
|
User,
|
||||||
userOrgs
|
userOrgs
|
||||||
} from "@server/db/schema";
|
} from "@server/db/schema";
|
||||||
|
@ -89,7 +91,12 @@ export async function verifyResourceSession(
|
||||||
return notAllowed(res);
|
return notAllowed(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!resource.sso && !pincode && !password) {
|
if (
|
||||||
|
!resource.sso &&
|
||||||
|
!pincode &&
|
||||||
|
!password &&
|
||||||
|
!resource.emailWhitelistEnabled
|
||||||
|
) {
|
||||||
logger.debug("Resource allowed because no auth");
|
logger.debug("Resource allowed because no auth");
|
||||||
return allowed(res);
|
return allowed(res);
|
||||||
}
|
}
|
||||||
|
@ -103,7 +110,7 @@ export async function verifyResourceSession(
|
||||||
const sessionToken = sessions[config.server.session_cookie_name];
|
const sessionToken = sessions[config.server.session_cookie_name];
|
||||||
|
|
||||||
// check for unified login
|
// check for unified login
|
||||||
if (sso && sessionToken && !resource.otpEnabled) {
|
if (sso && sessionToken) {
|
||||||
const { session, user } = await validateSessionToken(sessionToken);
|
const { session, user } = await validateSessionToken(sessionToken);
|
||||||
if (session && user) {
|
if (session && user) {
|
||||||
const isAllowed = await isUserAllowedToAccessResource(
|
const isAllowed = await isUserAllowedToAccessResource(
|
||||||
|
@ -125,69 +132,46 @@ export async function verifyResourceSession(
|
||||||
`${config.server.resource_session_cookie_name}_${resource.resourceId}`
|
`${config.server.resource_session_cookie_name}_${resource.resourceId}`
|
||||||
];
|
];
|
||||||
|
|
||||||
if (
|
if (resourceSessionToken) {
|
||||||
sso &&
|
|
||||||
sessionToken &&
|
|
||||||
resourceSessionToken &&
|
|
||||||
resource.otpEnabled
|
|
||||||
) {
|
|
||||||
const { session, user } = await validateSessionToken(sessionToken);
|
|
||||||
const { resourceSession } = await validateResourceSessionToken(
|
|
||||||
resourceSessionToken,
|
|
||||||
resource.resourceId
|
|
||||||
);
|
|
||||||
|
|
||||||
if (session && user && resourceSession) {
|
|
||||||
if (!resourceSession.usedOtp) {
|
|
||||||
logger.debug("Resource not allowed because OTP not used");
|
|
||||||
return notAllowed(res, redirectUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isAllowed = await isUserAllowedToAccessResource(
|
|
||||||
user,
|
|
||||||
resource
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isAllowed) {
|
|
||||||
logger.debug(
|
|
||||||
"Resource allowed because user and resource session is valid"
|
|
||||||
);
|
|
||||||
return allowed(res);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((pincode || password) && resourceSessionToken) {
|
|
||||||
const { resourceSession } = await validateResourceSessionToken(
|
const { resourceSession } = await validateResourceSessionToken(
|
||||||
resourceSessionToken,
|
resourceSessionToken,
|
||||||
resource.resourceId
|
resource.resourceId
|
||||||
);
|
);
|
||||||
|
|
||||||
if (resourceSession) {
|
if (resourceSession) {
|
||||||
if (resource.otpEnabled && !resourceSession.usedOtp) {
|
return allowed(res);
|
||||||
logger.debug("Resource not allowed because OTP not used");
|
|
||||||
return notAllowed(res, redirectUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
// Might not be needed
|
||||||
pincode &&
|
// if (pincode && resourceSession.pincodeId) {
|
||||||
resourceSession.pincodeId === pincode.pincodeId
|
// logger.debug(
|
||||||
) {
|
// "Resource allowed because pincode session is valid"
|
||||||
logger.debug(
|
// );
|
||||||
"Resource allowed because pincode session is valid"
|
// return allowed(res);
|
||||||
);
|
// }
|
||||||
return allowed(res);
|
//
|
||||||
}
|
// if (password && resourceSession.passwordId) {
|
||||||
|
// logger.debug(
|
||||||
if (
|
// "Resource allowed because password session is valid"
|
||||||
password &&
|
// );
|
||||||
resourceSession.passwordId === password.passwordId
|
// return allowed(res);
|
||||||
) {
|
// }
|
||||||
logger.debug(
|
//
|
||||||
"Resource allowed because password session is valid"
|
// if (
|
||||||
);
|
// resource.emailWhitelistEnabled &&
|
||||||
return allowed(res);
|
// resourceSession.whitelistId
|
||||||
}
|
// ) {
|
||||||
|
// logger.debug(
|
||||||
|
// "Resource allowed because whitelist session is valid"
|
||||||
|
// );
|
||||||
|
// return allowed(res);
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// if (resourceSession.accessTokenId) {
|
||||||
|
// logger.debug(
|
||||||
|
// "Resource allowed because access token session is valid"
|
||||||
|
// );
|
||||||
|
// return allowed(res);
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -6,8 +6,10 @@ import * as target from "./target";
|
||||||
import * as user from "./user";
|
import * as user from "./user";
|
||||||
import * as auth from "./auth";
|
import * as auth from "./auth";
|
||||||
import * as role from "./role";
|
import * as role from "./role";
|
||||||
|
import * as accessToken from "./accessToken";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import {
|
import {
|
||||||
|
verifyAccessTokenAccess,
|
||||||
rateLimitMiddleware,
|
rateLimitMiddleware,
|
||||||
verifySessionMiddleware,
|
verifySessionMiddleware,
|
||||||
verifySessionUserMiddleware,
|
verifySessionUserMiddleware,
|
||||||
|
@ -114,11 +116,13 @@ authenticated.put(
|
||||||
verifyUserHasAction(ActionsEnum.createResource),
|
verifyUserHasAction(ActionsEnum.createResource),
|
||||||
resource.createResource
|
resource.createResource
|
||||||
);
|
);
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/site/:siteId/resources",
|
"/site/:siteId/resources",
|
||||||
verifyUserHasAction(ActionsEnum.listResources),
|
verifyUserHasAction(ActionsEnum.listResources),
|
||||||
resource.listResources
|
resource.listResources
|
||||||
);
|
);
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/org/:orgId/resources",
|
"/org/:orgId/resources",
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
|
@ -278,31 +282,59 @@ authenticated.post(
|
||||||
authenticated.post(
|
authenticated.post(
|
||||||
`/resource/:resourceId/password`,
|
`/resource/:resourceId/password`,
|
||||||
verifyResourceAccess,
|
verifyResourceAccess,
|
||||||
verifyUserHasAction(ActionsEnum.updateResource), // REVIEW: group all resource related updates under update resource?
|
verifyUserHasAction(ActionsEnum.setResourcePassword),
|
||||||
resource.setResourcePassword
|
resource.setResourcePassword
|
||||||
);
|
);
|
||||||
|
|
||||||
authenticated.post(
|
authenticated.post(
|
||||||
`/resource/:resourceId/pincode`,
|
`/resource/:resourceId/pincode`,
|
||||||
verifyResourceAccess,
|
verifyResourceAccess,
|
||||||
verifyUserHasAction(ActionsEnum.updateResource),
|
verifyUserHasAction(ActionsEnum.setResourcePincode),
|
||||||
resource.setResourcePincode
|
resource.setResourcePincode
|
||||||
);
|
);
|
||||||
|
|
||||||
authenticated.post(
|
authenticated.post(
|
||||||
`/resource/:resourceId/whitelist`,
|
`/resource/:resourceId/whitelist`,
|
||||||
verifyResourceAccess,
|
verifyResourceAccess,
|
||||||
verifyUserHasAction(ActionsEnum.updateResource),
|
verifyUserHasAction(ActionsEnum.setResourceWhitelist),
|
||||||
resource.setResourceWhitelist
|
resource.setResourceWhitelist
|
||||||
);
|
);
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
`/resource/:resourceId/whitelist`,
|
`/resource/:resourceId/whitelist`,
|
||||||
verifyResourceAccess,
|
verifyResourceAccess,
|
||||||
verifyUserHasAction(ActionsEnum.getResource),
|
verifyUserHasAction(ActionsEnum.getResourceWhitelist),
|
||||||
resource.getResourceWhitelist
|
resource.getResourceWhitelist
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
`/resource/:resourceId/access-token`,
|
||||||
|
verifyResourceAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.generateAccessToken),
|
||||||
|
accessToken.generateAccessToken
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.delete(
|
||||||
|
`/access-token/:accessTokenId`,
|
||||||
|
verifyAccessTokenAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.deleteAcessToken),
|
||||||
|
accessToken.deleteAccessToken
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
`/org/:orgId/access-tokens`,
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.listAccessTokens),
|
||||||
|
accessToken.listAccessTokens
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
`/resource/:resourceId/access-tokens`,
|
||||||
|
verifyResourceAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.listAccessTokens),
|
||||||
|
accessToken.listAccessTokens
|
||||||
|
);
|
||||||
|
|
||||||
unauthenticated.get("/resource/:resourceId/auth", resource.getResourceAuthInfo);
|
unauthenticated.get("/resource/:resourceId/auth", resource.getResourceAuthInfo);
|
||||||
|
|
||||||
// authenticated.get(
|
// authenticated.get(
|
||||||
|
@ -422,3 +454,7 @@ authRouter.post("/reset-password/", auth.resetPassword);
|
||||||
authRouter.post("/resource/:resourceId/password", resource.authWithPassword);
|
authRouter.post("/resource/:resourceId/password", resource.authWithPassword);
|
||||||
authRouter.post("/resource/:resourceId/pincode", resource.authWithPincode);
|
authRouter.post("/resource/:resourceId/pincode", resource.authWithPincode);
|
||||||
authRouter.post("/resource/:resourceId/whitelist", resource.authWithWhitelist);
|
authRouter.post("/resource/:resourceId/whitelist", resource.authWithWhitelist);
|
||||||
|
authRouter.post(
|
||||||
|
"/resource/:resourceId/access-token",
|
||||||
|
resource.authWithAccessToken
|
||||||
|
);
|
||||||
|
|
157
server/routers/resource/authWithAccessToken.ts
Normal file
157
server/routers/resource/authWithAccessToken.ts
Normal file
|
@ -0,0 +1,157 @@
|
||||||
|
import { generateSessionToken } from "@server/auth";
|
||||||
|
import db from "@server/db";
|
||||||
|
import { resourceAccessToken, resources } from "@server/db/schema";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import response from "@server/utils/response";
|
||||||
|
import { eq, and } 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/resource";
|
||||||
|
import config from "@server/config";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { verify } from "@node-rs/argon2";
|
||||||
|
import { isWithinExpirationDate } from "oslo";
|
||||||
|
|
||||||
|
const authWithAccessTokenBodySchema = z.object({
|
||||||
|
accessToken: z.string()
|
||||||
|
});
|
||||||
|
|
||||||
|
const authWithAccessTokenParamsSchema = z.object({
|
||||||
|
resourceId: z.string().transform(Number).pipe(z.number().int().positive())
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AuthWithAccessTokenResponse = {
|
||||||
|
session?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function authWithAccessToken(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
const parsedBody = authWithAccessTokenBodySchema.safeParse(req.body);
|
||||||
|
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedBody.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedParams = authWithAccessTokenParamsSchema.safeParse(req.params);
|
||||||
|
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { resourceId } = parsedParams.data;
|
||||||
|
const { accessToken: at } = parsedBody.data;
|
||||||
|
|
||||||
|
const [accessTokenId, accessToken] = at.split(".");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [result] = await db
|
||||||
|
.select()
|
||||||
|
.from(resourceAccessToken)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(resourceAccessToken.resourceId, resourceId),
|
||||||
|
eq(resourceAccessToken.accessTokenId, accessTokenId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.leftJoin(
|
||||||
|
resources,
|
||||||
|
eq(resources.resourceId, resourceAccessToken.resourceId)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const resource = result?.resources;
|
||||||
|
const tokenItem = result?.resourceAccessToken;
|
||||||
|
|
||||||
|
if (!tokenItem) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.UNAUTHORIZED,
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Email is not whitelisted"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resource) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.BAD_REQUEST, "Resource does not exist")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const validCode = await verify(tokenItem.tokenHash, accessToken, {
|
||||||
|
memoryCost: 19456,
|
||||||
|
timeCost: 2,
|
||||||
|
outputLen: 32,
|
||||||
|
parallelism: 1
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!validCode) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.UNAUTHORIZED, "Invalid access token")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
tokenItem.expiresAt &&
|
||||||
|
!isWithinExpirationDate(new Date(tokenItem.expiresAt))
|
||||||
|
) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.UNAUTHORIZED,
|
||||||
|
"Access token has expired"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = generateSessionToken();
|
||||||
|
await createResourceSession({
|
||||||
|
resourceId,
|
||||||
|
token,
|
||||||
|
accessTokenId: tokenItem.accessTokenId,
|
||||||
|
sessionLength: tokenItem.sessionLength,
|
||||||
|
expiresAt: tokenItem.expiresAt,
|
||||||
|
doNotExtend: tokenItem.expiresAt ? false : true
|
||||||
|
});
|
||||||
|
const cookieName = `${config.server.resource_session_cookie_name}_${resource.resourceId}`;
|
||||||
|
const cookie = serializeResourceSessionCookie(cookieName, token);
|
||||||
|
res.appendHeader("Set-Cookie", cookie);
|
||||||
|
|
||||||
|
return response<AuthWithAccessTokenResponse>(res, {
|
||||||
|
data: {
|
||||||
|
session: token
|
||||||
|
},
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Authenticated with resource successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(e);
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Failed to authenticate with resource"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -174,7 +174,6 @@ export async function authWithWhitelist(
|
||||||
const cookie = serializeResourceSessionCookie(
|
const cookie = serializeResourceSessionCookie(
|
||||||
cookieName,
|
cookieName,
|
||||||
token,
|
token,
|
||||||
resource.fullDomain
|
|
||||||
);
|
);
|
||||||
res.appendHeader("Set-Cookie", cookie);
|
res.appendHeader("Set-Cookie", cookie);
|
||||||
|
|
||||||
|
@ -188,7 +187,6 @@ export async function authWithWhitelist(
|
||||||
status: HttpCode.OK
|
status: HttpCode.OK
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw e;
|
|
||||||
logger.error(e);
|
logger.error(e);
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
|
|
|
@ -15,3 +15,4 @@ export * from "./authWithPincode";
|
||||||
export * from "./setResourceWhitelist";
|
export * from "./setResourceWhitelist";
|
||||||
export * from "./getResourceWhitelist";
|
export * from "./getResourceWhitelist";
|
||||||
export * from "./authWithWhitelist";
|
export * from "./authWithWhitelist";
|
||||||
|
export * from "./authWithAccessToken";
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { Metadata } from "next";
|
import { Metadata } from "next";
|
||||||
import { TopbarNav } from "./components/TopbarNav";
|
import { TopbarNav } from "./components/TopbarNav";
|
||||||
import { Cog, Combine, Settings, Users, Waypoints } from "lucide-react";
|
import { Cog, Combine, Link, Settings, Users, Waypoints } from "lucide-react";
|
||||||
import Header from "./components/Header";
|
import Header from "./components/Header";
|
||||||
import { verifySession } from "@app/lib/auth/verifySession";
|
import { verifySession } from "@app/lib/auth/verifySession";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
@ -30,10 +30,15 @@ const topNavItems = [
|
||||||
icon: <Waypoints className="h-4 w-4" />
|
icon: <Waypoints className="h-4 w-4" />
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Access",
|
title: "Users & Roles",
|
||||||
href: "/{orgId}/settings/access",
|
href: "/{orgId}/settings/access",
|
||||||
icon: <Users className="h-4 w-4" />
|
icon: <Users className="h-4 w-4" />
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Sharable Links",
|
||||||
|
href: "/{orgId}/settings/links",
|
||||||
|
icon: <Link className="h-4 w-4" />
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "General",
|
title: "General",
|
||||||
href: "/{orgId}/settings/general",
|
href: "/{orgId}/settings/general",
|
||||||
|
@ -105,7 +110,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
|
||||||
<div className="container mx-auto sm:px-0 px-3">{children}</div>
|
<div className="container mx-auto sm:px-0 px-3">{children}</div>
|
||||||
|
|
||||||
<footer className="w-full mt-6 py-3">
|
<footer className="w-full mt-6 py-3">
|
||||||
<div className="container mx-auto flex justify-end items-center px-3 sm:px-0 text-sm text-muted space-x-3">
|
<div className="container mx-auto flex justify-end items-center px-3 sm:px-0 text-sm text-muted space-x-3 select-none">
|
||||||
<div>Built by Fossorial</div>
|
<div>Built by Fossorial</div>
|
||||||
<a
|
<a
|
||||||
href="https://github.com/fosrl/pangolin"
|
href="https://github.com/fosrl/pangolin"
|
||||||
|
@ -117,7 +122,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
className="w-5 h-5"
|
className="w-4 h-4"
|
||||||
>
|
>
|
||||||
<path d="M12 0C5.37 0 0 5.373 0 12c0 5.303 3.438 9.8 8.207 11.385.6.11.82-.26.82-.577v-2.17c-3.338.726-4.042-1.61-4.042-1.61-.546-1.385-1.333-1.755-1.333-1.755-1.09-.744.082-.73.082-.73 1.205.085 1.84 1.24 1.84 1.24 1.07 1.835 2.807 1.305 3.492.997.107-.775.42-1.305.763-1.605-2.665-.305-5.467-1.335-5.467-5.93 0-1.31.468-2.382 1.236-3.22-.123-.303-.535-1.523.117-3.176 0 0 1.008-.322 3.3 1.23a11.52 11.52 0 013.006-.403c1.02.005 2.045.137 3.006.403 2.29-1.552 3.295-1.23 3.295-1.23.654 1.653.242 2.873.12 3.176.77.838 1.235 1.91 1.235 3.22 0 4.605-2.805 5.623-5.475 5.92.43.37.814 1.1.814 2.22v3.293c0 .32.217.693.825.576C20.565 21.795 24 17.298 24 12 24 5.373 18.627 0 12 0z" />
|
<path d="M12 0C5.37 0 0 5.373 0 12c0 5.303 3.438 9.8 8.207 11.385.6.11.82-.26.82-.577v-2.17c-3.338.726-4.042-1.61-4.042-1.61-.546-1.385-1.333-1.755-1.333-1.755-1.09-.744.082-.73.082-.73 1.205.085 1.84 1.24 1.84 1.24 1.07 1.835 2.807 1.305 3.492.997.107-.775.42-1.305.763-1.605-2.665-.305-5.467-1.335-5.467-5.93 0-1.31.468-2.382 1.236-3.22-.123-.303-.535-1.523.117-3.176 0 0 1.008-.322 3.3 1.23a11.52 11.52 0 013.006-.403c1.02.005 2.045.137 3.006.403 2.29-1.552 3.295-1.23 3.295-1.23.654 1.653.242 2.873.12 3.176.77.838 1.235 1.91 1.235 3.22 0 4.605-2.805 5.623-5.475 5.92.43.37.814 1.1.814 2.22v3.293c0 .32.217.693.825.576C20.565 21.795 24 17.298 24 12 24 5.373 18.627 0 12 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|
|
@ -45,7 +45,7 @@ import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||||
import { formatAxiosError } from "@app/lib/utils";
|
import { formatAxiosError } from "@app/lib/utils";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import LoginForm from "@app/components/LoginForm";
|
import LoginForm from "@app/components/LoginForm";
|
||||||
import { AuthWithPasswordResponse, AuthWithWhitelistResponse } from "@server/routers/resource";
|
import { AuthWithPasswordResponse, AuthWithAccessTokenResponse } from "@server/routers/resource";
|
||||||
import { redirect } from "next/dist/server/api-utils";
|
import { redirect } from "next/dist/server/api-utils";
|
||||||
import ResourceAccessDenied from "./ResourceAccessDenied";
|
import ResourceAccessDenied from "./ResourceAccessDenied";
|
||||||
import { createApiClient } from "@app/api";
|
import { createApiClient } from "@app/api";
|
||||||
|
@ -166,7 +166,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||||
|
|
||||||
const onWhitelistSubmit = (values: any) => {
|
const onWhitelistSubmit = (values: any) => {
|
||||||
setLoadingLogin(true);
|
setLoadingLogin(true);
|
||||||
api.post<AxiosResponse<AuthWithWhitelistResponse>>(
|
api.post<AxiosResponse<AuthWithAccessTokenResponse>>(
|
||||||
`/auth/resource/${props.resource.id}/whitelist`,
|
`/auth/resource/${props.resource.id}/whitelist`,
|
||||||
{ email: values.email, otp: values.otp }
|
{ email: values.email, otp: values.otp }
|
||||||
)
|
)
|
||||||
|
|
|
@ -43,8 +43,8 @@ export default async function ResourceAuthPage(props: {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasAuth = authInfo.password || authInfo.pincode || authInfo.sso;
|
const hasAuth = authInfo.password || authInfo.pincode || authInfo.sso || authInfo.whitelist;
|
||||||
const isSSOOnly = authInfo.sso && !authInfo.password && !authInfo.pincode;
|
const isSSOOnly = authInfo.sso && !authInfo.password && !authInfo.pincode && !authInfo.whitelist;
|
||||||
|
|
||||||
const redirectUrl = searchParams.redirect || authInfo.url;
|
const redirectUrl = searchParams.redirect || authInfo.url;
|
||||||
|
|
||||||
|
@ -70,8 +70,6 @@ export default async function ResourceAuthPage(props: {
|
||||||
AxiosResponse<CheckResourceSessionResponse>
|
AxiosResponse<CheckResourceSessionResponse>
|
||||||
>(`/resource-session/${params.resourceId}/${sessionId}`);
|
>(`/resource-session/${params.resourceId}/${sessionId}`);
|
||||||
|
|
||||||
console.log("resource session already exists and is valid");
|
|
||||||
|
|
||||||
if (res && res.data.data.valid) {
|
if (res && res.data.data.valid) {
|
||||||
doRedirect = true;
|
doRedirect = true;
|
||||||
}
|
}
|
||||||
|
@ -96,7 +94,6 @@ export default async function ResourceAuthPage(props: {
|
||||||
await authCookieHeader(),
|
await authCookieHeader(),
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(res.data);
|
|
||||||
doRedirect = true;
|
doRedirect = true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
userIsUnauthorized = true;
|
userIsUnauthorized = true;
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue