diff --git a/client/src/javascript/components/AppWrapper.tsx b/client/src/javascript/components/AppWrapper.tsx index 78de260a..38da887a 100644 --- a/client/src/javascript/components/AppWrapper.tsx +++ b/client/src/javascript/components/AppWrapper.tsx @@ -22,7 +22,7 @@ const AppWrapper: React.FC = (props: AppWrapperProps) => { overlay = ; } - if (AuthStore.isAuthenticated && !ClientStatusStore.isConnected && !ConfigStore.disableAuth) { + if (AuthStore.isAuthenticated && !ClientStatusStore.isConnected && ConfigStore.authMethod !== 'none') { overlay = (
diff --git a/client/src/javascript/components/modals/settings-modal/SettingsModal.tsx b/client/src/javascript/components/modals/settings-modal/SettingsModal.tsx index e979a38d..08ac7d60 100644 --- a/client/src/javascript/components/modals/settings-modal/SettingsModal.tsx +++ b/client/src/javascript/components/modals/settings-modal/SettingsModal.tsx @@ -128,7 +128,7 @@ class SettingsModal extends Component { - if (ConfigStore.disableAuth) { + if (ConfigStore.authMethod === 'none') { return null; } diff --git a/client/src/javascript/stores/ConfigStore.ts b/client/src/javascript/stores/ConfigStore.ts index ed2c9261..c8e241a2 100644 --- a/client/src/javascript/stores/ConfigStore.ts +++ b/client/src/javascript/stores/ConfigStore.ts @@ -1,18 +1,19 @@ import {makeAutoObservable} from 'mobx'; +import type {AuthMethod} from '@shared/schema/Auth'; import type {AuthVerificationPreloadConfigs} from '@shared/schema/api/auth'; class ConfigStore { baseURI = window.location.pathname.substr(0, window.location.pathname.lastIndexOf('/') + 1); - disableAuth = false; + authMethod: AuthMethod = 'default'; pollInterval = 2000; constructor() { makeAutoObservable(this); } - handlePreloadConfigs({disableAuth, pollInterval}: AuthVerificationPreloadConfigs) { - this.disableAuth = disableAuth != null ? disableAuth : false; + handlePreloadConfigs({authMethod, pollInterval}: AuthVerificationPreloadConfigs) { + this.authMethod = authMethod || 'default'; this.pollInterval = pollInterval || 2000; } } diff --git a/config.cli.js b/config.cli.js index 39406baa..ed84dea1 100644 --- a/config.cli.js +++ b/config.cli.js @@ -30,40 +30,47 @@ const {argv} = require('yargs') }) .option('secret', { alias: 's', + hidden: true, describe: 'A unique secret, a random one will be generated if not provided', type: 'string', }) + .option('auth', { + describe: 'Access control and user management method', + choices: ['default', 'none'], + }) .option('noauth', { alias: 'n', + hidden: true, default: false, - describe: "Disable Flood's builtin access control system, needs rthost+rtport OR rtsocket.", + describe: "Disable Flood's builtin access control system, deprecated, use auth=none instead", type: 'boolean', }) .option('rthost', { - describe: "Depends on noauth: Host of rTorrent's SCGI interface", + describe: "Host of rTorrent's SCGI interface", type: 'string', }) .option('rtport', { - describe: "Depends on noauth: Port of rTorrent's SCGI interface", + describe: "Port of rTorrent's SCGI interface", type: 'number', }) .option('rtsocket', { conflicts: ['rthost', 'rtport'], - describe: "Depends on noauth: Path to rTorrent's SCGI unix socket", + describe: "Path to rTorrent's SCGI unix socket", type: 'string', }) .option('qburl', { - describe: 'Depends on noauth: URL to qBittorrent Web API', + describe: 'URL to qBittorrent Web API', type: 'string', }) .option('qbuser', { - describe: 'Depends on noauth: Username of qBittorrent Web API', + describe: 'Username of qBittorrent Web API', type: 'string', }) .option('qbpass', { - describe: 'Depends on noauth: Password of qBittorrent Web API', + describe: 'Password of qBittorrent Web API', type: 'string', }) + .group(['rthost', 'rtport', 'rtsocket', 'qburl', 'qbuser', 'qbpass'], 'When auth=none:') .option('ssl', { default: false, describe: 'Enable SSL, key.pem and fullchain.pem needed in runtime directory', @@ -195,12 +202,17 @@ if (argv.rtsocket != null || argv.rthost != null) { }; } +let authMethod = 'default'; +if (argv.noauth || argv.auth === 'none') { + authMethod = 'none'; +} + const CONFIG = { baseURI: argv.baseuri, dbCleanInterval: argv.dbclean, dbPath: path.resolve(path.join(argv.rundir, 'db')), tempPath: path.resolve(path.join(argv.rundir, 'temp')), - disableUsersAndAuth: argv.noauth, + authMethod, configUser: connectionSettings, floodServerHost: argv.host, floodServerPort: argv.port, diff --git a/config.d.ts b/config.d.ts index cd35f3dc..64a7203d 100644 --- a/config.d.ts +++ b/config.d.ts @@ -1,3 +1,4 @@ +import type {AuthMethod} from '@shared/schema/Auth'; import type {ClientConnectionSettings} from '@shared/schema/ClientConnectionSettings'; declare const CONFIG: { @@ -5,7 +6,7 @@ declare const CONFIG: { dbCleanInterval: number; dbPath: string; tempPath: string; - disableUsersAndAuth: boolean; + authMethod: AuthMethod; configUser: ClientConnectionSettings; floodServerHost: string; floodServerPort: number; diff --git a/config.template.js b/config.template.js index a986ecb0..f69a2f03 100644 --- a/config.template.js +++ b/config.template.js @@ -24,11 +24,23 @@ const CONFIG = { // Where to store Flood's temporary files tempPath: './run/temp/', - // If this is true, there will be no users and no attempt to - // authenticate or password-protect any page. In that case, - // instead of per-user config, the following configUser settings - // will be used. - disableUsersAndAuth: false, + // + // Authentication and user management method: + // + // default: + // Flood uses its own authentication and user management system. Users are authenticated + // by password and will be prompted to configure the connection to torrent client in the + // web interface. On successful authentication via /authenticate API endpoint, Flood will + // send a cookie with token to user. Users with admin privileges may create additional + // users with different password and torrent client configurations. + // + // none: + // There is no per-user config and no attempt to authenticate. An auth cookie with token is + // still needed to access API endpoints. This allows us to utilize browser's protections + // against session hijacking. The cookie with token will be sent unconditionally when + // /authenticate or /verify endpoints are accessed. Instead of per-user config, the + // configUser settings will be used. + authMethod: 'default', // Settings for the no-user configuration. configUser: { diff --git a/server/.jest/auth.setup.js b/server/.jest/auth.setup.js index 4f375deb..198f8f9f 100644 --- a/server/.jest/auth.setup.js +++ b/server/.jest/auth.setup.js @@ -7,7 +7,7 @@ const temporaryRuntimeDirectory = path.resolve(os.tmpdir(), `flood.test.${crypto process.argv = ['node', 'flood']; process.argv.push('--rundir', temporaryRuntimeDirectory); -process.argv.push('--noauth', 'false'); +process.argv.push('--auth', 'default'); afterAll(() => { if (process.env.CI !== 'true') { diff --git a/server/.jest/qbittorrent.setup.js b/server/.jest/qbittorrent.setup.js index 71da5c4f..078ca39c 100644 --- a/server/.jest/qbittorrent.setup.js +++ b/server/.jest/qbittorrent.setup.js @@ -21,7 +21,7 @@ const qBittorrentDaemon = spawn( process.argv = ['node', 'flood']; process.argv.push('--rundir', temporaryRuntimeDirectory); -process.argv.push('--noauth'); +process.argv.push('--auth', 'none'); process.argv.push('--qburl', `http://127.0.0.1:${qbtPort}`); process.argv.push('--qbuser', 'admin'); process.argv.push('--qbpass', 'adminadmin'); diff --git a/server/.jest/rtorrent.setup.js b/server/.jest/rtorrent.setup.js index c0066b55..0327c4ad 100644 --- a/server/.jest/rtorrent.setup.js +++ b/server/.jest/rtorrent.setup.js @@ -32,7 +32,7 @@ const rTorrentProcess = spawn( process.argv = ['node', 'flood']; process.argv.push('--rundir', temporaryRuntimeDirectory); -process.argv.push('--noauth'); +process.argv.push('--auth', 'none'); process.argv.push('--rtsocket', rTorrentSocket); process.argv.push('--allowedpath', temporaryRuntimeDirectory); diff --git a/server/bin/web-server.ts b/server/bin/web-server.ts index 7530e75e..90d6eed0 100755 --- a/server/bin/web-server.ts +++ b/server/bin/web-server.ts @@ -103,7 +103,7 @@ const startWebServer = () => { console.log(chalk.green(`Flood server starting on ${address}.\n`)); - if (config.disableUsersAndAuth) { + if (config.authMethod === 'none') { console.log(chalk.yellow('Starting without builtin authentication\n')); } }; diff --git a/server/models/Users.ts b/server/models/Users.ts index 4826fbc2..af0f0299 100644 --- a/server/models/Users.ts +++ b/server/models/Users.ts @@ -169,7 +169,7 @@ class Users { } lookupUser(username: string, callback: (err: Error | null, user?: UserInDatabase) => void): void { - if (config.disableUsersAndAuth) { + if (config.authMethod === 'none') { return callback(null, this.getConfigUser()); } @@ -185,7 +185,7 @@ class Users { } listUsers(callback: (users: Array | null, err?: Error) => void): void { - if (config.disableUsersAndAuth) { + if (config.authMethod === 'none') { return callback([this.getConfigUser()]); } diff --git a/server/routes/api/auth.ts b/server/routes/api/auth.ts index ee99eee9..f9b75ee7 100644 --- a/server/routes/api/auth.ts +++ b/server/routes/api/auth.ts @@ -81,7 +81,7 @@ const validationError = (res: Response, err: Error) => { }; const preloadConfigs: AuthVerificationPreloadConfigs = { - disableAuth: config.disableUsersAndAuth, + authMethod: config.authMethod, pollInterval: config.torrentClientPollInterval, }; @@ -98,7 +98,7 @@ router.use('/users', passport.authenticate('jwt', {session: false}), requireAdmi * @return {AuthAuthenticationResponse} 200 - success response - application/json */ router.post('/authenticate', (req, res) => { - if (config.disableUsersAndAuth) { + if (config.authMethod === 'none') { sendAuthenticationResponse(res, Users.getConfigUser()); return; } @@ -163,8 +163,8 @@ router.use('/register', (req, res, next) => { * @return {AuthAuthenticationResponse} 200 - success response - application/json */ router.post('/register', (req, res) => { - // No user can be registered when disableUsersAndAuth is true - if (config.disableUsersAndAuth) { + // No user can be registered when authMethod is none + if (config.authMethod === 'none') { // Return 404 res.status(404).send('Not found'); return; @@ -200,7 +200,7 @@ router.post('/regis // Allow unauthenticated verification if no users are currently registered. router.use('/verify', (req, res, next) => { // Unconditionally provide a token if auth is disabled - if (config.disableUsersAndAuth) { + if (config.authMethod === 'none') { const {username, level} = Users.getConfigUser(); getAuthToken(username, res); @@ -284,8 +284,8 @@ router.get('/logout', (_req, res) => { router.use('/', requireAdmin); router.use('/users', (_req, res, next) => { - // No operation on user when disableUsersAndAuth is true - if (config.disableUsersAndAuth) { + // No operation on user when authMethod is none + if (config.authMethod === 'none') { // Return 404 res.status(404).send('Not found'); } diff --git a/shared/schema/Auth.ts b/shared/schema/Auth.ts index c5440a21..353b6c16 100644 --- a/shared/schema/Auth.ts +++ b/shared/schema/Auth.ts @@ -4,6 +4,8 @@ import type {infer as zodInfer} from 'zod'; import {AccessLevel} from './constants/Auth'; import {clientConnectionSettingsSchema} from './ClientConnectionSettings'; +export type AuthMethod = 'default' | 'none'; + export const credentialsSchema = object({ username: string(), password: string(), diff --git a/shared/schema/api/auth.ts b/shared/schema/api/auth.ts index 627b170e..5c7831a6 100644 --- a/shared/schema/api/auth.ts +++ b/shared/schema/api/auth.ts @@ -3,6 +3,8 @@ import type {infer as zodInfer} from 'zod'; import {AccessLevel} from '../constants/Auth'; import {credentialsSchema} from '../Auth'; +import type {AuthMethod} from '../Auth'; + // All auth requests are schema validated to ensure security. // POST /api/auth/authenticate @@ -26,7 +28,7 @@ export type AuthUpdateUserOptions = zodInfer; // GET /api/auth/verify - preload configurations export interface AuthVerificationPreloadConfigs { - disableAuth: boolean; + authMethod: AuthMethod; pollInterval: number; }