c2cgeoportal-geoportal 2.3.5.80__py3-none-any.whl → 2.9rc1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- c2cgeoportal_geoportal/__init__.py +960 -0
- c2cgeoportal_geoportal/lib/__init__.py +256 -0
- c2cgeoportal_geoportal/lib/authentication.py +250 -0
- c2cgeoportal_geoportal/lib/bashcolor.py +46 -0
- c2cgeoportal_geoportal/lib/cacheversion.py +75 -0
- c2cgeoportal_geoportal/lib/caching.py +176 -0
- c2cgeoportal_geoportal/lib/check_collector.py +80 -0
- c2cgeoportal_geoportal/lib/checker.py +295 -0
- c2cgeoportal_geoportal/lib/common_headers.py +170 -0
- c2cgeoportal_geoportal/lib/dbreflection.py +266 -0
- c2cgeoportal_geoportal/lib/filter_capabilities.py +360 -0
- c2cgeoportal_geoportal/lib/fulltextsearch.py +50 -0
- c2cgeoportal_geoportal/lib/functionality.py +166 -0
- c2cgeoportal_geoportal/lib/headers.py +62 -0
- c2cgeoportal_geoportal/lib/i18n.py +38 -0
- c2cgeoportal_geoportal/lib/layers.py +132 -0
- c2cgeoportal_geoportal/lib/lingva_extractor.py +937 -0
- c2cgeoportal_geoportal/lib/loader.py +57 -0
- c2cgeoportal_geoportal/lib/metrics.py +117 -0
- c2cgeoportal_geoportal/lib/oauth2.py +1186 -0
- c2cgeoportal_geoportal/lib/oidc.py +302 -0
- c2cgeoportal_geoportal/lib/wmstparsing.py +353 -0
- c2cgeoportal_geoportal/lib/xsd.py +166 -0
- c2cgeoportal_geoportal/py.typed +0 -0
- c2cgeoportal_geoportal/resources.py +49 -0
- c2cgeoportal_geoportal/scaffolds/advance_create/ci/config.yaml +26 -0
- c2cgeoportal_geoportal/scaffolds/advance_create/cookiecutter.json +18 -0
- c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/.dockerignore +6 -0
- c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/.eslintrc.yaml +19 -0
- c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/.prospector.yaml +30 -0
- c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/Dockerfile +75 -0
- c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/Makefile +6 -0
- c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/alembic.ini +58 -0
- c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/alembic.yaml +19 -0
- c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/development.ini +121 -0
- c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/gunicorn.conf.py +139 -0
- c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/language_mapping +3 -0
- c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/lingva-client.cfg +5 -0
- c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/lingva-server.cfg +6 -0
- c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/production.ini +38 -0
- c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/requirements.txt +2 -0
- c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/setup.py +25 -0
- c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/webpack.api.js +41 -0
- c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/webpack.apps.js +64 -0
- c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/webpack.commons.js +11 -0
- c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/webpack.config.js +22 -0
- c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/{{cookiecutter.package}}_geoportal/__init__.py +42 -0
- c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/{{cookiecutter.package}}_geoportal/authentication.py +10 -0
- c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/{{cookiecutter.package}}_geoportal/dev.py +14 -0
- c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/{{cookiecutter.package}}_geoportal/models.py +8 -0
- c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/{{cookiecutter.package}}_geoportal/multi_organization.py +7 -0
- c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/{{cookiecutter.package}}_geoportal/resources.py +11 -0
- c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/{{cookiecutter.package}}_geoportal/static-ngeo/api/index.js +12 -0
- c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/{{cookiecutter.package}}_geoportal/static-ngeo/js/{{cookiecutter.package}}module.js +25 -0
- c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/{{cookiecutter.package}}_geoportal/subscribers.py +39 -0
- c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/{{cookiecutter.package}}_geoportal/views/__init__.py +0 -0
- c2cgeoportal_geoportal/scaffolds/advance_update/cookiecutter.json +18 -0
- c2cgeoportal_geoportal/scaffolds/advance_update/{{cookiecutter.project}}/geoportal/CONST_Makefile +121 -0
- c2cgeoportal_geoportal/scaffolds/create/cookiecutter.json +18 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/.dockerignore +14 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/.editorconfig +17 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/.github/workflows/main.yaml +73 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/.github/workflows/rebuild.yaml +50 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/.github/workflows/update_l10n.yaml +66 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/.gitignore +16 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/.pre-commit-config.yaml +35 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/.prettierignore +1 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/.prettierrc.yaml +2 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/Dockerfile +75 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/Makefile +70 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/README.rst +29 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/build +179 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/ci/config.yaml +22 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/ci/docker-compose-check +25 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/ci/requirements.txt +2 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/docker-compose-db.yaml +24 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/docker-compose-lib.yaml +511 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/docker-compose-qgis.yaml +21 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/docker-compose.override.sample.yaml +59 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/docker-compose.yaml +121 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/env.default +102 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/env.project +69 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/geoportal/vars.yaml +430 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/geoportal/{{cookiecutter.package}}_geoportal/locale/en/LC_MESSAGES/{{cookiecutter.package}}_geoportal-client.po +6 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/geoportal/{{cookiecutter.package}}_geoportal/static/css/desktop.css +0 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/geoportal/{{cookiecutter.package}}_geoportal/static/css/iframe_api.css +0 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/geoportal/{{cookiecutter.package}}_geoportal/static/css/mobile.css +0 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/geoportal/{{cookiecutter.package}}_geoportal/static/images/banner_left.png +0 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/geoportal/{{cookiecutter.package}}_geoportal/static/images/banner_right.png +0 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/geoportal/{{cookiecutter.package}}_geoportal/static/images/blank.png +0 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/geoportal/{{cookiecutter.package}}_geoportal/static/images/markers/marker-blue.png +0 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/geoportal/{{cookiecutter.package}}_geoportal/static/images/markers/marker-gold.png +0 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/geoportal/{{cookiecutter.package}}_geoportal/static/images/markers/marker-green.png +0 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/geoportal/{{cookiecutter.package}}_geoportal/static/images/markers/marker.png +0 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/geoportal/{{cookiecutter.package}}_geoportal/static/robot.txt.tmpl +3 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/mapserver/data/Readme.txt +69 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/mapserver/data/TM_EUROPE_BORDERS-0.3.sql +70 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/mapserver/demo.map.tmpl +224 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/mapserver/fonts/Arial.ttf +0 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/mapserver/fonts/Arialbd.ttf +0 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/mapserver/fonts/Arialbi.ttf +0 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/mapserver/fonts/Ariali.ttf +0 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/mapserver/fonts/NotoSans-Bold.ttf +0 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/mapserver/fonts/NotoSans-BoldItalic.ttf +0 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/mapserver/fonts/NotoSans-Italic.ttf +0 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/mapserver/fonts/NotoSans-Regular.ttf +0 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/mapserver/fonts/Verdana.ttf +0 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/mapserver/fonts/Verdanab.ttf +0 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/mapserver/fonts/Verdanai.ttf +0 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/mapserver/fonts/Verdanaz.ttf +0 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/mapserver/fonts.conf +12 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/mapserver/mapserver.conf +15 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/mapserver/mapserver.map.tmpl +87 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/mapserver/tinyows.xml.tmpl +36 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/print/print-apps/{{cookiecutter.package}}/A3_Landscape.jrxml +207 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/print/print-apps/{{cookiecutter.package}}/A3_Portrait.jrxml +185 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/print/print-apps/{{cookiecutter.package}}/A4_Landscape.jrxml +200 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/print/print-apps/{{cookiecutter.package}}/A4_Portrait.jrxml +170 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/print/print-apps/{{cookiecutter.package}}/config.yaml.tmpl +175 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/print/print-apps/{{cookiecutter.package}}/legend.jrxml +109 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/print/print-apps/{{cookiecutter.package}}/localisation.properties +4 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/print/print-apps/{{cookiecutter.package}}/localisation_fr.properties +4 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/print/print-apps/{{cookiecutter.package}}/logo.png +0 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/print/print-apps/{{cookiecutter.package}}/north.svg +93 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/print/print-apps/{{cookiecutter.package}}/results.jrxml +25 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/project.yaml +18 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/pyproject.toml +7 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/qgisserver/pg_service.conf.tmpl +15 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/run_alembic.sh +11 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/scripts/db-backup +126 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/scripts/db-restore +132 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/setup.cfg +7 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/spell-ignore-words.txt +5 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/tests/__init__.py +0 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/tests/test_app.py +43 -0
- c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/tilegeneration/config.yaml.tmpl +195 -0
- c2cgeoportal_geoportal/scaffolds/update/cookiecutter.json +18 -0
- c2cgeoportal_geoportal/scaffolds/update/{{cookiecutter.project}}/.upgrade.yaml +67 -0
- c2cgeoportal_geoportal/scaffolds/update/{{cookiecutter.project}}/CONST_CHANGELOG.txt +295 -0
- c2cgeoportal_geoportal/scaffolds/update/{{cookiecutter.project}}/CONST_create_template/tests/test_testapp.py +48 -0
- c2cgeoportal_geoportal/scaffolds/update/{{cookiecutter.project}}/geoportal/CONST_config-schema.yaml +922 -0
- c2cgeoportal_geoportal/scaffolds/update/{{cookiecutter.project}}/geoportal/CONST_vars.yaml +1503 -0
- c2cgeoportal_geoportal/scripts/__init__.py +64 -0
- c2cgeoportal_geoportal/scripts/c2cupgrade.py +879 -0
- c2cgeoportal_geoportal/scripts/create_demo_theme.py +80 -0
- c2cgeoportal_geoportal/scripts/manage_users.py +140 -0
- c2cgeoportal_geoportal/scripts/pcreate.py +314 -0
- c2cgeoportal_geoportal/scripts/theme2fts.py +347 -0
- c2cgeoportal_geoportal/scripts/urllogin.py +81 -0
- c2cgeoportal_geoportal/templates/login.html +90 -0
- c2cgeoportal_geoportal/templates/notlogin.html +62 -0
- c2cgeoportal_geoportal/templates/testi18n.html +12 -0
- c2cgeoportal_geoportal/views/__init__.py +59 -0
- c2cgeoportal_geoportal/views/dev.py +57 -0
- c2cgeoportal_geoportal/views/dynamic.py +208 -0
- c2cgeoportal_geoportal/views/entry.py +174 -0
- c2cgeoportal_geoportal/views/fulltextsearch.py +189 -0
- c2cgeoportal_geoportal/views/geometry_processing.py +75 -0
- c2cgeoportal_geoportal/views/i18n.py +129 -0
- c2cgeoportal_geoportal/views/layers.py +713 -0
- c2cgeoportal_geoportal/views/login.py +679 -0
- c2cgeoportal_geoportal/views/mapserverproxy.py +191 -0
- c2cgeoportal_geoportal/views/memory.py +90 -0
- c2cgeoportal_geoportal/views/ogcproxy.py +120 -0
- c2cgeoportal_geoportal/views/pdfreport.py +245 -0
- c2cgeoportal_geoportal/views/printproxy.py +143 -0
- c2cgeoportal_geoportal/views/profile.py +127 -0
- c2cgeoportal_geoportal/views/proxy.py +259 -0
- c2cgeoportal_geoportal/views/raster.py +193 -0
- c2cgeoportal_geoportal/views/resourceproxy.py +73 -0
- c2cgeoportal_geoportal/views/shortener.py +152 -0
- c2cgeoportal_geoportal/views/theme.py +1322 -0
- c2cgeoportal_geoportal/views/tinyowsproxy.py +189 -0
- c2cgeoportal_geoportal/views/vector_tiles.py +83 -0
- {c2cgeoportal_geoportal-2.3.5.80.dist-info → c2cgeoportal_geoportal-2.9rc1.dist-info}/METADATA +21 -24
- c2cgeoportal_geoportal-2.9rc1.dist-info/RECORD +192 -0
- {c2cgeoportal_geoportal-2.3.5.80.dist-info → c2cgeoportal_geoportal-2.9rc1.dist-info}/WHEEL +1 -1
- c2cgeoportal_geoportal-2.9rc1.dist-info/entry_points.txt +28 -0
- c2cgeoportal_geoportal-2.9rc1.dist-info/top_level.txt +2 -0
- tests/__init__.py +100 -0
- tests/test_cachebuster.py +71 -0
- tests/test_caching.py +275 -0
- tests/test_checker.py +85 -0
- tests/test_decimaljson.py +47 -0
- tests/test_headerstween.py +64 -0
- tests/test_i18n.py +31 -0
- tests/test_init.py +193 -0
- tests/test_locale_negociator.py +69 -0
- tests/test_mapserverproxy_route_predicate.py +64 -0
- tests/test_raster.py +267 -0
- tests/test_wmstparsing.py +238 -0
- tests/xmlstr.py +103 -0
- c2cgeoportal_geoportal-2.3.5.80.dist-info/DESCRIPTION.rst +0 -8
- c2cgeoportal_geoportal-2.3.5.80.dist-info/RECORD +0 -7
- c2cgeoportal_geoportal-2.3.5.80.dist-info/entry_points.txt +0 -22
- c2cgeoportal_geoportal-2.3.5.80.dist-info/metadata.json +0 -1
- c2cgeoportal_geoportal-2.3.5.80.dist-info/top_level.txt +0 -1
@@ -0,0 +1,679 @@
|
|
1
|
+
# Copyright (c) 2011-2024, Camptocamp SA
|
2
|
+
# All rights reserved.
|
3
|
+
|
4
|
+
# Redistribution and use in source and binary forms, with or without
|
5
|
+
# modification, are permitted provided that the following conditions are met:
|
6
|
+
|
7
|
+
# 1. Redistributions of source code must retain the above copyright notice, this
|
8
|
+
# list of conditions and the following disclaimer.
|
9
|
+
# 2. Redistributions in binary form must reproduce the above copyright notice,
|
10
|
+
# this list of conditions and the following disclaimer in the documentation
|
11
|
+
# and/or other materials provided with the distribution.
|
12
|
+
|
13
|
+
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
14
|
+
# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
15
|
+
# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
16
|
+
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
|
17
|
+
# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
18
|
+
# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
19
|
+
# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
|
20
|
+
# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
21
|
+
# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
22
|
+
# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
23
|
+
|
24
|
+
# The views and conclusions contained in the software and documentation are those
|
25
|
+
# of the authors and should not be interpreted as representing official policies,
|
26
|
+
# either expressed or implied, of the FreeBSD Project.
|
27
|
+
|
28
|
+
|
29
|
+
import json
|
30
|
+
import logging
|
31
|
+
import secrets
|
32
|
+
import string
|
33
|
+
import sys
|
34
|
+
import urllib.parse
|
35
|
+
from typing import Any
|
36
|
+
|
37
|
+
import pkce
|
38
|
+
import pyotp
|
39
|
+
import pyramid.request
|
40
|
+
import pyramid.response
|
41
|
+
from pyramid.httpexceptions import (
|
42
|
+
HTTPBadRequest,
|
43
|
+
HTTPForbidden,
|
44
|
+
HTTPFound,
|
45
|
+
HTTPUnauthorized,
|
46
|
+
exception_response,
|
47
|
+
)
|
48
|
+
from pyramid.response import Response
|
49
|
+
from pyramid.security import forget, remember
|
50
|
+
from pyramid.view import forbidden_view_config, view_config
|
51
|
+
from sqlalchemy.orm.exc import NoResultFound # type: ignore[attr-defined]
|
52
|
+
|
53
|
+
from c2cgeoportal_commons import models
|
54
|
+
from c2cgeoportal_commons.lib.email_ import send_email_config
|
55
|
+
from c2cgeoportal_commons.models import static
|
56
|
+
from c2cgeoportal_geoportal import is_allowed_url, is_valid_referrer
|
57
|
+
from c2cgeoportal_geoportal.lib import get_setting, is_intranet, oauth2, oidc
|
58
|
+
from c2cgeoportal_geoportal.lib.common_headers import Cache, set_common_headers
|
59
|
+
from c2cgeoportal_geoportal.lib.functionality import get_functionality
|
60
|
+
|
61
|
+
_LOG = logging.getLogger(__name__)
|
62
|
+
|
63
|
+
|
64
|
+
class Login:
|
65
|
+
"""
|
66
|
+
All the login, logout, oauth2, user information views.
|
67
|
+
|
68
|
+
Also manage the 2fa.
|
69
|
+
"""
|
70
|
+
|
71
|
+
def __init__(self, request: pyramid.request.Request):
|
72
|
+
self.request = request
|
73
|
+
self.settings = request.registry.settings
|
74
|
+
self.lang = request.locale_name
|
75
|
+
|
76
|
+
self.authentication_settings = self.settings.get("authentication", {})
|
77
|
+
|
78
|
+
self.two_factor_auth = self.authentication_settings.get("two_factor", False)
|
79
|
+
self.two_factor_issuer_name = self.authentication_settings.get("two_factor_issuer_name")
|
80
|
+
|
81
|
+
def _functionality(self) -> dict[str, list[str | int | float | bool | list[Any] | dict[str, Any]]]:
|
82
|
+
functionality = {}
|
83
|
+
for func_ in get_setting(self.settings, ("functionalities", "available_in_templates"), []):
|
84
|
+
functionality[func_] = get_functionality(func_, self.request, is_intranet(self.request))
|
85
|
+
return functionality
|
86
|
+
|
87
|
+
def _referrer_log(self) -> None:
|
88
|
+
if not hasattr(self.request, "is_valid_referer"):
|
89
|
+
self.request.is_valid_referer = is_valid_referrer(self.request)
|
90
|
+
if not self.request.is_valid_referer:
|
91
|
+
_LOG.info("Invalid referrer for %s: %s", self.request.path_qs, repr(self.request.referrer))
|
92
|
+
|
93
|
+
@forbidden_view_config(renderer="login.html") # type: ignore
|
94
|
+
def loginform403(self) -> dict[str, Any] | pyramid.response.Response:
|
95
|
+
if self.authentication_settings.get("openid_connect", {}).get("enabled", False):
|
96
|
+
return HTTPFound(
|
97
|
+
location=self.request.route_url(
|
98
|
+
"oidc_login",
|
99
|
+
_query={"came_from": f"{self.request.path}?{urllib.parse.urlencode(self.request.GET)}"},
|
100
|
+
)
|
101
|
+
)
|
102
|
+
|
103
|
+
if self.request.authenticated_userid is not None:
|
104
|
+
return HTTPForbidden()
|
105
|
+
|
106
|
+
set_common_headers(self.request, "login", Cache.PRIVATE_NO)
|
107
|
+
|
108
|
+
return {
|
109
|
+
"lang": self.lang,
|
110
|
+
"login_params": {"came_from": f"{self.request.path}?{urllib.parse.urlencode(self.request.GET)}"},
|
111
|
+
"two_fa": self.two_factor_auth,
|
112
|
+
}
|
113
|
+
|
114
|
+
@view_config(route_name="loginform", renderer="login.html") # type: ignore
|
115
|
+
def loginform(self) -> dict[str, Any]:
|
116
|
+
if self.authentication_settings.get("openid_connect", {}).get("enabled", False):
|
117
|
+
raise HTTPBadRequest("View disabled by OpenID Connect")
|
118
|
+
|
119
|
+
set_common_headers(self.request, "login", Cache.PUBLIC)
|
120
|
+
|
121
|
+
return {
|
122
|
+
"lang": self.lang,
|
123
|
+
"login_params": {"came_from": self.request.params.get("came_from") or "/"},
|
124
|
+
"two_fa": self.two_factor_auth,
|
125
|
+
}
|
126
|
+
|
127
|
+
@staticmethod
|
128
|
+
def _validate_2fa_totp(user: static.User, otp: str) -> bool:
|
129
|
+
if pyotp.TOTP(user.tech_data.get("2fa_totp_secret", "")).verify(otp):
|
130
|
+
return True
|
131
|
+
return False
|
132
|
+
|
133
|
+
@view_config(route_name="login") # type: ignore
|
134
|
+
def login(self) -> pyramid.response.Response:
|
135
|
+
assert models.DBSession is not None
|
136
|
+
if self.authentication_settings.get("openid_connect", {}).get("enabled", False):
|
137
|
+
raise HTTPBadRequest("View disabled by OpenID Connect")
|
138
|
+
|
139
|
+
self._referrer_log()
|
140
|
+
|
141
|
+
login = self.request.POST.get("login")
|
142
|
+
password = self.request.POST.get("password")
|
143
|
+
if login is None or password is None:
|
144
|
+
raise HTTPBadRequest("'login' and 'password' should be available in request params.")
|
145
|
+
username = self.request.registry.validate_user(self.request, login, password)
|
146
|
+
user: static.User | None
|
147
|
+
if username is not None:
|
148
|
+
user = models.DBSession.query(static.User).filter(static.User.username == username).one()
|
149
|
+
if self.two_factor_auth:
|
150
|
+
if "2fa_totp_secret" not in user.tech_data:
|
151
|
+
user.is_password_changed = False
|
152
|
+
if not user.is_password_changed:
|
153
|
+
user.tech_data["2fa_totp_secret"] = pyotp.random_base32()
|
154
|
+
if self.request.GET.get("type") == "oauth2":
|
155
|
+
raise HTTPFound(location=self.request.route_url("notlogin"))
|
156
|
+
return set_common_headers(
|
157
|
+
self.request,
|
158
|
+
"login",
|
159
|
+
Cache.PRIVATE_NO,
|
160
|
+
response=Response(
|
161
|
+
json.dumps(
|
162
|
+
{
|
163
|
+
"username": user.username,
|
164
|
+
"is_password_changed": False,
|
165
|
+
"two_factor_enable": self.two_factor_auth,
|
166
|
+
"two_factor_totp_secret": user.tech_data["2fa_totp_secret"],
|
167
|
+
"otp_uri": pyotp.TOTP(user.tech_data["2fa_totp_secret"]).provisioning_uri(
|
168
|
+
user.email, issuer_name=self.two_factor_issuer_name
|
169
|
+
),
|
170
|
+
}
|
171
|
+
),
|
172
|
+
headers=(("Content-Type", "text/json"),),
|
173
|
+
),
|
174
|
+
)
|
175
|
+
otp = self.request.POST.get("otp")
|
176
|
+
if otp is None:
|
177
|
+
raise HTTPBadRequest("The second factor is missing.")
|
178
|
+
if not self._validate_2fa_totp(user, otp):
|
179
|
+
_LOG.info("The second factor is wrong for user '%s'.", user.username)
|
180
|
+
raise HTTPUnauthorized("See server logs for details")
|
181
|
+
user.update_last_login()
|
182
|
+
user.tech_data["consecutive_failed"] = "0"
|
183
|
+
|
184
|
+
if not user.is_password_changed:
|
185
|
+
if self.request.GET.get("type") == "oauth2":
|
186
|
+
raise HTTPFound(location=self.request.route_url("notlogin"))
|
187
|
+
return set_common_headers(
|
188
|
+
self.request,
|
189
|
+
"login",
|
190
|
+
Cache.PRIVATE_NO,
|
191
|
+
response=Response(
|
192
|
+
json.dumps(
|
193
|
+
{
|
194
|
+
"username": user.username,
|
195
|
+
"is_password_changed": False,
|
196
|
+
"two_factor_enable": self.two_factor_auth,
|
197
|
+
}
|
198
|
+
),
|
199
|
+
headers=(("Content-Type", "text/json"),),
|
200
|
+
),
|
201
|
+
)
|
202
|
+
|
203
|
+
_LOG.info("User '%s' logged in.", username)
|
204
|
+
if self.request.GET.get("type") == "oauth2":
|
205
|
+
self._oauth2_login(user)
|
206
|
+
|
207
|
+
headers = remember(self.request, username)
|
208
|
+
|
209
|
+
came_from = self.request.params.get("came_from")
|
210
|
+
if came_from:
|
211
|
+
if not came_from.startswith("/"):
|
212
|
+
allowed_hosts = self.request.registry.settings.get("authorized_referers", [])
|
213
|
+
came_from_hostname, ok = is_allowed_url(self.request, came_from, allowed_hosts)
|
214
|
+
if not ok:
|
215
|
+
message = (
|
216
|
+
f"Invalid hostname '{came_from_hostname}' in 'came_from' parameter, "
|
217
|
+
f"is not the current host '{self.request.host}' "
|
218
|
+
f"or part of allowed hosts: {', '.join(allowed_hosts)}"
|
219
|
+
)
|
220
|
+
_LOG.debug(message)
|
221
|
+
return HTTPBadRequest(message)
|
222
|
+
return HTTPFound(location=came_from, headers=headers)
|
223
|
+
|
224
|
+
headers.append(("Content-Type", "text/json"))
|
225
|
+
return set_common_headers(
|
226
|
+
self.request,
|
227
|
+
"login",
|
228
|
+
Cache.PRIVATE_NO,
|
229
|
+
response=Response(json.dumps(self._user(self.request.get_user(username))), headers=headers),
|
230
|
+
)
|
231
|
+
user = models.DBSession.query(static.User).filter(static.User.username == login).one_or_none()
|
232
|
+
if user and not user.deactivated:
|
233
|
+
if "consecutive_failed" not in user.tech_data:
|
234
|
+
user.tech_data["consecutive_failed"] = "0"
|
235
|
+
user.tech_data["consecutive_failed"] = str(int(user.tech_data["consecutive_failed"]) + 1)
|
236
|
+
if int(user.tech_data["consecutive_failed"]) >= self.request.registry.settings.get(
|
237
|
+
"authentication", {}
|
238
|
+
).get("max_consecutive_failures", sys.maxsize):
|
239
|
+
user.deactivated = True
|
240
|
+
user.tech_data["consecutive_failed"] = "0"
|
241
|
+
|
242
|
+
if hasattr(self.request, "tm"):
|
243
|
+
self.request.tm.commit()
|
244
|
+
raise HTTPUnauthorized("See server logs for details")
|
245
|
+
|
246
|
+
def _oauth2_login(self, user: static.User) -> pyramid.response.Response:
|
247
|
+
self.request.user_ = user
|
248
|
+
_LOG.debug(
|
249
|
+
"Call OAuth create_authorization_response with:\nurl: %s\nmethod: %s\nbody:\n%s",
|
250
|
+
self.request.current_route_url(_query=self.request.GET),
|
251
|
+
self.request.method,
|
252
|
+
self.request.body,
|
253
|
+
)
|
254
|
+
headers, body, status = oauth2.get_oauth_client(
|
255
|
+
self.request.registry.settings
|
256
|
+
).create_authorization_response(
|
257
|
+
self.request.current_route_url(_query=self.request.GET),
|
258
|
+
self.request.method,
|
259
|
+
self.request.body,
|
260
|
+
self.request.headers,
|
261
|
+
)
|
262
|
+
if hasattr(self.request, "tm"):
|
263
|
+
self.request.tm.commit()
|
264
|
+
_LOG.debug("OAuth create_authorization_response return\nstatus: %s\nbody:\n%s", status, body)
|
265
|
+
|
266
|
+
location = headers.get("Location")
|
267
|
+
location_hostname = urllib.parse.urlparse(location).hostname
|
268
|
+
allowed_hosts = self.request.registry.settings.get("authentication", {}).get("allowed_hosts", [])
|
269
|
+
location_hostname, ok = is_allowed_url(self.request, location, allowed_hosts)
|
270
|
+
if not ok:
|
271
|
+
message = (
|
272
|
+
f"Invalid location hostname '{location_hostname}', "
|
273
|
+
f"is not the current host '{self.request.host}' "
|
274
|
+
f"or part of allowed_hosts: {', '.join(allowed_hosts)}"
|
275
|
+
)
|
276
|
+
_LOG.debug(message)
|
277
|
+
return HTTPBadRequest(message)
|
278
|
+
|
279
|
+
if status == 302:
|
280
|
+
raise HTTPFound(location=location)
|
281
|
+
if status != 200:
|
282
|
+
if body:
|
283
|
+
raise exception_response(status, details=body)
|
284
|
+
raise exception_response(status)
|
285
|
+
return set_common_headers(
|
286
|
+
self.request,
|
287
|
+
"login",
|
288
|
+
Cache.PRIVATE_NO,
|
289
|
+
response=Response(body, headers=headers.items()),
|
290
|
+
)
|
291
|
+
|
292
|
+
@view_config(route_name="logout") # type: ignore
|
293
|
+
def logout(self) -> pyramid.response.Response:
|
294
|
+
if self.authentication_settings.get("openid_connect", {}).get("enabled", False):
|
295
|
+
client = oidc.get_oidc_client(self.request, self.request.host)
|
296
|
+
if hasattr(client, "revoke_token"):
|
297
|
+
user_info = json.loads(self.request.authenticated_userid)
|
298
|
+
client.revoke_token(user_info["access_token"])
|
299
|
+
if user_info.get("refresh_token") is not None:
|
300
|
+
client.revoke_token(user_info["refresh_token"])
|
301
|
+
|
302
|
+
headers = forget(self.request)
|
303
|
+
|
304
|
+
if not self.request.user:
|
305
|
+
_LOG.info("Logout on non login user.")
|
306
|
+
raise HTTPUnauthorized("See server logs for details")
|
307
|
+
|
308
|
+
_LOG.info("User '%s' (%s) logging out.", self.request.user.username, self.request.user.id)
|
309
|
+
|
310
|
+
headers.append(("Content-Type", "text/json"))
|
311
|
+
return set_common_headers(
|
312
|
+
self.request, "login", Cache.PRIVATE_NO, response=Response("true", headers=headers)
|
313
|
+
)
|
314
|
+
|
315
|
+
def _user(self, user: static.User | None = None) -> dict[str, Any]:
|
316
|
+
result = {
|
317
|
+
"functionalities": self._functionality(),
|
318
|
+
"is_intranet": is_intranet(self.request),
|
319
|
+
"login_type": (
|
320
|
+
"oidc"
|
321
|
+
if self.authentication_settings.get("openid_connect", {}).get("enabled", False)
|
322
|
+
else "local"
|
323
|
+
),
|
324
|
+
}
|
325
|
+
if not self.authentication_settings.get("openid_connect", {}).get("enabled", False):
|
326
|
+
result["two_factor_enable"] = self.two_factor_auth
|
327
|
+
|
328
|
+
user = self.request.user if user is None else user
|
329
|
+
if user is not None:
|
330
|
+
result.update(
|
331
|
+
{
|
332
|
+
"username": user.display_name,
|
333
|
+
"email": user.email,
|
334
|
+
"roles": [{"name": r.name, "id": r.id} for r in user.roles],
|
335
|
+
}
|
336
|
+
)
|
337
|
+
return result
|
338
|
+
|
339
|
+
@view_config(route_name="loginuser", renderer="json") # type: ignore
|
340
|
+
def loginuser(self) -> dict[str, Any]:
|
341
|
+
_LOG.info("Client IP address: %s", self.request.client_addr)
|
342
|
+
set_common_headers(self.request, "login", Cache.PRIVATE_NO)
|
343
|
+
return self._user()
|
344
|
+
|
345
|
+
@view_config(route_name="change_password", renderer="json") # type: ignore
|
346
|
+
def change_password(self) -> pyramid.response.Response:
|
347
|
+
assert models.DBSession is not None
|
348
|
+
|
349
|
+
if self.authentication_settings.get("openid_connect", {}).get("enabled", False):
|
350
|
+
raise HTTPBadRequest("View disabled by OpenID Connect")
|
351
|
+
|
352
|
+
set_common_headers(self.request, "login", Cache.PRIVATE_NO)
|
353
|
+
|
354
|
+
login = self.request.POST.get("login")
|
355
|
+
old_password = self.request.POST.get("oldPassword")
|
356
|
+
new_password = self.request.POST.get("newPassword")
|
357
|
+
new_password_confirm = self.request.POST.get("confirmNewPassword")
|
358
|
+
otp = self.request.POST.get("otp")
|
359
|
+
if new_password is None or new_password_confirm is None or old_password is None:
|
360
|
+
raise HTTPBadRequest(
|
361
|
+
"'oldPassword', 'newPassword' and 'confirmNewPassword' should be available in "
|
362
|
+
"request params."
|
363
|
+
)
|
364
|
+
if self.two_factor_auth and otp is None:
|
365
|
+
raise HTTPBadRequest("The second factor is missing.")
|
366
|
+
if login is None and self.request.user is None:
|
367
|
+
raise HTTPBadRequest("You should be logged in or 'login' should be available in request params.")
|
368
|
+
if new_password != new_password_confirm:
|
369
|
+
raise HTTPBadRequest("The new password and the new password confirmation do not match")
|
370
|
+
|
371
|
+
if login is not None:
|
372
|
+
user = models.DBSession.query(static.User).filter_by(username=login).one_or_none()
|
373
|
+
if user is None:
|
374
|
+
_LOG.info("The login '%s' does not exist.", login)
|
375
|
+
raise HTTPUnauthorized("See server logs for details")
|
376
|
+
|
377
|
+
if self.two_factor_auth:
|
378
|
+
if not self._validate_2fa_totp(user, otp):
|
379
|
+
_LOG.info("The second factor is wrong for user '%s'.", login)
|
380
|
+
raise HTTPUnauthorized("See server logs for details")
|
381
|
+
else:
|
382
|
+
user = self.request.user
|
383
|
+
|
384
|
+
assert user is not None
|
385
|
+
if self.request.registry.validate_user(self.request, user.username, old_password) is None:
|
386
|
+
_LOG.info("The old password is wrong for user '%s'.", user.username)
|
387
|
+
raise HTTPUnauthorized("See server logs for details")
|
388
|
+
|
389
|
+
user.password = new_password
|
390
|
+
user.is_password_changed = True
|
391
|
+
models.DBSession.flush()
|
392
|
+
_LOG.info("Password changed for user '%s'", user.username)
|
393
|
+
|
394
|
+
headers = remember(self.request, user.username)
|
395
|
+
headers.append(("Content-Type", "text/json"))
|
396
|
+
return set_common_headers(
|
397
|
+
self.request,
|
398
|
+
"login",
|
399
|
+
Cache.PRIVATE_NO,
|
400
|
+
response=Response(json.dumps(self._user(user)), headers=headers),
|
401
|
+
)
|
402
|
+
|
403
|
+
@staticmethod
|
404
|
+
def generate_password() -> str:
|
405
|
+
allchars = "".join(
|
406
|
+
[
|
407
|
+
string.ascii_letters * 2,
|
408
|
+
string.digits * 2,
|
409
|
+
string.punctuation, # One time to have less punctuation char
|
410
|
+
]
|
411
|
+
)
|
412
|
+
return "".join(secrets.choice(allchars) for i in range(8))
|
413
|
+
|
414
|
+
def _loginresetpassword(
|
415
|
+
self,
|
416
|
+
) -> tuple[static.User | None, str | None, str | None, str | None]:
|
417
|
+
assert models.DBSession is not None
|
418
|
+
|
419
|
+
username = self.request.POST.get("login")
|
420
|
+
if username is None:
|
421
|
+
raise HTTPBadRequest("'login' should be available in request params.")
|
422
|
+
try:
|
423
|
+
user = models.DBSession.query(static.User).filter(static.User.username == username).one()
|
424
|
+
except NoResultFound:
|
425
|
+
return None, None, None, f"The login '{username}' does not exist."
|
426
|
+
|
427
|
+
if user.email is None or user.email == "":
|
428
|
+
return None, None, None, f"The user '{user.username}' has no registered email address."
|
429
|
+
|
430
|
+
password = self.generate_password()
|
431
|
+
user.set_temp_password(password)
|
432
|
+
|
433
|
+
return user, username, password, None
|
434
|
+
|
435
|
+
@view_config(route_name="loginresetpassword", renderer="json") # type: ignore
|
436
|
+
def loginresetpassword(self) -> dict[str, Any]:
|
437
|
+
if self.authentication_settings.get("openid_connect", {}).get("enabled", False):
|
438
|
+
raise HTTPBadRequest("View disabled by OpenID Connect")
|
439
|
+
|
440
|
+
set_common_headers(self.request, "login", Cache.PRIVATE_NO)
|
441
|
+
|
442
|
+
user, username, password, error = self._loginresetpassword()
|
443
|
+
if error is not None:
|
444
|
+
_LOG.info(error)
|
445
|
+
return {"success": True}
|
446
|
+
|
447
|
+
if user is None:
|
448
|
+
_LOG.info("The user is not found without any error.")
|
449
|
+
return {"success": True}
|
450
|
+
|
451
|
+
if user.deactivated:
|
452
|
+
_LOG.info("The user '%s' is deactivated", username)
|
453
|
+
return {"success": True}
|
454
|
+
|
455
|
+
send_email_config(
|
456
|
+
self.request.registry.settings,
|
457
|
+
"reset_password",
|
458
|
+
user.email,
|
459
|
+
user=username,
|
460
|
+
password=password,
|
461
|
+
application_url=self.request.route_url("base"),
|
462
|
+
current_url=self.request.current_route_url(),
|
463
|
+
)
|
464
|
+
|
465
|
+
return {"success": True}
|
466
|
+
|
467
|
+
@view_config(route_name="oauth2introspect") # type: ignore
|
468
|
+
def oauth2introspect(self) -> pyramid.response.Response:
|
469
|
+
if self.authentication_settings.get("openid_connect", {}).get("enabled", False):
|
470
|
+
raise HTTPBadRequest("View disabled by OpenID Connect")
|
471
|
+
|
472
|
+
_LOG.debug(
|
473
|
+
"Call OAuth create_introspect_response with:\nurl: %s\nmethod: %s\nbody:\n%s",
|
474
|
+
self.request.current_route_url(_query=self.request.GET),
|
475
|
+
self.request.method,
|
476
|
+
self.request.body,
|
477
|
+
)
|
478
|
+
headers, body, status = oauth2.get_oauth_client(
|
479
|
+
self.request.registry.settings
|
480
|
+
).create_introspect_response(
|
481
|
+
self.request.current_route_url(_query=self.request.GET),
|
482
|
+
self.request.method,
|
483
|
+
self.request.body,
|
484
|
+
self.request.headers,
|
485
|
+
)
|
486
|
+
_LOG.debug("OAuth create_introspect_response return status: %s", status)
|
487
|
+
|
488
|
+
# All requests to /token will return a json response, no redirection.
|
489
|
+
if status != 200:
|
490
|
+
if body:
|
491
|
+
raise exception_response(status, detail=body)
|
492
|
+
raise exception_response(status)
|
493
|
+
return set_common_headers(
|
494
|
+
self.request,
|
495
|
+
"login",
|
496
|
+
Cache.PRIVATE_NO,
|
497
|
+
response=Response(body, headers=headers.items()),
|
498
|
+
)
|
499
|
+
|
500
|
+
@view_config(route_name="oauth2token") # type: ignore
|
501
|
+
def oauth2token(self) -> pyramid.response.Response:
|
502
|
+
if self.authentication_settings.get("openid_connect", {}).get("enabled", False):
|
503
|
+
raise HTTPBadRequest("View disabled by OpenID Connect")
|
504
|
+
|
505
|
+
_LOG.debug(
|
506
|
+
"Call OAuth create_token_response with:\nurl: %s\nmethod: %s\nbody:\n%s",
|
507
|
+
self.request.current_route_url(_query=self.request.GET),
|
508
|
+
self.request.method,
|
509
|
+
self.request.body,
|
510
|
+
)
|
511
|
+
headers, body, status = oauth2.get_oauth_client(self.request.registry.settings).create_token_response(
|
512
|
+
self.request.current_route_url(_query=self.request.GET),
|
513
|
+
self.request.method,
|
514
|
+
self.request.body,
|
515
|
+
self.request.headers,
|
516
|
+
{},
|
517
|
+
)
|
518
|
+
_LOG.debug("OAuth create_token_response return status: %s", status)
|
519
|
+
|
520
|
+
# All requests to /token will return a json response, no redirection.
|
521
|
+
if status != 200:
|
522
|
+
if body:
|
523
|
+
raise exception_response(status, detail=body)
|
524
|
+
raise exception_response(status)
|
525
|
+
return set_common_headers(
|
526
|
+
self.request,
|
527
|
+
"login",
|
528
|
+
Cache.PRIVATE_NO,
|
529
|
+
response=Response(body, headers=headers.items()),
|
530
|
+
)
|
531
|
+
|
532
|
+
@view_config(route_name="oauth2revoke_token") # type: ignore
|
533
|
+
def oauth2revoke_token(self) -> pyramid.response.Response:
|
534
|
+
if self.authentication_settings.get("openid_connect", {}).get("enabled", False):
|
535
|
+
raise HTTPBadRequest("View disabled by OpenID Connect")
|
536
|
+
|
537
|
+
_LOG.debug(
|
538
|
+
"Call OAuth create_revocation_response with:\nurl: %s\nmethod: %s\nbody:\n%s",
|
539
|
+
self.request.create_revocation_response(_query=self.request.GET),
|
540
|
+
self.request.method,
|
541
|
+
self.request.body,
|
542
|
+
)
|
543
|
+
headers, body, status = oauth2.get_oauth_client(
|
544
|
+
self.request.registry.settings
|
545
|
+
).create_authorize_response(
|
546
|
+
self.request.current_route_url(_query=self.request.GET),
|
547
|
+
self.request.method,
|
548
|
+
self.request.body,
|
549
|
+
self.request.headers,
|
550
|
+
)
|
551
|
+
if status != 200:
|
552
|
+
if body:
|
553
|
+
raise exception_response(status, detail=body)
|
554
|
+
raise exception_response(status)
|
555
|
+
return set_common_headers(
|
556
|
+
self.request,
|
557
|
+
"login",
|
558
|
+
Cache.PRIVATE_NO,
|
559
|
+
response=Response(body, headers=headers.items()),
|
560
|
+
)
|
561
|
+
|
562
|
+
@view_config(route_name="oauth2loginform", renderer="login.html") # type: ignore
|
563
|
+
def oauth2loginform(self) -> dict[str, Any]:
|
564
|
+
if self.authentication_settings.get("openid_connect", {}).get("enabled", False):
|
565
|
+
raise HTTPBadRequest("View disabled by OpenID Connect")
|
566
|
+
|
567
|
+
set_common_headers(self.request, "login", Cache.PUBLIC)
|
568
|
+
|
569
|
+
if self.request.user:
|
570
|
+
self._oauth2_login(self.request.user)
|
571
|
+
|
572
|
+
login_param = {"type": "oauth2"}
|
573
|
+
login_param.update(self.request.params)
|
574
|
+
return {
|
575
|
+
"lang": self.lang,
|
576
|
+
"login_params": login_param,
|
577
|
+
"two_fa": self.two_factor_auth,
|
578
|
+
}
|
579
|
+
|
580
|
+
@view_config(route_name="notlogin", renderer="notlogin.html") # type: ignore
|
581
|
+
def notlogin(self) -> dict[str, Any]:
|
582
|
+
set_common_headers(self.request, "login", Cache.PUBLIC)
|
583
|
+
|
584
|
+
return {"lang": self.lang}
|
585
|
+
|
586
|
+
@view_config(route_name="oidc_login") # type: ignore
|
587
|
+
def oidc_login(self) -> pyramid.response.Response:
|
588
|
+
client = oidc.get_oidc_client(self.request, self.request.host)
|
589
|
+
if "came_from" in self.request.params:
|
590
|
+
self.request.response.set_cookie(
|
591
|
+
"came_from",
|
592
|
+
self.request.params["came_from"],
|
593
|
+
httponly=True,
|
594
|
+
samesite="Lax",
|
595
|
+
secure=True,
|
596
|
+
domain=self.request.domain,
|
597
|
+
max_age=600,
|
598
|
+
)
|
599
|
+
|
600
|
+
code_verifier, code_challenge = pkce.generate_pkce_pair()
|
601
|
+
self.request.response.set_cookie(
|
602
|
+
"code_verifier",
|
603
|
+
code_verifier,
|
604
|
+
httponly=True,
|
605
|
+
samesite="Lax",
|
606
|
+
secure=True,
|
607
|
+
domain=self.request.domain,
|
608
|
+
max_age=600,
|
609
|
+
)
|
610
|
+
self.request.response.set_cookie(
|
611
|
+
"code_challenge",
|
612
|
+
code_challenge,
|
613
|
+
httponly=True,
|
614
|
+
samesite="Lax",
|
615
|
+
secure=True,
|
616
|
+
domain=self.request.domain,
|
617
|
+
max_age=600,
|
618
|
+
)
|
619
|
+
|
620
|
+
try:
|
621
|
+
return HTTPFound(
|
622
|
+
location=client.authorization_code_flow.start_authentication(
|
623
|
+
code_challenge=code_challenge,
|
624
|
+
code_challenge_method="S256",
|
625
|
+
),
|
626
|
+
headers=self.request.response.headers,
|
627
|
+
)
|
628
|
+
finally:
|
629
|
+
client.authorization_code_flow.code_challenge = ""
|
630
|
+
|
631
|
+
@view_config(route_name="oidc_callback") # type: ignore
|
632
|
+
def oidc_callback(self) -> pyramid.response.Response:
|
633
|
+
client = oidc.get_oidc_client(self.request, self.request.host)
|
634
|
+
assert models.DBSession is not None
|
635
|
+
|
636
|
+
token_response = client.authorization_code_flow.handle_authentication_result(
|
637
|
+
"?" + urllib.parse.urlencode(self.request.params),
|
638
|
+
code_verifier=self.request.cookies["code_verifier"],
|
639
|
+
code_challenge=self.request.cookies["code_challenge"],
|
640
|
+
code_challenge_method="S256",
|
641
|
+
)
|
642
|
+
self.request.response.delete_cookie("code_verifier")
|
643
|
+
self.request.response.delete_cookie("code_challenge")
|
644
|
+
|
645
|
+
remember_object = oidc.OidcRemember(self.request).remember(token_response, self.request.host)
|
646
|
+
|
647
|
+
user: static.User | oidc.DynamicUser | None = self.request.get_user_from_remember(
|
648
|
+
remember_object, update_create_user=True
|
649
|
+
)
|
650
|
+
if user is not None:
|
651
|
+
self.request.user_ = user
|
652
|
+
|
653
|
+
if "came_from" in self.request.cookies:
|
654
|
+
came_from = self.request.cookies["came_from"]
|
655
|
+
self.request.response.delete_cookie("came_from")
|
656
|
+
|
657
|
+
return HTTPFound(location=came_from, headers=self.request.response.headers)
|
658
|
+
|
659
|
+
if user is not None:
|
660
|
+
return set_common_headers(
|
661
|
+
self.request,
|
662
|
+
"login",
|
663
|
+
Cache.PRIVATE_NO,
|
664
|
+
response=Response(
|
665
|
+
# TODO respect the user interface...
|
666
|
+
json.dumps(
|
667
|
+
{
|
668
|
+
"username": user.display_name,
|
669
|
+
"email": user.email,
|
670
|
+
"is_intranet": is_intranet(self.request),
|
671
|
+
"functionalities": self._functionality(),
|
672
|
+
"roles": [{"name": r.name, "id": r.id} for r in user.roles],
|
673
|
+
}
|
674
|
+
),
|
675
|
+
headers=(("Content-Type", "text/json"),),
|
676
|
+
),
|
677
|
+
)
|
678
|
+
else:
|
679
|
+
return HTTPUnauthorized("See server logs for details")
|