From 72dc02ff2e7b635b4baa50e1e6c42ee7376bb17a Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Wed, 18 Dec 2024 23:14:26 -0500 Subject: [PATCH] access token endpoints and other backend support --- server/auth/actions.ts | 9 +- server/auth/resource.ts | 49 +++-- server/db/schema.ts | 26 +++ server/internalServer.ts | 37 ++-- server/logger.ts | 1 + .../helpers/canUserAccessResource.ts | 45 +++++ server/middlewares/index.ts | 1 + server/middlewares/verifyAccessTokenAccess.ts | 123 ++++++++++++ server/middlewares/verifyTargetAccess.ts | 19 +- .../routers/accessToken/deleteAccessToken.ts | 67 +++++++ .../accessToken/generateAccessToken.ts | 120 ++++++++++++ server/routers/accessToken/index.ts | 3 + .../routers/accessToken/listAccessTokens.ts | 183 ++++++++++++++++++ server/routers/auth/checkResourceSession.ts | 1 - server/routers/badger/verifySession.ts | 100 ++++------ server/routers/external.ts | 44 ++++- .../routers/resource/authWithAccessToken.ts | 157 +++++++++++++++ server/routers/resource/authWithWhitelist.ts | 2 - server/routers/resource/index.ts | 1 + src/app/[orgId]/settings/layout.tsx | 13 +- .../components/ResourceAuthPortal.tsx | 4 +- src/app/auth/resource/[resourceId]/page.tsx | 7 +- 22 files changed, 905 insertions(+), 107 deletions(-) create mode 100644 server/middlewares/helpers/canUserAccessResource.ts create mode 100644 server/middlewares/verifyAccessTokenAccess.ts create mode 100644 server/routers/accessToken/deleteAccessToken.ts create mode 100644 server/routers/accessToken/generateAccessToken.ts create mode 100644 server/routers/accessToken/index.ts create mode 100644 server/routers/accessToken/listAccessTokens.ts create mode 100644 server/routers/resource/authWithAccessToken.ts diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 2e98b25d..6adbf0d6 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -49,7 +49,14 @@ export enum ActionsEnum { // addUserAction = "addUserAction", // removeUserAction = "removeUserAction", // removeUserSite = "removeUserSite", - getOrgUser = "getOrgUser" + getOrgUser = "getOrgUser", + "setResourcePassword" = "setResourcePassword", + "setResourcePincode" = "setResourcePincode", + "setResourceWhitelist" = "setResourceWhitelist", + "getResourceWhitelist" = "getResourceWhitelist", + "generateAccessToken" = "generateAccessToken", + "deleteAcessToken" = "deleteAcessToken", + "listAccessTokens" = "listAccessTokens" } export async function checkUserActionPermission( diff --git a/server/auth/resource.ts b/server/auth/resource.ts index 6b6e6aa9..293a9d7a 100644 --- a/server/auth/resource.ts +++ b/server/auth/resource.ts @@ -1,6 +1,10 @@ import { encodeHexLowerCase } from "@oslojs/encoding"; 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 { eq, and } from "drizzle-orm"; import config from "@server/config"; @@ -17,12 +21,19 @@ export async function createResourceSession(opts: { passwordId?: number; pincodeId?: number; whitelistId?: number; + accessTokenId?: string; usedOtp?: boolean; + doNotExtend?: boolean; + expiresAt?: number | null; + sessionLength: number; }): Promise { - if (!opts.passwordId && !opts.pincodeId && !opts.whitelistId) { - throw new Error( - "At least one of passwordId or pincodeId must be provided" - ); + if ( + !opts.passwordId && + !opts.pincodeId && + !opts.whitelistId && + !opts.accessTokenId + ) { + throw new Error("Auth method must be provided"); } const sessionId = encodeHexLowerCase( @@ -31,11 +42,16 @@ export async function createResourceSession(opts: { const session: ResourceSession = { 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, passwordId: opts.passwordId || 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); @@ -66,9 +82,18 @@ export async function validateResourceSessionToken( 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( - Date.now() + SESSION_COOKIE_EXPIRES + Date.now() + resourceSession.sessionLength ).getTime(); await db .update(resourceSessions) @@ -138,8 +163,7 @@ export async function invalidateAllSessions( export function serializeResourceSessionCookie( cookieName: string, - token: string, - fqdn: string + token: string ): string { if (SECURE_COOKIES) { 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( - cookieName: string, - fqdn: string + cookieName: string ): string { if (SECURE_COOKIES) { return `${cookieName}=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/; Secure; Domain=${COOKIE_DOMAIN}`; diff --git a/server/db/schema.ts b/server/db/schema.ts index 0c74f8ca..bd099e66 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -277,12 +277,32 @@ export const resourcePassword = sqliteTable("resourcePassword", { 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", { sessionId: text("id").primaryKey(), resourceId: integer("resourceId") .notNull() .references(() => resources.resourceId, { onDelete: "cascade" }), expiresAt: integer("expiresAt").notNull(), + sessionLength: integer("sessionLength").notNull(), + doNotExtend: integer("doNotExtend", { mode: "boolean" }) + .notNull() + .default(false), passwordId: integer("passwordId").references( () => resourcePassword.passwordId, { @@ -300,6 +320,12 @@ export const resourceSessions = sqliteTable("resourceSessions", { { onDelete: "cascade" } + ), + accessTokenId: text("accessTokenId").references( + () => resourceAccessToken.accessTokenId, + { + onDelete: "cascade" + } ) }); diff --git a/server/internalServer.ts b/server/internalServer.ts index b846d8f4..57b51278 100644 --- a/server/internalServer.ts +++ b/server/internalServer.ts @@ -4,29 +4,34 @@ import cors from "cors"; import cookieParser from "cookie-parser"; import config from "@server/config"; import logger from "@server/logger"; -import { errorHandlerMiddleware, notFoundMiddleware } from "@server/middlewares"; +import { + errorHandlerMiddleware, + notFoundMiddleware +} from "@server/middlewares"; import internal from "@server/routers/internal"; const internalPort = config.server.internal_port; export function createInternalServer() { - const internalServer = express(); + const internalServer = express(); - internalServer.use(helmet()); - internalServer.use(cors()); - internalServer.use(cookieParser()); - internalServer.use(express.json()); + internalServer.use(helmet()); + internalServer.use(cors()); + internalServer.use(cookieParser()); + internalServer.use(express.json()); - const prefix = `/api/v1`; - internalServer.use(prefix, internal); + const prefix = `/api/v1`; + internalServer.use(prefix, internal); - internalServer.use(notFoundMiddleware); - internalServer.use(errorHandlerMiddleware); + internalServer.use(notFoundMiddleware); + internalServer.use(errorHandlerMiddleware); - internalServer.listen(internalPort, (err?: any) => { - if (err) throw err; - logger.info(`Internal server is running on http://localhost:${internalPort}`); - }); + internalServer.listen(internalPort, (err?: any) => { + if (err) throw err; + logger.info( + `Internal server is running on http://localhost:${internalPort}` + ); + }); - return internalServer; -} \ No newline at end of file + return internalServer; +} diff --git a/server/logger.ts b/server/logger.ts index 832c7c9f..0fb34779 100644 --- a/server/logger.ts +++ b/server/logger.ts @@ -16,6 +16,7 @@ const hformat = winston.format.printf( const transports: any = [ new winston.transports.Console({ format: winston.format.combine( + winston.format.errors({ stack: true }), winston.format.colorize(), winston.format.splat(), winston.format.timestamp(), diff --git a/server/middlewares/helpers/canUserAccessResource.ts b/server/middlewares/helpers/canUserAccessResource.ts new file mode 100644 index 00000000..bdafaa0d --- /dev/null +++ b/server/middlewares/helpers/canUserAccessResource.ts @@ -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 { + 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; +} diff --git a/server/middlewares/index.ts b/server/middlewares/index.ts index c7ada386..03de18cb 100644 --- a/server/middlewares/index.ts +++ b/server/middlewares/index.ts @@ -13,3 +13,4 @@ export * from "./verifyUserAccess"; export * from "./verifyAdmin"; export * from "./verifySetResourceUsers"; export * from "./verifyUserInRole"; +export * from "./verifyAccessTokenAccess"; diff --git a/server/middlewares/verifyAccessTokenAccess.ts b/server/middlewares/verifyAccessTokenAccess.ts new file mode 100644 index 00000000..159bd662 --- /dev/null +++ b/server/middlewares/verifyAccessTokenAccess.ts @@ -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" + ) + ); + } +} diff --git a/server/middlewares/verifyTargetAccess.ts b/server/middlewares/verifyTargetAccess.ts index eb086622..a5c19ba0 100644 --- a/server/middlewares/verifyTargetAccess.ts +++ b/server/middlewares/verifyTargetAccess.ts @@ -4,6 +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 "./helpers/canUserAccessResource"; export async function verifyTargetAccess( req: Request, @@ -99,8 +100,24 @@ export async function verifyTargetAccess( } else { req.userOrgRoleId = req.userOrg.roleId; 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) { return next( createHttpError( diff --git a/server/routers/accessToken/deleteAccessToken.ts b/server/routers/accessToken/deleteAccessToken.ts new file mode 100644 index 00000000..67223f2e --- /dev/null +++ b/server/routers/accessToken/deleteAccessToken.ts @@ -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 { + 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") + ); + } +} diff --git a/server/routers/accessToken/generateAccessToken.ts b/server/routers/accessToken/generateAccessToken.ts new file mode 100644 index 00000000..3036aa5b --- /dev/null +++ b/server/routers/accessToken/generateAccessToken.ts @@ -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 { + 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(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" + ) + ); + } +} diff --git a/server/routers/accessToken/index.ts b/server/routers/accessToken/index.ts new file mode 100644 index 00000000..074a7c46 --- /dev/null +++ b/server/routers/accessToken/index.ts @@ -0,0 +1,3 @@ +export * from "./generateAccessToken"; +export * from "./listAccessTokens"; +export * from "./deleteAccessToken"; diff --git a/server/routers/accessToken/listAccessTokens.ts b/server/routers/accessToken/listAccessTokens.ts new file mode 100644 index 00000000..30a20074 --- /dev/null +++ b/server/routers/accessToken/listAccessTokens.ts @@ -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>>; + pagination: { total: number; limit: number; offset: number }; +}; + +export async function listAccessTokens( + req: Request, + res: Response, + next: NextFunction +): Promise { + 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`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(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") + ); + } +} diff --git a/server/routers/auth/checkResourceSession.ts b/server/routers/auth/checkResourceSession.ts index 11f4ca90..c5f453fb 100644 --- a/server/routers/auth/checkResourceSession.ts +++ b/server/routers/auth/checkResourceSession.ts @@ -4,7 +4,6 @@ import { z } from "zod"; import { fromError } from "zod-validation-error"; import HttpCode from "@server/types/HttpCode"; import { response } from "@server/utils"; -import { validateSessionToken } from "@server/auth"; import { validateResourceSessionToken } from "@server/auth/resource"; export const params = z.object({ diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index 032c0faf..fadcd354 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -7,9 +7,11 @@ import { response } from "@server/utils/response"; import { validateSessionToken } from "@server/auth"; import db from "@server/db"; import { + resourceAccessToken, resourcePassword, resourcePincode, resources, + resourceWhitelist, User, userOrgs } from "@server/db/schema"; @@ -89,7 +91,12 @@ export async function verifyResourceSession( return notAllowed(res); } - if (!resource.sso && !pincode && !password) { + if ( + !resource.sso && + !pincode && + !password && + !resource.emailWhitelistEnabled + ) { logger.debug("Resource allowed because no auth"); return allowed(res); } @@ -103,7 +110,7 @@ export async function verifyResourceSession( const sessionToken = sessions[config.server.session_cookie_name]; // check for unified login - if (sso && sessionToken && !resource.otpEnabled) { + if (sso && sessionToken) { const { session, user } = await validateSessionToken(sessionToken); if (session && user) { const isAllowed = await isUserAllowedToAccessResource( @@ -125,69 +132,46 @@ export async function verifyResourceSession( `${config.server.resource_session_cookie_name}_${resource.resourceId}` ]; - if ( - 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) { + if (resourceSessionToken) { const { resourceSession } = await validateResourceSessionToken( resourceSessionToken, resource.resourceId ); if (resourceSession) { - if (resource.otpEnabled && !resourceSession.usedOtp) { - logger.debug("Resource not allowed because OTP not used"); - return notAllowed(res, redirectUrl); - } + return allowed(res); - if ( - pincode && - resourceSession.pincodeId === pincode.pincodeId - ) { - logger.debug( - "Resource allowed because pincode session is valid" - ); - return allowed(res); - } - - if ( - password && - resourceSession.passwordId === password.passwordId - ) { - logger.debug( - "Resource allowed because password session is valid" - ); - return allowed(res); - } + // Might not be needed + // if (pincode && resourceSession.pincodeId) { + // logger.debug( + // "Resource allowed because pincode session is valid" + // ); + // return allowed(res); + // } + // + // if (password && resourceSession.passwordId) { + // logger.debug( + // "Resource allowed because password session is valid" + // ); + // return allowed(res); + // } + // + // if ( + // resource.emailWhitelistEnabled && + // 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); + // } } } diff --git a/server/routers/external.ts b/server/routers/external.ts index 7148acc9..70df8722 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -6,8 +6,10 @@ import * as target from "./target"; import * as user from "./user"; import * as auth from "./auth"; import * as role from "./role"; +import * as accessToken from "./accessToken"; import HttpCode from "@server/types/HttpCode"; import { + verifyAccessTokenAccess, rateLimitMiddleware, verifySessionMiddleware, verifySessionUserMiddleware, @@ -114,11 +116,13 @@ authenticated.put( verifyUserHasAction(ActionsEnum.createResource), resource.createResource ); + authenticated.get( "/site/:siteId/resources", verifyUserHasAction(ActionsEnum.listResources), resource.listResources ); + authenticated.get( "/org/:orgId/resources", verifyOrgAccess, @@ -278,31 +282,59 @@ authenticated.post( authenticated.post( `/resource/:resourceId/password`, verifyResourceAccess, - verifyUserHasAction(ActionsEnum.updateResource), // REVIEW: group all resource related updates under update resource? + verifyUserHasAction(ActionsEnum.setResourcePassword), resource.setResourcePassword ); authenticated.post( `/resource/:resourceId/pincode`, verifyResourceAccess, - verifyUserHasAction(ActionsEnum.updateResource), + verifyUserHasAction(ActionsEnum.setResourcePincode), resource.setResourcePincode ); authenticated.post( `/resource/:resourceId/whitelist`, verifyResourceAccess, - verifyUserHasAction(ActionsEnum.updateResource), + verifyUserHasAction(ActionsEnum.setResourceWhitelist), resource.setResourceWhitelist ); authenticated.get( `/resource/:resourceId/whitelist`, verifyResourceAccess, - verifyUserHasAction(ActionsEnum.getResource), + verifyUserHasAction(ActionsEnum.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); // authenticated.get( @@ -422,3 +454,7 @@ authRouter.post("/reset-password/", auth.resetPassword); authRouter.post("/resource/:resourceId/password", resource.authWithPassword); authRouter.post("/resource/:resourceId/pincode", resource.authWithPincode); authRouter.post("/resource/:resourceId/whitelist", resource.authWithWhitelist); +authRouter.post( + "/resource/:resourceId/access-token", + resource.authWithAccessToken +); diff --git a/server/routers/resource/authWithAccessToken.ts b/server/routers/resource/authWithAccessToken.ts new file mode 100644 index 00000000..41002860 --- /dev/null +++ b/server/routers/resource/authWithAccessToken.ts @@ -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 { + 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(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" + ) + ); + } +} diff --git a/server/routers/resource/authWithWhitelist.ts b/server/routers/resource/authWithWhitelist.ts index 234125a5..c5b6a561 100644 --- a/server/routers/resource/authWithWhitelist.ts +++ b/server/routers/resource/authWithWhitelist.ts @@ -174,7 +174,6 @@ export async function authWithWhitelist( const cookie = serializeResourceSessionCookie( cookieName, token, - resource.fullDomain ); res.appendHeader("Set-Cookie", cookie); @@ -188,7 +187,6 @@ export async function authWithWhitelist( status: HttpCode.OK }); } catch (e) { - throw e; logger.error(e); return next( createHttpError( diff --git a/server/routers/resource/index.ts b/server/routers/resource/index.ts index 03f2a5ea..7dbee1bf 100644 --- a/server/routers/resource/index.ts +++ b/server/routers/resource/index.ts @@ -15,3 +15,4 @@ export * from "./authWithPincode"; export * from "./setResourceWhitelist"; export * from "./getResourceWhitelist"; export * from "./authWithWhitelist"; +export * from "./authWithAccessToken"; diff --git a/src/app/[orgId]/settings/layout.tsx b/src/app/[orgId]/settings/layout.tsx index 0e7c5b55..979b0b54 100644 --- a/src/app/[orgId]/settings/layout.tsx +++ b/src/app/[orgId]/settings/layout.tsx @@ -1,6 +1,6 @@ import { Metadata } from "next"; 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 { verifySession } from "@app/lib/auth/verifySession"; import { redirect } from "next/navigation"; @@ -30,10 +30,15 @@ const topNavItems = [ icon: }, { - title: "Access", + title: "Users & Roles", href: "/{orgId}/settings/access", icon: }, + { + title: "Sharable Links", + href: "/{orgId}/settings/links", + icon: + }, { title: "General", href: "/{orgId}/settings/general", @@ -105,7 +110,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
{children}