diff --git a/README.md b/README.md index 1c9b2157..6705aab7 100644 --- a/README.md +++ b/README.md @@ -1396,15 +1396,25 @@ Edit the configuration of the Internxt CLI WebDav server as the port or the prot ``` USAGE - $ internxt webdav-config [--json] [-l ] [-p ] [-s | -h] [-t ] [-c] + $ internxt webdav-config [--json] [-x] [--debug] [-l ] [-p ] [-s | -h] [-t ] [-c] [-a] [-u + ] [-w ] FLAGS + -a, --[no-]customAuth Configures the WebDAV server to use custom authentication. -c, --[no-]createFullPath Auto-create missing parent directories during file uploads. -h, --http Configures the WebDAV server to use insecure plain HTTP. -l, --host= The listening host for the WebDAV server. -p, --port= The new port for the WebDAV server. -s, --https Configures the WebDAV server to use HTTPS with self-signed certificates. -t, --timeout= Configures the WebDAV server to use this timeout in minutes. + -u, --username= Configures the WebDAV server to use this username for custom authentication. + -w, --password= Configures the WebDAV server to use this password for custom authentication. + +HELPER FLAGS + -x, --non-interactive [env: INXT_NONINTERACTIVE] Prevents the CLI from being interactive. When enabled, the CLI will + not request input through the console and will throw errors directly. + --debug [env: INXT_DEBUG] Enables debug mode. When enabled, the CLI will print debug messages to the + console. GLOBAL FLAGS --json Format output as json. diff --git a/docker/README.md b/docker/README.md index 70bc112c..e7cb76b7 100644 --- a/docker/README.md +++ b/docker/README.md @@ -20,8 +20,12 @@ services: INXT_PASSWORD: "" # Your Internxt account password INXT_TWOFACTORCODE: "" # (Optional) Current 2FA one-time code INXT_OTPTOKEN: "" # (Optional) OTP secret for auto-generating 2FA codes + INXT_WORKSPACE_ID: "" # (Optional) Workspace ID to use for WebDAV server WEBDAV_PORT: "" # (Optional) WebDAV port. Defaults to 3005 if empty WEBDAV_PROTOCOL: "" # (Optional) WebDAV protocol. Accepts 'http' or 'https'. Defaults to 'https' if empty + WEBDAV_CUSTOM_AUTH: "" # (Optional) Enable custom authentication. Set to 'true' to enable + WEBDAV_USERNAME: "" # (Optional) Custom username for WebDAV authentication + WEBDAV_PASSWORD: "" # (Optional) Custom password for WebDAV authentication ports: - "127.0.0.1:3005:3005" # Map container port to host. Change if WEBDAV_PORT is customized ``` @@ -42,8 +46,12 @@ docker run -d \ -e INXT_PASSWORD="your_password" \ -e INXT_TWOFACTORCODE="" \ -e INXT_OTPTOKEN="" \ + -e INXT_WORKSPACE_ID="" \ -e WEBDAV_PORT="" \ -e WEBDAV_PROTOCOL="" \ + -e WEBDAV_CUSTOM_AUTH="false" \ + -e WEBDAV_USERNAME="" \ + -e WEBDAV_PASSWORD="" \ -p 127.0.0.1:3005:3005 \ internxt/webdav:latest ``` @@ -77,12 +85,28 @@ You can also run the `internxt/webdav` image directly on popular NAS devices lik | `INXT_PASSWORD` | ✅ Yes | Your Internxt account password. | | `INXT_TWOFACTORCODE` | ❌ No | Temporary one-time code from your 2FA app. Must be refreshed every startup. | | `INXT_OTPTOKEN` | ❌ No | OTP secret key (base32). Used to auto-generate fresh codes at runtime. | +| `INXT_WORKSPACE_ID` | ❌ No | Workspace ID to use. If set, the WebDAV server will operate within this workspace. | | `WEBDAV_PORT` | ❌ No | Port for the WebDAV server. Defaults to `3005` if left empty. | | `WEBDAV_PROTOCOL` | ❌ No | Protocol for the WebDAV server. Accepts `http` or `https`. Defaults to `https` if left empty. | +| `WEBDAV_CUSTOM_AUTH` | ❌ No | Enable custom Basic Authentication for WebDAV. Set to `true` to enable. | +| `WEBDAV_USERNAME` | ❌ No | Username for custom WebDAV authentication. Required if `WEBDAV_CUSTOM_AUTH` is enabled. | +| `WEBDAV_PASSWORD` | ❌ No | Password for custom WebDAV authentication. Required if `WEBDAV_CUSTOM_AUTH` is enabled. | --- +### Custom WebDAV Authentication + +By default, the WebDAV server starts with anonymous authentication enabled, meaning anyone with access to the server URL can connect without credentials. Under the hood, the server uses your Internxt credentials to access your files, but clients don't need to authenticate. If you want to restrict access to your WebDAV server or simply enhance its security, you can enable custom authentication with `WEBDAV_CUSTOM_AUTH`. + +**Security recommendations:** +- 🚨 **We strongly recommend NOT exposing your WebDAV server to the internet.** Keep it on your secure local network whenever possible. +- ⚠️ **Do NOT use your Internxt username and password** for `WEBDAV_USERNAME` and `WEBDAV_PASSWORD` +- Create unique, strong credentials specifically for WebDAV access +- Try to always use HTTPS (`WEBDAV_PROTOCOL=https`) when enabling custom authentication + +**Important:** When connecting to your WebDAV server with custom authentication enabled, you must use the credentials defined in `WEBDAV_USERNAME` and `WEBDAV_PASSWORD`, not your Internxt account credentials. + ### 🔄 2FA Options Explained If your Internxt account has **two-factor authentication enabled**, you can choose one of the following: @@ -95,6 +119,9 @@ If your Internxt account has **two-factor authentication enabled**, you can choo 💡 **Recommended:** Use `INXT_OTPTOKEN` if you want your container to run unattended without re-entering codes on each restart. +### Using Workspaces +If you have access to Internxt Workspaces and want to use the WebDAV server with a specific workspace instead of your personal drive, you can set the INXT_WORKSPACE_ID environment variable. + ## 🌐 Accessing WebDAV Once running, your Internxt WebDAV server will be available at: diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 9eb7d6ad..55089ea6 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -22,6 +22,11 @@ fi internxt login-legacy $LOGIN_ARGS +if [ -n "$INXT_WORKSPACE_ID" ]; then + echo "Switching to workspace: $INXT_WORKSPACE_ID" + internxt workspaces use -i="$INXT_WORKSPACE_ID" +fi + WEBDAV_ARGS="-l=0.0.0.0" @@ -36,6 +41,16 @@ elif [ "$proto" = "https" ]; then WEBDAV_ARGS="$WEBDAV_ARGS -s" fi +customAuth=$(echo "$WEBDAV_CUSTOM_AUTH" | tr '[:upper:]' '[:lower:]') +if [ "$customAuth" = "true" ] || [ "$customAuth" = "1" ] || [ "$customAuth" = "yes" ] || [ "$customAuth" = "y" ]; then + if [ -z "$WEBDAV_USERNAME" ] || [ -z "$WEBDAV_PASSWORD" ]; then + echo "Error: WEBDAV_USERNAME and WEBDAV_PASSWORD must be set when WEBDAV_CUSTOM_AUTH is enabled." + exit 1 + fi + echo "Enabling custom WebDAV authentication for user: $WEBDAV_USERNAME" + WEBDAV_ARGS="$WEBDAV_ARGS --customAuth -u=$WEBDAV_USERNAME -w=$WEBDAV_PASSWORD" +fi + internxt webdav-config $WEBDAV_ARGS internxt webdav enable diff --git a/src/commands/webdav-config.ts b/src/commands/webdav-config.ts index a75b9d60..8685f228 100644 --- a/src/commands/webdav-config.ts +++ b/src/commands/webdav-config.ts @@ -1,7 +1,12 @@ import { Command, Flags } from '@oclif/core'; import { ConfigService } from '../services/config.service'; import { CLIUtils } from '../utils/cli.utils'; -import { NotValidPortError } from '../types/command.types'; +import { + EmptyCustomAuthUsernameError, + MissingCredentialsWhenUsingAuthError, + NotValidPortError, + EmptyCustomAuthPasswordError, +} from '../types/command.types'; import { ValidationService } from '../services/validation.service'; export default class WebDAVConfig extends Command { @@ -10,6 +15,7 @@ export default class WebDAVConfig extends Command { static readonly aliases = []; static readonly examples = ['<%= config.bin %> <%= command.id %>']; static readonly flags = { + ...CLIUtils.CommonFlags, host: Flags.string({ char: 'l', description: 'The listening host for the WebDAV server.', @@ -44,13 +50,31 @@ export default class WebDAVConfig extends Command { required: false, allowNo: true, }), + customAuth: Flags.boolean({ + char: 'a', + description: 'Configures the WebDAV server to use custom authentication.', + required: false, + default: undefined, + allowNo: true, + }), + username: Flags.string({ + char: 'u', + description: 'Configures the WebDAV server to use this username for custom authentication.', + required: false, + }), + password: Flags.string({ + char: 'w', + description: 'Configures the WebDAV server to use this password for custom authentication.', + required: false, + }), }; static readonly enableJsonFlag = true; public run = async () => { - const { - flags: { host, port, http, https, timeout, createFullPath }, - } = await this.parse(WebDAVConfig); + const { flags } = await this.parse(WebDAVConfig); + const { host, port, https, http, timeout, createFullPath, customAuth, username, password } = flags; + const nonInteractive = flags['non-interactive']; + const webdavConfig = await ConfigService.instance.readWebdavConfig(); if (host) { @@ -81,10 +105,30 @@ export default class WebDAVConfig extends Command { webdavConfig['createFullPath'] = createFullPath; } + if (customAuth !== undefined) { + webdavConfig['customAuth'] = customAuth; + } + if (username) { + webdavConfig['username'] = await this.getUsername(username, nonInteractive); + } + if (password) { + webdavConfig['password'] = await this.getPassword(password, nonInteractive); + } + if (webdavConfig['customAuth'] && (!webdavConfig['username'] || !webdavConfig['password'])) { + throw new MissingCredentialsWhenUsingAuthError(); + } + await ConfigService.instance.saveWebdavConfig(webdavConfig); - const message = `On the next start, the WebDAV server will use the next config: ${JSON.stringify(webdavConfig)}`; + + const printWebdavConfig = { + ...webdavConfig, + password: undefined, + }; + + const message = + 'On the next start, the WebDAV server will use the next config: ' + JSON.stringify(printWebdavConfig); CLIUtils.success(this.log.bind(this), message); - return { success: true, message, config: webdavConfig }; + return { success: true, message, config: printWebdavConfig }; }; public catch = async (error: Error) => { @@ -97,4 +141,48 @@ export default class WebDAVConfig extends Command { }); this.exit(1); }; + + private getUsername = async (usernameFlag: string | undefined, nonInteractive: boolean): Promise => { + const username = await CLIUtils.getValueFromFlag( + { + value: usernameFlag, + name: WebDAVConfig.flags['username'].name, + }, + { + nonInteractive, + prompt: { + message: 'What is the custom auth username?', + options: { type: 'input' }, + }, + }, + { + validate: ValidationService.instance.validateStringIsNotEmpty, + error: new EmptyCustomAuthUsernameError(), + }, + this.log.bind(this), + ); + return username; + }; + + private getPassword = async (passwordFlag: string | undefined, nonInteractive: boolean): Promise => { + const password = await CLIUtils.getValueFromFlag( + { + value: passwordFlag, + name: WebDAVConfig.flags['password'].name, + }, + { + nonInteractive, + prompt: { + message: 'What is the custom auth password?', + options: { type: 'password' }, + }, + }, + { + validate: ValidationService.instance.validateStringIsNotEmpty, + error: new EmptyCustomAuthPasswordError(), + }, + this.log.bind(this), + ); + return password; + }; } diff --git a/src/commands/workspaces-use.ts b/src/commands/workspaces-use.ts index b019a9d2..b180486e 100644 --- a/src/commands/workspaces-use.ts +++ b/src/commands/workspaces-use.ts @@ -1,7 +1,12 @@ import { Command, Flags } from '@oclif/core'; import { ConfigService } from '../services/config.service'; -import { CLIUtils } from '../utils/cli.utils'; -import { MissingCredentialsError, NotValidWorkspaceUuidError } from '../types/command.types'; +import { CLIUtils, LogReporter } from '../utils/cli.utils'; +import { + LoginCredentials, + MissingCredentialsError, + NotValidWorkspaceUuidError, + Workspace, +} from '../types/command.types'; import { WorkspaceService } from '../services/drive/workspace.service'; import { FormatUtils } from '../utils/format.utils'; import { ValidationService } from '../services/validation.service'; @@ -43,46 +48,22 @@ export default class WorkspacesUse extends Command { const userCredentials = await ConfigService.instance.readUser(); if (!userCredentials) throw new MissingCredentialsError(); + const reporter = this.log.bind(this); + if (flags['personal']) { - return WorkspacesUnset.unsetWorkspace(userCredentials, this.log.bind(this)); + return WorkspacesUnset.unsetWorkspace(userCredentials, reporter); } - const workspaces = await WorkspaceService.instance.getAvailableWorkspaces(userCredentials.user); - const availableWorkspaces: string[] = workspaces.map((workspaceData) => { - const name = workspaceData.workspace.name; - const id = workspaceData.workspace.id; - const totalUsedSpace = - Number(workspaceData.workspaceUser?.driveUsage ?? 0) + Number(workspaceData.workspaceUser?.backupsUsage ?? 0); - const spaceLimit = Number(workspaceData.workspaceUser?.spaceLimit ?? 0); - const usedSpace = FormatUtils.humanFileSize(totalUsedSpace); - const availableSpace = FormatUtils.formatLimit(spaceLimit); + const workspace = await this.getWorkspace(userCredentials, flags['id'], nonInteractive, reporter); - return `[${id}] Name: ${name} | Used Space: ${usedSpace} | Available Space: ${availableSpace}`; - }); - const workspaceUuid = await this.getWorkspaceUuid(flags['id'], availableWorkspaces, nonInteractive); - - const workspaceCredentials = await WorkspaceService.instance.getWorkspaceCredentials(workspaceUuid); - const selectedWorkspace = workspaces.find((workspace) => workspace.workspace.id === workspaceUuid); - if (!selectedWorkspace) throw new NotValidWorkspaceUuidError(); - - SdkManager.init({ token: userCredentials.token, workspaceToken: workspaceCredentials.token }); - - await ConfigService.instance.saveUser({ - ...userCredentials, - workspace: { - workspaceCredentials, - workspaceData: selectedWorkspace, - }, - }); - - void DatabaseService.instance.clear(); + await this.setWorkspace(userCredentials, workspace); const message = - `Workspace ${workspaceUuid} selected successfully. Now WebDAV and all of the CLI commands ` + - 'will operate within this workspace until it is changed or unset.'; - CLIUtils.success(this.log.bind(this), message); + `Workspace ${workspace.workspaceCredentials.id} selected successfully. Now WebDAV and ` + + 'all of the CLI commands will operate within this workspace until it is changed or unset.'; + CLIUtils.success(reporter, message); - return { success: true, list: { workspaces } }; + return { success: true, workspace }; }; public catch = async (error: Error) => { @@ -100,6 +81,7 @@ export default class WorkspacesUse extends Command { workspaceUuidFlag: string | undefined, availableWorkspaces: string[], nonInteractive: boolean, + reporter: LogReporter, ): Promise => { const workspaceUuid = await CLIUtils.getValueFromFlag( { @@ -121,11 +103,49 @@ export default class WorkspacesUse extends Command { ValidationService.instance.validateUUIDv4(this.extractUuidFromWorkspaceString(value)), error: new NotValidWorkspaceUuidError(), }, - this.log.bind(this), + reporter, ); return this.extractUuidFromWorkspaceString(workspaceUuid); }; private extractUuidFromWorkspaceString = (workspaceString: string) => workspaceString.match(/\[(.*?)\]/)?.[1] ?? workspaceString; + + private getWorkspace = async ( + userCredentials: LoginCredentials, + workspaceUuidFlag: string | undefined, + nonInteractive: boolean, + reporter: LogReporter, + ): Promise => { + const workspaces = await WorkspaceService.instance.getAvailableWorkspaces(userCredentials.user); + const availableWorkspaces: string[] = workspaces.map((workspaceData) => { + const name = workspaceData.workspace.name; + const id = workspaceData.workspace.id; + const totalUsedSpace = + Number(workspaceData.workspaceUser?.driveUsage ?? 0) + Number(workspaceData.workspaceUser?.backupsUsage ?? 0); + const spaceLimit = Number(workspaceData.workspaceUser?.spaceLimit ?? 0); + const usedSpace = FormatUtils.humanFileSize(totalUsedSpace); + const availableSpace = FormatUtils.formatLimit(spaceLimit); + + return `[${id}] Name: ${name} | Used Space: ${usedSpace} | Available Space: ${availableSpace}`; + }); + const workspaceUuid = await this.getWorkspaceUuid(workspaceUuidFlag, availableWorkspaces, nonInteractive, reporter); + + const workspaceCredentials = await WorkspaceService.instance.getWorkspaceCredentials(workspaceUuid); + const selectedWorkspace = workspaces.find((workspace) => workspace.workspace.id === workspaceUuid); + if (!selectedWorkspace) throw new NotValidWorkspaceUuidError(); + + return { workspaceCredentials, workspaceData: selectedWorkspace }; + }; + + private setWorkspace = async (userCredentials: LoginCredentials, workspace: Workspace) => { + SdkManager.init({ token: userCredentials.token, workspaceToken: workspace.workspaceCredentials.token }); + + await ConfigService.instance.saveUser({ + ...userCredentials, + workspace, + }); + + void DatabaseService.instance.clear(); + }; } diff --git a/src/constants/configs.ts b/src/constants/configs.ts index 726acc4c..1f7bd8cd 100644 --- a/src/constants/configs.ts +++ b/src/constants/configs.ts @@ -12,3 +12,4 @@ export const WEBDAV_DEFAULT_PORT = '3005'; export const WEBDAV_DEFAULT_PROTOCOL = 'https'; export const WEBDAV_DEFAULT_TIMEOUT = 0; export const WEBDAV_DEFAULT_CREATE_FULL_PATH = true; +export const WEBDAV_DEFAULT_CUSTOM_AUTH = false; diff --git a/src/services/config.service.ts b/src/services/config.service.ts index 1d625267..8b04692a 100644 --- a/src/services/config.service.ts +++ b/src/services/config.service.ts @@ -13,6 +13,7 @@ import { WEBDAV_DEFAULT_PORT, WEBDAV_DEFAULT_PROTOCOL, WEBDAV_DEFAULT_TIMEOUT, + WEBDAV_DEFAULT_CUSTOM_AUTH, WEBDAV_SSL_CERTS_DIR, } from '../constants/configs'; @@ -92,6 +93,9 @@ export class ConfigService { protocol: configs?.protocol ?? WEBDAV_DEFAULT_PROTOCOL, timeoutMinutes: configs?.timeoutMinutes ?? WEBDAV_DEFAULT_TIMEOUT, createFullPath: configs?.createFullPath ?? WEBDAV_DEFAULT_CREATE_FULL_PATH, + customAuth: configs?.customAuth ?? WEBDAV_DEFAULT_CUSTOM_AUTH, + username: configs?.username ?? '', + password: configs?.password ?? '', }; } catch { return { @@ -100,6 +104,9 @@ export class ConfigService { protocol: WEBDAV_DEFAULT_PROTOCOL, timeoutMinutes: WEBDAV_DEFAULT_TIMEOUT, createFullPath: WEBDAV_DEFAULT_CREATE_FULL_PATH, + customAuth: WEBDAV_DEFAULT_CUSTOM_AUTH, + username: '', + password: '', }; } }; diff --git a/src/services/crypto.service.ts b/src/services/crypto.service.ts index fc1d5ea3..0095d6dc 100644 --- a/src/services/crypto.service.ts +++ b/src/services/crypto.service.ts @@ -6,7 +6,6 @@ import { ConfigService } from '../services/config.service'; import { StreamUtils } from '../utils/stream.utils'; import { LoginCredentials } from '../types/command.types'; import { WorkspaceData } from '@internxt/sdk/dist/workspaces'; -import { logger } from '../utils/logger.utils'; export class CryptoService { public static readonly instance: CryptoService = new CryptoService(); @@ -206,8 +205,7 @@ export class CryptoService { privateKeyInBase64, privateKyberKeyInBase64, }); - } catch (error) { - logger.warn('Decryption failed, using fallback mnemonic', { error }); + } catch { return user.mnemonic; } }; diff --git a/src/webdav/services/webdav-folder.service.ts b/src/services/webdav/webdav-folder.service.ts similarity index 100% rename from src/webdav/services/webdav-folder.service.ts rename to src/services/webdav/webdav-folder.service.ts diff --git a/src/types/command.types.ts b/src/types/command.types.ts index ef53f0ee..b5649501 100644 --- a/src/types/command.types.ts +++ b/src/types/command.types.ts @@ -52,6 +52,9 @@ export interface WebdavConfig { protocol: 'http' | 'https'; timeoutMinutes: number; createFullPath: boolean; + customAuth: boolean; + username: string; + password: string; } export class NotValidEmailError extends Error { @@ -150,6 +153,22 @@ export class EmptyFolderNameError extends Error { } } +export class EmptyCustomAuthUsernameError extends Error { + constructor() { + super('Custom auth username can not be empty'); + + Object.setPrototypeOf(this, EmptyCustomAuthUsernameError.prototype); + } +} + +export class EmptyCustomAuthPasswordError extends Error { + constructor() { + super('Custom auth password can not be empty'); + + Object.setPrototypeOf(this, EmptyCustomAuthPasswordError.prototype); + } +} + export class NotValidPortError extends Error { constructor() { super('Port should be a number between 1 and 65535'); @@ -182,6 +201,14 @@ export class NotValidWorkspaceUuidError extends Error { } } +export class MissingCredentialsWhenUsingAuthError extends Error { + constructor() { + super('When using custom WebDAV authentication, both username and password must be provided'); + + Object.setPrototypeOf(this, MissingCredentialsWhenUsingAuthError.prototype); + } +} + export interface PaginatedItem { name: string; type: string; diff --git a/src/webdav/handlers/MKCOL.handler.ts b/src/webdav/handlers/MKCOL.handler.ts index f015cc90..6679b431 100644 --- a/src/webdav/handlers/MKCOL.handler.ts +++ b/src/webdav/handlers/MKCOL.handler.ts @@ -3,7 +3,7 @@ import { Request, Response } from 'express'; import { WebDavUtils } from '../../utils/webdav.utils'; import { webdavLogger } from '../../utils/logger.utils'; import { XMLUtils } from '../../utils/xml.utils'; -import { WebDavFolderService } from '../services/webdav-folder.service'; +import { WebDavFolderService } from '../../services/webdav/webdav-folder.service'; import { MethodNotAllowed } from '../../utils/errors.utils'; export class MKCOLRequestHandler implements WebDavMethodHandler { diff --git a/src/webdav/handlers/MOVE.handler.ts b/src/webdav/handlers/MOVE.handler.ts index c3df9e54..4fddefb2 100644 --- a/src/webdav/handlers/MOVE.handler.ts +++ b/src/webdav/handlers/MOVE.handler.ts @@ -5,7 +5,7 @@ import { WebDavMethodHandler } from '../../types/webdav.types'; import { NotFoundError } from '../../utils/errors.utils'; import { webdavLogger } from '../../utils/logger.utils'; import { WebDavUtils } from '../../utils/webdav.utils'; -import { WebDavFolderService } from '../services/webdav-folder.service'; +import { WebDavFolderService } from '../../services/webdav/webdav-folder.service'; export class MOVERequestHandler implements WebDavMethodHandler { handle = async (req: Request, res: Response) => { diff --git a/src/webdav/handlers/PUT.handler.ts b/src/webdav/handlers/PUT.handler.ts index 2a4c7cab..baaf1bff 100644 --- a/src/webdav/handlers/PUT.handler.ts +++ b/src/webdav/handlers/PUT.handler.ts @@ -10,7 +10,7 @@ import { EncryptionVersion } from '@internxt/sdk/dist/drive/storage/types'; import { CLIUtils } from '../../utils/cli.utils'; import { BufferStream } from '../../utils/stream.utils'; import { Readable } from 'node:stream'; -import { WebDavFolderService } from '../services/webdav-folder.service'; +import { WebDavFolderService } from '../../services/webdav/webdav-folder.service'; import { AsyncUtils } from '../../utils/async.utils'; import { ThumbnailUtils } from '../../utils/thumbnail.utils'; import { ThumbnailService } from '../../services/thumbnail.service'; diff --git a/src/webdav/middewares/webdav-auth.middleware.ts b/src/webdav/middewares/webdav-auth.middleware.ts new file mode 100644 index 00000000..9fc6c48a --- /dev/null +++ b/src/webdav/middewares/webdav-auth.middleware.ts @@ -0,0 +1,56 @@ +import { RequestHandler, Response } from 'express'; +import { XMLUtils } from '../../utils/xml.utils'; +import { WebdavConfig } from '../../types/command.types'; + +export const WebDAVAuthMiddleware = (configs: WebdavConfig): RequestHandler => { + return (req, res, next) => { + (async () => { + if (configs.customAuth) { + const authHeader = req.headers.authorization; + + if (!authHeader) { + return sendUnauthorizedError(res, 'Missing Authorization header.'); + } + + // Parse Basic Authentication + if (!authHeader.startsWith('Basic ')) { + return sendUnauthorizedError( + res, + 'Unsupported authentication method. Only Basic authentication is supported.', + ); + } + + const base64Credentials = authHeader.substring(6); // Remove 'Basic ' prefix + const credentials = Buffer.from(base64Credentials, 'base64').toString('utf-8'); + const [username, password] = credentials.split(':'); + + if (!username || !password) { + return sendUnauthorizedError(res, 'Invalid authentication credentials format.'); + } + + if (username !== configs.username || password !== configs.password) { + return sendUnauthorizedError(res, 'Authentication failed. Please check your WebDAV custom credentials.'); + } else { + next(); + return; + } + } else { + next(); + return; + } + })(); + }; +}; + +const sendUnauthorizedError = (res: Response, message: string) => { + const errorBodyXML = XMLUtils.toWebDavXML( + { + [XMLUtils.addDefaultNamespace('responsedescription')]: message, + }, + {}, + 'error', + ); + + res.status(401).send(errorBodyXML); + return; +}; diff --git a/src/webdav/webdav-server.ts b/src/webdav/webdav-server.ts index 4c13b994..2ad468dd 100644 --- a/src/webdav/webdav-server.ts +++ b/src/webdav/webdav-server.ts @@ -21,11 +21,14 @@ import { PROPPATCHRequestHandler } from './handlers/PROPPATCH.handler'; import { MOVERequestHandler } from './handlers/MOVE.handler'; import { COPYRequestHandler } from './handlers/COPY.handler'; import { MkcolMiddleware } from './middewares/mkcol.middleware'; +import { WebdavConfig } from '../types/command.types'; +import { WebDAVAuthMiddleware } from './middewares/webdav-auth.middleware'; export class WebDavServer { constructor(private readonly app: Express) {} - private readonly registerStartMiddlewares = () => { + private readonly registerStartMiddlewares = (configs: WebdavConfig) => { + this.app.use(WebDAVAuthMiddleware(configs)); this.app.use(AuthMiddleware()); this.app.use( RequestLoggerMiddleware({ @@ -40,7 +43,7 @@ export class WebDavServer { this.app.use(ErrorHandlingMiddleware); }; - private readonly registerHandlers = async () => { + private readonly registerHandlers = () => { const serverListenPath = /(.*)/; this.app.head(serverListenPath, asyncHandler(new HEADRequestHandler().handle)); this.app.get(serverListenPath, asyncHandler(new GETRequestHandler().handle)); @@ -59,8 +62,8 @@ export class WebDavServer { start = async () => { const configs = await ConfigService.instance.readWebdavConfig(); this.app.disable('x-powered-by'); - this.registerStartMiddlewares(); - await this.registerHandlers(); + this.registerStartMiddlewares(configs); + this.registerHandlers(); this.registerEndMiddleWares(); const plainHttp = configs.protocol === 'http'; @@ -78,7 +81,8 @@ export class WebDavServer { server.listen(Number(configs.port), configs.host, undefined, () => { webdavLogger.info( `Internxt ${SdkManager.getAppDetails().clientVersion} WebDav server ` + - `listening at ${configs.protocol}://${configs.host}:${configs.port}`, + `listening at ${configs.protocol}://${configs.host}:${configs.port}` + + `${configs.customAuth ? ' (with custom authentication)' : ''}`, ); }); }; diff --git a/test/services/config.service.test.ts b/test/services/config.service.test.ts index 65851bb9..2e023813 100644 --- a/test/services/config.service.test.ts +++ b/test/services/config.service.test.ts @@ -14,6 +14,7 @@ import { WEBDAV_DEFAULT_PORT, WEBDAV_DEFAULT_PROTOCOL, WEBDAV_DEFAULT_TIMEOUT, + WEBDAV_DEFAULT_CUSTOM_AUTH, WEBDAV_SSL_CERTS_DIR, } from '../../src/constants/configs'; @@ -143,6 +144,9 @@ describe('Config service', () => { protocol: 'https', timeoutMinutes: crypto.randomInt(100), createFullPath: false, + customAuth: WEBDAV_DEFAULT_CUSTOM_AUTH, + username: '', + password: '', }; const stringConfig = JSON.stringify(webdavConfig); @@ -159,6 +163,9 @@ describe('Config service', () => { protocol: 'http', timeoutMinutes: crypto.randomInt(100), createFullPath: false, + customAuth: WEBDAV_DEFAULT_CUSTOM_AUTH, + username: '', + password: '', }; const stringConfig = JSON.stringify(webdavConfig); @@ -176,6 +183,9 @@ describe('Config service', () => { protocol: WEBDAV_DEFAULT_PROTOCOL, timeoutMinutes: WEBDAV_DEFAULT_TIMEOUT, createFullPath: WEBDAV_DEFAULT_CREATE_FULL_PATH, + customAuth: WEBDAV_DEFAULT_CUSTOM_AUTH, + username: '', + password: '', }; const fsStub = vi.spyOn(fs, 'readFile').mockResolvedValue(''); @@ -192,6 +202,9 @@ describe('Config service', () => { protocol: WEBDAV_DEFAULT_PROTOCOL, timeoutMinutes: WEBDAV_DEFAULT_TIMEOUT, createFullPath: WEBDAV_DEFAULT_CREATE_FULL_PATH, + customAuth: WEBDAV_DEFAULT_CUSTOM_AUTH, + username: '', + password: '', }; const fsStub = vi.spyOn(fs, 'readFile').mockRejectedValue(new Error()); diff --git a/test/webdav/services/webdav-folder.service.test.ts b/test/services/webdav/webdav-folder.service.test.ts similarity index 98% rename from test/webdav/services/webdav-folder.service.test.ts rename to test/services/webdav/webdav-folder.service.test.ts index 09a99dac..73dbb081 100644 --- a/test/webdav/services/webdav-folder.service.test.ts +++ b/test/services/webdav/webdav-folder.service.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { fail } from 'node:assert'; -import { WebDavFolderService } from '../../../src/webdav/services/webdav-folder.service'; +import { WebDavFolderService } from '../../../src/services/webdav/webdav-folder.service'; import { DriveFolderService } from '../../../src/services/drive/drive-folder.service'; import { ConfigService } from '../../../src/services/config.service'; import { AuthService } from '../../../src/services/auth.service'; diff --git a/test/utils/network.utils.test.ts b/test/utils/network.utils.test.ts index e5c86d02..389d6e25 100644 --- a/test/utils/network.utils.test.ts +++ b/test/utils/network.utils.test.ts @@ -6,6 +6,7 @@ import { NetworkUtils } from '../../src/utils/network.utils'; import { Stats } from 'node:fs'; import { fail } from 'node:assert'; import { WebdavConfig } from '../../src/types/command.types'; +import { WEBDAV_DEFAULT_CUSTOM_AUTH } from '../../src/constants/configs'; vi.mock('node:fs/promises', async () => { const actual = await vi.importActual('node:fs/promises'); @@ -46,6 +47,9 @@ describe('Network utils', () => { protocol: 'https', timeoutMinutes: randomInt(900), createFullPath: true, + customAuth: WEBDAV_DEFAULT_CUSTOM_AUTH, + username: '', + password: '', }; const sslSelfSigned: GenerateResult = { private: randomBytes(8).toString('hex'), @@ -79,6 +83,9 @@ describe('Network utils', () => { protocol: 'https', timeoutMinutes: randomInt(900), createFullPath: true, + customAuth: WEBDAV_DEFAULT_CUSTOM_AUTH, + username: '', + password: '', }; const sslMock = { private: randomBytes(8).toString('hex'), @@ -121,6 +128,9 @@ describe('Network utils', () => { protocol: 'https', timeoutMinutes: randomInt(900), createFullPath: true, + customAuth: WEBDAV_DEFAULT_CUSTOM_AUTH, + username: '', + password: '', }; const sslSelfSigned: GenerateResult = { private: randomBytes(8).toString('hex'), diff --git a/test/webdav/handlers/MKCOL.handler.test.ts b/test/webdav/handlers/MKCOL.handler.test.ts index 35e9d541..8535d30c 100644 --- a/test/webdav/handlers/MKCOL.handler.test.ts +++ b/test/webdav/handlers/MKCOL.handler.test.ts @@ -11,7 +11,7 @@ import { WebDavRequestedResource } from '../../../src/types/webdav.types'; import { WebDavUtils } from '../../../src/utils/webdav.utils'; import { AuthService } from '../../../src/services/auth.service'; import { UserCredentialsFixture } from '../../fixtures/login.fixture'; -import { WebDavFolderService } from '../../../src/webdav/services/webdav-folder.service'; +import { WebDavFolderService } from '../../../src/services/webdav/webdav-folder.service'; describe('MKCOL request handler', () => { let sut: MKCOLRequestHandler; diff --git a/test/webdav/webdav-server.test.ts b/test/webdav/webdav-server.test.ts index 6c749bbb..57221854 100644 --- a/test/webdav/webdav-server.test.ts +++ b/test/webdav/webdav-server.test.ts @@ -8,6 +8,7 @@ import { WebDavServer } from '../../src/webdav/webdav-server'; import { NetworkUtils } from '../../src/utils/network.utils'; import { WebdavConfig } from '../../src/types/command.types'; import { UserCredentialsFixture } from '../fixtures/login.fixture'; +import { WEBDAV_DEFAULT_CUSTOM_AUTH } from '../../src/constants/configs'; describe('WebDav server', () => { it('When the WebDav server is started with https, it should generate self-signed certificates', async () => { @@ -17,6 +18,9 @@ describe('WebDav server', () => { protocol: 'https', timeoutMinutes: randomInt(900), createFullPath: true, + customAuth: WEBDAV_DEFAULT_CUSTOM_AUTH, + username: '', + password: '', }; const sslSelfSigned = { private: randomBytes(8).toString('hex'), @@ -56,6 +60,9 @@ describe('WebDav server', () => { protocol: 'http', timeoutMinutes: randomInt(900), createFullPath: true, + customAuth: WEBDAV_DEFAULT_CUSTOM_AUTH, + username: '', + password: '', }; vi.spyOn(ConfigService.instance, 'readWebdavConfig').mockResolvedValue(webdavConfig);