From cf89ecd88796d307f125ccc3c031e5ef9a034ca8 Mon Sep 17 00:00:00 2001 From: silverwind Date: Fri, 2 Oct 2026 21:08:14 +0200 Subject: [PATCH] refactor!: move go-chi/session into Gitea (#39504) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `gitea.com/go-chi/session` package only exists for Gitea, so it moves into `modules/session` to fix its bugs directly. Fixes the flake in https://github.com/go-gitea/gitea/actions/runs/36726154500/job/109923538400. - Sessions are only written back when changed, so a read-only request can't revert a concurrent change or restore a logged-out session, like https://github.com/go-macaron/session/commit/ae808a4a4660c802965c834299ab08f167effd12 - The session cookie is only set once a session holds data - Every backend refreshes the expiry on load and file sessions are written atomically - Also fix https://github.com/go-gitea/gitea/issues/36176 ## ⚠️ BREAKING ⚠️ * the `mysql`, `postgres`, `couchbase` and `memcache` session providers are removed, use `file`, `db` or `redis` instead * login-related cookies are renamed to `gitea_session` and `gitea_remember`, if you'd like to use the old names, set `COOKIE_NAME` and `COOKIE_REMEMBER_NAME` in app.ini --------- Co-authored-by: wxiaoguang --- assets/go-licenses.json | 25 -- cmd/dump.go | 10 +- custom/conf/app.example.ini | 8 +- go.mod | 5 - go.sum | 58 ----- models/auth/session.go | 101 +++----- modules/session/db.go | 160 +----------- modules/session/file.go | 115 +++++++++ modules/session/main_test.go | 16 ++ modules/session/mem.go | 120 ++++++--- modules/session/redis.go | 218 +++------------- modules/session/session.go | 146 +++++++++++ modules/session/session_test.go | 264 ++++++++++++++++++++ modules/session/store.go | 136 ++++++++-- modules/session/virtual.go | 202 --------------- modules/setting/security.go | 2 +- modules/setting/session.go | 26 +- modules/setting/session_test.go | 12 +- modules/util/util.go | 10 + routers/common/middleware.go | 21 +- routers/web/admin/config.go | 16 -- routers/web/auth/auth.go | 8 +- routers/web/web.go | 2 - services/auth/auth.go | 15 +- services/auth/reverseproxy_test.go | 15 +- services/auth/source/oauth2/store.go | 5 +- services/context/base.go | 10 - services/context/base_test.go | 21 -- services/context/context_cookie.go | 15 -- services/context/context_test.go | 10 - services/contexttest/context_tests.go | 2 +- tests/integration/create_no_session_test.go | 80 ++---- tests/integration/repo_test.go | 3 +- tests/integration/session_test.go | 37 ++- 34 files changed, 913 insertions(+), 981 deletions(-) create mode 100644 modules/session/file.go create mode 100644 modules/session/main_test.go create mode 100644 modules/session/session.go create mode 100644 modules/session/session_test.go delete mode 100644 modules/session/virtual.go diff --git a/assets/go-licenses.json b/assets/go-licenses.json index 6518c2d0061..728cc8fef4b 100644 --- a/assets/go-licenses.json +++ b/assets/go-licenses.json @@ -14,11 +14,6 @@ "path": "filippo.io/edwards25519/LICENSE", "licenseText": "Copyright (c) 2009 The Go Authors. All rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are\nmet:\n\n * Redistributions of source code must retain the above copyright\nnotice, this list of conditions and the following disclaimer.\n * Redistributions in binary form must reproduce the above\ncopyright notice, this list of conditions and the following disclaimer\nin the documentation and/or other materials provided with the\ndistribution.\n * Neither the name of Google Inc. nor the names of its\ncontributors may be used to endorse or promote products derived from\nthis software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS\n\"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT\nLIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR\nA PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\nOWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT\nLIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\nDATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\nTHEORY OF 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": "gitea.com/go-chi/session", - "path": "gitea.com/go-chi/session/LICENSE", - "licenseText": "Apache License\nVersion 2.0, January 2004\nhttp://www.apache.org/licenses/\n\nTERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n1. Definitions.\n\n\"License\" shall mean the terms and conditions for use, reproduction, and\ndistribution as defined by Sections 1 through 9 of this document.\n\n\"Licensor\" shall mean the copyright owner or entity authorized by the copyright\nowner that is granting the License.\n\n\"Legal Entity\" shall mean the union of the acting entity and all other entities\nthat control, are controlled by, or are under common control with that entity.\nFor the purposes of this definition, \"control\" means (i) the power, direct or\nindirect, to cause the direction or management of such entity, whether by\ncontract or otherwise, or (ii) ownership of fifty percent (50%) or more of the\noutstanding shares, or (iii) beneficial ownership of such entity.\n\n\"You\" (or \"Your\") shall mean an individual or Legal Entity exercising\npermissions granted by this License.\n\n\"Source\" form shall mean the preferred form for making modifications, including\nbut not limited to software source code, documentation source, and configuration\nfiles.\n\n\"Object\" form shall mean any form resulting from mechanical transformation or\ntranslation of a Source form, including but not limited to compiled object code,\ngenerated documentation, and conversions to other media types.\n\n\"Work\" shall mean the work of authorship, whether in Source or Object form, made\navailable under the License, as indicated by a copyright notice that is included\nin or attached to the work (an example is provided in the Appendix below).\n\n\"Derivative Works\" shall mean any work, whether in Source or Object form, that\nis based on (or derived from) the Work and for which the editorial revisions,\nannotations, elaborations, or other modifications represent, as a whole, an\noriginal work of authorship. For the purposes of this License, Derivative Works\nshall not include works that remain separable from, or merely link (or bind by\nname) to the interfaces of, the Work and Derivative Works thereof.\n\n\"Contribution\" shall mean any work of authorship, including the original version\nof the Work and any modifications or additions to that Work or Derivative Works\nthereof, that is intentionally submitted to Licensor for inclusion in the Work\nby the copyright owner or by an individual or Legal Entity authorized to submit\non behalf of the copyright owner. For the purposes of this definition,\n\"submitted\" means any form of electronic, verbal, or written communication sent\nto the Licensor or its representatives, including but not limited to\ncommunication on electronic mailing lists, source code control systems, and\nissue tracking systems that are managed by, or on behalf of, the Licensor for\nthe purpose of discussing and improving the Work, but excluding communication\nthat is conspicuously marked or otherwise designated in writing by the copyright\nowner as \"Not a Contribution.\"\n\n\"Contributor\" shall mean Licensor and any individual or Legal Entity on behalf\nof whom a Contribution has been received by Licensor and subsequently\nincorporated within the Work.\n\n2. Grant of Copyright License.\n\nSubject to the terms and conditions of this License, each Contributor hereby\ngrants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free,\nirrevocable copyright license to reproduce, prepare Derivative Works of,\npublicly display, publicly perform, sublicense, and distribute the Work and such\nDerivative Works in Source or Object form.\n\n3. Grant of Patent License.\n\nSubject to the terms and conditions of this License, each Contributor hereby\ngrants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free,\nirrevocable (except as stated in this section) patent license to make, have\nmade, use, offer to sell, sell, import, and otherwise transfer the Work, where\nsuch license applies only to those patent claims licensable by such Contributor\nthat are necessarily infringed by their Contribution(s) alone or by combination\nof their Contribution(s) with the Work to which such Contribution(s) was\nsubmitted. If You institute patent litigation against any entity (including a\ncross-claim or counterclaim in a lawsuit) alleging that the Work or a\nContribution incorporated within the Work constitutes direct or contributory\npatent infringement, then any patent licenses granted to You under this License\nfor that Work shall terminate as of the date such litigation is filed.\n\n4. Redistribution.\n\nYou may reproduce and distribute copies of the Work or Derivative Works thereof\nin any medium, with or without modifications, and in Source or Object form,\nprovided that You meet the following conditions:\n\nYou must give any other recipients of the Work or Derivative Works a copy of\nthis License; and\nYou must cause any modified files to carry prominent notices stating that You\nchanged the files; and\nYou must retain, in the Source form of any Derivative Works that You distribute,\nall copyright, patent, trademark, and attribution notices from the Source form\nof the Work, excluding those notices that do not pertain to any part of the\nDerivative Works; and\nIf the Work includes a \"NOTICE\" text file as part of its distribution, then any\nDerivative Works that You distribute must include a readable copy of the\nattribution notices contained within such NOTICE file, excluding those notices\nthat do not pertain to any part of the Derivative Works, in at least one of the\nfollowing places: within a NOTICE text file distributed as part of the\nDerivative Works; within the Source form or documentation, if provided along\nwith the Derivative Works; or, within a display generated by the Derivative\nWorks, if and wherever such third-party notices normally appear. The contents of\nthe NOTICE file are for informational purposes only and do not modify the\nLicense. You may add Your own attribution notices within Derivative Works that\nYou distribute, alongside or as an addendum to the NOTICE text from the Work,\nprovided that such additional attribution notices cannot be construed as\nmodifying the License.\nYou may add Your own copyright statement to Your modifications and may provide\nadditional or different license terms and conditions for use, reproduction, or\ndistribution of Your modifications, or for any such Derivative Works as a whole,\nprovided Your use, reproduction, and distribution of the Work otherwise complies\nwith the conditions stated in this License.\n\n5. Submission of Contributions.\n\nUnless You explicitly state otherwise, any Contribution intentionally submitted\nfor inclusion in the Work by You to the Licensor shall be under the terms and\nconditions of this License, without any additional terms or conditions.\nNotwithstanding the above, nothing herein shall supersede or modify the terms of\nany separate license agreement you may have executed with Licensor regarding\nsuch Contributions.\n\n6. Trademarks.\n\nThis License does not grant permission to use the trade names, trademarks,\nservice marks, or product names of the Licensor, except as required for\nreasonable and customary use in describing the origin of the Work and\nreproducing the content of the NOTICE file.\n\n7. Disclaimer of Warranty.\n\nUnless required by applicable law or agreed to in writing, Licensor provides the\nWork (and each Contributor provides its Contributions) on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied,\nincluding, without limitation, any warranties or conditions of TITLE,\nNON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are\nsolely responsible for determining the appropriateness of using or\nredistributing the Work and assume any risks associated with Your exercise of\npermissions under this License.\n\n8. Limitation of Liability.\n\nIn no event and under no legal theory, whether in tort (including negligence),\ncontract, or otherwise, unless required by applicable law (such as deliberate\nand grossly negligent acts) or agreed to in writing, shall any Contributor be\nliable to You for damages, including any direct, indirect, special, incidental,\nor consequential damages of any character arising as a result of this License or\nout of the use or inability to use the Work (including but not limited to\ndamages for loss of goodwill, work stoppage, computer failure or malfunction, or\nany and all other commercial damages or losses), even if such Contributor has\nbeen advised of the possibility of such damages.\n\n9. Accepting Warranty or Additional Liability.\n\nWhile redistributing the Work or Derivative Works thereof, You may choose to\noffer, and charge a fee for, acceptance of support, warranty, indemnity, or\nother liability obligations and/or rights consistent with this License. However,\nin accepting such obligations, You may act only on Your own behalf and on Your\nsole responsibility, not on behalf of any other Contributor, and only if You\nagree to indemnify, defend, and hold each Contributor harmless for any liability\nincurred by, or claims asserted against, such Contributor by reason of your\naccepting any such warranty or additional liability.\n\nEND OF TERMS AND CONDITIONS\n\nAPPENDIX: How to apply the Apache License to your work\n\nTo apply the Apache License to your work, attach the following boilerplate\nnotice, with the fields enclosed by brackets \"[]\" replaced with your own\nidentifying information. (Don't include the brackets!) The text should be\nenclosed in the appropriate comment syntax for the file format. We also\nrecommend that a file or class name and description of purpose be included on\nthe same \"printed page\" as the copyright notice for easier identification within\nthird-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." - }, { "name": "gitea.com/lunny/dingtalk_webhook", "path": "gitea.com/lunny/dingtalk_webhook/LICENSE", @@ -319,21 +314,6 @@ "path": "github.com/coder/websocket/LICENSE.txt", "licenseText": "Copyright (c) 2025 Coder\n\nPermission to use, copy, modify, and distribute this software for any\npurpose with or without fee is hereby granted, provided that the above\ncopyright notice and this permission notice appear in all copies.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\" AND THE AUTHOR DISCLAIMS ALL WARRANTIES\nWITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF\nMERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR\nANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES\nWHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN\nACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF\nOR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.\n" }, - { - "name": "github.com/couchbase/go-couchbase", - "path": "github.com/couchbase/go-couchbase/LICENSE", - "licenseText": "Copyright (c) 2013 Couchbase, Inc.\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\nof the Software, and to permit persons to whom the Software is furnished to do\nso, 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" - }, - { - "name": "github.com/couchbase/gomemcached", - "path": "github.com/couchbase/gomemcached/LICENSE", - "licenseText": "Copyright (c) 2013 Dustin Sallings\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\nall copies 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/couchbase/goutils", - "path": "github.com/couchbase/goutils/LICENSE.txt", - "licenseText": "Source code in this repository is licensed under various licenses. The\nBusiness Source License 1.1 (BSL) is one such license. Each file indicates in\na section at the beginning of the file the name of the license that applies to\nit. All licenses used in this repository can be found in the top-level\nlicenses directory.\n" - }, { "name": "github.com/cpuguy83/go-md2man/v2", "path": "github.com/cpuguy83/go-md2man/v2/LICENSE.md", @@ -514,11 +494,6 @@ "path": "github.com/golang/snappy/LICENSE", "licenseText": "Copyright (c) 2011 The Snappy-Go Authors. All rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are\nmet:\n\n * Redistributions of source code must retain the above copyright\nnotice, this list of conditions and the following disclaimer.\n * Redistributions in binary form must reproduce the above\ncopyright notice, this list of conditions and the following disclaimer\nin the documentation and/or other materials provided with the\ndistribution.\n * Neither the name of Google Inc. nor the names of its\ncontributors may be used to endorse or promote products derived from\nthis software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS\n\"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT\nLIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR\nA PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\nOWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT\nLIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\nDATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\nTHEORY OF 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/google/flatbuffers", - "path": "github.com/google/flatbuffers/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\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/google/go-github/v92", "path": "github.com/google/go-github/v92/LICENSE", diff --git a/cmd/dump.go b/cmd/dump.go index 037e788b28f..eddd9f7c008 100644 --- a/cmd/dump.go +++ b/cmd/dump.go @@ -13,13 +13,11 @@ import ( "gitea.dev/models/db" "gitea.dev/modules/dump" - "gitea.dev/modules/json" "gitea.dev/modules/log" "gitea.dev/modules/setting" "gitea.dev/modules/storage" "gitea.dev/modules/util" - "gitea.com/go-chi/session" "github.com/urfave/cli/v3" ) @@ -249,12 +247,8 @@ func runDump(ctx context.Context, cmd *cli.Command) error { log.Info("Packing data directory...%s", setting.AppDataPath) var excludes []string - if setting.SessionConfig.OriginalProvider == "file" { - var opts session.Options - if err = json.Unmarshal([]byte(setting.SessionConfig.ProviderConfig), &opts); err != nil { - return err - } - excludes = append(excludes, opts.ProviderConfig) + if setting.SessionConfig.Provider == "file" { + excludes = append(excludes, setting.SessionConfig.ProviderConfig) } if cmd.IsSet("skip-index") && cmd.Bool("skip-index") { diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index 61834e85bc9..984ea780ea5 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -459,7 +459,7 @@ INTERNAL_TOKEN = ;LOGIN_REMEMBER_DAYS = 31 ;; ;; Name of cookie used to store authentication information. -;COOKIE_REMEMBER_NAME = gitea_incredible +;COOKIE_REMEMBER_NAME = gitea_remember ;; ;; URL or path that Gitea should redirect users to *after* performing its own logout. ;; Use this, if needed, when authentication is handled by a reverse proxy or SSO. @@ -2012,19 +2012,17 @@ LEVEL = Info ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; -;; Either "memory", "file", "redis", "db", "mysql", "couchbase", "memcache" or "postgres" -;; Default is "file". "db" will reuse the configuration in [database] +;; Either "memory", "file", "redis" or "db", default is "file". "db" will reuse the configuration in [database] ;PROVIDER = file ;; ;; Provider config options ;; memory: doesn't have any config yet ;; file: session file path, e.g. `data/sessions`, relative paths will be made absolute against _`AppWorkPath`_. ;; redis: default to [redis] CONN_STR -;; mysql: go-sql-driver/mysql dsn config string, e.g. `root:password@/session_table` ;PROVIDER_CONFIG = ;; ;; Session cookie name -;COOKIE_NAME = i_like_gitea +;COOKIE_NAME = gitea_session ;; ;; If you use session in https only: true or false. If not set, it defaults to `true` if the ROOT_URL is an HTTPS URL. ;COOKIE_SECURE = diff --git a/go.mod b/go.mod index 50b81f2ce31..e1eda7eb9e0 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,6 @@ toolchain go1.27.1 require ( connectrpc.com/connect v1.21.0 - gitea.com/go-chi/session v0.0.0-20260708011333-ebced8a7a2d6 gitea.com/lunny/dingtalk_webhook v0.0.0-20171025031554-e3534c89ef96 gitea.com/lunny/levelqueue v0.4.2-0.20230414023320-3c0159fe0fe4 gitea.dev/actionslib v1.2.1 @@ -152,9 +151,6 @@ require ( github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/cloudflare/circl v1.6.3 // indirect - github.com/couchbase/go-couchbase v0.1.1 // indirect - github.com/couchbase/gomemcached v0.3.4 // indirect - github.com/couchbase/goutils v0.3.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/cyphar/filepath-securejoin v0.6.1 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect @@ -175,7 +171,6 @@ require ( github.com/golang-sql/sqlexp v0.1.0 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/golang/snappy v1.0.0 // indirect - github.com/google/flatbuffers v25.12.19+incompatible // indirect github.com/google/go-querystring v1.2.0 // indirect github.com/google/go-tpm v0.9.8 // indirect github.com/google/uuid v1.6.0 // indirect diff --git a/go.sum b/go.sum index 71d4e3c9b89..80628ee795a 100644 --- a/go.sum +++ b/go.sum @@ -8,8 +8,6 @@ dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= -gitea.com/go-chi/session v0.0.0-20260708011333-ebced8a7a2d6 h1:YWzVGeC/8SZThrJS48ZmQYLkzssdeABxHPhbdnxPDIU= -gitea.com/go-chi/session v0.0.0-20260708011333-ebced8a7a2d6/go.mod h1:KDvcfMUoXfATPHs2mbMoXFTXT45/FAFAS39waz9tPk0= gitea.com/lunny/dingtalk_webhook v0.0.0-20171025031554-e3534c89ef96 h1:+wWBi6Qfruqu7xJgjOIrKVQGiLUZdpKYCZewJ4clqhw= gitea.com/lunny/dingtalk_webhook v0.0.0-20171025031554-e3534c89ef96/go.mod h1:VyMQP6ue6MKHM8UsOXfNfuMKD0oSAWZdXVcpHIN2yaY= gitea.com/lunny/levelqueue v0.4.2-0.20230414023320-3c0159fe0fe4 h1:IFT+hup2xejHqdhS7keYWioqfmxdnfblFDTGoOwcZ+o= @@ -167,13 +165,6 @@ github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= -github.com/couchbase/go-couchbase v0.1.1 h1:ClFXELcKj/ojyoTYbsY34QUrrYCBi/1G749sXSCkdhk= -github.com/couchbase/go-couchbase v0.1.1/go.mod h1:+/bddYDxXsf9qt0xpDUtRR47A2GjaXmGGAqQ/k3GJ8A= -github.com/couchbase/gomemcached v0.3.4 h1:VGdrZUJbt5lLyI/MXnyVCZKHKYXg/vaud08lJIAeZps= -github.com/couchbase/gomemcached v0.3.4/go.mod h1:pISAjweI42vljCumsJIo7CVhqIMIIP9g3Wfhl1JJw68= -github.com/couchbase/goutils v0.1.2/go.mod h1:h89Ek/tiOxxqjz30nPPlwZdQbdB8BwgnuBxeoUe/ViE= -github.com/couchbase/goutils v0.3.0 h1:rsv72B6BDjW9jmwlfiDUrdu3EpNvPuo5WLULHzQ0DLE= -github.com/couchbase/goutils v0.3.0/go.mod h1:7Gm+D3vXfV4HS+hQWvKfy6e6ILCptGXNqBKvQXhplhk= github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= @@ -308,9 +299,6 @@ github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/gomodule/redigo v1.9.3 h1:dNPSXeXv6HCq2jdyWfjgmhBdqnR6PRO3m/G05nvpPC8= github.com/gomodule/redigo v1.9.3/go.mod h1:KsU3hiK/Ay8U42qpaJk+kuNa3C+spxapWpM+ywhcgtw= -github.com/google/flatbuffers v24.3.25+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= -github.com/google/flatbuffers v25.12.19+incompatible h1:haMV2JRRJCe1998HeW/p0X9UaMTK6SDo0ffLn2+DbLs= -github.com/google/flatbuffers v25.12.19+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= @@ -337,8 +325,6 @@ github.com/google/pprof v0.0.0-20260906184651-6331bc6350fe h1:QAinXoAFJdGQYztXn3 github.com/google/pprof v0.0.0-20260906184651-6331bc6350fe/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e h1:JKmoR8x90Iww1ks85zJ1lfDGgIiMDuIptTOhJq+zKyg= -github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8= github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= @@ -396,8 +382,6 @@ github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7X github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= -github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY= @@ -571,10 +555,6 @@ github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBB github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q= github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg= github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow= -github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304 h1:Jpy1PXuP99tXNrhbq2BaPz9B+jNAvH1JPQQpG/9GCXY= -github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= -github.com/smartystreets/goconvey v0.0.0-20190731233626-505e41936337 h1:WN9BUFbdyOsSH/XohnWpXOlq9NBD5sGAB2FciQMUEe8= -github.com/smartystreets/goconvey v0.0.0-20190731233626-505e41936337/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= github.com/sorairolake/lzip-go v0.3.8 h1:j5Q2313INdTA80ureWYRhX+1K78mUXfMoPZCw/ivWik= github.com/sorairolake/lzip-go v0.3.8/go.mod h1:JcBqGMV0frlxwrsE9sMWXDjqn3EeVf0/54YPsw66qkU= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= @@ -598,7 +578,6 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= @@ -672,10 +651,6 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= -golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= -golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= -golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= @@ -684,10 +659,6 @@ golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ= golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -698,12 +669,7 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= -golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= -golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98= @@ -713,11 +679,6 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= -golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -741,22 +702,11 @@ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= -golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= -golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= -golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= -golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= -golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -764,11 +714,6 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= @@ -777,9 +722,6 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/models/auth/session.go b/models/auth/session.go index 6de69a61308..3fbfceace9f 100644 --- a/models/auth/session.go +++ b/models/auth/session.go @@ -5,7 +5,6 @@ package auth import ( "context" - "fmt" "gitea.dev/models/db" "gitea.dev/modules/timeutil" @@ -13,49 +12,46 @@ import ( "xorm.io/builder" ) -// Session represents a session compatible for go-chi session type Session struct { - Key string `xorm:"pk CHAR(16)"` // has to be Key to match with go-chi/session - Data []byte `xorm:"BLOB"` // on MySQL this has a maximum size of 64Kb - this may need to be increased - Expiry timeutil.TimeStamp // has to be Expiry to match with go-chi/session + Key string `xorm:"pk CHAR(16)"` // the limit is from legacy go-chi/session + Data []byte `xorm:"BLOB"` // on MySQL this has a maximum size of 64Kb + LastAccessTime timeutil.TimeStamp `xorm:"expiry"` // last access time, the field name is from legacy go-chi/session, we don't want to change it at the moment } +const DbSessionLastAccessTime = "expiry" // maybe we can make a deeper clean up in the future, just keep this PR focused + func init() { db.RegisterModel(new(Session)) } -// UpdateSession updates the session with provided id -func UpdateSession(ctx context.Context, key string, data []byte) error { - _, err := db.GetEngine(ctx).ID(key).Update(&Session{ - Data: data, - Expiry: timeutil.TimeStampNow(), - }) +// UpdateSession stores the data of the session with provided id, creating the session only if create is set +func UpdateSession(ctx context.Context, key string, data []byte, create bool) error { + session := &Session{Key: key, Data: data, LastAccessTime: timeutil.TimeStampNow()} + update := func() (int64, error) { + return db.GetEngine(ctx).ID(key).Cols("data", DbSessionLastAccessTime).Update(session) + } + if updated, err := update(); err != nil || updated > 0 || !create { + return err + } + insertErr := db.Insert(ctx, session) + if insertErr == nil { + return nil + } + // the row exists if a concurrent request inserted it, or if MySQL reported an unchanged row as not updated + if exist, err := db.Exist[Session](ctx, builder.Eq{"`key`": key}); err != nil || !exist { + return insertErr + } + _, err := update() return err } -// ReadSession reads the data for the provided session -func ReadSession(ctx context.Context, key string) (*Session, error) { - return db.WithTx2(ctx, func(ctx context.Context) (*Session, error) { - session, exist, err := db.Get[Session](ctx, builder.Eq{"`key`": key}) - if err != nil { - return nil, err - } else if !exist { - session = &Session{ - Key: key, - Expiry: timeutil.TimeStampNow(), - } - if err := db.Insert(ctx, session); err != nil { - return nil, err - } - } - - return session, nil - }) +func UpdateSessionLastAccessTime(ctx context.Context, key string) error { + _, err := db.GetEngine(ctx).ID(key).Cols(DbSessionLastAccessTime).Update(&Session{LastAccessTime: timeutil.TimeStampNow()}) + return err } -// ExistSession checks if a session exists -func ExistSession(ctx context.Context, key string) (bool, error) { - return db.Exist[Session](ctx, builder.Eq{"`key`": key}) +func GetSession(ctx context.Context, key string) (*Session, bool, error) { + return db.Get[Session](ctx, builder.Eq{"`key`": key}) } // DestroySession destroys a session @@ -66,47 +62,8 @@ func DestroySession(ctx context.Context, key string) error { return err } -// RegenerateSession regenerates a session from the old id -func RegenerateSession(ctx context.Context, oldKey, newKey string) (*Session, error) { - return db.WithTx2(ctx, func(ctx context.Context) (*Session, error) { - if has, err := db.Exist[Session](ctx, builder.Eq{"`key`": newKey}); err != nil { - return nil, err - } else if has { - return nil, fmt.Errorf("session Key: %s already exists", newKey) - } - - if has, err := db.Exist[Session](ctx, builder.Eq{"`key`": oldKey}); err != nil { - return nil, err - } else if !has { - if err := db.Insert(ctx, &Session{ - Key: oldKey, - Expiry: timeutil.TimeStampNow(), - }); err != nil { - return nil, err - } - } - - if _, err := db.Exec(ctx, "UPDATE `session` SET `key` = ? WHERE `key`=?", newKey, oldKey); err != nil { - return nil, err - } - - s, _, err := db.Get[Session](ctx, builder.Eq{"`key`": newKey}) - if err != nil { - // is not exist, it should be impossible - return nil, err - } - - return s, nil - }) -} - -// CountSessions returns the number of sessions -func CountSessions(ctx context.Context) (int64, error) { - return db.GetEngine(ctx).Count(&Session{}) -} - // CleanupSessions cleans up expired sessions func CleanupSessions(ctx context.Context, maxLifetime int64) error { - _, err := db.GetEngine(ctx).Where("expiry <= ?", timeutil.TimeStampNow().Add(-maxLifetime)).Delete(&Session{}) + _, err := db.GetEngine(ctx).Where(DbSessionLastAccessTime+" <= ?", timeutil.TimeStampNow().Add(-maxLifetime)).Delete(&Session{}) return err } diff --git a/modules/session/db.go b/modules/session/db.go index 748c7ec6f78..51a371ec78f 100644 --- a/modules/session/db.go +++ b/modules/session/db.go @@ -5,172 +5,38 @@ package session import ( "context" - "fmt" - "log" - "sync" "gitea.dev/models/auth" + "gitea.dev/modules/log" "gitea.dev/modules/timeutil" - - "gitea.com/go-chi/session" ) -// DBStore represents a session store implementation based on the DB. -type DBStore struct { - sid string - lock sync.RWMutex - data map[any]any +type dbBackend struct { + maxLifetime int64 } func dbContext() context.Context { return context.Background() } -// NewDBStore creates and returns a DB session store. -func NewDBStore(sid string, kv map[any]any) *DBStore { - return &DBStore{ - sid: sid, - data: kv, - } -} - -// Set sets value to given key in session. -func (s *DBStore) Set(key, val any) error { - s.lock.Lock() - defer s.lock.Unlock() - - s.data[key] = val - return nil -} - -// Get gets value by given key in session. -func (s *DBStore) Get(key any) any { - s.lock.RLock() - defer s.lock.RUnlock() - - return s.data[key] -} - -// Delete delete a key from session. -func (s *DBStore) Delete(key any) error { - s.lock.Lock() - defer s.lock.Unlock() - - delete(s.data, key) - return nil -} - -// ID returns current session ID. -func (s *DBStore) ID() string { - return s.sid -} - -// Release releases resource and save data to provider. -func (s *DBStore) Release() error { - // Skip encoding if the data is empty - if len(s.data) == 0 { - return nil - } - - data, err := session.EncodeGob(s.data) - if err != nil { - return err - } - - return auth.UpdateSession(dbContext(), s.sid, data) -} - -// Flush deletes all session data. -func (s *DBStore) Flush() error { - s.lock.Lock() - defer s.lock.Unlock() - - s.data = make(map[any]any) - return nil -} - -// DBProvider represents a DB session provider implementation. -type DBProvider struct { - maxLifetime int64 -} - -// Init initializes DB session provider. -// connStr: username:password@protocol(address)/dbname?param=value -func (p *DBProvider) Init(maxLifetime int64, connStr string) error { - p.maxLifetime = maxLifetime - return nil -} - -// Read returns raw session store by session ID. -func (p *DBProvider) Read(sid string) (session.RawStore, error) { - s, err := auth.ReadSession(dbContext(), sid) - if err != nil { +func (b *dbBackend) load(sid string) ([]byte, error) { + sess, exist, err := auth.GetSession(dbContext(), sid) + if err != nil || !exist || sess.LastAccessTime.Add(b.maxLifetime) <= timeutil.TimeStampNow() { return nil, err } - - var kv map[any]any - if len(s.Data) == 0 || s.Expiry.Add(p.maxLifetime) <= timeutil.TimeStampNow() { - kv = make(map[any]any) - } else { - kv, err = session.DecodeGob(s.Data) - if err != nil { - return nil, err - } - } - - return NewDBStore(sid, kv), nil + return sess.Data, auth.UpdateSessionLastAccessTime(dbContext(), sid) } -// Exist returns true if session with given ID exists. -func (p *DBProvider) Exist(sid string) (bool, error) { - has, err := auth.ExistSession(dbContext(), sid) - if err != nil { - return false, fmt.Errorf("session/DB: error checking existence: %w", err) - } - return has, nil +func (b *dbBackend) save(sid string, data []byte, create bool) error { + return auth.UpdateSession(dbContext(), sid, data, create) } -// Destroy deletes a session by session ID. -func (p *DBProvider) Destroy(sid string) error { +func (b *dbBackend) destroy(sid string) error { return auth.DestroySession(dbContext(), sid) } -// Regenerate regenerates a session store from old session ID to new one. -func (p *DBProvider) Regenerate(oldsid, sid string) (_ session.RawStore, err error) { - s, err := auth.RegenerateSession(dbContext(), oldsid, sid) - if err != nil { - return nil, err - } - - var kv map[any]any - if len(s.Data) == 0 || s.Expiry.Add(p.maxLifetime) <= timeutil.TimeStampNow() { - kv = make(map[any]any) - } else { - kv, err = session.DecodeGob(s.Data) - if err != nil { - return nil, err - } - } - - return NewDBStore(sid, kv), nil -} - -// Count counts and returns number of sessions. -func (p *DBProvider) Count() (int, error) { - total, err := auth.CountSessions(dbContext()) - if err != nil { - return 0, fmt.Errorf("session/DB: error counting records: %w", err) - } - return int(total), nil -} - -// GC calls GC to clean expired sessions. -func (p *DBProvider) GC() { - if err := auth.CleanupSessions(dbContext(), p.maxLifetime); err != nil { - log.Printf("session/DB: error garbage collecting: %v", err) +func (b *dbBackend) gc() { + if err := auth.CleanupSessions(dbContext(), b.maxLifetime); err != nil { + log.Error("Unable to garbage collect sessions: %v", err) } } - -func init() { - session.Register("db", &DBProvider{}) -} diff --git a/modules/session/file.go b/modules/session/file.go new file mode 100644 index 00000000000..39d7d0ea5df --- /dev/null +++ b/modules/session/file.go @@ -0,0 +1,115 @@ +// Copyright 2013 Beego Authors +// Copyright 2014 The Macaron Authors +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package session + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "sync" + "time" + + "gitea.dev/modules/log" + "gitea.dev/modules/util" +) + +type fileBackend struct { + lock sync.RWMutex // exclusive for removals, so they never interleave with a load or save + rootPath string + maxLifetime time.Duration +} + +func newFileBackend(rootPath string, maxLifetime int64) *fileBackend { + return &fileBackend{rootPath: filepath.Clean(rootPath), maxLifetime: time.Duration(maxLifetime) * time.Second} +} + +func (b *fileBackend) filepath(sid string) string { + return filepath.Join(b.rootPath, sid[0:1], sid[1:2], sid) +} + +func ignoreNotExist(err error) error { + if errors.Is(err, fs.ErrNotExist) { + return nil + } + return err +} + +func (b *fileBackend) load(sid string) ([]byte, error) { + b.lock.RLock() + defer b.lock.RUnlock() + filename := b.filepath(sid) + stat, err := os.Lstat(filename) + if err != nil { + return nil, ignoreNotExist(err) + } + if !stat.Mode().IsRegular() { + return nil, fmt.Errorf("session file %s is not a regular file", filename) + } + if time.Since(stat.ModTime()) > b.maxLifetime { + return nil, nil + } + data, err := os.ReadFile(filename) + if err == nil { + now := time.Now() + err = os.Chtimes(filename, now, now) + } + return data, ignoreNotExist(err) +} + +func (b *fileBackend) save(sid string, data []byte, create bool) error { + b.lock.RLock() + defer b.lock.RUnlock() + filename := b.filepath(sid) + if create { + if err := os.MkdirAll(filepath.Dir(filename), 0o700); err != nil { + return err + } + } else if _, err := os.Lstat(filename); err != nil { + return ignoreNotExist(err) + } + tmpFile, err := os.CreateTemp(filepath.Dir(filename), sid+".*.tmp") + if err != nil { + return err + } + _, err = tmpFile.Write(data) + if err = errors.Join(err, tmpFile.Close()); err == nil { + err = util.RenameWithRetry(tmpFile.Name(), filename) + } + if err != nil { + _ = os.Remove(tmpFile.Name()) + } + return err +} + +func (b *fileBackend) destroy(sid string) error { + b.lock.Lock() + defer b.lock.Unlock() + return ignoreNotExist(os.Remove(b.filepath(sid))) +} + +func (b *fileBackend) expired(path string) bool { + info, err := os.Lstat(path) + return err == nil && time.Since(info.ModTime()) > b.maxLifetime +} + +func (b *fileBackend) gc() { + err := filepath.WalkDir(b.rootPath, func(path string, entry fs.DirEntry, err error) error { + if err != nil || entry.IsDir() || !b.expired(path) { + return ignoreNotExist(err) + } + b.lock.Lock() + defer b.lock.Unlock() + if b.expired(path) { // a concurrent load may have refreshed it + err = os.Remove(path) + } + return ignoreNotExist(err) + }) + if err != nil { + log.Error("Unable to garbage collect session files: %v", err) + } +} diff --git a/modules/session/main_test.go b/modules/session/main_test.go new file mode 100644 index 00000000000..52401f2c1f2 --- /dev/null +++ b/modules/session/main_test.go @@ -0,0 +1,16 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package session + +import ( + "testing" + + "gitea.dev/models/unittest" + + _ "gitea.dev/models" +) + +func TestMain(m *testing.M) { + unittest.MainTest(m, &unittest.TestOptions{FixtureFiles: []string{}}) +} diff --git a/modules/session/mem.go b/modules/session/mem.go index bb807bc91a1..2fe9fca6a9b 100644 --- a/modules/session/mem.go +++ b/modules/session/mem.go @@ -4,65 +4,113 @@ package session import ( - "bytes" - "encoding/gob" + "maps" "net/http" + "sync" + "time" - "gitea.com/go-chi/session" + "gitea.dev/modules/util" ) -type mockMemRawStore struct { - s *session.MemStore +type memoryBackend struct { + lock sync.Mutex + maxLifetime time.Duration + sessions map[string]memorySession } -var _ session.RawStore = (*mockMemRawStore)(nil) +type memorySession struct { + data []byte + accessed time.Time +} -func (m *mockMemRawStore) Set(k, v any) error { - // We need to use gob to encode the value, to make it have the same behavior as other stores and catch abuses. - // Because gob needs to "Register" the type before it can encode it, and it's unable to decode a struct to "any" so use a map to help to decode the value. - var buf bytes.Buffer - if err := gob.NewEncoder(&buf).Encode(map[string]any{"v": v}); err != nil { - return err +func newMemoryBackend(maxLifetime int64) *memoryBackend { + return &memoryBackend{maxLifetime: time.Duration(maxLifetime) * time.Second, sessions: map[string]memorySession{}} +} + +func (b *memoryBackend) expired(sess memorySession) bool { + return time.Since(sess.accessed) > b.maxLifetime +} + +func (b *memoryBackend) load(sid string) ([]byte, error) { + b.lock.Lock() + defer b.lock.Unlock() + sess, ok := b.sessions[sid] + if !ok || b.expired(sess) { + return nil, nil } - return m.s.Set(k, buf.Bytes()) + sess.accessed = time.Now() + b.sessions[sid] = sess + return sess.data, nil } -func (m *mockMemRawStore) Get(k any) (ret any) { - v, ok := m.s.Get(k).([]byte) - if !ok { - return nil +func (b *memoryBackend) save(sid string, data []byte, create bool) error { + b.lock.Lock() + defer b.lock.Unlock() + if _, exists := b.sessions[sid]; exists || create { + b.sessions[sid] = memorySession{data: data, accessed: time.Now()} } - var w map[string]any - _ = gob.NewDecoder(bytes.NewBuffer(v)).Decode(&w) - return w["v"] + return nil } -func (m *mockMemRawStore) Delete(k any) error { - return m.s.Delete(k) +func (b *memoryBackend) destroy(sid string) error { + b.lock.Lock() + defer b.lock.Unlock() + delete(b.sessions, sid) + return nil } -func (m *mockMemRawStore) ID() string { - return m.s.ID() -} - -func (m *mockMemRawStore) Release() error { - return m.s.Release() -} - -func (m *mockMemRawStore) Flush() error { - return m.s.Flush() +func (b *memoryBackend) gc() { + b.lock.Lock() + defer b.lock.Unlock() + maps.DeleteFunc(b.sessions, func(_ string, sess memorySession) bool { return b.expired(sess) }) } type mockMemStore struct { - *mockMemRawStore + sid string + data map[any][]byte } var _ Store = (*mockMemStore)(nil) -func (m mockMemStore) Destroy(writer http.ResponseWriter, request *http.Request) error { +// NewMockMemStore returns a store encoding each value like the real backends do, to catch values that can't be stored +func NewMockMemStore(sid string) Store { + return &mockMemStore{sid: sid, data: map[any][]byte{}} +} + +func (m *mockMemStore) Set(key, value any) error { + encoded, err := util.PackData(map[any]any{key: value}) + if err == nil { + m.data[key] = encoded + } + return err +} + +func (m *mockMemStore) Get(key any) any { + var decoded map[any]any + _ = util.UnpackData(m.data[key], &decoded) + return decoded[key] +} + +func (m *mockMemStore) Delete(key any) error { + delete(m.data, key) return nil } -func NewMockMemStore(sid string) Store { - return &mockMemStore{&mockMemRawStore{session.NewMemStore(sid)}} +func (m *mockMemStore) ID() string { + return m.sid } + +func (m *mockMemStore) Release() error { + return nil +} + +func (m *mockMemStore) Flush() error { + clear(m.data) + return nil +} + +func (m *mockMemStore) Destroy(http.ResponseWriter, *http.Request) error { + return nil +} + +func (m *mockMemStore) Regenerate(http.ResponseWriter, *http.Request) {} diff --git a/modules/session/redis.go b/modules/session/redis.go index e04debb5882..d9533f34396 100644 --- a/modules/session/redis.go +++ b/modules/session/redis.go @@ -6,214 +6,58 @@ package session import ( - "fmt" - "sync" + "errors" "time" "gitea.dev/modules/graceful" "gitea.dev/modules/nosql" - "gitea.com/go-chi/session" "github.com/redis/go-redis/v9" ) -// RedisStore represents a redis session store implementation. -type RedisStore struct { - c redis.UniversalClient - prefix, sid string - duration time.Duration - lock sync.RWMutex - data map[any]any +type redisBackend struct { + client redis.UniversalClient + prefix string + maxLifetime time.Duration } -// NewRedisStore creates and returns a redis session store. -func NewRedisStore(c redis.UniversalClient, prefix, sid string, dur time.Duration, kv map[any]any) *RedisStore { - return &RedisStore{ - c: c, - prefix: prefix, - sid: sid, - duration: dur, - data: kv, +func newRedisBackend(config string, maxLifetime int64) (*redisBackend, error) { + uri := nosql.ToRedisURI(config) + b := &redisBackend{ + client: nosql.GetManager().GetRedisClient(uri.String()), + prefix: uri.Query().Get("prefix"), + maxLifetime: time.Duration(maxLifetime) * time.Second, } + return b, b.client.Ping(graceful.GetManager().ShutdownContext()).Err() } -// Set sets value to given key in session. -func (s *RedisStore) Set(key, val any) error { - s.lock.Lock() - defer s.lock.Unlock() - - s.data[key] = val - return nil -} - -// Get gets value by given key in session. -func (s *RedisStore) Get(key any) any { - s.lock.RLock() - defer s.lock.RUnlock() - - return s.data[key] -} - -// Delete delete a key from session. -func (s *RedisStore) Delete(key any) error { - s.lock.Lock() - defer s.lock.Unlock() - - delete(s.data, key) - return nil -} - -// ID returns current session ID. -func (s *RedisStore) ID() string { - return s.sid -} - -// Release releases resource and save data to provider. -func (s *RedisStore) Release() error { - // Skip encoding if the data is empty - if len(s.data) == 0 { +func (b *redisBackend) load(sid string) ([]byte, error) { + ctx := graceful.GetManager().HammerContext() + var get *redis.StringCmd + _, err := b.client.Pipelined(ctx, func(pipe redis.Pipeliner) error { + get = pipe.Get(ctx, b.prefix+sid) + pipe.Expire(ctx, b.prefix+sid, b.maxLifetime) return nil + }) + if errors.Is(err, redis.Nil) { + return nil, nil } - - data, err := session.EncodeGob(s.data) - if err != nil { - return err - } - - return s.c.Set(graceful.GetManager().HammerContext(), s.prefix+s.sid, string(data), s.duration).Err() -} - -// Flush deletes all session data. -func (s *RedisStore) Flush() error { - s.lock.Lock() - defer s.lock.Unlock() - - s.data = make(map[any]any) - return nil -} - -// RedisProvider represents a redis session provider implementation. -type RedisProvider struct { - c redis.UniversalClient - duration time.Duration - prefix string -} - -// Init initializes redis session provider. -// configs: network=tcp,addr=:6379,password=macaron,db=0,pool_size=100,idle_timeout=180,prefix=session; -func (p *RedisProvider) Init(maxlifetime int64, configs string) (err error) { - p.duration, err = time.ParseDuration(fmt.Sprintf("%ds", maxlifetime)) - if err != nil { - return err - } - - uri := nosql.ToRedisURI(configs) - - for k, v := range uri.Query() { - switch k { - case "prefix": - p.prefix = v[0] - } - } - - p.c = nosql.GetManager().GetRedisClient(uri.String()) - return p.c.Ping(graceful.GetManager().ShutdownContext()).Err() -} - -// Read returns raw session store by session ID. -func (p *RedisProvider) Read(sid string) (session.RawStore, error) { - psid := p.prefix + sid - if exist, err := p.Exist(sid); err == nil && !exist { - if err := p.c.Set(graceful.GetManager().HammerContext(), psid, "", p.duration).Err(); err != nil { - return nil, err - } - } else if err != nil { - return nil, err - } - - var kv map[any]any - kvs, err := p.c.Get(graceful.GetManager().HammerContext(), psid).Result() if err != nil { return nil, err } - if len(kvs) == 0 { - kv = make(map[any]any) - } else { - kv, err = session.DecodeGob([]byte(kvs)) - if err != nil { - return nil, err - } - } - - return NewRedisStore(p.c, p.prefix, sid, p.duration, kv), nil + return get.Bytes() } -// Exist returns true if session with given ID exists. -func (p *RedisProvider) Exist(sid string) (bool, error) { - v, err := p.c.Exists(graceful.GetManager().HammerContext(), p.prefix+sid).Result() - return err == nil && v == 1, err +func (b *redisBackend) save(sid string, data []byte, create bool) error { + ctx := graceful.GetManager().HammerContext() + if create { + return b.client.Set(ctx, b.prefix+sid, data, b.maxLifetime).Err() + } + return b.client.SetXX(ctx, b.prefix+sid, data, b.maxLifetime).Err() } -// Destroy deletes a session by session ID. -func (p *RedisProvider) Destroy(sid string) error { - return p.c.Del(graceful.GetManager().HammerContext(), p.prefix+sid).Err() +func (b *redisBackend) destroy(sid string) error { + return b.client.Del(graceful.GetManager().HammerContext(), b.prefix+sid).Err() } -// Regenerate regenerates a session store from old session ID to new one. -func (p *RedisProvider) Regenerate(oldsid, sid string) (_ session.RawStore, err error) { - poldsid := p.prefix + oldsid - psid := p.prefix + sid - - if exist, err := p.Exist(sid); err != nil { - return nil, err - } else if exist { - return nil, fmt.Errorf("new sid '%s' already exists", sid) - } - if exist, err := p.Exist(oldsid); err == nil && !exist { - // Make a fake old session. - if err := p.c.Set(graceful.GetManager().HammerContext(), poldsid, "", p.duration).Err(); err != nil { - return nil, err - } - } else if err != nil { - return nil, err - } - - // do not use Rename here, because the old sid and new sid may be in different redis cluster slot. - kvs, err := p.c.Get(graceful.GetManager().HammerContext(), poldsid).Result() - if err != nil { - return nil, err - } - - if err = p.c.Del(graceful.GetManager().HammerContext(), poldsid).Err(); err != nil { - return nil, err - } - - if err = p.c.Set(graceful.GetManager().HammerContext(), psid, kvs, p.duration).Err(); err != nil { - return nil, err - } - - var kv map[any]any - if len(kvs) == 0 { - kv = make(map[any]any) - } else { - kv, err = session.DecodeGob([]byte(kvs)) - if err != nil { - return nil, err - } - } - - return NewRedisStore(p.c, p.prefix, sid, p.duration, kv), nil -} - -// Count counts and returns number of sessions. -func (p *RedisProvider) Count() (int, error) { - size, err := p.c.DBSize(graceful.GetManager().HammerContext()).Result() - return int(size), err -} - -// GC calls GC to clean expired sessions. -func (*RedisProvider) GC() {} - -func init() { - session.Register("redis", &RedisProvider{}) -} +func (*redisBackend) gc() {} diff --git a/modules/session/session.go b/modules/session/session.go new file mode 100644 index 00000000000..30541e73da7 --- /dev/null +++ b/modules/session/session.go @@ -0,0 +1,146 @@ +// Copyright 2013 Beego Authors +// Copyright 2014 The Macaron Authors +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package session + +import ( + "context" + "encoding/gob" + "fmt" + "net/http" + "time" + + "gitea.dev/modules/graceful" + "gitea.dev/modules/log" + "gitea.dev/modules/setting" + "gitea.dev/modules/util" +) + +type backend interface { + load(sid string) ([]byte, error) // returns nil for missing or expired sessions, refreshes the expiry of others + save(sid string, data []byte, create bool) error // without create, only an existing session is updated + destroy(sid string) error + gc() +} + +// CHI-SESSION-GOB-REGISTER: packages must gob.Register the types they store at startup, so data stored before a restart still decodes +func init() { + gob.Register([]any{}) + gob.Register(map[int]any{}) + gob.Register(map[string]any{}) + gob.Register(map[any]any{}) + gob.Register(map[string]string{}) + gob.Register(map[int]string{}) + gob.Register(map[int]int{}) + gob.Register(map[int]int64{}) +} + +func newBackend(provider, config string, maxLifetime int64) (backend, error) { + switch provider { + case "memory": + return newMemoryBackend(maxLifetime), nil + case "file": + return newFileBackend(config, maxLifetime), nil + case "redis": + return newRedisBackend(config, maxLifetime) + case "db": + return &dbBackend{maxLifetime: maxLifetime}, nil + } + return nil, fmt.Errorf(`unsupported [session] PROVIDER %q, supported are "memory", "file", "redis" and "db", use "db" or "redis" to replace the removed "mysql", "postgres", "couchbase" and "memcache" providers`, provider) +} + +func Sessioner() (func(next http.Handler) http.Handler, error) { + backend, err := newBackend(setting.SessionConfig.Provider, setting.SessionConfig.ProviderConfig, setting.SessionConfig.Maxlifetime) + if err != nil { + return nil, err + } + go runGC(graceful.GetManager().ShutdownContext(), backend, time.Duration(setting.SessionConfig.Gclifetime)*time.Second) + return func(next http.Handler) http.Handler { + return sessionHandler(backend, next) + }, nil +} + +func sessionHandler(backend backend, next http.Handler) http.Handler { + return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) { + sess, err := startSession(backend, resp, req) + if err != nil { + log.Error("Unable to start session: %v", err) + resp.WriteHeader(http.StatusInternalServerError) + return + } + next.ServeHTTP(resp, req.WithContext(context.WithValue(req.Context(), ContextKey, sess))) + if err := sess.Release(); err != nil { + log.Error("Unable to release session: %v", err) + } + }) +} + +func startSession(backend backend, resp http.ResponseWriter, req *http.Request) (*store, error) { + sess := &store{backend: backend, resp: resp, data: map[any]any{}} + cookie, err := req.Cookie(setting.SessionConfig.CookieName) + if err != nil || !isValidSessionID(cookie.Value) { + sess.sid = newSessionID() + return sess, nil + } + sess.sid, sess.cookieSID = cookie.Value, cookie.Value + encoded, err := backend.load(sess.sid) + if err != nil { + return nil, err + } + if len(encoded) == 0 { + return sess, nil + } + var data map[any]any + if err := util.UnpackData(encoded, &data); err != nil { + log.Error("Unable to decode session data, starting with an empty session: %v", err) + sess.stored, sess.changed = true, true + } else if len(data) > 0 { + sess.data, sess.stored = data, true + } + return sess, nil +} + +func newSessionID() string { + // lower case (in case the file system is case-insensitive) and length=16 (db session's primary key is fixed size 16) + // the entropy is about 36^16 > 80 bits + return util.FastCryptoRandomString(16, "abcdefghijklmnopqrstuvwxyz0123456789") +} + +func isValidSessionID(sid string) bool { + if len(sid) != 16 { // db session has a primary key with fixed size 16 + return false + } + for i := range len(sid) { + c := sid[i] + valid := (c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') + if !valid { + return false + } + } + return true +} + +func newCookie(value string) *http.Cookie { + return &http.Cookie{ + Name: setting.SessionConfig.CookieName, + Value: value, + Path: util.IfZero(setting.SessionConfig.CookiePath, "/"), + Domain: setting.SessionConfig.Domain, + Secure: setting.SessionConfig.Secure, + HttpOnly: true, + SameSite: setting.SessionConfig.SameSite, + } +} + +func runGC(ctx context.Context, backend backend, interval time.Duration) { + for { + backend.gc() + select { + case <-ctx.Done(): + return + case <-time.After(interval): + } + } +} diff --git a/modules/session/session_test.go b/modules/session/session_test.go new file mode 100644 index 00000000000..2ac0c1fdb54 --- /dev/null +++ b/modules/session/session_test.go @@ -0,0 +1,264 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package session + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + auth_model "gitea.dev/models/auth" + "gitea.dev/models/db" + "gitea.dev/modules/setting" + "gitea.dev/modules/test" + "gitea.dev/modules/timeutil" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type handleFunc func(resp http.ResponseWriter, req *http.Request, sess Store) + +type failingBackend struct { + backend + failDestroy bool +} + +func (b *failingBackend) destroy(sid string) error { + if b.failDestroy { + return errors.New("destroy failed") + } + return b.backend.destroy(sid) +} + +func TestSession(t *testing.T) { + defer test.MockVariableValue(&setting.SessionConfig.CookiePath, "/sub")() + defer test.MockVariableValue(&setting.SessionConfig.Secure, true)() + + _, err := newBackend("mysql", "", 3600) + assert.ErrorContains(t, err, `use "db" or "redis"`) + + t.Run("GCStopsOnShutdown", func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + cancel() + runGC(ctx, newMemoryBackend(3600), time.Hour) + }) + + cases := []struct { + name string + newBackend func(t *testing.T) backend + }{ + { + name: "memory", + newBackend: func(*testing.T) backend { return newMemoryBackend(3600) }, + }, + { + name: "file", + newBackend: func(t *testing.T) backend { return newFileBackend(t.TempDir(), 3600) }, + }, + { + name: "db", + newBackend: func(*testing.T) backend { return &dbBackend{maxLifetime: 3600} }, + }, + { + name: "redis", + newBackend: func(t *testing.T) backend { + backend, err := newRedisBackend(test.PrepareTestRedis(t)+"?prefix=gitea-test-session-", 3600) + require.NoError(t, err) + return backend + }, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + backend := &failingBackend{backend: tc.newBackend(t)} + serve := func(sid string, handle handleFunc) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, "/", nil) + if sid != "" { + req.AddCookie(&http.Cookie{Name: setting.SessionConfig.CookieName, Value: sid}) + } + resp := httptest.NewRecorder() + sessionHandler(backend, http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) { + handle(resp, req, GetContextSession(req)) + })).ServeHTTP(resp, req) + return resp + } + create := func(t *testing.T) string { + cookies := serve("", func(_ http.ResponseWriter, _ *http.Request, sess Store) { + require.NoError(t, sess.Set("key", "value")) + }).Result().Cookies() + require.Len(t, cookies, 1) + return cookies[0].Value + } + get := func(sid string, key any) (value any) { + serve(sid, func(_ http.ResponseWriter, _ *http.Request, sess Store) { value = sess.Get(key) }) + return value + } + + t.Run("CookieOnlyOnceSessionHoldsData", func(t *testing.T) { + assert.Empty(t, serve("", func(http.ResponseWriter, *http.Request, Store) {}).Result().Cookies()) + + resp := serve("../../etc/passwd", func(resp http.ResponseWriter, req *http.Request, sess Store) { + require.NoError(t, sess.Set("key", "value")) + require.NoError(t, sess.Set("other", 1)) + http.Redirect(resp, req, "https://example.com/", http.StatusSeeOther) + }) + cookies := resp.Result().Cookies() + require.Len(t, cookies, 1) + sid := cookies[0].Value + assert.True(t, isValidSessionID(sid)) + assert.Equal(t, "/sub", cookies[0].Path) + assert.True(t, cookies[0].HttpOnly) + assert.True(t, cookies[0].Secure) + assert.Equal(t, http.SameSiteLaxMode, cookies[0].SameSite) + switch sessionBackend := backend.backend.(type) { + case *dbBackend: + now := timeutil.TimeStampNow() + _, err := db.GetEngine(t.Context()).ID(sid).Cols(auth_model.DbSessionLastAccessTime).Update(&auth_model.Session{LastAccessTime: now - 60}) + require.NoError(t, err) + _, err = sessionBackend.load(sid) + require.NoError(t, err) + sess, _, err := auth_model.GetSession(t.Context(), sid) + require.NoError(t, err) + assert.GreaterOrEqual(t, sess.LastAccessTime, now) + case *redisBackend: + require.NoError(t, sessionBackend.client.Expire(t.Context(), "gitea-test-session-"+sid, time.Minute).Err()) + _, err := sessionBackend.load(sid) + require.NoError(t, err) + ttl, err := sessionBackend.client.TTL(t.Context(), "gitea-test-session-"+sid).Result() + require.NoError(t, err) + assert.Greater(t, ttl, time.Minute) + } + + resp = serve(sid, func(_ http.ResponseWriter, _ *http.Request, sess Store) { + assert.Equal(t, sid, sess.ID()) + assert.Equal(t, "value", sess.Get("key")) + require.NoError(t, sess.Set("key", "changed")) + }) + assert.Empty(t, resp.Result().Cookies()) + }) + + t.Run("RegenerateMovesDataToNewID", func(t *testing.T) { + oldSID := create(t) + var newSID string + resp := serve(oldSID, func(resp http.ResponseWriter, req *http.Request, sess Store) { + sess.Regenerate(resp, req) + newSID = sess.ID() + }) + cookies := resp.Result().Cookies() + require.Len(t, cookies, 1) + assert.Equal(t, newSID, cookies[0].Value) + assert.NotEqual(t, oldSID, newSID) + assert.Nil(t, get(oldSID, "key")) + assert.Equal(t, "value", get(newSID, "key")) + + resp = serve("malformed", func(resp http.ResponseWriter, req *http.Request, sess Store) { sess.Regenerate(resp, req) }) + assert.Empty(t, resp.Result().Cookies()) + }) + + t.Run("DestroyIsNotUndoneByRelease", func(t *testing.T) { + sid := create(t) + var resp *httptest.ResponseRecorder + serve(sid, func(_ http.ResponseWriter, _ *http.Request, concurrent Store) { + resp = serve(sid, func(resp http.ResponseWriter, req *http.Request, sess Store) { + require.NoError(t, sess.Flush()) + require.NoError(t, sess.Destroy(resp, req)) + }) + require.NoError(t, concurrent.Set("key", "changed")) + }) + cookies := resp.Result().Cookies() + require.Len(t, cookies, 1) + assert.Equal(t, -1, cookies[0].MaxAge) + assert.Equal(t, "/sub", cookies[0].Path) + assert.True(t, cookies[0].Secure) + encoded, err := backend.load(sid) + require.NoError(t, err) + assert.Nil(t, encoded) + }) + + t.Run("FailedDestroyIsRetriedByRelease", func(t *testing.T) { + sid := create(t) + serve(sid, func(resp http.ResponseWriter, req *http.Request, sess Store) { + backend.failDestroy = true + require.Error(t, sess.Destroy(resp, req)) + backend.failDestroy = false + assert.Nil(t, sess.Get("key")) + }) + encoded, err := backend.load(sid) + require.NoError(t, err) + assert.Nil(t, encoded) + }) + + t.Run("EmptiedSessionIsPersisted", func(t *testing.T) { + for _, empty := range []func(Store) error{Store.Flush, func(sess Store) error { return sess.Delete("key") }} { + sid := create(t) + serve(sid, func(_ http.ResponseWriter, _ *http.Request, sess Store) { require.NoError(t, empty(sess)) }) + assert.Nil(t, get(sid, "key")) + } + }) + + t.Run("UnchangedReleaseKeepsConcurrentChanges", func(t *testing.T) { + sid := create(t) + serve(sid, func(_ http.ResponseWriter, _ *http.Request, reader Store) { + serve(sid, func(_ http.ResponseWriter, _ *http.Request, writer Store) { + require.NoError(t, writer.Set("key", "changed")) + }) + require.NoError(t, reader.Delete("missing")) + }) + assert.Equal(t, "changed", get(sid, "key")) + }) + + t.Run("ConcurrentNewSessionWritesThrough", func(t *testing.T) { + sid := newSessionID() + serve(sid, func(_ http.ResponseWriter, _ *http.Request, first Store) { + serve(sid, func(_ http.ResponseWriter, _ *http.Request, second Store) { + require.NoError(t, second.Set("second", 2)) + }) + require.NoError(t, first.Set("first", 1)) + }) + assert.Equal(t, 1, get(sid, "first")) + }) + + t.Run("UndecodableDataReadsAsEmptySession", func(t *testing.T) { + sid := newSessionID() + require.NoError(t, backend.save(sid, []byte("undecodable"), true)) + resp := serve(sid, func(_ http.ResponseWriter, _ *http.Request, sess Store) { assert.Nil(t, sess.Get("key")) }) + assert.Equal(t, http.StatusOK, resp.Code) + encoded, err := backend.load(sid) + require.NoError(t, err) + assert.Nil(t, encoded) + }) + }) + } +} + +func TestFileBackendWritesAtomicallyAndExpiresByModTime(t *testing.T) { + root := t.TempDir() + backend := newFileBackend(root, 3600) + sid := newSessionID() + filename := filepath.Join(root, sid[0:1], sid[1:2], sid) + require.NoError(t, backend.save(sid, []byte("first"), true)) + require.NoError(t, backend.save(sid, []byte("second"), false)) + + entries, err := os.ReadDir(filepath.Dir(filename)) + require.NoError(t, err) + require.Len(t, entries, 1) + assert.Equal(t, sid, entries[0].Name()) + encoded, err := backend.load(sid) + require.NoError(t, err) + assert.Equal(t, "second", string(encoded)) + + expired := time.Now().Add(-2 * time.Hour) + require.NoError(t, os.Chtimes(filename, expired, expired)) + encoded, err = backend.load(sid) + require.NoError(t, err) + assert.Nil(t, encoded) + backend.gc() + assert.NoFileExists(t, filename) +} diff --git a/modules/session/store.go b/modules/session/store.go index 3befbf49959..a09cf236fee 100644 --- a/modules/session/store.go +++ b/modules/session/store.go @@ -5,43 +5,139 @@ package session import ( "net/http" + "sync" - "gitea.dev/modules/setting" - - "gitea.com/go-chi/session" + "gitea.dev/modules/log" + "gitea.dev/modules/util" ) -type RawStore = session.RawStore - type Store interface { - RawStore + Set(key, value any) error + Get(key any) any + Delete(key any) error + ID() string + Release() error + Flush() error Destroy(http.ResponseWriter, *http.Request) error + Regenerate(http.ResponseWriter, *http.Request) } -type mockStoreContextKeyStruct struct{} +type store struct { + backend backend + resp http.ResponseWriter + lock sync.RWMutex + sid string + cookieSID string // the session ID the client holds + data map[any]any + stored bool // the backend holds data for sid + changed bool +} -var MockStoreContextKey = mockStoreContextKeyStruct{} +type contextKeyStruct struct{} -// RegenerateSession regenerates the underlying session and returns the new store -func RegenerateSession(resp http.ResponseWriter, req *http.Request) (Store, error) { +var ContextKey = contextKeyStruct{} + +func (s *store) Set(key, value any) error { + s.lock.Lock() + defer s.lock.Unlock() + s.data[key] = value + s.changed = true + s.sendCookie(s.resp) + return nil +} + +func (s *store) Get(key any) any { + s.lock.RLock() + defer s.lock.RUnlock() + return s.data[key] +} + +func (s *store) Delete(key any) error { + s.lock.Lock() + defer s.lock.Unlock() + if _, ok := s.data[key]; ok { + delete(s.data, key) + s.changed = true + } + return nil +} + +func (s *store) ID() string { + s.lock.RLock() + defer s.lock.RUnlock() + return s.sid +} + +func (s *store) Flush() error { + s.lock.Lock() + defer s.lock.Unlock() + clear(s.data) + s.changed = true + return nil +} + +func (s *store) Release() error { + s.lock.Lock() + defer s.lock.Unlock() + var err error + switch { + case !s.changed: + return nil + case len(s.data) > 0: + var data []byte + if data, err = util.PackData(s.data); err == nil { + err = s.backend.save(s.sid, data, !s.stored) + } + case s.stored: + err = s.backend.destroy(s.sid) + } + if err == nil { + s.changed, s.stored = false, len(s.data) > 0 + } + return err +} + +func (s *store) Destroy(resp http.ResponseWriter, _ *http.Request) error { + s.lock.Lock() + defer s.lock.Unlock() + err := s.backend.destroy(s.sid) + cookie := newCookie("") + cookie.MaxAge = -1 + http.SetCookie(resp, cookie) + if err == nil { + s.sid, s.stored = newSessionID(), false + } + s.cookieSID, s.changed = "", err != nil // Release retries a failed destroy + clear(s.data) + return err +} + +// Regenerate moves the session data to a new session ID, so an ID known before sign-in is never authenticated +func (s *store) Regenerate(resp http.ResponseWriter, req *http.Request) { for _, f := range BeforeRegenerateSession { f(resp, req) } - if setting.IsInTesting { - if store, ok := req.Context().Value(MockStoreContextKey).(Store); ok { - return store, nil + s.lock.Lock() + defer s.lock.Unlock() + if s.stored { + if err := s.backend.destroy(s.sid); err != nil { + log.Error("Unable to destroy the regenerated session: %v", err) } } - return session.RegenerateSession(resp, req) + s.sid, s.stored, s.changed = newSessionID(), false, true + s.sendCookie(resp) +} + +func (s *store) sendCookie(resp http.ResponseWriter) { + if s.cookieSID != s.sid && len(s.data) > 0 { + http.SetCookie(resp, newCookie(s.sid)) + s.cookieSID = s.sid + } } func GetContextSession(req *http.Request) Store { - if setting.IsInTesting { - if store, ok := req.Context().Value(MockStoreContextKey).(Store); ok { - return store - } - } - return session.GetSession(req) + sess, _ := req.Context().Value(ContextKey).(Store) + return sess } // BeforeRegenerateSession is a list of functions that are called before a session is regenerated. diff --git a/modules/session/virtual.go b/modules/session/virtual.go deleted file mode 100644 index c51c5fe7881..00000000000 --- a/modules/session/virtual.go +++ /dev/null @@ -1,202 +0,0 @@ -// Copyright 2019 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package session - -import ( - "fmt" - "sync" - - "gitea.dev/modules/json" - - "gitea.com/go-chi/session" - couchbase "gitea.com/go-chi/session/couchbase" - memcache "gitea.com/go-chi/session/memcache" - mysql "gitea.com/go-chi/session/mysql" - postgres "gitea.com/go-chi/session/postgres" -) - -// VirtualSessionProvider represents a shadowed session provider implementation. -type VirtualSessionProvider struct { - lock sync.RWMutex - provider session.Provider -} - -// Init initializes the cookie session provider with the given config. -func (o *VirtualSessionProvider) Init(gcLifetime int64, config string) error { - var opts session.Options - if err := json.Unmarshal([]byte(config), &opts); err != nil { - return err - } - // Note that these options are unprepared so we can't just use NewManager here. - // Nor can we access the provider map in session. - // So we will just have to do this by hand. - // This is only slightly more wrong than modules/setting/session.go:23 - switch opts.Provider { - case "memory": - o.provider = &session.MemProvider{} - case "file": - o.provider = &session.FileProvider{} - case "redis": - o.provider = &RedisProvider{} - case "db": - o.provider = &DBProvider{} - case "mysql": - o.provider = &mysql.MysqlProvider{} - case "postgres": - o.provider = &postgres.PostgresProvider{} - case "couchbase": - o.provider = &couchbase.CouchbaseProvider{} - case "memcache": - o.provider = &memcache.MemcacheProvider{} - default: - return fmt.Errorf("VirtualSessionProvider: Unknown Provider: %s", opts.Provider) - } - return o.provider.Init(gcLifetime, opts.ProviderConfig) -} - -// Read returns raw session store by session ID. -func (o *VirtualSessionProvider) Read(sid string) (session.RawStore, error) { - o.lock.RLock() - defer o.lock.RUnlock() - if exist, err := o.provider.Exist(sid); err == nil && exist { - return o.provider.Read(sid) - } else if err != nil { - return nil, fmt.Errorf("check if '%s' exist failed: %w", sid, err) - } - kv := make(map[any]any) - return NewVirtualStore(o, sid, kv), nil -} - -// Exist returns true if session with given ID exists. -func (o *VirtualSessionProvider) Exist(sid string) (bool, error) { - return true, nil -} - -// Destroy deletes a session by session ID. -func (o *VirtualSessionProvider) Destroy(sid string) error { - o.lock.Lock() - defer o.lock.Unlock() - return o.provider.Destroy(sid) -} - -// Regenerate regenerates a session store from old session ID to new one. -func (o *VirtualSessionProvider) Regenerate(oldsid, sid string) (session.RawStore, error) { - o.lock.Lock() - defer o.lock.Unlock() - return o.provider.Regenerate(oldsid, sid) -} - -// Count counts and returns number of sessions. -func (o *VirtualSessionProvider) Count() (int, error) { - o.lock.RLock() - defer o.lock.RUnlock() - return o.provider.Count() -} - -// GC calls GC to clean expired sessions. -func (o *VirtualSessionProvider) GC() { - o.provider.GC() -} - -func init() { - session.Register("VirtualSession", &VirtualSessionProvider{}) -} - -// VirtualStore represents a virtual session store implementation. -type VirtualStore struct { - p *VirtualSessionProvider - sid string - lock sync.RWMutex - data map[any]any - released bool -} - -// NewVirtualStore creates and returns a virtual session store. -func NewVirtualStore(p *VirtualSessionProvider, sid string, kv map[any]any) *VirtualStore { - return &VirtualStore{ - p: p, - sid: sid, - data: kv, - } -} - -// Set sets value to given key in session. -func (s *VirtualStore) Set(key, val any) error { - s.lock.Lock() - defer s.lock.Unlock() - - s.data[key] = val - return nil -} - -// Get gets value by given key in session. -func (s *VirtualStore) Get(key any) any { - s.lock.RLock() - defer s.lock.RUnlock() - - return s.data[key] -} - -// Delete delete a key from session. -func (s *VirtualStore) Delete(key any) error { - s.lock.Lock() - defer s.lock.Unlock() - - delete(s.data, key) - return nil -} - -// ID returns current session ID. -func (s *VirtualStore) ID() string { - return s.sid -} - -// Release releases resource and save data to provider. -func (s *VirtualStore) Release() error { - s.lock.Lock() - defer s.lock.Unlock() - // Now need to lock the provider - s.p.lock.Lock() - defer s.p.lock.Unlock() - if len(s.data) > 0 { - // Now ensure that we don't exist! - realProvider := s.p.provider - - if !s.released { - if exist, err := realProvider.Exist(s.sid); err == nil && exist { - // This is an error! - return fmt.Errorf("new sid '%s' already exists", s.sid) - } else if err != nil { - return fmt.Errorf("check if '%s' exist failed: %w", s.sid, err) - } - } - realStore, err := realProvider.Read(s.sid) - if err != nil { - return err - } - if err := realStore.Flush(); err != nil { - return err - } - for key, value := range s.data { - if err := realStore.Set(key, value); err != nil { - return err - } - } - err = realStore.Release() - if err == nil { - s.released = true - } - return err - } - return nil -} - -// Flush deletes all session data. -func (s *VirtualStore) Flush() error { - s.lock.Lock() - defer s.lock.Unlock() - - s.data = make(map[any]any) - return nil -} diff --git a/modules/setting/security.go b/modules/setting/security.go index 1c514af5a5c..53a70b14100 100644 --- a/modules/setting/security.go +++ b/modules/setting/security.go @@ -151,7 +151,7 @@ func loadSecurityFrom(rootCfg ConfigProvider) { SecretKey = "!#@FDEWREWR&*(" } - CookieRememberName = sec.Key("COOKIE_REMEMBER_NAME").MustString("gitea_incredible") + CookieRememberName = sec.Key("COOKIE_REMEMBER_NAME").MustString("gitea_remember") ReverseProxyAuthUser = sec.Key("REVERSE_PROXY_AUTHENTICATION_USER").MustString("X-WEBAUTH-USER") ReverseProxyAuthEmail = sec.Key("REVERSE_PROXY_AUTHENTICATION_EMAIL").MustString("X-WEBAUTH-EMAIL") diff --git a/modules/setting/session.go b/modules/setting/session.go index 67f1b7ca6b5..cd41ffb30b7 100644 --- a/modules/setting/session.go +++ b/modules/setting/session.go @@ -4,19 +4,17 @@ package setting import ( + "cmp" "net/http" "path/filepath" "strings" - "gitea.dev/modules/json" - "gitea.dev/modules/log" "gitea.dev/modules/util" ) // SessionConfig defines Session settings var SessionConfig = struct { - OriginalProvider string - Provider string + Provider string // Provider configuration, it's corresponding to provider. ProviderConfig string // Cookie name to save session ID. Default is "MacaronSession". @@ -34,7 +32,8 @@ var SessionConfig = struct { // SameSite declares if your cookie should be restricted to a first-party or same-site context. Valid strings are "none", "lax", "strict". Default is "lax" SameSite http.SameSite }{ - CookieName: "i_like_gitea", + Provider: "memory", // the "Install" page doesn't load the [session] config + CookieName: "gitea_session", Gclifetime: 86400, Maxlifetime: 86400, SameSite: http.SameSiteLaxMode, @@ -42,7 +41,7 @@ var SessionConfig = struct { func loadSessionFrom(rootCfg ConfigProvider) { sec := rootCfg.Section("session") - SessionConfig.Provider = sec.Key("PROVIDER").In("file", []string{"memory", "file", "redis", "mysql", "postgres", "couchbase", "memcache", "db"}) + SessionConfig.Provider = sec.Key("PROVIDER").MustString("file") switch SessionConfig.Provider { case "redis": @@ -59,12 +58,12 @@ func loadSessionFrom(rootCfg ConfigProvider) { SessionConfig.ProviderConfig = sec.Key("PROVIDER_CONFIG").String() } - SessionConfig.CookieName = sec.Key("COOKIE_NAME").MustString("i_like_gitea") - // HINT: INSTALL-PAGE-COOKIE-INIT: the cookie system is not properly initialized on the Install page, so there is no CookiePath + SessionConfig.CookieName = sec.Key("COOKIE_NAME").MustString("gitea_session") + // HINT: INSTALL-PAGE-COOKIE-INIT: the cookie system is not properly initialized on the "Install" page, so there is no CookiePath SessionConfig.CookiePath = util.IfZero(AppSubURL, "/") SessionConfig.Secure = sec.Key("COOKIE_SECURE").MustBool(strings.HasPrefix(strings.ToLower(AppURL), "https://")) - SessionConfig.Gclifetime = sec.Key("GC_INTERVAL_TIME").MustInt64(86400) - SessionConfig.Maxlifetime = sec.Key("SESSION_LIFE_TIME").MustInt64(86400) + SessionConfig.Gclifetime = cmp.Or(max(sec.Key("GC_INTERVAL_TIME").MustInt64(86400), 0), 3600) + SessionConfig.Maxlifetime = cmp.Or(max(sec.Key("SESSION_LIFE_TIME").MustInt64(86400), 0), SessionConfig.Gclifetime) SessionConfig.Domain = sec.Key("DOMAIN").String() samesiteString := sec.Key("SAME_SITE").In("lax", []string{"none", "lax", "strict"}) switch strings.ToLower(samesiteString) { @@ -75,11 +74,4 @@ func loadSessionFrom(rootCfg ConfigProvider) { default: SessionConfig.SameSite = http.SameSiteLaxMode } - shadowConfig, err := json.Marshal(SessionConfig) - if err != nil { - log.Fatal("Can't shadow session config: %v", err) - } - SessionConfig.ProviderConfig = string(shadowConfig) - SessionConfig.OriginalProvider = SessionConfig.Provider - SessionConfig.Provider = "VirtualSession" } diff --git a/modules/setting/session_test.go b/modules/setting/session_test.go index 0549ea11f99..7ce1956a013 100644 --- a/modules/setting/session_test.go +++ b/modules/setting/session_test.go @@ -69,7 +69,6 @@ PROVIDER = file loadRedisFrom(cfg) loadSessionFrom(cfg) - // ProviderConfig is shadowed into a JSON blob at the end of loadSessionFrom assert.Contains(t, SessionConfig.ProviderConfig, tt.wantContain) if tt.wantMissing != "" { assert.NotContains(t, SessionConfig.ProviderConfig, tt.wantMissing) @@ -77,3 +76,14 @@ PROVIDER = file }) } } + +func TestSessionNonPositiveLifetimesUseDefaults(t *testing.T) { + defer test.MockVariableValue(&SessionConfig)() + for _, lifetime := range []string{"0", "-1"} { + cfg, err := NewConfigProviderFromData("[session]\nGC_INTERVAL_TIME = " + lifetime + "\nSESSION_LIFE_TIME = " + lifetime) + assert.NoError(t, err) + loadSessionFrom(cfg) + assert.EqualValues(t, 3600, SessionConfig.Gclifetime) + assert.EqualValues(t, 3600, SessionConfig.Maxlifetime) + } +} diff --git a/modules/util/util.go b/modules/util/util.go index 57c28274a18..443273681fd 100644 --- a/modules/util/util.go +++ b/modules/util/util.go @@ -118,6 +118,16 @@ func FastCryptoRandomBytes(length int) []byte { return buf } +func FastCryptoRandomString(length int, chars string) string { + buf := make([]byte, length) + limit := int64(len(chars)) + for i := range buf { + num := FastCryptoRandomInt(limit) + buf[i] = chars[num] + } + return string(buf) +} + func FastCryptoRandomHex(length int) string { buf := FastCryptoRandomBytes(length / 2) return hex.EncodeToString(buf) diff --git a/routers/common/middleware.go b/routers/common/middleware.go index dcd9ea3ad63..1821c4f6efe 100644 --- a/routers/common/middleware.go +++ b/routers/common/middleware.go @@ -14,11 +14,11 @@ import ( "gitea.dev/modules/log" "gitea.dev/modules/public" "gitea.dev/modules/reqctx" + "gitea.dev/modules/session" "gitea.dev/modules/setting" "gitea.dev/modules/web/routing" "gitea.dev/services/context" - "gitea.com/go-chi/session" "github.com/chi-middleware/proxy" "github.com/go-chi/chi/v5" ) @@ -122,24 +122,7 @@ func ForwardedHeadersHandler(limit int, trustedProxies []string) func(h http.Han } func MustInitSessioner() func(next http.Handler) http.Handler { - // TODO: CHI-SESSION-GOB-REGISTER: chi-session has a design problem: it calls gob.Register for "Set" - // But if the server restarts, then the first "Get" will fail to decode the previously stored session data because the structs are not registered yet. - // So each package should make sure their structs are registered correctly during startup for session storage. - - middleware, err := session.Sessioner(session.Options{ - Provider: setting.SessionConfig.Provider, - ProviderConfig: setting.SessionConfig.ProviderConfig, - CookieName: setting.SessionConfig.CookieName, - CookiePath: setting.SessionConfig.CookiePath, - Gclifetime: setting.SessionConfig.Gclifetime, - Maxlifetime: setting.SessionConfig.Maxlifetime, - Secure: setting.SessionConfig.Secure, - SameSite: setting.SessionConfig.SameSite, - Domain: setting.SessionConfig.Domain, - - // in the future, if websocket is used, the websocket handler should manage its own session sync (release) - IgnoreReleaseForWebSocket: true, - }) + middleware, err := session.Sessioner() if err != nil { log.Fatal("common.Sessioner failed: %v", err) } diff --git a/routers/web/admin/config.go b/routers/web/admin/config.go index 74124b19990..069d75e7f90 100644 --- a/routers/web/admin/config.go +++ b/routers/web/admin/config.go @@ -18,8 +18,6 @@ import ( "gitea.dev/modules/util" "gitea.dev/services/context" "gitea.dev/services/mailer" - - "gitea.com/go-chi/session" ) const ( @@ -93,20 +91,6 @@ func Config(ctx *context.Context) { ctx.Data["CacheItemTTL"] = setting.CacheService.TTL sessionCfg := setting.SessionConfig - if sessionCfg.Provider == "VirtualSession" { - var realSession session.Options - if err := json.Unmarshal([]byte(sessionCfg.ProviderConfig), &realSession); err != nil { - log.Error("Unable to unmarshall session config for virtual provider config: %s\nError: %v", sessionCfg.ProviderConfig, err) - } - sessionCfg.Provider = realSession.Provider - sessionCfg.ProviderConfig = realSession.ProviderConfig - sessionCfg.CookieName = realSession.CookieName - sessionCfg.CookiePath = realSession.CookiePath - sessionCfg.Gclifetime = realSession.Gclifetime - sessionCfg.Maxlifetime = realSession.Maxlifetime - sessionCfg.Secure = realSession.Secure - sessionCfg.Domain = realSession.Domain - } sessionCfg.ProviderConfig = "" ctx.Data["SessionConfig"] = sessionCfg diff --git a/routers/web/auth/auth.go b/routers/web/auth/auth.go index 1f2f94509bc..ae33c1a6c62 100644 --- a/routers/web/auth/auth.go +++ b/routers/web/auth/auth.go @@ -444,7 +444,9 @@ func extractUserNameFromOAuth2(gothUser *goth.User) (string, error) { // HandleSignOut resets the session and sets the cookies func HandleSignOut(ctx *context.Context) { _ = ctx.Session.Flush() - _ = ctx.Session.Destroy(ctx.Resp, ctx.Req) + if err := ctx.Session.Destroy(ctx.Resp, ctx.Req); err != nil { + log.Error("Unable to destroy session: %v", err) + } ctx.DeleteSiteCookie(setting.CookieRememberName) middleware.DeleteRedirectToCookie(ctx.Resp) } @@ -931,10 +933,8 @@ func ActivateEmail(ctx *context.Context) { } func regenerateSession(ctx *context.Context, updates map[string]any) error { - if _, err := session.RegenerateSession(ctx.Resp, ctx.Req); err != nil { - return fmt.Errorf("regenerate session: %w", err) - } sess := ctx.Session + sess.Regenerate(ctx.Resp, ctx.Req) sessID := sess.ID() for k, v := range updates { if err := sess.Set(k, v); err != nil { diff --git a/routers/web/web.go b/routers/web/web.go index 4db0ec1bb14..2cadc573d09 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -49,8 +49,6 @@ import ( "gitea.dev/services/context" "gitea.dev/services/forms" - _ "gitea.dev/modules/session" // to register all internal adapters - chi_middleware "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/cors" "github.com/klauspost/compress/gzhttp" diff --git a/services/auth/auth.go b/services/auth/auth.go index 050ecb0e6f1..0a0faf26540 100644 --- a/services/auth/auth.go +++ b/services/auth/auth.go @@ -40,19 +40,14 @@ func Init() { // handleSignInNonInteractive clears existing session variables and stores new ones for the specified user object // it is mainly for middleware sign-in which doesn't need user's interaction. func handleSignInNonInteractive(resp http.ResponseWriter, req *http.Request, sess SessionStore, user *user_model.User) { - // We need to regenerate the session... - newSess, err := session.RegenerateSession(resp, req) - if err != nil { - log.Error(fmt.Sprintf("Error regenerating session: %v", err)) - } else { - sess = newSess - } - + sess.Regenerate(resp, req) ClearSessionKeysForSignIn(sess) - err = sess.Set(session.KeyUID, user.ID) - if err != nil { + if err := sess.Set(session.KeyUID, user.ID); err != nil { log.Error(fmt.Sprintf("Error setting session: %v", err)) } + if err := sess.Release(); err != nil { // save before a long-lived handler like a websocket runs + log.Error("Error saving session: %v", err) + } opts := &user_service.UpdateOptions{SetLastLogin: true} // Language setting of the user overwrites the one previously set diff --git a/services/auth/reverseproxy_test.go b/services/auth/reverseproxy_test.go index 0f9d7f2746f..53164b1b07c 100644 --- a/services/auth/reverseproxy_test.go +++ b/services/auth/reverseproxy_test.go @@ -34,6 +34,16 @@ func TestReverseProxyIgnoresBot(t *testing.T) { assert.Nil(t, user) } +type releaseCountingStore struct { + session.Store + released int +} + +func (s *releaseCountingStore) Release() error { + s.released++ + return s.Store.Release() +} + func TestReverseProxyLastLogin(t *testing.T) { require.NoError(t, unittest.PrepareTestDatabase()) defer test.MockVariableValue(&setting.ReverseProxyAuthUser, "X-WEBAUTH-USER")() @@ -41,13 +51,15 @@ func TestReverseProxyLastLogin(t *testing.T) { user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) require.Zero(t, user.LastLoginUnix) - ctx, resp := contexttest.MockContext(t, "/", contexttest.MockContextOption{SessionStore: session.NewMockMemStore("reverse-proxy-last-login")}) + sess := &releaseCountingStore{Store: session.NewMockMemStore("reverse-proxy-last-login")} + ctx, resp := contexttest.MockContext(t, "/", contexttest.MockContextOption{SessionStore: sess}) ctx.Req.Header.Set(setting.ReverseProxyAuthUser, user.Name) rp := &ReverseProxy{CreateSession: true} _, err := rp.Verify(ctx.Req, resp, ctx, ctx.Session) require.NoError(t, err) assert.NotZero(t, unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: user.ID}).LastLoginUnix) + assert.Equal(t, 1, sess.released) user.LastLoginUnix = 1 require.NoError(t, user_model.UpdateUserCols(t.Context(), user, "last_login_unix")) @@ -55,4 +67,5 @@ func TestReverseProxyLastLogin(t *testing.T) { _, err = rp.Verify(ctx.Req, resp, ctx, ctx.Session) require.NoError(t, err) assert.EqualValues(t, 1, unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: user.ID}).LastLoginUnix) // no write without a new session + assert.Equal(t, 1, sess.released) } diff --git a/services/auth/source/oauth2/store.go b/services/auth/source/oauth2/store.go index 28a394a230b..4b17eb32b34 100644 --- a/services/auth/source/oauth2/store.go +++ b/services/auth/source/oauth2/store.go @@ -32,7 +32,6 @@ func (st *SessionsStore) New(r *http.Request, name string) (*sessions.Session, e return st.getOrNew(r, name, true) } -// getOrNew gets the session from the chi-session if it exists. Override permits the overriding of an unexpected object. func (st *SessionsStore) getOrNew(r *http.Request, name string, override bool) (*sessions.Session, error) { store := session_module.GetContextSession(r) @@ -55,7 +54,7 @@ func (st *SessionsStore) getOrNew(r *http.Request, name string, override bool) ( } session.IsNew = override - session.ID = store.ID() // Simply copy the session id from the chi store + session.ID = store.ID() return session, store.Set(name, session) } @@ -65,7 +64,7 @@ func (st *SessionsStore) Save(r *http.Request, w http.ResponseWriter, session *s store := session_module.GetContextSession(r) if session.IsNew { - _, _ = session_module.RegenerateSession(w, r) + store.Regenerate(w, r) session.IsNew = false } diff --git a/services/context/base.go b/services/context/base.go index 1f0663c7818..8d537b3c2e9 100644 --- a/services/context/base.go +++ b/services/context/base.go @@ -149,16 +149,6 @@ func (b *Base) PlainText(status int, text string) { // Redirect redirects the request func (b *Base) Redirect(location string, status ...int) { code := util.OptionalArg(status, http.StatusSeeOther) - - if !httplib.IsRelativeURL(location) { - // Some browsers (Safari) have buggy behavior for Cookie + Cache + External Redirection, eg: /my-path => https://other/path - // 1. the first request to "/my-path" contains cookie - // 2. some time later, the request to "/my-path" doesn't contain cookie (caused by Prevent web tracking) - // 3. Gitea's Sessioner doesn't see the session cookie, so it generates a new session id, and returns it to browser - // 4. then the browser accepts the empty session, then the user is logged out - // So in this case, we should remove the session cookie from the response header - removeSessionCookieHeader(b.Resp) - } // In case the request is made by "fetch-action" module, make JS redirect to the new location // Otherwise, the JS fetch will follow the redirection and read a "login" page, embed it to the current page, which is not expected. if httplib.IsGiteaFetchActionRequest(b.Req) { diff --git a/services/context/base_test.go b/services/context/base_test.go index 583e123888a..a4c8f24479f 100644 --- a/services/context/base_test.go +++ b/services/context/base_test.go @@ -21,27 +21,6 @@ func TestMain(m *testing.M) { func TestRedirect(t *testing.T) { req, _ := http.NewRequest(http.MethodGet, "/", nil) - - cases := []struct { - url string - keep bool - }{ - {"http://test", false}, - {"https://test", false}, - {"//test", false}, - {"/://test", true}, - {"/test", true}, - } - for _, c := range cases { - resp := httptest.NewRecorder() - b := NewBaseContextForTest(t, resp, req) - resp.Header().Add("Set-Cookie", (&http.Cookie{Name: setting.SessionConfig.CookieName, Value: "dummy"}).String()) - b.Redirect(c.url) - has := resp.Header().Get("Set-Cookie") == "i_like_gitea=dummy" - assert.Equal(t, c.keep, has, "url = %q", c.url) - } - - req, _ = http.NewRequest(http.MethodGet, "/", nil) resp := httptest.NewRecorder() req.Header.Add("X-Gitea-Fetch-Action", "1") b := NewBaseContextForTest(t, resp, req) diff --git a/services/context/context_cookie.go b/services/context/context_cookie.go index af1ebe91bac..8e1f1eab754 100644 --- a/services/context/context_cookie.go +++ b/services/context/context_cookie.go @@ -4,26 +4,11 @@ package context import ( - "net/http" - "strings" - - "gitea.dev/modules/setting" "gitea.dev/modules/web/middleware" ) const CookieNameFlash = "gitea_flash" -func removeSessionCookieHeader(w http.ResponseWriter) { - cookies := w.Header()["Set-Cookie"] - w.Header().Del("Set-Cookie") - for _, cookie := range cookies { - if strings.HasPrefix(cookie, setting.SessionConfig.CookieName+"=") { - continue - } - w.Header().Add("Set-Cookie", cookie) - } -} - // SetSiteCookie convenience function to set most cookies consistently func (ctx *Context) SetSiteCookie(name, value string, maxAge int) { middleware.SetSiteCookie(ctx.Resp, name, value, maxAge) diff --git a/services/context/context_test.go b/services/context/context_test.go index 8944f8c8195..1fb85ef1b41 100644 --- a/services/context/context_test.go +++ b/services/context/context_test.go @@ -18,16 +18,6 @@ import ( "github.com/stretchr/testify/assert" ) -func TestRemoveSessionCookieHeader(t *testing.T) { - w := httptest.NewRecorder() - w.Header().Add("Set-Cookie", (&http.Cookie{Name: setting.SessionConfig.CookieName, Value: "foo"}).String()) - w.Header().Add("Set-Cookie", (&http.Cookie{Name: "other", Value: "bar"}).String()) - assert.Len(t, w.Header().Values("Set-Cookie"), 2) - removeSessionCookieHeader(w) - assert.Len(t, w.Header().Values("Set-Cookie"), 1) - assert.Contains(t, "other=bar", w.Header().Get("Set-Cookie")) -} - func TestServerErrorFetchActionRespondsJSON(t *testing.T) { req, _ := http.NewRequest(http.MethodPost, "/", nil) req.Header.Add("X-Gitea-Fetch-Action", "1") diff --git a/services/contexttest/context_tests.go b/services/contexttest/context_tests.go index 41cf9eaaf8f..ab675dadeaf 100644 --- a/services/contexttest/context_tests.go +++ b/services/contexttest/context_tests.go @@ -70,7 +70,7 @@ func MockContext(t *testing.T, reqPath string, opts ...MockContextOption) (*cont ctx := context.NewWebContext(base, opt.Render, nil) ctx.SetContextValue(chi.RouteCtxKey, chiCtx) if opt.SessionStore != nil { - ctx.SetContextValue(session.MockStoreContextKey, opt.SessionStore) + ctx.SetContextValue(session.ContextKey, opt.SessionStore) ctx.Session = opt.SessionStore } ctx.Cache = cache.GetCache() diff --git a/tests/integration/create_no_session_test.go b/tests/integration/create_no_session_test.go index a03e7e4da5a..eb931b541ae 100644 --- a/tests/integration/create_no_session_test.go +++ b/tests/integration/create_no_session_test.go @@ -6,100 +6,52 @@ package integration import ( "net/http" "net/http/httptest" - "os" "path/filepath" "testing" - "gitea.dev/modules/json" "gitea.dev/modules/setting" "gitea.dev/modules/test" "gitea.dev/routers" "gitea.dev/tests" - "gitea.com/go-chi/session" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) -func getSessionID(t *testing.T, resp *httptest.ResponseRecorder) string { - cookies := resp.Result().Cookies() - found := false - sessionID := "" - for _, cookie := range cookies { +func getSessionID(resp *httptest.ResponseRecorder) string { + for _, cookie := range resp.Result().Cookies() { if cookie.Name == setting.SessionConfig.CookieName { - sessionID = cookie.Value - found = true + return cookie.Value } } - assert.True(t, found) - assert.NotEmpty(t, sessionID) - return sessionID -} - -func sessionFile(tmpDir, sessionID string) string { - return filepath.Join(tmpDir, sessionID[0:1], sessionID[1:2], sessionID) -} - -func sessionFileExist(t *testing.T, tmpDir, sessionID string) bool { - sessionFile := sessionFile(tmpDir, sessionID) - _, err := os.Lstat(sessionFile) - if err != nil { - if os.IsNotExist(err) { - return false - } - assert.NoError(t, err) - } - return true + return "" } func TestSessionFileCreation(t *testing.T) { defer tests.PrepareTestEnv(t)() - defer test.MockVariableValue(&setting.SessionConfig.ProviderConfig)() - defer test.MockVariableValue(&testWebRoutes)() - - var config session.Options - err := json.Unmarshal([]byte(setting.SessionConfig.ProviderConfig), &config) - assert.NoError(t, err) - - config.Provider = "file" - - // Now create a temporaryDirectory tmpDir := t.TempDir() - config.ProviderConfig = tmpDir - - newConfigBytes, err := json.Marshal(config) - assert.NoError(t, err) - - setting.SessionConfig.ProviderConfig = string(newConfigBytes) - - testWebRoutes = routers.NormalRoutes() + defer test.MockVariableValue(&setting.SessionConfig.Provider, "file")() + defer test.MockVariableValue(&setting.SessionConfig.ProviderConfig, tmpDir)() + defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())() t.Run("NoSessionOnViewIssue", func(t *testing.T) { defer tests.PrintCurrentTest(t)() - req := NewRequest(t, "GET", "/user2/repo1/issues/1") - resp := MakeRequest(t, req, http.StatusOK) - sessionID := getSessionID(t, resp) - - // We're not logged in so there should be no session - assert.False(t, sessionFileExist(t, tmpDir, sessionID)) + resp := MakeRequest(t, NewRequest(t, "GET", "/user2/repo1/issues/1"), http.StatusOK) + assert.Empty(t, getSessionID(resp)) }) t.Run("CreateSessionOnLogin", func(t *testing.T) { defer tests.PrintCurrentTest(t)() - req := NewRequest(t, "GET", "/user/login") - resp := MakeRequest(t, req, http.StatusOK) - sessionID := getSessionID(t, resp) + resp := MakeRequest(t, NewRequest(t, "GET", "/user/login"), http.StatusOK) + assert.Empty(t, getSessionID(resp)) - // We're not logged in so there should be no session - assert.False(t, sessionFileExist(t, tmpDir, sessionID)) - - req = NewRequestWithValues(t, "POST", "/user/login", map[string]string{ + req := NewRequestWithValues(t, "POST", "/user/login", map[string]string{ "user_name": "user2", "password": userPassword, }) - resp = MakeRequest(t, req, http.StatusSeeOther) - sessionID = getSessionID(t, resp) - - assert.FileExists(t, sessionFile(tmpDir, sessionID)) + sessionID := getSessionID(MakeRequest(t, req, http.StatusSeeOther)) + require.Len(t, sessionID, 16) + assert.FileExists(t, filepath.Join(tmpDir, sessionID[0:1], sessionID[1:2], sessionID)) }) } diff --git a/tests/integration/repo_test.go b/tests/integration/repo_test.go index 7bf6861727f..890fc3d8ea5 100644 --- a/tests/integration/repo_test.go +++ b/tests/integration/repo_test.go @@ -68,8 +68,7 @@ func testViewRepoPublic(t *testing.T) { req = NewRequest(t, "GET", "/org3/repo3") MakeRequest(t, req, http.StatusNotFound) - session = loginUser(t, "user1") - session.MakeRequest(t, req, http.StatusNotFound) + loginUser(t, "user1").MakeRequest(t, NewRequest(t, "GET", "/org3/repo3"), http.StatusOK) } func testViewRepoWithCache(t *testing.T) { diff --git a/tests/integration/session_test.go b/tests/integration/session_test.go index 7529eee469f..ba12caaa0e5 100644 --- a/tests/integration/session_test.go +++ b/tests/integration/session_test.go @@ -5,32 +5,31 @@ package integration import ( "testing" + "time" "gitea.dev/models/auth" - "gitea.dev/models/unittest" + "gitea.dev/modules/timeutil" "gitea.dev/tests" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) -func Test_RegenerateSession(t *testing.T) { - defer tests.PrepareTestEnv(t)() +func TestUpdateSession(t *testing.T) { + defer tests.PrintCurrentTest(t)() + defer timeutil.MockSet(time.Now())() + key := "0123456789abcdef" + for _, create := range []bool{true, true, false} { + require.NoError(t, auth.UpdateSession(t.Context(), key, []byte("data"), create)) + } + sess, exist, err := auth.GetSession(t.Context(), key) + require.NoError(t, err) + require.True(t, exist) + assert.Equal(t, []byte("data"), sess.Data) - assert.NoError(t, unittest.PrepareTestDatabase()) - - key := "new_key890123456" // it must be 16 characters long - key2 := "new_key890123457" // it must be 16 characters - exist, err := auth.ExistSession(t.Context(), key) - assert.NoError(t, err) + require.NoError(t, auth.DestroySession(t.Context(), key)) + require.NoError(t, auth.UpdateSession(t.Context(), key, []byte("data"), false)) + _, exist, err = auth.GetSession(t.Context(), key) + require.NoError(t, err) assert.False(t, exist) - - sess, err := auth.RegenerateSession(t.Context(), "", key) - assert.NoError(t, err) - assert.Equal(t, key, sess.Key) - assert.Empty(t, sess.Data) - - sess, err = auth.ReadSession(t.Context(), key2) - assert.NoError(t, err) - assert.Equal(t, key2, sess.Key) - assert.Empty(t, sess.Data) }