├── .classpath ├── .devcontainer └── devcontainer.json ├── .editorconfig ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ └── bug_report.md └── workflows │ ├── release.yml │ └── tests.yml ├── .gitignore ├── .mvn └── wrapper │ └── maven-wrapper.properties ├── LICENSE.txt ├── build.sh ├── docs ├── README.md ├── images │ └── realm_installation.png └── release_process.md ├── mvnw ├── mvnw.cmd ├── package.json ├── pom.xml ├── src ├── frontend │ ├── src │ │ ├── components │ │ │ ├── OAuth2ProxyApiTokenComponent.jsx │ │ │ └── OAuth2ProxyApiTokenComponent.test.jsx │ │ └── index.js │ ├── test │ │ └── __mocks__ │ │ │ └── @sonatype │ │ │ └── nexus-ui-plugin.js │ └── webpack.config.js ├── main │ ├── java │ │ └── com │ │ │ └── github │ │ │ └── tumbl3w33d │ │ │ ├── OAuth2ProxyApiTokenInvalidateQuartz.java │ │ │ ├── OAuth2ProxyApiTokenInvalidateTask.java │ │ │ ├── OAuth2ProxyApiTokenInvalidateTaskDescriptor.java │ │ │ ├── OAuth2ProxyFilter.java │ │ │ ├── OAuth2ProxyHeaderAuthToken.java │ │ │ ├── OAuth2ProxyHeaderAuthTokenFactory.java │ │ │ ├── OAuth2ProxyRealm.java │ │ │ ├── OAuth2ProxyRealmCredentialsMatcher.java │ │ │ ├── OAuth2ProxyUiPluginDescriptor.java │ │ │ ├── h2 │ │ │ ├── OAuth2ProxyLoginRecordDAO.java │ │ │ ├── OAuth2ProxyLoginRecordStore.java │ │ │ ├── OAuth2ProxyRoleDAO.java │ │ │ ├── OAuth2ProxyRoleStore.java │ │ │ ├── OAuth2ProxyStores.java │ │ │ ├── OAuth2ProxyTokenInfoDAO.java │ │ │ ├── OAuth2ProxyTokenInfoStore.java │ │ │ ├── OAuth2ProxyUserDAO.java │ │ │ ├── OAuth2ProxyUserStore.java │ │ │ └── RoleIdentifierTypeHandler.java │ │ │ ├── logout │ │ │ ├── OAuth2ProxyLogoutCapability.java │ │ │ ├── OAuth2ProxyLogoutCapabilityConfiguration.java │ │ │ ├── OAuth2ProxyLogoutCapabilityConfigurationState.java │ │ │ ├── OAuth2ProxyLogoutCapabilityDescriptor.java │ │ │ └── OAuth2ProxyLogoutHandler.java │ │ │ └── users │ │ │ ├── IncompleteOAuth2ProxyUserDataException.java │ │ │ ├── OAuth2ProxyAuthorizationManager.java │ │ │ ├── OAuth2ProxyUserManager.java │ │ │ ├── db │ │ │ ├── OAuth2ProxyLoginRecord.java │ │ │ ├── OAuth2ProxyRole.java │ │ │ ├── OAuth2ProxyTokenInfo.java │ │ │ └── OAuth2ProxyUser.java │ │ │ └── rest │ │ │ └── OAuth2ProxyUserResource.java │ └── resources │ │ ├── com │ │ └── github │ │ │ └── tumbl3w33d │ │ │ └── h2 │ │ │ ├── OAuth2ProxyLoginRecordDAO.xml │ │ │ ├── OAuth2ProxyRoleDAO.xml │ │ │ ├── OAuth2ProxyTokenInfoDAO.xml │ │ │ └── OAuth2ProxyUserDAO.xml │ │ └── static │ │ └── rapture │ │ └── resources │ │ └── nexus-oauth2-proxy-bundle.css └── test │ └── java │ └── com │ └── github │ └── tumbl3w33d │ ├── OAuth2ProxyFilterTest.java │ ├── OAuth2ProxyHeaderAuthTokenFactoryTest.java │ ├── OAuth2ProxyRealmTest.java │ └── users │ └── OAuth2ProxyUserManagerTest.java └── yarn.lock /.classpath: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Java", 3 | "image": "mcr.microsoft.com/devcontainers/java", 4 | "initializeCommand": "docker pull mcr.microsoft.com/devcontainers/java", 5 | "features": { 6 | "ghcr.io/devcontainers/features/git-lfs:1": {}, 7 | "ghcr.io/devcontainers/features/node:1": {} 8 | }, 9 | "customizations": { 10 | "vscode": { 11 | "settings": { 12 | "java.compile.nullAnalysis.mode": "automatic", 13 | "java.configuration.updateBuildConfiguration": "automatic", 14 | "java.jdt.ls.java.home": "/docker-java-home", 15 | "telemetry.enableTelemetry": false 16 | }, 17 | "extensions": [ 18 | "EditorConfig.EditorConfig", 19 | "kennylong.kubernetes-yaml-formatter", 20 | "vscjava.vscode-java-pack" 21 | ] 22 | } 23 | }, 24 | "remoteUser": "vscode", 25 | "containerEnv": { 26 | "HOME": "/home/vscode" 27 | } 28 | } -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | end_of_line = lf 3 | insert_final_newline = false 4 | trim_trailing_whitespace = true 5 | indent_style = space 6 | indent_size = 4 7 | 8 | [*.md] 9 | trim_trailing_whitespace = false 10 | insert_final_newline = true 11 | 12 | [*.yml] 13 | indent_size = 2 -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.png filter=lfs diff=lfs merge=lfs -text -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG] Something is wrong" 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Expected** 11 | What behavior did you expect? 12 | 13 | **Actual** 14 | What happened instead? 15 | 16 | **Relevant Versions** 17 | Nexus version: `` 18 | Plugin version: `` 19 | 20 | **Additional information** 21 | Whatever else could be good to know to figure out what's wrong. 22 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Tag-based release 2 | 3 | env: 4 | JDK_VERSION: 17 5 | 6 | on: 7 | create: 8 | tags: 9 | - "*" 10 | 11 | jobs: 12 | release: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | 18 | - name: Set up JDK 19 | uses: actions/setup-java@v4 20 | with: 21 | java-version: ${{ env.JDK_VERSION }} 22 | distribution: "temurin" 23 | cache: maven 24 | 25 | - name: Build with Maven 26 | run: | 27 | ./mvnw --batch-mode clean package -DskipTests -PbuildKar 28 | mv $(ls target/nexus-oauth2-proxy-plugin-*-bundle.kar) target/nexus-oauth2-proxy-plugin.kar 29 | 30 | - name: Release 31 | uses: softprops/action-gh-release@v2 32 | with: 33 | draft: false 34 | files: target/nexus-oauth2-proxy-plugin.kar 35 | name: Release ${{ github.ref_name }} 36 | prerelease: false 37 | token: ${{ secrets.GITHUB_TOKEN }} 38 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Test Java and Javascript code 2 | 3 | env: 4 | JDK_VERSION: 17 5 | NODEJS_VERSION: 18 6 | 7 | on: 8 | push: 9 | branches: 10 | - "**" 11 | pull_request: 12 | branches: 13 | - "**" 14 | 15 | jobs: 16 | yarn_test: 17 | name: Run Javascript tests 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v4 21 | 22 | - name: Set up Node.js 23 | uses: actions/setup-node@v4 24 | with: 25 | node-version: ${{ env.NODEJS_VERSION }} 26 | 27 | - name: Install Node.js dependencies 28 | run: yarn install 29 | 30 | - name: Run Node.js tests 31 | run: yarn test 32 | 33 | maven_test: 34 | name: Run Java tests 35 | runs-on: ubuntu-latest 36 | steps: 37 | - uses: actions/checkout@v4 38 | 39 | - name: Set up JDK 40 | uses: actions/setup-java@v4 41 | with: 42 | java-version: ${{ env.JDK_VERSION }} 43 | distribution: "temurin" 44 | cache: maven 45 | 46 | - name: Run Maven tests 47 | run: ./mvnw clean test 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | #.classpath // we need this because when auto generated some entries are missing 2 | .project 3 | .settings 4 | .vscode 5 | node_modules 6 | target 7 | -------------------------------------------------------------------------------- /.mvn/wrapper/maven-wrapper.properties: -------------------------------------------------------------------------------- 1 | # Licensed to the Apache Software Foundation (ASF) under one 2 | # or more contributor license agreements. See the NOTICE file 3 | # distributed with this work for additional information 4 | # regarding copyright ownership. The ASF licenses this file 5 | # to you under the Apache License, Version 2.0 (the 6 | # "License"); you may not use this file except in compliance 7 | # with the License. You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, 12 | # software distributed under the License is distributed on an 13 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | # KIND, either express or implied. See the License for the 15 | # specific language governing permissions and limitations 16 | # under the License. 17 | wrapperVersion=3.3.2 18 | distributionType=only-script 19 | distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip 20 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | 2 | Eclipse Public License - v 1.0 3 | 4 | THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. 5 | 6 | 1. DEFINITIONS 7 | 8 | "Contribution" means: 9 | 10 | a) in the case of the initial Contributor, the initial code and documentation distributed under this Agreement, and 11 | 12 | b) in the case of each subsequent Contributor: 13 | 14 | i) changes to the Program, and 15 | 16 | ii) additions to the Program; 17 | 18 | where such changes and/or additions to the Program originate from and are distributed by that particular Contributor. A Contribution 'originates' from a Contributor if it was added to the Program by such Contributor itself or anyone acting on such Contributor's behalf. Contributions do not include additions to the Program which: (i) are separate modules of software distributed in conjunction with the Program under their own license agreement, and (ii) are not derivative works of the Program. 19 | 20 | "Contributor" means any person or entity that distributes the Program. 21 | 22 | "Licensed Patents" mean patent claims licensable by a Contributor which are necessarily infringed by the use or sale of its Contribution alone or when combined with the Program. 23 | 24 | "Program" means the Contributions distributed in accordance with this Agreement. 25 | 26 | "Recipient" means anyone who receives the Program under this Agreement, including all Contributors. 27 | 28 | 2. GRANT OF RIGHTS 29 | 30 | a) Subject to the terms of this Agreement, each Contributor hereby grants Recipient a non-exclusive, worldwide, royalty-free copyright license to reproduce, prepare derivative works of, publicly display, publicly perform, distribute and sublicense the Contribution of such Contributor, if any, and such derivative works, in source code and object code form. 31 | 32 | b) Subject to the terms of this Agreement, each Contributor hereby grants Recipient a non-exclusive, worldwide, royalty-free patent license under Licensed Patents to make, use, sell, offer to sell, import and otherwise transfer the Contribution of such Contributor, if any, in source code and object code form. This patent license shall apply to the combination of the Contribution and the Program if, at the time the Contribution is added by the Contributor, such addition of the Contribution causes such combination to be covered by the Licensed Patents. The patent license shall not apply to any other combinations which include the Contribution. No hardware per se is licensed hereunder. 33 | 34 | c) Recipient understands that although each Contributor grants the licenses to its Contributions set forth herein, no assurances are provided by any Contributor that the Program does not infringe the patent or other intellectual property rights of any other entity. Each Contributor disclaims any liability to Recipient for claims brought by any other entity based on infringement of intellectual property rights or otherwise. As a condition to exercising the rights and licenses granted hereunder, each Recipient hereby assumes sole responsibility to secure any other intellectual property rights needed, if any. For example, if a third party patent license is required to allow Recipient to distribute the Program, it is Recipient's responsibility to acquire that license before distributing the Program. 35 | 36 | d) Each Contributor represents that to its knowledge it has sufficient copyright rights in its Contribution, if any, to grant the copyright license set forth in this Agreement. 37 | 38 | 3. REQUIREMENTS 39 | 40 | A Contributor may choose to distribute the Program in object code form under its own license agreement, provided that: 41 | 42 | a) it complies with the terms and conditions of this Agreement; and 43 | 44 | b) its license agreement: 45 | 46 | i) effectively disclaims on behalf of all Contributors all warranties and conditions, express and implied, including warranties or conditions of title and non-infringement, and implied warranties or conditions of merchantability and fitness for a particular purpose; 47 | 48 | ii) effectively excludes on behalf of all Contributors all liability for damages, including direct, indirect, special, incidental and consequential damages, such as lost profits; 49 | 50 | iii) states that any provisions which differ from this Agreement are offered by that Contributor alone and not by any other party; and 51 | 52 | iv) states that source code for the Program is available from such Contributor, and informs licensees how to obtain it in a reasonable manner on or through a medium customarily used for software exchange. 53 | 54 | When the Program is made available in source code form: 55 | 56 | a) it must be made available under this Agreement; and 57 | 58 | b) a copy of this Agreement must be included with each copy of the Program. 59 | 60 | Contributors may not remove or alter any copyright notices contained within the Program. 61 | 62 | Each Contributor must identify itself as the originator of its Contribution, if any, in a manner that reasonably allows subsequent Recipients to identify the originator of the Contribution. 63 | 64 | 4. COMMERCIAL DISTRIBUTION 65 | 66 | Commercial distributors of software may accept certain responsibilities with respect to end users, business partners and the like. While this license is intended to facilitate the commercial use of the Program, the Contributor who includes the Program in a commercial product offering should do so in a manner which does not create potential liability for other Contributors. Therefore, if a Contributor includes the Program in a commercial product offering, such Contributor ("Commercial Contributor") hereby agrees to defend and indemnify every other Contributor ("Indemnified Contributor") against any losses, damages and costs (collectively "Losses") arising from claims, lawsuits and other legal actions brought by a third party against the Indemnified Contributor to the extent caused by the acts or omissions of such Commercial Contributor in connection with its distribution of the Program in a commercial product offering. The obligations in this section do not apply to any claims or Losses relating to any actual or alleged intellectual property infringement. In order to qualify, an Indemnified Contributor must: a) promptly notify the Commercial Contributor in writing of such claim, and b) allow the Commercial Contributor to control, and cooperate with the Commercial Contributor in, the defense and any related settlement negotiations. The Indemnified Contributor may participate in any such claim at its own expense. 67 | 68 | For example, a Contributor might include the Program in a commercial product offering, Product X. That Contributor is then a Commercial Contributor. If that Commercial Contributor then makes performance claims, or offers warranties related to Product X, those performance claims and warranties are such Commercial Contributor's responsibility alone. Under this section, the Commercial Contributor would have to defend claims against the other Contributors related to those performance claims and warranties, and if a court requires any other Contributor to pay any damages as a result, the Commercial Contributor must pay those damages. 69 | 70 | 5. NO WARRANTY 71 | 72 | EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, THE PROGRAM IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR IMPLIED INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR CONDITIONS OF TITLE, NON-INFRINGEMENT, MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. Each Recipient is solely responsible for determining the appropriateness of using and distributing the Program and assumes all risks associated with its exercise of rights under this Agreement , including but not limited to the risks and costs of program errors, compliance with applicable laws, damage to or loss of data, programs or equipment, and unavailability or interruption of operations. 73 | 74 | 6. DISCLAIMER OF LIABILITY 75 | 76 | EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, NEITHER RECIPIENT NOR ANY CONTRIBUTORS SHALL HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING WITHOUT LIMITATION LOST PROFITS), HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OR DISTRIBUTION OF THE PROGRAM OR THE EXERCISE OF ANY RIGHTS GRANTED HEREUNDER, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 77 | 78 | 7. GENERAL 79 | 80 | If any provision of this Agreement is invalid or unenforceable under applicable law, it shall not affect the validity or enforceability of the remainder of the terms of this Agreement, and without further action by the parties hereto, such provision shall be reformed to the minimum extent necessary to make such provision valid and enforceable. 81 | 82 | If Recipient institutes patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Program itself (excluding combinations of the Program with other software or hardware) infringes such Recipient's patent(s), then such Recipient's rights granted under Section 2(b) shall terminate as of the date such litigation is filed. 83 | 84 | All Recipient's rights under this Agreement shall terminate if it fails to comply with any of the material terms or conditions of this Agreement and does not cure such failure in a reasonable period of time after becoming aware of such noncompliance. If all Recipient's rights under this Agreement terminate, Recipient agrees to cease use and distribution of the Program as soon as reasonably practicable. However, Recipient's obligations under this Agreement and any licenses granted by Recipient relating to the Program shall continue and survive. 85 | 86 | Everyone is permitted to copy and distribute copies of this Agreement, but in order to avoid inconsistency the Agreement is copyrighted and may only be modified in the following manner. The Agreement Steward reserves the right to publish new versions (including revisions) of this Agreement from time to time. No one other than the Agreement Steward has the right to modify this Agreement. The Eclipse Foundation is the initial Agreement Steward. The Eclipse Foundation may assign the responsibility to serve as the Agreement Steward to a suitable separate entity. Each new version of the Agreement will be given a distinguishing version number. The Program (including Contributions) may always be distributed subject to the version of the Agreement under which it was received. In addition, after a new version of the Agreement is published, Contributor may elect to distribute the Program (including its Contributions) under the new version. Except as expressly stated in Sections 2(a) and 2(b) above, Recipient receives no rights or licenses to the intellectual property of any Contributor under this Agreement, whether expressly, by implication, estoppel or otherwise. All rights in the Program not expressly granted under this Agreement are reserved. 87 | 88 | This Agreement is governed by the laws of the State of New York and the intellectual property laws of the United States of America. No party to this Agreement will bring a legal action under this Agreement more than one year after the cause of action arose. Each party waives its rights to a jury trial in any resulting litigation. 89 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | ./mvnw -PbuildKar clean package 4 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | ⚠️ Note: With 3.78 Sonatype [broke the loading of custom plugins](https://community.sonatype.com/t/custom-plugins-still-possible-starting-with-3-78/14589). Do not upgrade to that version or higher unless you have a solution for that problem. Unfortunately, there is nothing that can be done about it for now. 2 | 3 | # OpenID Connect for Sonatype Nexus Artifact Repository 4 | 5 | This plugin has been developed to facilitate the integration of Nexus with any identity provider that is compatible with [OAuth2 Proxy](https://github.com/oauth2-proxy/oauth2-proxy). 6 | 7 | Rather than executing its own OIDC (OpenID Connect) authentication flow, this plugin leverages OAuth2 Proxy to undertake the authentication process, relying on it to provide the necessary information through headers. 8 | 9 | Furthermore, acknowledging the importance of non-interactive programmatic access within the Nexus environment, this plugin incorporates an API token feature. The plugin introduces an additional endpoint that allows authenticated users to reset their own API token to a system-generated one via the Nexus UI, with the caveat that this token is displayed solely once and is subject to reset with each access of this user menu item. 10 | 11 | ## Disclaimer 12 | 13 | It is important to highlight that this plugin is provided on an 'as-is' basis, without any form of express or implied warranty. Under no circumstances shall the authors be held accountable for any damages or liabilities arising from the utilization of this plugin. Users are advised to proceed at their own risk. 14 | 15 | ## Features 16 | 17 | * makes use of several headers sent by OAuth2 proxy (depending on its configuration) 18 | * see constants in [OAuth2ProxyHeaderAuthTokenFactory](../src/main/java/com/github/tumbl3w33d/OAuth2ProxyHeaderAuthTokenFactory.java) 19 | * creates an AuthenticationToken used by Nexus 20 | * creates a user in a dedicated database table (i.e., not where Nexus checks for 'Local' users) if none with the given id (`preferred_username`) exists 21 | * anyone authenticated with your identity provider can access Nexus 22 | * you would control access by granting necessary scopes accessing OAuth2 Proxy only to eligible user groups 23 | * user creation currently has a rather simplistic strategy to extract `.` from `preferred_username` 24 | * group/scope to role sync 25 | * if you configure OAuth2 Proxy with the well-known `groups` claim, it will retrieve that information from the identity provider 26 | * the groups received in the related header will be stored in a dedicated database table and become available for the 'external role mapping' functionality 27 | * ⚠️ note: [currently it is necessary](https://github.com/tumbl3w33d/nexus-oauth2-proxy-plugin/issues/26) to use this mapping mechanism because assigning Nexus' default roles to users created via plugin has no effect 28 | * automatic expiry of API tokens 29 | * there is a configurable task that lets API tokens expire, so another login and manual renewal by the user is necessary 30 | * by default, the token will expire after 30 days of inactivity. As long as the user keeps showing up regularly, their token will not expire 31 | * The expiration can be configured as a regular nexus task. You can: 32 | * adjust inactivity period that leads to token invalidation 33 | * enable and set max token age, meaning the token automatically expires after a certain time, regardless of activity 34 | * enable mail notifications on token expiry (requires having a mail server configured in Nexus to work) 35 | * backchannel logout in IDP via oauth2 proxy (if supported) when the logout is performed in Nexus 36 | * make sure to enable the OAuth2 Proxy: Logout capability in Nexus to make this work 37 | 38 | **Note**: If the OAuth2 Proxy: Logout capability is not enabled, the logout button is non-operative, which is a common limitation with header-based authentication methods. To force a logout, you need to logout from your identity provider and/or delete the OAuth2 Proxy cookie if you must logout for some reason. 39 | 40 | ## Supported Nexus version 41 | 42 | **Note**: See the warning on top of this document. 43 | 44 | This plugin moves along with the latest OSS version of Nexus. 45 | 46 | When they introduce breaking changes, like the change of underlying database with version 3.71.0, this results in a new major version of this plugin being released when adjustments have been made. You are free to use older versions but they will probably not receive maintenance, unless you contribute it yourself. In addition, as long as the user base is small and quiet, there will not be much effort invested in adding complex migration logic. Since this plugin is mostly developed for internal use (so far), an appropriate solution for that use case will be found and that might mean dropping existing data (which basically means persisted API tokens) and start over in order keep things simple. 47 | 48 | ## Installation 49 | 50 | The recommended way to install the plugin is by dropping the `.kar` into the Nexus `/deploy` folder, as described [here](https://sonatype-nexus-community.github.io/nexus-development-guides/plugin-install.html#more-permanent-install). 51 | 52 | Once Nexus picked it up, it will offer you to activate the new realm this plugin comes with: 53 | 54 | ![alt text](images/realm_installation.png) 55 | 56 | You can see the `OAuth2ProxyRealm` activated and first in the list, so it takes effect first in the authentication chain. 57 | 58 | Now you should be able to access the Nexus via your OAuth2 Proxy and end up logged in. If you encounter strange behavior during this activation phase, it is easiest to test with a private browser window to rule out cached cookies or basic authentication can interfere. 59 | 60 | Users that logged in using this realm will now appear in the Nexus administration section under `Security -> Users -> Source: OAuth2Proxy`. 61 | 62 | You also want to visit `System -> Tasks` where you will find a new task for invalidating API tokens for users who did not show up for a while. While it has a default of 30d defined in the code, it appeared that it does not take effect until once set and saved via UI, so make sure you set an appropriate value there. 63 | 64 | ## Necessary infrastructure 65 | 66 | You typically put an OAuth2 Proxy in front of your application and make sure that related `X-Forwarded-` headers do not reach the application other than those originating from the OAuth2 Proxy. 67 | 68 | For non-interactive programmatic access you circumvent the OAuth2 Proxy and go straight to the Nexus application. To achieve that, you could check for the presence of an `Authorization: Basic` header earlier in the chain of proxies. In that case the required credentials are the user's id and the generated API token. 69 | 70 | ## Example with HAProxy as entrypoint 71 | 72 | ```apacheconf 73 | # the entrypoint to your nexus + oauth2 proxy setup 74 | frontend you-name-it 75 | # just illustrating that you must ensure TLS 76 | bind *:443 ssl crt /usr/local/etc/haproxy/cert alpn h2,http/1.1 77 | 78 | # if this is too invasive for your use case, be more specific 79 | http-request del-header ^X-Forwarded.* 80 | 81 | # use case: artifact download from /repository via UI 82 | use_backend be_oauth2-proxy if { req.cook(_oauth2_proxy) -m found } 83 | 84 | # use case: programmatic access, circumvent oauth2 proxy 85 | acl is_basic_auth hdr_beg(Authorization) -i basic 86 | acl is_repo_req path_beg /repository/ 87 | use_backend be_nexus if is_basic_auth OR is_repo_req 88 | 89 | # use case: interactive access via browser 90 | default_backend oauth2-proxy 91 | 92 | 93 | backend oauth2-proxy 94 | # interactive OIDC login 95 | option httpchk GET /ping 96 | server oauth2-proxy oauth2-proxy:4180 check 97 | 98 | 99 | backend nexus 100 | # non-interactive programmatic access 101 | server nexus nexus:8081 check 102 | ``` 103 | 104 | ## Example with Nginx as entrypoint 105 | 106 | ```apacheconf 107 | ... 108 | ... 109 | # The block server only 110 | server { 111 | listen 443 ssl; 112 | server_name your_nexus_host; 113 | 114 | proxy_headers_hash_bucket_size 128; 115 | 116 | ssl_certificate /etc/nginx/certs/server-tls.crt; 117 | ssl_certificate_key /etc/nginx/certs/user.key; 118 | 119 | 120 | location / { 121 | # Clear existing headers that will be added upstream by oauth2-proxy (Optional, depends on your config) 122 | proxy_set_header X-Forwarded-Email ""; 123 | proxy_set_header X-Forwarded-Groups ""; 124 | proxy_set_header X-Forwarded-User ""; 125 | 126 | # Set proxy pass headers 127 | proxy_set_header Host $host; 128 | proxy_set_header X-Real-IP $remote_addr; 129 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 130 | proxy_set_header X-Forwarded-Proto $scheme; 131 | proxy_set_header Cookie $http_cookie; 132 | 133 | proxy_pass http://oauth2-proxy:4180; 134 | 135 | # Usually oauth2-proxy delivers the login screen with a 403 status code. 136 | # Safari can't handle that so we intercept 403 errors and return 200 instead. 137 | proxy_intercept_errors on; 138 | error_page 403 =200 @change_status; 139 | } 140 | 141 | location @change_status { 142 | # proxy to the auth-proxy, but this time with status code 200 143 | proxy_pass http://auth2-proxy:4180; 144 | } 145 | } 146 | ... 147 | ... 148 | ``` 149 | 150 | ## Example OAuth2 Proxy config 151 | 152 | ```apacheconf 153 | reverse_proxy = true 154 | 155 | http_address = "0.0.0.0:4180" 156 | 157 | email_domains = [ "*" ] 158 | 159 | # make sure to request group information for mapping to roles 160 | scope = "openid email profile groups" 161 | 162 | # your nexus is the backend 163 | upstreams = ["http://nexus:8081"] 164 | 165 | provider = "oidc" 166 | oidc_issuer_url = "https://idm.example.com/consult/your/idp-documentation" 167 | code_challenge_method = "S256" # PKCE, if your idp supports that 168 | client_id = "get the client id from your identity provider" 169 | client_secret = "get the secret from your identity provider" 170 | cookie_secret = "generate an individual cookie secret" 171 | backend_logout_url = "https://idm.example.com/consult/your/idp-documentation/for/logout-url?id_token_hint={id_token}" 172 | 173 | # we don't need to wait for people to press the button, just redirect 174 | skip_provider_button = true 175 | ``` 176 | 177 | **Note**: Depending on the amount of data the OAuth2 Proxy receives from your IDP (especially list of groups) you might want to look into [changing its session storage to redis/valkey](https://oauth2-proxy.github.io/oauth2-proxy/configuration/session_storage/#redis-storage). The proxy will warn you about data exceeding limits which results in multiple cookies being set for the proxy session. 178 | 179 | ## Optional: Bearer token authentication and token rotation 180 | 181 | If for some reason you need to use a Bearer token for machine-to-machine communcation or in general accessing Nexus programmatically e.g. because corporate guidelines prevent you from using Basic Auth using the api token, it is possible to set this up: 182 | 183 | Add 'skip_jwt_bearer_tokens = true' to your OAuth2 Proxy configuration. This flag makes OAuth2 Proxy optionally accept Bearer tokens instead of performing the auth flow itself as long as the Bearer token is valid and the audience matches the configured client id. For details, check the [official documentation](https://oauth2-proxy.github.io/oauth2-proxy/configuration/overview/). OAuth2 Proxy will then populate the x-forwarded headers based on information from this token, so for Nexus the login mechanism is still transparent. 184 | 185 | Leveraging this way of authenticating it is possible to create an automatic token rotation even if the API token is invalidated by obtaining the Bearer token via some kind of OIDC login and then using it to perform an authenticated REST call against https://your_nexus_host/service/rest/oauth2-proxy/user/reset-token to obtain a new API token. Keep in mind that this REST call immediately invalidates the old token! Also make sure your reverse proxy is configured to route this URL to Nexus via OAuth2 Proxy (if you use the above example configs, this should automatically be the case) 186 | 187 | ## Troubleshooting 188 | 189 | If you encounter authentication issues, you can activate logging for the plugin classes by creating a logger in the Nexus administration section (`Support -> Logging -> Create Logger`), e.g. for the top level package `com.github.tumbl3w33d`. 190 | 191 | ## Use with Authentik 192 | 193 | A user of the plugin [successfully configured their Authentik installation](https://github.com/tumbl3w33d/nexus-oauth2-proxy-plugin/issues/25#issuecomment-2563165385) in place of OAuth2 Proxy. While this setup is not being tested during development, it will probably work fine. 194 | -------------------------------------------------------------------------------- /docs/images/realm_installation.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:9c4698cffa73e7016a7acb55d90edbd68ec6dc4d9e4361f7a4dfc88ce76eb021 3 | size 105413 4 | -------------------------------------------------------------------------------- /docs/release_process.md: -------------------------------------------------------------------------------- 1 | # How to publish a new release 2 | 3 | * set new version in the pom 4 | * create a commit for the change 5 | * tag the commit with a signed tag 6 | * push the commit and the tag 7 | * update the pom to the new development version 8 | * create a commit for the change 9 | * push the commit 10 | 11 | ## Example 12 | 13 | * a minor version is to be released 14 | * current version in pom is `0.1.0-SNAPSHOT` 15 | 16 | Steps: 17 | 18 | ```shell 19 | mvn versions:set -DnewVersion=0.1.0 20 | git commit -am "Prepare for release 0.1.0" 21 | git tag -s 0.1.0 -m "0.1.0" 22 | git push && git push --tags 23 | mvn versions:set -DnewVersion=0.2.0-SNAPSHOT 24 | git commit -am "Start next development iteration with 0.2.0-SNAPSHOT" 25 | git push 26 | ``` 27 | -------------------------------------------------------------------------------- /mvnw: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # ---------------------------------------------------------------------------- 3 | # Licensed to the Apache Software Foundation (ASF) under one 4 | # or more contributor license agreements. See the NOTICE file 5 | # distributed with this work for additional information 6 | # regarding copyright ownership. The ASF licenses this file 7 | # to you under the Apache License, Version 2.0 (the 8 | # "License"); you may not use this file except in compliance 9 | # with the License. You may obtain a copy of the License at 10 | # 11 | # http://www.apache.org/licenses/LICENSE-2.0 12 | # 13 | # Unless required by applicable law or agreed to in writing, 14 | # software distributed under the License is distributed on an 15 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 16 | # KIND, either express or implied. See the License for the 17 | # specific language governing permissions and limitations 18 | # under the License. 19 | # ---------------------------------------------------------------------------- 20 | 21 | # ---------------------------------------------------------------------------- 22 | # Apache Maven Wrapper startup batch script, version 3.3.2 23 | # 24 | # Optional ENV vars 25 | # ----------------- 26 | # JAVA_HOME - location of a JDK home dir, required when download maven via java source 27 | # MVNW_REPOURL - repo url base for downloading maven distribution 28 | # MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven 29 | # MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output 30 | # ---------------------------------------------------------------------------- 31 | 32 | set -euf 33 | [ "${MVNW_VERBOSE-}" != debug ] || set -x 34 | 35 | # OS specific support. 36 | native_path() { printf %s\\n "$1"; } 37 | case "$(uname)" in 38 | CYGWIN* | MINGW*) 39 | [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" 40 | native_path() { cygpath --path --windows "$1"; } 41 | ;; 42 | esac 43 | 44 | # set JAVACMD and JAVACCMD 45 | set_java_home() { 46 | # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched 47 | if [ -n "${JAVA_HOME-}" ]; then 48 | if [ -x "$JAVA_HOME/jre/sh/java" ]; then 49 | # IBM's JDK on AIX uses strange locations for the executables 50 | JAVACMD="$JAVA_HOME/jre/sh/java" 51 | JAVACCMD="$JAVA_HOME/jre/sh/javac" 52 | else 53 | JAVACMD="$JAVA_HOME/bin/java" 54 | JAVACCMD="$JAVA_HOME/bin/javac" 55 | 56 | if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then 57 | echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 58 | echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 59 | return 1 60 | fi 61 | fi 62 | else 63 | JAVACMD="$( 64 | 'set' +e 65 | 'unset' -f command 2>/dev/null 66 | 'command' -v java 67 | )" || : 68 | JAVACCMD="$( 69 | 'set' +e 70 | 'unset' -f command 2>/dev/null 71 | 'command' -v javac 72 | )" || : 73 | 74 | if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then 75 | echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 76 | return 1 77 | fi 78 | fi 79 | } 80 | 81 | # hash string like Java String::hashCode 82 | hash_string() { 83 | str="${1:-}" h=0 84 | while [ -n "$str" ]; do 85 | char="${str%"${str#?}"}" 86 | h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) 87 | str="${str#?}" 88 | done 89 | printf %x\\n $h 90 | } 91 | 92 | verbose() { :; } 93 | [ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } 94 | 95 | die() { 96 | printf %s\\n "$1" >&2 97 | exit 1 98 | } 99 | 100 | trim() { 101 | # MWRAPPER-139: 102 | # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. 103 | # Needed for removing poorly interpreted newline sequences when running in more 104 | # exotic environments such as mingw bash on Windows. 105 | printf "%s" "${1}" | tr -d '[:space:]' 106 | } 107 | 108 | # parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties 109 | while IFS="=" read -r key value; do 110 | case "${key-}" in 111 | distributionUrl) distributionUrl=$(trim "${value-}") ;; 112 | distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; 113 | esac 114 | done <"${0%/*}/.mvn/wrapper/maven-wrapper.properties" 115 | [ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in ${0%/*}/.mvn/wrapper/maven-wrapper.properties" 116 | 117 | case "${distributionUrl##*/}" in 118 | maven-mvnd-*bin.*) 119 | MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ 120 | case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in 121 | *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; 122 | :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; 123 | :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; 124 | :Linux*x86_64*) distributionPlatform=linux-amd64 ;; 125 | *) 126 | echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 127 | distributionPlatform=linux-amd64 128 | ;; 129 | esac 130 | distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" 131 | ;; 132 | maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; 133 | *) MVN_CMD="mvn${0##*/mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; 134 | esac 135 | 136 | # apply MVNW_REPOURL and calculate MAVEN_HOME 137 | # maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ 138 | [ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" 139 | distributionUrlName="${distributionUrl##*/}" 140 | distributionUrlNameMain="${distributionUrlName%.*}" 141 | distributionUrlNameMain="${distributionUrlNameMain%-bin}" 142 | MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" 143 | MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" 144 | 145 | exec_maven() { 146 | unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : 147 | exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" 148 | } 149 | 150 | if [ -d "$MAVEN_HOME" ]; then 151 | verbose "found existing MAVEN_HOME at $MAVEN_HOME" 152 | exec_maven "$@" 153 | fi 154 | 155 | case "${distributionUrl-}" in 156 | *?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; 157 | *) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; 158 | esac 159 | 160 | # prepare tmp dir 161 | if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then 162 | clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } 163 | trap clean HUP INT TERM EXIT 164 | else 165 | die "cannot create temp dir" 166 | fi 167 | 168 | mkdir -p -- "${MAVEN_HOME%/*}" 169 | 170 | # Download and Install Apache Maven 171 | verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." 172 | verbose "Downloading from: $distributionUrl" 173 | verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" 174 | 175 | # select .zip or .tar.gz 176 | if ! command -v unzip >/dev/null; then 177 | distributionUrl="${distributionUrl%.zip}.tar.gz" 178 | distributionUrlName="${distributionUrl##*/}" 179 | fi 180 | 181 | # verbose opt 182 | __MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' 183 | [ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v 184 | 185 | # normalize http auth 186 | case "${MVNW_PASSWORD:+has-password}" in 187 | '') MVNW_USERNAME='' MVNW_PASSWORD='' ;; 188 | has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; 189 | esac 190 | 191 | if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then 192 | verbose "Found wget ... using wget" 193 | wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" 194 | elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then 195 | verbose "Found curl ... using curl" 196 | curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" 197 | elif set_java_home; then 198 | verbose "Falling back to use Java to download" 199 | javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" 200 | targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" 201 | cat >"$javaSource" <<-END 202 | public class Downloader extends java.net.Authenticator 203 | { 204 | protected java.net.PasswordAuthentication getPasswordAuthentication() 205 | { 206 | return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); 207 | } 208 | public static void main( String[] args ) throws Exception 209 | { 210 | setDefault( new Downloader() ); 211 | java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); 212 | } 213 | } 214 | END 215 | # For Cygwin/MinGW, switch paths to Windows format before running javac and java 216 | verbose " - Compiling Downloader.java ..." 217 | "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" 218 | verbose " - Running Downloader.java ..." 219 | "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" 220 | fi 221 | 222 | # If specified, validate the SHA-256 sum of the Maven distribution zip file 223 | if [ -n "${distributionSha256Sum-}" ]; then 224 | distributionSha256Result=false 225 | if [ "$MVN_CMD" = mvnd.sh ]; then 226 | echo "Checksum validation is not supported for maven-mvnd." >&2 227 | echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 228 | exit 1 229 | elif command -v sha256sum >/dev/null; then 230 | if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c >/dev/null 2>&1; then 231 | distributionSha256Result=true 232 | fi 233 | elif command -v shasum >/dev/null; then 234 | if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then 235 | distributionSha256Result=true 236 | fi 237 | else 238 | echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 239 | echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 240 | exit 1 241 | fi 242 | if [ $distributionSha256Result = false ]; then 243 | echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 244 | echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 245 | exit 1 246 | fi 247 | fi 248 | 249 | # unzip and move 250 | if command -v unzip >/dev/null; then 251 | unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" 252 | else 253 | tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" 254 | fi 255 | printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/mvnw.url" 256 | mv -- "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" 257 | 258 | clean || : 259 | exec_maven "$@" 260 | -------------------------------------------------------------------------------- /mvnw.cmd: -------------------------------------------------------------------------------- 1 | <# : batch portion 2 | @REM ---------------------------------------------------------------------------- 3 | @REM Licensed to the Apache Software Foundation (ASF) under one 4 | @REM or more contributor license agreements. See the NOTICE file 5 | @REM distributed with this work for additional information 6 | @REM regarding copyright ownership. The ASF licenses this file 7 | @REM to you under the Apache License, Version 2.0 (the 8 | @REM "License"); you may not use this file except in compliance 9 | @REM with the License. You may obtain a copy of the License at 10 | @REM 11 | @REM http://www.apache.org/licenses/LICENSE-2.0 12 | @REM 13 | @REM Unless required by applicable law or agreed to in writing, 14 | @REM software distributed under the License is distributed on an 15 | @REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 16 | @REM KIND, either express or implied. See the License for the 17 | @REM specific language governing permissions and limitations 18 | @REM under the License. 19 | @REM ---------------------------------------------------------------------------- 20 | 21 | @REM ---------------------------------------------------------------------------- 22 | @REM Apache Maven Wrapper startup batch script, version 3.3.2 23 | @REM 24 | @REM Optional ENV vars 25 | @REM MVNW_REPOURL - repo url base for downloading maven distribution 26 | @REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven 27 | @REM MVNW_VERBOSE - true: enable verbose log; others: silence the output 28 | @REM ---------------------------------------------------------------------------- 29 | 30 | @IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) 31 | @SET __MVNW_CMD__= 32 | @SET __MVNW_ERROR__= 33 | @SET __MVNW_PSMODULEP_SAVE=%PSModulePath% 34 | @SET PSModulePath= 35 | @FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( 36 | IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) 37 | ) 38 | @SET PSModulePath=%__MVNW_PSMODULEP_SAVE% 39 | @SET __MVNW_PSMODULEP_SAVE= 40 | @SET __MVNW_ARG0_NAME__= 41 | @SET MVNW_USERNAME= 42 | @SET MVNW_PASSWORD= 43 | @IF NOT "%__MVNW_CMD__%"=="" (%__MVNW_CMD__% %*) 44 | @echo Cannot start maven from wrapper >&2 && exit /b 1 45 | @GOTO :EOF 46 | : end batch / begin powershell #> 47 | 48 | $ErrorActionPreference = "Stop" 49 | if ($env:MVNW_VERBOSE -eq "true") { 50 | $VerbosePreference = "Continue" 51 | } 52 | 53 | # calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties 54 | $distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl 55 | if (!$distributionUrl) { 56 | Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" 57 | } 58 | 59 | switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { 60 | "maven-mvnd-*" { 61 | $USE_MVND = $true 62 | $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" 63 | $MVN_CMD = "mvnd.cmd" 64 | break 65 | } 66 | default { 67 | $USE_MVND = $false 68 | $MVN_CMD = $script -replace '^mvnw','mvn' 69 | break 70 | } 71 | } 72 | 73 | # apply MVNW_REPOURL and calculate MAVEN_HOME 74 | # maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ 75 | if ($env:MVNW_REPOURL) { 76 | $MVNW_REPO_PATTERN = if ($USE_MVND) { "/org/apache/maven/" } else { "/maven/mvnd/" } 77 | $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace '^.*'+$MVNW_REPO_PATTERN,'')" 78 | } 79 | $distributionUrlName = $distributionUrl -replace '^.*/','' 80 | $distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' 81 | $MAVEN_HOME_PARENT = "$HOME/.m2/wrapper/dists/$distributionUrlNameMain" 82 | if ($env:MAVEN_USER_HOME) { 83 | $MAVEN_HOME_PARENT = "$env:MAVEN_USER_HOME/wrapper/dists/$distributionUrlNameMain" 84 | } 85 | $MAVEN_HOME_NAME = ([System.Security.Cryptography.MD5]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' 86 | $MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" 87 | 88 | if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { 89 | Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" 90 | Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" 91 | exit $? 92 | } 93 | 94 | if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { 95 | Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" 96 | } 97 | 98 | # prepare tmp dir 99 | $TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile 100 | $TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" 101 | $TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null 102 | trap { 103 | if ($TMP_DOWNLOAD_DIR.Exists) { 104 | try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } 105 | catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } 106 | } 107 | } 108 | 109 | New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null 110 | 111 | # Download and Install Apache Maven 112 | Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." 113 | Write-Verbose "Downloading from: $distributionUrl" 114 | Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" 115 | 116 | $webclient = New-Object System.Net.WebClient 117 | if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { 118 | $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) 119 | } 120 | [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 121 | $webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null 122 | 123 | # If specified, validate the SHA-256 sum of the Maven distribution zip file 124 | $distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum 125 | if ($distributionSha256Sum) { 126 | if ($USE_MVND) { 127 | Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." 128 | } 129 | Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash 130 | if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { 131 | Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." 132 | } 133 | } 134 | 135 | # unzip and move 136 | Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null 137 | Rename-Item -Path "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" -NewName $MAVEN_HOME_NAME | Out-Null 138 | try { 139 | Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null 140 | } catch { 141 | if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { 142 | Write-Error "fail to move MAVEN_HOME" 143 | } 144 | } finally { 145 | try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } 146 | catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } 147 | } 148 | 149 | Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" 150 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nexus-oauth2-proxy-plugin", 3 | "version": "1.0.0", 4 | "private": true, 5 | "scripts": { 6 | "build": "webpack --config ./src/frontend/webpack.config.js", 7 | "build-all": "webpack --config ./src/frontend/webpack.config.js", 8 | "test": "jest", 9 | "test-coverage": "jest --coverage", 10 | "test-watch": "jest --watch", 11 | "watch": "webpack --watch --config ./src/frontend/webpack.config.js" 12 | }, 13 | "dependencies": { 14 | "@fortawesome/free-solid-svg-icons": "5", 15 | "axios": ">=1.6", 16 | "react": "17" 17 | }, 18 | "devDependencies": { 19 | "@babel/core": "7", 20 | "@babel/preset-env": "7", 21 | "@babel/preset-react": "7", 22 | "@testing-library/dom": "10", 23 | "@testing-library/jest-dom": "6", 24 | "@testing-library/react": "12", 25 | "@types/react": "17", 26 | "babel-jest": "29", 27 | "babel-loader": "9", 28 | "react-dom": "17", 29 | "jest": "29", 30 | "jest-environment-jsdom": "29", 31 | "terser-webpack-plugin": "5", 32 | "webpack": ">=5.94", 33 | "webpack-cli": "5" 34 | }, 35 | "babel": { 36 | "presets": [ 37 | "@babel/preset-react", 38 | "@babel/preset-env" 39 | ] 40 | }, 41 | "jest": { 42 | "clearMocks": true, 43 | "coverageDirectory": "/target/frontend-coverage", 44 | "coveragePathIgnorePatterns": [ 45 | "/node_modules/" 46 | ], 47 | "moduleFileExtensions": [ 48 | "js", 49 | "jsx" 50 | ], 51 | "roots": [ 52 | "/src/frontend" 53 | ], 54 | "testEnvironment": "jsdom", 55 | "testMatch": [ 56 | "**/?(*.)test.jsx" 57 | ], 58 | "transform": { 59 | "^.+\\.jsx$": "babel-jest" 60 | } 61 | } 62 | } -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 4.0.0 6 | 7 | 8 | org.sonatype.nexus.plugins 9 | nexus-plugins 10 | 3.75.1-01 11 | 12 | 13 | com.github.tumbl3w33d 14 | nexus-oauth2-proxy-plugin 15 | 3.6.0-SNAPSHOT 16 | ${project.groupId}:${project.artifactId} 17 | bundle 18 | 19 | https://github.com/tumbl3w33d/nexus-oauth2-proxy-plugin 20 | 21 | This plugin adds a OAuth2 proxy realm to Sonatype Nexus OSS and enables you 22 | to authenticate with users of your OIDC identity provider and authorize depending 23 | on their OIDC scopes. 24 | 25 | 26 | 30 | 31 | 32 | 33 | true 34 | 35 | 36 | 37 | groovy-plugins-release 38 | https://groovy.jfrog.io/artifactory/plugins-release 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | org.apache.karaf.tooling 48 | karaf-maven-plugin 49 | 50 | 51 | com.github.eirslett 52 | frontend-maven-plugin 53 | 54 | 55 | maven-surefire-plugin 56 | 57 | false 58 | 59 | 60 | 61 | maven-compiler-plugin 62 | 63 | 17 64 | 17 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | org.sonatype.nexus 73 | nexus-plugin-api 74 | provided 75 | 76 | 81 | 82 | javax.activation 83 | activation 84 | 85 | 86 | 87 | 88 | org.sonatype.nexus 89 | nexus-capability 90 | provided 91 | 92 | 93 | org.sonatype.nexus 94 | nexus-common 95 | provided 96 | 97 | 98 | org.sonatype.nexus 99 | nexus-rest 100 | provided 101 | 102 | 103 | org.sonatype.nexus 104 | nexus-datastore-mybatis 105 | provided 106 | 107 | 108 | 109 | org.sonatype.nexus 110 | nexus-rapture 111 | provided 112 | 113 | 114 | org.apache.httpcomponents 115 | httpclient 116 | provided 117 | 118 | 119 | 120 | org.junit.jupiter 121 | junit-jupiter-engine 122 | 5.11.3 123 | test 124 | 125 | 126 | org.junit.jupiter 127 | junit-jupiter-api 128 | 5.11.3 129 | test 130 | 131 | 132 | org.mockito 133 | mockito-core 134 | 5.14.2 135 | test 136 | 137 | 138 | org.mockito 139 | mockito-junit-jupiter 140 | 5.14.2 141 | test 142 | 143 | 144 | com.github.valfirst 145 | slf4j-test 146 | 3.0.1 147 | test 148 | 149 | 150 | -------------------------------------------------------------------------------- /src/frontend/src/components/OAuth2ProxyApiTokenComponent.jsx: -------------------------------------------------------------------------------- 1 | import Axios from 'axios'; 2 | import React from 'react'; 3 | import { faKey } from '@fortawesome/free-solid-svg-icons'; 4 | 5 | import { 6 | ContentBody, 7 | Page, 8 | PageHeader, 9 | PageTitle, 10 | Section, 11 | SectionFooter 12 | } from '@sonatype/nexus-ui-plugin'; 13 | 14 | export default function OAuth2ProxyApiTokenComponent() { 15 | return 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | } 24 | 25 | function TokenSection() { 26 | const [token, setToken] = React.useState('****************************************'); 27 | const [resetFailed, setResetFailed] = React.useState(false); 28 | const [resetInProgress, setResetInProgress] = React.useState(false); 29 | const [tokenFreshlyReset, setTokenFreshlyReset] = React.useState(false); 30 | 31 | const resetToken = React.useCallback(async () => { 32 | if(resetInProgress) { 33 | console.log("Still resetting the token, not sending another request now"); 34 | } else { 35 | setResetInProgress(true); 36 | Axios.post("/service/rest/oauth2-proxy/user/reset-token") 37 | .then(response => { 38 | setToken(response.data); 39 | setTokenFreshlyReset(true); 40 | setResetInProgress(false); 41 | }) 42 | .catch(error => { 43 | setResetFailed(true); 44 | console.error('Failed to reset token:' + JSON.stringify(error.toJSON())); 45 | setResetInProgress(false); 46 | }); 47 | } 48 | }, [resetInProgress]) 49 | 50 | if(resetFailed) { 51 | return
52 |

⛔ Failed to generate a new access token

53 |
54 | } 55 | 56 | if(tokenFreshlyReset) { 57 | return
58 |

✔ A new API token has been created. It is only displayed once. Store it in a safe place!

59 |

⚠️ The old API token has been invalidated

60 |

💡 Make sure no one was watching when displaying this token. If in doubt, just reset it once more.

61 | resetToken()} token={token}/> 62 |
63 | } 64 | 65 | return
66 |

⚠️ Your current API token is hidden. Click the button to generate a new token

67 |

⚠️ When a new token is generated, the old one is invalidated immediately

68 | resetToken()} token={token}/> 69 |
70 | } 71 | 72 | function TokenFooter({resetInProgress, resetPressed, token}) { 73 | const buttonStyle = { 74 | marginRight: '1em', 75 | minWidth: '10em' 76 | } 77 | let buttonText = resetInProgress ? "Generating..." : "Regenerate Token" 78 | return 79 | 80 | Your API token: {token} 81 | 82 | } -------------------------------------------------------------------------------- /src/frontend/src/components/OAuth2ProxyApiTokenComponent.test.jsx: -------------------------------------------------------------------------------- 1 | import Axios from 'axios'; 2 | import React from 'react'; 3 | import OAuth2ProxyApiTokenComponent from './OAuth2ProxyApiTokenComponent'; 4 | 5 | import '@testing-library/jest-dom' 6 | import { act, render, fireEvent } from '@testing-library/react'; 7 | 8 | jest.mock('axios'); 9 | 10 | // mock private @sonatype/nexus-ui-plugin 11 | const createMockComponent = () => jest.fn(({ children, ...props }) =>
{children}
); 12 | jest.mock('@sonatype/nexus-ui-plugin', () => ({ 13 | Page: createMockComponent(), 14 | PageHeader: createMockComponent(), 15 | PageTitle: createMockComponent(), 16 | ContentBody: createMockComponent(), 17 | Section: createMockComponent(), 18 | SectionFooter: createMockComponent(), 19 | })); 20 | 21 | describe('OAuth2ProxyApiTokenComponent', () => { 22 | 23 | beforeEach(() => { 24 | Axios.post.mockImplementationOnce(() => Promise.resolve({ data: 'foobar' })); 25 | }); 26 | 27 | it('renders page without immediately re-generating the token', async () => { 28 | const { findByText } = render(); 29 | 30 | const token = await findByText(/Your current API token is hidden/i); 31 | expect(token).toBeInTheDocument(); 32 | 33 | const button = await findByText(/Regenerate Token/i); 34 | expect(button).toBeInTheDocument(); 35 | 36 | expect(Axios.post).toHaveBeenCalledTimes(0); 37 | }); 38 | 39 | it('calls post request once when regenerate button is clicked', async () => { 40 | await act(async () => { 41 | const { findByText } = render(); 42 | const button = await findByText(/Regenerate Token/i); 43 | fireEvent.click(button); 44 | }); 45 | 46 | expect(Axios.post).toHaveBeenCalledTimes(1); 47 | expect(Axios.post).toHaveBeenCalledWith('/service/rest/oauth2-proxy/user/reset-token'); 48 | }); 49 | 50 | it('renders response content as token', async () => { 51 | await act(async () => { 52 | const { findByText } = render(); 53 | const button = await findByText(/Regenerate Token/i); 54 | fireEvent.click(button); 55 | 56 | const token = await findByText(/foobar/i); 57 | expect(token).toBeInTheDocument(); 58 | }); 59 | }); 60 | }); -------------------------------------------------------------------------------- /src/frontend/src/index.js: -------------------------------------------------------------------------------- 1 | import OAuth2ProxyApiTokenComponent from './components/OAuth2ProxyApiTokenComponent'; 2 | 3 | window.plugins.push({ 4 | id: 'nexus-oauth2-proxy-plugin', 5 | 6 | features: [{ 7 | mode: 'user', 8 | path: '/oauth2proxy-apitoken', 9 | view: OAuth2ProxyApiTokenComponent, 10 | text: 'OAuth2 Proxy API Token', 11 | description: 'Access OAuth2 proxy API token', 12 | iconCls: 'x-fa fa-key', 13 | visibility: { 14 | requiresUser: true 15 | } 16 | }] 17 | }); 18 | 19 | const toolbarXPath = '//div[contains(@class, "x-toolbar") and contains(@role, "group")]' 20 | const loginButtonXPath = '//a[contains(@id, "signin")]' 21 | const logoutButtonXPath = '//a[contains(@id, "signout")]' 22 | 23 | function findFirstElementByXPath(xPath, searchRoot) { 24 | return document.evaluate(xPath, searchRoot, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; 25 | } 26 | 27 | function waitForElement(xPath, searchRoot, elementNameForLogging) { 28 | elementNameForLogging = (typeof elementNameForLogging === 'undefined') ? xPath : elementNameForLogging; 29 | return new Promise(resolve => { 30 | let initElement = findFirstElementByXPath(xPath, searchRoot); 31 | if(initElement && initElement.checkVisibility()) { 32 | return resolve(initElement); 33 | } 34 | 35 | console.log(elementNameForLogging + " not yet visible."); 36 | const observer = new MutationObserver(mutations => { 37 | let obsElement = findFirstElementByXPath(xPath, searchRoot); 38 | if(obsElement && obsElement.checkVisibility()) { 39 | console.log("Observer found " + elementNameForLogging + ". Unregistering observer and resolving promise"); 40 | observer.disconnect(); 41 | resolve(obsElement) 42 | } 43 | }); 44 | observer.observe(searchRoot, {attributes: false, childList:true, subtree: true}) 45 | console.log("Waiting for " + elementNameForLogging + " to become visible..."); 46 | }); 47 | } 48 | 49 | function triggerPageReloadWhenLogoutButtonIsReplacedWithLoginButton(toolbar) { 50 | const toolbarObserver = new MutationObserver(mutations => { 51 | let loginButton = findFirstElementByXPath(loginButtonXPath, toolbar); 52 | if(loginButton && loginButton.checkVisibility()) { 53 | console.log("Observer found login button. Either this is on page load or logout was done. Re-Checking in 1 second to make sure..."); 54 | window.setTimeout(() => { 55 | let stillLoginButton = findFirstElementByXPath(loginButtonXPath, toolbar); 56 | if(stillLoginButton && stillLoginButton.checkVisibility()) { 57 | console.log("Login button still present. Assuming logout was done. Reloading page to retrigger oauth login..."); 58 | toolbarObserver.disconnect(); 59 | location.reload(); 60 | } else { 61 | console.log("Login button is gone now. Assuming page was still loading. Skipping page reload..."); 62 | } 63 | }, 1000); 64 | } 65 | }); 66 | toolbarObserver.observe(toolbar, {attributes: true, childList:true, subtree: true}) 67 | console.log("Waiting for login button to become visible..."); 68 | } 69 | 70 | waitForElement(toolbarXPath, document, "toolbar").then(toolbar => { 71 | waitForElement(logoutButtonXPath, toolbar, "logout button").then(logoutButton => { 72 | triggerPageReloadWhenLogoutButtonIsReplacedWithLoginButton(toolbar); 73 | }); 74 | }); -------------------------------------------------------------------------------- /src/frontend/test/__mocks__/@sonatype/nexus-ui-plugin.js: -------------------------------------------------------------------------------- 1 | // https://jestjs.io/docs/manual-mocks#mocking-node-modules -------------------------------------------------------------------------------- /src/frontend/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const TerserPlugin = require('terser-webpack-plugin'); 4 | 5 | module.exports = { 6 | mode: 'production', 7 | devtool: 'source-map', 8 | entry: './src/frontend/src', 9 | output: { 10 | filename: 'nexus-oauth2-proxy-bundle.js', 11 | path: path.resolve(__dirname, '..', '..', 'target', 'classes', 'static') 12 | }, 13 | module: { 14 | rules: [ 15 | { 16 | test: /\.jsx?$/, 17 | exclude: /node_modules/, 18 | use: { 19 | loader: 'babel-loader', 20 | options: { 21 | presets: ['@babel/preset-react'] // Ensure React JSX is appropriately transpiled 22 | } 23 | } 24 | } 25 | ] 26 | }, 27 | externals: { 28 | '@sonatype/nexus-ui-plugin': 'nxrmUiPlugin', 29 | axios: 'axios', 30 | react: 'react' 31 | }, 32 | resolve: { 33 | extensions: ['.js', '.jsx'] 34 | }, 35 | optimization: { 36 | minimize: true, 37 | minimizer: [ 38 | new TerserPlugin({ 39 | terserOptions: { 40 | sourceMap: true 41 | } 42 | }) 43 | ] 44 | } 45 | }; 46 | -------------------------------------------------------------------------------- /src/main/java/com/github/tumbl3w33d/OAuth2ProxyApiTokenInvalidateQuartz.java: -------------------------------------------------------------------------------- 1 | package com.github.tumbl3w33d; 2 | 3 | import static com.google.common.base.Preconditions.checkNotNull; 4 | import static org.sonatype.nexus.common.app.ManagedLifecycle.Phase.TASKS; 5 | 6 | import java.util.Date; 7 | 8 | import javax.inject.Inject; 9 | import javax.inject.Named; 10 | import javax.inject.Singleton; 11 | 12 | import org.sonatype.nexus.common.app.ManagedLifecycle; 13 | import org.sonatype.nexus.common.stateguard.StateGuardLifecycleSupport; 14 | import org.sonatype.nexus.scheduling.TaskConfiguration; 15 | import org.sonatype.nexus.scheduling.TaskScheduler; 16 | import org.sonatype.nexus.scheduling.schedule.Schedule; 17 | 18 | @Named 19 | @ManagedLifecycle(phase = TASKS) 20 | @Singleton 21 | public class OAuth2ProxyApiTokenInvalidateQuartz extends StateGuardLifecycleSupport { 22 | 23 | private final TaskScheduler taskScheduler; 24 | 25 | private final String taskCron; 26 | 27 | @Inject 28 | public OAuth2ProxyApiTokenInvalidateQuartz(final TaskScheduler taskScheduler, 29 | @Named("${nexus.tasks.oauth2-proxy.api-token-invalidate.cron:-0 0 0 * * ?}") final String taskCron) { 30 | this.taskScheduler = checkNotNull(taskScheduler); 31 | this.taskCron = checkNotNull(taskCron); 32 | } 33 | 34 | @Override 35 | protected void doStart() throws Exception { 36 | if (!taskScheduler.listsTasks().stream() 37 | .anyMatch((info) -> OAuth2ProxyApiTokenInvalidateTaskDescriptor.TYPE_ID 38 | .equals(info.getConfiguration().getTypeId()))) { 39 | TaskConfiguration configuration = taskScheduler.createTaskConfigurationInstance( 40 | OAuth2ProxyApiTokenInvalidateTaskDescriptor.TYPE_ID); 41 | Schedule schedule = taskScheduler.getScheduleFactory().cron(new Date(), taskCron); 42 | taskScheduler.scheduleTask(configuration, schedule); 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/main/java/com/github/tumbl3w33d/OAuth2ProxyApiTokenInvalidateTask.java: -------------------------------------------------------------------------------- 1 | package com.github.tumbl3w33d; 2 | 3 | import static org.sonatype.nexus.logging.task.TaskLogType.NEXUS_LOG_ONLY; 4 | 5 | import java.sql.Timestamp; 6 | import java.time.Instant; 7 | import java.time.temporal.ChronoUnit; 8 | import java.util.HashSet; 9 | import java.util.Map; 10 | import java.util.Set; 11 | 12 | import javax.inject.Inject; 13 | import javax.inject.Named; 14 | 15 | import org.apache.commons.mail.EmailException; 16 | import org.apache.commons.mail.SimpleEmail; 17 | import org.sonatype.nexus.common.app.BaseUrlHolder; 18 | import org.sonatype.nexus.email.EmailManager; 19 | import org.sonatype.nexus.logging.task.TaskLogging; 20 | import org.sonatype.nexus.scheduling.Cancelable; 21 | import org.sonatype.nexus.scheduling.TaskSupport; 22 | import org.sonatype.nexus.security.SecuritySystem; 23 | import org.sonatype.nexus.security.user.User; 24 | import org.sonatype.nexus.security.user.UserNotFoundException; 25 | 26 | import com.github.tumbl3w33d.h2.OAuth2ProxyLoginRecordStore; 27 | import com.github.tumbl3w33d.h2.OAuth2ProxyTokenInfoStore; 28 | import com.github.tumbl3w33d.users.OAuth2ProxyUserManager; 29 | import com.github.tumbl3w33d.users.db.OAuth2ProxyLoginRecord; 30 | import com.github.tumbl3w33d.users.db.OAuth2ProxyTokenInfo; 31 | 32 | @Named 33 | @TaskLogging(NEXUS_LOG_ONLY) 34 | public class OAuth2ProxyApiTokenInvalidateTask extends TaskSupport implements Cancelable { 35 | 36 | private final OAuth2ProxyLoginRecordStore loginRecordStore; 37 | private final OAuth2ProxyTokenInfoStore tokenInfoStore; 38 | private final OAuth2ProxyUserManager userManager; 39 | private final SecuritySystem securitySystem; 40 | private final EmailManager mailManager; 41 | 42 | @Inject 43 | public OAuth2ProxyApiTokenInvalidateTask(@Named OAuth2ProxyLoginRecordStore loginRecordStore, 44 | @Named OAuth2ProxyTokenInfoStore tokenInfoStore, @Named OAuth2ProxyUserManager userManager, 45 | SecuritySystem securitySystem, EmailManager mailManager) { 46 | this.loginRecordStore = loginRecordStore; 47 | this.tokenInfoStore = tokenInfoStore; 48 | this.userManager = userManager; 49 | this.securitySystem = securitySystem; 50 | this.mailManager = mailManager; 51 | } 52 | 53 | @Override 54 | protected Void execute() throws Exception { 55 | Map loginRecords = loginRecordStore.getAllLoginRecords(); 56 | Map tokenInfos = tokenInfoStore.getAllTokenInfos(); 57 | 58 | if (loginRecords.isEmpty() && tokenInfos.isEmpty()) { 59 | log.debug("No records found, nothing to do"); 60 | return null; 61 | } 62 | 63 | int configuredIdleExpiration = getConfiguration().getInteger( 64 | OAuth2ProxyApiTokenInvalidateTaskDescriptor.CONFIG_IDLE_EXPIRY, 65 | OAuth2ProxyApiTokenInvalidateTaskDescriptor.CONFIG_IDLE_EXPIRY_DEFAULT); 66 | 67 | int configuredMaxTokenAge = getConfiguration().getInteger( 68 | OAuth2ProxyApiTokenInvalidateTaskDescriptor.CONFIG_AGE, 69 | OAuth2ProxyApiTokenInvalidateTaskDescriptor.CONFIG_AGE_DEFAULT); 70 | 71 | boolean notify = getConfiguration().getBoolean(OAuth2ProxyApiTokenInvalidateTaskDescriptor.NOTIFY, 72 | OAuth2ProxyApiTokenInvalidateTaskDescriptor.NOTIFY_DEFAULT); 73 | 74 | Set userIds = new HashSet<>(loginRecords.size()); 75 | userIds.addAll(loginRecords.keySet()); 76 | userIds.addAll(tokenInfos.keySet()); 77 | for (String userId : userIds) { 78 | if ("admin".equals(userId)) { 79 | // never reset the admin "token" as it would overwrite the password, possibly locking people out of nexus 80 | // when the task would run before OIDC setup is completed 81 | continue; 82 | } 83 | 84 | if (isUserIdleTimeExpired(userId, loginRecords.get(userId), configuredIdleExpiration) 85 | || isTokenLifespanExpired(userId, tokenInfos.get(userId), configuredMaxTokenAge)) { 86 | resetApiToken(userId, notify); 87 | log.info("API token of user {} has been reset", userId); 88 | } 89 | 90 | } 91 | return null; 92 | } 93 | 94 | private boolean isUserIdleTimeExpired(String userId, OAuth2ProxyLoginRecord loginRecord, int configuredIdleTime) { 95 | if (configuredIdleTime <= 0) { 96 | return false; 97 | } 98 | 99 | Timestamp lastLoginDate = loginRecord.getLastLogin(); 100 | log.debug("Last known login for {} was {}", userId, OAuth2ProxyRealm.formatDateString(lastLoginDate)); 101 | long timePassed = ChronoUnit.DAYS.between(lastLoginDate.toInstant(), Instant.now()); 102 | log.debug("Time passed since login: {} - configured maximum: {}", timePassed, configuredIdleTime); 103 | if (timePassed >= configuredIdleTime) { 104 | log.debug("Idle time expired for {}", userId); 105 | return true; 106 | } 107 | return false; 108 | } 109 | 110 | private boolean isTokenLifespanExpired(String userId, OAuth2ProxyTokenInfo tokenInfo, int configuredMaxTokenAge) { 111 | if (configuredMaxTokenAge <= 0) { 112 | return false; 113 | } 114 | 115 | Timestamp tokenCreationDate = tokenInfo.getTokenCreation(); 116 | log.debug("API token for {} was created at {}", userId, OAuth2ProxyRealm.formatDateString(tokenCreationDate)); 117 | long timePassed = ChronoUnit.DAYS.between(tokenCreationDate.toInstant(), Instant.now()); 118 | log.debug("Time passed since token creation: {} - configured maximum: {}", timePassed, configuredMaxTokenAge); 119 | if (timePassed >= configuredMaxTokenAge) { 120 | log.debug("Token lifespan expired for user {}", userId); 121 | return true; 122 | } 123 | return false; 124 | } 125 | 126 | @Override 127 | public String getMessage() { 128 | return "Invalidate OAuth2 Proxy API tokens of users who did not show up for a while"; 129 | } 130 | 131 | private void resetApiToken(String userId, boolean notify) { 132 | try { 133 | securitySystem.changePassword(userId, OAuth2ProxyRealm.generateSecureRandomString(32)); 134 | log.debug("API token reset for user {} succeeded", userId); 135 | if (notify) { 136 | sendMail(userId); 137 | } 138 | } catch (UserNotFoundException e) { 139 | log.error("Unable to reset API token of user {}", userId); 140 | log.debug("Unable to reset API token of user {}", userId, e); 141 | } 142 | } 143 | 144 | private void sendMail(String userId) throws UserNotFoundException { 145 | if (mailManager.getConfiguration().isEnabled()) { 146 | User user = userManager.getUser(userId); 147 | String to = user.getEmailAddress(); 148 | try { 149 | SimpleEmail mail = new SimpleEmail(); 150 | mail.addTo(to); 151 | if (BaseUrlHolder.isSet()) { 152 | mail.setMsg("Your OAuth2 Proxy API Token on " + BaseUrlHolder.get() 153 | + " has been invalidated because of inactivity or expired token lifespan"); 154 | } else { 155 | mail.setMsg( 156 | "Your OAuth2 Proxy API Token has been invalidated because of inactivity or expired token lifespan"); 157 | } 158 | mailManager.send(mail); 159 | } catch (EmailException e) { 160 | log.warn("Failed to send notification email about oauth2 API token reset to user " + user.getName()); 161 | log.debug("Failed to send notification email", e); 162 | } 163 | } else { 164 | log.warn("Sending token invalidation notifications is enabled, but no mail server is configured in Nexus"); 165 | } 166 | } 167 | 168 | } 169 | -------------------------------------------------------------------------------- /src/main/java/com/github/tumbl3w33d/OAuth2ProxyApiTokenInvalidateTaskDescriptor.java: -------------------------------------------------------------------------------- 1 | package com.github.tumbl3w33d; 2 | 3 | import javax.inject.Inject; 4 | import javax.inject.Named; 5 | import javax.inject.Singleton; 6 | 7 | import org.sonatype.nexus.common.upgrade.AvailabilityVersion; 8 | import org.sonatype.nexus.formfields.CheckboxFormField; 9 | import org.sonatype.nexus.formfields.FormField; 10 | import org.sonatype.nexus.formfields.NumberTextFormField; 11 | import org.sonatype.nexus.scheduling.TaskDescriptorSupport; 12 | 13 | @AvailabilityVersion(from = "1.0") 14 | @Named 15 | @Singleton 16 | public class OAuth2ProxyApiTokenInvalidateTaskDescriptor extends TaskDescriptorSupport { 17 | 18 | public static final String TYPE_ID = "oauth2-proxy-api-token.cleanup"; 19 | 20 | public static final String CONFIG_IDLE_EXPIRY = TYPE_ID + "-expiry"; 21 | public static final int CONFIG_IDLE_EXPIRY_DEFAULT = 30; 22 | private static final NumberTextFormField maxIdleAge = new NumberTextFormField(CONFIG_IDLE_EXPIRY, // 23 | "User idle time in days", // 24 | "After the user has been inactive for this amount of days the API token will be overwritten and the user must renew it interactively. Setting this to 0 or a negative value disables max idle time entirely. Default is " 25 | + CONFIG_IDLE_EXPIRY_DEFAULT + " days.", 26 | FormField.MANDATORY)// 27 | .withMinimumValue(1)// 28 | .withInitialValue(CONFIG_IDLE_EXPIRY_DEFAULT); 29 | 30 | public static final String CONFIG_AGE = TYPE_ID + "-max-age"; 31 | public static final int CONFIG_AGE_DEFAULT = -1; 32 | private static final NumberTextFormField maxAge = new NumberTextFormField(CONFIG_AGE, // 33 | "Max token age in days", // 34 | "After this amount of days the API token will be overwritten and the user must renew it interactively. Setting this to 0 or a negative value disables max token age entirely. Default is " 35 | + CONFIG_AGE_DEFAULT + " days.", 36 | FormField.MANDATORY)// 37 | .withInitialValue(CONFIG_AGE_DEFAULT); 38 | 39 | public static final String NOTIFY = TYPE_ID + "-notify"; 40 | public static final Boolean NOTIFY_DEFAULT = false; 41 | private static final CheckboxFormField notify = new CheckboxFormField(NOTIFY, // 42 | "Send Email on token invalidation", // 43 | "Defines whether an email is send to the affected user if their API token is invalidated automatically based on any condition. Default is " 44 | + NOTIFY_DEFAULT, 45 | FormField.OPTIONAL)// 46 | .withInitialValue(NOTIFY_DEFAULT); 47 | 48 | @Inject 49 | public OAuth2ProxyApiTokenInvalidateTaskDescriptor() { 50 | super(TYPE_ID, OAuth2ProxyApiTokenInvalidateTask.class, "OAuth2 Proxy API token invalidator", 51 | TaskDescriptorSupport.VISIBLE, TaskDescriptorSupport.EXPOSED, TaskDescriptorSupport.REQUEST_RECOVERY, 52 | new FormField[] { maxIdleAge, maxAge, notify }); 53 | } 54 | 55 | @Override 56 | public boolean allowConcurrentRun() { 57 | return true; 58 | } 59 | 60 | } 61 | -------------------------------------------------------------------------------- /src/main/java/com/github/tumbl3w33d/OAuth2ProxyFilter.java: -------------------------------------------------------------------------------- 1 | package com.github.tumbl3w33d; 2 | 3 | import javax.inject.Named; 4 | import javax.inject.Singleton; 5 | import javax.servlet.ServletRequest; 6 | import javax.servlet.ServletResponse; 7 | import javax.servlet.http.HttpServletRequest; 8 | 9 | import org.apache.shiro.authc.AuthenticationToken; 10 | import org.apache.shiro.web.util.WebUtils; 11 | import org.slf4j.Logger; 12 | import org.slf4j.LoggerFactory; 13 | import org.sonatype.nexus.security.authc.NexusAuthenticationFilter; 14 | 15 | @Named 16 | @Singleton 17 | public class OAuth2ProxyFilter extends NexusAuthenticationFilter { 18 | private static final Logger logger = LoggerFactory.getLogger(OAuth2ProxyFilter.class); 19 | 20 | @Override 21 | protected boolean isLoginAttempt(ServletRequest request, ServletResponse response) { 22 | HttpServletRequest httpRequest = (HttpServletRequest) request; 23 | return OAuth2ProxyHeaderAuthTokenFactory.OAUTH2_PROXY_HEADERS.stream() 24 | .anyMatch(header -> httpRequest.getHeader(header) != null); 25 | } 26 | 27 | @Override 28 | protected AuthenticationToken createToken(ServletRequest request, ServletResponse response) { 29 | HttpServletRequest webReq = WebUtils.toHttp(request); 30 | 31 | if (webReq.getHeader("Authorization") != null) { 32 | logger.debug("not handling requests with Authorization header"); 33 | return null; 34 | } 35 | 36 | OAuth2ProxyHeaderAuthTokenFactory tokenFactory = new OAuth2ProxyHeaderAuthTokenFactory(); 37 | 38 | logger.debug("creating token from OAuth2 proxy headers"); 39 | return tokenFactory.createToken(request, response); 40 | } 41 | 42 | @Override 43 | protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception { 44 | if (isLoginAttempt(request, response)) { 45 | return executeLogin(request, response); 46 | } 47 | 48 | return false; 49 | } 50 | 51 | /* Only overriding to be able to mock it */ 52 | @Override 53 | protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception { 54 | return super.executeLogin(request, response); 55 | } 56 | } -------------------------------------------------------------------------------- /src/main/java/com/github/tumbl3w33d/OAuth2ProxyHeaderAuthToken.java: -------------------------------------------------------------------------------- 1 | package com.github.tumbl3w33d; 2 | 3 | import org.apache.shiro.authc.HostAuthenticationToken; 4 | import org.slf4j.Logger; 5 | import org.slf4j.LoggerFactory; 6 | import org.sonatype.nexus.security.authc.HttpHeaderAuthenticationToken; 7 | 8 | public class OAuth2ProxyHeaderAuthToken implements HostAuthenticationToken { 9 | 10 | private static final long serialVersionUID = 234235998981350L; 11 | private final Logger logger = LoggerFactory.getLogger(OAuth2ProxyHeaderAuthToken.class); 12 | 13 | HttpHeaderAuthenticationToken user; 14 | HttpHeaderAuthenticationToken preferred_username; 15 | HttpHeaderAuthenticationToken email; 16 | HttpHeaderAuthenticationToken accessToken; 17 | HttpHeaderAuthenticationToken groups; 18 | 19 | String host; 20 | 21 | @Override 22 | public Object getPrincipal() { 23 | Object principal = preferred_username == null ? null : preferred_username.getHeaderValue(); 24 | logger.debug("returning principal: {}", principal); 25 | return principal; 26 | } 27 | 28 | @Override 29 | public Object getCredentials() { 30 | logger.trace("there are no credentials for oauth2 proxy authentication - returning null"); 31 | return null; 32 | } 33 | 34 | @Override 35 | public String getHost() { 36 | return this.host; 37 | } 38 | } -------------------------------------------------------------------------------- /src/main/java/com/github/tumbl3w33d/OAuth2ProxyHeaderAuthTokenFactory.java: -------------------------------------------------------------------------------- 1 | package com.github.tumbl3w33d; 2 | 3 | import java.util.Arrays; 4 | import java.util.Collections; 5 | import java.util.List; 6 | 7 | import javax.inject.Named; 8 | import javax.inject.Singleton; 9 | import javax.servlet.ServletRequest; 10 | import javax.servlet.ServletResponse; 11 | import javax.servlet.http.HttpServletRequest; 12 | 13 | import org.apache.shiro.authc.AuthenticationToken; 14 | import org.apache.shiro.subject.support.DefaultSubjectContext; 15 | import org.apache.shiro.web.util.WebUtils; 16 | import org.slf4j.Logger; 17 | import org.slf4j.LoggerFactory; 18 | import org.sonatype.nexus.security.authc.HttpHeaderAuthenticationToken; 19 | import org.sonatype.nexus.security.authc.HttpHeaderAuthenticationTokenFactorySupport; 20 | 21 | @Named 22 | @Singleton 23 | public class OAuth2ProxyHeaderAuthTokenFactory extends HttpHeaderAuthenticationTokenFactorySupport { 24 | 25 | private final Logger logger = LoggerFactory.getLogger(OAuth2ProxyHeaderAuthTokenFactory.class); 26 | 27 | static final String X_FORWARDED_USER = "X-Forwarded-User"; 28 | static final String X_FORWARDED_PREFERRED_USERNAME = "X-Forwarded-Preferred-Username"; 29 | static final String X_FORWARDED_EMAIL = "X-Forwarded-Email"; 30 | static final String X_FORWARDED_ACCESS_TOKEN = "X-Forwarded-Access-Token"; 31 | static final String X_FORWARDED_GROUPS = "X-Forwarded-Groups"; 32 | 33 | static final List OAUTH2_PROXY_HEADERS = Collections.unmodifiableList(Arrays.asList(X_FORWARDED_USER, 34 | X_FORWARDED_PREFERRED_USERNAME, X_FORWARDED_EMAIL, X_FORWARDED_ACCESS_TOKEN, X_FORWARDED_GROUPS)); 35 | 36 | @Override 37 | public AuthenticationToken createToken(ServletRequest request, ServletResponse response) { 38 | 39 | HttpServletRequest httpRequest = WebUtils.toHttp(request); 40 | String xForwardedUserHeader = httpRequest.getHeader(X_FORWARDED_USER); 41 | String xForwardedEmailHeader = httpRequest.getHeader(X_FORWARDED_EMAIL); 42 | String xForwardedPrefUsernameHeader = httpRequest.getHeader(X_FORWARDED_PREFERRED_USERNAME); 43 | 44 | if (xForwardedUserHeader == null || xForwardedEmailHeader == null || xForwardedPrefUsernameHeader == null) { 45 | // any proxy header is missing... 46 | if (httpRequest.getHeader("Authorization") == null) { 47 | // ...and Auth header is missing too -> probably UI based access 48 | logger.debug("required OAuth2 proxy headers incomplete - {}: {} - {}: {} - {}: {}", X_FORWARDED_USER, 49 | xForwardedUserHeader, X_FORWARDED_EMAIL, xForwardedEmailHeader, X_FORWARDED_PREFERRED_USERNAME, 50 | xForwardedPrefUsernameHeader); 51 | return null; 52 | } else { 53 | // ...and Auth header is present -> some access bypassing oauth2 proxy, so we are not responsible 54 | logger.debug("not handling requests with Authorization header, but without any oauth2 proxy headers"); 55 | return null; 56 | } 57 | } 58 | 59 | OAuth2ProxyHeaderAuthToken token = new OAuth2ProxyHeaderAuthToken(); 60 | 61 | token.user = new HttpHeaderAuthenticationToken(X_FORWARDED_USER, xForwardedUserHeader, request.getRemoteHost()); 62 | 63 | token.email = new HttpHeaderAuthenticationToken(X_FORWARDED_EMAIL, xForwardedEmailHeader, 64 | request.getRemoteHost()); 65 | 66 | token.preferred_username = new HttpHeaderAuthenticationToken(X_FORWARDED_PREFERRED_USERNAME, 67 | xForwardedPrefUsernameHeader, request.getRemoteHost()); 68 | 69 | // (unused) depending on oauth2 proxy config, this might be missing 70 | String accessToken = httpRequest.getHeader(X_FORWARDED_ACCESS_TOKEN); 71 | if (accessToken != null && !accessToken.isEmpty()) { 72 | token.accessToken = new HttpHeaderAuthenticationToken(X_FORWARDED_ACCESS_TOKEN, accessToken, 73 | request.getRemoteHost()); 74 | } 75 | 76 | // depending on oauth2 proxy claims, this might be missing 77 | String groups = httpRequest.getHeader(X_FORWARDED_GROUPS); 78 | if (groups != null && !groups.isEmpty()) { 79 | token.groups = new HttpHeaderAuthenticationToken(X_FORWARDED_GROUPS, groups, request.getRemoteHost()); 80 | } 81 | 82 | token.host = request.getRemoteHost(); 83 | 84 | logger.debug( 85 | "created token from oauth2 proxy headers: user: {} - preferred_username: {} - email: {} - access token: {} - groups: {}", 86 | token.user.getHeaderValue(), token.preferred_username.getHeaderValue(), token.email.getHeaderValue(), 87 | token.accessToken != null ? "" : null, 88 | token.groups != null ? token.groups.getHeaderValue() : null); 89 | 90 | if (httpRequest.getHeader("Authorization") == null) { 91 | // "normal" oauth2 login, probably via UI -> create a user session 92 | 93 | // NexusBasicHttpAuthenticationFilter which is for reasons Sonatype itself does not know the root of the 94 | // inheritance hierarchy of nexus auth filters turns off shiro session creation by setting this flag to false 95 | 96 | // Nexus solely relies on the fact that the session is manually created by POSTing to SessionServlet as part of the login dialog 97 | // As we never get a login dialog, this does not trigger, which means there is no user session ever created. 98 | 99 | // While that does not hurt normal login as we continuously login with the oauth proxy anyways, 100 | // it causes the logout button to throw exceptions instead of triggering a LogoutEvent we can listen to for redirecting 101 | // logout to the oauth proxy 102 | request.setAttribute(DefaultSubjectContext.SESSION_CREATION_ENABLED, Boolean.TRUE); 103 | } else { 104 | // most likely programmatic access making use of skip_jwt_bearer_tokens option in oauth2 proxy, meaning an already existing 105 | // jwt token not created (but validated!) by oauth2 proxy is used for login. In this case we don't need a session, so nothing else to do 106 | } 107 | 108 | return token; 109 | 110 | } 111 | 112 | @Override 113 | protected List getHttpHeaderNames() { 114 | return OAUTH2_PROXY_HEADERS; 115 | } 116 | } -------------------------------------------------------------------------------- /src/main/java/com/github/tumbl3w33d/OAuth2ProxyRealm.java: -------------------------------------------------------------------------------- 1 | package com.github.tumbl3w33d; 2 | 3 | import static com.google.common.base.Preconditions.checkNotNull; 4 | 5 | import java.security.SecureRandom; 6 | import java.sql.Timestamp; 7 | import java.time.LocalDate; 8 | import java.time.LocalDateTime; 9 | import java.time.ZoneId; 10 | import java.time.format.DateTimeFormatter; 11 | import java.util.Date; 12 | import java.util.HashSet; 13 | import java.util.Optional; 14 | import java.util.Set; 15 | import java.util.stream.Collectors; 16 | import java.util.stream.Stream; 17 | 18 | import javax.inject.Inject; 19 | import javax.inject.Named; 20 | import javax.inject.Singleton; 21 | 22 | import org.apache.shiro.authc.AuthenticationException; 23 | import org.apache.shiro.authc.AuthenticationInfo; 24 | import org.apache.shiro.authc.AuthenticationToken; 25 | import org.apache.shiro.authc.SimpleAuthenticationInfo; 26 | import org.apache.shiro.authc.UsernamePasswordToken; 27 | import org.apache.shiro.authc.credential.PasswordService; 28 | import org.apache.shiro.authz.AuthorizationInfo; 29 | import org.apache.shiro.authz.SimpleAuthorizationInfo; 30 | import org.apache.shiro.realm.AuthorizingRealm; 31 | import org.apache.shiro.subject.PrincipalCollection; 32 | import org.eclipse.sisu.Description; 33 | import org.slf4j.Logger; 34 | import org.slf4j.LoggerFactory; 35 | import org.sonatype.nexus.common.event.EventManager; 36 | import org.sonatype.nexus.security.role.RoleIdentifier; 37 | import org.sonatype.nexus.security.user.User; 38 | import org.sonatype.nexus.security.user.UserNotFoundException; 39 | 40 | import com.github.tumbl3w33d.h2.OAuth2ProxyLoginRecordStore; 41 | import com.github.tumbl3w33d.h2.OAuth2ProxyRoleStore; 42 | import com.github.tumbl3w33d.logout.OAuth2ProxyLogoutHandler; 43 | import com.github.tumbl3w33d.users.OAuth2ProxyUserManager; 44 | import com.github.tumbl3w33d.users.OAuth2ProxyUserManager.UserWithPrincipals; 45 | import com.github.tumbl3w33d.users.db.OAuth2ProxyLoginRecord; 46 | 47 | @Named(OAuth2ProxyRealm.NAME) 48 | @Singleton 49 | @Description(OAuth2ProxyRealm.NAME) 50 | public class OAuth2ProxyRealm extends AuthorizingRealm { 51 | 52 | public static final String NAME = "OAuth2ProxyRealm"; 53 | 54 | final Logger logger = LoggerFactory.getLogger(OAuth2ProxyRealm.class.getName()); 55 | 56 | private static final String ID = "oauth2-proxy-realm"; 57 | 58 | private static final String ALLOWED_CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; 59 | 60 | static final String CLASS_USER_LOGIN = "OAuth2ProxyUserLogin"; 61 | static final String FIELD_USER_ID = "userId"; 62 | static final String FIELD_LAST_LOGIN = "lastLogin"; 63 | 64 | private final OAuth2ProxyUserManager userManager; 65 | private final OAuth2ProxyRoleStore roleStore; 66 | private final OAuth2ProxyLoginRecordStore loginRecordStore; 67 | private PasswordService passwordService; 68 | 69 | @Inject 70 | public OAuth2ProxyRealm(@Named OAuth2ProxyUserManager userManager, @Named OAuth2ProxyRoleStore roleStore, 71 | @Named OAuth2ProxyLoginRecordStore loginRecordStore, @Named PasswordService passwordService, 72 | EventManager eventManager, OAuth2ProxyLogoutHandler logoutHandler) { 73 | this.userManager = checkNotNull(userManager); 74 | this.roleStore = roleStore; 75 | this.loginRecordStore = loginRecordStore; 76 | this.passwordService = passwordService; 77 | 78 | setName(ID); 79 | 80 | setCredentialsMatcher(new OAuth2ProxyRealmCredentialsMatcher()); 81 | 82 | // authentication is provided by oauth2 proxy headers with every request 83 | setAuthenticationCachingEnabled(false); 84 | setAuthorizationCachingEnabled(false); 85 | eventManager.register(logoutHandler); 86 | logger.debug("Registered oauth2 proxy logout handler"); 87 | } 88 | 89 | private boolean isApiTokenMatching(AuthenticationToken token) { 90 | logger.debug("token principal for matching api token: {}", token.getPrincipal()); 91 | 92 | if (token.getPrincipal() instanceof String) { 93 | Optional maybeApiToken = userManager.getApiToken((String) token.getPrincipal()); 94 | 95 | if (!maybeApiToken.isPresent()) { 96 | logger.debug( 97 | "unable to retrieve API token from database user in order to match against provided auth token secret"); 98 | return false; 99 | } 100 | 101 | String apiToken = maybeApiToken.get(); 102 | 103 | return passwordService.passwordsMatch(token.getCredentials(), apiToken); 104 | } 105 | 106 | logger.debug("principal received from auth token is not a string"); 107 | 108 | return false; 109 | } 110 | 111 | @Override 112 | protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException { 113 | logger.trace("call to doGetAuthenticationInfo with token {}", token); 114 | 115 | if (token instanceof UsernamePasswordToken) { 116 | if (isApiTokenMatching(token)) { 117 | // the condition method already ensures the principal is a string 118 | String userId = (String) token.getPrincipal(); 119 | 120 | logger.debug("programmatic access by {} succeeded", userId); 121 | 122 | UserWithPrincipals userWithPrincipals = findUserById(userId); 123 | 124 | logger.debug("found principal {} for programmatic access", userWithPrincipals); 125 | 126 | recordLogin(userId); 127 | 128 | return new SimpleAuthenticationInfo(userWithPrincipals.getPrincipals(), null); 129 | } 130 | 131 | logger.debug("programmatic access failed because credentials did not match"); 132 | 133 | return null; 134 | } 135 | 136 | OAuth2ProxyHeaderAuthToken oauth2Token = (OAuth2ProxyHeaderAuthToken) token; 137 | 138 | String userid = oauth2Token.user.getHeaderValue(); 139 | String preferred_username = oauth2Token.preferred_username.getHeaderValue(); 140 | String email = oauth2Token.email != null ? oauth2Token.email.getHeaderValue() : null; 141 | String accessToken = oauth2Token.accessToken != null ? oauth2Token.accessToken.getHeaderValue() : null; 142 | String groups = oauth2Token.groups != null ? oauth2Token.groups.getHeaderValue() : null; 143 | 144 | logger.trace( 145 | "getting authentication info - user: {} - preferred username: {} - email: {} - access token: {} - groups: {}", 146 | userid, preferred_username, email, accessToken != null ? "" : null, groups); 147 | 148 | final String oauth2proxyUserId = token.getPrincipal().toString(); 149 | logger.debug("determined user id {}", oauth2proxyUserId); 150 | 151 | UserWithPrincipals userWithPrincipals = findUserById(oauth2proxyUserId); 152 | logger.debug("found principal {} for interactive access", userWithPrincipals); 153 | 154 | if (!userWithPrincipals.hasPrincipals()) { 155 | logger.debug("need to create a new user object for {}", oauth2proxyUserId); 156 | 157 | User newUserObject = OAuth2ProxyUserManager.createUserObject(preferred_username, email); 158 | logger.debug("created preliminary user object {}", newUserObject); 159 | userManager.addUser(newUserObject, generateSecureRandomString(32)); 160 | logger.debug("created the user via userManager"); 161 | userWithPrincipals.setUser(newUserObject); 162 | userWithPrincipals.addPrincipal(newUserObject.getUserId(), OAuth2ProxyUserManager.AUTHENTICATING_REALM); 163 | logger.info("created new user object for {}", oauth2proxyUserId); 164 | } 165 | 166 | if (userWithPrincipals.hasUser()) { 167 | String userId = userWithPrincipals.getUser().getUserId(); 168 | 169 | logger.trace("user {} (source {}) has roles {} before sync", userId, 170 | userWithPrincipals.getUser().getSource(), userWithPrincipals.getUser().getRoles()); 171 | 172 | if (oauth2Token.groups != null) { 173 | logger.trace("user {} has identity provider groups {}", userId, oauth2Token.groups); 174 | syncExternalRolesForGroups(userWithPrincipals.getUser(), oauth2Token.groups.getHeaderValue()); 175 | } 176 | 177 | } 178 | 179 | if (userWithPrincipals.hasPrincipals()) { 180 | logger.debug("found principals for OAuth2 proxy user '{}': '{}' from realms '{}'", oauth2proxyUserId, 181 | userWithPrincipals.getPrincipals(), userWithPrincipals.getPrincipals().getRealmNames()); 182 | 183 | recordLogin(oauth2proxyUserId); 184 | 185 | return new SimpleAuthenticationInfo(userWithPrincipals.getPrincipals(), null); 186 | } 187 | 188 | logger.debug("No found principals for OAuth2 proxy user '{}'", oauth2proxyUserId); 189 | 190 | return null; 191 | } 192 | 193 | @Override 194 | protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { 195 | 196 | String principal = principals.getPrimaryPrincipal().toString(); 197 | 198 | logger.debug("call to doGetAuthorizationInfo found primary principle {}", principal); 199 | 200 | try { 201 | User user = userManager.getUser(principal); 202 | 203 | Set roles = user.getRoles().stream().map(role -> role.getRoleId()).collect(Collectors.toSet()); 204 | 205 | logger.trace("user {} has roles {}", principal, roles); 206 | 207 | return new SimpleAuthorizationInfo(roles); 208 | } catch (UserNotFoundException e) { 209 | logger.debug("unable to find user with principal {} in source {} for authorization", principal, 210 | OAuth2ProxyUserManager.SOURCE); 211 | 212 | return null; 213 | } 214 | } 215 | 216 | void syncExternalRolesForGroups(User user, String groupsString) { 217 | 218 | Set idpGroups = Stream.of(groupsString.trim().split(",")) 219 | .map(groupString -> new RoleIdentifier(OAuth2ProxyUserManager.SOURCE, groupString)) 220 | .collect(Collectors.toSet()); 221 | 222 | roleStore.addRolesIfMissing(idpGroups); 223 | 224 | user.addAllRoles(idpGroups); 225 | 226 | logger.trace("added idp groups as role to user {}: {}", user.getUserId(), 227 | user.getRoles().stream().map(group -> group.getRoleId()).collect(Collectors.toSet())); 228 | 229 | Set rolesToDelete = new HashSet<>(); 230 | 231 | for (RoleIdentifier role : user.getRoles()) { 232 | if (!role.getSource().equals(OAuth2ProxyUserManager.SOURCE)) { 233 | // not touching roles assigned outside of this realm logic 234 | logger.debug("group sync leaving {}'s role {} untouched", user.getUserId(), role.getRoleId(), 235 | role.getSource()); 236 | continue; 237 | } 238 | 239 | if (!idpGroups.stream().anyMatch(idpGroup -> idpGroup.getRoleId().equals(role.getRoleId()))) { 240 | logger.trace("marking role {} of user {} for deletion", role.getRoleId(), user.getUserId()); 241 | rolesToDelete.add(role); 242 | } else { 243 | logger.trace("user {} still has group for role {} in identity provider", user.getUserId(), 244 | role.getRoleId()); 245 | } 246 | } 247 | 248 | for (RoleIdentifier role : rolesToDelete) { 249 | user.removeRole(role); 250 | logger.info("deleted role {} from user {}", role.getRoleId(), user.getUserId()); 251 | } 252 | 253 | try { 254 | userManager.updateUserGroups(user); 255 | } catch (UserNotFoundException e) { 256 | logger.warn("user {} cannot be found in db for group synchronization", user.getUserId()); 257 | return; 258 | } catch (Exception e) { 259 | logger.error("unexpected error when updating user groups - {}", e); 260 | return; 261 | } 262 | } 263 | 264 | private UserWithPrincipals findUserById(final String oauth2proxyUserId) { 265 | UserWithPrincipals userWithPrincipals = new UserWithPrincipals(); 266 | try { 267 | User user = userManager.getUser(oauth2proxyUserId); 268 | userWithPrincipals.setUser(user); 269 | userWithPrincipals.addPrincipal(oauth2proxyUserId, OAuth2ProxyUserManager.AUTHENTICATING_REALM); 270 | } catch (UserNotFoundException e) { 271 | logger.debug("meh, no user {} yet", oauth2proxyUserId); 272 | } 273 | return userWithPrincipals; 274 | } 275 | 276 | @Override 277 | public boolean supports(AuthenticationToken token) { 278 | if (token instanceof OAuth2ProxyHeaderAuthToken) { 279 | logger.debug("announcing support for token {}", token); 280 | return true; 281 | } else if (token instanceof UsernamePasswordToken) { 282 | logger.debug("announcing support for token {}", token); 283 | return true; 284 | } else { 285 | logger.debug("token of type {} is not handled by this realm", token); 286 | return false; 287 | } 288 | } 289 | 290 | public static String generateSecureRandomString(int length) { 291 | SecureRandom random = new SecureRandom(); 292 | StringBuilder sb = new StringBuilder(length); 293 | 294 | for (int i = 0; i < length; i++) { 295 | int randomIndex = random.nextInt(ALLOWED_CHARACTERS.length()); 296 | char randomChar = ALLOWED_CHARACTERS.charAt(randomIndex); 297 | sb.append(randomChar); 298 | } 299 | 300 | return sb.toString(); 301 | } 302 | 303 | void recordLogin(String userId) { 304 | Optional maybeRecord = loginRecordStore.getLoginRecord(userId); 305 | 306 | if (!maybeRecord.isPresent()) { 307 | logger.debug("No login recorded for {} yet. Creating it now.", userId); 308 | loginRecordStore.createLoginRecord(userId); 309 | return; 310 | } 311 | 312 | Timestamp lastLoginDate = maybeRecord.get().getLastLogin(); 313 | LocalDate lastLoginLocalDate = lastLoginDate.toInstant().atZone(ZoneId.systemDefault()).toLocalDate(); 314 | LocalDate nowLocalDate = LocalDate.now(); 315 | 316 | logger.debug("Last known login for {} was {}", userId, lastLoginDate); 317 | 318 | if (!lastLoginLocalDate.equals(nowLocalDate)) { 319 | logger.debug("Updating last known login for {} (was {})", userId, lastLoginDate); 320 | loginRecordStore.updateLoginRecord(userId); 321 | } else { 322 | logger.debug("login record of {} is already up-to-date", userId); 323 | } 324 | } 325 | 326 | static String formatDateString(Date date) { 327 | if (date != null) { 328 | DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd"); 329 | LocalDateTime localDateTime = date.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime(); 330 | return localDateTime.format(formatter); 331 | } else { 332 | return "unknown"; 333 | } 334 | } 335 | 336 | } -------------------------------------------------------------------------------- /src/main/java/com/github/tumbl3w33d/OAuth2ProxyRealmCredentialsMatcher.java: -------------------------------------------------------------------------------- 1 | package com.github.tumbl3w33d; 2 | 3 | import org.apache.shiro.authc.AuthenticationInfo; 4 | import org.apache.shiro.authc.AuthenticationToken; 5 | import org.apache.shiro.authc.credential.CredentialsMatcher; 6 | import org.sonatype.goodies.common.ComponentSupport; 7 | 8 | final class OAuth2ProxyRealmCredentialsMatcher extends ComponentSupport implements CredentialsMatcher { 9 | 10 | @Override 11 | public boolean doCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) { 12 | log.debug("authInfo: {}", info); 13 | 14 | return true; 15 | } 16 | } -------------------------------------------------------------------------------- /src/main/java/com/github/tumbl3w33d/OAuth2ProxyUiPluginDescriptor.java: -------------------------------------------------------------------------------- 1 | package com.github.tumbl3w33d; 2 | 3 | import static java.util.Arrays.asList; 4 | 5 | import java.util.List; 6 | 7 | import javax.annotation.Nullable; 8 | import javax.inject.Inject; 9 | import javax.inject.Named; 10 | import javax.inject.Singleton; 11 | 12 | import org.eclipse.sisu.Priority; 13 | import org.eclipse.sisu.space.ClassSpace; 14 | 15 | import org.sonatype.nexus.ui.UiPluginDescriptor; 16 | 17 | @Named 18 | @Singleton 19 | @Priority(Integer.MAX_VALUE - 300) 20 | public class OAuth2ProxyUiPluginDescriptor implements UiPluginDescriptor { 21 | 22 | private final List scripts; 23 | 24 | private final List styles; 25 | 26 | @Inject 27 | public OAuth2ProxyUiPluginDescriptor(final ClassSpace space) { 28 | scripts = asList("/static/nexus-oauth2-proxy-bundle.js"); 29 | styles = asList("/static/rapture/resources/nexus-oauth2-proxy-bundle.css"); 30 | } 31 | 32 | @Override 33 | public String getName() { 34 | return "nexus-oauth2-proxy-plugin"; 35 | } 36 | 37 | @Nullable 38 | @Override 39 | public List getScripts(final boolean isDebug) { 40 | return scripts; 41 | } 42 | 43 | @Nullable 44 | @Override 45 | public List getStyles() { 46 | return styles; 47 | } 48 | } -------------------------------------------------------------------------------- /src/main/java/com/github/tumbl3w33d/h2/OAuth2ProxyLoginRecordDAO.java: -------------------------------------------------------------------------------- 1 | package com.github.tumbl3w33d.h2; 2 | 3 | import org.sonatype.nexus.datastore.api.IdentifiedDataAccess; 4 | 5 | import com.github.tumbl3w33d.users.db.OAuth2ProxyLoginRecord; 6 | 7 | public interface OAuth2ProxyLoginRecordDAO extends IdentifiedDataAccess { 8 | 9 | } 10 | -------------------------------------------------------------------------------- /src/main/java/com/github/tumbl3w33d/h2/OAuth2ProxyLoginRecordStore.java: -------------------------------------------------------------------------------- 1 | package com.github.tumbl3w33d.h2; 2 | 3 | import static com.github.tumbl3w33d.h2.OAuth2ProxyStores.loginRecordDAO; 4 | 5 | import java.util.Collections; 6 | import java.util.Map; 7 | import java.util.Optional; 8 | import java.util.function.Function; 9 | import java.util.stream.Collectors; 10 | import java.util.stream.StreamSupport; 11 | 12 | import javax.inject.Inject; 13 | import javax.inject.Named; 14 | import javax.inject.Singleton; 15 | 16 | import org.sonatype.nexus.common.stateguard.StateGuardLifecycleSupport; 17 | import org.sonatype.nexus.datastore.api.DataSession; 18 | import org.sonatype.nexus.datastore.api.DataSessionSupplier; 19 | import org.sonatype.nexus.transaction.Transactional; 20 | import org.sonatype.nexus.transaction.TransactionalStore; 21 | 22 | import com.github.tumbl3w33d.users.db.OAuth2ProxyLoginRecord; 23 | 24 | @Named("mybatis") 25 | @Singleton 26 | public class OAuth2ProxyLoginRecordStore extends StateGuardLifecycleSupport 27 | implements TransactionalStore> { 28 | 29 | private final DataSessionSupplier sessionSupplier; 30 | 31 | @Inject 32 | public OAuth2ProxyLoginRecordStore(final DataSessionSupplier sessionSupplier) { 33 | this.sessionSupplier = sessionSupplier; 34 | } 35 | 36 | @Override 37 | public DataSession openSession() { 38 | return OAuth2ProxyStores.openSession(sessionSupplier); 39 | } 40 | 41 | @Transactional 42 | public Map getAllLoginRecords() { 43 | return Collections.unmodifiableMap(StreamSupport.stream(loginRecordDAO().browse().spliterator(), false) 44 | .collect(Collectors.toMap(OAuth2ProxyLoginRecord::getId, Function.identity()))); 45 | } 46 | 47 | @Transactional 48 | public Optional getLoginRecord(String userId) { 49 | log.trace("call to getLoginRecord with userId {}", userId); 50 | 51 | try { 52 | return loginRecordDAO().read(userId); 53 | } catch (Exception e) { 54 | log.error("unable to retrieve login record for {} - {}", userId, e); 55 | throw e; 56 | } 57 | } 58 | 59 | @Transactional 60 | public void createLoginRecord(String userId) { 61 | log.trace("call to createLoginRecord with userId {}", userId); 62 | try { 63 | loginRecordDAO().create(OAuth2ProxyLoginRecord.of(userId)); 64 | } catch (Exception e) { 65 | log.error("unable to create login record for {} - {}", userId, e); 66 | throw e; 67 | } 68 | } 69 | 70 | @Transactional 71 | public void updateLoginRecord(String userId) { 72 | loginRecordDAO().update(OAuth2ProxyLoginRecord.of(userId)); 73 | } 74 | 75 | } 76 | -------------------------------------------------------------------------------- /src/main/java/com/github/tumbl3w33d/h2/OAuth2ProxyRoleDAO.java: -------------------------------------------------------------------------------- 1 | package com.github.tumbl3w33d.h2; 2 | 3 | import org.sonatype.nexus.datastore.api.IdentifiedDataAccess; 4 | 5 | import com.github.tumbl3w33d.users.db.OAuth2ProxyRole; 6 | 7 | public interface OAuth2ProxyRoleDAO extends IdentifiedDataAccess { 8 | 9 | } 10 | -------------------------------------------------------------------------------- /src/main/java/com/github/tumbl3w33d/h2/OAuth2ProxyRoleStore.java: -------------------------------------------------------------------------------- 1 | package com.github.tumbl3w33d.h2; 2 | 3 | import static com.github.tumbl3w33d.h2.OAuth2ProxyStores.roleDAO; 4 | 5 | import java.util.Optional; 6 | import java.util.Set; 7 | import java.util.stream.Collectors; 8 | import java.util.stream.StreamSupport; 9 | 10 | import javax.inject.Inject; 11 | import javax.inject.Named; 12 | import javax.inject.Singleton; 13 | 14 | import org.sonatype.nexus.common.stateguard.StateGuardLifecycleSupport; 15 | import org.sonatype.nexus.datastore.api.DataSession; 16 | import org.sonatype.nexus.datastore.api.DataSessionSupplier; 17 | import org.sonatype.nexus.datastore.api.DuplicateKeyException; 18 | import org.sonatype.nexus.security.role.DuplicateRoleException; 19 | import org.sonatype.nexus.security.role.Role; 20 | import org.sonatype.nexus.security.role.RoleIdentifier; 21 | import org.sonatype.nexus.transaction.Transactional; 22 | import org.sonatype.nexus.transaction.TransactionalStore; 23 | 24 | import com.github.tumbl3w33d.users.db.OAuth2ProxyRole; 25 | import com.google.common.collect.ImmutableSet; 26 | 27 | @Named("mybatis") 28 | @Singleton 29 | public class OAuth2ProxyRoleStore extends StateGuardLifecycleSupport implements TransactionalStore> { 30 | 31 | private final DataSessionSupplier sessionSupplier; 32 | 33 | @Inject 34 | public OAuth2ProxyRoleStore( 35 | final DataSessionSupplier sessionSupplier) { 36 | this.sessionSupplier = sessionSupplier; 37 | } 38 | 39 | @Override 40 | public DataSession openSession() { 41 | return OAuth2ProxyStores.openSession(sessionSupplier); 42 | } 43 | 44 | @Transactional 45 | public Set getAllRoles() { 46 | Set allRoles = StreamSupport.stream(roleDAO().browse().spliterator(), false) 47 | .map(OAuth2ProxyRole::toNexusRole).collect(Collectors.toSet()); 48 | 49 | return ImmutableSet.copyOf(allRoles); 50 | } 51 | 52 | @Transactional 53 | public Optional getRole(String roleId) { 54 | return roleDAO().read(roleId); 55 | } 56 | 57 | @Transactional 58 | private void addRole(String newRoleId) { 59 | if (newRoleId == null || newRoleId.trim().isEmpty()) { 60 | throw new RuntimeException("cannot create role without name - received: " + newRoleId); 61 | } 62 | 63 | try { 64 | roleDAO().create(OAuth2ProxyRole.of(newRoleId)); 65 | } catch (DuplicateKeyException e) { 66 | throw new DuplicateRoleException(newRoleId); 67 | } 68 | } 69 | 70 | @Transactional 71 | public void addRolesIfMissing(Set idpGroups) { 72 | Set existingRoles = getAllRoles().stream().map(existingRole -> existingRole.getName()) 73 | .collect(Collectors.toSet()); 74 | Set idpGroupStrings = idpGroups.stream().map(idpGroup -> idpGroup.getRoleId()) 75 | .collect(Collectors.toSet()); 76 | Set newGroups = idpGroupStrings.stream().filter(s -> !existingRoles.contains(s)) 77 | .collect(Collectors.toSet()); 78 | 79 | newGroups.forEach(newGroup -> addRole(newGroup)); 80 | } 81 | 82 | @Transactional 83 | public void deleteRole(String roleIdToDelete) { 84 | roleDAO().delete(roleIdToDelete); 85 | 86 | log.info("deleted role {}", roleIdToDelete); 87 | } 88 | 89 | } 90 | -------------------------------------------------------------------------------- /src/main/java/com/github/tumbl3w33d/h2/OAuth2ProxyStores.java: -------------------------------------------------------------------------------- 1 | package com.github.tumbl3w33d.h2; 2 | 3 | import static org.sonatype.nexus.datastore.api.DataStoreManager.DEFAULT_DATASTORE_NAME; 4 | 5 | import org.sonatype.nexus.datastore.api.DataAccess; 6 | import org.sonatype.nexus.datastore.api.DataSession; 7 | import org.sonatype.nexus.datastore.api.DataSessionSupplier; 8 | import org.sonatype.nexus.transaction.UnitOfWork; 9 | 10 | public class OAuth2ProxyStores { 11 | 12 | public static DataSession openSession(DataSessionSupplier supplier) { 13 | return supplier.openSession(DEFAULT_DATASTORE_NAME); 14 | } 15 | 16 | private static DataSession thisSession() { 17 | return UnitOfWork.currentSession(); 18 | } 19 | 20 | private static T dao(final Class daoClass) { 21 | return thisSession().access(daoClass); 22 | } 23 | 24 | public static OAuth2ProxyUserDAO userDAO() { 25 | return dao(OAuth2ProxyUserDAO.class); 26 | } 27 | 28 | public static OAuth2ProxyRoleDAO roleDAO() { 29 | return dao(OAuth2ProxyRoleDAO.class); 30 | } 31 | 32 | public static OAuth2ProxyLoginRecordDAO loginRecordDAO() { 33 | return dao(OAuth2ProxyLoginRecordDAO.class); 34 | } 35 | 36 | public static OAuth2ProxyTokenInfoDAO tokenInfoDAO() { 37 | return dao(OAuth2ProxyTokenInfoDAO.class); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/main/java/com/github/tumbl3w33d/h2/OAuth2ProxyTokenInfoDAO.java: -------------------------------------------------------------------------------- 1 | package com.github.tumbl3w33d.h2; 2 | 3 | import org.sonatype.nexus.datastore.api.IdentifiedDataAccess; 4 | 5 | import com.github.tumbl3w33d.users.db.OAuth2ProxyTokenInfo; 6 | 7 | public interface OAuth2ProxyTokenInfoDAO extends IdentifiedDataAccess { 8 | 9 | } 10 | -------------------------------------------------------------------------------- /src/main/java/com/github/tumbl3w33d/h2/OAuth2ProxyTokenInfoStore.java: -------------------------------------------------------------------------------- 1 | package com.github.tumbl3w33d.h2; 2 | 3 | import static com.github.tumbl3w33d.h2.OAuth2ProxyStores.tokenInfoDAO; 4 | 5 | import java.util.Collections; 6 | import java.util.Map; 7 | import java.util.Optional; 8 | import java.util.function.Function; 9 | import java.util.stream.Collectors; 10 | import java.util.stream.StreamSupport; 11 | 12 | import javax.inject.Inject; 13 | import javax.inject.Named; 14 | import javax.inject.Singleton; 15 | 16 | import org.sonatype.nexus.common.stateguard.StateGuardLifecycleSupport; 17 | import org.sonatype.nexus.datastore.api.DataSession; 18 | import org.sonatype.nexus.datastore.api.DataSessionSupplier; 19 | import org.sonatype.nexus.transaction.Transactional; 20 | import org.sonatype.nexus.transaction.TransactionalStore; 21 | 22 | import com.github.tumbl3w33d.users.db.OAuth2ProxyTokenInfo; 23 | 24 | @Named("mybatis") 25 | @Singleton 26 | public class OAuth2ProxyTokenInfoStore extends StateGuardLifecycleSupport 27 | implements TransactionalStore> { 28 | 29 | private final DataSessionSupplier sessionSupplier; 30 | 31 | @Inject 32 | public OAuth2ProxyTokenInfoStore(final DataSessionSupplier sessionSupplier) { 33 | this.sessionSupplier = sessionSupplier; 34 | } 35 | 36 | @Override 37 | public DataSession openSession() { 38 | return OAuth2ProxyStores.openSession(sessionSupplier); 39 | } 40 | 41 | @Transactional 42 | public Map getAllTokenInfos() { 43 | return Collections.unmodifiableMap(StreamSupport.stream(tokenInfoDAO().browse().spliterator(), false) 44 | .collect(Collectors.toMap(OAuth2ProxyTokenInfo::getId, Function.identity()))); 45 | } 46 | 47 | @Transactional 48 | public Optional getTokenInfo(String userId) { 49 | log.trace("call to getTokenInfo with userId {}", userId); 50 | 51 | try { 52 | return tokenInfoDAO().read(userId); 53 | } catch (Exception e) { 54 | log.error("unable to retrieve token record for {} - {}", userId, e); 55 | throw e; 56 | } 57 | } 58 | 59 | @Transactional 60 | public void createTokenInfo(String userId) { 61 | log.trace("call to createTokenInfo with userId {}", userId); 62 | try { 63 | tokenInfoDAO().create(OAuth2ProxyTokenInfo.of(userId)); 64 | } catch (Exception e) { 65 | log.error("unable to create token record for {} - {}", userId, e); 66 | throw e; 67 | } 68 | } 69 | 70 | @Transactional 71 | public void updateTokenInfo(String userId) { 72 | tokenInfoDAO().update(OAuth2ProxyTokenInfo.of(userId)); 73 | } 74 | 75 | } 76 | -------------------------------------------------------------------------------- /src/main/java/com/github/tumbl3w33d/h2/OAuth2ProxyUserDAO.java: -------------------------------------------------------------------------------- 1 | package com.github.tumbl3w33d.h2; 2 | 3 | import org.apache.ibatis.annotations.Param; 4 | import org.sonatype.nexus.datastore.api.IdentifiedDataAccess; 5 | 6 | import com.github.tumbl3w33d.users.db.OAuth2ProxyUser; 7 | 8 | public interface OAuth2ProxyUserDAO extends IdentifiedDataAccess { 9 | 10 | public void updateApiToken(@Param("preferredUsername") String preferredUsername, 11 | @Param("apiToken") String apiToken); 12 | 13 | public void updateGroups(@Param("preferredUsername") String preferredUsername, 14 | @Param("groupString") String groupString); 15 | } 16 | -------------------------------------------------------------------------------- /src/main/java/com/github/tumbl3w33d/h2/OAuth2ProxyUserStore.java: -------------------------------------------------------------------------------- 1 | package com.github.tumbl3w33d.h2; 2 | 3 | import static com.github.tumbl3w33d.h2.OAuth2ProxyStores.userDAO; 4 | import static com.google.common.base.Preconditions.checkNotNull; 5 | 6 | import java.util.Optional; 7 | import java.util.Set; 8 | import java.util.stream.Collectors; 9 | import java.util.stream.StreamSupport; 10 | 11 | import javax.inject.Inject; 12 | import javax.inject.Named; 13 | import javax.inject.Singleton; 14 | 15 | import org.apache.shiro.authc.credential.PasswordService; 16 | import org.sonatype.nexus.common.stateguard.StateGuardLifecycleSupport; 17 | import org.sonatype.nexus.datastore.api.DataSession; 18 | import org.sonatype.nexus.datastore.api.DataSessionSupplier; 19 | import org.sonatype.nexus.datastore.api.DuplicateKeyException; 20 | import org.sonatype.nexus.security.role.RoleIdentifier; 21 | import org.sonatype.nexus.security.user.DuplicateUserException; 22 | import org.sonatype.nexus.security.user.User; 23 | import org.sonatype.nexus.security.user.UserNotFoundException; 24 | import org.sonatype.nexus.transaction.Transactional; 25 | import org.sonatype.nexus.transaction.TransactionalStore; 26 | 27 | import com.github.tumbl3w33d.users.db.OAuth2ProxyUser; 28 | import com.google.common.collect.ImmutableSet; 29 | 30 | @Named("mybatis") 31 | @Singleton 32 | public class OAuth2ProxyUserStore extends StateGuardLifecycleSupport implements TransactionalStore> { 33 | 34 | private final PasswordService passwordService; 35 | private final DataSessionSupplier sessionSupplier; 36 | 37 | @Inject 38 | public OAuth2ProxyUserStore(final DataSessionSupplier sessionSupplier, final PasswordService passwordService) { 39 | this.passwordService = passwordService; 40 | this.sessionSupplier = checkNotNull(sessionSupplier); 41 | } 42 | 43 | @Override 44 | public DataSession openSession() { 45 | return OAuth2ProxyStores.openSession(sessionSupplier); 46 | } 47 | 48 | @Transactional 49 | public Set getAllUsers() { 50 | Set allUsers = StreamSupport.stream(userDAO().browse().spliterator(), false) 51 | .map(OAuth2ProxyUser::toNexusUser).collect(Collectors.toSet()); 52 | return ImmutableSet.copyOf(allUsers); 53 | } 54 | 55 | @Transactional 56 | public Optional getInternalUser(String userId) { 57 | log.debug("attempting to find user with id {} in db", userId); 58 | return userDAO().read(userId); 59 | } 60 | 61 | @Transactional 62 | public Optional getUser(String userId) { 63 | Optional maybeUser = getInternalUser(userId); 64 | 65 | if (maybeUser.isPresent()) { 66 | log.debug("retrieved internal user for {}, trying conversion to nexus user", userId); 67 | return Optional.of(maybeUser.get().toNexusUser()); 68 | } 69 | 70 | log.debug("user {} not found in db", userId); 71 | 72 | return Optional.empty(); 73 | } 74 | 75 | @Transactional 76 | public void addUser(OAuth2ProxyUser newUser) { 77 | newUser.setApiToken(hashToken(newUser.getApiToken())); 78 | 79 | log.debug("hashed API token of new user {} before persisting", newUser.getPreferredUsername()); 80 | try { 81 | 82 | userDAO().create(newUser); 83 | } catch (DuplicateKeyException e) { 84 | throw new DuplicateUserException(newUser.getId()); 85 | } 86 | 87 | log.debug("added user {} to db", newUser.getPreferredUsername()); 88 | } 89 | 90 | private String hashToken(String plaintextToken) { 91 | if (plaintextToken == null || plaintextToken.isEmpty()) { 92 | // should not happen because the call is internal by our implementation 93 | throw new RuntimeException("empty/null password passed to hash method"); 94 | } 95 | 96 | try { 97 | return this.passwordService.encryptPassword(plaintextToken); 98 | } catch (IllegalArgumentException e) { 99 | log.error("failed to hash token - {}", e); 100 | throw e; 101 | } 102 | } 103 | 104 | @Transactional 105 | public User updateUser(User user) throws UserNotFoundException { 106 | OAuth2ProxyUser proxyUser = OAuth2ProxyUser.of(user); 107 | 108 | log.debug("generic update call for user {}. Just updating user's groups, because no other use case exists.", 109 | user.getUserId()); 110 | 111 | userDAO().updateGroups(proxyUser.getId(), proxyUser.getGroupString()); 112 | 113 | return user; 114 | } 115 | 116 | @Transactional 117 | public void deleteUser(OAuth2ProxyUser userToDelete) { 118 | String userToDeleteId = userToDelete.getId(); 119 | log.debug("about to delete user {}", userToDeleteId); 120 | userDAO().delete(userToDelete.getPreferredUsername()); 121 | log.info("deleted user {}", userToDeleteId); 122 | } 123 | 124 | @Transactional 125 | public boolean updateUserApiToken(String userId, String token) { 126 | Optional maybeDbuser = getUser(userId); 127 | 128 | if (!maybeDbuser.isPresent()) { 129 | log.warn("could not retrieve existing user {} for token update", userId); 130 | return false; 131 | } 132 | 133 | User dbuser = maybeDbuser.get(); 134 | 135 | log.debug("about to update the api token of user {}", dbuser.getUserId()); 136 | 137 | try { 138 | // we only update the token, not the rest that possibly changed 139 | userDAO().updateApiToken(dbuser.getUserId(), hashToken(token)); 140 | } catch (Exception e) { 141 | log.error("failed to update API token of user {} - {}", dbuser.getUserId(), e); 142 | throw e; 143 | } 144 | 145 | log.debug("updated api token of user {}", dbuser.getUserId()); 146 | 147 | return true; 148 | } 149 | 150 | @Transactional 151 | public void updateUserGroups(String userId, Set groups) throws UserNotFoundException { 152 | String sortedCommaSepGroups = groups.stream() 153 | .map(RoleIdentifier::getRoleId) 154 | .sorted() 155 | .collect(Collectors.joining(",")); 156 | 157 | log.debug("updating group string of user {}: {}", userId, sortedCommaSepGroups); 158 | 159 | userDAO().updateGroups(userId, sortedCommaSepGroups); 160 | } 161 | 162 | @Transactional 163 | public Optional getApiToken(String principal) { 164 | Optional maybeInternalUser = getInternalUser(principal); 165 | 166 | if (maybeInternalUser.isPresent()) { 167 | OAuth2ProxyUser internalUser = maybeInternalUser.get(); 168 | log.debug("retrieved internal user {} for getting api token", internalUser.getPreferredUsername()); 169 | return Optional.ofNullable(internalUser.getApiToken()); 170 | } 171 | 172 | log.debug("unable to retrieve API token of user {}", principal); 173 | 174 | return Optional.empty(); 175 | } 176 | 177 | } 178 | -------------------------------------------------------------------------------- /src/main/java/com/github/tumbl3w33d/h2/RoleIdentifierTypeHandler.java: -------------------------------------------------------------------------------- 1 | package com.github.tumbl3w33d.h2; 2 | 3 | import java.sql.CallableStatement; 4 | import java.sql.PreparedStatement; 5 | import java.sql.ResultSet; 6 | import java.sql.SQLException; 7 | import java.util.Arrays; 8 | import java.util.Set; 9 | import java.util.stream.Collectors; 10 | 11 | import org.apache.ibatis.type.BaseTypeHandler; 12 | import org.apache.ibatis.type.JdbcType; 13 | import org.sonatype.nexus.security.role.RoleIdentifier; 14 | 15 | import com.github.tumbl3w33d.users.OAuth2ProxyUserManager; 16 | 17 | public class RoleIdentifierTypeHandler extends BaseTypeHandler> { 18 | 19 | @Override 20 | public void setNonNullParameter(PreparedStatement ps, int i, Set parameter, JdbcType jdbcType) 21 | throws SQLException { 22 | String groupString = parameter.stream() 23 | .map(RoleIdentifier::getRoleId) 24 | .sorted() 25 | .collect(Collectors.joining(",")); 26 | ps.setString(i, groupString); 27 | } 28 | 29 | private Set groupStringToSet(String groupString) { 30 | return Arrays.stream(groupString.split(",")) 31 | .map(roleId -> new RoleIdentifier(OAuth2ProxyUserManager.SOURCE, roleId)) 32 | .collect(Collectors.toSet()); 33 | } 34 | 35 | @Override 36 | public Set getNullableResult(ResultSet rs, String columnName) throws SQLException { 37 | String groupString = rs.getString(columnName); 38 | return groupStringToSet(groupString); 39 | } 40 | 41 | @Override 42 | public Set getNullableResult(ResultSet rs, int columnIndex) throws SQLException { 43 | String groupString = rs.getString(columnIndex); 44 | return groupStringToSet(groupString); 45 | } 46 | 47 | @Override 48 | public Set getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { 49 | String groupString = cs.getString(columnIndex); 50 | return groupStringToSet(groupString); 51 | } 52 | } -------------------------------------------------------------------------------- /src/main/java/com/github/tumbl3w33d/logout/OAuth2ProxyLogoutCapability.java: -------------------------------------------------------------------------------- 1 | package com.github.tumbl3w33d.logout; 2 | 3 | import static com.google.common.base.Preconditions.checkNotNull; 4 | 5 | import java.util.Map; 6 | 7 | import javax.inject.Inject; 8 | import javax.inject.Named; 9 | 10 | import org.sonatype.nexus.capability.CapabilitySupport; 11 | 12 | @Named(OAuth2ProxyLogoutCapabilityDescriptor.TYPE_ID) 13 | public class OAuth2ProxyLogoutCapability extends CapabilitySupport { 14 | 15 | private final OAuth2ProxyLogoutCapabilityConfigurationState state; 16 | 17 | @Inject 18 | public OAuth2ProxyLogoutCapability(OAuth2ProxyLogoutCapabilityConfigurationState state) { 19 | this.state = checkNotNull(state); 20 | } 21 | 22 | @Override 23 | protected OAuth2ProxyLogoutCapabilityConfiguration createConfig(Map properties) { 24 | return new OAuth2ProxyLogoutCapabilityConfiguration(properties); 25 | } 26 | 27 | @Override 28 | protected void onActivate(OAuth2ProxyLogoutCapabilityConfiguration config) throws Exception { 29 | state.set(config); 30 | } 31 | 32 | @Override 33 | protected void onUpdate(OAuth2ProxyLogoutCapabilityConfiguration config) throws Exception { 34 | state.set(config); 35 | } 36 | 37 | @Override 38 | protected void onPassivate(OAuth2ProxyLogoutCapabilityConfiguration config) throws Exception { 39 | state.reset(); 40 | } 41 | 42 | @Override 43 | protected void onRemove(OAuth2ProxyLogoutCapabilityConfiguration config) throws Exception { 44 | state.reset(); 45 | } 46 | 47 | } 48 | -------------------------------------------------------------------------------- /src/main/java/com/github/tumbl3w33d/logout/OAuth2ProxyLogoutCapabilityConfiguration.java: -------------------------------------------------------------------------------- 1 | package com.github.tumbl3w33d.logout; 2 | 3 | import java.util.Map; 4 | import java.util.Objects; 5 | 6 | public class OAuth2ProxyLogoutCapabilityConfiguration { 7 | 8 | public static final String LOGOUT_URL_ID = "oauth2-proxy-logout-url"; 9 | public static final String LOGOUT_URL_LABEL = "OAuth2 Proxy logout url"; 10 | public static final String LOGOUT_URL_HELP = "URL to be called for backchannel logout in OAuth2 Proxy when the Nexus logout button is pressed. Defaults to '{nexus-base-url}/oauth2/sign_out' if not specified"; 11 | 12 | private String logoutUrl; 13 | 14 | public OAuth2ProxyLogoutCapabilityConfiguration(Map properties) { 15 | if (properties != null) { 16 | logoutUrl = properties.get(LOGOUT_URL_ID); 17 | } 18 | } 19 | 20 | public String getLogoutUrl() { 21 | return logoutUrl; 22 | } 23 | 24 | public void setLogoutUrl(String logoutUrl) { 25 | this.logoutUrl = logoutUrl; 26 | } 27 | 28 | @Override 29 | public int hashCode() { 30 | return Objects.hash(logoutUrl); 31 | } 32 | 33 | @Override 34 | public boolean equals(Object obj) { 35 | if (this == obj) 36 | return true; 37 | if (obj == null) 38 | return false; 39 | if (getClass() != obj.getClass()) 40 | return false; 41 | OAuth2ProxyLogoutCapabilityConfiguration other = (OAuth2ProxyLogoutCapabilityConfiguration) obj; 42 | return Objects.equals(logoutUrl, other.logoutUrl); 43 | } 44 | 45 | @Override 46 | public String toString() { 47 | return "OAuth2ProxyLogoutCapabilityConfiguration [logoutUrl=" + logoutUrl + "]"; 48 | } 49 | 50 | } 51 | -------------------------------------------------------------------------------- /src/main/java/com/github/tumbl3w33d/logout/OAuth2ProxyLogoutCapabilityConfigurationState.java: -------------------------------------------------------------------------------- 1 | package com.github.tumbl3w33d.logout; 2 | 3 | import java.util.Collections; 4 | import java.util.Map; 5 | 6 | import javax.inject.Named; 7 | import javax.inject.Singleton; 8 | 9 | import org.sonatype.nexus.rapture.StateContributor; 10 | 11 | @Named 12 | @Singleton 13 | public class OAuth2ProxyLogoutCapabilityConfigurationState implements StateContributor { 14 | 15 | private OAuth2ProxyLogoutCapabilityConfiguration config; 16 | 17 | @Override 18 | public Map getState() { 19 | if (config != null) { 20 | return Collections.singletonMap("oauth2-proxy-logout", config); 21 | } 22 | return null; 23 | } 24 | 25 | public OAuth2ProxyLogoutCapabilityConfiguration getConfig() { 26 | return config; 27 | } 28 | 29 | public void set(OAuth2ProxyLogoutCapabilityConfiguration config) { 30 | this.config = config; 31 | } 32 | 33 | public void reset() { 34 | this.config = null; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/main/java/com/github/tumbl3w33d/logout/OAuth2ProxyLogoutCapabilityDescriptor.java: -------------------------------------------------------------------------------- 1 | package com.github.tumbl3w33d.logout; 2 | 3 | import java.util.List; 4 | import java.util.Map; 5 | 6 | import javax.inject.Named; 7 | import javax.inject.Singleton; 8 | 9 | import org.sonatype.nexus.capability.CapabilityDescriptorSupport; 10 | import org.sonatype.nexus.capability.CapabilityType; 11 | import org.sonatype.nexus.common.upgrade.AvailabilityVersion; 12 | import org.sonatype.nexus.formfields.FormField; 13 | import org.sonatype.nexus.formfields.StringTextFormField; 14 | 15 | import com.google.common.collect.Lists; 16 | 17 | @AvailabilityVersion(from = "1.0") 18 | @Named(OAuth2ProxyLogoutCapabilityDescriptor.TYPE_ID) 19 | @Singleton 20 | public class OAuth2ProxyLogoutCapabilityDescriptor 21 | extends CapabilityDescriptorSupport { 22 | 23 | public static final String TYPE_ID = "oauth2-proxy.logout"; 24 | public static final CapabilityType TYPE = CapabilityType.capabilityType(TYPE_ID); 25 | 26 | @SuppressWarnings("rawtypes") 27 | private final List formFields; 28 | 29 | public OAuth2ProxyLogoutCapabilityDescriptor() { 30 | formFields = Lists.newArrayList(new StringTextFormField(OAuth2ProxyLogoutCapabilityConfiguration.LOGOUT_URL_ID, 31 | OAuth2ProxyLogoutCapabilityConfiguration.LOGOUT_URL_LABEL, 32 | OAuth2ProxyLogoutCapabilityConfiguration.LOGOUT_URL_HELP, FormField.OPTIONAL)); 33 | } 34 | 35 | @Override 36 | public CapabilityType type() { 37 | return TYPE; 38 | } 39 | 40 | @Override 41 | public String name() { 42 | return "OAuth2 Proxy: Logout"; 43 | } 44 | 45 | @SuppressWarnings("rawtypes") 46 | @Override 47 | public List formFields() { 48 | return formFields; 49 | } 50 | 51 | @Override 52 | protected OAuth2ProxyLogoutCapabilityConfiguration createConfig(Map properties) { 53 | return new OAuth2ProxyLogoutCapabilityConfiguration(properties); 54 | } 55 | 56 | @Override 57 | protected String renderAbout() throws Exception { 58 | return "Specify settings regarding logout of OAuth2 Proxy triggered by Nexus. If this capability is disabled, no OAuth2 Proxy logout will be performed, effectively rendering the Nexus logout button ineffective"; 59 | } 60 | 61 | } 62 | -------------------------------------------------------------------------------- /src/main/java/com/github/tumbl3w33d/logout/OAuth2ProxyLogoutHandler.java: -------------------------------------------------------------------------------- 1 | package com.github.tumbl3w33d.logout; 2 | 3 | import java.io.IOException; 4 | import java.net.URI; 5 | import java.util.Arrays; 6 | import java.util.stream.Collectors; 7 | 8 | import javax.inject.Inject; 9 | import javax.inject.Named; 10 | import javax.inject.Singleton; 11 | import javax.servlet.http.Cookie; 12 | import javax.servlet.http.HttpServletRequest; 13 | import javax.servlet.http.HttpServletResponse; 14 | 15 | import org.apache.http.client.config.RequestConfig; 16 | import org.apache.http.client.methods.CloseableHttpResponse; 17 | import org.apache.http.client.methods.HttpGet; 18 | import org.apache.http.client.protocol.RequestAddCookies; 19 | import org.apache.http.impl.client.BasicCookieStore; 20 | import org.apache.http.impl.client.CloseableHttpClient; 21 | import org.apache.http.impl.client.HttpClients; 22 | import org.apache.http.impl.cookie.BasicClientCookie; 23 | import org.apache.shiro.SecurityUtils; 24 | import org.apache.shiro.web.util.WebUtils; 25 | import org.slf4j.Logger; 26 | import org.slf4j.LoggerFactory; 27 | import org.sonatype.nexus.common.app.BaseUrlHolder; 28 | import org.sonatype.nexus.security.authc.LogoutEvent; 29 | 30 | import com.github.tumbl3w33d.OAuth2ProxyRealm; 31 | import com.google.common.eventbus.AllowConcurrentEvents; 32 | import com.google.common.eventbus.Subscribe; 33 | 34 | @Singleton 35 | @Named 36 | public class OAuth2ProxyLogoutHandler { 37 | 38 | private final Logger logger = LoggerFactory.getLogger(OAuth2ProxyLogoutHandler.class); 39 | 40 | private OAuth2ProxyLogoutCapabilityConfigurationState configState; 41 | 42 | @Inject 43 | public OAuth2ProxyLogoutHandler(OAuth2ProxyLogoutCapabilityConfigurationState configState) { 44 | this.configState = configState; 45 | } 46 | 47 | @AllowConcurrentEvents 48 | @Subscribe 49 | public void handle(final LogoutEvent event) { 50 | if (OAuth2ProxyRealm.NAME.equals(event.getRealm())) { 51 | if (configState.getConfig() != null) { 52 | logger.debug("Triggering OAuth2 proxy logout for user " + event.getPrincipal()); 53 | URI logoutUrl = URI.create(determineLogoutUrl(configState.getConfig())); 54 | BasicCookieStore cookieStore = prepareCookieStore(logoutUrl); 55 | try (CloseableHttpClient client = constructClient(cookieStore)) { 56 | performOAuth2ProxyLogout(client, logoutUrl); 57 | logger.info("User " + event.getPrincipal() + " was logged out from oauth2 proxy successfully"); 58 | } catch (IOException e) { 59 | logger.error("Failed to logout from oauth2 proxy: {}", 60 | e.getMessage() == null ? e.getClass() : e.getMessage()); 61 | logger.debug("Failed to logout from oauth2 proxy", e); 62 | } 63 | } else { 64 | logger.debug( 65 | "Logout from OAuth2 Proxy is disabled: enable the 'OAuth2 Proxy: Logout' capability to change this"); 66 | } 67 | } 68 | } 69 | 70 | private String determineLogoutUrl(OAuth2ProxyLogoutCapabilityConfiguration config) { 71 | if (config.getLogoutUrl() != null && !config.getLogoutUrl().trim().isEmpty()) { 72 | return config.getLogoutUrl(); 73 | } else { 74 | String result = joinUri(BaseUrlHolder.get(), "oauth2/sign_out"); 75 | logger.debug("Logout URL configured in logout capability is empty: Using default: {}", result); 76 | return result; 77 | } 78 | } 79 | 80 | private String joinUri(String... parts) { 81 | return Arrays.stream(parts).map(this::stripLeadingAndTrailingSlashes).collect(Collectors.joining("/")); 82 | } 83 | 84 | private String stripLeadingAndTrailingSlashes(String s) { 85 | if (s.startsWith("/")) { 86 | s = s.substring(1); 87 | } 88 | if (s.endsWith("/")) { 89 | s = s.substring(0, s.length() - 1); 90 | } 91 | return s; 92 | } 93 | 94 | private BasicCookieStore prepareCookieStore(URI logoutUrl) { 95 | BasicCookieStore cookieStore = new BasicCookieStore(); 96 | HttpServletRequest request = WebUtils.getHttpRequest(SecurityUtils.getSubject()); 97 | Arrays.stream(request.getCookies()).map(c -> toHttpCookie(logoutUrl, c)).forEach(c -> cookieStore.addCookie(c)); 98 | return cookieStore; 99 | } 100 | 101 | private BasicClientCookie toHttpCookie(URI logoutUrl, Cookie cookie) { 102 | BasicClientCookie result = new BasicClientCookie(cookie.getName(), cookie.getValue()); 103 | result.setDomain(logoutUrl.getHost()); 104 | result.setPath("/"); 105 | return result; 106 | } 107 | 108 | private CloseableHttpClient constructClient(BasicCookieStore cookieStore) { 109 | return HttpClients.custom()// 110 | .disableCookieManagement() // disable response cookie processing as we don't care 111 | .setDefaultCookieStore(cookieStore) // set cookie store containing the servlet requests cookies 112 | .addInterceptorLast(new RequestAddCookies()) // disable also disabled request cookie processing, re-enable it manually 113 | .build(); 114 | } 115 | 116 | private void performOAuth2ProxyLogout(CloseableHttpClient client, URI logoutUrl) throws IOException { 117 | HttpGet req = new HttpGet(logoutUrl); 118 | req.setConfig(constructRequestConfig()); 119 | 120 | // oauth2 proxy will respond with 302, which means success 121 | try (CloseableHttpResponse resp = client.execute(req)) { 122 | if (resp.getStatusLine().getStatusCode() == 302) { 123 | // pass the Set-Cookie header(s) to the frontend caller so the client session is invalidated as well 124 | HttpServletResponse response = WebUtils.getHttpResponse(SecurityUtils.getSubject()); 125 | Arrays.stream(resp.getHeaders("Set-Cookie")) 126 | .forEach(h -> response.addHeader("Set-Cookie", h.getValue())); 127 | } 128 | } 129 | } 130 | 131 | private RequestConfig constructRequestConfig() { 132 | return RequestConfig.copy(RequestConfig.DEFAULT)// 133 | .setRedirectsEnabled(false) // don't follow the redirect oauth2 proxy will respond with 134 | .setConnectTimeout(5000) // very limited waiting time, might be that the URL is not configured 135 | .build(); 136 | } 137 | 138 | } 139 | -------------------------------------------------------------------------------- /src/main/java/com/github/tumbl3w33d/users/IncompleteOAuth2ProxyUserDataException.java: -------------------------------------------------------------------------------- 1 | package com.github.tumbl3w33d.users; 2 | 3 | public class IncompleteOAuth2ProxyUserDataException extends RuntimeException { 4 | 5 | private static final long serialVersionUID = 548791947914L; 6 | 7 | public IncompleteOAuth2ProxyUserDataException(String message) { 8 | super(message); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/main/java/com/github/tumbl3w33d/users/OAuth2ProxyAuthorizationManager.java: -------------------------------------------------------------------------------- 1 | package com.github.tumbl3w33d.users; 2 | 3 | import java.util.Collections; 4 | import java.util.List; 5 | import java.util.Optional; 6 | import java.util.Set; 7 | 8 | import javax.inject.Inject; 9 | import javax.inject.Named; 10 | import javax.inject.Singleton; 11 | 12 | import org.sonatype.nexus.security.authz.AbstractReadOnlyAuthorizationManager; 13 | import org.sonatype.nexus.security.privilege.NoSuchPrivilegeException; 14 | import org.sonatype.nexus.security.privilege.Privilege; 15 | import org.sonatype.nexus.security.role.Role; 16 | 17 | import com.github.tumbl3w33d.OAuth2ProxyRealm; 18 | import com.github.tumbl3w33d.h2.OAuth2ProxyRoleStore; 19 | import com.github.tumbl3w33d.users.db.OAuth2ProxyRole; 20 | 21 | @Named(OAuth2ProxyRealm.NAME) 22 | @Singleton 23 | public class OAuth2ProxyAuthorizationManager extends AbstractReadOnlyAuthorizationManager { 24 | 25 | private final OAuth2ProxyRoleStore roleStore; 26 | 27 | @Inject 28 | public OAuth2ProxyAuthorizationManager(final OAuth2ProxyRoleStore roleStore) { 29 | this.roleStore = roleStore; 30 | } 31 | 32 | @Override 33 | public String getSource() { 34 | return OAuth2ProxyRealm.NAME; 35 | } 36 | 37 | @Override 38 | public Set listRoles() { 39 | return roleStore.getAllRoles(); 40 | } 41 | 42 | @Override 43 | public Role getRole(String roleId) { 44 | Optional maybeRole = roleStore.getRole(roleId); 45 | if (maybeRole.isPresent()) { 46 | return maybeRole.get().toNexusRole(); 47 | } 48 | return null; 49 | } 50 | 51 | @Override 52 | public Set listPrivileges() { 53 | return null; 54 | } 55 | 56 | @Override 57 | public Privilege getPrivilege(String privilegeId) throws NoSuchPrivilegeException { 58 | return null; 59 | } 60 | 61 | @Override 62 | public Privilege getPrivilegeByName(String privilegeName) throws NoSuchPrivilegeException { 63 | return null; 64 | } 65 | 66 | @Override 67 | public List getPrivileges(Set privilegeIds) { 68 | return Collections.emptyList(); 69 | } 70 | 71 | @Override 72 | public String getRealmName() { 73 | return OAuth2ProxyRealm.NAME; 74 | } 75 | 76 | } 77 | -------------------------------------------------------------------------------- /src/main/java/com/github/tumbl3w33d/users/OAuth2ProxyUserManager.java: -------------------------------------------------------------------------------- 1 | package com.github.tumbl3w33d.users; 2 | 3 | import java.util.Collections; 4 | import java.util.HashSet; 5 | import java.util.Optional; 6 | import java.util.Set; 7 | import java.util.stream.Collectors; 8 | 9 | import javax.inject.Inject; 10 | import javax.inject.Named; 11 | import javax.inject.Singleton; 12 | 13 | import org.apache.shiro.subject.SimplePrincipalCollection; 14 | import org.eclipse.sisu.Description; 15 | import org.sonatype.nexus.security.user.AbstractUserManager; 16 | import org.sonatype.nexus.security.user.User; 17 | import org.sonatype.nexus.security.user.UserNotFoundException; 18 | import org.sonatype.nexus.security.user.UserSearchCriteria; 19 | import org.sonatype.nexus.security.user.UserStatus; 20 | 21 | import com.github.tumbl3w33d.OAuth2ProxyRealm; 22 | import com.github.tumbl3w33d.h2.OAuth2ProxyTokenInfoStore; 23 | import com.github.tumbl3w33d.h2.OAuth2ProxyUserStore; 24 | import com.github.tumbl3w33d.users.db.OAuth2ProxyUser; 25 | 26 | @Named(OAuth2ProxyUserManager.SOURCE) 27 | @Singleton 28 | @Description(OAuth2ProxyUserManager.SOURCE) 29 | public class OAuth2ProxyUserManager extends AbstractUserManager { 30 | 31 | public static final String AUTHENTICATING_REALM = OAuth2ProxyRealm.NAME; 32 | public static final String SOURCE = "OAuth2Proxy"; 33 | 34 | private final OAuth2ProxyUserStore userStore; 35 | private final OAuth2ProxyTokenInfoStore tokenInfoStore; 36 | 37 | @Inject 38 | public OAuth2ProxyUserManager(final OAuth2ProxyUserStore userStore, 39 | @Named final OAuth2ProxyTokenInfoStore tokenInfoStore) { 40 | this.userStore = userStore; 41 | this.tokenInfoStore = tokenInfoStore; 42 | } 43 | 44 | @Override 45 | public String getSource() { 46 | return SOURCE; 47 | } 48 | 49 | @Override 50 | public String getAuthenticationRealmName() { 51 | return AUTHENTICATING_REALM; 52 | } 53 | 54 | @Override 55 | public Set listUsers() { 56 | return userStore.getAllUsers(); 57 | } 58 | 59 | @Override 60 | public Set listUserIds() { 61 | return listUsers().stream().map(User::getUserId).collect(Collectors.toSet()); 62 | } 63 | 64 | @Override 65 | public Set searchUsers(UserSearchCriteria criteria) { 66 | Set ret = new HashSet<>(); 67 | 68 | Set users = userStore.getAllUsers(); 69 | 70 | if (users == null || users.isEmpty()) { 71 | log.debug("call to searchUsers returns empty result"); 72 | return Collections.emptySet(); 73 | } 74 | 75 | ret.addAll(users); 76 | 77 | String email = criteria.getEmail(); 78 | if (email != null && !email.isEmpty()) { 79 | ret = ret.stream().filter(user -> user.getEmailAddress().equals(email)) 80 | .collect(Collectors.toSet()); 81 | } 82 | 83 | String id = criteria.getUserId(); 84 | if (id != null && !id.isEmpty()) { 85 | ret = ret.stream().filter(user -> user.getUserId().equals(id)) 86 | .collect(Collectors.toSet()); 87 | } 88 | 89 | log.debug("call to searchUsers returns {}", ret); 90 | 91 | return ret; 92 | } 93 | 94 | @Override 95 | public User getUser(String userId) throws UserNotFoundException { 96 | Optional maybeUser = userStore.getUser(userId); 97 | 98 | if (maybeUser.isPresent()) { 99 | log.debug("getUser returning a user object found in db for {}", userId); 100 | return maybeUser.get(); 101 | } 102 | 103 | log.debug("getUser unable to find a user object for {}", userId); 104 | 105 | throw new UserNotFoundException("no user {} found in db"); 106 | } 107 | 108 | @Override 109 | public User getUser(final String userId, final Set roleIds) throws UserNotFoundException { 110 | // FIXME: make use of the roleIds 111 | return getUser(userId); 112 | } 113 | 114 | public static User createUserObject(String preferred_username, String email) { 115 | 116 | User newUser = new User(); 117 | newUser.setUserId(preferred_username); 118 | 119 | // naive approach to figure out names from username 120 | if (preferred_username.contains(".")) { 121 | String[] name_parts = preferred_username.split("\\."); 122 | String assumed_firstname = name_parts[0].substring(0, 1).toUpperCase() + name_parts[0].substring(1); 123 | newUser.setFirstName(assumed_firstname); 124 | String assumed_lastname = name_parts[1].substring(0, 1).toUpperCase() + name_parts[1].substring(1); 125 | newUser.setLastName(assumed_lastname); 126 | } 127 | 128 | newUser.setEmailAddress(email); 129 | newUser.setSource(OAuth2ProxyUserManager.SOURCE); 130 | newUser.setReadOnly(false); 131 | newUser.setStatus(UserStatus.active); 132 | 133 | return newUser; 134 | } 135 | 136 | @Override 137 | public boolean supportsWrite() { 138 | return true; 139 | } 140 | 141 | @Override 142 | public User addUser(User user, String password) { 143 | OAuth2ProxyUser internalUser = OAuth2ProxyUser.of(user); 144 | internalUser.setApiToken(password); 145 | log.debug("converted preliminary new user to internal user object for persisting"); 146 | userStore.addUser(internalUser); 147 | log.debug("persisted new user {}", user.getUserId()); 148 | return user; 149 | } 150 | 151 | /** 152 | * Nexus requires this generic update method to be available, but 153 | * we reduce it to an update of groups as there is no other use case 154 | * in this context. 155 | */ 156 | @Deprecated 157 | @Override 158 | public User updateUser(User user) throws UserNotFoundException { 159 | return userStore.updateUser(user); 160 | } 161 | 162 | public User updateUserGroups(User user) throws UserNotFoundException { 163 | userStore.updateUserGroups(user.getUserId(), user.getRoles()); 164 | return user; 165 | } 166 | 167 | @Override 168 | public void deleteUser(String userId) throws UserNotFoundException { 169 | Optional maybeUserToDel = userStore.getUser(userId); 170 | 171 | if (maybeUserToDel.isPresent()) { 172 | userStore.deleteUser(OAuth2ProxyUser.of(maybeUserToDel.get())); 173 | } else { 174 | log.warn("could not retrieve user {} for deletion", userId); 175 | } 176 | } 177 | 178 | @Override 179 | public void changePassword(String userId, String newPassword) throws UserNotFoundException { 180 | log.trace("changePassword called for {}, will set as api token", userId); 181 | 182 | Optional maybeUserToUpdate = userStore.getUser(userId); 183 | 184 | if (maybeUserToUpdate.isPresent()) { 185 | userStore.updateUserApiToken(userId, newPassword); 186 | tokenInfoStore.updateTokenInfo(userId); 187 | } else { 188 | log.warn("could not retrieve user {} for changing password", userId); 189 | } 190 | } 191 | 192 | public static final class UserWithPrincipals { 193 | private User user; 194 | 195 | private final SimplePrincipalCollection principals = new SimplePrincipalCollection(); 196 | 197 | public boolean hasPrincipals() { 198 | return !principals.isEmpty(); 199 | } 200 | 201 | public SimplePrincipalCollection getPrincipals() { 202 | return principals; 203 | } 204 | 205 | public void addPrincipal(String userId, String authRealmName) { 206 | this.principals.add(userId, authRealmName); 207 | } 208 | 209 | public boolean hasUser() { 210 | return user != null; 211 | } 212 | 213 | public User getUser() { 214 | return user; 215 | } 216 | 217 | public void setUser(User user) { 218 | this.user = user; 219 | } 220 | 221 | @Override 222 | public String toString() { 223 | return "user: " + user + " - principals: " + principals; 224 | } 225 | } 226 | 227 | public Optional getApiToken(String principal) { 228 | return userStore.getApiToken(principal); 229 | } 230 | 231 | @Override 232 | public boolean isConfigured() { 233 | return true; 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /src/main/java/com/github/tumbl3w33d/users/db/OAuth2ProxyLoginRecord.java: -------------------------------------------------------------------------------- 1 | package com.github.tumbl3w33d.users.db; 2 | 3 | import java.io.Serializable; 4 | import java.sql.Timestamp; 5 | import java.util.Date; 6 | 7 | import org.sonatype.nexus.common.entity.AbstractEntity; 8 | import org.sonatype.nexus.common.entity.HasStringId; 9 | 10 | public class OAuth2ProxyLoginRecord extends AbstractEntity implements Serializable, HasStringId { 11 | 12 | private static final long serialVersionUID = 2397868513451L; 13 | 14 | private String userId; 15 | private Timestamp lastLogin; 16 | 17 | public Timestamp getLastLogin() { 18 | return lastLogin; 19 | } 20 | 21 | public void setLastLogin(Timestamp lastLogin) { 22 | this.lastLogin = lastLogin; 23 | } 24 | 25 | @Override 26 | public String getId() { 27 | return this.userId; 28 | } 29 | 30 | @Override 31 | public void setId(String id) { 32 | this.userId = id; 33 | } 34 | 35 | public static OAuth2ProxyLoginRecord of(String userId) { 36 | OAuth2ProxyLoginRecord newRecord = new OAuth2ProxyLoginRecord(); 37 | newRecord.setId(userId); 38 | newRecord.setLastLogin(new Timestamp(new Date().getTime())); 39 | return newRecord; 40 | } 41 | 42 | } 43 | -------------------------------------------------------------------------------- /src/main/java/com/github/tumbl3w33d/users/db/OAuth2ProxyRole.java: -------------------------------------------------------------------------------- 1 | package com.github.tumbl3w33d.users.db; 2 | 3 | import java.io.Serializable; 4 | import java.util.Objects; 5 | 6 | import org.sonatype.nexus.common.entity.AbstractEntity; 7 | import org.sonatype.nexus.common.entity.HasStringId; 8 | import org.sonatype.nexus.security.role.Role; 9 | import org.sonatype.nexus.security.role.RoleIdentifier; 10 | 11 | import com.github.tumbl3w33d.users.OAuth2ProxyUserManager; 12 | 13 | public class OAuth2ProxyRole extends AbstractEntity implements Comparable, Serializable, HasStringId { 14 | 15 | private static final long serialVersionUID = 1230813656398236L; 16 | 17 | private String name; 18 | 19 | public String toString() { 20 | return "OAuth2ProxyRole(" + name + ")"; 21 | } 22 | 23 | @Override 24 | public int compareTo(OAuth2ProxyRole o) { 25 | if (o == null) { 26 | return 1; 27 | } 28 | return getName().compareTo(o.getName()); 29 | } 30 | 31 | @Override 32 | public boolean equals(Object obj) { 33 | if (this == obj) 34 | return true; 35 | if (obj == null || getClass() != obj.getClass()) 36 | return false; 37 | OAuth2ProxyRole other = (OAuth2ProxyRole) obj; 38 | return Objects.equals(name, other.name); 39 | } 40 | 41 | @Override 42 | public int hashCode() { 43 | return Objects.hash(name); 44 | } 45 | 46 | public static OAuth2ProxyRole of(RoleIdentifier nexusRole) { 47 | OAuth2ProxyRole role = new OAuth2ProxyRole(); 48 | role.setName(nexusRole.getRoleId()); 49 | return role; 50 | } 51 | 52 | public static OAuth2ProxyRole of(String roleName) { 53 | OAuth2ProxyRole role = new OAuth2ProxyRole(); 54 | role.setName(roleName); 55 | return role; 56 | } 57 | 58 | public String getName() { 59 | return name; 60 | } 61 | 62 | public void setName(String name) { 63 | this.name = name; 64 | } 65 | 66 | public Role toNexusRole() { 67 | Role nexusRole = new Role(); 68 | nexusRole.setRoleId(getName()); 69 | nexusRole.setSource(OAuth2ProxyUserManager.SOURCE); 70 | nexusRole.setName(getName()); 71 | // nexusRole.setReadOnly(true); 72 | nexusRole.setDescription("identity provider role '" + getName() + "'"); 73 | 74 | return nexusRole; 75 | } 76 | 77 | @Override 78 | public String getId() { 79 | return getId(); 80 | } 81 | 82 | @Override 83 | public void setId(String id) { 84 | setId(id); 85 | } 86 | 87 | } 88 | -------------------------------------------------------------------------------- /src/main/java/com/github/tumbl3w33d/users/db/OAuth2ProxyTokenInfo.java: -------------------------------------------------------------------------------- 1 | package com.github.tumbl3w33d.users.db; 2 | 3 | import java.io.Serializable; 4 | import java.sql.Timestamp; 5 | 6 | import org.joda.time.Instant; 7 | import org.sonatype.nexus.common.entity.AbstractEntity; 8 | import org.sonatype.nexus.common.entity.HasStringId; 9 | 10 | public class OAuth2ProxyTokenInfo extends AbstractEntity implements Serializable, HasStringId { 11 | 12 | private static final long serialVersionUID = -2052302452536776751L; 13 | 14 | private String userId; 15 | private Timestamp tokenCreation; 16 | 17 | public Timestamp getTokenCreation() { 18 | return tokenCreation; 19 | } 20 | 21 | public void setTokenCreation(Timestamp tokenCreation) { 22 | this.tokenCreation = tokenCreation; 23 | } 24 | 25 | @Override 26 | public String getId() { 27 | return this.userId; 28 | } 29 | 30 | @Override 31 | public void setId(String id) { 32 | this.userId = id; 33 | } 34 | 35 | public static OAuth2ProxyTokenInfo of(String userId) { 36 | OAuth2ProxyTokenInfo newRecord = new OAuth2ProxyTokenInfo(); 37 | newRecord.setId(userId); 38 | newRecord.setTokenCreation(new Timestamp(Instant.now().getMillis())); 39 | return newRecord; 40 | } 41 | 42 | } 43 | -------------------------------------------------------------------------------- /src/main/java/com/github/tumbl3w33d/users/db/OAuth2ProxyUser.java: -------------------------------------------------------------------------------- 1 | package com.github.tumbl3w33d.users.db; 2 | 3 | import java.io.Serializable; 4 | import java.util.Arrays; 5 | import java.util.Collections; 6 | import java.util.Objects; 7 | import java.util.Optional; 8 | import java.util.Set; 9 | import java.util.stream.Collectors; 10 | import java.util.stream.Stream; 11 | 12 | import org.slf4j.Logger; 13 | import org.slf4j.LoggerFactory; 14 | import org.sonatype.nexus.common.entity.AbstractEntity; 15 | import org.sonatype.nexus.common.entity.HasStringId; 16 | import org.sonatype.nexus.security.role.RoleIdentifier; 17 | import org.sonatype.nexus.security.user.User; 18 | import org.sonatype.nexus.security.user.UserStatus; 19 | 20 | import com.github.tumbl3w33d.users.IncompleteOAuth2ProxyUserDataException; 21 | import com.github.tumbl3w33d.users.OAuth2ProxyUserManager; 22 | 23 | public class OAuth2ProxyUser extends AbstractEntity implements Comparable, Serializable, HasStringId { 24 | 25 | private static final long serialVersionUID = 982589242389412L; 26 | private static final Logger logger = LoggerFactory.getLogger(OAuth2ProxyUser.class.getName()); 27 | 28 | private String preferred_username; 29 | private String email; 30 | private String apiToken; 31 | 32 | private Set groups; 33 | 34 | public String getEmail() { 35 | return this.email; 36 | } 37 | 38 | public void setEmail(String email) { 39 | this.email = email; 40 | } 41 | 42 | public String getPreferredUsername() { 43 | return this.preferred_username; 44 | } 45 | 46 | public void setPreferredUsername(String preferred_username) { 47 | this.preferred_username = preferred_username; 48 | } 49 | 50 | public String getApiToken() { 51 | return apiToken; 52 | } 53 | 54 | public void setApiToken(String apiToken) { 55 | this.apiToken = apiToken; 56 | } 57 | 58 | public Set getGroups() { 59 | return Collections.unmodifiableSet(this.groups); 60 | } 61 | 62 | public void setGroups(String groupString) { 63 | this.groups = Stream.of(groupString.split(",")) 64 | .map(group -> new RoleIdentifier(OAuth2ProxyUserManager.SOURCE, group)) 65 | .collect(Collectors.toSet()); 66 | } 67 | 68 | public void setGroups(Set groups) { 69 | this.groups = groups; 70 | } 71 | 72 | public String toString() { 73 | return String.format("OAuth2ProxyUser(%s) [email: %s | apiToken: %s]", preferred_username, email, 74 | apiToken != null ? "" : "null"); 75 | } 76 | 77 | @Override 78 | public int compareTo(OAuth2ProxyUser o) { 79 | if (o == null) { 80 | return 1; 81 | } 82 | return getPreferredUsername().compareTo(o.getPreferredUsername()); 83 | } 84 | 85 | public User toNexusUser() { 86 | User user = new User(); 87 | user.setUserId(getPreferredUsername()); 88 | logger.debug("user conversion set id {}", user.getUserId()); 89 | 90 | String email = getEmail(); 91 | if (email != null) { 92 | email = email.trim(); 93 | } 94 | user.setEmailAddress(email); 95 | logger.debug("user conversion set email {}", user.getEmailAddress()); 96 | 97 | Optional maybeNameParts = getNameParts(getPreferredUsername()); 98 | 99 | if (maybeNameParts.isPresent()) { 100 | String[] nameParts = maybeNameParts.get(); 101 | logger.debug("user conversion about to set name parts {}", Arrays.toString(nameParts)); 102 | user.setFirstName(nameParts[0]); 103 | user.setLastName(nameParts[1]); 104 | logger.debug("succeeded setting firstname {} and lastname {}", user.getFirstName(), user.getLastName()); 105 | } else { 106 | throw new IncompleteOAuth2ProxyUserDataException( 107 | "preferredUsername missing or in unexpected format - " + getPreferredUsername()); 108 | } 109 | 110 | user.setSource(OAuth2ProxyUserManager.SOURCE); 111 | user.setStatus(UserStatus.active); 112 | user.addAllRoles(getGroups()); 113 | logger.debug("set {} user {} active and added their groups as roles", OAuth2ProxyUserManager.SOURCE, 114 | user.getUserId()); 115 | return user; 116 | } 117 | 118 | public static OAuth2ProxyUser of(User nexusUser) { 119 | if (nexusUser.getEmailAddress() == null || nexusUser.getEmailAddress().isEmpty()) { 120 | throw new IncompleteOAuth2ProxyUserDataException("a nexus user must have an email - " + nexusUser); 121 | } 122 | 123 | OAuth2ProxyUser user = new OAuth2ProxyUser(); 124 | 125 | user.setPreferredUsername(nexusUser.getUserId()); 126 | user.setEmail(nexusUser.getEmailAddress()); 127 | user.setGroups(nexusUser.getRoles()); 128 | 129 | return user; 130 | } 131 | 132 | private static Optional getNameParts(String preferredUsername) { 133 | String[] ret = new String[2]; 134 | 135 | if (preferredUsername == null) { 136 | logger.debug("unable to extract name parts from null input as preferredUsername"); 137 | return Optional.empty(); 138 | } 139 | 140 | // naive approach to figure out names from username 141 | if (preferredUsername.contains(".")) { 142 | String[] name_parts = preferredUsername.split("\\."); 143 | 144 | try { 145 | String assumed_firstname = name_parts[0].substring(0, 1).toUpperCase() + name_parts[0].substring(1); 146 | ret[0] = assumed_firstname; 147 | String assumed_lastname = name_parts[1].substring(0, 1).toUpperCase() + name_parts[1].substring(1); 148 | ret[1] = assumed_lastname; 149 | return Optional.of(ret); 150 | } catch (IndexOutOfBoundsException e) { 151 | logger.debug("preferred username in unexpected format - " + preferredUsername); 152 | } 153 | } 154 | return Optional.empty(); 155 | } 156 | 157 | public String getGroupString() { 158 | return groups.stream().map(group -> group.getRoleId()).collect(Collectors.joining(",")); 159 | } 160 | 161 | @Override 162 | public boolean equals(Object obj) { 163 | if (this == obj) 164 | return true; 165 | if (obj == null || getClass() != obj.getClass()) 166 | return false; 167 | OAuth2ProxyUser other = (OAuth2ProxyUser) obj; 168 | return Objects.equals(preferred_username, other.preferred_username) && 169 | Objects.equals(groups, other.groups) && 170 | Objects.equals(email, other.email) && 171 | Objects.equals(apiToken, other.apiToken); 172 | } 173 | 174 | @Override 175 | public int hashCode() { 176 | return Objects.hash(preferred_username, groups, email, apiToken); 177 | } 178 | 179 | @Override 180 | public String getId() { 181 | return getPreferredUsername(); 182 | } 183 | 184 | @Override 185 | public void setId(String id) { 186 | setPreferredUsername(id); 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /src/main/java/com/github/tumbl3w33d/users/rest/OAuth2ProxyUserResource.java: -------------------------------------------------------------------------------- 1 | 2 | package com.github.tumbl3w33d.users.rest; 3 | 4 | import static com.google.common.base.Preconditions.checkNotNull; 5 | 6 | import javax.inject.Inject; 7 | import javax.inject.Named; 8 | import javax.inject.Singleton; 9 | import javax.ws.rs.Consumes; 10 | import javax.ws.rs.DELETE; 11 | import javax.ws.rs.POST; 12 | import javax.ws.rs.Path; 13 | import javax.ws.rs.PathParam; 14 | import javax.ws.rs.Produces; 15 | import javax.ws.rs.core.MediaType; 16 | import javax.ws.rs.core.Response.Status; 17 | 18 | import org.apache.shiro.authz.annotation.RequiresAuthentication; 19 | import org.apache.shiro.authz.annotation.RequiresPermissions; 20 | import org.apache.shiro.authz.annotation.RequiresUser; 21 | import org.sonatype.goodies.common.ComponentSupport; 22 | import org.sonatype.nexus.rest.Resource; 23 | import org.sonatype.nexus.rest.WebApplicationMessageException; 24 | import org.sonatype.nexus.security.SecuritySystem; 25 | import org.sonatype.nexus.security.user.NoSuchUserManagerException; 26 | import org.sonatype.nexus.security.user.User; 27 | import org.sonatype.nexus.security.user.UserNotFoundException; 28 | 29 | import com.github.tumbl3w33d.OAuth2ProxyRealm; 30 | import com.github.tumbl3w33d.users.OAuth2ProxyUserManager; 31 | 32 | @Named 33 | @Singleton 34 | @Consumes(MediaType.APPLICATION_JSON) 35 | @Produces(MediaType.APPLICATION_JSON) 36 | @Path("/oauth2-proxy/user") 37 | public class OAuth2ProxyUserResource extends ComponentSupport implements Resource { 38 | 39 | private final SecuritySystem securitySystem; 40 | 41 | @Inject 42 | public OAuth2ProxyUserResource(final SecuritySystem securitySystem) { 43 | this.securitySystem = checkNotNull(securitySystem); 44 | } 45 | 46 | private User getCurrentUser() throws UserNotFoundException { 47 | User user = securitySystem.currentUser(); 48 | if (user == null) { 49 | throw new UserNotFoundException("Unable to get current user"); 50 | } 51 | return user; 52 | } 53 | 54 | @POST 55 | @Path("/reset-token") 56 | @RequiresAuthentication 57 | @RequiresUser 58 | @Produces(MediaType.TEXT_PLAIN) 59 | public String resetToken() { 60 | String generatedToken = OAuth2ProxyRealm.generateSecureRandomString(32); 61 | 62 | try { 63 | User user = getCurrentUser(); 64 | 65 | securitySystem.changePassword(user.getUserId(), generatedToken, true); 66 | 67 | log.debug("user {} reset their api token", user.getUserId()); 68 | 69 | } catch (UserNotFoundException e) { 70 | log.debug("user not found for token reset"); 71 | throw new WebApplicationMessageException(Status.NOT_FOUND, "no user found for token reset", 72 | MediaType.APPLICATION_JSON); 73 | } 74 | 75 | return generatedToken; 76 | } 77 | 78 | @DELETE 79 | @Path("/{userId}") 80 | @RequiresAuthentication 81 | @RequiresPermissions("nexus:users:delete") 82 | public void deleteUser(@PathParam("userId") final String userId) { 83 | User user = null; 84 | try { 85 | user = securitySystem.getUser(userId, OAuth2ProxyUserManager.SOURCE); 86 | 87 | securitySystem.deleteUser(userId, user.getSource()); 88 | 89 | } catch (NoSuchUserManagerException e) { 90 | throw new WebApplicationMessageException(Status.INTERNAL_SERVER_ERROR, 91 | "unable to access related user manager", 92 | MediaType.APPLICATION_JSON); 93 | } catch (UserNotFoundException e) { 94 | log.debug("unable to retrieve user with id {} for deletion - {}", userId, e); 95 | throw new WebApplicationMessageException(Status.NOT_FOUND, "no such user", MediaType.APPLICATION_JSON); 96 | } 97 | } 98 | 99 | } -------------------------------------------------------------------------------- /src/main/resources/com/github/tumbl3w33d/h2/OAuth2ProxyLoginRecordDAO.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | CREATE TABLE IF NOT EXISTS oauth2_proxy_login_record ( 11 | userId VARCHAR(255) NOT NULL, 12 | lastLogin TIMESTAMP NOT NULL, 13 | PRIMARY KEY (userId) 14 | ); 15 | 16 | 17 | 18 | INSERT INTO oauth2_proxy_login_record (userId, lastLogin) 19 | VALUES (#{userId, jdbcType=VARCHAR}, #{lastLogin, jdbcType=TIMESTAMP}) 20 | 21 | 22 | 23 | UPDATE oauth2_proxy_login_record 24 | SET lastLogin = #{lastLogin, jdbcType=TIMESTAMP} 25 | WHERE userId = #{userId, jdbcType=VARCHAR} 26 | 27 | 28 | 33 | 34 | 35 | DELETE FROM oauth2_proxy_login_record 36 | WHERE userId = #{userId} 37 | 38 | 39 | 43 | 44 | -------------------------------------------------------------------------------- /src/main/resources/com/github/tumbl3w33d/h2/OAuth2ProxyRoleDAO.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CREATE TABLE IF NOT EXISTS oauth2_proxy_role ( 6 | name VARCHAR(512) NOT NULL, 7 | CONSTRAINT pk_oauth2_proxy_role_name PRIMARY KEY (name) 8 | ); 9 | 10 | 11 | 14 | 15 | 16 | INSERT INTO oauth2_proxy_role(name) 17 | VALUES (#{name}); 18 | 19 | 20 | 23 | 24 | 25 | DELETE FROM oauth2_proxy_role WHERE name = #{value}; 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/main/resources/com/github/tumbl3w33d/h2/OAuth2ProxyTokenInfoDAO.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | CREATE TABLE IF NOT EXISTS oauth2_proxy_token_info ( 11 | userId VARCHAR(255) NOT NULL, 12 | tokenCreation TIMESTAMP NOT NULL, 13 | PRIMARY KEY (userId) 14 | ); 15 | 16 | -- Seed this table by assuming the last login was when the token was created. While not correct, this is better than nothing 17 | INSERT INTO oauth2_proxy_token_info (userId, tokenCreation) 18 | SELECT l.userId, l.lastLogin 19 | FROM oauth2_proxy_login_record l 20 | WHERE NOT EXISTS ( 21 | SELECT 1 22 | FROM oauth2_proxy_token_info t 23 | WHERE t.userId = l.userId 24 | ); 25 | 26 | 27 | 28 | INSERT INTO oauth2_proxy_token_info (userId, tokenCreation) 29 | VALUES (#{userId, jdbcType=VARCHAR}, #{tokenCreation, jdbcType=TIMESTAMP}) 30 | 31 | 32 | 33 | UPDATE oauth2_proxy_token_info 34 | SET tokenCreation = #{tokenCreation, jdbcType=TIMESTAMP} 35 | WHERE userId = #{userId, jdbcType=VARCHAR} 36 | 37 | 38 | 43 | 44 | 45 | DELETE FROM oauth2_proxy_token_info 46 | WHERE userId = #{userId} 47 | 48 | 49 | 53 | 54 | -------------------------------------------------------------------------------- /src/main/resources/com/github/tumbl3w33d/h2/OAuth2ProxyUserDAO.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CREATE TABLE IF NOT EXISTS oauth2_proxy_user ( 6 | preferred_username VARCHAR(512) NOT NULL, 7 | email VARCHAR(512) NOT NULL, 8 | apiToken VARCHAR(512), 9 | groups TEXT, 10 | 11 | CONSTRAINT pk_oauth2_proxy_user_preferred_username PRIMARY KEY (preferred_username) 12 | ); 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 25 | 26 | 27 | INSERT INTO oauth2_proxy_user(preferred_username, email, apiToken, groups) 28 | VALUES (#{preferred_username}, #{email}, #{apiToken}, #{groupString}); 29 | 30 | 31 | 34 | 35 | 36 | UPDATE oauth2_proxy_user SET 37 | groups = #{groupString} 38 | WHERE preferred_username = #{preferredUsername}; 39 | 40 | 41 | 42 | UPDATE oauth2_proxy_user SET apiToken = #{apiToken} 43 | WHERE preferred_username = #{preferredUsername}; 44 | 45 | 46 | 47 | DELETE FROM oauth2_proxy_user WHERE preferred_username = #{value}; 48 | 49 | 50 | -------------------------------------------------------------------------------- /src/main/resources/static/rapture/resources/nexus-oauth2-proxy-bundle.css: -------------------------------------------------------------------------------- 1 | .nx-icon-oauth2proxy-security-source-x16 { 2 | background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAhGVYSWZNTQAqAAAACAAFARIAAwAAAAEAAQAAARoABQAAAAEAAABKARsABQAAAAEAAABSASgAAwAAAAEAAgAAh2kABAAAAAEAAABaAAAAAAAAAEgAAAABAAAASAAAAAEAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAEKADAAQAAAABAAAAEAAAAADHbxzxAAAACXBIWXMAAAsTAAALEwEAmpwYAAACMmlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUgNi4wLjAiPgogICA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPgogICAgICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgICAgICAgICB4bWxuczpleGlmPSJodHRwOi8vbnMuYWRvYmUuY29tL2V4aWYvMS4wLyIKICAgICAgICAgICAgeG1sbnM6dGlmZj0iaHR0cDovL25zLmFkb2JlLmNvbS90aWZmLzEuMC8iPgogICAgICAgICA8ZXhpZjpQaXhlbFlEaW1lbnNpb24+NDQ3PC9leGlmOlBpeGVsWURpbWVuc2lvbj4KICAgICAgICAgPGV4aWY6UGl4ZWxYRGltZW5zaW9uPjQ0NzwvZXhpZjpQaXhlbFhEaW1lbnNpb24+CiAgICAgICAgIDxleGlmOkNvbG9yU3BhY2U+MTwvZXhpZjpDb2xvclNwYWNlPgogICAgICAgICA8dGlmZjpPcmllbnRhdGlvbj4xPC90aWZmOk9yaWVudGF0aW9uPgogICAgICA8L3JkZjpEZXNjcmlwdGlvbj4KICAgPC9yZGY6UkRGPgo8L3g6eG1wbWV0YT4KRlsFQgAAA0hJREFUOBE9U11sVEUU/mbuzN1t2bbuFlMxUNKWnwJFxUaggAYNKTEaCNKWCPGBgEQfTAwvxoSHJUTCGw888UIC8S9WYgwhMSihQDTVtoJmTUtx6RZhC41td9vdvXvv3rnjmb3gyc5m5tzzffOdc+ZYeGq9vRb6+iwMDARVV9eetxvfPPB5XdfOY8VEcwXpW7fIH0BrhutMIBPGsWrw9qTAQNKv7rf2vIq21Sfb1m3Yll7SCjCOttw0sqmhCSc9nsRP5y9U43q/sdDfFxgCszTW7VyBjvWnIu0v7H1uRQcmmfS7awV7VoB9Ma9UA1PSmprEbGpoBOnRT3Dz26uGKFSw7+jH9tKW0/UdG7E4Vq/Gyp7+cm1C9Lz4PDhnGJ6cxeafH6rWiB2Uyo6cnxhDaeyPC1hVc9BCZ3d79JXtlxKdryEWiXh3nbLcFZP8xOtt+Pp2Fuf+fITDncuQWHD4V9m81Vwb8dG01GcNiZfdG4P3OfxgsayPm1wV91ybksH6ughcP8C5ezmcySwg53hY01hrSgihfPr5TNbGACmaBAKtEJjCa8Y4J7hi2bJPLo3xigJ8hV8ys/h1qgBYDJUwcQZNGMZ9Yc6mEqYYHsFBOfe0xuEQuH/LciMIuWIZm5bE0J1zccWpYLlgumAQ1NKQwOBouTpgkgi2tsTRUGOjPD+HYqmELWupnWQ/TOZxpeghSmDSUzUOxjQxVQ+M9uYxlNzwSfx+J4OL10eq3zxSpMIwUqUNiGQzTRdrybhlpAc6CJjhqolIBMpH+sEUZvILmMnlYUvLAI1sQ6ipXqaoUkDWZd2ZaQQtq4UdiXpwPVl0fVYjBPa/9QZdwiClRJkUONQZ2DbtGPfyc0Tj3w8f0t6P3rWalp1NvNRV59bHdVfUUk1cC2mZylBfqEtziqnBitZBqSjm/xqBO3HnFPpPf8qQTHJaRN0cxzu7P4ut7Piw0L4BiC6ipKkWAQ2PlAoVT8Ye/I1CavhHjKeOYvhyypCb6dMww5T5voTR3y57Dv8uXvi3JW7xVY3PNPK44GzR438se+jaaH74xmFcOnsM2bvTdCkNYDiRhghVJUeOyPBA/5t378Ch44P8g5MZ7Hjv/f/9ZgrN6D+x/wDwkGKqioOY7wAAAABJRU5ErkJggg==') no-repeat center center !important; 3 | height: 16px; 4 | width: 16px; 5 | } 6 | 7 | .nx-icon-oauth2proxy-security-source-x32 { 8 | background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAhGVYSWZNTQAqAAAACAAFARIAAwAAAAEAAQAAARoABQAAAAEAAABKARsABQAAAAEAAABSASgAAwAAAAEAAgAAh2kABAAAAAEAAABaAAAAAAAAAEgAAAABAAAASAAAAAEAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAIKADAAQAAAABAAAAIAAAAABfvA/wAAAACXBIWXMAAAsTAAALEwEAmpwYAAACMmlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUgNi4wLjAiPgogICA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPgogICAgICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgICAgICAgICB4bWxuczpleGlmPSJodHRwOi8vbnMuYWRvYmUuY29tL2V4aWYvMS4wLyIKICAgICAgICAgICAgeG1sbnM6dGlmZj0iaHR0cDovL25zLmFkb2JlLmNvbS90aWZmLzEuMC8iPgogICAgICAgICA8ZXhpZjpQaXhlbFlEaW1lbnNpb24+NDQ3PC9leGlmOlBpeGVsWURpbWVuc2lvbj4KICAgICAgICAgPGV4aWY6UGl4ZWxYRGltZW5zaW9uPjQ0NzwvZXhpZjpQaXhlbFhEaW1lbnNpb24+CiAgICAgICAgIDxleGlmOkNvbG9yU3BhY2U+MTwvZXhpZjpDb2xvclNwYWNlPgogICAgICAgICA8dGlmZjpPcmllbnRhdGlvbj4xPC90aWZmOk9yaWVudGF0aW9uPgogICAgICA8L3JkZjpEZXNjcmlwdGlvbj4KICAgPC9yZGY6UkRGPgo8L3g6eG1wbWV0YT4KRlsFQgAACDlJREFUWAmVV11sHFcV/uZvZ3f2z45D7NqOYwfHCZBSQkNfQFUQpVH7UPGSghAI1BKnrVQhIInUkNarpInbAolKVBpMgwSiD62pUKQitUgoUR9ARTQoECdpY8d2HCdx7Tj2endndn455+7M7tqkdrjanblz595zvvM750i405HLqcj1eYAU8BFl54FHvVJpNzyvB3H990hnXsaxvSOCXG+vhtZWD7mcvxJ5aaUN2PGmAgzSb5CYE+MfPP9Nzyw8C0n6nNq0BmrCgDU9BRTnPejGCaSyh/HK7nFBl4EMDLg0F6DF2pLLJwPI5WTaK5MUTIAkPvSQZxUOIpDuVRpWId210Y03t0mSFpPd/JxbGPtQM69PAKWijUTyGIxYP17Zd1PwqwBxxHzJ5TYAAgm9AyoGdokD2q4Xtzpmvp9U/YCcbUSmc6Mba1snxTRdiXsu5MCHo6gwyTRuftYtjFzQrBuTQNmcl41kv39fyxHsIlos0GkS6HRFoAjHYgD1SH/4QgfmC8/Dtr9LakW6s8c11nZJ0BPKKt+D6fkYc1mz9JclbFZlLMgKLFpwZ6fdheEhzZ6+AXjumGKk9nu/2f+6YMq+BPiRf9QAsK0HH/XwoyMJzOX3wSruJVXGku1dfnL9Jp+IqK7nYTVJfKFMvkUMn2iKI63JOJu38Ze5MgxdRjOBKSkaZN/3relr3sKlc5o7x5YI3leN5F731/vfE0B27CB+g14FAKuHPfbx3LdIdUehxFr05lakN9ztaJlGDcTUJXWvlmVcsDwcXp/F9+5pRUtGJ+GJoePhzMQcvv2PSUx4ATrJe0xWjEZHHcc3J0f9/MgFNSjME/DYm1CVJ3EiNwsCIQkvZ8m//9weyPJLajKFzMZ7HH31XawqKSDGrOhVCjE3Xfy8uxE/+UqnEIIvfhAIEDy/NF1Az7sjWEtkOf48ekfRAknV4Fkltzh6EcWJUZWEHEcithUDuRkF5wcD9B7eRIsn9TVtQdOXtnlqKqsRY4klZwIq/Ys01UntJ7Z1kYMruJG3cOTvV3Di3MfIygG6mpJoSsbwadfF764uYJ2uoEggiAj7AYOQKWpkPZU1zdnp1bDsu3D2vT9xqAGO9VVoMaR7NruSoqq+YwvGzJylT5GhpkjNTzYbggkf+cX7E+j7aBZ/yJfxwKlxDF3P8zK+2JoRZ4VmxAodloiN7yOwy4g1t+vxT7UAtnk/Tp0ikXh4gQJSk0TOw1JLxLh+KPxMSDIq5SQaRdvFv8jx1qRi+DJpgx1y/JYp3sXJVKAljuFFVARNpuND0RMMSMJ/TN7Ngy3Gg+X93xGE6ySDeElWBTk8PiYbW2xnGuT8Yvj8eHsylQ0MKzyDlnVBCCC8hVvqb4JuSDDkQedrHDRepMdYKItOYck8or31tBbPKzvY05cdzKpigoA8vrI1RiovUriR6XDGIe2pEt64dBOX50yML5D/kDrYBPzxWAnIigDY6vPMWVNEuE3OmyhYLtYnVBjEiDUQS0oYobV3Ls+hk8Bti6s4Xfawlt4TnGVBLAuA0Wfpwsnnn1/rwpb2LPKmg4yu4lcPboBCaudE5LgepRD2jMqwKU3v/usIjs+Y6CaTFELNha8X3ZYFwDujs40kMTNrMGJVArbtoGDbyKaS1TWeaASsw6CI8kocXMuOT/a+Jcfc0AG88M5x3jfwBhq+04erUzNiN6X/qoM6vI9UEgmwhFz18Y4BRAqO1FwomXh7aBS4eBOjV69XCXLy4hHeqmapbVg8u2MA1WMsFUmfSRrYdf8XgC0tuHdzT/X1/zsJAYSJaFl91V5yphy5cg3D16axhnzib2eGYJVtckQiV5cjlgdToRcCqOmtPsnUE4jYsyP++6PL6H7sBbw8NIZ2Q8fXnz6O42+9I7ZH+yIc0XOFVvgU2efGuFSJAkW24Trw7XKgpbPwylZow8jinO5r8w0dbciffFGEHgPmEHQpFMU8JK7xfuJXPcWI+KNEC25xgbKb6uPph5yKBvTY23DK5fnzH8TchTlbjukEWCaCPl1p0NmZYqWm5BiPx3WkkwkYiTiSRgKJeBwZCkWRHMn7uUC5TLUppVA4LDQxl1SSVVb84sh5uzw7DcQTb5GU9EXaRjUaF4qP5R4hECep5qOKd5NrrOuWCIgik2amiehG+hJuTWris8UhyHSXDgbLmpqyPfyZAHfQ3KY6UVLkwKY6Mf/hWc2ZmyVA/rvoVB7mKqyioage3Hm4B+XiL+lTuV3hCrjnbkdvblepIJHmHQcLIgdUlbqUf+2ZepcOktimatkvLbiF4fNKaXJMQtnKy3Fjv//bvmNiM5WCNWqRJuiN0nvwG16p+DPIardOxUOq5/NOoqFJ00n+gApTYcjayRpjVjX5gEd1hemUPfPKSJAfvaiiQMWKnhiAlv4pBnZz1pJIev6HGohIcKWKHdQFUY2YC2R54sAev0RdUNxIGm2dQXL9Z6hcS6sMIqDSXCQnYhhFjkx5N6B0WJ666uWHz2keq1uWT2nJ1B7n1Wc+EGzqS3+BJGJef6/f9NRLLShRf1C2HkcqQ/3BBs/o6A5kPUFAqGmi9CscjCzLduYy3J6hVs13h5V48hnvtWf/WGV8m37xdoqMoFCH1Esd0kClQ3qif4tTKnCHtF3ONIpGJd7awY4qk2O5hdGwI7KtW9QRHfLbnzuKnMQNiIyhz0pCqxHluvtyACrbmADHZNgjqjsPbXet4gGS/D4lbE7L3AGVChaZ6hgSLf149alb4rDoqBe3YnW8xXRlANGJpV3yzoOPeGZxH2lkE8X0a9AbjuL4jyfF9ooJl+2KI7L/BSz4fhcad0hvAAAAAElFTkSuQmCC') no-repeat center center !important; 9 | height: 32px; 10 | width: 32px; 11 | } -------------------------------------------------------------------------------- /src/test/java/com/github/tumbl3w33d/OAuth2ProxyFilterTest.java: -------------------------------------------------------------------------------- 1 | package com.github.tumbl3w33d; 2 | 3 | import static com.github.tumbl3w33d.OAuth2ProxyHeaderAuthTokenFactoryTest.createMockRequestViaProxy; 4 | import static com.github.tumbl3w33d.OAuth2ProxyHeaderAuthTokenFactoryTest.groups; 5 | import static com.github.tumbl3w33d.OAuth2ProxyHeaderAuthTokenFactoryTest.host; 6 | import static com.github.tumbl3w33d.OAuth2ProxyHeaderAuthTokenFactoryTest.mail; 7 | import static com.github.tumbl3w33d.OAuth2ProxyHeaderAuthTokenFactoryTest.userUuid; 8 | import static com.github.tumbl3w33d.OAuth2ProxyHeaderAuthTokenFactoryTest.username; 9 | import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; 10 | import static org.junit.jupiter.api.Assertions.assertEquals; 11 | import static org.junit.jupiter.api.Assertions.assertFalse; 12 | import static org.junit.jupiter.api.Assertions.assertNotNull; 13 | import static org.junit.jupiter.api.Assertions.assertNull; 14 | import static org.junit.jupiter.api.Assertions.assertTrue; 15 | import static org.mockito.ArgumentMatchers.any; 16 | 17 | import javax.servlet.ServletRequest; 18 | import javax.servlet.ServletResponse; 19 | import javax.servlet.http.HttpServletRequest; 20 | 21 | import org.apache.shiro.authc.AuthenticationToken; 22 | import org.junit.jupiter.api.BeforeEach; 23 | import org.junit.jupiter.api.Test; 24 | import org.mockito.Mockito; 25 | 26 | public class OAuth2ProxyFilterTest { 27 | 28 | private OAuth2ProxyFilter filter; 29 | 30 | @Test 31 | void testCreateToken() { 32 | HttpServletRequest request = createMockRequestViaProxy(false); 33 | ServletResponse response = Mockito.mock(ServletResponse.class); 34 | 35 | AuthenticationToken token = filter.createToken(request, response); 36 | assertNotNull(token); 37 | assertDoesNotThrow(() -> (OAuth2ProxyHeaderAuthToken) token); 38 | OAuth2ProxyHeaderAuthToken proxyToken = (OAuth2ProxyHeaderAuthToken) token; 39 | assertEquals(OAuth2ProxyHeaderAuthTokenFactoryTest.username, proxyToken.getPrincipal()); 40 | assertNull(proxyToken.getCredentials()); 41 | assertEquals(userUuid, proxyToken.user.getHeaderValue()); 42 | assertEquals(username, proxyToken.preferred_username.getHeaderValue()); 43 | assertEquals(mail, proxyToken.email.getHeaderValue()); 44 | assertEquals(groups, proxyToken.groups.getHeaderValue()); 45 | assertEquals(host, proxyToken.getHost()); 46 | 47 | // incomplete set of proxy headers 48 | request = createMockRequestViaProxy(true); 49 | Mockito.reset(response); 50 | assertNull(filter.createToken(request, response)); 51 | 52 | // programmatic access - reject requests with Authorization header 53 | Mockito.when(request.getHeader("Authorization")).thenReturn("Basic foo123=="); 54 | 55 | assertNull(filter.createToken(request, response)); 56 | } 57 | 58 | @Test 59 | void testIsLoginAttempt() { 60 | HttpServletRequest request = Mockito.mock(HttpServletRequest.class); 61 | ServletResponse response = Mockito.mock(ServletResponse.class); 62 | assertFalse(filter.isLoginAttempt(request, response)); 63 | 64 | request = createMockRequestViaProxy(false); 65 | assertTrue(filter.isLoginAttempt(request, response)); 66 | } 67 | 68 | @Test 69 | void testOnAccessDenied() throws Exception { 70 | 71 | filter = Mockito.spy(new OAuth2ProxyFilter()); 72 | Mockito.doReturn(true).when(filter).executeLogin(any(ServletRequest.class), any(ServletResponse.class)); 73 | 74 | HttpServletRequest request = Mockito.mock(HttpServletRequest.class); 75 | ServletResponse response = Mockito.mock(ServletResponse.class); 76 | assertFalse(filter.onAccessDenied(request, response)); 77 | 78 | request = createMockRequestViaProxy(false); 79 | boolean onAccessDeniedResult = filter.onAccessDenied(request, response); 80 | assertTrue(onAccessDeniedResult); 81 | } 82 | 83 | @BeforeEach 84 | private void prepareFilter() { 85 | filter = new OAuth2ProxyFilter(); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/test/java/com/github/tumbl3w33d/OAuth2ProxyHeaderAuthTokenFactoryTest.java: -------------------------------------------------------------------------------- 1 | package com.github.tumbl3w33d; 2 | 3 | import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; 4 | import static org.junit.jupiter.api.Assertions.assertEquals; 5 | import static org.junit.jupiter.api.Assertions.assertNotNull; 6 | import static org.junit.jupiter.api.Assertions.assertNull; 7 | 8 | import java.util.List; 9 | import java.util.UUID; 10 | 11 | import javax.servlet.ServletResponse; 12 | import javax.servlet.http.HttpServletRequest; 13 | 14 | import org.apache.shiro.authc.AuthenticationToken; 15 | import org.junit.jupiter.api.Test; 16 | import org.mockito.Mockito; 17 | 18 | import com.google.common.collect.ImmutableSet; 19 | 20 | public class OAuth2ProxyHeaderAuthTokenFactoryTest { 21 | 22 | static final String userUuid = UUID.randomUUID().toString(); 23 | static final String username = "test.user"; 24 | static final String mail = "test.user@example.com"; 25 | static final String groups = "administrators@idm.example.com,devs@idm.example.com"; 26 | static final String host = "127.0.0.1"; 27 | 28 | @Test 29 | void testCreateToken() { 30 | OAuth2ProxyHeaderAuthTokenFactory factory = new OAuth2ProxyHeaderAuthTokenFactory(); 31 | HttpServletRequest request = createMockRequestViaProxy(false); 32 | ServletResponse response = Mockito.mock(ServletResponse.class); 33 | 34 | AuthenticationToken token = factory.createToken(request, response); 35 | assertNotNull(token); 36 | assertDoesNotThrow(() -> (OAuth2ProxyHeaderAuthToken) token); 37 | OAuth2ProxyHeaderAuthToken proxyToken = (OAuth2ProxyHeaderAuthToken) token; 38 | assertToken(proxyToken); 39 | 40 | // programmatic access - accept requests with Authorization header AND complete oauth2 proxy headers 41 | // this allows oauth2 proxy's --skip-jwt-bearer-tokens option to be used for realising alternative oidc login flows 42 | // e.g. obtaining an access token via device flow, then using it to login to nexus without having to deal with redirects 43 | // in this case oauth2 proxy validates the token, populates all headers, but also forwards the Authorization header 44 | Mockito.when(request.getHeader("Authorization")).thenReturn("Basic foo123=="); 45 | assertNotNull(token); 46 | assertDoesNotThrow(() -> (OAuth2ProxyHeaderAuthToken) token); 47 | proxyToken = (OAuth2ProxyHeaderAuthToken) token; 48 | assertToken(proxyToken); 49 | 50 | // programmatic access - reject requests with Authorization header but with incomplete oauth2 proxy headers 51 | Mockito.when(request.getHeader("Authorization")).thenReturn("Basic foo123=="); 52 | Mockito.when(request.getHeader(OAuth2ProxyHeaderAuthTokenFactory.X_FORWARDED_USER)).thenReturn(null); 53 | assertNull(factory.createToken(request, response)); 54 | } 55 | 56 | private static void assertToken(OAuth2ProxyHeaderAuthToken proxyToken) { 57 | assertEquals(username, proxyToken.getPrincipal()); 58 | assertNull(proxyToken.getCredentials()); 59 | assertEquals(userUuid, proxyToken.user.getHeaderValue()); 60 | assertEquals(username, proxyToken.preferred_username.getHeaderValue()); 61 | assertEquals(mail, proxyToken.email.getHeaderValue()); 62 | assertEquals(groups, proxyToken.groups.getHeaderValue()); 63 | assertEquals(host, proxyToken.getHost()); 64 | assertNull(proxyToken.accessToken); 65 | } 66 | 67 | static HttpServletRequest createMockRequestViaProxy(boolean fakeMissingHeader) { 68 | HttpServletRequest request = Mockito.mock(HttpServletRequest.class); 69 | Mockito.when(request.getHeader(OAuth2ProxyHeaderAuthTokenFactory.X_FORWARDED_USER)).thenReturn(userUuid); 70 | if (!fakeMissingHeader) { 71 | Mockito.when(request.getHeader(OAuth2ProxyHeaderAuthTokenFactory.X_FORWARDED_PREFERRED_USERNAME)) 72 | .thenReturn(username); 73 | } 74 | Mockito.when(request.getHeader(OAuth2ProxyHeaderAuthTokenFactory.X_FORWARDED_EMAIL)).thenReturn(mail); 75 | Mockito.when(request.getHeader(OAuth2ProxyHeaderAuthTokenFactory.X_FORWARDED_GROUPS)).thenReturn(groups); 76 | Mockito.when(request.getRemoteHost()).thenReturn(host); 77 | return request; 78 | } 79 | 80 | @Test 81 | void testGetHttpHeaderNames() { 82 | OAuth2ProxyHeaderAuthTokenFactory factory = new OAuth2ProxyHeaderAuthTokenFactory(); 83 | List headers = factory.getHttpHeaderNames(); 84 | headers.containsAll(ImmutableSet.of(OAuth2ProxyHeaderAuthTokenFactory.X_FORWARDED_USER, 85 | OAuth2ProxyHeaderAuthTokenFactory.X_FORWARDED_PREFERRED_USERNAME, 86 | OAuth2ProxyHeaderAuthTokenFactory.X_FORWARDED_EMAIL, 87 | OAuth2ProxyHeaderAuthTokenFactory.X_FORWARDED_GROUPS, 88 | OAuth2ProxyHeaderAuthTokenFactory.X_FORWARDED_ACCESS_TOKEN)); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/test/java/com/github/tumbl3w33d/OAuth2ProxyRealmTest.java: -------------------------------------------------------------------------------- 1 | package com.github.tumbl3w33d; 2 | 3 | import static org.junit.jupiter.api.Assertions.assertEquals; 4 | import static org.junit.jupiter.api.Assertions.assertFalse; 5 | import static org.junit.jupiter.api.Assertions.assertNotEquals; 6 | import static org.junit.jupiter.api.Assertions.assertNotNull; 7 | import static org.junit.jupiter.api.Assertions.assertNull; 8 | import static org.junit.jupiter.api.Assertions.assertThrows; 9 | import static org.junit.jupiter.api.Assertions.assertTrue; 10 | import static org.mockito.ArgumentMatchers.any; 11 | import static org.mockito.ArgumentMatchers.anyString; 12 | import static org.mockito.Mockito.doThrow; 13 | import static org.mockito.Mockito.verify; 14 | import static org.mockito.Mockito.when; 15 | 16 | import java.util.Date; 17 | import java.util.Optional; 18 | import java.util.Set; 19 | import java.util.UUID; 20 | import java.util.stream.Collectors; 21 | import java.util.stream.Stream; 22 | 23 | import org.apache.shiro.authc.AuthenticationInfo; 24 | import org.apache.shiro.authc.BearerToken; 25 | import org.apache.shiro.authc.UsernamePasswordToken; 26 | import org.apache.shiro.authc.credential.PasswordService; 27 | import org.apache.shiro.authz.AuthorizationInfo; 28 | import org.apache.shiro.subject.PrincipalCollection; 29 | import org.apache.shiro.subject.SimplePrincipalCollection; 30 | import org.junit.jupiter.api.AfterEach; 31 | import org.junit.jupiter.api.BeforeEach; 32 | import org.junit.jupiter.api.Test; 33 | import org.junit.jupiter.api.extension.ExtendWith; 34 | import org.mockito.ArgumentCaptor; 35 | import org.mockito.Captor; 36 | import org.mockito.InjectMocks; 37 | import org.mockito.Mock; 38 | import org.mockito.Mockito; 39 | import org.mockito.junit.jupiter.MockitoExtension; 40 | import org.slf4j.Logger; 41 | import org.sonatype.nexus.common.event.EventManager; 42 | import org.sonatype.nexus.datastore.api.DataSession; 43 | import org.sonatype.nexus.security.authc.HttpHeaderAuthenticationToken; 44 | import org.sonatype.nexus.security.internal.DefaultSecurityPasswordService; 45 | import org.sonatype.nexus.security.role.RoleIdentifier; 46 | import org.sonatype.nexus.security.user.User; 47 | import org.sonatype.nexus.security.user.UserManager; 48 | import org.sonatype.nexus.security.user.UserNotFoundException; 49 | import org.sonatype.nexus.transaction.UnitOfWork; 50 | 51 | import com.github.tumbl3w33d.h2.OAuth2ProxyLoginRecordDAO; 52 | import com.github.tumbl3w33d.h2.OAuth2ProxyLoginRecordStore; 53 | import com.github.tumbl3w33d.h2.OAuth2ProxyRoleDAO; 54 | import com.github.tumbl3w33d.h2.OAuth2ProxyRoleStore; 55 | import com.github.tumbl3w33d.h2.OAuth2ProxyUserDAO; 56 | import com.github.tumbl3w33d.logout.OAuth2ProxyLogoutHandler; 57 | import com.github.tumbl3w33d.users.OAuth2ProxyUserManager; 58 | import com.github.tumbl3w33d.users.OAuth2ProxyUserManager.UserWithPrincipals; 59 | import com.github.tumbl3w33d.users.db.OAuth2ProxyLoginRecord; 60 | import com.google.common.collect.ImmutableSet; 61 | 62 | @ExtendWith(MockitoExtension.class) 63 | public class OAuth2ProxyRealmTest { 64 | 65 | @Mock 66 | private Logger logger; 67 | 68 | @InjectMocks 69 | private OAuth2ProxyLoginRecordStore loginRecordStore; 70 | 71 | private OAuth2ProxyRealm oauth2ProxyRealm; 72 | 73 | @Mock 74 | private static DataSession dataSession; 75 | 76 | @Mock 77 | private static OAuth2ProxyUserDAO userDAO; 78 | 79 | @Mock 80 | private static OAuth2ProxyRoleDAO roleDAO; 81 | 82 | @Mock 83 | private static OAuth2ProxyLoginRecordDAO loginRecordDAO; 84 | 85 | @Captor 86 | private ArgumentCaptor recordCaptor; 87 | 88 | @BeforeEach 89 | public void setUp() { 90 | oauth2ProxyRealm = getTestRealm(null, null); 91 | 92 | UnitOfWork.beginBatch(dataSession); 93 | } 94 | 95 | @AfterEach 96 | public void tearDown() { 97 | UnitOfWork.end(); 98 | } 99 | 100 | @Test 101 | public void testFormatDateString() { 102 | assertEquals("unknown", OAuth2ProxyRealm.formatDateString(null)); 103 | assertEquals("2024-05-10", OAuth2ProxyRealm.formatDateString(new Date(1715343438000l))); 104 | } 105 | 106 | @Test 107 | void testDoGetAuthorizationInfo() { 108 | OAuth2ProxyUserManager userManager = Mockito.mock(OAuth2ProxyUserManager.class); 109 | 110 | oauth2ProxyRealm = getTestRealm(userManager, null); 111 | 112 | User existingUser = OAuth2ProxyUserManager.createUserObject("test.user", "test.user@example.com"); 113 | 114 | existingUser.addAllRoles( 115 | ImmutableSet.of(new RoleIdentifier(OAuth2ProxyUserManager.SOURCE, "administrators@idm.example.com"), 116 | new RoleIdentifier(OAuth2ProxyUserManager.SOURCE, "devs@idm.example.com"))); 117 | 118 | PrincipalCollection principalCollection = new SimplePrincipalCollection("test.user", 119 | OAuth2ProxyUserManager.SOURCE); 120 | 121 | try { 122 | Mockito.when(userManager.getUser("test.user")).thenReturn(existingUser); 123 | } catch (UserNotFoundException e) { 124 | } 125 | 126 | AuthorizationInfo authzInfo = oauth2ProxyRealm.doGetAuthorizationInfo(principalCollection); 127 | 128 | assertNotNull(authzInfo); 129 | assertEquals(ImmutableSet.of("administrators@idm.example.com", "devs@idm.example.com"), authzInfo.getRoles()); 130 | } 131 | 132 | @Test 133 | void testDoGetAuthenticationInfo() { 134 | when(dataSession.access(OAuth2ProxyLoginRecordDAO.class)).thenReturn(loginRecordDAO); 135 | testProgrammaticAccess(); 136 | 137 | testInteractiveAccessExistingUser(); 138 | 139 | testInteractiveAccessNewUser(); 140 | } 141 | 142 | /* 143 | * Make sure there was a call to user creation in case no user for the provided 144 | * proxy headers exists yet. 145 | */ 146 | private void testInteractiveAccessNewUser() { 147 | OAuth2ProxyUserManager userManager = Mockito.mock(OAuth2ProxyUserManager.class); 148 | try { 149 | Mockito.when(userManager.getUser(anyString())).thenThrow(new UserNotFoundException("test.user")); 150 | } catch (UserNotFoundException e) { 151 | } 152 | oauth2ProxyRealm = getTestRealm(userManager, null); 153 | 154 | OAuth2ProxyHeaderAuthToken token = createTestOAuth2ProxyHeaderAuthToken(); 155 | 156 | AuthenticationInfo authInfo = oauth2ProxyRealm.doGetAuthenticationInfo(token); 157 | 158 | ArgumentCaptor userCaptor = ArgumentCaptor.forClass(User.class); 159 | verify(userManager).addUser(userCaptor.capture(), anyString()); 160 | User capturedUser = userCaptor.getValue(); 161 | assertEquals("test.user", capturedUser.getUserId()); 162 | assertEquals("Test", capturedUser.getFirstName()); 163 | assertEquals("User", capturedUser.getLastName()); 164 | assertEquals(OAuth2ProxyUserManager.SOURCE, capturedUser.getSource()); 165 | assertNotNull(authInfo); 166 | assertEquals(authInfo.getPrincipals().getPrimaryPrincipal(), "test.user"); 167 | } 168 | 169 | private void testInteractiveAccessExistingUser() { 170 | OAuth2ProxyUserManager userManager = Mockito.mock(OAuth2ProxyUserManager.class); 171 | oauth2ProxyRealm = getTestRealm(userManager, null); 172 | 173 | OAuth2ProxyHeaderAuthToken token = createTestOAuth2ProxyHeaderAuthToken(); 174 | 175 | User existingUser = OAuth2ProxyUserManager.createUserObject("test.user", "test.user@example.com"); 176 | 177 | try { 178 | Mockito.when(userManager.getUser("test.user")).thenReturn(existingUser); 179 | } catch (UserNotFoundException e) { 180 | } 181 | AuthenticationInfo authInfo = oauth2ProxyRealm.doGetAuthenticationInfo(token); 182 | assertNotNull(authInfo); 183 | assertEquals(authInfo.getPrincipals().getPrimaryPrincipal(), "test.user"); 184 | } 185 | 186 | private OAuth2ProxyHeaderAuthToken createTestOAuth2ProxyHeaderAuthToken() { 187 | OAuth2ProxyHeaderAuthToken token = new OAuth2ProxyHeaderAuthToken(); 188 | String testHost = "127.0.0.1"; 189 | token.user = new HttpHeaderAuthenticationToken(OAuth2ProxyHeaderAuthTokenFactory.X_FORWARDED_USER, 190 | UUID.randomUUID().toString(), testHost); 191 | token.preferred_username = new HttpHeaderAuthenticationToken( 192 | OAuth2ProxyHeaderAuthTokenFactory.X_FORWARDED_PREFERRED_USERNAME, "test.user", testHost); 193 | token.email = new HttpHeaderAuthenticationToken(OAuth2ProxyHeaderAuthTokenFactory.X_FORWARDED_EMAIL, 194 | "test.user@example.com", testHost); 195 | token.groups = new HttpHeaderAuthenticationToken(OAuth2ProxyHeaderAuthTokenFactory.X_FORWARDED_GROUPS, 196 | "administrators@idm.example.com,devs@idm.example.com", testHost); 197 | return token; 198 | } 199 | 200 | private void testProgrammaticAccess() { 201 | OAuth2ProxyUserManager userManager = Mockito.mock(OAuth2ProxyUserManager.class); 202 | oauth2ProxyRealm = getTestRealm(userManager, null); 203 | 204 | String hashedPassword = "$shiro1$SHA-512$1024$tz9GiwuH8w6FVj0kz+tEEQ==$DocY8XBn+cySKW6u3ZXy6fKnjpYJpFoTeqe9W8VFYmzdR0y6oFZu40faVDe6Clnb+vrpElRQhXDoVmnESLNa2A=="; 205 | Mockito.when(userManager.getApiToken(anyString())).thenReturn(Optional.of(hashedPassword)); 206 | 207 | UsernamePasswordToken upToken = new UsernamePasswordToken("test.user", "secret123"); 208 | AuthenticationInfo authInfo = oauth2ProxyRealm.doGetAuthenticationInfo(upToken); 209 | String primaryPrincipal = (String) authInfo.getPrincipals().getPrimaryPrincipal(); 210 | assertNotNull(authInfo); 211 | assertEquals("test.user", primaryPrincipal); 212 | upToken = new UsernamePasswordToken("test.user", "foobar123"); 213 | assertNull(oauth2ProxyRealm.doGetAuthenticationInfo(upToken)); 214 | } 215 | 216 | @Test 217 | void testGenerateSecureRandomString() { 218 | final String allowedCharacters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; 219 | String first = OAuth2ProxyRealm.generateSecureRandomString(32); 220 | assertNotNull(first); 221 | assertTrue(first.chars().allMatch(c -> allowedCharacters.indexOf(c) != -1)); 222 | String second = OAuth2ProxyRealm.generateSecureRandomString(32); 223 | assertNotEquals(first, second); 224 | assertTrue(second.chars().allMatch(c -> allowedCharacters.indexOf(c) != -1)); 225 | } 226 | 227 | @Test 228 | void testSupports() { 229 | 230 | BearerToken wrongToken = new BearerToken("foobar"); 231 | assertFalse(oauth2ProxyRealm.supports(wrongToken)); 232 | 233 | UsernamePasswordToken usernamePasswordToken = new UsernamePasswordToken("foo", "bar"); 234 | assertTrue(oauth2ProxyRealm.supports(usernamePasswordToken)); 235 | 236 | OAuth2ProxyHeaderAuthToken proxyHeaderToken = new OAuth2ProxyHeaderAuthToken(); 237 | assertTrue(oauth2ProxyRealm.supports(proxyHeaderToken)); 238 | } 239 | 240 | @Test 241 | void testDoCredentialsMatch() { 242 | // this realm does not handle anything credential-related 243 | // (delegated to oauth2 proxy) 244 | assertTrue(oauth2ProxyRealm.getCredentialsMatcher().doCredentialsMatch(null, null)); 245 | } 246 | 247 | @Test 248 | void testRecordLogin() { 249 | when(dataSession.access(OAuth2ProxyLoginRecordDAO.class)).thenReturn(loginRecordDAO); 250 | oauth2ProxyRealm.recordLogin("foo123"); 251 | 252 | verify(loginRecordDAO).create(recordCaptor.capture()); 253 | assertEquals("foo123", recordCaptor.getValue().getId()); 254 | assertNotNull(recordCaptor.getValue().getLastLogin()); 255 | 256 | // Simulate a failed save 257 | doThrow(new RuntimeException("DB Error")).when(loginRecordDAO).create(any(OAuth2ProxyLoginRecord.class)); 258 | assertThrows(RuntimeException.class, () -> oauth2ProxyRealm.recordLogin("bar123")); 259 | } 260 | 261 | @Test 262 | void testUserWithPrincipals() { 263 | UserWithPrincipals newUser = new UserWithPrincipals(); 264 | assertFalse(newUser.hasPrincipals()); 265 | 266 | newUser.addPrincipal("test.user", "TestAuthRealm"); 267 | assertTrue(newUser.hasPrincipals()); 268 | } 269 | 270 | @Test 271 | void testSyncExternalRolesForGroups() throws UserNotFoundException { 272 | OAuth2ProxyRoleStore roleStore = Mockito.mock(OAuth2ProxyRoleStore.class); 273 | OAuth2ProxyUserManager userManager = Mockito.mock(OAuth2ProxyUserManager.class); 274 | oauth2ProxyRealm = getTestRealm(userManager, roleStore); 275 | 276 | User user = OAuth2ProxyUserManager.createUserObject("test.user", "test.user@example.com"); 277 | 278 | // user had another idp group before 279 | user.addRole(new RoleIdentifier(OAuth2ProxyUserManager.SOURCE, "other@idm.example.com")); 280 | // and was assigned a group from default source 281 | user.addRole(new RoleIdentifier(UserManager.DEFAULT_SOURCE, "nx-big-boss")); 282 | 283 | String groups = "administrators@idm.example.com,devs@idm.example.com"; 284 | 285 | oauth2ProxyRealm.syncExternalRolesForGroups(user, groups); 286 | 287 | ArgumentCaptor userCaptor = ArgumentCaptor.forClass(User.class); 288 | verify(userManager).updateUserGroups(userCaptor.capture()); 289 | user = userCaptor.getValue(); 290 | 291 | @SuppressWarnings("unchecked") 292 | ArgumentCaptor> roleCaptor = ArgumentCaptor.forClass(Set.class); 293 | verify(roleStore).addRolesIfMissing(roleCaptor.capture()); 294 | Set capturedRoles = roleCaptor.getValue(); 295 | Set testGroups = Stream.of(groups.split(",")) 296 | .map(group -> new RoleIdentifier(OAuth2ProxyUserManager.SOURCE, group)).collect(Collectors.toSet()); 297 | assertEquals(testGroups, capturedRoles); 298 | 299 | assertTrue( 300 | user.getRoles().stream().anyMatch(role -> role.getRoleId().equals("administrators@idm.example.com"))); 301 | assertTrue(user.getRoles().stream().anyMatch(role -> role.getRoleId().equals("devs@idm.example.com"))); 302 | assertTrue(user.getRoles().stream().anyMatch(role -> role.getRoleId().equals("nx-big-boss")), 303 | "expected group sync to leave non-idp groups untouched"); 304 | assertFalse(user.getRoles().stream().anyMatch(role -> role.getRoleId().equals("other@idm.example.com"))); 305 | } 306 | 307 | private OAuth2ProxyRealm getTestRealm(OAuth2ProxyUserManager userManager, OAuth2ProxyRoleStore roleStore) { 308 | PasswordService passwordService = new DefaultSecurityPasswordService(Mockito.mock(PasswordService.class)); 309 | 310 | if (userManager == null) { 311 | userManager = Mockito.mock(OAuth2ProxyUserManager.class); 312 | } 313 | if (roleStore == null) { 314 | roleStore = Mockito.mock(OAuth2ProxyRoleStore.class); 315 | } 316 | OAuth2ProxyRealm realm = new OAuth2ProxyRealm(userManager, roleStore, loginRecordStore, passwordService, 317 | Mockito.mock(EventManager.class), Mockito.mock(OAuth2ProxyLogoutHandler.class)); 318 | return realm; 319 | } 320 | } 321 | -------------------------------------------------------------------------------- /src/test/java/com/github/tumbl3w33d/users/OAuth2ProxyUserManagerTest.java: -------------------------------------------------------------------------------- 1 | package com.github.tumbl3w33d.users; 2 | 3 | import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; 4 | import static org.junit.jupiter.api.Assertions.assertEquals; 5 | import static org.junit.jupiter.api.Assertions.assertNotNull; 6 | import static org.junit.jupiter.api.Assertions.assertThrows; 7 | import static org.junit.jupiter.api.Assertions.assertTrue; 8 | import static org.mockito.ArgumentMatchers.any; 9 | import static org.mockito.ArgumentMatchers.anyString; 10 | import static org.mockito.Mockito.verify; 11 | 12 | import java.util.Optional; 13 | import java.util.Set; 14 | 15 | import org.junit.jupiter.api.Test; 16 | import org.mockito.ArgumentCaptor; 17 | import org.mockito.Mockito; 18 | import org.sonatype.nexus.security.user.User; 19 | import org.sonatype.nexus.security.user.UserNotFoundException; 20 | import org.sonatype.nexus.security.user.UserSearchCriteria; 21 | import org.sonatype.nexus.security.user.UserStatus; 22 | 23 | import com.github.tumbl3w33d.h2.OAuth2ProxyTokenInfoStore; 24 | import com.github.tumbl3w33d.h2.OAuth2ProxyUserStore; 25 | import com.github.tumbl3w33d.users.db.OAuth2ProxyUser; 26 | import com.google.common.collect.ImmutableSet; 27 | 28 | public class OAuth2ProxyUserManagerTest { 29 | 30 | private OAuth2ProxyUserManager userManager; 31 | 32 | @Test 33 | void testAddUser() { 34 | OAuth2ProxyUserStore userStore = Mockito.mock(OAuth2ProxyUserStore.class); 35 | User exampleUser = OAuth2ProxyUserManager.createUserObject("test.user", 36 | "test.user@example.com"); 37 | 38 | userManager = getTestUserManager(userStore); 39 | userManager.addUser(exampleUser, "secret123"); 40 | 41 | ArgumentCaptor userCaptor = ArgumentCaptor.forClass(OAuth2ProxyUser.class); 42 | verify(userStore).addUser(userCaptor.capture()); 43 | 44 | OAuth2ProxyUser capturedUser = userCaptor.getValue(); 45 | 46 | assertEquals("secret123", capturedUser.getApiToken()); 47 | } 48 | 49 | @Test 50 | void testChangePassword() throws UserNotFoundException { 51 | OAuth2ProxyUserStore userStore = Mockito.mock(OAuth2ProxyUserStore.class); 52 | 53 | User exampleUser = OAuth2ProxyUserManager.createUserObject("test.user", 54 | "test.user@example.com"); 55 | Mockito.when(userStore.getUser(any())).thenReturn(Optional.of(exampleUser)); 56 | userManager = getTestUserManager(userStore); 57 | userManager.changePassword(exampleUser.getUserId(), "secret123"); 58 | 59 | ArgumentCaptor userIdCaptor = ArgumentCaptor.forClass(String.class); 60 | ArgumentCaptor passwordCaptor = ArgumentCaptor.forClass(String.class); 61 | verify(userStore).updateUserApiToken(userIdCaptor.capture(), 62 | passwordCaptor.capture()); 63 | 64 | String capturedUserId = userIdCaptor.getValue(); 65 | String capturedPassword = passwordCaptor.getValue(); 66 | 67 | assertEquals(exampleUser.getUserId(), capturedUserId); 68 | assertEquals("secret123", capturedPassword); 69 | } 70 | 71 | @Test 72 | void testCreateUserObject() { 73 | User user = OAuth2ProxyUserManager.createUserObject("test.user", "test.user@example.com"); 74 | assertNotNull(user); 75 | assertEquals("test.user", user.getUserId()); 76 | assertEquals("Test", user.getFirstName()); 77 | assertEquals("User", user.getLastName()); 78 | assertEquals("test.user@example.com", user.getEmailAddress()); 79 | assertEquals(UserStatus.active, user.getStatus()); 80 | assertEquals(OAuth2ProxyUserManager.SOURCE, user.getSource()); 81 | } 82 | 83 | @Test 84 | void testDeleteUser() { 85 | OAuth2ProxyUserStore userStore = Mockito.mock(OAuth2ProxyUserStore.class); 86 | 87 | User exampleUser = OAuth2ProxyUserManager.createUserObject("test.user", 88 | "test.user@example.com"); 89 | Mockito.when(userStore.getUser(any())).thenReturn(Optional.of(exampleUser)); 90 | userManager = getTestUserManager(userStore); 91 | assertDoesNotThrow(() -> userManager.deleteUser(exampleUser.getUserId())); 92 | 93 | ArgumentCaptor userCaptor = ArgumentCaptor.forClass(OAuth2ProxyUser.class); 94 | verify(userStore).deleteUser(userCaptor.capture()); 95 | 96 | OAuth2ProxyUser capturedUser = userCaptor.getValue(); 97 | 98 | assertEquals(OAuth2ProxyUser.of(exampleUser), capturedUser); 99 | } 100 | 101 | @Test 102 | void testGetApiToken() { 103 | OAuth2ProxyUserStore userStore = Mockito.mock(OAuth2ProxyUserStore.class); 104 | Mockito.when(userStore.getApiToken("test.user")).thenReturn(Optional.of("secret123")); 105 | 106 | userManager = getTestUserManager(userStore); 107 | Optional token = userManager.getApiToken("test.user"); 108 | 109 | ArgumentCaptor principal = ArgumentCaptor.forClass(String.class); 110 | verify(userStore).getApiToken(principal.capture()); 111 | String capturedPrincipal = principal.getValue(); 112 | 113 | assertEquals("test.user", capturedPrincipal); 114 | assertEquals(Optional.of("secret123"), token); 115 | } 116 | 117 | @Test 118 | void testGetUser() throws UserNotFoundException { 119 | OAuth2ProxyUserStore userStore = Mockito.mock(OAuth2ProxyUserStore.class); 120 | User exampleUser = OAuth2ProxyUserManager.createUserObject("test.user", 121 | "test.user@example.com"); 122 | Mockito.when(userStore.getUser(anyString())).thenReturn(Optional.of(exampleUser)); 123 | OAuth2ProxyUserManager userManager = new OAuth2ProxyUserManager(userStore, 124 | Mockito.mock(OAuth2ProxyTokenInfoStore.class)); 125 | 126 | assertDoesNotThrow(() -> { 127 | User user = userManager.getUser(exampleUser.getUserId()); 128 | assertNotNull(user); 129 | assertEquals(exampleUser.getUserId(), user.getUserId()); 130 | }); 131 | } 132 | 133 | @Test 134 | void testGetUserForNonExisting() throws UserNotFoundException { 135 | OAuth2ProxyUserStore userStore = Mockito.mock(OAuth2ProxyUserStore.class); 136 | User exampleUser = OAuth2ProxyUserManager.createUserObject("test.user", 137 | "test.user@example.com"); 138 | Mockito.when(userStore.getUser(anyString())).thenReturn(Optional.empty()); 139 | OAuth2ProxyUserManager userManager = new OAuth2ProxyUserManager(userStore, 140 | Mockito.mock(OAuth2ProxyTokenInfoStore.class)); 141 | 142 | assertThrows(UserNotFoundException.class, 143 | () -> userManager.getUser(exampleUser.getUserId())); 144 | } 145 | 146 | @Test 147 | void testGetUserWithRoleIds() { 148 | OAuth2ProxyUserStore userStore = Mockito.mock(OAuth2ProxyUserStore.class); 149 | User exampleUser = OAuth2ProxyUserManager.createUserObject("test.user", 150 | "test.user@example.com"); 151 | Mockito.when(userStore.getUser(anyString())).thenReturn(Optional.of(exampleUser)); 152 | OAuth2ProxyUserManager userManager = new OAuth2ProxyUserManager(userStore, 153 | Mockito.mock(OAuth2ProxyTokenInfoStore.class)); 154 | 155 | assertDoesNotThrow(() -> { 156 | User user = userManager.getUser(exampleUser.getUserId(), 157 | ImmutableSet.of("foo", "bar")); 158 | assertNotNull(user); 159 | assertEquals(exampleUser.getUserId(), user.getUserId()); 160 | }); 161 | } 162 | 163 | @Test 164 | void testListUserIds() { 165 | OAuth2ProxyUserStore userStore = Mockito.mock(OAuth2ProxyUserStore.class); 166 | User exampleUser1 = OAuth2ProxyUserManager.createUserObject("test.user1", 167 | "test.user1@example.com"); 168 | User exampleUser2 = OAuth2ProxyUserManager.createUserObject("test.user2", 169 | "test.user2@example.com"); 170 | ImmutableSet testUsers = ImmutableSet.of(exampleUser1, exampleUser2); 171 | Mockito.when(userStore.getAllUsers()).thenReturn(testUsers); 172 | OAuth2ProxyUserManager userManager = new OAuth2ProxyUserManager(userStore, 173 | Mockito.mock(OAuth2ProxyTokenInfoStore.class)); 174 | 175 | assertEquals(ImmutableSet.of("test.user1", "test.user2"), 176 | userManager.listUserIds()); 177 | } 178 | 179 | @Test 180 | void testListUsers() { 181 | OAuth2ProxyUserStore userStore = Mockito.mock(OAuth2ProxyUserStore.class); 182 | User exampleUser1 = OAuth2ProxyUserManager.createUserObject("test.user1", 183 | "test.user1@example.com"); 184 | User exampleUser2 = OAuth2ProxyUserManager.createUserObject("test.user2", 185 | "test.user2@example.com"); 186 | ImmutableSet testUsers = ImmutableSet.of(exampleUser1, exampleUser2); 187 | Mockito.when(userStore.getAllUsers()).thenReturn(testUsers); 188 | OAuth2ProxyUserManager userManager = new OAuth2ProxyUserManager(userStore, 189 | Mockito.mock(OAuth2ProxyTokenInfoStore.class)); 190 | 191 | assertEquals(testUsers, userManager.listUsers()); 192 | } 193 | 194 | @Test 195 | void testSearchUsers() { 196 | OAuth2ProxyUserStore userStore = Mockito.mock(OAuth2ProxyUserStore.class); 197 | User exampleUser = OAuth2ProxyUserManager.createUserObject("test.user", 198 | "test.user@example.com"); 199 | Mockito.when(userStore.getAllUsers()).thenReturn(ImmutableSet.of(exampleUser)); 200 | OAuth2ProxyUserManager userManager = new OAuth2ProxyUserManager(userStore, 201 | Mockito.mock(OAuth2ProxyTokenInfoStore.class)); 202 | 203 | Set searchResult = userManager.searchUsers(new UserSearchCriteria("test.user")); 204 | assertTrue(searchResult.size() == 1); 205 | assertTrue(searchResult.stream().anyMatch(user -> user.getUserId().equals("test.user"))); 206 | 207 | searchResult = userManager.searchUsers(new UserSearchCriteria("foo.bar")); 208 | assertTrue(searchResult.isEmpty()); 209 | 210 | UserSearchCriteria criteria = new UserSearchCriteria(); 211 | criteria.setEmail("foo.bar@example.com"); 212 | searchResult = userManager.searchUsers(criteria); 213 | assertTrue(searchResult.isEmpty()); 214 | 215 | Mockito.when(userStore.getAllUsers()).thenReturn(ImmutableSet.of()); 216 | searchResult = userManager.searchUsers(new UserSearchCriteria("test.user")); 217 | assertTrue(searchResult.isEmpty()); 218 | } 219 | 220 | @Test 221 | @SuppressWarnings("deprecation") 222 | void testUpdateUser() { 223 | OAuth2ProxyUserStore userStore = Mockito.mock(OAuth2ProxyUserStore.class); 224 | User exampleUser = OAuth2ProxyUserManager.createUserObject("test.user", 225 | "test.user@example.com"); 226 | 227 | userManager = getTestUserManager(userStore); 228 | assertDoesNotThrow(() -> { 229 | userManager.updateUser(exampleUser); 230 | ArgumentCaptor userCaptor = ArgumentCaptor.forClass(User.class); 231 | verify(userStore).updateUser(userCaptor.capture()); 232 | 233 | User capturedUser = userCaptor.getValue(); 234 | 235 | assertEquals(exampleUser, capturedUser); 236 | }); 237 | } 238 | 239 | @Test 240 | void testSupportsWrite() { 241 | assertTrue(getTestUserManager(null).supportsWrite()); 242 | } 243 | 244 | @Test 245 | void testgetAuthenticationRealmName() { 246 | assertEquals(OAuth2ProxyUserManager.AUTHENTICATING_REALM, 247 | getTestUserManager(null).getAuthenticationRealmName()); 248 | } 249 | 250 | @Test 251 | void testGetSource() { 252 | assertEquals(OAuth2ProxyUserManager.SOURCE, getTestUserManager(null).getSource()); 253 | } 254 | 255 | private OAuth2ProxyUserManager getTestUserManager(OAuth2ProxyUserStore userStore) { 256 | 257 | if (userStore == null) { 258 | userStore = Mockito.mock(OAuth2ProxyUserStore.class); 259 | } 260 | 261 | OAuth2ProxyUserManager userManager = new OAuth2ProxyUserManager(userStore, 262 | Mockito.mock(OAuth2ProxyTokenInfoStore.class)); 263 | return userManager; 264 | } 265 | } 266 | --------------------------------------------------------------------------------