feat: facebook login (#820)

* Facebook strategy

* Update user_auth_system.md

* Update user_auth_system.md
This commit is contained in:
Marco Beretta 2023-08-25 02:10:48 +02:00 committed by GitHub
parent a569020312
commit 007d51ede1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 155 additions and 27 deletions

View file

@ -229,6 +229,13 @@ GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET= GOOGLE_CLIENT_SECRET=
GOOGLE_CALLBACK_URL=/oauth/google/callback GOOGLE_CALLBACK_URL=/oauth/google/callback
# Facebook:
# Add your Facebook Client ID and Secret here, you must register an app with Facebook to get these values
# https://developers.facebook.com/
FACEBOOK_CLIENT_ID=
FACEBOOK_CLIENT_SECRET=
FACEBOOK_CALLBACK_URL=/oauth/facebook/callback
# OpenID: # OpenID:
# See OpenID provider to get the below values # See OpenID provider to get the below values
# Create random string for OPENID_SESSION_SECRET # Create random string for OPENID_SESSION_SECRET

View file

@ -63,6 +63,11 @@ const userSchema = mongoose.Schema(
unique: true, unique: true,
sparse: true, sparse: true,
}, },
facebookId: {
type: String,
unique: true,
sparse: true,
},
openidId: { openidId: {
type: String, type: String,
unique: true, unique: true,

View file

@ -8,6 +8,8 @@ afterEach(() => {
delete process.env.APP_TITLE; delete process.env.APP_TITLE;
delete process.env.GOOGLE_CLIENT_ID; delete process.env.GOOGLE_CLIENT_ID;
delete process.env.GOOGLE_CLIENT_SECRET; delete process.env.GOOGLE_CLIENT_SECRET;
delete process.env.FACEBOOK_CLIENT_ID;
delete process.env.FACEBOOK_CLIENT_SECRET;
delete process.env.OPENID_CLIENT_ID; delete process.env.OPENID_CLIENT_ID;
delete process.env.OPENID_CLIENT_SECRET; delete process.env.OPENID_CLIENT_SECRET;
delete process.env.OPENID_ISSUER; delete process.env.OPENID_ISSUER;
@ -31,6 +33,8 @@ describe.skip('GET /', () => {
process.env.APP_TITLE = 'Test Title'; process.env.APP_TITLE = 'Test Title';
process.env.GOOGLE_CLIENT_ID = 'Test Google Client Id'; process.env.GOOGLE_CLIENT_ID = 'Test Google Client Id';
process.env.GOOGLE_CLIENT_SECRET = 'Test Google Client Secret'; process.env.GOOGLE_CLIENT_SECRET = 'Test Google Client Secret';
process.env.FACEBOOK_CLIENT_ID = 'Test Facebook Client Id';
process.env.FACEBOOK_CLIENT_SECRET = 'Test Facebook Client Secret';
process.env.OPENID_CLIENT_ID = 'Test OpenID Id'; process.env.OPENID_CLIENT_ID = 'Test OpenID Id';
process.env.OPENID_CLIENT_SECRET = 'Test OpenID Secret'; process.env.OPENID_CLIENT_SECRET = 'Test OpenID Secret';
process.env.OPENID_ISSUER = 'Test OpenID Issuer'; process.env.OPENID_ISSUER = 'Test OpenID Issuer';
@ -51,6 +55,7 @@ describe.skip('GET /', () => {
expect(response.body).toEqual({ expect(response.body).toEqual({
appTitle: 'Test Title', appTitle: 'Test Title',
googleLoginEnabled: true, googleLoginEnabled: true,
facebookLoginEnabled: true,
openidLoginEnabled: true, openidLoginEnabled: true,
openidLabel: 'Test OpenID', openidLabel: 'Test OpenID',
openidImageUrl: 'http://test-server.com', openidImageUrl: 'http://test-server.com',

View file

@ -5,6 +5,8 @@ router.get('/', async function (req, res) {
try { try {
const appTitle = process.env.APP_TITLE || 'LibreChat'; const appTitle = process.env.APP_TITLE || 'LibreChat';
const googleLoginEnabled = !!process.env.GOOGLE_CLIENT_ID && !!process.env.GOOGLE_CLIENT_SECRET; const googleLoginEnabled = !!process.env.GOOGLE_CLIENT_ID && !!process.env.GOOGLE_CLIENT_SECRET;
const facebookLoginEnabled =
!!process.env.FACEBOOK_CLIENT_ID && !!process.env.FACEBOOK_CLIENT_SECRET;
const openidLoginEnabled = const openidLoginEnabled =
!!process.env.OPENID_CLIENT_ID && !!process.env.OPENID_CLIENT_ID &&
!!process.env.OPENID_CLIENT_SECRET && !!process.env.OPENID_CLIENT_SECRET &&
@ -27,6 +29,7 @@ router.get('/', async function (req, res) {
return res.status(200).send({ return res.status(200).send({
appTitle, appTitle,
googleLoginEnabled, googleLoginEnabled,
facebookLoginEnabled,
openidLoginEnabled, openidLoginEnabled,
openidLabel, openidLabel,
openidImageUrl, openidImageUrl,

View file

@ -38,7 +38,8 @@ router.get(
router.get( router.get(
'/facebook', '/facebook',
passport.authenticate('facebook', { passport.authenticate('facebook', {
scope: ['public_profile', 'email'], scope: ['public_profile'],
profileFields: ['id', 'email', 'name'],
session: false, session: false,
}), }),
); );
@ -49,7 +50,8 @@ router.get(
failureRedirect: `${domains.client}/login`, failureRedirect: `${domains.client}/login`,
failureMessage: true, failureMessage: true,
session: false, session: false,
scope: ['public_profile', 'email'], scope: ['public_profile'],
profileFields: ['id', 'email', 'name'],
}), }),
(req, res) => { (req, res) => {
const token = req.user.generateToken(); const token = req.user.generateToken();

View file

@ -5,8 +5,7 @@ const domains = config.domains;
const facebookLogin = async (accessToken, refreshToken, profile, cb) => { const facebookLogin = async (accessToken, refreshToken, profile, cb) => {
try { try {
console.log('facebookLogin => profile', profile); const email = profile.emails[0]?.value;
const email = profile.emails[0].value;
const facebookId = profile.id; const facebookId = profile.id;
const oldUser = await User.findOne({ const oldUser = await User.findOne({
email, email,
@ -15,17 +14,17 @@ const facebookLogin = async (accessToken, refreshToken, profile, cb) => {
process.env.ALLOW_SOCIAL_REGISTRATION?.toLowerCase() === 'true'; process.env.ALLOW_SOCIAL_REGISTRATION?.toLowerCase() === 'true';
if (oldUser) { if (oldUser) {
oldUser.avatar = profile.photos[0].value; oldUser.avatar = profile.photo;
await oldUser.save(); await oldUser.save();
return cb(null, oldUser); return cb(null, oldUser);
} else if (ALLOW_SOCIAL_REGISTRATION) { } else if (ALLOW_SOCIAL_REGISTRATION) {
const newUser = await new User({ const newUser = await new User({
provider: 'facebook', provider: 'facebook',
facebookId, facebookId,
username: profile.name.givenName + profile.name.familyName, username: profile.displayName,
email, email,
name: profile.displayName, name: profile.name?.givenName + ' ' + profile.name?.familyName,
avatar: profile.photos[0].value, avatar: profile.photos[0]?.value,
}).save(); }).save();
return cb(null, newUser); return cb(null, newUser);
@ -43,23 +42,12 @@ const facebookLogin = async (accessToken, refreshToken, profile, cb) => {
module.exports = () => module.exports = () =>
new FacebookStrategy( new FacebookStrategy(
{ {
clientID: process.env.FACEBOOK_APP_ID, clientID: process.env.FACEBOOK_CLIENT_ID,
clientSecret: process.env.FACEBOOK_SECRET, clientSecret: process.env.FACEBOOK_CLIENT_SECRET,
callbackURL: `${domains.server}${process.env.FACEBOOK_CALLBACK_URL}`, callbackURL: `${domains.server}${process.env.FACEBOOK_CALLBACK_URL}`,
proxy: true, proxy: true,
// profileFields: [ scope: ['public_profile'],
// 'id', profileFields: ['id', 'email', 'name'],
// 'email',
// 'gender',
// 'profileUrl',
// 'displayName',
// 'locale',
// 'name',
// 'timezone',
// 'updated_time',
// 'verified',
// 'picture.type(large)'
// ]
}, },
facebookLogin, facebookLogin,
); );

View file

@ -4,7 +4,7 @@ import { useAuthContext } from '~/hooks/AuthContext';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useLocalize } from '~/hooks'; import { useLocalize } from '~/hooks';
import { useGetStartupConfig } from 'librechat-data-provider'; import { useGetStartupConfig } from 'librechat-data-provider';
import { GoogleIcon, OpenIDIcon, GithubIcon, DiscordIcon } from '~/components'; import { GoogleIcon, FacebookIcon, OpenIDIcon, GithubIcon, DiscordIcon } from '~/components';
function Login() { function Login() {
const { login, error, isAuthenticated } = useAuthContext(); const { login, error, isAuthenticated } = useAuthContext();
@ -65,6 +65,20 @@ function Login() {
</div> </div>
</> </>
)} )}
{startupConfig?.facebookLoginEnabled && startupConfig?.socialLoginEnabled && (
<>
<div className="mt-2 flex gap-x-2">
<a
aria-label="Login with Facebook"
className="justify-left flex w-full items-center space-x-3 rounded-md border border-gray-300 px-5 py-3 hover:bg-gray-50 focus:ring-2 focus:ring-violet-600 focus:ring-offset-1"
href={`${startupConfig.serverDomain}/oauth/facebook`}
>
<FacebookIcon />
<p>{localize('com_auth_facebook_login')}</p>
</a>
</div>
</>
)}
{startupConfig?.openidLoginEnabled && startupConfig?.socialLoginEnabled && ( {startupConfig?.openidLoginEnabled && startupConfig?.socialLoginEnabled && (
<> <>
<div className="mt-2 flex gap-x-2"> <div className="mt-2 flex gap-x-2">

View file

@ -7,7 +7,7 @@ import {
TRegisterUser, TRegisterUser,
useGetStartupConfig, useGetStartupConfig,
} from 'librechat-data-provider'; } from 'librechat-data-provider';
import { GoogleIcon, OpenIDIcon, GithubIcon, DiscordIcon } from '~/components'; import { GoogleIcon, FacebookIcon, OpenIDIcon, GithubIcon, DiscordIcon } from '~/components';
function Registration() { function Registration() {
const navigate = useNavigate(); const navigate = useNavigate();
@ -308,6 +308,20 @@ function Registration() {
</div> </div>
</> </>
)} )}
{startupConfig?.facebookLoginEnabled && startupConfig?.socialLoginEnabled && (
<>
<div className="mt-2 flex gap-x-2">
<a
aria-label="Login with Facebook"
className="justify-left flex w-full items-center space-x-3 rounded-md border border-gray-300 px-5 py-3 hover:bg-gray-50 focus:ring-2 focus:ring-violet-600 focus:ring-offset-1"
href={`${startupConfig.serverDomain}/oauth/facebook`}
>
<FacebookIcon />
<p>{localize('com_auth_facebook_login')}</p>
</a>
</div>
</>
)}
{startupConfig?.openidLoginEnabled && startupConfig?.socialLoginEnabled && ( {startupConfig?.openidLoginEnabled && startupConfig?.socialLoginEnabled && (
<> <>
<div className="mt-2 flex gap-x-2"> <div className="mt-2 flex gap-x-2">

View file

@ -23,6 +23,7 @@ const setup = ({
isError: false, isError: false,
data: { data: {
googleLoginEnabled: true, googleLoginEnabled: true,
facebookLoginEnabled: true,
openidLoginEnabled: true, openidLoginEnabled: true,
openidLabel: 'Test OpenID', openidLabel: 'Test OpenID',
openidImageUrl: 'http://test-server.com', openidImageUrl: 'http://test-server.com',
@ -67,6 +68,21 @@ test('renders login form', () => {
'href', 'href',
'mock-server/oauth/google', 'mock-server/oauth/google',
); );
expect(getByRole('link', { name: /Login with Facebook/i })).toBeInTheDocument();
expect(getByRole('link', { name: /Login with Facebook/i })).toHaveAttribute(
'href',
'mock-server/oauth/facebook',
);
expect(getByRole('link', { name: /Login with Github/i })).toBeInTheDocument();
expect(getByRole('link', { name: /Login with Github/i })).toHaveAttribute(
'href',
'mock-server/oauth/github',
);
expect(getByRole('link', { name: /Login with Discord/i })).toBeInTheDocument();
expect(getByRole('link', { name: /Login with Discord/i })).toHaveAttribute(
'href',
'mock-server/oauth/discord',
);
}); });
test('calls loginUser.mutate on login', async () => { test('calls loginUser.mutate on login', async () => {

View file

@ -24,6 +24,7 @@ const setup = ({
isError: false, isError: false,
data: { data: {
googleLoginEnabled: true, googleLoginEnabled: true,
facebookLoginEnabled: true,
openidLoginEnabled: true, openidLoginEnabled: true,
openidLabel: 'Test OpenID', openidLabel: 'Test OpenID',
openidImageUrl: 'http://test-server.com', openidImageUrl: 'http://test-server.com',
@ -75,6 +76,21 @@ test('renders registration form', () => {
'href', 'href',
'mock-server/oauth/google', 'mock-server/oauth/google',
); );
expect(getByRole('link', { name: /Login with Facebook/i })).toBeInTheDocument();
expect(getByRole('link', { name: /Login with Facebook/i })).toHaveAttribute(
'href',
'mock-server/oauth/facebook',
);
expect(getByRole('link', { name: /Login with Github/i })).toBeInTheDocument();
expect(getByRole('link', { name: /Login with Github/i })).toHaveAttribute(
'href',
'mock-server/oauth/github',
);
expect(getByRole('link', { name: /Login with Discord/i })).toBeInTheDocument();
expect(getByRole('link', { name: /Login with Discord/i })).toHaveAttribute(
'href',
'mock-server/oauth/discord',
);
}); });
// eslint-disable-next-line jest/no-commented-out-tests // eslint-disable-next-line jest/no-commented-out-tests

View file

@ -0,0 +1,28 @@
import React from 'react';
export default function FacebookIcon() {
return (
<svg viewBox="0 0 40 40" width="25" height="25">
<linearGradient
id="a"
x1={-277.375}
x2={-277.375}
y1={406.602}
y2={407.573}
gradientTransform="matrix(40 0 0 -39.7778 11115.001 16212.334)"
gradientUnits="userSpaceOnUse"
>
<stop offset={0} stopColor="#0062e0" />
<stop offset={1} stopColor="#19afff" />
</linearGradient>
<path
fill="url(#a)"
d="M16.7 39.8C7.2 38.1 0 29.9 0 20 0 9 9 0 20 0s20 9 20 20c0 9.9-7.2 18.1-16.7 19.8l-1.1-.9h-4.4l-1.1.9z"
/>
<path
fill="#fff"
d="m27.8 25.6.9-5.6h-5.3v-3.9c0-1.6.6-2.8 3-2.8H29V8.2c-1.4-.2-3-.4-4.4-.4-4.6 0-7.8 2.8-7.8 7.8V20h-5v5.6h5v14.1c1.1.2 2.2.3 3.3.3 1.1 0 2.2-.1 3.3-.3V25.6h4.4z"
/>
</svg>
);
}

View file

@ -13,6 +13,7 @@ export { default as StopGeneratingIcon } from './StopGeneratingIcon';
export { default as RegenerateIcon } from './RegenerateIcon'; export { default as RegenerateIcon } from './RegenerateIcon';
export { default as ContinueIcon } from './ContinueIcon'; export { default as ContinueIcon } from './ContinueIcon';
export { default as GoogleIcon } from './GoogleIcon'; export { default as GoogleIcon } from './GoogleIcon';
export { default as FacebookIcon } from './FacebookIcon';
export { default as OpenIDIcon } from './OpenIDIcon'; export { default as OpenIDIcon } from './OpenIDIcon';
export { default as GithubIcon } from './GithubIcon'; export { default as GithubIcon } from './GithubIcon';
export { default as DiscordIcon } from './DiscordIcon'; export { default as DiscordIcon } from './DiscordIcon';

View file

@ -34,6 +34,7 @@ export default {
com_auth_sign_up: 'Cadastre-se', com_auth_sign_up: 'Cadastre-se',
com_auth_sign_in: 'Entrar', com_auth_sign_in: 'Entrar',
com_auth_google_login: 'Entrar com o Google', com_auth_google_login: 'Entrar com o Google',
com_auth_facebook_login: 'Entrar com o Facebook',
com_auth_github_login: 'Entrar com o Github', com_auth_github_login: 'Entrar com o Github',
com_auth_discord_login: 'Entrar com o Discord', com_auth_discord_login: 'Entrar com o Discord',
com_auth_email: 'Email', com_auth_email: 'Email',

View file

@ -34,6 +34,7 @@ export default {
com_auth_sign_up: 'Registrieren', com_auth_sign_up: 'Registrieren',
com_auth_sign_in: 'Anmelden', com_auth_sign_in: 'Anmelden',
com_auth_google_login: 'Anmelden mit Google', com_auth_google_login: 'Anmelden mit Google',
com_auth_facebook_login: 'Anmelden mit Facebook',
com_auth_github_login: 'Anmelden mit Github', com_auth_github_login: 'Anmelden mit Github',
com_auth_discord_login: 'Anmelden mit Discord', com_auth_discord_login: 'Anmelden mit Discord',
com_auth_email: 'E-Mail', com_auth_email: 'E-Mail',

View file

@ -34,6 +34,7 @@ export default {
com_auth_sign_up: 'Sign up', com_auth_sign_up: 'Sign up',
com_auth_sign_in: 'Sign in', com_auth_sign_in: 'Sign in',
com_auth_google_login: 'Login with Google', com_auth_google_login: 'Login with Google',
com_auth_facebook_login: 'Login with Facebook',
com_auth_github_login: 'Login with Github', com_auth_github_login: 'Login with Github',
com_auth_discord_login: 'Login with Discord', com_auth_discord_login: 'Login with Discord',
com_auth_email: 'Email', com_auth_email: 'Email',

View file

@ -35,6 +35,7 @@ export default {
com_auth_sign_up: 'Registrarse', com_auth_sign_up: 'Registrarse',
com_auth_sign_in: 'Iniciar sesión', com_auth_sign_in: 'Iniciar sesión',
com_auth_google_login: 'Iniciar sesión con Google', com_auth_google_login: 'Iniciar sesión con Google',
com_auth_facebook_login: 'Iniciar sesión con Facebook',
com_auth_github_login: 'Iniciar sesión con GitHub', com_auth_github_login: 'Iniciar sesión con GitHub',
com_auth_discord_login: 'Iniciar sesión con Discord', com_auth_discord_login: 'Iniciar sesión con Discord',
com_auth_email: 'Email', com_auth_email: 'Email',

View file

@ -35,6 +35,7 @@ export default {
com_auth_sign_up: 'S\'inscrire', com_auth_sign_up: 'S\'inscrire',
com_auth_sign_in: 'Se connecter', com_auth_sign_in: 'Se connecter',
com_auth_google_login: 'Se connecter avec Google', com_auth_google_login: 'Se connecter avec Google',
com_auth_facebook_login: 'Se connecter avec Facebook',
com_auth_github_login: 'Se connecter avec Github', com_auth_github_login: 'Se connecter avec Github',
com_auth_discord_login: 'Se connecter avec Discord', com_auth_discord_login: 'Se connecter avec Discord',
com_auth_email: 'Courriel', com_auth_email: 'Courriel',

View file

@ -35,6 +35,7 @@ export default {
com_auth_sign_up: 'Registrati', com_auth_sign_up: 'Registrati',
com_auth_sign_in: 'Accedi', com_auth_sign_in: 'Accedi',
com_auth_google_login: 'Accedi con Google', com_auth_google_login: 'Accedi con Google',
com_auth_facebook_login: 'Accedi con Facebook',
com_auth_github_login: 'Accedi con Github', com_auth_github_login: 'Accedi con Github',
com_auth_discord_login: 'Accedi con Discord', com_auth_discord_login: 'Accedi con Discord',
com_auth_email: 'Email', com_auth_email: 'Email',

View file

@ -31,6 +31,7 @@ export default {
com_auth_sign_up: '注册', com_auth_sign_up: '注册',
com_auth_sign_in: '登录', com_auth_sign_in: '登录',
com_auth_google_login: '谷歌登录', com_auth_google_login: '谷歌登录',
com_auth_facebook_login: 'Facebook登录',
com_auth_github_login: 'Github登录', com_auth_github_login: 'Github登录',
com_auth_discord_login: 'Discord登录', com_auth_discord_login: 'Discord登录',
com_auth_email: '电子邮箱', com_auth_email: '电子邮箱',

View file

@ -45,6 +45,24 @@ To enable Google login, you must create an application in the [Google Cloud Cons
--- ---
## Facebook Authentication
### (It only works with a domain, not with localhost)
1. Go to [Facebook Developer Portal](https://developers.facebook.com/)
2. Create a new Application and give it a name
4. In the Dashboard tab select product and select "Facebook login", then tap on "Configure" and "Settings". Male sure "OAuth client access", "Web OAuth access", "Apply HTTPS" and "Use limited mode for redirect URIs" are **enabled**
5. In the Valid OAuth Redirect URIs add "your-domain/oauth/facebook/callback" (example: http://example.com/oauth/facebook/callback)
6. Save changes and in the "settings" tab, reset the Client Secret
7. Put the Client ID and Client Secret in the .env file:
```bash
FACEBOOK_CLIENT_ID=your_client_id
FACEBOOK_CLIENT_SECRET=your_client_secret
FACEBOOK_CALLBACK_URL=/oauth/facebook/callback # this should be the same for everyone
```
8. Save the .env file
---
## OpenID Authentication with Azure AD ## OpenID Authentication with Azure AD
1. Go to the [Azure Portal](https://portal.azure.com/) and sign in with your account. 1. Go to the [Azure Portal](https://portal.azure.com/) and sign in with your account.
@ -132,6 +150,7 @@ DISCORD_CLIENT_SECRET=your_client_secret
DISCORD_CALLBACK_URL=/oauth/discord/callback # this should be the same for everyone DISCORD_CALLBACK_URL=/oauth/discord/callback # this should be the same for everyone
``` ```
8. Save the .env file 8. Save the .env file
--- ---
## **Email and Password Reset** ## **Email and Password Reset**

3
package-lock.json generated
View file

@ -15,7 +15,8 @@
"packages/*" "packages/*"
], ],
"dependencies": { "dependencies": {
"axios": "^1.4.0" "axios": "^1.4.0",
"passport-facebook": "^3.0.0"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.32.1", "@playwright/test": "^1.32.1",

View file

@ -54,7 +54,8 @@
}, },
"homepage": "https://github.com/danny-avila/LibreChat#readme", "homepage": "https://github.com/danny-avila/LibreChat#readme",
"dependencies": { "dependencies": {
"axios": "^1.4.0" "axios": "^1.4.0",
"passport-facebook": "^3.0.0"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.32.1", "@playwright/test": "^1.32.1",

View file

@ -159,6 +159,7 @@ export type TResetPassword = {
export type TStartupConfig = { export type TStartupConfig = {
appTitle: string; appTitle: string;
googleLoginEnabled: boolean; googleLoginEnabled: boolean;
facebookLoginEnabled: boolean;
openidLoginEnabled: boolean; openidLoginEnabled: boolean;
githubLoginEnabled: boolean; githubLoginEnabled: boolean;
openidLabel: string; openidLabel: string;