Merge pull request #2953 from QuantumEntangledAndy/feat/AdditionalLogins Add additional login methods
Signed| @@ -51,6 +51,19 @@ requestProxy: | ||
| 51 | 51 | enableUserAccounts: false |
| 52 | 52 | # Enable discreet login mode: hides user list on the login screen |
| 53 | 53 | enableDiscreetLogin: false |
| 54 | +# Enable's authlia based auto login. Only enable this if you | |
| 55 | +# have setup and installed Authelia as a middle-ware on your | |
| 56 | +# reverse proxy | |
| 57 | +# https://www.authelia.com/ | |
| 58 | +# This will use auto login to an account with the same username | |
| 59 | +# as that used for authlia. (Ensure the username in authlia | |
| 60 | +# is an exact match with that in sillytavern) | |
| 61 | +autheliaAuth: false | |
| 62 | +# If `basicAuthMode` and this are enabled then | |
| 63 | +# the username and passwords for basic auth are the same as those | |
| 64 | +# for the individual accounts | |
| 65 | +perUserBasicAuth: false | |
| 66 | + | |
| 54 | 67 | # User session timeout *in seconds* (defaults to 24 hours). |
| 55 | 68 | ## Set to a positive number to expire session after a certain time of inactivity |
| 56 | 69 | ## Set to 0 to expire session when the browser is closed |
| @@ -180,7 +180,13 @@ function displayError(message) { | ||
| 180 | 180 | * Preserves the query string. |
| 181 | 181 | */ |
| 182 | 182 | function redirectToHome() { |
| 183 | - window.location.href = '/' + window.location.search; | |
| 183 | + // After a login theres no need to preserve the | |
| 184 | + // noauto (if present) | |
| 185 | + const urlParams = new URLSearchParams(window.location.search); | |
| 186 | + | |
| 187 | + urlParams.delete('noauto'); | |
| 188 | + | |
| 189 | + window.location.href = '/' + urlParams.toString(); | |
| 184 | 190 | } |
| 185 | 191 | |
| 186 | 192 | /** |
| @@ -848,7 +848,14 @@ async function logout() { | ||
| 848 | 848 | headers: getRequestHeaders(), |
| 849 | 849 | }); |
| 850 | 850 | |
| 851 | - window.location.reload(); | |
| 851 | + // On an explicit logout stop auto login | |
| 852 | + // to allow user to change username even | |
| 853 | + // when auto auth (such as authelia or basic) | |
| 854 | + // would be valid | |
| 855 | + const urlParams = new URLSearchParams(window.location.search); | |
| 856 | + urlParams.set('noauto', 'true'); | |
| 857 | + | |
| 858 | + window.location.search = urlParams.toString(); | |
| 852 | 859 | } |
| 853 | 860 | |
| 854 | 861 | /** |
| @@ -65,6 +65,7 @@ const DEFAULT_WHITELIST = true; | ||
| 65 | 65 | const DEFAULT_ACCOUNTS = false; |
| 66 | 66 | const DEFAULT_CSRF_DISABLED = false; |
| 67 | 67 | const DEFAULT_BASIC_AUTH = false; |
| 68 | +const DEFAULT_PER_USER_BASIC_AUTH = false; | |
| 68 | 69 | |
| 69 | 70 | const DEFAULT_ENABLE_IPV6 = false; |
| 70 | 71 | const DEFAULT_ENABLE_IPV4 = true; |
| @@ -184,6 +185,7 @@ const enableWhitelist = cliArguments.whitelist ?? getConfigValue('whitelistMode' | ||
| 184 | 185 | const dataRoot = cliArguments.dataRoot ?? getConfigValue('dataRoot', './data'); |
| 185 | 186 | const disableCsrf = cliArguments.disableCsrf ?? getConfigValue('disableCsrfProtection', DEFAULT_CSRF_DISABLED); |
| 186 | 187 | const basicAuthMode = cliArguments.basicAuthMode ?? getConfigValue('basicAuthMode', DEFAULT_BASIC_AUTH); |
| 188 | +const perUserBasicAuth = getConfigValue('perUserBasicAuth', DEFAULT_PER_USER_BASIC_AUTH); | |
| 187 | 189 | const enableAccounts = getConfigValue('enableUserAccounts', DEFAULT_ACCOUNTS); |
| 188 | 190 | |
| 189 | 191 | const uploadsPath = path.join(dataRoot, require('./src/constants').UPLOADS_DIRECTORY); |
| @@ -361,7 +363,7 @@ app.get('/login', async (request, response) => { | ||
| 361 | 363 | } |
| 362 | 364 | |
| 363 | 365 | try { |
| 364 | 366 | const autoLogin = await userModule.tryAutoLogin(request, basicAuthMode); |
| 365 | 367 | |
| 366 | 368 | if (autoLogin) { |
| 367 | 369 | return response.redirect('/'); |
| @@ -756,9 +758,13 @@ const postSetupTasks = async function (v6Failed, v4Failed) { | ||
| 756 | 758 | } |
| 757 | 759 | |
| 758 | 760 | if (basicAuthMode) { |
| 759 | - const basicAuthUser = getConfigValue('basicAuthUser', {}); | |
| 761 | + if (perUserBasicAuth && !enableAccounts) { | |
| 760 | - if (!basicAuthUser?.username || !basicAuthUser?.password) { | |
| 762 | + console.error(color.red('Per-user basic authentication is enabled, but user accounts are disabled. This configuration may be insecure.')); | |
| 761 | - console.warn(color.yellow('Basic Authentication is enabled, but username or password is not set or empty!')); | |
| 763 | + } else if (!perUserBasicAuth) { | |
| 764 | + const basicAuthUser = getConfigValue('basicAuthUser', {}); | |
| 765 | + if (!basicAuthUser?.username || !basicAuthUser?.password) { | |
| 766 | + console.warn(color.yellow('Basic Authentication is enabled, but username or password is not set or empty!')); | |
| 767 | + } | |
| 762 | 768 | } |
| 763 | 769 | } |
| 764 | 770 | }; |
| @@ -2,14 +2,19 @@ | ||
| 2 | 2 | * When applied, this middleware will ensure the request contains the required header for basic authentication and only |
| 3 | 3 | * allow access to the endpoint after successful authentication. |
| 4 | 4 | */ |
| 5 | 5 | const { getConfiggetAllUserHandles, toKey, getPasswordHash } = require('../utilusers.js'); |
| 6 | +const { getConfig, getConfigValue } = require('../util.js'); | |
| 7 | +const storage = require('node-persist'); | |
| 8 | + | |
| 9 | +const PER_USER_BASIC_AUTH = getConfigValue('perUserBasicAuth', false); | |
| 10 | +const ENABLE_ACCOUNTS = getConfigValue('enableUserAccounts', false); | |
| 6 | 11 | |
| 7 | 12 | const unauthorizedResponse = (res) => { |
| 8 | 13 | res.set('WWW-Authenticate', 'Basic realm="SillyTavern", charset="UTF-8"'); |
| 9 | 14 | return res.status(401).send('Authentication required'); |
| 10 | 15 | }; |
| 11 | 16 | |
| 12 | 17 | const basicAuthMiddleware = async function (request, response, callback) { |
| 13 | 18 | const config = getConfig(); |
| 14 | 19 | const authHeader = request.headers.authorization; |
| 15 | 20 | |
| @@ -23,15 +28,25 @@ const basicAuthMiddleware = function (request, response, callback) { | ||
| 23 | 28 | return unauthorizedResponse(response); |
| 24 | 29 | } |
| 25 | 30 | |
| 31 | + const usePerUserAuth = PER_USER_BASIC_AUTH && ENABLE_ACCOUNTS; | |
| 26 | 32 | const [username, password] = Buffer.from(credentials, 'base64') |
| 27 | 33 | .toString('utf8') |
| 28 | 34 | .split(':'); |
| 29 | 35 | |
| 30 | 36 | if (!usePerUserAuth && username === config.basicAuthUser.username && password === config.basicAuthUser.password) { |
| 31 | 37 | return callback(); |
| 32 | 38 | } else if (usePerUserAuth) { |
| 33 | - return unauthorizedResponse(response); | |
| 39 | + const userHandles = await getAllUserHandles(); | |
| 40 | + for (const userHandle of userHandles) { | |
| 41 | + if (username === userHandle) { | |
| 42 | + const user = await storage.getItem(toKey(userHandle)); | |
| 43 | + if (user && user.enabled && (user.password && user.password === getPasswordHash(password, user.salt))) { | |
| 44 | + return callback(); | |
| 45 | + } | |
| 46 | + } | |
| 47 | + } | |
| 34 | 48 | } |
| 49 | + return unauthorizedResponse(response); | |
| 35 | 50 | }; |
| 36 | 51 | |
| 37 | 52 | module.exports = basicAuthMiddleware; |
| @@ -19,6 +19,8 @@ const { readSecret, writeSecret } = require('./endpoints/secrets'); | ||
| 19 | 19 | const KEY_PREFIX = 'user:'; |
| 20 | 20 | const AVATAR_PREFIX = 'avatar:'; |
| 21 | 21 | const ENABLE_ACCOUNTS = getConfigValue('enableUserAccounts', false); |
| 22 | +const AUTHELIA_AUTH = getConfigValue('autheliaAuth', false); | |
| 23 | +const PER_USER_BASIC_AUTH = getConfigValue('perUserBasicAuth', false); | |
| 22 | 24 | const ANON_CSRF_SECRET = crypto.randomBytes(64).toString('base64'); |
| 23 | 25 | |
| 24 | 26 | /** |
| @@ -567,14 +569,43 @@ function shouldRedirectToLogin(request) { | ||
| 567 | 569 | |
| 568 | 570 | /** |
| 569 | 571 | * Tries auto-login if there is only one user and it's not password protected. |
| 572 | + * or another configured method such authlia or basic | |
| 570 | 573 | * @param {import('express').Request} request Request object |
| 574 | + * @param {boolean} basicAuthMode If Basic auth mode is enabled | |
| 571 | 575 | * @returns {Promise<boolean>} Whether auto-login was performed |
| 572 | 576 | */ |
| 573 | 577 | async function tryAutoLogin(request, basicAuthMode) { |
| 574 | 578 | if (!ENABLE_ACCOUNTS || request.user || !request.session) { |
| 575 | 579 | return false; |
| 576 | 580 | } |
| 577 | 581 | |
| 582 | + if (!request.query.noauto) { | |
| 583 | + if (await singleUserLogin(request)) { | |
| 584 | + return true; | |
| 585 | + } | |
| 586 | + | |
| 587 | + if (AUTHELIA_AUTH && await autheliaUserLogin(request)) { | |
| 588 | + return true; | |
| 589 | + } | |
| 590 | + | |
| 591 | + if (basicAuthMode && PER_USER_BASIC_AUTH && await basicUserLogin(request)) { | |
| 592 | + return true; | |
| 593 | + } | |
| 594 | + } | |
| 595 | + | |
| 596 | + return false; | |
| 597 | +} | |
| 598 | + | |
| 599 | +/** | |
| 600 | + * Tries auto-login if there is only one user and it's not password protected. | |
| 601 | + * @param {import('express').Request} request Request object | |
| 602 | + * @returns {Promise<boolean>} Whether auto-login was performed | |
| 603 | + */ | |
| 604 | +async function singleUserLogin(request) { | |
| 605 | + if (!request.session) { | |
| 606 | + return false; | |
| 607 | + } | |
| 608 | + | |
| 578 | 609 | const userHandles = await getAllUserHandles(); |
| 579 | 610 | if (userHandles.length === 1) { |
| 580 | 611 | const user = await storage.getItem(toKey(userHandles[0])); |
| @@ -583,6 +614,75 @@ async function tryAutoLogin(request) { | ||
| 583 | 614 | return true; |
| 584 | 615 | } |
| 585 | 616 | } |
| 617 | + return false; | |
| 618 | +} | |
| 619 | + | |
| 620 | +/** | |
| 621 | + * Tries auto-login with authlia trusted headers. | |
| 622 | + * https://www.authelia.com/integration/trusted-header-sso/introduction/ | |
| 623 | + * @param {import('express').Request} request Request object | |
| 624 | + * @returns {Promise<boolean>} Whether auto-login was performed | |
| 625 | + */ | |
| 626 | +async function autheliaUserLogin(request) { | |
| 627 | + if (!request.session) { | |
| 628 | + return false; | |
| 629 | + } | |
| 630 | + | |
| 631 | + const remoteUser = request.get('Remote-User'); | |
| 632 | + if (!remoteUser) { | |
| 633 | + return false; | |
| 634 | + } | |
| 635 | + | |
| 636 | + const userHandles = await getAllUserHandles(); | |
| 637 | + for (const userHandle of userHandles) { | |
| 638 | + if (remoteUser === userHandle) { | |
| 639 | + const user = await storage.getItem(toKey(userHandle)); | |
| 640 | + if (user && user.enabled) { | |
| 641 | + request.session.handle = userHandle; | |
| 642 | + return true; | |
| 643 | + } | |
| 644 | + } | |
| 645 | + } | |
| 646 | + return false; | |
| 647 | +} | |
| 648 | + | |
| 649 | +/** | |
| 650 | + * Tries auto-login with basic auth username. | |
| 651 | + * @param {import('express').Request} request Request object | |
| 652 | + * @returns {Promise<boolean>} Whether auto-login was performed | |
| 653 | + */ | |
| 654 | +async function basicUserLogin(request) { | |
| 655 | + if (!request.session) { | |
| 656 | + return false; | |
| 657 | + } | |
| 658 | + | |
| 659 | + const authHeader = request.headers.authorization; | |
| 660 | + | |
| 661 | + if (!authHeader) { | |
| 662 | + return false; | |
| 663 | + } | |
| 664 | + | |
| 665 | + const [scheme, credentials] = authHeader.split(' '); | |
| 666 | + | |
| 667 | + if (scheme !== 'Basic' || !credentials) { | |
| 668 | + return false; | |
| 669 | + } | |
| 670 | + | |
| 671 | + const [username, password] = Buffer.from(credentials, 'base64') | |
| 672 | + .toString('utf8') | |
| 673 | + .split(':'); | |
| 674 | + | |
| 675 | + const userHandles = await getAllUserHandles(); | |
| 676 | + for (const userHandle of userHandles) { | |
| 677 | + if (username === userHandle) { | |
| 678 | + const user = await storage.getItem(toKey(userHandle)); | |
| 679 | + // Verify pass again here just to be sure | |
| 680 | + if (user && user.enabled && user.password && user.password === getPasswordHash(password, user.salt)) { | |
| 681 | + request.session.handle = userHandle; | |
| 682 | + return true; | |
| 683 | + } | |
| 684 | + } | |
| 685 | + } | |
| 586 | 686 | |
| 587 | 687 | return false; |
| 588 | 688 | } |