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 (
{localize(lang, 'com_auth_google_login')}
{localize(lang, 'com_auth_google_login')}