diff --git a/.env.example b/.env.example index c8049f1d94..f61df95dee 100644 --- a/.env.example +++ b/.env.example @@ -631,6 +631,7 @@ ILLEGAL_MODEL_REQ_SCORE=5 #========================# ALLOW_EMAIL_LOGIN=true +# ALLOW_EMAIL_LOGIN_OVERRIDE=false # note: permits direct API email login while ALLOW_EMAIL_LOGIN=false; each use is logged ALLOW_REGISTRATION=true ALLOW_SOCIAL_LOGIN=false ALLOW_SOCIAL_REGISTRATION=false diff --git a/api/server/middleware/index.js b/api/server/middleware/index.js index 7d9bb9e8ea..42fdb9b1d7 100644 --- a/api/server/middleware/index.js +++ b/api/server/middleware/index.js @@ -2,6 +2,7 @@ const validatePasswordReset = require('./validatePasswordReset'); const setTwoFactorTempUser = require('./setTwoFactorTempUser'); const validateRegistration = require('./validateRegistration'); const buildEndpointOption = require('./buildEndpointOption'); +const validateEmailLogin = require('./validateEmailLogin'); const validateMessageReq = require('./validateMessageReq'); const { prepareMessageRequestValidation, sendValidationResponse } = require('./messageValidation'); const checkDomainAllowed = require('./checkDomainAllowed'); @@ -53,4 +54,5 @@ module.exports = { buildEndpointOption, validateRegistration, validatePasswordReset, + validateEmailLogin, }; diff --git a/api/server/middleware/validateEmailLogin.js b/api/server/middleware/validateEmailLogin.js new file mode 100644 index 0000000000..a2eb60383c --- /dev/null +++ b/api/server/middleware/validateEmailLogin.js @@ -0,0 +1,3 @@ +const { validateEmailLogin } = require('@librechat/api'); + +module.exports = validateEmailLogin; diff --git a/api/server/routes/admin/auth.js b/api/server/routes/admin/auth.js index 47081232de..72fdf7eb7f 100644 --- a/api/server/routes/admin/auth.js +++ b/api/server/routes/admin/auth.js @@ -73,6 +73,7 @@ router.post( middleware.logHeaders, middleware.loginLimiter, middleware.checkBan, + middleware.validateEmailLogin, middleware.requireLocalAuth, tenantContextMiddleware, requireAdminAccess, diff --git a/api/server/routes/admin/auth.refresh.test.js b/api/server/routes/admin/auth.refresh.test.js index d4fb59c569..fafce2d2d1 100644 --- a/api/server/routes/admin/auth.refresh.test.js +++ b/api/server/routes/admin/auth.refresh.test.js @@ -97,6 +97,7 @@ jest.mock('~/server/middleware', () => ({ logHeaders: jest.fn((req, res, next) => next()), loginLimiter: jest.fn((req, res, next) => next()), checkBan: jest.fn((req, res, next) => next()), + validateEmailLogin: jest.fn((req, res, next) => next()), requireLocalAuth: jest.fn((req, res, next) => next()), requireJwtAuth: jest.fn((req, res, next) => next()), checkDomainAllowed: jest.fn((req, res, next) => next()), @@ -106,6 +107,7 @@ const openIdClient = require('openid-client'); const { logger } = require('@librechat/data-schemas'); const { isEnabled, applyAdminRefresh, buildOpenIDRefreshParams } = require('@librechat/api'); const { getOpenIdConfig } = require('~/strategies'); +const middleware = require('~/server/middleware'); const adminAuthRouter = require('./auth'); const ORIGINAL_OPENID_SCOPE = process.env.OPENID_SCOPE; @@ -248,3 +250,44 @@ describe('admin auth OpenID refresh route', () => { expect(debugOutput).not.toContain('https://api.example.com'); }); }); + +describe('admin local login route', () => { + let app; + + beforeEach(() => { + jest.clearAllMocks(); + + app = express(); + app.use(express.json()); + app.use('/api/admin', adminAuthRouter); + }); + + it('applies the email login gate before local auth', async () => { + const response = await request(app).post('/api/admin/login/local').send({ + email: 'admin@example.com', + password: 'password', + }); + + expect(response.status).toBe(200); + expect(middleware.validateEmailLogin).toHaveBeenCalledTimes(1); + expect(middleware.requireLocalAuth).toHaveBeenCalledTimes(1); + expect(middleware.validateEmailLogin.mock.invocationCallOrder[0]).toBeLessThan( + middleware.requireLocalAuth.mock.invocationCallOrder[0], + ); + }); + + it('stops before local auth when the email login gate rejects the request', async () => { + middleware.validateEmailLogin.mockImplementationOnce((req, res) => + res.status(403).json({ message: 'Email login is not allowed.' }), + ); + + const response = await request(app).post('/api/admin/login/local').send({ + email: 'admin@example.com', + password: 'password', + }); + + expect(response.status).toBe(403); + expect(response.body).toEqual({ message: 'Email login is not allowed.' }); + expect(middleware.requireLocalAuth).not.toHaveBeenCalled(); + }); +}); diff --git a/api/server/routes/auth.2fa-ratelimit.test.js b/api/server/routes/auth.2fa-ratelimit.test.js index 3d11189a83..4867f78afe 100644 --- a/api/server/routes/auth.2fa-ratelimit.test.js +++ b/api/server/routes/auth.2fa-ratelimit.test.js @@ -56,6 +56,7 @@ jest.mock('~/server/middleware', () => { setTwoFactorTempUser: (...args) => mockSetTwoFactorTempUser(...args), twoFactorTempLimiter: (...args) => mockTwoFactorTempLimiter(...args), checkBan: (...args) => mockCheckBan(...args), + validateEmailLogin: pass, requireLocalAuth: pass, requireLdapAuth: pass, registerLimiter: pass, diff --git a/api/server/routes/auth.cloudfront.test.js b/api/server/routes/auth.cloudfront.test.js index 7456501119..56f2acf06f 100644 --- a/api/server/routes/auth.cloudfront.test.js +++ b/api/server/routes/auth.cloudfront.test.js @@ -53,6 +53,7 @@ jest.mock('~/server/middleware', () => { setTwoFactorTempUser: pass, twoFactorTempLimiter: pass, checkBan: pass, + validateEmailLogin: pass, requireLocalAuth: pass, requireLdapAuth: pass, registerLimiter: pass, diff --git a/api/server/routes/auth.js b/api/server/routes/auth.js index 191c1d3ee1..6c942dff7b 100644 --- a/api/server/routes/auth.js +++ b/api/server/routes/auth.js @@ -45,6 +45,7 @@ router.post( middleware.logHeaders, middleware.loginLimiter, middleware.checkBan, + middleware.validateEmailLogin, ldapAuth ? middleware.requireLdapAuth : middleware.requireLocalAuth, setBalanceConfig, loginController, diff --git a/api/server/routes/auth.reset-password-ratelimit.test.js b/api/server/routes/auth.reset-password-ratelimit.test.js index 86ec583ec5..7d49576d7c 100644 --- a/api/server/routes/auth.reset-password-ratelimit.test.js +++ b/api/server/routes/auth.reset-password-ratelimit.test.js @@ -56,6 +56,7 @@ jest.mock('~/server/middleware', () => { setTwoFactorTempUser: pass, twoFactorTempLimiter: pass, checkBan: (...args) => mockCheckBan(...args), + validateEmailLogin: pass, requireLocalAuth: pass, requireLdapAuth: pass, registerLimiter: pass, diff --git a/packages/api/src/middleware/email.spec.ts b/packages/api/src/middleware/email.spec.ts new file mode 100644 index 0000000000..70dec4e742 --- /dev/null +++ b/packages/api/src/middleware/email.spec.ts @@ -0,0 +1,121 @@ +import { logger } from '@librechat/data-schemas'; +import type { NextFunction, Request, Response } from 'express'; +import { validateEmailLogin } from './email'; + +jest.mock('@librechat/data-schemas', () => ({ + ...jest.requireActual('@librechat/data-schemas'), + logger: { + warn: jest.fn(), + }, +})); + +describe('validateEmailLogin', () => { + const originalEnv = { + ALLOW_EMAIL_LOGIN: process.env.ALLOW_EMAIL_LOGIN, + ALLOW_EMAIL_LOGIN_OVERRIDE: process.env.ALLOW_EMAIL_LOGIN_OVERRIDE, + }; + + let req: Request; + let res: Response; + let next: jest.MockedFunction; + + function createRequest(ip = '127.0.0.1'): Request { + return { ip } as Request; + } + + beforeEach(() => { + delete process.env.ALLOW_EMAIL_LOGIN; + delete process.env.ALLOW_EMAIL_LOGIN_OVERRIDE; + req = createRequest(); + res = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + } as Partial as Response; + next = jest.fn(); + (logger.warn as jest.Mock).mockClear(); + }); + + afterAll(() => { + for (const [key, value] of Object.entries(originalEnv)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + }); + + it('should allow login when ALLOW_EMAIL_LOGIN is unset (default)', () => { + validateEmailLogin(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + }); + + it('should allow login when ALLOW_EMAIL_LOGIN is true', () => { + process.env.ALLOW_EMAIL_LOGIN = 'true'; + + validateEmailLogin(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + }); + + it('should reject login with 403 when ALLOW_EMAIL_LOGIN is false', () => { + process.env.ALLOW_EMAIL_LOGIN = 'false'; + + validateEmailLogin(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ message: 'Email login is not allowed.' }); + }); + + it('should log blocked login attempts with the request IP', () => { + process.env.ALLOW_EMAIL_LOGIN = 'false'; + req = createRequest('10.0.0.42'); + + validateEmailLogin(req, res, next); + + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('10.0.0.42')); + }); + + it('should treat non-true values as disabled', () => { + process.env.ALLOW_EMAIL_LOGIN = 'no'; + + validateEmailLogin(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(403); + }); + + it('should allow login when disabled but ALLOW_EMAIL_LOGIN_OVERRIDE is true', () => { + process.env.ALLOW_EMAIL_LOGIN = 'false'; + process.env.ALLOW_EMAIL_LOGIN_OVERRIDE = 'true'; + + validateEmailLogin(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + }); + + it('should log override logins with the request IP', () => { + process.env.ALLOW_EMAIL_LOGIN = 'false'; + process.env.ALLOW_EMAIL_LOGIN_OVERRIDE = 'true'; + req = createRequest('10.0.0.42'); + + validateEmailLogin(req, res, next); + + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('ALLOW_EMAIL_LOGIN_OVERRIDE')); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('10.0.0.42')); + }); + + it('should ignore the override when email login is enabled', () => { + process.env.ALLOW_EMAIL_LOGIN_OVERRIDE = 'true'; + + validateEmailLogin(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(logger.warn).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/api/src/middleware/email.ts b/packages/api/src/middleware/email.ts new file mode 100644 index 0000000000..0d6fa8210d --- /dev/null +++ b/packages/api/src/middleware/email.ts @@ -0,0 +1,27 @@ +import { logger } from '@librechat/data-schemas'; +import type { NextFunction, Request, Response } from 'express'; +import { isEnabled } from '~/utils'; + +export function validateEmailLogin( + req: Request, + res: Response, + next: NextFunction, +): Response | void { + const emailLoginEnabled = + process.env.ALLOW_EMAIL_LOGIN === undefined || isEnabled(process.env.ALLOW_EMAIL_LOGIN); + if (emailLoginEnabled) { + next(); + return; + } + + if (isEnabled(process.env.ALLOW_EMAIL_LOGIN_OVERRIDE)) { + logger.warn( + `[validateEmailLogin] Email login is disabled; allowing login attempt via ALLOW_EMAIL_LOGIN_OVERRIDE. IP: ${req.ip}`, + ); + next(); + return; + } + + logger.warn(`[validateEmailLogin] Login attempt while email login is disabled. IP: ${req.ip}`); + return res.status(403).json({ message: 'Email login is not allowed.' }); +} diff --git a/packages/api/src/middleware/index.ts b/packages/api/src/middleware/index.ts index 0650a819ba..1d5fad12a5 100644 --- a/packages/api/src/middleware/index.ts +++ b/packages/api/src/middleware/index.ts @@ -1,6 +1,7 @@ export * from './access'; export * from './admin'; export * from './error'; +export * from './email'; export * from './notFound'; export * from './balance'; export * from './json';