+
@@ -290,6 +291,12 @@ export default function MultiUserAuth() {
+
diff --git a/frontend/src/components/Modals/Password/SocialProviders.jsx b/frontend/src/components/Modals/Password/SocialProviders.jsx
new file mode 100644
index 0000000000..c8e20d832e
--- /dev/null
+++ b/frontend/src/components/Modals/Password/SocialProviders.jsx
@@ -0,0 +1,63 @@
+import System from "@/models/system";
+import { AUTH_TOKEN, AUTH_USER } from "@/utils/constants";
+import paths from "@/utils/paths";
+import { GoogleLogin } from "@react-oauth/google";
+import { useEffect, useState } from "react";
+
+export default function SocialProviders({
+ setError,
+ setLoading,
+ setUser,
+ setToken,
+}) {
+ const [settings, setSettings] = useState(null);
+
+ useEffect(() => {
+ async function fetchSettings() {
+ const _settings = await System.keys();
+ setSettings(_settings);
+ }
+ fetchSettings();
+ }, []);
+
+ const handleGoogleLogin = async (data) => {
+ setError(null);
+ setLoading(true);
+ const { valid, user, token, message } = await System.socialLogin(
+ data,
+ "google"
+ );
+ if (valid && !!token && !!user) {
+ setUser(user);
+ setToken(token);
+
+ window.localStorage.setItem(AUTH_USER, JSON.stringify(user));
+ window.localStorage.setItem(AUTH_TOKEN, token);
+ window.location = paths.home();
+ } else {
+ setError(message);
+ setLoading(false);
+ }
+ setLoading(false);
+ };
+
+ return (
+ <>
+ {settings?.GoogleAuthClientId && (
+ <>
+
{
+ handleGoogleLogin(credentialResponse);
+ }}
+ onError={() => {
+ setError("Something went wrong");
+ }}
+ theme={"filled_black"}
+ />
+ {/* Add here other social providers */}
+ or
+ >
+ )}
+ >
+ );
+}
diff --git a/frontend/src/models/system.js b/frontend/src/models/system.js
index d2252be16f..93370ff60f 100644
--- a/frontend/src/models/system.js
+++ b/frontend/src/models/system.js
@@ -78,6 +78,20 @@ const System = {
return { valid: false, message: e.message };
});
},
+ socialLogin: async function (body, provider) {
+ return await fetch(`${API_BASE}/social-login/${provider}`, {
+ 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 };
+ });
+ },
recoverAccount: async function (username, recoveryCodes) {
return await fetch(`${API_BASE}/system/recover-account`, {
method: "POST",
diff --git a/frontend/src/pages/Admin/System/index.jsx b/frontend/src/pages/Admin/System/index.jsx
index bdab765a5a..57c590f6ad 100644
--- a/frontend/src/pages/Admin/System/index.jsx
+++ b/frontend/src/pages/Admin/System/index.jsx
@@ -4,6 +4,7 @@ import { isMobile } from "react-device-detect";
import Admin from "@/models/admin";
import showToast from "@/utils/toast";
import CTAButton from "@/components/lib/CTAButton";
+import System from "@/models/system";
export default function AdminSystem() {
const [saving, setSaving] = useState(false);
@@ -13,6 +14,11 @@ export default function AdminSystem() {
enabled: false,
limit: 10,
});
+ const [canLoginWithGoogle, setCanLoginWithGoogle] = useState({
+ enabled: false,
+ clientId: null,
+ allowedDomain: null,
+ });
const handleSubmit = async (e) => {
e.preventDefault();
@@ -21,7 +27,14 @@ export default function AdminSystem() {
users_can_delete_workspaces: canDelete,
limit_user_messages: messageLimit.enabled,
message_limit: messageLimit.limit,
+ users_can_login_with_google: canLoginWithGoogle.enabled,
+ allowed_domain: canLoginWithGoogle.allowedDomain,
});
+ if (canLoginWithGoogle.enabled && canLoginWithGoogle.clientId) {
+ await System.updateSystem({
+ GoogleAuthClientId: canLoginWithGoogle.clientId,
+ });
+ }
setSaving(false);
setHasChanges(false);
showToast("System preferences updated successfully.", "success");
@@ -36,6 +49,12 @@ export default function AdminSystem() {
enabled: settings.limit_user_messages,
limit: settings.message_limit,
});
+ setCanLoginWithGoogle({
+ ...canLoginWithGoogle,
+ enabled: settings.users_can_login_with_google,
+ clientId: settings.users_can_login_with_google ? "*".repeat(20) : "",
+ allowedDomain: settings.allowed_domain,
+ });
}
fetchSettings();
}, []);
@@ -148,6 +167,87 @@ export default function AdminSystem() {
)}
+
+
+
+
+ Users can login with Google
+
+
+ Enable this option if you want users to be able to log in using
+ their Google accounts. You can restrict access to users with
+ emails from your organization's domain.
+
+
+
+
+
+ {canLoginWithGoogle.enabled && (
+
+
+
+ e.target.blur()}
+ onChange={(e) => {
+ setCanLoginWithGoogle({
+ ...canLoginWithGoogle,
+ clientId: e.target.value,
+ });
+ }}
+ value={canLoginWithGoogle.clientId}
+ min={1}
+ max={300}
+ className="w-1/3 rounded-lg border border-stroke bg-transparent py-4 pl-6 pr-10 text-slate-200 dark:text-slate-200 outline-none focus:border-primary focus-visible:shadow-none dark:border-form-strokedark dark:bg-form-input dark:focus:border-primary"
+ />
+
+
+
+
+ Restrict access to a specific domain, or leave empty to allow
+ login with any Google account.
+
+
+ e.target.blur()}
+ onChange={(e) => {
+ setCanLoginWithGoogle({
+ ...canLoginWithGoogle,
+ allowedDomain: e.target.value,
+ });
+ }}
+ value={canLoginWithGoogle.allowedDomain}
+ min={1}
+ max={300}
+ className="w-1/3 rounded-lg border border-stroke bg-transparent py-4 pl-6 pr-10 text-slate-200 dark:text-slate-200 outline-none focus:border-primary focus-visible:shadow-none dark:border-form-strokedark dark:bg-form-input dark:focus:border-primary"
+ />
+
+
+ )}
+
diff --git a/frontend/yarn.lock b/frontend/yarn.lock
index 93bdc0884f..2a9afae295 100644
--- a/frontend/yarn.lock
+++ b/frontend/yarn.lock
@@ -538,6 +538,11 @@
picocolors "^1.0.0"
tslib "^2.6.0"
+"@react-oauth/google@^0.12.1":
+ version "0.12.1"
+ resolved "https://registry.yarnpkg.com/@react-oauth/google/-/google-0.12.1.tgz#b76432c3a525e9afe076f787d2ded003fcc1bee9"
+ integrity sha512-qagsy22t+7UdkYAiT5ZhfM4StXi9PPNvw0zuwNmabrWyMKddczMtBIOARflbaIj+wHiQjnMAsZmzsUYuXeyoSg==
+
"@remix-run/router@1.14.1":
version "1.14.1"
resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.14.1.tgz#6d2dd03d52e604279c38911afc1079d58c50a755"
diff --git a/server/.env.example b/server/.env.example
index f51d617718..d9f387e0fb 100644
--- a/server/.env.example
+++ b/server/.env.example
@@ -232,3 +232,9 @@ TTS_PROVIDER="native"
#------ Bing Search ----------- https://portal.azure.com/
# AGENT_BING_SEARCH_API_KEY=
+
+###########################################
+######## SOCIAL PROVIDERS KEYS ###############
+###########################################
+
+# GOOGLE_AUTH_CLIENT_ID=
diff --git a/server/endpoints/admin.js b/server/endpoints/admin.js
index 59d645447e..79f4f7b923 100644
--- a/server/endpoints/admin.js
+++ b/server/endpoints/admin.js
@@ -355,6 +355,12 @@ function adminEndpoints(app) {
?.value,
[]
) || [],
+ users_can_login_with_google:
+ (await SystemSettings.get({ label: "users_can_login_with_google" }))
+ ?.value === "true",
+ allowed_domain: (
+ await SystemSettings.get({ label: "allowed_domain" })
+ )?.value,
custom_app_name:
(await SystemSettings.get({ label: "custom_app_name" }))?.value ||
null,
diff --git a/server/endpoints/system.js b/server/endpoints/system.js
index 6ab30c5c10..d0a717f89a 100644
--- a/server/endpoints/system.js
+++ b/server/endpoints/system.js
@@ -50,6 +50,7 @@ const {
resetPassword,
generateRecoveryCodes,
} = require("../utils/PasswordRecovery");
+const { SocialProvider } = require("../utils/socialProviders");
const { SlashCommandPresets } = require("../models/slashCommandsPresets");
function systemEndpoints(app) {
@@ -130,6 +131,24 @@ function systemEndpoints(app) {
return;
}
+ // Users who created their account with a social provider do not have a password. So they can only log in with the social provider.
+ if (existingUser.use_social_provider) {
+ await EventLogs.logEvent(
+ "failed_login_user_use_social_provider",
+ {
+ ip: request.ip || "Unknown IP",
+ username: username || "Unknown user",
+ },
+ existingUser?.id
+ );
+ response.status(200).json({
+ user: null,
+ valid: false,
+ token: null,
+ message: "[005] Invalid login credentials.",
+ });
+ return;
+ }
if (!bcrypt.compareSync(String(password), existingUser.password)) {
await EventLogs.logEvent(
"failed_login_invalid_password",
@@ -246,6 +265,122 @@ function systemEndpoints(app) {
}
});
+ app.post("/social-login/:provider", async (request, response) => {
+ try {
+ if (!(await SystemSettings.isMultiUserMode())) {
+ response.status(200).json({
+ user: null,
+ valid: false,
+ token: null,
+ message: "Social login is available on multi-user mode",
+ });
+ return;
+ }
+
+ const { provider } = request.params;
+ const data = reqBody(request);
+ const socialProvider = new SocialProvider(provider);
+ const { username } = await socialProvider.login(data);
+ let user = await User.get({ username: String(username) });
+
+ const allowedDomain = (
+ await SystemSettings.get({ label: "allowed_domain" })
+ )?.value;
+ if (allowedDomain && allowedDomain !== username.split("@")[1]) {
+ await EventLogs.logEvent(
+ "failed_login_domain_not_allowed",
+ {
+ ip: request.ip || "Unknown IP",
+ username: username || "Unknown user",
+ },
+ user?.id
+ );
+ response.status(200).json({
+ user: null,
+ valid: false,
+ token: null,
+ message: "[006] Domain not allowed by admin.",
+ });
+ return;
+ }
+
+ if (!user) {
+ const { user: newUser, error } = await User.createWithSocialProvider({
+ username: String(username),
+ });
+ if (!newUser) {
+ await EventLogs.logEvent(
+ "failed_login_error_creating_user",
+ {
+ ip: request.ip || "Unknown IP",
+ username: username || "Unknown user",
+ },
+ existingUser?.id
+ );
+ response.status(200).json({
+ user: null,
+ valid: false,
+ token: null,
+ message: error,
+ });
+ return;
+ }
+ await EventLogs.logEvent(
+ "user_created",
+ {
+ userName: newUser.username,
+ createdBy: newUser.username,
+ },
+ newUser.id
+ );
+ user = newUser;
+ }
+
+ if (user.suspended) {
+ await EventLogs.logEvent(
+ "failed_login_account_suspended",
+ {
+ ip: request.ip || "Unknown IP",
+ username: username || "Unknown user",
+ },
+ user?.id
+ );
+ response.status(200).json({
+ user: null,
+ valid: false,
+ token: null,
+ message: "[004] Account suspended by admin.",
+ });
+ return;
+ }
+
+ await Telemetry.sendTelemetry(
+ "login_event",
+ { multiUserMode: false },
+ user?.id
+ );
+
+ await EventLogs.logEvent(
+ "login_event",
+ {
+ ip: request.ip || "Unknown IP",
+ username: user.username || "Unknown user",
+ },
+ user?.id
+ );
+
+ response.status(200).json({
+ valid: true,
+ user: user,
+ token: makeJWT({ id: user.id, username: user.username }, "30d"),
+ message: null,
+ });
+ return;
+ } catch (e) {
+ console.log(e.message, e);
+ response.sendStatus(500).end();
+ }
+ });
app.post(
"/system/recover-account",
[isMultiUserSetup],
diff --git a/server/models/systemSettings.js b/server/models/systemSettings.js
index ac05231984..4cf3c1e9df 100644
--- a/server/models/systemSettings.js
+++ b/server/models/systemSettings.js
@@ -26,6 +26,8 @@ const SystemSettings = {
"text_splitter_chunk_overlap",
"agent_search_provider",
"default_agent_skills",
+ "users_can_login_with_google",
+ "allowed_domain",
"agent_sql_connections",
"custom_app_name",
],
@@ -176,6 +178,15 @@ const SystemSettings = {
AgentGoogleSearchEngineKey: process.env.AGENT_GSE_KEY || null,
AgentSerperApiKey: process.env.AGENT_SERPER_DEV_KEY || null,
AgentBingSearchApiKey: process.env.AGENT_BING_SEARCH_API_KEY || null,
+
+ // --------------------------------------------------------
+ // Social Providers
+ // --------------------------------------------------------
+ GoogleAuthClientId:
+ (await this.get({ label: "users_can_login_with_google" }))?.value ===
+ "true"
+ ? process.env.GOOGLE_AUTH_CLIENT_ID
+ : null,
};
},
diff --git a/server/models/user.js b/server/models/user.js
index f08548afb7..3062072e22 100644
--- a/server/models/user.js
+++ b/server/models/user.js
@@ -62,6 +62,21 @@ const User = {
}
},
+ createWithSocialProvider: async function ({ username }) {
+ try {
+ const user = await prisma.users.create({
+ data: {
+ username,
+ use_social_provider: true,
+ },
+ });
+ return { user, error: null };
+ } catch (error) {
+ console.error("FAILED TO CREATE USER.", error.message);
+ return { user: null, error: error.message };
+ }
+ },
+
// Log the changes to a user object, but omit sensitive fields
// that are not meant to be logged.
loggedChanges: function (updates, prev = {}) {
diff --git a/server/package.json b/server/package.json
index 4f9954700e..8e178bcb3c 100644
--- a/server/package.json
+++ b/server/package.json
@@ -49,6 +49,7 @@
"express-ws": "^5.0.2",
"extract-json-from-string": "^1.0.1",
"extract-zip": "^2.0.1",
+ "google-auth-library": "^9.9.0",
"graphql": "^16.7.1",
"joi": "^17.11.0",
"joi-password-complexity": "^5.2.0",
diff --git a/server/prisma/migrations/20240509170128_init/migration.sql b/server/prisma/migrations/20240509170128_init/migration.sql
new file mode 100644
index 0000000000..74fd5b302b
--- /dev/null
+++ b/server/prisma/migrations/20240509170128_init/migration.sql
@@ -0,0 +1,20 @@
+-- RedefineTables
+PRAGMA foreign_keys=OFF;
+CREATE TABLE "new_users" (
+ "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
+ "username" TEXT,
+ "password" TEXT,
+ "use_social_provider" BOOLEAN NOT NULL DEFAULT false,
+ "pfpFilename" TEXT,
+ "role" TEXT NOT NULL DEFAULT 'default',
+ "suspended" INTEGER NOT NULL DEFAULT 0,
+ "seen_recovery_codes" BOOLEAN DEFAULT false,
+ "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "lastUpdatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+INSERT INTO "new_users" ("createdAt", "id", "lastUpdatedAt", "password", "pfpFilename", "role", "seen_recovery_codes", "suspended", "username") SELECT "createdAt", "id", "lastUpdatedAt", "password", "pfpFilename", "role", "seen_recovery_codes", "suspended", "username" FROM "users";
+DROP TABLE "users";
+ALTER TABLE "new_users" RENAME TO "users";
+CREATE UNIQUE INDEX "users_username_key" ON "users"("username");
+PRAGMA foreign_key_check;
+PRAGMA foreign_keys=ON;
diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma
index 0ded65be63..52152d4e97 100644
--- a/server/prisma/schema.prisma
+++ b/server/prisma/schema.prisma
@@ -58,7 +58,8 @@ model system_settings {
model users {
id Int @id @default(autoincrement())
username String? @unique
- password String
+ password String?
+ use_social_provider Boolean @default(false)
pfpFilename String?
role String @default("default")
suspended Int @default(0)
diff --git a/server/utils/helpers/updateENV.js b/server/utils/helpers/updateENV.js
index d5cdc68f2e..ab5f9293b6 100644
--- a/server/utils/helpers/updateENV.js
+++ b/server/utils/helpers/updateENV.js
@@ -404,6 +404,12 @@ const KEY_MAPPING = {
checks: [],
},
+ // Social Providers
+ GoogleAuthClientId: {
+ envKey: "GOOGLE_AUTH_CLIENT_ID",
+ checks: [isNotEmpty],
+ },
+
// TTS/STT Integration ENVS
TextToSpeechProvider: {
envKey: "TTS_PROVIDER",
diff --git a/server/utils/socialProviders/index.js b/server/utils/socialProviders/index.js
new file mode 100644
index 0000000000..a12e68bd9e
--- /dev/null
+++ b/server/utils/socialProviders/index.js
@@ -0,0 +1,56 @@
+const { OAuth2Client } = require("google-auth-library");
+
+class SocialProviderAbstract {
+ constructor() {
+ if (this.constructor === SocialProviderAbstract) {
+ throw new Error("Cannot instantiate abstract class");
+ }
+ }
+ async login(data) {
+ throw new Error("Method 'login' must be implemented");
+ }
+}
+
+class GoogleProvider extends SocialProviderAbstract {
+ constructor() {
+ super();
+ this.client = new OAuth2Client();
+ }
+ async login({ credential }) {
+ const { email, name, picture, error } = await this._verify(credential);
+ if (error) {
+ throw new Error("Google Sign-In failed");
+ }
+ return { username: email };
+ }
+
+ async _verify(token) {
+ try {
+ const ticket = await this.client.verifyIdToken({
+ idToken: token,
+ audience: process.env.GOOGLE_AUTH_CLIENT_ID,
+ });
+ const userData = ticket.getPayload();
+ return { ...userData, error: null };
+ } catch (error) {
+ return { error: error.message };
+ }
+ }
+}
+
+const providers = {
+ google: new GoogleProvider(),
+ // Add here new providers
+};
+
+class SocialProvider {
+ constructor(provider) {
+ this.instance = providers[provider];
+ }
+
+ async login(data) {
+ return await this.instance.login(data);
+ }
+}
+
+module.exports.SocialProvider = SocialProvider;
diff --git a/server/yarn.lock b/server/yarn.lock
index d274e574f3..dd64250c32 100644
--- a/server/yarn.lock
+++ b/server/yarn.lock
@@ -3200,6 +3200,17 @@ gaxios@^5.0.0, gaxios@^5.0.1:
is-stream "^2.0.0"
node-fetch "^2.6.9"
+gaxios@^6.0.0, gaxios@^6.1.1:
+ version "6.5.0"
+ resolved "https://registry.yarnpkg.com/gaxios/-/gaxios-6.5.0.tgz#21bc20e24f21189ce8907079b56205ff9fd2c0d7"
+ integrity sha512-R9QGdv8j4/dlNoQbX3hSaK/S0rkMijqjVvW3YM06CoBdbU/VdKd159j4hePpng0KuE6Lh6JJ7UdmVGJZFcAG1w==
+ dependencies:
+ extend "^3.0.2"
+ https-proxy-agent "^7.0.1"
+ is-stream "^2.0.0"
+ node-fetch "^2.6.9"
+ uuid "^9.0.1"
+
gcp-metadata@^5.3.0:
version "5.3.0"
resolved "https://registry.yarnpkg.com/gcp-metadata/-/gcp-metadata-5.3.0.tgz#6f45eb473d0cb47d15001476b48b663744d25408"
@@ -3208,6 +3219,13 @@ gcp-metadata@^5.3.0:
gaxios "^5.0.0"
json-bigint "^1.0.0"
+gcp-metadata@^6.1.0:
+ version "6.1.0"
+ resolved "https://registry.yarnpkg.com/gcp-metadata/-/gcp-metadata-6.1.0.tgz#9b0dd2b2445258e7597f2024332d20611cbd6b8c"
+ integrity sha512-Jh/AIwwgaxan+7ZUUmRLCjtchyDiqh4KjBJ5tW3plBZb5iL/BPcso8A5DlzeD9qlw0duCamnNdpFjxwaT0KyKg==
+ dependencies:
+ gaxios "^6.0.0"
+ json-bigint "^1.0.0"
generate-function@^2.3.1:
version "2.3.1"
resolved "https://registry.yarnpkg.com/generate-function/-/generate-function-2.3.1.tgz#f069617690c10c868e73b8465746764f97c3479f"
@@ -3318,6 +3336,18 @@ google-auth-library@^8.0.2:
jws "^4.0.0"
lru-cache "^6.0.0"
+google-auth-library@^9.9.0:
+ version "9.9.0"
+ resolved "https://registry.yarnpkg.com/google-auth-library/-/google-auth-library-9.9.0.tgz#71488ef444335ff4ea91611729b88c0f57625fdf"
+ integrity sha512-9l+zO07h1tDJdIHN74SpnWIlNR+OuOemXlWJlLP9pXy6vFtizgpEzMuwJa4lqY9UAdiAv5DVd5ql0Am916I+aA==
+ dependencies:
+ base64-js "^1.3.0"
+ ecdsa-sig-formatter "^1.0.11"
+ gaxios "^6.1.1"
+ gcp-metadata "^6.1.0"
+ gtoken "^7.0.0"
+ jws "^4.0.0"
+
google-p12-pem@^4.0.0:
version "4.0.1"
resolved "https://registry.yarnpkg.com/google-p12-pem/-/google-p12-pem-4.0.1.tgz#82841798253c65b7dc2a4e5fe9df141db670172a"
@@ -3378,6 +3408,14 @@ gtoken@^6.1.0:
google-p12-pem "^4.0.0"
jws "^4.0.0"
+gtoken@^7.0.0:
+ version "7.1.0"
+ resolved "https://registry.yarnpkg.com/gtoken/-/gtoken-7.1.0.tgz#d61b4ebd10132222817f7222b1e6064bd463fc26"
+ integrity sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==
+ dependencies:
+ gaxios "^6.0.0"
+ jws "^4.0.0"
+
guid-typescript@^1.0.9:
version "1.0.9"
resolved "https://registry.yarnpkg.com/guid-typescript/-/guid-typescript-1.0.9.tgz#e35f77003535b0297ea08548f5ace6adb1480ddc"
@@ -3513,7 +3551,7 @@ https-proxy-agent@^5.0.0:
agent-base "6"
debug "4"
-https-proxy-agent@^7.0.0:
+https-proxy-agent@^7.0.0, https-proxy-agent@^7.0.1:
version "7.0.4"
resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-7.0.4.tgz#8e97b841a029ad8ddc8731f26595bad868cb4168"
integrity sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg==