c2cgeoportal-geoportal 2.3.5.79__py3-none-any.whl → 2.9rc44__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 (198) 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 +77 -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 +172 -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 +304 -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 +513 -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 +65 -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 +16 -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 +78 -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 +304 -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_vars.yaml.swp +0 -0
  142. c2cgeoportal_geoportal/scaffolds/update/{{cookiecutter.project}}/geoportal/CONST_config-schema.yaml +927 -0
  143. c2cgeoportal_geoportal/scaffolds/update/{{cookiecutter.project}}/geoportal/CONST_vars.yaml +1503 -0
  144. c2cgeoportal_geoportal/scripts/__init__.py +64 -0
  145. c2cgeoportal_geoportal/scripts/c2cupgrade.py +879 -0
  146. c2cgeoportal_geoportal/scripts/create_demo_theme.py +83 -0
  147. c2cgeoportal_geoportal/scripts/manage_users.py +140 -0
  148. c2cgeoportal_geoportal/scripts/pcreate.py +296 -0
  149. c2cgeoportal_geoportal/scripts/theme2fts.py +347 -0
  150. c2cgeoportal_geoportal/scripts/urllogin.py +81 -0
  151. c2cgeoportal_geoportal/templates/login.html +90 -0
  152. c2cgeoportal_geoportal/templates/notlogin.html +62 -0
  153. c2cgeoportal_geoportal/templates/testi18n.html +12 -0
  154. c2cgeoportal_geoportal/views/__init__.py +59 -0
  155. c2cgeoportal_geoportal/views/dev.py +57 -0
  156. c2cgeoportal_geoportal/views/dynamic.py +208 -0
  157. c2cgeoportal_geoportal/views/entry.py +174 -0
  158. c2cgeoportal_geoportal/views/fulltextsearch.py +189 -0
  159. c2cgeoportal_geoportal/views/geometry_processing.py +75 -0
  160. c2cgeoportal_geoportal/views/i18n.py +129 -0
  161. c2cgeoportal_geoportal/views/layers.py +713 -0
  162. c2cgeoportal_geoportal/views/login.py +684 -0
  163. c2cgeoportal_geoportal/views/mapserverproxy.py +234 -0
  164. c2cgeoportal_geoportal/views/memory.py +90 -0
  165. c2cgeoportal_geoportal/views/ogcproxy.py +120 -0
  166. c2cgeoportal_geoportal/views/pdfreport.py +245 -0
  167. c2cgeoportal_geoportal/views/printproxy.py +143 -0
  168. c2cgeoportal_geoportal/views/profile.py +192 -0
  169. c2cgeoportal_geoportal/views/proxy.py +261 -0
  170. c2cgeoportal_geoportal/views/raster.py +233 -0
  171. c2cgeoportal_geoportal/views/resourceproxy.py +73 -0
  172. c2cgeoportal_geoportal/views/shortener.py +152 -0
  173. c2cgeoportal_geoportal/views/theme.py +1322 -0
  174. c2cgeoportal_geoportal/views/tinyowsproxy.py +189 -0
  175. c2cgeoportal_geoportal/views/vector_tiles.py +83 -0
  176. {c2cgeoportal_geoportal-2.3.5.79.dist-info → c2cgeoportal_geoportal-2.9rc44.dist-info}/METADATA +21 -24
  177. c2cgeoportal_geoportal-2.9rc44.dist-info/RECORD +193 -0
  178. {c2cgeoportal_geoportal-2.3.5.79.dist-info → c2cgeoportal_geoportal-2.9rc44.dist-info}/WHEEL +1 -1
  179. c2cgeoportal_geoportal-2.9rc44.dist-info/entry_points.txt +28 -0
  180. c2cgeoportal_geoportal-2.9rc44.dist-info/top_level.txt +2 -0
  181. tests/__init__.py +100 -0
  182. tests/test_cachebuster.py +71 -0
  183. tests/test_caching.py +275 -0
  184. tests/test_checker.py +85 -0
  185. tests/test_decimaljson.py +47 -0
  186. tests/test_headerstween.py +64 -0
  187. tests/test_i18n.py +31 -0
  188. tests/test_init.py +193 -0
  189. tests/test_locale_negociator.py +69 -0
  190. tests/test_mapserverproxy_route_predicate.py +64 -0
  191. tests/test_raster.py +267 -0
  192. tests/test_wmstparsing.py +238 -0
  193. tests/xmlstr.py +103 -0
  194. c2cgeoportal_geoportal-2.3.5.79.dist-info/DESCRIPTION.rst +0 -8
  195. c2cgeoportal_geoportal-2.3.5.79.dist-info/RECORD +0 -7
  196. c2cgeoportal_geoportal-2.3.5.79.dist-info/entry_points.txt +0 -22
  197. c2cgeoportal_geoportal-2.3.5.79.dist-info/metadata.json +0 -1
  198. c2cgeoportal_geoportal-2.3.5.79.dist-info/top_level.txt +0 -1
@@ -0,0 +1,684 @@
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
+ import c2cgeoportal_commons.lib.url
54
+ from c2cgeoportal_commons import models
55
+ from c2cgeoportal_commons.lib.email_ import send_email_config
56
+ from c2cgeoportal_commons.models import static
57
+ from c2cgeoportal_geoportal import is_allowed_url, is_valid_referrer
58
+ from c2cgeoportal_geoportal.lib import get_setting, is_intranet, oauth2, oidc
59
+ from c2cgeoportal_geoportal.lib.common_headers import Cache, set_common_headers
60
+ from c2cgeoportal_geoportal.lib.functionality import get_functionality
61
+
62
+ _LOG = logging.getLogger(__name__)
63
+
64
+
65
+ class Login:
66
+ """
67
+ All the login, logout, oauth2, user information views.
68
+
69
+ Also manage the 2fa.
70
+ """
71
+
72
+ def __init__(self, request: pyramid.request.Request):
73
+ self.request = request
74
+ self.settings = request.registry.settings
75
+ self.lang = request.locale_name
76
+
77
+ self.authentication_settings = self.settings.get("authentication", {})
78
+
79
+ self.two_factor_auth = self.authentication_settings.get("two_factor", False)
80
+ self.two_factor_issuer_name = self.authentication_settings.get("two_factor_issuer_name")
81
+
82
+ def _functionality(self) -> dict[str, list[str | int | float | bool | list[Any] | dict[str, Any]]]:
83
+ functionality = {}
84
+ for func_ in get_setting(self.settings, ("functionalities", "available_in_templates"), []):
85
+ functionality[func_] = get_functionality(func_, self.request, is_intranet(self.request))
86
+ return functionality
87
+
88
+ def _referrer_log(self) -> None:
89
+ if not hasattr(self.request, "is_valid_referer"):
90
+ self.request.is_valid_referer = is_valid_referrer(self.request)
91
+ if not self.request.is_valid_referer:
92
+ _LOG.info("Invalid referrer for %s: %s", self.request.path_qs, repr(self.request.referrer))
93
+
94
+ @forbidden_view_config(renderer="login.html") # type: ignore[misc]
95
+ def loginform403(self) -> dict[str, Any] | pyramid.response.Response:
96
+ if self.request.authenticated_userid is not None:
97
+ return HTTPForbidden()
98
+
99
+ if self.authentication_settings.get("openid_connect", {}).get("enabled", False):
100
+ return HTTPFound(
101
+ location=self.request.route_url(
102
+ "oidc_login",
103
+ _query={"came_from": f"{self.request.path}?{urllib.parse.urlencode(self.request.GET)}"},
104
+ )
105
+ )
106
+
107
+ set_common_headers(self.request, "login", Cache.PRIVATE_NO)
108
+
109
+ return {
110
+ "lang": self.lang,
111
+ "login_params": {"came_from": f"{self.request.path}?{urllib.parse.urlencode(self.request.GET)}"},
112
+ "two_fa": self.two_factor_auth,
113
+ }
114
+
115
+ @view_config(route_name="loginform", renderer="login.html") # type: ignore[misc]
116
+ def loginform(self) -> dict[str, Any]:
117
+ if self.authentication_settings.get("openid_connect", {}).get("enabled", False):
118
+ raise HTTPBadRequest("View disabled by OpenID Connect")
119
+
120
+ set_common_headers(self.request, "login", Cache.PUBLIC)
121
+
122
+ return {
123
+ "lang": self.lang,
124
+ "login_params": {"came_from": self.request.params.get("came_from") or "/"},
125
+ "two_fa": self.two_factor_auth,
126
+ }
127
+
128
+ @staticmethod
129
+ def _validate_2fa_totp(user: static.User, otp: str) -> bool:
130
+ if pyotp.TOTP(user.tech_data.get("2fa_totp_secret", "")).verify(otp):
131
+ return True
132
+ return False
133
+
134
+ @view_config(route_name="login") # type: ignore[misc]
135
+ def login(self) -> pyramid.response.Response:
136
+ assert models.DBSession is not None
137
+ if self.authentication_settings.get("openid_connect", {}).get("enabled", False):
138
+ raise HTTPBadRequest("View disabled by OpenID Connect")
139
+
140
+ self._referrer_log()
141
+
142
+ login = self.request.POST.get("login")
143
+ password = self.request.POST.get("password")
144
+ if login is None or password is None:
145
+ raise HTTPBadRequest("'login' and 'password' should be available in request params.")
146
+ username = self.request.registry.validate_user(self.request, login, password)
147
+ user: static.User | None
148
+ if username is not None:
149
+ user = models.DBSession.query(static.User).filter(static.User.username == username).one()
150
+ if self.two_factor_auth:
151
+ if "2fa_totp_secret" not in user.tech_data:
152
+ user.is_password_changed = False
153
+ if not user.is_password_changed:
154
+ user.tech_data["2fa_totp_secret"] = pyotp.random_base32()
155
+ if self.request.GET.get("type") == "oauth2":
156
+ raise HTTPFound(location=self.request.route_url("notlogin"))
157
+ return set_common_headers(
158
+ self.request,
159
+ "login",
160
+ Cache.PRIVATE_NO,
161
+ response=Response(
162
+ json.dumps(
163
+ {
164
+ "username": user.username,
165
+ "is_password_changed": False,
166
+ "two_factor_enable": self.two_factor_auth,
167
+ "two_factor_totp_secret": user.tech_data["2fa_totp_secret"],
168
+ "otp_uri": pyotp.TOTP(user.tech_data["2fa_totp_secret"]).provisioning_uri(
169
+ user.email, issuer_name=self.two_factor_issuer_name
170
+ ),
171
+ }
172
+ ),
173
+ headers=(("Content-Type", "text/json"),),
174
+ ),
175
+ )
176
+ otp = self.request.POST.get("otp")
177
+ if otp is None:
178
+ raise HTTPBadRequest("The second factor is missing.")
179
+ if not self._validate_2fa_totp(user, otp):
180
+ _LOG.info("The second factor is wrong for user '%s'.", user.username)
181
+ raise HTTPUnauthorized("See server logs for details")
182
+ user.update_last_login()
183
+ user.tech_data["consecutive_failed"] = "0"
184
+
185
+ if not user.is_password_changed:
186
+ if self.request.GET.get("type") == "oauth2":
187
+ raise HTTPFound(location=self.request.route_url("notlogin"))
188
+ return set_common_headers(
189
+ self.request,
190
+ "login",
191
+ Cache.PRIVATE_NO,
192
+ response=Response(
193
+ json.dumps(
194
+ {
195
+ "username": user.username,
196
+ "is_password_changed": False,
197
+ "two_factor_enable": self.two_factor_auth,
198
+ }
199
+ ),
200
+ headers=(("Content-Type", "text/json"),),
201
+ ),
202
+ )
203
+
204
+ _LOG.info("User '%s' logged in.", username)
205
+ if self.request.GET.get("type") == "oauth2":
206
+ self._oauth2_login(user)
207
+
208
+ headers = remember(self.request, username)
209
+
210
+ came_from = self.request.params.get("came_from")
211
+ if came_from:
212
+ if not came_from.startswith("/"):
213
+ allowed_hosts = self.request.registry.settings.get("authorized_referers", [])
214
+ came_from_hostname, ok = is_allowed_url(self.request, came_from, allowed_hosts)
215
+ if not ok:
216
+ message = (
217
+ f"Invalid hostname '{came_from_hostname}' in 'came_from' parameter, "
218
+ f"is not the current host '{self.request.host}' "
219
+ f"or part of allowed hosts: {', '.join(allowed_hosts)}"
220
+ )
221
+ _LOG.debug(message)
222
+ return HTTPBadRequest(message)
223
+ return HTTPFound(location=came_from, headers=headers)
224
+
225
+ headers.append(("Content-Type", "text/json"))
226
+ return set_common_headers(
227
+ self.request,
228
+ "login",
229
+ Cache.PRIVATE_NO,
230
+ response=Response(json.dumps(self._user(self.request.get_user(username))), headers=headers),
231
+ )
232
+ user = models.DBSession.query(static.User).filter(static.User.username == login).one_or_none()
233
+ if user and not user.deactivated:
234
+ if "consecutive_failed" not in user.tech_data:
235
+ user.tech_data["consecutive_failed"] = "0"
236
+ user.tech_data["consecutive_failed"] = str(int(user.tech_data["consecutive_failed"]) + 1)
237
+ if int(user.tech_data["consecutive_failed"]) >= self.request.registry.settings.get(
238
+ "authentication", {}
239
+ ).get("max_consecutive_failures", sys.maxsize):
240
+ user.deactivated = True
241
+ user.tech_data["consecutive_failed"] = "0"
242
+
243
+ if hasattr(self.request, "tm"):
244
+ self.request.tm.commit()
245
+ raise HTTPUnauthorized("See server logs for details")
246
+
247
+ def _oauth2_login(self, user: static.User) -> pyramid.response.Response:
248
+ self.request.user_ = user
249
+ _LOG.debug(
250
+ "Call OAuth create_authorization_response with:\nurl: %s\nmethod: %s\nbody:\n%s",
251
+ self.request.current_route_url(_query=self.request.GET),
252
+ self.request.method,
253
+ self.request.body,
254
+ )
255
+ headers, body, status = oauth2.get_oauth_client(
256
+ self.request.registry.settings
257
+ ).create_authorization_response(
258
+ self.request.current_route_url(_query=self.request.GET),
259
+ self.request.method,
260
+ self.request.body,
261
+ self.request.headers,
262
+ )
263
+ if hasattr(self.request, "tm"):
264
+ self.request.tm.commit()
265
+ _LOG.debug("OAuth create_authorization_response return\nstatus: %s\nbody:\n%s", status, body)
266
+
267
+ location = headers.get("Location")
268
+ location_hostname = urllib.parse.urlparse(location).hostname
269
+ allowed_hosts = self.request.registry.settings.get("authentication", {}).get("allowed_hosts", [])
270
+ location_hostname, ok = is_allowed_url(self.request, location, allowed_hosts)
271
+ if not ok:
272
+ message = (
273
+ f"Invalid location hostname '{location_hostname}', "
274
+ f"is not the current host '{self.request.host}' "
275
+ f"or part of allowed_hosts: {', '.join(allowed_hosts)}"
276
+ )
277
+ _LOG.debug(message)
278
+ return HTTPBadRequest(message)
279
+
280
+ if status == 302:
281
+ raise HTTPFound(location=location)
282
+ if status != 200:
283
+ if body:
284
+ raise exception_response(status, details=body)
285
+ raise exception_response(status)
286
+ return set_common_headers(
287
+ self.request,
288
+ "login",
289
+ Cache.PRIVATE_NO,
290
+ response=Response(body, headers=headers.items()),
291
+ )
292
+
293
+ @view_config(route_name="logout") # type: ignore[misc]
294
+ def logout(self) -> pyramid.response.Response:
295
+ if self.authentication_settings.get("openid_connect", {}).get("enabled", False):
296
+ client = oidc.get_oidc_client(self.request, self.request.host)
297
+ if hasattr(client, "revoke_token"):
298
+ user_info = json.loads(self.request.authenticated_userid)
299
+ client.revoke_token(user_info["access_token"])
300
+ if user_info.get("refresh_token") is not None:
301
+ client.revoke_token(user_info["refresh_token"])
302
+
303
+ headers = forget(self.request)
304
+
305
+ if not self.request.user:
306
+ _LOG.info("Logout on non login user.")
307
+ raise HTTPUnauthorized("See server logs for details")
308
+
309
+ _LOG.info("User '%s' (%s) logging out.", self.request.user.username, self.request.user.id)
310
+
311
+ headers.append(("Content-Type", "text/json"))
312
+ return set_common_headers(
313
+ self.request, "login", Cache.PRIVATE_NO, response=Response("true", headers=headers)
314
+ )
315
+
316
+ def _user(self, user: static.User | None = None) -> dict[str, Any]:
317
+ result = {
318
+ "functionalities": self._functionality(),
319
+ "is_intranet": is_intranet(self.request),
320
+ "login_type": (
321
+ "oidc"
322
+ if self.authentication_settings.get("openid_connect", {}).get("enabled", False)
323
+ else "local"
324
+ ),
325
+ }
326
+ if not self.authentication_settings.get("openid_connect", {}).get("enabled", False):
327
+ result["two_factor_enable"] = self.two_factor_auth
328
+
329
+ user = self.request.user if user is None else user
330
+ if user is not None:
331
+ result.update(
332
+ {
333
+ "username": user.display_name,
334
+ "email": user.email,
335
+ "roles": [{"name": r.name, "id": r.id} for r in user.roles],
336
+ }
337
+ )
338
+ return result
339
+
340
+ @view_config(route_name="loginuser", renderer="json") # type: ignore[misc]
341
+ def loginuser(self) -> dict[str, Any]:
342
+ _LOG.info("Client IP address: %s", self.request.client_addr)
343
+ set_common_headers(self.request, "login", Cache.PRIVATE_NO)
344
+ return self._user()
345
+
346
+ @view_config(route_name="change_password", renderer="json") # type: ignore[misc]
347
+ def change_password(self) -> pyramid.response.Response:
348
+ assert models.DBSession is not None
349
+
350
+ if self.authentication_settings.get("openid_connect", {}).get("enabled", False):
351
+ raise HTTPBadRequest("View disabled by OpenID Connect")
352
+
353
+ set_common_headers(self.request, "login", Cache.PRIVATE_NO)
354
+
355
+ login = self.request.POST.get("login")
356
+ old_password = self.request.POST.get("oldPassword")
357
+ new_password = self.request.POST.get("newPassword")
358
+ new_password_confirm = self.request.POST.get("confirmNewPassword")
359
+ otp = self.request.POST.get("otp")
360
+ if new_password is None or new_password_confirm is None or old_password is None:
361
+ raise HTTPBadRequest(
362
+ "'oldPassword', 'newPassword' and 'confirmNewPassword' should be available in "
363
+ "request params."
364
+ )
365
+ if self.two_factor_auth and otp is None:
366
+ raise HTTPBadRequest("The second factor is missing.")
367
+ if login is None and self.request.user is None:
368
+ raise HTTPBadRequest("You should be logged in or 'login' should be available in request params.")
369
+ if new_password != new_password_confirm:
370
+ raise HTTPBadRequest("The new password and the new password confirmation do not match")
371
+
372
+ if login is not None:
373
+ user = models.DBSession.query(static.User).filter_by(username=login).one_or_none()
374
+ if user is None:
375
+ _LOG.info("The login '%s' does not exist.", login)
376
+ raise HTTPUnauthorized("See server logs for details")
377
+
378
+ if self.two_factor_auth:
379
+ if not self._validate_2fa_totp(user, otp):
380
+ _LOG.info("The second factor is wrong for user '%s'.", login)
381
+ raise HTTPUnauthorized("See server logs for details")
382
+ else:
383
+ user = self.request.user
384
+
385
+ assert user is not None
386
+ if self.request.registry.validate_user(self.request, user.username, old_password) is None:
387
+ _LOG.info("The old password is wrong for user '%s'.", user.username)
388
+ raise HTTPUnauthorized("See server logs for details")
389
+
390
+ user.password = new_password
391
+ user.is_password_changed = True
392
+ models.DBSession.flush()
393
+ _LOG.info("Password changed for user '%s'", user.username)
394
+
395
+ headers = remember(self.request, user.username)
396
+ headers.append(("Content-Type", "text/json"))
397
+ return set_common_headers(
398
+ self.request,
399
+ "login",
400
+ Cache.PRIVATE_NO,
401
+ response=Response(json.dumps(self._user(user)), headers=headers),
402
+ )
403
+
404
+ @staticmethod
405
+ def generate_password() -> str:
406
+ allchars = "".join(
407
+ [
408
+ string.ascii_letters * 2,
409
+ string.digits * 2,
410
+ string.punctuation, # One time to have less punctuation char
411
+ ]
412
+ )
413
+ return "".join(secrets.choice(allchars) for i in range(8))
414
+
415
+ def _loginresetpassword(
416
+ self,
417
+ ) -> tuple[static.User | None, str | None, str | None, str | None]:
418
+ assert models.DBSession is not None
419
+
420
+ username = self.request.POST.get("login")
421
+ if username is None:
422
+ raise HTTPBadRequest("'login' should be available in request params.")
423
+ try:
424
+ user = models.DBSession.query(static.User).filter(static.User.username == username).one()
425
+ except NoResultFound:
426
+ return None, None, None, f"The login '{username}' does not exist."
427
+
428
+ if user.email is None or user.email == "":
429
+ return None, None, None, f"The user '{user.username}' has no registered email address."
430
+
431
+ password = self.generate_password()
432
+ user.set_temp_password(password)
433
+
434
+ return user, username, password, None
435
+
436
+ @view_config(route_name="loginresetpassword", renderer="json") # type: ignore[misc]
437
+ def loginresetpassword(self) -> dict[str, Any]:
438
+ if self.authentication_settings.get("openid_connect", {}).get("enabled", False):
439
+ raise HTTPBadRequest("View disabled by OpenID Connect")
440
+
441
+ set_common_headers(self.request, "login", Cache.PRIVATE_NO)
442
+
443
+ user, username, password, error = self._loginresetpassword()
444
+ if error is not None:
445
+ _LOG.info(error)
446
+ return {"success": True}
447
+
448
+ if user is None:
449
+ _LOG.info("The user is not found without any error.")
450
+ return {"success": True}
451
+
452
+ if user.deactivated:
453
+ _LOG.info("The user '%s' is deactivated", username)
454
+ return {"success": True}
455
+
456
+ send_email_config(
457
+ self.request.registry.settings,
458
+ "reset_password",
459
+ user.email,
460
+ user=username,
461
+ password=password,
462
+ application_url=self.request.route_url("base"),
463
+ current_url=self.request.current_route_url(),
464
+ )
465
+
466
+ return {"success": True}
467
+
468
+ @view_config(route_name="oauth2introspect") # type: ignore[misc]
469
+ def oauth2introspect(self) -> pyramid.response.Response:
470
+ if self.authentication_settings.get("openid_connect", {}).get("enabled", False):
471
+ raise HTTPBadRequest("View disabled by OpenID Connect")
472
+
473
+ _LOG.debug(
474
+ "Call OAuth create_introspect_response with:\nurl: %s\nmethod: %s\nbody:\n%s",
475
+ self.request.current_route_url(_query=self.request.GET),
476
+ self.request.method,
477
+ self.request.body,
478
+ )
479
+ headers, body, status = oauth2.get_oauth_client(
480
+ self.request.registry.settings
481
+ ).create_introspect_response(
482
+ self.request.current_route_url(_query=self.request.GET),
483
+ self.request.method,
484
+ self.request.body,
485
+ self.request.headers,
486
+ )
487
+ _LOG.debug("OAuth create_introspect_response return status: %s", status)
488
+
489
+ # All requests to /token will return a json response, no redirection.
490
+ if status != 200:
491
+ if body:
492
+ raise exception_response(status, detail=body)
493
+ raise exception_response(status)
494
+ return set_common_headers(
495
+ self.request,
496
+ "login",
497
+ Cache.PRIVATE_NO,
498
+ response=Response(body, headers=headers.items()),
499
+ )
500
+
501
+ @view_config(route_name="oauth2token") # type: ignore[misc]
502
+ def oauth2token(self) -> pyramid.response.Response:
503
+ if self.authentication_settings.get("openid_connect", {}).get("enabled", False):
504
+ raise HTTPBadRequest("View disabled by OpenID Connect")
505
+
506
+ _LOG.debug(
507
+ "Call OAuth create_token_response with:\nurl: %s\nmethod: %s\nbody:\n%s",
508
+ self.request.current_route_url(_query=self.request.GET),
509
+ self.request.method,
510
+ self.request.body,
511
+ )
512
+ headers, body, status = oauth2.get_oauth_client(self.request.registry.settings).create_token_response(
513
+ self.request.current_route_url(_query=self.request.GET),
514
+ self.request.method,
515
+ self.request.body,
516
+ self.request.headers,
517
+ {},
518
+ )
519
+ _LOG.debug("OAuth create_token_response return status: %s", status)
520
+
521
+ # All requests to /token will return a json response, no redirection.
522
+ if status != 200:
523
+ if body:
524
+ raise exception_response(status, detail=body)
525
+ raise exception_response(status)
526
+ return set_common_headers(
527
+ self.request,
528
+ "login",
529
+ Cache.PRIVATE_NO,
530
+ response=Response(body, headers=headers.items()),
531
+ )
532
+
533
+ @view_config(route_name="oauth2revoke_token") # type: ignore[misc]
534
+ def oauth2revoke_token(self) -> pyramid.response.Response:
535
+ if self.authentication_settings.get("openid_connect", {}).get("enabled", False):
536
+ raise HTTPBadRequest("View disabled by OpenID Connect")
537
+
538
+ _LOG.debug(
539
+ "Call OAuth create_revocation_response with:\nurl: %s\nmethod: %s\nbody:\n%s",
540
+ self.request.create_revocation_response(_query=self.request.GET),
541
+ self.request.method,
542
+ self.request.body,
543
+ )
544
+ headers, body, status = oauth2.get_oauth_client(
545
+ self.request.registry.settings
546
+ ).create_authorize_response(
547
+ self.request.current_route_url(_query=self.request.GET),
548
+ self.request.method,
549
+ self.request.body,
550
+ self.request.headers,
551
+ )
552
+ if status != 200:
553
+ if body:
554
+ raise exception_response(status, detail=body)
555
+ raise exception_response(status)
556
+ return set_common_headers(
557
+ self.request,
558
+ "login",
559
+ Cache.PRIVATE_NO,
560
+ response=Response(body, headers=headers.items()),
561
+ )
562
+
563
+ @view_config(route_name="oauth2loginform", renderer="login.html") # type: ignore[misc]
564
+ def oauth2loginform(self) -> dict[str, Any]:
565
+ if self.authentication_settings.get("openid_connect", {}).get("enabled", False):
566
+ raise HTTPBadRequest("View disabled by OpenID Connect")
567
+
568
+ set_common_headers(self.request, "login", Cache.PUBLIC)
569
+
570
+ if self.request.user:
571
+ self._oauth2_login(self.request.user)
572
+
573
+ login_param = {"type": "oauth2"}
574
+ login_param.update(self.request.params)
575
+ return {
576
+ "lang": self.lang,
577
+ "login_params": login_param,
578
+ "two_fa": self.two_factor_auth,
579
+ }
580
+
581
+ @view_config(route_name="notlogin", renderer="notlogin.html") # type: ignore[misc]
582
+ def notlogin(self) -> dict[str, Any]:
583
+ set_common_headers(self.request, "login", Cache.PUBLIC)
584
+
585
+ return {"lang": self.lang}
586
+
587
+ @view_config(route_name="oidc_login") # type: ignore[misc]
588
+ def oidc_login(self) -> pyramid.response.Response:
589
+ client = oidc.get_oidc_client(self.request, self.request.host)
590
+ if "came_from" in self.request.params:
591
+ self.request.response.set_cookie(
592
+ "came_from",
593
+ self.request.params["came_from"],
594
+ httponly=True,
595
+ samesite="Lax",
596
+ secure=True,
597
+ domain=self.request.domain,
598
+ max_age=600,
599
+ )
600
+
601
+ code_verifier, code_challenge = pkce.generate_pkce_pair()
602
+ self.request.response.set_cookie(
603
+ "code_verifier",
604
+ code_verifier,
605
+ httponly=True,
606
+ samesite="Lax",
607
+ secure=True,
608
+ domain=self.request.domain,
609
+ max_age=600,
610
+ )
611
+ self.request.response.set_cookie(
612
+ "code_challenge",
613
+ code_challenge,
614
+ httponly=True,
615
+ samesite="Lax",
616
+ secure=True,
617
+ domain=self.request.domain,
618
+ max_age=600,
619
+ )
620
+
621
+ try:
622
+
623
+ url = c2cgeoportal_commons.lib.url.Url(
624
+ client.authorization_code_flow.start_authentication(
625
+ code_challenge=code_challenge,
626
+ code_challenge_method="S256",
627
+ )
628
+ )
629
+ url.add_query(
630
+ self.authentication_settings.get("openid_connect", {}).get("login_extra_params", {})
631
+ )
632
+ return HTTPFound(location=url.url(), headers=self.request.response.headers)
633
+ finally:
634
+ client.authorization_code_flow.code_challenge = ""
635
+
636
+ @view_config(route_name="oidc_callback") # type: ignore[misc]
637
+ def oidc_callback(self) -> pyramid.response.Response:
638
+ client = oidc.get_oidc_client(self.request, self.request.host)
639
+ assert models.DBSession is not None
640
+
641
+ token_response = client.authorization_code_flow.handle_authentication_result(
642
+ "?" + urllib.parse.urlencode(self.request.params),
643
+ code_verifier=self.request.cookies["code_verifier"],
644
+ code_challenge=self.request.cookies["code_challenge"],
645
+ code_challenge_method="S256",
646
+ )
647
+ self.request.response.delete_cookie("code_verifier")
648
+ self.request.response.delete_cookie("code_challenge")
649
+
650
+ remember_object = oidc.OidcRemember(self.request).remember(token_response, self.request.host)
651
+
652
+ user: static.User | oidc.DynamicUser | None = self.request.get_user_from_remember(
653
+ remember_object, update_create_user=True
654
+ )
655
+ if user is not None:
656
+ self.request.user_ = user
657
+
658
+ if "came_from" in self.request.cookies:
659
+ came_from = self.request.cookies["came_from"]
660
+ self.request.response.delete_cookie("came_from")
661
+
662
+ return HTTPFound(location=came_from, headers=self.request.response.headers)
663
+
664
+ if user is not None:
665
+ return set_common_headers(
666
+ self.request,
667
+ "login",
668
+ Cache.PRIVATE_NO,
669
+ response=Response(
670
+ # TODO respect the user interface...
671
+ json.dumps(
672
+ {
673
+ "username": user.display_name,
674
+ "email": user.email,
675
+ "is_intranet": is_intranet(self.request),
676
+ "functionalities": self._functionality(),
677
+ "roles": [{"name": r.name, "id": r.id} for r in user.roles],
678
+ }
679
+ ),
680
+ headers=(("Content-Type", "text/json"),),
681
+ ),
682
+ )
683
+ else:
684
+ return HTTPUnauthorized("See server logs for details")