├── .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 |

4 |
OAuth2 / OpenId Authentication in Plone
5 |
6 |
7 |
8 |
9 |
10 | [](https://pypi.org/project/pas.plugins.authomatic/)
11 | [](https://pypi.org/project/pas.plugins.authomatic/)
12 | [](https://pypi.org/project/pas.plugins.authomatic/)
13 | [](https://pypi.org/project/pas.plugins.authomatic/)
14 | [](https://pypi.org/project/pas.plugins.authomatic/)
15 |
16 |
17 | [](https://pypi.org/project/pas.plugins.authomatic/)
18 |
19 | [](https://github.com/collective/pas.plugins.authomatic/actions/workflows/main.yml)
20 | 
21 |
22 | [](https://github.com/collective/pas.plugins.authomatic)
23 | [](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 |
186 |
187 | Configuration parameters for the different authorization are provided as JSON text in there. We use JSON because of its flexibility.
188 |
189 |
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 |
--------------------------------------------------------------------------------
/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 |
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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------