├── .gitignore ├── .travis.yml ├── CHANGELOG.rst ├── INSTALL.md ├── LICENSE.txt ├── MANIFEST.in ├── README.md ├── bin ├── travis-build.bash └── travis-run.sh ├── ci ├── docker-compose.yml └── idm-config.js ├── ckanext ├── __init__.py └── oauth2 │ ├── __init__.py │ ├── constants.py │ ├── controller.py │ ├── db.py │ ├── oauth2.py │ ├── plugin.py │ ├── templates │ └── header.html │ └── tests │ ├── __init__.py │ ├── test_controller.py │ ├── test_db.py │ ├── test_oauth2.py │ ├── test_plugin.py │ └── test_selenium.py ├── coverage.xml ├── requirements ├── all.pip ├── install.pip └── test.pip ├── setup.cfg ├── setup.py ├── test-fiware.ini └── test.ini /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # Mac OS 4 | .DS_Store 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Packages 10 | *.eggs 11 | *.egg-info 12 | dist 13 | build 14 | eggs 15 | parts 16 | var 17 | sdist 18 | develop-eggs 19 | .installed.cfg 20 | lib 21 | lib64 22 | __pycache__ 23 | 24 | # Installer logs 25 | pip-log.txt 26 | 27 | # Unit test / coverage reports 28 | .coverage 29 | .tox 30 | nosetests.xml 31 | 32 | # Translations 33 | *.mo 34 | 35 | # Mr Developer 36 | .mr.developer.cfg 37 | .project 38 | .pydevproject 39 | .idea 40 | venv 41 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: required 2 | language: python 3 | python: 4 | - "2.7" 5 | env: 6 | - CKANVERSION=2.6.3 POSTGISVERSION=2 INTEGRATION_TEST=true 7 | - CKANVERSION=2.7.2 POSTGISVERSION=2 IDM_VERSION=v6 8 | - CKANVERSION=2.7.3 POSTGISVERSION=2 INTEGRATION_TEST=true 9 | - CKANVERSION=2.8.0 POSTGISVERSION=2 INTEGRATION_TEST=true 10 | services: 11 | - docker 12 | - redis-server 13 | - postgresql 14 | addons: 15 | firefox: "60.0esr" 16 | before_install: 17 | - wget https://github.com/mozilla/geckodriver/releases/download/v0.20.1/geckodriver-v0.20.1-linux64.tar.gz 18 | - mkdir geckodriver 19 | - tar -xzf geckodriver-v0.20.1-linux64.tar.gz -C geckodriver 20 | - export PATH=$PATH:$PWD/geckodriver 21 | install: 22 | - . bin/travis-build.bash 23 | script: 24 | - bash bin/travis-run.sh 25 | after_success: coveralls 26 | branches: 27 | only: 28 | - master 29 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | CHANGELOG 2 | ========= 3 | 4 | Nothing yet 5 | -------------------------------------------------------------------------------- /INSTALL.md: -------------------------------------------------------------------------------- 1 | To install the plugin, **enter your virtualenv** and install the package using `pip` as follows: 2 | 3 | ``` 4 | pip install ckanext-oauth2 5 | ``` 6 | 7 | Add the following to your CKAN `.ini` (generally `/etc/ckan/default/production.ini`) file: 8 | 9 | ``` 10 | ckan.plugins = oauth2 11 | 12 | ## OAuth2 configuration 13 | 14 | ckan.oauth2.register_url = https://YOUR_OAUTH_SERVICE/users/sign_up 15 | ckan.oauth2.reset_url = https://YOUR_OAUTH_SERVICE/users/password/new 16 | ckan.oauth2.edit_url = https://YOUR_OAUTH_SERVICE/settings 17 | ckan.oauth2.authorization_endpoint = https://YOUR_OAUTH_SERVICE/authorize 18 | ckan.oauth2.token_endpoint = https://YOUR_OAUTH_SERVICE/token 19 | ckan.oauth2.profile_api_url = https://YOUR_OAUTH_SERVICE/user 20 | ckan.oauth2.client_id = YOUR_CLIENT_ID 21 | ckan.oauth2.client_secret = YOUR_CLIENT_SECRET 22 | ckan.oauth2.scope = profile other.scope 23 | ckan.oauth2.rememberer_name = auth_tkt 24 | ckan.oauth2.profile_api_user_field = JSON_FIELD_TO_FIND_THE_USER_IDENTIFIER 25 | ckan.oauth2.profile_api_fullname_field = JSON_FIELD_TO_FIND_THE_USER_FULLNAME 26 | ckan.oauth2.profile_api_mail_field = JSON_FIELD_TO_FIND_THE_USER_MAIL 27 | ckan.oauth2.authorization_header = OAUTH2_HEADER 28 | ``` 29 | 30 | > **Note**: In case you are using FIWARE as OAuth2 provider, this is the concrete OAuth2 configuration you should use (e.g. using FIWARE Lab): 31 | > 32 | > ``` 33 | > ## OAuth2 configuration 34 | > ckan.oauth2.register_url = https://account.lab.fiware.org/users/sign_up 35 | > ckan.oauth2.reset_url = https://account.lab.fiware.org/users/password/new 36 | > ckan.oauth2.edit_url = https://account.lab.fiware.org/idm/settings 37 | > ckan.oauth2.authorization_endpoint = https://account.lab.fiware.org/oauth2/authorize 38 | > ckan.oauth2.token_endpoint = https://account.lab.fiware.org/oauth2/token 39 | > ckan.oauth2.profile_api_url = https://account.lab.fiware.org/user 40 | > ckan.oauth2.client_id = YOUR_CLIENT_ID 41 | > ckan.oauth2.client_secret = YOUR_CLIENT_SECRET 42 | > ckan.oauth2.scope = all_info 43 | > ckan.oauth2.profile_api_user_field = id 44 | > ckan.oauth2.profile_api_fullname_field = displayName 45 | > ckan.oauth2.profile_api_mail_field = email 46 | > ckan.oauth2.authorization_header = Authorization 47 | > ``` 48 | > 49 | > And this is an example for using Google as OAuth2 provider: 50 | > 51 | > ``` 52 | > ## OAuth2 configuration 53 | > ckan.oauth2.authorization_endpoint = https://accounts.google.com/o/oauth2/auth 54 | > ckan.oauth2.token_endpoint = https://accounts.google.com/o/oauth2/token 55 | > ckan.oauth2.profile_api_url = https://www.googleapis.com/oauth2/v1/userinfo 56 | > ckan.oauth2.client_id = YOUR_CLIENT_ID 57 | > ckan.oauth2.client_secret = YOUR_CLIENT_SECRET 58 | > ckan.oauth2.scope = openid email profile 59 | > ckan.oauth2.profile_api_user_field = email 60 | > ckan.oauth2.profile_api_fullname_field = name 61 | > ckan.oauth2.profile_api_mail_field = email 62 | > ckan.oauth2.authorization_header = Authorization 63 | > ``` 64 | 65 | You can also use environment variables to configure this plugin, the name of the environment variables are: 66 | 67 | - `CKAN_OAUTH2_REGISTER_URL` 68 | - `CKAN_OAUTH2_RESET_URL` 69 | - `CKAN_OAUTH2_EDIT_URL` 70 | - `CKAN_OAUTH2_AUTHORIZATION_ENDPOINT` 71 | - `CKAN_OAUTH2_TOKEN_ENDPOINT` 72 | - `CKAN_OAUTH2_PROFILE_API_URL` 73 | - `CKAN_OAUTH2_CLIENT_ID` 74 | - `CKAN_OAUTH2_CLIENT_SECRET` 75 | - `CKAN_OAUTH2_SCOPE` 76 | - `CKAN_OAUTH2_REMEMBERER_NAME` 77 | - `CKAN_OAUTH2_PROFILE_API_USER_FIELD` 78 | - `CKAN_OAUTH2_PROFILE_API_FULLNAME_FIELD` 79 | - `CKAN_OAUTH2_PROFILE_API_MAIL_FIELD` 80 | - `CKAN_OAUTH2_AUTHORIZATION_HEADER` 81 | 82 | **Additional notes**: 83 | * This extension only works when your CKAN instance is working over HTTPS, since OAuth 2.0 depends on it. You can follow the [Starting CKAN over HTTPs tutorial](https://github.com/conwetlab/ckanext-oauth2/wiki/Starting-CKAN-over-HTTPs) to learn how to do that. 84 | * You can run the extension to connect to a OAuth2 server using HTTP, or to a server using an invalid certificate (e.g. a self-signed one), by editing the file `/etc/apache2/envvars` and adding the following environment variable, or directly exporting the variable in the shell if you are executing development server with "paster serve ..." : 85 | ``` 86 | export OAUTHLIB_INSECURE_TRANSPORT=True 87 | ``` 88 | * The callback URL that you should set on your OAuth 2.0 is: `https://YOUR_CKAN_INSTANCE/oauth2/callback`, replacing `YOUR_CKAN_INSTANCE` by the machine and port where your CKAN instance is running. 89 | * If you are connecting to FIWARE KeyRock v6 or v5, you have to set `ckan.oauth2.legacy_idm` to `true`. 90 | 91 | Refer to this document for integration between CKAN and WSO2-IS IDM using oauth2 with settings: 92 | https://github.com/conwetlab/ckanext-oauth2/wiki/Integration-between-WSO2-IS-and-CKAN-using-Oauth2 93 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | GNU AFFERO GENERAL PUBLIC LICENSE 2 | Version 3, 19 November 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU Affero General Public License is a free, copyleft license for 11 | software and other kinds of works, specifically designed to ensure 12 | cooperation with the community in the case of network server software. 13 | 14 | The licenses for most software and other practical works are designed 15 | to take away your freedom to share and change the works. By contrast, 16 | our General Public Licenses are intended to guarantee your freedom to 17 | share and change all versions of a program--to make sure it remains free 18 | software for all its users. 19 | 20 | When we speak of free software, we are referring to freedom, not 21 | price. Our General Public Licenses are designed to make sure that you 22 | have the freedom to distribute copies of free software (and charge for 23 | them if you wish), that you receive source code or can get it if you 24 | want it, that you can change the software or use pieces of it in new 25 | free programs, and that you know you can do these things. 26 | 27 | Developers that use our General Public Licenses protect your rights 28 | with two steps: (1) assert copyright on the software, and (2) offer 29 | you this License which gives you legal permission to copy, distribute 30 | and/or modify the software. 31 | 32 | A secondary benefit of defending all users' freedom is that 33 | improvements made in alternate versions of the program, if they 34 | receive widespread use, become available for other developers to 35 | incorporate. Many developers of free software are heartened and 36 | encouraged by the resulting cooperation. However, in the case of 37 | software used on network servers, this result may fail to come about. 38 | The GNU General Public License permits making a modified version and 39 | letting the public access it on a server without ever releasing its 40 | source code to the public. 41 | 42 | The GNU Affero General Public License is designed specifically to 43 | ensure that, in such cases, the modified source code becomes available 44 | to the community. It requires the operator of a network server to 45 | provide the source code of the modified version running there to the 46 | users of that server. Therefore, public use of a modified version, on 47 | a publicly accessible server, gives the public access to the source 48 | code of the modified version. 49 | 50 | An older license, called the Affero General Public License and 51 | published by Affero, was designed to accomplish similar goals. This is 52 | a different license, not a version of the Affero GPL, but Affero has 53 | released a new version of the Affero GPL which permits relicensing under 54 | this license. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | TERMS AND CONDITIONS 60 | 61 | 0. Definitions. 62 | 63 | "This License" refers to version 3 of the GNU Affero General Public License. 64 | 65 | "Copyright" also means copyright-like laws that apply to other kinds of 66 | works, such as semiconductor masks. 67 | 68 | "The Program" refers to any copyrightable work licensed under this 69 | License. Each licensee is addressed as "you". "Licensees" and 70 | "recipients" may be individuals or organizations. 71 | 72 | To "modify" a work means to copy from or adapt all or part of the work 73 | in a fashion requiring copyright permission, other than the making of an 74 | exact copy. The resulting work is called a "modified version" of the 75 | earlier work or a work "based on" the earlier work. 76 | 77 | A "covered work" means either the unmodified Program or a work based 78 | on the Program. 79 | 80 | To "propagate" a work means to do anything with it that, without 81 | permission, would make you directly or secondarily liable for 82 | infringement under applicable copyright law, except executing it on a 83 | computer or modifying a private copy. Propagation includes copying, 84 | distribution (with or without modification), making available to the 85 | public, and in some countries other activities as well. 86 | 87 | To "convey" a work means any kind of propagation that enables other 88 | parties to make or receive copies. Mere interaction with a user through 89 | a computer network, with no transfer of a copy, is not conveying. 90 | 91 | An interactive user interface displays "Appropriate Legal Notices" 92 | to the extent that it includes a convenient and prominently visible 93 | feature that (1) displays an appropriate copyright notice, and (2) 94 | tells the user that there is no warranty for the work (except to the 95 | extent that warranties are provided), that licensees may convey the 96 | work under this License, and how to view a copy of this License. If 97 | the interface presents a list of user commands or options, such as a 98 | menu, a prominent item in the list meets this criterion. 99 | 100 | 1. Source Code. 101 | 102 | The "source code" for a work means the preferred form of the work 103 | for making modifications to it. "Object code" means any non-source 104 | form of a work. 105 | 106 | A "Standard Interface" means an interface that either is an official 107 | standard defined by a recognized standards body, or, in the case of 108 | interfaces specified for a particular programming language, one that 109 | is widely used among developers working in that language. 110 | 111 | The "System Libraries" of an executable work include anything, other 112 | than the work as a whole, that (a) is included in the normal form of 113 | packaging a Major Component, but which is not part of that Major 114 | Component, and (b) serves only to enable use of the work with that 115 | Major Component, or to implement a Standard Interface for which an 116 | implementation is available to the public in source code form. A 117 | "Major Component", in this context, means a major essential component 118 | (kernel, window system, and so on) of the specific operating system 119 | (if any) on which the executable work runs, or a compiler used to 120 | produce the work, or an object code interpreter used to run it. 121 | 122 | The "Corresponding Source" for a work in object code form means all 123 | the source code needed to generate, install, and (for an executable 124 | work) run the object code and to modify the work, including scripts to 125 | control those activities. However, it does not include the work's 126 | System Libraries, or general-purpose tools or generally available free 127 | programs which are used unmodified in performing those activities but 128 | which are not part of the work. For example, Corresponding Source 129 | includes interface definition files associated with source files for 130 | the work, and the source code for shared libraries and dynamically 131 | linked subprograms that the work is specifically designed to require, 132 | such as by intimate data communication or control flow between those 133 | subprograms and other parts of the work. 134 | 135 | The Corresponding Source need not include anything that users 136 | can regenerate automatically from other parts of the Corresponding 137 | Source. 138 | 139 | The Corresponding Source for a work in source code form is that 140 | same work. 141 | 142 | 2. Basic Permissions. 143 | 144 | All rights granted under this License are granted for the term of 145 | copyright on the Program, and are irrevocable provided the stated 146 | conditions are met. This License explicitly affirms your unlimited 147 | permission to run the unmodified Program. The output from running a 148 | covered work is covered by this License only if the output, given its 149 | content, constitutes a covered work. This License acknowledges your 150 | rights of fair use or other equivalent, as provided by copyright law. 151 | 152 | You may make, run and propagate covered works that you do not 153 | convey, without conditions so long as your license otherwise remains 154 | in force. You may convey covered works to others for the sole purpose 155 | of having them make modifications exclusively for you, or provide you 156 | with facilities for running those works, provided that you comply with 157 | the terms of this License in conveying all material for which you do 158 | not control copyright. Those thus making or running the covered works 159 | for you must do so exclusively on your behalf, under your direction 160 | and control, on terms that prohibit them from making any copies of 161 | your copyrighted material outside their relationship with you. 162 | 163 | Conveying under any other circumstances is permitted solely under 164 | the conditions stated below. Sublicensing is not allowed; section 10 165 | makes it unnecessary. 166 | 167 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 168 | 169 | No covered work shall be deemed part of an effective technological 170 | measure under any applicable law fulfilling obligations under article 171 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 172 | similar laws prohibiting or restricting circumvention of such 173 | measures. 174 | 175 | When you convey a covered work, you waive any legal power to forbid 176 | circumvention of technological measures to the extent such circumvention 177 | is effected by exercising rights under this License with respect to 178 | the covered work, and you disclaim any intention to limit operation or 179 | modification of the work as a means of enforcing, against the work's 180 | users, your or third parties' legal rights to forbid circumvention of 181 | technological measures. 182 | 183 | 4. Conveying Verbatim Copies. 184 | 185 | You may convey verbatim copies of the Program's source code as you 186 | receive it, in any medium, provided that you conspicuously and 187 | appropriately publish on each copy an appropriate copyright notice; 188 | keep intact all notices stating that this License and any 189 | non-permissive terms added in accord with section 7 apply to the code; 190 | keep intact all notices of the absence of any warranty; and give all 191 | recipients a copy of this License along with the Program. 192 | 193 | You may charge any price or no price for each copy that you convey, 194 | and you may offer support or warranty protection for a fee. 195 | 196 | 5. Conveying Modified Source Versions. 197 | 198 | You may convey a work based on the Program, or the modifications to 199 | produce it from the Program, in the form of source code under the 200 | terms of section 4, provided that you also meet all of these conditions: 201 | 202 | a) The work must carry prominent notices stating that you modified 203 | it, and giving a relevant date. 204 | 205 | b) The work must carry prominent notices stating that it is 206 | released under this License and any conditions added under section 207 | 7. This requirement modifies the requirement in section 4 to 208 | "keep intact all notices". 209 | 210 | c) You must license the entire work, as a whole, under this 211 | License to anyone who comes into possession of a copy. This 212 | License will therefore apply, along with any applicable section 7 213 | additional terms, to the whole of the work, and all its parts, 214 | regardless of how they are packaged. This License gives no 215 | permission to license the work in any other way, but it does not 216 | invalidate such permission if you have separately received it. 217 | 218 | d) If the work has interactive user interfaces, each must display 219 | Appropriate Legal Notices; however, if the Program has interactive 220 | interfaces that do not display Appropriate Legal Notices, your 221 | work need not make them do so. 222 | 223 | A compilation of a covered work with other separate and independent 224 | works, which are not by their nature extensions of the covered work, 225 | and which are not combined with it such as to form a larger program, 226 | in or on a volume of a storage or distribution medium, is called an 227 | "aggregate" if the compilation and its resulting copyright are not 228 | used to limit the access or legal rights of the compilation's users 229 | beyond what the individual works permit. Inclusion of a covered work 230 | in an aggregate does not cause this License to apply to the other 231 | parts of the aggregate. 232 | 233 | 6. Conveying Non-Source Forms. 234 | 235 | You may convey a covered work in object code form under the terms 236 | of sections 4 and 5, provided that you also convey the 237 | machine-readable Corresponding Source under the terms of this License, 238 | in one of these ways: 239 | 240 | a) Convey the object code in, or embodied in, a physical product 241 | (including a physical distribution medium), accompanied by the 242 | Corresponding Source fixed on a durable physical medium 243 | customarily used for software interchange. 244 | 245 | b) Convey the object code in, or embodied in, a physical product 246 | (including a physical distribution medium), accompanied by a 247 | written offer, valid for at least three years and valid for as 248 | long as you offer spare parts or customer support for that product 249 | model, to give anyone who possesses the object code either (1) a 250 | copy of the Corresponding Source for all the software in the 251 | product that is covered by this License, on a durable physical 252 | medium customarily used for software interchange, for a price no 253 | more than your reasonable cost of physically performing this 254 | conveying of source, or (2) access to copy the 255 | Corresponding Source from a network server at no charge. 256 | 257 | c) Convey individual copies of the object code with a copy of the 258 | written offer to provide the Corresponding Source. This 259 | alternative is allowed only occasionally and noncommercially, and 260 | only if you received the object code with such an offer, in accord 261 | with subsection 6b. 262 | 263 | d) Convey the object code by offering access from a designated 264 | place (gratis or for a charge), and offer equivalent access to the 265 | Corresponding Source in the same way through the same place at no 266 | further charge. You need not require recipients to copy the 267 | Corresponding Source along with the object code. If the place to 268 | copy the object code is a network server, the Corresponding Source 269 | may be on a different server (operated by you or a third party) 270 | that supports equivalent copying facilities, provided you maintain 271 | clear directions next to the object code saying where to find the 272 | Corresponding Source. Regardless of what server hosts the 273 | Corresponding Source, you remain obligated to ensure that it is 274 | available for as long as needed to satisfy these requirements. 275 | 276 | e) Convey the object code using peer-to-peer transmission, provided 277 | you inform other peers where the object code and Corresponding 278 | Source of the work are being offered to the general public at no 279 | charge under subsection 6d. 280 | 281 | A separable portion of the object code, whose source code is excluded 282 | from the Corresponding Source as a System Library, need not be 283 | included in conveying the object code work. 284 | 285 | A "User Product" is either (1) a "consumer product", which means any 286 | tangible personal property which is normally used for personal, family, 287 | or household purposes, or (2) anything designed or sold for incorporation 288 | into a dwelling. In determining whether a product is a consumer product, 289 | doubtful cases shall be resolved in favor of coverage. For a particular 290 | product received by a particular user, "normally used" refers to a 291 | typical or common use of that class of product, regardless of the status 292 | of the particular user or of the way in which the particular user 293 | actually uses, or expects or is expected to use, the product. A product 294 | is a consumer product regardless of whether the product has substantial 295 | commercial, industrial or non-consumer uses, unless such uses represent 296 | the only significant mode of use of the product. 297 | 298 | "Installation Information" for a User Product means any methods, 299 | procedures, authorization keys, or other information required to install 300 | and execute modified versions of a covered work in that User Product from 301 | a modified version of its Corresponding Source. The information must 302 | suffice to ensure that the continued functioning of the modified object 303 | code is in no case prevented or interfered with solely because 304 | modification has been made. 305 | 306 | If you convey an object code work under this section in, or with, or 307 | specifically for use in, a User Product, and the conveying occurs as 308 | part of a transaction in which the right of possession and use of the 309 | User Product is transferred to the recipient in perpetuity or for a 310 | fixed term (regardless of how the transaction is characterized), the 311 | Corresponding Source conveyed under this section must be accompanied 312 | by the Installation Information. But this requirement does not apply 313 | if neither you nor any third party retains the ability to install 314 | modified object code on the User Product (for example, the work has 315 | been installed in ROM). 316 | 317 | The requirement to provide Installation Information does not include a 318 | requirement to continue to provide support service, warranty, or updates 319 | for a work that has been modified or installed by the recipient, or for 320 | the User Product in which it has been modified or installed. Access to a 321 | network may be denied when the modification itself materially and 322 | adversely affects the operation of the network or violates the rules and 323 | protocols for communication across the network. 324 | 325 | Corresponding Source conveyed, and Installation Information provided, 326 | in accord with this section must be in a format that is publicly 327 | documented (and with an implementation available to the public in 328 | source code form), and must require no special password or key for 329 | unpacking, reading or copying. 330 | 331 | 7. Additional Terms. 332 | 333 | "Additional permissions" are terms that supplement the terms of this 334 | License by making exceptions from one or more of its conditions. 335 | Additional permissions that are applicable to the entire Program shall 336 | be treated as though they were included in this License, to the extent 337 | that they are valid under applicable law. If additional permissions 338 | apply only to part of the Program, that part may be used separately 339 | under those permissions, but the entire Program remains governed by 340 | this License without regard to the additional permissions. 341 | 342 | When you convey a copy of a covered work, you may at your option 343 | remove any additional permissions from that copy, or from any part of 344 | it. (Additional permissions may be written to require their own 345 | removal in certain cases when you modify the work.) You may place 346 | additional permissions on material, added by you to a covered work, 347 | for which you have or can give appropriate copyright permission. 348 | 349 | Notwithstanding any other provision of this License, for material you 350 | add to a covered work, you may (if authorized by the copyright holders of 351 | that material) supplement the terms of this License with terms: 352 | 353 | a) Disclaiming warranty or limiting liability differently from the 354 | terms of sections 15 and 16 of this License; or 355 | 356 | b) Requiring preservation of specified reasonable legal notices or 357 | author attributions in that material or in the Appropriate Legal 358 | Notices displayed by works containing it; or 359 | 360 | c) Prohibiting misrepresentation of the origin of that material, or 361 | requiring that modified versions of such material be marked in 362 | reasonable ways as different from the original version; or 363 | 364 | d) Limiting the use for publicity purposes of names of licensors or 365 | authors of the material; or 366 | 367 | e) Declining to grant rights under trademark law for use of some 368 | trade names, trademarks, or service marks; or 369 | 370 | f) Requiring indemnification of licensors and authors of that 371 | material by anyone who conveys the material (or modified versions of 372 | it) with contractual assumptions of liability to the recipient, for 373 | any liability that these contractual assumptions directly impose on 374 | those licensors and authors. 375 | 376 | All other non-permissive additional terms are considered "further 377 | restrictions" within the meaning of section 10. If the Program as you 378 | received it, or any part of it, contains a notice stating that it is 379 | governed by this License along with a term that is a further 380 | restriction, you may remove that term. If a license document contains 381 | a further restriction but permits relicensing or conveying under this 382 | License, you may add to a covered work material governed by the terms 383 | of that license document, provided that the further restriction does 384 | not survive such relicensing or conveying. 385 | 386 | If you add terms to a covered work in accord with this section, you 387 | must place, in the relevant source files, a statement of the 388 | additional terms that apply to those files, or a notice indicating 389 | where to find the applicable terms. 390 | 391 | Additional terms, permissive or non-permissive, may be stated in the 392 | form of a separately written license, or stated as exceptions; 393 | the above requirements apply either way. 394 | 395 | 8. Termination. 396 | 397 | You may not propagate or modify a covered work except as expressly 398 | provided under this License. Any attempt otherwise to propagate or 399 | modify it is void, and will automatically terminate your rights under 400 | this License (including any patent licenses granted under the third 401 | paragraph of section 11). 402 | 403 | However, if you cease all violation of this License, then your 404 | license from a particular copyright holder is reinstated (a) 405 | provisionally, unless and until the copyright holder explicitly and 406 | finally terminates your license, and (b) permanently, if the copyright 407 | holder fails to notify you of the violation by some reasonable means 408 | prior to 60 days after the cessation. 409 | 410 | Moreover, your license from a particular copyright holder is 411 | reinstated permanently if the copyright holder notifies you of the 412 | violation by some reasonable means, this is the first time you have 413 | received notice of violation of this License (for any work) from that 414 | copyright holder, and you cure the violation prior to 30 days after 415 | your receipt of the notice. 416 | 417 | Termination of your rights under this section does not terminate the 418 | licenses of parties who have received copies or rights from you under 419 | this License. If your rights have been terminated and not permanently 420 | reinstated, you do not qualify to receive new licenses for the same 421 | material under section 10. 422 | 423 | 9. Acceptance Not Required for Having Copies. 424 | 425 | You are not required to accept this License in order to receive or 426 | run a copy of the Program. Ancillary propagation of a covered work 427 | occurring solely as a consequence of using peer-to-peer transmission 428 | to receive a copy likewise does not require acceptance. However, 429 | nothing other than this License grants you permission to propagate or 430 | modify any covered work. These actions infringe copyright if you do 431 | not accept this License. Therefore, by modifying or propagating a 432 | covered work, you indicate your acceptance of this License to do so. 433 | 434 | 10. Automatic Licensing of Downstream Recipients. 435 | 436 | Each time you convey a covered work, the recipient automatically 437 | receives a license from the original licensors, to run, modify and 438 | propagate that work, subject to this License. You are not responsible 439 | for enforcing compliance by third parties with this License. 440 | 441 | An "entity transaction" is a transaction transferring control of an 442 | organization, or substantially all assets of one, or subdividing an 443 | organization, or merging organizations. If propagation of a covered 444 | work results from an entity transaction, each party to that 445 | transaction who receives a copy of the work also receives whatever 446 | licenses to the work the party's predecessor in interest had or could 447 | give under the previous paragraph, plus a right to possession of the 448 | Corresponding Source of the work from the predecessor in interest, if 449 | the predecessor has it or can get it with reasonable efforts. 450 | 451 | You may not impose any further restrictions on the exercise of the 452 | rights granted or affirmed under this License. For example, you may 453 | not impose a license fee, royalty, or other charge for exercise of 454 | rights granted under this License, and you may not initiate litigation 455 | (including a cross-claim or counterclaim in a lawsuit) alleging that 456 | any patent claim is infringed by making, using, selling, offering for 457 | sale, or importing the Program or any portion of it. 458 | 459 | 11. Patents. 460 | 461 | A "contributor" is a copyright holder who authorizes use under this 462 | License of the Program or a work on which the Program is based. The 463 | work thus licensed is called the contributor's "contributor version". 464 | 465 | A contributor's "essential patent claims" are all patent claims 466 | owned or controlled by the contributor, whether already acquired or 467 | hereafter acquired, that would be infringed by some manner, permitted 468 | by this License, of making, using, or selling its contributor version, 469 | but do not include claims that would be infringed only as a 470 | consequence of further modification of the contributor version. For 471 | purposes of this definition, "control" includes the right to grant 472 | patent sublicenses in a manner consistent with the requirements of 473 | this License. 474 | 475 | Each contributor grants you a non-exclusive, worldwide, royalty-free 476 | patent license under the contributor's essential patent claims, to 477 | make, use, sell, offer for sale, import and otherwise run, modify and 478 | propagate the contents of its contributor version. 479 | 480 | In the following three paragraphs, a "patent license" is any express 481 | agreement or commitment, however denominated, not to enforce a patent 482 | (such as an express permission to practice a patent or covenant not to 483 | sue for patent infringement). To "grant" such a patent license to a 484 | party means to make such an agreement or commitment not to enforce a 485 | patent against the party. 486 | 487 | If you convey a covered work, knowingly relying on a patent license, 488 | and the Corresponding Source of the work is not available for anyone 489 | to copy, free of charge and under the terms of this License, through a 490 | publicly available network server or other readily accessible means, 491 | then you must either (1) cause the Corresponding Source to be so 492 | available, or (2) arrange to deprive yourself of the benefit of the 493 | patent license for this particular work, or (3) arrange, in a manner 494 | consistent with the requirements of this License, to extend the patent 495 | license to downstream recipients. "Knowingly relying" means you have 496 | actual knowledge that, but for the patent license, your conveying the 497 | covered work in a country, or your recipient's use of the covered work 498 | in a country, would infringe one or more identifiable patents in that 499 | country that you have reason to believe are valid. 500 | 501 | If, pursuant to or in connection with a single transaction or 502 | arrangement, you convey, or propagate by procuring conveyance of, a 503 | covered work, and grant a patent license to some of the parties 504 | receiving the covered work authorizing them to use, propagate, modify 505 | or convey a specific copy of the covered work, then the patent license 506 | you grant is automatically extended to all recipients of the covered 507 | work and works based on it. 508 | 509 | A patent license is "discriminatory" if it does not include within 510 | the scope of its coverage, prohibits the exercise of, or is 511 | conditioned on the non-exercise of one or more of the rights that are 512 | specifically granted under this License. You may not convey a covered 513 | work if you are a party to an arrangement with a third party that is 514 | in the business of distributing software, under which you make payment 515 | to the third party based on the extent of your activity of conveying 516 | the work, and under which the third party grants, to any of the 517 | parties who would receive the covered work from you, a discriminatory 518 | patent license (a) in connection with copies of the covered work 519 | conveyed by you (or copies made from those copies), or (b) primarily 520 | for and in connection with specific products or compilations that 521 | contain the covered work, unless you entered into that arrangement, 522 | or that patent license was granted, prior to 28 March 2007. 523 | 524 | Nothing in this License shall be construed as excluding or limiting 525 | any implied license or other defenses to infringement that may 526 | otherwise be available to you under applicable patent law. 527 | 528 | 12. No Surrender of Others' Freedom. 529 | 530 | If conditions are imposed on you (whether by court order, agreement or 531 | otherwise) that contradict the conditions of this License, they do not 532 | excuse you from the conditions of this License. If you cannot convey a 533 | covered work so as to satisfy simultaneously your obligations under this 534 | License and any other pertinent obligations, then as a consequence you may 535 | not convey it at all. For example, if you agree to terms that obligate you 536 | to collect a royalty for further conveying from those to whom you convey 537 | the Program, the only way you could satisfy both those terms and this 538 | License would be to refrain entirely from conveying the Program. 539 | 540 | 13. Remote Network Interaction; Use with the GNU General Public License. 541 | 542 | Notwithstanding any other provision of this License, if you modify the 543 | Program, your modified version must prominently offer all users 544 | interacting with it remotely through a computer network (if your version 545 | supports such interaction) an opportunity to receive the Corresponding 546 | Source of your version by providing access to the Corresponding Source 547 | from a network server at no charge, through some standard or customary 548 | means of facilitating copying of software. This Corresponding Source 549 | shall include the Corresponding Source for any work covered by version 3 550 | of the GNU General Public License that is incorporated pursuant to the 551 | following paragraph. 552 | 553 | Notwithstanding any other provision of this License, you have 554 | permission to link or combine any covered work with a work licensed 555 | under version 3 of the GNU General Public License into a single 556 | combined work, and to convey the resulting work. The terms of this 557 | License will continue to apply to the part which is the covered work, 558 | but the work with which it is combined will remain governed by version 559 | 3 of the GNU General Public License. 560 | 561 | 14. Revised Versions of this License. 562 | 563 | The Free Software Foundation may publish revised and/or new versions of 564 | the GNU Affero General Public License from time to time. Such new versions 565 | will be similar in spirit to the present version, but may differ in detail to 566 | address new problems or concerns. 567 | 568 | Each version is given a distinguishing version number. If the 569 | Program specifies that a certain numbered version of the GNU Affero General 570 | Public License "or any later version" applies to it, you have the 571 | option of following the terms and conditions either of that numbered 572 | version or of any later version published by the Free Software 573 | Foundation. If the Program does not specify a version number of the 574 | GNU Affero General Public License, you may choose any version ever published 575 | by the Free Software Foundation. 576 | 577 | If the Program specifies that a proxy can decide which future 578 | versions of the GNU Affero General Public License can be used, that proxy's 579 | public statement of acceptance of a version permanently authorizes you 580 | to choose that version for the Program. 581 | 582 | Later license versions may give you additional or different 583 | permissions. However, no additional obligations are imposed on any 584 | author or copyright holder as a result of your choosing to follow a 585 | later version. 586 | 587 | 15. Disclaimer of Warranty. 588 | 589 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 590 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 591 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 592 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 593 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 594 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 595 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 596 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 597 | 598 | 16. Limitation of Liability. 599 | 600 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 601 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 602 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 603 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 604 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 605 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 606 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 607 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 608 | SUCH DAMAGES. 609 | 610 | 17. Interpretation of Sections 15 and 16. 611 | 612 | If the disclaimer of warranty and limitation of liability provided 613 | above cannot be given local legal effect according to their terms, 614 | reviewing courts shall apply local law that most closely approximates 615 | an absolute waiver of all civil liability in connection with the 616 | Program, unless a warranty or assumption of liability accompanies a 617 | copy of the Program in return for a fee. 618 | 619 | END OF TERMS AND CONDITIONS 620 | 621 | How to Apply These Terms to Your New Programs 622 | 623 | If you develop a new program, and you want it to be of the greatest 624 | possible use to the public, the best way to achieve this is to make it 625 | free software which everyone can redistribute and change under these terms. 626 | 627 | To do so, attach the following notices to the program. It is safest 628 | to attach them to the start of each source file to most effectively 629 | state the exclusion of warranty; and each file should have at least 630 | the "copyright" line and a pointer to where the full notice is found. 631 | 632 | 633 | Copyright (C) 634 | 635 | This program is free software: you can redistribute it and/or modify 636 | it under the terms of the GNU Affero General Public License as published by 637 | the Free Software Foundation, either version 3 of the License, or 638 | (at your option) any later version. 639 | 640 | This program is distributed in the hope that it will be useful, 641 | but WITHOUT ANY WARRANTY; without even the implied warranty of 642 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 643 | GNU Affero General Public License for more details. 644 | 645 | You should have received a copy of the GNU Affero General Public License 646 | along with this program. If not, see . 647 | 648 | Also add information on how to contact you by electronic and paper mail. 649 | 650 | If your software can interact with users remotely through a computer 651 | network, you should also make sure that it provides a way for users to 652 | get its source. For example, if your program is a web application, its 653 | interface could display a "Source" link that leads users to an archive 654 | of the code. There are many ways you could offer source, and different 655 | solutions will be better for different programs; see section 13 for the 656 | specific requirements. 657 | 658 | You should also get your employer (if you work as a programmer) or school, 659 | if any, to sign a "copyright disclaimer" for the program, if necessary. 660 | For more information on this, and how to apply and follow the GNU AGPL, see 661 | . -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include ckanext/oauth2/templates * 2 | include README.md 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | OAuth2 CKAN extension 2 | ===================== 3 | 4 | [![Build Status](https://travis-ci.org/conwetlab/ckanext-oauth2.svg?branch=master)](https://travis-ci.org/conwetlab/ckanext-oauth2) 5 | [![Coverage Status](https://coveralls.io/repos/github/conwetlab/ckanext-oauth2/badge.svg?branch=master)](https://coveralls.io/github/conwetlab/ckanext-oauth2?branch=master) 6 | 7 | The OAuth2 extension allows site visitors to login through an OAuth2 server. 8 | 9 | **Note**: This extension is being tested in CKAN 2.6, 2.7 and 2.8. These are therefore considered as the supported versions 10 | 11 | 12 | ## Links 13 | 14 | 1. [Activating & Installing the plugin](https://github.com/conwetlab/ckanext-oauth2/wiki/Activating-and-Installing) 15 | 2. [Starting CKAN over HTTPs](https://github.com/conwetlab/ckanext-oauth2/wiki/Starting-CKAN-over-HTTPs) 16 | 3. [How it works?](https://github.com/conwetlab/ckanext-oauth2/wiki/How-it-works%3F) 17 | 18 | 19 | ## Credits 20 | 21 | Based on the idea proposed by [Etalab](https://github.com/etalab/ckanext-oauth2) 22 | -------------------------------------------------------------------------------- /bin/travis-build.bash: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | function test_connection { 6 | echo "Testing $1 connection" 7 | 8 | attempt_counter=0 9 | max_attempts=50 10 | 11 | until $(curl --output /dev/null --silent --head --fail --insecure $2); do 12 | if [ ${attempt_counter} -eq ${max_attempts} ];then 13 | echo "Max attempts reached" 14 | exit 1 15 | fi 16 | 17 | attempt_counter=$(($attempt_counter+1)) 18 | sleep 5 19 | done 20 | 21 | echo "$1 connection, OK" 22 | } 23 | 24 | echo "This is travis-build.bash..." 25 | 26 | echo "Installing the packages that CKAN requires..." 27 | sudo apt-get clean 28 | sudo rm -r /var/lib/apt/lists/* 29 | 30 | sudo apt-get update -qq 31 | sudo apt-get install solr-jetty 32 | 33 | echo "Installing CKAN and its Python dependencies..." 34 | git clone https://github.com/ckan/ckan 35 | cd ckan 36 | git checkout ckan-$CKANVERSION 37 | python setup.py develop 38 | 39 | sed -i "s|psycopg2==2.4.5|psycopg2==2.7.1|g" requirements.txt 40 | 41 | pip install -r requirements.txt --allow-all-external 42 | pip install -r dev-requirements.txt --allow-all-external 43 | cd - 44 | 45 | echo "Setting up Solr..." 46 | # solr is multicore for tests on ckan master now, but it's easier to run tests 47 | # on Travis single-core still. 48 | # see https://github.com/ckan/ckan/issues/2972 49 | sed -i -e 's/solr_url.*/solr_url = http:\/\/127.0.0.1:8983\/solr/' ckan/test-core.ini 50 | printf "NO_START=0\nJETTY_HOST=127.0.0.1\nJETTY_PORT=8983\nJAVA_HOME=$JAVA_HOME" | sudo tee /etc/default/jetty 51 | sudo cp ckan/ckan/config/solr/schema.xml /etc/solr/conf/schema.xml 52 | sudo service jetty restart 53 | 54 | echo "Creating the PostgreSQL user and database..." 55 | sudo -u postgres psql -c "CREATE USER ckan_default WITH PASSWORD 'pass';" 56 | sudo -u postgres psql -c "CREATE USER datastore_default WITH PASSWORD 'pass';" 57 | sudo -u postgres psql -c "CREATE DATABASE ckan_test WITH OWNER ckan_default;" 58 | sudo -u postgres psql -c "CREATE DATABASE datastore_test WITH OWNER ckan_default;" 59 | 60 | echo "Initialising the database..." 61 | cd ckan 62 | paster db init -c test-core.ini 63 | cd - 64 | 65 | echo "Installing ckanext-oauth2 and its requirements..." 66 | python setup.py develop 67 | 68 | if [ "$INTEGRATION_TEST" = "true" ]; then 69 | sudo sh -c 'echo "\n[ SAN ]\nsubjectAltName=DNS:localhost" >> /etc/ssl/openssl.cnf' 70 | sudo openssl req -new -newkey rsa:2048 -days 3650 -nodes -x509 \ 71 | -subj '/O=API Umbrella/CN=localhost' \ 72 | -keyout /etc/ssl/self_signed.key -out /usr/local/share/ca-certificates/self_signed.crt \ 73 | -reqexts SAN -extensions SAN 74 | 75 | sudo update-ca-certificates 76 | export REQUESTS_CA_BUNDLE="/etc/ssl/certs/ca-certificates.crt" 77 | 78 | docker network create main 79 | cd ${TRAVIS_BUILD_DIR}/ci 80 | 81 | docker-compose up -d 82 | cd .. 83 | 84 | # Wait until idm is ready 85 | test_connection 'KeyRock' http://localhost:3000 86 | fi 87 | 88 | echo "travis-build.bash is done." 89 | -------------------------------------------------------------------------------- /bin/travis-run.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [ "${INTEGRATION_TEST}" = "true" ]; then 4 | xvfb-run --server-args="-screen 0 1280x1024x24" python setup.py nosetests 5 | else 6 | python setup.py nosetests 7 | fi 8 | -------------------------------------------------------------------------------- /ci/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | networks: 3 | main: 4 | external: true 5 | 6 | services: 7 | mysql: 8 | image: mysql:5.7 9 | networks: 10 | main: 11 | aliases: 12 | - mysql.idm.docker 13 | environment: 14 | - MYSQL_ROOT_PASSWORD=idm 15 | 16 | fiware-idm: 17 | image: fiware/idm:7.6.0 18 | ports: 19 | - "3000:3000" 20 | - "443:443" 21 | networks: 22 | main: 23 | aliases: 24 | - idm.docker 25 | #volumes: 26 | # - /etc/ssl/self_signed.key:/opt/fiware-idm/certs/self_signed.key:ro 27 | # - /usr/local/share/ca-certificates/self_signed.crt:/opt/fiware-idm/certs/self_signed.crt:ro 28 | environment: 29 | - IDM_DB_HOST=mysql.idm.docker 30 | -------------------------------------------------------------------------------- /ci/idm-config.js: -------------------------------------------------------------------------------- 1 | var config = {}; 2 | 3 | config.host = 'https://localhost'; 4 | config.port = 3000 5 | 6 | // HTTPS enable 7 | config.https = { 8 | enabled: true, 9 | cert_file: 'certs/self_signed.crt', 10 | key_file: 'certs/self_signed.key', 11 | port: 443 12 | }; 13 | 14 | // Config email list type to use domain filtering 15 | config.email_list_type = null // whitelist or blacklist 16 | 17 | // Secret for user sessions in web 18 | config.session = { 19 | secret: 'nodejs_idm', // Must be changed 20 | expires: 60 * 60 * 1000 // 1 hour 21 | } 22 | 23 | // Key to encrypt user passwords 24 | config.password_encryption = { 25 | key: 'nodejs_idm' // Must be changed 26 | } 27 | 28 | // Config oauth2 parameters 29 | config.oauth2 = { 30 | authorization_code_lifetime: 5 * 60, // Five minutes 31 | access_token_lifetime: 60 * 60, // One hour 32 | refresh_token_lifetime: 60 * 60 * 24 * 14 // Two weeks 33 | } 34 | 35 | // Config api parameters 36 | config.api = { 37 | token_lifetime: 60 * 60 // One hour 38 | } 39 | 40 | // Enable authzforce 41 | config.authorization = { 42 | level: 'basic', 43 | authzforce: { 44 | enabled: false, 45 | host: '', 46 | port: 8080 47 | } 48 | } 49 | 50 | var database_host = (process.env.DATABASE_HOST) ? process.env.DATABASE_HOST : 'localhost' 51 | 52 | // Database info 53 | config.database = { 54 | host: database_host, // default: 'localhost' 55 | password: 'idm', // default: 'idm' 56 | username: 'root', // default: 'root' 57 | database: 'idm', // default: 'idm' 58 | dialect: 'mysql', // default: 'mysql' 59 | port: undefined // default: undefined (which means that the port 60 | // is the default for each dialect) 61 | }; 62 | 63 | // External user authentication 64 | config.external_auth = { 65 | enabled: false, 66 | authentication_driver: 'custom_authentication_driver', 67 | database: { 68 | host: 'localhost', 69 | database: 'db_name', 70 | username: 'db_user', 71 | password: 'db_pass', 72 | user_table: 'user', 73 | dialect: 'mysql', 74 | port: undefined 75 | } 76 | } 77 | 78 | // Email configuration 79 | config.mail = { 80 | host: 'localhost', 81 | port: 25, 82 | from: 'noreply@localhost' 83 | } 84 | 85 | 86 | // Config themes 87 | config.site = { 88 | title: 'Identity Manager', 89 | theme: 'default' 90 | }; 91 | 92 | // Config eIDAs Authentication 93 | config.eidas = { 94 | enabled: false, 95 | gateway_host: 'localhost', 96 | idp_host: 'https://se-eidas.redsara.es/EidasNode/ServiceProvider', 97 | metadata_expiration: 60 * 60 * 24 * 365 // One year 98 | } 99 | 100 | module.exports = config; 101 | -------------------------------------------------------------------------------- /ckanext/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright (c) 2014 CoNWeT Lab., Universidad Politécnica de Madrid 4 | 5 | # This file is part of OAuth2 CKAN Extension. 6 | 7 | # OAuth2 CKAN Extension is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU Affero General Public License as published by 9 | # the Free Software Foundation, either version 3 of the License, or 10 | # (at your option) any later version. 11 | 12 | # OAuth2 CKAN Extension is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU Affero General Public License for more details. 16 | 17 | # You should have received a copy of the GNU Affero General Public License 18 | # along with OAuth2 CKAN Extension. If not, see . 19 | 20 | # this is a namespace package 21 | try: 22 | import pkg_resources 23 | pkg_resources.declare_namespace(__name__) 24 | except ImportError: 25 | import pkgutil 26 | __path__ = pkgutil.extend_path(__path__, __name__) 27 | -------------------------------------------------------------------------------- /ckanext/oauth2/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright (c) 2014-2018 CoNWeT Lab., Universidad Politécnica de Madrid 4 | 5 | # This file is part of OAuth2 CKAN Extension. 6 | 7 | # OAuth2 CKAN Extension is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU Affero General Public License as published by 9 | # the Free Software Foundation, either version 3 of the License, or 10 | # (at your option) any later version. 11 | 12 | # OAuth2 CKAN Extension is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU Affero General Public License for more details. 16 | 17 | # You should have received a copy of the GNU Affero General Public License 18 | # along with OAuth2 CKAN Extension. If not, see . 19 | 20 | __version__ = '0.7.0' 21 | __description__ = 'OAuth2 support for CKAN' 22 | -------------------------------------------------------------------------------- /ckanext/oauth2/constants.py: -------------------------------------------------------------------------------- 1 | CAME_FROM_FIELD = 'came_from' 2 | INITIAL_PAGE = '/dashboard' 3 | REDIRECT_URL = 'oauth2/callback' 4 | -------------------------------------------------------------------------------- /ckanext/oauth2/controller.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright (c) 2014 CoNWeT Lab., Universidad Politécnica de Madrid 4 | # Copyright (c) 2018 Future Internet Consulting and Development Solutions S.L. 5 | 6 | # This file is part of OAuth2 CKAN Extension. 7 | 8 | # OAuth2 CKAN Extension is free software: you can redistribute it and/or modify 9 | # it under the terms of the GNU Affero General Public License as published by 10 | # the Free Software Foundation, either version 3 of the License, or 11 | # (at your option) any later version. 12 | 13 | # OAuth2 CKAN Extension is distributed in the hope that it will be useful, 14 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 | # GNU Affero General Public License for more details. 17 | 18 | # You should have received a copy of the GNU Affero General Public License 19 | # along with OAuth2 CKAN Extension. If not, see . 20 | 21 | from __future__ import unicode_literals 22 | 23 | import logging 24 | import constants 25 | 26 | from ckan.common import session 27 | import ckan.lib.helpers as helpers 28 | import ckan.lib.base as base 29 | import ckan.plugins.toolkit as toolkit 30 | import oauth2 31 | 32 | from ckanext.oauth2.plugin import _get_previous_page 33 | 34 | 35 | log = logging.getLogger(__name__) 36 | 37 | 38 | class OAuth2Controller(base.BaseController): 39 | 40 | def __init__(self): 41 | self.oauth2helper = oauth2.OAuth2Helper() 42 | 43 | def login(self): 44 | log.debug('login') 45 | 46 | # Log in attemps are fired when the user is not logged in and they click 47 | # on the log in button 48 | 49 | # Get the page where the user was when the loggin attemp was fired 50 | # When the user is not logged in, he/she should be redirected to the dashboard when 51 | # the system cannot get the previous page 52 | came_from_url = _get_previous_page(constants.INITIAL_PAGE) 53 | 54 | self.oauth2helper.challenge(came_from_url) 55 | 56 | def callback(self): 57 | try: 58 | token = self.oauth2helper.get_token() 59 | user_name = self.oauth2helper.identify(token) 60 | self.oauth2helper.remember(user_name) 61 | self.oauth2helper.update_token(user_name, token) 62 | self.oauth2helper.redirect_from_callback() 63 | except Exception as e: 64 | 65 | session.save() 66 | 67 | # If the callback is called with an error, we must show the message 68 | error_description = toolkit.request.GET.get('error_description') 69 | if not error_description: 70 | if e.message: 71 | error_description = e.message 72 | elif hasattr(e, 'description') and e.description: 73 | error_description = e.description 74 | elif hasattr(e, 'error') and e.error: 75 | error_description = e.error 76 | else: 77 | error_description = type(e).__name__ 78 | 79 | toolkit.response.status_int = 302 80 | redirect_url = oauth2.get_came_from(toolkit.request.params.get('state')) 81 | redirect_url = '/' if redirect_url == constants.INITIAL_PAGE else redirect_url 82 | toolkit.response.location = redirect_url 83 | helpers.flash_error(error_description) 84 | -------------------------------------------------------------------------------- /ckanext/oauth2/db.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright (c) 2014 CoNWeT Lab., Universidad Politécnica de Madrid 4 | 5 | # This file is part of OAuth2 CKAN Extension. 6 | 7 | # OAuth2 CKAN Extension is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU Affero General Public License as published by 9 | # the Free Software Foundation, either version 3 of the License, or 10 | # (at your option) any later version. 11 | 12 | # OAuth2 CKAN Extension is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU Affero General Public License for more details. 16 | 17 | # You should have received a copy of the GNU Affero General Public License 18 | # along with OAuth2 CKAN Extension. If not, see . 19 | 20 | import sqlalchemy as sa 21 | 22 | UserToken = None 23 | 24 | 25 | def init_db(model): 26 | 27 | global UserToken 28 | if UserToken is None: 29 | 30 | class _UserToken(model.DomainObject): 31 | 32 | @classmethod 33 | def by_user_name(cls, user_name): 34 | return model.Session.query(cls).filter_by(user_name=user_name).first() 35 | 36 | UserToken = _UserToken 37 | 38 | user_token_table = sa.Table('user_token', model.meta.metadata, 39 | sa.Column('user_name', sa.types.UnicodeText, primary_key=True), 40 | sa.Column('access_token', sa.types.UnicodeText), 41 | sa.Column('token_type', sa.types.UnicodeText), 42 | sa.Column('refresh_token', sa.types.UnicodeText), 43 | sa.Column('expires_in', sa.types.UnicodeText) 44 | ) 45 | 46 | # Create the table only if it does not exist 47 | user_token_table.create(checkfirst=True) 48 | 49 | model.meta.mapper(UserToken, user_token_table) 50 | -------------------------------------------------------------------------------- /ckanext/oauth2/oauth2.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright (c) 2014 CoNWeT Lab., Universidad Politécnica de Madrid 4 | # Copyright (c) 2018 Future Internet Consulting and Development Solutions S.L. 5 | 6 | # This file is part of OAuth2 CKAN Extension. 7 | 8 | # OAuth2 CKAN Extension is free software: you can redistribute it and/or modify 9 | # it under the terms of the GNU Affero General Public License as published by 10 | # the Free Software Foundation, either version 3 of the License, or 11 | # (at your option) any later version. 12 | 13 | # OAuth2 CKAN Extension is distributed in the hope that it will be useful, 14 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 | # GNU Affero General Public License for more details. 17 | 18 | # You should have received a copy of the GNU Affero General Public License 19 | # along with OAuth2 CKAN Extension. If not, see . 20 | 21 | 22 | from __future__ import unicode_literals 23 | 24 | import base64 25 | import ckan.model as model 26 | import db 27 | import json 28 | import logging 29 | from six.moves.urllib.parse import urljoin 30 | import os 31 | 32 | from base64 import b64encode, b64decode 33 | from ckan.plugins import toolkit 34 | from oauthlib.oauth2 import InsecureTransportError 35 | import requests 36 | from requests_oauthlib import OAuth2Session 37 | import six 38 | 39 | import jwt 40 | 41 | import constants 42 | 43 | 44 | log = logging.getLogger(__name__) 45 | 46 | 47 | def generate_state(url): 48 | return b64encode(bytes(json.dumps({constants.CAME_FROM_FIELD: url}))) 49 | 50 | 51 | def get_came_from(state): 52 | return json.loads(b64decode(state)).get(constants.CAME_FROM_FIELD, '/') 53 | 54 | 55 | REQUIRED_CONF = ("authorization_endpoint", "token_endpoint", "client_id", "client_secret", "profile_api_url", "profile_api_user_field", "profile_api_mail_field") 56 | 57 | 58 | class OAuth2Helper(object): 59 | 60 | def __init__(self): 61 | 62 | self.verify_https = os.environ.get('OAUTHLIB_INSECURE_TRANSPORT', '') == "" 63 | if self.verify_https and os.environ.get("REQUESTS_CA_BUNDLE", "").strip() != "": 64 | self.verify_https = os.environ["REQUESTS_CA_BUNDLE"].strip() 65 | 66 | self.jwt_enable = six.text_type(os.environ.get('CKAN_OAUTH2_JWT_ENABLE', toolkit.config.get('ckan.oauth2.jwt.enable',''))).strip().lower() in ("true", "1", "on") 67 | 68 | self.legacy_idm = six.text_type(os.environ.get('CKAN_OAUTH2_LEGACY_IDM', toolkit.config.get('ckan.oauth2.legacy_idm', ''))).strip().lower() in ("true", "1", "on") 69 | self.authorization_endpoint = six.text_type(os.environ.get('CKAN_OAUTH2_AUTHORIZATION_ENDPOINT', toolkit.config.get('ckan.oauth2.authorization_endpoint', ''))).strip() 70 | self.token_endpoint = six.text_type(os.environ.get('CKAN_OAUTH2_TOKEN_ENDPOINT', toolkit.config.get('ckan.oauth2.token_endpoint', ''))).strip() 71 | self.profile_api_url = six.text_type(os.environ.get('CKAN_OAUTH2_PROFILE_API_URL', toolkit.config.get('ckan.oauth2.profile_api_url', ''))).strip() 72 | self.client_id = six.text_type(os.environ.get('CKAN_OAUTH2_CLIENT_ID', toolkit.config.get('ckan.oauth2.client_id', ''))).strip() 73 | self.client_secret = six.text_type(os.environ.get('CKAN_OAUTH2_CLIENT_SECRET', toolkit.config.get('ckan.oauth2.client_secret', ''))).strip() 74 | self.scope = six.text_type(os.environ.get('CKAN_OAUTH2_SCOPE', toolkit.config.get('ckan.oauth2.scope', ''))).strip() 75 | self.rememberer_name = six.text_type(os.environ.get('CKAN_OAUTH2_REMEMBER_NAME', toolkit.config.get('ckan.oauth2.rememberer_name', 'auth_tkt'))).strip() 76 | self.profile_api_user_field = six.text_type(os.environ.get('CKAN_OAUTH2_PROFILE_API_USER_FIELD', toolkit.config.get('ckan.oauth2.profile_api_user_field', ''))).strip() 77 | self.profile_api_fullname_field = six.text_type(os.environ.get('CKAN_OAUTH2_PROFILE_API_FULLNAME_FIELD', toolkit.config.get('ckan.oauth2.profile_api_fullname_field', ''))).strip() 78 | self.profile_api_mail_field = six.text_type(os.environ.get('CKAN_OAUTH2_PROFILE_API_MAIL_FIELD', toolkit.config.get('ckan.oauth2.profile_api_mail_field', ''))).strip() 79 | self.profile_api_groupmembership_field = six.text_type(os.environ.get('CKAN_OAUTH2_PROFILE_API_GROUPMEMBERSHIP_FIELD', toolkit.config.get('ckan.oauth2.profile_api_groupmembership_field', ''))).strip() 80 | self.sysadmin_group_name = six.text_type(os.environ.get('CKAN_OAUTH2_SYSADMIN_GROUP_NAME', toolkit.config.get('ckan.oauth2.sysadmin_group_name', ''))).strip() 81 | 82 | self.redirect_uri = urljoin(urljoin(toolkit.config.get('ckan.site_url', 'http://localhost:5000'), toolkit.config.get('ckan.root_path')), constants.REDIRECT_URL) 83 | 84 | # Init db 85 | db.init_db(model) 86 | 87 | missing = [key for key in REQUIRED_CONF if getattr(self, key, "") == ""] 88 | if missing: 89 | raise ValueError("Missing required oauth2 conf: %s" % ", ".join(missing)) 90 | elif self.scope == "": 91 | self.scope = None 92 | 93 | def challenge(self, came_from_url): 94 | # This function is called by the log in function when the user is not logged in 95 | state = generate_state(came_from_url) 96 | oauth = OAuth2Session(self.client_id, redirect_uri=self.redirect_uri, scope=self.scope, state=state) 97 | auth_url, _ = oauth.authorization_url(self.authorization_endpoint) 98 | log.debug('Challenge: Redirecting challenge to page {0}'.format(auth_url)) 99 | # CKAN 2.6 only supports bytes 100 | return toolkit.redirect_to(auth_url.encode('utf-8')) 101 | 102 | def get_token(self): 103 | oauth = OAuth2Session(self.client_id, redirect_uri=self.redirect_uri, scope=self.scope) 104 | 105 | # Just because of FIWARE Authentication 106 | headers = { 107 | 'Accept': 'application/json', 108 | 'Content-Type': 'application/x-www-form-urlencoded', 109 | } 110 | 111 | if self.legacy_idm: 112 | # This is only required for Keyrock v6 and v5 113 | headers['Authorization'] = 'Basic %s' % base64.urlsafe_b64encode( 114 | '%s:%s' % (self.client_id, self.client_secret) 115 | ) 116 | 117 | try: 118 | token = oauth.fetch_token(self.token_endpoint, 119 | headers=headers, 120 | client_secret=self.client_secret, 121 | authorization_response=toolkit.request.url, 122 | verify=self.verify_https) 123 | except requests.exceptions.SSLError as e: 124 | # TODO search a better way to detect invalid certificates 125 | if "verify failed" in six.text_type(e): 126 | raise InsecureTransportError() 127 | else: 128 | raise 129 | 130 | return token 131 | 132 | def identify(self, token): 133 | 134 | if self.jwt_enable: 135 | 136 | access_token = bytes(token['access_token']) 137 | user_data = jwt.decode(access_token, verify=False) 138 | user = self.user_json(user_data) 139 | else: 140 | 141 | try: 142 | if self.legacy_idm: 143 | profile_response = requests.get(self.profile_api_url + '?access_token=%s' % token['access_token'], verify=self.verify_https) 144 | else: 145 | oauth = OAuth2Session(self.client_id, token=token) 146 | profile_response = oauth.get(self.profile_api_url, verify=self.verify_https) 147 | 148 | except requests.exceptions.SSLError as e: 149 | # TODO search a better way to detect invalid certificates 150 | if "verify failed" in six.text_type(e): 151 | raise InsecureTransportError() 152 | else: 153 | raise 154 | 155 | # Token can be invalid 156 | if not profile_response.ok: 157 | error = profile_response.json() 158 | if error.get('error', '') == 'invalid_token': 159 | raise ValueError(error.get('error_description')) 160 | else: 161 | profile_response.raise_for_status() 162 | else: 163 | user_data = profile_response.json() 164 | user = self.user_json(user_data) 165 | 166 | # Save the user in the database 167 | model.Session.add(user) 168 | model.Session.commit() 169 | model.Session.remove() 170 | 171 | return user.name 172 | 173 | def user_json(self, user_data): 174 | email = user_data[self.profile_api_mail_field] 175 | user_name = user_data[self.profile_api_user_field] 176 | 177 | # In CKAN can exists more than one user associated with the same email 178 | # Some providers, like Google and FIWARE only allows one account per email 179 | user = None 180 | users = model.User.by_email(email) 181 | if len(users) == 1: 182 | user = users[0] 183 | 184 | # If the user does not exist, we have to create it... 185 | if user is None: 186 | user = model.User(email=email) 187 | 188 | # Now we update his/her user_name with the one provided by the OAuth2 service 189 | # In the future, users will be obtained based on this field 190 | user.name = user_name 191 | 192 | # Update fullname 193 | if self.profile_api_fullname_field != "" and self.profile_api_fullname_field in user_data: 194 | user.fullname = user_data[self.profile_api_fullname_field] 195 | 196 | # Update sysadmin status 197 | if self.profile_api_groupmembership_field != "" and self.profile_api_groupmembership_field in user_data: 198 | user.sysadmin = self.sysadmin_group_name in user_data[self.profile_api_groupmembership_field] 199 | 200 | return user 201 | 202 | def _get_rememberer(self, environ): 203 | plugins = environ.get('repoze.who.plugins', {}) 204 | return plugins.get(self.rememberer_name) 205 | 206 | def remember(self, user_name): 207 | ''' 208 | Remember the authenticated identity. 209 | 210 | This method simply delegates to another IIdentifier plugin if configured. 211 | ''' 212 | log.debug('Repoze OAuth remember') 213 | environ = toolkit.request.environ 214 | rememberer = self._get_rememberer(environ) 215 | identity = {'repoze.who.userid': user_name} 216 | headers = rememberer.remember(environ, identity) 217 | for header, value in headers: 218 | toolkit.response.headers.add(header, value) 219 | 220 | def redirect_from_callback(self): 221 | '''Redirect to the callback URL after a successful authentication.''' 222 | state = toolkit.request.params.get('state') 223 | came_from = get_came_from(state) 224 | toolkit.response.status = 302 225 | toolkit.response.location = came_from 226 | 227 | def get_stored_token(self, user_name): 228 | user_token = db.UserToken.by_user_name(user_name=user_name) 229 | if user_token: 230 | return { 231 | 'access_token': user_token.access_token, 232 | 'refresh_token': user_token.refresh_token, 233 | 'expires_in': user_token.expires_in, 234 | 'token_type': user_token.token_type 235 | } 236 | 237 | def update_token(self, user_name, token): 238 | 239 | user_token = db.UserToken.by_user_name(user_name=user_name) 240 | # Create the user if it does not exist 241 | if not user_token: 242 | user_token = db.UserToken() 243 | user_token.user_name = user_name 244 | # Save the new token 245 | user_token.access_token = token['access_token'] 246 | user_token.token_type = token['token_type'] 247 | user_token.refresh_token = token.get('refresh_token') 248 | if 'expires_in' in token: 249 | user_token.expires_in = token['expires_in'] 250 | else: 251 | access_token = jwt.decode(user_token.access_token, verify=False) 252 | user_token.expires_in = access_token['exp'] - access_token['iat'] 253 | 254 | model.Session.add(user_token) 255 | model.Session.commit() 256 | 257 | def refresh_token(self, user_name): 258 | token = self.get_stored_token(user_name) 259 | if token: 260 | client = OAuth2Session(self.client_id, token=token, scope=self.scope) 261 | try: 262 | token = client.refresh_token(self.token_endpoint, client_secret=self.client_secret, client_id=self.client_id, verify=self.verify_https) 263 | except requests.exceptions.SSLError as e: 264 | # TODO search a better way to detect invalid certificates 265 | if "verify failed" in six.text_type(e): 266 | raise InsecureTransportError() 267 | else: 268 | raise 269 | self.update_token(user_name, token) 270 | log.info('Token for user %s has been updated properly' % user_name) 271 | return token 272 | else: 273 | log.warn('User %s has no refresh token' % user_name) 274 | -------------------------------------------------------------------------------- /ckanext/oauth2/plugin.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright (c) 2014 CoNWeT Lab., Universidad Politécnica de Madrid 4 | # Copyright (c) 2018 Future Internet Consulting and Development Solutions S.L. 5 | 6 | # This file is part of OAuth2 CKAN Extension. 7 | 8 | # OAuth2 CKAN Extension is free software: you can redistribute it and/or modify 9 | # it under the terms of the GNU Affero General Public License as published by 10 | # the Free Software Foundation, either version 3 of the License, or 11 | # (at your option) any later version. 12 | 13 | # OAuth2 CKAN Extension is distributed in the hope that it will be useful, 14 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 | # GNU Affero General Public License for more details. 17 | 18 | # You should have received a copy of the GNU Affero General Public License 19 | # along with OAuth2 CKAN Extension. If not, see . 20 | 21 | from __future__ import unicode_literals 22 | 23 | import logging 24 | import oauth2 25 | import os 26 | 27 | from functools import partial 28 | from ckan import plugins 29 | from ckan.common import g 30 | from ckan.plugins import toolkit 31 | from urlparse import urlparse 32 | 33 | log = logging.getLogger(__name__) 34 | 35 | 36 | def _no_permissions(context, msg): 37 | user = context['user'] 38 | return {'success': False, 'msg': msg.format(user=user)} 39 | 40 | 41 | @toolkit.auth_sysadmins_check 42 | def user_create(context, data_dict): 43 | msg = toolkit._('Users cannot be created.') 44 | return _no_permissions(context, msg) 45 | 46 | 47 | @toolkit.auth_sysadmins_check 48 | def user_update(context, data_dict): 49 | msg = toolkit._('Users cannot be edited.') 50 | return _no_permissions(context, msg) 51 | 52 | 53 | @toolkit.auth_sysadmins_check 54 | def user_reset(context, data_dict): 55 | msg = toolkit._('Users cannot reset passwords.') 56 | return _no_permissions(context, msg) 57 | 58 | 59 | @toolkit.auth_sysadmins_check 60 | def request_reset(context, data_dict): 61 | msg = toolkit._('Users cannot reset passwords.') 62 | return _no_permissions(context, msg) 63 | 64 | 65 | def _get_previous_page(default_page): 66 | if 'came_from' not in toolkit.request.params: 67 | came_from_url = toolkit.request.headers.get('Referer', default_page) 68 | else: 69 | came_from_url = toolkit.request.params.get('came_from', default_page) 70 | 71 | came_from_url_parsed = urlparse(came_from_url) 72 | 73 | # Avoid redirecting users to external hosts 74 | if came_from_url_parsed.netloc != '' and came_from_url_parsed.netloc != toolkit.request.host: 75 | came_from_url = default_page 76 | 77 | # When a user is being logged and REFERER == HOME or LOGOUT_PAGE 78 | # he/she must be redirected to the dashboard 79 | pages = ['/', '/user/logged_out_redirect'] 80 | if came_from_url_parsed.path in pages: 81 | came_from_url = default_page 82 | 83 | return came_from_url 84 | 85 | 86 | class OAuth2Plugin(plugins.SingletonPlugin): 87 | 88 | plugins.implements(plugins.IAuthenticator, inherit=True) 89 | plugins.implements(plugins.IAuthFunctions, inherit=True) 90 | plugins.implements(plugins.IRoutes, inherit=True) 91 | plugins.implements(plugins.IConfigurer) 92 | 93 | def __init__(self, name=None): 94 | '''Store the OAuth 2 client configuration''' 95 | log.debug('Init OAuth2 extension') 96 | 97 | self.oauth2helper = oauth2.OAuth2Helper() 98 | 99 | def before_map(self, m): 100 | log.debug('Setting up the redirections to the OAuth2 service') 101 | 102 | m.connect('/user/login', 103 | controller='ckanext.oauth2.controller:OAuth2Controller', 104 | action='login') 105 | 106 | # We need to handle petitions received to the Callback URL 107 | # since some error can arise and we need to process them 108 | m.connect('/oauth2/callback', 109 | controller='ckanext.oauth2.controller:OAuth2Controller', 110 | action='callback') 111 | 112 | # Redirect the user to the OAuth service register page 113 | if self.register_url: 114 | m.redirect('/user/register', self.register_url) 115 | 116 | # Redirect the user to the OAuth service reset page 117 | if self.reset_url: 118 | m.redirect('/user/reset', self.reset_url) 119 | 120 | # Redirect the user to the OAuth service reset page 121 | if self.edit_url: 122 | m.redirect('/user/edit/{user}', self.edit_url) 123 | 124 | return m 125 | 126 | def identify(self): 127 | log.debug('identify') 128 | 129 | def _refresh_and_save_token(user_name): 130 | new_token = self.oauth2helper.refresh_token(user_name) 131 | if new_token: 132 | toolkit.c.usertoken = new_token 133 | 134 | environ = toolkit.request.environ 135 | apikey = toolkit.request.headers.get(self.authorization_header, '') 136 | user_name = None 137 | 138 | if self.authorization_header == "authorization": 139 | if apikey.startswith('Bearer '): 140 | apikey = apikey[7:].strip() 141 | else: 142 | apikey = '' 143 | 144 | # This API Key is not the one of CKAN, it's the one provided by the OAuth2 Service 145 | if apikey: 146 | try: 147 | token = {'access_token': apikey} 148 | user_name = self.oauth2helper.identify(token) 149 | except Exception: 150 | pass 151 | 152 | # If the authentication via API fails, we can still log in the user using session. 153 | if user_name is None and 'repoze.who.identity' in environ: 154 | user_name = environ['repoze.who.identity']['repoze.who.userid'] 155 | log.info('User %s logged using session' % user_name) 156 | 157 | # If we have been able to log in the user (via API or Session) 158 | if user_name: 159 | g.user = user_name 160 | toolkit.c.user = user_name 161 | toolkit.c.usertoken = self.oauth2helper.get_stored_token(user_name) 162 | toolkit.c.usertoken_refresh = partial(_refresh_and_save_token, user_name) 163 | else: 164 | g.user = None 165 | log.warn('The user is not currently logged...') 166 | 167 | def get_auth_functions(self): 168 | # we need to prevent some actions being authorized. 169 | return { 170 | 'user_create': user_create, 171 | 'user_update': user_update, 172 | 'user_reset': user_reset, 173 | 'request_reset': request_reset 174 | } 175 | 176 | def update_config(self, config): 177 | # Update our configuration 178 | self.register_url = os.environ.get("CKAN_OAUTH2_REGISTER_URL", config.get('ckan.oauth2.register_url', None)) 179 | self.reset_url = os.environ.get("CKAN_OAUTH2_RESET_URL", config.get('ckan.oauth2.reset_url', None)) 180 | self.edit_url = os.environ.get("CKAN_OAUTH2_EDIT_URL", config.get('ckan.oauth2.edit_url', None)) 181 | self.authorization_header = os.environ.get("CKAN_OAUTH2_AUTHORIZATION_HEADER", config.get('ckan.oauth2.authorization_header', 'Authorization')).lower() 182 | 183 | # Add this plugin's templates dir to CKAN's extra_template_paths, so 184 | # that CKAN will use this plugin's custom templates. 185 | plugins.toolkit.add_template_directory(config, 'templates') 186 | -------------------------------------------------------------------------------- /ckanext/oauth2/templates/header.html: -------------------------------------------------------------------------------- 1 | {% ckan_extends %} 2 | 3 | {% if header_account_notlogged is defined %} 4 | {% block header_account_notlogged %} 5 |
  • {% link_for _('Log in'), controller='user', action='login' %}
  • 6 |
  • {% link_for _('Register'), controller='user', action='register', class_='sub' %}
  • 7 | {% endblock %} 8 | {% endif %} -------------------------------------------------------------------------------- /ckanext/oauth2/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/conwetlab/ckanext-oauth2/e68dd2664229b7563d77b2c8fc869fe57b747c88/ckanext/oauth2/tests/__init__.py -------------------------------------------------------------------------------- /ckanext/oauth2/tests/test_controller.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright (c) 2014 CoNWeT Lab., Universidad Politécnica de Madrid 4 | # Copyright (c) 2018 Future Internet Consulting and Development Solutions S.L. 5 | 6 | # This file is part of OAuth2 CKAN Extension. 7 | 8 | # OAuth2 CKAN Extension is free software: you can redistribute it and/or modify 9 | # it under the terms of the GNU Affero General Public License as published by 10 | # the Free Software Foundation, either version 3 of the License, or 11 | # (at your option) any later version. 12 | 13 | # OAuth2 CKAN Extension is distributed in the hope that it will be useful, 14 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 | # GNU Affero General Public License for more details. 17 | 18 | # You should have received a copy of the GNU Affero General Public License 19 | # along with OAuth2 CKAN Extension. If not, see . 20 | 21 | from base64 import b64decode, b64encode 22 | import unittest 23 | import json 24 | 25 | from mock import MagicMock 26 | from parameterized import parameterized 27 | 28 | from ckanext.oauth2 import controller, plugin 29 | 30 | 31 | RETURNED_STATUS = 302 32 | EXAMPLE_FLASH = 'This is a test' 33 | EXCEPTION_MSG = 'Invalid' 34 | CAME_FROM_FIELD = 'came_from' 35 | 36 | 37 | class CompleteException(Exception): 38 | description = 'Exception description' 39 | error = 'Exception error' 40 | 41 | 42 | class ErrorException(Exception): 43 | error = 'Exception error 2' 44 | 45 | 46 | class VoidException(Exception): 47 | pass 48 | 49 | 50 | class OAuth2PluginTest(unittest.TestCase): 51 | 52 | def setUp(self): 53 | # Save the response, the request and the helper functions and mock them 54 | self._helpers = controller.helpers 55 | controller.helpers = MagicMock() 56 | 57 | self._oauth2 = controller.oauth2 58 | controller.oauth2 = MagicMock() 59 | 60 | self._toolkit_controller = controller.toolkit 61 | self._toolkit_plugin = plugin.toolkit 62 | plugin.toolkit = controller.toolkit = MagicMock() 63 | 64 | self.__session = controller.session 65 | controller.session = MagicMock() 66 | 67 | self.controller = controller.OAuth2Controller() 68 | 69 | def tearDown(self): 70 | # Unmock the function 71 | controller.helpers = self._helpers 72 | controller.oauth2 = self._oauth2 73 | controller.toolkit = self._toolkit_controller 74 | controller.session = self.__session 75 | plugin.toolkit = self._toolkit_plugin 76 | 77 | def generate_state(self, url): 78 | return b64encode(bytes(json.dumps({CAME_FROM_FIELD: url}))) 79 | 80 | def get_came_from(self, state): 81 | return json.loads(b64decode(state)).get(CAME_FROM_FIELD, '/') 82 | 83 | def test_callback_no_errors(self): 84 | oauth2Helper = controller.oauth2.OAuth2Helper.return_value 85 | 86 | token = 'TOKEN' 87 | user_id = 'user_id' 88 | oauth2Helper.get_token.return_value = token 89 | oauth2Helper.identify.return_value = user_id 90 | 91 | # Call the controller 92 | self.controller.callback() 93 | 94 | oauth2Helper.get_token.assert_called_once() 95 | oauth2Helper.identify.assert_called_once_with(token) 96 | oauth2Helper.remember.assert_called_once_with(user_id) 97 | oauth2Helper.update_token.assert_called_once_with(user_id, token) 98 | oauth2Helper.redirect_from_callback.assert_called_once_with() 99 | 100 | @parameterized.expand([ 101 | (), 102 | ('/',), 103 | ('/', CompleteException(EXCEPTION_MSG), None, EXCEPTION_MSG), 104 | ('/', CompleteException(), None, CompleteException.description), 105 | ('/', ErrorException(EXCEPTION_MSG), None, EXCEPTION_MSG), 106 | ('/', ErrorException(), None, ErrorException.error), 107 | ('/', VoidException(EXCEPTION_MSG), None, EXCEPTION_MSG), 108 | ('/', VoidException(), None, type(VoidException()).__name__), 109 | ('/about', Exception(EXCEPTION_MSG), EXAMPLE_FLASH, EXAMPLE_FLASH) 110 | ]) 111 | def test_callback_errors(self, came_from=None, exception=Exception(EXCEPTION_MSG), 112 | error_description=None, expected_flash=EXCEPTION_MSG): 113 | 114 | # Recover function 115 | controller.oauth2.get_came_from = self.get_came_from 116 | 117 | oauth2Helper = controller.oauth2.OAuth2Helper.return_value 118 | oauth2Helper.get_token.side_effect = exception 119 | 120 | controller.toolkit.request.GET = {} 121 | controller.toolkit.request.GET['state'] = self.generate_state(came_from) 122 | if error_description is not None: 123 | controller.toolkit.request.GET['error_description'] = error_description 124 | controller.toolkit.request.params.get = controller.toolkit.request.GET.get 125 | 126 | # Call the controller 127 | self.controller.callback() 128 | 129 | # Check the state and the location 130 | controller.session.save.assert_called_once_with() 131 | self.assertEquals(RETURNED_STATUS, controller.toolkit.response.status_int) 132 | self.assertEquals(came_from, controller.toolkit.response.location) 133 | controller.helpers.flash_error.assert_called_once_with(expected_flash) 134 | 135 | @parameterized.expand([ 136 | (), 137 | (None, None, '/dashboard'), 138 | ('/about', None, '/about'), 139 | ('/about', '/ckan-admin', '/ckan-admin'), 140 | (None, '/ckan-admin', '/ckan-admin'), 141 | ('/', None, '/dashboard'), 142 | ('/user/logged_out_redirect', None, '/dashboard'), 143 | ('/', '/ckan-admin', '/ckan-admin'), 144 | ('/user/logged_out_redirect', '/ckan-admin', '/ckan-admin'), 145 | ('http://google.es', None, '/dashboard'), 146 | ('http://google.es', None, '/dashboard') 147 | ]) 148 | def test_login(self, referer=None, came_from=None, expected_referer='/dashboard'): 149 | 150 | # The login function will check these variables 151 | controller.toolkit.request.headers = {} 152 | controller.toolkit.request.params = {} 153 | 154 | if referer: 155 | controller.toolkit.request.headers['Referer'] = referer 156 | 157 | if came_from: 158 | controller.toolkit.request.params['came_from'] = came_from 159 | 160 | # Call the function 161 | self.controller.login() 162 | 163 | self.controller.oauth2helper.challenge.assert_called_once_with(expected_referer) 164 | -------------------------------------------------------------------------------- /ckanext/oauth2/tests/test_db.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright (c) 2014 CoNWeT Lab., Universidad Politécnica de Madrid 4 | 5 | # This file is part of OAuth2 CKAN Extension. 6 | 7 | # OAuth2 CKAN Extension is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU Affero General Public License as published by 9 | # the Free Software Foundation, either version 3 of the License, or 10 | # (at your option) any later version. 11 | 12 | # OAuth2 CKAN Extension is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU Affero General Public License for more details. 16 | 17 | # You should have received a copy of the GNU Affero General Public License 18 | # along with OAuth2 CKAN Extension. If not, see . 19 | 20 | import unittest 21 | import ckanext.oauth2.db as db 22 | 23 | from mock import MagicMock 24 | 25 | 26 | class DBTest(unittest.TestCase): 27 | 28 | def setUp(self): 29 | # Restart databse initial status 30 | db.UserToken = None 31 | 32 | # Create mocks 33 | self._sa = db.sa 34 | db.sa = MagicMock() 35 | 36 | def tearDown(self): 37 | db.UserToken = None 38 | db.sa = self._sa 39 | 40 | def test_initdb_not_initialized(self): 41 | 42 | # Call the function 43 | model = MagicMock() 44 | db.init_db(model) 45 | 46 | # Assert that table method has been called 47 | db.sa.Table.assert_called_once() 48 | model.meta.mapper.assert_called_once() 49 | 50 | def test_initdb_initialized(self): 51 | db.UserToken = MagicMock() 52 | 53 | # Call the function 54 | model = MagicMock() 55 | db.init_db(model) 56 | 57 | # Assert that table method has been called 58 | self.assertEquals(0, db.sa.Table.call_count) 59 | self.assertEquals(0, model.meta.mapper.call_count) 60 | -------------------------------------------------------------------------------- /ckanext/oauth2/tests/test_oauth2.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright (c) 2014 CoNWeT Lab., Universidad Politécnica de Madrid 4 | # Copyright (c) 2018 Future Internet Consulting and Development Solutions S.L. 5 | 6 | # This file is part of OAuth2 CKAN Extension. 7 | 8 | # OAuth2 CKAN Extension is free software: you can redistribute it and/or modify 9 | # it under the terms of the GNU Affero General Public License as published by 10 | # the Free Software Foundation, either version 3 of the License, or 11 | # (at your option) any later version. 12 | 13 | # OAuth2 CKAN Extension is distributed in the hope that it will be useful, 14 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 | # GNU Affero General Public License for more details. 17 | 18 | # You should have received a copy of the GNU Affero General Public License 19 | # along with OAuth2 CKAN Extension. If not, see . 20 | 21 | from __future__ import print_function, unicode_literals 22 | 23 | from base64 import b64encode, urlsafe_b64encode 24 | import json 25 | import os 26 | import unittest 27 | from urllib import urlencode 28 | 29 | import ckanext.oauth2.oauth2 as oauth2 30 | from ckanext.oauth2.oauth2 import OAuth2Helper 31 | import httpretty 32 | from mock import patch, MagicMock 33 | from parameterized import parameterized 34 | from oauthlib.oauth2 import InsecureTransportError, MissingCodeError, MissingTokenError 35 | from requests.exceptions import SSLError 36 | 37 | OAUTH2TOKEN = { 38 | 'access_token': 'token', 39 | 'token_type': 'Bearer', 40 | 'expires_in': '3600', 41 | 'refresh_token': 'refresh_token', 42 | } 43 | 44 | 45 | def make_request(secure, host, path, params): 46 | request = MagicMock() 47 | 48 | # Generate the string of paramaters1 49 | params_str = '' 50 | for param in params: 51 | params_str += '%s=%s&' % (param, params[param]) 52 | 53 | secure = 's' if secure else '' 54 | request.url = 'http%s://%s/%s?%s' % (secure, host, path, params_str) 55 | request.host = host 56 | request.host_url = 'http%s://%s' % (secure, host) 57 | request.params = params 58 | return request 59 | 60 | 61 | class OAuth2PluginTest(unittest.TestCase): 62 | 63 | def setUp(self): 64 | 65 | self._user_field = 'nickName' 66 | self._fullname_field = 'fullname' 67 | self._email_field = 'mail' 68 | self._profile_api_url = 'https://test/oauth2/user' 69 | self._group_field = 'groups' 70 | 71 | # Get the functions that can be mocked and affect other tests 72 | self._toolkit = oauth2.toolkit 73 | self._User = oauth2.model.User 74 | self._Session = oauth2.model.Session 75 | self._db = oauth2.db 76 | self._OAuth2Session = oauth2.OAuth2Session 77 | 78 | # Mock toolkit 79 | oauth2.toolkit = MagicMock() 80 | 81 | def tearDown(self): 82 | # Reset the functions 83 | oauth2.toolkit = self._toolkit 84 | oauth2.model.User = self._User 85 | oauth2.model.Session = self._Session 86 | oauth2.db = self._db 87 | oauth2.OAuth2Session = self._OAuth2Session 88 | 89 | def _helper(self, fullname_field=True, mail_field=True, conf=None, missing_conf=None, jwt_enable=False): 90 | oauth2.db = MagicMock() 91 | oauth2.jwt = MagicMock() 92 | 93 | oauth2.toolkit.config = { 94 | 'ckan.oauth2.legacy_idm': 'false', 95 | 'ckan.oauth2.authorization_endpoint': 'https://test/oauth2/authorize/', 96 | 'ckan.oauth2.token_endpoint': 'https://test/oauth2/token/', 97 | 'ckan.oauth2.client_id': 'client-id', 98 | 'ckan.oauth2.client_secret': 'client-secret', 99 | 'ckan.oauth2.profile_api_url': self._profile_api_url, 100 | 'ckan.oauth2.profile_api_user_field': self._user_field, 101 | 'ckan.oauth2.profile_api_mail_field': self._email_field, 102 | } 103 | if conf is not None: 104 | oauth2.toolkit.config.update(conf) 105 | if missing_conf is not None: 106 | del oauth2.toolkit.config[missing_conf] 107 | 108 | helper = OAuth2Helper() 109 | 110 | if fullname_field: 111 | helper.profile_api_fullname_field = self._fullname_field 112 | 113 | if jwt_enable: 114 | helper.jwt_enable = True 115 | 116 | return helper 117 | 118 | @parameterized.expand([ 119 | ("ckan.oauth2.authorization_endpoint"), 120 | ("ckan.oauth2.token_endpoint"), 121 | ("ckan.oauth2.client_id"), 122 | ("ckan.oauth2.client_secret"), 123 | ("ckan.oauth2.profile_api_url"), 124 | ("ckan.oauth2.profile_api_user_field"), 125 | ("ckan.oauth2.profile_api_mail_field"), 126 | ]) 127 | def test_minimum_conf(self, conf_to_remove): 128 | with self.assertRaises(ValueError): 129 | self._helper(missing_conf=conf_to_remove) 130 | 131 | @patch('ckanext.oauth2.oauth2.OAuth2Session') 132 | def test_get_token_with_no_credentials(self, oauth2_session_mock): 133 | state = b64encode(json.dumps({'came_from': 'initial-page'})) 134 | oauth2.toolkit.request = make_request(True, 'data.com', 'callback', {'state': state}) 135 | 136 | helper = self._helper() 137 | 138 | oauth2_session_mock().fetch_token.side_effect = MissingCodeError("Missing code parameter in response.") 139 | with self.assertRaises(MissingCodeError): 140 | helper.get_token() 141 | 142 | @patch('ckanext.oauth2.oauth2.OAuth2Session') 143 | @patch.dict(os.environ, {'OAUTHLIB_INSECURE_TRANSPORT': ''}) 144 | def test_get_token(self, OAuth2Session): 145 | helper = self._helper() 146 | token = OAUTH2TOKEN 147 | OAuth2Session().fetch_token.return_value = OAUTH2TOKEN 148 | 149 | state = b64encode(json.dumps({'came_from': 'initial-page'})) 150 | oauth2.toolkit.request = make_request(True, 'data.com', 'callback', {'state': state, 'code': 'code'}) 151 | retrieved_token = helper.get_token() 152 | 153 | for key in token: 154 | self.assertIn(key, retrieved_token) 155 | self.assertEquals(token[key], retrieved_token[key]) 156 | 157 | @patch('ckanext.oauth2.oauth2.OAuth2Session') 158 | def test_get_token_legacy_idm(self, OAuth2Session): 159 | helper = self._helper() 160 | helper.legacy_idm = True 161 | helper.verify_https = True 162 | OAuth2Session().fetch_token.return_value = OAUTH2TOKEN 163 | 164 | state = b64encode(json.dumps({'came_from': 'initial-page'})) 165 | oauth2.toolkit.request = make_request(True, 'data.com', 'callback', {'state': state, 'code': 'code'}) 166 | retrieved_token = helper.get_token() 167 | 168 | expected_headers = { 169 | 'Accept': 'application/json', 170 | 'Content-Type': 'application/x-www-form-urlencoded', 171 | 'Authorization': 'Basic %s' % urlsafe_b64encode( 172 | '%s:%s' % (helper.client_id, helper.client_secret) 173 | ) 174 | } 175 | 176 | OAuth2Session().fetch_token.assert_called_once_with( 177 | helper.token_endpoint, 178 | headers=expected_headers, 179 | client_secret=helper.client_secret, 180 | authorization_response=oauth2.toolkit.request.url, 181 | verify=True 182 | ) 183 | self.assertEqual(retrieved_token, OAUTH2TOKEN) 184 | 185 | @httpretty.activate 186 | @patch.dict(os.environ, {'OAUTHLIB_INSECURE_TRANSPORT': ''}) 187 | def test_get_token_insecure(self): 188 | helper = self._helper() 189 | token = OAUTH2TOKEN 190 | httpretty.register_uri(httpretty.POST, helper.token_endpoint, body=json.dumps(token)) 191 | 192 | state = b64encode(json.dumps({'came_from': 'initial-page'})) 193 | oauth2.toolkit.request = make_request(False, 'data.com', 'callback', {'state': state, 'code': 'code'}) 194 | 195 | with self.assertRaises(InsecureTransportError): 196 | helper.get_token() 197 | 198 | @httpretty.activate 199 | @patch.dict(os.environ, {'OAUTHLIB_INSECURE_TRANSPORT': ''}) 200 | def test_get_token_invalid_cert(self): 201 | helper = self._helper() 202 | token = OAUTH2TOKEN 203 | httpretty.register_uri(httpretty.POST, helper.token_endpoint, body=json.dumps(token)) 204 | 205 | state = b64encode(json.dumps({'came_from': 'initial-page'})) 206 | oauth2.toolkit.request = make_request(True, 'data.com', 'callback', {'state': state, 'code': 'code'}) 207 | 208 | with self.assertRaises(InsecureTransportError): 209 | with patch('ckanext.oauth2.oauth2.OAuth2Session') as oauth2_session_mock: 210 | oauth2_session_mock().fetch_token.side_effect = SSLError('(Caused by SSLError(SSLError("bad handshake: Error([(\'SSL routines\', \'tls_process_server_certificate\', \'certificate verify failed\')],)",),)') 211 | helper.get_token() 212 | 213 | @httpretty.activate 214 | @patch.dict(os.environ, {'OAUTHLIB_INSECURE_TRANSPORT': ''}) 215 | def test_get_token_unexpected_ssl_error(self): 216 | helper = self._helper() 217 | token = OAUTH2TOKEN 218 | httpretty.register_uri(httpretty.POST, helper.token_endpoint, body=json.dumps(token)) 219 | 220 | state = b64encode(json.dumps({'came_from': 'initial-page'})) 221 | oauth2.toolkit.request = make_request(True, 'data.com', 'callback', {'state': state, 'code': 'code'}) 222 | 223 | with self.assertRaises(SSLError): 224 | with patch('ckanext.oauth2.oauth2.OAuth2Session') as oauth2_session_mock: 225 | oauth2_session_mock().fetch_token.side_effect = SSLError('unexpected error') 226 | helper.get_token() 227 | 228 | @httpretty.activate 229 | @patch.dict(os.environ, {'OAUTHLIB_INSECURE_TRANSPORT': 'True'}) 230 | def test_get_token_insecure_enabled(self): 231 | helper = self._helper() 232 | token = OAUTH2TOKEN 233 | httpretty.register_uri(httpretty.POST, helper.token_endpoint, body=json.dumps(token)) 234 | 235 | state = b64encode(json.dumps({'came_from': 'initial-page'})) 236 | oauth2.toolkit.request = make_request(False, 'data.com', 'callback', {'state': state, 'code': 'code'}) 237 | retrieved_token = helper.get_token() 238 | 239 | for key in token: 240 | self.assertIn(key, retrieved_token) 241 | self.assertEquals(token[key], retrieved_token[key]) 242 | 243 | @httpretty.activate 244 | def test_get_token_error(self): 245 | helper = self._helper() 246 | token = { 247 | 'info': 'auth_error', 248 | 'error_description': 'Some description' 249 | } 250 | httpretty.register_uri(httpretty.POST, helper.token_endpoint, body=json.dumps(token)) 251 | 252 | state = b64encode(json.dumps({'came_from': 'initial-page'})) 253 | oauth2.toolkit.request = make_request(True, 'data.com', 'callback', {'state': state, 'code': 'code'}) 254 | 255 | with self.assertRaises(MissingTokenError): 256 | helper.get_token() 257 | 258 | @parameterized.expand([ 259 | ({},), 260 | ([('Set-Cookie', 'cookie1="cookie1val"; Path=/')],), 261 | ([('Set-Cookie', 'cookie1="cookie1val"; Path=/'), ('Set-Cookie', 'cookie12="cookie2val"; Path=/')],) 262 | ]) 263 | def test_remember(self, headers): 264 | user_name = 'user_name' 265 | 266 | # Configure the mocks 267 | environ = MagicMock() 268 | plugins = MagicMock() 269 | authenticator = MagicMock() 270 | authenticator.remember = MagicMock(return_value=headers) 271 | 272 | environ.get = MagicMock(return_value=plugins) 273 | oauth2.toolkit.request.environ = environ 274 | plugins.get = MagicMock(return_value=authenticator) 275 | 276 | # Call the function 277 | helper = self._helper() 278 | helper.remember(user_name) 279 | 280 | # Check that the remember method has been called properly 281 | authenticator.remember.assert_called_once_with(environ, {'repoze.who.userid': user_name}) 282 | 283 | for header, value in headers: 284 | oauth2.toolkit.response.headers.add.assert_any_call(header, value) 285 | 286 | def test_challenge(self): 287 | helper = self._helper() 288 | 289 | # Build mocks 290 | request = MagicMock() 291 | request = make_request(False, 'localhost', 'user/login', {}) 292 | request.environ = MagicMock() 293 | request.headers = {} 294 | came_from = '/came_from_example' 295 | 296 | oauth2.toolkit.request = request 297 | 298 | # Call the method 299 | helper.challenge(came_from) 300 | 301 | # Check 302 | state = urlencode({'state': b64encode(bytes(json.dumps({'came_from': came_from})))}) 303 | expected_url = 'https://test/oauth2/authorize/?response_type=code&client_id=client-id&' + \ 304 | 'redirect_uri=http%3A%2F%2Flocalhost%3A5000%2Foauth2%2Fcallback&' + state 305 | oauth2.toolkit.redirect_to.assert_called_once_with(expected_url) 306 | 307 | @parameterized.expand([ 308 | ('test_user', 'Test User Full Name', 'test@test.com'), 309 | ('test_user', None, 'test@test.com'), 310 | # ('test_user', 'Test User Full Name', None), 311 | ('test_user', 'Test User Full Name', 'test@test.com', False), 312 | ('test_user', None, 'test@test.com', False), 313 | ('test_user', None, 'test@test.com', False, False, False), 314 | ('test_user', None, 'test@test.com', False, False, True), 315 | ('test_user', 'Test User Full Name', 'test@test.com', True, True), 316 | ('test_user', 'Test User Full Name', 'test@test.com', True, False), 317 | ('test_user', 'Test User Full Name', 'test@test.com', True, True, True), 318 | ('test_user', 'Test User Full Name', 'test@test.com', True, True, False), 319 | ('test_user', None, 'test@test.com', True, True), 320 | # ('test_user', 'Test User Full Name', None, True, True), 321 | ('test_user', None, 'test@test.com', True, False), 322 | ]) 323 | @httpretty.activate 324 | def test_identify(self, username, fullname=None, email=None, user_exists=True, 325 | fullname_field=True, sysadmin=None): 326 | 327 | self.helper = helper = self._helper(fullname_field) 328 | 329 | # Simulate the HTTP Request 330 | user_info = {} 331 | user_info[self._user_field] = username 332 | user_info[self._email_field] = email 333 | 334 | if fullname: 335 | user_info[self._fullname_field] = fullname 336 | 337 | if sysadmin is not None: 338 | self.helper.profile_api_groupmembership_field = self._group_field 339 | self.helper.sysadmin_group_name = "admin" 340 | user_info[self._group_field] = "admin" if sysadmin else "other" 341 | 342 | httpretty.register_uri(httpretty.GET, self._profile_api_url, body=json.dumps(user_info)) 343 | 344 | print(username, fullname, email, user_exists, fullname_field, sysadmin) 345 | 346 | # Create the mocks 347 | request = make_request(False, 'localhost', '/oauth2/callback', {}) 348 | oauth2.toolkit.request = request 349 | oauth2.model.Session = MagicMock() 350 | user = MagicMock() 351 | user.name = None 352 | user.fullname = None 353 | user.email = email 354 | oauth2.model.User = MagicMock(return_value=user) 355 | oauth2.model.User.by_email = MagicMock(return_value=[user] if user_exists else []) 356 | 357 | # Call the function 358 | returned_username = helper.identify(OAUTH2TOKEN) 359 | 360 | # The function must return the user name 361 | self.assertEquals(username, returned_username) 362 | 363 | # Asserts 364 | oauth2.model.User.by_email.assert_called_once_with(email) 365 | 366 | # Check if the user is created or not 367 | if not user_exists: 368 | oauth2.model.User.assert_called_once_with(email=email) 369 | else: 370 | self.assertEquals(0, oauth2.model.User.called) 371 | 372 | # Check that user properties are set properly 373 | self.assertEquals(username, user.name) 374 | self.assertEquals(email, user.email) 375 | if sysadmin is not None: 376 | self.assertEquals(sysadmin, user.sysadmin) 377 | 378 | if fullname and fullname_field: 379 | self.assertEquals(fullname, user.fullname) 380 | else: 381 | self.assertEquals(None, user.fullname) 382 | 383 | # Check that the user is saved 384 | oauth2.model.Session.add.assert_called_once_with(user) 385 | oauth2.model.Session.commit.assert_called_once() 386 | oauth2.model.Session.remove.assert_called_once() 387 | 388 | def test_identify_jwt(self): 389 | 390 | helper = self._helper(jwt_enable=True) 391 | token = OAUTH2TOKEN 392 | user_data ={self._user_field: 'test_user', self._email_field: 'test@test.com'} 393 | 394 | oauth2.jwt.decode.return_value = user_data 395 | 396 | oauth2.model.Session = MagicMock() 397 | user = MagicMock() 398 | user.name = None 399 | user.email = None 400 | oauth2.model.User = MagicMock(return_value=user) 401 | oauth2.model.User.by_email = MagicMock(return_value=[user]) 402 | 403 | returned_username = helper.identify(token) 404 | 405 | self.assertEquals(user_data[self._user_field], returned_username) 406 | 407 | oauth2.model.Session.add.assert_called_once_with(user) 408 | oauth2.model.Session.commit.assert_called_once() 409 | oauth2.model.Session.remove.assert_called_once() 410 | 411 | @parameterized.expand([ 412 | ({'error': 'invalid_token', 'error_description': 'Error Description'},), 413 | ({'error': 'another_error'},) 414 | ]) 415 | @httpretty.activate 416 | def test_identify_invalid_token(self, user_info): 417 | 418 | helper = self._helper() 419 | token = {'access_token': 'OAUTH_TOKEN'} 420 | 421 | httpretty.register_uri(httpretty.GET, helper.profile_api_url, status=401, body=json.dumps(user_info)) 422 | 423 | exception_risen = False 424 | try: 425 | helper.identify(token) 426 | except Exception as e: 427 | if user_info['error'] == 'invalid_token': 428 | self.assertIsInstance(e, ValueError) 429 | self.assertEquals(user_info['error_description'], e.message) 430 | exception_risen = True 431 | 432 | self.assertTrue(exception_risen) 433 | 434 | @patch.dict(os.environ, {'OAUTHLIB_INSECURE_TRANSPORT': ''}) 435 | def test_identify_invalid_cert(self): 436 | 437 | helper = self._helper() 438 | token = {'access_token': 'OAUTH_TOKEN'} 439 | 440 | with self.assertRaises(InsecureTransportError): 441 | with patch('ckanext.oauth2.oauth2.OAuth2Session') as oauth2_session_mock: 442 | oauth2_session_mock().get.side_effect = SSLError('(Caused by SSLError(SSLError("bad handshake: Error([(\'SSL routines\', \'tls_process_server_certificate\', \'certificate verify failed\')],)",),)') 443 | helper.identify(token) 444 | 445 | @patch.dict(os.environ, {'OAUTHLIB_INSECURE_TRANSPORT': ''}) 446 | def test_identify_invalid_cert_legacy(self): 447 | 448 | helper = self._helper(conf={"ckan.oauth2.legacy_idm": "True"}) 449 | token = {'access_token': 'OAUTH_TOKEN'} 450 | 451 | with self.assertRaises(InsecureTransportError): 452 | with patch('ckanext.oauth2.oauth2.requests.get') as requests_get_mock: 453 | requests_get_mock.side_effect = SSLError('(Caused by SSLError(SSLError("bad handshake: Error([(\'SSL routines\', \'tls_process_server_certificate\', \'certificate verify failed\')],)",),)') 454 | helper.identify(token) 455 | 456 | @patch.dict(os.environ, {'OAUTHLIB_INSECURE_TRANSPORT': ''}) 457 | def test_identify_unexpected_ssl_error(self): 458 | 459 | helper = self._helper() 460 | token = {'access_token': 'OAUTH_TOKEN'} 461 | 462 | with self.assertRaises(SSLError): 463 | with patch('ckanext.oauth2.oauth2.OAuth2Session') as oauth2_session_mock: 464 | oauth2_session_mock().get.side_effect = SSLError('unexpected error') 465 | helper.identify(token) 466 | 467 | def test_get_stored_token_non_existing_user(self): 468 | helper = self._helper() 469 | oauth2.db.UserToken.by_user_name = MagicMock(return_value=None) 470 | self.assertIsNone(helper.get_stored_token('user')) 471 | 472 | def test_get_stored_token_existing_user(self): 473 | helper = self._helper() 474 | 475 | usertoken = MagicMock() 476 | usertoken.access_token = OAUTH2TOKEN['access_token'] 477 | usertoken.token_type = OAUTH2TOKEN['token_type'] 478 | usertoken.expires_in = OAUTH2TOKEN['expires_in'] 479 | usertoken.refresh_token = OAUTH2TOKEN['refresh_token'] 480 | 481 | oauth2.db.UserToken.by_user_name = MagicMock(return_value=usertoken) 482 | self.assertEquals(OAUTH2TOKEN, helper.get_stored_token('user')) 483 | 484 | @parameterized.expand([ 485 | ({'came_from': 'http://localhost/dataset'}, ), 486 | ({},) 487 | ]) 488 | def test_redirect_from_callback(self, identity): 489 | came_from = 'initial-page' 490 | state = b64encode(json.dumps({'came_from': came_from})) 491 | oauth2.toolkit.request = make_request(True, 'data.com', 'callback', {'state': state, 'code': 'code'}) 492 | 493 | helper = self._helper() 494 | helper.redirect_from_callback() 495 | 496 | self.assertEquals(302, oauth2.toolkit.response.status) 497 | self.assertEquals(came_from, oauth2.toolkit.response.location) 498 | 499 | @parameterized.expand([ 500 | (True, True), 501 | (True, False), 502 | (False, False), 503 | (False, True), 504 | ]) 505 | def test_update_token(self, user_exists, jwt_expires_in): 506 | helper = self._helper() 507 | user = 'user' 508 | 509 | if user_exists: 510 | usertoken = MagicMock() 511 | usertoken.user_name = user 512 | usertoken.access_token = OAUTH2TOKEN['access_token'] 513 | usertoken.token_type = OAUTH2TOKEN['token_type'] 514 | usertoken.expires_in = OAUTH2TOKEN['expires_in'] 515 | usertoken.refresh_token = OAUTH2TOKEN['refresh_token'] 516 | else: 517 | usertoken = None 518 | oauth2.db.UserToken = MagicMock() 519 | 520 | oauth2.model.Session = MagicMock() 521 | oauth2.db.UserToken.by_user_name = MagicMock(return_value=usertoken) 522 | 523 | # The token to be updated 524 | if jwt_expires_in: 525 | newtoken = { 526 | 'access_token': 'new_access_token', 527 | 'token_type': 'new_token_type', 528 | 'expires_in': 'new_expires_in', 529 | 'refresh_token': 'new_refresh_token' 530 | } 531 | helper.update_token('user', newtoken) 532 | 533 | # Check that the object has been stored 534 | oauth2.model.Session.add.assert_called_once() 535 | oauth2.model.Session.commit.assert_called_once() 536 | 537 | # Check that the object contains the correct information 538 | tk = oauth2.model.Session.add.call_args_list[0][0][0] 539 | self.assertEquals(user, tk.user_name) 540 | self.assertEquals(newtoken['access_token'], tk.access_token) 541 | self.assertEquals(newtoken['token_type'], tk.token_type) 542 | self.assertEquals(newtoken['expires_in'], tk.expires_in) 543 | self.assertEquals(newtoken['refresh_token'], tk.refresh_token) 544 | else: 545 | newtoken = { 546 | 'access_token': 'new_access_token', 547 | 'token_type': 'new_token_type', 548 | 'refresh_token': 'new_refresh_token' 549 | } 550 | expires_in_data = {'exp': 3600, 'iat': 0} 551 | oauth2.jwt.decode.return_value = expires_in_data 552 | helper.update_token('user', newtoken) 553 | 554 | # Check that the object has been stored 555 | oauth2.model.Session.add.assert_called_once() 556 | oauth2.model.Session.commit.assert_called_once() 557 | 558 | # Check that the object contains the correct information 559 | tk = oauth2.model.Session.add.call_args_list[0][0][0] 560 | self.assertEquals(user, tk.user_name) 561 | self.assertEquals(newtoken['access_token'], tk.access_token) 562 | self.assertEquals(newtoken['token_type'], tk.token_type) 563 | self.assertEquals(3600, tk.expires_in) 564 | self.assertEquals(newtoken['refresh_token'], tk.refresh_token) 565 | 566 | 567 | @parameterized.expand([ 568 | (True,), 569 | (False,) 570 | ]) 571 | @patch.dict(os.environ, {'OAUTHLIB_INSECURE_TRANSPORT': '', 'REQUESTS_CA_BUNDLE': ''}) 572 | def test_refresh_token(self, user_exists): 573 | username = 'user' 574 | helper = self.helper = self._helper() 575 | 576 | # mock get_token 577 | if user_exists: 578 | current_token = OAUTH2TOKEN 579 | else: 580 | current_token = None 581 | 582 | # mock plugin functions 583 | helper.get_stored_token = MagicMock(return_value=current_token) 584 | helper.update_token = MagicMock() 585 | 586 | # The token returned by the system 587 | newtoken = { 588 | 'access_token': 'new_access_token', 589 | 'token_type': 'new_token_type', 590 | 'expires_in': 'new_expires_in', 591 | 'refresh_token': 'new_refresh_token' 592 | } 593 | session = MagicMock() 594 | session.refresh_token = MagicMock(return_value=newtoken) 595 | oauth2.OAuth2Session = MagicMock(return_value=session) 596 | 597 | # Call the function 598 | result = helper.refresh_token(username) 599 | 600 | if user_exists: 601 | self.assertEquals(newtoken, result) 602 | helper.get_stored_token.assert_called_once_with(username) 603 | oauth2.OAuth2Session.assert_called_once_with(helper.client_id, token=current_token, scope=helper.scope) 604 | session.refresh_token.assert_called_once_with(helper.token_endpoint, client_secret=helper.client_secret, client_id=helper.client_id, verify=True) 605 | helper.update_token.assert_called_once_with(username, newtoken) 606 | else: 607 | self.assertIsNone(result) 608 | self.assertEquals(0, oauth2.OAuth2Session.call_count) 609 | self.assertEquals(0, session.refresh_token.call_count) 610 | self.assertEquals(0, helper.update_token.call_count) 611 | 612 | @patch.dict(os.environ, {'OAUTHLIB_INSECURE_TRANSPORT': ''}) 613 | def test_refresh_token_invalid_cert(self): 614 | username = 'user' 615 | current_token = OAUTH2TOKEN 616 | helper = self._helper() 617 | 618 | # mock plugin functions 619 | helper.get_stored_token = MagicMock(return_value=current_token) 620 | 621 | with self.assertRaises(InsecureTransportError): 622 | with patch('ckanext.oauth2.oauth2.OAuth2Session') as oauth2_session_mock: 623 | oauth2_session_mock().refresh_token.side_effect = SSLError('(Caused by SSLError(SSLError("bad handshake: Error([(\'SSL routines\', \'tls_process_server_certificate\', \'certificate verify failed\')],)",),)') 624 | helper.refresh_token(username) 625 | 626 | @patch.dict(os.environ, {'OAUTHLIB_INSECURE_TRANSPORT': ''}) 627 | def test_refresh_token_unexpected_ssl_error(self): 628 | username = 'user' 629 | current_token = OAUTH2TOKEN 630 | helper = self._helper() 631 | 632 | # mock plugin functions 633 | helper.get_stored_token = MagicMock(return_value=current_token) 634 | 635 | with self.assertRaises(SSLError): 636 | with patch('ckanext.oauth2.oauth2.OAuth2Session') as oauth2_session_mock: 637 | oauth2_session_mock().refresh_token.side_effect = SSLError('unexpected error') 638 | helper.refresh_token(username) 639 | -------------------------------------------------------------------------------- /ckanext/oauth2/tests/test_plugin.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright (c) 2014 CoNWeT Lab., Universidad Politécnica de Madrid 4 | # Copyright (c) 2018 Future Internet Consulting and Development Solutions S.L. 5 | 6 | # This file is part of OAuth2 CKAN Extension. 7 | 8 | # OAuth2 CKAN Extension is free software: you can redistribute it and/or modify 9 | # it under the terms of the GNU Affero General Public License as published by 10 | # the Free Software Foundation, either version 3 of the License, or 11 | # (at your option) any later version. 12 | 13 | # OAuth2 CKAN Extension is distributed in the hope that it will be useful, 14 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 | # GNU Affero General Public License for more details. 17 | 18 | # You should have received a copy of the GNU Affero General Public License 19 | # along with OAuth2 CKAN Extension. If not, see . 20 | 21 | import unittest 22 | import ckanext.oauth2.plugin as plugin 23 | 24 | from mock import MagicMock, patch 25 | from parameterized import parameterized 26 | 27 | CUSTOM_AUTHORIZATION_HEADER = 'x-auth-token' 28 | OAUTH2_AUTHORIZATION_HEADER = 'authorization' 29 | HOST = 'ckan.example.org' 30 | 31 | 32 | class PluginTest(unittest.TestCase): 33 | 34 | def setUp(self): 35 | # Save functions and mock them 36 | 37 | self._toolkit = plugin.toolkit 38 | plugin.toolkit = MagicMock() 39 | plugin.toolkit.config = {'ckan.oauth2.authorization_header': OAUTH2_AUTHORIZATION_HEADER} 40 | 41 | self._oauth2 = plugin.oauth2 42 | plugin.oauth2 = MagicMock() 43 | 44 | # Create the plugin 45 | self._plugin = plugin.OAuth2Plugin() 46 | self._plugin.update_config(plugin.toolkit.config) 47 | 48 | def tearDown(self): 49 | # Unmock functions 50 | plugin.toolkit = self._toolkit 51 | 52 | def _set_identity(self, identity): 53 | plugin.toolkit.request.environ = {} 54 | if identity: 55 | plugin.toolkit.request.environ['repoze.who.identity'] = {'repoze.who.userid': identity} 56 | 57 | @parameterized.expand([ 58 | (), 59 | ('a'), 60 | (None, 'a',), 61 | (None, None, 'a'), 62 | ('a', 'b', 'c') 63 | ]) 64 | def test_before_map(self, register_url=None, reset_url=None, edit_url=None): 65 | 66 | # Setup the config dictionary 67 | plugin.toolkit.config = {} 68 | 69 | if register_url: 70 | plugin.toolkit.config['ckan.oauth2.register_url'] = register_url 71 | 72 | if reset_url: 73 | plugin.toolkit.config['ckan.oauth2.reset_url'] = reset_url 74 | 75 | if edit_url: 76 | plugin.toolkit.config['ckan.oauth2.edit_url'] = edit_url 77 | 78 | self._plugin.update_config(plugin.toolkit.config) 79 | 80 | # In this case we need a own instance of the plugin, so we create it 81 | self._plugin = plugin.OAuth2Plugin() 82 | 83 | # Create the mapper (mock) and call the function 84 | mapper = MagicMock() 85 | self._plugin.before_map(mapper) 86 | 87 | # Check that the mapper has been called correctly 88 | mapper.connect.assert_called_with('/oauth2/callback', 89 | controller='ckanext.oauth2.controller:OAuth2Controller', 90 | action='callback') 91 | 92 | if register_url: 93 | mapper.redirect.assert_any_call('/user/register', register_url) 94 | 95 | if reset_url: 96 | mapper.redirect.assert_any_call('/user/reset', reset_url) 97 | 98 | if edit_url: 99 | mapper.redirect.assert_any_call('/user/edit/{user}', edit_url) 100 | 101 | def test_auth_functions(self): 102 | 103 | EXPECTED_AUTH_FUNCTIONS = ['user_create', 'user_update', 'user_reset', 'request_reset'] 104 | 105 | auth_functions = self._plugin.get_auth_functions() 106 | 107 | for auth_function in auth_functions: 108 | self.assertIn(auth_function, EXPECTED_AUTH_FUNCTIONS) 109 | function_result = auth_functions[auth_function]({'user': 'test'}, {}) 110 | self.assertIn('success', function_result) 111 | self.assertEquals(False, function_result['success']) 112 | 113 | @parameterized.expand([ 114 | ({}, None, None, None, False), 115 | ({}, None, None, None, True), 116 | 117 | ({}, None, 'test', 'test', False), 118 | ({}, None, 'test', 'test', True), 119 | 120 | ({'invalid_header': 'api_key'}, None, None, None, False), 121 | ({'invalid_header': 'api_key'}, None, 'test2', 'test2', False), 122 | ({'invalid_header': 'api_key'}, None, None, None, True), 123 | ({'invalid_header': 'api_key'}, None, 'test2', 'test2', True), 124 | 125 | ({OAUTH2_AUTHORIZATION_HEADER: 'Bearer api_key'}, 'test', None, 'test', True), 126 | ({OAUTH2_AUTHORIZATION_HEADER: 'Bearer api_key'}, 'test', 'test2', 'test', True), 127 | ({OAUTH2_AUTHORIZATION_HEADER: 'Bearer api_key'}, ValueError('Invalid Key'), 'test2', 'test2', True), 128 | ({OAUTH2_AUTHORIZATION_HEADER: 'Bearer api_key'}, ValueError('Invalid Key'), None, None, True), 129 | ({OAUTH2_AUTHORIZATION_HEADER: 'Bearer api_key'}, None, 'test2', 'test2', True), 130 | ({OAUTH2_AUTHORIZATION_HEADER: 'Otherr api_key'}, None, None, None, True), 131 | ({OAUTH2_AUTHORIZATION_HEADER: 'api_key'}, None, 'test2', 'test2', True), 132 | ({OAUTH2_AUTHORIZATION_HEADER: 'api_key'}, None, None, None, True), 133 | 134 | ({CUSTOM_AUTHORIZATION_HEADER: 'api_key'}, 'test', None, 'test', False), 135 | ({CUSTOM_AUTHORIZATION_HEADER: 'api_key'}, 'test', 'test2', 'test', False), 136 | ({CUSTOM_AUTHORIZATION_HEADER: 'api_key'}, ValueError('Invalid Key'), 'test2', 'test2', False), 137 | ({CUSTOM_AUTHORIZATION_HEADER: 'api_key'}, ValueError('Invalid Key'), None, None, False), 138 | ({CUSTOM_AUTHORIZATION_HEADER: 'api_key'}, None, 'test2', 'test2', False), 139 | 140 | ]) 141 | @patch("ckanext.oauth2.plugin.g") 142 | def test_identify(self, headers, authenticate_result, identity, expected_user, oauth2, g_mock): 143 | 144 | if not oauth2: 145 | plugin.toolkit.config = {'ckan.oauth2.authorization_header': CUSTOM_AUTHORIZATION_HEADER} 146 | self._plugin.update_config(plugin.toolkit.config) 147 | 148 | self._set_identity(identity) 149 | 150 | usertoken = { 151 | 'access_token': 'current_access_token', 152 | 'refresh_token': 'current_refresh_token', 153 | 'token_type': 'current_token_type', 154 | 'expires_in': '2678399' 155 | } 156 | newtoken = { 157 | 'access_token': 'new_access_token', 158 | 'refresh_token': 'new_refresh_token', 159 | 'token_type': 'new_token_type', 160 | 'expires_in': '3600' 161 | } 162 | 163 | def authenticate_side_effect(identity): 164 | if isinstance(authenticate_result, Exception): 165 | raise authenticate_result 166 | else: 167 | return authenticate_result 168 | 169 | self._plugin.oauth2helper.identify = MagicMock(side_effect=authenticate_side_effect) 170 | self._plugin.oauth2helper.get_stored_token = MagicMock(return_value=usertoken) 171 | self._plugin.oauth2helper.refresh_token = MagicMock(return_value=newtoken) 172 | 173 | # Authentication header is not included 174 | plugin.toolkit.request.headers = headers 175 | 176 | # The identify function must set the user id in this variable 177 | plugin.toolkit.c.user = None 178 | plugin.toolkit.c.usertoken = None 179 | plugin.toolkit.c.usertoken_refresh = None 180 | 181 | # Call the function 182 | self._plugin.identify() 183 | 184 | # Check that the function "authenticate" (called when the API Key is included) has not been called 185 | if oauth2 and OAUTH2_AUTHORIZATION_HEADER in headers and headers[OAUTH2_AUTHORIZATION_HEADER].startswith('Bearer '): 186 | token = headers[OAUTH2_AUTHORIZATION_HEADER].replace('Bearer ', '') 187 | self._plugin.oauth2helper.identify.assert_called_once_with({'access_token': token}) 188 | elif not oauth2 and CUSTOM_AUTHORIZATION_HEADER in headers: 189 | self._plugin.oauth2helper.identify.assert_called_once_with({'access_token': headers[CUSTOM_AUTHORIZATION_HEADER]}) 190 | else: 191 | self.assertEquals(0, self._plugin.oauth2helper.identify.call_count) 192 | 193 | self.assertEquals(expected_user, g_mock.user) 194 | self.assertEquals(expected_user, plugin.toolkit.c.user) 195 | 196 | if expected_user is None: 197 | self.assertIsNone(plugin.toolkit.c.usertoken) 198 | self.assertIsNone(plugin.toolkit.c.usertoken_refresh) 199 | else: 200 | self.assertEquals(usertoken, plugin.toolkit.c.usertoken) 201 | 202 | # method 'usertoken_refresh' should relay on the one provided by the repoze.who module 203 | plugin.toolkit.c.usertoken_refresh() 204 | self._plugin.oauth2helper.refresh_token.assert_called_once_with(expected_user) 205 | self.assertEquals(newtoken, plugin.toolkit.c.usertoken) 206 | -------------------------------------------------------------------------------- /ckanext/oauth2/tests/test_selenium.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright (c) 2014 - 2017 CoNWeT Lab., Universidad Politécnica de Madrid 4 | # Copyright (c) 2018 Future Internet Consulting and Development Solutions S.L. 5 | 6 | # This file is part of OAuth2 CKAN Extension. 7 | 8 | # OAuth2 CKAN Extension is free software: you can redistribute it and/or modify 9 | # it under the terms of the GNU Affero General Public License as published by 10 | # the Free Software Foundation, either version 3 of the License, or 11 | # (at your option) any later version. 12 | 13 | # OAuth2 CKAN Extension is distributed in the hope that it will be useful, 14 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 | # GNU Affero General Public License for more details. 17 | 18 | # You should have received a copy of the GNU Affero General Public License 19 | # along with OAuth2 CKAN Extension. If not, see . 20 | 21 | from __future__ import print_function 22 | 23 | import unittest 24 | import os 25 | from subprocess import Popen 26 | import time 27 | from urlparse import urljoin 28 | 29 | from parameterized import parameterized 30 | import requests 31 | from selenium import webdriver 32 | from selenium.webdriver.common.by import By 33 | from selenium.webdriver.support import expected_conditions as EC 34 | from selenium.webdriver.support.ui import WebDriverWait 35 | 36 | 37 | IDM_URL = "http://localhost:3000" 38 | FILAB2_MAIL = "admin@test.com" 39 | FILAB_PASSWORD = "1234" 40 | PASS_INTEGRATION_TESTS = os.environ.get("INTEGRATION_TEST", "").strip().lower() in ('1', 'true', 'on') 41 | AUTH_TOKEN_ENDPOINT = "v1/auth/tokens" 42 | APPLICATION_ENDPOINT = "v1/applications" 43 | 44 | 45 | @unittest.skipUnless(PASS_INTEGRATION_TESTS, "set INTEGRATION_TEST environment variable (e.g. INTEGRATION_TEST=true) for running the integration tests") 46 | class IntegrationTest(unittest.TestCase): 47 | 48 | @classmethod 49 | def setUpClass(cls): 50 | # nose calls this method also if they are going to be skiped 51 | if not PASS_INTEGRATION_TESTS: 52 | return 53 | 54 | # Get an admin token 55 | body = { 56 | "name": "admin@test.com", 57 | "password": "1234" 58 | } 59 | url = urljoin(IDM_URL, AUTH_TOKEN_ENDPOINT) 60 | response = requests.post(url, json=body) 61 | print(response.text) 62 | 63 | token = response.headers["X-Subject-Token"] 64 | 65 | # Create the OAuth2 application 66 | headers = { 67 | "X-Auth-Token": token 68 | } 69 | 70 | body = { 71 | "application": { 72 | "name": "Travis Selenium Tests", 73 | "description": "Travis Selenium Tests", 74 | "redirect_uri": "http://localhost:5000/oauth2/callback", 75 | "url": "http://localhost:5000", 76 | "grant_type": [ 77 | "authorization_code" 78 | ] 79 | } 80 | } 81 | 82 | url = urljoin(IDM_URL, APPLICATION_ENDPOINT) 83 | response = requests.post(url, json=body, headers=headers) 84 | app = response.json() 85 | 86 | # Run CKAN 87 | env = os.environ.copy() 88 | env['DEBUG'] = 'True' 89 | env['OAUTHLIB_INSECURE_TRANSPORT'] = 'False' 90 | env['CKAN_OAUTH2_CLIENT_ID'] = app['application']['id'] 91 | env['CKAN_OAUTH2_CLIENT_SECRET'] = app['application']['secret'] 92 | cls._process = Popen(['paster', 'serve', 'test-fiware.ini'], env=env) 93 | 94 | # Init Selenium 95 | cls.driver = webdriver.Firefox() 96 | cls.base_url = 'http://localhost:5000/' 97 | cls.driver.set_window_size(1024, 768) 98 | 99 | @classmethod 100 | def tearDownClass(cls): 101 | # nose calls this method also if they are going to be skiped 102 | if not PASS_INTEGRATION_TESTS: 103 | return 104 | 105 | cls._process.terminate() 106 | cls.driver.quit() 107 | 108 | @classmethod 109 | def _introduce_log_in_parameters(cls, username=FILAB2_MAIL, password=FILAB_PASSWORD): 110 | driver = cls.driver 111 | id_username = WebDriverWait(cls.driver, 10).until(EC.presence_of_element_located((By.ID, "id_email"))) 112 | id_username.clear() 113 | id_username.send_keys(username) 114 | driver.find_element_by_id("id_password").clear() 115 | driver.find_element_by_id("id_password").send_keys(password) 116 | driver.find_element_by_xpath("//button[@type='submit']").click() 117 | WebDriverWait(driver, 30).until(EC.staleness_of(id_username)) 118 | 119 | def delete_cookies(self, domain): 120 | self.driver.get(domain) 121 | self.driver.delete_all_cookies() 122 | 123 | def setUp(self): 124 | self.delete_cookies(self.base_url) 125 | self.delete_cookies(IDM_URL) 126 | self.verificationErrors = [] 127 | self.accept_next_alert = True 128 | 129 | def tearDown(self): 130 | self.assertEqual([], self.verificationErrors) 131 | 132 | def _log_in(self, referer, username=FILAB2_MAIL, password=FILAB_PASSWORD, authorize=True): 133 | driver = self.driver 134 | driver.get(referer) 135 | WebDriverWait(driver, 30).until(lambda driver: driver.current_url == referer) 136 | 137 | WebDriverWait(driver, 30).until(EC.element_to_be_clickable((By.LINK_TEXT, "Log in"))).click() 138 | self._introduce_log_in_parameters(username, password) 139 | 140 | if driver.current_url.startswith(IDM_URL) and authorize: 141 | WebDriverWait(driver, 30).until(EC.element_to_be_clickable((By.XPATH, "//button[@type='submit']"))).click() 142 | 143 | def test_basic_login(self): 144 | driver = self.driver 145 | self._log_in(self.base_url) 146 | WebDriverWait(driver, 20).until(lambda driver: (self.base_url + 'dashboard') == driver.current_url) 147 | self.assertEqual("admin", driver.find_element_by_link_text("admin").text) 148 | driver.find_element_by_link_text("About").click() 149 | WebDriverWait(driver, 20).until(lambda driver: (self.base_url + 'about') == driver.current_url) 150 | self.assertEqual("admin", driver.find_element_by_css_selector("span.username").text) 151 | driver.find_element_by_css_selector("a[title=\"Edit settings\"]").click() 152 | time.sleep(3) # Wait the OAuth2 Server to return the page 153 | self.assertTrue(driver.current_url.startswith(IDM_URL + "/idm/settings"), "%s does not starts with %s" % (driver.current_url, IDM_URL + "/idm/settings")) 154 | 155 | def test_basic_login_different_referer(self): 156 | driver = self.driver 157 | self._log_in(self.base_url + "about") 158 | WebDriverWait(driver, 20).until(lambda driver: (self.base_url + 'about') == driver.current_url) 159 | self.assertEqual("admin", driver.find_element_by_css_selector("span.username").text) 160 | driver.find_element_by_link_text("Datasets").click() 161 | WebDriverWait(driver, 20).until(lambda driver: (self.base_url + 'dataset') == driver.current_url) 162 | self.assertEqual("admin", driver.find_element_by_css_selector("span.username").text) 163 | 164 | def test_user_access_unauthorized_page(self): 165 | driver = self.driver 166 | self._log_in(self.base_url) 167 | driver.get(self.base_url + "ckan-admin") 168 | 169 | # Check that an error message is shown 170 | self.assertIn("Need to be system administrator to administer", self.driver.find_element_by_tag_name('body').text) 171 | 172 | def test_register_btn(self): 173 | driver = self.driver 174 | driver.get(self.base_url) 175 | WebDriverWait(driver, 10).until(EC.element_to_be_clickable((By.LINK_TEXT, "Register"))).click() 176 | WebDriverWait(driver, 10).until(lambda driver: driver.current_url == (IDM_URL + "/sign_up")) 177 | 178 | @parameterized.expand([ 179 | ("user/register", IDM_URL + "/sign_up"), 180 | ("user/reset", IDM_URL + "/password/request") 181 | ]) 182 | def test_register(self, action, expected_url): 183 | driver = self.driver 184 | driver.get(self.base_url + action) 185 | WebDriverWait(driver, 10).until(lambda driver: print(driver.current_url) or driver.current_url == expected_url) 186 | 187 | 188 | if __name__ == "__main__": 189 | unittest.main() 190 | -------------------------------------------------------------------------------- /coverage.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | /home/ssalvador/PycharmProjects/ckanext-oauth2 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | 319 | 320 | 321 | 322 | 323 | 324 | 325 | 326 | 327 | 328 | 329 | 330 | 331 | 332 | 333 | 334 | 335 | 336 | 337 | 338 | 339 | 340 | 341 | 342 | 343 | 344 | 345 | 346 | 347 | -------------------------------------------------------------------------------- /requirements/all.pip: -------------------------------------------------------------------------------- 1 | -r install.pip 2 | -r test.pip 3 | -------------------------------------------------------------------------------- /requirements/install.pip: -------------------------------------------------------------------------------- 1 | requests-oauthlib==0.8.0 2 | pyjwt==1.7.1 -------------------------------------------------------------------------------- /requirements/test.pip: -------------------------------------------------------------------------------- 1 | parameterized 2 | selenium==3.5.0 3 | httpretty>=0.6.5 4 | mock 5 | sure 6 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | 4 | [flake8] 5 | ignore=E501 6 | 7 | [metadata] 8 | description-file = README.md 9 | 10 | [nosetests] 11 | ckan=1 12 | with-pylons=test.ini 13 | with-xunit=1 14 | with-coverage=1 15 | cover-package=ckanext.oauth2 16 | cover-inclusive=1 17 | cover-erase=1 18 | cover-xml=1 19 | verbosity=2 20 | 21 | [pep8] 22 | ignore=E501 23 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | # Copyright (c) 2014 CoNWeT Lab., Universidad Politécnica de Madrid 5 | 6 | # This file is part of OAuth2 CKAN Extension. 7 | 8 | # OAuth2 CKAN Extension is free software: you can redistribute it and/or modify 9 | # it under the terms of the GNU Affero General Public License as published by 10 | # the Free Software Foundation, either version 3 of the License, or 11 | # (at your option) any later version. 12 | 13 | # OAuth2 CKAN Extension is distributed in the hope that it will be useful, 14 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 | # GNU Affero General Public License for more details. 17 | 18 | # You should have received a copy of the GNU Affero General Public License 19 | # along with OAuth2 CKAN Extension. If not, see . 20 | 21 | import re 22 | 23 | from setuptools import setup, find_packages 24 | 25 | from ckanext.oauth2 import __version__, __description__ 26 | 27 | 28 | PYPI_RST_FILTERS = ( 29 | # Remove travis ci badge 30 | (r'.*travis-ci\.org/.*', ''), 31 | # Remove pypip.in badges 32 | (r'.*pypip\.in/.*', ''), 33 | (r'.*crate\.io/.*', ''), 34 | (r'.*coveralls\.io/.*', ''), 35 | ) 36 | 37 | 38 | def rst(filename): 39 | ''' 40 | Load rst file and sanitize it for PyPI. 41 | Remove unsupported github tags: 42 | - code-block directive 43 | - travis ci build badge 44 | ''' 45 | content = open(filename).read() 46 | for regex, replacement in PYPI_RST_FILTERS: 47 | content = re.sub(regex, replacement, content) 48 | return content 49 | 50 | 51 | # long_description = '\n'.join(( 52 | # rst('README.md'), 53 | # rst('CHANGELOG.rst'), 54 | # '' 55 | # )) 56 | 57 | setup( 58 | name='ckanext-oauth2', 59 | version=__version__, 60 | description=__description__, 61 | long_description=''' 62 | The OAuth2 extension allows site visitors to login through an OAuth2 server. 63 | ''', 64 | keywords='CKAN, OAuth2', 65 | author='Aitor Magán', 66 | author_email='amagan@conwet.com', 67 | url='https://github.com/conwetlab/ckanext-oauth2', 68 | download_url='https://github.com/conwetlab/ckanext-oauth2/tarball/v' + __version__, 69 | license='', 70 | packages=find_packages(exclude=['ez_setup', 'examples', 'tests']), 71 | namespace_packages=['ckanext'], 72 | include_package_data=True, 73 | zip_safe=False, 74 | setup_requires=[ 75 | 'nose>=1.3.0' 76 | ], 77 | install_requires=[ 78 | 'requests-oauthlib==0.8.0', 79 | 'pyjwt==1.7.1', 80 | ], 81 | tests_require=[ 82 | 'parameterized', 83 | 'selenium==3.5.0' 84 | ], 85 | test_suite='nosetests', 86 | entry_points={ 87 | 'ckan.plugins': [ 88 | 'oauth2 = ckanext.oauth2.plugin:OAuth2Plugin', 89 | ], 90 | 'nose.plugins': [ 91 | 'pylons = pylons.test:PylonsPlugin' 92 | ] 93 | }, 94 | classifiers=[ 95 | "Development Status :: 5 - Production/Stable", 96 | "Environment :: Web Environment", 97 | 'Intended Audience :: Developers', 98 | 'Programming Language :: Python :: 2', 99 | 'Programming Language :: Python :: 2.7', 100 | 'Programming Language :: Python', 101 | 'Topic :: Internet :: WWW/HTTP :: Session', 102 | 'Topic :: Internet :: WWW/HTTP :: WSGI :: Middleware', 103 | 'Topic :: Software Development :: Libraries :: Python Modules', 104 | 'Topic :: System :: Systems Administration :: Authentication/Directory', 105 | ], 106 | ) 107 | -------------------------------------------------------------------------------- /test-fiware.ini: -------------------------------------------------------------------------------- 1 | [DEFAULT] 2 | debug = false 3 | # Uncomment and replace with the address which should receive any error reports 4 | #email_to = you@yourdomain.com 5 | smtp_server = localhost 6 | error_email_from = paste@localhost 7 | 8 | [server:main] 9 | use = egg:Paste#http 10 | host = 0.0.0.0 11 | port = 5000 12 | 13 | 14 | [app:main] 15 | #use = config:/usr/lib/ckan/default/src/ckan/test-core.ini 16 | use = config:./ckan/test-core.ini 17 | # Here we hard-code the database and a flag to make default tests 18 | # run fast. 19 | faster_db_test_hacks = True 20 | #sqlalchemy.url = sqlite:/// 21 | # NB: other test configuration should go in test-core.ini, which is 22 | # what the postgres tests use. 23 | 24 | ckan.site_id = ckanext.oauth2.test 25 | ckan.site_url = http://localhost:5000 26 | 27 | ckan.cache_validation_enabled = True 28 | ckan.cache_enabled = False 29 | ckan.tests.functional.test_cache.expires = 1800 30 | ckan.tests.functional.test_cache.TestCacheBasics.test_get_cache_expires.expires = 3600 31 | 32 | ckan.plugins = oauth2 33 | 34 | ## OAuth2 configuration 35 | ckan.oauth2.register_url = http://localhost:3000/sign_up 36 | ckan.oauth2.reset_url = http://localhost:3000/password/request 37 | ckan.oauth2.edit_url = http://localhost:3000/idm/settings 38 | ckan.oauth2.authorization_endpoint = http://localhost:3000/oauth2/authorize 39 | ckan.oauth2.token_endpoint = http://localhost:3000/oauth2/token 40 | ckan.oauth2.profile_api_url = http://localhost:3000/user 41 | ckan.oauth2.is_legacy = true 42 | # The following parameters are configured through environment variables 43 | #ckan.oauth2.client_id = 361020fd7cf64456890dd98da88e64f3 44 | #ckan.oauth2.client_secret = edf713bf8a2344139f46a757fadae24f 45 | ckan.oauth2.scope = all_info 46 | ckan.oauth2.rememberer_name = auth_tkt 47 | ckan.oauth2.profile_api_user_field = username 48 | ckan.oauth2.profile_api_fullname_field = username 49 | ckan.oauth2.profile_api_mail_field = email 50 | ckan.oauth2.authorization_header = Bearer 51 | 52 | 53 | #who.config_file = %(here)s/who-fiware.ini 54 | 55 | # Avoid legacy templates 56 | ckan.legacy_templates = no 57 | 58 | # Logging configuration 59 | [loggers] 60 | keys = root, ckan, ckanext, sqlalchemy 61 | 62 | [handlers] 63 | keys = console 64 | 65 | [formatters] 66 | keys = generic 67 | 68 | [logger_root] 69 | level = WARN 70 | handlers = console 71 | 72 | [logger_ckan] 73 | qualname = ckan 74 | handlers = console 75 | level = INFO 76 | propagate = 0 77 | 78 | [logger_ckanext] 79 | qualname = ckanext 80 | handlers = console 81 | level = DEBUG 82 | propagate = 0 83 | 84 | [logger_sqlalchemy] 85 | handlers = console 86 | qualname = sqlalchemy.engine 87 | level = WARN 88 | 89 | [handler_console] 90 | class = StreamHandler 91 | args = (sys.stdout,) 92 | level = NOTSET 93 | formatter = generic 94 | 95 | [formatter_generic] 96 | format = %(asctime)s %(levelname)-5.5s [%(name)s] %(message)s 97 | -------------------------------------------------------------------------------- /test.ini: -------------------------------------------------------------------------------- 1 | [DEFAULT] 2 | debug = false 3 | # Uncomment and replace with the address which should receive any error reports 4 | #email_to = you@yourdomain.com 5 | smtp_server = localhost 6 | error_email_from = paste@localhost 7 | 8 | [server:main] 9 | use = egg:Paste#http 10 | host = 0.0.0.0 11 | port = 5000 12 | 13 | 14 | [app:main] 15 | #use = config:/usr/lib/ckan/default/src/ckan/test-core.ini 16 | use = config:./ckan/test-core.ini 17 | # Here we hard-code the database and a flag to make default tests 18 | # run fast. 19 | faster_db_test_hacks = True 20 | #sqlalchemy.url = sqlite:/// 21 | # NB: other test configuration should go in test-core.ini, which is 22 | # what the postgres tests use. 23 | 24 | ckan.site_id = ckanext.oauth2.test 25 | ckan.site_url = http://localhost 26 | 27 | ckan.cache_validation_enabled = True 28 | ckan.cache_enabled = False 29 | ckan.tests.functional.test_cache.expires = 1800 30 | ckan.tests.functional.test_cache.TestCacheBasics.test_get_cache_expires.expires = 3600 31 | 32 | ckan.plugins = oauth2 33 | 34 | ## OAuth2 configuration 35 | ckan.oauth2.register_url = https://account.lab.fiware.org/sign_up 36 | ckan.oauth2.reset_url = https://account.lab.fiware.org/password/request 37 | ckan.oauth2.edit_url = https://account.lab.fiware.org/settings 38 | ckan.oauth2.authorization_endpoint = https://account.lab.fiware.org/oauth2/authorize 39 | ckan.oauth2.token_endpoint = https://account.lab.fiware.org/oauth2/token 40 | ckan.oauth2.profile_api_url = https://account.lab.fiware.org/user 41 | ckan.oauth2.client_id = 361020fd7cf64456890dd98da88e64f3 42 | ckan.oauth2.client_secret = edf713bf8a2344139f46a757fadae24f 43 | ckan.oauth2.scope = all_info 44 | ckan.oauth2.rememberer_name = auth_tkt 45 | ckan.oauth2.profile_api_user_field = id 46 | ckan.oauth2.profile_api_fullname_field = displayName 47 | ckan.oauth2.profile_api_mail_field = email 48 | ckan.oauth2.authorization_header = X-Auth-Token 49 | ckan.oauth2.legacy_idm = True 50 | 51 | 52 | #who.config_file = %(here)s/who-fiware.ini 53 | 54 | # Avoid legacy templates 55 | ckan.legacy_templates = no 56 | 57 | # Logging configuration 58 | [loggers] 59 | keys = root, ckan, ckanext, sqlalchemy 60 | 61 | [handlers] 62 | keys = console 63 | 64 | [formatters] 65 | keys = generic 66 | 67 | [logger_root] 68 | level = WARN 69 | handlers = console 70 | 71 | [logger_ckan] 72 | qualname = ckan 73 | handlers = console 74 | level = INFO 75 | propagate = 0 76 | 77 | [logger_ckanext] 78 | qualname = ckanext 79 | handlers = console 80 | level = DEBUG 81 | propagate = 0 82 | 83 | [logger_sqlalchemy] 84 | handlers = console 85 | qualname = sqlalchemy.engine 86 | level = WARN 87 | 88 | [handler_console] 89 | class = StreamHandler 90 | args = (sys.stdout,) 91 | level = NOTSET 92 | formatter = generic 93 | 94 | [formatter_generic] 95 | format = %(asctime)s %(levelname)-5.5s [%(name)s] %(message)s 96 | --------------------------------------------------------------------------------