From 62e3f62e82f38b12209a22b1deff43cdf93922a0 Mon Sep 17 00:00:00 2001 From: Timothy Carambat Date: Fri, 9 Jun 2023 11:27:27 -0700 Subject: [PATCH] 12 auth implementation (#13) * Add Auth protection for cloud-based or private instances * skip check on local dev --- .../CannotRemoveModal/index.jsx | 11 +- .../ConfirmationModal/index.jsx | 2 +- .../Modals/MangeWorkspace/Directory/index.jsx | 2 +- .../Modals/MangeWorkspace/index.jsx | 10 +- frontend/src/components/Modals/Password.jsx | 119 ++++++++++++++++ .../Sidebar/ActiveWorkspaces/index.jsx | 5 +- .../components/Sidebar/Placeholder/index.jsx | 134 ++++++++++++++++++ frontend/src/models/system.js | 30 +++- frontend/src/models/workspace.js | 13 +- frontend/src/pages/Main/index.jsx | 18 +++ frontend/src/pages/WorkspaceChat/index.jsx | 22 +++ frontend/src/utils/request.js | 9 ++ server/.env.example | 1 + server/endpoints/system.js | 36 ++++- server/index.js | 3 +- server/package.json | 1 + server/utils/http/index.js | 20 +++ server/utils/middleware/validatedRequest.js | 12 +- server/utils/vectorDbProviders/lance/index.js | 7 +- 19 files changed, 429 insertions(+), 26 deletions(-) create mode 100644 frontend/src/components/Modals/Password.jsx create mode 100644 frontend/src/components/Sidebar/Placeholder/index.jsx create mode 100644 frontend/src/utils/request.js diff --git a/frontend/src/components/Modals/MangeWorkspace/CannotRemoveModal/index.jsx b/frontend/src/components/Modals/MangeWorkspace/CannotRemoveModal/index.jsx index a66442fd..51028aab 100644 --- a/frontend/src/components/Modals/MangeWorkspace/CannotRemoveModal/index.jsx +++ b/frontend/src/components/Modals/MangeWorkspace/CannotRemoveModal/index.jsx @@ -1,10 +1,7 @@ import React from "react"; import { titleCase } from "text-case"; -export default function CannotRemoveModal({ - hideModal, - vectordb, -}) { +export default function CannotRemoveModal({ hideModal, vectordb }) { return (

- {titleCase(vectordb)} does not support atomic removal of documents.
Unfortunately, you will have to delete the entire workspace to remove this document from being referenced. + {titleCase(vectordb)} does not support atomic removal of + documents. +
+ Unfortunately, you will have to delete the entire workspace to + remove this document from being referenced.

diff --git a/frontend/src/components/Modals/MangeWorkspace/ConfirmationModal/index.jsx b/frontend/src/components/Modals/MangeWorkspace/ConfirmationModal/index.jsx index 6dce4235..bec3beec 100644 --- a/frontend/src/components/Modals/MangeWorkspace/ConfirmationModal/index.jsx +++ b/frontend/src/components/Modals/MangeWorkspace/ConfirmationModal/index.jsx @@ -86,4 +86,4 @@ export default function ConfirmationModal({
); -} \ No newline at end of file +} diff --git a/frontend/src/components/Modals/MangeWorkspace/Directory/index.jsx b/frontend/src/components/Modals/MangeWorkspace/Directory/index.jsx index b2eb4125..b444f979 100644 --- a/frontend/src/components/Modals/MangeWorkspace/Directory/index.jsx +++ b/frontend/src/components/Modals/MangeWorkspace/Directory/index.jsx @@ -147,4 +147,4 @@ export default function Directory({ )} ); -} \ No newline at end of file +} diff --git a/frontend/src/components/Modals/MangeWorkspace/index.jsx b/frontend/src/components/Modals/MangeWorkspace/index.jsx index 4c3cde12..675e622e 100644 --- a/frontend/src/components/Modals/MangeWorkspace/index.jsx +++ b/frontend/src/components/Modals/MangeWorkspace/index.jsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from "react"; -import { X } from 'react-feather'; +import { X } from "react-feather"; import System from "../../../models/system"; import Workspace from "../../../models/workspace"; import paths from "../../../utils/paths"; @@ -18,7 +18,7 @@ export default function ManageWorkspace({ hideModal = noop, workspace }) { const [originalDocuments, setOriginalDocuments] = useState([]); const [selectedFiles, setSelectFiles] = useState([]); const [vectordb, setVectorDB] = useState(null); - const [showingNoRemovalModal, setShowingNoRemovalModal] = useState(false) + const [showingNoRemovalModal, setShowingNoRemovalModal] = useState(false); useEffect(() => { async function fetchKeys() { @@ -29,7 +29,7 @@ export default function ManageWorkspace({ hideModal = noop, workspace }) { setDirectories(localFiles); setOriginalDocuments([...originalDocs]); setSelectFiles([...originalDocs]); - setVectorDB(settings?.VectorDB) + setVectorDB(settings?.VectorDB); setLoading(false); } fetchKeys(); @@ -99,7 +99,7 @@ export default function ManageWorkspace({ hideModal = noop, workspace }) { return isFolder ? originalDocuments.some((doc) => doc.includes(filepath.split("/")[0])) : originalDocuments.some((doc) => doc.includes(filepath)); - } + }; const toggleSelection = (filepath) => { const isFolder = !filepath.includes("/"); @@ -108,7 +108,7 @@ export default function ManageWorkspace({ hideModal = noop, workspace }) { if (isSelected(filepath)) { // Certain vector DBs do not contain the ability to delete vectors // so we cannot remove from these. The user will have to clear the entire workspace. - if (['lancedb'].includes(vectordb) && isOriginalDoc(filepath)) { + if (["lancedb"].includes(vectordb) && isOriginalDoc(filepath)) { setShowingNoRemovalModal(true); return false; } diff --git a/frontend/src/components/Modals/Password.jsx b/frontend/src/components/Modals/Password.jsx new file mode 100644 index 00000000..67f554f8 --- /dev/null +++ b/frontend/src/components/Modals/Password.jsx @@ -0,0 +1,119 @@ +import React, { useState, useEffect, useRef } from "react"; +import System from "../../models/system"; + +export default function PasswordModal() { + const [loading, setLoading] = useState(false); + const formEl = useRef(null); + const [error, setError] = useState(null); + const handleLogin = async (e) => { + setError(null); + setLoading(true); + e.preventDefault(); + const data = {}; + + const form = new FormData(formEl.current); + for (var [key, value] of form.entries()) data[key] = value; + const { valid, token, message } = await System.requestToken(data); + if (valid && !!token) { + window.localStorage.setItem("anythingllm_authtoken", token); + window.location.reload(); + } else { + setError(message); + setLoading(false); + } + setLoading(false); + }; + + return ( +
+
+
+
+
+
+

+ This workspace is password protected. +

+
+
+
+
+ + +
+ {error && ( +

+ Error: {error} +

+ )} +

+ You will only have to enter this password once. After + successful login it will be stored in your browser. +

+
+
+
+ +
+
+
+
+
+ ); +} + +export function usePasswordModal() { + const [requiresAuth, setRequiresAuth] = useState(null); + useEffect(() => { + async function checkAuthReq() { + if (!window) return; + if (import.meta.env.DEV) { + setRequiresAuth(false); + } else { + const currentToken = window.localStorage.getItem("anythingllm_authtoken"); + const settings = await System.keys(); + const requiresAuth = settings?.RequiresAuth || false; + + // If Auth is disabled - skip check + if (!requiresAuth) { + setRequiresAuth(requiresAuth); + return; + } + + if (!!currentToken) { + const valid = await System.checkAuth(currentToken); + if (!valid) { + setRequiresAuth(true); + window.localStorage.removeItem("anythingllm_authtoken"); + return; + } else { + setRequiresAuth(false); + return; + } + } + setRequiresAuth(true); + } + } + checkAuthReq(); + }, []); + + return { requiresAuth }; +} diff --git a/frontend/src/components/Sidebar/ActiveWorkspaces/index.jsx b/frontend/src/components/Sidebar/ActiveWorkspaces/index.jsx index 67da8086..9a3cdc58 100644 --- a/frontend/src/components/Sidebar/ActiveWorkspaces/index.jsx +++ b/frontend/src/components/Sidebar/ActiveWorkspaces/index.jsx @@ -51,10 +51,11 @@ export default function ActiveWorkspaces() { >

diff --git a/frontend/src/components/Sidebar/Placeholder/index.jsx b/frontend/src/components/Sidebar/Placeholder/index.jsx new file mode 100644 index 00000000..5f81c0d4 --- /dev/null +++ b/frontend/src/components/Sidebar/Placeholder/index.jsx @@ -0,0 +1,134 @@ +import React, { useRef } from "react"; +import { + BookOpen, + Briefcase, + Cpu, + GitHub, + Key, + Plus, + AlertCircle, +} from "react-feather"; +import * as Skeleton from "react-loading-skeleton"; +import "react-loading-skeleton/dist/skeleton.css"; +import paths from "../../../utils/paths"; + +export default function Sidebar() { + const sidebarRef = useRef(null); + + // const handleWidthToggle = () => { + // if (!sidebarRef.current) return false; + // sidebarRef.current.classList.add('translate-x-[-100%]') + // } + + return ( + <> +

+ {/* */} + +
+ {/* Header Information */} +
+

+ AnythingLLM +

+
+ +
+
+ + {/* Primary Body */} +
+
+
+
+ +
+ +
+
+
+
+
+
+ + ); +} diff --git a/frontend/src/models/system.js b/frontend/src/models/system.js index 4e24ffba..a2c3cc9c 100644 --- a/frontend/src/models/system.js +++ b/frontend/src/models/system.js @@ -1,4 +1,5 @@ import { API_BASE } from "../utils/constants"; +import { baseHeaders } from "../utils/request"; const System = { ping: async function () { @@ -7,7 +8,9 @@ const System = { .catch(() => false); }, totalIndexes: async function () { - return await fetch(`${API_BASE}/system-vectors`) + return await fetch(`${API_BASE}/system/system-vectors`, { + headers: baseHeaders(), + }) .then((res) => { if (!res.ok) throw new Error("Could not find indexes."); return res.json(); @@ -25,7 +28,9 @@ const System = { .catch(() => null); }, localFiles: async function () { - return await fetch(`${API_BASE}/local-files`) + return await fetch(`${API_BASE}/system/local-files`, { + headers: baseHeaders(), + }) .then((res) => { if (!res.ok) throw new Error("Could not find setup information."); return res.json(); @@ -33,6 +38,27 @@ const System = { .then((res) => res.localFiles) .catch(() => null); }, + checkAuth: async function (currentToken = null) { + return await fetch(`${API_BASE}/system/check-token`, { + headers: baseHeaders(currentToken), + }) + .then((res) => res.ok) + .catch(() => false); + }, + requestToken: async function (body) { + return await fetch(`${API_BASE}/request-token`, { + method: "POST", + body: JSON.stringify({ ...body }), + }) + .then((res) => { + if (!res.ok) throw new Error("Could not validate login."); + return res.json(); + }) + .then((res) => res) + .catch((e) => { + return { valid: false, message: e.message }; + }); + }, }; export default System; diff --git a/frontend/src/models/workspace.js b/frontend/src/models/workspace.js index 16096a92..11f97437 100644 --- a/frontend/src/models/workspace.js +++ b/frontend/src/models/workspace.js @@ -1,10 +1,12 @@ import { API_BASE } from "../utils/constants"; +import { baseHeaders } from "../utils/request"; const Workspace = { new: async function (data = {}) { const { workspace, message } = await fetch(`${API_BASE}/workspace/new`, { method: "POST", body: JSON.stringify(data), + headers: baseHeaders(), }) .then((res) => res.json()) .catch((e) => { @@ -19,6 +21,7 @@ const Workspace = { { method: "POST", body: JSON.stringify(changes), // contains 'adds' and 'removes' keys that are arrays of filepaths + headers: baseHeaders(), } ) .then((res) => res.json()) @@ -29,7 +32,9 @@ const Workspace = { return { workspace, message }; }, chatHistory: async function (slug) { - const history = await fetch(`${API_BASE}/workspace/${slug}/chats`) + const history = await fetch(`${API_BASE}/workspace/${slug}/chats`, { + headers: baseHeaders(), + }) .then((res) => res.json()) .then((res) => res.history || []) .catch(() => []); @@ -39,6 +44,7 @@ const Workspace = { const chatResult = await fetch(`${API_BASE}/workspace/${slug}/chat`, { method: "POST", body: JSON.stringify({ message, mode }), + headers: baseHeaders(), }) .then((res) => res.json()) .catch((e) => { @@ -57,7 +63,9 @@ const Workspace = { return workspaces; }, bySlug: async function (slug = "") { - const workspace = await fetch(`${API_BASE}/workspace/${slug}`) + const workspace = await fetch(`${API_BASE}/workspace/${slug}`, { + headers: baseHeaders(), + }) .then((res) => res.json()) .then((res) => res.workspace) .catch(() => null); @@ -66,6 +74,7 @@ const Workspace = { delete: async function (slug) { const result = await fetch(`${API_BASE}/workspace/${slug}`, { method: "DELETE", + headers: baseHeaders(), }) .then((res) => res.ok) .catch(() => false); diff --git a/frontend/src/pages/Main/index.jsx b/frontend/src/pages/Main/index.jsx index bff97c45..94ae3130 100644 --- a/frontend/src/pages/Main/index.jsx +++ b/frontend/src/pages/Main/index.jsx @@ -1,8 +1,26 @@ import React from "react"; import DefaultChatContainer from "../../components/DefaultChat"; import Sidebar from "../../components/Sidebar"; +import SidebarPlaceholder from "../../components/Sidebar/Placeholder"; +import ChatPlaceholder from "../../components/WorkspaceChat/LoadingChat"; +import PasswordModal, { + usePasswordModal, +} from "../../components/Modals/Password"; export default function Main() { + const { requiresAuth } = usePasswordModal(); + if (requiresAuth === null || requiresAuth) { + return ( + <> + {requiresAuth && } +
+ + +
+ + ); + } + return (
diff --git a/frontend/src/pages/WorkspaceChat/index.jsx b/frontend/src/pages/WorkspaceChat/index.jsx index b891295c..45fe5d31 100644 --- a/frontend/src/pages/WorkspaceChat/index.jsx +++ b/frontend/src/pages/WorkspaceChat/index.jsx @@ -3,8 +3,30 @@ import { default as WorkspaceChatContainer } from "../../components/WorkspaceCha import Sidebar from "../../components/Sidebar"; import { useParams } from "react-router-dom"; import Workspace from "../../models/workspace"; +import SidebarPlaceholder from "../../components/Sidebar/Placeholder"; +import ChatPlaceholder from "../../components/WorkspaceChat/LoadingChat"; +import PasswordModal, { + usePasswordModal, +} from "../../components/Modals/Password"; export default function WorkspaceChat() { + const { requiresAuth } = usePasswordModal(); + if (requiresAuth === null || requiresAuth) { + return ( + <> + {requiresAuth && } +
+ + +
+ + ); + } + + return ; +} + +function ShowWorkspaceChat() { const { slug } = useParams(); const [workspace, setWorkspace] = useState(null); const [loading, setLoading] = useState(true); diff --git a/frontend/src/utils/request.js b/frontend/src/utils/request.js new file mode 100644 index 00000000..4de7681e --- /dev/null +++ b/frontend/src/utils/request.js @@ -0,0 +1,9 @@ +// Sets up the base headers for all authenticated requests so that we are able to prevent +// basic spoofing since a valid token is required and that cannot be spoofed +export function baseHeaders(providedToken = null) { + const token = + providedToken || window.localStorage.getItem("anythingllm_authtoken"); + return { + Authorization: token ? `Bearer ${token}` : null, + }; +} diff --git a/server/.env.example b/server/.env.example index 541134f0..3934fd6a 100644 --- a/server/.env.example +++ b/server/.env.example @@ -18,4 +18,5 @@ PINECONE_INDEX= # CLOUD DEPLOYMENT VARIRABLES ONLY # AUTH_TOKEN="hunter2" # This is the password to your application if remote hosting. +# JWT_SECRET="my-random-string-for-seeding" # Only needed if AUTH_TOKEN is set. Please generate random string at least 12 chars long. # STORAGE_DIR= # absolute filesystem path with no trailing slash \ No newline at end of file diff --git a/server/endpoints/system.js b/server/endpoints/system.js index f03cb368..1d225d0b 100644 --- a/server/endpoints/system.js +++ b/server/endpoints/system.js @@ -3,6 +3,7 @@ process.env.NODE_ENV === "development" : require("dotenv").config(); const { viewLocalFiles } = require("../utils/files"); const { getVectorDbClass } = require("../utils/helpers"); +const { reqBody, makeJWT } = require("../utils/http"); function systemEndpoints(app) { if (!app) return; @@ -15,6 +16,7 @@ function systemEndpoints(app) { try { const vectorDB = process.env.VECTOR_DB || "pinecone"; const results = { + RequiresAuth: !!process.env.AUTH_TOKEN, VectorDB: vectorDB, OpenAiKey: !!process.env.OPEN_AI_KEY, OpenAiModelPref: process.env.OPEN_MODEL_PREF || "gpt-3.5-turbo", @@ -38,7 +40,37 @@ function systemEndpoints(app) { } }); - app.get("/system-vectors", async (_, response) => { + app.get("/system/check-token", (_, response) => { + response.sendStatus(200).end(); + }); + + app.post("/request-token", (request, response) => { + try { + const { password } = reqBody(request); + if (password !== process.env.AUTH_TOKEN) { + response + .status(402) + .json({ + valid: false, + token: null, + message: "Invalid password provided", + }); + return; + } + + response.status(200).json({ + valid: true, + token: makeJWT({ p: password }, "30d"), + message: null, + }); + return; + } catch (e) { + console.log(e.message, e); + response.sendStatus(500).end(); + } + }); + + app.get("/system/system-vectors", async (_, response) => { try { const VectorDb = getVectorDbClass(); const vectorCount = await VectorDb.totalIndicies(); @@ -49,7 +81,7 @@ function systemEndpoints(app) { } }); - app.get("/local-files", async (_, response) => { + app.get("/system/local-files", async (_, response) => { try { const localFiles = await viewLocalFiles(); response.status(200).json({ localFiles }); diff --git a/server/index.js b/server/index.js index 648d34c0..3eec6198 100644 --- a/server/index.js +++ b/server/index.js @@ -14,7 +14,6 @@ const { getVectorDbClass } = require("./utils/helpers"); const app = express(); app.use(cors({ origin: true })); -app.use(validatedRequest); app.use(bodyParser.text()); app.use(bodyParser.json()); app.use( @@ -23,6 +22,8 @@ app.use( }) ); +app.use("/system/*", validatedRequest); +app.use("/workspace/*", validatedRequest); systemEndpoints(app); workspaceEndpoints(app); chatEndpoints(app); diff --git a/server/package.json b/server/package.json index 31ae23c6..70f6e3a7 100644 --- a/server/package.json +++ b/server/package.json @@ -30,6 +30,7 @@ "sqlite": "^4.2.1", "sqlite3": "^5.1.6", "uuid": "^9.0.0", + "jsonwebtoken": "^8.5.1", "vectordb": "0.1.5-beta" }, "devDependencies": { diff --git a/server/utils/http/index.js b/server/utils/http/index.js index e4f2813d..af42f5de 100644 --- a/server/utils/http/index.js +++ b/server/utils/http/index.js @@ -1,3 +1,9 @@ +process.env.NODE_ENV === "development" + ? require("dotenv").config({ path: `.env.${process.env.NODE_ENV}` }) + : require("dotenv").config(); +const JWT = require("jsonwebtoken"); +const SECRET = process.env.JWT_SECRET; + function reqBody(request) { return typeof request.body === "string" ? JSON.parse(request.body) @@ -8,7 +14,21 @@ function queryParams(request) { return request.query; } +function makeJWT(info = {}, expiry = "30d") { + if (!SECRET) throw new Error("Cannot create JWT as JWT_SECRET is unset."); + return JWT.sign(info, SECRET, { expiresIn: expiry }); +} + +function decodeJWT(jwtToken) { + try { + return JWT.verify(jwtToken, SECRET); + } catch {} + return null; +} + module.exports = { reqBody, queryParams, + makeJWT, + decodeJWT, }; diff --git a/server/utils/middleware/validatedRequest.js b/server/utils/middleware/validatedRequest.js index a1d9bf3b..4e7c519a 100644 --- a/server/utils/middleware/validatedRequest.js +++ b/server/utils/middleware/validatedRequest.js @@ -1,6 +1,13 @@ +const { decodeJWT } = require("../http"); + function validatedRequest(request, response, next) { // When in development passthrough auth token for ease of development. - if (process.env.NODE_ENV === "development" || !process.env.AUTH_TOKEN) { + // Or if the user simply did not set an Auth token or JWT Secret + if ( + process.env.NODE_ENV === "development" || + !process.env.AUTH_TOKEN || + !process.env.JWT_SECRET + ) { next(); return; } @@ -22,7 +29,8 @@ function validatedRequest(request, response, next) { return; } - if (token !== process.env.AUTH_TOKEN) { + const { p } = decodeJWT(token); + if (p !== process.env.AUTH_TOKEN) { response.status(403).json({ error: "Invalid auth token found.", }); diff --git a/server/utils/vectorDbProviders/lance/index.js b/server/utils/vectorDbProviders/lance/index.js index 8617a423..e064154f 100644 --- a/server/utils/vectorDbProviders/lance/index.js +++ b/server/utils/vectorDbProviders/lance/index.js @@ -26,8 +26,9 @@ function curateLanceSources(sources = []) { } const LanceDb = { - uri: `${!!process.env.STORAGE_DIR ? `${process.env.STORAGE_DIR}/` : "./" - }lancedb`, + uri: `${ + !!process.env.STORAGE_DIR ? `${process.env.STORAGE_DIR}/` : "./" + }lancedb`, name: "LanceDb", connect: async function () { if (process.env.VECTOR_DB !== "lancedb") @@ -282,4 +283,4 @@ const LanceDb = { }, }; -module.exports.LanceDb = LanceDb +module.exports.LanceDb = LanceDb;