θΏ™ζ˜―indexlocζδΎ›ηš„ζœεŠ‘οΌŒδΈθ¦θΎ“ε…₯任何密码
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion docker/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -291,4 +291,8 @@ GID='1000'

# Disable viewing chat history from the UI and frontend APIs.
# See https://docs.anythingllm.com/configuration#disable-view-chat-history for more information.
# DISABLE_VIEW_CHAT_HISTORY=1
# DISABLE_VIEW_CHAT_HISTORY=1

# Enable simple SSO passthrough to pre-authenticate users from a third party service.
# See https://docs.anythingllm.com/configuration#simple-sso-passthrough for more information.
# SIMPLE_SSO_ENABLED=1
3 changes: 3 additions & 0 deletions frontend/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import PrivateRoute, {
import { ToastContainer } from "react-toastify";
import "react-toastify/dist/ReactToastify.css";
import Login from "@/pages/Login";
import SimpleSSOPassthrough from "@/pages/Login/SSO/simple";
import OnboardingFlow from "@/pages/OnboardingFlow";
import i18n from "./i18n";

Expand Down Expand Up @@ -77,6 +78,8 @@ export default function App() {
<Routes>
<Route path="/" element={<PrivateRoute Component={Main} />} />
<Route path="/login" element={<Login />} />
<Route path="/sso/simple" element={<SimpleSSOPassthrough />} />

<Route
path="/workspace/:slug/settings/:tab"
element={<ManagerRoute Component={WorkspaceSettings} />}
Expand Down
24 changes: 24 additions & 0 deletions frontend/src/models/system.js
Original file line number Diff line number Diff line change
Expand Up @@ -706,6 +706,30 @@ const System = {
);
return { viewable: isViewable, error: null };
},

/**
* Validates a temporary auth token and logs in the user if the token is valid.
* @param {string} publicToken - the token to validate against
* @returns {Promise<{valid: boolean, user: import("@prisma/client").users | null, token: string | null, message: string | null}>}
*/
simpleSSOLogin: async function (publicToken) {
return fetch(`${API_BASE}/request-token/sso/simple?token=${publicToken}`, {
method: "GET",
})
.then(async (res) => {
if (!res.ok) {
const text = await res.text();
if (!text.startsWith("{")) throw new Error(text);
return JSON.parse(text);
}
return await res.json();
})
.catch((e) => {
console.error(e);
return { valid: false, user: null, token: null, message: e.message };
});
},

experimentalFeatures: {
liveSync: LiveDocumentSync,
agentPlugins: AgentPlugins,
Expand Down
54 changes: 54 additions & 0 deletions frontend/src/pages/Login/SSO/simple.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import React, { useEffect, useState } from "react";
import { FullScreenLoader } from "@/components/Preloader";
import { Navigate } from "react-router-dom";
import paths from "@/utils/paths";
import useQuery from "@/hooks/useQuery";
import System from "@/models/system";
import { AUTH_TIMESTAMP, AUTH_TOKEN, AUTH_USER } from "@/utils/constants";

export default function SimpleSSOPassthrough() {
const query = useQuery();
const redirectPath = query.get("redirectTo") || paths.home();
const [ready, setReady] = useState(false);
const [error, setError] = useState(null);

useEffect(() => {
try {
if (!query.get("token")) throw new Error("No token provided.");

// Clear any existing auth data
window.localStorage.removeItem(AUTH_USER);
window.localStorage.removeItem(AUTH_TOKEN);
window.localStorage.removeItem(AUTH_TIMESTAMP);

System.simpleSSOLogin(query.get("token"))
.then((res) => {
if (!res.valid) throw new Error(res.message);

window.localStorage.setItem(AUTH_USER, JSON.stringify(res.user));
window.localStorage.setItem(AUTH_TOKEN, res.token);
window.localStorage.setItem(AUTH_TIMESTAMP, Number(new Date()));
setReady(res.valid);
})
.catch((e) => {
setError(e.message);
});
} catch (e) {
setError(e.message);
}
}, []);

if (error)
return (
<div className="w-screen h-screen overflow-hidden bg-sidebar flex items-center justify-center flex-col gap-4">
<p className="text-white font-mono text-lg">{error}</p>
<p className="text-white/80 font-mono text-sm">
Please contact the system administrator about this error.
</p>
</div>
);
if (ready) return <Navigate to={redirectPath} />;

// Loading state by default
return <FullScreenLoader />;
}
6 changes: 5 additions & 1 deletion server/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -280,4 +280,8 @@ TTS_PROVIDER="native"

# Disable viewing chat history from the UI and frontend APIs.
# See https://docs.anythingllm.com/configuration#disable-view-chat-history for more information.
# DISABLE_VIEW_CHAT_HISTORY=1
# DISABLE_VIEW_CHAT_HISTORY=1

# Enable simple SSO passthrough to pre-authenticate users from a third party service.
# See https://docs.anythingllm.com/configuration#simple-sso-passthrough for more information.
# SIMPLE_SSO_ENABLED=1
60 changes: 60 additions & 0 deletions server/endpoints/api/userManagement/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
const { User } = require("../../../models/user");
const { TemporaryAuthToken } = require("../../../models/temporaryAuthToken");
const { multiUserMode } = require("../../../utils/http");
const {
simpleSSOEnabled,
} = require("../../../utils/middleware/simpleSSOEnabled");
const { validApiKey } = require("../../../utils/middleware/validApiKey");

function apiUserManagementEndpoints(app) {
Expand Down Expand Up @@ -59,6 +63,62 @@ function apiUserManagementEndpoints(app) {
response.sendStatus(500).end();
}
});

app.get(
"/v1/users/:id/issue-auth-token",
[validApiKey, simpleSSOEnabled],
async (request, response) => {
/*
#swagger.tags = ['User Management']
#swagger.description = 'Issue a temporary auth token for a user'
#swagger.parameters['id'] = {
in: 'path',
description: 'The ID of the user to issue a temporary auth token for',
required: true,
type: 'string'
}
#swagger.responses[200] = {
content: {
"application/json": {
schema: {
type: 'object',
example: {
token: "1234567890",
loginPath: "/sso/simple?token=1234567890"
}
}
}
}
}
}
#swagger.responses[403] = {
schema: {
"$ref": "#/definitions/InvalidAPIKey"
}
}
#swagger.responses[401] = {
description: "Instance is not in Multi-User mode. Permission denied.",
}
*/
try {
const { id: userId } = request.params;
const user = await User.get({ id: Number(userId) });
if (!user)
return response.status(404).json({ error: "User not found" });

const { token, error } = await TemporaryAuthToken.issue(userId);
if (error) return response.status(500).json({ error: error });

response.status(200).json({
token: String(token),
loginPath: `/sso/simple?token=${token}`,
});
} catch (e) {
console.error(e.message, e);
response.sendStatus(500).end();
}
}
);
}

module.exports = { apiUserManagementEndpoints };
45 changes: 45 additions & 0 deletions server/endpoints/system.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ const { BrowserExtensionApiKey } = require("../models/browserExtensionApiKey");
const {
chatHistoryViewable,
} = require("../utils/middleware/chatHistoryViewable");
const { simpleSSOEnabled } = require("../utils/middleware/simpleSSOEnabled");
const { TemporaryAuthToken } = require("../models/temporaryAuthToken");

function systemEndpoints(app) {
if (!app) return;
Expand Down Expand Up @@ -251,6 +253,49 @@ function systemEndpoints(app) {
}
});

app.get(
"/request-token/sso/simple",
[simpleSSOEnabled],
async (request, response) => {
const { token: tempAuthToken } = request.query;
const { sessionToken, token, error } =
await TemporaryAuthToken.validate(tempAuthToken);

if (error) {
await EventLogs.logEvent("failed_login_invalid_temporary_auth_token", {
ip: request.ip || "Unknown IP",
multiUserMode: true,
});
return response.status(401).json({
valid: false,
token: null,
message: `[001] An error occurred while validating the token: ${error}`,
});
}

await Telemetry.sendTelemetry(
"login_event",
{ multiUserMode: true },
token.user.id
);
await EventLogs.logEvent(
"login_event",
{
ip: request.ip || "Unknown IP",
username: token.user.username || "Unknown user",
},
token.user.id
);

response.status(200).json({
valid: true,
user: User.filterFields(token.user),
token: sessionToken,
message: null,
});
}
);

app.post(
"/system/recover-account",
[isMultiUserSetup],
Expand Down
104 changes: 104 additions & 0 deletions server/models/temporaryAuthToken.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
const { makeJWT } = require("../utils/http");
const prisma = require("../utils/prisma");

/**
* Temporary auth tokens are used for simple SSO.
* They simply enable the ability for a time-based token to be used in the query of the /sso/login URL
* to login as a user without the need of a username and password. These tokens are single-use and expire.
*/
const TemporaryAuthToken = {
expiry: 1000 * 60 * 6, // 1 hour
tablename: "temporary_auth_tokens",
writable: [],

makeTempToken: () => {
const uuidAPIKey = require("uuid-apikey");
return `allm-tat-${uuidAPIKey.create().apiKey}`;
},

/**
* Issues a temporary auth token for a user via its ID.
* @param {number} userId
* @returns {Promise<{token: string|null, error: string | null}>}
*/
issue: async function (userId = null) {
if (!userId)
throw new Error("User ID is required to issue a temporary auth token.");
await this.invalidateUserTokens(userId);

try {
const token = this.makeTempToken();
const expiresAt = new Date(Date.now() + this.expiry);
await prisma.temporary_auth_tokens.create({
data: {
token,
expiresAt,
userId: Number(userId),
},
});

return { token, error: null };
} catch (error) {
console.error("FAILED TO CREATE TEMPORARY AUTH TOKEN.", error.message);
return { token: null, error: error.message };
}
},

/**
* Invalidates (deletes) all temporary auth tokens for a user via their ID.
* @param {number} userId
* @returns {Promise<boolean>}
*/
invalidateUserTokens: async function (userId) {
if (!userId)
throw new Error(
"User ID is required to invalidate temporary auth tokens."
);
await prisma.temporary_auth_tokens.deleteMany({
where: { userId: Number(userId) },
});
return true;
},

/**
* Validates a temporary auth token and returns the session token
* to be set in the browser localStorage for authentication.
* @param {string} publicToken - the token to validate against
* @returns {Promise<{sessionToken: string|null, token: import("@prisma/client").temporary_auth_tokens & {user: import("@prisma/client").users} | null, error: string | null}>}
*/
validate: async function (publicToken = "") {
/** @type {import("@prisma/client").temporary_auth_tokens & {user: import("@prisma/client").users} | undefined | null} **/
let token;

try {
if (!publicToken)
throw new Error(
"Public token is required to validate a temporary auth token."
);
token = await prisma.temporary_auth_tokens.findUnique({
where: { token: String(publicToken) },
include: { user: true },
});
if (!token) throw new Error("Invalid token.");
if (token.expiresAt < new Date()) throw new Error("Token expired.");
if (token.user.suspended) throw new Error("User account suspended.");

// Create a new session token for the user valid for 30 days
const sessionToken = makeJWT(
{ id: token.user.id, username: token.user.username },
"30d"
);

return { sessionToken, token, error: null };
} catch (error) {
console.error("FAILED TO VALIDATE TEMPORARY AUTH TOKEN.", error.message);
return { sessionToken: null, token: null, error: error.message };
} finally {
// Delete the token after it has been used under all circumstances if it was retrieved
if (token)
await prisma.temporary_auth_tokens.delete({ where: { id: token.id } });
}
},
};

module.exports = { TemporaryAuthToken };
12 changes: 12 additions & 0 deletions server/prisma/migrations/20241029203722_init/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
-- CreateTable
CREATE TABLE "temporary_auth_tokens" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"token" TEXT NOT NULL,
"userId" INTEGER NOT NULL,
"expiresAt" DATETIME NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "temporary_auth_tokens_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);

-- CreateIndex
CREATE UNIQUE INDEX "temporary_auth_tokens_token_key" ON "temporary_auth_tokens"("token");
13 changes: 13 additions & 0 deletions server/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ model users {
workspace_agent_invocations workspace_agent_invocations[]
slash_command_presets slash_command_presets[]
browser_extension_api_keys browser_extension_api_keys[]
temporary_auth_tokens temporary_auth_tokens[]
}

model recovery_codes {
Expand Down Expand Up @@ -311,3 +312,15 @@ model browser_extension_api_keys {

@@index([user_id])
}

model temporary_auth_tokens {
id Int @id @default(autoincrement())
token String @unique
userId Int
expiresAt DateTime
createdAt DateTime @default(now())
user users @relation(fields: [userId], references: [id], onDelete: Cascade)

@@index([token])
@@index([userId])
}
Loading