diff --git a/.env.example b/.env.example index e1d5a57a7d..c43cf05837 100644 --- a/.env.example +++ b/.env.example @@ -187,6 +187,9 @@ MEILI_MASTER_KEY=DrhYf7zENyR6AlUCKmnz0eYASOQdl6zxH7s7MKFSfFCt # Allow Public Registration ALLOW_REGISTRATION=true +# Allow Social Registration +ALLOW_SOCIAL_LOGIN=false + # JWT Secrets JWT_SECRET=secret JWT_REFRESH_SECRET=secret @@ -219,12 +222,20 @@ OPENID_IMAGE_URL= SESSION_EXPIRY=(1000 * 60 * 60 * 24) * 7 # Github: -# Get the Client ID and Secret from your Github Application +# Get the Client ID and Secret from your Discord Application +# Add your Discord Client ID and Client Secret here: + +GITHUB_CLIENT_ID=your_client_id +GITHUB_CLIENT_SECRET=your_client_secret +GITHUB_CALLBACK_URL=/oauth/github/callback # this should be the same for everyone + +# Discord: +# Get the Client ID and Secret from your Discord Application # Add your Github Client ID and Client Secret here: -GITHUB_CLIENT_ID= -GITHUB_CLIENT_SECRET= -GITHUB_CALLBACK_URL=/oauth/github/callback +DISCORD_CLIENT_ID=your_client_id +DISCORD_CLIENT_SECRET=your_client_secret +DISCORD_CALLBACK_URL=/oauth/discord/callback # this should be the same for everyone ########################### # Application Domains diff --git a/api/models/User.js b/api/models/User.js index 011291c346..25703b421c 100644 --- a/api/models/User.js +++ b/api/models/User.js @@ -75,6 +75,11 @@ const userSchema = mongoose.Schema( unique: true, sparse: true }, + discordId: { + type: String, + unique: true, + sparse: true + }, plugins: { type: Array, default: [] diff --git a/api/server/index.js b/api/server/index.js index 947edfdb86..eeb762dd9b 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -45,6 +45,9 @@ config.validate(); // Validate the config if (process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET) { require('../strategies/githubStrategy'); } + if (process.env.DISCORD_CLIENT_ID && process.env.DISCORD_CLIENT_SECRET) { + require('../strategies/discordStrategy'); + } if (process.env.OPENID_CLIENT_ID && process.env.OPENID_CLIENT_SECRET && process.env.OPENID_ISSUER && process.env.OPENID_SCOPE && process.env.OPENID_SESSION_SECRET) { diff --git a/api/server/routes/__tests__/config.spec.js b/api/server/routes/__tests__/config.spec.js index 928fc2208a..c3f0ba7782 100644 --- a/api/server/routes/__tests__/config.spec.js +++ b/api/server/routes/__tests__/config.spec.js @@ -16,8 +16,11 @@ afterEach(() => { delete process.env.OPENID_AUTH_URL; delete process.env.GITHUB_CLIENT_ID; delete process.env.GITHUB_CLIENT_SECRET; + delete process.env.DISCORD_CLIENT_ID; + delete process.env.DISCORD_CLIENT_SECRET; delete process.env.DOMAIN_SERVER; delete process.env.ALLOW_REGISTRATION; + delete process.env.ALLOW_SOCIAL_LOGIN; }); //TODO: This works/passes locally but http request tests fail with 404 in CI. Need to figure out why. @@ -36,8 +39,11 @@ describe.skip('GET /', () => { process.env.OPENID_AUTH_URL= 'http://test-server.com'; process.env.GITHUB_CLIENT_ID = 'Test Github client Id'; process.env.GITHUB_CLIENT_SECRET= 'Test Github client Secret'; + process.env.DISCORD_CLIENT_ID = 'Test Discord client Id'; + process.env.DISCORD_CLIENT_SECRET= 'Test Discord client Secret'; process.env.DOMAIN_SERVER = 'http://test-server.com'; process.env.ALLOW_REGISTRATION = 'true'; + process.env.ALLOW_SOCIAL_LOGIN = 'true'; const response = await request(app).get('/'); @@ -49,8 +55,10 @@ describe.skip('GET /', () => { openidLabel: 'Test OpenID', openidImageUrl: 'http://test-server.com', githubLoginEnabled: true, + discordLoginEnabled: true, serverDomain: 'http://test-server.com', registrationEnabled: 'true', + socialLoginEnabled: 'true', }); }); }); diff --git a/api/server/routes/config.js b/api/server/routes/config.js index 115fffa359..bbb5906035 100644 --- a/api/server/routes/config.js +++ b/api/server/routes/config.js @@ -12,10 +12,24 @@ router.get('/', async function (req, res) { const openidLabel = process.env.OPENID_BUTTON_LABEL || 'Login with OpenID'; const openidImageUrl = process.env.OPENID_IMAGE_URL; const githubLoginEnabled = !!process.env.GITHUB_CLIENT_ID && !!process.env.GITHUB_CLIENT_SECRET; + const discordLoginEnabled = !!process.env.DISCORD_CLIENT_ID && !!process.env.DISCORD_CLIENT_SECRET; const serverDomain = process.env.DOMAIN_SERVER || 'http://localhost:3080'; const registrationEnabled = process.env.ALLOW_REGISTRATION === 'true'; + const socialLoginEnabled = process.env.ALLOW_SOCIAL_LOGIN === 'true'; + + return res.status(200).send({ + appTitle, + googleLoginEnabled, + openidLoginEnabled, + openidLabel, + openidImageUrl, + githubLoginEnabled, + discordLoginEnabled, + serverDomain, + registrationEnabled, + socialLoginEnabled + }); - return res.status(200).send({appTitle, googleLoginEnabled, openidLoginEnabled, openidLabel, openidImageUrl, githubLoginEnabled, serverDomain, registrationEnabled}); } catch (err) { console.error(err); return res.status(500).send({error: err.message}); diff --git a/api/server/routes/oauth.js b/api/server/routes/oauth.js index 8289232e26..d57ceb0516 100644 --- a/api/server/routes/oauth.js +++ b/api/server/routes/oauth.js @@ -115,4 +115,32 @@ router.get( } ); + +router.get( + '/discord', + passport.authenticate('discord', { + scope: ['identify', 'email'], + session: false + }) +); + +router.get( + '/discord/callback', + passport.authenticate('discord', { + failureRedirect: `${domains.client}/login`, + failureMessage: true, + session: false, + scope: ['identify', 'email'] + }), + (req, res) => { + const token = req.user.generateToken(); + res.cookie('token', token, { + expires: new Date(Date.now() + eval(process.env.SESSION_EXPIRY)), + httpOnly: false, + secure: isProduction + }); + res.redirect(domains.client); + } +); + module.exports = router; diff --git a/api/strategies/discordStrategy.js b/api/strategies/discordStrategy.js new file mode 100644 index 0000000000..428279ebfb --- /dev/null +++ b/api/strategies/discordStrategy.js @@ -0,0 +1,57 @@ +const passport = require('passport'); +const { Strategy: DiscordStrategy } = require('passport-discord'); +const User = require('../models/User'); +const config = require('../../config/loader'); +const domains = config.domains; + +const discordLogin = new DiscordStrategy( + { + clientID: process.env.DISCORD_CLIENT_ID, + clientSecret: process.env.DISCORD_CLIENT_SECRET, + callbackURL: `${domains.server}${process.env.DISCORD_CALLBACK_URL}`, + scope: ['identify', 'email'] // Request scopes + }, + async (accessToken, refreshToken, profile, cb) => { + try { + const discordId = profile.id; + const email = profile.email; + + const existingUser = await User.findOne({ discordId }); + if (existingUser) { + return cb(null, existingUser); + } + + const userWithEmail = await User.findOne({ email }); + if (userWithEmail) { + userWithEmail.discordId = discordId; + await userWithEmail.save(); + return cb(null, userWithEmail); + } + + let avatarURL; + if (profile.avatar) { + const format = profile.avatar.startsWith('a_') ? 'gif' : 'png'; + avatarURL = `https://cdn.discordapp.com/avatars/${profile.id}/${profile.avatar}.${format}`; + } else { + const defaultAvatarNum = Number(profile.discriminator) % 5; + avatarURL = `https://cdn.discordapp.com/embed/avatars/${defaultAvatarNum}.png`; + } + + const newUser = await User.create({ + provider: 'discord', + discordId, + username: profile.username, + email, + name: profile.global_name, + avatar: avatarURL + }); + + cb(null, newUser); + } catch (err) { + console.error(err); + cb(err); + } + } +); + +passport.use(discordLogin); diff --git a/api/strategies/githubStrategy.js b/api/strategies/githubStrategy.js index d377b61833..57578a26bb 100644 --- a/api/strategies/githubStrategy.js +++ b/api/strategies/githubStrategy.js @@ -33,7 +33,7 @@ const githubLogin = new GitHubStrategy( email, emailVerified: profile.emails[0].verified, name: profile.displayName, - avatar: profile.photos[0].value + avatar: profile.photos[0].value }).save(); cb(null, newUser); diff --git a/client/src/components/Auth/Login.tsx b/client/src/components/Auth/Login.tsx index 95a08c7e00..2999c6a220 100644 --- a/client/src/components/Auth/Login.tsx +++ b/client/src/components/Auth/Login.tsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import React, { useEffect } from 'react'; import LoginForm from './LoginForm'; import { useAuthContext } from '~/hooks/AuthContext'; import { useNavigate } from 'react-router-dom'; @@ -7,6 +7,7 @@ import { useRecoilValue } from 'recoil'; import store from '~/store'; import { localize } from '~/localization/Translation'; import { useGetStartupConfig } from '@librechat/data-provider'; +import { GoogleIcon, OpenIDIcon, GithubIcon, DiscordIcon } from '~/components' function Login() { const { login, error, isAuthenticated } = useAuthContext(); @@ -21,6 +22,7 @@ function Login() { navigate('/chat/new'); } }, [isAuthenticated, navigate]); + return (
@@ -43,46 +45,26 @@ function Login() {

)} - {startupConfig?.googleLoginEnabled && ( + {startupConfig?.socialLoginEnabled && ( +
+
Or
+
+ )} + {startupConfig?.googleLoginEnabled && startupConfig?.socialLoginEnabled && ( <> -
-
Or
-
+
- - - - - - + href={`${startupConfig.serverDomain}/oauth/google`}> +

{localize(lang, 'com_auth_google_login')}

)} - {startupConfig?.openidLoginEnabled && ( + {startupConfig?.openidLoginEnabled && startupConfig?.socialLoginEnabled && ( <>
) : ( - - - + )}

{startupConfig.openidLabel}

)} - {startupConfig?.githubLoginEnabled && ( - <> -
-
Or
-
-
- - - - -

Login with GitHub

-
-
- -)} + {startupConfig?.githubLoginEnabled && startupConfig?.socialLoginEnabled && ( + <> +
+ + +

Login with Github

+
+
+ + )} + {startupConfig?.discordLoginEnabled && startupConfig?.socialLoginEnabled && ( + <> + +
+ + +

Login with Discord

+
+
+ + )}
); -} - +}; export default Login; diff --git a/client/src/components/Auth/Registration.tsx b/client/src/components/Auth/Registration.tsx index 29a9ec353f..83ef456330 100644 --- a/client/src/components/Auth/Registration.tsx +++ b/client/src/components/Auth/Registration.tsx @@ -9,6 +9,7 @@ import { TRegisterUser, useGetStartupConfig } from '@librechat/data-provider'; +import { GoogleIcon, OpenIDIcon, GithubIcon, DiscordIcon } from '~/components' function Registration() { const navigate = useNavigate(); @@ -283,51 +284,28 @@ function Registration() { {localize(lang, 'com_auth_login')}

- {startupConfig?.googleLoginEnabled && ( + {startupConfig?.socialLoginEnabled && ( +
+
Or
+
+ )} + {startupConfig?.googleLoginEnabled && startupConfig?.socialLoginEnabled && ( <> -
-
Or
-
- - - - - - + href={`${startupConfig.serverDomain}/oauth/google`}> +

{localize(lang, 'com_auth_google_login')}

)} - {startupConfig?.openidLoginEnabled && ( + {startupConfig?.openidLoginEnabled && startupConfig?.socialLoginEnabled && ( <> -
-
Or
-
+
) : ( - - - + )}

{startupConfig.openidLabel}

)} - {startupConfig?.githubLoginEnabled && ( - <> -
-
Or
-
-
- - - - -

Login with GitHub

-
-
- -)} + {startupConfig?.githubLoginEnabled && startupConfig?.socialLoginEnabled && ( + <> +
+ + +

Login with Github

+
+
+ + )} + {startupConfig?.discordLoginEnabled && startupConfig?.socialLoginEnabled && ( + <> + +
+ + +

Login with Discord

+
+
+ + )} ); diff --git a/client/src/components/Auth/__tests__/Login.spec.tsx b/client/src/components/Auth/__tests__/Login.spec.tsx index 6c6d4c9edd..b142367546 100644 --- a/client/src/components/Auth/__tests__/Login.spec.tsx +++ b/client/src/components/Auth/__tests__/Login.spec.tsx @@ -27,7 +27,9 @@ const setup = ({ openidLabel: 'Test OpenID', openidImageUrl: 'http://test-server.com', githubLoginEnabled: true, + discordLoginEnabled: true, registrationEnabled: true, + socialLoginEnabled: true, serverDomain: 'mock-server' } } diff --git a/client/src/components/Auth/__tests__/Registration.spec.tsx b/client/src/components/Auth/__tests__/Registration.spec.tsx index b720116fd7..f0bc8fa51f 100644 --- a/client/src/components/Auth/__tests__/Registration.spec.tsx +++ b/client/src/components/Auth/__tests__/Registration.spec.tsx @@ -27,7 +27,9 @@ const setup = ({ openidLabel: 'Test OpenID', openidImageUrl: 'http://test-server.com', githubLoginEnabled: true, + discordLoginEnabled: true, registrationEnabled: true, + socialLoginEnabled: true, serverDomain: 'mock-server' } } diff --git a/client/src/components/svg/DiscordIcon.jsx b/client/src/components/svg/DiscordIcon.jsx new file mode 100644 index 0000000000..1c1732f63b --- /dev/null +++ b/client/src/components/svg/DiscordIcon.jsx @@ -0,0 +1,12 @@ +import React from 'react'; + +export default function DiscordIcon() { + return ( + + + + + ); +} diff --git a/client/src/components/svg/GithubIcon.jsx b/client/src/components/svg/GithubIcon.jsx new file mode 100644 index 0000000000..e3a83cc73f --- /dev/null +++ b/client/src/components/svg/GithubIcon.jsx @@ -0,0 +1,12 @@ +import React from 'react'; + +export default function GithubIcon() { + return ( + + + + ); +} diff --git a/client/src/components/svg/GoogleIcon.jsx b/client/src/components/svg/GoogleIcon.jsx new file mode 100644 index 0000000000..ef7ec6c8c9 --- /dev/null +++ b/client/src/components/svg/GoogleIcon.jsx @@ -0,0 +1,29 @@ +import React from 'react'; + +export default function GoogleIcon() { + return ( + + + + + + + ); +} \ No newline at end of file diff --git a/client/src/components/svg/OpenIDIcon.jsx b/client/src/components/svg/OpenIDIcon.jsx new file mode 100644 index 0000000000..ee29683eef --- /dev/null +++ b/client/src/components/svg/OpenIDIcon.jsx @@ -0,0 +1,11 @@ +import React from 'react'; + +export default function OpenIDIcon() { + return ( + + + + ); +} diff --git a/client/src/components/svg/index.ts b/client/src/components/svg/index.ts index a5361ff25f..1d6c9ca174 100644 --- a/client/src/components/svg/index.ts +++ b/client/src/components/svg/index.ts @@ -4,4 +4,8 @@ export { default as BingIcon } from './BingIcon'; export { default as CogIcon } from './CogIcon'; export { default as Spinner } from './Spinner'; export { default as MessagesSquared } from './MessagesSquared'; -export { default as StopGeneratingIcon } from './StopGeneratingIcon'; \ No newline at end of file +export { default as StopGeneratingIcon } from './StopGeneratingIcon'; +export { default as GoogleIcon } from './GoogleIcon'; +export { default as OpenIDIcon } from './OpenIDIcon'; +export { default as GithubIcon } from './GithubIcon'; +export { default as DiscordIcon } from './DiscordIcon'; \ No newline at end of file diff --git a/docs/install/user_auth_system.md b/docs/install/user_auth_system.md index 12298c188b..5733ec0b01 100644 --- a/docs/install/user_auth_system.md +++ b/docs/install/user_auth_system.md @@ -77,9 +77,29 @@ OPENID_CALLBACK_URL=/oauth/openid/callback 5. Remove the Active checkbox in the Webhook section 6. Save changes and generate a Client Secret 7. In the Permissions & events tab select, open the Account Permissions and set Email addresses to Read-only -8. Put the Client ID and Client Secret in the .env file +8. Put the Client ID and Client Secret in the .env file: +``` +GITHUB_CLIENT_ID=your_client_id +GITHUB_CLIENT_SECRET=your_client_secret +GITHUB_CALLBACK_URL=/oauth/github/callback # this should be the same for everyone +``` 9. Save the .env file +--- +## How to Set Up Discord Authentication + +1. Go to [Discord Developer Portal](https://discord.com/developers) +2. Create a new Application and give it a name +4. In the OAuth2 general settings add a redirect URL and set it as "[Your DOMAIN_CLIENT](https://github.com/danny-avila/LibreChat/blob/main/.env.example#L219)/oauth/discord/callback" (example: http://localhost:3080/oauth/discord/callback) +5. in the Default Authorization Link set applications.commands +6. Save changes and reset the Client Secret +7. Put the Client ID and Client Secret in the .env file: +``` +DISCORD_CLIENT_ID=your_client_id +DISCORD_CLIENT_SECRET=your_client_secret +DISCORD_CALLBACK_URL=/oauth/discord/callback # this should be the same for everyone +``` +8. Save the .env file --- ## **Email and Password Reset** diff --git a/package-lock.json b/package-lock.json index 8df0a52c0b..209faa2056 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "dependencies": { "axios": "^1.4.0", "passport": "^0.6.0", + "passport-discord": "^0.1.4", "passport-github2": "^0.1.12" }, "devDependencies": { @@ -20404,6 +20405,14 @@ "url": "https://github.com/sponsors/jaredhanson" } }, + "node_modules/passport-discord": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/passport-discord/-/passport-discord-0.1.4.tgz", + "integrity": "sha512-VJWPYqSOmh7SaCLw/C+k1ZqCzJnn2frrmQRx1YrcPJ3MQ+Oa31XclbbmqFICSvl8xv3Fqd6YWQ4H4p1MpIN9rA==", + "dependencies": { + "passport-oauth2": "^1.5.0" + } + }, "node_modules/passport-facebook": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/passport-facebook/-/passport-facebook-3.0.0.tgz", @@ -41105,6 +41114,14 @@ "utils-merge": "^1.0.1" } }, + "passport-discord": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/passport-discord/-/passport-discord-0.1.4.tgz", + "integrity": "sha512-VJWPYqSOmh7SaCLw/C+k1ZqCzJnn2frrmQRx1YrcPJ3MQ+Oa31XclbbmqFICSvl8xv3Fqd6YWQ4H4p1MpIN9rA==", + "requires": { + "passport-oauth2": "^1.5.0" + } + }, "passport-facebook": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/passport-facebook/-/passport-facebook-3.0.0.tgz", diff --git a/package.json b/package.json index 947529cc51..fec617b50a 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "dependencies": { "axios": "^1.4.0", "passport": "^0.6.0", + "passport-discord": "^0.1.4", "passport-github2": "^0.1.12" }, "devDependencies": { diff --git a/packages/data-provider/src/types.ts b/packages/data-provider/src/types.ts index 7faa1e207a..be2abe9f32 100644 --- a/packages/data-provider/src/types.ts +++ b/packages/data-provider/src/types.ts @@ -252,8 +252,10 @@ export type TStartupConfig = { githubLoginEnabled: boolean; openidLabel: string; openidImageUrl: string; + discordLoginEnabled: boolean; serverDomain: string; registrationEnabled: boolean; + socialLoginEnabled: boolean; } export type TRefreshTokenResponse = {