diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index 9464cfa..0000000 --- a/.eslintignore +++ /dev/null @@ -1,2 +0,0 @@ -webpack.config.js -/dist/ \ No newline at end of file diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index bfa0292..0000000 --- a/.eslintrc.js +++ /dev/null @@ -1,65 +0,0 @@ -module.exports = { - root: true, - parserOptions: { - parser: '@babel/eslint-parser', - sourceType: 'module', - }, - extends: [ - 'plugin:vue/recommended', - '@vue/airbnb', - 'standard', - ], - env: { - browser: true, - }, - plugins: [ - 'vue', - ], - // add your custom rules here - rules: { - 'class-methods-use-this': 0, - 'comma-dangle': ['error', { - arrays: 'always-multiline', - objects: 'always-multiline', - imports: 'never', - exports: 'never', - functions: 'ignore', - }], - 'import/extensions': 0, - 'import/no-extraneous-dependencies': 0, - 'import/no-unresolved': 0, - 'import/prefer-default-export': 0, - 'indent': ['error', 4], - 'no-continue': 0, - 'no-control-regex': 0, - 'no-multi-assign': 0, - 'no-param-reassign': ['error', { props: false }], - 'no-plusplus': 0, - 'no-prototype-builtins': 0, - 'prefer-promise-reject-errors': 0, - 'quote-props': ['error', 'consistent-as-needed'], - 'object-shorthand': 0, - 'operator-linebreak': 0, - 'prefer-const': 0, - 'prefer-destructuring': 0, - 'prefer-object-spread': 0, - 'prefer-template': 0, - 'semi': ['error', 'always'], - 'space-before-function-paren': ['error', 'never'], - 'vue/html-closing-bracket-spacing': 0, - 'vue/html-indent': ['error', 4], - 'vue/max-attributes-per-line': 0, - 'vue/multiline-html-element-content-newline': 0, - 'vue/no-mutating-props': 0, - 'vue/no-v-html': 0, - 'vue/require-prop-types': 0, - 'vue/singleline-html-element-content-newline': 0, - 'vuejs-accessibility/anchor-has-content': 0, - 'vuejs-accessibility/click-events-have-key-events': 0, - 'vuejs-accessibility/form-control-has-label': 0, - 'vuejs-accessibility/iframe-has-title': 0, - 'vuejs-accessibility/interactive-supports-focus': 0, - 'vuejs-accessibility/label-has-for': 0, - 'vuejs-accessibility/mouse-events-have-key-events': 0, - }, -}; diff --git a/.gitignore b/.gitignore index 4d0f2a8..42c0c9b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .DS_Store dist/ node_modules/ +tests/coverage/ # local env files .env.local @@ -10,12 +11,3 @@ node_modules/ npm-debug.log* yarn-debug.log* yarn-error.log* - -# Editor directories and files -.idea -.vscode -*.suo -*.ntvs* -*.njsproj -*.sln -*.sw* diff --git a/.prettierrc.js b/.prettierrc.js new file mode 100644 index 0000000..64d5827 --- /dev/null +++ b/.prettierrc.js @@ -0,0 +1,9 @@ +module.exports = { + printWidth: 120, + quoteProps: 'consistent', + semi: true, + singleQuote: true, + trailingComma: 'es5', + tabWidth: 4, + jsdocVerticalAlignment: true, +}; diff --git a/.stylelintrc.js b/.stylelintrc.js index bcc35e6..e655bcd 100644 --- a/.stylelintrc.js +++ b/.stylelintrc.js @@ -1,21 +1,85 @@ module.exports = { - extends: 'stylelint-config-standard', + plugins: ['@stylistic/stylelint-plugin'], + extends: [ + 'stylelint-config-standard', + 'stylelint-config-recommended', + 'stylelint-config-recommended-vue', + 'stylelint-config-recommended-vue/scss', + 'stylelint-config-standard-scss', + 'stylelint-config-recommended-scss', + ], overrides: [ { - files: ['*.vue', '**/*.vue'], + files: ['**/*.vue', '**/*.html'], + customSyntax: 'postcss-html', + }, + { + files: [ + '**/*.scss', + ], + customSyntax: 'postcss', + extends: [ + 'stylelint-config-recess-order', + ], + }, + { + files: [], customSyntax: 'postcss-html', + extends: [ + 'stylelint-config-recess-order', + ], }, ], rules: { 'alpha-value-notation': null, 'color-function-notation': null, + 'declaration-block-no-redundant-longhand-properties': null, 'declaration-no-important': true, - 'indentation': 4, + 'declaration-property-value-no-unknown': null, // breaks css round()" + 'media-feature-range-notation': null, 'no-descending-specificity': null, - 'no-empty-first-line': null, + 'number-max-precision': null, 'property-no-vendor-prefix': null, + 'value-keyword-case': [ + 'lower', + { + ignoreFunctions: ['v-bind'], + }, + ], + 'scss/at-rule-no-unknown': [ + true, + { + ignoreAtRules: [ + 'each', + 'else', + 'extends', + 'for', + 'function', + 'if', + 'ignores', + 'include', + 'media', + 'mixin', + 'return', + 'use', + + // Font Awesome 4 + 'fa-font-path', + ], + }, + ], + 'scss/double-slash-comment-empty-line-before': null, + 'scss/double-slash-comment-whitespace-inside': null, + 'scss/no-global-function-names': null, 'selector-class-pattern': null, 'shorthand-property-no-redundant-values': null, - 'string-quotes': 'single', + + '@stylistic/color-hex-case': 'lower', + '@stylistic/indentation': 4, + // '@stylistic/no-empty-first-line': true, + '@stylistic/number-leading-zero': 'always', + '@stylistic/property-case': 'lower', + '@stylistic/string-quotes': 'single', + '@stylistic/unit-case': 'lower', }, }; diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..db7ae10 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,8 @@ +{ + "recommendations": [ + "vue.volar", + "dbaeumer.vscode-eslint", + "stylelint.vscode-stylelint", + "rvest.vs-code-prettier-eslint" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..eb6a994 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,23 @@ +{ + "files.eol": "\n", + "files.insertFinalNewline": true, + "files.trimFinalNewlines": true, + "files.trimTrailingWhitespace": true, + "stylelint.validate": [ + "vue", + "css", + "less", + "sass", + "scss", + "postcss" + ], + "[javascript]": { + "editor.defaultFormatter": "rvest.vs-code-prettier-eslint" + }, + "[vue]": { + "editor.defaultFormatter": "rvest.vs-code-prettier-eslint" + }, + "[css]": { + "editor.defaultFormatter": "rvest.vs-code-prettier-eslint" + }, +} diff --git a/build/commands/build.js b/build/commands/build.js new file mode 100644 index 0000000..92fea38 --- /dev/null +++ b/build/commands/build.js @@ -0,0 +1,149 @@ +const webpack = require('webpack'); +const minimist = require('minimist'); +const { rimraf } = require('rimraf'); + +const utils = require('../utils'); +const webpackConfigFunc = require('../../webpack.config'); + +const argv = minimist(process.argv.slice(2)); + +(async () => { + const ora = await import('ora').then((m) => m.default); + const chalk = await import('chalk').then((m) => m.default); + const cliui = await import('cliui').then((m) => m.default); + const webpackConfig = await webpackConfigFunc({}, argv); + + const spinner = ora(); + + console.log(); + spinner.text = `Building for ${webpackConfig.mode}...`; + spinner.start(); + + rimraf(utils.pathResolve('dist') + '/*', { glob: true }).then(() => { + webpack(webpackConfig, (wpErr, stats) => { + spinner.stop(); + console.log(); + + if (wpErr) { + console.error(wpErr); + console.log(); + process.exit(1); + } + + if (stats.hasErrors()) { + process.exit(1); + } + + const statsOutput = stats.toString({ + all: false, + colors: true, + logging: 'info', + loggingTrace: true, + }); + + if (statsOutput) { + process.stdout.write(`${statsOutput}\n\n`); + } + + const getCompressedAsset = (asset, type) => { + if (!Array.isArray(asset.related)) { + return undefined; + } + return asset.related.find((relAsset) => relAsset.type === type); + }; + + const isJS = (val) => /\.js$/.test(val); + const isCSS = (val) => /\.css$/.test(val); + const assetSorter = (a, b) => { + if (isJS(a.name) && isCSS(b.name)) { + return -1; + } + if (isCSS(a.name) && isJS(b.name)) { + return 1; + } + return b.size - a.size; + }; + + const data = stats.toJson(); + const files = Object.values(data.assetsByChunkName).flat(); + + const out = [ + // Column headers + ['File', 'Size', 'Gzip', 'Brotli'], + ]; + const totals = { + size: 0, + gzip: 0, + brotli: 0, + }; + + data.assets.sort(assetSorter).forEach((asset) => { + if (!asset.emitted) { + return; + } + + const gzipAsset = getCompressedAsset(asset, 'gzipped'); + const brotliAsset = getCompressedAsset(asset, 'brotliCompressed'); + + totals.size += asset.size; + totals.gzip += gzipAsset ? gzipAsset.size : asset.size; + totals.brotli += brotliAsset ? brotliAsset.size : asset.size; + + if (files.includes(asset.name)) { + out.push([ + asset.name, + utils.formatSize(asset.size), + gzipAsset ? utils.formatSize(gzipAsset.size) : '', + brotliAsset ? utils.formatSize(brotliAsset.size) : '', + ]); + } + }); + + out.push([ + 'Totals (including assets)', + utils.formatSize(totals.size), + utils.formatSize(totals.gzip), + utils.formatSize(totals.brotli), + ]); + + const colWidths = out.reduce((acc, row) => { + row.forEach((col, idx) => { + acc[idx] = Math.max(acc[idx] || 0, col.length + 4); + }); + return acc; + }, []); + + const table = cliui(); + out.forEach((row, rowIdx) => { + table.div( + ...row.map((col, colIdx) => ({ + text: + rowIdx === 0 || (rowIdx === out.length - 1 && colIdx === 0) + ? chalk.cyan.bold(col) + : col, + width: colWidths[colIdx], + padding: + rowIdx === 0 || rowIdx === out.length - 2 + ? [0, 0, 1, 3] + : [0, 0, 0, 3], + })) + ); + }); + + console.log(table.toString()); + console.log(); + console.log(); + console.log( + chalk.bgGreen.black(' DONE '), + `Build Complete. The ${chalk.cyan('dist')} directory is ready to be deployed` + ); + console.log(); + }); + }).catch((rmErr) => { + spinner.stop(); + console.log(); + console.error(rmErr); + console.log(); + process.exit(1); + }); +})(); diff --git a/build/commands/dev.js b/build/commands/dev.js new file mode 100644 index 0000000..d78974f --- /dev/null +++ b/build/commands/dev.js @@ -0,0 +1,64 @@ +const webpack = require('webpack'); +const minimist = require('minimist'); +const portfinder = require('portfinder'); +const WebpackDevServer = require('webpack-dev-server'); + +const utils = require('../utils'); +const webpackConfigFunc = require('../../webpack.config'); + +const argv = minimist(process.argv.slice(2)); + +(async () => { + const ora = await import('ora').then((m) => m.default); + const chalk = await import('chalk').then((m) => m.default); + const webpackConfig = await webpackConfigFunc({ WEBPACK_SERVE: true }, argv); + + const spinner = ora(); + + console.log(); + spinner.text = 'Starting development server...'; + spinner.start(); + + const devServerOptions = webpackConfig.devServer; + + const protocol = devServerOptions.https ? 'https' : 'http'; + const host = devServerOptions.host || '0.0.0.0'; + const port = await portfinder.getPortPromise({ + port: devServerOptions.port || 8080, + host, + }); + + Object.assign(devServerOptions, { host, port }); + + const compiler = webpack(webpackConfig); + const server = new WebpackDevServer(devServerOptions, compiler); + + compiler.hooks.done.tap('dev', (stats) => { + spinner.stop(); + + if (stats.hasErrors()) { + return; + } + + console.log(' App running at:'); + if (host === 'localhost' || host.substring(0, 4) === '127.' || host === '0.0.0.0') { + const hostText = host === '127.0.0.1' ? 'localhost' : host; + console.log(` - Local: ${chalk.cyan(`${protocol}://${hostText}:${port}`)}`); + + if (host !== '0.0.0.0') { + console.log(` - Network: ${chalk.grey('use "--host" to expose')}`); + } + } + if (host !== 'localhost' && host.substring(0, 4) !== '127.') { + const networkIPs = utils.getNetworkIPs(); + networkIPs.forEach((ip) => { + console.log(` - Network: ${chalk.cyan(`${protocol}://${ip}:${port}`)}`); + }); + } + console.log(); + console.log(' Note that the development build is not optimized.'); + console.log(` To create a production build, run ${chalk.cyan('yarn build')}.`); + }); + + await server.start(); +})(); diff --git a/build/configs/base.js b/build/configs/base.js new file mode 100644 index 0000000..c1662e3 --- /dev/null +++ b/build/configs/base.js @@ -0,0 +1,138 @@ +const { merge } = require('webpack-merge'); + +const ESLintPlugin = require('eslint-webpack-plugin'); +const ESLintFormatter = require('eslint-formatter-friendly'); +const { VueLoaderPlugin } = require('vue-loader'); +const CaseSensitivePathsPlugin = require('case-sensitive-paths-webpack-plugin'); +const FriendlyErrorsWebpackPlugin = require('@nuxt/friendly-errors-webpack-plugin'); + +const utils = require('../utils'); +const pkg = require('../../package.json'); + +const cssConfig = require('./css'); + +module.exports = (env, argv, config) => { + let sourceMap; + if (config.mode === 'development') { + sourceMap = env.WEBPACK_SERVE ? 'eval-source-map' : 'source-map'; + } else if (argv.srcmap) { + sourceMap = 'source-map'; + } + + const baseConfig = merge(config, { + context: process.cwd(), + + entry: { + app: './src/plugin.js', + }, + + devtool: sourceMap, + + output: { + path: utils.pathResolve('dist'), + publicPath: 'auto', + filename: pkg.name.replace(/^kiwiirc-/, '') + '.js', + }, + + resolve: { + alias: { + '@': utils.pathResolve('src'), + }, + extensions: ['.js', '.jsx', '.vue', '.json'], + }, + + externals: { + vue: 'kiwi.Vue', + }, + + performance: { + maxEntrypointSize: 512 * utils.KiB, // 0.5MiB + maxAssetSize: 512 * utils.KiB, // 0.5MiB + }, + + plugins: [ + new ESLintPlugin({ + emitError: true, + emitWarning: true, + failOnError: false, + extensions: ['.ts', '.tsx', '.js', '.jsx', '.vue'], + formatter: ESLintFormatter, + }), + new VueLoaderPlugin(), + new CaseSensitivePathsPlugin(), + new FriendlyErrorsWebpackPlugin(), + ], + + module: { + rules: [ + { + test: /\.vue$/, + use: [ + { + loader: 'vue-loader', + options: { + transformAssetUrls: { + // Defaults + video: ['src', 'poster'], + source: 'src', + img: 'src', + image: ['xlink:href', 'href'], + use: ['xlink:href', 'href'], + + // Object can be used for svg files + object: 'data', + }, + compilerOptions: { + comments: false, + }, + }, + }, + ], + }, + + { + test: /\.m?jsx?$/, + exclude: (file) => { + // always transpile js in vue files + if (/\.vue\.jsx?$/.test(file)) { + return false; + } + // Don't transpile node_modules + return /node_modules/.test(file); + }, + use: ['thread-loader', 'babel-loader'], + }, + + // images + { + test: /\.(png|jpe?g|gif|webp)(\?.*)?$/, + type: 'asset', + generator: { filename: 'static/img/[name].[contenthash:8][ext][query]' }, + }, + + // svg + { + test: /\.(svg)(\?.*)?$/, + exclude: /node_modules/, + use: ['vue-loader', 'svg-loader'], + }, + + // media + { + test: /\.(mp4|webm|ogg|mp3|wav|flac|aac)(\?.*)?$/, + type: 'asset', + generator: { filename: 'static/media/[name].[contenthash:8][ext][query]' }, + }, + + // fonts + { + test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/i, + type: 'asset', + generator: { filename: 'static/fonts/[name].[contenthash:8][ext][query]' }, + }, + ], + }, + }); + + return cssConfig(env, argv, baseConfig); +}; diff --git a/build/configs/css.js b/build/configs/css.js new file mode 100644 index 0000000..87551af --- /dev/null +++ b/build/configs/css.js @@ -0,0 +1,89 @@ +const Autoprefixer = require('autoprefixer'); +const { merge } = require('webpack-merge'); + +const cssRules = [ + { + test: /\.css$/, + use: [ + { + loader: 'vue-style-loader', + }, + { + loader: 'css-loader', + options: { + importLoaders: 2, + esModule: false, + }, + }, + { + loader: 'postcss-loader', + options: { + postcssOptions: { + plugins: [Autoprefixer], + }, + }, + }, + ], + }, + { + test: /\.less$/, + use: [ + { + loader: 'vue-style-loader', + }, + { + loader: 'css-loader', + options: { + importLoaders: 2, + esModule: false, + }, + }, + { + loader: 'postcss-loader', + options: { + postcssOptions: { + plugins: [Autoprefixer], + }, + }, + }, + { + loader: 'less-loader', + }, + ], + }, + { + test: /\.s[ac]ss$/, + use: [ + { + loader: 'vue-style-loader', + }, + { + loader: 'css-loader', + options: { + importLoaders: 2, + esModule: false, + }, + }, + { + loader: 'postcss-loader', + options: { + postcssOptions: { + plugins: [Autoprefixer], + }, + }, + }, + { + loader: 'sass-loader', + options: { + api: 'modern-compiler', + }, + }, + ], + }, +]; + +module.exports = (env, argv, config) => merge(config, { + module: { + rules: cssRules, + }, +}); diff --git a/build/configs/dev.js b/build/configs/dev.js new file mode 100644 index 0000000..7ba4293 --- /dev/null +++ b/build/configs/dev.js @@ -0,0 +1,65 @@ +const { merge } = require('webpack-merge'); +const murmurhash3 = require('murmurhash3js'); +const utils = require('../utils'); +const pkg = require('../../package.json'); + +const baseConfig = require('./base'); + +module.exports = (env, argv, config) => { + const pluginNumber = Math.abs(murmurhash3.x86.hash32(pkg.name)) % 1000; + const portNumber = utils.mapRange(pluginNumber, 0, 999, 9000, 9999); + + const devConfig = { + plugins: [], + + devServer: { + devMiddleware: { + publicPath: 'auto', + }, + open: false, + host: '127.0.0.1', + port: portNumber, + allowedHosts: ['localhost', '127.0.0.1'], + headers: { + 'Access-Control-Allow-Origin': '*', + }, + static: [ + { + directory: utils.pathResolve('static'), + publicPath: 'static', + }, + ], + client: { + logging: 'info', + overlay: { + runtimeErrors: true, + errors: true, + warnings: false, + }, + }, + }, + + infrastructureLogging: { + level: 'warn', + }, + + stats: { + all: false, + loggingDebug: ['sass-loader'], + }, + }; + + if (argv.host) { + const newHost = argv.host === true ? '0.0.0.0' : argv.host; + devConfig.devServer.host = newHost; + devConfig.devServer.allowedHosts.push( + newHost === '0.0.0.0' ? '*' : newHost, + ); + } + + if (argv.port) { + devConfig.devServer.port = argv.port; + } + + return merge(baseConfig(env, argv, config), devConfig); +}; diff --git a/build/configs/prod.js b/build/configs/prod.js new file mode 100644 index 0000000..52de7e0 --- /dev/null +++ b/build/configs/prod.js @@ -0,0 +1,44 @@ +const CompressionPlugin = require('compression-webpack-plugin'); +const TerserPlugin = require('terser-webpack-plugin'); +const CssMinimizerPlugin = require('css-minimizer-webpack-plugin'); +const { merge } = require('webpack-merge'); +const zlib = require('zlib'); + +const baseConfig = require('./base'); +const terserOptions = require('./terser'); + +module.exports = (env, argv, config) => { + const compressionTest = /\.(js|css|js.map|css.map|svg|json|ttf|eot|woff2?)(\?.*)?$/; + + const prodConfig = { + plugins: [ + new CompressionPlugin({ + filename: '[path][base].gz', + algorithm: 'gzip', + test: compressionTest, + compressionOptions: { + level: 9, + }, + threshold: 1024, + }), + new CompressionPlugin({ + filename: '[path][base].br', + algorithm: 'brotliCompress', + test: compressionTest, + compressionOptions: { + params: { + [zlib.constants.BROTLI_PARAM_QUALITY]: 8, + }, + }, + threshold: 1024, + }), + ], + + optimization: { + minimize: true, + minimizer: [new TerserPlugin(terserOptions), new CssMinimizerPlugin()], + }, + }; + + return merge(baseConfig(env, argv, config), prodConfig); +}; diff --git a/build/configs/terser.js b/build/configs/terser.js new file mode 100644 index 0000000..56e0e0b --- /dev/null +++ b/build/configs/terser.js @@ -0,0 +1,22 @@ +module.exports = { + terserOptions: { + compress: { + booleans: true, + conditionals: true, + dead_code: true, + evaluate: true, + if_return: true, + sequences: true, + unused: true, + }, + mangle: { + safari10: true, + }, + format: { + comments: false, + }, + }, + extractComments: { + condition: false, + }, +}; diff --git a/build/plugins/eslint-rules/class-name-prefix.js b/build/plugins/eslint-rules/class-name-prefix.js new file mode 100644 index 0000000..2faf29a --- /dev/null +++ b/build/plugins/eslint-rules/class-name-prefix.js @@ -0,0 +1,74 @@ +const pkg = require('../../../package.json'); + +const pkgClass = pkg.name.replace(/^kiwiirc-/, ''); +const pkgClassShort = pkgClass.replace(/^plugin-/, 'p-'); + +const allowedPrefixes = [ + 'kiwi-', + 'kc-', + 'u-', + `${pkgClass}-`, +]; + +const specialPrefixes = [ + 'irc-fg-', + 'irc-bg-', + 'g-', +]; + +if (pkgClass !== pkgClassShort) { + allowedPrefixes.push(`${pkgClassShort}-`); +} + +const prefixes = [...allowedPrefixes, ...specialPrefixes]; + +const reportMessage = `Expected class name to start with one of ['${allowedPrefixes.join("', '")}'] ({{ class }})`; + +module.exports = { + rules: { + 'class-name-prefix': { + meta: { + type: 'suggestion', + docs: { + description: `HTML class names must start with one of ['kiwi-', 'u-', 'irc-fg-', 'irc-bg-', 'g-']`, + category: 'Stylistic Issues', + recommended: true, + url: null, + }, + schema: [], + messages: { + invalidClass: reportMessage, + }, + }, + create(context) { + const sourceCode = context.getSourceCode(); + + return sourceCode.parserServices.defineTemplateBodyVisitor({ + "VAttribute[key.name='class']"(node) { + if (!node.value || node.value.type !== 'VLiteral') { + return; + } + + const classes = node.value.value.split(' '); + classes.forEach((c) => { + // Ignore empty and fontawesome classes + if (!c || c === 'fa' || c.startsWith('fa-')) { + return; + } + if (prefixes.every((p) => !c.startsWith(p))) { + context.report({ + node, + messageId: 'invalidClass', + data: { + class: c, + }, + severity: 1, + }); + } + }); + }, + }); + }, + }, + }, +}; diff --git a/build/plugins/eslint-rules/index.js b/build/plugins/eslint-rules/index.js new file mode 100644 index 0000000..620f2ec --- /dev/null +++ b/build/plugins/eslint-rules/index.js @@ -0,0 +1,12 @@ +module.exports = { + configs: { + recommended: [{ + plugins: { + kiwiirc: require('./class-name-prefix'), + }, + rules: { + 'kiwiirc/class-name-prefix': 'warn', + }, + }], + }, +}; diff --git a/build/plugins/eslint-rules/package.json b/build/plugins/eslint-rules/package.json new file mode 100644 index 0000000..d74b85d --- /dev/null +++ b/build/plugins/eslint-rules/package.json @@ -0,0 +1,9 @@ +{ + "name": "@kiwiirc/eslint-plugin", + "version": "1.1.0", + "private": true, + "main": "index.js", + "peerDependencies": { + "eslint": "^8.57.1 || ^9.22.0" + } +} diff --git a/build/utils.js b/build/utils.js new file mode 100644 index 0000000..b05099f --- /dev/null +++ b/build/utils.js @@ -0,0 +1,63 @@ +const os = require('os'); +const path = require('path'); +const { execSync } = require('child_process'); + +module.exports.pathResolve = (...args) => path.posix.join(process.cwd(), ...args); + +module.exports.getCommitHash = () => { + let commitHash = 'unknown'; + try { + commitHash = execSync('git rev-parse --short HEAD').toString().trim(); + const modified = execSync('git diff --quiet HEAD -- || echo true').toString(); + if (modified.trim() === 'true') { + commitHash += '-modified'; + } + } catch (err) { + console.error('Failed to get commit hash:', err); + } + return commitHash; +}; + +module.exports.getNetworkIPs = () => { + const interfaces = os.networkInterfaces(); + const ips = []; + + for (const iface of Object.values(interfaces)) { + for (const alias of iface) { + if (alias.family === 'IPv4' && alias.address !== '127.0.0.1' && !alias.internal) { + ips.push(alias.address); + } + } + } + + return ips; +}; + +module.exports.formatSize = (_size) => { + const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']; + let size = _size; + let pos = 0; + + while (size >= 1024 && pos < units.length) { + size /= 1024; + pos++; + } + + return `${parseFloat(size.toFixed(2))} ${units[pos]}`; +}; + +module.exports.KiB = 1024; +module.exports.MiB = 1048576; +module.exports.GiB = 1073741824; + +/* + * Re-maps a number from one range to another + * http://processing.org/reference/map_.html + */ +module.exports.mapRange = (value, vMin, vMax, dMin, dMax) => { + const vValue = parseFloat(value); + const vRange = vMax - vMin; + const dRange = dMax - dMin; + + return (vValue - vMin) * dRange / vRange + dMin; +}; diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..aa0fe39 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,289 @@ +/* eslint sort-keys: ['error', 'asc', { allowLineSeparatedGroups: true }] */ +/* eslint-disable sort-keys */ + +import globals from 'globals'; +import js from '@eslint/js'; +import vueParser from 'vue-eslint-parser'; +import jsdoc from 'eslint-plugin-jsdoc'; + +import pluginVue from 'eslint-plugin-vue'; +import pluginVueA11y from 'eslint-plugin-vuejs-accessibility'; +import pluginImport from 'eslint-plugin-import'; +import pluginStylistic from '@stylistic/eslint-plugin'; +import kiwiirc from '@kiwiirc/eslint-plugin'; + +import * as utils from './build/utils.js'; + +export default [ + js.configs.recommended, + pluginImport.flatConfigs.recommended, + pluginStylistic.configs.all, + ...pluginVueA11y.configs['flat/recommended'], + ...pluginVue.configs['flat/recommended'], + ...kiwiirc.configs.recommended, + + { + plugins: { + jsdoc, + }, + + languageOptions: { + globals: { + ...globals.browser, + }, + parser: vueParser, + ecmaVersion: 2020, + sourceType: 'module', + parserOptions: { + parser: '@babel/eslint-parser', + extraFileExtensions: ['.vue'], + }, + }, + + settings: { + 'import/resolver': { + alias: { + map: [['@', utils.pathResolve('src')]], + extensions: ['.js', '.vue', '.json'], + }, + }, + }, + + /* eslint-enable sort-keys */ + rules: { + 'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'warn', + 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'warn', + + 'arrow-body-style': ['error', 'as-needed'], + 'no-await-in-loop': 'error', + 'no-cond-assign': [ + 'error', + 'always', + ], + 'no-control-regex': 'off', + 'no-else-return': 'error', + 'no-multiple-empty-lines': ['error', { + max: 1, + maxEOF: 0, + }], + 'no-new': 'error', + 'no-param-reassign': ['error', { + props: false, + }], + 'no-underscore-dangle': [ + 'error', + { + enforceInMethodNames: true, + }, + ], + 'no-unused-expressions': [ + 'error', + { + allowShortCircuit: true, + allowTaggedTemplates: true, + allowTernary: true, + }, + ], + 'no-unused-vars': ['error', { + args: 'none', + caughtErrors: 'none', + }], + 'no-use-before-define': [ + 'error', + { + classes: false, + functions: true, + variables: true, + }, + ], + 'object-shorthand': ['error', 'always'], + 'prefer-const': 'error', + 'prefer-destructuring': 'off', + + /* + Import Rules + */ + 'import/extensions': ['error', 'ignorePackages', { + js: 'always', + vue: 'always', + }], + 'import/no-cycle': 'off', + 'import/no-unresolved': ['error', { + ignore: [ + // virtual path to locales + '^locale:', + ], + }], + 'import/prefer-default-export': 'off', + + /* + Vue Rules + */ + 'vue/html-indent': ['error', 4], + 'vue/max-attributes-per-line': ['error', { + multiline: { + max: 1, + }, + singleline: { + max: 8, + }, + }], + 'vue/max-len': ['error', { + code: 120, + comments: 120, + ignoreRegExpLiterals: true, + ignoreTemplateLiterals: true, + ignoreUrls: true, + tabWidth: 4, + template: 120, + }], + 'vue/no-mutating-props': ['error', { + shallowOnly: true, + }], + 'vue/no-v-html': 'off', + + /* + Stylistic Rules + */ + '@stylistic/array-bracket-newline': ['error', 'consistent'], + '@stylistic/brace-style': ['error', '1tbs'], + '@stylistic/comma-dangle': ['error', { + arrays: 'always-multiline', + exports: 'never', + functions: 'ignore', + imports: 'never', + objects: 'always-multiline', + }], + '@stylistic/dot-location': ['error', 'property'], + '@stylistic/function-call-argument-newline': ['error', 'consistent'], + '@stylistic/function-paren-newline': ['error', 'multiline-arguments'], + '@stylistic/indent': ['error', 4, { + SwitchCase: 0, + }], + '@stylistic/multiline-ternary': ['error', 'always-multiline'], + '@stylistic/no-extra-parens': ['error', 'all', { + ignoredNodes: ['NewExpression > MemberExpression'], + nestedBinaryExpressions: false, + }], + '@stylistic/object-curly-spacing': ['error', 'always'], + '@stylistic/object-property-newline': ['error', { + allowAllPropertiesOnSameLine: true, + }], + '@stylistic/quote-props': ['error', 'consistent-as-needed'], + '@stylistic/quotes': ['error', 'single', { + allowTemplateLiterals: 'avoidEscape', + avoidEscape: true, + }], + '@stylistic/semi': ['error', 'always'], + '@stylistic/space-before-function-paren': ['error', { + anonymous: 'always', + asyncArrow: 'always', + named: 'never', + }], + + /* + Stylistic Remove + */ + '@stylistic/array-element-newline': 0, + '@stylistic/indent-binary-ops': 0, + '@stylistic/lines-around-comment': 0, + '@stylistic/multiline-comment-style': 0, + '@stylistic/newline-per-chained-call': 0, + '@stylistic/padded-blocks': 0, + '@stylistic/wrap-regex': 0, + + /* + Accessibility Rules + */ + 'vuejs-accessibility/click-events-have-key-events': 'off', + 'vuejs-accessibility/interactive-supports-focus': 'off', + 'vuejs-accessibility/mouse-events-have-key-events': 'off', + }, + + }, + + // { + // ignores: [], + // linterOptions: { + // reportUnusedDisableDirectives: 'off', + // }, + // rules: { + // 'no-else-return': 'off', + // 'no-use-before-define': 'off', + // 'object-shorthand': 'off', + // 'prefer-const': 'off', + // 'prefer-destructuring': 'off', + + // /* + // Import Rules + // */ + // 'import/extensions': 'off', + + // /* + // Vue Rules + // */ + // 'vue/max-attributes-per-line': 'off', + // 'vue/max-len': ['error', { + // code: 120, + // comments: 120, + // ignoreRegExpLiterals: true, + // ignoreStrings: true, + // ignoreTemplateLiterals: true, + // ignoreUrls: true, + // tabWidth: 4, + // template: 120, + // }], + // 'vue/multi-word-component-names': 'off', + // 'vue/multiline-html-element-content-newline': 'off', + // 'vue/no-unused-components': 'off', + // 'vue/one-component-per-file': 'off', + // 'vue/require-default-prop': 'off', + // 'vue/require-explicit-emits': 'off', + // 'vue/require-prop-types': 'off', + // 'vue/singleline-html-element-content-newline': 'off', + // 'vue/v-on-event-hyphenation': 'off', + + // /* + // Stylistic Rules + // */ + // '@stylistic/multiline-ternary': 'off', + // '@stylistic/no-extra-parens': 'off', + // '@stylistic/operator-linebreak': 'off', + + // /* + // Accessibility Rules + // */ + // 'vuejs-accessibility/anchor-has-content': 'off', + // 'vuejs-accessibility/form-control-has-label': 'off', + // 'vuejs-accessibility/iframe-has-title': 'off', + // 'vuejs-accessibility/label-has-for': 'off', + // 'vuejs-accessibility/no-static-element-interactions': 'off', + // }, + // }, + + { + files: ['eslint.config.mjs', '*.js', 'build/**/*.js'], + languageOptions: { + globals: { + ...globals.node, + }, + }, + + rules: { + 'import/extensions': ['error', { + js: 'ignorePackages', + json: 'ignorePackages', + }], + 'import/no-extraneous-dependencies': ['error', { + devDependencies: true, + }], + 'no-console': 'off', + 'no-underscore-dangle': 'off', + }, + + }, + + { + ignores: ['dist', 'tests', '**.old.**'], + }, +]; diff --git a/jitsi-plugin/README.md b/jitsi-plugin/README.md new file mode 100644 index 0000000..8f584c3 --- /dev/null +++ b/jitsi-plugin/README.md @@ -0,0 +1,73 @@ +# KiwiIRC - Jitsi-meet JWT authentication + +### Installing jitsi-meet + +It is recommended to use the docker method to install jitsi-meet as this allows better control over the version of jitsi-meet that is used. + +The documentation for docker install can be found [here](https://jitsi.github.io/handbook/docs/devops-guide/devops-guide-docker/) + +### Configuring docker-jitsi-meet + +The files from `jitsi-meet_xxx` should be copied to `prosody/prosody-plugins-custom` within your `CONFIG` path. + +There are several environment variables that need setting for docker-jitsi-meet to allow it to work well with kiwiirc-plugin-conference, these are listed below: + +``` +AUTH_TYPE=jwt +ENABLE_AUTH=1 +ENABLE_AUTO_OWNER=0 +ENABLE_BREAKOUT_ROOMS=0 +ENABLE_GUESTS=0 +ENABLE_LOBBY=0 +ENABLE_PREJOIN_PAGE=0 +ENABLE_WELCOME_PAGE=0 +JICOFO_ENABLE_AUTH=0 +JWT_ACCEPTED_ISSUERS= +JWT_APP_ID= +JWT_APP_SECRET= +JWT_AUTH_TYPE=kiwiirc_token +JWT_ENABLE_DOMAIN_VERIFICATION=1 +JWT_TOKEN_AUTH_MODULE=kiwiirc_token_verification +XMPP_MUC_MODULES=kiwiirc_xmpp_muc +``` + +It is also advised to set `JITSI_IMAGE_VERSION` so that it does not update unexpectedly. + +### Special kiwiirc environment variables + +Some environment variables have been added to allow tweaking how the irc token is treated, these are listed below. + +``` +KIWIIRC_EVERYONE_MODERATOR=0 # makes every user with a token becomes jitsi moderator. +KIWIIRC_DISABLE_OWNER_MODERATOR=0 # stops channel owner from becoming a moderator. +KIWIIRC_DISABLE_OP_MODERATOR=0 # stops op's from becoming a moderator (network operators are always moderators). +KIWIIRC_DISABLE_HALFOP_MODERATOR=0 # stops half-op's from becoming a moderator. +KIWIIRC_DISABLE_QUERY_MODERATOR=0 # stops both members of a query chat becoming moderators. +JWT_VFY_URL= # optional URL for an external token verification endpoint (see below). +``` + +### External token verification (`JWT_VFY_URL`) + +When `JWT_VFY_URL` is set, every token must also be accepted by that URL before the user is allowed to connect. Prosody performs a `GET` request to the URL with the token supplied as an `Authorization: Bearer` header. A `200` or `204` response allows the connection; any other response rejects it. + +| Configuration | Behaviour | +|---|---| +| `JWT_APP_SECRET` only | HMAC signature check only (default) | +| `JWT_APP_SECRET` + `JWT_VFY_URL` | HMAC signature check **and** URL check — both must pass | +| `JWT_VFY_URL` only | URL is the sole cryptographic authority | + +This can alternatively be set as the `jwt_vfy_url` option directly in `prosody.cfg.lua` for non-Docker deployments. + +### Token replay protection + +Enabled automatically — no configuration required. Each token is recorded by its SHA-256 hash from the moment it is first used to authenticate. Any attempt to reuse the same token for a second connection is rejected with `token has already been used`, even if the signature is still valid. Entries are automatically expired once the token's `exp` claim is reached. + + +### Allowing users without tokens to connect + +To allow users with the link to the conference to connect without a token set the following environment variables +``` +ENABLE_GUESTS=1 +JWT_ALLOW_EMPTY=1 +ENABLE_PREJOIN_PAGE=1 +``` diff --git a/jitsi-plugin/jitsi-meet_10888/kiwiirc_config.lua b/jitsi-plugin/jitsi-meet_10888/kiwiirc_config.lua new file mode 100644 index 0000000..06986f6 --- /dev/null +++ b/jitsi-plugin/jitsi-meet_10888/kiwiirc_config.lua @@ -0,0 +1,10 @@ +local kiwi_config = { + ["KIWIIRC_EVERYONE_MODERATOR"] = false, + ["KIWIIRC_DISABLE_OWNER_MODERATOR"] = false, + ["KIWIIRC_DISABLE_OP_MODERATOR"] = false, + ["KIWIIRC_DISABLE_HALFOP_MODERATOR"] = false, + ["KIWIIRC_DISABLE_QUERY_MODERATOR"] = false, + ["XMPP_AUTH_DOMAIN"] = "auth.meet.jitsi", +} + +return kiwi_config diff --git a/jitsi-plugin/jitsi-meet_10888/kiwiirc_luajwtjitsi.lib.lua b/jitsi-plugin/jitsi-meet_10888/kiwiirc_luajwtjitsi.lib.lua new file mode 100644 index 0000000..14e651e --- /dev/null +++ b/jitsi-plugin/jitsi-meet_10888/kiwiirc_luajwtjitsi.lib.lua @@ -0,0 +1,263 @@ +local cjson_safe = require 'cjson.safe' +local basexx = require 'basexx' +local digest = require 'openssl.digest' +local hmac = require 'openssl.hmac' +local pkey = require 'openssl.pkey' + +-- Generates an RSA signature of the data. +-- @param data The data to be signed. +-- @param key The private signing key in PEM format. +-- @param algo The digest algorithm to user when generating the signature: sha256, sha384, or sha512. +-- @return The signature or nil and an error message. +local function signRS (data, key, algo) + local privkey = pkey.new(key) + if privkey == nil then + return nil, 'Not a private PEM key' + else + local datadigest = digest.new(algo):update(data) + return privkey:sign(datadigest) + end +end + +-- Verifies an RSA signature on the data. +-- @param data The signed data. +-- @param signature The signature to be verified. +-- @param key The public key of the signer. +-- @param algo The digest algorithm to user when generating the signature: sha256, sha384, or sha512. +-- @return True if the signature is valid, false otherwise. Also returns false if the key is invalid. +local function verifyRS (data, signature, key, algo) + local pubkey = pkey.new(key) + if pubkey == nil then + return false + end + + local datadigest = digest.new(algo):update(data) + return pubkey:verify(signature, datadigest) +end + +local alg_sign = { + ['HS256'] = function(data, key) return hmac.new(key, 'sha256'):final(data) end, + ['HS384'] = function(data, key) return hmac.new(key, 'sha384'):final(data) end, + ['HS512'] = function(data, key) return hmac.new(key, 'sha512'):final(data) end, + ['RS256'] = function(data, key) return signRS(data, key, 'sha256') end, + ['RS384'] = function(data, key) return signRS(data, key, 'sha384') end, + ['RS512'] = function(data, key) return signRS(data, key, 'sha512') end +} + +local alg_verify = { + ['HS256'] = function(data, signature, key) return signature == alg_sign['HS256'](data, key) end, + ['HS384'] = function(data, signature, key) return signature == alg_sign['HS384'](data, key) end, + ['HS512'] = function(data, signature, key) return signature == alg_sign['HS512'](data, key) end, + ['RS256'] = function(data, signature, key) return verifyRS(data, signature, key, 'sha256') end, + ['RS384'] = function(data, signature, key) return verifyRS(data, signature, key, 'sha384') end, + ['RS512'] = function(data, signature, key) return verifyRS(data, signature, key, 'sha512') end +} + +-- Splits a token into segments, separated by '.'. +-- @param token The full token to be split. +-- @return A table of segments. +local function split_token(token) + local segments={} + for str in string.gmatch(token, "([^\\.]+)") do + table.insert(segments, str) + end + return segments +end + +-- Parses a JWT token into it's header, body, and signature. +-- @param token The JWT token to be parsed. +-- @return A JSON header and body represented as a table, and a signature. +local function parse_token(token) + local segments=split_token(token) + if #segments ~= 3 then + return nil, nil, nil, "Invalid token" + end + + local header, err = cjson_safe.decode(basexx.from_url64(segments[1])) + if err then + return nil, nil, nil, "Invalid header" + end + + local body, err = cjson_safe.decode(basexx.from_url64(segments[2])) + if err then + return nil, nil, nil, "Invalid body" + end + + local sig, err = basexx.from_url64(segments[3]) + if err then + return nil, nil, nil, "Invalid signature" + end + + return header, body, sig +end + +-- Removes the signature from a JWT token. +-- @param token A JWT token. +-- @return The token without its signature. +local function strip_signature(token) + local segments=split_token(token) + if #segments ~= 3 then + return nil, nil, nil, "Invalid token" + end + + table.remove(segments) + return table.concat(segments, ".") +end + +-- Verifies that a claim is in a list of allowed claims. Allowed claims can be exact values, or the +-- catch all wildcard '*'. +-- @param claim The claim to be verified. +-- @param acceptedClaims A table of accepted claims. +-- @return True if the claim was allowed, false otherwise. +local function verify_claim(claim, acceptedClaims) + for i, accepted in ipairs(acceptedClaims) do + if accepted == '*' then + return true; + end + if claim == accepted then + return true; + end + end + + return false; +end + +local M = {} + +-- Encodes the data into a signed JWT token. +-- @param data The data the put in the body of the JWT token. +-- @param key The key to use for signing the JWT token. +-- @param alg The signature algorithm to use: HS256, HS384, HS512, RS256, RS384, or RS512. +-- @param header Additional values to put in the JWT header. +-- @param The resulting JWT token, or nil and an error message. +function M.encode(data, key, alg, header) + if type(data) ~= 'table' then return nil, "Argument #1 must be table" end + if type(key) ~= 'string' then return nil, "Argument #2 must be string" end + + alg = alg or "HS256" + + if not alg_sign[alg] then + return nil, "Algorithm not supported" + end + + header = header or {} + + header['typ'] = 'JWT' + header['alg'] = alg + + local headerEncoded, err = cjson_safe.encode(header) + if headerEncoded == nil then + return nil, err + end + + local dataEncoded, err = cjson_safe.encode(data) + if dataEncoded == nil then + return nil, err + end + + local segments = { + basexx.to_url64(headerEncoded), + basexx.to_url64(dataEncoded) + } + + local signing_input = table.concat(segments, ".") + local signature, error = alg_sign[alg](signing_input, key) + if signature == nil then + return nil, error + end + + segments[#segments+1] = basexx.to_url64(signature) + + return table.concat(segments, ".") +end + +-- Verify that the token is valid, and if it is return the decoded JSON payload data. +-- @param token The token to verify. +-- @param expectedAlgo The signature algorithm the caller expects the token to be signed with: +-- HS256, HS384, HS512, RS256, RS384, or RS512. +-- @param key The verification key used for the signature. +-- @param acceptedIssuers Optional table of accepted issuers. If not nil, the 'iss' claim will be +-- checked against this list. +-- @param acceptedAudiences Optional table of accepted audiences. If not nil, the 'aud' claim will +-- be checked against this list. +-- @return A table representing the JSON body of the token, or nil and an error message. +function M.verify(token, expectedAlgo, key, acceptedIssuers, acceptedAudiences) + if type(token) ~= 'string' then return nil, "token argument must be string" end + if type(expectedAlgo) ~= 'string' then return nil, "algorithm argument must be string" end + if type(key) ~= 'string' then return nil, "key argument must be string" end + if acceptedIssuers ~= nil and type(acceptedIssuers) ~= 'table' then + return nil, "acceptedIssuers argument must be table" + end + if acceptedAudiences ~= nil and type(acceptedAudiences) ~= 'table' then + return nil, "acceptedAudiences argument must be table" + end + + if not alg_verify[expectedAlgo] then + return nil, "Algorithm not supported" + end + + local header, body, sig, err = parse_token(token) + if err ~= nil then + return nil, err + end + + -- Validate header + if not header.typ or header.typ ~= "JWT" then + return nil, "Invalid typ" + end + + if not header.alg or header.alg ~= expectedAlgo then + return nil, "Invalid or incorrect alg" + end + + -- Validate signature + if not alg_verify[expectedAlgo](strip_signature(token), sig, key) then + return nil, 'Invalid signature' + end + + -- Validate body + if body.exp and type(body.exp) ~= "number" then + return nil, "exp must be number" + end + + if body.nbf and type(body.nbf) ~= "number" then + return nil, "nbf must be number" + end + + + if body.exp and os.time() >= body.exp then + return nil, "Token expired" + end + + if body.nbf and os.time() < body.nbf then + return nil, "Not acceptable by nbf" + end + + if acceptedIssuers ~= nil then + local issClaim = body.iss; + if issClaim == nil then + return nil, "'iss' claim is missing"; + end + if not verify_claim(issClaim, acceptedIssuers) then + return nil, "invalid 'iss' claim"; + end + end + + if acceptedAudiences ~= nil then + local audClaim = body.aud; + if audClaim == nil then + -- Missing aud is only acceptable when the wildcard '*' is in the list, + -- which means any (or no) audience is permitted. InspIRCd's EXTJWT + -- module does not emit an aud claim, so we must tolerate its absence. + if not verify_claim('*', acceptedAudiences) then + return nil, "'aud' claim is missing"; + end + elseif not verify_claim(audClaim, acceptedAudiences) then + return nil, "invalid 'aud' claim"; + end + end + + return body +end + +return M diff --git a/jitsi-plugin/jitsi-meet_10888/kiwiirc_token/jwk.lib.lua b/jitsi-plugin/jitsi-meet_10888/kiwiirc_token/jwk.lib.lua new file mode 100644 index 0000000..cf1aee8 --- /dev/null +++ b/jitsi-plugin/jitsi-meet_10888/kiwiirc_token/jwk.lib.lua @@ -0,0 +1,134 @@ +local basexx = require "basexx"; + +local M = {} + +-- Helper function to encode bytes to base64 +function base64_encode(bytes) + return basexx.to_base64(bytes) +end + +-- Pure Lua ASN.1 DER encoder (no external dependencies) +local ASN1 = {} + +-- Encode ASN.1 length field +function ASN1.encode_length(len) + if len < 128 then + return string.char(len) + elseif len < 256 then + return string.char(0x81, len) + elseif len < 65536 then + return string.char(0x82, math.floor(len / 256), len % 256) + else + local b1 = math.floor(len / 65536) + local b2 = math.floor((len % 65536) / 256) + local b3 = len % 256 + return string.char(0x83, b1, b2, b3) + end +end + +-- Encode ASN.1 INTEGER +function ASN1.encode_integer(bytes) + -- ASN.1 INTEGER tag is 0x02 + -- If the high bit is set, prepend 0x00 to indicate positive number + if bytes:byte(1) >= 0x80 then + bytes = string.char(0x00) .. bytes + end + return string.char(0x02) .. ASN1.encode_length(#bytes) .. bytes +end + +-- Encode ASN.1 SEQUENCE +function ASN1.encode_sequence(content) + -- ASN.1 SEQUENCE tag is 0x30 + return string.char(0x30) .. ASN1.encode_length(#content) .. content +end + +-- Encode ASN.1 BIT STRING +function ASN1.encode_bit_string(content) + -- ASN.1 BIT STRING tag is 0x03 + -- First byte indicates number of unused bits (0x00 for byte-aligned) + return string.char(0x03) .. ASN1.encode_length(#content + 1) .. string.char(0x00) .. content +end + +-- Encode ASN.1 OBJECT IDENTIFIER +function ASN1.encode_oid(oid_bytes) + -- ASN.1 OID tag is 0x06 + return string.char(0x06) .. ASN1.encode_length(#oid_bytes) .. oid_bytes +end + +-- Encode ASN.1 NULL +function ASN1.encode_null() + -- ASN.1 NULL tag is 0x05, length 0 + return string.char(0x05, 0x00) +end + +-- Convert DER to PEM format +function ASN1.der_to_pem(der, label) + label = label or "PUBLIC KEY" + local base64 = base64_encode(der) + + -- Break into 64-character lines + local lines = {} + for i = 1, #base64, 64 do + table.insert(lines, base64:sub(i, i + 63)) + end + + return "-----BEGIN " .. label .. "-----\n" .. + table.concat(lines, "\n") .. "\n" .. + "-----END " .. label .. "-----\n" +end + +-- Helper function to decode base64url +function base64url_decode(str) + -- Convert base64url to base64 + str = str:gsub('-', '+'):gsub('_', '/') + -- Add padding if needed + local padding = #str % 4 + if padding > 0 then + str = str .. string.rep('=', 4 - padding) + end + return basexx.from_base64(str) +end + +-- Helper function to convert JWK to PEM format +function M.jwk_to_pem(jwk) + -- Decode the modulus (n) and exponent (e) from base64url + local n_bytes = base64url_decode(jwk.n) + local e_bytes = base64url_decode(jwk.e) + + -- Build RSA public key structure + -- RSAPublicKey ::= SEQUENCE { + -- modulus INTEGER, -- n + -- publicExponent INTEGER -- e + -- } + local modulus_asn1 = ASN1.encode_integer(n_bytes) + local exponent_asn1 = ASN1.encode_integer(e_bytes) + local rsa_pubkey = ASN1.encode_sequence(modulus_asn1 .. exponent_asn1) + + -- Build SubjectPublicKeyInfo structure + -- SubjectPublicKeyInfo ::= SEQUENCE { + -- algorithm AlgorithmIdentifier, + -- subjectPublicKey BIT STRING + -- } + + -- RSA OID: 1.2.840.113549.1.1.1 (rsaEncryption) + -- Encoded as: 06 09 2A 86 48 86 F7 0D 01 01 01 + local rsa_oid = string.char(0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x01) + local rsa_oid_encoded = ASN1.encode_oid(rsa_oid) + + -- AlgorithmIdentifier ::= SEQUENCE { + -- algorithm OBJECT IDENTIFIER, + -- parameters NULL + -- } + local algorithm_id = ASN1.encode_sequence(rsa_oid_encoded .. ASN1.encode_null()) + + -- Wrap the RSA public key in a BIT STRING + local subject_public_key = ASN1.encode_bit_string(rsa_pubkey) + + -- Final SubjectPublicKeyInfo + local spki = ASN1.encode_sequence(algorithm_id .. subject_public_key) + + -- Convert to PEM format + return ASN1.der_to_pem(spki, "PUBLIC KEY") +end + +return M diff --git a/jitsi-plugin/jitsi-meet_10888/kiwiirc_token/util.lib.lua b/jitsi-plugin/jitsi-meet_10888/kiwiirc_token/util.lib.lua new file mode 100644 index 0000000..080e4f1 --- /dev/null +++ b/jitsi-plugin/jitsi-meet_10888/kiwiirc_token/util.lib.lua @@ -0,0 +1,657 @@ +-- Token authentication +-- Copyright (C) 2021-present 8x8, Inc. + +local basexx = require "basexx"; +local have_async, async = pcall(require, "util.async"); +local hex = require "util.hex"; +local jwt = module:require "kiwiirc_luajwtjitsi"; +local jwk_to_pem = module:require "kiwiirc_token/jwk".jwk_to_pem; +local jid = require "util.jid"; +local json_safe = require "cjson.safe"; +local path = require "util.paths"; +local sha256 = require "util.hashes".sha256; +local main_util = module:require "util"; +local ends_with = main_util.ends_with; +local http_get_with_retry = main_util.http_get_with_retry; +local extract_subdomain = main_util.extract_subdomain; +local starts_with = main_util.starts_with; +local table_shallow_copy = main_util.table_shallow_copy; +local cjson_safe = require 'cjson.safe' +local timer = require "util.timer"; +local async = require "util.async"; +local inspect = require 'inspect'; + +local kiwi_util = module:require "kiwiirc_util"; +local query_pattern = kiwi_util.query_pattern(); + +local nr_retries = 3; +local ssl = require "ssl"; + +-- TODO: Figure out a less arbitrary default cache size. +local cacheSize = module:get_option_number("jwt_pubkey_cache_size", 128); + +-- the cache for generated asap jwt tokens +local jwtKeyCache = require 'util.cache'.new(cacheSize); + +local ASAPTTL_THRESHOLD = module:get_option_number('asap_ttl_threshold', 600); +local ASAPTTL = module:get_option_number('asap_ttl', 3600); +local ASAPIssuer = module:get_option_string('asap_issuer', 'jitsi'); +local ASAPAudience = module:get_option_string('asap_audience', 'jitsi'); +local ASAPKeyId = module:get_option_string('asap_key_id', 'jitsi'); +local ASAPKeyPath = module:get_option_string('asap_key_path', '/etc/prosody/certs/asap.key'); + +local ASAPKey; +local f = io.open(ASAPKeyPath, 'r'); + +if f then + ASAPKey = f:read('*all'); + f:close(); +end + +-- Replay protection: track SHA-256 hashes of tokens that have been authenticated +-- until their expiry, so each token can only establish one session. +-- Keys: hex SHA-256 of the raw token string. Values: expiry (unix timestamp). +local usedTokens = {}; +local TOKEN_SWEEP_INTERVAL = 60; -- seconds between sweeps + +local function sweep_used_tokens() + local now = os.time(); + for hash, exp in pairs(usedTokens) do + if now >= exp then + usedTokens[hash] = nil; + end + end + return TOKEN_SWEEP_INTERVAL; +end +timer.add_task(TOKEN_SWEEP_INTERVAL, sweep_used_tokens); + +local Util = {} +Util.__index = Util + +--- Constructs util class for token verifications. +-- Constructor that uses the passed module to extract all the +-- needed configurations. +-- If configuration is missing returns nil +-- @param module the module in which options to check for configs. +-- @return the new instance or nil +function Util.new(module) + local self = setmetatable({}, Util) + + self.appId = module:get_option_string("app_id"); + self.appSecret = module:get_option_string("app_secret"); + self.asapKeyServer = module:get_option_string("asap_key_server"); + -- A URL that will return json file with a mapping between kids and public keys + -- If the response Cache-Control header we will respect it and refresh it + self.cacheKeysUrl = module:get_option_string("cache_keys_url"); + self.signatureAlgorithm = module:get_option_string("signature_algorithm"); + self.allowEmptyToken = module:get_option_boolean("allow_empty_token"); + + self.cache = require"util.cache".new(cacheSize); + + --[[ + Multidomain can be supported in some deployments. In these deployments + there is a virtual conference muc, which address contains the subdomain + to use. Those deployments are accessible + by URL https://domain/subdomain. + Then the address of the room will be: + roomName@conference.subdomain.domain. This is like a virtual address + where there is only one muc configured by default with address: + conference.domain and the actual presentation of the room in that muc + component is [subdomain]roomName@conference.domain. + These setups relay on configuration 'muc_domain_base' which holds + the main domain and we use it to subtract subdomains from the + virtual addresses. + The following configurations are for multidomain setups and domain name + verification: + --]] + + -- optional parameter for custom muc component prefix, + -- defaults to "conference" + self.muc_domain_prefix = module:get_option_string( + "muc_mapper_domain_prefix", "conference"); + -- domain base, which is the main domain used in the deployment, + -- the main VirtualHost for the deployment + self.muc_domain_base = module:get_option_string("muc_mapper_domain_base"); + -- The "real" MUC domain that we are proxying to + if self.muc_domain_base then + self.muc_domain = module:get_option_string( + "muc_mapper_domain", + self.muc_domain_prefix.."."..self.muc_domain_base); + end + -- whether domain name verification is enabled, by default it is enabled + -- when disabled checking domain name and tenant if available will be skipped, we will check only room name. + self.enableDomainVerification = module:get_option_boolean('enable_domain_verification', true); + + if self.allowEmptyToken == true then + module:log("warn", "WARNING - empty tokens allowed"); + end + + if self.appId == nil then + module:log("error", "'app_id' must not be empty"); + return nil; + end + + -- Optional pre-configured URL for an external token verification endpoint. + -- When set alongside app_secret, the token must pass both verifications. + -- Can be set via the JWT_VFY_URL environment variable or the jwt_vfy_url Prosody option. + self.jwtVfyUrl = os.getenv('JWT_VFY_URL') or module:get_option_string('jwt_vfy_url'); + + if self.appSecret == nil and self.asapKeyServer == nil and self.cacheKeysUrl == nil + and self.jwtVfyUrl == nil then + module:log("error", "'app_secret', 'asap_key_server', 'cache_keys_url' or 'jwt_vfy_url' must be specified"); + return nil; + end + + -- Set defaults for signature algorithm + if self.signatureAlgorithm == nil then + if self.asapKeyServer ~= nil or self.cacheKeysUrl then + self.signatureAlgorithm = "RS256" + elseif self.appSecret ~= nil then + self.signatureAlgorithm = "HS256" + elseif self.jwtVfyUrl ~= nil then + -- jwt_vfy_url-only mode: EXTJWT uses HS256, but allow override via signature_algorithm + self.signatureAlgorithm = "HS256" + end + end + + --array of accepted issuers: by default only includes our appId + self.acceptedIssuers = module:get_option_array('asap_accepted_issuers',{self.appId}) + + --array of accepted audiences: by default only includes our appId + self.acceptedAudiences = module:get_option_array('asap_accepted_audiences',{'*'}) + + self.requireRoomClaim = module:get_option_boolean('asap_require_room_claim', true); + + if (self.asapKeyServer or self.cacheKeysUrl) and not have_async then + module:log("error", "requires a version of Prosody with util.async"); + return nil; + end + + if self.cacheKeysUrl then + self.cachedKeys = {}; + local update_keys_cache; + update_keys_cache = async.runner(function (name) + local content, code, cache_for; + content, code, cache_for = http_get_with_retry(self.cacheKeysUrl, nr_retries); + if content ~= nil then + local keys_to_delete = table_shallow_copy(self.cachedKeys); + -- Let's convert any certificate to public key + for k, v in pairs(cjson_safe.decode(content)) do + -- JWKS format + if k == "keys" and type(v) == "table" then + for _, key in ipairs(v) do + if key.kid then + self.cachedKeys[key.kid] = jwk_to_pem(key); + + -- do not clean this key if it already exists + keys_to_delete[key.kid] = nil; + end + end + -- direct PEM mapping (Firebase) + elseif starts_with(v, '-----BEGIN CERTIFICATE-----') then + self.cachedKeys[k] = ssl.loadcertificate(v):pubkey(); + -- do not clean this key if it already exists + keys_to_delete[k] = nil; + end + end + -- let's schedule the clean in an hour and a half, current tokens will be valid for an hour + timer.add_task(90*60, function () + for k, _ in pairs(keys_to_delete) do + self.cachedKeys[k] = nil; + end + end); + + if cache_for then + cache_for = tonumber(cache_for); + -- let's schedule new update 60 seconds before the cache expiring + if cache_for > 60 then + cache_for = cache_for - 60; + end + timer.add_task(cache_for, function () + update_keys_cache:run("update_keys_cache"); + end); + else + -- no cache header let's consider updating in 6hours + timer.add_task(6*60*60, function () + update_keys_cache:run("update_keys_cache"); + end); + end + else + module:log('warn', 'Failed to retrieve cached public keys code:%s', code); + -- failed let's retry in 30 seconds + timer.add_task(30, function () + update_keys_cache:run("update_keys_cache"); + end); + end + end); + update_keys_cache:run("update_keys_cache"); + end + + return self +end + +function Util:set_asap_key_server(asapKeyServer) + self.asapKeyServer = asapKeyServer; +end + +function Util:set_asap_accepted_issuers(acceptedIssuers) + self.acceptedIssuers = acceptedIssuers; +end + +function Util:set_asap_accepted_audiences(acceptedAudiences) + self.acceptedAudiences = acceptedAudiences; +end + +function Util:set_asap_require_room_claim(checkRoom) + self.requireRoomClaim = checkRoom; +end + +function Util:clear_asap_cache() + self.cache = require"util.cache".new(cacheSize); +end + +--- Returns the public key by keyID +-- @param keyId the key ID to request +-- @return the public key (the content of requested resource) or nil +function Util:get_public_key(keyId) + local content = self.cache:get(keyId); + local code; + if content == nil then + -- If the key is not found in the cache. + -- module:log("debug", "Cache miss for key: %s", keyId); + local keyurl = path.join(self.asapKeyServer, hex.to(sha256(keyId))..'.pem'); + -- module:log("debug", "Fetching public key from: %s", keyurl); + content, code = http_get_with_retry(keyurl, nr_retries); + if content ~= nil then + self.cache:set(keyId, content); + else + if code == nil then + -- this is timeout after nr_retries retries + module:log('warn', 'Timeout retrieving %s from %s', keyId, keyurl); + end + end + return content; + else + -- If the key is in the cache, use it. + -- module:log("debug", "Cache hit for key: %s", keyId); + return content; + end +end + +--- Verifies token and process needed values to be stored in the session. +-- Token is obtained from session.auth_token. +-- Stores in session the following values: +-- session.jitsi_meet_room - the room name value from the token +-- session.jitsi_meet_domain - the domain name value from the token +-- session.jitsi_meet_context_user - the user details from the token +-- session.jitsi_meet_context_room - the room details from the token +-- session.jitsi_meet_context_group - the group value from the token +-- session.jitsi_meet_context_features - the features value from the token +-- @param session the current session +-- @return false and error +function Util:process_and_verify_token(session) + if session.auth_token == nil then + if self.allowEmptyToken then + return true; + else + return false, "not-allowed", "token required"; + end + end + + -- Replay protection: reject tokens that have already been used. + -- Checked before expensive cryptographic verification. + local token_hash = hex.to(sha256(session.auth_token)); + if usedTokens[token_hash] ~= nil then + module:log("warn", "Replay detected: token hash %s already used", token_hash:sub(1, 16)); + return false, "not-allowed", "token has already been used"; + end + + local key; + local skip_sig_verify = false; + if session.public_key then + -- We're using an public key stored in the session + -- module:log("debug","Public key was found on the session"); + key = session.public_key; + elseif (self.asapKeyServer or self.cacheKeysUrl) and session.auth_token ~= nil then + -- We're fetching an public key from an ASAP server + local dotFirst = session.auth_token:find("%."); + if not dotFirst then return false, "not-allowed", "Invalid token" end + local headerPartEncoded = basexx.from_url64(session.auth_token:sub(1,dotFirst-1)); + if not headerPartEncoded then return false, "not-allowed", "Invalid token" end + local header, err = json_safe.decode(headerPartEncoded); + if err then + return false, "not-allowed", "bad token format"; + end + local kid = header["kid"]; + if kid == nil then + return false, "not-allowed", "'kid' claim is missing"; + end + local alg = header["alg"]; + if alg == nil then + return false, "not-allowed", "'alg' claim is missing"; + end + if alg.sub(alg,1,2) ~= "RS" then + return false, "not-allowed", "'kid' claim only support with RS family"; + end + + if self.cachedKeys and self.cachedKeys[kid] then + key = self.cachedKeys[kid]; + else + key = self:get_public_key(kid); + end + + if key == nil then + return false, "not-allowed", "could not obtain public key"; + end + elseif self.appSecret ~= nil then + -- We're using a symmetric secret + key = self.appSecret + elseif self.jwtVfyUrl ~= nil then + -- jwtVfyUrl-only mode: skip local signature verification; the vfy URL + -- endpoint is the sole cryptographic authority for this token + skip_sig_verify = true; + end + + if not skip_sig_verify and key == nil then + return false, "not-allowed", "signature verification key is missing"; + end + + -- verify the whole token (or decode claims without signature verification) + local claims, msg; + if skip_sig_verify then + -- decode payload without verifying signature + local dotFirst = session.auth_token:find("%."); + if not dotFirst then return false, "not-allowed", "Invalid token" end + local dotSecond = session.auth_token:find("%.", dotFirst + 1); + if not dotSecond then return false, "not-allowed", "Invalid token" end + local payloadDecoded = basexx.from_url64(session.auth_token:sub(dotFirst + 1, dotSecond - 1)); + if not payloadDecoded then return false, "not-allowed", "Invalid token" end + claims, msg = json_safe.decode(payloadDecoded); + if not claims then + return false, "not-allowed", msg or "bad token format"; + end + else + claims, msg = jwt.verify( + session.auth_token, + self.signatureAlgorithm, + key, + self.acceptedIssuers, + self.acceptedAudiences + ) + end + if claims ~= nil then + -- If a verification URL is configured, the token must also be accepted by it. + -- This is an additional check on top of (or instead of) the shared secret. + if self.jwtVfyUrl then + local _, vfy_code = http_get_with_retry(self.jwtVfyUrl, nr_retries, session.auth_token); + if vfy_code ~= 200 and vfy_code ~= 204 then + return false, "not-allowed", "token rejected by verification endpoint"; + end + end + + -- Register token as used. Stored until its expiry so replayed tokens are + -- rejected even if the signature would otherwise still be valid. + local exp = claims["exp"]; + usedTokens[token_hash] = exp or (os.time() + 3600); + + if self.requireRoomClaim then + if claims["channel"] ~= nil then + claims["room"] = kiwi_util.encode_room_name(claims["iss"], claims["channel"]) + module:log("debug", "room encoded from '%s/%s' to '%s'", claims["iss"], claims["channel"], claims["room"]); + else + claims["room"] = "*"; + module:log("debug", "room maybe query"); + end + end + + local joined = claims["joined"]; + if claims["channel"] ~= nil and (joined == nil or joined <= 0) then + return false, "not-allowed", "user is not member of the channel"; + end + + -- Binds room name to the session which is later checked on MUC join + session.jitsi_meet_channel = claims["channel"]; + session.jitsi_meet_room = claims["room"]; + -- Binds domain name to the session + session.jitsi_meet_domain = "meet.jitsi"; + + session.jitsi_meet_joined = claims["joined"]; + session.jitsi_meet_issuer = claims["iss"]; + + session.jitsi_meet_affiliation = kiwi_util.get_kiwiirc_affiliation(claims); + module:log("debug", "token affiliation: '%s' for %s", session.jitsi_meet_affiliation, claims.sub); + + claims["context"] = {}; + claims["context"]["user"] = {}; + claims["context"]["user"]["name"] = claims["sub"]; + + -- Binds the user details to the session if available + if claims["context"] ~= nil then + session.jitsi_meet_str_tenant = claims["context"]["tenant"]; + + if claims["context"]["user"] ~= nil then + session.jitsi_meet_context_user = claims["context"]["user"]; + end + + if claims["context"]["group"] ~= nil then + -- Binds any group details to the session + session.jitsi_meet_context_group = claims["context"]["group"]; + end + + if claims["context"]["features"] ~= nil then + -- Binds any features details to the session + session.jitsi_meet_context_features = claims["context"]["features"]; + end + if claims["context"]["room"] ~= nil then + session.jitsi_meet_context_room = claims["context"]["room"] + end + elseif claims["user_id"] then + session.jitsi_meet_context_user = {}; + session.jitsi_meet_context_user.id = claims["user_id"]; + end + + -- fire event that token has been verified and pass the session and the decoded token + prosody.events.fire_event('jitsi-authentication-token-verified', { + session = session; + claims = claims; + }); + + if session.contextRequired and claims["context"] == nil then + return false, "not-allowed", 'jwt missing required context claim'; + end + + return true; + else + return false, "not-allowed", msg; + end +end + +--- Verifies room name and domain if necessary. +-- Checks configs and if necessary checks the room name extracted from +-- room_address against the one saved in the session when token was verified. +-- Also verifies domain name from token against the domain in the room_address, +-- if enableDomainVerification is enabled. +-- @param session the current session +-- @param room_address the whole room address as received +-- @return returns true in case room was verified or there is no need to verify +-- it and returns false in case verification was processed +-- and was not successful +function Util:verify_room(session, room_address) + if self.allowEmptyToken and session.auth_token == nil then + --module:log("debug", "Skipped room token verification - empty tokens are allowed"); + return true; + end + + -- extract room name using all chars, except the not allowed ones + local room,_,_ = jid.split(room_address); + if room == nil then + module:log('error', 'Unable to get name of the MUC room ? to: %s', room_address); + return false, 'invalid-room-address', 'Room address is invalid'; + end + + module:log("debug", "verify_room: '%s'", room) + + -- kiwiirc: channel conferences verify the encoded room name directly; + -- query conferences (no channel) must match the query room name pattern + if session.jitsi_meet_channel ~= nil then + if session.jitsi_meet_room ~= room then + module:log("warn", "verify_room: Not matching '%s' ~= '%s'", session.jitsi_meet_room, room); + return false, 'room-mismatch', 'Room does not match the room from token'; + end + elseif not room:match(query_pattern) then + module:log("warn", "verify_room: Not a query"); + return false, 'room-mismatch', 'Room does not match the room from token'; + end + + local auth_room = session.jitsi_meet_room; + if auth_room then + if type(auth_room) == 'string' then + auth_room = string.lower(auth_room); + else + module:log('warn', 'session.jitsi_meet_room not string: %s', inspect(auth_room)); + end + end + + if not self.enableDomainVerification then + -- if auth_room is missing, this means user is anonymous (no token for its domain) we let it through + if auth_room and (room ~= auth_room and not ends_with(room, ']'..auth_room)) and auth_room ~= '*' then + return false, 'room-mismatch', 'Room does not match the room from token'; + end + + return true; + end + + local room_address_to_verify = jid.bare(room_address); + local room_node = jid.node(room_address); + -- parses bare room address, for multidomain expected format is: + -- [subdomain]roomName@conference.domain + local target_subdomain, target_room = extract_subdomain(room_node); + + -- if we have '*' as room name in token, this means all rooms are allowed + -- so we will use the actual name of the room when constructing strings + -- to verify subdomains and domains to simplify checks + local room_to_check; + if auth_room == '*' then + -- authorized for accessing any room assign to room_to_check the actual + -- room name + if target_room ~= nil then + -- we are in multidomain mode and we were able to extract room name + room_to_check = target_room; + else + -- no target_room, room_address_to_verify does not contain subdomain + -- so we get just the node which is the room name + room_to_check = room_node; + end + else + -- no wildcard, so check room against authorized room from the token + if session.jitsi_meet_context_room and (session.jitsi_meet_context_room["regex"] == true or session.jitsi_meet_context_room["regex"] == "true") then + if target_room ~= nil then + -- room with subdomain + room_to_check = target_room:match(auth_room); + else + room_to_check = room_node:match(auth_room); + end + else + -- not a regex + room_to_check = auth_room; + end + + if not room_to_check then + if not self.requireRoomClaim then + -- if we do not require to have the room claim, and it is missing + -- there is no point of continue and verifying the roomName and the tenant + return true; + end + + return false, 'room-name-does-not-match', 'Room name cannot be matched to the one from token.'; + end + end + + if session.jitsi_meet_str_tenant + and string.lower(session.jitsi_meet_str_tenant) ~= session.jitsi_web_query_prefix then + session.jitsi_meet_tenant_mismatch = true; + + module:log('warn', 'Tenant differs for user:%s group:%s url_tenant:%s token_tenant:%s', + session.jitsi_meet_context_user and session.jitsi_meet_context_user.id or '', + session.jitsi_meet_context_group, + session.jitsi_web_query_prefix, session.jitsi_meet_str_tenant); + end + + local auth_domain = string.lower(session.jitsi_meet_domain); + local subdomain_to_check; + if target_subdomain then + if auth_domain == '*' then + -- check for wildcard in JWT claim, allow access if found + subdomain_to_check = target_subdomain; + else + -- no wildcard in JWT claim, so check subdomain against sub in token + subdomain_to_check = auth_domain; + end + -- from this point we depend on muc_domain_base, + -- deny access if option is missing + if not self.muc_domain_base then + module:log("warn", "No 'muc_domain_base' option set, denying access!"); + return false, 'server-missing-config', 'Misconfiguration of server'; + end + + return room_address_to_verify == jid.join( + "["..subdomain_to_check.."]"..room_to_check, self.muc_domain); + else + if auth_domain == '*' then + -- check for wildcard in JWT claim, allow access if found + subdomain_to_check = self.muc_domain; + else + -- no wildcard in JWT claim, so check subdomain against sub in token + subdomain_to_check = self.muc_domain_prefix.."."..auth_domain; + end + -- we do not have a domain part (multidomain is not enabled) + -- verify with info from the token + return room_address_to_verify == jid.join(room_to_check, subdomain_to_check); + end +end + +function Util:generateAsapToken(audience) + if not ASAPKey then + module:log('warn', 'No ASAP Key read, asap key generation is disabled'); + return '' + end + + audience = audience or ASAPAudience + local t = os.time() + local err + local exp_key = 'asap_exp.'..audience + local token_key = 'asap_token.'..audience + local exp = jwtKeyCache:get(exp_key) + local token = jwtKeyCache:get(token_key) + + --if we find a token and it isn't too far from expiry, then use it + if token ~= nil and exp ~= nil then + exp = tonumber(exp) + if (exp - t) > ASAPTTL_THRESHOLD then + return token + end + end + + --expiry is the current time plus TTL + exp = t + ASAPTTL + local payload = { + iss = ASAPIssuer, + aud = audience, + nbf = t, + exp = exp, + } + + -- encode + local alg = 'RS256' + token, err = jwt.encode(payload, ASAPKey, alg, { kid = ASAPKeyId }) + if not err then + token = 'Bearer '..token + jwtKeyCache:set(exp_key, exp) + jwtKeyCache:set(token_key, token) + return token + else + return '' + end +end + +return Util; diff --git a/jitsi-plugin/jitsi-meet_10888/kiwiirc_util.lib.lua b/jitsi-plugin/jitsi-meet_10888/kiwiirc_util.lib.lua new file mode 100644 index 0000000..91b2726 --- /dev/null +++ b/jitsi-plugin/jitsi-meet_10888/kiwiirc_util.lib.lua @@ -0,0 +1,885 @@ +local http_server = require "net.http.server"; +local jid = require "util.jid"; +local sha256 = require "util.hashes".sha256; +local st = require 'util.stanza'; +local timer = require "util.timer"; +local http = require "net.http"; +local cache = require "util.cache"; +local array = require "util.array"; +local is_set = require 'util.set'.is_set; +local usermanager = require 'core.usermanager'; + +local config_global_admin_jids = module:context('*'):get_option_set('admins', {}) / jid.prep; +local config_admin_jids = module:get_option_inherited_set('admins', {}) / jid.prep; + +local http_timeout = 30; +local have_async, async = pcall(require, "util.async"); +local http_headers = { + ["User-Agent"] = "Prosody ("..prosody.version.."; "..prosody.platform..")" +}; + +local muc_domain_prefix = module:get_option_string("muc_mapper_domain_prefix", "conference"); + +-- defaults to module.host, the module that uses the utility +local muc_domain_base = module:get_option_string("muc_mapper_domain_base", module.host); + +-- The "real" MUC domain that we are proxying to +local muc_domain = module:get_option_string("muc_mapper_domain", muc_domain_prefix.."."..muc_domain_base); + +local escaped_muc_domain_base = muc_domain_base:gsub("%p", "%%%1"); +local escaped_muc_domain_prefix = muc_domain_prefix:gsub("%p", "%%%1"); +-- The pattern used to extract the target subdomain +-- (e.g. extract 'foo' from 'conference.foo.example.com') +local target_subdomain_pattern = "^"..escaped_muc_domain_prefix..".([^%.]+)%."..escaped_muc_domain_base; + +-- table to store all incoming iqs without roomname in it, like discoinfo to the muc component +local roomless_iqs = {}; + +local OUTBOUND_SIP_JIBRI_PREFIXES = { 'outbound-sip-jibri@', 'sipjibriouta@', 'sipjibrioutb@' }; +local INBOUND_SIP_JIBRI_PREFIXES = { 'inbound-sip-jibri@', 'sipjibriina@', 'sipjibriina@' }; +local RECORDER_PREFIXES = module:get_option_inherited_set('recorder_prefixes', { 'recorder@recorder.', 'jibria@recorder.', 'jibrib@recorder.' }); +local TRANSCRIBER_PREFIXES = module:get_option_inherited_set('transcriber_prefixes', { 'transcriber@recorder.', 'transcribera@recorder.', 'transcriberb@recorder.' }); + +local split_subdomain_cache = cache.new(1000); +local extract_subdomain_cache = cache.new(1000); +local internal_room_jid_cache = cache.new(1000); + +local moderated_subdomains = module:get_option_set("allowners_moderated_subdomains", {}) +local moderated_rooms = module:get_option_set("allowners_moderated_rooms", {}) + +-- Utility function to split room JID to include room name and subdomain +-- (e.g. from room1@conference.foo.example.com/res returns (room1, example.com, res, foo)) +local function room_jid_split_subdomain(room_jid) + local ret = split_subdomain_cache:get(room_jid); + if ret then + return ret.node, ret.host, ret.resource, ret.subdomain; + end + + local node, host, resource = jid.split(room_jid); + + local target_subdomain = host and host:match(target_subdomain_pattern); + local cache_value = {node=node, host=host, resource=resource, subdomain=target_subdomain}; + split_subdomain_cache:set(room_jid, cache_value); + return node, host, resource, target_subdomain; +end + +--- Utility function to check and convert a room JID from +--- virtual room1@conference.foo.example.com to real [foo]room1@conference.example.com +-- @param room_jid the room jid to match and rewrite if needed +-- @param stanza the stanza +-- @return returns room jid [foo]room1@conference.example.com when it has subdomain +-- otherwise room1@conference.example.com(the room_jid value untouched) +local function room_jid_match_rewrite(room_jid, stanza) + local node, _, resource, target_subdomain = room_jid_split_subdomain(room_jid); + if not target_subdomain then + -- module:log("debug", "No need to rewrite out 'to' %s", room_jid); + return room_jid; + end + -- Ok, rewrite room_jid address to new format + local new_node, new_host, new_resource; + if node then + new_node, new_host, new_resource = "["..target_subdomain.."]"..node, muc_domain, resource; + else + -- module:log("debug", "No room name provided so rewriting only host 'to' %s", room_jid); + new_host, new_resource = muc_domain, resource; + + if (stanza and stanza.attr and stanza.attr.id) then + roomless_iqs[stanza.attr.id] = stanza.attr.to; + end + end + + return jid.join(new_node, new_host, new_resource); +end + +-- Utility function to check and convert a room JID from real [foo]room1@muc.example.com to virtual room1@muc.foo.example.com +local function internal_room_jid_match_rewrite(room_jid, stanza) + -- first check for roomless_iqs + if (stanza and stanza.attr and stanza.attr.id and roomless_iqs[stanza.attr.id]) then + local result = roomless_iqs[stanza.attr.id]; + roomless_iqs[stanza.attr.id] = nil; + return result; + end + + local ret = internal_room_jid_cache:get(room_jid); + if ret then + return ret; + end + + local node, host, resource = jid.split(room_jid); + if host ~= muc_domain or not node then + -- module:log("debug", "No need to rewrite %s (not from the MUC host)", room_jid); + internal_room_jid_cache:set(room_jid, room_jid); + return room_jid; + end + + local target_subdomain, target_node = extract_subdomain(node); + if not (target_node and target_subdomain) then + -- module:log("debug", "Not rewriting... unexpected node format: %s", node); + internal_room_jid_cache:set(room_jid, room_jid); + return room_jid; + end + + -- Ok, rewrite room_jid address to pretty format + ret = jid.join(target_node, muc_domain_prefix..".".. target_subdomain.."."..muc_domain_base, resource); + internal_room_jid_cache:set(room_jid, ret); + return ret; +end + +--- Finds and returns room by its jid +-- @param room_jid the room jid to search in the muc component +-- @return returns room if found or nil +function get_room_from_jid(room_jid) + local _, host = jid.split(room_jid); + local component = hosts[host]; + if component then + local muc = component.modules.muc + if muc then + return muc.get_room_from_jid(room_jid); + else + return + end + end +end + +-- Returns the room if available, work and in multidomain mode +-- @param room_name the name of the room +-- @param group name of the group (optional) +-- @return returns room if found or nil +function get_room_by_name_and_subdomain(room_name, subdomain) + local room_address; + + -- if there is a subdomain we are in multidomain mode and that subdomain is not our main host + if subdomain and subdomain ~= "" and subdomain ~= muc_domain_base then + room_address = jid.join("["..subdomain.."]"..room_name, muc_domain); + else + room_address = jid.join(room_name, muc_domain); + end + + return get_room_from_jid(room_address); +end + +function async_handler_wrapper(event, handler) + if not have_async then + module:log("error", "requires a version of Prosody with util.async"); + return nil; + end + + local runner = async.runner; + + -- Grab a local response so that we can send the http response when + -- the handler is done. + local response = event.response; + local async_func = runner( + function (event) + local result = handler(event) + + -- If there is a status code in the result from the + -- wrapped handler then add it to the response. + if tonumber(result.status_code) ~= nil then + response.status_code = result.status_code + end + + -- If there are headers in the result from the + -- wrapped handler then add them to the response. + if result.headers ~= nil then + response.headers = result.headers + end + + -- Send the response to the waiting http client with + -- or without the body from the wrapped handler. + if result.body ~= nil then + response:send(result.body) + else + response:send(); + end + end + ) + async_func:run(event) + -- return true to keep the client http connection open. + return true; +end + +--- Updates presence stanza, by adding identity node +-- @param stanza the presence stanza +-- @param user the user to which presence we are updating identity +-- @param group the group of the user to which presence we are updating identity +-- @param creator_user the user who created the user which presence we +-- are updating (this is the poltergeist case, where a user creates +-- a poltergeist), optional. +-- @param creator_group the group of the user who created the user which +-- presence we are updating (this is the poltergeist case, where a user creates +-- a poltergeist), optional. +function update_presence_identity(stanza, user, group, creator_user, creator_group) + + -- First remove any 'identity' element if it already + -- exists, so it cannot be spoofed by a client + stanza:maptags( + function(tag) + for k, v in pairs(tag) do + if k == "name" and v == "identity" then + return nil + end + end + return tag + end + ); + + if not user then + return; + end + + stanza:tag("identity"):tag("user"); + for k, v in pairs(user) do + v = tostring(v) + stanza:tag(k):text(v):up(); + end + stanza:up(); + + -- Add the group information if it is present + if group then + stanza:tag("group"):text(group):up(); + end + + -- Add the creator user information if it is present + if creator_user then + stanza:tag("creator_user"); + for k, v in pairs(creator_user) do + stanza:tag(k):text(v):up(); + end + stanza:up(); + + -- Add the creator group information if it is present + if creator_group then + stanza:tag("creator_group"):text(creator_group):up(); + end + end + + stanza:up(); -- Close identity tag +end + +-- Utility function to check whether feature is present and enabled. Allow +-- a feature if there are features present in the session(coming from +-- the token) and the value of the feature is true. +-- if features are missing from the token we check whether it is moderator +function is_feature_allowed(ft, features, is_moderator) + if features then + return features[ft] == "true" or features[ft] == true; + else + return is_moderator; + end +end + +--- Extracts the subdomain and room name from internal jid node [foo]room1 +-- @return subdomain(optional, if extracted or nil), the room name, the customer_id in case of vpaas +function extract_subdomain(room_node) + local ret = extract_subdomain_cache:get(room_node); + if ret then + return ret.subdomain, ret.room, ret.customer_id; + end + + local subdomain, room_name = room_node:match("^%[([^%]]+)%](.+)$"); + + if not subdomain then + room_name = room_node; + end + + local _, customer_id = subdomain and subdomain:match("^(vpaas%-magic%-cookie%-)(.*)$") or nil, nil; + local cache_value = { subdomain=subdomain, room=room_name, customer_id=customer_id }; + extract_subdomain_cache:set(room_node, cache_value); + return subdomain, room_name, customer_id; +end + +function starts_with(str, start) + if not str then + return false; + end + return str:sub(1, #start) == start +end + +function starts_with_one_of(str, prefixes) + if not str or not prefixes then + return false; + end + + if is_set(prefixes) then + -- set is a table with keys and value of true + for k, _ in prefixes:items() do + if starts_with(str, k) then + return k; + end + end + else + for _, v in pairs(prefixes) do + if starts_with(str, v) then + return v; + end + end + end + + return false +end + +function ends_with(str, ending) + if not str then + return false; + end + + return ending == "" or str:sub(-#ending) == ending +end + +-- healthcheck rooms in jicofo starts with a string '__jicofo-health-check' +function is_healthcheck_room(room_jid) + return starts_with(room_jid, "__jicofo-health-check"); +end + +--- Utility function to make an http get request and +--- retry @param retry number of times +-- @param url endpoint to be called +-- @param retry nr of retries, if retry is +-- @param auth_token value to be passed as auth Bearer +-- nil there will be no retries +-- @returns result of the http call or nil if +-- the external call failed after the last retry +function http_get_with_retry(url, retry, auth_token) + local content, code, cache_for; + local timeout_occurred; + local wait, done = async.waiter(); + local request_headers = http_headers or {} + if auth_token ~= nil then + request_headers['Authorization'] = 'Bearer ' .. auth_token + end + + local function cb(content_, code_, response_, request_) + if timeout_occurred == nil then + code = code_; + if code == 200 or code == 204 then + -- module:log("debug", "External call was successful, content %s", content_); + content = content_; + + -- if there is cache-control header, let's return the max-age value + if response_ and response_.headers and response_.headers['cache-control'] then + local vals = {}; + for k, v in response_.headers['cache-control']:gmatch('(%w+)=(%w+)') do + vals[k] = v; + end + -- max-age=123 will be parsed by the regex ^ to age=123 + cache_for = vals.age; + end + else + module:log("warn", "Error on GET request: Code %s, Content %s", + code_, content_); + end + done(); + else + module:log("warn", "External call reply delivered after timeout from: %s", url); + end + end + + local function call_http() + return http.request(url, { + headers = request_headers, + method = "GET" + }, cb); + end + + local request = call_http(); + + local function cancel() + -- TODO: This check is racey. Not likely to be a problem, but we should + -- still stick a mutex on content / code at some point. + if code == nil then + timeout_occurred = true; + module:log("warn", "Timeout %s seconds making the external call to: %s", http_timeout, url); + -- no longer present in prosody 0.11, so check before calling + if http.destroy_request ~= nil then + http.destroy_request(request); + end + if retry == nil then + module:log("debug", "External call failed and retry policy is not set"); + done(); + elseif retry ~= nil and retry < 1 then + module:log("debug", "External call failed after retry") + done(); + else + module:log("debug", "External call failed, retry nr %s", retry) + retry = retry - 1; + request = call_http() + return http_timeout; + end + end + end + timer.add_task(http_timeout, cancel); + wait(); + + return content, code, cache_for; +end + +-- Checks whether there is status in the false +-- -> true, room_name, subdomain +-- -> true, room_name, nil (if no subdomain is used for the room) +function is_moderated(room_jid) + if moderated_subdomains:empty() and moderated_rooms:empty() then + return false; + end + + local room_node = jid.node(room_jid); + -- parses bare room address, for multidomain expected format is: + -- [subdomain]roomName@conference.domain + local target_subdomain, target_room_name = extract_subdomain(room_node); + if target_subdomain then + if moderated_subdomains:contains(target_subdomain) then + return true, target_room_name, target_subdomain; + end + elseif moderated_rooms:contains(room_node) then + return true, room_node, nil; + end + + return false; +end + +-- check if the room tenant starts with vpaas-magic-cookie- +-- @param room the room to check +function is_vpaas(room) + if not room then + return false; + end + + -- stored check in room object if it exist + if room.is_vpaas ~= nil then + return room.is_vpaas; + end + + room.is_vpaas = false; + + local node, host = jid.split(room.jid); + if host ~= muc_domain or not node then + return false; + end + local tenant, conference_name = node:match('^%[([^%]]+)%](.+)$'); + if not (tenant and conference_name) then + return false; + end + + if not starts_with(tenant, 'vpaas-magic-cookie-') then + return false; + end + + room.is_vpaas = true; + return true; +end + +-- Returns the initiator extension if the stanza is coming from a sip jigasi +function is_sip_jigasi(stanza) + if not stanza then + return false; + end + + return stanza:get_child('initiator', 'http://jitsi.org/protocol/jigasi'); +end + +-- This requires presence stanza being passed +function is_transcriber_jigasi(stanza) + if not stanza then + return false; + end + + local features = stanza:get_child('features'); + if not features then + return false; + end + + for i = 1, #features do + local feature = features[i]; + if feature.attr and feature.attr.var and feature.attr.var == 'http://jitsi.org/protocol/transcriber' then + return true; + end + end + + return false; +end + +function is_transcriber(jid) + return starts_with_one_of(jid, TRANSCRIBER_PREFIXES); +end + +function get_sip_jibri_email_prefix(email) + if not email then + return nil; + elseif starts_with_one_of(email, INBOUND_SIP_JIBRI_PREFIXES) then + return starts_with_one_of(email, INBOUND_SIP_JIBRI_PREFIXES); + elseif starts_with_one_of(email, OUTBOUND_SIP_JIBRI_PREFIXES) then + return starts_with_one_of(email, OUTBOUND_SIP_JIBRI_PREFIXES); + else + return nil; + end +end + +function is_sip_jibri_join(stanza) + if not stanza then + return false; + end + + local features = stanza:get_child('features'); + local email = stanza:get_child_text('email'); + + if not features or not email then + return false; + end + + for i = 1, #features do + local feature = features[i]; + if feature.attr and feature.attr.var and feature.attr.var == "http://jitsi.org/protocol/jibri" then + if get_sip_jibri_email_prefix(email) then + module:log("debug", "Occupant with email %s is a sip jibri ", email); + return true; + end + end + end + + return false +end + +function is_jibri(occupant) + return starts_with_one_of(type(occupant) == "string" and occupant or occupant.jid, RECORDER_PREFIXES) +end + +-- process a host module directly if loaded or hooks to wait for its load +function process_host_module(name, callback) + local function process_host(host) + + if host == name then + callback(module:context(host), host); + end + end + + if prosody.hosts[name] == nil then + module:log('info', 'No host/component found, will wait for it: %s', name) + + -- when a host or component is added + prosody.events.add_handler('host-activated', process_host, -100); -- make sure everything is loaded + else + process_host(name); + end +end + +function table_shallow_copy(t) + local t2 = {} + for k, v in pairs(t) do + t2[k] = v + end + return t2 +end + +local function table_find(tab, val) + if not tab or val == nil then + return nil + end + + for i, v in ipairs(tab) do + if v == val then + return i + end + end + return nil +end + +-- Adds second table values to the first table +local function table_add(t1, t2) + for _,v in ipairs(t2) do + table.insert(t1, v); + end +end + +-- Returns as a first result the removed items and as a second the added items +local function table_compare(old_table, new_table) + local removed = {} + local added = {} + local modified = {} + + -- Find removed items (in old but not in new) + for id, value in pairs(old_table) do + if new_table[id] == nil then + table.insert(removed, id) + elseif new_table[id] ~= value then + table.insert(modified, id) + end + end + + -- Find added items (in new but not in old) + for id, _ in pairs(new_table) do + if old_table[id] == nil then + table.insert(added, id) + end + end + + return removed, added, modified +end + +local function table_equals(t1, t2) + if t1 == nil then + return t2 == nil; + end + if t2 == nil then + return t1 == nil; + end + + local removed, added, modified = table_compare(t1, t2); + + return next(removed) == nil and next(added) == nil and next(modified) == nil +end + +-- Splits a string using delimiter +function split_string(str, delimiter) + str = str .. delimiter; + local result = array(); + for w in str:gmatch("(.-)" .. delimiter) do + result:push(w); + end + + return result; +end + +-- send iq result that the iq was received and will be processed +function respond_iq_result(origin, stanza) + -- respond with successful receiving the iq + origin.send(st.iq({ + type = 'result'; + from = stanza.attr.to; + to = stanza.attr.from; + id = stanza.attr.id + })); +end + +-- Note: http_server.get_request_from_conn() was added in Prosody 0.12.3, +-- this code provides backwards compatibility with older versions +local get_request_from_conn = http_server.get_request_from_conn or function (conn) + local response = conn and conn._http_open_response; + return response and response.request or nil; +end; + +-- Discover real remote IP of a session +function get_ip(session) + local request = get_request_from_conn(session.conn); + return request and request.ip or session.ip; +end + +-- Checks whether the provided jid is in the list of admins +-- we are not using the new permissions and roles api as we have few global modules which need to be +-- refactored into host modules, as that api needs to be executed in host context +local function is_admin(_jid) + local bare_jid = jid.bare(_jid); + + if config_global_admin_jids:contains(bare_jid) or config_admin_jids:contains(bare_jid) then + return true; + end + return false; +end + +-- KiwiIRC-specific utilities + +local kiwi_config = module:require "kiwiirc_config"; + +local base36_chars = "0123456789abcdefghijklmnopqrstuvwxyz"; + +local function base36_encode(binary) + local base36 = ""; + local bytes = {}; + + for i = 1, #binary do + bytes[i] = string.byte(binary, i); + end + + while #bytes > 0 do + local quotient = {}; + local remainder = 0; + + for i = #bytes, 1, -1 do + local accumulator = bytes[i] + remainder * 256; + local digit = math.floor(accumulator / 36); + remainder = accumulator % 36; + if #quotient > 0 or digit > 0 then + table.insert(quotient, 1, digit); + end + end + + base36 = base36 .. base36_chars:sub(remainder + 1, remainder + 1); + bytes = quotient; + end + + return base36; +end + +local function kiwiirc_get_env(key) + local value = os.getenv(key); + + if value == nil then + local default = kiwi_config[key]; + if default == nil then + return false; + else + return default; + end + end + + if value == "false" or value == "0" then + return false; + end + + if value == "true" or value == "1" then + return true; + end + + return value; +end + +local function kiwiirc_encode_room_name(server, channel) + local hash = sha256(server .. "/" .. channel); + local hash_b36 = base36_encode(hash); + return string.sub(hash_b36, -16); +end + +local function kiwiirc_get_affiliation(claims) + local allMod = kiwiirc_get_env("KIWIIRC_EVERYONE_MODERATOR"); + if allMod then + return "owner"; + end + + local queryMod = kiwiirc_get_env("KIWIIRC_DISABLE_QUERY_MODERATOR"); + if claims.channel == nil and not queryMod then + return "owner"; + end + + if claims.umodes ~= nil then + for _, v in ipairs(claims.umodes) do + if v == "o" then return "owner"; end + end + end + + if claims.cmodes ~= nil then + local channelOwner = kiwiirc_get_env("KIWIIRC_DISABLE_OWNER_MODERATOR"); + if not channelOwner then + for _, v in ipairs(claims.cmodes) do + if v == "q" then return "owner"; end + end + end + + local op = kiwiirc_get_env("KIWIIRC_DISABLE_OP_MODERATOR"); + if not op then + for _, v in ipairs(claims.cmodes) do + if v == "o" then return "owner"; end + end + end + + local halfop = kiwiirc_get_env("KIWIIRC_DISABLE_HALFOP_MODERATOR"); + if not halfop then + for _, v in ipairs(claims.cmodes) do + if v == "h" then return "owner"; end + end + end + end + + return "member"; +end + +local function kiwiirc_query_pattern() + local pattern = "^q%-"; + for i = 1, 16 do + pattern = pattern .. "[a-z0-9]"; + end + return pattern .. "$"; +end + +-- Filter out identity information (nick name, email, etc) from a presence stanza. +local function filter_identity_from_presence(orig_stanza) + local stanza = st.clone(orig_stanza); + + stanza:remove_children('nick', 'http://jabber.org/protocol/nick'); + stanza:remove_children('email'); + stanza:remove_children('stats-id'); + local identity = stanza:get_child('identity'); + if identity then + local user = identity:get_child('user'); + local name = identity:get_child('name'); + if user then + user:remove_children('email'); + user:remove_children('name'); + end + if name then + name:remove_children('name'); -- Remove name with no namespace + end + end + + return stanza; +end + +return { + OUTBOUND_SIP_JIBRI_PREFIXES = OUTBOUND_SIP_JIBRI_PREFIXES; + INBOUND_SIP_JIBRI_PREFIXES = INBOUND_SIP_JIBRI_PREFIXES; + RECORDER_PREFIXES = RECORDER_PREFIXES; + extract_subdomain = extract_subdomain; + filter_identity_from_presence = filter_identity_from_presence; + is_admin = is_admin; + is_feature_allowed = is_feature_allowed; + is_jibri = is_jibri; + is_healthcheck_room = is_healthcheck_room; + is_moderated = is_moderated; + is_sip_jibri_join = is_sip_jibri_join; + is_sip_jigasi = is_sip_jigasi; + is_transcriber = is_transcriber; + is_transcriber_jigasi = is_transcriber_jigasi; + is_vpaas = is_vpaas; + get_focus_occupant = get_focus_occupant; + get_ip = get_ip; + get_room_from_jid = get_room_from_jid; + get_room_by_name_and_subdomain = get_room_by_name_and_subdomain; + get_sip_jibri_email_prefix = get_sip_jibri_email_prefix; + async_handler_wrapper = async_handler_wrapper; + presence_check_status = presence_check_status; + process_host_module = process_host_module; + respond_iq_result = respond_iq_result; + room_jid_match_rewrite = room_jid_match_rewrite; + room_jid_split_subdomain = room_jid_split_subdomain; + internal_room_jid_match_rewrite = internal_room_jid_match_rewrite; + update_presence_identity = update_presence_identity; + http_get_with_retry = http_get_with_retry; + ends_with = ends_with; + split_string = split_string; + starts_with = starts_with; + starts_with_one_of = starts_with_one_of; + table_add = table_add; + table_compare = table_compare; + table_shallow_copy = table_shallow_copy; + table_find = table_find; + table_equals = table_equals; + encode_room_name = kiwiirc_encode_room_name; + get_kiwiirc_affiliation = kiwiirc_get_affiliation; + get_kiwiirc_env = kiwiirc_get_env; + query_pattern = kiwiirc_query_pattern; +}; diff --git a/jitsi-plugin/jitsi-meet_10888/mod_auth_kiwiirc_token.lua b/jitsi-plugin/jitsi-meet_10888/mod_auth_kiwiirc_token.lua new file mode 100644 index 0000000..1bd1549 --- /dev/null +++ b/jitsi-plugin/jitsi-meet_10888/mod_auth_kiwiirc_token.lua @@ -0,0 +1,248 @@ +-- Token authentication +-- Copyright (C) 2021-present 8x8, Inc. + +local formdecode = require "util.http".formdecode; +local generate_uuid = require "util.uuid".generate; +local new_sasl = require "util.sasl".new; +local sasl = require "util.sasl"; +local token_util = module:require "kiwiirc_token/util".new(module); +local sessions = prosody.full_sessions; + +-- no token configuration +if token_util == nil then + return; +end + +module:depends("jitsi_session"); + +local measure_pre_fetch_fail = module:measure('pre_fetch_fail', 'counter'); +local measure_verify_fail = module:measure('verify_fail', 'counter'); +local measure_success = module:measure('success', 'counter'); +local measure_ban = module:measure('ban', 'counter'); +local measure_post_auth_fail = module:measure('post_auth_fail', 'counter'); + +-- define auth provider +local provider = {}; + +local host = module.host; + +module:hook("pre-resource-unbind", function (e) + local error, session = e.error, e.session; + + prosody.events.fire_event('jitsi-pre-session-unbind', { + jid = session.full_jid, + session = session, + error = error + }); +end, 11); + +-- Extract token from Authorization header or 'token' URL query param when session is created. +-- The Authorization header is used for EXTJWT tokens sent by kiwiirc. The query param +-- allows an override for compatibility. +function init_session(event) + local session, request = event.session, event.request; + local query = request.url.query; + + local token = nil; + + -- extract token from Authorization header + if request.headers["authorization"] then + -- assumes the header value starts with "Bearer " + token = request.headers["authorization"]:sub(8, #request.headers["authorization"]) + end + + -- allow override of token via query parameter + if query ~= nil then + local params = formdecode(query); + + -- The following fields are filled in the session, by extracting them + -- from the query and no validation is being done. + -- After validating auth_token will be cleaned in case of error and few + -- other fields will be extracted from the token and set in the session + + if params and params.token then + token = params.token; + end + end + + -- in either case set auth_token in the session + session.auth_token = token; + session.user_agent_header = request.headers['user_agent']; +end + +module:hook_global("bosh-session", init_session); +module:hook_global("websocket-session", init_session); + +function provider.test_password(username, password) + return nil, "Password based auth not supported"; +end + +function provider.get_password(username) + return nil; +end + +function provider.set_password(username, password) + return nil, "Set password not supported"; +end + +function provider.user_exists(username) + return nil; +end + +function provider.create_user(username, password) + return nil; +end + +function provider.delete_user(username) + return nil; +end + +function first_stage_auth(session) + -- retrieve custom public key from server and save it on the session + local pre_event_result = prosody.events.fire_event("pre-jitsi-authentication-fetch-key", session); + if pre_event_result ~= nil and pre_event_result.res == false then + module:log("warn", + "Error verifying token on pre authentication stage:%s, reason:%s", pre_event_result.error, pre_event_result.reason); + session.auth_token = nil; + measure_pre_fetch_fail(1); + return pre_event_result; + end + + local res, error, reason = token_util:process_and_verify_token(session); + if res == false then + module:log("warn", + "Error verifying token err:%s, reason:%s tenant:%s room:%s user_agent:%s", + error, reason, session.jitsi_web_query_prefix, session.jitsi_web_query_room, + session.user_agent_header); + session.auth_token = nil; + measure_verify_fail(1); + return { res = res, error = error, reason = reason }; + end + + local shouldAllow = prosody.events.fire_event("jitsi-access-ban-check", session); + if shouldAllow == false then + module:log("warn", "user is banned") + measure_ban(1); + return { res = false, error = "not-allowed", reason = "user is banned" }; + end + + return { verify_result = res, custom_username = prosody.events.fire_event("pre-jitsi-authentication", session) }; +end + +function second_stage_auth(session) + local post_event_result = prosody.events.fire_event("post-jitsi-authentication", session); + if post_event_result ~= nil and post_event_result.res == false then + module:log("warn", + "Error verifying token on post authentication stage :%s, reason:%s", post_event_result.error, post_event_result.reason); + session.auth_token = nil; + measure_post_auth_fail(1); + return post_event_result; + end +end + +function provider.get_sasl_handler(session) + + local function get_username_from_token(self, message) + + local s1_result = first_stage_auth(session); + if s1_result.res == false then + return s1_result.res, s1_result.error, s1_result.reason; + end + + if s1_result.custom_username then + self.username = s1_result.custom_username; + elseif session.previd ~= nil then + for _, session1 in pairs(sessions) do + if (session1.resumption_token == session.previd) then + self.username = session1.username; + break; + end + end + else + self.username = message; + end + + local s2_result = second_stage_auth(session); + if s2_result and s2_result.res ~= nil then + return s2_result.res, s2_result.error, s2_result.reason; + end + + measure_success(1); + session._jitsi_auth_done = true; + return s1_result.verify_result; + end + + return new_sasl(host, { anonymous = get_username_from_token }); +end + +module:provides("auth", provider); + +local function anonymous(self, message) + + local username = generate_uuid(); + + -- This calls the handler created in 'provider.get_sasl_handler(session)' + local result, err, msg = self.profile.anonymous(self, username, self.realm); + + if result == true then + if (self.username == nil) then + self.username = username; + end + return "success"; + else + return "failure", err, msg; + end + end + +sasl.registerMechanism("ANONYMOUS", {"anonymous"}, anonymous); + +module:hook_global('c2s-session-updated', function (event) + local session, from_session = event.session, event.from_session; + + if not from_session.auth_token then + return; + end + + -- we care to handle sessions from other hosts (anonymous hosts) + if module.host ~= event.from_session.host then + -- Handle session updates (e.g., when a session is resumed on some anonymous host with a token we need to do all the checks here) + session.auth_token = event.from_session.auth_token; + + local s1_result = first_stage_auth(session); + if s1_result.res == false then + event.session:close(); + return; + end + + local s2_result = second_stage_auth(session); + if s2_result and s2_result.res == false then + event.session:close(); + return; + end + session._jitsi_auth_done = true; + end + + if not session._jitsi_auth_done then + module:log('warn', 'Impossible case hit where session did not pass auth flow'); + event.session:close(); + return; + end + + -- copy all the custom fields we set in the session + session.auth_token = from_session.auth_token; + session.jitsi_meet_context_user = from_session.jitsi_meet_context_user; + session.jitsi_meet_context_group = from_session.jitsi_meet_context_group; + session.jitsi_meet_context_features = from_session.jitsi_meet_context_features; + session.jitsi_meet_context_room = from_session.jitsi_meet_context_room; + session.jitsi_meet_room = from_session.jitsi_meet_room; + session.jitsi_meet_str_tenant = from_session.jitsi_meet_str_tenant; + session.jitsi_meet_domain = from_session.jitsi_meet_domain; + session.jitsi_meet_tenant_mismatch = from_session.jitsi_meet_tenant_mismatch; + session.jitsi_breakout_main_jid = from_session.jitsi_breakout_main_jid; + -- kiwiirc-specific fields + session.jitsi_meet_channel = from_session.jitsi_meet_channel; + session.jitsi_meet_affiliation = from_session.jitsi_meet_affiliation; + session.jitsi_meet_issuer = from_session.jitsi_meet_issuer; + session.jitsi_meet_joined = from_session.jitsi_meet_joined; + session.user_agent_header = from_session.user_agent_header; +end, 1); diff --git a/jitsi-plugin/jitsi-meet_10888/mod_kiwiirc_token_verification.lua b/jitsi-plugin/jitsi-meet_10888/mod_kiwiirc_token_verification.lua new file mode 100644 index 0000000..fed5130 --- /dev/null +++ b/jitsi-plugin/jitsi-meet_10888/mod_kiwiirc_token_verification.lua @@ -0,0 +1,150 @@ +-- Token authentication +-- Copyright (C) 2021-present 8x8, Inc. + +local log = module._log; +local host = module.host; +local st = require "util.stanza"; +local jid_split = require 'util.jid'.split; +local jid_bare = require 'util.jid'.bare; + +local util = module:require 'util'; +local is_admin = util.is_admin; + +local DEBUG = false; + +local measure_success = module:measure('success', 'counter'); +local measure_fail = module:measure('fail', 'counter'); + +local parentHostName = string.gmatch(tostring(host), "%w+.(%w.+)")(); +if parentHostName == nil then + module:log("error", "Failed to start - unable to get parent hostname"); + return; +end + +local parentCtx = module:context(parentHostName); +if parentCtx == nil then + module:log("error", + "Failed to start - unable to get parent context for host: %s", + tostring(parentHostName)); + return; +end + +local token_util = module:require "kiwiirc_token/util".new(parentCtx); + +-- no token configuration +if token_util == nil then + return; +end + +module:log("debug", + "%s - starting MUC token verifier app_id: %s app_secret: %s allow empty: %s", + tostring(host), tostring(token_util.appId), tostring(token_util.appSecret), + tostring(token_util.allowEmptyToken)); + +-- option to disable room modification (sending muc config form) for guest that do not provide token +local require_token_for_moderation; +-- option to allow domains to skip token verification +local allowlist; +local function load_config() + require_token_for_moderation = module:get_option_boolean("token_verification_require_token_for_moderation"); + allowlist = module:get_option_set('token_verification_allowlist', {}); +end +load_config(); + +-- verify user and whether he is allowed to join a room based on the token information +local function verify_user(session, stanza) + if DEBUG then + module:log("debug", "Session token: %s, session room: %s", + tostring(session.auth_token), tostring(session.jitsi_meet_room)); + end + + -- token not required for admin users + local user_jid = stanza.attr.from; + if is_admin(user_jid) then + if DEBUG then module:log("debug", "Token not required from admin user: %s", user_jid); end + return true; + end + + -- token not required for users matching allow list + local user_bare_jid = jid_bare(user_jid); + local _, user_domain = jid_split(user_jid); + + -- allowlist for participants, jigasi (sip & transcriber), jibri (recorder & sip) + if allowlist:contains(user_domain) + or allowlist:contains(user_bare_jid) + + -- allow main participants in visitor mode + or session.type == 's2sin' then + if DEBUG then module:log("debug", "Token not required from user in allow list: %s", user_jid); end + return true; + end + + if DEBUG then module:log("debug", "Will verify token for user: %s, room: %s ", user_jid, stanza.attr.to); end + local res, err, reason = token_util:verify_room(session, stanza.attr.to); + if not res then + if not err and not reason then + reason = 'Room and token mismatched'; + end + + module:log('error', 'Token %s not allowed to join: %s err: %s reason: %s', + tostring(session.auth_token), tostring(stanza.attr.to), err, reason); + + local response = st.error_reply(stanza, 'cancel', 'not-allowed', reason); + if err then + response:tag(err, { xmlns = 'http://jitsi.org/protocol/jitmeet' }); + end + + session.send(response); + return false; -- we need to just return non nil + end + if DEBUG then module:log("debug", "allowed: %s to enter/create room: %s", user_jid, stanza.attr.to); end + return true; +end + +module:hook("muc-room-pre-create", function(event) + local origin, stanza = event.origin, event.stanza; + if DEBUG then module:log("debug", "pre create: %s %s", tostring(origin), tostring(stanza)); end + if not verify_user(origin, stanza) then + measure_fail(1); + return true; -- Returning any value other than nil will halt processing of the event + end + measure_success(1); +end, 99); + +module:hook("muc-occupant-pre-join", function(event) + local origin, room, stanza = event.origin, event.room, event.stanza; + if DEBUG then module:log("debug", "pre join: %s %s", tostring(room), tostring(stanza)); end + if not verify_user(origin, stanza) then + measure_fail(1); + return true; -- Returning any value other than nil will halt processing of the event + end + measure_success(1); +end, 99); + +for event_name, method in pairs { + -- Normal room interactions + ["iq-set/bare/http://jabber.org/protocol/muc#owner:query"] = "handle_owner_query_set_to_room" ; + -- Host room + ["iq-set/host/http://jabber.org/protocol/muc#owner:query"] = "handle_owner_query_set_to_room" ; +} do + module:hook(event_name, function (event) + local session, stanza = event.origin, event.stanza; + + -- if we do not require token we pass it through(default behaviour) + -- or the request is coming from admin (focus) + if not require_token_for_moderation or is_admin(stanza.attr.from) then + return; + end + + -- jitsi_meet_room is set after the token had been verified + if not session.auth_token or not session.jitsi_meet_room then + session.send( + st.error_reply( + stanza, "cancel", "not-allowed", "Room modification disabled for guests")); + return true; + end + + end, -1); -- the default prosody hook is on -2 +end + +module:hook_global('config-reloaded', load_config); diff --git a/jitsi-plugin/jitsi-meet_10888/mod_kiwiirc_xmpp_muc.lua b/jitsi-plugin/jitsi-meet_10888/mod_kiwiirc_xmpp_muc.lua new file mode 100644 index 0000000..a37888e --- /dev/null +++ b/jitsi-plugin/jitsi-meet_10888/mod_kiwiirc_xmpp_muc.lua @@ -0,0 +1,127 @@ +local LOGLEVEL = "debug"; + +local usermanager = require "core.usermanager"; +local is_healthcheck_room = module:require "util".is_healthcheck_room; +local timer = require "util.timer"; +local st = require "util.stanza"; +local it = require "util.iterators"; +local jid = require "util.jid"; + +local kiwi_util = module:require "kiwiirc_util"; +local query_pattern = kiwi_util.query_pattern(); + +module:log(LOGLEVEL, "loaded"); + +local function _is_admin(jid) + local roles = usermanager.get_roles and usermanager.get_roles(jid, module.host); + if roles then + return roles["prosody:operator"] or roles["prosody:admin"]; + end + return usermanager.is_admin(jid, module.host); +end + +module:hook("muc-occupant-pre-join", function(event) + local allow_guest_create = kiwi_util.get_kiwiirc_env("KIWIIRC_ALLOW_GUEST_CREATE"); + + if allow_guest_create then + return; + end + + local room, origin, stanza, session = event.room, event.origin, event.stanza, event.session; + if not event.is_new_room then + local affiliation = origin.jitsi_meet_affiliation; + local participant_count = it.count(room:each_occupant()); + local auth_domain = kiwi_util.get_kiwiirc_env("XMPP_AUTH_DOMAIN") or "auth.meet.jitsi"; + local has_affiliate = false; + + if affiliation ~= nil then + -- User is an affiliate meaning they authed with jwt token + has_affiliate = true; + else + -- User is a guest + for _, o in room:each_occupant() do + -- Check current members for a user who authed with jwt token + if o.bare_jid:match("^focus@" .. auth_domain) == nil and room:get_affiliation(o.jid) ~= nil then + has_affiliate = true; + end + end + end + + if not has_affiliate then + -- User is a guest and no irc users are present + origin.send(st.error_reply(stanza, "cancel", "service-unavailable", "No IRC user is present in conference room")); + return true; + end + end +end) + +module:hook("muc-occupant-pre-join", function(event) + local room, origin, stanza = event.room, event.origin, event.stanza; + local room_node = jid.node(room.jid); + + if not room_node or not room_node:match(query_pattern) then + return; + end + + if _is_admin(stanza.attr.from) then + return; + end + + local user_nick = origin.jitsi_meet_context_user and origin.jitsi_meet_context_user.name; + local iss = origin.jitsi_meet_issuer; + + if not user_nick or not iss then + return; + end + + local peer_nick = stanza:get_child_text('email'); + if not peer_nick or peer_nick == "" then + module:log("warn", "query room join rejected: no peer nick in presence for user '%s'", user_nick); + origin.send(st.error_reply(stanza, "cancel", "not-allowed", "Query conference requires peer identification")); + return true; + end + + local nicks = { user_nick, peer_nick }; + table.sort(nicks); + local sorted_pair = nicks[1] .. "+" .. nicks[2]; + local expected_node = "q-" .. kiwi_util.encode_room_name(iss, sorted_pair); + + if room_node ~= expected_node then + module:log("warn", "query room join rejected: '%s' claimed peer '%s' but room '%s' ~= expected '%s'", + user_nick, peer_nick, room_node, expected_node); + origin.send(st.error_reply(stanza, "cancel", "not-allowed", "Not authorized for this query conference")); + return true; + end +end) + +module:hook("muc-occupant-joined", function (event) + local room, occupant = event.room, event.occupant; + + if is_healthcheck_room(room.jid) or _is_admin(occupant.jid) then + module:log(LOGLEVEL, "skip affiliation, %s", occupant.jid); + return; + end + + if not event.origin.auth_token then + module:log(LOGLEVEL, "skip affiliation, no token"); + return; + end + + local affiliation = event.origin.jitsi_meet_affiliation; + + if affiliation == nil then + return; + end + + local i = 0 + local function setAffiliation() + room:set_affiliation(true, occupant.bare_jid, affiliation) + if i > 3 then return end; + + i = i + 1; + timer.add_task(0.2 * i, setAffiliation); + end + setAffiliation() + + module:log(LOGLEVEL, "replacing affiliation: '%s' with '%s' for %s", room:get_affiliation(occupant.jid), affiliation, occupant.jid) +end) diff --git a/jitsi-plugin/jitsi-meet_9646/kiwiirc_config.lua b/jitsi-plugin/jitsi-meet_9646/kiwiirc_config.lua new file mode 100644 index 0000000..06986f6 --- /dev/null +++ b/jitsi-plugin/jitsi-meet_9646/kiwiirc_config.lua @@ -0,0 +1,10 @@ +local kiwi_config = { + ["KIWIIRC_EVERYONE_MODERATOR"] = false, + ["KIWIIRC_DISABLE_OWNER_MODERATOR"] = false, + ["KIWIIRC_DISABLE_OP_MODERATOR"] = false, + ["KIWIIRC_DISABLE_HALFOP_MODERATOR"] = false, + ["KIWIIRC_DISABLE_QUERY_MODERATOR"] = false, + ["XMPP_AUTH_DOMAIN"] = "auth.meet.jitsi", +} + +return kiwi_config diff --git a/jitsi-plugin/jitsi-meet_9646/kiwiirc_luajwtjitsi.lib.lua b/jitsi-plugin/jitsi-meet_9646/kiwiirc_luajwtjitsi.lib.lua new file mode 100644 index 0000000..14e651e --- /dev/null +++ b/jitsi-plugin/jitsi-meet_9646/kiwiirc_luajwtjitsi.lib.lua @@ -0,0 +1,263 @@ +local cjson_safe = require 'cjson.safe' +local basexx = require 'basexx' +local digest = require 'openssl.digest' +local hmac = require 'openssl.hmac' +local pkey = require 'openssl.pkey' + +-- Generates an RSA signature of the data. +-- @param data The data to be signed. +-- @param key The private signing key in PEM format. +-- @param algo The digest algorithm to user when generating the signature: sha256, sha384, or sha512. +-- @return The signature or nil and an error message. +local function signRS (data, key, algo) + local privkey = pkey.new(key) + if privkey == nil then + return nil, 'Not a private PEM key' + else + local datadigest = digest.new(algo):update(data) + return privkey:sign(datadigest) + end +end + +-- Verifies an RSA signature on the data. +-- @param data The signed data. +-- @param signature The signature to be verified. +-- @param key The public key of the signer. +-- @param algo The digest algorithm to user when generating the signature: sha256, sha384, or sha512. +-- @return True if the signature is valid, false otherwise. Also returns false if the key is invalid. +local function verifyRS (data, signature, key, algo) + local pubkey = pkey.new(key) + if pubkey == nil then + return false + end + + local datadigest = digest.new(algo):update(data) + return pubkey:verify(signature, datadigest) +end + +local alg_sign = { + ['HS256'] = function(data, key) return hmac.new(key, 'sha256'):final(data) end, + ['HS384'] = function(data, key) return hmac.new(key, 'sha384'):final(data) end, + ['HS512'] = function(data, key) return hmac.new(key, 'sha512'):final(data) end, + ['RS256'] = function(data, key) return signRS(data, key, 'sha256') end, + ['RS384'] = function(data, key) return signRS(data, key, 'sha384') end, + ['RS512'] = function(data, key) return signRS(data, key, 'sha512') end +} + +local alg_verify = { + ['HS256'] = function(data, signature, key) return signature == alg_sign['HS256'](data, key) end, + ['HS384'] = function(data, signature, key) return signature == alg_sign['HS384'](data, key) end, + ['HS512'] = function(data, signature, key) return signature == alg_sign['HS512'](data, key) end, + ['RS256'] = function(data, signature, key) return verifyRS(data, signature, key, 'sha256') end, + ['RS384'] = function(data, signature, key) return verifyRS(data, signature, key, 'sha384') end, + ['RS512'] = function(data, signature, key) return verifyRS(data, signature, key, 'sha512') end +} + +-- Splits a token into segments, separated by '.'. +-- @param token The full token to be split. +-- @return A table of segments. +local function split_token(token) + local segments={} + for str in string.gmatch(token, "([^\\.]+)") do + table.insert(segments, str) + end + return segments +end + +-- Parses a JWT token into it's header, body, and signature. +-- @param token The JWT token to be parsed. +-- @return A JSON header and body represented as a table, and a signature. +local function parse_token(token) + local segments=split_token(token) + if #segments ~= 3 then + return nil, nil, nil, "Invalid token" + end + + local header, err = cjson_safe.decode(basexx.from_url64(segments[1])) + if err then + return nil, nil, nil, "Invalid header" + end + + local body, err = cjson_safe.decode(basexx.from_url64(segments[2])) + if err then + return nil, nil, nil, "Invalid body" + end + + local sig, err = basexx.from_url64(segments[3]) + if err then + return nil, nil, nil, "Invalid signature" + end + + return header, body, sig +end + +-- Removes the signature from a JWT token. +-- @param token A JWT token. +-- @return The token without its signature. +local function strip_signature(token) + local segments=split_token(token) + if #segments ~= 3 then + return nil, nil, nil, "Invalid token" + end + + table.remove(segments) + return table.concat(segments, ".") +end + +-- Verifies that a claim is in a list of allowed claims. Allowed claims can be exact values, or the +-- catch all wildcard '*'. +-- @param claim The claim to be verified. +-- @param acceptedClaims A table of accepted claims. +-- @return True if the claim was allowed, false otherwise. +local function verify_claim(claim, acceptedClaims) + for i, accepted in ipairs(acceptedClaims) do + if accepted == '*' then + return true; + end + if claim == accepted then + return true; + end + end + + return false; +end + +local M = {} + +-- Encodes the data into a signed JWT token. +-- @param data The data the put in the body of the JWT token. +-- @param key The key to use for signing the JWT token. +-- @param alg The signature algorithm to use: HS256, HS384, HS512, RS256, RS384, or RS512. +-- @param header Additional values to put in the JWT header. +-- @param The resulting JWT token, or nil and an error message. +function M.encode(data, key, alg, header) + if type(data) ~= 'table' then return nil, "Argument #1 must be table" end + if type(key) ~= 'string' then return nil, "Argument #2 must be string" end + + alg = alg or "HS256" + + if not alg_sign[alg] then + return nil, "Algorithm not supported" + end + + header = header or {} + + header['typ'] = 'JWT' + header['alg'] = alg + + local headerEncoded, err = cjson_safe.encode(header) + if headerEncoded == nil then + return nil, err + end + + local dataEncoded, err = cjson_safe.encode(data) + if dataEncoded == nil then + return nil, err + end + + local segments = { + basexx.to_url64(headerEncoded), + basexx.to_url64(dataEncoded) + } + + local signing_input = table.concat(segments, ".") + local signature, error = alg_sign[alg](signing_input, key) + if signature == nil then + return nil, error + end + + segments[#segments+1] = basexx.to_url64(signature) + + return table.concat(segments, ".") +end + +-- Verify that the token is valid, and if it is return the decoded JSON payload data. +-- @param token The token to verify. +-- @param expectedAlgo The signature algorithm the caller expects the token to be signed with: +-- HS256, HS384, HS512, RS256, RS384, or RS512. +-- @param key The verification key used for the signature. +-- @param acceptedIssuers Optional table of accepted issuers. If not nil, the 'iss' claim will be +-- checked against this list. +-- @param acceptedAudiences Optional table of accepted audiences. If not nil, the 'aud' claim will +-- be checked against this list. +-- @return A table representing the JSON body of the token, or nil and an error message. +function M.verify(token, expectedAlgo, key, acceptedIssuers, acceptedAudiences) + if type(token) ~= 'string' then return nil, "token argument must be string" end + if type(expectedAlgo) ~= 'string' then return nil, "algorithm argument must be string" end + if type(key) ~= 'string' then return nil, "key argument must be string" end + if acceptedIssuers ~= nil and type(acceptedIssuers) ~= 'table' then + return nil, "acceptedIssuers argument must be table" + end + if acceptedAudiences ~= nil and type(acceptedAudiences) ~= 'table' then + return nil, "acceptedAudiences argument must be table" + end + + if not alg_verify[expectedAlgo] then + return nil, "Algorithm not supported" + end + + local header, body, sig, err = parse_token(token) + if err ~= nil then + return nil, err + end + + -- Validate header + if not header.typ or header.typ ~= "JWT" then + return nil, "Invalid typ" + end + + if not header.alg or header.alg ~= expectedAlgo then + return nil, "Invalid or incorrect alg" + end + + -- Validate signature + if not alg_verify[expectedAlgo](strip_signature(token), sig, key) then + return nil, 'Invalid signature' + end + + -- Validate body + if body.exp and type(body.exp) ~= "number" then + return nil, "exp must be number" + end + + if body.nbf and type(body.nbf) ~= "number" then + return nil, "nbf must be number" + end + + + if body.exp and os.time() >= body.exp then + return nil, "Token expired" + end + + if body.nbf and os.time() < body.nbf then + return nil, "Not acceptable by nbf" + end + + if acceptedIssuers ~= nil then + local issClaim = body.iss; + if issClaim == nil then + return nil, "'iss' claim is missing"; + end + if not verify_claim(issClaim, acceptedIssuers) then + return nil, "invalid 'iss' claim"; + end + end + + if acceptedAudiences ~= nil then + local audClaim = body.aud; + if audClaim == nil then + -- Missing aud is only acceptable when the wildcard '*' is in the list, + -- which means any (or no) audience is permitted. InspIRCd's EXTJWT + -- module does not emit an aud claim, so we must tolerate its absence. + if not verify_claim('*', acceptedAudiences) then + return nil, "'aud' claim is missing"; + end + elseif not verify_claim(audClaim, acceptedAudiences) then + return nil, "invalid 'aud' claim"; + end + end + + return body +end + +return M diff --git a/jitsi-plugin/jitsi-meet_9646/kiwiirc_token/util.lib.lua b/jitsi-plugin/jitsi-meet_9646/kiwiirc_token/util.lib.lua new file mode 100644 index 0000000..04cca7b --- /dev/null +++ b/jitsi-plugin/jitsi-meet_9646/kiwiirc_token/util.lib.lua @@ -0,0 +1,644 @@ +-- Token authentication +-- Copyright (C) 2021-present 8x8, Inc. + +local basexx = require "basexx"; +local have_async, async = pcall(require, "util.async"); +local hex = require "util.hex"; +local jwt = module:require "kiwiirc_luajwtjitsi"; +local jid = require "util.jid"; +local json_safe = require "cjson.safe"; +local path = require "util.paths"; +local sha256 = require "util.hashes".sha256; +local main_util = module:require "util"; +local ends_with = main_util.ends_with; +local http_get_with_retry = main_util.http_get_with_retry; +local extract_subdomain = main_util.extract_subdomain; +local starts_with = main_util.starts_with; +local table_shallow_copy = main_util.table_shallow_copy; +local cjson_safe = require 'cjson.safe' +local timer = require "util.timer"; +local async = require "util.async"; +local inspect = require 'inspect'; + +local kiwi_util = module:require "kiwiirc_util"; +local query_pattern = kiwi_util.query_pattern(); + +local nr_retries = 3; +local ssl = require "ssl"; + +-- TODO: Figure out a less arbitrary default cache size. +local cacheSize = module:get_option_number("jwt_pubkey_cache_size", 128); + +-- the cache for generated asap jwt tokens +local jwtKeyCache = require 'util.cache'.new(cacheSize); + +local ASAPTTL_THRESHOLD = module:get_option_number('asap_ttl_threshold', 600); +local ASAPTTL = module:get_option_number('asap_ttl', 3600); +local ASAPIssuer = module:get_option_string('asap_issuer', 'jitsi'); +local ASAPAudience = module:get_option_string('asap_audience', 'jitsi'); +local ASAPKeyId = module:get_option_string('asap_key_id', 'jitsi'); +local ASAPKeyPath = module:get_option_string('asap_key_path', '/etc/prosody/certs/asap.key'); + +local ASAPKey; +local f = io.open(ASAPKeyPath, 'r'); + +if f then + ASAPKey = f:read('*all'); + f:close(); +end + +-- Replay protection: track SHA-256 hashes of tokens that have been authenticated +-- until their expiry, so each token can only establish one session. +-- Keys: hex SHA-256 of the raw token string. Values: expiry (unix timestamp). +local usedTokens = {}; +local TOKEN_SWEEP_INTERVAL = 60; -- seconds between sweeps + +local function sweep_used_tokens() + local now = os.time(); + for hash, exp in pairs(usedTokens) do + if now >= exp then + usedTokens[hash] = nil; + end + end + return TOKEN_SWEEP_INTERVAL; +end +timer.add_task(TOKEN_SWEEP_INTERVAL, sweep_used_tokens); + +local Util = {} +Util.__index = Util + +--- Constructs util class for token verifications. +-- Constructor that uses the passed module to extract all the +-- needed configurations. +-- If configuration is missing returns nil +-- @param module the module in which options to check for configs. +-- @return the new instance or nil +function Util.new(module) + local self = setmetatable({}, Util) + + self.appId = module:get_option_string("app_id"); + self.appSecret = module:get_option_string("app_secret"); + self.asapKeyServer = module:get_option_string("asap_key_server"); + -- A URL that will return json file with a mapping between kids and public keys + -- If the response Cache-Control header we will respect it and refresh it + self.cacheKeysUrl = module:get_option_string("cache_keys_url"); + self.signatureAlgorithm = module:get_option_string("signature_algorithm"); + self.allowEmptyToken = module:get_option_boolean("allow_empty_token"); + + self.cache = require"util.cache".new(cacheSize); + + --[[ + Multidomain can be supported in some deployments. In these deployments + there is a virtual conference muc, which address contains the subdomain + to use. Those deployments are accessible + by URL https://domain/subdomain. + Then the address of the room will be: + roomName@conference.subdomain.domain. This is like a virtual address + where there is only one muc configured by default with address: + conference.domain and the actual presentation of the room in that muc + component is [subdomain]roomName@conference.domain. + These setups relay on configuration 'muc_domain_base' which holds + the main domain and we use it to subtract subdomains from the + virtual addresses. + The following confgurations are for multidomain setups and domain name + verification: + --]] + + -- optional parameter for custom muc component prefix, + -- defaults to "conference" + self.muc_domain_prefix = module:get_option_string( + "muc_mapper_domain_prefix", "conference"); + -- domain base, which is the main domain used in the deployment, + -- the main VirtualHost for the deployment + self.muc_domain_base = module:get_option_string("muc_mapper_domain_base"); + -- The "real" MUC domain that we are proxying to + if self.muc_domain_base then + self.muc_domain = module:get_option_string( + "muc_mapper_domain", + self.muc_domain_prefix.."."..self.muc_domain_base); + end + -- whether domain name verification is enabled, by default it is enabled + -- when disabled checking domain name and tenant if available will be skipped, we will check only room name. + self.enableDomainVerification = module:get_option_boolean('enable_domain_verification', true); + + if self.allowEmptyToken == true then + module:log("warn", "WARNING - empty tokens allowed"); + end + + if self.appId == nil then + module:log("error", "'app_id' must not be empty"); + return nil; + end + + -- Optional pre-configured URL for an external token verification endpoint. + -- When set alongside app_secret, the token must pass both verifications. + -- Can be set via the JWT_VFY_URL environment variable or the jwt_vfy_url Prosody option. + self.jwtVfyUrl = os.getenv('JWT_VFY_URL') or module:get_option_string('jwt_vfy_url'); + + if self.appSecret == nil and self.asapKeyServer == nil and self.cacheKeysUrl == nil + and self.jwtVfyUrl == nil then + module:log("error", "'app_secret', 'asap_key_server', 'cache_keys_url' or 'jwt_vfy_url' must be specified"); + return nil; + end + + -- Set defaults for signature algorithm + if self.signatureAlgorithm == nil then + if self.asapKeyServer ~= nil then + self.signatureAlgorithm = "RS256" + elseif self.appSecret ~= nil then + self.signatureAlgorithm = "HS256" + elseif self.jwtVfyUrl ~= nil then + -- jwt_vfy_url-only mode: EXTJWT uses HS256, but allow override via signature_algorithm + self.signatureAlgorithm = "HS256" + end + end + + --array of accepted issuers: by default only includes our appId + self.acceptedIssuers = module:get_option_array('asap_accepted_issuers',{self.appId}) + + --array of accepted audiences: by default only includes our appId + self.acceptedAudiences = module:get_option_array('asap_accepted_audiences',{'*'}) + + self.requireRoomClaim = module:get_option_boolean('asap_require_room_claim', true); + + if self.asapKeyServer and not have_async then + module:log("error", "requires a version of Prosody with util.async"); + return nil; + end + + if self.cacheKeysUrl then + self.cachedKeys = {}; + local update_keys_cache; + update_keys_cache = async.runner(function (name) + local content, code, cache_for; + content, code, cache_for = http_get_with_retry(self.cacheKeysUrl, nr_retries); + if content ~= nil then + local keys_to_delete = table_shallow_copy(self.cachedKeys); + -- Let's convert any certificate to public key + for k, v in pairs(cjson_safe.decode(content)) do + if starts_with(v, '-----BEGIN CERTIFICATE-----') then + self.cachedKeys[k] = ssl.loadcertificate(v):pubkey(); + -- do not clean this key if it already exists + keys_to_delete[k] = nil; + end + end + -- let's schedule the clean in an hour and a half, current tokens will be valid for an hour + timer.add_task(90*60, function () + for k, _ in pairs(keys_to_delete) do + self.cachedKeys[k] = nil; + end + end); + + if cache_for then + cache_for = tonumber(cache_for); + -- let's schedule new update 60 seconds before the cache expiring + if cache_for > 60 then + cache_for = cache_for - 60; + end + timer.add_task(cache_for, function () + update_keys_cache:run("update_keys_cache"); + end); + else + -- no cache header let's consider updating in 6hours + timer.add_task(6*60*60, function () + update_keys_cache:run("update_keys_cache"); + end); + end + else + module:log('warn', 'Failed to retrieve cached public keys code:%s', code); + -- failed let's retry in 30 seconds + timer.add_task(30, function () + update_keys_cache:run("update_keys_cache"); + end); + end + end); + update_keys_cache:run("update_keys_cache"); + end + + return self +end + +function Util:set_asap_key_server(asapKeyServer) + self.asapKeyServer = asapKeyServer; +end + +function Util:set_asap_accepted_issuers(acceptedIssuers) + self.acceptedIssuers = acceptedIssuers; +end + +function Util:set_asap_accepted_audiences(acceptedAudiences) + self.acceptedAudiences = acceptedAudiences; +end + +function Util:set_asap_require_room_claim(checkRoom) + self.requireRoomClaim = checkRoom; +end + +function Util:clear_asap_cache() + self.cache = require"util.cache".new(cacheSize); +end + +--- Returns the public key by keyID +-- @param keyId the key ID to request +-- @return the public key (the content of requested resource) or nil +function Util:get_public_key(keyId) + local content = self.cache:get(keyId); + local code; + if content == nil then + -- If the key is not found in the cache. + -- module:log("debug", "Cache miss for key: %s", keyId); + local keyurl = path.join(self.asapKeyServer, hex.to(sha256(keyId))..'.pem'); + -- module:log("debug", "Fetching public key from: %s", keyurl); + content, code = http_get_with_retry(keyurl, nr_retries); + if content ~= nil then + self.cache:set(keyId, content); + else + if code == nil then + -- this is timout after nr_retries retries + module:log('warn', 'Timeout retrieving %s from %s', keyId, keyurl); + end + end + return content; + else + -- If the key is in the cache, use it. + -- module:log("debug", "Cache hit for key: %s", keyId); + return content; + end +end + +--- Verifies token and process needed values to be stored in the session. +-- Token is obtained from session.auth_token. +-- Stores in session the following values: +-- session.jitsi_meet_room - the room name value from the token +-- session.jitsi_meet_domain - the domain name value from the token +-- session.jitsi_meet_context_user - the user details from the token +-- session.jitsi_meet_context_room - the room details from the token +-- session.jitsi_meet_context_group - the group value from the token +-- session.jitsi_meet_context_features - the features value from the token +-- @param session the current session +-- @return false and error +function Util:process_and_verify_token(session) + if session.auth_token == nil then + if self.allowEmptyToken then + return true; + else + return false, "not-allowed", "token required"; + end + end + + -- Replay protection: reject tokens that have already been used. + -- Checked before expensive cryptographic verification. + local token_hash = hex.to(sha256(session.auth_token)); + if usedTokens[token_hash] ~= nil then + module:log("warn", "Replay detected: token hash %s already used", token_hash:sub(1, 16)); + return false, "not-allowed", "token has already been used"; + end + + local key; + local skip_sig_verify = false; + if session.public_key then + -- We're using an public key stored in the session + -- module:log("debug","Public key was found on the session"); + key = session.public_key; + elseif (self.asapKeyServer or self.cacheKeysUrl) and session.auth_token ~= nil then + -- We're fetching an public key from an ASAP server + local dotFirst = session.auth_token:find("%."); + if not dotFirst then return false, "not-allowed", "Invalid token" end + local headerPartEncoded = basexx.from_url64(session.auth_token:sub(1,dotFirst-1)); + if not headerPartEncoded then return false, "not-allowed", "Invalid token" end + local header, err = json_safe.decode(headerPartEncoded); + if err then + return false, "not-allowed", "bad token format"; + end + local kid = header["kid"]; + if kid == nil then + return false, "not-allowed", "'kid' claim is missing"; + end + local alg = header["alg"]; + if alg == nil then + return false, "not-allowed", "'alg' claim is missing"; + end + if alg.sub(alg,1,2) ~= "RS" then + return false, "not-allowed", "'kid' claim only support with RS family"; + end + + if self.cachedKeys and self.cachedKeys[kid] then + key = self.cachedKeys[kid]; + else + key = self:get_public_key(kid); + end + + if key == nil then + return false, "not-allowed", "could not obtain public key"; + end + elseif self.appSecret ~= nil then + -- We're using a symmetric secret + key = self.appSecret + elseif self.jwtVfyUrl ~= nil then + -- jwtVfyUrl-only mode: skip local signature verification; the vfy URL + -- endpoint is the sole cryptographic authority for this token + skip_sig_verify = true; + end + + if not skip_sig_verify and key == nil then + return false, "not-allowed", "signature verification key is missing"; + end + + -- verify the whole token (or decode claims without signature verification) + local claims, msg; + if skip_sig_verify then + -- decode payload without verifying signature + local dotFirst = session.auth_token:find("%."); + if not dotFirst then return false, "not-allowed", "Invalid token" end + local dotSecond = session.auth_token:find("%.", dotFirst + 1); + if not dotSecond then return false, "not-allowed", "Invalid token" end + local payloadDecoded = basexx.from_url64(session.auth_token:sub(dotFirst + 1, dotSecond - 1)); + if not payloadDecoded then return false, "not-allowed", "Invalid token" end + claims, msg = json_safe.decode(payloadDecoded); + if not claims then + return false, "not-allowed", msg or "bad token format"; + end + else + claims, msg = jwt.verify( + session.auth_token, + self.signatureAlgorithm, + key, + self.acceptedIssuers, + self.acceptedAudiences + ) + end + if claims ~= nil then + -- If a verification URL is configured, the token must also be accepted by it. + -- This is an additional check on top of (or instead of) the shared secret. + if self.jwtVfyUrl then + local _, vfy_code = http_get_with_retry(self.jwtVfyUrl, nr_retries, session.auth_token); + if vfy_code ~= 200 and vfy_code ~= 204 then + return false, "not-allowed", "token rejected by verification endpoint"; + end + end + + -- Register token as used. Stored until its expiry so replayed tokens are + -- rejected even if the signature would otherwise still be valid. + local exp = claims["exp"]; + usedTokens[token_hash] = exp or (os.time() + 3600); + + if self.requireRoomClaim then + if claims["channel"] ~= nil then + claims["room"] = kiwi_util.encode_room_name(claims["iss"], claims["channel"]) + module:log("debug", "room encoded from '%s/%s' to '%s'", claims["iss"], claims["channel"], claims["room"]); + else + claims["room"] = "*"; + module:log("debug", "room maybe query"); + end + end + + local joined = claims["joined"]; + if claims["channel"] ~= nil and (joined == nil or joined <= 0) then + return false, "not-allowed", "user is not member of the channel"; + end + + -- Binds room name to the session which is later checked on MUC join + session.jitsi_meet_channel = claims["channel"]; + session.jitsi_meet_room = claims["room"]; + -- Binds domain name to the session + session.jitsi_meet_domain = "meet.jitsi"; + + session.jitsi_meet_joined = claims["joined"]; + session.jitsi_meet_issuer = claims["iss"]; + + session.jitsi_meet_affiliation = kiwi_util.get_kiwiirc_affiliation(claims); + module:log("debug", "token affiliation: '%s' for %s", session.jitsi_meet_affiliation, claims.sub); + + claims["context"] = {}; + claims["context"]["user"] = {}; + claims["context"]["user"]["name"] = claims["sub"]; + + -- Binds the user details to the session if available + if claims["context"] ~= nil then + session.jitsi_meet_str_tenant = claims["context"]["tenant"]; + + if claims["context"]["user"] ~= nil then + session.jitsi_meet_context_user = claims["context"]["user"]; + end + + if claims["context"]["group"] ~= nil then + -- Binds any group details to the session + session.jitsi_meet_context_group = claims["context"]["group"]; + end + + if claims["context"]["features"] ~= nil then + -- Binds any features details to the session + session.jitsi_meet_context_features = claims["context"]["features"]; + end + if claims["context"]["room"] ~= nil then + session.jitsi_meet_context_room = claims["context"]["room"] + end + elseif claims["user_id"] then + session.jitsi_meet_context_user = {}; + session.jitsi_meet_context_user.id = claims["user_id"]; + end + + -- fire event that token has been verified and pass the session and the decoded token + prosody.events.fire_event('jitsi-authentication-token-verified', { + session = session; + claims = claims; + }); + + if session.contextRequired and claims["context"] == nil then + return false, "not-allowed", 'jwt missing required context claim'; + end + + return true; + else + return false, "not-allowed", msg; + end +end + +--- Verifies room name and domain if necessary. +-- Checks configs and if necessary checks the room name extracted from +-- room_address against the one saved in the session when token was verified. +-- Also verifies domain name from token against the domain in the room_address, +-- if enableDomainVerification is enabled. +-- @param session the current session +-- @param room_address the whole room address as received +-- @return returns true in case room was verified or there is no need to verify +-- it and returns false in case verification was processed +-- and was not successful +function Util:verify_room(session, room_address) + if self.allowEmptyToken and session.auth_token == nil then + --module:log("debug", "Skipped room token verification - empty tokens are allowed"); + return true; + end + + -- extract room name using all chars, except the not allowed ones + local room,_,_ = jid.split(room_address); + if room == nil then + module:log('error', 'Unable to get name of the MUC room ? to: %s', room_address); + return false, 'invalid-room-address', 'Room address is invalid'; + end + + module:log("debug", "verify_room: '%s'", room) + + -- kiwiirc: channel conferences verify the encoded room name directly; + -- query conferences (no channel) must match the query room name pattern + if session.jitsi_meet_channel ~= nil then + if session.jitsi_meet_room ~= room then + module:log("warn", "verify_room: Not matching '%s' ~= '%s'", session.jitsi_meet_room, room); + return false, 'room-mismatch', 'Room does not match the room from token'; + end + elseif not room:match(query_pattern) then + module:log("warn", "verify_room: Not a query"); + return false, 'room-mismatch', 'Room does not match the room from token'; + end + + local auth_room = session.jitsi_meet_room; + if auth_room then + if type(auth_room) == 'string' then + auth_room = string.lower(auth_room); + else + module:log('warn', 'session.jitsi_meet_room not string: %s', inspect(auth_room)); + end + end + + if not self.enableDomainVerification then + -- if auth_room is missing, this means user is anonymous (no token for its domain) we let it through + if auth_room and (room ~= auth_room and not ends_with(room, ']'..auth_room)) and auth_room ~= '*' then + return false, 'room-mismatch', 'Room does not match the room from token'; + end + + return true; + end + + local room_address_to_verify = jid.bare(room_address); + local room_node = jid.node(room_address); + -- parses bare room address, for multidomain expected format is: + -- [subdomain]roomName@conference.domain + local target_subdomain, target_room = extract_subdomain(room_node); + + -- if we have '*' as room name in token, this means all rooms are allowed + -- so we will use the actual name of the room when constructing strings + -- to verify subdomains and domains to simplify checks + local room_to_check; + if auth_room == '*' then + -- authorized for accessing any room assign to room_to_check the actual + -- room name + if target_room ~= nil then + -- we are in multidomain mode and we were able to extract room name + room_to_check = target_room; + else + -- no target_room, room_address_to_verify does not contain subdomain + -- so we get just the node which is the room name + room_to_check = room_node; + end + else + -- no wildcard, so check room against authorized room from the token + if session.jitsi_meet_context_room and (session.jitsi_meet_context_room["regex"] == true or session.jitsi_meet_context_room["regex"] == "true") then + if target_room ~= nil then + -- room with subdomain + room_to_check = target_room:match(auth_room); + else + room_to_check = room_node:match(auth_room); + end + else + -- not a regex + room_to_check = auth_room; + end + if not room_to_check then + if not self.requireRoomClaim then + -- if we do not require to have the room claim, and it is missing + -- there is no point of continue and verifying the roomName and the tenant + return true; + end + + return false, 'room-name-does-not-match', 'Room name cannot be matched to the one from token.'; + end + end + + if session.jitsi_meet_str_tenant + and string.lower(session.jitsi_meet_str_tenant) ~= session.jitsi_web_query_prefix then + session.jitsi_meet_tenant_mismatch = true; + + module:log('warn', 'Tenant differs for user:%s group:%s url_tenant:%s token_tenant:%s', + session.jitsi_meet_context_user and session.jitsi_meet_context_user.id or '', + session.jitsi_meet_context_group, + session.jitsi_web_query_prefix, session.jitsi_meet_str_tenant); + end + + local auth_domain = string.lower(session.jitsi_meet_domain); + local subdomain_to_check; + if target_subdomain then + if auth_domain == '*' then + -- check for wildcard in JWT claim, allow access if found + subdomain_to_check = target_subdomain; + else + -- no wildcard in JWT claim, so check subdomain against sub in token + subdomain_to_check = auth_domain; + end + -- from this point we depend on muc_domain_base, + -- deny access if option is missing + if not self.muc_domain_base then + module:log("warn", "No 'muc_domain_base' option set, denying access!"); + return false, 'server-missing-config', 'Misconfiguration of server'; + end + + return room_address_to_verify == jid.join( + "["..subdomain_to_check.."]"..room_to_check, self.muc_domain); + else + if auth_domain == '*' then + -- check for wildcard in JWT claim, allow access if found + subdomain_to_check = self.muc_domain; + else + -- no wildcard in JWT claim, so check subdomain against sub in token + subdomain_to_check = self.muc_domain_prefix.."."..auth_domain; + end + -- we do not have a domain part (multidomain is not enabled) + -- verify with info from the token + return room_address_to_verify == jid.join(room_to_check, subdomain_to_check); + end +end + +function Util:generateAsapToken(audience) + if not ASAPKey then + module:log('warn', 'No ASAP Key read, asap key generation is disabled'); + return '' + end + + audience = audience or ASAPAudience + local t = os.time() + local err + local exp_key = 'asap_exp.'..audience + local token_key = 'asap_token.'..audience + local exp = jwtKeyCache:get(exp_key) + local token = jwtKeyCache:get(token_key) + + --if we find a token and it isn't too far from expiry, then use it + if token ~= nil and exp ~= nil then + exp = tonumber(exp) + if (exp - t) > ASAPTTL_THRESHOLD then + return token + end + end + + --expiry is the current time plus TTL + exp = t + ASAPTTL + local payload = { + iss = ASAPIssuer, + aud = audience, + nbf = t, + exp = exp, + } + + -- encode + local alg = 'RS256' + token, err = jwt.encode(payload, ASAPKey, alg, { kid = ASAPKeyId }) + if not err then + token = 'Bearer '..token + jwtKeyCache:set(exp_key, exp) + jwtKeyCache:set(token_key, token) + return token + else + return '' + end +end + +return Util; diff --git a/jitsi-plugin/jitsi-meet_9646/kiwiirc_util.lib.lua b/jitsi-plugin/jitsi-meet_9646/kiwiirc_util.lib.lua new file mode 100644 index 0000000..f561402 --- /dev/null +++ b/jitsi-plugin/jitsi-meet_9646/kiwiirc_util.lib.lua @@ -0,0 +1,689 @@ +local jid = require "util.jid"; +local sha256 = require "util.hashes".sha256; +local timer = require "util.timer"; +local http = require "net.http"; +local cache = require "util.cache"; +local usermanager = require 'core.usermanager'; + +local http_timeout = 30; +local have_async, async = pcall(require, "util.async"); +local http_headers = { + ["User-Agent"] = "Prosody ("..prosody.version.."; "..prosody.platform..")" +}; + +local muc_domain_prefix = module:get_option_string("muc_mapper_domain_prefix", "conference"); + +-- defaults to module.host, the module that uses the utility +local muc_domain_base = module:get_option_string("muc_mapper_domain_base", module.host); + +-- The "real" MUC domain that we are proxying to +local muc_domain = module:get_option_string("muc_mapper_domain", muc_domain_prefix.."."..muc_domain_base); + +local escaped_muc_domain_base = muc_domain_base:gsub("%p", "%%%1"); +local escaped_muc_domain_prefix = muc_domain_prefix:gsub("%p", "%%%1"); +-- The pattern used to extract the target subdomain +-- (e.g. extract 'foo' from 'conference.foo.example.com') +local target_subdomain_pattern = "^"..escaped_muc_domain_prefix..".([^%.]+)%."..escaped_muc_domain_base; + +-- table to store all incoming iqs without roomname in it, like discoinfo to the muc component +local roomless_iqs = {}; + +local OUTBOUND_SIP_JIBRI_PREFIXES = { 'outbound-sip-jibri@', 'sipjibriouta@', 'sipjibrioutb@' }; +local INBOUND_SIP_JIBRI_PREFIXES = { 'inbound-sip-jibri@', 'sipjibriina@', 'sipjibriina@' }; + +local split_subdomain_cache = cache.new(1000); +local extract_subdomain_cache = cache.new(1000); +local internal_room_jid_cache = cache.new(1000); + +local moderated_subdomains = module:get_option_set("allowners_moderated_subdomains", {}) +local moderated_rooms = module:get_option_set("allowners_moderated_rooms", {}) + +-- Utility function to split room JID to include room name and subdomain +-- (e.g. from room1@conference.foo.example.com/res returns (room1, example.com, res, foo)) +local function room_jid_split_subdomain(room_jid) + local ret = split_subdomain_cache:get(room_jid); + if ret then + return ret.node, ret.host, ret.resource, ret.subdomain; + end + + local node, host, resource = jid.split(room_jid); + + local target_subdomain = host and host:match(target_subdomain_pattern); + local cache_value = {node=node, host=host, resource=resource, subdomain=target_subdomain}; + split_subdomain_cache:set(room_jid, cache_value); + return node, host, resource, target_subdomain; +end + +--- Utility function to check and convert a room JID from +--- virtual room1@conference.foo.example.com to real [foo]room1@conference.example.com +-- @param room_jid the room jid to match and rewrite if needed +-- @param stanza the stanza +-- @return returns room jid [foo]room1@conference.example.com when it has subdomain +-- otherwise room1@conference.example.com(the room_jid value untouched) +local function room_jid_match_rewrite(room_jid, stanza) + local node, _, resource, target_subdomain = room_jid_split_subdomain(room_jid); + if not target_subdomain then + -- module:log("debug", "No need to rewrite out 'to' %s", room_jid); + return room_jid; + end + -- Ok, rewrite room_jid address to new format + local new_node, new_host, new_resource; + if node then + new_node, new_host, new_resource = "["..target_subdomain.."]"..node, muc_domain, resource; + else + -- module:log("debug", "No room name provided so rewriting only host 'to' %s", room_jid); + new_host, new_resource = muc_domain, resource; + + if (stanza and stanza.attr and stanza.attr.id) then + roomless_iqs[stanza.attr.id] = stanza.attr.to; + end + end + + return jid.join(new_node, new_host, new_resource); +end + +-- Utility function to check and convert a room JID from real [foo]room1@muc.example.com to virtual room1@muc.foo.example.com +local function internal_room_jid_match_rewrite(room_jid, stanza) + -- first check for roomless_iqs + if (stanza and stanza.attr and stanza.attr.id and roomless_iqs[stanza.attr.id]) then + local result = roomless_iqs[stanza.attr.id]; + roomless_iqs[stanza.attr.id] = nil; + return result; + end + + local ret = internal_room_jid_cache:get(room_jid); + if ret then + return ret; + end + + local node, host, resource = jid.split(room_jid); + if host ~= muc_domain or not node then + -- module:log("debug", "No need to rewrite %s (not from the MUC host)", room_jid); + internal_room_jid_cache:set(room_jid, room_jid); + return room_jid; + end + + local target_subdomain, target_node = extract_subdomain(node); + if not (target_node and target_subdomain) then + -- module:log("debug", "Not rewriting... unexpected node format: %s", node); + internal_room_jid_cache:set(room_jid, room_jid); + return room_jid; + end + + -- Ok, rewrite room_jid address to pretty format + ret = jid.join(target_node, muc_domain_prefix..".".. target_subdomain.."."..muc_domain_base, resource); + internal_room_jid_cache:set(room_jid, ret); + return ret; +end + +--- Finds and returns room by its jid +-- @param room_jid the room jid to search in the muc component +-- @return returns room if found or nil +function get_room_from_jid(room_jid) + local _, host = jid.split(room_jid); + local component = hosts[host]; + if component then + local muc = component.modules.muc + if muc and rawget(muc,"rooms") then + -- We're running 0.9.x or 0.10 (old MUC API) + return muc.rooms[room_jid]; + elseif muc and rawget(muc,"get_room_from_jid") then + -- We're running >0.10 (new MUC API) + return muc.get_room_from_jid(room_jid); + else + return + end + end +end + +-- Returns the room if available, work and in multidomain mode +-- @param room_name the name of the room +-- @param group name of the group (optional) +-- @return returns room if found or nil +function get_room_by_name_and_subdomain(room_name, subdomain) + local room_address; + + -- if there is a subdomain we are in multidomain mode and that subdomain is not our main host + if subdomain and subdomain ~= "" and subdomain ~= muc_domain_base then + room_address = jid.join("["..subdomain.."]"..room_name, muc_domain); + else + room_address = jid.join(room_name, muc_domain); + end + + return get_room_from_jid(room_address); +end + +function async_handler_wrapper(event, handler) + if not have_async then + module:log("error", "requires a version of Prosody with util.async"); + return nil; + end + + local runner = async.runner; + + -- Grab a local response so that we can send the http response when + -- the handler is done. + local response = event.response; + local async_func = runner( + function (event) + local result = handler(event) + + -- If there is a status code in the result from the + -- wrapped handler then add it to the response. + if tonumber(result.status_code) ~= nil then + response.status_code = result.status_code + end + + -- If there are headers in the result from the + -- wrapped handler then add them to the response. + if result.headers ~= nil then + response.headers = result.headers + end + + -- Send the response to the waiting http client with + -- or without the body from the wrapped handler. + if result.body ~= nil then + response:send(result.body) + else + response:send(); + end + end + ) + async_func:run(event) + -- return true to keep the client http connection open. + return true; +end + +--- Updates presence stanza, by adding identity node +-- @param stanza the presence stanza +-- @param user the user to which presence we are updating identity +-- @param group the group of the user to which presence we are updating identity +-- @param creator_user the user who created the user which presence we +-- are updating (this is the poltergeist case, where a user creates +-- a poltergeist), optional. +-- @param creator_group the group of the user who created the user which +-- presence we are updating (this is the poltergeist case, where a user creates +-- a poltergeist), optional. +function update_presence_identity( + stanza, user, group, creator_user, creator_group) + + -- First remove any 'identity' element if it already + -- exists, so it cannot be spoofed by a client + stanza:maptags( + function(tag) + for k, v in pairs(tag) do + if k == "name" and v == "identity" then + return nil + end + end + return tag + end + ) + + stanza:tag("identity"):tag("user"); + for k, v in pairs(user) do + v = tostring(v) + stanza:tag(k):text(v):up(); + end + stanza:up(); + + -- Add the group information if it is present + if group then + stanza:tag("group"):text(group):up(); + end + + -- Add the creator user information if it is present + if creator_user then + stanza:tag("creator_user"); + for k, v in pairs(creator_user) do + stanza:tag(k):text(v):up(); + end + stanza:up(); + + -- Add the creator group information if it is present + if creator_group then + stanza:tag("creator_group"):text(creator_group):up(); + end + end + + stanza:up(); -- Close identity tag +end + +-- Utility function to check whether feature is present and enabled. Allow +-- a feature if there are features present in the session(coming from +-- the token) and the value of the feature is true. +-- If features is not present in the token we skip feature detection and allow +-- everything. +function is_feature_allowed(features, ft) + if (features == nil or features[ft] == "true" or features[ft] == true) then + return true; + else + return false; + end +end + +--- Extracts the subdomain and room name from internal jid node [foo]room1 +-- @return subdomain(optional, if extracted or nil), the room name +function extract_subdomain(room_node) + local ret = extract_subdomain_cache:get(room_node); + if ret then + return ret.subdomain, ret.room; + end + + local subdomain, room_name = room_node:match("^%[([^%]]+)%](.+)$"); + local cache_value = {subdomain=subdomain, room=room_name}; + extract_subdomain_cache:set(room_node, cache_value); + return subdomain, room_name; +end + +function starts_with(str, start) + if not str then + return false; + end + return str:sub(1, #start) == start +end + +function starts_with_one_of(str, prefixes) + if not str then + return false; + end + for i=1,#prefixes do + if starts_with(str, prefixes[i]) then + return prefixes[i]; + end + end + return false +end + + +function ends_with(str, ending) + return ending == "" or str:sub(-#ending) == ending +end + +-- healthcheck rooms in jicofo starts with a string '__jicofo-health-check' +function is_healthcheck_room(room_jid) + return starts_with(room_jid, "__jicofo-health-check"); +end + +--- Utility function to make an http get request and +--- retry @param retry number of times +-- @param url endpoint to be called +-- @param retry nr of retries, if retry is +-- @param auth_token value to be passed as auth Bearer +-- nil there will be no retries +-- @returns result of the http call or nil if +-- the external call failed after the last retry +function http_get_with_retry(url, retry, auth_token) + local content, code, cache_for; + local timeout_occurred; + local wait, done = async.waiter(); + local request_headers = http_headers or {} + if auth_token ~= nil then + request_headers['Authorization'] = 'Bearer ' .. auth_token + end + + local function cb(content_, code_, response_, request_) + if timeout_occurred == nil then + code = code_; + if code == 200 or code == 204 then + -- module:log("debug", "External call was successful, content %s", content_); + content = content_; + + -- if there is cache-control header, let's return the max-age value + if response_ and response_.headers and response_.headers['cache-control'] then + local vals = {}; + for k, v in response_.headers['cache-control']:gmatch('(%w+)=(%w+)') do + vals[k] = v; + end + -- max-age=123 will be parsed by the regex ^ to age=123 + cache_for = vals.age; + end + else + module:log("warn", "Error on GET request: Code %s, Content %s", + code_, content_); + end + done(); + else + module:log("warn", "External call reply delivered after timeout from: %s", url); + end + end + + local function call_http() + return http.request(url, { + headers = request_headers, + method = "GET" + }, cb); + end + + local request = call_http(); + + local function cancel() + -- TODO: This check is racey. Not likely to be a problem, but we should + -- still stick a mutex on content / code at some point. + if code == nil then + timeout_occurred = true; + module:log("warn", "Timeout %s seconds making the external call to: %s", http_timeout, url); + -- no longer present in prosody 0.11, so check before calling + if http.destroy_request ~= nil then + http.destroy_request(request); + end + if retry == nil then + module:log("debug", "External call failed and retry policy is not set"); + done(); + elseif retry ~= nil and retry < 1 then + module:log("debug", "External call failed after retry") + done(); + else + module:log("debug", "External call failed, retry nr %s", retry) + retry = retry - 1; + request = call_http() + return http_timeout; + end + end + end + timer.add_task(http_timeout, cancel); + wait(); + + return content, code, cache_for; +end + +-- Checks whether there is status in the false +-- -> true, room_name, subdomain +-- -> true, room_name, nil (if no subdomain is used for the room) +function is_moderated(room_jid) + if moderated_subdomains:empty() and moderated_rooms:empty() then + return false; + end + + local room_node = jid.node(room_jid); + -- parses bare room address, for multidomain expected format is: + -- [subdomain]roomName@conference.domain + local target_subdomain, target_room_name = extract_subdomain(room_node); + if target_subdomain then + if moderated_subdomains:contains(target_subdomain) then + return true, target_room_name, target_subdomain; + end + elseif moderated_rooms:contains(room_node) then + return true, room_node, nil; + end + + return false; +end + +-- check if the room tenant starts with vpaas-magic-cookie- +-- @param room the room to check +function is_vpaas(room) + if not room then + return false; + end + + -- stored check in room object if it exist + if room.is_vpaas ~= nil then + return room.is_vpaas; + end + + room.is_vpaas = false; + + local node, host = jid.split(room.jid); + if host ~= muc_domain or not node then + return false; + end + local tenant, conference_name = node:match('^%[([^%]]+)%](.+)$'); + if not (tenant and conference_name) then + return false; + end + + if not starts_with(tenant, 'vpaas-magic-cookie-') then + return false; + end + + room.is_vpaas = true; + return true; +end + +-- Returns the initiator extension if the stanza is coming from a sip jigasi +function is_sip_jigasi(stanza) + return stanza:get_child('initiator', 'http://jitsi.org/protocol/jigasi'); +end + +function get_sip_jibri_email_prefix(email) + if not email then + return nil; + elseif starts_with_one_of(email, INBOUND_SIP_JIBRI_PREFIXES) then + return starts_with_one_of(email, INBOUND_SIP_JIBRI_PREFIXES); + elseif starts_with_one_of(email, OUTBOUND_SIP_JIBRI_PREFIXES) then + return starts_with_one_of(email, OUTBOUND_SIP_JIBRI_PREFIXES); + else + return nil; + end +end + +function is_sip_jibri_join(stanza) + if not stanza then + return false; + end + + local features = stanza:get_child('features'); + local email = stanza:get_child_text('email'); + + if not features or not email then + return false; + end + + for i = 1, #features do + local feature = features[i]; + if feature.attr and feature.attr.var and feature.attr.var == "http://jitsi.org/protocol/jibri" then + if get_sip_jibri_email_prefix(email) then + module:log("debug", "Occupant with email %s is a sip jibri ", email); + return true; + end + end + end + + return false +end + +-- process a host module directly if loaded or hooks to wait for its load +function process_host_module(name, callback) + local function process_host(host) + + if host == name then + callback(module:context(host), host); + end + end + + if prosody.hosts[name] == nil then + module:log('info', 'No host/component found, will wait for it: %s', name) + + -- when a host or component is added + prosody.events.add_handler('host-activated', process_host); + else + process_host(name); + end +end + +function table_shallow_copy(t) + local t2 = {} + for k, v in pairs(t) do + t2[k] = v + end + return t2 +end + +local function is_admin(_jid) + return usermanager.is_admin(_jid, module.host); +end + +-- KiwiIRC-specific utilities + +local kiwi_config = module:require "kiwiirc_config"; + +local base36_chars = "0123456789abcdefghijklmnopqrstuvwxyz"; + +local function base36_encode(binary) + local base36 = ""; + local bytes = {}; + + for i = 1, #binary do + bytes[i] = string.byte(binary, i); + end + + while #bytes > 0 do + local quotient = {}; + local remainder = 0; + + for i = #bytes, 1, -1 do + local accumulator = bytes[i] + remainder * 256; + local digit = math.floor(accumulator / 36); + remainder = accumulator % 36; + if #quotient > 0 or digit > 0 then + table.insert(quotient, 1, digit); + end + end + + base36 = base36 .. base36_chars:sub(remainder + 1, remainder + 1); + bytes = quotient; + end + + return base36; +end + +local function kiwiirc_get_env(key) + local value = os.getenv(key); + + if value == nil then + local default = kiwi_config[key]; + if default == nil then + return false; + else + return default; + end + end + + if value == "false" or value == "0" then + return false; + end + + if value == "true" or value == "1" then + return true; + end + + return value; +end + +local function kiwiirc_encode_room_name(server, channel) + local hash = sha256(server .. "/" .. channel); + local hash_b36 = base36_encode(hash); + return string.sub(hash_b36, -16); +end + +local function kiwiirc_get_affiliation(claims) + local allMod = kiwiirc_get_env("KIWIIRC_EVERYONE_MODERATOR"); + if allMod then + return "owner"; + end + + local queryMod = kiwiirc_get_env("KIWIIRC_DISABLE_QUERY_MODERATOR"); + if claims.channel == nil and not queryMod then + return "owner"; + end + + if claims.umodes ~= nil then + for _, v in ipairs(claims.umodes) do + if v == "o" then return "owner"; end + end + end + + if claims.cmodes ~= nil then + local channelOwner = kiwiirc_get_env("KIWIIRC_DISABLE_OWNER_MODERATOR"); + if not channelOwner then + for _, v in ipairs(claims.cmodes) do + if v == "q" then return "owner"; end + end + end + + local op = kiwiirc_get_env("KIWIIRC_DISABLE_OP_MODERATOR"); + if not op then + for _, v in ipairs(claims.cmodes) do + if v == "o" then return "owner"; end + end + end + + local halfop = kiwiirc_get_env("KIWIIRC_DISABLE_HALFOP_MODERATOR"); + if not halfop then + for _, v in ipairs(claims.cmodes) do + if v == "h" then return "owner"; end + end + end + end + + return "member"; +end + +local function kiwiirc_query_pattern() + local pattern = "^q%-"; + for i = 1, 16 do + pattern = pattern .. "[a-z0-9]"; + end + return pattern .. "$"; +end + +return { + OUTBOUND_SIP_JIBRI_PREFIXES = OUTBOUND_SIP_JIBRI_PREFIXES; + INBOUND_SIP_JIBRI_PREFIXES = INBOUND_SIP_JIBRI_PREFIXES; + extract_subdomain = extract_subdomain; + is_admin = is_admin; + is_feature_allowed = is_feature_allowed; + is_healthcheck_room = is_healthcheck_room; + is_moderated = is_moderated; + is_sip_jibri_join = is_sip_jibri_join; + is_sip_jigasi = is_sip_jigasi; + is_vpaas = is_vpaas; + get_focus_occupant = get_focus_occupant; + get_room_from_jid = get_room_from_jid; + get_room_by_name_and_subdomain = get_room_by_name_and_subdomain; + get_sip_jibri_email_prefix = get_sip_jibri_email_prefix; + async_handler_wrapper = async_handler_wrapper; + presence_check_status = presence_check_status; + process_host_module = process_host_module; + room_jid_match_rewrite = room_jid_match_rewrite; + room_jid_split_subdomain = room_jid_split_subdomain; + internal_room_jid_match_rewrite = internal_room_jid_match_rewrite; + update_presence_identity = update_presence_identity; + http_get_with_retry = http_get_with_retry; + ends_with = ends_with; + starts_with = starts_with; + starts_with_one_of = starts_with_one_of; + table_shallow_copy = table_shallow_copy; + encode_room_name = kiwiirc_encode_room_name; + get_kiwiirc_affiliation = kiwiirc_get_affiliation; + get_kiwiirc_env = kiwiirc_get_env; + query_pattern = kiwiirc_query_pattern; +}; diff --git a/jitsi-plugin/jitsi-meet_9646/mod_auth_kiwiirc_token.lua b/jitsi-plugin/jitsi-meet_9646/mod_auth_kiwiirc_token.lua new file mode 100644 index 0000000..37d8919 --- /dev/null +++ b/jitsi-plugin/jitsi-meet_9646/mod_auth_kiwiirc_token.lua @@ -0,0 +1,244 @@ +-- Token authentication +-- Copyright (C) 2021-present 8x8, Inc. + +local formdecode = require "util.http".formdecode; +local generate_uuid = require "util.uuid".generate; +local new_sasl = require "util.sasl".new; +local sasl = require "util.sasl"; +local token_util = module:require "kiwiirc_token/util".new(module); +local sessions = prosody.full_sessions; + +-- no token configuration +if token_util == nil then + return; +end + +module:depends("jitsi_session"); + +local measure_pre_fetch_fail = module:measure('pre_fetch_fail', 'counter'); +local measure_verify_fail = module:measure('verify_fail', 'counter'); +local measure_success = module:measure('success', 'counter'); +local measure_ban = module:measure('ban', 'counter'); +local measure_post_auth_fail = module:measure('post_auth_fail', 'counter'); + +-- define auth provider +local provider = {}; + +local host = module.host; + +module:hook("pre-resource-unbind", function (e) + local error, session = e.error, e.session; + + prosody.events.fire_event('jitsi-pre-session-unbind', { + jid = session.full_jid, + session = session, + error = error + }); +end, 11); + +-- Extract token from Authorization header or 'token' URL query param when session is created. +-- The Authorization header is used for EXTJWT tokens sent by kiwiirc. The query param +-- allows an override for compatibility. +function init_session(event) + local session, request = event.session, event.request; + local query = request.url.query; + + local token = nil; + + -- extract token from Authorization header + if request.headers["authorization"] then + -- assumes the header value starts with "Bearer " + token = request.headers["authorization"]:sub(8, #request.headers["authorization"]) + end + + -- allow override of token via query parameter + if query ~= nil then + local params = formdecode(query); + + -- The following fields are filled in the session, by extracting them + -- from the query and no validation is being done. + -- After validating auth_token will be cleaned in case of error and few + -- other fields will be extracted from the token and set in the session + + if params and params.token then + token = params.token; + end + end + + -- in either case set auth_token in the session + session.auth_token = token; + session.user_agent_header = request.headers['user_agent']; +end + +module:hook_global("bosh-session", init_session); +module:hook_global("websocket-session", init_session); + +function provider.test_password(username, password) + return nil, "Password based auth not supported"; +end + +function provider.get_password(username) + return nil; +end + +function provider.set_password(username, password) + return nil, "Set password not supported"; +end + +function provider.user_exists(username) + return nil; +end + +function provider.create_user(username, password) + return nil; +end + +function provider.delete_user(username) + return nil; +end + +function first_stage_auth(session) + -- retrieve custom public key from server and save it on the session + local pre_event_result = prosody.events.fire_event("pre-jitsi-authentication-fetch-key", session); + if pre_event_result ~= nil and pre_event_result.res == false then + module:log("warn", + "Error verifying token on pre authentication stage:%s, reason:%s", pre_event_result.error, pre_event_result.reason); + session.auth_token = nil; + measure_pre_fetch_fail(1); + return pre_event_result; + end + + local res, error, reason = token_util:process_and_verify_token(session); + if res == false then + module:log("warn", + "Error verifying token err:%s, reason:%s tenant:%s room:%s user_agent:%s", + error, reason, session.jitsi_web_query_prefix, session.jitsi_web_query_room, + session.user_agent_header); + session.auth_token = nil; + measure_verify_fail(1); + return { res = res, error = error, reason = reason }; + end + + local shouldAllow = prosody.events.fire_event("jitsi-access-ban-check", session); + if shouldAllow == false then + module:log("warn", "user is banned") + measure_ban(1); + return { res = false, error = "not-allowed", reason = "user is banned" }; + end + + return { verify_result = res, custom_username = prosody.events.fire_event("pre-jitsi-authentication", session) }; +end + +function second_stage_auth(session) + local post_event_result = prosody.events.fire_event("post-jitsi-authentication", session); + if post_event_result ~= nil and post_event_result.res == false then + module:log("warn", + "Error verifying token on post authentication stage :%s, reason:%s", post_event_result.error, post_event_result.reason); + session.auth_token = nil; + measure_post_auth_fail(1); + return post_event_result; + end +end + +function provider.get_sasl_handler(session) + + local function get_username_from_token(self, message) + + local s1_result = first_stage_auth(session); + if s1_result.res == false then + return s1_result.res, s1_result.error, s1_result.reason; + end + + if s1_result.custom_username then + self.username = s1_result.custom_username; + elseif session.previd ~= nil then + for _, session1 in pairs(sessions) do + if (session1.resumption_token == session.previd) then + self.username = session1.username; + break; + end + end + else + self.username = message; + end + + local s2_result = second_stage_auth(session); + if s2_result and s2_result.res ~= nil then + return s2_result.res, s2_result.error, s2_result.reason; + end + + measure_success(1); + session._jitsi_auth_done = true; + return s1_result.verify_result; + end + + return new_sasl(host, { anonymous = get_username_from_token }); +end + +module:provides("auth", provider); + +local function anonymous(self, message) + + local username = generate_uuid(); + + -- This calls the handler created in 'provider.get_sasl_handler(session)' + local result, err, msg = self.profile.anonymous(self, username, self.realm); + + if result == true then + if (self.username == nil) then + self.username = username; + end + return "success"; + else + return "failure", err, msg; + end + end + +sasl.registerMechanism("ANONYMOUS", {"anonymous"}, anonymous); + +module:hook_global('c2s-session-updated', function (event) + local session, from_session = event.session, event.from_session; + + if not from_session.auth_token then + return; + end + + -- we care to handle sessions from other hosts (anonymous hosts) + if module.host ~= event.from_session.host then + -- Handle session updates (e.g., when a session is resumed on some anonymous host with a token we need to do all the checks here) + session.auth_token = event.from_session.auth_token; + + local s1_result = first_stage_auth(session); + if s1_result.res == false then + event.session:close(); + return; + end + + local s2_result = second_stage_auth(session); + if s2_result and s2_result.res == false then + event.session:close(); + return; + end + session._jitsi_auth_done = true; + end + + if not session._jitsi_auth_done then + module:log('warn', 'Impossible case hit where session did not pass auth flow'); + event.session:close(); + return; + end + + -- copy all the custom fields we set in the session + session.auth_token = from_session.auth_token; + session.jitsi_meet_context_user = from_session.jitsi_meet_context_user; + session.jitsi_meet_context_group = from_session.jitsi_meet_context_group; + session.jitsi_meet_context_features = from_session.jitsi_meet_context_features; + session.jitsi_meet_context_room = from_session.jitsi_meet_context_room; + session.jitsi_meet_room = from_session.jitsi_meet_room; + -- kiwiirc-specific fields + session.jitsi_meet_channel = from_session.jitsi_meet_channel; + session.jitsi_meet_affiliation = from_session.jitsi_meet_affiliation; + session.jitsi_meet_issuer = from_session.jitsi_meet_issuer; + session.jitsi_meet_joined = from_session.jitsi_meet_joined; + session.user_agent_header = from_session.user_agent_header; +end, 1); diff --git a/jitsi-plugin/jitsi-meet_9646/mod_kiwiirc_token_verification.lua b/jitsi-plugin/jitsi-meet_9646/mod_kiwiirc_token_verification.lua new file mode 100644 index 0000000..fed5130 --- /dev/null +++ b/jitsi-plugin/jitsi-meet_9646/mod_kiwiirc_token_verification.lua @@ -0,0 +1,150 @@ +-- Token authentication +-- Copyright (C) 2021-present 8x8, Inc. + +local log = module._log; +local host = module.host; +local st = require "util.stanza"; +local jid_split = require 'util.jid'.split; +local jid_bare = require 'util.jid'.bare; + +local util = module:require 'util'; +local is_admin = util.is_admin; + +local DEBUG = false; + +local measure_success = module:measure('success', 'counter'); +local measure_fail = module:measure('fail', 'counter'); + +local parentHostName = string.gmatch(tostring(host), "%w+.(%w.+)")(); +if parentHostName == nil then + module:log("error", "Failed to start - unable to get parent hostname"); + return; +end + +local parentCtx = module:context(parentHostName); +if parentCtx == nil then + module:log("error", + "Failed to start - unable to get parent context for host: %s", + tostring(parentHostName)); + return; +end + +local token_util = module:require "kiwiirc_token/util".new(parentCtx); + +-- no token configuration +if token_util == nil then + return; +end + +module:log("debug", + "%s - starting MUC token verifier app_id: %s app_secret: %s allow empty: %s", + tostring(host), tostring(token_util.appId), tostring(token_util.appSecret), + tostring(token_util.allowEmptyToken)); + +-- option to disable room modification (sending muc config form) for guest that do not provide token +local require_token_for_moderation; +-- option to allow domains to skip token verification +local allowlist; +local function load_config() + require_token_for_moderation = module:get_option_boolean("token_verification_require_token_for_moderation"); + allowlist = module:get_option_set('token_verification_allowlist', {}); +end +load_config(); + +-- verify user and whether he is allowed to join a room based on the token information +local function verify_user(session, stanza) + if DEBUG then + module:log("debug", "Session token: %s, session room: %s", + tostring(session.auth_token), tostring(session.jitsi_meet_room)); + end + + -- token not required for admin users + local user_jid = stanza.attr.from; + if is_admin(user_jid) then + if DEBUG then module:log("debug", "Token not required from admin user: %s", user_jid); end + return true; + end + + -- token not required for users matching allow list + local user_bare_jid = jid_bare(user_jid); + local _, user_domain = jid_split(user_jid); + + -- allowlist for participants, jigasi (sip & transcriber), jibri (recorder & sip) + if allowlist:contains(user_domain) + or allowlist:contains(user_bare_jid) + + -- allow main participants in visitor mode + or session.type == 's2sin' then + if DEBUG then module:log("debug", "Token not required from user in allow list: %s", user_jid); end + return true; + end + + if DEBUG then module:log("debug", "Will verify token for user: %s, room: %s ", user_jid, stanza.attr.to); end + local res, err, reason = token_util:verify_room(session, stanza.attr.to); + if not res then + if not err and not reason then + reason = 'Room and token mismatched'; + end + + module:log('error', 'Token %s not allowed to join: %s err: %s reason: %s', + tostring(session.auth_token), tostring(stanza.attr.to), err, reason); + + local response = st.error_reply(stanza, 'cancel', 'not-allowed', reason); + if err then + response:tag(err, { xmlns = 'http://jitsi.org/protocol/jitmeet' }); + end + + session.send(response); + return false; -- we need to just return non nil + end + if DEBUG then module:log("debug", "allowed: %s to enter/create room: %s", user_jid, stanza.attr.to); end + return true; +end + +module:hook("muc-room-pre-create", function(event) + local origin, stanza = event.origin, event.stanza; + if DEBUG then module:log("debug", "pre create: %s %s", tostring(origin), tostring(stanza)); end + if not verify_user(origin, stanza) then + measure_fail(1); + return true; -- Returning any value other than nil will halt processing of the event + end + measure_success(1); +end, 99); + +module:hook("muc-occupant-pre-join", function(event) + local origin, room, stanza = event.origin, event.room, event.stanza; + if DEBUG then module:log("debug", "pre join: %s %s", tostring(room), tostring(stanza)); end + if not verify_user(origin, stanza) then + measure_fail(1); + return true; -- Returning any value other than nil will halt processing of the event + end + measure_success(1); +end, 99); + +for event_name, method in pairs { + -- Normal room interactions + ["iq-set/bare/http://jabber.org/protocol/muc#owner:query"] = "handle_owner_query_set_to_room" ; + -- Host room + ["iq-set/host/http://jabber.org/protocol/muc#owner:query"] = "handle_owner_query_set_to_room" ; +} do + module:hook(event_name, function (event) + local session, stanza = event.origin, event.stanza; + + -- if we do not require token we pass it through(default behaviour) + -- or the request is coming from admin (focus) + if not require_token_for_moderation or is_admin(stanza.attr.from) then + return; + end + + -- jitsi_meet_room is set after the token had been verified + if not session.auth_token or not session.jitsi_meet_room then + session.send( + st.error_reply( + stanza, "cancel", "not-allowed", "Room modification disabled for guests")); + return true; + end + + end, -1); -- the default prosody hook is on -2 +end + +module:hook_global('config-reloaded', load_config); diff --git a/jitsi-plugin/jitsi-meet_9646/mod_kiwiirc_xmpp_muc.lua b/jitsi-plugin/jitsi-meet_9646/mod_kiwiirc_xmpp_muc.lua new file mode 100644 index 0000000..ae6f736 --- /dev/null +++ b/jitsi-plugin/jitsi-meet_9646/mod_kiwiirc_xmpp_muc.lua @@ -0,0 +1,123 @@ +local LOGLEVEL = "debug"; + +local is_admin = require "core.usermanager".is_admin; +local is_healthcheck_room = module:require "util".is_healthcheck_room; +local timer = require "util.timer"; +local st = require "util.stanza"; +local it = require "util.iterators"; +local jid = require "util.jid"; + +local kiwi_util = module:require "kiwiirc_util"; +local query_pattern = kiwi_util.query_pattern(); + +module:log(LOGLEVEL, "loaded"); + +local function _is_admin(jid) + return is_admin(jid, module.host); +end + +module:hook("muc-occupant-pre-join", function(event) + local allow_guest_create = kiwi_util.get_kiwiirc_env("KIWIIRC_ALLOW_GUEST_CREATE"); + + if allow_guest_create then + return; + end + + local room, origin, stanza, session = event.room, event.origin, event.stanza, event.session; + if not event.is_new_room then + local affiliation = origin.jitsi_meet_affiliation; + local participant_count = it.count(room:each_occupant()); + local auth_domain = kiwi_util.get_kiwiirc_env("XMPP_AUTH_DOMAIN") or "auth.meet.jitsi"; + local has_affiliate = false; + + if affiliation ~= nil then + -- User is an affiliate meaning they authed with jwt token + has_affiliate = true; + else + -- User is a guest + for _, o in room:each_occupant() do + -- Check current members for a user who authed with jwt token + if o.bare_jid:match("^focus@" .. auth_domain) == nil and room:get_affiliation(o.jid) ~= nil then + has_affiliate = true; + end + end + end + + if not has_affiliate then + -- User is a guest and no irc users are present + origin.send(st.error_reply(stanza, "cancel", "service-unavailable", "No IRC user is present in conference room")); + return true; + end + end +end) + +module:hook("muc-occupant-pre-join", function(event) + local room, origin, stanza = event.room, event.origin, event.stanza; + local room_node = jid.node(room.jid); + + if not room_node or not room_node:match(query_pattern) then + return; + end + + if _is_admin(stanza.attr.from) then + return; + end + + local user_nick = origin.jitsi_meet_context_user and origin.jitsi_meet_context_user.name; + local iss = origin.jitsi_meet_issuer; + + if not user_nick or not iss then + return; + end + + local peer_nick = stanza:get_child_text('email'); + if not peer_nick or peer_nick == "" then + module:log("warn", "query room join rejected: no peer nick in presence for user '%s'", user_nick); + origin.send(st.error_reply(stanza, "cancel", "not-allowed", "Query conference requires peer identification")); + return true; + end + + local nicks = { user_nick, peer_nick }; + table.sort(nicks); + local sorted_pair = nicks[1] .. "+" .. nicks[2]; + local expected_node = "q-" .. kiwi_util.encode_room_name(iss, sorted_pair); + + if room_node ~= expected_node then + module:log("warn", "query room join rejected: '%s' claimed peer '%s' but room '%s' ~= expected '%s'", + user_nick, peer_nick, room_node, expected_node); + origin.send(st.error_reply(stanza, "cancel", "not-allowed", "Not authorized for this query conference")); + return true; + end +end) + +module:hook("muc-occupant-joined", function (event) + local room, occupant = event.room, event.occupant; + + if is_healthcheck_room(room.jid) or _is_admin(occupant.jid) then + module:log(LOGLEVEL, "skip affiliation, %s", occupant.jid); + return; + end + + if not event.origin.auth_token then + module:log(LOGLEVEL, "skip affiliation, no token"); + return; + end + + local affiliation = event.origin.jitsi_meet_affiliation; + + if affiliation == nil then + return; + end + + local i = 0 + local function setAffiliation() + room:set_affiliation(true, occupant.bare_jid, affiliation) + if i > 3 then return end; + + i = i + 1; + timer.add_task(0.2 * i, setAffiliation); + end + setAffiliation() + + module:log(LOGLEVEL, "replacing affiliation: '%s' with '%s' for %s", room:get_affiliation(occupant.jid), affiliation, occupant.jid) +end) diff --git a/jsconfig.json b/jsconfig.json new file mode 100644 index 0000000..eac2f41 --- /dev/null +++ b/jsconfig.json @@ -0,0 +1,5 @@ +{ + "vueCompilerOptions": { + "target": 2.7, + } +} diff --git a/package.json b/package.json index 25d3ee4..69b7291 100755 --- a/package.json +++ b/package.json @@ -1,44 +1,88 @@ { "name": "kiwiirc-plugin-conference", - "version": "1.0.1", - "main": "./src/plugin.js", + "version": "1.1.0", "license": "Apache-2.0", "private": true, "scripts": { - "build": "webpack", - "watch": "webpack --watch", - "dev": "webpack-dev-server", + "dev": "node build/commands/dev.js", + "build": "node build/commands/build.js", + "stats": "node build/commands/build.js --stats", "lint": "npm-run-all lint:*", - "lint:eslint": "eslint --ext .js,.vue ./", - "lint:stylelint": "stylelint \"src/**/*.{vue,htm,html,css,sss,less,scss}\"" - }, - "dependencies": { - "platform": "^1.3.6", - "postcss-html": "^1.8.1" + "lint:js": "eslint --ext .js,.vue ./", + "lint:style": "stylelint \"./src/**/*.{vue,html,css,less,scss,sass}\"" }, + "dependencies": {}, "devDependencies": { "@babel/core": "^7.29.0", "@babel/eslint-parser": "^7.28.6", - "@babel/preset-env": "^7.29.0", - "@vue/eslint-config-airbnb": "^6.0.0", - "babel-loader": "9.2.1", - "css-loader": "^6.11.0", - "eslint": "^7.32.0", - "eslint-config-standard": "^14.1.1", + "@babel/plugin-transform-runtime": "^7.29.0", + "@babel/preset-env": "^7.29.2", + "@eslint/js": "^9.39.4", + "@kiwiirc/eslint-plugin": "./build/plugins/eslint-rules", + "@nuxt/friendly-errors-webpack-plugin": "^2.6.0", + "@stylistic/eslint-plugin": "^5.10.0", + "@stylistic/stylelint-plugin": "^5.1.0", + "autoprefixer": "^10.5.0", + "babel-loader": "^10.1.1", + "case-sensitive-paths-webpack-plugin": "^2.4.0", + "chalk": "^5.6.2", + "cliui": "^9.0.1", + "compression-webpack-plugin": "^12.0.0", + "css-loader": "^7.1.4", + "css-minimizer-webpack-plugin": "^8.0.0", + "eslint": "^9.39.4", + "eslint-formatter-friendly": "^7.0.0", + "eslint-import-resolver-alias": "^1.1.2", "eslint-plugin-import": "^2.32.0", - "eslint-plugin-node": "^11.1.0", - "eslint-plugin-promise": "^4.3.1", - "eslint-plugin-standard": "^4.1.0", - "eslint-plugin-vue": "^8.7.1", - "eslint-plugin-vuejs-accessibility": "^1.2.0", + "eslint-plugin-jsdoc": "^62.9.0", + "eslint-plugin-vue": "^10.8.0", + "eslint-plugin-vuejs-accessibility": "^2.5.0", + "eslint-webpack-plugin": "^6.0.0", + "globals": "^17.5.0", + "html-loader": "^5.1.0", + "jsdoc": "^4.0.5", + "less": "^4.6.4", + "less-loader": "^12.3.2", + "minimist": "^1.2.8", + "murmurhash3js": "^3.0.1", "npm-run-all": "^4.1.5", - "style-loader": "^3.3.4", - "stylelint": "^14.16.1", - "stylelint-config-standard": "^29.0.0", + "ora": "^9.3.0", + "portfinder": "^1.0.38", + "postcss": "^8.5.10", + "postcss-html": "^1.8.1", + "postcss-less": "^6.0.0", + "postcss-loader": "^8.2.1", + "postcss-scss": "^4.0.9", + "prettier": "^3.8.3", + "prettier-eslint": "^16.4.2", + "prettier-plugin-jsdoc": "^1.8.0", + "rimraf": "^6.1.3", + "sass": "^1.99.0", + "sass-loader": "^16.0.7", + "style-loader": "^4.0.0", + "stylelint": "^17.8.0", + "stylelint-config-recess-order": "^7.7.0", + "stylelint-config-recommended": "^18.0.0", + "stylelint-config-recommended-scss": "^17.0.1", + "stylelint-config-recommended-vue": "^1.6.1", + "stylelint-config-standard": "^40.0.0", + "stylelint-config-standard-scss": "^17.0.0", + "stylelint-order": "^8.1.1", + "stylelint-webpack-plugin": "5.1.0", + "terser-webpack-plugin": "^5.4.0", + "thread-loader": "^4.0.4", + "tslib": "^2.8.1", + "typescript": "^6.0.2", + "vue": "^2.7.16", + "vue-eslint-parser": "^10.4.0", "vue-loader": "^15.11.1", + "vue-style-loader": "^4.1.3", "vue-template-compiler": "^2.7.16", - "webpack": "^5.104.1", - "webpack-cli": "^5.1.4", - "webpack-dev-server": "^4.15.2" - } + "webpack": "^5.106.2", + "webpack-bundle-analyzer": "^5.3.0", + "webpack-cli": "^7.0.2", + "webpack-dev-server": "^5.2.3", + "webpack-merge": "^6.0.1" + }, + "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" } diff --git a/src/components/HeaderButton.vue b/src/components/HeaderButton.vue index d228d44..a8a3cde 100644 --- a/src/components/HeaderButton.vue +++ b/src/components/HeaderButton.vue @@ -1,49 +1,79 @@ - - diff --git a/src/components/JitsiMediaView.vue b/src/components/JitsiMediaView.vue index fe8faa5..4fc2b77 100644 --- a/src/components/JitsiMediaView.vue +++ b/src/components/JitsiMediaView.vue @@ -1,245 +1,414 @@ - - diff --git a/src/components/MessageTemplate.vue b/src/components/MessageTemplate.vue index cc007c4..0422351 100644 --- a/src/components/MessageTemplate.vue +++ b/src/components/MessageTemplate.vue @@ -3,71 +3,71 @@
{{ buffer.isQuery() ? inviteText : joinText }}
-
+