diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index 9175db9..0000000 --- a/.eslintrc.js +++ /dev/null @@ -1,9 +0,0 @@ -module.exports = { - extends: [ - '@nextcloud', - ], - rules: { - 'jsdoc/require-jsdoc': 'off', - 'vue/first-attribute-linebreak': 'off', - }, -} diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 852b265..6dd9544 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -16,22 +16,6 @@ updates: time: "03:00" timezone: Europe/Paris open-pull-requests-limit: 10 - - package-ecosystem: composer - directory: "/vendor-bin/openapi-extractor" - schedule: - interval: weekly - day: saturday - time: "03:00" - timezone: Europe/Paris - open-pull-requests-limit: 10 - - package-ecosystem: composer - directory: "/vendor-bin/phpunit" - schedule: - interval: weekly - day: saturday - time: "03:00" - timezone: Europe/Paris - open-pull-requests-limit: 10 - package-ecosystem: composer directory: "/vendor-bin/psalm" schedule: diff --git a/.github/workflows/lint-eslint.yml b/.github/workflows/lint-eslint.yml deleted file mode 100644 index d0a8a2f..0000000 --- a/.github/workflows/lint-eslint.yml +++ /dev/null @@ -1,95 +0,0 @@ -# This workflow is provided via the organization template repository -# -# https://github.com/nextcloud/.github -# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization -# -# Use lint-eslint together with lint-eslint-when-unrelated to make eslint a required check for GitHub actions -# https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/defining-the-mergeability-of-pull-requests/troubleshooting-required-status-checks#handling-skipped-but-required-checks - -name: Lint eslint - -on: pull_request - -permissions: - contents: read - -concurrency: - group: lint-eslint-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -jobs: - changes: - runs-on: ubuntu-latest-low - - outputs: - src: ${{ steps.changes.outputs.src}} - - steps: - - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 - id: changes - continue-on-error: true - with: - filters: | - src: - - '.github/workflows/**' - - 'src/**' - - 'appinfo/info.xml' - - 'package.json' - - 'package-lock.json' - - 'tsconfig.json' - - '.eslintrc.*' - - '.eslintignore' - - '**.js' - - '**.ts' - - '**.vue' - - lint: - runs-on: ubuntu-latest - - needs: changes - if: needs.changes.outputs.src != 'false' - - name: NPM lint - - steps: - - name: Checkout - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 - - - name: Read package.json node and npm engines version - uses: skjnldsv/read-package-engines-version-actions@8205673bab74a63eb9b8093402fd9e0e018663a1 # v2.2 - id: versions - with: - fallbackNode: '^20' - fallbackNpm: '^10' - - - name: Set up node ${{ steps.versions.outputs.nodeVersion }} - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v3 - with: - node-version: ${{ steps.versions.outputs.nodeVersion }} - - - name: Set up npm ${{ steps.versions.outputs.npmVersion }} - run: npm i -g npm@"${{ steps.versions.outputs.npmVersion }}" - - - name: Install dependencies - env: - CYPRESS_INSTALL_BINARY: 0 - PUPPETEER_SKIP_DOWNLOAD: true - run: npm ci - - - name: Lint - run: npm run lint - - summary: - permissions: - contents: none - runs-on: ubuntu-latest-low - needs: [changes, lint] - - if: always() - - # This is the summary, we just avoid to rename it so that branch protection rules still match - name: eslint - - steps: - - name: Summary status - run: if ${{ needs.changes.outputs.src != 'false' && needs.lint.result != 'success' }}; then exit 1; fi diff --git a/.github/workflows/lint-stylelint.yml b/.github/workflows/lint-stylelint.yml deleted file mode 100644 index 4effd42..0000000 --- a/.github/workflows/lint-stylelint.yml +++ /dev/null @@ -1,48 +0,0 @@ -# This workflow is provided via the organization template repository -# -# https://github.com/nextcloud/.github -# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization - -name: Lint stylelint - -on: pull_request - -permissions: - contents: read - -concurrency: - group: lint-stylelint-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -jobs: - lint: - runs-on: ubuntu-latest - - name: stylelint - - steps: - - name: Checkout - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 - - - name: Read package.json node and npm engines version - uses: skjnldsv/read-package-engines-version-actions@8205673bab74a63eb9b8093402fd9e0e018663a1 # v2.2 - id: versions - with: - fallbackNode: '^20' - fallbackNpm: '^10' - - - name: Set up node ${{ steps.versions.outputs.nodeVersion }} - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v3 - with: - node-version: ${{ steps.versions.outputs.nodeVersion }} - - - name: Set up npm ${{ steps.versions.outputs.npmVersion }} - run: npm i -g npm@"${{ steps.versions.outputs.npmVersion }}" - - - name: Install dependencies - env: - CYPRESS_INSTALL_BINARY: 0 - run: npm ci - - - name: Lint - run: npm run stylelint diff --git a/.github/workflows/openapi.yml b/.github/workflows/openapi.yml deleted file mode 100644 index b0c3ce2..0000000 --- a/.github/workflows/openapi.yml +++ /dev/null @@ -1,85 +0,0 @@ -name: OpenAPI - -on: pull_request - -permissions: - contents: read - -concurrency: - group: openapi-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -jobs: - openapi: - runs-on: ubuntu-latest - - if: ${{ github.repository_owner != 'nextcloud-gmbh' }} - - steps: - - name: Checkout - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 - - - name: Get php version - id: php_versions - uses: icewind1991/nextcloud-version-matrix@58becf3b4bb6dc6cef677b15e2fd8e7d48c0908f # v1.3.1 - - - name: Set up php - uses: shivammathur/setup-php@a4e22b60bbb9c1021113f2860347b0759f66fe5d # v2 - with: - php-version: ${{ steps.php_versions.outputs.php-available }} - extensions: xml - coverage: none - ini-file: development - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Check Typescript OpenApi types - id: check_typescript_openapi - uses: andstor/file-existence-action@076e0072799f4942c8bc574a82233e1e4d13e9d6 # v3.0.0 - with: - files: "src/types/openapi/openapi*.ts" - - - name: Read package.json node and npm engines version - if: steps.check_typescript_openapi.outputs.files_exists == 'true' - uses: skjnldsv/read-package-engines-version-actions@8205673bab74a63eb9b8093402fd9e0e018663a1 # v2.2 - id: node_versions - # Continue if no package.json - continue-on-error: true - with: - fallbackNode: '^20' - fallbackNpm: '^10' - - - name: Set up node ${{ steps.node_versions.outputs.nodeVersion }} - if: ${{ steps.node_versions.outputs.nodeVersion }} - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 - with: - node-version: ${{ steps.node_versions.outputs.nodeVersion }} - - - name: Set up npm ${{ steps.node_versions.outputs.npmVersion }} - if: ${{ steps.node_versions.outputs.nodeVersion }} - run: npm i -g npm@"${{ steps.node_versions.outputs.npmVersion }}" - - - name: Install dependencies & build - if: ${{ steps.node_versions.outputs.nodeVersion }} - env: - CYPRESS_INSTALL_BINARY: 0 - PUPPETEER_SKIP_DOWNLOAD: true - run: | - npm ci - - - name: Set up dependencies - run: composer i - - - name: Regenerate OpenAPI - run: composer run openapi - - - name: Check openapi*.json and typescript changes - run: | - bash -c "[[ ! \"`git status --porcelain `\" ]] || (echo 'Please run \"composer run openapi\" and commit the openapi*.json files and (if applicable) src/types/openapi/openapi*.ts, see the section \"Show changes on failure\" for details' && exit 1)" - - - name: Show changes on failure - if: failure() - run: | - git status - git --no-pager diff - exit 1 # make it red to grab attention diff --git a/.github/workflows/psalm-matrix.yml b/.github/workflows/psalm-matrix.yml deleted file mode 100644 index 8e3d42f..0000000 --- a/.github/workflows/psalm-matrix.yml +++ /dev/null @@ -1,68 +0,0 @@ -# This workflow is provided via the organization template repository -# -# https://github.com/nextcloud/.github -# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization - -name: Static analysis - -on: pull_request - -concurrency: - group: psalm-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -jobs: - matrix: - runs-on: ubuntu-latest-low - outputs: - ocp-matrix: ${{ steps.versions.outputs.ocp-matrix }} - steps: - - name: Checkout app - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 - - name: Get version matrix - id: versions - uses: icewind1991/nextcloud-version-matrix@58becf3b4bb6dc6cef677b15e2fd8e7d48c0908f # v1.3.1 - - static-analysis: - runs-on: ubuntu-latest - needs: matrix - strategy: - # do not stop on another job's failure - fail-fast: false - matrix: ${{ fromJson(needs.matrix.outputs.ocp-matrix) }} - - name: static-psalm-analysis ${{ matrix.ocp-version }} - steps: - - name: Checkout - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 - - - name: Set up php${{ matrix.php-versions }} - uses: shivammathur/setup-php@a4e22b60bbb9c1021113f2860347b0759f66fe5d # v2 - with: - php-version: ${{ matrix.php-versions }} - extensions: bz2, ctype, curl, dom, fileinfo, gd, iconv, intl, json, libxml, mbstring, openssl, pcntl, posix, session, simplexml, xmlreader, xmlwriter, zip, zlib, sqlite, pdo_sqlite - coverage: none - ini-file: development - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Install dependencies - run: composer i - - - name: Install dependencies - run: composer require --dev nextcloud/ocp:${{ matrix.ocp-version }} --ignore-platform-reqs --with-dependencies - - - name: Run coding standards check - run: composer run psalm - - summary: - runs-on: ubuntu-latest-low - needs: static-analysis - - if: always() - - name: static-psalm-analysis-summary - - steps: - - name: Summary status - run: if ${{ needs.static-analysis.result != 'success' }}; then exit 1; fi diff --git a/.github/workflows/update-nextcloud-ocp-approve-merge.yml b/.github/workflows/update-nextcloud-ocp-approve-merge.yml deleted file mode 100644 index 9d24f73..0000000 --- a/.github/workflows/update-nextcloud-ocp-approve-merge.yml +++ /dev/null @@ -1,49 +0,0 @@ -# This workflow is provided via the organization template repository -# -# https://github.com/nextcloud/.github -# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization - -name: Auto approve nextcloud/ocp - -on: - pull_request_target: - branches: - - main - - master - - stable* - -permissions: - contents: read - -concurrency: - group: update-nextcloud-ocp-approve-merge-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -jobs: - auto-approve-merge: - if: github.actor == 'nextcloud-command' - runs-on: ubuntu-latest-low - permissions: - # for hmarr/auto-approve-action to approve PRs - pull-requests: write - # for alexwilson/enable-github-automerge-action to approve PRs - contents: write - - steps: - - uses: mdecoleman/pr-branch-name@bab4c71506bcd299fb350af63bb8e53f2940a599 # v2.0.0 - id: branchname - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - - # GitHub actions bot approve - - uses: hmarr/auto-approve-action@b40d6c9ed2fa10c9a2749eca7eb004418a705501 # v2 - if: startsWith(steps.branchname.outputs.branch, 'automated/noid/') && endsWith(steps.branchname.outputs.branch, 'update-nextcloud-ocp') - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - - # Enable GitHub auto merge - - name: Auto merge - uses: alexwilson/enable-github-automerge-action@56e3117d1ae1540309dc8f7a9f2825bc3c5f06ff # main - if: startsWith(steps.branchname.outputs.branch, 'automated/noid/') && endsWith(steps.branchname.outputs.branch, 'update-nextcloud-ocp') - with: - github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/update-nextcloud-ocp-matrix.yml b/.github/workflows/update-nextcloud-ocp-matrix.yml deleted file mode 100644 index e1fa4d9..0000000 --- a/.github/workflows/update-nextcloud-ocp-matrix.yml +++ /dev/null @@ -1,94 +0,0 @@ -# This workflow is provided via the organization template repository -# -# https://github.com/nextcloud/.github -# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization - -name: Update nextcloud/ocp - -on: - workflow_dispatch: - schedule: - - cron: '5 2 * * 0' - -jobs: - update-nextcloud-ocp: - runs-on: ubuntu-latest - - strategy: - fail-fast: false - matrix: - branches: ['main'] - target: ['stable29'] - - name: update-nextcloud-ocp-${{ matrix.branches }} - - steps: - - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 - with: - ref: ${{ matrix.branches }} - submodules: true - - - name: Set up php8.2 - uses: shivammathur/setup-php@a4e22b60bbb9c1021113f2860347b0759f66fe5d # v2 - with: - php-version: 8.2 - # https://docs.nextcloud.com/server/stable/admin_manual/installation/source_installation.html#prerequisites-for-manual-installation - extensions: bz2, ctype, curl, dom, fileinfo, gd, iconv, intl, json, libxml, mbstring, openssl, pcntl, posix, session, simplexml, xmlreader, xmlwriter, zip, zlib, sqlite, pdo_sqlite - coverage: none - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Read codeowners - id: codeowners - run: | - grep '/appinfo/info.xml' .github/CODEOWNERS | cut -f 2- -d ' ' | xargs | awk '{ print "codeowners="$0 }' >> $GITHUB_OUTPUT - continue-on-error: true - - - name: Composer install - run: composer install - - - name: Composer update nextcloud/ocp - id: update_branch - run: composer require --dev nextcloud/ocp:dev-${{ matrix.target }} - - - name: Raise on issue on failure - uses: dacbd/create-issue-action@cdb57ab6ff8862aa09fee2be6ba77a59581921c2 # v2.0.0 - if: ${{ failure() && steps.update_branch.conclusion == 'failure' }} - with: - token: ${{ secrets.GITHUB_TOKEN }} - title: Failed to update nextcloud/ocp package} - body: Please check the output of the GitHub action and manually resolve the issues
${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
${{ steps.codeowners.outputs.codeowners }} - - - name: Reset checkout 3rdparty - run: | - git clean -f 3rdparty - git checkout 3rdparty - continue-on-error: true - - - name: Reset checkout vendor - run: | - git clean -f vendor - git checkout vendor - continue-on-error: true - - - name: Reset checkout vendor-bin - run: | - git clean -f vendor-bin - git checkout vendor-bin - continue-on-error: true - - - name: Create Pull Request - uses: peter-evans/create-pull-request@a4f52f8033a6168103c2538976c07b467e8163bc # v6.0.1 - with: - token: ${{ secrets.COMMAND_BOT_PAT }} - commit-message: "chore(dev-deps): Bump nextcloud/ocp package" - committer: GitHub - author: nextcloud-command - signoff: true - branch: automated/noid/${{ matrix.branches }}-update-nextcloud-ocp - title: "[${{ matrix.branches }}] Update nextcloud/ocp dependency" - body: | - Auto-generated update of [nextcloud/ocp](https://github.com/nextcloud-deps/ocp/) dependency - labels: | - dependencies - 3. to review diff --git a/.gitignore b/.gitignore index ad47d83..fc95ac8 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ /node_modules/ *.lock +/package-lock.json diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index 91fc1d9..240047d 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -3,17 +3,18 @@ declare(strict_types=1); require_once './vendor-bin/cs-fixer/vendor/autoload.php'; +require_once './src/CodingStandard/Config.php'; -use Nextcloud\CodingStandard\Config; +use OCA\PrivacyIDEA\CodingStandard\Config; $config = new Config(); -$config - ->getFinder() - ->notPath('build') - ->notPath('l10n') - ->notPath('node_modules') - ->notPath('src') - ->notPath('vendor') - ->in(__DIR__); + +$config->getFinder() + ->notPath('build') + ->notPath('l10n') + ->notPath('node_modules') + ->notPath('src') + ->notPath('vendor') + ->in(__DIR__); return $config; diff --git a/appinfo/routes.php b/appinfo/routes.php index 589ee07..b7d651b 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -1,15 +1,15 @@ [ - [ - 'name' => 'Settings#setValue', - 'url' => '/setValue', - 'verb' => 'POST' - ], - [ - 'name' => 'Settings#getValue', - 'url' => '/getValue', - 'verb' => 'GET' - ], - ]); \ No newline at end of file +return [ + 'routes' => [ + [ + 'name' => 'Settings#setValue', + 'url' => '/setValue', + 'verb' => 'POST' + ], + [ + 'name' => 'Settings#getValue', + 'url' => '/getValue', + 'verb' => 'GET' + ], + ]]; diff --git a/composer.json b/composer.json index a074ebe..068a59c 100644 --- a/composer.json +++ b/composer.json @@ -22,10 +22,7 @@ ], "lint": "find . -name \\*.php -not -path './vendor/*' -not -path './vendor-bin/*' -not -path './build/*' -print0 | xargs -0 -n1 php -l", "cs:check": "php-cs-fixer fix --dry-run --diff", - "cs:fix": "php-cs-fixer fix", - "psalm": "psalm --threads=1 --no-cache", - "test:unit": "phpunit tests -c tests/phpunit.xml --colors=always --fail-on-warning --fail-on-risky", - "openapi": "generate-spec" + "cs:fix": "php-cs-fixer fix" }, "require": { "bamarni/composer-bin-plugin": "^1.8", diff --git a/js/main.js b/js/main.js index 6bc409d..827141e 100644 --- a/js/main.js +++ b/js/main.js @@ -80,7 +80,7 @@ function processWebauthn() + window.location.hostname + (window.location.port ? ':' + window.location.port : ''); } - piSetValue("origin", window.origin); // todo check if this is correct (window.location.origin) + piSetValue("origin", window.origin); try { diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 64ce1d6..9cb3402 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -11,15 +11,19 @@ class Application extends App implements IBootstrap { - public const APP_ID = 'privacyidea'; + public const APP_ID = 'privacyidea'; - /** @psalm-suppress PossiblyUnusedMethod */ - public function __construct() - { - parent::__construct(self::APP_ID); - } + /** @psalm-suppress PossiblyUnusedMethod */ + public function __construct() + { + parent::__construct(self::APP_ID); + } - public function register(IRegistrationContext $context): void {} + public function register(IRegistrationContext $context): void + { + } - public function boot(IBootContext $context): void {} -} \ No newline at end of file + public function boot(IBootContext $context): void + { + } +} diff --git a/lib/Controller/SettingsController.php b/lib/Controller/SettingsController.php index e073127..04d00ae 100644 --- a/lib/Controller/SettingsController.php +++ b/lib/Controller/SettingsController.php @@ -12,50 +12,50 @@ class SettingsController extends Controller { - /** @var IL10N Translation */ - private $trans; - /** @var IConfig Configuration object */ - private IConfig $config; - private IAppConfig $appConfig; + /** @var IL10N Translation */ + private $trans; + /** @var IConfig Configuration object */ + private IConfig $config; + private IAppConfig $appConfig; - /** - * @param string $appName - * @param IRequest $request - * @param IL10N $trans - * @param IConfig $config - * @param IAppConfig $appConfig - */ - public function __construct(string $appName, - IRequest $request, - IL10N $trans, - IConfig $config, - IAppConfig $appConfig) - { - parent::__construct($appName, $request); - $this->trans = $trans; - $this->config = $config; - $this->appConfig = $appConfig; - } + /** + * @param string $appName + * @param IRequest $request + * @param IL10N $trans + * @param IConfig $config + * @param IAppConfig $appConfig + */ + public function __construct(string $appName, + IRequest $request, + IL10N $trans, + IConfig $config, + IAppConfig $appConfig) + { + parent::__construct($appName, $request); + $this->trans = $trans; + $this->config = $config; + $this->appConfig = $appConfig; + } - /** - * Set a configuration value in the privacyIDEA app config. - * - * @param string $key configuration key - * @param string $value configuration value - */ - public function setValue(string $key, string $value): void - { - $this->appConfig->setValueString("privacyidea", $key, $value); - } + /** + * Set a configuration value in the privacyIDEA app config. + * + * @param string $key configuration key + * @param string $value configuration value + */ + public function setValue(string $key, string $value): void + { + $this->appConfig->setValueString('privacyidea', $key, $value); + } - /** - * Retrieve a configuration from the privacyIDEA app config. - * - * @param string $key configuration key - * @return string - */ - public function getValue(string $key): string - { - return $this->appConfig->getValueString("privacyidea", $key); - } -} \ No newline at end of file + /** + * Retrieve a configuration from the privacyIDEA app config. + * + * @param string $key configuration key + * @return string + */ + public function getValue(string $key): string + { + return $this->appConfig->getValueString('privacyidea', $key); + } +} diff --git a/lib/PIClient/AuthenticationStatus.php b/lib/PIClient/AuthenticationStatus.php index 1acd469..e207a0e 100644 --- a/lib/PIClient/AuthenticationStatus.php +++ b/lib/PIClient/AuthenticationStatus.php @@ -16,8 +16,8 @@ abstract class AuthenticationStatus { - const CHALLENGE = "CHALLENGE"; - const ACCEPT = "ACCEPT"; - const REJECT = "REJECT"; - const NONE = "NONE"; -} \ No newline at end of file + public const CHALLENGE = 'CHALLENGE'; + public const ACCEPT = 'ACCEPT'; + public const REJECT = 'REJECT'; + public const NONE = 'NONE'; +} diff --git a/lib/PIClient/PIBadRequestException.php b/lib/PIClient/PIBadRequestException.php index 4acab6e..f61d7b5 100644 --- a/lib/PIClient/PIBadRequestException.php +++ b/lib/PIClient/PIBadRequestException.php @@ -18,4 +18,4 @@ class PIBadRequestException extends Exception { -} \ No newline at end of file +} diff --git a/lib/PIClient/PIChallenge.php b/lib/PIClient/PIChallenge.php index c3cb5c4..94f0d94 100644 --- a/lib/PIClient/PIChallenge.php +++ b/lib/PIClient/PIChallenge.php @@ -16,27 +16,27 @@ class PIChallenge { - /* @var string Type of token this challenge is for. */ - public string $type = ""; + /* @var string Type of token this challenge is for. */ + public string $type = ''; - /* @var string Message extracted from this challenge. */ - public string $message = ""; + /* @var string Message extracted from this challenge. */ + public string $message = ''; - /* @var string Image data extracted from this challenge. */ - public string $image = ""; + /* @var string Image data extracted from this challenge. */ + public string $image = ''; - /* @var string TransactionId to reference this challenge in later requests. */ - public string $transactionID = ""; + /* @var string TransactionId to reference this challenge in later requests. */ + public string $transactionID = ''; - /* @var string Client mode in which the challenge should be processed. */ - public string $clientMode = ""; + /* @var string Client mode in which the challenge should be processed. */ + public string $clientMode = ''; - /* @var string Serial of token this challenge is for. */ - public string $serial = ""; + /* @var string Serial of token this challenge is for. */ + public string $serial = ''; - /* @var array Arbitrary attributes that can be appended to the challenge by the server. */ - public array $attributes = array(); + /* @var array Arbitrary attributes that can be appended to the challenge by the server. */ + public array $attributes = []; - /* @var string WebAuthn sign request in JSON format */ - public string $webAuthnSignRequest = ""; -} \ No newline at end of file + /* @var string WebAuthn sign request in JSON format */ + public string $webAuthnSignRequest = ''; +} diff --git a/lib/PIClient/PIResponse.php b/lib/PIClient/PIResponse.php index 96f7c2d..a1a6668 100644 --- a/lib/PIClient/PIResponse.php +++ b/lib/PIClient/PIResponse.php @@ -16,344 +16,305 @@ class PIResponse { - /* @var string Combined messages of all triggered token. */ - private string $messages = ""; - - /* @var string Message from the response. Should be shown to the user. */ - private string $message = ""; - - /* @var string Transaction ID is used to reference the challenges contained in this response in later requests. */ - private string $transactionID = ""; - - /* @var string Preferred mode in which client should work after triggering challenges. */ - private string $preferredClientMode = ""; - - /* @var string Raw response in JSON format. */ - private string $raw = ""; - - /* @var array Array of PIChallenge objects representing the triggered token challenges. */ - private array $multiChallenge = array(); - - /* @var bool Status indicates if the request was processed successfully by the server. */ - private bool $status = false; - - /* @var bool Value is true if the authentication was successful. */ - private bool $value = false; - - /* @var string Authentication Status. */ - private string $authenticationStatus = ""; - - /* @var string If an error occurred, the error code will be set here. */ - private string $errorCode = ""; - - /* @var string If an error occurred, the error message will be set here. */ - private string $errorMessage = ""; - - /** - * Create a PIResponse object from the JSON response of the server. - * - * @param string $json Server response in JSON format. - * @param PrivacyIDEA $privacyIDEA PrivacyIDEA object. - * @return PIResponse|null Returns the PIResponse object or null if the response of the server is empty or malformed. - */ - public static function fromJSON(string $json, PrivacyIDEA $privacyIDEA): ?PIResponse - { - assert('string' === gettype($json)); - - if ($json == null || $json == "") - { - $privacyIDEA->log("error", "Response from the server is empty."); - return null; - } - - $ret = new PIResponse(); - $map = json_decode($json, true); - if ($map == null) - { - $privacyIDEA->log("error", "Response from the server is malformed:\n" . $json); - return null; - } - $ret->raw = $json; - - // If value is not present, an error occurred - if (!isset($map['result']['value'])) - { - $ret->errorCode = $map['result']['error']['code']; - $ret->errorMessage = $map['result']['error']['message']; - return $ret; - } - - if (isset($map['detail']['messages'])) - { - $ret->messages = implode(", ", array_unique($map['detail']['messages'])) ?: ""; - } - if (isset($map['detail']['message'])) - { - $ret->message = $map['detail']['message']; - } - if (isset($map['detail']['transaction_id'])) - { - $ret->transactionID = $map['detail']['transaction_id']; - } - if (isset($map['detail']['preferred_client_mode'])) - { - $pref = $map['detail']['preferred_client_mode']; - if ($pref === "poll") - { - $ret->preferredClientMode = "push"; - } - elseif ($pref === "interactive") - { - $ret->preferredClientMode = "otp"; - } - else - { - $ret->preferredClientMode = $map['detail']['preferred_client_mode']; - } - } - - // Check if the authentication status is legit - $r = null; - if (!empty($map['result']['authentication'])) - { - $r = $map['result']['authentication']; - } - if ($r === AuthenticationStatus::CHALLENGE) - { - $ret->authenticationStatus = AuthenticationStatus::CHALLENGE; - } - elseif ($r === AuthenticationStatus::ACCEPT) - { - $ret->authenticationStatus = AuthenticationStatus::ACCEPT; - } - elseif ($r === AuthenticationStatus::REJECT) - { - $ret->authenticationStatus = AuthenticationStatus::REJECT; - } - else - { - $privacyIDEA->log("debug", "Unknown authentication status."); - $ret->authenticationStatus = AuthenticationStatus::NONE; - } - $ret->status = $map['result']['status'] ?: false; - $ret->value = $map['result']['value'] ?: false; - - // Add any challenges to multiChallenge - if (isset($map['detail']['multi_challenge'])) - { - $mc = $map['detail']['multi_challenge']; - foreach ($mc as $challenge) - { - $tmp = new PIChallenge(); - $tmp->transactionID = $challenge['transaction_id']; - $tmp->message = $challenge['message']; - $tmp->serial = $challenge['serial']; - $tmp->type = $challenge['type']; - if (isset($challenge['image'])) - { - $tmp->image = $challenge['image']; - } - if (isset($challenge['attributes'])) - { - $tmp->attributes = $challenge['attributes']; - } - if (isset($challenge['client_mode'])) - { - $tmp->clientMode = $challenge['client_mode']; - } - - if ($tmp->type === "webauthn") - { - $t = $challenge['attributes']['webAuthnSignRequest']; - $tmp->webAuthnSignRequest = json_encode($t); - } - - $ret->multiChallenge[] = $tmp; - } - } - return $ret; - } - - // Getters - - /** - * Get an array with all triggered token types. - * @return array - */ - public function getTriggeredTokenTypes(): array - { - $ret = array(); - foreach ($this->multiChallenge as $challenge) - { - $ret[] = $challenge->type; - } - return array_unique($ret); - } - - /** - * Get the message of any token that is not Push or WebAuthn. Those are OTP tokens requiring an input field. - * @return string - */ - public function getOtpMessage(): string - { - foreach ($this->multiChallenge as $challenge) - { - if ($challenge->type !== "push" && $challenge->type !== "webauthn") - { - return $challenge->message; - } - } - return ""; - } - - /** - * Get the Push token message if any were triggered. - * @return string - */ - public function getPushMessage(): string - { - foreach ($this->multiChallenge as $challenge) - { - if ($challenge->type === "push") - { - return $challenge->message; - } - } - return ""; - } - - /** - * @return string Combined messages of all triggered token. - */ - public function getMessages(): string - { - return $this->messages; - } - - /** - * @return string Message from the response. Should be shown to the user. - */ - public function getMessage(): string - { - return $this->message; - } - - /** - * @return string Transaction ID is used to reference the challenges contained in this response in later requests. - */ - public function getTransactionID(): string - { - return $this->transactionID; - } - - /** - * @return string Preferred mode in which client should work after triggering challenges. - */ - public function getPreferredClientMode(): string - { - return $this->preferredClientMode; - } - - /** - * @return string Raw response in JSON format. - */ - public function getRawResponse(): string - { - return $this->raw; - } - - /** - * Get the WebAuthnSignRequest for any triggered WebAuthn token. - * @return string WebAuthnSignRequest or empty string if no WebAuthn token was triggered. - */ - public function getWebauthnSignRequest(): string - { - $arr = []; - $webauthn = ""; - foreach ($this->multiChallenge as $challenge) - { - if ($challenge->type === "webauthn") - { - $t = json_decode($challenge->webAuthnSignRequest); - if (empty($webauthn)) - { - $webauthn = $t; - } - $arr[] = $challenge->attributes['webAuthnSignRequest']['allowCredentials'][0]; - } - } - if (empty($webauthn)) - { - return ""; - } - else - { - $webauthn->allowCredentials = $arr; - return json_encode($webauthn); - } - } - - /** - * Get the WebAuthn token message if any were triggered. - * @return string - */ - public function getWebauthnMessage(): string - { - foreach ($this->multiChallenge as $challenge) - { - if ($challenge->type === "webauthn") - { - return $challenge->message; - } - } - return ""; - } - - /** - * @return array Array of PIChallenge objects representing the triggered token challenges. - */ - public function getMultiChallenge(): array - { - return $this->multiChallenge; - } - - /** - * @return bool Status indicates if the request was processed successfully by the server. - */ - public function getStatus(): bool - { - return $this->status; - } - - /** - * @return bool Value is true if the authentication was successful. - */ - public function getValue(): bool - { - return $this->value; - } - - /** - * @return string Authentication Status. - */ - public function getAuthenticationStatus(): string - { - return $this->authenticationStatus; - } - - /** - * @return string If an error occurred, the error code will be set here. - */ - public function getErrorCode(): string - { - return $this->errorCode; - } - - /** - * @return string If an error occurred, the error message will be set here. - */ - public function getErrorMessage(): string - { - return $this->errorMessage; - } -} \ No newline at end of file + /* @var string Combined messages of all triggered token. */ + private string $messages = ''; + + /* @var string Message from the response. Should be shown to the user. */ + private string $message = ''; + + /* @var string Transaction ID is used to reference the challenges contained in this response in later requests. */ + private string $transactionID = ''; + + /* @var string Preferred mode in which client should work after triggering challenges. */ + private string $preferredClientMode = ''; + + /* @var string Raw response in JSON format. */ + private string $raw = ''; + + /* @var array Array of PIChallenge objects representing the triggered token challenges. */ + private array $multiChallenge = []; + + /* @var bool Status indicates if the request was processed successfully by the server. */ + private bool $status = false; + + /* @var bool Value is true if the authentication was successful. */ + private bool $value = false; + + /* @var string Authentication Status. */ + private string $authenticationStatus = ''; + + /* @var string If an error occurred, the error code will be set here. */ + private string $errorCode = ''; + + /* @var string If an error occurred, the error message will be set here. */ + private string $errorMessage = ''; + + /** + * Create a PIResponse object from the JSON response of the server. + * + * @param string $json Server response in JSON format. + * @param PrivacyIDEA $privacyIDEA PrivacyIDEA object. + * @return PIResponse|null Returns the PIResponse object or null if the response of the server is empty or malformed. + */ + public static function fromJSON(string $json, PrivacyIDEA $privacyIDEA): ?PIResponse + { + assert(gettype($json) === 'string'); + + if ($json == null || $json == '') { + $privacyIDEA->log('error', 'Response from the server is empty.'); + return null; + } + + $ret = new PIResponse(); + $map = json_decode($json, true); + if ($map == null) { + $privacyIDEA->log('error', "Response from the server is malformed:\n" . $json); + return null; + } + $ret->raw = $json; + + // If value is not present, an error occurred + if (!isset($map['result']['value'])) { + $ret->errorCode = $map['result']['error']['code']; + $ret->errorMessage = $map['result']['error']['message']; + return $ret; + } + + if (isset($map['detail']['messages'])) { + $ret->messages = implode(', ', array_unique($map['detail']['messages'])) ?: ''; + } + if (isset($map['detail']['message'])) { + $ret->message = $map['detail']['message']; + } + if (isset($map['detail']['transaction_id'])) { + $ret->transactionID = $map['detail']['transaction_id']; + } + if (isset($map['detail']['preferred_client_mode'])) { + $pref = $map['detail']['preferred_client_mode']; + if ($pref === 'poll') { + $ret->preferredClientMode = 'push'; + } elseif ($pref === 'interactive') { + $ret->preferredClientMode = 'otp'; + } else { + $ret->preferredClientMode = $map['detail']['preferred_client_mode']; + } + } + + // Check if the authentication status is legit + $r = null; + if (!empty($map['result']['authentication'])) { + $r = $map['result']['authentication']; + } + if ($r === AuthenticationStatus::CHALLENGE) { + $ret->authenticationStatus = AuthenticationStatus::CHALLENGE; + } elseif ($r === AuthenticationStatus::ACCEPT) { + $ret->authenticationStatus = AuthenticationStatus::ACCEPT; + } elseif ($r === AuthenticationStatus::REJECT) { + $ret->authenticationStatus = AuthenticationStatus::REJECT; + } else { + $privacyIDEA->log('debug', 'Unknown authentication status.'); + $ret->authenticationStatus = AuthenticationStatus::NONE; + } + $ret->status = $map['result']['status'] ?: false; + $ret->value = $map['result']['value'] ?: false; + + // Add any challenges to multiChallenge + if (isset($map['detail']['multi_challenge'])) { + $mc = $map['detail']['multi_challenge']; + foreach ($mc as $challenge) { + $tmp = new PIChallenge(); + $tmp->transactionID = $challenge['transaction_id']; + $tmp->message = $challenge['message']; + $tmp->serial = $challenge['serial']; + $tmp->type = $challenge['type']; + if (isset($challenge['image'])) { + $tmp->image = $challenge['image']; + } + if (isset($challenge['attributes'])) { + $tmp->attributes = $challenge['attributes']; + } + if (isset($challenge['client_mode'])) { + $tmp->clientMode = $challenge['client_mode']; + } + + if ($tmp->type === 'webauthn') { + $t = $challenge['attributes']['webAuthnSignRequest']; + $tmp->webAuthnSignRequest = json_encode($t); + } + + $ret->multiChallenge[] = $tmp; + } + } + return $ret; + } + + // Getters + + /** + * Get an array with all triggered token types. + * @return array + */ + public function getTriggeredTokenTypes(): array + { + $ret = []; + foreach ($this->multiChallenge as $challenge) { + $ret[] = $challenge->type; + } + return array_unique($ret); + } + + /** + * Get the message of any token that is not Push or WebAuthn. Those are OTP tokens requiring an input field. + * @return string + */ + public function getOtpMessage(): string + { + foreach ($this->multiChallenge as $challenge) { + if ($challenge->type !== 'push' && $challenge->type !== 'webauthn') { + return $challenge->message; + } + } + return ''; + } + + /** + * Get the Push token message if any were triggered. + * @return string + */ + public function getPushMessage(): string + { + foreach ($this->multiChallenge as $challenge) { + if ($challenge->type === 'push') { + return $challenge->message; + } + } + return ''; + } + + /** + * @return string Combined messages of all triggered token. + */ + public function getMessages(): string + { + return $this->messages; + } + + /** + * @return string Message from the response. Should be shown to the user. + */ + public function getMessage(): string + { + return $this->message; + } + + /** + * @return string Transaction ID is used to reference the challenges contained in this response in later requests. + */ + public function getTransactionID(): string + { + return $this->transactionID; + } + + /** + * @return string Preferred mode in which client should work after triggering challenges. + */ + public function getPreferredClientMode(): string + { + return $this->preferredClientMode; + } + + /** + * @return string Raw response in JSON format. + */ + public function getRawResponse(): string + { + return $this->raw; + } + + /** + * Get the WebAuthnSignRequest for any triggered WebAuthn token. + * @return string WebAuthnSignRequest or empty string if no WebAuthn token was triggered. + */ + public function getWebauthnSignRequest(): string + { + $arr = []; + $webauthn = ''; + foreach ($this->multiChallenge as $challenge) { + if ($challenge->type === 'webauthn') { + $t = json_decode($challenge->webAuthnSignRequest); + if (empty($webauthn)) { + $webauthn = $t; + } + $arr[] = $challenge->attributes['webAuthnSignRequest']['allowCredentials'][0]; + } + } + if (empty($webauthn)) { + return ''; + } else { + $webauthn->allowCredentials = $arr; + return json_encode($webauthn); + } + } + + /** + * Get the WebAuthn token message if any were triggered. + * @return string + */ + public function getWebauthnMessage(): string + { + foreach ($this->multiChallenge as $challenge) { + if ($challenge->type === 'webauthn') { + return $challenge->message; + } + } + return ''; + } + + /** + * @return array Array of PIChallenge objects representing the triggered token challenges. + */ + public function getMultiChallenge(): array + { + return $this->multiChallenge; + } + + /** + * @return bool Status indicates if the request was processed successfully by the server. + */ + public function getStatus(): bool + { + return $this->status; + } + + /** + * @return bool Value is true if the authentication was successful. + */ + public function getValue(): bool + { + return $this->value; + } + + /** + * @return string Authentication Status. + */ + public function getAuthenticationStatus(): string + { + return $this->authenticationStatus; + } + + /** + * @return string If an error occurred, the error code will be set here. + */ + public function getErrorCode(): string + { + return $this->errorCode; + } + + /** + * @return string If an error occurred, the error message will be set here. + */ + public function getErrorMessage(): string + { + return $this->errorMessage; + } +} diff --git a/lib/PIClient/PrivacyIDEA.php b/lib/PIClient/PrivacyIDEA.php index 8f6bc87..3f4e740 100644 --- a/lib/PIClient/PrivacyIDEA.php +++ b/lib/PIClient/PrivacyIDEA.php @@ -17,12 +17,12 @@ use RecursiveArrayIterator; use RecursiveIteratorIterator; -const AUTHENTICATORDATA = "authenticatordata"; -const CLIENTDATA = "clientdata"; -const SIGNATUREDATA = "signaturedata"; -const CREDENTIALID = "credentialid"; -const USERHANDLE = "userhandle"; -const ASSERTIONCLIENTEXTENSIONS = "assertionclientextensions"; +const AUTHENTICATORDATA = 'authenticatordata'; +const CLIENTDATA = 'clientdata'; +const SIGNATUREDATA = 'signaturedata'; +const CREDENTIALID = 'credentialid'; +const USERHANDLE = 'userhandle'; +const ASSERTIONCLIENTEXTENSIONS = 'assertionclientextensions'; /** * PHP client to aid develop plugins for the privacyIDEA authentication server. @@ -32,551 +32,485 @@ */ class PrivacyIDEA { - /* @var string User agent name which should be forwarded to the privacyIDEA server. */ - private string $userAgent; - - /* @var string URL of the privacyIDEA server. */ - private string $serverURL; - - /* @var string User's realm. */ - private string $realm = ""; - - /* @var bool Disable host verification for SSL. */ - private bool $sslVerifyHost = true; - - /* @var bool Disable peer verification for SSL. */ - private bool $sslVerifyPeer = true; - - /* @var string Account name for privacyIDEA service account. Required to use the /validate/triggerchallenge endpoint. */ - private string $serviceAccountName = ""; - - /* @var string Password for privacyIDEA service account. Required to use the /validate/triggerchallenge endpoint. */ - private string $serviceAccountPass = ""; - - /* @var string Realm for privacyIDEA service account. Optional to use the /validate/triggerchallenge endpoint. */ - private string $serviceAccountRealm = ""; - - /* @var bool Send the "client" parameter to allow using the original IP address in the privacyIDEA policies. */ - private bool $forwardClientIP = false; - - /* @var string Timeout for the request. */ - private string $timeout = "5"; - - /* @var bool Ignore the system-wide proxy settings and send the authentication requests directly to privacyIDEA. */ - private bool $noProxy = false; - - /* @var object|null Implementation of the PILog interface. */ - private ?object $logger = null; - - /** - * PrivacyIDEA constructor. - * @param $userAgent string User agent. - * @param $serverURL string privacyIDEA server URL. - */ - public function __construct(string $userAgent, string $serverURL) - { - $this->userAgent = $userAgent; - $this->serverURL = $serverURL; - } - - /** - * Try to authenticate the user by the /validate/check endpoint. - * - * @param string $username Username to authenticate. - * @param string $pass This can be the OTP, but also the PIN to trigger a token or PIN+OTP depending on the configuration of the server. - * @param string|null $transactionID Optional transaction ID. Used to reference a challenge that was triggered beforehand. - * @param array|null $headers Optional headers to forward to the server. - * @return PIResponse|null Returns PIResponse object or null if response was empty or malformed, or some parameter is missing. - * @throws PIBadRequestException If an error occurs during the request. - */ - public function validateCheck(string $username, string $pass, string $transactionID = null, array $headers = null): ?PIResponse - { - assert('string' === gettype($username)); - assert('string' === gettype($pass)); - - if (!empty($username)) - { - $params["user"] = $username; - $params["pass"] = $pass; - if (!empty($transactionID)) - { - // Add transaction ID in case of challenge response - $params["transaction_id"] = $transactionID; - } - if (empty($headers)) - { - $headers = array(''); - } - if (!empty($this->realm)) - { - $params["realm"] = $this->realm; - } - - $response = $this->sendRequest($params, $headers, 'POST', '/validate/check'); - - $ret = PIResponse::fromJSON($response, $this); - if ($ret == null) - { - $this->log("debug", "Server did not respond."); - } - return $ret; - } - else - { - $this->log("debug", "Missing username for /validate/check."); - } - return null; - } - - /** - * Trigger all challenges for the given username. - * This function requires a service account to be set. - * - * @param string $username Username for which the challenges should be triggered. - * @param array|null $headers Optional headers to forward to the server. - * @return PIResponse|null Returns PIResponse object or null if response was empty or malformed, or some parameter is missing. - * @throws PIBadRequestException If an error occurs during the request. - */ - public function triggerChallenge(string $username, array $headers = null): ?PIResponse - { - assert('string' === gettype($username)); - - if ($username) - { - $authToken = $this->getAuthToken(); - $authTokenHeader = array("authorization:" . $authToken); - - $params = array("user" => $username); - - if (!empty($this->realm)) - { - $params["realm"] = $this->realm; - } - - if (!empty($headers)) - { - $headers = array_merge($headers, $authTokenHeader); - } - else - { - $headers = $authTokenHeader; - } - - $response = $this->sendRequest($params, $headers, 'POST', '/validate/triggerchallenge'); - - return PIResponse::fromJSON($response, $this); - } - else - { - $this->log("debug", "Username missing!"); - } - return null; - } - - /** - * Poll for the transaction status. - * - * @param $transactionID string Transaction ID of the triggered challenge. - * @param array|null $headers Optional headers to forward to the server. - * @return bool True if the push request has been accepted, false otherwise. - * @throws PIBadRequestException If an error occurs during the request. - */ - public function pollTransaction(string $transactionID, array $headers = null): bool - { - assert('string' === gettype($transactionID)); - - if (!empty($transactionID)) - { - $params = array("transaction_id" => $transactionID); - if (empty($headers)) - { - $headers = array(''); - } - - $responseJSON = $this->sendRequest($params, $headers, 'GET', '/validate/polltransaction'); - $response = json_decode($responseJSON, true); - return $response['result']['value']; - } - else - { - $this->log("debug", "TransactionID missing!"); - } - return false; - } - - /** - * Send request to /validate/check endpoint with the data required to authenticate using WebAuthn token. - * - * @param string $username Username to authenticate. - * @param string $transactionID Transaction ID of the triggered challenge. - * @param string $webAuthnSignResponse WebAuthn sign response. - * @param string $origin Origin required to authenticate using WebAuthn token. - * @param array|null $headers Optional headers to forward to the server. - * @return PIResponse|null Returns PIResponse object or null if response was empty or malformed, or some parameter is missing. - * @throws PIBadRequestException If an error occurs during the request. - */ - public function validateCheckWebAuthn(string $username, string $transactionID, string $webAuthnSignResponse, string $origin, array $headers = null): ?PIResponse - { - assert('string' === gettype($username)); - assert('string' === gettype($transactionID)); - assert('string' === gettype($webAuthnSignResponse)); - assert('string' === gettype($origin)); - - if (!empty($username) && !empty($transactionID) && !empty($webAuthnSignResponse) && !empty($origin)) - { - // Compose standard validate/check params - $params["user"] = $username; - $params["pass"] = ""; - $params["transaction_id"] = $transactionID; - - if (!empty($this->realm)) - { - $params["realm"] = $this->realm; - } - - // Additional WebAuthn params - $tmp = json_decode($webAuthnSignResponse, true); - - $params[CREDENTIALID] = $tmp[CREDENTIALID]; - $params[CLIENTDATA] = $tmp[CLIENTDATA]; - $params[SIGNATUREDATA] = $tmp[SIGNATUREDATA]; - $params[AUTHENTICATORDATA] = $tmp[AUTHENTICATORDATA]; - - if (!empty($tmp[USERHANDLE])) - { - $params[USERHANDLE] = $tmp[USERHANDLE]; - } - if (!empty($tmp[ASSERTIONCLIENTEXTENSIONS])) - { - $params[ASSERTIONCLIENTEXTENSIONS] = $tmp[ASSERTIONCLIENTEXTENSIONS]; - } - - $originHeader = array("Origin:" . $origin); - if (!empty($headers)) - { - $headers = array_merge($headers, $originHeader); - } - else - { - $headers = $originHeader; - } - - $response = $this->sendRequest($params, $headers, 'POST', '/validate/check'); - - return PIResponse::fromJSON($response, $this); - } - else - { - // Handle debug message if $username is empty - $this->log("debug", "validateCheckWebAuthn: parameters are incomplete!"); - } - return null; - } - - /** - * Check if name and pass of service account are set. - * @return bool - */ - public function serviceAccountAvailable(): bool - { - return (!empty($this->serviceAccountName) && !empty($this->serviceAccountPass)); - } - - /** - * Retrieves the auth token from the server using the service account. An auth token is required for some requests to the privacyIDEA. - * - * @return string Auth token or empty string if the response did not contain a token or no service account is configured. - * @throws PIBadRequestException If an error occurs during the request. - */ - public function getAuthToken(): string - { - if (!$this->serviceAccountAvailable()) - { - $this->log("error", "Cannot retrieve auth token without service account!"); - return ""; - } - - $params = array("username" => $this->serviceAccountName, "password" => $this->serviceAccountPass); - if ($this->serviceAccountRealm != null && $this->serviceAccountRealm != "") - { - $params["realm"] = $this->serviceAccountRealm; - } - - $response = json_decode($this->sendRequest($params, array(''), 'POST', '/auth'), true); - - if (!empty($response['result']['value'])) - { - // Ensure an admin account - if (!empty($response['result']['value']['token'])) - { - if ($this->findRecursive($response, 'role') != 'admin') - { - $this->log("debug", "Auth token was of a user without admin role."); - return ""; - } - return $response['result']['value']['token']; - } - } - $this->log("debug", "/auth response did not contain the auth token."); - return ""; - } - - /** - * Find key recursively in array. - * - * @param array $haystack The array which will be searched. - * @param string $needle Search string. - * @return mixed Result of key search. - */ - public function findRecursive(array $haystack, string $needle): mixed - { - assert(is_array($haystack)); - assert(is_string($needle)); - - $iterator = new RecursiveArrayIterator($haystack); - $recursive = new RecursiveIteratorIterator($iterator, RecursiveIteratorIterator::SELF_FIRST); - - foreach ($recursive as $key => $value) - { - if ($key === $needle) - { - return $value; - } - } - return false; - } - - /** - * Send requests to the endpoint with specified parameters and headers. - * - * @param $params array Request parameters. - * @param $headers array Headers to forward. - * @param $httpMethod string GET or POST. - * @param $endpoint string Endpoint of the privacyIDEA API (e.g. /validate/check). - * @return string Returns a string with the server response. - * @throws PIBadRequestException If an error occurs. - */ - private function sendRequest(array $params, array $headers, string $httpMethod, string $endpoint): string - { - assert('array' === gettype($params)); - assert('array' === gettype($headers)); - assert('string' === gettype($httpMethod)); - assert('string' === gettype($endpoint)); - - // Add the client parameter if wished. - if ($this->forwardClientIP === true) - { - $serverHeaders = $_SERVER; - foreach (array("X-Forwarded-For", "HTTP_X_FORWARDED_FOR", "REMOTE_ADDR") as $clientKey) - { - if (array_key_exists($clientKey, $serverHeaders)) - { - $clientIP = $serverHeaders[$clientKey]; - $this->log("debug", "Forwarding Client IP: " . $clientKey . ": " . $clientIP); - $params['client'] = $clientIP; - break; - } - } - } - - // Ignore proxy settings if wished. - if ($this->noProxy === true) - { - $this->log("debug", "Ignoring proxy settings."); - $params["proxy"] = ["https" => "", "http" => ""]; - } - - // Set request's timeout - $params["timeout"] = $this->timeout; - - $this->log("debug", "Sending " . http_build_query($params, '', ', ') . " to " . $endpoint); - - $completeUrl = $this->serverURL . $endpoint; - - $curlInstance = curl_init(); - curl_setopt($curlInstance, CURLOPT_URL, $completeUrl); - curl_setopt($curlInstance, CURLOPT_HEADER, true); - if ($headers) - { - curl_setopt($curlInstance, CURLOPT_HTTPHEADER, $headers); - } - curl_setopt($curlInstance, CURLOPT_RETURNTRANSFER, true); - curl_setopt($curlInstance, CURLOPT_USERAGENT, $this->userAgent); - if ($httpMethod === "POST") - { - curl_setopt($curlInstance, CURLOPT_POST, true); - curl_setopt($curlInstance, CURLOPT_POSTFIELDS, $params); - } - elseif ($httpMethod === "PUT") - { - curl_setopt($curlInstance, CURLOPT_CUSTOMREQUEST, "PUT"); - curl_setopt($curlInstance, CURLOPT_POSTFIELDS, $params); - } - elseif ($httpMethod === "DELETE") - { - curl_setopt($curlInstance, CURLOPT_CUSTOMREQUEST, "DELETE"); - curl_setopt($curlInstance, CURLOPT_POSTFIELDS, $params); - } - elseif ($httpMethod === "GET") - { - $paramsStr = '?'; - if (!empty($params)) - { - foreach ($params as $key => $value) - { - $paramsStr .= $key . "=" . $value . "&"; - } - } - curl_setopt($curlInstance, CURLOPT_URL, $completeUrl . $paramsStr); - } - - // Disable host and/or peer verification for SSL if configured. - if ($this->sslVerifyHost === true) - { - curl_setopt($curlInstance, CURLOPT_SSL_VERIFYHOST, 2); - } - else - { - curl_setopt($curlInstance, CURLOPT_SSL_VERIFYHOST, 0); - } - - if ($this->sslVerifyPeer === true) - { - curl_setopt($curlInstance, CURLOPT_SSL_VERIFYPEER, 2); - } - else - { - curl_setopt($curlInstance, CURLOPT_SSL_VERIFYPEER, 0); - } - - $response = curl_exec($curlInstance); - - if (!$response) - { - // Handle the error - $curlErrno = curl_errno($curlInstance); - $this->log("error", "Bad request: " . curl_error($curlInstance) . " errno: " . $curlErrno); - throw new PIBadRequestException("Unable to reach the authentication server (" . $curlErrno . ")"); - } - - $headerSize = curl_getinfo($curlInstance, CURLINFO_HEADER_SIZE); - $ret = substr($response, $headerSize); - curl_close($curlInstance); - - // Log the response - if ($endpoint != "/auth" && $this->logger != null) - { - $retJson = json_decode($ret, true); - $this->log("debug", $endpoint . " returned " . json_encode($retJson, JSON_PRETTY_PRINT)); - } - - // Return decoded response - return $ret; - } - - /** - * Log a message with the given log level. - * - * @param $level - * @param $message - */ - function log($level, $message): void - { - $context = ["app" => "privacyIDEA"]; - if ($level === 'debug') - { - $this->logger->debug($message, $context); - } - if ($level === 'info') - { - $this->logger->info($message, $context); - } - if ($level === 'error') - { - $this->logger->error($message, $context); - } - } - - // Setters - - /** - * @param string $realm User's realm. - * @return void - */ - public function setRealm(string $realm): void - { - $this->realm = $realm; - } - - /** - * @param bool $sslVerifyHost Disable host verification for SSL. - * @return void - */ - public function setSSLVerifyHost(bool $sslVerifyHost): void - { - $this->sslVerifyHost = $sslVerifyHost; - } - - /** - * @param bool $sslVerifyPeer Disable peer verification for SSL. - * @return void - */ - public function setSSLVerifyPeer(bool $sslVerifyPeer): void - { - $this->sslVerifyPeer = $sslVerifyPeer; - } - - /** - * @param string $serviceAccountName Account name for privacyIDEA service account. Required to use the /validate/triggerchallenge endpoint. - * @return void - */ - public function setServiceAccountName(string $serviceAccountName): void - { - $this->serviceAccountName = $serviceAccountName; - } - - /** - * @param string $serviceAccountPass Password for privacyIDEA service account. Required to use the /validate/triggerchallenge endpoint. - * @return void - */ - public function setServiceAccountPass(string $serviceAccountPass): void - { - $this->serviceAccountPass = $serviceAccountPass; - } - - /** - * @param string $serviceAccountRealm Realm for privacyIDEA service account. Optional to use the /validate/triggerchallenge endpoint. - * @return void - */ - public function setServiceAccountRealm(string $serviceAccountRealm): void - { - $this->serviceAccountRealm = $serviceAccountRealm; - } - - /** - * @param bool $forwardClientIP Send the "client" parameter to allow using the original IP address in the privacyIDEA policies. - * @return void - */ - public function setForwardClientIP(bool $forwardClientIP): void - { - $this->forwardClientIP = $forwardClientIP; - } - - /** - * @param bool $noProxy Ignore the system-wide proxy settings and send the authentication requests directly to privacyIDEA. - * @return void - */ - public function setNoProxy(bool $noProxy): void - { - $this->$noProxy = $noProxy; - } - - /** - * @param object|null $logger Implementation of the PILog interface. - * @return void - */ - public function setLogger(?object $logger): void - { - $this->logger = $logger; - } -} \ No newline at end of file + /* @var string User agent name which should be forwarded to the privacyIDEA server. */ + private string $userAgent; + + /* @var string URL of the privacyIDEA server. */ + private string $serverURL; + + /* @var string User's realm. */ + private string $realm = ''; + + /* @var bool Disable host verification for SSL. */ + private bool $sslVerifyHost = true; + + /* @var bool Disable peer verification for SSL. */ + private bool $sslVerifyPeer = true; + + /* @var string Account name for privacyIDEA service account. Required to use the /validate/triggerchallenge endpoint. */ + private string $serviceAccountName = ''; + + /* @var string Password for privacyIDEA service account. Required to use the /validate/triggerchallenge endpoint. */ + private string $serviceAccountPass = ''; + + /* @var string Realm for privacyIDEA service account. Optional to use the /validate/triggerchallenge endpoint. */ + private string $serviceAccountRealm = ''; + + /* @var string Send the "client" parameter to allow using the original IP address in the privacyIDEA policies. */ + private string $forwardClientIP = ''; + + /* @var string Timeout for the request. */ + private string $timeout = '5'; + + /* @var bool Ignore the system-wide proxy settings and send the authentication requests directly to privacyIDEA. */ + private bool $noProxy = false; + + /* @var object|null Implementation of the PILog interface. */ + private ?object $logger = null; + + /** + * PrivacyIDEA constructor. + * @param $userAgent string User agent. + * @param $serverURL string privacyIDEA server URL. + */ + public function __construct(string $userAgent, string $serverURL) + { + $this->userAgent = $userAgent; + $this->serverURL = $serverURL; + } + + /** + * Try to authenticate the user by the /validate/check endpoint. + * + * @param string $username Username to authenticate. + * @param string $pass This can be the OTP, but also the PIN to trigger a token or PIN+OTP depending on the configuration of the server. + * @param string|null $transactionID Optional transaction ID. Used to reference a challenge that was triggered beforehand. + * @param array|null $headers Optional headers to forward to the server. + * @return PIResponse|null Returns PIResponse object or null if response was empty or malformed, or some parameter is missing. + * @throws PIBadRequestException If an error occurs during the request. + */ + public function validateCheck(string $username, string $pass, ?string $transactionID = null, ?array $headers = null): ?PIResponse + { + assert(gettype($username) === 'string'); + assert(gettype($pass) === 'string'); + + if (!empty($username)) { + $params['user'] = $username; + $params['pass'] = $pass; + if (!empty($transactionID)) { + // Add transaction ID in case of challenge response + $params['transaction_id'] = $transactionID; + } + if (empty($headers)) { + $headers = ['']; + } + if (!empty($this->realm)) { + $params['realm'] = $this->realm; + } + + $response = $this->sendRequest($params, $headers, 'POST', '/validate/check'); + + $ret = PIResponse::fromJSON($response, $this); + if ($ret == null) { + $this->log('debug', 'Server did not respond.'); + } + return $ret; + } else { + $this->log('debug', 'Missing username for /validate/check.'); + } + return null; + } + + /** + * Trigger all challenges for the given username. + * This function requires a service account to be set. + * + * @param string $username Username for which the challenges should be triggered. + * @param array|null $headers Optional headers to forward to the server. + * @return PIResponse|null Returns PIResponse object or null if response was empty or malformed, or some parameter is missing. + * @throws PIBadRequestException If an error occurs during the request. + */ + public function triggerChallenge(string $username, ?array $headers = null): ?PIResponse + { + assert(gettype($username) === 'string'); + + if ($username) { + $authToken = $this->getAuthToken(); + $authTokenHeader = ['authorization:' . $authToken]; + + $params = ['user' => $username]; + + if (!empty($this->realm)) { + $params['realm'] = $this->realm; + } + + if (!empty($headers)) { + $headers = array_merge($headers, $authTokenHeader); + } else { + $headers = $authTokenHeader; + } + + $response = $this->sendRequest($params, $headers, 'POST', '/validate/triggerchallenge'); + + return PIResponse::fromJSON($response, $this); + } else { + $this->log('debug', 'Username missing!'); + } + return null; + } + + /** + * Poll for the transaction status. + * + * @param $transactionID string Transaction ID of the triggered challenge. + * @param array|null $headers Optional headers to forward to the server. + * @return bool True if the push request has been accepted, false otherwise. + * @throws PIBadRequestException If an error occurs during the request. + */ + public function pollTransaction(string $transactionID, ?array $headers = null): bool + { + assert(gettype($transactionID) === 'string'); + + if (!empty($transactionID)) { + $params = ['transaction_id' => $transactionID]; + if (empty($headers)) { + $headers = ['']; + } + + $responseJSON = $this->sendRequest($params, $headers, 'GET', '/validate/polltransaction'); + $response = json_decode($responseJSON, true); + return $response['result']['value']; + } else { + $this->log('debug', 'TransactionID missing!'); + } + return false; + } + + /** + * Send request to /validate/check endpoint with the data required to authenticate using WebAuthn token. + * + * @param string $username Username to authenticate. + * @param string $transactionID Transaction ID of the triggered challenge. + * @param string $webAuthnSignResponse WebAuthn sign response. + * @param string $origin Origin required to authenticate using WebAuthn token. + * @param array|null $headers Optional headers to forward to the server. + * @return PIResponse|null Returns PIResponse object or null if response was empty or malformed, or some parameter is missing. + * @throws PIBadRequestException If an error occurs during the request. + */ + public function validateCheckWebAuthn(string $username, string $transactionID, string $webAuthnSignResponse, string $origin, ?array $headers = null): ?PIResponse + { + assert(gettype($username) === 'string'); + assert(gettype($transactionID) === 'string'); + assert(gettype($webAuthnSignResponse) === 'string'); + assert(gettype($origin) === 'string'); + + if (!empty($username) && !empty($transactionID) && !empty($webAuthnSignResponse) && !empty($origin)) { + // Compose standard validate/check params + $params['user'] = $username; + $params['pass'] = ''; + $params['transaction_id'] = $transactionID; + + if (!empty($this->realm)) { + $params['realm'] = $this->realm; + } + + // Additional WebAuthn params + $tmp = json_decode($webAuthnSignResponse, true); + + $params[CREDENTIALID] = $tmp[CREDENTIALID]; + $params[CLIENTDATA] = $tmp[CLIENTDATA]; + $params[SIGNATUREDATA] = $tmp[SIGNATUREDATA]; + $params[AUTHENTICATORDATA] = $tmp[AUTHENTICATORDATA]; + + if (!empty($tmp[USERHANDLE])) { + $params[USERHANDLE] = $tmp[USERHANDLE]; + } + if (!empty($tmp[ASSERTIONCLIENTEXTENSIONS])) { + $params[ASSERTIONCLIENTEXTENSIONS] = $tmp[ASSERTIONCLIENTEXTENSIONS]; + } + + $originHeader = ['Origin:' . $origin]; + if (!empty($headers)) { + $headers = array_merge($headers, $originHeader); + } else { + $headers = $originHeader; + } + + $response = $this->sendRequest($params, $headers, 'POST', '/validate/check'); + + return PIResponse::fromJSON($response, $this); + } else { + // Handle debug message if $username is empty + $this->log('debug', 'validateCheckWebAuthn: parameters are incomplete!'); + } + return null; + } + + /** + * Check if name and pass of service account are set. + * @return bool + */ + public function serviceAccountAvailable(): bool + { + return (!empty($this->serviceAccountName) && !empty($this->serviceAccountPass)); + } + + /** + * Retrieves the auth token from the server using the service account. An auth token is required for some requests to the privacyIDEA. + * + * @return string Auth token or empty string if the response did not contain a token or no service account is configured. + * @throws PIBadRequestException If an error occurs during the request. + */ + public function getAuthToken(): string + { + if (!$this->serviceAccountAvailable()) { + $this->log('error', 'Cannot retrieve auth token without service account!'); + return ''; + } + + $params = ['username' => $this->serviceAccountName, 'password' => $this->serviceAccountPass]; + if ($this->serviceAccountRealm != null && $this->serviceAccountRealm != '') { + $params['realm'] = $this->serviceAccountRealm; + } + + $response = json_decode($this->sendRequest($params, [''], 'POST', '/auth'), true); + + if (!empty($response['result']['value'])) { + // Ensure an admin account + if (!empty($response['result']['value']['token'])) { + if ($this->findRecursive($response, 'role') != 'admin') { + $this->log('debug', 'Auth token was of a user without admin role.'); + return ''; + } + return $response['result']['value']['token']; + } + } + $this->log('debug', '/auth response did not contain the auth token.'); + return ''; + } + + /** + * Find key recursively in array. + * + * @param array $haystack The array which will be searched. + * @param string $needle Search string. + * @return mixed Result of key search. + */ + public function findRecursive(array $haystack, string $needle): mixed + { + assert(is_array($haystack)); + assert(is_string($needle)); + + $iterator = new RecursiveArrayIterator($haystack); + $recursive = new RecursiveIteratorIterator($iterator, RecursiveIteratorIterator::SELF_FIRST); + + foreach ($recursive as $key => $value) { + if ($key === $needle) { + return $value; + } + } + return false; + } + + /** + * Send requests to the endpoint with specified parameters and headers. + * + * @param $params array Request parameters. + * @param $headers array Headers to forward. + * @param $httpMethod string GET or POST. + * @param $endpoint string Endpoint of the privacyIDEA API (e.g. /validate/check). + * @return string Returns a string with the server response. + * @throws PIBadRequestException If an error occurs. + */ + private function sendRequest(array $params, array $headers, string $httpMethod, string $endpoint): string + { + assert(gettype($params) === 'array'); + assert(gettype($headers) === 'array'); + assert(gettype($httpMethod) === 'string'); + assert(gettype($endpoint) === 'string'); + + // Add the client parameter if forwarded. + if (!empty($this->forwardClientIP)) { + $params['client'] = $this->forwardClientIP; + $this->log('debug', 'Forwarding Client IP: ' . $this->forwardClientIP); + } + + // Ignore proxy settings if wished. + if ($this->noProxy === true) { + $this->log('debug', 'Ignoring proxy settings.'); + $params['proxy'] = ['https' => '', 'http' => '']; + } + + // Set request's timeout + $params['timeout'] = $this->timeout; + + $this->log('debug', 'Sending ' . http_build_query($params, '', ', ') . ' to ' . $endpoint); + + $completeUrl = $this->serverURL . $endpoint; + + $curlInstance = curl_init(); + curl_setopt($curlInstance, CURLOPT_URL, $completeUrl); + curl_setopt($curlInstance, CURLOPT_HEADER, true); + if ($headers) { + curl_setopt($curlInstance, CURLOPT_HTTPHEADER, $headers); + } + curl_setopt($curlInstance, CURLOPT_RETURNTRANSFER, true); + curl_setopt($curlInstance, CURLOPT_USERAGENT, $this->userAgent); + if ($httpMethod === 'POST') { + curl_setopt($curlInstance, CURLOPT_POST, true); + curl_setopt($curlInstance, CURLOPT_POSTFIELDS, $params); + } elseif ($httpMethod === 'PUT') { + curl_setopt($curlInstance, CURLOPT_CUSTOMREQUEST, 'PUT'); + curl_setopt($curlInstance, CURLOPT_POSTFIELDS, $params); + } elseif ($httpMethod === 'DELETE') { + curl_setopt($curlInstance, CURLOPT_CUSTOMREQUEST, 'DELETE'); + curl_setopt($curlInstance, CURLOPT_POSTFIELDS, $params); + } elseif ($httpMethod === 'GET') { + $paramsStr = '?'; + if (!empty($params)) { + foreach ($params as $key => $value) { + $paramsStr .= $key . '=' . $value . '&'; + } + } + curl_setopt($curlInstance, CURLOPT_URL, $completeUrl . $paramsStr); + } + + // Disable host and/or peer verification for SSL if configured. + if ($this->sslVerifyHost === true) { + curl_setopt($curlInstance, CURLOPT_SSL_VERIFYHOST, 2); + } else { + curl_setopt($curlInstance, CURLOPT_SSL_VERIFYHOST, 0); + } + + if ($this->sslVerifyPeer === true) { + curl_setopt($curlInstance, CURLOPT_SSL_VERIFYPEER, 2); + } else { + curl_setopt($curlInstance, CURLOPT_SSL_VERIFYPEER, 0); + } + + $response = curl_exec($curlInstance); + + if (!$response) { + // Handle the error + $curlErrno = curl_errno($curlInstance); + $this->log('error', 'Bad request: ' . curl_error($curlInstance) . ' errno: ' . $curlErrno); + throw new PIBadRequestException('Unable to reach the authentication server (' . $curlErrno . ')'); + } + + $headerSize = curl_getinfo($curlInstance, CURLINFO_HEADER_SIZE); + $ret = substr($response, $headerSize); + curl_close($curlInstance); + + // Log the response + if ($endpoint != '/auth' && $this->logger != null) { + $retJson = json_decode($ret, true); + $this->log('debug', $endpoint . ' returned ' . json_encode($retJson, JSON_PRETTY_PRINT)); + } + + // Return decoded response + return $ret; + } + + /** + * Log a message with the given log level. + * + * @param $level + * @param $message + */ + public function log($level, $message): void + { + $context = ['app' => 'privacyIDEA']; + if ($level === 'debug') { + $this->logger->debug($message, $context); + } + if ($level === 'info') { + $this->logger->info($message, $context); + } + if ($level === 'error') { + $this->logger->error($message, $context); + } + } + + // Setters + + /** + * @param string $realm User's realm. + * @return void + */ + public function setRealm(string $realm): void + { + $this->realm = $realm; + } + + /** + * @param bool $sslVerifyHost Disable host verification for SSL. + * @return void + */ + public function setSSLVerifyHost(bool $sslVerifyHost): void + { + $this->sslVerifyHost = $sslVerifyHost; + } + + /** + * @param bool $sslVerifyPeer Disable peer verification for SSL. + * @return void + */ + public function setSSLVerifyPeer(bool $sslVerifyPeer): void + { + $this->sslVerifyPeer = $sslVerifyPeer; + } + + /** + * @param string $serviceAccountName Account name for privacyIDEA service account. Required to use the /validate/triggerchallenge endpoint. + * @return void + */ + public function setServiceAccountName(string $serviceAccountName): void + { + $this->serviceAccountName = $serviceAccountName; + } + + /** + * @param string $serviceAccountPass Password for privacyIDEA service account. Required to use the /validate/triggerchallenge endpoint. + * @return void + */ + public function setServiceAccountPass(string $serviceAccountPass): void + { + $this->serviceAccountPass = $serviceAccountPass; + } + + /** + * @param string $serviceAccountRealm Realm for privacyIDEA service account. Optional to use the /validate/triggerchallenge endpoint. + * @return void + */ + public function setServiceAccountRealm(string $serviceAccountRealm): void + { + $this->serviceAccountRealm = $serviceAccountRealm; + } + + /** + * @param bool $clientIP Send the "client" parameter to allow using the original IP address in the privacyIDEA policies. + * @return void + */ + public function setForwardClientIP(bool $clientIP): void + { + $this->forwardClientIP = $clientIP; + } + + /** + * @param bool $noProxy Ignore the system-wide proxy settings and send the authentication requests directly to privacyIDEA. + * @return void + */ + public function setNoProxy(bool $noProxy): void + { + $this->$noProxy = $noProxy; + } + + /** + * @param object|null $logger Implementation of the PILog interface. + * @return void + */ + public function setLogger(?object $logger): void + { + $this->logger = $logger; + } +} diff --git a/lib/Provider/PrivacyIDEAProvider.php b/lib/Provider/PrivacyIDEAProvider.php index 49240e6..aa03d53 100644 --- a/lib/Provider/PrivacyIDEAProvider.php +++ b/lib/Provider/PrivacyIDEAProvider.php @@ -20,671 +20,541 @@ class PrivacyIDEAProvider implements IProvider { - /** @var IAppConfig */ - private IAppConfig $appConfig; - /** @var LoggerInterface */ - private LoggerInterface $logger; - /** @var IRequest */ - private IRequest $request; - /** @var IGroupManager */ - private IGroupManager $groupManager; - /** @var IL10N */ - private IL10N $trans; - /** @var ISession */ - private ISession $session; - /** @var PrivacyIDEA */ - private PrivacyIDEA $pi; - - /** - * PrivacyIDEAProvider constructor. - * - * @param IAppConfig $appConfig - * @param LoggerInterface $logger - * @param IRequest $request - * @param IGroupManager $groupManager - * @param IL10N $trans - * @param ISession $session - */ - public function __construct(IAppConfig $appConfig, LoggerInterface $logger, IRequest $request, IGroupManager $groupManager, IL10N $trans, ISession $session) - { - $this->appConfig = $appConfig; - $this->logger = $logger; - $this->request = $request; - $this->groupManager = $groupManager; - $this->trans = $trans; - $this->session = $session; - if ($this->session->get("piAllowCreatingPIInstance") === true) - { - $this->pi = $this->createPrivacyIDEAInstance(); - } - } - - /** - * Get the template for rending the 2FA provider view. - * - * @param IUser $user - * @return Template - * @throws TwoFactorException - * @throws PIBadRequestException - */ - public function getTemplate(IUser $user): Template - { - if ($this->isTwoFactorAuthEnabledForUser($user) === false) - { - $this->session->set("piNoAuthRequired", true); - $this->verifyChallenge($user, ""); - } - else - { - $this->session->set("piAllowCreatingPIInstance", true); - $this->pi = $this->createPrivacyIDEAInstance(); - } - $authenticationFlow = $this->getAppValue("piSelectedAuthFlow", "piAuthFlowDefault"); - $this->log("debug", "Selected authentication flow: " . $authenticationFlow); - $username = $user->getUID(); - $headers = array(); - $headersFromConfig = $this->getAppValue("piForwardHeaders", ""); - if (!empty($headersFromConfig)) - { - $headers = $this->getHeadersToForward($headersFromConfig); - } - - if ($authenticationFlow === "piAuthFlowTriggerChallenge") - { - if (!empty($this->pi)) - { - if (!$this->pi->serviceAccountAvailable()) - { - $this->log("error", "Service account name or password is not set in config. Cannot trigger the challenges."); - } - else - { - if ($this->session->get("piTriggerChallengeDone") !== true) - { - try - { - $response = $this->pi->triggerChallenge($username, $headers); - $this->session->set("piTriggerChallengeDone", true); - if ($response !== null) - { - $this->processPIResponse($response); - } - else - { - $this->log("error", "No response from privacyIDEA server for triggerchallenge."); - } - } - catch (PIBadRequestException $e) - { - $this->handlePIException($e); - } - } - } - } - } - elseif ($authenticationFlow === "piAuthFlowSendStaticPass") - { - // Call /validate/check with a static pass from the configuration - // This could already end up the authentication if the "passOnNoToken" policy is set. - // Otherwise, it triggers the challenges. - if ($this->session->get("piStaticPassDone") !== true) - { - $response = $this->pi->validateCheck($username, $this->getAppValue("piStaticPass", ""), "", $headers); - $this->session->set("piStaticPassDone", true); - if ($response->getAuthenticationStatus() === AuthenticationStatus::ACCEPT) - { - // Complete the authentication - $this->session->set("piSuccess", true); - $this->verifyChallenge($user, ""); - } - else - { - $this->processPIResponse($response); - } - } - } - elseif ($authenticationFlow === "piAuthFlowSeparateOTP") - { - $this->session->set("piSeparateOTP", true); - } - elseif ($authenticationFlow !== "piAuthFlowDefault") - { - $this->log("error", "Unknown authentication flow: " . $authenticationFlow . ". Fallback to default."); - } - - // Set options, tokens and load counter to the template - $template = new Template("privacyidea", "main"); - - if (!empty($this->session->get("piMessage"))) - { - $template->assign("message", $this->session->get("piMessage")); - } - else - { - $template->assign("message", $this->getAppValue("piDefaultMessage", "Please enter the OTP!")); - } - if ($this->session->get("piMode") !== null) - { - $template->assign("mode", $this->session->get("piMode")); - } - if ($this->session->get("piWebAuthnSignRequest") !== null) - { - $template->assign("webAuthnSignRequest", $this->session->get("piWebAuthnSignRequest")); - } - if ($this->session->get("piPushAvailable")) - { - $template->assign("pushAvailable", $this->session->get("piPushAvailable")); - } - if ($this->session->get("piOTPAvailable")) - { - $template->assign("otpAvailable", $this->session->get("piOTPAvailable")); - } - if ($this->session->get("piImgWebauthn") !== null) - { - $template->assign("imgWebauthn", $this->session->get("piImgWebauthn")); - } - if ($this->session->get("piImgPush") !== null) - { - $template->assign("imgPush", $this->session->get("piImgPush")); - } - if ($this->session->get("piImgOTP") !== null) - { - $template->assign("imgOTP", $this->session->get("piImgOTP")); - } - $template->assign("activateAutoSubmitOtpLength", $this->getAppValue("piActivateAutoSubmitOtpLength", "0")); - $template->assign("autoSubmitOtpLength", $this->getAppValue("piAutoSubmitOtpLength", "6")); - $template->assign("pollInBrowser", $this->getAppValue("piPollInBrowser", "0")); - $template->assign("pollInBrowserUrl", $this->getAppValue("piPollInBrowserURL", "")); - if ($this->session->get("piTransactionID") !== null) - { - $template->assign("transactionID", $this->session->get("piTransactionID")); - } - if ($this->session->get("piSeparateOTP") !== null && $this->session->get("piSeparateOTP") === true) - { - $template->assign("separateOTP", $this->session->get("piSeparateOTP")); - } - if ($this->session->get("piPollInBrowserFailed") !== null && $this->session->get("piPollInBrowserFailed") === true) - { - $template->assign("pollInBrowserFailed", $this->session->get("piPollInBrowserFailed")); - } - if ($this->session->get("piErrorMessage") !== null) - { - $template->assign("errorMessage", $this->session->get("piErrorMessage")); - } - if ($this->session->get("piAutoSubmit") !== null && $this->session->get("piAutoSubmit") === true) - { - $template->assign("autoSubmit", $this->session->get("piAutoSubmit")); - } - - $loads = 1; - if ($this->session->get("piLoadCounter") !== null) - { - $loads = $this->session->get("piLoadCounter"); - } - $template->assign("loadCounter", $loads); - - // Add translations - $template->assign("verify", $this->trans->t("Verify")); - $template->assign("alternateLoginOptions", $this->trans->t("Alternate Login Options")); - - return $template; - } - - /** - * Verify the given challenge. - * - * @param IUser $user - * @param string $challenge - * @return Bool True in case of success. In case of failure, this raises - * a TwoFactorException with a descriptive error message. - * @throws TwoFactorException|Exception - */ - public function verifyChallenge(IUser $user, string $challenge): bool - { - if ($this->session->get("piNoAuthRequired") || $this->session->get("piSuccess")) - { - $this->session->set("piAutoSubmit", true); - return true; - } - - if (!empty($this->request->getParam("passField"))) - { - $password = $this->request->getParam("passField") . $challenge; - } - else - { - $password = $challenge; - } - $username = $user->getUID(); - $transactionID = $this->session->get("piTransactionID"); - $mode = $this->request->getParam("mode"); - $this->session->set("piMode", $mode); - - $piResponse = null; - if ($this->request->getParam("modeChanged") === "1") - { - throw new TwoFactorException(" "); - } - - if ($mode === "push") - { - $this->log("debug", "Processing PUSH response..."); - - if ($this->pi->pollTransaction($transactionID)) - { - // The challenge has been answered. Now we need to verify it. - $piResponse = $this->pi->validateCheck($username, "", $transactionID); - $this->processPIResponse($piResponse); - } - else - { - $this->log("debug", "PUSH not confirmed yet..."); - } - - // Increase load counter - if ($this->request->getParam("loadCounter")) - { - $counter = $this->request->getParam("loadCounter"); - $this->session->set("piLoadCounter", $counter + 1); - } - } - elseif ($mode === "webauthn") - { - $webAuthnSignResponse = json_decode($this->request->getParam("webAuthnSignResponse"), true); - $origin = $this->request->getParam("origin"); - - if (empty($webAuthnSignResponse)) - { - $this->log("error", "Incomplete data for WebAuthn authentication: WebAuthn sign response is missing!"); - } - else - { - $piResponse = $this->pi->validateCheckWebAuthn($username, $transactionID, json_encode($webAuthnSignResponse), $origin); - $this->processPIResponse($piResponse); - } - } - else - { - if (!empty($transactionID)) - { - $this->log("debug", "Transaction ID: " . $transactionID); - $piResponse = $this->pi->validateCheck($username, $password, $transactionID); - } - else - { - $piResponse = $this->pi->validateCheck($username, $password); - } - $this->processPIResponse($piResponse); - } - - if ($piResponse !== null) - { - if (!empty($piResponse->getErrorMessage())) - { - throw new TwoFactorException($piResponse->getErrorMessage()); - } - else - { - if ($piResponse->getStatus() === true) - { - if ($piResponse->getAuthenticationStatus() === AuthenticationStatus::ACCEPT) - { - $this->log("debug", "User authenticated successfully!"); - return true; - } - else - { - if (!empty($piResponse->getMessages())) - { - $this->session->set("piMessage", $piResponse->getMessages()); - $this->log("debug", $piResponse->getMessages()); - } - else - { - $this->session->set("piMessage", $piResponse->getMessage()); - $this->log("debug", $piResponse->getMessage()); - } - } - } - elseif ($mode === "push") - { - $this->log("debug", "PUSH not confirmed yet..."); - } - else - { - // status === false - $this->log("error", "privacyIDEA error code: " . $piResponse->getErrorCode()); - $this->log("error", "privacyIDEA error message: " . $piResponse->getErrorMessage()); - throw new TwoFactorException($this->trans->t("Failed to authenticate.") . " " . $piResponse->getErrorMessage()); - } - } - } - throw new TwoFactorException(" "); - } - - /** - * Create a new privacyIDEA object with the given configuration. - * - * @return PrivacyIDEA|null privacyIDEA object or null on error. - */ - private function createPrivacyIDEAInstance(): ?PrivacyIDEA - { - $this->log("info", "Creating privacyIDEA instance..."); - if (!empty($this->getAppValue("piURL", ""))) - { - $pi = new PrivacyIDEA("privacyidea-nextcloud/1.0.0", $this->getAppValue("piURL", "")); - $pi->setLogger($this->logger); - $pi->setSSLVerifyHost($this->getAppValue("piSSLVerify", "true")); - $pi->setSSLVerifyPeer($this->getAppValue("piSSLVerify", "true")); - $pi->setServiceAccountName($this->getAppValue("piServiceName", "")); - $pi->setServiceAccountPass($this->getAppValue("piServicePass", "")); - $pi->setServiceAccountRealm($this->getAppValue("piServiceRealm", "")); - $pi->setRealm($this->getAppValue("piRealm", "")); - $pi->setForwardClientIP($this->getAppValue("piForwardClientIP", false)); - $pi->setNoProxy($this->getAppValue("piNoProxy", false)); - return $pi; - } - else - { - $this->log("error", "Cannot create privacyIDEA instance: Server URL missing in configuration!"); - } - return null; - } - - /** - * Process the response from privacyIDEA and write information to session. - * - * @param PIResponse $response - * @return void - */ - private function processPIResponse(PIResponse $response): void - { - $this->log("info", "Processing server response..."); - $this->session->set("piMode", "otp"); - $this->log("info", "Authentication status: " . $response->getAuthenticationStatus()); - if (!empty($response->getMultiChallenge())) - { - $triggeredTokens = $response->getTriggeredTokenTypes(); - if (!empty($response->getPreferredClientMode())) - { - if ($response->getPreferredClientMode() === "interactive") - { - $this->session->set("piMode", "otp"); - } - elseif ($response->getPreferredClientMode() === "poll") - { - $this->session->set("piMode", "push"); - } - else - { - $this->session->set("piMode", $response->getPreferredClientMode()); - } - $this->log("debug", "Preferred client mode: " . $this->session->get("piMode")); - } - $this->session->set("piPushAvailable", in_array("push", $triggeredTokens)); - $this->session->set("piOTPAvailable", true); - $this->session->set("piMessage", $response->getMessages()); - $this->session->set("piTransactionID", $response->getTransactionID()); - if (in_array("webauthn", $triggeredTokens)) - { - $this->session->set("piWebAuthnSignRequest", $response->getWebauthnSignRequest()); - } - - // Search for the images - foreach ($response->getMultiChallenge() as $challenge) - { - if (!empty($challenge->image)) - { - if (!empty($challenge->clientMode) && $challenge->clientMode === "interactive") - { - $this->session->set("piImageOtp", $challenge->image); - } - elseif (!empty($challenge->clientMode) && $challenge->clientMode === "poll") - { - $this->session->set("piImagePush", $challenge->image); - } - elseif (!empty($challenge->clientMode) && $challenge->clientMode === "webauthn") - { - $this->session->set("piImageWebAuthn", $challenge->image); - } - } - } - } - elseif (!empty($response->getErrorCode())) - { - // privacyIDEA returned an error, prepare it to display. - $this->log("error", "Error code: " . $response->getErrorCode() . ", Error Message: " . $response->getErrorMessage()); - $this->session->set("piErrorCode", $response->getErrorCode()); - $this->session->set("piErrorMessage", $response->getErrorMessage()); - } - elseif ($response->getAuthenticationStatus() === AuthenticationStatus::ACCEPT) - { - // The user has been authenticated successfully. - $this->log("info", $response->getMessage()); - } - else - { - // Unexpected response - $this->log("error", $response->getMessage()); - $this->session->set("piErrorMessage", $response->getMessage()); - } - } - - /** - * Search for the configured headers in $_SERVER and return all found with their values. - * - * @return array Headers to forward with their values. - */ - private function getHeadersToForward(string $headers): array - { - $cleanHeaders = str_replace(' ', '', $headers); - $arrHeaders = explode(',', $cleanHeaders); - - $headersToForward = array(); - foreach ($arrHeaders as $header) - { - if (array_key_exists($header, $_SERVER)) - { - $this->log("debug", "Found matching header: " . $header); - $value = $_SERVER[$header]; - if (is_array($_SERVER[$header])) - { - $value = implode(',', $_SERVER[$header]); - } - $header = array($header => $value); - $headersToForward = array_push($headersToForward, $header); - } - else - { - $this->log("debug", "No values for header: " . $header . " found."); - } - } - return $headersToForward; - } - - /** - * Log the exceptions coming from the privacyIDEA. - * Also set the error code and message in the session. - * - * @param PIBadRequestException $e - * @return void - */ - private function handlePIException(PIBadRequestException $e): void - { - $this->log("error", "Exception: " . $e->getMessage()); - $this->session->set("piErrorCode", $e->getCode()); - $this->session->set("piErrorMessage", $e->getMessage()); - } - - /** - * Check whether 2FA is enabled for the given user. - * - * @param IUser $user - * @return bool - */ - public function isTwoFactorAuthEnabledForUser(IUser $user): bool - { - $piActive = $this->getAppValue('piActivatePI', '0'); - $piExcludeIPs = $this->getAppValue('piExcludeIPs', ''); - $piInExGroups = $this->getAppValue('piInExGroupsField', ''); - $piInOrExSelected = $this->getAppValue('piInOrExSelected', 'exclude'); - - if ($piActive === "1") - { - if ($piExcludeIPs) - { - $ipAddresses = explode(",", $piExcludeIPs); - $clientIP = ip2long($this->getClientIP()); - foreach ($ipAddresses as $address) - { - if (str_contains($address, '-')) - { - $range = explode('-', $address); - $startIP = ip2long($range[0]); - $endIP = ip2long($range[1]); - if ($clientIP >= $startIP && $clientIP <= $endIP) - { - return false; - } - } - else - { - if ($clientIP === ip2long($address)) - { - return false; - } - } - } - } - if (!empty($piInExGroups)) - { - $piInExGroups = str_replace(" ", "", $piInExGroups); - $groups = explode(",", $piInExGroups); - $checkEnabled = false; - foreach ($groups as $group) - { - if ($this->groupManager->isInGroup($user->getUID(), trim($group))) - { - $this->log("debug", "[isTwoFactorEnabledForUser] The user " . $user->getUID() . " is in group " . $group . "."); - if ($piInOrExSelected === "exclude") - { - $this->log("debug", "[isTwoFactorEnabledForUser] The group " . $group . " is excluded (User does not need MFA)."); - return false; - } - if ($piInOrExSelected === "include") - { - $this->log("debug", "[isTwoFactorEnabledForUser] The group " . $group . " is included (User needs MFA)."); - return true; - } - } - $this->log("debug", "[isTwoFactorEnabledForUser] The user " . $user->getUID() . " is not in group " . $group . "."); - if ($piInOrExSelected === "exclude") - { - $this->log("debug", "[isTwoFactorEnabledForUser] The group " . $group . " is excluded (User may need MFA)."); - $checkEnabled = true; - } - if ($piInOrExSelected === "include") - { - $this->log("debug", "[isTwoFactorEnabledForUser] The group " . $group . " is included (User may not need MFA)."); - $checkEnabled = false; - } - } - if (!$checkEnabled) - { - return false; - } - } - $this->log("debug", "[isTwoFactorAuthEnabledForUser] User needs MFA."); - return true; - } - $this->log("debug", "[isTwoFactorAuthEnabledForUser] privacyIDEA is not enabled."); - return false; - } - - /** - * Retrieve a value from the privacyIDEA app configuration store. - * - * @param string $key application config key - * @param $default - * @return string - */ - private function getAppValue(string $key, $default): string - { - return $this->appConfig->getValueString('privacyidea', $key, $default); - } - - /** - * Get the client IP address. - * - * @return mixed|string - */ - public function getClientIP(): mixed - { - if (array_key_exists('HTTP_X_FORWARDED_FOR', $_SERVER)) - { - return $_SERVER["HTTP_X_FORWARDED_FOR"]; - } - else if (array_key_exists('REMOTE_ADDR', $_SERVER)) - { - return $_SERVER["REMOTE_ADDR"]; - } - else if (array_key_exists('HTTP_CLIENT_IP', $_SERVER)) - { - return $_SERVER["HTTP_CLIENT_IP"]; - } - return ''; - } - - /** - * Get unique identifier of this 2FA provider. - * - * @return string - */ - public function getId(): string - { - return 'privacyidea'; - } - - /** - * Get the display name for selecting the 2FA provider. - * - * @return string - */ - public function getDisplayName(): string - { - return 'PrivacyIDEA'; - } - - /** - * Get the description for selecting the 2FA provider. - * - * @return string - */ - public function getDescription(): string - { - return 'Use PrivacyIDEA for multi-factor authentication'; - } - - /** - * Log a message with the given log level. - * - * @param $level - * @param $message - */ - private function log($level, $message): void - { - $context = ["app" => "privacyIDEA"]; - if ($level === 'debug') - { - $this->logger->debug($message, $context); - } - if ($level === 'info') - { - $this->logger->info($message, $context); - } - if ($level === 'error') - { - $this->logger->error($message, $context); - } - } -} \ No newline at end of file + /** @var IAppConfig */ + private IAppConfig $appConfig; + /** @var LoggerInterface */ + private LoggerInterface $logger; + /** @var IRequest */ + private IRequest $request; + /** @var IGroupManager */ + private IGroupManager $groupManager; + /** @var IL10N */ + private IL10N $trans; + /** @var ISession */ + private ISession $session; + /** @var PrivacyIDEA */ + private PrivacyIDEA $pi; + + /** + * PrivacyIDEAProvider constructor. + * + * @param IAppConfig $appConfig + * @param LoggerInterface $logger + * @param IRequest $request + * @param IGroupManager $groupManager + * @param IL10N $trans + * @param ISession $session + */ + public function __construct(IAppConfig $appConfig, LoggerInterface $logger, IRequest $request, IGroupManager $groupManager, IL10N $trans, ISession $session) + { + $this->appConfig = $appConfig; + $this->logger = $logger; + $this->request = $request; + $this->groupManager = $groupManager; + $this->trans = $trans; + $this->session = $session; + if ($this->session->get('piAllowCreatingPIInstance') === true) { + $this->pi = $this->createPrivacyIDEAInstance(); + } + } + + /** + * Get the template for rending the 2FA provider view. + * + * @param IUser $user + * @return Template + * @throws TwoFactorException + * @throws PIBadRequestException + */ + public function getTemplate(IUser $user): Template + { + if ($this->isTwoFactorAuthEnabledForUser($user) === false) { + $this->session->set('piNoAuthRequired', true); + $this->verifyChallenge($user, ''); + } else { + $this->session->set('piAllowCreatingPIInstance', true); + $this->pi = $this->createPrivacyIDEAInstance(); + } + $authenticationFlow = $this->getAppValue('piSelectedAuthFlow', 'piAuthFlowDefault'); + $this->log('debug', 'Selected authentication flow: ' . $authenticationFlow); + $username = $user->getUID(); + $headers = []; + $headersFromConfig = $this->getAppValue('piForwardHeaders', ''); + if (!empty($headersFromConfig)) { + $headers = $this->getHeadersToForward($headersFromConfig); + } + + if ($authenticationFlow === 'piAuthFlowTriggerChallenge') { + if (!empty($this->pi)) { + if (!$this->pi->serviceAccountAvailable()) { + $this->log('error', 'Service account name or password is not set in config. Cannot trigger the challenges.'); + } else { + if ($this->session->get('piTriggerChallengeDone') !== true) { + try { + $response = $this->pi->triggerChallenge($username, $headers); + $this->session->set('piTriggerChallengeDone', true); + if ($response !== null) { + $this->processPIResponse($response); + } else { + $this->log('error', 'No response from privacyIDEA server for triggerchallenge.'); + } + } catch (PIBadRequestException $e) { + $this->handlePIException($e); + } + } + } + } + } elseif ($authenticationFlow === 'piAuthFlowSendStaticPass') { + // Call /validate/check with a static pass from the configuration + // This could already end up the authentication if the "passOnNoToken" policy is set. + // Otherwise, it triggers the challenges. + if ($this->session->get('piStaticPassDone') !== true) { + $response = $this->pi->validateCheck($username, $this->getAppValue('piStaticPass', ''), '', $headers); + $this->session->set('piStaticPassDone', true); + if ($response->getAuthenticationStatus() === AuthenticationStatus::ACCEPT) { + // Complete the authentication + $this->session->set('piSuccess', true); + $this->verifyChallenge($user, ''); + } else { + $this->processPIResponse($response); + } + } + } elseif ($authenticationFlow === 'piAuthFlowSeparateOTP') { + $this->session->set('piSeparateOTP', true); + } elseif ($authenticationFlow !== 'piAuthFlowDefault') { + $this->log('error', 'Unknown authentication flow: ' . $authenticationFlow . '. Fallback to default.'); + } + + // Set options, tokens and load counter to the template + $template = new Template('privacyidea', 'main'); + + if (!empty($this->session->get('piMessage'))) { + $template->assign('message', $this->session->get('piMessage')); + } else { + $template->assign('message', $this->getAppValue('piDefaultMessage', 'Please enter the OTP!')); + } + if ($this->session->get('piMode') !== null) { + $template->assign('mode', $this->session->get('piMode')); + } + if ($this->session->get('piWebAuthnSignRequest') !== null) { + $template->assign('webAuthnSignRequest', $this->session->get('piWebAuthnSignRequest')); + } + if ($this->session->get('piPushAvailable')) { + $template->assign('pushAvailable', $this->session->get('piPushAvailable')); + } + if ($this->session->get('piOTPAvailable')) { + $template->assign('otpAvailable', $this->session->get('piOTPAvailable')); + } + if ($this->session->get('piImgWebauthn') !== null) { + $template->assign('imgWebauthn', $this->session->get('piImgWebauthn')); + } + if ($this->session->get('piImgPush') !== null) { + $template->assign('imgPush', $this->session->get('piImgPush')); + } + if ($this->session->get('piImgOTP') !== null) { + $template->assign('imgOTP', $this->session->get('piImgOTP')); + } + $template->assign('activateAutoSubmitOtpLength', $this->getAppValue('piActivateAutoSubmitOtpLength', '0')); + $template->assign('autoSubmitOtpLength', $this->getAppValue('piAutoSubmitOtpLength', '6')); + $template->assign('pollInBrowser', $this->getAppValue('piPollInBrowser', '0')); + $template->assign('pollInBrowserUrl', $this->getAppValue('piPollInBrowserURL', '')); + if ($this->session->get('piTransactionID') !== null) { + $template->assign('transactionID', $this->session->get('piTransactionID')); + } + if ($this->session->get('piSeparateOTP') !== null && $this->session->get('piSeparateOTP') === true) { + $template->assign('separateOTP', $this->session->get('piSeparateOTP')); + } + if ($this->session->get('piPollInBrowserFailed') !== null && $this->session->get('piPollInBrowserFailed') === true) { + $template->assign('pollInBrowserFailed', $this->session->get('piPollInBrowserFailed')); + } + if ($this->session->get('piErrorMessage') !== null) { + $template->assign('errorMessage', $this->session->get('piErrorMessage')); + } + if ($this->session->get('piAutoSubmit') !== null && $this->session->get('piAutoSubmit') === true) { + $template->assign('autoSubmit', $this->session->get('piAutoSubmit')); + } + + $loads = 1; + if ($this->session->get('piLoadCounter') !== null) { + $loads = $this->session->get('piLoadCounter'); + } + $template->assign('loadCounter', $loads); + + // Add translations + $template->assign('verify', $this->trans->t('Verify')); + $template->assign('alternateLoginOptions', $this->trans->t('Alternate Login Options')); + + return $template; + } + + /** + * Verify the given challenge. + * + * @param IUser $user + * @param string $challenge + * @return Bool True in case of success. In case of failure, this raises + * a TwoFactorException with a descriptive error message. + * @throws TwoFactorException|Exception + */ + public function verifyChallenge(IUser $user, string $challenge): bool + { + if ($this->session->get('piNoAuthRequired') || $this->session->get('piSuccess')) { + $this->session->set('piAutoSubmit', true); + return true; + } + + if (!empty($this->request->getParam('passField'))) { + $password = $this->request->getParam('passField') . $challenge; + } else { + $password = $challenge; + } + $username = $user->getUID(); + $transactionID = $this->session->get('piTransactionID'); + $mode = $this->request->getParam('mode'); + $this->session->set('piMode', $mode); + + $piResponse = null; + if ($this->request->getParam('modeChanged') === '1') { + throw new TwoFactorException(' '); + } + + if ($mode === 'push') { + $this->log('debug', 'Processing PUSH response...'); + + if ($this->pi->pollTransaction($transactionID)) { + // The challenge has been answered. Now we need to verify it. + $piResponse = $this->pi->validateCheck($username, '', $transactionID); + $this->processPIResponse($piResponse); + } else { + $this->log('debug', 'PUSH not confirmed yet...'); + } + + // Increase load counter + if ($this->request->getParam('loadCounter')) { + $counter = $this->request->getParam('loadCounter'); + $this->session->set('piLoadCounter', $counter + 1); + } + } elseif ($mode === 'webauthn') { + $webAuthnSignResponse = json_decode($this->request->getParam('webAuthnSignResponse'), true); + $origin = $this->request->getParam('origin'); + + if (empty($webAuthnSignResponse)) { + $this->log('error', 'Incomplete data for WebAuthn authentication: WebAuthn sign response is missing!'); + } else { + $piResponse = $this->pi->validateCheckWebAuthn($username, $transactionID, json_encode($webAuthnSignResponse), $origin); + $this->processPIResponse($piResponse); + } + } else { + if (!empty($transactionID)) { + $this->log('debug', 'Transaction ID: ' . $transactionID); + $piResponse = $this->pi->validateCheck($username, $password, $transactionID); + } else { + $piResponse = $this->pi->validateCheck($username, $password); + } + $this->processPIResponse($piResponse); + } + + if ($piResponse !== null) { + if (!empty($piResponse->getErrorMessage())) { + throw new TwoFactorException($piResponse->getErrorMessage()); + } else { + if ($piResponse->getStatus() === true) { + if ($piResponse->getAuthenticationStatus() === AuthenticationStatus::ACCEPT) { + $this->log('debug', 'User authenticated successfully!'); + return true; + } else { + if (!empty($piResponse->getMessages())) { + $this->session->set('piMessage', $piResponse->getMessages()); + $this->log('debug', $piResponse->getMessages()); + } else { + $this->session->set('piMessage', $piResponse->getMessage()); + $this->log('debug', $piResponse->getMessage()); + } + } + } elseif ($mode === 'push') { + $this->log('debug', 'PUSH not confirmed yet...'); + } else { + // status === false + $this->log('error', 'privacyIDEA error code: ' . $piResponse->getErrorCode()); + $this->log('error', 'privacyIDEA error message: ' . $piResponse->getErrorMessage()); + throw new TwoFactorException($this->trans->t('Failed to authenticate.') . ' ' . $piResponse->getErrorMessage()); + } + } + } + throw new TwoFactorException(' '); + } + + /** + * Create a new privacyIDEA object with the given configuration. + * + * @return PrivacyIDEA|null privacyIDEA object or null on error. + */ + private function createPrivacyIDEAInstance(): ?PrivacyIDEA + { + $this->log('info', 'Creating privacyIDEA instance...'); + if (!empty($this->getAppValue('piURL', ''))) { + $pi = new PrivacyIDEA('privacyidea-nextcloud/1.0.0', $this->getAppValue('piURL', '')); + $pi->setLogger($this->logger); + $pi->setSSLVerifyHost($this->getAppValue('piSSLVerify', true)); + $pi->setSSLVerifyPeer($this->getAppValue('piSSLVerify', true)); + $pi->setServiceAccountName($this->getAppValue('piServiceName', '')); + $pi->setServiceAccountPass($this->getAppValue('piServicePass', '')); + $pi->setServiceAccountRealm($this->getAppValue('piServiceRealm', '')); + $pi->setRealm($this->getAppValue('piRealm', '')); + $pi->setNoProxy($this->getAppValue('piNoProxy', false)); + if ($this->getAppValue('piForwardClientIP', false) && !empty($this->getClientIP())) { + $pi->setForwardClientIP($this->getClientIP()); + } + return $pi; + } else { + $this->log('error', 'Cannot create privacyIDEA instance: Server URL missing in configuration!'); + } + return null; + } + + /** + * Process the response from privacyIDEA and write information to session. + * + * @param PIResponse $response + * @return void + */ + private function processPIResponse(PIResponse $response): void + { + $this->log('info', 'Processing server response...'); + $this->session->set('piMode', 'otp'); + $this->log('info', 'Authentication status: ' . $response->getAuthenticationStatus()); + if (!empty($response->getMultiChallenge())) { + $triggeredTokens = $response->getTriggeredTokenTypes(); + if (!empty($response->getPreferredClientMode())) { + if ($response->getPreferredClientMode() === 'interactive') { + $this->session->set('piMode', 'otp'); + } elseif ($response->getPreferredClientMode() === 'poll') { + $this->session->set('piMode', 'push'); + } else { + $this->session->set('piMode', $response->getPreferredClientMode()); + } + $this->log('debug', 'Preferred client mode: ' . $this->session->get('piMode')); + } + $this->session->set('piPushAvailable', in_array('push', $triggeredTokens)); + $this->session->set('piOTPAvailable', true); + $this->session->set('piMessage', $response->getMessages()); + $this->session->set('piTransactionID', $response->getTransactionID()); + if (in_array('webauthn', $triggeredTokens)) { + $this->session->set('piWebAuthnSignRequest', $response->getWebauthnSignRequest()); + } + + // Search for the images + foreach ($response->getMultiChallenge() as $challenge) { + if (!empty($challenge->image)) { + if (!empty($challenge->clientMode) && $challenge->clientMode === 'interactive') { + $this->session->set('piImageOtp', $challenge->image); + } elseif (!empty($challenge->clientMode) && $challenge->clientMode === 'poll') { + $this->session->set('piImagePush', $challenge->image); + } elseif (!empty($challenge->clientMode) && $challenge->clientMode === 'webauthn') { + $this->session->set('piImageWebAuthn', $challenge->image); + } + } + } + } elseif (!empty($response->getErrorCode())) { + // privacyIDEA returned an error, prepare it to display. + $this->log('error', 'Error code: ' . $response->getErrorCode() . ', Error Message: ' . $response->getErrorMessage()); + $this->session->set('piErrorCode', $response->getErrorCode()); + $this->session->set('piErrorMessage', $response->getErrorMessage()); + } elseif ($response->getAuthenticationStatus() === AuthenticationStatus::ACCEPT) { + // The user has been authenticated successfully. + $this->log('info', $response->getMessage()); + } else { + // Unexpected response + $this->log('error', $response->getMessage()); + $this->session->set('piErrorMessage', $response->getMessage()); + } + } + + /** + * Search for the configured headers in $_SERVER and return all found with their values. + * + * @return array Headers to forward with their values. + */ + private function getHeadersToForward(string $headers): array + { + $cleanHeaders = str_replace(' ', '', $headers); + $arrHeaders = explode(',', $cleanHeaders); + + $headersToForward = []; + foreach ($arrHeaders as $header) { + if (array_key_exists($header, $_SERVER)) { + $this->log('debug', 'Found matching header: ' . $header); + $value = $_SERVER[$header]; + if (is_array($_SERVER[$header])) { + $value = implode(',', $_SERVER[$header]); + } + $header = [$header => $value]; + $headersToForward = array_push($headersToForward, $header); + } else { + $this->log('debug', 'No values for header: ' . $header . ' found.'); + } + } + return $headersToForward; + } + + /** + * Log the exceptions coming from the privacyIDEA. + * Also set the error code and message in the session. + * + * @param PIBadRequestException $e + * @return void + */ + private function handlePIException(PIBadRequestException $e): void + { + $this->log('error', 'Exception: ' . $e->getMessage()); + $this->session->set('piErrorCode', $e->getCode()); + $this->session->set('piErrorMessage', $e->getMessage()); + } + + /** + * Check whether 2FA is enabled for the given user. + * + * @param IUser $user + * @return bool + */ + public function isTwoFactorAuthEnabledForUser(IUser $user): bool + { + $piActive = $this->getAppValue('piActivatePI', '0'); + $piExcludeIPs = $this->getAppValue('piExcludeIPs', ''); + $piInExGroups = $this->getAppValue('piInExGroupsField', ''); + $piInOrExSelected = $this->getAppValue('piInOrExSelected', 'exclude'); + + if ($piActive === '1') { + if ($piExcludeIPs) { + $ipAddresses = explode(',', $piExcludeIPs); + $clientIP = ip2long($this->getClientIP()); + foreach ($ipAddresses as $address) { + if (str_contains($address, '-')) { + $range = explode('-', $address); + $startIP = ip2long($range[0]); + $endIP = ip2long($range[1]); + if ($clientIP >= $startIP && $clientIP <= $endIP) { + return false; + } + } else { + if ($clientIP === ip2long($address)) { + return false; + } + } + } + } + if (!empty($piInExGroups)) { + $piInExGroups = str_replace(' ', '', $piInExGroups); + $groups = explode(',', $piInExGroups); + $checkEnabled = false; + foreach ($groups as $group) { + if ($this->groupManager->isInGroup($user->getUID(), trim($group))) { + $this->log('debug', '[isTwoFactorEnabledForUser] The user ' . $user->getUID() . ' is in group ' . $group . '.'); + if ($piInOrExSelected === 'exclude') { + $this->log('debug', '[isTwoFactorEnabledForUser] The group ' . $group . ' is excluded (User does not need MFA).'); + return false; + } + if ($piInOrExSelected === 'include') { + $this->log('debug', '[isTwoFactorEnabledForUser] The group ' . $group . ' is included (User needs MFA).'); + return true; + } + } + $this->log('debug', '[isTwoFactorEnabledForUser] The user ' . $user->getUID() . ' is not in group ' . $group . '.'); + if ($piInOrExSelected === 'exclude') { + $this->log('debug', '[isTwoFactorEnabledForUser] The group ' . $group . ' is excluded (User may need MFA).'); + $checkEnabled = true; + } + if ($piInOrExSelected === 'include') { + $this->log('debug', '[isTwoFactorEnabledForUser] The group ' . $group . ' is included (User may not need MFA).'); + $checkEnabled = false; + } + } + if (!$checkEnabled) { + return false; + } + } + $this->log('debug', '[isTwoFactorAuthEnabledForUser] User needs MFA.'); + return true; + } + $this->log('debug', '[isTwoFactorAuthEnabledForUser] privacyIDEA is not enabled.'); + return false; + } + + /** + * Retrieve a value from the privacyIDEA app configuration store. + * + * @param string $key application config key + * @param $default + * @return string + */ + private function getAppValue(string $key, $default): string + { + return $this->appConfig->getValueString('privacyidea', $key, $default); + } + + /** + * Get the client IP address. + * + * @return string Client IP address or an empty string. + */ + public function getClientIP(): string + { + $clientIP = $this->request->getRemoteAddress(); + if (!empty($clientIP)) { + return $clientIP; + } else { + $this->log('error', 'Cannot get client IP address.'); + return ''; + } + } + + /** + * Get unique identifier of this 2FA provider. + * + * @return string + */ + public function getId(): string + { + return 'privacyidea'; + } + + /** + * Get the display name for selecting the 2FA provider. + * + * @return string + */ + public function getDisplayName(): string + { + return 'PrivacyIDEA'; + } + + /** + * Get the description for selecting the 2FA provider. + * + * @return string + */ + public function getDescription(): string + { + return 'Use PrivacyIDEA for multi-factor authentication'; + } + + /** + * Log a message with the given log level. + * + * @param $level + * @param $message + */ + private function log($level, $message): void + { + $context = ['app' => 'privacyIDEA']; + if ($level === 'debug') { + $this->logger->debug($message, $context); + } + if ($level === 'info') { + $this->logger->info($message, $context); + } + if ($level === 'error') { + $this->logger->error($message, $context); + } + } +} diff --git a/lib/Sections/PrivacyIDEAAdmin.php b/lib/Sections/PrivacyIDEAAdmin.php index 0efd369..ee64916 100644 --- a/lib/Sections/PrivacyIDEAAdmin.php +++ b/lib/Sections/PrivacyIDEAAdmin.php @@ -8,32 +8,32 @@ class PrivacyIDEAAdmin implements IIconSection { - private IL10N $l; - private IURLGenerator $urlGenerator; - - public function __construct(IL10N $l, IURLGenerator $urlGenerator) - { - $this->l = $l; - $this->urlGenerator = $urlGenerator; - } - - public function getIcon(): string - { - return $this->urlGenerator->imagePath('privacyidea', 'settings-dark.svg'); - } - - public function getID(): string - { - return 'privacyidea'; - } - - public function getName(): string - { - return $this->l->t('privacyIDEA'); - } - - public function getPriority(): int - { - return 91; - } -} \ No newline at end of file + private IL10N $l; + private IURLGenerator $urlGenerator; + + public function __construct(IL10N $l, IURLGenerator $urlGenerator) + { + $this->l = $l; + $this->urlGenerator = $urlGenerator; + } + + public function getIcon(): string + { + return $this->urlGenerator->imagePath('privacyidea', 'settings-dark.svg'); + } + + public function getID(): string + { + return 'privacyidea'; + } + + public function getName(): string + { + return $this->l->t('privacyIDEA'); + } + + public function getPriority(): int + { + return 91; + } +} diff --git a/lib/Settings/Admin.php b/lib/Settings/Admin.php index e96a438..cb97b00 100644 --- a/lib/Settings/Admin.php +++ b/lib/Settings/Admin.php @@ -3,33 +3,33 @@ namespace OCA\PrivacyIDEA\Settings; use OCP\AppFramework\Http\TemplateResponse; -use OCP\Settings\ISettings; use OCP\IConfig; +use OCP\Settings\ISettings; class Admin implements ISettings { - private IConfig $config; + private IConfig $config; - public function __construct(IConfig $config) - { - $this->config = $config; - } + public function __construct(IConfig $config) + { + $this->config = $config; + } - /** - * @return TemplateResponse - */ - public function getForm(): TemplateResponse - { - return new TemplateResponse('privacyidea', 'settings-admin', [], ''); - } + /** + * @return TemplateResponse + */ + public function getForm(): TemplateResponse + { + return new TemplateResponse('privacyidea', 'settings-admin', [], ''); + } - public function getSection(): string - { - return 'privacyidea'; - } + public function getSection(): string + { + return 'privacyidea'; + } - public function getPriority(): int - { - return 10; - } -} \ No newline at end of file + public function getPriority(): int + { + return 10; + } +} diff --git a/package.json b/package.json index ff89edb..cec0e73 100644 --- a/package.json +++ b/package.json @@ -19,9 +19,6 @@ "@nextcloud/router": "^3.0.1" }, "devDependencies": { - "@nextcloud/browserslist-config": "^3.0.1", - "@nextcloud/eslint-config": "^8.3.0", - "@nextcloud/stylelint-config": "^2.4.0", - "@nextcloud/webpack-vue-config": "^6.0.1" + "@nextcloud/browserslist-config": "^3.0.1" } } \ No newline at end of file diff --git a/src/CodingStandard/Config.php b/src/CodingStandard/Config.php new file mode 100644 index 0000000..506ccab --- /dev/null +++ b/src/CodingStandard/Config.php @@ -0,0 +1,79 @@ +setIndent("\t"); + $this->registerCustomFixers(new PhpCsFixerCustomFixers\Fixers()); + } + + public function getRules() : array { + return [ + '@PSR1' => true, + '@PSR2' => true, + 'align_multiline_comment' => true, + 'array_indentation' => true, + 'array_syntax' => true, + 'binary_operator_spaces' => [ + 'default' => 'single_space', + ], + 'blank_line_after_namespace' => true, + 'blank_line_after_opening_tag' => true, + 'cast_spaces' => ['space' => 'none'], + 'concat_space' => ['spacing' => 'one'], + 'elseif' => true, + 'encoding' => true, + 'full_opening_tag' => true, + 'function_declaration' => [ + 'closure_function_spacing' => 'one', + ], + 'indentation_type' => true, + 'line_ending' => true, + 'list_syntax' => true, + 'lowercase_cast' => true, + 'lowercase_keywords' => true, + 'method_argument_space' => [ + 'on_multiline' => 'ignore', + ], + 'method_chaining_indentation' => true, + 'no_closing_tag' => true, + 'no_leading_import_slash' => true, + 'no_short_bool_cast' => true, + 'no_spaces_after_function_name' => true, + 'no_spaces_inside_parenthesis' => true, + 'no_trailing_whitespace' => true, + 'no_trailing_whitespace_in_comment' => true, + 'no_unused_imports' => true, + 'nullable_type_declaration_for_default_null_value' => true, + 'nullable_type_declaration' => ['syntax' => 'question_mark'], + 'ordered_imports' => [ + 'imports_order' => ['class', 'function', 'const'], + 'sort_algorithm' => 'alpha' + ], + 'phpdoc_align' => ['align' => 'left'], + 'phpdoc_single_line_var_spacing' => true, + 'phpdoc_var_annotation_correct_order' => true, + 'short_scalar_cast' => true, + 'single_blank_line_at_eof' => true, + 'single_class_element_per_statement' => true, + 'single_import_per_statement' => true, + 'single_line_after_imports' => true, + 'single_quote' => ['strings_containing_single_quote_chars' => false], + 'switch_case_space' => true, + 'trailing_comma_in_multiline' => ['elements' => ['parameters']], + 'types_spaces' => ['space' => 'none', 'space_multiple_catch' => 'none'], + 'visibility_required' => [ + 'elements' => ['property', 'method', 'const'] + ], + 'yoda_style' => ['equal' => false, 'identical' => false, 'less_and_greater' => false], + PhpCsFixerCustomFixers\Fixer\MultilinePromotedPropertiesFixer::name() => true, + ]; + } +} diff --git a/stylelint.config.js b/stylelint.config.js deleted file mode 100644 index 3be3a7b..0000000 --- a/stylelint.config.js +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = { - extends: 'stylelint-config-recommended-vue', -} diff --git a/templates/main.php b/templates/main.php index 33f89e5..7bf40fd 100644 --- a/templates/main.php +++ b/templates/main.php @@ -19,13 +19,13 @@ - + WebAuthn image

+if (!empty($_['imgPush']) && $_['mode'] === 'push') : ?> Push image

+if (!empty($_['imgOTP']) && $_['mode'] === 'otp') : ?> OTP image

@@ -75,13 +75,10 @@ diff --git a/vendor-bin/cs-fixer/composer.json b/vendor-bin/cs-fixer/composer.json index dc131e7..2414a84 100644 --- a/vendor-bin/cs-fixer/composer.json +++ b/vendor-bin/cs-fixer/composer.json @@ -1,7 +1,4 @@ { - "require-dev": { - "nextcloud/coding-standard": "^1.2" - }, "config": { "platform": { "php": "8.1" diff --git a/vendor-bin/openapi-extractor/composer.json b/vendor-bin/openapi-extractor/composer.json deleted file mode 100644 index dde3ad3..0000000 --- a/vendor-bin/openapi-extractor/composer.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "repositories": [ - { - "type": "vcs", - "url": "https://github.com/nextcloud/openapi-extractor" - } - ], - "require-dev": { - "nextcloud/openapi-extractor": "dev-main" - }, - "config": { - "platform": { - "php": "8.1" - } - } -} diff --git a/vendor-bin/phpunit/composer.json b/vendor-bin/phpunit/composer.json deleted file mode 100644 index fe8b171..0000000 --- a/vendor-bin/phpunit/composer.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "require-dev": { - "phpunit/phpunit": "^10.5" - }, - "config": { - "platform": { - "php": "8.1" - } - } -}