diff --git a/default/config.yaml b/default/config.yaml index b3057451a..d95719f22 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/public/scripts/login.js b/public/scripts/login.js index e02ec1039..dec6a90e9 100644 --- a/public/scripts/login.js +++ b/public/scripts/login.js @@ -180,7 +180,13 @@ function displayError(message) { * Preserves the query string. */ function redirectToHome() { - window.location.href = '/' + window.location.search; + // After a login theres no need to preserve the + // noauto (if present) + const urlParams = new URLSearchParams(window.location.search); + + urlParams.delete('noauto'); + + window.location.href = '/' + urlParams.toString(); } /** diff --git a/public/scripts/user.js b/public/scripts/user.js index 4aab9bb1b..c5d984e1b 100644 --- a/public/scripts/user.js +++ b/public/scripts/user.js @@ -848,7 +848,14 @@ async function logout() { headers: getRequestHeaders(), }); - window.location.reload(); + // On an explicit logout stop auto login + // to allow user to change username even + // when auto auth (such as authelia or basic) + // would be valid + const urlParams = new URLSearchParams(window.location.search); + urlParams.set('noauto', 'true'); + + window.location.search = urlParams.toString(); } /** diff --git a/server.js b/server.js index 677561517..99c8c44d3 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_PER_USER_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 perUserBasicAuth = getConfigValue('perUserBasicAuth', DEFAULT_PER_USER_BASIC_AUTH); const enableAccounts = getConfigValue('enableUserAccounts', DEFAULT_ACCOUNTS); const uploadsPath = path.join(dataRoot, require('./src/constants').UPLOADS_DIRECTORY); @@ -361,7 +363,7 @@ app.get('/login', async (request, response) => { } try { - const autoLogin = await userModule.tryAutoLogin(request); + const autoLogin = await userModule.tryAutoLogin(request, basicAuthMode); if (autoLogin) { return response.redirect('/'); @@ -756,9 +758,13 @@ 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 (perUserBasicAuth && !enableAccounts) { + console.error(color.red('Per-user basic authentication is enabled, but user accounts are disabled. This configuration may be insecure.')); + } else if (!perUserBasicAuth) { + 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..07408e7bd 100644 --- a/src/middleware/basicAuth.js +++ b/src/middleware/basicAuth.js @@ -2,14 +2,19 @@ * 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 PER_USER_BASIC_AUTH = getConfigValue('perUserBasicAuth', false); +const ENABLE_ACCOUNTS = getConfigValue('enableUserAccounts', 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; @@ -23,15 +28,25 @@ const basicAuthMiddleware = function (request, response, callback) { return unauthorizedResponse(response); } + const usePerUserAuth = PER_USER_BASIC_AUTH && ENABLE_ACCOUNTS; const [username, password] = Buffer.from(credentials, 'base64') .toString('utf8') .split(':'); - if (username === config.basicAuthUser.username && password === config.basicAuthUser.password) { + if (!usePerUserAuth && username === config.basicAuthUser.username && password === config.basicAuthUser.password) { return callback(); - } else { - return unauthorizedResponse(response); + } else if (usePerUserAuth) { + const userHandles = await getAllUserHandles(); + for (const userHandle of userHandles) { + if (username === userHandle) { + const user = await storage.getItem(toKey(userHandle)); + if (user && user.enabled && (user.password && user.password === getPasswordHash(password, user.salt))) { + return callback(); + } + } + } } + return unauthorizedResponse(response); }; module.exports = basicAuthMiddleware; diff --git a/src/users.js b/src/users.js index 7ef260ac0..4fb85ab0d 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 PER_USER_BASIC_AUTH = getConfigValue('perUserBasicAuth', false); const ANON_CSRF_SECRET = crypto.randomBytes(64).toString('base64'); /** @@ -565,13 +567,42 @@ function shouldRedirectToLogin(request) { return ENABLE_ACCOUNTS && !request.user; } +/** + * Tries auto-login if there is only one user and it's not password protected. + * or another configured method such authlia or basic + * @param {import('express').Request} request Request object + * @param {boolean} basicAuthMode If Basic auth mode is enabled + * @returns {Promise} Whether auto-login was performed + */ +async function tryAutoLogin(request, basicAuthMode) { + if (!ENABLE_ACCOUNTS || request.user || !request.session) { + return false; + } + + if (!request.query.noauto) { + if (await singleUserLogin(request)) { + return true; + } + + if (AUTHELIA_AUTH && await autheliaUserLogin(request)) { + return true; + } + + if (basicAuthMode && PER_USER_BASIC_AUTH && await basicUserLogin(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 tryAutoLogin(request) { - if (!ENABLE_ACCOUNTS || request.user || !request.session) { +async function singleUserLogin(request) { + if (!request.session) { return false; } @@ -583,6 +614,75 @@ 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 autheliaUserLogin(request) { + if (!request.session) { + return false; + } + + const remoteUser = request.get('Remote-User'); + if (!remoteUser) { + return false; + } + + const userHandles = await getAllUserHandles(); + for (const userHandle of userHandles) { + if (remoteUser === userHandle) { + const user = await storage.getItem(toKey(userHandle)); + if (user && user.enabled) { + 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 basicUserLogin(request) { + if (!request.session) { + return false; + } + + const authHeader = request.headers.authorization; + + if (!authHeader) { + return false; + } + + const [scheme, credentials] = authHeader.split(' '); + + if (scheme !== 'Basic' || !credentials) { + return false; + } + + const [username, password] = Buffer.from(credentials, 'base64') + .toString('utf8') + .split(':'); + + const userHandles = await getAllUserHandles(); + for (const userHandle of userHandles) { + if (username === userHandle) { + const user = await storage.getItem(toKey(userHandle)); + // Verify pass again here just to be sure + if (user && user.enabled && user.password && user.password === getPasswordHash(password, user.salt)) { + request.session.handle = userHandle; + return true; + } + } + } return false; }