ソースを参照

allow access token in resource url

Milo Schwartz 6 ヶ月 前
コミット
f5fda5d8ea

+ 45 - 0
server/auth/canUserAccessResource.ts

@@ -0,0 +1,45 @@
+import db from "@server/db";
+import { and, eq } from "drizzle-orm";
+import { roleResources, userResources } from "@server/db/schema";
+
+export async function canUserAccessResource({
+    userId,
+    resourceId,
+    roleId
+}: {
+    userId: string;
+    resourceId: number;
+    roleId: number;
+}): Promise<boolean> {
+    const roleResourceAccess = await db
+        .select()
+        .from(roleResources)
+        .where(
+            and(
+                eq(roleResources.resourceId, resourceId),
+                eq(roleResources.roleId, roleId)
+            )
+        )
+        .limit(1);
+
+    if (roleResourceAccess.length > 0) {
+        return true;
+    }
+
+    const userResourceAccess = await db
+        .select()
+        .from(userResources)
+        .where(
+            and(
+                eq(userResources.userId, userId),
+                eq(userResources.resourceId, resourceId)
+            )
+        )
+        .limit(1);
+
+    if (userResourceAccess.length > 0) {
+        return true;
+    }
+
+    return false;
+}

+ 67 - 0
server/auth/verifyResourceAccessToken.ts

@@ -0,0 +1,67 @@
+import db from "@server/db";
+import {
+    Resource,
+    ResourceAccessToken,
+    resourceAccessToken,
+} from "@server/db/schema";
+import { and, eq } from "drizzle-orm";
+import { isWithinExpirationDate } from "oslo";
+import { verifyPassword } from "./password";
+
+export async function verifyResourceAccessToken({
+    resource,
+    accessTokenId,
+    accessToken
+}: {
+    resource: Resource;
+    accessTokenId: string;
+    accessToken: string;
+}): Promise<{
+    valid: boolean;
+    error?: string;
+    tokenItem?: ResourceAccessToken;
+}> {
+    const [result] = await db
+        .select()
+        .from(resourceAccessToken)
+        .where(
+            and(
+                eq(resourceAccessToken.resourceId, resource.resourceId),
+                eq(resourceAccessToken.accessTokenId, accessTokenId)
+            )
+        )
+        .limit(1);
+
+    const tokenItem = result;
+
+    if (!tokenItem) {
+        return {
+            valid: false,
+            error: "Access token does not exist for resource"
+        };
+    }
+
+    const validCode = await verifyPassword(accessToken, tokenItem.tokenHash);
+
+    if (!validCode) {
+        return {
+            valid: false,
+            error: "Invalid access token"
+        };
+    }
+
+    if (
+        tokenItem.expiresAt &&
+        !isWithinExpirationDate(new Date(tokenItem.expiresAt))
+    ) {
+        return {
+            valid: false,
+            error: "Access token has expired"
+        };
+    }
+
+    return {
+        valid: true,
+        tokenItem
+    };
+}

+ 3 - 3
server/lib/config.ts

@@ -11,9 +11,9 @@ const portSchema = z.number().positive().gt(0).lte(65535);
 const hostnameSchema = z
     .string()
     .regex(
-        /^(?!-)[a-zA-Z0-9-]{1,63}(?<!-)(\.[a-zA-Z]{2,})*$/,
-        "Invalid hostname. Must be a valid hostname like 'localhost' or 'test.example.com'."
-    );
+        /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$|^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)+([A-Za-z]|[A-Za-z][A-Za-z0-9\-]*[A-Za-z0-9])$/
+    )
+    .or(z.literal("localhost"));
 
 const environmentSchema = z.object({
     app: z.object({

+ 1 - 1
server/middlewares/verifyAccessTokenAccess.ts

@@ -4,7 +4,7 @@ import { resourceAccessToken, resources, userOrgs } from "@server/db/schema";
 import { and, eq } from "drizzle-orm";
 import createHttpError from "http-errors";
 import HttpCode from "@server/types/HttpCode";
-import { canUserAccessResource } from "@server/lib/canUserAccessResource";
+import { canUserAccessResource } from "@server/auth/canUserAccessResource";
 
 export async function verifyAccessTokenAccess(
     req: Request,

+ 1 - 1
server/middlewares/verifyTargetAccess.ts

@@ -4,7 +4,7 @@ import { resources, targets, userOrgs } from "@server/db/schema";
 import { and, eq } from "drizzle-orm";
 import createHttpError from "http-errors";
 import HttpCode from "@server/types/HttpCode";
-import { canUserAccessResource } from "../lib/canUserAccessResource";
+import { canUserAccessResource } from "../auth/canUserAccessResource";
 
 export async function verifyTargetAccess(
     req: Request,

+ 83 - 4
server/routers/badger/verifySession.ts

@@ -7,6 +7,7 @@ import { response } from "@server/lib/response";
 import { validateSessionToken } from "@server/auth/sessions/app";
 import db from "@server/db";
 import {
+    ResourceAccessToken,
     resourceAccessToken,
     resourcePassword,
     resourcePincode,
@@ -17,9 +18,15 @@ import {
 } from "@server/db/schema";
 import { and, eq } from "drizzle-orm";
 import config from "@server/lib/config";
-import { validateResourceSessionToken } from "@server/auth/sessions/resource";
+import {
+    createResourceSession,
+    serializeResourceSessionCookie,
+    validateResourceSessionToken
+} from "@server/auth/sessions/resource";
 import { Resource, roleResources, userResources } from "@server/db/schema";
 import logger from "@server/logger";
+import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
+import { generateSessionToken } from "@server/auth";
 
 const verifyResourceSessionSchema = z.object({
     sessions: z.record(z.string()).optional(),
@@ -28,6 +35,7 @@ const verifyResourceSessionSchema = z.object({
     host: z.string(),
     path: z.string(),
     method: z.string(),
+    accessToken: z.string().optional(),
     tls: z.boolean()
 });
 
@@ -59,7 +67,8 @@ export async function verifyResourceSession(
     }
 
     try {
-        const { sessions, host, originalRequestURL } = parsedBody.data;
+        const { sessions, host, originalRequestURL, accessToken: token } =
+            parsedBody.data;
 
         const [result] = await db
             .select()
@@ -103,11 +112,41 @@ export async function verifyResourceSession(
 
         const redirectUrl = `${config.getRawConfig().app.dashboard_url}/auth/resource/${encodeURIComponent(resource.resourceId)}?redirect=${encodeURIComponent(originalRequestURL)}`;
 
+        // check for access token
+        let validAccessToken: ResourceAccessToken | undefined;
+        if (token) {
+            const [accessTokenId, accessToken] = token.split(".");
+            const { valid, error, tokenItem } = await verifyResourceAccessToken(
+                {
+                    resource,
+                    accessTokenId,
+                    accessToken
+                }
+            );
+
+            if (error) {
+                logger.debug("Access token invalid: " + error);
+            }
+
+            if (valid && tokenItem) {
+                validAccessToken = tokenItem;
+
+                if (!sessions) {
+                    return await createAccessTokenSession(
+                        res,
+                        resource,
+                        tokenItem
+                    );
+                }
+            }
+        }
+
         if (!sessions) {
             return notAllowed(res);
         }
 
-        const sessionToken = sessions[config.getRawConfig().server.session_cookie_name];
+        const sessionToken =
+            sessions[config.getRawConfig().server.session_cookie_name];
 
         // check for unified login
         if (sso && sessionToken) {
@@ -172,6 +211,16 @@ export async function verifyResourceSession(
             }
         }
 
+        // At this point we have checked all sessions, but since the access token is valid, we should allow access
+        // and create a new session.
+        if (validAccessToken) {
+            return await createAccessTokenSession(
+                res,
+                resource,
+                validAccessToken
+            );
+        }
+
         logger.debug("No more auth to check, resource not allowed");
         return notAllowed(res, redirectUrl);
     } catch (e) {
@@ -209,11 +258,41 @@ function allowed(res: Response) {
     return response<VerifyUserResponse>(res, data);
 }
 
+async function createAccessTokenSession(
+    res: Response,
+    resource: Resource,
+    tokenItem: ResourceAccessToken
+) {
+    const token = generateSessionToken();
+    await createResourceSession({
+        resourceId: resource.resourceId,
+        token,
+        accessTokenId: tokenItem.accessTokenId,
+        sessionLength: tokenItem.sessionLength,
+        expiresAt: tokenItem.expiresAt,
+        doNotExtend: tokenItem.expiresAt ? true : false
+    });
+    const cookieName = `${config.getRawConfig().server.resource_session_cookie_name}_${resource.resourceId}`;
+    const cookie = serializeResourceSessionCookie(cookieName, token);
+    res.appendHeader("Set-Cookie", cookie);
+    logger.debug("Access token is valid, creating new session")
+    return response<VerifyUserResponse>(res, {
+        data: { valid: true },
+        success: true,
+        error: false,
+        message: "Access allowed",
+        status: HttpCode.OK
+    });
+}
+
 async function isUserAllowedToAccessResource(
     user: User,
     resource: Resource
 ): Promise<boolean> {
-    if (config.getRawConfig().flags?.require_email_verification && !user.emailVerified) {
+    if (
+        config.getRawConfig().flags?.require_email_verification &&
+        !user.emailVerified
+    ) {
         return false;
     }
 

+ 17 - 39
server/routers/resource/authWithAccessToken.ts

@@ -14,9 +14,7 @@ import {
 } from "@server/auth/sessions/resource";
 import config from "@server/lib/config";
 import logger from "@server/logger";
-import { verify } from "@node-rs/argon2";
-import { isWithinExpirationDate } from "oslo";
-import { verifyPassword } from "@server/auth/password";
+import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
 
 const authWithAccessTokenBodySchema = z
     .object({
@@ -69,58 +67,38 @@ export async function authWithAccessToken(
     const { accessToken, accessTokenId } = parsedBody.data;
 
     try {
-        const [result] = await db
+        const [resource] = await db
             .select()
-            .from(resourceAccessToken)
-            .where(
-                and(
-                    eq(resourceAccessToken.resourceId, resourceId),
-                    eq(resourceAccessToken.accessTokenId, accessTokenId)
-                )
-            )
-            .leftJoin(
-                resources,
-                eq(resources.resourceId, resourceAccessToken.resourceId)
-            )
+            .from(resources)
+            .where(eq(resources.resourceId, resourceId))
             .limit(1);
 
-        const resource = result?.resources;
-        const tokenItem = result?.resourceAccessToken;
-
-        if (!tokenItem) {
-            return next(
-                createHttpError(
-                    HttpCode.UNAUTHORIZED,
-                    createHttpError(
-                        HttpCode.BAD_REQUEST,
-                        "Access token does not exist for resource"
-                    )
-                )
-            );
-        }
-
         if (!resource) {
             return next(
-                createHttpError(HttpCode.BAD_REQUEST, "Resource does not exist")
+                createHttpError(HttpCode.NOT_FOUND, "Resource not found")
             );
         }
 
-        const validCode = await verifyPassword(accessToken, tokenItem.tokenHash);
+        const { valid, error, tokenItem } = await verifyResourceAccessToken({
+            resource,
+            accessTokenId,
+            accessToken
+        });
 
-        if (!validCode) {
+        if (!valid) {
             return next(
-                createHttpError(HttpCode.UNAUTHORIZED, "Invalid access token")
+                createHttpError(
+                    HttpCode.UNAUTHORIZED,
+                    error || "Invalid access token"
+                )
             );
         }
 
-        if (
-            tokenItem.expiresAt &&
-            !isWithinExpirationDate(new Date(tokenItem.expiresAt))
-        ) {
+        if (!tokenItem || !resource) {
             return next(
                 createHttpError(
                     HttpCode.UNAUTHORIZED,
-                    "Access token has expired"
+                    "Access token does not exist for resource"
                 )
             );
         }

+ 1 - 0
server/routers/traefik/getTraefikConfig.ts

@@ -56,6 +56,7 @@ export async function traefikConfigProvider(
                                 config.getRawConfig().server.resource_session_cookie_name,
                             userSessionCookieName:
                                 config.getRawConfig().server.session_cookie_name,
+                            accessTokenQueryParam: "p_token"
                         },
                     },
                 },

+ 2 - 0
server/setup/setupServerAdmin.ts

@@ -69,6 +69,8 @@ export async function setupServerAdmin() {
 
             const userId = generateId(15);
 
+            await trx.update(users).set({ serverAdmin: false });
+
             await db.insert(users).values({
                 userId: userId,
                 email: email,

+ 2 - 3
src/app/auth/resource/[resourceId]/page.tsx

@@ -45,11 +45,10 @@ export default async function ResourceAuthPage(props: {
     const user = await getUser({ skipCheckVerifyEmail: true });
 
     if (!authInfo) {
-        {
-            /* @ts-ignore */
-        } // TODO: fix this
+        // TODO: fix this
         return (
             <div className="w-full max-w-md">
+            {/* @ts-ignore */}
                 <ResourceNotFound />
             </div>
         );