├── .editorconfig ├── .flake8 ├── .github ├── FUNDING.yml └── workflows │ ├── changelog.yml │ ├── config.yml │ └── main.yml ├── .gitignore ├── .vscode ├── extensions.json └── settings.json ├── CHANGELOG.md ├── CONTRIBUTORS.md ├── LICENSE.txt ├── Makefile ├── README.md ├── dependabot.yml ├── docs ├── authomatic.svg ├── plone-control-panel.png └── plugin-settings.png ├── instance.yaml ├── mx.ini ├── news └── .changelog_template.jinja ├── pyproject.toml ├── src └── pas │ ├── __init__.py │ └── plugins │ ├── __init__.py │ └── authomatic │ ├── __init__.py │ ├── browser │ ├── __init__.py │ ├── add_plugin.pt │ ├── authomatic.png │ ├── authomatic.pt │ ├── configure.zcml │ ├── controlpanel.py │ ├── resources │ │ ├── authomatic-logo.svg │ │ ├── authomatic.css │ │ └── authomatic.less │ └── view.py │ ├── configure.zcml │ ├── integration │ ├── __init__.py │ ├── restapi.py │ └── zope.py │ ├── interfaces.py │ ├── locales │ ├── __init__.py │ ├── __main__.py │ ├── de │ │ └── LC_MESSAGES │ │ │ └── pas.plugins.authomatic.po │ ├── es │ │ └── LC_MESSAGES │ │ │ └── pas.plugins.authomatic.po │ ├── fr │ │ └── LC_MESSAGES │ │ │ └── pas.plugins.authomatic.po │ ├── it │ │ └── LC_MESSAGES │ │ │ └── pas.plugins.authomatic.po │ ├── pas.plugins.authomatic-manual.pot │ ├── pas.plugins.authomatic.pot │ ├── pt_BR │ │ └── LC_MESSAGES │ │ │ └── pas.plugins.authomatic.po │ └── ro │ │ └── LC_MESSAGES │ │ └── pas.plugins.authomatic.po │ ├── log.py │ ├── meta.zcml │ ├── patches │ ├── __init__.py │ └── authomatic.py │ ├── plugin.py │ ├── profiles.zcml │ ├── profiles │ ├── default │ │ ├── browserlayer.xml │ │ ├── controlpanel.xml │ │ ├── metadata.xml │ │ └── registry.xml │ └── uninstall │ │ ├── browserlayer.xml │ │ ├── controlpanel.xml │ │ └── registry.xml │ ├── services │ ├── __init__.py │ ├── authomatic.py │ ├── configure.zcml │ └── login.py │ ├── setuphandlers.py │ ├── testing.py │ ├── useridentities.py │ ├── useridfactories.py │ └── utils.py └── tests ├── conftest.py ├── functional ├── conftest.py ├── test_controlpanel.py └── test_controlpanel_functional.py ├── plugin ├── conftest.py ├── test_plugin.py ├── test_useridentities.py └── test_useridfactories.py ├── services ├── conftest.py ├── test_services_authomatic.py └── test_services_login.py └── setup ├── test_setup_install.py ├── test_setup_uninstall.py ├── test_setuphandler.py └── test_upgrades.py /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig Configuration file, for more details see: 2 | # https://EditorConfig.org 3 | # EditorConfig is a convention description, that could be interpreted 4 | # by multiple editors to enforce common coding conventions for specific 5 | # file types 6 | 7 | # top-most EditorConfig file: 8 | # Will ignore other EditorConfig files in Home directory or upper tree level. 9 | root = true 10 | 11 | 12 | [*] # For All Files 13 | # Unix-style newlines with a newline ending every file 14 | end_of_line = lf 15 | insert_final_newline = true 16 | trim_trailing_whitespace = true 17 | # Set default charset 18 | charset = utf-8 19 | # Indent style default 20 | indent_style = space 21 | 22 | [*.{py,cfg,ini}] 23 | # 4 space indentation 24 | indent_size = 4 25 | 26 | [*.{html,dtml,pt,zpt,xml,zcml,js,json,ts,less,scss,css,sass,yml,yaml}] 27 | # 2 space indentation 28 | indent_size = 2 29 | 30 | [{Makefile,.gitmodules}] 31 | # Tab indentation (no size specified, but view as 4 spaces) 32 | indent_style = tab 33 | indent_size = unset 34 | tab_width = unset 35 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | doctests = 1 3 | ignore = 4 | # black takes care of line length 5 | E501, 6 | # black takes care of where to break lines 7 | W503, 8 | # black takes care of spaces within slicing (list[:]) 9 | E203, 10 | # black takes care of spaces after commas 11 | E231, 12 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [plone] 2 | -------------------------------------------------------------------------------- /.github/workflows/changelog.yml: -------------------------------------------------------------------------------- 1 | name: Change log check 2 | on: 3 | pull_request: 4 | types: [assigned, opened, synchronize, reopened, labeled, unlabeled] 5 | branches: 6 | - main 7 | 8 | env: 9 | python-version: 3.12 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | with: 17 | # Fetch all history 18 | fetch-depth: '0' 19 | 20 | - name: Setup uv 21 | uses: plone/meta/.github/actions/setup_uv@2.x 22 | with: 23 | python-version: ${{ env.python-version }} 24 | - name: Check for presence of a Change Log fragment (only pull requests) 25 | if: github.event_name == 'pull_request' 26 | run: | 27 | git fetch --no-tags origin ${{ github.base_ref }} 28 | uvx towncrier check --compare-with origin/${{ github.base_ref }} --config pyproject.toml --dir . 29 | -------------------------------------------------------------------------------- /.github/workflows/config.yml: -------------------------------------------------------------------------------- 1 | name: 'Compute Config variables' 2 | 3 | on: 4 | workflow_call: 5 | inputs: 6 | python-version: 7 | required: false 8 | type: string 9 | default: "3.12" 10 | plone-version: 11 | required: false 12 | type: string 13 | default: "6.1.1" 14 | outputs: 15 | backend: 16 | description: "Flag reporting if we should run the backend jobs" 17 | value: ${{ jobs.config.outputs.backend }} 18 | docs: 19 | description: "Flag reporting if we should run the docs jobs" 20 | value: ${{ jobs.config.outputs.docs }} 21 | base-tag: 22 | description: "Base tag to be used when creating container images" 23 | value: ${{ jobs.config.outputs.base-tag }} 24 | python-version: 25 | description: "Python version to be used" 26 | value: ${{ inputs.python-version }} 27 | plone-version: 28 | description: "Plone version to be used" 29 | value: ${{ inputs.plone-version }} 30 | 31 | jobs: 32 | config: 33 | runs-on: ubuntu-latest 34 | outputs: 35 | backend: ${{ steps.filter.outputs.backend }} 36 | docs: ${{ steps.filter.outputs.docs }} 37 | base-tag: ${{ steps.vars.outputs.BASE_TAG }} 38 | plone-version: ${{ steps.vars.outputs.plone-version }} 39 | 40 | steps: 41 | - name: Checkout 42 | uses: actions/checkout@v4 43 | 44 | - name: Compute several vars needed for the CI 45 | id: vars 46 | run: | 47 | echo "base-tag=sha-$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT 48 | echo "plone-version=${{ inputs.plone-version }}" >> $GITHUB_OUTPUT 49 | 50 | - uses: dorny/paths-filter@v3.0.2 51 | id: filter 52 | with: 53 | filters: | 54 | backend: 55 | - '**' 56 | - '.github/workflows/config.yml' 57 | - '.github/workflows/main.yml' 58 | docs: 59 | - '.readthedocs.yaml' 60 | - 'docs/**' 61 | - '.github/workflows/docs.yaml' 62 | 63 | - name: Test vars 64 | run: | 65 | echo "base-tag: ${{ steps.vars.outputs.base-tag }}" 66 | echo 'plone-version: ${{ steps.vars.outputs.plone-version }}' 67 | echo 'event-name: ${{ github.event_name }}' 68 | echo "ref-name: ${{ github.ref_name }}" 69 | echo 'Paths - backend: ${{ steps.filter.outputs.backend }}' 70 | echo 'Paths - docs: ${{ steps.filter.outputs.docs }}' 71 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI pas.plugins.authomatic 2 | 3 | on: 4 | push: 5 | 6 | jobs: 7 | 8 | config: 9 | name: "Compute configuration values" 10 | uses: ./.github/workflows/config.yml 11 | lint: 12 | name: "Lint codebase" 13 | uses: plone/meta/.github/workflows/backend-lint.yml@2.x 14 | needs: 15 | - config 16 | with: 17 | python-version: ${{ needs.config.outputs.python-version }} 18 | plone-version: ${{ needs.config.outputs.plone-version }} 19 | test: 20 | name: "Test codebase" 21 | uses: plone/meta/.github/workflows/backend-pytest.yml@2.x 22 | needs: 23 | - config 24 | strategy: 25 | matrix: 26 | python-version: ["3.13", "3.12", "3.11", "3.10"] 27 | plone-version: ["6.1-latest", "6.0-latest"] 28 | with: 29 | python-version: ${{ matrix.python-version }} 30 | plone-version: ${{ matrix.plone-version }} 31 | 32 | coverage: 33 | name: "Backend: Coverage" 34 | uses: plone/meta/.github/workflows/backend-pytest-coverage.yml@2.x 35 | needs: 36 | - config 37 | - test 38 | with: 39 | python-version: ${{ needs.config.outputs.python-version }} 40 | plone-version: ${{ needs.config.outputs.plone-version }} 41 | 42 | report: 43 | name: "Final report" 44 | if: ${{ always() }} 45 | runs-on: ubuntu-latest 46 | needs: 47 | - config 48 | - lint 49 | - test 50 | - coverage 51 | steps: 52 | - name: Report 53 | shell: bash 54 | run: | 55 | echo '# Workflow Report' >> $GITHUB_STEP_SUMMARY 56 | echo '| Job ID | Conclusion |' >> $GITHUB_STEP_SUMMARY 57 | echo '| --- | --- |' >> $GITHUB_STEP_SUMMARY 58 | echo '| Config | ${{ needs.config.result }} |' >> $GITHUB_STEP_SUMMARY 59 | echo '| Lint | ${{ needs.coverage.result }} |' >> $GITHUB_STEP_SUMMARY 60 | echo '| Test | ${{ needs.coverage.result }} |' >> $GITHUB_STEP_SUMMARY 61 | echo '| Coverage | ${{ needs.coverage.result }} |' >> $GITHUB_STEP_SUMMARY 62 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | !src/pas 2 | .coverage* 3 | .pydevproject 4 | .pytest_cache 5 | .ruff_cache 6 | .python-version 7 | .venv 8 | *.egg-info 9 | *.log 10 | *.mo 11 | *.pyc 12 | *.swp 13 | constraints-mxdev.txt 14 | constraints.txt 15 | instance 16 | requirements-mxdev.txt 17 | requirements.lock 18 | requirements.txt 19 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "charliermarsh.ruff", 4 | "ms-python.python" 5 | ] 6 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "flake8.args": ["--config=pyproject.toml"], 3 | "ruff.organizeImports": true, 4 | "python.terminal.activateEnvironment": true, 5 | "python.testing.pytestArgs": [ 6 | "tests" 7 | ], 8 | "python.testing.unittestEnabled": false, 9 | "python.testing.pytestEnabled": true, 10 | "[markdown]": { 11 | "editor.formatOnSave": false 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 8 | 9 | 10 | 11 | ## 2.0.0 (2025-05-15) 12 | 13 | 14 | ### Internal: 15 | 16 | - GHA: Use plone/meta shared workflows. @ericof [#102](https://github.com/collective/pas.plugins.authomatic/issues/102) 17 | 18 | 19 | ### Documentation: 20 | 21 | - Update README file with release instructions. @ericof 22 | 23 | ## 2.0.0rc3 (2025-04-11) 24 | 25 | 26 | ### Bug fixes: 27 | 28 | - Fix walrus operator usage to correctly assign authomatic_cfg() result [#100](https://github.com/collective/pas.plugins.authomatic/issues/100) 29 | 30 | ## 2.0.0rc2 (2025-04-03) 31 | 32 | 33 | ### Bug fixes: 34 | 35 | - Add missing `plone.api` dependency. @mauritsvanrees [#95](https://github.com/collective/pas.plugins.authomatic/issues/95) 36 | 37 | ## 2.0.0rc1 (2025-03-27) 38 | 39 | 40 | ### New features: 41 | 42 | - - Documented integration with Microsoft Entra ID @alecghica [#87](https://github.com/collective/pas.plugins.authomatic/issues/87) 43 | 44 | 45 | ### Internal: 46 | 47 | - Add suuport to Python 3.13 @ericof [#89](https://github.com/collective/pas.plugins.authomatic/issues/89) 48 | - Use UV to manage the environment @ericof [#90](https://github.com/collective/pas.plugins.authomatic/issues/90) 49 | - Update .vscode configuration @ericof [#91](https://github.com/collective/pas.plugins.authomatic/issues/91) 50 | - GHA: Update workflows. @ericof [#92](https://github.com/collective/pas.plugins.authomatic/issues/92) 51 | - Use pytest-plone 1.0.0a1 @ericof [#93](https://github.com/collective/pas.plugins.authomatic/issues/93) 52 | 53 | ## 2.0.0b3 (2025-02-03) 54 | 55 | 56 | ### New features: 57 | 58 | - Register the adapter as needed by the @login endpoint present in plone.restapi @erral [#73](https://github.com/collective/pas.plugins.authomatic/issues/73) 59 | 60 | 61 | ### Internal: 62 | 63 | - Require plone.restapi higher than 9.10.0 [@ericof] 64 | 65 | ## 2.0.0b2 (2025-01-14) 66 | 67 | 68 | ### Internal: 69 | 70 | - Move CHANGELOG.md entries to CHANGES.md [@ericof] [#84](https://github.com/collective/pas.plugins.authomatic/issues/84) 71 | - Document release process [@ericof] [#85](https://github.com/collective/pas.plugins.authomatic/issues/85) 72 | - Rename logging.py to log.py [@ericof] [#86](https://github.com/collective/pas.plugins.authomatic/issues/86) 73 | 74 | ## 2.0.0b1 (2025-01-14) 75 | 76 | 77 | ### Internal: 78 | 79 | - Modernize package repository [@ericof] [#71](https://github.com/collective/pas.plugins.authomatic/issues/71) 80 | - Move tests to pytest [@ericof] [#72](https://github.com/collective/pas.plugins.authomatic/issues/72) 81 | - Drop Plone 5.2 support [@ericof] [#80](https://github.com/collective/pas.plugins.authomatic/issues/80) 82 | - Update i18n mechanism, update Brazilian Portuguese translation [@ericof] [#82](https://github.com/collective/pas.plugins.authomatic/issues/82) 83 | 84 | 85 | ## 1.4.0 (2024-12-13) 86 | 87 | 88 | - Patch `authomatic.providers.BaseProvider._fetch` to support Python 3.12 @ericof. 89 | 90 | 91 | ## 1.3.0 (2024-11-21) 92 | 93 | - Search users by fullname and email. @alecghica 94 | - Fix login on Volto frontend when already logged-in in Plone Classic. @avoinea 95 | - Add the possibility to override the ZopeRequestAdapter. 96 | - Fix the authomatic view when it is reporting an exception that does not have a message attribute 97 | 98 | 99 | ## 1.2.0 (2023-09-13) 100 | 101 | - Add Spanish translation. @macagua 102 | 103 | - Better handle values from identity data. @cekk 104 | 105 | - Add `username_userid` User ID factory. @ericof 106 | 107 | - Annotate transaction in POST calls to authenticate a user. @ericof 108 | 109 | 110 | ## 1.1.2 (2023-03-15) 111 | 112 | - Support Python 3.11 for Plone 6. @ericof 113 | 114 | - Lint fixes @ericof 115 | 116 | 117 | ## 1.1.1 (2022-10-14) 118 | 119 | - Upgrade plone/code-analysis-action to version 2. @ericof 120 | 121 | - Fix packaging issue related to CHANGELOG.md not being included in the source package. @ericof 122 | 123 | - Support Python 3.10 for Plone 6. @ericof 124 | 125 | 126 | ## 1.1.0 (2022-10-10) 127 | 128 | - Add the plone.restapi adapter to show the controlpanel in Volto. @erral 129 | 130 | - Add possibility to redirect to `next_url` via provided cookie @avoinea 131 | 132 | 133 | ## 1.0.0 (2022-07-25) 134 | 135 | - Use plone/plone-setup GitHub Action. @ericof 136 | 137 | - Add Brazilian Portuguese translation. @ericof 138 | 139 | - Use plone/code-analysis-action GitHub Action for code analysis. @ericof 140 | 141 | - Fix doChangeUser takes 2 positional arguments but 3 were given @avoinea 142 | 143 | ## 1.0b2 (2021-08-18) 144 | 145 | - Fix tox setup, move CI from TravisCI to GitHub Actions. @jensens 146 | 147 | - Code Style Black, Isort, zpretty and Pyupgrade applied. @jensens 148 | 149 | - Add missing no-op methods for IUserManagement to plugin. 150 | This fixes the tests. @jensens 151 | 152 | - Drop Python 2 support and so require Plone 5.2. @jensens 153 | 154 | - Include permissions from CMFCore to avoid ComponentLookupError. @bsuttor 155 | 156 | - Fixed ModuleNotFoundError: No module named 'App.class_init' on Zope 5. @bsuttor 157 | 158 | - Add french translation @mpeeters 159 | 160 | - PAS event notification IPrincipalCreatedEvent. @jensens 161 | 162 | - Python 3 and Plone 52 compatibility. @cekk 163 | 164 | - Fix #44: Fullfill strictly exact_match when enumerating users @allusa 165 | 166 | - Allow users deletion. @cekk 167 | 168 | - Drop Plone < 5.1.x compatibility. @cekk 169 | 170 | - Fix #54: Notification of PrincipalCreated event. @ericof 171 | 172 | - Closes #55: Support plone.restapi. @ericof 173 | 174 | ## 1.0b1 (2017-11-20) 175 | 176 | - Slighly beautify login modal. @jensens 177 | 178 | - Fix #33" Page does not exist Control Settings. @jensens 179 | 180 | - Fix #31: Link is broken to JSON configuration documentation in help text. @jensens 181 | 182 | - Fix #28: After uninstall plone.external_login_url is still registered and the login broken. @jensens 183 | 184 | - Support for Plone 5.1 tested (worked, ust control-panel icon needed some tweak). 185 | Buildout configuration for 5.1 added. @jensens 186 | 187 | - Install: Hide non-valid profiles at install form. @jensens 188 | 189 | - Additional checks to ensure to never have an empty/None key stored. @jensens 190 | 191 | - Fix #27: Update user data after login. @jensens 192 | 193 | - Fix filter users bug in enumerateUsers plugin where it was always returning 194 | all the users. @sneridagh 195 | 196 | - fix typo and wording of login message @tkimnguyen 197 | 198 | 199 | ## 1.0a7 (2016-02-15) 200 | 201 | - Workaround for None users. @sneridagh 202 | 203 | 204 | ## 1.0a6 (2016-01-11) 205 | 206 | - Fix #21: When you logout and then login again, a new user is created. @jensens 207 | 208 | 209 | ## 1.0a5 (2015-12-04) 210 | 211 | - Fix: #18 "Provider Login" option for "Generator for Plone User ID" seems 212 | broken @jensens 213 | 214 | - Fix: Title indicates if an identity is added @jensens 215 | 216 | - Fix: Correct usage of plone.protect @jensens 217 | 218 | 219 | ## 1.0a4 (2015-11-20) 220 | 221 | - Added german translation @jensens 222 | 223 | - Restored Plone 4 compatibility @keul 224 | 225 | - Added italian translation @keul 226 | 227 | - Proper uninstall @keul 228 | 229 | ## 1.0a3 (2015-11-15) 230 | 231 | - Refactor authomatic-handler to enable adding identities. @jensens 232 | 233 | - Fix: use secret from settings as secret for Authomatic. @jensens 234 | 235 | - Renamed view ``authomatic-login`` to ``authomatic-handler``, because this 236 | view will be used to add an identity too (url must be registered on provider 237 | side sometimes and we want to do this only once). @jensens 238 | 239 | 240 | ## 1.0a2 (2015-11-14) 241 | 242 | - Minimal validation of JSON. @jensens 243 | 244 | - Make the whole ``remember`` procedure a ``safeWrite`` if called from login 245 | view. We can not pass a authenticator token here, because of redirects and 246 | expected return urls . @jensens 247 | 248 | - Allow selection of user id generator strategy. @jensens 249 | 250 | - Allow multiple services for one user. This changes a lot behind the scenes. @jensens 251 | 252 | - Use authomatic.core.User attributes instead of raw provider data. closes [#9](https://github.com/collective/pas.plugins.authomatic/issues/9) @ericof 253 | 254 | 255 | ## 1.0a1 (2015-10-28) 256 | 257 | - Initial release. 258 | -------------------------------------------------------------------------------- /CONTRIBUTORS.md: -------------------------------------------------------------------------------- 1 | # Maintainers 2 | 3 | * [Érico Andrei](https://github.com/ericof) 4 | * [Jens Klein](https://github.com/jensens) 5 | 6 | # Contributors 7 | Several people helped to make this package work see [GitHub](https://github.com/collective/pas.plugins.authomatic/graphs/contributors) for the whole list. 8 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | ### Defensive settings for make: 2 | # https://tech.davis-hansson.com/p/make/ 3 | SHELL:=bash 4 | .ONESHELL: 5 | .SHELLFLAGS:=-xeu -o pipefail -O inherit_errexit -c 6 | .SILENT: 7 | .DELETE_ON_ERROR: 8 | MAKEFLAGS+=--warn-undefined-variables 9 | MAKEFLAGS+=--no-builtin-rules 10 | 11 | # We like colors 12 | # From: https://coderwall.com/p/izxssa/colored-makefile-for-golang-projects 13 | RED=`tput setaf 1` 14 | GREEN=`tput setaf 2` 15 | RESET=`tput sgr0` 16 | YELLOW=`tput setaf 3` 17 | 18 | # Python checks 19 | UV?=uv 20 | 21 | # installed? 22 | ifeq (, $(shell which $(UV) )) 23 | $(error "UV=$(UV) not found in $(PATH)") 24 | endif 25 | 26 | BACKEND_FOLDER=$(shell dirname $(realpath $(firstword $(MAKEFILE_LIST)))) 27 | 28 | ifdef PLONE_VERSION 29 | PLONE_VERSION := $(PLONE_VERSION) 30 | else 31 | PLONE_VERSION := 6.1.1 32 | endif 33 | 34 | VENV_FOLDER=$(BACKEND_FOLDER)/.venv 35 | BIN_FOLDER=$(VENV_FOLDER)/bin 36 | TESTS_FOLDER=$(BACKEND_FOLDER)/tests 37 | 38 | # Environment variables to be exported 39 | export PYTHONWARNINGS := ignore 40 | export DOCKER_BUILDKIT := 1 41 | 42 | all: build 43 | 44 | # Add the following 'help' target to your Makefile 45 | # And add help text after each target name starting with '\#\#' 46 | .PHONY: help 47 | help: ## This help message 48 | @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' 49 | 50 | ############################################ 51 | # Config 52 | ############################################ 53 | instance/etc/zope.ini instance/etc/zope.conf: ## Create instance configuration 54 | @echo "$(GREEN)==> Create instance configuration$(RESET)" 55 | @uvx cookiecutter -f --no-input -c 2.1.1 --config-file instance.yaml gh:plone/cookiecutter-zope-instance 56 | 57 | .PHONY: config 58 | config: instance/etc/zope.ini 59 | 60 | ############################################ 61 | # Installation 62 | ############################################ 63 | requirements-mxdev.txt: ## Generate constraints file 64 | @echo "$(GREEN)==> Generate constraints file$(RESET)" 65 | @echo '-c https://dist.plone.org/release/$(PLONE_VERSION)/constraints.txt' > requirements.txt 66 | @uvx mxdev -c mx.ini 67 | 68 | $(VENV_FOLDER): requirements-mxdev.txt ## Install dependencies 69 | @echo "$(GREEN)==> Install environment$(RESET)" 70 | @uv venv $(VENV_FOLDER) 71 | @uv pip install -r requirements-mxdev.txt 72 | 73 | .PHONY: sync 74 | sync: $(VENV_FOLDER) ## Sync project dependencies 75 | @echo "$(GREEN)==> Sync project dependencies$(RESET)" 76 | @uv pip install -r requirements-mxdev.txt 77 | 78 | .PHONY: install 79 | install: $(VENV_FOLDER) config ## Install Plone and dependencies 80 | 81 | .PHONY: clean 82 | clean: ## Clean installation and instance 83 | @echo "$(RED)==> Cleaning environment and build$(RESET)" 84 | @rm -rf $(VENV_FOLDER) pyvenv.cfg .installed.cfg instance .venv .pytest_cache .ruff_cache constraints* requirements* 85 | $(MAKE) -C "./docs" clean 86 | 87 | ############################################ 88 | # Instance 89 | ############################################ 90 | .PHONY: remove-data 91 | remove-data: ## Remove all content 92 | @echo "$(RED)==> Removing all content$(RESET)" 93 | rm -rf $(VENV_FOLDER) instance/var 94 | 95 | .PHONY: start 96 | start: $(VENV_FOLDER) instance/etc/zope.ini ## Start a Plone instance on localhost:8080 97 | @uv run runwsgi instance/etc/zope.ini 98 | 99 | .PHONY: console 100 | console: $(VENV_FOLDER) instance/etc/zope.ini ## Start a console into a Plone instance 101 | @uv run zconsole debug instance/etc/zope.conf 102 | 103 | .PHONY: create-site 104 | create-site: $(VENV_FOLDER) instance/etc/zope.ini ## Create a new site from scratch 105 | @uv run zconsole run instance/etc/zope.conf ./scripts/create_site.py 106 | 107 | ########################################### 108 | # Docs 109 | ########################################### 110 | .PHONY: docs-install 111 | docs-install: ## Install documentation dependencies 112 | $(MAKE) -C "./docs/" install 113 | 114 | .PHONY: docs-build 115 | docs-build: ## Build documentation 116 | $(MAKE) -C "./docs/" html 117 | 118 | .PHONY: docs-live 119 | docs-live: ## Rebuild documentation on changes, with live-reload in the browser 120 | $(MAKE) -C "./docs/" livehtml 121 | 122 | ############################################ 123 | # QA 124 | ############################################ 125 | .PHONY: lint 126 | lint: ## Check and fix code base according to Plone standards 127 | @echo "$(GREEN)==> Lint codebase$(RESET)" 128 | @uvx ruff@latest check --fix --config $(BACKEND_FOLDER)/pyproject.toml 129 | @uvx pyroma@latest -d . 130 | @uvx check-python-versions@latest . 131 | @uvx zpretty@latest --check src 132 | 133 | .PHONY: format 134 | format: ## Check and fix code base according to Plone standards 135 | @echo "$(GREEN)==> Format codebase$(RESET)" 136 | @uvx ruff@latest check --select I --fix --config $(BACKEND_FOLDER)/pyproject.toml 137 | @uvx ruff@latest format --config $(BACKEND_FOLDER)/pyproject.toml 138 | @uvx zpretty@latest -i src 139 | 140 | .PHONY: check 141 | check: format lint ## Check and fix code base according to Plone standards 142 | 143 | ############################################ 144 | # i18n 145 | ############################################ 146 | .PHONY: i18n 147 | i18n: $(VENV_FOLDER) ## Update locales 148 | @echo "$(GREEN)==> Updating locales$(RESET)" 149 | @uv run python -m pas.plugins.authomatic.locales 150 | 151 | ############################################ 152 | # Tests 153 | ############################################ 154 | .PHONY: test 155 | test: $(VENV_FOLDER) ## run tests 156 | @uv run pytest 157 | 158 | .PHONY: test-coverage 159 | test-coverage: $(VENV_FOLDER) ## run tests with coverage 160 | @uv run pytest --cov=pas.plugins.authomatic --cov-report term-missing 161 | 162 | 163 | ############################################ 164 | # Release 165 | ############################################ 166 | .PHONY: changelog 167 | changelog: ## Release the package to pypi.org 168 | @echo "🚀 Display the draft for the changelog" 169 | @uv run towncrier --draft 170 | 171 | .PHONY: release 172 | release: ## Release the package to pypi.org 173 | @echo "🚀 Release package" 174 | @uv run prerelease 175 | @uv run release 176 | @rm -Rf dist 177 | @uv build 178 | @uv publish 179 | @uv run postrelease 180 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | logo 4 |

OAuth2 / OpenId Authentication in Plone

5 | 6 |
7 | 8 |
9 | 10 | [![PyPI](https://img.shields.io/pypi/v/pas.plugins.authomatic)](https://pypi.org/project/pas.plugins.authomatic/) 11 | [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/pas.plugins.authomatic)](https://pypi.org/project/pas.plugins.authomatic/) 12 | [![PyPI - Wheel](https://img.shields.io/pypi/wheel/pas.plugins.authomatic)](https://pypi.org/project/pas.plugins.authomatic/) 13 | [![PyPI - License](https://img.shields.io/pypi/l/pas.plugins.authomatic)](https://pypi.org/project/pas.plugins.authomatic/) 14 | [![PyPI - Status](https://img.shields.io/pypi/status/pas.plugins.authomatic)](https://pypi.org/project/pas.plugins.authomatic/) 15 | 16 | 17 | [![PyPI - Plone Versions](https://img.shields.io/pypi/frameworkversions/plone/pas.plugins.authomatic)](https://pypi.org/project/pas.plugins.authomatic/) 18 | 19 | [![CI](https://github.com/collective/pas.plugins.authomatic/actions/workflows/main.yml/badge.svg)](https://github.com/collective/pas.plugins.authomatic/actions/workflows/main.yml) 20 | ![Code Style](https://img.shields.io/badge/Code%20Style-Black-000000) 21 | 22 | [![GitHub contributors](https://img.shields.io/github/contributors/collective/pas.plugins.authomatic)](https://github.com/collective/pas.plugins.authomatic) 23 | [![GitHub Repo stars](https://img.shields.io/github/stars/collective/pas.plugins.authomatic?style=social)](https://github.com/collective/pas.plugins.authomatic) 24 | 25 |
26 | 27 | ## Features 28 | 29 | **pas.plugins.authomatic** provides OAuth2 and OpenID login capability for Plone sites by integrating the awesome [Authomatic](https://authomatic.github.io/authomatic/) package. 30 | 31 | ``` 32 | Authomatic is a framework agnostic library 33 | for Python web applications 34 | with a minimalistic but powerful interface 35 | which simplifies authentication of users 36 | by third party providers like Facebook or Twitter 37 | through standards like OAuth and OpenID. 38 | ``` 39 | *by author Peter Hudec on Authomatic website* 40 | 41 | ### Supported Providers 42 | 43 | Out of the box, **pas.plugins.authomatic** supports the following providers 44 | 45 | #### OAuth 1.0 46 | 47 | - Bitbucket 48 | - Flickr 49 | - Meetup 50 | - Plurk 51 | - Twitter 52 | - Tumblr 53 | - UbuntuOne 54 | - Vimeo 55 | - Xero 56 | - Xing 57 | - Yahoo 58 | 59 | #### OAuth 2.0 60 | 61 | - Amazon 62 | - Behance 63 | - Bitly 64 | - Cosm 65 | - DeviantART 66 | - Eventbrite 67 | - Facebook 68 | - Foursquare 69 | - GitHub 70 | - Google 71 | - LinkedIn 72 | - PayPal 73 | - Reddit 74 | - Viadeo 75 | - VK 76 | - WindowsLive 77 | - Yammer 78 | - Yandex 79 | 80 | #### OpenID 81 | 82 | - python-openid 83 | - Google App Engine based OpenID. 84 | 85 | 86 | ## Documentation 87 | 88 | This package supports Plone sites using Volto or the Classic UI. 89 | 90 | ### Volto Frontend 91 | 92 | - Endpoint `@login` with GET: Returns list of authentication options 93 | - Endpoint `@login-authomatic` with GET: Provide information to start the OAuth process. 94 | - Endpoint `@login-authomatic` with POST: Handles OAuth login and returns a JSON web token (JWT). 95 | - For Volto sites you must also install [@plone-collective/volto-authomatic](https://github.com/collective/volto-authomatic). 96 | - Plugin configuration is available in the Control-panel `/controlpanel/authomatic` (linked under users) 97 | - Example JSON configuration (first level key is the PROVIDER): 98 | 99 | ```json 100 | { 101 | "github": { 102 | "display": { 103 | "title": "Github", 104 | "cssclasses": { 105 | "button": "plone-btn plone-btn-default", 106 | "icon": "glypicon glyphicon-github" 107 | }, 108 | "as_form": false 109 | }, 110 | "propertymap": { 111 | "email": "email", 112 | "link": "home_page", 113 | "location": "location", 114 | "name": "fullname" 115 | }, 116 | "class_": "authomatic.providers.oauth2.GitHub", 117 | "consumer_key": "5c4901d141e736f114a7", 118 | "consumer_secret": "d4692ca3c0ab6cc1f8b28d3ccb1ea15b61e7ef5c", 119 | "access_headers": { 120 | "User-Agent": "Plone Authomatic Plugin" 121 | } 122 | }, 123 | } 124 | ``` 125 | 126 | ### Classic UI 127 | 128 | - This package creates a view called `authomatic-handler` where you can login with different providers. 129 | - The view can be used as well to add an identity from a provider to an existing account. 130 | - The provider is choosen in the URL so if you call `/authomatic-handler/PROVIDER` you will use PROVIDER to login. 131 | - Plugin configuration is available in the Controlpanel `@@authomatic-controlpanel` (linked under users) 132 | - Example JSON configuration (first level key is the PROVIDER): 133 | 134 | ```json 135 | { 136 | "github": { 137 | "display": { 138 | "title": "Github", 139 | "cssclasses": { 140 | "button": "plone-btn plone-btn-default", 141 | "icon": "glypicon glyphicon-github" 142 | }, 143 | "as_form": false 144 | }, 145 | "propertymap": { 146 | "email": "email", 147 | "link": "home_page", 148 | "location": "location", 149 | "name": "fullname" 150 | }, 151 | "class_": "authomatic.providers.oauth2.GitHub", 152 | "consumer_key": "5c4901d141e736f114a7", 153 | "consumer_secret": "d4692ca3c0ab6cc1f8b28d3ccb1ea15b61e7ef5c", 154 | "access_headers": { 155 | "User-Agent": "Plone Authomatic Plugin" 156 | } 157 | }, 158 | } 159 | ``` 160 | 161 | ## Installation 162 | 163 | Add **pas.plugins.authomatic** to the Plone installation using `pip`: 164 | 165 | ```bash 166 | pip install pas.plugins.authomatic 167 | ``` 168 | or add it as a dependency on your package's `setup.py` 169 | 170 | ```python 171 | install_requires = [ 172 | "pas.plugins.authomatic", 173 | "Products.CMFPlone", 174 | "plone.restapi", 175 | "setuptools", 176 | ], 177 | ``` 178 | 179 | Start Plone and activate the plugin in the addons control-panel. 180 | 181 | ## Configuration 182 | 183 | Using Classic UI, go to the `Authomatic` controlpanel. 184 | 185 | Screenshot 186 | 187 | Configuration parameters for the different authorization are provided as JSON text in there. We use JSON because of its flexibility. 188 | 189 | Screenshot 190 | 191 | Details about the configuration of each provider can be found at [Authomatic provider section](https://authomatic.github.io/authomatic/reference/providers.html). 192 | 193 | There are some differences in configuration: 194 | 195 | - Value of `"class_"` has to be a string, which is then resolved as a dotted path. 196 | - Each provider can get an optional entry `display` with sub-enties such as: 197 | 198 | - `title` which is used in the templates instead of the section name. 199 | - `iconclasses` which is applied in the templates to an span. 200 | - `buttonclasses` which is applied in the templates to the button. 201 | - `as_form` (true/false) which renders a form for OpenId providers. 202 | 203 | - Each provider can get an optional entry `propertymap`. 204 | It is a mapping from authomatic/provider user properties to plone user properties, like `"fullname": "name",`. Look at each providers documentation which properties are available. 205 | 206 | ## Integration with Entra ID 207 | 208 | Enumeration PAS plugin: if you're using **pas.plugins.authomatic** with *Microsoft Entra ID*, we recommend pairing it with [pas.plugins.eea](https://github.com/eea/pas.plugins.eea) for proper user enumeration and metadata synchronization. This complementary plugin enables listing all the Entra ID users and groups and is compatible with both Plone 5 and Plone 6. 209 | 210 | ## Source Code and Contributions 211 | 212 | If you want to help with the development (improvement, update, bug-fixing, ...) of `pas.plugins.authomatic` this is a great idea! 213 | 214 | - [Issue Tracker](https://github.com/collective/pas.plugins.authomatic/issues) 215 | - [Source Code](https://github.com/collective/pas.plugins.authomatic/) 216 | 217 | Please do larger changes on a branch and submit a Pull Request. 218 | 219 | Creator of **pas.plugins.authomatic** is Jens Klein. 220 | 221 | We appreciate any contribution and if a release is needed to be done on PyPI, please just contact one of us. 222 | 223 | ### Development 224 | 225 | You need a working `python` environment (system, virtualenv, pyenv, etc) version 3.7 or superior. 226 | 227 | Then install the dependencies and a development instance using: 228 | 229 | ```bash 230 | make install 231 | ``` 232 | 233 | To run tests for this package: 234 | 235 | ```bash 236 | make test 237 | ``` 238 | 239 | To lint the codebase: 240 | 241 | ```bash 242 | make lint 243 | ``` 244 | 245 | By default we use the latest Plone version in the 6.x series. 246 | 247 | ### Changelog entries 248 | 249 | The `CHANGES.md` file is managed using [towncrier](https://towncrier.readthedocs.io/). All non trivial changes must be accompanied by an entry in the `news` directory. Using such a tool instead of editing the file directly, has the following benefits: 250 | 251 | * It avoids merge conflicts in CHANGES.md. 252 | * It avoids news entries ending up under the wrong version header. 253 | 254 | The best way of adding news entries is this: 255 | 256 | * First create an issue describing the change you want to make. The issue number serves as a unique indicator for the news entry. As example, let's say you have created issue 42. 257 | 258 | * Create a file inside of the news/ directory, named after that issue number: 259 | 260 | * For bug fixes: 42.bugfix. 261 | * For new features: 42.feature. 262 | * For internal changes: 42.internal. 263 | * For breaking changs: 42.breaking. 264 | * Any other extensions are ignored. 265 | 266 | * The contents of this file should be markdown formatted text that will be used as the content of the CHANGES.md entry. 267 | 268 | Towncrier will automatically add a reference to the issue when rendering the CHANGES.md file. 269 | 270 | ### Releasing `pas.plugins.authomatic` 271 | 272 | Releasing `pas.plugins.authomatic` is done using a combination of [zest.releaser](https://zestreleaser.readthedocs.io/) and [hatch](https://hatch.pypa.io/latest/). 273 | 274 | To release the package run: 275 | 276 | ```bash 277 | make release 278 | ``` 279 | 280 | ## License 281 | 282 | The project is licensed under the GPLv2. 283 | -------------------------------------------------------------------------------- /dependabot.yml: -------------------------------------------------------------------------------- 1 | # Generated from: 2 | # https://github.com/plone/meta/tree/main/config/default 3 | # See the inline comments on how to expand/tweak this configuration file 4 | version: 2 5 | updates: 6 | 7 | - package-ecosystem: "github-actions" 8 | directory: "/" 9 | schedule: 10 | # Check for updates to GitHub Actions every week 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /docs/authomatic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | image/svg+xml 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /docs/plone-control-panel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/collective/pas.plugins.authomatic/64357d3f3015e5d01dd29e6c1c7fa7a710fd90ab/docs/plone-control-panel.png -------------------------------------------------------------------------------- /docs/plugin-settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/collective/pas.plugins.authomatic/64357d3f3015e5d01dd29e6c1c7fa7a710fd90ab/docs/plugin-settings.png -------------------------------------------------------------------------------- /instance.yaml: -------------------------------------------------------------------------------- 1 | default_context: 2 | initial_user_password: 'admin' 3 | zcml_package_includes: 'pas.plugins.authomatic' 4 | -------------------------------------------------------------------------------- /mx.ini: -------------------------------------------------------------------------------- 1 | ; This is a mxdev configuration file 2 | ; it can be used to override versions of packages already defined in the 3 | ; constraints files and to add new packages from VCS like git. 4 | ; to learn more about mxdev visit https://pypi.org/project/mxdev/ 5 | 6 | [settings] 7 | main-package = -e .[test] 8 | ; example how to override a package version 9 | ; version-overrides = 10 | ; example.package==2.1.0a2 11 | 12 | ; example section to use packages from git 13 | ; [example.contenttype] 14 | ; url = https://github.com/collective/example.contenttype.git 15 | ; pushurl = git@github.com:collective/example.contenttype.git 16 | ; extras = test 17 | ; branch = feature-7 18 | version-overrides = 19 | plone.restapi>=9.10.0 20 | pytest-plone>=1.0.0a0 21 | plone.autoinclude>=2.0.3 22 | zest.releaser>=9.5.0 23 | -------------------------------------------------------------------------------- /news/.changelog_template.jinja: -------------------------------------------------------------------------------- 1 | {% if sections[""] %} 2 | {% for category, val in definitions.items() if category in sections[""] %} 3 | 4 | ### {{ definitions[category]['name'] }} 5 | 6 | {% for text, values in sections[""][category].items() %} 7 | - {{ text }} {{ values|join(', ') }} 8 | {% endfor %} 9 | 10 | {% endfor %} 11 | {% else %} 12 | No significant changes. 13 | 14 | 15 | {% endif %} 16 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "pas.plugins.authomatic" 3 | dynamic = ["version"] 4 | description = "Provides OAuth2/OpenID login for Plone using Authomatic." 5 | readme = "README.md" 6 | license = {text = "GNU General Public License v2 (GPLv2)"} 7 | requires-python = ">=3.10" 8 | authors = [ 9 | { name = "Jens Klein", email = "dev@bluedynamics.com" }, 10 | { name = "Érico Andrei", email = "ericof@plone.org" }, 11 | { name = "Matthias Dollfuss" }, 12 | ] 13 | keywords = [ 14 | "Authentication", 15 | "OAuth", 16 | "PAS", 17 | "Plone", 18 | "Python", 19 | ] 20 | classifiers = [ 21 | "Development Status :: 5 - Production/Stable", 22 | "Environment :: Web Environment", 23 | "Framework :: Plone", 24 | "Framework :: Plone :: 6.0", 25 | "Framework :: Plone :: 6.1", 26 | "Framework :: Plone :: Addon", 27 | "Framework :: Zope", 28 | "Framework :: Zope :: 5", 29 | "License :: OSI Approved :: GNU General Public License v2 (GPLv2)", 30 | "Programming Language :: Python", 31 | "Programming Language :: Python :: 3 :: Only", 32 | "Programming Language :: Python :: 3.10", 33 | "Programming Language :: Python :: 3.11", 34 | "Programming Language :: Python :: 3.12", 35 | "Programming Language :: Python :: 3.13", 36 | ] 37 | dependencies = [ 38 | "Products.CMFPlone>=6.0", 39 | "authomatic>=1.3.0", 40 | "plone.restapi>=9.10.0", 41 | "plone.api", 42 | ] 43 | 44 | [project.optional-dependencies] 45 | test = [ 46 | "collective.MockMailHost", 47 | "plone.app.robotframework[debug]", 48 | "plone.app.testing", 49 | "plone.restapi[test]", 50 | "zest.releaser[recommended]", 51 | "pytest-cov", 52 | "pytest-plone>=1.0.0a1", 53 | "zest-releaser[recommended]>=9.5.0", 54 | "zestreleaser-towncrier>=1.3.0", 55 | ] 56 | 57 | [project.urls] 58 | Changelog = "https://github.com/collective/pas.plugins.authomatic/blob/main/CHANGELOG.md" 59 | Homepage = "https://github.com/collective/pas.plugins.authomatic" 60 | Issues = "https://github.com/collective/pas.plugins.authomatic/issues" 61 | Repository = "https://github.com/collective/pas.plugins.authomatic/" 62 | 63 | 64 | [project.entry-points."z3c.autoinclude.plugin"] 65 | target = "plone" 66 | 67 | [build-system] 68 | requires = ["hatchling"] 69 | build-backend = "hatchling.build" 70 | 71 | [tool.hatch.version] 72 | path = "src/pas/plugins/authomatic/__init__.py" 73 | 74 | [tool.hatch.build] 75 | strict-naming = true 76 | 77 | [tool.hatch.build.targets.sdist] 78 | include = [ 79 | "/*.md", 80 | "/*.yaml", 81 | "/*.yml", 82 | "/docs", 83 | "/src", 84 | "/tests", 85 | ] 86 | exclude = [ 87 | "/.github", 88 | ] 89 | 90 | [tool.hatch.build.targets.wheel] 91 | packages = ["src/pas",] 92 | 93 | [tool.uv] 94 | managed = false 95 | 96 | [tool.towncrier] 97 | package = "pas.plugins.authomatic" 98 | package_dir = "src" 99 | directory = "news/" 100 | filename = "CHANGELOG.md" 101 | start_string = "\n" 102 | issue_format = "[#{issue}](https://github.com/collective/pas.plugins.authomatic/issues/{issue})" 103 | title_format = "## {version} ({project_date})" 104 | template = "news/.changelog_template.jinja" 105 | underlines = ["", "", ""] 106 | 107 | [[tool.towncrier.type]] 108 | directory = "breaking" 109 | name = "Breaking changes:" 110 | showcontent = true 111 | 112 | [[tool.towncrier.type]] 113 | directory = "feature" 114 | name = "New features:" 115 | showcontent = true 116 | 117 | [[tool.towncrier.type]] 118 | directory = "bugfix" 119 | name = "Bug fixes:" 120 | showcontent = true 121 | 122 | [[tool.towncrier.type]] 123 | directory = "internal" 124 | name = "Internal:" 125 | showcontent = true 126 | 127 | [[tool.towncrier.type]] 128 | directory = "documentation" 129 | name = "Documentation:" 130 | showcontent = true 131 | 132 | [[tool.towncrier.type]] 133 | directory = "tests" 134 | name = "Tests" 135 | showcontent = true 136 | 137 | [tool.black] 138 | enable-unstable-feature = [ 139 | "hug_parens_with_braces_and_square_brackets" 140 | ] 141 | 142 | [tool.ruff] 143 | target-version = "py310" 144 | line-length = 88 145 | fix = true 146 | lint.select = [ 147 | # flake8-2020 148 | "YTT", 149 | # flake8-bandit 150 | "S", 151 | # flake8-bugbear 152 | "B", 153 | # flake8-builtins 154 | "A", 155 | # flake8-comprehensions 156 | "C4", 157 | # flake8-debugger 158 | "T10", 159 | # flake8-simplify 160 | "SIM", 161 | # mccabe 162 | "C90", 163 | # pycodestyle 164 | "E", "W", 165 | # pyflakes 166 | "F", 167 | # pygrep-hooks 168 | "PGH", 169 | # pyupgrade 170 | "UP", 171 | # ruff 172 | "RUF", 173 | ] 174 | lint.ignore = [ 175 | # DoNotAssignLambda 176 | "E731", 177 | ] 178 | 179 | [tool.ruff.lint.isort] 180 | case-sensitive = false 181 | no-sections = true 182 | force-single-line = true 183 | from-first = true 184 | lines-after-imports = 2 185 | lines-between-types = 1 186 | order-by-type = false 187 | 188 | [tool.ruff.format] 189 | preview = true 190 | 191 | [tool.ruff.lint.per-file-ignores] 192 | "tests/*" = ["E501", "RUF001", "S101"] 193 | 194 | [tool.pytest.ini_options] 195 | testpaths = ["tests"] 196 | 197 | [tool.coverage.run] 198 | source_pkgs = ["pas.plugins.authomatic", "tests"] 199 | branch = true 200 | parallel = true 201 | omit = [ 202 | "src/pas/plugins/authomatic/locales/*", 203 | ] 204 | 205 | [tool.zest-releaser] 206 | upload-pypi = false # Build and upload with uv 207 | python-file-with-version = "src/pas/plugins/authomatic/__init__.py" 208 | -------------------------------------------------------------------------------- /src/pas/__init__.py: -------------------------------------------------------------------------------- 1 | __import__("pkg_resources").declare_namespace(__name__) 2 | -------------------------------------------------------------------------------- /src/pas/plugins/__init__.py: -------------------------------------------------------------------------------- 1 | __import__("pkg_resources").declare_namespace(__name__) 2 | -------------------------------------------------------------------------------- /src/pas/plugins/authomatic/__init__.py: -------------------------------------------------------------------------------- 1 | from pas.plugins.authomatic.patches import apply_patches 2 | 3 | 4 | __version__ = "2.0.1.dev0" 5 | 6 | 7 | PACKAGE_NAME = "pas.plugins.authomatic" 8 | 9 | 10 | apply_patches() 11 | 12 | 13 | def initialize(context): 14 | """Initializer called when used as a Zope 2 product. 15 | 16 | This is referenced from configure.zcml. Regstrations as a "Zope 2 product" 17 | is necessary for GenericSetup profiles to work, for example. 18 | 19 | Here, we call the Archetypes machinery to register our content types 20 | with Zope and the CMF. 21 | """ 22 | from AccessControl.Permissions import add_user_folders 23 | from pas.plugins.authomatic.plugin import AuthomaticPlugin 24 | from pas.plugins.authomatic.plugin import manage_addAuthomaticPlugin 25 | from pas.plugins.authomatic.plugin import manage_addAuthomaticPluginForm 26 | from pas.plugins.authomatic.plugin import tpl_dir 27 | from Products.PluggableAuthService import registerMultiPlugin 28 | 29 | registerMultiPlugin(AuthomaticPlugin.meta_type) 30 | context.registerClass( 31 | AuthomaticPlugin, 32 | permission=add_user_folders, 33 | icon=tpl_dir / "authomatic.png", 34 | constructors=(manage_addAuthomaticPluginForm, manage_addAuthomaticPlugin), 35 | visibility=None, 36 | ) 37 | -------------------------------------------------------------------------------- /src/pas/plugins/authomatic/browser/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/collective/pas.plugins.authomatic/64357d3f3015e5d01dd29e6c1c7fa7a710fd90ab/src/pas/plugins/authomatic/browser/__init__.py -------------------------------------------------------------------------------- /src/pas/plugins/authomatic/browser/add_plugin.pt: -------------------------------------------------------------------------------- 1 |

Header

2 | 3 |

Add Authomatic plugin

4 | 5 |

6 | Adds the pas.plugin.authomatic plugin. 7 |

8 | 9 |
12 | 13 | 14 | 17 | 23 | 24 | 25 | 28 | 33 | 34 | 35 | 42 | 43 |
15 | Id 16 | 18 | 22 |
26 | Title 27 | 29 | 32 |
36 |
37 | 40 |
41 |
44 |
45 |

Footer

46 | -------------------------------------------------------------------------------- /src/pas/plugins/authomatic/browser/authomatic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/collective/pas.plugins.authomatic/64357d3f3015e5d01dd29e6c1c7fa7a710fd90ab/src/pas/plugins/authomatic/browser/authomatic.png -------------------------------------------------------------------------------- /src/pas/plugins/authomatic/browser/authomatic.pt: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 10 |

Log in with

11 |
12 | 13 |

Add identity

14 |
15 |

Choose one of these external authentication providers

18 | 59 |
60 | 61 | 62 | -------------------------------------------------------------------------------- /src/pas/plugins/authomatic/browser/configure.zcml: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | 15 | 16 | 17 | 24 | 25 | 26 | 33 | 34 | 35 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /src/pas/plugins/authomatic/browser/controlpanel.py: -------------------------------------------------------------------------------- 1 | from pas.plugins.authomatic.interfaces import _ 2 | from pas.plugins.authomatic.interfaces import IPasPluginsAuthomaticLayer 3 | from pas.plugins.authomatic.interfaces import IPasPluginsAuthomaticSettings 4 | from plone.app.registry.browser import controlpanel 5 | from plone.restapi.controlpanels import RegistryConfigletPanel 6 | from zope.component import adapter 7 | from zope.interface import Interface 8 | 9 | 10 | class AuthomaticSettingsEditForm(controlpanel.RegistryEditForm): 11 | schema = IPasPluginsAuthomaticSettings 12 | label = _("PAS Authomatic Plugin Settings") 13 | description = "" 14 | 15 | def updateFields(self): 16 | super().updateFields() 17 | # self.fields['json_config'].widgetFactory = TextLinesFieldWidget 18 | 19 | def updateWidgets(self): 20 | super().updateWidgets() 21 | 22 | 23 | class AuthomaticSettingsEditFormSettingsControlPanel( 24 | controlpanel.ControlPanelFormWrapper 25 | ): 26 | form = AuthomaticSettingsEditForm 27 | 28 | 29 | @adapter(Interface, IPasPluginsAuthomaticLayer) 30 | class AuthomaticSettingsConfigletPanel(RegistryConfigletPanel): 31 | """Control Panel endpoint""" 32 | 33 | schema = IPasPluginsAuthomaticSettings 34 | configlet_id = "authomatic" 35 | configlet_category_id = "plone-users" 36 | title = _("Authomatic settings") 37 | group = "" 38 | schema_prefix = "pas.plugins.authomatic.interfaces.IPasPluginsAuthomaticSettings" 39 | -------------------------------------------------------------------------------- /src/pas/plugins/authomatic/browser/resources/authomatic-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 18 | 20 | 43 | 45 | 46 | 48 | image/svg+xml 49 | 51 | 52 | 53 | 54 | 55 | 60 | 65 | 68 | 73 | 78 | 79 | 80 | 81 | -------------------------------------------------------------------------------- /src/pas/plugins/authomatic/browser/resources/authomatic.css: -------------------------------------------------------------------------------- 1 | // -------------------------------------------------------------------------------- /src/pas/plugins/authomatic/browser/resources/authomatic.less: -------------------------------------------------------------------------------- 1 | // span.icon-controlpanel-authomatic { 2 | // background-image: url(authomatic-logo.svg); 3 | // background-size: 72px auto, 72px auto; 4 | // height: 72px; 5 | // width: 72px; 6 | // margin-left: auto; 7 | // margin-right: auto; 8 | // &:before { 9 | // content: ''; 10 | // } 11 | // } 12 | 13 | -------------------------------------------------------------------------------- /src/pas/plugins/authomatic/browser/view.py: -------------------------------------------------------------------------------- 1 | from authomatic import Authomatic 2 | from pas.plugins.authomatic.integration import ZopeRequestAdapter 3 | from pas.plugins.authomatic.interfaces import _ 4 | from pas.plugins.authomatic.utils import authomatic_cfg 5 | from pas.plugins.authomatic.utils import authomatic_settings 6 | from plone import api 7 | from plone.app.layout.navigation.interfaces import INavigationRoot 8 | from plone.protect.interfaces import IDisableCSRFProtection 9 | from Products.CMFCore.interfaces import ISiteRoot 10 | from Products.Five.browser import BrowserView 11 | from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile 12 | from zope.interface import alsoProvides 13 | from zope.interface import implementer 14 | from zope.publisher.interfaces import IPublishTraverse 15 | 16 | import logging 17 | 18 | 19 | logger = logging.getLogger(__file__) 20 | 21 | 22 | def is_root(obj): 23 | """Check if current context is Navigation root or a Portal.""" 24 | return ISiteRoot.providedBy(obj) or INavigationRoot.providedBy(obj) 25 | 26 | 27 | @implementer(IPublishTraverse) 28 | class AuthomaticView(BrowserView): 29 | template = ViewPageTemplateFile("authomatic.pt") 30 | 31 | zope_request_adapter_factory = ZopeRequestAdapter 32 | 33 | @property 34 | def zope_request_adapter(self): 35 | return self.zope_request_adapter_factory(self) 36 | 37 | def publishTraverse(self, request, name): 38 | if name and not hasattr(self, "provider"): 39 | self.provider = name 40 | return self 41 | 42 | @property 43 | def _provider_names(self): 44 | cfgs = authomatic_cfg() 45 | if not cfgs: 46 | raise ValueError("Authomatic configuration has errors.") 47 | return list(cfgs.keys()) 48 | 49 | def providers(self): 50 | cfgs = authomatic_cfg() 51 | if not cfgs: 52 | raise ValueError("Authomatic configuration has errors.") 53 | for identifier, cfg in cfgs.items(): 54 | entry = cfg.get("display", {}) 55 | cssclasses = entry.get("cssclasses", {}) 56 | record = { 57 | "identifier": identifier, 58 | "title": entry.get("title", identifier), 59 | "iconclasses": cssclasses.get("icon", "glypicon glyphicon-log-in"), 60 | "buttonclasses": cssclasses.get( 61 | "button", "plone-btn plone-btn-default" 62 | ), 63 | "as_form": entry.get("as_form", False), 64 | } 65 | yield record 66 | 67 | def _add_identity(self, result, provider_name): 68 | # delegate to PAS plugin to add the identity 69 | alsoProvides(self.request, IDisableCSRFProtection) 70 | aclu = api.portal.get_tool("acl_users") 71 | aclu.authomatic.remember_identity(result) 72 | api.portal.show_message( 73 | _( 74 | "added_identity", 75 | default="Added identity provided by ${provider}", 76 | mapping={"provider": provider_name}, 77 | ), 78 | self.request, 79 | ) 80 | 81 | def _remember_identity(self, result, provider_name): 82 | alsoProvides(self.request, IDisableCSRFProtection) 83 | aclu = api.portal.get_tool("acl_users") 84 | aclu.authomatic.remember(result) 85 | api.portal.show_message( 86 | _( 87 | "logged_in_with", 88 | "Logged in with ${provider}", 89 | mapping={"provider": provider_name}, 90 | ), 91 | self.request, 92 | ) 93 | 94 | def _handle_error(self, error): 95 | try: 96 | return error.message 97 | except AttributeError: 98 | return str(error) 99 | 100 | def __call__(self): 101 | provider = getattr(self, "provider", "") 102 | if (cfg := authomatic_cfg()) is None: 103 | return _("Authomatic is not configured") 104 | if not is_root(self.context): 105 | # callback url is expected on either navigationroot or site root 106 | # so bevor going on redirect 107 | root = api.portal.get_navigation_root(self.context) 108 | root_url = root.absolute_url() 109 | self.request.response.redirect(f"{root_url}/authomatic-handler/{provider}") 110 | return _("redirecting") 111 | if not provider: 112 | return self.template() 113 | elif provider not in cfg: 114 | return _("Provider not supported") 115 | if not self.is_anon: 116 | if provider in self._provider_names: 117 | logger.warning( 118 | f"Provider {provider} is already connected to current user" 119 | ) 120 | return self._redirect() 121 | # TODO: some sort of CSRF check might be needed, so that 122 | # not an account got connected by CSRF. Research needed. 123 | pass 124 | secret = authomatic_settings().secret 125 | auth = Authomatic(cfg, secret=secret) 126 | result = auth.login(self.zope_request_adapter, self.provider) 127 | if not result: 128 | logger.info("return from view") 129 | # let authomatic do its work 130 | return 131 | elif error := result.error: 132 | return self._handle_error(error) 133 | display = cfg[self.provider].get("display", {}) 134 | provider_name = display.get("title", self.provider) 135 | if not self.is_anon: 136 | # now we delegate to PAS plugin to add the identity 137 | self._add_identity(result, provider_name) 138 | else: 139 | # now we delegate to PAS plugin in order to login 140 | self._remember_identity(result, provider_name) 141 | 142 | return self._redirect() 143 | 144 | def _redirect(self): 145 | next_url = self.request.cookies.get("next_url", "") 146 | self.request.response.expireCookie("next_url") 147 | self.request.response.redirect(self.context.absolute_url() + next_url) 148 | return _("redirecting") 149 | 150 | @property 151 | def is_anon(self): 152 | return api.user.is_anonymous() 153 | -------------------------------------------------------------------------------- /src/pas/plugins/authomatic/configure.zcml: -------------------------------------------------------------------------------- 1 | 8 | 9 | 13 | 14 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 31 | 35 | 39 | 43 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /src/pas/plugins/authomatic/integration/__init__.py: -------------------------------------------------------------------------------- 1 | from .restapi import RestAPIAdapter 2 | from .zope import ZopeRequestAdapter 3 | 4 | 5 | __all__ = [ 6 | "RestAPIAdapter", 7 | "ZopeRequestAdapter", 8 | ] 9 | -------------------------------------------------------------------------------- /src/pas/plugins/authomatic/integration/restapi.py: -------------------------------------------------------------------------------- 1 | from authomatic.adapters import BaseAdapter 2 | 3 | import logging 4 | 5 | 6 | logger = logging.getLogger(__file__) 7 | 8 | 9 | Headers = dict | None 10 | 11 | 12 | class RestAPIAdapter(BaseAdapter): 13 | """Adapter for plone.restapi usage.""" 14 | 15 | frontend_route: str = "login-authomatic" 16 | 17 | def __init__( 18 | self, view, provider: str, params: Headers = None, cookies: Headers = None 19 | ): 20 | """Initialize the adapter. 21 | 22 | :param view: Service 23 | :param provider: ID of the Authomatic provider. 24 | :param params: Query string parameters, parsed as a dictionary. 25 | :param cookies: Dictionary with cookies information. 26 | """ 27 | self.view = view 28 | self.public_url = view.public_url 29 | self.provider = provider 30 | self.headers = {} 31 | self._cookies = cookies if cookies else {} 32 | self._params = params 33 | 34 | # ========================================================================= 35 | # Request 36 | # ========================================================================= 37 | 38 | @property 39 | def url(self) -> str: 40 | """OAuth redirection URL. 41 | 42 | :returns: URL to be used in the redirection. 43 | """ 44 | return f"{self.public_url}/{self.frontend_route}/{self.provider}" 45 | 46 | @property 47 | def params(self): 48 | """HTTP parameters (GET/POST). 49 | 50 | :returns: Dictionary with HTTP parameters. 51 | """ 52 | params = self._params 53 | if not params: 54 | params = dict(self.view.request.form) 55 | to_remove = ["provider", "publicUrl"] 56 | params = {k: v for k, v in params.items() if k not in to_remove} 57 | return params 58 | 59 | @property 60 | def cookies(self) -> dict: 61 | """Cookies information. 62 | 63 | :returns: Dictionary with cookies to be passed to Authomatic. 64 | """ 65 | return self._cookies 66 | 67 | # ========================================================================= 68 | # Response 69 | # ========================================================================= 70 | 71 | def write(self, value: str): 72 | """Log Authomatic attempts to write to response.""" 73 | logger.debug(f"Authomatic wrote {value} to response.") 74 | 75 | def set_header(self, key: str, value: str): 76 | """Store Authomatic header values. 77 | 78 | :params key: Header key. 79 | :params value: Header value. 80 | """ 81 | self.headers[key] = value 82 | logger.debug(f"Authomatic set header {key} with {value}to response.") 83 | 84 | def set_status(self, status: int): 85 | """Log Authomatic attempts to set status code to response. 86 | 87 | :param status: Status code. 88 | """ 89 | logger.debug(f"Authomatic set code {status} to response.") 90 | -------------------------------------------------------------------------------- /src/pas/plugins/authomatic/integration/zope.py: -------------------------------------------------------------------------------- 1 | from authomatic.adapters import BaseAdapter 2 | 3 | import http 4 | import logging 5 | 6 | 7 | logger = logging.getLogger(__file__) 8 | 9 | 10 | class ZopeRequestAdapter(BaseAdapter): 11 | """Adapter for Zope2 requests package.""" 12 | 13 | def __init__(self, view): 14 | """ 15 | :param view: 16 | BrowserView 17 | """ 18 | self.view = view 19 | 20 | # ========================================================================= 21 | # Request 22 | # ========================================================================= 23 | 24 | @property 25 | def url(self): 26 | view_url = self.view.context.absolute_url() 27 | url = f"{view_url}/authomatic-handler/{self.view.provider}" 28 | logger.debug("url" + url) 29 | return url 30 | 31 | @property 32 | def params(self): 33 | return dict(self.view.request.form) 34 | 35 | @property 36 | def cookies(self): 37 | # special handling since zope parsing does to much decoding 38 | cookie = http.cookies.SimpleCookie() 39 | cookie.load(self.view.request["HTTP_COOKIE"]) 40 | cookies = {k: c.value for k, c in cookie.items()} 41 | return cookies 42 | 43 | # ========================================================================= 44 | # Response 45 | # ========================================================================= 46 | 47 | def write(self, value): 48 | logger.debug("write " + value) 49 | self.view.request.response.write(value) 50 | 51 | def set_header(self, key, value): 52 | logger.info("set_header " + key + "=" + value) 53 | self.view.request.response.setHeader(key, value) 54 | 55 | def set_status(self, status): 56 | code, message = status.split(" ") 57 | code = int(code) 58 | logger.debug(f"set_status {code}") 59 | self.view.request.response.setStatus(code) 60 | -------------------------------------------------------------------------------- /src/pas/plugins/authomatic/interfaces.py: -------------------------------------------------------------------------------- 1 | from zope import schema 2 | from zope.component import getUtilitiesFor 3 | from zope.i18nmessageid import MessageFactory 4 | from zope.interface import Interface 5 | from zope.interface import Invalid 6 | from zope.interface import provider 7 | from zope.publisher.interfaces.browser import IDefaultBrowserLayer 8 | from zope.schema.interfaces import IVocabularyFactory 9 | from zope.schema.vocabulary import SimpleTerm 10 | from zope.schema.vocabulary import SimpleVocabulary 11 | 12 | import json 13 | import random 14 | import string 15 | 16 | 17 | _ = MessageFactory("pas.plugins.authomatic") 18 | 19 | DEFAULT_ID = "authomatic" 20 | 21 | DEFAULT_CONFIG = """\ 22 | { 23 | "github": { 24 | "id": 1, 25 | "display": { 26 | "title": "Github", 27 | "cssclasses": { 28 | "button": "plone-btn plone-btn-default", 29 | "icon": "glypicon glyphicon-github" 30 | }, 31 | "as_form": false 32 | }, 33 | "propertymap": { 34 | "email": "email", 35 | "link": "home_page", 36 | "location": "location", 37 | "name": "fullname" 38 | }, 39 | "class_": "authomatic.providers.oauth2.GitHub", 40 | "consumer_key": "Example, please get a key and secret. See", 41 | "consumer_secret": "https://github.com/settings/applications/new", 42 | "access_headers": { 43 | "User-Agent": "Plone (pas.plugins.authomatic)" 44 | } 45 | } 46 | } 47 | """ 48 | 49 | random_secret = "".join( 50 | random.SystemRandom().choice(string.ascii_letters + string.digits) 51 | for _ in range(10) 52 | ) 53 | 54 | 55 | def validate_cfg_json(value): 56 | """check that we have at least valid json and its a dict""" 57 | try: 58 | jv = json.loads(value) 59 | except ValueError as e: 60 | raise Invalid( 61 | _( 62 | "invalid_json", 63 | "JSON is not valid, parser complained: ${message}", 64 | mapping={"message": f"{e.msg} {e.pos}"}, 65 | ) 66 | ) from None 67 | if not isinstance(jv, dict): 68 | raise Invalid(_("invalid_cfg_no_dict", "JSON root must be a mapping (dict)")) 69 | if len(jv) < 1: 70 | raise Invalid( 71 | _( 72 | "invalid_cfg_empty_dict", 73 | "At least one provider must be configured.", 74 | ) 75 | ) 76 | return True 77 | 78 | 79 | @provider(IVocabularyFactory) 80 | def userid_factory_vocabulary(context): 81 | items = [] 82 | for name, factory in getUtilitiesFor(IUserIDFactory): 83 | items.append([factory.title, name]) 84 | items = [SimpleTerm(name, name, title) for title, name in sorted(items)] 85 | return SimpleVocabulary(items) 86 | 87 | 88 | class IPasPluginsAuthomaticSettings(Interface): 89 | secret = schema.TextLine( 90 | title=_("Secret"), 91 | description=_( 92 | "help_secret", 93 | default="Some random string used to encrypt the state", 94 | ), 95 | required=True, 96 | default=random_secret, 97 | ) 98 | userid_factory_name = schema.Choice( 99 | vocabulary="pas.plugins.authomatic.userid_vocabulary", 100 | title=_("Generator for Plone User IDs."), 101 | description=_( 102 | "help_userid_factory_name", 103 | default="It is visible if no fullname is mapped and in some " 104 | "rare cases in URLs. It is the identifier used for " 105 | "the user inside Plone.", 106 | ), 107 | default="uuid", 108 | ) 109 | json_config = schema.SourceText( 110 | title=_("JSON configuration"), 111 | description=_( 112 | "help_json_config", 113 | default="Configuration parameters for the different " 114 | "authorization providers. Details at " 115 | "https://authomatic.github.io/authomatic/reference/" 116 | "providers.html " 117 | '- difference: "class_" has to be a string, which is ' 118 | "then resolved as a dotted path. Also sections " 119 | '"display" and "propertymap" are special.', 120 | ), 121 | required=True, 122 | constraint=validate_cfg_json, 123 | default=DEFAULT_CONFIG, 124 | ) 125 | 126 | 127 | class IAuthomaticPlugin(Interface): 128 | """Member Properties To Group Plugin""" 129 | 130 | def remember(result): 131 | """remember user as valid 132 | 133 | result is authomatic result data. 134 | """ 135 | 136 | 137 | class IUserIDFactory(Interface): 138 | """generates a userid on call""" 139 | 140 | def __call__(service_name, service_user_id, raw_user): 141 | """returns string, unique amongst plugins userids""" 142 | 143 | 144 | class IPasPluginsAuthomaticLayer(IDefaultBrowserLayer): 145 | """Marker interface that defines a browser layer.""" 146 | -------------------------------------------------------------------------------- /src/pas/plugins/authomatic/locales/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/collective/pas.plugins.authomatic/64357d3f3015e5d01dd29e6c1c7fa7a710fd90ab/src/pas/plugins/authomatic/locales/__init__.py -------------------------------------------------------------------------------- /src/pas/plugins/authomatic/locales/__main__.py: -------------------------------------------------------------------------------- 1 | """Update locales.""" 2 | 3 | from pathlib import Path 4 | 5 | import logging 6 | import re 7 | import subprocess 8 | 9 | 10 | logger = logging.getLogger("i18n") 11 | logger.setLevel(logging.DEBUG) 12 | 13 | 14 | PATTERN = r"^[a-z]{2}.*" 15 | 16 | locale_path = Path(__file__).parent.resolve() 17 | target_path = locale_path.parent.resolve() 18 | domains = [path.name[:-4] for path in locale_path.glob("*.pot")] 19 | 20 | i18ndude = "uvx i18ndude" 21 | 22 | # ignore node_modules files resulting in errors 23 | excludes = '"*.html *json-schema*.xml"' 24 | 25 | 26 | def locale_folder_setup(domain: str): 27 | languages = [path for path in locale_path.glob("*") if path.is_dir()] 28 | for lang_folder in languages: 29 | lc_messages_path = lang_folder / "LC_MESSAGES" 30 | lang = lang_folder.name 31 | if lc_messages_path.exists(): 32 | continue 33 | elif re.match(PATTERN, lang): 34 | lc_messages_path.mkdir() 35 | cmd = ( 36 | f"msginit --locale={lang} " 37 | f"--input={locale_path}/{domain}.pot " 38 | f"--output={locale_path}/{lang}/LC_MESSAGES/{domain}.po" 39 | ) 40 | subprocess.call(cmd, shell=True) # noQA: S602 41 | 42 | 43 | def _rebuild(domain: str): 44 | cmd = ( 45 | f"{i18ndude} rebuild-pot --pot {locale_path}/{domain}.pot " 46 | f"--exclude {excludes} " 47 | f"--create {domain} {target_path}" 48 | ) 49 | subprocess.call(cmd, shell=True) # noQA: S602 50 | 51 | 52 | def _sync(domain: str): 53 | cmd = ( 54 | f"{i18ndude} sync --pot {locale_path}/{domain}.pot " 55 | f"{locale_path}/*/LC_MESSAGES/{domain}.po" 56 | ) 57 | subprocess.call(cmd, shell=True) # noQA: S602 58 | 59 | 60 | def main(): 61 | for domain in domains: 62 | logger.info(f"Updating translations for {domain}") 63 | locale_folder_setup(domain) 64 | _rebuild(domain) 65 | _sync(domain) 66 | 67 | 68 | if __name__ == "__main__": 69 | main() 70 | -------------------------------------------------------------------------------- /src/pas/plugins/authomatic/locales/de/LC_MESSAGES/pas.plugins.authomatic.po: -------------------------------------------------------------------------------- 1 | msgid "" 2 | msgstr "" 3 | "Project-Id-Version: PACKAGE VERSION\n" 4 | "POT-Creation-Date: 2025-03-27 17:12+0000\n" 5 | "PO-Revision-Date: 2015-11-12 12:20+0000\n" 6 | "Last-Translator: Jens Klein \n" 7 | "Language-Team: BlueDynamics Alliance \n" 8 | "MIME-Version: 1.0\n" 9 | "Content-Type: text/plain; charset=UTF-8\n" 10 | "Content-Transfer-Encoding: 8bit\n" 11 | "Plural-Forms: nplurals=1; plural=0;\n" 12 | "Language-Code: de\n" 13 | "Language-Name: Deutsch\n" 14 | "Preferred-Encodings: utf-8 latin1\n" 15 | "Domain: DOMAIN\n" 16 | "X-Is-Fallback-For: de-at de-li de-lu de-ch de-de\n" 17 | 18 | #: pas/plugins/authomatic/profiles/default/controlpanel.xml 19 | msgid "Authomatic (OAuth2/OpenID)" 20 | msgstr "" 21 | 22 | #: pas/plugins/authomatic/profiles.zcml:16 23 | msgid "Authomatic PAS Plugin" 24 | msgstr "Authomatic PAS Plugin" 25 | 26 | #: pas/plugins/authomatic/profiles.zcml:25 27 | msgid "Authomatic PAS Plugin: uninstall" 28 | msgstr "" 29 | 30 | #: pas/plugins/authomatic/browser/view.py:103 31 | msgid "Authomatic is not configured" 32 | msgstr "" 33 | 34 | #: pas/plugins/authomatic/browser/controlpanel.py:36 35 | msgid "Authomatic settings" 36 | msgstr "" 37 | 38 | #: pas/plugins/authomatic/profiles.zcml:16 39 | msgid "Authomatic: Login with OAuth/OpenID 3rd party auth providers using the pas.plugins.authomatic add-on." 40 | msgstr "" 41 | 42 | #: pas/plugins/authomatic/interfaces.py:100 43 | msgid "Generator for Plone User IDs." 44 | msgstr "Generator für Plone User-Ids." 45 | 46 | #: pas/plugins/authomatic/interfaces.py:110 47 | msgid "JSON configuration" 48 | msgstr "JSON Konfiguration" 49 | 50 | #: pas/plugins/authomatic/browser/controlpanel.py:12 51 | msgid "PAS Authomatic Plugin Settings" 52 | msgstr "Einstellungen für das PAS Authomatic Plugin" 53 | 54 | #: pas/plugins/authomatic/useridfactories.py:29 55 | msgid "Provider User ID" 56 | msgstr "User ID des Anbieters" 57 | 58 | #: pas/plugins/authomatic/useridfactories.py:36 59 | msgid "Provider User Name" 60 | msgstr "" 61 | 62 | #: pas/plugins/authomatic/useridfactories.py:51 63 | msgid "Provider User Name or User ID" 64 | msgstr "" 65 | 66 | #: pas/plugins/authomatic/browser/view.py:114 67 | msgid "Provider not supported" 68 | msgstr "" 69 | 70 | #: pas/plugins/authomatic/interfaces.py:90 71 | msgid "Secret" 72 | msgstr "Geheimes Wort" 73 | 74 | #: pas/plugins/authomatic/useridfactories.py:22 75 | msgid "UUID as User ID" 76 | msgstr "UUID als User-ID" 77 | 78 | #: pas/plugins/authomatic/profiles.zcml:25 79 | msgid "Uninstalls the pas.plugins.authomatic add-on. This REMOVES ALL USERS managed by this plugin!" 80 | msgstr "" 81 | 82 | #. Default: "Choose one of these external authentication providers" 83 | #: pas/plugins/authomatic/browser/authomatic.pt:15 84 | msgid "add_description" 85 | msgstr "Wählen Sie einens dieser externen Authentisierungs-Anbieter" 86 | 87 | #. Default: "Add identity" 88 | #: pas/plugins/authomatic/browser/authomatic.pt:13 89 | msgid "add_identity_title" 90 | msgstr "Identität hinzufügen" 91 | 92 | #. Default: "Added identity provided by ${provider}" 93 | #: pas/plugins/authomatic/browser/view.py:73 94 | msgid "added_identity" 95 | msgstr "Indentität über den Anbieter ${provider} hinzugefügt" 96 | 97 | #. Default: "Configuration parameters for the different authorization providers. Details at https://authomatic.github.io/authomatic/reference/providers.html - difference: \"class_\" has to be a string, which is then resolved as a dotted path. Also sections \"display\" and \"propertymap\" are special." 98 | #: pas/plugins/authomatic/interfaces.py:111 99 | #, fuzzy 100 | msgid "help_json_config" 101 | msgstr "" 102 | "Einstellungs-Parameter für die verschiedenen Authorisierungs-Anbieter.\n" 103 | "Details auf der Seite http://authomatic.github.io/authomatic/reference/providers.html - Unterschiede: 'class_' muss eine Zeichenfolge sein, diese wird dann als Punkt-Pfad zu einer Klasse aufgelöst. Die Abteilungen ``display`` und ``propertymap`` sind speziell." 104 | 105 | #. Default: "Some random string used to encrypt the state" 106 | #: pas/plugins/authomatic/interfaces.py:91 107 | msgid "help_secret" 108 | msgstr "Ein Zufalls-String, der zur Verschlüsselung des 'State' verwendet wird." 109 | 110 | #. Default: "It is visible if no fullname is mapped and in some rare cases in URLs. It is the identifier used for the user inside Plone." 111 | #: pas/plugins/authomatic/interfaces.py:101 112 | msgid "help_userid_factory_name" 113 | msgstr "" 114 | "Die User-Id ist sichtbar, wenn kein 'fullname' zugewiesen wurde und in einigen seltenen Fällen in der URL.\n" 115 | "Innerhalb von Plone wird sie zur zur Identifikation herangezogen." 116 | 117 | #. Default: "At least one provider must be configured." 118 | #: pas/plugins/authomatic/interfaces.py:71 119 | msgid "invalid_cfg_empty_dict" 120 | msgstr "Mindestens ein Anbieter muss konfiguriert werden." 121 | 122 | #. Default: "JSON root must be a mapping (dict)" 123 | #: pas/plugins/authomatic/interfaces.py:68 124 | msgid "invalid_cfg_no_dict" 125 | msgstr "Die JSON Wurzel muss ein Mapping (Schlüssel zu Wert) sein." 126 | 127 | #. Default: "JSON is not valid, parser complained: ${message}" 128 | #: pas/plugins/authomatic/interfaces.py:61 129 | msgid "invalid_json" 130 | msgstr "Das JSON ist nicht gültig, der Parser meldet: {$message}" 131 | 132 | #. Default: "Logged in with ${provider}" 133 | #: pas/plugins/authomatic/browser/view.py:86 134 | msgid "logged_in_with" 135 | msgstr "Eingeloggt mit ${provider}" 136 | 137 | #. Default: "Log in with" 138 | #: pas/plugins/authomatic/browser/authomatic.pt:10 139 | msgid "login_title" 140 | msgstr "Einloggen mit" 141 | 142 | #: pas/plugins/authomatic/browser/view.py:110 143 | msgid "redirecting" 144 | msgstr "" 145 | -------------------------------------------------------------------------------- /src/pas/plugins/authomatic/locales/es/LC_MESSAGES/pas.plugins.authomatic.po: -------------------------------------------------------------------------------- 1 | # Translation of pas.plugins.authomatic.pot to Spanish 2 | # Leonardo J. Caballero G. , 2023. 3 | msgid "" 4 | msgstr "" 5 | "Project-Id-Version: pas.plugins.authomatic\n" 6 | "POT-Creation-Date: 2025-03-27 17:12+0000\n" 7 | "PO-Revision-Date: 2023-05-25 14:22-0400\n" 8 | "Last-Translator: Leonardo J. Caballero G. \n" 9 | "Language-Team: ES \n" 10 | "MIME-Version: 1.0\n" 11 | "Content-Type: text/plain; charset=utf-8\n" 12 | "Content-Transfer-Encoding: 8bit\n" 13 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 14 | "Language-Code: es\n" 15 | "Language-Name: Español\n" 16 | "Preferred-Encodings: utf-8\n" 17 | "Domain: pas.plugins.authomatic\n" 18 | "Report-Msgid-Bugs-To: \n" 19 | "Language: es\n" 20 | "X-Is-Fallback-For: es-ar es-bo es-cl es-co es-cr es-do es-ec es-es es-sv es-gt es-hn es-mx es-ni es-pa es-py es-pe es-pr es-us es-uy es-ve\n" 21 | 22 | #: pas/plugins/authomatic/profiles/default/controlpanel.xml 23 | msgid "Authomatic (OAuth2/OpenID)" 24 | msgstr "Authomatic (OAuth2/OpenID)" 25 | 26 | #: pas/plugins/authomatic/profiles.zcml:16 27 | msgid "Authomatic PAS Plugin" 28 | msgstr "Plugin PAS Authomatic" 29 | 30 | #: pas/plugins/authomatic/profiles.zcml:25 31 | msgid "Authomatic PAS Plugin: uninstall" 32 | msgstr "Complemento PAS Authomatic: desinstalar" 33 | 34 | #: pas/plugins/authomatic/browser/view.py:103 35 | msgid "Authomatic is not configured" 36 | msgstr "Authomatic no esta configurado" 37 | 38 | #: pas/plugins/authomatic/browser/controlpanel.py:36 39 | msgid "Authomatic settings" 40 | msgstr "" 41 | 42 | #: pas/plugins/authomatic/profiles.zcml:16 43 | msgid "Authomatic: Login with OAuth/OpenID 3rd party auth providers using the pas.plugins.authomatic add-on." 44 | msgstr "Authomatic: inicie sesión con proveedores de autenticación de terceros de OAuth/OpenID mediante el complemento pas.plugins.authomatic." 45 | 46 | #: pas/plugins/authomatic/interfaces.py:100 47 | msgid "Generator for Plone User IDs." 48 | msgstr "Generador de ID de usuario de Plone." 49 | 50 | #: pas/plugins/authomatic/interfaces.py:110 51 | msgid "JSON configuration" 52 | msgstr "Configuración JSON" 53 | 54 | #: pas/plugins/authomatic/browser/controlpanel.py:12 55 | msgid "PAS Authomatic Plugin Settings" 56 | msgstr "Configuración de complemento PAS Authomatic" 57 | 58 | #: pas/plugins/authomatic/useridfactories.py:29 59 | msgid "Provider User ID" 60 | msgstr "User ID del provider" 61 | 62 | #: pas/plugins/authomatic/useridfactories.py:36 63 | msgid "Provider User Name" 64 | msgstr "Nombre de usuario del proveedor" 65 | 66 | #: pas/plugins/authomatic/useridfactories.py:51 67 | msgid "Provider User Name or User ID" 68 | msgstr "" 69 | 70 | #: pas/plugins/authomatic/browser/view.py:114 71 | msgid "Provider not supported" 72 | msgstr "Proveedor no compatible" 73 | 74 | #: pas/plugins/authomatic/interfaces.py:90 75 | msgid "Secret" 76 | msgstr "Secreto" 77 | 78 | #: pas/plugins/authomatic/useridfactories.py:22 79 | msgid "UUID as User ID" 80 | msgstr "UUID como ID de Usuario" 81 | 82 | #: pas/plugins/authomatic/profiles.zcml:25 83 | msgid "Uninstalls the pas.plugins.authomatic add-on. This REMOVES ALL USERS managed by this plugin!" 84 | msgstr "Desinstala el complemento pas.plugins.authomatic. ¡Esto ELIMINA TODOS LOS USUARIOS administrados por este complemento!" 85 | 86 | #. Default: "Choose one of these external authentication providers" 87 | #: pas/plugins/authomatic/browser/authomatic.pt:15 88 | msgid "add_description" 89 | msgstr "Elija uno de estos proveedores de autenticación externos" 90 | 91 | #. Default: "Add identity" 92 | #: pas/plugins/authomatic/browser/authomatic.pt:13 93 | msgid "add_identity_title" 94 | msgstr "Añadir identidad" 95 | 96 | #. Default: "Added identity provided by ${provider}" 97 | #: pas/plugins/authomatic/browser/view.py:73 98 | msgid "added_identity" 99 | msgstr "Añadir identidad proveída por ${provider}" 100 | 101 | #. Default: "Configuration parameters for the different authorization providers. Details at https://authomatic.github.io/authomatic/reference/providers.html - difference: \"class_\" has to be a string, which is then resolved as a dotted path. Also sections \"display\" and \"propertymap\" are special." 102 | #: pas/plugins/authomatic/interfaces.py:111 103 | msgid "help_json_config" 104 | msgstr "Parámetros de configuración de los diferentes proveedores de autorización. Detalles en https://authomatic.github.io/authomatic/reference/providers.html - diferencia: \"class_\" tiene que ser una cadena, que luego se resuelve como una ruta punteada. También las secciones \"display\" y \"propertymap\" son especiales." 105 | 106 | #. Default: "Some random string used to encrypt the state" 107 | #: pas/plugins/authomatic/interfaces.py:91 108 | msgid "help_secret" 109 | msgstr "Alguna cadena aleatoria utilizada para cifrar el estado" 110 | 111 | #. Default: "It is visible if no fullname is mapped and in some rare cases in URLs. It is the identifier used for the user inside Plone." 112 | #: pas/plugins/authomatic/interfaces.py:101 113 | msgid "help_userid_factory_name" 114 | msgstr "Es visible si no se asigna ningún nombre completo y, en algunos casos excepcionales, en las URL. Es el identificador utilizado para el usuario dentro de Plone." 115 | 116 | #. Default: "At least one provider must be configured." 117 | #: pas/plugins/authomatic/interfaces.py:71 118 | msgid "invalid_cfg_empty_dict" 119 | msgstr "Se debe configurar al menos un proveedor." 120 | 121 | #. Default: "JSON root must be a mapping (dict)" 122 | #: pas/plugins/authomatic/interfaces.py:68 123 | msgid "invalid_cfg_no_dict" 124 | msgstr "La raíz JSON debe ser una asignación (dict)" 125 | 126 | #. Default: "JSON is not valid, parser complained: ${message}" 127 | #: pas/plugins/authomatic/interfaces.py:61 128 | msgid "invalid_json" 129 | msgstr "JSON no es válido, el analizador se quejó: ${message}" 130 | 131 | #. Default: "Logged in with ${provider}" 132 | #: pas/plugins/authomatic/browser/view.py:86 133 | msgid "logged_in_with" 134 | msgstr "Iniciado sesión con ${provider}" 135 | 136 | #. Default: "Log in with" 137 | #: pas/plugins/authomatic/browser/authomatic.pt:10 138 | msgid "login_title" 139 | msgstr "Inicia con" 140 | 141 | #: pas/plugins/authomatic/browser/view.py:110 142 | msgid "redirecting" 143 | msgstr "redirigir" 144 | -------------------------------------------------------------------------------- /src/pas/plugins/authomatic/locales/fr/LC_MESSAGES/pas.plugins.authomatic.po: -------------------------------------------------------------------------------- 1 | msgid "" 2 | msgstr "" 3 | "Project-Id-Version: PACKAGE VERSION\n" 4 | "POT-Creation-Date: 2025-03-27 17:12+0000\n" 5 | "PO-Revision-Date: YEAR-MO-DA HO:MI +ZONE\n" 6 | "Last-Translator: Martin Peeters\n" 7 | "Language-Team: Affinitic\n" 8 | "MIME-Version: 1.0\n" 9 | "Content-Type: text/plain; charset=utf-8\n" 10 | "Content-Transfer-Encoding: 8bit\n" 11 | "Plural-Forms: nplurals=1; plural=0\n" 12 | "Language-Code: fr\n" 13 | "Language-Name: French\n" 14 | "Preferred-Encodings: utf-8 latin1\n" 15 | "Domain: DOMAIN\n" 16 | 17 | #: pas/plugins/authomatic/profiles/default/controlpanel.xml 18 | msgid "Authomatic (OAuth2/OpenID)" 19 | msgstr "" 20 | 21 | #: pas/plugins/authomatic/profiles.zcml:16 22 | msgid "Authomatic PAS Plugin" 23 | msgstr "" 24 | 25 | #: pas/plugins/authomatic/profiles.zcml:25 26 | msgid "Authomatic PAS Plugin: uninstall" 27 | msgstr "" 28 | 29 | #: pas/plugins/authomatic/browser/view.py:103 30 | msgid "Authomatic is not configured" 31 | msgstr "" 32 | 33 | #: pas/plugins/authomatic/browser/controlpanel.py:36 34 | msgid "Authomatic settings" 35 | msgstr "" 36 | 37 | #: pas/plugins/authomatic/profiles.zcml:16 38 | msgid "Authomatic: Login with OAuth/OpenID 3rd party auth providers using the pas.plugins.authomatic add-on." 39 | msgstr "" 40 | 41 | #: pas/plugins/authomatic/interfaces.py:100 42 | msgid "Generator for Plone User IDs." 43 | msgstr "Générateur pour les identifiants des utilisateurs Plone" 44 | 45 | #: pas/plugins/authomatic/interfaces.py:110 46 | msgid "JSON configuration" 47 | msgstr "Configuration JSON" 48 | 49 | #: pas/plugins/authomatic/browser/controlpanel.py:12 50 | msgid "PAS Authomatic Plugin Settings" 51 | msgstr "Paramètres pour l'addon PAS Authomatic" 52 | 53 | #: pas/plugins/authomatic/useridfactories.py:29 54 | msgid "Provider User ID" 55 | msgstr "Fournisseur d'identifiant utilisateur" 56 | 57 | #: pas/plugins/authomatic/useridfactories.py:36 58 | msgid "Provider User Name" 59 | msgstr "Fournisseur de nom d'utilisateur" 60 | 61 | #: pas/plugins/authomatic/useridfactories.py:51 62 | msgid "Provider User Name or User ID" 63 | msgstr "" 64 | 65 | #: pas/plugins/authomatic/browser/view.py:114 66 | msgid "Provider not supported" 67 | msgstr "" 68 | 69 | #: pas/plugins/authomatic/interfaces.py:90 70 | msgid "Secret" 71 | msgstr "" 72 | 73 | #: pas/plugins/authomatic/useridfactories.py:22 74 | msgid "UUID as User ID" 75 | msgstr "UUID comme identifiant utilisateur" 76 | 77 | #: pas/plugins/authomatic/profiles.zcml:25 78 | msgid "Uninstalls the pas.plugins.authomatic add-on. This REMOVES ALL USERS managed by this plugin!" 79 | msgstr "" 80 | 81 | #. Default: "Choose one of these external authentication providers" 82 | #: pas/plugins/authomatic/browser/authomatic.pt:15 83 | msgid "add_description" 84 | msgstr "Choisissez un fournisseur externe d'identification" 85 | 86 | #. Default: "Add identity" 87 | #: pas/plugins/authomatic/browser/authomatic.pt:13 88 | msgid "add_identity_title" 89 | msgstr "Ajouter une identité" 90 | 91 | #. Default: "Added identity provided by ${provider}" 92 | #: pas/plugins/authomatic/browser/view.py:73 93 | msgid "added_identity" 94 | msgstr "Identitée ajoutée fournie par ${provider}" 95 | 96 | #. Default: "Configuration parameters for the different authorization providers. Details at https://authomatic.github.io/authomatic/reference/providers.html - difference: \"class_\" has to be a string, which is then resolved as a dotted path. Also sections \"display\" and \"propertymap\" are special." 97 | #: pas/plugins/authomatic/interfaces.py:111 98 | msgid "help_json_config" 99 | msgstr "" 100 | 101 | #. Default: "Some random string used to encrypt the state" 102 | #: pas/plugins/authomatic/interfaces.py:91 103 | msgid "help_secret" 104 | msgstr "Chaîne de caractères aléatoire utilisée pour l'encryption" 105 | 106 | #. Default: "It is visible if no fullname is mapped and in some rare cases in URLs. It is the identifier used for the user inside Plone." 107 | #: pas/plugins/authomatic/interfaces.py:101 108 | msgid "help_userid_factory_name" 109 | msgstr "Visible si le nom ne correspond pas ou dans de rares cas dans les URLs. Il s'agit de l'identifiant pour l'utilisateur dans Plone." 110 | 111 | #. Default: "At least one provider must be configured." 112 | #: pas/plugins/authomatic/interfaces.py:71 113 | msgid "invalid_cfg_empty_dict" 114 | msgstr "Au moins un fournisseur doit être configuré" 115 | 116 | #. Default: "JSON root must be a mapping (dict)" 117 | #: pas/plugins/authomatic/interfaces.py:68 118 | msgid "invalid_cfg_no_dict" 119 | msgstr "La racine du JSON doit être un dictionnaire" 120 | 121 | #. Default: "JSON is not valid, parser complained: ${message}" 122 | #: pas/plugins/authomatic/interfaces.py:61 123 | msgid "invalid_json" 124 | msgstr "le JSON n'est pas valide, erreur lors du traitement: ${message}" 125 | 126 | #. Default: "Logged in with ${provider}" 127 | #: pas/plugins/authomatic/browser/view.py:86 128 | msgid "logged_in_with" 129 | msgstr "Connecté via ${provider}" 130 | 131 | #. Default: "Log in with" 132 | #: pas/plugins/authomatic/browser/authomatic.pt:10 133 | msgid "login_title" 134 | msgstr "Se connecter avec" 135 | 136 | #: pas/plugins/authomatic/browser/view.py:110 137 | msgid "redirecting" 138 | msgstr "" 139 | -------------------------------------------------------------------------------- /src/pas/plugins/authomatic/locales/it/LC_MESSAGES/pas.plugins.authomatic.po: -------------------------------------------------------------------------------- 1 | msgid "" 2 | msgstr "" 3 | "Project-Id-Version: PACKAGE VERSION\n" 4 | "POT-Creation-Date: 2025-03-27 17:12+0000\n" 5 | "PO-Revision-Date: 2015-11-18 08:53+0000\n" 6 | "Last-Translator: keul \n" 7 | "Language-Team: Abstract Open Solutions \n" 8 | "MIME-Version: 1.0\n" 9 | "Content-Type: text/plain; charset=utf-8\n" 10 | "Content-Transfer-Encoding: 8bit\n" 11 | "Plural-Forms: nplurals=1; plural=0\n" 12 | "Language-Code: it\n" 13 | "Language-Name: Italian\n" 14 | "Preferred-Encodings: utf-8 latin1\n" 15 | "Domain: it\n" 16 | "X-Is-Fallback-For: it-ch it-it\n" 17 | 18 | #: pas/plugins/authomatic/profiles/default/controlpanel.xml 19 | msgid "Authomatic (OAuth2/OpenID)" 20 | msgstr "" 21 | 22 | #: pas/plugins/authomatic/profiles.zcml:16 23 | msgid "Authomatic PAS Plugin" 24 | msgstr "Plugin PAS Authomatic" 25 | 26 | #: pas/plugins/authomatic/profiles.zcml:25 27 | msgid "Authomatic PAS Plugin: uninstall" 28 | msgstr "" 29 | 30 | #: pas/plugins/authomatic/browser/view.py:103 31 | msgid "Authomatic is not configured" 32 | msgstr "" 33 | 34 | #: pas/plugins/authomatic/browser/controlpanel.py:36 35 | msgid "Authomatic settings" 36 | msgstr "" 37 | 38 | #: pas/plugins/authomatic/profiles.zcml:16 39 | msgid "Authomatic: Login with OAuth/OpenID 3rd party auth providers using the pas.plugins.authomatic add-on." 40 | msgstr "" 41 | 42 | #: pas/plugins/authomatic/interfaces.py:100 43 | msgid "Generator for Plone User IDs." 44 | msgstr "Generazione degli User ID Plone." 45 | 46 | #: pas/plugins/authomatic/interfaces.py:110 47 | msgid "JSON configuration" 48 | msgstr "Configurazione JSON" 49 | 50 | #: pas/plugins/authomatic/browser/controlpanel.py:12 51 | msgid "PAS Authomatic Plugin Settings" 52 | msgstr "Impostazioni del Plugin PAS Authomatic" 53 | 54 | #: pas/plugins/authomatic/useridfactories.py:29 55 | msgid "Provider User ID" 56 | msgstr "User ID del provider" 57 | 58 | #: pas/plugins/authomatic/useridfactories.py:36 59 | msgid "Provider User Name" 60 | msgstr "" 61 | 62 | #: pas/plugins/authomatic/useridfactories.py:51 63 | msgid "Provider User Name or User ID" 64 | msgstr "" 65 | 66 | #: pas/plugins/authomatic/browser/view.py:114 67 | msgid "Provider not supported" 68 | msgstr "" 69 | 70 | #: pas/plugins/authomatic/interfaces.py:90 71 | msgid "Secret" 72 | msgstr "" 73 | 74 | #: pas/plugins/authomatic/useridfactories.py:22 75 | msgid "UUID as User ID" 76 | msgstr "UUID come User ID" 77 | 78 | #: pas/plugins/authomatic/profiles.zcml:25 79 | msgid "Uninstalls the pas.plugins.authomatic add-on. This REMOVES ALL USERS managed by this plugin!" 80 | msgstr "" 81 | 82 | #. Default: "Choose one of these external authentication providers" 83 | #: pas/plugins/authomatic/browser/authomatic.pt:15 84 | msgid "add_description" 85 | msgstr "" 86 | 87 | #. Default: "Add identity" 88 | #: pas/plugins/authomatic/browser/authomatic.pt:13 89 | msgid "add_identity_title" 90 | msgstr "" 91 | 92 | #. Default: "Added identity provided by ${provider}" 93 | #: pas/plugins/authomatic/browser/view.py:73 94 | msgid "added_identity" 95 | msgstr "Aggiunta identità fornita da ${provider}" 96 | 97 | #. Default: "Configuration parameters for the different authorization providers. Details at https://authomatic.github.io/authomatic/reference/providers.html - difference: \"class_\" has to be a string, which is then resolved as a dotted path. Also sections \"display\" and \"propertymap\" are special." 98 | #: pas/plugins/authomatic/interfaces.py:111 99 | #, fuzzy 100 | msgid "help_json_config" 101 | msgstr "" 102 | "Parametri di configurazione per i diversi authorization providers.\n" 103 | "Dettagli all'indirizzo http://authomatic.github.io/authomatic/reference/providers.html - differenze: 'class_' deve essere una stringa , che sarà risolta come dotted path. In più le sezioni ``display`` e ``propertymap`` sono speciali" 104 | 105 | #. Default: "Some random string used to encrypt the state" 106 | #: pas/plugins/authomatic/interfaces.py:91 107 | msgid "help_secret" 108 | msgstr "Una stringa casuale usata per criptare lo stato" 109 | 110 | #. Default: "It is visible if no fullname is mapped and in some rare cases in URLs. It is the identifier used for the user inside Plone." 111 | #: pas/plugins/authomatic/interfaces.py:101 112 | msgid "help_userid_factory_name" 113 | msgstr "" 114 | "E' visibile quanto nessun nome utente è mappato e in quale raro caso negli URL.\n" 115 | "E' l'identificatore usato per l'utente all'interno di Plone." 116 | 117 | #. Default: "At least one provider must be configured." 118 | #: pas/plugins/authomatic/interfaces.py:71 119 | msgid "invalid_cfg_empty_dict" 120 | msgstr "Deve essere configurato almeno un provider." 121 | 122 | #. Default: "JSON root must be a mapping (dict)" 123 | #: pas/plugins/authomatic/interfaces.py:68 124 | msgid "invalid_cfg_no_dict" 125 | msgstr "La radice JSON deve essere una struttura mapping (dizionario)" 126 | 127 | #. Default: "JSON is not valid, parser complained: ${message}" 128 | #: pas/plugins/authomatic/interfaces.py:61 129 | msgid "invalid_json" 130 | msgstr "JSON invalido, parser riporta: {$message}" 131 | 132 | #. Default: "Logged in with ${provider}" 133 | #: pas/plugins/authomatic/browser/view.py:86 134 | msgid "logged_in_with" 135 | msgstr "Autenticato con ${provider}" 136 | 137 | #. Default: "Log in with" 138 | #: pas/plugins/authomatic/browser/authomatic.pt:10 139 | msgid "login_title" 140 | msgstr "Autenticazione usando" 141 | 142 | #: pas/plugins/authomatic/browser/view.py:110 143 | msgid "redirecting" 144 | msgstr "" 145 | -------------------------------------------------------------------------------- /src/pas/plugins/authomatic/locales/pas.plugins.authomatic-manual.pot: -------------------------------------------------------------------------------- 1 | #--- PLEASE EDIT THE LINES BELOW CORRECTLY --- 2 | #SOME DESCRIPTIVE TITLE. 3 | #FIRST AUTHOR , YEAR. 4 | msgid "" 5 | msgstr "" 6 | "Project-Id-Version: PACKAGE VERSION\n" 7 | "POT-Creation-Date: 2025-03-27 17:12+0000\n" 8 | "PO-Revision-Date: YEAR-MO-DA HO:MI +ZONE\n" 9 | "Last-Translator: FULL NAME \n" 10 | "Language-Team: LANGUAGE \n" 11 | "MIME-Version: 1.0\n" 12 | "Content-Type: text/plain; charset=utf-8\n" 13 | "Content-Transfer-Encoding: 8bit\n" 14 | "Plural-Forms: nplurals=1; plural=0\n" 15 | "Language-Code: en\n" 16 | "Language-Name: English\n" 17 | "Preferred-Encodings: utf-8 latin1\n" 18 | "Domain: pas.plugins.authomatic-manual\n" 19 | 20 | #: pas/plugins/authomatic/browser/view.py:103 21 | msgid "Authomatic is not configured" 22 | msgstr "" 23 | 24 | #: pas/plugins/authomatic/browser/controlpanel.py:36 25 | msgid "Authomatic settings" 26 | msgstr "" 27 | 28 | #: pas/plugins/authomatic/interfaces.py:100 29 | msgid "Generator for Plone User IDs." 30 | msgstr "" 31 | 32 | #: pas/plugins/authomatic/interfaces.py:110 33 | msgid "JSON configuration" 34 | msgstr "" 35 | 36 | #: pas/plugins/authomatic/browser/controlpanel.py:12 37 | msgid "PAS Authomatic Plugin Settings" 38 | msgstr "" 39 | 40 | #: pas/plugins/authomatic/useridfactories.py:29 41 | msgid "Provider User ID" 42 | msgstr "" 43 | 44 | #: pas/plugins/authomatic/useridfactories.py:36 45 | msgid "Provider User Name" 46 | msgstr "" 47 | 48 | #: pas/plugins/authomatic/useridfactories.py:51 49 | msgid "Provider User Name or User ID" 50 | msgstr "" 51 | 52 | #: pas/plugins/authomatic/browser/view.py:114 53 | msgid "Provider not supported" 54 | msgstr "" 55 | 56 | #: pas/plugins/authomatic/interfaces.py:90 57 | msgid "Secret" 58 | msgstr "" 59 | 60 | #: pas/plugins/authomatic/useridfactories.py:22 61 | msgid "UUID as User ID" 62 | msgstr "" 63 | 64 | #. Default: "Added identity provided by ${provider}" 65 | #: pas/plugins/authomatic/browser/view.py:73 66 | msgid "added_identity" 67 | msgstr "" 68 | 69 | #. Default: "Configuration parameters for the different authorization providers. Details at https://authomatic.github.io/authomatic/reference/providers.html - difference: \"class_\" has to be a string, which is then resolved as a dotted path. Also sections \"display\" and \"propertymap\" are special." 70 | #: pas/plugins/authomatic/interfaces.py:111 71 | msgid "help_json_config" 72 | msgstr "" 73 | 74 | #. Default: "Some random string used to encrypt the state" 75 | #: pas/plugins/authomatic/interfaces.py:91 76 | msgid "help_secret" 77 | msgstr "" 78 | 79 | #. Default: "It is visible if no fullname is mapped and in some rare cases in URLs. It is the identifier used for the user inside Plone." 80 | #: pas/plugins/authomatic/interfaces.py:101 81 | msgid "help_userid_factory_name" 82 | msgstr "" 83 | 84 | #. Default: "At least one provider must be configured." 85 | #: pas/plugins/authomatic/interfaces.py:71 86 | msgid "invalid_cfg_empty_dict" 87 | msgstr "" 88 | 89 | #. Default: "JSON root must be a mapping (dict)" 90 | #: pas/plugins/authomatic/interfaces.py:68 91 | msgid "invalid_cfg_no_dict" 92 | msgstr "" 93 | 94 | #. Default: "JSON is not valid, parser complained: ${message}" 95 | #: pas/plugins/authomatic/interfaces.py:61 96 | msgid "invalid_json" 97 | msgstr "" 98 | 99 | #. Default: "Logged in with ${provider}" 100 | #: pas/plugins/authomatic/browser/view.py:86 101 | msgid "logged_in_with" 102 | msgstr "" 103 | 104 | #: pas/plugins/authomatic/browser/view.py:110 105 | msgid "redirecting" 106 | msgstr "" 107 | -------------------------------------------------------------------------------- /src/pas/plugins/authomatic/locales/pas.plugins.authomatic.pot: -------------------------------------------------------------------------------- 1 | #--- PLEASE EDIT THE LINES BELOW CORRECTLY --- 2 | #SOME DESCRIPTIVE TITLE. 3 | #FIRST AUTHOR , YEAR. 4 | msgid "" 5 | msgstr "" 6 | "Project-Id-Version: PACKAGE VERSION\n" 7 | "POT-Creation-Date: 2025-03-27 17:12+0000\n" 8 | "PO-Revision-Date: YEAR-MO-DA HO:MI +ZONE\n" 9 | "Last-Translator: FULL NAME \n" 10 | "Language-Team: LANGUAGE \n" 11 | "MIME-Version: 1.0\n" 12 | "Content-Type: text/plain; charset=utf-8\n" 13 | "Content-Transfer-Encoding: 8bit\n" 14 | "Plural-Forms: nplurals=1; plural=0\n" 15 | "Language-Code: en\n" 16 | "Language-Name: English\n" 17 | "Preferred-Encodings: utf-8 latin1\n" 18 | "Domain: pas.plugins.authomatic\n" 19 | 20 | #: pas/plugins/authomatic/profiles/default/controlpanel.xml 21 | msgid "Authomatic (OAuth2/OpenID)" 22 | msgstr "" 23 | 24 | #: pas/plugins/authomatic/profiles.zcml:16 25 | msgid "Authomatic PAS Plugin" 26 | msgstr "" 27 | 28 | #: pas/plugins/authomatic/profiles.zcml:25 29 | msgid "Authomatic PAS Plugin: uninstall" 30 | msgstr "" 31 | 32 | #: pas/plugins/authomatic/browser/view.py:103 33 | msgid "Authomatic is not configured" 34 | msgstr "" 35 | 36 | #: pas/plugins/authomatic/browser/controlpanel.py:36 37 | msgid "Authomatic settings" 38 | msgstr "" 39 | 40 | #: pas/plugins/authomatic/profiles.zcml:16 41 | msgid "Authomatic: Login with OAuth/OpenID 3rd party auth providers using the pas.plugins.authomatic add-on." 42 | msgstr "" 43 | 44 | #: pas/plugins/authomatic/interfaces.py:100 45 | msgid "Generator for Plone User IDs." 46 | msgstr "" 47 | 48 | #: pas/plugins/authomatic/interfaces.py:110 49 | msgid "JSON configuration" 50 | msgstr "" 51 | 52 | #: pas/plugins/authomatic/browser/controlpanel.py:12 53 | msgid "PAS Authomatic Plugin Settings" 54 | msgstr "" 55 | 56 | #: pas/plugins/authomatic/useridfactories.py:29 57 | msgid "Provider User ID" 58 | msgstr "" 59 | 60 | #: pas/plugins/authomatic/useridfactories.py:36 61 | msgid "Provider User Name" 62 | msgstr "" 63 | 64 | #: pas/plugins/authomatic/useridfactories.py:51 65 | msgid "Provider User Name or User ID" 66 | msgstr "" 67 | 68 | #: pas/plugins/authomatic/browser/view.py:114 69 | msgid "Provider not supported" 70 | msgstr "" 71 | 72 | #: pas/plugins/authomatic/interfaces.py:90 73 | msgid "Secret" 74 | msgstr "" 75 | 76 | #: pas/plugins/authomatic/useridfactories.py:22 77 | msgid "UUID as User ID" 78 | msgstr "" 79 | 80 | #: pas/plugins/authomatic/profiles.zcml:25 81 | msgid "Uninstalls the pas.plugins.authomatic add-on. This REMOVES ALL USERS managed by this plugin!" 82 | msgstr "" 83 | 84 | #. Default: "Choose one of these external authentication providers" 85 | #: pas/plugins/authomatic/browser/authomatic.pt:15 86 | msgid "add_description" 87 | msgstr "" 88 | 89 | #. Default: "Add identity" 90 | #: pas/plugins/authomatic/browser/authomatic.pt:13 91 | msgid "add_identity_title" 92 | msgstr "" 93 | 94 | #. Default: "Added identity provided by ${provider}" 95 | #: pas/plugins/authomatic/browser/view.py:73 96 | msgid "added_identity" 97 | msgstr "" 98 | 99 | #. Default: "Configuration parameters for the different authorization providers. Details at https://authomatic.github.io/authomatic/reference/providers.html - difference: \"class_\" has to be a string, which is then resolved as a dotted path. Also sections \"display\" and \"propertymap\" are special." 100 | #: pas/plugins/authomatic/interfaces.py:111 101 | msgid "help_json_config" 102 | msgstr "" 103 | 104 | #. Default: "Some random string used to encrypt the state" 105 | #: pas/plugins/authomatic/interfaces.py:91 106 | msgid "help_secret" 107 | msgstr "" 108 | 109 | #. Default: "It is visible if no fullname is mapped and in some rare cases in URLs. It is the identifier used for the user inside Plone." 110 | #: pas/plugins/authomatic/interfaces.py:101 111 | msgid "help_userid_factory_name" 112 | msgstr "" 113 | 114 | #. Default: "At least one provider must be configured." 115 | #: pas/plugins/authomatic/interfaces.py:71 116 | msgid "invalid_cfg_empty_dict" 117 | msgstr "" 118 | 119 | #. Default: "JSON root must be a mapping (dict)" 120 | #: pas/plugins/authomatic/interfaces.py:68 121 | msgid "invalid_cfg_no_dict" 122 | msgstr "" 123 | 124 | #. Default: "JSON is not valid, parser complained: ${message}" 125 | #: pas/plugins/authomatic/interfaces.py:61 126 | msgid "invalid_json" 127 | msgstr "" 128 | 129 | #. Default: "Logged in with ${provider}" 130 | #: pas/plugins/authomatic/browser/view.py:86 131 | msgid "logged_in_with" 132 | msgstr "" 133 | 134 | #. Default: "Log in with" 135 | #: pas/plugins/authomatic/browser/authomatic.pt:10 136 | msgid "login_title" 137 | msgstr "" 138 | 139 | #: pas/plugins/authomatic/browser/view.py:110 140 | msgid "redirecting" 141 | msgstr "" 142 | -------------------------------------------------------------------------------- /src/pas/plugins/authomatic/locales/pt_BR/LC_MESSAGES/pas.plugins.authomatic.po: -------------------------------------------------------------------------------- 1 | msgid "" 2 | msgstr "" 3 | "Project-Id-Version: PACKAGE VERSION\n" 4 | "POT-Creation-Date: 2025-03-27 17:12+0000\n" 5 | "PO-Revision-Date: YEAR-MO-DA HO:MI +ZONE\n" 6 | "Last-Translator: FULL NAME \n" 7 | "Language-Team: LANGUAGE \n" 8 | "MIME-Version: 1.0\n" 9 | "Content-Type: text/plain; charset=utf-8\n" 10 | "Content-Transfer-Encoding: 8bit\n" 11 | "Plural-Forms: nplurals=1; plural=0\n" 12 | "Language-Code: en\n" 13 | "Language-Name: English\n" 14 | "Preferred-Encodings: utf-8 latin1\n" 15 | "Domain: DOMAIN\n" 16 | 17 | #: pas/plugins/authomatic/profiles/default/controlpanel.xml 18 | msgid "Authomatic (OAuth2/OpenID)" 19 | msgstr "Authomatic (OAuth2/OpenID)" 20 | 21 | #: pas/plugins/authomatic/profiles.zcml:16 22 | msgid "Authomatic PAS Plugin" 23 | msgstr "Plugin PAS Authomatic" 24 | 25 | #: pas/plugins/authomatic/profiles.zcml:25 26 | msgid "Authomatic PAS Plugin: uninstall" 27 | msgstr "Plugin PAS Authomatic: Remover" 28 | 29 | #: pas/plugins/authomatic/browser/view.py:103 30 | msgid "Authomatic is not configured" 31 | msgstr "Authomatic não está configurado" 32 | 33 | #: pas/plugins/authomatic/browser/controlpanel.py:36 34 | msgid "Authomatic settings" 35 | msgstr "Configurações do Authomatic" 36 | 37 | #: pas/plugins/authomatic/profiles.zcml:16 38 | msgid "Authomatic: Login with OAuth/OpenID 3rd party auth providers using the pas.plugins.authomatic add-on." 39 | msgstr "Authomatic: Login com provedores OAuth/OpenID usando o complemento pas.plugins.authomatic." 40 | 41 | #: pas/plugins/authomatic/interfaces.py:100 42 | msgid "Generator for Plone User IDs." 43 | msgstr "Gerador de IDs de usuários Plone" 44 | 45 | #: pas/plugins/authomatic/interfaces.py:110 46 | msgid "JSON configuration" 47 | msgstr "Configuração em JSON" 48 | 49 | #: pas/plugins/authomatic/browser/controlpanel.py:12 50 | msgid "PAS Authomatic Plugin Settings" 51 | msgstr "Configurações do Plugin Authomatic" 52 | 53 | #: pas/plugins/authomatic/useridfactories.py:29 54 | msgid "Provider User ID" 55 | msgstr "ID de usuário do provedor" 56 | 57 | #: pas/plugins/authomatic/useridfactories.py:36 58 | msgid "Provider User Name" 59 | msgstr "Nome de usuário do provedor" 60 | 61 | #: pas/plugins/authomatic/useridfactories.py:51 62 | msgid "Provider User Name or User ID" 63 | msgstr "Nome de usuário ou ID do provedor" 64 | 65 | #: pas/plugins/authomatic/browser/view.py:114 66 | msgid "Provider not supported" 67 | msgstr "Provedor não suportado" 68 | 69 | #: pas/plugins/authomatic/interfaces.py:90 70 | msgid "Secret" 71 | msgstr "Segredo" 72 | 73 | #: pas/plugins/authomatic/useridfactories.py:22 74 | msgid "UUID as User ID" 75 | msgstr "ID de usuário como UUID" 76 | 77 | #: pas/plugins/authomatic/profiles.zcml:25 78 | msgid "Uninstalls the pas.plugins.authomatic add-on. This REMOVES ALL USERS managed by this plugin!" 79 | msgstr "Remove o complemento pas.plugins.authomatic. (Isto removerá TODOS os usuários gerenciados por este plugin!" 80 | 81 | #. Default: "Choose one of these external authentication providers" 82 | #: pas/plugins/authomatic/browser/authomatic.pt:15 83 | msgid "add_description" 84 | msgstr "Escolha um dos serviços externos de autenticação" 85 | 86 | #. Default: "Add identity" 87 | #: pas/plugins/authomatic/browser/authomatic.pt:13 88 | msgid "add_identity_title" 89 | msgstr "Adicionar identidade" 90 | 91 | #. Default: "Added identity provided by ${provider}" 92 | #: pas/plugins/authomatic/browser/view.py:73 93 | msgid "added_identity" 94 | msgstr "Adicionada identidade provida por ${provider}" 95 | 96 | #. Default: "Configuration parameters for the different authorization providers. Details at https://authomatic.github.io/authomatic/reference/providers.html - difference: \"class_\" has to be a string, which is then resolved as a dotted path. Also sections \"display\" and \"propertymap\" are special." 97 | #: pas/plugins/authomatic/interfaces.py:111 98 | msgid "help_json_config" 99 | msgstr "Parâmetros de configuração para os diferentes provedores de autorização. Detalhes em https://authomatic.github.io/authomatic/reference/providers.html - diferença: \"class_\" deve ser uma string, que será resolvida como um caminho com pontos. Além disso, as seções \"display\" e \"propertymap\" são especiais.”" 100 | 101 | #. Default: "Some random string used to encrypt the state" 102 | #: pas/plugins/authomatic/interfaces.py:91 103 | msgid "help_secret" 104 | msgstr "String aleatória utilizada para encriptar as informações de estado" 105 | 106 | #. Default: "It is visible if no fullname is mapped and in some rare cases in URLs. It is the identifier used for the user inside Plone." 107 | #: pas/plugins/authomatic/interfaces.py:101 108 | msgid "help_userid_factory_name" 109 | msgstr "Visível caso nenhum 'nome completo' estiver mapeado e, em raros caso, nas URLs. É o identificador utilizado pelo usuário no site Plone." 110 | 111 | #. Default: "At least one provider must be configured." 112 | #: pas/plugins/authomatic/interfaces.py:71 113 | msgid "invalid_cfg_empty_dict" 114 | msgstr "Ao menos um provedor deve ser configurado." 115 | 116 | #. Default: "JSON root must be a mapping (dict)" 117 | #: pas/plugins/authomatic/interfaces.py:68 118 | msgid "invalid_cfg_no_dict" 119 | msgstr "A raiz fo JSON deve ser um mapeamento (dicionário)" 120 | 121 | #. Default: "JSON is not valid, parser complained: ${message}" 122 | #: pas/plugins/authomatic/interfaces.py:61 123 | msgid "invalid_json" 124 | msgstr "JSON não é válido, mensagem do parser foi: ${message}" 125 | 126 | #. Default: "Logged in with ${provider}" 127 | #: pas/plugins/authomatic/browser/view.py:86 128 | msgid "logged_in_with" 129 | msgstr "Autenticado com ${provider}" 130 | 131 | #. Default: "Log in with" 132 | #: pas/plugins/authomatic/browser/authomatic.pt:10 133 | msgid "login_title" 134 | msgstr "Entrar com" 135 | 136 | #: pas/plugins/authomatic/browser/view.py:110 137 | msgid "redirecting" 138 | msgstr "redirecionando" 139 | -------------------------------------------------------------------------------- /src/pas/plugins/authomatic/locales/ro/LC_MESSAGES/pas.plugins.authomatic.po: -------------------------------------------------------------------------------- 1 | msgid "" 2 | msgstr "" 3 | "Project-Id-Version: PACKAGE VERSION\n" 4 | "POT-Creation-Date: 2025-03-27 17:12+0000\n" 5 | "PO-Revision-Date: YEAR-MO-DA HO:MI +ZONE\n" 6 | "Last-Translator: FULL NAME \n" 7 | "Language-Team: LANGUAGE \n" 8 | "MIME-Version: 1.0\n" 9 | "Content-Type: text/plain; charset=utf-8\n" 10 | "Content-Transfer-Encoding: 8bit\n" 11 | "Plural-Forms: nplurals=1; plural=0\n" 12 | "Language-Code: en\n" 13 | "Language-Name: English\n" 14 | "Preferred-Encodings: utf-8 latin1\n" 15 | "Domain: DOMAIN\n" 16 | 17 | #: pas/plugins/authomatic/profiles/default/controlpanel.xml 18 | msgid "Authomatic (OAuth2/OpenID)" 19 | msgstr "" 20 | 21 | #: pas/plugins/authomatic/profiles.zcml:16 22 | msgid "Authomatic PAS Plugin" 23 | msgstr "" 24 | 25 | #: pas/plugins/authomatic/profiles.zcml:25 26 | msgid "Authomatic PAS Plugin: uninstall" 27 | msgstr "" 28 | 29 | #: pas/plugins/authomatic/browser/view.py:103 30 | msgid "Authomatic is not configured" 31 | msgstr "" 32 | 33 | #: pas/plugins/authomatic/browser/controlpanel.py:36 34 | msgid "Authomatic settings" 35 | msgstr "" 36 | 37 | #: pas/plugins/authomatic/profiles.zcml:16 38 | msgid "Authomatic: Login with OAuth/OpenID 3rd party auth providers using the pas.plugins.authomatic add-on." 39 | msgstr "" 40 | 41 | #: pas/plugins/authomatic/interfaces.py:100 42 | msgid "Generator for Plone User IDs." 43 | msgstr "" 44 | 45 | #: pas/plugins/authomatic/interfaces.py:110 46 | msgid "JSON configuration" 47 | msgstr "" 48 | 49 | #: pas/plugins/authomatic/browser/controlpanel.py:12 50 | msgid "PAS Authomatic Plugin Settings" 51 | msgstr "" 52 | 53 | #: pas/plugins/authomatic/useridfactories.py:29 54 | msgid "Provider User ID" 55 | msgstr "" 56 | 57 | #: pas/plugins/authomatic/useridfactories.py:36 58 | msgid "Provider User Name" 59 | msgstr "" 60 | 61 | #: pas/plugins/authomatic/useridfactories.py:51 62 | msgid "Provider User Name or User ID" 63 | msgstr "" 64 | 65 | #: pas/plugins/authomatic/browser/view.py:114 66 | msgid "Provider not supported" 67 | msgstr "" 68 | 69 | #: pas/plugins/authomatic/interfaces.py:90 70 | msgid "Secret" 71 | msgstr "" 72 | 73 | #: pas/plugins/authomatic/useridfactories.py:22 74 | msgid "UUID as User ID" 75 | msgstr "" 76 | 77 | #: pas/plugins/authomatic/profiles.zcml:25 78 | msgid "Uninstalls the pas.plugins.authomatic add-on. This REMOVES ALL USERS managed by this plugin!" 79 | msgstr "" 80 | 81 | #. Default: "Choose one of these external authentication providers" 82 | #: pas/plugins/authomatic/browser/authomatic.pt:15 83 | msgid "add_description" 84 | msgstr "Autentifica-te cu" 85 | 86 | #. Default: "Add identity" 87 | #: pas/plugins/authomatic/browser/authomatic.pt:13 88 | msgid "add_identity_title" 89 | msgstr "Intra in cont" 90 | 91 | #. Default: "Added identity provided by ${provider}" 92 | #: pas/plugins/authomatic/browser/view.py:73 93 | msgid "added_identity" 94 | msgstr "" 95 | 96 | #. Default: "Configuration parameters for the different authorization providers. Details at https://authomatic.github.io/authomatic/reference/providers.html - difference: \"class_\" has to be a string, which is then resolved as a dotted path. Also sections \"display\" and \"propertymap\" are special." 97 | #: pas/plugins/authomatic/interfaces.py:111 98 | msgid "help_json_config" 99 | msgstr "" 100 | 101 | #. Default: "Some random string used to encrypt the state" 102 | #: pas/plugins/authomatic/interfaces.py:91 103 | msgid "help_secret" 104 | msgstr "" 105 | 106 | #. Default: "It is visible if no fullname is mapped and in some rare cases in URLs. It is the identifier used for the user inside Plone." 107 | #: pas/plugins/authomatic/interfaces.py:101 108 | msgid "help_userid_factory_name" 109 | msgstr "" 110 | 111 | #. Default: "At least one provider must be configured." 112 | #: pas/plugins/authomatic/interfaces.py:71 113 | msgid "invalid_cfg_empty_dict" 114 | msgstr "" 115 | 116 | #. Default: "JSON root must be a mapping (dict)" 117 | #: pas/plugins/authomatic/interfaces.py:68 118 | msgid "invalid_cfg_no_dict" 119 | msgstr "" 120 | 121 | #. Default: "JSON is not valid, parser complained: ${message}" 122 | #: pas/plugins/authomatic/interfaces.py:61 123 | msgid "invalid_json" 124 | msgstr "JSON invalid" 125 | 126 | #. Default: "Logged in with ${provider}" 127 | #: pas/plugins/authomatic/browser/view.py:86 128 | msgid "logged_in_with" 129 | msgstr "Ai fost autentificat cu ${provider}" 130 | 131 | #. Default: "Log in with" 132 | #: pas/plugins/authomatic/browser/authomatic.pt:10 133 | msgid "login_title" 134 | msgstr "Intra in cont cu" 135 | 136 | #: pas/plugins/authomatic/browser/view.py:110 137 | msgid "redirecting" 138 | msgstr "" 139 | -------------------------------------------------------------------------------- /src/pas/plugins/authomatic/log.py: -------------------------------------------------------------------------------- 1 | from pas.plugins.authomatic import PACKAGE_NAME 2 | 3 | import logging 4 | 5 | 6 | logger = logging.getLogger(PACKAGE_NAME) 7 | -------------------------------------------------------------------------------- /src/pas/plugins/authomatic/meta.zcml: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/pas/plugins/authomatic/patches/__init__.py: -------------------------------------------------------------------------------- 1 | def apply_patches(): 2 | """Apply patches.""" 3 | from .authomatic import patch_base_provider_fetch 4 | 5 | patch_base_provider_fetch() 6 | -------------------------------------------------------------------------------- /src/pas/plugins/authomatic/patches/authomatic.py: -------------------------------------------------------------------------------- 1 | from authomatic.exceptions import FetchError 2 | from authomatic.providers import BaseProvider 3 | from http import client as http_client 4 | from pas.plugins.authomatic.log import logger 5 | from urllib import parse 6 | 7 | import authomatic.core 8 | import ssl 9 | 10 | 11 | def patch_base_provider_fetch(): 12 | def _fetch( 13 | self, 14 | url, 15 | method="GET", 16 | params=None, 17 | headers=None, 18 | body="", 19 | max_redirects=5, 20 | content_parser=None, 21 | certificate_file=None, 22 | ssl_verify=True, 23 | ): 24 | """ 25 | Fetches a URL. 26 | 27 | :param str url: 28 | The URL to fetch. 29 | 30 | :param str method: 31 | HTTP method of the request. 32 | 33 | :param dict params: 34 | Dictionary of request parameters. 35 | 36 | :param dict headers: 37 | HTTP headers of the request. 38 | 39 | :param str body: 40 | Body of ``POST``, ``PUT`` and ``PATCH`` requests. 41 | 42 | :param int max_redirects: 43 | Number of maximum HTTP redirects to follow. 44 | 45 | :param function content_parser: 46 | A callable to be used to parse the :attr:`.Response.data` 47 | from :attr:`.Response.content`. 48 | 49 | :param str certificate_file: 50 | Optional certificate file to be used for HTTPS connection. 51 | 52 | :param bool ssl_verify: 53 | Verify SSL on HTTPS connection. 54 | """ 55 | # 'magic' using _kwarg method 56 | # pylint:disable=no-member 57 | params = params or {} 58 | params.update(self.access_params) 59 | 60 | headers = headers or {} 61 | headers.update(self.access_headers) 62 | 63 | url_parsed = parse.urlsplit(url) 64 | query = parse.urlencode(params) 65 | 66 | if method in ("POST", "PUT", "PATCH") and not body: 67 | # Put querystring to body 68 | body = query 69 | query = "" 70 | headers.update({"Content-Type": "application/x-www-form-urlencoded"}) 71 | params = ( 72 | "", 73 | "", 74 | url_parsed.path or "", 75 | query or "", 76 | "", 77 | ) 78 | request_path = parse.urlunsplit(params) 79 | 80 | self._log_param("host", url_parsed.hostname, last=False) 81 | self._log_param("method", method, last=False) 82 | self._log_param("body", body, last=False) 83 | self._log_param("params", params, last=False) 84 | self._log_param("headers", headers, last=False) 85 | self._log_param("certificate", certificate_file, last=False) 86 | self._log_param("SSL verify", ssl_verify, last=True) 87 | 88 | # Connect 89 | if url_parsed.scheme.lower() == "https": 90 | if ssl_verify: 91 | context = ssl.create_default_context( 92 | purpose=ssl.Purpose.SERVER_AUTH, cafile=certificate_file 93 | ) 94 | else: 95 | context = ssl._create_unverified_context() # noQA: S323 96 | 97 | connection = http_client.HTTPSConnection( 98 | url_parsed.hostname, port=url_parsed.port, context=context 99 | ) 100 | else: 101 | connection = http_client.HTTPConnection( 102 | url_parsed.hostname, port=url_parsed.port 103 | ) 104 | 105 | try: 106 | connection.request(method, request_path, body, headers) 107 | except Exception as e: 108 | raise FetchError( 109 | "Fetching URL failed", original_message=str(e), url=request_path 110 | ) from None 111 | 112 | response = connection.getresponse() 113 | location = response.getheader("Location") 114 | 115 | if response.status in (300, 301, 302, 303, 307) and location: 116 | if location == url: 117 | raise FetchError( 118 | "Url redirects to itself!", url=location, status=response.status 119 | ) 120 | 121 | if max_redirects > 0: 122 | remaining_redirects = max_redirects - 1 123 | 124 | self._log_param("Redirecting to", url) 125 | self._log_param("Remaining redirects", remaining_redirects) 126 | 127 | # Call this method again. 128 | response = self._fetch( 129 | url=location, 130 | params=params, 131 | method=method, 132 | headers=headers, 133 | max_redirects=remaining_redirects, 134 | certificate_file=certificate_file, 135 | ssl_verify=ssl_verify, 136 | ) 137 | 138 | else: 139 | raise FetchError( 140 | "Max redirects reached!", url=location, status=response.status 141 | ) 142 | else: 143 | self._log_param("Got response") 144 | self._log_param("url", url, last=False) 145 | self._log_param("status", response.status, last=False) 146 | self._log_param("headers", response.getheaders(), last=True) 147 | 148 | return authomatic.core.Response(response, content_parser) 149 | 150 | BaseProvider._fetch = _fetch 151 | logger.info("Patched authomatic.providers.BaseProvider._fetch") 152 | -------------------------------------------------------------------------------- /src/pas/plugins/authomatic/plugin.py: -------------------------------------------------------------------------------- 1 | from AccessControl import ClassSecurityInfo 2 | from AccessControl.class_init import InitializeClass 3 | from BTrees.OOBTree import OOBTree 4 | from operator import itemgetter 5 | from pas.plugins.authomatic.interfaces import IAuthomaticPlugin 6 | from pas.plugins.authomatic.useridentities import UserIdentities 7 | from pas.plugins.authomatic.useridfactories import new_userid 8 | from pathlib import Path 9 | from plone import api 10 | from Products.PageTemplates.PageTemplateFile import PageTemplateFile 11 | from Products.PlonePAS.interfaces.capabilities import IDeleteCapability 12 | from Products.PlonePAS.interfaces.plugins import IUserManagement 13 | from Products.PluggableAuthService.events import PrincipalCreated 14 | from Products.PluggableAuthService.interfaces import plugins as pas_interfaces 15 | from Products.PluggableAuthService.interfaces.authservice import _noroles 16 | from Products.PluggableAuthService.plugins.BasePlugin import BasePlugin 17 | from Products.PluggableAuthService.utils import createViewName 18 | from zope.event import notify 19 | from zope.interface import implementer 20 | 21 | import logging 22 | 23 | 24 | logger = logging.getLogger(__name__) 25 | tpl_dir = Path(__file__).parent.resolve() / "browser" 26 | 27 | _marker = {} 28 | 29 | 30 | def manage_addAuthomaticPlugin( 31 | context, 32 | id, # noQA: A002 33 | title="", 34 | RESPONSE=None, 35 | **kw, 36 | ): 37 | """Create an instance of a Authomatic Plugin.""" 38 | plugin = AuthomaticPlugin(id, title, **kw) 39 | context._setObject(plugin.getId(), plugin) 40 | if RESPONSE is not None: 41 | RESPONSE.redirect("manage_workspace") 42 | 43 | 44 | manage_addAuthomaticPluginForm = PageTemplateFile( 45 | tpl_dir / "add_plugin.pt", 46 | globals(), 47 | __name__="addAuthomaticPlugin", 48 | ) 49 | 50 | 51 | @implementer( 52 | IAuthomaticPlugin, 53 | pas_interfaces.IAuthenticationPlugin, 54 | pas_interfaces.IPropertiesPlugin, 55 | pas_interfaces.IUserEnumerationPlugin, 56 | IUserManagement, 57 | IDeleteCapability, 58 | ) 59 | class AuthomaticPlugin(BasePlugin): 60 | """Authomatic PAS Plugin""" 61 | 62 | security = ClassSecurityInfo() 63 | meta_type = "Authomatic Plugin" 64 | manage_options = BasePlugin.manage_options 65 | 66 | # Tell PAS not to swallow our exceptions 67 | _dont_swallow_my_exceptions = True 68 | 69 | def __init__(self, id, title=None, **kw): # noQA: A002 70 | self._setId(id) 71 | self.title = title 72 | self.plugin_caching = True 73 | self._init_trees() 74 | 75 | def _init_trees(self): 76 | # (provider_name, provider_userid) -> userid 77 | self._userid_by_identityinfo = OOBTree() 78 | 79 | # userid -> userdata 80 | self._useridentities_by_userid = OOBTree() 81 | 82 | def _provider_id(self, result): 83 | """helper to get the provider identifier""" 84 | if not result.user.id: 85 | raise ValueError("Invalid: Empty user.id") 86 | if not result.provider.name: 87 | raise ValueError("Invalid: Empty provider.name") 88 | return (result.provider.name, result.user.id) 89 | 90 | @security.private 91 | def lookup_identities(self, result): 92 | """looks up the UserIdentities by using the provider name and the 93 | userid at this provider 94 | """ 95 | userid = self._userid_by_identityinfo.get(self._provider_id(result), None) 96 | return self._useridentities_by_userid.get(userid, None) 97 | 98 | @security.private 99 | def remember_identity(self, result, userid=None): 100 | """stores authomatic result data""" 101 | if userid is None: 102 | # create a new userid 103 | userid = new_userid(self, result) 104 | useridentities = UserIdentities(userid) 105 | self._useridentities_by_userid[userid] = useridentities 106 | else: 107 | # use existing userid 108 | useridentities = self._useridentities_by_userid.get(userid, None) 109 | if useridentities is None: 110 | raise ValueError("Invalid userid") 111 | provider_id = self._provider_id(result) 112 | if provider_id not in self._userid_by_identityinfo: 113 | self._userid_by_identityinfo[provider_id] = userid 114 | 115 | useridentities.handle_result(result) 116 | return useridentities 117 | 118 | @security.private 119 | def remember(self, result): 120 | """remember user as valid 121 | 122 | result is authomatic result data. 123 | """ 124 | # first fetch provider specific user-data 125 | result.user.update() 126 | 127 | do_notify_created = False 128 | 129 | # lookup user by 130 | useridentities = self.lookup_identities(result) 131 | if useridentities is None: 132 | # new/unknown user 133 | useridentities = self.remember_identity(result) 134 | do_notify_created = True 135 | logger.info(f"New User: {useridentities.userid}") 136 | else: 137 | useridentities.update_userdata(result) 138 | logger.info(f"Updated Userdata: {useridentities.userid}") 139 | 140 | # login (get new security manager) 141 | logger.info(f"Login User: {useridentities.userid}") 142 | aclu = api.portal.get_tool("acl_users") 143 | user = aclu._findUser(aclu.plugins, useridentities.userid) 144 | accessed, container, name, value = aclu._getObjectContext( 145 | self.REQUEST["PUBLISHED"], self.REQUEST 146 | ) 147 | # Add the user to the SM stack 148 | aclu._authorizeUser(user, accessed, container, name, value, _noroles) 149 | if do_notify_created: 150 | # be a good citizen in PAS world and notify user creation 151 | notify(PrincipalCreated(user)) 152 | 153 | # do login post-processing 154 | self.REQUEST["__ac_password"] = useridentities.secret 155 | mt = api.portal.get_tool("portal_membership") 156 | logger.info(f"Login Postprocessing: {useridentities.userid}") 157 | mt.loginUser(self.REQUEST) 158 | 159 | # ## 160 | # pas_interfaces.IAuthenticationPlugin 161 | 162 | @security.public 163 | def authenticateCredentials(self, credentials): 164 | """credentials -> (userid, login) 165 | 166 | - 'credentials' will be a mapping, as returned by IExtractionPlugin. 167 | - Return a tuple consisting of user ID (which may be different 168 | from the login name) and login 169 | - If the credentials cannot be authenticated, return None. 170 | """ 171 | login = credentials.get("login", None) 172 | password = credentials.get("password", None) 173 | if not login or login not in self._useridentities_by_userid: 174 | return None 175 | identities = self._useridentities_by_userid[login] 176 | if identities.check_password(password): 177 | return login, login 178 | 179 | # ## 180 | # pas_interfaces.plugins.IPropertiesPlugin 181 | 182 | @security.private 183 | def getPropertiesForUser(self, user, request=None): 184 | identity = self._useridentities_by_userid.get(user.getId(), _marker) 185 | if identity is _marker: 186 | return None 187 | return identity.propertysheet 188 | 189 | # ## 190 | # pas_interfaces.plugins.IUserEnumaration 191 | 192 | @security.private 193 | def enumerateUsers( 194 | self, 195 | id=None, # noQA: A002 196 | login=None, 197 | exact_match=False, 198 | sort_by=None, 199 | max_results=None, 200 | **kw, 201 | ): 202 | """-> ( user_info_1, ... user_info_N ) 203 | 204 | o Return mappings for users matching the given criteria. 205 | 206 | o 'id' or 'login', in combination with 'exact_match' true, will 207 | return at most one mapping per supplied ID ('id' and 'login' 208 | may be sequences). 209 | 210 | o If 'exact_match' is False, then 'id' and / or login may be 211 | treated by the plugin as "contains" searches (more complicated 212 | searches may be supported by some plugins using other keyword 213 | arguments). 214 | 215 | o If 'sort_by' is passed, the results will be sorted accordingly. 216 | known valid values are 'id' and 'login' (some plugins may support 217 | others). 218 | 219 | o If 'max_results' is specified, it must be a positive integer, 220 | limiting the number of returned mappings. If unspecified, the 221 | plugin should return mappings for all users satisfying the criteria. 222 | 223 | o Minimal keys in the returned mappings: 224 | 225 | 'id' -- (required) the user ID, which may be different than 226 | the login name 227 | 228 | 'login' -- (required) the login name 229 | 230 | 'pluginid' -- (required) the plugin ID (as returned by getId()) 231 | 232 | 'editurl' -- (optional) the URL to a page for updating the 233 | mapping's user 234 | 235 | o Plugin *must* ignore unknown criteria. 236 | 237 | o Plugin may raise ValueError for invalid criteria. 238 | 239 | o Insufficiently-specified criteria may have catastrophic 240 | scaling issues for some implementations. 241 | """ 242 | if id and login and id != login: 243 | raise ValueError("plugin does not support id different from login") 244 | search_id = id or login 245 | if not (search_id and isinstance(search_id, str)): 246 | return () 247 | 248 | pluginid = self.getId() 249 | ret = [] 250 | # shortcut for exact match of login/id 251 | identity = None 252 | if exact_match and search_id and search_id in self._useridentities_by_userid: 253 | identity = self._useridentities_by_userid[search_id] 254 | if identity is not None: 255 | userid = identity.userid 256 | ret.append({"id": userid, "login": userid, "pluginid": pluginid}) 257 | return ret 258 | 259 | if exact_match: 260 | # we're claiming an exact match search, if we still don't 261 | # have anything, better bail. 262 | return ret 263 | 264 | # non exact expensive search 265 | for userid in self._useridentities_by_userid: 266 | if not userid: 267 | logger.warn("None userid found. This should not happen!") 268 | continue 269 | 270 | # search for a match in fullname, email and userid 271 | identity = self._useridentities_by_userid[userid] 272 | search_term = search_id.lower() 273 | identity_userid = identity.userid 274 | identity_fullname = identity.propertysheet.getProperty( 275 | "fullname", "" 276 | ).lower() 277 | identity_email = identity.propertysheet.getProperty("email", "").lower() 278 | if ( 279 | search_term not in identity_userid 280 | and search_term not in identity_fullname 281 | and search_term not in identity_email 282 | ): 283 | continue 284 | 285 | ret.append({ 286 | "id": identity_userid, 287 | "login": identity.userid, 288 | "pluginid": pluginid, 289 | }) 290 | if max_results and len(ret) >= max_results: 291 | break 292 | return ( 293 | sorted(ret, key=itemgetter(sort_by)) if sort_by in ["id", "login"] else ret 294 | ) 295 | 296 | @security.public 297 | def allowDeletePrincipal(self, principal_id): 298 | """True if this plugin can delete a certain user/group. 299 | This is true if this plugin manages the user. 300 | """ 301 | return principal_id in self._useridentities_by_userid 302 | 303 | @security.private 304 | def doDeleteUser(self, userid): 305 | """Given a user id, delete that user""" 306 | return self.removeUser(userid) 307 | 308 | @security.private 309 | def doChangeUser(self, userid, password=None, **kw): 310 | """do nothing""" 311 | return False 312 | 313 | @security.private 314 | def doAddUser(self, login, password): 315 | """do nothing""" 316 | return False 317 | 318 | @security.private 319 | def getPluginIdByUserId(self, user_id): 320 | """ 321 | return the right key for given user_id 322 | """ 323 | for k, v in self._userid_by_identityinfo.items(): 324 | if v == user_id: 325 | return k 326 | return "" 327 | 328 | @security.private 329 | def removeUser(self, user_id): 330 | """ """ 331 | # Remove the user from all persistent dicts 332 | if user_id not in self._useridentities_by_userid: 333 | # invalid userid 334 | return 335 | del self._useridentities_by_userid[user_id] 336 | 337 | plugin_id = self.getPluginIdByUserId(user_id) 338 | if plugin_id: 339 | del self._userid_by_identityinfo[plugin_id] 340 | # Also, remove from the cache 341 | view_name = createViewName("enumerateUsers") 342 | self.ZCacheable_invalidate(view_name=view_name) 343 | view_name = createViewName("enumerateUsers", user_id) 344 | self.ZCacheable_invalidate(view_name=view_name) 345 | 346 | 347 | InitializeClass(AuthomaticPlugin) 348 | -------------------------------------------------------------------------------- /src/pas/plugins/authomatic/profiles.zcml: -------------------------------------------------------------------------------- 1 | 8 | 9 | 17 | 18 | 26 | 27 | 31 | 32 | 33 | 38 | 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /src/pas/plugins/authomatic/profiles/default/browserlayer.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | -------------------------------------------------------------------------------- /src/pas/plugins/authomatic/profiles/default/controlpanel.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 18 | Manage portal 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/pas/plugins/authomatic/profiles/default/metadata.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 1000 4 | 5 | -------------------------------------------------------------------------------- /src/pas/plugins/authomatic/profiles/default/registry.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | authomatic-handler 8 | 9 | 10 | 13 | 14 | ++plone++pas.plugins.authomatic/authomatic.less 15 | 16 | 17 | 18 | 21 | 22 | pas-plugins-authomatic 23 | 24 | True 25 | True 26 | ++plone++pas.plugins.authomatic/authomatic.css 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /src/pas/plugins/authomatic/profiles/uninstall/browserlayer.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | -------------------------------------------------------------------------------- /src/pas/plugins/authomatic/profiles/uninstall/controlpanel.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/pas/plugins/authomatic/profiles/uninstall/registry.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | 9 | 13 | 14 | 18 | 19 | 20 | 21 | 22 | 26 | 27 | 28 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /src/pas/plugins/authomatic/services/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/collective/pas.plugins.authomatic/64357d3f3015e5d01dd29e6c1c7fa7a710fd90ab/src/pas/plugins/authomatic/services/__init__.py -------------------------------------------------------------------------------- /src/pas/plugins/authomatic/services/authomatic.py: -------------------------------------------------------------------------------- 1 | from authomatic import Authomatic 2 | from pas.plugins.authomatic.integration import RestAPIAdapter 3 | from pas.plugins.authomatic.utils import authomatic_cfg 4 | from pas.plugins.authomatic.utils import authomatic_settings 5 | from plone import api 6 | from plone.protect.interfaces import IDisableCSRFProtection 7 | from plone.restapi.deserializer import json_body 8 | from plone.restapi.services import Service 9 | from Products.PluggableAuthService.interfaces.plugins import IAuthenticationPlugin 10 | from transaction.interfaces import NoTransaction 11 | from urllib.parse import parse_qsl 12 | from zope.interface import alsoProvides 13 | from zope.interface import implementer 14 | from zope.publisher.interfaces import IPublishTraverse 15 | 16 | import logging 17 | import transaction 18 | 19 | 20 | logger = logging.getLogger("pas.plugins.authomatic") 21 | 22 | 23 | @implementer(IPublishTraverse) 24 | class LoginAuthomatic(Service): 25 | """Base class for Authomatic login.""" 26 | 27 | AUTHOMATIC_COOKIE = "authomatic" 28 | provider_id: str = "" 29 | _providers = None 30 | _data = None 31 | 32 | def publishTraverse(self, request, name): 33 | # Store the first path segment as the provider 34 | request["TraversalRequestNameStack"] = [] 35 | self.provider_id = name 36 | return self 37 | 38 | @property 39 | def providers(self) -> dict: 40 | """Return Authomatic providers.""" 41 | providers = self._providers 42 | if not providers: 43 | try: 44 | providers = authomatic_cfg() 45 | except KeyError: 46 | # Authomatic is not configured 47 | providers = {} 48 | except ModuleNotFoundError: 49 | # Bad configuration 50 | providers = {} 51 | return providers 52 | 53 | @property 54 | def json_body(self): 55 | if not self._data: 56 | self._data = json_body(self.request) 57 | return self._data 58 | 59 | @property 60 | def public_url(self) -> str: 61 | method = self.request.get("REQUEST_METHOD") 62 | data = {} 63 | if method == "GET": 64 | data = self.request.form 65 | elif method == "POST": 66 | data = self.json_body 67 | public_url = data.get("publicUrl", "") 68 | if not public_url: 69 | public_url = api.portal.get().absolute_url() 70 | return public_url 71 | 72 | def get_auth(self) -> Authomatic: 73 | providers = self.providers 74 | secret = authomatic_settings().secret 75 | return Authomatic(providers, secret=secret) 76 | 77 | def _provider_not_found(self, provider: str) -> dict: 78 | """Return 404 status code for a provider not found.""" 79 | self.request.response.setStatus(404) 80 | if not provider: 81 | message = "Provider was not informed." 82 | else: 83 | message = f"Provider {provider} is not available." 84 | return { 85 | "error": { 86 | "type": "Provider not found", 87 | "message": message, 88 | } 89 | } 90 | 91 | 92 | class Get(LoginAuthomatic): 93 | """Provide information to start the OAuth process.""" 94 | 95 | def extract_cookie_identifier(self, headers: dict) -> str: 96 | """Get value of Authomatic cookie. 97 | 98 | :param headers: Dictionary with headers set by Authomatic. 99 | :returns: Value for the cookie set by Authomatic. 100 | """ 101 | cookie_prefix = f"{self.AUTHOMATIC_COOKIE}=" 102 | value = "" 103 | cookies = headers.get("Set-Cookie", "").split(";") 104 | for cookie in cookies: 105 | if cookie.startswith(cookie_prefix): 106 | value = cookie.replace(cookie_prefix, "") 107 | return value 108 | 109 | def reply(self) -> dict: 110 | """Generate URL and session information to be used by the frontend. 111 | 112 | :returns: URL and session information. 113 | """ 114 | provider = self.provider_id 115 | if provider not in self.providers: 116 | return self._provider_not_found(provider) 117 | 118 | auth = self.get_auth() 119 | adapter = RestAPIAdapter(self, provider) 120 | result = auth.login(adapter, provider) 121 | if result and result.error: 122 | self.request.response.setStatus(500) 123 | return { 124 | "error": { 125 | "type": "Configuration error", 126 | "message": f"Provider {provider} is not properly configured.", 127 | } 128 | } 129 | else: 130 | headers = adapter.headers 131 | identifier = self.extract_cookie_identifier(headers) 132 | next_url = headers["Location"] 133 | return { 134 | "next_url": next_url, 135 | "session": identifier, 136 | } 137 | 138 | 139 | class Post(LoginAuthomatic): 140 | """Handles OAuth login and returns a JSON web token (JWT).""" 141 | 142 | _aclu = None 143 | 144 | def _get_acl_users(self): 145 | """Get the acl_users tool. 146 | 147 | :returns: ACL tool. 148 | """ 149 | if not self._aclu: 150 | self._aclu = api.portal.get_tool("acl_users") 151 | return self._aclu 152 | 153 | def _get_jwt_plugin(self): 154 | """Get the JWT authentication plugin. 155 | 156 | :returns: JWT Authentication plugin. 157 | """ 158 | aclu = self._get_acl_users() 159 | plugins = aclu._getOb("plugins") 160 | authenticators = plugins.listPlugins(IAuthenticationPlugin) 161 | plugin = None 162 | for _, authenticator in authenticators: 163 | if authenticator.meta_type == "JWT Authentication Plugin": 164 | plugin = authenticator 165 | break 166 | return plugin 167 | 168 | def _add_identity(self, result, userid=None): 169 | """Add an identity to an existing user. 170 | 171 | :param result: Authomatic login result. 172 | """ 173 | aclu = self._get_acl_users() 174 | aclu.authomatic.remember_identity(result, userid) 175 | 176 | def _remember_identity(self, result): 177 | """Store identity information. 178 | 179 | :param result: Authomatic login result. 180 | """ 181 | aclu = self._get_acl_users() 182 | aclu.authomatic.remember(result) 183 | 184 | def get_token(self, user) -> str: 185 | """Generate JWT token for user. 186 | 187 | :param user: User memberdata. 188 | :returns: JWT token. 189 | """ 190 | token = "" 191 | plugin = self._get_jwt_plugin() 192 | if plugin: 193 | payload = {"fullname": user.getProperty("fullname")} 194 | token = plugin.create_token(user.getId(), data=payload) 195 | return token 196 | 197 | def _annotate_transaction(self, action, user): 198 | """Add a note to the current transaction.""" 199 | try: 200 | # Get the current transaction 201 | tx = transaction.get() 202 | except NoTransaction: 203 | return None 204 | # Set user on the transaction 205 | tx.setUser(user.getUser()) 206 | user_info = user.getProperty("fullname") or user.getUserName() 207 | msg = "" 208 | if action == "login": 209 | msg = f"(Logged in {user_info})" 210 | elif action == "add_identity": 211 | msg = f"(Added new identity to user {user_info})" 212 | tx.note(msg) 213 | 214 | def reply(self) -> dict: 215 | """Process OAuth callback, authenticate the user and return a JWT Token. 216 | 217 | :returns: Token information. 218 | """ 219 | provider = self.provider_id 220 | if provider not in self.providers: 221 | return self._provider_not_found(provider) 222 | 223 | data = self.json_body 224 | qs = data.get("qs", "") 225 | if qs.startswith("?"): 226 | qs = qs[1:] 227 | qs = dict(parse_qsl(qs)) 228 | cookies = {self.AUTHOMATIC_COOKIE: data.get("session", "")} 229 | adapter = RestAPIAdapter(self, provider, qs, cookies) 230 | auth = self.get_auth() 231 | result = auth.login(adapter, provider) 232 | if result and result.error: 233 | self.request.response.setStatus(401) 234 | return { 235 | "error": { 236 | "type": "Authentication Error", 237 | "message": f"{result.error}", 238 | } 239 | } 240 | elif result: 241 | alsoProvides(self.request, IDisableCSRFProtection) 242 | action = "" 243 | if api.user.is_anonymous(): 244 | self._remember_identity(result) 245 | action = "login" 246 | else: 247 | # Authenticated user, add an identity to it 248 | try: 249 | userid = api.user.get_current().getId() 250 | self._add_identity(result, userid) 251 | action = "add_identity" 252 | except ValueError as err: 253 | logger.exception(err) 254 | 255 | user = api.user.get_current() 256 | # Make sure we are not setting cookies here 257 | # as it will break the authentication mechanism with JWT tokens 258 | self.request.response.cookies = {} 259 | if action: 260 | self._annotate_transaction(action, user=user) 261 | return {"token": self.get_token(user)} 262 | -------------------------------------------------------------------------------- /src/pas/plugins/authomatic/services/configure.zcml: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | 8 | 12 | 13 | 14 | 21 | 22 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /src/pas/plugins/authomatic/services/login.py: -------------------------------------------------------------------------------- 1 | from pas.plugins.authomatic.utils import authomatic_cfg 2 | from plone.base.interfaces import IPloneSiteRoot 3 | from plone.restapi.interfaces import ILoginProviders 4 | from zope.component import adapter 5 | from zope.interface import implementer 6 | 7 | 8 | @adapter(IPloneSiteRoot) 9 | @implementer(ILoginProviders) 10 | class AuthomaticLoginProviders: 11 | def __init__(self, context): 12 | self.context = context 13 | 14 | def get_providers(self) -> list[dict]: 15 | """List all configured Authomatic plugins. 16 | 17 | :returns: List of login options. 18 | """ 19 | try: 20 | providers = authomatic_cfg() 21 | except KeyError: 22 | # Authomatic is not configured 23 | providers = {} 24 | plugins = [] 25 | for provider_id, provider in providers.items(): 26 | entry = provider.get("display", {}) 27 | title = entry.get("title", provider_id) 28 | 29 | plugins.append({ 30 | "id": provider_id, 31 | "plugin": "authomatic", 32 | "title": title, 33 | "url": f"{self.context.absolute_url()}/@login-oidc/{provider_id}", 34 | }) 35 | return plugins 36 | -------------------------------------------------------------------------------- /src/pas/plugins/authomatic/setuphandlers.py: -------------------------------------------------------------------------------- 1 | from pas.plugins.authomatic.interfaces import DEFAULT_ID 2 | from pas.plugins.authomatic.plugin import AuthomaticPlugin 3 | from Products.CMFPlone.interfaces import INonInstallable 4 | from zope.interface import implementer 5 | 6 | 7 | TITLE = "Authomatic OAuth plugin (pas.plugins.authomatic)" 8 | 9 | 10 | def _add_plugin(pas, pluginid=DEFAULT_ID): 11 | if pluginid in pas.objectIds(): 12 | return f"{TITLE} already installed." 13 | if pluginid != DEFAULT_ID: 14 | return f"ID of plugin must be {DEFAULT_ID}" 15 | plugin = AuthomaticPlugin(pluginid, title=TITLE) 16 | pas._setObject(pluginid, plugin) 17 | plugin = pas[plugin.getId()] # get plugin acquisition wrapped! 18 | for info in pas.plugins.listPluginTypeInfo(): 19 | interface = info["interface"] 20 | if not interface.providedBy(plugin): 21 | continue 22 | pas.plugins.activatePlugin(interface, plugin.getId()) 23 | pas.plugins.movePluginsDown( 24 | interface, 25 | [x[0] for x in pas.plugins.listPlugins(interface)[:-1]], 26 | ) 27 | 28 | 29 | def _remove_plugin(pas, pluginid=DEFAULT_ID): 30 | if pluginid in pas.objectIds(): 31 | pas.manage_delObjects([pluginid]) 32 | 33 | 34 | def post_install(context): 35 | _add_plugin(context.aq_parent.acl_users) 36 | 37 | 38 | def post_uninstall(context): 39 | _remove_plugin(context.aq_parent.acl_users) 40 | 41 | 42 | @implementer(INonInstallable) 43 | class HiddenProfiles: 44 | def getNonInstallableProfiles(self): 45 | """Do not show on Plone's list of installable profiles.""" 46 | return [ 47 | "pas.plugins.authomatic:uninstall", 48 | ] 49 | -------------------------------------------------------------------------------- /src/pas/plugins/authomatic/testing.py: -------------------------------------------------------------------------------- 1 | from contextlib import suppress 2 | from plone.app.robotframework.testing import REMOTE_LIBRARY_BUNDLE_FIXTURE 3 | from plone.app.testing import applyProfile 4 | from plone.app.testing import FunctionalTesting 5 | from plone.app.testing import IntegrationTesting 6 | from plone.app.testing import PLONE_FIXTURE 7 | from plone.app.testing import PloneSandboxLayer 8 | from plone.protect import auto 9 | from plone.testing import Layer 10 | from plone.testing.zope import installProduct 11 | from plone.testing.zope import INTEGRATION_TESTING 12 | from plone.testing.zope import WSGI_SERVER_FIXTURE 13 | from Products.CMFCore.interfaces import ISiteRoot 14 | from Products.PlonePAS.setuphandlers import migrate_root_uf 15 | from zope.component import provideUtility 16 | 17 | import pas.plugins.authomatic 18 | 19 | 20 | ORIGINAL_CSRF_DISABLED = auto.CSRF_DISABLED 21 | 22 | 23 | class PasPluginsAuthomaticZopeLayer(Layer): 24 | defaultBases = (INTEGRATION_TESTING,) 25 | 26 | # Products that will be installed, plus options 27 | products = ( 28 | ( 29 | "Products.GenericSetup", 30 | {"loadZCML": True}, 31 | ), 32 | ( 33 | "Products.CMFCore", 34 | {"loadZCML": True}, 35 | ), 36 | ( 37 | "Products.PluggableAuthService", 38 | {"loadZCML": True}, 39 | ), 40 | ( 41 | "Products.PluginRegistry", 42 | {"loadZCML": True}, 43 | ), 44 | ( 45 | "Products.PlonePAS", 46 | {"loadZCML": True}, 47 | ), 48 | ) 49 | 50 | def setUp(self): 51 | self.setUpZCML() 52 | 53 | def testSetUp(self): 54 | self.setUpProducts() 55 | provideUtility(self["app"], provides=ISiteRoot) 56 | migrate_root_uf(self["app"]) 57 | 58 | def setUpZCML(self): 59 | """Stack a new global registry and load ZCML configuration of Plone 60 | and the core set of add-on products into it. 61 | """ 62 | 63 | # Load dependent products's ZCML 64 | from zope.configuration import xmlconfig 65 | from zope.dottedname.resolve import resolve 66 | 67 | def loadAll(filename): 68 | for p, config in self.products: 69 | if not config["loadZCML"]: 70 | continue 71 | 72 | with suppress(ImportError): 73 | package = resolve(p) 74 | 75 | with suppress(OSError): 76 | xmlconfig.file( 77 | filename, package, context=self["configurationContext"] 78 | ) 79 | 80 | loadAll("meta.zcml") 81 | loadAll("configure.zcml") 82 | loadAll("overrides.zcml") 83 | 84 | def setUpProducts(self): 85 | """Install all old-style products listed in the the ``products`` tuple 86 | of this class. 87 | """ 88 | for prd, _ in self.products: 89 | installProduct(self["app"], prd) 90 | 91 | 92 | AUTHOMATIC_ZOPE_FIXTURE = PasPluginsAuthomaticZopeLayer() 93 | 94 | 95 | class PasPluginsAuthomaticPloneLayer(PloneSandboxLayer): 96 | defaultBases = (PLONE_FIXTURE,) 97 | 98 | def setUpZope(self, app, configurationContext): 99 | auto.CSRF_DISABLED = True 100 | self.loadZCML(package=pas.plugins.authomatic) 101 | installProduct(app, "pas.plugins.authomatic") 102 | 103 | def tearDownZope(self, app): 104 | auto.CSRF_DISABLED = ORIGINAL_CSRF_DISABLED 105 | 106 | def setUpPloneSite(self, portal): 107 | applyProfile(portal, "plone.restapi:default") 108 | applyProfile(portal, "pas.plugins.authomatic:default") 109 | 110 | 111 | FIXTURE = PasPluginsAuthomaticPloneLayer() 112 | 113 | 114 | INTEGRATION_TESTING = IntegrationTesting( 115 | bases=(FIXTURE,), 116 | name="PasPluginsAuthomaticPloneLayer:IntegrationTesting", 117 | ) 118 | 119 | 120 | FUNCTIONAL_TESTING = FunctionalTesting( 121 | bases=(FIXTURE,), 122 | name="PasPluginsAuthomaticPloneLayer:FunctionalTesting", 123 | ) 124 | 125 | 126 | RESTAPI_TESTING = FunctionalTesting( 127 | bases=(FIXTURE, WSGI_SERVER_FIXTURE), 128 | name="PasPluginsAuthomaticPloneLayer:RestAPITesting", 129 | ) 130 | 131 | 132 | ACCEPTANCE_TESTING = FunctionalTesting( 133 | bases=( 134 | FIXTURE, 135 | REMOTE_LIBRARY_BUNDLE_FIXTURE, 136 | WSGI_SERVER_FIXTURE, 137 | ), 138 | name="PasPluginsAuthomaticPloneLayer:AcceptanceTesting", 139 | ) 140 | -------------------------------------------------------------------------------- /src/pas/plugins/authomatic/useridentities.py: -------------------------------------------------------------------------------- 1 | from authomatic.core import Credentials 2 | from pas.plugins.authomatic.utils import authomatic_cfg 3 | from persistent import Persistent 4 | from persistent.dict import PersistentDict 5 | from Products.PluggableAuthService.UserPropertySheet import UserPropertySheet 6 | 7 | import logging 8 | import uuid 9 | 10 | 11 | logger = logging.getLogger("pas.plugins.authomatic") 12 | 13 | 14 | class UserIdentity(PersistentDict): 15 | def __init__(self, result): 16 | super().__init__() 17 | self["provider_name"] = result.provider.name 18 | self.update(result.user.to_dict()) 19 | 20 | @property 21 | def credentials(self): 22 | cfg = authomatic_cfg() 23 | return Credentials.deserialize(cfg, self.user["credentials"]) 24 | 25 | @credentials.setter 26 | def credentials(self, credentials): 27 | self.data["credentials"] = credentials.serialize() 28 | 29 | 30 | class UserIdentities(Persistent): 31 | def __init__(self, userid): 32 | self.userid = userid 33 | self._identities = PersistentDict() 34 | self._sheet = None 35 | self._secret = str(uuid.uuid4()) 36 | 37 | @property 38 | def secret(self): 39 | return self._secret 40 | 41 | def check_password(self, password): 42 | return password == self._secret 43 | 44 | def handle_result(self, result): 45 | """add a authomatic result to this user""" 46 | self._sheet = None # invalidate property sheet 47 | self._identities[result.provider.name] = UserIdentity(result) 48 | 49 | def identity(self, provider): 50 | """users identity at a distinct provider""" 51 | return self._identities.get(provider, None) 52 | 53 | def update_userdata(self, result): 54 | self._sheet = None # invalidate property sheet 55 | identity = self._identities[result.provider.name] 56 | identity.update(result.user.to_dict()) 57 | 58 | @property 59 | def propertysheet(self): 60 | if self._sheet is not None: 61 | return self._sheet 62 | # build sheet from identities 63 | pdata = {"id": self.userid} 64 | cfgs_providers = authomatic_cfg() 65 | for provider_name in cfgs_providers: 66 | identity = self.identity(provider_name) 67 | if identity is None: 68 | continue 69 | logger.debug(identity) 70 | cfg = cfgs_providers[provider_name] 71 | for akey, pkey in cfg.get("propertymap", {}).items(): 72 | # Always search first on the user attributes, then on the raw 73 | # data this guaratees we do not break existing configurations 74 | ainfo = identity.get(akey, None) or identity["data"].get(akey, None) 75 | if ainfo is None: 76 | continue 77 | if isinstance(pkey, dict): 78 | for k, v in pkey.items(): 79 | pdata[k] = ainfo.get(v) 80 | else: 81 | pdata[pkey] = ainfo 82 | self._sheet = UserPropertySheet(**pdata) 83 | return self._sheet 84 | -------------------------------------------------------------------------------- /src/pas/plugins/authomatic/useridfactories.py: -------------------------------------------------------------------------------- 1 | from pas.plugins.authomatic.interfaces import _ 2 | from pas.plugins.authomatic.interfaces import IUserIDFactory 3 | from pas.plugins.authomatic.utils import authomatic_settings 4 | from zope.component import queryUtility 5 | from zope.interface import implementer 6 | 7 | import uuid 8 | 9 | 10 | @implementer(IUserIDFactory) 11 | class BaseUserIDFactory: 12 | def normalize(self, plugin, result, userid): 13 | new_userid = userid 14 | counter = 2 # first was taken, so logically its second 15 | while new_userid in plugin._useridentities_by_userid: 16 | new_userid = f"{userid}_{counter}" 17 | counter += 1 18 | return new_userid 19 | 20 | 21 | class UUID4UserIDFactory(BaseUserIDFactory): 22 | title = _("UUID as User ID") 23 | 24 | def __call__(self, plugin, result): 25 | return self.normalize(plugin, result, str(uuid.uuid4())) 26 | 27 | 28 | class ProviderIDUserIDFactory(BaseUserIDFactory): 29 | title = _("Provider User ID") 30 | 31 | def __call__(self, plugin, result): 32 | return self.normalize(plugin, result, result.user.id) 33 | 34 | 35 | class ProviderIDUserNameFactory(BaseUserIDFactory): 36 | title = _("Provider User Name") 37 | 38 | def __call__(self, plugin, result): 39 | return self.normalize(plugin, result, result.user.username) 40 | 41 | 42 | def new_userid(plugin, result): 43 | settings = authomatic_settings() 44 | factory = queryUtility( 45 | IUserIDFactory, name=settings.userid_factory_name, default=UUID4UserIDFactory() 46 | ) 47 | return factory(plugin, result) 48 | 49 | 50 | class ProviderIDUserNameIdFactory(BaseUserIDFactory): 51 | title = _("Provider User Name or User ID") 52 | 53 | def __call__(self, plugin, result): 54 | user_id = result.user.username 55 | if not user_id: 56 | user_id = result.user.id 57 | return self.normalize(plugin, result, user_id) 58 | -------------------------------------------------------------------------------- /src/pas/plugins/authomatic/utils.py: -------------------------------------------------------------------------------- 1 | from pas.plugins.authomatic.interfaces import DEFAULT_ID 2 | from pas.plugins.authomatic.interfaces import IPasPluginsAuthomaticSettings 3 | from plone import api 4 | from plone.registry.interfaces import IRegistry 5 | from zope.component import queryUtility 6 | from zope.dottedname.resolve import resolve 7 | 8 | import json 9 | 10 | 11 | def authomatic_plugin(): 12 | """returns the authomatic pas-plugin instance""" 13 | aclu = api.portal.get_tool("acl_users") 14 | # XXX we should better iterate over all plugins and fetch the 15 | # authomatic plugin. There could be even 2 of them, even if this does not 16 | # make sense. 17 | return aclu.get(DEFAULT_ID, None) 18 | 19 | 20 | def authomatic_settings(): 21 | """fetches the authomatic settings from registry""" 22 | registry = queryUtility(IRegistry) 23 | return registry.forInterface(IPasPluginsAuthomaticSettings) 24 | 25 | 26 | def authomatic_cfg(): 27 | """fetches the authomatic configuration from the settings and 28 | returns it as a dict 29 | """ 30 | settings = authomatic_settings() 31 | try: 32 | cfg = json.loads(settings.json_config) 33 | except ValueError: 34 | return None 35 | if not isinstance(cfg, dict): 36 | return None 37 | ids = set() 38 | cnt = 1 39 | for provider in cfg: 40 | if "class_" in cfg[provider]: 41 | cfg[provider]["class_"] = resolve(cfg[provider]["class_"]) 42 | if "id" in cfg[provider]: 43 | cfg[provider]["id"] = int(cfg[provider]["id"]) 44 | else: 45 | # pick some id 46 | while cnt in ids: 47 | cnt += 1 48 | cfg[provider]["id"] = cnt 49 | ids.update([cfg[provider]["id"]]) 50 | return cfg 51 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from pas.plugins.authomatic.testing import ACCEPTANCE_TESTING 2 | from pas.plugins.authomatic.testing import FUNCTIONAL_TESTING 3 | from pas.plugins.authomatic.testing import INTEGRATION_TESTING 4 | from pas.plugins.authomatic.testing import RESTAPI_TESTING 5 | from pytest_plone import fixtures_factory 6 | 7 | import pytest 8 | 9 | 10 | pytest_plugins = ["pytest_plone"] 11 | 12 | 13 | globals().update( 14 | fixtures_factory(( 15 | (FUNCTIONAL_TESTING, "functional"), 16 | (INTEGRATION_TESTING, "integration"), 17 | (ACCEPTANCE_TESTING, "acceptance"), 18 | (RESTAPI_TESTING, "restapi"), 19 | )) 20 | ) 21 | 22 | 23 | @pytest.fixture 24 | def plugin_id(): 25 | return "authomatic" 26 | 27 | 28 | @pytest.fixture 29 | def add_plugin(): 30 | def func(aclu, plugin_id): 31 | from pas.plugins.authomatic.setuphandlers import _add_plugin 32 | 33 | result = _add_plugin(aclu, plugin_id) 34 | return result 35 | 36 | return func 37 | 38 | 39 | @pytest.fixture 40 | def mock_result(): 41 | class MockResult(dict): 42 | def __init__(self, *args, **kwargs): 43 | super().__init__(*args, **kwargs) 44 | self.__dict__ = self 45 | 46 | def to_dict(self): 47 | return self 48 | 49 | return MockResult 50 | 51 | 52 | @pytest.fixture 53 | def mock_credentials(mock_result): 54 | class MockCredentials(mock_result): 55 | def refresh(*args): 56 | pass 57 | 58 | return MockCredentials 59 | 60 | 61 | @pytest.fixture 62 | def make_user(mock_result): 63 | mock = mock_result 64 | 65 | def make_user(login, plugin=None, password=None): 66 | from pas.plugins.authomatic.useridentities import UserIdentities 67 | 68 | uis = UserIdentities(login) 69 | if password: 70 | uis._secret = password 71 | plugin._useridentities_by_userid[login] = uis 72 | mock_result = mock(provider=mock(name="mock_provider"), user=mock()) 73 | uis.handle_result(mock_result) 74 | return uis 75 | 76 | return make_user 77 | -------------------------------------------------------------------------------- /tests/functional/conftest.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/collective/pas.plugins.authomatic/64357d3f3015e5d01dd29e6c1c7fa7a710fd90ab/tests/functional/conftest.py -------------------------------------------------------------------------------- /tests/functional/test_controlpanel.py: -------------------------------------------------------------------------------- 1 | from pas.plugins.authomatic.interfaces import IPasPluginsAuthomaticSettings 2 | from plone import api 3 | from plone.app.testing import logout 4 | 5 | import json 6 | import pytest 7 | 8 | 9 | class TestControlPanel: 10 | @pytest.fixture(autouse=True) 11 | def _initialize(self, http_request, portal): 12 | self.portal = portal 13 | self.request = http_request 14 | 15 | def setUp(self): 16 | self.portal = self.layer["portal"] 17 | self.request = self.layer["request"] 18 | 19 | def test_authomatic_controlpanel_view(self): 20 | view = api.content.get_view( 21 | name="authomatic-controlpanel", context=self.portal, request=self.request 22 | ) 23 | assert view() 24 | 25 | def test_authomatic_controlpanel_view_protected(self): 26 | from AccessControl import Unauthorized 27 | 28 | logout() 29 | with pytest.raises(Unauthorized) as exc: 30 | self.portal.restrictedTraverse("@@authomatic-controlpanel") 31 | assert "Unauthorized('@@authomatic-controlpanel'" in str(exc) 32 | 33 | def test_authomatic_in_controlpanel(self): 34 | portal_controlpanel = api.portal.get_tool("portal_controlpanel") 35 | cp_ids = [a.getAction(self)["id"] for a in portal_controlpanel.listActions()] 36 | assert "authomatic" in cp_ids 37 | 38 | def test_json_config_property(self): 39 | assert "json_config" in IPasPluginsAuthomaticSettings 40 | value = api.portal.get_registry_record( 41 | name="json_config", interface=IPasPluginsAuthomaticSettings 42 | ) 43 | assert isinstance(value, str) 44 | 45 | @pytest.mark.parametrize( 46 | "path,expected", 47 | [ 48 | ["github/class_", "authomatic.providers.oauth2.GitHub"], 49 | ["github/consumer_key", "Example, please get a key and secret. See"], 50 | ["github/consumer_secret", "https://github.com/settings/applications/new"], 51 | ["github/id", 1], 52 | ["github/propertymap/email", "email"], 53 | ["github/propertymap/link", "home_page"], 54 | ["github/propertymap/location", "location"], 55 | ["github/propertymap/name", "fullname"], 56 | ], 57 | ) 58 | def test_json_config_default(self, path, expected): 59 | value = json.loads( 60 | api.portal.get_registry_record( 61 | name="json_config", interface=IPasPluginsAuthomaticSettings 62 | ) 63 | ) 64 | for segment in path.split("/"): 65 | value = value[segment] 66 | assert value == expected 67 | -------------------------------------------------------------------------------- /tests/functional/test_controlpanel_functional.py: -------------------------------------------------------------------------------- 1 | from plone.app.testing import SITE_OWNER_NAME 2 | from plone.app.testing import SITE_OWNER_PASSWORD 3 | from plone.testing.zope import Browser 4 | from zope.component.hooks import setSite 5 | 6 | import pytest 7 | 8 | 9 | @pytest.fixture() 10 | def http_request(functional): 11 | return functional["request"] 12 | 13 | 14 | @pytest.fixture() 15 | def portal(functional): 16 | portal = functional["portal"] 17 | setSite(portal) 18 | yield portal 19 | 20 | 21 | @pytest.fixture 22 | def browser(app): 23 | browser = Browser(app) 24 | browser.handleErrors = False 25 | 26 | 27 | @pytest.fixture() 28 | def browser_factory(app): 29 | def factory(handle_errors=True): 30 | browser = Browser(app) 31 | browser.handleErrors = handle_errors 32 | return browser 33 | 34 | return factory 35 | 36 | 37 | @pytest.fixture() 38 | def browser_manager(browser_factory): 39 | browser = browser_factory() 40 | browser.addHeader( 41 | "Authorization", 42 | f"Basic {SITE_OWNER_NAME}:{SITE_OWNER_PASSWORD}", 43 | ) 44 | return browser 45 | 46 | 47 | class TestControlPanelFunctional: 48 | @pytest.fixture(autouse=True) 49 | def _initialize(self, http_request, portal, browser_manager): 50 | self.portal = portal 51 | self.portal_url = portal.absolute_url() 52 | self.request = http_request 53 | self.browser = browser_manager 54 | 55 | def test_empty_form(self): 56 | self.browser.open(f"{self.portal_url}/authomatic-controlpanel") 57 | assert " Settings" in self.browser.contents 58 | -------------------------------------------------------------------------------- /tests/plugin/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.fixture 5 | def plugin(portal, add_plugin, plugin_id): 6 | add_plugin(portal.acl_users, plugin_id) 7 | return portal.acl_users[plugin_id] 8 | -------------------------------------------------------------------------------- /tests/plugin/test_plugin.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.fixture 5 | def populate_users(make_user): 6 | def func(plugin): 7 | logins = [ 8 | "123joe", 9 | "123jane", 10 | "123wily", 11 | "123willi", 12 | ] 13 | for login in logins: 14 | make_user(login, plugin) 15 | 16 | return func 17 | 18 | 19 | @pytest.fixture 20 | def populate_admins(portal, plugin, make_user): 21 | aclu = portal.acl_users 22 | aclu.userFolderAddUser("admin", "admin", [], []) # zope admin 23 | make_user("administrator", plugin=plugin) # oauth administrator 24 | 25 | 26 | class TestPlugin: 27 | @pytest.fixture(autouse=True) 28 | def _set_up(self, portal, plugin): 29 | self.aclu = portal.acl_users 30 | self.plugin = plugin 31 | 32 | def test_user_enumeration_empty_query(self, populate_users): 33 | populate_users(self.plugin) 34 | # https://github.com/collective/pas.plugins.authomatic/pull/25/commits/5c0f6b1dc76a0d769e35a845ce4c4dd4307655ba 35 | # Due to the workarround, now the enumerateUsers plugin doesn't return 36 | # any users when searching with an empty query 37 | assert len(self.plugin.enumerateUsers()) == 0 38 | 39 | @pytest.mark.parametrize( 40 | "query,expected", 41 | [ 42 | ["123", 4], 43 | ["123j", 2], 44 | ["123jo", 1], 45 | ["123w", 2], 46 | ["user", 0], 47 | ], 48 | ) 49 | def test_user_enumeration_not_exact_match(self, populate_users, query, expected): 50 | populate_users(self.plugin) 51 | # check by user id 52 | result = self.plugin.enumerateUsers(id=query) 53 | assert len(result) == expected 54 | 55 | @pytest.mark.parametrize( 56 | "login", 57 | [ 58 | "123joe", 59 | "123jane", 60 | "123wily", 61 | "123willi", 62 | ], 63 | ) 64 | def test_user_enumeration(self, make_user, login): 65 | make_user(login, plugin=self.plugin) 66 | # check by user id 67 | result = self.plugin.enumerateUsers(id=login, exact_match=True) 68 | assert len(result) == 1 69 | assert result[0] == {"login": login, "pluginid": "authomatic", "id": login} 70 | 71 | def test_user_delete(self, populate_users): 72 | populate_users(self.plugin) 73 | assert len(self.plugin.enumerateUsers(login="123j")) == 2 74 | assert len(self.plugin.enumerateUsers(login="123joe")) == 1 75 | self.plugin.doDeleteUser(userid="123joe") 76 | assert len(self.plugin.enumerateUsers(login="123j")) == 1 77 | assert len(self.plugin.enumerateUsers(login="123joe")) == 0 78 | 79 | def test_user_delete_invalid_uid(self, populate_users): 80 | populate_users(self.plugin) 81 | assert len(self.plugin.enumerateUsers(login="123j")) == 2 82 | self.plugin.doDeleteUser(userid="123foo") 83 | assert len(self.plugin.enumerateUsers(login="123j")) == 2 84 | 85 | def test_authentication_empty_deny(self): 86 | credentials = {} 87 | result = self.plugin.authenticateCredentials(credentials) 88 | assert result is None 89 | 90 | def test_authentication_nonexistent_deny(self): 91 | credentials = { 92 | "login": "UNSET", 93 | "password": "UNSET", 94 | } 95 | result = self.plugin.authenticateCredentials(credentials) 96 | assert result is None 97 | 98 | def test_authentication_user_no_pass_deny(self, make_user): 99 | make_user("joe", plugin=self.plugin) 100 | credentials = { 101 | "login": "joe", 102 | "password": "SECRET", 103 | } 104 | result = self.plugin.authenticateCredentials(credentials) 105 | assert result is None 106 | 107 | def test_authentication_user_same_pass_allow(self, make_user): 108 | make_user("joe", plugin=self.plugin, password="SECRET") # noQA: S106 109 | credentials = {"login": "joe", "password": "SECRET"} 110 | result = self.plugin.authenticateCredentials(credentials) 111 | assert result == ("joe", "joe") 112 | 113 | def test_admin_search_exact_match(self, populate_admins): 114 | # check searching exact user by plugins: authomatic and ZODBUserManager 115 | assert len(self.aclu.searchUsers(id="adm", exact_match=True)) == 0 116 | 117 | @pytest.mark.parametrize( 118 | "userid,expected_plugin_id", 119 | [ 120 | ("administrator", "authomatic"), 121 | ("admin", "source_users"), 122 | ], 123 | ) 124 | def test_authentication_zope_admin( 125 | self, populate_admins, userid, expected_plugin_id 126 | ): 127 | user = self.aclu.searchUsers(id=userid, exact_match=True)[0] 128 | assert user["pluginid"] == expected_plugin_id 129 | -------------------------------------------------------------------------------- /tests/plugin/test_useridentities.py: -------------------------------------------------------------------------------- 1 | from Products.PluggableAuthService.UserPropertySheet import UserPropertySheet 2 | 3 | import pytest 4 | 5 | 6 | @pytest.fixture 7 | def authomatic_user_factory(mock_result): 8 | from authomatic.core import User 9 | 10 | def func(provider_name="MockPlone", data=None, props=None): 11 | props = props if props else {} 12 | provider = mock_result(name=provider_name) 13 | if not data: 14 | data = { 15 | "displayName": "Andrew Pipkin", 16 | "domain": "foobar.com", 17 | "emails": [{"type": "account", "value": "andrewpipkin@foobar.com"}], 18 | "etag": '"xxxxxxxxxxxx/xxxxxxxxxxxx"', 19 | "id": "123456789", 20 | "image": { 21 | "isDefault": False, 22 | "url": "https://lh3.googleusercontent.com/photo.jpg", 23 | }, 24 | "isPlusUser": False, 25 | "kind": "plus#person", 26 | "language": "en_GB", 27 | "name": {"familyName": "Pipkin", "givenName": "Andrew"}, 28 | "objectType": "person", 29 | "verified": False, 30 | } 31 | user = User(provider) 32 | user.data = data 33 | user.id = "123456789" 34 | user.username = "andrewpipkin" 35 | user.name = "Andrew Pipkin" 36 | user.first_name = "Andrew" 37 | user.last_name = "Pipkin" 38 | user.nickname = "Andy" 39 | user.link = "http://peterhudec.github.io/authomatic/" 40 | user.email = "andrewpipkin@foobar.com" 41 | user.picture = "https://lh3.googleusercontent.com/photo.jpg?sz=50" 42 | user.location = "Innsbruck" 43 | for prop in props: 44 | setattr(user, prop, props[prop]) 45 | return user 46 | 47 | return func 48 | 49 | 50 | @pytest.fixture 51 | def one_user(make_user, mock_result, authomatic_user_factory): 52 | def func(plugin, provider_name, data=None, props=None): 53 | data = data if data else {} 54 | props = props if props else {} 55 | user = make_user("mustermann", plugin=plugin) 56 | authomatic_result = mock_result( 57 | user=authomatic_user_factory( 58 | provider_name=provider_name, data=data, props=props 59 | ), 60 | provider=mock_result(name=provider_name), 61 | ) 62 | user.handle_result(authomatic_result) 63 | return user 64 | 65 | return func 66 | 67 | 68 | @pytest.fixture 69 | def patch_authomatic(monkeypatch): 70 | def func(provider_name: str = "mockhub", custom_props: dict | None = None): 71 | from pas.plugins.authomatic import useridentities 72 | 73 | def authomatic_cfg(): 74 | proppropertymap = { 75 | "email": "email", 76 | "link": "home_page", 77 | "location": "location", 78 | "name": "fullname", 79 | } 80 | if custom_props: 81 | proppropertymap.update(custom_props) 82 | return {provider_name: {"propertymap": proppropertymap}} 83 | 84 | monkeypatch.setattr(useridentities, "authomatic_cfg", authomatic_cfg) 85 | 86 | return func 87 | 88 | 89 | class TestUserIdentity: 90 | @pytest.fixture(autouse=True) 91 | def _set_up(self, mock_result): 92 | self.input_name = "mockprovider" 93 | self.result = mock_result( 94 | provider=mock_result(name=self.input_name), 95 | user=mock_result(), 96 | ) 97 | 98 | def test_init(self): 99 | from pas.plugins.authomatic.useridentities import UserIdentity 100 | 101 | ui = UserIdentity(self.result) 102 | assert self.input_name == ui["provider_name"] 103 | 104 | 105 | class TestUserIdentities: 106 | provider_name: str = "mockhub" 107 | 108 | @pytest.fixture(autouse=True) 109 | def _set_up(self, plugin, patch_authomatic, one_user): 110 | self.plugin = plugin 111 | patch_authomatic(self.provider_name) 112 | self.user = one_user(plugin, self.provider_name, data={}) 113 | 114 | def test_identities_init(self): 115 | input_userid = "mockuserid" 116 | from pas.plugins.authomatic.useridentities import UserIdentities 117 | 118 | uis = UserIdentities(input_userid) 119 | assert uis.userid == input_userid 120 | 121 | def test_sheet_existing_user(self): 122 | sheet = self.user.propertysheet 123 | assert isinstance(sheet, UserPropertySheet) 124 | 125 | @pytest.mark.parametrize( 126 | "prop_name, expected", 127 | [ 128 | ["home_page", "http://peterhudec.github.io/authomatic/"], 129 | ["fullname", "Andrew Pipkin"], 130 | ["email", "andrewpipkin@foobar.com"], 131 | ], 132 | ) 133 | def test_sheet_existing_user_attributes(self, prop_name, expected): 134 | sheet = self.user.propertysheet 135 | assert sheet.getProperty(prop_name) == expected 136 | 137 | def test_read_attribute_from_provider_data_if_default_is_none(self, one_user): 138 | user = one_user( 139 | self.plugin, 140 | self.provider_name, 141 | data={"email": "jdoe@foobar.com"}, 142 | props={"email": None}, 143 | ) 144 | sheet = user.propertysheet 145 | assert sheet.getProperty("email") == "jdoe@foobar.com" 146 | 147 | 148 | class TestUserIdentitiesCustomProps: 149 | provider_name: str = "mockhub" 150 | 151 | @pytest.fixture(autouse=True) 152 | def _set_up(self, plugin, patch_authomatic, one_user): 153 | self.plugin = plugin 154 | custom_props = {"domain": "customdomain"} 155 | patch_authomatic(self.provider_name, custom_props=custom_props) 156 | self.user = one_user(plugin, self.provider_name, data={}) 157 | 158 | @pytest.mark.parametrize( 159 | "prop_name, expected", 160 | [ 161 | ["home_page", "http://peterhudec.github.io/authomatic/"], 162 | ["fullname", "Andrew Pipkin"], 163 | ["email", "andrewpipkin@foobar.com"], 164 | ["customdomain", "foobar.com"], 165 | ], 166 | ) 167 | def test_sheet_user_attributes(self, prop_name, expected): 168 | sheet = self.user.propertysheet 169 | assert sheet.getProperty(prop_name) == expected 170 | -------------------------------------------------------------------------------- /tests/plugin/test_useridfactories.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.fixture 5 | def plugin(): 6 | from BTrees.OOBTree import OOBTree 7 | 8 | class MockPlugin: 9 | _useridentities_by_userid = OOBTree() 10 | 11 | return MockPlugin 12 | 13 | 14 | class TestUserIDFactories: 15 | def test_normalizer(self, plugin, mock_result): 16 | from pas.plugins.authomatic.useridfactories import BaseUserIDFactory 17 | 18 | bf = BaseUserIDFactory() 19 | 20 | mock_plugin = plugin 21 | mock_result = mock_result() 22 | assert bf.normalize(mock_plugin, mock_result, "fo") == "fo" 23 | mock_plugin._useridentities_by_userid["fo"] = 1 24 | assert bf.normalize(mock_plugin, mock_result, "fo") == "fo_2" 25 | -------------------------------------------------------------------------------- /tests/services/conftest.py: -------------------------------------------------------------------------------- 1 | from plone.restapi.testing import RelativeSession 2 | from zope.component.hooks import setSite 3 | 4 | import pytest 5 | 6 | 7 | @pytest.fixture() 8 | def http_request(restapi): 9 | return restapi["request"] 10 | 11 | 12 | @pytest.fixture() 13 | def portal(restapi): 14 | portal = restapi["portal"] 15 | setSite(portal) 16 | yield portal 17 | 18 | 19 | @pytest.fixture() 20 | def request_api_factory(portal): 21 | def factory(): 22 | url = portal.absolute_url() 23 | api_session = RelativeSession(f"{url}/++api++") 24 | return api_session 25 | 26 | return factory 27 | 28 | 29 | @pytest.fixture() 30 | def api_anon_request(request_api_factory): 31 | return request_api_factory() 32 | -------------------------------------------------------------------------------- /tests/services/test_services_authomatic.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import quote_plus 2 | 3 | import pytest 4 | 5 | 6 | class TestServiceAuthomaticGet: 7 | @pytest.fixture(autouse=True) 8 | def _setup(self, api_anon_request): 9 | self.api_session = api_anon_request 10 | 11 | @pytest.mark.parametrize( 12 | "url,error_type,error_message", 13 | [ 14 | ["/@login-authomatic", "Provider not found", "Provider was not informed."], 15 | [ 16 | "/@login-authomatic/unknown-provider", 17 | "Provider not found", 18 | "Provider unknown-provider is not available.", 19 | ], 20 | [ 21 | "/@login-authomatic/unknown-provider", 22 | "Provider not found", 23 | "Provider unknown-provider is not available.", 24 | ], 25 | ], 26 | ) 27 | def test_service_without_provider_id(self, url, error_type, error_message): 28 | response = self.api_session.get(url) 29 | assert response.status_code == 404 30 | data = response.json() 31 | error = data["error"] 32 | assert error["type"] == error_type 33 | assert error["message"] == error_message 34 | 35 | def test_service_valid_provider_id(self): 36 | response = self.api_session.get("/@login-authomatic/github") 37 | assert response.status_code == 200 38 | data = response.json() 39 | assert "session" in data 40 | assert "next_url" in data 41 | assert quote_plus("/plone/login-authomatic") in data["next_url"] 42 | 43 | def test_service_with_publicUrl(self): 44 | response = self.api_session.get( 45 | "/@login-authomatic/github?publicUrl=https://plone.org" 46 | ) 47 | assert response.status_code == 200 48 | data = response.json() 49 | assert "session" in data 50 | assert "next_url" in data 51 | assert quote_plus("https://plone.org/login-authomatic") in data["next_url"] 52 | -------------------------------------------------------------------------------- /tests/services/test_services_login.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | class TestServiceLogin: 5 | @pytest.fixture(autouse=True) 6 | def _setup(self, api_anon_request): 7 | self.api_session = api_anon_request 8 | 9 | def test_get_login_endpoint(self): 10 | response = self.api_session.get("/@login") 11 | assert response.status_code == 200 12 | 13 | @pytest.mark.parametrize( 14 | "key,expected", 15 | [ 16 | ["id", "github"], 17 | ["plugin", "authomatic"], 18 | ["title", "Github"], 19 | ], 20 | ) 21 | def test_get_login_options(self, key, expected): 22 | response = self.api_session.get("/@login") 23 | data = response.json() 24 | options = data.get("options") 25 | assert len(options) == 1 26 | option = data["options"][0] 27 | assert option[key] == expected 28 | -------------------------------------------------------------------------------- /tests/setup/test_setup_install.py: -------------------------------------------------------------------------------- 1 | from pas.plugins.authomatic import PACKAGE_NAME 2 | 3 | 4 | class TestSetupInstall: 5 | def test_addon_installed(self, installer): 6 | """Test if kitconcept_intranet is installed.""" 7 | assert installer.is_product_installed(PACKAGE_NAME) is True 8 | 9 | def test_browserlayer(self, browser_layers): 10 | """Test that IDefaultBrowserLayer is registered.""" 11 | from pas.plugins.authomatic.interfaces import IPasPluginsAuthomaticLayer 12 | 13 | assert IPasPluginsAuthomaticLayer in browser_layers 14 | 15 | def test_latest_version(self, profile_last_version): 16 | """Test latest version of default profile.""" 17 | assert profile_last_version(f"{PACKAGE_NAME}:default") == "1000" 18 | -------------------------------------------------------------------------------- /tests/setup/test_setup_uninstall.py: -------------------------------------------------------------------------------- 1 | from pas.plugins.authomatic import PACKAGE_NAME 2 | 3 | import pytest 4 | 5 | 6 | class TestSetupUninstall: 7 | @pytest.fixture(autouse=True) 8 | def uninstalled(self, installer): 9 | installer.uninstall_product(PACKAGE_NAME) 10 | 11 | def test_addon_uninstalled(self, installer): 12 | """Test if kitconcept_intranet is uninstalled.""" 13 | assert installer.is_product_installed(PACKAGE_NAME) is False 14 | 15 | def test_browserlayer_not_registered(self, browser_layers): 16 | """Test that IDefaultBrowserLayer is not registered.""" 17 | from pas.plugins.authomatic.interfaces import IPasPluginsAuthomaticLayer 18 | 19 | assert IPasPluginsAuthomaticLayer not in browser_layers 20 | -------------------------------------------------------------------------------- /tests/setup/test_setuphandler.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.fixture 5 | def plugin(add_plugin, app, plugin_id): 6 | add_plugin(app.acl_users, plugin_id) 7 | return app.acl_users[plugin_id] 8 | 9 | 10 | class TestSetupHandlers: 11 | @pytest.fixture(autouse=True) 12 | def _setup(self, app): 13 | self.aclu = app.acl_users 14 | 15 | def test_addplugin(self, add_plugin, plugin_id): 16 | from pas.plugins.authomatic.plugin import AuthomaticPlugin 17 | 18 | add_plugin(self.aclu, plugin_id) 19 | plugin = self.aclu[plugin_id] 20 | assert plugin_id in self.aclu.objectIds() 21 | 22 | assert isinstance(plugin, AuthomaticPlugin) 23 | 24 | def test_cannot_add_duplicated_plugin(self, plugin, add_plugin, plugin_id): 25 | from pas.plugins.authomatic.setuphandlers import TITLE 26 | 27 | assert plugin_id in self.aclu.objectIds() 28 | result = add_plugin(self.aclu, plugin_id) 29 | assert result == f"{TITLE} already installed." 30 | 31 | def test_removeplugin(self, plugin, plugin_id): 32 | from pas.plugins.authomatic.setuphandlers import _remove_plugin 33 | 34 | assert plugin_id in self.aclu.objectIds() 35 | _remove_plugin(self.aclu, pluginid=plugin_id) 36 | assert plugin_id not in self.aclu.objectIds() 37 | -------------------------------------------------------------------------------- /tests/setup/test_upgrades.py: -------------------------------------------------------------------------------- 1 | from pas.plugins.authomatic import PACKAGE_NAME 2 | from Products.GenericSetup.upgrade import listUpgradeSteps 3 | 4 | import pytest 5 | 6 | 7 | @pytest.fixture 8 | def available_steps(setup_tool): 9 | """Test available steps.""" 10 | 11 | def _match(item, source, dest): 12 | source, dest = (source,), (dest,) 13 | return item["source"] == source and item["dest"] == dest 14 | 15 | def func(profile: str, src: str, dst: str): 16 | steps = listUpgradeSteps(setup_tool, profile, src) 17 | steps = [s for s in steps if _match(s[0], src, dst)] 18 | return steps 19 | 20 | return func 21 | 22 | 23 | class TestUpgrades: 24 | profile = f"{PACKAGE_NAME}:default" 25 | 26 | @pytest.mark.parametrize("src,dst,expected", [("1", "1000", 1)]) 27 | def test_available(self, available_steps, src, dst, expected): 28 | steps = available_steps(self.profile, src, dst) 29 | assert len(steps) == expected 30 | --------------------------------------------------------------------------------