c2cgeoportal-geoportal 2.3.5.79__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.
Files changed (197) hide show
  1. c2cgeoportal_geoportal/__init__.py +960 -0
  2. c2cgeoportal_geoportal/lib/__init__.py +256 -0
  3. c2cgeoportal_geoportal/lib/authentication.py +250 -0
  4. c2cgeoportal_geoportal/lib/bashcolor.py +46 -0
  5. c2cgeoportal_geoportal/lib/cacheversion.py +75 -0
  6. c2cgeoportal_geoportal/lib/caching.py +176 -0
  7. c2cgeoportal_geoportal/lib/check_collector.py +80 -0
  8. c2cgeoportal_geoportal/lib/checker.py +295 -0
  9. c2cgeoportal_geoportal/lib/common_headers.py +170 -0
  10. c2cgeoportal_geoportal/lib/dbreflection.py +266 -0
  11. c2cgeoportal_geoportal/lib/filter_capabilities.py +360 -0
  12. c2cgeoportal_geoportal/lib/fulltextsearch.py +50 -0
  13. c2cgeoportal_geoportal/lib/functionality.py +166 -0
  14. c2cgeoportal_geoportal/lib/headers.py +62 -0
  15. c2cgeoportal_geoportal/lib/i18n.py +38 -0
  16. c2cgeoportal_geoportal/lib/layers.py +132 -0
  17. c2cgeoportal_geoportal/lib/lingva_extractor.py +937 -0
  18. c2cgeoportal_geoportal/lib/loader.py +57 -0
  19. c2cgeoportal_geoportal/lib/metrics.py +117 -0
  20. c2cgeoportal_geoportal/lib/oauth2.py +1186 -0
  21. c2cgeoportal_geoportal/lib/oidc.py +302 -0
  22. c2cgeoportal_geoportal/lib/wmstparsing.py +353 -0
  23. c2cgeoportal_geoportal/lib/xsd.py +166 -0
  24. c2cgeoportal_geoportal/py.typed +0 -0
  25. c2cgeoportal_geoportal/resources.py +49 -0
  26. c2cgeoportal_geoportal/scaffolds/advance_create/ci/config.yaml +26 -0
  27. c2cgeoportal_geoportal/scaffolds/advance_create/cookiecutter.json +18 -0
  28. c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/.dockerignore +6 -0
  29. c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/.eslintrc.yaml +19 -0
  30. c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/.prospector.yaml +30 -0
  31. c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/Dockerfile +75 -0
  32. c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/Makefile +6 -0
  33. c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/alembic.ini +58 -0
  34. c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/alembic.yaml +19 -0
  35. c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/development.ini +121 -0
  36. c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/gunicorn.conf.py +139 -0
  37. c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/language_mapping +3 -0
  38. c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/lingva-client.cfg +5 -0
  39. c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/lingva-server.cfg +6 -0
  40. c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/production.ini +38 -0
  41. c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/requirements.txt +2 -0
  42. c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/setup.py +25 -0
  43. c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/webpack.api.js +41 -0
  44. c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/webpack.apps.js +64 -0
  45. c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/webpack.commons.js +11 -0
  46. c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/webpack.config.js +22 -0
  47. c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/{{cookiecutter.package}}_geoportal/__init__.py +42 -0
  48. c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/{{cookiecutter.package}}_geoportal/authentication.py +10 -0
  49. c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/{{cookiecutter.package}}_geoportal/dev.py +14 -0
  50. c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/{{cookiecutter.package}}_geoportal/models.py +8 -0
  51. c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/{{cookiecutter.package}}_geoportal/multi_organization.py +7 -0
  52. c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/{{cookiecutter.package}}_geoportal/resources.py +11 -0
  53. c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/{{cookiecutter.package}}_geoportal/static-ngeo/api/index.js +12 -0
  54. c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/{{cookiecutter.package}}_geoportal/static-ngeo/js/{{cookiecutter.package}}module.js +25 -0
  55. c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/{{cookiecutter.package}}_geoportal/subscribers.py +39 -0
  56. c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/{{cookiecutter.package}}_geoportal/views/__init__.py +0 -0
  57. c2cgeoportal_geoportal/scaffolds/advance_update/cookiecutter.json +18 -0
  58. c2cgeoportal_geoportal/scaffolds/advance_update/{{cookiecutter.project}}/geoportal/CONST_Makefile +121 -0
  59. c2cgeoportal_geoportal/scaffolds/create/cookiecutter.json +18 -0
  60. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/.dockerignore +14 -0
  61. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/.editorconfig +17 -0
  62. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/.github/workflows/main.yaml +73 -0
  63. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/.github/workflows/rebuild.yaml +50 -0
  64. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/.github/workflows/update_l10n.yaml +66 -0
  65. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/.gitignore +16 -0
  66. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/.pre-commit-config.yaml +35 -0
  67. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/.prettierignore +1 -0
  68. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/.prettierrc.yaml +2 -0
  69. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/Dockerfile +75 -0
  70. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/Makefile +70 -0
  71. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/README.rst +29 -0
  72. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/build +179 -0
  73. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/ci/config.yaml +22 -0
  74. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/ci/docker-compose-check +25 -0
  75. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/ci/requirements.txt +2 -0
  76. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/docker-compose-db.yaml +24 -0
  77. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/docker-compose-lib.yaml +511 -0
  78. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/docker-compose-qgis.yaml +21 -0
  79. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/docker-compose.override.sample.yaml +59 -0
  80. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/docker-compose.yaml +121 -0
  81. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/env.default +102 -0
  82. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/env.project +69 -0
  83. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/geoportal/vars.yaml +430 -0
  84. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/geoportal/{{cookiecutter.package}}_geoportal/locale/en/LC_MESSAGES/{{cookiecutter.package}}_geoportal-client.po +6 -0
  85. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/geoportal/{{cookiecutter.package}}_geoportal/static/css/desktop.css +0 -0
  86. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/geoportal/{{cookiecutter.package}}_geoportal/static/css/iframe_api.css +0 -0
  87. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/geoportal/{{cookiecutter.package}}_geoportal/static/css/mobile.css +0 -0
  88. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/geoportal/{{cookiecutter.package}}_geoportal/static/images/banner_left.png +0 -0
  89. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/geoportal/{{cookiecutter.package}}_geoportal/static/images/banner_right.png +0 -0
  90. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/geoportal/{{cookiecutter.package}}_geoportal/static/images/blank.png +0 -0
  91. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/geoportal/{{cookiecutter.package}}_geoportal/static/images/markers/marker-blue.png +0 -0
  92. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/geoportal/{{cookiecutter.package}}_geoportal/static/images/markers/marker-gold.png +0 -0
  93. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/geoportal/{{cookiecutter.package}}_geoportal/static/images/markers/marker-green.png +0 -0
  94. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/geoportal/{{cookiecutter.package}}_geoportal/static/images/markers/marker.png +0 -0
  95. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/geoportal/{{cookiecutter.package}}_geoportal/static/robot.txt.tmpl +3 -0
  96. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/mapserver/data/Readme.txt +69 -0
  97. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/mapserver/data/TM_EUROPE_BORDERS-0.3.sql +70 -0
  98. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/mapserver/demo.map.tmpl +224 -0
  99. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/mapserver/fonts/Arial.ttf +0 -0
  100. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/mapserver/fonts/Arialbd.ttf +0 -0
  101. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/mapserver/fonts/Arialbi.ttf +0 -0
  102. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/mapserver/fonts/Ariali.ttf +0 -0
  103. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/mapserver/fonts/NotoSans-Bold.ttf +0 -0
  104. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/mapserver/fonts/NotoSans-BoldItalic.ttf +0 -0
  105. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/mapserver/fonts/NotoSans-Italic.ttf +0 -0
  106. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/mapserver/fonts/NotoSans-Regular.ttf +0 -0
  107. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/mapserver/fonts/Verdana.ttf +0 -0
  108. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/mapserver/fonts/Verdanab.ttf +0 -0
  109. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/mapserver/fonts/Verdanai.ttf +0 -0
  110. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/mapserver/fonts/Verdanaz.ttf +0 -0
  111. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/mapserver/fonts.conf +12 -0
  112. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/mapserver/mapserver.conf +15 -0
  113. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/mapserver/mapserver.map.tmpl +87 -0
  114. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/mapserver/tinyows.xml.tmpl +36 -0
  115. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/print/print-apps/{{cookiecutter.package}}/A3_Landscape.jrxml +207 -0
  116. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/print/print-apps/{{cookiecutter.package}}/A3_Portrait.jrxml +185 -0
  117. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/print/print-apps/{{cookiecutter.package}}/A4_Landscape.jrxml +200 -0
  118. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/print/print-apps/{{cookiecutter.package}}/A4_Portrait.jrxml +170 -0
  119. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/print/print-apps/{{cookiecutter.package}}/config.yaml.tmpl +175 -0
  120. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/print/print-apps/{{cookiecutter.package}}/legend.jrxml +109 -0
  121. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/print/print-apps/{{cookiecutter.package}}/localisation.properties +4 -0
  122. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/print/print-apps/{{cookiecutter.package}}/localisation_fr.properties +4 -0
  123. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/print/print-apps/{{cookiecutter.package}}/logo.png +0 -0
  124. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/print/print-apps/{{cookiecutter.package}}/north.svg +93 -0
  125. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/print/print-apps/{{cookiecutter.package}}/results.jrxml +25 -0
  126. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/project.yaml +18 -0
  127. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/pyproject.toml +7 -0
  128. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/qgisserver/pg_service.conf.tmpl +15 -0
  129. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/run_alembic.sh +11 -0
  130. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/scripts/db-backup +126 -0
  131. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/scripts/db-restore +132 -0
  132. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/setup.cfg +7 -0
  133. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/spell-ignore-words.txt +5 -0
  134. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/tests/__init__.py +0 -0
  135. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/tests/test_app.py +43 -0
  136. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/tilegeneration/config.yaml.tmpl +195 -0
  137. c2cgeoportal_geoportal/scaffolds/update/cookiecutter.json +18 -0
  138. c2cgeoportal_geoportal/scaffolds/update/{{cookiecutter.project}}/.upgrade.yaml +67 -0
  139. c2cgeoportal_geoportal/scaffolds/update/{{cookiecutter.project}}/CONST_CHANGELOG.txt +295 -0
  140. c2cgeoportal_geoportal/scaffolds/update/{{cookiecutter.project}}/CONST_create_template/tests/test_testapp.py +48 -0
  141. c2cgeoportal_geoportal/scaffolds/update/{{cookiecutter.project}}/geoportal/CONST_config-schema.yaml +922 -0
  142. c2cgeoportal_geoportal/scaffolds/update/{{cookiecutter.project}}/geoportal/CONST_vars.yaml +1503 -0
  143. c2cgeoportal_geoportal/scripts/__init__.py +64 -0
  144. c2cgeoportal_geoportal/scripts/c2cupgrade.py +879 -0
  145. c2cgeoportal_geoportal/scripts/create_demo_theme.py +80 -0
  146. c2cgeoportal_geoportal/scripts/manage_users.py +140 -0
  147. c2cgeoportal_geoportal/scripts/pcreate.py +314 -0
  148. c2cgeoportal_geoportal/scripts/theme2fts.py +347 -0
  149. c2cgeoportal_geoportal/scripts/urllogin.py +81 -0
  150. c2cgeoportal_geoportal/templates/login.html +90 -0
  151. c2cgeoportal_geoportal/templates/notlogin.html +62 -0
  152. c2cgeoportal_geoportal/templates/testi18n.html +12 -0
  153. c2cgeoportal_geoportal/views/__init__.py +59 -0
  154. c2cgeoportal_geoportal/views/dev.py +57 -0
  155. c2cgeoportal_geoportal/views/dynamic.py +208 -0
  156. c2cgeoportal_geoportal/views/entry.py +174 -0
  157. c2cgeoportal_geoportal/views/fulltextsearch.py +189 -0
  158. c2cgeoportal_geoportal/views/geometry_processing.py +75 -0
  159. c2cgeoportal_geoportal/views/i18n.py +129 -0
  160. c2cgeoportal_geoportal/views/layers.py +713 -0
  161. c2cgeoportal_geoportal/views/login.py +679 -0
  162. c2cgeoportal_geoportal/views/mapserverproxy.py +191 -0
  163. c2cgeoportal_geoportal/views/memory.py +90 -0
  164. c2cgeoportal_geoportal/views/ogcproxy.py +120 -0
  165. c2cgeoportal_geoportal/views/pdfreport.py +245 -0
  166. c2cgeoportal_geoportal/views/printproxy.py +143 -0
  167. c2cgeoportal_geoportal/views/profile.py +127 -0
  168. c2cgeoportal_geoportal/views/proxy.py +259 -0
  169. c2cgeoportal_geoportal/views/raster.py +193 -0
  170. c2cgeoportal_geoportal/views/resourceproxy.py +73 -0
  171. c2cgeoportal_geoportal/views/shortener.py +152 -0
  172. c2cgeoportal_geoportal/views/theme.py +1322 -0
  173. c2cgeoportal_geoportal/views/tinyowsproxy.py +189 -0
  174. c2cgeoportal_geoportal/views/vector_tiles.py +83 -0
  175. {c2cgeoportal_geoportal-2.3.5.79.dist-info → c2cgeoportal_geoportal-2.9rc1.dist-info}/METADATA +21 -24
  176. c2cgeoportal_geoportal-2.9rc1.dist-info/RECORD +192 -0
  177. {c2cgeoportal_geoportal-2.3.5.79.dist-info → c2cgeoportal_geoportal-2.9rc1.dist-info}/WHEEL +1 -1
  178. c2cgeoportal_geoportal-2.9rc1.dist-info/entry_points.txt +28 -0
  179. c2cgeoportal_geoportal-2.9rc1.dist-info/top_level.txt +2 -0
  180. tests/__init__.py +100 -0
  181. tests/test_cachebuster.py +71 -0
  182. tests/test_caching.py +275 -0
  183. tests/test_checker.py +85 -0
  184. tests/test_decimaljson.py +47 -0
  185. tests/test_headerstween.py +64 -0
  186. tests/test_i18n.py +31 -0
  187. tests/test_init.py +193 -0
  188. tests/test_locale_negociator.py +69 -0
  189. tests/test_mapserverproxy_route_predicate.py +64 -0
  190. tests/test_raster.py +267 -0
  191. tests/test_wmstparsing.py +238 -0
  192. tests/xmlstr.py +103 -0
  193. c2cgeoportal_geoportal-2.3.5.79.dist-info/DESCRIPTION.rst +0 -8
  194. c2cgeoportal_geoportal-2.3.5.79.dist-info/RECORD +0 -7
  195. c2cgeoportal_geoportal-2.3.5.79.dist-info/entry_points.txt +0 -22
  196. c2cgeoportal_geoportal-2.3.5.79.dist-info/metadata.json +0 -1
  197. c2cgeoportal_geoportal-2.3.5.79.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")