| 1 | /** |
| 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. |
| 4 | */ |
| 5 | import { Buffer } from 'node:buffer'; |
| 6 | import path from 'node:path'; |
| 7 | import storage from 'node-persist'; |
| 8 | import { RateLimiterMemory, RateLimiterRes } from 'rate-limiter-flexible'; |
| 9 | import { getAllUserHandles, toKey, getPasswordHash } from '../users.js'; |
| 10 | import { getConfigValue, safeReadFileSync } from '../util.js'; |
| 11 | import { getIpAddress, retryAfter } from '../express-common.js'; |
| 12 | |
| 13 | const PER_USER_BASIC_AUTH = !!getConfigValue('perUserBasicAuth', false, 'boolean'); |
| 14 | const ENABLE_ACCOUNTS = !!getConfigValue('enableUserAccounts', false, 'boolean'); |
| 15 | const PREFER_REAL_IP_HEADER = !!getConfigValue('rateLimiting.preferRealIpHeader', false, 'boolean'); |
| 16 | const BASIC_AUTH_ATTEMPTS = getConfigValue('rateLimiting.basicAuthMaxAttempts', 5, 'number'); |
| 17 | |
| 18 | const basicAuthLimiter = new RateLimiterMemory({ |
| 19 | points: BASIC_AUTH_ATTEMPTS > 0 ? BASIC_AUTH_ATTEMPTS : Number.MAX_SAFE_INTEGER, |
| 20 | duration: 60, |
| 21 | }); |
| 22 | |
| 23 | const basicAuthMiddleware = async function (request, response, callback) { |
| 24 | const unauthorizedResponse = (res) => { |
| 25 | const unauthorizedWebpage = safeReadFileSync(path.join(globalThis.DATA_ROOT, '_errors', 'unauthorized.html')) ?? ''; |
| 26 | res.set('WWW-Authenticate', 'Basic realm="SillyTavern", charset="UTF-8"'); |
| 27 | return res.status(401).send(unauthorizedWebpage); |
| 28 | }; |
| 29 | |
| 30 | try { |
| 31 | const ip = getIpAddress(request, PREFER_REAL_IP_HEADER); |
| 32 | |
| 33 | const basicAuthUserName = getConfigValue('basicAuthUser.username'); |
| 34 | const basicAuthUserPassword = getConfigValue('basicAuthUser.password'); |
| 35 | const authHeader = request.headers.authorization; |
| 36 | |
| 37 | if (!authHeader) { |
| 38 | return unauthorizedResponse(response); |
| 39 | } |
| 40 | |
| 41 | const [scheme, credentials] = authHeader.split(' '); |
| 42 | |
| 43 | if (scheme !== 'Basic' || !credentials) { |
| 44 | return unauthorizedResponse(response); |
| 45 | } |
| 46 | |
| 47 | const rateLimit = await basicAuthLimiter.get(ip); |
| 48 | |
| 49 | if (rateLimit !== null && rateLimit.consumedPoints > basicAuthLimiter.points) { |
| 50 | throw rateLimit; |
| 51 | } |
| 52 | |
| 53 | const usePerUserAuth = PER_USER_BASIC_AUTH && ENABLE_ACCOUNTS; |
| 54 | const [username, ...passwordParts] = Buffer.from(credentials, 'base64') |
| 55 | .toString('utf8') |
| 56 | .split(':'); |
| 57 | const password = passwordParts.join(':'); |
| 58 | |
| 59 | if (!usePerUserAuth && username === basicAuthUserName && password === basicAuthUserPassword) { |
| 60 | await basicAuthLimiter.delete(ip); |
| 61 | return callback(); |
| 62 | } else if (usePerUserAuth) { |
| 63 | const userHandles = await getAllUserHandles(); |
| 64 | for (const userHandle of userHandles) { |
| 65 | if (username === userHandle) { |
| 66 | const user = await storage.getItem(toKey(userHandle)); |
| 67 | if (user && user.enabled && (user.password && user.password === getPasswordHash(password, user.salt))) { |
| 68 | await basicAuthLimiter.delete(ip); |
| 69 | return callback(); |
| 70 | } |
| 71 | } |
| 72 | } |
| 73 | } |
| 74 | |
| 75 | await basicAuthLimiter.consume(ip); |
| 76 | return unauthorizedResponse(response); |
| 77 | } catch (error) { |
| 78 | if (error instanceof RateLimiterRes) { |
| 79 | console.error('Basic auth failed: Rate limited from', getIpAddress(request, PREFER_REAL_IP_HEADER), request.method, request.originalUrl); |
| 80 | return retryAfter(response, error).sendStatus(429); |
| 81 | } |
| 82 | console.error('Basic auth error:', error); |
| 83 | return response.sendStatus(500); |
| 84 | } |
| 85 | }; |
| 86 | |
| 87 | export default basicAuthMiddleware; |