From 5bb8d1924d77c675467694de26697b876d709a17 Mon Sep 17 00:00:00 2001 From: techknowlogick Date: Thu, 22 Feb 2024 19:08:17 -0500 Subject: [PATCH] Support SAML authentication (#25165) Closes https://github.com/go-gitea/gitea/issues/5512 This PR adds basic SAML support - Adds SAML 2.0 as an auth source - Adds SAML configuration documentation - Adds integration test: - Use bare-bones SAML IdP to test protocol flow and test account is linked successfully (only runs on Postgres by default) - Adds documentation for configuring and running SAML integration test locally Future PRs: - Support group mapping - Support auto-registration (account linking) Co-Authored-By: @jackHay22 --------- Co-authored-by: jackHay22 Co-authored-by: Lunny Xiao Co-authored-by: KN4CK3R Co-authored-by: wxiaoguang Co-authored-by: Jason Song Co-authored-by: morphelinho Co-authored-by: Zettat123 Co-authored-by: Yarden Shoham Co-authored-by: 6543 <6543@obermui.de> Co-authored-by: silverwind --- .github/workflows/pull-db-tests.yml | 8 + assets/go-licenses.json | 25 +++ docs/content/usage/authentication.en-us.md | 69 ++++++ go.mod | 5 + go.sum | 12 ++ models/auth/oauth2.go | 20 +- models/auth/source.go | 38 ++++ options/locale/locale_en-US.ini | 14 ++ routers/init.go | 2 + routers/web/admin/auths.go | 84 ++++++++ routers/web/auth/auth.go | 35 ++- routers/web/auth/linkaccount.go | 45 ++-- routers/web/auth/oauth.go | 19 +- routers/web/auth/openid.go | 5 +- routers/web/auth/saml.go | 172 +++++++++++++++ routers/web/web.go | 5 + .../auth/source/saml/assert_interface_test.go | 22 ++ services/auth/source/saml/init.go | 29 +++ services/auth/source/saml/name_id_format.go | 38 ++++ services/auth/source/saml/providers.go | 109 ++++++++++ services/auth/source/saml/source.go | 202 ++++++++++++++++++ .../auth/source/saml/source_authenticate.go | 16 ++ services/auth/source/saml/source_callout.go | 89 ++++++++ services/auth/source/saml/source_metadata.go | 32 +++ services/auth/source/saml/source_register.go | 23 ++ services/externalaccount/link.go | 11 +- services/externalaccount/user.go | 12 +- services/forms/auth_form.go | 15 +- templates/admin/auth/edit.tmpl | 66 ++++++ templates/admin/auth/new.tmpl | 6 + templates/admin/auth/source/saml.tmpl | 62 ++++++ templates/user/auth/signin_inner.tmpl | 17 ++ tests/integration/README.md | 17 ++ tests/integration/saml_test.go | 150 +++++++++++++ web_src/js/features/admin/common.js | 8 +- web_src/js/features/user-auth.js | 21 ++ web_src/js/index.js | 6 +- 37 files changed, 1440 insertions(+), 69 deletions(-) create mode 100644 routers/web/auth/saml.go create mode 100644 services/auth/source/saml/assert_interface_test.go create mode 100644 services/auth/source/saml/init.go create mode 100644 services/auth/source/saml/name_id_format.go create mode 100644 services/auth/source/saml/providers.go create mode 100644 services/auth/source/saml/source.go create mode 100644 services/auth/source/saml/source_authenticate.go create mode 100644 services/auth/source/saml/source_callout.go create mode 100644 services/auth/source/saml/source_metadata.go create mode 100644 services/auth/source/saml/source_register.go create mode 100644 templates/admin/auth/source/saml.tmpl create mode 100644 tests/integration/saml_test.go diff --git a/.github/workflows/pull-db-tests.yml b/.github/workflows/pull-db-tests.yml index a3886bf6180..8843c6d65e6 100644 --- a/.github/workflows/pull-db-tests.yml +++ b/.github/workflows/pull-db-tests.yml @@ -37,6 +37,14 @@ jobs: MINIO_ROOT_PASSWORD: 12345678 ports: - "9000:9000" + simplesaml: + image: allspice/simple-saml + ports: + - "8080:8080" + env: + SIMPLESAMLPHP_SP_ENTITY_ID: http://localhost:3002/user/saml/test-sp/metadata + SIMPLESAMLPHP_SP_ASSERTION_CONSUMER_SERVICE: http://localhost:3002/user/saml/test-sp/acs + SIMPLESAMLPHP_SP_SINGLE_LOGOUT_SERVICE: http://localhost:3002/user/saml/test-sp/acs steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 diff --git a/assets/go-licenses.json b/assets/go-licenses.json index 2aa60780c48..ed722b01920 100644 --- a/assets/go-licenses.json +++ b/assets/go-licenses.json @@ -124,6 +124,11 @@ "path": "github.com/aymerick/douceur/LICENSE", "licenseText": "The MIT License (MIT)\n\nCopyright (c) 2015 Aymerick JEHANNE\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n\n" }, + { + "name": "github.com/beevik/etree", + "path": "github.com/beevik/etree/LICENSE", + "licenseText": "Copyright 2015-2019 Brett Vickers. All rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions\nare met:\n\n 1. Redistributions of source code must retain the above copyright\n notice, this list of conditions and the following disclaimer.\n\n 2. Redistributions in binary form must reproduce the above copyright\n notice, this list of conditions and the following disclaimer in the\n documentation and/or other materials provided with the distribution.\n\nTHIS SOFTWARE IS PROVIDED BY COPYRIGHT HOLDER ``AS IS'' AND ANY\nEXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\nIMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR\nPURPOSE ARE DISCLAIMED. IN NO EVENT SHALL COPYRIGHT HOLDER OR\nCONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,\nEXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,\nPROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR\nPROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY\nOF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n" + }, { "name": "github.com/beorn7/perks/quantile", "path": "github.com/beorn7/perks/quantile/LICENSE", @@ -639,6 +644,11 @@ "path": "github.com/jhillyerd/enmime/LICENSE", "licenseText": "The MIT License (MIT)\n\nCopyright (c) 2012-2016 James Hillyerd, All Rights Reserved\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of\nthis software and associated documentation files (the \"Software\"), to deal in\nthe Software without restriction, including without limitation the rights to\nuse, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software is furnished to do so,\nsubject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS\nFOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR\nCOPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER\nIN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN\nCONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n" }, + { + "name": "github.com/jonboulle/clockwork", + "path": "github.com/jonboulle/clockwork/LICENSE", + "licenseText": "Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\n TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n 1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n 2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n 3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n 4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n 5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n 6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n 7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n 8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n 9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n\n END OF TERMS AND CONDITIONS\n\n APPENDIX: How to apply the Apache License to your work.\n\n To apply the Apache License to your work, attach the following\n boilerplate notice, with the fields enclosed by brackets \"{}\"\n replaced with your own identifying information. (Don't include\n the brackets!) The text should be enclosed in the appropriate\n comment syntax for the file format. We also recommend that a\n file or class name and description of purpose be included on the\n same \"printed page\" as the copyright notice for easier\n identification within third-party archives.\n\n Copyright {yyyy} {name of copyright owner}\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n" + }, { "name": "github.com/josharian/intern", "path": "github.com/josharian/intern/license.md", @@ -719,6 +729,11 @@ "path": "github.com/markbates/goth/LICENSE.txt", "licenseText": "Copyright (c) 2014 Mark Bates\n\nMIT License\n\nPermission is hereby granted, free of charge, to any person obtaining\na copy of this software and associated documentation files (the\n\"Software\"), to deal in the Software without restriction, including\nwithout limitation the rights to use, copy, modify, merge, publish,\ndistribute, sublicense, and/or sell copies of the Software, and to\npermit persons to whom the Software is furnished to do so, subject to\nthe following conditions:\n\nThe above copyright notice and this permission notice shall be\nincluded in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND,\nEXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF\nMERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND\nNONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE\nLIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION\nWITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n" }, + { + "name": "github.com/mattermost/xml-roundtrip-validator", + "path": "github.com/mattermost/xml-roundtrip-validator/LICENSE.txt", + "licenseText": " Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\n TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n 1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n 2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n 3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n 4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n 5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n 6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n 7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n 8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n 9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n\n END OF TERMS AND CONDITIONS\n\n APPENDIX: How to apply the Apache License to your work.\n\n To apply the Apache License to your work, attach the following\n boilerplate notice, with the fields enclosed by brackets \"[]\"\n replaced with your own identifying information. (Don't include\n the brackets!) The text should be enclosed in the appropriate\n comment syntax for the file format. We also recommend that a\n file or class name and description of purpose be included on the\n same \"printed page\" as the copyright notice for easier\n identification within third-party archives.\n\n Copyright [yyyy] [name of copyright owner]\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n" + }, { "name": "github.com/mattn/go-colorable", "path": "github.com/mattn/go-colorable/LICENSE", @@ -904,6 +919,16 @@ "path": "github.com/rs/xid/LICENSE", "licenseText": "Copyright (c) 2015 Olivier Poitrey \u003crs@dailymotion.com\u003e\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is furnished\nto do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n" }, + { + "name": "github.com/russellhaering/gosaml2", + "path": "github.com/russellhaering/gosaml2/LICENSE", + "licenseText": "\n Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\n TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n 1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n 2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n 3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n 4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n 5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n 6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n 7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n 8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n 9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n" + }, + { + "name": "github.com/russellhaering/goxmldsig", + "path": "github.com/russellhaering/goxmldsig/LICENSE", + "licenseText": "\n Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\n TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n 1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n 2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n 3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n 4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n 5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n 6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n 7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n 8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n 9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n" + }, { "name": "github.com/russross/blackfriday/v2", "path": "github.com/russross/blackfriday/v2/LICENSE.txt", diff --git a/docs/content/usage/authentication.en-us.md b/docs/content/usage/authentication.en-us.md index adc936dfbec..1838cfcc776 100644 --- a/docs/content/usage/authentication.en-us.md +++ b/docs/content/usage/authentication.en-us.md @@ -349,3 +349,72 @@ If set `ENABLE_REVERSE_PROXY_FULL_NAME=true`, a user full name expected in `X-WE You can also limit the reverse proxy's IP address range with `REVERSE_PROXY_TRUSTED_PROXIES` which default value is `127.0.0.0/8,::1/128`. By `REVERSE_PROXY_LIMIT`, you can limit trusted proxies level. Notice: Reverse Proxy Auth doesn't support the API. You still need an access token or basic auth to make API requests. + +## SAML + +### Configuring Gitea as a SAML 2.0 Service Provider + +- Navigate to `Site Administration > Identity & Access > Authentication Sources`. +- Click the `Add Authentication Source` button. +- Select `SAML` as the authentication type. + +#### Features Not Yet Supported + +Currently, auto-registration is not supported for SAML. During the external account linking process the user will be prompted to set a username and email address or link to an existing account. + +SAML group mapping is not supported. + +#### Settings + +- `Authentication Name` **(required)** + + - The name of this authentication source (appears in the Gitea ACS and metadata URLs) + +- `SAML NameID Format` **(required)** + + - This specifies how Identity Provider (IdP) users are mapped to Gitea users. This option will be provider specific. + +- `Icon URL` (optional) + + - URL of an icon to display on the Sign-In page for this authentication source. + +- `[Insecure] Skip Assertion Signature Validation` (optional) + + - This option is not recommended and disables integrity verification of IdP SAML assertions. + +- `Identity Provider Metadata URL` (optional if XML set) + + - The URL of the IdP metadata endpoint. + - This field must be set if `Identity Provider Metadata XML` is left blank. + +- `Identity Provider Metadata XML` (optional if URL set) + + - The XML returned by the IdP metadata endpoint. + - This field must be set if `Identity Provider Metadata URL` is left blank. + +- `Service Provider Certificate` (optional) + + - X.509-formatted certificate (with `Service Provider Private Key`) used for signing SAML requests. + - A certificate will be generated if this field is left blank. + +- `Service Provider Private Key` (optional) + + - DSA/RSA private key (with `Service Provider Certificate`) used for signing SAML requests. + - A private key will be generated if this field is left blank. + +- `Email Assertion Key` (optional) + + - The SAML assertion key used for the IdP user's email (depends on provider configuration). + +- `Name Assertion Key` (optional) + + - The SAML assertion key used for the IdP user's nickname (depends on provider configuration). + +- `Username Assertion Key` (optional) + + - The SAML assertion key used for the IdP user's username (depends on provider configuration). + +### Configuring a SAML 2.0 Identity Provider to use Gitea + +- The service provider assertion consumer service url will look like: `http(s)://[mydomain]/user/saml/[Authentication Name]/acs`. +- The service provider metadata url will look like: `http(s)://[mydomain]/user/saml/[Authentication Name]/metadata`. diff --git a/go.mod b/go.mod index 7a752ec874c..012a34612f6 100644 --- a/go.mod +++ b/go.mod @@ -91,6 +91,8 @@ require ( github.com/quasoft/websspi v1.1.2 github.com/redis/go-redis/v9 v9.4.0 github.com/robfig/cron/v3 v3.0.1 + github.com/russellhaering/gosaml2 v0.9.1 + github.com/russellhaering/goxmldsig v1.3.0 github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 github.com/sassoftware/go-rpmutils v0.2.1-0.20240124161140-277b154961dd github.com/sergi/go-diff v1.3.1 @@ -143,6 +145,7 @@ require ( github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/aymerick/douceur v0.2.0 // indirect + github.com/beevik/etree v1.1.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bits-and-blooms/bitset v1.13.0 // indirect github.com/blevesearch/bleve_index_api v1.1.5 // indirect @@ -216,6 +219,7 @@ require ( github.com/imdario/mergo v0.3.16 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/jessevdk/go-flags v1.5.0 // indirect + github.com/jonboulle/clockwork v0.3.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/klauspost/pgzip v1.2.6 // indirect @@ -225,6 +229,7 @@ require ( github.com/magiconair/properties v1.8.7 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/markbates/going v1.0.3 // indirect + github.com/mattermost/xml-roundtrip-validator v0.1.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-runewidth v0.0.15 // indirect github.com/mholt/acmez v1.2.0 // indirect diff --git a/go.sum b/go.sum index b3b8ad8ce48..393e10cfa01 100644 --- a/go.sum +++ b/go.sum @@ -130,6 +130,8 @@ github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3d github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/beevik/etree v1.1.0 h1:T0xke/WvNtMoCqgzPhkX2r4rjY3GDZFi+FjpRZY2Jbs= +github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bits-and-blooms/bitset v1.1.10/go.mod h1:w0XsmFg8qg6cmpTtJ0z3pKgjTDBMMnI/+I2syrE6XBE= @@ -566,6 +568,9 @@ github.com/jhillyerd/enmime v1.1.0 h1:ubaIzg68VY7CMCe2YbHe6nkRvU9vujixTkNz3EBvZO github.com/jhillyerd/enmime v1.1.0/go.mod h1:FRFuUPCLh8PByQv+8xRcLO9QHqaqTqreYhopv5eyk4I= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= +github.com/jonboulle/clockwork v0.3.0 h1:9BSCMi8C+0qdApAp4auwX0RkLGUjs956h0EkuQymUhg= +github.com/jonboulle/clockwork v0.3.0/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= @@ -634,6 +639,8 @@ github.com/markbates/going v1.0.3 h1:mY45T5TvW+Xz5A6jY7lf4+NLg9D8+iuStIHyR7M8qsE github.com/markbates/going v1.0.3/go.mod h1:fQiT6v6yQar9UD6bd/D4Z5Afbk9J6BBVBtLiyY4gp2o= github.com/markbates/goth v1.78.0 h1:7VEIFDycJp9deyVv3YraGBPdD0ZYQW93Y3Aw1eVP3BY= github.com/markbates/goth v1.78.0/go.mod h1:X6xdNgpapSENS0O35iTBBcMHoJDQDfI9bJl+APCkYMc= +github.com/mattermost/xml-roundtrip-validator v0.1.0 h1:RXbVD2UAl7A7nOTR4u7E3ILa4IbtvKBHw64LDsmu9hU= +github.com/mattermost/xml-roundtrip-validator v0.1.0/go.mod h1:qccnGMcpgwcNaBnxqpJpWWUiPNr5H3O8eDgGV9gT5To= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= @@ -766,12 +773,17 @@ github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc= github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/russellhaering/gosaml2 v0.9.1 h1:H/whrl8NuSoxyW46Ww5lKPskm+5K+qYLw9afqJ/Zef0= +github.com/russellhaering/gosaml2 v0.9.1/go.mod h1:ja+qgbayxm+0mxBRLMSUuX3COqy+sb0RRhIGun/W2kc= +github.com/russellhaering/goxmldsig v1.3.0 h1:DllIWUgMy0cRUMfGiASiYEa35nsieyD3cigIwLonTPM= +github.com/russellhaering/goxmldsig v1.3.0/go.mod h1:gM4MDENBQf7M+V824SGfyIUVFWydB7n0KkEubVJl+Tw= github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= diff --git a/models/auth/oauth2.go b/models/auth/oauth2.go index 9d53fffc786..a252458d4ee 100644 --- a/models/auth/oauth2.go +++ b/models/auth/oauth2.go @@ -8,6 +8,7 @@ import ( "crypto/sha256" "encoding/base32" "encoding/base64" + "encoding/gob" "fmt" "net" "net/url" @@ -81,6 +82,10 @@ func Init(ctx context.Context) error { builtinAllClientIDs = append(builtinAllClientIDs, clientID) } + // This is needed in order to encode and store the struct in the goth/gothic session + // during the process of linking the external user. + gob.Register(LinkAccountUser{}) + var registeredApps []*OAuth2Application if err := db.GetEngine(ctx).In("client_id", builtinAllClientIDs).Find(®isteredApps); err != nil { return err @@ -605,21 +610,6 @@ func (err ErrOAuthApplicationNotFound) Unwrap() error { return util.ErrNotExist } -// GetActiveOAuth2SourceByName returns a OAuth2 AuthSource based on the given name -func GetActiveOAuth2SourceByName(ctx context.Context, name string) (*Source, error) { - authSource := new(Source) - has, err := db.GetEngine(ctx).Where("name = ? and type = ? and is_active = ?", name, OAuth2, true).Get(authSource) - if err != nil { - return nil, err - } - - if !has { - return nil, fmt.Errorf("oauth2 source not found, name: %q", name) - } - - return authSource, nil -} - func DeleteOAuth2RelictsByUserID(ctx context.Context, userID int64) error { deleteCond := builder.Select("id").From("oauth2_grant").Where(builder.Eq{"oauth2_grant.user_id": userID}) diff --git a/models/auth/source.go b/models/auth/source.go index 1bdde8235cd..bc564d35baf 100644 --- a/models/auth/source.go +++ b/models/auth/source.go @@ -14,6 +14,7 @@ import ( "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/util" + "github.com/markbates/goth" "xorm.io/builder" "xorm.io/xorm" "xorm.io/xorm/convert" @@ -32,6 +33,7 @@ const ( DLDAP // 5 OAuth2 // 6 SSPI // 7 + SAML // 8 ) // String returns the string name of the LoginType @@ -52,6 +54,7 @@ var Names = map[Type]string{ PAM: "PAM", OAuth2: "OAuth2", SSPI: "SPNEGO with SSPI", + SAML: "SAML", } // Config represents login config as far as the db is concerned @@ -121,6 +124,12 @@ type Source struct { UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"` } +// LinkAccountUser is used to link an external user with a local user +type LinkAccountUser struct { + Type Type + GothUser goth.User +} + // TableName xorm will read the table name from this method func (Source) TableName() string { return "login_source" @@ -180,6 +189,11 @@ func (source *Source) IsSSPI() bool { return source.Type == SSPI } +// IsSAML returns true of this source is of the SAML type. +func (source *Source) IsSAML() bool { + return source.Type == SAML +} + // HasTLS returns true of this source supports TLS. func (source *Source) HasTLS() bool { hasTLSer, ok := source.Cfg.(HasTLSer) @@ -392,3 +406,27 @@ func IsErrSourceInUse(err error) bool { func (err ErrSourceInUse) Error() string { return fmt.Sprintf("login source is still used by some users [id: %d]", err.ID) } + +// GetActiveAuthProviderSources returns all activated sources +func GetActiveAuthProviderSources(ctx context.Context, authType Type) ([]*Source, error) { + sources := make([]*Source, 0, 1) + if err := db.GetEngine(ctx).Where("is_active = ? and type = ?", true, authType).Find(&sources); err != nil { + return nil, err + } + return sources, nil +} + +// GetActiveAuthSourceByName returns an AuthSource based on the given name and type +func GetActiveAuthSourceByName(ctx context.Context, name string, authType Type) (*Source, error) { + authSource := new(Source) + has, err := db.GetEngine(ctx).Where("name = ? and type = ? and is_active = ?", name, authType, true).Get(authSource) + if err != nil { + return nil, err + } + + if !has { + return nil, fmt.Errorf("auth source not found, name: %q", name) + } + + return authSource, nil +} diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 574e99e6544..ae34d72e41a 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -522,6 +522,9 @@ Content = Content SSPISeparatorReplacement = Separator SSPIDefaultLanguage = Default Language +SAMLMetadata = Either SAML Identity Provider metadata URL or XML +SAMLMetadataURL = SAML Identity Provider metadata URL is invalid + require_error = ` cannot be empty.` alpha_dash_error = ` should contain only alphanumeric, dash ('-') and underscore ('_') characters.` alpha_dash_dot_error = ` should contain only alphanumeric, dash ('-'), underscore ('_') and dot ('.') characters.` @@ -3026,7 +3029,18 @@ auths.sspi_separator_replacement = Separator to use instead of \, / and @ auths.sspi_separator_replacement_helper = The character to use to replace the separators of down-level logon names (eg. the \ in "DOMAIN\user") and user principal names (eg. the @ in "user@example.org"). auths.sspi_default_language = Default user language auths.sspi_default_language_helper = Default language for users automatically created by SSPI auth method. Leave empty if you prefer language to be automatically detected. +auths.saml_nameidformat = SAML NameID Format +auths.saml_identity_provider_metadata_url = Identity Provider Metadata URL +auths.saml_identity_provider_metadata = Identity Provider Metadata XML +auths.saml_insecure_skip_assertion_signature_validation = [Insecure] Skip Assertion Signature Validation +auths.saml_service_provider_certificate = Service Provider Certificate +auths.saml_service_provider_private_key = Service Provider Private Key +auths.saml_identity_provider_email_assertion_key = Email Assertion Key +auths.saml_identity_provider_name_assertion_key = Name Assertion Key +auths.saml_identity_provider_username_assertion_key = Username Assertion Key +auths.saml_icon_url = Icon URL auths.tips = Tips +auths.tips.saml = Documentation can be found at https://docs.gitea.com/usage/authentication#saml auths.tips.oauth2.general = OAuth2 Authentication auths.tips.oauth2.general.tip = When registering a new OAuth2 authentication, the callback/redirect URL should be: auths.tip.oauth2_provider = OAuth2 Provider diff --git a/routers/init.go b/routers/init.go index e0a7150ba31..9ae8c368a26 100644 --- a/routers/init.go +++ b/routers/init.go @@ -35,6 +35,7 @@ import ( actions_service "code.gitea.io/gitea/services/actions" "code.gitea.io/gitea/services/auth" "code.gitea.io/gitea/services/auth/source/oauth2" + "code.gitea.io/gitea/services/auth/source/saml" "code.gitea.io/gitea/services/automerge" "code.gitea.io/gitea/services/cron" feed_service "code.gitea.io/gitea/services/feed" @@ -138,6 +139,7 @@ func InitWebInstalled(ctx context.Context) { log.Info("ORM engine initialization successful!") mustInit(system.Init) mustInitCtx(ctx, oauth2.Init) + mustInitCtx(ctx, saml.Init) mustInit(release_service.Init) diff --git a/routers/web/admin/auths.go b/routers/web/admin/auths.go index 7fdd18dfae9..187b569d39d 100644 --- a/routers/web/admin/auths.go +++ b/routers/web/admin/auths.go @@ -1,9 +1,12 @@ // Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2024 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package admin import ( + "crypto/tls" + "crypto/x509" "errors" "fmt" "net/http" @@ -25,6 +28,7 @@ import ( "code.gitea.io/gitea/services/auth/source/ldap" "code.gitea.io/gitea/services/auth/source/oauth2" pam_service "code.gitea.io/gitea/services/auth/source/pam" + "code.gitea.io/gitea/services/auth/source/saml" "code.gitea.io/gitea/services/auth/source/smtp" "code.gitea.io/gitea/services/auth/source/sspi" "code.gitea.io/gitea/services/forms" @@ -71,6 +75,7 @@ var ( {auth.SMTP.String(), auth.SMTP}, {auth.OAuth2.String(), auth.OAuth2}, {auth.SSPI.String(), auth.SSPI}, + {auth.SAML.String(), auth.SAML}, } if pam.Supported { items = append(items, dropdownItem{auth.Names[auth.PAM], auth.PAM}) @@ -83,6 +88,16 @@ var ( {ldap.SecurityProtocolNames[ldap.SecurityProtocolLDAPS], ldap.SecurityProtocolLDAPS}, {ldap.SecurityProtocolNames[ldap.SecurityProtocolStartTLS], ldap.SecurityProtocolStartTLS}, } + + nameIDFormats = []dropdownItem{ + {saml.NameIDFormatNames[saml.SAML20Persistent], saml.SAML20Persistent}, // use this as default value + {saml.NameIDFormatNames[saml.SAML11Email], saml.SAML11Email}, + {saml.NameIDFormatNames[saml.SAML11Persistent], saml.SAML11Persistent}, + {saml.NameIDFormatNames[saml.SAML11Unspecified], saml.SAML11Unspecified}, + {saml.NameIDFormatNames[saml.SAML20Email], saml.SAML20Email}, + {saml.NameIDFormatNames[saml.SAML20Transient], saml.SAML20Transient}, + {saml.NameIDFormatNames[saml.SAML20Unspecified], saml.SAML20Unspecified}, + } ) // NewAuthSource render adding a new auth source page @@ -98,6 +113,8 @@ func NewAuthSource(ctx *context.Context) { ctx.Data["is_sync_enabled"] = true ctx.Data["AuthSources"] = authSources ctx.Data["SecurityProtocols"] = securityProtocols + ctx.Data["CurrentNameIDFormat"] = saml.NameIDFormatNames[saml.SAML20Persistent] + ctx.Data["NameIDFormats"] = nameIDFormats ctx.Data["SMTPAuths"] = smtp.Authenticators oauth2providers := oauth2.GetSupportedOAuth2Providers() ctx.Data["OAuth2Providers"] = oauth2providers @@ -231,6 +248,52 @@ func parseSSPIConfig(ctx *context.Context, form forms.AuthenticationForm) (*sspi }, nil } +func parseSAMLConfig(ctx *context.Context, form forms.AuthenticationForm) (*saml.Source, error) { + if util.IsEmptyString(form.IdentityProviderMetadata) && util.IsEmptyString(form.IdentityProviderMetadataURL) { + return nil, fmt.Errorf("%s %s", ctx.Tr("form.SAMLMetadata"), ctx.Tr("form.require_error")) + } + + if !util.IsEmptyString(form.IdentityProviderMetadataURL) { + _, err := url.Parse(form.IdentityProviderMetadataURL) + if err != nil { + return nil, fmt.Errorf("%s", ctx.Tr("form.SAMLMetadataURL")) + } + } + + // check the integrity of the certificate and private key (autogenerated if these form fields are blank) + if !util.IsEmptyString(form.ServiceProviderCertificate) && !util.IsEmptyString(form.ServiceProviderPrivateKey) { + keyPair, err := tls.X509KeyPair([]byte(form.ServiceProviderCertificate), []byte(form.ServiceProviderPrivateKey)) + if err != nil { + return nil, err + } + keyPair.Leaf, err = x509.ParseCertificate(keyPair.Certificate[0]) + if err != nil { + return nil, err + } + } else { + privateKey, cert, err := saml.GenerateSAMLSPKeypair() + if err != nil { + return nil, err + } + + form.ServiceProviderPrivateKey = privateKey + form.ServiceProviderCertificate = cert + } + + return &saml.Source{ + IdentityProviderMetadata: form.IdentityProviderMetadata, + IdentityProviderMetadataURL: form.IdentityProviderMetadataURL, + InsecureSkipAssertionSignatureValidation: form.InsecureSkipAssertionSignatureValidation, + NameIDFormat: saml.NameIDFormat(form.NameIDFormat), + ServiceProviderCertificate: form.ServiceProviderCertificate, + ServiceProviderPrivateKey: form.ServiceProviderPrivateKey, + EmailAssertionKey: form.EmailAssertionKey, + NameAssertionKey: form.NameAssertionKey, + UsernameAssertionKey: form.UsernameAssertionKey, + IconURL: form.SAMLIconURL, + }, nil +} + // NewAuthSourcePost response for adding an auth source func NewAuthSourcePost(ctx *context.Context) { form := *web.GetForm(ctx).(*forms.AuthenticationForm) @@ -244,6 +307,8 @@ func NewAuthSourcePost(ctx *context.Context) { ctx.Data["SMTPAuths"] = smtp.Authenticators oauth2providers := oauth2.GetSupportedOAuth2Providers() ctx.Data["OAuth2Providers"] = oauth2providers + ctx.Data["CurrentNameIDFormat"] = saml.NameIDFormatNames[saml.NameIDFormat(form.NameIDFormat)] + ctx.Data["NameIDFormats"] = nameIDFormats ctx.Data["SSPIAutoCreateUsers"] = true ctx.Data["SSPIAutoActivateUsers"] = true @@ -290,6 +355,13 @@ func NewAuthSourcePost(ctx *context.Context) { ctx.RenderWithErr(ctx.Tr("admin.auths.login_source_of_type_exist"), tplAuthNew, form) return } + case auth.SAML: + var err error + config, err = parseSAMLConfig(ctx, form) + if err != nil { + ctx.RenderWithErr(err.Error(), tplAuthNew, form) + return + } default: ctx.Error(http.StatusBadRequest) return @@ -336,6 +408,7 @@ func EditAuthSource(ctx *context.Context) { ctx.Data["SMTPAuths"] = smtp.Authenticators oauth2providers := oauth2.GetSupportedOAuth2Providers() ctx.Data["OAuth2Providers"] = oauth2providers + ctx.Data["NameIDFormats"] = nameIDFormats source, err := auth.GetSourceByID(ctx, ctx.ParamsInt64(":authid")) if err != nil { @@ -344,6 +417,9 @@ func EditAuthSource(ctx *context.Context) { } ctx.Data["Source"] = source ctx.Data["HasTLS"] = source.HasTLS() + if source.IsSAML() { + ctx.Data["CurrentNameIDFormat"] = saml.NameIDFormatNames[source.Cfg.(*saml.Source).NameIDFormat] + } if source.IsOAuth2() { type Named interface { @@ -378,6 +454,8 @@ func EditAuthSourcePost(ctx *context.Context) { } ctx.Data["Source"] = source ctx.Data["HasTLS"] = source.HasTLS() + ctx.Data["CurrentNameIDFormat"] = saml.NameIDFormatNames[saml.SAML20Persistent] + ctx.Data["NameIDFormats"] = nameIDFormats if ctx.HasError() { ctx.HTML(http.StatusOK, tplAuthEdit) @@ -412,6 +490,12 @@ func EditAuthSourcePost(ctx *context.Context) { ctx.RenderWithErr(err.Error(), tplAuthEdit, form) return } + case auth.SAML: + config, err = parseSAMLConfig(ctx, form) + if err != nil { + ctx.RenderWithErr(err.Error(), tplAuthEdit, form) + return + } default: ctx.Error(http.StatusBadRequest) return diff --git a/routers/web/auth/auth.go b/routers/web/auth/auth.go index 3de1f3373dc..f5955ec5ffb 100644 --- a/routers/web/auth/auth.go +++ b/routers/web/auth/auth.go @@ -28,6 +28,7 @@ import ( "code.gitea.io/gitea/routers/utils" auth_service "code.gitea.io/gitea/services/auth" "code.gitea.io/gitea/services/auth/source/oauth2" + "code.gitea.io/gitea/services/auth/source/saml" "code.gitea.io/gitea/services/externalaccount" "code.gitea.io/gitea/services/forms" "code.gitea.io/gitea/services/mailer" @@ -170,6 +171,14 @@ func SignIn(ctx *context.Context) { return } ctx.Data["OAuth2Providers"] = oauth2Providers + + samlProviders, err := saml.GetSAMLProviders(ctx, util.OptionalBoolTrue) + if err != nil { + ctx.ServerError("UserSignIn", err) + return + } + ctx.Data["SAMLProviders"] = samlProviders + ctx.Data["Title"] = ctx.Tr("sign_in") ctx.Data["SignInLink"] = setting.AppSubURL + "/user/login" ctx.Data["PageIsSignIn"] = true @@ -193,6 +202,14 @@ func SignInPost(ctx *context.Context) { return } ctx.Data["OAuth2Providers"] = oauth2Providers + + samlProviders, err := saml.GetSAMLProviders(ctx, util.OptionalBoolTrue) + if err != nil { + ctx.ServerError("UserSignIn", err) + return + } + ctx.Data["SAMLProviders"] = samlProviders + ctx.Data["Title"] = ctx.Tr("sign_in") ctx.Data["SignInLink"] = setting.AppSubURL + "/user/login" ctx.Data["PageIsSignIn"] = true @@ -504,7 +521,7 @@ func SignUpPost(ctx *context.Context) { Passwd: form.Password, } - if !createAndHandleCreatedUser(ctx, tplSignUp, form, u, nil, nil, false) { + if !createAndHandleCreatedUser(ctx, tplSignUp, form, u, nil, nil, false, auth.NoType) { // error already handled return } @@ -515,16 +532,16 @@ func SignUpPost(ctx *context.Context) { // createAndHandleCreatedUser calls createUserInContext and // then handleUserCreated. -func createAndHandleCreatedUser(ctx *context.Context, tpl base.TplName, form any, u *user_model.User, overwrites *user_model.CreateUserOverwriteOptions, gothUser *goth.User, allowLink bool) bool { - if !createUserInContext(ctx, tpl, form, u, overwrites, gothUser, allowLink) { +func createAndHandleCreatedUser(ctx *context.Context, tpl base.TplName, form any, u *user_model.User, overwrites *user_model.CreateUserOverwriteOptions, gothUser *goth.User, allowLink bool, authType auth.Type) bool { + if !createUserInContext(ctx, tpl, form, u, overwrites, gothUser, allowLink, authType) { return false } - return handleUserCreated(ctx, u, gothUser) + return handleUserCreated(ctx, u, gothUser, authType) } // createUserInContext creates a user and handles errors within a given context. // Optionally a template can be specified. -func createUserInContext(ctx *context.Context, tpl base.TplName, form any, u *user_model.User, overwrites *user_model.CreateUserOverwriteOptions, gothUser *goth.User, allowLink bool) (ok bool) { +func createUserInContext(ctx *context.Context, tpl base.TplName, form any, u *user_model.User, overwrites *user_model.CreateUserOverwriteOptions, gothUser *goth.User, allowLink bool, authType auth.Type) (ok bool) { if err := user_model.CreateUser(ctx, u, overwrites); err != nil { if allowLink && (user_model.IsErrUserAlreadyExist(err) || user_model.IsErrEmailAlreadyUsed(err)) { if setting.OAuth2Client.AccountLinking == setting.OAuth2AccountLinkingAuto { @@ -541,10 +558,10 @@ func createUserInContext(ctx *context.Context, tpl base.TplName, form any, u *us } // TODO: probably we should respect 'remember' user's choice... - linkAccount(ctx, user, *gothUser, true) + linkAccount(ctx, user, *gothUser, true, authType) return false // user is already created here, all redirects are handled } else if setting.OAuth2Client.AccountLinking == setting.OAuth2AccountLinkingLogin { - showLinkingLogin(ctx, *gothUser) + showLinkingLogin(ctx, *gothUser, authType) return false // user will be created only after linking login } } @@ -590,7 +607,7 @@ func createUserInContext(ctx *context.Context, tpl base.TplName, form any, u *us // handleUserCreated does additional steps after a new user is created. // It auto-sets admin for the only user, updates the optional external user and // sends a confirmation email if required. -func handleUserCreated(ctx *context.Context, u *user_model.User, gothUser *goth.User) (ok bool) { +func handleUserCreated(ctx *context.Context, u *user_model.User, gothUser *goth.User, authType auth.Type) (ok bool) { // Auto-set admin for the only user. if user_model.CountUsers(ctx, nil) == 1 { opts := &user_service.UpdateOptions{ @@ -606,7 +623,7 @@ func handleUserCreated(ctx *context.Context, u *user_model.User, gothUser *goth. // update external user information if gothUser != nil { - if err := externalaccount.UpdateExternalUser(ctx, u, *gothUser); err != nil { + if err := externalaccount.UpdateExternalUser(ctx, u, *gothUser, authType); err != nil { if !errors.Is(err, util.ErrNotExist) { log.Error("UpdateExternalUser failed: %v", err) } diff --git a/routers/web/auth/linkaccount.go b/routers/web/auth/linkaccount.go index 1d94e52fe3e..c62ae840831 100644 --- a/routers/web/auth/linkaccount.go +++ b/routers/web/auth/linkaccount.go @@ -48,13 +48,13 @@ func LinkAccount(ctx *context.Context) { ctx.Data["SignInLink"] = setting.AppSubURL + "/user/link_account_signin" ctx.Data["SignUpLink"] = setting.AppSubURL + "/user/link_account_signup" - gothUser := ctx.Session.Get("linkAccountGothUser") - if gothUser == nil { + externalLinkUser := ctx.Session.Get("linkAccountUser") + if externalLinkUser == nil { ctx.ServerError("UserSignIn", errors.New("not in LinkAccount session")) return } - gu, _ := gothUser.(goth.User) + gu := externalLinkUser.(auth.LinkAccountUser).GothUser uname, err := getUserName(&gu) if err != nil { ctx.ServerError("UserSignIn", err) @@ -135,12 +135,14 @@ func LinkAccountPostSignIn(ctx *context.Context) { ctx.Data["SignInLink"] = setting.AppSubURL + "/user/link_account_signin" ctx.Data["SignUpLink"] = setting.AppSubURL + "/user/link_account_signup" - gothUser := ctx.Session.Get("linkAccountGothUser") - if gothUser == nil { + externalLinkUserInterface := ctx.Session.Get("linkAccountUser") + if externalLinkUserInterface == nil { ctx.ServerError("UserSignIn", errors.New("not in LinkAccount session")) return } + externalLinkUser := externalLinkUserInterface.(auth.LinkAccountUser) + if ctx.HasError() { ctx.HTML(http.StatusOK, tplLinkAccount) return @@ -152,10 +154,10 @@ func LinkAccountPostSignIn(ctx *context.Context) { return } - linkAccount(ctx, u, gothUser.(goth.User), signInForm.Remember) + linkAccount(ctx, u, externalLinkUser.GothUser, signInForm.Remember, externalLinkUser.Type) } -func linkAccount(ctx *context.Context, u *user_model.User, gothUser goth.User, remember bool) { +func linkAccount(ctx *context.Context, u *user_model.User, gothUser goth.User, remember bool, authType auth.Type) { updateAvatarIfNeed(ctx, gothUser.AvatarURL, u) // If this user is enrolled in 2FA, we can't sign the user in just yet. @@ -168,7 +170,7 @@ func linkAccount(ctx *context.Context, u *user_model.User, gothUser goth.User, r return } - err = externalaccount.LinkAccountToUser(ctx, u, gothUser) + err = externalaccount.LinkAccountToUser(ctx, u, gothUser, authType) if err != nil { ctx.ServerError("UserLinkAccount", err) return @@ -222,14 +224,14 @@ func LinkAccountPostRegister(ctx *context.Context) { ctx.Data["SignInLink"] = setting.AppSubURL + "/user/link_account_signin" ctx.Data["SignUpLink"] = setting.AppSubURL + "/user/link_account_signup" - gothUserInterface := ctx.Session.Get("linkAccountGothUser") - if gothUserInterface == nil { + externalLinkUser := ctx.Session.Get("linkAccountUser") + if externalLinkUser == nil { ctx.ServerError("UserSignUp", errors.New("not in LinkAccount session")) return } - gothUser, ok := gothUserInterface.(goth.User) + linkUser, ok := externalLinkUser.(auth.LinkAccountUser) if !ok { - ctx.ServerError("UserSignUp", fmt.Errorf("session linkAccountGothUser type is %t but not goth.User", gothUserInterface)) + ctx.ServerError("UserSignUp", fmt.Errorf("session linkAccountUser type is %t but not goth.User", externalLinkUser)) return } @@ -275,7 +277,7 @@ func LinkAccountPostRegister(ctx *context.Context) { } } - authSource, err := auth.GetActiveOAuth2SourceByName(ctx, gothUser.Provider) + authSource, err := auth.GetActiveAuthSourceByName(ctx, linkUser.GothUser.Provider, linkUser.Type) if err != nil { ctx.ServerError("CreateUser", err) return @@ -285,21 +287,24 @@ func LinkAccountPostRegister(ctx *context.Context) { Name: form.UserName, Email: form.Email, Passwd: form.Password, - LoginType: auth.OAuth2, + LoginType: authSource.Type, LoginSource: authSource.ID, - LoginName: gothUser.UserID, + LoginName: linkUser.GothUser.UserID, } - if !createAndHandleCreatedUser(ctx, tplLinkAccount, form, u, nil, &gothUser, false) { + if !createAndHandleCreatedUser(ctx, tplLinkAccount, form, u, nil, &linkUser.GothUser, false, linkUser.Type) { // error already handled return } - source := authSource.Cfg.(*oauth2.Source) - if err := syncGroupsToTeams(ctx, source, &gothUser, u); err != nil { - ctx.ServerError("SyncGroupsToTeams", err) - return + if linkUser.Type == auth.OAuth2 { + source := authSource.Cfg.(*oauth2.Source) + if err := syncGroupsToTeams(ctx, source, &linkUser.GothUser, u); err != nil { + ctx.ServerError("SyncGroupsToTeams", err) + return + } } + // TODO we will support some form of group mapping for SAML handleSignIn(ctx, u, false) } diff --git a/routers/web/auth/oauth.go b/routers/web/auth/oauth.go index ee0770ef379..d00644dd5f2 100644 --- a/routers/web/auth/oauth.go +++ b/routers/web/auth/oauth.go @@ -841,7 +841,7 @@ func handleAuthorizeError(ctx *context.Context, authErr AuthorizeError, redirect func SignInOAuth(ctx *context.Context) { provider := ctx.Params(":provider") - authSource, err := auth.GetActiveOAuth2SourceByName(ctx, provider) + authSource, err := auth.GetActiveAuthSourceByName(ctx, provider, auth.OAuth2) if err != nil { ctx.ServerError("SignIn", err) return @@ -892,7 +892,7 @@ func SignInOAuthCallback(ctx *context.Context) { } // first look if the provider is still active - authSource, err := auth.GetActiveOAuth2SourceByName(ctx, provider) + authSource, err := auth.GetActiveAuthSourceByName(ctx, provider, auth.OAuth2) if err != nil { ctx.ServerError("SignIn", err) return @@ -935,7 +935,7 @@ func SignInOAuthCallback(ctx *context.Context) { if u == nil { if ctx.Doer != nil { // attach user to already logged in user - err = externalaccount.LinkAccountToUser(ctx, ctx.Doer, gothUser) + err = externalaccount.LinkAccountToUser(ctx, ctx.Doer, gothUser, auth.OAuth2) if err != nil { ctx.ServerError("UserLinkAccount", err) return @@ -988,7 +988,7 @@ func SignInOAuthCallback(ctx *context.Context) { u.IsAdmin = isAdmin.ValueOrDefault(false) u.IsRestricted = isRestricted.ValueOrDefault(false) - if !createAndHandleCreatedUser(ctx, base.TplName(""), nil, u, overwriteDefault, &gothUser, setting.OAuth2Client.AccountLinking != setting.OAuth2AccountLinkingDisabled) { + if !createAndHandleCreatedUser(ctx, base.TplName(""), nil, u, overwriteDefault, &gothUser, setting.OAuth2Client.AccountLinking != setting.OAuth2AccountLinkingDisabled, auth.OAuth2) { // error already handled return } @@ -999,7 +999,7 @@ func SignInOAuthCallback(ctx *context.Context) { } } else { // no existing user is found, request attach or new account - showLinkingLogin(ctx, gothUser) + showLinkingLogin(ctx, gothUser, auth.OAuth2) return } } @@ -1063,9 +1063,12 @@ func getUserAdminAndRestrictedFromGroupClaims(source *oauth2.Source, gothUser *g return isAdmin, isRestricted } -func showLinkingLogin(ctx *context.Context, gothUser goth.User) { +func showLinkingLogin(ctx *context.Context, gothUser goth.User, authType auth.Type) { if err := updateSession(ctx, nil, map[string]any{ - "linkAccountGothUser": gothUser, + "linkAccountUser": auth.LinkAccountUser{ + Type: authType, + GothUser: gothUser, + }, }); err != nil { ctx.ServerError("updateSession", err) return @@ -1144,7 +1147,7 @@ func handleOAuth2SignIn(ctx *context.Context, source *auth.Source, u *user_model } // update external user information - if err := externalaccount.UpdateExternalUser(ctx, u, gothUser); err != nil { + if err := externalaccount.UpdateExternalUser(ctx, u, gothUser, auth.OAuth2); err != nil { if !errors.Is(err, util.ErrNotExist) { log.Error("UpdateExternalUser failed: %v", err) } diff --git a/routers/web/auth/openid.go b/routers/web/auth/openid.go index 29ef772b1c6..bf377b44969 100644 --- a/routers/web/auth/openid.go +++ b/routers/web/auth/openid.go @@ -8,6 +8,7 @@ import ( "net/http" "net/url" + auth_model "code.gitea.io/gitea/models/auth" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/auth/openid" "code.gitea.io/gitea/modules/base" @@ -363,7 +364,7 @@ func RegisterOpenIDPost(ctx *context.Context) { Email: form.Email, Passwd: password, } - if !createUserInContext(ctx, tplSignUpOID, form, u, nil, nil, false) { + if !createUserInContext(ctx, tplSignUpOID, form, u, nil, nil, false, auth_model.NoType) { // error already handled return } @@ -379,7 +380,7 @@ func RegisterOpenIDPost(ctx *context.Context) { return } - if !handleUserCreated(ctx, u, nil) { + if !handleUserCreated(ctx, u, nil, auth_model.NoType) { // error already handled return } diff --git a/routers/web/auth/saml.go b/routers/web/auth/saml.go new file mode 100644 index 00000000000..29d689d2e91 --- /dev/null +++ b/routers/web/auth/saml.go @@ -0,0 +1,172 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "errors" + "fmt" + "net/http" + "strings" + + "code.gitea.io/gitea/models/auth" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web/middleware" + "code.gitea.io/gitea/services/auth/source/saml" + "code.gitea.io/gitea/services/externalaccount" + + "github.com/markbates/goth" +) + +func SignInSAML(ctx *context.Context) { + provider := ctx.Params(":provider") + + loginSource, err := auth.GetActiveAuthSourceByName(ctx, provider, auth.SAML) + if err != nil || loginSource == nil { + ctx.NotFound("SAMLMetadata", err) + return + } + + if err = loginSource.Cfg.(*saml.Source).Callout(ctx.Req, ctx.Resp); err != nil { + if strings.Contains(err.Error(), "no provider for ") { + ctx.Error(http.StatusNotFound) + return + } + ctx.ServerError("SignIn", err) + } +} + +func SignInSAMLCallback(ctx *context.Context) { + provider := ctx.Params(":provider") + loginSource, err := auth.GetActiveAuthSourceByName(ctx, provider, auth.SAML) + if err != nil || loginSource == nil { + ctx.NotFound("SignInSAMLCallback", err) + return + } + + if loginSource == nil { + ctx.ServerError("SignIn", fmt.Errorf("no valid provider found, check configured callback url in provider")) + return + } + + u, gothUser, err := samlUserLoginCallback(*ctx, loginSource, ctx.Req, ctx.Resp) + if err != nil { + ctx.ServerError("SignInSAMLCallback", err) + return + } + + if u == nil { + if ctx.Doer != nil { + // attach user to already logged in user + err = externalaccount.LinkAccountToUser(ctx, ctx.Doer, gothUser, auth.SAML) + if err != nil { + ctx.ServerError("LinkAccountToUser", err) + return + } + + ctx.Redirect(setting.AppSubURL + "/user/settings/security") + return + } else if !setting.Service.AllowOnlyInternalRegistration && false { + // TODO: allow auto registration from saml users (OAuth2 uses the following setting.OAuth2Client.EnableAutoRegistration) + } else { + // no existing user is found, request attach or new account + showLinkingLogin(ctx, gothUser, auth.SAML) + return + } + } + + handleSamlSignIn(ctx, loginSource, u, gothUser) +} + +func handleSamlSignIn(ctx *context.Context, source *auth.Source, u *user_model.User, gothUser goth.User) { + if err := updateSession(ctx, nil, map[string]any{ + "uid": u.ID, + "uname": u.Name, + }); err != nil { + ctx.ServerError("updateSession", err) + return + } + + // Clear whatever CSRF cookie has right now, force to generate a new one + ctx.Csrf.DeleteCookie(ctx) + + // Register last login + u.SetLastLogin() + + // update external user information + if err := externalaccount.UpdateExternalUser(ctx, u, gothUser, auth.SAML); err != nil { + if !errors.Is(err, util.ErrNotExist) { + log.Error("UpdateExternalUser failed: %v", err) + } + } + + if err := resetLocale(ctx, u); err != nil { + ctx.ServerError("resetLocale", err) + return + } + + if redirectTo := ctx.GetSiteCookie("redirect_to"); len(redirectTo) > 0 { + middleware.DeleteRedirectToCookie(ctx.Resp) + ctx.RedirectToFirst(redirectTo) + return + } + + ctx.Redirect(setting.AppSubURL + "/") +} + +func samlUserLoginCallback(ctx context.Context, authSource *auth.Source, request *http.Request, response http.ResponseWriter) (*user_model.User, goth.User, error) { + samlSource := authSource.Cfg.(*saml.Source) + + gothUser, err := samlSource.Callback(request, response) + if err != nil { + return nil, gothUser, err + } + + user := &user_model.User{ + LoginName: gothUser.UserID, + LoginType: auth.SAML, + LoginSource: authSource.ID, + } + + hasUser, err := user_model.GetUser(ctx, user) + if err != nil { + return nil, goth.User{}, err + } + + if hasUser { + return user, gothUser, nil + } + + // search in external linked users + externalLoginUser := &user_model.ExternalLoginUser{ + ExternalID: gothUser.UserID, + LoginSourceID: authSource.ID, + } + hasUser, err = user_model.GetExternalLogin(ctx, externalLoginUser) + if err != nil { + return nil, goth.User{}, err + } + if hasUser { + user, err = user_model.GetUserByID(request.Context(), externalLoginUser.UserID) + return user, gothUser, err + } + + // no user found to login + return nil, gothUser, nil +} + +func SAMLMetadata(ctx *context.Context) { + provider := ctx.Params(":provider") + loginSource, err := auth.GetActiveAuthSourceByName(ctx, provider, auth.SAML) + if err != nil || loginSource == nil { + ctx.NotFound("SAMLMetadata", err) + return + } + if err = loginSource.Cfg.(*saml.Source).Metadata(ctx.Req, ctx.Resp); err != nil { + ctx.ServerError("SAMLMetadata", err) + } +} diff --git a/routers/web/web.go b/routers/web/web.go index 864164972e0..77c8319f067 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -667,6 +667,11 @@ func registerRoutes(m *web.Route) { m.Get("/{provider}", auth.SignInOAuth) m.Get("/{provider}/callback", auth.SignInOAuthCallback) }) + m.Group("/saml", func() { + m.Get("/{provider}", auth.SignInSAML) // redir to SAML IDP + m.Post("/{provider}/acs", auth.SignInSAMLCallback) + m.Get("/{provider}/metadata", auth.SAMLMetadata) + }) }) // ***** END: User ***** diff --git a/services/auth/source/saml/assert_interface_test.go b/services/auth/source/saml/assert_interface_test.go new file mode 100644 index 00000000000..2ca7057b8a5 --- /dev/null +++ b/services/auth/source/saml/assert_interface_test.go @@ -0,0 +1,22 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package saml_test + +import ( + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/services/auth" + "code.gitea.io/gitea/services/auth/source/saml" +) + +// This test file exists to assert that our Source exposes the interfaces that we expect +// It tightly binds the interfaces and implementation without breaking go import cycles + +type sourceInterface interface { + auth_model.Config + auth_model.SourceSettable + auth_model.RegisterableSource + auth.PasswordAuthenticator +} + +var _ (sourceInterface) = &saml.Source{} diff --git a/services/auth/source/saml/init.go b/services/auth/source/saml/init.go new file mode 100644 index 00000000000..f1d6d9fa4bc --- /dev/null +++ b/services/auth/source/saml/init.go @@ -0,0 +1,29 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package saml + +import ( + "context" + "sync" + + "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/modules/log" +) + +var samlRWMutex = sync.RWMutex{} + +func Init(ctx context.Context) error { + loginSources, _ := auth.GetActiveAuthProviderSources(ctx, auth.SAML) + for _, source := range loginSources { + samlSource, ok := source.Cfg.(*Source) + if !ok { + continue + } + err := samlSource.RegisterSource() + if err != nil { + log.Error("Unable to register source: %s due to Error: %v.", source.Name, err) + } + } + return nil +} diff --git a/services/auth/source/saml/name_id_format.go b/services/auth/source/saml/name_id_format.go new file mode 100644 index 00000000000..1ddf0477297 --- /dev/null +++ b/services/auth/source/saml/name_id_format.go @@ -0,0 +1,38 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package saml + +type NameIDFormat int + +const ( + SAML11Email NameIDFormat = iota + 1 + SAML11Persistent + SAML11Unspecified + SAML20Email + SAML20Persistent + SAML20Transient + SAML20Unspecified +) + +const DefaultNameIDFormat NameIDFormat = SAML20Persistent + +var NameIDFormatNames = map[NameIDFormat]string{ + SAML11Email: "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + SAML11Persistent: "urn:oasis:names:tc:SAML:1.1:nameid-format:persistent", + SAML11Unspecified: "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified", + SAML20Email: "urn:oasis:names:tc:SAML:2.0:nameid-format:emailAddress", + SAML20Persistent: "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent", + SAML20Transient: "urn:oasis:names:tc:SAML:2.0:nameid-format:transient", + SAML20Unspecified: "urn:oasis:names:tc:SAML:2.0:nameid-format:unspecified", +} + +// String returns the name of the NameIDFormat +func (n NameIDFormat) String() string { + return NameIDFormatNames[n] +} + +// Int returns the int value of the NameIDFormat +func (n NameIDFormat) Int() int { + return int(n) +} diff --git a/services/auth/source/saml/providers.go b/services/auth/source/saml/providers.go new file mode 100644 index 00000000000..d0b36ff44d9 --- /dev/null +++ b/services/auth/source/saml/providers.go @@ -0,0 +1,109 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package saml + +import ( + "context" + "fmt" + "html" + "html/template" + "io" + "net/http" + "sort" + "time" + + "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/httplib" + "code.gitea.io/gitea/modules/svg" + "code.gitea.io/gitea/modules/util" +) + +// Providers is list of known/available providers. +type Providers map[string]Source + +var providers = Providers{} + +// Provider is an interface for describing a single SAML provider +type Provider interface { + Name() string + IconHTML(size int) template.HTML +} + +// AuthSourceProvider is a SAML provider +type AuthSourceProvider struct { + sourceName, iconURL string +} + +func (p *AuthSourceProvider) Name() string { + return p.sourceName +} + +func (p *AuthSourceProvider) IconHTML(size int) template.HTML { + if p.iconURL != "" { + return template.HTML(fmt.Sprintf(`%s`, + size, + size, + html.EscapeString(p.iconURL), html.EscapeString(p.Name()), + )) + } + return svg.RenderHTML("gitea-lock-cog", size, "gt-mr-3") +} + +func readIdentityProviderMetadata(ctx context.Context, source *Source) ([]byte, error) { + if source.IdentityProviderMetadata != "" { + return []byte(source.IdentityProviderMetadata), nil + } + + req := httplib.NewRequest(source.IdentityProviderMetadataURL, "GET") + req.SetTimeout(20*time.Second, time.Minute) + resp, err := req.Response() + if err != nil { + return nil, fmt.Errorf("Unable to contact gitea: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, err + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + return data, nil +} + +func createProviderFromSource(source *auth.Source) (Provider, error) { + samlCfg, ok := source.Cfg.(*Source) + if !ok { + return nil, fmt.Errorf("invalid SAML source config: %v", samlCfg) + } + return &AuthSourceProvider{sourceName: source.Name, iconURL: samlCfg.IconURL}, nil +} + +// GetSAMLProviders returns the list of configured SAML providers +func GetSAMLProviders(ctx context.Context, isActive util.OptionalBool) ([]Provider, error) { + authSources, err := db.Find[auth.Source](ctx, auth.FindSourcesOptions{ + IsActive: isActive, + LoginType: auth.SAML, + }) + if err != nil { + return nil, err + } + + samlProviders := make([]Provider, 0, len(authSources)) + for _, source := range authSources { + p, err := createProviderFromSource(source) + if err != nil { + return nil, err + } + samlProviders = append(samlProviders, p) + } + + sort.Slice(samlProviders, func(i, j int) bool { + return samlProviders[i].Name() < samlProviders[j].Name() + }) + + return samlProviders, nil +} diff --git a/services/auth/source/saml/source.go b/services/auth/source/saml/source.go new file mode 100644 index 00000000000..52388646b59 --- /dev/null +++ b/services/auth/source/saml/source.go @@ -0,0 +1,202 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package saml + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "encoding/xml" + "errors" + "fmt" + "math/big" + "net/url" + "time" + + "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + + saml2 "github.com/russellhaering/gosaml2" + "github.com/russellhaering/gosaml2/types" + dsig "github.com/russellhaering/goxmldsig" +) + +// Source holds configuration for the SAML login source. +type Source struct { + // IdentityProviderMetadata description: The SAML Identity Provider metadata XML contents (for static configuration of the SAML Service Provider). The value of this field should be an XML document whose root element is `` or ``. To escape the value into a JSON string, you may want to use a tool like https://json-escape-text.now.sh. + IdentityProviderMetadata string + // IdentityProviderMetadataURL description: The SAML Identity Provider metadata URL (for dynamic configuration of the SAML Service Provider). + IdentityProviderMetadataURL string + // InsecureSkipAssertionSignatureValidation description: Whether the Service Provider should (insecurely) accept assertions from the Identity Provider without a valid signature. + InsecureSkipAssertionSignatureValidation bool + // NameIDFormat description: The SAML NameID format to use when performing user authentication. + NameIDFormat NameIDFormat + // ServiceProviderCertificate description: The SAML Service Provider certificate in X.509 encoding (begins with "-----BEGIN CERTIFICATE-----"). This certificate is used by the Identity Provider to validate the Service Provider's AuthnRequests and LogoutRequests. It corresponds to the Service Provider's private key (`serviceProviderPrivateKey`). To escape the value into a JSON string, you may want to use a tool like https://json-escape-text.now.sh. + ServiceProviderCertificate string + // ServiceProviderIssuer description: The SAML Service Provider name, used to identify this Service Provider. This is required if the "externalURL" field is not set (as the SAML metadata endpoint is computed as ".auth/saml/metadata"), or when using multiple SAML authentication providers. + ServiceProviderIssuer string + // ServiceProviderPrivateKey description: The SAML Service Provider private key in PKCS#8 encoding (begins with "-----BEGIN PRIVATE KEY-----"). This private key is used to sign AuthnRequests and LogoutRequests. It corresponds to the Service Provider's certificate (`serviceProviderCertificate`). To escape the value into a JSON string, you may want to use a tool like https://json-escape-text.now.sh. + ServiceProviderPrivateKey string + + CallbackURL string + IconURL string + + // EmailAssertionKey description: Assertion key for user.Email + EmailAssertionKey string + // NameAssertionKey description: Assertion key for user.NickName + NameAssertionKey string + // UsernameAssertionKey description: Assertion key for user.Name + UsernameAssertionKey string + + // reference to the authSource + authSource *auth.Source + + samlSP *saml2.SAMLServiceProvider +} + +func GenerateSAMLSPKeypair() (string, string, error) { + key, err := rsa.GenerateKey(rand.Reader, 4096) + if err != nil { + return "", "", err + } + + keyBytes := x509.MarshalPKCS1PrivateKey(key) + keyPem := pem.EncodeToMemory( + &pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: keyBytes, + }, + ) + + now := time.Now() + + template := &x509.Certificate{ + SerialNumber: big.NewInt(0), + NotBefore: now.Add(-5 * time.Minute), + NotAfter: now.Add(365 * 24 * time.Hour), + + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{}, + BasicConstraintsValid: true, + } + + certificate, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + return "", "", err + } + + certPem := pem.EncodeToMemory( + &pem.Block{ + Type: "CERTIFICATE", + Bytes: certificate, + }, + ) + + return string(keyPem), string(certPem), nil +} + +func (source *Source) initSAMLSp() error { + source.CallbackURL = setting.AppURL + "user/saml/" + url.PathEscape(source.authSource.Name) + "/acs" + + idpMetadata, err := readIdentityProviderMetadata(context.Background(), source) + if err != nil { + return err + } + { + if source.IdentityProviderMetadataURL != "" { + log.Trace(fmt.Sprintf("Identity Provider metadata: %s", source.IdentityProviderMetadataURL), string(idpMetadata)) + } + } + + metadata := &types.EntityDescriptor{} + err = xml.Unmarshal(idpMetadata, metadata) + if err != nil { + return err + } + + certStore := dsig.MemoryX509CertificateStore{ + Roots: []*x509.Certificate{}, + } + + if metadata.IDPSSODescriptor == nil { + return errors.New("saml idp metadata missing IDPSSODescriptor") + } + + for _, kd := range metadata.IDPSSODescriptor.KeyDescriptors { + for idx, xcert := range kd.KeyInfo.X509Data.X509Certificates { + if xcert.Data == "" { + return fmt.Errorf("metadata certificate(%d) must not be empty", idx) + } + certData, err := base64.StdEncoding.DecodeString(xcert.Data) + if err != nil { + return err + } + + idpCert, err := x509.ParseCertificate(certData) + if err != nil { + return err + } + + certStore.Roots = append(certStore.Roots, idpCert) + } + } + + var keyStore dsig.X509KeyStore + + if source.ServiceProviderCertificate != "" && source.ServiceProviderPrivateKey != "" { + keyPair, err := tls.X509KeyPair([]byte(source.ServiceProviderCertificate), []byte(source.ServiceProviderPrivateKey)) + if err != nil { + return err + } + keyPair.Leaf, err = x509.ParseCertificate(keyPair.Certificate[0]) + if err != nil { + return err + } + keyStore = dsig.TLSCertKeyStore(keyPair) + } + + source.samlSP = &saml2.SAMLServiceProvider{ + IdentityProviderSSOURL: metadata.IDPSSODescriptor.SingleSignOnServices[0].Location, + IdentityProviderIssuer: metadata.EntityID, + AudienceURI: setting.AppURL + "user/saml/" + url.PathEscape(source.authSource.Name) + "/metadata", + AssertionConsumerServiceURL: source.CallbackURL, + SkipSignatureValidation: source.InsecureSkipAssertionSignatureValidation, + NameIdFormat: source.NameIDFormat.String(), + IDPCertificateStore: &certStore, + SignAuthnRequests: source.ServiceProviderCertificate != "" && source.ServiceProviderPrivateKey != "", + SPKeyStore: keyStore, + ServiceProviderIssuer: setting.AppURL + "user/saml/" + url.PathEscape(source.authSource.Name) + "/metadata", + } + + return nil +} + +// FromDB fills up a SAML from serialized format. +func (source *Source) FromDB(bs []byte) error { + if err := json.UnmarshalHandleDoubleEncode(bs, &source); err != nil { + return err + } + + return source.initSAMLSp() +} + +// ToDB exports a SAML to a serialized format. +func (source *Source) ToDB() ([]byte, error) { + return json.Marshal(source) +} + +// SetAuthSource sets the related AuthSource +func (source *Source) SetAuthSource(authSource *auth.Source) { + source.authSource = authSource +} + +func init() { + auth.RegisterTypeConfig(auth.SAML, &Source{}) +} diff --git a/services/auth/source/saml/source_authenticate.go b/services/auth/source/saml/source_authenticate.go new file mode 100644 index 00000000000..d118917f874 --- /dev/null +++ b/services/auth/source/saml/source_authenticate.go @@ -0,0 +1,16 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package saml + +import ( + "context" + + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/services/auth/source/db" +) + +// Authenticate falls back to the db authenticator +func (source *Source) Authenticate(ctx context.Context, user *user_model.User, login, password string) (*user_model.User, error) { + return db.Authenticate(ctx, user, login, password) +} diff --git a/services/auth/source/saml/source_callout.go b/services/auth/source/saml/source_callout.go new file mode 100644 index 00000000000..5366f8a5271 --- /dev/null +++ b/services/auth/source/saml/source_callout.go @@ -0,0 +1,89 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package saml + +import ( + "fmt" + "net/http" + "strings" + + "github.com/markbates/goth" +) + +// Callout redirects request/response pair to authenticate against the provider +func (source *Source) Callout(request *http.Request, response http.ResponseWriter) error { + samlRWMutex.RLock() + defer samlRWMutex.RUnlock() + if _, ok := providers[source.authSource.Name]; !ok { + return fmt.Errorf("no provider for this saml") + } + + authURL, err := providers[source.authSource.Name].samlSP.BuildAuthURL("") + if err == nil { + http.Redirect(response, request, authURL, http.StatusTemporaryRedirect) + } + return err +} + +// Callback handles SAML callback, resolve to a goth user and send back to original url +// this will trigger a new authentication request, but because we save it in the session we can use that +func (source *Source) Callback(request *http.Request, response http.ResponseWriter) (goth.User, error) { + samlRWMutex.RLock() + defer samlRWMutex.RUnlock() + + user := goth.User{ + Provider: source.authSource.Name, + } + samlResponse := request.FormValue("SAMLResponse") + assertions, err := source.samlSP.RetrieveAssertionInfo(samlResponse) + if err != nil { + return user, err + } + + if assertions.WarningInfo.OneTimeUse { + return user, fmt.Errorf("SAML response contains one time use warning") + } + + if assertions.WarningInfo.ProxyRestriction != nil { + return user, fmt.Errorf("SAML response contains proxy restriction warning: %v", assertions.WarningInfo.ProxyRestriction) + } + + if assertions.WarningInfo.NotInAudience { + return user, fmt.Errorf("SAML response contains audience warning") + } + + if assertions.WarningInfo.InvalidTime { + return user, fmt.Errorf("SAML response contains invalid time warning") + } + + samlMap := make(map[string]string) + for key, value := range assertions.Values { + keyParsed := strings.ToLower(key[strings.LastIndex(key, "/")+1:]) // Uses the trailing slug as the key name. + valueParsed := value.Values[0].Value + samlMap[keyParsed] = valueParsed + + } + + user.UserID = assertions.NameID + if user.UserID == "" { + return user, fmt.Errorf("no nameID found in SAML response") + } + + // email + if _, ok := samlMap[source.EmailAssertionKey]; !ok { + user.Email = samlMap[source.EmailAssertionKey] + } + // name + if _, ok := samlMap[source.NameAssertionKey]; !ok { + user.NickName = samlMap[source.NameAssertionKey] + } + // username + if _, ok := samlMap[source.UsernameAssertionKey]; !ok { + user.Name = samlMap[source.UsernameAssertionKey] + } + + // TODO: utilize groups once mapping is supported + + return user, nil +} diff --git a/services/auth/source/saml/source_metadata.go b/services/auth/source/saml/source_metadata.go new file mode 100644 index 00000000000..9fb8c758e35 --- /dev/null +++ b/services/auth/source/saml/source_metadata.go @@ -0,0 +1,32 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package saml + +import ( + "encoding/xml" + "fmt" + "net/http" +) + +// Metadata redirects request/response pair to authenticate against the provider +func (source *Source) Metadata(request *http.Request, response http.ResponseWriter) error { + samlRWMutex.RLock() + defer samlRWMutex.RUnlock() + if _, ok := providers[source.authSource.Name]; !ok { + return fmt.Errorf("provider does not exist") + } + + metadata, err := providers[source.authSource.Name].samlSP.Metadata() + if err != nil { + return err + } + buf, err := xml.Marshal(metadata) + if err != nil { + return err + } + + response.Header().Set("Content-Type", "application/samlmetadata+xml; charset=utf-8") + _, _ = response.Write(buf) + return nil +} diff --git a/services/auth/source/saml/source_register.go b/services/auth/source/saml/source_register.go new file mode 100644 index 00000000000..93eaaa88b66 --- /dev/null +++ b/services/auth/source/saml/source_register.go @@ -0,0 +1,23 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package saml + +// RegisterSource causes an OAuth2 configuration to be registered +func (source *Source) RegisterSource() error { + samlRWMutex.Lock() + defer samlRWMutex.Unlock() + if err := source.initSAMLSp(); err != nil { + return err + } + providers[source.authSource.Name] = *source + return nil +} + +// UnregisterSource causes an SAML configuration to be unregistered +func (source *Source) UnregisterSource() error { + samlRWMutex.Lock() + defer samlRWMutex.Unlock() + delete(providers, source.authSource.Name) + return nil +} diff --git a/services/externalaccount/link.go b/services/externalaccount/link.go index d6e2ea7e942..1f4c6728b86 100644 --- a/services/externalaccount/link.go +++ b/services/externalaccount/link.go @@ -7,9 +7,8 @@ import ( "context" "fmt" + "code.gitea.io/gitea/models/auth" user_model "code.gitea.io/gitea/models/user" - - "github.com/markbates/goth" ) // Store represents a thing that stores things @@ -21,10 +20,12 @@ type Store interface { // LinkAccountFromStore links the provided user with a stored external user func LinkAccountFromStore(ctx context.Context, store Store, user *user_model.User) error { - gothUser := store.Get("linkAccountGothUser") - if gothUser == nil { + externalLinkUserInterface := store.Get("linkAccountUser") + if externalLinkUserInterface == nil { return fmt.Errorf("not in LinkAccount session") } - return LinkAccountToUser(ctx, user, gothUser.(goth.User)) + externalLinkUser := externalLinkUserInterface.(auth.LinkAccountUser) + + return LinkAccountToUser(ctx, user, externalLinkUser.GothUser, externalLinkUser.Type) } diff --git a/services/externalaccount/user.go b/services/externalaccount/user.go index e2de41da188..fa85a656696 100644 --- a/services/externalaccount/user.go +++ b/services/externalaccount/user.go @@ -16,8 +16,8 @@ import ( "github.com/markbates/goth" ) -func toExternalLoginUser(ctx context.Context, user *user_model.User, gothUser goth.User) (*user_model.ExternalLoginUser, error) { - authSource, err := auth.GetActiveOAuth2SourceByName(ctx, gothUser.Provider) +func toExternalLoginUser(ctx context.Context, user *user_model.User, gothUser goth.User, authType auth.Type) (*user_model.ExternalLoginUser, error) { + authSource, err := auth.GetActiveAuthSourceByName(ctx, gothUser.Provider, authType) if err != nil { return nil, err } @@ -43,8 +43,8 @@ func toExternalLoginUser(ctx context.Context, user *user_model.User, gothUser go } // LinkAccountToUser link the gothUser to the user -func LinkAccountToUser(ctx context.Context, user *user_model.User, gothUser goth.User) error { - externalLoginUser, err := toExternalLoginUser(ctx, user, gothUser) +func LinkAccountToUser(ctx context.Context, user *user_model.User, gothUser goth.User, authType auth.Type) error { + externalLoginUser, err := toExternalLoginUser(ctx, user, gothUser, authType) if err != nil { return err } @@ -71,8 +71,8 @@ func LinkAccountToUser(ctx context.Context, user *user_model.User, gothUser goth } // UpdateExternalUser updates external user's information -func UpdateExternalUser(ctx context.Context, user *user_model.User, gothUser goth.User) error { - externalLoginUser, err := toExternalLoginUser(ctx, user, gothUser) +func UpdateExternalUser(ctx context.Context, user *user_model.User, gothUser goth.User, authType auth.Type) error { + externalLoginUser, err := toExternalLoginUser(ctx, user, gothUser, authType) if err != nil { return err } diff --git a/services/forms/auth_form.go b/services/forms/auth_form.go index 25acbbb99e8..85be38b4031 100644 --- a/services/forms/auth_form.go +++ b/services/forms/auth_form.go @@ -1,3 +1,4 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. // Copyright 2014 The Gogs Authors. All rights reserved. // SPDX-License-Identifier: MIT @@ -15,7 +16,7 @@ import ( // AuthenticationForm form for authentication type AuthenticationForm struct { ID int64 - Type int `binding:"Range(2,7)"` + Type int `binding:"Range(2,9)"` Name string `binding:"Required;MaxSize(30)"` Host string Port int @@ -82,6 +83,18 @@ type AuthenticationForm struct { SSPIDefaultLanguage string GroupTeamMap string `binding:"ValidGroupTeamMap"` GroupTeamMapRemoval bool + + // SAML Settings + NameIDFormat int + IdentityProviderMetadata string + IdentityProviderMetadataURL string + InsecureSkipAssertionSignatureValidation bool + ServiceProviderCertificate string + ServiceProviderPrivateKey string + EmailAssertionKey string + NameAssertionKey string + UsernameAssertionKey string + SAMLIconURL string } // Validate validates fields diff --git a/templates/admin/auth/edit.tmpl b/templates/admin/auth/edit.tmpl index 25abefae000..2182d011e9a 100644 --- a/templates/admin/auth/edit.tmpl +++ b/templates/admin/auth/edit.tmpl @@ -367,6 +367,69 @@ {{end}} + + {{if .Source.IsSAML}} + {{$cfg:=.Source.Cfg}} +
+ + +
+ +
+ + +
+ +
+ + +
+
+ + +
+ +
+
+ + +
+
+ +
+ + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ {{end}} + {{if .Source.IsSSPI}} {{$cfg:=.Source.Cfg}} @@ -441,6 +504,9 @@
GMail Settings:

Host: smtp.gmail.com, Port: 587, Enable TLS Encryption: true

+
SAML Settings:
+

{{ctx.Locale.Tr "admin.auths.tips.saml"}}

+
{{ctx.Locale.Tr "admin.auths.tips.oauth2.general"}}:

{{ctx.Locale.Tr "admin.auths.tips.oauth2.general.tip"}}

diff --git a/templates/admin/auth/new.tmpl b/templates/admin/auth/new.tmpl index f32f77d5dc2..665b0e30863 100644 --- a/templates/admin/auth/new.tmpl +++ b/templates/admin/auth/new.tmpl @@ -53,6 +53,9 @@ {{template "admin/auth/source/sspi" .}} + + {{template "admin/auth/source/saml" .}} +
@@ -85,6 +88,9 @@
GMail Settings:

Host: smtp.gmail.com, Port: 587, Enable TLS Encryption: true

+
SAML Settings:
+

{{ctx.Locale.Tr "admin.auths.tips.saml"}}

+
{{ctx.Locale.Tr "admin.auths.tips.oauth2.general"}}:

{{ctx.Locale.Tr "admin.auths.tips.oauth2.general.tip"}}

diff --git a/templates/admin/auth/source/saml.tmpl b/templates/admin/auth/source/saml.tmpl new file mode 100644 index 00000000000..050e22ddcc3 --- /dev/null +++ b/templates/admin/auth/source/saml.tmpl @@ -0,0 +1,62 @@ +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ + +
+ +
+
+ + +
+
+ +
+ + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
diff --git a/templates/user/auth/signin_inner.tmpl b/templates/user/auth/signin_inner.tmpl index 0d0064b02af..1b4e2b25f9f 100644 --- a/templates/user/auth/signin_inner.tmpl +++ b/templates/user/auth/signin_inner.tmpl @@ -69,5 +69,22 @@
{{end}} + {{if .SAMLProviders}} +
+ {{.locale.Tr "sign_in_or"}} +
+
+
+
+ {{range $provider := .SAMLProviders}} + + {{end}} +
+
+
+ {{end}} diff --git a/tests/integration/README.md b/tests/integration/README.md index f6f74ca21ff..c6914835119 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -110,3 +110,20 @@ SLOW_FLUSH = 5S ; 5s is the default value ```bash GITEA_SLOW_TEST_TIME="10s" GITEA_SLOW_FLUSH_TIME="5s" make test-sqlite ``` + +## Running SimpleSAML for testing SAML locally + +```shell +docker run \ +-p 8080:8080 \ +-p 8443:8443 \ +-e SIMPLESAMLPHP_SP_ENTITY_ID=http://localhost:3003/user/saml/test-sp/metadata \ +-e SIMPLESAMLPHP_SP_ASSERTION_CONSUMER_SERVICE=http://localhost:3003/user/saml/test-sp/acs \ +-e SIMPLESAMLPHP_SP_SINGLE_LOGOUT_SERVICE=http://localhost:3003/user/saml/test-sp/acs \ +--add-host=localhost:192.168.65.2 \ +-d allspice/simple-saml +``` + +```shell +TEST_SIMPLESAML_URL=localhost:8080 make test-sqlite#TestSAMLRegistration +``` diff --git a/tests/integration/saml_test.go b/tests/integration/saml_test.go new file mode 100644 index 00000000000..585fd35c5f4 --- /dev/null +++ b/tests/integration/saml_test.go @@ -0,0 +1,150 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "crypto/tls" + "crypto/x509" + "fmt" + "io" + "net/http" + "net/http/cookiejar" + "net/url" + "os" + "regexp" + "strings" + "testing" + "time" + + "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/services/auth/source/saml" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestSAMLRegistration(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + samlURL := "localhost:8080" + + if os.Getenv("CI") == "" || !setting.Database.Type.IsPostgreSQL() { + // Make it possible to run tests against a local simplesaml instance + samlURL = os.Getenv("TEST_SIMPLESAML_URL") + if samlURL == "" { + t.Skip("TEST_SIMPLESAML_URL not set and not running in CI") + return + } + } + + privateKey, cert, err := saml.GenerateSAMLSPKeypair() + assert.NoError(t, err) + + // verify that the keypair can be parsed + keyPair, err := tls.X509KeyPair([]byte(cert), []byte(privateKey)) + assert.NoError(t, err) + keyPair.Leaf, err = x509.ParseCertificate(keyPair.Certificate[0]) + assert.NoError(t, err) + + assert.NoError(t, auth.CreateSource(db.DefaultContext, &auth.Source{ + Type: auth.SAML, + Name: "test-sp", + IsActive: true, + IsSyncEnabled: false, + Cfg: &saml.Source{ + IdentityProviderMetadata: "", + IdentityProviderMetadataURL: fmt.Sprintf("http://%s/simplesaml/saml2/idp/metadata.php", samlURL), + InsecureSkipAssertionSignatureValidation: false, + NameIDFormat: 4, + ServiceProviderCertificate: "", // SimpleSAMLPhp requires that the SP certificate be specified in the server configuration rather than SP metadata + ServiceProviderPrivateKey: "", + EmailAssertionKey: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress", + NameAssertionKey: "http://schemas.xmlsoap.org/claims/CommonName", + UsernameAssertionKey: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name", + IconURL: "", + }, + })) + + // check the saml metadata url + req := NewRequest(t, "GET", "/user/saml/test-sp/metadata") + MakeRequest(t, req, http.StatusOK) + + req = NewRequest(t, "GET", "/user/saml/test-sp") + resp := MakeRequest(t, req, http.StatusTemporaryRedirect) + + jar, err := cookiejar.New(nil) + assert.NoError(t, err) + + client := http.Client{ + Timeout: 30 * time.Second, + Jar: jar, + } + + httpReq, err := http.NewRequest("GET", test.RedirectURL(resp), nil) + assert.NoError(t, err) + + var formRedirectURL *url.URL + client.CheckRedirect = func(req *http.Request, via []*http.Request) error { + // capture the redirected destination to use in POST request + formRedirectURL = req.URL + return nil + } + + res, err := client.Do(httpReq) + client.CheckRedirect = nil + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, res.StatusCode) + assert.NotNil(t, formRedirectURL) + + form := url.Values{ + "username": {"user1"}, + "password": {"user1pass"}, + } + + httpReq, err = http.NewRequest("POST", formRedirectURL.String(), strings.NewReader(form.Encode())) + assert.NoError(t, err) + httpReq.Header.Add("Content-Type", "application/x-www-form-urlencoded") + + res, err = client.Do(httpReq) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, res.StatusCode) + + body, err := io.ReadAll(res.Body) + assert.NoError(t, err) + + samlResMatcher := regexp.MustCompile(``) + matches := samlResMatcher.FindStringSubmatch(string(body)) + assert.Len(t, matches, 2) + assert.NoError(t, res.Body.Close()) + + session := emptyTestSession(t) + + req = NewRequestWithValues(t, "POST", "/user/saml/test-sp/acs", map[string]string{ + "SAMLResponse": matches[1], + }) + resp = session.MakeRequest(t, req, http.StatusSeeOther) + assert.Equal(t, test.RedirectURL(resp), "/user/link_account") + + csrf := GetCSRF(t, session, test.RedirectURL(resp)) + + // link the account + req = NewRequestWithValues(t, "POST", "/user/link_account_signup", map[string]string{ + "_csrf": csrf, + "user_name": "samluser", + "email": "saml@example.com", + }) + + resp = session.MakeRequest(t, req, http.StatusSeeOther) + assert.Equal(t, test.RedirectURL(resp), "/") + + // verify that the user was created + u, err := user_model.GetUserByEmail(db.DefaultContext, "saml@example.com") + assert.NoError(t, err) + assert.NotNil(t, u) + assert.Equal(t, "samluser", u.Name) +} diff --git a/web_src/js/features/admin/common.js b/web_src/js/features/admin/common.js index 044976ea7b6..4804163971d 100644 --- a/web_src/js/features/admin/common.js +++ b/web_src/js/features/admin/common.js @@ -103,9 +103,9 @@ export function initAdminCommon() { // New authentication if ($('.admin.new.authentication').length > 0) { $('#auth_type').on('change', function () { - hideElem($('.ldap, .dldap, .smtp, .pam, .oauth2, .has-tls, .search-page-size, .sspi')); + hideElem($('.ldap, .dldap, .smtp, .pam, .oauth2, .has-tls, .search-page-size, .sspi, .saml')); - $('.ldap input[required], .binddnrequired input[required], .dldap input[required], .smtp input[required], .pam input[required], .oauth2 input[required], .has-tls input[required], .sspi input[required]').removeAttr('required'); + $('.ldap input[required], .binddnrequired input[required], .dldap input[required], .smtp input[required], .pam input[required], .oauth2 input[required], .has-tls input[required], .sspi input[required], .saml input[required]').removeAttr('required'); $('.binddnrequired').removeClass('required'); const authType = $(this).val(); @@ -137,6 +137,10 @@ export function initAdminCommon() { showElem($('.sspi')); $('.sspi div.required input').attr('required', 'required'); break; + case '8': // SAML + showElem($('.saml')); + $('.saml div.required input').attr('required', 'required'); + break; } if (authType === '2' || authType === '5') { onSecurityProtocolChange(); diff --git a/web_src/js/features/user-auth.js b/web_src/js/features/user-auth.js index 60d186e6997..3bf84e31df6 100644 --- a/web_src/js/features/user-auth.js +++ b/web_src/js/features/user-auth.js @@ -20,3 +20,24 @@ export function initUserAuthOauth2() { }); } } + +export function initUserAuthSAML() { + const outer = document.getElementById('saml-login-navigator'); + if (!outer) return; + const inner = document.getElementById('saml-login-navigator-inner'); + + checkAppUrl(); + + for (const link of outer.querySelectorAll('.saml-login-link')) { + link.addEventListener('click', () => { + inner.classList.add('gt-invisible'); + outer.classList.add('is-loading'); + setTimeout(() => { + // recover previous content to let user try again + // usually redirection will be performed before this action + outer.classList.remove('is-loading'); + inner.classList.remove('gt-invisible'); + }, 5000); + }); + } +} diff --git a/web_src/js/index.js b/web_src/js/index.js index 117279c3c4a..ddd435f05e8 100644 --- a/web_src/js/index.js +++ b/web_src/js/index.js @@ -23,7 +23,10 @@ import {initFindFileInRepo} from './features/repo-findfile.js'; import {initCommentContent, initMarkupContent} from './markup/content.js'; import {initPdfViewer} from './render/pdf.js'; -import {initUserAuthOauth2} from './features/user-auth.js'; +import { + initUserAuthOauth2, + initUserAuthSAML +} from './features/user-auth.js'; import { initRepoIssueDue, initRepoIssueReferenceRepositorySearch, @@ -179,6 +182,7 @@ onDomReady(() => { initCaptcha(); initUserAuthOauth2(); + initUserAuthSAML(); initUserAuthWebAuthn(); initUserAuthWebAuthnRegister(); initUserSettings();