Merge pull request #2953 from QuantumEntangledAndy/feat/AdditionalLogins Add additional login methods
Signed| @@ -51,6 +51,19 @@ requestProxy: | |||
| 51 | enableUserAccounts: false | 51 | enableUserAccounts: false |
| 52 | # Enable discreet login mode: hides user list on the login screen | 52 | # Enable discreet login mode: hides user list on the login screen |
| 53 | enableDiscreetLogin: false | 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 | # User session timeout *in seconds* (defaults to 24 hours). | 67 | # User session timeout *in seconds* (defaults to 24 hours). |
| 55 | ## Set to a positive number to expire session after a certain time of inactivity | 68 | ## Set to a positive number to expire session after a certain time of inactivity |
| 56 | ## Set to 0 to expire session when the browser is closed | 69 | ## Set to 0 to expire session when the browser is closed |
| @@ -180,7 +180,13 @@ function displayError(message) { | |||
| 180 | * Preserves the query string. | 180 | * Preserves the query string. |
| 181 | */ | 181 | */ |
| 182 | function redirectToHome() { | 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 | headers: getRequestHeaders(), | 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 | const DEFAULT_ACCOUNTS = false; | 65 | const DEFAULT_ACCOUNTS = false; |
| 66 | const DEFAULT_CSRF_DISABLED = false; | 66 | const DEFAULT_CSRF_DISABLED = false; |
| 67 | const DEFAULT_BASIC_AUTH = false; | 67 | const DEFAULT_BASIC_AUTH = false; |
| 68 | const DEFAULT_PER_USER_BASIC_AUTH = false; | ||
| 68 | 69 | ||
| 69 | const DEFAULT_ENABLE_IPV6 = false; | 70 | const DEFAULT_ENABLE_IPV6 = false; |
| 70 | const DEFAULT_ENABLE_IPV4 = true; | 71 | const DEFAULT_ENABLE_IPV4 = true; |
| @@ -184,6 +185,7 @@ const enableWhitelist = cliArguments.whitelist ?? getConfigValue('whitelistMode' | |||
| 184 | const dataRoot = cliArguments.dataRoot ?? getConfigValue('dataRoot', './data'); | 185 | const dataRoot = cliArguments.dataRoot ?? getConfigValue('dataRoot', './data'); |
| 185 | const disableCsrf = cliArguments.disableCsrf ?? getConfigValue('disableCsrfProtection', DEFAULT_CSRF_DISABLED); | 186 | const disableCsrf = cliArguments.disableCsrf ?? getConfigValue('disableCsrfProtection', DEFAULT_CSRF_DISABLED); |
| 186 | const basicAuthMode = cliArguments.basicAuthMode ?? getConfigValue('basicAuthMode', DEFAULT_BASIC_AUTH); | 187 | const basicAuthMode = cliArguments.basicAuthMode ?? getConfigValue('basicAuthMode', DEFAULT_BASIC_AUTH); |
| 188 | const perUserBasicAuth = getConfigValue('perUserBasicAuth', DEFAULT_PER_USER_BASIC_AUTH); | ||
| 187 | const enableAccounts = getConfigValue('enableUserAccounts', DEFAULT_ACCOUNTS); | 189 | const enableAccounts = getConfigValue('enableUserAccounts', DEFAULT_ACCOUNTS); |
| 188 | 190 | ||
| 189 | const uploadsPath = path.join(dataRoot, require('./src/constants').UPLOADS_DIRECTORY); | 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 | try { | 365 | try { |
| 364 | const autoLogin = await userModule.tryAutoLogin(request); | 366 | const autoLogin = await userModule.tryAutoLogin(request, basicAuthMode); |
| 365 | 367 | ||
| 366 | if (autoLogin) { | 368 | if (autoLogin) { |
| 367 | return response.redirect('/'); | 369 | return response.redirect('/'); |
| @@ -756,11 +758,15 @@ const postSetupTasks = async function (v6Failed, v4Failed) { | |||
| 756 | } | 758 | } |
| 757 | 759 | ||
| 758 | if (basicAuthMode) { | 760 | if (basicAuthMode) { |
| 761 | if (perUserBasicAuth && !enableAccounts) { | ||
| 762 | console.error(color.red('Per-user basic authentication is enabled, but user accounts are disabled. This configuration may be insecure.')); | ||
| 763 | } else if (!perUserBasicAuth) { | ||
| 759 | const basicAuthUser = getConfigValue('basicAuthUser', {}); | 764 | const basicAuthUser = getConfigValue('basicAuthUser', {}); |
| 760 | if (!basicAuthUser?.username || !basicAuthUser?.password) { | 765 | if (!basicAuthUser?.username || !basicAuthUser?.password) { |
| 761 | console.warn(color.yellow('Basic Authentication is enabled, but username or password is not set or empty!')); | 766 | console.warn(color.yellow('Basic Authentication is enabled, but username or password is not set or empty!')); |
| 762 | } | 767 | } |
| 763 | } | 768 | } |
| 769 | } | ||
| 764 | }; | 770 | }; |
| 765 | 771 | ||
| 766 | /** | 772 | /** |
| @@ -2,14 +2,19 @@ | |||
| 2 | * When applied, this middleware will ensure the request contains the required header for basic authentication and only | 2 | * When applied, this middleware will ensure the request contains the required header for basic authentication and only |
| 3 | * allow access to the endpoint after successful authentication. | 3 | * allow access to the endpoint after successful authentication. |
| 4 | */ | 4 | */ |
| 5 | const { getConfig } = require('../util.js'); | 5 | const { getAllUserHandles, toKey, getPasswordHash } = require('../users.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 | const unauthorizedResponse = (res) => { | 12 | const unauthorizedResponse = (res) => { |
| 8 | res.set('WWW-Authenticate', 'Basic realm="SillyTavern", charset="UTF-8"'); | 13 | res.set('WWW-Authenticate', 'Basic realm="SillyTavern", charset="UTF-8"'); |
| 9 | return res.status(401).send('Authentication required'); | 14 | return res.status(401).send('Authentication required'); |
| 10 | }; | 15 | }; |
| 11 | 16 | ||
| 12 | const basicAuthMiddleware = function (request, response, callback) { | 17 | const basicAuthMiddleware = async function (request, response, callback) { |
| 13 | const config = getConfig(); | 18 | const config = getConfig(); |
| 14 | const authHeader = request.headers.authorization; | 19 | const authHeader = request.headers.authorization; |
| 15 | 20 | ||
| @@ -23,15 +28,25 @@ const basicAuthMiddleware = function (request, response, callback) { | |||
| 23 | return unauthorizedResponse(response); | 28 | return unauthorizedResponse(response); |
| 24 | } | 29 | } |
| 25 | 30 | ||
| 31 | const usePerUserAuth = PER_USER_BASIC_AUTH && ENABLE_ACCOUNTS; | ||
| 26 | const [username, password] = Buffer.from(credentials, 'base64') | 32 | const [username, password] = Buffer.from(credentials, 'base64') |
| 27 | .toString('utf8') | 33 | .toString('utf8') |
| 28 | .split(':'); | 34 | .split(':'); |
| 29 | 35 | ||
| 30 | if (username === config.basicAuthUser.username && password === config.basicAuthUser.password) { | 36 | if (!usePerUserAuth && username === config.basicAuthUser.username && password === config.basicAuthUser.password) { |
| 31 | return callback(); | 37 | return callback(); |
| 32 | } else { | 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 | } | ||
| 34 | } | 46 | } |
| 47 | } | ||
| 48 | } | ||
| 49 | return unauthorizedResponse(response); | ||
| 35 | }; | 50 | }; |
| 36 | 51 | ||
| 37 | module.exports = basicAuthMiddleware; | 52 | module.exports = basicAuthMiddleware; |
| @@ -19,6 +19,8 @@ const { readSecret, writeSecret } = require('./endpoints/secrets'); | |||
| 19 | const KEY_PREFIX = 'user:'; | 19 | const KEY_PREFIX = 'user:'; |
| 20 | const AVATAR_PREFIX = 'avatar:'; | 20 | const AVATAR_PREFIX = 'avatar:'; |
| 21 | const ENABLE_ACCOUNTS = getConfigValue('enableUserAccounts', false); | 21 | const ENABLE_ACCOUNTS = getConfigValue('enableUserAccounts', false); |
| 22 | const AUTHELIA_AUTH = getConfigValue('autheliaAuth', false); | ||
| 23 | const PER_USER_BASIC_AUTH = getConfigValue('perUserBasicAuth', false); | ||
| 22 | const ANON_CSRF_SECRET = crypto.randomBytes(64).toString('base64'); | 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 | * Tries auto-login if there is only one user and it's not password protected. | 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 | * @param {import('express').Request} request Request object | 573 | * @param {import('express').Request} request Request object |
| 574 | * @param {boolean} basicAuthMode If Basic auth mode is enabled | ||
| 571 | * @returns {Promise<boolean>} Whether auto-login was performed | 575 | * @returns {Promise<boolean>} Whether auto-login was performed |
| 572 | */ | 576 | */ |
| 573 | async function tryAutoLogin(request) { | 577 | async function tryAutoLogin(request, basicAuthMode) { |
| 574 | if (!ENABLE_ACCOUNTS || request.user || !request.session) { | 578 | if (!ENABLE_ACCOUNTS || request.user || !request.session) { |
| 575 | return false; | 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 | const userHandles = await getAllUserHandles(); | 609 | const userHandles = await getAllUserHandles(); |
| 579 | if (userHandles.length === 1) { | 610 | if (userHandles.length === 1) { |
| 580 | const user = await storage.getItem(toKey(userHandles[0])); | 611 | const user = await storage.getItem(toKey(userHandles[0])); |
| @@ -583,6 +614,75 @@ async function tryAutoLogin(request) { | |||
| 583 | return true; | 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 | return false; | 687 | return false; |
| 588 | } | 688 | } |