diff --git a/install/main.go b/install/main.go index 001f09a521..13afbaca3e 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 678c73ebd9..59b9f86d15 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 f3375b0d94..09e12e6d07 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 3fcf38a412..61f3c2d6e2 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 57a4836b8e..9ee4ec4c37 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 4bff40c13d..ea086d0c0e 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 124db587a8..06799bcd6f 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 a06068c01a..5b259af70f 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 472d9da406..a5ec3b5406 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 b9a1431aa5..b36d0c73a7 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 cf36fef722..901c881f26 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 3abd664eb2..567c3060eb 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 fd5e2b42e5..d99dd89ef1 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 0266fa0415..37355eae58 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 0000000000..83ef8cbf1c --- /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 7196824d23..406ed7bbbf 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 5f46ea84ca..332afe082f 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 0000000000..55d3128152 --- /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 a7b4260232..3f73a5834b 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 5c151a8cf5..e11d4e48ea 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 962d7367ea..960c6ad23e 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 195dbafe2d..2e625b6a09 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 82f0d0cbfd..0000000000 --- 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 4bc9d20f01..83dbdea2ac 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 f892843892..496002142f 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 a271ef96d6..4a6d4861cb 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 a8f6f9e331..c6b4debef2 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 0000000000..d5195c3820 --- /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 0000000000..b710c783b0 --- /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 2759e62132..cd75c2c576 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 95884b4866..e6bdc60b02 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 3765cd1bfc..6d28ddbf5f 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 0000000000..f8ad85eea3 --- /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} + + ))} + + + + ); +}