diff --git a/install/main.go b/install/main.go index 001f09a52..13afbaca3 100644 --- a/install/main.go +++ b/install/main.go @@ -76,7 +76,7 @@ var redisFlag *bool func main() { crowdsecFlag := flag.Bool("crowdsec", false, "Enable the CrowdSec installation prompt") - redisFlag = flag.Bool("redis", false, "Install Redis as cacheing solution. Required for HA. Not required for the Enterprise version.") + redisFlag = flag.Bool("redis", false, "Install Redis as caching solution. Required for HA. Not required for the Enterprise version.") flag.Parse() // print a banner about prerequisites - opening port 80, 443, 51820, and 21820 on the VPS and firewall and pointing your domain to the VPS IP with a records. Docs are at http://localhost:3000/Getting%20Started/dns-networking diff --git a/messages/en-US.json b/messages/en-US.json index 678c73ebd..59b9f86d1 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -178,7 +178,7 @@ "shareDeleteConfirm": "Confirm Delete Shareable Link", "shareQuestionRemove": "Are you sure you want to delete this share link?", "shareMessageRemove": "Once deleted, the link will no longer work and anyone using it will lose access to the resource.", - "shareTokenDescription": "The access token can be passed in two ways: as a query parameter or in the request headers. These must be passed from the client on every request for authenticated access.", + "shareTokenDescription": "The access token can be passed as a query parameter or in request headers. By default it must be sent on every request. If session persistence is enabled, the first request exchanges it for a session cookie.", "accessToken": "Access Token", "usageExamples": "Usage Examples", "tokenId": "Token ID", @@ -196,8 +196,14 @@ "shareTitleOptional": "Title (optional)", "sharePathOptional": "Path (optional)", "sharePathDescription": "The link will redirect users to this path after authentication.", + "shareAssociateUserOptional": "Associate User (optional)", + "shareAssociateUserDescription": "When set, requests using this link are attributed to the user in access logs and identity headers. The link is removed if the user leaves the organization.", + "userSelect": "Select user", + "usersNotFound": "No users found", "expireIn": "Expire In", "neverExpire": "Never expire", + "sharePersistSession": "Persist session after first use", + "sharePersistSessionDescription": "When enabled, the first request with this token via a query param or header sets a session cookie so later requests do not need the token. Leave off for API clients that should send the token on every request.", "shareExpireDescription": "Expiration time is how long the link will be usable and provide access to the resource. After this time, the link will no longer work, and users who used this link will lose access to the resource.", "shareSeeOnce": "You will only be able to see this link once. Make sure to copy it.", "shareAccessHint": "Anyone with this link can access the resource. Share it with care.", @@ -3089,8 +3095,8 @@ "sourceAddress": "Source Address", "destinationAddress": "Destination Address", "duration": "Duration", - "licenseRequiredToUse": "An Enterprise Edition license or Pangolin Cloud is required to use this feature. Book a free demo or POC trial to learn more.", - "ossEnterpriseEditionRequired": "The Enterprise Edition is required to use this feature. This feature is also available in Pangolin Cloud. Book a free demo or POC trial to learn more.", + "licenseRequiredToUse": "An Enterprise Edition license or Pangolin Cloud is required to use this feature. Book a free demo or POC trial to learn more.", + "ossEnterpriseEditionRequired": "The Enterprise Edition is required to use this feature. This feature is also available in Pangolin Cloud. Book a free demo or POC trial to learn more.", "certResolver": "Certificate Resolver", "certResolverDescription": "Select the certificate resolver to use for this resource.", "selectCertResolver": "Select Certificate Resolver", diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index f3375b0d9..09e12e6d0 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -107,6 +107,7 @@ export const sites = pgTable( lastPing: integer("lastPing"), address: varchar("address"), endpoint: varchar("endpoint"), + localEndpoints: varchar("localEndpoints"), // JSON encoded list of string ips on the local machine to try to connect to publicKey: varchar("publicKey"), lastHolePunch: bigint("lastHolePunch", { mode: "number" }), listenPort: integer("listenPort"), @@ -905,12 +906,16 @@ export const resourceAccessToken = pgTable("resourceAccessToken", { resourceId: integer("resourceId") .notNull() .references(() => resources.resourceId, { onDelete: "cascade" }), + userId: varchar("userId").references(() => users.userId, { + onDelete: "cascade" + }), path: varchar("path"), tokenHash: varchar("tokenHash").notNull(), sessionLength: bigint("sessionLength", { mode: "number" }).notNull(), expiresAt: bigint("expiresAt", { mode: "number" }), title: varchar("title"), description: varchar("description"), + persistSession: boolean("persistSession").notNull().default(false), createdAt: bigint("createdAt", { mode: "number" }).notNull() }); diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 3fcf38a41..61f3c2d6e 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -118,6 +118,7 @@ export const sites = sqliteTable("sites", { // exit node stuff that is how to connect to the site when it has a wg server address: text("address"), // this is the address of the wireguard interface in newt endpoint: text("endpoint"), // this is how to reach gerbil externally - gets put into the wireguard config + localEndpoints: text("localEndpoints"), // JSON encoded list of string ips on the local machine to try to connect to publicKey: text("publicKey"), // TODO: Fix typo in publicKey lastHolePunch: integer("lastHolePunch"), listenPort: integer("listenPort"), @@ -1125,12 +1126,18 @@ export const resourceAccessToken = sqliteTable("resourceAccessToken", { resourceId: integer("resourceId") .notNull() .references(() => resources.resourceId, { onDelete: "cascade" }), + userId: text("userId").references(() => users.userId, { + onDelete: "cascade" + }), path: text("path"), tokenHash: text("tokenHash").notNull(), sessionLength: integer("sessionLength").notNull(), expiresAt: integer("expiresAt"), title: text("title"), description: text("description"), + persistSession: integer("persistSession", { mode: "boolean" }) + .notNull() + .default(false), createdAt: integer("createdAt").notNull() }); diff --git a/server/lib/consts.ts b/server/lib/consts.ts index 57a4836b8..9ee4ec4c3 100644 --- a/server/lib/consts.ts +++ b/server/lib/consts.ts @@ -2,7 +2,7 @@ import path from "path"; import { fileURLToPath } from "url"; // This is a placeholder value replaced by the build process -export const APP_VERSION = "1.20.0"; +export const APP_VERSION = "1.21.0"; export const __FILENAME = fileURLToPath(import.meta.url); export const __DIRNAME = path.dirname(__FILENAME); diff --git a/server/lib/userOrg.ts b/server/lib/userOrg.ts index 4bff40c13..ea086d0c0 100644 --- a/server/lib/userOrg.ts +++ b/server/lib/userOrg.ts @@ -2,6 +2,7 @@ import { db, Org, orgs, + resourceAccessToken, resources, siteResources, sites, @@ -83,6 +84,15 @@ export async function removeUserFromOrg( .delete(userOrgs) .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, org.orgId))); + await trx + .delete(resourceAccessToken) + .where( + and( + eq(resourceAccessToken.userId, userId), + eq(resourceAccessToken.orgId, org.orgId) + ) + ); + await trx.delete(userResources).where( and( eq(userResources.userId, userId), diff --git a/server/private/routers/hybrid.ts b/server/private/routers/hybrid.ts index 124db587a..06799bcd6 100644 --- a/server/private/routers/hybrid.ts +++ b/server/private/routers/hybrid.ts @@ -58,7 +58,8 @@ import { resourceRules, resourcePolicyRules, userOrgRoles, - roles + roles, + resourceAccessToken } from "@server/db"; import { eq, and, inArray, isNotNull, isNull, ne, or, sql } from "drizzle-orm"; import { alias } from "@server/db"; @@ -81,11 +82,16 @@ import config from "@server/lib/config"; import { exchangeSession } from "@server/routers/badger"; import { ResourceSessionValidationResult, + createResourceSession, + serializeResourceSessionCookie, validateResourceSessionToken } from "@server/auth/sessions/resource"; import { checkExitNodeOrg, resolveExitNodes } from "#private/lib/exitNodes"; import { maxmindLookup } from "@server/db/maxmind"; import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken"; +import { generateSessionToken } from "@server/auth/sessions/app"; +import { logAccessAudit } from "#private/lib/logAccessAudit"; +import { getUserOrgRoles } from "@server/lib/userOrgRoles"; import semver from "semver"; import { maxmindAsnLookup } from "@server/db/maxmindAsn"; import { checkOrgAccessPolicy } from "@server/lib/checkOrgAccessPolicy"; @@ -178,6 +184,73 @@ const validateResourceAccessTokenBodySchema = z.strictObject({ accessToken: z.string() }); +const createAccessTokenSessionParamsSchema = z.strictObject({ + resourceId: z.coerce.number().int().positive() +}); + +const createAccessTokenSessionBodySchema = z.strictObject({ + accessTokenId: z.string().min(1) +}); + +const getAccessTokenParamsSchema = z.strictObject({ + accessTokenId: z.string().min(1) +}); + +const logAccessAuditBodySchema = z.strictObject({ + action: z.boolean(), + type: z.string(), + orgId: z.string(), + resourceId: z.number().optional(), + siteResourceId: z.number().optional(), + user: z + .object({ + username: z.string(), + userId: z.string() + }) + .optional(), + apiKey: z + .object({ + name: z.string().nullable(), + apiKeyId: z.string() + }) + .optional(), + metadata: z.any().optional(), + userAgent: z.string().optional(), + requestIp: z.string().optional() +}); + +type AccessTokenUserData = { + userId: string; + username: string; + email: string | null; + name: string | null; + role: string | null; +}; + +async function resolveAccessTokenUserData( + userId: string, + orgId: string +): Promise { + const [user] = await db + .select() + .from(users) + .where(eq(users.userId, userId)) + .limit(1); + + if (!user) { + return undefined; + } + + const userOrgRoles = await getUserOrgRoles(user.userId, orgId); + return { + userId: user.userId, + username: user.username, + email: user.email, + name: user.name, + role: userOrgRoles.map((r) => r.roleName).join(", ") || null + }; +} + // Certificates by domains query validation const getCertificatesByDomainsQuerySchema = z.strictObject({ // Accept domains as string or array (domains or domains[]) @@ -1829,8 +1902,23 @@ hybridRouter.post( resourceId }); + let userData: AccessTokenUserData | undefined; + if ( + result.valid && + result.tokenItem?.userId && + result.tokenItem.orgId + ) { + userData = await resolveAccessTokenUserData( + result.tokenItem.userId, + result.tokenItem.orgId + ); + } + return response(res, { - data: result, + data: { + ...result, + userData + }, success: true, error: false, message: result.valid @@ -1850,6 +1938,233 @@ hybridRouter.post( } ); +// Create a resource session from a valid access token (for remote nodes) +hybridRouter.post( + "/resource/:resourceId/session/create-access-token", + async (req: Request, res: Response, next: NextFunction) => { + try { + const parsedParams = createAccessTokenSessionParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = createAccessTokenSessionBodySchema.safeParse( + req.body + ); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { resourceId } = parsedParams.data; + const { accessTokenId } = parsedBody.data; + + const [tokenItem] = await db + .select() + .from(resourceAccessToken) + .where(eq(resourceAccessToken.accessTokenId, accessTokenId)) + .limit(1); + + if (!tokenItem || tokenItem.resourceId !== resourceId) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "Access token not found" + ) + ); + } + + if (!tokenItem.persistSession) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Access token does not allow session persistence" + ) + ); + } + + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if (!resource || !resource.fullDomain) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Resource not found") + ); + } + + const token = generateSessionToken(); + const sess = await createResourceSession({ + resourceId: resource.resourceId, + token, + accessTokenId: tokenItem.accessTokenId, + sessionLength: tokenItem.sessionLength, + expiresAt: tokenItem.expiresAt, + doNotExtend: tokenItem.expiresAt ? true : false + }); + + const cookieName = config.getRawConfig().server.session_cookie_name; + const cookie = serializeResourceSessionCookie( + cookieName, + resource.fullDomain, + token, + !resource.ssl, + new Date(sess.expiresAt) + ); + + return response(res, { + data: { cookie }, + success: true, + error: false, + message: "Access token session created successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to create access token session" + ) + ); + } + } +); + +// Resolve access token metadata for remote nodes (cookie session path) +hybridRouter.get( + "/resource/access-token/:accessTokenId", + async (req: Request, res: Response, next: NextFunction) => { + try { + const parsedParams = getAccessTokenParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { accessTokenId } = parsedParams.data; + + const [tokenItem] = await db + .select() + .from(resourceAccessToken) + .where(eq(resourceAccessToken.accessTokenId, accessTokenId)) + .limit(1); + + if (!tokenItem) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "Access token not found" + ) + ); + } + + let userData: AccessTokenUserData | undefined; + if (tokenItem.userId) { + userData = await resolveAccessTokenUserData( + tokenItem.userId, + tokenItem.orgId + ); + } + + return response(res, { + data: { tokenItem, userData }, + success: true, + error: false, + message: "Access token retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to get access token" + ) + ); + } + } +); + +// Access audit log from remote nodes +hybridRouter.post( + "/logs/access", + async (req: Request, res: Response, next: NextFunction) => { + try { + const parsedBody = logAccessAuditBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const remoteExitNode = req.remoteExitNode; + if (!remoteExitNode || !remoteExitNode.exitNodeId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Remote exit node not found" + ) + ); + } + + if ( + await checkExitNodeOrg( + remoteExitNode.exitNodeId, + parsedBody.data.orgId + ) + ) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Exit node not allowed for this organization" + ) + ); + } + + await logAccessAudit(parsedBody.data); + + return response(res, { + data: null, + success: true, + error: false, + message: "Access audit log saved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to save access audit log" + ) + ); + } + } +); + const geoIpLookupParamsSchema = z.object({ ip: z.union([z.ipv4(), z.ipv6()]) }); diff --git a/server/routers/accessToken/generateAccessToken.ts b/server/routers/accessToken/generateAccessToken.ts index a06068c01..5b259af70 100644 --- a/server/routers/accessToken/generateAccessToken.ts +++ b/server/routers/accessToken/generateAccessToken.ts @@ -1,4 +1,3 @@ -import { hash } from "@node-rs/argon2"; import { generateId, generateIdFromEntropySize, @@ -8,18 +7,18 @@ import { db } from "@server/db"; import { ResourceAccessToken, resourceAccessToken, - resources + resources, + userOrgs } from "@server/db"; import HttpCode from "@server/types/HttpCode"; import response from "@server/lib/response"; -import { eq } from "drizzle-orm"; +import { and, 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"; -import { hashPassword } from "@server/auth/password"; import { encodeHexLowerCase } from "@oslojs/encoding"; import { sha256 } from "@oslojs/crypto/sha2"; import { OpenAPITags, registry } from "@server/openApi"; @@ -28,7 +27,9 @@ export const generateAccessTokenBodySchema = z.strictObject({ validForSeconds: z.int().positive().optional(), // seconds title: z.string().optional(), path: z.string().optional(), - description: z.string().optional() + description: z.string().optional(), + persistSession: z.boolean().optional().default(false), + userId: z.string().optional() }); export const generateAccssTokenParamsSchema = z.strictObject({ @@ -101,7 +102,14 @@ export async function generateAccessToken( } const { resourceId } = parsedParams.data; - const { validForSeconds, title, path, description } = parsedBody.data; + const { + validForSeconds, + title, + path, + description, + persistSession, + userId + } = parsedBody.data; const [resource] = await db .select() @@ -112,6 +120,28 @@ export async function generateAccessToken( return next(createHttpError(HttpCode.NOT_FOUND, "Resource not found")); } + if (userId) { + const [membership] = await db + .select() + .from(userOrgs) + .where( + and( + eq(userOrgs.userId, userId), + eq(userOrgs.orgId, resource.orgId) + ) + ) + .limit(1); + + if (!membership) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "User is not a member of this organization" + ) + ); + } + } + try { const sessionLength = validForSeconds ? validForSeconds * 1000 @@ -133,23 +163,27 @@ export async function generateAccessToken( accessTokenId: id, orgId: resource.orgId, resourceId, + userId: userId || null, tokenHash, expiresAt: expiresAt || null, sessionLength: sessionLength, title: title || null, path: path || null, description: description || null, + persistSession, createdAt: new Date().getTime() }) .returning({ accessTokenId: resourceAccessToken.accessTokenId, orgId: resourceAccessToken.orgId, resourceId: resourceAccessToken.resourceId, + userId: resourceAccessToken.userId, expiresAt: resourceAccessToken.expiresAt, sessionLength: resourceAccessToken.sessionLength, title: resourceAccessToken.title, path: resourceAccessToken.path, description: resourceAccessToken.description, + persistSession: resourceAccessToken.persistSession, createdAt: resourceAccessToken.createdAt }) .execute(); diff --git a/server/routers/accessToken/listAccessTokens.ts b/server/routers/accessToken/listAccessTokens.ts index 472d9da40..a5ec3b540 100644 --- a/server/routers/accessToken/listAccessTokens.ts +++ b/server/routers/accessToken/listAccessTokens.ts @@ -6,12 +6,13 @@ import { userResources, roleResources, resourceAccessToken, - sites + sites, + users } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; -import { sql, eq, or, inArray, and, count, isNull, lt, gt } from "drizzle-orm"; +import { sql, eq, or, inArray, and, count, isNull, gt } from "drizzle-orm"; import logger from "@server/logger"; import stoi from "@server/lib/stoi"; import { fromZodError } from "zod-validation-error"; @@ -55,11 +56,16 @@ function queryAccessTokens( accessTokenId: resourceAccessToken.accessTokenId, orgId: resourceAccessToken.orgId, resourceId: resourceAccessToken.resourceId, + userId: resourceAccessToken.userId, + userName: users.name, + username: users.username, + userEmail: users.email, sessionLength: resourceAccessToken.sessionLength, expiresAt: resourceAccessToken.expiresAt, tokenHash: resourceAccessToken.tokenHash, title: resourceAccessToken.title, description: resourceAccessToken.description, + persistSession: resourceAccessToken.persistSession, createdAt: resourceAccessToken.createdAt, resourceName: resources.name, resourceNiceId: resources.niceId, @@ -75,6 +81,7 @@ function queryAccessTokens( eq(resourceAccessToken.resourceId, resources.resourceId) ) .leftJoin(sites, eq(resources.resourceId, sites.siteId)) + .leftJoin(users, eq(resourceAccessToken.userId, users.userId)) .where( and( inArray( @@ -97,6 +104,7 @@ function queryAccessTokens( eq(resourceAccessToken.resourceId, resources.resourceId) ) .leftJoin(sites, eq(resources.resourceId, sites.siteId)) + .leftJoin(users, eq(resourceAccessToken.userId, users.userId)) .where( and( inArray( diff --git a/server/routers/auth/logout.ts b/server/routers/auth/logout.ts index b9a1431aa..b36d0c73a 100644 --- a/server/routers/auth/logout.ts +++ b/server/routers/auth/logout.ts @@ -16,18 +16,26 @@ export async function logout( next: NextFunction ): Promise { const { user, session } = await verifySession(req); + const isSecure = req.protocol === "https"; + + // Always clear the session cookie so logout is idempotent, even when + // the session is already missing or invalid + res.setHeader("Set-Cookie", createBlankSessionTokenCookie(isSecure)); + if (!user || !session) { if (config.getRawConfig().app.log_failed_attempts) { logger.info( - `Log out failed because missing or invalid session. IP: ${req.ip}.` + `Log out with missing or invalid session. IP: ${req.ip}.` ); } - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "You must be logged in to sign out" - ) - ); + + return response(res, { + data: null, + success: true, + error: false, + message: "Logged out successfully", + status: HttpCode.OK + }); } try { @@ -37,9 +45,6 @@ export async function logout( logger.error("Failed to invalidate session", error); } - const isSecure = req.protocol === "https"; - res.setHeader("Set-Cookie", createBlankSessionTokenCookie(isSecure)); - return response(res, { data: null, success: true, diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index cf36fef72..901c881f2 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -1,4 +1,9 @@ -import { validateResourceSessionToken } from "@server/auth/sessions/resource"; +import { + createResourceSession, + serializeResourceSessionCookie, + validateResourceSessionToken +} from "@server/auth/sessions/resource"; +import { generateSessionToken } from "@server/auth/sessions/app"; import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken"; import { getResourceByDomain, @@ -13,6 +18,7 @@ import { LoginPage, Org, Resource, + ResourceAccessToken, ResourceHeaderAuth, ResourceHeaderAuthExtendedCompatibility, ResourcePassword, @@ -21,7 +27,10 @@ import { ResourcePolicyPassword, ResourcePolicyHeaderAuth, ResourceRule, - ResourceSession + ResourceSession, + db, + resourceAccessToken, + users } from "@server/db"; import config from "@server/lib/config"; import { isIpInCidr, stripPortFromHost } from "@server/lib/ip"; @@ -41,11 +50,13 @@ import { enforceResourceSessionLength } from "#dynamic/lib/checkOrgAccessPolicy"; import { logRequestAudit } from "./logRequestAudit"; +import { logAccessAudit } from "#dynamic/lib/logAccessAudit"; import { REGIONS } from "@server/db/regions"; import { localCache } from "#dynamic/lib/cache"; import { APP_VERSION } from "@server/lib/consts"; import { isSubscribed } from "#dynamic/lib/isSubscribed"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { eq } from "drizzle-orm"; const verifyResourceSessionSchema = z.object({ sessions: z.record(z.string(), z.string()).optional(), @@ -350,22 +361,15 @@ export async function verifyResourceSession( } if (valid && tokenItem) { - logRequestAudit( - { - action: true, - reason: 102, // valid access token - resourceId: resource.resourceId, - orgId: resource.orgId, - location: ipCC, - apiKey: { - name: tokenItem.title, - apiKeyId: tokenItem.accessTokenId - } - }, - parsedBody.data + return await allowAccessToken( + res, + resource, + tokenItem, + sessions, + dontStripSession, + parsedBody.data, + ipCC ); - - return allowed(res, undefined, dontStripSession); } } @@ -401,22 +405,15 @@ export async function verifyResourceSession( } if (valid && tokenItem) { - logRequestAudit( - { - action: true, - reason: 102, // valid access token - resourceId: resource.resourceId, - orgId: resource.orgId, - location: ipCC, - apiKey: { - name: tokenItem.title, - apiKeyId: tokenItem.accessTokenId - } - }, - parsedBody.data + return await allowAccessToken( + res, + resource, + tokenItem, + sessions, + dontStripSession, + parsedBody.data, + ipCC ); - - return allowed(res, undefined, dontStripSession); } } @@ -666,22 +663,37 @@ export async function verifyResourceSession( "Resource allowed because access token session is valid" ); - logRequestAudit( + const [tokenItem] = await db + .select() + .from(resourceAccessToken) + .where( + eq( + resourceAccessToken.accessTokenId, + resourceSession.accessTokenId + ) + ) + .limit(1); + + const userData = tokenItem + ? await getAccessTokenUserData( + tokenItem, + resource.orgId + ) + : undefined; + + logAccessTokenRequestAudit( { - action: true, - reason: 102, // valid access token resourceId: resource.resourceId, orgId: resource.orgId, location: ipCC, - apiKey: { - name: null, - apiKeyId: resourceSession.accessTokenId - } + accessTokenId: resourceSession.accessTokenId, + tokenTitle: tokenItem?.title ?? null, + userData }, parsedBody.data ); - return allowed(res, undefined, dontStripSession); + return allowed(res, userData, dontStripSession); } if (resourceSession.userSessionId && sso) { @@ -892,6 +904,227 @@ function allowed( return response(res, data); } +async function allowAccessToken( + res: Response, + resource: Resource, + tokenItem: ResourceAccessToken, + sessions: Record | undefined, + dontStripSession: boolean | undefined, + auditBody: VerifyResourceSessionSchema, + location?: string +) { + const userData = await getAccessTokenUserData(tokenItem, resource.orgId); + + logAccessTokenRequestAudit( + { + resourceId: resource.resourceId, + orgId: resource.orgId, + location, + accessTokenId: tokenItem.accessTokenId, + tokenTitle: tokenItem.title, + userData + }, + auditBody + ); + + if (!tokenItem.persistSession) { + logAccessTokenAccessAudit(tokenItem, resource, userData, auditBody); + return allowed(res, userData, dontStripSession); + } + + const resourceSessionToken = extractResourceSessionToken( + sessions ?? {}, + resource.ssl + ); + + if (resourceSessionToken) { + const sessionCacheKey = `session:${resourceSessionToken}`; + let resourceSession: ResourceSession | null | undefined = + localCache.get(sessionCacheKey); + + if (!resourceSession) { + const result = await validateResourceSessionToken( + resourceSessionToken, + resource.resourceId + ); + resourceSession = result?.resourceSession; + localCache.set(sessionCacheKey, resourceSession, 5); + } + + if ( + resourceSession && + !resourceSession.isRequestToken && + resourceSession.accessTokenId === tokenItem.accessTokenId + ) { + logger.debug( + "Resource allowed because existing access token session is valid" + ); + return allowed(res, userData, dontStripSession); + } + } + + logAccessTokenAccessAudit(tokenItem, resource, userData, auditBody); + return await createAccessTokenSession(res, resource, tokenItem, userData); +} + +async function createAccessTokenSession( + res: Response, + resource: Resource, + tokenItem: ResourceAccessToken, + userData?: BasicUserData +) { + const token = generateSessionToken(); + const sess = await createResourceSession({ + resourceId: resource.resourceId, + token, + accessTokenId: tokenItem.accessTokenId, + sessionLength: tokenItem.sessionLength, + expiresAt: tokenItem.expiresAt, + doNotExtend: tokenItem.expiresAt ? true : false + }); + const cookieName = config.getRawConfig().server.session_cookie_name; + const cookie = serializeResourceSessionCookie( + cookieName, + resource.fullDomain!, + token, + !resource.ssl, + new Date(sess.expiresAt) + ); + res.appendHeader("Set-Cookie", cookie); + logger.debug("Access token is valid, creating new session"); + return allowed(res, userData); +} + +async function getAccessTokenUserData( + tokenItem: ResourceAccessToken, + orgId: string +): Promise { + if (!tokenItem.userId) { + return undefined; + } + + const cacheKey = `accessTokenUser:${tokenItem.userId}:${orgId}`; + const cached = localCache.get(cacheKey) as BasicUserData | null | undefined; + if (cached !== undefined) { + return cached ?? undefined; + } + + const [user] = await db + .select() + .from(users) + .where(eq(users.userId, tokenItem.userId)) + .limit(1); + + if (!user) { + localCache.set(cacheKey, null, 5); + return undefined; + } + + const userOrgRoles = await getUserOrgRoles(user.userId, orgId); + const userData: BasicUserData = { + userId: user.userId, + username: user.username, + email: user.email, + name: user.name, + role: userOrgRoles.map((r) => r.roleName).join(", ") || null + }; + + localCache.set(cacheKey, userData, 12); + return userData; +} + +function logAccessTokenRequestAudit( + data: { + resourceId: number; + orgId: string; + location?: string; + accessTokenId: string; + tokenTitle: string | null; + userData?: BasicUserData; + }, + body: VerifyResourceSessionSchema +) { + if (data.userData) { + logRequestAudit( + { + action: true, + reason: 102, // valid access token + resourceId: data.resourceId, + orgId: data.orgId, + location: data.location, + user: { + username: data.userData.username, + userId: data.userData.userId + }, + metadata: { + accessTokenId: data.accessTokenId, + accessTokenTitle: data.tokenTitle + } + }, + body + ); + return; + } + + logRequestAudit( + { + action: true, + reason: 102, // valid access token + resourceId: data.resourceId, + orgId: data.orgId, + location: data.location, + apiKey: { + name: data.tokenTitle, + apiKeyId: data.accessTokenId + } + }, + body + ); +} + +function logAccessTokenAccessAudit( + tokenItem: ResourceAccessToken, + resource: Resource, + userData: BasicUserData | undefined, + body: VerifyResourceSessionSchema +) { + const userAgent = + body.headers?.["user-agent"] || body.headers?.["User-Agent"]; + + if (userData) { + logAccessAudit({ + orgId: resource.orgId, + resourceId: resource.resourceId, + action: true, + type: "accessToken", + user: { + username: userData.username, + userId: userData.userId + }, + metadata: { + accessTokenId: tokenItem.accessTokenId, + accessTokenTitle: tokenItem.title + }, + userAgent, + requestIp: body.requestIp + }); + return; + } + + logAccessAudit({ + orgId: resource.orgId, + resourceId: resource.resourceId, + action: true, + type: "accessToken", + apiKey: { + name: tokenItem.title, + apiKeyId: tokenItem.accessTokenId + }, + userAgent, + requestIp: body.requestIp + }); +} + async function headerAuthChallenged( res: Response, redirectPath?: string, diff --git a/server/routers/integration.ts b/server/routers/integration.ts index 3abd664eb..567c3060e 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -809,16 +809,6 @@ authenticated.post( accessToken.generateAccessToken ); -authenticated.post( - `/resource/:resourceId/session-token`, - verifyApiKeyResourceAccess, - verifyApiKeyUserAccess, - verifyLimits, - verifyApiKeyHasAction(ActionsEnum.createResourceSessionToken), - logActionAudit(ActionsEnum.createResourceSessionToken), - resource.createResourceSessionToken -); - authenticated.delete( `/access-token/:accessTokenId`, verifyApiKeyAccessTokenAccess, diff --git a/server/routers/newt/handleNewtGetConfigMessage.ts b/server/routers/newt/handleNewtGetConfigMessage.ts index fd5e2b42e..d99dd89ef 100644 --- a/server/routers/newt/handleNewtGetConfigMessage.ts +++ b/server/routers/newt/handleNewtGetConfigMessage.ts @@ -29,7 +29,7 @@ export const handleNewtGetConfigMessage: MessageHandler = async (context) => { return; } - const { publicKey, port, chainId } = message.data; + const { publicKey, port, localEndpoints, chainId } = message.data; const siteId = newt.siteId; // Get the current site data @@ -69,7 +69,10 @@ export const handleNewtGetConfigMessage: MessageHandler = async (context) => { .update(sites) .set({ publicKey, - listenPort: port + listenPort: port, + localEndpoints: localEndpoints + ? JSON.stringify(localEndpoints) + : null }) .where(eq(sites.siteId, siteId)) .returning(); diff --git a/server/routers/olm/buildConfiguration.ts b/server/routers/olm/buildConfiguration.ts index 0266fa041..37355eae5 100644 --- a/server/routers/olm/buildConfiguration.ts +++ b/server/routers/olm/buildConfiguration.ts @@ -30,6 +30,7 @@ export async function buildSiteConfigurationForOlmClient( siteId: number; name?: string; endpoint?: string; + localEndpoints?: string[]; publicKey?: string; serverIP?: string | null; serverPort?: number | null; @@ -206,6 +207,9 @@ export async function buildSiteConfigurationForOlmClient( name: site.name, // relayEndpoint: relayEndpoint, // this can be undefined now if not relayed // lets not do this for now because it would conflict with the hole punch testing endpoint: site.endpoint, + localEndpoints: site.localEndpoints + ? JSON.parse(site.localEndpoints) + : undefined, publicKey: site.publicKey, serverIP: site.address, serverPort: site.listenPort, diff --git a/server/routers/olm/handleOlmLocalMessage.ts b/server/routers/olm/handleOlmLocalMessage.ts new file mode 100644 index 000000000..83ef8cbf1 --- /dev/null +++ b/server/routers/olm/handleOlmLocalMessage.ts @@ -0,0 +1,74 @@ +import { db, sites } from "@server/db"; +import { MessageHandler } from "@server/routers/ws"; +import { clients, Olm } from "@server/db"; +import { and, eq } from "drizzle-orm"; +import { updatePeer as newtUpdatePeer } from "../newt/peers"; +import logger from "@server/logger"; + +export const handleOlmLocalMessage: MessageHandler = async (context) => { + const { message, client: c, sendToClient } = context; + const olm = c as Olm; + + logger.info("Handling local olm message!"); + + if (!olm) { + logger.warn("Olm not found"); + return; + } + + if (!olm.clientId) { + logger.warn("Olm has no client!"); + return; + } + + const clientId = olm.clientId; + + const [client] = await db + .select() + .from(clients) + .where(eq(clients.clientId, clientId)) + .limit(1); + + if (!client) { + logger.warn("Client not found"); + return; + } + + // make sure we hand endpoints for both the site and the client and the lastHolePunch is not too old + if (!client.pubKey) { + logger.warn("Client has no endpoint or listen port"); + return; + } + + const { siteId, chainId } = message.data; + + // Get the site + const [site] = await db + .select() + .from(sites) + .where(eq(sites.siteId, siteId)) + .limit(1); + + if (!site || !site.exitNodeId) { + logger.warn("Site not found or has no exit node"); + return; + } + + // update the peer on the newt + await newtUpdatePeer(siteId, client.pubKey, { + endpoint: "" // this removes the endpoint so the newt knows to accept local + }); + + // Just ack the message, we don't keep sending it + return { + message: { + type: "olm/wg/peer/local", + data: { + siteId: siteId, + chainId + } + }, + broadcast: false, + excludeSender: false + }; +}; diff --git a/server/routers/olm/handleOlmRelayMessage.ts b/server/routers/olm/handleOlmRelayMessage.ts index 7196824d2..406ed7bbb 100644 --- a/server/routers/olm/handleOlmRelayMessage.ts +++ b/server/routers/olm/handleOlmRelayMessage.ts @@ -79,9 +79,9 @@ export const handleOlmRelayMessage: MessageHandler = async (context) => { ) ); - // update the peer on the exit node + // update the peer on the newt await newtUpdatePeer(siteId, client.pubKey, { - endpoint: "" // this removes the endpoint so the exit node knows to relay + endpoint: "" // this removes the endpoint so the newt knows to relay }); return { diff --git a/server/routers/olm/handleOlmServerPeerAddMessage.ts b/server/routers/olm/handleOlmServerPeerAddMessage.ts index 5f46ea84c..332afe082 100644 --- a/server/routers/olm/handleOlmServerPeerAddMessage.ts +++ b/server/routers/olm/handleOlmServerPeerAddMessage.ts @@ -3,24 +3,15 @@ import { db, networks, siteNetworks, - siteResources, + siteResources } from "@server/db"; import { MessageHandler } from "@server/routers/ws"; -import { - clients, - clientSitesAssociationsCache, - Olm, - sites -} from "@server/db"; +import { clients, clientSitesAssociationsCache, Olm, sites } from "@server/db"; import { and, eq, inArray, isNotNull, isNull } from "drizzle-orm"; import logger from "@server/logger"; -import { - generateAliasConfig, -} from "@server/lib/ip"; +import { generateAliasConfig } from "@server/lib/ip"; import { generateRemoteSubnets } from "@server/lib/ip"; -import { - addPeer as newtAddPeer, -} from "@server/routers/newt/peers"; +import { addPeer as newtAddPeer } from "@server/routers/newt/peers"; export const handleOlmServerPeerAddMessage: MessageHandler = async ( context @@ -135,10 +126,7 @@ export const handleOlmServerPeerAddMessage: MessageHandler = async ( clientSiteResourcesAssociationsCache.siteResourceId ) ) - .innerJoin( - networks, - eq(siteResources.networkId, networks.networkId) - ) + .innerJoin(networks, eq(siteResources.networkId, networks.networkId)) .innerJoin( siteNetworks, and( @@ -147,10 +135,7 @@ export const handleOlmServerPeerAddMessage: MessageHandler = async ( ) ) .where( - eq( - clientSiteResourcesAssociationsCache.clientId, - client.clientId - ) + eq(clientSiteResourcesAssociationsCache.clientId, client.clientId) ); // Return connect message with all site configurations @@ -161,6 +146,9 @@ export const handleOlmServerPeerAddMessage: MessageHandler = async ( siteId: site.siteId, name: site.name, endpoint: site.endpoint, + localEndpoints: site.localEndpoints + ? JSON.parse(site.localEndpoints) + : undefined, publicKey: site.publicKey, serverIP: site.address, serverPort: site.listenPort, @@ -170,7 +158,7 @@ export const handleOlmServerPeerAddMessage: MessageHandler = async ( aliases: generateAliasConfig( allSiteResources.map(({ siteResources }) => siteResources) ), - chainId: chainId, + chainId: chainId } }, broadcast: false, diff --git a/server/routers/olm/handleOlmUnLocalMessage.ts b/server/routers/olm/handleOlmUnLocalMessage.ts new file mode 100644 index 000000000..55d312815 --- /dev/null +++ b/server/routers/olm/handleOlmUnLocalMessage.ts @@ -0,0 +1,95 @@ +import { db, exitNodes, sites } from "@server/db"; +import { MessageHandler } from "@server/routers/ws"; +import { clients, clientSitesAssociationsCache, Olm } from "@server/db"; +import { and, eq } from "drizzle-orm"; +import { updatePeer as newtUpdatePeer } from "../newt/peers"; +import logger from "@server/logger"; + +export const handleOlmUnLocalMessage: MessageHandler = async (context) => { + const { message, client: c, sendToClient } = context; + const olm = c as Olm; + + logger.info("Handling unlocal olm message!"); + + if (!olm) { + logger.warn("Olm not found"); + return; + } + + if (!olm.clientId) { + logger.warn("Olm has no client!"); + return; + } + + const clientId = olm.clientId; + + const [client] = await db + .select() + .from(clients) + .where(eq(clients.clientId, clientId)) + .limit(1); + + if (!client) { + logger.warn("Client not found"); + return; + } + + // make sure we hand endpoints for both the site and the client and the lastHolePunch is not too old + if (!client.pubKey) { + logger.warn("Client has no endpoint or listen port"); + return; + } + + const { siteId, chainId } = message.data; + + // Get the site + const [site] = await db + .select() + .from(sites) + .where(eq(sites.siteId, siteId)) + .limit(1); + + if (!site) { + logger.warn("Site not found or has no exit node"); + return; + } + + const [clientSiteAssociation] = await db + .select() + .from(clientSitesAssociationsCache) + .where( + and( + eq(clientSitesAssociationsCache.clientId, olm.clientId), + eq(clientSitesAssociationsCache.siteId, siteId) + ) + ); + + if (!clientSiteAssociation) { + logger.warn("Client-Site association not found"); + return; + } + + if (!clientSiteAssociation.endpoint) { + logger.warn("Client-Site association has no endpoint, cannot unrelay"); + return; + } + + // update the peer on the newt + await newtUpdatePeer(siteId, client.pubKey, { + endpoint: clientSiteAssociation.isRelayed + ? "" + : clientSiteAssociation.endpoint // this is the endpoint of the client to connect directly to the newt + }); + + return { + message: { + type: "olm/wg/peer/unlocal", + data: { + siteId: siteId, + chainId + } + }, + broadcast: false, + excludeSender: false + }; +}; diff --git a/server/routers/olm/handleOlmUnRelayMessage.ts b/server/routers/olm/handleOlmUnRelayMessage.ts index a7b426023..3f73a5834 100644 --- a/server/routers/olm/handleOlmUnRelayMessage.ts +++ b/server/routers/olm/handleOlmUnRelayMessage.ts @@ -77,9 +77,9 @@ export const handleOlmUnRelayMessage: MessageHandler = async (context) => { return; } - // update the peer on the exit node + // update the peer on the newt await newtUpdatePeer(siteId, client.pubKey, { - endpoint: clientSiteAssociation.endpoint // this is the endpoint of the client to connect directly to the exit node + endpoint: clientSiteAssociation.endpoint // this is the endpoint of the client to connect directly to the newt }); return { diff --git a/server/routers/olm/index.ts b/server/routers/olm/index.ts index 5c151a8cf..e11d4e48e 100644 --- a/server/routers/olm/index.ts +++ b/server/routers/olm/index.ts @@ -13,3 +13,5 @@ export * from "./recoverOlmWithFingerprint"; export * from "./handleOlmDisconnectingMessage"; export * from "./handleOlmServerInitAddPeerHandshake"; export * from "./offlineChecker"; +export * from "./handleOlmUnLocalMessage"; +export * from "./handleOlmLocalMessage"; diff --git a/server/routers/olm/peers.ts b/server/routers/olm/peers.ts index 962d7367e..960c6ad23 100644 --- a/server/routers/olm/peers.ts +++ b/server/routers/olm/peers.ts @@ -18,6 +18,7 @@ export async function addPeer( serverPort: number | null; remoteSubnets: string[] | null; // optional, comma-separated list of subnets that this site can access aliases: Alias[]; + localEndpoints?: string[]; // optional, list of local endpoints for the peer }, olmId?: string, version?: string | null @@ -44,6 +45,7 @@ export async function addPeer( name: peer.name, publicKey: peer.publicKey, endpoint: peer.endpoint, + localEndpoints: peer.localEndpoints, relayEndpoint: peer.relayEndpoint, serverIP: peer.serverIP, serverPort: peer.serverPort, diff --git a/server/routers/resource/authWithAccessToken.ts b/server/routers/resource/authWithAccessToken.ts index 195dbafe2..2e625b6a0 100644 --- a/server/routers/resource/authWithAccessToken.ts +++ b/server/routers/resource/authWithAccessToken.ts @@ -1,6 +1,6 @@ import { generateSessionToken } from "@server/auth/sessions/app"; import { db } from "@server/db"; -import { Resource, resources } from "@server/db"; +import { Resource, resources, users } from "@server/db"; import HttpCode from "@server/types/HttpCode"; import response from "@server/lib/response"; import { eq } from "drizzle-orm"; @@ -156,11 +156,42 @@ export async function authWithAccessToken( doNotExtend: true }); + let accessAuditUser: { username: string; userId: string } | undefined; + if (tokenItem.userId) { + const [associatedUser] = await db + .select({ + userId: users.userId, + username: users.username + }) + .from(users) + .where(eq(users.userId, tokenItem.userId)) + .limit(1); + if (associatedUser) { + accessAuditUser = { + userId: associatedUser.userId, + username: associatedUser.username + }; + } + } + logAccessAudit({ orgId: resource.orgId, resourceId: resource.resourceId, action: true, type: "accessToken", + apiKey: accessAuditUser + ? undefined + : { + name: tokenItem.title, + apiKeyId: tokenItem.accessTokenId + }, + user: accessAuditUser, + metadata: accessAuditUser + ? { + accessTokenId: tokenItem.accessTokenId, + accessTokenTitle: tokenItem.title + } + : undefined, userAgent: req.headers["user-agent"], requestIp: req.ip }); diff --git a/server/routers/resource/createResourceSessionToken.ts b/server/routers/resource/createResourceSessionToken.ts deleted file mode 100644 index 82f0d0cbf..000000000 --- a/server/routers/resource/createResourceSessionToken.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { Request, Response, NextFunction } from "express"; -import { z } from "zod"; -import { db } from "@server/db"; -import { resources, users, userOrgs } from "@server/db"; -import { eq, and } from "drizzle-orm"; -import { createResourceSession } from "@server/auth/sessions/resource"; -import HttpCode from "@server/types/HttpCode"; -import createHttpError from "http-errors"; -import { fromError } from "zod-validation-error"; -import logger from "@server/logger"; -import { createSession, generateSessionToken } from "@server/auth/sessions/app"; -import { response } from "@server/lib/response"; - -const createResourceSessionTokenParams = z.strictObject({ - resourceId: z.coerce.number().int().positive() -}); - -const createResourceSessionTokenBody = z.strictObject({ - userId: z.string().nonempty(), - idpId: z.coerce.number().int().positive().optional() -}); - -export type CreateResourceSessionTokenResponse = { - requestToken: string; -}; - -export async function createResourceSessionToken( - req: Request, - res: Response, - next: NextFunction -): Promise { - try { - const parsedParams = createResourceSessionTokenParams.safeParse( - req.params - ); - if (!parsedParams.success) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - fromError(parsedParams.error).toString() - ) - ); - } - - const parsedBody = createResourceSessionTokenBody.safeParse(req.body); - if (!parsedBody.success) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - fromError(parsedBody.error).toString() - ) - ); - } - - const { resourceId } = parsedParams.data; - const { userId, idpId } = parsedBody.data; - - const [resource] = await db - .select() - .from(resources) - .where(eq(resources.resourceId, resourceId)) - .limit(1); - - if (!resource) { - return next( - createHttpError( - HttpCode.NOT_FOUND, - `Resource with ID ${resourceId} not found` - ) - ); - } - - const candidates = await db - .select({ userId: users.userId }) - .from(userOrgs) - .innerJoin(users, eq(userOrgs.userId, users.userId)) - .where( - and( - eq(users.userId, userId), - eq(userOrgs.orgId, resource.orgId) - ) - ); - - if (candidates.length === 0) { - return next( - createHttpError( - HttpCode.NOT_FOUND, - `User not found in the organization that owns this resource` - ) - ); - } - - if (candidates.length > 1) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Multiple users match this username (external users from different identity providers). Specify idpId to disambiguate." - ) - ); - } - - const targetUserId = candidates[0].userId; - - const appSessionToken = generateSessionToken(); - const appSession = await createSession(appSessionToken, targetUserId); - - const requestToken = generateSessionToken(); - await createResourceSession({ - resourceId, - token: requestToken, - userSessionId: appSession.sessionId, - isRequestToken: true, - expiresAt: Date.now() + 1000 * 30, // 30 seconds - sessionLength: 1000 * 30, - doNotExtend: true - }); - - logger.debug("Resource session token created successfully"); - - return response(res, { - data: { requestToken }, - success: true, - error: false, - message: "Resource session token created successfully", - status: HttpCode.OK - }); - } catch (error) { - logger.error(error); - return next( - createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") - ); - } -} diff --git a/server/routers/resource/index.ts b/server/routers/resource/index.ts index 4bc9d20f0..83dbdea2a 100644 --- a/server/routers/resource/index.ts +++ b/server/routers/resource/index.ts @@ -17,7 +17,6 @@ export * from "./getResourceWhitelist"; export * from "./authWithWhitelist"; export * from "./authWithAccessToken"; export * from "./getExchangeToken"; -export * from "./createResourceSessionToken"; export * from "./createResourceRule"; export * from "./deleteResourceRule"; export * from "./listResourceRules"; diff --git a/server/routers/ws/messageHandlers.ts b/server/routers/ws/messageHandlers.ts index f89284389..496002142 100644 --- a/server/routers/ws/messageHandlers.ts +++ b/server/routers/ws/messageHandlers.ts @@ -20,7 +20,9 @@ import { handleOlmServerPeerAddMessage, handleOlmUnRelayMessage, handleOlmDisconnectingMessage, - handleOlmServerInitAddPeerHandshake + handleOlmServerInitAddPeerHandshake, + handleOlmLocalMessage, + handleOlmUnLocalMessage } from "../olm"; import { handleHealthcheckStatusMessage } from "../target"; import { handleRoundTripMessage } from "./handleRoundTripMessage"; @@ -32,6 +34,8 @@ export const messageHandlers: Record = { "olm/wg/register": handleOlmRegisterMessage, "olm/wg/relay": handleOlmRelayMessage, "olm/wg/unrelay": handleOlmUnRelayMessage, + "olm/wg/local": handleOlmLocalMessage, + "olm/wg/unlocal": handleOlmUnLocalMessage, "olm/ping": handleOlmPingMessage, "olm/disconnecting": handleOlmDisconnectingMessage, "newt/disconnecting": handleNewtDisconnectingMessage, diff --git a/server/setup/migrationsPg.ts b/server/setup/migrationsPg.ts index a271ef96d..4a6d4861c 100644 --- a/server/setup/migrationsPg.ts +++ b/server/setup/migrationsPg.ts @@ -27,6 +27,7 @@ import m18 from "./scriptsPg/1.18.3"; import m19 from "./scriptsPg/1.18.4"; import m20 from "./scriptsPg/1.19.0"; import m21 from "./scriptsPg/1.20.0"; +import m22 from "./scriptsPg/1.21.0"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -53,7 +54,8 @@ const migrations = [ { version: "1.18.3", run: m18 }, { version: "1.18.4", run: m19 }, { version: "1.19.0", run: m20 }, - { version: "1.20.0", run: m21 } + { version: "1.20.0", run: m21 }, + { version: "1.21.0", run: m22 } // Add new migrations here as they are created ] as { version: string; diff --git a/server/setup/migrationsSqlite.ts b/server/setup/migrationsSqlite.ts index a8f6f9e33..c6b4debef 100644 --- a/server/setup/migrationsSqlite.ts +++ b/server/setup/migrationsSqlite.ts @@ -46,6 +46,7 @@ import m40 from "./scriptsSqlite/1.18.4"; import m41 from "./scriptsSqlite/1.19.0"; import m42 from "./scriptsSqlite/1.19.1"; import m43 from "./scriptsSqlite/1.20.0"; +import m44 from "./scriptsSqlite/1.21.0"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -89,7 +90,8 @@ const migrations = [ { version: "1.18.4", run: m40 }, { version: "1.19.0", run: m41 }, { version: "1.19.1", run: m42 }, - { version: "1.20.0", run: m43 } + { version: "1.20.0", run: m43 }, + { version: "1.21.0", run: m44 } // Add new migrations here as they are created ] as const; diff --git a/server/setup/scriptsPg/1.21.0.ts b/server/setup/scriptsPg/1.21.0.ts new file mode 100644 index 000000000..d5195c382 --- /dev/null +++ b/server/setup/scriptsPg/1.21.0.ts @@ -0,0 +1,95 @@ +import { db } from "@server/db/pg/driver"; +import { APP_PATH } from "@server/lib/consts"; +import { sql } from "drizzle-orm"; +import fs from "fs"; +import yaml from "js-yaml"; +import path from "path"; +import z from "zod"; +import { fromZodError } from "zod-validation-error"; + +const version = "1.21.0"; + +export default async function migration() { + console.log(`Running setup script ${version}...`); + + try { + await db.execute(sql`BEGIN`); + + await db.execute(sql` + ALTER TABLE "resourceAccessToken" ADD COLUMN "userId" varchar; + `); + + await db.execute(sql` + ALTER TABLE "resourceAccessToken" ADD COLUMN "persistSession" boolean DEFAULT false NOT NULL; + `); + + await db.execute(sql` + ALTER TABLE "resources" ADD COLUMN "status" varchar DEFAULT 'approved'; + `); + + await db.execute(sql` + ALTER TABLE "siteResources" ADD COLUMN "status" varchar DEFAULT 'approved'; + `); + + await db.execute(sql` + ALTER TABLE "sites" ADD COLUMN "localEndpoints" varchar; + `); + + await db.execute(sql` + ALTER TABLE "resourceAccessToken" ADD CONSTRAINT "resourceAccessToken_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; + `); + + await db.execute(sql`COMMIT`); + console.log("Migrated database"); + } catch (e) { + await db.execute(sql`ROLLBACK`); + console.log("Unable to migrate database"); + console.log(e); + throw e; + } + + try { + const traefikPath = path.join( + APP_PATH, + "traefik", + "traefik_config.yml" + ); + + const schema = z.object({ + experimental: z.object({ + plugins: z.object({ + badger: z.object({ + moduleName: z.string(), + version: z.string() + }) + }) + }) + }); + + const traefikFileContents = fs.readFileSync(traefikPath, "utf8"); + const traefikConfig = yaml.load(traefikFileContents) as any; + + const parsedConfig = schema.safeParse(traefikConfig); + + if (!parsedConfig.success) { + throw new Error(fromZodError(parsedConfig.error).toString()); + } + + traefikConfig.experimental.plugins.badger.version = "v1.5.0"; + + const updatedTraefikYaml = yaml.dump(traefikConfig); + + fs.writeFileSync(traefikPath, updatedTraefikYaml, "utf8"); + + console.log( + "Updated the version of Badger in your Traefik configuration to v1.5.0" + ); + } catch (e) { + console.log( + "We were unable to update the version of Badger in your Traefik configuration. Please update it manually. Check the release notes for this version for more information." + ); + console.error(e); + } + + console.log(`${version} migration complete`); +} diff --git a/server/setup/scriptsSqlite/1.21.0.ts b/server/setup/scriptsSqlite/1.21.0.ts new file mode 100644 index 000000000..b710c783b --- /dev/null +++ b/server/setup/scriptsSqlite/1.21.0.ts @@ -0,0 +1,104 @@ +import { APP_PATH } from "@server/lib/consts"; +import Database from "better-sqlite3"; +import fs from "fs"; +import yaml from "js-yaml"; +import path from "path"; +import z from "zod"; +import { fromZodError } from "zod-validation-error"; + +const version = "1.21.0"; + +export default async function migration() { + console.log(`Running setup script ${version}...`); + + const location = path.join(APP_PATH, "db", "db.sqlite"); + const db = new Database(location); + + try { + db.pragma("foreign_keys = OFF"); + + db.transaction(() => { + db.prepare( + ` + ALTER TABLE 'resourceAccessToken' ADD 'userId' text REFERENCES user(id); + ` + ).run(); + + db.prepare( + ` + ALTER TABLE 'resourceAccessToken' ADD 'persistSession' integer DEFAULT false NOT NULL; + ` + ).run(); + + db.prepare( + ` + ALTER TABLE 'resources' ADD 'status' text DEFAULT 'approved'; + ` + ).run(); + + db.prepare( + ` + ALTER TABLE 'siteResources' ADD 'status' text DEFAULT 'approved'; + ` + ).run(); + + db.prepare( + ` + ALTER TABLE 'sites' ADD 'localEndpoints' text; + ` + ).run(); + })(); + + db.pragma("foreign_keys = ON"); + + console.log("Migrated database"); + } catch (e) { + console.log("Failed to migrate db:", e); + throw e; + } + + try { + const traefikPath = path.join( + APP_PATH, + "traefik", + "traefik_config.yml" + ); + + const schema = z.object({ + experimental: z.object({ + plugins: z.object({ + badger: z.object({ + moduleName: z.string(), + version: z.string() + }) + }) + }) + }); + + const traefikFileContents = fs.readFileSync(traefikPath, "utf8"); + const traefikConfig = yaml.load(traefikFileContents) as any; + + const parsedConfig = schema.safeParse(traefikConfig); + + if (!parsedConfig.success) { + throw new Error(fromZodError(parsedConfig.error).toString()); + } + + traefikConfig.experimental.plugins.badger.version = "v1.5.0"; + + const updatedTraefikYaml = yaml.dump(traefikConfig); + + fs.writeFileSync(traefikPath, updatedTraefikYaml, "utf8"); + + console.log( + "Updated the version of Badger in your Traefik configuration to v1.5.0" + ); + } catch (e) { + console.log( + "We were unable to update the version of Badger in your Traefik configuration. Please update it manually. Check the release notes for this version for more information." + ); + console.error(e); + } + + console.log(`${version} migration complete`); +} diff --git a/src/actions/server.ts b/src/actions/server.ts index 2759e6213..cd75c2c57 100644 --- a/src/actions/server.ts +++ b/src/actions/server.ts @@ -248,6 +248,39 @@ export async function loginProxy( return await makeApiRequest(url, "POST", request); } +export async function logoutProxy(): Promise> { + const env = pullEnv(); + const serverPort = process.env.SERVER_EXTERNAL_PORT; + const url = `http://localhost:${serverPort}/api/v1/auth/logout`; + + const result = await makeApiRequest(url, "POST"); + + try { + const headersList = await reqHeaders(); + const host = headersList.get("host")?.split(":")[0]; + const allCookies = await cookies(); + const clearOptions = { + httpOnly: true, + secure: true, + sameSite: "lax" as const, + path: "/", + maxAge: 0 + }; + // Clear both host-only and domain-scoped variants. + allCookies.set(env.server.sessionCookieName, "", clearOptions); + if (host) { + allCookies.set(env.server.sessionCookieName, "", { + ...clearOptions, + domain: host + }); + } + } catch (cookieError) { + console.error("Failed to clear session cookie:", cookieError); + } + + return result; +} + export async function securityKeyStartProxy( request: SecurityKeyStartRequest, forceLogin?: boolean diff --git a/src/components/CreateShareLinkForm.tsx b/src/components/CreateShareLinkForm.tsx index 95884b486..e6bdc60b0 100644 --- a/src/components/CreateShareLinkForm.tsx +++ b/src/components/CreateShareLinkForm.tsx @@ -52,7 +52,6 @@ import { ChevronsUpDown } from "lucide-react"; import { Checkbox } from "@app/components/ui/checkbox"; import { GenerateAccessTokenResponse } from "@server/routers/accessToken"; import { constructShareLink } from "@app/lib/shareLinks"; -import { ShareLinkRow } from "@app/components/ShareLinksTable"; import { QRCodeCanvas, QRCodeSVG } from "qrcode.react"; import { Collapsible, @@ -63,11 +62,26 @@ import AccessTokenSection from "@app/components/AccessTokenUsage"; import { useTranslations } from "next-intl"; import { toUnicode } from "punycode"; import { ResourceSelector, type SelectedResource } from "./resource-selector"; +import { UserSelector, type SelectedUser } from "@app/components/user-selector"; + +type CreatedShareLink = { + accessTokenId: string; + resourceId: number; + resourceName: string; + resourceNiceId: string; + title: string | null; + createdAt: number; + expiresAt: number | null; + userId?: string | null; + userName?: string | null; + username?: string | null; + userEmail?: string | null; +}; type FormProps = { open: boolean; setOpen: (open: boolean) => void; - onCreated?: (result: ShareLinkRow) => void; + onCreated?: (result: CreatedShareLink) => void; }; export default function CreateShareLinkForm({ @@ -85,6 +99,8 @@ export default function CreateShareLinkForm({ const [accessToken, setAccessToken] = useState(null); const [loading, setLoading] = useState(false); const [neverExpire, setNeverExpire] = useState(false); + const [persistSession, setPersistSession] = useState(false); + const [selectedUser, setSelectedUser] = useState(null); const [isOpen, setIsOpen] = useState(false); const t = useTranslations(); @@ -175,7 +191,9 @@ export default function CreateShareLinkForm({ values.resourceName || "Resource" + values.resourceId }), - path: values.path + path: values.path, + persistSession, + userId: selectedUser?.id } ) .catch((e) => { @@ -205,7 +223,11 @@ export default function CreateShareLinkForm({ resourceNiceId: selectedResource ? selectedResource.niceId : "", title: token.title, createdAt: token.createdAt, - expiresAt: token.expiresAt + expiresAt: token.expiresAt, + userId: token.userId, + userName: selectedUser?.text ?? null, + username: null, + userEmail: null }); } @@ -220,6 +242,9 @@ export default function CreateShareLinkForm({ setOpen(val); setLink(null); setLoading(false); + setNeverExpire(false); + setPersistSession(false); + setSelectedUser(null); form.reset(); }} > @@ -344,6 +369,48 @@ export default function CreateShareLinkForm({ )} /> +
+ + {t( + "shareAssociateUserOptional" + )} + + + + + + + + + +

+ {t( + "shareAssociateUserDescription" + )} +

+
+
@@ -437,6 +504,34 @@ export default function CreateShareLinkForm({
+
+ + setPersistSession( + val as boolean + ) + } + className="mt-0.5" + /> +
+ +

+ {t( + "sharePersistSessionDescription" + )} +

+
+
+

{t("shareExpireDescription")}

diff --git a/src/components/OrgPolicyRequired.tsx b/src/components/OrgPolicyRequired.tsx index 3765cd1bf..6d28ddbf5 100644 --- a/src/components/OrgPolicyRequired.tsx +++ b/src/components/OrgPolicyRequired.tsx @@ -12,8 +12,8 @@ import { Shield, ArrowRight } from "lucide-react"; import Link from "next/link"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; -import { createApiClient } from "@app/lib/api"; -import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useState } from "react"; +import { logoutProxy } from "@app/actions/server"; type OrgPolicyRequiredProps = { orgId: string; @@ -40,21 +40,23 @@ export default function OrgPolicyRequired({ }: OrgPolicyRequiredProps) { const t = useTranslations(); const router = useRouter(); - - const api = createApiClient(useEnvContext()); + const [loading, setLoading] = useState(false); const sessionExpired = policies?.maxSessionLength && policies.maxSessionLength.compliant === false; - function reauthenticate() { - api.post("/auth/logout") - .catch(() => {}) - .then(() => { - const destination = redirectAfterAuth ?? `/${orgId}`; - router.push(destination); - router.refresh(); - }); + async function reauthenticate() { + setLoading(true); + try { + await logoutProxy(); + } catch (error) { + console.error("Error during logout:", error); + } finally { + const destination = redirectAfterAuth ?? `/${orgId}`; + router.push(destination); + router.refresh(); + } } if (sessionExpired) { @@ -76,6 +78,7 @@ export default function OrgPolicyRequired({ + ); + }, + cell: ({ row }) => { + const r = row.original; + if (!r.userId) { + return -; + } + return ( + + + + ); + } + }, // { // accessorKey: "domain", // header: "Link", diff --git a/src/components/user-selector.tsx b/src/components/user-selector.tsx new file mode 100644 index 000000000..f8ad85eea --- /dev/null +++ b/src/components/user-selector.tsx @@ -0,0 +1,106 @@ +import { orgQueries } from "@app/lib/queries"; +import { getUserDisplayName } from "@app/lib/getUserDisplayName"; +import { useQuery } from "@tanstack/react-query"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from "./ui/command"; +import { useMemo, useState } from "react"; +import { useTranslations } from "next-intl"; +import { CheckIcon } from "lucide-react"; +import { cn } from "@app/lib/cn"; +import { useDebounce } from "use-debounce"; +import type { SelectedUser } from "./users-selector"; + +export type { SelectedUser }; + +export type UserSelectorProps = { + orgId: string; + selectedUser?: SelectedUser | null; + onSelectUser: (user: SelectedUser | null) => void; + allowClear?: boolean; +}; + +export function UserSelector({ + orgId, + selectedUser, + onSelectUser, + allowClear = true +}: UserSelectorProps) { + const t = useTranslations(); + const [userSearchQuery, setUserSearchQuery] = useState(""); + const [debouncedValue] = useDebounce(userSearchQuery, 150); + + const { data: users = [] } = useQuery( + orgQueries.users({ orgId, perPage: 10, query: debouncedValue }) + ); + + const usersShown = useMemo(() => { + const allUsers: Array = users.map((u) => ({ + id: u.id, + text: getUserDisplayName(u) + })); + if ( + debouncedValue.trim().length === 0 && + selectedUser && + !allUsers.find((user) => user.id === selectedUser.id) + ) { + allUsers.unshift(selectedUser); + } + return allUsers; + }, [users, selectedUser, debouncedValue]); + + return ( + + + + {t("usersNotFound")} + + {allowClear && ( + { + onSelectUser(null); + }} + > + + {t("none")} + + )} + {usersShown.map((user) => ( + { + onSelectUser(user); + }} + > + + {user.text} + + ))} + + + + ); +}