diff --git a/default/config.yaml b/default/config.yaml index a7e7a747e..69c32af29 100644 --- a/default/config.yaml +++ b/default/config.yaml @@ -51,6 +51,19 @@ requestProxy: enableUserAccounts: false # Enable discreet login mode: hides user list on the login screen enableDiscreetLogin: false +# Enable's authlia based auto login. Only enable this if you +# have setup and installed Authelia as a middle-ware on your +# reverse proxy +# https://www.authelia.com/ +# This will use auto login to an account with the same username +# as that used for authlia. (Ensure the username in authlia +# is an exact match with that in sillytavern) +autheliaAuth: false +# If `basicAuthMode` and this are enabled then +# the username and passwords for basic auth are the same as those +# for the individual accounts +perUserBasicAuth: false + # User session timeout *in seconds* (defaults to 24 hours). ## Set to a positive number to expire session after a certain time of inactivity ## Set to 0 to expire session when the browser is closed diff --git a/server.js b/server.js index 677561517..c85f5a541 100644 --- a/server.js +++ b/server.js @@ -65,6 +65,7 @@ const DEFAULT_WHITELIST = true; const DEFAULT_ACCOUNTS = false; const DEFAULT_CSRF_DISABLED = false; const DEFAULT_BASIC_AUTH = false; +const DEFAULT_PERUSER_BASIC_AUTH = false; const DEFAULT_ENABLE_IPV6 = false; const DEFAULT_ENABLE_IPV4 = true; @@ -184,6 +185,7 @@ const enableWhitelist = cliArguments.whitelist ?? getConfigValue('whitelistMode' const dataRoot = cliArguments.dataRoot ?? getConfigValue('dataRoot', './data'); const disableCsrf = cliArguments.disableCsrf ?? getConfigValue('disableCsrfProtection', DEFAULT_CSRF_DISABLED); const basicAuthMode = cliArguments.basicAuthMode ?? getConfigValue('basicAuthMode', DEFAULT_BASIC_AUTH); +const PERUSER_BASIC_AUTH = getConfigValue('perUserBasicAuth', DEFAULT_PERUSER_BASIC_AUTH); const enableAccounts = getConfigValue('enableUserAccounts', DEFAULT_ACCOUNTS); const uploadsPath = path.join(dataRoot, require('./src/constants').UPLOADS_DIRECTORY); @@ -756,9 +758,11 @@ const postSetupTasks = async function (v6Failed, v4Failed) { } if (basicAuthMode) { - const basicAuthUser = getConfigValue('basicAuthUser', {}); - if (!basicAuthUser?.username || !basicAuthUser?.password) { - console.warn(color.yellow('Basic Authentication is enabled, but username or password is not set or empty!')); + if (!PERUSER_BASIC_AUTH) { + const basicAuthUser = getConfigValue('basicAuthUser', {}); + if (!basicAuthUser?.username || !basicAuthUser?.password) { + console.warn(color.yellow('Basic Authentication is enabled, but username or password is not set or empty!')); + } } } }; diff --git a/src/middleware/basicAuth.js b/src/middleware/basicAuth.js index 190cc39bd..3d7afa23e 100644 --- a/src/middleware/basicAuth.js +++ b/src/middleware/basicAuth.js @@ -2,14 +2,18 @@ * When applied, this middleware will ensure the request contains the required header for basic authentication and only * allow access to the endpoint after successful authentication. */ -const { getConfig } = require('../util.js'); +const { getAllUserHandles, toKey, getPasswordHash } = require('../users.js'); +const { getConfig, getConfigValue } = require('../util.js'); +const storage = require('node-persist'); + +const PERUSER_BASIC_AUTH = getConfigValue('perUserBasicAuth', false); const unauthorizedResponse = (res) => { res.set('WWW-Authenticate', 'Basic realm="SillyTavern", charset="UTF-8"'); return res.status(401).send('Authentication required'); }; -const basicAuthMiddleware = function (request, response, callback) { +const basicAuthMiddleware = async function (request, response, callback) { const config = getConfig(); const authHeader = request.headers.authorization; @@ -27,11 +31,25 @@ const basicAuthMiddleware = function (request, response, callback) { .toString('utf8') .split(':'); - if (username === config.basicAuthUser.username && password === config.basicAuthUser.password) { + + if (! PERUSER_BASIC_AUTH && username === config.basicAuthUser.username && password === config.basicAuthUser.password) { return callback(); - } else { - return unauthorizedResponse(response); + } else if (PERUSER_BASIC_AUTH) { + const userHandles = await getAllUserHandles(); + for (const userHandle of userHandles) { + if (username == userHandle) { + const user = await storage.getItem(toKey(userHandle)); + if (user && (user.password && user.password === getPasswordHash(password, user.salt))) { + return callback(); + } + else if (user && !user.password && !password) { + // Login to an account without password + return callback(); + } + } + } } + return unauthorizedResponse(response); }; module.exports = basicAuthMiddleware; diff --git a/src/users.js b/src/users.js index 7ef260ac0..93d29d323 100644 --- a/src/users.js +++ b/src/users.js @@ -19,6 +19,8 @@ const { readSecret, writeSecret } = require('./endpoints/secrets'); const KEY_PREFIX = 'user:'; const AVATAR_PREFIX = 'avatar:'; const ENABLE_ACCOUNTS = getConfigValue('enableUserAccounts', false); +const AUTHELIA_AUTH = getConfigValue('autheliaAuth', false); +const PERUSER_BASIC_AUTH = getConfigValue('perUserBasicAuth', false); const ANON_CSRF_SECRET = crypto.randomBytes(64).toString('base64'); /** @@ -575,6 +577,31 @@ async function tryAutoLogin(request) { return false; } + if (await singler_user_login(request)) { + return true; + } + + if (AUTHELIA_AUTH && await authelia_user_login(request)) { + return true; + } + + if (PERUSER_BASIC_AUTH && await basic_user_login(request)) { + return true; + } + + return false; +} + +/** + * Tries auto-login if there is only one user and it's not password protected. + * @param {import('express').Request} request Request object + * @returns {Promise} Whether auto-login was performed + */ +async function singler_user_login(request) { + if (!request.session) { + return false; + } + const userHandles = await getAllUserHandles(); if (userHandles.length === 1) { const user = await storage.getItem(toKey(userHandles[0])); @@ -583,7 +610,78 @@ async function tryAutoLogin(request) { return true; } } + return false; +} +/** + * Tries auto-login with authlia trusted headers. + * https://www.authelia.com/integration/trusted-header-sso/introduction/ + * @param {import('express').Request} request Request object + * @returns {Promise} Whether auto-login was performed + */ +async function authelia_user_login(request) { + if (!request.session) { + return false; + } + + const remote_user = request.get("Remote-User"); + if (!remote_user) { + return false; + } + + const userHandles = await getAllUserHandles(); + for (const userHandle of userHandles) { + if (remote_user == userHandle) { + const user = await storage.getItem(toKey(userHandle)); + if (user) { + request.session.handle = userHandle; + return true; + } + } + } + return false; +} + +/** + * Tries auto-login with basic auth username. + * @param {import('express').Request} request Request object + * @returns {Promise} Whether auto-login was performed + */ +async function basic_user_login(request) { + if (!request.session) { + return false; + } + + const auth_header = request.get("Authorization"); + if (!auth_header) { + return false; + } + + const parts = auth_header.split(' '); + if (!parts || parts.length < 2 || parts[0].toLowerCase() != "basic") { + return false; + } + + const b64auth = parts[1]; + const [login, password] = Buffer.from(b64auth, 'base64').toString().split(':') + + const userHandles = await getAllUserHandles(); + for (const userHandle of userHandles) { + if (login == userHandle) { + const user = await storage.getItem(toKey(userHandle)); + // Verify pass again here just to be sure + if (user && user.password && user.password === getPasswordHash(password, user.salt)) { + request.session.handle = userHandle; + return true; + } + else if (user && !user.password && !password) { + // Login to an account without password + request.session.handle = userHandle; + return true; + } + } + } + return false; }